Commit 45dcdb1105df17efb44d21685f25c41c0e12d4eb

Authored by YunaiV
1 parent e838637f

feat(wms):优化 inventory 的库存加锁,防止并发问题

yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/controller/admin/order/check/WmsCheckOrderController.java
... ... @@ -160,7 +160,7 @@ public class WmsCheckOrderController {
160 160 if (order == null) {
161 161 return null;
162 162 }
163   - List<WmsCheckOrderRespVO> list = buildCheckOrderRespVOList(List.of(order));
  163 + List<WmsCheckOrderRespVO> list = buildCheckOrderRespVOList(Collections.singletonList(order));
164 164 return CollUtil.getFirst(list);
165 165 }
166 166  
... ...
yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/controller/admin/order/movement/WmsMovementOrderController.java
... ... @@ -160,7 +160,7 @@ public class WmsMovementOrderController {
160 160 if (order == null) {
161 161 return null;
162 162 }
163   - List<WmsMovementOrderRespVO> list = buildMovementOrderRespVOList(List.of(order));
  163 + List<WmsMovementOrderRespVO> list = buildMovementOrderRespVOList(Collections.singletonList(order));
164 164 return CollUtil.getFirst(list);
165 165 }
166 166  
... ...
yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/controller/admin/order/receipt/WmsReceiptOrderController.java
... ... @@ -163,7 +163,7 @@ public class WmsReceiptOrderController {
163 163 if (order == null) {
164 164 return null;
165 165 }
166   - List<WmsReceiptOrderRespVO> list = buildReceiptOrderRespVOList(List.of(order));
  166 + List<WmsReceiptOrderRespVO> list = buildReceiptOrderRespVOList(Collections.singletonList(order));
167 167 return CollUtil.getFirst(list);
168 168 }
169 169  
... ...
yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/controller/admin/order/shipment/WmsShipmentOrderController.java
... ... @@ -163,7 +163,7 @@ public class WmsShipmentOrderController {
163 163 if (order == null) {
164 164 return null;
165 165 }
166   - List<WmsShipmentOrderRespVO> list = buildShipmentOrderRespVOList(List.of(order));
  166 + List<WmsShipmentOrderRespVO> list = buildShipmentOrderRespVOList(Collections.singletonList(order));
167 167 return CollUtil.getFirst(list);
168 168 }
169 169  
... ...
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&lt;WmsInventoryDetail
47 47 return selectJoinPage(reqVO, WmsInventoryDetailDO.class, query);
48 48 }
49 49  
50   - private static void appendDaysToExpiresQuery(MPJLambdaWrapperX<WmsInventoryDetailDO> query, Integer daysToExpires) {
  50 + static void appendDaysToExpiresQuery(MPJLambdaWrapperX<WmsInventoryDetailDO> query, Integer daysToExpires) {
51 51 if (daysToExpires == null) {
52 52 return;
53 53 }
... ... @@ -56,7 +56,7 @@ public interface WmsInventoryDetailMapper extends BaseMapperX&lt;WmsInventoryDetail
56 56 .leIfPresent(WmsInventoryDetailDO::getExpirationDate, startTime.plusDays(daysToExpires));
57 57 }
58 58  
59   - private static void appendDimensionOrder(MPJLambdaWrapperX<WmsInventoryDetailDO> query, String type) {
  59 + static void appendDimensionOrder(MPJLambdaWrapperX<WmsInventoryDetailDO> query, String type) {
60 60 if (StrUtil.equals(WmsInventoryDetailPageReqVO.TYPE_WAREHOUSE, type)) {
61 61 query.orderByAsc(WmsInventoryDetailDO::getWarehouseId)
62 62 .orderByAsc(WmsInventoryDetailDO::getAreaId)
... ...
yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/dal/mysql/inventory/WmsInventoryMapper.java
1 1 package cn.iocoder.yudao.module.wms.dal.mysql.inventory;
2 2  
  3 +import cn.hutool.core.collection.CollUtil;
3 4 import cn.hutool.core.util.StrUtil;
4 5 import cn.iocoder.yudao.framework.common.pojo.PageResult;
5 6 import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
  7 +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
6 8 import cn.iocoder.yudao.framework.mybatis.core.query.MPJLambdaWrapperX;
7 9 import cn.iocoder.yudao.module.wms.controller.admin.inventory.vo.WmsInventoryPageReqVO;
8 10 import cn.iocoder.yudao.module.wms.dal.dataobject.inventory.WmsInventoryDO;
9 11 import cn.iocoder.yudao.module.wms.dal.dataobject.md.item.WmsItemDO;
10 12 import cn.iocoder.yudao.module.wms.dal.dataobject.md.item.WmsItemSkuDO;
11   -import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
12 13 import org.apache.ibatis.annotations.Mapper;
13 14  
14   -import java.math.BigDecimal;
  15 +import java.util.Collection;
  16 +import java.util.Collections;
  17 +import java.util.List;
15 18  
16 19 /**
17 20 * WMS 库存 Mapper
... ... @@ -72,27 +75,42 @@ public interface WmsInventoryMapper extends BaseMapperX&lt;WmsInventoryDO&gt; {
72 75 WmsInventoryDO::getAreaId, areaId);
73 76 }
74 77  
75   - default WmsInventoryDO selectBySkuIdAndWarehouseIdAndAreaIdForUpdate(Long skuId, Long warehouseId, Long areaId) {
76   - return selectOneForUpdate(WmsInventoryDO::getSkuId, skuId,
77   - WmsInventoryDO::getWarehouseId, warehouseId,
78   - WmsInventoryDO::getAreaId, areaId);
79   - }
80   -
81 78 /**
82   - * 增量更新库存数量
  79 + * 根据多个唯一键,批量查询库存列表
83 80 *
84   - * @param id 库存编号
85   - * @param quantity 变更数量
86   - * @return 更新行数
  81 + * @param keys 唯一键列表:由 SKU 编号 + 仓库编号 + 库区编号组成
  82 + * @return 库存列表
87 83 */
88   - @SuppressWarnings("UnusedReturnValue")
89   - default int updateQuantity(Long id, BigDecimal quantity) {
90   - return update(null, new LambdaUpdateWrapper<WmsInventoryDO>()
91   - .eq(WmsInventoryDO::getId, id)
92   - .setSql("quantity = quantity + (" + quantity + ")"));
  84 + default List<WmsInventoryDO> selectListByKeys(Collection<WmsInventoryDO> keys) {
  85 + if (CollUtil.isEmpty(keys)) {
  86 + return Collections.emptyList();
  87 + }
  88 + return selectList(new LambdaQueryWrapperX<WmsInventoryDO>()
  89 + .and(query -> {
  90 + boolean first = true;
  91 + for (WmsInventoryDO key : keys) {
  92 + if (!first) {
  93 + query.or();
  94 + }
  95 + query.eq(WmsInventoryDO::getSkuId, key.getSkuId())
  96 + .eq(WmsInventoryDO::getWarehouseId, key.getWarehouseId())
  97 + .eq(WmsInventoryDO::getAreaId, key.getAreaId());
  98 + first = false;
  99 + }
  100 + }));
  101 + }
  102 +
  103 + default List<WmsInventoryDO> selectListByIdsForUpdate(Collection<Long> ids) {
  104 + if (CollUtil.isEmpty(ids)) {
  105 + return Collections.emptyList();
  106 + }
  107 + return selectList(new LambdaQueryWrapperX<WmsInventoryDO>()
  108 + .in(WmsInventoryDO::getId, ids)
  109 + .orderByAsc(WmsInventoryDO::getId)
  110 + .last("FOR UPDATE"));
93 111 }
94 112  
95   - private static void appendDimensionOrder(MPJLambdaWrapperX<WmsInventoryDO> query, String type) {
  113 + static void appendDimensionOrder(MPJLambdaWrapperX<WmsInventoryDO> query, String type) {
96 114 if (StrUtil.equals(WmsInventoryPageReqVO.TYPE_WAREHOUSE, type)) {
97 115 query.orderByAsc(WmsInventoryDO::getWarehouseId)
98 116 .orderByAsc(WmsItemSkuDO::getItemId)
... ...
yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/enums/ErrorCodeConstants.java
... ... @@ -92,7 +92,8 @@ public interface ErrorCodeConstants {
92 92 ErrorCode CHECK_ORDER_DETAIL_NOT_EXISTS = new ErrorCode(1_060_203_006, "盘库单明细不存在");
93 93  
94 94 // ========== WMS 库存 1-060-300-000 ==========
95   - ErrorCode INVENTORY_QUANTITY_NOT_ENOUGH = new ErrorCode(1_060_300_000, "库存不足,当前库存:{},变更数量:{}");
  95 + ErrorCode INVENTORY_QUANTITY_NOT_ENOUGH = new ErrorCode(1_060_300_000,
  96 + "库存不足,商品:{},商品规格:{},仓库:{},库区:{},当前库存:{},变更数量:{}");
96 97 ErrorCode INVENTORY_DETAIL_NOT_EXISTS = new ErrorCode(1_060_300_001, "库存明细不存在");
97 98 ErrorCode INVENTORY_DETAIL_REMAIN_QUANTITY_NOT_ENOUGH = new ErrorCode(1_060_300_002, "库存明细剩余数量不足,当前剩余:{},出库数量:{}");
98 99  
... ...
yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/service/inventory/WmsInventoryDetailServiceImpl.java
... ... @@ -17,6 +17,7 @@ import java.util.Comparator;
17 17 import java.util.LinkedHashMap;
18 18 import java.util.List;
19 19 import java.util.Map;
  20 +import java.util.stream.Collectors;
20 21  
21 22 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
22 23 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
... ... @@ -50,6 +51,8 @@ public class WmsInventoryDetailServiceImpl implements WmsInventoryDetailService
50 51 inventoryDetailMapper.insertBatch(list);
51 52 }
52 53  
  54 + // DONE @AI:加锁,也按照 inventory 做法;一批直接加锁;避免按照顺序,多个明细交叉拿锁,导致死锁
  55 + // 批量扣减库存明细:先一次性锁定相关明细,再在内存合并扣减数量后统一校验与更新。
53 56 @Override
54 57 @Transactional(rollbackFor = Exception.class)
55 58 public void decreaseInventoryDetailList(List<WmsInventoryChangeReqDTO.Item> items) {
... ... @@ -61,9 +64,9 @@ public class WmsInventoryDetailServiceImpl implements WmsInventoryDetailService
61 64 }
62 65 List<WmsInventoryChangeReqDTO.Item> sortedItems = items.stream()
63 66 .sorted(Comparator.comparing(WmsInventoryChangeReqDTO.Item::getInventoryDetailId))
64   - .toList();
  67 + .collect(Collectors.toList());
65 68  
66   - // 1. 按库存明细编号固定顺序加锁,避免多明细并发扣减时交叉拿锁
  69 + // 1. 批量锁定本次涉及的库存明细行,避免逐条加锁时不同事务交叉等待
67 70 Map<Long, WmsInventoryDetailDO> detailMap = convertMap(inventoryDetailMapper.selectListByIdsForUpdate(
68 71 convertSet(sortedItems, WmsInventoryChangeReqDTO.Item::getInventoryDetailId)),
69 72 WmsInventoryDetailDO::getId);
... ...
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;
2 2  
3 3 import cn.hutool.core.collection.CollUtil;
4 4 import cn.hutool.core.lang.Tuple;
  5 +import cn.hutool.core.util.ObjectUtil;
5 6 import cn.hutool.core.util.StrUtil;
  7 +import cn.iocoder.yudao.framework.common.exception.ServiceException;
6 8 import cn.iocoder.yudao.framework.common.pojo.PageResult;
7 9 import cn.iocoder.yudao.module.wms.controller.admin.inventory.vo.WmsInventoryPageReqVO;
8 10 import cn.iocoder.yudao.module.wms.dal.dataobject.inventory.WmsInventoryDetailDO;
9 11 import cn.iocoder.yudao.module.wms.dal.dataobject.inventory.WmsInventoryDO;
10 12 import cn.iocoder.yudao.module.wms.dal.dataobject.inventory.WmsInventoryHistoryDO;
  13 +import cn.iocoder.yudao.module.wms.dal.dataobject.md.item.WmsItemDO;
  14 +import cn.iocoder.yudao.module.wms.dal.dataobject.md.item.WmsItemSkuDO;
11 15 import cn.iocoder.yudao.module.wms.dal.mysql.inventory.WmsInventoryMapper;
12 16 import cn.iocoder.yudao.module.wms.framework.config.WmsProperties;
13 17 import cn.iocoder.yudao.module.wms.service.inventory.dto.WmsInventoryChangeReqDTO;
  18 +import cn.iocoder.yudao.module.wms.service.md.item.WmsItemService;
  19 +import cn.iocoder.yudao.module.wms.service.md.item.WmsItemSkuService;
14 20 import jakarta.annotation.Resource;
  21 +import lombok.extern.slf4j.Slf4j;
15 22 import org.springframework.dao.DuplicateKeyException;
16 23 import org.springframework.stereotype.Service;
17 24 import org.springframework.transaction.annotation.Transactional;
... ... @@ -19,12 +26,14 @@ import org.springframework.validation.annotation.Validated;
19 26  
20 27 import java.math.BigDecimal;
21 28 import java.util.ArrayList;
22   -import java.util.Comparator;
  29 +import java.util.Collections;
23 30 import java.util.IdentityHashMap;
24 31 import java.util.List;
25 32 import java.util.Map;
26 33  
27 34 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  35 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
  36 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
28 37 import static cn.iocoder.yudao.module.wms.enums.ErrorCodeConstants.*;
29 38  
30 39 /**
... ... @@ -34,6 +43,7 @@ import static cn.iocoder.yudao.module.wms.enums.ErrorCodeConstants.*;
34 43 */
35 44 @Service
36 45 @Validated
  46 +@Slf4j
37 47 public class WmsInventoryServiceImpl implements WmsInventoryService {
38 48  
39 49 @Resource
... ... @@ -45,6 +55,11 @@ public class WmsInventoryServiceImpl implements WmsInventoryService {
45 55 private WmsInventoryHistoryService inventoryHistoryService;
46 56  
47 57 @Resource
  58 + private WmsItemSkuService itemSkuService;
  59 + @Resource
  60 + private WmsItemService itemService;
  61 +
  62 + @Resource
48 63 private WmsProperties wmsProperties;
49 64  
50 65 @Override
... ... @@ -67,95 +82,135 @@ public class WmsInventoryServiceImpl implements WmsInventoryService {
67 82 return;
68 83 }
69 84  
70   - // 1. 按固定顺序变更库存余额,避免多 SKU/仓库并发操作时交叉拿锁
  85 + // 1. 补齐并锁定本次涉及的库存余额行,再计算库存变更
71 86 Map<WmsInventoryChangeReqDTO.Item, Tuple> resultMap = changeInventoryList(reqDTO.getItems());
72 87  
73   - // 2. 构建库存流水与批次库存明细
  88 + // 2.1 构建库存流水与批次库存明细
74 89 boolean batchEnabled = wmsProperties.isBatchEnabled();
75 90 List<WmsInventoryHistoryDO> histories = new ArrayList<>(reqDTO.getItems().size());
76   - List<WmsInventoryDetailDO> details = batchEnabled ? new ArrayList<>(reqDTO.getItems().size()) : List.of();
77   - List<WmsInventoryChangeReqDTO.Item> decreaseItems = batchEnabled ? new ArrayList<>(reqDTO.getItems().size()) : List.of();
  91 + List<WmsInventoryDetailDO> increaseDetails = batchEnabled ? new ArrayList<>(reqDTO.getItems().size()) : Collections.emptyList();
  92 + List<WmsInventoryChangeReqDTO.Item> decreaseItems = batchEnabled ? new ArrayList<>(reqDTO.getItems().size()) : Collections.emptyList();
78 93 for (WmsInventoryChangeReqDTO.Item item : reqDTO.getItems()) {
79 94 histories.add(buildInventoryHistory(reqDTO, item, resultMap.get(item)));
80   - if (batchEnabled) {
81   - if (isIncrease(item)) {
82   - details.add(buildInventoryDetail(reqDTO, item));
83   - } else {
84   - decreaseItems.add(item);
85   - }
  95 + if (!batchEnabled) {
  96 + continue;
  97 + }
  98 + if (item.getQuantity().compareTo(BigDecimal.ZERO) > 0) {
  99 + increaseDetails.add(buildInventoryDetail(reqDTO, item));
  100 + } else {
  101 + decreaseItems.add(item);
86 102 }
87 103 }
88 104  
89   - // 3. 按固定顺序变更批次库存明细
  105 + // 2.2.1 扣减已有批次库存明细,再写入新增批次库存明细
90 106 if (batchEnabled) {
91 107 inventoryDetailService.decreaseInventoryDetailList(decreaseItems);
92   - inventoryDetailService.createInventoryDetailList(details);
  108 + inventoryDetailService.createInventoryDetailList(increaseDetails);
93 109 }
94   -
95   - // 4. 批量写入库存流水
  110 + // 2.2.2 批量写入库存流水
96 111 inventoryHistoryService.createInventoryHistoryList(histories);
97 112 }
98 113  
99 114 /**
100 115 * 批量变更库存余额
101 116 *
102   - * 按 SKU、仓库、库区固定顺序拿锁;相同库存余额只更新一次,避免同一批操作内反复更新。
  117 + * 1. 库存行按 ID 批量加锁
  118 + * 2. 库存变更先在内存计算并校验,全部通过后批量覆盖库存数量。
103 119 *
104 120 * @param items 库存变更明细列表
105 121 * @return 每条变更明细对应的变更前数量、变更后数量
106 122 */
107 123 private Map<WmsInventoryChangeReqDTO.Item, Tuple> changeInventoryList(List<WmsInventoryChangeReqDTO.Item> items) {
108   - List<WmsInventoryChangeReqDTO.Item> sortedItems = sortByInventoryKey(items);
  124 + // 1.1 创建或锁定库存行
  125 + List<WmsInventoryDO> inventories = getOrCreateInventoryList(items);
  126 + // 1.2 锁定库存:避免 quantity 变更时的并发问题,导致 quantity 前后计算不对
  127 + inventories = inventoryMapper.selectListByIdsForUpdate(convertSet(inventories, WmsInventoryDO::getId));
  128 +
  129 + // 2.1 校验库存充足
109 130 Map<WmsInventoryChangeReqDTO.Item, Tuple> resultMap = new IdentityHashMap<>(items.size());
110   - for (int i = 0; i < sortedItems.size(); ) {
111   - WmsInventoryChangeReqDTO.Item firstItem = sortedItems.get(i);
112   - WmsInventoryDO inventory = getOrCreateInventory(firstItem);
113   - BigDecimal currentQuantity = inventory.getQuantity();
114   - BigDecimal totalChangeQuantity = BigDecimal.ZERO;
115   -
116   - int j = i;
117   - while (j < sortedItems.size() && isSameInventoryKey(firstItem, sortedItems.get(j))) {
118   - WmsInventoryChangeReqDTO.Item item = sortedItems.get(j);
119   - BigDecimal beforeQuantity = currentQuantity;
120   - currentQuantity = currentQuantity.add(item.getQuantity());
121   - if (currentQuantity.compareTo(BigDecimal.ZERO) < 0) {
122   - throw exception(INVENTORY_QUANTITY_NOT_ENOUGH, beforeQuantity, item.getQuantity());
123   - }
124   - resultMap.put(item, new Tuple(beforeQuantity, currentQuantity));
125   - totalChangeQuantity = totalChangeQuantity.add(item.getQuantity());
126   - j++;
  131 + for (WmsInventoryChangeReqDTO.Item item : items) {
  132 + WmsInventoryDO inventory = findInventory(inventories, item);
  133 + if (inventory == null) {
  134 + throw new IllegalStateException("库存行不存在,skuId=" + item.getSkuId()
  135 + + ", warehouseId=" + item.getWarehouseId() + ", areaId=" + item.getAreaId());
127 136 }
128   -
129   - if (totalChangeQuantity.compareTo(BigDecimal.ZERO) != 0) {
130   - inventoryMapper.updateQuantity(inventory.getId(), totalChangeQuantity);
  137 + BigDecimal beforeQuantity = inventory.getQuantity();
  138 + BigDecimal afterQuantity = beforeQuantity.add(item.getQuantity());
  139 + if (afterQuantity.compareTo(BigDecimal.ZERO) < 0) {
  140 + throw buildInventoryQuantityNotEnoughException(item, beforeQuantity);
131 141 }
132   - i = j;
  142 + inventory.setQuantity(afterQuantity);
  143 + resultMap.put(item, new Tuple(beforeQuantity, afterQuantity));
  144 + }
  145 + // 2.2 批量更新库存数量(加锁安全)
  146 + if (CollUtil.isNotEmpty(inventories)) {
  147 + inventoryMapper.updateBatch(convertList(inventories, inventory ->
  148 + new WmsInventoryDO().setId(inventory.getId()).setQuantity(inventory.getQuantity())));
133 149 }
134 150 return resultMap;
135 151 }
136 152  
137   - private WmsInventoryDO getOrCreateInventory(WmsInventoryChangeReqDTO.Item item) {
138   - // 1. 查询库存余额,使用 FOR UPDATE 锁定行
139   - WmsInventoryDO inventory = inventoryMapper.selectBySkuIdAndWarehouseIdAndAreaIdForUpdate(item.getSkuId(),
140   - item.getWarehouseId(), item.getAreaId());
141   - if (inventory != null) {
142   - return inventory;
  153 + private List<WmsInventoryDO> getOrCreateInventoryList(List<WmsInventoryChangeReqDTO.Item> items) {
  154 + // 先按库存维度在内存去重,避免同一批明细重复查询或重复补行。
  155 + List<WmsInventoryDO> inventoryKeyList = new ArrayList<>(items.size());
  156 + for (WmsInventoryChangeReqDTO.Item item : items) {
  157 + if (findInventory(inventoryKeyList, item) == null) {
  158 + inventoryKeyList.add(new WmsInventoryDO().setSkuId(item.getSkuId())
  159 + .setWarehouseId(item.getWarehouseId()).setAreaId(item.getAreaId()));
  160 + }
  161 + }
  162 +
  163 + // 批量查询已存在的库存行;这里不加锁,后续统一按库存 ID 批量加锁。
  164 + List<WmsInventoryDO> inventories = inventoryMapper.selectListByKeys(inventoryKeyList);
  165 +
  166 + // 对比库存维度,找出数据库中还不存在的库存行,再按唯一索引兜底补齐。
  167 + List<WmsInventoryDO> missingInventoryList = new ArrayList<>(inventoryKeyList.size());
  168 + for (WmsInventoryDO inventoryKey : inventoryKeyList) {
  169 + if (findInventory(inventories, inventoryKey) == null) {
  170 + missingInventoryList.add(inventoryKey);
  171 + }
143 172 }
144   - // 2.1 不存在,则插入一条库存余额记录,初始数量为 0
  173 + if (CollUtil.isEmpty(missingInventoryList)) {
  174 + return inventories;
  175 + }
  176 +
  177 + inventories.addAll(createMissingInventoryList(missingInventoryList));
  178 + return inventories;
  179 + }
  180 +
  181 + private List<WmsInventoryDO> createMissingInventoryList(List<WmsInventoryDO> missingInventoryList) {
  182 + // 优先批量插入缺失库存行;并发唯一键冲突时,再逐个插入并回查冲突行。
  183 + List<WmsInventoryDO> newInventoryList = convertList(missingInventoryList, missingInventory ->
  184 + new WmsInventoryDO().setSkuId(missingInventory.getSkuId()).setWarehouseId(missingInventory.getWarehouseId())
  185 + .setAreaId(missingInventory.getAreaId()).setQuantity(BigDecimal.ZERO));
145 186 try {
146   - inventory = new WmsInventoryDO().setSkuId(item.getSkuId())
147   - .setWarehouseId(item.getWarehouseId()).setAreaId(item.getAreaId()).setQuantity(BigDecimal.ZERO);
148   - inventoryMapper.insert(inventory);
149   - return inventory;
  187 + inventoryMapper.insertBatch(newInventoryList);
  188 + return newInventoryList;
150 189 } catch (DuplicateKeyException ex) {
151   - // 2.2 插入失败,说明有并发冲突,重试查询库存余额
152   - inventory = inventoryMapper.selectBySkuIdAndWarehouseIdAndAreaIdForUpdate(item.getSkuId(),
153   - item.getWarehouseId(), item.getAreaId());
154   - if (inventory == null) {
155   - throw ex;
  190 + // 并发事务可能已经补齐部分库存行,降级为逐个插入并回查唯一键冲突的行。
  191 + log.warn("[createMissingInventoryList][missingInventoryList({}) 批量插入库存行冲突,降级为逐个插入]",
  192 + missingInventoryList, ex);
  193 + }
  194 +
  195 + // 批量插入失败后逐条补齐;单条唯一键冲突时,回查并使用并发事务创建的库存行。
  196 + List<WmsInventoryDO> resultList = new ArrayList<>(missingInventoryList.size());
  197 + for (WmsInventoryDO missingInventory : missingInventoryList) {
  198 + WmsInventoryDO inventory = new WmsInventoryDO().setSkuId(missingInventory.getSkuId())
  199 + .setWarehouseId(missingInventory.getWarehouseId()).setAreaId(missingInventory.getAreaId())
  200 + .setQuantity(BigDecimal.ZERO);
  201 + try {
  202 + inventoryMapper.insert(inventory);
  203 + } catch (DuplicateKeyException ex) {
  204 + inventory = inventoryMapper.selectBySkuIdAndWarehouseIdAndAreaId(
  205 + missingInventory.getSkuId(), missingInventory.getWarehouseId(), missingInventory.getAreaId());
  206 + log.warn("[createMissingInventoryList][missingInventory({}) 插入库存行冲突,回查已有库存行]", missingInventory, ex);
  207 + if (inventory == null) {
  208 + throw ex;
  209 + }
156 210 }
157   - return inventory;
  211 + resultList.add(inventory);
158 212 }
  213 + return resultList;
159 214 }
160 215  
161 216 private WmsInventoryHistoryDO buildInventoryHistory(WmsInventoryChangeReqDTO reqDTO,
... ... @@ -181,23 +236,32 @@ public class WmsInventoryServiceImpl implements WmsInventoryService {
181 236 .setOrderId(reqDTO.getOrderId()).setOrderNo(reqDTO.getOrderNo()).setOrderType(reqDTO.getOrderType());
182 237 }
183 238  
184   - private boolean isIncrease(WmsInventoryChangeReqDTO.Item item) {
185   - return item.getQuantity().compareTo(BigDecimal.ZERO) > 0;
  239 + private static WmsInventoryDO findInventory(List<WmsInventoryDO> inventories, WmsInventoryChangeReqDTO.Item item) {
  240 + return CollUtil.findOne(inventories, inventory -> isSameInventory(inventory, item));
  241 + }
  242 +
  243 + private static WmsInventoryDO findInventory(List<WmsInventoryDO> inventories, WmsInventoryDO key) {
  244 + return CollUtil.findOne(inventories, inventory -> isSameInventory(inventory, key));
  245 + }
  246 +
  247 + private static boolean isSameInventory(WmsInventoryDO inventory, WmsInventoryChangeReqDTO.Item item) {
  248 + return ObjectUtil.equal(inventory.getSkuId(), item.getSkuId())
  249 + && ObjectUtil.equal(inventory.getWarehouseId(), item.getWarehouseId())
  250 + && ObjectUtil.equal(inventory.getAreaId(), item.getAreaId());
186 251 }
187 252  
188   - private static List<WmsInventoryChangeReqDTO.Item> sortByInventoryKey(List<WmsInventoryChangeReqDTO.Item> items) {
189   - return items.stream()
190   - .sorted(Comparator.comparing(WmsInventoryChangeReqDTO.Item::getSkuId)
191   - .thenComparing(WmsInventoryChangeReqDTO.Item::getWarehouseId)
192   - .thenComparing(WmsInventoryChangeReqDTO.Item::getAreaId))
193   - .toList();
  253 + private static boolean isSameInventory(WmsInventoryDO inventory, WmsInventoryDO key) {
  254 + return ObjectUtil.equal(inventory.getSkuId(), key.getSkuId())
  255 + && ObjectUtil.equal(inventory.getWarehouseId(), key.getWarehouseId())
  256 + && ObjectUtil.equal(inventory.getAreaId(), key.getAreaId());
194 257 }
195 258  
196   - private static boolean isSameInventoryKey(WmsInventoryChangeReqDTO.Item item1,
197   - WmsInventoryChangeReqDTO.Item item2) {
198   - return item1.getSkuId().equals(item2.getSkuId())
199   - && item1.getWarehouseId().equals(item2.getWarehouseId())
200   - && item1.getAreaId().equals(item2.getAreaId());
  259 + private ServiceException buildInventoryQuantityNotEnoughException(WmsInventoryChangeReqDTO.Item item,
  260 + BigDecimal beforeQuantity) {
  261 + WmsItemSkuDO skuDO = itemSkuService.validateItemSkuExists(item.getSkuId());
  262 + WmsItemDO itemDO = itemService.validateItemExists(skuDO.getItemId());
  263 + return exception(INVENTORY_QUANTITY_NOT_ENOUGH, itemDO.getName(), skuDO.getName(),
  264 + item.getWarehouseId(), item.getAreaId(), beforeQuantity, item.getQuantity());
201 265 }
202 266  
203 267 }
... ...
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;
16 16 import cn.iocoder.yudao.module.wms.enums.inventory.WmsInventoryOrderTypeEnum;
17 17 import cn.iocoder.yudao.module.wms.framework.config.WmsProperties;
18 18 import cn.iocoder.yudao.module.wms.service.inventory.dto.WmsInventoryChangeReqDTO;
  19 +import cn.iocoder.yudao.module.wms.service.md.item.WmsItemService;
  20 +import cn.iocoder.yudao.module.wms.service.md.item.WmsItemSkuService;
19 21 import jakarta.annotation.Resource;
20 22 import org.junit.jupiter.api.Test;
21 23 import org.springframework.context.annotation.Import;
  24 +import org.springframework.test.context.bean.override.mockito.MockitoBean;
22 25  
23 26 import java.math.BigDecimal;
24 27 import java.util.ArrayList;
  28 +import java.util.Collections;
  29 +import java.util.Comparator;
25 30 import java.util.List;
26 31  
27 32 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
29 34 import static cn.iocoder.yudao.module.wms.enums.ErrorCodeConstants.INVENTORY_QUANTITY_NOT_ENOUGH;
30 35 import static org.junit.jupiter.api.Assertions.assertEquals;
31 36 import static org.junit.jupiter.api.Assertions.assertNotNull;
  37 +import static org.mockito.Mockito.when;
32 38  
33 39 @Import({WmsInventoryServiceImpl.class, WmsInventoryDetailServiceImpl.class, WmsInventoryHistoryServiceImpl.class,
34 40 WmsProperties.class})
... ... @@ -50,6 +56,11 @@ public class WmsInventoryServiceImplTest extends BaseDbUnitTest {
50 56 @Resource
51 57 private WmsProperties wmsProperties;
52 58  
  59 + @MockitoBean
  60 + private WmsItemSkuService itemSkuService;
  61 + @MockitoBean
  62 + private WmsItemService itemService;
  63 +
53 64 @Test
54 65 public void testChangeInventory_createInventory() {
55 66 // mock 数据
... ... @@ -105,6 +116,128 @@ public class WmsInventoryServiceImplTest extends BaseDbUnitTest {
105 116 }
106 117  
107 118 @Test
  119 + public void testChangeInventory_sameInventoryKeyKeepItemGranularity() {
  120 + // mock 数据
  121 + wmsProperties.setBatchEnable(false);
  122 + WmsItemDO item = createItem("ITEM-001", "红富士苹果");
  123 + WmsItemSkuDO sku = createSku(item.getId(), "SKU-001", "10kg 箱装");
  124 + WmsInventoryChangeReqDTO reqDTO = createChangeReq(sku.getId(), 100L, 1001L, "5.00");
  125 + List<WmsInventoryChangeReqDTO.Item> items = new ArrayList<>(reqDTO.getItems());
  126 + items.add(new WmsInventoryChangeReqDTO.Item()
  127 + .setSkuId(sku.getId())
  128 + .setWarehouseId(100L)
  129 + .setAreaId(1001L)
  130 + .setQuantity(new BigDecimal("7.00"))
  131 + .setAmount(new BigDecimal("140.00"))
  132 + .setRemark("测试入库 2"));
  133 + reqDTO.setItems(items);
  134 +
  135 + // 调用
  136 + inventoryService.changeInventory(reqDTO);
  137 +
  138 + // 断言:库存余额只保留一条,数量逐条累加
  139 + WmsInventoryDO inventory = inventoryMapper.selectBySkuIdAndWarehouseIdAndAreaId(sku.getId(), 100L, 1001L);
  140 + assertNotNull(inventory);
  141 + assertEquals(0, new BigDecimal("12.00").compareTo(inventory.getQuantity()));
  142 + // 断言:库存流水保持用户明细颗粒度
  143 + List<WmsInventoryHistoryDO> histories = inventoryHistoryMapper.selectList();
  144 + histories.sort(Comparator.comparing(WmsInventoryHistoryDO::getId));
  145 + assertEquals(2, histories.size());
  146 + assertEquals(0, BigDecimal.ZERO.compareTo(histories.get(0).getBeforeQuantity()));
  147 + assertEquals(0, new BigDecimal("5.00").compareTo(histories.get(0).getAfterQuantity()));
  148 + assertEquals(0, new BigDecimal("5.00").compareTo(histories.get(0).getQuantity()));
  149 + assertEquals(0, new BigDecimal("5.00").compareTo(histories.get(1).getBeforeQuantity()));
  150 + assertEquals(0, new BigDecimal("12.00").compareTo(histories.get(1).getAfterQuantity()));
  151 + assertEquals(0, new BigDecimal("7.00").compareTo(histories.get(1).getQuantity()));
  152 + }
  153 +
  154 + @Test
  155 + public void testChangeInventory_sameExistingInventoryKeyKeepItemGranularity() {
  156 + // mock 数据
  157 + wmsProperties.setBatchEnable(false);
  158 + WmsItemDO item = createItem("ITEM-001", "红富士苹果");
  159 + WmsItemSkuDO sku = createSku(item.getId(), "SKU-001", "10kg 箱装");
  160 + inventoryMapper.insert(createInventory(sku.getId(), 100L, 1001L, "2.00"));
  161 + WmsInventoryChangeReqDTO reqDTO = createChangeReq(sku.getId(), 100L, 1001L, "3.00");
  162 + List<WmsInventoryChangeReqDTO.Item> items = new ArrayList<>(reqDTO.getItems());
  163 + items.add(new WmsInventoryChangeReqDTO.Item()
  164 + .setSkuId(sku.getId())
  165 + .setWarehouseId(100L)
  166 + .setAreaId(1001L)
  167 + .setQuantity(new BigDecimal("4.00"))
  168 + .setAmount(new BigDecimal("80.00"))
  169 + .setRemark("测试入库 2"));
  170 + reqDTO.setItems(items);
  171 +
  172 + // 调用
  173 + inventoryService.changeInventory(reqDTO);
  174 +
  175 + // 断言:库存余额只保留一条,数量逐条累加
  176 + WmsInventoryDO inventory = inventoryMapper.selectBySkuIdAndWarehouseIdAndAreaId(sku.getId(), 100L, 1001L);
  177 + assertNotNull(inventory);
  178 + assertEquals(0, new BigDecimal("9.00").compareTo(inventory.getQuantity()));
  179 + // 断言:库存流水保持用户明细颗粒度
  180 + List<WmsInventoryHistoryDO> histories = inventoryHistoryMapper.selectList();
  181 + histories.sort(Comparator.comparing(WmsInventoryHistoryDO::getId));
  182 + assertEquals(2, histories.size());
  183 + assertEquals(0, new BigDecimal("2.00").compareTo(histories.get(0).getBeforeQuantity()));
  184 + assertEquals(0, new BigDecimal("5.00").compareTo(histories.get(0).getAfterQuantity()));
  185 + assertEquals(0, new BigDecimal("3.00").compareTo(histories.get(0).getQuantity()));
  186 + assertEquals(0, new BigDecimal("5.00").compareTo(histories.get(1).getBeforeQuantity()));
  187 + assertEquals(0, new BigDecimal("9.00").compareTo(histories.get(1).getAfterQuantity()));
  188 + assertEquals(0, new BigDecimal("4.00").compareTo(histories.get(1).getQuantity()));
  189 + }
  190 +
  191 + @Test
  192 + public void testChangeInventory_multipleInventoryKeysKeepItemGranularity() {
  193 + // mock 数据
  194 + wmsProperties.setBatchEnable(false);
  195 + WmsItemDO item = createItem("ITEM-001", "红富士苹果");
  196 + WmsItemSkuDO sku = createSku(item.getId(), "SKU-001", "10kg 箱装");
  197 + WmsInventoryChangeReqDTO reqDTO = createChangeReq(sku.getId(), 100L, 1001L, "5.00");
  198 + List<WmsInventoryChangeReqDTO.Item> items = new ArrayList<>(reqDTO.getItems());
  199 + items.add(new WmsInventoryChangeReqDTO.Item()
  200 + .setSkuId(sku.getId())
  201 + .setWarehouseId(200L)
  202 + .setAreaId(2001L)
  203 + .setQuantity(new BigDecimal("3.00"))
  204 + .setAmount(new BigDecimal("60.00"))
  205 + .setRemark("测试入库 2"));
  206 + items.add(new WmsInventoryChangeReqDTO.Item()
  207 + .setSkuId(sku.getId())
  208 + .setWarehouseId(100L)
  209 + .setAreaId(1001L)
  210 + .setQuantity(new BigDecimal("-2.00"))
  211 + .setAmount(new BigDecimal("40.00"))
  212 + .setRemark("测试出库"));
  213 + reqDTO.setItems(items);
  214 +
  215 + // 调用
  216 + inventoryService.changeInventory(reqDTO);
  217 +
  218 + // 断言:不同库存余额分别更新
  219 + WmsInventoryDO inventory1 = inventoryMapper.selectBySkuIdAndWarehouseIdAndAreaId(sku.getId(), 100L, 1001L);
  220 + assertNotNull(inventory1);
  221 + assertEquals(0, new BigDecimal("3.00").compareTo(inventory1.getQuantity()));
  222 + WmsInventoryDO inventory2 = inventoryMapper.selectBySkuIdAndWarehouseIdAndAreaId(sku.getId(), 200L, 2001L);
  223 + assertNotNull(inventory2);
  224 + assertEquals(0, new BigDecimal("3.00").compareTo(inventory2.getQuantity()));
  225 + // 断言:批量加锁后仍按明细颗粒度记录 before/after
  226 + List<WmsInventoryHistoryDO> histories = inventoryHistoryMapper.selectList();
  227 + histories.sort(Comparator.comparing(WmsInventoryHistoryDO::getId));
  228 + assertEquals(3, histories.size());
  229 + assertEquals(100L, histories.get(0).getWarehouseId());
  230 + assertEquals(0, BigDecimal.ZERO.compareTo(histories.get(0).getBeforeQuantity()));
  231 + assertEquals(0, new BigDecimal("5.00").compareTo(histories.get(0).getAfterQuantity()));
  232 + assertEquals(200L, histories.get(1).getWarehouseId());
  233 + assertEquals(0, BigDecimal.ZERO.compareTo(histories.get(1).getBeforeQuantity()));
  234 + assertEquals(0, new BigDecimal("3.00").compareTo(histories.get(1).getAfterQuantity()));
  235 + assertEquals(100L, histories.get(2).getWarehouseId());
  236 + assertEquals(0, new BigDecimal("5.00").compareTo(histories.get(2).getBeforeQuantity()));
  237 + assertEquals(0, new BigDecimal("3.00").compareTo(histories.get(2).getAfterQuantity()));
  238 + }
  239 +
  240 + @Test
108 241 public void testChangeInventory_quantityNotEnough() {
109 242 // mock 数据
110 243 wmsProperties.setBatchEnable(false);
... ... @@ -112,10 +245,26 @@ public class WmsInventoryServiceImplTest extends BaseDbUnitTest {
112 245 WmsItemSkuDO sku = createSku(item.getId(), "SKU-001", "10kg 箱装");
113 246 inventoryMapper.insert(createInventory(sku.getId(), 100L, 1001L, "2.00"));
114 247 WmsInventoryChangeReqDTO reqDTO = createChangeReq(sku.getId(), 100L, 1001L, "-3.00");
  248 + mockItemSkuAndItem(item, sku);
115 249  
116 250 // 调用,并断言
117 251 assertServiceException(() -> inventoryService.changeInventory(reqDTO), INVENTORY_QUANTITY_NOT_ENOUGH,
118   - new BigDecimal("2.000000"), new BigDecimal("-3.00"));
  252 + item.getName(), sku.getName(), 100L, 1001L, new BigDecimal("2.000000"), new BigDecimal("-3.00"));
  253 + }
  254 +
  255 + @Test
  256 + public void testChangeInventory_quantityNotEnoughWithoutInventory() {
  257 + // mock 数据
  258 + wmsProperties.setBatchEnable(false);
  259 + WmsItemDO item = createItem("ITEM-001", "红富士苹果");
  260 + WmsItemSkuDO sku = createSku(item.getId(), "SKU-001", "10kg 箱装");
  261 + WmsInventoryChangeReqDTO reqDTO = createChangeReq(sku.getId(), 100L, 1001L, "-3.00");
  262 + mockItemSkuAndItem(item, sku);
  263 +
  264 + // 调用,并断言
  265 + assertServiceException(() -> inventoryService.changeInventory(reqDTO), INVENTORY_QUANTITY_NOT_ENOUGH,
  266 + item.getName(), sku.getName(), 100L, 1001L, BigDecimal.ZERO.setScale(6), new BigDecimal("-3.00"));
  267 + assertEquals(0, inventoryMapper.selectCount());
119 268 }
120 269  
121 270 @Test
... ... @@ -256,6 +405,11 @@ public class WmsInventoryServiceImplTest extends BaseDbUnitTest {
256 405 return sku;
257 406 }
258 407  
  408 + private void mockItemSkuAndItem(WmsItemDO item, WmsItemSkuDO sku) {
  409 + when(itemSkuService.validateItemSkuExists(sku.getId())).thenReturn(sku);
  410 + when(itemService.validateItemExists(item.getId())).thenReturn(item);
  411 + }
  412 +
259 413 private static WmsInventoryDO createInventory(Long skuId, Long warehouseId, Long areaId, String quantity) {
260 414 return WmsInventoryDO.builder()
261 415 .skuId(skuId)
... ... @@ -281,7 +435,7 @@ public class WmsInventoryServiceImplTest extends BaseDbUnitTest {
281 435 .setOrderId(1L)
282 436 .setOrderNo("RK202605120001")
283 437 .setOrderType(WmsInventoryOrderTypeEnum.RECEIPT.getType())
284   - .setItems(List.of(new WmsInventoryChangeReqDTO.Item()
  438 + .setItems(Collections.singletonList(new WmsInventoryChangeReqDTO.Item()
285 439 .setSkuId(skuId)
286 440 .setWarehouseId(warehouseId)
287 441 .setAreaId(areaId)
... ...
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;
16 16 import org.springframework.test.context.bean.override.mockito.MockitoBean;
17 17  
18 18 import java.math.BigDecimal;
  19 +import java.util.Arrays;
19 20 import java.util.List;
20 21  
21 22 import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
... ... @@ -129,7 +130,7 @@ public class WmsReceiptOrderDetailServiceImplTest extends BaseDbUnitTest {
129 130 WmsReceiptOrderSaveReqVO reqVO = new WmsReceiptOrderSaveReqVO();
130 131 reqVO.setWarehouseId(100L);
131 132 reqVO.setAreaId(0L);
132   - reqVO.setDetails(List.of(details));
  133 + reqVO.setDetails(Arrays.asList(details));
133 134 return reqVO;
134 135 }
135 136  
... ...