feat: CRM 发票/合同导入、MES 质检批量导入与检测台账、回款与报价校验、权限码细化

- CRM:发票批量导入、合同导入字段调整、回款必填校验、报价单号可选(留空自动生成/保持原值)
- MES:IQC/IPQC/OQC/RQC 批量导入(导入助手 + 响应 VO)、检测结果台账、物料列表按分类排序、工作站按名称查询
- HRM:考勤/薪酬接口权限码拆分细化、节假日查询条件调整与提示信息带参
- framework:ExcelUtils 新增 readFlat(纵向合并单元格拉平读取),支撑批量导入
- docs:业务可视化更新、联调文档与初始化/菜单 SQL(含合并初始化脚本 config_export_all_20260928.sql)
- 不含本地配置 application-local.yaml
已添加28个文件
已修改53个文件
13874 ■■■■■ 文件已修改
docs/contract_product_field_change.md 104 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/crm_invoice_import_integration.md 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/crm_sale_quotation_custom_no.md 92 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/hrm_attendance_holiday_fix_integration.md 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/mes_md_item_sort_integration.md 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/mes_qc_import_integration.md 157 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/flows.json 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/modules.json 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/objects.json 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/index.html 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/02-sales-flow.mmd 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/05-quality-flow.mmd 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/qc_inspection_ledger_integration.md 160 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/receivable_invoice_link.md 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/config_export_all_20260928.sql 9577 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/crm_invoice_import_menu.sql 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/gen_config_export_all.py 304 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/hrm_holiday_config_fix.sql 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/mes_qc_import_menu.sql 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/qc_inspection_ledger_menu.sql 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/role_permission_sync_20260924.sql 266 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/util/ExcelUtils.java 163 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/CrmContractController.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractImportExcelVO.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractRespVO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | 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/controller/admin/quotation/vo/CrmSaleQuotationSaveReqVO.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/receivable/CrmReceivableSaveReqVO.java 7 ●●●●● 补丁 | 查看 | 原始文档 | 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/dal/mysql/quotation/CrmSaleQuotationMapper.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java 2 ●●●●● 补丁 | 查看 | 原始文档 | 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 44 ●●●●● 补丁 | 查看 | 原始文档 | 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-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationServiceImpl.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableServiceImpl.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/HrmAttendanceExceptionController.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/HrmAttendanceHolidayController.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/HrmAttendanceRecordController.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/HrmAttendanceRuleController.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/HrmAttendanceSummaryController.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceHolidayPageReqVO.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/HrmEmployeeSocialSecurityController.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/HrmSalaryCalculationController.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/HrmSalaryPaymentController.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/HrmSocialSecuritySchemeController.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/HrmTaxRateConfigController.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/attendance/HrmAttendanceHolidayMapper.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceHolidayServiceImpl.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/ipqc/MesQcIpqcController.java 29 ●●●●● 补丁 | 查看 | 原始文档 | 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/md/item/MesMdItemMapper.java 14 ●●●●● 补丁 | 查看 | 原始文档 | 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 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/md/workstation/MesMdWorkstationService.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/md/workstation/MesMdWorkstationServiceImpl.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/imports/MesQcImportHelper.java 405 ●●●●● 补丁 | 查看 | 原始文档 | 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 152 ●●●●● 补丁 | 查看 | 原始文档 | 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 129 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/resources/mapper/qc/ledger/MesQcInspectionLedgerMapper.xml 176 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/contract_product_field_change.md
@@ -151,7 +151,7 @@
<!-- äº§å“æ˜Žç»†è¡¨æ ¼ -->
<el-table-column prop="itemCode" label="物料编码" />
<el-table-column prop="itemName" label="物料名称" />
<el-table-column prop="itemBarCode" label="物料条码" />
<el-table-column prop="barCode" label="物料条码" />
<el-table-column prop="itemUnitName" label="单位" />
<el-table-column prop="itemPrice" label="原价" />
<el-table-column prop="contractPrice" label="合同价" />
@@ -209,3 +209,105 @@
2. **价格可修改**:`contractPrice` é»˜è®¤ç­‰äºŽç‰©æ–™åŽŸä»·ï¼Œä½†å¯ä»¥æ‰‹åŠ¨ä¿®æ”¹
3. **过渡期**:后端暂时兼容 `productId` å’Œ `sellingPrice` å­—段,但后续会移除兼容逻辑
4. **其他模块同步修改**:销售报价单也使用 MDM ç‰©æ–™ï¼Œå­—段与合同一致
---
# 2026-09-24 å¢žé‡ï¼šåˆåŒäº§å“æ˜Žç»†æ˜¾ç¤ºå­—段补全 + åˆ é™¤åˆåŒçº§è”清理明细
## ä¸€ã€æ˜¾ç¤ºå­—段补全
### èƒŒæ™¯
合同详情接口此前未解析产品明细的计量单位名称,条码字段名与前端列绑定不一致,导致以下位置 **单位列、物料条码列恒为空**(商机接口一直正常返回,本次对齐商机做法):
- åˆåŒè¯¦æƒ… â†’「产品」页签(`ProductDetailsList` å…±ç”¨ç»„件)
- åˆåŒå®¡æ‰¹é¡µ â†’ äº§å“æ˜Žç»†è¡¨
- ç¼–辑合同弹窗 â†’ äº§å“è¡¨æ ¼ï¼ˆå·²ä¿å­˜è¡Œæ¬¡çš„规格型号同样为空)
### æ¶‰åŠçš„æŽ¥å£
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | /crm/contract/get | èŽ·å¾—åˆåŒè¯¦æƒ…ï¼ˆå«äº§å“æ˜Žç»†ï¼‰ï¼Œæœ¬æ¬¡å˜æ›´ä»…å½±å“æ­¤æŽ¥å£ |
> `/crm/contract/page`、`/crm/contract/page-by-customer`、`/crm/contract/page-by-business`、导出 Excel å‡**不含**产品明细,不受影响。
### å“åº”字段变更(data.products[])
| å­—段 | ç±»åž‹ | å˜æ›´ | è¯´æ˜Ž |
|------|------|------|------|
| itemUnitName | String | è¡¥å…¨å–值 | è®¡é‡å•位名称,按 `itemUnitId` è§£æžï¼ˆå¦‚「米」「个」),此前恒为 null |
| barCode | String | **字段更名** | ç‰©æ–™æ¡ç ï¼ŒåŽŸå­—æ®µå `itemBarCode` æ”¹ä¸º `barCode`,与商机接口及前端产品明细列表列绑定(field: 'barCode')一致 |
| itemSpecification | String | æ–°å¢ž | è§„格型号,取自 MDM ç‰©æ–™ï¼Œä¸Žå•†æœºæŽ¥å£ä¸€è‡´ |
| itemBarCode | - | å·²ç§»é™¤ | å‰ç«¯å¦‚有引用请改用 `barCode` |
**响应示例(节选):**
```json
{
  "code": 0,
  "data": {
    "id": 19,
    "no": "WTJC2025120501",
    "products": [
      {
        "id": 42,
        "itemId": 38,
        "itemCode": "PHC300AB70-4.5",
        "itemName": "PHC300AB70-4.5",
        "barCode": null,
        "itemSpecification": null,
        "itemUnitId": 3,
        "itemUnitName": "ç±³",
        "itemPrice": 68.000000,
        "contractPrice": 68.000000,
        "count": 179208.000000,
        "totalPrice": 12186144.000000
      }
    ]
  }
}
```
### å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| itemUnitName | åˆåŒè¯¦æƒ…产品页签、审批页产品表、编辑弹窗产品表 | å•位列,取值 MDM è®¡é‡å•位名称 |
| barCode | åˆåŒè¯¦æƒ…产品页签(物料条码列) | ä¸Žå•†æœºäº§å“åˆ—表列绑定字段一致 |
| itemSpecification | ç¼–辑弹窗产品表格 | è¯¦æƒ…页产品页签无此列 |
### ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|------|------|
| å•位名称来源 | ç”± MDM è®¡é‡å•位表按产品明细的 itemUnitId è§£æžï¼›å¯¼å…¥çš„合同,itemUnitId å–自物料主数据的计量单位 |
| æ¡ç å–值 | å–自 MDM ç‰©æ–™æ¡£æ¡ˆçš„æ¡ç ï¼›ç‰©æ–™æœªç»´æŠ¤æ¡ç æ—¶ä¸ºç©ºï¼Œå±žæ­£å¸¸ |
| è§„格型号取值 | å–自 MDM ç‰©æ–™æ¡£æ¡ˆçš„规格型号;未维护时为空 |
| ç¼–辑合同后保存 | äº§å“æ˜Žç»†å›žä¼ æ—¶ barCode / itemSpecification / itemUnitName å±žäºŽå±•示字段,后端不落库,由接口每次按 itemId / itemUnitId å®žæ—¶è§£æžï¼Œä¸å½±å“ä¿å­˜ç»“æžœ |
### æ³¨æ„äº‹é¡¹
- æœ¬æ¬¡ä¸º**响应字段名变更**(itemBarCode â†’ barCode)。前端类型声明 `CrmContractApi.ContractProduct.itemBarCode` å»ºè®®åŒæ­¥æ”¹ä¸º `barCode`(仅类型声明,运行时不读该字段,不改也不报错)。
- å‰ç«¯é¡µé¢ä»£ç **无需改动**:单位列、条码列、规格型号列绑定字段已与本次返回一致。
- åˆåŒã€Œäº§å“ã€é¡µç­¾ã€å®¡æ‰¹é¡µäº§å“è¡¨å¤ç”¨åŒä¸€åˆ—定义,修复后三处同时生效。
---
## äºŒã€åˆ é™¤åˆåŒçº§è”清理产品明细
### èƒŒæ™¯
`DELETE /crm/contract/delete` æ­¤å‰åªåˆ é™¤åˆåŒä¸»è¡¨ã€æŠ¥ä»·å•引用与数据权限,**未删除 `crm_contract_product` äº§å“æ˜Žç»†**,导致删除合同后明细成为孤儿数据(表内 deleted=0 ä½†åˆåŒå·²ä¸å­˜åœ¨ï¼‰ã€‚
### å˜æ›´åŽè¡Œä¸º
| æ“ä½œ | å½±å“æ•°æ® |
|------|----------|
| åˆ é™¤åˆåŒ | åˆåŒä¸»è¡¨ï¼ˆé€»è¾‘删除)+ äº§å“æ˜Žç»†ï¼ˆé€»è¾‘删除)+ æŠ¥ä»·å•引用解除 + æ•°æ®æƒé™åˆ é™¤ |
### æ³¨æ„äº‹é¡¹
- çº§è”删除为**逻辑删除**(deleted=1),与合同主表口径一致,历史数据仍可追溯。
- åˆåŒå·²è¢«å›žæ¬¾å¼•用时依旧禁止删除(原有校验不变)。
- åŽ†å²é—ç•™çš„å­¤å„¿æ˜Žç»†ï¼ˆæœ¬æ¬¡ä¿®å¤å‰åˆ é™¤äº§ç”Ÿçš„ï¼‰ä¸ä¼šè‡ªåŠ¨æ¸…ç†ï¼Œå¦‚éœ€æ¸…ç†è¯·æŒ‰ contract_id æ ¸å¯¹åŽæ‰‹å·¥å¤„理。
docs/crm_invoice_import_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,91 @@
# 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`(菜单已新增:营销管理 -> å¼€ç¥¨ç®¡ç† -> å¼€ç¥¨å¯¼å…¥ï¼Œå¹¶å·²æŽˆäºˆå¼€ç¥¨ç®¡ç†çŽ°æœ‰è§’è‰²ï¼‰ã€‚
**请求参数(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`;该菜单权限已授予角色 2/155/160/161/162,其余自建角色需在「角色管理」中勾选「开票导入」后再使用
- ç©ºè¡Œä¼šè¢«è‡ªåŠ¨è·³è¿‡ï¼Œä¸ç®—å¤±è´¥
docs/crm_sale_quotation_custom_no.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,92 @@
# CRM é”€å”®æŠ¥ä»·å• - æŠ¥ä»·å•号支持自定义(前端联调方案)
> **调整内容**:报价单的「报价单号」由「系统自动生成、不可编辑」改为**支持用户自定义**:
> æ–°å¢žæ—¶ç•™ç©º = ç³»ç»Ÿè‡ªåŠ¨ç”Ÿæˆï¼›å¡«å†™ = ä½¿ç”¨ç”¨æˆ·å¡«å†™çš„编号(需全库唯一)。
> ä¿®æ”¹æ—¶ç•™ç©º = ä¿æŒåŽŸç¼–å·ä¸å˜ï¼›å¡«å†™ = æ›¿æ¢ä¸ºæ–°ç¼–号(需全库唯一)。
> å‰ç«¯éœ€æŠŠè¯¥è¾“入框放开编辑,并把提示文案改为「报价单号自定义」。
## æ¶‰åŠé¡µé¢
- `views/crm/saleQuotation/index.vue` â€” æŠ¥ä»·å•列表页(列表展示的报价单号来源不变,无需改动)
- `views/crm/saleQuotation/modules/form.vue` â€” æŠ¥ä»·å•新增/编辑弹窗(**本次改动主体**)
- `views/crm/saleQuotation/data.ts` â€” è¡¨å•字段配置(**本次改动主体**)
## ä¸šåŠ¡æµç¨‹ä¸Žæ•°æ®å¸¦å…¥
1. **新增报价单**:打开弹窗 â†’ æŠ¥ä»·å•号输入框默认为空 â†’ ç”¨æˆ·å¯å¡«è‡ªå®šä¹‰ç¼–号,也可不填 â†’ æäº¤æ—¶æŠŠ `no` åŽŸæ ·å¸¦ç»™åŽç«¯ã€‚ä¸å¡«ç”±åŽç«¯æŒ‰ç¼–ç è§„åˆ™è‡ªåŠ¨ç”Ÿæˆã€‚
2. **编辑报价单**:打开弹窗 â†’ è°ƒç”¨ `GET /crm/sale-quotation/get?id=` å›žå¡«è¡¨å• â†’ æŠ¥ä»·å•号输入框显示**原编号**(可修改)→ æäº¤æ—¶æŠŠ `no` åŽŸæ ·å¸¦ç»™åŽç«¯ã€‚ä¸å¡«ï¼ˆæ¸…ç©ºï¼‰è¡¨ç¤ºä¿æŒåŽŸç¼–å·ä¸å˜ã€‚
3. **保存成功后**:列表刷新,展示后端最终返回的报价单号(自定义编号或自动生成编号)。
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| POST | `/admin-api/crm/sale-quotation/create` | æ–°å¢žæŠ¥ä»·å•,请求体新增选填 `no` |
| PUT | `/admin-api/crm/sale-quotation/update` | ä¿®æ”¹æŠ¥ä»·å•,请求体新增选填 `no` |
| GET | `/admin-api/crm/sale-quotation/get` | å›žå¡«ç¼–辑表单(`no` å·²åœ¨å“åº”中) |
**请求参数新增:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| no | String | å¦ | æŠ¥ä»·å•号。长度 â‰¤ 32 å­—符;新增留空由系统自动生成;修改留空保持原值。填写时后端校验全库唯一 |
**请求示例:**
```json
{
  "no": "BJ-2026-001",
  "name": "报价单A",
  "customerId": 18336,
  "ownerUserId": 17144,
  "quotationTime": "2026-09-01 00:00:00",
  "items": [
    { "itemId": 20529, "quotationPrice": 123.00, "count": 10, "taxPercent": 13.00 }
  ]
}
```
**错误提示(后端已给出可执行文案):**
| åœºæ™¯ | æç¤º |
|------|------|
| ç¼–号已被占用(含已删除的报价单占用) | æŠ¥ä»·å•号【填写值】已被占用,不能重复使用。请换一个编号,或清空编号由系统自动生成(已删除的报价单也会占用其原编号) |
| ç¼–号超长 | æŠ¥ä»·å•号长度不能超过 32 ä¸ªå­—符 |
## å‰ç«¯ä¿®æ”¹ç‚¹ï¼ˆä»…说明,不含代码)
1. `data.ts` ä¸­ã€ŒæŠ¥ä»·å•号」字段配置:
   - åŽ»æŽ‰è¾“å…¥æ¡†çš„ç¦ç”¨ï¼ˆåŽŸä¸ºä¿å­˜æ—¶è‡ªåŠ¨ç”Ÿæˆã€ä¸å¯ç¼–è¾‘ï¼‰ï¼Œæ”¹ä¸ºå¯ç¼–è¾‘ã€‚
   - æç¤ºæ–‡æ¡ˆç”±ã€Œä¿å­˜æ—¶è‡ªåŠ¨ç”Ÿæˆã€æ”¹ä¸ºã€Œ**报价单号自定义**」,并补充「留空由系统自动生成」的说明。
   - æ ¡éªŒè§„则:非必填;可加长度上限 32 çš„前端提示,避免提交后被后端拦截。
2. `modules/form.vue`:
   - æ–°å¢ž/编辑弹窗打开时,报价单号输入框允许用户输入。
   - ç¼–辑态回填逻辑不变:`GET /crm/sale-quotation/get` å·²è¿”回 `no`,会一并回填到表单。
   - æäº¤é€»è¾‘不变:直接把表单值(含 `no`)提交给 create/update æŽ¥å£ã€‚用户未填写时提交空值即可(后端按「留空」语义处理),无需前端自行生成编号。
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| æŠ¥ä»·å•号 | åˆ—表列、详情、表单输入框 | åŽç«¯è¿”回的最终编号(自定义或自动生成) |
| æŠ¥ä»·å•号(输入框提示) | æ–°å¢ž/编辑表单 | ã€ŒæŠ¥ä»·å•号自定义」,提示留空由系统生成 |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|------|------|
| æ–°å¢žï¼Œç¼–号留空 | åŽç«¯æŒ‰ç¼–码规则(`BJ` + æ—¥æœŸ + åºå·ï¼‰è‡ªåŠ¨ç”Ÿæˆ |
| æ–°å¢žï¼Œå¡«å†™ç¼–号 | ä½¿ç”¨ç”¨æˆ·å¡«å†™çš„编号,需全库唯一 |
| ä¿®æ”¹ï¼Œç¼–号留空 | ä¿æŒåŽŸç¼–å·ä¸å˜ï¼ˆä¸ä¼šè¢«æ¸…ç©ºï¼‰ |
| ä¿®æ”¹ï¼Œå¡«å†™ç¼–号 | æ ¡éªŒå”¯ä¸€åŽæ›¿æ¢ï¼›ä¸ŽåŽŸç¼–å·ç›¸åŒè§†ä¸ºæœªå˜æ›´ï¼Œå…è®¸ä¿å­˜ |
| ç¼–号唯一性范围 | è¦†ç›–**历史已删除**的报价单(数据库唯一索引不含逻辑删除标记),删除报价单后其编号不可再被使用 |
| ç¼–号前后空格 | åŽç«¯æäº¤å‰ä¼šåšé¦–尾去空格处理,纯空格等同留空 |
| éžè‰ç¨¿çŠ¶æ€ | æŠ¥ä»·å•一旦提交/转合同,不允许修改(含编号),维持原有限制 |
## æ³¨æ„äº‹é¡¹
- ã€Œç•™ç©ºã€ä¸Žã€Œå¡«å†™åŽåˆ é™¤å†…容」在后端是同一语义(保持/自动生成),前端无需区分。
- ç¼–号建议使用字母、数字、短横线组合,避免使用特殊字符,便于后续打印与检索;后端仅做长度与唯一性校验。
- ç¼–辑态若原报价单由系统自动生成编号,用户可直接改成自定义编号;改后原编号不会被释放,仍占用。
- æŠ¥ä»·å•号会带入「转合同」生成的合同备注(`由报价单[xxx]生成`),若修改编号需注意历史合同备注中保留的是旧编号。
- æœ¬æ¬¡ä»…后端放开 `no` å­—段与唯一性校验,前端放开输入框即可生效,无需其他接口配合。
docs/hrm_attendance_holiday_fix_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,70 @@
# HRM è€ƒå‹¤èŠ‚å‡æ—¥é…ç½® - å‰ç«¯è”调方案
> é’ˆå¯¹ã€ŒèŠ‚å‡æ—¥è®¾ç½®æœ‰é—®é¢˜ã€çš„ä¿®å¤è¯´æ˜Žã€‚é—®é¢˜å‡ºåœ¨**后端**:权限码与前端/菜单不一致、缺字典、搜索参数不匹配。
> ä¿®å¤åŽ**前端不需要改任何代码**,页面即可正常打开、搜索、新增、编辑、导出。
## æ¶‰åŠé¡µé¢
- `views/hrm/attendance/holiday/index.vue`(考勤 Â· èŠ‚å‡æ—¥é…ç½®ï¼Œèœå•ã€ŒèŠ‚å‡æ—¥é…ç½®ã€ï¼‰
- å…³è”影响页面:排班管理、考勤打卡、考勤统计(都依赖同一份节假日数据)
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | `/hrm/attendance/holiday/page` | åˆ†é¡µæŸ¥è¯¢ï¼ˆå‚数按下表修正) |
| GET | `/hrm/attendance/holiday/get` | è¯¦æƒ…(id) |
| POST | `/hrm/attendance/holiday/create` | æ–°å¢ž |
| PUT | `/hrm/attendance/holiday/update` | ä¿®æ”¹ |
| DELETE | `/hrm/attendance/holiday/delete` | åˆ é™¤ï¼ˆid) |
| GET | `/hrm/attendance/holiday/export-excel` | å¯¼å‡ºï¼ˆä¸Žåˆ†é¡µåŒå‚数) |
**请求参数(分页 / å¯¼å‡ºï¼Œæœ¬æ¬¡ä¿®æ­£ç‚¹åœ¨ã€Œå˜æ›´ã€åˆ—):**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž | å˜æ›´ |
|------|------|------|------|------|
| name | String | å¦ | èŠ‚å‡æ—¥åç§°ï¼Œæ¨¡ç³ŠåŒ¹é… | ä¸å˜ |
| type | Integer | å¦ | ç±»åž‹ï¼š1-休息日,2-工作日 | ä¸å˜ |
| isLegal | Integer | å¦ | æ˜¯å¦æ³•定节假日:0-否,1-是 | **新增支持**(此前前端传了被后端忽略) |
| date | Array | å¦ | æ—¥æœŸåŒºé—´ï¼Œæ ¼å¼ `[开始日期, ç»“束日期]`,如 `["2026-01-01","2026-12-31"]` | **新增支持**,取代原先的 `startDate`/`endDate` |
| pageNo / pageSize | Integer | å¦ | åˆ†é¡µå‚æ•° | ä¸å˜ |
> å‰ç«¯æœç´¢è¡¨å•的「日期」区间控件当前提交的字段名就是 `date`(重复参数形式),修复后与后端一致,**无需改动**。
**权限码(本次修正,前端无需改动):**
| æŽ¥å£ | ä¿®å¤å‰ï¼ˆä¸å­˜åœ¨ï¼Œå¿…ç„¶ 403) | ä¿®å¤åŽï¼ˆä¸Žèœå•节点一致) |
|------|--------------------------|------------------------|
| æ–°å¢ž | `hrm:attendance:create` | `hrm:attendance-holiday:create` |
| ä¿®æ”¹ | `hrm:attendance:update` | `hrm:attendance-holiday:update` |
| åˆ é™¤ | `hrm:attendance:delete` | `hrm:attendance-holiday:delete` |
| è¯¦æƒ… / åˆ†é¡µ | `hrm:attendance:query` | `hrm:attendance-holiday:query`(**菜单中新增该按钮节点**) |
| å¯¼å‡º | `hrm:attendance:export` | `hrm:attendance-holiday:export` |
## å­—典(本次新增,页面自动生效)
| å­—典类型 | å€¼ | æ˜¾ç¤º | é¢œè‰² |
|----------|-----|------|------|
| `hrm_holiday_type` | 1 | ä¼‘息日 | success(绿) |
| `hrm_holiday_type` | 2 | å·¥ä½œæ—¥ | primary(蓝) |
修复前该字典不存在,导致「类型」单选没有任何选项、列表「类型」列显示空白,且类型为必选,新增/编辑**点保存必然校验失败**。
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|------|------|
| æ—¥æœŸå”¯ä¸€ | ä¸€ä¸ªæ—¥æœŸåªèƒ½æœ‰ä¸€æ¡é…ç½®ï¼Œé‡å¤æ–°å¢žä¼šæç¤ºã€Œæ—¥æœŸã€xxx】已存在节假日配置…请改为编辑该日期的记录,或先删除原记录再新增」 |
| ä¿®æ”¹æ—¥æœŸ | ä¿®æ”¹è®°å½•时允许改日期;改后的日期若已被占用,同样按上一条提示 |
| ç±»åž‹å«ä¹‰ | 1-休息日:当天不上班(法定节假日应同时勾选「法定节假日」);2-工作日:当天上班(常用于调休补班) |
| æœªé…ç½®çš„æ—¥æœŸ | ä¸è½åº“、不参与「节假日」列表;系统判定工作日时按「周一至周五算工作日、周末算休息日」兜底 |
| ã€Œæ³•定节假日」 | ä»…作标记与筛选,不影响是否上班的判定 |
| æ•°æ®å¼•用 | æŽ’班计划、考勤打卡、考勤统计均读取这张表:配置为「工作日」的周末会正常生成出勤要求 |
## æ³¨æ„äº‹é¡¹
- åŽç«¯ä»£ç å˜æ›´éœ€è¦**重启后端服务**;菜单与字典变更已直接写入数据库,**相关角色需重新登录**(或刷新权限缓存)后新的「节假日配置查询」按钮权限才生效
- æœ¬æ¬¡ä¿®å¤å‰è¯¥è¡¨é•¿æœŸä¸º 0 è¡Œï¼ˆä¿å­˜è¢«å‰ç«¯å¿…填校验与后端 403 åŒé‡å µæ­»ï¼‰ï¼Œé¡µé¢æ­¤å‰å½•入不下任何数据
- `startDate` / `endDate` ä¸¤ä¸ªæ—§å‚数已移除;如仍有其它调用方在使用,需改用 `date` æ•°ç»„
- åŒä¸€ä¸ªã€Œæƒé™ç ä¸Žèœå•不一致」的缺陷在 HRM è€ƒå‹¤çš„另外 4 ä¸ªé¡µé¢ï¼ˆè€ƒå‹¤è§„则 `hrm:attendance-rule:*`、考勤记录 `hrm:attendance-record:*`、考勤统计 `hrm:attendance-summary:*`、考勤异常 `hrm:attendance-exception:*`)同样存在,**本次未一并修改**,需要时可按同样方式对齐
- å¯¼å‡ºä¸Žåˆ†é¡µå…±ç”¨åŒä¸€å¥—查询参数,修复后导出会带上页面上已选的筛选条件
docs/mes_md_item_sort_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,60 @@
# MES ç‰©æ–™ç®¡ç†åˆ—表排序 - å‰ç«¯è”调方案
> éœ€æ±‚:物料管理列表的排序不清晰(原先按创建先后倒序,同一分类的物料被打散),改为按「物料分类」归类排序。
> æœ¬æ¬¡ä»…调整后端默认排序,**接口路径、请求参数、响应结构均未变化**,前端不改也能立即生效。
## æ¶‰åŠé¡µé¢
- `views/mes/md/item/index.vue`(物料管理列表)
- ç‰©æ–™é€‰æ‹©å¼¹çª—(复用同一分页接口 `/mes/md/item/page`,排序同步生效)
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | `/mes/md/item/page` | ç‰©æ–™äº§å“åˆ†é¡µï¼ˆæŽ’序规则见下,无参数变化) |
**请求参数:** æ— æ–°å¢žã€æ— åˆ é™¤ã€‚
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| code | String | å¦ | ç‰©æ–™ç¼–码,模糊匹配 |
| name | String | å¦ | ç‰©æ–™åç§°ï¼Œæ¨¡ç³ŠåŒ¹é… |
| itemTypeId | Long | å¦ | ç‰©æ–™åˆ†ç±»ç¼–号,**含其所有子分类**(前端搜索表单当前未提供该筛选项,可按需补充) |
| status | Integer | å¦ | çŠ¶æ€ |
## æŽ’序规则
列表默认排序(由后端统一给出,前端无需传排序参数):
| ä¼˜å…ˆçº§ | æŽ’序字段 | æ–¹å‘ | è¯´æ˜Ž |
|--------|----------|------|------|
| 1 | ç‰©æ–™åˆ†ç±»çš„「显示排序」 | å‡åº | åˆ†ç±»ä¹‹é—´æŒ‰åˆ†ç±»ç®¡ç†é‡Œç»´æŠ¤çš„æ˜¾ç¤ºæŽ’序 |
| 2 | ç‰©æ–™åˆ†ç±»ç¼–码 | å‡åº | æ˜¾ç¤ºæŽ’序相同的分类,按分类编码 |
| 3 | ç‰©æ–™ç¼–码 | å‡åº | åŒä¸€åˆ†ç±»å†…部,按物料编码 |
效果:**同一物料分类的物料在列表中连续成组出现**,分类顺序可预期、可调整,不再随创建时间散落。
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| ç‰©æ–™åˆ†ç±» | åˆ—表列「物料分类」 | å·²å±•示,本次不改字段,仅让同一分类的记录相邻 |
| ç‰©æ–™åç§°ã€ç‰©æ–™ç¼–码 | åˆ—表列 | ä»…展示,未加前端点击排序;本次改动只调整默认顺序,不含列点击排序能力 |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|------|------|
| é»˜è®¤æŽ’序 | åˆ†ç±»æ˜¾ç¤ºæŽ’序 â†’ åˆ†ç±»ç¼–码 â†’ ç‰©æ–™ç¼–码 |
| è°ƒæ•´é¡ºåº | åœ¨ã€Œç‰©æ–™åˆ†ç±»ã€ä¸­ä¿®æ”¹åˆ†ç±»çš„「显示排序」,物料列表的顺序随之变化,无需改代码 |
| æœªè®¾ç½®åˆ†ç±»çš„物料 | åˆ†ç±»ä¸ºç©ºï¼ˆå«åˆ†ç±»è¢«åˆ é™¤ï¼‰çš„物料排在最前面,便于发现未归类数据 |
| åˆ†ç±»ç­›é€‰ | æŒ‰åˆ†ç±»ç­›é€‰æ—¶åŒ…含其子分类,排序规则不变 |
| å…¶å®ƒæŸ¥è¯¢æ¡ä»¶ | ç¼–码 / åç§° / çŠ¶æ€ç­›é€‰ä¸å½±å“æŽ’åºè§„åˆ™ |
## æ³¨æ„äº‹é¡¹
- æœ¬æ¬¡æ˜¯åŽç«¯é»˜è®¤æŽ’序变化,**前端不改代码即可看到效果**,需重启后端服务生效
- åŽŸæ¥æŒ‰ã€Œåˆ›å»ºæ—¶é—´å€’åºã€çš„æŽ’åºå·²å–æ¶ˆï¼›å¦‚éœ€æ¢å¤ã€Œæœ€æ–°åˆ›å»ºçš„æŽ’æœ€å‰ã€ï¼Œæˆ–éœ€è¦ç‚¹å‡»è¡¨å¤´æŽ’åºï¼Œå±žæ–°å¢žèƒ½åŠ›ï¼Œéœ€åŽç«¯é…åˆè°ƒæ•´ï¼ˆå½“å‰è¯¥åˆ†é¡µèµ°çš„æ˜¯è”è¡¨æŸ¥è¯¢ï¼Œæ¡†æž¶å±‚çš„å‰ç«¯æŽ’åºå­—æ®µä¸ä¼šè‡ªåŠ¨ç”Ÿæ•ˆï¼‰
- ç‰©æ–™é€‰æ‹©å¼¹çª—(选择物料时弹出的列表)与物料管理列表共用同一接口,排序一起变化,属于预期
- åˆ†ç±»çš„「显示排序」为空的分类会排在设置了排序的分类之前
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`(菜单已新增:质检管理 -> æ¥æ–™æ£€éªŒ/过程检验/出货检验 -> ã€Œæ¥æ–™æ£€éªŒå¯¼å…¥ / è¿‡ç¨‹æ£€éªŒå¯¼å…¥ / å‡ºè´§æ£€éªŒå¯¼å…¥ã€ï¼Œå·²æŽˆäºˆæ¥æ–™/过程/出货检验现有角色)。
**请求参数(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` åç§°ï¼šè‡ªåˆ¶å·¥åºæ£€éªŒ / é¦–检 / å·¡æ£€ / è‡ªæ£€ / æˆå“æ£€éªŒ |
| ç”Ÿäº§å·¥å•号 | æ˜¯ | éœ€ä¸Žã€Œç”Ÿäº§ç®¡ç†-生产工单」中的工单号一致;**产品物料由工单推导,模板不填物料** |
| å·¥ä½åç§° | æ˜¯ | éœ€ä¸Žã€ŒåŸºç¡€æ•°æ®-工位管理」中的名称一致 |
| è´¨æ£€æ–¹æ¡ˆ | å¦ | å¡«æ–¹æ¡ˆåç§°ï¼›ç•™ç©ºåˆ™åŽç«¯æŒ‰ç‰©æ–™è‡ªåŠ¨åŒ¹é… 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 åºŸå“æ•°é‡ | å·¥åºŸ + æ–™åºŸ + å…¶ä»–废品 ä¹‹å’Œä¸å¾—超过不合格品数量,超出该行失败 |
| IPQC ç‰©æ–™æ¥æº | äº§å“ç‰©æ–™ç”±ã€Œç”Ÿäº§å·¥å•号」推导,模板不提供物料列;工单不存在或未确认时该行失败 |
| çŠ¶æ€ | å¯¼å…¥åŽçš„æ£€éªŒå•统一为「待检验」(草稿),不直接完成 |
| æ£€éªŒè¡Œç”Ÿæˆ | ç”±åŽç«¯æŒ‰åŒ¹é…åˆ°çš„质检方案自动生成检验行(检验指标),无需前端额外处理 |
| OQC å®¢æˆ·æ•°æ®æƒé™ | ã€Œå®¢æˆ·åç§°ã€å¿…须在当前登录用户的可见客户范围内,否则该行失败(否则导入后列表中也看不到该单) |
| éƒ¨åˆ†æˆåŠŸ | é€è¡Œå¯¼å…¥ï¼Œå¤±è´¥è¡Œä¸å½±å“æˆåŠŸè¡Œï¼ˆåŒä¸€æ–‡ä»¶å†…ä¸ä¼šæ•´ä½“å›žæ»šï¼‰ï¼›å…¨éƒ¨è¡Œä¸ºç©ºæ—¶è¿”å›žç©ºç»“æžœ |
| ç©ºè¡Œ | æ•´è¡Œä¸ºç©ºçš„记录自动跳过,不记为失败 |
## æ³¨æ„äº‹é¡¹
- ã€Œæ£€éªŒç±»åž‹ã€ã€Œæ£€æµ‹ç»“果」等字典列填**名称**(如 é¦–检、校验通过),也兼容填数字(1/2);填错时失败原因会列出可选值
- ã€Œè´¨æ£€æ–¹æ¡ˆã€åˆ—填的是**方案名称**,不是方案编码;留空即为「自动匹配」,是推荐用法
- IPQC çš„「检验类型」是**必填**列,且方案的检验类型需与检验单的类型可匹配,否则可能提示未配置方案
- ã€Œæ£€æµ‹äººå‘˜ã€ç•™ç©ºå–当前登录用户;填写姓名时若存在同名用户,会取匹配到的第一条,建议填姓名全称
- ä¸‰ä¸ªåˆ—表的导入互不影响,权限码各自独立;自建角色需在「角色管理」中勾选对应的「XX检验导入」按钮后才可见该按钮
- å¯¼å…¥å¼¹çª—的进度条 / ç»“果区 / æ¨¡æ¿ä¸‹è½½äº¤äº’可直接复用现有 `import-form.vue` å®žçް
- æ—¥æœŸåˆ—支持 `yyyy-MM-dd HH:mm:ss` ä¸Ž `yyyy-MM-dd` ä¸¤ç§æ ¼å¼ï¼›IQC çš„æ£€æµ‹/来料日期、OQC çš„æ£€æµ‹/出货日期为必填,IPQC çš„æ£€æµ‹æ—¥æœŸå¯ç•™ç©º
docs/project-business/data/flows.json
@@ -5,7 +5,7 @@
     "steps": [
       {"stage": "获客", "action": "线索转化客户、建联系人、立项商机", "roles": ["销售内勤"]},
       {"stage": "报价", "action": "商机赢单后建报价单(AI OCR é¢„å¡«),转合同", "roles": ["销售内勤"]},
       {"stage": "签约", "action": "合同提交 BPM å®¡æ‰¹(可选流程定义),通过=20", "roles": ["销售", "审批人"]},
       {"stage": "签约", "action": "合同来源:手工新建 / æŠ¥ä»·å•转入 / Excel å¯¼å…¥(一合同多产品明细行);提交 BPM å®¡æ‰¹(可选流程定义),通过=20", "roles": ["销售", "审批人"]},
       {"stage": "接单", "action": "合同一键生成销售订单(XSDD),销售订单再走 BPM;审批通过自动为 needProduction æ˜Žç»†åˆ›å»º MPS", "roles": ["销售", "计划员"]},
       {"stage": "交付", "action": "发货通知审核(占用)→ é”€å”®å‡ºåº“ OQC â†’ å‘运 â†’ å‡ºåº“回写部分/全部出库", "roles": ["仓管", "质检"]},
       {"stage": "回款", "action": "开票 BPM(累计≤合同额) â†’ å›žæ¬¾å¼ºåˆ¶å…ˆå¼€ç¥¨(累计≤合同剩余与开票剩余)", "roles": ["财务", "销售"]}]},
@@ -35,6 +35,7 @@
       {"stage": "过程", "action": "报工质检工序产出行→IPQC,合格拆行入库、不合格生成 NCR", "roles": ["IPQC è´¨æ£€å‘˜"]},
       {"stage": "出货", "action": "销售出库提交→OQC,完成后出库单进入待拣货", "roles": ["OQC è´¨æ£€å‘˜"]},
       {"stage": "退货", "action": "退料/销售退货/售后退货→RQC,结论回调单据与售后", "roles": ["RQC è´¨æ£€å‘˜"]},
       {"stage": "导入", "action": "IQC/IPQC/OQC ä¸‰åˆ—表各支持 Excel æ‰¹é‡å¯¼å…¥(按钮在「新增」与「导出」之间,纯新增不做更新覆盖):一行一张单,编号留空自动生成,质检方案留空按物料+检验类型自动匹配,检测人员留空取当前登录用户;物料/供应商/客户/工单/工位按编码或名称精确匹配(IPQC ç‰©æ–™ç”±å·¥å•推导,模板不填),合格+不合格=检测/接收数量、IPQC å·¥åºŸ+料废+其他废品≤不合格、OQC å®¢æˆ·é¡»åœ¨æœ¬äººæ•°æ®æƒé™å†…;逐行导入部分成功,失败行进 failureList å¸¦è¡Œå·ä¸Žå¯æ‰§è¡ŒåŠ¨ä½œï¼ŒæˆåŠŸå•ä¸€å¾‹å¾…æ£€éªŒ0;同一批数据不可重复导入:文件内重复行与库内已存在同业务键单据(IQC ç‰©æ–™+供应商+供应商批次+来料日期、IPQC ç”Ÿäº§å·¥å•+检验类型+工位+检测日期、OQC ç‰©æ–™+客户+批次号+出货日期)均拒该行,提示含维度、双方实际值与「先删除该单据再重试」动作", "roles": ["质检员", "IQC è´¨æ£€å‘˜", "IPQC è´¨æ£€å‘˜", "OQC è´¨æ£€å‘˜"]},
       {"stage": "不合格", "action": "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审批→生成销售订单·支持Excel导入)", "开票(BPM审批·支持Excel导入·同一批数据不可重复导入)→回款(先开票校验)", "回款计划", "销售目标", "数据权限三级(负责人/只读/读写)"],
     "objects": ["线索", "客户", "联系人", "商机", "报价单", "合同", "发票", "回款", "销售目标"],
     "roles": ["销售内勤", "销售经理", "财务(开票回款)"],
     "upstream": ["主数据(物料)"], "downstream": ["进销存(销售订单)", "售后(客户)", "AI(报价OCR)"],
docs/project-business/data/objects.json
@@ -7,8 +7,8 @@
    {"name": "联系人", "module": "销售管理", "table": "crm_contact", "doc": "客户下联系人,与商机多对多", "upstream": ["客户"], "downstream": ["商机"], "keywords": "联系人 contact"},
    {"name": "商机", "module": "销售管理", "table": "crm_business", "doc": "销售机会,DB é…ç½®å¼é˜¶æ®µæŽ¨è¿›ï¼Œç»“束态: èµ¢/输/无效", "upstream": ["客户"], "downstream": ["报价单", "合同"], "keywords": "商机 business èµ¢å• è¾“单 é˜¶æ®µ"},
    {"name": "报价单", "module": "销售管理", "table": "crm_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_contract", "doc": "HT编号,BPM å®¡æ‰¹(0草稿/10审批中/20通过/30驳回/40取消/50作废),通过后可生成销售订单;支持 Excel å¯¼å…¥ï¼ˆä¸€åˆåŒå¤šäº§å“æ˜Žç»†è¡Œã€åˆå¹¶å•元格自动拉平,客户与物料按名称/编码校验);产品明细带出物料名称/编码/条码/规格型号/计量单位;删除合同时连带删除产品明细,不留孤儿数据", "upstream": ["报价单"], "downstream": ["销售订单", "回款计划", "开票"], "keywords": "合同 contract å®¡æ‰¹ å¯¼å…¥"},
    {"name": "发票", "module": "销售管理", "table": "crm_invoice", "doc": "KP编号(可留空自动生成、系统内唯一),BPM å®¡æ‰¹ï¼Œç´¯è®¡å¼€ç¥¨â‰¤åˆåŒé¢ï¼Œé™„件必传;支持 Excel å¯¼å…¥ï¼ˆä»…新增不更新,合同须审核通过且本次金额≤合同剩余可开票额;同一批数据不可重复导入——同合同下发票号码相同、或发票号码留空时开票金额+发票抬头+开票日期相同即判重复,文件内重复行与库内已有单都拒该行并回显维度与实际值)", "upstream": ["合同"], "downstream": ["回款"], "keywords": "开票 å‘票 invoice å¯¼å…¥ åŽ»é‡"},
    {"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": "报工质检工序产出行触发,完成回调推进报工与产出入库;行操作「出报告」仅在已完成时可见", "upstream": ["报工"], "downstream": ["产出入库", "NCR", "报告实例(出报告)"], "keywords": "IPQC è¿‡ç¨‹æ£€éªŒ å·¡æ£€ å‡ºæŠ¥å‘Š"},
    {"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": "报工质检工序产出行触发,完成回调推进报工与产出入库;行操作「出报告」仅在已完成时可见;支持 Excel æ‰¹é‡å¯¼å…¥ï¼ˆäº§å“ç‰©æ–™ç”±ç”Ÿäº§å·¥å•推导,模板不填物料;工废+料废+其他废品不得超过不合格品数量);同一批数据不可重复导入(生产工单+检验类型+工位+检测日期,文件内重复行与库内已有单都拒该行)", "upstream": ["报工", "Excel æ‰¹é‡å¯¼å…¥"], "downstream": ["产出入库", "NCR", "报告实例(出报告)"], "keywords": "IPQC è¿‡ç¨‹æ£€éªŒ å·¡æ£€ å‡ºæŠ¥å‘Š æ‰¹é‡å¯¼å…¥ å¯¼å…¥æ¨¡æ¿"},
    {"name": "OQC出货检验", "module": "质量", "table": "mes_qc_oqc", "doc": "销售出库提交触发,完成推进出库单待拣货;行操作「出报告」仅在已完成时可见;支持 Excel æ‰¹é‡å¯¼å…¥ï¼ˆå®¢æˆ·é¡»åœ¨å½“前登录用户的客户数据权限范围内);同一批数据不可重复导入(物料+客户+批次号+出货日期,文件内重复行与库内已有单都拒该行)", "upstream": ["销售出库单", "Excel æ‰¹é‡å¯¼å…¥"], "downstream": ["销售出库单(推进)", "报告实例(出报告)"], "keywords": "OQC å‡ºè´§æ£€éªŒ å‡ºåŽ‚æ£€éªŒ å‡ºæŠ¥å‘Š æ‰¹é‡å¯¼å…¥ å¯¼å…¥æ¨¡æ¿"},
    {"name": "RQC退货检验", "module": "质量", "table": "mes_qc_rqc", "doc": "生产退料/销售退货/售后退货触发,结论回调售后;行操作「出报告」仅在已完成时可见。措辞已统一为「退货检验」(原菜单父项与 mes_qc_type å­—典写「退料检验」,与后端枚举/表注释/菜单权限子项不一致;RQC ä¸‰è€…都覆盖,故取「退货检验」)", "upstream": ["退料", "退货"], "downstream": ["退货入库", "售后回调", "报告实例(出报告)"], "keywords": "RQC é€€è´§æ£€éªŒ é€€æ–™æ£€éªŒ å‡ºæŠ¥å‘Š"},
    {"name": "NCR不合格品", "module": "质量", "table": "mes_qc_ncr", "doc": "待评审0→已评审6→已处置7→已关闭8,处置:退货/报废/返工/让步接收/降级", "upstream": ["IQC/IPQC/OQC/RQC"], "downstream": ["退货/报废/返工(台账)"], "keywords": "NCR ä¸åˆæ ¼ å¤„ç½® ç‰¹é‡‡ æŠ¥åºŸ"},
    {"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审批→生成销售订单·支持Excel导入)", "开票(BPM审批·支持Excel导入·同一批数据不可重复导入)→回款(先开票校验)", "回款计划", "销售目标", "数据权限三级(负责人/只读/读写)"],
     "objects": ["线索", "客户", "联系人", "商机", "报价单", "合同", "发票", "回款", "销售目标"],
     "roles": ["销售内勤", "销售经理", "财务(开票回款)"],
     "upstream": ["主数据(物料)"], "downstream": ["进销存(销售订单)", "售后(客户)", "AI(报价OCR)"],
@@ -450,8 +450,8 @@
    {"name": "联系人", "module": "销售管理", "table": "crm_contact", "doc": "客户下联系人,与商机多对多", "upstream": ["客户"], "downstream": ["商机"], "keywords": "联系人 contact"},
    {"name": "商机", "module": "销售管理", "table": "crm_business", "doc": "销售机会,DB é…ç½®å¼é˜¶æ®µæŽ¨è¿›ï¼Œç»“束态: èµ¢/输/无效", "upstream": ["客户"], "downstream": ["报价单", "合同"], "keywords": "商机 business èµ¢å• è¾“单 é˜¶æ®µ"},
    {"name": "报价单", "module": "销售管理", "table": "crm_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_contract", "doc": "HT编号,BPM å®¡æ‰¹(0草稿/10审批中/20通过/30驳回/40取消/50作废),通过后可生成销售订单;支持 Excel å¯¼å…¥ï¼ˆä¸€åˆåŒå¤šäº§å“æ˜Žç»†è¡Œã€åˆå¹¶å•元格自动拉平,客户与物料按名称/编码校验);产品明细带出物料名称/编码/条码/规格型号/计量单位;删除合同时连带删除产品明细,不留孤儿数据", "upstream": ["报价单"], "downstream": ["销售订单", "回款计划", "开票"], "keywords": "合同 contract å®¡æ‰¹ å¯¼å…¥"},
    {"name": "发票", "module": "销售管理", "table": "crm_invoice", "doc": "KP编号(可留空自动生成、系统内唯一),BPM å®¡æ‰¹ï¼Œç´¯è®¡å¼€ç¥¨â‰¤åˆåŒé¢ï¼Œé™„件必传;支持 Excel å¯¼å…¥ï¼ˆä»…新增不更新,合同须审核通过且本次金额≤合同剩余可开票额;同一批数据不可重复导入——同合同下发票号码相同、或发票号码留空时开票金额+发票抬头+开票日期相同即判重复,文件内重复行与库内已有单都拒该行并回显维度与实际值)", "upstream": ["合同"], "downstream": ["回款"], "keywords": "开票 å‘票 invoice å¯¼å…¥ åŽ»é‡"},
    {"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": "报工质检工序产出行触发,完成回调推进报工与产出入库;行操作「出报告」仅在已完成时可见", "upstream": ["报工"], "downstream": ["产出入库", "NCR", "报告实例(出报告)"], "keywords": "IPQC è¿‡ç¨‹æ£€éªŒ å·¡æ£€ å‡ºæŠ¥å‘Š"},
    {"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": "报工质检工序产出行触发,完成回调推进报工与产出入库;行操作「出报告」仅在已完成时可见;支持 Excel æ‰¹é‡å¯¼å…¥ï¼ˆäº§å“ç‰©æ–™ç”±ç”Ÿäº§å·¥å•推导,模板不填物料;工废+料废+其他废品不得超过不合格品数量);同一批数据不可重复导入(生产工单+检验类型+工位+检测日期,文件内重复行与库内已有单都拒该行)", "upstream": ["报工", "Excel æ‰¹é‡å¯¼å…¥"], "downstream": ["产出入库", "NCR", "报告实例(出报告)"], "keywords": "IPQC è¿‡ç¨‹æ£€éªŒ å·¡æ£€ å‡ºæŠ¥å‘Š æ‰¹é‡å¯¼å…¥ å¯¼å…¥æ¨¡æ¿"},
    {"name": "OQC出货检验", "module": "质量", "table": "mes_qc_oqc", "doc": "销售出库提交触发,完成推进出库单待拣货;行操作「出报告」仅在已完成时可见;支持 Excel æ‰¹é‡å¯¼å…¥ï¼ˆå®¢æˆ·é¡»åœ¨å½“前登录用户的客户数据权限范围内);同一批数据不可重复导入(物料+客户+批次号+出货日期,文件内重复行与库内已有单都拒该行)", "upstream": ["销售出库单", "Excel æ‰¹é‡å¯¼å…¥"], "downstream": ["销售出库单(推进)", "报告实例(出报告)"], "keywords": "OQC å‡ºè´§æ£€éªŒ å‡ºåŽ‚æ£€éªŒ å‡ºæŠ¥å‘Š æ‰¹é‡å¯¼å…¥ å¯¼å…¥æ¨¡æ¿"},
    {"name": "RQC退货检验", "module": "质量", "table": "mes_qc_rqc", "doc": "生产退料/销售退货/售后退货触发,结论回调售后;行操作「出报告」仅在已完成时可见。措辞已统一为「退货检验」(原菜单父项与 mes_qc_type å­—典写「退料检验」,与后端枚举/表注释/菜单权限子项不一致;RQC ä¸‰è€…都覆盖,故取「退货检验」)", "upstream": ["退料", "退货"], "downstream": ["退货入库", "售后回调", "报告实例(出报告)"], "keywords": "RQC é€€è´§æ£€éªŒ é€€æ–™æ£€éªŒ å‡ºæŠ¥å‘Š"},
    {"name": "NCR不合格品", "module": "质量", "table": "mes_qc_ncr", "doc": "待评审0→已评审6→已处置7→已关闭8,处置:退货/报废/返工/让步接收/降级", "upstream": ["IQC/IPQC/OQC/RQC"], "downstream": ["退货/报废/返工(台账)"], "keywords": "NCR ä¸åˆæ ¼ å¤„ç½® ç‰¹é‡‡ æŠ¥åºŸ"},
    {"name": "设备", "module": "设备", "table": "mes_dv_machinery", "doc": "台账状态:停机/生产中/保养中;点检·保养·维修工单;AI é£Žé™©é¢„测消费其数据", "upstream": [], "downstream": ["点检", "ç»´ä¿®", "生产风险预测"], "keywords": "设备 ç‚¹æ£€ ä¿å…» ç»´ä¿® machinery"},
@@ -500,7 +500,7 @@
     "steps": [
       {"stage": "获客", "action": "线索转化客户、建联系人、立项商机", "roles": ["销售内勤"]},
       {"stage": "报价", "action": "商机赢单后建报价单(AI OCR é¢„å¡«),转合同", "roles": ["销售内勤"]},
       {"stage": "签约", "action": "合同提交 BPM å®¡æ‰¹(可选流程定义),通过=20", "roles": ["销售", "审批人"]},
       {"stage": "签约", "action": "合同来源:手工新建 / æŠ¥ä»·å•转入 / Excel å¯¼å…¥(一合同多产品明细行);提交 BPM å®¡æ‰¹(可选流程定义),通过=20", "roles": ["销售", "审批人"]},
       {"stage": "接单", "action": "合同一键生成销售订单(XSDD),销售订单再走 BPM;审批通过自动为 needProduction æ˜Žç»†åˆ›å»º MPS", "roles": ["销售", "计划员"]},
       {"stage": "交付", "action": "发货通知审核(占用)→ é”€å”®å‡ºåº“ OQC â†’ å‘运 â†’ å‡ºåº“回写部分/全部出库", "roles": ["仓管", "质检"]},
       {"stage": "回款", "action": "开票 BPM(累计≤合同额) â†’ å›žæ¬¾å¼ºåˆ¶å…ˆå¼€ç¥¨(累计≤合同剩余与开票剩余)", "roles": ["财务", "销售"]}]},
@@ -530,6 +530,7 @@
       {"stage": "过程", "action": "报工质检工序产出行→IPQC,合格拆行入库、不合格生成 NCR", "roles": ["IPQC è´¨æ£€å‘˜"]},
       {"stage": "出货", "action": "销售出库提交→OQC,完成后出库单进入待拣货", "roles": ["OQC è´¨æ£€å‘˜"]},
       {"stage": "退货", "action": "退料/销售退货/售后退货→RQC,结论回调单据与售后", "roles": ["RQC è´¨æ£€å‘˜"]},
       {"stage": "导入", "action": "IQC/IPQC/OQC ä¸‰åˆ—表各支持 Excel æ‰¹é‡å¯¼å…¥(按钮在「新增」与「导出」之间,纯新增不做更新覆盖):一行一张单,编号留空自动生成,质检方案留空按物料+检验类型自动匹配,检测人员留空取当前登录用户;物料/供应商/客户/工单/工位按编码或名称精确匹配(IPQC ç‰©æ–™ç”±å·¥å•推导,模板不填),合格+不合格=检测/接收数量、IPQC å·¥åºŸ+料废+其他废品≤不合格、OQC å®¢æˆ·é¡»åœ¨æœ¬äººæ•°æ®æƒé™å†…;逐行导入部分成功,失败行进 failureList å¸¦è¡Œå·ä¸Žå¯æ‰§è¡ŒåŠ¨ä½œï¼ŒæˆåŠŸå•ä¸€å¾‹å¾…æ£€éªŒ0;同一批数据不可重复导入:文件内重复行与库内已存在同业务键单据(IQC ç‰©æ–™+供应商+供应商批次+来料日期、IPQC ç”Ÿäº§å·¥å•+检验类型+工位+检测日期、OQC ç‰©æ–™+客户+批次号+出货日期)均拒该行,提示含维度、双方实际值与「先删除该单据再重试」动作", "roles": ["质检员", "IQC è´¨æ£€å‘˜", "IPQC è´¨æ£€å‘˜", "OQC è´¨æ£€å‘˜"]},
       {"stage": "不合格", "action": "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": ["质检员", "质量工程师"]}]},
@@ -695,6 +696,7 @@
  E -->|输单或无效| X[结束]
  F -->|转合同·带入客户产品折扣| G[合同 HT·草稿0→审批中10]
  D -->|直接建合同| G
  Q[合同 Excel å¯¼å…¥Â·ä¸€åˆåŒå¤šäº§å“æ˜Žç»†è¡Œ] -->|批量建草稿合同| G
  G -->|BPM审批通过20| H[生成 ERP é”€å”®è®¢å• XSDD]
  H -->|提交BPM| I[销售订单审批·审批中15→已审核20]
  I -->|明细需生产·自动创建| J[MES ä¸»ç”Ÿäº§è®¡åˆ’ MPS]
@@ -754,6 +756,18 @@
  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 å·¥åºŸ+料废+其他废品 ä¸å¾—超过不合格品数量| IMPQ
  IMP -.->|OQC æ ¡éªŒå®¢æˆ·åœ¨æœ¬äººæ•°æ®æƒé™å†…·否则导入后列表也看不到| IMPP[数据权限前置校验]
  IMP -.->|检测人员留空=当前登录用户·IQC检测/来料日期与OQC检测/出货日期必填| IMPD[人员与日期]
  IMP --> IMR[响应 createList æˆåŠŸç¼–å· + failureList è¡Œå·â†’原因·整行空自动跳过]
  IMR -.->|导入单一律待检验0·不走来源单据回写·不生成NCR| IMRNOTE[与触发式建单的区别·finish æ‰åˆ¤å®šä¸Žå›žå†™]
  A1 --> R{检验判定 MesQcCheckResultEnum}
  A2 --> R
  A3 --> R
@@ -808,7 +822,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/02-sales-flow.mmd
@@ -7,6 +7,7 @@
  E -->|输单或无效| X[结束]
  F -->|转合同·带入客户产品折扣| G[合同 HT·草稿0→审批中10]
  D -->|直接建合同| G
  Q[合同 Excel å¯¼å…¥Â·ä¸€åˆåŒå¤šäº§å“æ˜Žç»†è¡Œ] -->|批量建草稿合同| G
  G -->|BPM审批通过20| H[生成 ERP é”€å”®è®¢å• XSDD]
  H -->|提交BPM| I[销售订单审批·审批中15→已审核20]
  I -->|明细需生产·自动创建| J[MES ä¸»ç”Ÿäº§è®¡åˆ’ MPS]
docs/project-business/mermaid/05-quality-flow.mmd
@@ -3,6 +3,18 @@
  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 å·¥åºŸ+料废+其他废品 ä¸å¾—超过不合格品数量| IMPQ
  IMP -.->|OQC æ ¡éªŒå®¢æˆ·åœ¨æœ¬äººæ•°æ®æƒé™å†…·否则导入后列表也看不到| IMPP[数据权限前置校验]
  IMP -.->|检测人员留空=当前登录用户·IQC检测/来料日期与OQC检测/出货日期必填| IMPD[人员与日期]
  IMP --> IMR[响应 createList æˆåŠŸç¼–å· + failureList è¡Œå·â†’原因·整行空自动跳过]
  IMR -.->|导入单一律待检验0·不走来源单据回写·不生成NCR| IMRNOTE[与触发式建单的区别·finish æ‰åˆ¤å®šä¸Žå›žå†™]
  A1 --> R{检验判定 MesQcCheckResultEnum}
  A2 --> R
  A3 --> R
docs/qc_inspection_ledger_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,160 @@
# è´¨é‡ç®¡ç† - æ£€æµ‹ç»“果台账(分类 / æ—¶é—´åŒºé—´ç­›é€‰ + å¯¼å‡ºï¼‰ - å‰ç«¯è”调方案
> æœ¬æ¬¡è§£å†³ä¸¤ä»¶äº‹ï¼š
> 1. **直接展示**:四类检验单(IQC/IPQC/OQC/RQC)列表接口**已经返回**检验项实测结果汇总字段 `indicatorResultSummary`,但前端列表未展示 â†’ å‰ç«¯åŠ ä¸€åˆ—å³å¯ï¼Œ**后端无需改动**。
> 2. **筛选展示 + å¯¼å‡ºå°è´¦**:新增跨四类的「检测结果台账」查询与导出接口(分类 + æ£€æµ‹æ—¥æœŸåŒºé—´ + ç‰©æ–™/批次/检测结果/检测人筛选),一行一个检测项结果,用于查询、打印、存档。
## æ¶‰åŠé¡µé¢
- **新增**:检测结果台账页 `views/mes/qc/ledger/index.vue`(菜单已由后端 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. æ£€éªŒå•未录入检验结果时,该单仍会出现在台账中(检测项相关列为空),不会漏单。
## 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 | - | ç‰©æ–™ä¿¡æ¯ï¼ˆç¼–码、名称、规格型号、计量单位) |
| 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,参数与分页接口完全一致 |
**导出列:** åˆ†ç±» / æ£€éªŒå•编号 / æ£€éªŒå•名称 / ç‰©æ–™ç¼–码 / ç‰©æ–™åç§° / è§„格型号 / æ‰¹æ¬¡å· / æ£€æµ‹é¡¹ / æ£€æµ‹å€¼ / åˆ¤å®šä¾æ® / æ£€æµ‹ç»“æžœ / æ£€æµ‹æ•°é‡ / åˆæ ¼æ•°é‡ / ä¸åˆæ ¼æ•°é‡ / æ£€æµ‹äººå‘˜ / æ£€æµ‹æ—¥æœŸ / å•据状态 / å¤‡æ³¨ã€‚
## æƒé™ä¸Žèœå•(已由后端 SQL é…ç½®å®Œæˆï¼‰
| èœå• | æƒé™ç  | è¯´æ˜Ž |
|------|--------|------|
| è´¨é‡ç®¡ç† â†’ æ£€æµ‹ç»“果台账 | â€” | type=2,component `mes/qc/ledger/index` |
| æ£€æµ‹ç»“果台账查询 | `mes:qc-inspection-ledger:query` | é¡µé¢åŠ è½½ã€åˆ†é¡µæŸ¥è¯¢ |
| æ£€æµ‹ç»“果台账导出 | `mes:qc-inspection-ledger:export` | å¯¼å‡ºæŒ‰é’® |
菜单已授权给所有持有「来料检验查询」的角色;前端建好 `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 å€’序 |
| æ—¶é—´åŒºé—´ | æŒ‰ã€Œæ£€æµ‹æ—¥æœŸã€é—­åŒºé—´è¿‡æ»¤ï¼›åªä¼ å¼€å§‹æˆ–只传结束均可 |
| ä¸€è¡Œä¸€æ£€æµ‹é¡¹ | ä¸€å¼ æ£€éªŒå•录入多个检测项结果时展开为多行(其余字段重复) |
| æ£€éªŒå•未录入检测项 | è¯¥å•保留一行,检测项 / æ£€æµ‹å€¼ / åˆ¤å®šä¾æ®ä¸ºç©º |
| åˆ¤å®šä¾æ® | ç”±æ£€éªŒå•行的标准值与误差上下限拼装:上下限都有 â†’ `下限~上限`;仅上限 â†’ `≤上限`;仅下限 â†’ `≥下限`;仅标准值 â†’ `标准值 å€¼`;都没有则为空 |
| é€»è¾‘删除 | å·²åˆ é™¤çš„æ£€éªŒå•不出现在台账;已删除的检测项仍展示其历史名称,保证历史台账可读 |
| å¯¼å‡ºèŒƒå›´ | å¯¼å‡ºéµå¾ªå½“前筛选条件;不选时间则导出全部符合其它条件的记录 |
## æ³¨æ„äº‹é¡¹
- æœ¬æ¬¡åŽç«¯æ–°å¢žäº†é¡µé¢æŽ¥å£ä¸Žèœå•,**需重启后端服务**后新接口才可用;相关角色需重新登录以刷新菜单与权限缓存。
- å°è´¦æŽ¥å£çš„ `qcId` + `qcType` å¯å®šä½åˆ°åŽŸå§‹æ£€éªŒå•ï¼Œè‹¥éœ€è¦ç‚¹å‡»è·³è½¬ï¼Œè¯·æŒ‰åˆ†ç±»è·³åˆ°å¯¹åº”é¡µé¢ï¼ˆæ¥æ–™æ£€éªŒ / è¿‡ç¨‹æ£€éªŒ / å‡ºè´§æ£€éªŒ / é€€è´§æ£€éªŒï¼‰ã€‚
- å°è´¦çš„「检测结果」列与四类检验单列表的「检测结果」列口径一致,均取检验单头的检测结论(字典 `mes_qc_check_result`)。
- å››ç±»æ£€éªŒå•列表新增的「检测结果(检验项)」列直接使用已有字段 `indicatorResultSummary`,不需要新接口;导出时该内容已包含在「检验指标」列中。
- å°è´¦ä¸ºåªè¯»é¡µé¢ï¼Œä¸æä¾›æ–°å¢ž / ä¿®æ”¹ / åˆ é™¤æ“ä½œã€‚
docs/receivable_invoice_link.md
@@ -1,5 +1,8 @@
# å¼€ç¥¨ä¸Žå›žæ¬¾å…³è”(先开票后回款) - å‰ç«¯è”调方案
> **本次调整(最新)**:回款创建表单的【关联开票】与【回款期数】由必填改为**选填**,支持「未关联开票 / æœªå…³è”回款计划」直接录入回款。
> åŽç«¯å·²æ”¾å¼€ï¼ˆ`invoiceId` ä¸å†å¼ºåˆ¶ï¼‰ï¼Œå‰ç«¯éœ€åŽ»æŽ‰è¿™ä¸¤ä¸ªå­—æ®µçš„å¿…å¡«æ ¡éªŒï¼Œè¯¦è§æ–‡æœ«ã€Œæœ¬æ¬¡è°ƒæ•´ã€ç« èŠ‚ã€‚
## æ¶‰åŠé¡µé¢
- å›žæ¬¾ç®¡ç†åˆ—表页
@@ -51,14 +54,15 @@
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| POST | `/crm/receivable/create` | è¯·æ±‚体新增 `invoiceId`(**必填**) |
| POST | `/crm/receivable/create` | è¯·æ±‚体新增 `invoiceId`(**选填**) |
| PUT | `/crm/receivable/update` | è¯·æ±‚体新增 `invoiceId`(可修改) |
**请求参数新增:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| invoiceId | Long | æ˜¯ | å…³è”的开票编号,必须为审批通过且已上传附件的开票 |
| invoiceId | Long | å¦ | å…³è”的开票编号。填写时必须为审批通过且已上传附件的开票;不填表示该笔回款暂不关联开票 |
| planId | Long | å¦ | å…³è”的回款计划编号。不填表示不占用回款计划期数 |
### 3. å˜æ›´æŽ¥å£ï¼šå›žæ¬¾æŸ¥è¯¢ï¼ˆå“åº”新增字段)
@@ -90,6 +94,31 @@
| å‰©ä½™å¯å›žæ¬¾é‡‘额 | å¼€ç¥¨åˆ—表列、开票详情页 | å¼€ç¥¨é‡‘额 - è¯¥å¼€ç¥¨å·²å›žæ¬¾é‡‘额 |
| æ˜¯å¦å·²ä¸Šä¼ é™„ä»¶ | å¼€ç¥¨åˆ—表列 | è¾…助判断开票是否可回款(需审批通过 + å·²ä¸Šä¼ é™„件) |
## æœ¬æ¬¡è°ƒæ•´ï¼ˆã€å…³è”开票】【回款期数】改为选填)
### å˜æ›´ç‚¹
| é¡¹ | è°ƒæ•´å‰ | è°ƒæ•´åŽ |
|----|--------|--------|
| å…³è”开票(invoiceId) | å¿…填,不选无法保存 | é€‰å¡«ï¼›ä¸é€‰å³å½•入一笔不关联开票的回款 |
| å›žæ¬¾æœŸæ•°ï¼ˆplanId) | å¿…填,不选无法保存 | é€‰å¡«ï¼›ä¸é€‰å³ä¸å ç”¨å›žæ¬¾è®¡åˆ’期数 |
| åˆåŒï¼ˆcontractId) | å¿…å¡« | **仍为必填**(金额上限校验依赖合同) |
| å›žæ¬¾é‡‘额、回款日期 | å¿…å¡« | **仍为必填** |
### å‰ç«¯çš„æ”¹åŠ¨ç‚¹ï¼ˆåŽ»å¿…å¡«ï¼‰
- å›žæ¬¾åˆ›å»º/编辑表单的【关联开票】【回款期数】两个字段,去掉必填校验规则即可,其余联动逻辑(选合同后加载开票/期数选项、选中后带入客户与金额)保持不变。
- å»ºè®®å–消必填后,字段提示文案相应改为「可不选」类表述,避免用户误以为漏填。
### ä¸å¡«æ—¶çš„后端行为
| åœºæ™¯ | è¡Œä¸º |
|------|------|
| ä¸é€‰å…³è”开票 | è·³è¿‡ã€Œå¼€ç¥¨æ˜¯å¦å®¡æ‰¹é€šè¿‡ / æ˜¯å¦å·²ä¸Šä¼ é™„ä»¶ / æ˜¯å¦ä¸ŽåˆåŒä¸€è‡´ã€åŠã€Œä¸è¶…过该开票剩余可回款金额」两组校验 |
| ä¸é€‰å…³è”开票时的金额上限 | ä»å—**合同级**校验约束:累计回款不得超过合同总金额 |
| ä¸é€‰å›žæ¬¾æœŸæ•° | ä¸æ›´æ–°ä»»ä½•回款计划的已回款标记,回款计划列表中该期仍显示未回款 |
| ç¼–辑既有回款 | æœªæäº¤ invoiceId æ—¶ä¿ç•™åŽŸå…³è”å…³ç³»ï¼ˆä¸ä¼šå› æ¼ä¼ è€Œè¢«æ¸…ç©ºï¼‰ï¼›å¦‚éœ€è§£é™¤å…³è”ï¼Œéœ€æ˜¾å¼æ¸…ç©ºè¯¥å­—æ®µï¼ˆå½“å‰å‰ç«¯ç¼–è¾‘æ€è¯¥å­—æ®µä¸ºç¦ç”¨ï¼Œå¦‚éœ€æ”¯æŒè§£é™¤éœ€å¦è¡Œå¼€æ”¾ï¼‰ |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
@@ -112,8 +141,8 @@
## æ³¨æ„äº‹é¡¹
- `invoiceId` ä¸ºå¿…填,回款创建/更新**必须**携带,否则返回参数校验错误。
- `invoiceId`、`planId` å‡ä¸º**选填**;填了开票就按开票校验,不填只受合同金额上限约束。
- `invoice`、`remainingInvoicePrice`、`hasAttachment` å‡ä¸ºåŽç«¯è®¡ç®—的只读字段,前端**不要**提交。
- å›žæ¬¾é‡‘额建议在选中开票后预填为 `remainingInvoicePrice`,避免超限被后端拦截。
- å­˜é‡å›žæ¬¾æ•°æ®è‹¥æœªå…³è”开票(`invoiceId` ä¸ºç©ºï¼‰ï¼Œç¼–辑时会被要求重新选择合格开票。
- å­˜é‡å›žæ¬¾æ•°æ®è‹¥æœªå…³è”开票(`invoiceId` ä¸ºç©ºï¼‰ï¼Œç¼–辑保存时不再被要求补选开票。
- å¼€ç¥¨åˆ—表展示"剩余可回款金额"时,文案建议用"剩余可回款金额(含审批中)"避免歧义,与合同"已开票金额(含审批中)"口径一致。
docs/sql/config_export_all_20260928.sql
¶Ô±ÈÐÂÎļþ
ÎļþÌ«´ó
docs/sql/crm_invoice_import_menu.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,17 @@
-- =====================================================
-- CRM å¼€ç¥¨å¯¼å…¥ èœå•与按钮权限
-- æ–°å¢žæŒ‰é’®æƒé™ï¼šcrm:invoice:import(开票列表「导入」按钮)
-- æŒ‚载到 å¼€ç¥¨ç®¡ç†èœå•(parent_id = 1063093)
-- =====================================================
INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
VALUES (1075417, '开票导入', 'crm:invoice:import', 3, 5, 1063093, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0');
-- ä¸Žå¼€ç¥¨ç®¡ç†å…¶å®ƒæŒ‰é’®ï¼ˆå¯¼å‡ºã€æ–°å»ºå¼€ç¥¨ç­‰ï¼‰æŽˆäºˆç›¸åŒçš„角色
INSERT INTO `system_role_menu` (`role_id`, `menu_id`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
VALUES
  (2, 1075417, '1', NOW(), '1', NOW(), b'0'),
  (155, 1075417, '1', NOW(), '1', NOW(), b'0'),
  (160, 1075417, '1', NOW(), '1', NOW(), b'0'),
  (161, 1075417, '1', NOW(), '1', NOW(), b'0'),
  (162, 1075417, '1', NOW(), '1', NOW(), b'0');
docs/sql/gen_config_export_all.py
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,304 @@
# -*- coding: utf-8 -*-
"""重新生成 docs/sql/config_export_all_20260928.sql(全量重生成,替换 0918 ç‰ˆï¼‰ã€‚
阶段一:当前源库 ruoyi-vue-pro å…¨åº“ 416 å¼ è¡¨ CREATE TABLE + 6 ç±»é…ç½®æ•°æ®ï¼ˆmysqldump å­—面量)。
阶段二:沿用 0918 æ–‡ä»¶çš„跨库身份/菜单/OAuth2 è¡¥å¯¼æ®µï¼ˆé€å­—复用)。
"""
import re
import subprocess
import sys
MYSQL_BIN = r"D:\MySQL\mysql-8.0.31-winx64\bin"
DB = "ruoyi-vue-pro"
USER = "root"
PWD = "1234"
BASE_DIR = r"D:\develop\XingRuan\MOM\mom-pro2-after\docs\sql"
SRC_0918 = BASE_DIR + r"\config_export_all_20260918.sql"
OUT = BASE_DIR + r"\config_export_all_20260928.sql"
MYSQL = MYSQL_BIN + r"\mysql.exe"
MYSQLDUMP = MYSQL_BIN + r"\mysqldump.exe"
COMMON = ["--default-character-set=utf8mb4", "--no-tablespaces", "--skip-comments",
          "--skip-add-locks", "--skip-disable-keys", "--skip-triggers",
          "--column-statistics=0", "--set-gtid-purged=OFF", "--single-transaction"]
# ---------------- 6 ç±»é…ç½®è¡¨ï¼ˆè¡¨å, è¿‡æ»¤æ¡ä»¶ None=全表) ----------------
SECTIONS = [
    ("① ååŒå®¡æ‰¹æµç¨‹ï¼ˆå¼•擎元数据+定义,仅激活流程,剔历史/运行/身份)", [
        ("act_ge_property", None),
        ("act_id_property", None),
        ("act_re_procdef", "SUSPENSION_STATE_=1"),
        ("act_re_deployment", "ID_ IN (SELECT DEPLOYMENT_ID_ FROM act_re_procdef WHERE SUSPENSION_STATE_=1)"),
        ("act_ge_bytearray",
         "DEPLOYMENT_ID_ IN (SELECT DEPLOYMENT_ID_ FROM act_re_procdef WHERE SUSPENSION_STATE_=1) "
         "OR NAME_ IN ('source','source-extra')"),
        ("act_re_model", None),
        ("bpm_process_definition_info", "deleted=0"),
    ]),
    ("② ååŒï¼ˆä¸šåŠ¡é™„ä»¶é…ç½® + åˆ†ç±» + è¡¨å• + æ‰©å±•配置)", [
        ("bpm_business_attach", "deleted=0"),
        ("bpm_category", "deleted=0"),
        ("bpm_form", "deleted=0"),
        ("bpm_user_group", "deleted=0"),
        ("bpm_process_listener", "deleted=0"),
        ("bpm_process_expression", "deleted=0"),
    ]),
    ("③ BI å†³ç­–大屏 / go-view", [
        ("bi_dashboard", "deleted=0"),
        ("bi_chart_config", "deleted=0"),
        ("bi_data_source_config", "deleted=0"),
        ("bi_warehouse_coordinate", "deleted=0"),
        ("report_go_view_project", "deleted=0"),
    ]),
    ("④ MES ç¼–码规则(剔流水记录)", [
        ("mes_md_auto_code_rule", "deleted=0"),
        ("mes_md_auto_code_part", "deleted=0"),
    ]),
    ("⑤ ç³»ç»Ÿé…ç½®ä¸»æ•°æ®ï¼ˆå­—å…¸/租户/公司配置;剔身份/菜单/日志)", [
        ("system_dict_type", "deleted=0"),
        ("system_dict_data", "deleted=0"),
        ("system_tenant", "deleted=0"),
        ("system_tenant_package", "deleted=0"),
        ("system_company_config", "deleted=0"),
    ]),
    ("⑥ AI é…ç½®ï¼ˆä»…模型+key)+ åŸºç¡€è®¾æ–½é…ç½®ç®¡ç†", [
        ("ai_model", "deleted=0"),
        ("ai_api_key", "deleted=0"),
        ("infra_config", "deleted=0"),
        ("infra_job", "deleted=0"),
        ("infra_file_config", "deleted=0"),
        ("infra_data_source_config", "deleted=0"),
        ("infra_codegen_table", "deleted=0"),
        ("infra_codegen_column", "deleted=0"),
    ]),
]
def run(cmd):
    p = subprocess.run(cmd, capture_output=True)
    if p.returncode != 0:
        sys.stderr.write("FAILED: %s\n%s\n" % (" ".join(cmd), p.stderr.decode("utf-8", "replace")))
        sys.exit(1)
    return p.stdout.decode("utf-8")
def strip_mysqldump_noise(text):
    out = []
    for line in text.splitlines():
        if line.startswith("/*!") or line.startswith("--"):
            continue
        out.append(line)
    return out
def fetch_tables():
    sql = ("SELECT table_name FROM information_schema.tables "
           "WHERE table_schema='%s' AND table_type='BASE TABLE' ORDER BY table_name" % DB)
    out = run([MYSQL, "-u" + USER, "-p" + PWD, "--default-character-set=utf8mb4", "-N", "-B", "-e", sql])
    return [l.strip() for l in out.splitlines() if l.strip()]
def fetch_view_count():
    sql = ("SELECT COUNT(*) FROM information_schema.views WHERE table_schema='%s'" % DB)
    out = run([MYSQL, "-u" + USER, "-p" + PWD, "--default-character-set=utf8mb4", "-N", "-B", "-e", sql])
    return int(out.strip())
def dump_ddl(tables):
    """一次导出全部表结构,切分成 {表名: ddl块}。"""
    out = run([MYSQLDUMP, "-u" + USER, "-p" + PWD] + COMMON + ["--no-data", DB] + tables)
    lines = strip_mysqldump_noise(out)
    blocks = {}
    cur = None
    buf = []
    for line in lines:
        m = re.match(r"^DROP TABLE IF EXISTS `([^`]+)`;", line)
        if m:
            if cur:
                blocks[cur] = "\n".join(buf).rstrip()
            cur = m.group(1)
            buf = [line]
        elif cur is not None:
            buf.append(line)
    if cur:
        blocks[cur] = "\n".join(buf).rstrip()
    return blocks
def dump_data(tables_filters):
    """tables_filters: [(table, where)] ç›¸åŒ where çš„分组调用;返回 {表名: insert语句}。"""
    groups = {}
    for t, w in tables_filters:
        if w is None:
            groups.setdefault(None, []).append(t)
        else:
            groups.setdefault(w, []).append(t)
    data = {}
    for where, tables in groups.items():
        cmd = [MYSQLDUMP, "-u" + USER, "-p" + PWD] + COMMON + \
              ["--no-create-info", "--complete-insert", "--hex-blob", "--order-by-primary"]
        if where is not None:
            cmd.append("--where=" + where)
        cmd.append(DB)
        cmd += tables
        out = run(cmd)
        lines = strip_mysqldump_noise(out)
        stmt_lines = []
        for line in lines:
            if not line.strip():
                continue
            if line.startswith("INSERT INTO "):
                stmt_lines = [line]
            elif stmt_lines:
                stmt_lines.append(line)
            else:
                sys.stderr.write("UNEXPECTED line: %s\n" % line[:120])
                continue
            stmt = "\n".join(stmt_lines)
            if stmt.rstrip().endswith(";"):
                m = re.match(r"^INSERT INTO `([^`]+)`", stmt)
                data[m.group(1)] = stmt.rstrip()
                stmt_lines = []
    return data
def fetch_counts(tables_filters):
    parts = []
    for t, w in tables_filters:
        parts.append("SELECT '%s' AS t, COUNT(*) AS c FROM `%s`%s"
                     % (t, t, (" WHERE " + w) if w else ""))
    sql = " UNION ALL ".join(parts)
    out = run([MYSQL, "-u" + USER, "-p" + PWD, "--default-character-set=utf8mb4", "-N", "-B", DB, "-e", sql])
    counts = {}
    for line in out.splitlines():
        if not line.strip():
            continue
        k, v = line.split("\t")
        counts[k] = int(v)
    return counts
def load_stage2_block():
    with open(SRC_0918, encoding="utf-8") as f:
        lines = f.read().splitlines()
    for i, line in enumerate(lines):
        if "阶段二:补导身份" in line:
            start = i - 1  # ä¸Šæ–¹ =============== æ¨ªçº¿
            return "\n".join(lines[start:]).rstrip() + "\n"
    raise RuntimeError("0918 æ–‡ä»¶æœªæ‰¾åˆ°é˜¶æ®µäºŒæ®µè½")
HEADER = """-- =======================================================================
-- æ–‡ä»¶ï¼šconfig_export_all_20260928.sql
-- ç”¨é€”:ruoyi-vue-pro「全库表结构 + 6 ç±»é…ç½®æ•°æ®ã€å¯¼å‡ºï¼Œä¾›åŒç»“构全新库初始化
--       é˜¶æ®µä¸€ï¼šå…¨åº“ {n_tables} å¼ è¡¨ CREATE TABLE + 6 ç±»é…ç½®æ•°æ®ï¼ˆå­—面量,自包含,不依赖源库在线)
--       é˜¶æ®µäºŒï¼šè·¨åº“补导 èœå•/角色/用户/OAuth2 ç­‰èº«ä»½æ•°æ®ï¼ˆæ‰§è¡ŒæœŸä»Žæºåº“拷贝)
-- æ¥æºåº“:ruoyi-vue-pro
-- åŸºçº¿ï¼š2026-09-18 ç‰ˆï¼ˆ419 å¼ è¡¨ï¼‰â†’ æœ¬æ¬¡ä¸º**全量重生成**(不再基于旧文件打补丁):
--       ç›´æŽ¥æŒ‰å½“前源库最新状态重导,一次性覆盖 0918 ä¹‹åŽå…¨éƒ¨ç»“构与配置漂移。
-- æœ¬æ¬¡å˜æ›´ï¼ˆç›¸å¯¹ 0918,2026-09-28):
--   â‘  è¡¨ç»“构:419 â†’ {n_tables} å¼ 
--      - åˆ é™¤ 6 å¼ åŽ†å²å¤‡ä»½è¡¨ï¼ˆbpm_bak_20260904_bytearray/category/deployment/model/procdef/procinfo,源库已无)
--      + æ–°å¢ž 3 å¼ è¡¨ï¼ˆafter_sale_problem、after_sale_visit、bpm_process_auto_approve_config,仅结构,源库无数据)
--      + 10 å¼ è¡¨ DDL ä¸Ž 0918 ä¸åŒï¼Œä»¥å½“前源库为准(after_sale_ticket、mes_qc_indicator、mes_qc_indicator_result_detail、
--        mes_qc_ipqc、mes_qc_iqc、mes_qc_oqc、mes_qc_rqc、mes_qc_template、mes_qc_template_indicator、mes_wm_return_issue_line)
--   â‘¡ é…ç½®æ•°æ®ä»¥å½“前源库为准重导(下列为 0918 å£°æ˜Žå€¼ â†’ æœ¬æ¬¡å¯¼å‡ºå€¼ï¼‰ï¼š
--      - system_dict_type 241→{n_dicttype}、system_dict_data 1157→{n_dictdata}(含 hrm_holiday_type èŠ‚å‡æ—¥ç±»åž‹ç­‰æ–°å¢žå­—å…¸ï¼‰
--      - system_company_config 0→1、bi_chart_config 36→37、bi_warehouse_coordinate 1→2、ai_api_key 1→3、bpm_user_group 3→4
--      - bpm_business_attach 11→4、bpm_category 22→15、bpm_form 4→3、bpm_process_listener 1→0(源库已删除的行不再导出)
--      - ååŒæµç¨‹å®šä¹‰æŒ‰æºåº“当前**激活**集合重导({n_procdef} ä¸ªæµç¨‹å®šä¹‰ + å¯¹åº”部署/字节资源 + è®¾è®¡æ€æ¨¡åž‹ï¼‰
--   â‘¢ èœå•/权限节点(节假日配置 6014/60141-60145、各模块导入按钮 1075311/1075417-1075420 ç­‰ï¼‰ç”±é˜¶æ®µäºŒ
--      è·¨åº“全量拷贝自动携带,无需改动本文件数据段;在此登记以说明本版文件确实覆盖这些变更。
-- éªŒè¯ï¼š{verify}
-- èŒƒå›´ï¼šå…¨åº“ BASE TABLE å…± {n_tables} å¼ ï¼Œå…¨éƒ¨è¾“出 CREATE TABLE;
--       ä»…以下配置类表带数据(INSERT),其余业务/系统/运行历史表只保留空表结构:
--     â‘  ååŒå®¡æ‰¹æµç¨‹ï¼ˆå¼•擎元数据+定义,仅激活流程,剔历史/运行/身份):act_ge_property、act_id_property、act_re_procdef、act_re_deployment、act_ge_bytearray、act_re_model、bpm_process_definition_info
--     â‘¡ ååŒï¼ˆä¸šåŠ¡é™„ä»¶é…ç½® + åˆ†ç±» + è¡¨å• + æ‰©å±•配置):bpm_business_attach、bpm_category、bpm_form、bpm_user_group、bpm_process_listener、bpm_process_expression
--     â‘¢ BI å†³ç­–大屏 / go-view:bi_dashboard、bi_chart_config、bi_data_source_config、bi_warehouse_coordinate、report_go_view_project
--     â‘£ MES ç¼–码规则(剔流水记录):mes_md_auto_code_rule、mes_md_auto_code_part
--     â‘¤ ç³»ç»Ÿé…ç½®ä¸»æ•°æ®ï¼ˆå­—å…¸/租户/公司配置;剔身份/菜单/日志):system_dict_type、system_dict_data、system_tenant、system_tenant_package、system_company_config
--     âš  èº«ä»½/权限类表(system_users/role/menu/role_menu/user_role/dept/post/oauth2_client)此处仅建空表结构,数据由文件末尾"阶段二"跨库补导。
--     â‘¥ AI é…ç½®ï¼ˆä»…模型+key)+ åŸºç¡€è®¾æ–½é…ç½®ç®¡ç†ï¼šai_model、ai_api_key、infra_config、infra_job、infra_file_config、infra_data_source_config、infra_codegen_table、infra_codegen_column
-- è¿‡æ»¤ï¼šååŒæµç¨‹ä»…保留激活(SUSPENSION_STATE_=1)定义及其部署资源(bpmn/png å­—节);
--       è®¾è®¡æ€æ¨¡åž‹ act_re_model ä¸Žæ¨¡åž‹æºç å­—节(NAME_=source/source-extra)按源库全量导出(含个别已删除流程残留的模型,
--       æºåº“实际存在即保留,供流程设计器打开);运行期变量字节(var-*/hist.var-*)一律不导出;
--       å…¶ä½™é…ç½®è¡¨ä»…导 deleted=0;编码规则剔除流水记录 mes_md_auto_code_record。
-- âš  è„šæœ¬å«å…¨åº“ DROP TABLE IF EXISTS,会把除本脚本建表外的所有表删除重建,
--   ä¸šåŠ¡è¡¨æ•°æ®å°†å…¨éƒ¨æ¸…ç©ºï¼Œä»…å¯åœ¨å…¨æ–°/可覆盖的空库执行!
-- =======================================================================
"""
VERIFY_PLACEHOLDER = "(待空库整份执行验证后回填;见文件末尾生成备注)"
def main():
    tables = fetch_tables()
    views = fetch_view_count()
    if views:
        print("WARNING: æºåº“存在 %d ä¸ªè§†å›¾ï¼Œæœ¬ç”Ÿæˆå™¨æœªå¤„理" % views)
    ddl = dump_ddl(tables)
    missing = [t for t in tables if t not in ddl]
    extra = [t for t in ddl if t not in tables]
    if missing or extra:
        print("DDL é›†åˆä¸ä¸€è‡´ missing=%s extra=%s" % (missing, extra))
        sys.exit(1)
    print("tables=%d ddl_blocks=%d" % (len(tables), len(ddl)))
    all_cfg = [tf for _, tfs in SECTIONS for tf in tfs]
    cfg_names = [t for t, _ in all_cfg]
    counts = fetch_counts(all_cfg)
    data = dump_data(all_cfg)
    for t in cfg_names:
        if counts.get(t, 0) > 0 and t not in data:
            print("MISSING data for %s (count=%d)" % (t, counts[t]))
            sys.exit(1)
    print("config tables=%d, with data=%d" % (len(cfg_names), len(data)))
    rest = [t for t in tables if t not in set(cfg_names)]
    print("rest tables=%d" % len(rest))
    parts = []
    header = HEADER.format(
        n_tables=len(tables),
        n_dicttype=counts["system_dict_type"],
        n_dictdata=counts["system_dict_data"],
        n_procdef=counts["act_re_procdef"],
        verify=VERIFY_PLACEHOLDER,
    )
    parts.append(header)
    parts.append("SET NAMES utf8mb4;\nSET FOREIGN_KEY_CHECKS = 0;\n"
                 "SET @OLD_SQL_MODE = @@SQL_MODE, SQL_MODE = 'NO_AUTO_VALUE_ON_ZERO';\n\n")
    for title, tfs in SECTIONS:
        parts.append("\n-- ***********************************************************************\n")
        parts.append("-- %s(DDL + æ•°æ®ï¼‰\n" % title)
        parts.append("-- ***********************************************************************\n\n")
        for t, w in tfs:
            n = counts.get(t, 0)
            parts.append("-- ============ è¡¨ï¼š%s(导出 %d è¡Œï¼‰ ============\n" % (t, n))
            parts.append(ddl[t] + "\n")
            if n > 0:
                parts.append(data[t] + "\n")
            parts.append("\n")
    parts.append("\n-- ***********************************************************************\n")
    parts.append("-- å…¶ä½™ %d å¼ è¡¨ï¼ˆä»…表结构,无数据)\n" % len(rest))
    parts.append("-- ***********************************************************************\n\n")
    for t in rest:
        parts.append("-- ============ è¡¨ï¼š%s(仅 DDL,无数据) ============\n" % t)
        parts.append(ddl[t] + "\n\n")
    parts.append("\nSET FOREIGN_KEY_CHECKS = 1;\nSET SQL_MODE = @OLD_SQL_MODE;\n\n\n")
    parts.append(load_stage2_block())
    text = "".join(parts)
    with open(OUT, "w", encoding="utf-8", newline="\n") as f:
        f.write(text)
    print("WROTE %s bytes=%d lines=%d" % (OUT, len(text.encode("utf-8")), text.count("\n")))
if __name__ == "__main__":
    main()
docs/sql/hrm_holiday_config_fix.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,59 @@
-- =====================================================
-- HRM è€ƒå‹¤èŠ‚å‡æ—¥é…ç½® ä¿®å¤è„šæœ¬
--
-- èƒŒæ™¯ï¼šè¯¥é¡µé¢ï¼ˆèœå• 6014「节假日配置」)存在两处缺失,导致页面不可用:
--   1. ç¼ºå­—å…¸ hrm_holiday_type:前端「类型」单选与列表「类型」列取不到选项/文本,
--      ä¸”类型为必选,新增/编辑无法保存(hrm_attendance_holiday è¡¨é•¿æœŸ 0 è¡Œï¼‰ã€‚
--   2. ç¼ºæŒ‰é’®æƒé™ hrm:attendance-holiday:query:后端 /page、/get ä¾èµ–该权限码,
--      éžè¶…管角色(菜单已授权)因无此权限码而全部 403。
--
-- å†…容:
--   â‘  å­—典类型 hrm_holiday_type(1-休息日 / 2-工作日,对应 HrmAttendanceHolidayTypeEnum)
--   â‘¡ èœå•按钮 60141「节假日配置查询」= hrm:attendance-holiday:query
--   â‘¢ å°† 60141 æŽˆäºˆå·²æ‹¥æœ‰ã€ŒèŠ‚å‡æ—¥é…ç½®æ–°å¢žã€(60142) çš„全部角色
--
-- å¹‚等:先删后插,可重复执行
-- æ‰§è¡Œæ–¹å¼ï¼ˆé¿å…ä¸­æ–‡ä¹±ç ï¼‰ï¼š
--   cmd /c "mysql -uroot -p1234 --default-character-set=utf8mb4 ruoyi-vue-pro < docs\sql\hrm_holiday_config_fix.sql"
-- =====================================================
SET NAMES utf8mb4;
-- ------------------------------------------------------------
-- â‘  å­—典:hrm_holiday_type
-- ------------------------------------------------------------
DELETE FROM `system_dict_data` WHERE `dict_type` = 'hrm_holiday_type';
DELETE FROM `system_dict_type` WHERE `type` = 'hrm_holiday_type';
INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
VALUES (1065500, 'HRM èŠ‚å‡æ—¥ç±»åž‹', 'hrm_holiday_type', 0, '1', NOW(), '1', NOW(), b'0');
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
VALUES
  (1065501, 1, '休息日', '1', 'hrm_holiday_type', 0, 'success', '', '1', NOW(), '1', NOW(), b'0'),
  (1065502, 2, '工作日', '2', 'hrm_holiday_type', 0, 'primary', '', '1', NOW(), '1', NOW(), b'0');
-- ------------------------------------------------------------
-- â‘¡ èœå•按钮:节假日配置查询
-- ------------------------------------------------------------
DELETE FROM `system_role_menu` WHERE `menu_id` = 60141;
DELETE FROM `system_menu` WHERE `id` = 60141;
INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
VALUES (60141, '节假日配置查询', 'hrm:attendance-holiday:query', 3, 1, 6014, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0');
-- ------------------------------------------------------------
-- â‘¢ æŽˆæƒï¼šè·Ÿéšã€ŒèŠ‚å‡æ—¥é…ç½®æ–°å¢žã€(60142) çš„角色
-- ------------------------------------------------------------
INSERT INTO `system_role_menu` (`role_id`, `menu_id`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT DISTINCT rm.`role_id`, 60141, '1', NOW(), '1', NOW(), b'0'
FROM `system_role_menu` rm
WHERE rm.`menu_id` = 60142 AND rm.`deleted` = b'0';
-- ------------------------------------------------------------
-- æ ¡éªŒ
-- ------------------------------------------------------------
SELECT '  dict_type ' AS item, COUNT(*) AS cnt FROM `system_dict_type` WHERE `type` = 'hrm_holiday_type'
UNION ALL SELECT '  dict_data ', COUNT(*) FROM `system_dict_data` WHERE `dict_type` = 'hrm_holiday_type'
UNION ALL SELECT '  menu_60141', COUNT(*) FROM `system_menu` WHERE `id` = 60141
UNION ALL SELECT '  roles     ', COUNT(*) FROM `system_role_menu` WHERE `menu_id` = 60141 AND `deleted` = b'0';
docs/sql/mes_qc_import_menu.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,37 @@
-- =====================================================
-- MES è´¨æ£€å•导入 èœå•与按钮权限
-- æ–°å¢žæŒ‰é’®æƒé™ï¼š
--   mes:qc-iqc:import  æ¥æ–™æ£€éªŒåˆ—表「批量导入」按钮(parent_id = 5640)
--   mes:qc-ipqc:import è¿‡ç¨‹æ£€éªŒåˆ—表「批量导入」按钮(parent_id = 5650)
--   mes:qc-oqc:import  å‡ºè´§æ£€éªŒåˆ—表「批量导入」按钮(parent_id = 5660)
--
-- æ‰§è¡Œæ–¹å¼ï¼ˆé¿å…ä¸­æ–‡èœå•名乱码):
--   cmd /c "mysql -uroot -p1234 --default-character-set=utf8mb4 ruoyi-vue-pro < docs\sql\mes_qc_import_menu.sql"
-- =====================================================
SET NAMES utf8mb4;
INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
VALUES (1075418, '来料检验导入', 'mes:qc-iqc:import', 3, 6, 5640, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0');
INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
VALUES (1075419, '过程检验导入', 'mes:qc-ipqc:import', 3, 6, 5650, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0');
INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
VALUES (1075420, '出货检验导入', 'mes:qc-oqc:import', 3, 6, 5660, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0');
-- ä¸Žè´¨æ£€æ£€éªŒå•其它按钮(查询/创建/更新/删除/导出)授予相同的角色
INSERT INTO `system_role_menu` (`role_id`, `menu_id`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
VALUES
  (2, 1075418, '1', NOW(), '1', NOW(), b'0'),
  (155, 1075418, '1', NOW(), '1', NOW(), b'0'),
  (160, 1075418, '1', NOW(), '1', NOW(), b'0'),
  (162, 1075418, '1', NOW(), '1', NOW(), b'0'),
  (2, 1075419, '1', NOW(), '1', NOW(), b'0'),
  (155, 1075419, '1', NOW(), '1', NOW(), b'0'),
  (160, 1075419, '1', NOW(), '1', NOW(), b'0'),
  (162, 1075419, '1', NOW(), '1', NOW(), b'0'),
  (2, 1075420, '1', NOW(), '1', NOW(), b'0'),
  (155, 1075420, '1', NOW(), '1', NOW(), b'0'),
  (160, 1075420, '1', NOW(), '1', NOW(), b'0'),
  (162, 1075420, '1', NOW(), '1', NOW(), b'0');
docs/sql/qc_inspection_ledger_menu.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,53 @@
-- =====================================================
-- è´¨é‡ç®¡ç† - æ£€æµ‹ç»“果台账 èœå•与权限
--
-- èƒŒæ™¯ï¼šè´¨æ£€å„模块(IQC/IPQC/OQC/RQC)的检测结果原先分散在各自列表内,
--       ç¼ºå°‘跨类型的统一查询与台帐导出。本次新增「检测结果台账」页面:
--       æŒ‰åˆ†ç±»ï¼ˆæ£€éªŒç±»åž‹ï¼‰+ æ£€æµ‹æ—¥æœŸåŒºé—´ + ç‰©æ–™/批次/检测结果/检测人筛选,
--       åˆ—表直接展示检测项实测结果,并支持导出 Excel å°è´¦ç”¨äºŽæŸ¥è¯¢ã€æ‰“印、存档。
--
-- å†…容:
--   â‘  èœå• 1075421「检测结果台账」 type=2,component=mes/qc/ledger/index
--   â‘¡ æŒ‰é’® 1075422「检测结果台账查询」= mes:qc-inspection-ledger:query
--   â‘¢ æŒ‰é’® 1075423「检测结果台账导出」= mes:qc-inspection-ledger:export
--   â‘£ å°†ä»¥ä¸Š 3 ä¸ªèœå•授予已拥有「来料检验查询」(5641) çš„全部角色
--
-- å¹‚等:先删后插,可重复执行
-- æ‰§è¡Œæ–¹å¼ï¼ˆé¿å…ä¸­æ–‡ä¹±ç ï¼‰ï¼š
--   cmd /c "mysql -uroot -p1234 --default-character-set=utf8mb4 ruoyi-vue-pro < docs\sql\qc_inspection_ledger_menu.sql"
-- =====================================================
SET NAMES utf8mb4;
-- ------------------------------------------------------------
-- â‘  èœå•:检测结果台账
-- ------------------------------------------------------------
DELETE FROM `system_role_menu` WHERE `menu_id` IN (1075421, 1075422, 1075423);
DELETE FROM `system_menu` WHERE `id` IN (1075421, 1075422, 1075423);
INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
VALUES (1075421, '检测结果台账', '', 2, 14, 5500, 'inspection-ledger', 'ep:document', 'mes/qc/ledger/index', 'MesQcInspectionLedger', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0');
-- ------------------------------------------------------------
-- â‘¡ æŒ‰é’®ï¼šæŸ¥è¯¢ / å¯¼å‡º
-- ------------------------------------------------------------
INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
VALUES (1075422, '检测结果台账查询', 'mes:qc-inspection-ledger:query', 3, 1, 1075421, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
       (1075423, '检测结果台账导出', 'mes:qc-inspection-ledger:export', 3, 2, 1075421, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0');
-- ------------------------------------------------------------
-- â‘¢ æŽˆæƒï¼šè·Ÿéšã€Œæ¥æ–™æ£€éªŒæŸ¥è¯¢ã€(5641) çš„角色
-- ------------------------------------------------------------
INSERT INTO `system_role_menu` (`role_id`, `menu_id`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT DISTINCT rm.`role_id`, m.`id`, '1', NOW(), '1', NOW(), b'0'
FROM `system_role_menu` rm
    JOIN (SELECT 1075421 AS `id` UNION ALL SELECT 1075422 UNION ALL SELECT 1075423) m
WHERE rm.`menu_id` = 5641 AND rm.`deleted` = b'0';
-- ------------------------------------------------------------
-- æ ¡éªŒ
-- ------------------------------------------------------------
SELECT '  menu_1075421' AS item, COUNT(*) AS cnt FROM `system_menu` WHERE `id` = 1075421
UNION ALL SELECT '  menu_1075422', COUNT(*) FROM `system_menu` WHERE `id` = 1075422
UNION ALL SELECT '  menu_1075423', COUNT(*) FROM `system_menu` WHERE `id` = 1075423
UNION ALL SELECT '  roles       ', COUNT(*) FROM `system_role_menu` WHERE `menu_id` IN (1075421, 1075422, 1075423) AND `deleted` = b'0';
docs/sql/role_permission_sync_20260924.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,266 @@
-- =====================================================
-- è‡ªå»ºè§’色「看不到数据」修复:权限节点补齐 + è§’色自动继承
-- æ—¥æœŸï¼š2026-09-24
--
-- èƒŒæ™¯ï¼š
--   æ¡†æž¶æƒé™æ ¡éªŒï¼ˆPermissionServiceImpl#hasAnyPermission)为严格模式:
--   æŽ§åˆ¶å™¨ @PreAuthorize ä¸­çš„æƒé™æ ‡è¯†è‹¥åœ¨ system_menu ä¸­ä¸å­˜åœ¨ï¼Œé™¤è¶…管外一律判定无权限。
--   æŽ’查发现全项目有 141 ä¸ªæŽ§åˆ¶å™¨æƒé™æ ‡è¯†åœ¨ system_menu ä¸­ç¼ºå¤±/命名不匹配,
--   å¯¼è‡´è‡ªå»ºè§’色(以及除超管外的所有角色)打开页面后接口 403、列表为空——即"看不到数据"。
--   ç³»ç»Ÿè‡ªå¸¦ã€Œè¶…级管理员」角色因代码直返全部权限而正常,两者对比即用户反馈的现象。
--
-- æœ¬è„šæœ¬å†…å®¹ï¼š
--   â‘  è¡¥é½ HRM/MES/ERP/CRM/SRM/BI/BPM ç¼ºå¤±çš„æŒ‰é’®æƒé™èŠ‚ç‚¹ï¼ˆtype=3)
--   â‘¡ æ–°å¢ž HRM「薪资结构」页面菜单(type=2,此前前端页面无菜单入口)
--   â‘¢ æ–°èŠ‚ç‚¹è‡ªåŠ¨æŽˆæƒç»™å·²æŒæœ‰å¯¹åº”é¡µé¢èœå•çš„å…¨éƒ¨è§’è‰²ï¼ˆåŒæ­¥/继承机制)
--   â‘£ ä¿®æ­£å”®åŽã€ŒæŸ¥çœ‹å…¨éƒ¨ã€æ•°æ®æƒé™èŠ‚ç‚¹ï¼ˆpermission ç”± aftersales:admin æ”¹ä¸º aftersales:view-all,
--      ä¸Ž AfterSalePermissionUtils#hasViewAllPermission çš„æ ¡éªŒæ ‡è¯†ä¸€è‡´ï¼‰
--   â‘¤ CRM「查看全部客户」(602583) ç»§æ‰¿æŽˆæƒï¼šå·²æŒæœ‰ crm:customer:query çš„角色自动获得
--
-- é…å¥—代码变更:yudao-module-hrm 9 ä¸ªæŽ§åˆ¶å™¨ @PreAuthorize æƒé™æ ‡è¯†å·²å¯¹é½èœå•命名
--   ï¼ˆhrm:attendance:* â†’ hrm:attendance-record/exception/rule/summary:*;
--    hrm:salary:* â†’ hrm:salary-calculation/payment:*;
--    hrm:social-security:* â†’ hrm:employee-social-security/social-security-scheme:*;
--    hrm:tax-rate:* â†’ hrm:tax-rate-config:*),需重新编译并重启后端后生效。
--
-- å¹‚等:先删后插,可重复执行
-- æ‰§è¡Œæ–¹å¼ï¼ˆé¿å…ä¸­æ–‡ä¹±ç ï¼‰ï¼š
--   cmd /c "mysql -uroot -p1234 --default-character-set=utf8mb4 ruoyi-vue-pro < docs\sql\role_permission_sync_20260924.sql"
--
-- æ‰§è¡ŒåŽç”Ÿæ•ˆæ¡ä»¶ï¼š
--   1. é‡å¯åŽç«¯ï¼ˆæŽ§åˆ¶å™¨å˜æ›´ + èœå•缓存)
--   2. æƒé™ç¼“存刷新:任意角色在「角色管理 â†’ èœå•权限」里保存一次(触发权限缓存清空),
--      æˆ–重启 Redis;否则此前 403 è¯·æ±‚遗留的"空权限"缓存会继续拦截(新权限标识不受影响)
--   3. ç›®æ ‡ç”¨æˆ·é‡æ–°ç™»å½•(刷新登录态中的权限列表)
-- =====================================================
SET NAMES utf8mb4;
-- ------------------------------------------------------------
-- â‘  æ¸…理旧数据(幂等)
-- ------------------------------------------------------------
DELETE FROM `system_role_menu` WHERE `menu_id` BETWEEN 1080001 AND 1080078;
DELETE FROM `system_menu` WHERE `id` BETWEEN 1080001 AND 1080078;
-- ------------------------------------------------------------
-- â‘¡ æ–°å¢žæŒ‰é’®æƒé™èŠ‚ç‚¹ï¼ˆtype=3)
-- ------------------------------------------------------------
INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
VALUES
-- HRM è€ƒå‹¤è®°å½•(6011)
(1080001, '考勤记录查询', 'hrm:attendance-record:query', 3, 10, 6011, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- HRM è€ƒå‹¤å¼‚常(6012)
(1080002, '考勤异常查询', 'hrm:attendance-exception:query', 3, 10, 6012, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080003, '考勤异常修改', 'hrm:attendance-exception:update', 3, 20, 6012, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080004, '考勤异常删除', 'hrm:attendance-exception:delete', 3, 30, 6012, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- HRM è€ƒå‹¤è§„则(6013)
(1080005, '考勤规则查询', 'hrm:attendance-rule:query', 3, 10, 6013, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- HRM è€ƒå‹¤ç»Ÿè®¡ï¼ˆ6015)
(1080006, '考勤统计查询', 'hrm:attendance-summary:query', 3, 10, 6015, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- HRM åŠ ç­ç”³è¯·ï¼ˆ1065211)
(1080007, '加班申请查询', 'hrm:overtime:query', 3, 10, 1065211, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080008, '加班管理', 'hrm:overtime:manage', 3, 20, 1065211, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- HRM è°ƒå²—申请(6004)
(1080009, '调岗申请查询', 'hrm:transfer:query', 3, 10, 6004, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080010, '调岗申请导出', 'hrm:transfer:export', 3, 20, 6004, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- HRM ç¦»èŒç”³è¯·ï¼ˆ6005)
(1080011, '离职申请查询', 'hrm:resignation:query', 3, 10, 6005, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080012, '离职申请导出', 'hrm:resignation:export', 3, 20, 6005, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080013, '离职交接', 'hrm:resignation:handover', 3, 30, 6005, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- HRM è¯·å‡ç”³è¯·ï¼ˆ6003)
(1080014, '请假申请导出', 'hrm:leave:export', 3, 10, 6003, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- HRM è–ªé…¬æ ¸ç®—(6021)
(1080015, '薪酬核算查询', 'hrm:salary-calculation:query', 3, 10, 6021, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080016, '薪酬核算删除', 'hrm:salary-calculation:delete', 3, 20, 6021, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080017, '薪酬核算保存', 'hrm:salary-calculation:save', 3, 30, 6021, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- HRM è–ªèµ„发放(6022)
(1080018, '薪资发放查询', 'hrm:salary-payment:query', 3, 10, 6022, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080019, '薪资发放生成', 'hrm:salary-payment:generate', 3, 20, 6022, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080020, '薪资发放删除', 'hrm:salary-payment:delete', 3, 30, 6022, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- HRM å‘˜å·¥ç¤¾ä¿å…¬ç§¯é‡‘档案(6023)
(1080021, '员工社保查询', 'hrm:employee-social-security:query', 3, 10, 6023, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- HRM ç¤¾ä¿å…¬ç§¯é‡‘方案(602445)
(1080022, '社保方案查询', 'hrm:social-security-scheme:query', 3, 10, 602445, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- HRM ä¸ªç¨Žç¨ŽçŽ‡é…ç½®ï¼ˆ6024)
(1080023, '个税税率查询', 'hrm:tax-rate-config:query', 3, 10, 6024, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- HRM å‘˜å·¥åˆåŒï¼ˆ1063105)
(1080024, '员工合同导入', 'hrm:employee-contract:import', 3, 10, 1063105, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- MES è®¡é‡å•位(602467)
(1080025, '计量单位查询', 'mes:md-unit-measure:query', 3, 10, 602467, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080026, '计量单位新增', 'mes:md-unit-measure:create', 3, 20, 602467, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080027, '计量单位修改', 'mes:md-unit-measure:update', 3, 30, 602467, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080028, '计量单位删除', 'mes:md-unit-measure:delete', 3, 40, 602467, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080029, '计量单位导出', 'mes:md-unit-measure:export', 3, 50, 602467, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080030, '计量单位导入', 'mdm:unit:import', 3, 60, 602467, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080031, '计量单位数据导出', 'mdm:unit:export', 3, 70, 602467, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- MES ç‰©æ–™åˆ†ç±»ï¼ˆ5110)
(1080032, '物料分类查询', 'mdm:item-category:query', 3, 10, 5110, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080033, '物料分类新增', 'mdm:item-category:create', 3, 20, 5110, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080034, '物料分类修改', 'mdm:item-category:update', 3, 30, 5110, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080035, '物料分类删除', 'mdm:item-category:delete', 3, 40, 5110, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- MES è½¦é—´è®¾ç½®ï¼ˆ5160)
(1080036, '车间导出', 'mes:md-workshop:export', 3, 10, 5160, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- MES åŸºç¡€å‚数(602474)
(1080037, '基础参数查询', 'mes:pd-process-param:query', 3, 10, 602474, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080038, '基础参数新增', 'mes:pd-process-param:create', 3, 20, 602474, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080039, '基础参数修改', 'mes:pd-process-param:update', 3, 30, 602474, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080040, '基础参数删除', 'mes:pd-process-param:delete', 3, 40, 602474, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080041, '基础参数导出', 'mes:pd-process-param:export', 3, 50, 602474, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- MES å‚数模板(602475)
(1080042, '参数模板查询', 'mes:pd-process-param-template:query', 3, 10, 602475, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080043, '参数模板新增', 'mes:pd-process-param-template:create', 3, 20, 602475, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080044, '参数模板修改', 'mes:pd-process-param-template:update', 3, 30, 602475, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080045, '参数模板删除', 'mes:pd-process-param-template:delete', 3, 40, 602475, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080046, '参数模板导出', 'mes:pd-process-param-template:export', 3, 50, 602475, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- MES ç”Ÿäº§è®¡åˆ’(602504)
(1080047, '生产计划查询', 'mes:pro-mps:query', 3, 10, 602504, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- MES è®¡é‡å™¨å…·ï¼ˆ5380)
(1080048, '计量器具导入', 'mes:dv-metering:import', 3, 10, 5380, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- MES åº“存现有量(5786)
(1080049, '库存现有量导入', 'mes:wm-material-stock:import', 3, 10, 5786, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- MES å¤–协发料(5881)
(1080050, '外协发料完成', 'mes:wm-outsource-issue:finish', 3, 10, 5881, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- MES é‡‡è´­é€€è´§ï¼ˆ5830)
(1080051, '采购退货完成', 'mes:wm-return-vendor:finish', 3, 10, 5830, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- MES ç›˜ç‚¹ä»»åŠ¡ï¼ˆ5957)
(1080052, '盘点任务完成', 'mes:wm-stock-taking-task:finish', 3, 10, 5957, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- MES äº§å“å…¥åº“(5819)
(1080053, '产品入库查询', 'mes:wm-product-produce:query', 3, 10, 5819, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- MES å››ç±»æ£€éªŒå•完成
(1080054, '来料检验完成', 'mes:qc-iqc:finish', 3, 10, 5640, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080055, '过程检验完成', 'mes:qc-ipqc:finish', 3, 10, 5650, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080056, '出货检验完成', 'mes:qc-oqc:finish', 3, 10, 5660, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080057, '退货检验完成', 'mes:qc-rqc:finish', 3, 10, 5670, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- MES è´¨æ£€ç®¡ç†ï¼ˆ5500):AI è¾…助判定(授权范围=四类检验页角色,见 â‘£ï¼‰
(1080058, '质检修改', 'mes:qc:update', 3, 50, 5500, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- ERP é‡‡è´­ç”³è¯·ï¼ˆ602498)
(1080059, '采购申请查询', 'erp:purchase-request:query', 3, 10, 602498, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080060, '采购申请提交', 'erp:purchase-request:submit', 3, 20, 602498, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080061, '采购申请生成订单', 'erp:purchase-request:generate-order', 3, 30, 602498, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- ERP é‡‡è´­è®¢å•(2666)
(1080062, '采购订单导入', 'erp:purchase-order:import', 3, 10, 2666, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- ERP é”€å”®è®¢å•(2638)
(1080063, '销售订单取消', 'erp:sale-order:cancel', 3, 10, 2638, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080064, '销售订单启用', 'erp:sale-order:enable', 3, 20, 2638, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080065, '销售订单导入', 'erp:sale-order:import', 3, 30, 2638, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- CRM å¼€ç¥¨ç®¡ç†ï¼ˆ1063093)
(1080066, '开票查询', 'crm:invoice:query', 3, 10, 1063093, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- CRM é”€å”®æŠ¥ä»·ï¼ˆ602512)
(1080067, '报价单查询', 'crm:sale-quotation:query', 3, 10, 602512, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080068, '报价单导出', 'crm:sale-quotation:export', 3, 20, 602512, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080069, '报价单转合同', 'crm:sale-quotation:convert-contract', 3, 30, 602512, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- SRM æ‹›æŠ•标管理(602545)
(1080070, '招标配置查询', 'srm:tender-config:query', 3, 10, 602545, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080071, '招标配置修改', 'srm:tender-config:update', 3, 20, 602545, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- BI å†³ç­–大屏(1063000,目录;授权范围=各主题大屏页角色,见 â‘¤ï¼‰
(1080072, '大屏查询', 'bi:dashboard:query', 3, 10, 1063000, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- BPM OA é€šçŸ¥å…¬å‘Šï¼ˆ602608)
(1080073, 'OA通知公告查询', 'bpm:oa-notice:query', 3, 10, 602608, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- HRM è–ªèµ„结构页面菜单(type=2,前端页面此前无菜单入口)
(1080074, '薪资结构', '', 2, 70, 6020, 'structure', 'ep:document', 'hrm/salary/structure/index', 'HrmSalaryStructure', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
-- HRM è–ªèµ„结构按钮(父级 1080074)
(1080075, '薪资结构查询', 'hrm:salary-structure:query', 3, 10, 1080074, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080076, '薪资结构新增', 'hrm:salary-structure:create', 3, 20, 1080074, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080077, '薪资结构修改', 'hrm:salary-structure:update', 3, 30, 1080074, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1080078, '薪资结构删除', 'hrm:salary-structure:delete', 3, 40, 1080074, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0');
-- ------------------------------------------------------------
-- â‘¢ è‡ªåŠ¨æŽˆæƒï¼šæ–°èŠ‚ç‚¹ â†’ å·²æŒæœ‰å¯¹åº”父页面菜单的全部角色(同步/继承机制)
-- ------------------------------------------------------------
INSERT INTO `system_role_menu` (`role_id`, `menu_id`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT DISTINCT rm.`role_id`, m.`id`, '1', NOW(), '1', NOW(), b'0'
FROM `system_role_menu` rm
JOIN (
    SELECT 1080001 AS `id`, 6011 AS `parent` UNION ALL SELECT 1080002, 6012 UNION ALL SELECT 1080003, 6012
    UNION ALL SELECT 1080004, 6012 UNION ALL SELECT 1080005, 6013 UNION ALL SELECT 1080006, 6015
    UNION ALL SELECT 1080007, 1065211 UNION ALL SELECT 1080008, 1065211
    UNION ALL SELECT 1080009, 6004 UNION ALL SELECT 1080010, 6004
    UNION ALL SELECT 1080011, 6005 UNION ALL SELECT 1080012, 6005 UNION ALL SELECT 1080013, 6005
    UNION ALL SELECT 1080014, 6003
    UNION ALL SELECT 1080015, 6021 UNION ALL SELECT 1080016, 6021 UNION ALL SELECT 1080017, 6021
    UNION ALL SELECT 1080018, 6022 UNION ALL SELECT 1080019, 6022 UNION ALL SELECT 1080020, 6022
    UNION ALL SELECT 1080021, 6023 UNION ALL SELECT 1080022, 602445 UNION ALL SELECT 1080023, 6024
    UNION ALL SELECT 1080024, 1063105
    UNION ALL SELECT 1080025, 602467 UNION ALL SELECT 1080026, 602467 UNION ALL SELECT 1080027, 602467
    UNION ALL SELECT 1080028, 602467 UNION ALL SELECT 1080029, 602467 UNION ALL SELECT 1080030, 602467
    UNION ALL SELECT 1080031, 602467
    UNION ALL SELECT 1080032, 5110 UNION ALL SELECT 1080033, 5110 UNION ALL SELECT 1080034, 5110
    UNION ALL SELECT 1080035, 5110
    UNION ALL SELECT 1080036, 5160
    UNION ALL SELECT 1080037, 602474 UNION ALL SELECT 1080038, 602474 UNION ALL SELECT 1080039, 602474
    UNION ALL SELECT 1080040, 602474 UNION ALL SELECT 1080041, 602474
    UNION ALL SELECT 1080042, 602475 UNION ALL SELECT 1080043, 602475 UNION ALL SELECT 1080044, 602475
    UNION ALL SELECT 1080045, 602475 UNION ALL SELECT 1080046, 602475
    UNION ALL SELECT 1080047, 602504 UNION ALL SELECT 1080048, 5380 UNION ALL SELECT 1080049, 5786
    UNION ALL SELECT 1080050, 5881 UNION ALL SELECT 1080051, 5830 UNION ALL SELECT 1080052, 5957
    UNION ALL SELECT 1080053, 5819
    UNION ALL SELECT 1080054, 5640 UNION ALL SELECT 1080055, 5650 UNION ALL SELECT 1080056, 5660
    UNION ALL SELECT 1080057, 5670
    UNION ALL SELECT 1080059, 602498 UNION ALL SELECT 1080060, 602498 UNION ALL SELECT 1080061, 602498
    UNION ALL SELECT 1080062, 2666
    UNION ALL SELECT 1080063, 2638 UNION ALL SELECT 1080064, 2638 UNION ALL SELECT 1080065, 2638
    UNION ALL SELECT 1080066, 1063093
    UNION ALL SELECT 1080067, 602512 UNION ALL SELECT 1080068, 602512 UNION ALL SELECT 1080069, 602512
    UNION ALL SELECT 1080070, 602545 UNION ALL SELECT 1080071, 602545
    UNION ALL SELECT 1080073, 602608
) m ON rm.`menu_id` = m.`parent`
WHERE rm.`deleted` = b'0'
  AND NOT EXISTS (SELECT 1 FROM `system_role_menu` x WHERE x.`role_id` = rm.`role_id` AND x.`menu_id` = m.`id` AND x.`deleted` = b'0');
-- è–ªèµ„结构页面菜单(1080074):授权给已持有薪酬管理下任一页面的角色
INSERT INTO `system_role_menu` (`role_id`, `menu_id`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT DISTINCT rm.`role_id`, 1080074, '1', NOW(), '1', NOW(), b'0'
FROM `system_role_menu` rm
WHERE rm.`menu_id` IN (6021, 6022, 6023, 6024, 602445, 602449) AND rm.`deleted` = b'0'
  AND NOT EXISTS (SELECT 1 FROM `system_role_menu` x WHERE x.`role_id` = rm.`role_id` AND x.`menu_id` = 1080074 AND x.`deleted` = b'0');
-- è–ªèµ„结构按钮(1080075-1080078):授权给持有薪资结构页面的角色
INSERT INTO `system_role_menu` (`role_id`, `menu_id`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT DISTINCT rm.`role_id`, m.`id`, '1', NOW(), '1', NOW(), b'0'
FROM `system_role_menu` rm
JOIN (SELECT 1080075 AS `id` UNION ALL SELECT 1080076 UNION ALL SELECT 1080077 UNION ALL SELECT 1080078) m
WHERE rm.`menu_id` = 1080074 AND rm.`deleted` = b'0'
  AND NOT EXISTS (SELECT 1 FROM `system_role_menu` x WHERE x.`role_id` = rm.`role_id` AND x.`menu_id` = m.`id` AND x.`deleted` = b'0');
-- ------------------------------------------------------------
-- â‘£ è´¨æ£€ä¿®æ”¹ï¼ˆmes:qc:update,1080058):授权给持有四类检验页面的角色
-- ------------------------------------------------------------
INSERT INTO `system_role_menu` (`role_id`, `menu_id`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT DISTINCT rm.`role_id`, 1080058, '1', NOW(), '1', NOW(), b'0'
FROM `system_role_menu` rm
WHERE rm.`menu_id` IN (5640, 5650, 5660, 5670) AND rm.`deleted` = b'0'
  AND NOT EXISTS (SELECT 1 FROM `system_role_menu` x WHERE x.`role_id` = rm.`role_id` AND x.`menu_id` = 1080058 AND x.`deleted` = b'0');
-- ------------------------------------------------------------
-- â‘¤ å¤§å±æŸ¥è¯¢ï¼ˆbi:dashboard:query,1080072):授权给持有任一主题大屏页面的角色
-- ------------------------------------------------------------
INSERT INTO `system_role_menu` (`role_id`, `menu_id`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT DISTINCT rm.`role_id`, 1080072, '1', NOW(), '1', NOW(), b'0'
FROM `system_role_menu` rm
WHERE rm.`menu_id` IN (1063001, 1063002, 1063003, 1063004, 1063005) AND rm.`deleted` = b'0'
  AND NOT EXISTS (SELECT 1 FROM `system_role_menu` x WHERE x.`role_id` = rm.`role_id` AND x.`menu_id` = 1080072 AND x.`deleted` = b'0');
-- ------------------------------------------------------------
-- â‘¥ CRM「查看全部客户」数据权限继承:已持有 crm:customer:query çš„角色自动获得 602583
-- ------------------------------------------------------------
INSERT INTO `system_role_menu` (`role_id`, `menu_id`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT DISTINCT rm.`role_id`, 602583, '1', NOW(), '1', NOW(), b'0'
FROM `system_role_menu` rm
JOIN `system_menu` m ON m.`id` = rm.`menu_id` AND m.`deleted` = b'0'
WHERE m.`permission` = 'crm:customer:query' AND rm.`deleted` = b'0'
  AND NOT EXISTS (SELECT 1 FROM `system_role_menu` x WHERE x.`role_id` = rm.`role_id` AND x.`menu_id` = 602583 AND x.`deleted` = b'0');
-- ------------------------------------------------------------
-- â‘¦ å”®åŽã€ŒæŸ¥çœ‹å…¨éƒ¨ã€æ•°æ®æƒé™èŠ‚ç‚¹ä¿®æ­£ï¼špermission ä¸ŽåŽç«¯æ ¡éªŒæ ‡è¯†å¯¹é½
-- ------------------------------------------------------------
UPDATE `system_menu`
SET `permission` = 'aftersales:view-all', `updater` = '1', `update_time` = NOW()
WHERE `id` = 602595 AND `deleted` = b'0' AND `permission` <> 'aftersales:view-all';
-- ------------------------------------------------------------
-- â‘§ æ ¡éªŒ
-- ------------------------------------------------------------
SELECT 'menu_nodes   ' AS item, COUNT(*) AS cnt FROM `system_menu` WHERE `id` BETWEEN 1080001 AND 1080078 AND `deleted` = b'0';
SELECT 'role_menu    ' AS item, COUNT(*) AS cnt FROM `system_role_menu` WHERE `menu_id` BETWEEN 1080001 AND 1080078 AND `deleted` = b'0';
SELECT 'view_all_602595' AS item, `permission` AS cnt FROM `system_menu` WHERE `id` = 602595;
SELECT 'crm_viewall  ' AS item, COUNT(DISTINCT `role_id`) AS cnt FROM `system_role_menu` WHERE `menu_id` = 602583 AND `deleted` = b'0';
yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/util/ExcelUtils.java
@@ -2,7 +2,12 @@
import cn.idev.excel.ExcelWriter;
import cn.idev.excel.FastExcelFactory;
import cn.idev.excel.annotation.ExcelProperty;
import cn.idev.excel.context.AnalysisContext;
import cn.idev.excel.converters.longconverter.LongStringConverter;
import cn.idev.excel.enums.CellExtraTypeEnum;
import cn.idev.excel.event.AnalysisEventListener;
import cn.idev.excel.metadata.CellExtra;
import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
import cn.iocoder.yudao.framework.excel.core.handler.ColumnWidthMatchStyleStrategy;
import cn.iocoder.yudao.framework.excel.core.handler.SelectSheetWriteHandler;
@@ -11,7 +16,13 @@
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
 * Excel å·¥å…·ç±»
@@ -64,4 +75,156 @@
        }
    }
    /**
     * è¯»å– Excel,并把「纵向合并单元格」的值填充到合并区域内的每一行,即把合并单元格拉平成普通表格。
     *
     * ä¾‹å¦‚ A2:A5 åˆå¹¶ã€å€¼ä¸º 100 æ—¶ï¼Œè¯»å–结果中第 2~5 è¡Œçš„该列均为 100。
     * å› ä¸º Excel åªåœ¨åˆå¹¶åŒºåŸŸå·¦ä¸Šè§’保存值,直接读取时其余行为空,会导致「同一张单的多行明细」无法识别。
     * æ³¨æ„ï¼šåªå¤„理单列的纵向合并;表头行的合并、跨列的横向合并不处理。
     *
     * @param file æ–‡ä»¶
     * @param head Excel head å¤´
     * @return æ•°æ®åˆ—表
     */
    public static <T> List<T> readFlat(MultipartFile file, Class<T> head) throws IOException {
        MergedCellFillReadListener<T> listener = new MergedCellFillReadListener<>(head);
        // å‚考 https://t.zsxq.com/zM77F å¸–子,增加 try å¤„理,兼容 windows åœºæ™¯
        try (InputStream inputStream = file.getInputStream()) {
            FastExcelFactory.read(inputStream, head, listener)
                    .extraRead(CellExtraTypeEnum.MERGE) // é¢å¤–读取合并单元格信息
                    .autoCloseStream(false) // ä¸è¦è‡ªåŠ¨å…³é—­ï¼Œäº¤ç»™ Servlet è‡ªå·±å¤„理
                    .doReadAllSync();
        }
        listener.fillMergedCell();
        return listener.getDataList();
    }
    /**
     * åˆå¹¶å•元格填充的读取监听器:读取过程中收集表头、数据行与合并区域,读取完成后把合并单元格的值填充到合并区域内的每一行
     */
    private static class MergedCellFillReadListener<T> extends AnalysisEventListener<T> {
        /**
         * è¡¨å¤´ï¼škey ä¸º sheet ç¼–号,value ä¸ºã€Œåˆ—号 -> è¡¨å¤´æ–‡æœ¬ã€
         */
        private final Map<Integer, Map<Integer, String>> sheetHeaderMap = new HashMap<>();
        /**
         * æ•°æ®è¡Œï¼škey ä¸ºã€Œsheet ç¼–号 - è¡Œå·ã€ï¼Œvalue ä¸ºæ•°æ®è¡Œ
         */
        private final Map<String, T> rowDataMap = new HashMap<>();
        /**
         * åˆå¹¶åŒºåŸŸï¼škey ä¸º sheet ç¼–号
         */
        private final Map<Integer, List<CellExtra>> sheetMergedCellMap = new HashMap<>();
        /**
         * ã€Œè¡¨å¤´æ–‡æœ¬ -> å­—段」,用于把合并单元格的值填充回字段
         */
        private final Map<String, Field> headerFieldMap = new HashMap<>();
        /**
         * é‡å¤å‡ºçŽ°çš„è¡¨å¤´æ–‡æœ¬ï¼Œæ— æ³•ç¡®å®šå¡«å……åˆ°å“ªä¸ªå­—æ®µï¼Œå¿½ç•¥
         */
        private final Set<String> ambiguousHeaderSet = new HashSet<>();
        /**
         * æ•°æ®åˆ—表,即读取结果
         */
        private final List<T> dataList = new ArrayList<>();
        private MergedCellFillReadListener(Class<T> head) {
            for (Field field : head.getDeclaredFields()) {
                ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class);
                if (excelProperty == null || excelProperty.value().length == 0
                        || excelProperty.value()[0].trim().isEmpty()) {
                    continue;
                }
                String header = excelProperty.value()[0].trim();
                if (headerFieldMap.containsKey(header)) {
                    ambiguousHeaderSet.add(header);
                    continue;
                }
                field.setAccessible(true);
                headerFieldMap.put(header, field);
            }
        }
        @Override
        public void invokeHeadMap(Map<Integer, String> headMap, AnalysisContext context) {
            sheetHeaderMap.put(context.readSheetHolder().getSheetNo(), headMap);
        }
        @Override
        public void invoke(T data, AnalysisContext context) {
            dataList.add(data);
            rowDataMap.put(buildRowKey(context.readSheetHolder().getSheetNo(),
                    context.readRowHolder().getRowIndex()), data);
        }
        @Override
        public void extra(CellExtra extra, AnalysisContext context) {
            if (extra.getType() == CellExtraTypeEnum.MERGE) {
                sheetMergedCellMap.computeIfAbsent(context.readSheetHolder().getSheetNo(), k -> new ArrayList<>())
                        .add(extra);
            }
        }
        @Override
        public void doAfterAllAnalysed(AnalysisContext context) {
        }
        /**
         * æŠŠåˆå¹¶å•元格的值,填充到合并区域内的每一行
         */
        public void fillMergedCell() {
            sheetMergedCellMap.forEach((sheetNo, mergedCellList) -> {
                Map<Integer, String> headerMap = sheetHeaderMap.get(sheetNo);
                if (headerMap == null) {
                    return;
                }
                mergedCellList.forEach(extra -> fillMergedCell(sheetNo, headerMap, extra));
            });
        }
        private void fillMergedCell(Integer sheetNo, Map<Integer, String> headerMap, CellExtra extra) {
            // åªå¤„理单列的纵向合并;跨列的横向合并(如表头合并)不处理
            if (extra.getFirstColumnIndex() == null || !extra.getFirstColumnIndex().equals(extra.getLastColumnIndex())) {
                return;
            }
            // è¡¨å¤´è¡Œçš„合并不处理
            if (extra.getFirstRowIndex() == null || extra.getFirstRowIndex() < 1) {
                return;
            }
            String header = headerMap.get(extra.getFirstColumnIndex());
            Field field = header == null ? null : headerFieldMap.get(header);
            if (field == null || ambiguousHeaderSet.contains(header)) {
                return;
            }
            T sourceData = rowDataMap.get(buildRowKey(sheetNo, extra.getFirstRowIndex()));
            if (sourceData == null) {
                return;
            }
            try {
                Object value = field.get(sourceData);
                if (value == null) {
                    return;
                }
                for (int rowIndex = extra.getFirstRowIndex() + 1; rowIndex <= extra.getLastRowIndex(); rowIndex++) {
                    T targetData = rowDataMap.get(buildRowKey(sheetNo, rowIndex));
                    if (targetData != null) {
                        field.set(targetData, value);
                    }
                }
            } catch (IllegalAccessException e) {
                // å­—段已 setAccessible,理论上不会发生;即使发生也忽略该合并单元格,不影响其它数据
            }
        }
        private String buildRowKey(Integer sheetNo, Integer rowIndex) {
            return sheetNo + "-" + rowIndex;
        }
        public List<T> getDataList() {
            return dataList;
        }
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/CrmContractController.java
@@ -24,6 +24,8 @@
import cn.iocoder.yudao.module.crm.enums.common.CrmAuditStatusEnum;
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.crm.service.business.CrmBusinessService;
import cn.iocoder.yudao.module.crm.service.contact.CrmContactService;
import cn.iocoder.yudao.module.crm.service.contract.CrmContractService;
@@ -81,6 +83,8 @@
    @Resource
    private MdmItemApi mdmItemApi;
    @Resource
    private MdmUnitMeasureApi mdmUnitMeasureApi;
    @Resource
    private AdminUserApi adminUserApi;
@@ -132,7 +136,13 @@
        contractVO.setProducts(BeanUtils.toBean(businessProducts, CrmContractRespVO.Product.class, businessProductVO ->
                MapUtils.findAndThen(itemMap, businessProductVO.getItemId(),
                        item -> businessProductVO.setItemCode(item.getCode())
                                .setItemName(item.getName()).setItemBarCode(item.getBarCode()))));
                                .setItemName(item.getName()).setBarCode(item.getBarCode())
                                .setItemSpecification(item.getSpecification()))));
        // æ‹¼æŽ¥è®¡é‡å•位名称
        Map<Long, MdmUnitMeasureRespDTO> unitMap = mdmUnitMeasureApi.getUnitMeasureMap(
                convertSet(businessProducts, CrmContractProductDO::getItemUnitId));
        contractVO.getProducts().forEach(product -> MapUtils.findAndThen(unitMap, product.getItemUnitId(),
                unit -> product.setItemUnitName(unit.getName())));
        return contractVO;
    }
@@ -192,7 +202,7 @@
                        .orderDate("2026-01-01 10:00:00").startTime("2026-01-01").endTime("2026-12-31")
                        .discountPercent(new BigDecimal("0")).depositPrice(new BigDecimal("0"))
                        .productCode("ITEM001").productName("示例产品A")
                        .count(100).contractPrice(new BigDecimal("20.00")).remark("示例数据,导入前请替换")
                        .count(new BigDecimal("100")).contractPrice(new BigDecimal("20.00")).remark("示例数据,导入前请替换")
                        .build());
        ExcelUtils.write(response, "合同导入模板.xls", "合同", CrmContractImportExcelVO.class, list);
    }
@@ -201,7 +211,8 @@
    @Operation(summary = "导入合同")
    @PreAuthorize("@ss.hasPermission('crm:contract:import')")
    public CommonResult<CrmContractImportRespVO> importContractExcel(@RequestParam("file") MultipartFile file) throws Exception {
        List<CrmContractImportExcelVO> list = ExcelUtils.read(file, CrmContractImportExcelVO.class);
        // readFlat:把纵向合并单元格(同一张合同的多行明细)拉平,否则除首行外的主表字段会读不到
        List<CrmContractImportExcelVO> list = ExcelUtils.readFlat(file, CrmContractImportExcelVO.class);
        return success(contractService.importContractList(list));
    }
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractImportExcelVO.java
@@ -13,6 +13,7 @@
 * ç®¡ç†åŽå° - åˆåŒå¯¼å…¥ Excel VO
 *
 * è¯´æ˜Žï¼šä¸€è¡Œ = ä¸€æ¡äº§å“æ˜Žç»†ï¼›ç›¸åŒâ€œåˆåŒç¼–号”的多行会合并为一张合同(主表字段取该组首行)。
 * ä¸»è¡¨å­—段(合同编号、合同名称、客户名称、时间、备注等)可以纵向合并单元格填写,读取时会自动填充到合并区域内的每一行。
 * â€œåˆåŒç¼–号”留空时,每一行会各自生成一张新合同(单条明细)。
 * è´Ÿè´£äººã€åˆåŒçŠ¶æ€ã€é‡‘é¢ ç”±ç³»ç»Ÿå¤„理:负责人 = å½“前登录用户;状态 = æœªæäº¤ï¼›é‡‘额按明细与优惠率自动计算。
 *
@@ -66,8 +67,8 @@
    private String productName;
    @ExcelProperty("数量")
    @Schema(description = "产品数量", requiredMode = Schema.RequiredMode.REQUIRED)
    private Integer count;
    @Schema(description = "产品数量,支持小数(如 53176.5)", requiredMode = Schema.RequiredMode.REQUIRED)
    private BigDecimal count;
    @ExcelProperty("合同单价(元)")
    @Schema(description = "产品合同单价;留空则取该物料的销售价")
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractRespVO.java
@@ -158,7 +158,9 @@
        @Schema(description = "物料名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "物料A")
        private String itemName;
        @Schema(description = "物料条码", example = "BAR001")
        private String itemBarCode;
        private String barCode;
        @Schema(description = "规格型号", example = "M8*20")
        private String itemSpecification;
        @Schema(description = "计量单位编号", example = "1")
        private Long itemUnitId;
        @Schema(description = "计量单位名称", example = "个")
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/controller/admin/quotation/vo/CrmSaleQuotationSaveReqVO.java
@@ -3,6 +3,7 @@
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@@ -21,6 +22,10 @@
    @Schema(description = "报价单编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "10430")
    private Long id;
    @Schema(description = "报价单号,可不填:新增时留空自动生成,修改时留空保持原值", example = "BJ20260901000001")
    @Size(max = 32, message = "报价单号长度不能超过 32 ä¸ªå­—符")
    private String no;
    @Schema(description = "报价单名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "报价单A")
    @NotNull(message = "报价单名称不能为空")
    private String name;
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/receivable/CrmReceivableSaveReqVO.java
@@ -32,13 +32,12 @@
    @NotNull(message = "合同编号不能为空")
    private Long contractId;
    @Schema(description = "关联的开票编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "25787")
    @Schema(description = "关联的开票编号,可不填(未开票先回款)", example = "25787")
    @DiffLogField(name = "关联开票", function = CrmInvoiceParseFunction.NAME)
    @NotNull(message = "关联开票编号不能为空")
    private Long invoiceId;
    @Schema(description = "回款计划编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
    @DiffLogField(name = "合同", function = CrmReceivablePlanParseFunction.NAME)
    @Schema(description = "回款计划编号,可不填", example = "1024")
    @DiffLogField(name = "回款计划", function = CrmReceivablePlanParseFunction.NAME)
    private Long planId;
    @Schema(description = "回款方式", example = "2")
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/dal/mysql/quotation/CrmSaleQuotationMapper.java
@@ -13,6 +13,7 @@
import cn.iocoder.yudao.module.crm.enums.quotation.CrmSaleQuotationStatusEnum;
import cn.iocoder.yudao.module.crm.util.CrmPermissionUtils;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@@ -28,6 +29,13 @@
        return selectOne(CrmSaleQuotationDO::getNo, no);
    }
    /**
     * æŒ‰æŠ¥ä»·å•号查询记录(不过滤逻辑删除,含已删除行)
     * ç”¨äºŽè‡ªå®šä¹‰å•号的唯一性预校验:uk_no å”¯ä¸€ç´¢å¼•覆盖已删除行,需一起判断才能给出可读提示
     */
    @Select("SELECT * FROM crm_sale_quotation WHERE no = #{no} LIMIT 1")
    CrmSaleQuotationDO selectAnyByNo(String no);
    default PageResult<CrmSaleQuotationDO> selectPage(CrmSaleQuotationPageReqVO pageReqVO, Long userId) {
        return selectPage(pageReqVO, userId, null);
    }
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java
@@ -114,7 +114,7 @@
    // ========== é”€å”®æŠ¥ä»· 1_020_015_000 ==========
    ErrorCode SALE_QUOTATION_NOT_EXISTS = new ErrorCode(1_020_015_000, "销售报价单不存在");
    ErrorCode SALE_QUOTATION_NO_EXISTS = new ErrorCode(1_020_015_001, "销售报价单号已存在");
    ErrorCode SALE_QUOTATION_NO_EXISTS = new ErrorCode(1_020_015_001, "报价单号【{}】已被占用,不能重复使用。请换一个编号,或清空编号由系统自动生成(已删除的报价单也会占用其原编号)");
    ErrorCode SALE_QUOTATION_UPDATE_FAIL_NOT_DRAFT = new ErrorCode(1_020_015_002, "销售报价单({})不是草稿状态,无法修改");
    ErrorCode SALE_QUOTATION_DELETE_FAIL_NOT_DRAFT = new ErrorCode(1_020_015_003, "销售报价单({})不是草稿状态,无法删除");
    ErrorCode SALE_QUOTATION_CONVERT_FAIL_NOT_DRAFT = new ErrorCode(1_020_015_004, "销售报价单({})不是草稿状态,无法转合同");
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
@@ -327,46 +327,58 @@
        CrmContractImportExcelVO head = rows.get(0);
        // 1. ä¸»è¡¨å¿…å¡«
        if (StrUtil.isBlank(head.getName())) {
            throw new IllegalArgumentException("合同名称不能为空");
            throw new IllegalArgumentException("合同名称不能为空:请填写模板中该行的「合同名称」列;"
                    + "若同一张合同的多行使用了合并单元格,需填写在合并区域的首行");
        }
        if (StrUtil.isBlank(head.getCustomerName())) {
            throw new IllegalArgumentException("客户名称不能为空");
            throw new IllegalArgumentException("客户名称不能为空:请填写模板中该行的「客户名称」列;"
                    + "若同一张合同的多行使用了合并单元格,需填写在合并区域的首行");
        }
        Long customerId = customerIdMap.get(head.getCustomerName().trim());
        if (customerId == null) {
            throw new IllegalArgumentException("客户不存在:" + head.getCustomerName());
            throw new IllegalArgumentException("客户不存在:" + head.getCustomerName()
                    + "。请先在「CRM å®¢æˆ·ç®¡ç†ã€ä¸­åˆ›å»ºè¯¥å®¢æˆ·ï¼ˆåç§°éœ€ä¸Žæ¨¡æ¿å®Œå…¨ä¸€è‡´ï¼‰ï¼Œæˆ–修改模板中的客户名称后重新导入");
        }
        customerService.validateCustomer(customerId);
        // 2. æ˜Žç»†
        List<CrmContractSaveReqVO.Product> products = new ArrayList<>();
        List<CrmContractProductDO> productDOs = new ArrayList<>();
        for (CrmContractImportExcelVO row : rows) {
            if (StrUtil.isBlank(row.getProductCode())) {
                throw new IllegalArgumentException("产品编码不能为空");
                throw new IllegalArgumentException("产品编码不能为空:请填写模板中该行的「产品编码」列");
            }
            CommonResult<MdmItemRespDTO> itemResult = mdmItemApi.getItemByCode(row.getProductCode().trim());
            MdmItemRespDTO item = itemResult == null ? null : itemResult.getData();
            if (item == null) {
                throw new IllegalArgumentException("产品不存在:" + row.getProductCode());
                throw new IllegalArgumentException("产品不存在:" + row.getProductCode()
                        + "。请先在「物料管理」中维护该物料(编码需与模板完全一致),或修改模板中的产品编码后重新导入");
            }
            if (row.getCount() == null || row.getCount() <= 0) {
                throw new IllegalArgumentException("产品[" + row.getProductCode() + "]的数量必须为正整数");
            if (row.getCount() == null) {
                throw new IllegalArgumentException("产品[" + row.getProductCode()
                        + "]的数量不能为空:请填写模板中该行的「数量」列");
            }
            if (row.getCount().compareTo(BigDecimal.ZERO) <= 0) {
                throw new IllegalArgumentException("产品[" + row.getProductCode() + "]的数量必须大于 0,当前为 "
                        + row.getCount().stripTrailingZeros().toPlainString() + "。请修改模板后重新导入");
            }
            BigDecimal contractPrice = row.getContractPrice() != null ? row.getContractPrice()
                    : (item.getSalesPrice() != null ? item.getSalesPrice() : BigDecimal.ZERO);
            CrmContractSaveReqVO.Product product = new CrmContractSaveReqVO.Product();
            CrmContractProductDO product = new CrmContractProductDO();
            product.setItemId(item.getId());
            product.setItemUnitId(item.getUnitMeasureId());
            product.setItemPrice(item.getSalesPrice() != null ? item.getSalesPrice() : contractPrice);
            product.setContractPrice(contractPrice);
            product.setCount(row.getCount());
            products.add(product);
            product.setTotalPrice(MoneyUtils.priceMultiply(contractPrice, row.getCount()));
            productDOs.add(product);
        }
        // 3. åˆåŒç¼–号
        String no = StrUtil.isNotBlank(head.getNo()) ? head.getNo().trim()
                : noRedisDAO.generate(CrmNoRedisDAO.CONTRACT_NO_PREFIX);
        if (contractMapper.selectByNo(no) != null) {
            throw new IllegalArgumentException("合同编号已存在:" + no);
            throw new IllegalArgumentException("合同编号已存在:" + no
                    + "。请修改模板中的合同编号,或先在系统中删除该编号的合同后重新导入");
        }
        // 4. ç»„装主表(负责人=登录用户;状态=未提交)
@@ -384,7 +396,6 @@
        contract.setAuditStatus(CrmAuditStatusEnum.DRAFT.getStatus());
        // 5. æ˜Žç»†è½åº“ + è‡ªåŠ¨ç®—ä»·
        List<CrmContractProductDO> productDOs = validateContractProducts(products);
        calculateTotalPrice(contract, productDOs);
        contractMapper.insert(contract);
        productDOs.forEach(product -> product.setContractId(contract.getId()));
@@ -472,7 +483,9 @@
        if (quotation != null) {
            crmSaleQuotationService.clearContractReference(quotation.getId());
        }
        // 2.2 åˆ é™¤æ•°æ®æƒé™
        // 2.3 åˆ é™¤åˆåŒäº§å“æ˜Žç»†
        contractProductMapper.delete(CrmContractProductDO::getContractId, id);
        // 2.4 åˆ é™¤æ•°æ®æƒé™
        crmPermissionService.deletePermission(CrmBizTypeEnum.CRM_CONTRACT.getType(), id);
        // 3. è®°å½•操作日志上下文
@@ -644,6 +657,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-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationServiceImpl.java
@@ -1,6 +1,7 @@
package cn.iocoder.yudao.module.crm.service.quotation;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.number.MoneyUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
@@ -81,11 +82,11 @@
    public Long createSaleQuotation(CrmSaleQuotationSaveReqVO createReqVO, Long userId) {
        // 1.1 æ ¡éªŒç‰©æ–™é¡¹çš„æœ‰æ•ˆæ€§
        List<CrmSaleQuotationProductDO> quotationProducts = validateQuotationItems(createReqVO.getItems());
        // 1.2 ç”ŸæˆæŠ¥ä»·å•号,并校验唯一性
        String no = noRedisDAO.generate(CrmNoRedisDAO.SALE_QUOTATION_NO_PREFIX);
        if (saleQuotationMapper.selectByNo(no) != null) {
            throw exception(SALE_QUOTATION_NO_EXISTS);
        }
        // 1.2 ç¡®å®šæŠ¥ä»·å•号(前端填写则用自定义单号,留空则自动生成),并校验唯一性
        String customNo = StrUtil.trim(createReqVO.getNo());
        String no = StrUtil.isBlank(customNo)
                ? noRedisDAO.generate(CrmNoRedisDAO.SALE_QUOTATION_NO_PREFIX) : customNo;
        validateQuotationNoUnique(no, null);
        // 1.3 æ ¡éªŒå…³è”字段
        validateRelationDataExists(createReqVO);
@@ -123,8 +124,15 @@
        // 1.4 æ ¡éªŒå…³è”字段
        validateRelationDataExists(updateReqVO);
        // 2.1 æ›´æ–°æŠ¥ä»·å•
        // 2.1 æ›´æ–°æŠ¥ä»·å•(报价单号留空表示保持原值,填写则校验唯一性后替换)
        CrmSaleQuotationDO updateObj = BeanUtils.toBean(updateReqVO, CrmSaleQuotationDO.class);
        String no = StrUtil.trim(updateReqVO.getNo());
        if (StrUtil.isBlank(no)) {
            updateObj.setNo(saleQuotation.getNo());
        } else {
            validateQuotationNoUnique(no, saleQuotation.getId());
            updateObj.setNo(no);
        }
        calculateTotalPrice(updateObj, quotationProducts);
        saleQuotationMapper.updateById(updateObj);
        // 2.2 æ›´æ–°æŠ¥ä»·æ˜Žç»†
@@ -167,6 +175,19 @@
        return saleQuotation;
    }
    /**
     * æ ¡éªŒæŠ¥ä»·å•号唯一(含已逻辑删除的报价单,因 uk_no å”¯ä¸€ç´¢å¼•覆盖已删除行)
     *
     * @param no       æŠ¥ä»·å•号
     * @param selfId   è‡ªèº«ç¼–号(修改时排除自己,新增传 null)
     */
    private void validateQuotationNoUnique(String no, Long selfId) {
        CrmSaleQuotationDO exists = saleQuotationMapper.selectAnyByNo(no);
        if (exists != null && !exists.getId().equals(selfId)) {
            throw exception(SALE_QUOTATION_NO_EXISTS, no);
        }
    }
    private List<CrmSaleQuotationProductDO> validateQuotationItems(List<CrmSaleQuotationSaveReqVO.Item> list) {
        // 1. æ ¡éªŒç‰©æ–™å­˜åœ¨ä¸”启用
        Set<Long> itemIds = convertSet(list, CrmSaleQuotationSaveReqVO.Item::getItemId);
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableServiceImpl.java
@@ -136,6 +136,10 @@
     * æ ¡éªŒå…³è”开票合格:存在、审批通过、已上传发票附件、与所选合同一致
     */
    private void validateInvoiceForReceivable(CrmReceivableSaveReqVO reqVO) {
        // æœªå…³è”开票时直接跳过(允许先回款后开票)
        if (reqVO.getInvoiceId() == null) {
            return;
        }
        // 1. æ ¡éªŒå¼€ç¥¨å­˜åœ¨
        CrmInvoiceDO invoice = invoiceService.getInvoice(reqVO.getInvoiceId());
        if (invoice == null) {
@@ -164,6 +168,10 @@
     * æ ¡éªŒç´¯è®¡å›žæ¬¾é‡‘额不超过该开票剩余可回款金额(先开票后回款)
     */
    private void validateReceivablePriceExceedsInvoiceRemaining(CrmReceivableSaveReqVO reqVO) {
        // æœªå…³è”开票时直接跳过(金额上限仍由合同级校验 validateReceivablePriceExceedsLimit å…œåº•)
        if (reqVO.getInvoiceId() == null) {
            return;
        }
        // 1. è®¡ç®—该开票累计回款金额(不含本次,统计口径同合同金额校验:草稿 + å®¡æ‰¹ä¸­ + å®¡æ‰¹é€šè¿‡ï¼‰
        CrmInvoiceDO invoice = invoiceService.getInvoice(reqVO.getInvoiceId());
        List<CrmReceivableDO> receivables = receivableMapper.selectListByInvoiceIdAndStatus(reqVO.getInvoiceId(),
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java
@@ -84,8 +84,8 @@
    ErrorCode ATTENDANCE_RULE_NOT_EXISTS = new ErrorCode(1_030_004_005, "考勤规则不存在");
    ErrorCode ATTENDANCE_RULE_NAME_EXISTS = new ErrorCode(1_030_004_006, "考勤规则名称已存在");
    ErrorCode ATTENDANCE_RULE_DEPT_EXISTS = new ErrorCode(1_030_004_007, "该部门已配置考勤规则");
    ErrorCode ATTENDANCE_HOLIDAY_NOT_EXISTS = new ErrorCode(1_030_004_008, "节假日配置不存在");
    ErrorCode ATTENDANCE_HOLIDAY_DATE_EXISTS = new ErrorCode(1_030_004_009, "该日期节假日配置已存在");
    ErrorCode ATTENDANCE_HOLIDAY_NOT_EXISTS = new ErrorCode(1_030_004_008, "节假日配置不存在(ID={}),可能已被他人删除。请刷新列表后重试");
    ErrorCode ATTENDANCE_HOLIDAY_DATE_EXISTS = new ErrorCode(1_030_004_009, "日期【{}】已存在节假日配置,不能重复新增。请改为编辑该日期的记录(可直接修改类型与备注),或先删除原记录再新增");
    ErrorCode ATTENDANCE_CLOCK_FAIL_NO_RULE = new ErrorCode(1_030_004_010, "打卡失败,未找到考勤规则");
    ErrorCode ATTENDANCE_CLOCK_FAIL_HOLIDAY = new ErrorCode(1_030_004_011, "打卡失败,当日为休息日");
    ErrorCode ATTENDANCE_CLOCK_FAIL_NOT_WORKDAY = new ErrorCode(1_030_004_012, "打卡失败,当日不是工作日");
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/HrmAttendanceExceptionController.java
@@ -45,14 +45,14 @@
    @PostMapping("/create")
    @Operation(summary = "新增考勤异常申请")
    @PreAuthorize("@ss.hasPermission('hrm:attendance:create')")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-exception:create')")
    public CommonResult<Long> createAttendanceException(@Valid @RequestBody HrmAttendanceExceptionSaveReqVO createReqVO) {
        return success(attendanceExceptionService.createAttendanceException(createReqVO));
    }
    @PutMapping("/update")
    @Operation(summary = "更新考勤异常申请")
    @PreAuthorize("@ss.hasPermission('hrm:attendance:update')")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-exception:update')")
    public CommonResult<Boolean> updateAttendanceException(@Valid @RequestBody HrmAttendanceExceptionSaveReqVO updateReqVO) {
        attendanceExceptionService.updateAttendanceException(updateReqVO);
        return success(true);
@@ -61,7 +61,7 @@
    @DeleteMapping("/delete")
    @Operation(summary = "删除考勤异常申请")
    @Parameter(name = "id", description = "申请ID", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:attendance:delete')")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-exception:delete')")
    public CommonResult<Boolean> deleteAttendanceException(@RequestParam("id") Long id) {
        attendanceExceptionService.deleteAttendanceException(id);
        return success(true);
@@ -70,7 +70,7 @@
    @GetMapping("/get")
    @Operation(summary = "获得考勤异常申请")
    @Parameter(name = "id", description = "申请ID", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:attendance:query')")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-exception:query')")
    public CommonResult<HrmAttendanceExceptionRespVO> getAttendanceException(@RequestParam("id") Long id) {
        HrmAttendanceExceptionDO attendanceException = attendanceExceptionService.getAttendanceException(id);
        if (attendanceException == null) {
@@ -96,7 +96,7 @@
    @GetMapping("/page")
    @Operation(summary = "获得考勤异常申请分页")
    @PreAuthorize("@ss.hasPermission('hrm:attendance:query')")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-exception:query')")
    public CommonResult<PageResult<HrmAttendanceExceptionRespVO>> getAttendanceExceptionPage(@Valid HrmAttendanceExceptionPageReqVO pageReqVO) {
        PageResult<HrmAttendanceExceptionDO> pageResult = attendanceExceptionService.getAttendanceExceptionPage(pageReqVO);
        List<HrmAttendanceExceptionRespVO> list = BeanUtils.toBean(pageResult.getList(), HrmAttendanceExceptionRespVO.class);
@@ -145,7 +145,7 @@
    @PostMapping("/submit")
    @Operation(summary = "提交考勤异常申请")
    @PreAuthorize("@ss.hasPermission('hrm:attendance:submit')")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-exception:submit')")
    public CommonResult<Boolean> submitAttendanceException(
            @RequestParam("id") Long id,
            @RequestParam("processDefinitionKey") String processDefinitionKey) {
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/HrmAttendanceHolidayController.java
@@ -38,14 +38,14 @@
    @PostMapping("/create")
    @Operation(summary = "新增节假日配置")
    @PreAuthorize("@ss.hasPermission('hrm:attendance:create')")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-holiday:create')")
    public CommonResult<Long> createAttendanceHoliday(@Valid @RequestBody HrmAttendanceHolidaySaveReqVO createReqVO) {
        return success(attendanceHolidayService.createAttendanceHoliday(createReqVO));
    }
    @PutMapping("/update")
    @Operation(summary = "更新节假日配置")
    @PreAuthorize("@ss.hasPermission('hrm:attendance:update')")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-holiday:update')")
    public CommonResult<Boolean> updateAttendanceHoliday(@Valid @RequestBody HrmAttendanceHolidaySaveReqVO updateReqVO) {
        attendanceHolidayService.updateAttendanceHoliday(updateReqVO);
        return success(true);
@@ -54,7 +54,7 @@
    @DeleteMapping("/delete")
    @Operation(summary = "删除节假日配置")
    @Parameter(name = "id", description = "节假日ID", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:attendance:delete')")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-holiday:delete')")
    public CommonResult<Boolean> deleteAttendanceHoliday(@RequestParam("id") Long id) {
        attendanceHolidayService.deleteAttendanceHoliday(id);
        return success(true);
@@ -63,7 +63,7 @@
    @GetMapping("/get")
    @Operation(summary = "获得节假日配置")
    @Parameter(name = "id", description = "节假日ID", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:attendance:query')")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-holiday:query')")
    public CommonResult<HrmAttendanceHolidayRespVO> getAttendanceHoliday(@RequestParam("id") Long id) {
        HrmAttendanceHolidayDO attendanceHoliday = attendanceHolidayService.getAttendanceHoliday(id);
        return success(BeanUtils.toBean(attendanceHoliday, HrmAttendanceHolidayRespVO.class));
@@ -71,7 +71,7 @@
    @GetMapping("/page")
    @Operation(summary = "获得节假日配置分页")
    @PreAuthorize("@ss.hasPermission('hrm:attendance:query')")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-holiday:query')")
    public CommonResult<PageResult<HrmAttendanceHolidayRespVO>> getAttendanceHolidayPage(@Valid HrmAttendanceHolidayPageReqVO pageReqVO) {
        PageResult<HrmAttendanceHolidayDO> pageResult = attendanceHolidayService.getAttendanceHolidayPage(pageReqVO);
        return success(BeanUtils.toBean(pageResult, HrmAttendanceHolidayRespVO.class));
@@ -79,7 +79,7 @@
    @GetMapping("/export-excel")
    @Operation(summary = "导出节假日配置 Excel")
    @PreAuthorize("@ss.hasPermission('hrm:attendance:export')")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-holiday:export')")
    @ApiAccessLog(operateType = EXPORT)
    public void exportAttendanceHolidayExcel(@Valid HrmAttendanceHolidayPageReqVO pageReqVO,
                                               HttpServletResponse response) throws IOException {
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/HrmAttendanceRecordController.java
@@ -56,7 +56,7 @@
    @PostMapping("/clock-in")
    @Operation(summary = "上班打卡")
    @PreAuthorize("@ss.hasPermission('hrm:attendance:clock')")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-record:clock')")
    public CommonResult<HrmAttendanceRecordRespVO> clockIn(@RequestParam("location") String location) {
        Long loginUserId = getLoginUserId();
        HrmEmployeeDO employee = employeeService.getEmployeeByUserId(loginUserId);
@@ -77,7 +77,7 @@
    @PostMapping("/clock-out")
    @Operation(summary = "下班打卡")
    @PreAuthorize("@ss.hasPermission('hrm:attendance:clock')")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-record:clock')")
    public CommonResult<HrmAttendanceRecordRespVO> clockOut(@RequestParam("location") String location) {
        Long loginUserId = getLoginUserId();
        HrmEmployeeDO employee = employeeService.getEmployeeByUserId(loginUserId);
@@ -102,7 +102,7 @@
    @GetMapping("/page")
    @Operation(summary = "获得考勤记录分页")
    @PreAuthorize("@ss.hasPermission('hrm:attendance:query')")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-record:query')")
    public CommonResult<PageResult<HrmAttendanceRecordRespVO>> getAttendanceRecordPage(@Valid HrmAttendanceRecordPageReqVO pageReqVO) {
        PageResult<HrmAttendanceRecordDO> pageResult = attendanceRecordService.getAttendanceRecordPage(pageReqVO);
        // è½¬æ¢ä¸º VO
@@ -157,7 +157,7 @@
    @GetMapping("/get")
    @Operation(summary = "获得考勤记录")
    @Parameter(name = "id", description = "记录ID", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:attendance:query')")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-record:query')")
    public CommonResult<HrmAttendanceRecordRespVO> getAttendanceRecord(@RequestParam("id") Long id) {
        HrmAttendanceRecordDO attendanceRecord = attendanceRecordService.getAttendanceRecord(id);
        if (attendanceRecord == null) {
@@ -281,7 +281,7 @@
    @GetMapping("/export-excel")
    @Operation(summary = "导出考勤记录 Excel")
    @PreAuthorize("@ss.hasPermission('hrm:attendance:export')")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-record:export')")
    @ApiAccessLog(operateType = EXPORT)
    public void exportAttendanceRecordExcel(@Valid HrmAttendanceRecordPageReqVO pageReqVO,
                                              HttpServletResponse response) throws IOException {
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/HrmAttendanceRuleController.java
@@ -44,14 +44,14 @@
    @PostMapping("/create")
    @Operation(summary = "新增考勤规则")
    @PreAuthorize("@ss.hasPermission('hrm:attendance:create')")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-rule:create')")
    public CommonResult<Long> createAttendanceRule(@Valid @RequestBody HrmAttendanceRuleSaveReqVO createReqVO) {
        return success(attendanceRuleService.createAttendanceRule(createReqVO));
    }
    @PutMapping("/update")
    @Operation(summary = "更新考勤规则")
    @PreAuthorize("@ss.hasPermission('hrm:attendance:update')")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-rule:update')")
    public CommonResult<Boolean> updateAttendanceRule(@Valid @RequestBody HrmAttendanceRuleSaveReqVO updateReqVO) {
        attendanceRuleService.updateAttendanceRule(updateReqVO);
        return success(true);
@@ -60,7 +60,7 @@
    @DeleteMapping("/delete")
    @Operation(summary = "删除考勤规则")
    @Parameter(name = "id", description = "规则ID", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:attendance:delete')")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-rule:delete')")
    public CommonResult<Boolean> deleteAttendanceRule(@RequestParam("id") Long id) {
        attendanceRuleService.deleteAttendanceRule(id);
        return success(true);
@@ -69,7 +69,7 @@
    @GetMapping("/get")
    @Operation(summary = "获得考勤规则")
    @Parameter(name = "id", description = "规则ID", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:attendance:query')")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-rule:query')")
    public CommonResult<HrmAttendanceRuleRespVO> getAttendanceRule(@RequestParam("id") Long id) {
        HrmAttendanceRuleDO attendanceRule = attendanceRuleService.getAttendanceRule(id);
        if (attendanceRule == null) {
@@ -88,7 +88,7 @@
    @GetMapping("/page")
    @Operation(summary = "获得考勤规则分页")
    @PreAuthorize("@ss.hasPermission('hrm:attendance:query')")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-rule:query')")
    public CommonResult<PageResult<HrmAttendanceRuleRespVO>> getAttendanceRulePage(@Valid HrmAttendanceRulePageReqVO pageReqVO) {
        PageResult<HrmAttendanceRuleDO> pageResult = attendanceRuleService.getAttendanceRulePage(pageReqVO);
        List<HrmAttendanceRuleRespVO> list = BeanUtils.toBean(pageResult.getList(), HrmAttendanceRuleRespVO.class);
@@ -111,7 +111,7 @@
    @GetMapping("/list")
    @Operation(summary = "获得考勤规则列表")
    @PreAuthorize("@ss.hasPermission('hrm:attendance:query')")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-rule:query')")
    public CommonResult<List<HrmAttendanceRuleRespVO>> getAttendanceRuleList() {
        List<HrmAttendanceRuleDO> list = attendanceRuleService.getAttendanceRuleList();
        return success(BeanUtils.toBean(list, HrmAttendanceRuleRespVO.class));
@@ -119,7 +119,7 @@
    @GetMapping("/export-excel")
    @Operation(summary = "导出考勤规则 Excel")
    @PreAuthorize("@ss.hasPermission('hrm:attendance:export')")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-rule:export')")
    @ApiAccessLog(operateType = EXPORT)
    public void exportAttendanceRuleExcel(@Valid HrmAttendanceRulePageReqVO pageReqVO,
                                            HttpServletResponse response) throws IOException {
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/HrmAttendanceSummaryController.java
@@ -47,7 +47,7 @@
    @GetMapping("/page")
    @Operation(summary = "获得考勤统计汇总分页")
    @PreAuthorize("@ss.hasPermission('hrm:attendance:query')")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-summary:query')")
    public CommonResult<PageResult<HrmAttendanceSummaryRespVO>> getAttendanceSummaryPage(@Valid HrmAttendanceSummaryPageReqVO pageReqVO) {
        PageResult<HrmAttendanceSummaryDO> pageResult = attendanceSummaryService.getAttendanceSummaryPage(pageReqVO);
        List<HrmAttendanceSummaryRespVO> list = BeanUtils.toBean(pageResult.getList(), HrmAttendanceSummaryRespVO.class);
@@ -96,7 +96,7 @@
    @GetMapping("/get")
    @Operation(summary = "获得员工月度考勤汇总")
    @PreAuthorize("@ss.hasPermission('hrm:attendance:query')")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-summary:query')")
    public CommonResult<HrmAttendanceSummaryRespVO> getMonthlySummary(
            @RequestParam("userId") Long userId,
            @RequestParam("yearMonth") String yearMonth) {
@@ -122,7 +122,7 @@
    @PostMapping("/generate")
    @Operation(summary = "生成月度考勤汇总")
    @Parameter(name = "yearMonth", description = "年月,格式:yyyy-MM", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:attendance:calculate')")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-summary:generate')")
    public CommonResult<Boolean> generateMonthlySummary(@RequestParam("yearMonth") String yearMonth,
                                                          @RequestParam(value = "deptId", required = false) Long deptId,
                                                          @RequestParam(value = "userId", required = false) Long userId) {
@@ -138,7 +138,7 @@
    @GetMapping("/export-excel")
    @Operation(summary = "导出考勤统计汇总 Excel")
    @PreAuthorize("@ss.hasPermission('hrm:attendance:export')")
    @PreAuthorize("@ss.hasPermission('hrm:attendance-summary:export')")
    @ApiAccessLog(operateType = EXPORT)
    public void exportAttendanceSummaryExcel(@Valid HrmAttendanceSummaryPageReqVO pageReqVO,
                                               HttpServletResponse response) throws IOException {
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceHolidayPageReqVO.java
@@ -20,10 +20,10 @@
    @Schema(description = "类型:1-休息日,2-工作日", example = "1")
    private Integer type;
    @Schema(description = "开始日期", example = "2024-01-01")
    private LocalDate startDate;
    @Schema(description = "是否法定节假日:0-否,1-是", example = "1")
    private Integer isLegal;
    @Schema(description = "结束日期", example = "2024-12-31")
    private LocalDate endDate;
    @Schema(description = "日期区间,传 [开始日期, ç»“束日期]", example = "[\"2026-01-01\", \"2026-12-31\"]")
    private LocalDate[] date;
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/HrmEmployeeSocialSecurityController.java
@@ -46,7 +46,7 @@
    @PostMapping("/create")
    @Operation(summary = "创建员工社保公积金档案")
    @PreAuthorize("@ss.hasPermission('hrm:social-security:create')")
    @PreAuthorize("@ss.hasPermission('hrm:employee-social-security:create')")
    public CommonResult<Long> createEmployeeSocialSecurity(@Valid @RequestBody HrmEmployeeSocialSecuritySaveReqVO createReqVO) {
        HrmEmployeeSocialSecurityDO socialSecurity = BeanUtils.toBean(createReqVO, HrmEmployeeSocialSecurityDO.class);
        return success(employeeSocialSecurityService.createEmployeeSocialSecurity(socialSecurity));
@@ -54,7 +54,7 @@
    @PutMapping("/update")
    @Operation(summary = "更新员工社保公积金档案")
    @PreAuthorize("@ss.hasPermission('hrm:social-security:update')")
    @PreAuthorize("@ss.hasPermission('hrm:employee-social-security:update')")
    public CommonResult<Boolean> updateEmployeeSocialSecurity(@Valid @RequestBody HrmEmployeeSocialSecuritySaveReqVO updateReqVO) {
        HrmEmployeeSocialSecurityDO socialSecurity = BeanUtils.toBean(updateReqVO, HrmEmployeeSocialSecurityDO.class);
        employeeSocialSecurityService.updateEmployeeSocialSecurity(socialSecurity);
@@ -64,7 +64,7 @@
    @DeleteMapping("/delete")
    @Operation(summary = "删除员工社保公积金档案")
    @Parameter(name = "id", description = "档案ID", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:social-security:delete')")
    @PreAuthorize("@ss.hasPermission('hrm:employee-social-security:delete')")
    public CommonResult<Boolean> deleteEmployeeSocialSecurity(@RequestParam("id") Long id) {
        employeeSocialSecurityService.deleteEmployeeSocialSecurity(id);
        return success(true);
@@ -73,7 +73,7 @@
    @GetMapping("/get")
    @Operation(summary = "获取员工社保公积金档案")
    @Parameter(name = "id", description = "档案ID", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:social-security:query')")
    @PreAuthorize("@ss.hasPermission('hrm:employee-social-security:query')")
    public CommonResult<HrmEmployeeSocialSecurityRespVO> getEmployeeSocialSecurity(@RequestParam("id") Long id) {
        HrmEmployeeSocialSecurityDO socialSecurity = employeeSocialSecurityService.getEmployeeSocialSecurity(id);
        if (socialSecurity == null) {
@@ -88,7 +88,7 @@
    @GetMapping("/page")
    @Operation(summary = "获取员工社保公积金档案分页")
    @PreAuthorize("@ss.hasPermission('hrm:social-security:query')")
    @PreAuthorize("@ss.hasPermission('hrm:employee-social-security:query')")
    public CommonResult<PageResult<HrmEmployeeSocialSecurityRespVO>> getEmployeeSocialSecurityPage(
            @Valid HrmEmployeeSocialSecurityPageReqVO pageReqVO) {
        PageResult<HrmEmployeeSocialSecurityDO> pageResult = employeeSocialSecurityService.getEmployeeSocialSecurityPage(pageReqVO);
@@ -157,7 +157,7 @@
    @GetMapping("/list-by-user")
    @Operation(summary = "获取员工社保公积金档案历史列表")
    @Parameter(name = "userId", description = "系统用户ID", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:social-security:query')")
    @PreAuthorize("@ss.hasPermission('hrm:employee-social-security:query')")
    public CommonResult<List<HrmEmployeeSocialSecurityRespVO>> getEmployeeSocialSecurityList(
            @RequestParam("userId") Long userId) {
        List<HrmEmployeeSocialSecurityDO> list = employeeSocialSecurityService.getEmployeeSocialSecurityList(userId);
@@ -174,7 +174,7 @@
    @GetMapping("/get-latest")
    @Operation(summary = "获取员工最新生效的社保公积金档案")
    @Parameter(name = "userId", description = "系统用户ID", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:social-security:query')")
    @PreAuthorize("@ss.hasPermission('hrm:employee-social-security:query')")
    public CommonResult<HrmEmployeeSocialSecurityRespVO> getLatestEmployeeSocialSecurity(
            @RequestParam("userId") Long userId) {
        HrmEmployeeSocialSecurityDO socialSecurity = employeeSocialSecurityService.getLatestEmployeeSocialSecurity(userId);
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/HrmSalaryCalculationController.java
@@ -48,7 +48,7 @@
    @GetMapping("/page")
    @Operation(summary = "获得薪酬核算分页")
    @PreAuthorize("@ss.hasPermission('hrm:salary:query')")
    @PreAuthorize("@ss.hasPermission('hrm:salary-calculation:query')")
    public CommonResult<PageResult<HrmSalaryCalculationRespVO>> getSalaryCalculationPage(@Valid HrmSalaryCalculationPageReqVO pageReqVO) {
        PageResult<HrmSalaryCalculationDO> pageResult = salaryCalculationService.getSalaryCalculationPage(pageReqVO);
        // è½¬æ¢ä¸º VO
@@ -100,7 +100,7 @@
    @GetMapping("/get")
    @Operation(summary = "获得薪酬核算")
    @Parameter(name = "id", description = "核算ID", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:salary:query')")
    @PreAuthorize("@ss.hasPermission('hrm:salary-calculation:query')")
    public CommonResult<HrmSalaryCalculationRespVO> getSalaryCalculation(@RequestParam("id") Long id) {
        HrmSalaryCalculationDO salaryCalculation = salaryCalculationService.getSalaryCalculation(id);
        if (salaryCalculation == null) {
@@ -127,7 +127,7 @@
    @Operation(summary = "执行薪酬核算")
    @Parameter(name = "period", description = "核算周期", required = true, example = "2024-01")
    @Parameter(name = "deptId", description = "部门ID,为空则核算所有部门", example = "1")
    @PreAuthorize("@ss.hasPermission('hrm:salary:calculate')")
    @PreAuthorize("@ss.hasPermission('hrm:salary-calculation:calculate')")
    public CommonResult<Boolean> calculateSalary(@RequestParam("period") String period,
                                                  @RequestParam(value = "deptId", required = false) Long deptId) {
        salaryCalculationService.calculateSalary(period, deptId);
@@ -136,7 +136,7 @@
    @PostMapping("/confirm")
    @Operation(summary = "批量确认薪酬核算")
    @PreAuthorize("@ss.hasPermission('hrm:salary:confirm')")
    @PreAuthorize("@ss.hasPermission('hrm:salary-calculation:confirm')")
    public CommonResult<Boolean> confirmSalaryCalculation(@RequestBody Map<String, List<Long>> request) {
        List<Long> ids = request.get("ids");
        salaryCalculationService.confirmSalaryCalculation(ids);
@@ -145,7 +145,7 @@
    @PostMapping("/revoke")
    @Operation(summary = "批量撤销薪酬核算确认")
    @PreAuthorize("@ss.hasPermission('hrm:salary:confirm')")
    @PreAuthorize("@ss.hasPermission('hrm:salary-calculation:confirm')")
    public CommonResult<Boolean> revokeSalaryCalculation(@RequestBody Map<String, List<Long>> request) {
        List<Long> ids = request.get("ids");
        salaryCalculationService.revokeSalaryCalculation(ids);
@@ -155,7 +155,7 @@
    @DeleteMapping("/delete")
    @Operation(summary = "删除薪酬核算记录")
    @Parameter(name = "id", description = "核算ID", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:salary:delete')")
    @PreAuthorize("@ss.hasPermission('hrm:salary-calculation:delete')")
    public CommonResult<Boolean> deleteSalaryCalculation(@RequestParam("id") Long id) {
        salaryCalculationService.deleteSalaryCalculation(id);
        return success(true);
@@ -163,7 +163,7 @@
    @GetMapping("/export-excel")
    @Operation(summary = "导出薪酬核算 Excel")
    @PreAuthorize("@ss.hasPermission('hrm:salary:export')")
    @PreAuthorize("@ss.hasPermission('hrm:salary-calculation:export')")
    @ApiAccessLog(operateType = EXPORT)
    public void exportSalaryCalculationExcel(@Valid HrmSalaryCalculationPageReqVO pageReqVO,
                                               HttpServletResponse response) throws IOException {
@@ -183,7 +183,7 @@
    @Operation(summary = "预览部门下所有员工的薪资核算信息")
    @Parameter(name = "period", description = "核算周期", required = true, example = "2024-01")
    @Parameter(name = "deptId", description = "部门ID,为空则查询全部", example = "1")
    @PreAuthorize("@ss.hasPermission('hrm:salary:query')")
    @PreAuthorize("@ss.hasPermission('hrm:salary-calculation:query')")
    public CommonResult<List<HrmSalaryCalculationRespVO>> previewSalaryCalculation(
            @RequestParam("period") String period,
            @RequestParam(value = "deptId", required = false) Long deptId) {
@@ -200,7 +200,7 @@
    @PostMapping("/save")
    @Operation(summary = "批量保存薪酬核算记录")
    @PreAuthorize("@ss.hasPermission('hrm:salary:calculate')")
    @PreAuthorize("@ss.hasPermission('hrm:salary-calculation:save')")
    public CommonResult<Boolean> saveSalaryCalculation(@Valid @RequestBody HrmSalaryCalculationSaveReqVO saveReqVO) {
        // è½¬æ¢ä¸º DO åˆ—表
        List<HrmSalaryCalculationDO> calculationList = saveReqVO.getList().stream()
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/HrmSalaryPaymentController.java
@@ -47,7 +47,7 @@
    @GetMapping("/page")
    @Operation(summary = "获得薪资发放分页")
    @PreAuthorize("@ss.hasPermission('hrm:salary:query')")
    @PreAuthorize("@ss.hasPermission('hrm:salary-payment:query')")
    public CommonResult<PageResult<HrmSalaryPaymentRespVO>> getSalaryPaymentPage(@Valid HrmSalaryPaymentPageReqVO pageReqVO) {
        PageResult<HrmSalaryPaymentDO> pageResult = salaryPaymentService.getSalaryPaymentPage(pageReqVO);
        return success(BeanUtils.toBean(pageResult, HrmSalaryPaymentRespVO.class));
@@ -56,7 +56,7 @@
    @GetMapping("/get")
    @Operation(summary = "获得薪资发放")
    @Parameter(name = "id", description = "发放ID", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:salary:query')")
    @PreAuthorize("@ss.hasPermission('hrm:salary-payment:query')")
    public CommonResult<HrmSalaryPaymentRespVO> getSalaryPayment(@RequestParam("id") Long id) {
        HrmSalaryPaymentDO salaryPayment = salaryPaymentService.getSalaryPayment(id);
        HrmSalaryPaymentRespVO respVO = BeanUtils.toBean(salaryPayment, HrmSalaryPaymentRespVO.class);
@@ -67,7 +67,7 @@
    @PostMapping("/generate")
    @Operation(summary = "生成薪资发放台账")
    @Parameter(name = "period", description = "核算周期", required = true, example = "2024-01")
    @PreAuthorize("@ss.hasPermission('hrm:salary:generate')")
    @PreAuthorize("@ss.hasPermission('hrm:salary-payment:generate')")
    public CommonResult<Long> generatePayment(@RequestParam("period") String period) {
        return success(salaryPaymentService.generatePayment(period));
    }
@@ -75,7 +75,7 @@
    @PostMapping("/pay")
    @Operation(summary = "发放薪资(整个台账)")
    @Parameter(name = "id", description = "发放台账ID", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:salary:pay')")
    @PreAuthorize("@ss.hasPermission('hrm:salary-payment:pay')")
    public CommonResult<Boolean> paySalary(@RequestParam("id") Long id) {
        salaryPaymentService.paySalary(id);
        return success(true);
@@ -83,7 +83,7 @@
    @PostMapping("/pay-detail")
    @Operation(summary = "批量发放薪资明细")
    @PreAuthorize("@ss.hasPermission('hrm:salary:pay')")
    @PreAuthorize("@ss.hasPermission('hrm:salary-payment:pay')")
    public CommonResult<Boolean> paySalaryDetail(@RequestBody Map<String, List<Long>> request) {
        List<Long> detailIds = request.get("detailIds");
        salaryPaymentService.paySalaryDetail(detailIds);
@@ -92,7 +92,7 @@
    @PostMapping("/revoke-detail")
    @Operation(summary = "批量撤销薪资发放明细")
    @PreAuthorize("@ss.hasPermission('hrm:salary:pay')")
    @PreAuthorize("@ss.hasPermission('hrm:salary-payment:pay')")
    public CommonResult<Boolean> revokeSalaryPaymentDetail(@RequestBody Map<String, List<Long>> request) {
        List<Long> detailIds = request.get("detailIds");
        salaryPaymentService.revokeSalaryPaymentDetail(detailIds);
@@ -102,7 +102,7 @@
    @DeleteMapping("/delete")
    @Operation(summary = "删除薪资发放台账")
    @Parameter(name = "id", description = "发放台账ID", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:salary:delete')")
    @PreAuthorize("@ss.hasPermission('hrm:salary-payment:delete')")
    public CommonResult<Boolean> deleteSalaryPayment(@RequestParam("id") Long id) {
        salaryPaymentService.deleteSalaryPayment(id);
        return success(true);
@@ -110,7 +110,7 @@
    @GetMapping("/detail/page")
    @Operation(summary = "获得薪资发放明细分页")
    @PreAuthorize("@ss.hasPermission('hrm:salary:query')")
    @PreAuthorize("@ss.hasPermission('hrm:salary-payment:query')")
    public CommonResult<PageResult<HrmSalaryPaymentDetailRespVO>> getSalaryPaymentDetailPage(@Valid HrmSalaryPaymentDetailPageReqVO pageReqVO) {
        PageResult<HrmSalaryPaymentDetailDO> pageResult = salaryPaymentService.getSalaryPaymentDetailPage(pageReqVO);
        List<HrmSalaryPaymentDetailRespVO> list = BeanUtils.toBean(pageResult.getList(), HrmSalaryPaymentDetailRespVO.class);
@@ -124,7 +124,7 @@
    @GetMapping("/detail/list")
    @Operation(summary = "获得薪资发放明细列表")
    @Parameter(name = "paymentId", description = "发放台账ID", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:salary:query')")
    @PreAuthorize("@ss.hasPermission('hrm:salary-payment:query')")
    public CommonResult<List<HrmSalaryPaymentDetailRespVO>> getSalaryPaymentDetailList(@RequestParam("paymentId") Long paymentId) {
        List<HrmSalaryPaymentDetailDO> detailList = salaryPaymentService.getSalaryPaymentDetailListByPaymentId(paymentId);
        List<HrmSalaryPaymentDetailRespVO> list = BeanUtils.toBean(detailList, HrmSalaryPaymentDetailRespVO.class);
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/HrmSocialSecuritySchemeController.java
@@ -30,7 +30,7 @@
    @PostMapping("/create")
    @Operation(summary = "创建社保公积金方案")
    @PreAuthorize("@ss.hasPermission('hrm:social-security:create')")
    @PreAuthorize("@ss.hasPermission('hrm:social-security-scheme:create')")
    public CommonResult<Long> createScheme(@Valid @RequestBody HrmSocialSecuritySchemeSaveReqVO createReqVO) {
        HrmSocialSecuritySchemeDO scheme = BeanUtils.toBean(createReqVO, HrmSocialSecuritySchemeDO.class);
        return success(schemeService.createScheme(scheme));
@@ -38,7 +38,7 @@
    @PutMapping("/update")
    @Operation(summary = "更新社保公积金方案")
    @PreAuthorize("@ss.hasPermission('hrm:social-security:update')")
    @PreAuthorize("@ss.hasPermission('hrm:social-security-scheme:update')")
    public CommonResult<Boolean> updateScheme(@Valid @RequestBody HrmSocialSecuritySchemeSaveReqVO updateReqVO) {
        HrmSocialSecuritySchemeDO scheme = BeanUtils.toBean(updateReqVO, HrmSocialSecuritySchemeDO.class);
        schemeService.updateScheme(scheme);
@@ -48,7 +48,7 @@
    @DeleteMapping("/delete")
    @Operation(summary = "删除社保公积金方案")
    @Parameter(name = "id", description = "方案ID", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:social-security:delete')")
    @PreAuthorize("@ss.hasPermission('hrm:social-security-scheme:delete')")
    public CommonResult<Boolean> deleteScheme(@RequestParam("id") Long id) {
        schemeService.deleteScheme(id);
        return success(true);
@@ -57,7 +57,7 @@
    @GetMapping("/get")
    @Operation(summary = "获取社保公积金方案")
    @Parameter(name = "id", description = "方案ID", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:social-security:query')")
    @PreAuthorize("@ss.hasPermission('hrm:social-security-scheme:query')")
    public CommonResult<HrmSocialSecuritySchemeRespVO> getScheme(@RequestParam("id") Long id) {
        HrmSocialSecuritySchemeDO scheme = schemeService.getScheme(id);
        return success(BeanUtils.toBean(scheme, HrmSocialSecuritySchemeRespVO.class));
@@ -66,7 +66,7 @@
    @GetMapping("/list")
    @Operation(summary = "获取社保公积金方案列表")
    @Parameter(name = "status", description = "状态")
    @PreAuthorize("@ss.hasPermission('hrm:social-security:query')")
    @PreAuthorize("@ss.hasPermission('hrm:social-security-scheme:query')")
    public CommonResult<List<HrmSocialSecuritySchemeRespVO>> getSchemeList(
            @RequestParam(value = "status", required = false) Integer status) {
        List<HrmSocialSecuritySchemeDO> list = schemeService.getSchemeList(status);
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/salary/HrmTaxRateConfigController.java
@@ -31,7 +31,7 @@
    @PostMapping("/create-list")
    @Operation(summary = "批量创建个税税率配置(一个税率表包含7个档位)")
    @PreAuthorize("@ss.hasPermission('hrm:tax-rate:create')")
    @PreAuthorize("@ss.hasPermission('hrm:tax-rate-config:create')")
    public CommonResult<Boolean> createTaxRateConfigList(@Valid @RequestBody List<HrmTaxRateConfigSaveReqVO> createReqVOList) {
        List<HrmTaxRateConfigDO> configList = BeanUtils.toBean(createReqVOList, HrmTaxRateConfigDO.class);
        taxRateConfigService.createTaxRateConfigList(configList);
@@ -40,7 +40,7 @@
    @PutMapping("/update")
    @Operation(summary = "更新个税税率配置")
    @PreAuthorize("@ss.hasPermission('hrm:tax-rate:update')")
    @PreAuthorize("@ss.hasPermission('hrm:tax-rate-config:update')")
    public CommonResult<Boolean> updateTaxRateConfig(@Valid @RequestBody HrmTaxRateConfigSaveReqVO updateReqVO) {
        HrmTaxRateConfigDO config = BeanUtils.toBean(updateReqVO, HrmTaxRateConfigDO.class);
        taxRateConfigService.updateTaxRateConfig(config);
@@ -50,7 +50,7 @@
    @DeleteMapping("/delete")
    @Operation(summary = "删除个税税率配置")
    @Parameter(name = "id", description = "配置ID", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:tax-rate:delete')")
    @PreAuthorize("@ss.hasPermission('hrm:tax-rate-config:delete')")
    public CommonResult<Boolean> deleteTaxRateConfig(@RequestParam("id") Long id) {
        taxRateConfigService.deleteTaxRateConfig(id);
        return success(true);
@@ -59,7 +59,7 @@
    @GetMapping("/get")
    @Operation(summary = "获取个税税率配置")
    @Parameter(name = "id", description = "配置ID", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:tax-rate:query')")
    @PreAuthorize("@ss.hasPermission('hrm:tax-rate-config:query')")
    public CommonResult<HrmTaxRateConfigRespVO> getTaxRateConfig(@RequestParam("id") Long id) {
        HrmTaxRateConfigDO config = taxRateConfigService.getTaxRateConfig(id);
        return success(BeanUtils.toBean(config, HrmTaxRateConfigRespVO.class));
@@ -67,7 +67,7 @@
    @GetMapping("/list")
    @Operation(summary = "获取个税税率配置列表")
    @PreAuthorize("@ss.hasPermission('hrm:tax-rate:query')")
    @PreAuthorize("@ss.hasPermission('hrm:tax-rate-config:query')")
    public CommonResult<List<HrmTaxRateConfigRespVO>> getTaxRateConfigList() {
        List<HrmTaxRateConfigDO> list = taxRateConfigService.getTaxRateConfigList();
        return success(BeanUtils.toBean(list, HrmTaxRateConfigRespVO.class));
@@ -76,7 +76,7 @@
    @GetMapping("/get-effective")
    @Operation(summary = "获取生效的个税税率配置列表(按级数排序)")
    @Parameter(name = "effectiveDate", description = "生效日期", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:tax-rate:query')")
    @PreAuthorize("@ss.hasPermission('hrm:tax-rate-config:query')")
    public CommonResult<List<HrmTaxRateConfigRespVO>> getEffectiveTaxRateConfigList(
            @RequestParam("effectiveDate") LocalDate effectiveDate) {
        List<HrmTaxRateConfigDO> list = taxRateConfigService.getEffectiveTaxRateConfigList(effectiveDate);
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/attendance/HrmAttendanceHolidayMapper.java
@@ -22,7 +22,8 @@
        return selectPage(reqVO, new LambdaQueryWrapperX<HrmAttendanceHolidayDO>()
                .likeIfPresent(HrmAttendanceHolidayDO::getName, reqVO.getName())
                .eqIfPresent(HrmAttendanceHolidayDO::getType, reqVO.getType())
                .betweenIfPresent(HrmAttendanceHolidayDO::getDate, reqVO.getStartDate(), reqVO.getEndDate())
                .eqIfPresent(HrmAttendanceHolidayDO::getIsLegal, reqVO.getIsLegal())
                .betweenIfPresent(HrmAttendanceHolidayDO::getDate, reqVO.getDate())
                .orderByDesc(HrmAttendanceHolidayDO::getDate));
    }
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceHolidayServiceImpl.java
@@ -62,13 +62,13 @@
    private void validateAttendanceHolidayExists(Long id) {
        if (attendanceHolidayMapper.selectById(id) == null) {
            throw exception(ATTENDANCE_HOLIDAY_NOT_EXISTS);
            throw exception(ATTENDANCE_HOLIDAY_NOT_EXISTS, id);
        }
    }
    private void validateAttendanceHolidayDateExists(LocalDate date) {
        if (attendanceHolidayMapper.selectByDate(date) != null) {
            throw exception(ATTENDANCE_HOLIDAY_DATE_EXISTS);
            throw exception(ATTENDANCE_HOLIDAY_DATE_EXISTS, date);
        }
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/ipqc/MesQcIpqcController.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.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.md.item.MesMdItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.unitmeasure.MesMdUnitMeasureDO;
@@ -45,8 +47,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;
@@ -151,6 +155,31 @@
        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("示例-XX工单过程检验").type("首检").workOrderCode("MO20260101000001")
                        .workstationName("示例工位").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
 *
 * è¯´æ˜Žï¼šä¸€è¡Œ = ä¸€å¼ è¿‡ç¨‹æ£€éªŒå•。产品物料由生产工单(或生产任务)自动推导,模板无需填写;
 * å·¥ä½ã€è´¨æ£€æ–¹æ¡ˆæŒ‰åç§°åŒ¹é…ç³»ç»Ÿå·²æœ‰æ•°æ®ï¼›æ£€æµ‹äººå‘˜ç•™ç©ºå–当前登录用户;状态固定为「待检验」。
 *
 * @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 = "MO20260101000001")
    private String workOrderCode;
    @ExcelProperty("工位名称")
    @Schema(description = "工位名称,需与工位管理中的名称一致", requiredMode = Schema.RequiredMode.REQUIRED, example = "装配工位1")
    private String workstationName;
    @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", example = "2")
    private BigDecimal laborScrapQuantity;
    @ExcelProperty("料废数量")
    @Schema(description = "料废数量,留空默认 0", example = "2")
    private BigDecimal materialScrapQuantity;
    @ExcelProperty("其他废品数量")
    @Schema(description = "其他废品数量,留空默认 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.srm.api.supplier.SrmSupplierApi;
import cn.iocoder.yudao.module.srm.api.supplier.dto.SrmSupplierRespDTO;
@@ -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;
@@ -42,8 +44,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-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 å®¢æˆ·ç®¡ç†ä¸­çš„名称一致", requiredMode = Schema.RequiredMode.REQUIRED, example = "示例客户有限公司")
    private String clientName;
    @ExcelProperty("批次号")
    @Schema(description = "批次号", example = "BC20260101")
    private String batchCode;
    @ExcelProperty("质检方案")
    @Schema(description = "质检方案名称,留空则按物料自动匹配", example = "出货检验通用方案")
    private String templateName;
    @ExcelProperty("本次出货数量")
    @Schema(description = "本次出货数量,不能小于 0", requiredMode = Schema.RequiredMode.REQUIRED, 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", requiredMode = Schema.RequiredMode.REQUIRED, 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/md/item/MesMdItemMapper.java
@@ -5,9 +5,10 @@
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.string.StrUtils;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.MPJLambdaWrapperX;
import cn.iocoder.yudao.module.mes.controller.admin.md.item.vo.MesMdItemPageReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdItemTypeDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@@ -16,7 +17,6 @@
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
@@ -29,7 +29,10 @@
public interface MesMdItemMapper extends BaseMapperX<MesMdItemDO> {
    default PageResult<MesMdItemDO> selectPage(MesMdItemPageReqVO reqVO, Collection<Long> itemTypeIds) {
        return selectPage(reqVO, new LambdaQueryWrapperX<MesMdItemDO>()
        MPJLambdaWrapperX<MesMdItemDO> query = new MPJLambdaWrapperX<>();
        query.selectAll(MesMdItemDO.class);
        // å…³è”物料分类,仅用于排序:同分类的物料聚在一起,分类之间按分类的显示排序
        query.leftJoin(MesMdItemTypeDO.class, MesMdItemTypeDO::getId, MesMdItemDO::getItemTypeId)
                .likeIfPresent(MesMdItemDO::getCode, reqVO.getCode())
                .likeIfPresent(MesMdItemDO::getName, reqVO.getName())
                .inIfPresent(MesMdItemDO::getItemTypeId, itemTypeIds)
@@ -37,7 +40,10 @@
                        StrUtil.isBlank(reqVO.getIds()) ? null : StrUtils.splitToLong(reqVO.getIds(), ","))
                .eqIfPresent(MesMdItemDO::getStatus, reqVO.getStatus())
                .betweenIfPresent(MesMdItemDO::getCreateTime, reqVO.getCreateTime())
                .orderByDesc(MesMdItemDO::getId));
                .orderByAsc(MesMdItemTypeDO::getSort)
                .orderByAsc(MesMdItemTypeDO::getCode)
                .orderByAsc(MesMdItemDO::getCode);
        return selectPage(reqVO, query);
    }
    default MesMdItemDO selectByCode(String code) {
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 workOrderId, Integer type, Long workstationId,
                                                    LocalDateTime inspectDate) {
        return selectList(new LambdaQueryWrapperX<MesQcIpqcDO>()
                .eq(MesQcIpqcDO::getWorkOrderId, workOrderId)
                .eq(MesQcIpqcDO::getType, type)
                .eq(MesQcIpqcDO::getWorkstationId, workstationId)
                .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;
/**
@@ -54,4 +55,18 @@
        return selectOne(MesQcOqcDO::getCode, code);
    }
    /**
     * æŒ‰å¯¼å…¥åŽ»é‡é”®æŸ¥è¯¢ï¼šåŒä¸€ ç‰©æ–™ + å®¢æˆ· + æ‰¹æ¬¡å· + å‡ºè´§æ—¥æœŸ è§†ä¸ºåŒä¸€æ‰¹å‡ºè´§æ£€éªŒæ•°æ®
     */
    default List<MesQcOqcDO> selectListByImportKey(Long itemId, Long clientId, String batchCode,
                                                   LocalDateTime outDate) {
        return selectList(new LambdaQueryWrapperX<MesQcOqcDO>()
                .eq(MesQcOqcDO::getItemId, itemId)
                .eq(MesQcOqcDO::getClientId, clientId)
                .eq(batchCode != null, MesQcOqcDO::getBatchCode, batchCode)
                .isNull(batchCode == null, MesQcOqcDO::getBatchCode)
                .eq(MesQcOqcDO::getOutDate, outDate)
                .orderByAsc(MesQcOqcDO::getId));
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/md/workstation/MesMdWorkstationService.java
@@ -74,6 +74,14 @@
    PageResult<MesMdWorkstationDO> getWorkstationPage(MesMdWorkstationPageReqVO pageReqVO);
    /**
     * æŒ‰åç§°èŽ·å¾—å·¥ä½œç«™ï¼ˆåç§°å…¨å±€å”¯ä¸€ï¼‰
     *
     * @param name å·¥ä½œç«™åç§°
     * @return å·¥ä½œç«™
     */
    MesMdWorkstationDO getWorkstationByName(String name);
    /**
     * èŽ·å¾—æŒ‡å®šçŠ¶æ€çš„å·¥ä½œç«™åˆ—è¡¨
     *
     * @param status çŠ¶æ€
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/md/workstation/MesMdWorkstationServiceImpl.java
@@ -239,6 +239,11 @@
    }
    @Override
    public MesMdWorkstationDO getWorkstationByName(String name) {
        return workstationMapper.selectByName(name);
    }
    @Override
    public List<MesMdWorkstationDO> getWorkstationListByStatus(Integer status) {
        return workstationMapper.selectListByStatus(status);
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/imports/MesQcImportHelper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,405 @@
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.md.workstation.MesMdWorkstationDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderDO;
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.md.workstation.MesMdWorkstationService;
import cn.iocoder.yudao.module.mes.service.pro.workorder.MesProWorkOrderService;
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;
    @Resource
    private MesProWorkOrderService workOrderService;
    @Resource
    private MesMdWorkstationService workstationService;
    /** è´¨æ£€æ–¹æ¡ˆåç§° -> ç¼–号(可能重名,命中多个时取第一个) */
    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;
    }
    /**
     * è§£æžå®¢æˆ·ç¼–号,基于客户名称
     */
    public Long resolveClientId(String clientName) {
        if (StrUtil.isBlank(clientName)) {
            throw new IllegalArgumentException("客户名称不能为空:请填写模板中该行的「客户名称」列");
        }
        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 å®¢æˆ·ç®¡ç†-我负责的」中查看),"
                + "或联系管理员为您开通该客户的权限后重新导入");
    }
    /**
     * è§£æžç”Ÿäº§å·¥å•编号,基于工单号
     */
    public Long resolveWorkOrderId(String workOrderCode) {
        if (StrUtil.isBlank(workOrderCode)) {
            throw new IllegalArgumentException("生产工单号不能为空:请填写模板中该行的「生产工单号」列");
        }
        MesProWorkOrderDO workOrder = workOrderService.getWorkOrder(workOrderCode.trim());
        if (workOrder == null) {
            throw new IllegalArgumentException("生产工单不存在:" + workOrderCode.trim()
                    + "。请在「生产管理-生产工单」中核对该工单号(需与模板完全一致),或修改模板后重新导入");
        }
        return workOrder.getId();
    }
    /**
     * è§£æžå·¥ä½ç¼–号,基于工位名称
     */
    public Long resolveWorkstationId(String workstationName) {
        if (StrUtil.isBlank(workstationName)) {
            throw new IllegalArgumentException("工位名称不能为空:请填写模板中该行的「工位名称」列");
        }
        MesMdWorkstationDO workstation = workstationService.getWorkstationByName(workstationName.trim());
        if (workstation == null) {
            throw new IllegalArgumentException("工位不存在:" + workstationName.trim()
                    + "。请在「基础数据-工位管理」中核对该工位名称(需与模板完全一致),或修改模板后重新导入");
        }
        return workstation.getId();
    }
    /**
     * è§£æžå­—典值:支持填写字典名称(如 æ ¡éªŒé€šè¿‡ï¼‰æˆ–字典值(如 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
@@ -3,12 +3,19 @@
import cn.hutool.core.collection.CollUtil;
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.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.enums.DictTypeConstants;
import cn.iocoder.yudao.module.mes.enums.md.autocode.MesMdAutoCodeRuleCodeEnum;
import cn.iocoder.yudao.module.mes.service.qc.imports.MesQcImportHelper;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.workstation.MesMdWorkstationDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.route.MesProRouteProductDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.task.MesProTaskDO;
@@ -46,7 +53,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;
@@ -114,6 +125,9 @@
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private MesQcImportHelper qcImportHelper;
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -456,4 +470,142 @@
        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.getWorkOrderCode())
                && StrUtil.isBlank(row.getWorkstationName()) && 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.setWorkOrderId(qcImportHelper.resolveWorkOrderId(row.getWorkOrderCode()));
        reqVO.setWorkstationId(qcImportHelper.resolveWorkstationId(row.getWorkstationName()));
        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.getWorkOrderId(),
                reqVO.getType(), reqVO.getWorkstationId(), reqVO.getInspectDate()));
        if (duplicate != null) {
            throw MesQcImportHelper.duplicateExistingException(buildImportKeyDesc(row), "过程检验单",
                    duplicate.getCode(), "质量管理-过程检验");
        }
        // 6. å¤ç”¨åˆ›å»ºé€»è¾‘:校验工单/工位/人员、按物料匹配质检方案、生成检验行
        try {
            createIpqc(reqVO);
        } catch (ServiceException ex) {
            MesQcImportHelper.rethrowTemplateError(ex, row.getWorkOrderCode(), "过程检验(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.getWorkOrderCode()), StrUtil.trimToEmpty(row.getType()),
                StrUtil.trimToEmpty(row.getWorkstationName()), StrUtil.trimToEmpty(row.getInspectDate()));
    }
    private String buildImportKeyDesc(MesQcIpqcImportExcelVO row) {
        return "生产工单[" + MesQcImportHelper.describeImportValue(row.getWorkOrderCode())
                + "]、检验类型[" + MesQcImportHelper.describeImportValue(row.getType())
                + "]、工位[" + MesQcImportHelper.describeImportValue(row.getWorkstationName())
                + "]、检测日期[" + 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,11 +2,18 @@
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.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.enums.DictTypeConstants;
import cn.iocoder.yudao.module.mes.enums.md.autocode.MesMdAutoCodeRuleCodeEnum;
import cn.iocoder.yudao.module.mes.service.qc.imports.MesQcImportHelper;
import cn.iocoder.yudao.module.mes.dal.dataobject.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;
@@ -82,6 +89,9 @@
    @Resource
    @Lazy
    private MesQcNcrService ncrService;
    @Resource
    private MesQcImportHelper qcImportHelper;
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -425,4 +435,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.dataobject.md.item.MesMdItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.unitmeasure.MesMdUnitMeasureDO;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.ledger.MesQcInspectionLedgerMapper;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemService;
import cn.iocoder.yudao.module.mes.service.md.unitmeasure.MesMdUnitMeasureService;
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 MesMdItemService itemService;
    @Resource
    private MesMdUnitMeasureService unitMeasureService;
    @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());
        }
        // æ‰¹é‡æŸ¥è¯¢ç‰©æ–™ã€è®¡é‡å•位、检测人员,并拼装判定依据
        Map<Long, MesMdItemDO> itemMap = itemService.getItemMap(convertSet(list, MesQcInspectionLedgerRespVO::getItemId));
        Map<Long, MesMdUnitMeasureDO> unitMeasureMap = unitMeasureService.getUnitMeasureMap(
                convertSet(itemMap.values(), MesMdItemDO::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,10 +2,17 @@
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.enums.DictTypeConstants;
import cn.iocoder.yudao.module.mes.enums.md.autocode.MesMdAutoCodeRuleCodeEnum;
import cn.iocoder.yudao.module.mes.service.qc.imports.MesQcImportHelper;
import cn.iocoder.yudao.module.mes.dal.dataobject.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;
@@ -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;
@@ -80,6 +91,9 @@
    @Resource
    @Lazy
    private MesQcNcrService ncrService;
    @Resource
    private MesQcImportHelper qcImportHelper;
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -397,4 +411,119 @@
        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());
        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(), "本次出货数量", true));
        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.parseRequiredDateTime(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,
                NULL 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>