fix: 领料功能修改,移除订单领料,添加工序领料功能,支持按工序查询物料与已领料记录
已添加1个文件
已修改33个文件
2356 ■■■■ 文件已修改
doc/sql/20260812_flat_bom_and_operation_pick.sql 121 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/bean/dto/ProductionBomStructureDto.java 11 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/bean/dto/ProductionOrderPickDto.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/bean/vo/ProductionBomStructureVo.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/bean/vo/ProductionOrderPickVo.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/controller/ProductionBomStructureController.java 12 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/controller/ProductionOrderPickController.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/mapper/ProductionOrderPickMapper.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/pojo/ProductionOrderPick.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/pojo/ProductionProductInput.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/ProductionBomStructureService.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/ProductionOrderPickService.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/ProductionOrderService.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionBomStructureServiceImpl.java 514 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionOrderPickServiceImpl.java 244 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionOrderRoutingOperationServiceImpl.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionOrderServiceImpl.java 237 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionProductMainServiceImpl.java 120 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/util/TaskPlanQuantityUtil.java 119 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/impl/SalesLedgerProductServiceImpl.java 31 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/technology/bean/dto/BomImportDto.java 35 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/technology/bean/dto/TechnologyBomStructureDto.java 11 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/technology/bean/dto/TechnologyRoutingOperationDto.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/technology/bean/vo/TechnologyBomStructureVo.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/technology/controller/TechnologyBomStructureController.java 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/technology/controller/TechnologyRoutingOperationController.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/technology/service/TechnologyRoutingOperationService.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/technology/service/impl/TechnologyBomServiceImpl.java 370 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/technology/service/impl/TechnologyBomStructureServiceImpl.java 158 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/technology/service/impl/TechnologyRoutingOperationServiceImpl.java 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/technology/service/impl/TechnologyRoutingServiceImpl.java 63 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/production/ProductionBomStructureMapper.xml 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/production/ProductionOrderPickMapper.xml 57 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/technology/TechnologyBomStructureMapper.xml 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/sql/20260812_flat_bom_and_operation_pick.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,121 @@
-- =============================================================================
-- æ‰å¹³ BOM + å·¥åºç»Ÿä¸€é¢†æ–™ + æŠ¥å·¥é¢†æ–™é—¨ç¦ æ•°æ®åº“迁移脚本
-- é€‚用版本:2026-08-12 éœ€æ±‚(简化BOM配置 / å·¥è‰ºè·¯çº¿æŒ‰å·¥åºç”Ÿæˆ / é¢†æ–™ä¸‹æ²‰åˆ°å·¥åºæŠ¥å·¥ï¼‰
-- æ‰§è¡Œé¡ºåºï¼šå…ˆæ‰§è¡Œæœ¬è„šæœ¬ï¼ˆåŠ å­—æ®µ + å›žå¡« + æ•°æ®æ£€æŸ¥ï¼‰ï¼Œå†å‘布后端代码。
-- è¯´æ˜Žï¼š
--   1. æœ¬è„šæœ¬ä¸ç‰©ç†åˆ é™¤ technology_bom_structure / production_bom_structure çš„
--      åŽ†å²å±‚çº§ä¸Žæ•°é‡åˆ—ï¼ˆparent_id / unit_quantity / demanded_quantity / unit),
--      ä»…停止使用其业务语义,保证历史订单与报表可回溯。
--   2. ç¬¬ 3 éƒ¨åˆ†æ˜¯â€œæ•°æ®æ£€æŸ¥â€ï¼Œéœ€è¦äººå·¥ç¡®è®¤åŽå†å†³å®šæ˜¯å¦æ‰§è¡Œå¯¹åº”的清理语句。
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 1. é¢†æ–™ä¸»è®°å½•增加工序工单归属:领料从订单级下沉到工序(工单)级
-- -----------------------------------------------------------------------------
ALTER TABLE production_order_pick
    ADD COLUMN production_operation_task_id BIGINT NULL COMMENT '生产工单id(工序任务),领料归属的工序执行单元' AFTER production_order_id;
CREATE INDEX idx_pop_operation_task ON production_order_pick (production_operation_task_id);
CREATE INDEX idx_pop_order_operation ON production_order_pick (production_order_id, technology_operation_id);
-- å›žå¡«åŽ†å²é¢†æ–™çš„å·¥å•å½’å±žï¼š
-- åªå›žå¡«èƒ½å¤Ÿå”¯ä¸€ç¡®è®¤çš„记录(同一条领料的流水里工单ID唯一)。
-- æ— æ³•唯一确认的历史数据保持为 NULL:它们仍会出现在订单领料汇总中,
-- ä½†ä¸ä¼šè¢«å½“作某道工序的领料,因此不会错误解锁工序报工。
UPDATE production_order_pick pop
    JOIN (SELECT pick_id,
                 MIN(production_operation_task_id) AS task_id
          FROM production_order_pick_record
          WHERE production_operation_task_id IS NOT NULL
          GROUP BY pick_id
          HAVING COUNT(DISTINCT production_operation_task_id) = 1) r
    ON r.pick_id = pop.id
SET pop.production_operation_task_id = r.task_id
WHERE pop.production_operation_task_id IS NULL;
-- -----------------------------------------------------------------------------
-- 2. æŠ¥å·¥æŠ•入记录来源领料:保证同一笔领料只被一次报工归集,避免重复投入
-- -----------------------------------------------------------------------------
ALTER TABLE production_product_input
    ADD COLUMN production_order_pick_id BIGINT NULL COMMENT '来源领料记录id(production_order_pick.id)' AFTER product_model_id;
-- å”¯ä¸€ç´¢å¼•:MySQL å…è®¸å¤šä¸ª NULL,历史投入记录(无来源领料)不受影响,
-- æ–°æ•°æ®åˆ™ä¿è¯ä¸€æ¡é¢†æ–™ä¸ä¼šè¢«ä¸¤æ¡æŠ¥å·¥æŠ•入重复引用。
CREATE UNIQUE INDEX uk_ppi_production_order_pick ON production_product_input (production_order_pick_id);
-- -----------------------------------------------------------------------------
-- 3. åŽ†å² BOM æ•°æ®æ£€æŸ¥ä¸Žæ¸…理(先查,再由业务确认后执行清理)
-- -----------------------------------------------------------------------------
-- 3.1 æ£€æŸ¥ï¼šæ—§çš„ BOM æˆå“æ ¹èŠ‚ç‚¹ï¼ˆæ— çˆ¶èŠ‚ç‚¹ã€æ— å·¥åºï¼Œä¸”è§„æ ¼ç­‰äºŽ BOM æˆå“è§„格)。
-- è¿™äº›è¡Œä¸æ˜¯ç‰©æ–™ï¼Œæ–°ç‰ˆæœ¬æŸ¥è¯¢å·²å°†å…¶è¿‡æ»¤ï¼Œå»ºè®®ç¡®è®¤åŽåˆ é™¤ã€‚
SELECT tbs.id,
       tbs.bom_id,
       tb.bom_no,
       tbs.product_model_id
FROM technology_bom_structure tbs
         JOIN technology_bom tb ON tbs.bom_id = tb.id
WHERE tbs.parent_id IS NULL
  AND tbs.operation_id IS NULL
  AND tbs.product_model_id = tb.product_model_id;
-- 3.1 æ¸…理(确认无误后执行)
-- DELETE tbs
-- FROM technology_bom_structure tbs
--          JOIN technology_bom tb ON tbs.bom_id = tb.id
-- WHERE tbs.parent_id IS NULL
--   AND tbs.operation_id IS NULL
--   AND tbs.product_model_id = tb.product_model_id;
-- 3.2 æ£€æŸ¥ï¼šç¼ºå°‘消耗工序的物料行。
-- æ‰å¹³ BOM è¦æ±‚每个物料都必须指定消耗工序,否则该 BOM æ— æ³•生成工艺路线,
-- å¿…须在“产品结构详情”页面补齐,不能默认给一个工序。
SELECT tbs.id,
       tbs.bom_id,
       tb.bom_no,
       tbs.product_model_id
FROM technology_bom_structure tbs
         JOIN technology_bom tb ON tbs.bom_id = tb.id
WHERE tbs.operation_id IS NULL
  AND NOT (tbs.parent_id IS NULL AND tbs.product_model_id = tb.product_model_id);
-- 3.3 æ£€æŸ¥ï¼šåŒä¸€ BOM ä¸‹é‡å¤çš„“物料规格 + æ¶ˆè€—工序”。
-- æ–°ç‰ˆæœ¬ä¿å­˜æ—¶ä¼šæ‹’绝重复配置,历史重复数据需要先合并。
SELECT bom_id,
       product_model_id,
       operation_id,
       COUNT(*) AS duplicate_count
FROM technology_bom_structure
WHERE operation_id IS NOT NULL
GROUP BY bom_id, product_model_id, operation_id
HAVING COUNT(*) > 1;
-- 3.4 æ£€æŸ¥ï¼šåŽ†å²å¤šå±‚ BOM ä¸­æ›¾ä½œä¸ºâ€œä¸Šä¸€é“工序产出”的中间节点。
-- è¿™äº›è¡ŒåŽŸæœ¬ä»£è¡¨è‡ªåˆ¶åŠæˆå“äº§å‡ºï¼Œä¸æ˜¯éœ€è¦é¢†ç”¨çš„ç‰©æ–™ã€‚
-- æ‰å¹³åŒ–后它们会变成可领用物料,需要业务确认是否删除。
SELECT child.id,
       child.bom_id,
       tb.bom_no,
       child.product_model_id,
       child.operation_id
FROM technology_bom_structure child
         JOIN technology_bom tb ON child.bom_id = tb.id
WHERE EXISTS (SELECT 1
              FROM technology_bom_structure sub
              WHERE sub.parent_id = child.id);
-- 3.5 å¯é€‰ï¼šå°†åŽ†å²å±‚çº§é™ä¸ºä¸€çº§ï¼ˆç¡®è®¤ 3.1 / 3.4 å¤„理完成后再执行)。
-- æ–°ä»£ç ä¸å†è¯»å– parent_id çš„业务语义,本步骤只是让数据与模型保持一致。
-- UPDATE technology_bom_structure SET parent_id = NULL WHERE parent_id IS NOT NULL;
-- 3.6 æ£€æŸ¥ï¼šåŽ†å²è®¢å• BOM å¿«ç…§ä¸­çš„æˆå“æ ¹èŠ‚ç‚¹ï¼ˆå¿«ç…§ä¿æŒç‹¬ç«‹ï¼Œé»˜è®¤ä¸æ”¹å†™ï¼‰ã€‚
-- æ–°ç‰ˆæœ¬é¢†æ–™å»ºè®®å·²æŒ‰â€œå·¥åºéžç©º + è§„格不等于成品规格”过滤,历史快照可保持原样。
SELECT pbs.id,
       pbs.production_order_id,
       pbs.production_order_bom_id,
       pbs.product_model_id
FROM production_bom_structure pbs
         JOIN production_order_bom pob ON pbs.production_order_bom_id = pob.id
WHERE pbs.parent_id IS NULL
  AND pbs.technology_operation_id IS NULL
  AND pbs.product_model_id = pob.product_model_id;
src/main/java/com/ruoyi/production/bean/dto/ProductionBomStructureDto.java
@@ -23,9 +23,10 @@
    @Schema(description = "规格型号")
    private String model;
    private String tempId;
    private String parentTempId;
    private List<ProductionBomStructureDto> children;
    /**
     * è®¢å•BOM快照同样是扁平的“物料规格 + æ¶ˆè€—工序”,明细项只需要
     * id(更新时)、productModelId、technologyOperationId。
     */
    @Schema(description = "扁平物料明细列表")
    private List<ProductionBomStructureDto> detailList;
}
src/main/java/com/ruoyi/production/bean/dto/ProductionOrderPickDto.java
@@ -15,9 +15,6 @@
    @Schema(description = "生产订单ID")
    private Long productionOrderId;
    @Schema(description = "生产工单ID")
    private Long productionOperationTaskId;
    @Schema(description = "批号")
    private String batchNo;
src/main/java/com/ruoyi/production/bean/vo/ProductionBomStructureVo.java
@@ -5,8 +5,9 @@
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
/**
 * è®¢å•BOM快照明细展示对象,扁平结构:一行 = ä¸€ä¸ªç‰©æ–™è§„æ ¼ + å…¶æ¶ˆè€—工序。
 */
@Data
@EqualsAndHashCode(callSuper = true)
public class ProductionBomStructureVo extends ProductionBomStructure {
@@ -22,6 +23,4 @@
    @Schema(description = "规格型号")
    private String model;
    private List<ProductionBomStructureVo> children;
}
src/main/java/com/ruoyi/production/bean/vo/ProductionOrderPickVo.java
@@ -21,12 +21,21 @@
    @Schema(description = "领用数量")
    private BigDecimal pickQuantity;
    @Schema(description = "净领料数量(领料+补料-退料)")
    private BigDecimal effectiveQuantity;
    @Schema(description = "是否为已领料记录:false è¡¨ç¤ºæ¥è‡ªBOM的可领用物料建议")
    private Boolean picked;
    @Schema(description = "产品名称")
    private String productName;
    @Schema(description = "规格型号")
    private String model;
    @Schema(description = "工单编号")
    private String workOrderNo;
    @Schema(description = "批号")
    private List<String> batchNoList;
}
src/main/java/com/ruoyi/production/controller/ProductionBomStructureController.java
@@ -28,14 +28,20 @@
    private ProductionBomStructureService productionBomStructureService;
    @GetMapping("/listByBomId/{bomId}")
    @Operation(summary = "根据BOM查询生产订单BOM结构树")
    @Operation(summary = "根据订单BOM查询扁平物料明细")
    public R<List<ProductionBomStructureVo>> listByBomId(@PathVariable Long bomId) {
        return R.ok(productionBomStructureService.listByBomId(bomId));
    }
    @Log(title = "新增或修改BOM结构", businessType = BusinessType.UPDATE)
    @GetMapping("/listByOrderId/{productionOrderId}")
    @Operation(summary = "根据生产订单查询订单BOM快照的扁平物料明细")
    public R<List<ProductionBomStructureVo>> listByOrderId(@PathVariable Long productionOrderId) {
        return R.ok(productionBomStructureService.listByOrderId(productionOrderId));
    }
    @Log(title = "全量保存订单BOM扁平物料明细", businessType = BusinessType.UPDATE)
    @PostMapping("/addOrUpdateBomStructs")
    @Operation(summary = "新增或修改BOM结构")
    @Operation(summary = "全量保存订单BOM扁平物料明细")
    public R addProductionBomStructure(@RequestBody ProductionBomStructureDto productionBomStructureDto) {
        return R.ok(productionBomStructureService.addProductionBomStructure(productionBomStructureDto));
    }
src/main/java/com/ruoyi/production/controller/ProductionOrderPickController.java
@@ -48,5 +48,24 @@
    public R<List<ProductionOrderPickVo>> listPickedDetail(@PathVariable Long productionOrderId) {
        return R.ok(productionOrderPickService.listPickedDetail(productionOrderId));
    }
    @GetMapping("/taskMaterials/{productionOperationTaskId}")
    @Operation(summary = "查询工序工单的可领用物料与已领料记录")
    public R<List<ProductionOrderPickVo>> listTaskMaterials(@PathVariable Long productionOperationTaskId) {
        return R.ok(productionOrderPickService.listTaskMaterials(productionOperationTaskId));
    }
    @Log(title = "工序领料", businessType = BusinessType.INSERT)
    @PostMapping("/savePickByTask")
    @Operation(summary = "工序统一领料并立即扣减库存")
    public R<Boolean> savePickByTask(@RequestBody ProductionOrderPickDto dto) {
        return R.ok(productionOrderPickService.savePickByTask(dto));
    }
    @GetMapping("/taskPicked/{productionOperationTaskId}")
    @Operation(summary = "查询工序工单是否已存在有效领料")
    public R<Boolean> hasEffectivePick(@PathVariable Long productionOperationTaskId) {
        return R.ok(productionOrderPickService.hasEffectivePick(productionOperationTaskId));
    }
}
src/main/java/com/ruoyi/production/mapper/ProductionOrderPickMapper.java
@@ -20,4 +20,9 @@
public interface ProductionOrderPickMapper extends BaseMapper<ProductionOrderPick> {
    List<ProductionOrderPickVo> listPickedDetailByOrderId(@Param("productionOrderId") Long productionOrderId);
    /**
     * æŸ¥è¯¢æŸä¸ªå·¥åºå·¥å•已产生的领料记录。
     */
    List<ProductionOrderPickVo> listPickedDetailByTaskId(@Param("productionOperationTaskId") Long productionOperationTaskId);
}
src/main/java/com/ruoyi/production/pojo/ProductionOrderPick.java
@@ -46,6 +46,9 @@
    @Schema(description = "生产订单id")
    private Long productionOrderId;
    @Schema(description = "生产工单id(工序任务),领料归属的工序执行单元")
    private Long productionOperationTaskId;
    @Schema(description = "备注")
    private String remark;
