package cn.iocoder.yudao.module.mes.service.pro.route; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.extra.spring.SpringUtil; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; import cn.iocoder.yudao.module.mes.controller.admin.pro.route.vo.*; import cn.iocoder.yudao.module.mes.controller.admin.pro.route.vo.process.MesProRouteProcessSaveReqVO; import cn.iocoder.yudao.module.mes.controller.admin.pro.route.vo.product.MesProRouteProductSaveReqVO; import cn.iocoder.yudao.module.mes.controller.admin.pro.route.vo.productbom.MesProRouteProductBomSaveReqVO; import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdItemDO; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.route.*; import cn.iocoder.yudao.module.mes.dal.mysql.md.item.MesMdItemMapper; import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemTypeService; import cn.iocoder.yudao.module.mes.service.pro.process.MesProProcessService; import cn.iocoder.yudao.module.mes.dal.mysql.pro.process.MesProProcessMapper; import cn.iocoder.yudao.module.mes.dal.mysql.pro.route.*; import cn.iocoder.yudao.module.mes.dal.mysql.wm.warehouse.*; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.*; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.*; /** 完整模板按路线分组逐组事务导入,不删除表格中未出现的其他产品或投料。 */ @Service @Slf4j public class MesProRouteFullImportService { @Resource private MesProRouteMapper routeMapper; @Resource private MesProRouteProductMapper productMapper; @Resource private MesProRouteProcessMapper processMapper; @Resource private MesProRouteProcessOutputMapper outputMapper; @Resource private MesProRouteProductBomMapper bomMapper; @Resource private MesMdItemMapper itemMapper; @Resource private MesMdItemTypeService itemTypeService; @Resource private MesProProcessMapper operationMapper; @Resource private MesProProcessService operationService; @Resource private MesWmWarehouseMapper warehouseMapper; @Resource private MesWmWarehouseLocationMapper locationMapper; @Resource private MesWmWarehouseAreaMapper areaMapper; @Resource private MesProRouteService routeService; @Resource private MesProRouteProductService productService; @Resource private MesProRouteProcessService processService; @Resource private MesProRouteProductBomService bomService; public MesProRouteImportRespVO importRows(List rows, boolean updateSupport) { if (CollUtil.isEmpty(rows)) throw exception(PRO_ROUTE_IMPORT_LIST_IS_EMPTY); var response = MesProRouteImportRespVO.builder().createCodes(new ArrayList<>()) .updateCodes(new ArrayList<>()).failureCodes(new LinkedHashMap<>()).build(); Map> groups = new LinkedHashMap<>(); for (int i = 0; i < rows.size(); i++) { var row = rows.get(i); if (row == null) continue; if (StrUtil.isBlank(row.getRecordType()) && StrUtil.isBlank(row.getRouteCode())) { boolean hasContent = cn.hutool.core.bean.BeanUtil.beanToMap(row).values().stream() .anyMatch(v -> v != null && (!(v instanceof String) || StrUtil.isNotBlank((String) v))); if (hasContent) response.getFailureCodes().put("第 " + (i + 2) + " 行", "记录类型和路线编码不能为空"); continue; } if (StrUtil.isBlank(row.getRouteCode())) { response.getFailureCodes().put("第 " + (i + 2) + " 行", "路线编码不能为空"); continue; } groups.computeIfAbsent(row.getRouteCode().trim(), k -> new ArrayList<>()).add(row); } // 缺失路线编码的行无法归组,拒绝整个文件以避免关联明细被悄悄遗漏。 if (!response.getFailureCodes().isEmpty()) return response; if (groups.isEmpty()) throw exception(PRO_ROUTE_IMPORT_LIST_IS_EMPTY); for (var entry : groups.entrySet()) { try { boolean created = SpringUtil.getBean(MesProRouteFullImportService.class) .importGroup(entry.getKey(), entry.getValue(), updateSupport); (created ? response.getCreateCodes() : response.getUpdateCodes()).add(entry.getKey()); } catch (ServiceException ex) { response.getFailureCodes().put(entry.getKey(), ex.getMessage()); } catch (Exception ex) { log.error("[importRows] 工艺路线完整模板导入失败,路线={}", entry.getKey(), ex); response.getFailureCodes().put(entry.getKey(), "导入失败,请检查模板内容或联系管理员"); } } return response; } @Transactional(rollbackFor = Exception.class) public boolean importGroup(String code, List rows, boolean updateSupport) { Map> types = new LinkedHashMap<>(); for (var row : rows) { String type = StrUtil.trimToEmpty(row.getRecordType()); if (!Set.of("路线", "产品", "工序", "产出", "投料").contains(type)) fail("记录类型必须为路线、产品、工序、产出或投料"); types.computeIfAbsent(type, k -> new ArrayList<>()).add(row); } var headers = types.getOrDefault("路线", List.of()); if (headers.size() != 1 || StrUtil.isBlank(headers.get(0).getRouteName())) fail("每条路线须有且仅有一条路线记录并填写名称"); var products = types.getOrDefault("产品", List.of()); List references = products.stream().filter(p -> yes(p.getTemplateReference(), "模板引用")).toList(); if (references.size() != 1 || StrUtil.isBlank(references.get(0).getProductCode())) fail("每条路线须有且仅有一个模板引用产品"); String referenceCode = references.get(0).getProductCode().trim(); if (MesProRouteFullExcelService.REFERENCE.equals(referenceCode)) fail("产品行须填写实际产品编码,不能填写 @模板产品"); var steps = types.getOrDefault("工序", List.of()); if (steps.isEmpty()) fail("至少需要一条工序记录"); if (steps.stream().anyMatch(p -> StrUtil.isBlank(p.getProcessCode()) || p.getProcessSort() == null)) fail("工序记录须填写工序编码和工序顺序"); if (steps.stream().noneMatch(p -> yes(p.getKeyFlag(), "关键工序"))) fail("至少需要一个关键工序"); var lastStep = steps.stream().max(Comparator.comparing(MesProRouteFullExcelVO::getProcessSort)).orElseThrow(); boolean hasFinalOutput = types.getOrDefault("产出", List.of()).stream().anyMatch(out -> StrUtil.equals(StrUtil.trimToEmpty(out.getProcessCode()), StrUtil.trimToEmpty(lastStep.getProcessCode())) && isReference(out.getProductCode(), referenceCode) && isReference(out.getItemCode(), referenceCode)); if (!hasFinalOutput) fail("末道工序必须有归属到模板产品自身的产出记录"); if (types.getOrDefault("投料", List.of()).stream().noneMatch(bom -> isReference(bom.getProductCode(), referenceCode))) fail("模板产品至少需要一条工序投料记录"); for (var row : products) if (StrUtil.isBlank(row.getProductCode()) || StrUtil.isBlank(row.getWarehouseCode()) || StrUtil.isBlank(row.getLocationCode()) || StrUtil.isBlank(row.getAreaCode())) fail("产品记录须填写产品、仓库、库区和库位编码"); MesProRouteDO route = routeMapper.selectByCode(code); boolean created = route == null; if (!created) { if (!updateSupport) fail("路线编码已存在,请勾选更新已有数据"); route = routeMapper.selectOne(new LambdaQueryWrapperX().eq(MesProRouteDO::getId, route.getId()).last("FOR UPDATE")); if (route == null) fail("路线不存在"); if (CommonStatusEnum.ENABLE.getStatus().equals(route.getStatus())) fail("路线已启用,请先停用后再导入更新"); } MesProRouteSaveReqVO routeReq = new MesProRouteSaveReqVO(); routeReq.setCode(code); routeReq.setName(headers.get(0).getRouteName().trim()); routeReq.setDescription(headers.get(0).getRouteDescription()); routeReq.setRemark(headers.get(0).getRouteRemark()); Long routeId; if (created) routeId = routeService.createRoute(routeReq); else { routeId = route.getId(); routeReq.setId(routeId); routeService.updateRoute(routeReq); } Set proposedProductIds = new HashSet<>(); for (var row : products) proposedProductIds.add(item(row.getProductCode(), referenceCode).getId()); boolean hasOtherProducts = productMapper.selectListByRouteId(routeId).stream() .anyMatch(p -> !proposedProductIds.contains(p.getItemId())); if (!created && hasOtherProducts && (!Objects.equals(route.getName(), routeReq.getName()) || !Objects.equals(StrUtil.nullToEmpty(route.getDescription()), StrUtil.nullToEmpty(routeReq.getDescription())) || !Objects.equals(StrUtil.nullToEmpty(route.getRemark()), StrUtil.nullToEmpty(routeReq.getRemark())))) fail("路线包含其他产品,不能通过单产品模板修改路线基本信息"); Set productIds = new HashSet<>(); Long refProductId = null; for (var row : products) { MesMdItemDO item = item(row.getProductCode(), referenceCode); if (!productIds.add(item.getId())) fail("产品重复:" + row.getProductCode()); var wh = warehouseMapper.selectByCode(row.getWarehouseCode().trim()); if (wh == null) fail("仓库不存在:" + row.getWarehouseCode()); var loc = locationMapper.selectByCode(wh.getId(), row.getLocationCode().trim()); if (loc == null) fail("库区不存在:" + row.getLocationCode()); var area = areaMapper.selectByCode(loc.getId(), row.getAreaCode().trim()); if (area == null) fail("库位不存在:" + row.getAreaCode()); if (row.getQuantity() != null && row.getQuantity() <= 0) fail("生产数量必须大于零"); if (row.getProductionTime() != null && row.getProductionTime().signum() < 0) fail("生产用时不能为负数"); MesProRouteProductSaveReqVO req = new MesProRouteProductSaveReqVO(); req.setRouteId(routeId); req.setItemId(item.getId()); req.setWarehouseId(wh.getId()); req.setLocationId(loc.getId()); req.setAreaId(area.getId()); req.setQuantity(row.getQuantity()); req.setProductionTime(row.getProductionTime()); req.setTimeUnitType(timeUnit(row.getTimeUnitType())); req.setRemark(row.getProductRemark()); MesProRouteProductDO existing = productMapper.selectByItemId(item.getId()); Long id; if (existing == null) id = productService.createRouteProduct(req); else { if (!existing.getRouteId().equals(routeId)) fail("产品已绑定其他路线:" + item.getCode()); if (hasOtherProducts && !item.getCode().equals(referenceCode) && (!Objects.equals(existing.getWarehouseId(), req.getWarehouseId()) || !Objects.equals(existing.getLocationId(), req.getLocationId()) || !Objects.equals(existing.getAreaId(), req.getAreaId()) || !Objects.equals(existing.getQuantity(), req.getQuantity()) || (existing.getProductionTime() == null ? req.getProductionTime() != null : req.getProductionTime() == null || existing.getProductionTime().compareTo(req.getProductionTime()) != 0) || !Objects.equals(StrUtil.nullToEmpty(existing.getTimeUnitType()), StrUtil.nullToEmpty(req.getTimeUnitType())) || !Objects.equals(StrUtil.nullToEmpty(existing.getRemark()), StrUtil.nullToEmpty(req.getRemark())))) fail("路线包含其他产品,不能通过单产品模板修改共用产出产品:" + item.getCode()); req.setId(existing.getId()); productService.updateRouteProduct(req); id = existing.getId(); } if (yes(row.getTemplateReference(), "模板引用")) refProductId = id; } productService.setTemplateReference(refProductId); Map existingProcesses = new HashMap<>(); for (var process : processMapper.selectListByRouteId(routeId)) { var op = operationMapper.selectById(process.getProcessId()); if (op != null) existingProcesses.put(op.getCode(), process); } Set stepCodes = new HashSet<>(); Set sorts = new HashSet<>(); for (var row : steps) { if (StrUtil.isBlank(row.getProcessCode()) || row.getProcessSort() == null) fail("工序记录须填写工序编码和工序顺序"); String processCode = row.getProcessCode().trim(); if (row.getProcessSort() < 0 || (row.getPrepareTime() != null && row.getPrepareTime() < 0) || (row.getWaitTime() != null && row.getWaitTime() < 0)) fail("工序顺序、准备时间和等待时间不能为负数:" + processCode); if (row.getLinkType() != null && (row.getLinkType() < 0 || row.getLinkType() > 3)) fail("工序关系只能填写 0、1、2 或 3:" + processCode); if (!stepCodes.add(processCode) || !sorts.add(row.getProcessSort())) fail("工序编码和工序顺序不能重复"); var operation = operationMapper.selectByCode(processCode); if (operation == null) fail("工序不存在:" + processCode); operationService.validateProcessExistsAndEnable(operation.getId()); MesProRouteProcessDO existing = existingProcesses.get(processCode); if (existing == null && hasOtherProducts) fail("路线包含其他产品,不能通过单产品模板新增共用工序:" + processCode); MesProRouteProcessSaveReqVO req = new MesProRouteProcessSaveReqVO(); req.setRouteId(routeId); req.setProcessId(operation.getId()); req.setSort(row.getProcessSort()); req.setLinkType(row.getLinkType()); req.setPrepareTime(row.getPrepareTime()); req.setWaitTime(row.getWaitTime()); req.setColorCode(row.getColorCode()); req.setKeyFlag(yes(row.getKeyFlag(), "关键工序")); req.setCheckFlag(yes(row.getCheckFlag(), "质检工序")); req.setBackflushFlag(yes(row.getBackflushFlag(), "倒冲")); req.setRemark(row.getProcessRemark()); if (existing != null && hasOtherProducts && (!Objects.equals(existing.getSort(), req.getSort()) || !Objects.equals(existing.getLinkType(), req.getLinkType()) || !Objects.equals(existing.getPrepareTime(), req.getPrepareTime()) || !Objects.equals(existing.getWaitTime(), req.getWaitTime()) || !Objects.equals(existing.getColorCode(), req.getColorCode()) || !Objects.equals(Boolean.TRUE.equals(existing.getKeyFlag()), req.getKeyFlag()) || !Objects.equals(Boolean.TRUE.equals(existing.getCheckFlag()), req.getCheckFlag()) || !Objects.equals(Boolean.TRUE.equals(existing.getBackflushFlag()), req.getBackflushFlag()) || !Objects.equals(StrUtil.nullToEmpty(existing.getRemark()), StrUtil.nullToEmpty(req.getRemark())))) { fail("路线包含其他产品,不能通过单产品模板修改共用工序:" + processCode); } // 保留其他产品的既有产出,避免一次导入删除整条路线的产出。 Map merged = new LinkedHashMap<>(); if (existing != null) { for (var out : outputMapper.selectListByRouteProcessId(existing.getId())) { var v = new MesProRouteProcessSaveReqVO.OutputItem(); v.setItemId(out.getItemId()); v.setProductId(out.getProductId()); v.setSort(out.getSort()); v.setRemark(out.getRemark()); merged.put(out.getProductId() + ":" + out.getItemId(), v); } // 旧数据只有 outputItemId 时也必须保留,不能因导入而清空。 if (merged.isEmpty() && StrUtil.isNotBlank(existing.getOutputItemId())) { for (String text : existing.getOutputItemId().split(",")) if (StrUtil.isNotBlank(text)) { Long id = Long.valueOf(text.trim()); var v = new MesProRouteProcessSaveReqVO.OutputItem(); Long owner = isFinished(id) && productMapper.selectListByRouteId(routeId).stream().anyMatch(p -> id.equals(p.getItemId())) ? id : null; v.setItemId(id); v.setProductId(owner); merged.put(owner + ":" + id, v); } } } Set importedOutputKeys = new HashSet<>(); for (var out : types.getOrDefault("产出", List.of())) { if (!processCode.equals(StrUtil.trimToEmpty(out.getProcessCode()))) continue; MesMdItemDO outputItem = item(out.getItemCode(), referenceCode); Long ownerId = StrUtil.isBlank(out.getProductCode()) ? null : item(out.getProductCode(), referenceCode).getId(); if (!productIds.contains(outputItem.getId())) fail("产出物料未出现在产品记录:" + outputItem.getCode()); if (ownerId != null && !productIds.contains(ownerId)) fail("产出归属产品未出现在产品记录"); var v = new MesProRouteProcessSaveReqVO.OutputItem(); v.setItemId(outputItem.getId()); v.setProductId(ownerId); v.setSort(out.getOutputSort()); v.setRemark(out.getDetailRemark()); String outputKey = ownerId + ":" + outputItem.getId(); if (!importedOutputKeys.add(outputKey)) fail("工序产出记录重复:" + processCode + " / " + outputItem.getCode()); if (hasOtherProducts && ownerId == null) { var old = merged.get(outputKey); if (old == null || !Objects.equals(old.getSort(), v.getSort()) || !Objects.equals(StrUtil.nullToEmpty(old.getRemark()), StrUtil.nullToEmpty(v.getRemark()))) fail("路线包含其他产品,不能通过单产品模板修改共用产出:" + outputItem.getCode()); } merged.put(outputKey, v); } req.setOutputs(new ArrayList<>(merged.values())); if (existing == null) processService.createRouteProcess(req); else { req.setId(existing.getId()); processService.updateRouteProcess(req); } } for (var row : types.getOrDefault("产出", List.of())) if (!stepCodes.contains(StrUtil.trimToEmpty(row.getProcessCode()))) fail("产出记录找不到对应的工序记录:" + row.getProcessCode()); Set bomKeys = new HashSet<>(); for (var row : types.getOrDefault("投料", List.of())) { String operationCode = StrUtil.trimToEmpty(row.getProcessCode()); if (!stepCodes.contains(operationCode)) fail("投料记录找不到对应的工序记录:" + operationCode); var op = operationMapper.selectByCode(operationCode); MesMdItemDO product = item(row.getProductCode(), referenceCode), material = item(row.getItemCode(), referenceCode); if (!productIds.contains(product.getId())) fail("投料产品未出现在产品记录:" + product.getCode()); if (row.getBomQuantity() == null || row.getBomQuantity().signum() <= 0) fail("投料比例必须大于零"); if (!bomKeys.add(op.getId() + ":" + product.getId() + ":" + material.getId())) fail("投料记录重复:" + material.getCode()); MesProRouteProductBomSaveReqVO req = new MesProRouteProductBomSaveReqVO(); req.setRouteId(routeId); req.setProcessId(op.getId()); req.setProductId(product.getId()); req.setItemId(material.getId()); req.setQuantity(row.getBomQuantity()); req.setRemark(row.getDetailRemark()); var existing = bomMapper.selectByUnique(material.getId(), op.getId(), product.getId()); if (hasOtherProducts && !product.getCode().equals(referenceCode) && (existing == null || existing.getQuantity() == null || existing.getQuantity().compareTo(req.getQuantity()) != 0 || !Objects.equals(StrUtil.nullToEmpty(existing.getRemark()), StrUtil.nullToEmpty(req.getRemark())))) fail("路线包含其他产品,不能通过单产品模板修改共用产品投料:" + product.getCode()); if (existing == null) bomService.createRouteProductBom(req); else { if (!existing.getRouteId().equals(routeId)) fail("投料记录属于其他路线"); req.setId(existing.getId()); bomService.updateRouteProductBom(req); } } return created; } private MesMdItemDO item(String code, String referenceCode) { if (StrUtil.isBlank(code)) { fail("物料编码不能为空"); return null; } String resolved = MesProRouteFullExcelService.REFERENCE.equals(code.trim()) ? referenceCode : code.trim(); MesMdItemDO item = itemMapper.selectByCode(resolved); if (item == null) fail("物料不存在:" + resolved); return item; } private boolean isReference(String value, String referenceCode) { return StrUtil.isNotBlank(value) && (MesProRouteFullExcelService.REFERENCE.equals(value.trim()) || referenceCode.equals(value.trim())); } private boolean isFinished(Long itemId) { MesMdItemDO source = itemMapper.selectById(itemId); if (source == null) fail("原工序的产出物料不存在:" + itemId); var type = itemTypeService.getItemType(source.getItemTypeId()); Set seen = new HashSet<>(); while (type != null && seen.add(type.getId())) { if ("FG".equals(type.getCode())) return true; if (type.getParentId() == null || type.getParentId() == 0L) break; type = itemTypeService.getItemType(type.getParentId()); } return false; } private String timeUnit(String value) { if (StrUtil.isBlank(value)) return null; return switch (value.trim()) { case "分钟", "MINUTE" -> "MINUTE"; case "小时", "HOUR" -> "HOUR"; case "天", "DAY" -> "DAY"; // 保留历史数据中的旧单位编码,不推测其含义。 case "1", "2", "3" -> value.trim(); default -> throw exception(PRO_ROUTE_FULL_EXCEL_INVALID, "生产时间单位只能填写分钟、小时或天"); }; } private boolean yes(String value, String field) { if (StrUtil.isBlank(value) || "否".equals(value.trim())) return false; if ("是".equals(value.trim())) return true; fail(field + "只能填写是或否"); return false; } private void fail(String message) { throw exception(PRO_ROUTE_FULL_EXCEL_INVALID, message); } }