gaoluyang
5 天以前 6b8585fc452ecdcaa82d86fab79a9f27bb442851
Merge remote-tracking branch 'origin/dev_南通_苏珩' into dev_南通_苏珩
已添加13个文件
已修改37个文件
1245 ■■■■■ 文件已修改
docs/project-business/data/flows.json 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/objects.json 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/index.html 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/02-sales-flow.mmd 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/04-production-flow.mmd 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/05-quality-flow.mmd 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/10-business-object.mmd 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/item-sort/ItemSortMigration.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/20261104_hrm_resignation_handover_columns.sql 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/20261104_mdm_item_technical_file.sql 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/20261104_work_order_technical_files.sql 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/mdm_mes_item_add_sort.sql 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/controller/admin/item/MdmItemController.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/dal/mysql/item/MdmItemTechnicalFileMapper.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/item/MdmItemTechnicalFileService.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/item/MdmItemTechnicalFileServiceImpl.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/task/MesProTaskController.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/task/vo/MesProWorkstationPendingCountVO.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/workorder/MesProWorkOrderController.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/attachment/MesQcAttachmentController.java 124 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/link/MesQcSourceLinkController.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/scrap/MesWmScrapReceiptController.java 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pro/workorder/MesProWorkOrderTechnicalFileDO.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/task/MesProTaskMapper.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/workorder/MesProWorkOrderTechnicalFileMapper.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/scrap/MesWmScrapReceiptMapper.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/qc/MesQcCheckResultEnum.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/workorder/MesProWorkOrderServiceImpl.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/workorder/MesProWorkOrderTechnicalFileService.java 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/MesQcCompletionHelper.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ipqc/MesQcIpqcServiceImpl.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/iqc/MesQcIqcServiceImpl.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/link/MesQcSourceLinkService.java 234 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/oqc/MesQcOqcServiceImpl.java 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/rqc/MesQcRqcServiceImpl.java 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesLineServiceImpl.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesServiceImpl.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/ipqc/MesQcIpqcServiceImplTest.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/iqc/MesQcIqcServiceImplTest.java 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/oqc/MesQcOqcAutoCreateTest.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/oqc/MesQcOqcServiceImplTest.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/rqc/MesQcRqcServiceImplTest.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesLineReelTest.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesLineServiceImplTest.java 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/resources/sql/create_tables.sql 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/storage/StorageAttachmentApi.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/storage/StorageAttachmentApiImpl.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/util/storage/StorageFileUtil.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application-local.yaml 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/flows.json
@@ -1,13 +1,13 @@
{
  "flows": [
    {"id": "sales", "name": "销售流程", "mermaid": "mermaid/02-sales-flow.mmd", "color": "#4f9cff",
     "summary": "线索转化客户 â†’ å•†æœºæŽ¨è¿› â†’ æŠ¥ä»·(可AI识别) â†’ åˆåŒ BPM å®¡æ‰¹ â†’ ç”Ÿæˆ ERP é”€å”®è®¢å•并审批 â†’ éœ€ç”Ÿäº§è¡Œè‡ªåŠ¨å»º MPS â†’ å‘货通知 â†’ é”€å”®å‡ºåº“(OQC)回写 â†’ å¼€ç¥¨(BPM) â†’ å›žæ¬¾(先开票校验)。",
     "summary": "线索转化客户 â†’ å•†æœºæŽ¨è¿› â†’ æŠ¥ä»·(可AI识别) â†’ åˆåŒ BPM å®¡æ‰¹ â†’ ç”Ÿæˆ ERP é”€å”®è®¢å•并审批 â†’ éœ€ç”Ÿäº§è¡Œè‡ªåŠ¨å»º MPS â†’ å‘货通知 â†’ é”€å”®å‡ºåº“提交(需检验行转待检测) â†’ ç‹¬ç«‹å½•å…¥ OQC åŽæŒ‰ç‰©æ–™+盘号+数量关联回写 â†’ æ‹£è´§å‘运 â†’ å¼€ç¥¨(BPM) â†’ å›žæ¬¾(先开票校验)。",
     "steps": [
       {"stage": "获客", "action": "线索转化客户、建联系人、立项商机", "roles": ["销售内勤"]},
       {"stage": "报价", "action": "商机赢单后建报价单(AI OCR é¢„å¡«),转合同", "roles": ["销售内勤"]},
       {"stage": "签约", "action": "合同来源:手工新建 / æŠ¥ä»·å•转入 / Excel å¯¼å…¥(一合同多产品明细行);提交 BPM å®¡æ‰¹(可选流程定义),通过=20", "roles": ["销售", "审批人"]},
       {"stage": "接单", "action": "合同一键生成销售订单(XSDD),销售订单再走 BPM;审批通过自动为 needProduction æ˜Žç»†åˆ›å»º MPS", "roles": ["销售", "计划员"]},
       {"stage": "交付", "action": "发货通知审核(占用)→ é”€å”®å‡ºåº“ OQC â†’ å‘运 â†’ å‡ºåº“回写部分/全部出库", "roles": ["仓管", "质检"]},
       {"stage": "交付", "action": "发货通知审核(占用)→ é”€å”®å‡ºåº“提交:有 oqcCheckFlag è¡Œè½¬å¾…检测,提交时不再自动建 OQC;质检员独立录入并完成 OQC åŽï¼Œåœ¨æ£€éªŒå•详情按物料+盘号+数量一致选择待检出库行关联,全部待检行合格后出库单转待拣货 â†’ å‘运 â†’ å‡ºåº“回写部分/全部出库", "roles": ["仓管", "质检"]},
       {"stage": "回款", "action": "开票 BPM(累计≤合同额) â†’ å›žæ¬¾å¼ºåˆ¶å…ˆå¼€ç¥¨(累计≤合同剩余与开票剩余)", "roles": ["财务", "销售"]}]},
    {"id": "purchase", "name": "采购流程", "mermaid": "mermaid/03-purchase-flow.mmd", "color": "#5fc79e",
     "summary": "供应商档案(多联系人/多银行·含银行行号) â†’ è¯¢ä»·æ¯”ä»·(产品×供应商横向报价,按产品选中标) â†’ ç¡®è®¤åŽæŒ‰ä¸­æ ‡ä¾›åº”商拆采购订单草稿并走现有 BPM â†’ ç¡®è®¤æ”¶è´§ç”Ÿæˆ MES åˆ°è´§é€šçŸ¥ â†’ IQC(合格/特采入库·退货/报废) â†’ é‡‡è´­å…¥åº“回写订单与申请 â†’ æ¥ç¥¨(AI OCR) â†’ ä»˜æ¬¾å• BPM â†’ ä¾›åº”商往来对账;投标保证金台账独立记录中标与退回。",
@@ -27,14 +27,15 @@
       {"stage": "派工", "action": "工单确认按工艺路线逐工序生成任务,可指派/暂停/取消", "roles": ["车间主任"]},
       {"stage": "备料", "action": "生产领料单出库或线边虚拟库备料", "roles": ["仓管"]},
       {"stage": "报工", "action": "提交报工:上一道工序未报满直接拦截;启用 BPM èµ°å®¡æ‰¹", "roles": ["操作工"]},
       {"stage": "联动", "action": "审批通过按工序标记: å€’冲→自动物料消耗扣线边库(允许负库存);质检→产出待检行等 IPQC;关键非检→直接产出入库", "roles": ["系统自动"]},
       {"stage": "联动", "action": "审批通过按工序标记: å€’冲→自动物料消耗扣线边库(允许负库存);质检→报工转待检验并生成 PENDING äº§å‡ºè¡Œï¼Œç­‰å¾…质检员独立录入 IPQC;关键非检→直接产出入库", "roles": ["系统自动"]},
       {"stage": "检验", "action": "已完成 IPQC åœ¨è¯¦æƒ…页按物料+盘号+数量一致选择待检产出行关联:回写报工完成并拆分合格/不合格产出入库;未关联的已完成检验单不回写", "roles": ["IPQC è´¨æ£€å‘˜"]},
       {"stage": "完工", "action": "仅末道工序回写工单已生产数量,达标自动完成;MPS ç´¯è®¡åˆ¤æ–­å®Œæˆå¹¶å‘事件给销售订单", "roles": ["系统自动"]}]},
    {"id": "quality", "name": "质量流程", "mermaid": "mermaid/05-quality-flow.mmd", "color": "#e06c6c",
     "summary": "四道检验闸口(IQC/IPQC/OQC/RQC,二态 0→4) + åˆ¤å®šå››æžœ(合格/特采/退货/报废) + NCR è¯„审处置关闭;AI æä¾›åˆ¤å®šå»ºè®®ï¼›NCR å½“前仅台账不触发返工单;检验单可由智能质检报告平台一键出件(generate-from-qc:数据从 MES å–,五道校验后渲染落库)。",
     "summary": "四道检验闸口(IQC/IPQC/OQC/RQC,二态 0→4) + å®Œæˆæ—¶æŒ‰åˆæ ¼/不合格数量自动判定(1 æ ¡éªŒé€šè¿‡/2 æ ¡éªŒä¸é€šè¿‡) + NCR è¯„审处置关闭;IPQC å®ŒæˆåŽæŒ‰ç‰©æ–™+盘号+数量关联报工待检行,OQC å®ŒæˆåŽæŒ‰åŒæ ·è§„则关联销售出库待检行;AI æä¾›åˆ¤å®šå»ºè®®ï¼›NCR å½“前仅台账不触发返工单;检验单可由智能质检报告平台一键出件(generate-from-qc:数据从 MES å–,五道校验后渲染落库)。",
     "steps": [
       {"stage": "来料", "action": "到货通知/外协入库→IQC,结论回写推进待入库或触发退货", "roles": ["IQC è´¨æ£€å‘˜"]},
       {"stage": "过程", "action": "报工质检工序产出行→IPQC,合格拆行入库、不合格生成 NCR", "roles": ["IPQC è´¨æ£€å‘˜"]},
       {"stage": "出货", "action": "销售出库提交→OQC,完成后出库单进入待拣货", "roles": ["OQC è´¨æ£€å‘˜"]},
       {"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": "NCR: å¾…评审→评审→处置(退货/报废/返工/让步接收/降级)→关闭", "roles": ["质量工程师"]},
docs/project-business/data/objects.json
@@ -29,12 +29,12 @@
    {"name": "主生产计划", "module": "生产", "table": "mes_pro_mps", "doc": "MPS计划,草稿0→已下发1→完成2/取消3,可合并下发", "upstream": ["销售订单"], "downstream": ["生产工单"], "keywords": "MPS ä¸»è®¡åˆ’ ç”Ÿäº§è®¡åˆ’"},
    {"name": "生产工单", "module": "生产", "table": "mes_pro_work_order", "doc": "草稿0→已确认1→完成2/取消3,确认时按工艺路线生成任务,最后一道工序报工回写已生产数量", "upstream": ["MPS"], "downstream": ["生产任务", "领料", "完工入库"], "keywords": "工单 ç”Ÿäº§å·¥å• work order"},
    {"name": "生产任务", "module": "生产", "table": "mes_pro_task", "doc": "工序粒度,预备0→生产中1→部分2→完成4,可暂停9取消5", "upstream": ["生产工单"], "downstream": ["报工"], "keywords": "任务 task å·¥åº"},
    {"name": "报工单", "module": "生产", "table": "mes_pro_feedback", "doc": "草稿0→审批中2→待检3/完成4,按工序顺序校验(上道工序报满才可报),联动倒冲消耗/产出入库/IPQC", "upstream": ["生产任务"], "downstream": ["产品产出", "物料消耗", "IPQC"], "keywords": "报工 feedback"},
    {"name": "报工单", "module": "生产", "table": "mes_pro_feedback", "doc": "草稿0→审批中2→待检3/完成4,按工序顺序校验(上道工序报满才可报),联动倒冲消耗/产出入库/IPQC;质检工序报工审批后转待检验并生成 PENDING äº§å‡ºè¡Œï¼Œç­‰å·²å®Œæˆ IPQC æŒ‰ç‰©æ–™+盘号+数量关联后才回写完成", "upstream": ["生产任务"], "downstream": ["产品产出", "物料消耗", "IPQC"], "keywords": "报工 feedback"},
    {"name": "工艺路线", "module": "生产", "table": "mes_pro_route", "doc": "工序顺序+质检/关键/倒冲标记,驱动工单任务与报工规则", "upstream": [], "downstream": ["生产工单"], "keywords": "工艺 è·¯çº¿ route å·¥åº"},
    {"name": "到货通知单", "module": "仓储", "table": "mes_wm_arrival_notice", "doc": "采购订单确认收货生成,草稿0→待质检2→待入库3→完成4", "upstream": ["采购订单"], "downstream": ["IQC", "采购入库"], "keywords": "到货 æ”¶è´§ arrival"},
    {"name": "采购入库单", "module": "仓储", "table": "mes_wm_item_receipt", "doc": "草稿0→待上架2→待执行3→完成4,完成后库存+并回写 ERP inCount/inStatus", "upstream": ["到货通知"], "downstream": ["库存台账", "采购订单(回写)"], "keywords": "采购入库 æ”¶è´§å…¥åº“ receipt"},
    {"name": "生产领料单", "module": "仓储", "table": "mes_wm_product_issue", "doc": "草稿0→待拣货2→待执行3→完成4,库存-", "upstream": ["生产工单"], "downstream": ["库存台账"], "keywords": "领料 issue"},
    {"name": "销售出库单", "module": "仓储", "table": "mes_wm_product_sales", "doc": "发货通知审核后生成,提交→OQC检测→拣货→发运10→待执行3→完成4,回写销售订单出库数与状态", "upstream": ["发货通知", "销售订单"], "downstream": ["库存台账", "OQC"], "keywords": "销售出库 å‘è´§ å‡ºåº“单 sales"},
    {"name": "销售出库单", "module": "仓储", "table": "mes_wm_product_sales", "doc": "发货通知审核后生成,提交后有 oqcCheckFlag è¡Œè½¬å¾…检测1(提交时不再自动建 OQC),需质检员独立录入 OQC å¹¶åœ¨æ£€éªŒå•详情按物料+盘号+数量关联待检行;全部待检行合格后转待拣货2→发运10→待执行3→完成4,回写销售订单出库数与状态", "upstream": ["发货通知", "销售订单"], "downstream": ["库存台账", "OQC"], "keywords": "销售出库 å‘è´§ å‡ºåº“单 sales"},
    {"name": "销售退货单", "module": "仓储", "table": "mes_wm_return_sales", "doc": "草稿→待检验(RQC)→待上架→待入库→完成,售后与手工两来源", "upstream": ["售后退货申请"], "downstream": ["库存台账", "RQC"], "keywords": "销售退货 é€€è´§å…¥åº“"},
    {"name": "供应商退货单", "module": "仓储", "table": "mes_wm_return_vendor", "doc": "质量部发起退货,草稿0→审批中1(BPM:采购→质量→仓库)→待拣货2→待执行退货3→完成4,可取消5,库存-;上传附件走 storage-attachment;处理方式 1退款(完成后自动生成负数冲红付款单) / 2换货(完成后自动生成待质检到货通知单并创建 IQC,重新入库质检);合同号从采购订单快照", "upstream": ["IQC", "采购订单(合同号)"], "downstream": ["库存台账", "采购订单(returnCount)", "付款单(冲红)", "到货通知(换货)"], "keywords": "供应商退货 é€€æ–™ é€€è´§ vendor"},
    {"name": "调拨单", "module": "仓储", "table": "mes_wm_transfer", "doc": "内部/外部调拨,成对 MOVE_OUT+MOVE_IN äº‹åŠ¡ï¼Œåœ¨é€”æ•°é‡", "upstream": [], "downstream": ["库存台账"], "keywords": "调拨 ç§»åº“ transfer"},
@@ -42,8 +42,8 @@
    {"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": "OQC出货检验", "module": "质量", "table": "mes_qc_oqc", "doc": "销售出库提交触发,完成推进出库单待拣货;行操作「出报告」仅在已完成时可见;支持 Excel æ‰¹é‡å¯¼å…¥ï¼ˆå®¢æˆ·é¡»åœ¨å½“前登录用户的客户数据权限范围内);同一批数据不可重复导入(物料+客户+批次号+出货日期,文件内重复行与库内已有单都拒该行)", "upstream": ["销售出库单", "Excel æ‰¹é‡å¯¼å…¥"], "downstream": ["销售出库单(推进)", "报告实例(出报告)"], "keywords": "OQC å‡ºè´§æ£€éªŒ å‡ºåŽ‚æ£€éªŒ å‡ºæŠ¥å‘Š æ‰¹é‡å¯¼å…¥ å¯¼å…¥æ¨¡æ¿"},
    {"name": "IPQC过程检验", "module": "质量", "table": "mes_qc_ipqc", "doc": "报工质检工序产出行待检后独立录入,完成时按合格+不合格=检测数量自动判定 1 é€šè¿‡/2 ä¸é€šè¿‡ï¼ˆéœ€å·²ä¸Šä¼ æ£€éªŒé™„件);已完成且未绑定来源的单据,在详情按物料+盘号+数量一致选择待检产出行关联,回写报工完成与产出入库;未关联不回写。行操作「出报告」仅在已完成时可见;支持 Excel æ‰¹é‡å¯¼å…¥ï¼ˆäº§å“ç‰©æ–™ç”±ç”Ÿäº§å·¥å•推导,模板不填物料;工废+料废+其他废品不得超过不合格品数量);同一批数据不可重复导入(生产工单+检验类型+工位+检测日期,文件内重复行与库内已有单都拒该行)", "upstream": ["报工", "Excel æ‰¹é‡å¯¼å…¥"], "downstream": ["产出入库", "NCR", "报告实例(出报告)"], "keywords": "IPQC è¿‡ç¨‹æ£€éªŒ å·¡æ£€ ç›˜å·å…³è” æŠ¥å·¥å…³è” å‡ºæŠ¥å‘Š æ‰¹é‡å¯¼å…¥ å¯¼å…¥æ¨¡æ¿"},
    {"name": "OQC出货检验", "module": "质量", "table": "mes_qc_oqc", "doc": "销售出库提交后待检测行独立录入,完成时按合格+不合格=检测数量自动判定 1 é€šè¿‡/2 ä¸é€šè¿‡ï¼ˆéœ€å·²ä¸Šä¼ æ£€éªŒé™„件);已完成且未绑定来源的单据,在详情按物料+盘号+数量一致选择待检出库行关联,回写出库行质量状态 PASS/FAIL,全部待检行合格后出库单转待拣货;不合格行留待后续处置,不自动取消出库单。行操作「出报告」仅在已完成时可见;支持 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 ä¸åˆæ ¼ å¤„ç½® ç‰¹é‡‡ æŠ¥åºŸ"},
    {"name": "设备", "module": "设备", "table": "mes_dv_machinery", "doc": "台账状态:停机/生产中/保养中;点检·保养·维修工单;AI é£Žé™©é¢„测消费其数据", "upstream": [], "downstream": ["点检", "ç»´ä¿®", "生产风险预测"], "keywords": "设备 ç‚¹æ£€ ä¿å…» ç»´ä¿® machinery"},
docs/project-business/index.html
@@ -734,7 +734,12 @@
  H -->|提交BPM| I[销售订单审批·审批中15→已审核20]
  I -->|明细需生产·自动创建| J[MES ä¸»ç”Ÿäº§è®¡åˆ’ MPS]
  I --> K[发货通知单]
  K -->|审核通过| L[销售出库单·OQC→拣货→发运→出库完成]
  K -->|审核通过| L0[销售出库单·提交]
  L0 -->|需检验行 oqcCheckFlag=1| L1[待检测1·出库行质量状态 PENDING]
  L0 -->|无需检验| L2[待拣货2]
  L1 -->|已完成 OQC æŒ‰ç‰©æ–™+盘号+数量一致选待检行关联| L3[出库行质量状态 PASS/FAIL]
  L3 -->|全部待检行合格| L2
  L2 --> L[拣货→发运→出库完成]
  L -->|回写出库数量与状态| M[销售订单 éƒ¨åˆ†å‡ºåº“2/全部出库3]
  G --> N[回款计划·按期数分期]
  G -->|提交BPM| O[开票 KP·审批中10→通过20·累计不超合同额]
@@ -777,10 +782,12 @@
  P5 -->|启用BPM则提交审批| P6{报工审批}
  P6 -->|驳回| P5
  P6 -->|通过且倒冲工序| P7[按工单BOM自动生成物料消耗·扣线边虚拟库FIFO允许负库存]
  P6 -->|通过且质检工序| P8[产出行待检→IPQC è¿‡ç¨‹æ£€éªŒ]
  P6 -->|通过且质检工序| P8[报工转待检验·产出行 PENDING]
  P6 -->|通过且非质检关键工序| P9[产品产出直接完工入库]
  P8 -->|检验合格| P10[拆合格与不合格行入库]
  P8 -->|不合格| P11[NCR ä¸åˆæ ¼å“]
  P8 -->|已完成 IPQC æŒ‰ç‰©æ–™+盘号+数量一致选待检产出行关联| P8L[回写报工完成·报工状态→完成4]
  P8L -->|合格数量>0| P10[合格行入库·不合格数量拆出为不合格行]
  P8L -->|不合格数量>0| P11[NCR ä¸åˆæ ¼å“Â·ç­‰å¾…后续处置]
  P8 -.->|未关联的已完成 IPQC ä¸å›žå†™Â·éœ€åœ¨æ£€éªŒå•详情手动关联| P8
  P9 --> P12[进度回写·仅最后一道工序报工才更新工单已生产数量]
  P10 --> P12
  P12 -->|已生产数量≥计划数量·自动| P13[工单完成2]
@@ -790,8 +797,8 @@
<pre style="display:none" class="mmd-src" data-mmd="05-quality-flow">
flowchart TD
  T1[到货通知单 æˆ– å¤–协入库单] --> A1[IQC æ¥æ–™æ£€éªŒÂ·è‰ç¨¿0→完成4]
  T2[报工质检工序通过后产出行] --> A2[IPQC è¿‡ç¨‹æ£€éªŒÂ·è‰ç¨¿0→完成4]
  T3[销售出库单提交] --> A3[OQC å‡ºè´§æ£€éªŒÂ·è‰ç¨¿0→完成4]
  T2[报工质检工序通过后产出行·独立录入 IPQC] --> A2[IPQC è¿‡ç¨‹æ£€éªŒÂ·è‰ç¨¿0→完成4]
  T3[销售出库单待检测行·独立录入 OQC] --> A3[OQC å‡ºè´§æ£€éªŒÂ·è‰ç¨¿0→完成4]
  T4[生产退料单 æˆ– é”€å”®é€€è´§å• æˆ– å”®åŽé€€è´§] --> A4[RQC é€€è´§æ£€éªŒÂ·è‰ç¨¿0→完成4]
  T5[Excel æ‰¹é‡å¯¼å…¥Â·IQC IPQC OQC ä¸‰åˆ—表各一个入口·按钮在新增与导出之间] --> IMP{导入闸门·逐行解析·失败行互不影响}
  IMP -->|编号留空自动生成·编号重复或同一批数据重复则拒该行| A1
@@ -809,12 +816,11 @@
  A2 --> R
  A3 --> R
  A4 --> R
  R -->|1 åˆæ ¼| Q1[允许入库·回写来源单据推进状态]
  R -->|2 ç‰¹é‡‡| Q2[允许入库·并生成NCR留痕]
  R -->|3 ä¸åˆæ ¼é€€è´§| Q3[生成NCR·走供应商退货或销售退货]
  R -->|4 ä¸åˆæ ¼æŠ¥åºŸ| Q4[生成NCR·走报废出库]
  A2 -->|合格数量| Q5[报工转完成·产出拆行入库]
  A3 -->|完成| Q6[销售出库单推进待拣货]
  R -->|1 æ ¡éªŒé€šè¿‡| Q1[允许入库·回写来源单据推进状态]
  R -->|2 æ ¡éªŒä¸é€šè¿‡| Q2[生成NCR·留待后续处置·不自动取消来源单据]
  R -.->|3 ä¸åˆæ ¼é€€è´§ 4 ä¸åˆæ ¼æŠ¥åºŸ ä¸ºåŽ†å²æžšä¸¾Â·å®Œæˆæ—¶ä¸å†äº§ç”Ÿ| Q4[退货/报废走后续单据]
  A2 -->|已完成且未绑定来源·按物料+盘号+数量一致| Q5[关联报工待检行·回写报工完成与产出入库]
  A3 -->|已完成且未绑定来源·按物料+盘号+数量一致| Q6[关联销售出库待检行·回写出库行质量状态·全部合格推进待拣货]
  A4 -->|完成| Q7[退货单推进上架或入库·售后回调结论]
  N1[NCR å¾…评审0] -->|评审| N2[已评审6]
  N2 -->|处置方式:退货1报废2返工3让步接收4降级使用5| N3[已处置7]
@@ -1082,8 +1088,8 @@
  mes_wm_arrival_notice ||--o| mes_qc_iqc : "触发IQC"
  mes_wm_arrival_notice ||--o{ mes_wm_item_receipt : "生成采购入库"
  erp_sale_order ||--o{ mes_wm_product_sales : "销售出库"
  mes_wm_product_sales ||--o| mes_qc_oqc : "触发OQC"
  mes_pro_feedback ||--o| mes_qc_ipqc : "质检工序触发IPQC"
  mes_wm_product_sales ||--o| mes_qc_oqc : "出库待检行按物料+盘号+数量关联OQC"
  mes_pro_feedback ||--o| mes_qc_ipqc : "报工待检行按物料+盘号+数量关联IPQC"
  mes_pro_feedback ||--o{ mes_wm_product_produce : "产出入库"
  mes_pro_feedback ||--o{ mes_wm_item_consume : "BOM倒冲消耗"
  mes_qc_iqc ||--o| mes_qc_ncr : "不合格生成NCR"
docs/project-business/mermaid/02-sales-flow.mmd
@@ -12,7 +12,12 @@
  H -->|提交BPM| I[销售订单审批·审批中15→已审核20]
  I -->|明细需生产·自动创建| J[MES ä¸»ç”Ÿäº§è®¡åˆ’ MPS]
  I --> K[发货通知单]
  K -->|审核通过| L[销售出库单·OQC→拣货→发运→出库完成]
  K -->|审核通过| L0[销售出库单·提交]
  L0 -->|需检验行 oqcCheckFlag=1| L1[待检测1·出库行质量状态 PENDING]
  L0 -->|无需检验| L2[待拣货2]
  L1 -->|已完成 OQC æŒ‰ç‰©æ–™+盘号+数量一致选待检行关联| L3[出库行质量状态 PASS/FAIL]
  L3 -->|全部待检行合格| L2
  L2 --> L[拣货→发运→出库完成]
  L -->|回写出库数量与状态| M[销售订单 éƒ¨åˆ†å‡ºåº“2/全部出库3]
  G --> N[回款计划·按期数分期]
  G -->|提交BPM| O[开票 KP·审批中10→通过20·累计不超合同额]
docs/project-business/mermaid/04-production-flow.mmd
@@ -8,10 +8,12 @@
  P5 -->|启用BPM则提交审批| P6{报工审批}
  P6 -->|驳回| P5
  P6 -->|通过且倒冲工序| P7[按工单BOM自动生成物料消耗·扣线边虚拟库FIFO允许负库存]
  P6 -->|通过且质检工序| P8[产出行待检→IPQC è¿‡ç¨‹æ£€éªŒ]
  P6 -->|通过且质检工序| P8[报工转待检验·产出行 PENDING]
  P6 -->|通过且非质检关键工序| P9[产品产出直接完工入库]
  P8 -->|检验合格| P10[拆合格与不合格行入库]
  P8 -->|不合格| P11[NCR ä¸åˆæ ¼å“]
  P8 -->|已完成 IPQC æŒ‰ç‰©æ–™+盘号+数量一致选待检产出行关联| P8L[回写报工完成·报工状态→完成4]
  P8L -->|合格数量>0| P10[合格行入库·不合格数量拆出为不合格行]
  P8L -->|不合格数量>0| P11[NCR ä¸åˆæ ¼å“Â·ç­‰å¾…后续处置]
  P8 -.->|未关联的已完成 IPQC ä¸å›žå†™Â·éœ€åœ¨æ£€éªŒå•详情手动关联| P8
  P9 --> P12[进度回写·仅最后一道工序报工才更新工单已生产数量]
  P10 --> P12
  P12 -->|已生产数量≥计划数量·自动| P13[工单完成2]
docs/project-business/mermaid/05-quality-flow.mmd
@@ -1,7 +1,7 @@
flowchart TD
  T1[到货通知单 æˆ– å¤–协入库单] --> A1[IQC æ¥æ–™æ£€éªŒÂ·è‰ç¨¿0→完成4]
  T2[报工质检工序通过后产出行] --> A2[IPQC è¿‡ç¨‹æ£€éªŒÂ·è‰ç¨¿0→完成4]
  T3[销售出库单提交] --> A3[OQC å‡ºè´§æ£€éªŒÂ·è‰ç¨¿0→完成4]
  T2[报工质检工序通过后产出行·独立录入 IPQC] --> A2[IPQC è¿‡ç¨‹æ£€éªŒÂ·è‰ç¨¿0→完成4]
  T3[销售出库单待检测行·独立录入 OQC] --> A3[OQC å‡ºè´§æ£€éªŒÂ·è‰ç¨¿0→完成4]
  T4[生产退料单 æˆ– é”€å”®é€€è´§å• æˆ– å”®åŽé€€è´§] --> A4[RQC é€€è´§æ£€éªŒÂ·è‰ç¨¿0→完成4]
  T5[Excel æ‰¹é‡å¯¼å…¥Â·IQC IPQC OQC ä¸‰åˆ—表各一个入口·按钮在新增与导出之间] --> IMP{导入闸门·逐行解析·失败行互不影响}
  IMP -->|编号留空自动生成·编号重复或同一批数据重复则拒该行| A1
@@ -19,12 +19,11 @@
  A2 --> R
  A3 --> R
  A4 --> R
  R -->|1 åˆæ ¼| Q1[允许入库·回写来源单据推进状态]
  R -->|2 ç‰¹é‡‡| Q2[允许入库·并生成NCR留痕]
  R -->|3 ä¸åˆæ ¼é€€è´§| Q3[生成NCR·走供应商退货或销售退货]
  R -->|4 ä¸åˆæ ¼æŠ¥åºŸ| Q4[生成NCR·走报废出库]
  A2 -->|合格数量| Q5[报工转完成·产出拆行入库]
  A3 -->|完成| Q6[销售出库单推进待拣货]
  R -->|1 æ ¡éªŒé€šè¿‡| Q1[允许入库·回写来源单据推进状态]
  R -->|2 æ ¡éªŒä¸é€šè¿‡| Q2[生成NCR·留待后续处置·不自动取消来源单据]
  R -.->|3 ä¸åˆæ ¼é€€è´§ 4 ä¸åˆæ ¼æŠ¥åºŸ ä¸ºåŽ†å²æžšä¸¾Â·å®Œæˆæ—¶ä¸å†äº§ç”Ÿ| Q4[退货/报废走后续单据]
  A2 -->|已完成且未绑定来源·按物料+盘号+数量一致| Q5[关联报工待检行·回写报工完成与产出入库]
  A3 -->|已完成且未绑定来源·按物料+盘号+数量一致| Q6[关联销售出库待检行·回写出库行质量状态·全部合格推进待拣货]
  A4 -->|完成| Q7[退货单推进上架或入库·售后回调结论]
  N1[NCR å¾…评审0] -->|评审| N2[已评审6]
  N2 -->|处置方式:退货1报废2返工3让步接收4降级使用5| N3[已处置7]
docs/project-business/mermaid/10-business-object.mmd
@@ -16,8 +16,8 @@
  mes_wm_arrival_notice ||--o| mes_qc_iqc : "触发IQC"
  mes_wm_arrival_notice ||--o{ mes_wm_item_receipt : "生成采购入库"
  erp_sale_order ||--o{ mes_wm_product_sales : "销售出库"
  mes_wm_product_sales ||--o| mes_qc_oqc : "触发OQC"
  mes_pro_feedback ||--o| mes_qc_ipqc : "质检工序触发IPQC"
  mes_wm_product_sales ||--o| mes_qc_oqc : "出库待检行按物料+盘号+数量关联OQC"
  mes_pro_feedback ||--o| mes_qc_ipqc : "报工待检行按物料+盘号+数量关联IPQC"
  mes_pro_feedback ||--o{ mes_wm_product_produce : "产出入库"
  mes_pro_feedback ||--o{ mes_wm_item_consume : "BOM倒冲消耗"
  mes_qc_iqc ||--o| mes_qc_ncr : "不合格生成NCR"
scripts/item-sort/ItemSortMigration.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,13 @@
import java.sql.*;
public class ItemSortMigration {
 public static void main(String[] a) throws Exception {
  try (Connection c = DriverManager.getConnection("jdbc:mysql://1.95.78.124:9000/mom-xgdl?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true", "root", System.getenv("MOM_DB_PASSWORD")); Statement s=c.createStatement()) {
   if (a.length > 0) { for (String table : new String[]{"mdm_item", "mes_md_item"}) { try (ResultSet r=s.executeQuery("SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name=\u0027"+table+"\u0027 AND column_name=\u0027sort\u0027")) { r.next(); if (r.getInt(1)==0) { try (Statement ddl=c.createStatement()) { ddl.executeUpdate("ALTER TABLE `"+table+"` ADD COLUMN sort INT NOT NULL DEFAULT 0 COMMENT \u0027排序,数值越小越靠前\u0027"); } } } } System.out.println("Synchronized rows="+s.executeUpdate("UPDATE mes_md_item m JOIN mdm_item d ON m.mdm_item_id=d.id SET m.sort=d.sort WHERE m.sort <> d.sort")); }
   try(ResultSet r=s.executeQuery("SELECT COUNT(*), SUM(m.mdm_item_id IS NOT NULL), SUM(d.id IS NOT NULL) FROM mes_md_item m LEFT JOIN mdm_item d ON m.mdm_item_id=d.id WHERE m.deleted=0")) {r.next(); System.out.println("MES active="+r.getLong(1)+" linked="+r.getLong(2)+" matched="+r.getLong(3));}
   for(String table : new String[]{"mdm_item","mes_md_item"}) {
    try(ResultSet r=s.executeQuery("SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='"+table+"' AND column_name='sort'")) { r.next(); System.out.println(table+" sort columns="+r.getInt(1)); }
    try(ResultSet r=s.executeQuery("SELECT COUNT(*) FROM `"+table+"`")) {r.next(); System.out.println(table+" rows="+r.getLong(1));}
   }
  }
 }
}
sql/mysql/20261104_hrm_resignation_handover_columns.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,14 @@
-- è¡¥é½ç¦»èŒç”³è¯·äº¤æŽ¥å­—段;只添加缺失字段,可重复执行。
SET @target_db = DATABASE();
SET @ddl = (SELECT IF(COUNT(*) = 0,
  'ALTER TABLE hrm_resignation_application ADD COLUMN handover_employee_id BIGINT NULL COMMENT ''交接员工ID''',
  'SELECT 1') FROM information_schema.columns WHERE table_schema = @target_db AND table_name = 'hrm_resignation_application' AND column_name = 'handover_employee_id');
PREPARE handover_stmt FROM @ddl;
EXECUTE handover_stmt;
DEALLOCATE PREPARE handover_stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
  'ALTER TABLE hrm_resignation_application ADD COLUMN handover_data_status TINYINT NOT NULL DEFAULT 0 COMMENT ''用户数据交接状态:0未开始 10进行中 20已完成''',
  'SELECT 1') FROM information_schema.columns WHERE table_schema = @target_db AND table_name = 'hrm_resignation_application' AND column_name = 'handover_data_status');
PREPARE handover_stmt FROM @ddl;
EXECUTE handover_stmt;
DEALLOCATE PREPARE handover_stmt;
sql/mysql/20261104_mdm_item_technical_file.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,15 @@
-- ç‰©æ–™æŠ€æœ¯æ–‡ä»¶è¿½åŠ è®°å½•ï¼›å·¥å•ä¿å­˜å…¶ä¸­å„æ¡è®°å½•çš„å¿«ç…§ã€‚
CREATE TABLE IF NOT EXISTS `mdm_item_technical_file` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `item_id` bigint NOT NULL COMMENT '物料编号',
  `storage_blob_id` bigint NOT NULL COMMENT '文件存储编号',
  `version_no` int NOT NULL COMMENT '上传序号',
  `file_name` varchar(255) DEFAULT NULL COMMENT '文件名称',
  `creator` varchar(64) DEFAULT '',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updater` varchar(64) DEFAULT '',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `deleted` bit(1) NOT NULL DEFAULT b'0',
  PRIMARY KEY (`id`),
  KEY `idx_item_version` (`item_id`, `version_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物料技术文件记录';
sql/mysql/20261104_work_order_technical_files.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,16 @@
-- æŒ‰å·¥å•保存技术文件清单,历史工单不做猜测性回填。
CREATE TABLE IF NOT EXISTS `mes_pro_work_order_technical_file` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `work_order_id` bigint NOT NULL,
  `source_file_id` bigint NOT NULL,
  `blob_id` bigint NOT NULL,
  `version_no` int DEFAULT NULL,
  `file_name` varchar(255) DEFAULT NULL,
  `creator` varchar(64) DEFAULT '',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updater` varchar(64) DEFAULT '',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `deleted` bit(1) NOT NULL DEFAULT b'0',
  PRIMARY KEY (`id`),
  KEY `idx_work_order_id` (`work_order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='生产工单技术文件快照';
sql/mysql/mdm_mes_item_add_sort.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,3 @@
ALTER TABLE mdm_item ADD COLUMN sort INT NOT NULL DEFAULT 0 COMMENT '排序,数值越小越靠前';
ALTER TABLE mes_md_item ADD COLUMN sort INT NOT NULL DEFAULT 0 COMMENT '排序,数值越小越靠前';
UPDATE mes_md_item m JOIN mdm_item d ON m.mdm_item_id = d.id SET m.sort = d.sort WHERE m.sort <> d.sort;
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/controller/admin/item/MdmItemController.java
@@ -114,6 +114,14 @@
        return success(technicalFileService.createTechnicalFile(reqVO));
    }
    @DeleteMapping("/technical-file/delete")
    @Operation(summary = "从物料移除技术文件")
    @PreAuthorize("@ss.hasPermission('mdm:item:update')")
    public CommonResult<Boolean> deleteTechnicalFile(@RequestParam("itemId") Long itemId, @RequestParam("id") Long id) {
        technicalFileService.deleteTechnicalFile(itemId, id);
        return success(true);
    }
    @GetMapping("/technical-file/list")
    @Operation(summary = "获得产品技术文件历史")
    public CommonResult<List<MdmItemTechnicalFileRespVO>> getTechnicalFileList(@RequestParam("itemId") Long itemId) {
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/dal/mysql/item/MdmItemTechnicalFileMapper.java
@@ -4,6 +4,8 @@
import cn.iocoder.yudao.module.mdm.dal.dataobject.item.MdmItemTechnicalFileDO;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@@ -16,11 +18,7 @@
                .orderByDesc(MdmItemTechnicalFileDO::getVersionNo));
    }
    default Integer selectMaxVersionNo(Long itemId) {
        MdmItemTechnicalFileDO row = selectOne(new LambdaQueryWrapper<MdmItemTechnicalFileDO>()
                .eq(MdmItemTechnicalFileDO::getItemId, itemId)
                .orderByDesc(MdmItemTechnicalFileDO::getVersionNo)
                .last("LIMIT 1"));
        return row == null || row.getVersionNo() == null ? 0 : row.getVersionNo();
    }
    // åŒ…含已移除记录,避免后续上传复用已有工单显示过的序号。
    @Select("SELECT COALESCE(MAX(version_no), 0) FROM mdm_item_technical_file WHERE item_id = #{itemId}")
    Integer selectMaxVersionNo(@Param("itemId") Long itemId);
}
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/item/MdmItemTechnicalFileService.java
@@ -7,5 +7,6 @@
public interface MdmItemTechnicalFileService {
    Long createTechnicalFile(MdmItemTechnicalFileCreateReqVO reqVO);
    void deleteTechnicalFile(Long itemId, Long id);
    List<MdmItemTechnicalFileRespVO> getTechnicalFileList(Long itemId);
}
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/item/MdmItemTechnicalFileServiceImpl.java
@@ -52,6 +52,17 @@
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteTechnicalFile(Long itemId, Long id) {
        MdmItemTechnicalFileDO file = mapper.selectById(id);
        if (file == null || !java.util.Objects.equals(file.getItemId(), itemId)) {
            throw new cn.iocoder.yudao.framework.common.exception.ServiceException(400, "技术文件不存在或不属于该物料");
        }
        // åªç§»é™¤ç‰©æ–™çš„当前文件记录。保留附件及文件本体,供已有工单快照使用。
        mapper.deleteById(id);
    }
    @Override
    public List<MdmItemTechnicalFileRespVO> getTechnicalFileList(Long itemId) {
        return mapper.selectListByItemId(itemId).stream().map(file -> {
            MdmItemTechnicalFileRespVO resp = BeanUtils.toBean(file, MdmItemTechnicalFileRespVO.class);
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/task/MesProTaskController.java
@@ -73,6 +73,22 @@
public class MesProTaskController {
    @Resource
    private cn.iocoder.yudao.module.mes.dal.mysql.pro.task.MesProTaskMapper pendingCountMapper;
    @GetMapping("/unfinished-workstation-counts")
    @Operation(summary = "获得各工作站未完成任务数量")
    public CommonResult<List<cn.iocoder.yudao.module.mes.controller.admin.pro.task.vo.MesProWorkstationPendingCountVO>> getPendingWorkstationCounts() {
        Long userId = cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId();
        Long assigneeId = userId != null && !permissionApi.hasAnyRoles(userId,
                cn.iocoder.yudao.module.system.enums.permission.RoleCodeEnum.SUPER_ADMIN.getCode()) ? userId : null;
        return success(pendingCountMapper.selectPendingCounts(assigneeId,
                cn.iocoder.yudao.module.mes.enums.pro.MesProTaskStatusEnum.PREPARE.getStatus(),
                cn.iocoder.yudao.module.mes.enums.pro.MesProTaskStatusEnum.PRODUCING.getStatus(),
                cn.iocoder.yudao.module.mes.enums.pro.MesProTaskStatusEnum.PARTIAL_FINISHED.getStatus(),
                cn.iocoder.yudao.module.mes.enums.pro.MesProTaskStatusEnum.PAUSED.getStatus()));
    }
    @Resource
    private cn.iocoder.yudao.module.mes.service.pro.task.schedule.MesProBatchScheduleService batchScheduleService;
    @PostMapping("/auto-schedule/preview")
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/task/vo/MesProWorkstationPendingCountVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,9 @@
package cn.iocoder.yudao.module.mes.controller.admin.pro.task.vo;
import lombok.Data;
@Data
public class MesProWorkstationPendingCountVO {
    private Long workstationId;
    private Long pendingCount;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/workorder/MesProWorkOrderController.java
@@ -67,6 +67,16 @@
    private MesProTaskService taskService;
    @Resource
    private MesProProcessService processService;
    @Resource
    private cn.iocoder.yudao.module.mes.service.pro.workorder.MesProWorkOrderTechnicalFileService technicalFileService;
    @GetMapping("/technical-files")
    @Operation(summary = "获得工单创建时的技术文件快照")
    @PreAuthorize("@ss.hasPermission('mes:pro-work-order:query')")
    public CommonResult<List<cn.iocoder.yudao.module.mdm.controller.admin.item.vo.MdmItemTechnicalFileRespVO>> getTechnicalFiles(@RequestParam("id") Long id) {
        workOrderService.validateWorkOrderExists(id);
        return success(technicalFileService.list(id));
    }
    @PostMapping("/create")
    @Operation(summary = "创建生产工单")
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/attachment/MesQcAttachmentController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,124 @@
package cn.iocoder.yudao.module.mes.controller.admin.qc.attachment;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.mes.service.qc.iqc.MesQcIqcService;
import cn.iocoder.yudao.module.mes.service.qc.ipqc.MesQcIpqcService;
import cn.iocoder.yudao.module.mes.service.qc.oqc.MesQcOqcService;
import cn.iocoder.yudao.module.mes.service.qc.rqc.MesQcRqcService;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import cn.iocoder.yudao.module.system.api.storage.dto.StorageBlobRespDTO;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/** è´¨æ£€é™„件独立于检验结论,上传附件不自动将产品判为合格。 */
@RestController
@RequestMapping("/mes/qc/attachment")
@Validated
public class MesQcAttachmentController {
    @Resource private MesQcIqcService iqcService;
    @Resource private MesQcIpqcService ipqcService;
    @Resource private MesQcOqcService oqcService;
    @Resource private MesQcRqcService rqcService;
    @Resource private StorageAttachmentApi storageApi;
    @Resource private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
    @GetMapping("/list")
    @PreAuthorize("@ss.hasPermission('mes:qc-' + #type.toLowerCase() + ':query')")
    public CommonResult<List<StorageBlobRespDTO>> list(
            @RequestParam @Pattern(regexp = "iqc|ipqc|oqc|rqc") String type,
            @RequestParam @NotNull Long id) {
        validateDocument(type, id);
        var files = storageApi.listAttachments(recordType(type), id);
        return success(files == null ? List.of() : files);
    }
    @PutMapping("/save")
    @PreAuthorize("@ss.hasPermission('mes:qc-' + #req.type.toLowerCase() + ':update')")
    @Transactional(rollbackFor = Exception.class)
    public CommonResult<Boolean> save(@Valid @RequestBody SaveReq req) {
        lockDocument(req.getType(), req.getId());
        validateDocument(req.getType(), req.getId());
        // bindAttachments ä»…替换关联,不删除共享文件本体。
        storageApi.bindAttachments("file", recordType(req.getType()), req.getId(), req.getBlobIds().stream().distinct().toList());
        return success(true);
    }
    @PostMapping("/append")
    @PreAuthorize("@ss.hasPermission('mes:qc-' + #req.type.toLowerCase() + ':update')")
    @Transactional(rollbackFor = Exception.class)
    public CommonResult<Boolean> append(@Valid @RequestBody AppendReq req) {
        lockDocument(req.getType(), req.getId());
        validateDocument(req.getType(), req.getId());
        var existing = storageApi.listAttachments(recordType(req.getType()), req.getId());
        var ids = new java.util.LinkedHashSet<Long>();
        if (existing != null) existing.forEach(blob -> ids.add(blob.getId()));
        ids.add(req.getBlobId());
        if (ids.size() > 50) throw new ServiceException(400, "每张质检单最多上传50份附件");
        storageApi.bindAttachments("file", recordType(req.getType()), req.getId(), List.copyOf(ids));
        return success(true);
    }
    @DeleteMapping("/remove")
    @PreAuthorize("@ss.hasPermission('mes:qc-' + #type.toLowerCase() + ':update')")
    @Transactional(rollbackFor = Exception.class)
    public CommonResult<Boolean> remove(
            @RequestParam @Pattern(regexp = "iqc|ipqc|oqc|rqc") String type,
            @RequestParam @NotNull Long id, @RequestParam @NotNull Long blobId) {
        lockDocument(type, id);
        validateDocument(type, id);
        storageApi.removeReference("file", recordType(type), id, blobId);
        return success(true);
    }
    private String recordType(String type) {
        return switch (type) {
            case "iqc", "ipqc", "oqc", "rqc" -> "mes_qc_" + type + "_attachment";
            default -> throw new ServiceException(400, "质检类型无效");
        };
    }
    private void lockDocument(String type, Long id) {
        String table = switch (type) {
            case "iqc" -> "mes_qc_iqc";
            case "ipqc" -> "mes_qc_ipqc";
            case "oqc" -> "mes_qc_oqc";
            case "rqc" -> "mes_qc_rqc";
            default -> throw new ServiceException(400, "质检类型无效");
        };
        var ids = jdbcTemplate.queryForList("SELECT id FROM " + table + " WHERE id = ? AND deleted = 0 FOR UPDATE", Long.class, id);
        if (ids.isEmpty()) throw new ServiceException(400, "质检单不存在");
    }
    private Integer validateDocument(String type, Long id) {
        return switch (type) {
            case "iqc" -> iqcService.validateIqcExists(id).getStatus();
            case "ipqc" -> ipqcService.validateIpqcExists(id).getStatus();
            case "oqc" -> oqcService.validateOqcExists(id).getStatus();
            case "rqc" -> rqcService.validateRqcExists(id).getStatus();
            default -> throw new ServiceException(400, "质检类型无效");
        };
    }
    @Data public static class SaveReq {
        @NotNull @Pattern(regexp = "iqc|ipqc|oqc|rqc") private String type;
        @NotNull private Long id;
        @NotEmpty @Size(max = 50) private List<@NotNull Long> blobIds;
    }
    @Data public static class AppendReq {
        @NotNull @Pattern(regexp = "iqc|ipqc|oqc|rqc") private String type;
        @NotNull private Long id;
        @NotNull private Long blobId;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/link/MesQcSourceLinkController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,38 @@
package cn.iocoder.yudao.module.mes.controller.admin.qc.link;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.mes.service.qc.link.MesQcSourceLinkService;
import jakarta.annotation.Resource;
import jakarta.validation.constraints.NotNull;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@RestController
@RequestMapping("/mes/qc/source-link")
@Validated
public class MesQcSourceLinkController {
    @Resource private MesQcSourceLinkService service;
    @GetMapping("/feedback-candidates")
    @PreAuthorize("@ss.hasPermission('mes:qc-ipqc:update')")
    public CommonResult<List<MesQcSourceLinkService.Candidate>> feedbackCandidates(@RequestParam @NotNull Long qcId) {
        return success(service.feedbackCandidates(qcId));
    }
    @PostMapping("/feedback")
    @PreAuthorize("@ss.hasPermission('mes:qc-ipqc:update')")
    public CommonResult<Boolean> feedback(@RequestParam @NotNull Long qcId, @RequestParam @NotNull Long lineId) {
        service.linkFeedback(qcId, lineId); return success(true);
    }
    @GetMapping("/sales-candidates")
    @PreAuthorize("@ss.hasPermission('mes:qc-oqc:update')")
    public CommonResult<List<MesQcSourceLinkService.Candidate>> salesCandidates(@RequestParam @NotNull Long qcId) {
        return success(service.salesCandidates(qcId));
    }
    @PostMapping("/sales")
    @PreAuthorize("@ss.hasPermission('mes:qc-oqc:update')")
    public CommonResult<Boolean> sales(@RequestParam @NotNull Long qcId, @RequestParam @NotNull Long lineId) {
        service.linkSales(qcId, lineId); return success(true);
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/scrap/MesWmScrapReceiptController.java
@@ -42,6 +42,49 @@
    @Resource private MesProWorkOrderMapper workOrderMapper;
    @Resource private MesWmTransactionService transactionService;
    @Resource private MesWmWarehouseAreaService areaService;
    @Resource private cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseService warehouseService;
    @Resource private cn.iocoder.yudao.module.mdm.api.item.MdmItemApi itemApi;
    @GetMapping("/order-outputs")
    @PreAuthorize("@ss.hasPermission('mes:wm-scrap-receipt:create')")
    public CommonResult<List<OutputItem>> orderOutputs(@RequestParam("workOrderId") Long workOrderId) {
        var order = workOrderMapper.selectById(workOrderId);
        if (order == null || !java.util.Objects.equals(order.getStatus(), cn.iocoder.yudao.module.mes.enums.pro.MesProWorkOrderStatusEnum.FINISHED.getStatus()))
            throw new ServiceException(400, "仅已完成的生产订单可以登记废料");
        var list = receiptMapper.selectOrderOutputs(workOrderId, cn.iocoder.yudao.module.mes.enums.wm.MesWmProductProduceStatusEnum.FINISHED.getStatus());
        var items = itemApi.getItemMap(list.stream().map(OutputItem::getItemId).collect(java.util.stream.Collectors.toSet()));
        list.forEach(output -> { var item = items.get(output.getItemId()); if (item != null) { output.setItemName(item.getName()); output.setItemCode(item.getCode()); } });
        return success(list);
    }
    @PostMapping("/create-from-output")
    @PreAuthorize("@ss.hasPermission('mes:wm-scrap-receipt:create')")
    @Transactional(rollbackFor = Exception.class)
    public CommonResult<Long> createFromOutput(@Valid @RequestBody CreateReq req) {
        var locked = workOrderMapper.lockScheduleOrders(List.of(req.getWorkOrderId()));
        if (locked.isEmpty() || !java.util.Objects.equals(locked.get(0).getStatus(), cn.iocoder.yudao.module.mes.enums.pro.MesProWorkOrderStatusEnum.FINISHED.getStatus()))
            throw new ServiceException(400, "仅已完成的生产订单可以登记废料");
        if (receiptMapper.selectOrderOutputs(req.getWorkOrderId(), cn.iocoder.yudao.module.mes.enums.wm.MesWmProductProduceStatusEnum.FINISHED.getStatus()).stream()
                .noneMatch(item -> java.util.Objects.equals(item.getItemId(), req.getItemId())
                        && java.util.Objects.equals(item.getBatchId(), req.getBatchId())
                        && java.util.Objects.equals(cn.hutool.core.util.StrUtil.blankToDefault(item.getBatchCode(), ""),
                                cn.hutool.core.util.StrUtil.blankToDefault(req.getBatchCode(), ""))))
            throw new ServiceException(400, "所选物料不是该订单的已完成产出");
        var warehouse = warehouseService.validateWarehouseExists(req.getWarehouseId());
        if (!java.util.Objects.equals("废料仓", warehouse.getWarehouseType()) && !java.util.Objects.equals("废料", warehouse.getWarehouseType()))
            throw new ServiceException(400, "请选择仓库类型为废料仓的仓库");
        return create(req);
    }
    @Data
    public static class OutputItem {
        private Long itemId;
        private String itemCode;
        private String itemName;
        private Long batchId;
        private String batchCode;
        private BigDecimal quantity;
    }
    @PostMapping("/create")
    @PreAuthorize("@ss.hasPermission('mes:wm-scrap-receipt:create')")
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pro/workorder/MesProWorkOrderTechnicalFileDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,19 @@
package cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("mes_pro_work_order_technical_file")
public class MesProWorkOrderTechnicalFileDO extends BaseDO {
    @TableId private Long id;
    private Long workOrderId;
    private Long sourceFileId;
    private Long blobId;
    private Integer versionNo;
    private String fileName;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/task/MesProTaskMapper.java
@@ -11,6 +11,8 @@
import cn.iocoder.yudao.module.mes.enums.pro.MesProTaskStatusEnum;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.math.BigDecimal;
import java.util.List;
@@ -23,6 +25,16 @@
@Mapper
public interface MesProTaskMapper extends BaseMapperX<MesProTaskDO> {
    @Select({"<script>",
            "SELECT t.workstation_id AS workstationId, COUNT(*) AS pendingCount FROM mes_pro_task t",
            "WHERE t.deleted = 0 AND t.workstation_id IS NOT NULL AND t.status IN (#{prepare}, #{producing}, #{partial}, #{paused})",
            "<if test='assigneeId != null'>AND EXISTS (SELECT 1 FROM mes_pro_task_assignee a",
            "WHERE a.task_id=t.id AND a.user_id=#{assigneeId} AND a.deleted=0)</if>",
            "GROUP BY t.workstation_id", "</script>"})
    java.util.List<cn.iocoder.yudao.module.mes.controller.admin.pro.task.vo.MesProWorkstationPendingCountVO> selectPendingCounts(
            @Param("assigneeId") Long assigneeId, @Param("prepare") Integer prepare,
            @Param("producing") Integer producing, @Param("partial") Integer partial, @Param("paused") Integer paused);
    /** æ‰€æœ‰æŽ’产写入口以相同顺序锁定工作站;事务提交后释放,跨进程防止资源竞态。 */
    @org.apache.ibatis.annotations.Select("SELECT id FROM mes_md_workstation WHERE deleted = 0 ORDER BY id FOR UPDATE")
    List<Long> lockSchedulingResources();
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/workorder/MesProWorkOrderTechnicalFileMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,13 @@
package cn.iocoder.yudao.module.mes.dal.mysql.pro.workorder;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderTechnicalFileDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface MesProWorkOrderTechnicalFileMapper extends BaseMapperX<MesProWorkOrderTechnicalFileDO> {
    default List<MesProWorkOrderTechnicalFileDO> selectByWorkOrderId(Long id) {
        return selectList(MesProWorkOrderTechnicalFileDO::getWorkOrderId, id);
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/scrap/MesWmScrapReceiptMapper.java
@@ -14,6 +14,12 @@
@Mapper
public interface MesWmScrapReceiptMapper extends BaseMapperX<MesWmScrapReceiptDO> {
    @Select("SELECT l.item_id AS itemId, l.batch_id AS batchId, l.batch_code AS batchCode, SUM(l.quantity) AS quantity FROM mes_wm_product_produce p " +
            "JOIN mes_wm_product_produce_line l ON l.produce_id=p.id AND l.deleted=0 " +
            "WHERE p.deleted=0 AND p.work_order_id=#{orderId} AND p.status=#{finished} " +
            "AND l.item_id IS NOT NULL AND l.quantity > 0 GROUP BY l.item_id,l.batch_id,l.batch_code")
    List<cn.iocoder.yudao.module.mes.controller.admin.wm.scrap.MesWmScrapReceiptController.OutputItem> selectOrderOutputs(
            @Param("orderId") Long orderId, @Param("finished") Integer finished);
    default MesWmScrapReceiptDO selectByCode(String code) {
        return selectOne(MesWmScrapReceiptDO::getCode, code);
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/qc/MesQcCheckResultEnum.java
@@ -38,10 +38,10 @@
     * åˆ¤æ–­æ˜¯å¦å¯ä»¥å…¥åº“
     *
     * @param type æ£€æµ‹ç»“æžœ
     * @return true å¯ä»¥å…¥åº“
     * @return true å¯ä»¥å…¥åº“。当前质检字典将 2 ç”¨ä½œä¸é€šè¿‡ï¼Œä¸èƒ½æŒ‰æ—§â€œç‰¹é‡‡â€å«ä¹‰æ”¾è¡Œã€‚
     */
    public static boolean canInbound(Integer type) {
        return QUALIFIED.getType().equals(type) || SPECIAL_APPROVAL.getType().equals(type);
        return QUALIFIED.getType().equals(type);
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/workorder/MesProWorkOrderServiceImpl.java
@@ -74,6 +74,8 @@
    private MesMdWorkstationService workstationService;
    @Resource
    private CrmCustomerApi customerApi;
    @Resource
    private MesProWorkOrderTechnicalFileService technicalFileService;
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -89,6 +91,7 @@
        MesProWorkOrderDO workOrder = BeanUtils.toBean(createReqVO, MesProWorkOrderDO.class);
        workOrder.setStatus(MesProWorkOrderStatusEnum.PREPARE.getStatus());
        workOrderMapper.insert(workOrder);
        technicalFileService.snapshot(workOrder.getId(), workOrder.getProductId());
        // 3. è‡ªåŠ¨ç”Ÿæˆ BOM:根据产品 BOM ç”Ÿæˆå·¥å• BOM
        workOrderBomService.generateWorkOrderBom(workOrder.getId(), createReqVO, false);
@@ -125,6 +128,7 @@
        // å·¥å•盘号沿用创建时的来源快照,避免与计划和已生成的下游记录不一致。
        updateObj.setReelNo(null);
        workOrderMapper.updateById(updateObj);
        if (productChanged) technicalFileService.replace(updateReqVO.getId(), updateReqVO.getProductId());
    }
    @Override
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/workorder/MesProWorkOrderTechnicalFileService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,57 @@
package cn.iocoder.yudao.module.mes.service.pro.workorder;
import cn.iocoder.yudao.module.mdm.controller.admin.item.vo.MdmItemTechnicalFileRespVO;
import cn.iocoder.yudao.module.mdm.service.item.MdmItemTechnicalFileService;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderTechnicalFileDO;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.workorder.MesProWorkOrderTechnicalFileMapper;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class MesProWorkOrderTechnicalFileService {
    private static final String RECORD_TYPE = "mes_work_order_technical_file";
    @Resource private MdmItemTechnicalFileService itemTechnicalFileService;
    @Resource private MesProWorkOrderTechnicalFileMapper mapper;
    @Resource private StorageAttachmentApi storageApi;
    /** è°ƒç”¨æ–¹åœ¨å·¥å•事务中执行,避免清单与工单创建结果不一致。 */
    public void snapshot(Long orderId, Long itemId) {
        var files = itemTechnicalFileService.getTechnicalFileList(itemId);
        for (var file : files) {
            MesProWorkOrderTechnicalFileDO row = new MesProWorkOrderTechnicalFileDO();
            row.setWorkOrderId(orderId);
            row.setSourceFileId(file.getId());
            row.setBlobId(file.getBlobId());
            row.setVersionNo(file.getVersionNo());
            row.setFileName(file.getFileName());
            mapper.insert(row);
        }
        if (!files.isEmpty()) storageApi.bindAttachments("file", RECORD_TYPE, orderId,
                files.stream().map(MdmItemTechnicalFileRespVO::getBlobId).distinct().toList());
    }
    public void replace(Long orderId, Long itemId) {
        // ä¸è°ƒç”¨ deleteAttachmentsByRecord:该接口会删除共享的底层文件。
        mapper.delete(MesProWorkOrderTechnicalFileDO::getWorkOrderId, orderId);
        snapshot(orderId, itemId);
    }
    public List<MdmItemTechnicalFileRespVO> list(Long orderId) {
        var blobs = storageApi.listAttachments(RECORD_TYPE, orderId);
        var byId = blobs == null ? Map.<Long, cn.iocoder.yudao.module.system.api.storage.dto.StorageBlobRespDTO>of()
                : blobs.stream().collect(Collectors.toMap(b -> b.getId(), Function.identity(), (a, b) -> a));
        return mapper.selectByWorkOrderId(orderId).stream().map(row -> {
            MdmItemTechnicalFileRespVO result = new MdmItemTechnicalFileRespVO();
            result.setId(row.getSourceFileId()); result.setBlobId(row.getBlobId());
            result.setVersionNo(row.getVersionNo()); result.setFileName(row.getFileName());
            var blob = byId.get(row.getBlobId());
            if (blob != null) { result.setPreviewURL(blob.getPreviewURL()); result.setDownloadURL(blob.getDownloadURL()); }
            return result;
        }).toList();
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/MesQcCompletionHelper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,37 @@
package cn.iocoder.yudao.module.mes.service.qc;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
/** æŒ‰æ•°é‡æ±‡æ€»è´¨æ£€ç»“果,不推断不合格品的后续处置方式。 */
@Component
public class MesQcCompletionHelper {
    @Resource private StorageAttachmentApi storageApi;
    @Resource private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
    public void lockDocument(String type, Long id) {
        String table = switch (type) {
            case "iqc", "ipqc", "oqc", "rqc" -> "mes_qc_" + type;
            default -> throw new ServiceException(400, "质检类型无效");
        };
        var rows = jdbcTemplate.queryForList("SELECT id FROM " + table + " WHERE id = ? AND deleted = 0 FOR UPDATE", Long.class, id);
        if (rows.isEmpty()) throw new ServiceException(400, "质检单不存在");
    }
    public int validateAndGetResult(String type, Long id, BigDecimal qualified, BigDecimal unqualified, BigDecimal checked) {
        if (qualified == null || unqualified == null || qualified.signum() < 0 || unqualified.signum() < 0) {
            throw new ServiceException(400, "请填写大于等于零的合格数量和不合格数量");
        }
        BigDecimal total = qualified.add(unqualified);
        if (total.signum() <= 0 || checked == null || total.compareTo(checked) != 0) {
            throw new ServiceException(400, "合格数量与不合格数量之和须大于零且等于检测数量");
        }
        var attachments = storageApi.listAttachments("mes_qc_" + type + "_attachment", id);
        if (attachments == null || attachments.isEmpty()) throw new ServiceException(400, "请先上传检验附件");
        // ä¸ŽçŽ°æœ‰ mes_qc_check_result å­—典一致:1 é€šè¿‡ï¼Œ2 ä¸é€šè¿‡ã€‚
        return unqualified.signum() == 0 ? 1 : 2;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ipqc/MesQcIpqcServiceImpl.java
@@ -76,6 +76,9 @@
@Slf4j
public class MesQcIpqcServiceImpl implements MesQcIpqcService {
    @Resource
    private cn.iocoder.yudao.module.mes.service.qc.MesQcCompletionHelper completionHelper;
    /**
     * IPQC ä¿å­˜æ•°æ®æ ¡éªŒä¸Šä¸‹æ–‡
     *
@@ -141,22 +144,7 @@
        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 èŽ·å–æ¥æºå•æ®ç¼–å·
        String sourceDocCode = validateAndGetSourceDocCode(
                createReqVO.getSourceDocType(), createReqVO.getSourceDocId(), createReqVO.getSourceLineId());
@@ -166,13 +154,10 @@
                .setItemId(itemId)
                .setReelNo(workOrder.getReelNo())
                .setProcessId(getProcessId(itemId, ctx.workstation().getProcessId()))
                .setTemplateId(templateId)
                .setTemplateId(null)
                .setSourceDocCode(sourceDocCode)
                .setStatus(MesQcStatusEnum.DRAFT.getStatus());
        ipqcMapper.insert(ipqc);
        // 3. ä»Žæ¨¡æ¿æŒ‡æ ‡è‡ªåŠ¨ç”Ÿæˆæ£€éªŒè¡Œ
        ipqcLineService.createLinesFromTemplate(ipqc.getId(), templateId);
        return ipqc.getId();
    }
@@ -247,18 +232,16 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void finishIpqc(Long id) {
        completionHelper.lockDocument("ipqc", id);
        // 1.1 æ ¡éªŒå­˜åœ¨ + è‰ç¨¿çŠ¶æ€
        MesQcIpqcDO ipqc = validateIpqcStatusPrepare(id);
        // 1.2 æ ¡éªŒæ£€æµ‹ç»“论必填
        if (ipqc.getCheckResult() == null) {
            throw exception(QC_IPQC_CHECK_RESULT_EMPTY);
        }
        // 1.3 æ ¡éªŒè‡³å°‘存在一条检测结果
        indicatorResultService.validateIndicatorResultExistsByQcIdAndType(id, MesQcTypeEnum.IPQC.getType());
        int result = completionHelper.validateAndGetResult("ipqc", id, ipqc.getQualifiedQuantity(), ipqc.getUnqualifiedQuantity(), ipqc.getCheckQuantity());
        ipqc.setCheckResult(result);
        // 2. æ›´æ–°çŠ¶æ€ä¸ºå·²å®Œæˆ
        ipqcMapper.updateById(new MesQcIpqcDO()
                .setId(id).setStatus(MesQcStatusEnum.FINISHED.getStatus()));
                .setId(id).setStatus(MesQcStatusEnum.FINISHED.getStatus()).setCheckResult(result));
        // 3. ä¸åˆæ ¼æ—¶è‡ªåŠ¨åˆ›å»º NCR
        generateNcrIfNeeded(ipqc);
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/iqc/MesQcIqcServiceImpl.java
@@ -63,6 +63,9 @@
public class MesQcIqcServiceImpl implements MesQcIqcService {
    @Resource
    private cn.iocoder.yudao.module.mes.service.qc.MesQcCompletionHelper completionHelper;
    @Resource
    private MesQcIqcMapper iqcMapper;
    @Resource
@@ -104,33 +107,15 @@
        // 1.2 æ ¡éªŒæ¥æºå•据参数,并验证数据存在
        String sourceDocCode = validateAndGetSourceDocCode(
                createReqVO.getSourceDocType(), createReqVO.getSourceDocId(), createReqVO.getSourceLineId());
        // 1.3 æ ¹æ®äº§å“ + IQC ç±»åž‹æŸ¥æ‰¾å¯ç”¨è´¨æ£€æ–¹æ¡ˆï¼ˆæ”¯æŒå‰ç«¯æ˜¾å¼æŒ‡å®šï¼‰
        List<MesQcTemplateItemDO> templateItems = templateItemService.getTemplateItemList(
                createReqVO.getItemId(), MesQcTypeEnum.IQC.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(item -> Objects.equals(item.getTemplateId(), createReqVO.getTemplateId()))
                    .findFirst().orElse(null);
            if (templateItem == null) {
                throw exception(QC_NO_TEMPLATE); // æŒ‡å®šäº†æ–¹æ¡ˆä½†ä¸åœ¨åŒ¹é…åˆ—表中,拒绝
            }
        }
        Long templateId = templateItem.getTemplateId();
        // æ–°å»ºè´¨æ£€å•不依赖质检方案。
        // 2. æ’入主表
        MesQcIqcDO iqc = BeanUtils.toBean(createReqVO, MesQcIqcDO.class)
                .setTemplateId(templateId)
                .setTemplateId(null)
                .setCheckQuantity(createReqVO.getQualifiedQuantity().add(createReqVO.getUnqualifiedQuantity()))
                .setSourceDocCode(sourceDocCode)
                .setStatus(MesQcStatusEnum.DRAFT.getStatus());
        iqcMapper.insert(iqc);
        // 3. ä»Žæ¨¡æ¿æŒ‡æ ‡è‡ªåŠ¨ç”Ÿæˆæ£€éªŒè¡Œ
        iqcLineService.createLinesFromTemplate(iqc.getId(), templateId);
        return iqc.getId();
    }
@@ -175,17 +160,16 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void finishIqc(Long id) {
        completionHelper.lockDocument("iqc", id);
        // 1.1 æ ¡éªŒå­˜åœ¨ + è‰ç¨¿çŠ¶æ€
        MesQcIqcDO iqc = validateIqcStatusPrepare(id);
        if (iqc.getCheckResult() == null) {
            throw exception(QC_IQC_CHECK_RESULT_EMPTY);
        }
        // 1.2 æ ¡éªŒè‡³å°‘存在一条检测结果
        indicatorResultService.validateIndicatorResultExistsByQcIdAndType(id, MesQcTypeEnum.IQC.getType());
        int result = completionHelper.validateAndGetResult("iqc", id, iqc.getQualifiedQuantity(), iqc.getUnqualifiedQuantity(), iqc.getCheckQuantity());
        iqc.setCheckResult(result);
        // 2. æ›´æ–°çŠ¶æ€ä¸ºå·²å®Œæˆ
        MesQcIqcDO updateObj = new MesQcIqcDO()
                .setId(id).setStatus(MesQcStatusEnum.FINISHED.getStatus());
                .setId(id).setStatus(MesQcStatusEnum.FINISHED.getStatus()).setCheckResult(result);
        iqcMapper.updateById(updateObj);
        // 3. ä¸åˆæ ¼æ—¶è‡ªåŠ¨åˆ›å»º NCR
@@ -387,17 +371,6 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createIqcFromArrivalNotice(Long noticeId, MesWmArrivalNoticeLineDO line, String noticeCode, Long vendorId) {
        // 1. æ ¡éªŒç‰©æ–™æ˜¯å¦é…ç½®äº† IQC æ£€éªŒæ¨¡æ¿
        MesQcTemplateItemDO templateItem;
        try {
            templateItem = templateItemService.getRequiredTemplateByItemIdAndType(
                    line.getItemId(), MesQcTypeEnum.IQC.getType());
        } catch (Exception e) {
            // èŽ·å–ç‰©æ–™ä¿¡æ¯ï¼Œæä¾›æ›´æ˜Žç¡®çš„é”™è¯¯æç¤º
            String itemInfo = getItemInfo(line.getItemId());
            throw exception(QC_NO_TEMPLATE_FOR_ITEM, itemInfo);
        }
        Long templateId = templateItem.getTemplateId();
        // 2. ç”Ÿæˆæ£€éªŒå•编码
        String code = "IQC" + System.currentTimeMillis();
@@ -406,7 +379,7 @@
        MesQcIqcDO iqc = new MesQcIqcDO();
        iqc.setCode(code);
        iqc.setName(noticeCode + "-物料检验");
        iqc.setTemplateId(templateId);
        iqc.setSourceDocType(MesBizTypeConstants.WM_ARRIVAL_NOTICE);
        iqc.setSourceDocId(noticeId);
        iqc.setSourceLineId(line.getId());
@@ -427,8 +400,6 @@
        iqc.setStatus(MesQcStatusEnum.DRAFT.getStatus());
        iqcMapper.insert(iqc);
        // 4. ä»Žæ¨¡æ¿æŒ‡æ ‡è‡ªåŠ¨ç”Ÿæˆæ£€éªŒè¡Œ
        iqcLineService.createLinesFromTemplate(iqc.getId(), templateId);
        return iqc.getId();
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/link/MesQcSourceLinkService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,234 @@
package cn.iocoder.yudao.module.mes.service.qc.link;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.erp.api.sale.ErpSaleOrderApi;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.feedback.MesProFeedbackDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ipqc.MesQcIpqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.oqc.MesQcOqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productproduce.MesWmProductProduceLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesLineDO;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.feedback.MesProFeedbackMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.ipqc.MesQcIpqcMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.oqc.MesQcOqcMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productproduce.MesWmProductProduceLineMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesLineMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesMapper;
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.pro.MesProFeedbackStatusEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmProductSalesStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmQualityStatusEnum;
import cn.iocoder.yudao.module.mes.service.pro.feedback.MesProFeedbackService;
import cn.iocoder.yudao.module.mes.service.wm.productsales.MesWmProductSalesLineService;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.*;
/** ä¸€å¼ å·²å®Œæˆè´¨æ£€å•对应一条数量完全一致的待检业务行。 */
@Service
public class MesQcSourceLinkService {
    @Resource private MesQcIpqcMapper ipqcMapper;
    @Resource private MesQcOqcMapper oqcMapper;
    @Resource private MesProFeedbackMapper feedbackMapper;
    @Resource private MesWmProductProduceLineMapper produceLineMapper;
    @Resource private MesWmProductSalesMapper salesMapper;
    @Resource private MesWmProductSalesLineMapper salesLineMapper;
    @Resource private ErpSaleOrderApi saleOrderApi;
    @Resource @Lazy private MesProFeedbackService feedbackService;
    @Resource @Lazy private MesWmProductSalesLineService salesLineService;
    public record Candidate(Long lineId, Long documentId, String documentCode, String reelNo, BigDecimal quantity) {}
    public List<Candidate> feedbackCandidates(Long qcId) {
        MesQcIpqcDO qc = ipqcMapper.selectById(qcId);
        requireIpqc(qc);
        var lines = produceLineMapper.selectList(new LambdaQueryWrapperX<MesWmProductProduceLineDO>()
                .eq(MesWmProductProduceLineDO::getItemId, qc.getItemId())
                .eq(MesWmProductProduceLineDO::getQualityStatus, MesWmQualityStatusEnum.PENDING.getStatus())
                .isNotNull(MesWmProductProduceLineDO::getFeedbackId).orderByDesc(MesWmProductProduceLineDO::getId));
        List<Candidate> result = new ArrayList<>();
        for (var line : lines) {
            MesProFeedbackDO feedback = feedbackMapper.selectById(line.getFeedbackId());
            if (feedback != null && feedbackMismatch(qc, line, feedback) == null) {
                result.add(new Candidate(line.getId(), feedback.getId(), feedback.getCode(), line.getReelNo(), line.getQuantity()));
            }
        }
        return result;
    }
    public List<Candidate> salesCandidates(Long qcId) {
        MesQcOqcDO qc = oqcMapper.selectById(qcId);
        requireOqc(qc);
        var lines = salesLineMapper.selectList(new LambdaQueryWrapperX<MesWmProductSalesLineDO>()
                .eq(MesWmProductSalesLineDO::getItemId, qc.getItemId())
                .eq(MesWmProductSalesLineDO::getOqcCheckFlag, true)
                .isNull(MesWmProductSalesLineDO::getOqcId).orderByDesc(MesWmProductSalesLineDO::getId));
        List<Candidate> result = new ArrayList<>();
        for (var line : lines) {
            var sales = salesMapper.selectById(line.getSalesId());
            String reel = salesReel(line);
            if (sales != null && salesMismatch(qc, line, sales, reel) == null) {
                result.add(new Candidate(line.getId(), sales.getId(), sales.getCode(), reel, line.getQuantity()));
            }
        }
        return result;
    }
    @Transactional(rollbackFor = Exception.class)
    public void linkFeedback(Long qcId, Long lineId) {
        var qc = ipqcMapper.selectOne(new LambdaQueryWrapperX<MesQcIpqcDO>().eq(MesQcIpqcDO::getId, qcId).last("FOR UPDATE"));
        requireIpqc(qc);
        var line = produceLineMapper.selectById(lineId);
        if (line == null || line.getFeedbackId() == null) {
            throw new ServiceException(400, "所选报工产出行不存在或未关联报工单,请刷新候选列表后重试");
        }
        var feedback = feedbackMapper.selectByIdForUpdate(line.getFeedbackId());
        line = produceLineMapper.selectOne(new LambdaQueryWrapperX<MesWmProductProduceLineDO>()
                .eq(MesWmProductProduceLineDO::getId, lineId).last("FOR UPDATE"));
        String mismatch = feedbackMismatch(qc, line, feedback);
        if (mismatch != null) throw new ServiceException(400, mismatch);
        long used = ipqcMapper.selectCount(new LambdaQueryWrapperX<MesQcIpqcDO>()
                .eq(MesQcIpqcDO::getSourceDocType, MesBizTypeConstants.PRO_FEEDBACK)
                .eq(MesQcIpqcDO::getSourceLineId, lineId));
        if (used > 0) throw new ServiceException(400, "该待检产出行已被其他过程检验单关联,请刷新候选列表后重试");
        ipqcMapper.updateById(new MesQcIpqcDO().setId(qcId).setSourceDocType(MesBizTypeConstants.PRO_FEEDBACK)
                .setSourceDocId(feedback.getId()).setSourceLineId(lineId).setSourceDocCode(feedback.getCode()));
        feedbackService.updateProFeedbackWhenIpqcFinish(feedback.getId(), lineId, qc.getQualifiedQuantity(), qc.getUnqualifiedQuantity(),
                zero(qc.getLaborScrapQuantity()), zero(qc.getMaterialScrapQuantity()), zero(qc.getOtherScrapQuantity()));
    }
    @Transactional(rollbackFor = Exception.class)
    public void linkSales(Long qcId, Long lineId) {
        var qc = oqcMapper.selectOne(new LambdaQueryWrapperX<MesQcOqcDO>().eq(MesQcOqcDO::getId, qcId).last("FOR UPDATE"));
        requireOqc(qc);
        var line = salesLineMapper.selectById(lineId);
        if (line == null || line.getSalesId() == null) {
            throw new ServiceException(400, "所选销售出库明细不存在或未关联出库单,请刷新候选列表后重试");
        }
        var sales = salesMapper.selectOne(new LambdaQueryWrapperX<MesWmProductSalesDO>()
                .eq(MesWmProductSalesDO::getId, line.getSalesId()).last("FOR UPDATE"));
        line = salesLineMapper.selectOne(new LambdaQueryWrapperX<MesWmProductSalesLineDO>()
                .eq(MesWmProductSalesLineDO::getId, lineId).last("FOR UPDATE"));
        String mismatch = salesMismatch(qc, line, sales, salesReel(line));
        if (mismatch != null) throw new ServiceException(400, mismatch);
        var linked = oqcMapper.selectOne(new LambdaQueryWrapperX<MesQcOqcDO>()
                .eq(MesQcOqcDO::getSourceDocType, MesBizTypeConstants.WM_PRODUCT_SALES)
                .eq(MesQcOqcDO::getSourceLineId, lineId).last("LIMIT 1"));
        if (linked != null) {
            throw new ServiceException(400, "该出库明细已关联出货检验单[" + linked.getCode() + "],请刷新候选列表后重试");
        }
        oqcMapper.updateById(new MesQcOqcDO().setId(qcId).setSourceDocType(MesBizTypeConstants.WM_PRODUCT_SALES)
                .setSourceDocId(sales.getId()).setSourceLineId(lineId).setSourceDocCode(sales.getCode()));
        salesLineService.updateProductSalesLineWhenOqcFinish(lineId, qcId, qc.getCheckResult());
    }
    /** è¿”回第一处不满足的原因;为空表示可关联。 */
    private String feedbackMismatch(MesQcIpqcDO q, MesWmProductProduceLineDO l, MesProFeedbackDO f) {
        if (l == null) return "所选报工产出行不存在或已删除,请刷新候选列表后重试";
        if (f == null) return "关联的报工单不存在或已删除,请刷新候选列表后重试";
        if (!Objects.equals(f.getStatus(), MesProFeedbackStatusEnum.UNCHECK.getStatus())) {
            return "报工单[" + f.getCode() + "]当前状态为[" + feedbackStatusName(f.getStatus()) + "],仅待检验报工可关联,请重新选择";
        }
        if (!Objects.equals(l.getQualityStatus(), MesWmQualityStatusEnum.PENDING.getStatus())) {
            return "报工产出行当前质量状态为[" + qualityStatusName(l.getQualityStatus()) + "],已被检验或已入库,请重新选择";
        }
        if (!Objects.equals(q.getItemId(), l.getItemId())) {
            return "物料不一致:质检单物料ID=" + display(q.getItemId()) + ",报工产出行物料ID=" + display(l.getItemId()) + ",请重新选择";
        }
        if (!Objects.equals(q.getWorkOrderId(), f.getWorkOrderId())) {
            return "生产工单不一致:质检单工单ID=" + display(q.getWorkOrderId()) + ",报工单工单ID=" + display(f.getWorkOrderId()) + ",请重新选择";
        }
        if (q.getTaskId() != null && !Objects.equals(q.getTaskId(), f.getTaskId())) {
            return "生产任务不一致:质检单任务ID=" + display(q.getTaskId()) + ",报工单任务ID=" + display(f.getTaskId()) + ",请重新选择";
        }
        if (!sameReel(q.getReelNo(), l.getReelNo())) {
            return "盘号不一致:质检单盘号[" + display(q.getReelNo()) + "],报工产出行盘号[" + display(l.getReelNo()) + "],请重新选择";
        }
        if (!sameQty(q.getCheckQuantity(), l.getQuantity())) {
            return "数量不一致:质检单检测数量[" + display(q.getCheckQuantity()) + "],待检产出行数量[" + display(l.getQuantity()) + "],请重新选择";
        }
        return null;
    }
    private String salesMismatch(MesQcOqcDO q, MesWmProductSalesLineDO l, MesWmProductSalesDO s, String reel) {
        if (l == null) return "所选销售出库明细不存在或已删除,请刷新候选列表后重试";
        if (s == null) return "所属销售出库单不存在或已删除,请刷新候选列表后重试";
        if (!Objects.equals(s.getStatus(), MesWmProductSalesStatusEnum.CONFIRMED.getStatus())) {
            return "销售出库单[" + s.getCode() + "]当前状态为[" + salesStatusName(s.getStatus()) + "],仅待检测出库单可关联,请重新选择";
        }
        if (!Boolean.TRUE.equals(l.getOqcCheckFlag())) {
            return "该出库明细未勾选「需要出货检验」,无法关联,请先确认出库明细设置";
        }
        if (l.getOqcId() != null) {
            return "该出库明细已关联质检单ID=" + l.getOqcId() + ",请刷新候选列表后重新选择";
        }
        if (!Objects.equals(q.getItemId(), l.getItemId())) {
            return "物料不一致:质检单物料ID=" + display(q.getItemId()) + ",出库明细物料ID=" + display(l.getItemId()) + ",请重新选择";
        }
        if (q.getClientId() != null && !Objects.equals(q.getClientId(), s.getClientId())) {
            return "客户不一致:质检单客户ID=" + display(q.getClientId()) + ",销售出库单客户ID=" + display(s.getClientId()) + ",请重新选择";
        }
        if (!sameReel(q.getReelNo(), reel)) {
            return "盘号不一致:质检单盘号[" + display(q.getReelNo()) + "],出库明细盘号[" + display(reel) + "],请确认销售订单明细盘号后重新选择";
        }
        if (!sameQty(q.getCheckQuantity(), l.getQuantity())) {
            return "数量不一致:质检单检测数量[" + display(q.getCheckQuantity()) + "],待检出库明细数量[" + display(l.getQuantity()) + "],请重新选择";
        }
        return null;
    }
    private String salesReel(MesWmProductSalesLineDO line) {
        if (line == null || line.getSaleOrderItemId() == null) return null;
        var item = saleOrderApi.getSaleOrderItem(line.getSaleOrderItemId()).getCheckedData();
        return item == null ? null : item.getReelNo();
    }
    private static boolean sameReel(String a, String b) { return a != null && b != null && !a.isBlank() && a.trim().equals(b.trim()); }
    private static boolean sameQty(BigDecimal a, BigDecimal b) { return a != null && b != null && a.signum() > 0 && a.compareTo(b) == 0; }
    private static BigDecimal zero(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; }
    private static String display(Object value) { return value == null ? "空" : value.toString(); }
    private static String feedbackStatusName(Integer status) {
        for (var e : MesProFeedbackStatusEnum.values()) if (Objects.equals(e.getStatus(), status)) return e.getName();
        return "未知(" + display(status) + ")";
    }
    private static String qualityStatusName(Integer status) {
        for (var e : MesWmQualityStatusEnum.values()) if (Objects.equals(e.getStatus(), status)) return e.getName();
        return "未知(" + display(status) + ")";
    }
    private static String salesStatusName(Integer status) {
        for (var e : MesWmProductSalesStatusEnum.values()) if (Objects.equals(e.getStatus(), status)) return e.getName();
        return "未知(" + display(status) + ")";
    }
    private static void requireIpqc(MesQcIpqcDO q) {
        if (q == null) throw new ServiceException(400, "过程检验单不存在或已删除,请刷新后重试");
        if (!Objects.equals(q.getStatus(), MesQcStatusEnum.FINISHED.getStatus())) {
            throw new ServiceException(400, "仅已完成的过程检验单可关联,当前状态为[" + qcStatusName(q.getStatus()) + "]");
        }
        if (q.getSourceDocType() != null || q.getSourceDocId() != null || q.getSourceLineId() != null) {
            throw new ServiceException(400, "该过程检验单已关联来源单据[" + display(q.getSourceDocCode()) + "],不能重复关联");
        }
        if (q.getReelNo() == null || q.getReelNo().isBlank()) {
            throw new ServiceException(400, "该过程检验单缺少盘号,无法按盘号筛选待检产出行,请补充盘号后重试");
        }
    }
    private static void requireOqc(MesQcOqcDO q) {
        if (q == null) throw new ServiceException(400, "出货检验单不存在或已删除,请刷新后重试");
        if (!Objects.equals(q.getStatus(), MesQcStatusEnum.FINISHED.getStatus())) {
            throw new ServiceException(400, "仅已完成的出货检验单可关联,当前状态为[" + qcStatusName(q.getStatus()) + "]");
        }
        if (q.getSourceDocType() != null || q.getSourceDocId() != null || q.getSourceLineId() != null) {
            throw new ServiceException(400, "该出货检验单已关联来源单据[" + display(q.getSourceDocCode()) + "],不能重复关联");
        }
        if (q.getReelNo() == null || q.getReelNo().isBlank()) {
            throw new ServiceException(400, "该出货检验单缺少盘号,无法按盘号筛选待检出库明细,请补充盘号后重试");
        }
    }
    private static String qcStatusName(Integer status) {
        for (var e : MesQcStatusEnum.values()) if (Objects.equals(e.getStatus(), status)) return e.getName();
        return "未知(" + display(status) + ")";
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/oqc/MesQcOqcServiceImpl.java
@@ -67,6 +67,9 @@
public class MesQcOqcServiceImpl implements MesQcOqcService {
    @Resource
    private cn.iocoder.yudao.module.mes.service.qc.MesQcCompletionHelper completionHelper;
    @Resource
    private MesQcOqcMapper oqcMapper;
    @Resource
@@ -111,26 +114,11 @@
        // 1.2 æ ¡éªŒæ¥æºå•据参数,并验证数据存在
        String sourceDocCode = validateAndGetSourceDocCode(
                createReqVO.getSourceDocType(), createReqVO.getSourceLineId());
        // 1.3 æ ¹æ®äº§å“ + OQC ç±»åž‹æŸ¥æ‰¾å¯ç”¨è´¨æ£€æ–¹æ¡ˆï¼ˆæ”¯æŒå‰ç«¯æ˜¾å¼æŒ‡å®šï¼‰
        List<MesQcTemplateItemDO> templateItems = templateItemService.getTemplateItemList(
                createReqVO.getItemId(), MesQcTypeEnum.OQC.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();
        // æ–°å»ºè´¨æ£€å•不依赖质检方案。
        // 2. æ’入主表
        MesQcOqcDO oqc = BeanUtils.toBean(createReqVO, MesQcOqcDO.class)
                .setTemplateId(templateId)
                .setTemplateId(null)
                .setSourceDocCode(sourceDocCode)
                .setStatus(MesQcStatusEnum.DRAFT.getStatus());
        if (createReqVO.getSourceLineId() != null) {
@@ -138,9 +126,6 @@
                    .validateProductSalesLineExists(createReqVO.getSourceLineId())));
        }
        oqcMapper.insert(oqc);
        // 3. ä»Žæ¨¡æ¿æŒ‡æ ‡è‡ªåŠ¨ç”Ÿæˆæ£€éªŒè¡Œ
        oqcLineService.createLinesFromTemplate(oqc.getId(), templateId);
        return oqc.getId();
    }
@@ -176,18 +161,16 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void finishOqc(Long id) {
        completionHelper.lockDocument("oqc", id);
        // 1.1 æ ¡éªŒå­˜åœ¨ + è‰ç¨¿çŠ¶æ€
        MesQcOqcDO oqc = validateOqcStatusPrepare(id);
        // 1.2 æ ¡éªŒæ£€æµ‹ç»“论必填
        if (oqc.getCheckResult() == null) {
            throw exception(QC_OQC_CHECK_RESULT_EMPTY);
        }
        // 1.3 æ ¡éªŒè‡³å°‘存在一条检测结果
        indicatorResultService.validateIndicatorResultExistsByQcIdAndType(id, MesQcTypeEnum.OQC.getType());
        int result = completionHelper.validateAndGetResult("oqc", id, oqc.getQualifiedQuantity(), oqc.getUnqualifiedQuantity(), oqc.getCheckQuantity());
        oqc.setCheckResult(result);
        // 2. æ›´æ–°çŠ¶æ€ä¸ºå·²å®Œæˆ
        MesQcOqcDO updateObj = new MesQcOqcDO()
                .setId(id).setStatus(MesQcStatusEnum.FINISHED.getStatus());
                .setId(id).setStatus(MesQcStatusEnum.FINISHED.getStatus()).setCheckResult(result);
        oqcMapper.updateById(updateObj);
        // 3. ä¸åˆæ ¼æ—¶è‡ªåŠ¨åˆ›å»º NCR
@@ -367,17 +350,6 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createOqcFromProductSales(Long salesId, MesWmProductSalesLineDO line, String salesCode, Long clientId) {
        // 1. æ ¡éªŒç‰©æ–™æ˜¯å¦é…ç½®äº† OQC æ£€éªŒæ¨¡æ¿
        MesQcTemplateItemDO templateItem;
        try {
            templateItem = templateItemService.getRequiredTemplateByItemIdAndType(
                    line.getItemId(), MesQcTypeEnum.OQC.getType());
        } catch (Exception e) {
            // èŽ·å–ç‰©æ–™ä¿¡æ¯ï¼Œæä¾›æ›´æ˜Žç¡®çš„é”™è¯¯æç¤º
            String itemInfo = getItemInfo(line.getItemId());
            throw exception(QC_NO_TEMPLATE_FOR_ITEM, itemInfo);
        }
        Long templateId = templateItem.getTemplateId();
        // 2. ç”Ÿæˆæ£€éªŒå•编码
        String code = "OQC" + System.currentTimeMillis();
@@ -386,7 +358,7 @@
        MesQcOqcDO oqc = new MesQcOqcDO();
        oqc.setCode(code);
        oqc.setName(salesCode + "-出货检验");
        oqc.setTemplateId(templateId);
        oqc.setSourceDocType(MesBizTypeConstants.WM_PRODUCT_SALES);
        oqc.setSourceDocId(salesId);
        oqc.setSourceLineId(line.getId());
@@ -407,8 +379,6 @@
        oqc.setStatus(MesQcStatusEnum.DRAFT.getStatus());
        oqcMapper.insert(oqc);
        // 4. ä»Žæ¨¡æ¿æŒ‡æ ‡è‡ªåŠ¨ç”Ÿæˆæ£€éªŒè¡Œ
        oqcLineService.createLinesFromTemplate(oqc.getId(), templateId);
        return oqc.getId();
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/rqc/MesQcRqcServiceImpl.java
@@ -50,6 +50,9 @@
public class MesQcRqcServiceImpl implements MesQcRqcService {
    @Resource
    private cn.iocoder.yudao.module.mes.service.qc.MesQcCompletionHelper completionHelper;
    @Resource
    private MesQcRqcMapper rqcMapper;
    @Resource
@@ -90,29 +93,14 @@
    public Long createRqc(MesQcRqcSaveReqVO createReqVO) {
        // 1.1 æ ¡éªŒæ•°æ®
        validateRqcSaveData(createReqVO);
        // 1.2 æ ¹æ®ç‰©æ–™ + RQC æ£€æµ‹ç§ç±»æŸ¥æ‰¾å¯ç”¨è´¨æ£€æ–¹æ¡ˆï¼ˆæ”¯æŒå‰ç«¯æ˜¾å¼æŒ‡å®šï¼‰
        List<MesQcTemplateItemDO> templateItems = templateItemService.getTemplateItemList(
                createReqVO.getItemId(), MesQcTypeEnum.RQC.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.3 èŽ·å–æ¥æºå•æ®ç¼–å·
        String sourceDocCode = validateAndGetSourceDocCode(
                createReqVO.getSourceDocType(), createReqVO.getSourceDocId());
        // 2. æ’入主表
        MesQcRqcDO rqc = BeanUtils.toBean(createReqVO, MesQcRqcDO.class)
                .setTemplateId(templateId)
                .setTemplateId(null)
                .setSourceDocCode(sourceDocCode)
                .setStatus(MesQcStatusEnum.DRAFT.getStatus());
        if (Objects.equals(createReqVO.getSourceDocType(), MesBizTypeConstants.WM_RETURN_SALES)
@@ -125,9 +113,6 @@
            rqc.setBatchCode(line.getBatchCode());
        }
        rqcMapper.insert(rqc);
        // 3. ä»Žæ¨¡æ¿æŒ‡æ ‡è‡ªåŠ¨ç”Ÿæˆæ£€éªŒè¡Œ
        rqcLineService.createLinesFromTemplate(rqc.getId(), templateId);
        return rqc.getId();
    }
@@ -159,25 +144,15 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void finishRqc(Long id) {
        completionHelper.lockDocument("rqc", id);
        // 1.1 æ ¡éªŒå­˜åœ¨ + è‰ç¨¿çŠ¶æ€
        MesQcRqcDO rqc = validateRqcStatusPrepare(id);
        // 1.2 æ ¡éªŒæ£€æµ‹ç»“论必填
        if (rqc.getCheckResult() == null) {
            throw exception(QC_RQC_CHECK_RESULT_EMPTY);
        }
        // 1.3 æ ¡éªŒåˆæ ¼å“ + ä¸åˆæ ¼å“ = æ£€æµ‹æ•°é‡
        if (rqc.getCheckQuantity() != null && rqc.getCheckQuantity().compareTo(BigDecimal.ZERO) > 0) {
            BigDecimal qualified = rqc.getQualifiedQuantity() != null ? rqc.getQualifiedQuantity() : BigDecimal.ZERO;
            BigDecimal unqualified = rqc.getUnqualifiedQuantity() != null ? rqc.getUnqualifiedQuantity() : BigDecimal.ZERO;
            if (qualified.add(unqualified).compareTo(rqc.getCheckQuantity()) != 0) {
                throw exception(QC_RQC_QUANTITY_MISMATCH);
            }
        }
        // 1.4 æ ¡éªŒè‡³å°‘存在一条检测结果
        indicatorResultService.validateIndicatorResultExistsByQcIdAndType(id, MesQcTypeEnum.RQC.getType());
        int result = completionHelper.validateAndGetResult("rqc", id, rqc.getQualifiedQuantity(), rqc.getUnqualifiedQuantity(), rqc.getCheckQuantity());
        rqc.setCheckResult(result);
        // 2. æ›´æ–°çŠ¶æ€ä¸ºå·²å®Œæˆ
        MesQcRqcDO updateObj = new MesQcRqcDO().setId(id).setStatus(MesQcStatusEnum.FINISHED.getStatus());
        MesQcRqcDO updateObj = new MesQcRqcDO().setId(id).setStatus(MesQcStatusEnum.FINISHED.getStatus()).setCheckResult(result);
        rqcMapper.updateById(updateObj);
        // 3. ä¸åˆæ ¼æ—¶è‡ªåŠ¨åˆ›å»º NCR
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesLineServiceImpl.java
@@ -93,35 +93,7 @@
        }
        productSalesLineMapper.updateById(updateObj);
        // æ£€æŸ¥æ˜¯å¦éœ€è¦åˆ›å»º OQC æ£€éªŒå•:从不需要检验变为需要检验
        if (Boolean.TRUE.equals(updateReqVO.getOqcCheckFlag())
                && !Boolean.TRUE.equals(oldLine.getOqcCheckFlag())) {
            // èŽ·å–å‡ºåº“å•ä¿¡æ¯
            MesWmProductSalesDO sales = productSalesService.getProductSales(updateReqVO.getSalesId());
            if (sales == null) {
                log.warn("[updateProductSalesLine] å‡ºåº“单不存在,salesId={}", updateReqVO.getSalesId());
                return;
            }
            // æž„建出库明细 DO
            MesWmProductSalesLineDO line = new MesWmProductSalesLineDO();
            line.setId(updateReqVO.getId());
            line.setItemId(updateReqVO.getItemId());
            line.setSaleOrderItemId(updateObj.getSaleOrderItemId() != null
                    ? updateObj.getSaleOrderItemId() : oldLine.getSaleOrderItemId());
            line.setBatchCode(updateReqVO.getBatchCode());
            line.setQuantity(updateReqVO.getQuantity());
            // åˆ›å»º OQC æ£€éªŒå•,让异常抛出
            Long oqcId = oqcService.createOqcFromProductSales(
                    sales.getId(), line, sales.getCode(), sales.getClientId());
            // æ›´æ–°å‡ºåº“明细的 oqcId å’Œè´¨é‡çŠ¶æ€
            productSalesLineMapper.updateById(new MesWmProductSalesLineDO()
                    .setId(updateReqVO.getId())
                    .setOqcId(oqcId)
                    .setQualityStatus(MesWmQualityStatusEnum.PENDING.getStatus()));
        }
        // æäº¤å‡ºåº“单后由质检人员选择已完成的出厂检验单。
    }
    @Override
@@ -269,19 +241,18 @@
        Long salesId = currentLine.getSalesId();
        List<MesWmProductSalesLineDO> allLines = productSalesLineMapper.selectListBySalesId(salesId);
        // 2.1 æ£€æŸ¥æ˜¯å¦æœ‰ä¸åˆæ ¼è¡Œ â†’ å–消出库单
        // ä¸åˆæ ¼è¡Œç•™å¾…后续处置,不能因为检验结果自动取消整张出库单。
        boolean hasNg = CollectionUtils.anyMatch(allLines,
                line -> Boolean.TRUE.equals(line.getOqcCheckFlag())
                        && Objects.equals(line.getQualityStatus(), MesWmQualityStatusEnum.FAIL.getStatus()));
        if (hasNg) {
            log.info("[updateProductSalesLineWhenOqcFinish][销售出库单({}) å­˜åœ¨è´¨æ£€ä¸åˆæ ¼è¡Œï¼Œå–消出库单]", salesId);
            productSalesService.cancelProductSales(salesId);
            log.info("[updateProductSalesLineWhenOqcFinish][销售出库单({}) å­˜åœ¨è´¨æ£€ä¸åˆæ ¼è¡Œï¼Œç­‰å¾…后续处置]", salesId);
            return;
        }
        // 2.2 æ£€æŸ¥æ˜¯å¦æ‰€æœ‰ OQC è¡Œéƒ½å·²å®Œæˆï¼Œè‡ªåŠ¨æµè½¬ CONFIRMED â†’ APPROVING
        boolean hasUnchecked = CollectionUtils.anyMatch(allLines,
                line -> Boolean.TRUE.equals(line.getOqcCheckFlag()) && line.getOqcId() == null);
                line -> Boolean.TRUE.equals(line.getOqcCheckFlag()) && !Objects.equals(line.getQualityStatus(), MesWmQualityStatusEnum.PASS.getStatus()));
        if (!hasUnchecked) {
            log.info("[updateProductSalesLineWhenOqcFinish][销售出库单({}) æ‰€æœ‰ OQC è¡Œæ£€éªŒå®Œæˆï¼Œæµè½¬åˆ°å¾…拣货]", salesId);
            productSalesService.confirmProductSales(salesId);
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesServiceImpl.java
@@ -153,19 +153,7 @@
        boolean needOqc = CollectionUtils.anyMatch(lines,
                line -> Boolean.TRUE.equals(line.getOqcCheckFlag()));
        if (needOqc) {
            // ä¸ºéœ€è¦ OQC æ£€éªŒä¸”尚未创建检验单的明细创建 OQC æ£€éªŒå•
            List<MesWmProductSalesLineDO> oqcLines = CollectionUtils.filterList(lines,
                    line -> Boolean.TRUE.equals(line.getOqcCheckFlag()) && line.getOqcId() == null);
            for (MesWmProductSalesLineDO line : oqcLines) {
                // ç›´æŽ¥è°ƒç”¨ï¼Œè®©å¼‚常抛出,保证数据一致性
                Long oqcId = oqcService.createOqcFromProductSales(
                        sales.getId(), line, sales.getCode(), sales.getClientId());
                // æ›´æ–°å‡ºåº“明细的 oqcId
                productSalesLineMapper.updateById(new MesWmProductSalesLineDO()
                        .setId(line.getId())
                        .setOqcId(oqcId));
            }
            // æ£€éªŒå•由质检人员先行录入,待检明细通过盘号关联已完成的检验单。
            // æ‰¹é‡åˆå§‹åŒ– OQC è¡Œçš„质量状态为"待检验"
            List<Long> oqcLineIds = convertList(
                    CollectionUtils.filterList(lines, line -> Boolean.TRUE.equals(line.getOqcCheckFlag())),
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/ipqc/MesQcIpqcServiceImplTest.java
@@ -23,12 +23,14 @@
import cn.iocoder.yudao.module.mes.service.pro.route.MesProRouteProductService;
import cn.iocoder.yudao.module.mes.service.pro.task.MesProTaskService;
import cn.iocoder.yudao.module.mes.service.pro.workorder.MesProWorkOrderService;
import cn.iocoder.yudao.module.mes.service.qc.MesQcCompletionHelper;
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.wm.productproduce.MesWmProductProduceLineService;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@@ -39,6 +41,7 @@
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
@@ -86,6 +89,13 @@
    private MesQcIndicatorResultService indicatorResultService;
    @MockitoBean
    private MesWmProductProduceLineService productProduceLineService;
    @MockitoBean
    private MesQcCompletionHelper completionHelper;
    @BeforeEach
    void stubCompletionHelper() {
        when(completionHelper.validateAndGetResult(anyString(), anyLong(), any(), any(), any())).thenReturn(1);
    }
    @Test
    public void testFinishIpqc_writeBack_feedback() {
@@ -221,17 +231,27 @@
    }
    @Test
    public void testFinishIpqc_checkResultEmpty() {
        // å‡†å¤‡æ•°æ®ï¼šcheckResult ä¸ºç©ºï¼Œåº”该报错
    public void testFinishIpqc_checkResultAutoComputed() {
        // å‡†å¤‡æ•°æ®ï¼šæ£€æµ‹ç»“论不再由用户填写,完成时按合格/不合格数量自动判定
        MesQcIpqcDO ipqc = randomPojo(MesQcIpqcDO.class, o -> {
            o.setStatus(MesQcStatusEnum.DRAFT.getStatus());
            o.setCheckResult(null); // æ£€æµ‹ç»“论为空
            o.setCheckResult(null);
            o.setSourceDocType(null);
            o.setSourceDocId(null);
            o.setSourceLineId(null);
            o.setCheckQuantity(BigDecimal.valueOf(100));
            o.setQualifiedQuantity(BigDecimal.valueOf(100));
            o.setUnqualifiedQuantity(BigDecimal.ZERO);
        });
        ipqcMapper.insert(ipqc);
        // è°ƒç”¨ï¼Œåº”该抛异常(检测结论必填)
        assertThrows(Exception.class, () -> ipqcService.finishIpqc(ipqc.getId()));
        // è°ƒç”¨
        ipqcService.finishIpqc(ipqc.getId());
        // æ–­è¨€ï¼šæ£€æµ‹ç»“论由系统判定并回写
        MesQcIpqcDO updatedIpqc = ipqcMapper.selectById(ipqc.getId());
        assertEquals(MesQcStatusEnum.FINISHED.getStatus(), updatedIpqc.getStatus());
        assertEquals(1, updatedIpqc.getCheckResult());
    }
    @Test
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/iqc/MesQcIqcServiceImplTest.java
@@ -6,12 +6,18 @@
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcStatusEnum;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemService;
import cn.iocoder.yudao.module.mdm.api.item.MdmItemApi;
import cn.iocoder.yudao.module.mes.service.qc.MesQcCompletionHelper;
import cn.iocoder.yudao.module.mes.service.qc.defectrecord.MesQcDefectRecordService;
import cn.iocoder.yudao.module.mes.service.qc.imports.MesQcImportHelper;
import cn.iocoder.yudao.module.mes.service.qc.ncr.MesQcNcrService;
import cn.iocoder.yudao.module.srm.api.supplier.SrmSupplierApi;
import cn.iocoder.yudao.module.mes.service.qc.template.MesQcTemplateItemService;
import cn.iocoder.yudao.module.mes.service.wm.arrivalnotice.MesWmArrivalNoticeService;
import cn.iocoder.yudao.module.mes.service.wm.outsourcereceipt.MesWmOutsourceReceiptService;
import cn.iocoder.yudao.module.mes.service.qc.indicatorresult.MesQcIndicatorResultService;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@@ -23,6 +29,7 @@
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId;
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
@@ -55,6 +62,21 @@
    private AdminUserApi adminUserApi;
    @MockitoBean
    private MesQcIndicatorResultService indicatorResultService;
    @MockitoBean
    private MesQcCompletionHelper completionHelper;
    @MockitoBean
    private SrmSupplierApi srmSupplierApi;
    @MockitoBean
    private MdmItemApi mdmItemApi;
    @MockitoBean
    private MesQcNcrService ncrService;
    @MockitoBean
    private MesQcImportHelper qcImportHelper;
    @BeforeEach
    void stubCompletionHelper() {
        when(completionHelper.validateAndGetResult(anyString(), anyLong(), any(), any(), any())).thenReturn(1);
    }
    @Test
    public void testFinishIqc_writeBack_arrivalNotice() {
@@ -174,17 +196,25 @@
    }
    @Test
    public void testFinishIqc_checkResultEmpty() {
        // å‡†å¤‡æ•°æ®ï¼šcheckResult ä¸ºç©ºï¼Œåº”该报错
    public void testFinishIqc_checkResultAutoComputed() {
        // å‡†å¤‡æ•°æ®ï¼šæ£€æµ‹ç»“论不再由用户填写,完成时按合格/不合格数量自动判定
        MesQcIqcDO iqc = randomPojo(MesQcIqcDO.class, o -> {
            o.setStatus(MesQcStatusEnum.DRAFT.getStatus());
            o.setCheckResult(null); // æ£€æµ‹ç»“论为空
            o.setCheckResult(null);
            o.setSourceDocType(null);
            o.setCheckQuantity(BigDecimal.valueOf(100));
            o.setQualifiedQuantity(BigDecimal.valueOf(100));
            o.setUnqualifiedQuantity(BigDecimal.ZERO);
        });
        iqcMapper.insert(iqc);
        // è°ƒç”¨ï¼Œåº”该抛异常(检测结论必填)
        assertThrows(Exception.class, () -> iqcService.finishIqc(iqc.getId()));
        // è°ƒç”¨
        iqcService.finishIqc(iqc.getId());
        // æ–­è¨€ï¼šæ£€æµ‹ç»“论由系统判定并回写
        MesQcIqcDO updatedIqc = iqcMapper.selectById(iqc.getId());
        assertEquals(MesQcStatusEnum.FINISHED.getStatus(), updatedIqc.getStatus());
        assertEquals(1, updatedIqc.getCheckResult());
    }
    @Test
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/oqc/MesQcOqcAutoCreateTest.java
@@ -4,11 +4,9 @@
import cn.iocoder.yudao.module.erp.api.sale.ErpSaleOrderApi;
import cn.iocoder.yudao.module.erp.api.sale.dto.ErpSaleOrderItemRespDTO;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.template.MesQcTemplateItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesLineDO;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.oqc.MesQcOqcMapper;
import cn.iocoder.yudao.module.mes.service.qc.template.MesQcTemplateItemService;
import cn.iocoder.yudao.module.mes.service.wm.productsales.MesWmProductSalesService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -28,7 +26,6 @@
class MesQcOqcAutoCreateTest {
    @InjectMocks private MesQcOqcServiceImpl service;
    @Mock private MesQcOqcMapper mapper;
    @Mock private MesQcTemplateItemService templates;
    @Mock private MesQcOqcLineService lines;
    @Mock private MesWmProductSalesService sales;
    @Mock private ErpSaleOrderApi saleOrderApi;
@@ -36,8 +33,6 @@
    @Test
    void generatedInspectionUsesHistoricalShipmentDateAndPreservesPendingQuantities() {
        LocalDateTime date = LocalDateTime.of(2026, 9, 23, 15, 30);
        when(templates.getRequiredTemplateByItemIdAndType(10L, 3))
                .thenReturn(new MesQcTemplateItemDO().setTemplateId(20L));
        when(sales.getProductSales(30L)).thenReturn(new MesWmProductSalesDO().setSalesDate(date));
        MesWmProductSalesLineDO line = new MesWmProductSalesLineDO().setId(40L)
                .setItemId(10L).setSaleOrderItemId(60L).setQuantity(BigDecimal.valueOf(200)).setBatchCode("B001");
@@ -60,8 +55,6 @@
    @Test
    void historicalShipmentWithoutSaleItemDoesNotGuessReel() {
        when(templates.getRequiredTemplateByItemIdAndType(10L, 3))
                .thenReturn(new MesQcTemplateItemDO().setTemplateId(20L));
        MesWmProductSalesLineDO line = new MesWmProductSalesLineDO().setId(40L)
                .setItemId(10L).setQuantity(BigDecimal.ONE);
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/oqc/MesQcOqcServiceImplTest.java
@@ -6,6 +6,7 @@
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcStatusEnum;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemService;
import cn.iocoder.yudao.module.mes.service.qc.MesQcCompletionHelper;
import cn.iocoder.yudao.module.mes.service.qc.defectrecord.MesQcDefectRecordService;
import cn.iocoder.yudao.module.mes.service.qc.template.MesQcTemplateItemService;
import cn.iocoder.yudao.module.mes.service.wm.productsales.MesWmProductSalesLineService;
@@ -13,6 +14,7 @@
import cn.iocoder.yudao.module.mes.service.qc.indicatorresult.MesQcIndicatorResultService;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@@ -22,7 +24,7 @@
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
@@ -66,6 +68,13 @@
    private AdminUserApi adminUserApi;
    @MockitoBean
    private MesQcIndicatorResultService indicatorResultService;
    @MockitoBean
    private MesQcCompletionHelper completionHelper;
    @BeforeEach
    void stubCompletionHelper() {
        when(completionHelper.validateAndGetResult(anyString(), anyLong(), any(), any(), any())).thenReturn(1);
    }
    @Test
    public void testFinishOqc_writeBack_productSales() {
@@ -165,17 +174,25 @@
    }
    @Test
    public void testFinishOqc_checkResultEmpty() {
        // å‡†å¤‡æ•°æ®ï¼šcheckResult ä¸ºç©ºï¼Œåº”该报错
    public void testFinishOqc_checkResultAutoComputed() {
        // å‡†å¤‡æ•°æ®ï¼šæ£€æµ‹ç»“论不再由用户填写,完成时按合格/不合格数量自动判定
        MesQcOqcDO oqc = randomPojo(MesQcOqcDO.class, o -> {
            o.setStatus(MesQcStatusEnum.DRAFT.getStatus());
            o.setCheckResult(null); // æ£€æµ‹ç»“论为空
            o.setCheckResult(null);
            o.setSourceDocType(null);
            o.setCheckQuantity(java.math.BigDecimal.valueOf(100));
            o.setQualifiedQuantity(java.math.BigDecimal.valueOf(100));
            o.setUnqualifiedQuantity(java.math.BigDecimal.ZERO);
        });
        oqcMapper.insert(oqc);
        // è°ƒç”¨ï¼Œåº”该抛异常(检测结论必填)
        assertServiceException(() -> oqcService.finishOqc(oqc.getId()), QC_OQC_CHECK_RESULT_EMPTY);
        // è°ƒç”¨
        oqcService.finishOqc(oqc.getId());
        // æ–­è¨€ï¼šæ£€æµ‹ç»“论由系统判定并回写
        MesQcOqcDO updatedOqc = oqcMapper.selectById(oqc.getId());
        assertEquals(MesQcStatusEnum.FINISHED.getStatus(), updatedOqc.getStatus());
        assertEquals(1, updatedOqc.getCheckResult());
    }
    @Test
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/rqc/MesQcRqcServiceImplTest.java
@@ -6,6 +6,7 @@
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcStatusEnum;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemService;
import cn.iocoder.yudao.module.mes.service.qc.MesQcCompletionHelper;
import cn.iocoder.yudao.module.mes.service.qc.defectrecord.MesQcDefectRecordService;
import cn.iocoder.yudao.module.mes.service.qc.template.MesQcTemplateItemService;
import cn.iocoder.yudao.module.mes.service.wm.returnissue.MesWmReturnIssueLineService;
@@ -14,6 +15,7 @@
import cn.iocoder.yudao.module.mes.service.qc.ncr.MesQcNcrService;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@@ -27,8 +29,7 @@
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.QC_RQC_NOT_PREPARE;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.*;
/**
 * {@link MesQcRqcServiceImpl} çš„单元测试类
@@ -62,6 +63,13 @@
    private MesQcIndicatorResultService indicatorResultService;
    @MockitoBean
    private MesQcNcrService ncrService;
    @MockitoBean
    private MesQcCompletionHelper completionHelper;
    @BeforeEach
    void stubCompletionHelper() {
        when(completionHelper.validateAndGetResult(anyString(), anyLong(), any(), any(), any())).thenReturn(1);
    }
    @Test
    public void testFinishRqc_successWithReturnIssue() {
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesLineReelTest.java
@@ -95,17 +95,17 @@
    }
    @Test
    void enablingInspectionRetainsSalesItemForOqc() {
    void enablingInspectionKeepsSalesItemAndDefersOqcToLink() {
        var req = request();
        req.setId(6L);
        req.setOqcCheckFlag(true);
        when(mapper.selectById(6L)).thenReturn(MesWmProductSalesLineDO.builder()
                .id(6L).salesId(1L).oqcCheckFlag(false).build());
        when(salesService.getProductSales(1L)).thenReturn(MesWmProductSalesDO.builder().id(1L).build());
        when(oqcService.createOqcFromProductSales(eq(1L), any(), any(), any())).thenReturn(7L);
        service.updateProductSalesLine(req);
        var line = ArgumentCaptor.forClass(MesWmProductSalesLineDO.class);
        verify(oqcService).createOqcFromProductSales(eq(1L), line.capture(), any(), any());
        verify(mapper).updateById(line.capture());
        assertEquals(5L, line.getValue().getSaleOrderItemId());
        // æ£€éªŒå•由质检人员录入后通过盘号关联,行保存时不再自动创建 OQC。
        verifyNoInteractions(oqcService);
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesLineServiceImplTest.java
@@ -10,8 +10,11 @@
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemService;
import cn.iocoder.yudao.module.erp.api.sale.ErpSaleOrderApi;
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
import cn.iocoder.yudao.module.mes.service.qc.oqc.MesQcOqcService;
import cn.iocoder.yudao.module.mes.service.wm.batch.MesWmBatchService;
import cn.iocoder.yudao.module.mes.service.wm.salesnotice.MesWmSalesNoticeLineService;
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId;
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
@@ -39,9 +42,15 @@
    private MesWmProductSalesDetailService productSalesDetailService;
    @MockitoBean
    private MesMdItemService itemService;
    private MesMdmItemService mdmItemService;
    @MockitoBean
    private MesWmBatchService batchService;
    @MockitoBean
    private MesWmSalesNoticeLineService salesNoticeLineService;
    @MockitoBean
    private MesQcOqcService oqcService;
    @MockitoBean
    private ErpSaleOrderApi saleOrderApi;
    @Test
    public void testUpdateProductSalesLineWhenOqcFinish_pass() {
@@ -63,13 +72,15 @@
        MesWmProductSalesLineDO updateLine = productSalesLineMapper.selectById(lineDO.getId());
        assertEquals(oqcId, updateLine.getOqcId());
        assertEquals(MesWmQualityStatusEnum.PASS.getStatus(), updateLine.getQualityStatus());
        // æ–­è¨€ï¼šå…¨éƒ¨éœ€æ£€è¡Œåˆæ ¼ â†’ å‡ºåº“单自动流转待拣货
        verify(productSalesService).confirmProductSales(lineDO.getSalesId());
    }
    @Test
    public void testUpdateProductSalesLineWhenOqcFinish_fail() {
        // å‡†å¤‡å‚æ•°
        // å‡†å¤‡å‚数:2 è¡¨ç¤ºæ ¡éªŒä¸é€šè¿‡
        Long oqcId = randomLongId();
        Integer checkResult = MesQcCheckResultEnum.QUALIFIED.getType();
        Integer checkResult = 2;
        // mock æ•°æ®
        MesWmProductSalesLineDO lineDO = randomPojo(MesWmProductSalesLineDO.class, o -> {
@@ -85,10 +96,13 @@
        MesWmProductSalesLineDO updateLine = productSalesLineMapper.selectById(lineDO.getId());
        assertEquals(oqcId, updateLine.getOqcId());
        assertEquals(MesWmQualityStatusEnum.FAIL.getStatus(), updateLine.getQualityStatus());
        // æ–­è¨€ï¼šå­˜åœ¨ä¸åˆæ ¼è¡Œæ—¶ä¿æŒå¾…检测,不自动取消整张出库单
        verify(productSalesService, never()).cancelProductSales(anyLong());
        verify(productSalesService, never()).confirmProductSales(anyLong());
    }
    @Test
    public void testUpdateProductSalesLineWhenOqcFinish_cancelWhenNg() {
    public void testUpdateProductSalesLineWhenOqcFinish_noCancelWhenNg() {
        // å‡†å¤‡æ•°æ®ï¼šåŒä¸€å‡ºåº“单下 2 è¡Œï¼Œç¬¬ 1 è¡Œå·²åˆæ ¼ï¼Œç¬¬ 2 è¡Œå³å°†å˜æ›´ä¸ºä¸åˆæ ¼
        Long salesId = randomLongId();
        MesWmProductSalesLineDO line1 = randomPojo(MesWmProductSalesLineDO.class, o -> {
@@ -107,14 +121,15 @@
        // è°ƒç”¨ï¼šç¬¬ 2 è¡Œè´¨æ£€ä¸åˆæ ¼
        productSalesLineService.updateProductSalesLineWhenOqcFinish(
                line2.getId(), randomLongId(), MesQcCheckResultEnum.QUALIFIED.getType());
                line2.getId(), randomLongId(), 2);
        // æ–­è¨€ï¼šåº”该调用 cancelProductSales
        verify(productSalesService).cancelProductSales(eq(salesId));
        // æ–­è¨€ï¼šä¸åˆæ ¼è¡Œç•™å¾…后续处置,不能自动取消出库单
        verify(productSalesService, never()).cancelProductSales(anyLong());
        verify(productSalesService, never()).confirmProductSales(anyLong());
    }
    @Test
    public void testUpdateProductSalesLineWhenOqcFinish_noCancelWhenAllPass() {
    public void testUpdateProductSalesLineWhenOqcFinish_confirmWhenAllPass() {
        // å‡†å¤‡æ•°æ®ï¼šåŒä¸€å‡ºåº“单下 2 è¡Œï¼Œç¬¬ 1 è¡Œå·²åˆæ ¼ï¼Œç¬¬ 2 è¡Œå³å°†å˜æ›´ä¸ºåˆæ ¼
        Long salesId = randomLongId();
        MesWmProductSalesLineDO line1 = randomPojo(MesWmProductSalesLineDO.class, o -> {
@@ -135,7 +150,8 @@
        productSalesLineService.updateProductSalesLineWhenOqcFinish(
                line2.getId(), randomLongId(), MesQcCheckResultEnum.QUALIFIED.getType());
        // æ–­è¨€ï¼šä¸åº”该调用 cancelProductSales
        // æ–­è¨€ï¼šå…¨éƒ¨éœ€æ£€è¡Œåˆæ ¼ â†’ æµè½¬å¾…拣货,不取消出库单
        verify(productSalesService).confirmProductSales(eq(salesId));
        verify(productSalesService, never()).cancelProductSales(anyLong());
    }
@@ -157,12 +173,13 @@
        });
        productSalesLineMapper.insert(line2);
        // è°ƒç”¨ï¼šç¬¬ 2 è¡Œè´¨æ£€ä¸åˆæ ¼
        // è°ƒç”¨ï¼šç¬¬ 2 è¡Œè´¨æ£€åˆæ ¼
        productSalesLineService.updateProductSalesLineWhenOqcFinish(
                line2.getId(), randomLongId(), MesQcCheckResultEnum.QUALIFIED.getType());
        // æ–­è¨€ï¼šç¬¬ 2 è¡Œä¸åˆæ ¼ â†’ åº”该取消出库单
        verify(productSalesService).cancelProductSales(eq(salesId));
        // æ–­è¨€ï¼šæœªå‹¾é€‰å‡ºè´§æ£€éªŒçš„行不参与待检判断 â†’ å…¨éƒ¨éœ€æ£€è¡Œåˆæ ¼åŽæµè½¬å¾…拣货
        verify(productSalesService).confirmProductSales(eq(salesId));
        verify(productSalesService, never()).cancelProductSales(anyLong());
    }
}
yudao-module-mes/src/test/resources/sql/create_tables.sql
@@ -475,6 +475,7 @@
CREATE TABLE IF NOT EXISTS "mes_wm_product_sales_line" (
    "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
    "sales_id" bigint NOT NULL,
    "sale_order_item_id" bigint DEFAULT NULL,
    "notice_line_id" bigint DEFAULT NULL,
    "item_id" bigint NOT NULL,
    "quantity" decimal(20,6) NOT NULL,
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/storage/StorageAttachmentApi.java
@@ -26,4 +26,7 @@
    void deleteAttachmentsByRecord(String recordType, Long recordId);
    /** ä»…移除指定业务关联,不删除共享文件。 */
    void removeReference(String application, String recordType, Long recordId, Long blobId);
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/storage/StorageAttachmentApiImpl.java
@@ -18,6 +18,19 @@
    @Resource
    private SystemStorageAttachmentService systemStorageAttachmentService;
    @Resource
    private cn.iocoder.yudao.module.system.dal.mysql.storage.SystemStorageAttachmentMapper attachmentMapper;
    @Override
    @org.springframework.transaction.annotation.Transactional(rollbackFor = Exception.class)
    public void removeReference(String application, String recordType, Long recordId, Long blobId) {
        attachmentMapper.delete(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageAttachmentDO>()
                .eq(cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageAttachmentDO::getApplication, application)
                .eq(cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageAttachmentDO::getRecordType, recordType)
                .eq(cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageAttachmentDO::getRecordId, recordId)
                .eq(cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageAttachmentDO::getStorageBlobId, blobId));
    }
    @Override
    public void bindAttachments(String application, String recordType, Long recordId, List<Long> blobIds) {
        if (blobIds == null || blobIds.isEmpty()) {
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java
@@ -67,6 +67,11 @@
    MES_PD_DOCUMENT("mes_pd_document", "MES è®¾è®¡èµ„æ–™"),
    MDM_ITEM_TECHNICAL_FILE("mdm_item_technical_file", "MDM ç‰©æ–™æŠ€æœ¯æ–‡ä»¶"),
    MES_WORK_ORDER_TECHNICAL_FILE("mes_work_order_technical_file", "生产工单技术文件快照"),
    MES_QC_IQC_ATTACHMENT("mes_qc_iqc_attachment", "来料质检附件"),
    MES_QC_IPQC_ATTACHMENT("mes_qc_ipqc_attachment", "过程质检附件"),
    MES_QC_OQC_ATTACHMENT("mes_qc_oqc_attachment", "出货质检附件"),
    MES_QC_RQC_ATTACHMENT("mes_qc_rqc_attachment", "退货质检附件"),
    // ===== SRM =====
    SRM_SUPPLIER_CERTIFICATE("srm_supplier_certificate", "SRM ä¾›åº”商资质证书"),
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/util/storage/StorageFileUtil.java
@@ -141,6 +141,14 @@
        if (CollUtil.isEmpty(storageBlobIds)) {
            return;
        }
        // ç”Ÿäº§å·¥å•依赖原始文件反映历史版本,不允许通过其他附件入口删除这些共享文件。
        Long snapshotReferences = storageAttachmentMapper.selectCount(
                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SystemStorageAttachmentDO>()
                        .eq(SystemStorageAttachmentDO::getRecordType, "mes_work_order_technical_file")
                        .in(SystemStorageAttachmentDO::getStorageBlobId, storageBlobIds));
        if (snapshotReferences != null && snapshotReferences > 0) {
            throw new cn.iocoder.yudao.framework.common.exception.ServiceException(400, "技术文件已被生产工单留存,不能删除");
        }
        // åŒæ—¶åˆ é™¤ç£ç›˜æ–‡ä»¶
        List<SystemStorageBlobDO> blobs = storageBlobMapper.selectByIds(storageBlobIds);
        for (SystemStorageBlobDO blob : blobs) {
yudao-server/src/main/resources/application-local.yaml
@@ -202,7 +202,7 @@
  storage: # æ–‡ä»¶ä¸Šä¼ é…ç½®
    path: D:/uploads # æ–‡ä»¶å­˜å‚¨æ ¹ç›®å½•(按实际服务器路径配置)
    url-prefix: /admin-api/system/storage-blob # URL路径前缀
    domain: http://192.168.1.6:48080 # åŸŸåå‰ç¼€ï¼ˆæŒ‰å®žé™…域名配置)
    domain: http://192.168.0.10:48080 # åŸŸåå‰ç¼€ï¼ˆæŒ‰å®žé™…域名配置)
    expired: 120 # token过期时间(分钟)
    use-limit: 10 # token使用次数上限
    jwt-secret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG # JWT密钥(至少32字符)