gaoluyang
3 天以前 25a40b9c4e6a1b650610b8b13088d486699532ce
采购申请批量提交、订单导入重复校验;修复考勤异常申请字段不匹配

- 采购订单导入:按采购单编号或订单指纹识别重复,新增 overwrite 参数与 duplicateList;
前端检测到重复弹框确认后才覆盖;覆盖前校验入库、退货、发票、采购申请、审批流程占用
- 采购申请:新增批量提交接口(逐张独立事务)与前端批量提交按钮,按 50 张分批调用;
审批通过自动生成订单前,优先复用已审核且未入库的同内容订单并回填关联,避免重复生成
- 考勤异常申请:前后端字段名对齐(exceptionDate/type),类型选项与后端枚举一致,
列表异常日期区间筛选生效;新增时自动带出当前员工与部门,无员工档案时给出明确提示
- 新增脚本:采购申请批量提交按钮权限、HRM 审批状态字典

Co-Authored-By: Claude Code <noreply@anthropic.com>
已添加5个文件
已修改16个文件
798 ■■■■■ 文件已修改
docs/project-business/data/flows.json 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/index.html 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/03-purchase-flow.mmd 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/purchase_order_duplicate_and_batch_submit_integration.md 123 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/erp_purchase_request_submit_menu.sql 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/hrm_audit_status_dict.sql 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchaseOrderController.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchaseRequestController.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/order/ErpPurchaseOrderImportDuplicateVO.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/order/ErpPurchaseOrderImportRespVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/request/ErpPurchaseRequestSubmitBatchRespVO.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseOrderMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErrorCodeConstants.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseOrderService.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseOrderServiceImpl.java 225 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseRequestService.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseRequestServiceImpl.java 217 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceExceptionPageReqVO.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/attendance/HrmAttendanceExceptionMapper.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceExceptionServiceImpl.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/flows.json
@@ -10,11 +10,11 @@
       {"stage": "交付", "action": "发货通知审核(占用)→ é”€å”®å‡ºåº“ OQC â†’ å‘运 â†’ å‡ºåº“回写部分/全部出库", "roles": ["仓管", "质检"]},
       {"stage": "回款", "action": "开票 BPM(累计≤合同额) â†’ å›žæ¬¾å¼ºåˆ¶å…ˆå¼€ç¥¨(累计≤合同剩余与开票剩余)", "roles": ["财务", "销售"]}]},
    {"id": "purchase", "name": "采购流程", "mermaid": "mermaid/03-purchase-flow.mmd", "color": "#5fc79e",
     "summary": "SRM å‡†å…¥/定标供源 â†’ é‡‡è´­ç”³è¯· BPM â†’ é‡‡è´­è®¢å• BPM â†’ ç¡®è®¤æ”¶è´§ç”Ÿæˆ MES åˆ°è´§é€šçŸ¥ â†’ IQC(合格/特采入库·退货/报废) â†’ é‡‡è´­å…¥åº“回写订单与申请 â†’ æ¥ç¥¨(AI OCR) â†’ ä»˜æ¬¾å• BPM。",
     "summary": "SRM å‡†å…¥/定标供源 â†’ é‡‡è´­ç”³è¯· BPM(支持单张/批量提交) â†’ é‡‡è´­è®¢å•导入(重复检测+弹框覆盖) æˆ– BPM â†’ ç¡®è®¤æ”¶è´§ç”Ÿæˆ MES åˆ°è´§é€šçŸ¥ â†’ IQC(合格/特采入库·退货/报废) â†’ é‡‡è´­å…¥åº“回写订单与申请 â†’ æ¥ç¥¨(AI OCR) â†’ ä»˜æ¬¾å• BPM。",
     "steps": [
       {"stage": "寻源", "action": "供应商准入(BPM)建档;招标完整版或简易比价,定标审批通过自动生成采购订单", "roles": ["采购开发"]},
       {"stage": "申请", "action": "采购申请(CGSQ)提交 BPM,驳回可重置回草稿;通过且有供应商自动生成订单", "roles": ["采购员", "审批人"]},
       {"stage": "下单", "action": "采购订单(CGDD)自动提交 BPM(15→20)", "roles": ["采购员"]},
       {"stage": "申请", "action": "采购申请(CGSQ)单张或勾选多张批量提交 BPM(仅未提交/驳回可提交,逐单独立事务,失败项单独提示),驳回可重置回草稿;通过且有供应商时,先按“供应商+明细(物料/数量/单价/税率)”匹配已审核订单,匹配到则关联申请、不重复生成,未匹配才自动生成", "roles": ["采购员", "审批人"]},
       {"stage": "下单", "action": "采购订单(CGDD)可 Excel å¯¼å…¥(勾选审核直接置 20)或自动提交 BPM(15→20);导入时按单号或“供应商+明细”识别重复,重复项默认不导入并弹框提示,用户确认后才覆盖已有订单;已入库/已退货/已关联发票或采购申请的订单禁止覆盖", "roles": ["采购员"]},
       {"stage": "收货", "action": "确认收货生成到货通知(0→待质检2/待入库3→4);需检物料自动创建 IQC", "roles": ["仓管", "IQC è´¨æ£€å‘˜"]},
       {"stage": "入库", "action": "IQC åˆæ ¼/特采→采购入库(待上架→待执行→完成)库存+,回写订单 inCount/inStatus ä¸Žç”³è¯·æ˜Žç»†", "roles": ["仓管"]},
       {"stage": "退货", "action": "IQC ä¸åˆæ ¼é€€è´§â†’供应商退货出库(选料锁定到订单行);报废走 NCR", "roles": ["仓管"]},
docs/project-business/index.html
@@ -357,7 +357,7 @@
    {"key": "erp", "name": "进销存与财务", "color": "#5fc79e", "tag": "ERP", "flow": "purchase",
     "positioning": "商贸与资金单据中枢,向 MES å‘起实物收发",
     "problem": "采购销售单据审批、来票付款控制、订单执行进度回写",
     "features": ["采购申请(BPM)→订单(自动/手动生成)", "确认收货→MES到货通知", "入库/退货数量状态回写", "采购来票(金额≤订单)", "付款单(必须关联来票·BPM)", "销售订单(BPM·审批通过自动建MPS)", "出库状态回写", "收款单(关联CRM客户)", "结算账户", "AI发票OCR"],
     "features": ["采购申请(BPM·支持批量提交)→订单(自动/手动生成·相同明细不重复生成)", "采购订单导入(重复检测·弹框确认覆盖)", "确认收货→MES到货通知", "入库/退货数量状态回写", "采购来票(金额≤订单)", "付款单(必须关联来票·BPM)", "销售订单(BPM·审批通过自动建MPS)", "出库状态回写", "收款单(关联CRM客户)", "结算账户", "AI发票OCR"],
     "objects": ["采购申请", "采购订单", "采购来票", "付款单", "销售订单", "收款单", "结算账户"],
     "roles": ["采购员", "销售员", "出纳", "会计"],
     "upstream": ["CRM(合同)", "SRM(定标)", "主数据"], "downstream": ["生产(MPS)", "仓储(收发)"],
@@ -457,8 +457,8 @@
    {"name": "供应商", "module": "供应商管理", "table": "srm_supplier", "doc": "供应商档案:执照/税号/银行/联系人,ERP ä¸Ž MES å…±ç”¨", "upstream": ["准入申请"], "downstream": ["采购订单", "到货通知", "供应商退货", "绩效评价"], "keywords": "供应商 supplier vendor"},
    {"name": "准入申请", "module": "供应商管理", "table": "srm_supplier_apply", "doc": "AP编号,BPM å®¡æ‰¹(采购/质量/财务),通过自动建供应商档案", "upstream": [], "downstream": ["供应商"], "keywords": "准入 ä¾›åº”商申请 apply"},
    {"name": "招标项目", "module": "供应商管理", "table": "srm_tender_project", "doc": "完整版(发布→投标→开标→评标→定标)与简易比价两种模式", "upstream": [], "downstream": ["定标→采购订单"], "keywords": "招标 æŠ•æ ‡ æ¯”ä»· å®šæ ‡ tender å¯»æº"},
    {"name": "采购申请", "module": "进销存", "table": "erp_purchase_request", "doc": "CGSQ编号,BPM å®¡æ‰¹ï¼Œé€šè¿‡åŽæœ‰ä¾›åº”商自动生成订单", "upstream": ["生产缺口(人工)"], "downstream": ["采购订单"], "keywords": "采购申请 è¯·è´­ request"},
    {"name": "采购订单", "module": "进销存", "table": "erp_purchase_order", "doc": "CGDD编号,BPM å®¡æ‰¹(15审批中/20已审核),入库状态:未0部分1全部2", "upstream": ["采购申请", "定标"], "downstream": ["到货通知", "采购来票", "供应商退货(约束物料)"], "keywords": "采购订单 purchase"},
    {"name": "采购申请", "module": "进销存", "table": "erp_purchase_request", "doc": "CGSQ编号,BPM å®¡æ‰¹(支持批量提交),通过后有供应商时先匹配相同明细的已审核订单,匹配不到才自动生成", "upstream": ["生产缺口(人工)"], "downstream": ["采购订单"], "keywords": "采购申请 è¯·è´­ request"},
    {"name": "采购订单", "module": "进销存", "table": "erp_purchase_order", "doc": "CGDD编号,BPM å®¡æ‰¹(15审批中/20已审核)或 Excel å¯¼å…¥ï¼Œå¯¼å…¥é‡å¤æ—¶å¼¹æ¡†ç¡®è®¤è¦†ç›–;入库状态:未0部分1全部2", "upstream": ["采购申请", "定标"], "downstream": ["到货通知", "采购来票", "供应商退货(约束物料)"], "keywords": "采购订单 purchase"},
    {"name": "采购来票", "module": "进销存", "table": "erp_purchase_invoice", "doc": "FPD编号,AI OCR é¢„填,累计≤订单总价,付款单的前置", "upstream": ["采购订单"], "downstream": ["付款单"], "keywords": "来票 å‘票 é‡‡è´­å‘票"},
    {"name": "付款单", "module": "财务", "table": "erp_finance_payment", "doc": "FKD编号,必须关联来票+附件,累计≤来票金额,BPM å®¡æ‰¹", "upstream": ["采购来票"], "downstream": ["结算账户"], "keywords": "付款 åº”付 payment"},
    {"name": "收款单", "module": "财务", "table": "erp_finance_receipt", "doc": "SKD编号,关联 CRM å®¢æˆ·æ ¡éªŒï¼Œæ—  BPM(手动审核)", "upstream": ["销售(弱关联)"], "downstream": ["结算账户"], "keywords": "收款 åº”æ”¶ receipt"},
@@ -505,11 +505,11 @@
       {"stage": "交付", "action": "发货通知审核(占用)→ é”€å”®å‡ºåº“ OQC â†’ å‘运 â†’ å‡ºåº“回写部分/全部出库", "roles": ["仓管", "质检"]},
       {"stage": "回款", "action": "开票 BPM(累计≤合同额) â†’ å›žæ¬¾å¼ºåˆ¶å…ˆå¼€ç¥¨(累计≤合同剩余与开票剩余)", "roles": ["财务", "销售"]}]},
    {"id": "purchase", "name": "采购流程", "mermaid": "mermaid/03-purchase-flow.mmd", "color": "#5fc79e",
     "summary": "SRM å‡†å…¥/定标供源 â†’ é‡‡è´­ç”³è¯· BPM â†’ é‡‡è´­è®¢å• BPM â†’ ç¡®è®¤æ”¶è´§ç”Ÿæˆ MES åˆ°è´§é€šçŸ¥ â†’ IQC(合格/特采入库·退货/报废) â†’ é‡‡è´­å…¥åº“回写订单与申请 â†’ æ¥ç¥¨(AI OCR) â†’ ä»˜æ¬¾å• BPM。",
     "summary": "SRM å‡†å…¥/定标供源 â†’ é‡‡è´­ç”³è¯· BPM(支持单张/批量提交) â†’ é‡‡è´­è®¢å•导入(重复检测+弹框覆盖) æˆ– BPM â†’ ç¡®è®¤æ”¶è´§ç”Ÿæˆ MES åˆ°è´§é€šçŸ¥ â†’ IQC(合格/特采入库·退货/报废) â†’ é‡‡è´­å…¥åº“回写订单与申请 â†’ æ¥ç¥¨(AI OCR) â†’ ä»˜æ¬¾å• BPM。",
     "steps": [
       {"stage": "寻源", "action": "供应商准入(BPM)建档;招标完整版或简易比价,定标审批通过自动生成采购订单", "roles": ["采购开发"]},
       {"stage": "申请", "action": "采购申请(CGSQ)提交 BPM,驳回可重置回草稿;通过且有供应商自动生成订单", "roles": ["采购员", "审批人"]},
       {"stage": "下单", "action": "采购订单(CGDD)自动提交 BPM(15→20)", "roles": ["采购员"]},
       {"stage": "申请", "action": "采购申请(CGSQ)单张或勾选多张批量提交 BPM(仅未提交/驳回可提交,逐单独立事务,失败项单独提示),驳回可重置回草稿;通过且有供应商时,先按“供应商+明细(物料/数量/单价/税率)”匹配已审核订单,匹配到则关联申请、不重复生成,未匹配才自动生成", "roles": ["采购员", "审批人"]},
       {"stage": "下单", "action": "采购订单(CGDD)可 Excel å¯¼å…¥(勾选审核直接置 20)或自动提交 BPM(15→20);导入时按单号或“供应商+明细”识别重复,重复项默认不导入并弹框提示,用户确认后才覆盖已有订单;已入库/已退货/已关联发票或采购申请的订单禁止覆盖", "roles": ["采购员"]},
       {"stage": "收货", "action": "确认收货生成到货通知(0→待质检2/待入库3→4);需检物料自动创建 IQC", "roles": ["仓管", "IQC è´¨æ£€å‘˜"]},
       {"stage": "入库", "action": "IQC åˆæ ¼/特采→采购入库(待上架→待执行→完成)库存+,回写订单 inCount/inStatus ä¸Žç”³è¯·æ˜Žç»†", "roles": ["仓管"]},
       {"stage": "退货", "action": "IQC ä¸åˆæ ¼é€€è´§â†’供应商退货出库(选料锁定到订单行);报废走 NCR", "roles": ["仓管"]},
docs/project-business/mermaid/03-purchase-flow.mmd
@@ -3,9 +3,12 @@
  S1 --> S2[资质证书管理]
  S1 --> S3[绩效评价·质量交付价格服务四维]
  S4[SRM æ‹›æ ‡æ¯”价·发布→投标→开标→评标→定标] -->|定标审批通过生成订单| C3
  C1[采购申请 CGSQ·草稿0→审批中10] -->|提交BPM| C2{审批}
  C1[采购申请 CGSQ·草稿0→审批中10] -->|单张提交或批量提交BPM| C2{审批}
  C2 -->|驳回30→可重置回草稿| C1
  C2 -->|通过20且有供应商·自动生成| C3[采购订单 CGDD·审批中15→已审核20]
  C2 -->|通过20且有供应商·先按供应商+明细匹配已审核订单| C2a{是否已有相同订单}
  C2a -->|有·关联申请不重复生成| C3[采购订单 CGDD·审批中15→已审核20]
  C2a -->|无·自动生成| C3
  C0[采购订单导入 Excel·勾选审核直接20] -->|单号或供应商+明细重复时弹框确认覆盖| C3
  C2 -->|通过20无供应商| C4[手动指定供应商生成订单]
  C4 --> C3
  C3 -->|确认收货| C5[到货通知单·草稿0→待质检2→待入库3→完成4]
docs/purchase_order_duplicate_and_batch_submit_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,123 @@
# é‡‡è´­è®¢å•导入重复检测与采购申请批量提交 - å‰ç«¯è”调方案
## æ¶‰åŠé¡µé¢
- ERP â†’ é‡‡è´­ç®¡ç† â†’ é‡‡è´­è®¢å• â†’ å¯¼å…¥å¼¹çª—
- ERP â†’ é‡‡è´­ç®¡ç† â†’ é‡‡è´­ç”³è¯· â†’ åˆ—表工具栏、提交审批流程弹窗
## ä¸šåŠ¡æµç¨‹ä¸Žæ•°æ®å¸¦å…¥
1. é‡‡è´­ç”³è¯·åˆ—表勾选多张单据 â†’ ç‚¹å‡»ã€Œæ‰¹é‡æäº¤ã€â†’ å‰ç«¯è¿‡æ»¤å‡ºâ€œæœªæäº¤(0)/审批不通过(30)”的申请 â†’ é€‰æ‹©å®¡æ‰¹æµç¨‹ â†’ æŒ‰æ¯æ‰¹ 50 å¼ è°ƒç”¨æ‰¹é‡æäº¤æŽ¥å£ã€‚
2. æ‰¹é‡æäº¤é€å¼ åˆ›å»º BPM æµç¨‹å®žä¾‹å¹¶å›žå†™ç”³è¯·çŠ¶æ€ä¸ºâ€œå®¡æ‰¹ä¸­(10)”,单张失败不影响其它申请,失败原因按申请单号返回。
3. é‡‡è´­ç”³è¯·å®¡æ‰¹é€šè¿‡ä¸”已填供应商时,后端先按“供应商 + æ˜Žç»†ï¼ˆç‰©æ–™ã€æ•°é‡ã€å•价、税率)”匹配系统内已审核且尚未入库/退货的采购订单:
   - åŒ¹é…åˆ°ï¼šæŠŠç”³è¯·å…³è”到该订单,并回填订单明细的申请明细编号,不再生成新订单;
   - åŒ¹é…ä¸åˆ°ï¼šä¿æŒåŽŸæœ‰é€»è¾‘è‡ªåŠ¨ç”Ÿæˆä¸€å¼ å·²å®¡æ ¸é‡‡è´­è®¢å•ã€‚
4. é‡‡è´­è®¢å•导入时,后端按以下顺序识别重复:
   - æ–‡ä»¶ä¸­çš„“采购单编号”在系统内已存在;
   - æ–‡ä»¶ä¸­çš„“采购单编号”留空(由系统自动生成编号)且供应商、明细、数量、单价、税率与系统内已有订单完全一致。
5. å‘现重复且未开启覆盖时,重复订单不落库,返回 `duplicateList`;前端弹框提示,用户确认覆盖后以 `overwrite=true` é‡æ–°å¯¼å…¥ã€‚
6. å·²å…¥åº“、已退货、已关联发票或已关联采购申请、已提交审批流程的订单禁止覆盖,覆盖失败原因进入 `failureList`。
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| POST | /erp/purchase-order/import | å¯¼å…¥é‡‡è´­è®¢å•(新增 `overwrite` å‚数、响应新增 `duplicateList`) |
| PUT | /erp/purchase-request/submit-batch | æ‰¹é‡æäº¤é‡‡è´­ç”³è¯·å®¡æ‰¹ï¼ˆæ–°å¢žï¼‰ |
### 1. å¯¼å…¥é‡‡è´­è®¢å•
**请求参数(multipart/form-data):**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| file | File | æ˜¯ | xls/xlsx æ–‡ä»¶ |
| directApprove | Boolean | å¦ | æ˜¯å¦ç›´æŽ¥å°†å¯¼å…¥è®¢å•置为“已审核(20)”,默认 false |
| overwrite | Boolean | å¦ | æ˜¯å¦è¦†ç›–重复订单,默认 false;false æ—¶é‡å¤é¡¹åªè¿”回不落库 |
**响应新增字段:**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| duplicateList | Array | æ£€æµ‹åˆ°çš„重复订单;未开启覆盖时这些订单未导入 |
| duplicateList[].key | String | å¯¼å…¥æ–‡ä»¶ä¸­çš„采购单编号;编号为空时为“第 N è¡Œâ€ |
| duplicateList[].existingNo | String | ç³»ç»Ÿå†…已存在的采购订单编号 |
| duplicateList[].existingId | Number | ç³»ç»Ÿå†…已存在的采购订单编号 |
| duplicateList[].reason | String | é‡å¤åŽŸå› ï¼šé‡‡è´­å•ç¼–å·å·²å­˜åœ¨ / ä¾›åº”商、明细、数量、单价、金额与已有订单一致 |
**响应示例:**
```json
{
  "code": 0,
  "data": {
    "createList": ["CGDD20261105000001"],
    "updateList": [],
    "failureList": {},
    "duplicateList": [
      {
        "key": "第 2 è¡Œ",
        "existingNo": "CGDD20261014000006",
        "existingId": 2106,
        "reason": "供应商、明细、数量、单价、金额与已有订单一致"
      }
    ]
  }
}
```
### 2. æ‰¹é‡æäº¤é‡‡è´­ç”³è¯·
**请求参数(query):**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| ids | String | æ˜¯ | é‡‡è´­ç”³è¯·ç¼–号,多个用英文逗号分隔 |
| processDefinitionKey | String | æ˜¯ | BPM å®¡æ‰¹æµç¨‹å®šä¹‰ Key |
**响应字段:**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| successIds | Number[] | æäº¤æˆåŠŸçš„é‡‡è´­ç”³è¯·ç¼–å· |
| failureMap | Object | æäº¤å¤±è´¥é›†åˆï¼›key ä¸ºé‡‡è´­ç”³è¯·å•号(取不到单号时为“编号 xxx”),value ä¸ºå¤±è´¥åŽŸå›  |
**响应示例:**
```json
{
  "code": 0,
  "data": {
    "successIds": [2195, 2196],
    "failureMap": {
      "CGSQ20261105000003": "提交失败,采购申请单(CGSQ20261105000003)没有明细,请先添加明细后再提交"
    }
  }
}
```
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| duplicateList | å¯¼å…¥ç»“果区、重复确认弹框 | å±•示导入文件标识 â†’ å·²å­˜åœ¨è®¢å•编号 + é‡å¤åŽŸå›  |
| updateList | å¯¼å…¥ç»“果区 | è¦†ç›–模式下展示被更新订单编号 |
| failureMap | æ‰¹é‡æäº¤ç»“果弹框 | é€æ¡å±•示“申请单号:失败原因” |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|------|------|
| æ‰¹é‡æäº¤èŒƒå›´ | ä»…“未提交(0)”和“审批不通过(30)”的申请可提交;其它状态自动跳过并提示 |
| æ‰¹é‡æäº¤äº‹åŠ¡ | æ¯å¼ ç”³è¯·ç‹¬ç«‹äº‹åŠ¡ï¼Œå•å¼ å¤±è´¥ä¸å›žæ»šå…¶å®ƒç”³è¯· |
| å¯¼å…¥é‡å¤-单号 | æ–‡ä»¶å¡«å†™äº†â€œé‡‡è´­å•编号”且系统已存在,判定重复 |
| å¯¼å…¥é‡å¤-无单号 | æ–‡ä»¶æœªå¡«â€œé‡‡è´­å•编号”时,才按“供应商 + æ˜Žç»†â€æŒ‡çº¹åˆ¤å®šé‡å¤ï¼›å¡«å†™äº†æ˜Žç¡®å•号的正常重复采购不会被指纹拦截 |
| è¦†ç›–限制 | å·²å…¥åº“、已退货、已关联发票、已关联采购申请、已提交审批流程的订单不可覆盖 |
| å®¡æ‰¹è‡ªåŠ¨ç”Ÿæˆ | åŒ¹é…åˆ°ç›¸åŒå·²å®¡æ ¸è®¢å•时只建立关联,不重复生成订单;匹配不到才新增 |
## æ³¨æ„äº‹é¡¹
- å¯¼å…¥é‡å¤å¼¹æ¡†æä¾›â€œè¦†ç›–重复订单”和“不覆盖”两个选项;选择不覆盖时,重复项保留在结果区,可再次点击“覆盖重复订单”。
- è‹¥ç¡®è®¤æ˜¯æ­£å¸¸çš„æ–°é‡‡è´­è€Œéžé‡å¤ä¸Šä¼ ï¼Œéœ€åœ¨æ–‡ä»¶ä¸­å¡«å†™ä¸€ä¸ªç³»ç»Ÿä¸­ä¸å­˜åœ¨çš„采购单编号后重新导入。
- æ‰¹é‡æäº¤å»ºè®®æŒ‰ 50 å¼ ä¸€æ‰¹è°ƒç”¨ï¼Œé¿å…ä¸€æ¬¡æäº¤è¿‡å¤šå¯¼è‡´è¯·æ±‚超时。
- `overwrite=true` ä¼šé‡æ–°å¤„理整个文件,包含首次已成功新增的非重复项,因此 `updateList` å¯èƒ½åŒ…含这些订单编号。
docs/sql/erp_purchase_request_submit_menu.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,35 @@
-- é‡‡è´­ç”³è¯·ã€Œæ‰¹é‡æäº¤ã€æŒ‰é’®æƒé™
-- å½±å“é¡µé¢ï¼šERP -> é‡‡è´­ç®¡ç† -> é‡‡è´­ç”³è¯·
--
-- èƒŒæ™¯ï¼šé‡‡è´­ç”³è¯·æäº¤æŽ¥å£ï¼ˆPUT /erp/purchase-request/submit、/submit-batch)使用
--       æƒé™ç  erp:purchase-request:submit,但 system_menu ä¸­ä»Žæœªç»´æŠ¤è¯¥æƒé™æŒ‰é’®ï¼Œ
--       å¯¼è‡´å‰ç«¯å·¥å…·æ ã€Œæ‰¹é‡æäº¤ã€æŒ‰é’®è¢«æƒé™è¿‡æ»¤éšè—ï¼Œæ™®é€šè§’色调用接口会 403。
--
-- æœ¬è„šæœ¬å¹‚ç­‰ï¼Œå¯é‡å¤æ‰§è¡Œã€‚æ‰§è¡ŒåŽéœ€æ¸…ç†æƒé™ç¼“å­˜ï¼ˆé‡å¯åŽç«¯æˆ–åˆ é™¤
-- redis ä¸­çš„ permission_menu_ids:erp:purchase-request:submit ä¸Žç›¸å…³ role_menu_ids:*),
-- å‰ç«¯é‡æ–°ç™»å½•/刷新后生效。
-- 1. è¡¥æŒ‰é’®èœå•(挂在“采购申请”菜单下)
INSERT INTO system_menu (name, permission, type, sort, parent_id, status, icon, creator, updater)
SELECT '批量提交审批', 'erp:purchase-request:submit', 3, 9, p.id, 0, '#', '1', '1'
FROM system_menu p
WHERE p.component = 'erp/purchase/request/index'
  AND p.type = 2
  AND p.deleted = b'0'
  AND NOT EXISTS (
      SELECT 1 FROM system_menu m
      WHERE m.permission = 'erp:purchase-request:submit' AND m.deleted = b'0'
  );
