package cn.iocoder.yudao.module.mes.service.trace.crossregion; import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.ip.core.utils.AreaUtils; import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; import cn.iocoder.yudao.module.crm.api.customer.CrmCustomerApi; import cn.iocoder.yudao.module.crm.api.customer.dto.CrmCustomerRespDTO; import cn.iocoder.yudao.module.mes.controller.admin.trace.crossregion.vo.MesTraceCrossRegionHandleReqVO; import cn.iocoder.yudao.module.mes.controller.admin.trace.crossregion.vo.MesTraceCrossRegionStatisticsReqVO; import cn.iocoder.yudao.module.mes.controller.admin.trace.crossregion.vo.MesTraceCrossRegionStatisticsRespVO; import cn.iocoder.yudao.module.mes.controller.admin.trace.crossregion.vo.MesTraceCrossRegionSummaryRespVO; import cn.iocoder.yudao.module.mes.controller.admin.trace.crossregion.vo.MesTraceCrossRegionWarningPageReqVO; import cn.iocoder.yudao.module.mes.controller.admin.trace.crossregion.vo.MesTraceCrossRegionWarningRespVO; import cn.iocoder.yudao.module.mes.controller.admin.trace.crossregion.vo.MesTraceAntiConfigRespVO; import cn.iocoder.yudao.module.mes.dal.dataobject.mdm.MesMdmItemDO; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeDO; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.batch.MesProBatchDO; import cn.iocoder.yudao.module.mes.dal.dataobject.trace.consumer.MesTraceConsumerScanEventDO; import cn.iocoder.yudao.module.mes.dal.dataobject.trace.crossregion.MesTraceAntiConfigDO; import cn.iocoder.yudao.module.mes.dal.dataobject.trace.crossregion.MesTraceCrossRegionWarningDO; import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesDO; import cn.iocoder.yudao.module.mes.dal.mysql.trace.crossregion.MesTraceAntiConfigMapper; import cn.iocoder.yudao.module.mes.dal.mysql.trace.crossregion.MesTraceCrossRegionWarningMapper; import cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesDetailMapper; import cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesMapper; import cn.iocoder.yudao.module.mes.enums.trace.MesTraceWarningStatusEnum; import cn.iocoder.yudao.module.mes.enums.trace.MesTraceWarningTypeEnum; import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService; import cn.iocoder.yudao.module.mes.service.pro.bagcode.MesProBagCodeService; import cn.iocoder.yudao.module.mes.service.pro.batch.MesProBatchService; import cn.iocoder.yudao.module.mes.service.pro.pallet.MesProPalletService; import cn.iocoder.yudao.module.system.api.user.AdminUserApi; import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; import org.springframework.context.annotation.Import; import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.time.LocalDateTime; import java.util.Collections; import java.util.List; import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.MES_TRACE_ANTI_CONFIG_VALUE_INVALID; import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.MES_TRACE_CROSS_REGION_STATUS_INVALID; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** * {@link MesTraceCrossRegionServiceImpl} 的单元测试 * * @author 超级管理员 */ @Import(MesTraceCrossRegionServiceImpl.class) public class MesTraceCrossRegionServiceImplTest extends BaseDbUnitTest { @Resource private MesTraceCrossRegionService crossRegionService; @Resource private MesTraceCrossRegionWarningMapper warningMapper; @Resource private MesTraceAntiConfigMapper antiConfigMapper; @MockitoBean private MesProBagCodeService bagCodeService; @MockitoBean private MesProPalletService palletService; @MockitoBean private MesProBatchService batchService; @MockitoBean private MesMdmItemService mdmItemService; @MockitoBean private CrmCustomerApi customerApi; @MockitoBean private AdminUserApi adminUserApi; @MockitoBean private MesWmProductSalesMapper productSalesMapper; @MockitoBean private MesWmProductSalesDetailMapper productSalesDetailMapper; // ==================== 窜货判定 ==================== @Test public void testCheckAndCreateWarning_跨省触发() { // 配置省级粒度 antiConfigMapper.insert(MesTraceAntiConfigDO.builder() .configKey("warning_level").configValue("PROVINCE").build()); mockDealer("江苏省", "南京市"); MesTraceConsumerScanEventDO event = buildEvent("BAG", "浙江省", "杭州市", true); crossRegionService.checkAndCreateWarning(event); List warnings = warningMapper.selectList(); assertEquals(1, warnings.size()); MesTraceCrossRegionWarningDO warning = warnings.get(0); assertEquals(MesTraceWarningTypeEnum.CROSS_PROVINCE.getType(), warning.getWarningType()); assertEquals("江苏省", warning.getDealerProvince()); assertEquals("浙江省", warning.getScanProvince()); assertEquals("SO-001", warning.getSalesCode()); assertEquals("华东经销商", warning.getClientName()); assertEquals(event.getId(), warning.getScanEventId()); assertEquals(MesTraceWarningStatusEnum.PENDING.getStatus(), warning.getStatus()); } @Test public void testCheckAndCreateWarning_跨市触发() { // 市级粒度:扫码市 ≠ 经销商市 → CROSS_CITY antiConfigMapper.insert(MesTraceAntiConfigDO.builder() .configKey("warning_level").configValue("CITY").build()); mockDealer("江苏省", "南京市"); MesTraceConsumerScanEventDO event = buildEvent("BAG", "江苏省", "苏州市", true); crossRegionService.checkAndCreateWarning(event); List warnings = warningMapper.selectList(); assertEquals(1, warnings.size()); assertEquals(MesTraceWarningTypeEnum.CROSS_CITY.getType(), warnings.get(0).getWarningType()); assertEquals("南京市", warnings.get(0).getDealerCity()); assertEquals("苏州市", warnings.get(0).getScanCity()); } @Test public void testCheckAndCreateWarning_同地区不预警() { antiConfigMapper.insert(MesTraceAntiConfigDO.builder() .configKey("warning_level").configValue("PROVINCE").build()); mockDealer("江苏省", "南京市"); MesTraceConsumerScanEventDO event = buildEvent("BAG", "江苏省", "南京市", true); crossRegionService.checkAndCreateWarning(event); assertEquals(0, warningMapper.selectCount()); } @Test public void testCheckAndCreateWarning_扫码无地区容错() { antiConfigMapper.insert(MesTraceAntiConfigDO.builder() .configKey("warning_level").configValue("PROVINCE").build()); mockDealer("江苏省", "南京市"); MesTraceConsumerScanEventDO event = buildEvent("BAG", null, null, true); crossRegionService.checkAndCreateWarning(event); assertEquals(0, warningMapper.selectCount()); } @Test public void testCheckAndCreateWarning_批次入口跳过() { antiConfigMapper.insert(MesTraceAntiConfigDO.builder() .configKey("warning_level").configValue("PROVINCE").build()); mockDealer("江苏省", "南京市"); MesTraceConsumerScanEventDO event = buildEvent("BATCH", "浙江省", "杭州市", true); crossRegionService.checkAndCreateWarning(event); assertEquals(0, warningMapper.selectCount()); } @Test public void testCheckAndCreateWarning_袋码未出库跳过() { antiConfigMapper.insert(MesTraceAntiConfigDO.builder() .configKey("warning_level").configValue("PROVINCE").build()); // 袋码未回写出库单,且明细表无记录 → 不预警 when(bagCodeService.getBagCodeByCode("BAG-00000001")).thenReturn( MesProBagCodeDO.builder().id(1L).code("BAG-00000001").batchId(10L).build()); MesTraceConsumerScanEventDO event = buildEvent("BAG", "浙江省", "杭州市", true); crossRegionService.checkAndCreateWarning(event); verify(productSalesDetailMapper).selectLatestByBagCodeId(1L); assertEquals(0, warningMapper.selectCount()); } @Test public void testCheckAndCreateWarning_袋码未绑定批次跳过() { antiConfigMapper.insert(MesTraceAntiConfigDO.builder() .configKey("warning_level").configValue("PROVINCE").build()); when(bagCodeService.getBagCodeByCode("BAG-00000001")).thenReturn(null); MesTraceConsumerScanEventDO event = buildEvent("BAG", "浙江省", "杭州市", true); crossRegionService.checkAndCreateWarning(event); assertEquals(0, warningMapper.selectCount()); } // ==================== 状态流转 ==================== @Test public void testHandleWarning_状态流转() { MesTraceCrossRegionWarningDO warning = buildWarning(MesTraceWarningStatusEnum.PENDING.getStatus()); warningMapper.insert(warning); MesTraceCrossRegionHandleReqVO reqVO = new MesTraceCrossRegionHandleReqVO(); reqVO.setId(warning.getId()); reqVO.setStatus(MesTraceWarningStatusEnum.HANDLED.getStatus()); reqVO.setHandleRemark("确认为物流中转"); crossRegionService.handleWarning(reqVO); MesTraceCrossRegionWarningDO updated = warningMapper.selectById(warning.getId()); assertEquals(MesTraceWarningStatusEnum.HANDLED.getStatus(), updated.getStatus()); assertEquals("确认为物流中转", updated.getHandleRemark()); } @Test public void testHandleWarning_已处理不可再处理() { MesTraceCrossRegionWarningDO warning = buildWarning(MesTraceWarningStatusEnum.HANDLED.getStatus()); warningMapper.insert(warning); MesTraceCrossRegionHandleReqVO reqVO = new MesTraceCrossRegionHandleReqVO(); reqVO.setId(warning.getId()); reqVO.setStatus(MesTraceWarningStatusEnum.FALSE_ALARM.getStatus()); ServiceException ex = assertThrows(ServiceException.class, () -> crossRegionService.handleWarning(reqVO)); assertEquals(MES_TRACE_CROSS_REGION_STATUS_INVALID.getCode(), ex.getCode()); } // ==================== 配置 ==================== @Test public void testGetConfig_updateConfig() { antiConfigMapper.insert(MesTraceAntiConfigDO.builder() .configKey("warning_level").configValue("PROVINCE").build()); MesTraceAntiConfigRespVO config = crossRegionService.getConfig(); assertEquals("PROVINCE", config.getWarningLevel()); crossRegionService.updateConfig("CITY"); assertEquals("CITY", antiConfigMapper.selectByConfigKey("warning_level").getConfigValue()); ServiceException ex = assertThrows(ServiceException.class, () -> crossRegionService.updateConfig("INVALID")); assertEquals(MES_TRACE_ANTI_CONFIG_VALUE_INVALID.getCode(), ex.getCode()); } // ==================== 汇总与统计 ==================== @Test public void testGetWarningSummary() { warningMapper.insert(buildWarning(MesTraceWarningStatusEnum.PENDING.getStatus()) .setWarningType(MesTraceWarningTypeEnum.CROSS_PROVINCE.getType())); warningMapper.insert(buildWarning(MesTraceWarningStatusEnum.HANDLED.getStatus()) .setWarningType(MesTraceWarningTypeEnum.CROSS_CITY.getType())); warningMapper.insert(buildWarning(MesTraceWarningStatusEnum.FALSE_ALARM.getStatus()) .setWarningType(MesTraceWarningTypeEnum.CROSS_PROVINCE.getType())); MesTraceCrossRegionSummaryRespVO vo = crossRegionService.getWarningSummary(null, null); assertEquals(3L, vo.getTotal()); assertEquals(1L, vo.getPending()); assertEquals(1L, vo.getHandled()); assertEquals(1L, vo.getFalseAlarm()); assertEquals(2L, vo.getCrossProvince()); assertEquals(1L, vo.getCrossCity()); } @Test public void testGetWarningStatistics_分组统计() { warningMapper.insert(buildWarning(MesTraceWarningStatusEnum.PENDING.getStatus()) .setWarningType(MesTraceWarningTypeEnum.CROSS_PROVINCE.getType()).setScanProvince("浙江省")); warningMapper.insert(buildWarning(MesTraceWarningStatusEnum.PENDING.getStatus()) .setWarningType(MesTraceWarningTypeEnum.CROSS_PROVINCE.getType()).setScanProvince("浙江省")); warningMapper.insert(buildWarning(MesTraceWarningStatusEnum.PENDING.getStatus()) .setWarningType(MesTraceWarningTypeEnum.CROSS_PROVINCE.getType()).setScanProvince("江苏省")); MesTraceCrossRegionStatisticsReqVO reqVO = new MesTraceCrossRegionStatisticsReqVO(); reqVO.setGroupBy("scanProvince"); List list = crossRegionService.getWarningStatistics(reqVO); assertEquals(2, list.size()); assertEquals("浙江省", list.get(0).getName()); assertEquals(2L, list.get(0).getCount()); assertEquals("江苏省", list.get(1).getName()); assertEquals(1L, list.get(1).getCount()); } @Test public void testGetWarningStatistics_非法维度拒绝() { MesTraceCrossRegionStatisticsReqVO reqVO = new MesTraceCrossRegionStatisticsReqVO(); reqVO.setGroupBy("item_code; DROP TABLE x"); assertThrows(IllegalArgumentException.class, () -> crossRegionService.getWarningStatistics(reqVO)); } // ==================== 分页 ==================== @Test public void testGetWarningPage() { when(adminUserApi.getUserMap(any())).thenReturn(Collections.emptyMap()); warningMapper.insert(buildWarning(MesTraceWarningStatusEnum.PENDING.getStatus()) .setWarningType(MesTraceWarningTypeEnum.CROSS_PROVINCE.getType()) .setScanProvince("浙江省").setDealerProvince("江苏省")); MesTraceCrossRegionWarningPageReqVO reqVO = new MesTraceCrossRegionWarningPageReqVO(); reqVO.setStatus(MesTraceWarningStatusEnum.PENDING.getStatus()); var page = crossRegionService.getWarningPage(reqVO); assertEquals(1L, page.getTotal()); MesTraceCrossRegionWarningRespVO vo = page.getList().get(0); assertEquals(MesTraceWarningStatusEnum.PENDING.getName(), vo.getStatusName()); assertEquals(MesTraceWarningTypeEnum.CROSS_PROVINCE.getName(), vo.getWarningTypeName()); assertTrue(vo.getWarningNo() != null); } // ==================== 辅助方法 ==================== /** * 构建消费者扫码事件(id 固定,便于断言关联) */ private MesTraceConsumerScanEventDO buildEvent(String sourceType, String province, String city, boolean success) { return MesTraceConsumerScanEventDO.builder() .id(1L) .normalizedCode("BAG-00000001") .sourceType(sourceType) .province(province) .city(city) .batchId(10L) .success(success) .eventTime(LocalDateTime.of(2026, 9, 1, 10, 0)) .build(); } /** * Mock 袋码 → 出库单 → 经销商(指定地区)链路 */ private void mockDealer(String dealerProvince, String dealerCity) { MesProBagCodeDO bag = MesProBagCodeDO.builder().id(1L).code("BAG-00000001") .batchId(10L).salesId(100L).build(); when(bagCodeService.getBagCodeByCode("BAG-00000001")).thenReturn(bag); MesWmProductSalesDO sales = MesWmProductSalesDO.builder().id(100L).code("SO-001") .clientId(200L).clientName("华东经销商").build(); when(productSalesMapper.selectById(100L)).thenReturn(sales); Integer areaId = AreaUtils.parseArea(dealerProvince + "/" + dealerCity).getId(); CrmCustomerRespDTO customer = new CrmCustomerRespDTO(); customer.setId(200L); customer.setName("华东经销商"); customer.setAreaId(areaId); when(customerApi.getCustomer(200L)).thenReturn(customer); MesProBatchDO batch = MesProBatchDO.builder().id(10L).itemId(5L).build(); when(batchService.getBatch(10L)).thenReturn(batch); MesMdmItemDO item = MesMdmItemDO.builder().id(5L).code("ITEM001").name("水稻种子").build(); when(mdmItemService.getItem(5L)).thenReturn(item); } private MesTraceCrossRegionWarningDO buildWarning(Integer status) { return MesTraceCrossRegionWarningDO.builder() .warningNo("WARN20260901100000ABCDEF") .batchId(10L) .batchCode("PC20260901L001M001_001") .salesId(100L) .salesCode("SO-001") .clientId(200L) .clientName("华东经销商") .dealerProvince("江苏省") .dealerCity("南京市") .scanProvince("浙江省") .scanCity("杭州市") .status(status) .eventTime(LocalDateTime.of(2026, 9, 1, 10, 0)) .build(); } }