高
3 天以前 c6a2b472cde9dff4d21b95637ee6d99a412c785c
feat(mes,crm): 新增质检单批量导入、检测结果台账与开票导入

从 dev_business 移植三块能力,并按英泽防锈分支的既有结构做适配,不改动原有逻辑。

MES 质检批量导入(IQC / IPQC / OQC)
- 各列表新增「下载导入模板」与「导入」两个接口,纯新增不做更新覆盖
- 逐行解析、部分成功:失败行进 failureList(含行号与可执行动作),整行留空自动跳过
- 编号留空自动生成;物料/供应商/客户按编码或名称精确匹配;质检方案留空按物料+检验类型自动匹配
- 本次导入内重复行与库内同业务键单据均拦截,提示含具体维度、双方实际值与下一步动作
- IQC 原样移植;IPQC 按批号模型重写(必填批号,去重键=物料+批号+检验类型+检测日期);
OQC 随本分支放宽(客户/出货数量/出货日期可空,客户留空时不校验客户数据权限)

MES 检测结果台账
- 跨 IQC / IPQC / OQC / RQC 统一分页查询与 Excel 导出,一行一个检测项
- 物料与计量单位解析改用 MdmItemApi / MdmUnitMeasureApi,与分支既有写法一致
- IPQC 批次号取批号(原为空),并同步 VO 描述

CRM 开票导入
- 开票列表新增模板下载与导入接口,合同编号匹配已审核合同并自动带入客户
- 同一合同下按「合同+发票号码」或「合同+金额+抬头+日期」判重
- CrmContractService 纯新增 getContractByNo;发票金额校验中的剩余额度计算抽为
getRemainingInvoicePrice 复用,行为等价

菜单与文档
- 新增三个 ID 自适应的幂等菜单脚本(按权限码反查父菜单与角色,可反复执行)
- 新增三份前端联调方案文档;同步更新 docs/project-business 业务可视化增量

