Commit b690a5967e54fe4a3d36928d693a8cf0cc003246

Authored by YunaiV
1 parent 8843b782

✨ feat(mes): 新增物料消耗记录明细功能

实现物料消耗记录明细的增删改查功能,包括数据对象、Mapper、Service 接口及实现类的创建。支持按消耗记录编号和行编号查询消耗明细,增强了物料消耗管理的灵活性和可追溯性。
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/itemconsume/MesWmItemConsumeDetailDO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.mes.dal.dataobject.wm.itemconsume;
  2 +
  3 +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
  4 +import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdItemDO;
  5 +import cn.iocoder.yudao.module.mes.dal.dataobject.wm.batch.MesWmBatchDO;
  6 +import cn.iocoder.yudao.module.mes.dal.dataobject.wm.materialstock.MesWmMaterialStockDO;
  7 +import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseAreaDO;
  8 +import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseDO;
  9 +import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseLocationDO;
  10 +import com.baomidou.mybatisplus.annotation.KeySequence;
  11 +import com.baomidou.mybatisplus.annotation.TableId;
  12 +import com.baomidou.mybatisplus.annotation.TableName;
  13 +import lombok.*;
  14 +
  15 +import java.math.BigDecimal;
  16 +
  17 +/**
  18 + * MES 物料消耗记录明细 DO
  19 + *
  20 + * 记录 line 级别的消耗按线边库 FIFO 分配到具体批次的明细。
  21 + * 一条 line 可能拆分为多条 detail(当一个批次库存不够,需要从下一个批次继续分配时)。
  22 + *
  23 + * @author 芋道源码
  24 + */
  25 +@TableName("mes_wm_item_consume_detail")
  26 +@KeySequence("mes_wm_item_consume_detail_seq")
  27 +@Data
  28 +@EqualsAndHashCode(callSuper = true)
  29 +@ToString(callSuper = true)
  30 +@Builder
  31 +@NoArgsConstructor
  32 +@AllArgsConstructor
  33 +public class MesWmItemConsumeDetailDO extends BaseDO {
  34 +
  35 + /**
  36 + * 编号
  37 + */
  38 + @TableId
  39 + private Long id;
  40 + /**
  41 + * 消耗记录编号
  42 + *
  43 + * 关联 {@link MesWmItemConsumeDO#getId()}
  44 + */
  45 + private Long consumeId;
  46 + /**
  47 + * 消耗记录行编号
  48 + *
  49 + * 关联 {@link MesWmItemConsumeLineDO#getId()}
  50 + */
  51 + private Long lineId;
  52 + /**
  53 + * 库存台账编号
  54 + *
  55 + * 关联 {@link MesWmMaterialStockDO#getId()}
  56 + * 当线边库中无可用库存时为 null
  57 + */
  58 + private Long materialStockId;
  59 + /**
  60 + * 物料编号
  61 + *
  62 + * 关联 {@link MesMdItemDO#getId()}
  63 + */
  64 + private Long itemId;
  65 + /**
  66 + * 消耗数量
  67 + */
  68 + private BigDecimal quantity;
  69 + /**
  70 + * 批次编号
  71 + *
  72 + * 关联 {@link MesWmBatchDO#getId()}
  73 + */
  74 + private Long batchId;
  75 + /**
  76 + * 批次号
  77 + *
  78 + * 冗余 {@link MesWmBatchDO#getCode()}
  79 + */
  80 + private String batchCode;
  81 + /**
  82 + * 仓库编号
  83 + *
  84 + * 关联 {@link MesWmWarehouseDO#getId()}
  85 + */
  86 + private Long warehouseId;
  87 + /**
  88 + * 库区编号
  89 + *
  90 + * 关联 {@link MesWmWarehouseLocationDO#getId()}
  91 + */
  92 + private Long locationId;
  93 + /**
  94 + * 库位编号
  95 + *
  96 + * 关联 {@link MesWmWarehouseAreaDO#getId()}
  97 + */
  98 + private Long areaId;
  99 + /**
  100 + * 备注
  101 + */
  102 + private String remark;
  103 +
  104 +}
... ...
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/itemconsume/MesWmItemConsumeDetailMapper.java 0 → 100644
  1 +package cn.iocoder.yudao.module.mes.dal.mysql.wm.itemconsume;
  2 +
  3 +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
  4 +import cn.iocoder.yudao.module.mes.dal.dataobject.wm.itemconsume.MesWmItemConsumeDetailDO;
  5 +import org.apache.ibatis.annotations.Mapper;
  6 +
  7 +import java.util.List;
  8 +
  9 +/**
  10 + * MES 物料消耗记录明细 Mapper
  11 + *
  12 + * @author 芋道源码
  13 + */
  14 +@Mapper
  15 +public interface MesWmItemConsumeDetailMapper extends BaseMapperX<MesWmItemConsumeDetailDO> {
  16 +
  17 + default List<MesWmItemConsumeDetailDO> selectListByConsumeId(Long consumeId) {
  18 + return selectList(MesWmItemConsumeDetailDO::getConsumeId, consumeId);
  19 + }
  20 +
  21 + default List<MesWmItemConsumeDetailDO> selectListByLineId(Long lineId) {
  22 + return selectList(MesWmItemConsumeDetailDO::getLineId, lineId);
  23 + }
  24 +
  25 +}
... ...
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/itemconsume/MesWmItemConsumeDetailService.java 0 → 100644
  1 +package cn.iocoder.yudao.module.mes.service.wm.itemconsume;
  2 +
  3 +import cn.iocoder.yudao.module.mes.dal.dataobject.wm.itemconsume.MesWmItemConsumeDetailDO;
  4 +
  5 +import java.util.List;
  6 +
  7 +/**
  8 + * MES 物料消耗记录明细 Service 接口
  9 + *
  10 + * @author 芋道源码
  11 + */
  12 +public interface MesWmItemConsumeDetailService {
  13 +
  14 + /**
  15 + * 批量创建消耗明细
  16 + *
  17 + * @param details 消耗明细列表
  18 + */
  19 + void createItemConsumeDetailBatch(List<MesWmItemConsumeDetailDO> details);
  20 +
  21 + /**
  22 + * 查询指定消耗记录的所有明细
  23 + *
  24 + * @param consumeId 消耗记录编号
  25 + * @return 消耗明细列表
  26 + */
  27 + List<MesWmItemConsumeDetailDO> getItemConsumeDetailListByConsumeId(Long consumeId);
  28 +
  29 +}
