src/main/java/com/ruoyi/production/service/impl/ProductionBomStructureServiceImpl.java
@@ -3,6 +3,7 @@
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.basic.mapper.ProductModelMapper;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.production.bean.dto.ProductionBomStructureDto;
import com.ruoyi.production.bean.vo.ProductionBomStructureVo;
@@ -23,6 +24,7 @@
import com.ruoyi.production.pojo.ProductionOrderRoutingOperationParam;
import com.ruoyi.production.pojo.ProductionProductMain;
import com.ruoyi.production.service.ProductionBomStructureService;
import com.ruoyi.production.util.TaskPlanQuantityUtil;
import com.ruoyi.technology.mapper.TechnologyOperationMapper;
import com.ruoyi.technology.mapper.TechnologyOperationParamMapper;
import com.ruoyi.technology.mapper.TechnologyParamMapper;
@@ -30,7 +32,6 @@
import com.ruoyi.technology.pojo.TechnologyOperationParam;
import com.ruoyi.technology.pojo.TechnologyParam;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -60,123 +61,137 @@
    private final ProductionOrderRoutingOperationParamMapper productionOrderRoutingOperationParamMapper;
    private final ProductionOperationTaskMapper productionOperationTaskMapper;
    private final ProductionProductMainMapper productionProductMainMapper;
    private final ProductModelMapper productModelMapper;
    private final TechnologyOperationMapper technologyOperationMapper;
    private final TechnologyOperationParamMapper technologyOperationParamMapper;
    private final TechnologyParamMapper technologyParamMapper;
    /**
     * 根据BOM查询并组装结构树。
     * 按订单BOM查询扁平物料明细。
     */
    @Override
    public List<ProductionBomStructureVo> listByBomId(Long bomId) {
        // 按BOMID查询生产结构数据
        List<ProductionBomStructureVo> list = productionBomStructureMapper.listByBomId(bomId);
        Map<Long, ProductionBomStructureVo> map = new HashMap<>();
        for (ProductionBomStructureVo node : list) {
            node.setChildren(new ArrayList<>());
            map.put(node.getId(), node);
        if (bomId == null) {
            return new ArrayList<>();
        }
        List<ProductionBomStructureVo> tree = new ArrayList<>();
        for (ProductionBomStructureVo node : list) {
            Long parentId = node.getParentId();
            if (parentId == null || parentId == 0L) {
                tree.add(node);
                continue;
            }
            ProductionBomStructureVo parent = map.get(parentId);
            if (parent != null) {
                parent.getChildren().add(node);
            }
        }
        return tree;
        return productionBomStructureMapper.listByBomId(bomId);
    }
    /**
     * 按生产订单查询订单BOM快照的扁平物料明细。
     */
    @Override
    public List<ProductionBomStructureVo> listByOrderId(Long productionOrderId) {
        if (productionOrderId == null) {
            return new ArrayList<>();
        }
        ProductionOrderBom orderBom = productionOrderBomMapper.selectOne(
                Wrappers.<ProductionOrderBom>lambdaQuery()
                        .eq(ProductionOrderBom::getProductionOrderId, productionOrderId)
                        .orderByDesc(ProductionOrderBom::getId)
                        .last("limit 1"));
        if (orderBom == null || orderBom.getId() == null) {
            return new ArrayList<>();
        }
        return listByBomId(orderBom.getId());
    }
    /**
     * 全量同步订单BOM快照的扁平物料明细,并按其中的工序重建订单工艺路线快照与工单。
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Boolean addProductionBomStructure(ProductionBomStructureDto dto) {
        // 新增生产BOM结构
        // 读取当前订单BOM主键,并把前端树结构拍平成列表
        Long orderBomId = dto.getProductionOrderBomId();
        List<ProductionBomStructureDto> flatDtoList = new ArrayList<>();
        flattenTree(dto.getChildren(), flatDtoList);
        if (orderBomId == null) {
            throw new ServiceException("订单BOM ID不能为空");
        }
        ProductionOrderBom orderBom = productionOrderBomMapper.selectById(orderBomId);
        if (orderBom == null) {
            throw new ServiceException("订单BOM不存在");
        }
        Long productionOrderId = dto.getProductionOrderId() != null
                ? dto.getProductionOrderId()
                : orderBom.getProductionOrderId();
        // 查询数据库已有结构,用于后续做增删改对比
        List<ProductionBomStructureDto> detailList = dto.getDetailList() == null
                ? Collections.emptyList()
                : dto.getDetailList();
        List<ProductionBomStructure> dbList = this.list(new LambdaQueryWrapper<ProductionBomStructure>()
                .eq(ProductionBomStructure::getProductionOrderBomId, orderBomId));
        Set<Long> dbIds = dbList.stream().map(ProductionBomStructure::getId).collect(Collectors.toSet());
        // 收集前端仍然存在的节点ID
        Set<Long> frontendIds = new HashSet<>();
        for (ProductionBomStructureDto item : flatDtoList) {
            if (item.getId() != null) {
                frontendIds.add(item.getId());
            }
        }
        // 计算需要删除的节点(数据库有、前端已删除)
        Set<Long> deleteIds = new HashSet<>();
        for (ProductionBomStructure dbItem : dbList) {
            if (!frontendIds.contains(dbItem.getId())) {
                deleteIds.add(dbItem.getId());
            }
        }
        // 先删掉前端已经移除的节点
        if (!deleteIds.isEmpty()) {
            this.removeByIds(deleteIds);
        }
        // 按是否有ID拆分为新增和更新,同时缓存新增节点的临时ID映射
        Set<Long> submittedIds = new HashSet<>();
        Set<String> relationKeys = new HashSet<>();
        List<ProductionBomStructure> insertList = new ArrayList<>();
        List<ProductionBomStructure> updateList = new ArrayList<>();
        Map<String, ProductionBomStructure> tempEntityMap = new HashMap<>();
        for (ProductionBomStructureDto item : flatDtoList) {
        for (ProductionBomStructureDto item : detailList) {
            Long productModelId = item.getProductModelId();
            Long technologyOperationId = item.getTechnologyOperationId();
            if (productModelId == null) {
                throw new ServiceException("请选择物料规格");
            }
            if (productModelMapper.selectById(productModelId) == null) {
                throw new ServiceException("物料规格不存在");
            }
            if (technologyOperationId == null) {
                throw new ServiceException("请选择消耗工序");
            }
            if (technologyOperationMapper.selectById(technologyOperationId) == null) {
                throw new ServiceException("消耗工序不存在");
            }
            if (productModelId.equals(orderBom.getProductModelId())) {
                throw new ServiceException("成品规格不能作为自身的物料");
            }
            if (!relationKeys.add(productModelId + "#" + technologyOperationId)) {
                throw new ServiceException("同一物料规格在同一工序下不能重复配置");
            }
            ProductionBomStructure entity = new ProductionBomStructure();
            BeanUtils.copyProperties(item, entity);
            entity.setProductionOrderBomId(orderBomId);
            entity.setProductionOrderId(productionOrderId);
            entity.setProductModelId(productModelId);
            entity.setTechnologyOperationId(technologyOperationId);
            // 扁平快照不再使用层级与数量语义
            entity.setParentId(null);
            entity.setUnitQuantity(null);
            entity.setDemandedQuantity(null);
            entity.setUnit(null);
            if (item.getId() == null) {
                entity.setParentId(null);
                insertList.add(entity);
                tempEntityMap.put(item.getTempId(), entity);
            } else {
                if (!dbIds.contains(item.getId())) {
                    throw new ServiceException("订单BOM物料明细不存在或不属于当前订单BOM");
                }
                entity.setId(item.getId());
                submittedIds.add(item.getId());
                updateList.add(entity);
            }
        }
        // 批量新增,拿到数据库生成的真实ID
        Set<Long> deleteIds = new HashSet<>(dbIds);
        deleteIds.removeAll(submittedIds);
        if (!deleteIds.isEmpty()) {
            this.removeByIds(deleteIds);
        }
        if (!updateList.isEmpty()) {
            this.updateBatchById(updateList);
        }
        if (!insertList.isEmpty()) {
            this.saveBatch(insertList);
        }
        // 新增节点二次回写父ID(前端传的是临时父ID)
        List<ProductionBomStructure> parentFixList = new ArrayList<>();
        for (ProductionBomStructureDto item : flatDtoList) {
            if (item.getId() == null && item.getParentTempId() != null) {
                ProductionBomStructure child = tempEntityMap.get(item.getTempId());
                if (child == null) {
                    continue;
                }
                ProductionBomStructure parent = tempEntityMap.get(item.getParentTempId());
                // 父节点是本次新增时,直接用新增后的真实ID;否则回退为前端传入父ID
                Long realParentId = parent != null ? parent.getId() : Long.valueOf(item.getParentTempId());
                child.setParentId(realParentId);
                parentFixList.add(child);
            }
        }
        // 回写新增节点的父子关系
        if (!parentFixList.isEmpty()) {
            this.updateBatchById(parentFixList);
        }
        // 批量更新已有节点
        if (!updateList.isEmpty()) {
            this.updateBatchById(updateList);
        }
        syncDemandedQuantityAndTaskPlanQuantity(orderBomId, dto.getProductionOrderId());
        syncRoutingAndTaskByStructure(orderBomId, productionOrderId);
        return true;
    }
    private void syncDemandedQuantityAndTaskPlanQuantity(Long orderBomId, Long productionOrderId) {
    /**
     * 订单BOM物料变化后,按其工序集合重建订单工艺路线工序快照与工单计划数量。
     */
    private void syncRoutingAndTaskByStructure(Long orderBomId, Long productionOrderId) {
        if (orderBomId == null) {
            return;
        }
@@ -193,52 +208,20 @@
            return;
        }
        BigDecimal orderQuantity = defaultDecimal(productionOrder.getQuantity());
        List<ProductionBomStructure> structureList = this.list(
                Wrappers.<ProductionBomStructure>lambdaQuery()
                        .eq(ProductionBomStructure::getProductionOrderBomId, orderBomId)
                        .orderByAsc(ProductionBomStructure::getId));
        //同步需求数量
        syncStructureDemandedQuantity(structureList, orderQuantity);
        Long rootProductModelId = orderBom.getProductModelId() != null ? orderBom.getProductModelId() : productionOrder.getProductModelId();
        //同步生产工艺路线
        Long rootProductModelId = orderBom.getProductModelId() != null
                ? orderBom.getProductModelId()
                : productionOrder.getProductModelId();
        // 同步生产工艺路线快照
        syncRoutingOperationsByBom(currentProductionOrderId, productionOrder, orderBom, structureList, rootProductModelId);
        //同步工单
        syncTaskPlanQuantity(
                currentProductionOrderId,
                structureList,
                orderQuantity,
                rootProductModelId);
        // 同步工单计划数量:扁平BOM无数量配置,计划数量统一取订单数量
        syncTaskPlanQuantity(currentProductionOrderId, TaskPlanQuantityUtil.resolveTaskPlanQuantity(productionOrder));
    }
    private void syncStructureDemandedQuantity(List<ProductionBomStructure> structureList, BigDecimal orderQuantity) {
        if (structureList == null || structureList.isEmpty()) {
            return;
        }
        List<ProductionBomStructure> updateList = new ArrayList<>();
        for (ProductionBomStructure structure : structureList) {
            if (structure == null || structure.getId() == null) {
                continue;
            }
            BigDecimal demandedQuantity = defaultDecimal(structure.getUnitQuantity()).multiply(orderQuantity);
            if (compareDecimal(structure.getDemandedQuantity(), demandedQuantity) == 0) {
                continue;
            }
            ProductionBomStructure update = new ProductionBomStructure();
            update.setId(structure.getId());
            update.setDemandedQuantity(demandedQuantity);
            updateList.add(update);
            structure.setDemandedQuantity(demandedQuantity);
        }
        if (!updateList.isEmpty()) {
            this.updateBatchById(updateList);
        }
    }
    private void syncTaskPlanQuantity(Long productionOrderId,
                                      List<ProductionBomStructure> structureList,
                                      BigDecimal orderQuantity,
                                      Long rootProductModelId) {
    private void syncTaskPlanQuantity(Long productionOrderId, BigDecimal planQuantity) {
        List<ProductionOperationTask> taskList = productionOperationTaskMapper.selectList(
                Wrappers.<ProductionOperationTask>lambdaQuery()
                        .eq(ProductionOperationTask::getProductionOrderId, productionOrderId)
@@ -246,33 +229,10 @@
        if (taskList == null || taskList.isEmpty()) {
            return;
        }
        Set<Long> routingOperationIds = taskList.stream()
                .map(ProductionOperationTask::getProductionOrderRoutingOperationId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        if (routingOperationIds.isEmpty()) {
            return;
        }
        Map<Long, ProductionOrderRoutingOperation> routingOperationMap = productionOrderRoutingOperationMapper
                .selectBatchIds(routingOperationIds)
                .stream()
                .filter(item -> item != null && item.getId() != null)
                .collect(Collectors.toMap(ProductionOrderRoutingOperation::getId, item -> item, (left, right) -> left));
        // Keep task plan quantities aligned with the same order BOM snapshot demand used during snapshot creation.
        Map<String, BigDecimal> demandedQuantityMap = buildOperationDemandedQuantityMap(structureList, rootProductModelId);
        for (ProductionOperationTask task : taskList) {
            if (task == null || task.getId() == null || task.getProductionOrderRoutingOperationId() == null) {
            if (task == null || task.getId() == null) {
                continue;
            }
            ProductionOrderRoutingOperation routingOperation = routingOperationMap.get(task.getProductionOrderRoutingOperationId());
            if (routingOperation == null) {
                continue;
            }
            BigDecimal planQuantity = resolveTaskPlanQuantity(
                    routingOperation,
                    demandedQuantityMap,
                    orderQuantity,
                    rootProductModelId);
            if (compareDecimal(task.getPlanQuantity(), planQuantity) == 0) {
                continue;
            }
@@ -370,30 +330,30 @@
        return orderRouting;
    }
    /**
     * 扁平BOM按工序去重生成期望的订单工序列表,
     * 顺序取物料明细中工序首次出现的顺序,产出规格固定为订单成品规格。
     */
    private List<ProductionOrderRoutingOperation> buildDesiredRoutingOperationList(List<ProductionBomStructure> structureList,
                                                                                   Long rootProductModelId) {
        if (structureList == null || structureList.isEmpty()) {
            return Collections.emptyList();
        }
        Map<Long, ProductionBomStructure> structureById = structureList.stream()
                .filter(item -> item != null && item.getId() != null)
                .collect(Collectors.toMap(ProductionBomStructure::getId, item -> item, (left, right) -> left));
        Map<String, ProductionBomStructure> uniqueOperationMap = new LinkedHashMap<>();
        for (ProductionBomStructure bomStructure : structureList) {
            if (bomStructure == null || bomStructure.getTechnologyOperationId() == null) {
        Set<Long> uniqueOperationIdList = new LinkedHashSet<>();
        for (ProductionBomStructure structure : structureList) {
            if (structure == null || structure.getTechnologyOperationId() == null) {
                continue;
            }
            Long outputProductModelId = resolveOutputProductModelId(resolveOperationOutputNode(bomStructure, structureById), rootProductModelId);
            uniqueOperationMap.putIfAbsent(buildBomOperationDedupKey(bomStructure, outputProductModelId), bomStructure);
            uniqueOperationIdList.add(structure.getTechnologyOperationId());
        }
        List<ProductionOrderRoutingOperation> desiredOperationList = new ArrayList<>();
        List<ProductionOrderRoutingOperation> desiredOperationList = new ArrayList<>(uniqueOperationIdList.size());
        int dragSort = 1;
        for (ProductionBomStructure bomStructure : uniqueOperationMap.values()) {
            Long outputProductModelId = resolveOutputProductModelId(resolveOperationOutputNode(bomStructure, structureById), rootProductModelId);
            TechnologyOperation technologyOperation = getTechnologyOperation(bomStructure.getTechnologyOperationId());
        for (Long operationId : uniqueOperationIdList) {
            TechnologyOperation technologyOperation = getTechnologyOperation(operationId);
            ProductionOrderRoutingOperation routingOperation = new ProductionOrderRoutingOperation();
            routingOperation.setProductModelId(outputProductModelId);
            routingOperation.setTechnologyOperationId(bomStructure.getTechnologyOperationId());
            routingOperation.setProductModelId(rootProductModelId);
            routingOperation.setTechnologyOperationId(operationId);
            routingOperation.setOperationName(technologyOperation == null ? null : technologyOperation.getName());
            routingOperation.setIsQuality(technologyOperation == null ? null : technologyOperation.getIsQuality());
            routingOperation.setIsProduction(technologyOperation == null ? null : technologyOperation.getIsProduction());
@@ -568,7 +528,7 @@
            return;
        }
        if (defaultDecimal(task.getCompleteQuantity()).compareTo(BigDecimal.ZERO) > 0) {
            throw new ServiceException("工序已产生报工记录,无法根据 BOM 变更删除对应工序快照");
            throw new ServiceException("工序已产生报工记录,无法根据 BOM 变更删除对应工序快照" + task.getWorkOrderNo());
        }
        long reportCount = productionProductMainMapper.selectCount(
                Wrappers.<ProductionProductMain>lambdaQuery()
@@ -609,94 +569,8 @@
        }
    }
    private Map<String, BigDecimal> buildOperationDemandedQuantityMap(List<ProductionBomStructure> structureList,
                                                                      Long rootProductModelId) {
        if (structureList == null || structureList.isEmpty()) {
            return Collections.emptyMap();
        }
        Map<Long, ProductionBomStructure> structureById = structureList.stream()
                .filter(item -> item != null && item.getId() != null)
                .collect(Collectors.toMap(ProductionBomStructure::getId, item -> item, (left, right) -> left));
        Map<String, BigDecimal> demandedQuantityMap = new HashMap<>();
        Set<String> mergedOutputNodeKeySet = new HashSet<>();
        for (ProductionBomStructure bomStructure : structureList) {
            if (bomStructure == null || bomStructure.getTechnologyOperationId() == null) {
                continue;
            }
            // Resolve the output node first, then read the output node demand for the task plan quantity.
            ProductionBomStructure outputNode = resolveOperationOutputNode(bomStructure, structureById);
            Long outputProductModelId = resolveOutputProductModelId(outputNode, rootProductModelId);
            if (outputProductModelId == null) {
                continue;
            }
            String mergedOutputNodeKey = buildOperationOutputNodeKey(
                    bomStructure.getTechnologyOperationId(),
                    outputNode == null ? null : outputNode.getId(),
                    outputProductModelId);
            if (!mergedOutputNodeKeySet.add(mergedOutputNodeKey)) {
                continue;
            }
            // Multiple input rows can point to the same output node, so only count that output demand once.
            String key = buildOperationDemandedQuantityKey(bomStructure.getTechnologyOperationId(), outputProductModelId);
            demandedQuantityMap.merge(key, defaultDecimal(outputNode == null ? null : outputNode.getDemandedQuantity()), BigDecimal::add);
        }
        return demandedQuantityMap;
    }
    private BigDecimal resolveTaskPlanQuantity(ProductionOrderRoutingOperation routingOperation,
                                               Map<String, BigDecimal> demandedQuantityMap,
                                               BigDecimal orderQuantity,
                                               Long rootProductModelId) {
        if (routingOperation == null || demandedQuantityMap == null || demandedQuantityMap.isEmpty()) {
            return orderQuantity;
        }
        Long outputProductModelId = routingOperation.getProductModelId() != null
                ? routingOperation.getProductModelId()
                : rootProductModelId;
        String key = buildOperationDemandedQuantityKey(
                routingOperation.getTechnologyOperationId(),
                outputProductModelId);
        BigDecimal planQuantity = demandedQuantityMap.get(key);
        return planQuantity != null ? planQuantity : orderQuantity;
    }
    private String buildOperationDemandedQuantityKey(Long operationId, Long outputProductModelId) {
        return String.valueOf(operationId) + "#" + String.valueOf(outputProductModelId);
    }
    private String buildRoutingOperationBucketKey(Long operationId, Long outputProductModelId) {
        return String.valueOf(operationId) + "#" + String.valueOf(outputProductModelId);
    }
    private String buildBomOperationDedupKey(ProductionBomStructure bomStructure, Long outputProductModelId) {
        Long operationId = bomStructure == null ? null : bomStructure.getTechnologyOperationId();
        Long parentId = bomStructure == null ? null : bomStructure.getParentId();
        return operationId + "#" + outputProductModelId + "#" + parentId;
    }
    private String buildOperationOutputNodeKey(Long operationId, Long outputNodeId, Long outputProductModelId) {
        return String.valueOf(operationId) + "#" + String.valueOf(outputNodeId) + "#" + String.valueOf(outputProductModelId);
    }
    private ProductionBomStructure resolveOperationOutputNode(ProductionBomStructure bomStructure,
                                                              Map<Long, ProductionBomStructure> structureById) {
        if (bomStructure == null) {
            return null;
        }
        // The root node is the first output node; other rows use their direct parent as the current operation output.
        if (bomStructure.getParentId() == null) {
            return bomStructure;
        }
        ProductionBomStructure parent = structureById.get(bomStructure.getParentId());
        return parent != null ? parent : bomStructure;
    }
    private Long resolveOutputProductModelId(ProductionBomStructure outputNode,
                                             Long rootProductModelId) {
        if (outputNode == null) {
            return rootProductModelId;
        }
        return outputNode.getProductModelId() != null ? outputNode.getProductModelId() : rootProductModelId;
    }
    private TechnologyOperation getTechnologyOperation(Long technologyOperationId) {
@@ -730,20 +604,6 @@
    private int compareDecimal(BigDecimal left, BigDecimal right) {
        return defaultDecimal(left).compareTo(defaultDecimal(right));
    }
    /**
     * 将树形结构拍平成列表,便于统一保存。
     */
    private void flattenTree(List<ProductionBomStructureDto> source, List<ProductionBomStructureDto> result) {
        // 扁平化处理树
        if (source == null) {
            return;
        }
        for (ProductionBomStructureDto node : source) {
            result.add(node);
            flattenTree(node.getChildren(), result);
        }
    }
}