src/main/java/com/ruoyi/production/pojo/ProductionProductInput.java
@@ -20,6 +20,9 @@
    @Schema(description = "产品id")
    private Long productModelId;
    @Schema(description = "来源领料记录id(production_order_pick.id),保证同一笔领料只被一次报工归集")
    private Long productionOrderPickId;
    @Schema(description = "数量")
    private BigDecimal quantity;
src/main/java/com/ruoyi/production/service/ProductionBomStructureService.java
@@ -19,5 +19,10 @@
    List<ProductionBomStructureVo> listByBomId(Long bomId);
    /**
     * æŒ‰ç”Ÿäº§è®¢å•查询订单BOM快照的扁平物料明细。
     */
    List<ProductionBomStructureVo> listByOrderId(Long productionOrderId);
    Boolean addProductionBomStructure(ProductionBomStructureDto productionBomStructureDto);
}
src/main/java/com/ruoyi/production/service/ProductionOrderPickService.java
@@ -22,4 +22,20 @@
    Boolean updatePick(ProductionOrderPickDto dto);
    List<ProductionOrderPickVo> listPickedDetail(Long productionOrderId);
    /**
     * æŸ¥è¯¢æŸä¸ªå·¥åºå·¥å•的领料物料清单:
     * æ—¢åŒ…含订单BOM快照中分配给该工序的可领用物料建议,也包含该工单已产生的领料记录。
     */
    List<ProductionOrderPickVo> listTaskMaterials(Long productionOperationTaskId);
    /**
     * å·¥åºç»Ÿä¸€é¢†æ–™ï¼šæŒ‰å·¥å•直接领料并立即扣减库存,可重复领料。
     */
    Boolean savePickByTask(ProductionOrderPickDto dto);
    /**
     * åˆ¤æ–­æŸä¸ªå·¥åºå·¥å•是否已存在有效领料(任一物料净领料数量大于0)。
     */
    boolean hasEffectivePick(Long productionOperationTaskId);
}
src/main/java/com/ruoyi/production/service/ProductionOrderService.java
@@ -32,6 +32,11 @@
    List<ProductionOrderPickVo> pick(Long productionOrderId);
    /**
     * æŒ‰è®¢å•BOM快照给出可领用物料建议,technologyOperationId éžç©ºæ—¶åªè¿”回该工序的物料。
     */
    List<ProductionOrderPickVo> pick(Long productionOrderId, Long technologyOperationId);
    ProductionOrderWorkOrderDetailVo getWorkOrderReportInspectDetail(ProductionOrderDto productionOrderDto);
    int updateOrder(ProductionOrderDto productionOrderDto);
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;
@@ -31,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;
@@ -61,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;
        }
@@ -194,55 +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<>();
        BigDecimal lastProcessDemandedQuantity = orderQuantity;
        for (ProductionBomStructure structure : structureList) {
            if (structure == null || structure.getId() == null) {
                continue;
            }
            BigDecimal demandedQuantity = lastProcessDemandedQuantity.multiply(defaultDecimal(structure.getUnitQuantity()));
//            if (compareDecimal(structure.getDemandedQuantity(), demandedQuantity) == 0) {
//                continue;
//            }
            ProductionBomStructure update = new ProductionBomStructure();
            update.setId(structure.getId());
            update.setDemandedQuantity(demandedQuantity);
            updateList.add(update);
            structure.setDemandedQuantity(demandedQuantity);
            lastProcessDemandedQuantity = 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)
@@ -250,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 = TaskPlanQuantityUtil.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;
            }
@@ -374,45 +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<Long, List<ProductionBomStructure>> treeMap = buildParentChildMap(structureList);
        // ä½¿ç”¨åŽåºéåŽ†æž„å»ºæ“ä½œåˆ—è¡¨ï¼ˆå…ˆå­åŽçˆ¶ï¼Œç¡®ä¿å·¥è‰ºè·¯çº¿é¡ºåºæ­£ç¡®ï¼‰
        // ä½¿ç”¨æ·±åº¦ä½œä¸ºæŽ’序依据的辅助结构
        Map<String, ProductionBomStructure> operationMap = new LinkedHashMap<>();
        Map<String, Integer> depthMap = new HashMap<>();
        buildOperationListPostOrderWithDepth(null, treeMap, operationMap, depthMap, structureById, rootProductModelId, 1);
        // æŒ‰æ·±åº¦æŽ’序,深度大的排前面
        List<Map.Entry<String, ProductionBomStructure>> sortedEntries = new ArrayList<>(operationMap.entrySet());
        sortedEntries.sort((a, b) -> {
            int depthCompare = Integer.compare(
                    depthMap.getOrDefault(b.getKey(), 0),
                    depthMap.getOrDefault(a.getKey(), 0));
            if (depthCompare != 0) {
                return depthCompare;
        Set<Long> uniqueOperationIdList = new LinkedHashSet<>();
        for (ProductionBomStructure structure : structureList) {
            if (structure == null || structure.getTechnologyOperationId() == null) {
                continue;
            }
            return 0;
        });
            uniqueOperationIdList.add(structure.getTechnologyOperationId());
        }
        List<ProductionOrderRoutingOperation> desiredOperationList = new ArrayList<>();
        List<ProductionOrderRoutingOperation> desiredOperationList = new ArrayList<>(uniqueOperationIdList.size());
        int dragSort = 1;
        for (Map.Entry<String, ProductionBomStructure> entry : sortedEntries) {
            ProductionBomStructure bomStructure = entry.getValue();
            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());
@@ -421,127 +362,6 @@
            desiredOperationList.add(routingOperation);
        }
        return desiredOperationList;
    }
    private void buildOperationListPostOrderWithDepth(Long parentId,
                                                      Map<Long, List<ProductionBomStructure>> treeMap,
                                                      Map<String, ProductionBomStructure> operationMap,
                                                      Map<String, Integer> depthMap,
                                                      Map<Long, ProductionBomStructure> structureById,
                                                      Long rootProductModelId,
                                                      int currentDepth) {
        List<ProductionBomStructure> children = treeMap.get(parentId);
        if (children == null || children.isEmpty()) {
            return;
        }
        for (ProductionBomStructure child : children) {
            // å…ˆé€’归处理子节点
            buildOperationListPostOrderWithDepth(child.getId(), treeMap, operationMap, depthMap, structureById, rootProductModelId, currentDepth + 1);
            // å†å¤„理当前节点
            if (child.getTechnologyOperationId() != null) {
                Long outputProductModelId = resolveOutputProductModelId(resolveOperationOutputNode(child, structureById), rootProductModelId);
                String key = buildBomOperationDedupKey(child, outputProductModelId);
                // ä¿ç•™æ·±åº¦æœ€å¤§çš„æ“ä½œ
                Integer existingDepth = depthMap.get(key);
                if (existingDepth == null || currentDepth > existingDepth) {
                    operationMap.put(key, child);
                    depthMap.put(key, currentDepth);
                }
            }
        }
    }
    private Map<Long, List<ProductionBomStructure>> buildParentChildMap(List<ProductionBomStructure> structureList) {
        Map<Long, List<ProductionBomStructure>> treeMap = new LinkedHashMap<>();
        Map<Long, ProductionBomStructure> structureById = new HashMap<>();
        // æž„建父-子映射和ID映射
        for (ProductionBomStructure structure : structureList) {
            if (structure == null) continue;
            Long parentId = structure.getParentId();
            treeMap.computeIfAbsent(parentId, k -> new ArrayList<>()).add(structure);
            if (structure.getId() != null) {
                structureById.put(structure.getId(), structure);
            }
        }
        // è®¡ç®—每个节点的深度(从根节点到当前节点的距离,根节点深度为1)
        Map<Long, Integer> depthMap = new HashMap<>();
        for (ProductionBomStructure structure : structureList) {
            if (structure == null || structure.getId() == null) continue;
            computeDepthFromRoot(structure.getId(), structureById, depthMap);
        }
        // å¯¹æ¯ä¸ªçˆ¶èŠ‚ç‚¹ä¸‹çš„å­èŠ‚ç‚¹æŒ‰æ·±åº¦å€’åºæŽ’åºï¼ˆæœ€æ·±å±‚çš„ä¼˜å…ˆï¼‰
        for (Map.Entry<Long, List<ProductionBomStructure>> entry : treeMap.entrySet()) {
            List<ProductionBomStructure> children = entry.getValue();
            children.sort((a, b) -> {
                // ä¼˜å…ˆæŒ‰æ·±åº¦æŽ’序,深度大的排前面(最深层优先)
                int depthCompare = Integer.compare(
                        depthMap.getOrDefault(b.getId(), 0),
                        depthMap.getOrDefault(a.getId(), 0));
                if (depthCompare != 0) {
                    return depthCompare;
                }
                // æ·±åº¦ç›¸åŒæ—¶æŒ‰ID排序保证稳定性
                return Long.compare(a.getId(), b.getId());
            });
        }
        return treeMap;
    }
    /**
     * è®¡ç®—节点深度(从根节点到当前节点的距离)
     * æ ¹èŠ‚ç‚¹æ·±åº¦ä¸º1,每向下一层深度加1
     */
    private int computeDepthFromRoot(Long nodeId, Map<Long, ProductionBomStructure> structureById, Map<Long, Integer> depthMap) {
        if (depthMap.containsKey(nodeId)) {
            return depthMap.get(nodeId);
        }
        ProductionBomStructure structure = structureById.get(nodeId);
        if (structure == null) {
            depthMap.put(nodeId, 1);
            return 1;
        }
        Long parentId = structure.getParentId();
        if (parentId == null || parentId == 0L) {
            // æ ¹èŠ‚ç‚¹æ·±åº¦ä¸º1
            depthMap.put(nodeId, 1);
            return 1;
        }
        // å­èŠ‚ç‚¹æ·±åº¦ = çˆ¶èŠ‚ç‚¹æ·±åº¦ + 1
        int parentDepth = computeDepthFromRoot(parentId, structureById, depthMap);
        int depth = parentDepth + 1;
        depthMap.put(nodeId, depth);
        return depth;
    }
    private void buildOperationListPostOrder(Long parentId,
                                             Map<Long, List<ProductionBomStructure>> treeMap,
                                             Map<String, ProductionBomStructure> uniqueOperationMap,
                                             Map<Long, ProductionBomStructure> structureById,
                                             Long rootProductModelId) {
        List<ProductionBomStructure> children = treeMap.get(parentId);
        if (children == null || children.isEmpty()) {
            return;
        }
        for (ProductionBomStructure child : children) {
            // å…ˆé€’归处理子节点
            buildOperationListPostOrder(child.getId(), treeMap, uniqueOperationMap, structureById, rootProductModelId);
            // å†å¤„理当前节点
            if (child.getTechnologyOperationId() != null) {
                Long outputProductModelId = resolveOutputProductModelId(resolveOperationOutputNode(child, structureById), rootProductModelId);
                String key = buildBomOperationDedupKey(child, outputProductModelId);
                // åŽ»é‡æ—¶ä¿ç•™æ·±åº¦æœ€å¤§çš„æ“ä½œï¼ˆåŽåºéåŽ†å…ˆé‡åˆ°æ·±å±‚èŠ‚ç‚¹ï¼Œæ‰€ä»¥ç›´æŽ¥è¦†ç›–å³å¯ï¼‰
                uniqueOperationMap.put(key, child);
            }
        }
    }
    private Map<String, Deque<ProductionOrderRoutingOperation>> buildExistingRoutingOperationBucketMap(List<ProductionOrderRoutingOperation> existingOperationList) {
@@ -749,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) {
@@ -870,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);
        }
    }
}
src/main/java/com/ruoyi/production/service/impl/ProductionOrderPickServiceImpl.java
@@ -10,14 +10,20 @@
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.production.bean.dto.ProductionOrderPickDto;
import com.ruoyi.production.bean.vo.ProductionOrderPickVo;
import com.ruoyi.production.mapper.ProductionBomStructureMapper;
import com.ruoyi.production.mapper.ProductionOperationTaskMapper;
import com.ruoyi.production.mapper.ProductionOrderMapper;
import com.ruoyi.production.mapper.ProductionOrderPickMapper;
import com.ruoyi.production.mapper.ProductionOrderPickRecordMapper;
import com.ruoyi.production.mapper.ProductionOrderRoutingOperationMapper;
import com.ruoyi.production.pojo.ProductionBomStructure;
import com.ruoyi.production.pojo.ProductionOperationTask;
import com.ruoyi.production.pojo.ProductionOrder;
import com.ruoyi.production.pojo.ProductionOrderPick;
import com.ruoyi.production.pojo.ProductionOrderPickRecord;
import com.ruoyi.production.pojo.ProductionOrderRoutingOperation;
import com.ruoyi.production.service.ProductionOrderPickService;
import com.ruoyi.production.service.ProductionOrderService;
import com.ruoyi.stock.dto.StockInventoryDto;
import com.ruoyi.stock.mapper.StockInventoryMapper;
import com.ruoyi.stock.pojo.StockInRecord;
@@ -26,6 +32,8 @@
import com.ruoyi.stock.service.StockInventoryService;
import com.ruoyi.stock.service.StockInRecordService;
import com.ruoyi.stock.service.StockOutRecordService;
import com.ruoyi.technology.mapper.TechnologyOperationMapper;
import com.ruoyi.technology.pojo.TechnologyOperation;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -53,6 +61,10 @@
    private final ProductionOrderMapper productionOrderMapper;
    private final ProductionOperationTaskMapper productionOperationTaskMapper;
    private final ProductionOrderPickRecordMapper productionOrderPickRecordMapper;
    private final ProductionOrderRoutingOperationMapper productionOrderRoutingOperationMapper;
    private final ProductionBomStructureMapper productionBomStructureMapper;
    private final ProductionOrderService productionOrderService;
    private final TechnologyOperationMapper technologyOperationMapper;
    private final StockInventoryMapper stockInventoryMapper;
    private final StockInventoryService stockInventoryService;
    private final StockInRecordService stockInRecordService;
@@ -83,6 +95,7 @@
            // ä¿å­˜é¢†æ–™ä¸»è®°å½•快照。
            ProductionOrderPick orderPick = new ProductionOrderPick();
            orderPick.setProductionOrderId(resolvedDto.getProductionOrderId());
            orderPick.setProductionOperationTaskId(resolvedDto.getProductionOperationTaskId());
            orderPick.setProductModelId(resolvedDto.getProductModelId());
            orderPick.setBatchNo(storedBatchNo);
            orderPick.setQuantity(resolvedDto.getPickQuantity());
