Commit b690a5967e54fe4a3d36928d693a8cf0cc003246
1 parent
8843b782
✨ feat(mes): 新增物料消耗记录明细功能
实现物料消耗记录明细的增删改查功能,包括数据对象、Mapper、Service 接口及实现类的创建。支持按消耗记录编号和行编号查询消耗明细,增强了物料消耗管理的灵活性和可追溯性。
Showing
9 changed files
with
876 additions
and
23 deletions
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 "mes_wm_item_receipt_detail" ( |
| 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 | +); | ... | ... |