... ...
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/itemconsume/MesWmItemConsumeDetailServiceImpl.java 0 → 100644
  1 +package cn.iocoder.yudao.module.mes.service.wm.itemconsume;
  2 +
  3 +import cn.iocoder.yudao.module.mes.dal.dataobject.wm.itemconsume.MesWmItemConsumeDetailDO;
  4 +import cn.iocoder.yudao.module.mes.dal.mysql.wm.itemconsume.MesWmItemConsumeDetailMapper;
  5 +import jakarta.annotation.Resource;
  6 +import org.springframework.stereotype.Service;
  7 +import org.springframework.validation.annotation.Validated;
  8 +
  9 +import java.util.List;
  10 +
  11 +/**
  12 + * MES 物料消耗记录明细 Service 实现类
  13 + *
  14 + * @author 芋道源码
  15 + */
  16 +@Service
  17 +@Validated
  18 +public class MesWmItemConsumeDetailServiceImpl implements MesWmItemConsumeDetailService {
  19 +
  20 + @Resource
  21 + private MesWmItemConsumeDetailMapper itemConsumeDetailMapper;
  22 +
  23 + @Override
  24 + public void createItemConsumeDetailBatch(List<MesWmItemConsumeDetailDO> details) {
  25 + itemConsumeDetailMapper.insertBatch(details);
  26 + }
  27 +
  28 + @Override
  29 + public List<MesWmItemConsumeDetailDO> getItemConsumeDetailListByConsumeId(Long consumeId) {
  30 + return itemConsumeDetailMapper.selectListByConsumeId(consumeId);
  31 + }
  32 +
  33 +}
... ...
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/itemconsume/MesWmItemConsumeLineServiceImpl.java
... ... @@ -5,8 +5,8 @@ import cn.iocoder.yudao.module.mes.controller.admin.wm.itemconsume.vo.MesWmItemC
5 5 import cn.iocoder.yudao.module.mes.dal.dataobject.wm.itemconsume.MesWmItemConsumeDO;
6 6 import cn.iocoder.yudao.module.mes.dal.dataobject.wm.itemconsume.MesWmItemConsumeLineDO;
7 7 import cn.iocoder.yudao.module.mes.dal.mysql.wm.itemconsume.MesWmItemConsumeLineMapper;
8   -import cn.iocoder.yudao.module.mes.dal.mysql.wm.itemconsume.MesWmItemConsumeMapper;
9 8 import jakarta.annotation.Resource;
  9 +import org.springframework.context.annotation.Lazy;
10 10 import org.springframework.stereotype.Service;
11 11 import org.springframework.validation.annotation.Validated;
12 12  
... ... @@ -25,7 +25,8 @@ public class MesWmItemConsumeLineServiceImpl implements MesWmItemConsumeLineServ
25 25 private MesWmItemConsumeLineMapper itemConsumeLineMapper;
26 26  
27 27 @Resource
28   - private MesWmItemConsumeMapper itemConsumeMapper;
  28 + @Lazy
  29 + private MesWmItemConsumeService itemConsumeService;