@@ -191,7 +204,237 @@
        List<ProductionOrderPickVo> detailList = baseMapper.listPickedDetailByOrderId(productionOrderId);
        fillBatchNoList(detailList);
        fillSelectableBatchNoList(detailList);
        fillPickedFlagAndEffectiveQuantity(detailList);
        return detailList;
    }
    /**
     * å·¥åºç‰©æ–™æ¸…单 = è®¢å•BOM快照中属于该工序的可领用物料建议 + è¯¥å·¥å•已领料记录。
     * å»ºè®®è¡Œ picked=false(id ä¸ºç©ºï¼‰ï¼ŒåŽ†å²é¢†æ–™è¡Œ picked=true。
     */
    @Override
    public List<ProductionOrderPickVo> listTaskMaterials(Long productionOperationTaskId) {
        if (productionOperationTaskId == null) {
            return Collections.emptyList();
        }
        TaskOperationContext context = resolveTaskOperationContext(productionOperationTaskId);
        List<ProductionOrderPickVo> result = new ArrayList<>();
        // BOM å»ºè®®ç‰©æ–™ï¼šæŒ‰å½“前工序过滤,不带任何需求数量
        List<ProductionOrderPickVo> suggestionList =
                productionOrderService.pick(context.productionOrderId(), context.technologyOperationId());
        for (ProductionOrderPickVo suggestion : suggestionList) {
            suggestion.setId(null);
            suggestion.setPicked(false);
            suggestion.setProductionOrderId(context.productionOrderId());
            suggestion.setProductionOperationTaskId(productionOperationTaskId);
            suggestion.setTechnologyOperationId(context.technologyOperationId());
            if (StringUtils.isEmpty(suggestion.getOperationName())) {
                suggestion.setOperationName(context.operationName());
            }
            suggestion.setWorkOrderNo(context.workOrderNo());
            result.add(suggestion);
        }
        // è¯¥å·¥å•已产生的领料记录
        List<ProductionOrderPickVo> pickedList = baseMapper.listPickedDetailByTaskId(productionOperationTaskId);
        fillBatchNoList(pickedList);
        fillSelectableBatchNoList(pickedList);
        fillPickedFlagAndEffectiveQuantity(pickedList);
        result.addAll(pickedList);
        return result;
    }
    /**
     * å·¥åºç»Ÿä¸€é¢†æ–™ï¼š
     * 1) ç”±å·¥å•反查订单与工序,不信任前端传入的归属字段;
     * 2) é€è¡Œæ ¡éªŒç‰©æ–™ã€æ•°é‡ä¸Žæ‰¹æ¬¡ï¼›
     * 3) å¤ç”¨å‡ºåº“申请 + ç«‹å³å®¡æ‰¹çš„库存扣减流程;
     * 4) å†™å…¥é¢†æ–™ä¸»è®°å½•与流水,重复领料即新增记录,不区分初领与补料。
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Boolean savePickByTask(ProductionOrderPickDto dto) {
        if (dto == null) {
            throw new ServiceException("参数不能为空");
        }
        Long productionOperationTaskId = dto.getProductionOperationTaskId();
        if (productionOperationTaskId == null) {
            throw new ServiceException("工单ID不能为空");
        }
        TaskOperationContext context = resolveTaskOperationContext(productionOperationTaskId);
        List<ProductionOrderPickDto> pickItems = resolvePickItems(dto).stream()
                .filter(Objects::nonNull)
                .filter(item -> item.getProductModelId() != null || item.getPickQuantity() != null)
                .collect(Collectors.toList());
        if (pickItems.isEmpty()) {
            throw new ServiceException("请至少填写一条领料物料");
        }
        for (int i = 0; i < pickItems.size(); i++) {
            int rowNo = i + 1;
            ProductionOrderPickDto resolvedDto = mergeDto(dto, pickItems.get(i));
            if (resolvedDto.getProductModelId() == null) {
                throw new ServiceException("第" + rowNo + "行参数错误:产品规格不能为空");
            }
            if (resolvedDto.getPickQuantity() == null
                    || resolvedDto.getPickQuantity().compareTo(BigDecimal.ZERO) <= 0) {
                throw new ServiceException("第" + rowNo + "行参数错误:领料数量必须大于0");
            }
            List<String> batchNoList = resolveBatchNoList(resolvedDto);
            String inventoryBatchNo = pickInventoryBatchNo(batchNoList);
            String storedBatchNo = formatBatchNoStorage(batchNoList);
            ProductionOrderPick orderPick = new ProductionOrderPick();
            orderPick.setProductionOrderId(context.productionOrderId());
            orderPick.setProductionOperationTaskId(productionOperationTaskId);
            orderPick.setProductModelId(resolvedDto.getProductModelId());
            orderPick.setBatchNo(storedBatchNo);
            orderPick.setQuantity(resolvedDto.getPickQuantity());
            orderPick.setRemark(resolvedDto.getRemark());
            orderPick.setOperationName(context.operationName());
            orderPick.setTechnologyOperationId(context.technologyOperationId());
            // æ‰å¹³BOM不配置数量,领料不再有需求数量约束
            orderPick.setDemandedQuantity(null);
            orderPick.setBom(isBomMaterial(context, resolvedDto.getProductModelId()));
            orderPick.setReturned(false);
            baseMapper.insert(orderPick);
            // ç«‹å³æ‰£å‡åº“存,失败则整个事务回滚
            subtractInventory(orderPick.getId(),
                    resolvedDto.getProductModelId(),
                    storedBatchNo,
                    resolvedDto.getPickQuantity(),
                    rowNo,
                    PICK_STOCK_OUT_RECORD_TYPE);
            insertPickRecord(orderPick.getId(),
                    context.productionOrderId(),
                    productionOperationTaskId,
                    resolvedDto.getProductModelId(),
                    inventoryBatchNo,
                    resolvedDto.getPickQuantity(),
                    BigDecimal.ZERO,
                    resolvedDto.getPickQuantity(),
                    PICK_TYPE_NORMAL,
                    resolvedDto.getRemark(),
                    null);
        }
        return true;
    }
    /**
     * å½“前工序是否已有有效领料:任一物料的净领料数量(领料+补料-退料)大于0。
     */
    @Override
    public boolean hasEffectivePick(Long productionOperationTaskId) {
        if (productionOperationTaskId == null) {
            return false;
        }
        List<ProductionOrderPick> pickList = baseMapper.selectList(
                Wrappers.<ProductionOrderPick>lambdaQuery()
                        .eq(ProductionOrderPick::getProductionOperationTaskId, productionOperationTaskId));
        for (ProductionOrderPick pick : pickList) {
            if (pick == null) {
                continue;
            }
            if (resolveEffectiveQuantity(pick).compareTo(BigDecimal.ZERO) > 0) {
                return true;
            }
        }
        return false;
    }
    /**
     * ç”±å·¥å•解析出订单、工序快照与工序名称,并校验归属关系。
     */
    private TaskOperationContext resolveTaskOperationContext(Long productionOperationTaskId) {
        ProductionOperationTask task = productionOperationTaskMapper.selectById(productionOperationTaskId);
        if (task == null) {
            throw new ServiceException("工单不存在");
        }
        if (task.getProductionOrderId() == null) {
            throw new ServiceException("工单未关联生产订单");
        }
        ProductionOrder productionOrder = productionOrderMapper.selectById(task.getProductionOrderId());
        if (productionOrder == null) {
            throw new ServiceException("生产订单不存在");
        }
        ProductionOrderRoutingOperation routingOperation =
                productionOrderRoutingOperationMapper.selectById(task.getProductionOrderRoutingOperationId());
        if (routingOperation == null) {
            throw new ServiceException("工单对应的订单工序不存在");
        }
        if (routingOperation.getProductionOrderId() != null
                && !Objects.equals(routingOperation.getProductionOrderId(), task.getProductionOrderId())) {
            throw new ServiceException("工单与订单工序归属不一致");
        }
        if (routingOperation.getTechnologyOperationId() == null) {
            throw new ServiceException("订单工序未关联工艺工序");
        }
        String operationName = routingOperation.getOperationName();
        if (StringUtils.isEmpty(operationName)) {
            TechnologyOperation technologyOperation =
                    technologyOperationMapper.selectById(routingOperation.getTechnologyOperationId());
            operationName = technologyOperation == null ? null : technologyOperation.getName();
        }
        return new TaskOperationContext(
                task.getId(),
                task.getProductionOrderId(),
                routingOperation.getTechnologyOperationId(),
                operationName,
                task.getWorkOrderNo());
    }
    /**
     * åˆ¤æ–­ç‰©æ–™æ˜¯å¦æ¥è‡ªè®¢å•BOM快照中的当前工序。
     */
    private boolean isBomMaterial(TaskOperationContext context, Long productModelId) {
        if (productModelId == null) {
            return false;
        }
        return productionBomStructureMapper.selectCount(
                Wrappers.<ProductionBomStructure>lambdaQuery()
                        .eq(ProductionBomStructure::getProductionOrderId, context.productionOrderId())
                        .eq(ProductionBomStructure::getTechnologyOperationId, context.technologyOperationId())
                        .eq(ProductionBomStructure::getProductModelId, productModelId)) > 0;
    }
    private void fillPickedFlagAndEffectiveQuantity(List<ProductionOrderPickVo> detailList) {
        if (detailList == null || detailList.isEmpty()) {
            return;
        }
        for (ProductionOrderPickVo detail : detailList) {
            if (detail == null) {
                continue;
            }
            detail.setPicked(detail.getId() != null);
            detail.setEffectiveQuantity(resolveEffectiveQuantity(detail));
        }
    }
    /**
     * å‡€é¢†æ–™æ•°é‡ = é¢†æ–™æ•°é‡ + è¡¥æ–™æ•°é‡ - é€€æ–™æ•°é‡ã€‚
     */
    private BigDecimal resolveEffectiveQuantity(ProductionOrderPick pick) {
        if (pick == null) {
            return BigDecimal.ZERO;
        }
        return defaultDecimal(pick.getQuantity())
                .add(defaultDecimal(pick.getFeedingQty()))
                .subtract(defaultDecimal(pick.getReturnQty()));
    }
    /**
     * å·¥å•对应的领料上下文,避免依赖前端传入的订单/工序字段。
     */
    private record TaskOperationContext(Long productionOperationTaskId,
                                        Long productionOrderId,
                                        Long technologyOperationId,
                                        String operationName,
                                        String workOrderNo) {
    }
    private void processDeletePickIds(ProductionOrderPickDto rootDto,
@@ -294,6 +537,7 @@
        ProductionOrderPick orderPick = new ProductionOrderPick();
        orderPick.setProductionOrderId(dto.getProductionOrderId());
        orderPick.setProductionOperationTaskId(dto.getProductionOperationTaskId());
        orderPick.setProductModelId(dto.getProductModelId());
        orderPick.setBatchNo(storedBatchNo);
        orderPick.setQuantity(dto.getPickQuantity());
src/main/java/com/ruoyi/production/service/impl/ProductionOrderRoutingOperationServiceImpl.java
@@ -137,38 +137,8 @@
                    throw new ServiceException("生产订单不存在");
                }
                // èŽ·å–è®¢å•BOM
                ProductionOrderBom orderBom = productionOrderBomMapper.selectOne(
                        Wrappers.<ProductionOrderBom>lambdaQuery()
                                .eq(ProductionOrderBom::getProductionOrderId, productionOrder.getId()));
                // ç¡®å®šæ ¹äº§å“è§„æ ¼ID
                Long rootProductModelId = orderBom != null && orderBom.getProductModelId() != null
                        ? orderBom.getProductModelId()
                        : productionOrder.getProductModelId();
                // èŽ·å–BOM结构列表
                List<ProductionBomStructure> orderBomStructureList = orderBom == null || orderBom.getId() == null
                        ? Collections.emptyList()
                        : productionBomStructureMapper.selectList(
                        Wrappers.<ProductionBomStructure>lambdaQuery()
                                .eq(ProductionBomStructure::getProductionOrderBomId, orderBom.getId())
                                .orderByAsc(ProductionBomStructure::getId));
                // æž„建工序需求量映射
                Map<String, BigDecimal> operationDemandedQuantityMap =
                        TaskPlanQuantityUtil.buildOperationDemandedQuantityMap(orderBomStructureList, rootProductModelId);
                // èŽ·å–å·¥è‰ºè·¯çº¿å·¥åºï¼ˆç”¨äºŽè®¡ç®—è®¡åˆ’æ•°é‡ï¼‰
                TechnologyRoutingOperation sourceOperation = technologyRoutingOperationMapper.selectById(
                        updatedOperation.getTechnologyRoutingOperationId());
                // å°†åŽŸæ¥çš„ç§æœ‰æ–¹æ³•æ›¿æ¢ä¸ºè°ƒç”¨å·¥å…·ç±»
                BigDecimal planQuantity = TaskPlanQuantityUtil.resolveTaskPlanQuantity(
                        sourceOperation,
                        operationDemandedQuantityMap,
                        productionOrder,
                        rootProductModelId);
                task.setPlanQuantity(planQuantity);
                // æ‰å¹³BOM不配置数量,工单计划数量统一取订单数量
                task.setPlanQuantity(TaskPlanQuantityUtil.resolveTaskPlanQuantity(productionOrder));
                task.setCompleteQuantity(BigDecimal.ZERO);
                task.setWorkOrderNo(generateNextTaskNo());
                task.setStatus(2);
src/main/java/com/ruoyi/production/service/impl/ProductionOrderServiceImpl.java
@@ -34,6 +34,7 @@
import com.ruoyi.quality.pojo.QualityInspectFile;
import com.ruoyi.quality.pojo.QualityInspectParam;
import com.ruoyi.production.service.ProductionOrderService;
import com.ruoyi.production.util.TaskPlanQuantityUtil;
import com.ruoyi.sales.mapper.SalesLedgerMapper;
import com.ruoyi.sales.mapper.SalesLedgerProductMapper;
import com.ruoyi.stock.mapper.StockInventoryMapper;
@@ -250,18 +251,8 @@
                        .eq(TechnologyRoutingOperation::getTechnologyRoutingId, technologyRouting.getId())
                        .orderByDesc(TechnologyRoutingOperation::getDragSort)
                        .orderByDesc(TechnologyRoutingOperation::getId));
        // Build task plan quantities from order BOM snapshot demand instead of recomputing from technology BOM units.
        Long rootProductModelId = orderBom != null && orderBom.getProductModelId() != null
                ? orderBom.getProductModelId()
                : productionOrder.getProductModelId();
        List<ProductionBomStructure> orderBomStructureList = orderBom == null || orderBom.getId() == null
                ? Collections.emptyList()
                : productionBomStructureMapper.selectList(
                Wrappers.<ProductionBomStructure>lambdaQuery()
                        .eq(ProductionBomStructure::getProductionOrderBomId, orderBom.getId())
                        .orderByAsc(ProductionBomStructure::getId));
        Map<String, BigDecimal> operationDemandedQuantityMap =
                buildOperationDemandedQuantityMap(orderBomStructureList, rootProductModelId);
        // æ‰å¹³ BOM ä¸é…ç½®æ•°é‡ï¼Œå·¥å•计划数量统一取订单数量。
        BigDecimal taskPlanQuantity = TaskPlanQuantityUtil.resolveTaskPlanQuantity(productionOrder);
        Map<Long, String> operationNameMap = technologyOperationMapper.selectBatchIds(
                        // éåŽ†å¤„ç†æ•°æ®å¹¶ç»„è£…ç»“æžœ
                        routingOperations.stream()
@@ -295,11 +286,7 @@
                ProductionOperationTask task = new ProductionOperationTask();
                task.setProductionOrderRoutingOperationId(targetOperation.getId());
                task.setProductionOrderId(productionOrder.getId());
                task.setPlanQuantity(resolveTaskPlanQuantity(
                        sourceOperation,
                        operationDemandedQuantityMap,
                        productionOrder,
                        rootProductModelId));
                task.setPlanQuantity(taskPlanQuantity);
                task.setCompleteQuantity(BigDecimal.ZERO);
                task.setWorkOrderNo(generateNextTaskNo());
                task.setStatus(2);
@@ -334,87 +321,11 @@
        return syncedParamCount;
    }
    private Map<String, BigDecimal> buildOperationDemandedQuantityMap(List<ProductionBomStructure> bomStructures,
                                                                      Long rootProductModelId) {
        if (bomStructures == null || bomStructures.isEmpty()) {
            return Collections.emptyMap();
        }
        Map<Long, ProductionBomStructure> structureById = bomStructures.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 : bomStructures) {
            if (bomStructure == null || bomStructure.getTechnologyOperationId() == null) {
                continue;
            }
            // The BOM row points to the producing operation; task quantity should come from that operation's output node.
            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;
            }
            // demandedQuantity is already the order-level required output quantity for the current output node.
            BigDecimal demandedQuantity = defaultDecimal(outputNode == null ? null : outputNode.getDemandedQuantity());
            String key = buildOperationDemandedQuantityKey(bomStructure.getTechnologyOperationId(), outputProductModelId);
            demandedQuantityMap.merge(key, demandedQuantity, BigDecimal::add);
        }
        return demandedQuantityMap;
    }
    private BigDecimal resolveTaskPlanQuantity(TechnologyRoutingOperation sourceOperation,
                                               Map<String, BigDecimal> operationDemandedQuantityMap,
                                               ProductionOrder productionOrder,
                                               Long rootProductModelId) {
        if (sourceOperation == null || operationDemandedQuantityMap == null || operationDemandedQuantityMap.isEmpty()) {
            return defaultDecimal(productionOrder == null ? null : productionOrder.getQuantity());
        }
        Long outputProductModelId = sourceOperation.getProductModelId() != null
                ? sourceOperation.getProductModelId()
                : rootProductModelId;
        String key = buildOperationDemandedQuantityKey(sourceOperation.getTechnologyOperationId(), outputProductModelId);
        BigDecimal planQuantity = operationDemandedQuantityMap.get(key);
        return planQuantity != null ? planQuantity : defaultDecimal(productionOrder == null ? null : productionOrder.getQuantity());
    }
    private String buildOperationDemandedQuantityKey(Long operationId, Long outputProductModelId) {
        return String.valueOf(operationId) + "#" + String.valueOf(outputProductModelId);
    }
    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; child 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;
    }
    /**
     * åŒæ­¥è®¢å•BOM快照:扁平复制“物料规格 + æ¶ˆè€—工序”关系。
     * ä¸å†ç”Ÿæˆæˆå“æ ¹èŠ‚ç‚¹ï¼Œä¹Ÿä¸å†è®¡ç®—å•ä½ç”¨é‡å’Œéœ€æ±‚æ•°é‡ã€‚
     */
    private ProductionOrderBom syncProductionOrderBomSnapshot(ProductionOrder productionOrder, TechnologyRouting technologyRouting) {
        // åŒæ­¥è®¢å•BOM快照结构
        if (technologyRouting.getBomId() == null) {
            return null;
        }
@@ -422,41 +333,32 @@
        if (technologyBom == null) {
            throw new ServiceException("工艺BOM不存在");
        }
        // æŸ¥è¯¢å¹¶å‡†å¤‡ä¸šåŠ¡æ•°æ®
        // åªå–真实物料行:必须有消耗工序,历史成品根节点(无工序)不属于物料
        List<TechnologyBomStructure> structureList = technologyBomStructureMapper.selectList(
                Wrappers.<TechnologyBomStructure>lambdaQuery()
                        .eq(TechnologyBomStructure::getBomId, technologyBom.getId())
                        .isNotNull(TechnologyBomStructure::getOperationId)
                        .orderByAsc(TechnologyBomStructure::getId));
        // éåŽ†å¤„ç†æ•°æ®å¹¶ç»„è£…ç»“æžœ
        TechnologyBomStructure root = structureList.stream().filter(item -> item.getParentId() == null).findFirst().orElse(null);
        BigDecimal orderQuantity = defaultDecimal(productionOrder.getQuantity());
        ProductionOrderBom orderBom = new ProductionOrderBom();
        orderBom.setProductionOrderId(productionOrder.getId());
        orderBom.setBomId(Long.valueOf(technologyBom.getId()));
        orderBom.setProductModelId(root != null ? root.getProductModelId() : productionOrder.getProductModelId());
        // BOM ä¸»è¡¨æœ¬èº«å³ä»£è¡¨æˆå“è§„æ ¼
        orderBom.setProductModelId(technologyBom.getProductModelId() != null
                ? technologyBom.getProductModelId()
                : productionOrder.getProductModelId());
        orderBom.setRemark(technologyBom.getRemark());
        orderBom.setBomNo(technologyBom.getBomNo());
        orderBom.setVersion(technologyBom.getVersion());
        // æŒä¹…化或输出处理结果
        productionOrderBomMapper.insert(orderBom);
        Map<Long, Long> idMap = new HashMap<>();
        BigDecimal lastProcessDemandedQuantity = orderQuantity;
        for (TechnologyBomStructure source : structureList) {
            // å­èŠ‚ç‚¹ parentId éœ€è¦æ˜ å°„成新快照节点 id,才能保留原始 BOM å±‚级。
            ProductionBomStructure target = new ProductionBomStructure();
            target.setProductionOrderId(productionOrder.getId());
            target.setProductionOrderBomId(orderBom.getId());
            target.setParentId(source.getParentId() == null ? null : idMap.get(source.getParentId()));
            target.setProductModelId(source.getProductModelId());
            target.setTechnologyOperationId(source.getOperationId());
            target.setUnitQuantity(source.getUnitQuantity());
            target.setDemandedQuantity(lastProcessDemandedQuantity.multiply(source.getUnitQuantity()));
            target.setUnit(source.getUnit());
            productionBomStructureMapper.insert(target);
            idMap.put(source.getId(), target.getId());
            lastProcessDemandedQuantity = target.getDemandedQuantity();
        }
        return orderBom;
    }