-- 2. æŽˆäºˆå·²æ‹¥æœ‰â€œé‡‡è´­ç”³è¯·â€èœå•的角色
INSERT INTO system_role_menu (role_id, menu_id, creator, updater)
SELECT DISTINCT rm.role_id, m.id, '1', '1'
FROM system_role_menu rm
JOIN system_menu p ON p.component = 'erp/purchase/request/index' AND p.type = 2 AND p.deleted = b'0'
JOIN system_menu m ON m.permission = 'erp:purchase-request:submit' AND m.deleted = b'0'
WHERE rm.menu_id = p.id
  AND rm.deleted = b'0'
  AND NOT EXISTS (
      SELECT 1 FROM system_role_menu x
      WHERE x.role_id = rm.role_id AND x.menu_id = m.id AND x.deleted = b'0'
  );
docs/sql/hrm_audit_status_dict.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,30 @@
-- HRM å®¡æ‰¹çŠ¶æ€å­—å…¸
-- å½±å“é¡µé¢ï¼šäººåŠ›èµ„æºæ¨¡å—ä¸­æ‰€æœ‰ä½¿ç”¨ hrm_audit_status å­—典的单据列表/详情(考勤异常申请、请假、离职、调岗等)
--
-- èƒŒæ™¯ï¼šä»£ç ä¸­å¤§é‡ä½¿ç”¨ @DictFormat("hrm_audit_status") ä¸Žå‰ç«¯ DICT_TYPE.HRM_AUDIT_STATUS,
--       ä½† system_dict_type / system_dict_data ä¸­ç¼ºå°‘该字典,导致状态列显示为空、导出无中文名。
--
-- æœ¬è„šæœ¬å¹‚ç­‰ï¼Œå¯é‡å¤æ‰§è¡Œã€‚æ‰§è¡ŒåŽæ¸…ç†å­—å…¸ç¼“å­˜ï¼ˆé‡å¯åŽç«¯ï¼Œæˆ–åˆ é™¤ Redis ä¸­çš„ dict_data:* ç›¸å…³é”®ï¼‰ï¼Œ
-- å‰ç«¯é‡æ–°ç™»å½•/刷新后生效。
-- 1. å­—典类型
INSERT INTO system_dict_type (name, type, status, remark, creator, updater)
SELECT 'HRM å®¡æ‰¹çŠ¶æ€', 'hrm_audit_status', 0, '人力模块单据审批状态', '1', '1'
WHERE NOT EXISTS (
    SELECT 1 FROM system_dict_type WHERE type = 'hrm_audit_status' AND deleted = b'0'
);
-- 2. å­—典数据(与 HrmAuditStatusEnum ä¿æŒä¸€è‡´ï¼‰
INSERT INTO system_dict_data (sort, label, value, dict_type, status, color_type, css_class, remark, creator, updater)
SELECT s.sort, s.label, s.value, 'hrm_audit_status', 0, s.color_type, '', '', '1', '1'
FROM (
    SELECT 0 AS sort, '草稿' AS label, '0' AS value, 'info' AS color_type
    UNION ALL SELECT 10, '审批中', '10', 'default'
    UNION ALL SELECT 20, '审批通过', '20', 'success'
    UNION ALL SELECT 30, '审批驳回', '30', 'danger'
    UNION ALL SELECT 40, '已撤销', '40', 'warning'
) s
WHERE NOT EXISTS (
    SELECT 1 FROM system_dict_data d
    WHERE d.dict_type = 'hrm_audit_status' AND d.value = s.value AND d.deleted = b'0'
);
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchaseOrderController.java
@@ -190,9 +190,10 @@
    @PreAuthorize("@ss.hasPermission('erp:purchase-order:import')")
    public CommonResult<ErpPurchaseOrderImportRespVO> importPurchaseOrderExcel(
            @RequestParam("file") MultipartFile file,
            @RequestParam(value = "directApprove", required = false, defaultValue = "false") Boolean directApprove) throws Exception {
            @RequestParam(value = "directApprove", required = false, defaultValue = "false") Boolean directApprove,
            @RequestParam(value = "overwrite", required = false, defaultValue = "false") Boolean overwrite) throws Exception {
        List<ErpPurchaseOrderImportExcelVO> list = ExcelUtils.read(file, ErpPurchaseOrderImportExcelVO.class);
        return success(purchaseOrderService.importPurchaseOrderList(list, directApprove));
        return success(purchaseOrderService.importPurchaseOrderList(list, directApprove, overwrite));
    }
    @PostMapping("/confirm-receipt")
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchaseRequestController.java
@@ -13,6 +13,7 @@
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.request.ErpPurchaseRequestSaveReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.request.ErpPurchaseRequestImportExcelVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.request.ErpPurchaseRequestImportRespVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.request.ErpPurchaseRequestSubmitBatchRespVO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseRequestDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseRequestItemDO;
import cn.iocoder.yudao.module.srm.api.supplier.SrmSupplierApi;
@@ -173,6 +174,17 @@
        return success(true);
    }
    @PutMapping("/submit-batch")
    @Operation(summary = "批量提交采购申请审批")
    @Parameter(name = "ids", description = "采购申请编号数组", required = true)
    @PreAuthorize("@ss.hasPermission('erp:purchase-request:submit')")
    public CommonResult<ErpPurchaseRequestSubmitBatchRespVO> submitPurchaseRequestBatch(
            @RequestParam("ids") List<Long> ids,
            @RequestParam("processDefinitionKey") String processDefinitionKey) {
        Long userId = getLoginUserId();
        return success(purchaseRequestService.submitPurchaseRequestBatch(ids, processDefinitionKey, userId));
    }
    @PutMapping("/reset-status")
    @Operation(summary = "重置采购申请状态(驳回→草稿)")
    @PreAuthorize("@ss.hasPermission('erp:purchase-request:update')")
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/order/ErpPurchaseOrderImportDuplicateVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,30 @@
package cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.order;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
/**
 * ç®¡ç†åŽå° - é‡‡è´­è®¢å•导入重复项 VO
 *
 * å¯¼å…¥æ—¶å¦‚果发现系统内已有相同采购单编号,或供应商、明细、金额完全一致的订单,
 * ä¸ç›´æŽ¥åˆ›å»ºæ–°è®¢å•,而是返回给前端弹框提示,由用户决定是否覆盖。
 */
