宁夏万通需求:商机状态组改为非必填;过程检验单生产订单、工位改为非必填

商机(/sale/business,代码 crm/business):
- 新增商机时「商机状态组」不再必填;未选状态组则不写默认阶段,也不参与状态组维度统计
- 变更商机阶段时先校验状态组:未设置状态组的商机给出精确提示(新增错误码 1_020_002_005,含商机名称与下一步动作),只允许改「结束状态」
- crm_business.status_type_id / status_id 放宽为可空(配置导出脚本同步)

过程检验单(/qc/ipqc):
- 生产订单、工位改为非必填:VO 去掉必填校验,服务层关联比对改为两边都有值才比
- Excel 导入:生产工单号/工位名称留空即不关联;去重键改用 eq+isNull 成对匹配,留空行也能去重

业务可视化文档(销售流程、质量流程/三列表导入说明)同步。

Co-Authored-By: Claude Code <noreply@anthropic.com>
已修改15个文件
230 ■■■■■ 文件已修改
docs/project-business/data/flows.json 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/modules.json 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/objects.json 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/index.html 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/02-sales-flow.mmd 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/config_export_all_20260928.sql 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/vo/business/CrmBusinessRespVO.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/vo/business/CrmBusinessSaveReqVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/business/CrmBusinessServiceImpl.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/ipqc/vo/MesQcIpqcSaveReqVO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/qc/ipqc/MesQcIpqcMapper.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/imports/MesQcImportHelper.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ipqc/MesQcIpqcServiceImpl.java 155 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/flows.json
@@ -3,7 +3,7 @@
    {"id": "sales", "name": "销售流程", "mermaid": "mermaid/02-sales-flow.mmd", "color": "#4f9cff",
     "summary": "线索转化客户 → 商机推进 → 报价(可AI识别) → 合同 BPM 审批 → 生成 ERP 销售订单并审批 → 需生产行自动建 MPS → 发货通知 → 销售出库(OQC)回写 → 开票(BPM) → 回款(先开票校验)。",
     "steps": [
       {"stage": "获客", "action": "线索转化客户、建联系人、立项商机", "roles": ["销售内勤"]},
       {"stage": "获客", "action": "线索转化客户、建联系人、立项商机(商机状态组/阶段选填,不选也能建单)", "roles": ["销售内勤"]},
       {"stage": "报价", "action": "商机赢单后建报价单(AI OCR 预填),转合同", "roles": ["销售内勤"]},
       {"stage": "签约", "action": "合同来源:手工新建 / 报价单转入 / Excel 导入(一合同多产品明细行);提交 BPM 审批(可选流程定义),通过=20", "roles": ["销售", "审批人"]},
       {"stage": "接单", "action": "合同一键生成销售订单(XSDD),销售订单再走 BPM;审批通过自动为 needProduction 明细创建 MPS", "roles": ["销售", "计划员"]},
@@ -35,7 +35,7 @@
       {"stage": "过程", "action": "报工质检工序产出行→IPQC,合格拆行入库、不合格生成 NCR", "roles": ["IPQC 质检员"]},
       {"stage": "出货", "action": "销售出库提交→OQC,完成后出库单进入待拣货", "roles": ["OQC 质检员"]},
       {"stage": "退货", "action": "退料/销售退货/售后退货→RQC,结论回调单据与售后", "roles": ["RQC 质检员"]},
       {"stage": "导入", "action": "IQC/IPQC/OQC 三列表各支持 Excel 批量导入(按钮在「新增」与「导出」之间,纯新增不做更新覆盖):一行一张单,编号留空自动生成,质检方案留空按物料+检验类型自动匹配,检测人员留空取当前登录用户;物料/供应商/客户/工单/工位按编码或名称精确匹配(IPQC 物料由工单推导,模板不填),合格+不合格=检测/接收数量、IPQC 工废+料废+其他废品≤不合格、OQC 客户须在本人数据权限内;逐行导入部分成功,失败行进 failureList 带行号与可执行动作,成功单一律待检验0;同一批数据不可重复导入:文件内重复行与库内已存在同业务键单据(IQC 物料+供应商+供应商批次+来料日期、IPQC 生产工单+检验类型+工位+检测日期、OQC 物料+客户+批次号+出货日期)均拒该行,提示含维度、双方实际值与「先删除该单据再重试」动作", "roles": ["质检员", "IQC 质检员", "IPQC 质检员", "OQC 质检员"]},
       {"stage": "导入", "action": "IQC/IPQC/OQC 三列表各支持 Excel 批量导入(按钮在「新增」与「导出」之间,纯新增不做更新覆盖):一行一张单,编号留空自动生成,质检方案留空按物料+检验类型自动匹配,检测人员留空取当前登录用户;物料/供应商/客户/工单/工位按编码或名称精确匹配(IPQC 物料由工单推导、生产工单号与工位名称可留空不关联,模板不填物料),合格+不合格=检测/接收数量、IPQC 工废+料废+其他废品≤不合格、OQC 客户须在本人数据权限内;逐行导入部分成功,失败行进 failureList 带行号与可执行动作,成功单一律待检验0;同一批数据不可重复导入:文件内重复行与库内已存在同业务键单据(IQC 物料+供应商+供应商批次+来料日期、IPQC 生产工单+检验类型+工位+检测日期(工单/工位留空按空值比对)、OQC 物料+客户+批次号+出货日期)均拒该行,提示含维度、双方实际值与「先删除该单据再重试」动作", "roles": ["质检员", "IQC 质检员", "IPQC 质检员", "OQC 质检员"]},
       {"stage": "不合格", "action": "NCR: 待评审→评审→处置(退货/报废/返工/让步接收/降级)→关闭", "roles": ["质量工程师"]},
       {"stage": "出件", "action": "两条出件入口:①通用 generate/preview(调用方自带上下文) 渲染 HTML 并冻结数据快照,编号 QR+日期+4位流水按天独立,preview 只渲染不落库不消耗编号,regenerate 用冻结快照重渲且数据与编号不变并作废已归档 PDF,导出 PDF 由后端调 Chromium 打印实例已存的 HTML 后归档附件中心;②generate-from-qc 由质检单直接出件(数据经 MesQcReportApi 从 MES 取,前端不拼上下文),出件前五道校验按业务优先级只报第一处:类型非法/单据不存在/未完成检验/未填判定/模板类型不符/无可用检验项,报告头另带单据原判定 qcResult+qcResultText 与引擎判定并存,响应回 undecidableCount 待判定项数与 warnings(分组标题跳过/指标已删/未录实测值/样品超3),同一张单据可反复出多份", "roles": ["质检员"]},
       {"stage": "建模板", "action": "AI 导入:上传既有检验报告文件(扫描件/照片、电子版 PDF、Word/Excel) → 识别成模板草稿(不落库) → 人工在设计器确认调整后保存为版本;.doc/.xls 旧格式不支持", "roles": ["质检员", "质量工程师"]}]},
