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<MesProRouteFullExcelVO> 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<String, List<MesProRouteFullExcelVO>> 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<MesProRouteFullExcelVO> rows, boolean updateSupport) {
|
Map<String, List<MesProRouteFullExcelVO>> 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<MesProRouteFullExcelVO> 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<MesProRouteDO>().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<Long> 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<Long> 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<String, MesProRouteProcessDO> existingProcesses = new HashMap<>();
|
for (var process : processMapper.selectListByRouteId(routeId)) {
|
var op = operationMapper.selectById(process.getProcessId());
|
if (op != null) existingProcesses.put(op.getCode(), process);
|
}
|
Set<String> stepCodes = new HashSet<>(); Set<Integer> 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<String, MesProRouteProcessSaveReqVO.OutputItem> 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<String> 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<String> 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<Long> 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); }
|
}
|