package cn.iocoder.yudao.module.mes.service.trace.crossregion; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.ObjUtil; import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.collection.MapUtils; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.framework.ip.core.Area; import cn.iocoder.yudao.framework.ip.core.enums.AreaTypeEnum; import cn.iocoder.yudao.framework.ip.core.utils.AreaUtils; import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; 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.MesTraceAntiConfigRespVO; 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.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.pro.pallet.MesProPalletDO; 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.dataobject.wm.productsales.MesWmProductSalesDetailDO; 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 cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; import java.time.LocalDateTime; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.MES_TRACE_ANTI_CONFIG_NOT_EXISTS; 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 cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.MES_TRACE_CROSS_REGION_WARNING_NOT_EXISTS; /** * 窜货预警 Service 实现类 * * @author 超级管理员 */ @Service @Validated @Slf4j public class MesTraceCrossRegionServiceImpl implements MesTraceCrossRegionService { private static final String CONFIG_KEY_WARNING_LEVEL = "warning_level"; /** * 分布统计分组白名单:仅允许固定业务字段分组,防止任意 key 传入。 */ private static final Map> STAT_GROUP_EXTRACTORS = Map.ofEntries( Map.entry("scanProvince", w -> w.getScanProvince()), Map.entry("scanCity", w -> w.getScanCity()), Map.entry("clientId", w -> w.getClientName()), Map.entry("itemId", w -> w.getItemName()), Map.entry("batchId", w -> w.getBatchCode())); @Resource private MesTraceCrossRegionWarningMapper warningMapper; @Resource private MesTraceAntiConfigMapper antiConfigMapper; @Resource private MesWmProductSalesMapper productSalesMapper; @Resource private MesWmProductSalesDetailMapper productSalesDetailMapper; @Resource private MesProBagCodeService bagCodeService; @Resource private MesProPalletService palletService; @Resource private MesProBatchService batchService; @Resource private MesMdmItemService mdmItemService; @Resource private CrmCustomerApi customerApi; @Resource private AdminUserApi adminUserApi; @Override public void checkAndCreateWarning(MesTraceConsumerScanEventDO event) { try { doCheckAndCreateWarning(event); } catch (Exception ex) { // 窜货判定失败不影响消费者溯源主流程 log.warn("[checkAndCreateWarning][窜货判定失败] traceCode={}", event == null ? null : event.getNormalizedCode(), ex); } } private void doCheckAndCreateWarning(MesTraceConsumerScanEventDO event) { if (event == null) { return; } // 1. 仅袋码/托盘入口可精确定位出库去向,批次码跳过 String sourceType = event.getSourceType(); boolean bag = "BAG".equals(sourceType); boolean pallet = "PALLET".equals(sourceType); if (!bag && !pallet) { return; } // 2. 扫码地区(IP 归属地,记录事件时已解析),无地区无法判定 String scanProvince = event.getProvince(); String scanCity = event.getCity(); if (StrUtil.isBlank(scanProvince) && StrUtil.isBlank(scanCity)) { return; } // 3. 反查袋码/托盘,定位出库单 Long codeId; Long batchId; String code; Long salesId; if (bag) { MesProBagCodeDO bagCode = bagCodeService.getBagCodeByCode(event.getNormalizedCode()); if (bagCode == null) { return; } codeId = bagCode.getId(); code = bagCode.getCode(); batchId = bagCode.getBatchId(); salesId = bagCode.getSalesId(); } else { MesProPalletDO palletCode = palletService.getPalletByCode(event.getNormalizedCode()); if (palletCode == null) { return; } codeId = palletCode.getId(); code = palletCode.getCode(); batchId = palletCode.getBatchId(); salesId = palletCode.getSalesId(); } // 4. 袋码未回写出库单(历史数据)时,兜底查明细表 if (salesId == null) { MesWmProductSalesDetailDO detail = bag ? productSalesDetailMapper.selectLatestByBagCodeId(codeId) : productSalesDetailMapper.selectLatestByPalletCodeId(codeId); if (detail == null || detail.getSalesId() == null) { return; // 未出库,无窜货比对基础 } salesId = detail.getSalesId(); } // 5. 查出库单与经销商地区 MesWmProductSalesDO sales = productSalesMapper.selectById(salesId); if (sales == null || sales.getClientId() == null) { return; } CrmCustomerRespDTO customer = customerApi.getCustomer(sales.getClientId()); if (customer == null || customer.getAreaId() == null) { return; } String[] dealerRegion = resolveRegion(customer.getAreaId()); String dealerProvince = dealerRegion[0]; String dealerCity = dealerRegion[1]; if (StrUtil.isBlank(dealerProvince)) { return; } // 6. 按判定粒度比对 String warningType = matchWarningType(getWarningLevel(), scanProvince, scanCity, dealerProvince, dealerCity); if (warningType == null) { return; } // 7. 冗余填充物料/批次信息 MesProBatchDO batch = batchId != null ? batchService.getBatch(batchId) : null; MesMdmItemDO item = batch != null && batch.getItemId() != null ? mdmItemService.getItem(batch.getItemId()) : null; // 8. 写入预警 MesTraceCrossRegionWarningDO warning = MesTraceCrossRegionWarningDO.builder() .warningNo(generateWarningNo()) .bagCodeId(bag ? codeId : null) .bagCode(bag ? code : null) .palletCodeId(pallet ? codeId : null) .palletCode(pallet ? code : null) .batchId(batch != null ? batch.getId() : null) .batchCode(batch != null ? batch.getCode() : null) .itemId(item != null ? item.getId() : null) .itemCode(item != null ? item.getCode() : null) .itemName(item != null ? item.getName() : null) .salesId(sales.getId()) .salesCode(sales.getCode()) .clientId(sales.getClientId()) .clientName(sales.getClientName()) .dealerProvince(dealerProvince) .dealerCity(dealerCity) .scanProvince(scanProvince) .scanCity(scanCity) .warningType(warningType) .scanEventId(event.getId()) .status(MesTraceWarningStatusEnum.PENDING.getStatus()) .eventTime(event.getEventTime() != null ? event.getEventTime() : LocalDateTime.now()) .build(); warningMapper.insert(warning); } @Override public PageResult getWarningPage(MesTraceCrossRegionWarningPageReqVO reqVO) { PageResult pageResult = warningMapper.selectPage(reqVO); if (CollUtil.isEmpty(pageResult.getList())) { return PageResult.empty(); } // 批量查处理人姓名 Set handleUserIds = convertSet(pageResult.getList(), MesTraceCrossRegionWarningDO::getHandleUserId); Map userMap = adminUserApi.getUserMap(handleUserIds); List list = BeanUtils.toBean(pageResult.getList(), MesTraceCrossRegionWarningRespVO.class, vo -> { MesTraceCrossRegionWarningDO warning = pageResult.getList().stream() .filter(w -> ObjUtil.equal(w.getId(), vo.getId())).findFirst().orElse(null); if (warning == null) { return; } vo.setStatusName(statusName(warning.getStatus())); vo.setWarningTypeName(warningTypeName(warning.getWarningType())); MapUtils.findAndThen(userMap, warning.getHandleUserId(), user -> vo.setHandleUserName(user.getNickname())); }); return new PageResult<>(list, pageResult.getTotal()); } @Override public void handleWarning(MesTraceCrossRegionHandleReqVO reqVO) { MesTraceCrossRegionWarningDO warning = warningMapper.selectById(reqVO.getId()); if (warning == null) { throw exception(MES_TRACE_CROSS_REGION_WARNING_NOT_EXISTS); } if (!ObjUtil.equal(MesTraceWarningStatusEnum.PENDING.getStatus(), warning.getStatus())) { throw exception(MES_TRACE_CROSS_REGION_STATUS_INVALID); } if (!ObjUtil.equal(MesTraceWarningStatusEnum.HANDLED.getStatus(), reqVO.getStatus()) && !ObjUtil.equal(MesTraceWarningStatusEnum.FALSE_ALARM.getStatus(), reqVO.getStatus())) { throw exception(MES_TRACE_CROSS_REGION_STATUS_INVALID); } warningMapper.updateById(new MesTraceCrossRegionWarningDO() .setId(warning.getId()) .setStatus(reqVO.getStatus()) .setHandleRemark(reqVO.getHandleRemark()) .setHandleUserId(SecurityFrameworkUtils.getLoginUserId()) .setHandleTime(LocalDateTime.now())); } @Override public MesTraceCrossRegionSummaryRespVO getWarningSummary(LocalDateTime startTime, LocalDateTime endTime) { List list = warningMapper.selectListByEventTimeBetween(startTime, endTime); MesTraceCrossRegionSummaryRespVO vo = new MesTraceCrossRegionSummaryRespVO(); long total = list.size(); long pending = 0L; long handled = 0L; long falseAlarm = 0L; long crossProvince = 0L; long crossCity = 0L; for (MesTraceCrossRegionWarningDO w : list) { if (ObjUtil.equal(MesTraceWarningStatusEnum.PENDING.getStatus(), w.getStatus())) { pending++; } else if (ObjUtil.equal(MesTraceWarningStatusEnum.HANDLED.getStatus(), w.getStatus())) { handled++; } else if (ObjUtil.equal(MesTraceWarningStatusEnum.FALSE_ALARM.getStatus(), w.getStatus())) { falseAlarm++; } if (ObjUtil.equal(MesTraceWarningTypeEnum.CROSS_PROVINCE.getType(), w.getWarningType())) { crossProvince++; } else if (ObjUtil.equal(MesTraceWarningTypeEnum.CROSS_CITY.getType(), w.getWarningType())) { crossCity++; } } vo.setTotal(total).setPending(pending).setHandled(handled).setFalseAlarm(falseAlarm) .setCrossProvince(crossProvince).setCrossCity(crossCity); return vo; } @Override public List getWarningStatistics(MesTraceCrossRegionStatisticsReqVO reqVO) { java.util.function.Function extractor = STAT_GROUP_EXTRACTORS.get(reqVO.getGroupBy()); if (extractor == null) { throw new IllegalArgumentException("不支持的分组维度"); } List list = warningMapper.selectListByEventTimeBetween(reqVO.getStartTime(), reqVO.getEndTime()); Map counts = new LinkedHashMap<>(); for (MesTraceCrossRegionWarningDO w : list) { String key = StrUtil.nullToDefault(extractor.apply(w), "未知"); counts.merge(key, 1L, Long::sum); } return counts.entrySet().stream() .sorted(Map.Entry.comparingByValue().reversed()) .map(entry -> new MesTraceCrossRegionStatisticsRespVO().setName(entry.getKey()).setCount(entry.getValue())) .toList(); } @Override public MesTraceAntiConfigRespVO getConfig() { MesTraceAntiConfigDO config = antiConfigMapper.selectByConfigKey(CONFIG_KEY_WARNING_LEVEL); if (config == null) { throw exception(MES_TRACE_ANTI_CONFIG_NOT_EXISTS); } return new MesTraceAntiConfigRespVO().setWarningLevel(config.getConfigValue()).setRemark(config.getRemark()); } @Override public void updateConfig(String warningLevel) { if (!ObjUtil.equal("PROVINCE", warningLevel) && !ObjUtil.equal("CITY", warningLevel)) { throw exception(MES_TRACE_ANTI_CONFIG_VALUE_INVALID); } MesTraceAntiConfigDO config = antiConfigMapper.selectByConfigKey(CONFIG_KEY_WARNING_LEVEL); if (config == null) { throw exception(MES_TRACE_ANTI_CONFIG_NOT_EXISTS); } antiConfigMapper.updateById(new MesTraceAntiConfigDO().setId(config.getId()).setConfigValue(warningLevel)); } // ==================== 私有方法 ==================== /** * 读取判定粒度:PROVINCE 跨省 / CITY 跨市,配置缺失时默认按省判定 */ private String getWarningLevel() { MesTraceAntiConfigDO config = antiConfigMapper.selectByConfigKey(CONFIG_KEY_WARNING_LEVEL); return config != null ? StrUtil.nullToDefault(config.getConfigValue(), "PROVINCE") : "PROVINCE"; } /** * 按判定粒度比对扫码地区与经销商地区,返回命中预警类型,未命中返回 null */ private String matchWarningType(String level, String scanProvince, String scanCity, String dealerProvince, String dealerCity) { if ("CITY".equals(level)) { // 城市粒度:扫码市与经销商市不一致(需双方都有城市,否则退回省级判断) if (StrUtil.isNotBlank(scanCity) && StrUtil.isNotBlank(dealerCity) && !scanCity.equals(dealerCity)) { return MesTraceWarningTypeEnum.CROSS_CITY.getType(); } if (StrUtil.isNotBlank(scanProvince) && StrUtil.isNotBlank(dealerProvince) && !scanProvince.equals(dealerProvince)) { return MesTraceWarningTypeEnum.CROSS_PROVINCE.getType(); } return null; } // 省级粒度 if (StrUtil.isNotBlank(scanProvince) && StrUtil.isNotBlank(dealerProvince) && !scanProvince.equals(dealerProvince)) { return MesTraceWarningTypeEnum.CROSS_PROVINCE.getType(); } return null; } /** * 根据 Area id 向上解析省市区(与消费者扫码事件 IP 归属地解析口径一致,保证可比较) */ private String[] resolveRegion(Integer areaId) { if (areaId == null) { return new String[]{null, null}; } Area area = AreaUtils.getArea(areaId); if (area == null) { return new String[]{null, null}; } String province = null; String city = null; Area node = area; while (node != null) { Integer type = node.getType(); if (AreaTypeEnum.PROVINCE.getType().equals(type)) { province = node.getName(); } else if (AreaTypeEnum.CITY.getType().equals(type)) { city = node.getName(); } node = node.getParent(); } return new String[]{province, city}; } /** * 生成预警编号:WARN + 时间戳 + 随机序号 */ private String generateWarningNo() { return "WARN" + java.time.LocalDateTime.now() .format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + StrUtil.sub(IdUtil.fastSimpleUUID(), 0, 6).toUpperCase(); } private String statusName(Integer status) { if (status == null) { return ""; } for (MesTraceWarningStatusEnum e : MesTraceWarningStatusEnum.values()) { if (ObjUtil.equal(e.getStatus(), status)) { return e.getName(); } } return String.valueOf(status); } private String warningTypeName(String type) { if (type == null) { return ""; } for (MesTraceWarningTypeEnum e : MesTraceWarningTypeEnum.values()) { if (ObjUtil.equal(e.getType(), type)) { return e.getName(); } } return type; } }