Co-Authored-By: Claude Code <noreply@anthropic.com>
已添加21个文件
已修改23个文件
2999 ■■■■■ 文件已修改
docs/crm_invoice_import_integration.md 92 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/mes_qc_import_integration.md 157 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/flows.json 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/modules.json 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/objects.json 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/index.html 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/05-quality-flow.mmd 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/qc_inspection_ledger_integration.md 162 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/crm_invoice_import_menu.sql 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/mes_qc_import_menu.sql 115 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/qc_inspection_ledger_menu.sql 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/invoice/CrmInvoiceController.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/invoice/vo/invoice/CrmInvoiceImportExcelVO.java 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/invoice/vo/invoice/CrmInvoiceImportRespVO.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/invoice/CrmInvoiceMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractService.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractServiceImpl.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/invoice/CrmInvoiceService.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/invoice/CrmInvoiceServiceImpl.java 250 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/ipqc/MesQcIpqcController.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/ipqc/vo/MesQcIpqcImportExcelVO.java 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/iqc/MesQcIqcController.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/iqc/vo/MesQcIqcImportExcelVO.java 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/ledger/MesQcInspectionLedgerController.java 113 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/ledger/vo/MesQcInspectionLedgerExportVO.java 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/ledger/vo/MesQcInspectionLedgerPageReqVO.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/ledger/vo/MesQcInspectionLedgerRespVO.java 102 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/oqc/MesQcOqcController.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/oqc/vo/MesQcOqcImportExcelVO.java 87 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/vo/MesQcImportRespVO.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/qc/ipqc/MesQcIpqcMapper.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/qc/iqc/MesQcIqcMapper.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/qc/ledger/MesQcInspectionLedgerMapper.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/qc/oqc/MesQcOqcMapper.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/imports/MesQcImportHelper.java 369 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ipqc/MesQcIpqcService.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ipqc/MesQcIpqcServiceImpl.java 154 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/iqc/MesQcIqcService.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/iqc/MesQcIqcServiceImpl.java 121 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ledger/MesQcInspectionLedgerService.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ledger/MesQcInspectionLedgerServiceImpl.java 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/oqc/MesQcOqcService.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/oqc/MesQcOqcServiceImpl.java 134 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/resources/mapper/qc/ledger/MesQcInspectionLedgerMapper.xml 176 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/crm_invoice_import_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,92 @@
# CRM å¼€ç¥¨å¯¼å…¥ - å‰ç«¯è”调方案
> éœ€æ±‚:开票管理列表增加「导入」按钮(位于「新增开票申请」与「导出」之间),用于按模板批量导入开票记录。
> å‚考实现:合同列表的导入弹窗(`views/crm/contract/modules/import-form.vue`),开票导入与其交互一致。
## æ¶‰åŠé¡µé¢
- ä¿®æ”¹ï¼š`views/crm/invoice/index.vue`(开票列表工具栏)
- æ–°å¢žï¼šå¯¼å…¥å¼¹çª—组件(建议 `views/crm/invoice/modules/import-form.vue`)
- ä¿®æ”¹ï¼š`src/api/crm/invoice/index.ts`(新增 2 ä¸ªæŽ¥å£ï¼‰
## ä¸šåŠ¡æµç¨‹ä¸Žæ•°æ®å¸¦å…¥
1. åˆåŒï¼ˆå·²å®¡æ ¸é€šè¿‡ï¼‰-> å¼€ç¥¨å¯¼å…¥ï¼šæ¨¡æ¿ä¸­å¡«å†™ã€ŒåˆåŒç¼–号」,后端按编号匹配合同,**客户自动取合同所属客户**,无需在模板中填写客户
2. å¯¼å…¥æˆåŠŸ -> å¼€ç¥¨åˆ—表:新增的开票为「未提交」状态,可在列表中继续编辑、上传发票附件、提交审批
3. å¯¼å…¥çš„负责人 = å½“前登录用户(同合同导入),导入后当前用户即可在「我负责的」下看到
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | `/crm/invoice/get-import-template` | ä¸‹è½½å¯¼å…¥æ¨¡æ¿ï¼ˆè¿”回 xls æ–‡ä»¶æµï¼‰ |
| POST | `/crm/invoice/import` | å¯¼å…¥å¼€ç¥¨ï¼ˆmultipart ä¸Šä¼  Excel) |
**权限码:** ä¸¤ä¸ªæŽ¥å£å‡è¦æ±‚ `crm:invoice:import`(按钮由 `docs/sql/crm_invoice_import_menu.sql` è„šæœ¬æŒ‰æƒé™ç è‡ªé€‚åº”åˆ›å»ºï¼šè¥é”€ç®¡ç† -> å¼€ç¥¨ç®¡ç† -> å¼€ç¥¨å¯¼å…¥ï¼Œçˆ¶èœå•跟随开票管理「导出」按钮、角色授权同样跟随「导出」按钮已有角色)。
**请求参数(POST /crm/invoice/import):**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| file | MultipartFile | æ˜¯ | ä»…支持 xls、xlsx |
**模板列(下载的模板已含表头与一行示例):**
| åˆ—名 | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|
| å¼€ç¥¨ç¼–号 | å¦ | ç•™ç©ºåˆ™ç³»ç»Ÿè‡ªåŠ¨ç”Ÿæˆï¼ˆKP+日期+6位序号);填写时不可与系统中已有开票编号重复 |
| åˆåŒç¼–号 | æ˜¯ | éœ€ä¸Žç³»ç»Ÿä¸­**已审核通过**的合同编号一致,客户由合同自动带入 |
| å‘票类型 | æ˜¯ | å¡«å†™å­—å…¸ `crm_invoice_type` çš„名称:增值税专用发票 / å¢žå€¼ç¨Žæ™®é€šå‘票 / ç”µå­å‘票 |
| å‘票号码 | å¦ | å‘票票面号码 |
| å‘票抬头 | å¦ | å‘票抬头 |
| å¼€ç¥¨é‡‘额(元) | æ˜¯ | éœ€å¤§äºŽ 0,且不超过该合同剩余可开票金额 |
| å¼€ç¥¨æ—¥æœŸ | å¦ | æ ¼å¼ `2026-01-01 10:00:00` æˆ– `2026-01-01`;留空取当前时间 |
| å¤‡æ³¨ | å¦ | å¤‡æ³¨ |
**响应:**
```json
{
  "code": 0,
  "data": {
    "createList": ["KP20260924000001", "KP20260924000002"],
    "failureList": {
      "第 4 è¡Œ": "合同编号不存在:HT9999。请先在「CRM åˆåŒç®¡ç†ã€ä¸­åˆ›å»ºæˆ–导入该合同(编号需与模板完全一致),或修改模板中的合同编号后重新导入"
    }
  }
}
```
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| createList | String[] | å¯¼å…¥æˆåŠŸçš„å¼€ç¥¨ç¼–å·æ•°ç»„ |
| failureList | Map | å¯¼å…¥å¤±è´¥é›†åˆï¼škey ä¸ºã€Œå‘票号码 xxx」或「第 N è¡Œã€ï¼ˆN ä¸º Excel è¡Œå·ï¼Œå«è¡¨å¤´ï¼‰ï¼Œvalue ä¸ºå¤±è´¥åŽŸå›  |
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| å¯¼å…¥æŒ‰é’® | å¼€ç¥¨åˆ—表工具栏 | ä½ç½®åœ¨ã€Œæ–°å¢žå¼€ç¥¨ç”³è¯·ã€ä¸Žã€Œå¯¼å‡ºã€ä¹‹é—´ï¼›æƒé™ç  `crm:invoice:import` |
| ä¸‹è½½å¯¼å…¥æ¨¡æ¿æŒ‰é’® | å¯¼å…¥å¼¹çª—左下角 | åŒåˆåŒå¯¼å…¥å¼¹çª—çš„ `prepend-footer` ä½ç½® |
| æ–°å¢žæ¡æ•° | å¯¼å…¥å¼¹çª—结果区 | å– `createList.length`,文案如「新增:N æ¡ã€ |
| å¤±è´¥æ˜Žç»† | å¯¼å…¥å¼¹çª—结果区 | éåކ `failureList`,逐条展示「key:value」,可滚动 |
| å¯¼å…¥æ¨¡æ¿è¯´æ˜Ž | å¯¼å…¥å¼¹çª—内灰色提示区 | å¿…填列:合同编号、发票类型、开票金额;合同必须为「审核通过」;负责人 = å½“前登录用户;状态为「未提交」 |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|------|------|
| åˆåŒçŠ¶æ€ | åªæœ‰ã€Œå®¡æ ¸é€šè¿‡ã€çš„合同才能导入开票;未提交/审批中/不通过的合同该行导入失败 |
| å¼€ç¥¨é‡‘额上限 | å•行金额不得超过「合同金额 âˆ’ å·²å¼€ç¥¨ï¼ˆå®¡æ‰¹ä¸­ + å®¡æ ¸é€šè¿‡ï¼‰ã€çš„剩余额度,超出则该行失败,提示中给出剩余额度与本次金额 |
| é‡å¤ç¼–号 | æ¨¡æ¿ä¸­å¡«å†™çš„开票编号在系统中已存在时该行失败 |
| é‡å¤å¯¼å…¥æ‹¦æˆª | åŒä¸€åˆåŒä¸‹ï¼šå¡«äº†ã€Œå‘票号码」时按「合同 + å‘票号码」判重;未填发票号码时按「合同 + å¼€ç¥¨é‡‘额 + å‘票抬头 + å¼€ç¥¨æ—¥æœŸã€åˆ¤é‡ï¼ˆå¼€ç¥¨æ—¥æœŸç•™ç©ºåˆ™ä¸æ¯”较该维度)。同一文件内重复、或与系统内已有开票重复都会失败并指出重复的单号 |
| éƒ¨åˆ†æˆåŠŸ | é€è¡Œå¯¼å…¥ï¼Œå¤±è´¥è¡Œä¸å½±å“æˆåŠŸè¡Œï¼ˆåŒä¸€æ–‡ä»¶å†…ä¸ä¼šæ•´ä½“å›žæ»šï¼‰ |
| æ•°æ®æƒé™ | å¯¼å…¥çš„开票负责人 = å½“前登录用户,自动创建数据权限,无需前端处理 |
| ä¸Žã€Œæ–°å»ºå¼€ç¥¨ç”³è¯·ã€çš„差异 | å¯¼å…¥ä¸äº§ç”Ÿé™„件;导入的开票默认「未提交」,需在列表中编辑上传发票附件后再提交审批 |
## æ³¨æ„äº‹é¡¹
- æ¨¡æ¿ã€Œå‘票类型」填**名称**(如 å¢žå€¼ç¨Žä¸“用发票),也兼容填数字(1/2/3);填错时失败原因会列出可选项
- å¯¼å…¥å¼¹çª—的进度条/结果区交互可直接复用合同导入的实现(`import-form.vue`)
- å‰ç«¯æŒ‰é’®çš„ `auth` éœ€ä½¿ç”¨æ–°çš„æƒé™ç  `crm:invoice:import`;执行 `docs/sql/crm_invoice_import_menu.sql` åŽé‡æ–°ç™»å½•以刷新菜单与权限缓存,自建角色若不在开票管理「导出」按钮的授权范围内,需在「角色管理」中另行勾选「开票导入」
- ç©ºè¡Œä¼šè¢«è‡ªåŠ¨è·³è¿‡ï¼Œä¸ç®—å¤±è´¥
docs/mes_qc_import_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,157 @@
# MES è´¨æ£€å•批量导入 - å‰ç«¯è”调方案
> éœ€æ±‚:来料检验(IQC)、过程检验(IPQC)、出货检验(OQC)三个列表各增加「批量导入」按钮,位置在「新增」与「导出」之间,用于按模板批量导入检验单。
> å‚考实现:现有各模块导入弹窗(如 `views/mes/md/item/modules/import-form.vue`、`views/crm/contract/modules/import-form.vue`),交互与结果区展示可直接沿用。
## æ¶‰åŠé¡µé¢
- ä¿®æ”¹ï¼š`views/mes/qc/iqc/index.vue`(来料检验单列表工具栏 + å¯¼å…¥å¼¹çª—)
- ä¿®æ”¹ï¼š`views/mes/qc/ipqc/index.vue`(过程检验单列表工具栏 + å¯¼å…¥å¼¹çª—)
- ä¿®æ”¹ï¼š`views/mes/qc/oqc/index.vue`(出货检验单列表工具栏 + å¯¼å…¥å¼¹çª—)
- æ–°å¢žï¼šä¸‰ä¸ªå¯¼å…¥å¼¹çª—组件(建议 `views/mes/qc/{iqc|ipqc|oqc}/modules/import-form.vue`)
- ä¿®æ”¹ï¼š`src/api/mes/qc/{iqc|ipqc|oqc}/index.ts`(各新增 2 ä¸ªæŽ¥å£ï¼‰
## ä¸šåŠ¡æµç¨‹ä¸Žæ•°æ®å¸¦å…¥
1. ä¸‹è½½æ¨¡æ¿ -> å¡«å†™ -> å¯¼å…¥ï¼š**一行 = ä¸€å¼ æ£€éªŒå•**。物料、供应商/客户、质检方案均按「编码 / åç§°ã€åŒ¹é…ç³»ç»Ÿä¸­å·²æœ‰æ•°æ®ï¼Œæ— éœ€å¡«å†™ ID
2. äº§å“ç‰©æ–™ -> è¿‡ç¨‹æ£€éªŒå•(IPQC):模板填写「产品物料编码」+「批号」,二者都是必填;过程检验单按 **物料 + æ‰¹å·** è®°å½•,**模板不填工单 / å·¥ä½ / å·¥åº**
3. è´¨æ£€æ–¹æ¡ˆ -> æ£€éªŒè¡Œï¼šå¯¼å…¥æˆåŠŸåŽç”±åŽç«¯æŒ‰ç‰©æ–™ + æ£€éªŒç±»åž‹åŒ¹é…è´¨æ£€æ–¹æ¡ˆï¼Œå¹¶è‡ªåŠ¨ç”Ÿæˆæ£€éªŒè¡Œï¼ˆæ£€éªŒæŒ‡æ ‡ï¼‰ï¼Œå‰ç«¯æ— éœ€é¢å¤–è°ƒç”¨
4. å¯¼å…¥æˆåŠŸ -> åˆ—表:新增单据状态为「待检验」(草稿),可在列表中编辑、录入检测结果、完成检验
5. æ£€æµ‹äººå‘˜ç•™ç©º = å½“前登录用户;填写时按姓名匹配系统用户
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | `/mes/qc/iqc/get-import-template` | ä¸‹è½½æ¥æ–™æ£€éªŒå•导入模板(返回 xls æ–‡ä»¶æµï¼‰ |
| POST | `/mes/qc/iqc/import` | å¯¼å…¥æ¥æ–™æ£€éªŒå•(multipart ä¸Šä¼  Excel) |
| GET | `/mes/qc/ipqc/get-import-template` | ä¸‹è½½è¿‡ç¨‹æ£€éªŒå•导入模板(返回 xls æ–‡ä»¶æµï¼‰ |
| POST | `/mes/qc/ipqc/import` | å¯¼å…¥è¿‡ç¨‹æ£€éªŒå•(multipart ä¸Šä¼  Excel) |
| GET | `/mes/qc/oqc/get-import-template` | ä¸‹è½½å‡ºè´§æ£€éªŒå•导入模板(返回 xls æ–‡ä»¶æµï¼‰ |
| POST | `/mes/qc/oqc/import` | å¯¼å…¥å‡ºè´§æ£€éªŒå•(multipart ä¸Šä¼  Excel) |
**权限码:** `mes:qc-iqc:import` / `mes:qc-ipqc:import` / `mes:qc-oqc:import`(菜单与按钮由 `docs/sql/mes_qc_import_menu.sql` è„šæœ¬æŒ‰æƒé™ç è‡ªé€‚åº”åˆ›å»ºï¼Œå·²è·Ÿéšå„æ£€éªŒå•ã€ŒæŸ¥è¯¢ã€æŒ‰é’®çš„è§’è‰²æŽˆæƒï¼›è‡ªå»ºè§’è‰²éœ€åœ¨ã€Œè§’è‰²ç®¡ç†ã€ä¸­å‹¾é€‰å¯¹åº”æŒ‰é’®ï¼‰ã€‚
**请求参数(POST .../import):**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| file | MultipartFile | æ˜¯ | ä»…支持 xls、xlsx;无 `updateSupport` å‚数,导入为**纯新增**,不做更新覆盖 |
**响应:**
```json
{
  "code": 0,
  "data": {
    "createList": ["IQC20260924000001", "IQC20260924000002"],
    "failureList": {
      "第 4 è¡Œ": "物料不存在:WL9999。请先在「物料管理」中维护该物料(编码需与模板完全一致),或修改模板中的产品物料编码后重新导入"
    }
  }
}
```
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| createList | String[] | å¯¼å…¥æˆåŠŸçš„æ£€éªŒå•ç¼–å·æ•°ç»„ï¼ˆæ¨¡æ¿æœªå¡«ç¼–å·æ—¶è¿”å›žç³»ç»Ÿè‡ªåŠ¨ç”Ÿæˆçš„ç¼–å·ï¼Œå¯ç”¨äºŽæç¤º/跳转) |
| failureList | Map | å¯¼å…¥å¤±è´¥é›†åˆï¼škey ä¸ºã€Œæ£€éªŒå•编号 X」或「第 N è¡Œã€ï¼ˆN ä¸º Excel è¡Œå·ï¼Œå«è¡¨å¤´è¡Œï¼‰ï¼Œvalue ä¸ºå¤±è´¥åŽŸå›  |
## æ¨¡æ¿åˆ—(下载的模板已含表头与一行示例)
### æ¥æ–™æ£€éªŒå•(IQC)
| åˆ—名 | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|
| æ£€éªŒå•编号 | å¦ | ç•™ç©ºåˆ™ç³»ç»Ÿè‡ªåŠ¨ç”Ÿæˆï¼›å¡«å†™æ—¶ä¸å¯ä¸Žç³»ç»Ÿä¸­å·²æœ‰ç¼–å·é‡å¤ |
| æ£€éªŒå•名称 | æ˜¯ | æ£€éªŒå•名称 |
| äº§å“ç‰©æ–™ç¼–码 | æ˜¯ | éœ€ä¸Žã€Œç‰©æ–™ç®¡ç†ã€ä¸­çš„物料编码一致 |
| ä¾›åº”商名称 | å¦ | éœ€ä¸Žã€Œä¾›åº”商管理」中的名称一致;留空则该单无供应商 |
| ä¾›åº”商批次号 | å¦ | ä¾›åº”商批次号 |
| è´¨æ£€æ–¹æ¡ˆ | å¦ | å¡«æ–¹æ¡ˆåç§°ï¼›ç•™ç©ºåˆ™åŽç«¯æŒ‰ç‰©æ–™è‡ªåŠ¨åŒ¹é… IQC æ–¹æ¡ˆ |
| æœ¬æ¬¡æŽ¥æ”¶æ•°é‡ | å¦ | ç•™ç©ºè‡ªåЍ = åˆæ ¼å“æ•°é‡ + ä¸åˆæ ¼å“æ•°é‡ï¼›å¡«å†™åˆ™å¿…须与之相等 |
| åˆæ ¼å“æ•°é‡ | æ˜¯ | ä¸èƒ½å°äºŽ 0 |
| ä¸åˆæ ¼å“æ•°é‡ | æ˜¯ | ä¸èƒ½å°äºŽ 0 |
| æ£€æµ‹ç»“æžœ | å¦ | å­—å…¸ `mes_qc_check_result` åç§°ï¼šæ ¡éªŒé€šè¿‡ / æ ¡éªŒä¸é€šè¿‡ |
| æ£€æµ‹äººå‘˜ | å¦ | ç”¨æˆ·å§“名;留空取当前登录用户 |
| æ£€æµ‹æ—¥æœŸ | æ˜¯ | æ ¼å¼ `2026-01-01 10:00:00` æˆ– `2026-01-01` |
| æ¥æ–™æ—¥æœŸ | æ˜¯ | æ ¼å¼ `2026-01-01 10:00:00` æˆ– `2026-01-01` |
| å¤‡æ³¨ | å¦ | å¤‡æ³¨ |
### è¿‡ç¨‹æ£€éªŒå•(IPQC)
| åˆ—名 | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|
| æ£€éªŒå•编号 | å¦ | ç•™ç©ºåˆ™ç³»ç»Ÿè‡ªåŠ¨ç”Ÿæˆï¼›å¡«å†™æ—¶ä¸å¯é‡å¤ |
| æ£€éªŒå•名称 | æ˜¯ | æ£€éªŒå•名称 |
| æ£€éªŒç±»åž‹ | æ˜¯ | å­—å…¸ `mes_ipqc_type` åç§°ï¼šè‡ªåˆ¶å·¥åºæ£€éªŒ / é¦–检 / å·¡æ£€ / è‡ªæ£€ / æˆå“æ£€éªŒ |
| äº§å“ç‰©æ–™ç¼–码 | æ˜¯ | éœ€ä¸Žã€Œç‰©æ–™ç®¡ç†ã€ä¸­çš„物料编码一致;**本分支过程检验按「物料 + æ‰¹å·ã€è®°å½•,不填工单/工位** |
| æ‰¹å· | æ˜¯ | è¿‡ç¨‹æ£€éªŒçš„æ‰¹å·ï¼Œæœ€é•¿ 100 å­—符 |
| è´¨æ£€æ–¹æ¡ˆ | å¦ | å¡«æ–¹æ¡ˆåç§°ï¼›ç•™ç©ºåˆ™åŽç«¯æŒ‰ç‰©æ–™è‡ªåŠ¨åŒ¹é… IPQC æ–¹æ¡ˆ |
| æ£€æµ‹æ•°é‡ | å¦ | ç•™ç©ºè‡ªåЍ = åˆæ ¼å“æ•°é‡ + ä¸åˆæ ¼å“æ•°é‡ï¼›å¡«å†™åˆ™å¿…须与之相等 |
| åˆæ ¼å“æ•°é‡ | æ˜¯ | ä¸èƒ½å°äºŽ 0 |
| ä¸åˆæ ¼å“æ•°é‡ | æ˜¯ | ä¸èƒ½å°äºŽ 0 |
| å·¥åºŸæ•°é‡ | å¦ | ç•™ç©ºæŒ‰ 0;与料废、其他废品之和不得超过不合格品数量 |
| æ–™åºŸæ•°é‡ | å¦ | ç•™ç©ºæŒ‰ 0 |
| å…¶ä»–废品数量 | å¦ | ç•™ç©ºæŒ‰ 0 |
| æ£€æµ‹ç»“æžœ | å¦ | å­—å…¸ `mes_qc_check_result` åç§° |
| æ£€æµ‹äººå‘˜ | å¦ | ç”¨æˆ·å§“名;留空取当前登录用户 |
| æ£€æµ‹æ—¥æœŸ | å¦ | æ ¼å¼ `2026-01-01 10:00:00` æˆ– `2026-01-01`;留空则不填 |
| å¤‡æ³¨ | å¦ | å¤‡æ³¨ |
### å‡ºè´§æ£€éªŒå•(OQC)
| åˆ—名 | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|
| æ£€éªŒå•编号 | å¦ | ç•™ç©ºåˆ™ç³»ç»Ÿè‡ªåŠ¨ç”Ÿæˆï¼›å¡«å†™æ—¶ä¸å¯é‡å¤ |
| æ£€éªŒå•名称 | æ˜¯ | æ£€éªŒå•名称 |
| äº§å“ç‰©æ–™ç¼–码 | æ˜¯ | éœ€ä¸Žã€Œç‰©æ–™ç®¡ç†ã€ä¸­çš„物料编码一致 |
| å®¢æˆ·åç§° | å¦ | éœ€ä¸Žã€ŒCRM å®¢æˆ·ç®¡ç†ã€ä¸­çš„名称一致;留空表示该单不属于任何客户。**填写时**必须在当前登录用户的客户数据权限范围内 |
| æ‰¹æ¬¡å· | å¦ | æ‰¹æ¬¡å· |
| è´¨æ£€æ–¹æ¡ˆ | å¦ | å¡«æ–¹æ¡ˆåç§°ï¼›ç•™ç©ºåˆ™åŽç«¯æŒ‰ç‰©æ–™è‡ªåŠ¨åŒ¹é… OQC æ–¹æ¡ˆ |
| æœ¬æ¬¡å‡ºè´§æ•°é‡ | å¦ | å¡«å†™æ—¶ä¸èƒ½å°äºŽ 0;留空表示未填写 |
| æ£€æµ‹æ•°é‡ | å¦ | ç•™ç©ºè‡ªåЍ = åˆæ ¼å“æ•°é‡ + ä¸åˆæ ¼å“æ•°é‡ï¼›å¡«å†™åˆ™å¿…须与之相等 |
| åˆæ ¼å“æ•°é‡ | æ˜¯ | ä¸èƒ½å°äºŽ 0 |
| ä¸åˆæ ¼å“æ•°é‡ | æ˜¯ | ä¸èƒ½å°äºŽ 0 |
| æ£€æµ‹ç»“æžœ | å¦ | å­—å…¸ `mes_qc_check_result` åç§° |
| æ£€æµ‹äººå‘˜ | å¦ | ç”¨æˆ·å§“名;留空取当前登录用户 |
| æ£€æµ‹æ—¥æœŸ | æ˜¯ | æ ¼å¼ `2026-01-01 10:00:00` æˆ– `2026-01-01` |
| å‡ºè´§æ—¥æœŸ | å¦ | æ ¼å¼ `2026-01-01 10:00:00` æˆ– `2026-01-01`;留空表示未填写 |
| å¤‡æ³¨ | å¦ | å¤‡æ³¨ |
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| æ‰¹é‡å¯¼å…¥æŒ‰é’® | ä¸‰ä¸ªåˆ—表工具栏 | ä½ç½®åœ¨ã€Œæ–°å¢žã€ä¸Žã€Œå¯¼å‡ºã€ä¹‹é—´ï¼ˆå³æ¸²æŸ“顺序:新增 -> æ‰¹é‡å¯¼å…¥ -> å¯¼å‡ºï¼‰ï¼›æƒé™ç  `mes:qc-{iqc|ipqc|oqc}:import` |
| ä¸‹è½½å¯¼å…¥æ¨¡æ¿æŒ‰é’® | å¯¼å…¥å¼¹çª—内 | åŒå…¶å®ƒæ¨¡å—导入弹窗的位置(模板下载按钮放弹窗左下角) |
| æ–°å¢žæ¡æ•° | å¯¼å…¥å¼¹çª—结果区 | å– `createList.length`,文案如「新增:N æ¡ã€ |
| å¤±è´¥æ˜Žç»† | å¯¼å…¥å¼¹çª—结果区 | éåކ `failureList`,逐条展示「key:value」,可滚动 |
| å¯¼å…¥è¯´æ˜Ž | å¯¼å…¥å¼¹çª—内灰色提示区 | æç¤ºå¿…填列、数量关系(合格 + ä¸åˆæ ¼ = æ£€æµ‹/接收数量)、状态为「待检验」、检测人员留空取当前登录用户 |
| æç¤ºè¯­è¡¥å…… | å¯¼å…¥å¼¹çª—成功提示 | å¯¼å…¥æˆåŠŸåŽå»ºè®®æç¤ºã€Œæ–°å¢žæ£€éªŒå•ä¸ºã€Žå¾…æ£€éªŒã€çŠ¶æ€ï¼Œè¯·åœ¨åˆ—è¡¨ä¸­ç¼–è¾‘å¹¶å½•å…¥æ£€æµ‹ç»“æžœã€ |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|------|------|
| å¯¼å…¥æ–¹å¼ | çº¯æ–°å¢žï¼Œä¸æ›´æ–°å·²æœ‰æ£€éªŒå•ï¼›`updateSupport` å‚数不存在 |
| ç¼–号生成 | ã€Œæ£€éªŒå•编号」留空由后端按编码规则自动生成(IQC / IPQC / OQC å„自前缀);填写时与系统中已有编号重复则该行失败 |
| è´¨æ£€æ–¹æ¡ˆåŒ¹é… | ã€Œè´¨æ£€æ–¹æ¡ˆã€ç•™ç©ºæ—¶åŽç«¯æŒ‰ç‰©æ–™ + æ£€éªŒç±»åž‹è‡ªåŠ¨åŒ¹é…ï¼›è‹¥è¯¥ç‰©æ–™æœªé…ç½®å¯¹åº”ç±»åž‹çš„æ–¹æ¡ˆï¼Œè¯¥è¡Œå¤±è´¥å¹¶æç¤ºéœ€é…ç½®æ–¹æ¡ˆçš„ç‰©æ–™ä¸Žå…¥å£ |
| æ•°é‡å…³ç³» | åˆæ ¼å“æ•°é‡ + ä¸åˆæ ¼å“æ•°é‡ å¿…须等于「检测数量」(IPQC/OQC)或「本次接收数量」(IQC);留空时自动取两者之和;填写不相等时该行失败并给出两边的实际值 |
| IPQC åºŸå“æ•°é‡ | å·¥åºŸ + æ–™åºŸ + å…¶ä»–废品 ä¹‹å’Œä¸å¾—超过不合格品数量,超出该行失败;三列留空均按 0 å¤„理 |
| IPQC è®°å½•维度 | ä»¥ã€Œäº§å“ç‰©æ–™ç¼–码 + æ‰¹å·ã€è¯†åˆ«ä¸€æ¡è¿‡ç¨‹æ£€éªŒè®°å½•,批号必填 |
| çŠ¶æ€ | å¯¼å…¥åŽçš„æ£€éªŒå•统一为「待检验」(草稿),不直接完成 |
| æ£€éªŒè¡Œç”Ÿæˆ | ç”±åŽç«¯æŒ‰åŒ¹é…åˆ°çš„质检方案自动生成检验行(检验指标),无需前端额外处理 |
| OQC å®¢æˆ·æ•°æ®æƒé™ | ã€Œå®¢æˆ·åç§°ã€å¡«å†™æ—¶å¿…须在当前登录用户的可见客户范围内,否则该行失败(否则导入后列表中也看不到该单);留空则不校验,导入后不受客户权限遮挡 |
| éƒ¨åˆ†æˆåŠŸ | é€è¡Œå¯¼å…¥ï¼Œå¤±è´¥è¡Œä¸å½±å“æˆåŠŸè¡Œï¼ˆåŒä¸€æ–‡ä»¶å†…ä¸ä¼šæ•´ä½“å›žæ»šï¼‰ï¼›å…¨éƒ¨è¡Œä¸ºç©ºæ—¶è¿”å›žç©ºç»“æžœ |
| ç©ºè¡Œ | æ•´è¡Œä¸ºç©ºçš„记录自动跳过,不记为失败 |
## æ³¨æ„äº‹é¡¹
- ã€Œæ£€éªŒç±»åž‹ã€ã€Œæ£€æµ‹ç»“果」等字典列填**名称**(如 é¦–检、校验通过),也兼容填数字(1/2);填错时失败原因会列出可选值
- ã€Œè´¨æ£€æ–¹æ¡ˆã€åˆ—填的是**方案名称**,不是方案编码;留空即为「自动匹配」,是推荐用法
- IPQC çš„「检验类型」「产品物料编码」「批号」都是**必填**列,且方案的检验类型需与检验单的类型可匹配,否则可能提示未配置方案
- ã€Œæ£€æµ‹äººå‘˜ã€ç•™ç©ºå–当前登录用户;填写姓名时若存在同名用户,会取匹配到的第一条,建议填姓名全称
- ä¸‰ä¸ªåˆ—表的导入互不影响,权限码各自独立;自建角色需在「角色管理」中勾选对应的「XX检验导入」按钮后才可见该按钮
- å¯¼å…¥å¼¹çª—的进度条 / ç»“果区 / æ¨¡æ¿ä¸‹è½½äº¤äº’可直接复用现有 `import-form.vue` å®žçް
- æ—¥æœŸåˆ—支持 `yyyy-MM-dd HH:mm:ss` ä¸Ž `yyyy-MM-dd` ä¸¤ç§æ ¼å¼ï¼›IQC çš„æ£€æµ‹/来料日期、OQC çš„æ£€æµ‹æ—¥æœŸä¸ºå¿…填,IPQC çš„æ£€æµ‹æ—¥æœŸã€OQC çš„出货日期可留空
docs/project-business/data/flows.json
@@ -29,12 +29,14 @@
       {"stage": "联动", "action": "审批通过按工序标记: å€’冲→自动物料消耗扣线边库(允许负库存);质检→产出待检行等 IPQC;关键非检→直接产出入库", "roles": ["系统自动"]},
       {"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) + åˆ¤å®šå››æžœ(合格/特采/退货/报废) + NCR è¯„审处置关闭;IQC/IPQC/OQC æ”¯æŒ Excel æ‰¹é‡å¯¼å…¥(纯新增、逐行部分成功、按业务键去重),并提供跨四类的检测结果台账查询与导出;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": "退料/销售退货/售后退货→RQC,结论回调单据与售后", "roles": ["RQC è´¨æ£€å‘˜"]},
       {"stage": "导入", "action": "IQC/IPQC/OQC ä¸‰åˆ—表各支持 Excel æ‰¹é‡å¯¼å…¥(按钮在「新增」与「导出」之间,纯新增不做更新覆盖):一行一张单,编号留空自动生成,质检方案留空按物料+检验类型自动匹配,检测人员留空取当前登录用户;物料/供应商/客户按编码或名称精确匹配(IQC ä¾›åº”商与供应商批次可留空、OQC å®¢æˆ·å¯ç•™ç©ºä¸”留空不受客户数据权限遮挡),合格+不合格=检测/接收数量、IPQC å·¥åºŸ+料废+其他废品≤不合格(三列留空按0),OQC å¡«å®¢æˆ·æ—¶é¡»åœ¨æœ¬äººå®¢æˆ·æ•°æ®æƒé™å†…;逐行导入部分成功,失败行进 failureList å¸¦è¡Œå·ä¸Žå¯æ‰§è¡ŒåŠ¨ä½œï¼ŒæˆåŠŸå•ä¸€å¾‹å¾…æ£€éªŒ0;同一批数据不可重复导入:文件内重复行与库内已存在同业务键单据(IQC ç‰©æ–™+供应商+供应商批次+来料日期、IPQC ç‰©æ–™+批号+检验类型+检测日期、OQC ç‰©æ–™+客户+批次号+出货日期)均拒该行,提示含维度、双方实际值与「先删除该单据再重试」动作", "roles": ["质检员", "IQC è´¨æ£€å‘˜", "IPQC è´¨æ£€å‘˜", "OQC è´¨æ£€å‘˜"]},
       {"stage": "台账", "action": "检测结果台账:跨 IQC/IPQC/OQC/RQC å››ç±»æ£€éªŒå•统一查询(分类+检测日期区间+检验单号+物料+批次号+检测结果+检测人),一行一个检测项结果,判定依据由标准值与上下限拼装;批次号按类型取来源(IQC å–供应商批次号,IPQC/OQC/RQC å–批号);同套筛选条件直接导出 Excel å°è´¦ç”¨äºŽæ‰“印存档;未录检测项的单仍保留一行不漏单", "roles": ["质检员", "质量工程师"]},
       {"stage": "不合格", "action": "NCR: å¾…评审→评审→处置(退货/报废/返工/让步接收/降级)→关闭", "roles": ["质量工程师"]},
       {"stage": "出件", "action": "两条出件入口:①通用 generate/preview(调用方自带上下文) æ¸²æŸ“ HTML å¹¶å†»ç»“数据快照,编号 QR+日期+4位流水按天独立,preview åªæ¸²æŸ“不落库不消耗编号,regenerate ç”¨å†»ç»“快照重渲且数据与编号不变并作废已归档 PDF,导出 PDF ç”±åŽç«¯è°ƒ Chromium æ‰“印实例已存的 HTML åŽå½’档附件中心;②generate-from-qc ç”±è´¨æ£€å•直接出件(数据经 MesQcReportApi ä»Ž MES å–,前端不拼上下文),出件前五道校验按业务优先级只报第一处:类型非法/单据不存在/未完成检验/未填判定/模板类型不符/无可用检验项,报告头另带单据原判定 qcResult+qcResultText ä¸Žå¼•擎判定并存,响应回 undecidableCount å¾…判定项数与 warnings(分组标题跳过/指标已删/未录实测值/样品超3),同一张单据可反复出多份", "roles": ["质检员"]},
       {"stage": "建模板", "action": "AI å¯¼å…¥ï¼šä¸Šä¼ æ—¢æœ‰æ£€éªŒæŠ¥å‘Šæ–‡ä»¶(扫描件/照片、电子版 PDF、Word/Excel) â†’ è¯†åˆ«æˆæ¨¡æ¿è‰ç¨¿(不落库) â†’ äººå·¥åœ¨è®¾è®¡å™¨ç¡®è®¤è°ƒæ•´åŽä¿å­˜ä¸ºç‰ˆæœ¬ï¼›.doc/.xls æ—§æ ¼å¼ä¸æ”¯æŒ", "roles": ["质检员", "质量工程师"]}]},
docs/project-business/data/modules.json
@@ -11,7 +11,7 @@
    {"key": "crm", "name": "销售管理(客户关系)", "color": "#4f9cff", "tag": "CRM", "flow": "sales",
     "positioning": "销售前端:从线索到回款的客户经营",
     "problem": "客户资产沉淀、商机转化、合同审批与开票回款管控",
     "features": ["线索转化客户", "公海池与领取上限", "联系人", "商机自定义阶段", "跟进记录", "报价单(AI OCR)", "合同(BPM审批→生成销售订单)", "开票(BPM)→回款(先开票校验)", "回款计划", "销售目标", "数据权限三级(负责人/只读/读写)"],
     "features": ["线索转化客户", "公海池与领取上限", "联系人", "商机自定义阶段", "跟进记录", "报价单(AI OCR)", "合同(BPM审批→生成销售订单)", "开票(BPM审批·支持Excel导入·同合同同发票号码/同金额抬头日期不可重复导入)→回款(先开票校验)", "回款计划", "销售目标", "数据权限三级(负责人/只读/读写)"],
     "objects": ["线索", "客户", "联系人", "商机", "报价单", "合同", "发票", "回款", "销售目标"],
     "roles": ["销售内勤", "销售经理", "财务(开票回款)"],
     "upstream": ["主数据(物料)"], "downstream": ["进销存(销售订单)", "售后(客户)", "AI(报价OCR)"],
docs/project-business/data/objects.json
@@ -8,7 +8,7 @@
    {"name": "商机", "module": "销售管理", "table": "crm_business", "doc": "销售机会,DB é…ç½®å¼é˜¶æ®µæŽ¨è¿›ï¼Œç»“束态: èµ¢/输/无效", "upstream": ["客户"], "downstream": ["报价单", "合同"], "keywords": "商机 business èµ¢å• è¾“单 é˜¶æ®µ"},
    {"name": "报价单", "module": "销售管理", "table": "crm_sale_quotation", "doc": "QT编号,选 MDM ç‰©æ–™æŠ¥ä»·ï¼ŒAI OCR å½•入,草稿→已转合同", "upstream": ["商机"], "downstream": ["合同"], "keywords": "报价 quotation QT"},
    {"name": "合同", "module": "销售管理", "table": "crm_contract", "doc": "HT编号,BPM å®¡æ‰¹(0草稿/10审批中/20通过/30驳回/40取消/50作废),通过后可生成销售订单", "upstream": ["报价单"], "downstream": ["销售订单", "回款计划", "开票"], "keywords": "合同 contract å®¡æ‰¹"},
    {"name": "发票", "module": "销售管理", "table": "crm_invoice", "doc": "KP编号,BPM å®¡æ‰¹ï¼Œç´¯è®¡å¼€ç¥¨â‰¤åˆåŒé¢ï¼Œé™„ä»¶å¿…ä¼ ", "upstream": ["合同"], "downstream": ["回款"], "keywords": "开票 å‘票 invoice"},
    {"name": "发票", "module": "销售管理", "table": "crm_invoice", "doc": "KP编号(可留空自动生成、系统内唯一),BPM å®¡æ‰¹ï¼Œç´¯è®¡å¼€ç¥¨â‰¤åˆåŒé¢ï¼Œé™„件必传;支持 Excel å¯¼å…¥ï¼ˆçº¯æ–°å¢žï¼ŒåˆåŒé¡»å®¡æ ¸é€šè¿‡ä¸”本次金额≤合同剩余可开票额;同一批数据不可重复导入——同合同下发票号码相同、或发票号码留空时开票金额+发票抬头+开票日期相同即判重复,文件内重复行与库内已有单都拒该行并回显双方实际值)", "upstream": ["合同", "Excel å¯¼å…¥"], "downstream": ["回款"], "keywords": "开票 å‘票 invoice å¯¼å…¥ åŽ»é‡"},
    {"name": "回款", "module": "销售管理", "table": "crm_receivable", "doc": "HK编号,创建即审批中(手动审核),强制先开票,金额双校验", "upstream": ["合同", "发票"], "downstream": ["收款单(关联)"], "keywords": "回款 æ”¶æ¬¾è®¡åˆ’ receivable"},
    {"name": "销售目标", "module": "销售管理", "table": "crm_sales_target", "doc": "å¹´/å­£/月×个人/部门/产品维度的合同回款等目标", "upstream": [], "downstream": ["业绩统计"], "keywords": "目标 target ä¸šç»©"},
    {"name": "供应商", "module": "供应商管理", "table": "srm_supplier", "doc": "供应商档案:执照/税号/银行/联系人,ERP ä¸Ž MES å…±ç”¨", "upstream": ["准入申请"], "downstream": ["采购订单", "到货通知", "供应商退货", "绩效评价"], "keywords": "供应商 supplier vendor"},
@@ -35,9 +35,9 @@
    {"name": "盘点任务", "module": "仓储", "table": "mes_wm_stock_taking_task", "doc": "静态/动态盘点,行结果:正常/盘盈(ADJUST_IN)/盘亏(ADJUST_OUT),可冻结库存、盲盘", "upstream": ["库存台账"], "downstream": ["库存台账(调整)"], "keywords": "盘点 ç›˜ç›ˆ ç›˜äº stock taking"},
    {"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,结论回写来源单据;行操作「出报告」仅在已完成时可见", "upstream": ["到货通知", "外协入库"], "downstream": ["采购入库", "供应商退货", "NCR", "报告实例(出报告)"], "keywords": "IQC æ¥æ–™æ£€éªŒ å‡ºæŠ¥å‘Š"},
    {"name": "IPQC过程检验", "module": "质量", "table": "mes_qc_ipqc", "doc": "报工质检工序产出行触发,完成回调推进报工与产出入库;表单只留「来源单据 + äº§å“ç‰©æ–™(前端手选 MDM ç‰©æ–™) + æ‰¹å·(必填) + æ•°é‡ + æ£€éªŒä¿¡æ¯ã€ï¼Œå·²åŽ»ç”Ÿäº§è®¢å•/生产任务/工位/工序,批号随报告落款;行操作「出报告」仅在已完成时可见", "upstream": ["报工"], "downstream": ["产出入库", "NCR", "报告实例(出报告)"], "keywords": "IPQC è¿‡ç¨‹æ£€éªŒ å·¡æ£€ å‡ºæŠ¥å‘Š æ‰¹å· batchCode MDM物料"},
    {"name": "OQC出货检验", "module": "质量", "table": "mes_qc_oqc", "doc": "销售出库提交触发,完成推进出库单待拣货;行操作「出报告」仅在已完成时可见", "upstream": ["销售出库单"], "downstream": ["销售出库单(推进)", "报告实例(出报告)"], "keywords": "OQC å‡ºè´§æ£€éªŒ å‡ºåŽ‚æ£€éªŒ å‡ºæŠ¥å‘Š"},
    {"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": "报工质检工序产出行触发,完成回调推进报工与产出入库;表单只留「来源单据 + äº§å“ç‰©æ–™(前端手选 MDM ç‰©æ–™) + æ‰¹å·(必填) + æ•°é‡ + æ£€éªŒä¿¡æ¯ã€ï¼Œå·²åŽ»ç”Ÿäº§è®¢å•/生产任务/工位/工序,批号随报告落款;行操作「出报告」仅在已完成时可见;支持 Excel æ‰¹é‡å¯¼å…¥ï¼ˆæ¨¡æ¿æŒ‰ã€Œäº§å“ç‰©æ–™ç¼–码 + æ‰¹å·(必填) + æ£€éªŒç±»åž‹ã€å¡«åˆ—,不填工单/工位;工废+料废+其他废品不得超过不合格品数量,三列留空按 0);同一批数据不可重复导入(物料+批号+检验类型+检测日期,文件内重复行与库内已有单都拒该行)", "upstream": ["报工", "Excel æ‰¹é‡å¯¼å…¥"], "downstream": ["产出入库", "NCR", "报告实例(出报告)"], "keywords": "IPQC è¿‡ç¨‹æ£€éªŒ å·¡æ£€ å‡ºæŠ¥å‘Š æ‰¹å· batchCode MDM物料 æ‰¹é‡å¯¼å…¥ å¯¼å…¥æ¨¡æ¿"},
    {"name": "OQC出货检验", "module": "质量", "table": "mes_qc_oqc", "doc": "销售出库提交触发,完成推进出库单待拣货;行操作「出报告」仅在已完成时可见;支持 Excel æ‰¹é‡å¯¼å…¥ï¼ˆå®¢æˆ·åç§°/本次出货数量/出货日期均可留空,留空即该单不属任何客户、不受客户数据权限遮挡;填客户时须在本人客户数据权限内);同一批数据不可重复导入(物料+客户+批次号+出货日期,文件内重复行与库内已有单都拒该行)", "upstream": ["销售出库单", "Excel æ‰¹é‡å¯¼å…¥"], "downstream": ["销售出库单(推进)", "报告实例(出报告)"], "keywords": "OQC å‡ºè´§æ£€éªŒ å‡ºåŽ‚æ£€éªŒ å‡ºæŠ¥å‘Š æ‰¹é‡å¯¼å…¥ å¯¼å…¥æ¨¡æ¿"},
    {"name": "RQC退货检验", "module": "质量", "table": "mes_qc_rqc", "doc": "生产退料/销售退货/售后退货触发,结论回调售后;行操作「出报告」仅在已完成时可见。措辞已统一为「退货检验」(原菜单父项与 mes_qc_type å­—典写「退料检验」,与后端枚举/表注释/菜单权限子项不一致;RQC ä¸‰è€…都覆盖,故取「退货检验」)", "upstream": ["退料", "退货"], "downstream": ["退货入库", "售后回调", "报告实例(出报告)"], "keywords": "RQC é€€è´§æ£€éªŒ é€€æ–™æ£€éªŒ å‡ºæŠ¥å‘Š"},
    {"name": "NCR不合格品", "module": "质量", "table": "mes_qc_ncr", "doc": "待评审0→已评审6→已处置7→已关闭8,处置:退货/报废/返工/让步接收/降级", "upstream": ["IQC/IPQC/OQC/RQC"], "downstream": ["退货/报废/返工(台账)"], "keywords": "NCR ä¸åˆæ ¼ å¤„ç½® ç‰¹é‡‡ æŠ¥åºŸ"},
    {"name": "设备", "module": "设备", "table": "mes_dv_machinery", "doc": "台账状态:停机/生产中/保养中;点检·保养·维修工单;AI é£Žé™©é¢„测消费其数据", "upstream": [], "downstream": ["点检", "ç»´ä¿®", "生产风险预测"], "keywords": "设备 ç‚¹æ£€ ä¿å…» ç»´ä¿® machinery"},
docs/project-business/index.html
@@ -341,7 +341,7 @@
    {"key": "crm", "name": "销售管理(客户关系)", "color": "#4f9cff", "tag": "CRM", "flow": "sales",
     "positioning": "销售前端:从线索到回款的客户经营",
     "problem": "客户资产沉淀、商机转化、合同审批与开票回款管控",
     "features": ["线索转化客户", "公海池与领取上限", "联系人", "商机自定义阶段", "跟进记录", "报价单(AI OCR)", "合同(BPM审批→生成销售订单)", "开票(BPM)→回款(先开票校验)", "回款计划", "销售目标", "数据权限三级(负责人/只读/读写)"],
     "features": ["线索转化客户", "公海池与领取上限", "联系人", "商机自定义阶段", "跟进记录", "报价单(AI OCR)", "合同(BPM审批→生成销售订单)", "开票(BPM审批·支持Excel导入·同合同同发票号码/同金额抬头日期不可重复导入)→回款(先开票校验)", "回款计划", "销售目标", "数据权限三级(负责人/只读/读写)"],
     "objects": ["线索", "客户", "联系人", "商机", "报价单", "合同", "发票", "回款", "销售目标"],
     "roles": ["销售内勤", "销售经理", "财务(开票回款)"],
     "upstream": ["主数据(物料)"], "downstream": ["进销存(销售订单)", "售后(客户)", "AI(报价OCR)"],
@@ -451,7 +451,7 @@
    {"name": "商机", "module": "销售管理", "table": "crm_business", "doc": "销售机会,DB é…ç½®å¼é˜¶æ®µæŽ¨è¿›ï¼Œç»“束态: èµ¢/输/无效", "upstream": ["客户"], "downstream": ["报价单", "合同"], "keywords": "商机 business èµ¢å• è¾“单 é˜¶æ®µ"},
    {"name": "报价单", "module": "销售管理", "table": "crm_sale_quotation", "doc": "QT编号,选 MDM ç‰©æ–™æŠ¥ä»·ï¼ŒAI OCR å½•入,草稿→已转合同", "upstream": ["商机"], "downstream": ["合同"], "keywords": "报价 quotation QT"},
    {"name": "合同", "module": "销售管理", "table": "crm_contract", "doc": "HT编号,BPM å®¡æ‰¹(0草稿/10审批中/20通过/30驳回/40取消/50作废),通过后可生成销售订单", "upstream": ["报价单"], "downstream": ["销售订单", "回款计划", "开票"], "keywords": "合同 contract å®¡æ‰¹"},
    {"name": "发票", "module": "销售管理", "table": "crm_invoice", "doc": "KP编号,BPM å®¡æ‰¹ï¼Œç´¯è®¡å¼€ç¥¨â‰¤åˆåŒé¢ï¼Œé™„ä»¶å¿…ä¼ ", "upstream": ["合同"], "downstream": ["回款"], "keywords": "开票 å‘票 invoice"},
    {"name": "发票", "module": "销售管理", "table": "crm_invoice", "doc": "KP编号(可留空自动生成、系统内唯一),BPM å®¡æ‰¹ï¼Œç´¯è®¡å¼€ç¥¨â‰¤åˆåŒé¢ï¼Œé™„件必传;支持 Excel å¯¼å…¥ï¼ˆçº¯æ–°å¢žï¼ŒåˆåŒé¡»å®¡æ ¸é€šè¿‡ä¸”本次金额≤合同剩余可开票额;同一批数据不可重复导入——同合同下发票号码相同、或发票号码留空时开票金额+发票抬头+开票日期相同即判重复,文件内重复行与库内已有单都拒该行并回显双方实际值)", "upstream": ["合同", "Excel å¯¼å…¥"], "downstream": ["回款"], "keywords": "开票 å‘票 invoice å¯¼å…¥ åŽ»é‡"},
    {"name": "回款", "module": "销售管理", "table": "crm_receivable", "doc": "HK编号,创建即审批中(手动审核),强制先开票,金额双校验", "upstream": ["合同", "发票"], "downstream": ["收款单(关联)"], "keywords": "回款 æ”¶æ¬¾è®¡åˆ’ receivable"},
    {"name": "销售目标", "module": "销售管理", "table": "crm_sales_target", "doc": "å¹´/å­£/月×个人/部门/产品维度的合同回款等目标", "upstream": [], "downstream": ["业绩统计"], "keywords": "目标 target ä¸šç»©"},
    {"name": "供应商", "module": "供应商管理", "table": "srm_supplier", "doc": "供应商档案:执照/税号/银行/联系人,ERP ä¸Ž MES å…±ç”¨", "upstream": ["准入申请"], "downstream": ["采购订单", "到货通知", "供应商退货", "绩效评价"], "keywords": "供应商 supplier vendor"},
@@ -478,9 +478,9 @@
    {"name": "盘点任务", "module": "仓储", "table": "mes_wm_stock_taking_task", "doc": "静态/动态盘点,行结果:正常/盘盈(ADJUST_IN)/盘亏(ADJUST_OUT),可冻结库存、盲盘", "upstream": ["库存台账"], "downstream": ["库存台账(调整)"], "keywords": "盘点 ç›˜ç›ˆ ç›˜äº stock taking"},
    {"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,结论回写来源单据;行操作「出报告」仅在已完成时可见", "upstream": ["到货通知", "外协入库"], "downstream": ["采购入库", "供应商退货", "NCR", "报告实例(出报告)"], "keywords": "IQC æ¥æ–™æ£€éªŒ å‡ºæŠ¥å‘Š"},
    {"name": "IPQC过程检验", "module": "质量", "table": "mes_qc_ipqc", "doc": "报工质检工序产出行触发,完成回调推进报工与产出入库;表单只留「来源单据 + äº§å“ç‰©æ–™(前端手选 MDM ç‰©æ–™) + æ‰¹å·(必填) + æ•°é‡ + æ£€éªŒä¿¡æ¯ã€ï¼Œå·²åŽ»ç”Ÿäº§è®¢å•/生产任务/工位/工序,批号随报告落款;行操作「出报告」仅在已完成时可见", "upstream": ["报工"], "downstream": ["产出入库", "NCR", "报告实例(出报告)"], "keywords": "IPQC è¿‡ç¨‹æ£€éªŒ å·¡æ£€ å‡ºæŠ¥å‘Š æ‰¹å· batchCode MDM物料"},
    {"name": "OQC出货检验", "module": "质量", "table": "mes_qc_oqc", "doc": "销售出库提交触发,完成推进出库单待拣货;行操作「出报告」仅在已完成时可见", "upstream": ["销售出库单"], "downstream": ["销售出库单(推进)", "报告实例(出报告)"], "keywords": "OQC å‡ºè´§æ£€éªŒ å‡ºåŽ‚æ£€éªŒ å‡ºæŠ¥å‘Š"},
    {"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": "报工质检工序产出行触发,完成回调推进报工与产出入库;表单只留「来源单据 + äº§å“ç‰©æ–™(前端手选 MDM ç‰©æ–™) + æ‰¹å·(必填) + æ•°é‡ + æ£€éªŒä¿¡æ¯ã€ï¼Œå·²åŽ»ç”Ÿäº§è®¢å•/生产任务/工位/工序,批号随报告落款;行操作「出报告」仅在已完成时可见;支持 Excel æ‰¹é‡å¯¼å…¥ï¼ˆæ¨¡æ¿æŒ‰ã€Œäº§å“ç‰©æ–™ç¼–码 + æ‰¹å·(必填) + æ£€éªŒç±»åž‹ã€å¡«åˆ—,不填工单/工位;工废+料废+其他废品不得超过不合格品数量,三列留空按 0);同一批数据不可重复导入(物料+批号+检验类型+检测日期,文件内重复行与库内已有单都拒该行)", "upstream": ["报工", "Excel æ‰¹é‡å¯¼å…¥"], "downstream": ["产出入库", "NCR", "报告实例(出报告)"], "keywords": "IPQC è¿‡ç¨‹æ£€éªŒ å·¡æ£€ å‡ºæŠ¥å‘Š æ‰¹å· batchCode MDM物料 æ‰¹é‡å¯¼å…¥ å¯¼å…¥æ¨¡æ¿"},
    {"name": "OQC出货检验", "module": "质量", "table": "mes_qc_oqc", "doc": "销售出库提交触发,完成推进出库单待拣货;行操作「出报告」仅在已完成时可见;支持 Excel æ‰¹é‡å¯¼å…¥ï¼ˆå®¢æˆ·åç§°/本次出货数量/出货日期均可留空,留空即该单不属任何客户、不受客户数据权限遮挡;填客户时须在本人客户数据权限内);同一批数据不可重复导入(物料+客户+批次号+出货日期,文件内重复行与库内已有单都拒该行)", "upstream": ["销售出库单", "Excel æ‰¹é‡å¯¼å…¥"], "downstream": ["销售出库单(推进)", "报告实例(出报告)"], "keywords": "OQC å‡ºè´§æ£€éªŒ å‡ºåŽ‚æ£€éªŒ å‡ºæŠ¥å‘Š æ‰¹é‡å¯¼å…¥ å¯¼å…¥æ¨¡æ¿"},
    {"name": "RQC退货检验", "module": "质量", "table": "mes_qc_rqc", "doc": "生产退料/销售退货/售后退货触发,结论回调售后;行操作「出报告」仅在已完成时可见。措辞已统一为「退货检验」(原菜单父项与 mes_qc_type å­—典写「退料检验」,与后端枚举/表注释/菜单权限子项不一致;RQC ä¸‰è€…都覆盖,故取「退货检验」)", "upstream": ["退料", "退货"], "downstream": ["退货入库", "售后回调", "报告实例(出报告)"], "keywords": "RQC é€€è´§æ£€éªŒ é€€æ–™æ£€éªŒ å‡ºæŠ¥å‘Š"},
    {"name": "NCR不合格品", "module": "质量", "table": "mes_qc_ncr", "doc": "待评审0→已评审6→已处置7→已关闭8,处置:退货/报废/返工/让步接收/降级", "upstream": ["IQC/IPQC/OQC/RQC"], "downstream": ["退货/报废/返工(台账)"], "keywords": "NCR ä¸åˆæ ¼ å¤„ç½® ç‰¹é‡‡ æŠ¥åºŸ"},
    {"name": "设备", "module": "设备", "table": "mes_dv_machinery", "doc": "台账状态:停机/生产中/保养中;点检·保养·维修工单;AI é£Žé™©é¢„测消费其数据", "upstream": [], "downstream": ["点检", "ç»´ä¿®", "生产风险预测"], "keywords": "设备 ç‚¹æ£€ ä¿å…» ç»´ä¿® machinery"},
@@ -524,12 +524,14 @@
       {"stage": "联动", "action": "审批通过按工序标记: å€’冲→自动物料消耗扣线边库(允许负库存);质检→产出待检行等 IPQC;关键非检→直接产出入库", "roles": ["系统自动"]},
       {"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) + åˆ¤å®šå››æžœ(合格/特采/退货/报废) + NCR è¯„审处置关闭;IQC/IPQC/OQC æ”¯æŒ Excel æ‰¹é‡å¯¼å…¥(纯新增、逐行部分成功、按业务键去重),并提供跨四类的检测结果台账查询与导出;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": "退料/销售退货/售后退货→RQC,结论回调单据与售后", "roles": ["RQC è´¨æ£€å‘˜"]},
       {"stage": "导入", "action": "IQC/IPQC/OQC ä¸‰åˆ—表各支持 Excel æ‰¹é‡å¯¼å…¥(按钮在「新增」与「导出」之间,纯新增不做更新覆盖):一行一张单,编号留空自动生成,质检方案留空按物料+检验类型自动匹配,检测人员留空取当前登录用户;物料/供应商/客户按编码或名称精确匹配(IQC ä¾›åº”商与供应商批次可留空、OQC å®¢æˆ·å¯ç•™ç©ºä¸”留空不受客户数据权限遮挡),合格+不合格=检测/接收数量、IPQC å·¥åºŸ+料废+其他废品≤不合格(三列留空按0),OQC å¡«å®¢æˆ·æ—¶é¡»åœ¨æœ¬äººå®¢æˆ·æ•°æ®æƒé™å†…;逐行导入部分成功,失败行进 failureList å¸¦è¡Œå·ä¸Žå¯æ‰§è¡ŒåŠ¨ä½œï¼ŒæˆåŠŸå•ä¸€å¾‹å¾…æ£€éªŒ0;同一批数据不可重复导入:文件内重复行与库内已存在同业务键单据(IQC ç‰©æ–™+供应商+供应商批次+来料日期、IPQC ç‰©æ–™+批号+检验类型+检测日期、OQC ç‰©æ–™+客户+批次号+出货日期)均拒该行,提示含维度、双方实际值与「先删除该单据再重试」动作", "roles": ["质检员", "IQC è´¨æ£€å‘˜", "IPQC è´¨æ£€å‘˜", "OQC è´¨æ£€å‘˜"]},
       {"stage": "台账", "action": "检测结果台账:跨 IQC/IPQC/OQC/RQC å››ç±»æ£€éªŒå•统一查询(分类+检测日期区间+检验单号+物料+批次号+检测结果+检测人),一行一个检测项结果,判定依据由标准值与上下限拼装;批次号按类型取来源(IQC å–供应商批次号,IPQC/OQC/RQC å–批号);同套筛选条件直接导出 Excel å°è´¦ç”¨äºŽæ‰“印存档;未录检测项的单仍保留一行不漏单", "roles": ["质检员", "质量工程师"]},
       {"stage": "不合格", "action": "NCR: å¾…评审→评审→处置(退货/报废/返工/让步接收/降级)→关闭", "roles": ["质量工程师"]},
       {"stage": "出件", "action": "两条出件入口:①通用 generate/preview(调用方自带上下文) æ¸²æŸ“ HTML å¹¶å†»ç»“数据快照,编号 QR+日期+4位流水按天独立,preview åªæ¸²æŸ“不落库不消耗编号,regenerate ç”¨å†»ç»“快照重渲且数据与编号不变并作废已归档 PDF,导出 PDF ç”±åŽç«¯è°ƒ Chromium æ‰“印实例已存的 HTML åŽå½’档附件中心;②generate-from-qc ç”±è´¨æ£€å•直接出件(数据经 MesQcReportApi ä»Ž MES å–,前端不拼上下文),出件前五道校验按业务优先级只报第一处:类型非法/单据不存在/未完成检验/未填判定/模板类型不符/无可用检验项,报告头另带单据原判定 qcResult+qcResultText ä¸Žå¼•擎判定并存,响应回 undecidableCount å¾…判定项数与 warnings(分组标题跳过/指标已删/未录实测值/样品超3),同一张单据可反复出多份", "roles": ["质检员"]},
       {"stage": "建模板", "action": "AI å¯¼å…¥ï¼šä¸Šä¼ æ—¢æœ‰æ£€éªŒæŠ¥å‘Šæ–‡ä»¶(扫描件/照片、电子版 PDF、Word/Excel) â†’ è¯†åˆ«æˆæ¨¡æ¿è‰ç¨¿(不落库) â†’ äººå·¥åœ¨è®¾è®¡å™¨ç¡®è®¤è°ƒæ•´åŽä¿å­˜ä¸ºç‰ˆæœ¬ï¼›.doc/.xls æ—§æ ¼å¼ä¸æ”¯æŒ", "roles": ["质检员", "质量工程师"]}]},
@@ -754,6 +756,25 @@
  T2[报工质检工序通过后产出行] --> A2[IPQC è¿‡ç¨‹æ£€éªŒÂ·è‰ç¨¿0→完成4]
  T3[销售出库单提交] --> A3[OQC å‡ºè´§æ£€éªŒÂ·è‰ç¨¿0→完成4]
  T4[生产退料单 æˆ– é”€å”®é€€è´§å• æˆ– å”®åŽé€€è´§] --> A4[RQC é€€è´§æ£€éªŒÂ·è‰ç¨¿0→完成4]
  T5[Excel æ‰¹é‡å¯¼å…¥Â·IQC IPQC OQC ä¸‰åˆ—表各一个入口·按钮在新增与导出之间] --> IMP{导入闸门·逐行解析·失败行互不影响}
  IMP -->|编号留空自动生成·编号重复或同一批数据重复则拒该行| A1
  IMP --> A2
  IMP --> A3
  IMP -.->|同一批数据不可重复导入·IQC ç‰©æ–™+供应商+供应商批次+来料日期 / IPQC ç‰©æ–™+批号+检验类型+检测日期 / OQC ç‰©æ–™+客户+批次号+出货日期·文件内重复行与库内已有单都拒并回显双方实际值| IMPDUP[导入去重·按业务键]
  IMP -.->|物料按编码 ä¾›åº”商与客户按名称 ç²¾ç¡®åŒ¹é…Â·æ–¹æ¡ˆç•™ç©ºæŒ‰ç‰©æ–™+检验类型自动匹配| IMPR[引用解析·QC_NO_TEMPLATE è¡¥ç‰©æ–™ä¸Žå¯æ‰§è¡ŒåŠ¨ä½œ]
  IMP -.->|合格+不合格 å¿…须等于 æ£€æµ‹æˆ–接收数量·不等等则拒该行| IMPQ[数量关系校验·两侧实际值都回显]
  IMP -.->|IPQC å·¥åºŸ+料废+其他废品 ä¸å¾—超过不合格品数量·三列留空按0| IMPQ
  IMP -.->|OQC å¡«å®¢æˆ·æ—¶æ ¡éªŒåœ¨æœ¬äººæ•°æ®æƒé™å†…·留空则不属于任何客户·不受遮挡| IMPP[数据权限前置校验]
  IMP -.->|检测人员留空=当前登录用户·IQC æ£€æµ‹ä¸Žæ¥æ–™æ—¥æœŸã€OQC æ£€æµ‹æ—¥æœŸå¿…填·IPQC æ£€æµ‹æ—¥æœŸä¸Ž OQC å‡ºè´§æ—¥æœŸå¯ç•™ç©º| IMPD[人员与日期]
  IMP --> IMR[响应 createList æˆåŠŸç¼–å· + failureList è¡Œå·â†’原因·整行空自动跳过]
  IMR -.->|导入单一律待检验0·不走来源单据回写·不生成NCR| IMRNOTE[与触发式建单的区别·finish æ‰åˆ¤å®šä¸Žå›žå†™]
  A1 -->|台账 UNION ALL| LG[检测结果台账·跨 IQC IPQC OQC RQC ç»Ÿä¸€æŸ¥è¯¢ä¸Žå¯¼å‡º]
  A2 -->|台账 UNION ALL| LG
  A3 -->|台账 UNION ALL| LG
  A4 -->|台账 UNION ALL| LG
  LG -->|一行一个检测项·未录结果也保留一行不漏单| LGF[批次号按类型取来源·IQC ä¾›åº”商批次号 / IPQC OQC RQC æ‰¹å·]
  LG -->|判定依据由标准值与上下限拼装| LGB[同一套筛选条件导出 Excel å°è´¦Â·åªè¯»é¡µä¸æä¾›å¢žåˆ æ”¹]
  LG -->|权限码| LGPERM["mes:qc-inspection-ledger:query æŸ¥è¯¢Â·:export å¯¼å‡ºÂ·èœå•挂质量管理目录"]
  A1 --> R{检验判定 MesQcCheckResultEnum}
  A2 --> R
  A3 --> R
@@ -808,7 +829,7 @@
  RCTX --> RCITEM[检验项=样品×指标交叉展开·全指标进不过滤·多样品时样品号折进 remark]
  RCTX --> RCQC[携带单据原判定 qcResult æ•°Â·qcResultText ä¸­æ–‡Â·ä¸Žå¼•擎判定并存互不覆盖]
  MESMAP -->|分组项 item_type=2 ä¸Žå®ƒçš„子项按 parent_id è¿˜åŽŸä¸¤å±‚Â·å½’å±žä¸çœ‹ type ä¹Ÿä¸çœ‹è¡Œåº| RCTXG
  RCTXG["分组还原·组头行背组名与 formula_text å…¬å¼Â·ç»„内第一行背 span=该组总行数·同组其余行 span=1 ä¸” hidden è®©ä½Â·åªåˆå¹¶æ£€éªŒé¡¹ç›®ä¸€åˆ—"] --- RCTX
  RCTXG["分组还原·组头行背组名与 formula_text å…¬å¼Â·ç»„内第一行背 span=该组总行数·同组其余行 span=1 ä¸” hidden è®©ä½Â·åˆå¹¶çš„æ˜¯æ¨¡æ¿ columns é‡Œå¸¦ # çš„那一列(默认列即检验项目)·合并行数是本单该组实际行数而非主数据子项数·该组本单无子项行则不合并"] --- RCTX
  MESMAP -.->|分组项本单没有属于它的子项 / ç»„头本单没有行由后端补一行| RCWARN
  MESMAP -.->|指标已删 / æœªå½•实测值(组头锚点行按定义无值·不报) / æ ·å“è¶… 3 ä¸ªÂ·è¿› warnings| RCWARN
  RCWARN[warnings è½¯æç¤ºÂ·ä¸è½åº“·事后看不到需自行留痕] --- RCTX
docs/project-business/mermaid/05-quality-flow.mmd
@@ -3,6 +3,25 @@
  T2[报工质检工序通过后产出行] --> A2[IPQC è¿‡ç¨‹æ£€éªŒÂ·è‰ç¨¿0→完成4]
  T3[销售出库单提交] --> A3[OQC å‡ºè´§æ£€éªŒÂ·è‰ç¨¿0→完成4]
  T4[生产退料单 æˆ– é”€å”®é€€è´§å• æˆ– å”®åŽé€€è´§] --> A4[RQC é€€è´§æ£€éªŒÂ·è‰ç¨¿0→完成4]
  T5[Excel æ‰¹é‡å¯¼å…¥Â·IQC IPQC OQC ä¸‰åˆ—表各一个入口·按钮在新增与导出之间] --> IMP{导入闸门·逐行解析·失败行互不影响}
  IMP -->|编号留空自动生成·编号重复或同一批数据重复则拒该行| A1
  IMP --> A2
  IMP --> A3
  IMP -.->|同一批数据不可重复导入·IQC ç‰©æ–™+供应商+供应商批次+来料日期 / IPQC ç‰©æ–™+批号+检验类型+检测日期 / OQC ç‰©æ–™+客户+批次号+出货日期·文件内重复行与库内已有单都拒并回显双方实际值| IMPDUP[导入去重·按业务键]
  IMP -.->|物料按编码 ä¾›åº”商与客户按名称 ç²¾ç¡®åŒ¹é…Â·æ–¹æ¡ˆç•™ç©ºæŒ‰ç‰©æ–™+检验类型自动匹配| IMPR[引用解析·QC_NO_TEMPLATE è¡¥ç‰©æ–™ä¸Žå¯æ‰§è¡ŒåŠ¨ä½œ]
  IMP -.->|合格+不合格 å¿…须等于 æ£€æµ‹æˆ–接收数量·不等等则拒该行| IMPQ[数量关系校验·两侧实际值都回显]
  IMP -.->|IPQC å·¥åºŸ+料废+其他废品 ä¸å¾—超过不合格品数量·三列留空按0| IMPQ
  IMP -.->|OQC å¡«å®¢æˆ·æ—¶æ ¡éªŒåœ¨æœ¬äººæ•°æ®æƒé™å†…·留空则不属于任何客户·不受遮挡| IMPP[数据权限前置校验]
  IMP -.->|检测人员留空=当前登录用户·IQC æ£€æµ‹ä¸Žæ¥æ–™æ—¥æœŸã€OQC æ£€æµ‹æ—¥æœŸå¿…填·IPQC æ£€æµ‹æ—¥æœŸä¸Ž OQC å‡ºè´§æ—¥æœŸå¯ç•™ç©º| IMPD[人员与日期]
  IMP --> IMR[响应 createList æˆåŠŸç¼–å· + failureList è¡Œå·â†’原因·整行空自动跳过]
  IMR -.->|导入单一律待检验0·不走来源单据回写·不生成NCR| IMRNOTE[与触发式建单的区别·finish æ‰åˆ¤å®šä¸Žå›žå†™]
  A1 -->|台账 UNION ALL| LG[检测结果台账·跨 IQC IPQC OQC RQC ç»Ÿä¸€æŸ¥è¯¢ä¸Žå¯¼å‡º]
  A2 -->|台账 UNION ALL| LG
  A3 -->|台账 UNION ALL| LG
  A4 -->|台账 UNION ALL| LG
  LG -->|一行一个检测项·未录结果也保留一行不漏单| LGF[批次号按类型取来源·IQC ä¾›åº”商批次号 / IPQC OQC RQC æ‰¹å·]
  LG -->|判定依据由标准值与上下限拼装| LGB[同一套筛选条件导出 Excel å°è´¦Â·åªè¯»é¡µä¸æä¾›å¢žåˆ æ”¹]
  LG -->|权限码| LGPERM["mes:qc-inspection-ledger:query æŸ¥è¯¢Â·:export å¯¼å‡ºÂ·èœå•挂质量管理目录"]
  A1 --> R{检验判定 MesQcCheckResultEnum}
  A2 --> R
  A3 --> R
docs/qc_inspection_ledger_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,162 @@
# è´¨é‡ç®¡ç† - æ£€æµ‹ç»“果台账(分类 / æ—¶é—´åŒºé—´ç­›é€‰ + å¯¼å‡ºï¼‰ - å‰ç«¯è”调方案
> æœ¬æ¬¡è§£å†³ä¸¤ä»¶äº‹ï¼š
> 1. **直接展示**:四类检验单(IQC/IPQC/OQC/RQC)列表接口**已经返回**检验项实测结果汇总字段 `indicatorResultSummary`,但前端列表未展示 â†’ å‰ç«¯åŠ ä¸€åˆ—å³å¯ï¼Œ**后端无需改动**。
> 2. **筛选展示 + å¯¼å‡ºå°è´¦**:新增跨四类的「检测结果台账」查询与导出接口(分类 + æ£€æµ‹æ—¥æœŸåŒºé—´ + ç‰©æ–™/批次/检测结果/检测人筛选),一行一个检测项结果,用于查询、打印、存档。
## æ¶‰åŠé¡µé¢
- **新增**:检测结果台账页 `views/mes/qc/ledger/index.vue`(菜单由 `docs/sql/qc_inspection_ledger_menu.sql` è„šæœ¬æŒ‰æƒé™ç è‡ªé€‚åº”åˆ›å»ºï¼šè´¨é‡ç®¡ç† â†’ æ£€æµ‹ç»“果台账,path `inspection-ledger`,component `mes/qc/ledger/index`)
- **变更(用于「直接展示」)**:来料检验 / è¿‡ç¨‹æ£€éªŒ / å‡ºè´§æ£€éªŒ / é€€è´§æ£€éªŒ å››ä¸ªåˆ—表页新增「检测结果(检验项)」列
## éœ€æ±‚点与落点对照
| éœ€æ±‚ | è½ç‚¹ |
|------|------|
| æ£€æµ‹ç»“果在界面上直接展示 | å››ç±»æ£€éªŒå•列表 API å·²è¿”回 `indicatorResultSummary`,前端列表加列(无需后端改动) |
| æ”¯æŒç­›é€‰å±•示 | æ–°å¢žå°è´¦é¡µï¼šåˆ†ç±» + æ£€æµ‹æ—¥æœŸåŒºé—´ + æ£€éªŒå•号 + ç‰©æ–™ + æ‰¹æ¬¡å· + æ£€æµ‹ç»“æžœ + æ£€æµ‹äºº |
| æŒ‰åˆ†ç±»ç­›é€‰ | å‚æ•° `qcType`(1 æ¥æ–™æ£€éªŒ / 2 è¿‡ç¨‹æ£€éªŒ / 3 å‡ºè´§æ£€éªŒ / 4 é€€è´§æ£€éªŒï¼‰ï¼Œä¸ä¼ ä¸ºå…¨éƒ¨ |
| æŒ‰æ—¶é—´åŒºé—´ç­›é€‰ | å‚æ•° `inspectDate`(按「检测日期」过滤) |
| å¯¼å‡ºä¸ºå°è´¦ | `GET /mes/qc/inspection-ledger/export-excel`,与列表同一套筛选参数 |
## ä¸šåŠ¡æµç¨‹ä¸Žæ•°æ®å¸¦å…¥
1. ç”¨æˆ·åœ¨å°è´¦é¡µè®¾ç½®ç­›é€‰æ¡ä»¶ï¼ˆåˆ†ç±»ã€æ£€æµ‹æ—¥æœŸåŒºé—´ç­‰ï¼‰â†’ åˆ—表分页展示,一行一个检测项结果。
2. åŒä¸€å¥—筛选参数直接传给导出接口 â†’ ç”Ÿæˆ Excel å°è´¦ï¼ˆæ–‡ä»¶åã€Œæ£€æµ‹ç»“果台账.xls」),用于打印与存档。
3. æ£€éªŒå•未录入检验结果时,该单仍会出现在台账中(检测项相关列为空),不会漏单。
4. å°è´¦çš„「批次号」列按检验类型取不同来源:来料检验取**供应商批次号**;过程检验、出货检验、退货检验取各自的**批次号**。
## API
### 1. å°è´¦åˆ†é¡µ
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | `/admin-api/mes/qc/inspection-ledger/page` | æ£€æµ‹ç»“果台账分页(四类检验单统一查询) |
**请求参数:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| qcType | Integer | å¦ | åˆ†ç±»ï¼ˆæ£€éªŒç±»åž‹ï¼‰ï¼š1 æ¥æ–™æ£€éªŒ / 2 è¿‡ç¨‹æ£€éªŒ / 3 å‡ºè´§æ£€éªŒ / 4 é€€è´§æ£€éªŒï¼›ä¸ä¼ ä¸ºå…¨éƒ¨ |
| inspectDate[0] | String | å¦ | æ£€æµ‹æ—¥æœŸå¼€å§‹ï¼Œæ ¼å¼ yyyy-MM-dd HH:mm:ss |
| inspectDate[1] | String | å¦ | æ£€æµ‹æ—¥æœŸç»“束,格式 yyyy-MM-dd HH:mm:ss |
| code | String | å¦ | æ£€éªŒå•编号,模糊匹配 |
| itemId | Long | å¦ | ç‰©æ–™ç¼–号 |
| batchNo | String | å¦ | æ‰¹æ¬¡å·ï¼Œæ¨¡ç³ŠåŒ¹é…ï¼ˆæ¥æ–™æ£€éªŒå–供应商批次号;过程/出货/退货检验取批次号) |
| checkResult | Integer | å¦ | æ£€æµ‹ç»“果,字典 `mes_qc_check_result` |
| inspectorUserId | Long | å¦ | æ£€æµ‹äººå‘˜ç¼–号 |
| pageNo / pageSize | Integer | å¦ | åˆ†é¡µå‚æ•° |
> `inspectDate` ä¸ŽçŽ°æœ‰å››ç±»æ£€éªŒå•åˆ—è¡¨çš„ä¼ å‚æ–¹å¼ä¸€è‡´ï¼ˆåŒä¸€å¥— RangePicker å‚数对象可直接复用)。
**响应字段:**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| qcType | Integer | åˆ†ç±»ï¼ˆæ£€éªŒç±»åž‹ï¼‰ï¼Œå­—å…¸ `mes_qc_type` |
| qcId | Long | æ£€éªŒå• ID(可跳转到对应检验单详情) |
| code | String | æ£€éªŒå•编号 |
| name | String | æ£€éªŒå•名称 |
| status | Integer | å•据状态,字典 `mes_order_status` |
| itemId / itemCode / itemName / itemSpecification / unitName | - | ç‰©æ–™ä¿¡æ¯ï¼ˆç¼–码、名称、规格型号、计量单位;均取自 MDM ç‰©æ–™ï¼‰ |
| batchNo | String | æ‰¹æ¬¡å· |
| checkQuantity / qualifiedQuantity / unqualifiedQuantity | BigDecimal | æ£€æµ‹æ•°é‡ / åˆæ ¼æ•°é‡ / ä¸åˆæ ¼æ•°é‡ |
| checkResult | Integer | æ£€æµ‹ç»“果,字典 `mes_qc_check_result` |
| inspectDate | String (LocalDateTime) | æ£€æµ‹æ—¥æœŸ |
| inspectorUserId / inspectorName | - | æ£€æµ‹äººå‘˜ |
| indicatorId / indicatorName | - | æ£€æµ‹é¡¹ç¼–号 / åç§° |
| indicatorValue | String | æ£€æµ‹å€¼ï¼ˆå®žæµ‹å€¼ï¼ŒåŽŸæ ·å­—ç¬¦ä¸²ï¼‰ |
| indicatorRemark | String | æ£€æµ‹é¡¹å¤‡æ³¨ |
| judgementBasis | String | åˆ¤å®šä¾æ®ï¼Œå¦‚ `10~20`、`≤20`、`≥10`、`标准值 15`;无法拼装时为空 |
| standardValue / minThreshold / maxThreshold | BigDecimal | åˆ¤å®šä¾æ®åŽŸå§‹å€¼ï¼ˆå‰ç«¯å±•ç¤ºç”¨ `judgementBasis` å³å¯ï¼‰ |
| remark | String | æ£€éªŒå•备注 |
**响应示例:**
```json
{
  "code": 0,
  "data": {
    "list": [
      {
        "qcType": 1,
        "qcId": 123,
        "code": "IQC1789019053513",
        "name": "AN1789019053466-物料检验",
        "status": 0,
        "itemId": 28,
        "itemCode": "WL0001",
        "itemName": "物料A",
        "itemSpecification": "100mm*50mm",
        "unitName": "个",
        "batchNo": "VB20260101",
        "checkQuantity": 99,
        "qualifiedQuantity": 99,
        "unqualifiedQuantity": 0,
        "checkResult": 1,
        "inspectDate": "2026-09-10 00:00:00",
        "inspectorUserId": 111,
        "inspectorName": "张三",
        "indicatorId": 2,
        "indicatorName": "温度",
        "indicatorValue": "15",
        "indicatorRemark": null,
        "judgementBasis": "10~20",
        "remark": null
      }
    ],
    "total": 5
  }
}
```
### 2. å°è´¦å¯¼å‡º
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | `/admin-api/mes/qc/inspection-ledger/export-excel` | å¯¼å‡ºæ£€æµ‹ç»“果台账 Excel,参数与分页接口完全一致 |
**导出列:** åˆ†ç±» / æ£€éªŒå•编号 / æ£€éªŒå•名称 / ç‰©æ–™ç¼–码 / ç‰©æ–™åç§° / è§„格型号 / æ‰¹æ¬¡å· / æ£€æµ‹é¡¹ / æ£€æµ‹å€¼ / åˆ¤å®šä¾æ® / æ£€æµ‹ç»“æžœ / æ£€æµ‹æ•°é‡ / åˆæ ¼æ•°é‡ / ä¸åˆæ ¼æ•°é‡ / æ£€æµ‹äººå‘˜ / æ£€æµ‹æ—¥æœŸ / å•据状态 / å¤‡æ³¨ã€‚
## æƒé™ä¸Žèœå•
| èœå• | æƒé™ç  | è¯´æ˜Ž |
|------|--------|------|
| è´¨é‡ç®¡ç† â†’ æ£€æµ‹ç»“果台账 | â€” | type=2,component `mes/qc/ledger/index` |
| æ£€æµ‹ç»“果台账查询 | `mes:qc-inspection-ledger:query` | é¡µé¢åŠ è½½ã€åˆ†é¡µæŸ¥è¯¢ |
| æ£€æµ‹ç»“果台账导出 | `mes:qc-inspection-ledger:export` | å¯¼å‡ºæŒ‰é’® |
菜单与按钮由 `docs/sql/qc_inspection_ledger_menu.sql` åˆ›å»ºï¼ˆ**菜单 ID è‡ªé€‚应**,不写死 ID;父菜单跟随「来料检验查询」按钮所在的检验单菜单,角色授权跟随「来料检验查询」按钮已有的角色)。执行脚本后需重新登录以刷新菜单与权限缓存。前端建好 `views/mes/qc/ledger/index.vue` åŽèœå•即可用。
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| åˆ†ç±» | å°è´¦åˆ—表列、筛选项 | ç”¨ `CellDict` + `DICT_TYPE.MES_QC_TYPE` æ¸²æŸ“ |
| æ£€æµ‹ç»“æžœ | å°è´¦åˆ—表列、筛选项 | ç”¨ `CellDict` + `DICT_TYPE.MES_QC_CHECK_RESULT` |
| æ£€æµ‹é¡¹ / æ£€æµ‹å€¼ / åˆ¤å®šä¾æ® | å°è´¦åˆ—表列 | ä¸€è¡Œä¸€ä¸ªæ£€æµ‹é¡¹ï¼›æ£€éªŒå•未录入结果时为空 |
| å•据状态 | å°è´¦åˆ—表列 | `DICT_TYPE.MES_ORDER_STATUS` |
| æ‰¹æ¬¡å· | å°è´¦åˆ—表列 | æ¥æ–™æ£€éªŒä¸ºä¾›åº”商批次号;过程/出货/退货检验为批次号 |
| æ£€æµ‹ç»“果(检验项) | å››ç±»æ£€éªŒå•列表列 | ç›´æŽ¥æ¸²æŸ“ `indicatorResultSummary`(如 `温度:15,电阻:1`),列宽建议 200+,内容过长可换行或省略号悬浮 |
| æ£€æµ‹æ—¥æœŸåŒºé—´ | å°è´¦é¡µç­›é€‰åŒº | RangePicker,valueFormat `YYYY-MM-DD HH:mm:ss` |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|------|------|
| åˆ†ç±»ä¸é€‰ | å››ç±»æ£€éªŒå•一起查询,按检测日期倒序、同日期按检验单 ID å€’序 |
| æ—¶é—´åŒºé—´ | æŒ‰ã€Œæ£€æµ‹æ—¥æœŸã€é—­åŒºé—´è¿‡æ»¤ï¼›åªä¼ å¼€å§‹æˆ–只传结束均可 |
| ä¸€è¡Œä¸€æ£€æµ‹é¡¹ | ä¸€å¼ æ£€éªŒå•录入多个检测项结果时展开为多行(其余字段重复) |
| æ£€éªŒå•未录入检测项 | è¯¥å•保留一行,检测项 / æ£€æµ‹å€¼ / åˆ¤å®šä¾æ®ä¸ºç©º |
| åˆ¤å®šä¾æ® | ç”±æ£€éªŒå•行的标准值与误差上下限拼装:上下限都有 â†’ `下限~上限`;仅上限 â†’ `≤上限`;仅下限 â†’ `≥下限`;仅标准值 â†’ `标准值 å€¼`;都没有则为空 |
| é€»è¾‘删除 | å·²åˆ é™¤çš„æ£€éªŒå•不出现在台账;已删除的检测项仍展示其历史名称,保证历史台账可读 |
| å¯¼å‡ºèŒƒå›´ | å¯¼å‡ºéµå¾ªå½“前筛选条件;不选时间则导出全部符合其它条件的记录 |
## æ³¨æ„äº‹é¡¹
- æœ¬æ¬¡åŽç«¯æ–°å¢žäº†é¡µé¢æŽ¥å£ä¸Žèœå•,**需重启后端服务**后新接口才可用;并执行 `docs/sql/qc_inspection_ledger_menu.sql` åŽé‡æ–°ç™»å½•以刷新菜单与权限缓存。
- å°è´¦æŽ¥å£çš„ `qcId` + `qcType` å¯å®šä½åˆ°åŽŸå§‹æ£€éªŒå•ï¼Œè‹¥éœ€è¦ç‚¹å‡»è·³è½¬ï¼Œè¯·æŒ‰åˆ†ç±»è·³åˆ°å¯¹åº”é¡µé¢ï¼ˆæ¥æ–™æ£€éªŒ / è¿‡ç¨‹æ£€éªŒ / å‡ºè´§æ£€éªŒ / é€€è´§æ£€éªŒï¼‰ã€‚
- å°è´¦çš„「检测结果」列与四类检验单列表的「检测结果」列口径一致,均取检验单头的检测结论(字典 `mes_qc_check_result`)。
- å››ç±»æ£€éªŒå•列表新增的「检测结果(检验项)」列直接使用已有字段 `indicatorResultSummary`,不需要新接口;导出时该内容已包含在「检验指标」列中。
- å°è´¦ä¸ºåªè¯»é¡µé¢ï¼Œä¸æä¾›æ–°å¢ž / ä¿®æ”¹ / åˆ é™¤æ“ä½œã€‚
docs/sql/crm_invoice_import_menu.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,70 @@
-- ============================================================
-- CRM å¼€ç¥¨å¯¼å…¥ èœå•与按钮权限
--
-- èƒŒæ™¯ï¼šå¼€ç¥¨ç®¡ç†æ–°å¢ž Excel æ‰¹é‡å¯¼å…¥èƒ½åŠ›
--       ï¼ˆåŽç«¯æŽ¥å£ GET /crm/invoice/get-import-template ä¸Ž POST /crm/invoice/import),
--       éœ€è¦åœ¨å¼€ç¥¨åˆ—表页暴露「导入」按钮权限,否则前端按钮会被 @PreAuthorize æ‹¦æˆªã€‚
--
-- æ–°å¢žæŒ‰é’®æƒé™ï¼šcrm:invoice:import(开票列表「导入」按钮)
--
-- ID è‡ªé€‚应:本脚本不写死菜单 ID
--   â‘  èœå• ID ç”± system_menu è‡ªå¢žåˆ†é…ï¼ˆä¸æŒ‡å®š id åˆ—);
--   â‘¡ çˆ¶èœå•按权限码反查开票管理菜单(crm:invoice:export -> å…¶ parent_id),
--      æŸ¥ä¸åˆ°æ—¶ä¾æ¬¡å›žé€€ crm:invoice:query、名称「开票管理」的 type=2 èœå•ï¼›
--   â‘¢ è§’色授权跟随开票管理「导出」按钮已有的角色,不写死 role_id。
--
-- å¹‚等:按权限码先删后插,可重复执行。
--
-- æ‰§è¡Œæ–¹å¼ï¼ˆPowerShell,避免中文乱码):
--   mysql -uroot -p1234 --default-character-set=utf8mb4 ruoyi-vue-pro < docs/sql/crm_invoice_import_menu.sql
-- ============================================================
SET NAMES utf8mb4;
-- ------------------------------------------------------------
-- 0. å¹‚等清理
-- ------------------------------------------------------------
DELETE rm FROM `system_role_menu` rm
    JOIN `system_menu` m ON rm.`menu_id` = m.`id`