docs/project-business/data/modules.json
@@ -11,7 +11,7 @@
    {"key": "crm", "name": "销售管理(客户关系)", "color": "#4f9cff", "tag": "CRM", "flow": "sales",
     "positioning": "销售前端:从线索到回款的客户经营",
     "problem": "客户资产沉淀、商机转化、合同审批与开票回款管控",
     "features": ["线索转化客户", "公海池与领取上限", "联系人", "商机自定义阶段", "跟进记录", "报价单(AI OCR)", "合同(BPM审批→生成销售订单·支持Excel导入)", "开票(BPM审批·支持Excel导入·同一批数据不可重复导入)→回款(先开票校验)", "回款计划", "销售目标", "数据权限三级(负责人/只读/读写)"],
     "features": ["线索转化客户", "公海池与领取上限", "联系人", "商机自定义阶段(状态组选填)", "跟进记录", "报价单(AI OCR)", "合同(BPM审批→生成销售订单·支持Excel导入)", "开票(BPM审批·支持Excel导入·同一批数据不可重复导入)→回款(先开票校验)", "回款计划", "销售目标", "数据权限三级(负责人/只读/读写)"],
     "objects": ["线索", "客户", "联系人", "商机", "报价单", "合同", "发票", "回款", "销售目标"],
     "roles": ["销售内勤", "销售经理", "财务(开票回款)"],
     "upstream": ["主数据(物料)"], "downstream": ["进销存(销售订单)", "售后(客户)", "AI(报价OCR)"],
docs/project-business/data/objects.json
@@ -5,7 +5,7 @@
    {"name": "客户", "module": "销售管理", "table": "crm_customer", "doc": "客户主档,公海池/锁定/成交状态,负责人数据权限", "upstream": ["线索"], "downstream": ["联系人", "商机", "报价单", "合同", "售后工单", "收款单"], "keywords": "客户 customer 公海"},
    {"name": "线索", "module": "销售管理", "table": "crm_clue", "doc": "原始销售线索,仅一次转化为客户", "upstream": [], "downstream": ["客户"], "keywords": "线索 clue"},
    {"name": "联系人", "module": "销售管理", "table": "crm_contact", "doc": "客户下联系人,与商机多对多", "upstream": ["客户"], "downstream": ["商机"], "keywords": "联系人 contact"},
    {"name": "商机", "module": "销售管理", "table": "crm_business", "doc": "销售机会,DB 配置式阶段推进,结束态: 赢/输/无效", "upstream": ["客户"], "downstream": ["报价单", "合同"], "keywords": "商机 business 赢单 输单 阶段"},
    {"name": "商机", "module": "销售管理", "table": "crm_business", "doc": "销售机会,DB 配置式阶段推进(商机状态组/阶段为选填:不选状态组也能建单,此时无默认阶段、不可变更阶段、不参与状态组统计),结束态: 赢/输/无效", "upstream": ["客户"], "downstream": ["报价单", "合同"], "keywords": "商机 business 赢单 输单 阶段 状态组"},
    {"name": "报价单", "module": "销售管理", "table": "crm_sale_quotation", "doc": "QT编号,选 MDM 物料报价,AI OCR 录入,草稿→已转合同", "upstream": ["商机"], "downstream": ["合同"], "keywords": "报价 quotation QT"},
    {"name": "合同", "module": "销售管理", "table": "crm_contract", "doc": "HT编号,BPM 审批(0草稿/10审批中/20通过/30驳回/40取消/50作废),通过后可生成销售订单;支持 Excel 导入(一合同多产品明细行、合并单元格自动拉平,客户与物料按名称/编码校验);产品明细带出物料名称/编码/条码/规格型号/计量单位;删除合同时连带删除产品明细,不留孤儿数据", "upstream": ["报价单"], "downstream": ["销售订单", "回款计划", "开票"], "keywords": "合同 contract 审批 导入"},
    {"name": "发票", "module": "销售管理", "table": "crm_invoice", "doc": "KP编号(可留空自动生成、系统内唯一),BPM 审批,累计开票≤合同额,附件必传;支持 Excel 导入(仅新增不更新,合同须审核通过且本次金额≤合同剩余可开票额;同一批数据不可重复导入——同合同下发票号码相同、或发票号码留空时开票金额+发票抬头+开票日期相同即判重复,文件内重复行与库内已有单都拒该行并回显维度与实际值)", "upstream": ["合同"], "downstream": ["回款"], "keywords": "开票 发票 invoice 导入 去重"},
@@ -36,7 +36,7 @@
    {"name": "库存台账", "module": "仓储", "table": "mes_wm_material_stock", "doc": "物料×仓库×库区×库位×批次×供应商六元组,数量/冻结/占用/在途四量", "upstream": ["全部入库单"], "downstream": ["全部出库单", "事务流水"], "keywords": "库存 台账 stock 可用量"},
    {"name": "批次", "module": "仓储", "table": "mes_wm_batch", "doc": "关联物料/工单/供应商/客户/质量状态;SN 码含批次 uuid;条码支持18种业务对象", "upstream": ["入库单据"], "downstream": ["库存台账"], "keywords": "批次 batch SN 条码 追溯"},
    {"name": "IQC来料检验", "module": "质量", "table": "mes_qc_iqc", "doc": "到货通知/外协入库触发,草稿0→完成4,结论回写来源单据;行操作「出报告」仅在已完成时可见;支持 Excel 批量导入(工具栏「批量导入」,纯新增、导入单为草稿、编号可留空自动生成);同一批数据不可重复导入(物料+供应商+供应商批次+来料日期,文件内重复行与库内已有单都拒该行)", "upstream": ["到货通知", "外协入库", "Excel 批量导入"], "downstream": ["采购入库", "供应商退货", "NCR", "报告实例(出报告)"], "keywords": "IQC 来料检验 出报告 批量导入 导入模板"},
    {"name": "IPQC过程检验", "module": "质量", "table": "mes_qc_ipqc", "doc": "报工质检工序产出行触发,完成回调推进报工与产出入库;行操作「出报告」仅在已完成时可见;支持 Excel 批量导入(产品物料由生产工单推导,模板不填物料;工废+料废+其他废品不得超过不合格品数量);同一批数据不可重复导入(生产工单+检验类型+工位+检测日期,文件内重复行与库内已有单都拒该行)", "upstream": ["报工", "Excel 批量导入"], "downstream": ["产出入库", "NCR", "报告实例(出报告)"], "keywords": "IPQC 过程检验 巡检 出报告 批量导入 导入模板"},
    {"name": "IPQC过程检验", "module": "质量", "table": "mes_qc_ipqc", "doc": "报工质检工序产出行触发,完成回调推进报工与产出入库;行操作「出报告」仅在已完成时可见;新增/修改时生产订单、工位均非必填:留空则不做工单与工位的工序校验、产品物料不再由工单推导(记录可无产品物料);质检方案必填,未选生产订单/生产任务时按「过程检验」种类列出全部启用方案并校验方案本身(存在/启用/种类含过程检验),不再要求与产品物料绑定;有工单/任务时仍按产品物料已绑定方案匹配;支持 Excel 批量导入(生产工单号、工位名称留空即不关联,工废+料废+其他废品不得超过不合格品数量);同一批数据不可重复导入(生产工单+检验类型+工位+检测日期,留空字段按空值比对,文件内重复行与库内已有单都拒该行)", "upstream": ["报工", "Excel 批量导入"], "downstream": ["产出入库", "NCR", "报告实例(出报告)"], "keywords": "IPQC 过程检验 巡检 出报告 批量导入 导入模板"},
    {"name": "OQC出货检验", "module": "质量", "table": "mes_qc_oqc", "doc": "销售出库提交触发,完成推进出库单待拣货;行操作「出报告」仅在已完成时可见;支持 Excel 批量导入(客户须在当前登录用户的客户数据权限范围内);同一批数据不可重复导入(物料+客户+批次号+出货日期,文件内重复行与库内已有单都拒该行)", "upstream": ["销售出库单", "Excel 批量导入"], "downstream": ["销售出库单(推进)", "报告实例(出报告)"], "keywords": "OQC 出货检验 出厂检验 出报告 批量导入 导入模板"},
    {"name": "RQC退货检验", "module": "质量", "table": "mes_qc_rqc", "doc": "生产退料/销售退货/售后退货触发,结论回调售后;行操作「出报告」仅在已完成时可见。措辞已统一为「退货检验」(原菜单父项与 mes_qc_type 字典写「退料检验」,与后端枚举/表注释/菜单权限子项不一致;RQC 三者都覆盖,故取「退货检验」)", "upstream": ["退料", "退货"], "downstream": ["退货入库", "售后回调", "报告实例(出报告)"], "keywords": "RQC 退货检验 退料检验 出报告"},
    {"name": "NCR不合格品", "module": "质量", "table": "mes_qc_ncr", "doc": "待评审0→已评审6→已处置7→已关闭8,处置:退货/报废/返工/让步接收/降级", "upstream": ["IQC/IPQC/OQC/RQC"], "downstream": ["退货/报废/返工(台账)"], "keywords": "NCR 不合格 处置 特采 报废"},
docs/project-business/index.html
@@ -341,7 +341,7 @@
    {"key": "crm", "name": "销售管理(客户关系)", "color": "#4f9cff", "tag": "CRM", "flow": "sales",
     "positioning": "销售前端:从线索到回款的客户经营",
     "problem": "客户资产沉淀、商机转化、合同审批与开票回款管控",
     "features": ["线索转化客户", "公海池与领取上限", "联系人", "商机自定义阶段", "跟进记录", "报价单(AI OCR)", "合同(BPM审批→生成销售订单·支持Excel导入)", "开票(BPM审批·支持Excel导入·同一批数据不可重复导入)→回款(先开票校验)", "回款计划", "销售目标", "数据权限三级(负责人/只读/读写)"],
     "features": ["线索转化客户", "公海池与领取上限", "联系人", "商机自定义阶段(状态组选填)", "跟进记录", "报价单(AI OCR)", "合同(BPM审批→生成销售订单·支持Excel导入)", "开票(BPM审批·支持Excel导入·同一批数据不可重复导入)→回款(先开票校验)", "回款计划", "销售目标", "数据权限三级(负责人/只读/读写)"],
     "objects": ["线索", "客户", "联系人", "商机", "报价单", "合同", "发票", "回款", "销售目标"],
     "roles": ["销售内勤", "销售经理", "财务(开票回款)"],
     "upstream": ["主数据(物料)"], "downstream": ["进销存(销售订单)", "售后(客户)", "AI(报价OCR)"],
@@ -448,7 +448,7 @@
    {"name": "客户", "module": "销售管理", "table": "crm_customer", "doc": "客户主档,公海池/锁定/成交状态,负责人数据权限", "upstream": ["线索"], "downstream": ["联系人", "商机", "报价单", "合同", "售后工单", "收款单"], "keywords": "客户 customer 公海"},
    {"name": "线索", "module": "销售管理", "table": "crm_clue", "doc": "原始销售线索,仅一次转化为客户", "upstream": [], "downstream": ["客户"], "keywords": "线索 clue"},
    {"name": "联系人", "module": "销售管理", "table": "crm_contact", "doc": "客户下联系人,与商机多对多", "upstream": ["客户"], "downstream": ["商机"], "keywords": "联系人 contact"},
    {"name": "商机", "module": "销售管理", "table": "crm_business", "doc": "销售机会,DB 配置式阶段推进,结束态: 赢/输/无效", "upstream": ["客户"], "downstream": ["报价单", "合同"], "keywords": "商机 business 赢单 输单 阶段"},
    {"name": "商机", "module": "销售管理", "table": "crm_business", "doc": "销售机会,DB 配置式阶段推进(商机状态组/阶段为选填:不选状态组也能建单,此时无默认阶段、不可变更阶段、不参与状态组统计),结束态: 赢/输/无效", "upstream": ["客户"], "downstream": ["报价单", "合同"], "keywords": "商机 business 赢单 输单 阶段 状态组"},
    {"name": "报价单", "module": "销售管理", "table": "crm_sale_quotation", "doc": "QT编号,选 MDM 物料报价,AI OCR 录入,草稿→已转合同", "upstream": ["商机"], "downstream": ["合同"], "keywords": "报价 quotation QT"},
    {"name": "合同", "module": "销售管理", "table": "crm_contract", "doc": "HT编号,BPM 审批(0草稿/10审批中/20通过/30驳回/40取消/50作废),通过后可生成销售订单;支持 Excel 导入(一合同多产品明细行、合并单元格自动拉平,客户与物料按名称/编码校验);产品明细带出物料名称/编码/条码/规格型号/计量单位;删除合同时连带删除产品明细,不留孤儿数据", "upstream": ["报价单"], "downstream": ["销售订单", "回款计划", "开票"], "keywords": "合同 contract 审批 导入"},
    {"name": "发票", "module": "销售管理", "table": "crm_invoice", "doc": "KP编号(可留空自动生成、系统内唯一),BPM 审批,累计开票≤合同额,附件必传;支持 Excel 导入(仅新增不更新,合同须审核通过且本次金额≤合同剩余可开票额;同一批数据不可重复导入——同合同下发票号码相同、或发票号码留空时开票金额+发票抬头+开票日期相同即判重复,文件内重复行与库内已有单都拒该行并回显维度与实际值)", "upstream": ["合同"], "downstream": ["回款"], "keywords": "开票 发票 invoice 导入 去重"},
@@ -479,7 +479,7 @@
    {"name": "库存台账", "module": "仓储", "table": "mes_wm_material_stock", "doc": "物料×仓库×库区×库位×批次×供应商六元组,数量/冻结/占用/在途四量", "upstream": ["全部入库单"], "downstream": ["全部出库单", "事务流水"], "keywords": "库存 台账 stock 可用量"},
    {"name": "批次", "module": "仓储", "table": "mes_wm_batch", "doc": "关联物料/工单/供应商/客户/质量状态;SN 码含批次 uuid;条码支持18种业务对象", "upstream": ["入库单据"], "downstream": ["库存台账"], "keywords": "批次 batch SN 条码 追溯"},
    {"name": "IQC来料检验", "module": "质量", "table": "mes_qc_iqc", "doc": "到货通知/外协入库触发,草稿0→完成4,结论回写来源单据;行操作「出报告」仅在已完成时可见;支持 Excel 批量导入(工具栏「批量导入」,纯新增、导入单为草稿、编号可留空自动生成);同一批数据不可重复导入(物料+供应商+供应商批次+来料日期,文件内重复行与库内已有单都拒该行)", "upstream": ["到货通知", "外协入库", "Excel 批量导入"], "downstream": ["采购入库", "供应商退货", "NCR", "报告实例(出报告)"], "keywords": "IQC 来料检验 出报告 批量导入 导入模板"},
    {"name": "IPQC过程检验", "module": "质量", "table": "mes_qc_ipqc", "doc": "报工质检工序产出行触发,完成回调推进报工与产出入库;行操作「出报告」仅在已完成时可见;支持 Excel 批量导入(产品物料由生产工单推导,模板不填物料;工废+料废+其他废品不得超过不合格品数量);同一批数据不可重复导入(生产工单+检验类型+工位+检测日期,文件内重复行与库内已有单都拒该行)", "upstream": ["报工", "Excel 批量导入"], "downstream": ["产出入库", "NCR", "报告实例(出报告)"], "keywords": "IPQC 过程检验 巡检 出报告 批量导入 导入模板"},
    {"name": "IPQC过程检验", "module": "质量", "table": "mes_qc_ipqc", "doc": "报工质检工序产出行触发,完成回调推进报工与产出入库;行操作「出报告」仅在已完成时可见;新增/修改时生产订单、工位均非必填:留空则不做工单与工位的工序校验、产品物料不再由工单推导(记录可无产品物料);质检方案必填,未选生产订单/生产任务时按「过程检验」种类列出全部启用方案并校验方案本身(存在/启用/种类含过程检验),不再要求与产品物料绑定;有工单/任务时仍按产品物料已绑定方案匹配;支持 Excel 批量导入(生产工单号、工位名称留空即不关联,工废+料废+其他废品不得超过不合格品数量);同一批数据不可重复导入(生产工单+检验类型+工位+检测日期,留空字段按空值比对,文件内重复行与库内已有单都拒该行)", "upstream": ["报工", "Excel 批量导入"], "downstream": ["产出入库", "NCR", "报告实例(出报告)"], "keywords": "IPQC 过程检验 巡检 出报告 批量导入 导入模板"},
    {"name": "OQC出货检验", "module": "质量", "table": "mes_qc_oqc", "doc": "销售出库提交触发,完成推进出库单待拣货;行操作「出报告」仅在已完成时可见;支持 Excel 批量导入(客户须在当前登录用户的客户数据权限范围内);同一批数据不可重复导入(物料+客户+批次号+出货日期,文件内重复行与库内已有单都拒该行)", "upstream": ["销售出库单", "Excel 批量导入"], "downstream": ["销售出库单(推进)", "报告实例(出报告)"], "keywords": "OQC 出货检验 出厂检验 出报告 批量导入 导入模板"},
    {"name": "RQC退货检验", "module": "质量", "table": "mes_qc_rqc", "doc": "生产退料/销售退货/售后退货触发,结论回调售后;行操作「出报告」仅在已完成时可见。措辞已统一为「退货检验」(原菜单父项与 mes_qc_type 字典写「退料检验」,与后端枚举/表注释/菜单权限子项不一致;RQC 三者都覆盖,故取「退货检验」)", "upstream": ["退料", "退货"], "downstream": ["退货入库", "售后回调", "报告实例(出报告)"], "keywords": "RQC 退货检验 退料检验 出报告"},
    {"name": "NCR不合格品", "module": "质量", "table": "mes_qc_ncr", "doc": "待评审0→已评审6→已处置7→已关闭8,处置:退货/报废/返工/让步接收/降级", "upstream": ["IQC/IPQC/OQC/RQC"], "downstream": ["退货/报废/返工(台账)"], "keywords": "NCR 不合格 处置 特采 报废"},
@@ -498,7 +498,7 @@
    {"id": "sales", "name": "销售流程", "mermaid": "mermaid/02-sales-flow.mmd", "color": "#4f9cff",
     "summary": "线索转化客户 → 商机推进 → 报价(可AI识别) → 合同 BPM 审批 → 生成 ERP 销售订单并审批 → 需生产行自动建 MPS → 发货通知 → 销售出库(OQC)回写 → 开票(BPM) → 回款(先开票校验)。",
     "steps": [
       {"stage": "获客", "action": "线索转化客户、建联系人、立项商机", "roles": ["销售内勤"]},
       {"stage": "获客", "action": "线索转化客户、建联系人、立项商机(商机状态组/阶段选填,不选也能建单)", "roles": ["销售内勤"]},
       {"stage": "报价", "action": "商机赢单后建报价单(AI OCR 预填),转合同", "roles": ["销售内勤"]},
       {"stage": "签约", "action": "合同来源:手工新建 / 报价单转入 / Excel 导入(一合同多产品明细行);提交 BPM 审批(可选流程定义),通过=20", "roles": ["销售", "审批人"]},
       {"stage": "接单", "action": "合同一键生成销售订单(XSDD),销售订单再走 BPM;审批通过自动为 needProduction 明细创建 MPS", "roles": ["销售", "计划员"]},
@@ -530,7 +530,7 @@
       {"stage": "过程", "action": "报工质检工序产出行→IPQC,合格拆行入库、不合格生成 NCR", "roles": ["IPQC 质检员"]},
       {"stage": "出货", "action": "销售出库提交→OQC,完成后出库单进入待拣货", "roles": ["OQC 质检员"]},
       {"stage": "退货", "action": "退料/销售退货/售后退货→RQC,结论回调单据与售后", "roles": ["RQC 质检员"]},
       {"stage": "导入", "action": "IQC/IPQC/OQC 三列表各支持 Excel 批量导入(按钮在「新增」与「导出」之间,纯新增不做更新覆盖):一行一张单,编号留空自动生成,质检方案留空按物料+检验类型自动匹配,检测人员留空取当前登录用户;物料/供应商/客户/工单/工位按编码或名称精确匹配(IPQC 物料由工单推导,模板不填),合格+不合格=检测/接收数量、IPQC 工废+料废+其他废品≤不合格、OQC 客户须在本人数据权限内;逐行导入部分成功,失败行进 failureList 带行号与可执行动作,成功单一律待检验0;同一批数据不可重复导入:文件内重复行与库内已存在同业务键单据(IQC 物料+供应商+供应商批次+来料日期、IPQC 生产工单+检验类型+工位+检测日期、OQC 物料+客户+批次号+出货日期)均拒该行,提示含维度、双方实际值与「先删除该单据再重试」动作", "roles": ["质检员", "IQC 质检员", "IPQC 质检员", "OQC 质检员"]},
       {"stage": "导入", "action": "IQC/IPQC/OQC 三列表各支持 Excel 批量导入(按钮在「新增」与「导出」之间,纯新增不做更新覆盖):一行一张单,编号留空自动生成,质检方案留空按物料+检验类型自动匹配,检测人员留空取当前登录用户;物料/供应商/客户/工单/工位按编码或名称精确匹配(IPQC 物料由工单推导、生产工单号与工位名称可留空不关联,模板不填物料),合格+不合格=检测/接收数量、IPQC 工废+料废+其他废品≤不合格、OQC 客户须在本人数据权限内;逐行导入部分成功,失败行进 failureList 带行号与可执行动作,成功单一律待检验0;同一批数据不可重复导入:文件内重复行与库内已存在同业务键单据(IQC 物料+供应商+供应商批次+来料日期、IPQC 生产工单+检验类型+工位+检测日期(工单/工位留空按空值比对)、OQC 物料+客户+批次号+出货日期)均拒该行,提示含维度、双方实际值与「先删除该单据再重试」动作", "roles": ["质检员", "IQC 质检员", "IPQC 质检员", "OQC 质检员"]},
       {"stage": "不合格", "action": "NCR: 待评审→评审→处置(退货/报废/返工/让步接收/降级)→关闭", "roles": ["质量工程师"]},
       {"stage": "出件", "action": "两条出件入口:①通用 generate/preview(调用方自带上下文) 渲染 HTML 并冻结数据快照,编号 QR+日期+4位流水按天独立,preview 只渲染不落库不消耗编号,regenerate 用冻结快照重渲且数据与编号不变并作废已归档 PDF,导出 PDF 由后端调 Chromium 打印实例已存的 HTML 后归档附件中心;②generate-from-qc 由质检单直接出件(数据经 MesQcReportApi 从 MES 取,前端不拼上下文),出件前五道校验按业务优先级只报第一处:类型非法/单据不存在/未完成检验/未填判定/模板类型不符/无可用检验项,报告头另带单据原判定 qcResult+qcResultText 与引擎判定并存,响应回 undecidableCount 待判定项数与 warnings(分组标题跳过/指标已删/未录实测值/样品超3),同一张单据可反复出多份", "roles": ["质检员"]},
       {"stage": "建模板", "action": "AI 导入:上传既有检验报告文件(扫描件/照片、电子版 PDF、Word/Excel) → 识别成模板草稿(不落库) → 人工在设计器确认调整后保存为版本;.doc/.xls 旧格式不支持", "roles": ["质检员", "质量工程师"]}]},
@@ -691,7 +691,7 @@
  A[线索 crm_clue] -->|转化·仅一次| B[客户 crm_customer]
  B --> C[联系人 crm_contact]
  B --> D[商机 crm_business]
  D -->|自定义阶段推进| E{商机结束}
  D -->|自定义阶段推进(状态组选填)| E{商机结束}
  E -->|赢单| F[报价单 QT·草稿0→已转合同10]
  E -->|输单或无效| X[结束]
  F -->|转合同·带入客户产品折扣| G[合同 HT·草稿0→审批中10]
docs/project-business/mermaid/02-sales-flow.mmd
@@ -2,7 +2,7 @@
  A[线索 crm_clue] -->|转化·仅一次| B[客户 crm_customer]
  B --> C[联系人 crm_contact]
  B --> D[商机 crm_business]
  D -->|自定义阶段推进| E{商机结束}
  D -->|自定义阶段推进(状态组选填)| E{商机结束}
  E -->|赢单| F[报价单 QT·草稿0→已转合同10]
  E -->|输单或无效| X[结束]
  F -->|转合同·带入客户产品折扣| G[合同 HT·草稿0→审批中10]
docs/sql/config_export_all_20260928.sql
@@ -2209,8 +2209,8 @@
  `contact_last_time` datetime DEFAULT NULL COMMENT '最后跟进时间',
  `contact_next_time` datetime DEFAULT NULL COMMENT '下次联系时间',
  `owner_user_id` bigint DEFAULT NULL COMMENT '负责人的用户编号',
  `status_type_id` bigint NOT NULL COMMENT '商机状态组编号',
  `status_id` bigint NOT NULL COMMENT '商机状态编号',
  `status_type_id` bigint DEFAULT NULL COMMENT '商机状态组编号',
  `status_id` bigint DEFAULT NULL COMMENT '商机状态编号',
  `end_status` int DEFAULT NULL COMMENT '结束状态',
  `end_remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '结束时的备注',
  `deal_time` datetime DEFAULT NULL COMMENT '预计成交日期',
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/vo/business/CrmBusinessRespVO.java
@@ -52,15 +52,15 @@
    @ExcelProperty("负责人部门")
    private String ownerUserDeptName;
    @Schema(description = "商机状态组编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "25714")
    @Schema(description = "商机状态组编号(未设置状态组时为空)", example = "25714")
    private Long statusTypeId;
    @Schema(description = "商机状组名字", requiredMode = Schema.RequiredMode.REQUIRED, example = "进行中")
    @Schema(description = "商机状组名字", example = "进行中")
    @ExcelProperty("商机状态组")
    private String statusTypeName;
    @Schema(description = "商机状态编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "30320")
    @Schema(description = "商机状态编号(未设置状态组时为空)", example = "30320")
    private Long statusId;
    @Schema(description = "状态名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "跟进中")
    @Schema(description = "状态名称", example = "跟进中")
    @ExcelProperty("商机状态")
    private String statusName;
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/vo/business/CrmBusinessSaveReqVO.java
@@ -43,9 +43,8 @@
    @DiffLogField(name = "负责人", function = SysAdminUserParseFunction.NAME)
    private Long ownerUserId;
    @Schema(description = "商机状态组编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "25714")
    @Schema(description = "商机状态组编号(非必填:未选择时不带默认阶段,也不参与状态组统计)", example = "25714")
    @DiffLogField(name = "商机状态组")
    @NotNull(message = "商机状态组不能为空")
    private Long statusTypeId;
    @Schema(description = "预计成交日期")
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java
@@ -31,6 +31,7 @@
    ErrorCode BUSINESS_UPDATE_STATUS_FAIL_END_STATUS = new ErrorCode(1_020_002_002, "更新商机状态失败,原因:已经是结束状态");
    ErrorCode BUSINESS_UPDATE_STATUS_FAIL_STATUS_EQUALS = new ErrorCode(1_020_002_003, "更新商机状态失败,原因:已经是该状态");
    ErrorCode BUSINESS_CONTACT_CUSTOMER_NOT_MATCH = new ErrorCode(1_020_002_004, "商机关联的联系人不属于该客户");
    ErrorCode BUSINESS_UPDATE_STATUS_FAIL_NO_STATUS_TYPE = new ErrorCode(1_020_002_005, "商机【{}】未设置「商机状态组」,无法变更「商机阶段」。请在新增商机时选择「商机状态组」(需先在【营销管理 - 系统配置 - 商机状态配置】中创建状态组),或改为只变更「结束状态」");
    // ========== 联系人管理 1-020-003-000 ==========
    ErrorCode CONTACT_NOT_EXISTS = new ErrorCode(1_020_003_000, "联系人不存在");
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/business/CrmBusinessServiceImpl.java
@@ -100,7 +100,11 @@
        // 2.1 插入商机
        CrmBusinessDO business = BeanUtils.toBean(createReqVO, CrmBusinessDO.class);
        business.setStatusId(businessStatusService.getBusinessStatusListByTypeId(createReqVO.getStatusTypeId()).get(0).getId()); // 默认状态
        // 商机状态组为选填:未选择时不设置默认阶段,也不参与「商机状态组」维度的统计
        if (createReqVO.getStatusTypeId() != null) {
            List<CrmBusinessStatusDO> statusList = businessStatusService.getBusinessStatusListByTypeId(createReqVO.getStatusTypeId());
            business.setStatusId(CollUtil.isNotEmpty(statusList) ? statusList.get(0).getId() : null); // 默认状态
        }
        calculateTotalPrice(business, businessProducts);
        businessMapper.insert(business);
        // 2.2 插入商机关联商品
@@ -297,6 +301,10 @@
        // 1.3 校验商机状态
        CrmBusinessStatusDO status = null;
        if (reqVO.getStatusId() != null) {
            // 商机状态组为选填:未设置状态组的商机没有可选阶段,不允许变更「商机阶段」
            if (business.getStatusTypeId() == null) {
                throw exception(BUSINESS_UPDATE_STATUS_FAIL_NO_STATUS_TYPE, business.getName());
            }
            status = businessStatusService.validateBusinessStatus(business.getStatusTypeId(), reqVO.getStatusId());
        }
        // 1.4 校验是不是状态没变更
@@ -312,7 +320,7 @@
        // 3. 记录操作日志上下文
        LogRecordContext.putVariable("businessName", business.getName());
        LogRecordContext.putVariable("oldStatusName", getBusinessStatusName(business.getEndStatus(),
                businessStatusService.getBusinessStatus(business.getStatusId())));
                business.getStatusId() != null ? businessStatusService.getBusinessStatus(business.getStatusId()) : null));
        LogRecordContext.putVariable("newStatusName", getBusinessStatusName(reqVO.getEndStatus(), status));
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/ipqc/vo/MesQcIpqcSaveReqVO.java
@@ -44,15 +44,13 @@
    // ========== 生产关联 ==========
    @Schema(description = "生产工单 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
    @NotNull(message = "生产工单不能为空")
    @Schema(description = "生产工单 ID(非必填:未选择时不校验工单、不按工单推导物料)", example = "10")
    private Long workOrderId;
    @Schema(description = "生产任务 ID", example = "20")
    private Long taskId;
    @Schema(description = "工位 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "30")
    @NotNull(message = "工位不能为空")
    @Schema(description = "工位 ID(非必填:未选择时不校验工位关联的工序)", example = "30")
    private Long workstationId;
    @Schema(description = "工序 ID", example = "40")
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/qc/ipqc/MesQcIpqcMapper.java
@@ -48,9 +48,12 @@
    default List<MesQcIpqcDO> selectListByImportKey(Long workOrderId, Integer type, Long workstationId,
                                                    LocalDateTime inspectDate) {
        return selectList(new LambdaQueryWrapperX<MesQcIpqcDO>()
                .eq(MesQcIpqcDO::getWorkOrderId, workOrderId)
                // 生产工单、工位为选填:留空时比对其它同样留空的检验单
                .eq(workOrderId != null, MesQcIpqcDO::getWorkOrderId, workOrderId)
                .isNull(workOrderId == null, MesQcIpqcDO::getWorkOrderId)
                .eq(MesQcIpqcDO::getType, type)
                .eq(MesQcIpqcDO::getWorkstationId, workstationId)
                .eq(workstationId != null, MesQcIpqcDO::getWorkstationId, workstationId)
                .isNull(workstationId == null, MesQcIpqcDO::getWorkstationId)
                .eq(inspectDate != null, MesQcIpqcDO::getInspectDate, inspectDate)
                .isNull(inspectDate == null, MesQcIpqcDO::getInspectDate)
                .orderByAsc(MesQcIpqcDO::getId));
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java
@@ -411,6 +411,8 @@
    ErrorCode QC_IPQC_SOURCE_LINE_NOT_BELONG = new ErrorCode(1_040_604_009, "来源单据行不属于该报工单");
    ErrorCode QC_IPQC_SOURCE_LINE_REQUIRED = new ErrorCode(1_040_604_010, "来源单据类型为报工时,来源产出行 ID 不能为空");
    ErrorCode QC_IPQC_SOURCE_LINE_NOT_PENDING = new ErrorCode(1_040_604_011, "来源产出行不是待检验状态");
    ErrorCode QC_IPQC_TEMPLATE_IS_DISABLE = new ErrorCode(1_040_604_012, "所选质检方案【{}】已禁用,请重新选择已启用的质检方案");
    ErrorCode QC_IPQC_TEMPLATE_TYPE_NOT_MATCH = new ErrorCode(1_040_604_013, "所选质检方案【{}】的检测种类不包含过程检验,请重新选择检测种类包含过程检验的质检方案");
    // ========== MES 质量管理-过程检验行(1-040-604-100) ==========
    ErrorCode QC_IPQC_LINE_NOT_EXISTS = new ErrorCode(1_040_604_100, "过程检验行不存在");
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/imports/MesQcImportHelper.java
@@ -219,11 +219,11 @@
    }
    /**
     * 解析生产工单编号,基于工单号
     * 解析生产工单编号,基于工单号(选填:工单号留空时返回 null,表示该行不关联生产工单)
     */
    public Long resolveWorkOrderId(String workOrderCode) {
        if (StrUtil.isBlank(workOrderCode)) {
            throw new IllegalArgumentException("生产工单号不能为空:请填写模板中该行的「生产工单号」列");
            return null;
        }
        MesProWorkOrderDO workOrder = workOrderService.getWorkOrder(workOrderCode.trim());
        if (workOrder == null) {
@@ -234,11 +234,11 @@
    }
    /**
     * 解析工位编号,基于工位名称
     * 解析工位编号,基于工位名称(选填:工位名称留空时返回 null,表示该行不关联工位)
     */
    public Long resolveWorkstationId(String workstationName) {
        if (StrUtil.isBlank(workstationName)) {
            throw new IllegalArgumentException("工位名称不能为空:请填写模板中该行的「工位名称」列");
            return null;
        }
        MesMdWorkstationDO workstation = workstationService.getWorkstationByName(workstationName.trim());
        if (workstation == null) {
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ipqc/MesQcIpqcServiceImpl.java
@@ -4,6 +4,7 @@
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productproduce.MesWmProductProduceLineDO;
@@ -16,6 +17,7 @@
import cn.iocoder.yudao.module.mes.enums.DictTypeConstants;
import cn.iocoder.yudao.module.mes.enums.md.autocode.MesMdAutoCodeRuleCodeEnum;
import cn.iocoder.yudao.module.mes.service.qc.imports.MesQcImportHelper;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.workstation.MesMdWorkstationDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.route.MesProRouteProductDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.task.MesProTaskDO;
@@ -23,6 +25,7 @@
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.feedback.MesProFeedbackDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.defectrecord.MesQcDefectRecordDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ipqc.MesQcIpqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.template.MesQcTemplateDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.template.MesQcTemplateItemDO;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.ipqc.MesQcIpqcMapper;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcDefectLevelEnum;
@@ -40,6 +43,7 @@
import cn.iocoder.yudao.module.mes.service.qc.defectrecord.MesQcDefectRecordService;
import cn.iocoder.yudao.module.mes.service.qc.indicatorresult.MesQcIndicatorResultService;
import cn.iocoder.yudao.module.mes.service.qc.template.MesQcTemplateItemService;
import cn.iocoder.yudao.module.mes.service.qc.template.MesQcTemplateService;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ncr.vo.MesQcNcrSaveReqVO;
import cn.iocoder.yudao.module.mes.service.qc.ncr.MesQcNcrService;
import cn.iocoder.yudao.module.mes.service.wm.productproduce.MesWmProductProduceLineService;
@@ -76,16 +80,19 @@
    /**
     * IPQC 保存数据校验上下文
     *
     * @param workstation 工位
     * @param workstation 工位(可选,未指定 workstationId 时为空)
     * @param task        生产任务(可选,指定 taskId 时非空)
     * @param workOrder   生产工单(可选,未指定 workOrderId 时为空)
     */
    private record SaveDataContext(MesMdWorkstationDO workstation, MesProTaskDO task) {}
    private record SaveDataContext(MesMdWorkstationDO workstation, MesProTaskDO task, MesProWorkOrderDO workOrder) {}
    @Resource
    private MesQcIpqcMapper ipqcMapper;
    @Resource
    private MesQcTemplateItemService templateItemService;
    @Resource
    private MesQcTemplateService templateService;
    @Resource
    private MesQcIpqcLineService ipqcLineService;
    @Resource
@@ -132,36 +139,21 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createIpqc(MesQcIpqcSaveReqVO createReqVO) {
        // 1.1 校验数据
        // 1.1 校验数据(生产订单、工位均非必填)
        SaveDataContext ctx = validateIpqcSaveData(createReqVO);
        // 1.2 校验工单存在
        MesProWorkOrderDO workOrder = workOrderService.validateWorkOrderExists(createReqVO.getWorkOrderId());
        // 1.3 确定检验物料:有任务时取任务的物料,无任务时取工单的产品
        Long itemId = (ctx.task() != null) ? ctx.task().getItemId() : workOrder.getProductId();
        // 1.4 根据物料 + IPQC 类型查找可用质检方案(支持前端显式指定)
        List<MesQcTemplateItemDO> templateItems = templateItemService.getTemplateItemList(
                itemId, MesQcTypeEnum.IPQC.getType(), null, null);
        if (CollUtil.isEmpty(templateItems)) {
            throw exception(QC_NO_TEMPLATE);
        }
        MesQcTemplateItemDO templateItem = templateItems.get(0);
        if (createReqVO.getTemplateId() != null) {
            templateItem = templateItems.stream()
                    .filter(available -> Objects.equals(available.getTemplateId(), createReqVO.getTemplateId()))
                    .findFirst().orElse(null);
            if (templateItem == null) {
                throw exception(QC_NO_TEMPLATE); // 指定了方案但不在匹配列表中,拒绝
            }
        }
        Long templateId = templateItem.getTemplateId();
        // 1.5 获取来源单据编号
        // 1.2 确定检验物料:有任务时取任务的物料,有工单时取工单的产品,都没有时取请求指定的产品物料
        Long itemId = resolveItemId(createReqVO, ctx);
        // 1.3 确定质检方案:显式指定时校验方案本身可用,未指定时按产品物料已绑定方案兜底
        Long templateId = resolveTemplateId(itemId, createReqVO.getTemplateId());
        // 1.4 获取来源单据编号
        String sourceDocCode = validateAndGetSourceDocCode(
                createReqVO.getSourceDocType(), createReqVO.getSourceDocId(), createReqVO.getSourceLineId());
        // 2. 插入主表
        MesQcIpqcDO ipqc = BeanUtils.toBean(createReqVO, MesQcIpqcDO.class)
                .setItemId(itemId)
                .setProcessId(getProcessId(itemId, ctx.workstation().getProcessId()))
                .setProcessId(getProcessId(itemId,
                        ctx.workstation() != null ? ctx.workstation().getProcessId() : null))
                .setTemplateId(templateId)
                .setSourceDocCode(sourceDocCode)
                .setStatus(MesQcStatusEnum.DRAFT.getStatus());
@@ -186,19 +178,23 @@
        updateObj.setTemplateId(null); // 不允许修改模板
        updateObj.setItemId(null); // 不允许修改物料
        updateObj.setWorkOrderId(null); // 不允许修改工单
        updateObj.setProcessId(getProcessId(existIpqc.getItemId(), ctx.workstation().getProcessId()));
        updateObj.setProcessId(getProcessId(existIpqc.getItemId(),
                ctx.workstation() != null ? ctx.workstation().getProcessId() : null));
        ipqcMapper.updateById(updateObj);
    }
    private SaveDataContext validateIpqcSaveData(MesQcIpqcSaveReqVO reqVO) {
        // 校验编号唯一
        validateIpqcCodeUnique(reqVO.getId(), reqVO.getCode());
        // 校验工位、检测人员存在
        MesMdWorkstationDO workstation = workstationService.validateWorkstationExists(reqVO.getWorkstationId());
        // 校验工位存在(工位非必填,未选择时跳过)
        MesMdWorkstationDO workstation = reqVO.getWorkstationId() != null
                ? workstationService.validateWorkstationExists(reqVO.getWorkstationId()) : null;
        adminUserApi.validateUser(reqVO.getInspectorUserId());
        // 校验工单存在
        MesProWorkOrderDO workOrder = workOrderService.validateWorkOrderConfirmed(reqVO.getWorkOrderId());
        if (reqVO.getProcessId() != null && ObjUtil.notEqual(workstation.getProcessId(), reqVO.getProcessId())) {
        // 校验工单存在且已确认(生产订单非必填,未选择时跳过)
        MesProWorkOrderDO workOrder = reqVO.getWorkOrderId() != null
                ? workOrderService.validateWorkOrderConfirmed(reqVO.getWorkOrderId()) : null;
        if (workstation != null && reqVO.getProcessId() != null
                && ObjUtil.notEqual(workstation.getProcessId(), reqVO.getProcessId())) {
            throw exception(PRO_WORKSTATION_PROCESS_MISMATCH);
        }
        // 校验任务关系(如果指定了任务)
@@ -206,24 +202,103 @@
        if (reqVO.getTaskId() != null) {
            task = taskService.validateTaskNotFinished(reqVO.getTaskId());
            validateTaskRelation(task, workstation, workOrder, reqVO);
        } else {
        } else if (workOrder != null && reqVO.getItemId() != null
                && ObjUtil.notEqual(reqVO.getItemId(), workOrder.getProductId())) {
            // 无任务时,itemId 仍需与工单产品一致
            if (reqVO.getItemId() != null && ObjUtil.notEqual(reqVO.getItemId(), workOrder.getProductId())) {
                throw exception(PRO_WORK_ORDER_PRODUCT_MISMATCH);
            }
            throw exception(PRO_WORK_ORDER_PRODUCT_MISMATCH);
        }
        return new SaveDataContext(workstation, task);
        return new SaveDataContext(workstation, task, workOrder);
    }
    /**
     * 确定检验物料:任务优先,其次工单产品,都没有时取请求指定的产品物料(都未指定则为空)
     */
    private Long resolveItemId(MesQcIpqcSaveReqVO reqVO, SaveDataContext ctx) {
        if (ctx.task() != null) {
            return ctx.task().getItemId();
        }
        if (ctx.workOrder() != null) {
            return ctx.workOrder().getProductId();
        }
        return reqVO.getItemId();
    }
    /**
     * 确定质检方案
     *
     * <p>产品物料已绑定 IPQC 方案时沿用原逻辑:未显式指定取绑定列表首个,显式指定则必须命中绑定列表;
     * <p>产品物料未匹配到任何方案(未选生产订单/生产任务/产品物料)时,显式指定的方案改为校验方案本身:
     * 存在、已启用、检测种类包含过程检验,不再要求与产品物料绑定。
     */
    private Long resolveTemplateId(Long itemId, Long explicitTemplateId) {
        List<MesQcTemplateItemDO> templateItems = templateItemService.getTemplateItemList(
                itemId, MesQcTypeEnum.IPQC.getType(), null, null);
        if (CollUtil.isNotEmpty(templateItems)) {
            if (explicitTemplateId == null) {
                return templateItems.get(0).getTemplateId();
            }
            return templateItems.stream()
                    .filter(available -> Objects.equals(available.getTemplateId(), explicitTemplateId))
                    .findFirst()
                    .orElseThrow(() -> exception(QC_NO_TEMPLATE)) // 指定了方案但不在匹配列表中,拒绝
                    .getTemplateId();
        }
        // 产品物料未匹配到任何 IPQC 方案
        if (explicitTemplateId != null) {
            return validateTemplateUsable(explicitTemplateId);
        }
        throw new ServiceException(QC_IPQC_NO_TEMPLATE.getCode(), buildNoTemplateMessage(itemId));
    }
    /**
     * 校验质检方案本身可用:存在、已启用、检测种类包含过程检验,返回方案编号
     */
    private Long validateTemplateUsable(Long templateId) {
        MesQcTemplateDO template = templateService.getTemplate(templateId);
        if (template == null) {
            throw new ServiceException(QC_TEMPLATE_NOT_EXISTS.getCode(), StrUtil.format(
                    "所选质检方案不存在(ID={}),可能已被删除,请刷新后重新选择质检方案", templateId));
        }
        if (!CommonStatusEnum.isEnable(template.getStatus())) {
            throw exception(QC_IPQC_TEMPLATE_IS_DISABLE, template.getName());
        }
        if (!CollUtil.contains(template.getTypes(), MesQcTypeEnum.IPQC.getType())) {
            throw exception(QC_IPQC_TEMPLATE_TYPE_NOT_MATCH, template.getName());
        }
        return template.getId();
    }
    /**
     * 构建「无可用质检方案」的提示:区分「有产品物料但未绑定方案」与「未选工单/任务/物料」
     */
    private String buildNoTemplateMessage(Long itemId) {
        if (itemId == null) {
            return "未选择生产订单/生产任务/产品物料,无法自动匹配质检方案:"
                    + "请直接选择「质检方案」后重试,或先选择生产订单/生产任务以自动带出该产品已绑定的方案。";
        }
        return StrUtil.format("产品物料[{}]未绑定「过程检验」质检方案:请在【质量管理-质检方案】中为该物料"
                + "配置检测种类包含过程检验的方案,或在本单手动选择质检方案。", describeItem(itemId));
    }
    /**
     * 描述产品物料,用于异常提示:优先给出「编码 名称」,查不到时回退为物料编号
     */
    private String describeItem(Long itemId) {
        MesMdItemDO item = itemService.getItem(itemId);
        return item != null ? item.getCode() + " " + item.getName() : "物料编号 ID=" + itemId;
    }
    private void validateTaskRelation(MesProTaskDO task, MesMdWorkstationDO workstation,
                                      MesProWorkOrderDO workOrder, MesQcIpqcSaveReqVO reqVO) {
        if (ObjUtil.notEqual(task.getWorkOrderId(), workOrder.getId())) {
        // 生产订单、工位均非必填,未选择时跳过对应比对
        if (workOrder != null && ObjUtil.notEqual(task.getWorkOrderId(), workOrder.getId())) {
            throw exception(PRO_TASK_WORK_ORDER_MISMATCH);
        }
        if (ObjUtil.notEqual(task.getWorkstationId(), workstation.getId())) {
        if (workstation != null && ObjUtil.notEqual(task.getWorkstationId(), workstation.getId())) {
            throw exception(PRO_TASK_WORKSTATION_MISMATCH);
        }
        Long expectedProcessId = reqVO.getProcessId() != null ? reqVO.getProcessId() : workstation.getProcessId();
        Long expectedProcessId = reqVO.getProcessId() != null ? reqVO.getProcessId()
                : (workstation != null ? workstation.getProcessId() : null);
        if (expectedProcessId != null && ObjUtil.notEqual(task.getProcessId(), expectedProcessId)) {
            throw exception(PRO_TASK_ROUTE_PROCESS_MISMATCH);
        }
@@ -528,7 +603,7 @@
                    + "。请修改模板中的「检验单编号」,或先在系统中删除该编号的检验单后重新导入");
        }
        reqVO.setCode(code);
        // 2. 名称、检验类型、工单、工位、质检方案
        // 2. 名称、检验类型、工单、工位、质检方案(生产工单号、工位名称留空即不关联,不再拦截)
        if (StrUtil.isBlank(row.getName())) {
            throw new IllegalArgumentException("检验单名称不能为空:请填写模板中该行的「检验单名称」列");
        }