@@ -1059,12 +961,20 @@
    @Override
    public List<ProductionOrderPickVo> pick(Long productionOrderId) {
        // æŸ¥è¯¢è®¢å•领料、投料与退料明细
        return pick(productionOrderId, null);
    }
    /**
     * æŒ‰è®¢å•BOM快照给出可领用物料建议。
     * æ‰å¹³BOM只描述“物料规格 + æ¶ˆè€—工序”,因此这里不再计算需求数量;
     * ä¼ å…¥å·¥åºID时只返回该工序的物料。
     */
    @Override
    public List<ProductionOrderPickVo> pick(Long productionOrderId, Long technologyOperationId) {
        if (productionOrderId == null) {
            return Collections.emptyList();
        }
        // æŸ¥è¯¢å¹¶å‡†å¤‡ä¸šåŠ¡æ•°æ®
        ProductionOrderBom orderBom = productionOrderBomMapper.selectOne(
                Wrappers.<ProductionOrderBom>lambdaQuery()
                        .eq(ProductionOrderBom::getProductionOrderId, productionOrderId)
@@ -1074,67 +984,35 @@
            return Collections.emptyList();
        }
        // æŸ¥è¯¢å®Œæ•´çš„BOM结构(包括根节点),用于计算层级需求数量
        List<ProductionBomStructureVo> bomStructureList = productionBomStructureMapper.listByBomId(orderBom.getId());
        if (bomStructureList == null || bomStructureList.isEmpty()) {
            return Collections.emptyList();
        }
        // æŸ¥è¯¢ç”Ÿäº§è®¢å•获取订单数量
        ProductionOrder productionOrder = productionOrderMapper.selectById(productionOrderId);
        BigDecimal orderQuantity = productionOrder != null ? defaultDecimal(productionOrder.getQuantity()) : BigDecimal.ZERO;
        // æž„建树形结构并计算层级需求数量
        Map<Long, ProductionBomStructureVo> structureByIdMap = bomStructureList.stream()
                .filter(s -> s != null && s.getId() != null)
                .collect(Collectors.toMap(ProductionBomStructureVo::getId, s -> s));
        // æŒ‰å±‚级计算需求数量:子级需求数量 = çˆ¶çº§éœ€æ±‚数量 Ã— å­çº§å•位产出所需数量
        for (ProductionBomStructureVo structure : bomStructureList) {
            if (structure == null) continue;
            if (structure.getParentId() == null || structure.getParentId() == 0) {
                // æ ¹èŠ‚ç‚¹ï¼šéœ€æ±‚æ•°é‡ = è®¢å•数量
                structure.setDemandedQuantity(orderQuantity);
            } else {
                // å­èŠ‚ç‚¹ï¼šéœ€æ±‚æ•°é‡ = çˆ¶çº§éœ€æ±‚数量 Ã— å­çº§å•位产出所需数量
                ProductionBomStructureVo parent = structureByIdMap.get(structure.getParentId());
                if (parent != null) {
                    BigDecimal parentDemandedQty = defaultDecimal(parent.getDemandedQuantity());
                    BigDecimal unitQuantity = defaultDecimal(structure.getUnitQuantity());
                    structure.setDemandedQuantity(parentDemandedQty.multiply(unitQuantity));
                }
            }
        }
        // æ”¶é›†è®¢å•成品规格ID:包含生产订单成品规格与BOM根节点规格,用于排除“自己”这一物料
        // æˆå“è§„格不需要领料:兼容历史快照中可能存在的成品根节点
        Set<Long> finishedProductModelIds = new HashSet<>();
        if (productionOrder != null && productionOrder.getProductModelId() != null) {
            finishedProductModelIds.add(productionOrder.getProductModelId());
        }
        bomStructureList.stream()
                .filter(s -> s != null && (s.getParentId() == null || s.getParentId() == 0))
                .map(ProductionBomStructureVo::getProductModelId)
                .filter(Objects::nonNull)
                .forEach(finishedProductModelIds::add);
        if (orderBom.getProductModelId() != null) {
            finishedProductModelIds.add(orderBom.getProductModelId());
        }
        // æ”¶é›†æ‰€æœ‰è¢«å¼•用为父级的节点ID:BOM从最后一道工序往里配置前一道工序,
        // ä¸­é—´èŠ‚ç‚¹æ˜¯ä¸Šä¸€é“å·¥åºçš„äº§å‡ºï¼ˆè‡ªåˆ¶ï¼‰ï¼Œåªæœ‰æ¯æ¡åˆ†æ”¯æœ€é‡Œå±‚çš„å¶å­èŠ‚ç‚¹æ‰æ˜¯çœŸæ­£éœ€è¦é¢†ç”¨çš„ç‰©æ–™ã€‚
        Set<Long> parentNodeIds = bomStructureList.stream()
                .filter(s -> s != null && s.getParentId() != null && s.getParentId() != 0)
                .map(ProductionBomStructureVo::getParentId)
        List<ProductionBomStructureVo> materialStructureList = bomStructureList.stream()
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        // è¿‡æ»¤å‡ºå¶å­èŠ‚ç‚¹ï¼ˆå®žé™…é¢†æ–™é¡¹ï¼‰ï¼šéžæ ¹èŠ‚ç‚¹ã€éžä¸­é—´èŠ‚ç‚¹ï¼Œå¹¶æŽ’é™¤ä¸Žè®¢å•æˆå“åŒè§„æ ¼çš„ç‰©æ–™ï¼ˆè‡ªåˆ¶æˆå“æ— éœ€é¢†æ–™ï¼‰
        List<ProductionBomStructureVo> childStructureList = bomStructureList.stream()
                .filter(s -> s != null && s.getParentId() != null && s.getParentId() != 0)
                .filter(s -> s.getId() == null || !parentNodeIds.contains(s.getId()))
                .filter(s -> s.getProductModelId() == null || !finishedProductModelIds.contains(s.getProductModelId()))
                .filter(s -> s.getProductModelId() != null && !finishedProductModelIds.contains(s.getProductModelId()))
                .filter(s -> s.getTechnologyOperationId() != null)
                .filter(s -> technologyOperationId == null
                        || technologyOperationId.equals(s.getTechnologyOperationId()))
                .collect(Collectors.toList());
        if (materialStructureList.isEmpty()) {
            return Collections.emptyList();
        }
        // éåŽ†å¤„ç†æ•°æ®å¹¶ç»„è£…ç»“æžœ
        List<Long> productModelIds = childStructureList.stream()
        List<Long> productModelIds = materialStructureList.stream()
                .map(ProductionBomStructureVo::getProductModelId)
                .filter(Objects::nonNull)
                .distinct()
@@ -1160,31 +1038,26 @@
        }
        Map<String, ProductionOrderPickVo> mergedPickMap = new LinkedHashMap<>();
        for (ProductionBomStructureVo structure : childStructureList) {
            if (structure == null || structure.getProductModelId() == null) {
        for (ProductionBomStructureVo structure : materialStructureList) {
            Long productModelId = structure.getProductModelId();
            String mergeKey = structure.getTechnologyOperationId() + "#" + productModelId;
            if (mergedPickMap.containsKey(mergeKey)) {
                continue;
            }
            Long productModelId = structure.getProductModelId();
            String mergeKey = String.valueOf(structure.getTechnologyOperationId()) + "#" + productModelId;
            ProductionOrderPickVo vo = mergedPickMap.get(mergeKey);
            if (vo == null) {
                vo = new ProductionOrderPickVo();
                vo.setProductModelId(productModelId);
                vo.setOperationName(structure.getOperationName());
                vo.setTechnologyOperationId(structure.getTechnologyOperationId());
                vo.setProductName(structure.getProductName());
                vo.setModel(structure.getModel());
                vo.setDemandedQuantity(BigDecimal.ZERO);
                vo.setUnit(structure.getUnit());
                List<String> batchNoList = stockBatchNoMap.get(productModelId) == null
                        ? Collections.emptyList()
                        : new ArrayList<>(stockBatchNoMap.get(productModelId));
                vo.setBatchNoList(batchNoList);
                vo.setStockQuantity(stockQuantityMap.getOrDefault(productModelId, BigDecimal.ZERO));
                vo.setBom(true);
                mergedPickMap.put(mergeKey, vo);
            }
            vo.setDemandedQuantity(defaultDecimal(vo.getDemandedQuantity()).add(defaultDecimal(structure.getDemandedQuantity())));
            ProductionOrderPickVo vo = new ProductionOrderPickVo();
            vo.setProductModelId(productModelId);
            vo.setOperationName(structure.getOperationName());
            vo.setTechnologyOperationId(structure.getTechnologyOperationId());
            vo.setProductName(structure.getProductName());
            vo.setModel(structure.getModel());
            vo.setUnit(structure.getUnit());
            List<String> batchNoList = stockBatchNoMap.get(productModelId) == null
                    ? Collections.emptyList()
                    : new ArrayList<>(stockBatchNoMap.get(productModelId));
            vo.setBatchNoList(batchNoList);
            vo.setStockQuantity(stockQuantityMap.getOrDefault(productModelId, BigDecimal.ZERO));
            vo.setBom(true);
            mergedPickMap.put(mergeKey, vo);
        }
        return new ArrayList<>(mergedPickMap.values());
    }
src/main/java/com/ruoyi/production/service/impl/ProductionProductMainServiceImpl.java
@@ -16,11 +16,11 @@
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.bean.BeanUtils;
import com.ruoyi.procurementrecord.utils.StockUtils;
import com.ruoyi.production.bean.dto.ProductStructureDto;
import com.ruoyi.production.bean.dto.ProductionProductMainDto;
import com.ruoyi.production.enums.ProductOrderStatusEnum;
import com.ruoyi.production.mapper.*;
import com.ruoyi.production.pojo.*;
import com.ruoyi.production.service.ProductionOrderPickService;
import com.ruoyi.production.service.ProductionProductMainService;
import com.ruoyi.project.system.domain.SysUser;
import com.ruoyi.project.system.mapper.SysUserMapper;
@@ -65,10 +65,10 @@
    private final ProductionAccountMapper productionAccountMapper;
    private final ProductionOperationTaskMapper productionOperationTaskMapper;
    private final ProductionOrderMapper productionOrderMapper;
    private final ProductionOrderBomMapper productionOrderBomMapper;
    private final ProductionBomStructureMapper productionBomStructureMapper;
    private final ProductionOrderRoutingOperationMapper productionOrderRoutingOperationMapper;
    private final ProductionOrderRoutingOperationParamMapper productionOrderRoutingOperationParamMapper;
    private final ProductionOrderPickMapper productionOrderPickMapper;
    private final ProductionOrderPickService productionOrderPickService;
    private final TechnologyRoutingOperationMapper technologyRoutingOperationMapper;
    private final TechnologyOperationMapper technologyOperationMapper;
    private final StockUtils stockUtils;
@@ -255,6 +255,11 @@
        if (productionOrder == null) {
            throw new ServiceException("生产订单不存在");
        }
        // æŠ¥å·¥å‰ç½®æ¡ä»¶ï¼šå½“前工序必须已经领料(任一物料净领料数量大于0)。
        // è¯¥æ ¡éªŒåœ¨äº‹åŠ¡å†…æ‰§è¡Œï¼Œç»•è¿‡å‰ç«¯ç›´æŽ¥è°ƒç”¨æŽ¥å£åŒæ ·å—é™ã€‚
        if (!productionOrderPickService.hasEffectivePick(taskId)) {
            throw new ServiceException("当前工序未领料,无法报工");
        }
        TechnologyRoutingOperation technologyRoutingOperation = technologyRoutingOperationMapper.selectById(routingOperation.getTechnologyRoutingOperationId());
        TechnologyOperation technologyOperation = technologyRoutingOperation == null ? null
                : technologyOperationMapper.selectById(technologyRoutingOperation.getTechnologyOperationId());
@@ -274,24 +279,9 @@
        productionProductMainMapper.insert(productionProductMain);
        syncOperationParamInputValue(dto, routingOperation.getId(), productionProductMain.getId());
        List<ProductStructureDto> productStructureDtos = resolveInputStructures(
                productionOrder.getId(), routingOperation, productModel.getId());
       // å¦‚果没有bom子节点了,那么投入就是他本身
        if (productStructureDtos.isEmpty()) {
            ProductStructureDto fallbackInput = new ProductStructureDto();
            fallbackInput.setProductModelId(productModel.getId());
            fallbackInput.setUnitQuantity(BigDecimal.ONE);
            productStructureDtos.add(fallbackInput);
        }
        for (ProductStructureDto item : productStructureDtos) {
            // å½“前实现按工序成品直接作为投入,后续若接入领料记录可在这里替换来源。
            ProductionProductInput productionProductInput = new ProductionProductInput();
            productionProductInput.setProductionProductMainId(productionProductMain.getId());
            productionProductInput.setProductModelId(item.getProductModelId());
            productionProductInput.setInputQuantity(item.getUnitQuantity().multiply(defaultDecimal(dto.getQuantity())));
            productionProductInput.setQuantity(productionProductInput.getInputQuantity());
            productionProductInputMapper.insert(productionProductInput);
        }
        // æŠ¥å·¥æŠ•入来源于当前工序的实际领料记录,不再按 BOM å•位用量 Ã— æŠ¥å·¥æ•°é‡æŽ¨ç®—。
        // æ¯æ¡æœ‰æ•ˆé¢†æ–™åªä¼šè¢«é¦–次报工归集一次,重复报工不会重复计入同一批物料。
        insertInputsFromTaskPicks(taskId, productionProductMain.getId());
        ProductionProductOutput productionProductOutput = new ProductionProductOutput();
        productionProductOutput.setProductionProductMainId(productionProductMain.getId());
@@ -503,62 +493,54 @@
        return target;
    }
    private List<ProductStructureDto> resolveInputStructures(Long productionOrderId,
                                                             ProductionOrderRoutingOperation routingOperation,
                                                             Long outputProductModelId) {
        if (productionOrderId == null || routingOperation == null || routingOperation.getTechnologyOperationId() == null) {
            return new ArrayList<>();
    /**
     * ç”¨å½“前工序尚未被报工归集的实际领料生成报工投入。
     * æŠ•入数量取净领料数量(领料+补料-退料),并记录来源领料ID,
     * ä¿è¯åŒä¸€ç¬”领料不会在多次报工中被重复计入。
     */
    private void insertInputsFromTaskPicks(Long productionOperationTaskId, Long productionProductMainId) {
        if (productionOperationTaskId == null || productionProductMainId == null) {
            return;
        }
        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<>();
        List<ProductionOrderPick> taskPickList = productionOrderPickMapper.selectList(
                Wrappers.<ProductionOrderPick>lambdaQuery()
                        .eq(ProductionOrderPick::getProductionOperationTaskId, productionOperationTaskId)
                        .orderByAsc(ProductionOrderPick::getId));
        if (taskPickList.isEmpty()) {
            return;
        }
        List<Long> pickIdList = taskPickList.stream()
                .map(ProductionOrderPick::getId)
                .filter(Objects::nonNull)
                .collect(Collectors.toList());
        Set<Long> consumedPickIdSet = pickIdList.isEmpty()
                ? Collections.emptySet()
                : productionProductInputMapper.selectList(
                        Wrappers.<ProductionProductInput>lambdaQuery()
                                .in(ProductionProductInput::getProductionOrderPickId, pickIdList))
                .stream()
                .map(ProductionProductInput::getProductionOrderPickId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        List<ProductionBomStructure> bomNodeList = productionBomStructureMapper.selectList(
                Wrappers.<ProductionBomStructure>lambdaQuery()
                        .eq(ProductionBomStructure::getProductionOrderBomId, orderBom.getId())
                        .orderByAsc(ProductionBomStructure::getId));
        if (bomNodeList.isEmpty()) {
            return new ArrayList<>();
        }
        Map<Long, ProductionBomStructure> nodeMap = bomNodeList.stream()
                .filter(item -> item != null && item.getId() != null)
                .collect(Collectors.toMap(ProductionBomStructure::getId, item -> item, (left, right) -> left));
        Long currentOutputModelId = routingOperation.getProductModelId() != null
                ? routingOperation.getProductModelId()
                : outputProductModelId;
        Map<Long, BigDecimal> unitQtyByProductModel = new LinkedHashMap<>();
        for (ProductionBomStructure node : bomNodeList) {
            if (node == null || node.getParentId() == null || node.getProductModelId() == null) {
        for (ProductionOrderPick pick : taskPickList) {
            if (pick == null || pick.getId() == null || consumedPickIdSet.contains(pick.getId())) {
                continue;
            }
            if (!Objects.equals(node.getTechnologyOperationId(), routingOperation.getTechnologyOperationId())) {
            BigDecimal effectiveQuantity = defaultDecimal(pick.getQuantity())
                    .add(defaultDecimal(pick.getFeedingQty()))
                    .subtract(defaultDecimal(pick.getReturnQty()));
            if (effectiveQuantity.compareTo(BigDecimal.ZERO) <= 0) {
                continue;
            }
            ProductionBomStructure parent = nodeMap.get(node.getParentId());
            if (parent == null || !Objects.equals(parent.getProductModelId(), currentOutputModelId)) {
                continue;
            }
            unitQtyByProductModel.merge(node.getProductModelId(), defaultDecimal(node.getUnitQuantity()), BigDecimal::add);
            ProductionProductInput productionProductInput = new ProductionProductInput();
            productionProductInput.setProductionProductMainId(productionProductMainId);
            productionProductInput.setProductModelId(pick.getProductModelId());
            productionProductInput.setProductionOrderPickId(pick.getId());
            productionProductInput.setInputQuantity(effectiveQuantity);
            productionProductInput.setQuantity(effectiveQuantity);
            productionProductInputMapper.insert(productionProductInput);
        }
        List<ProductStructureDto> result = new ArrayList<>();
        for (Map.Entry<Long, BigDecimal> entry : unitQtyByProductModel.entrySet()) {
            if (entry.getValue().compareTo(BigDecimal.ZERO) <= 0) {
                continue;
            }
            ProductStructureDto item = new ProductStructureDto();
            item.setProductModelId(entry.getKey());
            item.setUnitQuantity(entry.getValue());
            result.add(item);
        }
        return result;
    }
    private Boolean removeProductMainByProductionTask(ProductionProductMain productionProductMain) {
src/main/java/com/ruoyi/production/util/TaskPlanQuantityUtil.java
@@ -1,130 +1,23 @@
package com.ruoyi.production.util;
import com.ruoyi.production.pojo.ProductionBomStructure;
import com.ruoyi.production.pojo.ProductionOrder;
import com.ruoyi.production.pojo.ProductionOrderRoutingOperation;
import com.ruoyi.technology.pojo.TechnologyRoutingOperation;
import lombok.experimental.UtilityClass;
import java.math.BigDecimal;
import java.util.*;
import java.util.stream.Collectors;
/**
 * å·¥å•计划数量计算工具类
 * å·¥å•计划数量计算工具类。
 * æ‰å¹³ BOM åªç»´æŠ¤â€œç‰©æ–™è§„æ ¼ + æ¶ˆè€—工序”,不配置任何数量,
 * å› æ­¤å·¥åºå·¥å•的计划数量统一取生产订单的下单数量。
 */
@UtilityClass
public class TaskPlanQuantityUtil {
    /**
     * è®¡ç®—工单计划数量(使用 TechnologyRoutingOperation)
     * æŒ‰ç”Ÿäº§è®¢å•数量解析工单计划数量。
     */
    public BigDecimal resolveTaskPlanQuantity(TechnologyRoutingOperation sourceOperation,
                                              Map<String, BigDecimal> operationDemandedQuantityMap,
                                              ProductionOrder productionOrder,
                                              Long rootProductModelId) {
        if (sourceOperation == null || operationDemandedQuantityMap == null || operationDemandedQuantityMap.isEmpty()) {
            return defaultDecimal(productionOrder == null ? null : productionOrder.getQuantity());
        }
        Long outputProductModelId = sourceOperation.getProductModelId() != null
                ? sourceOperation.getProductModelId()
                : rootProductModelId;
        String key = buildOperationDemandedQuantityKey(sourceOperation.getTechnologyOperationId(), outputProductModelId);
        BigDecimal planQuantity = operationDemandedQuantityMap.get(key);
        return planQuantity != null ? planQuantity : defaultDecimal(productionOrder == null ? null : productionOrder.getQuantity());
    }
    /**
     * è®¡ç®—工单计划数量(使用 ProductionOrderRoutingOperation)
     */
    public 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;
    }
    /**
     * æž„建工序需求量映射表
     */
    public Map<String, BigDecimal> buildOperationDemandedQuantityMap(List<ProductionBomStructure> bomStructures, Long rootProductModelId) {
        if (bomStructures == null || bomStructures.isEmpty()) {
            return Collections.emptyMap();
        }
        Map<Long, ProductionBomStructure> structureById = new HashMap<>();
        for (ProductionBomStructure item : bomStructures) {
            if (item != null && item.getId() != null) {
                structureById.put(item.getId(), item);
            }
        }
        Map<String, BigDecimal> demandedQuantityMap = new HashMap<>();
        Set<String> mergedOutputNodeKeySet = new HashSet<>();
        for (ProductionBomStructure bomStructure : bomStructures) {
            if (bomStructure == null || bomStructure.getTechnologyOperationId() == null) {
                continue;
            }
            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;
            }
            BigDecimal demandedQuantity = defaultDecimal(outputNode == null ? null : outputNode.getDemandedQuantity());
            String key = buildOperationDemandedQuantityKey(bomStructure.getTechnologyOperationId(), outputProductModelId);
            demandedQuantityMap.merge(key, demandedQuantity, BigDecimal::add);
        }
        return demandedQuantityMap;
    }
    /**
     * æž„建工序需求量key
     */
    public String buildOperationDemandedQuantityKey(Long operationId, Long outputProductModelId) {
        return String.valueOf(operationId) + "#" + String.valueOf(outputProductModelId);
    }
    /**
     * æž„建输出节点key
     */
    public String buildOperationOutputNodeKey(Long operationId, Long outputNodeId, Long outputProductModelId) {
        return String.valueOf(operationId) + "#" + String.valueOf(outputNodeId) + "#" + String.valueOf(outputProductModelId);
    }
    /**
     * è§£æžå·¥åºè¾“出节点
     */
    public ProductionBomStructure resolveOperationOutputNode(ProductionBomStructure bomStructure,
                                                             Map<Long, ProductionBomStructure> structureById) {
        if (bomStructure == null) {
            return null;
        }
        if (bomStructure.getParentId() == null) {
            return bomStructure;
        }
        ProductionBomStructure parent = structureById.get(bomStructure.getParentId());
        return parent != null ? parent : bomStructure;
    }
    /**
     * è§£æžè¾“出产品规格ID
     */
    public Long resolveOutputProductModelId(ProductionBomStructure outputNode, Long rootProductModelId) {
        if (outputNode == null) {
            return rootProductModelId;
        }
        return outputNode.getProductModelId() != null ? outputNode.getProductModelId() : rootProductModelId;
    public BigDecimal resolveTaskPlanQuantity(ProductionOrder productionOrder) {
        return defaultDecimal(productionOrder == null ? null : productionOrder.getQuantity());
    }
    /**
src/main/java/com/ruoyi/sales/service/impl/SalesLedgerProductServiceImpl.java
@@ -308,29 +308,30 @@
        }
        int count = 0;
        StringBuilder stringBuffer = new StringBuilder();
        // æ‰å¹³BOM只维护“物料规格 + æ¶ˆè€—工序”,不配置用量,
        // å› æ­¤è¿™é‡Œåªæ ¡éªŒæ¯ä¸ªç‰©æ–™æ˜¯å¦æœ‰å¯ç”¨åº“存,不再按单位用量核算需求数量。
        for (TechnologyBomStructureVo structure : structureList) {
            if (structure.getParentId() == null) {
            if (structure == null || structure.getProductModelId() == null) {
                continue;
            }
            StockInventory stockInventory = stockInventoryMapper.selectOne(
                    new QueryWrapper<StockInventory>().lambda().eq(StockInventory::getProductModelId, structure.getProductModelId()));
            if (stockInventory == null) {
                count++;
                stringBuffer.append(structure.getProductName()).append("-").append(structure.getModel()).append("库存不足").append(System.lineSeparator());
                continue;
            List<StockInventory> stockList = stockInventoryMapper.selectList(
                    new QueryWrapper<StockInventory>().lambda()
                            .eq(StockInventory::getProductModelId, structure.getProductModelId()));
            BigDecimal available = BigDecimal.ZERO;
            for (StockInventory stockInventory : stockList) {
                if (stockInventory == null) {
                    continue;
                }
                BigDecimal quantity = stockInventory.getQualitity() == null ? BigDecimal.ZERO : stockInventory.getQualitity();
                BigDecimal locked = stockInventory.getLockedQuantity() == null ? BigDecimal.ZERO : stockInventory.getLockedQuantity();
                available = available.add(quantity.subtract(locked));
            }
            BigDecimal required = salesLedgerProduct.getQuantity().multiply(structure.getUnitQuantity() == null ? BigDecimal.ZERO : structure.getUnitQuantity());
            BigDecimal remain = stockInventory.getQualitity()
                    .subtract(stockInventory.getLockedQuantity())
                    .subtract(required)
                    .divide(BigDecimal.ONE, 2, RoundingMode.CEILING);
            if (remain.compareTo(BigDecimal.ZERO) < 0) {
            if (available.compareTo(BigDecimal.ZERO) <= 0) {
                count++;
                stringBuffer.append(structure.getProductName())
                        .append("-")
                        .append(structure.getModel())
                        .append("库存不足,少")
                        .append(remain.abs())
                        .append("无可用库存")
                        .append(System.lineSeparator());
            }
        }
src/main/java/com/ruoyi/technology/bean/dto/BomImportDto.java
@@ -3,33 +3,32 @@
import com.ruoyi.framework.aspectj.lang.annotation.Excel;
import lombok.Data;
import java.math.BigDecimal;
/**
 * BOM å¯¼å…¥å¯¼å‡ºè¡Œï¼šæ‰å¹³ç»“构,一行 = æˆå“ + ä¸€ä¸ªç‰©æ–™è§„æ ¼ + è¯¥ç‰©æ–™çš„æ¶ˆè€—工序。
 * ä¸å†åŒ…含层级(父项/子项)和单位用量。
 */
@Data
public class BomImportDto {
    @Excel(name = "父项产品编号")
    private String parentCode;
    @Excel(name = "成品产品编号")
    private String productCode;
    @Excel(name = "父项产品名称")
    private String parentName;
    @Excel(name = "成品产品名称")
    private String productName;
    @Excel(name = "父项产品规格")
    private String parentSpec;
    @Excel(name = "成品产品规格")
    private String productSpec;
    @Excel(name = "子项产品编号")
    private String childCode;
    @Excel(name = "物料产品编号")
    private String materialCode;
    @Excel(name = "子项产品名称")
    private String childName;
    @Excel(name = "物料产品名称")
    private String materialName;
    @Excel(name = "子项产品规格")
    private String childSpec;
    @Excel(name = "物料产品规格")
    private String materialSpec;
    @Excel(name = "单位用量")
    private BigDecimal unitQty;
    @Excel(name = "投料工序")
    @Excel(name = "消耗工序")
    private String process;
    @Excel(name = "备注")
src/main/java/com/ruoyi/technology/bean/dto/TechnologyBomStructureDto.java
@@ -23,9 +23,10 @@
    @Schema(description = "规格型号")
    private String model;
    private String tempId;
    private String parentTempId;
    private List<TechnologyBomStructureDto> children;
    /**
     * BOM åªç»´æŠ¤æ‰å¹³çš„“物料规格 + æ¶ˆè€—工序”关系,不再有层级和数量。
     * æ˜Žç»†é¡¹åªéœ€è¦ id(更新时)、productModelId、operationId。
     */
    @Schema(description = "扁平物料明细列表")
    private List<TechnologyBomStructureDto> detailList;
}
src/main/java/com/ruoyi/technology/bean/dto/TechnologyRoutingOperationDto.java
@@ -5,6 +5,8 @@
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
@EqualsAndHashCode(callSuper = true)
@Data
@Schema(name = "TechnologyRoutingOperationDto对象", description = "工艺路线工序查询参数")
@@ -12,4 +14,11 @@
    @Schema(description = "工艺路线id(兼容旧参数 routeId)")
    private Long routeId;
    /**
     * å·¥è‰ºè·¯çº¿å·¥åºå…¨é‡ä¿å­˜åˆ—表:按提交顺序作为工序顺序,
     * æ¯é¡¹åªéœ€è¦ id(更新时)与 technologyOperationId。
     */
    @Schema(description = "工艺路线工序列表(全量保存)")
    private List<TechnologyRoutingOperationDto> operationList;
}
src/main/java/com/ruoyi/technology/bean/vo/TechnologyBomStructureVo.java
@@ -5,8 +5,9 @@
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
/**
 * BOM æ˜Žç»†å±•示对象,扁平结构:一行 = ä¸€ä¸ªç‰©æ–™è§„æ ¼ + å…¶æ¶ˆè€—工序。
 */
@Data
@EqualsAndHashCode(callSuper = true)
public class TechnologyBomStructureVo extends TechnologyBomStructure {
@@ -25,6 +26,4 @@
    @Schema(description = "产品编码")
    private String productCode;
    private List<TechnologyBomStructureVo> children;
}
src/main/java/com/ruoyi/technology/controller/TechnologyBomStructureController.java
@@ -22,14 +22,14 @@
    private final TechnologyBomStructureService technologyBomStructureService;
    @PostMapping
    @Operation(summary = "新增或修改BOM结构")
    @Log(title = "新增或修改BOM结构", businessType = BusinessType.INSERT)
    @Operation(summary = "全量保存BOM扁平物料明细")
    @Log(title = "全量保存BOM扁平物料明细", businessType = BusinessType.INSERT)
    public R addOrUpdate(@RequestBody TechnologyBomStructureDto technologyBomStructureDto) {
        return R.ok(technologyBomStructureService.addTechnologyBomStructure(technologyBomStructureDto));
    }
    @GetMapping("/listByBomId/{bomId}")
    @Operation(summary = "根据BOM查询结构树")
    @Operation(summary = "根据BOM查询扁平物料明细")
    public R<List<TechnologyBomStructureVo>> listByBomId(@PathVariable Long bomId) {
        return R.ok(technologyBomStructureService.listByBomId(bomId));
    }
src/main/java/com/ruoyi/technology/controller/TechnologyRoutingOperationController.java
@@ -51,6 +51,13 @@
    }
    @Log(title = "工艺路线工序", businessType = BusinessType.UPDATE)
    @Operation(summary = "全量保存工艺路线工序")
    @PostMapping("/saveRouteOperations")
    public R saveRouteOperations(@RequestBody TechnologyRoutingOperationDto technologyRoutingOperationDto) {
        return R.ok(technologyRoutingOperationService.saveRouteOperations(technologyRoutingOperationDto));
    }
    @Log(title = "工艺路线工序", businessType = BusinessType.UPDATE)
    @Operation(summary = "修改工艺路线工序")
    @PutMapping
    public R edit(@RequestBody TechnologyRoutingOperation technologyRoutingOperation) {
src/main/java/com/ruoyi/technology/service/TechnologyRoutingOperationService.java
@@ -20,6 +20,11 @@
    boolean saveTechnologyRoutingOperation(TechnologyRoutingOperation technologyRoutingOperation);
    /**
     * å…¨é‡ä¿å­˜å·¥è‰ºè·¯çº¿å·¥åºï¼šæŒ‰æäº¤é¡ºåºç¡®å®šå·¥åºé¡ºåºï¼Œæœªå›žä¼ çš„工序会被删除。
     */
    boolean saveRouteOperations(TechnologyRoutingOperationDto dto);
    boolean removeTechnologyRoutingOperation(Long id);
    int sort(TechnologyRoutingOperation technologyRoutingOperation);
src/main/java/com/ruoyi/technology/service/impl/TechnologyBomServiceImpl.java
@@ -1,6 +1,5 @@
package com.ruoyi.technology.service.impl;
import cn.hutool.core.collection.CollUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
@@ -12,19 +11,18 @@
import com.ruoyi.basic.service.IProductService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.bean.BeanUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.framework.web.domain.R;
import com.ruoyi.technology.bean.dto.BomImportDto;
import com.ruoyi.technology.bean.dto.TechnologyBomDto;
import com.ruoyi.technology.bean.dto.TechnologyBomStructureDto;
import com.ruoyi.technology.bean.vo.TechnologyBomStructureVo;
import com.ruoyi.technology.bean.vo.TechnologyBomVo;
import com.ruoyi.technology.mapper.TechnologyBomMapper;
import com.ruoyi.technology.mapper.TechnologyBomStructureMapper;
import com.ruoyi.technology.mapper.TechnologyOperationMapper;
import com.ruoyi.technology.mapper.TechnologyRoutingMapper;
import com.ruoyi.technology.pojo.TechnologyBom;
import com.ruoyi.technology.pojo.TechnologyBomStructure;
import com.ruoyi.technology.pojo.TechnologyOperation;
import com.ruoyi.technology.pojo.TechnologyRouting;
import com.ruoyi.technology.service.TechnologyBomService;
import com.ruoyi.technology.service.TechnologyBomStructureService;
@@ -34,10 +32,12 @@
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.math.BigDecimal;
import java.util.*;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
@RequiredArgsConstructor
@@ -48,7 +48,7 @@
    private final TechnologyBomStructureService technologyBomStructureService;
    private final TechnologyRoutingMapper technologyRoutingMapper;
    private final IProductService productService;
    private final TechnologyBomStructureMapper technologyBomStructureMapper;
    private final TechnologyOperationMapper technologyOperationMapper;
    /**
     * åˆ†é¡µæŸ¥è¯¢BOM列表。
@@ -84,7 +84,7 @@
    }
    /**
     * æ–°å¢žBOM并初始化根结构节点。
     * æ–°å¢žBOM。BOM主表本身即代表成品规格,明细表不再写入成品根节点。
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -96,12 +96,12 @@
        }
        technologyBom.setBomNo("BM." + String.format("%05d", technologyBom.getId()));
        technologyBomMapper.updateById(technologyBom);
        initRootStructure(technologyBom.getId().longValue(), technologyBom.getProductModelId());
        return R.ok();
    }
    /**
     * ä¿®æ”¹BOM,规格变化时同步刷新关联结构与路线数据。
     * ä¿®æ”¹BOM。成品规格变化时同步刷新工艺路线的成品规格,
     * å¹¶æ¸…理与新成品规格冲突的物料行(成品不能作为自身物料),其余物料-工序关系保留。
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -117,8 +117,8 @@
        if (oldBom.getProductModelId() != null && !oldBom.getProductModelId().equals(technologyBom.getProductModelId())) {
            technologyRoutingMapper.updateProductModelByBomId(technologyBom.getProductModelId(), technologyBom.getId().longValue());
            technologyBomStructureService.remove(Wrappers.<TechnologyBomStructure>lambdaQuery()
                    .eq(TechnologyBomStructure::getBomId, technologyBom.getId().longValue()));
            initRootStructure(technologyBom.getId().longValue(), technologyBom.getProductModelId());
                    .eq(TechnologyBomStructure::getBomId, technologyBom.getId().longValue())
                    .eq(TechnologyBomStructure::getProductModelId, technologyBom.getProductModelId()));
        }
        if (technologyBom.getBomNo() == null) {
            technologyBom.setBomNo(oldBom.getBomNo());
@@ -160,19 +160,9 @@
    }
    /**
     * åˆå§‹åŒ–BOM根节点结构。
     * å¯¼å…¥æ‰å¹³BOM:每行 = æˆå“ + ä¸€ä¸ªç‰©æ–™è§„æ ¼ + æ¶ˆè€—工序。
     * åŒä¸€æ¬¡å¯¼å…¥çš„æ‰€æœ‰è¡Œå¿…须属于同一个成品;旧的父子/单位用量模板会被明确拒绝。
     */
    private void initRootStructure(Long bomId, Long productModelId) {
        ProductModel productModel = productModelService.getById(productModelId);
        TechnologyBomStructure root = new TechnologyBomStructure();
        root.setBomId(bomId);
        root.setParentId(null);
        root.setProductModelId(productModelId);
        root.setUnit(productModel.getUnit());
        root.setUnitQuantity(BigDecimal.ONE);
        technologyBomStructureService.save(root);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public R uploadBom(MultipartFile file) {
@@ -184,159 +174,135 @@
            return R.fail("Excel解析失败");
        }
        if (list == null || list.isEmpty()) return R.fail("数据为空");
        if (list == null || list.isEmpty()) {
            return R.fail("数据为空");
        }
        //  å¤„理工序
        list.forEach(dto -> {
            dto.setParentName(clean(dto.getParentName()));
            dto.setParentSpec(clean(dto.getParentSpec()));
            dto.setChildName(clean(dto.getChildName()));
            dto.setChildSpec(clean(dto.getChildSpec()));
            dto.setProductName(clean(dto.getProductName()));
            dto.setProductSpec(clean(dto.getProductSpec()));
            dto.setMaterialName(clean(dto.getMaterialName()));
            dto.setMaterialSpec(clean(dto.getMaterialSpec()));
            dto.setProcess(clean(dto.getProcess()));
        });
        handleProcess(list);
//        Map<String, Long> processMap = productProcessService.list().stream()
//                .collect(Collectors.toMap(ProductProcess::getName, ProductProcess::getId, (k1, k2) -> k1));
        //  åˆ›å»º BOM æ•°æ®
        boolean templateMatched = list.stream().anyMatch(dto -> StringUtils.isNotBlank(dto.getProductName())
                && StringUtils.isNotBlank(dto.getMaterialName()));
        if (!templateMatched) {
            return R.fail("BOM导入模板已更新为扁平结构,请使用最新模板(成品产品名称/规格、物料产品名称/规格、消耗工序)");
        }
        BomImportDto first = list.get(0);
        ProductModel rootModel = findModel(first.getParentName(), first.getParentSpec());
        ProductModel productModel = findModel(first.getProductName(), first.getProductSpec());
        TechnologyBom bom = new TechnologyBom();
        bom.setProductModelId(rootModel.getId());
        bom.setProductModelId(productModel.getId());
        bom.setVersion("1.0");
        bom.setRemark(first.getRemark());
        technologyBomMapper.insert(bom);
        bom.setBomNo("BM." + String.format("%05d", bom.getId()));
        technologyBomMapper.updateById(bom);
        // è®°å½•已经插入结构的节点:Key = "名称+规格", Value = structure_id
        Map<String, Long> treePathMap = new HashMap<>();
        Map<String, Long> operationIdByName = new HashMap<>();
        for (TechnologyOperation operation : technologyOperationMapper.selectList(null)) {
            if (StringUtils.isNotBlank(operation.getName())) {
                operationIdByName.putIfAbsent(operation.getName().trim(), operation.getId());
            }
        }
        Set<String> relationKeys = new HashSet<>();
        for (int i = 0; i < list.size(); i++) {
            BomImportDto dto = list.get(i);
            String parentKey = dto.getParentName() + "|" + dto.getParentSpec();
            String childKey = dto.getChildName() + "|" + dto.getChildSpec();
            //处理根节点,第一行且子项为空
            if (i == 0 && StringUtils.isBlank(dto.getChildName())) {
                TechnologyBomStructure rootNode = new TechnologyBomStructure();
                rootNode.setBomId(bom.getId().longValue());
                rootNode.setParentId(null); // é¡¶å±‚没有父节点
                rootNode.setProductModelId(rootModel.getId());
                rootNode.setUnitQuantity(BigDecimal.ONE);
                rootNode.setUnit(rootModel.getUnit());
                technologyBomStructureService.save(rootNode);
                treePathMap.put(parentKey, rootNode.getId());
                continue;
            int rowNo = i + 2;
            if (StringUtils.isBlank(dto.getMaterialName())) {
                throw new ServiceException("导入失败:第" + rowNo + "行缺少物料产品名称");
            }
            if (StringUtils.isNotBlank(dto.getProductName())
                    && !sameModel(dto, first)) {
                throw new ServiceException("导入失败:第" + rowNo + "行的成品与第一行不一致,一次导入只能维护一个BOM");
            }
            if (StringUtils.isBlank(dto.getProcess())) {
                throw new ServiceException("导入失败:第" + rowNo + "行缺少消耗工序");
            }
            Long operationId = operationIdByName.get(dto.getProcess());
            if (operationId == null) {
                throw new ServiceException("导入失败:工序未维护[" + dto.getProcess() + "](第" + rowNo + "行)");
            }
            //  å¤„理子层级节点
            //  æ‰¾åˆ°çˆ¶èŠ‚ç‚¹åœ¨æ•°æ®åº“é‡Œçš„ ID
            Long parentStructureId = treePathMap.get(parentKey);
            if (parentStructureId == null) {
                // å¦‚æžœ Map é‡Œæ‰¾ä¸åˆ°ï¼Œè¯´æ˜Ž Excel é¡ºåºä¹±äº†æˆ–者数据有误
                throw new ServiceException("导入失败: çˆ¶é¡¹[" + dto.getParentName() + "]必须在其子项之前定义");
            ProductModel materialModel = findModel(dto.getMaterialName(), dto.getMaterialSpec());
            if (materialModel.getId().equals(productModel.getId())) {
                throw new ServiceException("导入失败:第" + rowNo + "行成品规格不能作为自身的物料");
            }
            if (!relationKeys.add(materialModel.getId() + "#" + operationId)) {
                throw new ServiceException("导入失败:第" + rowNo + "行物料与工序重复配置");
            }
            //  èŽ·å–å­é¡¹æ¨¡åž‹ä¿¡æ¯
            ProductModel childModel = findModel(dto.getChildName(), dto.getChildSpec());
            //  æ’入结构表
            TechnologyBomStructure node = new TechnologyBomStructure();
            node.setBomId(bom.getId().longValue());
            node.setParentId(parentStructureId); // çˆ¶èŠ‚ç‚¹ID
            node.setProductModelId(childModel.getId());
            node.setUnitQuantity(dto.getUnitQty());
            node.setUnit(childModel.getUnit());
//            if (processMap.containsKey(dto.getProcess())) {
//                node.setProcessId(processMap.get(dto.getProcess()));
//            }
            node.setProductModelId(materialModel.getId());
            node.setOperationId(operationId);
            technologyBomStructureService.save(node);
            //  æŠŠå½“前子项记录到 Map,作为以后更深层级的父项查找依据
            //  åŒä¸€çˆ¶é¡¹ä¸‹çš„同名子项不需要重复记录
            treePathMap.put(childKey, node.getId());
        }
        return R.ok("BOM导入成功");
    }
    /**
     * å¯¼å‡ºæ‰å¹³BOM:成品 + ç‰©æ–™ + æ¶ˆè€—工序。
     */
    @Override
    public void exportBom(HttpServletResponse response, Long bomId) {
        if (bomId == null) {
            throw new ServiceException("BOM ID不能为空");
        }
        TechnologyBom bom = technologyBomMapper.selectById(bomId);
        if (bom == null) {
            throw new ServiceException("BOM不存在");
        }
        List<TechnologyBomStructureVo> treeData = technologyBomStructureService.listByBomId(bomId);
        if (treeData == null || treeData.isEmpty()) {
        List<TechnologyBomStructureVo> detailList = technologyBomStructureService.listByBomId(bomId);
        if (detailList == null || detailList.isEmpty()) {
            return;
        }
        //  å°†æ ‘形结构扁平化 ä½¿ç”¨ BFS算法 å¯¼å‡º,按层级顺序
        List<BomImportDto> exportList = new ArrayList<>();
        ProductModel productModel = productModelService.getById(bom.getProductModelId());
        String productName = null;
        if (productModel != null && productModel.getProductId() != null) {
            Product product = productService.getById(productModel.getProductId());
            productName = product == null ? null : product.getProductName();
        }
        // Map<ID, Node> idMap ç”¨äºŽæŸ¥æ‰¾çˆ¶èŠ‚ç‚¹
        Map<Long, TechnologyBomStructureVo> idMap = new HashMap<>();
        populateMap(treeData, idMap);
        //  treeData çš„第一个是根节点
        for (TechnologyBomStructureVo root : treeData) {
            //  æ·»åŠ æ ¹èŠ‚ç‚¹
            BomImportDto rootRow = new BomImportDto();
            rootRow.setParentName(root.getProductName());
            rootRow.setParentSpec(root.getModel());
            rootRow.setUnitQty(root.getUnitQuantity());
            rootRow.setParentCode(root.getProductCode());
            rootRow.setRemark("");
            exportList.add(rootRow);
            //  BFS éåކ-队列
            Queue<TechnologyBomStructureVo> queue = new LinkedList<>();
            if (root.getChildren() != null) {
                queue.addAll(root.getChildren());
            }
            while (!queue.isEmpty()) {
                TechnologyBomStructureVo child = queue.poll();
                // æŸ¥æ‰¾çˆ¶èŠ‚ç‚¹
                TechnologyBomStructureVo parent = idMap.get(child.getParentId());
                if (parent == null) {
                    // é™¤äº†æœ€å¤–层节点,其他节点的父类肯定是不会为空的
                    continue;
                }
                BomImportDto row = new BomImportDto();
                // çˆ¶ç±»ä¿¡æ¯
                row.setParentName(parent.getProductName());
                row.setParentSpec(parent.getModel());
                // å­ç±»ä¿¡æ¯
                row.setChildName(child.getProductName());
                row.setChildSpec(child.getModel());
                row.setUnitQty(child.getUnitQuantity());
                row.setProcess(child.getOperationName());
                row.setChildCode(child.getProductCode());
                exportList.add(row);
                //  å°†å­èŠ‚ç‚¹çš„å­èŠ‚ç‚¹åŠ å…¥é˜Ÿåˆ—-下一层
                if (child.getChildren() != null && !child.getChildren().isEmpty()) {
                    queue.addAll(child.getChildren());
                }
            }
        List<BomImportDto> exportList = new ArrayList<>(detailList.size());
        for (TechnologyBomStructureVo detail : detailList) {
            BomImportDto row = new BomImportDto();
            row.setProductName(productName);
            row.setProductSpec(productModel == null ? null : productModel.getModel());
            row.setProductCode(productModel == null ? null : productModel.getProductCode());
            row.setMaterialName(detail.getProductName());
            row.setMaterialSpec(detail.getModel());
            row.setMaterialCode(detail.getProductCode());
            row.setProcess(detail.getOperationName());
            exportList.add(row);
        }
        ExcelUtil<BomImportDto> util = new ExcelUtil<>(BomImportDto.class);
        util.exportExcel(response, exportList, "BOM结构导出");
    }
    /**
     * å¤åˆ¶BOM:复制成品规格与全部扁平物料-工序关系。
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public R copy(TechnologyBom technologyBom) {
        if (technologyBom == null || technologyBom.getId() == null) {
            throw new ServiceException("BOM ID不能为空");
        }
        TechnologyBom oldTechnologyBom = technologyBomMapper.selectById(technologyBom.getId());
        List<TechnologyBomStructureVo> oldTechnologyBomStructureVos = technologyBomStructureService.listByBomId(technologyBom.getId().longValue());
        //校验产品规格是否存在。
        if (oldTechnologyBom == null) {
            throw new ServiceException("BOM不存在");
        }
        validateProductModel(oldTechnologyBom.getProductModelId());
        TechnologyBom newTechnologyBom = new TechnologyBom();
        newTechnologyBom.setProductModelId(oldTechnologyBom.getProductModelId());
        newTechnologyBom.setVersion("FZ" + oldTechnologyBom.getVersion());
@@ -347,14 +313,30 @@
        }
        newTechnologyBom.setBomNo("BM." + String.format("%05d", newTechnologyBom.getId()));
        technologyBomMapper.updateById(newTechnologyBom);
        //初始化BOM根节点结构。
        initRootStructure(newTechnologyBom.getId().longValue(), newTechnologyBom.getProductModelId());
        //把产品结构里面的数据也全部都复制
        TechnologyBomStructureVo technologyBomStructureVo = oldTechnologyBomStructureVos.get(0);
        TechnologyBomStructureDto technologyBomStructureDto = convertTree(technologyBomStructureVo);
        technologyBomStructureDto.setBomId(newTechnologyBom.getId().longValue());
        technologyBomStructureService.addTechnologyBomStructure(technologyBomStructureDto);
        List<TechnologyBomStructureVo> oldDetailList =
                technologyBomStructureService.listByBomId(technologyBom.getId().longValue());
        if (oldDetailList != null && !oldDetailList.isEmpty()) {
            List<TechnologyBomStructure> newDetailList = new ArrayList<>(oldDetailList.size());
            for (TechnologyBomStructureVo oldDetail : oldDetailList) {
                TechnologyBomStructure newDetail = new TechnologyBomStructure();
                newDetail.setBomId(newTechnologyBom.getId().longValue());
                newDetail.setProductModelId(oldDetail.getProductModelId());
                newDetail.setOperationId(oldDetail.getOperationId());
                newDetailList.add(newDetail);
            }
            technologyBomStructureService.saveBatch(newDetailList);
        }
        return R.ok();
    }
    private boolean sameModel(BomImportDto row, BomImportDto first) {
        return StringUtils.equals(row.getProductName(), first.getProductName())
                && StringUtils.equals(defaultBlank(row.getProductSpec()), defaultBlank(first.getProductSpec()));
    }
    private String defaultBlank(String value) {
        return value == null ? "" : value;
    }
    private ProductModel findModel(String name, String spec) {
@@ -369,118 +351,8 @@
        return model;
    }
    private void handleProcess(List<BomImportDto> list) {
        Set<String> processNames = list.stream()
                .map(BomImportDto::getProcess)
                .filter(StringUtils::isNotBlank)
                .collect(Collectors.toSet());
        if (processNames.isEmpty()) {
            return;
        }
//        List<ProductProcess> exists = productProcessService.list(
//                new LambdaQueryWrapper<ProductProcess>().in(ProductProcess::getName, processNames)
//        );
//        Set<String> existNames = exists.stream()
//                .map(ProductProcess::getName)
//                .collect(Collectors.toSet());
//
//        List<ProductProcess> needSave = processNames.stream()
//                .filter(n -> !existNames.contains(n))
//                .map(n -> {
//                    ProductProcess p = new ProductProcess();
//                    p.setName(n);
//                    return p;
//                })
//                .collect(Collectors.toList());
//
//        if (!needSave.isEmpty()) {
//            productProcessService.saveBatch(needSave);
//            needSave.forEach(p -> p.setNo("GX" + String.format("%08d", p.getId())));
//            productProcessService.updateBatchById(needSave);
//        }
    }
    private String clean(String s) {
        if (s == null) return null;
        return s.replaceAll("[\\u00A0\\u3000]", "").trim();
    }
    private void populateMap(List<TechnologyBomStructureVo> nodes, Map<Long, TechnologyBomStructureVo> map) {
        if (nodes == null || nodes.isEmpty()) {
            return;
        }
        for (TechnologyBomStructureVo node : nodes) {
            map.put(node.getId(), node);
            populateMap(node.getChildren(), map);
        }
    }
    /**
     * é€’归转换树形结构 VO -> DTO
     * è‡ªåŠ¨ç”Ÿæˆè™šæ‹Ÿ tempId / parentTempId,保证任意层级树结构正确
     */
    public static TechnologyBomStructureDto convertTree(TechnologyBomStructureVo vo) {
        if (vo == null) {
            return null;
        }
        TechnologyBomStructureDto realDto = convertNode(vo, "0"); // æ ¹èŠ‚ç‚¹çˆ¶ID=0(纯数字)
        TechnologyBomStructureDto rootDto = new TechnologyBomStructureDto();
        rootDto.setTempId("0");
        rootDto.setChildren(Collections.singletonList(realDto));
        return rootDto;
    }
    /**
     * æ ¸å¿ƒé€’归方法
     * @param vo åŽŸå§‹èŠ‚ç‚¹
     * @param parentTempId çˆ¶èŠ‚ç‚¹ çº¯æ•°å­—ID
     * @return è½¬æ¢åŽDTO
     */
    private static TechnologyBomStructureDto convertNode(TechnologyBomStructureVo vo, String parentTempId) {
        if (vo == null) {
            return null;
        }
        TechnologyBomStructureDto dto = new TechnologyBomStructureDto();
        BeanUtils.copyProperties(vo, dto);
        String currentTempId = getNumberId();
        dto.setTempId(currentTempId);
        dto.setParentTempId(parentTempId);
        dto.setId(null);
        dto.setParentId(null);
        // ===================== é€’归子节点 =====================
        List<TechnologyBomStructureVo> voChildren = vo.getChildren();
        if (CollUtil.isNotEmpty(voChildren)) {
            List<TechnologyBomStructureDto> dtoChildren = new ArrayList<>();
            for (TechnologyBomStructureVo childVo : voChildren) {
                // å­èŠ‚ç‚¹çš„çˆ¶ID = å½“前节点的数字ID
                dtoChildren.add(convertNode(childVo, currentTempId));
            }
            dto.setChildren(dtoChildren);
        } else {
            dto.setChildren(new ArrayList<>());
        }
        return dto;
    }
    /**
     * ç”Ÿæˆ 13位 çº¯æ•°å­—随机ID(安全、不重复、高性能)
     */
    private static String getNumberId() {
        // ç”Ÿæˆ 1000000000000 ~ 9999999999999 ä¹‹é—´çš„æ•°å­—
        long min = 1000000000000L;
        long max = 9999999999999L;
        long randomNum = ThreadLocalRandom.current().nextLong(min, max + 1);
        return String.valueOf(randomNum);
    }
}
src/main/java/com/ruoyi/technology/service/impl/TechnologyBomStructureServiceImpl.java
@@ -2,135 +2,149 @@
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.basic.mapper.ProductModelMapper;
import com.ruoyi.basic.pojo.ProductModel;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.technology.bean.dto.TechnologyBomStructureDto;
import com.ruoyi.technology.bean.vo.TechnologyBomStructureVo;
import com.ruoyi.technology.mapper.TechnologyBomMapper;
import com.ruoyi.technology.mapper.TechnologyBomStructureMapper;
import com.ruoyi.technology.mapper.TechnologyOperationMapper;
import com.ruoyi.technology.pojo.TechnologyBom;
import com.ruoyi.technology.pojo.TechnologyBomStructure;
import com.ruoyi.technology.service.TechnologyBomStructureService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class TechnologyBomStructureServiceImpl extends ServiceImpl<TechnologyBomStructureMapper, TechnologyBomStructure> implements TechnologyBomStructureService {
    private final TechnologyBomStructureMapper technologyBomStructureMapper;
    private final TechnologyBomMapper technologyBomMapper;
    private final ProductModelMapper productModelMapper;
    private final TechnologyOperationMapper technologyOperationMapper;
    /**
     * ä¿å­˜BOM结构树,处理新增、更新和删除节点。
     * å…¨é‡åŒæ­¥ BOM çš„æ‰å¹³ç‰©æ–™æ˜Žç»†ï¼šä¸€è¡Œåªè¡¨ç¤ºâ€œæŸä¸ªç‰©æ–™è§„格在某道工序被消耗”。
     * ä¸å†æœ‰çˆ¶å­å±‚级、单位用量、需求数量和单位配置。
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Boolean addTechnologyBomStructure(TechnologyBomStructureDto dto) {
        Long bomId = dto.getBomId();
        List<TechnologyBomStructureDto> flatDtoList = new ArrayList<>();
        flattenTree(dto.getChildren(), flatDtoList);
        if (bomId == null) {
            throw new ServiceException("BOM ID不能为空");
        }
        TechnologyBom bom = technologyBomMapper.selectById(bomId);
        if (bom == null) {
            throw new ServiceException("BOM不存在");
        }
        List<TechnologyBomStructureDto> detailList = dto.getDetailList() == null
                ? Collections.emptyList()
                : dto.getDetailList();
        List<TechnologyBomStructure> dbList = this.list(new LambdaQueryWrapper<TechnologyBomStructure>()
                .eq(TechnologyBomStructure::getBomId, bomId));
        Set<Long> frontendIds = new HashSet<>();
        for (TechnologyBomStructureDto item : flatDtoList) {
            if (item.getId() != null) {
                frontendIds.add(item.getId());
            }
        }
        Set<Long> deleteIds = new HashSet<>();
        Set<Long> dbIds = new HashSet<>();
        for (TechnologyBomStructure dbItem : dbList) {
            if (!frontendIds.contains(dbItem.getId())) {
                deleteIds.add(dbItem.getId());
            }
        }
        if (!deleteIds.isEmpty()) {
            this.removeByIds(deleteIds);
            dbIds.add(dbItem.getId());
        }
        Set<Long> submittedIds = new HashSet<>();
        Set<String> relationKeys = new HashSet<>();
        List<TechnologyBomStructure> insertList = new ArrayList<>();
        List<TechnologyBomStructure> updateList = new ArrayList<>();
        Map<String, TechnologyBomStructure> tempEntityMap = new HashMap<>();
        for (TechnologyBomStructureDto item : flatDtoList) {
        for (TechnologyBomStructureDto item : detailList) {
            Long productModelId = item.getProductModelId();
            Long operationId = item.getOperationId();
            validateMaterial(bom, productModelId);
            validateOperation(operationId);
            if (!relationKeys.add(productModelId + "#" + operationId)) {
                throw new ServiceException("同一物料规格在同一工序下不能重复配置");
            }
            TechnologyBomStructure entity = new TechnologyBomStructure();
            BeanUtils.copyProperties(item, entity);
            entity.setBomId(bomId);
            entity.setProductModelId(productModelId);
            entity.setOperationId(operationId);
            // æ‰å¹³ BOM ä¸å†ä½¿ç”¨å±‚级和数量语义,历史列统一置空。
            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);
            }
        }
        if (!insertList.isEmpty()) {
            this.saveBatch(insertList);
        }
        List<TechnologyBomStructure> parentFixList = new ArrayList<>();
        for (TechnologyBomStructureDto item : flatDtoList) {
            if (item.getId() == null && item.getParentTempId() != null) {
                TechnologyBomStructure child = tempEntityMap.get(item.getTempId());
                if (child == null) {
                    continue;
                }
                TechnologyBomStructure parent = tempEntityMap.get(item.getParentTempId());
                Long realParentId = parent != null ? parent.getId() : Long.valueOf(item.getParentTempId());
                child.setParentId(realParentId);
                parentFixList.add(child);
            }
        }
        if (!parentFixList.isEmpty()) {
            this.updateBatchById(parentFixList);
        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);
        }
        return true;
    }
    /**
     * æ ¹æ®BOM查询并组装结构树。
     * æ ¹æ®BOM查询扁平物料明细,按稳定顺序返回。
     */
    @Override
    public List<TechnologyBomStructureVo> listByBomId(Long bomId) {
        List<TechnologyBomStructureVo> list = technologyBomStructureMapper.listByBomId(bomId);
        Map<Long, TechnologyBomStructureVo> map = new HashMap<>();
        for (TechnologyBomStructureVo node : list) {
            node.setChildren(new ArrayList<>());
            map.put(node.getId(), node);
        if (bomId == null) {
            return new ArrayList<>();
        }
        List<TechnologyBomStructureVo> tree = new ArrayList<>();
        for (TechnologyBomStructureVo node : list) {
            Long parentId = node.getParentId();
            if (parentId == null || parentId == 0L) {
                tree.add(node);
                continue;
            }
            TechnologyBomStructureVo parent = map.get(parentId);
            if (parent != null) {
                parent.getChildren().add(node);
            }
        }
        return tree;
        return technologyBomStructureMapper.listByBomId(bomId);
    }
    /**
     * å°†æ ‘形结构拍平成列表,便于统一保存。
     * æ ¡éªŒç‰©æ–™è§„格:必须存在,且不能是 BOM è‡ªèº«çš„æˆå“è§„格。
     */
    private void flattenTree(List<TechnologyBomStructureDto> source, List<TechnologyBomStructureDto> result) {
        if (source == null) {
            return;
    private void validateMaterial(TechnologyBom bom, Long productModelId) {
        if (productModelId == null) {
            throw new ServiceException("请选择物料规格");
        }
        for (TechnologyBomStructureDto node : source) {
            result.add(node);
            flattenTree(node.getChildren(), result);
        ProductModel productModel = productModelMapper.selectById(productModelId);
        if (productModel == null) {
            throw new ServiceException("物料规格不存在");
        }
        if (productModelId.equals(bom.getProductModelId())) {
            throw new ServiceException("成品规格不能作为自身的物料");
        }
    }
    /**
     * æ ¡éªŒæ¶ˆè€—工序必填且存在。
     */
    private void validateOperation(Long operationId) {
        if (operationId == null) {
            throw new ServiceException("请选择消耗工序");
        }
        if (technologyOperationMapper.selectById(operationId) == null) {
            throw new ServiceException("消耗工序不存在");
        }
    }
}
src/main/java/com/ruoyi/technology/service/impl/TechnologyRoutingOperationServiceImpl.java
@@ -4,19 +4,30 @@
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.technology.bean.dto.TechnologyRoutingOperationDto;
import com.ruoyi.technology.bean.dto.TechnologyRoutingOperationParamSyncDto;
import com.ruoyi.technology.bean.vo.TechnologyRoutingOperationVo;
import com.ruoyi.technology.mapper.TechnologyOperationMapper;
import com.ruoyi.technology.mapper.TechnologyRoutingMapper;
import com.ruoyi.technology.mapper.TechnologyRoutingOperationMapper;
import com.ruoyi.technology.mapper.TechnologyRoutingOperationParamMapper;
import com.ruoyi.technology.pojo.TechnologyOperation;
import com.ruoyi.technology.pojo.TechnologyRouting;
import com.ruoyi.technology.pojo.TechnologyRoutingOperation;
import com.ruoyi.technology.pojo.TechnologyRoutingOperationParam;
import com.ruoyi.technology.service.TechnologyRoutingOperationParamService;
import com.ruoyi.technology.service.TechnologyRoutingOperationService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Service
@Transactional(rollbackFor = Exception.class)
@@ -26,6 +37,8 @@
    private final TechnologyRoutingOperationMapper technologyRoutingOperationMapper;
    private final TechnologyRoutingMapper technologyRoutingMapper;
    private final TechnologyOperationMapper technologyOperationMapper;
    private final TechnologyRoutingOperationParamMapper technologyRoutingOperationParamMapper;
    private final TechnologyRoutingOperationParamService technologyRoutingOperationParamService;
    @Override
@@ -63,6 +76,93 @@
        return saved;
    }
    /**
     * å…¨é‡ä¿å­˜å·¥è‰ºè·¯çº¿å·¥åºï¼š
     * 1) æŒ‰æäº¤é¡ºåºä½œä¸ºå·¥åºé¡ºåºï¼ˆdragSort ä»Ž 1 å¼€å§‹ï¼‰ï¼›
     * 2) æ¯é“工序只允许出现一次,与“按BOM工序去重生成”保持一致;
     * 3) å·¥åºäº§å‡ºè§„格固定为工艺路线的成品规格;
     * 4) æœªå›žä¼ çš„工序连同参数快照一并删除。
     */
    @Override
    public boolean saveRouteOperations(TechnologyRoutingOperationDto dto) {
        if (dto == null) {
            throw new ServiceException("参数不能为空");
        }
        Long technologyRoutingId = dto.getTechnologyRoutingId() != null ? dto.getTechnologyRoutingId() : dto.getRouteId();
        if (technologyRoutingId == null) {
            throw new ServiceException("工艺路线ID不能为空");
        }
        TechnologyRouting technologyRouting = technologyRoutingMapper.selectById(technologyRoutingId);
        if (technologyRouting == null) {
            throw new ServiceException("工艺路线不存在");
        }
        List<TechnologyRoutingOperationDto> operationList = dto.getOperationList() == null
                ? Collections.emptyList()
                : dto.getOperationList();
        List<TechnologyRoutingOperation> dbList = this.list(Wrappers.<TechnologyRoutingOperation>lambdaQuery()
                .eq(TechnologyRoutingOperation::getTechnologyRoutingId, technologyRoutingId));
        Set<Long> dbIds = dbList.stream().map(TechnologyRoutingOperation::getId).collect(Collectors.toSet());
        Set<Long> submittedIds = new HashSet<>();
        Set<Long> submittedOperationIds = new HashSet<>();
        List<TechnologyRoutingOperation> insertList = new ArrayList<>();
        List<TechnologyRoutingOperation> updateList = new ArrayList<>();
        int dragSort = 1;
        for (TechnologyRoutingOperationDto item : operationList) {
            Long technologyOperationId = item.getTechnologyOperationId();
            if (technologyOperationId == null) {
                throw new ServiceException("请为所有工序选择工艺工序");
            }
            TechnologyOperation technologyOperation = technologyOperationMapper.selectById(technologyOperationId);
            if (technologyOperation == null) {
                throw new ServiceException("工艺工序不存在");
            }
            if (!submittedOperationIds.add(technologyOperationId)) {
                throw new ServiceException("同一道工序不能重复出现在工艺路线中:" + technologyOperation.getName());
            }
            TechnologyRoutingOperation entity = new TechnologyRoutingOperation();
            entity.setTechnologyRoutingId(technologyRoutingId);
            entity.setProductModelId(technologyRouting.getProductModelId());
            entity.setTechnologyOperationId(technologyOperationId);
            entity.setDragSort(dragSort++);
            entity.setIsQuality(technologyOperation.getIsQuality());
            entity.setIsProduction(technologyOperation.getIsProduction());
            entity.setType(technologyOperation.getType());
            if (item.getId() == null) {
                insertList.add(entity);
            } else {
                if (!dbIds.contains(item.getId())) {
                    throw new ServiceException("工艺路线工序不存在或不属于当前工艺路线");
                }
                entity.setId(item.getId());
                submittedIds.add(item.getId());
                updateList.add(entity);
            }
        }
        Set<Long> deleteIds = new HashSet<>(dbIds);
        deleteIds.removeAll(submittedIds);
        if (!deleteIds.isEmpty()) {
            // åˆ é™¤å·¥åºæ—¶åŒæ—¶æ¸…理其参数快照,避免留下孤立数据
            technologyRoutingOperationParamMapper.delete(Wrappers.<TechnologyRoutingOperationParam>lambdaQuery()
                    .in(TechnologyRoutingOperationParam::getTechnologyRoutingOperationId, deleteIds));
            for (Long deleteId : deleteIds) {
                removeTechnologyRoutingOperation(deleteId);
            }
        }
        if (!updateList.isEmpty()) {
            this.updateBatchById(updateList);
        }
        for (TechnologyRoutingOperation insert : insertList) {
            saveTechnologyRoutingOperation(insert);
        }
        return true;
    }
    @Override
    public boolean removeTechnologyRoutingOperation(Long id) {
        TechnologyRoutingOperation technologyRoutingOperation = this.getById(id);
src/main/java/com/ruoyi/technology/service/impl/TechnologyRoutingServiceImpl.java
@@ -20,10 +20,9 @@
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Service
@@ -105,9 +104,10 @@
    /**
     * æŒ‰å½“前选择的 BOM é‡å»ºå·¥è‰ºè·¯çº¿å·¥åºï¼š
     * 1. å…ˆåˆ é™¤è¯¥å·¥è‰ºè·¯çº¿ä¸‹å·²æœ‰å·¥åº
     * 2. è¯»å– BOM ç»“构中的 operation_id
     * 3. æŒ‰ BOM å‡ºçŽ°é¡ºåºåŽ»é‡åŽé‡æ–°å†™å…¥å·¥è‰ºè·¯çº¿å·¥åºè¡¨
     * 1. å…ˆåˆ é™¤è¯¥å·¥è‰ºè·¯çº¿ä¸‹å·²æœ‰å·¥åºåŠå‚æ•°å¿«ç…§
     * 2. è¯»å–扁平 BOM æ˜Žç»†ä¸­çš„ operation_id
     * 3. æŒ‰ BOM ä¸­é¦–次出现的顺序对工序去重,每道工序生成一条路线工序
     * 4. æ¯æ¡è·¯çº¿å·¥åºçš„产出规格固定为工艺路线的成品规格
     */
    private void syncRoutingOperationsByBom(TechnologyRouting technologyRouting) {
        List<TechnologyRoutingOperation> oldRoutingOperationList = technologyRoutingOperationMapper.selectList(
@@ -127,65 +127,32 @@
                Wrappers.<TechnologyBomStructure>lambdaQuery()
                        .eq(TechnologyBomStructure::getBomId, technologyRouting.getBomId())
                        .isNotNull(TechnologyBomStructure::getOperationId)
                        .orderByDesc(TechnologyBomStructure::getId)
                        .orderByAsc(TechnologyBomStructure::getId)
        );
        if (bomStructures.isEmpty()) {
            throw new ServiceException("bom产品结构为空!");
        }
        // åŒä¸€ä¸ª BOM ä¸­å¯èƒ½é‡å¤å¼•用相同工序,按照上一层的父节点的产品是否相同和工序是否相同
        Map<Long, TechnologyBomStructure> structureById = new HashMap<>();
        // æ‰å¹³ BOM ä¸­å¤šä¸ªç‰©æ–™å¯ä»¥æŒ‡å‘同一道工序,这里只按工序去重
        Set<Long> uniqueOperationIdList = new LinkedHashSet<>();
        for (TechnologyBomStructure bomStructure : bomStructures) {
            if (bomStructure != null && bomStructure.getId() != null) {
                structureById.put(bomStructure.getId(), bomStructure);
            }
        }
        Map<String, TechnologyBomStructure> uniqueOperationMap = new LinkedHashMap<>();
        for (TechnologyBomStructure bomStructure : bomStructures) {
            Long outputProductModelId = resolveOutputProductModelId(bomStructure, structureById, technologyRouting.getProductModelId());
            uniqueOperationMap.putIfAbsent(buildBomOperationDedupKey(bomStructure, outputProductModelId), bomStructure);
            uniqueOperationIdList.add(bomStructure.getOperationId());
        }
        int dragSort = 1;
        for (TechnologyBomStructure bomStructure : uniqueOperationMap.values()) {
        for (Long operationId : uniqueOperationIdList) {
            TechnologyRoutingOperation routingOperation = new TechnologyRoutingOperation();
            routingOperation.setTechnologyRoutingId(technologyRouting.getId());
            routingOperation.setProductModelId(resolveOutputProductModelId(bomStructure, structureById, technologyRouting.getProductModelId()));
            routingOperation.setTechnologyOperationId(bomStructure.getOperationId());
            routingOperation.setProductModelId(technologyRouting.getProductModelId());
            routingOperation.setTechnologyOperationId(operationId);
            routingOperation.setDragSort(dragSort++);
            TechnologyOperation technologyOperation = getOperation(bomStructure.getOperationId());
            TechnologyOperation technologyOperation = getOperation(operationId);
            routingOperation.setIsQuality(technologyOperation != null ? technologyOperation.getIsQuality() : null);
            routingOperation.setIsProduction(technologyOperation != null ? technologyOperation.getIsProduction() : null);
            routingOperation.setType(technologyOperation != null ? technologyOperation.getType() : null);
            technologyRoutingOperationMapper.insert(routingOperation);
            syncRoutingOperationParams(routingOperation.getId(), bomStructure.getOperationId());
            syncRoutingOperationParams(routingOperation.getId(), operationId);
        }
    }
    private String buildBomOperationDedupKey(TechnologyBomStructure bomStructure, Long outputProductModelId) {
        Long operationId = bomStructure == null ? null : bomStructure.getOperationId();
        Long parentId = bomStructure == null ? null : bomStructure.getParentId();
        return operationId + "#"
                + outputProductModelId + "#"
                + parentId;
    }
    private Long resolveOutputProductModelId(TechnologyBomStructure bomStructure,
                                             Map<Long, TechnologyBomStructure> structureById,
                                             Long routingProductModelId) {
        if (bomStructure == null) {
            return routingProductModelId;
        }
        Long parentId = bomStructure.getParentId();
        if (parentId == null) {
            return routingProductModelId != null ? routingProductModelId : bomStructure.getProductModelId();
        }
        TechnologyBomStructure parent = structureById.get(parentId);
        if (parent != null && parent.getProductModelId() != null) {
            return parent.getProductModelId();
        }
        return routingProductModelId != null ? routingProductModelId : bomStructure.getProductModelId();
    }
    private void syncRoutingOperationParams(Long technologyRoutingOperationId, Long technologyOperationId) {
src/main/resources/mapper/production/ProductionBomStructureMapper.xml
@@ -44,7 +44,7 @@
                LEFT JOIN product p ON pm.product_id = p.id
                LEFT JOIN technology_operation top1 ON pbs.technology_operation_id = top1.id
        WHERE
            pbs.parent_id IS NOT NULL
            pbs.technology_operation_id IS NOT NULL
          AND pbs.production_order_bom_id = #{bomId}
        ORDER BY
            pbs.id
src/main/resources/mapper/production/ProductionOrderPickMapper.xml
@@ -11,6 +11,7 @@
        <result column="create_time" property="createTime" />
        <result column="update_time" property="updateTime" />
        <result column="production_order_id" property="productionOrderId" />
        <result column="production_operation_task_id" property="productionOperationTaskId" />
        <result column="remark" property="remark" />
        <result column="create_user" property="createUser" />
        <result column="dept_id" property="deptId" />
@@ -24,19 +25,55 @@
        <result column="is_bom" property="bom" />
    </resultMap>
    <select id="listPickedDetailByOrderId" resultType="com.ruoyi.production.bean.vo.ProductionOrderPickVo">
        select pop.*,
               pop.is_bom as bom,
               pop.is_returned as returned,
               pop.quantity as pickQuantity,
               p.product_name as productName,
               pm.model as model,
               pm.unit as unit
    <sql id="PickDetailColumns">
        pop.id as id,
        pop.product_model_id as productModelId,
        pop.quantity as quantity,
        pop.quantity as pickQuantity,
        pop.batch_no as batchNo,
        pop.create_time as createTime,
        pop.update_time as updateTime,
        pop.production_order_id as productionOrderId,
        pop.production_operation_task_id as productionOperationTaskId,
        pop.remark as remark,
        pop.technology_operation_id as technologyOperationId,
        pop.demanded_quantity as demandedQuantity,
        pop.feeding_qty as feedingQty,
        pop.return_qty as returnQty,
        pop.actual_qty as actualQty,
        pop.is_returned as returned,
        pop.is_bom as bom,
        coalesce(pop.operation_name, top1.name) as operationName,
        p.product_name as productName,
        pm.model as model,
        pm.unit as unit,
        pot.work_order_no as workOrderNo
    </sql>
    <sql id="PickDetailJoins">
        from production_order_pick pop
                 left join product_model pm on pop.product_model_id = pm.id
                 left join product p on pm.product_id = p.id
        left join product_model pm on pop.product_model_id = pm.id
        left join product p on pm.product_id = p.id
        left join technology_operation top1 on pop.technology_operation_id = top1.id
        left join production_operation_task pot on pop.production_operation_task_id = pot.id
    </sql>
    <!-- è®¢å•级领料汇总:包含该订单下所有工序的领料记录 -->
    <select id="listPickedDetailByOrderId" resultType="com.ruoyi.production.bean.vo.ProductionOrderPickVo">
        select
        <include refid="PickDetailColumns" />
        <include refid="PickDetailJoins" />
        where pop.production_order_id = #{productionOrderId}
        order by pop.create_time desc, pop.id desc
    </select>
    <!-- å·¥åºï¼ˆå·¥å•)级领料记录 -->
    <select id="listPickedDetailByTaskId" resultType="com.ruoyi.production.bean.vo.ProductionOrderPickVo">
        select
        <include refid="PickDetailColumns" />
        <include refid="PickDetailJoins" />
        where pop.production_operation_task_id = #{productionOperationTaskId}
        order by pop.create_time desc, pop.id desc
    </select>
</mapper>
src/main/resources/mapper/technology/TechnologyBomStructureMapper.xml
@@ -16,6 +16,10 @@
        <result column="dept_id" property="deptId" />
    </resultMap>
    <!--
        æ‰å¹³ BOM æ˜Žç»†ï¼šä¸€è¡Œ = ä¸€ä¸ªç‰©æ–™è§„æ ¼ + å…¶æ¶ˆè€—工序。
        åŽ†å²æ•°æ®ä¸­ BOM æˆå“æ ¹èŠ‚ç‚¹ï¼ˆæ— çˆ¶èŠ‚ç‚¹ã€æ— å·¥åºä¸”è§„æ ¼ç­‰äºŽ BOM æˆå“è§„格)不是物料,需要排除。
    -->
    <select id="listByBomId" resultType="com.ruoyi.technology.bean.vo.TechnologyBomStructureVo">
        select tbs.*,
               p.product_name as productName,
@@ -24,10 +28,14 @@
               top1.name as operationName,
               pm.product_code as productCode
        from technology_bom_structure tbs
        inner join technology_bom tb on tbs.bom_id = tb.id
        left join product_model pm on tbs.product_model_id = pm.id
        left join product p on pm.product_id = p.id
        left join technology_operation top1 on tbs.operation_id = top1.id
        where tbs.bom_id = #{bomId}
          and not (tbs.parent_id is null
                   and tbs.operation_id is null
                   and tbs.product_model_id = tb.product_model_id)
        order by tbs.id
    </select>
</mapper>