package cn.iocoder.yudao.module.mes.service.wm.itemconsume; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.collection.ListUtil; import cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil; import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; import cn.iocoder.yudao.module.mes.controller.admin.wm.materialstock.vo.MesWmMaterialStockListReqVO; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.feedback.MesProFeedbackDO; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.route.MesProRouteDO; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.route.MesProRouteProductBomDO; import cn.iocoder.yudao.module.mes.dal.dataobject.wm.itemconsume.MesWmItemConsumeDO; import cn.iocoder.yudao.module.mes.dal.dataobject.wm.itemconsume.MesWmItemConsumeDetailDO; import cn.iocoder.yudao.module.mes.dal.dataobject.wm.itemconsume.MesWmItemConsumeLineDO; import cn.iocoder.yudao.module.mes.dal.dataobject.wm.materialstock.MesWmMaterialStockDO; import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseAreaDO; import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseDO; import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseLocationDO; import cn.iocoder.yudao.module.mes.dal.mysql.wm.itemconsume.MesWmItemConsumeMapper; import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants; import cn.iocoder.yudao.module.mes.enums.wm.MesWmItemConsumeStatusEnum; import cn.iocoder.yudao.module.mes.enums.wm.MesWmTransactionTypeEnum; import cn.iocoder.yudao.module.mes.service.pro.route.MesProRouteProductBomService; import cn.iocoder.yudao.module.mes.service.pro.route.MesProRouteService; import cn.iocoder.yudao.module.mes.service.wm.materialstock.MesWmMaterialStockService; import cn.iocoder.yudao.module.mes.service.wm.transaction.MesWmTransactionService; import cn.iocoder.yudao.module.mes.service.wm.transaction.dto.MesWmTransactionSaveReqDTO; import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseAreaService; import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseLocationService; import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseService; import jakarta.annotation.Resource; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.context.annotation.Import; import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.math.BigDecimal; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.concurrent.atomic.AtomicLong; import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_FEEDBACK_ROUTE_PROCESS_INVALID; import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_ROUTE_NOT_EXISTS; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; /** * {@link MesWmItemConsumeServiceImpl} 的单元测试 * * @author 芋道源码 */ @Import(MesWmItemConsumeServiceImpl.class) public class MesWmItemConsumeServiceImplTest extends BaseDbUnitTest { @Resource private MesWmItemConsumeServiceImpl itemConsumeService; @Resource private MesWmItemConsumeMapper itemConsumeMapper; @MockitoBean private MesWmItemConsumeLineService itemConsumeLineService; @MockitoBean private MesWmItemConsumeDetailService itemConsumeDetailService; @MockitoBean private MesProRouteProductBomService routeProductBomService; @MockitoBean private MesProRouteService routeService; @MockitoBean private MesWmTransactionService wmTransactionService; @MockitoBean private MesWmWarehouseService warehouseService; @MockitoBean private MesWmWarehouseLocationService locationService; @MockitoBean private MesWmWarehouseAreaService areaService; @MockitoBean private MesWmMaterialStockService materialStockService; // ========== 公共 mock 数据 ========== private static final Long WAREHOUSE_ID = 100L; private static final Long LOCATION_ID = 200L; private static final Long AREA_ID = 300L; private MesWmWarehouseDO virtualWarehouse; private MesWmWarehouseLocationDO virtualLocation; private MesWmWarehouseAreaDO virtualArea; @BeforeEach public void setUp() { virtualWarehouse = new MesWmWarehouseDO(); virtualWarehouse.setId(WAREHOUSE_ID); virtualWarehouse.setCode(MesWmWarehouseDO.WIP_VIRTUAL_WAREHOUSE); virtualLocation = new MesWmWarehouseLocationDO(); virtualLocation.setId(LOCATION_ID); virtualLocation.setCode(MesWmWarehouseLocationDO.WIP_VIRTUAL_LOCATION); virtualArea = new MesWmWarehouseAreaDO(); virtualArea.setId(AREA_ID); virtualArea.setCode(MesWmWarehouseAreaDO.WIP_VIRTUAL_AREA); when(warehouseService.getWarehouseByCode(MesWmWarehouseDO.WIP_VIRTUAL_WAREHOUSE)) .thenReturn(virtualWarehouse); when(locationService.getWarehouseLocationByCode(MesWmWarehouseLocationDO.WIP_VIRTUAL_LOCATION)) .thenReturn(virtualLocation); when(areaService.getWarehouseAreaByCode(MesWmWarehouseAreaDO.WIP_VIRTUAL_AREA)) .thenReturn(virtualArea); lenient().when(routeService.validateRouteExists(anyLong())).thenAnswer(invocation -> { Long routeId = invocation.getArgument(0); return new MesProRouteDO().setId(routeId); }); // mock createItemConsumeLineBatch:模拟 insertBatch 后填充 ID AtomicLong lineIdSeq = new AtomicLong(1000L); doAnswer(invocation -> { List lines = invocation.getArgument(0); for (MesWmItemConsumeLineDO line : lines) { line.setId(lineIdSeq.getAndIncrement()); } return null; }).when(itemConsumeLineService).createItemConsumeLineBatch(any()); } private MesProFeedbackDO buildFeedback() { return MesProFeedbackDO.builder() .id(randomLongId()).workOrderId(randomLongId()).taskId(randomLongId()) .workstationId(randomLongId()).processId(randomLongId()).routeId(randomLongId()) .itemId(randomLongId()).feedbackQuantity(BigDecimal.TEN) .build(); } // ========== generateItemConsume ========== @Test public void testGenerateItemConsume_routeContextIncomplete() { MesProFeedbackDO feedback = buildFeedback(); feedback.setRouteId(null); assertServiceException(() -> itemConsumeService.generateItemConsume(feedback), PRO_FEEDBACK_ROUTE_PROCESS_INVALID); verify(routeProductBomService, never()).getRouteProductBomList(any(), any(), any()); } @Test public void testGenerateItemConsume_routeNotExists() { MesProFeedbackDO feedback = buildFeedback(); doThrow(ServiceExceptionUtil.exception(PRO_ROUTE_NOT_EXISTS)).when(routeService) .validateRouteExists(feedback.getRouteId()); assertServiceException(() -> itemConsumeService.generateItemConsume(feedback), PRO_ROUTE_NOT_EXISTS); verify(routeProductBomService, never()).getRouteProductBomList(any(), any(), any()); } @Test public void testGenerateItemConsume_noBom() { // 准备 MesProFeedbackDO feedback = buildFeedback(); when(routeProductBomService.getRouteProductBomList( feedback.getRouteId(), feedback.getProcessId(), feedback.getItemId())) .thenReturn(Collections.emptyList()); // 调用 MesWmItemConsumeDO result = itemConsumeService.generateItemConsume(feedback); // 断言 assertNull(result); verify(itemConsumeLineService, never()).createItemConsumeLineBatch(any()); verify(itemConsumeDetailService, never()).createItemConsumeDetailBatch(any()); } @Test public void testGenerateItemConsume_noStock() { // 准备:1 个 BOM 物料,线边库无库存 MesProFeedbackDO feedback = buildFeedback(); Long bomItemId = randomLongId(); MesProRouteProductBomDO bom = new MesProRouteProductBomDO(); bom.setItemId(bomItemId); bom.setQuantity(new BigDecimal("2")); // 用料比例 = 2 when(routeProductBomService.getRouteProductBomList(any(), any(), any())) .thenReturn(ListUtil.of(bom)); when(materialStockService.getMaterialStockList(any(MesWmMaterialStockListReqVO.class))) .thenReturn(Collections.emptyList()); // 调用 MesWmItemConsumeDO result = itemConsumeService.generateItemConsume(feedback); // 断言 1:消耗单头已入库 assertNotNull(result); assertNotNull(result.getId()); assertEquals(MesWmItemConsumeStatusEnum.PREPARE.getStatus(), result.getStatus()); assertEquals(feedback.getId(), result.getFeedbackId()); // 断言 2:消耗行正确 @SuppressWarnings("unchecked") ArgumentCaptor> lineCaptor = ArgumentCaptor.forClass(List.class); verify(itemConsumeLineService).createItemConsumeLineBatch(lineCaptor.capture()); List lines = lineCaptor.getValue(); assertEquals(1, lines.size()); assertEquals(bomItemId, lines.get(0).getItemId()); // 消耗数量 = 2 × 10 = 20 assertEquals(0, new BigDecimal("20").compareTo(lines.get(0).getQuantity())); // 断言 3:生成 1 条无批次的 detail @SuppressWarnings("unchecked") ArgumentCaptor> detailCaptor = ArgumentCaptor.forClass(List.class); verify(itemConsumeDetailService).createItemConsumeDetailBatch(detailCaptor.capture()); List details = detailCaptor.getValue(); assertEquals(1, details.size()); MesWmItemConsumeDetailDO detail = details.get(0); assertEquals(bomItemId, detail.getItemId()); assertEquals(0, new BigDecimal("20").compareTo(detail.getQuantity())); assertNull(detail.getMaterialStockId()); assertNull(detail.getBatchId()); assertEquals(WAREHOUSE_ID, detail.getWarehouseId()); assertEquals(LOCATION_ID, detail.getLocationId()); assertEquals(AREA_ID, detail.getAreaId()); } @Test public void testGenerateItemConsume_singleBatchSufficient() { // 准备:1 个 BOM 物料,线边库有 1 个批次且库存充足 MesProFeedbackDO feedback = buildFeedback(); Long bomItemId = randomLongId(); MesProRouteProductBomDO bom = new MesProRouteProductBomDO(); bom.setItemId(bomItemId); bom.setQuantity(new BigDecimal("3")); // 消耗 = 3 × 10 = 30 when(routeProductBomService.getRouteProductBomList(any(), any(), any())) .thenReturn(ListUtil.of(bom)); Long stockId = randomLongId(); Long batchId = randomLongId(); MesWmMaterialStockDO stock = new MesWmMaterialStockDO(); stock.setId(stockId); stock.setItemId(bomItemId); stock.setQuantity(new BigDecimal("50")); // 库存 50 > 需求 30 stock.setBatchId(batchId); stock.setBatchCode("BATCH-001"); when(materialStockService.getMaterialStockList(any(MesWmMaterialStockListReqVO.class))) .thenReturn(ListUtil.of(stock)); // 调用 MesWmItemConsumeDO result = itemConsumeService.generateItemConsume(feedback); // 断言:生成 1 条带批次的 detail assertNotNull(result); @SuppressWarnings("unchecked") ArgumentCaptor> detailCaptor = ArgumentCaptor.forClass(List.class); verify(itemConsumeDetailService).createItemConsumeDetailBatch(detailCaptor.capture()); List details = detailCaptor.getValue(); assertEquals(1, details.size()); MesWmItemConsumeDetailDO detail = details.get(0); assertEquals(0, new BigDecimal("30").compareTo(detail.getQuantity())); assertEquals(stockId, detail.getMaterialStockId()); assertEquals(batchId, detail.getBatchId()); assertEquals("BATCH-001", detail.getBatchCode()); } @Test public void testGenerateItemConsume_multiBatchFifo() { // 准备:1 个 BOM 物料,需消耗 30,线边库有 2 个批次(20 + 15) MesProFeedbackDO feedback = buildFeedback(); Long bomItemId = randomLongId(); MesProRouteProductBomDO bom = new MesProRouteProductBomDO(); bom.setItemId(bomItemId); bom.setQuantity(new BigDecimal("3")); // 消耗 = 3 × 10 = 30 when(routeProductBomService.getRouteProductBomList(any(), any(), any())) .thenReturn(ListUtil.of(bom)); Long stockId1 = randomLongId(); Long stockId2 = randomLongId(); MesWmMaterialStockDO stock1 = new MesWmMaterialStockDO(); stock1.setId(stockId1); stock1.setItemId(bomItemId); stock1.setQuantity(new BigDecimal("20")); // 先进先出:先消耗这批 stock1.setBatchId(1L); stock1.setBatchCode("BATCH-001"); MesWmMaterialStockDO stock2 = new MesWmMaterialStockDO(); stock2.setId(stockId2); stock2.setItemId(bomItemId); stock2.setQuantity(new BigDecimal("15")); stock2.setBatchId(2L); stock2.setBatchCode("BATCH-002"); // FIFO 顺序返回 when(materialStockService.getMaterialStockList(any(MesWmMaterialStockListReqVO.class))) .thenReturn(Arrays.asList(stock1, stock2)); // 调用 MesWmItemConsumeDO result = itemConsumeService.generateItemConsume(feedback); // 断言:生成 2 条带批次的 detail(20 + 10) assertNotNull(result); @SuppressWarnings("unchecked") ArgumentCaptor> detailCaptor = ArgumentCaptor.forClass(List.class); verify(itemConsumeDetailService).createItemConsumeDetailBatch(detailCaptor.capture()); List details = detailCaptor.getValue(); assertEquals(2, details.size()); // 第 1 条:批次 1 全部消耗 20 assertEquals(stockId1, details.get(0).getMaterialStockId()); assertEquals(0, new BigDecimal("20").compareTo(details.get(0).getQuantity())); assertEquals("BATCH-001", details.get(0).getBatchCode()); // 第 2 条:批次 2 消耗 10(30 - 20 = 10) assertEquals(stockId2, details.get(1).getMaterialStockId()); assertEquals(0, new BigDecimal("10").compareTo(details.get(1).getQuantity())); assertEquals("BATCH-002", details.get(1).getBatchCode()); } @Test public void testGenerateItemConsume_insufficientStock() { // 准备:需消耗 30,线边库只有 1 个批次(库存 18),不足部分生成无批次 detail MesProFeedbackDO feedback = buildFeedback(); Long bomItemId = randomLongId(); MesProRouteProductBomDO bom = new MesProRouteProductBomDO(); bom.setItemId(bomItemId); bom.setQuantity(new BigDecimal("3")); // 消耗 = 3 × 10 = 30 when(routeProductBomService.getRouteProductBomList(any(), any(), any())) .thenReturn(ListUtil.of(bom)); Long stockId = randomLongId(); MesWmMaterialStockDO stock = new MesWmMaterialStockDO(); stock.setId(stockId); stock.setItemId(bomItemId); stock.setQuantity(new BigDecimal("18")); // 不够 30 stock.setBatchId(1L); stock.setBatchCode("BATCH-001"); when(materialStockService.getMaterialStockList(any(MesWmMaterialStockListReqVO.class))) .thenReturn(ListUtil.of(stock)); // 调用 MesWmItemConsumeDO result = itemConsumeService.generateItemConsume(feedback); // 断言:2 条 detail(18 带批次 + 12 无批次) assertNotNull(result); @SuppressWarnings("unchecked") ArgumentCaptor> detailCaptor = ArgumentCaptor.forClass(List.class); verify(itemConsumeDetailService).createItemConsumeDetailBatch(detailCaptor.capture()); List details = detailCaptor.getValue(); assertEquals(2, details.size()); // 第 1 条:批次全部消耗 18 assertEquals(stockId, details.get(0).getMaterialStockId()); assertEquals(0, new BigDecimal("18").compareTo(details.get(0).getQuantity())); assertEquals("BATCH-001", details.get(0).getBatchCode()); // 第 2 条:剩余 12 无批次 assertNull(details.get(1).getMaterialStockId()); assertEquals(0, new BigDecimal("12").compareTo(details.get(1).getQuantity())); assertNull(details.get(1).getBatchId()); } @Test public void testGenerateItemConsume_multipleBomItems() { // 准备:2 个 BOM 物料 MesProFeedbackDO feedback = buildFeedback(); Long itemA = randomLongId(); Long itemB = randomLongId(); MesProRouteProductBomDO bomA = new MesProRouteProductBomDO(); bomA.setItemId(itemA); bomA.setQuantity(new BigDecimal("1")); // 消耗 = 1 × 10 = 10 MesProRouteProductBomDO bomB = new MesProRouteProductBomDO(); bomB.setItemId(itemB); bomB.setQuantity(new BigDecimal("2")); // 消耗 = 2 × 10 = 20 when(routeProductBomService.getRouteProductBomList(any(), any(), any())) .thenReturn(Arrays.asList(bomA, bomB)); // 物料 A 有库存,物料 B 无库存 MesWmMaterialStockDO stockA = new MesWmMaterialStockDO(); stockA.setId(randomLongId()); stockA.setItemId(itemA); stockA.setQuantity(new BigDecimal("100")); stockA.setBatchId(1L); stockA.setBatchCode("BATCH-A"); when(materialStockService.getMaterialStockList(argThat((MesWmMaterialStockListReqVO req) -> req != null && itemA.equals(req.getItemId())))) .thenReturn(ListUtil.of(stockA)); when(materialStockService.getMaterialStockList(argThat((MesWmMaterialStockListReqVO req) -> req != null && itemB.equals(req.getItemId())))) .thenReturn(Collections.emptyList()); // 调用 MesWmItemConsumeDO result = itemConsumeService.generateItemConsume(feedback); // 断言:2 条消耗行,2 条 detail assertNotNull(result); @SuppressWarnings("unchecked") ArgumentCaptor> lineCaptor = ArgumentCaptor.forClass(List.class); verify(itemConsumeLineService).createItemConsumeLineBatch(lineCaptor.capture()); assertEquals(2, lineCaptor.getValue().size()); @SuppressWarnings("unchecked") ArgumentCaptor> detailCaptor = ArgumentCaptor.forClass(List.class); verify(itemConsumeDetailService).createItemConsumeDetailBatch(detailCaptor.capture()); List details = detailCaptor.getValue(); assertEquals(2, details.size()); // 物料 A:有批次 MesWmItemConsumeDetailDO detailA = CollUtil.findOne(details, d -> itemA.equals(d.getItemId())); assertEquals(0, new BigDecimal("10").compareTo(detailA.getQuantity())); assertNotNull(detailA.getMaterialStockId()); assertEquals("BATCH-A", detailA.getBatchCode()); // 物料 B:无批次 MesWmItemConsumeDetailDO detailB = CollUtil.findOne(details, d -> itemB.equals(d.getItemId())); assertEquals(0, new BigDecimal("20").compareTo(detailB.getQuantity())); assertNull(detailB.getMaterialStockId()); } // ========== finishItemConsume ========== @Test public void testFinishItemConsume_withDetails() { // 准备:插入一条消耗记录 MesWmItemConsumeDO consume = MesWmItemConsumeDO.builder() .workOrderId(randomLongId()).taskId(randomLongId()) .workstationId(randomLongId()).processId(randomLongId()) .feedbackId(randomLongId()).consumeDate(java.time.LocalDateTime.now()) .status(MesWmItemConsumeStatusEnum.PREPARE.getStatus()).build(); itemConsumeMapper.insert(consume); // mock detail 列表 Long lineId1 = randomLongId(); Long lineId2 = randomLongId(); MesWmItemConsumeDetailDO detail1 = MesWmItemConsumeDetailDO.builder() .consumeId(consume.getId()).lineId(lineId1).itemId(randomLongId()) .quantity(new BigDecimal("20")).batchId(1L).batchCode("BATCH-001") .warehouseId(WAREHOUSE_ID).locationId(LOCATION_ID).areaId(AREA_ID).build(); MesWmItemConsumeDetailDO detail2 = MesWmItemConsumeDetailDO.builder() .consumeId(consume.getId()).lineId(lineId2).itemId(randomLongId()) .quantity(new BigDecimal("12")) .warehouseId(WAREHOUSE_ID).locationId(LOCATION_ID).areaId(AREA_ID).build(); when(itemConsumeDetailService.getItemConsumeDetailListByConsumeId(consume.getId())) .thenReturn(Arrays.asList(detail1, detail2)); // 调用 itemConsumeService.finishItemConsume(consume.getId()); // 断言 1:状态更新为已完成 MesWmItemConsumeDO updated = itemConsumeMapper.selectById(consume.getId()); assertEquals(MesWmItemConsumeStatusEnum.FINISHED.getStatus(), updated.getStatus()); // 断言 2:创建了 2 次库存事务 ArgumentCaptor txCaptor = ArgumentCaptor.forClass(MesWmTransactionSaveReqDTO.class); verify(wmTransactionService, times(2)).createTransaction(txCaptor.capture()); List txList = txCaptor.getAllValues(); // 验证第 1 条事务 MesWmTransactionSaveReqDTO tx1 = txList.get(0); assertEquals(MesWmTransactionTypeEnum.OUT.getType(), tx1.getType()); assertEquals(detail1.getItemId(), tx1.getItemId()); assertEquals(0, new BigDecimal("-20").compareTo(tx1.getQuantity())); assertEquals(1L, tx1.getBatchId()); assertEquals("BATCH-001", tx1.getBatchCode()); assertEquals(WAREHOUSE_ID, tx1.getWarehouseId()); assertFalse(tx1.getCheckFlag()); assertEquals(MesBizTypeConstants.WM_ITEM_CONSUME, tx1.getBizType()); assertEquals(consume.getId(), tx1.getBizId()); assertEquals(lineId1, tx1.getBizLineId()); // 验证第 2 条事务(无批次) MesWmTransactionSaveReqDTO tx2 = txList.get(1); assertEquals(detail2.getItemId(), tx2.getItemId()); assertEquals(0, new BigDecimal("-12").compareTo(tx2.getQuantity())); assertNull(tx2.getBatchId()); } @Test public void testFinishItemConsume_emptyDetails() { // 准备:插入一条消耗记录,但没有 detail MesWmItemConsumeDO consume = MesWmItemConsumeDO.builder() .workOrderId(randomLongId()).taskId(randomLongId()) .workstationId(randomLongId()).processId(randomLongId()) .feedbackId(randomLongId()).consumeDate(java.time.LocalDateTime.now()) .status(MesWmItemConsumeStatusEnum.PREPARE.getStatus()).build(); itemConsumeMapper.insert(consume); when(itemConsumeDetailService.getItemConsumeDetailListByConsumeId(consume.getId())) .thenReturn(Collections.emptyList()); // 调用 itemConsumeService.finishItemConsume(consume.getId()); // 断言:状态更新为已完成,但无事务创建 MesWmItemConsumeDO updated = itemConsumeMapper.selectById(consume.getId()); assertEquals(MesWmItemConsumeStatusEnum.FINISHED.getStatus(), updated.getStatus()); verify(wmTransactionService, never()).createTransaction(any()); } // ========== getByFeedbackId ========== @Test public void testGetByFeedbackId_exists() { Long feedbackId = randomLongId(); MesWmItemConsumeDO consume = MesWmItemConsumeDO.builder() .workOrderId(randomLongId()).taskId(randomLongId()) .workstationId(randomLongId()).processId(randomLongId()) .feedbackId(feedbackId).consumeDate(java.time.LocalDateTime.now()) .status(MesWmItemConsumeStatusEnum.PREPARE.getStatus()).build(); itemConsumeMapper.insert(consume); MesWmItemConsumeDO result = itemConsumeService.getByFeedbackId(feedbackId); assertNotNull(result); assertEquals(consume.getId(), result.getId()); } @Test public void testGetByFeedbackId_notExists() { MesWmItemConsumeDO result = itemConsumeService.getByFeedbackId(randomLongId()); assertNull(result); } }