29 30  
30 31 @Override
31 32 public void createItemConsumeLineBatch(List<MesWmItemConsumeLineDO> lines) {
... ... @@ -33,10 +34,9 @@ public class MesWmItemConsumeLineServiceImpl implements MesWmItemConsumeLineServ
33 34 }
34 35  
35 36 @Override
36   - public PageResult<MesWmItemConsumeLineDO> getItemConsumeLinePage(
37   - MesWmItemConsumeLinePageReqVO pageReqVO) {
38   - // 1. 先通过 feedbackId 找到消耗头
39   - MesWmItemConsumeDO consume = itemConsumeMapper.selectByFeedbackId(pageReqVO.getFeedbackId());
  37 + public PageResult<MesWmItemConsumeLineDO> getItemConsumeLinePage(MesWmItemConsumeLinePageReqVO pageReqVO) {
  38 + // 1. 先通过 feedbackId 找到消耗头(通过 Service 调用,不直接注入 Mapper)
  39 + MesWmItemConsumeDO consume = itemConsumeService.getByFeedbackId(pageReqVO.getFeedbackId());
40 40 if (consume == null) {
41 41 return PageResult.empty();
42 42 }
... ...
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/itemconsume/MesWmItemConsumeService.java
... ... @@ -24,10 +24,18 @@ public interface MesWmItemConsumeService {
24 24 /**
25 25 * 完成物料消耗(库存扣减)
26 26 *
27   - * 遍历消耗行,扣减线边库库存,更新消耗单状态为已完成
  27 + * 遍历消耗明细(detail),按批次精确扣减线边库库存,更新消耗单状态为已完成
28 28 *
29 29 * @param consumeId 消耗记录编号
30 30 */
31 31 void finishItemConsume(Long consumeId);
32 32  
  33 + /**
  34 + * 根据报工记录编号获取消耗记录
  35 + *
  36 + * @param feedbackId 报工记录编号
  37 + * @return 消耗记录,不存在返回 null
  38 + */
  39 + MesWmItemConsumeDO getByFeedbackId(Long feedbackId);
  40 +
33 41 }
... ...
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/itemconsume/MesWmItemConsumeServiceImpl.java
1 1 package cn.iocoder.yudao.module.mes.service.wm.itemconsume;
2 2  
3 3 import cn.hutool.core.collection.CollUtil;
  4 +import cn.iocoder.yudao.module.mes.controller.admin.wm.materialstock.vo.MesWmMaterialStockListReqVO;
4 5 import cn.iocoder.yudao.module.mes.dal.dataobject.pro.feedback.MesProFeedbackDO;
5 6 import cn.iocoder.yudao.module.mes.dal.dataobject.pro.route.MesProRouteProductBomDO;
6 7 import cn.iocoder.yudao.module.mes.dal.dataobject.wm.itemconsume.MesWmItemConsumeDO;
  8 +import cn.iocoder.yudao.module.mes.dal.dataobject.wm.itemconsume.MesWmItemConsumeDetailDO;
7 9 import cn.iocoder.yudao.module.mes.dal.dataobject.wm.itemconsume.MesWmItemConsumeLineDO;
  10 +import cn.iocoder.yudao.module.mes.dal.dataobject.wm.materialstock.MesWmMaterialStockDO;
8 11 import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseAreaDO;
9 12 import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseDO;
10 13 import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseLocationDO;
11 14 import cn.iocoder.yudao.module.mes.dal.mysql.wm.itemconsume.MesWmItemConsumeMapper;
12   -import cn.iocoder.yudao.module.mes.enums.wm.MesWmItemConsumeStatusEnum;
13 15 import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
  16 +import cn.iocoder.yudao.module.mes.enums.wm.MesWmItemConsumeStatusEnum;
14 17 import cn.iocoder.yudao.module.mes.enums.wm.MesWmTransactionTypeEnum;
15 18 import cn.iocoder.yudao.module.mes.service.pro.route.MesProRouteProductBomService;
  19 +import cn.iocoder.yudao.module.mes.service.wm.materialstock.MesWmMaterialStockService;
16 20 import cn.iocoder.yudao.module.mes.service.wm.transaction.MesWmTransactionService;
17 21 import cn.iocoder.yudao.module.mes.service.wm.transaction.dto.MesWmTransactionSaveReqDTO;
18 22 import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseAreaService;
... ... @@ -22,7 +26,9 @@ import jakarta.annotation.Resource;
22 26 import org.springframework.stereotype.Service;
23 27 import org.springframework.validation.annotation.Validated;
24 28  
  29 +import java.math.BigDecimal;
25 30 import java.time.LocalDateTime;
  31 +import java.util.ArrayList;
26 32 import java.util.List;
27 33  
28 34 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
... ... @@ -41,6 +47,8 @@ public class MesWmItemConsumeServiceImpl implements MesWmItemConsumeService {
41 47 @Resource
42 48 private MesWmItemConsumeLineService itemConsumeLineService;
43 49 @Resource
  50 + private MesWmItemConsumeDetailService itemConsumeDetailService;
  51 + @Resource
44 52 private MesProRouteProductBomService routeProductBomService;
45 53 @Resource
46 54 private MesWmTransactionService wmTransactionService;
... ... @@ -50,6 +58,8 @@ public class MesWmItemConsumeServiceImpl implements MesWmItemConsumeService {
50 58 private MesWmWarehouseLocationService locationService;
51 59 @Resource
52 60 private MesWmWarehouseAreaService areaService;
  61 + @Resource
  62 + private MesWmMaterialStockService materialStockService;
53 63  
54 64 @Override
55 65 public MesWmItemConsumeDO generateItemConsume(MesProFeedbackDO feedback) {
... ... @@ -60,43 +70,126 @@ public class MesWmItemConsumeServiceImpl implements MesWmItemConsumeService {
60 70 return null;
61 71 }
62 72  
63   - // 2.1 生成消耗单头
  73 + // 2. 获取虚拟线边库信息
  74 + MesWmWarehouseDO virtualWarehouse = warehouseService.getWarehouseByCode(MesWmWarehouseDO.WIP_VIRTUAL_WAREHOUSE);
  75 + MesWmWarehouseLocationDO virtualLocation = locationService.getWarehouseLocationByCode(
  76 + MesWmWarehouseLocationDO.WIP_VIRTUAL_LOCATION);
  77 + MesWmWarehouseAreaDO virtualArea = areaService.getWarehouseAreaByCode(MesWmWarehouseAreaDO.WIP_VIRTUAL_AREA);
  78 +
  79 + // 3. 生成消耗单头
64 80 MesWmItemConsumeDO consume = MesWmItemConsumeDO.builder()
65 81 .workOrderId(feedback.getWorkOrderId()).taskId(feedback.getTaskId())
66 82 .workstationId(feedback.getWorkstationId()).processId(feedback.getProcessId())
67 83 .feedbackId(feedback.getId()).consumeDate(LocalDateTime.now())
68 84 .status(MesWmItemConsumeStatusEnum.PREPARE.getStatus()).build();
69 85 itemConsumeMapper.insert(consume);
70   - // 2.2 批量生成消耗行(消耗数量 = BOM 用料比例 × 报工数量)
  86 +
  87 + // 4. 批量生成消耗行(消耗数量 = BOM 用料比例 × 报工数量)
71 88 List<MesWmItemConsumeLineDO> lines = convertList(boms, bom -> MesWmItemConsumeLineDO.builder()
72 89 .consumeId(consume.getId()).itemId(bom.getItemId())
73 90 .quantity(bom.getQuantity().multiply(feedback.getFeedbackQuantity())).build());
74 91 itemConsumeLineService.createItemConsumeLineBatch(lines);
75   - // TODO @AI(from codex):对齐,这里还需要按线边库/FIFO 生成消耗明细,并在库存不足时回退为无批次消耗明细。
  92 +
  93 + // 5. 按线边库 FIFO 生成消耗明细
  94 + List<MesWmItemConsumeDetailDO> allDetails = new ArrayList<>();
  95 + for (MesWmItemConsumeLineDO line : lines) {
  96 + generateDetailForLine(line, consume.getId(),
  97 + virtualWarehouse, virtualLocation, virtualArea, allDetails);
  98 + }
  99 + if (CollUtil.isNotEmpty(allDetails)) {
  100 + itemConsumeDetailService.createItemConsumeDetailBatch(allDetails);
  101 + }
76 102 return consume;
77 103 }
78 104  
  105 + /**
  106 + * 为单条消耗行按 FIFO 分配线边库批次,生成消耗明细
  107 + *
  108 + * 规则:
  109 + * 1. 查询该物料在虚拟线边库中的库存记录,按入库时间升序(FIFO)
  110 + * 2. 依次从各批次分配,直到消耗量满足
  111 + * 3. 如果线边库无库存或库存不足,剩余数量生成无批次明细(待后续盘库核销)
  112 + */
  113 + private void generateDetailForLine(MesWmItemConsumeLineDO line, Long consumeId,
  114 + MesWmWarehouseDO warehouse, MesWmWarehouseLocationDO location, MesWmWarehouseAreaDO area,
  115 + List<MesWmItemConsumeDetailDO> allDetails) {
  116 + // 查询该物料在线边库中的库存(按 receiptTime 升序 = FIFO;已排除 quantity = 0)
  117 + MesWmMaterialStockListReqVO stockQuery = new MesWmMaterialStockListReqVO().setItemId(line.getItemId())
  118 + .setWarehouseId(warehouse.getId()).setLocationId(location.getId()).setAreaId(area.getId());
  119 + List<MesWmMaterialStockDO> stocks = materialStockService.getMaterialStockList(stockQuery)
  120 + .stream().filter(s -> s.getQuantity().compareTo(BigDecimal.ZERO) > 0).toList();
  121 +
  122 + if (CollUtil.isEmpty(stocks)) {
  123 + // 线边库无该物料库存 → 生成不带批次的明细
  124 + allDetails.add(buildDetail(consumeId, line.getId(), null, line.getItemId(),
  125 + line.getQuantity(), null, null, warehouse, location, area));
  126 + return;
  127 + }
  128 +
  129 + // 按 FIFO 依次从各批次分配
  130 + BigDecimal remaining = line.getQuantity();
  131 + for (MesWmMaterialStockDO stock : stocks) {
  132 + if (remaining.compareTo(BigDecimal.ZERO) <= 0) {
  133 + break;
  134 + }
  135 + BigDecimal allocate = stock.getQuantity().min(remaining);
  136 + allDetails.add(buildDetail(consumeId, line.getId(), stock.getId(), line.getItemId(),
  137 + allocate, stock.getBatchId(), stock.getBatchCode(), warehouse, location, area));
  138 + remaining = remaining.subtract(allocate);
  139 + }
  140 +
  141 + // 库存不足时,剩余数量生成无批次明细(待后续盘库手工核销)
  142 + if (remaining.compareTo(BigDecimal.ZERO) > 0) {
  143 + allDetails.add(buildDetail(consumeId, line.getId(), null, line.getItemId(),
  144 + remaining, null, null, warehouse, location, area));
  145 + }
  146 + }
  147 +
  148 + private static MesWmItemConsumeDetailDO buildDetail(Long consumeId, Long lineId, Long materialStockId,
  149 + Long itemId, BigDecimal quantity,
  150 + Long batchId, String batchCode,
  151 + MesWmWarehouseDO warehouse,
  152 + MesWmWarehouseLocationDO location,
  153 + MesWmWarehouseAreaDO area) {
  154 + return MesWmItemConsumeDetailDO.builder()
  155 + .consumeId(consumeId).lineId(lineId).materialStockId(materialStockId)
  156 + .itemId(itemId).quantity(quantity)
  157 + .batchId(batchId).batchCode(batchCode)
  158 + .warehouseId(warehouse.getId()).locationId(location.getId()).areaId(area.getId())
  159 + .build();
  160 + }
  161 +
79 162 @Override
80 163 public void finishItemConsume(Long consumeId) {
81   - MesWmWarehouseDO virtualWarehouse = warehouseService.getWarehouseByCode(MesWmWarehouseDO.WIP_VIRTUAL_WAREHOUSE);
82   - MesWmWarehouseLocationDO virtualLocation = locationService.getWarehouseLocationByCode(MesWmWarehouseLocationDO.WIP_VIRTUAL_LOCATION);
83   - MesWmWarehouseAreaDO virtualArea = areaService.getWarehouseAreaByCode(MesWmWarehouseAreaDO.WIP_VIRTUAL_AREA);
  164 + // 1. 查询消耗明细(detail 级别,包含精确的批次/仓库信息)
  165 + List<MesWmItemConsumeDetailDO> details = itemConsumeDetailService
  166 + .getItemConsumeDetailListByConsumeId(consumeId);
84 167  
85   - // 遍历消耗行,创建库存事务(从线边库扣减)
86   - List<MesWmItemConsumeLineDO> lines = itemConsumeLineService.getItemConsumeLineListByConsumeId(consumeId);
87   - for (MesWmItemConsumeLineDO line : lines) {
  168 + // 2. 遍历明细,创建库存事务(按批次精确扣减线边库)
  169 + for (MesWmItemConsumeDetailDO detail : details) {
88 170 wmTransactionService.createTransaction(new MesWmTransactionSaveReqDTO()
89   - .setType(MesWmTransactionTypeEnum.OUT.getType()).setItemId(line.getItemId())
90   - .setQuantity(line.getQuantity().negate()) // 库存减少
91   - .setWarehouseId(virtualWarehouse.getId()).setLocationId(virtualLocation.getId()).setAreaId(virtualArea.getId())
  171 + .setType(MesWmTransactionTypeEnum.OUT.getType())
  172 + .setItemId(detail.getItemId())
  173 + .setQuantity(detail.getQuantity().negate())
  174 + .setBatchId(detail.getBatchId()).setBatchCode(detail.getBatchCode())
  175 + .setWarehouseId(detail.getWarehouseId())
  176 + .setLocationId(detail.getLocationId())
  177 + .setAreaId(detail.getAreaId())
92 178 .setCheckFlag(false) // 线边库允许负库存
93   - .setBizType(MesBizTypeConstants.WM_ITEM_CONSUME).setBizId(consumeId)
94   - .setBizCode("").setBizLineId(line.getId()));
  179 + .setBizType(MesBizTypeConstants.WM_ITEM_CONSUME)
  180 + .setBizId(consumeId)
  181 + .setBizCode("")
  182 + .setBizLineId(detail.getLineId()));
95 183 }
96 184  
97   - // 更新消耗单状态为已完成
  185 + // 3. 更新消耗单状态为已完成
98 186 itemConsumeMapper.updateById(MesWmItemConsumeDO.builder()
99 187 .id(consumeId).status(MesWmItemConsumeStatusEnum.FINISHED.getStatus()).build());
100 188 }
101 189  
  190 + @Override
  191 + public MesWmItemConsumeDO getByFeedbackId(Long feedbackId) {
  192 + return itemConsumeMapper.selectByFeedbackId(feedbackId);
  193 + }
  194 +
102 195 }
... ...
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/itemconsume/MesWmItemConsumeServiceImplTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.mes.service.wm.itemconsume;
  2 +
  3 +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
  4 +import cn.iocoder.yudao.module.mes.controller.admin.wm.materialstock.vo.MesWmMaterialStockListReqVO;
  5 +import cn.iocoder.yudao.module.mes.dal.dataobject.pro.feedback.MesProFeedbackDO;
  6 +import cn.iocoder.yudao.module.mes.dal.dataobject.pro.route.MesProRouteProductBomDO;
  7 +import cn.iocoder.yudao.module.mes.dal.dataobject.wm.itemconsume.MesWmItemConsumeDO;
  8 +import cn.iocoder.yudao.module.mes.dal.dataobject.wm.itemconsume.MesWmItemConsumeDetailDO;
  9 +import cn.iocoder.yudao.module.mes.dal.dataobject.wm.itemconsume.MesWmItemConsumeLineDO;
  10 +import cn.iocoder.yudao.module.mes.dal.dataobject.wm.materialstock.MesWmMaterialStockDO;
  11 +import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseAreaDO;
  12 +import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseDO;
  13 +import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseLocationDO;
  14 +import cn.iocoder.yudao.module.mes.dal.mysql.wm.itemconsume.MesWmItemConsumeMapper;
  15 +import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
  16 +import cn.iocoder.yudao.module.mes.enums.wm.MesWmItemConsumeStatusEnum;
  17 +import cn.iocoder.yudao.module.mes.enums.wm.MesWmTransactionTypeEnum;
  18 +import cn.iocoder.yudao.module.mes.service.pro.route.MesProRouteProductBomService;
  19 +import cn.iocoder.yudao.module.mes.service.wm.materialstock.MesWmMaterialStockService;
  20 +import cn.iocoder.yudao.module.mes.service.wm.transaction.MesWmTransactionService;
  21 +import cn.iocoder.yudao.module.mes.service.wm.transaction.dto.MesWmTransactionSaveReqDTO;
  22 +import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseAreaService;
  23 +import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseLocationService;
  24 +import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseService;
  25 +import jakarta.annotation.Resource;
  26 +import org.junit.jupiter.api.BeforeEach;
  27 +import org.junit.jupiter.api.Test;
  28 +import org.mockito.ArgumentCaptor;
  29 +import org.springframework.context.annotation.Import;
  30 +import org.springframework.test.context.bean.override.mockito.MockitoBean;
  31 +
  32 +import java.math.BigDecimal;
  33 +import java.util.Arrays;
  34 +import java.util.Collections;
  35 +import java.util.List;
  36 +import java.util.concurrent.atomic.AtomicLong;
  37 +
  38 +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId;
  39 +import static org.junit.jupiter.api.Assertions.*;
  40 +import static org.mockito.ArgumentMatchers.any;
  41 +import static org.mockito.ArgumentMatchers.argThat;
  42 +import static org.mockito.Mockito.*;
  43 +
  44 +/**
  45 + * {@link MesWmItemConsumeServiceImpl} 的单元测试
  46 + *
  47 + * @author 芋道源码
  48 + */
  49 +@Import(MesWmItemConsumeServiceImpl.class)
  50 +public class MesWmItemConsumeServiceImplTest extends BaseDbUnitTest {
  51 +
  52 + @Resource
  53 + private MesWmItemConsumeServiceImpl itemConsumeService;
  54 +
  55 + @Resource
  56 + private MesWmItemConsumeMapper itemConsumeMapper;
  57 +
  58 + @MockitoBean
  59 + private MesWmItemConsumeLineService itemConsumeLineService;
  60 + @MockitoBean
  61 + private MesWmItemConsumeDetailService itemConsumeDetailService;
  62 + @MockitoBean
  63 + private MesProRouteProductBomService routeProductBomService;
  64 + @MockitoBean
  65 + private MesWmTransactionService wmTransactionService;
  66 + @MockitoBean
  67 + private MesWmWarehouseService warehouseService;
  68 + @MockitoBean
  69 + private MesWmWarehouseLocationService locationService;
  70 + @MockitoBean
  71 + private MesWmWarehouseAreaService areaService;
  72 + @MockitoBean
  73 + private MesWmMaterialStockService materialStockService;
  74 +
  75 + // ========== 公共 mock 数据 ==========
  76 +
  77 + private static final Long WAREHOUSE_ID = 100L;
  78 + private static final Long LOCATION_ID = 200L;
  79 + private static final Long AREA_ID = 300L;
  80 +
  81 + private MesWmWarehouseDO virtualWarehouse;
  82 + private MesWmWarehouseLocationDO virtualLocation;
  83 + private MesWmWarehouseAreaDO virtualArea;
  84 +
  85 + @BeforeEach
  86 + public void setUp() {
  87 + virtualWarehouse = new MesWmWarehouseDO();
  88 + virtualWarehouse.setId(WAREHOUSE_ID);
  89 + virtualWarehouse.setCode(MesWmWarehouseDO.WIP_VIRTUAL_WAREHOUSE);
  90 +
  91 + virtualLocation = new MesWmWarehouseLocationDO();
  92 + virtualLocation.setId(LOCATION_ID);
  93 + virtualLocation.setCode(MesWmWarehouseLocationDO.WIP_VIRTUAL_LOCATION);
  94 +
  95 + virtualArea = new MesWmWarehouseAreaDO();
  96 + virtualArea.setId(AREA_ID);
  97 + virtualArea.setCode(MesWmWarehouseAreaDO.WIP_VIRTUAL_AREA);
  98 +
  99 + when(warehouseService.getWarehouseByCode(MesWmWarehouseDO.WIP_VIRTUAL_WAREHOUSE))
  100 + .thenReturn(virtualWarehouse);
  101 + when(locationService.getWarehouseLocationByCode(MesWmWarehouseLocationDO.WIP_VIRTUAL_LOCATION))
  102 + .thenReturn(virtualLocation);
  103 + when(areaService.getWarehouseAreaByCode(MesWmWarehouseAreaDO.WIP_VIRTUAL_AREA))
  104 + .thenReturn(virtualArea);
  105 +
  106 + // mock createItemConsumeLineBatch:模拟 insertBatch 后填充 ID
  107 + AtomicLong lineIdSeq = new AtomicLong(1000L);
  108 + doAnswer(invocation -> {
  109 + List<MesWmItemConsumeLineDO> lines = invocation.getArgument(0);
  110 + for (MesWmItemConsumeLineDO line : lines) {
  111 + line.setId(lineIdSeq.getAndIncrement());
  112 + }
  113 + return null;
  114 + }).when(itemConsumeLineService).createItemConsumeLineBatch(any());
  115 + }
  116 +
  117 + private MesProFeedbackDO buildFeedback() {
  118 + return MesProFeedbackDO.builder()
  119 + .id(randomLongId()).workOrderId(randomLongId()).taskId(randomLongId())
  120 + .workstationId(randomLongId()).processId(randomLongId()).routeId(randomLongId())
  121 + .itemId(randomLongId()).feedbackQuantity(BigDecimal.TEN)
  122 + .build();
  123 + }
  124 +
  125 + // ========== generateItemConsume ==========
  126 +
  127 + @Test
  128 + public void testGenerateItemConsume_noBom() {
  129 + // 准备
  130 + MesProFeedbackDO feedback = buildFeedback();
  131 + when(routeProductBomService.getRouteProductBomList(
  132 + feedback.getRouteId(), feedback.getProcessId(), feedback.getItemId()))
  133 + .thenReturn(Collections.emptyList());
  134 +
  135 + // 调用
  136 + MesWmItemConsumeDO result = itemConsumeService.generateItemConsume(feedback);
  137 +
  138 + // 断言
  139 + assertNull(result);
  140 + verify(itemConsumeLineService, never()).createItemConsumeLineBatch(any());
  141 + verify(itemConsumeDetailService, never()).createItemConsumeDetailBatch(any());
  142 + }
  143 +
  144 + @Test
  145 + public void testGenerateItemConsume_noStock() {
  146 + // 准备:1 个 BOM 物料,线边库无库存
  147 + MesProFeedbackDO feedback = buildFeedback();
  148 + Long bomItemId = randomLongId();
  149 + MesProRouteProductBomDO bom = new MesProRouteProductBomDO();
  150 + bom.setItemId(bomItemId);
  151 + bom.setQuantity(new BigDecimal("2")); // 用料比例 = 2
  152 + when(routeProductBomService.getRouteProductBomList(any(), any(), any()))
  153 + .thenReturn(List.of(bom));
  154 + when(materialStockService.getMaterialStockList(any(MesWmMaterialStockListReqVO.class)))
  155 + .thenReturn(Collections.emptyList());
  156 +
  157 + // 调用
  158 + MesWmItemConsumeDO result = itemConsumeService.generateItemConsume(feedback);
  159 +
  160 + // 断言 1:消耗单头已入库
  161 + assertNotNull(result);
  162 + assertNotNull(result.getId());
  163 + assertEquals(MesWmItemConsumeStatusEnum.PREPARE.getStatus(), result.getStatus());
  164 + assertEquals(feedback.getId(), result.getFeedbackId());
  165 +
  166 + // 断言 2:消耗行正确
  167 + @SuppressWarnings("unchecked")
  168 + ArgumentCaptor<List<MesWmItemConsumeLineDO>> lineCaptor = ArgumentCaptor.forClass(List.class);
  169 + verify(itemConsumeLineService).createItemConsumeLineBatch(lineCaptor.capture());
  170 + List<MesWmItemConsumeLineDO> lines = lineCaptor.getValue();
  171 + assertEquals(1, lines.size());
  172 + assertEquals(bomItemId, lines.get(0).getItemId());
  173 + // 消耗数量 = 2 × 10 = 20
  174 + assertEquals(0, new BigDecimal("20").compareTo(lines.get(0).getQuantity()));
  175 +
  176 + // 断言 3:生成 1 条无批次的 detail
  177 + @SuppressWarnings("unchecked")
  178 + ArgumentCaptor<List<MesWmItemConsumeDetailDO>> detailCaptor = ArgumentCaptor.forClass(List.class);
  179 + verify(itemConsumeDetailService).createItemConsumeDetailBatch(detailCaptor.capture());
  180 + List<MesWmItemConsumeDetailDO> details = detailCaptor.getValue();
  181 + assertEquals(1, details.size());
  182 + MesWmItemConsumeDetailDO detail = details.get(0);
  183 + assertEquals(bomItemId, detail.getItemId());
  184 + assertEquals(0, new BigDecimal("20").compareTo(detail.getQuantity()));
  185 + assertNull(detail.getMaterialStockId());
  186 + assertNull(detail.getBatchId());
  187 + assertEquals(WAREHOUSE_ID, detail.getWarehouseId());
  188 + assertEquals(LOCATION_ID, detail.getLocationId());
  189 + assertEquals(AREA_ID, detail.getAreaId());
  190 + }
  191 +
  192 + @Test
  193 + public void testGenerateItemConsume_singleBatchSufficient() {
  194 + // 准备:1 个 BOM 物料,线边库有 1 个批次且库存充足
  195 + MesProFeedbackDO feedback = buildFeedback();
  196 + Long bomItemId = randomLongId();
  197 + MesProRouteProductBomDO bom = new MesProRouteProductBomDO();
  198 + bom.setItemId(bomItemId);
  199 + bom.setQuantity(new BigDecimal("3")); // 消耗 = 3 × 10 = 30
  200 + when(routeProductBomService.getRouteProductBomList(any(), any(), any()))
  201 + .thenReturn(List.of(bom));
  202 +
  203 + Long stockId = randomLongId();
  204 + Long batchId = randomLongId();
  205 + MesWmMaterialStockDO stock = new MesWmMaterialStockDO();
  206 + stock.setId(stockId);
  207 + stock.setItemId(bomItemId);
  208 + stock.setQuantity(new BigDecimal("50")); // 库存 50 > 需求 30
  209 + stock.setBatchId(batchId);
  210 + stock.setBatchCode("BATCH-001");
  211 + when(materialStockService.getMaterialStockList(any(MesWmMaterialStockListReqVO.class)))
  212 + .thenReturn(List.of(stock));
  213 +
  214 + // 调用
  215 + MesWmItemConsumeDO result = itemConsumeService.generateItemConsume(feedback);
  216 +
  217 + // 断言:生成 1 条带批次的 detail
  218 + assertNotNull(result);
  219 + @SuppressWarnings("unchecked")
  220 + ArgumentCaptor<List<MesWmItemConsumeDetailDO>> detailCaptor = ArgumentCaptor.forClass(List.class);
  221 + verify(itemConsumeDetailService).createItemConsumeDetailBatch(detailCaptor.capture());
  222 + List<MesWmItemConsumeDetailDO> details = detailCaptor.getValue();
  223 + assertEquals(1, details.size());
  224 + MesWmItemConsumeDetailDO detail = details.get(0);
  225 + assertEquals(0, new BigDecimal("30").compareTo(detail.getQuantity()));
  226 + assertEquals(stockId, detail.getMaterialStockId());
  227 + assertEquals(batchId, detail.getBatchId());
  228 + assertEquals("BATCH-001", detail.getBatchCode());
  229 + }
  230 +
  231 + @Test
  232 + public void testGenerateItemConsume_multiBatchFifo() {
  233 + // 准备:1 个 BOM 物料,需消耗 30,线边库有 2 个批次(20 + 15)
  234 + MesProFeedbackDO feedback = buildFeedback();
  235 + Long bomItemId = randomLongId();
  236 + MesProRouteProductBomDO bom = new MesProRouteProductBomDO();
  237 + bom.setItemId(bomItemId);
  238 + bom.setQuantity(new BigDecimal("3")); // 消耗 = 3 × 10 = 30
  239 + when(routeProductBomService.getRouteProductBomList(any(), any(), any()))
  240 + .thenReturn(List.of(bom));
  241 +
  242 + Long stockId1 = randomLongId();
  243 + Long stockId2 = randomLongId();
  244 + MesWmMaterialStockDO stock1 = new MesWmMaterialStockDO();
  245 + stock1.setId(stockId1);
  246 + stock1.setItemId(bomItemId);
  247 + stock1.setQuantity(new BigDecimal("20")); // 先进先出:先消耗这批
  248 + stock1.setBatchId(1L);
  249 + stock1.setBatchCode("BATCH-001");
  250 +
  251 + MesWmMaterialStockDO stock2 = new MesWmMaterialStockDO();
  252 + stock2.setId(stockId2);
  253 + stock2.setItemId(bomItemId);
  254 + stock2.setQuantity(new BigDecimal("15"));
  255 + stock2.setBatchId(2L);
  256 + stock2.setBatchCode("BATCH-002");
  257 +
  258 + // FIFO 顺序返回
  259 + when(materialStockService.getMaterialStockList(any(MesWmMaterialStockListReqVO.class)))
  260 + .thenReturn(Arrays.asList(stock1, stock2));
  261 +
  262 + // 调用
  263 + MesWmItemConsumeDO result = itemConsumeService.generateItemConsume(feedback);
  264 +
  265 + // 断言:生成 2 条带批次的 detail(20 + 10)
  266 + assertNotNull(result);
  267 + @SuppressWarnings("unchecked")
  268 + ArgumentCaptor<List<MesWmItemConsumeDetailDO>> detailCaptor = ArgumentCaptor.forClass(List.class);
  269 + verify(itemConsumeDetailService).createItemConsumeDetailBatch(detailCaptor.capture());
  270 + List<MesWmItemConsumeDetailDO> details = detailCaptor.getValue();
  271 + assertEquals(2, details.size());
  272 +
  273 + // 第 1 条:批次 1 全部消耗 20
  274 + assertEquals(stockId1, details.get(0).getMaterialStockId());
  275 + assertEquals(0, new BigDecimal("20").compareTo(details.get(0).getQuantity()));
  276 + assertEquals("BATCH-001", details.get(0).getBatchCode());
  277 +
  278 + // 第 2 条:批次 2 消耗 10(30 - 20 = 10)
  279 + assertEquals(stockId2, details.get(1).getMaterialStockId());
  280 + assertEquals(0, new BigDecimal("10").compareTo(details.get(1).getQuantity()));
  281 + assertEquals("BATCH-002", details.get(1).getBatchCode());
  282 + }
  283 +
  284 + @Test
  285 + public void testGenerateItemConsume_insufficientStock() {
  286 + // 准备:需消耗 30,线边库只有 1 个批次(库存 18),不足部分生成无批次 detail
  287 + MesProFeedbackDO feedback = buildFeedback();
  288 + Long bomItemId = randomLongId();
  289 + MesProRouteProductBomDO bom = new MesProRouteProductBomDO();
  290 + bom.setItemId(bomItemId);
  291 + bom.setQuantity(new BigDecimal("3")); // 消耗 = 3 × 10 = 30
  292 + when(routeProductBomService.getRouteProductBomList(any(), any(), any()))
  293 + .thenReturn(List.of(bom));
  294 +
  295 + Long stockId = randomLongId();
  296 + MesWmMaterialStockDO stock = new MesWmMaterialStockDO();
  297 + stock.setId(stockId);
  298 + stock.setItemId(bomItemId);
  299 + stock.setQuantity(new BigDecimal("18")); // 不够 30
  300 + stock.setBatchId(1L);
  301 + stock.setBatchCode("BATCH-001");
  302 + when(materialStockService.getMaterialStockList(any(MesWmMaterialStockListReqVO.class)))
  303 + .thenReturn(List.of(stock));
  304 +
  305 + // 调用
  306 + MesWmItemConsumeDO result = itemConsumeService.generateItemConsume(feedback);
  307 +
  308 + // 断言:2 条 detail(18 带批次 + 12 无批次)
  309 + assertNotNull(result);
  310 + @SuppressWarnings("unchecked")
  311 + ArgumentCaptor<List<MesWmItemConsumeDetailDO>> detailCaptor = ArgumentCaptor.forClass(List.class);
  312 + verify(itemConsumeDetailService).createItemConsumeDetailBatch(detailCaptor.capture());
  313 + List<MesWmItemConsumeDetailDO> details = detailCaptor.getValue();
  314 + assertEquals(2, details.size());
  315 +
  316 + // 第 1 条:批次全部消耗 18
  317 + assertEquals(stockId, details.get(0).getMaterialStockId());
  318 + assertEquals(0, new BigDecimal("18").compareTo(details.get(0).getQuantity()));
  319 + assertEquals("BATCH-001", details.get(0).getBatchCode());
  320 +
  321 + // 第 2 条:剩余 12 无批次
  322 + assertNull(details.get(1).getMaterialStockId());
  323 + assertEquals(0, new BigDecimal("12").compareTo(details.get(1).getQuantity()));
  324 + assertNull(details.get(1).getBatchId());
  325 + }
  326 +
  327 + @Test
  328 + public void testGenerateItemConsume_multipleBomItems() {
  329 + // 准备:2 个 BOM 物料
  330 + MesProFeedbackDO feedback = buildFeedback();
  331 + Long itemA = randomLongId();
  332 + Long itemB = randomLongId();
  333 +
  334 + MesProRouteProductBomDO bomA = new MesProRouteProductBomDO();
  335 + bomA.setItemId(itemA);
  336 + bomA.setQuantity(new BigDecimal("1")); // 消耗 = 1 × 10 = 10
  337 +
  338 + MesProRouteProductBomDO bomB = new MesProRouteProductBomDO();
  339 + bomB.setItemId(itemB);
  340 + bomB.setQuantity(new BigDecimal("2")); // 消耗 = 2 × 10 = 20
  341 +
  342 + when(routeProductBomService.getRouteProductBomList(any(), any(), any()))
  343 + .thenReturn(Arrays.asList(bomA, bomB));
  344 +
  345 + // 物料 A 有库存,物料 B 无库存
  346 + MesWmMaterialStockDO stockA = new MesWmMaterialStockDO();
  347 + stockA.setId(randomLongId());
  348 + stockA.setItemId(itemA);
  349 + stockA.setQuantity(new BigDecimal("100"));
  350 + stockA.setBatchId(1L);
  351 + stockA.setBatchCode("BATCH-A");
  352 +
  353 + when(materialStockService.getMaterialStockList(argThat((MesWmMaterialStockListReqVO req) ->
  354 + req != null && itemA.equals(req.getItemId()))))
  355 + .thenReturn(List.of(stockA));
  356 + when(materialStockService.getMaterialStockList(argThat((MesWmMaterialStockListReqVO req) ->
  357 + req != null && itemB.equals(req.getItemId()))))
  358 + .thenReturn(Collections.emptyList());
  359 +
  360 + // 调用
  361 + MesWmItemConsumeDO result = itemConsumeService.generateItemConsume(feedback);
  362 +
  363 + // 断言:2 条消耗行,2 条 detail
  364 + assertNotNull(result);
  365 +
  366 + @SuppressWarnings("unchecked")
  367 + ArgumentCaptor<List<MesWmItemConsumeLineDO>> lineCaptor = ArgumentCaptor.forClass(List.class);
  368 + verify(itemConsumeLineService).createItemConsumeLineBatch(lineCaptor.capture());
  369 + assertEquals(2, lineCaptor.getValue().size());
  370 +
  371 + @SuppressWarnings("unchecked")
  372 + ArgumentCaptor<List<MesWmItemConsumeDetailDO>> detailCaptor = ArgumentCaptor.forClass(List.class);
  373 + verify(itemConsumeDetailService).createItemConsumeDetailBatch(detailCaptor.capture());
  374 + List<MesWmItemConsumeDetailDO> details = detailCaptor.getValue();
  375 + assertEquals(2, details.size());
  376 +
  377 + // 物料 A:有批次
  378 + MesWmItemConsumeDetailDO detailA = details.stream()
  379 + .filter(d -> itemA.equals(d.getItemId())).findFirst().orElseThrow();
  380 + assertEquals(0, new BigDecimal("10").compareTo(detailA.getQuantity()));
  381 + assertNotNull(detailA.getMaterialStockId());
  382 + assertEquals("BATCH-A", detailA.getBatchCode());
  383 +
  384 + // 物料 B:无批次
  385 + MesWmItemConsumeDetailDO detailB = details.stream()
  386 + .filter(d -> itemB.equals(d.getItemId())).findFirst().orElseThrow();
  387 + assertEquals(0, new BigDecimal("20").compareTo(detailB.getQuantity()));
  388 + assertNull(detailB.getMaterialStockId());
  389 + }
  390 +
  391 + // ========== finishItemConsume ==========
  392 +
  393 + @Test
  394 + public void testFinishItemConsume_withDetails() {
  395 + // 准备:插入一条消耗记录
  396 + MesWmItemConsumeDO consume = MesWmItemConsumeDO.builder()
  397 + .workOrderId(randomLongId()).taskId(randomLongId())
  398 + .workstationId(randomLongId()).processId(randomLongId())
  399 + .feedbackId(randomLongId()).consumeDate(java.time.LocalDateTime.now())
  400 + .status(MesWmItemConsumeStatusEnum.PREPARE.getStatus()).build();
  401 + itemConsumeMapper.insert(consume);
  402 +
  403 + // mock detail 列表
  404 + Long lineId1 = randomLongId();
  405 + Long lineId2 = randomLongId();
  406 + MesWmItemConsumeDetailDO detail1 = MesWmItemConsumeDetailDO.builder()
  407 + .consumeId(consume.getId()).lineId(lineId1).itemId(randomLongId())
  408 + .quantity(new BigDecimal("20")).batchId(1L).batchCode("BATCH-001")
  409 + .warehouseId(WAREHOUSE_ID).locationId(LOCATION_ID).areaId(AREA_ID).build();
  410 + MesWmItemConsumeDetailDO detail2 = MesWmItemConsumeDetailDO.builder()
  411 + .consumeId(consume.getId()).lineId(lineId2).itemId(randomLongId())
  412 + .quantity(new BigDecimal("12"))
  413 + .warehouseId(WAREHOUSE_ID).locationId(LOCATION_ID).areaId(AREA_ID).build();
  414 + when(itemConsumeDetailService.getItemConsumeDetailListByConsumeId(consume.getId()))
  415 + .thenReturn(Arrays.asList(detail1, detail2));
  416 +
  417 + // 调用
  418 + itemConsumeService.finishItemConsume(consume.getId());
  419 +
  420 + // 断言 1:状态更新为已完成
  421 + MesWmItemConsumeDO updated = itemConsumeMapper.selectById(consume.getId());
  422 + assertEquals(MesWmItemConsumeStatusEnum.FINISHED.getStatus(), updated.getStatus());
  423 +
  424 + // 断言 2:创建了 2 次库存事务
  425 + ArgumentCaptor<MesWmTransactionSaveReqDTO> txCaptor = ArgumentCaptor.forClass(MesWmTransactionSaveReqDTO.class);
  426 + verify(wmTransactionService, times(2)).createTransaction(txCaptor.capture());
  427 + List<MesWmTransactionSaveReqDTO> txList = txCaptor.getAllValues();
  428 +
  429 + // 验证第 1 条事务
  430 + MesWmTransactionSaveReqDTO tx1 = txList.get(0);
  431 + assertEquals(MesWmTransactionTypeEnum.OUT.getType(), tx1.getType());
  432 + assertEquals(detail1.getItemId(), tx1.getItemId());
  433 + assertEquals(0, new BigDecimal("-20").compareTo(tx1.getQuantity()));
  434 + assertEquals(1L, tx1.getBatchId());
  435 + assertEquals("BATCH-001", tx1.getBatchCode());
  436 + assertEquals(WAREHOUSE_ID, tx1.getWarehouseId());
  437 + assertFalse(tx1.getCheckFlag());
  438 + assertEquals(MesBizTypeConstants.WM_ITEM_CONSUME, tx1.getBizType());
  439 + assertEquals(consume.getId(), tx1.getBizId());
  440 + assertEquals(lineId1, tx1.getBizLineId());
  441 +
  442 + // 验证第 2 条事务(无批次)
  443 + MesWmTransactionSaveReqDTO tx2 = txList.get(1);
  444 + assertEquals(detail2.getItemId(), tx2.getItemId());
  445 + assertEquals(0, new BigDecimal("-12").compareTo(tx2.getQuantity()));
  446 + assertNull(tx2.getBatchId());
  447 + }
  448 +
  449 + @Test
  450 + public void testFinishItemConsume_emptyDetails() {
  451 + // 准备:插入一条消耗记录,但没有 detail
  452 + MesWmItemConsumeDO consume = MesWmItemConsumeDO.builder()
  453 + .workOrderId(randomLongId()).taskId(randomLongId())
  454 + .workstationId(randomLongId()).processId(randomLongId())
  455 + .feedbackId(randomLongId()).consumeDate(java.time.LocalDateTime.now())
  456 + .status(MesWmItemConsumeStatusEnum.PREPARE.getStatus()).build();
  457 + itemConsumeMapper.insert(consume);
  458 +
  459 + when(itemConsumeDetailService.getItemConsumeDetailListByConsumeId(consume.getId()))
  460 + .thenReturn(Collections.emptyList());
  461 +
  462 + // 调用
  463 + itemConsumeService.finishItemConsume(consume.getId());
  464 +
  465 + // 断言:状态更新为已完成,但无事务创建
  466 + MesWmItemConsumeDO updated = itemConsumeMapper.selectById(consume.getId());
  467 + assertEquals(MesWmItemConsumeStatusEnum.FINISHED.getStatus(), updated.getStatus());
  468 + verify(wmTransactionService, never()).createTransaction(any());
  469 + }
  470 +
  471 + // ========== getByFeedbackId ==========
  472 +
  473 + @Test
  474 + public void testGetByFeedbackId_exists() {
  475 + Long feedbackId = randomLongId();
  476 + MesWmItemConsumeDO consume = MesWmItemConsumeDO.builder()
  477 + .workOrderId(randomLongId()).taskId(randomLongId())
  478 + .workstationId(randomLongId()).processId(randomLongId())
  479 + .feedbackId(feedbackId).consumeDate(java.time.LocalDateTime.now())
  480 + .status(MesWmItemConsumeStatusEnum.PREPARE.getStatus()).build();
  481 + itemConsumeMapper.insert(consume);
  482 +
  483 + MesWmItemConsumeDO result = itemConsumeService.getByFeedbackId(feedbackId);
  484 + assertNotNull(result);
  485 + assertEquals(consume.getId(), result.getId());
  486 + }
  487 +
  488 + @Test
  489 + public void testGetByFeedbackId_notExists() {
  490 + MesWmItemConsumeDO result = itemConsumeService.getByFeedbackId(randomLongId());
  491 + assertNull(result);
  492 + }
  493 +
  494 +}
... ...
yudao-module-mes/src/test/resources/sql/create_tables.sql
... ... @@ -519,3 +519,70 @@ CREATE TABLE IF NOT EXISTS &quot;mes_wm_item_receipt_detail&quot; (
519 519 "tenant_id" bigint NOT NULL DEFAULT 0,
520 520 PRIMARY KEY ("id")
521 521 );
  522 +
  523 +-- ----------------------------
  524 +-- MES 物料消耗记录表
  525 +-- ----------------------------
  526 +CREATE TABLE IF NOT EXISTS "mes_wm_item_consume" (
  527 + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
  528 + "work_order_id" bigint NOT NULL,
  529 + "task_id" bigint NOT NULL,
  530 + "workstation_id" bigint NOT NULL,
  531 + "process_id" bigint NOT NULL,
  532 + "feedback_id" bigint NOT NULL,
  533 + "consume_date" timestamp NOT NULL,
  534 + "status" tinyint NOT NULL DEFAULT 0,
  535 + "remark" varchar(500) DEFAULT NULL,
  536 + "creator" varchar(64) DEFAULT '',
  537 + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  538 + "updater" varchar(64) DEFAULT '',
  539 + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  540 + "deleted" bit NOT NULL DEFAULT FALSE,
  541 + "tenant_id" bigint NOT NULL DEFAULT 0,
  542 + PRIMARY KEY ("id")
  543 +);
  544 +
  545 +-- ----------------------------
  546 +-- MES 物料消耗记录行表
  547 +-- ----------------------------
  548 +CREATE TABLE IF NOT EXISTS "mes_wm_item_consume_line" (
  549 + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
  550 + "consume_id" bigint NOT NULL,
  551 + "item_id" bigint NOT NULL,
  552 + "quantity" decimal(14,2) NOT NULL,
  553 + "batch_id" bigint DEFAULT NULL,
  554 + "batch_code" varchar(128) DEFAULT NULL,
  555 + "remark" varchar(500) DEFAULT NULL,
  556 + "creator" varchar(64) DEFAULT '',
  557 + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  558 + "updater" varchar(64) DEFAULT '',
  559 + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  560 + "deleted" bit NOT NULL DEFAULT FALSE,
  561 + "tenant_id" bigint NOT NULL DEFAULT 0,
  562 + PRIMARY KEY ("id")
  563 +);
  564 +
  565 +-- ----------------------------
  566 +-- MES 物料消耗记录明细表
  567 +-- ----------------------------
  568 +CREATE TABLE IF NOT EXISTS "mes_wm_item_consume_detail" (
  569 + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
  570 + "consume_id" bigint NOT NULL,
  571 + "line_id" bigint NOT NULL,
  572 + "material_stock_id" bigint DEFAULT NULL,
  573 + "item_id" bigint NOT NULL,
  574 + "quantity" decimal(14,2) NOT NULL,
  575 + "batch_id" bigint DEFAULT NULL,
  576 + "batch_code" varchar(128) DEFAULT NULL,
  577 + "warehouse_id" bigint NOT NULL,
  578 + "location_id" bigint NOT NULL,
  579 + "area_id" bigint NOT NULL,
  580 + "remark" varchar(500) DEFAULT NULL,
  581 + "creator" varchar(64) DEFAULT '',
  582 + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  583 + "updater" varchar(64) DEFAULT '',
  584 + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  585 + "deleted" bit NOT NULL DEFAULT FALSE,
  586 + "tenant_id" bigint NOT NULL DEFAULT 0,
  587 + PRIMARY KEY ("id")
  588 +);
... ...