WHERE m.`permission` = 'crm:invoice:import';
DELETE FROM `system_menu` WHERE `permission` = 'crm:invoice:import';
-- ------------------------------------------------------------
-- 1. çˆ¶èœå•:开票管理菜单(type=2)
-- ------------------------------------------------------------
SET @invoice_menu_id = COALESCE(
    (SELECT `parent_id` FROM `system_menu` WHERE `permission` = 'crm:invoice:export' AND `deleted` = b'0' LIMIT 1),
    (SELECT `parent_id` FROM `system_menu` WHERE `permission` = 'crm:invoice:query'  AND `deleted` = b'0' LIMIT 1),
    (SELECT `id`        FROM `system_menu` WHERE `name` = '开票管理' AND `type` = 2 AND `deleted` = b'0' LIMIT 1));
-- ------------------------------------------------------------
-- 2. æ–°å¢žæŒ‰é’®ï¼ˆèœå• ID è‡ªå¢žï¼‰
-- ------------------------------------------------------------
INSERT INTO `system_menu` (`name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT '开票导入', 'crm:invoice:import', 3, 5, @invoice_menu_id, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'
WHERE @invoice_menu_id IS NOT NULL;
SET @menu_invoice_import = IF(ROW_COUNT() > 0, LAST_INSERT_ID(), NULL);
-- ------------------------------------------------------------
-- 3. æŽˆæƒï¼šè·Ÿéšå¼€ç¥¨ç®¡ç†ã€Œå¯¼å‡ºã€æŒ‰é’®å·²æœ‰çš„角色(兜底「查询」按钮)
-- ------------------------------------------------------------
INSERT INTO `system_role_menu` (`role_id`, `menu_id`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT DISTINCT rm.`role_id`, @menu_invoice_import, '1', NOW(), '1', NOW(), b'0'
FROM `system_role_menu` rm
WHERE rm.`menu_id` = COALESCE(
        (SELECT `id` FROM `system_menu` WHERE `permission` = 'crm:invoice:export' AND `deleted` = b'0' LIMIT 1),
        (SELECT `id` FROM `system_menu` WHERE `permission` = 'crm:invoice:query'  AND `deleted` = b'0' LIMIT 1))
  AND rm.`deleted` = b'0'
  AND @menu_invoice_import IS NOT NULL;
-- ------------------------------------------------------------
-- 4. æ ¡éªŒï¼šèœå•应返回 1 è¡Œï¼›æŽˆæƒè¡Œæ•°ä¸Žã€Œå¼€ç¥¨ç®¡ç†å¯¼å‡ºã€æŒ‰é’®ä¸€è‡´
-- ------------------------------------------------------------
SELECT '菜单 crm:invoice:import  ' AS item, COUNT(*) AS cnt
    FROM `system_menu` WHERE `permission` = 'crm:invoice:import' AND `deleted` = b'0'
UNION ALL SELECT '授权 / å¼€ç¥¨å¯¼å‡ºæŽˆæƒ     ', CONCAT(
    (SELECT COUNT(*) FROM `system_role_menu` rm JOIN `system_menu` m ON rm.`menu_id` = m.`id`
      WHERE m.`permission` = 'crm:invoice:import' AND rm.`deleted` = b'0'), ' / ',
    (SELECT COUNT(*) FROM `system_role_menu` rm JOIN `system_menu` m ON rm.`menu_id` = m.`id`
      WHERE m.`permission` = 'crm:invoice:export' AND rm.`deleted` = b'0'));
docs/sql/mes_qc_import_menu.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,115 @@
-- ============================================================
-- MES è´¨æ£€å•批量导入 èœå•与按钮权限
--
-- èƒŒæ™¯ï¼šæ¥æ–™æ£€éªŒï¼ˆIQC)、过程检验(IPQC)、出货检验(OQC)各自新增「批量导入」能力
--       ï¼ˆåŽç«¯æŽ¥å£ GET /mes/qc/{iqc|ipqc|oqc}/get-import-template ä¸Ž POST .../import),
--       éœ€è¦åœ¨å¯¹åº”列表页暴露按钮权限,否则前端按钮会被 @PreAuthorize æ‹¦æˆªã€‚
--
-- æ–°å¢žæŒ‰é’®æƒé™ï¼š
--   mes:qc-iqc:import   æ¥æ–™æ£€éªŒåˆ—表「批量导入」按钮
--   mes:qc-ipqc:import  è¿‡ç¨‹æ£€éªŒåˆ—表「批量导入」按钮
--   mes:qc-oqc:import   å‡ºè´§æ£€éªŒåˆ—表「批量导入」按钮
--
-- ID è‡ªé€‚应:本脚本不写死菜单 ID
--   â‘  èœå• ID ç”± system_menu è‡ªå¢žåˆ†é…ï¼ˆä¸æŒ‡å®š id åˆ—);
--   â‘¡ çˆ¶èœå•按权限码反查该模块「查询」按钮所在菜单(mes:qc-iqc:query ç­‰ï¼‰ï¼Œ
--      æŸ¥ä¸åˆ°æ—¶ä¾æ¬¡å›žé€€åˆ°ã€Œå¯¼å‡ºã€æŒ‰é’®æ‰€åœ¨èœå•、同名 type=2 èœå•ï¼›
--   â‘¢ è§’色授权跟随同模块「查询」按钮已有的角色,不写死 role_id。
--   å› æ­¤è„šæœ¬å¯åœ¨ä»»ä½•åº“ç»“æž„ä¸€è‡´çš„å®žä¾‹ä¸Šç›´æŽ¥æ‰§è¡Œã€‚
--
-- å¹‚等:按权限码先删后插,可重复执行。
--
-- æ‰§è¡Œæ–¹å¼ï¼ˆPowerShell,避免中文菜单名乱码):
--   mysql -uroot -p1234 --default-character-set=utf8mb4 ruoyi-vue-pro < docs/sql/mes_qc_import_menu.sql
-- ============================================================
SET NAMES utf8mb4;
-- ------------------------------------------------------------
-- 0. å¹‚等清理:删除既有导入按钮及其角色授权
-- ------------------------------------------------------------
DELETE rm FROM `system_role_menu` rm
    JOIN `system_menu` m ON rm.`menu_id` = m.`id`
WHERE m.`permission` IN ('mes:qc-iqc:import', 'mes:qc-ipqc:import', 'mes:qc-oqc:import');
DELETE FROM `system_menu`
WHERE `permission` IN ('mes:qc-iqc:import', 'mes:qc-ipqc:import', 'mes:qc-oqc:import');
-- ------------------------------------------------------------
-- 1. çˆ¶èœå•:各检验单列表页所在菜单(type=2)
-- ------------------------------------------------------------
SET @iqc_parent = COALESCE(
    (SELECT `parent_id` FROM `system_menu` WHERE `permission` = 'mes:qc-iqc:query'  AND `deleted` = b'0' LIMIT 1),
    (SELECT `parent_id` FROM `system_menu` WHERE `permission` = 'mes:qc-iqc:export' AND `deleted` = b'0' LIMIT 1));
SET @ipqc_parent = COALESCE(
    (SELECT `parent_id` FROM `system_menu` WHERE `permission` = 'mes:qc-ipqc:query'  AND `deleted` = b'0' LIMIT 1),
    (SELECT `parent_id` FROM `system_menu` WHERE `permission` = 'mes:qc-ipqc:export' AND `deleted` = b'0' LIMIT 1));
SET @oqc_parent = COALESCE(
    (SELECT `parent_id` FROM `system_menu` WHERE `permission` = 'mes:qc-oqc:query'  AND `deleted` = b'0' LIMIT 1),
    (SELECT `parent_id` FROM `system_menu` WHERE `permission` = 'mes:qc-oqc:export' AND `deleted` = b'0' LIMIT 1));
-- ------------------------------------------------------------
-- 2. æ–°å¢žæŒ‰é’®ï¼ˆèœå• ID è‡ªå¢žï¼‰
-- ------------------------------------------------------------
INSERT INTO `system_menu` (`name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT '来料检验导入', 'mes:qc-iqc:import', 3, 6, @iqc_parent, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'
WHERE @iqc_parent IS NOT NULL;
SET @menu_iqc_import = IF(ROW_COUNT() > 0, LAST_INSERT_ID(), NULL);
INSERT INTO `system_menu` (`name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT '过程检验导入', 'mes:qc-ipqc:import', 3, 6, @ipqc_parent, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'
WHERE @ipqc_parent IS NOT NULL;
SET @menu_ipqc_import = IF(ROW_COUNT() > 0, LAST_INSERT_ID(), NULL);
INSERT INTO `system_menu` (`name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT '出货检验导入', 'mes:qc-oqc:import', 3, 6, @oqc_parent, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'
WHERE @oqc_parent IS NOT NULL;
SET @menu_oqc_import = IF(ROW_COUNT() > 0, LAST_INSERT_ID(), NULL);
-- ------------------------------------------------------------
-- 3. æŽˆæƒï¼šè·ŸéšåŒæ¨¡å—「查询」按钮已有的角色(同时兜底超级管理员角色 1)
-- ------------------------------------------------------------
INSERT INTO `system_role_menu` (`role_id`, `menu_id`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT DISTINCT rm.`role_id`, @menu_iqc_import, '1', NOW(), '1', NOW(), b'0'
FROM `system_role_menu` rm
WHERE rm.`menu_id` = (SELECT `id` FROM `system_menu` WHERE `permission` = 'mes:qc-iqc:query' AND `deleted` = b'0' LIMIT 1)
  AND rm.`deleted` = b'0'
  AND @menu_iqc_import IS NOT NULL;
INSERT INTO `system_role_menu` (`role_id`, `menu_id`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT DISTINCT rm.`role_id`, @menu_ipqc_import, '1', NOW(), '1', NOW(), b'0'
FROM `system_role_menu` rm
WHERE rm.`menu_id` = (SELECT `id` FROM `system_menu` WHERE `permission` = 'mes:qc-ipqc:query' AND `deleted` = b'0' LIMIT 1)
  AND rm.`deleted` = b'0'
  AND @menu_ipqc_import IS NOT NULL;
INSERT INTO `system_role_menu` (`role_id`, `menu_id`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT DISTINCT rm.`role_id`, @menu_oqc_import, '1', NOW(), '1', NOW(), b'0'
FROM `system_role_menu` rm
WHERE rm.`menu_id` = (SELECT `id` FROM `system_menu` WHERE `permission` = 'mes:qc-oqc:query' AND `deleted` = b'0' LIMIT 1)
  AND rm.`deleted` = b'0'
  AND @menu_oqc_import IS NOT NULL;
-- ------------------------------------------------------------
-- 4. æ ¡éªŒï¼šåº”各自返回 1 è¡Œèœå•;角色授权行数与同模块「查询」按钮一致
-- ------------------------------------------------------------
SELECT '菜单 mes:qc-iqc:import ' AS item, COUNT(*) AS cnt FROM `system_menu` WHERE `permission` = 'mes:qc-iqc:import'   AND `deleted` = b'0'
UNION ALL SELECT '菜单 mes:qc-ipqc:import', COUNT(*) FROM `system_menu` WHERE `permission` = 'mes:qc-ipqc:import'  AND `deleted` = b'0'
UNION ALL SELECT '菜单 mes:qc-oqc:import ', COUNT(*) FROM `system_menu` WHERE `permission` = 'mes:qc-oqc:import'   AND `deleted` = b'0'
UNION ALL SELECT 'IQC æŽˆæƒ / æŸ¥è¯¢æŽˆæƒ  ', CONCAT(
    (SELECT COUNT(*) FROM `system_role_menu` rm JOIN `system_menu` m ON rm.`menu_id` = m.`id`
      WHERE m.`permission` = 'mes:qc-iqc:import' AND rm.`deleted` = b'0'), ' / ',
    (SELECT COUNT(*) FROM `system_role_menu` rm JOIN `system_menu` m ON rm.`menu_id` = m.`id`
      WHERE m.`permission` = 'mes:qc-iqc:query' AND rm.`deleted` = b'0'))
UNION ALL SELECT 'IPQC æŽˆæƒ / æŸ¥è¯¢æŽˆæƒ ', CONCAT(
    (SELECT COUNT(*) FROM `system_role_menu` rm JOIN `system_menu` m ON rm.`menu_id` = m.`id`
      WHERE m.`permission` = 'mes:qc-ipqc:import' AND rm.`deleted` = b'0'), ' / ',
    (SELECT COUNT(*) FROM `system_role_menu` rm JOIN `system_menu` m ON rm.`menu_id` = m.`id`
      WHERE m.`permission` = 'mes:qc-ipqc:query' AND rm.`deleted` = b'0'))
UNION ALL SELECT 'OQC æŽˆæƒ / æŸ¥è¯¢æŽˆæƒ  ', CONCAT(
    (SELECT COUNT(*) FROM `system_role_menu` rm JOIN `system_menu` m ON rm.`menu_id` = m.`id`
      WHERE m.`permission` = 'mes:qc-oqc:import' AND rm.`deleted` = b'0'), ' / ',
    (SELECT COUNT(*) FROM `system_role_menu` rm JOIN `system_menu` m ON rm.`menu_id` = m.`id`
      WHERE m.`permission` = 'mes:qc-oqc:query' AND rm.`deleted` = b'0'));
docs/sql/qc_inspection_ledger_menu.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,94 @@
-- ============================================================
-- è´¨é‡ç®¡ç† - æ£€æµ‹ç»“果台账 èœå•与权限
--
-- èƒŒæ™¯ï¼šè´¨æ£€å„模块(IQC/IPQC/OQC/RQC)的检测结果原先分散在各自列表内,
--       ç¼ºå°‘跨类型的统一查询与台账导出。本次新增「检测结果台账」页面:
--       æŒ‰æ£€éªŒç±»åž‹ + æ£€æµ‹æ—¥æœŸåŒºé—´ + ç‰©æ–™/批次/检测结果/检测人筛选,
--       åˆ—表直接展示检测项实测结果,并支持导出 Excel å°è´¦ç”¨äºŽæŸ¥è¯¢ã€æ‰“印、存档。
--
-- æ–°å¢žèœå•与按钮:
--   â‘  èœå•「检测结果台账」   type=2,component=mes/qc/ledger/index
--   â‘¡ æŒ‰é’®ã€Œæ£€æµ‹ç»“果台账查询」= mes:qc-inspection-ledger:query
--   â‘¢ æŒ‰é’®ã€Œæ£€æµ‹ç»“果台账导出」= mes:qc-inspection-ledger:export
--
-- ID è‡ªé€‚应:本脚本不写死菜单 ID
--   â‘  èœå• ID ç”± system_menu è‡ªå¢žåˆ†é…ï¼ˆä¸æŒ‡å®š id åˆ—);
--   â‘¡ çˆ¶èœå• = ã€Œæ¥æ–™æ£€éªŒæŸ¥è¯¢ã€(mes:qc-iqc:query) æ‰€åœ¨æ£€éªŒå•菜单的上级(即质量管理目录),
--      æŸ¥ä¸åˆ°æ—¶å›žé€€ä¸ºåç§°ã€Œè´¨é‡ç®¡ç†ã€ã€type=1 çš„目录;
--   â‘¢ è§’色授权跟随「来料检验查询」按钮已有的角色,不写死 role_id。
--
-- å¹‚等:按权限码 / åç§°å…ˆåˆ åŽæ’,可重复执行。
--
-- æ‰§è¡Œæ–¹å¼ï¼ˆPowerShell,避免中文乱码):
--   mysql -uroot -p1234 --default-character-set=utf8mb4 ruoyi-vue-pro < docs/sql/qc_inspection_ledger_menu.sql
-- ============================================================
SET NAMES utf8mb4;
-- ------------------------------------------------------------
-- 0. å¹‚等清理
-- ------------------------------------------------------------
DELETE rm FROM `system_role_menu` rm
    JOIN `system_menu` m ON rm.`menu_id` = m.`id`
WHERE m.`permission` IN ('mes:qc-inspection-ledger:query', 'mes:qc-inspection-ledger:export')
   OR (m.`name` = '检测结果台账' AND m.`type` = 2);
DELETE FROM `system_menu`
WHERE `permission` IN ('mes:qc-inspection-ledger:query', 'mes:qc-inspection-ledger:export')
   OR (`name` = '检测结果台账' AND `type` = 2);
-- ------------------------------------------------------------
-- 1. çˆ¶èœå•:质量管理目录
--    æ¥æ–™æ£€éªŒæŸ¥è¯¢æŒ‰é’® -> æ¥æ–™æ£€éªŒèœå•(type=2) -> è´¨é‡ç®¡ç†ç›®å½•(type=1)
-- ------------------------------------------------------------
SET @iqc_menu_id = (SELECT `parent_id` FROM `system_menu`
                     WHERE `permission` = 'mes:qc-iqc:query' AND `deleted` = b'0' LIMIT 1);
SET @qc_dir_id = COALESCE(
    (SELECT `parent_id` FROM `system_menu` WHERE `id` = @iqc_menu_id),
    (SELECT `id` FROM `system_menu` WHERE `name` = '质量管理' AND `type` = 1 AND `deleted` = b'0' LIMIT 1));
-- ------------------------------------------------------------
-- 2. èœå•:检测结果台账(type=2)
-- ------------------------------------------------------------
INSERT INTO `system_menu` (`name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT '检测结果台账', '', 2, 14, @qc_dir_id, 'inspection-ledger', 'ep:document', 'mes/qc/ledger/index', 'MesQcInspectionLedger', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'
WHERE @qc_dir_id IS NOT NULL;
SET @menu_ledger = IF(ROW_COUNT() > 0, LAST_INSERT_ID(), NULL);
-- ------------------------------------------------------------
-- 3. æŒ‰é’®ï¼šæŸ¥è¯¢ / å¯¼å‡º
-- ------------------------------------------------------------
INSERT INTO `system_menu` (`name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT '检测结果台账查询', 'mes:qc-inspection-ledger:query', 3, 1, @menu_ledger, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'
WHERE @menu_ledger IS NOT NULL;
SET @menu_ledger_query = IF(ROW_COUNT() > 0, LAST_INSERT_ID(), NULL);
INSERT INTO `system_menu` (`name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT '检测结果台账导出', 'mes:qc-inspection-ledger:export', 3, 2, @menu_ledger, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'
WHERE @menu_ledger IS NOT NULL;
SET @menu_ledger_export = IF(ROW_COUNT() > 0, LAST_INSERT_ID(), NULL);
-- ------------------------------------------------------------
-- 4. æŽˆæƒï¼šè·Ÿéšã€Œæ¥æ–™æ£€éªŒæŸ¥è¯¢ã€æŒ‰é’®å·²æœ‰çš„角色
-- ------------------------------------------------------------
INSERT INTO `system_role_menu` (`role_id`, `menu_id`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT DISTINCT rm.`role_id`, t.`menu_id`, '1', NOW(), '1', NOW(), b'0'
FROM `system_role_menu` rm
    JOIN (SELECT @menu_ledger AS `menu_id`
          UNION ALL SELECT @menu_ledger_query
          UNION ALL SELECT @menu_ledger_export) t
WHERE rm.`menu_id` = @iqc_menu_id
  AND rm.`deleted` = b'0'
  AND t.`menu_id` IS NOT NULL;
-- ------------------------------------------------------------
-- 5. æ ¡éªŒï¼šèœå• 3 è¡Œï¼›æŽˆæƒè¡Œæ•° = 3 Ã— ã€Œæ¥æ–™æ£€éªŒæŸ¥è¯¢ã€çš„角色数
-- ------------------------------------------------------------
SELECT '菜单 æ£€æµ‹ç»“果台账        ' AS item, COUNT(*) AS cnt FROM `system_menu`
    WHERE `name` = '检测结果台账' AND `type` = 2 AND `deleted` = b'0'
UNION ALL SELECT '按钮 æŸ¥è¯¢/导出            ', COUNT(*) FROM `system_menu`
    WHERE `permission` IN ('mes:qc-inspection-ledger:query', 'mes:qc-inspection-ledger:export') AND `deleted` = b'0'
UNION ALL SELECT '授权 3 ä¸ªèœå•             ', COUNT(*) FROM `system_role_menu`
    WHERE `menu_id` IN (@menu_ledger, @menu_ledger_query, @menu_ledger_export) AND `deleted` = b'0'
UNION ALL SELECT '对比 æ¥æ–™æ£€éªŒæŸ¥è¯¢æŽˆæƒè¡Œæ•° ', COUNT(*) FROM `system_role_menu` rm JOIN `system_menu` m ON rm.`menu_id` = m.`id`
    WHERE m.`permission` = 'mes:qc-iqc:query' AND rm.`deleted` = b'0';
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/invoice/CrmInvoiceController.java
@@ -10,6 +10,8 @@
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract.CrmContractRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceImportExcelVO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceImportRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoicePageReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceSaveReqVO;
@@ -36,6 +38,7 @@
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.math.BigDecimal;
@@ -144,6 +147,27 @@
                buildInvoiceDetailList(list));
    }
    @GetMapping("/get-import-template")
    @Operation(summary = "获得导入开票模板")
    @PreAuthorize("@ss.hasPermission('crm:invoice:import')")
    public void importTemplate(HttpServletResponse response) throws IOException {
        List<CrmInvoiceImportExcelVO> list = Collections.singletonList(
                CrmInvoiceImportExcelVO.builder()
                        .contractNo("HT20260101000001").invoiceType("增值税专用发票")
                        .invoiceNo("02564178").invoiceTitle("示例客户有限公司").price(new BigDecimal("10000.00"))
                        .invoiceTime("2026-01-01 10:00:00").remark("示例数据,导入前请替换")
                        .build());
        ExcelUtils.write(response, "开票导入模板.xls", "开票", CrmInvoiceImportExcelVO.class, list);
    }
    @PostMapping("/import")
    @Operation(summary = "导入开票")
    @PreAuthorize("@ss.hasPermission('crm:invoice:import')")
    public CommonResult<CrmInvoiceImportRespVO> importInvoiceExcel(@RequestParam("file") MultipartFile file) throws Exception {
        List<CrmInvoiceImportExcelVO> list = ExcelUtils.read(file, CrmInvoiceImportExcelVO.class);
        return success(invoiceService.importInvoiceList(list));
    }
    private List<CrmInvoiceRespVO> buildInvoiceDetailList(List<CrmInvoiceDO> invoiceList) {
        if (CollUtil.isEmpty(invoiceList)) {
            return Collections.emptyList();
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/invoice/vo/invoice/CrmInvoiceImportExcelVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,59 @@
package cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice;
import cn.idev.excel.annotation.ExcelProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
/**
 * ç®¡ç†åŽå° - å¼€ç¥¨å¯¼å…¥ Excel VO
 *
 * è¯´æ˜Žï¼šä¸€è¡Œ = ä¸€å¼ å¼€ç¥¨è®°å½•。“合同编号”为必填,需与系统中已审核通过的合同编号一致。
 * å®¢æˆ·ã€è´Ÿè´£äººã€å®¡æ‰¹çŠ¶æ€ç”±ç³»ç»Ÿå¤„ç†ï¼šå®¢æˆ·å–åˆåŒæ‰€å±žå®¢æˆ·ï¼›è´Ÿè´£äºº = å½“前登录用户;审批状态 = æœªæäº¤ã€‚
 *
 * @author è¶…级管理员
 */
@Schema(description = "管理后台 - å¼€ç¥¨å¯¼å…¥ Excel VO")
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class CrmInvoiceImportExcelVO {
    @ExcelProperty("开票编号")
    @Schema(description = "开票编号,留空则自动生成", example = "KP20260101000001")
    private String no;
    @ExcelProperty("合同编号")
    @Schema(description = "合同编号,需与系统中已审核通过的合同编号一致", requiredMode = Schema.RequiredMode.REQUIRED, example = "HT20260101000001")
    private String contractNo;
    @ExcelProperty("发票类型")
    @Schema(description = "发票类型,填写字典 crm_invoice_type çš„名称,如 å¢žå€¼ç¨Žä¸“用发票", requiredMode = Schema.RequiredMode.REQUIRED, example = "增值税专用发票")
    private String invoiceType;
    @ExcelProperty("发票号码")
    @Schema(description = "发票号码", example = "02564178")
    private String invoiceNo;
    @ExcelProperty("发票抬头")
    @Schema(description = "发票抬头", example = "XX科技有限公司")
    private String invoiceTitle;
    @ExcelProperty("开票金额(元)")
    @Schema(description = "开票金额,需大于 0 ä¸”不超过合同剩余可开票金额", requiredMode = Schema.RequiredMode.REQUIRED, example = "10000.00")
    private BigDecimal price;
    @ExcelProperty("开票日期")
    @Schema(description = "开票日期,格式如 2026-01-01 10:00:00;留空取当前时间", example = "2026-01-01 10:00:00")
    private String invoiceTime;
    @ExcelProperty("备注")
    @Schema(description = "备注", example = "备注")
    private String remark;
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/invoice/vo/invoice/CrmInvoiceImportRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
package cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
 * ç®¡ç†åŽå° - å¼€ç¥¨å¯¼å…¥ Response VO
 *
 * @author è¶…级管理员
 */
@Schema(description = "管理后台 - å¼€ç¥¨å¯¼å…¥ Response VO")
@Data
@Builder
public class CrmInvoiceImportRespVO {
    @Schema(description = "创建成功的开票编号数组", example = "KP20260101000001")
    private List<String> createList;
    @Schema(description = "导入失败集合,key ä¸ºè¡Œå·æˆ–发票号码,value ä¸ºå¤±è´¥åŽŸå› ")
    private Map<String, String> failureList;
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/invoice/CrmInvoiceMapper.java
@@ -91,4 +91,13 @@
        return selectCount(CrmInvoiceDO::getContractId, contractId);
    }
    /**
     * æŸ¥è¯¢åˆåŒä¸‹çš„全部开票(导入去重用)
     */
    default List<CrmInvoiceDO> selectListByContractId(Long contractId) {
        return selectList(new LambdaQueryWrapperX<CrmInvoiceDO>()
                .eq(CrmInvoiceDO::getContractId, contractId)
                .orderByAsc(CrmInvoiceDO::getId));
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractService.java
@@ -107,6 +107,14 @@
    CrmContractDO getContract(Long id);
    /**
     * èŽ·å¾—åˆåŒï¼ŒåŸºäºŽåˆåŒç¼–å·
     *
     * @param no åˆåŒç¼–号
     * @return åˆåŒ
     */
    CrmContractDO getContractByNo(String no);
    /**
     * æ ¡éªŒåˆåŒæ˜¯å¦åˆæ³•
     *
     * @param id ç¼–号
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractServiceImpl.java
@@ -644,6 +644,11 @@
    }
    @Override
    public CrmContractDO getContractByNo(String no) {
        return contractMapper.selectByNo(no);
    }
    @Override
    public CrmContractDO validateContract(Long id) {
        return validateContractExists(id);
    }
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/invoice/CrmInvoiceService.java
@@ -1,6 +1,8 @@
package cn.iocoder.yudao.module.crm.service.invoice;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceImportExcelVO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceImportRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoicePageReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceSaveReqVO;
import cn.iocoder.yudao.module.crm.dal.dataobject.invoice.CrmInvoiceDO;
@@ -29,6 +31,14 @@
    Long createInvoice(@Valid CrmInvoiceSaveReqVO createReqVO);
    /**
     * å¯¼å…¥å¼€ç¥¨
     *
     * @param importList å¯¼å…¥çš„开票列表
     * @return å¯¼å…¥ç»“æžœ
     */
    CrmInvoiceImportRespVO importInvoiceList(List<CrmInvoiceImportExcelVO> importList);
    /**
     * æ›´æ–°å¼€ç¥¨
     *
     * @param updateReqVO æ›´æ–°ä¿¡æ¯
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/invoice/CrmInvoiceServiceImpl.java
@@ -5,12 +5,16 @@
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
import cn.iocoder.yudao.framework.dict.core.DictFrameworkUtils;
import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi;
import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceImportExcelVO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceImportRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoicePageReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceSaveReqVO;
import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO;
@@ -36,11 +40,18 @@
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
import static cn.iocoder.yudao.module.crm.enums.DictTypeConstants.CRM_INVOICE_TYPE;
import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.*;
import static cn.iocoder.yudao.module.crm.enums.LogRecordConstants.*;
import static cn.iocoder.yudao.module.crm.util.CrmAuditStatusUtils.convertBpmResultToAuditStatus;
@@ -118,22 +129,247 @@
        return invoice.getId();
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public CrmInvoiceImportRespVO importInvoiceList(List<CrmInvoiceImportExcelVO> importList) {
        CrmInvoiceImportRespVO respVO = CrmInvoiceImportRespVO.builder()
                .createList(new ArrayList<>())
                .failureList(new LinkedHashMap<>())
                .build();
        if (CollUtil.isEmpty(importList)) {
            return respVO;
        }
        Map<String, Integer> firstRowNoByKey = new HashMap<>();
        for (int i = 0; i < importList.size(); i++) {
            CrmInvoiceImportExcelVO row = importList.get(i);
            if (isImportRowEmpty(row)) {
                continue; // è·³è¿‡ç©ºè¡Œ
            }
            String label = StrUtil.isNotBlank(row.getInvoiceNo()) ? "发票号码 " + row.getInvoiceNo().trim()
                    : "第 " + (i + 2) + " è¡Œ";
            String importKey = buildInvoiceImportKey(row);
            try {
                Integer firstRowNo = firstRowNoByKey.get(importKey);
                if (firstRowNo != null) {
                    throw duplicateRowException(firstRowNo, buildInvoiceKeyDesc(row));
                }
                respVO.getCreateList().add(createInvoiceByImport(row));
                firstRowNoByKey.putIfAbsent(importKey, i + 2);
            } catch (Exception ex) {
                respVO.getFailureList().put(label, resolveImportErrorMessage(ex));
            }
        }
        return respVO;
    }
    private boolean isImportRowEmpty(CrmInvoiceImportExcelVO row) {
        return row == null || (StrUtil.isBlank(row.getNo()) && StrUtil.isBlank(row.getContractNo())
                && StrUtil.isBlank(row.getInvoiceType()) && StrUtil.isBlank(row.getInvoiceNo())
                && StrUtil.isBlank(row.getInvoiceTitle()) && row.getPrice() == null
                && StrUtil.isBlank(row.getInvoiceTime()) && StrUtil.isBlank(row.getRemark()));
    }
    /**
     * ä¾æ®å¯¼å…¥æ•°æ®åˆ›å»ºä¸€å¼ å¼€ç¥¨ã€‚客户取合同所属客户;负责人=当前登录用户;审批状态=未提交。
     */
    private String createInvoiceByImport(CrmInvoiceImportExcelVO row) {
        // 1. åˆåŒæ ¡éªŒ
        if (StrUtil.isBlank(row.getContractNo())) {
            throw new IllegalArgumentException("合同编号不能为空:请填写模板中该行的「合同编号」列");
        }
        CrmContractDO contract = contractService.getContractByNo(row.getContractNo().trim());
        if (contract == null) {
            throw new IllegalArgumentException("合同编号不存在:" + row.getContractNo().trim()
                    + "。请先在「CRM åˆåŒç®¡ç†ã€ä¸­åˆ›å»ºæˆ–导入该合同(编号需与模板完全一致),或修改模板中的合同编号后重新导入");
        }
        if (ObjUtil.notEqual(contract.getAuditStatus(), CrmAuditStatusEnum.APPROVE.getStatus())) {
            throw new IllegalArgumentException("合同[" + contract.getNo() + "]当前状态为「"
                    + CrmAuditStatusEnum.getNameByStatus(contract.getAuditStatus()) + "」,只有「审核通过」的合同才能开票。"
                    + "请先在「CRM åˆåŒç®¡ç†ã€ä¸­å®Œæˆè¯¥åˆåŒå®¡æ‰¹ï¼Œæˆ–改用其他已审核通过的合同编号后重新导入");
        }
        // 2. å¼€ç¥¨é‡‘额校验
        if (row.getPrice() == null) {
            throw new IllegalArgumentException("开票金额不能为空:请填写模板中该行的「开票金额(元)」列");
        }
        if (row.getPrice().compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("开票金额必须大于 0,当前为 "
                    + row.getPrice().stripTrailingZeros().toPlainString() + "。请修改模板中的「开票金额(元)」后重新导入");
        }
        BigDecimal remainingInvoicePrice = getRemainingInvoicePrice(contract, null);
        if (row.getPrice().compareTo(remainingInvoicePrice) > 0) {
            throw new IllegalArgumentException("开票金额超过合同剩余可开票金额:合同[" + contract.getNo()
                    + "]剩余可开票金额 " + remainingInvoicePrice.stripTrailingZeros().toPlainString() + " å…ƒï¼Œæœ¬æ¬¡å¯¼å…¥é‡‘额 "
                    + row.getPrice().stripTrailingZeros().toPlainString()
                    + " å…ƒã€‚请调整模板中的「开票金额(元)」,或先在系统中核对已开票金额后重新导入");
        }
        // 3. å‘票类型校验
        Integer invoiceType = resolveInvoiceType(row.getInvoiceType());
        // 4. å¼€ç¥¨ç¼–号:填写则使用,留空则自动生成
        String no = StrUtil.isNotBlank(row.getNo()) ? row.getNo().trim()
                : noRedisDAO.generate(CrmNoRedisDAO.INVOICE_PREFIX);
        if (invoiceMapper.selectByNo(no) != null) {
            throw new IllegalArgumentException("开票编号已存在:" + no
                    + "。请修改模板中的开票编号,或先在系统中删除该编号的开票后重新导入");
        }
        // 5. åŽ»é‡ï¼šåŒä¸€åˆåŒä¸‹ï¼Œå‘ç¥¨å·ç ï¼ˆç•™ç©ºæ—¶æŒ‰å¼€ç¥¨é‡‘é¢ã€å‘ç¥¨æŠ¬å¤´ã€å¼€ç¥¨æ—¥æœŸï¼‰ç›¸åŒçš„å¼€ç¥¨ä¸å¯é‡å¤å¯¼å…¥
        // å¼€ç¥¨æ—¥æœŸç•™ç©ºæ—¶è§†ä½œä¸æ¯”较该维度,故按“是否填写”传 null,不能用 parseImportDateTime çš„ now() å…œåº•值
        LocalDateTime invoiceTime = parseImportDateTime(row.getInvoiceTime());
        CrmInvoiceDO duplicate = findDuplicateInvoice(contract.getId(), row,
                StrUtil.isNotBlank(row.getInvoiceTime()) ? invoiceTime : null);
        if (duplicate != null) {
            throw duplicateExistingException(buildInvoiceKeyDesc(row), duplicate.getNo());
        }
        // 6. ä¿å­˜å¼€ç¥¨
        CrmInvoiceDO invoice = new CrmInvoiceDO().setNo(no).setCustomerId(contract.getCustomerId())
                .setContractId(contract.getId()).setOwnerUserId(getLoginUserId()).setInvoiceType(invoiceType)
                .setInvoiceNo(row.getInvoiceNo()).setInvoiceTitle(row.getInvoiceTitle()).setPrice(row.getPrice())
                .setInvoiceTime(invoiceTime).setRemark(row.getRemark())
                .setAuditStatus(CrmAuditStatusEnum.DRAFT.getStatus());
        invoiceMapper.insert(invoice);
        // 7. åˆ›å»ºæ•°æ®æƒé™ï¼ˆå¦åˆ™å½“前用户看不到该开票)
        permissionService.createPermission(new CrmPermissionCreateReqBO().setBizType(CrmBizTypeEnum.CRM_INVOICE.getType())
                .setBizId(invoice.getId()).setUserId(invoice.getOwnerUserId())
                .setLevel(CrmPermissionLevelEnum.OWNER.getLevel()));
        return invoice.getNo();
    }
    private Integer resolveInvoiceType(String text) {
        List<String> labelList = DictFrameworkUtils.getDictDataLabelList(CRM_INVOICE_TYPE);
        if (StrUtil.isBlank(text)) {
            throw new IllegalArgumentException("发票类型不能为空:请填写模板中该行的「发票类型」列,可选值:"
                    + String.join("、", labelList));
        }
        String value = text.trim();
        if (value.matches("\\d+") && DictFrameworkUtils.getDictDataValueList(CRM_INVOICE_TYPE).contains(value)) {
            return Integer.valueOf(value);
        }
        String parsedValue = DictFrameworkUtils.parseDictDataValue(CRM_INVOICE_TYPE, value);
        if (parsedValue == null) {
            throw new IllegalArgumentException("发票类型不存在:" + value + "。模板中「发票类型」请填写以下之一:"
                    + String.join("、", labelList));
        }
        return Integer.valueOf(parsedValue);
    }
    private LocalDateTime parseImportDateTime(String text) {
        if (StrUtil.isBlank(text)) {
            return LocalDateTime.now();
        }
        String value = text.trim();
        for (String pattern : new String[]{"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy/MM/dd HH:mm:ss"}) {
            try {
                return LocalDateTime.parse(value, DateTimeFormatter.ofPattern(pattern));
            } catch (Exception ignored) {
                // ç»§ç»­å°è¯•下一个格式
            }
        }
        for (String pattern : new String[]{"yyyy-MM-dd", "yyyy/M/d"}) {
            try {
                return java.time.LocalDate.parse(value, DateTimeFormatter.ofPattern(pattern)).atStartOfDay();
            } catch (Exception ignored) {
                // ç»§ç»­å°è¯•下一个格式
            }
        }
        throw new IllegalArgumentException("开票日期格式不正确:" + text + ",示例 2026-01-01 10:00:00");
    }
    private String resolveImportErrorMessage(Exception ex) {
        if (ex instanceof cn.iocoder.yudao.framework.common.exception.ServiceException serviceException) {
            return serviceException.getMessage();
        }
        return StrUtil.blankToDefault(ex.getMessage(), "导入失败");
    }
    /**
     * å¯¼å…¥åŽ»é‡é”®ï¼šå‘ç¥¨å·ç å¡«å†™æ—¶ä»¥ åˆåŒ + å‘票号码 ä¸ºå‡†ï¼›ç•™ç©ºæ—¶æ¯”较 åˆåŒ + å¼€ç¥¨é‡‘额 + å‘票抬头 + å¼€ç¥¨æ—¥æœŸ
     */
    private String buildInvoiceImportKey(CrmInvoiceImportExcelVO row) {
        if (StrUtil.isNotBlank(row.getInvoiceNo())) {
            return "no|" + StrUtil.trimToEmpty(row.getContractNo()) + "|" + row.getInvoiceNo().trim();
        }
        return "amt|" + StrUtil.trimToEmpty(row.getContractNo()) + "|"
                + (row.getPrice() == null ? "" : row.getPrice().stripTrailingZeros().toPlainString()) + "|"
                + StrUtil.trimToEmpty(row.getInvoiceTitle()) + "|" + StrUtil.trimToEmpty(row.getInvoiceTime());
    }
    private String buildInvoiceKeyDesc(CrmInvoiceImportExcelVO row) {
        if (StrUtil.isNotBlank(row.getInvoiceNo())) {
            return "合同编号[" + StrUtil.trimToEmpty(row.getContractNo()) + "]、发票号码[" + row.getInvoiceNo().trim() + "]";
        }
        return "合同编号[" + StrUtil.trimToEmpty(row.getContractNo()) + "]、开票金额["
                + (row.getPrice() == null ? "未填写" : row.getPrice().stripTrailingZeros().toPlainString() + " å…ƒ")
                + "]、发票抬头[" + StrUtil.blankToDefault(StrUtil.trimToNull(row.getInvoiceTitle()), "未填写") + "]"
                + (StrUtil.isBlank(row.getInvoiceTime()) ? "" : "、开票日期[" + row.getInvoiceTime().trim() + "]");
    }
    /**
     * æŸ¥è¯¢åŒåˆåŒä¸‹ä¸šåŠ¡æ•°æ®å®Œå…¨ç›¸åŒçš„å¼€ç¥¨
     */
    private CrmInvoiceDO findDuplicateInvoice(Long contractId, CrmInvoiceImportExcelVO row, LocalDateTime invoiceTime) {
        for (CrmInvoiceDO old : invoiceMapper.selectListByContractId(contractId)) {
            if (StrUtil.isNotBlank(row.getInvoiceNo())) {
                if (StrUtil.equals(StrUtil.trimToEmpty(old.getInvoiceNo()), row.getInvoiceNo().trim())) {
                    return old;
                }
                continue;
            }
            // å‘票号码留空时,比较开票金额、发票抬头;开票日期留空则不比较该维度
            if (old.getPrice().compareTo(row.getPrice()) != 0
                    || !StrUtil.equals(StrUtil.trimToEmpty(old.getInvoiceTitle()),
                            StrUtil.trimToEmpty(row.getInvoiceTitle()))) {
                continue;
            }
            if (invoiceTime != null && !invoiceTime.equals(old.getInvoiceTime())) {
                continue;
            }
            return old;
        }
        return null;
    }
    private IllegalArgumentException duplicateRowException(int firstRowNo, String keyDesc) {
        return new IllegalArgumentException("检测到重复导入:本次导入的第 " + firstRowNo
                + " è¡Œä¸Žå½“前行的数据完全相同(" + keyDesc + ")。请删除模板中重复的行后重新导入");
    }
    private IllegalArgumentException duplicateExistingException(String keyDesc, String existingNo) {
        return new IllegalArgumentException("检测到重复导入:" + keyDesc + " ä¸Žå·²æœ‰å¼€ç¥¨å•「" + existingNo
                + "」完全相同(可能是本次导入前面的行,也可能是系统中已有单据)。请勿重复导入;如确需重新导入,请先在「营销管理-开票管理」中删除该开票单后重试");
    }
    private void validateInvoicePriceExceedsLimit(CrmInvoiceSaveReqVO reqVO) {
        // 1. è®¡ç®—剩余可开票金额,不包括 reqVO è‡ªèº«ï¼ˆå®¡æ‰¹ä¸­ä¹Ÿå ç”¨é¢åº¦ï¼Œé˜²æ­¢ç´¯è®¡å¼€ç¥¨è¶…过合同金额)
        CrmContractDO contract = contractService.validateContract(reqVO.getContractId());
        List<CrmInvoiceDO> invoices = invoiceMapper.selectListByContractIdAndStatus(reqVO.getContractId(),
                List.of(CrmAuditStatusEnum.APPROVE.getStatus(), CrmAuditStatusEnum.PROCESS.getStatus()));
        if (reqVO.getId() != null) {
            invoices.removeIf(invoice -> ObjectUtil.equal(invoice.getId(), reqVO.getId()));
        }
        BigDecimal notInvoicePrice = contract.getTotalPrice().subtract(
                CollectionUtils.getSumValue(invoices, CrmInvoiceDO::getPrice, BigDecimal::add, BigDecimal.ZERO));
        BigDecimal notInvoicePrice = getRemainingInvoicePrice(contract, reqVO.getId());
        // 2. æ ¡éªŒé‡‘额是否超过
        if (reqVO.getPrice().compareTo(notInvoicePrice) > 0) {
            throw exception(INVOICE_CREATE_FAIL_PRICE_EXCEEDS_LIMIT, notInvoicePrice);
        }
    }
    /**
     * è®¡ç®—合同剩余可开票金额(审批中、审核通过的开票占用额度)
     *
     * @param contract          åˆåŒ
     * @param excludeInvoiceId  éœ€æŽ’除的开票编号,可为空
     */
    private BigDecimal getRemainingInvoicePrice(CrmContractDO contract, Long excludeInvoiceId) {
        List<CrmInvoiceDO> invoices = invoiceMapper.selectListByContractIdAndStatus(contract.getId(),
                List.of(CrmAuditStatusEnum.APPROVE.getStatus(), CrmAuditStatusEnum.PROCESS.getStatus()));
        if (excludeInvoiceId != null) {
            invoices.removeIf(invoice -> ObjectUtil.equal(invoice.getId(), excludeInvoiceId));
        }
        return contract.getTotalPrice().subtract(
                CollectionUtils.getSumValue(invoices, CrmInvoiceDO::getPrice, BigDecimal::add, BigDecimal.ZERO));
    }
    private void validateRelationDataExists(CrmInvoiceSaveReqVO reqVO) {
        if (reqVO.getOwnerUserId() != null) {
            adminUserApi.validateUser(reqVO.getOwnerUserId()); // æ ¡éªŒè´Ÿè´£äººå­˜åœ¨
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/ipqc/MesQcIpqcController.java
@@ -13,9 +13,11 @@
import cn.iocoder.yudao.module.mdm.api.item.dto.MdmItemRespDTO;
import cn.iocoder.yudao.module.mdm.api.unit.MdmUnitMeasureApi;
import cn.iocoder.yudao.module.mdm.api.unit.dto.MdmUnitMeasureRespDTO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ipqc.vo.MesQcIpqcImportExcelVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ipqc.vo.MesQcIpqcPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ipqc.vo.MesQcIpqcRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ipqc.vo.MesQcIpqcSaveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.vo.MesQcImportRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.vo.MesQcQualityExportVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.indicator.MesQcIndicatorDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.indicatorresult.MesQcIndicatorResultDO;
@@ -39,8 +41,10 @@
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
@@ -139,6 +143,30 @@
        ExcelUtils.write(response, "过程检验单.xls", "数据", MesQcQualityExportVO.class, exportList);
    }
    @GetMapping("/get-import-template")
    @Operation(summary = "获得导入过程检验单模板")
    @PreAuthorize("@ss.hasPermission('mes:qc-ipqc:import')")
    public void importTemplate(HttpServletResponse response) throws IOException {
        List<MesQcIpqcImportExcelVO> list = Collections.singletonList(
                MesQcIpqcImportExcelVO.builder()
                        .name("示例-物料A过程检验").type("首检").itemCode("WL0001").batchCode("BC20260101")
                        .templateName("").checkQuantity(new BigDecimal("100"))
                        .qualifiedQuantity(new BigDecimal("95")).unqualifiedQuantity(new BigDecimal("5"))
                        .laborScrapQuantity(new BigDecimal("2")).materialScrapQuantity(new BigDecimal("2"))
                        .otherScrapQuantity(new BigDecimal("1")).checkResult("校验通过").inspectorName("")
                        .inspectDate("2026-01-01 10:00:00").remark("示例数据,导入前请替换")
                        .build());
        ExcelUtils.write(response, "过程检验单导入模板.xls", "过程检验", MesQcIpqcImportExcelVO.class, list);
    }
    @PostMapping("/import")
    @Operation(summary = "导入过程检验单")
    @PreAuthorize("@ss.hasPermission('mes:qc-ipqc:import')")
    public CommonResult<MesQcImportRespVO> importIpqcExcel(@RequestParam("file") MultipartFile file) throws Exception {
        List<MesQcIpqcImportExcelVO> list = ExcelUtils.read(file, MesQcIpqcImportExcelVO.class);
        return success(ipqcService.importIpqcList(list));
    }
    // ==================== æ‹¼æŽ¥ VO ====================
    private List<MesQcIpqcRespVO> buildIpqcRespVOList(List<MesQcIpqcDO> list) {
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/ipqc/vo/MesQcIpqcImportExcelVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,91 @@
package cn.iocoder.yudao.module.mes.controller.admin.qc.ipqc.vo;
import cn.idev.excel.annotation.ExcelProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
/**
 * ç®¡ç†åŽå° - è¿‡ç¨‹æ£€éªŒå•导入 Excel VO
 *
 * è¯´æ˜Žï¼šä¸€è¡Œ = ä¸€å¼ è¿‡ç¨‹æ£€éªŒå•。产品物料按物料编码匹配系统已有数据(物料来自 MDM),批号必填;
 * è´¨æ£€æ–¹æ¡ˆæŒ‰åç§°åŒ¹é…ç³»ç»Ÿå·²æœ‰æ•°æ®ï¼›æ£€æµ‹äººå‘˜ç•™ç©ºå–当前登录用户;检验单编号留空自动生成;状态固定为「待检验」。
 *
 * @author è¶…级管理员
 */
@Schema(description = "管理后台 - è¿‡ç¨‹æ£€éªŒå•导入 Excel VO")
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class MesQcIpqcImportExcelVO {
    @ExcelProperty("检验单编号")
    @Schema(description = "检验单编号,留空则自动生成", example = "IPQC20260101000001")
    private String code;
    @ExcelProperty("检验单名称")
    @Schema(description = "检验单名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "XX批号过程检验")
    private String name;
    @ExcelProperty("检验类型")
    @Schema(description = "检验类型,填写字典 mes_ipqc_type çš„名称,如 é¦–检", requiredMode = Schema.RequiredMode.REQUIRED, example = "首检")
    private String type;
    @ExcelProperty("产品物料编码")
    @Schema(description = "产品物料编码,需与物料管理中的编码一致", requiredMode = Schema.RequiredMode.REQUIRED, example = "WL001")
    private String itemCode;
    @ExcelProperty("批号")
    @Schema(description = "批号", requiredMode = Schema.RequiredMode.REQUIRED, example = "BC20260101")
    private String batchCode;
    @ExcelProperty("质检方案")
    @Schema(description = "质检方案名称,留空则按物料自动匹配", example = "过程检验通用方案")
    private String templateName;
    @ExcelProperty("检测数量")
    @Schema(description = "检测数量,留空默认等于合格品数量与不合格品数量之和", example = "100")
    private BigDecimal checkQuantity;
    @ExcelProperty("合格品数量")
    @Schema(description = "合格品数量,不能小于 0", requiredMode = Schema.RequiredMode.REQUIRED, example = "95")
    private BigDecimal qualifiedQuantity;
    @ExcelProperty("不合格品数量")
    @Schema(description = "不合格品数量,不能小于 0", requiredMode = Schema.RequiredMode.REQUIRED, example = "5")
    private BigDecimal unqualifiedQuantity;
    @ExcelProperty("工废数量")
    @Schema(description = "工废数量,不能小于 0;留空按 0 å¤„理", example = "2")
    private BigDecimal laborScrapQuantity;
    @ExcelProperty("料废数量")
    @Schema(description = "料废数量,不能小于 0;留空按 0 å¤„理", example = "2")
    private BigDecimal materialScrapQuantity;
    @ExcelProperty("其他废品数量")
    @Schema(description = "其他废品数量,不能小于 0;留空按 0 å¤„理", example = "1")
    private BigDecimal otherScrapQuantity;
    @ExcelProperty("检测结果")
    @Schema(description = "检测结果,填写字典 mes_qc_check_result çš„名称,如 æ ¡éªŒé€šè¿‡", example = "校验通过")
    private String checkResult;
    @ExcelProperty("检测人员")
    @Schema(description = "检测人员姓名,留空取当前登录用户", example = "张三")
    private String inspectorName;
    @ExcelProperty("检测日期")
    @Schema(description = "检测日期,格式如 2026-01-01 10:00:00;留空表示未填写", example = "2026-01-01 10:00:00")
    private String inspectDate;
    @ExcelProperty("备注")
    @Schema(description = "备注", example = "备注")
    private String remark;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/iqc/MesQcIqcController.java
@@ -9,9 +9,11 @@
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.dict.core.DictFrameworkUtils;
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
import cn.iocoder.yudao.module.mes.controller.admin.qc.iqc.vo.MesQcIqcImportExcelVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.iqc.vo.MesQcIqcPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.iqc.vo.MesQcIqcRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.iqc.vo.MesQcIqcSaveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.vo.MesQcImportRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.vo.MesQcQualityExportVO;
import cn.iocoder.yudao.module.mdm.api.item.MdmItemApi;
import cn.iocoder.yudao.module.mdm.api.item.dto.MdmItemRespDTO;
@@ -41,8 +43,10 @@
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
@@ -144,6 +148,30 @@
        ExcelUtils.write(response, "来料检验单.xls", "数据", MesQcQualityExportVO.class, exportList);
    }
    @GetMapping("/get-import-template")
    @Operation(summary = "获得导入来料检验单模板")
    @PreAuthorize("@ss.hasPermission('mes:qc-iqc:import')")
    public void importTemplate(HttpServletResponse response) throws IOException {
        List<MesQcIqcImportExcelVO> list = Collections.singletonList(
                MesQcIqcImportExcelVO.builder()
                        .name("示例-物料A来料检验").itemCode("WL0001").vendorName("示例供应商有限公司")
                        .vendorBatch("VB20260101").templateName("")
                        .receivedQuantity(new BigDecimal("100")).qualifiedQuantity(new BigDecimal("99"))
                        .unqualifiedQuantity(new BigDecimal("1")).checkResult("校验通过").inspectorName("")
                        .inspectDate("2026-01-01 10:00:00").receiveDate("2026-01-01 09:00:00")
                        .remark("示例数据,导入前请替换")
                        .build());
        ExcelUtils.write(response, "来料检验单导入模板.xls", "来料检验", MesQcIqcImportExcelVO.class, list);
    }
    @PostMapping("/import")
    @Operation(summary = "导入来料检验单")
    @PreAuthorize("@ss.hasPermission('mes:qc-iqc:import')")
    public CommonResult<MesQcImportRespVO> importIqcExcel(@RequestParam("file") MultipartFile file) throws Exception {
        List<MesQcIqcImportExcelVO> list = ExcelUtils.read(file, MesQcIqcImportExcelVO.class);
        return success(iqcService.importIqcList(list));
    }
    // ==================== æ‹¼æŽ¥ VO ====================
    private List<MesQcIqcRespVO> buildIqcRespVOList(List<MesQcIqcDO> list) {
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/iqc/vo/MesQcIqcImportExcelVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,83 @@
package cn.iocoder.yudao.module.mes.controller.admin.qc.iqc.vo;
import cn.idev.excel.annotation.ExcelProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
/**
 * ç®¡ç†åŽå° - æ¥æ–™æ£€éªŒå•导入 Excel VO
 *
 * è¯´æ˜Žï¼šä¸€è¡Œ = ä¸€å¼ æ¥æ–™æ£€éªŒå•。物料、供应商、质检方案均按名称/编码匹配系统已有数据;
 * æ£€æµ‹äººå‘˜ç•™ç©ºå–当前登录用户;检验单编号留空自动生成;状态固定为「待检验」。
 *
 * @author è¶…级管理员
 */
@Schema(description = "管理后台 - æ¥æ–™æ£€éªŒå•导入 Excel VO")
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class MesQcIqcImportExcelVO {
    @ExcelProperty("检验单编号")
    @Schema(description = "检验单编号,留空则自动生成", example = "IQC20260101000001")
    private String code;
    @ExcelProperty("检验单名称")
    @Schema(description = "检验单名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "物料A来料检验")
    private String name;
    @ExcelProperty("产品物料编码")
    @Schema(description = "产品物料编码,需与物料管理中的编码一致", requiredMode = Schema.RequiredMode.REQUIRED, example = "WL001")
    private String itemCode;
    @ExcelProperty("供应商名称")
    @Schema(description = "供应商名称,需与供应商管理中的名称一致", example = "示例供应商有限公司")
    private String vendorName;
    @ExcelProperty("供应商批次号")
    @Schema(description = "供应商批次号", example = "VB20260101")
    private String vendorBatch;
    @ExcelProperty("质检方案")
    @Schema(description = "质检方案名称,留空则按物料自动匹配", example = "来料检验通用方案")
    private String templateName;
    @ExcelProperty("本次接收数量")
    @Schema(description = "本次接收数量,留空默认等于合格品数量与不合格品数量之和", example = "100")
    private BigDecimal receivedQuantity;
    @ExcelProperty("合格品数量")
    @Schema(description = "合格品数量,不能小于 0", requiredMode = Schema.RequiredMode.REQUIRED, example = "99")
    private BigDecimal qualifiedQuantity;
    @ExcelProperty("不合格品数量")
    @Schema(description = "不合格品数量,不能小于 0", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    private BigDecimal unqualifiedQuantity;
    @ExcelProperty("检测结果")
    @Schema(description = "检测结果,填写字典 mes_qc_check_result çš„名称,如 æ ¡éªŒé€šè¿‡", example = "校验通过")
    private String checkResult;
    @ExcelProperty("检测人员")
    @Schema(description = "检测人员姓名,留空取当前登录用户", example = "张三")
    private String inspectorName;
    @ExcelProperty("检测日期")
    @Schema(description = "检测日期,格式如 2026-01-01 10:00:00", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-01-01 10:00:00")
    private String inspectDate;
    @ExcelProperty("来料日期")
    @Schema(description = "来料日期,格式如 2026-01-01 10:00:00", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-01-01 10:00:00")
    private String receiveDate;
    @ExcelProperty("备注")
    @Schema(description = "备注", example = "备注")
    private String remark;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/ledger/MesQcInspectionLedgerController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,113 @@
package cn.iocoder.yudao.module.mes.controller.admin.qc.ledger;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.dict.core.DictFrameworkUtils;
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ledger.vo.MesQcInspectionLedgerExportVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ledger.vo.MesQcInspectionLedgerPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ledger.vo.MesQcInspectionLedgerRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.vo.MesQcQualityExportVO;
import cn.iocoder.yudao.module.mes.enums.DictTypeConstants;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcTypeEnum;
import cn.iocoder.yudao.module.mes.service.qc.ledger.MesQcInspectionLedgerService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.EXPORT;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@Tag(name = "管理后台 - MES æ£€æµ‹ç»“果台账")
@RestController
@RequestMapping("/mes/qc/inspection-ledger")
@Validated
public class MesQcInspectionLedgerController {
    @Resource
    private MesQcInspectionLedgerService ledgerService;
    @GetMapping("/page")
    @Operation(summary = "获得检测结果台账分页(四类检验单统一查询)")
    @PreAuthorize("@ss.hasPermission('mes:qc-inspection-ledger:query')")
    public CommonResult<PageResult<MesQcInspectionLedgerRespVO>> getInspectionLedgerPage(
            @Valid MesQcInspectionLedgerPageReqVO pageReqVO) {
        return success(ledgerService.getInspectionLedgerPage(pageReqVO));
    }
    @GetMapping("/export-excel")
    @Operation(summary = "导出检测结果台账 Excel")
    @PreAuthorize("@ss.hasPermission('mes:qc-inspection-ledger:export')")
    @ApiAccessLog(operateType = EXPORT)
    public void exportInspectionLedgerExcel(@Valid MesQcInspectionLedgerPageReqVO pageReqVO,
                                            HttpServletResponse response) throws IOException {
        pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE);
        List<MesQcInspectionLedgerRespVO> list = ledgerService.getInspectionLedgerPage(pageReqVO).getList();
        ExcelUtils.write(response, "检测结果台账.xls", "检测结果台账",
                MesQcInspectionLedgerExportVO.class, buildExportList(list));
    }
    private List<MesQcInspectionLedgerExportVO> buildExportList(List<MesQcInspectionLedgerRespVO> list) {
        if (CollUtil.isEmpty(list)) {
            return new ArrayList<>();
        }
        List<MesQcInspectionLedgerExportVO> exportList = new ArrayList<>(list.size());
        for (MesQcInspectionLedgerRespVO vo : list) {
            MesQcInspectionLedgerExportVO export = new MesQcInspectionLedgerExportVO();
            export.setQcTypeName(qcTypeName(vo.getQcType()));
            export.setCode(vo.getCode());
            export.setName(vo.getName());
            export.setItemCode(vo.getItemCode());
            export.setItemName(vo.getItemName());
            export.setItemSpecification(vo.getItemSpecification());
            export.setBatchNo(vo.getBatchNo());
            export.setIndicatorName(vo.getIndicatorName());
            export.setIndicatorValue(vo.getIndicatorValue());
            export.setJudgementBasis(vo.getJudgementBasis());
            export.setCheckResultText(parseDictLabel(DictTypeConstants.MES_QC_CHECK_RESULT, vo.getCheckResult()));
            export.setCheckQuantity(MesQcQualityExportVO.formatDecimal(vo.getCheckQuantity()));
            export.setQualifiedQuantity(MesQcQualityExportVO.formatDecimal(vo.getQualifiedQuantity()));
            export.setUnqualifiedQuantity(MesQcQualityExportVO.formatDecimal(vo.getUnqualifiedQuantity()));
            export.setInspectorName(vo.getInspectorName());
            export.setInspectDate(MesQcQualityExportVO.formatDateTime(vo.getInspectDate()));
            export.setStatusText(parseDictLabel(DictTypeConstants.MES_ORDER_STATUS, vo.getStatus()));
            export.setRemark(vo.getRemark());
            exportList.add(export);
        }
        return exportList;
    }
    private static String qcTypeName(Integer qcType) {
        for (MesQcTypeEnum e : MesQcTypeEnum.values()) {
            if (Objects.equals(e.getType(), qcType)) {
                return e.getName();
            }
        }
        return "";
    }
    private static String parseDictLabel(String dictType, Integer value) {
        if (value == null) {
            return "";
        }
        String label = DictFrameworkUtils.parseDictDataLabel(dictType, String.valueOf(value));
        return StrUtil.nullToEmpty(label);
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/ledger/vo/MesQcInspectionLedgerExportVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,70 @@
package cn.iocoder.yudao.module.mes.controller.admin.qc.ledger.vo;
import cn.idev.excel.annotation.ExcelProperty;
import lombok.Data;
/**
 * MES æ£€æµ‹ç»“果台账导出 Excel VO
 *
 * <p>一行 = ä¸€ä¸ªæ£€æµ‹é¡¹ç»“果(检验单未录入检测项时该单占一行,检测项相关列为空),用于查询、打印与存档。
 *
 * @author è¶…级管理员
 */
@Data
public class MesQcInspectionLedgerExportVO {
    @ExcelProperty("分类")
    private String qcTypeName;
    @ExcelProperty("检验单编号")
    private String code;
    @ExcelProperty("检验单名称")
    private String name;
    @ExcelProperty("物料编码")
    private String itemCode;
    @ExcelProperty("物料名称")
    private String itemName;
    @ExcelProperty("规格型号")
    private String itemSpecification;
    @ExcelProperty("批次号")
    private String batchNo;
    @ExcelProperty("检测项")
    private String indicatorName;
    @ExcelProperty("检测值")
    private String indicatorValue;
    @ExcelProperty("判定依据")
    private String judgementBasis;
    @ExcelProperty("检测结果")
    private String checkResultText;
    @ExcelProperty("检测数量")
    private String checkQuantity;
    @ExcelProperty("合格数量")
    private String qualifiedQuantity;
    @ExcelProperty("不合格数量")
    private String unqualifiedQuantity;
    @ExcelProperty("检测人员")
    private String inspectorName;
    @ExcelProperty("检测日期")
    private String inspectDate;
    @ExcelProperty("单据状态")
    private String statusText;
    @ExcelProperty("备注")
    private String remark;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/ledger/vo/MesQcInspectionLedgerPageReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,41 @@
package cn.iocoder.yudao.module.mes.controller.admin.qc.ledger.vo;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
@Schema(description = "管理后台 - MES æ£€æµ‹ç»“果台账分页 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
public class MesQcInspectionLedgerPageReqVO extends PageParam {
    @Schema(description = "分类(检验类型):1 æ¥æ–™æ£€éªŒ / 2 è¿‡ç¨‹æ£€éªŒ / 3 å‡ºè´§æ£€éªŒ / 4 é€€è´§æ£€éªŒï¼Œä¸ä¼ ä¸ºå…¨éƒ¨", example = "1")
    private Integer qcType;
    @Schema(description = "检测日期区间,传 [开始时间, ç»“束时间]",
            example = "[\"2026-01-01 00:00:00\", \"2026-12-31 23:59:59\"]")
    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
    private LocalDateTime[] inspectDate;
    @Schema(description = "检验单编号(模糊匹配)", example = "IQC1787563511197")
    private String code;
    @Schema(description = "物料编号", example = "28")
    private Long itemId;
    @Schema(description = "批次号(模糊匹配;来料检验取供应商批次号,过程/出货/退货检验取批次号)", example = "20260920a")
    private String batchNo;
    @Schema(description = "检测结果", example = "1")
    private Integer checkResult;
    @Schema(description = "检测人员编号", example = "1")
    private Long inspectorUserId;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/ledger/vo/MesQcInspectionLedgerRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,102 @@
package cn.iocoder.yudao.module.mes.controller.admin.qc.ledger.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - MES æ£€æµ‹ç»“果台账 Response VO")
@Data
public class MesQcInspectionLedgerRespVO {
    // ========== æ£€éªŒå• ==========
    @Schema(description = "分类(检验类型):1 æ¥æ–™æ£€éªŒ / 2 è¿‡ç¨‹æ£€éªŒ / 3 å‡ºè´§æ£€éªŒ / 4 é€€è´§æ£€éªŒï¼›å­—å…¸ mes_qc_type", example = "1")
    private Integer qcType;
    @Schema(description = "检验单 ID", example = "1")
    private Long qcId;
    @Schema(description = "检验单编号", example = "IQC1787563511197")
    private String code;
    @Schema(description = "检验单名称", example = "AN1787563511157-物料检验")
    private String name;
    @Schema(description = "单据状态;字典 mes_order_status", example = "4")
    private Integer status;
    // ========== ç‰©æ–™ ==========
    @Schema(description = "物料编号", example = "28")
    private Long itemId;
    @Schema(description = "物料编码", example = "WL0001")
    private String itemCode;
    @Schema(description = "物料名称", example = "物料A")
    private String itemName;
    @Schema(description = "规格型号", example = "100mm*50mm")
    private String itemSpecification;
    @Schema(description = "计量单位名称", example = "个")
    private String unitName;
    @Schema(description = "批次号(来料检验取供应商批次号,过程/出货/退货检验取批次号)", example = "20260920a")
    private String batchNo;
    // ========== æ•°é‡ä¸Žç»“æžœ ==========
    @Schema(description = "检测数量", example = "100.00")
    private BigDecimal checkQuantity;
    @Schema(description = "合格数量", example = "99.00")
    private BigDecimal qualifiedQuantity;
    @Schema(description = "不合格数量", example = "1.00")
    private BigDecimal unqualifiedQuantity;
    @Schema(description = "检测结果;字典 mes_qc_check_result", example = "1")
    private Integer checkResult;
    @Schema(description = "检测日期")
    private LocalDateTime inspectDate;
    @Schema(description = "检测人员编号", example = "1")
    private Long inspectorUserId;
    @Schema(description = "检测人员昵称", example = "张三")
    private String inspectorName;
    // ========== æ£€æµ‹é¡¹ç»“果(一条明细一行;检验单未录入检测项时该组字段为空) ==========
    @Schema(description = "检测项编号", example = "2")
    private Long indicatorId;
    @Schema(description = "检测项名称", example = "长度")
    private String indicatorName;
    @Schema(description = "检测值", example = "15")
    private String indicatorValue;
    @Schema(description = "检测项备注", example = "抽检")
    private String indicatorRemark;
    @Schema(description = "判定依据,由检测项标准值/误差上下限拼装", example = "10~20")
    private String judgementBasis;
    @Schema(description = "检测项标准值(判定依据原始值,前端展示用 judgementBasis å³å¯ï¼‰", example = "15")
    private BigDecimal standardValue;
    @Schema(description = "检测项误差下限(判定依据原始值)", example = "10")
    private BigDecimal minThreshold;
    @Schema(description = "检测项误差上限(判定依据原始值)", example = "20")
    private BigDecimal maxThreshold;
    @Schema(description = "检验单备注")
    private String remark;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/oqc/MesQcOqcController.java
@@ -9,9 +9,11 @@
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.dict.core.DictFrameworkUtils;
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
import cn.iocoder.yudao.module.mes.controller.admin.qc.oqc.vo.MesQcOqcImportExcelVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.oqc.vo.MesQcOqcPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.oqc.vo.MesQcOqcRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.oqc.vo.MesQcOqcSaveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.vo.MesQcImportRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.vo.MesQcQualityExportVO;
import cn.iocoder.yudao.module.crm.api.customer.CrmCustomerApi;
import cn.iocoder.yudao.module.crm.api.customer.dto.CrmCustomerRespDTO;
@@ -41,8 +43,10 @@
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
@@ -143,6 +147,30 @@
        ExcelUtils.write(response, "出货检验单.xls", "数据", MesQcQualityExportVO.class, exportList);
    }
    @GetMapping("/get-import-template")
    @Operation(summary = "获得导入出货检验单模板")
    @PreAuthorize("@ss.hasPermission('mes:qc-oqc:import')")
    public void importTemplate(HttpServletResponse response) throws IOException {
        List<MesQcOqcImportExcelVO> list = Collections.singletonList(
                MesQcOqcImportExcelVO.builder()
                        .name("示例-物料A出货检验").itemCode("WL0001").clientName("示例客户有限公司")
                        .batchCode("BC20260101").templateName("")
                        .outQuantity(new BigDecimal("100")).checkQuantity(new BigDecimal("10"))
                        .qualifiedQuantity(new BigDecimal("9")).unqualifiedQuantity(new BigDecimal("1"))
                        .checkResult("校验通过").inspectorName("").inspectDate("2026-01-01 10:00:00")
                        .outDate("2026-01-01 09:00:00").remark("示例数据,导入前请替换")
                        .build());
        ExcelUtils.write(response, "出货检验单导入模板.xls", "出货检验", MesQcOqcImportExcelVO.class, list);
    }
    @PostMapping("/import")
    @Operation(summary = "导入出货检验单")
    @PreAuthorize("@ss.hasPermission('mes:qc-oqc:import')")
    public CommonResult<MesQcImportRespVO> importOqcExcel(@RequestParam("file") MultipartFile file) throws Exception {
        List<MesQcOqcImportExcelVO> list = ExcelUtils.read(file, MesQcOqcImportExcelVO.class);
        return success(oqcService.importOqcList(list));
    }
    // ==================== æ‹¼æŽ¥ VO ====================
    private List<MesQcOqcRespVO> buildOqcRespVOList(List<MesQcOqcDO> list) {
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/oqc/vo/MesQcOqcImportExcelVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,87 @@
package cn.iocoder.yudao.module.mes.controller.admin.qc.oqc.vo;
import cn.idev.excel.annotation.ExcelProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
/**
 * ç®¡ç†åŽå° - å‡ºè´§æ£€éªŒå•导入 Excel VO
 *
 * è¯´æ˜Žï¼šä¸€è¡Œ = ä¸€å¼ å‡ºè´§æ£€éªŒå•。物料、客户、质检方案按编码/名称匹配系统已有数据;
 * æ£€æµ‹äººå‘˜ç•™ç©ºå–当前登录用户;检验单编号留空自动生成;状态固定为「待检验」。
 *
 * @author è¶…级管理员
 */
@Schema(description = "管理后台 - å‡ºè´§æ£€éªŒå•导入 Excel VO")
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class MesQcOqcImportExcelVO {
    @ExcelProperty("检验单编号")
    @Schema(description = "检验单编号,留空则自动生成", example = "OQC20260101000001")
    private String code;
    @ExcelProperty("检验单名称")
    @Schema(description = "检验单名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "物料A出货检验")
    private String name;
    @ExcelProperty("产品物料编码")
    @Schema(description = "产品物料编码,需与物料管理中的编码一致", requiredMode = Schema.RequiredMode.REQUIRED, example = "WL001")
    private String itemCode;
    @ExcelProperty("客户名称")
    @Schema(description = "客户名称,需与 CRM å®¢æˆ·ç®¡ç†ä¸­çš„名称一致;留空表示不属于任何客户", example = "示例客户有限公司")
    private String clientName;
    @ExcelProperty("批次号")
    @Schema(description = "批次号", example = "BC20260101")
    private String batchCode;
    @ExcelProperty("质检方案")
    @Schema(description = "质检方案名称,留空则按物料自动匹配", example = "出货检验通用方案")
    private String templateName;
    @ExcelProperty("本次出货数量")
    @Schema(description = "本次出货数量,留空表示未填写;填写时不能小于 0", example = "100")
    private BigDecimal outQuantity;
    @ExcelProperty("检测数量")
    @Schema(description = "检测数量,留空默认等于合格品数量与不合格品数量之和", example = "10")
    private BigDecimal checkQuantity;
    @ExcelProperty("合格品数量")
    @Schema(description = "合格品数量,不能小于 0", requiredMode = Schema.RequiredMode.REQUIRED, example = "9")
    private BigDecimal qualifiedQuantity;
    @ExcelProperty("不合格品数量")
    @Schema(description = "不合格品数量,不能小于 0", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    private BigDecimal unqualifiedQuantity;
    @ExcelProperty("检测结果")
    @Schema(description = "检测结果,填写字典 mes_qc_check_result çš„名称,如 æ ¡éªŒé€šè¿‡", example = "校验通过")
    private String checkResult;
    @ExcelProperty("检测人员")
    @Schema(description = "检测人员姓名,留空取当前登录用户", example = "张三")
    private String inspectorName;
    @ExcelProperty("检测日期")
    @Schema(description = "检测日期,格式如 2026-01-01 10:00:00", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-01-01 10:00:00")
    private String inspectDate;
    @ExcelProperty("出货日期")
    @Schema(description = "出货日期,格式如 2026-01-01 10:00:00;留空表示未填写", example = "2026-01-01 10:00:00")
    private String outDate;
    @ExcelProperty("备注")
    @Schema(description = "备注", example = "备注")
    private String remark;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/vo/MesQcImportRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
package cn.iocoder.yudao.module.mes.controller.admin.qc.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
 * ç®¡ç†åŽå° - è´¨æ£€å•导入 Response VO(IQC / IPQC / OQC å…±ç”¨ï¼‰
 *
 * @author è¶…级管理员
 */
@Schema(description = "管理后台 - è´¨æ£€å•导入 Response VO")
@Data
@Builder
public class MesQcImportRespVO {
    @Schema(description = "导入成功的检验单编号数组", example = "IQC20260101000001")
    private List<String> createList;
    @Schema(description = "导入失败集合,key ä¸ºè¡Œæ ‡è¯†ï¼ˆæ£€éªŒå•编号或第 N è¡Œï¼‰ï¼Œvalue ä¸ºå¤±è´¥åŽŸå› ")
    private Map<String, String> failureList;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/qc/ipqc/MesQcIpqcMapper.java
@@ -7,6 +7,7 @@
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ipqc.MesQcIpqcDO;
import org.apache.ibatis.annotations.Mapper;
import java.time.LocalDateTime;
import java.util.List;
/**
@@ -41,4 +42,18 @@
                .orderByDesc(MesQcIpqcDO::getId));
    }
    /**
     * æŒ‰å¯¼å…¥åŽ»é‡é”®æŸ¥è¯¢ï¼šåŒä¸€ ç‰©æ–™ + æ‰¹å· + æ£€éªŒç±»åž‹ + æ£€æµ‹æ—¥æœŸ è§†ä¸ºåŒä¸€æ‰¹è¿‡ç¨‹æ£€éªŒæ•°æ®
     */
    default List<MesQcIpqcDO> selectListByImportKey(Long itemId, String batchCode, Integer type,
                                                    LocalDateTime inspectDate) {
        return selectList(new LambdaQueryWrapperX<MesQcIpqcDO>()
                .eq(MesQcIpqcDO::getItemId, itemId)
                .eq(MesQcIpqcDO::getBatchCode, batchCode)
                .eq(MesQcIpqcDO::getType, type)
                .eq(inspectDate != null, MesQcIpqcDO::getInspectDate, inspectDate)
                .isNull(inspectDate == null, MesQcIpqcDO::getInspectDate)
                .orderByAsc(MesQcIpqcDO::getId));
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/qc/iqc/MesQcIqcMapper.java
@@ -7,6 +7,9 @@
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.iqc.MesQcIqcDO;
import org.apache.ibatis.annotations.Mapper;
import java.time.LocalDateTime;
import java.util.List;
/**
 * MES æ¥æ–™æ£€éªŒå•(IQC) Mapper
 *
@@ -36,4 +39,19 @@
        return selectCount(MesQcIqcDO::getVendorId, vendorId);
    }
    /**
     * æŒ‰å¯¼å…¥åŽ»é‡é”®æŸ¥è¯¢ï¼šåŒä¸€ ç‰©æ–™ + ä¾›åº”商 + ä¾›åº”商批次 + æ¥æ–™æ—¥æœŸ è§†ä¸ºåŒä¸€æ‰¹æ¥æ–™æ•°æ®
     */
    default List<MesQcIqcDO> selectListByImportKey(Long itemId, Long vendorId, String vendorBatch,
                                                   LocalDateTime receiveDate) {
        return selectList(new LambdaQueryWrapperX<MesQcIqcDO>()
                .eq(MesQcIqcDO::getItemId, itemId)
                .eq(vendorId != null, MesQcIqcDO::getVendorId, vendorId)
                .isNull(vendorId == null, MesQcIqcDO::getVendorId)
                .eq(vendorBatch != null, MesQcIqcDO::getVendorBatch, vendorBatch)
                .isNull(vendorBatch == null, MesQcIqcDO::getVendorBatch)
                .eq(MesQcIqcDO::getReceiveDate, receiveDate)
                .orderByAsc(MesQcIqcDO::getId));
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/qc/ledger/MesQcInspectionLedgerMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,20 @@
package cn.iocoder.yudao.module.mes.dal.mysql.qc.ledger;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ledger.vo.MesQcInspectionLedgerPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ledger.vo.MesQcInspectionLedgerRespVO;
import com.baomidou.mybatisplus.core.metadata.IPage;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
 * MES æ£€æµ‹ç»“果台账 Mapper
 *
 * ä½¿ç”¨ UNION ALL SQL ä»Žå››ç±»æ£€éªŒå•(IQC/IPQC/OQC/RQC)拉取检验单,再关联检验结果明细与检测项,一行一个检测项结果
 */
@Mapper
public interface MesQcInspectionLedgerMapper {
    IPage<MesQcInspectionLedgerRespVO> selectInspectionLedgerPage(IPage<MesQcInspectionLedgerRespVO> page,
                                                                 @Param("reqVO") MesQcInspectionLedgerPageReqVO reqVO);
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/qc/oqc/MesQcOqcMapper.java
@@ -8,6 +8,7 @@
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.oqc.MesQcOqcDO;
import org.apache.ibatis.annotations.Mapper;
import java.time.LocalDateTime;
import java.util.List;
/**
@@ -57,4 +58,22 @@
        return selectOne(MesQcOqcDO::getCode, code);
    }
    /**
     * æŒ‰å¯¼å…¥åŽ»é‡é”®æŸ¥è¯¢ï¼šåŒä¸€ ç‰©æ–™ + å®¢æˆ· + æ‰¹æ¬¡å· + å‡ºè´§æ—¥æœŸ è§†ä¸ºåŒä¸€æ‰¹å‡ºè´§æ£€éªŒæ•°æ®
     *
     * å®¢æˆ·ä¸Žå‡ºè´§æ—¥æœŸåœ¨æœ¬åˆ†æ”¯å¯ä¸ºç©ºï¼Œæ•…三个可空维度均做空值安全比较(null æ¯” null)。
     */
    default List<MesQcOqcDO> selectListByImportKey(Long itemId, Long clientId, String batchCode,
                                                   LocalDateTime outDate) {
        return selectList(new LambdaQueryWrapperX<MesQcOqcDO>()
                .eq(MesQcOqcDO::getItemId, itemId)
                .eq(clientId != null, MesQcOqcDO::getClientId, clientId)
                .isNull(clientId == null, MesQcOqcDO::getClientId)
                .eq(batchCode != null, MesQcOqcDO::getBatchCode, batchCode)
                .isNull(batchCode == null, MesQcOqcDO::getBatchCode)
                .eq(outDate != null, MesQcOqcDO::getOutDate, outDate)
                .isNull(outDate == null, MesQcOqcDO::getOutDate)
                .orderByAsc(MesQcOqcDO::getId));
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/imports/MesQcImportHelper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,369 @@
package cn.iocoder.yudao.module.mes.service.qc.imports;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.dict.core.DictFrameworkUtils;
import cn.iocoder.yudao.module.crm.api.customer.CrmCustomerApi;
import cn.iocoder.yudao.module.crm.api.customer.dto.CrmCustomerRespDTO;
import cn.iocoder.yudao.module.mdm.api.item.MdmItemApi;
import cn.iocoder.yudao.module.mdm.api.item.dto.MdmItemRespDTO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.template.MesQcTemplateDO;
import cn.iocoder.yudao.module.mes.service.md.autocode.MesMdAutoCodeRecordService;
import cn.iocoder.yudao.module.mes.service.qc.template.MesQcTemplateService;
import cn.iocoder.yudao.module.srm.api.supplier.SrmSupplierApi;
import cn.iocoder.yudao.module.srm.api.supplier.dto.SrmSupplierRespDTO;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.QC_NO_TEMPLATE;
/**
 * MES è´¨æ£€å•导入辅助类
 *
 * è´Ÿè´£æŠŠ Excel ä¸­çš„人类可读值(物料编码、供应商名称、客户名称、质检方案名称、检测人员姓名等)
 * è§£æžä¸ºç³»ç»Ÿç¼–号,并统一处理字典、日期、错误文案。
 * å„物料/供应商/客户/方案映射在 {@link #startImport()} åŽé¦–次使用时一次性加载,避免逐行查询。
 */
@Component
public class MesQcImportHelper {
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private MdmItemApi mdmItemApi;
    @Resource
    private SrmSupplierApi srmSupplierApi;
    @Resource
    private CrmCustomerApi crmCustomerApi;
    @Resource
    private MesQcTemplateService templateService;
    @Resource
    private MesMdAutoCodeRecordService autoCodeRecordService;
    /** è´¨æ£€æ–¹æ¡ˆåç§° -> ç¼–号(可能重名,命中多个时取第一个) */
    private Map<String, Long> templateNameMap;
    /** ä¾›åº”商名称 -> ç¼–号 */
    private Map<String, Long> supplierNameMap;
    /** å®¢æˆ·åç§° -> ç¼–号 */
    private Map<String, Long> customerNameMap;
    /** å½“前登录用户可见的客户编号;null è¡¨ç¤ºä¸é™åˆ¶ */
    private Set<Long> permittedClientIds;
    private boolean clientPermissionLoaded;
    /**
     * å¼€å§‹ä¸€æ¬¡å¯¼å…¥ï¼šæ¸…空上一次导入的缓存,保证新建的供应商/客户/方案本次即可命中
     */
    public void startImport() {
        templateNameMap = null;
        supplierNameMap = null;
        customerNameMap = null;
        permittedClientIds = null;
        clientPermissionLoaded = false;
    }
    /**
     * ç”Ÿæˆæ£€éªŒå•编号,如 IQC20260101000001
     */
    public String generateCode(String ruleCode) {
        return autoCodeRecordService.generateAutoCode(ruleCode);
    }
    /**
     * è§£æžæ£€æµ‹äººå‘˜ï¼šç•™ç©ºå–当前登录用户;填写时按姓名(昵称)精确匹配,取不到则用模糊匹配的第一条
     */
    public Long resolveInspectorUserId(String nickname) {
        if (StrUtil.isBlank(nickname)) {
            return getLoginUserId();
        }
        List<AdminUserRespDTO> users = adminUserApi.getUserListByNickname(nickname.trim());
        if (CollUtil.isEmpty(users)) {
            throw new IllegalArgumentException("检测人员不存在:" + nickname.trim()
                    + "。请在「检测人员」列填写系统内的用户姓名(可在「系统管理-用户管理」中核对),或留空使用当前登录用户");
        }
        return users.stream().filter(user -> nickname.trim().equals(user.getNickname()))
                .map(AdminUserRespDTO::getId).findFirst().orElseGet(() -> users.get(0).getId());
    }
    /**
     * è§£æžäº§å“ç‰©æ–™ç¼–号,基于物料编码
     */
    public Long resolveItemId(String itemCode) {
        if (StrUtil.isBlank(itemCode)) {
            throw new IllegalArgumentException("产品物料编码不能为空:请填写模板中该行的「产品物料编码」列");
        }
        MdmItemRespDTO item = mdmItemApi.getItemByCode(itemCode.trim()).getCheckedData();
        if (item == null) {
            throw new IllegalArgumentException("物料不存在:" + itemCode.trim()
                    + "。请先在「物料管理」中维护该物料(编码需与模板完全一致),或修改模板中的产品物料编码后重新导入");
        }
        return item.getId();
    }
    /**
     * è§£æžè´¨æ£€æ–¹æ¡ˆç¼–号,基于方案名称;留空返回 null(由系统按物料+检验类型自动匹配)
     */
    public Long resolveTemplateId(String templateName) {
        if (StrUtil.isBlank(templateName)) {
            return null;
        }
        if (templateNameMap == null) {
            templateNameMap = new HashMap<>();
            List<MesQcTemplateDO> templates = templateService.getTemplateList();
            if (CollUtil.isNotEmpty(templates)) {
                templates.forEach(template -> {
                    if (StrUtil.isNotBlank(template.getName())) {
                        templateNameMap.putIfAbsent(template.getName().trim(), template.getId());
                    }
                });
            }
        }
        Long templateId = templateNameMap.get(templateName.trim());
        if (templateId == null) {
            throw new IllegalArgumentException("质检方案不存在:" + templateName.trim()
                    + "。请在「质检管理-质检方案」中核对方案名称,或留空该列由系统按物料自动匹配方案后重新导入");
        }
        return templateId;
    }
    /**
     * è§£æžä¾›åº”商编号,基于供应商名称;留空返回 null
     */
    public Long resolveVendorId(String vendorName) {
        if (StrUtil.isBlank(vendorName)) {
            return null;
        }
        if (supplierNameMap == null) {
            supplierNameMap = new HashMap<>();
            List<SrmSupplierRespDTO> suppliers = srmSupplierApi.getSupplierSimpleList().getCheckedData();
            if (CollUtil.isNotEmpty(suppliers)) {
                suppliers.forEach(supplier -> {
                    if (StrUtil.isNotBlank(supplier.getName())) {
                        supplierNameMap.putIfAbsent(supplier.getName().trim(), supplier.getId());
                    }
                });
            }
        }
        Long vendorId = supplierNameMap.get(vendorName.trim());
        if (vendorId == null) {
            throw new IllegalArgumentException("供应商不存在或未通过准入审批:" + vendorName.trim()
                    + "。请在「供应商管理」中核对供应商名称(名称需与模板完全一致),或留空该列后重新导入");
        }
        return vendorId;
    }
    /**
     * è§£æžå®¢æˆ·ç¼–号,基于客户名称;留空返回 null
     *
     * æœ¬åˆ†æ”¯çš„出货检验单不强制关联客户(客户可空),故此处留空不报错。
     */
    public Long resolveClientId(String clientName) {
        if (StrUtil.isBlank(clientName)) {
            return null;
        }
        if (customerNameMap == null) {
            customerNameMap = new HashMap<>();
            List<CrmCustomerRespDTO> customers = crmCustomerApi.getCustomerSimpleList();
            if (CollUtil.isNotEmpty(customers)) {
                customers.forEach(customer -> {
                    if (StrUtil.isNotBlank(customer.getName())) {
                        customerNameMap.putIfAbsent(customer.getName().trim(), customer.getId());
                    }
                });
            }
        }
        Long clientId = customerNameMap.get(clientName.trim());
        if (clientId == null) {
            throw new IllegalArgumentException("客户不存在:" + clientName.trim()
                    + "。请先在「CRM å®¢æˆ·ç®¡ç†ã€ä¸­åˆ›å»ºè¯¥å®¢æˆ·ï¼ˆåç§°éœ€ä¸Žæ¨¡æ¿å®Œå…¨ä¸€è‡´ï¼‰ï¼Œæˆ–修改模板中的客户名称后重新导入");
        }
        return clientId;
    }
    /**
     * æ ¡éªŒå®¢æˆ·åœ¨å½“前登录用户的可见范围内
     *
     * å‡ºè´§æ£€éªŒå•列表按客户数据权限过滤,权限外的客户导入后本人在列表中看不到该单据,因此提前拦截。
     */
    public void validateClientPermission(Long clientId, String clientName) {
        if (!clientPermissionLoaded) {
            List<Long> permittedIds = crmCustomerApi.getPermittedCustomerIds();
            permittedClientIds = permittedIds == null ? null : new HashSet<>(permittedIds);
            clientPermissionLoaded = true;
        }
        if (permittedClientIds == null || permittedClientIds.contains(clientId)) {
            return;
        }
        throw new IllegalArgumentException("客户[" + clientName + "]不在您的数据权限范围内:"
                + "导入后您将无法在「出货检验」列表中看到该检验单。请改用您负责的客户(可在「CRM å®¢æˆ·ç®¡ç†-我负责的」中查看),"
                + "或联系管理员为您开通该客户的权限后重新导入");
    }
    /**
     * è§£æžå­—典值:支持填写字典名称(如 æ ¡éªŒé€šè¿‡ï¼‰æˆ–字典值(如 1)
     *
     * @param text       Excel å•元格内容
     * @param dictType   å­—典类型
     * @param fieldLabel åˆ—名,用于错误提示
     * @param required   æ˜¯å¦å¿…å¡«
     */
    public Integer resolveDictValue(String text, String dictType, String fieldLabel, boolean required) {
        List<String> labelList = DictFrameworkUtils.getDictDataLabelList(dictType);
        if (StrUtil.isBlank(text)) {
            if (required) {
                throw new IllegalArgumentException(fieldLabel + "不能为空:请填写模板中该行的「" + fieldLabel
                        + "」列,可选值:" + String.join("、", labelList));
            }
            return null;
        }
        String value = text.trim();
        if (value.matches("\\d+") && DictFrameworkUtils.getDictDataValueList(dictType).contains(value)) {
            return Integer.valueOf(value);
        }
        String parsedValue = DictFrameworkUtils.parseDictDataValue(dictType, value);
        if (parsedValue == null) {
            throw new IllegalArgumentException(fieldLabel + "不存在:" + value + "。模板中「" + fieldLabel
                    + "」请填写以下之一:" + String.join("、", labelList));
        }
        return Integer.valueOf(parsedValue);
    }
    /**
     * è§£æžæ•°é‡ï¼šå¿…填时不能为空,且不能小于 0
     */
    public BigDecimal resolveQuantity(BigDecimal value, String fieldLabel, boolean required) {
        if (value == null) {
            if (required) {
                throw new IllegalArgumentException(fieldLabel + "不能为空:请填写模板中该行的「" + fieldLabel + "」列");
            }
            return null;
        }
        if (value.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException(fieldLabel + "不能小于 0,当前为 " + value.stripTrailingZeros().toPlainString()
                    + "。请修改模板后重新导入");
        }
        return value;
    }
    /**
     * æ ¡éªŒã€Œåˆè®¡æ•°é‡ = åˆæ ¼å“æ•°é‡ + ä¸åˆæ ¼å“æ•°é‡ã€
     *
     * @param total            åˆè®¡æ•°é‡ï¼ˆåˆè®¡åˆ—留空时传 null,由合格 + ä¸åˆæ ¼ è‡ªåŠ¨æŽ¨ç®—ï¼‰
     * @param qualified        åˆæ ¼å“æ•°é‡
     * @param unqualified      ä¸åˆæ ¼å“æ•°é‡
     * @param totalFieldLabel  åˆè®¡åˆ—的列名,如「本次接收数量」
     */
    public BigDecimal resolveCheckQuantity(BigDecimal total, BigDecimal qualified, BigDecimal unqualified,
                                           String totalFieldLabel) {
        BigDecimal sum = qualified.add(unqualified);
        if (total == null) {
            return sum;
        }
        if (total.compareTo(sum) != 0) {
            throw new IllegalArgumentException(totalFieldLabel + "必须等于合格品数量与不合格品数量之和:合格品数量 "
                    + qualified.stripTrailingZeros().toPlainString() + " + ä¸åˆæ ¼å“æ•°é‡ "
                    + unqualified.stripTrailingZeros().toPlainString() + " = " + sum.stripTrailingZeros().toPlainString()
                    + ",而「" + totalFieldLabel + "」填写的是 " + total.stripTrailingZeros().toPlainString()
                    + "。请修改模板中的数量列,或将该列留空由系统自动合计后重新导入");
        }
        return total;
    }
    /**
     * è§£æžæ—¥æœŸæ—¶é—´ï¼šæ”¯æŒ 2026-01-01 10:00:00 / 2026-01-01 10:00 / 2026-01-01 / 2026/1/1;留空返回 null
     */
    public LocalDateTime parseDateTime(String text, String fieldLabel) {
        if (StrUtil.isBlank(text)) {
            return null;
        }
        String value = text.trim();
        for (String pattern : new String[]{"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy/MM/dd HH:mm:ss"}) {
            try {
                return LocalDateTime.parse(value, DateTimeFormatter.ofPattern(pattern));
            } catch (Exception ignored) {
                // ç»§ç»­å°è¯•下一个格式
            }
        }
        for (String pattern : new String[]{"yyyy-MM-dd", "yyyy/M/d"}) {
            try {
                return java.time.LocalDate.parse(value, DateTimeFormatter.ofPattern(pattern)).atStartOfDay();
            } catch (Exception ignored) {
                // ç»§ç»­å°è¯•下一个格式
            }
        }
        throw new IllegalArgumentException(fieldLabel + "格式不正确:" + text + ",示例 2026-01-01 10:00:00");
    }
    /**
     * è§£æžå¿…填的日期时间:留空报错
     */
    public LocalDateTime parseRequiredDateTime(String text, String fieldLabel) {
        LocalDateTime value = parseDateTime(text, fieldLabel);
        if (value == null) {
            throw new IllegalArgumentException(fieldLabel + "不能为空:请填写模板中该行的「" + fieldLabel
                    + "」列,示例 2026-01-01 10:00:00");
        }
        return value;
    }
    /**
     * ç‰©æ–™æœªé…ç½®è´¨æ£€æ–¹æ¡ˆæ—¶ï¼Œè¡¥å……物料与检验类型上下文再抛出;其余异常原样抛出
     */
    public static void rethrowTemplateError(ServiceException ex, String itemCode, String qcTypeLabel) {
        if (Objects.equals(ex.getCode(), QC_NO_TEMPLATE.getCode())) {
            throw new IllegalArgumentException("物料[" + StrUtil.blankToDefault(itemCode, "未知") + "]未配置"
                    + qcTypeLabel + "的质检方案。请在「质检管理-质检方案」中为该物料配置" + qcTypeLabel
                    + "方案,或在模板的「质检方案」列填写该物料已配置的方案名称后重新导入");
        }
        throw ex;
    }
    /**
     * å•行导入失败时的提示文案
     */
    public static String resolveImportErrorMessage(Exception ex) {
        if (ex instanceof cn.iocoder.yudao.framework.common.exception.ServiceException serviceException) {
            return serviceException.getMessage();
        }
        return StrUtil.blankToDefault(ex.getMessage(), "导入失败");
    }
    /**
     * é‡å¤æ•°æ®æç¤ºä¸­çš„取值展示:留空显示为「未填写」
     */
    public static String describeImportValue(String value) {
        return StrUtil.isBlank(value) ? "未填写" : value.trim();
    }
    /**
     * åŒä¸€ä»½æ¨¡æ¿å†…出现重复数据时的提示
     */
    public static IllegalArgumentException duplicateRowException(int firstRowNo, String keyDesc) {
        return new IllegalArgumentException("检测到重复导入:本次导入的第 " + firstRowNo
                + " è¡Œä¸Žå½“前行的数据完全相同(" + keyDesc + ")。请删除模板中重复的行后重新导入");
    }
    /**
     * ä¸Žç³»ç»Ÿä¸­å·²æœ‰å•据重复时的提示
     */
    public static IllegalArgumentException duplicateExistingException(String keyDesc, String docLabel,
                                                                    String existingCode, String menuPath) {
        return new IllegalArgumentException("检测到重复导入:" + keyDesc + " ä¸Žå·²æœ‰" + docLabel + "「" + existingCode
                + "」完全相同(可能是本次导入前面的行,也可能是系统中已有单据)。请勿重复导入;如确需重新导入,请先在「"
                + menuPath + "」中删除该" + docLabel + "后重试");
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ipqc/MesQcIpqcService.java
@@ -1,8 +1,10 @@
package cn.iocoder.yudao.module.mes.service.qc.ipqc;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ipqc.vo.MesQcIpqcImportExcelVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ipqc.vo.MesQcIpqcPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ipqc.vo.MesQcIpqcSaveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.vo.MesQcImportRespVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.defectrecord.MesQcDefectRecordDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ipqc.MesQcIpqcDO;
import jakarta.validation.Valid;
@@ -77,4 +79,12 @@
     */
    void recalculateDefectStats(Long ipqcId, List<MesQcDefectRecordDO> records);
    /**
     * æ‰¹é‡å¯¼å…¥è¿‡ç¨‹æ£€éªŒå•
     *
     * @param importList å¯¼å…¥è¡Œåˆ—表
     * @return å¯¼å…¥ç»“果(成功编号列表 + é€è¡Œå¤±è´¥åŽŸå› ï¼‰
     */
    MesQcImportRespVO importIpqcList(List<MesQcIpqcImportExcelVO> importList);
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ipqc/MesQcIpqcServiceImpl.java
@@ -4,24 +4,30 @@
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.mdm.api.item.MdmItemApi;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productproduce.MesWmProductProduceLineDO;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmQualityStatusEnum;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ipqc.vo.MesQcIpqcImportExcelVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ipqc.vo.MesQcIpqcPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ipqc.vo.MesQcIpqcSaveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.vo.MesQcImportRespVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.feedback.MesProFeedbackDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.defectrecord.MesQcDefectRecordDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ipqc.MesQcIpqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.template.MesQcTemplateItemDO;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.ipqc.MesQcIpqcMapper;
import cn.iocoder.yudao.module.mes.enums.DictTypeConstants;
import cn.iocoder.yudao.module.mes.enums.md.autocode.MesMdAutoCodeRuleCodeEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcDefectLevelEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcStatusEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcTypeEnum;
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.service.pro.feedback.MesProFeedbackService;
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.indicatorresult.MesQcIndicatorResultService;
import cn.iocoder.yudao.module.mes.service.qc.template.MesQcTemplateItemService;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ncr.vo.MesQcNcrSaveReqVO;
@@ -37,7 +43,11 @@
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
@@ -55,6 +65,9 @@
    @Resource
    private MesQcIpqcMapper ipqcMapper;
    @Resource
    private MesQcImportHelper qcImportHelper;
    @Resource
    private MesQcTemplateItemService templateItemService;
@@ -349,4 +362,145 @@
        ipqcMapper.updateById(updateIpqc);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public MesQcImportRespVO importIpqcList(List<MesQcIpqcImportExcelVO> importList) {
        MesQcImportRespVO respVO = MesQcImportRespVO.builder()
                .createList(new ArrayList<>()).failureList(new LinkedHashMap<>()).build();
        if (CollUtil.isEmpty(importList)) {
            return respVO;
        }
        qcImportHelper.startImport();
        Map<String, Integer> firstRowNoByKey = new HashMap<>();
        for (int i = 0; i < importList.size(); i++) {
            MesQcIpqcImportExcelVO row = importList.get(i);
            if (isImportRowEmpty(row)) {
                continue; // è·³è¿‡ç©ºè¡Œ
            }
            String label = StrUtil.isNotBlank(row.getCode()) ? "检验单编号 " + row.getCode().trim()
                    : "第 " + (i + 2) + " è¡Œ";
            String importKey = buildImportKey(row);
            try {
                Integer firstRowNo = firstRowNoByKey.get(importKey);
                if (firstRowNo != null) {
                    throw MesQcImportHelper.duplicateRowException(firstRowNo, buildImportKeyDesc(row));
                }
                respVO.getCreateList().add(createIpqcByImport(row));
                firstRowNoByKey.putIfAbsent(importKey, i + 2);
            } catch (Exception ex) {
                respVO.getFailureList().put(label, MesQcImportHelper.resolveImportErrorMessage(ex));
            }
        }
        return respVO;
    }
    private boolean isImportRowEmpty(MesQcIpqcImportExcelVO row) {
        return row == null || (StrUtil.isBlank(row.getCode()) && StrUtil.isBlank(row.getName())
                && StrUtil.isBlank(row.getType()) && StrUtil.isBlank(row.getItemCode())
                && StrUtil.isBlank(row.getBatchCode()) && StrUtil.isBlank(row.getTemplateName())
                && row.getCheckQuantity() == null && row.getQualifiedQuantity() == null
                && row.getUnqualifiedQuantity() == null && row.getLaborScrapQuantity() == null
                && row.getMaterialScrapQuantity() == null && row.getOtherScrapQuantity() == null
                && StrUtil.isBlank(row.getCheckResult()) && StrUtil.isBlank(row.getInspectorName())
                && StrUtil.isBlank(row.getInspectDate()) && StrUtil.isBlank(row.getRemark()));
    }
    /**
     * ä¾æ®å¯¼å…¥è¡Œåˆ›å»ºä¸€å¼ è¿‡ç¨‹æ£€éªŒå•:物料按产品物料编码匹配,批号必填,状态为「待检验」,检验行由质检方案自动生成
     *
     * @return æ£€éªŒå•编号
     */
    private String createIpqcByImport(MesQcIpqcImportExcelVO row) {
        MesQcIpqcSaveReqVO reqVO = new MesQcIpqcSaveReqVO();
        // 1. æ£€éªŒå•编号:填写则使用,留空自动生成
        String code = StrUtil.isNotBlank(row.getCode()) ? row.getCode().trim()
                : qcImportHelper.generateCode(MesMdAutoCodeRuleCodeEnum.QC_IPQC_CODE.getCode());
        if (ipqcMapper.selectByCode(code) != null) {
            throw new IllegalArgumentException("检验单编号已存在:" + code
                    + "。请修改模板中的「检验单编号」,或先在系统中删除该编号的检验单后重新导入");
        }
        reqVO.setCode(code);
        // 2. åç§°ã€æ£€éªŒç±»åž‹ã€äº§å“ç‰©æ–™ã€æ‰¹å·ã€è´¨æ£€æ–¹æ¡ˆ
        if (StrUtil.isBlank(row.getName())) {
            throw new IllegalArgumentException("检验单名称不能为空:请填写模板中该行的「检验单名称」列");
        }
        reqVO.setName(row.getName().trim());
        reqVO.setType(qcImportHelper.resolveDictValue(row.getType(),
                DictTypeConstants.MES_IPQC_TYPE, "检验类型", true));
        reqVO.setItemId(qcImportHelper.resolveItemId(row.getItemCode()));
        if (StrUtil.isBlank(row.getBatchCode())) {
            throw new IllegalArgumentException("批号不能为空:请填写模板中该行的「批号」列");
        }
        reqVO.setBatchCode(row.getBatchCode().trim());
        reqVO.setTemplateId(qcImportHelper.resolveTemplateId(row.getTemplateName()));
        // 3. æ•°é‡ï¼šåˆæ ¼å“ + ä¸åˆæ ¼å“ å¿…须等于检测数量(检测数量留空则自动合计);工废/料废/其他废留空按 0
        BigDecimal qualified = qcImportHelper.resolveQuantity(row.getQualifiedQuantity(), "合格品数量", true);
        BigDecimal unqualified = qcImportHelper.resolveQuantity(row.getUnqualifiedQuantity(), "不合格品数量", true);
        reqVO.setQualifiedQuantity(qualified);
        reqVO.setUnqualifiedQuantity(unqualified);
        reqVO.setCheckQuantity(qcImportHelper.resolveCheckQuantity(
                row.getCheckQuantity(), qualified, unqualified, "检测数量"));
        BigDecimal laborScrap = qcImportHelper.resolveQuantity(row.getLaborScrapQuantity(), "工废数量", false);
        BigDecimal materialScrap = qcImportHelper.resolveQuantity(row.getMaterialScrapQuantity(), "料废数量", false);
        BigDecimal otherScrap = qcImportHelper.resolveQuantity(row.getOtherScrapQuantity(), "其他废品数量", false);
        laborScrap = laborScrap != null ? laborScrap : BigDecimal.ZERO;
        materialScrap = materialScrap != null ? materialScrap : BigDecimal.ZERO;
        otherScrap = otherScrap != null ? otherScrap : BigDecimal.ZERO;
        validateScrapQuantity(unqualified, laborScrap, materialScrap, otherScrap);
        reqVO.setLaborScrapQuantity(laborScrap);
        reqVO.setMaterialScrapQuantity(materialScrap);
        reqVO.setOtherScrapQuantity(otherScrap);
        // 4. æ£€éªŒä¿¡æ¯
        reqVO.setCheckResult(qcImportHelper.resolveDictValue(row.getCheckResult(),
                DictTypeConstants.MES_QC_CHECK_RESULT, "检测结果", false));
        reqVO.setInspectorUserId(qcImportHelper.resolveInspectorUserId(row.getInspectorName()));
        reqVO.setInspectDate(qcImportHelper.parseDateTime(row.getInspectDate(), "检测日期"));
        reqVO.setRemark(StrUtil.trimToNull(row.getRemark()));
        // 5. åŽ»é‡ï¼šåŒä¸€ ç‰©æ–™ + æ‰¹å· + æ£€éªŒç±»åž‹ + æ£€æµ‹æ—¥æœŸ çš„过程检验单不可重复导入
        MesQcIpqcDO duplicate = CollUtil.getFirst(ipqcMapper.selectListByImportKey(reqVO.getItemId(),
                reqVO.getBatchCode(), reqVO.getType(), reqVO.getInspectDate()));
        if (duplicate != null) {
            throw MesQcImportHelper.duplicateExistingException(buildImportKeyDesc(row), "过程检验单",
                    duplicate.getCode(), "质量管理-过程检验");
        }
        // 6. å¤ç”¨åˆ›å»ºé€»è¾‘:校验物料/人员、按物料匹配质检方案、生成检验行
        try {
            createIpqc(reqVO);
        } catch (ServiceException ex) {
            MesQcImportHelper.rethrowTemplateError(ex, row.getItemCode(), "过程检验(IPQC)");
        }
        return code;
    }
    /**
     * æ ¡éªŒ å·¥åºŸ + æ–™åºŸ + å…¶ä»–废品 ä¸è¶…过不合格品数量
     */
    private void validateScrapQuantity(BigDecimal unqualified, BigDecimal laborScrap,
                                       BigDecimal materialScrap, BigDecimal otherScrap) {
        BigDecimal scrapSum = laborScrap.add(materialScrap).add(otherScrap);
        if (scrapSum.compareTo(unqualified) <= 0) {
            return;
        }
        throw new IllegalArgumentException("工废、料废、其他废品数量之和不能超过不合格品数量:不合格品数量为 "
                + unqualified.stripTrailingZeros().toPlainString() + ",而 å·¥åºŸ " + laborScrap.stripTrailingZeros().toPlainString()
                + " + æ–™åºŸ " + materialScrap.stripTrailingZeros().toPlainString() + " + å…¶ä»–废品 "
                + otherScrap.stripTrailingZeros().toPlainString() + " = " + scrapSum.stripTrailingZeros().toPlainString()
                + "。请修改模板中的废品数量或不合格品数量后重新导入");
    }
    /**
     * å¯¼å…¥åŽ»é‡é”®ï¼šåŒä¸€ ç‰©æ–™ + æ‰¹å· + æ£€éªŒç±»åž‹ + æ£€æµ‹æ—¥æœŸ è§†ä¸ºåŒä¸€æ‰¹è¿‡ç¨‹æ£€éªŒæ•°æ®
     */
    private String buildImportKey(MesQcIpqcImportExcelVO row) {
        return String.join("|", StrUtil.trimToEmpty(row.getItemCode()), StrUtil.trimToEmpty(row.getBatchCode()),
                StrUtil.trimToEmpty(row.getType()), StrUtil.trimToEmpty(row.getInspectDate()));
    }
    private String buildImportKeyDesc(MesQcIpqcImportExcelVO row) {
        return "物料[" + MesQcImportHelper.describeImportValue(row.getItemCode())
                + "]、批号[" + MesQcImportHelper.describeImportValue(row.getBatchCode())
                + "]、检验类型[" + MesQcImportHelper.describeImportValue(row.getType())
                + "]、检测日期[" + MesQcImportHelper.describeImportValue(row.getInspectDate()) + "]";
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/iqc/MesQcIqcService.java
@@ -1,8 +1,10 @@
package cn.iocoder.yudao.module.mes.service.qc.iqc;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.mes.controller.admin.qc.iqc.vo.MesQcIqcImportExcelVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.iqc.vo.MesQcIqcPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.iqc.vo.MesQcIqcSaveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.vo.MesQcImportRespVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.defectrecord.MesQcDefectRecordDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.iqc.MesQcIqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.arrivalnotice.MesWmArrivalNoticeLineDO;
@@ -107,4 +109,12 @@
     */
    Long createIqcFromArrivalNotice(Long noticeId, MesWmArrivalNoticeLineDO line, String noticeCode, Long vendorId);
    /**
     * æ‰¹é‡å¯¼å…¥æ¥æ–™æ£€éªŒå•
     *
     * @param importList å¯¼å…¥è¡Œåˆ—表
     * @return å¯¼å…¥ç»“果(成功编号列表 + é€è¡Œå¤±è´¥åŽŸå› ï¼‰
     */
    MesQcImportRespVO importIqcList(List<MesQcIqcImportExcelVO> importList);
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/iqc/MesQcIqcServiceImpl.java
@@ -2,21 +2,28 @@
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.hutool.core.util.ObjectUtil;
import cn.iocoder.yudao.module.mes.controller.admin.qc.iqc.vo.MesQcIqcImportExcelVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.iqc.vo.MesQcIqcPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.iqc.vo.MesQcIqcSaveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.vo.MesQcImportRespVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.defectrecord.MesQcDefectRecordDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.iqc.MesQcIqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.template.MesQcTemplateItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.arrivalnotice.MesWmArrivalNoticeLineDO;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.iqc.MesQcIqcMapper;
import cn.iocoder.yudao.module.mes.enums.DictTypeConstants;
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.md.autocode.MesMdAutoCodeRuleCodeEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcDefectLevelEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcStatusEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcTypeEnum;
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.indicatorresult.MesQcIndicatorResultService;
import cn.iocoder.yudao.module.mes.service.qc.template.MesQcTemplateItemService;
import cn.iocoder.yudao.module.mes.service.wm.arrivalnotice.MesWmArrivalNoticeService;
@@ -79,6 +86,9 @@
    @Resource
    @Lazy
    private MesQcNcrService ncrService;
    @Resource
    private MesQcImportHelper qcImportHelper;
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -422,4 +432,115 @@
        return "物料ID:" + itemId;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public MesQcImportRespVO importIqcList(List<MesQcIqcImportExcelVO> importList) {
        MesQcImportRespVO respVO = MesQcImportRespVO.builder()
                .createList(new ArrayList<>()).failureList(new LinkedHashMap<>()).build();
        if (CollUtil.isEmpty(importList)) {
            return respVO;
        }
        qcImportHelper.startImport();
        Map<String, Integer> firstRowNoByKey = new HashMap<>();
        for (int i = 0; i < importList.size(); i++) {
            MesQcIqcImportExcelVO row = importList.get(i);
            if (isImportRowEmpty(row)) {
                continue; // è·³è¿‡ç©ºè¡Œ
            }
            String label = StrUtil.isNotBlank(row.getCode()) ? "检验单编号 " + row.getCode().trim()
                    : "第 " + (i + 2) + " è¡Œ";
            String importKey = buildImportKey(row);
            try {
                Integer firstRowNo = firstRowNoByKey.get(importKey);
                if (firstRowNo != null) {
                    throw MesQcImportHelper.duplicateRowException(firstRowNo, buildImportKeyDesc(row));
                }
                respVO.getCreateList().add(createIqcByImport(row));
                firstRowNoByKey.putIfAbsent(importKey, i + 2);
            } catch (Exception ex) {
                respVO.getFailureList().put(label, MesQcImportHelper.resolveImportErrorMessage(ex));
            }
        }
        return respVO;
    }
    private boolean isImportRowEmpty(MesQcIqcImportExcelVO row) {
        return row == null || (StrUtil.isBlank(row.getCode()) && StrUtil.isBlank(row.getName())
                && StrUtil.isBlank(row.getItemCode()) && StrUtil.isBlank(row.getVendorName())
                && StrUtil.isBlank(row.getVendorBatch()) && StrUtil.isBlank(row.getTemplateName())
                && row.getReceivedQuantity() == null && row.getQualifiedQuantity() == null
                && row.getUnqualifiedQuantity() == null && StrUtil.isBlank(row.getCheckResult())
                && StrUtil.isBlank(row.getInspectorName()) && StrUtil.isBlank(row.getInspectDate())
                && StrUtil.isBlank(row.getReceiveDate()) && StrUtil.isBlank(row.getRemark()));
    }
    /**
     * ä¾æ®å¯¼å…¥è¡Œåˆ›å»ºä¸€å¼ æ¥æ–™æ£€éªŒå•:状态为「待检验」,检验行由质检方案自动生成
     *
     * @return æ£€éªŒå•编号
     */
    private String createIqcByImport(MesQcIqcImportExcelVO row) {
        MesQcIqcSaveReqVO reqVO = new MesQcIqcSaveReqVO();
        // 1. æ£€éªŒå•编号:填写则使用,留空自动生成
        String code = StrUtil.isNotBlank(row.getCode()) ? row.getCode().trim()
                : qcImportHelper.generateCode(MesMdAutoCodeRuleCodeEnum.QC_IQC_CODE.getCode());
        if (iqcMapper.selectByCode(code) != null) {
            throw new IllegalArgumentException("检验单编号已存在:" + code
                    + "。请修改模板中的「检验单编号」,或先在系统中删除该编号的检验单后重新导入");
        }
        reqVO.setCode(code);
        // 2. åç§°ã€ç‰©æ–™ã€ä¾›åº”商、质检方案
        if (StrUtil.isBlank(row.getName())) {
            throw new IllegalArgumentException("检验单名称不能为空:请填写模板中该行的「检验单名称」列");
        }
        reqVO.setName(row.getName().trim());
        reqVO.setItemId(qcImportHelper.resolveItemId(row.getItemCode()));
        reqVO.setVendorId(qcImportHelper.resolveVendorId(row.getVendorName()));
        reqVO.setVendorBatch(StrUtil.trimToNull(row.getVendorBatch()));
        reqVO.setTemplateId(qcImportHelper.resolveTemplateId(row.getTemplateName()));
        // 3. æ•°é‡ï¼šåˆæ ¼å“ + ä¸åˆæ ¼å“ å¿…须等于本次接收数量(接收数量留空则自动合计)
        BigDecimal qualified = qcImportHelper.resolveQuantity(row.getQualifiedQuantity(), "合格品数量", true);
        BigDecimal unqualified = qcImportHelper.resolveQuantity(row.getUnqualifiedQuantity(), "不合格品数量", true);
        reqVO.setQualifiedQuantity(qualified);
        reqVO.setUnqualifiedQuantity(unqualified);
        reqVO.setReceivedQuantity(qcImportHelper.resolveCheckQuantity(
                row.getReceivedQuantity(), qualified, unqualified, "本次接收数量"));
        // 4. æ£€éªŒä¿¡æ¯
        reqVO.setCheckResult(qcImportHelper.resolveDictValue(row.getCheckResult(),
                DictTypeConstants.MES_QC_CHECK_RESULT, "检测结果", false));
        reqVO.setInspectorUserId(qcImportHelper.resolveInspectorUserId(row.getInspectorName()));
        reqVO.setInspectDate(qcImportHelper.parseRequiredDateTime(row.getInspectDate(), "检测日期"));
        reqVO.setReceiveDate(qcImportHelper.parseRequiredDateTime(row.getReceiveDate(), "来料日期"));
        reqVO.setRemark(StrUtil.trimToNull(row.getRemark()));
        // 5. åŽ»é‡ï¼šåŒä¸€ ç‰©æ–™ + ä¾›åº”商 + ä¾›åº”商批次 + æ¥æ–™æ—¥æœŸ çš„æ¥æ–™æ£€éªŒå•不可重复导入
        MesQcIqcDO duplicate = CollUtil.getFirst(iqcMapper.selectListByImportKey(reqVO.getItemId(), reqVO.getVendorId(),
                reqVO.getVendorBatch(), reqVO.getReceiveDate()));
        if (duplicate != null) {
            throw MesQcImportHelper.duplicateExistingException(buildImportKeyDesc(row), "来料检验单",
                    duplicate.getCode(), "质量管理-来料检验");
        }
        // 6. å¤ç”¨åˆ›å»ºé€»è¾‘:校验物料/供应商/人员、按物料匹配质检方案、生成检验行
        try {
            createIqc(reqVO);
        } catch (ServiceException ex) {
            MesQcImportHelper.rethrowTemplateError(ex, row.getItemCode(), "来料检验(IQC)");
        }
        return code;
    }
    /**
     * å¯¼å…¥åŽ»é‡é”®ï¼šåŒä¸€ ç‰©æ–™ + ä¾›åº”商 + ä¾›åº”商批次 + æ¥æ–™æ—¥æœŸ è§†ä¸ºåŒä¸€æ‰¹æ¥æ–™æ•°æ®
     */
    private String buildImportKey(MesQcIqcImportExcelVO row) {
        return String.join("|", StrUtil.trimToEmpty(row.getItemCode()), StrUtil.trimToEmpty(row.getVendorName()),
                StrUtil.trimToEmpty(row.getVendorBatch()), StrUtil.trimToEmpty(row.getReceiveDate()));
    }
    private String buildImportKeyDesc(MesQcIqcImportExcelVO row) {
        return "物料[" + MesQcImportHelper.describeImportValue(row.getItemCode())
                + "]、供应商[" + MesQcImportHelper.describeImportValue(row.getVendorName())
                + "]、供应商批次[" + MesQcImportHelper.describeImportValue(row.getVendorBatch())
                + "]、来料日期[" + MesQcImportHelper.describeImportValue(row.getReceiveDate()) + "]";
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ledger/MesQcInspectionLedgerService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,22 @@
package cn.iocoder.yudao.module.mes.service.qc.ledger;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ledger.vo.MesQcInspectionLedgerPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ledger.vo.MesQcInspectionLedgerRespVO;
/**
 * MES æ£€æµ‹ç»“果台账 Service æŽ¥å£
 *
 * @author è¶…级管理员
 */
public interface MesQcInspectionLedgerService {
    /**
     * èŽ·å¾—æ£€æµ‹ç»“æžœå°è´¦åˆ†é¡µï¼ˆå››ç±»æ£€éªŒå•ç»Ÿä¸€æŸ¥è¯¢ï¼Œä¸€è¡Œä¸€ä¸ªæ£€æµ‹é¡¹ç»“æžœï¼‰
     *
     * @param pageReqVO åˆ†é¡µæŸ¥è¯¢
     * @return å°è´¦åˆ†é¡µ
     */
    PageResult<MesQcInspectionLedgerRespVO> getInspectionLedgerPage(MesQcInspectionLedgerPageReqVO pageReqVO);
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ledger/MesQcInspectionLedgerServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,75 @@
package cn.iocoder.yudao.module.mes.service.qc.ledger;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ledger.vo.MesQcInspectionLedgerPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ledger.vo.MesQcInspectionLedgerRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.vo.MesQcQualityExportVO;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.ledger.MesQcInspectionLedgerMapper;
import cn.iocoder.yudao.module.mdm.api.item.MdmItemApi;
import cn.iocoder.yudao.module.mdm.api.item.dto.MdmItemRespDTO;
import cn.iocoder.yudao.module.mdm.api.unit.MdmUnitMeasureApi;
import cn.iocoder.yudao.module.mdm.api.unit.dto.MdmUnitMeasureRespDTO;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import com.baomidou.mybatisplus.core.metadata.IPage;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.util.List;
import java.util.Map;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen;
/**
 * MES æ£€æµ‹ç»“果台账 Service å®žçŽ°ç±»
 *
 * @author è¶…级管理员
 */
@Service
@Validated
public class MesQcInspectionLedgerServiceImpl implements MesQcInspectionLedgerService {
    @Resource
    private MesQcInspectionLedgerMapper ledgerMapper;
    @Resource
    private MdmItemApi mdmItemApi;
    @Resource
    private MdmUnitMeasureApi mdmUnitMeasureApi;
    @Resource
    private AdminUserApi adminUserApi;
    @Override
    public PageResult<MesQcInspectionLedgerRespVO> getInspectionLedgerPage(MesQcInspectionLedgerPageReqVO pageReqVO) {
        // åˆ†é¡µæŸ¥è¯¢ï¼ˆå››ç±»æ£€éªŒå• UNION ALL)
        IPage<MesQcInspectionLedgerRespVO> page = MyBatisUtils.buildPage(pageReqVO);
        ledgerMapper.selectInspectionLedgerPage(page, pageReqVO);
        List<MesQcInspectionLedgerRespVO> list = page.getRecords();
        if (CollUtil.isEmpty(list)) {
            return PageResult.empty(page.getTotal());
        }
        // æ‰¹é‡æŸ¥è¯¢ç‰©æ–™ã€è®¡é‡å•位、检测人员,并拼装判定依据(物料与计量单位均来自 MDM)
        Map<Long, MdmItemRespDTO> itemMap = mdmItemApi.getItemMap(convertSet(list, MesQcInspectionLedgerRespVO::getItemId));
        Map<Long, MdmUnitMeasureRespDTO> unitMeasureMap = mdmUnitMeasureApi.getUnitMeasureMap(
                convertSet(itemMap.values(), MdmItemRespDTO::getUnitMeasureId));
        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(
                convertSet(list, MesQcInspectionLedgerRespVO::getInspectorUserId));
        for (MesQcInspectionLedgerRespVO vo : list) {
            findAndThen(itemMap, vo.getItemId(), item -> {
                vo.setItemCode(item.getCode());
                vo.setItemName(item.getName());
                vo.setItemSpecification(item.getSpecification());
                findAndThen(unitMeasureMap, item.getUnitMeasureId(), unit -> vo.setUnitName(unit.getName()));
            });
            findAndThen(userMap, vo.getInspectorUserId(), user -> vo.setInspectorName(user.getNickname()));
            vo.setJudgementBasis(MesQcQualityExportVO.buildRangeText(
                    vo.getStandardValue(), vo.getMinThreshold(), vo.getMaxThreshold()));
        }
        return new PageResult<>(list, page.getTotal());
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/oqc/MesQcOqcService.java
@@ -1,8 +1,10 @@
package cn.iocoder.yudao.module.mes.service.qc.oqc;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.mes.controller.admin.qc.oqc.vo.MesQcOqcImportExcelVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.oqc.vo.MesQcOqcPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.oqc.vo.MesQcOqcSaveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.vo.MesQcImportRespVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.defectrecord.MesQcDefectRecordDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.oqc.MesQcOqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesLineDO;
@@ -89,4 +91,12 @@
     */
    Long createOqcFromProductSales(Long salesId, MesWmProductSalesLineDO line, String salesCode, Long clientId);
    /**
     * æ‰¹é‡å¯¼å…¥å‡ºè´§æ£€éªŒå•
     *
     * @param importList å¯¼å…¥è¡Œåˆ—表
     * @return å¯¼å…¥ç»“果(成功编号列表 + é€è¡Œå¤±è´¥åŽŸå› ï¼‰
     */
    MesQcImportRespVO importOqcList(List<MesQcOqcImportExcelVO> importList);
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/oqc/MesQcOqcServiceImpl.java
@@ -2,19 +2,26 @@
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.mes.controller.admin.qc.oqc.vo.MesQcOqcImportExcelVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.oqc.vo.MesQcOqcPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.oqc.vo.MesQcOqcSaveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.vo.MesQcImportRespVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.defectrecord.MesQcDefectRecordDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.oqc.MesQcOqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.template.MesQcTemplateItemDO;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.oqc.MesQcOqcMapper;
import cn.iocoder.yudao.module.mes.enums.DictTypeConstants;
import cn.iocoder.yudao.module.mes.enums.md.autocode.MesMdAutoCodeRuleCodeEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcDefectLevelEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcStatusEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcTypeEnum;
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
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.indicatorresult.MesQcIndicatorResultService;
import cn.iocoder.yudao.module.mes.service.qc.template.MesQcTemplateItemService;
import cn.iocoder.yudao.module.mes.service.wm.productsales.MesWmProductSalesLineService;
@@ -35,7 +42,11 @@
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
@@ -52,6 +63,9 @@
    @Resource
    private MesQcOqcMapper oqcMapper;
    @Resource
    private MesQcImportHelper qcImportHelper;
    @Resource
    private MesQcTemplateItemService templateItemService;
@@ -397,4 +411,124 @@
        return "物料ID:" + itemId;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public MesQcImportRespVO importOqcList(List<MesQcOqcImportExcelVO> importList) {
        MesQcImportRespVO respVO = MesQcImportRespVO.builder()
                .createList(new ArrayList<>()).failureList(new LinkedHashMap<>()).build();
        if (CollUtil.isEmpty(importList)) {
            return respVO;
        }
        qcImportHelper.startImport();
        Map<String, Integer> firstRowNoByKey = new HashMap<>();
        for (int i = 0; i < importList.size(); i++) {
            MesQcOqcImportExcelVO row = importList.get(i);
            if (isImportRowEmpty(row)) {
                continue; // è·³è¿‡ç©ºè¡Œ
            }
            String label = StrUtil.isNotBlank(row.getCode()) ? "检验单编号 " + row.getCode().trim()
                    : "第 " + (i + 2) + " è¡Œ";
            String importKey = buildImportKey(row);
            try {
                Integer firstRowNo = firstRowNoByKey.get(importKey);
                if (firstRowNo != null) {
                    throw MesQcImportHelper.duplicateRowException(firstRowNo, buildImportKeyDesc(row));
                }
                respVO.getCreateList().add(createOqcByImport(row));
                firstRowNoByKey.putIfAbsent(importKey, i + 2);
            } catch (Exception ex) {
                respVO.getFailureList().put(label, MesQcImportHelper.resolveImportErrorMessage(ex));
            }
        }
        return respVO;
    }
    private boolean isImportRowEmpty(MesQcOqcImportExcelVO row) {
        return row == null || (StrUtil.isBlank(row.getCode()) && StrUtil.isBlank(row.getName())
                && StrUtil.isBlank(row.getItemCode()) && StrUtil.isBlank(row.getClientName())
                && StrUtil.isBlank(row.getBatchCode()) && StrUtil.isBlank(row.getTemplateName())
                && row.getOutQuantity() == null && row.getCheckQuantity() == null
                && row.getQualifiedQuantity() == null && row.getUnqualifiedQuantity() == null
                && StrUtil.isBlank(row.getCheckResult()) && StrUtil.isBlank(row.getInspectorName())
                && StrUtil.isBlank(row.getInspectDate()) && StrUtil.isBlank(row.getOutDate())
                && StrUtil.isBlank(row.getRemark()));
    }
    /**
     * ä¾æ®å¯¼å…¥è¡Œåˆ›å»ºä¸€å¼ å‡ºè´§æ£€éªŒå•:状态为「待检验」,检验行由质检方案自动生成
     *
     * æœ¬åˆ†æ”¯çš„出货检验单不强制关联客户、不强制填写出货数量与出货日期,故这三列留空不报错。
     *
     * @return æ£€éªŒå•编号
     */
    private String createOqcByImport(MesQcOqcImportExcelVO row) {
        MesQcOqcSaveReqVO reqVO = new MesQcOqcSaveReqVO();
        // 1. æ£€éªŒå•编号:填写则使用,留空自动生成
        String code = StrUtil.isNotBlank(row.getCode()) ? row.getCode().trim()
                : qcImportHelper.generateCode(MesMdAutoCodeRuleCodeEnum.QC_OQC_CODE.getCode());
        if (oqcMapper.selectByCode(code) != null) {
            throw new IllegalArgumentException("检验单编号已存在:" + code
                    + "。请修改模板中的「检验单编号」,或先在系统中删除该编号的检验单后重新导入");
        }
        reqVO.setCode(code);
        // 2. åç§°ã€ç‰©æ–™ã€å®¢æˆ·ã€æ‰¹æ¬¡ã€è´¨æ£€æ–¹æ¡ˆ
        if (StrUtil.isBlank(row.getName())) {
            throw new IllegalArgumentException("检验单名称不能为空:请填写模板中该行的「检验单名称」列");
        }
        reqVO.setName(row.getName().trim());
        reqVO.setItemId(qcImportHelper.resolveItemId(row.getItemCode()));
        Long clientId = qcImportHelper.resolveClientId(row.getClientName());
        if (clientId != null) {
            // æœªå¡«å®¢æˆ·æ—¶ä¸æ ¡éªŒæ•°æ®æƒé™ï¼ˆå•据不属于任何客户)
            qcImportHelper.validateClientPermission(clientId, row.getClientName().trim());
        }
        reqVO.setClientId(clientId);
        reqVO.setBatchCode(StrUtil.trimToNull(row.getBatchCode()));
        reqVO.setTemplateId(qcImportHelper.resolveTemplateId(row.getTemplateName()));
        // 3. æ•°é‡ï¼šåˆæ ¼å“ + ä¸åˆæ ¼å“ å¿…须等于检测数量(检测数量留空则自动合计);出货数量可留空
        reqVO.setOutQuantity(qcImportHelper.resolveQuantity(row.getOutQuantity(), "本次出货数量", false));
        BigDecimal qualified = qcImportHelper.resolveQuantity(row.getQualifiedQuantity(), "合格品数量", true);
        BigDecimal unqualified = qcImportHelper.resolveQuantity(row.getUnqualifiedQuantity(), "不合格品数量", true);
        reqVO.setQualifiedQuantity(qualified);
        reqVO.setUnqualifiedQuantity(unqualified);
        reqVO.setCheckQuantity(qcImportHelper.resolveCheckQuantity(
                row.getCheckQuantity(), qualified, unqualified, "检测数量"));
        // 4. æ£€éªŒä¿¡æ¯
        reqVO.setCheckResult(qcImportHelper.resolveDictValue(row.getCheckResult(),
                DictTypeConstants.MES_QC_CHECK_RESULT, "检测结果", false));
        reqVO.setInspectorUserId(qcImportHelper.resolveInspectorUserId(row.getInspectorName()));
        reqVO.setInspectDate(qcImportHelper.parseRequiredDateTime(row.getInspectDate(), "检测日期"));
        reqVO.setOutDate(qcImportHelper.parseDateTime(row.getOutDate(), "出货日期"));
        reqVO.setRemark(StrUtil.trimToNull(row.getRemark()));
        // 5. åŽ»é‡ï¼šåŒä¸€ ç‰©æ–™ + å®¢æˆ· + æ‰¹æ¬¡å· + å‡ºè´§æ—¥æœŸ çš„出货检验单不可重复导入
        MesQcOqcDO duplicate = CollUtil.getFirst(oqcMapper.selectListByImportKey(reqVO.getItemId(), reqVO.getClientId(),
                reqVO.getBatchCode(), reqVO.getOutDate()));
        if (duplicate != null) {
            throw MesQcImportHelper.duplicateExistingException(buildImportKeyDesc(row), "出货检验单",
                    duplicate.getCode(), "质量管理-出货检验");
        }
        // 6. å¤ç”¨åˆ›å»ºé€»è¾‘:校验物料/人员、按物料匹配质检方案、生成检验行
        try {
            createOqc(reqVO);
        } catch (ServiceException ex) {
            MesQcImportHelper.rethrowTemplateError(ex, row.getItemCode(), "出货检验(OQC)");
        }
        return code;
    }
    /**
     * å¯¼å…¥åŽ»é‡é”®ï¼šåŒä¸€ ç‰©æ–™ + å®¢æˆ· + æ‰¹æ¬¡å· + å‡ºè´§æ—¥æœŸ è§†ä¸ºåŒä¸€æ‰¹å‡ºè´§æ£€éªŒæ•°æ®
     */
    private String buildImportKey(MesQcOqcImportExcelVO row) {
        return String.join("|", StrUtil.trimToEmpty(row.getItemCode()), StrUtil.trimToEmpty(row.getClientName()),
                StrUtil.trimToEmpty(row.getBatchCode()), StrUtil.trimToEmpty(row.getOutDate()));
    }
    private String buildImportKeyDesc(MesQcOqcImportExcelVO row) {
        return "物料[" + MesQcImportHelper.describeImportValue(row.getItemCode())
                + "]、客户[" + MesQcImportHelper.describeImportValue(row.getClientName())
                + "]、批次号[" + MesQcImportHelper.describeImportValue(row.getBatchCode())
                + "]、出货日期[" + MesQcImportHelper.describeImportValue(row.getOutDate()) + "]";
    }
}
yudao-module-mes/src/main/resources/mapper/qc/ledger/MesQcInspectionLedgerMapper.xml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,176 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cn.iocoder.yudao.module.mes.dal.mysql.qc.ledger.MesQcInspectionLedgerMapper">
    <!--
        æ£€æµ‹ç»“果台账查询(四类检验单 UNION ALL)
        ä¸€è¡Œ = ä¸€ä¸ªæ£€æµ‹é¡¹ç»“果;检验单未录入检验结果时该单占一行,检测项相关列为空(LEFT JOIN ä¿è¯ä¸ä¸¢å•)。
        è¯´æ˜Žï¼šæ£€éªŒç»“果的关联键为 (qc_id, qc_type),qc_id ä¸ºå„检验单自身的 id,需按类型过滤 qc_type,否则不同类型的 id ä¼šä¸²å•。
        è¯´æ˜Žï¼šæ£€æµ‹é¡¹åç§°ä¸åšé€»è¾‘删除过滤,已停用/删除的检测项仍展示历史台账名称。
    -->
    <select id="selectInspectionLedgerPage" resultType="cn.iocoder.yudao.module.mes.controller.admin.qc.ledger.vo.MesQcInspectionLedgerRespVO">
        SELECT * FROM (
            <!-- 1. æ¥æ–™æ£€éªŒï¼ˆIQC),批次取供应商批次号 -->
            SELECT
                ${@cn.iocoder.yudao.module.mes.enums.qc.MesQcTypeEnum@IQC.type} AS qcType,
                q.id AS qcId,
                q.code AS code,
                q.name AS name,
                q.status AS status,
                q.item_id AS itemId,
                q.vendor_batch AS batchNo,
                q.check_quantity AS checkQuantity,
                q.qualified_quantity AS qualifiedQuantity,
                q.unqualified_quantity AS unqualifiedQuantity,
                q.check_result AS checkResult,
                q.inspect_date AS inspectDate,
                q.inspector_user_id AS inspectorUserId,
                q.remark AS remark,
                d.indicator_id AS indicatorId,
                i.name AS indicatorName,
                d.value AS indicatorValue,
                d.remark AS indicatorRemark,
                l.standard_value AS standardValue,
                l.min_threshold AS minThreshold,
                l.max_threshold AS maxThreshold
            FROM mes_qc_iqc q
                LEFT JOIN mes_qc_indicator_result r ON r.qc_id = q.id
                    AND r.qc_type = ${@cn.iocoder.yudao.module.mes.enums.qc.MesQcTypeEnum@IQC.type}
                    AND r.deleted = 0
                LEFT JOIN mes_qc_indicator_result_detail d ON d.result_id = r.id AND d.deleted = 0
                LEFT JOIN mes_qc_indicator i ON i.id = d.indicator_id
                LEFT JOIN mes_qc_iqc_line l ON l.iqc_id = q.id AND l.indicator_id = d.indicator_id AND l.deleted = 0
            WHERE q.deleted = 0
            UNION ALL
            <!-- 2. è¿‡ç¨‹æ£€éªŒï¼ˆIPQC),批次号取过程的批号 -->
            SELECT
                ${@cn.iocoder.yudao.module.mes.enums.qc.MesQcTypeEnum@IPQC.type} AS qcType,
                q.id AS qcId,
                q.code AS code,
                q.name AS name,
                q.status AS status,
                q.item_id AS itemId,
                q.batch_code AS batchNo,
                q.check_quantity AS checkQuantity,
                q.qualified_quantity AS qualifiedQuantity,
                q.unqualified_quantity AS unqualifiedQuantity,
                q.check_result AS checkResult,
                q.inspect_date AS inspectDate,
                q.inspector_user_id AS inspectorUserId,
                q.remark AS remark,
                d.indicator_id AS indicatorId,
                i.name AS indicatorName,
                d.value AS indicatorValue,
                d.remark AS indicatorRemark,
                l.standard_value AS standardValue,
                l.min_threshold AS minThreshold,
                l.max_threshold AS maxThreshold
            FROM mes_qc_ipqc q
                LEFT JOIN mes_qc_indicator_result r ON r.qc_id = q.id
                    AND r.qc_type = ${@cn.iocoder.yudao.module.mes.enums.qc.MesQcTypeEnum@IPQC.type}
                    AND r.deleted = 0
                LEFT JOIN mes_qc_indicator_result_detail d ON d.result_id = r.id AND d.deleted = 0
                LEFT JOIN mes_qc_indicator i ON i.id = d.indicator_id
                LEFT JOIN mes_qc_ipqc_line l ON l.ipqc_id = q.id AND l.indicator_id = d.indicator_id AND l.deleted = 0
            WHERE q.deleted = 0
            UNION ALL
            <!-- 3. å‡ºè´§æ£€éªŒï¼ˆOQC),批次取批次号 -->
            SELECT
                ${@cn.iocoder.yudao.module.mes.enums.qc.MesQcTypeEnum@OQC.type} AS qcType,
                q.id AS qcId,
                q.code AS code,
                q.name AS name,
                q.status AS status,
                q.item_id AS itemId,
                q.batch_code AS batchNo,
                q.check_quantity AS checkQuantity,
                q.qualified_quantity AS qualifiedQuantity,
                q.unqualified_quantity AS unqualifiedQuantity,
                q.check_result AS checkResult,
                q.inspect_date AS inspectDate,
                q.inspector_user_id AS inspectorUserId,
                q.remark AS remark,
                d.indicator_id AS indicatorId,
                i.name AS indicatorName,
                d.value AS indicatorValue,
                d.remark AS indicatorRemark,
                l.standard_value AS standardValue,
                l.min_threshold AS minThreshold,
                l.max_threshold AS maxThreshold
            FROM mes_qc_oqc q
                LEFT JOIN mes_qc_indicator_result r ON r.qc_id = q.id
                    AND r.qc_type = ${@cn.iocoder.yudao.module.mes.enums.qc.MesQcTypeEnum@OQC.type}
                    AND r.deleted = 0
                LEFT JOIN mes_qc_indicator_result_detail d ON d.result_id = r.id AND d.deleted = 0
                LEFT JOIN mes_qc_indicator i ON i.id = d.indicator_id
                LEFT JOIN mes_qc_oqc_line l ON l.oqc_id = q.id AND l.indicator_id = d.indicator_id AND l.deleted = 0
            WHERE q.deleted = 0
            UNION ALL
            <!-- 4. é€€è´§æ£€éªŒï¼ˆRQC),批次取批次号 -->
            SELECT
                ${@cn.iocoder.yudao.module.mes.enums.qc.MesQcTypeEnum@RQC.type} AS qcType,
                q.id AS qcId,
                q.code AS code,
                q.name AS name,
                q.status AS status,
                q.item_id AS itemId,
                q.batch_code AS batchNo,
                q.check_quantity AS checkQuantity,
                q.qualified_quantity AS qualifiedQuantity,
                q.unqualified_quantity AS unqualifiedQuantity,
                q.check_result AS checkResult,
                q.inspect_date AS inspectDate,
                q.inspector_user_id AS inspectorUserId,
                q.remark AS remark,
                d.indicator_id AS indicatorId,
                i.name AS indicatorName,
                d.value AS indicatorValue,
                d.remark AS indicatorRemark,
                l.standard_value AS standardValue,
                l.min_threshold AS minThreshold,
                l.max_threshold AS maxThreshold
            FROM mes_qc_rqc q
                LEFT JOIN mes_qc_indicator_result r ON r.qc_id = q.id
                    AND r.qc_type = ${@cn.iocoder.yudao.module.mes.enums.qc.MesQcTypeEnum@RQC.type}
                    AND r.deleted = 0
                LEFT JOIN mes_qc_indicator_result_detail d ON d.result_id = r.id AND d.deleted = 0
                LEFT JOIN mes_qc_indicator i ON i.id = d.indicator_id
                LEFT JOIN mes_qc_rqc_line l ON l.rqc_id = q.id AND l.indicator_id = d.indicator_id AND l.deleted = 0
            WHERE q.deleted = 0
        ) t
        <where>
            <if test="reqVO.qcType != null">
                AND t.qcType = #{reqVO.qcType}
            </if>
            <if test="reqVO.inspectDate != null and reqVO.inspectDate.length > 0 and reqVO.inspectDate[0] != null">
                AND t.inspectDate &gt;= #{reqVO.inspectDate[0]}
            </if>
            <if test="reqVO.inspectDate != null and reqVO.inspectDate.length > 1 and reqVO.inspectDate[1] != null">
                AND t.inspectDate &lt;= #{reqVO.inspectDate[1]}
            </if>
            <if test="reqVO.code != null and reqVO.code != ''">
                AND t.code LIKE CONCAT('%', #{reqVO.code}, '%')
            </if>
            <if test="reqVO.itemId != null">
                AND t.itemId = #{reqVO.itemId}
            </if>
            <if test="reqVO.batchNo != null and reqVO.batchNo != ''">
                AND t.batchNo LIKE CONCAT('%', #{reqVO.batchNo}, '%')
            </if>
            <if test="reqVO.checkResult != null">
                AND t.checkResult = #{reqVO.checkResult}
            </if>
            <if test="reqVO.inspectorUserId != null">
                AND t.inspectorUserId = #{reqVO.inspectorUserId}
            </if>
        </where>
        ORDER BY t.inspectDate DESC, t.qcId DESC, t.qcType ASC
    </select>
</mapper>