gaoluyang
5 天以前 a0b4de611e3b1986b2713c6a161cc1613ec31a5b
feat(mes): 采购入库简化为确认入库并自动生成入库单

- 新增 PUT /mes/wm/item-receipt/confirm:核对数量后一次完成批次补齐、入库明细、库存事务与单据完成
- 到货通知免检提交、IQC 全部需检行合格完成后自动生成采购入库单(待入库0),移除人工「执行入库」入口
- 确认入库校验状态、整单入库位置层级、行入库数量,并给出可执行的精确提示
- 同步业务可视化文档

Co-Authored-By: Claude Code <noreply@anthropic.com>
已修改14个文件
514 ■■■■■ 文件已修改
docs/project-business/data/flows.json 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/objects.json 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/index.html 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/03-purchase-flow.mmd 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/05-quality-flow.mmd 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/arrivalnotice/MesWmArrivalNoticeController.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/itemreceipt/MesWmItemReceiptController.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/arrivalnotice/MesWmArrivalNoticeService.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/arrivalnotice/MesWmArrivalNoticeServiceImpl.java 138 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/itemreceipt/MesWmItemReceiptLineService.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/itemreceipt/MesWmItemReceiptLineServiceImpl.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/itemreceipt/MesWmItemReceiptService.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/itemreceipt/MesWmItemReceiptServiceImpl.java 231 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/flows.json
@@ -67,11 +67,11 @@
      "name": "采购流程",
      "mermaid": "mermaid/03-purchase-flow.mmd",
      "color": "#5fc79e",
      "summary": "供应商档案(多联系人/多银行·含银行行号) → 询价比价(产品×供应商横向报价,按产品选中标) → 确认后按中标供应商拆采购订单草稿并走现有 BPM → 确认收货生成 MES 到货通知 → IQC(合格/特采入库·退货/报废) → 采购入库回写订单与申请 → 来票(AI OCR) → 付款单 BPM → 供应商往来对账。",
      "summary": "供应商档案(多联系人/多银行·含银行行号) → 采购申请(不记价格·不填供应商)审批通过后,采购员在询价比价页的待处理列表决定:发起询价比价确认价格,或选供应商直接生成零金额台账草稿 → 确认收货生成 MES 到货通知 → IQC(合格/特采入库·退货/报废) → 采购入库回写订单与申请 → 来票(AI OCR) → 付款单 BPM → 供应商往来对账。",
      "steps": [
        {
          "stage": "寻源",
          "action": "供应商档案维护多个联系人与多个银行账户(含银行行号);新增询价单时一并录入产品×参与供应商报价并选定每个产品的中标供应商,列表直接确认后按中标供应商生成采购订单草稿",
          "action": "供应商档案维护多个联系人与多个银行账户(含银行行号);询价单可按采购申请明细一键发起,也可独立新建;录入产品×参与供应商报价并选定每个产品的中标供应商,审核通过后确认转订单",
          "roles": [
            "采购员",
            "供应商管理员"
@@ -79,7 +79,15 @@
        },
        {
          "stage": "申请",
          "action": "采购申请(CGSQ)提交 BPM,驳回可重置回草稿;通过且有供应商自动生成订单",
          "action": "采购申请(CGSQ)只记产品/单位/数量/需求日期,不含任何价格字段,也不填供应商与是否质检;提交 BPM,驳回可重置回草稿;审批通过仅改状态,不自动生成台账",
          "roles": [
            "采购员",
            "审批人"
          ]
        },
        {
          "stage": "价格确认",
          "action": "审批通过后采购员在「询价比价」页的「采购申请待处理」页签(带数量角标,有待处理时默认展示)中二选一:①需要比价→发起询价,生成询价单草稿并回写申请;询价审核通过后确认转订单,按中标供应商拆分生成台账,价格取中标报价;②不需要比价→直接生成零金额、无供应商台账草稿,由采购员在台账补录供应商/单价/税率;台账提交审批时后端校验供应商、付款日期与每条明细单价,缺哪条就报哪条,补齐后才能提交。采购申请页本身不再提供这两个按钮。询价取消或删除会清空申请的询价关联,申请重新出现在待处理列表",
          "roles": [
            "采购员",
            "审批人"
@@ -94,7 +102,7 @@
        },
        {
          "stage": "收货",
          "action": "确认收货生成到货通知(0→待质检2/待入库3→4);需检物料自动创建 IQC",
          "action": "确认收货生成到货通知(0→待质检2/待入库3→4);需检物料由库管在到货通知点「执行质检」才生成 IQC,不点则不生成、也不能入库;无需检验的提交即自动生成采购入库单",
          "roles": [
            "仓管",
            "IQC 质检员"
@@ -102,7 +110,7 @@
        },
        {
          "stage": "入库",
          "action": "IQC 合格/特采→采购入库(待上架→待执行→完成)库存+,回写订单 inCount/inStatus 与申请明细",
          "action": "IQC 合格/特采→系统自动生成采购入库单(待入库0)→库管核对数量、选入库仓库/库区/库位后点「确认入库」一步完成(→完成4),库存+,回写订单 inCount/inStatus 与申请明细;不再有提交/执行上架/到货通知执行入库步骤",
          "roles": [
            "仓管"
          ]
docs/project-business/data/objects.json
@@ -199,8 +199,9 @@
      "name": "询价比价",
      "module": "供应商管理",
      "table": "srm_inquiry",
      "doc": "XJ编号,草稿0→已转订单20→已取消30(10询价中为历史状态);新增/编辑时一并录入产品行×参与供应商报价(srm_inquiry_quote)并选定每个产品的中标供应商,无需单独的报价/比价步骤,确认后按中标供应商拆分生成采购订单草稿并记录 srm_inquiry_order",
      "doc": "XJ编号,草稿0→询价中10→审核中40→审核通过50→已转订单20/已取消30/审核未通过60;可由采购申请明细一键发起(记录 purchaseRequestId/purchaseRequestNo 并回写申请 inquiryId/inquiryNo),也可独立新建;新增/编辑时一并录入产品行×参与供应商报价(srm_inquiry_quote)并选定每个产品的中标供应商,审核通过后确认转订单,按中标供应商拆分生成采购台账草稿并记录 srm_inquiry_order;取消或删除会清空采购申请的询价关联",
      "upstream": [
        "采购申请(可选)",
        "供应商",
        "主数据(物料)"
      ],
@@ -227,12 +228,13 @@
      "name": "采购申请",
      "module": "进销存",
      "table": "erp_purchase_request",
      "doc": "CGSQ编号,BPM 审批,通过后有供应商自动生成订单",
      "doc": "CGSQ编号,只记产品/单位/数量/需求日期,表头与明细均不含价格字段,不填供应商、不填是否质检(质检在台账环节确定);BPM 审批,通过后仅改状态,采购员到「询价比价」页的「采购申请待处理」中决定「发起询价」或「直接生成台账」:发起询价时回写 inquiryId/inquiryNo,询价确认后生成台账并回写 orderId/orderNo;直接生成时为零金额台账草稿,价格由采购员补录",
      "upstream": [
        "生产缺口(人工)"
      ],
      "downstream": [
        "采购订单"
        "询价比价(可选)",
        "采购台账"
      ],
      "keywords": "采购申请 请购 request"
    },
@@ -240,7 +242,7 @@
      "name": "采购订单",
      "module": "进销存",
      "table": "erp_purchase_order",
      "doc": "菜单名「采购台账」,CGDD编号,BPM 审批(草稿0→审批中15→已审核20),入库状态:未0部分1全部2;表头含采购类型(生产/办公用品/其他)、合同号(手工输入不自动生成)、项目名称、付款日期(必填,超30天账期标红)、付款方式(电汇/汇票/现金/手机支付);明细产品编号快照 MDM 物料编码;来源可为采购申请、询价比价确认生成的草稿或销售订单/生产工单(来源单号回溯);单价为含税单价,价税合计=单价×数量,税额从含税金额中反算(不再额外加税)",
      "doc": "菜单名「采购台账」,CGDD编号,BPM 审批(草稿0→审批中15→已审核20),入库状态:未0部分1全部2;表头含采购类型(生产/办公用品/其他)、合同号(手工输入不自动生成)、项目名称、付款日期(必填,超30天账期标红)、付款方式(电汇/汇票/现金/手机支付);明细产品编号快照 MDM 物料编码;来源可为采购申请、询价比价确认生成的草稿或销售订单/生产工单(来源单号回溯);单价为含税单价,价税合计=单价×数量,税额从含税金额中反算(不再额外加税);采购申请直接生成的草稿为零金额且无供应商,提交审批前必须补齐供应商、付款日期与明细单价(后端逐项校验并报具体缺口)",
      "upstream": [
        "采购申请",
        "询价比价(确认中标)"
@@ -497,7 +499,7 @@
      "name": "到货通知单",
      "module": "仓储",
      "table": "mes_wm_arrival_notice",
      "doc": "采购订单确认收货生成,草稿0→待质检2→待入库3→完成4;表头带采购合同号(选择采购订单自动带入,换货到货沿用原退货单合同号),可按合同号检索",
      "doc": "采购订单确认收货生成,草稿0→待质检2→待入库3→完成4;无需检验或 IQC 完成后自动生成采购入库单;表头带采购合同号(选择采购订单自动带入,换货到货沿用原退货单合同号),可按合同号检索",
      "upstream": [
        "采购订单",
        "供应商退货(换货)"
@@ -512,7 +514,7 @@
      "name": "采购入库单",
      "module": "仓储",
      "table": "mes_wm_item_receipt",
      "doc": "草稿0→待上架2→待执行3→完成4,完成后库存+并回写 ERP inCount/inStatus",
      "doc": "到货通知提交或 IQC 合格后自动生成(待入库0),库管核对数量并选择仓库/库区/库位后一步「确认入库」(完成4),完成后库存+并回写 ERP inCount/inStatus;旧流程的待上架2/待执行3 单据仍可确认入库",
      "upstream": [
        "到货通知"
      ],
docs/project-business/index.html
@@ -1544,7 +1544,7 @@
      "name": "到货通知单",
      "module": "仓储",
      "table": "mes_wm_arrival_notice",
      "doc": "采购订单确认收货生成,草稿0→待质检2→待入库3→完成4;表头带采购合同号(选择采购订单自动带入,换货到货沿用原退货单合同号),可按合同号检索",
      "doc": "采购订单确认收货生成,草稿0→待质检2→待入库3→完成4;无需检验或 IQC 完成后自动生成采购入库单;表头带采购合同号(选择采购订单自动带入,换货到货沿用原退货单合同号),可按合同号检索",
      "upstream": [
        "采购订单",
        "供应商退货(换货)"
@@ -1559,7 +1559,7 @@
      "name": "采购入库单",
      "module": "仓储",
      "table": "mes_wm_item_receipt",
      "doc": "草稿0→待上架2→待执行3→完成4,完成后库存+并回写 ERP inCount/inStatus",
      "doc": "到货通知提交或 IQC 合格后自动生成(待入库0),库管核对数量并选择仓库/库区/库位后一步「确认入库」(完成4),完成后库存+并回写 ERP inCount/inStatus;旧流程的待上架2/待执行3 单据仍可确认入库",
      "upstream": [
        "到货通知"
      ],
@@ -2032,7 +2032,7 @@
        },
        {
          "stage": "收货",
          "action": "确认收货生成到货通知(0→待质检2/待入库3→4);需检物料自动创建 IQC",
          "action": "确认收货生成到货通知(0→待质检2/待入库3→4);需检物料由库管在到货通知点「执行质检」才生成 IQC,不点则不生成、也不能入库;无需检验的提交即自动生成采购入库单",
          "roles": [
            "仓管",
            "IQC 质检员"
@@ -2040,7 +2040,7 @@
        },
        {
          "stage": "入库",
          "action": "IQC 合格/特采→采购入库(待上架→待执行→完成)库存+,回写订单 inCount/inStatus 与申请明细",
          "action": "IQC 合格/特采→系统自动生成采购入库单(待入库0)→库管核对数量、选入库仓库/库区/库位后点「确认入库」一步完成(→完成4),库存+,回写订单 inCount/inStatus 与申请明细;不再有提交/执行上架/到货通知执行入库步骤",
          "roles": [
            "仓管"
          ]
@@ -2855,10 +2855,12 @@
  C4 --> C3
  C3 -->|确认收货| C5[到货通知单·草稿0→待质检2→待入库3→完成4]
  C5 -->|物料需检验·自动创建| C6[IQC 来料检验]
  C6 -->|合格1或特采2 可入库| C7[采购入库单·草稿0→待上架2→待执行3→完成4]
  C5 -.->|无需检验·提交即自动生成入库单| C7
  C6 -->|合格1或特采2·全部需检行完成后自动生成入库单| C7[采购入库单·待入库0→完成4·库管核对数量后一步确认入库]
  C6 -->|不合格退货3| C8[供应商退货·草稿→审批中(BPM 采购→质量→仓库)→待拣货→待执行→完成·库存扣减;退款→负数冲红付款单;换货→到货通知+IQC 重新入库]
  C6 -->|不合格报废4| C9[NCR 不合格品处置]
  C7 -->|回写 inCount 与 inStatus 未入库0部分1全部2| C3
  C7 -.->|确认入库时选整单入库仓库/库区/库位·自动按行生成入库明细与批次·不再执行上架| C7NOTE[库存事务·库存+·到货通知完成]
  C7 -->|回写申请明细 inCount| C1
  C3 -->|订单总价控制| C10[采购来票 FPD·累计金额≤订单总价]
  C10 -->|付款单必须关联来票| C11[付款单 FKD·审批中15→已审核20·累计付款≤来票金额]
docs/project-business/mermaid/03-purchase-flow.mmd
@@ -1,21 +1,32 @@
flowchart TD
  S1[供应商档案 srm_supplier·多联系人/多银行含银行行号] --> S2[询价比价 srm_inquiry·草稿0→已转订单20]
  S2 -->|新增时一并录入产品×供应商报价并选定中标·列表直接确认| S3[按中标供应商拆分生成采购订单草稿]
  S3 -->|提交| C3
  C1[采购申请 CGSQ·草稿0→审批中10] -->|提交BPM| C2{审批}
  S1[供应商档案 srm_supplier·多联系人/多银行含银行行号] --> S2[询价比价 srm_inquiry·草稿0→询价中10→审核中40→审核通过50→已转订单20/已取消30]
  C1[采购申请 CGSQ·只记产品/数量/需求日期·不记价格·不填供应商·不填是否质检·草稿0→审批中10] -->|提交BPM| C2{审批}
  C2 -->|驳回30→可重置回草稿| C1
  C2 -->|通过20且有供应商·自动生成| C3[采购台账 CGDD·采购类型/合同号/项目/付款日期/付款方式·审批中15→已审核20]
  C2 -->|通过20无供应商| C4[手动指定供应商生成订单]
  C2 -->|通过20·仅改状态不生成台账| C2A{采购员在“询价比价”页的“采购申请待处理”页签(带角标·有待处理时默认展示)中决定}
  C2A -->|需要询价比价·发起询价| S2
  S2 -->|按申请明细生成询价单草稿·回写申请 inquiryId/inquiryNo| S2
  S2 -->|补充供应商×产品报价并选定中标·提交审核| S2B{询价审核}
  S2B -->|通过50·确认转订单| C3
  S2B -->|驳回60→可改后重提| S2
  S2 -->|取消/删除·清空申请询价关联| C2A
  C2A -->|不需要比价·生成台账| C4[直接生成零金额·无供应商台账草稿]
  C4 --> C3
  C3[采购台账 CGDD·价格来自中标报价或采购员补录·采购类型/合同号/项目/付款日期/付款方式·草稿0→审批中15→已审核20]
  C3 -->|确认收货| C5[到货通知单·草稿0→待质检2→待入库3→完成4]
  C5 -->|物料需检验·自动创建| C6[IQC 来料检验]
  C6 -->|合格1或特采2 可入库| C7[采购入库单·草稿0→待上架2→待执行3→完成4]
  C5 -->|物料需检验·库管点「执行质检」才创建| C6[IQC 来料检验]
  C5 -.->|不点执行质检则不生成 IQC·单据停在待质检·不能入库| C6
  C5 -.->|无需检验·提交即自动生成入库单| C7
  C6 -->|合格1或特采2·全部需检行完成后自动生成入库单| C7[采购入库单·待入库0→完成4·库管核对数量后一步确认入库]
  C6 -->|不合格退货3| C8[供应商退货·草稿→审批中(BPM 采购→质量→仓库)→待拣货→待执行→完成·库存扣减;退款→负数冲红付款单;换货→到货通知+IQC 重新入库]
  C6 -->|不合格报废4| C9[NCR 不合格品处置]
  C7 -->|回写 inCount 与 inStatus 未入库0部分1全部2| C3
  C7 -.->|确认入库时选整单入库仓库/库区/库位·自动按行生成入库明细与批次·不再执行上架| C7NOTE[库存事务·库存+·到货通知完成]
  C7 -->|回写申请明细 inCount| C1
  C3 -->|订单总价控制| C10[采购来票 FPD·累计金额≤订单总价]
  C10 -->|付款单必须关联来票| C11[付款单 FKD·审批中15→已审核20·累计付款≤来票金额]
  C3 --> S6[供应商往来·订单流水含已开票/未开票]
  C10 --> S6
  C11 --> S6
  C2 -.->|任务分配到审批人即发站内信·铃铛点击直达待办任务| N1[BPM 待办通知 bpm_task_assigned·审批中心·所有流程通用·任务自动通过/已结束不发]
  S2B -.->|同上| N1
  C3 -.->|同上| N1
docs/project-business/mermaid/05-quality-flow.mmd
@@ -1,5 +1,6 @@
flowchart TD
  T1[到货通知单 或 外协入库单] --> A1[IQC 来料检验·草稿0→完成4]
  T1 -.->|到货通知:库管点「执行质检」才生成 IQC·不点不生成也不能入库| A1
  T2[报工后产出行·由质量部门手工新建 IPQC] --> A2[IPQC 过程检验·草稿0→完成4]
  T2 -.->|新建时绑定生产工单·工序由工单工艺路线带出·半成品由该工序产出物料带出·盘号手工录入| A2
  A2 -.->|新建/编辑·选工位按工序带出该工序维护的自定义工资字段·数值/文本/下拉/布尔控件按字段类型决定| QWF[工资字段填写·由质检填写·报工审批重算工资优先取此值·未填回退工序固定值]
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/arrivalnotice/MesWmArrivalNoticeController.java
@@ -114,6 +114,14 @@
        return success(true);
    }
    @PostMapping("/create-iqc")
    @Operation(summary = "执行质检:为到货通知单的需检行生成来料检验单")
    @Parameter(name = "id", description = "到货通知单编号", required = true)
    @PreAuthorize("@ss.hasPermission('mes:wm-arrival-notice:update')")
    public CommonResult<Integer> createArrivalNoticeIqc(@RequestParam("id") Long id) {
        return success(arrivalNoticeService.createIqcForArrivalNotice(id));
    }
    @PostMapping("/generate-from-purchase-order")
    @Operation(summary = "根据采购订单生成到货通知单")
    @Parameter(name = "purchaseOrderId", description = "采购订单编号", required = true)
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/itemreceipt/MesWmItemReceiptController.java
@@ -127,14 +127,30 @@
    }
    @PutMapping("/finish")
    @Operation(summary = "执行入库")
    @Operation(summary = "执行入库(旧流程,已由确认入库取代)")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('mes:wm-item-receipt:finish')")
    @Deprecated
    public CommonResult<Boolean> finishItemReceipt(@RequestParam("id") Long id) {
        itemReceiptService.finishItemReceipt(id);
        return success(true);
    }
    @PutMapping("/confirm")
    @Operation(summary = "确认入库:核对数量后一步完成入库")
    @Parameter(name = "id", description = "采购入库单编号", required = true)
    @Parameter(name = "warehouseId", description = "入库仓库编号", required = true)
    @Parameter(name = "locationId", description = "入库库区编号", required = true)
    @Parameter(name = "areaId", description = "入库库位编号", required = true)
    @PreAuthorize("@ss.hasPermission('mes:wm-item-receipt:finish')")
    public CommonResult<Boolean> confirmItemReceipt(@RequestParam("id") Long id,
                                                    @RequestParam(value = "warehouseId", required = false) Long warehouseId,
                                                    @RequestParam(value = "locationId", required = false) Long locationId,
                                                    @RequestParam(value = "areaId", required = false) Long areaId) {
        itemReceiptService.confirmItemReceipt(id, warehouseId, locationId, areaId);
        return success(true);
    }
    @PutMapping("/cancel")
    @Operation(summary = "取消采购入库单")
    @Parameter(name = "id", description = "编号", required = true)
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java
@@ -590,6 +590,9 @@
    ErrorCode WM_ARRIVAL_NOTICE_NO_LINE = new ErrorCode(1_040_704_006, "至少需要一条行项目");
    ErrorCode WM_ARRIVAL_NOTICE_VENDOR_MISMATCH = new ErrorCode(1_040_704_007, "到货通知单的供应商与当前单据不一致");
    ErrorCode WM_ARRIVAL_NOTICE_ALREADY_EXISTS = new ErrorCode(1_040_704_008, "该采购订单已生成到货通知单,不能重复生成");
    ErrorCode WM_ARRIVAL_NOTICE_NO_IQC_LINE = new ErrorCode(1_040_704_009, "到货通知单没有需要检验的明细行,无需生成来料检验单");
    ErrorCode WM_ARRIVAL_NOTICE_IQC_ALREADY_CREATED = new ErrorCode(1_040_704_010, "到货通知单的需检行已全部生成来料检验单");
    ErrorCode WM_ARRIVAL_NOTICE_IQC_CREATE_FAILED = new ErrorCode(1_040_704_011, "生成来料检验单失败");
    ErrorCode WM_ARRIVAL_NOTICE_LINE_NOT_EXISTS = new ErrorCode(1_040_704_100, "到货通知单行不存在");
    ErrorCode WM_ARRIVAL_NOTICE_LINE_NOT_MATCH = new ErrorCode(1_040_704_101, "到货通知单行不属于指定的到货通知单");
@@ -613,6 +616,12 @@
    ErrorCode WM_ITEM_RECEIPT_DETAIL_NOT_EXISTS = new ErrorCode(1_040_705_200, "采购入库明细不存在");
    ErrorCode WM_ITEM_RECEIPT_DETAIL_QUANTITY_EXCEED = new ErrorCode(1_040_705_202, "上架明细总数量不能超过行入库数量");
    ErrorCode WM_ITEM_RECEIPT_BATCH_REQUIRED = new ErrorCode(1_040_705_203, "物料【{}-{}】已开启批次管理,上架明细的批次号不能为空");
    ErrorCode WM_ITEM_RECEIPT_CONFIRM_STATUS_ERROR = new ErrorCode(1_040_705_009, "采购入库单当前状态不允许确认入库");
    ErrorCode WM_ITEM_RECEIPT_CONFIRM_LOCATION_REQUIRED = new ErrorCode(1_040_705_010, "确认入库必须选择入库仓库、库区和库位");
    ErrorCode WM_ITEM_RECEIPT_CONFIRM_LOCATION_MISMATCH = new ErrorCode(1_040_705_011, "入库仓库、库区、库位的层级不匹配");
    ErrorCode WM_ITEM_RECEIPT_CONFIRM_QUANTITY_INVALID = new ErrorCode(1_040_705_013, "入库数量必须大于 0");
    ErrorCode WM_ITEM_RECEIPT_CONFIRM_BATCH_GENERATE_FAILED = new ErrorCode(1_040_705_014, "生成物料批次失败");
    ErrorCode WM_ITEM_RECEIPT_CONFIRM_DETAIL_MISSING = new ErrorCode(1_040_705_015, "旧流程的待执行入库单缺少上架明细");
    // ========== MES 仓库管理-领料申请单(1-040-706-000) ==========
    ErrorCode WM_MATERIAL_REQUEST_NOT_EXISTS = new ErrorCode(1_040_706_000, "领料申请单不存在");
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/arrivalnotice/MesWmArrivalNoticeService.java
@@ -64,6 +64,14 @@
    void submitArrivalNotice(Long id);
    /**
     * 执行质检:为待质检的到货通知单的需检行生成来料检验单(IQC)
     *
     * @param id 到货通知单编号
     * @return 本次新建的来料检验单数量
     */
    Integer createIqcForArrivalNotice(Long id);
    /**
     * IQC 检验完成后回写到货通知单(更新行 + 尝试推进主表状态)
     *
     * @param id 到货通知单编号
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/arrivalnotice/MesWmArrivalNoticeServiceImpl.java
@@ -2,6 +2,7 @@
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjUtil;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
@@ -22,6 +23,7 @@
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
import cn.iocoder.yudao.module.mes.service.qc.iqc.MesQcIqcService;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcCheckResultEnum;
import cn.iocoder.yudao.module.mes.service.wm.itemreceipt.MesWmItemReceiptService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
@@ -30,6 +32,7 @@
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
@@ -73,6 +76,10 @@
    @Resource
    @Lazy
    private MesQcIqcService iqcService;
    @Resource
    @Lazy
    private MesWmItemReceiptService itemReceiptService;
    @Override
    public Long createArrivalNotice(MesWmArrivalNoticeSaveReqVO createReqVO) {
@@ -161,34 +168,72 @@
        }
        // 2. 检查所有行的 iqcCheckFlag:如果有需要检验的行,进入待质检状态
        // 注意:来料检验单不在提交时生成,由库管点击「执行质检」时通过 createIqcForArrivalNotice 生成
        boolean needCheck = CollectionUtils.anyMatch(lines,
                line -> Boolean.TRUE.equals(line.getIqcCheckFlag()));
        if (needCheck) {
            // 2.1 需要检验,进入待质检
            arrivalNoticeMapper.updateById(new MesWmArrivalNoticeDO()
                    .setId(id).setStatus(MesWmArrivalNoticeStatusEnum.PENDING_QC.getStatus()));
            // 2.2 为需要质检的行自动创建 IQC 检验单
            for (MesWmArrivalNoticeLineDO line : lines) {
                if (Boolean.TRUE.equals(line.getIqcCheckFlag()) && line.getIqcId() == null) {
                    try {
                        Long iqcId = iqcService.createIqcFromArrivalNotice(
                                id, line, notice.getCode(), notice.getVendorId());
                        // 更新行上的 iqcId
                        arrivalNoticeLineService.updateArrivalNoticeLineIqcId(line.getId(), iqcId);
                        log.info("[submitArrivalNotice][到货通知单({}) 行({}) 自动创建 IQC 检验单({})]",
                                id, line.getId(), iqcId);
                    } catch (Exception e) {
                        log.warn("[submitArrivalNotice][到货通知单({}) 行({}) 创建 IQC 失败: {}]",
                                id, line.getId(), e.getMessage());
                        // 继续处理其他行,不中断流程
                    }
                }
            }
            return;
        }
        // 不需要检验,直接进入待入库
        // 不需要检验,直接进入待入库,并自动生成采购入库单交给库管确认入库
        arrivalNoticeMapper.updateById(new MesWmArrivalNoticeDO()
                .setId(id).setStatus(MesWmArrivalNoticeStatusEnum.PENDING_RECEIPT.getStatus()));
        autoGenerateItemReceipt(notice);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Integer createIqcForArrivalNotice(Long id) {
        // 1. 校验到货通知单存在且为待质检状态
        MesWmArrivalNoticeDO notice = validateArrivalNoticeExists(id);
        if (ObjUtil.notEqual(MesWmArrivalNoticeStatusEnum.PENDING_QC.getStatus(), notice.getStatus())) {
            String statusName = Arrays.stream(MesWmArrivalNoticeStatusEnum.values())
                    .filter(item -> ObjUtil.equal(item.getStatus(), notice.getStatus()))
                    .map(MesWmArrivalNoticeStatusEnum::getName).findFirst()
                    .orElse(String.valueOf(notice.getStatus()));
            throw new ServiceException(WM_ARRIVAL_NOTICE_STATUS_NOT_PENDING_QC.getCode(),
                    "到货通知单[" + notice.getCode() + "]当前状态为[" + statusName + "],只有「待质检」状态才能执行质检。"
                            + "请刷新列表确认单据状态;若该单据已执行过质检,请直接到【质量管理 - IQC检验】录入检测结果。");
        }
        // 2. 校验需检行
        List<MesWmArrivalNoticeLineDO> lines = arrivalNoticeLineService.getArrivalNoticeLineListByNoticeId(id);
        List<MesWmArrivalNoticeLineDO> needCheckLines = lines.stream()
                .filter(line -> Boolean.TRUE.equals(line.getIqcCheckFlag()))
                .toList();
        if (needCheckLines.isEmpty()) {
            throw new ServiceException(WM_ARRIVAL_NOTICE_NO_IQC_LINE.getCode(),
                    "到货通知单[" + notice.getCode() + "]的明细行均为「不需检验」,无需生成来料检验单。"
                            + "请直接到该单据执行入库;若本应检验,请核对采购订单行的检验标识(该标识由采购订单带入,到货通知单上不可修改)。");
        }
        List<MesWmArrivalNoticeLineDO> pendingLines = needCheckLines.stream()
                .filter(line -> line.getIqcId() == null)
                .toList();
        if (pendingLines.isEmpty()) {
            throw new ServiceException(WM_ARRIVAL_NOTICE_IQC_ALREADY_CREATED.getCode(),
                    "到货通知单[" + notice.getCode() + "]的 " + needCheckLines.size()
                            + " 条需检行均已生成来料检验单,无需重复生成。请直接到【质量管理 - IQC检验】录入检测结果;"
                            + "如需重新生成,请先删除原检验单。");
        }
        // 3. 逐行生成来料检验单
        int createdCount = 0;
        for (MesWmArrivalNoticeLineDO line : pendingLines) {
            Long iqcId;
            try {
                iqcId = iqcService.createIqcFromArrivalNotice(id, line, notice.getCode(), notice.getVendorId());
            } catch (Exception ex) {
                log.error("[createIqcForArrivalNotice][到货通知单({}) 行({}) 创建 IQC 失败]", id, line.getId(), ex);
                throw new ServiceException(WM_ARRIVAL_NOTICE_IQC_CREATE_FAILED.getCode(),
                        "到货通知单[" + notice.getCode() + "]的明细行(物料ID=" + line.getItemId()
                                + ")生成来料检验单失败:" + ex.getMessage()
                                + "。请稍后重试;若持续失败,请到【质量管理 - IQC检验】手工新建检验单。");
            }
            arrivalNoticeLineService.updateArrivalNoticeLineIqcId(line.getId(), iqcId);
            createdCount++;
            log.info("[createIqcForArrivalNotice][到货通知单({}) 行({}) 生成 IQC 检验单({})]",
                    id, line.getId(), iqcId);
        }
        return createdCount;
    }
    @Override
@@ -206,6 +251,47 @@
        // 同时认可仓库已确认的来料退货;保留原始不合格结论。
        arrivalDispositionService.refresh(id);
        // 3.1 判断是否所有需检行都已完成质检
        List<MesWmArrivalNoticeLineDO> lines = arrivalNoticeLineService.getArrivalNoticeLineListByNoticeId(id);
        boolean hasUnchecked = CollectionUtils.anyMatch(lines,
                line -> Boolean.TRUE.equals(line.getIqcCheckFlag()) && line.getIqcCheckResult() == null);
        if (hasUnchecked) {
            log.info("[updateArrivalNoticeWhenIqcFinish][到货通知单({}) 还有未完成检验的行,暂不更新状态]", id);
            return;
        }
        // 3.2 判断是否所有需检行都可以入库(合格或特采)
        boolean hasNonInbound = CollectionUtils.anyMatch(lines,
                line -> Boolean.TRUE.equals(line.getIqcCheckFlag())
                        && !MesQcCheckResultEnum.canInbound(line.getIqcCheckResult()));
        if (hasNonInbound) {
            log.info("[updateArrivalNoticeWhenIqcFinish][到货通知单({}) 存在不可入库的行(退货/报废),状态保持待质检]", id);
            return;
        }
        // 3.3 所有行检验完成且可入库,更新主表状态 PENDING_QC → PENDING_RECEIPT
        arrivalNoticeMapper.updateById(new MesWmArrivalNoticeDO()
                .setId(id).setStatus(MesWmArrivalNoticeStatusEnum.PENDING_RECEIPT.getStatus()));
        // 3.4 质检合格后直接生成采购入库单(待入库),库管只需核对数量并确认入库
        autoGenerateItemReceipt(notice);
    }
    /**
     * 到货通知单进入「待入库」后自动生成采购入库单。
     * 库管无需再回到到货通知点「执行入库」,直接到【采购入库】核对数量并确认入库即可。
     */
    private void autoGenerateItemReceipt(MesWmArrivalNoticeDO notice) {
        try {
            Long receiptId = itemReceiptService.generateItemReceiptFromArrivalNotice(notice.getId());
            log.info("[autoGenerateItemReceipt][到货通知单({}) 已自动生成采购入库单({})]",
                    notice.getId(), receiptId);
        } catch (ServiceException ex) {
            log.error("[autoGenerateItemReceipt][到货通知单({}) 自动生成采购入库单失败]",
                    notice.getId(), ex);
            throw new ServiceException(ex.getCode(),
                    "到货通知单[" + notice.getCode() + "]转入待入库后自动生成采购入库单失败:" + ex.getMessage()
                            + "。本次操作未生效,请稍后重试;若持续失败请联系管理员。");
        }
    }
    @Override
@@ -362,21 +448,11 @@
            arrivalNoticeLineService.createArrivalNoticeLineFromPurchaseOrderItem(line);
        }
        // 7. 如果有需要质检的行,更新状态为待质检并自动创建 IQC 单
        // 7. 如果有需要质检的行,更新状态为待质检
        // 注意:来料检验单不在生成时创建,由库管点击「执行质检」时通过 createIqcForArrivalNotice 生成
        if (hasNeedQc) {
            arrivalNoticeMapper.updateById(new MesWmArrivalNoticeDO()
                    .setId(notice.getId()).setStatus(MesWmArrivalNoticeStatusEnum.PENDING_QC.getStatus()));
            // 为需要质检的行自动创建 IQC 检验单
            List<MesWmArrivalNoticeLineDO> lines = arrivalNoticeLineService.getArrivalNoticeLineListByNoticeId(notice.getId());
            for (MesWmArrivalNoticeLineDO line : lines) {
                if (Boolean.TRUE.equals(line.getIqcCheckFlag())) {
                    Long iqcId = iqcService.createIqcFromArrivalNotice(
                            notice.getId(), line, notice.getCode(), notice.getVendorId());
                    arrivalNoticeLineService.updateArrivalNoticeLineIqcId(line.getId(), iqcId);
                    log.info("[generateArrivalNoticeFromPurchaseOrder][到货通知单({}) 行({}) 自动创建 IQC 检验单({})]",
                            notice.getId(), line.getId(), iqcId);
                }
            }
        }
        return notice.getId();
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/itemreceipt/MesWmItemReceiptLineService.java
@@ -67,6 +67,15 @@
    void deleteItemReceiptLineByReceiptId(Long receiptId);
    /**
     * 回写入库单行的批次(确认入库时自动生成批次后调用,不校验父单据状态)
     *
     * @param id        行编号
     * @param batchId   批次编号
     * @param batchCode 批次编码
     */
    void updateItemReceiptLineBatch(Long id, Long batchId, String batchCode);
    /**
     * 根据到货通知单行创建入库单行(内部方法,不校验)
     *
     * @param line 入库单行
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/itemreceipt/MesWmItemReceiptLineServiceImpl.java
@@ -134,6 +134,13 @@
    }
    @Override
    public void updateItemReceiptLineBatch(Long id, Long batchId, String batchCode) {
        // 内部方法:确认入库时自动生成批次后回写,不做父单据状态校验
        itemReceiptLineMapper.updateById(new MesWmItemReceiptLineDO()
                .setId(id).setBatchId(batchId).setBatchCode(batchCode));
    }
    @Override
    public void createItemReceiptLineFromArrivalNoticeLine(MesWmItemReceiptLineDO line) {
        // 内部方法,不做校验,直接插入
        itemReceiptLineMapper.insert(line);
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/itemreceipt/MesWmItemReceiptService.java
@@ -73,9 +73,23 @@
     *
     * @param id 编号
     */
    @Deprecated
    void finishItemReceipt(Long id);
    /**
     * 确认入库(简化流程):核对数量后一步完成入库
     *
     * 允许从草稿、旧流程的待上架(非审批中)、待执行入库 直接完成:
     * 逐行补齐批次与上架明细,按所选仓库/库区/库位生成库存事务,并把单据置为已完成。
     *
     * @param id          采购入库单编号
     * @param warehouseId 入库仓库编号
     * @param locationId  入库库区编号
     * @param areaId      入库库位编号
     */
    void confirmItemReceipt(Long id, Long warehouseId, Long locationId, Long areaId);
    /**
     * 取消采购入库单(任意非已完成/已取消状态 → 已取消)
     *
     * @param id 编号
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/itemreceipt/MesWmItemReceiptServiceImpl.java
@@ -4,17 +4,28 @@
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
import cn.iocoder.yudao.module.mes.controller.admin.wm.batch.vo.MesWmBatchGenerateReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.itemreceipt.vo.MesWmItemReceiptPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.itemreceipt.vo.MesWmItemReceiptSaveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.itemreceipt.vo.detail.MesWmItemReceiptDetailSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.arrivalnotice.MesWmArrivalNoticeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.batch.MesWmBatchDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.itemreceipt.MesWmItemReceiptDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.itemreceipt.MesWmItemReceiptDetailDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.itemreceipt.MesWmItemReceiptLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseAreaDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseLocationDO;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.itemreceipt.MesWmItemReceiptMapper;
import cn.iocoder.yudao.module.mes.service.wm.batch.MesWmBatchService;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseAreaService;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseLocationService;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseService;
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmItemReceiptStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmTransactionTypeEnum;
@@ -42,6 +53,7 @@
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Map;
@@ -94,6 +106,15 @@
    @Resource
    private MdmItemApi mdmItemApi;
    @Resource
    private MesWmWarehouseService warehouseService;
    @Resource
    private MesWmWarehouseLocationService warehouseLocationService;
    @Resource
    private MesWmWarehouseAreaService warehouseAreaService;
    @Resource
    private MesWmBatchService batchService;
    @Override
    public Long createItemReceipt(MesWmItemReceiptSaveReqVO createReqVO) {
@@ -229,6 +250,7 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    @Deprecated
    public void finishItemReceipt(Long id) {
        // 1. 校验存在
        MesWmItemReceiptDO receipt = validateItemReceiptExists(id);
@@ -236,23 +258,222 @@
            throw exception(WM_ITEM_RECEIPT_STATUS_ERROR);
        }
        // 2. 遍历所有明细,创建库存事务(增加库存 + 记录流水)
        // 2. 完成入库(库存事务 + 状态 + 到货通知 + 采购订单回写)
        completeItemReceipt(receipt);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void confirmItemReceipt(Long id, Long warehouseId, Long locationId, Long areaId) {
        // 1. 校验单据存在
        MesWmItemReceiptDO receipt = validateItemReceiptExists(id);
        // 2. 校验状态:草稿、旧流程的待上架(且不在 BPM 审批中)、待执行入库 均允许确认
        validateItemReceiptConfirmable(receipt);
        // 3. 校验行
        List<MesWmItemReceiptLineDO> lines = itemReceiptLineService.getItemReceiptLineListByReceiptId(id);
        if (CollUtil.isEmpty(lines)) {
            throw exception(WM_ITEM_RECEIPT_NO_LINE);
        }
        // 4. 校验入库位置(仓库 / 库区 / 库位)的层级关系
        validateConfirmLocation(receipt.getCode(), warehouseId, locationId, areaId);
        // 5. 逐行校验入库数量,并为启用批次管理但还没批次的物料补齐批次
        Map<Long, MdmItemRespDTO> itemMap = mdmItemApi.getItemMap(
                convertList(lines, MesWmItemReceiptLineDO::getItemId));
        for (MesWmItemReceiptLineDO line : lines) {
            validateConfirmLineQuantity(receipt.getCode(), line, itemMap);
            fillLineBatch(receipt, line, itemMap);
        }
        // 6. 补齐上架明细:没有明细的行按“行入库数量 + 所选位置”生成一条;已有明细的保持原样
        createDetailsForConfirm(receipt, lines, warehouseId, locationId, areaId);
        // 7. 完成入库(库存事务 + 状态 + 到货通知 + 采购订单回写)
        completeItemReceipt(receipt);
    }
    /**
     * 完成入库:创建库存事务、更新单据与关联单据状态
     */
    private void completeItemReceipt(MesWmItemReceiptDO receipt) {
        // 1. 遍历所有明细,创建库存事务(增加库存 + 记录流水)
        createTransactionList(receipt);
        // 3. 更新入库单状态
        // 2. 更新入库单状态
        itemReceiptMapper.updateById(new MesWmItemReceiptDO()
                .setId(id).setStatus(MesWmItemReceiptStatusEnum.FINISHED.getStatus()));
                .setId(receipt.getId()).setStatus(MesWmItemReceiptStatusEnum.FINISHED.getStatus()));
        // 4. 更新关联的到货通知单状态
        // 3. 更新关联的到货通知单状态
        if (receipt.getNoticeId() != null) {
            arrivalNoticeService.finishArrivalNotice(receipt.getNoticeId());
        }
        // 5. 回写 ERP 采购订单的入库数量
        // 4. 回写 ERP 采购订单的入库数量
        updateErpPurchaseOrderInCount(receipt);
    }
    /**
     * 校验采购入库单是否允许确认入库
     *
     * 草稿(新流程)、旧流程的待上架(无 BPM 审批)、待执行入库(旧流程已上架)都允许;
     * 审批中的单据必须等审批结束,已完成/已取消的不允许。
     */
    private void validateItemReceiptConfirmable(MesWmItemReceiptDO receipt) {
        MesWmItemReceiptStatusEnum status = Arrays.stream(MesWmItemReceiptStatusEnum.values())
                .filter(item -> ObjUtil.equal(item.getStatus(), receipt.getStatus()))
                .findFirst().orElse(null);
        String statusName = status != null ? status.getName() : String.valueOf(receipt.getStatus());
        // 1. BPM 审批中的单据不允许直接入库,否则会绕过审批
        if (ObjUtil.equal(MesWmItemReceiptStatusEnum.APPROVING.getStatus(), receipt.getStatus())
                && StrUtil.isNotBlank(receipt.getProcessInstanceId())) {
            throw new ServiceException(WM_ITEM_RECEIPT_CONFIRM_STATUS_ERROR.getCode(),
                    "采购入库单[" + receipt.getCode() + "]正在审批中(当前状态:" + statusName + "),不能确认入库。"
                            + "请等待审批通过后再确认入库;若审批已驳回,请按审批意见修改后重新提交。");
        }
        // 2. 已完成 / 已取消不允许重复确认
        if (ObjUtil.equal(MesWmItemReceiptStatusEnum.FINISHED.getStatus(), receipt.getStatus())
                || ObjUtil.equal(MesWmItemReceiptStatusEnum.CANCELED.getStatus(), receipt.getStatus())) {
            throw new ServiceException(WM_ITEM_RECEIPT_CONFIRM_STATUS_ERROR.getCode(),
                    "采购入库单[" + receipt.getCode() + "]当前状态为[" + statusName + "],不能确认入库。"
                            + "只有草稿、待上架或待执行入库的单据可以确认入库,请刷新列表确认单据状态。");
        }
    }
    /**
     * 校验确认入库时选择的仓库 / 库区 / 库位
     */
    private void validateConfirmLocation(String receiptCode, Long warehouseId, Long locationId, Long areaId) {
        if (warehouseId == null || locationId == null || areaId == null) {
            throw new ServiceException(WM_ITEM_RECEIPT_CONFIRM_LOCATION_REQUIRED.getCode(),
                    "采购入库单[" + receiptCode + "]确认入库前必须选择入库仓库、库区和库位(当前"
                            + "仓库=" + (warehouseId != null ? warehouseId : "未选择")
                            + ",库区=" + (locationId != null ? locationId : "未选择")
                            + ",库位=" + (areaId != null ? areaId : "未选择")
                            + ")。请点击「确认入库」,在弹窗中依次选择仓库、库区、库位后重新提交。");
        }
        MesWmWarehouseDO warehouse = warehouseService.getWarehouse(warehouseId);
        if (warehouse == null) {
            throw new ServiceException(WM_ITEM_RECEIPT_CONFIRM_LOCATION_REQUIRED.getCode(),
                    "所选入库仓库(ID=" + warehouseId + ")不存在或已被删除,无法确认入库。"
                            + "请刷新页面后重新选择仓库、库区、库位。");
        }
        MesWmWarehouseLocationDO location = warehouseLocationService.getWarehouseLocation(locationId);
        if (location == null) {
            throw new ServiceException(WM_ITEM_RECEIPT_CONFIRM_LOCATION_REQUIRED.getCode(),
                    "所选入库库区(ID=" + locationId + ")不存在或已被删除,无法确认入库。"
                            + "请刷新页面后重新选择仓库、库区、库位。");
        }
        MesWmWarehouseAreaDO area = warehouseAreaService.getWarehouseArea(areaId);
        if (area == null) {
            throw new ServiceException(WM_ITEM_RECEIPT_CONFIRM_LOCATION_REQUIRED.getCode(),
                    "所选入库库位(ID=" + areaId + ")不存在或已被删除,无法确认入库。"
                            + "请刷新页面后重新选择仓库、库区、库位。");
        }
        // 仓库 → 库区 → 库位 的层级必须一致
        if (ObjUtil.notEqual(location.getWarehouseId(), warehouse.getId())) {
            MesWmWarehouseDO locationWarehouse = warehouseService.getWarehouse(location.getWarehouseId());
            throw new ServiceException(WM_ITEM_RECEIPT_CONFIRM_LOCATION_MISMATCH.getCode(),
                    "入库仓库与库区不匹配:所选仓库[" + warehouse.getName() + "],库区[" + location.getName()
                            + "]属于仓库[" + (locationWarehouse != null ? locationWarehouse.getName()
                            : "ID=" + location.getWarehouseId()) + "]。请重新选择同一仓库下的库区。");
        }
        if (ObjUtil.notEqual(area.getLocationId(), location.getId())) {
            MesWmWarehouseLocationDO areaLocation = warehouseLocationService.getWarehouseLocation(area.getLocationId());
            throw new ServiceException(WM_ITEM_RECEIPT_CONFIRM_LOCATION_MISMATCH.getCode(),
                    "入库库区与库位不匹配:所选库区[" + location.getName() + "],库位[" + area.getName()
                            + "]属于库区[" + (areaLocation != null ? areaLocation.getName()
                            : "ID=" + area.getLocationId()) + "]。请重新选择同一库区下的库位。");
        }
    }
    /**
     * 校验单行入库数量
     */
    private void validateConfirmLineQuantity(String receiptCode, MesWmItemReceiptLineDO line,
                                             Map<Long, MdmItemRespDTO> itemMap) {
        if (line.getReceivedQuantity() != null && line.getReceivedQuantity().compareTo(BigDecimal.ZERO) > 0) {
            return;
        }
        MdmItemRespDTO item = itemMap.get(line.getItemId());
        String itemText = item != null ? item.getCode() + " " + item.getName() : "ID=" + line.getItemId();
        throw new ServiceException(WM_ITEM_RECEIPT_CONFIRM_QUANTITY_INVALID.getCode(),
                "采购入库单[" + receiptCode + "]的物料行[" + itemText + "]入库数量为["
                        + (line.getReceivedQuantity() != null ? line.getReceivedQuantity().toPlainString() : "空")
                        + "],必须大于 0 才能入库。请编辑该物料行,核对并填写实际收货数量后重试。");
    }
    /**
     * 为启用批次管理但还没有批次的物料行补齐批次
     *
     * 由到货通知单生成的入库行不会预先生成批次,这里在确认入库时统一生成,
     * 避免库管逐行打开物料行保存一次。
     */
    private void fillLineBatch(MesWmItemReceiptDO receipt, MesWmItemReceiptLineDO line,
                               Map<Long, MdmItemRespDTO> itemMap) {
        MdmItemRespDTO item = itemMap.get(line.getItemId());
        if (item == null || !Boolean.TRUE.equals(item.getIsBatchManaged()) || line.getBatchId() != null) {
            return;
        }
        MesWmBatchGenerateReqVO batchReqVO = new MesWmBatchGenerateReqVO();
        batchReqVO.setItemId(line.getItemId()).setProduceDate(line.getProductionDate())
                .setExpireDate(line.getExpireDate()).setLotNumber(line.getLotNumber())
                .setVendorId(receipt.getVendorId()).setReceiptDate(receipt.getReceiptDate())
                .setPurchaseOrderCode(receipt.getPurchaseOrderCode());
        MesWmBatchDO batch;
        try {
            batch = batchService.getOrGenerateBatchCode(batchReqVO);
        } catch (ServiceException ex) {
            // 批次配置要求的字段(生产日期、有效期、生产批号等)没有填:把行上下文补进提示
            throw new ServiceException(WM_ITEM_RECEIPT_CONFIRM_BATCH_GENERATE_FAILED.getCode(),
                    "物料[" + item.getCode() + " " + item.getName() + "]生成批次失败:" + ex.getMessage()
                            + "。请编辑采购入库单里该物料的物料行,补充批次要求的字段(生产日期、有效期、生产批号)后重试。");
        }
        if (batch == null) {
            return;
        }
        line.setBatchId(batch.getId()).setBatchCode(batch.getCode());
        itemReceiptLineService.updateItemReceiptLineBatch(line.getId(), batch.getId(), batch.getCode());
    }
    /**
     * 确认入库时补齐上架明细:没有明细的行按行入库数量生成一条明细,已有明细的校验数量合计
     */
    private void createDetailsForConfirm(MesWmItemReceiptDO receipt, List<MesWmItemReceiptLineDO> lines,
                                         Long warehouseId, Long locationId, Long areaId) {
        for (MesWmItemReceiptLineDO line : lines) {
            List<MesWmItemReceiptDetailDO> details = itemReceiptDetailService
                    .getItemReceiptDetailListByLineId(line.getId());
            if (CollUtil.isEmpty(details)) {
                // 旧流程“待执行入库”的单据应当已经有上架明细,缺了说明数据不完整
                if (ObjUtil.equal(MesWmItemReceiptStatusEnum.APPROVED.getStatus(), receipt.getStatus())) {
                    throw new ServiceException(WM_ITEM_RECEIPT_CONFIRM_DETAIL_MISSING.getCode(),
                            "采购入库单[" + receipt.getCode() + "]处于旧流程的「待执行入库」状态,但物料行(ID="
                                    + line.getId() + ")没有上架明细,无法确认入库。"
                                    + "请将该单据取消后重新生成,或联系管理员补齐上架明细。");
                }
                MesWmItemReceiptDetailSaveReqVO detailReqVO = new MesWmItemReceiptDetailSaveReqVO();
                detailReqVO.setReceiptId(receipt.getId()).setLineId(line.getId())
                        .setItemId(line.getItemId()).setQuantity(line.getReceivedQuantity())
                        .setBatchId(line.getBatchId())
                        .setWarehouseId(warehouseId).setLocationId(locationId).setAreaId(areaId);
                itemReceiptDetailService.createItemReceiptDetail(detailReqVO);
                continue;
            }
            // 已有上架明细(旧流程):数量合计必须与行入库数量一致
            BigDecimal totalDetailQty = CollectionUtils.getSumValue(details,
                    MesWmItemReceiptDetailDO::getQuantity, BigDecimal::add, BigDecimal.ZERO);
            if (line.getReceivedQuantity() == null || totalDetailQty.compareTo(line.getReceivedQuantity()) != 0) {
                MdmItemRespDTO item = mdmItemApi.getItem(line.getItemId()).getCheckedData();
                String itemText = item != null ? item.getCode() + " " + item.getName() : "ID=" + line.getItemId();
                throw new ServiceException(WM_ITEM_RECEIPT_DETAIL_QUANTITY_MISMATCH.getCode(),
                        "采购入库单[" + receipt.getCode() + "]的物料行[" + itemText + "]上架明细数量合计为["
                                + totalDetailQty.toPlainString() + "],与行入库数量["
                                + (line.getReceivedQuantity() != null ? line.getReceivedQuantity().toPlainString() : "空")
                                + "]不一致,无法确认入库。请在物料行上核对入库数量,或取消该单据后按简化流程重新生成。");
            }
        }
    }
    /**
     * 回写 ERP 采购订单的入库数量
     */
    private void updateErpPurchaseOrderInCount(MesWmItemReceiptDO receipt) {