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<MesTraceCrossRegionWarningDO> 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<MesTraceCrossRegionWarningDO> 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<MesTraceCrossRegionStatisticsRespVO> 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();
|
}
|
|
}
|