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,7 +160,7 @@ public class WmsCheckOrderController {
160 if (order == null) { 160 if (order == null) {
161 return null; 161 return null;
162 } 162 }
163 - List<WmsCheckOrderRespVO> list = buildCheckOrderRespVOList(List.of(order)); 163 + List<WmsCheckOrderRespVO> list = buildCheckOrderRespVOList(Collections.singletonList(order));
164 return CollUtil.getFirst(list); 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,7 +160,7 @@ public class WmsMovementOrderController {
160 if (order == null) { 160 if (order == null) {
161 return null; 161 return null;
162 } 162 }
163 - List<WmsMovementOrderRespVO> list = buildMovementOrderRespVOList(List.of(order)); 163 + List<WmsMovementOrderRespVO> list = buildMovementOrderRespVOList(Collections.singletonList(order));
164 return CollUtil.getFirst(list); 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,7 +163,7 @@ public class WmsReceiptOrderController {
163 if (order == null) { 163 if (order == null) {
164 return null; 164 return null;
165 } 165 }
166 - List<WmsReceiptOrderRespVO> list = buildReceiptOrderRespVOList(List.of(order)); 166 + List<WmsReceiptOrderRespVO> list = buildReceiptOrderRespVOList(Collections.singletonList(order));
167 return CollUtil.getFirst(list); 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,7 +163,7 @@ public class WmsShipmentOrderController {
163 if (order == null) { 163 if (order == null) {
164 return null; 164 return null;
165 } 165 }
166 - List<WmsShipmentOrderRespVO> list = buildShipmentOrderRespVOList(List.of(order)); 166 + List<WmsShipmentOrderRespVO> list = buildShipmentOrderRespVOList(Collections.singletonList(order));
167 return CollUtil.getFirst(list); 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,7 +47,7 @@ public interface WmsInventoryDetailMapper extends BaseMapperX&lt;WmsInventoryDetail
47 return selectJoinPage(reqVO, WmsInventoryDetailDO.class, query); 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 if (daysToExpires == null) { 51 if (daysToExpires == null) {
52 return; 52 return;
53 } 53 }
@@ -56,7 +56,7 @@ public interface WmsInventoryDetailMapper extends BaseMapperX&lt;WmsInventoryDetail @@ -56,7 +56,7 @@ public interface WmsInventoryDetailMapper extends BaseMapperX&lt;WmsInventoryDetail
56 .leIfPresent(WmsInventoryDetailDO::getExpirationDate, startTime.plusDays(daysToExpires)); 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 if (StrUtil.equals(WmsInventoryDetailPageReqVO.TYPE_WAREHOUSE, type)) { 60 if (StrUtil.equals(WmsInventoryDetailPageReqVO.TYPE_WAREHOUSE, type)) {
61 query.orderByAsc(WmsInventoryDetailDO::getWarehouseId) 61 query.orderByAsc(WmsInventoryDetailDO::getWarehouseId)
62 .orderByAsc(WmsInventoryDetailDO::getAreaId) 62 .orderByAsc(WmsInventoryDetailDO::getAreaId)
yudao-module-wms/src/main/java/cn/iocoder/yudao/module/wms/dal/mysql/inventory/WmsInventoryMapper.java
1 package cn.iocoder.yudao.module.wms.dal.mysql.inventory; 1 package cn.iocoder.yudao.module.wms.dal.mysql.inventory;
2 2
  3 +import cn.hutool.core.collection.CollUtil;
3 import cn.hutool.core.util.StrUtil; 4 import cn.hutool.core.util.StrUtil;
4 import cn.iocoder.yudao.framework.common.pojo.PageResult; 5 import cn.iocoder.yudao.framework.common.pojo.PageResult;
5 import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; 6 import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
  7 +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
6 import cn.iocoder.yudao.framework.mybatis.core.query.MPJLambdaWrapperX; 8 import cn.iocoder.yudao.framework.mybatis.core.query.MPJLambdaWrapperX;
7 import cn.iocoder.yudao.module.wms.controller.admin.inventory.vo.WmsInventoryPageReqVO; 9 import cn.iocoder.yudao.module.wms.controller.admin.inventory.vo.WmsInventoryPageReqVO;
8 import cn.iocoder.yudao.module.wms.dal.dataobject.inventory.WmsInventoryDO; 10 import cn.iocoder.yudao.module.wms.dal.dataobject.inventory.WmsInventoryDO;
9 import cn.iocoder.yudao.module.wms.dal.dataobject.md.item.WmsItemDO; 11 import cn.iocoder.yudao.module.wms.dal.dataobject.md.item.WmsItemDO;
10 import cn.iocoder.yudao.module.wms.dal.dataobject.md.item.WmsItemSkuDO; 12 import cn.iocoder.yudao.module.wms.dal.dataobject.md.item.WmsItemSkuDO;
11 -import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;  
12 import org.apache.ibatis.annotations.Mapper; 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 * WMS 库存 Mapper 20 * WMS 库存 Mapper
@@ -72,27 +75,42 @@ public interface WmsInventoryMapper extends BaseMapperX&lt;WmsInventoryDO&gt; { @@ -72,27 +75,42 @@ public interface WmsInventoryMapper extends BaseMapperX&lt;WmsInventoryDO&gt; {
72 WmsInventoryDO::getAreaId, areaId); 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 if (StrUtil.equals(WmsInventoryPageReqVO.TYPE_WAREHOUSE, type)) { 114 if (StrUtil.equals(WmsInventoryPageReqVO.TYPE_WAREHOUSE, type)) {
97 query.orderByAsc(WmsInventoryDO::getWarehouseId) 115 query.orderByAsc(WmsInventoryDO::getWarehouseId)
98 .orderByAsc(WmsItemSkuDO::getItemId) 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,7 +92,8 @@ public interface ErrorCodeConstants {
92 ErrorCode CHECK_ORDER_DETAIL_NOT_EXISTS = new ErrorCode(1_060_203_006, "盘库单明细不存在"); 92 ErrorCode CHECK_ORDER_DETAIL_NOT_EXISTS = new ErrorCode(1_060_203_006, "盘库单明细不存在");
93 93
94 // ========== WMS 库存 1-060-300-000 ========== 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 ErrorCode INVENTORY_DETAIL_NOT_EXISTS = new ErrorCode(1_060_300_001, "库存明细不存在"); 97 ErrorCode INVENTORY_DETAIL_NOT_EXISTS = new ErrorCode(1_060_300_001, "库存明细不存在");
97 ErrorCode INVENTORY_DETAIL_REMAIN_QUANTITY_NOT_ENOUGH = new ErrorCode(1_060_300_002, "库存明细剩余数量不足,当前剩余:{},出库数量:{}"); 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,6 +17,7 @@ import java.util.Comparator;
17 import java.util.LinkedHashMap; 17 import java.util.LinkedHashMap;
18 import java.util.List; 18 import java.util.List;
19 import java.util.Map; 19 import java.util.Map;
  20 +import java.util.stream.Collectors;
20 21
21 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; 22 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
22 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; 23 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
@@ -50,6 +51,8 @@ public class WmsInventoryDetailServiceImpl implements WmsInventoryDetailService @@ -50,6 +51,8 @@ public class WmsInventoryDetailServiceImpl implements WmsInventoryDetailService
50 inventoryDetailMapper.insertBatch(list); 51 inventoryDetailMapper.insertBatch(list);
51 } 52 }
52 53
  54 + // DONE @AI:加锁,也按照 inventory 做法;一批直接加锁;避免按照顺序,多个明细交叉拿锁,导致死锁
  55 + // 批量扣减库存明细:先一次性锁定相关明细,再在内存合并扣减数量后统一校验与更新。
53 @Override 56 @Override
54 @Transactional(rollbackFor = Exception.class) 57 @Transactional(rollbackFor = Exception.class)
55 public void decreaseInventoryDetailList(List<WmsInventoryChangeReqDTO.Item> items) { 58 public void decreaseInventoryDetailList(List<WmsInventoryChangeReqDTO.Item> items) {
@@ -61,9 +64,9 @@ public class WmsInventoryDetailServiceImpl implements WmsInventoryDetailService @@ -61,9 +64,9 @@ public class WmsInventoryDetailServiceImpl implements WmsInventoryDetailService
61 } 64 }
62 List<WmsInventoryChangeReqDTO.Item> sortedItems = items.stream() 65 List<WmsInventoryChangeReqDTO.Item> sortedItems = items.stream()
63 .sorted(Comparator.comparing(WmsInventoryChangeReqDTO.Item::getInventoryDetailId)) 66 .sorted(Comparator.comparing(WmsInventoryChangeReqDTO.Item::getInventoryDetailId))
64 - .toList(); 67 + .collect(Collectors.toList());
65 68
66 - // 1. 按库存明细编号固定顺序加锁,避免多明细并发扣减时交叉拿锁 69 + // 1. 批量锁定本次涉及的库存明细行,避免逐条加锁时不同事务交叉等待
67 Map<Long, WmsInventoryDetailDO> detailMap = convertMap(inventoryDetailMapper.selectListByIdsForUpdate( 70 Map<Long, WmsInventoryDetailDO> detailMap = convertMap(inventoryDetailMapper.selectListByIdsForUpdate(
68 convertSet(sortedItems, WmsInventoryChangeReqDTO.Item::getInventoryDetailId)), 71 convertSet(sortedItems, WmsInventoryChangeReqDTO.Item::getInventoryDetailId)),
69 WmsInventoryDetailDO::getId); 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,16 +2,23 @@ package cn.iocoder.yudao.module.wms.service.inventory;
2 2
3 import cn.hutool.core.collection.CollUtil; 3 import cn.hutool.core.collection.CollUtil;
4 import cn.hutool.core.lang.Tuple; 4 import cn.hutool.core.lang.Tuple;
  5 +import cn.hutool.core.util.ObjectUtil;
5 import cn.hutool.core.util.StrUtil; 6 import cn.hutool.core.util.StrUtil;
  7 +import cn.iocoder.yudao.framework.common.exception.ServiceException;
6 import cn.iocoder.yudao.framework.common.pojo.PageResult; 8 import cn.iocoder.yudao.framework.common.pojo.PageResult;
7 import cn.iocoder.yudao.module.wms.controller.admin.inventory.vo.WmsInventoryPageReqVO; 9 import cn.iocoder.yudao.module.wms.controller.admin.inventory.vo.WmsInventoryPageReqVO;
8 import cn.iocoder.yudao.module.wms.dal.dataobject.inventory.WmsInventoryDetailDO; 10 import cn.iocoder.yudao.module.wms.dal.dataobject.inventory.WmsInventoryDetailDO;
9 import cn.iocoder.yudao.module.wms.dal.dataobject.inventory.WmsInventoryDO; 11 import cn.iocoder.yudao.module.wms.dal.dataobject.inventory.WmsInventoryDO;
10 import cn.iocoder.yudao.module.wms.dal.dataobject.inventory.WmsInventoryHistoryDO; 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 import cn.iocoder.yudao.module.wms.dal.mysql.inventory.WmsInventoryMapper; 15 import cn.iocoder.yudao.module.wms.dal.mysql.inventory.WmsInventoryMapper;
12 import cn.iocoder.yudao.module.wms.framework.config.WmsProperties; 16 import cn.iocoder.yudao.module.wms.framework.config.WmsProperties;
13 import cn.iocoder.yudao.module.wms.service.inventory.dto.WmsInventoryChangeReqDTO; 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 import jakarta.annotation.Resource; 20 import jakarta.annotation.Resource;
  21 +import lombok.extern.slf4j.Slf4j;
15 import org.springframework.dao.DuplicateKeyException; 22 import org.springframework.dao.DuplicateKeyException;
16 import org.springframework.stereotype.Service; 23 import org.springframework.stereotype.Service;
17 import org.springframework.transaction.annotation.Transactional; 24 import org.springframework.transaction.annotation.Transactional;
@@ -19,12 +26,14 @@ import org.springframework.validation.annotation.Validated; @@ -19,12 +26,14 @@ import org.springframework.validation.annotation.Validated;
19 26
20 import java.math.BigDecimal; 27 import java.math.BigDecimal;
21 import java.util.ArrayList; 28 import java.util.ArrayList;
22 -import java.util.Comparator; 29 +import java.util.Collections;
23 import java.util.IdentityHashMap; 30 import java.util.IdentityHashMap;
24 import java.util.List; 31 import java.util.List;
25 import java.util.Map; 32 import java.util.Map;
26 33
27 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; 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 import static cn.iocoder.yudao.module.wms.enums.ErrorCodeConstants.*; 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,6 +43,7 @@ import static cn.iocoder.yudao.module.wms.enums.ErrorCodeConstants.*;
34 */ 43 */
35 @Service 44 @Service
36 @Validated 45 @Validated
  46 +@Slf4j
37 public class WmsInventoryServiceImpl implements WmsInventoryService { 47 public class WmsInventoryServiceImpl implements WmsInventoryService {
38 48
39 @Resource 49 @Resource
@@ -45,6 +55,11 @@ public class WmsInventoryServiceImpl implements WmsInventoryService { @@ -45,6 +55,11 @@ public class WmsInventoryServiceImpl implements WmsInventoryService {
45 private WmsInventoryHistoryService inventoryHistoryService; 55 private WmsInventoryHistoryService inventoryHistoryService;
46 56
47 @Resource 57 @Resource
  58 + private WmsItemSkuService itemSkuService;
  59 + @Resource
  60 + private WmsItemService itemService;
  61 +
  62 + @Resource
48 private WmsProperties wmsProperties; 63 private WmsProperties wmsProperties;
49 64
50 @Override 65 @Override
@@ -67,95 +82,135 @@ public class WmsInventoryServiceImpl implements WmsInventoryService { @@ -67,95 +82,135 @@ public class WmsInventoryServiceImpl implements WmsInventoryService {
67 return; 82 return;
68 } 83 }
69 84
70 - // 1. 按固定顺序变更库存余额,避免多 SKU/仓库并发操作时交叉拿锁 85 + // 1. 补齐并锁定本次涉及的库存余额行,再计算库存变更
71 Map<WmsInventoryChangeReqDTO.Item, Tuple> resultMap = changeInventoryList(reqDTO.getItems()); 86 Map<WmsInventoryChangeReqDTO.Item, Tuple> resultMap = changeInventoryList(reqDTO.getItems());
72 87
73 - // 2. 构建库存流水与批次库存明细 88 + // 2.1 构建库存流水与批次库存明细
74 boolean batchEnabled = wmsProperties.isBatchEnabled(); 89 boolean batchEnabled = wmsProperties.isBatchEnabled();
75 List<WmsInventoryHistoryDO> histories = new ArrayList<>(reqDTO.getItems().size()); 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 for (WmsInventoryChangeReqDTO.Item item : reqDTO.getItems()) { 93 for (WmsInventoryChangeReqDTO.Item item : reqDTO.getItems()) {
79 histories.add(buildInventoryHistory(reqDTO, item, resultMap.get(item))); 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 if (batchEnabled) { 106 if (batchEnabled) {
91 inventoryDetailService.decreaseInventoryDetailList(decreaseItems); 107 inventoryDetailService.decreaseInventoryDetailList(decreaseItems);
92 - inventoryDetailService.createInventoryDetailList(details); 108 + inventoryDetailService.createInventoryDetailList(increaseDetails);
93 } 109 }
94 -  
95 - // 4. 批量写入库存流水 110 + // 2.2.2 批量写入库存流水
96 inventoryHistoryService.createInventoryHistoryList(histories); 111 inventoryHistoryService.createInventoryHistoryList(histories);
97 } 112 }
98 113
99 /** 114 /**
100 * 批量变更库存余额 115 * 批量变更库存余额
101 * 116 *
102 - * 按 SKU、仓库、库区固定顺序拿锁;相同库存余额只更新一次,避免同一批操作内反复更新。 117 + * 1. 库存行按 ID 批量加锁
  118 + * 2. 库存变更先在内存计算并校验,全部通过后批量覆盖库存数量。
103 * 119 *
104 * @param items 库存变更明细列表 120 * @param items 库存变更明细列表
105 * @return 每条变更明细对应的变更前数量、变更后数量 121 * @return 每条变更明细对应的变更前数量、变更后数量
106 */ 122 */
107 private Map<WmsInventoryChangeReqDTO.Item, Tuple> changeInventoryList(List<WmsInventoryChangeReqDTO.Item> items) { 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 Map<WmsInventoryChangeReqDTO.Item, Tuple> resultMap = new IdentityHashMap<>(items.size()); 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 return resultMap; 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 try { 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 } catch (DuplicateKeyException ex) { 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 private WmsInventoryHistoryDO buildInventoryHistory(WmsInventoryChangeReqDTO reqDTO, 216 private WmsInventoryHistoryDO buildInventoryHistory(WmsInventoryChangeReqDTO reqDTO,
@@ -181,23 +236,32 @@ public class WmsInventoryServiceImpl implements WmsInventoryService { @@ -181,23 +236,32 @@ public class WmsInventoryServiceImpl implements WmsInventoryService {
181 .setOrderId(reqDTO.getOrderId()).setOrderNo(reqDTO.getOrderNo()).setOrderType(reqDTO.getOrderType()); 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,12 +16,17 @@ import cn.iocoder.yudao.module.wms.dal.mysql.md.item.WmsItemSkuMapper;
16 import cn.iocoder.yudao.module.wms.enums.inventory.WmsInventoryOrderTypeEnum; 16 import cn.iocoder.yudao.module.wms.enums.inventory.WmsInventoryOrderTypeEnum;
17 import cn.iocoder.yudao.module.wms.framework.config.WmsProperties; 17 import cn.iocoder.yudao.module.wms.framework.config.WmsProperties;
18 import cn.iocoder.yudao.module.wms.service.inventory.dto.WmsInventoryChangeReqDTO; 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 import jakarta.annotation.Resource; 21 import jakarta.annotation.Resource;
20 import org.junit.jupiter.api.Test; 22 import org.junit.jupiter.api.Test;
21 import org.springframework.context.annotation.Import; 23 import org.springframework.context.annotation.Import;
  24 +import org.springframework.test.context.bean.override.mockito.MockitoBean;
22 25
23 import java.math.BigDecimal; 26 import java.math.BigDecimal;
24 import java.util.ArrayList; 27 import java.util.ArrayList;
  28 +import java.util.Collections;
  29 +import java.util.Comparator;
25 import java.util.List; 30 import java.util.List;
26 31
27 import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; 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,6 +34,7 @@ import static cn.iocoder.yudao.module.wms.enums.ErrorCodeConstants.INVENTORY_DET
29 import static cn.iocoder.yudao.module.wms.enums.ErrorCodeConstants.INVENTORY_QUANTITY_NOT_ENOUGH; 34 import static cn.iocoder.yudao.module.wms.enums.ErrorCodeConstants.INVENTORY_QUANTITY_NOT_ENOUGH;
30 import static org.junit.jupiter.api.Assertions.assertEquals; 35 import static org.junit.jupiter.api.Assertions.assertEquals;
31 import static org.junit.jupiter.api.Assertions.assertNotNull; 36 import static org.junit.jupiter.api.Assertions.assertNotNull;
  37 +import static org.mockito.Mockito.when;
32 38
33 @Import({WmsInventoryServiceImpl.class, WmsInventoryDetailServiceImpl.class, WmsInventoryHistoryServiceImpl.class, 39 @Import({WmsInventoryServiceImpl.class, WmsInventoryDetailServiceImpl.class, WmsInventoryHistoryServiceImpl.class,
34 WmsProperties.class}) 40 WmsProperties.class})
@@ -50,6 +56,11 @@ public class WmsInventoryServiceImplTest extends BaseDbUnitTest { @@ -50,6 +56,11 @@ public class WmsInventoryServiceImplTest extends BaseDbUnitTest {
50 @Resource 56 @Resource
51 private WmsProperties wmsProperties; 57 private WmsProperties wmsProperties;
52 58
  59 + @MockitoBean
  60 + private WmsItemSkuService itemSkuService;
  61 + @MockitoBean
  62 + private WmsItemService itemService;
  63 +
53 @Test 64 @Test
54 public void testChangeInventory_createInventory() { 65 public void testChangeInventory_createInventory() {
55 // mock 数据 66 // mock 数据
@@ -105,6 +116,128 @@ public class WmsInventoryServiceImplTest extends BaseDbUnitTest { @@ -105,6 +116,128 @@ public class WmsInventoryServiceImplTest extends BaseDbUnitTest {
105 } 116 }
106 117
107 @Test 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 public void testChangeInventory_quantityNotEnough() { 241 public void testChangeInventory_quantityNotEnough() {
109 // mock 数据 242 // mock 数据
110 wmsProperties.setBatchEnable(false); 243 wmsProperties.setBatchEnable(false);
@@ -112,10 +245,26 @@ public class WmsInventoryServiceImplTest extends BaseDbUnitTest { @@ -112,10 +245,26 @@ public class WmsInventoryServiceImplTest extends BaseDbUnitTest {
112 WmsItemSkuDO sku = createSku(item.getId(), "SKU-001", "10kg 箱装"); 245 WmsItemSkuDO sku = createSku(item.getId(), "SKU-001", "10kg 箱装");
113 inventoryMapper.insert(createInventory(sku.getId(), 100L, 1001L, "2.00")); 246 inventoryMapper.insert(createInventory(sku.getId(), 100L, 1001L, "2.00"));
114 WmsInventoryChangeReqDTO reqDTO = createChangeReq(sku.getId(), 100L, 1001L, "-3.00"); 247 WmsInventoryChangeReqDTO reqDTO = createChangeReq(sku.getId(), 100L, 1001L, "-3.00");
  248 + mockItemSkuAndItem(item, sku);
115 249
116 // 调用,并断言 250 // 调用,并断言
117 assertServiceException(() -> inventoryService.changeInventory(reqDTO), INVENTORY_QUANTITY_NOT_ENOUGH, 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 @Test 270 @Test
@@ -256,6 +405,11 @@ public class WmsInventoryServiceImplTest extends BaseDbUnitTest { @@ -256,6 +405,11 @@ public class WmsInventoryServiceImplTest extends BaseDbUnitTest {
256 return sku; 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 private static WmsInventoryDO createInventory(Long skuId, Long warehouseId, Long areaId, String quantity) { 413 private static WmsInventoryDO createInventory(Long skuId, Long warehouseId, Long areaId, String quantity) {
260 return WmsInventoryDO.builder() 414 return WmsInventoryDO.builder()
261 .skuId(skuId) 415 .skuId(skuId)
@@ -281,7 +435,7 @@ public class WmsInventoryServiceImplTest extends BaseDbUnitTest { @@ -281,7 +435,7 @@ public class WmsInventoryServiceImplTest extends BaseDbUnitTest {
281 .setOrderId(1L) 435 .setOrderId(1L)
282 .setOrderNo("RK202605120001") 436 .setOrderNo("RK202605120001")
283 .setOrderType(WmsInventoryOrderTypeEnum.RECEIPT.getType()) 437 .setOrderType(WmsInventoryOrderTypeEnum.RECEIPT.getType())
284 - .setItems(List.of(new WmsInventoryChangeReqDTO.Item() 438 + .setItems(Collections.singletonList(new WmsInventoryChangeReqDTO.Item()
285 .setSkuId(skuId) 439 .setSkuId(skuId)
286 .setWarehouseId(warehouseId) 440 .setWarehouseId(warehouseId)
287 .setAreaId(areaId) 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,6 +16,7 @@ import org.springframework.context.annotation.Import;
16 import org.springframework.test.context.bean.override.mockito.MockitoBean; 16 import org.springframework.test.context.bean.override.mockito.MockitoBean;
17 17
18 import java.math.BigDecimal; 18 import java.math.BigDecimal;
  19 +import java.util.Arrays;
19 import java.util.List; 20 import java.util.List;
20 21
21 import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; 22 import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
@@ -129,7 +130,7 @@ public class WmsReceiptOrderDetailServiceImplTest extends BaseDbUnitTest { @@ -129,7 +130,7 @@ public class WmsReceiptOrderDetailServiceImplTest extends BaseDbUnitTest {
129 WmsReceiptOrderSaveReqVO reqVO = new WmsReceiptOrderSaveReqVO(); 130 WmsReceiptOrderSaveReqVO reqVO = new WmsReceiptOrderSaveReqVO();
130 reqVO.setWarehouseId(100L); 131 reqVO.setWarehouseId(100L);
131 reqVO.setAreaId(0L); 132 reqVO.setAreaId(0L);
132 - reqVO.setDetails(List.of(details)); 133 + reqVO.setDetails(Arrays.asList(details));
133 return reqVO; 134 return reqVO;
134 } 135 }
135 136