@Schema(description = "管理后台 - é‡‡è´­è®¢å•导入重复项 VO")
@Data
@Builder
public class ErpPurchaseOrderImportDuplicateVO {
    @Schema(description = "导入文件中的采购单编号;编号为空时使用行号", example = "CGDD20260101000001")
    private String key;
    @Schema(description = "系统内已存在的采购订单编号", example = "CGDD20260101000001")
    private String existingNo;
    @Schema(description = "系统内已存在的采购订单编号")
    private Long existingId;
    @Schema(description = "重复原因", example = "采购单编号已存在")
    private String reason;
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/order/ErpPurchaseOrderImportRespVO.java
@@ -26,4 +26,7 @@
    @Schema(description = "导入失败集合,key ä¸ºé‡‡è´­å•编号或行号,value ä¸ºå¤±è´¥åŽŸå› ")
    private Map<String, String> failureList;
    @Schema(description = "检测到的重复采购订单;未开启覆盖时这些订单不会导入,由前端弹框提示用户")
    private List<ErpPurchaseOrderImportDuplicateVO> duplicateList;
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/request/ErpPurchaseRequestSubmitBatchRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,24 @@
package cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.request;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
 * ç®¡ç†åŽå° - é‡‡è´­ç”³è¯·æ‰¹é‡æäº¤ Response VO
 */
@Schema(description = "管理后台 - é‡‡è´­ç”³è¯·æ‰¹é‡æäº¤ Response VO")
@Data
@Builder
public class ErpPurchaseRequestSubmitBatchRespVO {
    @Schema(description = "提交成功的采购申请编号数组")
    private List<Long> successIds;
    @Schema(description = "提交失败的采购申请集合,key ä¸ºé‡‡è´­ç”³è¯·å•号(取不到单号时为编号),value ä¸ºå¤±è´¥åŽŸå› ")
    private Map<String, String> failureMap;
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseOrderMapper.java
@@ -86,6 +86,15 @@
        return selectOne(ErpPurchaseOrderDO::getNo, no);
    }
    /**
     * æŒ‰ç¼–号查询并加行锁,用于采购申请审批通过时复用已有订单,避免并发重复占用
     */
    default ErpPurchaseOrderDO selectByIdForUpdate(Long id) {
        return selectOne(new LambdaQueryWrapperX<ErpPurchaseOrderDO>()
                .eq(ErpPurchaseOrderDO::getId, id)
                .last("FOR UPDATE"));
    }
    default ErpPurchaseOrderDO selectByProcessInstanceId(String processInstanceId) {
        return selectOne(ErpPurchaseOrderDO::getProcessInstanceId, processInstanceId);
    }
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErrorCodeConstants.java
@@ -46,7 +46,7 @@
    ErrorCode PURCHASE_REQUEST_NOT_EXISTS = new ErrorCode(1_030_104_000, "采购申请单不存在");
    ErrorCode PURCHASE_REQUEST_DELETE_FAIL_NOT_DRAFT = new ErrorCode(1_030_104_001, "采购申请单({})不是草稿状态,无法删除");
    ErrorCode PURCHASE_REQUEST_UPDATE_FAIL_NOT_DRAFT = new ErrorCode(1_030_104_002, "采购申请单({})不是草稿状态,无法修改");
    ErrorCode PURCHASE_REQUEST_SUBMIT_FAIL_NOT_DRAFT = new ErrorCode(1_030_104_003, "提交失败,只有草稿状态的申请单才能提交");
    ErrorCode PURCHASE_REQUEST_SUBMIT_FAIL_NOT_DRAFT = new ErrorCode(1_030_104_003, "提交失败,采购申请单({})不是草稿或驳回状态,无法提交");
    ErrorCode PURCHASE_REQUEST_NO_EXISTS = new ErrorCode(1_030_104_004, "生成申请单号失败,请重新提交");
    ErrorCode PURCHASE_REQUEST_UPDATE_AUDIT_STATUS_FAIL_NOT_PROCESS = new ErrorCode(1_030_104_005, "更新审批状态失败,只有审批中的申请单才能更新");
    ErrorCode PURCHASE_REQUEST_NOT_APPROVE = new ErrorCode(1_030_104_006, "采购申请单未审核通过,无法操作");
@@ -54,6 +54,7 @@
    ErrorCode PURCHASE_REQUEST_BPM_CATEGORY_NOT_EXISTS = new ErrorCode(1_030_104_008, "采购申请审批分类不存在,请先在BPM模块创建采购申请审批流程分类");
    ErrorCode PURCHASE_REQUEST_BPM_PROCESS_DEFINITION_NOT_EXISTS = new ErrorCode(1_030_104_009, "采购申请审批分类下没有可用的流程模型,请先创建并发布流程");
    ErrorCode PURCHASE_REQUEST_RESET_FAIL_NOT_REJECT = new ErrorCode(1_030_104_010, "重置失败,只有驳回状态的采购申请单才能重置");
    ErrorCode PURCHASE_REQUEST_ITEM_EMPTY = new ErrorCode(1_030_104_011, "提交失败,采购申请单({})没有明细,请先添加明细后再提交");
    // ========== ERP é‡‡è´­æ¥ç¥¨ï¼ˆ1-030-105-000) ==========
    ErrorCode PURCHASE_INVOICE_NOT_EXISTS = new ErrorCode(1_030_105_000, "来票不存在");
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseOrderService.java
@@ -34,9 +34,11 @@
     *
     * @param importList    å¯¼å…¥æ•°æ®ï¼ˆæ¯ä¸€è¡Œ = ä¸€æ¡æ˜Žç»†ï¼Œç›¸åŒé‡‡è´­å•编号合并为一张订单)
     * @param directApprove æ˜¯å¦å°†å¯¼å…¥çš„订单直接置为“已审核”:true=已审核(20),false=未审核(10)
     * @param overwrite     æ˜¯å¦è¦†ç›–系统内已有的重复订单:false æ—¶åªè¿”回重复项供前端提示,true æ—¶æ›´æ–°å·²æœ‰è®¢å•
     * @return å¯¼å…¥ç»“æžœ
     */
    ErpPurchaseOrderImportRespVO importPurchaseOrderList(List<ErpPurchaseOrderImportExcelVO> importList, Boolean directApprove);
    ErpPurchaseOrderImportRespVO importPurchaseOrderList(List<ErpPurchaseOrderImportExcelVO> importList,
                                                         Boolean directApprove, Boolean overwrite);
    /**
     * æ›´æ–°é‡‡è´­è®¢å•
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseOrderServiceImpl.java
@@ -16,12 +16,14 @@
import cn.iocoder.yudao.module.bpm.service.definition.BpmProcessDefinitionService;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.order.ErpPurchaseOrderPageReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.order.ErpPurchaseOrderSaveReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.order.ErpPurchaseOrderImportDuplicateVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.order.ErpPurchaseOrderImportExcelVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.order.ErpPurchaseOrderImportRespVO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseOrderDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseOrderItemDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseRequestDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseRequestItemDO;
import cn.iocoder.yudao.module.erp.dal.mysql.purchase.ErpPurchaseInvoiceOrderMapper;
import cn.iocoder.yudao.module.erp.dal.mysql.purchase.ErpPurchaseOrderItemMapper;
import cn.iocoder.yudao.module.erp.dal.mysql.purchase.ErpPurchaseOrderMapper;
import cn.iocoder.yudao.module.erp.dal.mysql.purchase.ErpPurchaseRequestItemMapper;
@@ -49,6 +51,7 @@
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*;
@@ -78,6 +81,8 @@
    private ErpPurchaseOrderMapper purchaseOrderMapper;
    @Resource
    private ErpPurchaseOrderItemMapper purchaseOrderItemMapper;
    @Resource
    private ErpPurchaseInvoiceOrderMapper invoiceOrderMapper;
    @Resource
    private ErpNoRedisDAO noRedisDAO;
@@ -180,11 +185,12 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public ErpPurchaseOrderImportRespVO importPurchaseOrderList(List<ErpPurchaseOrderImportExcelVO> importList,
                                                               Boolean directApprove) {
                                                               Boolean directApprove, Boolean overwrite) {
        ErpPurchaseOrderImportRespVO respVO = ErpPurchaseOrderImportRespVO.builder()
                .createList(new ArrayList<>())
                .updateList(new ArrayList<>())
                .failureList(new LinkedHashMap<>())
                .duplicateList(new ArrayList<>())
                .build();
        if (CollUtil.isEmpty(importList)) {
            return respVO;
@@ -204,7 +210,27 @@
            });
        }
        // 2. æŒ‰â€œé‡‡è´­å•编号”分组:同编号多行 = ä¸€å¼ è®¢å•的多条明细;编号为空则每行独立成单
        // 2. é¢„热系统内有效采购订单:按单号、按“供应商 + æ˜Žç»†â€æŒ‡çº¹å»ºç´¢å¼•,用于识别重复导入
        List<ErpPurchaseOrderDO> existingOrders = purchaseOrderMapper.selectList();
        Map<Long, List<ErpPurchaseOrderItemDO>> existingItemsMap = new HashMap<>();
        if (CollUtil.isNotEmpty(existingOrders)) {
            purchaseOrderItemMapper.selectListByOrderIds(
                            existingOrders.stream().map(ErpPurchaseOrderDO::getId).toList())
                    .forEach(item -> existingItemsMap.computeIfAbsent(item.getOrderId(), k -> new ArrayList<>()).add(item));
        }
        Map<String, ErpPurchaseOrderDO> existingNoMap = new HashMap<>();
        Map<String, List<ErpPurchaseOrderDO>> existingFingerprintMap = new HashMap<>();
        for (ErpPurchaseOrderDO order : existingOrders) {
            if (StrUtil.isNotBlank(order.getNo())) {
                existingNoMap.put(order.getNo(), order);
            }
            String fingerprint = buildOrderFingerprint(order.getSupplierId(), existingItemsMap.get(order.getId()));
            if (fingerprint != null) {
                existingFingerprintMap.computeIfAbsent(fingerprint, k -> new ArrayList<>()).add(order);
            }
        }
        // 3. æŒ‰â€œé‡‡è´­å•编号”分组:同编号多行 = ä¸€å¼ è®¢å•的多条明细;编号为空则每行独立成单
        Map<String, List<ErpPurchaseOrderImportExcelVO>> orderGroups = new LinkedHashMap<>();
        for (int i = 0; i < importList.size(); i++) {
            ErpPurchaseOrderImportExcelVO row = importList.get(i);
@@ -216,14 +242,50 @@
            orderGroups.computeIfAbsent(key, k -> new ArrayList<>()).add(row);
        }
        // 3. é€ä¸ªè®¢å•导入(单张订单失败不影响其它)
        // 4. é€ä¸ªè®¢å•导入(单张订单失败不影响其它)
        for (Map.Entry<String, List<ErpPurchaseOrderImportExcelVO>> entry : orderGroups.entrySet()) {
            List<ErpPurchaseOrderImportExcelVO> rows = entry.getValue();
            String label = StrUtil.isNotBlank(rows.get(0).getNo()) ? rows.get(0).getNo().trim()
                    : "第 " + entry.getKey().substring("__row_".length()) + " è¡Œ";
            try {
                ErpPurchaseOrderDO purchaseOrder = createPurchaseOrderByImport(rows, supplierIdMap, directApprove);
                respVO.getCreateList().add(purchaseOrder.getNo());
                ImportOrderData importData = prepareImportOrder(rows, supplierIdMap);
                // 4.1 å…ˆæŒ‰å•号找;只有“单号为空、由系统生成编号”的行才按“供应商 + å®Œæ•´æ˜Žç»†â€æŒ‡çº¹æ‰¾ã€‚
                //     æ–‡ä»¶é‡Œå†™äº†æ˜Žç¡®å•号的,视为用户明确要新增该单号,指纹相同也不拦截,避免正常的重复采购被误判。
                ErpPurchaseOrderDO existing = importData.order.getNo() == null ? null
                        : existingNoMap.get(importData.order.getNo());
                String duplicateReason = existing != null ? "采购单编号已存在" : null;
                boolean matchedByFingerprint = false;
                List<ErpPurchaseOrderDO> fingerprintCandidates = null;
                if (existing == null && importData.generatedNo && importData.fingerprint != null) {
                    fingerprintCandidates = existingFingerprintMap.get(importData.fingerprint);
                    if (CollUtil.isNotEmpty(fingerprintCandidates)) {
                        existing = fingerprintCandidates.get(0);
                        matchedByFingerprint = true;
                        duplicateReason = "供应商、明细、数量、单价、金额与已有订单一致";
                    }
                }
                // 4.2 å‘现重复:默认不导入,返回给前端弹框提示;勾选覆盖后才更新已有订单
                if (existing != null) {
                    if (!Boolean.TRUE.equals(overwrite)) {
                        respVO.getDuplicateList().add(buildImportDuplicate(label, existing, duplicateReason));
                        continue;
                    }
                    if (matchedByFingerprint && fingerprintCandidates.size() > 1) {
                        String nos = fingerprintCandidates.stream().map(ErpPurchaseOrderDO::getNo)
                                .collect(Collectors.joining("、"));
                        throw new IllegalArgumentException("找到多张相同订单(" + nos
                                + "),无法自动覆盖,请先保留一张后再导入");
                    }
                    updatePurchaseOrderByImport(existing, importData, directApprove);
                    respVO.getUpdateList().add(existing.getNo());
                    continue;
                }
                // 4.3 æ²¡æœ‰é‡å¤ï¼Œæ­£å¸¸æ–°å¢ž
                createPurchaseOrderByImport(importData, directApprove);
                respVO.getCreateList().add(importData.order.getNo());
            } catch (Exception ex) {
                respVO.getFailureList().put(label, resolveImportErrorMessage(ex));
            }
@@ -232,13 +294,10 @@
    }
    /**
     * ä¾æ®å¯¼å…¥æ•°æ®åˆ›å»ºä¸€å¼ é‡‡è´­è®¢å•(含明细)。
     *
     * æ³¨æ„ï¼šæ­¤å¤„不调用 submitPurchaseOrder,因此不会创建 BPM å·¥ä½œæµï¼›è®¢å•状态由 directApprove ç›´æŽ¥å†³å®šï¼š
     * å‹¾é€‰â€œæ˜¯å¦å®¡æ ¸â€ => å·²å®¡æ ¸(20),未勾选 => æœªå®¡æ ¸(10)。
     * æ ¡éªŒå¹¶ç»„装一张导入订单(不落库)。
     */
    private ErpPurchaseOrderDO createPurchaseOrderByImport(List<ErpPurchaseOrderImportExcelVO> rows,
                                                           Map<String, Long> supplierIdMap, Boolean directApprove) {
    private ImportOrderData prepareImportOrder(List<ErpPurchaseOrderImportExcelVO> rows,
                                               Map<String, Long> supplierIdMap) {
        // 1. ä¾›åº”商(取该组首行)
        ErpPurchaseOrderImportExcelVO head = rows.get(0);
        if (StrUtil.isBlank(head.getSupplierName())) {
@@ -278,31 +337,145 @@
            items.add(itemVO);
        }
        // 3. å•号:填了就用(校验唯一),没填自动生成
        String no = StrUtil.isNotBlank(head.getNo()) ? head.getNo().trim()
                : noRedisDAO.generate(ErpNoRedisDAO.PURCHASE_ORDER_NO_PREFIX);
        if (purchaseOrderMapper.selectByNo(no) != null) {
            throw new IllegalArgumentException("采购单编号已存在:" + no);
        }
        // 3. å•号:填了就用;留空时先不生成,等确认要新增时再生成,避免重复行被跳过也消耗流水号
        boolean generatedNo = StrUtil.isBlank(head.getNo());
        // 4. ç»„装主表:勾选“是否审核” => å·²å®¡æ ¸(20),否则未审核(10)
        // 4. ç»„装主表(状态由调用方决定)
        ErpPurchaseOrderDO purchaseOrder = new ErpPurchaseOrderDO();
        purchaseOrder.setNo(no);
        purchaseOrder.setNo(generatedNo ? null : head.getNo().trim());
        purchaseOrder.setSupplierId(supplierId);
        purchaseOrder.setOrderTime(parseImportOrderTime(head.getOrderTime()));
        purchaseOrder.setRemark(head.getRemark());
        purchaseOrder.setStatus(Boolean.TRUE.equals(directApprove)
                ? ErpAuditStatus.APPROVE.getStatus() : ErpAuditStatus.PROCESS.getStatus());
        purchaseOrder.setDepositPrice(BigDecimal.ZERO); // deposit_price ä¸º NOT NULL ä¸”无默认值,必须显式赋值
        purchaseOrder.setInCount(BigDecimal.ZERO);
        purchaseOrder.setInStatus(ErpPurchaseOrderInStatusEnum.NOT_IN.getStatus());
        List<ErpPurchaseOrderItemDO> itemDOs = validatePurchaseOrderItems(items);
        calculateTotalPrice(purchaseOrder, itemDOs);
        return new ImportOrderData(purchaseOrder, itemDOs,
                buildOrderFingerprint(supplierId, itemDOs), generatedNo,
                StrUtil.isNotBlank(head.getOrderTime()));
    }
    /**
     * æ–°å¢žå¯¼å…¥è®¢å•(含明细)。
     */
    private void createPurchaseOrderByImport(ImportOrderData importData, Boolean directApprove) {
        ErpPurchaseOrderDO purchaseOrder = importData.order;
        if (StrUtil.isBlank(purchaseOrder.getNo())) {
            purchaseOrder.setNo(noRedisDAO.generate(ErpNoRedisDAO.PURCHASE_ORDER_NO_PREFIX));
        }
        purchaseOrder.setStatus(Boolean.TRUE.equals(directApprove)
                ? ErpAuditStatus.APPROVE.getStatus() : ErpAuditStatus.PROCESS.getStatus());
        purchaseOrder.setInCount(BigDecimal.ZERO);
        purchaseOrder.setInStatus(ErpPurchaseOrderInStatusEnum.NOT_IN.getStatus());
        purchaseOrderMapper.insert(purchaseOrder);
        itemDOs.forEach(item -> item.setOrderId(purchaseOrder.getId()));
        purchaseOrderItemMapper.insertBatch(itemDOs);
        return purchaseOrder;
        importData.items.forEach(item -> item.setOrderId(purchaseOrder.getId()));
        purchaseOrderItemMapper.insertBatch(importData.items);
    }
    /**
     * è¦†ç›–已有导入订单(含明细)。
     */
    private void updatePurchaseOrderByImport(ErpPurchaseOrderDO existing, ImportOrderData importData,
                                             Boolean directApprove) {
        // 1. æœ‰ä¸‹æ¸¸å•据的订单不允许覆盖,避免把已入库/已开票的数据改掉
        if (existing.getInCount() != null && existing.getInCount().compareTo(BigDecimal.ZERO) > 0) {
            throw new IllegalArgumentException("采购订单[" + existing.getNo() + "]已入库(已入库数量 "
                    + normalizeNumber(existing.getInCount()) + "),不能覆盖;请先删除对应入库记录后再导入");
        }
        if (existing.getReturnCount() != null && existing.getReturnCount().compareTo(BigDecimal.ZERO) > 0) {
            throw new IllegalArgumentException("采购订单[" + existing.getNo() + "]已退货(已退货数量 "
                    + normalizeNumber(existing.getReturnCount()) + "),不能覆盖;请先删除对应退货记录后再导入");
        }
        if (CollUtil.isNotEmpty(invoiceOrderMapper.selectListByPurchaseOrderId(existing.getId()))) {
            throw new IllegalArgumentException("采购订单[" + existing.getNo()
                    + "]已关联发票,不能覆盖;请先解除发票关联后再导入");
        }
        if (StrUtil.isNotBlank(existing.getProcessInstanceId())) {
            throw new IllegalArgumentException("采购订单[" + existing.getNo()
                    + "]已提交审批流程,不能覆盖;请先在协同办公撤回或作废该审批后再导入");
        }
        List<ErpPurchaseOrderItemDO> oldItems = purchaseOrderItemMapper.selectListByOrderId(existing.getId());
        boolean linkedRequest = oldItems.stream().anyMatch(item -> item.getRequestItemId() != null);
        if (linkedRequest) {
            throw new IllegalArgumentException("采购订单[" + existing.getNo()
                    + "]已关联采购申请,不能覆盖;请先在采购申请中撤销或删除对应申请后再导入");
        }
        // 2. æ›´æ–°ä¸»è¡¨ï¼ˆä¿ç•™åŽŸå•å·ã€åŽŸå…¥åº“çŠ¶æ€ï¼‰
        ErpPurchaseOrderDO updateObj = importData.order;
        updateObj.setId(existing.getId());
        updateObj.setNo(existing.getNo());
        // æ–‡ä»¶æ²¡å¡«é‡‡è´­æ—¶é—´æ—¶ä¿ç•™åŽŸè®¢å•æ—¶é—´ï¼Œé¿å…è¦†ç›–æ—¶æŠŠæ—¥æœŸæ”¹æˆå½“å‰æ—¶é—´
        if (!importData.orderTimeProvided) {
            updateObj.setOrderTime(existing.getOrderTime());
        }
        updateObj.setStatus(Boolean.TRUE.equals(directApprove)
                ? ErpAuditStatus.APPROVE.getStatus() : ErpAuditStatus.PROCESS.getStatus());
        updateObj.setInCount(existing.getInCount() == null ? BigDecimal.ZERO : existing.getInCount());
        updateObj.setInStatus(existing.getInStatus());
        purchaseOrderMapper.updateById(updateObj);
        // 3. æ˜Žç»†æ•´ç»„替换
        purchaseOrderItemMapper.deleteByOrderId(existing.getId());
        importData.items.forEach(item -> item.setOrderId(existing.getId()));
        purchaseOrderItemMapper.insertBatch(importData.items);
    }
    private ErpPurchaseOrderImportDuplicateVO buildImportDuplicate(String label, ErpPurchaseOrderDO existing,
                                                                   String reason) {
        return ErpPurchaseOrderImportDuplicateVO.builder()
                .key(label)
                .existingNo(existing.getNo())
                .existingId(existing.getId())
                .reason(reason)
                .build();
    }
    /**
     * ç”Ÿæˆâ€œä¾›åº”商 + å®Œæ•´æ˜Žç»†â€çš„订单指纹,用于识别重复导入。
     * æ˜Žç»†æŒ‰ç‰©æ–™ã€æ•°é‡ã€å•价、税率排序后比较,不受行顺序影响。
     */
    private String buildOrderFingerprint(Long supplierId, List<ErpPurchaseOrderItemDO> items) {
        if (supplierId == null || CollUtil.isEmpty(items)) {
            return null;
        }
        List<String> parts = items.stream()
                .map(item -> String.join(":",
                        String.valueOf(item.getProductId()),
                        normalizeNumber(item.getCount()),
                        normalizeNumber(item.getProductPrice()),
                        normalizeNumber(item.getTaxPercent())))
                .sorted()
                .toList();
        return supplierId + "|" + String.join(",", parts);
    }
    private String normalizeNumber(BigDecimal value) {
        return value == null ? "" : value.stripTrailingZeros().toPlainString();
    }
    private static class ImportOrderData {
        private final ErpPurchaseOrderDO order;
        private final List<ErpPurchaseOrderItemDO> items;
        private final String fingerprint;
        /**
         * é‡‡è´­å•编号是否由系统自动生成(文件里留空)
         */
        private final boolean generatedNo;
        /**
         * æ–‡ä»¶é‡Œæ˜¯å¦å¡«å†™äº†é‡‡è´­æ—¶é—´
         */
        private final boolean orderTimeProvided;
        private ImportOrderData(ErpPurchaseOrderDO order, List<ErpPurchaseOrderItemDO> items, String fingerprint,
                                boolean generatedNo, boolean orderTimeProvided) {
            this.order = order;
            this.items = items;
            this.fingerprint = fingerprint;
            this.generatedNo = generatedNo;
            this.orderTimeProvided = orderTimeProvided;
        }
    }
    /**
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseRequestService.java
@@ -5,6 +5,7 @@
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.request.ErpPurchaseRequestSaveReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.request.ErpPurchaseRequestImportExcelVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.request.ErpPurchaseRequestImportRespVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.request.ErpPurchaseRequestSubmitBatchRespVO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseRequestDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseRequestItemDO;
import jakarta.validation.Valid;
@@ -90,6 +91,19 @@
    void submitPurchaseRequest(Long id, String processDefinitionKey, Long userId);
    /**
     * æ‰¹é‡æäº¤é‡‡è´­ç”³è¯·å®¡æ‰¹
     *
     * é€å•提交,单张失败不影响其它申请;每张申请独立事务。
     *
     * @param ids                  é‡‡è´­ç”³è¯·ç¼–号数组
     * @param processDefinitionKey æµç¨‹å®šä¹‰ Key
     * @param userId               ç”¨æˆ·ç¼–号
     * @return æ‰¹é‡æäº¤ç»“果(成功编号 + å¤±è´¥åŽŸå› ï¼‰
     */
    ErpPurchaseRequestSubmitBatchRespVO submitPurchaseRequestBatch(List<Long> ids, String processDefinitionKey,
                                                                   Long userId);
    /**
     * èŽ·å–é‡‡è´­ç”³è¯·å®¡æ‰¹æµç¨‹åˆ—è¡¨
     *
     * @return æµç¨‹å®šä¹‰åˆ—表
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseRequestServiceImpl.java
@@ -17,6 +17,7 @@
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.request.ErpPurchaseRequestSaveReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.request.ErpPurchaseRequestImportExcelVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.request.ErpPurchaseRequestImportRespVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.request.ErpPurchaseRequestSubmitBatchRespVO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseOrderDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseOrderItemDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseRequestDO;
@@ -39,6 +40,8 @@
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.flowable.engine.repository.ProcessDefinition;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
@@ -100,6 +103,13 @@
    private DeptApi deptApi;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    /**
     * è‡ªèº«ä»£ç†ï¼šæ‰¹é‡æäº¤æ—¶é€å•调用 {@link #submitPurchaseRequest},保证每张申请独立事务
     */
    @Autowired
    @Lazy
    private ErpPurchaseRequestService self;
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -450,13 +460,17 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void submitPurchaseRequest(Long id, String processDefinitionKey, Long userId) {
        // 1. æ ¡éªŒå­˜åœ¨ + è‰ç¨¿çŠ¶æ€
        ErpPurchaseRequestDO purchaseRequest = validatePurchaseRequestCanModify(id);
        // 1. æ ¡éªŒå­˜åœ¨ + è‰ç¨¿/驳回状态
        ErpPurchaseRequestDO purchaseRequest = validatePurchaseRequestExists(id);
        if (!ErpPurchaseRequestStatusEnum.DRAFT.getStatus().equals(purchaseRequest.getStatus())
                && !ErpPurchaseRequestStatusEnum.REJECT.getStatus().equals(purchaseRequest.getStatus())) {
            throw exception(PURCHASE_REQUEST_SUBMIT_FAIL_NOT_DRAFT, purchaseRequest.getNo());
        }
        // 2. æ ¡éªŒæ˜¯å¦æœ‰æ˜Žç»†
        List<ErpPurchaseRequestItemDO> items = purchaseRequestItemMapper.selectListByRequestId(id);
        if (CollUtil.isEmpty(items)) {
            throw exception(PURCHASE_REQUEST_NOT_EXISTS); // TODO: æ·»åŠ æ˜Žç»†ä¸ºç©ºçš„é”™è¯¯ç 
            throw exception(PURCHASE_REQUEST_ITEM_EMPTY, purchaseRequest.getNo());
        }
        // 3. åˆ›å»º BPM æµç¨‹å®žä¾‹
@@ -470,6 +484,52 @@
                .setId(id)
                .setStatus(ErpPurchaseRequestStatusEnum.PROCESS.getStatus())
                .setProcessInstanceId(processInstanceId));
    }
    @Override
    public ErpPurchaseRequestSubmitBatchRespVO submitPurchaseRequestBatch(List<Long> ids,
                                                                          String processDefinitionKey,
                                                                          Long userId) {
        ErpPurchaseRequestSubmitBatchRespVO respVO = ErpPurchaseRequestSubmitBatchRespVO.builder()
                .successIds(new ArrayList<>())
                .failureMap(new LinkedHashMap<>())
                .build();
        if (CollUtil.isEmpty(ids)) {
            return respVO;
        }
        List<Long> distinctIds = ids.stream().filter(Objects::nonNull).distinct().toList();
        if (CollUtil.isEmpty(distinctIds)) {
            return respVO;
        }
        // 1. é¢„热申请单号,失败时前端可直接看到是哪张申请单
        Map<Long, String> noMap = purchaseRequestMapper.selectRequestListByIds(distinctIds).stream()
                .filter(request -> request.getId() != null && StrUtil.isNotBlank(request.getNo()))
                .collect(java.util.stream.Collectors.toMap(ErpPurchaseRequestDO::getId, ErpPurchaseRequestDO::getNo,
                        (a, b) -> a));
        // 2. é€å•提交:单张失败不影响其它申请,通过 self ä»£ç†ä¿è¯æ¯å¼ ç‹¬ç«‹äº‹åŠ¡
        for (Long id : distinctIds) {
            String label = noMap.getOrDefault(id, "编号 " + id);
            try {
                self.submitPurchaseRequest(id, processDefinitionKey, userId);
                respVO.getSuccessIds().add(id);
            } catch (Exception ex) {
                String reason = resolveSubmitErrorMessage(ex);
                if (respVO.getFailureMap().containsKey(label)) {
                    label = label + "(编号 " + id + ")";
                }
                respVO.getFailureMap().put(label, reason);
            }
        }
        return respVO;
    }
    private String resolveSubmitErrorMessage(Exception ex) {
        if (ex instanceof cn.iocoder.yudao.framework.common.exception.ServiceException serviceException) {
            return serviceException.getMessage();
        }
        return StrUtil.blankToDefault(ex.getMessage(), "提交失败");
    }
    @Override
