diff --git a/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/controller/admin/order/check/WmsCheckOrderController.java b/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/controller/admin/order/check/WmsCheckOrderController.java index d0044c6..dfc2c3b 100644 --- a/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/controller/admin/order/check/WmsCheckOrderController.java +++ b/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/controller/admin/order/check/WmsCheckOrderController.java @@ -160,7 +160,7 @@ public class WmsCheckOrderController { if (order == null) { return null; } - List list = buildCheckOrderRespVOList(List.of(order)); + List list = buildCheckOrderRespVOList(Collections.singletonList(order)); return CollUtil.getFirst(list); } diff --git a/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/controller/admin/order/movement/WmsMovementOrderController.java b/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/controller/admin/order/movement/WmsMovementOrderController.java index 6d2a17a..c00403e 100644 --- a/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/controller/admin/order/movement/WmsMovementOrderController.java +++ b/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/controller/admin/order/movement/WmsMovementOrderController.java @@ -160,7 +160,7 @@ public class WmsMovementOrderController { if (order == null) { return null; } - List list = buildMovementOrderRespVOList(List.of(order)); + List list = buildMovementOrderRespVOList(Collections.singletonList(order)); return CollUtil.getFirst(list); } diff --git a/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/controller/admin/order/receipt/WmsReceiptOrderController.java b/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/controller/admin/order/receipt/WmsReceiptOrderController.java index ade0757..3d97a40 100644 --- a/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/controller/admin/order/receipt/WmsReceiptOrderController.java +++ b/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/controller/admin/order/receipt/WmsReceiptOrderController.java @@ -163,7 +163,7 @@ public class WmsReceiptOrderController { if (order == null) { return null; } - List list = buildReceiptOrderRespVOList(List.of(order)); + List list = buildReceiptOrderRespVOList(Collections.singletonList(order)); return CollUtil.getFirst(list); } diff --git a/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/controller/admin/order/shipment/WmsShipmentOrderController.java b/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/controller/admin/order/shipment/WmsShipmentOrderController.java index 0bdbde8..5049744 100644 --- a/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/controller/admin/order/shipment/WmsShipmentOrderController.java +++ b/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/controller/admin/order/shipment/WmsShipmentOrderController.java @@ -163,7 +163,7 @@ public class WmsShipmentOrderController { if (order == null) { return null; } - List list = buildShipmentOrderRespVOList(List.of(order)); + List list = buildShipmentOrderRespVOList(Collections.singletonList(order)); return CollUtil.getFirst(list); } diff --git a/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/dal/mysql/inventory/WmsInventoryDetailMapper.java b/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/dal/mysql/inventory/WmsInventoryDetailMapper.java index 79399cf..9d10c57 100644 --- a/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/dal/mysql/inventory/WmsInventoryDetailMapper.java +++ b/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/dal/mysql/inventory/WmsInventoryDetailMapper.java @@ -47,7 +47,7 @@ public interface WmsInventoryDetailMapper extends BaseMapperX query, Integer daysToExpires) { + static void appendDaysToExpiresQuery(MPJLambdaWrapperX query, Integer daysToExpires) { if (daysToExpires == null) { return; } @@ -56,7 +56,7 @@ public interface WmsInventoryDetailMapper extends BaseMapperX query, String type) { + static void appendDimensionOrder(MPJLambdaWrapperX query, String type) { if (StrUtil.equals(WmsInventoryDetailPageReqVO.TYPE_WAREHOUSE, type)) { query.orderByAsc(WmsInventoryDetailDO::getWarehouseId) .orderByAsc(WmsInventoryDetailDO::getAreaId) diff --git a/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/dal/mysql/inventory/WmsInventoryMapper.java b/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/dal/mysql/inventory/WmsInventoryMapper.java index 39b10e3..3d49898 100644 --- a/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/dal/mysql/inventory/WmsInventoryMapper.java +++ b/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/dal/mysql/inventory/WmsInventoryMapper.java @@ -1,17 +1,20 @@ package cn.iocoder.yudao.module.wms.dal.mysql.inventory; +import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; import cn.iocoder.yudao.framework.mybatis.core.query.MPJLambdaWrapperX; import cn.iocoder.yudao.module.wms.controller.admin.inventory.vo.WmsInventoryPageReqVO; import cn.iocoder.yudao.module.wms.dal.dataobject.inventory.WmsInventoryDO; import cn.iocoder.yudao.module.wms.dal.dataobject.md.item.WmsItemDO; import cn.iocoder.yudao.module.wms.dal.dataobject.md.item.WmsItemSkuDO; -import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import org.apache.ibatis.annotations.Mapper; -import java.math.BigDecimal; +import java.util.Collection; +import java.util.Collections; +import java.util.List; /** * WMS 库存 Mapper @@ -72,27 +75,42 @@ public interface WmsInventoryMapper extends BaseMapperX { WmsInventoryDO::getAreaId, areaId); } - default WmsInventoryDO selectBySkuIdAndWarehouseIdAndAreaIdForUpdate(Long skuId, Long warehouseId, Long areaId) { - return selectOneForUpdate(WmsInventoryDO::getSkuId, skuId, - WmsInventoryDO::getWarehouseId, warehouseId, - WmsInventoryDO::getAreaId, areaId); - } - /** - * 增量更新库存数量 + * 根据多个唯一键,批量查询库存列表 * - * @param id 库存编号 - * @param quantity 变更数量 - * @return 更新行数 + * @param keys 唯一键列表:由 SKU 编号 + 仓库编号 + 库区编号组成 + * @return 库存列表 */ - @SuppressWarnings("UnusedReturnValue") - default int updateQuantity(Long id, BigDecimal quantity) { - return update(null, new LambdaUpdateWrapper() - .eq(WmsInventoryDO::getId, id) - .setSql("quantity = quantity + (" + quantity + ")")); + default List selectListByKeys(Collection keys) { + if (CollUtil.isEmpty(keys)) { + return Collections.emptyList(); + } + return selectList(new LambdaQueryWrapperX() + .and(query -> { + boolean first = true; + for (WmsInventoryDO key : keys) { + if (!first) { + query.or(); + } + query.eq(WmsInventoryDO::getSkuId, key.getSkuId()) + .eq(WmsInventoryDO::getWarehouseId, key.getWarehouseId()) + .eq(WmsInventoryDO::getAreaId, key.getAreaId()); + first = false; + } + })); + } + + default List selectListByIdsForUpdate(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return selectList(new LambdaQueryWrapperX() + .in(WmsInventoryDO::getId, ids) + .orderByAsc(WmsInventoryDO::getId) + .last("FOR UPDATE")); } - private static void appendDimensionOrder(MPJLambdaWrapperX query, String type) { + static void appendDimensionOrder(MPJLambdaWrapperX query, String type) { if (StrUtil.equals(WmsInventoryPageReqVO.TYPE_WAREHOUSE, type)) { query.orderByAsc(WmsInventoryDO::getWarehouseId) .orderByAsc(WmsItemSkuDO::getItemId) diff --git a/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/enums/ErrorCodeConstants.java b/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/enums/ErrorCodeConstants.java index 9673986..980c6ad 100644 --- a/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/enums/ErrorCodeConstants.java +++ b/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/enums/ErrorCodeConstants.java @@ -92,7 +92,8 @@ public interface ErrorCodeConstants { ErrorCode CHECK_ORDER_DETAIL_NOT_EXISTS = new ErrorCode(1_060_203_006, "盘库单明细不存在"); // ========== WMS 库存 1-060-300-000 ========== - ErrorCode INVENTORY_QUANTITY_NOT_ENOUGH = new ErrorCode(1_060_300_000, "库存不足,当前库存:{},变更数量:{}"); + ErrorCode INVENTORY_QUANTITY_NOT_ENOUGH = new ErrorCode(1_060_300_000, + "库存不足,商品:{},商品规格:{},仓库:{},库区:{},当前库存:{},变更数量:{}"); ErrorCode INVENTORY_DETAIL_NOT_EXISTS = new ErrorCode(1_060_300_001, "库存明细不存在"); ErrorCode INVENTORY_DETAIL_REMAIN_QUANTITY_NOT_ENOUGH = new ErrorCode(1_060_300_002, "库存明细剩余数量不足,当前剩余:{},出库数量:{}"); diff --git a/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/service/inventory/WmsInventoryDetailServiceImpl.java b/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/service/inventory/WmsInventoryDetailServiceImpl.java index 6ee6d21..519c1a0 100644 --- a/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/service/inventory/WmsInventoryDetailServiceImpl.java +++ b/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/service/inventory/WmsInventoryDetailServiceImpl.java @@ -17,6 +17,7 @@ import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; @@ -50,6 +51,8 @@ public class WmsInventoryDetailServiceImpl implements WmsInventoryDetailService inventoryDetailMapper.insertBatch(list); } + // DONE @AI:加锁,也按照 inventory 做法;一批直接加锁;避免按照顺序,多个明细交叉拿锁,导致死锁 + // 批量扣减库存明细:先一次性锁定相关明细,再在内存合并扣减数量后统一校验与更新。 @Override @Transactional(rollbackFor = Exception.class) public void decreaseInventoryDetailList(List items) { @@ -61,9 +64,9 @@ public class WmsInventoryDetailServiceImpl implements WmsInventoryDetailService } List sortedItems = items.stream() .sorted(Comparator.comparing(WmsInventoryChangeReqDTO.Item::getInventoryDetailId)) - .toList(); + .collect(Collectors.toList()); - // 1. 按库存明细编号固定顺序加锁,避免多明细并发扣减时交叉拿锁 + // 1. 批量锁定本次涉及的库存明细行,避免逐条加锁时不同事务交叉等待 Map detailMap = convertMap(inventoryDetailMapper.selectListByIdsForUpdate( convertSet(sortedItems, WmsInventoryChangeReqDTO.Item::getInventoryDetailId)), WmsInventoryDetailDO::getId); diff --git a/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/service/inventory/WmsInventoryServiceImpl.java b/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/service/inventory/WmsInventoryServiceImpl.java index 81f9a3f..1c63aa6 100644 --- a/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/service/inventory/WmsInventoryServiceImpl.java +++ b/yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/service/inventory/WmsInventoryServiceImpl.java @@ -2,16 +2,23 @@ package cn.iocoder.yudao.module.wms.service.inventory; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.lang.Tuple; +import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.module.wms.controller.admin.inventory.vo.WmsInventoryPageReqVO; import cn.iocoder.yudao.module.wms.dal.dataobject.inventory.WmsInventoryDetailDO; import cn.iocoder.yudao.module.wms.dal.dataobject.inventory.WmsInventoryDO; import cn.iocoder.yudao.module.wms.dal.dataobject.inventory.WmsInventoryHistoryDO; +import cn.iocoder.yudao.module.wms.dal.dataobject.md.item.WmsItemDO; +import cn.iocoder.yudao.module.wms.dal.dataobject.md.item.WmsItemSkuDO; import cn.iocoder.yudao.module.wms.dal.mysql.inventory.WmsInventoryMapper; import cn.iocoder.yudao.module.wms.framework.config.WmsProperties; import cn.iocoder.yudao.module.wms.service.inventory.dto.WmsInventoryChangeReqDTO; +import cn.iocoder.yudao.module.wms.service.md.item.WmsItemService; +import cn.iocoder.yudao.module.wms.service.md.item.WmsItemSkuService; import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,12 +26,14 @@ import org.springframework.validation.annotation.Validated; import java.math.BigDecimal; import java.util.ArrayList; -import java.util.Comparator; +import java.util.Collections; import java.util.IdentityHashMap; import java.util.List; import java.util.Map; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; import static cn.iocoder.yudao.module.wms.enums.ErrorCodeConstants.*; /** @@ -34,6 +43,7 @@ import static cn.iocoder.yudao.module.wms.enums.ErrorCodeConstants.*; */ @Service @Validated +@Slf4j public class WmsInventoryServiceImpl implements WmsInventoryService { @Resource @@ -45,6 +55,11 @@ public class WmsInventoryServiceImpl implements WmsInventoryService { private WmsInventoryHistoryService inventoryHistoryService; @Resource + private WmsItemSkuService itemSkuService; + @Resource + private WmsItemService itemService; + + @Resource private WmsProperties wmsProperties; @Override @@ -67,95 +82,135 @@ public class WmsInventoryServiceImpl implements WmsInventoryService { return; } - // 1. 按固定顺序变更库存余额,避免多 SKU/仓库并发操作时交叉拿锁 + // 1. 补齐并锁定本次涉及的库存余额行,再计算库存变更 Map resultMap = changeInventoryList(reqDTO.getItems()); - // 2. 构建库存流水与批次库存明细 + // 2.1 构建库存流水与批次库存明细 boolean batchEnabled = wmsProperties.isBatchEnabled(); List histories = new ArrayList<>(reqDTO.getItems().size()); - List details = batchEnabled ? new ArrayList<>(reqDTO.getItems().size()) : List.of(); - List decreaseItems = batchEnabled ? new ArrayList<>(reqDTO.getItems().size()) : List.of(); + List increaseDetails = batchEnabled ? new ArrayList<>(reqDTO.getItems().size()) : Collections.emptyList(); + List decreaseItems = batchEnabled ? new ArrayList<>(reqDTO.getItems().size()) : Collections.emptyList(); for (WmsInventoryChangeReqDTO.Item item : reqDTO.getItems()) { histories.add(buildInventoryHistory(reqDTO, item, resultMap.get(item))); - if (batchEnabled) { - if (isIncrease(item)) { - details.add(buildInventoryDetail(reqDTO, item)); - } else { - decreaseItems.add(item); - } + if (!batchEnabled) { + continue; + } + if (item.getQuantity().compareTo(BigDecimal.ZERO) > 0) { + increaseDetails.add(buildInventoryDetail(reqDTO, item)); + } else { + decreaseItems.add(item); } } - // 3. 按固定顺序变更批次库存明细 + // 2.2.1 扣减已有批次库存明细,再写入新增批次库存明细 if (batchEnabled) { inventoryDetailService.decreaseInventoryDetailList(decreaseItems); - inventoryDetailService.createInventoryDetailList(details); + inventoryDetailService.createInventoryDetailList(increaseDetails); } - - // 4. 批量写入库存流水 + // 2.2.2 批量写入库存流水 inventoryHistoryService.createInventoryHistoryList(histories); } /** * 批量变更库存余额 * - * 按 SKU、仓库、库区固定顺序拿锁;相同库存余额只更新一次,避免同一批操作内反复更新。 + * 1. 库存行按 ID 批量加锁 + * 2. 库存变更先在内存计算并校验,全部通过后批量覆盖库存数量。 * * @param items 库存变更明细列表 * @return 每条变更明细对应的变更前数量、变更后数量 */ private Map changeInventoryList(List items) { - List sortedItems = sortByInventoryKey(items); + // 1.1 创建或锁定库存行 + List inventories = getOrCreateInventoryList(items); + // 1.2 锁定库存:避免 quantity 变更时的并发问题,导致 quantity 前后计算不对 + inventories = inventoryMapper.selectListByIdsForUpdate(convertSet(inventories, WmsInventoryDO::getId)); + + // 2.1 校验库存充足 Map resultMap = new IdentityHashMap<>(items.size()); - for (int i = 0; i < sortedItems.size(); ) { - WmsInventoryChangeReqDTO.Item firstItem = sortedItems.get(i); - WmsInventoryDO inventory = getOrCreateInventory(firstItem); - BigDecimal currentQuantity = inventory.getQuantity(); - BigDecimal totalChangeQuantity = BigDecimal.ZERO; - - int j = i; - while (j < sortedItems.size() && isSameInventoryKey(firstItem, sortedItems.get(j))) { - WmsInventoryChangeReqDTO.Item item = sortedItems.get(j); - BigDecimal beforeQuantity = currentQuantity; - currentQuantity = currentQuantity.add(item.getQuantity()); - if (currentQuantity.compareTo(BigDecimal.ZERO) < 0) { - throw exception(INVENTORY_QUANTITY_NOT_ENOUGH, beforeQuantity, item.getQuantity()); - } - resultMap.put(item, new Tuple(beforeQuantity, currentQuantity)); - totalChangeQuantity = totalChangeQuantity.add(item.getQuantity()); - j++; + for (WmsInventoryChangeReqDTO.Item item : items) { + WmsInventoryDO inventory = findInventory(inventories, item); + if (inventory == null) { + throw new IllegalStateException("库存行不存在,skuId=" + item.getSkuId() + + ", warehouseId=" + item.getWarehouseId() + ", areaId=" + item.getAreaId()); } - - if (totalChangeQuantity.compareTo(BigDecimal.ZERO) != 0) { - inventoryMapper.updateQuantity(inventory.getId(), totalChangeQuantity); + BigDecimal beforeQuantity = inventory.getQuantity(); + BigDecimal afterQuantity = beforeQuantity.add(item.getQuantity()); + if (afterQuantity.compareTo(BigDecimal.ZERO) < 0) { + throw buildInventoryQuantityNotEnoughException(item, beforeQuantity); } - i = j; + inventory.setQuantity(afterQuantity); + resultMap.put(item, new Tuple(beforeQuantity, afterQuantity)); + } + // 2.2 批量更新库存数量(加锁安全) + if (CollUtil.isNotEmpty(inventories)) { + inventoryMapper.updateBatch(convertList(inventories, inventory -> + new WmsInventoryDO().setId(inventory.getId()).setQuantity(inventory.getQuantity()))); } return resultMap; } - private WmsInventoryDO getOrCreateInventory(WmsInventoryChangeReqDTO.Item item) { - // 1. 查询库存余额,使用 FOR UPDATE 锁定行 - WmsInventoryDO inventory = inventoryMapper.selectBySkuIdAndWarehouseIdAndAreaIdForUpdate(item.getSkuId(), - item.getWarehouseId(), item.getAreaId()); - if (inventory != null) { - return inventory; + private List getOrCreateInventoryList(List items) { + // 先按库存维度在内存去重,避免同一批明细重复查询或重复补行。 + List inventoryKeyList = new ArrayList<>(items.size()); + for (WmsInventoryChangeReqDTO.Item item : items) { + if (findInventory(inventoryKeyList, item) == null) { + inventoryKeyList.add(new WmsInventoryDO().setSkuId(item.getSkuId()) + .setWarehouseId(item.getWarehouseId()).setAreaId(item.getAreaId())); + } + } + + // 批量查询已存在的库存行;这里不加锁,后续统一按库存 ID 批量加锁。 + List inventories = inventoryMapper.selectListByKeys(inventoryKeyList); + + // 对比库存维度,找出数据库中还不存在的库存行,再按唯一索引兜底补齐。 + List missingInventoryList = new ArrayList<>(inventoryKeyList.size()); + for (WmsInventoryDO inventoryKey : inventoryKeyList) { + if (findInventory(inventories, inventoryKey) == null) { + missingInventoryList.add(inventoryKey); + } } - // 2.1 不存在,则插入一条库存余额记录,初始数量为 0 + if (CollUtil.isEmpty(missingInventoryList)) { + return inventories; + } + + inventories.addAll(createMissingInventoryList(missingInventoryList)); + return inventories; + } + + private List createMissingInventoryList(List missingInventoryList) { + // 优先批量插入缺失库存行;并发唯一键冲突时,再逐个插入并回查冲突行。 + List newInventoryList = convertList(missingInventoryList, missingInventory -> + new WmsInventoryDO().setSkuId(missingInventory.getSkuId()).setWarehouseId(missingInventory.getWarehouseId()) + .setAreaId(missingInventory.getAreaId()).setQuantity(BigDecimal.ZERO)); try { - inventory = new WmsInventoryDO().setSkuId(item.getSkuId()) - .setWarehouseId(item.getWarehouseId()).setAreaId(item.getAreaId()).setQuantity(BigDecimal.ZERO); - inventoryMapper.insert(inventory); - return inventory; + inventoryMapper.insertBatch(newInventoryList); + return newInventoryList; } catch (DuplicateKeyException ex) { - // 2.2 插入失败,说明有并发冲突,重试查询库存余额 - inventory = inventoryMapper.selectBySkuIdAndWarehouseIdAndAreaIdForUpdate(item.getSkuId(), - item.getWarehouseId(), item.getAreaId()); - if (inventory == null) { - throw ex; + // 并发事务可能已经补齐部分库存行,降级为逐个插入并回查唯一键冲突的行。 + log.warn("[createMissingInventoryList][missingInventoryList({}) 批量插入库存行冲突,降级为逐个插入]", + missingInventoryList, ex); + } + + // 批量插入失败后逐条补齐;单条唯一键冲突时,回查并使用并发事务创建的库存行。 + List resultList = new ArrayList<>(missingInventoryList.size()); + for (WmsInventoryDO missingInventory : missingInventoryList) { + WmsInventoryDO inventory = new WmsInventoryDO().setSkuId(missingInventory.getSkuId()) + .setWarehouseId(missingInventory.getWarehouseId()).setAreaId(missingInventory.getAreaId()) + .setQuantity(BigDecimal.ZERO); + try { + inventoryMapper.insert(inventory); + } catch (DuplicateKeyException ex) { + inventory = inventoryMapper.selectBySkuIdAndWarehouseIdAndAreaId( + missingInventory.getSkuId(), missingInventory.getWarehouseId(), missingInventory.getAreaId()); + log.warn("[createMissingInventoryList][missingInventory({}) 插入库存行冲突,回查已有库存行]", missingInventory, ex); + if (inventory == null) { + throw ex; + } } - return inventory; + resultList.add(inventory); } + return resultList; } private WmsInventoryHistoryDO buildInventoryHistory(WmsInventoryChangeReqDTO reqDTO, @@ -181,23 +236,32 @@ public class WmsInventoryServiceImpl implements WmsInventoryService { .setOrderId(reqDTO.getOrderId()).setOrderNo(reqDTO.getOrderNo()).setOrderType(reqDTO.getOrderType()); } - private boolean isIncrease(WmsInventoryChangeReqDTO.Item item) { - return item.getQuantity().compareTo(BigDecimal.ZERO) > 0; + private static WmsInventoryDO findInventory(List inventories, WmsInventoryChangeReqDTO.Item item) { + return CollUtil.findOne(inventories, inventory -> isSameInventory(inventory, item)); + } + + private static WmsInventoryDO findInventory(List inventories, WmsInventoryDO key) { + return CollUtil.findOne(inventories, inventory -> isSameInventory(inventory, key)); + } + + private static boolean isSameInventory(WmsInventoryDO inventory, WmsInventoryChangeReqDTO.Item item) { + return ObjectUtil.equal(inventory.getSkuId(), item.getSkuId()) + && ObjectUtil.equal(inventory.getWarehouseId(), item.getWarehouseId()) + && ObjectUtil.equal(inventory.getAreaId(), item.getAreaId()); } - private static List sortByInventoryKey(List items) { - return items.stream() - .sorted(Comparator.comparing(WmsInventoryChangeReqDTO.Item::getSkuId) - .thenComparing(WmsInventoryChangeReqDTO.Item::getWarehouseId) - .thenComparing(WmsInventoryChangeReqDTO.Item::getAreaId)) - .toList(); + private static boolean isSameInventory(WmsInventoryDO inventory, WmsInventoryDO key) { + return ObjectUtil.equal(inventory.getSkuId(), key.getSkuId()) + && ObjectUtil.equal(inventory.getWarehouseId(), key.getWarehouseId()) + && ObjectUtil.equal(inventory.getAreaId(), key.getAreaId()); } - private static boolean isSameInventoryKey(WmsInventoryChangeReqDTO.Item item1, - WmsInventoryChangeReqDTO.Item item2) { - return item1.getSkuId().equals(item2.getSkuId()) - && item1.getWarehouseId().equals(item2.getWarehouseId()) - && item1.getAreaId().equals(item2.getAreaId()); + private ServiceException buildInventoryQuantityNotEnoughException(WmsInventoryChangeReqDTO.Item item, + BigDecimal beforeQuantity) { + WmsItemSkuDO skuDO = itemSkuService.validateItemSkuExists(item.getSkuId()); + WmsItemDO itemDO = itemService.validateItemExists(skuDO.getItemId()); + return exception(INVENTORY_QUANTITY_NOT_ENOUGH, itemDO.getName(), skuDO.getName(), + item.getWarehouseId(), item.getAreaId(), beforeQuantity, item.getQuantity()); } } diff --git a/yudao-module-wms/src/test/java/cn/iocoder/yudao/module/wms/service/inventory/WmsInventoryServiceImplTest.java b/yudao-module-wms/src/test/java/cn/iocoder/yudao/module/wms/service/inventory/WmsInventoryServiceImplTest.java index ed0c3de..d263516 100644 --- a/yudao-module-wms/src/test/java/cn/iocoder/yudao/module/wms/service/inventory/WmsInventoryServiceImplTest.java +++ b/yudao-module-wms/src/test/java/cn/iocoder/yudao/module/wms/service/inventory/WmsInventoryServiceImplTest.java @@ -16,12 +16,17 @@ import cn.iocoder.yudao.module.wms.dal.mysql.md.item.WmsItemSkuMapper; import cn.iocoder.yudao.module.wms.enums.inventory.WmsInventoryOrderTypeEnum; import cn.iocoder.yudao.module.wms.framework.config.WmsProperties; import cn.iocoder.yudao.module.wms.service.inventory.dto.WmsInventoryChangeReqDTO; +import cn.iocoder.yudao.module.wms.service.md.item.WmsItemService; +import cn.iocoder.yudao.module.wms.service.md.item.WmsItemSkuService; import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.math.BigDecimal; import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; import java.util.List; import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; @@ -29,6 +34,7 @@ import static cn.iocoder.yudao.module.wms.enums.ErrorCodeConstants.INVENTORY_DET import static cn.iocoder.yudao.module.wms.enums.ErrorCodeConstants.INVENTORY_QUANTITY_NOT_ENOUGH; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.when; @Import({WmsInventoryServiceImpl.class, WmsInventoryDetailServiceImpl.class, WmsInventoryHistoryServiceImpl.class, WmsProperties.class}) @@ -50,6 +56,11 @@ public class WmsInventoryServiceImplTest extends BaseDbUnitTest { @Resource private WmsProperties wmsProperties; + @MockitoBean + private WmsItemSkuService itemSkuService; + @MockitoBean + private WmsItemService itemService; + @Test public void testChangeInventory_createInventory() { // mock 数据 @@ -105,6 +116,128 @@ public class WmsInventoryServiceImplTest extends BaseDbUnitTest { } @Test + public void testChangeInventory_sameInventoryKeyKeepItemGranularity() { + // mock 数据 + wmsProperties.setBatchEnable(false); + WmsItemDO item = createItem("ITEM-001", "红富士苹果"); + WmsItemSkuDO sku = createSku(item.getId(), "SKU-001", "10kg 箱装"); + WmsInventoryChangeReqDTO reqDTO = createChangeReq(sku.getId(), 100L, 1001L, "5.00"); + List items = new ArrayList<>(reqDTO.getItems()); + items.add(new WmsInventoryChangeReqDTO.Item() + .setSkuId(sku.getId()) + .setWarehouseId(100L) + .setAreaId(1001L) + .setQuantity(new BigDecimal("7.00")) + .setAmount(new BigDecimal("140.00")) + .setRemark("测试入库 2")); + reqDTO.setItems(items); + + // 调用 + inventoryService.changeInventory(reqDTO); + + // 断言:库存余额只保留一条,数量逐条累加 + WmsInventoryDO inventory = inventoryMapper.selectBySkuIdAndWarehouseIdAndAreaId(sku.getId(), 100L, 1001L); + assertNotNull(inventory); + assertEquals(0, new BigDecimal("12.00").compareTo(inventory.getQuantity())); + // 断言:库存流水保持用户明细颗粒度 + List histories = inventoryHistoryMapper.selectList(); + histories.sort(Comparator.comparing(WmsInventoryHistoryDO::getId)); + assertEquals(2, histories.size()); + assertEquals(0, BigDecimal.ZERO.compareTo(histories.get(0).getBeforeQuantity())); + assertEquals(0, new BigDecimal("5.00").compareTo(histories.get(0).getAfterQuantity())); + assertEquals(0, new BigDecimal("5.00").compareTo(histories.get(0).getQuantity())); + assertEquals(0, new BigDecimal("5.00").compareTo(histories.get(1).getBeforeQuantity())); + assertEquals(0, new BigDecimal("12.00").compareTo(histories.get(1).getAfterQuantity())); + assertEquals(0, new BigDecimal("7.00").compareTo(histories.get(1).getQuantity())); + } + + @Test + public void testChangeInventory_sameExistingInventoryKeyKeepItemGranularity() { + // mock 数据 + wmsProperties.setBatchEnable(false); + WmsItemDO item = createItem("ITEM-001", "红富士苹果"); + WmsItemSkuDO sku = createSku(item.getId(), "SKU-001", "10kg 箱装"); + inventoryMapper.insert(createInventory(sku.getId(), 100L, 1001L, "2.00")); + WmsInventoryChangeReqDTO reqDTO = createChangeReq(sku.getId(), 100L, 1001L, "3.00"); + List items = new ArrayList<>(reqDTO.getItems()); + items.add(new WmsInventoryChangeReqDTO.Item() + .setSkuId(sku.getId()) + .setWarehouseId(100L) + .setAreaId(1001L) + .setQuantity(new BigDecimal("4.00")) + .setAmount(new BigDecimal("80.00")) + .setRemark("测试入库 2")); + reqDTO.setItems(items); + + // 调用 + inventoryService.changeInventory(reqDTO); + + // 断言:库存余额只保留一条,数量逐条累加 + WmsInventoryDO inventory = inventoryMapper.selectBySkuIdAndWarehouseIdAndAreaId(sku.getId(), 100L, 1001L); + assertNotNull(inventory); + assertEquals(0, new BigDecimal("9.00").compareTo(inventory.getQuantity())); + // 断言:库存流水保持用户明细颗粒度 + List histories = inventoryHistoryMapper.selectList(); + histories.sort(Comparator.comparing(WmsInventoryHistoryDO::getId)); + assertEquals(2, histories.size()); + assertEquals(0, new BigDecimal("2.00").compareTo(histories.get(0).getBeforeQuantity())); + assertEquals(0, new BigDecimal("5.00").compareTo(histories.get(0).getAfterQuantity())); + assertEquals(0, new BigDecimal("3.00").compareTo(histories.get(0).getQuantity())); + assertEquals(0, new BigDecimal("5.00").compareTo(histories.get(1).getBeforeQuantity())); + assertEquals(0, new BigDecimal("9.00").compareTo(histories.get(1).getAfterQuantity())); + assertEquals(0, new BigDecimal("4.00").compareTo(histories.get(1).getQuantity())); + } + + @Test + public void testChangeInventory_multipleInventoryKeysKeepItemGranularity() { + // mock 数据 + wmsProperties.setBatchEnable(false); + WmsItemDO item = createItem("ITEM-001", "红富士苹果"); + WmsItemSkuDO sku = createSku(item.getId(), "SKU-001", "10kg 箱装"); + WmsInventoryChangeReqDTO reqDTO = createChangeReq(sku.getId(), 100L, 1001L, "5.00"); + List items = new ArrayList<>(reqDTO.getItems()); + items.add(new WmsInventoryChangeReqDTO.Item() + .setSkuId(sku.getId()) + .setWarehouseId(200L) + .setAreaId(2001L) + .setQuantity(new BigDecimal("3.00")) + .setAmount(new BigDecimal("60.00")) + .setRemark("测试入库 2")); + items.add(new WmsInventoryChangeReqDTO.Item() + .setSkuId(sku.getId()) + .setWarehouseId(100L) + .setAreaId(1001L) + .setQuantity(new BigDecimal("-2.00")) + .setAmount(new BigDecimal("40.00")) + .setRemark("测试出库")); + reqDTO.setItems(items); + + // 调用 + inventoryService.changeInventory(reqDTO); + + // 断言:不同库存余额分别更新 + WmsInventoryDO inventory1 = inventoryMapper.selectBySkuIdAndWarehouseIdAndAreaId(sku.getId(), 100L, 1001L); + assertNotNull(inventory1); + assertEquals(0, new BigDecimal("3.00").compareTo(inventory1.getQuantity())); + WmsInventoryDO inventory2 = inventoryMapper.selectBySkuIdAndWarehouseIdAndAreaId(sku.getId(), 200L, 2001L); + assertNotNull(inventory2); + assertEquals(0, new BigDecimal("3.00").compareTo(inventory2.getQuantity())); + // 断言:批量加锁后仍按明细颗粒度记录 before/after + List histories = inventoryHistoryMapper.selectList(); + histories.sort(Comparator.comparing(WmsInventoryHistoryDO::getId)); + assertEquals(3, histories.size()); + assertEquals(100L, histories.get(0).getWarehouseId()); + assertEquals(0, BigDecimal.ZERO.compareTo(histories.get(0).getBeforeQuantity())); + assertEquals(0, new BigDecimal("5.00").compareTo(histories.get(0).getAfterQuantity())); + assertEquals(200L, histories.get(1).getWarehouseId()); + assertEquals(0, BigDecimal.ZERO.compareTo(histories.get(1).getBeforeQuantity())); + assertEquals(0, new BigDecimal("3.00").compareTo(histories.get(1).getAfterQuantity())); + assertEquals(100L, histories.get(2).getWarehouseId()); + assertEquals(0, new BigDecimal("5.00").compareTo(histories.get(2).getBeforeQuantity())); + assertEquals(0, new BigDecimal("3.00").compareTo(histories.get(2).getAfterQuantity())); + } + + @Test public void testChangeInventory_quantityNotEnough() { // mock 数据 wmsProperties.setBatchEnable(false); @@ -112,10 +245,26 @@ public class WmsInventoryServiceImplTest extends BaseDbUnitTest { WmsItemSkuDO sku = createSku(item.getId(), "SKU-001", "10kg 箱装"); inventoryMapper.insert(createInventory(sku.getId(), 100L, 1001L, "2.00")); WmsInventoryChangeReqDTO reqDTO = createChangeReq(sku.getId(), 100L, 1001L, "-3.00"); + mockItemSkuAndItem(item, sku); // 调用,并断言 assertServiceException(() -> inventoryService.changeInventory(reqDTO), INVENTORY_QUANTITY_NOT_ENOUGH, - new BigDecimal("2.000000"), new BigDecimal("-3.00")); + item.getName(), sku.getName(), 100L, 1001L, new BigDecimal("2.000000"), new BigDecimal("-3.00")); + } + + @Test + public void testChangeInventory_quantityNotEnoughWithoutInventory() { + // mock 数据 + wmsProperties.setBatchEnable(false); + WmsItemDO item = createItem("ITEM-001", "红富士苹果"); + WmsItemSkuDO sku = createSku(item.getId(), "SKU-001", "10kg 箱装"); + WmsInventoryChangeReqDTO reqDTO = createChangeReq(sku.getId(), 100L, 1001L, "-3.00"); + mockItemSkuAndItem(item, sku); + + // 调用,并断言 + assertServiceException(() -> inventoryService.changeInventory(reqDTO), INVENTORY_QUANTITY_NOT_ENOUGH, + item.getName(), sku.getName(), 100L, 1001L, BigDecimal.ZERO.setScale(6), new BigDecimal("-3.00")); + assertEquals(0, inventoryMapper.selectCount()); } @Test @@ -256,6 +405,11 @@ public class WmsInventoryServiceImplTest extends BaseDbUnitTest { return sku; } + private void mockItemSkuAndItem(WmsItemDO item, WmsItemSkuDO sku) { + when(itemSkuService.validateItemSkuExists(sku.getId())).thenReturn(sku); + when(itemService.validateItemExists(item.getId())).thenReturn(item); + } + private static WmsInventoryDO createInventory(Long skuId, Long warehouseId, Long areaId, String quantity) { return WmsInventoryDO.builder() .skuId(skuId) @@ -281,7 +435,7 @@ public class WmsInventoryServiceImplTest extends BaseDbUnitTest { .setOrderId(1L) .setOrderNo("RK202605120001") .setOrderType(WmsInventoryOrderTypeEnum.RECEIPT.getType()) - .setItems(List.of(new WmsInventoryChangeReqDTO.Item() + .setItems(Collections.singletonList(new WmsInventoryChangeReqDTO.Item() .setSkuId(skuId) .setWarehouseId(warehouseId) .setAreaId(areaId) diff --git a/yudao-module-wms/src/test/java/cn/iocoder/yudao/module/wms/service/order/receipt/WmsReceiptOrderDetailServiceImplTest.java b/yudao-module-wms/src/test/java/cn/iocoder/yudao/module/wms/service/order/receipt/WmsReceiptOrderDetailServiceImplTest.java index 33ac70b..71325d3 100644 --- a/yudao-module-wms/src/test/java/cn/iocoder/yudao/module/wms/service/order/receipt/WmsReceiptOrderDetailServiceImplTest.java +++ b/yudao-module-wms/src/test/java/cn/iocoder/yudao/module/wms/service/order/receipt/WmsReceiptOrderDetailServiceImplTest.java @@ -16,6 +16,7 @@ import org.springframework.context.annotation.Import; import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.math.BigDecimal; +import java.util.Arrays; import java.util.List; import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; @@ -129,7 +130,7 @@ public class WmsReceiptOrderDetailServiceImplTest extends BaseDbUnitTest { WmsReceiptOrderSaveReqVO reqVO = new WmsReceiptOrderSaveReqVO(); reqVO.setWarehouseId(100L); reqVO.setAreaId(0L); - reqVO.setDetails(List.of(details)); + reqVO.setDetails(Arrays.asList(details)); return reqVO; }