Commit 45dcdb1105df17efb44d21685f25c41c0e12d4eb
1 parent
e838637f
feat(wms):优化 inventory 的库存加锁,防止并发问题
Showing
11 changed files
with
339 additions
and
98 deletions
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<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<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<WmsInventoryDO> { |
| 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 | ... | ... |