@@ -572,10 +632,20 @@
        // 1. èŽ·å–ç”³è¯·æ˜Žç»†
        List<ErpPurchaseRequestItemDO> requestItems = purchaseRequestItemMapper.selectListByRequestId(id);
        if (CollUtil.isEmpty(requestItems)) {
            throw exception(PURCHASE_REQUEST_NOT_EXISTS);
            throw exception(PURCHASE_REQUEST_ITEM_EMPTY, purchaseRequest.getNo());
        }
        // 2. ç”Ÿæˆé‡‡è´­è®¢å•号
        // 2. å…ˆå°è¯•复用系统内已存在且供应商、明细完全一致的已审核采购订单(通常来自采购订单导入),
        //    é¿å…å®¡æ‰¹é€šè¿‡åŽåˆç”Ÿæˆä¸€å¼ é‡å¤è®¢å•
        ErpPurchaseOrderDO reusableOrder = findReusablePurchaseOrder(purchaseRequest, requestItems);
        if (reusableOrder != null) {
            linkPurchaseRequestToOrder(purchaseRequest, requestItems, reusableOrder);
            log.info("[doGeneratePurchaseOrder] é‡‡è´­ç”³è¯·({}) å¤ç”¨å·²æœ‰é‡‡è´­è®¢å•({}),未重复生成",
                    id, reusableOrder.getNo());
            return reusableOrder.getId();
        }
        // 3. ç”Ÿæˆé‡‡è´­è®¢å•号
        String orderNo = noRedisDAO.generate(ErpNoRedisDAO.PURCHASE_ORDER_NO_PREFIX);
        if (purchaseOrderMapper.selectByNo(orderNo) != null) {
            throw exception(PURCHASE_ORDER_NO_EXISTS);
@@ -628,6 +698,143 @@
    }
    /**
     * æŸ¥æ‰¾å¯å¤ç”¨çš„采购订单:供应商相同,且明细(物料、数量、单价、税率)与申请完全一致。
     *
     * åªåŒ¹é…å·²å®¡æ ¸è®¢å•,避免把申请挂到尚未审核的订单上;优先选择尚未关联采购申请的导入订单,
     * å…¶æ¬¡é€‰æ‹©ç¼–号最小的订单。匹配到的订单会加行锁,防止并发审批时两张申请同时占用同一张订单。
     */
    private ErpPurchaseOrderDO findReusablePurchaseOrder(ErpPurchaseRequestDO purchaseRequest,
                                                         List<ErpPurchaseRequestItemDO> requestItems) {
        if (purchaseRequest.getSupplierId() == null || CollUtil.isEmpty(requestItems)) {
            return null;
        }
        String targetFingerprint = buildRequestFingerprint(purchaseRequest.getSupplierId(), requestItems);
        if (targetFingerprint == null) {
            return null;
        }
        // 1. æŸ¥è¯¢åŒä¾›åº”商的已审核订单,按指纹筛选完全一致的
        List<ErpPurchaseOrderDO> orders = purchaseOrderMapper.selectList(
                purchaseRequest.getSupplierId(), ErpAuditStatus.APPROVE.getStatus());
        if (CollUtil.isEmpty(orders)) {
            return null;
        }
        Map<Long, List<ErpPurchaseOrderItemDO>> orderItemMap = purchaseOrderItemMapper.selectListByOrderIds(
                        orders.stream().map(ErpPurchaseOrderDO::getId).toList()).stream()
                .collect(java.util.stream.Collectors.groupingBy(ErpPurchaseOrderItemDO::getOrderId));
        List<ErpPurchaseOrderDO> candidates = orders.stream()
                .filter(order -> targetFingerprint.equals(
                        buildOrderFingerprint(order.getSupplierId(), orderItemMap.get(order.getId()))))
                // å·²å…¥åº“/已退货的订单说明这笔采购已经执行过,不能拿来顶新申请,否则新需求会被“吃掉”
                .filter(order -> order.getInCount() == null
                        || order.getInCount().compareTo(BigDecimal.ZERO) == 0)
                .filter(order -> order.getReturnCount() == null
                        || order.getReturnCount().compareTo(BigDecimal.ZERO) == 0)
                .sorted(Comparator
                        .comparing((ErpPurchaseOrderDO order) ->
                                hasLinkedRequestItem(orderItemMap.get(order.getId())) ? 1 : 0)
                        .thenComparing(ErpPurchaseOrderDO::getId))
                .toList();
        if (CollUtil.isEmpty(candidates)) {
            return null;
        }
        if (candidates.size() > 1) {
            log.warn("[findReusablePurchaseOrder] é‡‡è´­ç”³è¯·({}) åŒ¹é…åˆ° {} å¼ ç›¸åŒè®¢å•:{},将复用最早的一张",
                    purchaseRequest.getId(), candidates.size(),
                    candidates.stream().map(ErpPurchaseOrderDO::getNo).toList());
        }
        // 2. åŠ è¡Œé”åŽé‡æ–°åŠ è½½ï¼Œé¿å…å¹¶å‘å®¡æ‰¹é‡å¤å ç”¨
        ErpPurchaseOrderDO candidate = purchaseOrderMapper.selectByIdForUpdate(candidates.get(0).getId());
        if (candidate == null || !ErpAuditStatus.APPROVE.getStatus().equals(candidate.getStatus())) {
            return null;
        }
        return candidate;
    }
    /**
     * å°†é‡‡è´­ç”³è¯·å…³è”到已存在的采购订单,并把订单明细回填申请明细编号。
     * å·²è¢«å…¶å®ƒç”³è¯·å…³è”过的明细不重复回填,只建立申请单与订单的关联。
     */
    private void linkPurchaseRequestToOrder(ErpPurchaseRequestDO purchaseRequest,
                                            List<ErpPurchaseRequestItemDO> requestItems,
                                            ErpPurchaseOrderDO purchaseOrder) {
        List<ErpPurchaseOrderItemDO> orderItems = purchaseOrderItemMapper.selectListByOrderId(purchaseOrder.getId());
        List<ErpPurchaseOrderItemDO> remaining = new ArrayList<>(orderItems);
        List<ErpPurchaseOrderItemDO> updates = new ArrayList<>();
        for (ErpPurchaseRequestItemDO requestItem : requestItems) {
            ErpPurchaseOrderItemDO match = remaining.stream()
                    .filter(orderItem -> orderItem.getRequestItemId() == null)
                    .filter(orderItem -> Objects.equals(orderItem.getProductId(), requestItem.getProductId())
                            && numberEquals(orderItem.getCount(), requestItem.getCount())
                            && numberEquals(orderItem.getProductPrice(), requestItem.getProductPrice())
                            && numberEquals(orderItem.getTaxPercent(), requestItem.getTaxPercent()))
                    .findFirst().orElse(null);
            if (match != null) {
                match.setRequestItemId(requestItem.getId());
                updates.add(match);
                remaining.remove(match);
            }
        }
        if (CollUtil.isNotEmpty(updates)) {
            purchaseOrderItemMapper.updateBatch(updates);
        }
        purchaseRequestMapper.updateById(new ErpPurchaseRequestDO()
                .setId(purchaseRequest.getId())
                .setOrderId(purchaseOrder.getId())
                .setOrderNo(purchaseOrder.getNo()));
        log.info("[linkPurchaseRequestToOrder] é‡‡è´­ç”³è¯·({}) å…³è”采购订单({}),回填明细 {} æ¡",
                purchaseRequest.getId(), purchaseOrder.getNo(), updates.size());
    }
    private String buildRequestFingerprint(Long supplierId, List<ErpPurchaseRequestItemDO> items) {
        if (supplierId == null || CollUtil.isEmpty(items)) {
            return null;
        }
        return buildFingerprint(supplierId, items.stream()
                .map(item -> fingerprintPart(item.getProductId(), item.getCount(),
                        item.getProductPrice(), item.getTaxPercent()))
                .toList());
    }
    private String buildOrderFingerprint(Long supplierId, List<ErpPurchaseOrderItemDO> items) {
        if (supplierId == null || CollUtil.isEmpty(items)) {
            return null;
        }
        return buildFingerprint(supplierId, items.stream()
                .map(item -> fingerprintPart(item.getProductId(), item.getCount(),
                        item.getProductPrice(), item.getTaxPercent()))
                .toList());
    }
    private String buildFingerprint(Long supplierId, List<String> parts) {
        return supplierId + "|" + parts.stream().sorted().collect(java.util.stream.Collectors.joining(","));
    }
    private String fingerprintPart(Long productId, BigDecimal count, BigDecimal productPrice,
                                   BigDecimal taxPercent) {
        return String.join(":", String.valueOf(productId), normalizeNumber(count),
                normalizeNumber(productPrice), normalizeNumber(taxPercent));
    }
    private String normalizeNumber(BigDecimal value) {
        return value == null ? "" : value.stripTrailingZeros().toPlainString();
    }
    private boolean numberEquals(BigDecimal left, BigDecimal right) {
        if (left == null) {
            return right == null;
        }
        return right != null && left.compareTo(right) == 0;
    }
    private boolean hasLinkedRequestItem(List<ErpPurchaseOrderItemDO> orderItems) {
        return CollUtil.isNotEmpty(orderItems)
                && orderItems.stream().anyMatch(item -> item.getRequestItemId() != null);
    }
    /**
     * æ ¡éªŒé‡‡è´­ç”³è¯·å®¡æ‰¹åˆ†ç±»å’Œæµç¨‹å®šä¹‰æ˜¯å¦å­˜åœ¨
     */
    private void validatePurchaseRequestApproveCategoryAndProcessDefinition() {
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java
@@ -94,6 +94,7 @@
    ErrorCode ATTENDANCE_EXCEPTION_BPM_PROCESS_DEFINITION_NOT_EXISTS = new ErrorCode(1_030_004_015, "考勤异常审批流程不存在");
    ErrorCode ATTENDANCE_CLOCK_ALREADY_CLOCKED = new ErrorCode(1_030_004_016, "已打过卡,请勿重复打卡");
    ErrorCode ATTENDANCE_HOLIDAY_DATE_RANGE_INVALID = new ErrorCode(1_030_004_017, "结束日期【{}】不能早于开始日期【{}】,无法保存。请重新选择正确的日期区间");
    ErrorCode ATTENDANCE_EXCEPTION_EMPLOYEE_NOT_EXISTS = new ErrorCode(1_030_004_018, "新增考勤异常申请失败,当前登录账号({})未关联员工档案,无法确定申请人和部门。请使用已关联员工档案的账号登录,或先在「员工档案」中维护当前账号后再提交");
    // ========== è–ªé…¬ç®¡ç† 1-030-005-000 ==========
    ErrorCode SALARY_STRUCTURE_NOT_EXISTS = new ErrorCode(1_030_005_000, "薪酬结构不存在");
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceExceptionPageReqVO.java
@@ -6,6 +6,8 @@
import lombok.EqualsAndHashCode;
import lombok.ToString;
import java.time.LocalDate;
@Schema(description = "管理后台 - è€ƒå‹¤å¼‚常申请分页 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
@@ -27,4 +29,7 @@
    @Schema(description = "状态", example = "0")
    private Integer status;
    @Schema(description = "异常日期区间,传 [开始日期, ç»“束日期]", example = "[\"2026-01-01\", \"2026-12-31\"]")
    private LocalDate[] date;
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/attendance/HrmAttendanceExceptionMapper.java
@@ -7,6 +7,8 @@
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceExceptionDO;
import org.apache.ibatis.annotations.Mapper;
import java.time.LocalDate;
/**
 * è€ƒå‹¤å¼‚常申请 Mapper
 *
@@ -16,13 +18,18 @@
public interface HrmAttendanceExceptionMapper extends BaseMapperX<HrmAttendanceExceptionDO> {
    default PageResult<HrmAttendanceExceptionDO> selectPage(HrmAttendanceExceptionPageReqVO reqVO) {
        return selectPage(reqVO, new LambdaQueryWrapperX<HrmAttendanceExceptionDO>()
        LambdaQueryWrapperX<HrmAttendanceExceptionDO> query = new LambdaQueryWrapperX<HrmAttendanceExceptionDO>()
                .likeIfPresent(HrmAttendanceExceptionDO::getNo, reqVO.getNo())
                .eqIfPresent(HrmAttendanceExceptionDO::getUserId, reqVO.getUserId())
                .eqIfPresent(HrmAttendanceExceptionDO::getDeptId, reqVO.getDeptId())
                .eqIfPresent(HrmAttendanceExceptionDO::getType, reqVO.getType())
                .eqIfPresent(HrmAttendanceExceptionDO::getStatus, reqVO.getStatus())
                .orderByDesc(HrmAttendanceExceptionDO::getId));
                .eqIfPresent(HrmAttendanceExceptionDO::getStatus, reqVO.getStatus());
        // å¼‚常日期区间筛选
        LocalDate[] dateRange = reqVO.getDate();
        if (dateRange != null && dateRange.length >= 2 && dateRange[0] != null && dateRange[1] != null) {
            query.between(HrmAttendanceExceptionDO::getExceptionDate, dateRange[0], dateRange[1]);
        }
        return selectPage(reqVO, query.orderByDesc(HrmAttendanceExceptionDO::getId));
    }
    default HrmAttendanceExceptionDO selectByNo(String no) {
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceExceptionServiceImpl.java
@@ -8,8 +8,12 @@
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceExceptionPageReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceExceptionSaveReqVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceExceptionDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeDO;
import cn.iocoder.yudao.module.hrm.dal.mysql.attendance.HrmAttendanceExceptionMapper;
import cn.iocoder.yudao.module.hrm.service.employee.HrmEmployeeService;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import cn.iocoder.yudao.module.hrm.dal.redis.HrmNoRedisDAO;
import cn.iocoder.yudao.module.hrm.enums.HrmAuditStatusEnum;
import jakarta.annotation.Resource;
@@ -36,6 +40,10 @@
    @Resource
    private HrmAttendanceExceptionMapper attendanceExceptionMapper;
    @Resource
    private HrmEmployeeService employeeService;
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private HrmNoRedisDAO noRedisDAO;
    @Resource
    private BpmProcessInstanceApi bpmProcessInstanceApi;
@@ -48,6 +56,18 @@
    public Long createAttendanceException(HrmAttendanceExceptionSaveReqVO createReqVO) {
        HrmAttendanceExceptionDO attendanceException = BeanUtils.toBean(createReqVO, HrmAttendanceExceptionDO.class);
        attendanceException.setNo(generateNo());
        // ç”³è¯·äººé»˜è®¤ä¸ºå½“前登录用户对应的员工,前端表单不选择申请人
        Long loginUserId = cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId();
        HrmEmployeeDO employee = employeeService.getEmployeeByUserId(loginUserId);
        if (employee == null) {
            AdminUserRespDTO loginUser = adminUserApi.getUser(loginUserId);
            throw exception(ATTENDANCE_EXCEPTION_EMPLOYEE_NOT_EXISTS,
                    loginUser != null ? loginUser.getNickname() : String.valueOf(loginUserId));
        }
        attendanceException.setUserId(employee.getId());
        if (attendanceException.getDeptId() == null) {
            attendanceException.setDeptId(employee.getDeptId());
        }
        attendanceException.setStatus(HrmAuditStatusEnum.DRAFT.getStatus());
        attendanceExceptionMapper.insert(attendanceException);
        storageAttachmentApi.bindAttachments("file", "hrm_attendance_exception",