4 小时以前 9bad721754fe8bbe2e5f459d0706e0fefac569f3
feat(mes): 添加智能质检报告功能与AI导入能力

- 新增智能质检报告模块,包括报告模板、模板版本、报告实例实体设计
- 实现质检单据一键生成报告功能,支持多版本模板与数据快照冻结
- 集成AI识别能力,支持上传现有报告文件自动识别为模板草稿
- 添加PDF导出功能,基于Chromium渲染HTML生成PDF报告
- 完善质检指标体系,支持两层指标分组与公式计算
- 优化AI模型配置,增加temperature、maxTokens、timeout参数支持
- 更新文档流图,补充质检流程与模块关系说明
- 修复文件读取逻辑,避免临时文件删除导致的数据丢失问题
已添加144个文件
已修改37个文件
35196 ■■■■■ 文件已修改
.playwright-mcp/物料导入模板.xls 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/business.json 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/flows.json 7 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/modules.json 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/objects.json 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/index.html 155 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/01-business-mindmap.mmd 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/05-quality-flow.mmd 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/08-module-relation.mmd 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/09-data-flow.mmd 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/10-business-object.mmd 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/11-ai-business-flow.mmd 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/qc_report_ai_import_frontend_integration.md 360 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/qc_report_configurable_columns_design.md 239 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/qc_report_generate_from_qc_frontend_integration.md 251 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/qc_report_grouped_inspection_design.md 187 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/qc_report_indicator_hierarchy_frontend_integration.md 196 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/qc_report_instance_frontend_integration.md 290 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/qc_report_template_version_frontend_integration.md 124 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/config_export_all_20260918.sql 11435 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/qc_report_dict.sql 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/qc_report_menu.sql 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/qc_report_platform_ddl.sql 140 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/智能质检报告平台-开发进度.md 3382 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/智能质检报告平台-方案设计.md 374 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/智能质检报告平台-质检单对接方案.md 572 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/智能质检报告设计平台——Claude Code Agent 专业开发提示词.md 2547 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pom.xml 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-dependencies/pom.xml 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/config/AiAutoConfiguration.java 18 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/config/YudaoAiProperties.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/AiModelFactory.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/AiModelFactoryImpl.java 11 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/service/model/AiModelServiceImpl.java 8 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/ai/CrmSaleQuotationAiServiceImpl.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ai/ErpPurchaseInvoiceAiServiceImpl.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes-api/src/main/java/cn/iocoder/yudao/module/mes/api/qc/MesQcReportApi.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes-api/src/main/java/cn/iocoder/yudao/module/mes/api/qc/dto/MesQcReportItemRespDTO.java 89 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes-api/src/main/java/cn/iocoder/yudao/module/mes/api/qc/dto/MesQcReportRespDTO.java 116 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/api/qc/MesQcReportApiImpl.java 632 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/indicator/MesQcIndicatorController.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/indicator/vo/MesQcIndicatorPageReqVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/indicator/vo/MesQcIndicatorRespVO.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/indicator/vo/MesQcIndicatorSaveReqVO.java 15 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/qc/indicator/MesQcIndicatorDO.java 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/qc/indicator/MesQcIndicatorMapper.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/qc/MesQcIndicatorItemTypeEnum.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/indicator/MesQcIndicatorService.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/indicator/MesQcIndicatorServiceImpl.java 116 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/indicator/MesQcIndicatorServiceImplTest.java 323 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/resources/sql/clean.sql 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/resources/sql/create_tables.sql 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/pom.xml 98 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/config/QcReportAiImportProperties.java 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/config/QcReportPdfProperties.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/aiimport/QcReportAiImportController.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/aiimport/vo/QcReportAiDraftReqVO.java 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/aiimport/vo/QcReportAiDraftRespVO.java 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/aiimport/vo/QcReportComponentSpecVO.java 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/instance/QcReportInstanceController.java 172 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/instance/vo/QcReportInstanceGenerateFromQcReqVO.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/instance/vo/QcReportInstanceGenerateFromQcRespVO.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/instance/vo/QcReportInstanceGenerateReqVO.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/instance/vo/QcReportInstanceGenerateRespVO.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/instance/vo/QcReportInstancePageReqVO.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/instance/vo/QcReportInstancePdfRespVO.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/instance/vo/QcReportInstancePreviewRespVO.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/instance/vo/QcReportInstanceRegenerateRespVO.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/instance/vo/QcReportInstanceRespVO.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/template/QcReportTemplateController.java 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/template/vo/QcReportTemplatePageReqVO.java 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/template/vo/QcReportTemplateRespVO.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/template/vo/QcReportTemplateSaveReqVO.java 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/version/QcReportTemplateVersionController.java 113 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/version/vo/QcReportTemplateVersionPageReqVO.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/version/vo/QcReportTemplateVersionRespVO.java 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/version/vo/QcReportTemplateVersionSaveReqVO.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/version/vo/QcReportTemplateVersionUpdateReqVO.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/dal/dataobject/instance/QcReportInstanceDO.java 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/dal/dataobject/render/QcReportRenderRecordDO.java 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/dal/dataobject/template/QcReportTemplateDO.java 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/dal/dataobject/version/QcReportTemplateVersionDO.java 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/dal/dataobject/version/ReportTemplateSchema.java 145 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/dal/mysql/instance/QcReportInstanceMapper.java 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/dal/mysql/render/QcReportRenderRecordMapper.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/dal/mysql/template/QcReportTemplateMapper.java 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/dal/mysql/version/QcReportTemplateVersionMapper.java 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/Bindings.java 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/JsValues.java 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/Numbers.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/PageMargin.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/PageSetting.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/PageSizes.java 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/Paths.java 199 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/QualityReportEngine.java 115 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/QualityResult.java 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/ResolvedPage.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/context/InspectionItem.java 113 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/context/ReportContext.java 120 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/context/ReportContextCodec.java 137 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/context/ReportFields.java 85 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/render/CanvasSafety.java 268 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/render/HtmlRenderer.java 408 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/render/RenderNode.java 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/render/RenderOutcome.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/report/EvaluationOutcome.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/report/ReportEvaluator.java 190 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/rule/QualityRuleDefinition.java 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/rule/RuleEngine.java 101 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/rule/RuleEvaluator.java 415 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/rule/RuleNode.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/rule/RuleParser.java 372 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/rule/RuleRuntimeException.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/rule/RuleScope.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/rule/RuleSyntaxException.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/rule/RuleToken.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/rule/TokenType.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/enums/ErrorCodeConstants.java 117 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/enums/QcReportEnums.java 156 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/enums/QcReportSourceTypeEnum.java 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/aiimport/QcReportAiImportService.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/aiimport/QcReportAiImportServiceImpl.java 323 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/aiimport/document/ImageImportAdapter.java 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/aiimport/document/OfficeImportAdapter.java 372 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/aiimport/document/PdfImportAdapter.java 146 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/aiimport/document/QcReportDocumentExtract.java 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/aiimport/document/QcReportDocumentExtractService.java 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/aiimport/document/QcReportImportAdapter.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/aiimport/llm/QcReportAiDraftNormalizer.java 227 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/aiimport/llm/QcReportAiDraftParser.java 140 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/aiimport/llm/QcReportLlmCall.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/aiimport/llm/QcReportLlmCallPlanner.java 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/aiimport/llm/QcReportTemplatePromptBuilder.java 247 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/instance/GenerateFromQcResult.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/instance/GenerateResult.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/instance/MesQcReportContextMapper.java 218 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/instance/PdfArchiveResult.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/instance/QcReportInstanceService.java 117 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/instance/QcReportInstanceServiceImpl.java 531 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/instance/ReportNoGenerator.java 85 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/render/BrowserManager.java 229 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/render/PdfPrintOptions.java 76 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/render/PdfRenderService.java 104 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/template/QcReportTemplateService.java 98 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/template/QcReportTemplateServiceImpl.java 229 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/version/QcReportTemplateVersionService.java 113 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/version/QcReportTemplateVersionServiceImpl.java 238 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/test/java/cn/iocoder/yudao/module/qcreport/dal/dataobject/version/ReportTemplateSchemaCompatibilityTest.java 104 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/test/java/cn/iocoder/yudao/module/qcreport/engine/FrontendConformanceTest.java 172 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/test/java/cn/iocoder/yudao/module/qcreport/engine/QualityReportEngineTest.java 96 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/test/java/cn/iocoder/yudao/module/qcreport/engine/ReportEvaluatorTest.java 399 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/test/java/cn/iocoder/yudao/module/qcreport/engine/context/ReportContextCodecTest.java 198 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/test/java/cn/iocoder/yudao/module/qcreport/engine/render/CanvasSafetyTest.java 242 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/test/java/cn/iocoder/yudao/module/qcreport/service/aiimport/document/AiImportFixtures.java 241 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/test/java/cn/iocoder/yudao/module/qcreport/service/aiimport/document/QcReportImportAdapterTest.java 312 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/test/java/cn/iocoder/yudao/module/qcreport/service/aiimport/llm/QcReportAiDraftNormalizerTest.java 230 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/test/java/cn/iocoder/yudao/module/qcreport/service/aiimport/llm/QcReportAiDraftParserTest.java 132 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/test/java/cn/iocoder/yudao/module/qcreport/service/aiimport/llm/QcReportLlmCallPlannerTest.java 85 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/test/java/cn/iocoder/yudao/module/qcreport/service/aiimport/llm/QcReportTemplatePromptBuilderTest.java 244 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/test/java/cn/iocoder/yudao/module/qcreport/service/instance/MesQcReportContextMapperTest.java 364 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/test/java/cn/iocoder/yudao/module/qcreport/service/instance/ReportNoGeneratorTest.java 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/test/java/cn/iocoder/yudao/module/qcreport/service/render/PdfPrintOptionsTest.java 158 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/test/resources/qcreport/canvas/canvas-t1-v1.0.json 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/test/resources/qcreport/canvas/canvas-t1-v1.1.json 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/test/resources/qcreport/canvas/canvas-t3-v1.0.json 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/test/resources/qcreport/canvas/canvas-t5-v1.0.json 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/test/resources/qcreport/canvas/canvas-t5-v1.1.json 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/test/resources/qcreport/canvas/canvas-t5-v1.2.json 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/test/resources/qcreport/context.json 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/test/resources/qcreport/expected-html.html 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/test/resources/qcreport/expected-rules.json 247 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-qcreport/src/test/resources/qcreport/schema.json 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/storage/StorageBlobApi.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/storage/StorageBlobApiImpl.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/storage/SystemStorageBlobService.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/storage/SystemStorageBlobServiceImpl.java 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/pom.xml 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application-local.yaml 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application-test.yaml 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.playwright-mcp/ÎïÁϵ¼ÈëÄ£°å.xls
Binary files differ
docs/project-business/data/business.json
@@ -21,8 +21,8 @@
    "frontend": "mom-pro2-before(Vue3 ç®¡ç†åŽå°ï¼ŒåŒä»“库存放,只读参考)"
  },
  "stats": {
    "businessModules": 12,
    "coreObjects": 46,
    "businessModules": 13,
    "coreObjects": 48,
    "documentFlows": 6,
    "approvalListeners": 13,
    "aiCapabilities": 8,
docs/project-business/data/flows.json
@@ -29,13 +29,15 @@
       {"stage": "联动", "action": "审批通过按工序标记: å€’冲→自动物料消耗扣线边库(允许负库存);质检→产出待检行等 IPQC;关键非检→直接产出入库", "roles": ["系统自动"]},
       {"stage": "完工", "action": "仅末道工序回写工单已生产数量,达标自动完成;MPS ç´¯è®¡åˆ¤æ–­å®Œæˆå¹¶å‘事件给销售订单", "roles": ["系统自动"]}]},
    {"id": "quality", "name": "质量流程", "mermaid": "mermaid/05-quality-flow.mmd", "color": "#e06c6c",
     "summary": "四道检验闸口(IQC/IPQC/OQC/RQC,二态 0→4) + åˆ¤å®šå››æžœ(合格/特采/退货/报废) + NCR è¯„审处置关闭;AI æä¾›åˆ¤å®šå»ºè®®ï¼›NCR å½“前仅台账不触发返工单。",
     "summary": "四道检验闸口(IQC/IPQC/OQC/RQC,二态 0→4) + åˆ¤å®šå››æžœ(合格/特采/退货/报废) + NCR è¯„审处置关闭;AI æä¾›åˆ¤å®šå»ºè®®ï¼›NCR å½“前仅台账不触发返工单;检验单可由智能质检报告平台一键出件(generate-from-qc:数据从 MES å–,五道校验后渲染落库)。",
     "steps": [
       {"stage": "来料", "action": "到货通知/外协入库→IQC,结论回写推进待入库或触发退货", "roles": ["IQC è´¨æ£€å‘˜"]},
       {"stage": "过程", "action": "报工质检工序产出行→IPQC,合格拆行入库、不合格生成 NCR", "roles": ["IPQC è´¨æ£€å‘˜"]},
       {"stage": "出货", "action": "销售出库提交→OQC,完成后出库单进入待拣货", "roles": ["OQC è´¨æ£€å‘˜"]},
       {"stage": "退货", "action": "退料/销售退货/售后退货→RQC,结论回调单据与售后", "roles": ["RQC è´¨æ£€å‘˜"]},
       {"stage": "不合格", "action": "NCR: å¾…评审→评审→处置(退货/报废/返工/让步接收/降级)→关闭", "roles": ["质量工程师"]}]},
       {"stage": "不合格", "action": "NCR: å¾…评审→评审→处置(退货/报废/返工/让步接收/降级)→关闭", "roles": ["质量工程师"]},
       {"stage": "出件", "action": "两条出件入口:①通用 generate/preview(调用方自带上下文) æ¸²æŸ“ HTML å¹¶å†»ç»“数据快照,编号 QR+日期+4位流水按天独立,preview åªæ¸²æŸ“不落库不消耗编号,regenerate ç”¨å†»ç»“快照重渲且数据与编号不变并作废已归档 PDF,导出 PDF ç”±åŽç«¯è°ƒ Chromium æ‰“印实例已存的 HTML åŽå½’档附件中心;②generate-from-qc ç”±è´¨æ£€å•直接出件(数据经 MesQcReportApi ä»Ž MES å–,前端不拼上下文),出件前五道校验按业务优先级只报第一处:类型非法/单据不存在/未完成检验/未填判定/模板类型不符/无可用检验项,报告头另带单据原判定 qcResult+qcResultText ä¸Žå¼•擎判定并存,响应回 undecidableCount å¾…判定项数与 warnings(分组标题跳过/指标已删/未录实测值/样品超3),同一张单据可反复出多份", "roles": ["质检员"]},
       {"stage": "建模板", "action": "AI å¯¼å…¥ï¼šä¸Šä¼ æ—¢æœ‰æ£€éªŒæŠ¥å‘Šæ–‡ä»¶(扫描件/照片、电子版 PDF、Word/Excel) â†’ è¯†åˆ«æˆæ¨¡æ¿è‰ç¨¿(不落库) â†’ äººå·¥åœ¨è®¾è®¡å™¨ç¡®è®¤è°ƒæ•´åŽä¿å­˜ä¸ºç‰ˆæœ¬ï¼›.doc/.xls æ—§æ ¼å¼ä¸æ”¯æŒ", "roles": ["质检员", "质量工程师"]}]},
    {"id": "warehouse", "name": "仓储流程", "mermaid": "mermaid/06-warehouse-flow.mmd", "color": "#7e6bd6",
     "summary": "15 ç±»å•据驱动库存台账(六元组、四量)变动,全部经事务流水留痕;支持调拨在途、盘点调整、批次/SN è¿½æº¯ã€åº“存占用与四级冻结、线边虚拟库负库存;对外 WmsStockApi ä¾› ERP æ”¶å‘。",
     "steps": [
@@ -57,6 +59,7 @@
       {"stage": "录入提效", "action": "发票 OCR(图片多模态/PDF文本)、报价单 OCR â†’ ç»“构化预填", "roles": ["采购员", "销售"]},
       {"stage": "辅助判定", "action": "质检单+指标+缺陷 â†’ å»ºè®®åˆæ ¼/特采/退货/报废+理由", "roles": ["质检员"]},
       {"stage": "决策预测", "action": "缺料/生产时长/生产风险/交付四类预测供计划排产参考", "roles": ["计划员"]},
       {"stage": "模板识别", "action": "上传既有检验报告文件(扫描件/照片、电子版 PDF、Word/Excel) â†’ åˆ†é€šé“抽取(有文本层走文本、扫描版逐页转图) â†’ å¤§æ¨¡åž‹æŒ‰å‰ç«¯ä¸Šä¼ çš„组件清单产出扁平组件草稿 â†’ äººå·¥åœ¨è®¾è®¡å™¨ç¡®è®¤åŽä¿å­˜ä¸ºæ¨¡æ¿ç‰ˆæœ¬", "roles": ["质检员", "质量工程师"]},
       {"stage": "知识检索", "action": "文档→切片→Milvus å‘量→检索接口(预留)", "roles": ["—"]}]}
  ]
}
docs/project-business/data/modules.json
@@ -56,6 +56,14 @@
     "roles": ["质检员(IQC/IPQC/OQC)", "质量工程师"],
     "upstream": ["仓储(到货)", "生产(报工)", "销售(出库)", "售后(退货)"], "downstream": ["仓储(合格入库/报废出库)", "生产(NCR返工)"],
     "coreTables": ["mes_qc_iqc", "mes_qc_ipqc", "mes_qc_oqc", "mes_qc_rqc", "mes_qc_template", "mes_qc_indicator", "mes_qc_defect", "mes_qc_ncr"]},
    {"key": "qcreport", "name": "智能质检报告", "color": "#b06ce0", "tag": "QC-REPORT", "flow": "quality",
     "positioning": "可视化设计报告模板 â†’ ä¸€é”®å‡ºä»¶å¹¶å†»ç»“历史报告",
     "problem": "各行业质检报告格式各异,排版硬编码改一次发一次版;且历史报告必须可复现、不随业务数据变化",
     "features": ["GrapesJS å¯è§†åŒ–设计器(质检组件走注册机制,不硬编码进编辑器)", "模板 + å¤šç‰ˆæœ¬ï¼šè‰ç¨¿0/已发布1/已停用2,已发布版本内容不可覆盖", "组件数据绑定与独立判定规则引擎(禁用 eval / ScriptEngine)", "与前端逐字对齐的 Java æ¸²æŸ“引擎,出站 HTML å‰¥ç¦» on* äº‹ä»¶å±žæ€§ä¸Ž javascript:/vbscript:/data: åè®®", "出件接口:preview åªæ¸²æŸ“不落库不消耗编号 / generate æ¸²æŸ“+落库+冻结 data_snapshot / regenerate ç”¨å†»ç»“快照与同一版本重渲", "报告编号 QR+yyyyMMdd+4位流水,按天独立,可显式指定(撞号会报出具体编号)", "缺口清单只在 preview/generate/regenerate å“åº”里返回,不落库", "PDF(Playwright) å½’档与 MES å››ç§è´¨æ£€å•归一化对接待后续轮次"],
     "objects": ["报告模板", "报告实例"],
     "roles": ["报告设计员", "质检员(出件)"],
     "upstream": ["质量(质检单上下文,本轮由调用方传入)"], "downstream": ["附件中心(PDF å½’档·后续轮次)", "BI"],
     "coreTables": ["qc_report_template", "qc_report_template_version", "qc_report_instance", "qc_report_data_source", "qc_report_rule", "qc_report_render_record"]},
    {"key": "dv", "name": "设备与工装排班", "color": "#59a8b8", "tag": "MES-DV/TM/CAL", "flow": null,
     "positioning": "设备资产健康与生产资源保障",
     "problem": "设备点检保养维修可执行、工装状态可控、班组排班有依据",
docs/project-business/data/objects.json
@@ -35,16 +35,18 @@
    {"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": "RQC退货检验", "module": "质量", "table": "mes_qc_rqc", "doc": "生产退料/销售退货/售后退货触发,结论回调售后", "upstream": ["退料", "退货"], "downstream": ["退货入库", "售后回调"], "keywords": "RQC é€€è´§æ£€éªŒ"},
    {"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": "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"},
    {"name": "售后工单", "module": "售后", "table": "after_sale_ticket", "doc": "草稿0→处理中10→待入库30→待退款40→完结50→关闭60/取消99,判定:一般/ç»´ä¿®/退货", "upstream": ["客户反馈(人工录入)"], "downstream": ["维修记录", "退货申请"], "keywords": "售后 å·¥å• ticket ç»´ä¿®"},
    {"name": "退货申请", "module": "售后", "table": "after_sale_return", "doc": "草稿0→已审核20(自动建MES退货单)→已质检40→已入库50→已退款60→关闭70;类型:退款退货/换货/仅退款", "upstream": ["售后工单"], "downstream": ["销售退货单", "退款"], "keywords": "售后退货 return"},
    {"name": "员工", "module": "人力", "table": "hrm_employee", "doc": "在职1/试用2/离职3,入职自动建系统账号", "upstream": [], "downstream": ["交接映射", "考勤", "薪酬"], "keywords": "员工 å…¥èŒ employee"},
    {"name": "离职交接", "module": "人力", "table": "hrm_user_handover", "doc": "from→to æ˜ å°„,已完成20 åŽ expandUserIds æ‰©å±•交接人可见数据(当前各业务模块尚未实际接入调用)", "upstream": ["离职申请"], "downstream": ["各模块我的数据查询"], "keywords": "交接 ç¦»èŒ handover expandUserIds"},
    {"name": "排班计划", "module": "设备", "table": "mes_cal_plan", "doc": "班组×班次×日期排班,供生产与考勤引用", "upstream": [], "downstream": ["生产任务", "考勤"], "keywords": "排班 ç­ç»„ ç­æ¬¡ æ—¥åކ"}
    {"name": "排班计划", "module": "设备", "table": "mes_cal_plan", "doc": "班组×班次×日期排班,供生产与考勤引用", "upstream": [], "downstream": ["生产任务", "考勤"], "keywords": "排班 ç­ç»„ ç­æ¬¡ æ—¥åކ"},
    {"name": "报告模板", "module": "质检报告", "table": "qc_report_template", "doc": "智能质检报告模板,启用0/停用1;画布内容存在 qc_report_template_version(草稿0/已发布1/已停用2,已发布不可覆盖),currentVersion æŒ‡å‘当前已发布版本;源文件经 AI å¯¼å…¥è¯†åˆ«æˆè‰ç¨¿åŽç”±äººå·¥ç¡®è®¤æ‰æˆä¸ºç‰ˆæœ¬ï¼›åŒä¸€æ¨¡æ¿é‡å¤å¯¼å…¥æŒ‰ã€æ›¿æ¢ã€‘处理——先删旧附件(连带 blob è¡Œä¸Žç£ç›˜æ–‡ä»¶ï¼‰å†ç»‘新的,不留孤儿文件;删除模板会连带清附件", "upstream": ["人工设计(GrapesJS è®¾è®¡å™¨)", "AI å¯¼å…¥è‰ç¨¿(qc-report/ai-import/draft)"], "downstream": ["模板版本", "报告实例"], "keywords": "报告模板 è´¨æ£€æŠ¥å‘Š template è®¾è®¡å™¨ ç‰ˆæœ¬ AI导入 è¯†åˆ« è‰ç¨¿ æºæ–‡ä»¶æ›¿æ¢"},
    {"name": "报告实例", "module": "质检报告", "table": "qc_report_instance", "doc": "出件产物,编号 QR+yyyyMMdd+4位流水按天独立且业务唯一,可显式指定覆盖(注意 uk_report_no ä¸åŒºåˆ†è½¯åˆ é™¤ï¼Œåˆ æŽ‰çš„号不回收);两条出件入口:通用 generate(调用方自带 context)与 generate-from-qc(数据从 MES è´¨æ£€å•取,权限码 qc-report:instance:generate å·²å…¥ system_menu id=1075416,五道校验按业务优先级只报第一处:105_000 ç±»åž‹éžæ³• / 105_001 å•据不存在 / 105_002 æœªå®Œæˆ / 105_003 æœªåˆ¤å®š / 105_005 æ¨¡æ¿ç±»åž‹ä¸ç¬¦ / 105_006 æ— å¯ç”¨æ£€éªŒé¡¹ï¼›å››å¼ è´¨æ£€å•列表的行操作「出报告」仅在 status=已完成时可见,弹窗只列 reportType ä¸€è‡´ã€æ¨¡æ¿å¯ç”¨ã€ä¸” currentVersion æŒ‡å‘的版本已发布的模板(后端 /qc-report/template/selectable åˆ¤å®šâ€”—停用版本不清空 currentVersion,前端拿分页结果自己推导会漏),结果就地展示并跳本页列表);冻结 data_snapshot(判定后数据,含后端算好的 PASS/FAIL ä¸Žåˆæ ¼çŽ‡ï¼‰ä¸Ž render_html;regenerate åªé‡æ¸²æŽ’版、数据与编号不变;business_id/business_type å­˜ MES è¡¨åï¼ˆmes_qc_iqc ç­‰ï¼‰ä¸Žå•据 ID,可反查、一单可出多份;报告头另带单据原判定 qcResult(数)/qcResultText(中文,合格/特采/不合格退货/不合格报废),与引擎三态判定并存互不覆盖;响应带 undecidableCount=快照中 result ä¸ºç©ºä¸²çš„项数与 warnings(分组标题跳过/指标已删/未录实测值/样品超3),两者都不落库、事后看不到", "upstream": ["报告模板版本", "质检单上下文(通用 generate ç”±è°ƒç”¨æ–¹ä¼ å…¥)", "MES è´¨æ£€å• IQC/IPQC/OQC/RQC(generate-from-qc ç» MesQcReportApi å–æ•°)"], "downstream": ["前端实例页 /qc/report-instance(列表·iframe预览·快照页签·导出PDF)", "PDF归档(system_storage_attachment recordType=qc_report_instance)"], "keywords": "报告实例 å‡ºä»¶ QR编号 å¿«ç…§ å¤çް regenerate PDF归档 æŠ¥å‘Šå®žä¾‹é¡µ å¯¼å‡ºPDF generate-from-qc è´¨æ£€å•出报告 å‡ºæŠ¥å‘Šå¼¹çª— qcResult undecidableCount warnings å¾…判定 åæŸ¥ ä¸šåŠ¡å•æ®ç­›é€‰"}
  ]
}
docs/project-business/index.html
@@ -201,8 +201,8 @@
    "frontend": "mom-pro2-before(Vue3 ç®¡ç†åŽå°ï¼ŒåŒä»“库存放,只读参考)"
  },
  "stats": {
    "businessModules": 12,
    "coreObjects": 46,
    "businessModules": 13,
    "coreObjects": 48,
    "documentFlows": 6,
    "approvalListeners": 13,
    "aiCapabilities": 8,
@@ -386,6 +386,14 @@
     "roles": ["质检员(IQC/IPQC/OQC)", "质量工程师"],
     "upstream": ["仓储(到货)", "生产(报工)", "销售(出库)", "售后(退货)"], "downstream": ["仓储(合格入库/报废出库)", "生产(NCR返工)"],
     "coreTables": ["mes_qc_iqc", "mes_qc_ipqc", "mes_qc_oqc", "mes_qc_rqc", "mes_qc_template", "mes_qc_indicator", "mes_qc_defect", "mes_qc_ncr"]},
    {"key": "qcreport", "name": "智能质检报告", "color": "#b06ce0", "tag": "QC-REPORT", "flow": "quality",
     "positioning": "可视化设计报告模板 â†’ ä¸€é”®å‡ºä»¶å¹¶å†»ç»“历史报告",
     "problem": "各行业质检报告格式各异,排版硬编码改一次发一次版;且历史报告必须可复现、不随业务数据变化",
     "features": ["GrapesJS å¯è§†åŒ–设计器(质检组件走注册机制,不硬编码进编辑器)", "模板 + å¤šç‰ˆæœ¬ï¼šè‰ç¨¿0/已发布1/已停用2,已发布版本内容不可覆盖", "组件数据绑定与独立判定规则引擎(禁用 eval / ScriptEngine)", "与前端逐字对齐的 Java æ¸²æŸ“引擎,出站 HTML å‰¥ç¦» on* äº‹ä»¶å±žæ€§ä¸Ž javascript:/vbscript:/data: åè®®", "出件接口:preview åªæ¸²æŸ“不落库不消耗编号 / generate æ¸²æŸ“+落库+冻结 data_snapshot / regenerate ç”¨å†»ç»“快照与同一版本重渲", "报告编号 QR+yyyyMMdd+4位流水,按天独立,可显式指定(撞号会报出具体编号)", "缺口清单只在 preview/generate/regenerate å“åº”里返回,不落库", "PDF(Playwright) å½’档与 MES å››ç§è´¨æ£€å•归一化对接待后续轮次"],
     "objects": ["报告模板", "报告实例"],
     "roles": ["报告设计员", "质检员(出件)"],
     "upstream": ["质量(质检单上下文,本轮由调用方传入)"], "downstream": ["附件中心(PDF å½’档·后续轮次)", "BI"],
     "coreTables": ["qc_report_template", "qc_report_template_version", "qc_report_instance", "qc_report_data_source", "qc_report_rule", "qc_report_render_record"]},
    {"key": "dv", "name": "设备与工装排班", "color": "#59a8b8", "tag": "MES-DV/TM/CAL", "flow": null,
     "positioning": "设备资产健康与生产资源保障",
     "problem": "设备点检保养维修可执行、工装状态可控、班组排班有依据",
@@ -470,17 +478,19 @@
    {"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": "RQC退货检验", "module": "质量", "table": "mes_qc_rqc", "doc": "生产退料/销售退货/售后退货触发,结论回调售后", "upstream": ["退料", "退货"], "downstream": ["退货入库", "售后回调"], "keywords": "RQC é€€è´§æ£€éªŒ"},
    {"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": "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"},
    {"name": "售后工单", "module": "售后", "table": "after_sale_ticket", "doc": "草稿0→处理中10→待入库30→待退款40→完结50→关闭60/取消99,判定:一般/ç»´ä¿®/退货", "upstream": ["客户反馈(人工录入)"], "downstream": ["维修记录", "退货申请"], "keywords": "售后 å·¥å• ticket ç»´ä¿®"},
    {"name": "退货申请", "module": "售后", "table": "after_sale_return", "doc": "草稿0→已审核20(自动建MES退货单)→已质检40→已入库50→已退款60→关闭70;类型:退款退货/换货/仅退款", "upstream": ["售后工单"], "downstream": ["销售退货单", "退款"], "keywords": "售后退货 return"},
    {"name": "员工", "module": "人力", "table": "hrm_employee", "doc": "在职1/试用2/离职3,入职自动建系统账号", "upstream": [], "downstream": ["交接映射", "考勤", "薪酬"], "keywords": "员工 å…¥èŒ employee"},
    {"name": "离职交接", "module": "人力", "table": "hrm_user_handover", "doc": "from→to æ˜ å°„,已完成20 åŽ expandUserIds æ‰©å±•交接人可见数据(当前各业务模块尚未实际接入调用)", "upstream": ["离职申请"], "downstream": ["各模块我的数据查询"], "keywords": "交接 ç¦»èŒ handover expandUserIds"},
    {"name": "排班计划", "module": "设备", "table": "mes_cal_plan", "doc": "班组×班次×日期排班,供生产与考勤引用", "upstream": [], "downstream": ["生产任务", "考勤"], "keywords": "排班 ç­ç»„ ç­æ¬¡ æ—¥åކ"}
    {"name": "排班计划", "module": "设备", "table": "mes_cal_plan", "doc": "班组×班次×日期排班,供生产与考勤引用", "upstream": [], "downstream": ["生产任务", "考勤"], "keywords": "排班 ç­ç»„ ç­æ¬¡ æ—¥åކ"},
    {"name": "报告模板", "module": "质检报告", "table": "qc_report_template", "doc": "智能质检报告模板,启用0/停用1;画布内容存在 qc_report_template_version(草稿0/已发布1/已停用2,已发布不可覆盖),currentVersion æŒ‡å‘当前已发布版本;源文件经 AI å¯¼å…¥è¯†åˆ«æˆè‰ç¨¿åŽç”±äººå·¥ç¡®è®¤æ‰æˆä¸ºç‰ˆæœ¬ï¼›åŒä¸€æ¨¡æ¿é‡å¤å¯¼å…¥æŒ‰ã€æ›¿æ¢ã€‘处理——先删旧附件(连带 blob è¡Œä¸Žç£ç›˜æ–‡ä»¶ï¼‰å†ç»‘新的,不留孤儿文件;删除模板会连带清附件", "upstream": ["人工设计(GrapesJS è®¾è®¡å™¨)", "AI å¯¼å…¥è‰ç¨¿(qc-report/ai-import/draft)"], "downstream": ["模板版本", "报告实例"], "keywords": "报告模板 è´¨æ£€æŠ¥å‘Š template è®¾è®¡å™¨ ç‰ˆæœ¬ AI导入 è¯†åˆ« è‰ç¨¿ æºæ–‡ä»¶æ›¿æ¢"},
    {"name": "报告实例", "module": "质检报告", "table": "qc_report_instance", "doc": "出件产物,编号 QR+yyyyMMdd+4位流水按天独立且业务唯一,可显式指定覆盖(注意 uk_report_no ä¸åŒºåˆ†è½¯åˆ é™¤ï¼Œåˆ æŽ‰çš„号不回收);两条出件入口:通用 generate(调用方自带 context)与 generate-from-qc(数据从 MES è´¨æ£€å•取,权限码 qc-report:instance:generate å·²å…¥ system_menu id=1075416,五道校验按业务优先级只报第一处:105_000 ç±»åž‹éžæ³• / 105_001 å•据不存在 / 105_002 æœªå®Œæˆ / 105_003 æœªåˆ¤å®š / 105_005 æ¨¡æ¿ç±»åž‹ä¸ç¬¦ / 105_006 æ— å¯ç”¨æ£€éªŒé¡¹ï¼›å››å¼ è´¨æ£€å•列表的行操作「出报告」仅在 status=已完成时可见,弹窗只列 reportType ä¸€è‡´ã€æ¨¡æ¿å¯ç”¨ã€ä¸” currentVersion æŒ‡å‘的版本已发布的模板(后端 /qc-report/template/selectable åˆ¤å®šâ€”—停用版本不清空 currentVersion,前端拿分页结果自己推导会漏),结果就地展示并跳本页列表);冻结 data_snapshot(判定后数据,含后端算好的 PASS/FAIL ä¸Žåˆæ ¼çŽ‡ï¼‰ä¸Ž render_html;regenerate åªé‡æ¸²æŽ’版、数据与编号不变;business_id/business_type å­˜ MES è¡¨åï¼ˆmes_qc_iqc ç­‰ï¼‰ä¸Žå•据 ID,可反查、一单可出多份;报告头另带单据原判定 qcResult(数)/qcResultText(中文,合格/特采/不合格退货/不合格报废),与引擎三态判定并存互不覆盖;响应带 undecidableCount=快照中 result ä¸ºç©ºä¸²çš„项数与 warnings(分组标题跳过/指标已删/未录实测值/样品超3),两者都不落库、事后看不到", "upstream": ["报告模板版本", "质检单上下文(通用 generate ç”±è°ƒç”¨æ–¹ä¼ å…¥)", "MES è´¨æ£€å• IQC/IPQC/OQC/RQC(generate-from-qc ç» MesQcReportApi å–æ•°)"], "downstream": ["前端实例页 /qc/report-instance(列表·iframe预览·快照页签·导出PDF)", "PDF归档(system_storage_attachment recordType=qc_report_instance)"], "keywords": "报告实例 å‡ºä»¶ QR编号 å¿«ç…§ å¤çް regenerate PDF归档 æŠ¥å‘Šå®žä¾‹é¡µ å¯¼å‡ºPDF generate-from-qc è´¨æ£€å•出报告 å‡ºæŠ¥å‘Šå¼¹çª— qcResult undecidableCount warnings å¾…判定 åæŸ¥ ä¸šåŠ¡å•æ®ç­›é€‰"}
  ]
}</script>
<script type="application/json" id="data-flows">{
@@ -514,13 +524,15 @@
       {"stage": "联动", "action": "审批通过按工序标记: å€’冲→自动物料消耗扣线边库(允许负库存);质检→产出待检行等 IPQC;关键非检→直接产出入库", "roles": ["系统自动"]},
       {"stage": "完工", "action": "仅末道工序回写工单已生产数量,达标自动完成;MPS ç´¯è®¡åˆ¤æ–­å®Œæˆå¹¶å‘事件给销售订单", "roles": ["系统自动"]}]},
    {"id": "quality", "name": "质量流程", "mermaid": "mermaid/05-quality-flow.mmd", "color": "#e06c6c",
     "summary": "四道检验闸口(IQC/IPQC/OQC/RQC,二态 0→4) + åˆ¤å®šå››æžœ(合格/特采/退货/报废) + NCR è¯„审处置关闭;AI æä¾›åˆ¤å®šå»ºè®®ï¼›NCR å½“前仅台账不触发返工单。",
     "summary": "四道检验闸口(IQC/IPQC/OQC/RQC,二态 0→4) + åˆ¤å®šå››æžœ(合格/特采/退货/报废) + NCR è¯„审处置关闭;AI æä¾›åˆ¤å®šå»ºè®®ï¼›NCR å½“前仅台账不触发返工单;检验单可由智能质检报告平台一键出件(generate-from-qc:数据从 MES å–,五道校验后渲染落库)。",
     "steps": [
       {"stage": "来料", "action": "到货通知/外协入库→IQC,结论回写推进待入库或触发退货", "roles": ["IQC è´¨æ£€å‘˜"]},
       {"stage": "过程", "action": "报工质检工序产出行→IPQC,合格拆行入库、不合格生成 NCR", "roles": ["IPQC è´¨æ£€å‘˜"]},
       {"stage": "出货", "action": "销售出库提交→OQC,完成后出库单进入待拣货", "roles": ["OQC è´¨æ£€å‘˜"]},
       {"stage": "退货", "action": "退料/销售退货/售后退货→RQC,结论回调单据与售后", "roles": ["RQC è´¨æ£€å‘˜"]},
       {"stage": "不合格", "action": "NCR: å¾…评审→评审→处置(退货/报废/返工/让步接收/降级)→关闭", "roles": ["质量工程师"]}]},
       {"stage": "不合格", "action": "NCR: å¾…评审→评审→处置(退货/报废/返工/让步接收/降级)→关闭", "roles": ["质量工程师"]},
       {"stage": "出件", "action": "两条出件入口:①通用 generate/preview(调用方自带上下文) æ¸²æŸ“ HTML å¹¶å†»ç»“数据快照,编号 QR+日期+4位流水按天独立,preview åªæ¸²æŸ“不落库不消耗编号,regenerate ç”¨å†»ç»“快照重渲且数据与编号不变并作废已归档 PDF,导出 PDF ç”±åŽç«¯è°ƒ Chromium æ‰“印实例已存的 HTML åŽå½’档附件中心;②generate-from-qc ç”±è´¨æ£€å•直接出件(数据经 MesQcReportApi ä»Ž MES å–,前端不拼上下文),出件前五道校验按业务优先级只报第一处:类型非法/单据不存在/未完成检验/未填判定/模板类型不符/无可用检验项,报告头另带单据原判定 qcResult+qcResultText ä¸Žå¼•擎判定并存,响应回 undecidableCount å¾…判定项数与 warnings(分组标题跳过/指标已删/未录实测值/样品超3),同一张单据可反复出多份", "roles": ["质检员"]},
       {"stage": "建模板", "action": "AI å¯¼å…¥ï¼šä¸Šä¼ æ—¢æœ‰æ£€éªŒæŠ¥å‘Šæ–‡ä»¶(扫描件/照片、电子版 PDF、Word/Excel) â†’ è¯†åˆ«æˆæ¨¡æ¿è‰ç¨¿(不落库) â†’ äººå·¥åœ¨è®¾è®¡å™¨ç¡®è®¤è°ƒæ•´åŽä¿å­˜ä¸ºç‰ˆæœ¬ï¼›.doc/.xls æ—§æ ¼å¼ä¸æ”¯æŒ", "roles": ["质检员", "质量工程师"]}]},
    {"id": "warehouse", "name": "仓储流程", "mermaid": "mermaid/06-warehouse-flow.mmd", "color": "#7e6bd6",
     "summary": "15 ç±»å•据驱动库存台账(六元组、四量)变动,全部经事务流水留痕;支持调拨在途、盘点调整、批次/SN è¿½æº¯ã€åº“存占用与四级冻结、线边虚拟库负库存;对外 WmsStockApi ä¾› ERP æ”¶å‘。",
     "steps": [
@@ -542,6 +554,7 @@
       {"stage": "录入提效", "action": "发票 OCR(图片多模态/PDF文本)、报价单 OCR â†’ ç»“构化预填", "roles": ["采购员", "销售"]},
       {"stage": "辅助判定", "action": "质检单+指标+缺陷 â†’ å»ºè®®åˆæ ¼/特采/退货/报废+理由", "roles": ["质检员"]},
       {"stage": "决策预测", "action": "缺料/生产时长/生产风险/交付四类预测供计划排产参考", "roles": ["计划员"]},
       {"stage": "模板识别", "action": "上传既有检验报告文件(扫描件/照片、电子版 PDF、Word/Excel) â†’ åˆ†é€šé“抽取(有文本层走文本、扫描版逐页转图) â†’ å¤§æ¨¡åž‹æŒ‰å‰ç«¯ä¸Šä¼ çš„组件清单产出扁平组件草稿 â†’ äººå·¥åœ¨è®¾è®¡å™¨ç¡®è®¤åŽä¿å­˜ä¸ºæ¨¡æ¿ç‰ˆæœ¬", "roles": ["质检员", "质量工程师"]},
       {"stage": "知识检索", "action": "文档→切片→Milvus å‘量→检索接口(预留)", "roles": ["—"]}]}
  ]
}</script>
@@ -627,6 +640,13 @@
      RQC é€€è´§æ£€éªŒ
      NCR ä¸åˆæ ¼å“
      è´¨æ£€æ–¹æ¡ˆä¸ŽæŒ‡æ ‡ç¼ºé™·
    æ™ºèƒ½è´¨æ£€æŠ¥å‘Š QC-REPORT
      æŠ¥å‘Šæ¨¡æ¿ä¸Žå¤šç‰ˆæœ¬
      å¯è§†åŒ–设计器
      ç»„件绑定与判定规则
      HTML æ¸²æŸ“引擎
      æŠ¥å‘Šå‡ºä»¶ä¸Žç¼–号
      æ•°æ®å¿«ç…§å†»ç»“
    è®¾å¤‡ä¸ŽæŽ’班
      è®¾å¤‡å°è´¦
      ç‚¹æ£€ä¸Žä¿å…»
@@ -749,6 +769,69 @@
  N2 -->|处置方式:退货1报废2返工3让步接收4降级使用5| N3[已处置7]
  N3 -->|关闭| N4[已关闭8]
  Q8[支撑数据:质检方案·检验指标·缺陷库·指标结果] --- A1
  Q8H["检验指标的层级可在质检指标页维护·条目类型 æ‰€å±žåˆ†ç»„ å…¬å¼ æŽ’序号·只允许两层·删分组级联删子项"] --- Q8
  A1 -->|出件 generate-from-qc| GATE{出件六道校验·按业务优先级只报第一处}
  A2 -->|出件 generate-from-qc| GATE
  A3 -->|出件 generate-from-qc| GATE
  A4 -->|出件 generate-from-qc| GATE
  GATE -->|000 ç±»åž‹éžæ³•| GE0[105_000 qcType ä¸åœ¨ 1~4]
  GATE -->|001 å•据不存在| GE1[105_001]
  GATE -->|002 æœªå®Œæˆæ£€éªŒ| GE2[105_002·带当前状态中文名]
  GATE -->|003 æœªå¡«åˆ¤å®š| GE3[105_003·列出四个可选判定]
  GATE -->|005 æ¨¡æ¿ç±»åž‹ä¸ç¬¦| GE5[105_005·两边类型中文名都给·前端按 reportType é¢„过滤]
  GATE -->|006 æ— å¯ç”¨æ£€éªŒé¡¹| GE6[105_006]
  GATE -->|通过·只允许同类型已发布模板| RP[智能质检报告平台 qcreport]
  RP --> RPT[报告模板 qc_report_template·启用0停用1]
  RPT --> RPTV[模板版本 qc_report_template_version·草稿0已发布1已停用2·当前版本不可停用 101_010]
  RPTVSAVE[设计器保存 create æˆ– update] -->|写入侧闸门·规则101_006 ä¸Žç”»å¸ƒå®‰å…¨101_009 ä¸åˆæ ¼ä¸€å¾‹ä¸è½åº“| RPTV
  RPTVSAVE -.->|只卡新写入·存量脏画布不回溯核查| CVNOTE[画布校验覆盖三处注入面·tagName å±žæ€§å æ•´æ®µCSS]
  RPTV -->|按已发布版本渲染| RPI[报告实例 qc_report_instance·编号 QR+yyyyMMdd+4位流水·按天独立]
  RPI --> SNAP[冻结 data_snapshot·判定后数据含 PASS FAIL ä¸Žåˆæ ¼çއ]
  RPI --> RHTML[render_html å®Œæ•´ HTML æ–‡æ¡£Â·å‡ºç«™å·²å‰¥ç¦»äº‹ä»¶å±žæ€§ä¸Žå±é™©åè®®]
  RPI --> RREGEN[重新生成 regenerate·冻结快照+同一模板版本重渲·数据与编号不变·并作废已归档PDF]
  RPI --> RPDF[导出PDF åŽç«¯è°ƒChromium打印已存HTML·归档 system_storage_attachment·recordType=qc_report_instance]
  RPI --> RUI[报告实例页 /qc/report-instance·菜单挂质量管理 parent_id=5500]
  RUI -->|权限码| RPERM["qc-report:instance:query æŸ¥è¯¢Â·:export å¯¼å‡ºPDF·:delete åˆ é™¤Â·:create é€šç”¨å‡ºä»¶Â·:generate è´¨æ£€å•出件(menu 1075416)"]
  RUI -->|反查 businessType=mes_qc_iqc ç­‰è¡¨å + businessId| RBACK[同一张质检单可出多份报告·历史留痕需要·不做重复拦截]
  RUI --> RPREVIEW[详情弹窗·iframe sandbox空属性渲染 render_html·数据快照页签展示冻结 data_snapshot]
  A1 -.->|前端入口·行操作「出报告」仅 status=已完成可见| UIREP[出报告弹窗·IQC IPQC OQC RQC å››é¡µå…±ç”¨]
  A2 -.->|同上| UIREP
  A3 -.->|同上| UIREP
  A4 -.->|同上| UIREP
  UIREP -->|只列 reportType ä¸Žæœ¬å•一致·启用·当前版本已发布且画布非空·避免选中必被拒的组合| UIPICK[用户选模板]
  UIPICK -->|POST generate-from-qc| GATE
  UIREP -->|结果就地展示 reportNo itemCount| UIRES["undecidableCount>0 è“è‰²ä¿¡æ¯æ¡ï¼ˆå¤šä¸ºè¿‡ç¨‹å‚数无判定规则·黄色会几乎每单误报)·warnings ä¸Ž errors é€æ¡å±•示·不落库事后看不到"]
  UIRES -->|查看报告列表| RUI
  RUI -->|路由 query å¸¦å…¥ businessType+businessId å¹¶é¢„填搜索| RBACK
  GATE -->|MesQcReportApi.getQcReportData| MESMAP[MES å››å±‚取数归一·单头 / åˆ¤å®šä¾æ®è¡Œ max min threshold / æ ·å“ indicator_result / å®žæµ‹å€¼ result_detail]
  MESMAP --> RCTX[报告上下文 report+inspectionItems·由后端映射器产出·前端不再自己拼]
  RCTX --> RCITEM[检验项=样品×指标交叉展开·全指标进不过滤·多样品时样品号折进 remark]
  RCTX --> RCQC[携带单据原判定 qcResult æ•°Â·qcResultText ä¸­æ–‡Â·ä¸Žå¼•擎判定并存互不覆盖]
  MESMAP -->|分组项 item_type=2 ä¸Žå®ƒçš„子项按 parent_id è¿˜åŽŸä¸¤å±‚Â·å½’å±žä¸çœ‹ type ä¹Ÿä¸çœ‹è¡Œåº| RCTXG
  RCTXG["分组还原·组头行背组名与 formula_text å…¬å¼Â·ç»„内第一行背 span=该组总行数·同组其余行 span=1 ä¸” hidden è®©ä½Â·åªåˆå¹¶æ£€éªŒé¡¹ç›®ä¸€åˆ—"] --- RCTX
  MESMAP -.->|分组项本单没有属于它的子项 / ç»„头本单没有行由后端补一行| RCWARN
  MESMAP -.->|指标已删 / æœªå½•实测值(组头锚点行按定义无值·不报) / æ ·å“è¶… 3 ä¸ªÂ·è¿› warnings| RCWARN
  RCWARN[warnings è½¯æç¤ºÂ·ä¸è½åº“·事后看不到需自行留痕] --- RCTX
  RCITEM --- RCTX
  RCQC --- RCTX
  RCTX --> RPI
  RCTX --> RUNDEC["undecidableCount æ²¡æœ‰åˆ¤å®šç»“论的项数=快照里 result ä¸ºç©ºä¸²çš„项·含无判定规则与待判定两种·不计入合格率分母与报告结论·生成时弹提示不回填"]
  RPT -->|进入设计器并带 templateId·权限 qc-report:template:ai-import| AIIMP[AI导入 qc-report/ai-import/draft]
  AISRC["源文件 æ‰«æä»¶ç…§ç‰‡Â·ç”µå­ç‰ˆPDF·Word(.docx) Excel(.xlsx)·txt csv"] -->|上传走 system Storage API å– blobId·绑为 qc_report_template é™„ä»¶| AIIMP
  AISRC -.->|同一模板重复导入按替换处理·先删旧附件再绑新的·连同 blob è¡Œä¸Žç£ç›˜æ–‡ä»¶ä¸€èµ·æ¸…| AIIMP
  AISRC -.->|docx çš„ Word é¡µçœ‰é¡µè„šè¯»å–并以 é¡µçœ‰ é¡µè„š æ ‡è®°å¹¶å…¥è¯†åˆ«å†…容·页眉在正文前页脚在正文后·同时进 warnings·扫描件照片不受影响| AIIMP
  AIIMP -->|识别·只产草稿不落库| AIRD[模板草稿 æ‰å¹³ç»„ä»¶+属性清单]
  AIHINT[积木清单每项带 hint é€‰åž‹è¯´æ˜ŽÂ·æ¥è‡ªå‰ç«¯æ³¨å†Œè¡¨ aiHint·组件选型的唯一真相来源] -.->|选型以 hint ä¸ºå‡†Â·æœ‰ä¸“门组件就不退用通用组件凑数| AIIMP
  AIIMP -->|表格末行的落款签署行属落款不属数据行·与原件页脚合并且人名日期留空手填·不得自造绑定键| AIRD
  AIIMP -.->|原件列比默认多或表头分两层或样品字段不同时·写进组件的 columns headerSpans fields ä¸‰ä¸ªæ–‡æœ¬å±žæ€§è¦†ç›–·不换组件| AIRD
  AIKEY[报告可绑定键白名单·由后端 ReportFields åå°„生成·不手写] -.->|只禁编造不给可选集合模型只能猜·故全量枚举| AIIMP
  AIRD -->|人工在设计器确认·装配器编译进画布后保存| RPTV
  AIRD -.->|画布上的 ReportHeader ReportFooter æ˜¯æ­£æ–‡é‡Œçš„æŠ¬å¤´è½æ¬¾Â·ä¸Ž PDF æ¯é¡µé‡å¤çš„版式页眉页脚不是一回事| RPTV
  AIIMP -.->|表头某列自己既是列名又是上格·原件里是一格纵向合并跨住上下两层、下层那格在提取文本里写着↑同上| AIRD
  AIIMP -.->|该段标题与列名一字不差才认成纵向合并格、出 rowspan=2 ä¸€æ ¼ä¸”下层跳过该列·不一致就被当成另一层分组标题、结论二字上下各印一遍| AIRD
  RPTVSAVE -.->|设计器画布不跑绑定·组名格的 hidden å ä½ç¬¦ä¼šè¢«æµè§ˆå™¨å½“成真 hidden è®©æ•´è¡Œå·¦ç§»ã€æœ«åˆ—挤成竖排细条| CVNOTE
  RPTVSAVE -.->|自定列不带宽度声明·列宽只能由最小内容宽度倒推·而值是占位符这类不含空格的长串·整表会被撑出纸张、末列挤成竖条| CVWIDTH
  CVWIDTH["故自定列的单元格带 overflow-wrap:anywhere å…è®¸é•¿ä¸²æŠ˜è¡ŒÂ·é»˜è®¤åˆ—有手工宽度声明不加·实测加了反而把检验项目子项挤到 30 ä½™åƒç´ ã€è¡Œé«˜ç¿»å‡ å€"] --- RPTVSAVE
</pre>
<pre style="display:none" class="mmd-src" data-mmd="06-warehouse-flow">
flowchart LR
@@ -824,6 +907,8 @@
  AI[AI å¹³å° é€šä¹‰åƒé—®+Milvus] -.->|发票OCR| ERP
  AI -.->|报价OCR| CRM
  AI -.->|质检判定与生产预测| MES
  MES -.->|质检单上下文·本轮由调用方传入 å¾… MesQcReportApi æ‰“通| QCR[智能质检报告 qcreport æ¨¡æ¿ç‰ˆæœ¬å®žä¾‹å¿«ç…§]
  QCR -.->|PDF å½’档·后续轮次| STG
  MES --> BI[BI ç®¡ç†é©¾é©¶èˆ± SQL配置大屏]
  ERP --> BI
  CRM --> BI
@@ -872,6 +957,39 @@
  IV -->|回款强制先开票| RK
  RC -->|关联CRM客户校验| CU
  PA -->|关联来票与供应商| PI
  subgraph S4 [智能质检报告 qcreport]
    RPT[报告模板] --> RPTV[模板版本]
    RPTVSAVE[设计器保存 create æˆ– update] -->|写入侧闸门 è§„则101_006 ç”»å¸ƒå®‰å…¨101_009 ä¸åˆæ ¼ä¸è½åº“| RPTV
    RPTVSAVE -.->|只卡新写入 å­˜é‡è„ç”»å¸ƒä¸å›žæº¯æ ¸æŸ¥| CVNOTE[画布校验覆盖 tagName å±žæ€§å æ•´æ®µCSS ä¸‰å¤„注入面]
    RPTV --> RPI[报告实例]
    RPI --> SNAP[数据快照冻结]
    RPI --> RHTML[HTML æ¸²æŸ“产物]
    RPI --> RPDF[PDF äº§ç‰©]
    AISRC[源文件 æ‰«æä»¶ç…§ç‰‡Â·ç”µå­ç‰ˆPDF·Word Excel·txt csv] -->|上传走 system Storage API å– blobId å¹¶ç»‘为模板附件| AIRD[AI è¯†åˆ«è‰ç¨¿]
    AIRD -->|人工在设计器确认后保存| RPTV
  end
  QCDOC[质检单 IQC IPQC OQC RQC] -->|generate-from-qc·带 qcType qcId templateId| QCGATE{五道校验 ç±»åž‹ å­˜åœ¨ å·²å®Œæˆ å·²åˆ¤å®š æ¨¡æ¿ç±»åž‹ æœ‰æ£€éªŒé¡¹}
  QCGATE -->|任一不过·按业务优先级只报第一处| QCERR[105_000~105_006]
  QCGATE -->|通过| MESQCAPI[MesQcReportApi å››å±‚取数·单头 åˆ¤å®šä¾æ®è¡Œ æ ·å“ å®žæµ‹å€¼]
  MESQCAPI -->|纯函数映射器归一 å«åŽŸåˆ¤å®š qcResult qcResultText| RPI
  MESQCAPI -.->|分组标题跳过 æŒ‡æ ‡å·²åˆ  æœªå½•实测值 æ ·å“è¶…3| QCWARN[warnings è½¯æç¤ºÂ·ä¸è½åº“]
  QCWARN -.-> RPI
  RPI -.->|undecidableCount=快照 result ä¸ºç©ºä¸²çš„项·生成时弹提示| QCFE
  RPI -.->|businessType=mes_qc_iqc ç­‰è¡¨å + businessId åæŸ¥Â·ä¸€å•可出多份| QCFE
  RPT -.->|report_type æ•°å­—字符串须等于 qcType·前端据此过滤模板列表| QCGATE
  RPTV -->|模板停用后不再出新件·历史实例不受影响·当前版本不可停用 101_010| RPI
  RPT -->|进入设计器带 templateId è°ƒ AI å¯¼å…¥| AIRD
  RPI -->|列表 è¯¦æƒ… é¢„览 æ‰“印| QCFE["前端报告页面 /qc/report-instance å·²è½åœ°Â·iframe æ¸²æŸ“ render_html + å¿«ç…§é¡µç­¾ + å¯¼å‡ºPDF"]
  RPDF -->|归档 é™„件中心 recordType=qc_report_instance| PDFARCH[system_storage_attachment]
  AISRC -.->|归属 é™„件中心 recordType=qc_report_template| PDFARCH
  AISRC -.->|同一模板重复导入按替换处理·先删旧附件·连同 blob è¡Œä¸Žç£ç›˜æ–‡ä»¶ä¸€èµ·æ¸…| PDFARCH
  QCFE -.->|权限码 qc-report:instance:query/export/delete/create å·²å…¥ system_menu| SYS1[system_menu]
  QCGATE -.->|权限码 qc-report:instance:generate å·²å…¥ system_menu id=1075416| SYS1[system_menu]
  QCDOC -.->|前端入口 è¡Œæ“ä½œã€Œå‡ºæŠ¥å‘Šã€ä»… status=已完成可见·四页共用同一弹窗| UIREP[出报告弹窗·只列同类型 å¯ç”¨ ä¸”有生效版本的模板]
  UIREP -->|选模板后 POST generate-from-qc| QCGATE
  UIREP -.->|结果就地展示 reportNo itemCount ä¸Ž undecidableCount å‘Šè­¦| QCFE
  QCFE -.->|进入时带 businessType+businessId è·¯ç”± query·预填搜索表单的「业务单据」字段| UIREP
  RPT -.->|权限码 qc-report:template:ai-import å·²å…¥ system_menu| SYS1
</pre>
<pre style="display:none" class="mmd-src" data-mmd="10-business-object">
erDiagram
@@ -910,6 +1028,8 @@
  erp_purchase_order ||--o{ erp_purchase_invoice : "来票"
  erp_purchase_invoice ||--o{ erp_finance_payment : "付款"
  hrm_employee ||--o| hrm_user_handover : "离职交接映射"
  qc_report_template ||--o{ qc_report_template_version : "多版本"
  qc_report_template_version ||--o{ qc_report_instance : "冻结出件版本"
</pre>
<pre style="display:none" class="mmd-src" data-mmd="11-ai-business-flow">
flowchart TD
@@ -931,6 +1051,23 @@
  R3[工序标准工时+设备每小时产量+传送倍率] --> R4[生产时长预测]
  R5[IPQC缺陷率+设备保养点检+物料可用率+关键工序] --> R6[生产风险预测]
  R7[进度+剩余天数+任务完成状态+客户交期] --> R8[交付预测·能否按时+置信度+延误因素]
  W1[质检员上传既有检验报告文件 blobId] --> W2{扩展名与内容判定}
  W2 -->|png/jpg/bmp/gif/webp| W3[原字节走 chatWithImage]
  W2 -->|PDF æœ‰æ–‡æœ¬å±‚| W4[PDFBox æŠ½å–文本 â†’ chat]
  W2 -->|PDF æ‰«æç‰ˆ| W5[PDFRenderer é€é¡µè½¬PNG â†’ æ¯é¡µ chatWithImage]
  W2 -->|docx/xlsx| W6["POI æŠ½å–·表格归一成稳定网格(gridSpan è¡¥ç©ºå ä½ / vMerge ç»­æ ¼æ ‡ â†‘同上)"]
  W2 -->|txt/csv| W7[直读文本 â†’ chat]
  W2 -->|doc/xls| W8[拒绝·提示另存为 docx/xlsx]
  W3 --> W9[多页逐页识别]
  W4 --> W9
  W5 --> W9
  W6 --> W9
  W7 --> W9
  W9 --> W10["按(type,props)去重合并→扁平组件草稿+summary+warnings"]
  W10 --> W11[弹窗预览·人工确认后装进设计器画布]
  W11 --> W12[人工调整→保存为模板版本·这是唯一落库点]
  WCL[组件积木清单由前端从活注册表生成并随请求上传] --- W9
  WSAFE[只产草稿不落库·未注册type在装配阶段被跳过·永远产不出任意HTML] --- W10
  KB1[知识库文档上传URL] --> KB2[POI/Tika提取文本] --> KB3[切片约800tokens→向量化→Milvus]
  KB3 --> KB4[分段检索接口 topK+相似度阈值]
  KB4 -.-> KB5[暂未接入业务问答流程·待确认]
docs/project-business/mermaid/01-business-mindmap.mmd
@@ -55,6 +55,13 @@
      RQC é€€è´§æ£€éªŒ
      NCR ä¸åˆæ ¼å“
      è´¨æ£€æ–¹æ¡ˆä¸ŽæŒ‡æ ‡ç¼ºé™·
    æ™ºèƒ½è´¨æ£€æŠ¥å‘Š QC-REPORT
      æŠ¥å‘Šæ¨¡æ¿ä¸Žå¤šç‰ˆæœ¬
      å¯è§†åŒ–设计器
      ç»„件绑定与判定规则
      HTML æ¸²æŸ“引擎
      æŠ¥å‘Šå‡ºä»¶ä¸Žç¼–号
      æ•°æ®å¿«ç…§å†»ç»“
    è®¾å¤‡ä¸ŽæŽ’班
      è®¾å¤‡å°è´¦
      ç‚¹æ£€ä¸Žä¿å…»
docs/project-business/mermaid/05-quality-flow.mmd
@@ -18,3 +18,66 @@
  N2 -->|处置方式:退货1报废2返工3让步接收4降级使用5| N3[已处置7]
  N3 -->|关闭| N4[已关闭8]
  Q8[支撑数据:质检方案·检验指标·缺陷库·指标结果] --- A1
  Q8H["检验指标的层级可在质检指标页维护·条目类型 æ‰€å±žåˆ†ç»„ å…¬å¼ æŽ’序号·只允许两层·删分组级联删子项"] --- Q8
  A1 -->|出件 generate-from-qc| GATE{出件六道校验·按业务优先级只报第一处}
  A2 -->|出件 generate-from-qc| GATE
  A3 -->|出件 generate-from-qc| GATE
  A4 -->|出件 generate-from-qc| GATE
  GATE -->|000 ç±»åž‹éžæ³•| GE0[105_000 qcType ä¸åœ¨ 1~4]
  GATE -->|001 å•据不存在| GE1[105_001]
  GATE -->|002 æœªå®Œæˆæ£€éªŒ| GE2[105_002·带当前状态中文名]
  GATE -->|003 æœªå¡«åˆ¤å®š| GE3[105_003·列出四个可选判定]
  GATE -->|005 æ¨¡æ¿ç±»åž‹ä¸ç¬¦| GE5[105_005·两边类型中文名都给·前端按 reportType é¢„过滤]
  GATE -->|006 æ— å¯ç”¨æ£€éªŒé¡¹| GE6[105_006]
  GATE -->|通过·只允许同类型已发布模板| RP[智能质检报告平台 qcreport]
  RP --> RPT[报告模板 qc_report_template·启用0停用1]
  RPT --> RPTV[模板版本 qc_report_template_version·草稿0已发布1已停用2·当前版本不可停用 101_010]
  RPTVSAVE[设计器保存 create æˆ– update] -->|写入侧闸门·规则101_006 ä¸Žç”»å¸ƒå®‰å…¨101_009 ä¸åˆæ ¼ä¸€å¾‹ä¸è½åº“| RPTV
  RPTVSAVE -.->|只卡新写入·存量脏画布不回溯核查| CVNOTE[画布校验覆盖三处注入面·tagName å±žæ€§å æ•´æ®µCSS]
  RPTV -->|按已发布版本渲染| RPI[报告实例 qc_report_instance·编号 QR+yyyyMMdd+4位流水·按天独立]
  RPI --> SNAP[冻结 data_snapshot·判定后数据含 PASS FAIL ä¸Žåˆæ ¼çއ]
  RPI --> RHTML[render_html å®Œæ•´ HTML æ–‡æ¡£Â·å‡ºç«™å·²å‰¥ç¦»äº‹ä»¶å±žæ€§ä¸Žå±é™©åè®®]
  RPI --> RREGEN[重新生成 regenerate·冻结快照+同一模板版本重渲·数据与编号不变·并作废已归档PDF]
  RPI --> RPDF[导出PDF åŽç«¯è°ƒChromium打印已存HTML·归档 system_storage_attachment·recordType=qc_report_instance]
  RPI --> RUI[报告实例页 /qc/report-instance·菜单挂质量管理 parent_id=5500]
  RUI -->|权限码| RPERM["qc-report:instance:query æŸ¥è¯¢Â·:export å¯¼å‡ºPDF·:delete åˆ é™¤Â·:create é€šç”¨å‡ºä»¶Â·:generate è´¨æ£€å•出件(menu 1075416)"]
  RUI -->|反查 businessType=mes_qc_iqc ç­‰è¡¨å + businessId| RBACK[同一张质检单可出多份报告·历史留痕需要·不做重复拦截]
  RUI --> RPREVIEW[详情弹窗·iframe sandbox空属性渲染 render_html·数据快照页签展示冻结 data_snapshot]
  A1 -.->|前端入口·行操作「出报告」仅 status=已完成可见| UIREP[出报告弹窗·IQC IPQC OQC RQC å››é¡µå…±ç”¨]
  A2 -.->|同上| UIREP
  A3 -.->|同上| UIREP
  A4 -.->|同上| UIREP
  UIREP -->|只列 reportType ä¸Žæœ¬å•一致·启用·当前版本已发布且画布非空·避免选中必被拒的组合| UIPICK[用户选模板]
  UIPICK -->|POST generate-from-qc| GATE
  UIREP -->|结果就地展示 reportNo itemCount| UIRES["undecidableCount>0 è“è‰²ä¿¡æ¯æ¡ï¼ˆå¤šä¸ºè¿‡ç¨‹å‚数无判定规则·黄色会几乎每单误报)·warnings ä¸Ž errors é€æ¡å±•示·不落库事后看不到"]
  UIRES -->|查看报告列表| RUI
  RUI -->|路由 query å¸¦å…¥ businessType+businessId å¹¶é¢„填搜索| RBACK
  GATE -->|MesQcReportApi.getQcReportData| MESMAP[MES å››å±‚取数归一·单头 / åˆ¤å®šä¾æ®è¡Œ max min threshold / æ ·å“ indicator_result / å®žæµ‹å€¼ result_detail]
  MESMAP --> RCTX[报告上下文 report+inspectionItems·由后端映射器产出·前端不再自己拼]
  RCTX --> RCITEM[检验项=样品×指标交叉展开·全指标进不过滤·多样品时样品号折进 remark]
  RCTX --> RCQC[携带单据原判定 qcResult æ•°Â·qcResultText ä¸­æ–‡Â·ä¸Žå¼•擎判定并存互不覆盖]
  MESMAP -->|分组项 item_type=2 ä¸Žå®ƒçš„子项按 parent_id è¿˜åŽŸä¸¤å±‚Â·å½’å±žä¸çœ‹ type ä¹Ÿä¸çœ‹è¡Œåº| RCTXG
  RCTXG["分组还原·组头行背组名与 formula_text å…¬å¼Â·ç»„内第一行背 span=该组总行数·同组其余行 span=1 ä¸” hidden è®©ä½Â·åˆå¹¶çš„æ˜¯æ¨¡æ¿ columns é‡Œå¸¦ # çš„那一列(默认列即检验项目)·合并行数是本单该组实际行数而非主数据子项数·该组本单无子项行则不合并"] --- RCTX
  MESMAP -.->|分组项本单没有属于它的子项 / ç»„头本单没有行由后端补一行| RCWARN
  MESMAP -.->|指标已删 / æœªå½•实测值(组头锚点行按定义无值·不报) / æ ·å“è¶… 3 ä¸ªÂ·è¿› warnings| RCWARN
  RCWARN[warnings è½¯æç¤ºÂ·ä¸è½åº“·事后看不到需自行留痕] --- RCTX
  RCITEM --- RCTX
  RCQC --- RCTX
  RCTX --> RPI
  RCTX --> RUNDEC["undecidableCount æ²¡æœ‰åˆ¤å®šç»“论的项数=快照里 result ä¸ºç©ºä¸²çš„项·含无判定规则与待判定两种·不计入合格率分母与报告结论·生成时弹提示不回填"]
  RPT -->|进入设计器并带 templateId·权限 qc-report:template:ai-import| AIIMP[AI导入 qc-report/ai-import/draft]
  AISRC["源文件 æ‰«æä»¶ç…§ç‰‡Â·ç”µå­ç‰ˆPDF·Word(.docx) Excel(.xlsx)·txt csv"] -->|上传走 system Storage API å– blobId·绑为 qc_report_template é™„ä»¶| AIIMP
  AISRC -.->|同一模板重复导入按替换处理·先删旧附件再绑新的·连同 blob è¡Œä¸Žç£ç›˜æ–‡ä»¶ä¸€èµ·æ¸…| AIIMP
  AISRC -.->|docx çš„ Word é¡µçœ‰é¡µè„šè¯»å–并以 é¡µçœ‰ é¡µè„š æ ‡è®°å¹¶å…¥è¯†åˆ«å†…容·页眉在正文前页脚在正文后·同时进 warnings·扫描件照片不受影响| AIIMP
  AIIMP -->|识别·只产草稿不落库| AIRD[模板草稿 æ‰å¹³ç»„ä»¶+属性清单]
  AIHINT[积木清单每项带 hint é€‰åž‹è¯´æ˜ŽÂ·æ¥è‡ªå‰ç«¯æ³¨å†Œè¡¨ aiHint·组件选型的唯一真相来源] -.->|选型以 hint ä¸ºå‡†Â·æœ‰ä¸“门组件就不退用通用组件凑数| AIIMP
  AIIMP -->|表格末行的落款签署行属落款不属数据行·与原件页脚合并且人名日期留空手填·不得自造绑定键| AIRD
  AIIMP -.->|原件列比默认多或表头分两层或样品字段不同时·写进组件的 columns headerSpans fields ä¸‰ä¸ªæ–‡æœ¬å±žæ€§è¦†ç›–·不换组件| AIRD
  AIKEY[报告可绑定键白名单·由后端 ReportFields åå°„生成·不手写] -.->|只禁编造不给可选集合模型只能猜·故全量枚举| AIIMP
  AIRD -->|人工在设计器确认·装配器编译进画布后保存| RPTV
  AIRD -.->|画布上的 ReportHeader ReportFooter æ˜¯æ­£æ–‡é‡Œçš„æŠ¬å¤´è½æ¬¾Â·ä¸Ž PDF æ¯é¡µé‡å¤çš„版式页眉页脚不是一回事| RPTV
  AIIMP -.->|表头某列自己既是列名又是上格·原件里是一格纵向合并跨住上下两层、下层那格在提取文本里写着↑同上| AIRD
  AIIMP -.->|该段标题与列名一字不差才认成纵向合并格、出 rowspan=2 ä¸€æ ¼ä¸”下层跳过该列·不一致就被当成另一层分组标题、结论二字上下各印一遍| AIRD
  RPTVSAVE -.->|设计器画布不跑绑定·组名格的 hidden å ä½ç¬¦ä¼šè¢«æµè§ˆå™¨å½“成真 hidden è®©æ•´è¡Œå·¦ç§»ã€æœ«åˆ—挤成竖排细条| CVNOTE
  RPTVSAVE -.->|自定列不带宽度声明·列宽只能由最小内容宽度倒推·而值是占位符这类不含空格的长串·整表会被撑出纸张、末列挤成竖条| CVWIDTH
  CVWIDTH["故自定列的单元格带 overflow-wrap:anywhere å…è®¸é•¿ä¸²æŠ˜è¡ŒÂ·é»˜è®¤åˆ—有手工宽度声明不加·实测加了反而把检验项目子项挤到 30 ä½™åƒç´ ã€è¡Œé«˜ç¿»å‡ å€"] --- RPTVSAVE
docs/project-business/mermaid/08-module-relation.mmd
@@ -19,6 +19,8 @@
  AI[AI å¹³å° é€šä¹‰åƒé—®+Milvus] -.->|发票OCR| ERP
  AI -.->|报价OCR| CRM
  AI -.->|质检判定与生产预测| MES
  MES -.->|质检单上下文·本轮由调用方传入 å¾… MesQcReportApi æ‰“通| QCR[智能质检报告 qcreport æ¨¡æ¿ç‰ˆæœ¬å®žä¾‹å¿«ç…§]
  QCR -.->|PDF å½’档·后续轮次| STG
  MES --> BI[BI ç®¡ç†é©¾é©¶èˆ± SQL配置大屏]
  ERP --> BI
  CRM --> BI
docs/project-business/mermaid/09-data-flow.mmd
@@ -37,3 +37,36 @@
  IV -->|回款强制先开票| RK
  RC -->|关联CRM客户校验| CU
  PA -->|关联来票与供应商| PI
  subgraph S4 [智能质检报告 qcreport]
    RPT[报告模板] --> RPTV[模板版本]
    RPTVSAVE[设计器保存 create æˆ– update] -->|写入侧闸门 è§„则101_006 ç”»å¸ƒå®‰å…¨101_009 ä¸åˆæ ¼ä¸è½åº“| RPTV
    RPTVSAVE -.->|只卡新写入 å­˜é‡è„ç”»å¸ƒä¸å›žæº¯æ ¸æŸ¥| CVNOTE[画布校验覆盖 tagName å±žæ€§å æ•´æ®µCSS ä¸‰å¤„注入面]
    RPTV --> RPI[报告实例]
    RPI --> SNAP[数据快照冻结]
    RPI --> RHTML[HTML æ¸²æŸ“产物]
    RPI --> RPDF[PDF äº§ç‰©]
    AISRC[源文件 æ‰«æä»¶ç…§ç‰‡Â·ç”µå­ç‰ˆPDF·Word Excel·txt csv] -->|上传走 system Storage API å– blobId å¹¶ç»‘为模板附件| AIRD[AI è¯†åˆ«è‰ç¨¿]
    AIRD -->|人工在设计器确认后保存| RPTV
  end
  QCDOC[质检单 IQC IPQC OQC RQC] -->|generate-from-qc·带 qcType qcId templateId| QCGATE{五道校验 ç±»åž‹ å­˜åœ¨ å·²å®Œæˆ å·²åˆ¤å®š æ¨¡æ¿ç±»åž‹ æœ‰æ£€éªŒé¡¹}
  QCGATE -->|任一不过·按业务优先级只报第一处| QCERR[105_000~105_006]
  QCGATE -->|通过| MESQCAPI[MesQcReportApi å››å±‚取数·单头 åˆ¤å®šä¾æ®è¡Œ æ ·å“ å®žæµ‹å€¼]
  MESQCAPI -->|纯函数映射器归一 å«åŽŸåˆ¤å®š qcResult qcResultText| RPI
  MESQCAPI -.->|分组标题跳过 æŒ‡æ ‡å·²åˆ  æœªå½•实测值 æ ·å“è¶…3| QCWARN[warnings è½¯æç¤ºÂ·ä¸è½åº“]
  QCWARN -.-> RPI
  RPI -.->|undecidableCount=快照 result ä¸ºç©ºä¸²çš„项·生成时弹提示| QCFE
  RPI -.->|businessType=mes_qc_iqc ç­‰è¡¨å + businessId åæŸ¥Â·ä¸€å•可出多份| QCFE
  RPT -.->|report_type æ•°å­—字符串须等于 qcType·前端据此过滤模板列表| QCGATE
  RPTV -->|模板停用后不再出新件·历史实例不受影响·当前版本不可停用 101_010| RPI
  RPT -->|进入设计器带 templateId è°ƒ AI å¯¼å…¥| AIRD
  RPI -->|列表 è¯¦æƒ… é¢„览 æ‰“印| QCFE["前端报告页面 /qc/report-instance å·²è½åœ°Â·iframe æ¸²æŸ“ render_html + å¿«ç…§é¡µç­¾ + å¯¼å‡ºPDF"]
  RPDF -->|归档 é™„件中心 recordType=qc_report_instance| PDFARCH[system_storage_attachment]
  AISRC -.->|归属 é™„件中心 recordType=qc_report_template| PDFARCH
  AISRC -.->|同一模板重复导入按替换处理·先删旧附件·连同 blob è¡Œä¸Žç£ç›˜æ–‡ä»¶ä¸€èµ·æ¸…| PDFARCH
  QCFE -.->|权限码 qc-report:instance:query/export/delete/create å·²å…¥ system_menu| SYS1[system_menu]
  QCGATE -.->|权限码 qc-report:instance:generate å·²å…¥ system_menu id=1075416| SYS1[system_menu]
  QCDOC -.->|前端入口 è¡Œæ“ä½œã€Œå‡ºæŠ¥å‘Šã€ä»… status=已完成可见·四页共用同一弹窗| UIREP[出报告弹窗·只列同类型 å¯ç”¨ ä¸”有生效版本的模板]
  UIREP -->|选模板后 POST generate-from-qc| QCGATE
  UIREP -.->|结果就地展示 reportNo itemCount ä¸Ž undecidableCount å‘Šè­¦| QCFE
  QCFE -.->|进入时带 businessType+businessId è·¯ç”± query·预填搜索表单的「业务单据」字段| UIREP
  RPT -.->|权限码 qc-report:template:ai-import å·²å…¥ system_menu| SYS1
docs/project-business/mermaid/10-business-object.mmd
@@ -34,3 +34,5 @@
  erp_purchase_order ||--o{ erp_purchase_invoice : "来票"
  erp_purchase_invoice ||--o{ erp_finance_payment : "付款"
  hrm_employee ||--o| hrm_user_handover : "离职交接映射"
  qc_report_template ||--o{ qc_report_template_version : "多版本"
  qc_report_template_version ||--o{ qc_report_instance : "冻结出件版本"
docs/project-business/mermaid/11-ai-business-flow.mmd
@@ -17,6 +17,23 @@
  R3[工序标准工时+设备每小时产量+传送倍率] --> R4[生产时长预测]
  R5[IPQC缺陷率+设备保养点检+物料可用率+关键工序] --> R6[生产风险预测]
  R7[进度+剩余天数+任务完成状态+客户交期] --> R8[交付预测·能否按时+置信度+延误因素]
  W1[质检员上传既有检验报告文件 blobId] --> W2{扩展名与内容判定}
  W2 -->|png/jpg/bmp/gif/webp| W3[原字节走 chatWithImage]
  W2 -->|PDF æœ‰æ–‡æœ¬å±‚| W4[PDFBox æŠ½å–文本 â†’ chat]
  W2 -->|PDF æ‰«æç‰ˆ| W5[PDFRenderer é€é¡µè½¬PNG â†’ æ¯é¡µ chatWithImage]
  W2 -->|docx/xlsx| W6["POI æŠ½å–·表格归一成稳定网格(gridSpan è¡¥ç©ºå ä½ / vMerge ç»­æ ¼æ ‡ â†‘同上)"]
  W2 -->|txt/csv| W7[直读文本 â†’ chat]
  W2 -->|doc/xls| W8[拒绝·提示另存为 docx/xlsx]
  W3 --> W9[多页逐页识别]
  W4 --> W9
  W5 --> W9
  W6 --> W9
  W7 --> W9
  W9 --> W10["按(type,props)去重合并→扁平组件草稿+summary+warnings"]
  W10 --> W11[弹窗预览·人工确认后装进设计器画布]
  W11 --> W12[人工调整→保存为模板版本·这是唯一落库点]
  WCL[组件积木清单由前端从活注册表生成并随请求上传] --- W9
  WSAFE[只产草稿不落库·未注册type在装配阶段被跳过·永远产不出任意HTML] --- W10
  KB1[知识库文档上传URL] --> KB2[POI/Tika提取文本] --> KB3[切片约800tokens→向量化→Milvus]
  KB3 --> KB4[分段检索接口 topK+相似度阈值]
  KB4 -.-> KB5[暂未接入业务问答流程·待确认]
docs/qc_report_ai_import_frontend_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,360 @@
# æ™ºèƒ½è´¨æ£€æŠ¥å‘Š â€” AI å¯¼å…¥æ¨¡æ¿ å‰ç«¯è”调方案
> åŽç«¯æ¨¡å—:`yudao-module-qcreport` 接口前缀:`/admin-api/qc-report/ai-import`
> æœ¬è½®èŒƒå›´ï¼š**上传一份已有的检验报告文件 â†’ AI è¯»æ‡‚ â†’ äº§å‡ºæ¨¡æ¿è‰ç¨¿**。
> **接口不落库**,草稿只是「一串组件 + å„自的属性」;人工在设计器确认后点既有的「保存」才会写成模板版本。
## æ¶‰åŠé¡µé¢
| é¡µé¢ | è¯´æ˜Ž | æœ¬è½®çŠ¶æ€ |
|---|---|---|
| æ¨¡æ¿è®¾è®¡å™¨ï¼ˆæ—¢æœ‰ï¼‰ | é¡¶æ ã€ŒAI å¯¼å…¥ã€æŒ‰é’® â†’ å¼¹çª—选文件 â†’ è¯†åˆ« â†’ é¢„览 â†’ ã€Œç”Ÿæˆåˆ°ç”»å¸ƒã€ | å·²æŽ¥å…¥ |
| æ¨¡æ¿åˆ—表页 | æœ¬è½®**不做**「AI æ–°å»ºæ¨¡æ¿ã€å…¥å£ï¼Œè§ã€Œæ³¨æ„äº‹é¡¹ã€ | ä¸åš |
## ä¸šåŠ¡æµç¨‹ä¸Žæ•°æ®å¸¦å…¥
1. **进入设计器**:必须带 `templateId` è¿›å…¥ï¼ˆ`/qc/report-template/designer?id=5`)。
   AI å¯¼å…¥åªåœ¨æ—¢æœ‰æ¨¡æ¿çš„设计器内,`templateId` æ˜¯åŽç»­ã€Œç»‘附件」与「日志留痕」的主键。
2. **上传**:用户选文件后调 system æ¨¡å—既有的上传接口拿 `blobId`。
   **必须保持用户的选择顺序**——后端按这个顺序理解文档页序。**这一步不发识别请求**,用户点头才发。
3. **绑附件**:拿到 `blobId` åŽ**立即**调 system æ¨¡å—既有的附件绑定接口,把文件归属到当前模板
   ï¼ˆ`application='file'`、`recordType='qc_report_template'`、`recordId=templateId`)。
   âš  `application` å—后端枚举限制,**只能是 `file` / `image` / `avatar`**,传其它值会 500。
   **先绑再识别**:识别即使失败,文件也已归属到模板名下,不会留成没人认领的孤儿文件。
4. **识别**:调本文档的 `draft` æŽ¥å£ï¼Œå¸¦ä¸Š `blobIds`、`templateId`、`schemaVersion`、`catalog`、可选的 `hint`。
   **这条请求要单独放宽超时到 180 ç§’**(单次最长要发 5 æ¬¡å¤§æ¨¡åž‹è°ƒç”¨ï¼Œé»˜è®¤è¶…时会在模型返回前就掐断)。
5. **预览**:弹窗列识别出的组件(类型显示名 + å·²è¯†åˆ«çš„属性)、`summary`、`warnings`。
   è¿™ä¸€æ­¥**没有任何写操作**,用户可以反复重新上传。
6. **生成到画布**:画布非空时先弹替换确认(**替换后无法用撤销恢复**)→ å‰ç«¯è£…配器把草稿编译成画布数据装进去。
7. **保存**:人工在设计器调整后点**既有的**「保存」→ èµ°æ—¢æœ‰çš„版本保存接口。
   **这是唯一的落库点,AI è¿™æ¡è·¯æ²¡æœ‰ç¬¬äºŒæ¡ä¿å­˜è·¯å¾„**,因此既有的模板校验(含判定规则)自动生效。
**带入关系要点**
- `templateId` æ˜¯ä¸»çº¿ï¼Œä»Žæ¨¡æ¿åˆ—表带入设计器、再带入 AI å¯¼å…¥ä¸Žé™„件绑定。
- `catalog`(积木清单)**每次请求都从当前前端注册表现算一遍再传**,不要在应用里缓存成常量:
  ç¼“存它等于把「注册表是唯一真相来源」这条前提悄悄作废。
- `blobIds` çš„顺序即文档页序,多页文件按这个顺序逐页识别、最后合并去重。
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž | æƒé™ç  |
|---|---|---|---|
| POST | `/qc-report/ai-import/draft` | è¯†åˆ«æ–‡ä»¶ç”Ÿæˆæ¨¡æ¿è‰ç¨¿ã€‚**同步返回、不落库、不建版本行** | `qc-report:template:ai-import` |
> âš  æœ¬è½®**未**往 `system_menu` æ’å…¥ `qc-report:template:ai-import` æƒé™è¡Œï¼ˆä¸Žå‡ºä»¶æŽ¥å£åŒä¸€å£å¾„)。
> å½“前 `admin` æ˜¯è¶…管、直接放行,联调不受影响;**前端页面正式落地时需一并补菜单与按钮权限**,
> å¦åˆ™éžè¶…管角色调本接口会 403。
### è¯·æ±‚参数
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|---|---|---|---|
| `blobIds` | Long æ•°ç»„ | **是** | ä¸Šä¼ æ–‡ä»¶å¯¹åº”çš„ blobId。**顺序即文档页序**;最多 3 ä¸ªï¼ˆè¯¥ä¸Šé™ç”±åŽç«¯ `yudao.qcreport.ai-import.max-files` å†³å®šï¼Œè¶…限报 `1_070_104_005`),单个文件最大 20MB |
| `templateId` | Long | **是** | ç›®æ ‡æ¨¡æ¿ç¼–号,用于日志留痕与附件归属 |
| `schemaVersion` | String | **是** | å›ºå®šä¼  `1.1`。与设计器同源,两端不一致时后端直接报错 |
| `catalog` | Object æ•°ç»„ | **是** | å¯ç”¨ç»„件积木清单,见「`catalog` çš„æž„造方式」;最多 40 é¡¹ |
| `hint` | String | å¦ | ç”¨æˆ·è¡¥å……说明,会拼进提示词,例如「这是 IQC æ¥æ–™æ£€éªŒæŠ¥å‘Šã€ |
**`catalog` ä¸€é¡¹çš„结构**
| å­—段 | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|---|---|---|---|
| `type` | String | **是** | ç»„件类型,如 `QualityTable`。**大小写敏感**,模型会原样抄进结果 |
| `label` | String | å¦ | ç»„件显示名,供模型理解语义 |
| `category` | String | å¦ | ç»„件分类,如 `basic` / `data` / `result` |
| `hint` | String | **是** | è¯¥ç»„ä»¶çš„**选型说明**:用来放什么、不要用来放什么。模型选型以它为准,见「选型指引必须随清单带出」 |
| `fields` | Object æ•°ç»„ | å¦ | è¯¥ç»„件允许填写的属性 |
> `hint` æ ‡ä¸º**是**不是因为后端强制(后端收到空值不报错),而是因为**漏带它 = æ¨¡åž‹é€€å›žçžŽçŒœ**:
> ç»„件之间大量可互相替代(报告抬头可写 `Heading` / `Text` / `ReportHeader`),
> æ¨¡åž‹åªçœ‹ä¸­æ–‡ `label` æ—¶ä¼šæŒ‘字段最简单、最通用的那个。实测漏带前的表现就是
> ã€Œé¡µçœ‰é¡µè„šä½ç½®ç”¨äº†æ–‡æœ¬ç»„件」。
**`catalog[].fields[]` ä¸€é¡¹çš„结构**
| å­—段 | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|---|---|---|---|
| `key` | String | **是** | å±žæ€§ key,如 `itemsPath` |
| `label` | String | å¦ | å­—段显示名 |
| `type` | String | å¦ | å­—段类型,如 `text` / `number` / `boolean` / `enum` |
| `required` | Boolean | å¦ | æ˜¯å¦å¿…å¡«ï¼›**只置真不置假** |
| `bindable` | Boolean | å¦ | æ˜¯å¦æ”¯æŒæ•°æ®ç»‘定;**只置真不置假** |
| `enumOptions` | Object æ•°ç»„ | å¦ | æžšä¸¾å¯é€‰å€¼ï¼Œå…ƒç´ å½¢å¦‚ `{ label: '横向', value: 'landscape' }`。**仅在 `type=enum` æ—¶æœ‰æ„ä¹‰** |
### å“åº”字段
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|---|---|---|
| `components` | Object æ•°ç»„ | è¯†åˆ«å‡ºçš„组件草稿,按报告从上到下的顺序;已做过合并去重与非法项过滤 |
| `components[].type` | String | ç»„件类型,**保证是 `catalog` é‡Œå£°æ˜Žè¿‡çš„ type** |
| `components[].props` | Object | ç»„件属性。清单里未声明的 key å·²è¢«åŽç«¯ä¸¢å¼ƒ |
| `page` | Object | AI çŒœæµ‹çš„纸张配置,**可能为空**(为空表示沿用当前模板的纸张) |
| `page.size` | String | `A3` / `A4` / `A5` / `Letter` |
| `page.orientation` | String | `portrait` / `landscape` |
| `page.margin` | Object | `top` / `right` / `bottom` / `left`,单位 mm |
| `summary` | String | AI å¯¹è¿™ä»½æ–‡æ¡£çš„一句话说明,展示在预览弹窗顶部 |
| `warnings` | String æ•°ç»„ | è½¯æç¤ºï¼šè¢«è·³è¿‡çš„页、无法表达的区块、被丢弃的未知组件、**原件带页眉/页脚已被标记并入识别内容**等 |
| `durationMs` | Long | è¯†åˆ«æ€»è€—时(毫秒),含多次模型调用 |
**响应示例**
```json
{
  "code": 0,
  "data": {
    "components": [
      { "type": "ReportHeader", "props": { "title": "来料检验报告" } },
      { "type": "QualityTable", "props": { "itemsPath": "inspectionItems" } },
      { "type": "Result", "props": { "conclusion": "{{report.conclusion}}" } }
    ],
    "page": { "size": "A4", "orientation": "portrait" },
    "summary": "这是一份 IQC æ¥æ–™æ£€éªŒæŠ¥å‘Šï¼Œå«è¡¨å¤´ã€æ£€éªŒé¡¹ç›®è¡¨æ ¼ä¸Žç»“论栏。",
    "warnings": ["第 2 é¡µçš„判定规则说明无法用可用组件表达,已丢弃"],
    "durationMs": 12400
  }
}
```
> âš  **响应里没有 `rawText` å­—段**,这是有意的:完全解析不出结果时接口直接抛错
> ï¼ˆ`1_070_104_012`),响应体是错误结构而不是本响应;塞一个恒为空的字段只会让人误以为
> ã€Œè§£æžå¤±è´¥æ—¶å‰ç«¯èƒ½æ‹¿åˆ°æ¨¡åž‹åŽŸæ–‡ã€ã€‚æ¨¡åž‹åŽŸæ–‡å†™è¿›äº†æœåŠ¡ç«¯æ—¥å¿—ã€‚
## `catalog` çš„æž„造方式
`catalog` ä¸æ˜¯æ‰‹å†™çš„常量表,而是**从设计器实际使用的组件注册表现算出来的派生视图**。
| è¦ç‚¹ | è¯´æ˜Ž |
|---|---|
| **来源** | è®¾è®¡å™¨å®žé™…使用的那个组件注册表(`src/components/quality`),它是组件清单的**唯一真相来源** |
| **何时算** | æ¯æ¬¡å‘起识别请求前现算一遍 |
| **必须先去注册** | æ³¨å†Œè¡¨**只有显式注册过才有内容**。设计器初始化时已做过一次,弹窗里再补一次是幂等的兜底 |
| **剥掉什么** | å‰¥ç¦»ä¸å¯åºåˆ—化的实现成员(图标、构建函数、校验函数、默认值),只留「模型需要知道的」:类型、显示名、分类、**选型说明**、字段 |
| **为什么不在后端硬编码** | åŽç«¯æŠ„一份就是第二个真相来源,且是最坏的:前端加组件/改必填字段时前端立刻生效、后端副本静默过期,模型随即会编出注册表里根本不存在的 `type` |
| **边界为什么没被削弱** | æ¸…单进提示词后的唯一出口是「被模型抄成 JSON å­—符串」。真正把关的是**前端装配器对着活注册表查**:未注册的 `type` ä¼šè¢«è·³è¿‡ï¼Œ**永远产不出任意 HTML** |
| **前置校验** | æ³¨å†Œè¡¨ä¸ºç©ºæ—¶æž„造函数**直接抛错**,不要发一个空清单出去——后端会判为入参非法,报出来的错和真实原因隔了好几层 |
### é€‰åž‹æŒ‡å¼•必须随清单带出
`hint` æ˜¯**组件选型的唯一真相来源**,和组件定义同源:改注册表里某个组件的 `hint`,下次请求自动生效,
后端不需要(也不应该)同步任何一份副本。
| è¦ç‚¹ | è¯´æ˜Ž |
|---|---|
| **写在哪** | æ¯ä¸ªç»„件定义的一条 `aiHint` æ–‡æ¡ˆï¼Œéšæ¸…单序列化带出 |
| **写什么** | ã€Œç”¨æ¥æ”¾ä»€ä¹ˆã€+「**不要**用来放什么」。后者才是关键——模型误用的根因是「不知道某个通用组件不该用在这里」 |
| **怎么用** | åŽç«¯åªè´Ÿè´£æŠŠå®ƒåŽŸæ ·åºåˆ—åŒ–è¿›æç¤ºè¯ï¼Œä¸€æ¡æŠ½å–è§„åˆ™è¦æ±‚æ¨¡åž‹ã€Œé€‰åž‹æ—¶ä»¥ hint ä¸ºå‡†ï¼Œæ¸…单里存在专门组件就不要退而用通用组件凑数」 |
| **不写会怎样** | æ¨¡åž‹åªçœ‹åˆ°ç»„件中文名,会挑字段最简单的那个。实测症状就是:报告抬头与落款全落到了通用文本组件上 |
| **和设计器界面的关系** | `hint` **不进设计器界面**,它是给模型看的说明,不是组件的展示名或属性提示 |
## è‰ç¨¿ â†’ ç”»å¸ƒï¼šç¼–译契约
草稿**不能直接塞进画布**。它要先经前端的装配器编译成画布数据,再交给设计器载入(与「打开一份已保存的模板」走完全同一条路)。
**装配器(`assembleQualityCanvas`)入参 / å‡ºå‚**
| æ–¹å‘ | åå­— | ç±»åž‹ | è¯´æ˜Ž |
|---|---|---|---|
| å…¥ | `components` | è‰ç¨¿ç»„件数组 | å³å“åº”里的 `components`;可为空 |
| å‡º | `grapes` | Object | ç”»å¸ƒæ•°æ®ï¼Œå–‚给设计器的载入方法 |
| å‡º | `skipped` | Object æ•°ç»„ | è¢«è·³è¿‡çš„组件:`{ type, label, reason }`。**`reason` æ˜¯ç»™äººçœ‹çš„中文,直接展示** |
| å‡º | `problems` | String æ•°ç»„ | è£…进去了但有问题:必填缺失、收到结构化数据而回落默认值等 |
**逐项行为(与既有「从画布收集语义」互为逆运算)**
| æƒ…况 | è¡Œä¸º |
|---|---|
| `type` å–不到注册表里的定义 | è¿› `skipped`,**继续处理下一个**,不整批失败 |
| `type` ä¸ºç©º / ç¼ºå¤± | åŒä¸Šï¼Œè¿› `skipped` |
| AI æ¼äº†æŸäº›å­—段 | ç”¨ç»„件声明的默认值补齐(不会出现「组件缺属性」) |
| AI ç¼–了清单外的属性 key | **静默丢弃**(属性序列化只输出声明过的 key) |
| æŸä¸ªå­—段收到对象 / æ•°ç»„ | è®°ä¸€æ¡ `problems`,该字段按默认值处理(防止 `[object Object]` è¢«å†™è¿›ç”»å¸ƒï¼‰ |
| å¿…填字段为空 | è®°ä¸€æ¡ `problems`,**组件仍然装进画布**(用户可以在设计器里补) |
| ä¸€ä¸ªç»„件都装不进去 | **不动画布**,只报错。载入动作会清空撤销栈与已有内容,白白搭进去一版画布却什么都换不来 |
**载入动作的两个副作用(必须让用户知情)**
- **清空撤销栈**:装完之后**无法用撤销恢复**原来的画布。所以画布非空时要先弹确认,并建议用户先保存当前内容。
- **会用传入的判定规则覆盖当前规则**:装配路径**显式沿用当前已编辑的规则**,不会把用户写好的判定规则静默清空。
> **装配产物不能直接落库。** å‡ºå‚ `grapes` é‡Œæ¯ä¸ªèŠ‚ç‚¹çš„ `components` ä»æ˜¯**未解析的 HTML å­—符串**——它必须先进画布(GrapesJS ä¼šæŠŠå®ƒæ‹†æˆèŠ‚ç‚¹æ ‘ï¼‰æ‰èƒ½ä¿å­˜ã€‚ç»•è¿‡è®¾è®¡å™¨ç›´æŽ¥æŠŠè¿™ä¸²å†™è¿› `schema_json`,后端渲染器不会解析它,出件时组件会渲成空 div。
>
> åŒç†ï¼Œè‹¥è¦ç¨‹åºåŒ–地把组件放进画布,**要把节点对象交给画布,不要自己拼 HTML ä¸²å†è§£æž**:HTML è§£æžå™¨ä¼šæŠŠå±žæ€§åè½¬å°å†™ï¼ˆ`data-qc-itemsPath` â†’ `data-qc-itemspath`),属性面板与渲染都会绑不上。设计器内部走的就是「直接传对象」这条路径。
## AI å¯å¡«çš„æ¨¡æ¿ç»“构属性(列定义 / è¡¨å¤´è·¨åˆ— / æ ·å“å­—段)
原件的检验表**列**常常比组件默认列多(「检测方法」「标准要求」「结论」等),样品信息块的字段也常与默认六项不同。为此新增三个可填属性,AI ä¼šæŠŠåŽŸä»¶çš„åˆ—ä¸Žå­—æ®µå†™è¿›åŽ»ã€‚**这三个属性都是纯文本字符串,不是数组**——属性值在画布上是标量,数组存不进去。
| å±žæ€§ | æŒ‚在哪个组件 | ä½œç”¨ |
|---|---|---|
| `columns` | åˆ†ç»„检验项表 | è¦†ç›–该表的列:列标题 + æ¯åˆ—绑定的数据 |
| `headerSpans` | åˆ†ç»„检验项表 | è¡¨å¤´åˆ†ä¸Šä¸‹ä¸¤å±‚时,描述**上面那一层** |
| `fields` | æ ·å“ä¿¡æ¯ | è¦†ç›–样品信息块展示的字段 |
### `columns` çš„写法
一行字符串,列与列之间用 `|` åˆ†éš”,每列写作「列标题=绑定表达式」,**在第一个 `=` å¤„切分**(所以列标题里不能出现 `=`)。
- åˆ—标题前加 `#` = è¯¥åˆ—要**纵向合并跨住整组**(放组名的那一列),一表只有一列该加。
- **填了 `columns` æ•´è¡¨å°±æŒ‰å®ƒæ¥**,「显示序号 / æ˜¾ç¤ºæ£€æµ‹è¦æ±‚ / æ˜¾ç¤ºå®žæµ‹å€¼ / æ˜¾ç¤ºå•位 / æ˜¾ç¤ºåˆ¤å®šã€è¿™äº›å¼€å…³å…¨éƒ¨å¤±æ•ˆï¼ˆå®ƒä»¬åªç®¡é»˜è®¤åˆ—)。
- åˆ—标题与绑定表达式都会做 HTML è½¬ä¹‰ï¼Œç”¨æˆ·å†™ä»€ä¹ˆéƒ½ä¸ä¼šå˜æˆå¯æ‰§è¡Œçš„æ ‡ç­¾ã€‚
**可用的绑定路径是白名单**,写白名单外的路径会在校验时报错:`{{index}}` è¡Œåºå·ã€`{{item.itemName}}` æ£€éªŒé¡¹ç›®åã€`{{item.group.childName}}` å­é¡¹åã€`{{item.checkMethod}}` æ£€æµ‹æ–¹æ³•、`{{item.requirement}}` æ£€æµ‹è¦æ±‚、`{{item.standardValue}}` æ ‡å‡†å€¼ã€`{{item.actualValue}}` å®žæµ‹å€¼ã€`{{item.unit}}` å•位、`{{item.resultText}}` åˆ¤å®šç»“论、`{{item.remark}}` å¤‡æ³¨ã€‚
**给分组表写 `columns` æ—¶å¿…须保留「子项」列**(`子项={{item.group.childName}}`),且它不加 `#`。分组表的默认列本来就有这一列,AI æŠŠåˆ—写全时最容易漏掉它——一漏,子项名整列从报告上消失,而子项往往才是标准真正考核的对象。实测:不写明这条时 3/3 è½®éƒ½æŠŠå­é¡¹åˆ—丢掉(只写出 4 åˆ—),写明后 5/5 è½®ä¿ç•™ã€ä¸”与原件的 6 åˆ—逐列对齐。
### ã€Œ#」那一列到底怎么合并的
合并**不在前端算,也画布上看不见**,全部由数据下发、出件时才生效:
| çŽ¯èŠ‚ | è°åš | åšä»€ä¹ˆ |
|---|---|---|
| æ ‡å‡ºåˆå¹¶åˆ— | æ¨¡æ¿ | `columns` é‡Œç»™è¯¥åˆ—标题加 `#`,组件据此只在这一格写 `rowspan` / `hidden` ä¸¤ä¸ªç»‘定 |
| ç®—出合并几行 | åŽç«¯ | ç»„的第一行下发 `span = æœ¬å•该组的实际行数`、`hidden = ç©ºä¸²`;组内其余行下发 `span = 1`、`hidden = hidden` |
| æ¸²æˆè§†è§‰åˆå¹¶ | å‰ç«¯ / Java æ¸²æŸ“器 | ä¸¤è¾¹éƒ½åœ¨ã€Œæ±‚值结果为空串」时不输出该属性。于是组头行的 `hidden` æ¶ˆå¤±ã€é‚£ä¸€æ ¼å¸¦ç€ `rowspan` æ˜¾ç¤ºï¼›å…¶ä½™è¡Œçš„ `hidden="hidden"` è®©æ•´æ ¼ `display:none`,把位置让给上方的合并格 |
三条由此推出的口径:
- **合并行数是「本单该组实际出了几行」,不是指标主数据里该分组挂了多少个子项。** æœ¬å•少录两个子项,格子就只跨那几行。
- **该组在本单没有子项行时不合并**(后端按 `span = 1` ä¸‹å‘),独立录入项同理——本来就只有一行,无所谓跨行。
- **`hidden` åªèƒ½é ã€Œå±žæ€§åœ¨ä¸åœ¨ã€è¡¨è¾¾**,所以这个值必须是字符串:`hidden="false"` ç…§æ ·éšè—ï¼Œåªæœ‰ç©ºä¸²èƒ½è¡¨ç¤ºã€Œä¸è¾“出该属性 = è¿™ä¸€æ ¼å¯è§ã€ã€‚前端不要把它当布尔值处理。
### è‡ªå®šåˆ—的列宽:长串不许把表格撑出纸张
自定列**不带任何宽度声明**(默认列带:序号 48px、检测要求 30%、实测值 26%……),所以整张表的列宽只能由各列的**最小内容宽度**倒推。而报告里的值是 `{{item.standardValue}}` è¿™ç§ä¸å«ç©ºæ ¼çš„长串,最小内容宽度就是整串长度——几列加起来轻易超过 A4 æ­£æ–‡å®½åº¦ï¼Œæµè§ˆå™¨åªèƒ½æŠŠè¡¨æ ¼æ’‘出纸张,最末一列被挤成二十几个像素的竖条。
修法:**只在自定列时**给单元格加 `overflow-wrap:anywhere`。它会参与最小内容宽度计算(`break-word` ä¸ä¼šï¼‰ï¼Œåˆ—宽因此不再被长串绑架;出件时值本来就短(「烘箱干燥法」「13.2」),折行极少触发。
| åœºæ™¯ï¼ˆA4 çºµå‘,正文宽 672px) | ä¿®å‰ | ä¿®åŽ |
|---|---|---|
| è‡ªå®š 6 åˆ—,值为占位符 | æ•´è¡¨ 810px、溢出 138px、末列 29px | æ•´è¡¨ 672px、不溢出、末列 97px |
| è‡ªå®š 6 åˆ—,值为真实数据 | æœ¬å°± 672px | 672px,列宽分布不变 |
| **默认列**(分组表 7 åˆ—) | 807px、溢出 135px | **保持原样** |
最后一行是刻意为之:默认列的宽度声明已经定住版面,再叠一条折行反而会让没写宽度的「检验项目」「子项」被挤到 30 ä½™åƒç´ ã€è¡Œé«˜ä»Ž 59px æ¶¨åˆ° 283px(实测)。因此这条只挂在自定列上,**没有 `columns` çš„存量模板产物逐字不变**(探针以 sha256 å®ˆç€ï¼‰ã€‚
> è‡ªå®šåˆ—的属性是随本轮一起进的,存量模板与已发布版本里一条都没有(全库实测 `data-qc-columns` / `data-qc-headerSpans` / `data-qc-fields` å‘½ä¸­æ•°å‡ä¸º 0),因此这条改动对既有报告零影响。
### `headerSpans` çš„写法
同样是 `|` åˆ†éš”的一行字符串,每段写作「标题^跨越列数」,**在最后一个 `^` å¤„切分**;不写跨越列数时按 1 ç®—。
- **下面那一层由 `columns` å„列的标题自动拼出,不要重复写**——`headerSpans` åªæè¿°ä¸Šé¢é‚£ä¸€å±‚。
- **各段跨越列数之和必须等于列数**。对不上时整层会被丢弃、退回单层表头(不报错、不崩),所以这条不满足时用户看到的是「两层表头没生效」而不是一条错误。
- åŽŸä»¶æœ€å³è¾¹é‚£åˆ—ã€Œç»“è®ºã€/「判定」在原件里不属于上层分组标题时**要单独成段**(`结论^1`)。不点明时模型会图省事把它的列数并进「检验结果」(写成 `检验结果^6`),表头就把结论列画到检验结果底下。
- **某列自己既是列名又是上格时**(原件里是一格纵向合并、占满上下两层表头,提取文本里表现为下层表头那一格写着「↑同上」),仍照写一段、跨越列数写 1,但该段标题必须与 `columns` é‡Œè¿™ä¸€åˆ—的列标题**一字不差**。两者相同时报告会把它合出一格跨两行(`rowspan`),下层不再重复出这一格;不一致时会被当成另一层的新分组标题,「结论」二字在表头上印两遍。
  - è¿™æ˜¯æœ¬è½®ä¿®æŽ‰çš„一处真缺陷:模型此前 5/5 è½®éƒ½å†™ `结论^1`、列名也叫「结论」,两层各印一次,用户看到的就是「多了一个结论」。补上「两边一字不差 â‡’ åˆæˆä¸€æ ¼è·¨ä¸¤è¡Œã€è¿™æ¡åˆ¤æ®åŽï¼Œç›´è¿ž 5/5 è½®åˆæˆä¸ºä¸€æ ¼ã€‚
  - åä¾‹è§å®žæµ‹ï¼šæœ‰ 1/5 è½®æ¨¡åž‹æŠŠ `columns` é‡Œé‚£ä¸€åˆ—改名成「判定」、`headerSpans` å´ä»å†™ç€åŽŸä»¶ä¸Šçš„ã€Œç»“è®ºã€ï¼Œä¸¤è¾¹å¯¹ä¸ä¸Šåˆé€€å›žå¤šå°ä¸€ä¸ªè¡¨å¤´ã€‚æç¤ºè¯é‡Œå› æ­¤è¡¥äº†ä¸€å¥ã€Œä¸è¦è‡ªä½œä¸»å¼ æŠŠå®ƒæ”¹åæˆè¿‘ä¹‰è¯ã€å¹¶å†™æ˜ŽåŽæžœï¼Œå¤è·‘ 5/5 è½®ä¸å†å‡ºçŽ°ã€‚
### `fields` çš„写法
语法与 `columns` å®Œå…¨ç›¸åŒï¼ˆå­—段名=绑定表达式,`|` åˆ†éš”),但**不允许 `#` åˆå¹¶æ ‡è®°**(那是分组表标组名的)。每行放几组仍由「每行字段数」属性控制。
绑定表达式只能写报告上下文里确实存在的字段(与 `columns` åŒå—「不许自造键」约束);原件里有、上下文里没有的栏目(如「产品数量」「土豆品种」「生产日期」「有效日期」)会被丢弃,并在 `summary` é‡Œè¯´æ˜Žã€‚
字段数不是「每行字段数」的整数倍时(**原件常见**,如原件 5 é¡¹ã€æ¯è¡Œæ”¾ 2 ç»„),末行由最后一个值格横向跨掉余下的列,右侧不会留出只有边框的空格。这是渲染期的固定行为,**不需要在 `fields` é‡Œåšä»»ä½•标记**,也不会因此增减字段格数。
### ç”»å¸ƒæ˜¾ç¤ºä¸Žå‡ºä»¶ç»“果的差异
设计器画布**不求解绑定**——`{{item.group.childName}}` è¿™ç±»å ä½ç¬¦åŽŸæ ·ç•™ç€ã€‚ç”±æ­¤æœ‰ä¸€å¤„å¿…é¡»å•ç‹¬å¤„ç†ï¼šåˆ†ç»„æ£€éªŒé¡¹è¡¨çš„ç»„åæ ¼å†™æˆ `hidden="{{item.group.hidden}}"`,在浏览器眼里「有 `hidden` å±žæ€§ã€å°±æˆç«‹ï¼ŒäºŽæ˜¯ç”»å¸ƒé‡Œè¿™ä¸€æ ¼è¢« `display:none`,该行后面的格整体左移一格,末列只剩表头、被挤成竖排细条——画布上看到的排版与出件结果对不上。
设计器为此只在**画布文档内**注入一条引导样式,把「值还停在占位符」的 `hidden` è¿˜åŽŸæˆå¯è§æ ¼ã€‚å‡ºä»¶æ—¶è¯¥å€¼å·²è§£æˆç©ºä¸²æˆ– `hidden`,选择器不再命中,报告渲染一个字都不受影响;这条样式只加在画布 iframe é‡Œï¼Œä¸å†™è¿› Schema、不随模板保存。
占位符带来的第二处差异是**宽度**:占位符比真值长得多,撑不撑得破纸张在两侧表现不同。这一处不走画布样式,而是把 `overflow-wrap:anywhere` å†™è¿›è‡ªå®šåˆ—的单元格样式里——两侧用同一套规则,所见即所得(详见上一节)。
### è°åœ¨ä»€ä¹ˆæ—¶å€™æ ¡éªŒè¿™ä¸‰ä¸ªå±žæ€§
只有**保存 / å‘布前**的属性校验会检查它们(列定义语法、跨列数合计、`#` ç”¨åœ¨æ ·å“ä¿¡æ¯ä¸Šç­‰ï¼‰ï¼Œè®¾è®¡è¿‡ç¨‹ä¸­çš„中间态不触发。校验信息走既有的 `problems` é€šé“(**非阻断**),组件仍会装进画布,用户可以在设计器里改。
> **已知边界**:AI å¯¼å…¥è·¯å¾„会跑这套校验;设计器里**手工编辑**后点保存**目前不跑**(`validateQualityProps` åªåœ¨è£…配器里被调用)。也就是说手工填错的列定义不会在保存时被拦住,只会在出件时以「绑定取不到值」的形式暴露。这是既有缺口,本轮未改。
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|---|---|---|
| `summary` | å¼¹çª—顶部 | æˆåŠŸè‰²æç¤ºæ¡ã€‚ä¸ºç©ºæ—¶ä¸å±•ç¤º |
| `warnings` | å¼¹çª—中部 | è­¦å‘Šè‰²æç¤ºæ¡ï¼Œé€æ¡ `·` åˆ—出。**必须展示**——它承载「文件里有哪些内容没被识别到」 |
| `components` | å¼¹çª—中部列表 | æ¯è¡Œï¼šåºå· + ç±»åž‹æ˜¾ç¤ºå + ç±»åž‹åŽŸå§‹å€¼ + å·²è¯†åˆ«çš„属性(`字段显示名=值`)。类型取不到定义时额外打「未注册,将被跳过」标记 |
| `components` æ¡æ•° | åˆ—表上方 | ã€Œè¯†åˆ«å‡º N ä¸ªç»„件」;有未注册项时追加「M ä¸ªç»„件设计器不认识」标记 |
| `durationMs` | åˆ—表上方 | ã€Œè€—æ—¶ N ç§’」 |
| `page` | ä¸è¿›å¼¹çª— | å¹¶å…¥è®¾è®¡å™¨çš„纸张设置后再载入画布,见「业务规则」 |
| `skipped` / `problems` | è½½å…¥åŽå¼¹çª— | ã€Œæœ‰ N å¤„需要留意」,逐条列出 |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|---|---|
| **只作草稿,不落库** | æŽ¥å£ä¸å»ºæ¨¡æ¿ã€ä¸å»ºç‰ˆæœ¬è¡Œã€ä¸å†™ç”»å¸ƒã€‚**唯一的落库点是设计器既有的「保存」**,所以既有的模板校验(含判定规则)自动生效 |
| **多页逐页识别后去重合并** | æ‰«æä»¶/照片按页各出一次草稿,再按「类型 + å±žæ€§ã€å®Œå…¨ç›¸åŒåŽ»é‡åˆå¹¶ã€‚é¡µçœ‰ã€æ ‡é¢˜ã€è¡¨å¤´åœ¨å¤šé¡µé‡å¤å‡ºçŽ°æ—¶åªç•™ç¬¬ä¸€ä»½ |
| **`.doc` / `.xls` æ—§æ ¼å¼ä¸æ”¯æŒ** | ç›´æŽ¥æŠ¥ `1_070_104_003`,提示另存为 `.docx` / `.xlsx`。`.docx` / `.xlsx` / `.pdf` / å›¾ç‰‡ / `.txt` / `.csv` æ”¯æŒ |
| **「空白」与「损坏」分开报** | æ‰©å±•名合法但内容为空 â†’ `1_070_104_005`;扩展名合法但文件打不开(损坏、带密码) â†’ `1_070_104_015`。两者给用户的动作完全不同,不能合成一条 |
| **PDF èµ°å“ªæ¡é€šé“由「有没有文本层」决定** | ç”µå­ç‰ˆ PDF(有文本层)走文本通道,整份 1 æ¬¡è°ƒç”¨ï¼›æ‰«æç‰ˆ PDF(无文本层)逐页转图后按图识别 |
| **表格只识别结构** | è¡¨æ ¼çš„**具体数据行不会写进模板**,`QualityTable` çš„æ•°æ®æºä¸€å¾‹æ˜¯ `inspectionItems`;真实数据在出件时由业务单据提供。**这一点必须向用户说明**,否则用户会以为文件里的数据也导进来了 |
| **原件列/字段与默认不同时是覆盖,不是换组件** | æ£€éªŒè¡¨åˆ—比默认多(检测方法 / æ ‡å‡†è¦æ±‚ / ç»“论…)→ å†™ `columns`;表头分上下两层 â†’ å†™ `headerSpans`;样品信息字段与默认六项不同 â†’ å†™ `fields`。**一律不换组件**——换成别的组件只会把多出来的内容丢掉。三者的语法、白名单与校验见上文「AI å¯å¡«çš„æ¨¡æ¿ç»“构属性」 |
| **绑定表达式原样保留** | æ–‡ä»¶é‡Œå‡ºçް `{{report.reportNo}}` è¿™ç±»å ä½ç¬¦æ—¶ï¼ŒAI ä¼šåŽŸæ ·ä¿ç•™ï¼Œä¸ä¼šæ›¿æ¢æˆå®ƒçœ‹åˆ°çš„å­—é¢å€¼ |
| **AI ä¸å¾—自造绑定键** | æç¤ºè¯æ˜Žç¡®ç¦æ­¢æ¨¡åž‹å‘明新的 `{{...}}` é”®ï¼Œå¹¶ä¸”**把可用的键全量列了出来**:报告级字段取自后端 `ReportFields`(报告号、样品名、规格、批号、检验员、检验日期、结论、合格率、计数等 23 ä¸ªï¼Œåå°„生成、不手写),检验项字段取自表格组件的绑定路径。**只禁编造、不给可选集合时模型只能靠猜**——实测 5/5 è½®éƒ½ç¼–出了 `{{report.productionDate}}` / `{{report.expiryDate}}` è¿™ç±»ä¸å­˜åœ¨çš„键,报告上留白并触发「绑定取不到值」告警 |
| **`.docx` çš„ Word é¡µçœ‰/页脚会被标记并入内容** | Word æ–‡æ¡£çš„**页眉与页脚**(每页重复的公司抬头、厂址电话等)原本不在正文流里,现已被读取并以 `[页眉]` / `[页脚]` æ ‡è®°æ‹¼è¿›è¯†åˆ«å†…容(页眉在正文前、页脚在正文后),同时往 `warnings` é‡ŒåŠ ä¸€æ¡æç¤ºã€‚**扫描件/照片不受影响**:它们的页眉页脚本来就是页面像素的一部分 |
| **表格末行的落款签署行属于落款** | æ£€éªŒå‘˜ / å®¡æ ¸äºº / æ‰¹å‡†äºº / æ—¥æœŸè¿™ç±»ç­¾ç½²è¡Œ**常排在检验表格的最后一行**。它不算「具体数据行」,应被取出来与原件页脚合成放进 `ReportFooter`(`ReportFooter` ä¸Ž `QualityTable` ä¸¤æ¡é€‰åž‹è¯´æ˜Žé‡Œå„自写明了这条边界) |
| **签署行的人名与日期留空** | ç­¾ç½²è¡Œ**保留栏目名、值留空**(形如「检验员:\_\_\_\_ 审核人:\_\_\_\_ 日期:\_\_\_\_」),打印出来由人手填。人名与日期是每一份报告各自的数据,写死原件上的那几个人会让所有报告印成同一个检验员 |
| **落款允许多行,换行要真的折行** | ç­¾ç½²è¡Œä¸ŽåŽŸä»¶é¡µè„šåˆæˆä¸€æ®µ**多行**文本交给 `ReportFooter`,几行内容**必须折行显示**,不能糊成一片。属性值里保留的是原始换行;**折行发生在渲染产物这一侧**——组件生成的画布 `content` é‡Œæ˜¯ `<br>`,渲染引擎按原样输出。`Text` ç»„件同样支持多行 |
| **判定规则与数据绑定不由 AI ç”Ÿæˆ** | AI åªå‡ºç»„件的静态属性;判定规则、数据绑定一律保留默认值,由人工在设计器里配置 |
| **纸张的合并规则** | AI çŒœå‡ºçš„纸张并入当前设计器的纸张设置(**按字段合并**,AI æ²¡çŒœåˆ°çš„字段沿用用户当前设置),再随载入动作一起生效。**不同步的话,保存时会被模板保存接口按旧值写回** |
| **AI çŒœä¸åˆ°çº¸å¼ æ—¶ä¸è¦†ç›–** | `page` ä¸ºç©ºæ—¶**必须传当前设计器的纸张设置**,不能传空。传空会让载入动作退回默认 A4,把用户已经调好的纸张悄悄改掉 |
| **组件数上限** | è¯†åˆ«ç»“果最多 200 ä¸ªç»„件,超出截断并在 `warnings` é‡Œæç¤ºè¡¥å½• |
| **任何单个组件坏掉都不影响整体** | æœªçŸ¥ç±»åž‹ã€ç»“构化属性、必填缺失都只进 `warnings` / `problems`,其余组件正常返回。**只有「一个组件都没识别出来」才整体报错** |
| **功能可被运维关掉** | éƒ¨ç½²çŽ¯å¢ƒä¸èƒ½å‡ºç½‘æ—¶ä¼šå…³æŽ‰ï¼ˆ`yudao.qcreport.ai-import.enabled=false`),此时接口报 `1_070_104_000`。前端应原样提示,不要当成系统异常 |
### ã€Œæ­£æ–‡æŠ¬å¤´/落款」与「每页重复的页眉页脚」不是一回事
这条口径容易混淆,前端联调时务必对齐:
| | ç”»å¸ƒä¸Šçš„ `ReportHeader` / `ReportFooter` | æœåŠ¡ç«¯å‡ºä»¶æ—¶çš„é¡µçœ‰é¡µè„š |
|---|---|---|
| **位置** | **正文流里**的顶部抬头块 / åº•部落款块,随正文只出现一次 | æ‰“印时**每页重复**的版式区域 |
| **由谁生成** | AI ä»Žæ–‡ä»¶é‡Œè¯†åˆ«å‡ºæ¥ã€è£…进画布,用户可编辑 | æœåŠ¡ç«¯ PDF å‡ºä»¶é…ç½®ï¼ˆ`PdfPrintOptions`)加的,目前只有页码 |
| **本轮改动** | `.docx` çš„ Word é¡µçœ‰è¢«è¯»è¿›æ¥åŽï¼Œæ¨¡åž‹ä¼šæŠŠå®ƒæŒªè¿›æ­£æ–‡é¡¶éƒ¨çš„ `ReportHeader` | **未改动** |
也就是说:把 Word é¡µçœ‰è®¤æˆ `ReportHeader`,是**把它搬进正文顶部**,**不是**还原每页重复的打印版式。
真正每页重复的那一层仍由服务端 PDF å‡ºä»¶é…ç½®æŽ§åˆ¶ã€‚**不要向前端承诺「导入后会得到每页重复的页眉」**。
## æ³¨æ„äº‹é¡¹
- **不要缓存 `catalog`**:每次请求现算。理由见「`catalog` çš„æž„造方式」。
- **`catalog` æ¯é¡¹å¿…须带 `hint`**:选型指引是它可以被模型看见的唯一出口。注册表里新增组件时,
  è¯¥ç»„ä»¶çš„ `aiHint` è¦ä¸€å¹¶å†™å…¨â€”—漏一条,那个组件就基本不会被模型选中。
- **`blobIds` é¡ºåºæœ‰æ„ä¹‰**:多页文件按数组顺序理解页序,前端要保序,不要用无序集合承载。
- **上传与识别是两步**:用户选完文件**不发**识别请求,等用户点「开始识别」才发。上传在 AntDV çš„上传组件里要先关掉它内置的自动上传通道,由按钮统一触发。
- **绑附件要先于识别**:先绑再识别,识别失败文件也已归属到模板,不会留孤儿文件。
- **识别要放宽超时**:这条请求单独设 180 ç§’。用默认超时会在模型返回前就被掐断,而且错误信息看不出是超时还是模型挂了。
- **不要在弹窗里吞掉识别错误**:后端已按「维度 + å®žé™…值 + å¯æ‰§è¡ŒåŠ¨ä½œã€ç»™äº†ç²¾ç¡®æ–‡æ¡ˆï¼Œç”±è¯·æ±‚å±‚ç»Ÿä¸€å¼¹å‡ºå³å¯ï¼Œå¼¹çª—é‡Œå†åŒ…ä¸€å±‚åè€Œä¼šç›–ä½å®ƒã€‚
- **载入时要传当前规则**:装配路径如果不传当前已编辑的判定规则,等于把用户写好的规则静默清空。
- **落款的属性输入框是单行的**:可绑定字段统一用自定义控件(下拉 + å•行输入框)。**AI å¯¼å…¥å‡ºæ¥çš„多行落款值会原样保留、也能正确折行**,但只要用户去编辑那个输入框,换行就会被打掉、重新变回一片。要做「在设计器里也能手工敲多行落款」,得把该控件的输入框换成多行输入框——该控件被全部可绑定字段共用,属于需要单独评估的改动。
- **载入会清空撤销栈**:画布非空时先弹确认并建议先保存;文案要**明说无法用撤销恢复**。
- **`skipped` / `problems` é€æ¡å±•示**:不要只报条数,用户要照着改。
- **本轮不做「AI æ–°å»ºæ¨¡æ¿ã€**:设计器必须带 `templateId` è¿›å…¥ï¼ŒAI å¯¼å…¥åªåœ¨æ—¢æœ‰æ¨¡æ¿çš„设计器内。想做「上传文件直接造新模板」需先建模板壳,是另一条流程。
- **本轮不做 AI è‡ªåŠ¨å‘å¸ƒç‰ˆæœ¬**,也不做撤销。
- **文件类型在前端只做即时约束**(扩展名白名单 + æ‹–拽区提示),**权威校验在后端**,前端别把这里的 3 ä¸ªæ–‡ä»¶ä¸Šé™å½“成安全边界。
- **模板删除会连带清理附件**:本轮补齐了「删除模板时清理其名下附件」的动作,AI å¯¼å…¥çš„æºæ–‡ä»¶ä¸ä¼šåœ¨æ¨¡æ¿åˆ é™¤åŽå˜æˆæ°¸ä¹…孤儿。前端不需要处理,但验收时值得确认一次。
## é”™è¯¯ç 
| é”™è¯¯ç  | æŠ¥æ–‡ | è§¦å‘场景 |
|---|---|---|
| `1_070_104_000` | AI å¯¼å…¥åŠŸèƒ½æœªå¯ç”¨ï¼ˆ`yudao.qcreport.ai-import.enabled` å½“前为 false),请联系管理员开启后再试 | è¿ç»´æŠŠå¼€å…³å…³æˆäº† `false` |
| `1_070_104_001` | ä¸Šä¼ çš„æ–‡ä»¶ï¼ˆblobId=X)不存在或已被清理,请重新上传后再试 | `blobId` æŸ¥ä¸åˆ°ã€‚文件已被清理或编号传错 |
| `1_070_104_002` | ä¸æ”¯æŒçš„æ–‡ä»¶ã€ŒX」(扩展名:Y)。仅支持 PDF、Word(.docx)、Excel(.xlsx)、图片(png/jpg/jpeg/bmp/gif/webp) ä¸Žæ–‡æœ¬(txt/csv),请转换格式后重新导入 | æ‰©å±•名不在白名单内 |
| `1_070_104_003` | ä¸æ”¯æŒçš„æ–‡ä»¶ã€ŒX」(旧版 Office æ ¼å¼.Y)。请用 Office æ‰“开后「另存为」.docx æˆ– .xlsx å†é‡æ–°å¯¼å…¥ | ä¸Šä¼ äº† `.doc` / `.xls` |
| `1_070_104_004` | æ–‡ä»¶ã€ŒX」大小 YMB,超过单文件上限 ZMB,请压缩或拆分后重新导入 | è¶…过 `maxFileSizeMb`(默认 20MB) |
| `1_070_104_005` | æ–‡ä»¶ã€ŒX」未解析出任何可用内容(共 N é¡µï¼‰ã€‚若是扫描件,请确认分辨率不低于 200 DPI、文字无严重倾斜或遮挡;推荐改用电子版 PDF æˆ– Word/Excel | ç©ºæ–‡ä»¶ï¼›ä¼ çœŸä»¶ç­‰ç¼–码不受支持的图片;**内容为纯空白的 `.docx` / `.xlsx` / `.txt`**。`.docx` / `.xlsx` æ‰“不开(损坏、带密码)走 `1_070_104_015`,不会落到这里 |
| `1_070_104_006` | ä¸€æ¬¡æœ€å¤šå¯¼å…¥ N ä¸ªæ–‡ä»¶ï¼Œæœ¬æ¬¡æäº¤äº† M ä¸ªï¼Œè¯·åˆ†æ‰¹å¯¼å…¥ | è¶…过 `maxFiles`(默认 3) |
| `1_070_104_007` | æ–‡ä»¶ã€ŒX」共 N é¡µï¼Œè¶…过单次识别上限 M é¡µï¼Œè¯·æ‹†åˆ†åŽåˆ†æ‰¹å¯¼å…¥ | è¶…过 `maxPagesPerFile`(默认 5)。**不静默截断** |
| `1_070_104_008` | ç»„件清单参数不合法(原因),请刷新页面后重试 | `catalog` ä¸ºç©ºã€å«æ²¡æœ‰ `type` çš„项、超 40 é¡¹ã€åºåˆ—化超 32KB |
| `1_070_104_009` | ç»„件清单的 Schema ç‰ˆæœ¬ä¸ºã€ŒX」,服务端只接受「Y」,请刷新页面后重试 | `schemaVersion` ä¸Žè®¾è®¡å™¨ä¸ä¸€è‡´ï¼ˆé€šå¸¸æ˜¯å‰ç«¯å·²æ›´æ–°è€ŒåŽç«¯æœªå‘版) |
| `1_070_104_010` | AI è¯†åˆ«è°ƒç”¨å¤±è´¥ï¼ˆå·²è€—æ—¶ Nms):原因。请到「AI å¤§æ¨¡åž‹ã€ä¸­ç¡®è®¤å¯¹è¯æ¨¡åž‹å·²å¯ç”¨ä¸”密钥有效,或稍后重试 | æ¨¡åž‹æœåŠ¡å¼‚å¸¸ã€ç½‘ç»œä¸é€šã€å¯†é’¥æ— æ•ˆ |
| `1_070_104_011` | AI è¯†åˆ«è¶…时(本次已等 N ç§’)。文件页数或数量较多时耗时更长,请减少文件数量,或改用电子版 PDF / Word / Excel | æ¨¡åž‹æŽ’队或文件过大。**接口侧超时默认 60 ç§’**(后端 `yudao.ai.timeout`,与前端那条 axios çš„ 180 ç§’是两道独立的闸) |
| `1_070_104_012` | AI è¿”回的内容无法解析为模板草稿(已收到 N ä¸ªå­—符)。若文件内容很长,请拆分后分批导入;若持续失败,请改用手工设计 | æ¨¡åž‹è¾“出不是预期 JSON(被截断、夹带解释文字等)。原始输出已写进服务端日志 |
| `1_070_104_013` | AI æœªèƒ½ä»Žæ–‡ä»¶ã€ŒX」中识别出任何可用组件。请确认该文件是检验报告;若确实是,请改用手工设计模板 | ä¸€ä¸ªç»„件都没识别出来。**这是唯一一个「整体失败」的语义** |
| `1_070_104_014` | æœ¬æ¬¡æäº¤çš„æ–‡ä»¶åˆè®¡ N é¡µï¼Œè¶…过单次识别上限 M é¡µï¼ˆå•文件上限 K é¡µï¼‰ï¼Œè¯·å‡å°‘文件数量或分批导入 | æ¯ä¸ªæ–‡ä»¶éƒ½æ²¡è¶…、加起来超 `maxPagesPerRequest`(默认 8) |
| `1_070_104_015` | æ–‡ä»¶ã€ŒX」无法作为 .Y æ‰“开,内容已损坏或受密码保护。请先用 Office æ‰“开该文件,确认能正常显示后「另存为」.docx / .xlsx å†é‡æ–°å¯¼å…¥ï¼›è‹¥æ–‡ä»¶æœ‰æ‰“开密码,请先解除密码保护 | `.docx` / `.xlsx` è§£æžæŠ›å¼‚常(POI æ‰“不开)。**「空白」与「损坏」是两回事,报文分开**;此前损坏文件误报 `1_070_104_005`(让用户去查分辨率),已纠正 |
> æ³¨ï¼š`templateId` **不做存在性校验**,它只用于日志留痕与附件归属。前端务必先用真实存在的模板 id è¿›è®¾è®¡å™¨ã€‚
docs/qc_report_configurable_columns_design.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,239 @@
# è´¨æ£€æŠ¥å‘Šã€Œè¡¨è¾¾åŠ›ç¼ºå£ã€è®¾è®¡ï¼šå¯è‡ªå®šåˆ—çš„æ£€éªŒè¡¨ + å¯é…å­—段的样品信息
> çŠ¶æ€ï¼š**待确认**。本文只做方案,不动代码。
> è§¦å‘背景:AI å¯¼å…¥å·²èƒ½æ­£ç¡®é€‰åˆ° `GroupedQualityTable`(选型问题已修,见开发进度 Â§äºŒåå››ï¼‰ï¼Œ
> ä½†ç”¨æˆ·æ¯”对原件后指出「草稿仍不像原件」。经核查,剩下的差距**全部是「可用积木的表达力不够」**,
> ä¸æ˜¯è¯†åˆ«é—®é¢˜â€”—文字都抽出来了,是组件装不下。
---
## 1. è¯æ®ï¼šå·®è·åˆ°åº•在哪
用户原件《百事模版.docx》(142373 å­—节)与当前草稿的逐项对比:
| åŽŸä»¶é‡Œæœ‰çš„ | è‰ç¨¿é‡Œçš„下场 | åŽŸå›  |
|---|---|---|
| è¡¨æ ¼åˆ—「检测方法」(`GB 5009.3-2016` / `ANA.MTH-000034`) | **整列丢弃** | `GroupedQualityTable` çš„ 7 åˆ—写死在代码常量里 |
| è¡¨æ ¼åˆ—「结论」(`合格Checkout`) | æ•´åˆ—丢弃 | åŒä¸Š |
| ä¸¤çº§è¡¨å¤´ã€Œæ£€éªŒç»“æžœ \| ç»“论」 | æ‹å¹³ | ç»„件只支持单层表头 |
| æ ·å“ä¿¡æ¯ 7 é¡¹ï¼ˆäº§å“åç§° / äº§å“æ•°é‡ / è§„æ ¼ / ç”Ÿäº§æ—¥æœŸ / æ‰¹å· / åœŸè±†å“ç§ / æœ‰æ•ˆæ—¥æœŸï¼‰ | åªå‰© 6 é¡¹ï¼Œä¸”与原件的 6 é¡¹**不是同一批** | `SampleInfo` çš„字段写死在代码常量里 |
| é¦–行栏目名 `产品名称Material Names` | æ¨¡åž‹æ‹¿é€šç”¨ç»„件硬凑成独立一行 | æ²¡æœ‰ä»»ä½•组件能承接「表头里的栏目名」 |
两处「写死」的位置:
- `mom-pro2-before/src/components/quality/inspection/grouped-quality-table.ts` çš„ `COLUMNS` å¸¸é‡ï¼ˆ7 é¡¹ï¼‰
- `mom-pro2-before/src/components/quality/header/sample-info.ts` çš„ `SAMPLE_FIELDS` å¸¸é‡ï¼ˆ6 é¡¹ï¼‰
---
## 2. ä¸¤æ¡ç¡¬çº¦æŸï¼ˆå…ˆè®²æ¸…楚,它们决定方案形状)
### 2.1 å±žæ€§åè®®åªè®¤æ ‡é‡â€”—不能用「数组属性」
`QualityProps = Record<string, boolean | number | string | undefined>`,
`QualityFieldType` åªæœ‰ `boolean | enum | number | string | text` äº”种。
属性最终落到画布节点的 `attributes` ä¸Šï¼ˆå†™ä½œ `data-qc-<key>`),而
`ReportTemplateSchema.QualityComponentNode.attributes` ä¹Ÿæ˜¯å­—符串映射。
⇒ **新增一个「列数组」属性类型要动协议本身**(属性面板控件、画布序列化、schema å¥‘约测试、
两侧渲染器),代价远超收益。列定义必须能被编码成**一个标量字符串**。
### 2.2 æ¸²æŸ“器不认识组件类型——不需要动渲染器
这是好消息,已核查:
- åŽç«¯ `HtmlRenderer.renderAttributes` åªåšä¸¤ä»¶ä¸Žç»„件无关的事:跳过 `data-quality-type` / `data-qc-*`、
  æ‹¦æŽ‰ `on*` ä¸Žå±é™©åè®®ï¼›**没有任何按组件类型分支的逻辑**,也没有属性白名单。
- åŽç«¯ `CanvasSafety.validate` æ˜¯é»‘名单(`script` ç­‰å¯æ‰§è¡Œæ ‡ç­¾ã€äº‹ä»¶å±žæ€§ã€æ•´æ®µ CSS),
  ä¸æ˜¯æ ‡ç­¾/属性白名单 â‡’ `colspan` / `rowspan` æœ¬æ¥å°±æ”¾è¡Œã€‚
- è¡Œå¾ªçŽ¯é  `data-qc-repeat` / `data-qc-repeat-row` è¿™ä¸¤ä¸ªé€šç”¨å±žæ€§é©±åŠ¨ã€‚
⇒ **本轮不需要改 `HtmlRenderer`、`BASE_CSS`、`CanvasSafety`**,也不需要改 schema ç‰ˆæœ¬å·ã€‚
组件侧改动集中在「注册表里的一个 definition」。这也正是质量组件协议的设计初衷
(`core/types.ts` å¼€å¤´ï¼šã€Œæ–°å¢žè´¨é‡ç»„件=注册一个新 definition,不需要改动 GrapesJS é›†æˆä»£ç ã€ï¼‰ã€‚
---
## 3. æ–¹æ¡ˆ
### 3.1 æ€»åŽŸåˆ™ï¼šä¸æ–°å¢žç»„ä»¶ï¼Œç»™çŽ°æœ‰ç»„ä»¶åŠ ã€Œå¯é€‰è¦†ç›–ã€å±žæ€§
**推荐做法**:给 `GroupedQualityTable` åŠ ä¸¤ä¸ª**可选**属性 `columns` / `headerSpans`;给 `SampleInfo`
加一个**可选**属性 `fields`。
- å±žæ€§**缺席时行为逐字不变**(`buildContent` é‡Œå›žè½åˆ°çŽ°æœ‰å¸¸é‡ï¼‰â‡’ å­˜é‡å’Œå·²å‘布模板零影响,
  ä¸éœ€è¦æ•°æ®è¿ç§»ï¼Œä¸éœ€è¦æ”¹ `defaults`。
- **不新增第三个检验表组件**。当前 13 ä¸ªç»„件里已经有两个检验表(`QualityTable` / `GroupedQualityTable`),
  å†åŠ ä¸€ä¸ªå°±æ˜¯ä¸‰ä¸ªåŒç±»ç«žäº‰ï¼Œ**极可能把刚刚修好的选型稳定性再次打破**(参见 Â§äºŒåå››ï¼š
  é€‰åž‹ä¸ç¨³çš„æ ¹å› å°±æ˜¯æç¤ºè¯é‡Œå¤šäº†ä¸€å¤„指向平铺表的措辞,组件数量增加是同一个机理)。
  ç”¨æˆ·çš„æ”¶ç›Šæ¥è‡ªã€Œåˆ—能改」,不是「多一个组件」。
### 3.2 åˆ—/字段描述的语法
**单行字符串**,列与列之间用 `|` åˆ†éš”,每列写作 `列标题=绑定表达式`:
```
序号={{index}}|检验项目={{item.itemName}}|检测方法={{item.checkMethod}}|标准要求={{item.standardValue}}|实测值={{item.actualValue}}|单位={{item.unit}}|判定={{item.resultText}}
```
三条约束,都来自 2.1:
1. **必须单行**:这串东西最终是画布节点的一个 HTML å±žæ€§å€¼ï¼Œæ¢è¡Œåœ¨å±žæ€§é‡Œæ˜¯åˆæ³•的但极易被
   ç¼–辑器/序列化环节改写,统一按单行处理。
2. **`=` åªä½œç¬¬ä¸€ä¸ªåˆ†éš”符**:标题与表达式都不允许含 `=`,表达式里的 `{{...}}` åŽŸæ ·ä¿ç•™ã€‚
3. **标题里不允许出现 `|`**;需要竖线时用全角 `|`。
**纵向合并列(分组表专用)**:列标题前加 `#` è¡¨ç¤ºã€Œè¿™ä¸€åˆ—要跨住整组」,沿用现有
`GroupedQualityTable` çš„ `rowspan` / `hidden` æœºåˆ¶ï¼š
```
#检验项目={{item.itemName}}|子项={{item.group.childName}}|...
```
### 3.3 ä¸¤çº§è¡¨å¤´ï¼ˆå¯é€‰ï¼‰
`headerSpans` æè¿°**上面那一层**表头,单元格写作 `标题^跨越列数`:
```
检验结果^3|结论^2
```
下层表头由 `columns` é‡Œå„列的标题自动拼出(顺序即列顺序)。约束:
**各单元格跨越列数之和必须等于 `columns` çš„列数**,否则保存时给出精确报错:
> è¡¨å¤´è·¨åˆ—数合计 5,检验表的实际列数为 7,两者必须相等。请检查「表头跨列」里各段末尾的 `^` æ•°å­—。
(按 `error-message-precision`:给出具体维度 + ä¸¤ä¾§å®žé™…值 + å¯æ‰§è¡ŒåŠ¨ä½œã€‚ï¼‰
### 3.4 æ ·å“ä¿¡æ¯å­—段可配
同一个语法,用在 `SampleInfo` ä¸Šï¼š
```
样品编号={{report.sampleNo}}|产品名称={{report.productName}}|规格={{report.spec}}|生产日期={{report.produceDate}}|批号={{report.batchNo}}|土豆品种={{report.potatoVariety}}|有效日期={{report.expireDate}}
```
每行放几组仍由现有的 `pairsPerRow` å±žæ€§æŽ§åˆ¶ï¼Œå¸ƒå±€é€»è¾‘不变。
---
## 4. å¿…须一并修的数据侧缺口:「检测方法」目前不在渲染作用域
这是本轮最容易漏掉、且**不修则新功能形同虚设**的一条。
| å±‚ | çŽ°çŠ¶ |
|---|---|
| MES å•据侧 `MesQcReportItemRespDTO` | **已有** `checkMethod`(检测方法)、`tool`(检测工具) |
| qcreport å¼•擎 `InspectionItem` | **没有**这两个字段 |
| `ReportContext.itemMap()` ä¸‹å‘的前端作用域 | åªæœ‰ `index / itemCode / itemName / standardValue / actualValue / unit / requirement / group.* / upperLimit / lowerLimit / result / resultText / remark` |
⇒ å°±ç®—列定义里写了 `检测方法={{item.checkMethod}}`,渲染期也取不到值,只会按「绑定取不到值」
记一条缺口、留一片空白。
**须补的链路**(缺一不可,且两侧字段名必须逐字一致):
1. `MesQcReportContextMapper`:把 DTO çš„ `checkMethod`(必要时含 `tool`)映射进 `InspectionItem`
2. `InspectionItem`:加字段 + è¿› `copy()`
3. `ReportContext.itemMap()`:下发到前端作用域
4. `ReportContextCodec`:冻结快照的读写(否则历史报告重渲染时丢值)
5. å‰ç«¯ `engine/context.ts` çš„ `ReportContextItem`:镜像同名字段
6. å¯¹æ‹ fixture ä¸Žç›¸å…³å•测
**注意**:`resultText`(判定)是**引擎算出来的**(`InspectionItem.applyResult`),不是 MES ç»™çš„ï¼›
「结论」列如果要的是人工拍的结论,还要确认它是否已有下发(`MesQcReportContextMapper` æ³¨é‡Šæåˆ°
「人工拍的检验判定,与引擎算出的 result/resultText/conclusion åˆ†åˆ—两处」)。这一条**待确认后再定**。
---
## 5. è¢«å¦çš„备选
| å¤‡é€‰ | å¦æŽ‰çš„原因 |
|---|---|
| åˆ—的列定义用 JSON å­—符串 | æ¨¡åž‹è¦è¾“出转义后的嵌套 JSON(`"columns":"[{\"label\":…}]"`),实测这类转义是模型出错的重灾区;属性面板里人也没法手改。单行语法信息量相同而两边都好写。 |
| ç»™ `QualityFieldType` åŠ æ•°ç»„ç±»åž‹ | è¦åŠ¨åè®®ã€å±žæ€§é¢æ¿ã€ç”»å¸ƒåºåˆ—åŒ–ã€schema å¥‘约测试,代价远超收益(见 2.1)。 |
| æ–°å¢žç‹¬ç«‹çš„「可自定列检验表」组件 | ä¸Ž `QualityTable` / `GroupedQualityTable` ä¸‰æ–¹ç«žäº‰ï¼Œé€‰åž‹ç¨³å®šæ€§é£Žé™©å¤§ï¼ˆè§ 3.1);用户还要维护两套同类组件。 |
| åšä¸€ä¸ªå¯è§†åŒ–的列编辑器(增删拖拽) | æ”¶ç›Šç¡®å®žæ›´é«˜ï¼Œä½†æˆæœ¬æ˜¯å‰è€…的数倍,且需要新的属性面板控件类型。**建议先用手填字符串验证价值**,真有需求再迭代——那时字符串语法已经是现成的存储格式,不用迁移。 |
| æŠŠ `SampleInfo` çš„ `dataSchema` æ”¹æˆåŠ¨æ€ | ç»‘定选择器只能枚举静态 `dataSchema`,动态化会让「选数据字段」入口失效。**保持静态**:`columns`/`fields` å±žæ€§è®¾ä¸º**不可绑定**(`bindable` ä¸å£°æ˜Žï¼‰ï¼Œç”±äººæ‰‹å†™å®Œæ•´ä¸²ã€‚ |
---
## 6. AI ä¾§çš„影响(本轮的重点风险)
新属性必须让模型能填,否则 AI å¯¼å…¥ä»äº§ä¸å‡ºè¿™äº›åˆ—。
1. **清单自动带上**:`buildQualityComponentCatalog()` æŠŠ `propertySchema` åŽŸæ ·æ˜ å°„æˆ `fields`,
   æ–°å±žæ€§å£°æ˜Žä¸º `type: 'text'` å³è‡ªåŠ¨è¿›æç¤ºè¯ï¼Œ**不需要改 `catalog.ts`**。
   ç”¨çŽ°æœ‰ `text` ç±»åž‹ï¼ˆè€Œä¸æ˜¯è‡ªé€ ä¸€ä¸ªæ–°ç±»åž‹ï¼‰è¿˜èƒ½è®©å±žæ€§é¢æ¿ç›´æŽ¥æ¸²æŸ“成多行输入框,零改动。
2. **提示词要补规则**:需要新增一条抽取规则,说明「原件里检验表除标准列之外还有别的列
   ï¼ˆæ£€æµ‹æ–¹æ³•、结论、备注等)时,用 `columns` æŠŠè¿™äº›åˆ—按 `标题=绑定` å†™è¿›åŽ»ã€ï¼Œ
   å¹¶ç»™å‡ºå¯ç”¨ç»‘定路径白名单。
3. **`aiHint` å¿…须重写**:`GroupedQualityTable` çš„ hint è¦è¯´æ˜Žã€Œåˆ—与本组件默认不同时用 `columns` è¦†ç›–」,
   ä¸”**不能**让模型以为「列不一致就要换组件」——否则又会回到选型不稳。
4. **验收口径**:AI å¯¼å…¥çš„验收必须包含「列是否与原件的列**逐列对齐**」,
   è€Œä¸åªæ˜¯ã€Œtype é€‰å¯¹äº†ã€ã€‚这是本轮从用户反馈里学到的:`GroupedQualityTable` é€‰å¯¹ä¸ç­‰äºŽæŠ¥å‘Šå¯¹äº†ã€‚
---
## 7. å½±å“é¢ï¼ˆé¢„估改动清单)
**前端(`mom-pro2-before/src/components/quality/`)**
- `inspection/grouped-quality-table.ts`:`propertySchema` åŠ  `columns` / `headerSpans`(`type: 'text'`);
  `buildContent` é‡Œè§£æžï¼Œç¼ºå¸­å›žè½çŽ°æœ‰ `COLUMNS`;`validate` åŠ è·¨åˆ—æ•°æ ¡éªŒ
- `header/sample-info.ts`:`propertySchema` åŠ  `fields`(`type: 'text'`);`buildContent` åŒç†å›žè½
- æ–°å¢žä¸€ä¸ª**共用的解析函数**(列说明串 â†’ åˆ—定义数组),两个组件共用,避免两份解析逻辑漂移
- `aiHint` ä¸Ž `validate` çš„æ–‡æ¡ˆ
**后端(`yudao-module-qcreport`)**
- æ•°æ®ä¾§ 6 å¤„,见 Â§4
- æç¤ºè¯ï¼š`QcReportTemplatePromptBuilder.EXTRACTION_RULES` åŠ ä¸€æ¡è§„åˆ™
- **不需要**动 `HtmlRenderer` / `BASE_CSS` / `CanvasSafety` / schema ç‰ˆæœ¬å·
**不动**:`docs/sql/config_export_all_*.sql`(无表结构、无配置数据变更)。
---
## 8. å…¼å®¹æ€§
- æ–°å±žæ€§éƒ½æ˜¯**可选**且**不写进 `defaults`** â‡’ å·²ä¿å­˜æ¨¡æ¿çš„ `attributes` é‡Œæ²¡æœ‰è¿™ä¸ªé”®ï¼Œ
  `buildContent` èµ°å›žè½åˆ†æ”¯ï¼Œäº§ç‰©é€å­—不变。
- `schemaVersion` ä¿æŒ `1.1`:`ReportTemplateSchemaCompatibilityTest` å®ˆçš„æ˜¯ã€Œæ—§ JSON è¯»å¾—出」,
  åŠ å±žæ€§ä¸å½±å“ï¼›`QualityComponentNode.attributes` æ˜¯å­—符串映射,新键天然兼容。
- å·²å‘布版本(冻结快照)不重算,不受影响。
---
## 9. éªŒè¯è®¡åˆ’
1. å•测:两类组件各覆盖「属性缺席 â†’ äº§ç‰©ä¸Žæ”¹å‰é€å­—一致」(防回归的**第一优先级**用例)、
   ã€Œå±žæ€§åœ¨åœº â†’ åˆ—æ•°/列序/表头跨列正确」、跨列数不匹配的报错文案。
2. è§£æžå‡½æ•°çš„纯函数单测:非法输入(缺 `=`、空标题、`|` å‡ºçŽ°åœ¨æ ‡é¢˜é‡Œï¼‰å„è‡ªæŠ¥ä»€ä¹ˆé”™ã€‚
3. å‰åŽç«¯ä¸€è‡´æ€§ï¼šæ–°åˆ—产出的 HTML è¦è¿‡ `FrontendConformanceTest` ä¸Ž `CanvasSafetyTest`;
   `rowspan`/`hidden` çš„分组机制在覆盖列之后仍要工作。
4. æ•°æ®ä¾§ï¼š`{{item.checkMethod}}` åœ¨çœŸå®žè´¨æ£€å•上取得到值(不起缺口告警)。
5. AI å¯¼å…¥ï¼šçœŸå®žæ–‡ä»¶ + ç©º hint,检查**列逐列对齐**,不只看 type。
6. å­˜é‡å›žå½’:`mvn -o -pl yudao-module-qcreport -am test` å…¨ç»¿ï¼›å‰ç«¯ `pnpm typecheck`。
---
## 10. æ˜Žç¡®ä¸åš
- ä¸åšå¯è§†åŒ–列编辑器(先验证字符串方案的价值)
- ä¸æ–°å¢žæ£€éªŒè¡¨ç»„ä»¶
- ä¸æ”¹å±žæ€§åè®®ï¼ˆä¸åŠ æ•°ç»„ç±»åž‹ï¼‰
- ä¸æ”¹ `HtmlRenderer` / `BASE_CSS` / `CanvasSafety`
- ä¸åЍ schema ç‰ˆæœ¬å·ã€ä¸åšæ•°æ®è¿ç§»
- ä¸æ”¹ `docs/sql/config_export_all_*.sql`
---
## 11. å¾…裁决
1. **`headerSpans`(两级表头)是否本轮就做**?不做的话「检验结果 | ç»“论」这层仍还原不了,
   ä½†åˆ—覆盖已经能解决「检测方法/结论列丢失」这个更大的缺口。
2. **「结论」列取哪个值**?人工拍的判定(`MesQcReportContextMapper` é‡Œæåˆ°çš„那一处)还是
   å¼•擎算的 `resultText`?取错会让报告结论与单据结论不一致,属于业务口径,需你确认。
3. **`tool`(检测工具)是否一并下发**?原件没有这一列,先不下发,需要时再加。
4. **样品信息的默认字段要不要保持现状**(6 é¡¹ï¼‰ï¼Ÿæ”¹äº†é»˜è®¤å€¼ä¼šè®©**新建**的样品信息块与现在不同。
   å»ºè®®ä¿æŒä¸å˜ï¼Œåªè®©ã€Œå¯é…ã€æˆä¸ºç”¨æˆ·èƒ½æ”¹çš„能力。
docs/qc_report_generate_from_qc_frontend_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,251 @@
# è´¨æ£€å• â†’ è´¨æ£€æŠ¥å‘Š å‡ºä»¶ å‰ç«¯è”调方案
> åŽç«¯æ¨¡å—:`yudao-module-qcreport` 接口前缀:`/admin-api/qc-report/instance`
> ä¸Šæ¸¸æ•°æ®æºï¼š`yudao-module-mes`(`MesQcReportApi`,本模块不直接读 MES è¡¨ï¼‰
> æœ¬è½®èŒƒå›´ï¼š**由 MES è´¨æ£€å•(IQC/IPQC/OQC/RQC)直接出报告**。前端只需选「哪张单据 + ç”¨å“ªå¼ æ¨¡æ¿ã€ï¼Œ
> æ•°æ®å…¨éƒ¨ç”±åŽç«¯ä»Ž MES å–,**前端不再自己拼 `context`**。
## æ¶‰åŠé¡µé¢
| é¡µé¢ | è·¯ç”± | è¯´æ˜Ž | çŠ¶æ€ |
|---|---|---|---|
| MES è´¨æ£€å•列表(IQC/IPQC/OQC/RQC å››ä¸ªé¡µé¢ï¼‰ | `/mes/qc/iqc`、`ipqc`、`oqc`、`rqc` | è¡Œæ“ä½œæ–°å¢žã€Œå‡ºæŠ¥å‘Šã€ï¼šé€‰æ¨¡æ¿ â†’ è°ƒæœ¬æŽ¥å£ â†’ è·³æŠ¥å‘Šå®žä¾‹åˆ—表(已带筛选条件) | **已落地** |
| æŠ¥å‘Šå®žä¾‹åˆ—表页 | `/qc/report-instance` | ç”ŸæˆåŽæŒ‰ `businessType` + `businessId` åæŸ¥å¾—到;搜索表单已加「业务单据」字段并支持路由带入 | å·²è½åœ°ï¼ˆ`views/mes/qc/report/instance/index.vue`) |
| æ¨¡æ¿è®¾è®¡å™¨ | `/qc/report-template` | æ— æ”¹åŠ¨ï¼ˆæ¨¡æ¿éœ€å…ˆåœ¨è®¾è®¡å™¨é‡Œé…å¥½æ£€éªŒé¡¹è¡¨æ ¼å¹¶å‘å¸ƒï¼‰ | å·²è½åœ° |
> ä¸Ž `qc_report_instance_frontend_integration.md` çš„分工:那份讲的是「调用方自己准备数据」的通用出件;
> æœ¬ä»½è®²çš„æ˜¯ã€Œæ•°æ®ä»Ž MES å–」的专用入口。两者最终都落到同一个实例表、同一个列表页。
## ä¸šåŠ¡æµç¨‹ä¸Žæ•°æ®å¸¦å…¥
1. **质检单 â†’ å‡ºæŠ¥å‘Š**:用户在质检单**列表行操作**里点「出报告」→ å‰ç«¯å¼¹çª—列出可选模板
   ï¼ˆ**只列出 `reportType` ä¸Žè¯¥å•据类型一致的模板**)→ è°ƒ `generate-from-qc`。
2. **后端六道校验**(按业务优先级,只报第一处,不一次抛多条):
   ç±»åž‹åˆæ³• â†’ å•据存在 â†’ å·²å®Œæˆæ£€éªŒ â†’ å·²å¡«åˆ¤å®š â†’ æ¨¡æ¿ç±»åž‹åŒ¹é… â†’ æœ‰å¯ç”¨æ£€éªŒé¡¹ã€‚
3. **后端取数并出件**:`MesQcReportApi` è£…配质检单四层结构(单头 / åˆ¤å®šä¾æ® / æ ·å“ / å®žæµ‹å€¼ï¼‰
   â†’ å½’一成报告上下文 â†’ èµ°**既有的** `generate` æµç¨‹ï¼ˆç‰ˆæœ¬å‘布校验、编号生成、快照冻结、判定引擎全部照旧)。
4. **结果回到前端**:弹窗内就地展示结果(`reportNo` / `itemCount` / `undecidableCount` / `warnings` / `errors`),
   åº•部「查看报告列表」跳转报告实例列表页并带上筛选条件。
5. **反查**:报告实例列表页用 `businessType=mes_qc_iqc` + `businessId=1` å³å¯æŸ¥å‡ºè¯¥å•据出过的所有报告。
**带入关系要点**
- `qcType` + `qcId` æ˜¯ä¸»çº¿ï¼Œä»Žè´¨æ£€å•列表行带入。`qcType` ç”¨å­—å…¸ `mes_qc_type` çš„**数值**(`1`/`2`/`3`/`4`)。
- `templateId` ç”±ç”¨æˆ·åœ¨å¼¹çª—里选。**前端必须先按 `reportType` è¿‡æ»¤æ¨¡æ¿åˆ—表**,否则用户会选到后端必然拒绝的组合。
- `version` ä¸ä¼ æ—¶åŽç«¯å–模板的 `currentVersion`(要求已发布)。设计器刚存了草稿想立刻出件才需要显式传。
- `reportNo` ä¸ä¼ ç”±åŽç«¯è‡ªåŠ¨ç”Ÿæˆï¼ˆ`QR + æ—¥æœŸ + 4 ä½æµæ°´`)。
- **列表 â†’ æŠ¥å‘Šå®žä¾‹çš„反查筛选是路由 query å¸¦å…¥**:跳转时带 `businessType`(表名,如 `mes_qc_oqc`)
  ä¸Ž `businessId`(质检单主键的字符串形式)两个参数,报告实例列表页在挂载时把它们预填进搜索表单,
  ç”¨æˆ·è½åœ°å³çœ‹åˆ°åˆšå‡ºçš„æŠ¥å‘Šï¼Œä¸éœ€è¦è‡ªå·±å†ç­›ä¸€éã€‚
## UI å…¥å£ï¼ˆå·²è½åœ°ï¼‰
| ä½ç½® | å…ƒç´  | å±•示规则 | è¯´æ˜Ž |
|---|---|---|---|
| è´¨æ£€å•列表行操作 | ã€Œå‡ºæŠ¥å‘Šã€æŒ‰é’® | **仅当 `status === å·²å®Œæˆ` æ—¶æ˜¾ç¤º** | ä¸Žã€Œç¼–辑」「删除」(仅草稿可见)互斥,避免用户对未完成的单据点 |
| å¼¹çª—标题 | `出报告 - {单据编号}` | â€” | ç¼–号由列表行带入,仅用于展示 |
| å¼¹çª—选模板区 | å•选模板列表 | ç”±åŽç«¯ `/qc-report/template/selectable` è¿”回:`reportType` ä¸Žæœ¬å•类型一致、`status=启用`、**`currentVersion` æŒ‡å‘的版本处于「已发布」、且该版本的画布有可渲染的内容** | æ— å¯é€‰é¡¹æ—¶ç»™ç©ºçŠ¶æ€æç¤ºï¼Œå¼•å¯¼å…ˆåŽ»æ¨¡æ¿è®¾è®¡å™¨é…å¥½å¹¶å‘å¸ƒ |
| å¼¹çª—结果区 | æˆåŠŸæç¤º + å‘Šè­¦åŒº | ç”ŸæˆæˆåŠŸåŽå°±åœ°åˆ‡æ¢ï¼Œä¸å…³å¼¹çª— | `undecidableCount > 0` ç”¨**蓝色信息条**(不是黄色告警):这项数里绝大多数是「过程参数」这类正常情况,标黄会在几乎每份报告上误报 |
| å¼¹çª—底部 | ã€ŒæŸ¥çœ‹æŠ¥å‘Šåˆ—表」 | ç»“果区才出现 | å…³å¼¹çª—后跳报告实例列表并带上 `businessType` + `businessId` |
> **弹窗标题不带类型中文名**(仍保持这个做法):`mes_qc_rqc` è¿™ä¸€è·¯æ›¾æœ‰æŽªè¾žåˆ†æ­§â€”—
> åŽç«¯æžšä¸¾ã€è¡¨æ³¨é‡Šã€èœå•权限子项都写作「退货检验」,只有菜单父项与 `mes_qc_type` å­—典写作「退料检验」。
> çŽ°å·²ç»Ÿä¸€ä¸º**「退货检验」**(理由:RQC çš„对象同时覆盖生产退料、销售退货、售后退货,
> ã€Œé€€è´§æ£€éªŒã€æ˜¯èƒ½è¦†ç›–三者的说法;且它本就是多数派)。标题仍不带类型名,少一处需要跟着变的文案。
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž | æƒé™ç  |
|---|---|---|---|
| POST | `/qc-report/instance/generate-from-qc` | ç”±è´¨æ£€å•生成报告(数据从 MES å–) | `qc-report:instance:generate` |
| GET | `/qc-report/template/selectable` | å–「可用于出件」的模板列表 | `qc-report:template:query` |
> `qc-report:instance:generate` æ˜¯**独立权限码**,与 `:create`、`:query` éƒ½ä¸é€šç”¨ã€‚
**`GET /qc-report/template/selectable` è¯·æ±‚参数:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|---|---|---|---|
| `reportType` | String | æ˜¯ | æŠ¥å‘Šç±»åž‹ï¼Œå³ `mes_qc_type` çš„**数值字符串**,如 `"1"` / `"4"`。传 `"IQC"` è¿™ç±»è‹±æ–‡åä¼šä¸€ä¸ªéƒ½æŸ¥ä¸åˆ° |
**响应:** `Template[]`(**数组,不是分页结构**),字段与模板分页接口一致。
**为什么单开这个接口,而不是在前端过滤模板分页结果:**
两条过滤条件都落在**版本表**上,分页接口返回的 `currentVersion` åªæ˜¯æ¨¡æ¿è¡¨ä¸Šçš„一个字符串,看不到版本状态与画布内容。
1. **版本未发布**:停用一个版本**不会**清空模板的 `currentVersion`,于是会出现「`currentVersion` æœ‰å€¼ã€ä½†é‚£ä¸ªç‰ˆæœ¬å·²åœç”¨ã€çš„æ¨¡æ¿ï¼›è¿™ç§æ¨¡æ¿é€‰ä¸­åŽå‡ºä»¶å¿…然失败(`1_070_101_003`)。
2. **画布没有可渲染内容**:GrapesJS æŠŠã€Œæ‰“开过设计器但一个组件都没放」的项目存成 `{"assets":[],"styles":[]}`——它<b>非空</b>,所以旧判据会放它过去,渲染时又取不到根组件、body æ¸²æŸ“成空串,**不报错**,用户最终拿到一张白纸 PDF。
两条都是「看起来能选、选了必出问题」,判断它们要读版本行,放在后端做才是单点;
前端拿着分页结果自己推导会得到「看起来对但会漏」的结论。
> å·²è¡¥å…¥ `system_menu`:`id=1075416`,名称「报告实例质检单出件」,类型 3(按钮),
> æŒ‚在「报告实例」(`parent_id=1075410`)下,`sort=5`。非超管角色需授予该菜单后才可见按钮 / å¯è°ƒç”¨æŽ¥å£
> ï¼ˆè¶…管由代码直返全部菜单,不受影响)。
### è¯·æ±‚参数
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|---|---|---|---|
| `qcType` | Integer | **是** | è´¨æ£€ç±»åž‹ï¼š`1` æ¥æ–™æ£€éªŒ(IQC) / `2` è¿‡ç¨‹æ£€éªŒ(IPQC) / `3` å‡ºè´§æ£€éªŒ(OQC) / `4` é€€è´§æ£€éªŒ(RQC) |
| `qcId` | Long | **是** | è´¨æ£€å•编号(四张表各自的主键) |
| `templateId` | Long | **是** | æŠ¥å‘Šæ¨¡æ¿ç¼–号。**其 `reportType` å¿…须与 `qcType` ä¸€è‡´** |
| `version` | String | å¦ | æ¨¡æ¿ç‰ˆæœ¬å·ï¼Œå¦‚ `v1.0`。不传则取模板的 `currentVersion` |
| `reportNo` | String | å¦ | æŠ¥å‘Šç¼–号。传了就用它(撞号会报错),不传由后端自动生成 |
**请求示例**
```json
{
  "qcType": 1,
  "qcId": 1,
  "templateId": 1,
  "version": "v1.1"
}
```
### å“åº”字段
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|---|---|---|
| `id` | Long | æŠ¥å‘Šå®žä¾‹ç¼–号,拿它去查详情 / è·³è½¬ |
| `reportNo` | String | æœ€ç»ˆç”Ÿæ•ˆçš„æŠ¥å‘Šç¼–号 |
| `templateVersion` | String | æœ¬æ¬¡ä½¿ç”¨çš„æ¨¡æ¿ç‰ˆæœ¬å· |
| `itemCount` | Integer | å†™å…¥æŠ¥å‘Šçš„æ£€éªŒé¡¹æ¡æ•° |
| `undecidableCount` | Integer | **没有判定结论的项数**(「无判定规则」+ è§„则执行失败的「待判定」),见「业务规则」 |
| `warnings` | Array\<String\> | è½¯æç¤ºï¼šæ ·å“çš„取舍、没成组的分组项、为组头补出来的行、指标已删除、未录入实测值 |
| `errors` | Array\<String\> | æ¸²æŸ“期数据缺口(与通用出件接口同一套机制) |
**响应示例**
```json
{
  "code": 0,
  "data": {
    "id": 38,
    "reportNo": "QR20260919-0001",
    "templateVersion": "v1.1",
    "itemCount": 14,
    "undecidableCount": 5,
    "warnings": [
      "以下 2 ä¸ªåˆ†ç»„项在本单的检验明细里没有自己的行(多半是检验模板只选了子项),报告里为它们各补了一行来显示组名与公式:「水分、酸含量」",
      "以下 1 ä¸ªæŒ‡æ ‡æ˜¯æŒ‡æ ‡åº“里的分组项,但本单没有属于它们的子项,报告里按独立检验项列出、组名不合并:「色度」"
    ],
    "errors": []
  }
}
```
### æŠ¥å‘Šä¸Šä¸‹æ–‡æ–°å¢žå­—段(设计器可绑定)
出件时后端写进报告的每个检验项上,新增了下面 4 ä¸ªå­—段。它们不在这条接口的响应里,而是体现在**报告上下文**中,
供模板在设计器里绑定(「分组检验项表」组件已内置这些绑定,一般不需要手工配):
| å­—段 | ç±»åž‹ | å–值 | è¯´æ˜Ž |
|---|---|---|---|
| `item.requirement` | String | ç»„头行 = è¯¥æŒ‡æ ‡çš„公式;其余行 = å•据上的标准要求 | ã€Œæ£€æµ‹è¦æ±‚」列。与 `standardValue` å¹¶å­˜ä¸åˆå¹¶ï¼šä¸¤è€…来源不同(一个是指标主数据的公式,一个是单据行的标准要求) |
| `item.group.childName` | String | ç»„内子项 = è‡ªå·±çš„名字;组头行与独立项 = ç©ºä¸² | ã€Œå­é¡¹ã€åˆ— |
| `item.group.span` | Integer | ç»„内第一行 = è¯¥ç»„总行数;同组其余行与独立项 = `1` | ã€Œæ£€éªŒé¡¹ç›®ã€åˆ—的合并行数 |
| `item.group.hidden` | String | ç»„内第一行与独立项 = ç©ºä¸²ï¼›åŒç»„其余行 = `hidden` | `hidden` å±žæ€§é ã€Œåœ¨ä¸åœ¨ã€èµ·ä½œç”¨ï¼ˆå†™æˆ `hidden="false"` ç…§æ ·éšè—ï¼‰ï¼Œæ‰€ä»¥åªæœ‰ã€Œç©ºä¸² = è¿™ä¸€æ ¼å¯è§ã€è¿™ä¸€ä¸ªå¯åˆ¤å®šçš„形态 |
**没有分组的报告不会带 `group`**:整个报告一个分组都没有时,`item.group` è¿™ä¸ªé”®ä¸å‡ºçŽ°ï¼Œæ¨¡æ¿é‡Œä¹Ÿå°±ä¸ä¼šå¤šå‡ºä¸€å±‚ç©ºæ ¼å­ã€‚
一旦报告里有分组,则**每一项都会带 `group`**(独立项给空串与 `1`)—— å­—段缺席会让合并属性被跳过,反而把该隐藏的格子露出来。
**两个字段必须成对使用**:`item.group.span` ä¸Ž `item.group.hidden` åªä½œç”¨åœ¨ã€Œæ£€éªŒé¡¹ç›®ã€è¿™ä¸€åˆ—上。
不要把它们绑到别的列(如「检测要求」):组内子项各自的检测要求要逐行显示,并进组头格会把它们翻掉。
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|---|---|---|
| `undecidableCount` | ç”ŸæˆæˆåŠŸåŽçš„æç¤ºæ¡ | **不要当成故障报警**。它数的是「没有结论的项」,其中绝大多数是「过程参数」这类正常情况(试样质量 m、称量瓶 m0 â€”— åªä¾›å…¬å¼å–数,本来就没有可判定的规格)。提示文案建议「本次有 N é¡¹æ²¡æœ‰åˆ¤å®šç»“论」;**若确实要区分**「正常无规则」与「规则没跑通」,请以报告详情里各检验项的 `resultText` ä¸ºå‡†ï¼ˆ`无判定规则` / `待判定`),不要用这个总数反推 |
| `warnings` | ç”ŸæˆæˆåŠŸåŽçš„æç¤ºæ¡ | å»ºè®®å¯æŠ˜å ï¼›å†…容已经是完整中文句子,直接原文展示即可,不要改写 |
| `errors` | åŒä¸Š | æ•°æ®ç¼ºå£ï¼Œä¸Žé€šç”¨å‡ºä»¶æŽ¥å£åŒä¹‰ |
| `reportNo` | æˆåŠŸæç¤ºã€è·³è½¬åŽè¯¦æƒ…é¡µå¤´éƒ¨ | **业务唯一键**,建议带「复制编号」 |
| `id` | ä¸å±•示 | ç”¨æ¥è·³è½¬ `/qc/report-instance` è¯¦æƒ… |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|---|---|
| **谁提供数据** | åŽç«¯ã€‚前端**不要**自己拼 `context` ä¼ è¿›æ¥ï¼Œæœ¬æŽ¥å£ä¸æŽ¥å— `context` å‚æ•° |
| **类型不合法** | `qcType` ä¸åœ¨ 1~4 â†’ `1_070_105_000`,报文里列出收到的值与四个合法值 |
| **单据不存在** | â†’ `1_070_105_001`,报文带单据类型中文名与 ID |
| **单据未完成检验** | çŠ¶æ€ä¸æ˜¯ã€Œå·²å®Œæˆã€â†’ `1_070_105_002`,报文带**当前状态中文名**,用户一眼知道卡在哪一步 |
| **单据没有判定** | å·²å®Œæˆä½† `checkResult` ä¸ºç©º â†’ `1_070_105_003`,报文列出四个可选判定 |
| **模板类型不符** | æ¨¡æ¿ `reportType` â‰  `qcType` â†’ `1_070_105_005`,报文**同时给出模板类型与单据类型两边的中文名**。**前端应在选模板时就把不符的过滤掉**,这条是兜底 |
| **模板没配报告类型** | æ¨¡æ¿ `report_type` ä¸ºç©º â†’ åŒæ · `1_070_105_005`,但报文改成提示「请先给该模板设置报告类型」 |
| **没有可用检验项** | â†’ `1_070_105_006`,报文说明具体原因(如「只有分组标题」) |
| **能出多份报告吗** | **能**。同一张质检单可以反复出件,每次生成一份独立报告(历史留痕需要)。**不做重复生成拦截**,前端也不必拦 |
| **检验项怎么来的** | è´¨æ£€å•「判定依据行」(`max_threshold`/`min_threshold`/标准值)与「实测值明细」(按样品 Ã— æŒ‡æ ‡ï¼‰**交叉**展开。**不做任何指标过滤**,全部进报告 |
| **多个样品怎么办** | åŒä¸€ä¸ªï¼ˆæ ·å“ Ã— æŒ‡æ ‡ï¼‰å±•开成**一条**报告检验项。样品编号只在**样品数 > 1** æ—¶æŠ˜è¿›è¯¥é¡¹çš„备注(如「样品 S1」),单一样品时不写,避免污染 |
| **样品太多怎么办** | æ ·å“æ•°è¶…过 3 æ—¶ï¼ŒæŠ¥å‘Šå¤´çš„「样品编号」字段**留空**并发 `warnings`(列出前 3 ä¸ªä¸Žå‰ N æ€»æ•°ï¼‰ï¼Œä¸æˆªæ–­æ˜Žç»†æ•°æ® |
| **分组会怎样(本轮改动)** | åˆ†ç»„项**不再被跳过**。分组父项(如「水分」)作为一行进报告,组内子项紧随其后,组名在报告上纵向合并 â€”— æŠ¥å‘Šèƒ½çœ‹å‡ºå“ªäº›å­é¡¹å±žäºŽå“ªä¸€ç»„,父项的公式也不再丢 |
| **组名与公式放哪** | ç»„名放「检验项目」列并向下合并;公式放组头那一行的「检测要求」列。**组内子项各自的检测要求仍逐行保留**,二者不互相覆盖 |
| **谁是组头** | åŽç«¯æŒ‰æŒ‡æ ‡çš„ `parent_id` åˆ¤å½’属(不看行序、不看数值类型),并给每行下发 `group.span`(合并几行)与 `group.hidden`(这一格要不要让位)。前端**不需要也不会**自己算合并行数 |
| **分组项本单没有子项** | æŒ‰ç‹¬ç«‹æ£€éªŒé¡¹åˆ—出、组名不合并,并把名字收进 `warnings`(「…是指标库里的分组项,但本单没有属于它们的子项…」) |
| **组头本单没有行** | è‹¥æ£€éªŒæ¨¡æ¿åªå‹¾äº†å­é¡¹ï¼ŒåŽç«¯**为组头补一行**来放组名与公式,并在 `warnings` é‡Œè¯´æ˜Žä¸ºå“ªå‡ ä¸ªç»„头补了行。补出来的行实测值为空,判定为「无判定规则」,不影响合格率与结论 |
| **指标被删了会怎样** | å•据行引用了一个已被删除的指标 â†’ è¯¥æ¡è·³è¿‡ï¼Œ`warnings` é‡ŒæŠ¥ã€Œæœ‰ N æ¡æ˜Žç»†æ‰€å¼•用的检验指标已被删除」 |
| **没录实测值会怎样** | è¯¥é¡¹ç…§å¸¸è¿›æŠ¥å‘Šï¼Œä½† `warnings` æç¤ºã€Œç¬¬ N é¡¹ã€ŒX」尚未录入实测值」;**组头的锚点行除外**(它按定义没有实测值,值都在子项行上,报出来是误报) |
| **报告行序变了** | ä»Žæ­¤å‰çš„「行表顺序」改为按指标的 `sort_order` å‡åºï¼ˆç¼ºå¤±æŽ’最后,同值保持行序),组内子项按各自的 `sort_order`。这样报告顺序与检验单的设计意图一致;默认不需要前端做任何事 |
| **哪张画布组件能渲染分组** | éœ€è¦æ¨¡æ¿é‡Œç”¨**「分组检验项表」(`GroupedQualityTable`)**。用它之外的表(如基础「检验项表格」)不会出现合并效果 â€”— æ•°æ®ç…§å¸¸ä¸‹å‘,只是模板没画。二者用其一:有分组用前者,纯平层用后者 |
| **判定谁算** | æŠ¥å‘Šçº§çš„ `result`/`resultText`/`conclusion`/`passRate` ç”±**后端判定引擎**算(逐项规则 â†’ è§„格上下限 â†’ æ•°æ®è‡ªå¸¦ç»“果),与通用出件一致 |
| **质检单的原判定去哪了** | å­˜è¿›æŠ¥å‘Šçš„ `report.qcResult` / `report.qcResultText` ä¸¤ä¸ª**新字段**,与引擎判定**并存、互不覆盖**。质检单上的判定是人工拍的板(合格/特采/不合格退货/不合格报废),引擎只有三态装不下它,所以分开存 |
| **`undecidableCount` æ€Žä¹ˆç®—** | æ•°ã€Œå¿«ç…§é‡Œ `result` ä¸ºç©ºä¸²çš„项」。这正是引擎对「待判定」的定义,也顺带把「卡在规则报错上」的项算进去,不另算一遍判定条件 |
| **原判定是数字** | `qcResult` æ˜¯æ•°å­—字符串(`"1"`~`"4"`),`qcResultText` æ‰æ˜¯ä¸­æ–‡ï¼ˆåˆæ ¼/特采/不合格退货/不合格报废)。**画布上显示 `qcResultText`** |
| **模板必须已发布** | ä¸Žé€šç”¨å‡ºä»¶ä¸€è‡´ï¼Œè‰ç¨¿ç‰ˆæœ¬ä¼šè¢«æ‹’(`1_070_101_003`)。复用既有错误码,本接口不另造 |
| **模板已停用** | å¤ç”¨ `1_070_100_002` |
| **报告编号撞号** | å¤ç”¨ `1_070_102_001`。已软删除的编号**不回收** |
| **数据快照冻结** | å‡ºä»¶æ—¶æŠŠåˆ¤å®šåŽçš„æ•°æ®æ•´ä»½å†»ç»“。MES é‡Œçš„单据后来怎么改,**都不影响这份历史报告** |
## æ³¨æ„äº‹é¡¹
- **`engine/context.ts` çš„ `qcResult` / `qcResultText` å·²è¡¥é½**(类型接口 + `emptyReport()` ç©ºä¸²é»˜è®¤å€¼ï¼‰ï¼Œ
  è®¾è®¡å™¨çš„绑定面板里现在可以选到这两个字段,画布上也能绑。若要用它们,模板需自行绑到某个文本组件上。
- **模板列表要按 `reportType` è¿‡æ»¤**:模板的 `report_type` å­˜çš„æ˜¯å­—典数值字符串(`"1"`/`"2"`/`"3"`/`"4"`),
  ä¸Ž `qcType` **直接相等比较**即可,不要拿 `"IQC"` è¿™ç±»è‹±æ–‡ååŽ»æ¯”ã€‚
- **`qcType` ä¼ æ•°å€¼ä¸ä¼ å­—符串名**:后端按 `1`~`4` è¯†åˆ«ï¼Œä¼  `"IQC"` ä¼šå¾—到 `1_070_105_000`。
- **不要缓存 `warnings`**:它只在本次响应里出现,**不落库**。事后回看历史报告时看不到当时有哪些提示。
  éœ€è¦ç•™ç—•请在出件成功时自己记一份。
- **`warnings` ä¸Ž `errors` åˆ†å·¥**:`warnings` æ˜¯ã€Œæ•°æ®å–舍的软提示」(跳过、截断、缺值),
  `errors` æ˜¯ã€Œæ¨¡æ¿ç»‘定了但没数据的字段」这类渲染期缺口。**两者都要展示**,不要只显示其中一个。
- **`itemCount` ä¸ç­‰äºŽè´¨æ£€å•的行数**:指标被删会被跳过、一个指标多条实测值会展开成多条、
  ç»„头在本单没有行时后端会补一行,所以这个数**只能用来展示**,不要拿它去和质检单行数做断言。
- **部分字段刻意不带过去**:MES ä¾§æ£€éªŒé¡¹ä¸Šçš„「检验方法」(`checkMethod`)与「检验工具」(`tool`)
  åœ¨æŠ¥å‘Šä¸Šä¸‹æ–‡é‡Œ**没有承载位**,本轮未传递。若报告要展示这两列,需要先扩 `InspectionItem` å¥‘约
  ï¼ˆé‚£æ˜¯æ”¹åŠ¨å‰ç«¯å¯è§çš„æ•°æ®å¥‘çº¦ï¼Œéœ€å•ç‹¬ç¡®è®¤åŽå†åšï¼‰ã€‚
- **质检单上没有「部门」**:报告的 `department` å­—段恒为空,不要拿别的字段硬凑。
- **批次号取哪个字段按类型不同**:来料检验取供应商批号,出货/退货取批次号,过程检验两者都没有(只有工单号)。这是后端按业务语义定的,前端不用管。
- **报告实例落库时 `businessType` æ˜¯è¡¨å**(`mes_qc_iqc` / `mes_qc_ipqc` / `mes_qc_oqc` / `mes_qc_rqc`),
  ä¸Žå‰ç«¯è·¯ç”±/字典里的英文枚举同名但与字典数值不同,反查时**直接用这四个字符串**。
- **失败时不要重试出件**:五道校验失败都是「数据或选择不对」,重试同样会失败。按报文提示去改数据或换模板。
- **失败报文由请求拦截器自动弹出,业务代码不要再弹一次**:`code != 0` æ—¶æ‹¦æˆªå™¨å·²æŠŠåŽç«¯ `message`
  æ˜¾ç¤ºæˆ toast,调用处只写 `catch {}` å³å¯ï¼Œé‡å¤æç¤ºä¼šè®©ç”¨æˆ·çœ‹åˆ°ä¸¤æ¡ä¸€æ ·çš„æŠ¥é”™ã€‚
- **权限**:本接口权限码 `qc-report:instance:generate`,已补入 `system_menu`(`id=1075416`)。
## é”™è¯¯ç 
### æœ¬æŽ¥å£æ–°å¢žï¼ˆ`1_070_105_xxx`)
| é”™è¯¯ç  | æŠ¥æ–‡ | è§¦å‘场景 |
|---|---|---|
| `1_070_105_000` | è´¨æ£€ç±»åž‹ã€Œ9」不合法,只支持 IQC(来料检验)、IPQC(过程检验)、OQC(出货检验)、RQC(退货检验) | `qcType` ä¸åœ¨ 1~4 |
| `1_070_105_001` | æ¥æ–™æ£€éªŒçš„质检单(ID=999999)不存在或已被删除,请刷新列表后重新选择 | `qcId` æŸ¥ä¸åˆ° |
| `1_070_105_002` | æ¥æ–™æ£€éªŒçš„质检单「IQC20260919001」当前状态为「草稿」,尚未完成检验,不能生成报告。请先把该单据提交并完成检验判定后再生成 | å•据状态非「已完成」 |
| `1_070_105_003` | æ¥æ–™æ£€éªŒçš„质检单「IQC20260919001」尚未填写检验判定(判定为空),不能生成报告。请先在该单据上填写判定结论(合格 / ç‰¹é‡‡ / ä¸åˆæ ¼é€€è´§ / ä¸åˆæ ¼æŠ¥åºŸï¼‰åŽå†ç”Ÿæˆ | å·²å®Œæˆä½† `checkResult` ç©º |
| `1_070_105_005` | æŠ¥å‘Šæ¨¡æ¿çš„æŠ¥å‘Šç±»åž‹æ˜¯ã€Œå‡ºè´§æ£€éªŒã€ï¼Œä¸Žæœ¬æ¬¡è´¨æ£€å•的类型「来料检验」不一致,无法生成报告。请重新选择一张来料检验的报告模板 | æ¨¡æ¿ä¸Žå•据类型不符(或模板未配类型) |
| `1_070_105_006` | æ¥æ–™æ£€éªŒçš„质检单「IQC20260919001」(ID=1)没有任何检验指标,无法生成报告。请先在该单据上录入检验指标与实测值后再生成 | å•据没有任何检验指标行。若其中部分明细引用的指标已被删除,报文会在括号里补一句「其中 N æ¡æ˜Žç»†æ‰€å¼•用的检验指标已被删除」 |
### å¤ç”¨æ—¢æœ‰é”™è¯¯ç ï¼ˆæœ¬æŽ¥å£ä¸å¦é€ å‰¯æœ¬ï¼‰
| é”™è¯¯ç  | æŠ¥æ–‡ | è§¦å‘场景 |
|---|---|---|
| `1_070_100_000` | æ¨¡æ¿ä¸å­˜åœ¨ | `templateId` æ‰¾ä¸åˆ° |
| `1_070_100_002` | è¯¥æ¨¡æ¿å·²åœç”¨ï¼Œä¸èƒ½ç”ŸæˆæŠ¥å‘Š | æ¨¡æ¿ `status=1` |
| `1_070_101_000` | æ¨¡æ¿ç‰ˆæœ¬ä¸å­˜åœ¨ | `version` ä¼ äº†ä½†æŸ¥ä¸åˆ° |
| `1_070_101_003` | è¯¥æ¨¡æ¿ç‰ˆæœ¬æœªå‘布 | `version` æŒ‡å‘草稿 |
| `1_070_101_007` | è¯¥æ¨¡æ¿è¿˜æ²¡æœ‰å·²å‘布的版本,无法生成报告,请先在设计器中发布一个版本 | ä¸ä¼  `version` ä¸” `currentVersion` ä¸ºç©º |
| `1_070_101_008` | è¯¥æ¨¡æ¿ç‰ˆæœ¬æ²¡æœ‰ç”»å¸ƒå†…容,无法生成报告,请先在设计器中设计并保存模板内容 | ç‰ˆæœ¬æœ‰è®°å½•但画布空 |
| `1_070_102_001` | æŠ¥å‘Šç¼–号「X」已被占用,请换一个编号,或留空由系统按 QR+日期+流水 è‡ªåŠ¨ç”Ÿæˆ | æŒ‡å®šäº†é‡å¤ç¼–号 |
> **与原始方案文档的偏离(已落地口径)**:方案文档 Â§7 é‡Œåˆ—çš„ `105_004`(模板不存在)与 `105_007`(模板无已发布版本)
> **未实现**,改为复用上面的既有码。理由:那几条文案本来就是精确的,再造一遍只会多两条会漂移的副本。
docs/qc_report_grouped_inspection_design.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,187 @@
# å¤æ‚检验项(分组检验项)还原 â€” éœ€æ±‚与方案
> çŠ¶æ€ï¼š**已定稿,开工**。契约与结构见第四节,实现清单见第五节。
> å…³è”:`docs/qc_report_ai_import_frontend_integration.md`(AI å¯¼å…¥è”调)、`docs/智能质检报告平台-开发进度.md`(进度留痕)。
>
> å˜æ›´è®°å½•(2026-09-19):用项目自带的 `grapesjs@0.23.6` + Chromium å®žæµ‹åŽï¼Œ**推翻了初稿「用绑定样式隐藏重复单元格」的写法**(实测不可行,见第三节「隐藏通道」),改为 `hidden` å±žæ€§ï¼›åŒæ—¶æŠŠç”¨æˆ·å·²æ‹æ¿çš„两条口径(无判定规则、公式落格)与原始 docx çš„实测结构写进契约。
## ä¸€ã€èƒŒæ™¯ä¸Žç›®æ ‡
原型来自 `百事模版.docx`:检验项是**两层**的 â€”— ä¸€ä¸ªåˆ†ç»„(水分 / é…¸å«é‡ / æ­£ä¸é†‡å«é‡ï¼‰ï¼Œç»„内挂若干子项(过程参数 + ç»“果)。这种结构目前无法还原,报告里表现为「一堆散装参数行,看不出属于哪一组,公式也丢了」。
实测 `百事模版.docx` çš„æ£€éªŒç»“果表(`word/document.xml`,27 è¡Œï¼‰ï¼š**项目列用 `w:vMerge` çºµå‘合并**跨住整组的行,子项单独占一列,其余列(检测方法/标准要求/结果)逐行独立。即「组名合并、子项分行」是这份报告的原生版式 â€”— æœ¬æ–¹æ¡ˆè¦è¿˜åŽŸçš„å°±æ˜¯å®ƒã€‚
**已拍板的业务口径**(用户确认):
| å†…容 | å£å¾„ |
|---|---|
| çˆ¶é¡¹ï¼ˆåˆ†ç»„)的「检测要求」那一格 | æ˜¾ç¤º `mes_qc_indicator.formula_text` å…¬å¼ |
| ç»„内子项,如「水分结果 w」「酸含量结果 w3」 | æŒ‰æœ¬èº«ç»“构显示,不做特殊处理 |
| æ²¡æœ‰è§„格上下限的项 | åˆ¤å®šåˆ—显示「无判定规则」,**不计入合格率分母、不参与报告结论**(已于上一轮实现,见 `docs/智能质检报告平台-开发进度.md`) |
## äºŒã€æ–­å±‚:层级存在于数据库,但有三处被切断
实测 `mes_qc_indicator`(宿主 `2a8dea946ada`):
| id | name | item_type | parent_id | result_type | formula_text | sort_order |
|---|---|---|---|---|---|---|
| 21 | æ°´åˆ† | 2 | 0 | NULL | `w = (m1 - m0) / m Ã— 100%` | 30 |
| 22–25 | è¯•样质量 m / è¯•æ ·+称量瓶 m1 / ç§°é‡ç“¶ m0 / æ°´åˆ†ç»“æžœ w | 1 | 21 | 1 | â€” | 1–4 |
| 26 | é…¸å«é‡ | 2 | 0 | NULL | `w3 = c Ã— V Ã— 0.06005 / m Ã— 100%` | 40 |
| 27–30 | æ ‡æ¶²æµ“度 c / æ¶ˆè€—滴定液体积 V / è¯•样质量 m / é…¸å«é‡ç»“æžœ w3 | 1 | 26 | 1 | â€” | 1–4 |
| 31 | æ­£ä¸é†‡å«é‡ | 2 | 0 | NULL | `纯度 = 100% - æ°´åˆ† - é…¸å«é‡ - å…¶å®ƒæ‚è´¨` | 50 |
| 32 | æ­£ä¸é†‡å«é‡ /% | 1 | 31 | 1 | â€” | 1 |
| 19/20/33/34 | å¤–è§‚ / è‰²åº¦ / å¯†åº¦ / æŠ˜å…‰çއ | 1 | 0 | 3/1 | â€” | 10/20/60/70 |
**归属依据是 `parent_id`,不是行序**:`item_type=2` æ˜¯åˆ†ç»„头,子项 `sort_order` æŒ‰ç»„内重新从 1 æŽ’;而**行表里父项那一行与它的子项并不相邻**(iqc_id=1 é‡Œ æ°´åˆ† åœ¨ç¬¬ 1 è¡Œï¼Œå®ƒçš„子项在第 11–14 è¡Œï¼‰ã€‚
附带实测(同一次查询):该单**所有行**的 `standard_value` / `max_threshold` / `min_threshold` / `check_method` **全为 NULL** â€”— è¿‡ç¨‹å‚数没有规格,组头更是只有公式。这条决定了「检测要求列放公式」不会覆盖掉任何已有内容。
三处断层:
1. `MesQcIndicatorDO` åªæ˜ å°„ `id/code/name/type/tool/resultType/resultSpecification/remark` â€”— `parent_id`、`item_type`、`formula_text`、`unit_text`、`sort_order` ä¸€ä¸ªéƒ½æ²¡æ˜ å°„,层级到 DO å°±ä¸¢ã€‚
2. `MesQcReportItemRespDTO` æ²¡æœ‰ä»»ä½•父/组字段,类注释明写「报告侧不需要知道样品与实测值的层级关系,逐行渲染即可」。
3. `MesQcReportApiImpl.buildItems` ç”¨ `indicator.getResultType() == null` å½“「这是分组标题」的判据,把父项丢进 `groupItemNames` èµ° warnings è€Œä¸æ˜¯å…¥æŠ¥å‘Šã€‚**方向是反的**:被丢掉的恰好是带公式的组名(水分/酸含量/正丁醇含量),留下来的恰好是过程参数行。
## ä¸‰ã€å¼•擎能表达什么:探针实测结论
脚本 `.qc-conformance/verify-group-repeat.ts`(只读,不改代码不动库),产物 `group-probe-p4.html`、`group-probe-p5.html`。
**先决约束**(读 `report-evaluator.ts:237` + `ReportContext.itemMap` å¾—到):判定阶段把上下文重建成 `{ report, inspectionItems }` **两个键**,两侧一致收口。任何自定义顶层数组(`inspectionGroups` ä¹‹ç±»ï¼‰éƒ½è¿›ä¸äº†æ¸²æŸ“作用域 â€”— æŽ¢é’ˆ P3 å®žæµ‹æ¸²æŸ“出 0 è¡Œï¼Œä¸”不报错。**分组信息只能挂在每个检验项上。**
| ç»“æž„ | ç»“æžœ |
|---|---|
| P1 æ‰å¹³äº¤é”™ï¼ˆç»„头行与明细行同一套单元格,无 rowspan) | å¯ç”¨ï¼›ä½†ç»„头行必须是数组里的一项 â†’ **污染 total / passCount** |
| P2 æ¯è¡Œé¦–列都写 `rowspan` | æ¸²æŸ“出 `rowspan="5"`、`rowspan="1"`,但每行都自带首列 â†’ åˆ—错位,**不合并且更糟** |
| P3 é¡¶å±‚自定义数组 | æ¸²æŸ“ 0 è¡Œï¼ˆä¸Šä¸‹æ–‡è¢«æ”¶å£ï¼Œé™é»˜ä¸¢å¼ƒï¼‰ |
| P4 åµŒå¥— repeat(外层按组、`tbody` å†…再按 `children`) | å¯ç”¨ï¼Œ`rowspan` ç»‘定生效、顺序正确;但组必须作为一项进数组 â†’ åŒæ ·æ±¡æŸ“统计;且需要 `tbody` å¥— `tbody` |
| **P5 æ‰å¹³ + rowspan + éšè—é‡å¤å•元格** | **可用,采用** |
**P5 å®žæµ‹**(浏览器,`border-collapse: collapse`):
- é¦–行两格 `<td rowspan="4">水分</td><td rowspan="4">w = â€¦</td>`,高度 = 4 è¡Œä¹‹å’Œï¼Œåˆå¹¶æˆç«‹ï¼›
- ç»„内第 2–4 è¡ŒåŒä¸¤æ ¼è¢« `display:none` å‰”除后,这些行的可见单元格 `left` ä¸Žè¡¨å¤´ç¬¬ 3–7 åˆ—**逐列相等** â†’ åˆ—对齐无损。
探针当时并了**两**格(组名 + å…¬å¼ï¼‰ï¼›æœ€ç»ˆç»“构只并组名一格(见 Â§4.1 çš„口径说明),是探针结论的真子集 â€”— æœºåˆ¶ç›¸åŒï¼Œåªæ˜¯å°‘并一格,第 4 åˆ—照常逐行取值。
### éšè—é€šé“:初稿的写法不可行(2026-09-19 ä¿®æ­£ï¼‰
初稿写「`group.hidden` ç”¨å¸ƒå°”,`buildContent` é‡Œå†æŠŠå®ƒæŠ˜æˆ `display:none`」。这**做不到**,有两处硬冲突:
- `binding.ts:4` æ˜Žå†™ã€Œåªåšå–值与替换,不做任何表达式求值」,`{{...}}` å–到的是字面量,不可能变成 CSS;
- `buildContent` æ˜¯**设计期静态**的,它拿不到运行期的行数据,更谈不上按行折样式。
于是改成「把隐藏值交给数据侧」。三条通道实测(脚本走真实 `grapesjs.init` â†’ `getWrapper().append()` â†’ `getProjectData()` â†’ `loadProjectData()`,即设计器的真实序列化路径):
| é€šé“ | GrapesJS è§£æžæˆ | `getHtml()` | é¡¹ç›®æ•°æ®å¾€è¿” | èƒ½æŒ‰è¡Œå˜ |
|---|---|---|---|---|
| å†…容里写 `style="{{路径}}"` | åªä¿ç•™é™æ€ CSS è¿›èŠ‚ç‚¹ `style` å¯¹è±¡ï¼Œ**绑定串被整条丢弃** | ä¸¢å¤± | ç•™åœ¨ attributes,但画布/HTML é‡Œå·²æ²¡æœ‰ | å¦ |
| `attributes.style="{{路径}}"` | åŽŸæ ·ç•™åœ¨ attributes(不进 `style` å¯¹è±¡ï¼‰ | **丢失** | ä¿ç•™ | æ˜¯ï¼ˆä½†ç”»å¸ƒçœ‹ä¸åˆ°ï¼‰ |
| **`hidden="{{路径}}"`** | æ™®é€šå±žæ€§ï¼ŒåŽŸæ ·ç•™åœ¨ attributes | **保留** | ä¿ç•™ | **是** |
**采用 `hidden`**。再加两条实测把它的行为钉死:
- Chromium é‡Œ `td[hidden]` å®žæµ‹ `display: none`、高度 0,与 `style="display:none"` ç­‰æ•ˆ â€”— è€ŒæœåŠ¡ç«¯ PDF ç”¨çš„就是同一个 Chromium(`PdfRenderService` â†’ Playwright),所以浏览器里的结论直接适用于打印产物;
- ä¸¤ä¾§æ¸²æŸ“器**都跳过「求值后为空串」的属性**(前端 `render.ts:234`、后端 `HtmlRenderer.java:214`),于是「首行给空串 â†’ ä¸è¾“出该属性 â†’ å¯è§ï¼›å…¶ä½™è¡Œç»™ä¸€ä¸ªéžç©ºå€¼ â†’ è¾“出 â†’ éšè—ã€æ˜¯ä¸¤ä¾§é€å­—一致的确定性行为。
两个实测得到的硬约束,必须写进契约:
- **`{{item.group.children.length}}` å–不到值**:路径在数组上继续取属性会按元素 pluck,`.length` è¯»ä¸åˆ°ã€‚**跨行数必须由数据侧显式给出**(`group.span`)。
- **绑定的字段必须在每一项上都存在**(哪怕是空串)。字段缺席会渲染为空并记一条「在当前数据中取不到值」的问题;`hidden` è‹¥å–到 `undefined`,属性会被跳过 â†’ è¯¥éšè—çš„行反而露出来。
## å››ã€æ–¹æ¡ˆ
### 4.1 æ•°æ®ä¾§å¥‘约
在**现有 `inspectionItems` çš„æ¯ä¸€é¡¹**上增加(数组里仍然只有真实检验项,**不塞合成组头行以外的任何东西**;组头本身就是单据里的一项):
| å­—段 | ç±»åž‹ | å«ä¹‰ |
|---|---|---|
| `item.requirement` | String | æœ¬è¡Œã€Œæ£€æµ‹è¦æ±‚」文本:**分组父项 = `formula_text` å…¬å¼**,其余 = å•据上的标准要求 |
| `item.group.childName` | String | ã€Œå­é¡¹ã€åˆ—文本:**组内子项 = è‡ªå·±çš„名字**;组内锚点行(含多样品多出来的锚点行)与独立项 = ç©ºä¸² |
| `item.group.span` | Integer | **「检验项目」列**的 `rowspan`:**组内第一行 = è¯¥ç»„的总行数**(锚点行 + å­é¡¹è¡Œï¼‰ï¼›åŒç»„其余行与独立项 = 1 |
| `item.group.hidden` | String | **组内第一行 = ç©ºä¸²**(这一格可见,并向下合并);**同组其余行 = `hidden`**(让位给上面的合并格);独立项 = ç©ºä¸² |
> **合并口径统一成一条规则:组内第一行背 `span = è¯¥ç»„总行数`,同组其余行一律 `span = 1` + `hidden = hidden`。**
> è¿™æ ·å¤šæ ·å“çš„组头(一个指标多行)也自动落在规则里 â€”— åªæœ‰ç¬¬ä¸€è¡Œå¯è§å¹¶åˆå¹¶ï¼Œå¤šå‡ºæ¥çš„锚点行让位,
> æ—¢ä¸ä¼šå¤šå‡ºä¸€æ ¼ç©ºç™½ï¼Œä¹Ÿä¸ä¼šæŠŠåˆ—挤歪。
> **只合并「检验项目」一格,不合并「检测要求」**:docx åŽŸç”Ÿç‰ˆå¼é‡Œåˆå¹¶çš„åªæœ‰é¡¹ç›®åˆ—ï¼Œæ ‡å‡†è¦æ±‚ä»æ˜¯é€è¡Œçš„
> ï¼ˆå®žæµ‹ `20目上` / `0-5` é‚£ä¸€è¡Œå°±æ˜¯ç‹¬ç«‹çš„æ ‡å‡†è¦æ±‚)。若把检测要求也并进组头格,组内子项各自的标准要求
> å°±æ— å¤„可放、会被翻掉。组头的公式只占组头锚点行自己那一格。
两侧同步(否则前端预览与后端出件不一致):
- åŽç«¯ `engine/context/ReportContext.java`:`InspectionItem` åŠ  `requirement` ä¸ŽåµŒå¥— `ItemGroup group`,`itemMap()` æ˜¾å¼æ”¾å…¥ï¼ˆè¯¥æ–¹æ³•逐字段枚举,不靠反射;`group` ä¸º null æ—¶ä¸æ”¾ï¼Œé¿å…æ±¡æŸ“冻结 fixture çš„作用域)。
- å‰ç«¯ `components/quality/engine/context.ts`:`ReportContextItem` åŠ åŒåçš„å¯é€‰ `requirement` ä¸Ž `group`。
`hidden` æ˜¯**字符串而不是布尔**:这个属性靠「在不在」起作用,`hidden="false"` ç…§æ ·éšè—ï¼Œæ‰€ä»¥åªèƒ½ç”¨ã€Œç©ºä¸² = ä¸è¾“出」这一个可判定的形态。名字取得直白,值就是该属性的值。
### 4.2 åˆ†ç»„识别与报告顺序
- **谁是组头**:某项指标被本单里别的行用 `parent_id` æŒ‡è®¤ï¼Œå®ƒå°±æ˜¯ç»„头。不依赖 `item_type`,也不依赖行序 â€”— è¡Œåºæ˜¯é ä¸ä½çš„(父项那行与子项不相邻)。`item_type = 2`(分组项)只用来判断这个父指标「够不够格当组头」,不参与归属判断。
- **报告顺序**:顶层条目(独立项与组)按指标的 `sort_order` å‡åºï¼Œ`sort_order` ç¼ºå¤±çš„æŽ’最后,同值保持行序(稳定排序);组内子项按自己的 `sort_order`。实测这组数据排出来是 å¤–è§‚(10) â†’ è‰²åº¦(20) â†’ æ°´åˆ†ç»„(30) â†’ é…¸å«é‡ç»„(40) â†’ æ­£ä¸é†‡å«é‡ç»„(50) â†’ å¯†åº¦(60) â†’ æŠ˜å…‰çއ(70),与 docx å’Œ `sort_order` çš„æ„å›¾ä¸€è‡´ã€‚
  - è¿™æ˜¯**唯一一处对既有行为的改动**:此前报告行序 = è¡Œè¡¨é¡ºåºã€‚`sort_order` æœ¬å°±æ˜¯è¿™ä»½ä¸»æ•°æ®é‡Œè¡¨æ„ã€Œæ˜¾ç¤ºé¡ºåºã€çš„字段,且本单里独立项两者恰好一致,实际影响面很小。若业务要求严格按行序,改回一行即可(排序键换成行序)。
- **组头自己那一行**:组头指标通常在本单里也有行(水分就是第 1 è¡Œï¼‰ï¼Œå®ƒä½œä¸ºåˆå¹¶æ ¼çš„锚点行输出。
- **组头指标在本单里没有行**(检验模板只勾了子项时会这样)—— **仍然为它补一行**,否则组名与公式无处可放、报告上这组就散成了逐条明细。补出来的这一行:实测值与单位为空、`requirement` å–公式、`span` èƒŒæ•´ç»„行数(见 Â§4.1 çš„合并口径),并在响应的 `synthesizedGroupNames` é‡Œè¯´æ˜Žä¸ºå“ªå‡ ä¸ªç»„头补了行、为什么。
  - æ—©æœŸæ–¹æ¡ˆæ›¾æŠŠè¿™ç§æƒ…况判为「组不成立、按独立项处理」,那样会**原样复现本次要修的 bug**(组名与公式再次丢失)。实测确认这条路径可达(`mes_qc_template_indicator` å†³å®šæœ¬å•有哪些行,模板可能只勾子项),所以按「补行」实现。
- **组头指标在本单里根本没有子项**(即它是一个分组项,但没有任何行用 `parent_id` æŒ‡å‘它)—— æŒ‰ç‹¬ç«‹æ£€éªŒé¡¹åˆ—出、组名不合并,并把它的名字收进 `childlessGroupNames` è¯´æ˜Žã€‚
### 4.3 æ¸²æŸ“结构
新增组件 `GroupedQualityTable`「分组检验项表」(不改造 `QualityTable`:后者要加组相关 props å¹¶åœ¨ `buildContent` é‡Œåˆ†æ”¯ï¼Œå±žæ€§é¢æ¿å¤æ‚度上升,且新组件能保证既有画布零影响)。
7 åˆ—,其中第 2 æ ¼ï¼ˆæ£€éªŒé¡¹ç›®ï¼‰åœ¨ç»„头行纵向合并:
| åˆ— | ç»‘定 | ç»„头行 | ç»„内子项行 | ç‹¬ç«‹é¡¹ |
|---|---|---|---|---|
| 1 åºå· | `{{index}}` | è¡Œå· | è¡Œå· | è¡Œå· |
| 2 æ£€éªŒé¡¹ç›® | `{{item.itemName}}` + `rowspan="{{item.group.span}}"` + `hidden="{{item.group.hidden}}"` | ç»„名(合并) | è¯¥æ ¼éšè— | è‡ªå·±çš„名字 |
| 3 å­é¡¹ | `{{item.group.childName}}` | ç©º | è‡ªå·±çš„名字 | ç©º |
| 4 æ£€æµ‹è¦æ±‚ | `{{item.requirement}}` | **公式** | è‡ªå·±çš„æ ‡å‡†è¦æ±‚ | è‡ªå·±çš„æ ‡å‡†è¦æ±‚ |
| 5 å®žæµ‹å€¼ | `{{item.actualValue}}` | ç»„头自己的值 | è‡ªå·±çš„实测值 | è‡ªå·±çš„实测值 |
| 6 å•位 | `{{item.unit}}` | åŒä¸Š | åŒä¸Š | åŒä¸Š |
| 7 åˆ¤å®š | `{{item.resultText}}` | æ— åˆ¤å®šè§„则 | è‡ªå·±çš„判定 | è‡ªå·±çš„判定 |
结构要点:
1. å¤–层仍是**单层** `repeat`,路径 `inspectionItems`(默认值,属性面板可改)。
2. å‰ä¸¤åˆ—之外照搬 `QualityTable` çš„静态单元格样式(`components` è¿”回 HTML å­—符串,与其余 11 ä¸ªç»„件同款写法)。
3. `rowspan` / `hidden` å¿…须写在**属性**上,不能写进行内 `style`(行内样式会被 GrapesJS è§£æžæˆèŠ‚ç‚¹ `style` å¯¹è±¡ï¼Œè€Œ `renderStyle` ä¸è·‘绑定)。
4. `GroupedQualityTable` ä¸Ž `QualityTable` **二者用其一**:前者用于有分组的单据,后者用于纯平的检验项表。
## äº”、落地清单
**后端 `yudao-module-mes`**
- `MesQcIndicatorDO`:补映射 `parent_id` / `item_type` / `formula_text` / `unit_text` / `sort_order`。
- `MesQcReportItemRespDTO`:加 `requirement` ä¸Ž `groupChildName` / `groupSpan` / `groupHidden`(跨模块 API å˜æ›´ â†’ éœ€ `mvn compile -pl yudao-module-qcreport -am -q`)。
- `MesQcReportApiImpl.buildItems`:改用 `parent_id` åˆ¤å½’属(不再用 `result_type IS NULL`,也不再丢父项),按 `sort_order` æŽ’序,把组信息下发到组头的锚点行与每个子项。
- `MesQcReportRespDTO`:原 `groupItemNames` æ‹†æˆä¸¤ä¸ªå„说一件事的字段 â€”— `childlessGroupNames`(是分组项、但本单没有属于它的子项)与 `synthesizedGroupNames`(本单没有自己的行、被补了一行来放组名与公式),另有 `missingIndicatorCount`(明细引用的指标已被删除)。
**后端 `yudao-module-qcreport`**
- `engine/context/ReportContext.java`:`InspectionItem` åŠ  `requirement` ä¸Ž `group`,`itemMap()` å¸¦ä¸Šã€‚
- `MesQcReportContextMapper`:把 MES çš„ `requirement` / `group*` é€è¿›æŠ¥å‘Šä¸Šä¸‹æ–‡ï¼›ç»„头锚点行不再报「尚未录入实测值」(它按定义没有实测值);`appendSkipWarnings` æ–‡æ¡ˆéšçˆ¶é¡¹ä¸å†è¢«ä¸¢å¼ƒè€Œè°ƒæ•´ã€‚
**前端 `mom-pro2-before`**
- `engine/context.ts` çš„ `ReportContextItem` åŠ  `requirement` / `group`。
- æ–°å¢ž `inspection/grouped-quality-table.ts` å¹¶æ³¨å†Œï¼›`aiHint` å†™æ¸…「有分组时用它,无分组用 QualityTable」。
- `.qc-conformance/assemble-ts.ts`:`definitions.length === 12`(第 100 è¡Œï¼‰ã€`catalog.length === 12`(第 255 è¡Œï¼‰ã€å­—段集合断言都要随第 13 ä¸ªç»„件更新。
**测试与对拍**
- åŽç«¯å­˜é‡å¿…须全绿(`FrontendConformanceTest` é€å­—比对的期望产物只是 HTML ä¸Žè§„则结果,新增的 `group` ä¸å‚与任何 fixture è¡¨è¾¾å¼ï¼Œæ•…不必重生成)。实测:`yudao-module-qcreport` 141 é¡¹å…¨ç»¿ã€‚
- å‰ç«¯æŽ¢é’ˆ `.qc-conformance/verify-grouped-table.ts`:用真实引擎跑「组头 + å­é¡¹ + ç‹¬ç«‹é¡¹ã€æ··æŽ’的上下文,断言合并格、隐藏格、列对齐与「无判定规则」不受影响。**已落地并全绿**;列对齐另有一次浏览器实测(见进度文档)。
- åˆ†ç»„画布 fixture è¿› `FrontendConformanceTest` çš„双侧逐字比对 â€”— **本轮未做**,只做前端探针。理由:该 fixture è¦æ±‚前端先产出「期望 HTML」再由 Java é€å­—比对,而分组结构的关键风险(`rowspan` è®©ä½åŽåˆ—是否串位)取决于浏览器表格布局,逐字比对固定不住这一条,探针 + æµè§ˆå™¨å®žæµ‹æ‰æ˜¯æœ‰æ•ˆè¯æ®ã€‚
## å…­ã€å·²å®šçš„口径(原「待确认」)
1. **过程参数行的判定口径** â€”— å·²å®žçŽ°ã€Œæ— åˆ¤å®šè§„åˆ™ã€ï¼šæ—¢æ²¡æœ‰è§„æ ¼ä¸Šä¸‹é™ã€ä¹Ÿæ²¡æœ‰åˆ¤å®šè§„åˆ™çš„è¡Œæ ‡ä¸ºã€Œæ— åˆ¤å®šè§„åˆ™ã€ï¼Œä¸è®¡å…¥åˆæ ¼çŽ‡åˆ†æ¯ã€ä¸å‚ä¸ŽæŠ¥å‘Šç»“è®ºï¼›è¿™åªæ˜¯æ˜¾ç¤ºä¸Žç»Ÿè®¡å£å¾„ï¼Œ**不新增也不删除检验项**,`total` ä»æ˜¯çœŸå®žé¡¹æ•°ã€‚
2. **组头两列的宽度** â€”— ç»„名与公式各占一列,**检测要求列同时承担「组头放公式」与「其它行放标准要求」**(实测本单所有标准要求为空,不会互相覆盖)。不另开一列,避免纯平单据出现整列空白。
3. **AI å¯¼å…¥** â€”— æ¨¡åž‹è¦èƒ½åœ¨ `QualityTable` ä¸Ž `GroupedQualityTable` ä¹‹é—´é€‰å¯¹ï¼Œä¾èµ– `aiHint` ä¸Žæç¤ºè¯è§„则;识别准确率无法先验保证,需真实调用观察后如实汇报。
## ä¸ƒã€æ˜Žç¡®ä¸åš
- ä¸æ”¹ `HtmlRenderer` / `BASE_CSS`(P5 å·²éªŒè¯é›¶æ”¹åŠ¨å¯è¡Œï¼‰ã€‚
- ä¸æ”¹ Schema å¥‘约 1.1、不新增顶层上下文数组。
- ä¸ä¸ºã€Œåˆå¹¶å•元格」引入 `tbody` åµŒå¥—(P4 å¯è¡Œä½†å±žé™çº§å…¼å®¹è·¯å¾„,且污染统计,不采用)。
- ä¸åœ¨å±žæ€§é¢æ¿å¼•入结构化 props(既有能力不支持,且无必要)。
- ä¸æŠŠç»„内子项再向下嵌套(当前数据只有两层;真要更深的层级,`parent_id` å·²èƒ½è¡¨è¾¾ï¼Œä½†æ¸²æŸ“结构要重新设计)。
docs/qc_report_indicator_hierarchy_frontend_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,196 @@
# è´¨æ£€æŒ‡æ ‡å±‚级维护 - å‰ç«¯è”调方案
> å˜æ›´æ¨¡å—:`yudao-module-mes`(质检指标主数据)
> å˜æ›´ç›®çš„:让「分组项 / æ‰€å±žåˆ†ç»„ / å…¬å¼ / æŽ’序号」这四个层级字段可以在**质检指标页**维护,
> ä¸å†åªèƒ½é  SQL é€ æ•°æ®ã€‚
> æŠ¥å‘Šä¾§çš„取数逻辑**一行未改** â€”— ç•Œé¢é€ å‡ºæ¥çš„分组与 SQL é€ å‡ºæ¥çš„分组,在报告里完全同形。
## æ¶‰åŠé¡µé¢
- è´¨æ£€æŒ‡æ ‡åˆ—表页(菜单:质量管理 â†’ è´¨æ£€æŒ‡æ ‡ï¼‰
## ä¸šåŠ¡æµç¨‹ä¸Žæ•°æ®å¸¦å…¥
1. åœ¨è´¨æ£€æŒ‡æ ‡é¡µæ–°å»ºã€Œåˆ†ç»„项」→ è¯¥æŒ‡æ ‡æˆä¸º**顶级指标**,`所属分组` å›ºå®šä¸ºé¡¶çº§ã€`公式` å¿…填。
2. æ–°å»ºã€Œå½•入项」→ åœ¨ `所属分组` é‡Œé€‰æ‹©ç¬¬ 1 æ­¥å»ºçš„分组项 â†’ è¯¥æŒ‡æ ‡æˆä¸ºè¿™ä¸ªåˆ†ç»„的子项。
3. æ£€æµ‹æ–¹æ¡ˆ â†’ è´¨æ£€å• â†’ å‡ºæŠ¥å‘Šï¼šæŠ¥å‘Šä¾§æŒ‰ `parent_id` æŠŠåˆ†ç»„项与它的子项还原成两层表头,
   ç»„名纵向合并、公式进「检测要求」列。**这一步的展示效果与数据来自 SQL æ—¶å®Œå…¨ä¸€è‡´ã€‚**
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | `/mes/qc/indicator/page` | åˆ†é¡µæŸ¥è¯¢ï¼ˆæƒé™ `mes:qc-indicator:query`) |
| GET | `/mes/qc/indicator/group-list` | **新增**:分组项精简列表(权限 `mes:qc-indicator:query`) |
| POST | `/mes/qc/indicator/create` | æ–°å¢žï¼ˆæƒé™ `mes:qc-indicator:create`) |
| PUT | `/mes/qc/indicator/update` | ä¿®æ”¹ï¼ˆæƒé™ `mes:qc-indicator:update`) |
| DELETE | `/mes/qc/indicator/delete` | åˆ é™¤ï¼ˆæƒé™ `mes:qc-indicator:delete`) |
| GET | `/mes/qc/indicator/export-excel` | å¯¼å‡ºï¼ˆæƒé™ `mes:qc-indicator:export`) |
接口前缀为 `/admin-api`,与既有约定一致,下文路径均省略该前缀。
### GET /mes/qc/indicator/group-list
专供「所属分组」下拉。**只返回分组项**(`item_type = 2`),不返回录入项。
无请求参数。响应为指标对象数组(字段同下方响应字段表,此处只保证 `id` / `name` æœ‰å€¼ï¼‰ã€‚
### è¯·æ±‚参数(create / update)
`update` ä¸Ž `create` å…±ç”¨åŒä¸€ä¸ªè¯·æ±‚体。新增字段标 â˜…。
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| id | Long | update å¿…å¡« | ç¼–号 |
| code | String | æ˜¯ | æ£€æµ‹é¡¹ç¼–码 |
| name | String | æ˜¯ | æ£€æµ‹é¡¹åç§° |
| type | Integer | æ˜¯ | æ£€æµ‹é¡¹ç±»åž‹ï¼ˆå­—å…¸ `mes_indicator_type`) |
| tool | String | å¦ | æ£€æµ‹å·¥å…· |
| â˜… itemType | Integer | å¦ | æ¡ç›®ç±»åž‹ï¼š`1`=录入项(默认),`2`=分组项 |
| â˜… parentId | Long | å¦ | çˆ¶æŒ‡æ ‡ç¼–号,`0` æˆ–留空 = é¡¶çº§æŒ‡æ ‡ |
| â˜… formulaText | String | åˆ†ç»„项必填 | å…¬å¼å±•示文本,报告里作为该分组的「检测要求」 |
| â˜… sortOrder | Integer | å¦ | åŒçº§æŽ’序号,留空则自动排到同级末尾 |
| resultType | Integer | å½•入项必填 | ç»“果值类型(字典 `mes_qc_result_type`)。**分组项不填** |
| resultSpecification | String | æ¡ä»¶å¿…å¡« | ç»“果值属性。`resultType` ä¸ºã€Œæ–‡ä»¶ã€æˆ–「字典」时必填 |
| remark | String | å¦ | å¤‡æ³¨ |
> `resultType` åŽŸå…ˆå¸¦ `@NotNull`,本轮**摘掉了**:分组项按现网口径就是 `NULL`(现网 3 ä¸ªåˆ†ç»„行
> çš„ `result_type` å…¨ä¸º NULL)。改为服务端按条目类型条件校验:**只有录入项必填**。
### å“åº”字段新增(page / export-excel)
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| itemType | Integer | æ¡ç›®ç±»åž‹ |
| parentId | Long | çˆ¶æŒ‡æ ‡ç¼–号,`0` è¡¨ç¤ºé¡¶çº§ |
| parentName | String | **所属分组名称**,由后端回填;顶级指标为 `null` |
| formulaText | String | å…¬å¼ |
| sortOrder | Integer | åŒçº§æŽ’序号 |
`parentName` ç”±æŽ§åˆ¶å™¨å›žå¡«ï¼Œå–值方式是:收集本页所有非 `0` çš„ `parentId` â†’ ä¸€æ¬¡æ€§æŒ‰ id æ‰¹é‡æŸ¥æŒ‡æ ‡ â†’
建 `id â†’ name` æ˜ å°„回填。因此**父指标即使不在当前页、甚至不在筛选结果内,名称也能正确解析**。
> **`/get`(详情)接口不回填 `parentName`**,与 `/page`、`/export-excel` ä¸ä¸€è‡´ã€‚
> ç¼–辑弹窗的数据来源是列表行(列表行已带 `parentName`),不受影响;若后续有页面直接调 `/get` å±•示
> æ‰€å±žåˆ†ç»„,需要另行处理。
### å“åº”示例
```json
{
  "code": 0,
  "data": {
    "list": [
      {
        "id": 21,
        "code": "SEED-BT-WATER",
        "name": "水分",
        "type": 4,
        "itemType": 2,
        "parentId": 0,
        "parentName": null,
        "formulaText": "w = (m1 - m0) / m Ã— 100%",
        "sortOrder": 30,
        "resultType": null,
        "resultSpecification": null
      },
      {
        "id": 22,
        "code": "SEED-BT-WATER-M",
        "name": "试样质量 m",
        "type": 4,
        "itemType": 1,
        "parentId": 21,
        "parentName": "水分",
        "formulaText": null,
        "sortOrder": 1,
        "resultType": 1,
        "resultSpecification": null
      }
    ],
    "total": 34
  }
}
```
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| æ¡ç›®ç±»åž‹ | åˆ—表列、新增/修改表单 | åˆ—表用标签展示:`分组项` / `录入项` |
| æ‰€å±žåˆ†ç»„ | åˆ—表列、新增/修改表单 | åˆ—表取 `parentName`,为 `null` æ—¶æ˜¾ç¤º `-`(表示顶级指标) |
| å…¬å¼ | æ–°å¢ž/修改表单 | **仅分组项可见,且必填** |
| æŽ’序号 | æ–°å¢ž/修改表单 | æ•°å­—输入,最小 `0`;留空由服务端补齐 |
| æ¡ç›®ç±»åž‹ | æœç´¢è¡¨å• | ä¸‹æ‹‰ï¼šå½•入项 / åˆ†ç»„项 |
**「所属分组」不是树形展示**:分组项只能是顶级,所以候选项就是一层平铺的分组项列表
(外层套一个「顶级指标」根节点)。不提供多级树。
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
### å±‚级规则(只允许两层)
| åœºæ™¯ | è§„则 |
|------|------|
| åˆ†ç»„项 | å¿…须是**顶级指标**,`所属分组` é¡¹åœ¨è¡¨å•上**不出现**,落库固定 `parentId = 0` |
| å½•入项 | å¯æŒ‚到任意分组项下,也可作为顶级指标(`所属分组` ç•™ç©ºï¼‰ |
| ä¸ºä»€ä¹ˆåªæœ‰ä¸¤å±‚ | è§„则本身推出:父必须是**分组项**,而分组项**只能顶级** â‡’ ç¬¬ä¸‰å±‚无法构造 |
**「所属分组」在表单上的候选只有分组项**:新建录入项时不可能选到自己,也不可能选到另一个录入项。
### æ ¡éªŒè§„则与提示文案
服务端按业务优先级**只返回第一处不满足的条件**,避免一次抛多条让用户抓不住重点。
| é”™è¯¯ç  | è§¦å‘条件 | æç¤ºæ–‡æ¡ˆè¦ç‚¹ |
|--------|----------|--------------|
| `1040601007` | åˆ†ç»„项却选了所属分组 | å«åˆ†ç»„名 + æ‰€é€‰çˆ¶æŒ‡æ ‡å + æ”¹æ³•(把条目类型改为录入项再选分组) |
| `1040601006` | çˆ¶æŒ‡æ ‡æ˜¯è‡ªå·± | ã€Œçˆ¶æŒ‡æ ‡ä¸èƒ½æ˜¯è‡ªå·±ã€ |
| `1040601004` | çˆ¶æŒ‡æ ‡ä¸å­˜åœ¨ï¼ˆå·²è¢«åˆ é™¤ï¼‰ | å«çˆ¶æŒ‡æ ‡ç¼–号 + æ”¹æ³•(重新选择或清空) |
| `1040601005` | çˆ¶æŒ‡æ ‡ä¸æ˜¯åˆ†ç»„项 | å«çˆ¶æŒ‡æ ‡å + å®ƒå½“前的条目类型中文名 + æ”¹æ³•(改选分组项 / å…ˆæŠŠå®ƒçš„æ¡ç›®ç±»åž‹æ”¹ä¸ºåˆ†ç»„项) |
| `1040601009` | å½•入项未填结果值类型 | ã€Œå½•入项的结果值类型不能为空」 |
| `1040601003` | ç»“果值类型为文件/字典但结果值属性为空 | ã€Œç»“果值属性不能为空」 |
| `1040601008` | **改挂到分组下**时,该指标下已有子项 | å«æŒ‡æ ‡å + å­é¡¹æ¡æ•° + ã€Œè¯·å…ˆåˆ é™¤æˆ–移走这 N ä¸ªå­é¡¹ã€ |
| `1040601008` | **改为录入项**时,该指标下已有子项 | åŒä¸Šé”™è¯¯ç ï¼Œæ–‡æ¡ˆæ”¹ä¸ºã€Œå¦åˆ™è¿™äº›å­é¡¹ä¼šå¤±åŽ»æ‰€å±žåˆ†ç»„ã€ |
后两条用同一个错误码、**文案各自贴合场景**:一个是「不能再挂到别的分组下」,一个是
「不能反过来变成子项」。
### è½åº“时的自动处理
| å­—段 | å¤„理 |
|------|------|
| `parentId` | ç•™ç©ºè¡¥ `0` |
| `itemType` | ç•™ç©ºè¡¥ `1`(录入项) |
| `resultType` / `resultSpecification` | **分组项强制清空**。录入项改分组项时,旧的结果值会被真正写回 `NULL` |
| `sortOrder` | ç•™ç©ºè‡ªåŠ¨æŽ’åˆ°åŒçº§æœ«å°¾ï¼šé¡¶çº§æ­¥é•¿ `10`,子项步长 `1` |
> ã€Œæ”¹åˆ†ç»„项后结果值被清空」依赖 DO ä¸Šçš„ `updateStrategy = ALWAYS`。MyBatis-Plus é»˜è®¤çš„
> `NOT_NULL` ç­–略会把 `null` å­—段从 UPDATE è¯­å¥é‡Œå‰”除,导致旧结果值残留 â€”— è¿™æ˜¯æœ¬è½®ä¿®æŽ‰çš„
> ä¸€ä¸ªçœŸå®žå†™å…¥ç¼ºé™·ï¼Œå‰ç«¯æ— éœ€æ„ŸçŸ¥ã€‚
### åˆ é™¤
**删除分组项会级联删除它下面的所有子项。**
| æ“ä½œ | ç»“æžœ |
|------|------|
| åˆ é™¤åˆ†ç»„项 | å®ƒçš„全部子项一并删除 |
| åˆ é™¤å½•入项 | åªåˆ å®ƒè‡ªå·± |
报告侧对指标被删已有兜底:指标已被删除的明细行只计入 `missingIndicatorCount` è¿› `warnings`,
不会导致出件失败。
## æ³¨æ„äº‹é¡¹
- **「条目类型」不在导出 Excel é‡Œ**。`所属分组`、`公式`、`排序号` ä¸‰åˆ—已进 Excel,
  æ¡ç›®ç±»åž‹æ²¡æœ‰å¯¹åº”列(可结合「所属分组」是否有值判断)。这是本轮明确接受的口径。
- **`单位` å­—段不对前端暴露**,也不在新增/修改表单里。原因:该列在全部后端代码里没有任何读取方,
  æŠ¥å‘Šé‡Œçš„「单位」取自检验方案行的计量单位;放一个没人读的字段进表单只会误导录入人。
- **切换条目类型会清空录入项专属字段**:表单切到「分组项」时,所属分组会被重置为顶级、
  ç»“果值类型与结果值属性会被清空。这是必要的 â€”— å¦åˆ™è¢«éšè—çš„字段仍会随表单提交,
  æ®‹ç•™çš„ `parentId` ä¼šè¢«åŽç«¯å±‚级校验顶回来。
- **编辑分组项时,「所属分组」字段不可见、「公式」必填生效**。
- **`sortOrder` å…è®¸é‡å¤**:服务端不校验同级排序号的唯一性,相同排序号的相对顺序不保证。
- **`group-list` è¿”回的是全部分组项**,不按任何维度过滤;如果分组项数量增长到很大,
  éœ€è¦è€ƒè™‘改造为分页或搜索式选择。
docs/qc_report_instance_frontend_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,290 @@
# æ™ºèƒ½è´¨æ£€æŠ¥å‘Š â€” å‡ºä»¶æŽ¥å£ å‰ç«¯è”调方案
> åŽç«¯æ¨¡å—:`yudao-module-qcreport` 接口前缀:`/admin-api/qc-report/instance`
> æœ¬è½®èŒƒå›´ï¼š**HTML å‡ºä»¶ + PDF å‡ºä»¶**。PDF ç”±åŽç«¯è°ƒ Chromium æ‰“印,接口只返回归档结果与签名地址;
> æŠ¥å‘Šå®žä¾‹è¡¨**不存文件地址**,PDF ç»Ÿä¸€æŒ‚在附件库(`system_storage_attachment`)里。
## æ¶‰åŠé¡µé¢
| é¡µé¢ | è·¯ç”± | è¯´æ˜Ž | çŠ¶æ€ |
|---|---|---|---|
| æŠ¥å‘Šå®žä¾‹åˆ—表页 | `/qc/report-instance` | åˆ†é¡µæŸ¥çœ‹å·²å‡ºä»¶çš„æŠ¥å‘Šï¼ŒæŒ‰æ¨¡æ¿/编号/业务单据/状态/时间筛选 | **已落地**(`views/mes/qc/report/instance/index.vue`) |
| æŠ¥å‘Šé¢„览 / è¯¦æƒ…弹窗 | åŒä¸Š | å±•示渲染出的 HTML;数据快照页签;**「导出 PDF」按钮调 `pdf` æŽ¥å£** | **已落地**(`.../instance/modules/detail.vue`) |
| æ¨¡æ¿è®¾è®¡å™¨ï¼ˆæ—¢æœ‰ï¼‰ | `/qc/report-template` | ã€Œé¢„览」按钮需要调用本文档的 `preview` æŽ¥å£ | ä»å¾…接入(本轮只加了「AI å¯¼å…¥ã€æŒ‰é’®ï¼‰ |
| MES è´¨æ£€å•(既有,后续) | â€” | ã€Œå‡ºæŠ¥å‘Šã€å…¥å£ï¼ŒæŠŠè´¨æ£€å•数据归一化后调 `generate` | **后续轮次**(本轮由调用方自行组织数据) |
菜单已入库:「报告实例」挂在「质量管理」(`parent_id=5500`)下,`component` å¡« `mes/qc/report/instance/index`,前端动态路由解析到同名 `.vue`。
## ä¸šåŠ¡æµç¨‹ä¸Žæ•°æ®å¸¦å…¥
1. **设计器 â†’ é¢„览**:设计器把当前画布保存成版本后,用「当前画布对应的模板 + ç‰ˆæœ¬ã€åŠ ä¸€ä»½ç¤ºä¾‹æ•°æ®è°ƒç”¨ `preview`,
   æ‹¿åˆ° HTML ç›´æŽ¥å±•示。**预览不消耗报告编号**,可以反复调用;**预览接受草稿版本**(见「业务规则」)。
2. **业务系统 â†’ å‡ºä»¶**:调用方(本轮是手工构造,后续是 MES è´¨æ£€å•)准备「报告上下文」→ è°ƒ `generate`
   â†’ åŽç«¯æ¸²æŸ“、落库、冻结数据快照 â†’ è¿”回实例编号与报告编号。
3. **列表 â†’ è¯¦æƒ…**:列表拿到 `id` â†’ è°ƒ `get` å– `renderHtml` å±•示;需要看「当时用的什么数据」再调 `snapshot`。
4. **详情 â†’ é‡æ–°ç”Ÿæˆ**:调 `regenerate` ç”¨**冻结的快照**重渲,覆盖产物 HTML。数据与编号都不变。
   **已归档的 PDF ä¼šè¢«ä¸€å¹¶ä½œåºŸ**(见「业务规则」)。
5. **详情 â†’ å¯¼å‡º PDF**:点「导出 PDF」调 `pdf` æŽ¥å£ â†’ åŽç«¯æ‰“印并归档 â†’ æŽ¥å£è¿”回本次的下载地址(临时签名)。
   é¡µé¢è¦é•¿æœŸæ˜¾ç¤ºã€Œè¿™ä»½æŠ¥å‘Šæœ‰ PDF」请调**附件列表接口**(见「PDF å½’档怎么查」),不要缓存接口返回的签名地址。
**带入关系要点**
- `templateId` æ˜¯ä¸»çº¿ï¼Œä»Žæ¨¡æ¿åˆ—表带入设计器、再带入出件。
- `version` å¯ä»¥ä¸ä¼ ï¼šä¸ä¼ æ—¶åŽç«¯å–**模板当前的版本**(`currentVersion`);一定要用某个特定版本才需要显式传。
  **设计器预览建议始终显式传版本**——见「业务规则」里对 `currentVersion` çš„说明。
- `reportNo` å¯ä»¥ä¸ä¼ ï¼šä¸ä¼ æ—¶ç”±åŽç«¯æŒ‰ `QR + æ—¥æœŸ + 4 ä½æµæ°´` è‡ªåŠ¨ç”Ÿæˆã€‚
- `businessId` / `businessType` åªåšç•™ç—•与查询过滤,后端不校验、不解析它指向的单据。
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž | æƒé™ç  |
|---|---|---|---|
| POST | `/qc-report/instance/preview` | åªæ¸²æŸ“,**不落库、不生成、不消耗报告编号** | `qc-report:instance:query` |
| POST | `/qc-report/instance/generate` | æ¸²æŸ“ + è½åº“ + å†»ç»“数据快照 | `qc-report:instance:create` |
| GET | `/qc-report/instance/get?id=` | è¯¦æƒ…:元信息 + æ¸²æŸ“产物 HTML(**不含**数据快照) | `qc-report:instance:query` |
| GET | `/qc-report/instance/snapshot?id=` | å–冻结的数据快照 | `qc-report:instance:query` |
| GET | `/qc-report/instance/page` | åˆ†é¡µ | `qc-report:instance:query` |
| POST | `/qc-report/instance/regenerate?id=` | ç”¨å†»ç»“å¿«ç…§ + åŒä¸€æ¨¡æ¿ç‰ˆæœ¬é‡æ¸²ï¼Œè¦†ç›–产物 HTML | `qc-report:instance:create` |
| POST | `/qc-report/instance/pdf?id=` | **打印实例已存的产物 HTML æˆ PDF å¹¶å½’æ¡£**,可重复调用 | `qc-report:instance:export` |
| DELETE | `/qc-report/instance/delete?id=` | åˆ é™¤å®žä¾‹ï¼ˆ**已归档的 PDF ä¸€å¹¶æ¸…理**) | `qc-report:instance:delete` |
> âœ… å‰ç«¯å®žä¾‹é¡µè½åœ°æ—¶ï¼Œ`qc-report:instance:*` çš„菜单与按钮权限**已同步补入 `system_menu`**
> ï¼ˆèœå•「报告实例」+ æŸ¥è¯¢/出件/导出PDF/删除四条按钮 + `qc-report:template:ai-import` ä¸€æ¡ï¼‰ã€‚
> æœªæ’这些行之前,非超管角色调这些接口会 403;超管由代码直返全部菜单,联调从不受影响。
### å‡ºä»¶ / é¢„览请求参数(`preview` ä¸Ž `generate` åŒä¸€å¥—)
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|---|---|---|---|
| `templateId` | Long | **是** | æ¨¡æ¿ç¼–号 |
| `version` | String | å¦ | æ¨¡æ¿ç‰ˆæœ¬å·ï¼Œå¦‚ `v1.0`。不传则取该模板的 `currentVersion` |
| `reportNo` | String | å¦ | æŠ¥å‘Šç¼–号。传了就用它(撞号会报错),不传由后端自动生成 |
| `businessId` | String | å¦ | ä¸šåŠ¡å•æ®ç¼–å·ï¼Œä»…ç•™ç—•ä¸ŽæŸ¥è¯¢ç”¨ |
| `businessType` | String | å¦ | ä¸šåŠ¡å•æ®ç±»åž‹ï¼Œå¦‚ `mes_qc_oqc`,仅留痕与查询用 |
| `context` | Object | **是** | æŠ¥å‘Šæ•°æ®ï¼Œç»“构见下 |
**`context` ç»“æž„**(与设计器里的数据契约同构)
| å­—段 | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|---|---|---|---|
| `report` | Object | å¦ | æŠ¥å‘Šçº§å­—段。显式传 `null` ä¼šè¢«åŽç«¯å…œæˆç©ºå­—段集,不会报错 |
| `report.reportNo` | String | å¦ | æŠ¥å‘Šç¼–号。**顶层 `reportNo` ä¼˜å…ˆäºŽå®ƒ** |
| `report.reportName` | String | å¦ | æŠ¥å‘Šåç§° |
| `report.sampleNo` | String | å¦ | æ ·å“ç¼–号 |
| `report.productCode` | String | å¦ | äº§å“ç¼–码 |
| `report.productName` | String | å¦ | äº§å“åç§° |
| `report.spec` | String | å¦ | è§„格型号 |
| `report.batchNo` | String | å¦ | æ‰¹æ¬¡å· |
| `report.workOrderNo` | String | å¦ | å·¥å•号 |
| `report.inspectType` | String | å¦ | æ£€éªŒç±»åž‹ |
| `report.inspector` | String | å¦ | æ£€éªŒå‘˜ |
| `report.inspectDate` | String | å¦ | æ£€éªŒæ—¥æœŸ |
| `report.department` | String | å¦ | éƒ¨é—¨ |
| `report.customerName` | String | å¦ | å®¢æˆ·åç§° |
| `report.supplierName` | String | å¦ | ä¾›åº”商名称 |
| `report.result` / `report.resultText` / `report.conclusion` / `report.passRate` | String | å¦ | **判定结果,由后端算出并覆盖**,调用方不用传 |
| `report.total` / `report.passCount` / `report.failCount` | Number | å¦ | **同上,由后端算出** |
| `inspectionItems` | Array | **是** | æ£€éªŒé¡¹åˆ—表,**不能为空**(空数组会报错,见「错误码」) |
| `inspectionItems[].index` | Number | å¦ | åºå·ï¼Œæ¸²æŸ“ `{{index}}` ç”¨ |
| `inspectionItems[].itemCode` | String | å¦ | æ£€éªŒé¡¹ç¼–码 |
| `inspectionItems[].itemName` | String | å¦ | æ£€éªŒé¡¹åç§° |
| `inspectionItems[].standardValue` | String | å¦ | æ ‡å‡†å€¼ï¼ˆæ–‡æœ¬ï¼Œå¦‚ `10±0.5`) |
| `inspectionItems[].actualValue` | String | å¦ | å®žæµ‹å€¼ |
| `inspectionItems[].unit` | String | å¦ | å•位 |
| `inspectionItems[].upperLimit` / `lowerLimit` | Number | å¦ | è§„格上下限。**不传就是没有规格区间**,判定会落到规则或数据自带结果上 |
| `inspectionItems[].result` / `resultText` | String | å¦ | åˆ¤å®šç»“果,**由后端算出并覆盖** |
| `inspectionItems[].remark` | String | å¦ | å¤‡æ³¨ |
**请求示例**
```json
{
  "templateId": 5,
  "version": "v1.0",
  "businessId": "OQC20260918001",
  "businessType": "mes_qc_oqc",
  "context": {
    "report": { "reportName": "出货检验报告", "productName": "法兰", "inspector": "张三" },
    "inspectionItems": [
      { "index": 1, "itemName": "外观", "standardValue": "无划痕", "actualValue": "合格" },
      { "index": 2, "itemName": "长度", "standardValue": "10±0.5", "actualValue": 10.2, "upperLimit": 10.5, "lowerLimit": 9.5 }
    ]
  }
}
```
### å“åº”字段
**`preview` å“åº”**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|---|---|---|
| `reportNo` | String | æœ¬æ¬¡æ¸²æŸ“用的编号。**来自入参**,没传就是空 â€”— é¢„览不生成编号 |
| `html` | String | å®Œæ•´ HTML æ–‡æ¡£ï¼Œå¯ç›´æŽ¥é¢„览/打印 |
| `errors` | Array\<String\> | æ•°æ®ç¼ºå£æ¸…单,见下 |
**`generate` å“åº”**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|---|---|---|
| `id` | Long | å®žä¾‹ç¼–号,拿它去查详情 |
| `reportNo` | String | æœ€ç»ˆç”Ÿæ•ˆçš„æŠ¥å‘Šç¼–号(自动生成或入参指定) |
| `templateVersion` | String | æœ¬æ¬¡ä½¿ç”¨çš„æ¨¡æ¿ç‰ˆæœ¬å· |
| `status` | Number | æŠ¥å‘ŠçŠ¶æ€ï¼š`0` ç”Ÿæˆä¸­ / `1` ç”ŸæˆæˆåŠŸ / `2` ç”Ÿæˆå¤±è´¥ã€‚当前只会返回 `1` |
| `errors` | Array\<String\> | æ•°æ®ç¼ºå£æ¸…单 |
**`regenerate` å“åº”**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|---|---|---|
| `id` | Long | å®žä¾‹ç¼–号 |
| `reportNo` | String | æŠ¥å‘Šç¼–号(与生成时相同) |
| `errors` | Array\<String\> | æ•°æ®ç¼ºå£æ¸…单 |
**`pdf` å“åº”**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|---|---|---|
| `id` | Long | å®žä¾‹ç¼–号 |
| `reportNo` | String | æŠ¥å‘Šç¼–号 |
| `fileName` | String | PDF æ–‡ä»¶åï¼Œè§„则是 `<报告编号>.pdf`(如 `QR20260918-0001.pdf`) |
| `byteSize` | Long | PDF å­—节数 |
| `blobId` | Long | æ–‡ä»¶ç¼–号(`system_storage_blob` ä¸»é”®ï¼‰ |
| `attachmentId` | Long | é™„件关联编号(`system_storage_attachment` ä¸»é”®ï¼‰ |
| `previewURL` | String | é¢„览地址。**临时签名,会过期**,别存起来长期用 |
| `downloadURL` | String | ä¸‹è½½åœ°å€ã€‚**临时签名,会过期**,下载动作请拿即时返回的这个值 |
| `durationMs` | Long | æœ¬æ¬¡å‡ºä»¶æ€»è€—时(毫秒),含排队等并发名额、必要时重启浏览器的等待 |
| `pdfDurationMs` | Long | æ‰“印耗时(毫秒),仅含等页面资源与打印。与 `durationMs` ä¹‹å·®å°±æ˜¯ç­‰å¾…成本 |
| `browserStatus` | String | `READY`(复用了已在运行的浏览器)/ `RESTARTED`(本次重启了浏览器)。**仅供参考与排查**,前端不需要分支处理 |
> `pdf` æ˜¯**显式触发**的:用户点「导出 PDF」才生成。它不会随 `generate` / `regenerate` è‡ªåŠ¨è·‘ï¼Œ
> å› ä¸ºæ¯å¯¼ä¸€æ¬¡éƒ½è¦æ‹‰èµ· Chromium æ‰“印(秒级),出件接口不该背这个开销。
**`get` å“åº”**:`id`、`reportNo`、`templateId`、`templateVersion`、`businessId`、`businessType`、`renderHtml`、`status`、`creator`、`createTime`、`updateTime`。
**注意不含 `dataSnapshot`**(体积大且列表/详情用不到),要它请调 `snapshot`。
**`snapshot` å“åº”**:`{ "report": {...}, "inspectionItems": [...] }`,即出件当时冻结的那份数据(含算好的 PASS/FAIL ä¸Žåˆæ ¼çŽ‡ï¼‰ã€‚
**`page` è¯·æ±‚参数**(其余为通用分页参数 `pageNo` / `pageSize`)
| å‚æ•° | ç±»åž‹ | è¯´æ˜Ž |
|---|---|---|
| `templateId` | Long | æ¨¡æ¿ç¼–号,精确匹配 |
| `reportNo` | String | æŠ¥å‘Šç¼–号,**模糊匹配** |
| `businessType` | String | ä¸šåŠ¡å•æ®ç±»åž‹ï¼Œç²¾ç¡®åŒ¹é… |
| `businessId` | String | ä¸šåŠ¡å•æ®ç¼–å·ï¼Œç²¾ç¡®åŒ¹é… |
| `status` | Number | æŠ¥å‘ŠçŠ¶æ€ï¼Œç²¾ç¡®åŒ¹é… |
| `createTime` | Array\<String\> | åˆ›å»ºæ—¶é—´åŒºé—´ï¼Œ`["开始","结束"]`,格式 `yyyy-MM-dd HH:mm:ss` |
### PDF å½’档怎么查(不新增专用查询接口)
报告实例表里**没有也不会有** `pdfUrl` / `pdfPath` è¿™ç±»å­—段。PDF ç»Ÿä¸€é€šè¿‡ system æ¨¡å—的附件库里查,
走的是全项目通用的附件接口(ERP / CRM é¡µé¢åŒæ¬¾å§¿åŠ¿ï¼‰ï¼š
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|---|---|---|
| GET | `/system/storage-attachment/list?recordType=qc_report_instance&recordId=<实例编号>` | æŸ¥è¿™ä»½æŠ¥å‘Šå½’档的 PDF |
| å‚æ•° | å€¼ | è¯´æ˜Ž |
|---|---|---|
| `recordType` | å›ºå®š `qc_report_instance` | æŠ¥å‘Šå®žä¾‹çš„记录类型,写死这个值 |
| `recordId` | æŠ¥å‘Šå®žä¾‹ç¼–号(`instance.id`) | |
响应是一个数组,每项含 `url` / `previewURL` / `downloadURL` / `name` / `id`(附件编号)等。
**正常情况数组里最多一条**(再次导出会替换掉旧的,不会越导越多)。
前端展示「PDF」按钮的逻辑建议:
1. è¿›è¯¦æƒ…页时调一次附件列表接口,有数据 â†’ æ˜¾ç¤ºã€Œä¸‹è½½ PDF」;没有 â†’ æ˜¾ç¤ºã€Œå¯¼å‡º PDF」。
2. ç‚¹ã€Œå¯¼å‡º PDF」调 `pdf` æŽ¥å£ï¼ŒæˆåŠŸåŽç”¨è¿”å›žçš„ `downloadURL` ç›´æŽ¥ä¸‹è½½ï¼ˆæˆ–重新查一次附件列表刷新状态)。
3. **不要把 `downloadURL` å­˜è¿›å‰ç«¯çŠ¶æ€é•¿æœŸä½¿ç”¨**:它是带签名的临时地址,过期后会 401/403。
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|---|---|---|
| `reportNo` | åˆ—表列、详情头部、HTML å†… | åˆ—表建议加「复制编号」;**它是业务唯一键,不能重复** |
| `templateVersion` | åˆ—表列、详情 | å»ºè®®æ˜¾ç¤ºæˆã€Œæ¨¡æ¿å v1.0」;历史报告要能看出用的是哪一版 |
| `businessType` / `businessId` | åˆ—表列、详情 | å»ºè®®ç”¨å­—典把 `businessType` è½¬æˆä¸­æ–‡ï¼ˆå¦‚ `mes_qc_oqc` â†’ å‡ºè´§æ£€éªŒå•) |
| `status` | åˆ—表列 | ç”¨å­—å…¸ `qc_report_instance_status` æ¸²æŸ“标签(生成中/生成成功/生成失败) |
| `renderHtml` | è¯¦æƒ…、预览 | ç”¨ iframe æˆ–整块 HTML å®¹å™¨æ¸²æŸ“ï¼›**不要把 HTML å½“纯文本展示** |
| PDF æ˜¯å¦å­˜åœ¨ | è¯¦æƒ…页「下载 PDF / å¯¼å‡º PDF」按钮、列表列 | æ•°æ®æ¥æºæ˜¯é™„件列表接口(`recordType=qc_report_instance`),不是实例表。`regenerate` ä¹‹åŽè¦åˆ·æ–°è¿™ä¸ªçŠ¶æ€ |
| `creator` / `createTime` | åˆ—表列、详情 | â€” |
| `errors` | å‡ºä»¶/预览/重新生成后 | **用可折叠面板或提示条展示**,不要静默丢弃,见「业务规则」 |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|---|---|
| **报告编号谁生成** | åŽç«¯ã€‚格式 `QR + yyyyMMdd + '-' + 4 ä½æµæ°´`(如 `QR20260918-0001`),**按天独立,跨天从 0001 é‡æ¥**。传入 `reportNo` å¯è¦†ç›– |
| **指定编号撞号** | æŠ¥é”™å¹¶**指明是哪个编号**(不是笼统「编号已存在」)。要么换编号,要么留空让后端生成 |
| **预览不消耗序号** | `preview` åªæ¸²æŸ“,不落库、不取流水号。可以反复点预览,不会把当天的编号跳号 |
| **删掉的编号不会被重用** | åˆ é™¤æŠ¥å‘Šæ˜¯è½¯åˆ é™¤ï¼Œç¼–号仍然占位,当天的流水**只增不减**。删掉 `…-0001` ä¹‹åŽä¸‹ä¸€ä»½æ˜¯ `…-0002`,**不会回到 0001**。前端不要假设「编号连续」 |
| **用哪个版本(出件)** | `generate` **要求版本已发布**,草稿会被拒(`1_070_101_003`)。出件会把版本号冻结进实例、而实例并不存内容,用草稿出件等于给历史报告埋一颗「版本号对得上、内容已经变了」的雷 |
| **用哪个版本(预览)** | `preview` **接受草稿版本,也接受已停用版本**,只要版本存在且有画布内容即可。设计器的主流程就是「保存草稿 â†’ ç‚¹é¢„览」,草稿会变在预览这里没有后果(不落库、不冻结数据、不消耗编号) |
| **`currentVersion` ä¸ºç©ºæ—¶** | ä¸ä¼  `version` ä¸”模板 `currentVersion` ä¸ºç©º â†’ `1_070_101_007`。**此时 `preview` ä¹Ÿæ‹¿ä¸åˆ°ç‰ˆæœ¬**,设计器预览必须显式传 `version`(如刚保存的草稿 `v1.1`) |
| **数据快照冻结** | å‡ºä»¶æ—¶æŠŠã€Œåˆ¤å®šåŽçš„æ•°æ®ã€æ•´ä»½å†»ç»“进实例。业务系统里的数据后来怎么变,**都不影响这份历史报告** |
| **重新生成动的是什么** | åªé‡æ¸²**排版产物**,用的是**实例里冻结的快照**与**同一个模板版本**。数据、报告编号都不变 |
| **重新生成不要求版本仍已发布** | ç‰ˆæœ¬åŽæ¥è¢«åœç”¨ï¼ŒåŽ†å²æŠ¥å‘Šä¹Ÿä¸èƒ½å› æ­¤æ‰“ä¸å¼€ï¼Œæ‰€ä»¥ `regenerate` å…è®¸ç”¨å·²åœç”¨çš„版本 |
| **判定结果由后端算** | `result` / `resultText` / `conclusion` / `passRate` / `total` / `passCount` / `failCount` ä¸€å¾‹ä»¥**后端算出的**为准,调用方传了也会被覆盖 |
| **`result` ä¸Ž `resultText` åˆ†å·¥** | `result` æ˜¯æœºå™¨å€¼ï¼ˆ`PASS` / `FAIL` / ç©ºå­—符串),`resultText` æ˜¯ç»™äººçœ‹çš„中文(合格 / ä¸åˆæ ¼ / å¾…判定)。**画布上显示的是 `resultText`**,所以产物 HTML é‡Œæœä¸åˆ° `PASS` å­—样,别据此以为没判定 |
| **无法判定的项** | æ—¢æ²¡æœ‰è§„格上下限、也没有命中任何规则的检验项 â†’ `result` ä¸ºç©ºã€`resultText` ä¸º `待判定`,**不计入 `passCount`/`failCount` ä½†è®¡å…¥ `total`**,所以可能出现 `total=3 pass=1 fail=1`(合格率按 `pass/total` ç®—)。同时它会出现在 `errors` é‡Œï¼ˆå¦‚「第 1 é¡¹ã€Œå¤–观」没有规格上下限也没有判定规则,无法判定」) |
| **数据缺口清单** | æ¨¡æ¿ç»‘定了但数据里没有的字段、以及被安全策略拦下的模板属性,都会出现在 `errors` é‡Œã€‚**它只在 preview/generate/regenerate çš„响应里出现,不落库** â€”— äº‹åŽå›žçœ‹åŽ†å²æŠ¥å‘Šæ—¶**看不到**当时有哪些字段缺值。需要留痕请在出件时自行记录 |
| **PDF æ‰“的是哪份 HTML** | æ‰“的是**实例里已经存着的那份 HTML**,不重新渲染。所以「详情页看到的」与「打出来的」必然出自同一份字节,**不会出现页面上好好的、打出来变形** |
| **重渲即作废已归档 PDF** | è°ƒ `regenerate` ä¹‹åŽï¼Œä¹‹å‰å¯¼å‡ºçš„ PDF **会被作废**(附件列表会变成空)。原因:产物 HTML æ¢äº†ï¼Œå†ç•™ç€æ—§ PDF å°±æ˜¯ã€Œé¡µé¢æ˜¯æ–°æŽ’版、下载到的是旧文件」的静默不一致。**前端在 `regenerate` æˆåŠŸåŽè¦æŠŠã€Œä¸‹è½½ PDF」按钮恢复成「导出 PDF」** |
| **PDF çº¸é¢ä¸Žé¡µé¢é¢„览一致** | A4/A3/A5/Letter、横竖向、页边距都取自模板的纸张配置,与 HTML é‡Œ `@page` ç”¨çš„æ˜¯åŒä¸€ä»½æ•°å­—,不存在「预览 210mm、PDF æ‰“ 210.5mm」这类漂移 |
| **PDF æ˜¯"显式生成"的** | åªæœ‰è°ƒ `pdf` æŽ¥å£æ‰ä¼šæœ‰ PDF。`generate` / `regenerate` éƒ½ä¸ä¼šé¡ºå¸¦ç”Ÿæˆ PDF |
| **重复导出是安全的** | å†å¯¼ä¸€æ¬¡ä¼šæ›¿æ¢æŽ‰æ—§çš„归档(附件仍是 1 æ¡ï¼‰ï¼Œä¸ä¼šè¶Šå¯¼è¶Šå¤šã€ä¹Ÿä¸ä¼šç•™å­¤å„¿æ–‡ä»¶ã€‚**可以放心让用户重复点** |
| **出件很慢时先告诉用户** | é¦–次导出或浏览器刚崩过要重启时需要几秒,接口在 `durationMs` é‡Œå¦‚实返回。前端建议点按钮后转圈直到响应,不要自己设定时器判超时 |
| **并发上限** | æœåŠ¡ç«¯åŒæ—¶è¿›è¡Œçš„ PDF æ‰“印有上限(默认 2),排队超过 30 ç§’会返回 `1_070_103_007`。批量导出时不要并发猛打,串行或小并发 |
| **单份报告文件多页** | é•¿æŠ¥å‘Šï¼ˆæ£€éªŒé¡¹å¤šï¼‰ä¼šè‡ªåŠ¨åˆ†é¡µï¼Œ`printBackground` å·²å¼€ï¼Œåˆ¤å®šåº•色会打出来;表头跨页重复 |
| **PDF åŠŸèƒ½å¯è¢«è¿ç»´å…³æŽ‰** | éƒ¨ç½²çŽ¯å¢ƒæ²¡æµè§ˆå™¨æ—¶ä¼šå…³æŽ‰ï¼ˆ`yudao.qcreport.pdf.enabled=false`),此时 `pdf` æŽ¥å£è¿”回 `1_070_103_008`。前端应原样提示,不要当成系统异常 |
| **删除实例** | è½¯åˆ é™¤ã€‚删掉后 `get` è¿”回 `null`(不报错),前端要兜空。**已归档的 PDF ä¼šä¸€å¹¶æ¸…理** |
| **模板停用** | åœç”¨çš„æ¨¡æ¿**不能出新件**;已有历史报告不受影响 |
## æ³¨æ„äº‹é¡¹
- **列表/详情不要展示 `dataSnapshot` å­—段**:`get` ä¸Ž `page` éƒ½ä¸è¿”回它,这是有意的(体积大)。
- **`get` å¯¹å·²åˆ é™¤çš„实例返回 `data:null`**,不是报错。所有按 `id` å–详情的入口都要判空。
- **HTML æ¸²æŸ“要注意样式隔离**:产物是完整 HTML æ–‡æ¡£ï¼ˆè‡ªå¸¦ `@page` ä¸Žæ ·å¼ï¼‰ï¼Œ
  ç›´æŽ¥å¡žè¿›é¡µé¢ä¼šæ±¡æŸ“全局样式,建议用 `iframe` æ‰¿è½½ã€‚
- **预览响应里的 `reportNo` å¯èƒ½ä¸ºç©º**,这是正常的(预览不生成编号),不要拿它当出件结果。
- **`inspectionItems` ä¸èƒ½ä¼ ç©ºæ•°ç»„**:没有检验项的「报告」没有意义,后端会直接报错。
- **`businessId` ä¸ä¼šæ˜¯æ•°å­—类型**:虽然表里是 `varchar`,但调用方给的可能是工单号、批次号一类的字符串,前端别按 Long å¤„理。
- **`errors` å¯èƒ½å¾ˆé•¿**,尤其模板刚做好时。建议折叠展示 + æ˜¾ç¤ºæ¡æ•°ã€‚
- **权限**:接口权限码是 `qc-report:instance:query` / `:create` / `:delete` / `:export`。
  å·²å…¥åº“到 `system_menu`(见「API」节下的说明)。`create` æ˜¯**预置**的:本轮页面没有出件入口,
  å®ƒæ˜¯ç»™åŽç»­ `MesQcReportApi` è½®å‡†å¤‡çš„,不是遗漏。
- **「AI å¯¼å…¥ã€æŒ‰é’®ç”¨ç‹¬ç«‹æƒé™ç  `qc-report:template:ai-import`**:能改模板不等于能用大模型识别文件
  ï¼ˆæ¯æ¬¡è°ƒç”¨éƒ½äº§ç”Ÿè´¹ç”¨ï¼‰ï¼Œä¸¤è€…必须分开授权,不能合并到模板的编辑权限里。
- **预览必须 `sandbox=""`**(空属性,不是省略该属性):空值 = å®Œå…¨ç¦ç”¨è„šæœ¬ã€è¡¨å•ä¸ŽåŒæºï¼Œåªè®© CSS ç”Ÿæ•ˆã€‚
  äº§ç‰© HTML æ˜¯æ¸²æŸ“结果,不需要也不应该让它执行任何东西。
- **重复导出不堆文件已实测**:同一实例连点 3 æ¬¡ï¼Œåº“中始终只有 1 æ¡æœ‰æ•ˆé™„ä»¶
  ï¼ˆå‰ä¸¤æ¡é™„件行与 blob è¡Œå‡ä¸º `deleted=1`),磁盘目录里也只留当前这一份 PDF。
- **导出的 PDF ä¸­æ–‡ä¾èµ–服务器的字体**:开发机(Windows)自带微软雅黑没问题;
  ç”Ÿäº§ Linux æœåŠ¡å™¨è‹¥æ²¡è£… CJK å­—体会打成方框。这属于部署前置,前端不需要处理,但**验收时要在目标环境上看一眼中文**。
- **`pdf` æŽ¥å£çš„返回地址是签名的临时地址**,带过期时间与使用次数限制,
  **不要**把它写进数据库、也不要放进列表接口缓存,需要时重新查附件列表或重新导出。
- **附件列表里的 `name` æ˜¯ PDF æ–‡ä»¶å**(`QR20260918-0001.pdf`),下载时用它命名即可。
## é”™è¯¯ç 
| é”™è¯¯ç  | æŠ¥æ–‡ | è§¦å‘场景 |
|---|---|---|
| `1_070_100_000` | æ¨¡æ¿ä¸å­˜åœ¨ | `templateId` æ‰¾ä¸åˆ° |
| `1_070_100_002` | è¯¥æ¨¡æ¿å·²åœç”¨ï¼Œä¸èƒ½ç”ŸæˆæŠ¥å‘Š | æ¨¡æ¿ `status=1` |
| `1_070_101_000` | æ¨¡æ¿ç‰ˆæœ¬ä¸å­˜åœ¨ | `version` ä¼ äº†ä½†æŸ¥ä¸åˆ° |
| `1_070_101_003` | è¯¥æ¨¡æ¿ç‰ˆæœ¬æœªå‘布 | `generate` çš„ `version` æŒ‡å‘草稿(**`preview` ä¸æŠ¥è¿™ä¸ªé”™**) |
| `1_070_101_007` | è¯¥æ¨¡æ¿è¿˜æ²¡æœ‰å·²å‘布的版本,无法生成报告,请先在设计器中发布一个版本 | ä¸ä¼  `version` ä¸”模板 `currentVersion` ä¸ºç©ºï¼ˆ`preview` åŒæ ·ä¼šæŠ¥ï¼Œæ­¤æ—¶é¡»æ˜¾å¼ä¼  `version`) |
| `1_070_101_008` | è¯¥æ¨¡æ¿ç‰ˆæœ¬æ²¡æœ‰ç”»å¸ƒå†…容,无法生成报告,请先在设计器中设计并保存模板内容 | ç‰ˆæœ¬æœ‰è®°å½•但画布是空的 |
| `1_070_102_000` | æŠ¥å‘Šå®žä¾‹ä¸å­˜åœ¨ | `id` æ‰¾ä¸åˆ°ï¼ˆ`get` é™¤å¤–,它返回 null) |
| `1_070_102_001` | æŠ¥å‘Šç¼–号「X」已被占用,请换一个编号,或留空由系统按 QR+日期+流水 è‡ªåŠ¨ç”Ÿæˆ | æŒ‡å®šäº†é‡å¤ç¼–号。**含已被软删除的编号**(删掉的号不回收) |
| `1_070_102_001` | è‡ªåŠ¨ç”ŸæˆæŠ¥å‘Šç¼–å·æ—¶è¿žç»­ 5 æ¬¡ä¸Žå·²æœ‰æŠ¥å‘Šå†²çªï¼Œè¯·ç¨åŽé‡è¯•;也可在请求里显式指定一个未被占用的报告编号 | ä»…在同一时刻有多个出件请求抢同一个流水号时出现;正常使用不应看到 |
| `1_070_102_002` | æŠ¥å‘Šå®žä¾‹ç¼ºå°‘数据快照,无法重新生成 | å¿«ç…§ä¸ºç©ºçš„脏数据 |
| `1_070_102_003` | æŠ¥å‘Šå®žä¾‹æ²¡æœ‰æ¸²æŸ“产物 HTML,无法生成 PDF,请先对该报告执行「重新生成」 | `pdf` æ—¶å®žä¾‹çš„ `render_html` ä¸ºç©ºï¼ˆè„æ•°æ®ï¼‰ã€‚**提示里已给出动作:先「重新生成」** |
| `1_070_103_004` | PDF æ¸²æŸ“浏览器启动失败,请检查 Chromium æ˜¯å¦å·²å®‰è£… | æœåŠ¡å™¨æ²¡è£… Chrome / é…ç½®çš„ `executable-path` ä¸å¯¹ã€‚报文里会带上当前配置的浏览器来源与实际原因 |
| `1_070_103_005` | PDF ç”Ÿæˆå¤±è´¥ | æ‰“印过程中浏览器报错(含等页面资源超时) |
| `1_070_103_006` | PDF å·²ç”Ÿæˆï¼Œä½†å½’档到附件库失败 | æ‰“印成功、存档失败。**注意它不等于「PDF æ²¡ç”Ÿæˆã€**,前端的提示文案不要写成「生成失败」 |
| `1_070_103_007` | å½“前 PDF æ¸²æŸ“任务已达上限,请稍后重试 | å¹¶å‘排队超过 30 ç§’。报文里带当前并发上限值 |
| `1_070_103_008` | PDF å‡ºä»¶åŠŸèƒ½æœªå¯ç”¨ï¼Œè¯·è”ç³»ç®¡ç†å‘˜ | è¿ç»´æŠŠ `yudao.qcreport.pdf.enabled` å…³æˆäº† `false` |
| `1_070_103_000` | æ£€éªŒé¡¹åˆ—表为空,没有可判定的内容:请至少传入一个检验项(inspectionItems)再出件 | `inspectionItems` ä¸ºç©º |
docs/qc_report_template_version_frontend_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,124 @@
# æŠ¥å‘Šæ¨¡æ¿ç‰ˆæœ¬ç®¡ç† - å‰ç«¯è”调方案(保存校验 + åœç”¨çº¦æŸï¼‰
## æ¶‰åŠé¡µé¢
- æŠ¥å‘Šæ¨¡æ¿è®¾è®¡å™¨ï¼ˆä¿å­˜ / å¦å­˜æ–°ç‰ˆæœ¬ï¼Œèµ° `create` ä¸Ž `update`)
- æŠ¥å‘Šæ¨¡æ¿è¯¦æƒ… â†’ ç‰ˆæœ¬åˆ—表(发布 / å›žæ»š / åœç”¨ï¼‰
## æœ¬æ¬¡å˜æ›´æ¦‚è¿°
`qc_report_template_version` çš„写入口新增两条**拒绝规则**,各对应一个新错误码。
两条都不是新增字段、也不是新增接口,而是把「以前会静默存下 / å­˜ä¸‹åŽæ‰ç‚¸ã€çš„æƒ…况改成**当场报错**。
前端需要做的是:把后端返回的 `msg` åŽŸæ ·å±•ç¤ºï¼ˆæ–‡æ¡ˆå·²å¯ç›´æŽ¥ç»™ç”¨æˆ·çœ‹ï¼‰ï¼Œå¹¶ä¿è¯è¿™ä¸¤ç§å¤±è´¥**不弹通用「系统异常」**。
| æŽ¥å£ | æ–¹æ³• | æ–°å¢žæ‹’绝规则 | é”™è¯¯ç  |
|---|---|---|---|
| `/qc-report/template-version/create` | POST | ç”»å¸ƒå«ä¸å…è®¸æ¸²æŸ“的内容 | `1_070_101_009` |
| `/qc-report/template-version/update` | PUT | åŒä¸Š | `1_070_101_009` |
| `/qc-report/template-version/disable` | POST | ç›®æ ‡æ˜¯æ¨¡æ¿çš„**当前版本** | `1_070_101_010` |
> `create` ä¸Ž `update` å…±ç”¨åŒä¸€æ®µ Schema æ ¡éªŒï¼Œä¸¤æ¡å…¥å£è¡Œä¸ºä¸€è‡´ï¼š**只要提交的 Schema ä¸åˆæ ¼ï¼Œä¸€å¾‹ä¸è½åº“**。
## è§„则一:画布内容安全校验(`1_070_101_009`)
### æŒ¡çš„æ˜¯ä»€ä¹ˆ
报告产物是拼字符串拼出来的,模板画布里有三处内容会**不经转义**直接进入 HTML:
1. **节点的标签名**(如 `tagName: "h2"`);
2. **属性名**(属性**值**会转义,属性**名**不会);
3. **整段 CSS**(`styles` å†™æˆå­—符串时会被原样塞进 `<style>`)。
模板画布又是用户可编辑数据,所以这三处都是可被写入的注入面。
### æ ¡éªŒè§„则
| ç»´åº¦ | è§„则 |
|---|---|
| æ ‡ç­¾å | å¿…须落在白名单内:常规排版与文本标签(`div/p/span/h1~h6/img/hr`、表格一族、列表一族、常见行内语义标签)。脚本、样式、内嵌页面、表单类标签一律拒绝 |
| æ ‡ç­¾å | éœ€**整体**匹配白名单,`"img src=x onerror=alert(1)"` è¿™ç§ã€Œæ ‡ç­¾åé‡Œå¡žå±žæ€§ã€çš„写法会被拒 |
| å±žæ€§å | åªå…è®¸å­—母 / æ•°å­— / `-` `_` `:` `.`,且不能以数字开头;含引号或空格的属性名会被拒(它能顶出一个新属性) |
| å±žæ€§å | ä»¥ `on` å¼€å¤´çš„一律视为事件属性,拒绝 |
| `styles` | å¿…须是**样式规则数组**;写成一段 CSS æ–‡æœ¬ä¼šè¢«æ‹’(改样式走设计器的样式面板,保存后自然是规则数组) |
| `selectors` / `mediaText` / æ ·å¼å±žæ€§åä¸Žå±žæ€§å€¼ | å‡ºçް `<` å³æ‹’绝(`</style>` èƒ½æå‰é—­åˆæ ·å¼å—,把后面的内容变成真 HTML) |
### ä¸ä¼šè¯¯æ€çš„三种情况
设计器正常保存的画布**不会**被拦,以下三类在实现里被明确排除,前端无需特殊处理:
- **`textnode` èŠ‚ç‚¹ä¸Šçš„ `tagName`**:渲染器根本不读它;
- **GrapesJS æŒ‚在节点上的 `docEl` / `head` å…ƒæ•°æ®**:渲染器不读,校验也只沿 `components` èµ°ï¼›
- **存量画布**:已对运行库中全部真实版本实测通过。
### æŠ¥é”™æ–‡æ¡ˆ
一次最多返回 **8 æ¡**问题,每条都带**位置**与**违规内容**,形如:
```
模板画布里有不允许出现的内容,已拒绝保存:画布「根 > ç¬¬ 1 ä¸ªç»„件」的标签是「script」,
报告不支持该标签。可用的只有常规排版标签(div/p/span/h1~h6/img/hr ä¸Žè¡¨æ ¼ã€åˆ—表、行内文本标签),
脚本、样式、内嵌页面、表单类标签一律不允许。请在设计器中删掉这些内容后重新保存
```
多条问题以 `;` è¿žæŽ¥ã€‚改完再保存一次即可看到下一批。
## è§„则二:不能停用「当前版本」(`1_070_101_010`)
### æŒ¡çš„æ˜¯ä»€ä¹ˆ
模板表上的 `current_version` æŒ‡å‘「出件时默认用哪个版本」。
停用某个版本**不会**清空 `current_version`,于是停用当前版本会造出一份**自相矛盾**的模板:
`current_version` è¿˜æŒ‡ç€å®ƒï¼Œå®ƒå´å·²ç»ä¸å¯ç”¨ã€‚这种模板自此出不了报告,
而且失败发生在**出件那一刻**(报 `1_070_101_003`),用户不会想到起因是之前那次停用。
因此 `disable` ç›´æŽ¥æ‹’绝,**不会**顺手替用户改掉 `current_version`——
「改哪个版本」是用户的决定,不该由「停用」这个动作悄悄代劳。
### æŠ¥é”™æ–‡æ¡ˆ
```
版本「v1.1」是模板「来料检验报告模板」当前正在使用的版本,不能停用。
请先到该模板的版本列表里对另一个已发布版本执行「回滚」,把当前版本切走,再停用本版本;
若要让整个模板停止使用,请改为停用模板本身
```
文案里给出了**两条可执行出路**,前端不需要自己组织引导话术。
### è¢«æ‹’的判定口径
- æ¯”较对象是**模板表 `current_version` çš„字符串**与**该版本记录的 `version`**;
- åªè¦ç›¸ç­‰å°±æ‹’绝,**不看版本是否已发布**(草稿版本同样可能被 `current_version` æŒ‡åˆ°ï¼‰ï¼›
- åœç”¨**非当前版本**的已发布版本依然允许,行为不变。
## å­—段展示规则
本次无新增字段,无需调整列表或表单列。
| åœºæ™¯ | å‰ç«¯è¡¨çް |
|---|---|
| ä¿å­˜æ¨¡æ¿æŠ¥ `1_070_101_009` | ç”¨ `msg` å…¨æ–‡æç¤ºï¼ˆå¯èƒ½å«å¤šæ¡ï¼Œç”¨ `;` åˆ†éš”);**不要**截断成「保存失败」 |
| åœç”¨ç‰ˆæœ¬æŠ¥ `1_070_101_010` | ç”¨ `msg` å…¨æ–‡æç¤ºï¼›æç¤ºæ–‡æ¡ˆå·²å«åŽç»­æ“ä½œæ­¥éª¤ï¼Œæ— éœ€å¦åŠ æŒ‡å¼• |
| å…¶å®ƒé”™è¯¯ç  | è¡Œä¸ºä¸å˜ |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|---|---|
| æ–°å»ºç‰ˆæœ¬ / æ›´æ–°ç‰ˆæœ¬æ—¶ Schema ä¸ºç©º | å…è®¸ï¼ˆå…ˆå»ºç‰ˆæœ¬åŽè®¾è®¡ï¼‰ï¼Œä¸è§¦å‘画布校验 |
| æ–°å»ºç‰ˆæœ¬ / æ›´æ–°ç‰ˆæœ¬æ—¶ Schema éžç©ºä½†ç¼º `schemaVersion` | æ‹’绝,`1_070_101_004` |
| ç”»å¸ƒæœ‰é—®é¢˜ | æ‹’绝保存,`1_070_101_009`,**不落库** |
| æ›´æ–°**已发布**版本 | ä»ç„¶æ‹’绝,`1_070_101_002`(改动只能另存新草稿),与本次变更无关 |
| åœç”¨å½“前版本 | æ‹’绝,`1_070_101_010` |
| åœç”¨éžå½“前版本 | æ­£å¸¸åœç”¨ |
| åœç”¨æ•´ä¸ªæ¨¡æ¿ | èµ°ã€Œåœç”¨æ¨¡æ¿ã€å…¥å£ï¼Œä¸å—本次变更影响 |
## æ³¨æ„äº‹é¡¹
- **错误提示不要吞掉**:这两条都是**用户在编辑器里的操作反馈**,必须把后端 `msg` åŽŸæ ·å±•ç¤ºã€‚
  ç”»å¸ƒé—®é¢˜ä¸€æ¬¡æœ€å¤š 8 æ¡ï¼Œå¤šæ¡æ—¶å»ºè®®æŒ‰ `;` æ‹†è¡Œå±•示,便于逐条处理。
- **对校验上线前就已入库的脏数据没有兜底**:校验只在保存时执行,历史版本不会被回溯检查。
  è‹¥æ€€ç–‘存量版本有问题,需要另行核查(出件链路不受影响,仍按原样渲染)。
- **`1_070_101_009` çš„定位信息是「第 N ä¸ªç»„件」**:指该组件在其父节点 `components` æ•°ç»„中的序号(从 1 å¼€å§‹ï¼‰ï¼Œ
  è·¯å¾„形如 `æ ¹ > ç¬¬ 2 ä¸ªç»„ä»¶ > ç¬¬ 1 ä¸ªç»„ä»¶`;层级过深时中间部分会折叠为 `…`。
- ç”»å¸ƒæ ¡éªŒ**不改变渲染结果**:渲染引擎与前端 `engine/render.ts` çš„逐字一致性和存量报告的
  `regenerate` å¤çŽ°æ€§éƒ½ä¸å—å½±å“ï¼Œæœ¬æ¬¡åªåœ¨**写入侧**加闸门。
docs/sql/config_export_all_20260918.sql
¶Ô±ÈÐÂÎļþ
ÎļþÌ«´ó
docs/sql/qc_report_dict.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,55 @@
-- ============================================================
-- æ™ºèƒ½è´¨æ£€æŠ¥å‘Šè®¾è®¡å¹³å° å­—典数据
-- ç›®æ ‡åº“:ruoyi-vue-pro(local profile çš„ master æ•°æ®æºï¼‰
-- å¹‚等:先删后插
-- è¯´æ˜Žï¼šæŠ¥å‘Šç±»åž‹å¤ç”¨ MES å·²æœ‰å­—å…¸ mes_qc_type,不另建重复字典
-- ============================================================
USE `ruoyi-vue-pro`;
-- æ¸…理
DELETE FROM `system_dict_data` WHERE `id` BETWEEN 1065400 AND 1065499;
DELETE FROM `system_dict_type` WHERE `id` BETWEEN 1065400 AND 1065499;
-- ------------------------------------------------------------
-- å­—典类型
-- ------------------------------------------------------------
INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES
(1065400, '质检报告模板状态', 'qc_report_template_status', 0, '1', NOW(), '1', NOW(), b'0'),
(1065401, '质检报告模板版本状态', 'qc_report_version_status', 0, '1', NOW(), '1', NOW(), b'0'),
(1065402, '质检报告实例状态', 'qc_report_instance_status', 0, '1', NOW(), '1', NOW(), b'0'),
(1065403, '质检报告纸张尺寸', 'qc_report_page_size', 0, '1', NOW(), '1', NOW(), b'0'),
(1065404, '质检报告纸张方向', 'qc_report_orientation', 0, '1', NOW(), '1', NOW(), b'0'),
(1065405, '质检报告适用行业', 'qc_report_industry', 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
-- æ¨¡æ¿çŠ¶æ€ï¼ˆå¯¹åº” QcReportEnums.TemplateStatusEnum)
(1065410, 1, '启用', '0', 'qc_report_template_status', 0, 'success', '', '1', NOW(), '1', NOW(), b'0'),
(1065411, 2, '停用', '1', 'qc_report_template_status', 0, 'info',    '', '1', NOW(), '1', NOW(), b'0'),
-- ç‰ˆæœ¬çŠ¶æ€ï¼ˆå¯¹åº” QcReportEnums.VersionStatusEnum)
(1065420, 1, '草稿',   '0', 'qc_report_version_status', 0, 'default', '', '1', NOW(), '1', NOW(), b'0'),
(1065421, 2, '已发布', '1', 'qc_report_version_status', 0, 'success', '', '1', NOW(), '1', NOW(), b'0'),
(1065422, 3, '已停用', '2', 'qc_report_version_status', 0, 'info',    '', '1', NOW(), '1', NOW(), b'0'),
-- å®žä¾‹çŠ¶æ€ï¼ˆå¯¹åº” QcReportEnums.InstanceStatusEnum)
(1065430, 1, '生成中',   '0', 'qc_report_instance_status', 0, 'processing', '', '1', NOW(), '1', NOW(), b'0'),
(1065431, 2, '生成成功', '1', 'qc_report_instance_status', 0, 'success',    '', '1', NOW(), '1', NOW(), b'0'),
(1065432, 3, '生成失败', '2', 'qc_report_instance_status', 0, 'error',      '', '1', NOW(), '1', NOW(), b'0'),
-- çº¸å¼ å°ºå¯¸ï¼ˆå¯¹åº” QcReportEnums.PageSizeEnum,值为字符串)
(1065440, 1, 'A3',     'A3',     'qc_report_page_size', 0, 'default', '', '1', NOW(), '1', NOW(), b'0'),
(1065441, 2, 'A4',     'A4',     'qc_report_page_size', 0, 'default', '', '1', NOW(), '1', NOW(), b'0'),
(1065442, 3, 'A5',     'A5',     'qc_report_page_size', 0, 'default', '', '1', NOW(), '1', NOW(), b'0'),
(1065443, 4, 'Letter', 'Letter', 'qc_report_page_size', 0, 'default', '', '1', NOW(), '1', NOW(), b'0'),
-- çº¸å¼ æ–¹å‘(对应 QcReportEnums.OrientationEnum)
(1065450, 1, '纵向', 'portrait',  'qc_report_orientation', 0, 'default', '', '1', NOW(), '1', NOW(), b'0'),
(1065451, 2, '横向', 'landscape', 'qc_report_orientation', 0, 'default', '', '1', NOW(), '1', NOW(), b'0'),
-- é€‚用行业
(1065460, 1, '通用',     'general',     'qc_report_industry', 0, 'default', '', '1', NOW(), '1', NOW(), b'0'),
(1065461, 2, '电子电器', 'electronics', 'qc_report_industry', 0, 'default', '', '1', NOW(), '1', NOW(), b'0'),
(1065462, 3, '五金机械', 'hardware',    'qc_report_industry', 0, 'default', '', '1', NOW(), '1', NOW(), b'0'),
(1065463, 4, '汽车零部件', 'auto',      'qc_report_industry', 0, 'default', '', '1', NOW(), '1', NOW(), b'0'),
(1065464, 5, '塑胶制品', 'plastic',     'qc_report_industry', 0, 'default', '', '1', NOW(), '1', NOW(), b'0'),
(1065465, 6, '食品饮料', 'food',        'qc_report_industry', 0, 'default', '', '1', NOW(), '1', NOW(), b'0'),
(1065466, 7, '医药医疗', 'pharma',      'qc_report_industry', 0, 'default', '', '1', NOW(), '1', NOW(), b'0');
docs/sql/qc_report_menu.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,34 @@
-- ============================================================
-- æ™ºèƒ½è´¨æ£€æŠ¥å‘Šè®¾è®¡å¹³å° èœå•与权限
-- ç›®æ ‡åº“:ruoyi-vue-pro(local profile çš„ master æ•°æ®æºï¼‰
-- å½’属:质量管理(菜单 id = 5500)下
-- å¹‚等:先删后插
-- ============================================================
USE `ruoyi-vue-pro`;
-- æ¸…理(含之前可能已存在的记录;1075406 æ›¾ä½œä¸ºè®¾è®¡å™¨çš„隐藏菜单,已改为前端静态路由)
DELETE FROM `system_menu` WHERE `id` IN (1075400, 1075401, 1075402, 1075403, 1075404, 1075405, 1075406);
-- åŒæ­¥æ¸…掉设计器隐藏菜单遗留的角色授权,避免留下孤儿授权
DELETE FROM `system_role_menu` WHERE `menu_id` = 1075406;
-- æŠ¥å‘Šæ¨¡æ¿è®¾è®¡ï¼ˆèœå•)
INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`) VALUES
(1075400, '报告模板设计', '', 2, 12, 5500, 'report-template', 'ep:document', 'mes/qc/report/template/index', NULL, 0);
-- æŠ¥å‘Šæ¨¡æ¿ æŒ‰é’®æƒé™
INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`) VALUES
(1075401, '报告模板查询', 'qc-report:template:query',   3, 1, 1075400, '', '', NULL, NULL, 0),
(1075402, '报告模板创建', 'qc-report:template:create',  3, 2, 1075400, '', '', NULL, NULL, 0),
(1075403, '报告模板更新', 'qc-report:template:update',  3, 3, 1075400, '', '', NULL, NULL, 0),
(1075404, '报告模板删除', 'qc-report:template:delete',  3, 4, 1075400, '', '', NULL, NULL, 0),
(1075405, '报告模板发布', 'qc-report:template:publish', 3, 5, 1075400, '', '', NULL, NULL, 0);
-- æŽˆæƒç»™è¶…级管理员角色(若代码已对超管返回全部菜单,此步为冗余但无害)
INSERT INTO `system_role_menu` (`role_id`, `menu_id`, `creator`, `updater`, `deleted`)
SELECT 1, m.`id`, 'admin', 'admin', b'0'
FROM `system_menu` m
WHERE m.`id` IN (1075400, 1075401, 1075402, 1075403, 1075404, 1075405)
  AND NOT EXISTS (
      SELECT 1 FROM `system_role_menu` rm WHERE rm.`role_id` = 1 AND rm.`menu_id` = m.`id` AND rm.`deleted` = b'0'
  );
docs/sql/qc_report_platform_ddl.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,140 @@
-- ============================================================
-- æ™ºèƒ½è´¨æ£€æŠ¥å‘Šè®¾è®¡å¹³å° å»ºè¡¨è„šæœ¬
-- æ¨¡å—:yudao-module-qcreport
-- è¯´æ˜Žï¼šé¡¹ç›®ä¸ä½¿ç”¨å¤šç§Ÿæˆ·ï¼Œæ•…不含 tenant_id å­—段
-- ============================================================
-- ----------------------------
-- æŠ¥å‘Šæ¨¡æ¿
-- ----------------------------
DROP TABLE IF EXISTS `qc_report_template`;
CREATE TABLE `qc_report_template` (
    `id`               BIGINT       NOT NULL AUTO_INCREMENT COMMENT '模板编号',
    `template_code`    VARCHAR(64)  NOT NULL COMMENT '模板编码',
    `template_name`    VARCHAR(128) NOT NULL COMMENT '模板名称',
    `industry`         VARCHAR(32)           DEFAULT NULL COMMENT '所属行业',
    `report_type`      VARCHAR(32)           DEFAULT NULL COMMENT '报告类型',
    `page_size`        VARCHAR(16)  NOT NULL DEFAULT 'A4' COMMENT '纸张尺寸:A3/A4/A5/Letter',
    `orientation`      VARCHAR(16)  NOT NULL DEFAULT 'portrait' COMMENT '纸张方向:portrait/landscape',
    `status`           TINYINT      NOT NULL DEFAULT 0 COMMENT '模板状态:0启用 1停用',
    `current_version`  VARCHAR(32)           DEFAULT NULL COMMENT '当前版本号',
    `description`      VARCHAR(512)          DEFAULT NULL COMMENT '模板描述',
    `creator`          VARCHAR(64)           DEFAULT '' COMMENT '创建者',
    `create_time`      DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `updater`          VARCHAR(64)           DEFAULT '' COMMENT '更新者',
    `update_time`      DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    `deleted`          BIT(1)       NOT NULL DEFAULT b'0' COMMENT '是否删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_template_code` (`template_code`),
    KEY `idx_template_name` (`template_name`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '智能质检报告-报告模板';
-- ----------------------------
-- æ¨¡æ¿ç‰ˆæœ¬ï¼ˆå‘布后不可覆盖)
-- ----------------------------
DROP TABLE IF EXISTS `qc_report_template_version`;
CREATE TABLE `qc_report_template_version` (
    `id`          BIGINT       NOT NULL AUTO_INCREMENT COMMENT '版本编号',
    `template_id` BIGINT       NOT NULL COMMENT '模板编号',
    `version`     VARCHAR(32)  NOT NULL COMMENT '版本号,如 v1.0',
    `schema_json` LONGTEXT COMMENT '模板 Schema(GrapesJS JSON + ç»„ä»¶ + æ•°æ®æº + ç»‘定 + è§„则 + æ ·å¼ï¼‰',
    `status`      TINYINT      NOT NULL DEFAULT 0 COMMENT '版本状态:0草稿 1已发布 2已停用',
    `description` VARCHAR(512)          DEFAULT NULL COMMENT '版本说明',
    `creator`     VARCHAR(64)           DEFAULT '' COMMENT '创建者',
    `create_time` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `updater`     VARCHAR(64)           DEFAULT '' COMMENT '更新者',
    `update_time` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    `deleted`     BIT(1)       NOT NULL DEFAULT b'0' COMMENT '是否删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_template_version` (`template_id`, `version`),
    KEY `idx_template_id` (`template_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '智能质检报告-模板版本';
-- ----------------------------
-- æŠ¥å‘Šå®žä¾‹ï¼ˆå¿…须保存数据快照)
-- ----------------------------
DROP TABLE IF EXISTS `qc_report_instance`;
CREATE TABLE `qc_report_instance` (
    `id`               BIGINT       NOT NULL AUTO_INCREMENT COMMENT '实例编号',
    `report_no`        VARCHAR(64)  NOT NULL COMMENT '报告编号(业务唯一)',
    `template_id`      BIGINT       NOT NULL COMMENT '模板编号',
    `template_version` VARCHAR(32)  NOT NULL COMMENT '生成时使用的模板版本号',
    `business_id`      VARCHAR(64)           DEFAULT NULL COMMENT '业务单据编号',
    `business_type`    VARCHAR(64)           DEFAULT NULL COMMENT '业务单据类型,如 mes_qc_iqc',
    `data_snapshot`    LONGTEXT COMMENT '数据快照:渲染时的完整数据上下文',
    `render_html`      LONGTEXT COMMENT '渲染产物 HTML',
    `status`           TINYINT      NOT NULL DEFAULT 0 COMMENT '报告状态:0生成中 1生成成功 2生成失败',
    `creator`          VARCHAR(64)           DEFAULT '' COMMENT '创建者',
    `create_time`      DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `updater`          VARCHAR(64)           DEFAULT '' COMMENT '更新者',
    `update_time`      DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    `deleted`          BIT(1)       NOT NULL DEFAULT b'0' COMMENT '是否删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_report_no` (`report_no`),
    KEY `idx_template_id` (`template_id`),
    KEY `idx_business` (`business_type`, `business_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '智能质检报告-报告实例';
-- ----------------------------
-- æ•°æ®æºå®šä¹‰
-- ----------------------------
DROP TABLE IF EXISTS `qc_report_data_source`;
CREATE TABLE `qc_report_data_source` (
    `id`          BIGINT       NOT NULL AUTO_INCREMENT COMMENT '编号',
    `template_id` BIGINT       NOT NULL COMMENT '模板编号',
    `source_key`  VARCHAR(64)  NOT NULL COMMENT '数据源标识,如 inspectionItems',
    `source_type` VARCHAR(32)  NOT NULL COMMENT '数据源类型:BUILTIN/SQL/API',
    `config`      LONGTEXT COMMENT '数据源配置 JSON',
    `creator`     VARCHAR(64)           DEFAULT '' COMMENT '创建者',
    `create_time` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `updater`     VARCHAR(64)           DEFAULT '' COMMENT '更新者',
    `update_time` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    `deleted`     BIT(1)       NOT NULL DEFAULT b'0' COMMENT '是否删除',
    PRIMARY KEY (`id`),
    KEY `idx_template_id` (`template_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '智能质检报告-数据源';
-- ----------------------------
-- åˆ¤å®šè§„则
-- ----------------------------
DROP TABLE IF EXISTS `qc_report_rule`;
CREATE TABLE `qc_report_rule` (
    `id`          BIGINT       NOT NULL AUTO_INCREMENT COMMENT '编号',
    `template_id` BIGINT       NOT NULL COMMENT '模板编号',
    `rule_name`   VARCHAR(128) NOT NULL COMMENT '规则名称',
    `target_path` VARCHAR(256) NOT NULL COMMENT '作用目标路径,如 inspectionItems[].actualValue',
    `expression`  VARCHAR(1024) NOT NULL COMMENT '判定表达式,如 actualValue >= lowerLimit AND actualValue <= upperLimit',
    `pass_value`  VARCHAR(64)           DEFAULT 'PASS' COMMENT '合格时输出值',
    `fail_value`  VARCHAR(64)           DEFAULT 'FAIL' COMMENT '不合格时输出值',
    `creator`     VARCHAR(64)           DEFAULT '' COMMENT '创建者',
    `create_time` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `updater`     VARCHAR(64)           DEFAULT '' COMMENT '更新者',
    `update_time` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    `deleted`     BIT(1)       NOT NULL DEFAULT b'0' COMMENT '是否删除',
    PRIMARY KEY (`id`),
    KEY `idx_template_id` (`template_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '智能质检报告-判定规则';
-- ----------------------------
-- æ¸²æŸ“记录(排查用:耗时、浏览器状态、异常栈)
-- ----------------------------
DROP TABLE IF EXISTS `qc_report_render_record`;
CREATE TABLE `qc_report_render_record` (
    `id`             BIGINT      NOT NULL AUTO_INCREMENT COMMENT '编号',
    `report_id`      BIGINT               DEFAULT NULL COMMENT '报告实例编号',
    `template_id`    BIGINT               DEFAULT NULL COMMENT '模板编号',
    `business_id`    VARCHAR(64)          DEFAULT NULL COMMENT '业务单据编号',
    `render_start_time` DATETIME          DEFAULT NULL COMMENT '渲染开始时间',
    `render_end_time`   DATETIME          DEFAULT NULL COMMENT '渲染结束时间',
    `render_duration`   BIGINT            DEFAULT NULL COMMENT '渲染耗时(毫秒)',
    `pdf_duration`      BIGINT            DEFAULT NULL COMMENT 'PDF ç”Ÿæˆè€—时(毫秒)',
    `browser_status`    VARCHAR(64)       DEFAULT NULL COMMENT '浏览器状态',
    `error_stack`       LONGTEXT COMMENT '异常堆栈',
    `creator`        VARCHAR(64)          DEFAULT '' COMMENT '创建者',
    `create_time`    DATETIME    NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `updater`        VARCHAR(64)          DEFAULT '' COMMENT '更新者',
    `update_time`    DATETIME    NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    `deleted`        BIT(1)      NOT NULL DEFAULT b'0' COMMENT '是否删除',
    PRIMARY KEY (`id`),
    KEY `idx_report_id` (`report_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '智能质检报告-渲染记录';
docs/ÖÇÄÜÖʼ챨¸æÆ½Ì¨-¿ª·¢½ø¶È.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,3382 @@
# æ™ºèƒ½è´¨æ£€æŠ¥å‘Šè®¾è®¡å¹³å° â€” å¼€å‘进度
> ä¾æ®æ–‡æ¡£ï¼š`docs/智能质检报告设计平台——Claude Code Agent ä¸“业开发提示词.md`
> æ–¹æ¡ˆè®¾è®¡ï¼š`docs/智能质检报告平台-方案设计.md`
> å»ºè¡¨è„šæœ¬ï¼š`docs/sql/qc_report_platform_ddl.sql`
---
## 2026-09-17(第 1 å¤©ï¼‰
### ä¸€ã€ä»Šæ—¥å®Œæˆé¡¹
| # | äº‹é¡¹ | çŠ¶æ€ | äº§å‡º |
|---|---|---|---|
| 1 | **Phase 0 é¡¹ç›®æ‰«æ**(后端 + å‰ç«¯å¹¶è¡Œæ‰«æï¼‰ | âœ… å®Œæˆ | åŽç«¯æ¨¡å—布局/质检域/PDF能力/文件存储/框架工具、前端 Vben5.7+AntDV4.2.6/路由菜单驱动/GrapesJS ç¼ºå¤± ç­‰ç»“论 |
| 2 | **方案设计文档** | âœ… å®Œæˆ | `docs/智能质检报告平台-方案设计.md`(21 ä¸ªç« èŠ‚ï¼šæŠ€æœ¯æ ˆã€å¯å¤ç”¨èƒ½åŠ›ã€æ•°æ®åº“è®¾è®¡ã€ç»„ä»¶æž¶æž„ã€GrapesJS é›†æˆã€Schema、绑定/规则/渲染/Playwright/分页方案、API è‰æ¡ˆã€Phase 1~5 è®¡åˆ’、风险清单、待确认问题) |
| 3 | **关键决策确认**(用户拍板) | âœ… å®Œæˆ | è§ä¸‹æ–¹ã€ŒäºŒã€å…³é”®å†³ç­–」 |
| 4 | **后端模块骨架** | âœ… å®Œæˆ | æ–°å»º `yudao-module-qcreport` æ¨¡å—(pom + å®Œæ•´åŒ…目录骨架) |
| 5 | **模块注册** | âœ… å®Œæˆ | æ ¹ `pom.xml` çš„ `<modules>`、`yudao-server/pom.xml` çš„ `<dependencies>` å„加一条 |
| 6 | **错误码定义** | âœ… å®Œæˆ | `enums/ErrorCodeConstants.java`,使用 **1-070-000-000** æ®µï¼ˆå·²æ ¸å¯¹ 1-001~1-013/020/022/030/040/050/051/060 å‡è¢«å ç”¨ï¼‰ |
| 7 | **核心 DO** | âœ… å®Œæˆ | `QcReportTemplateDO`、`QcReportTemplateVersionDO`、`QcReportInstanceDO` |
| 8 | **模板 Schema æ¨¡åž‹** | âœ… å®Œæˆ | `ReportTemplateSchema`(page/grapes/components/dataSources/bindings/rules/styles) |
| 9 | **枚举集合** | âœ… å®Œæˆ | `QcReportEnums`:TemplateStatus / VersionStatus / InstanceStatus / PageSize / Orientation / CheckResult |
| 10 | **建表脚本** | âœ… å®Œæˆ | `docs/sql/qc_report_platform_ddl.sql`(6 å¼ è¡¨ï¼Œæ—  tenant_id) |
### äºŒã€å…³é”®å†³ç­–(已获用户确认)
| è®®é¢˜ | å†³ç­– | è¯´æ˜Ž |
|---|---|---|
| **前端修改授权** | âœ… **已授权** | å…è®¸ä¿®æ”¹ `mom-pro2-before`(新增页面/组件/API æ–‡ä»¶ï¼Œä¸é‡æž„既有代码) |
| **PDF æ–¹æ¡ˆ** | âœ… **用 Playwright + Chromium** | ç”¨æˆ·ç¡®è®¤è¿è¡Œ/部署环境**能联网**,可下载 Chromium |
| **今日范围** | âœ… **后端模块骨架 + æ¨¡æ¿ CRUD** | æ¸²æŸ“链路与前端设计器后续进行 |
| **模块命名** | âœ… `yudao-module-qcreport` | è¡¨å `qc_report_*`,接口前缀 `/qc-report/*`(贴合项目「模块_域」命名习惯) |
| åŽç«¯æ¨¡å—归属 | ç‹¬ç«‹æ–°æ¨¡å— | ä¸Žè¡Œä¸šä¸šåŠ¡è§£è€¦ï¼ˆæ–‡æ¡£ Â§35 è¦æ±‚核心编辑器不与行业强耦合) |
### ä¸‰ã€æ‰«æå¾—到的关键事实(后续开发必须遵守)
1. **UI æ ˆå”¯ä¸€**:前端是 **Ant Design Vue 4.2.6 + Vben Admin 5.7.0 å•应用版**,**无 Element Plus**。新页面必须用 AntDV,禁止引入 Element Plus。
2. **菜单/路由由后端驱动**:页面放到 `src/views/**/index.vue`,后端 `system_menu.component` å†™ç›¸å¯¹è·¯å¾„(如 `mes/qc/report/template/index`)即可出现菜单,**无需手工注册路由**。
3. **质检业务模型已存在且完备**(MES `qc` åŸŸï¼‰ï¼š`mes_qc_iqc / ipqc / oqc / rqc` + `*_line`、`mes_qc_template / _indicator / _item`、`mes_qc_indicator_result(_detail)`、`mes_qc_ncr`。**报告平台必须绑定复用,禁止重建质检模型。**
4. **项目零 PDF ç”Ÿæˆèƒ½åŠ›**:仅有 PDFBox(CRM/ERP åªè¯»è§£æžï¼‰ã€FastExcel(Excel å¯¼å‡ºï¼‰ã€‚Playwright/Chromium/FreeMarker/openhtmltopdf **全部不存在**,需新增。
5. **积木报表模块被注释禁用**:根 `pom.xml` ä¸Ž `yudao-server/pom.xml` ä¸­ `yudao-module-report` ä¸ºæ³¨é‡ŠçŠ¶æ€ï¼ˆæ³¨é‡Šè¯´æ˜Žã€Œç§¯æœ¨æŠ¥è¡¨æš‚ä¸æ”¯æŒ Spring Boot 4」)——故新模块**不复用该名字**,改用 `yudao-module-qcreport`。
6. **前端已装可直接复用的库**(避免重复造轮子):
   - `qrcode 1.5.4` + `jsbarcode 3.12.3`(已封装 `packages/effects/common-ui/src/components/barcode/barcode.vue`,含 `getImageBase64()`)
   - `vue3-print-nb 0.1.4`(打印,范例 `views/bpm/processInstance/detail/modules/process-print.vue`)
   - `vue3-signature 0.4.4`(电子签名,HRM å·²ç”¨ï¼‰
   - `vuedraggable 4.1.0` + `sortablejs 1.15.7`(拖拽)
   - `tinymce 7.9.3` / `@tiptap/*`(富文本)、`echarts 6.1.0`(图表)
   - `bpmn-js 18.16.1`(项目内唯一「画布+属性面板」先例,可参考布局组织)
7. **GrapesJS ç¡®è®¤æœªå®‰è£…**,Phase 1 éœ€æ–°å¢žä¾èµ–(体积/构建影响需评估)。
8. **JSON åˆ—存储范式**:`@TableName(value=..., autoResultMap = true)` + `@TableField(typeHandler = Jackson3TypeHandler.class)`(范例 `BpmFormDO.java:53`)。
9. **框架通用件**:`CommonResult` / `PageResult` / `PageParam` / `BaseDO`(creator/updater/createTime/updateTime/deleted è‡ªåŠ¨å¡«å……ï¼‰/ `ServiceException`+`ServiceExceptionUtil.exception(...)` / `BeanUtils` / `BaseMapperX` / `LambdaQueryWrapperX`。
10. **无多租户**:`yudao.tenant.enable: false`,新表**不加 tenant_id**(与项目规则一致)。
11. **测试基类**:JUnit5 + `BaseDbUnitTest`(H2)/ `BaseMockitoUnitTest` + PODAM。
12. **构建命令**:后端 `mvn compile -pl <模块> -am -q` / å…¨é‡ `mvn compile -q`;前端 `pnpm dev`(5666) / `pnpm build` / `pnpm typecheck`。
### å››ã€ä»Šæ—¥æ–°å¢ž/修改文件清单
**新增(后端)**
```
yudao-module-qcreport/pom.xml
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/
├── enums/ErrorCodeConstants.java               # 1-070 æ®µé”™è¯¯ç 
├── enums/QcReportEnums.java                    # 6 ç»„枚举
├── dal/dataobject/template/QcReportTemplateDO.java
├── dal/dataobject/version/QcReportTemplateVersionDO.java
├── dal/dataobject/version/ReportTemplateSchema.java
└── dal/dataobject/instance/QcReportInstanceDO.java
(另已建好 controller/service/dal.mysql/engine{bounding,rule,render}/config çš„空目录骨架)
```
**修改(后端公共文件,影响面已评估)**
| æ–‡ä»¶ | æ”¹åЍ | å½±å“é¢ |
|---|---|---|
| `pom.xml` | `<modules>` å¢žåŠ  `yudao-module-qcreport` | ä»…新增一个子模块,不影响既有模块构建 |
| `yudao-server/pom.xml` | `<dependencies>` å¢žåŠ  `yudao-module-qcreport` | å¯åŠ¨æ¨¡å—å¤šèšåˆä¸€ä¸ª jar |
**新增(文档/SQL)**
```
docs/智能质检报告平台-方案设计.md
docs/智能质检报告平台-开发进度.md     # æœ¬æ–‡ä»¶
docs/sql/qc_report_platform_ddl.sql   # 6 å¼ è¡¨å»ºè¡¨è„šæœ¬
```
### äº”、进行中 / æœªå®Œæˆé¡¹
| äº‹é¡¹ | çŠ¶æ€ | ä¸‹ä¸€æ­¥ |
|---|---|---|
| æ–°æ¨¡å—编译验证 | âœ… **已通过** | `mvn compile -pl yudao-module-qcreport -am -q` **exit code 0**,`target/classes/.../qcreport/{dal,enums}` å·²äº§å‡ºã€‚无需修复 |
| æ¨¡æ¿ CRUD(Controller/Service/Mapper/VO) |  æœªå¼€å§‹ | Phase 1 é¦–要任务 |
| ç‰ˆæœ¬ CRUD + å‘布/回滚 | â¬œ æœªå¼€å§‹ | |
| èœå•/权限 SQL(`system_menu` + æƒé™ç ï¼‰ |  æœªå¼€å§‹ | éœ€æŒ‰å½“前激活 profile æŒ‡å‘的目标库写入 |
| DDL è½åº“执行 | â¬œ æœªå¼€å§‹ | æ³¨æ„ï¼š**不得在运行库 `ruoyi-vue-pro` éšæ„æ‰§è¡Œ**,按目标库执行 |
| å‰ç«¯ Designer + Quality Components | â¬œ æœªå¼€å§‹ | å·²èŽ·æŽˆæƒ |
| GrapesJS ä¾èµ–引入 | â¬œ æœªå¼€å§‹ | éœ€è¯„估体积与 Vite æž„建影响 |
| æ•°æ®ç»‘定引擎 / è§„则引擎 | â¬œ æœªå¼€å§‹ | **禁止用 eval**,须自建 AST è§£æžç™½åå•求值 |
| HTML æ¸²æŸ“ + Playwright PDF | â¬œ æœªå¼€å§‹ | éœ€åŠ  `com.microsoft.playwright:playwright` åˆ° `yudao-dependencies` |
| æŠ¥å‘Šå®žä¾‹ + data_snapshot |  æœªå¼€å§‹ | |
### å…­ã€è¸©è¿‡çš„坑 / æ³¨æ„äº‹é¡¹
1. **错误码段号冲突**:新增模块前必须先 grep å…¨é¡¹ç›® `ErrorCodeConstants` ç¡®è®¤æ®µå·æœªè¢«å ç”¨ï¼ˆæœ¬é¡¹ç›®å·²ç”¨ 1-001~1-013、1-020、1-022、1-030、1-040、1-050、1-051、1-060),本次选用 **1-070**。
2. **`yudao-module-report` åå­—被占**(积木报表,注释状态),新模块避开该名,避免日后启用积木报表时冲突。
3. **前端是「单应用版」**(`@vben/web-antd-standalone`):业务代码在**æ ¹ `src/`**,不是 `apps/web-antd/src`。扫描时容易看错。
4. **前端无 Element Plus**:方案文档提到 Vben + AntDV,实际确认**无 Element Plus**,不要按"某业务模块已统一用 Element Plus"去选型。
5. **`docs/` ä¸‹å·²æœ‰å¤§é‡æ—¢æœ‰æ–¹æ¡ˆæ–‡æ¡£**,新文档命名需带模块前缀(本次用「智能质检报告平台-」)以免混淆。
6. **DDL ä¸­çš„ `schema` æ˜¯ MySQL ä¿ç•™å­—**,建表脚本已用反引号包裹;MyBatis-Plus æ˜ å°„时字段名 `schema` ä¹Ÿéœ€æ³¨æ„ï¼ˆå½“前由 `Jackson3TypeHandler` æ˜ å°„为 `ReportTemplateSchema` å¯¹è±¡ï¼Œæ­£å¸¸ï¼‰ã€‚
7. **Playwright é¦–次使用需下载 Chromium**(约 150MB),用户已确认环境可联网;实现时需处理「未安装时给出可读提示」(已定义错误码 `RENDER_PDF_BROWSER_LAUNCH_FAILED`)。
### ä¸ƒã€æ˜Žæ—¥ï¼ˆ2026-09-18)待办清单
1. **确认新模块编译结果**,修复编译错误(`mvn compile -pl yudao-module-qcreport -am -q`)。
2. **执行 DDL è½åº“**:目标库 = **`ruoyi-vue-pro`**(2026-09-17 æ™šå®žæµ‹ï¼š`application.yaml:6` active=local;`application-local.yaml:68` master æ•°æ®æº = `jdbc:mysql://localhost:3306/ruoyi-vue-pro`,root/123456)。
   âš  è¯¥é…ç½®æ¼‚移频繁(历史上出现过 mom-xgdl / mom-thzy),**动手前务必重新 grep ä¸€æ¬¡ datasource**;另需确认 MySQL MCP è¿žçš„æ˜¯å¦åŒä¸€å®žä¾‹ï¼Œä¸æ˜¯åˆ™ç”¨ `docker exec mysql8 mysql -uroot -p123456 -e "..."`,并用 `information_schema` å›žè¯»éªŒè¯ã€‚
3. **完成模板 CRUD**(Phase 1 åŽç«¯éƒ¨åˆ†ï¼‰ï¼š
   - `QcReportTemplateMapper` + `QcReportTemplateService/Impl` + `QcReportTemplateController`
   - VO:`QcReportTemplateSaveReqVO` / `PageReqVO` / `RespVO`
   - æŸ¥è¯¢æ¡ä»¶ï¼šæ¨¡æ¿åç§°ã€ç¼–码、行业、报告类型、状态、创建人、创建时间
   - æŽ¥å£ï¼šcreate / update / delete / get / page / copy
4. **版本 CRUD**:`QcReportTemplateVersionMapper/Service/Controller`,create / list / publish / rollback(发布后不可覆盖)。
5. **菜单与权限**:`system_menu` èœå•记录 + æƒé™ç  `qc-report:template:*`、`qc-report:instance:*`,按目标库写入。
6. **前端起步**(已获授权):评估并引入 GrapesJS,搭 `src/views/mes/qc/report/template/` åˆ—表页(照抄 `src/views/mes/qc/defect/` ç»“构)+ `src/api/mes/qc/report/` API æ–‡ä»¶ã€‚
7. ï¼ˆè‹¥æ—¶é—´å…è®¸ï¼‰å¯åЍ **Quality Components core**(types/registry/factory/serializer/validator)。
### å…«ã€é£Žé™©æé†’(明日开工前先看)
| # | é£Žé™© | ç­‰çº§ |
|---|---|---|
| 1 | Playwright Chromium ä¸‹è½½å—网络限制(已确认可联网,但需实测) | ðŸŸ¡ |
| 2 | GrapesJS æ–°å¢žä¾èµ–对 Vite æž„建体积的影响 | ðŸŸ¡ |
| 3 | è§„则引擎表达式安全(**严禁 eval / ScriptEngine**) |  |
| 4 | æ¨¡æ¿ HTML å±žç”¨æˆ·å¯ç¼–辑内容 â†’ å¿…须服务端 Sanitization(XSS) | ðŸŸ¡ |
| 5 | æ–°è¡¨ DDL å¿…须落到**目标库**,禁止污染运行库 | ðŸŸ¡ |
| 6 | 100+ é¡µ PDF / 1000+ è¡Œè¡¨æ ¼çš„æ€§èƒ½ä¸Žå†…å­˜ | ðŸŸ¢ |
---
## 2026-09-18(第 2 å¤©ï¼‰
### ä¸€ã€ä»Šæ—¥å®Œæˆé¡¹
| # | äº‹é¡¹ | çŠ¶æ€ | äº§å‡º |
|---|---|---|---|
| 1 | **DDL / å­—å…¸ / èœå• è½åº“** | âœ… å®Œæˆ | `qc_report_platform_ddl.sql`(6 å¼ è¡¨ï¼‰ã€`qc_report_dict.sql`、`qc_report_menu.sql` å·²æ‰§è¡Œåˆ°è¿è¡Œåº“ `ruoyi-vue-pro` |
| 2 | **模板 CRUD åŽç«¯** | âœ… å®Œæˆ | Controller/Service/Mapper/VO å…¨å¥—,含 **copy(复制模板)**;接口 `/qc-report/template/{create,update,delete,get,page,copy}` |
| 3 | **版本 CRUD + å‘布** | âœ… å®Œæˆ | `/qc-report/template-version/{create,update,get,list-by-template,publish}`;**已发布版本不可覆盖**,改动只能另存新草稿 |
| 4 | **菜单与权限码** | âœ… å®Œæˆ | èœå•「质量管理 â†’ æŠ¥å‘Šæ¨¡æ¿ã€+ æƒé™ç  `qc-report:template:*` |
| 5 | **前端引入 GrapesJS + åˆ—表页** | âœ… å®Œæˆ | `grapesjs ^0.23.6`(经 `catalog:` å£°æ˜Žï¼‰ï¼›åˆ—表页 `template/index.vue` + `modules/form.vue` + `data.ts` |
| 6 | **模板设计器页面(Phase 1 å‰ç«¯ï¼‰** | âœ… å®Œæˆ | `template/designer/`(`index.vue` / `use-designer.ts` / `blocks.ts` / `constants.ts`)+ é™æ€è·¯ç”± `router/routes/modules/qcreport.ts` |
| 7 | **设计器端到端联调验证** | âœ… å®Œæˆ | è§ä¸‹æ–¹ã€ŒäºŒã€è®¾è®¡å™¨è”调:定位并修复的 12 ä¸ªé—®é¢˜ã€ |
### äºŒã€è®¾è®¡å™¨è”调:定位并修复的 12 ä¸ªé—®é¢˜
Phase 1 çš„设计器是把 GrapesJS åµŒè¿› AntDV é¡µé¢ï¼Œç‹¬ç«‹è¸©äº† 12 ä¸ªå‘,均已修复并逐条在浏览器实测:
| # | çŽ°è±¡ | æ ¹å›  | ä¿®å¤ |
|---|---|---|---|
| 1 | é¡µé¢ç™½å±ï¼ŒæŽ§åˆ¶å°æŠ¥ `does not provide an export named 'useTabs'` | `useTabs` ä»Ž `@vben/common-ui` å¯¼å…¥ï¼Œå®žé™…在 **`@vben/hooks`** | æ”¹å¯¼å…¥æ¥æºï¼ˆ`designer/index.vue`) |
| 2 | è°ƒæ•´çº¸å¼ è®¾ç½®åŽï¼Œç”»å¸ƒä¸Šæœªä¿å­˜çš„内容被清空 | ã€Œåº”用到画布」复用了 `loadSchema()`,会把上次保存的数据重新灌回画布 | æ–°å¢ž**非破坏式** `applyPageSize()`,只重排尺寸与参考线,不动画布内容 |
| 3 | ç”»å¸ƒç©ºç™½ï¼Œiframe å°ºå¯¸ 0×0 | `Devices.remove(id)` + `add(id)` + `setDevice(id)` åŽ device å–值没变,Backbone ä¸è§¦å‘ `change:device`,`Canvas.updateDevice` ä¸æ‰§è¡Œ | è®¾å¤‡ id ç¼–码尺寸(`qc-report-page-1123x794`),保证取值真变化 |
| 4 | ä¿å­˜ä¸‹æ¥çš„æ ·å¼æŒ‚在 `@media (max-width: 794px)` ä¸Šï¼Œæ¢çº¸å¼ å³å¤±æ•ˆ | `Device` çš„ `widthMedia` ä¸º `null` æ—¶ä¼šå›žé€€æˆ `width`,产生按设备宽的媒体查询 | æ³¨å†Œè®¾å¤‡æ—¶æ˜¾å¼ `widthMedia: ''` |
| 5 | è€æ¨¡æ¿æ‰“开是空白编辑器(不是原有内容) | æ—§æ•°æ® `grapes` åªæœ‰ `{assets, styles}`、**没有 `pages`**,`loadProjectData` è½½ä¸å‡ºé¡µé¢ | `loadSchema` å¢žåŠ  `Array.isArray(pages) && pages.length > 0` å®ˆå« |
| 6 | çº¸å¼ çœ‹ä¸è§ï¼ˆæ·±è‰²èƒŒæ™¯ï¼‰ | GrapesJS é»˜è®¤**深色主题**,且 `--gjs-left-width:15%`/`--gjs-canvas-top:40px` ä»åœ¨ç”Ÿæ•ˆï¼Œ`panels:{defaults:[]}` å¹¶ä¸èƒ½åŽ»æŽ‰å®ƒä»¬ | åœ¨é¡µé¢æ ¹èŠ‚ç‚¹è¦†ç›– `--gjs-*` å˜é‡ä¸ºæµ…色主题 + ç½®é›¶ `--gjs-left-width`/`--gjs-canvas-top` |
| 7 | çº¸å¼ ç™½åº•时有时无 | `canvas.frameStyle` **只作用于第一个画布帧**,`loadProjectData` æ–°å»ºçš„帧没白底 | `applyFrameGuide()` åœ¨æ¯æ¬¡ `canvas:frame:load` ä¸Žé‡å»ºåŽæ˜¾å¼å†™ body ç™½åº• + é¡µè¾¹è· |
| 8 | ç”»å¸ƒæ¯”可视区大时被裁掉,且**无法滚动** | `.gjs-cv-canvas` æ˜¯ `overflow:hidden` çš„绝对定位舞台,帧也是绝对定位 | è¦†ç›–为 `overflow:auto` æ»šåŠ¨å®¹å™¨ï¼Œå¸§å®¹å™¨/帧包装 `position:static`、`margin:24px auto` å›žå½’文档流 |
| 9 | **点击左侧组件块无任何反应** | `BlockView.handleClick` åœ¨ä¸æ»¡è¶³ `block.onClick \|\| config.appendOnClick` æ—¶ç›´æŽ¥ return | `blockManager.appendOnClick: true`,点击即追加(拖拽同时可用) |
| 10 | **左右两侧「组件 / å±žæ€§ã€é¢æ¿æ»šä¸åŠ¨ï¼Œåº•éƒ¨è¢«è£** | Ant `Spin` å¤šåŒ…了一层 `.ant-spin-container`,它默认是**内容高度**而非撑满父级,把两栏撑到 785px > å¯è§†åŒº 735px,`overflow:auto` æ°¸ä¸è§¦å‘ | `:deep(.ant-spin-container){ height:100% }` æŠŠé«˜åº¦é“¾æŽ¥å›ž `flex-1` çˆ¶çº§ |
| 11 | ç”»å¸ƒ iframe çš„ `<style>` é‡Œå‡ºçŽ°å­—é¢é‡ `[object Object]` | `canvas.frameStyle` ä¼šè¢«**原样拼接**成 CSS æ–‡æœ¬ï¼Œä¼ å¯¹è±¡å³å‡ºé”™ | æ”¹ä¸ºä¼  CSS å­—符串 |
| 12 | æ‰“开已删除的模板 / åˆ‡æ¢å·²åˆ é™¤çš„版本时**整页崩溃**(`Cannot read properties of null`) | `getTemplate`/`getVersion` å¯¹å·²è½¯åˆ é™¤çš„记录返回 `data:null`,前端未兜 | ä¸¤å¤„均加空值守卫:提示「不存在或已被删除」并返回列表 / åˆ·æ–°ç‰ˆæœ¬åˆ—表 |
### ä¸‰ã€è”调验证结论(浏览器实测)
| éªŒè¯é¡¹ | ç»“æžœ |
|---|---|
| ç‚¹å‡»ç»„件块追加到画布 | âœ… è¿½åŠ å‡º `data-quality-type` ä¸º `Heading` / `Table` çš„组件,表格 12 ä¸ªå•元格正常生成 |
| ç”»å¸ƒæ»šåЍ | âœ… `scrollHeight 842 > clientHeight 725`,可滚动 |
| å·¦ä¾§ç»„件面板滚动 | âœ… `scrollHeight 785 > clientHeight 735`,实测 `scrollTop` å¯è¾¾ 50.7 |
| å³ä¾§å±žæ€§é¢æ¿æ»šåЍ | âœ… é€‰ä¸­ç»„件并展开样式分区后 `scrollHeight 1458 > clientHeight 735`,实测可滚到底 |
| ä¿å­˜è‰ç¨¿ï¼ˆæ–°å»ºç‰ˆæœ¬ï¼‰ | âœ… æ–°æ¨¡æ¿ä¿å­˜å‡º `v1.0(草稿)`,落库 `schema_json` 4527 å­—符 |
| ä¿å­˜å†…容正确性 | âœ… `schemaVersion=1.0`、`page=A4纵向10mm`、`grapes.pages` 1 ä¸ªã€`components` è®°å½• `ReportHeader`+`Table` ä¸”带 `index`;**无 `mediaText`、无 `[object Object]`** |
| é‡æ–°æ‰“开还原 | âœ… ç‰ˆæœ¬ä¸‹æ‹‰æ˜¾ç¤ºã€Œv1.0(草稿)」,画布还原出 `ReportHeader`+`Table` ä¸Žé¡µè¾¹è· |
| å‰ç«¯ç±»åž‹æ£€æŸ¥ | âœ… `pnpm typecheck` åœ¨æœ¬æ¨¡å—新改文件上 **0 error**(仓库其余 316 æ¡ä¸ºæ—¢æœ‰é—®é¢˜ï¼Œé›†ä¸­åœ¨ `views/bi/warehouse`、`views/wls`、`views/crm` ç­‰ï¼Œéžæœ¬æ¬¡å¼•入) |
### å››ã€ä»Šæ—¥æ–°å¢ž/修改文件清单
**新增(后端 `yudao-module-qcreport`)**
```
controller/admin/template/QcReportTemplateController.java
controller/admin/template/vo/{QcReportTemplatePageReqVO,RespVO,SaveReqVO}.java
controller/admin/version/QcReportTemplateVersionController.java
controller/admin/version/vo/{PageReqVO,RespVO,SaveReqVO,UpdateReqVO}.java
dal/mysql/{template/QcReportTemplateMapper,version/QcReportTemplateVersionMapper}.java
service/template/{QcReportTemplateService,QcReportTemplateServiceImpl}.java
service/version/{QcReportTemplateVersionService,QcReportTemplateVersionServiceImpl}.java
```
**新增(前端 `mom-pro2-before`)**
```
src/api/mes/qc/report/template/index.ts
src/api/mes/qc/report/version/index.ts
src/router/routes/modules/qcreport.ts                     # è®¾è®¡å™¨é™æ€è·¯ç”±ï¼ˆhideInMenu,带 id è·³è½¬ï¼‰
src/views/mes/qc/report/template/index.vue                # æ¨¡æ¿åˆ—表页(新增「设计」入口)
src/views/mes/qc/report/template/modules/form.vue
src/views/mes/qc/report/template/data.ts                  # åˆ—表/表单 schema
src/views/mes/qc/report/template/designer/index.vue       # è®¾è®¡å™¨é¡µé¢ï¼ˆé¡¶æ /纸张设置/三栏布局)
src/views/mes/qc/report/template/designer/use-designer.ts # GrapesJS å°è£…(画布 â†” Schema åŒå‘转换)
src/views/mes/qc/report/template/designer/blocks.ts       # åŸºç¡€ç»„件块定义
src/views/mes/qc/report/template/designer/constants.ts    # çº¸å¼ å°ºå¯¸/默认页/换算
```
**新增(SQL)**:`docs/sql/qc_report_dict.sql`、`docs/sql/qc_report_menu.sql`
### äº”、进行中 / æœªå®Œæˆé¡¹
| äº‹é¡¹ | çŠ¶æ€ | ä¸‹ä¸€æ­¥ |
|---|---|---|
| GrapesJS è®¾è®¡å™¨ï¼ˆPhase 1) | âœ… å®Œæˆå¹¶å®žæµ‹ | â€” |
| **Quality Components æ³¨å†Œæœºåˆ¶** | âœ… å®Œæˆ | è§æ–‡æœ«ã€ŒPhase 2 å®žæ–½è®°å½•」 |
| è´¨é‡ç»„件本体 | âœ… å®Œæˆ | 12 ä¸ªç»„件(基础 6 + æŠ¥å‘Š 3 + æ£€éªŒ 2 + ç»“æžœ 1) |
| QualityPropertyPanel(业务属性面板) | âœ… å®Œæˆ | åè®®å­—段经 traits è½åˆ°å±žæ€§é¢æ¿ï¼Œå«ã€Œç»‘定数据」控件 |
| æ•°æ®ç»‘定引擎 | âœ… å®Œæˆ | è·¯å¾„解析 + å–值 + ç¼ºå£ä¸ŠæŠ¥ |
| è§„则引擎 | âœ… å®Œæˆ | è‡ªå»º AST ç™½åå•求值,**无 eval / ScriptEngine** |
| HTML æ¸²æŸ“引擎 | âœ… å®Œæˆ | è§æ–‡æœ«ã€ŒPhase 2 å®žæ–½è®°å½•」 |
| Playwright / Chromium PDF | â¬œ æœªå¼€å§‹ | Phase 3;**禁止用截图方式生成 PDF**;需先定「前端渲染 HTML â†’ åŽç«¯æ‰“印」还是「后端 Java é‡å†™æ¸²æŸ“」 |
| æŠ¥å‘Šå®žä¾‹ + `data_snapshot` | â¬œ æœªå¼€å§‹ | Phase 4;生成时快照,保证历史报告不随主数据变化 |
| è‡ªåŠ¨åŒ–æµ‹è¯• / E2E | â¬œ æœªå¼€å§‹ | Phase 5 |
### å…­ã€è¸©è¿‡çš„坑 / æ³¨æ„äº‹é¡¹ï¼ˆè¡¥å……)
1. **GrapesJS çš„ `frameStyle` æ˜¯ CSS å­—符串不是对象**,传对象会被拼成 `[object Object]` å†™è¿›ç”»å¸ƒã€‚
2. **GrapesJS é»˜è®¤æ·±è‰²ä¸»é¢˜**,即使 `panels:{defaults:[]}` åŽ»æŽ‰äº†é»˜è®¤é¢æ¿ï¼Œ`--gjs-left-width`/`--gjs-canvas-top` ä»ä¼šå½±å“ `.gjs-cv-canvas` çš„宽高计算,必须一并覆盖。
3. **`Devices` çš„ `setDevice` åœ¨ id ä¸å˜æ—¶ä¸ä¼šè§¦å‘ `change:device`**(Backbone è¯­ä¹‰ï¼‰ï¼Œè¦é‡æŽ’画布就得让 device å€¼çœŸçš„变化。
4. **`Device.widthMedia` ä¸º `null` ä¼šå›žé€€æˆ `width`**,从而把样式写进 `@media (max-width: Npx)`;模板类画布应显式传 `''`。
5. **Ant `Spin` ä¼šæ’å…¥ `.ant-spin-container` æ‰“æ–­ `height:100%` é“¾**。任何「Spin åŒ…裹 + å†…部 flex æ’‘满 + å†…部再 overflow:auto」的布局都要补 `:deep(.ant-spin-container){height:100%}`,否则内部滚动条永远不出现、底部被静默裁掉。
6. **`loadProjectData` éœ€è¦å¸¦ `pages` çš„项目数据**,`{assets,styles}` è¿™ç§ä¼šè¢«è½½æˆç©ºç¼–辑器,载入前必须判空。
7. åŽç«¯å¯¹**已软删除**的记录,`get` æŽ¥å£è¿”回 `data:null`(不是报错),前端所有按 id å–详情的入口都要兜空。
### ä¸ƒã€çŽ¯å¢ƒçŠ¶æ€ä¸Žé—ç•™æé†’
1. **后端不在运行**:2026-09-18 12:18 ä¹‹åŽ `48080` å·²æ— ç›‘听(日志停在 12:18:37,无异常栈,是被停止而非崩溃)。当日最后一次「保存」因此返回 **502 Bad Gateway**(前端已有统一错误提示,非静默失败)。
   åŽç«¯ç”±ç”¨æˆ·åœ¨ IntelliJ ä¸­è¿è¡Œï¼ˆ`-Dmaven.multiModuleProjectDirectory=.../mom-pro2-after-test`),**启动/停止交由用户手动处理**。
2. **联调遗留数据**:当日为验证保存链路,在**模板 3(QC_IQC_DEMO_COPY)**上产生了一条 `v1.0` è‰ç¨¿ç‰ˆæœ¬ï¼ˆ`qc_report_template_version.id=6`)——该模板此前无版本,如不需要可直接删除。
3. æ•°æ®åº“:`application.yaml` `active: local` â†’ `application-local.yaml` æŒ‡å‘ `jdbc:mysql://localhost:3306/ruoyi-vue-pro`。**该配置历史上漂移过多次,动手前务必重新确认。**
### å…«ã€æ˜Žæ—¥ï¼ˆ2026-09-19)待办清单
> è¯¥æ¸…单已当场执行完毕,实施与验证记录见下一节「Phase 2 å®žæ–½è®°å½•」。
1. **Phase 2 èµ·æ­¥ï¼šQuality Components æ³¨å†Œæœºåˆ¶**
   - `engine/component` ä¸‹çš„ `types / registry / factory / serializer / validator`(独立注册,不进 GrapesJS å†…核)
   - è®¾è®¡å™¨çš„左侧「质量组件」分类改为从 registry åŠ¨æ€ç”Ÿæˆï¼Œè€Œä¸æ˜¯å†™æ­»åœ¨ `blocks.ts`
2. **质量组件本体**:QualityTable / SampleInfo / ReportHeader / InspectionItem / Result äº”类,每类带 `qualityType` + å±žæ€§ schema。
3. **QualityPropertyPanel**:选中质量组件时,右侧除 GrapesJS æ ·å¼é¢æ¿å¤–,再渲染业务属性表单。
4. **数据绑定引擎**(绑定路径解析 + å–值),为 Phase 3 æ¸²æŸ“做准备。
5. **规则引擎**:先定表达式语法与 AST æ±‚值器骨架,**严禁 `eval` / `ScriptEngine`**。
6. æ”¶å°¾ï¼šç¡®è®¤æ˜¯å¦æ¸…理模板 3 çš„联调遗留版本。
---
## Phase 2 å®žæ–½è®°å½•(2026-09-18 ç»­ï¼‰
> ç›®æ ‡ï¼šè®©ã€Œè´¨é‡ç»„件」成为一套**独立于 GrapesJS çš„æ³¨å†Œæœºåˆ¶**,并打通
> `模板 Schema + æŠ¥å‘Šä¸Šä¸‹æ–‡ â†’ HTML` çš„æ¸²æŸ“链路,达成 Phase 2 éªŒæ”¶æ ‡å‡†
> **「`inspectionItems` è‡ªåŠ¨ç”Ÿæˆè¡¨æ ¼å¹¶ç®—å‡º PASS/FAIL」**。
### ä¸€ã€äº¤ä»˜ç‰©
**协议层(`src/components/quality/core/`)** â€” ç»„件实现里不出现任何 GrapesJS æ¦‚念
| æ–‡ä»¶ | èŒè´£ |
|---|---|
| `types.ts` | `QualityComponentDefinition` åè®®ï¼špropertySchema / dataSchema / buildContent / validate |
| `registry.ts` | å¹‚等注册表,按 type ç´¢å¼• |
| `factory.ts` | åè®® â†’ GrapesJS äº§ç‰©ï¼šç»„件块、画布内容、traits(**唯一的桥接层**) |
| `serializer.ts` | ä¸šåŠ¡å±žæ€§ â†” `data-qc-*` å±žæ€§å¾€è¿” |
| `validator.ts` | å±žæ€§æ”¶æ•›ä¸Žæ ¡éªŒ |
| `attrs.ts` | `data-quality-type` / `data-qc-*` / `data-qc-repeat(-row)` / `data-qc-id` å¸¸é‡ |
| `html.ts` / `icons.ts` | `cssStyle` / `escapeHtml` / å†…联 SVG å›¾æ ‡ |
| `lookup.ts` | æŒ‰èŠ‚ç‚¹ä¸Šçš„ `data-quality-type` åæŸ¥å®šä¹‰ |
| `canvas-sync.ts` | ç”»å¸ƒæ–‡å­— â†” ä¸šåŠ¡å±žæ€§åŒå‘åŒæ­¥ï¼›è½½å…¥åŽé‡å»º traits |
| `trait-binding.ts` | ã€Œç»‘定数据」自定义 trait æŽ§ä»¶ï¼ˆä¸‹æ‹‰æŒ‘选 + æ‰‹å·¥è¾“å…¥ + `tip` æç¤ºï¼‰ |
**组件本体(12 ä¸ªï¼‰**
- åŸºç¡€ï¼š`Heading` / `Text` / `Image` / `Divider` / `Spacer` / `Table`
- æŠ¥å‘Šï¼š`ReportHeader` / `ReportFooter` / `SampleInfo`
- æ£€éªŒï¼š`QualityTable` / `InspectionItem`
- ç»“果:`ReportResult`
新增一个组件=在 `index.ts` çš„ `BUILT_IN_DEFINITIONS` é‡ŒåŠ ä¸€è¡Œï¼Œ**不改设计器页面、不改 GrapesJS é›†æˆä»£ç **。
**引擎层(`src/components/quality/engine/`)**
| æ–‡ä»¶ | èŒè´£ |
|---|---|
| `path.ts` | ç»‘定路径解析(`a.b.c`)与数值转换 |
| `binding.ts` | `{{path}}` æ–‡æœ¬è§£æžï¼Œç¼ºå€¼èµ°å›žè°ƒä¸ŠæŠ¥è€Œä¸æ˜¯é™é»˜åžæŽ‰ |
| `context.ts` | æŠ¥å‘Šä¸Šä¸‹æ–‡æ¨¡åž‹ + `PASS`/`FAIL`/`待判定` å¸¸é‡ |
| `rule-engine.ts` | **自建词法/AST è§£æžä¸Žç™½åå•求值,无 `eval`、无 `ScriptEngine`** |
| `report-evaluator.ts` | é€é¡¹åˆ¤å®šï¼ˆitem è§„则 â†’ è§„格上下限 â†’ æ•°æ®è‡ªå¸¦ç»“果)与报告汇总 |
| `page.ts` | A3/A4/A5/Letter çº¸å¼ ä¸Žé¡µè¾¹è·æ¢ç®—(mm) |
| `render.ts` | GrapesJS é¡¹ç›®æ•°æ® â†’ HTML æ–‡æ¡£ï¼ˆå« `@page` ä¸Žæ ·å¼é€‰æ‹©å™¨æ”¹å†™ï¼‰ |
设计器侧的接线:左侧组件面板改由 registry åŠ¨æ€ç”Ÿæˆï¼›`registerBindingTraitType()` æ³¨å†Œç»‘定控件;
`loadSchema` è½½å…¥åŽè°ƒç”¨ `restoreQualityTraits()` é‡å»ºå±žæ€§é¢æ¿å­—段。
### äºŒã€Phase 2 éªŒæ”¶ï¼šæµè§ˆå™¨å®žæµ‹ç»“æžœ
用**后端真实保存的 Schema**(模板 3 è‰ç¨¿ `qc_report_template_version.id=6`)配一份 3 è¡Œæ£€éªŒæ•°æ®ï¼Œ
在页面内直接调用 `renderReport({ schema, context })`:
| éªŒè¯é¡¹ | ç»“æžœ |
|---|---|
| æ£€éªŒé¡¹è¡¨æ ¼æŒ‰ `inspectionItems` è‡ªåŠ¨ç”Ÿæˆè¡Œ | âœ… `tbody` å±•å¼€ 3 è¡Œï¼ˆå¤–è§‚ / é•¿åº¦ / ç›´å¾„),全文档 3 è¡¨ / 10 `tr` / 42 `td` / 6 `th` |
| PASS/FAIL åˆ¤å®š | âœ… æœ‰è§„格上下限时 1 åˆæ ¼ / 1 ä¸åˆæ ¼ï¼Œ`result=FAIL`、`passRate=50.00%` |
| å…¨éƒ¨åˆæ ¼ | âœ… `result=PASS`、`passRate=100.00%`、结论「合格」 |
| çº¸å¼ ä¸Žé¡µè¾¹è· | âœ… äº§å‡º `@page { size: 210mm 297mm; margin: 10mm 10mm 10mm 10mm; }` |
| ç»‘定全部解析 | âœ… äº§ç‰©æ— æ®‹ç•™ `{{}}` |
| ç»„ä»¶ id ä¸å†æ³„漏成 DOM `id` | âœ… æ”¹å†™ä¸º `data-qc-id`,重复行不会撞 id |
| æ•°æ®ç¼ºå£æ˜¾å¼æš´éœ² | âœ… å¦‚「第 1 é¡¹ã€Œå¤–观」没有规格上下限也没有判定规则,无法判定」进 `errors`,不静默 |
| ã€Œç»‘定数据」控件(text æ¨¡å¼ï¼‰ | âœ… æŒ‘选「判定结论」写入 `{{report.conclusion}}`;手工输入「合格」时下拉回落「不绑定」 |
| ã€Œç»‘定数据」控件(path æ¨¡å¼ï¼‰ | âœ… æŒ‘选「检验项目列表」写入裸 `inspectionItems`(不加 `{{}}`) |
| è¡Œåºå· | âœ… `{{index}}` æ¸²æŸ“出 1、2… |
| å‰ç«¯ç±»åž‹æ£€æŸ¥ | âœ… `pnpm typecheck` åœ¨æœ¬æ¨¡å—新改文件上 **0 error**(仓库其余 316 æ¡ä¸ºæ—¢æœ‰é—®é¢˜ï¼Œéžæœ¬æ¬¡å¼•入) |
### ä¸‰ã€æœ¬é˜¶æ®µå®šä½å¹¶ä¿®å¤çš„问题
| # | çŽ°è±¡ | æ ¹å›  | ä¿®å¤ |
|---|---|---|---|
| 1 | **保存后重开模板,右侧业务属性面板全空**,组件只剩 Id/Title | traits **不参与** GrapesJS é¡¹ç›®æ•°æ®åºåˆ—化;且每个组件都自带 `id`/`title` ä¸¤ä¸ªé»˜è®¤ trait,导致「已有 trait å°±è·³è¿‡é‡å»ºã€çš„判空恒为真 | `restoreQualityTraits()` æŒ‰ `data-quality-type` ä»Žæ³¨å†Œè¡¨**无条件重建** traits。属性值仍优先读 `data-qc-*`,不会把用户改过的值冲回默认 |
| 2 | ç‚¹å‡»å·¦ä¾§ç»„件块,新组件被**塞进当前选中组件内部**,并顶掉它原有的文本 | `BlockView.handleClick` ä¼˜å…ˆ `append` è¿› `editor.getSelected()` | æ¯ä¸ª block è‡ªå®šä¹‰ `onClick`,统一追加到 `editor.getWrapper()`;拖拽仍可精确落点 |
| 3 | æ•´å¼ è¡¨æ ¼æ¸²æŸ“成一堆 `div` | é¡¹ç›®æ•°æ®åœ¨ `tagName` ç­‰äºŽç»„件类型默认值时不写 `tagName`,`table/thead/tbody/row/cell` åªç•™ `type` | `render.ts` å¢žåŠ  `TYPE_TAGS` æ˜ å°„按 `type` æŽ¨æ ‡ç­¾ |
| 4 | æ¸²æŸ“产物与设计器所见不一致 | GrapesJS `__innerHTML` æ˜¯ `!cmps.length ? content : cmps.map(...)`,**子组件优先于 content** | `renderChildren` åŒè§„则:有子组件就忽略 `content` |
| 5 | åºå·åˆ—永远空白 | é‡å¤å®¹å™¨æŠŠè¡Œå·æ³¨å…¥åˆ°ä½œç”¨åŸŸ**顶层** `index`,组件里却写的是 `{{item.index}}` | è¡¨è¾¾å¼æ”¹ `{{index}}`;并把「行序号」登记进 `dataSchema`,绑定下拉里能选到 |
| 6 | **「结论:合格」与「合格率 0.00%」同时出现** | æŠ¥å‘Šçº§åˆ¤å®šå†™æˆ `failCount === 0 ? PASS : FAIL`,全部待判定时 `failCount=0` è¢«åˆ¤æˆåˆæ ¼ | åªæœ‰ `passCount === total` æ‰ç»™åˆæ ¼ç»“论;存在待判定项时不下结论(没验过不能说合格,没证据也不能说不合格) |
| 7 | ã€Œç»‘定数据」控件给**路径型**字段包上 `{{}}`,渲染期就找不到数组 | åè®®é‡Œæ²¡åŒºåˆ†ã€Œæ–‡æœ¬ç»‘定」与「路径绑定」 | `QualityFieldSchema` æ–°å¢ž `bindAs: 'path' \| 'text'`(默认 `text`);`QualityTable` çš„「检验项数据源」声明为 `path` |
### å››ã€åŽç»­æ‰©å±•需求(2026-09-18 ä¸Žç”¨æˆ·ç¡®è®¤å£å¾„)
用户提出后续要加 **AI + OCR è¯†åˆ«æ–‡ä»¶è‡ªåŠ¨ç”Ÿæˆæ¨¡æ¿**。已确认两点口径:
1. **输入文件三类都要**:扫描件/照片、电子版 PDF、Word/Excel æ¨¡æ¿ã€‚
   â†’ è¾“入侧必须做成**可插拔适配器**,统一产出「中间文档结构」再交 AI。
   ï¼ˆPDF èµ° PDFBox、Word/Excel èµ° POI / FastExcel,项目均已有;扫描件需 OCR + ç‰ˆé¢åˆ†æžï¼ŒJava æ— çŽ°æˆæ–¹æ¡ˆï¼Œå€¾å‘æŽ¥äº‘ç«¯è¡¨æ ¼è¯†åˆ« API。)
2. **AI äº§å‡ºåªä½œè‰ç¨¿**,人工在设计器确认后才保存为正式版本。
**这两点口径对架构的影响(重要):**
因为草稿不需要立刻可渲染,**「语义 â†’ ç”»å¸ƒ JSON」的编译可以放在前端**:
AI å‡ºè¯­ä¹‰è‰ç¨¿ â†’ ç”¨æˆ·åœ¨è®¾è®¡å™¨æ‰“å¼€ â†’ å‰ç«¯ç”¨çŽ°æˆçš„ TS ç»„件定义(`factory.buildContent`)编译成画布 â†’
用户确认保存时才落 `grapes`。**服务端因此不需要组件知识**,Java æ¸²æŸ“引擎继续只认 `grapes`、继续组件无关,
不存在「12 ä¸ªç»„件在 TS/Java å„写一份」的漂移风险。
**必须写进契约的约束:** è¯­ä¹‰å±‚是**有意的子集**——只表达「用注册组件搭出来的扁平结构」,
不表达任意 HTML(用户手工拖入的裸表格、自定义 div ä¸åœ¨å…¶å†…)。AI åªèƒ½ç”¨å·²æ³¨å†Œçš„组件作为积木。
这是有意收窄的边界,不是实现疏漏。
**要做的事:**
- æŠŠ `ReportTemplateSchema.components` ä»Žã€Œ`collectSchema` é¡ºæ‰‹å¡«ã€æ¸²æŸ“不读」的**死数据补成正式契约**,
  è¿ž `dataSources` / `bindings` ä¸€èµ·å®šä¹‰æ¸…楚,并冻结进 `schemaVersion`。
- è¯¥å¥‘约同时也是**给 LLM çš„积木清单**(设计器组件面板、前端编译、AI æç¤ºä¸‰å¤„复用同一份来源)。
### äº”、留给下一步的提醒
1. **Phase 3 æœ‰ä¸€ä¸ªå¾…拍板的架构分叉**:渲染引擎目前是 **TS**,跑在浏览器里。后端 Playwright æ‰“印 PDF éœ€è¦ HTML——
   (A) å‰ç«¯æ¸²æŸ“ HTML â†’ POST ç»™åŽç«¯ï¼ŒåŽç«¯åš **Sanitization** å†ç”¨ Playwright/Chromium æ‰“印;
   (B) æŠŠæ¸²æŸ“引擎移植成 Java,服务端全链路自持。
   æ–¹æ¡ˆè®¾è®¡æ–‡æ¡£çš„风险清单要求「模板 HTML å±žç”¨æˆ·å¯ç¼–辑内容 â†’ **必须服务端 Sanitization**」,两条路都能满足,但投入差别很大。
2. **模板 3(QC_IQC_DEMO_COPY)的草稿版本 `id=6` æ˜¯è”调遗留数据**,里面是测试组件(且含一处早期「组件被塞进标题」的痕迹),建议直接删除。
3. **MySQL MCP å½“前指向沙盒实例**(`product-inventory-management-jcck`、MySQL 8.0.32),**不含本项目任何库**。
   æœ¬é˜¶æ®µæ ¸å¯¹çœŸå®žæ•°æ®èµ°çš„æ˜¯é¡µé¢å†… `requestClient` / åŽç«¯æŽ¥å£ï¼Œä¸æ˜¯ MCP。
4. å‰ç«¯**无测试框架**,本阶段的验证方式是「浏览器内 `await import('#/components/quality/index.ts')` ç›´æŽ¥è°ƒçº¯å‡½æ•° + Playwright æ“ä½œçœŸå®žè®¾è®¡å™¨ã€ã€‚
   Phase 5 è‹¥è¦è¡¥è‡ªåŠ¨åŒ–æµ‹è¯•ï¼Œéœ€è¦å…ˆå¼•å…¥æµ‹è¯•è¿è¡Œå™¨ã€‚
---
## Phase 3 å®žæ–½è®°å½•(2026-09-18 ç»­ï¼‰
> ç›®æ ‡ï¼šè®©**服务端自持出件链路**——`Schema + ä¸Šä¸‹æ–‡ â†’ HTML â†’ PDF`,
> ä¸ä¾èµ–浏览器、不依赖前端在跑,批量出件与定时任务才能走同一个入口。
### ä¸€ã€æž¶æž„分叉的结论:选 B(把引擎移植成 Java)
第一份进度里留的问题(A:前端渲染 HTML å›žä¼ åŽç«¯æ‰“印 / B:移植成 Java)已拍板走 **B**。
| ç»´åº¦ | A(前端渲染 + å›žä¼ ï¼‰ | B(Java å¼•擎) |
|---|---|---|
| æ€§èƒ½ | å‡ºä»¶è¦ä¹ˆå ç”¨æˆ·æµè§ˆå™¨ï¼Œè¦ä¹ˆèµ·æ— å¤´æµè§ˆå™¨ï¼›æ‰¹é‡å‡ºä»¶è¦æŽ’队等浏览器实例 | çº¯å†…存计算,无浏览器开销;PDF é˜¶æ®µæ‰éœ€è¦ Chromium,且可复用实例 |
| æ‰©å±•性 | å‰ç«¯ä¸€æ”¹æ¸²æŸ“逻辑,历史报告重出结果就变 | å¼•擎无状态、组件无关,接其它模块 / å®šæ—¶ä»»åŠ¡ / æ¶ˆæ¯æ¶ˆè´¹éƒ½ä¸æ”¹ä»£ç  |
| å¯ç»´æŠ¤æ€§ | æ¸²æŸ“逻辑散在浏览器端,排查要开 DevTools | æœåŠ¡ç«¯å¯æ–­ç‚¹ã€å¯å•æµ‹ã€å¯æ‰“æ—¥å¿—ï¼Œå‡ºä»¶é—®é¢˜ç›´æŽ¥å®šä½ |
| å¯ç”¨æ€§ | å‰ç«¯ä¸åœ¨çº¿å°±å‡ºä¸äº†ä»¶ | ä¸Žå‰ç«¯è§£è€¦ï¼ŒæŽ¥å£/任务都能出件 |
**B çš„唯一真实风险是「两套引擎漂移」**(预览用 TS、出件用 Java,不一致就是「预览与出件不符」)。
所以本阶段不是单纯移植,而是**连带做了一套对拍机制**——见第三节,这是 B èƒ½æˆç«‹çš„前提,不是附加项。
### äºŒã€äº¤ä»˜ç‰©
**Java å¼•擎(`yudao-module-qcreport/src/main/java/.../engine/`,24 ä¸ªæ–‡ä»¶ï¼‰**
| åŒ… | æ–‡ä»¶ | èŒè´£ |
|---|---|---|
| `engine/` | `QualityReportEngine` | é—¨é¢ï¼š`render` / `rulesOf` / `pageOf` / `grapesOf` / `validateRules` |
| | `Paths` `Numbers` `JsValues` `Bindings` | è·¯å¾„取值、`String(value)` è¯­ä¹‰ã€`{{path}}` ç»‘定解析 |
| | `QualityResult` | PASS/FAIL + `待判定` |
| | `PageSetting` `PageMargin` `ResolvedPage` `PageSizes` | çº¸å¼ /页边距换算与 `@page` è¾“出 |
| `engine/context/` | `ReportContext` `ReportFields` `InspectionItem` | æŠ¥å‘Šä¸Šä¸‹æ–‡æ¨¡åž‹ï¼ˆå­—段名与 TS ä¾§ä¸€è‡´ï¼‰ |
| `engine/rule/` | `RuleEngine` `RuleParser` `RuleEvaluator` `RuleNode` `RuleToken` `TokenType` | **自建词法/AST + ç™½åå•函数**,无 `eval`、无 `ScriptEngine` |
| | `QualityRuleDefinition` `RuleScope` | è§„则定义与作用域 |
| | `RuleSyntaxException` `RuleRuntimeException` | è¯­æ³•错误(带位置)与求值错误 |
| `engine/report/` | `ReportEvaluator` `EvaluationOutcome` | é€é¡¹åˆ¤å®šä¸ŽæŠ¥å‘Šæ±‡æ€» |
| `engine/render/` | `HtmlRenderer` `RenderNode` `RenderOutcome` | Schema â†’ HTML æ–‡æ¡£ï¼ˆå« `@page`、样式选择器改写) |
引擎**不依赖 Spring、不落库、不做权限**——只吃「Schema + ä¸Šä¸‹æ–‡ã€å HTML,
这样批量生成、定时任务、其它模块调用都走同一个入口。
**对拍机制(本阶段的核心产出之一)**
| ä½ç½® | ä½œç”¨ |
|---|---|
| `src/test/resources/qcreport/schema.json` `context.json` | åŒä¸€ä»½è¾“å…¥ fixture |
| `src/test/resources/qcreport/expected-html.html` `expected-rules.json` | **前端引擎**跑出来的期望产物(冻结) |
| `mom-pro2-before/.qc-conformance/render-ts.ts` | ç”¨å‰ç«¯å¼•擎重算期望产物的脚本(改动任一侧引擎后重跑) |
| `FrontendConformanceTest` | åŽç«¯å¼•擎跑同一 fixture,逐字比对 HTML / ç¼ºå£æ¸…单 / 41 æ¡è¡¨è¾¾å¼ |
| `ReportEvaluatorTest` | åˆ¤å®šè¯­ä¹‰ä¸Žæ±‚值健壮性(19 ä¸ªç”¨ä¾‹ï¼‰ |
重算期望产物的命令(在 `mom-pro2-before` ä¸‹ï¼‰ï¼š
```powershell
./node_modules/.bin/tsx .qc-conformance/render-ts.ts ../yudao-module-qcreport/src/test/resources/qcreport ../yudao-module-qcreport/src/test/resources/qcreport
```
### ä¸‰ã€å¯¹æ‹è¦†ç›–了什么
`FrontendConformanceTest` ä¸‰é¡¹æ–­è¨€ï¼Œ**全绿**:
| ç”¨ä¾‹ | æ¯”对内容 |
|---|---|
| æ¸²æŸ“产物逐字一致 | HTML å…¨æ–‡ï¼ˆå« `@page`、`data-qc-id` æ”¹å†™ã€é‡å¤è¡Œå±•开、转义)+ æ•°æ®ç¼ºå£æ¸…单 |
| åˆ¤å®šç»“果一致 | ç»“论 OK/不合格、total/passCount/failCount/passRate、逐项 PASS/FAIL |
| è§„则求值一致 | **41 æ¡è¡¨è¾¾å¼**的类型与文本,或报错文案逐字相同 |
41 æ¡è¡¨è¾¾å¼è¦†ç›–:字符串/数值/布尔比较、`IN` / `NOT IN` / `BETWEEN` / `NOT BETWEEN`、
`AND` / `OR` / `!` / ä¸€å…ƒè´Ÿå·ã€ç®—术与除零、文本拼接、`NULL` æ¯”较、
12 ä¸ªç™½åå•函数的正常值(含 `STDDEV` / `CP` / `CPK` çš„æµ®ç‚¹æ–‡æœ¬ï¼‰ã€
缺参 / ç±»åž‹ä¸ç¬¦ / æœªçŸ¥å‡½æ•° / è¯­æ³•残缺的报错文案、以及**真值口径**。
移植过程中真正会咬人的是 **JS ä¸Ž Java çš„取值/格式化差异**,逐条处理过:
| é™·é˜± | å¤„理 |
|---|---|
| `String(60)` â†’ `"60"`,Java `String.valueOf(60.0D)` â†’ `"60.0"` | `Numbers.toString` æŠŠæ•´æ•°å€¼æµ®ç‚¹æ•°æ”¶æˆæ•´æ•° |
| æ•°ç»„ `String()`:JS `"1,2"`,Java `"[1, 2]"` | `JsValues.asText` å¯¹ List é€å…ƒç´ æ‹¼æŽ¥ |
| ç§‘学计数法阈值不同(JS <-6 æˆ– â‰¥21,Java <-3 æˆ– â‰¥7) | ç»Ÿä¸€èµ° `Numbers.toString` |
| `toUpperCase()` å— locale å½±å“ï¼ˆåœŸè€³å…¶è¯­ i â†’ Ä°ï¼‰ | åˆ¤å®šå–值归一化用 `Locale.ROOT` |
| `Map.copyOf` ä¸ä¿è¯éåŽ†é¡ºåºï¼Œä¼šè®©æŠ¥é”™é‡Œçš„å¯ç”¨å‡½æ•°éšæœºæŽ’ | ç”¨ `Collections.unmodifiableMap` ä¿ä½ LinkedHashMap é¡ºåº |
| `Map.of(...).get(null)` æŠ› NPE | ç±»åž‹æ˜ å°„先判空再查表 |
| **真值口径**:能当数字看的取值一律按 `!== 0` åˆ¤çœŸ | æ‰€ä»¥ `"0"` æ˜¯**假值**,而原生 JS `Boolean("0")` æ˜¯çœŸå€¼ï¼›ç©ºæ•°ç»„也是假值。后端照搬前端,改动前必须先改前端 |
### å››ã€è¿œè¶…前端引擎的一处安全加固
`HtmlRenderer` åœ¨å±žæ€§è¾“出前加了一道闸,**这是有意偏离 TS ç‰ˆ**:
- **事件属性**(`on*`)一律不输出——模板里塞 `onclick` ä¸è¯¥å‡ºçŽ°åœ¨å‡ºä»¶äº§ç‰©é‡Œï¼›
- **危险协议 URL**(`javascript:` / `vbscript:` / `data:`,`data:image/` é™¤å¤–)一律不输出。
被拦下的属性**写进问题清单**(如「模板属性「onclick」是事件属性,报告产物不输出,已忽略」),
让模板作者看得见,而不是悄悄少个属性。TS ç‰ˆæ²¡æœ‰è¿™é“闸,所以这条**不在对拍范围内**,靠 `ReportEvaluatorTest` ä¹‹å¤–的单测守。
另需说明:**入站 HTML çš„ Sanitization ä»å½’出件接口负责**(Phase 3 ç¬¬ 4 æ­¥ï¼‰ï¼Œ
渲染引擎的这道闸只管「组件属性 â†’ äº§ç‰©å±žæ€§ã€è¿™ä¸€å±‚,两者不重叠。
### äº”、判定语义:一个真实缺陷被测试钉住了
Phase 2 ä¿®è¿‡çš„缺陷(「结论合格 + åˆæ ¼çއ 0%」自相矛盾)现在有回归用例守着:
三项里两项合格、一项待判定 â†’ **不给合格结论**(结论为「待判定」),合格率照算 66.67%。
`ReportEvaluatorTest` å¦æœ‰è¦†ç›–:单侧规格、规则优先于上下限、数据自带结果兜底、
规则跑不通时不给结论、停用规则忽略、报告级规则优先于汇总、空报告不给结论、
**判定不修改传入上下文**、函数缺参报可读错误(不是 `IndexOutOfBoundsException` ç©¿é€æˆã€Œç³»ç»Ÿå¼‚常」)。
### å…­ã€éªŒæ”¶
```
Tests run: 29, Failures: 0, Errors: 0, Skipped: 0
```
- `FrontendConformanceTest`:3/3 é€šè¿‡ï¼ˆHTML å…¨æ–‡é€å­—一致、41 æ¡è¡¨è¾¾å¼ä¸€è‡´ï¼‰
- `ReportEvaluatorTest`:23/23 é€šè¿‡ï¼ˆåˆ¤å®šè¯­ä¹‰ + è§„则求值健壮性 + ä¿å­˜å‰æ ¡éªŒï¼‰
- `ReportTemplateSchemaCompatibilityTest`:3/3 é€šè¿‡ï¼ˆèµ°ç”Ÿäº§åŒæ¬¾ `Jackson3TypeHandler` è¯»æ—§ JSON)
- ç¼–译:`mvn compile -pl yudao-module-qcreport -am -q -o` é€šè¿‡
运行命令:
```powershell
mvn test -pl yudao-module-qcreport -am -q -o -Dtest='FrontendConformanceTest,ReportEvaluatorTest,ReportTemplateSchemaCompatibilityTest' -Dsurefire.failIfNoSpecifiedTests=false
```
### ä¸ƒã€è¿›è¡Œä¸­ / æœªå®Œæˆé¡¹
| # | äº‹é¡¹ | çŠ¶æ€ |
|---|---|---|
| 1 | å¼•擎移植(渲染 + è§„则 + åˆ¤å®šï¼‰ | âœ… å®Œæˆå¹¶é€šè¿‡å¯¹æ‹ |
| 2 | å†»ç»“ Schema è¯­ä¹‰å±‚契约(契约 1.1) | âœ… å®Œæˆï¼Œè§ç¬¬ä¹èŠ‚ |
| 2b | ä¿®å¤è®¾è®¡å™¨ä¿å­˜æ¸…空判定规则(数据丢失) | âœ… å®Œæˆï¼Œè§ç¬¬ä¹èŠ‚ |
| 2c | è§„则编辑界面 + ä¿å­˜å‰æ ¡éªŒé—¸å£ | âœ… å®Œæˆå¹¶æµè§ˆå™¨å®žæµ‹ï¼Œè§ç¬¬åèŠ‚ |
| 3 | **引入 Playwright + ä¸‹è½½ Chromium** | â¬œ æœªå¼€å§‹ï¼Œ**需要用户重启后端** |
| 4 | å‡ºä»¶æŽ¥å£ + `data_snapshot` + HTML/PDF å½’档(含入站 HTML Sanitization) | â¬œ æœªå¼€å§‹ |
| 5 | Phase 4(实例/版本/权限/日志)、Phase 5(自动化 + E2E) | â¬œ æœªå¼€å§‹ |
| 6 | AI + OCR è¯†åˆ«æ–‡ä»¶ç”Ÿæˆæ¨¡æ¿è‰ç¨¿ | â¬œ æœªå¼€å§‹ï¼ˆå£å¾„已确认,见 Phase 2 ç¬¬å››èŠ‚ï¼‰ |
### ä¹ã€Schema å¥‘约冻结 1.1 + è§„则数据丢失修复(2026-09-18 ç»­ï¼‰
第 2 æ­¥åŽŸæœ¬ä»¥ä¸ºåªæ˜¯"补字段",实际排查出**三个真实缺陷**,一并处理了。
#### 9.1 ç¼ºé™·ä¸€ï¼šä¿å­˜ä¸€æ¬¡è®¾è®¡å™¨å°±æŠŠå·²æœ‰åˆ¤å®šè§„则抹掉(静默数据丢失)
`use-designer.ts` çš„ `collectSchema` é‡Œç¡¬ç¼–码 `rules: []`,而后端 `updateVersion` æ˜¯
`BeanUtils.toBean(updateReqVO, DO.class)` + `updateById`——**整份 Schema æ•´ä½“覆盖**。
两者叠加的结果:只要打开模板点一次保存,库里已有的规则就被清空,
而且没有任何报错,用户不会知道自己弄丢了什么。
修法是让设计器把"自己不负责编辑的字段"原样带回(`use-designer.ts`):
```ts
// è½½å…¥æ—¶ç•™å­˜ï¼Œä¿å­˜æ—¶å¸¦å›žï¼›åˆ‡åˆ°ç©ºç™½ç‰ˆæœ¬æ—¶è·Ÿç€æ¸…空
let retainedSchema: Pick<QcReportVersionApi.ReportTemplateSchema, 'rules'> = {};
```
`loadSchema` é‡Œ `retainedSchema = schema?.rules ? { rules: schema.rules } : {}`,
`collectSchema` æœ«å°¾ `...retained`。`rules` ä¸ºç©ºæ•°ç»„时是**真值**,所以
"切到一个 rules:[] çš„版本"会正确带上空数组而不是残留上一个版本的规则;
`schema` ä¸º undefined(没写过内容的草稿)时清空留存,也不会串味。
**已用浏览器实测**:写两条规则到模板 3 çš„草稿版本 â†’ é‡è½½è®¾è®¡å™¨ â†’ ç‚¹ã€Œä¿å­˜ã€â†’
回读版本 Schema,`rules` ä¸¤æ¡éƒ½åœ¨ã€‚验证完已把该草稿还原(`rules: []`、`schemaVersion: "1.0"`)。
#### 9.2 ç¼ºé™·äºŒï¼šè§„则校验写好了但没有任何调用方
`QualityReportEngine.validateRules` åªæœ‰å®šä¹‰æ²¡æœ‰è°ƒç”¨ï¼Œ`validateSchema` åªæŸ¥äº†
`schemaVersion` éžç©ºã€‚规则写错要到出件时才炸,用户看到的是"报告出不来",
却不知道是模板里哪条规则写坏的。现已在保存路径上接通,逐条列出是哪条规则、错在哪:
```java
throw new ServiceException(TEMPLATE_VERSION_RULE_INVALID.getCode(),
        TEMPLATE_VERSION_RULE_INVALID.getMsg() + ":" + String.join(";", ruleErrors));
```
错误码 `1_070_101_006`:`模板里的判定规则不合法,请修改后重试`。
**一个有意留下的例外**:`validateRules` **跳过空表达式的规则**,不当保存错误。
理由写在方法 javadoc é‡Œâ€”—这类项只可能是历史脏数据,而**规则编辑界面还不存在**,
报了错用户也无处可改,只能卡在"保存不了",等于把"一条坏数据"升级成"整份模板不能保存"。
执行期仍会照常把它报出来(`第 N é¡¹ã€ŒX」的规则「Y」无法执行:规则表达式为空`)并标为待判定,
不是静默吞掉。**规则编辑界面做出来之后,这里应当改成明确报错。**
> **已兑现**:规则编辑界面做出来之后(见第十节 10.1),后端这里按上面这句话改成了明确报错,
> å•测 `ReportEvaluatorTest.blankExpressionRuleBlocksSave` è·Ÿç€æ”¹å†™æˆ"读得出来、但阻断保存"。
#### 9.3 ç¼ºé™·ä¸‰ï¼š`dataSources` / `bindings` / `styles` æ˜¯æ­»æ•°æ®
三个字段自 1.0 èµ·å°±æ˜¯ç©ºæ•°ç»„,既无人写入也无人读取,类型上还是无契约的
`Record<string, unknown>[]`。已从前后端一并删除,`schemaVersion` å‡åˆ° `1.1`。
因死字段原本恒为 `[]`,**1.1 æ— éœ€æ•°æ®è¿ç§»**。
同时把 `components` ç”± `Map` æ”¹ä¸ºå¼ºç±»åž‹ `QualityComponentNode`(`id`/`index`/`qualityType`/`attributes`),
并在 `ReportTemplateSchema` javadoc é‡Œå†™æ˜Ž**语义层是有意的子集**:
只表达"用已注册质量组件搭出来的扁平结构",**不表达任意 HTML**。
这条边界是给 AI ç”Ÿæˆæ¨¡æ¿ç”¨çš„约束——有了它才能要求"只能用已注册组件当积木",
而不是吐一段谁也不敢渲染的 HTML。任何"给语义层加个万能 customHtml å­—段"的改动都在拆这条边界,需要先讨论。
#### 9.4 å­˜é‡æ•°æ®å…¼å®¹
`ReportTemplateSchemaCompatibilityTest` èµ°**生产同款读取路径**
(MyBatis-Plus `Jackson3TypeHandler` + `QcReportTemplateVersionDO.schema` å­—段),
不是测试自己 new ä¸€ä¸ª ObjectMapper——因为要防的就是"反序列化读取"这条链路。
实测模板 3 çš„库里记录确实还带着 `bindings`/`dataSources`/`styles` ä¸‰ä¸ªé”®ï¼Œç”¨ä¾‹ä»¥å®ƒä¸ºè“æœ¬ã€‚
一句澄清免得后人误解:**这个用例不负责证明 `@JsonIgnoreProperties` çš„必要性**——
实测把那行注解去掉本用例照样通过(Jackson 3 é»˜è®¤å°±å¿½ç•¥æœªçŸ¥å±žæ€§ï¼‰ã€‚
注解真正防的是另外两条:Spring ååºåˆ—化请求体用的 ObjectMapper(配置不受本模块控制),
以及将来有人给类型处理器换一个更严格的 Mapper。测试类 javadoc é‡Œå·²æ˜¾å¼å£°æ˜Žå®ƒ**不**是那条注解的守卫。
### å…«ã€ç•™ç»™ä¸‹ä¸€æ­¥çš„æé†’
1. **改动任一侧引擎后必须重跑对拍**:先跑 `render-ts.ts` é‡ç®—期望产物,再跑 `FrontendConformanceTest`。
   æœŸæœ›äº§ç‰©æ˜¯**冻结**的,不重算就只是「后端跟一份旧产物比」,比过了也不代表两边一致。
2. **第 3 æ­¥å¿…须由用户操作**:Playwright ä¾èµ–要加进 `yudao-dependencies` å¹¶ä¸‹è½½ Chromium,需要重启后端才生效。
3. **`.qc-conformance/` åœ¨ `mom-pro2-before` ä¸‹**(该目录整个不被本仓库跟踪),是临时对拍工具,不要当作前端正式代码。
4. **第九节的两项改动要后端重启才生效**:规则保存校验(`validateRules` æŽ¥çº¿ï¼‰ä¸Žå¥‘约 1.1
   ï¼ˆåˆ é™¤ä¸‰ä¸ªæ­»å­—段)都在后端。**重启前**浏览器验证到的只有前端那一半(规则留存),
   åŽç«¯é‚£ä¸€åŠç›®å‰é å•测保证。重启后可直接看模板 3 çš„草稿记录:三个死字段应从 `schema` é‡Œæ¶ˆå¤±ã€‚
5. æ¨¡æ¿ 3 è‰ç¨¿ç‰ˆæœ¬ `qc_report_template_version.id=6` æ˜¯è”调遗留数据,本轮验证已用到并**还原**
   ï¼ˆ`rules: []`、`schemaVersion: "1.0"`);建议后续连模板一起清掉,避免和正式数据的版本号混淆。
## åã€è§„则编辑界面 + ä¿å­˜å‰æ ¡éªŒé—¸å£ï¼ˆ2026-09-18 ç»­ï¼‰
用户选定的顺序是"**先规则 UI,再出件接口**":先做不需要重启后端的部分并在浏览器验证,
出件接口写完后再统一重启一次。本节是前半段。
### 10.1 äº¤ä»˜ç‰©
| æ–‡ä»¶ | æ”¹åЍ |
|---|---|
| `designer/modules/rule-editor.vue` | **新增**(约 170 è¡Œï¼‰ã€‚抽屉式规则编辑器:新增/删除规则、改名称、切作用域、启停、编辑表达式,逐条内联报错 |
| `designer/use-designer.ts` | `retainedSchema` é‚£å¥—"留存再带回"换成一等状态 `rules` ref,随 `loadSchema` è½½å…¥ã€éš `collectSchema` äº§å‡º |
| `designer/index.vue` | é¡¶æ åŠ ã€Œåˆ¤å®šè§„åˆ™ï¼ˆN)」按钮 + çº¢æ ‡ã€ŒN æ¡è§„则待修正」;`handleSave` / `handlePublish` å‰åŠ  `blockOnInvalidRules()` é—¸å£ |
| `components/quality/engine/report-evaluator.ts` | `validateRule` è¡¥ä¸Š**函数白名单**校验(原先只查语法),入参改用新类型 `RuleValidationInput` |
| `components/quality/engine/rule-engine.ts` | æ–°å¢ž `listAllFunctionNames()`,界面提示与引擎白名单共用一份来源 |
| `components/quality/index.ts` | å¯¼å‡º `listAllFunctionNames` |
| `engine/QualityReportEngine.java` | ç©ºè¡¨è¾¾å¼è§„则由"放行"改为**明确报错**(兑现第九节 9.2 ç•™çš„例外) |
| `ReportEvaluatorTest.java` | å¯¹åº”用例改写为"读得出来、但阻断保存" |
**为什么 `listAllFunctionNames` è¦ä»Žå¼•擎里导出而不是在 UI é‡Œå¦æŠ„一份**:白名单是引擎的一部分,
抄一份就会在加函数时漏改,用户看到的可用函数列表与引擎实际支持的悄悄对不上。
**为什么 `validateRule` ä¸å†å¤ç”¨ `QualityRuleDefinition`**:那个类型的 `expression` æ˜¯å¿…填,
而设计器里正在编辑的规则、历史数据里的规则项都可能是空的,校验函数恰恰要接住这种"还没写完"的入参,
所以单独定义了 `RuleValidationInput`(三个字段全可选)。
**前端为何也要拦白名单**:只让后端的 `validateRules` æ‹¦ï¼Œç”¨æˆ·ç‚¹ä¿å­˜åŽä¼šåœ¨ç•Œé¢ä¸Šæ”¶åˆ°åŽç«¯æŠ¥é”™ï¼Œ
但那时人已经离开抽屉,还得自己翻是哪条规则写坏的。设计器里直接拦,红字就标在出问题的那一条上,
同时把抽屉自动打开。
### 10.2 æœ¬è½®åˆå®šä½åˆ°ä¸€ä¸ªçœŸå®žç¼ºé™·ï¼šæ”¹çº¸å¼ è®¾ç½®ä»Žæ¥æ²¡ä¿å­˜æˆåŠŸè¿‡
**现象**:每次点「保存」控制台都抛一个未处理异常(`Unhandled error during execution of component event handler`
挂在保存按钮上),但保存成功的提示照常弹出来,纸张设置却永远不会生效。
**根因**:`handleSave` æœ«å°¾åŒæ­¥çº¸å¼ è®¾ç½®æ—¶åªä¼ äº†ä¸‰ä¸ªå­—段——
```ts
await updateTemplate({ id, pageSize, orientation });   // â† æ—§å†™æ³•
```
而 `qc-report/template/update` æ˜¯**整体更新**,`QcReportTemplateSaveReqVO` ä¸Š
`templateCode` / `templateName` / `pageSize` / `orientation` éƒ½æ˜¯ `@NotEmpty`。
漏传模板编码,后端直接 400:`请求参数不正确:模板编码不能为空`。
**为什么以前没被发现**:这个 400 å‘生在 `message.success('已保存到 v1.0')` **之后**,
版本本身已经存好了,所以表面上看"保存是成功的";只有跨版本核对模板表才会发现纸张设置没落库。
又因为设计器读纸张是从**版本 Schema** çš„ `page` è¯»çš„(`loadSchema`),
不是从模板表读的,重新进设计器看到的仍是正确纸张,把这个问题彻底盖住了。
**修法**:把模板原有字段一起带上,只覆盖纸张两项(与列表页 `form.vue` æ•´ä½“更新的口径一致):
```ts
await updateTemplate({
  ...template.value,
  id,
  pageSize: pageSetting.value.size,
  orientation: pageSetting.value.orientation,
});
```
### 10.3 æµè§ˆå™¨å®žæµ‹è¯æ®
环境:dev server `http://192.168.0.10:5666`,模板 **5 / å‡ºè´§æ£€éªŒæŠ¥å‘Šæ¨¡æ¿**(QC_OQC_UI)。
| # | éªŒè¯é¡¹ | ç»“æžœ |
|---|---|---|
| 1 | æŠ½å±‰æ‰“开,空态与帮助块(作用域说明、可用字段、可用函数)正常 | âœ… |
| 2 | æ–°å¢žè§„则 â†’ ç«‹åˆ»å†…联报错「规则「规则 1」的表达式为空」+ é¡¶éƒ¨ã€Œæœ‰ 1 æ¡è§„则还没写完整」 | âœ… |
| 3 | å¡«å…¥åˆæ³•表达式后报错行消失 | âœ… |
| 4 | åˆ‡åˆ°ã€ŒæŠ¥å‘Šçº§ã€ï¼Œè¡¨è¾¾å¼å ä½æç¤ºåŒæ­¥æ¢æˆ `PASS_RATE(...) >= 60` | âœ… |
| 5 | å¡«å…¥ `FOO(...)` â†’ å†…联报错「调用了不支持的函数 FOO,可用函数:ABS、AVG、…」 | âœ… |
| 6 | è§„则有问题时点「保存」→ æŠ½å±‰è‡ªåŠ¨æ‰“å¼€ + æç¤ºã€Œæœ‰ 1 æ¡åˆ¤å®šè§„则还没写完整,请先修正再保存」,**且不发任何写请求**(网络面板确认) | âœ… |
| 7 | æ”¹æˆåˆæ³•后保存 â†’ ã€Œå·²ä¿å­˜åˆ° v1.0」;**重载设计器后顶栏显示「判定规则(2)」,两条规则的作用域与表达式都在** | âœ… |
| 8 | ä¿å­˜çº¸å¼ è®¾ç½® â†’ `template/update` è¿”回 `{"code":0}`,无未处理异常;改纸张方向后回读模板表确实变了(验证完已改回 landscape) | âœ… |
| 9 | å‘布 v1.0 â†’ å·²å‘布版本内容不变;再点保存 â†’ å¦å­˜å‡ºæ–°è‰ç¨¿ v1.1,**两条规则完整带过去** | âœ… |
第 7、9 ä¸¤æ¡åˆèµ·æ¥è¯æ˜Žäº†**规则数据不再丢失**:既不会被覆盖清空,也不会在另存新版本时掉。
`pnpm typecheck` å›žåˆ°åŸºçº¿ **316 æ¡**(改动前基线 316,`qc/report` ä¸Ž `components/quality` ä¸‹ **0 æ¡**)。
后端单测 **29 æ¡å…¨ç»¿**(`ReportEvaluatorTest` 23 + `FrontendConformanceTest` 3 + `ReportTemplateSchemaCompatibilityTest` 3)。
### 10.4 æœ¬æ¬¡å®žæµ‹ç•™ä¸‹çš„æ•°æ®
验证在**模板 5(出货检验报告模板)**上做的,状态是:
- ç‰ˆæœ¬ `v1.0`(`qc_report_template_version.id=5`):**已发布**(只读),带 2 æ¡ç¤ºä¾‹è§„则
- ç‰ˆæœ¬ `v1.1`(`id=7`):**草稿**,同样 2 æ¡ç¤ºä¾‹è§„则
两条示例规则:`实测值在规格内`(逐项)、`整体合格率不低于 90%`(报告级)。
都是有意义的演示数据,**没有删除**;如果不想留,删掉模板 5 å³å¯ã€‚
### 10.5 ä¸‹ä¸€æ­¥
按用户选定的顺序,接下来是**出件接口**(Phase 3 ç¬¬ 4 æ­¥ï¼‰ï¼šå‡ºä»¶ + `data_snapshot` + HTML/PDF å½’æ¡£ +
入站 HTML æœåŠ¡ç«¯ Sanitization。动手前必须先处理一件事:
`QcReportInstanceDO` é‡Œæœ‰ä¸ª `pdfFileUrl` å­—段,**违反** `.claude/rules/file-upload.md`
(禁止业务表/DO å‡ºçް `file_url` / `attachment_url` ä¸€ç±»çš„æ–‡ä»¶åœ°å€å­—段)。
PDF å¿…须走 `system_storage_attachment` ä¸­é—´è¡¨ï¼Œ`recordType = 'qc_report_instance'`。
这个 DO ç›®å‰æ˜¯ä¸ªå­¤å„¿ï¼ˆæ²¡æœ‰ Mapper/Service/建表脚本),改起来没有兼容负担。
另:`ErrorCodeConstants` é‡Œ `1_070_102_000/001/002`(实例)与 `1_070_103_000..005`(渲染)已预留但未使用。
---
## åä¸€ã€å‡ºä»¶æŽ¥å£ï¼ˆPhase 3 ç¬¬ 4 æ­¥ï¼‰â€”— âœ… å®Œæˆï¼šä¸¤è½® HTTP è”调全绿(27/27 + 24/24),修复 2 ä¸ªçœŸå®žç¼ºé™·å¹¶å¤éªŒ
> å®Œæ•´æ–¹æ¡ˆè§è®¡åˆ’文件 `C:\Users\云\.claude\plans\smooth-greeting-garden.md`
> **代码层面已全部落地**(Controller / å•测 / åˆå¹¶è„šæœ¬éƒ½å·²å®Œæˆï¼Œ43 é¡¹æµ‹è¯•全绿)。
> å”¯ä¸€æ²¡åšçš„æ˜¯**端到端 HTTP è”è°ƒ**——需先重启后端 48080 è®©æ–°ä»£ç ç”Ÿæ•ˆï¼Œè§ 11.5。
### 11.1 æœ¬è½®å£å¾„(用户拍板)
| è®®é¢˜ | å†³ç­– |
|---|---|
| æ•°æ®æ¥æº | **两步走**:本轮接口只吃**调用方传入的上下文**,qcreport **不依赖** `yudao-module-mes`;下一轮再做 `MesQcReportApi` æ‰“通 IPQC/IPQC/OQC/RQC å››ç§è´¨æ£€å• |
| PDF èŒƒå›´ | **先只做 HTML**。PDF éš Playwright ä¸€èµ·ä¸Šï¼ˆéœ€è”网下载 Chromium + é‡å¯åŽç«¯ï¼‰ï¼›é¡ºå¸¦æ¸…掉违规的 `pdf_file_url` |
| æŠ¥å‘Šç¼–号 | **qcreport è‡ªå·±ç”Ÿæˆ**,`QR20260918-0001`;调用方也可显式传入覆盖 |
| ç¼ºå£æ¸…单落库? | **不落库**,只在 preview/generate/regenerate çš„响应里返回 |
| `pdf_file_url` æ€Žä¹ˆåˆ  | ç”¨ MCP ç›´æŽ¥åœ¨è¿è¡Œåº“执行 `DROP COLUMN` + åŒæ­¥æ”¹ DDL è„šæœ¬ |
| åˆå¹¶åˆå§‹åŒ–脚本缺口 | **本轮一并补上** |
### 11.2 å·²å®Œæˆ
**(1)清掉违规的 `pdf_file_url`**
| åŠ¨ä½œ | ç»“æžœ |
|---|---|
| `QcReportInstanceDO` åˆ å­—段 | âœ… å·²åˆ ï¼Œé¡ºæ‰‹æŠŠ `id` ä¸Šå†™é”™çš„æ³¨é‡Šã€ŒæŠ¥å‘Šç¼–号」改为「实例编号」 |
| `docs/sql/qc_report_platform_ddl.sql` åˆ åˆ—定义 | âœ… å·²åˆ  |
| è¿è¡Œåº“ `ruoyi-vue-pro` æ‰§è¡Œ `ALTER TABLE ... DROP COLUMN` | âœ… å·²æ‰§è¡Œ |
| **回读验证**(`information_schema`) | âœ… `ruoyi-vue-pro` **14 åˆ— / `has_pdf_url`=0**;`mom-jhhg` ä»æ˜¯ 15 åˆ—(历史遗留库,无 profile æŒ‡å‘,**有意不动**) |
**(2)错误码新增两个**(`enums/ErrorCodeConstants.java`,1-070-101 æ®µå†… 007/008 åŽŸæœ¬ç©ºç€ï¼‰
```java
TEMPLATE_NO_PUBLISHED_VERSION  = 1_070_101_007  // è¯¥æ¨¡æ¿è¿˜æ²¡æœ‰å·²å‘布的版本,无法生成报告,请先在设计器中发布一个版本
TEMPLATE_VERSION_CANVAS_EMPTY  = 1_070_101_008  // è¯¥æ¨¡æ¿ç‰ˆæœ¬æ²¡æœ‰ç”»å¸ƒå†…容,无法生成报告,请先在设计器中设计并保存模板内容
```
不加这两个码就只能复用「模板版本不存在」,用户看不懂问题在哪(违反 `.claude/rules/error-message-precision.md`)。
其余全部复用已预留的码:`REPORT_INSTANCE_NOT_EXISTS` / `REPORT_INSTANCE_NO_DUPLICATE` /
`REPORT_INSTANCE_DATA_SNAPSHOT_MISSING` / `RENDER_BUSINESS_DATA_MISSING`。
**(3)两个纯逻辑接缝**(可脱离 Spring å•测,单测见(8))
| æ–‡ä»¶ | èŒè´£ |
|---|---|
| `service/instance/ReportNoGenerator.java` | `prefix(day)` â†’ `QR20260918-`;`format(day, seq)` â†’ è¡¥é›¶ 4 ä½ã€è¶… 9999 è‡ªç„¶åŠ å®½ï¼›`nextSequence(latestNo, day)` â†’ å°¾å· +1,**跨日/格式不认识/为空一律归 1** |
| `engine/context/ReportContextCodec.java` | `toMap(ReportContext)`(=`context.toScope()`)/ `fromMap(Map)`。**手写**逐字段读回,不靠 Jackson åå°„ â€”— å¼•擎本来就是这个风格(`reportMap()`/`itemMap()` å…¨æ˜¯æ˜¾å¼å­—段清单),且能避开 Jackson 3 / Jackson 2 ä¸¤å¥— Mapper å¹¶å­˜å¸¦æ¥çš„æ•°å€¼ç±»åž‹ä¸ŽæœªçŸ¥å­—段差异。**这是「历史报告可复现」的唯一实现路径** |
**(4)持久层与 Service**
- `dal/mysql/instance/QcReportInstanceMapper.java`:`selectPage` / `selectByReportNo` /
  `selectLatestByReportNoPrefix`。**末者的排序刻意写成 `ORDER BY CHAR_LENGTH(report_no) DESC, report_no DESC LIMIT 1`**:
  åºå·è¡¥é›¶ 4 ä½ï¼Œå½“天一旦超过 9999 ä½å®½å°±å˜é•¿ï¼Œçº¯å­—典序会把 `...-9999` æŽ’到 `...-10000` å‰é¢ï¼ˆjavadoc å·²å†™æ˜Žï¼‰ã€‚
- `service/instance/QcReportInstanceService.java` + `Impl`:`preview` / `generate` / `regenerate` /
  `validateInstanceExists` / `getInstance` / `getSnapshot` / `getInstancePage` / `deleteInstance`。
- `service/instance/GenerateResult.java`:`record GenerateResult(QcReportInstanceDO instance, List<String> errors)`。
- `service/version/QcReportTemplateVersionService(+Impl)`:**新增** `getVersionByTemplateIdAndVersion(templateId, version)`,
  å–版本但**不要求已发布**(`regenerate` è¦ç”¨ï¼Œç†ç”±è§ 11.3)。
**(5)VO å…­ä¸ª**(`controller/admin/instance/vo/`)
`QcReportInstanceGenerateReqVO`(`templateId` @NotNull、`version`、`reportNo`、`businessId`、`businessType`、
`context` @NotNull **直接复用引擎的 `ReportContext`**)、`PageReqVO`、`RespVO`(**刻意不含 `dataSnapshot`**)、
`PreviewRespVO`(`reportNo`/`html`/`errors`)、`GenerateRespVO`(`id`/`reportNo`/`templateVersion`/`status`/`errors`)、
`RegenerateRespVO`(`id`/`reportNo`/`errors`)。
`context` ç”¨ `ReportContext` è€Œä¸å¦é€ é•œåƒ VO:它就是这个平台对外的唯一数据契约,另造一个只会多一处要同步的字段清单。
**(6)编译** âœ… `mvn compile -pl yudao-module-qcreport -am -q -o` â†’ **exit 0**
**(7)Controller ä¸ƒä¸ªç«¯ç‚¹** âœ…
`controller/admin/instance/QcReportInstanceController.java`,`@RequestMapping("/qc-report/instance")`,
照 `QcReportTemplateVersionController` çš„写法(`@Tag` / `@RestController` / `@Validated` /
`@PreAuthorize("@ss.hasPermission(...)")` / `CommonResult.success` / `BeanUtils.toBean`):
| ç«¯ç‚¹ | æƒé™ç  | ç¼–排 |
|---|---|---|
| `POST /preview` | `qc-report:instance:query` | `instanceService.preview(reqVO)` â†’ `PreviewRespVO`(`reportNo` å– `outcome.context().getReport().getReportNo()`,可能为空;`html`;`errors`) |
| `POST /generate` | `qc-report:instance:create` | `GenerateResult` â†’ `GenerateRespVO`(id/reportNo/templateVersion/status/errors) |
| `GET /get?id=` | `qc-report:instance:query` | `getInstance(id)` â†’ `BeanUtils.toBean(...)`,**判空**(软删后返回 `data:null`,不报错) |
| `GET /snapshot?id=` | `qc-report:instance:query` | `getSnapshot(id)` â†’ `CommonResult<Map<String,Object>>` |
| `GET /page` | `qc-report:instance:query` | `getInstancePage(reqVO)` â†’ `PageResult<RespVO>` |
| `POST /regenerate?id=` | `qc-report:instance:create` | `GenerateResult` â†’ `RegenerateRespVO`(id/reportNo/errors) |
| `DELETE /delete?id=` | `qc-report:instance:delete` | `deleteInstance(id)` |
**(8)两个单测类,14 æ¡ç”¨ä¾‹** âœ…
| ç±» | æ¡æ•° | å®ˆçš„æ˜¯ä»€ä¹ˆ |
|---|---|---|
| `service/instance/ReportNoGeneratorTest` | 7 | å‰ç¼€ä¸Žæ ¼å¼ã€è¶… 9999 ä½å®½è‡ªç„¶åŠ å®½ã€**locale æ— å…³**(设 `th-TH-u-ca-buddhist-nu-thai` ä»è¾“出 `QR20260918-0007`)、同日递增、无编号归 1、跨日归 1、格式不认识归 1(7 ç§ç•¸å½¢è¾“入) |
| `engine/context/ReportContextCodecTest` | 7 | **核心一条 `roundTripReproducesHtml`**:读 fixture â†’ æ¸²æŸ“ â†’ `toMap` â†’ `fromMap` â†’ å†æ¸²æŸ“,断言两次 HTML **逐字相同**且 `errors` ç›¸åŒ â€”— è¿™å°±æ˜¯ã€ŒåŽ†å²æŠ¥å‘Šå¯å¤çŽ°ã€çš„æœºå™¨è¯æ˜Žï¼ŒåŒæ—¶è¯å‡ºåˆ¤å®šæ˜¯å¹‚ç­‰çš„ã€‚å¦ï¼šå¿«ç…§å¸¦ç€ç®—å¥½çš„åˆ¤å®šä¸Žåˆæ ¼çŽ‡ã€å¾€è¿”ä¸ä¸¢å­—æ®µã€æ— ä¸Šä¸‹é™ä¿æŒ null、缺字段落空串、空快照不抛、数组里混入非对象元素只跳过该元素 |
全模块 **43 é¡¹å…¨ç»¿**(原有 29 + æœ¬è½® 14),`mvn -pl yudao-module-qcreport test` exit 0。
**(9)合并初始化脚本 `config_export_all_20260918.sql`** âœ…
0904 å‰¯æœ¬ + ä¸‰å¤„改动:①头部说明改写;②`qc_report_*` 6 å¼ è¡¨å»ºè¡¨æ®µæ’在 `oa_notice_user` ä¸Ž `qrtz_blob_triggers` ä¹‹é—´
(`qc_` < `qr_`),`qc_report_instance` **不含 `pdf_file_url`**;③阶段一的 `system_dict_type` è¡¥ 6 è¡Œï¼ˆ1065400~1065405)、
`system_dict_data` è¡¥ 21 è¡Œï¼ˆ1065410~1065466)。
**实测验证**(不是只看语法):在真实实例上建临时库 `qc_init_check_tmp`,整份 1.2 MB æ–‡ä»¶è·‘完 â†’
EXIT=0;**419 å¼ è¡¨**、6 å¼  `qc_report_*`、`qc_report_instance` 14 åˆ—æ—  `pdf_file_url`、
6 ä¸ªå­—典类型 + 21 æ¡å­—典数据、1 ä¸ª admin、1258 ä¸ªèœå•、1 ä¸ª default oauth2 client、
2349 æ¡ `system_role_menu`、qcreport èœå• 1075400~1075405 é½å…¨ï¼›éšåŽ drop ä¸´æ—¶åº“并确认残留为 0。
> æ—§æ–‡ä»¶ `config_export_all_20260904.sql` **原样保留备查**,只维护新版(`.claude/rules/sql-config-export-init.md`)。
> âš  é˜¶æ®µä¸€å«å…¨åº“ `DROP TABLE IF EXISTS`,**只能在全新/可覆盖空库执行,禁止在运行库上跑**。
**(10)发现并修复了 0904 åŸºçº¿é‡Œçš„一个既有缺陷(阶段二 â‘¥ï¼‰** âœ…
新库首次执行时脚本在 `system_role_menu` ä¸ŠæŠ¥
`Duplicate entry '6352' for key 'system_role_menu.PRIMARY'` æ•´æ®µä¸­æ–­ï¼Œ**库停在半初始化状态**。
根因**不是本轮引入的**,是 0904 åŸºçº¿æ—¢æœ‰å†™æ³•:阶段二 â‘¥ å†™æˆ
```sql
DELETE FROM `system_role_menu`;
INSERT INTO `system_role_menu`
SELECT rm.* FROM `ruoyi-vue-pro`.`system_role_menu` rm
INNER JOIN `system_user_role` ur ON ur.`role_id` = rm.`role_id`;
```
源库 `system_user_role` çš„**主键只有 `id`**,所以同一 `(user_id, role_id)` å¯ä»¥å­˜åœ¨å¤šè¡Œ â€”—
实测该表 5 è¡Œ / åªæœ‰ 4 ä¸ªä¸åŒ `role_id`(`1:1, 1:2, 1:160, 1:1, 1:3`,admin çš„ `role_id=1` ç¡®å®žæœ‰ 2 è¡Œï¼š
id=1 å»ºäºŽ 2022-01-11、id=129 å»ºäºŽ 2026-09-02)。`INNER JOIN` æŠŠ `system_role_menu` çš„æ¯ä¸€è¡Œæ”¾å¤§æˆ N è¡Œï¼Œ
撞主键导致整脚本中断。
**已改为去重写法**(`IN` å­æŸ¥è¯¢å¤©ç„¶åŽ»é‡ï¼Œä¸Žè¡Œæ•°æ— å…³ï¼‰ï¼š
```sql
-- âš  ä¸èƒ½å†™æˆ INNER JOIN `system_user_role`:源库 system_user_role çš„主键只是 id,
--   åŒä¸€ (user_id, role_id) å¯ä»¥å­˜åœ¨å¤šè¡Œï¼ˆå®žæµ‹ admin çš„ role_id=1 å°±æœ‰ 2 è¡Œï¼‰ï¼Œ
--   JOIN ä¼šæŠŠ system_role_menu çš„æ¯ä¸€è¡Œæ”¾å¤§æˆ N è¡Œï¼Œæ’ž system_role_menu ä¸»é”®å¯¼è‡´æ•´è„šæœ¬ä¸­æ–­ã€‚
--   ç”¨ IN(子查询天然去重)表达「这些角色的菜单」,与行数无关。
INSERT INTO `system_role_menu`
SELECT rm.* FROM `ruoyi-vue-pro`.`system_role_menu` rm
WHERE rm.`role_id` IN (SELECT `role_id` FROM `system_user_role`);
```
改后重跑临时库 EXIT=0,且行数与源库**逐项相等**(`role_menu` 2349=2349、`menus` 1258=1258)。
**这类缺陷只有真在空库跑一遍才会暴露,纯语法检查抓不到。**
**(11)⚠ çŽ¯å¢ƒå‘ï¼šMCP è¿žçš„不是后端用的那个库**
本轮做到一半时,同一个查询先能返回 `ruoyi-vue-pro` + `mom-jhhg`,几分钟后突然返回空。
查 `SELECT @@hostname, @@port, DATABASE(), @@version` â†’
`6c8bf09fabf1` / 3306 / `product-inventory-management-rbhb` / 8.4.6,是**另一台沙盒实例**
(30+ ä¸ª `product-inventory-management-*` åº“、没有 `mom-jhhg`、其 `ruoyi-vue-pro` æœ‰ 410 å¼ è¡¨ä¸” **0 å¼  `qc_report_*`**)。
改走权威路径:`docker ps` â†’ å®¹å™¨ `mysql8`(`0.0.0.0:3306`)→ `docker exec mysql8 mysql ...` å¾—
`@@hostname` = **`2a8dea946ada`**、**416 å¼ è¡¨**、6 å¼  `qc_report_*`。
在这台真实实例上回读:`ruoyi-vue-pro.qc_report_instance` **14 åˆ— / `has_pdf_url`=0**
(说明 11.2(1)的 `DROP COLUMN` è½å¯¹äº†åº“),`mom-jhhg` ä» 15 åˆ—(**有意不动**),实例表 0 è¡Œã€‚
> ä¸Žè®°å¿†é‡Œã€ŒMCP å¸¸è¿žå¦ä¸€å°æ²™ç›’实例」一致:**本项目的建表/迁移核验不要信 MCP,走 `docker exec mysql8`**。
> å¦ï¼šç”¨ `docker exec mysql8 mysql -e "..."` å†™ä¸­æ–‡æ³¨é‡Š**必须加 `--default-character-set=utf8mb4`**,
> å¦åˆ™å­˜è¿›åŽ»æ˜¯ä¹±ç ï¼ˆæœ¬è½®å·²è¸©è¿‡ä¸€æ¬¡ï¼š`实例编号` å†™æˆ `实例编å·`,加了参数重执行后
> `column_comment` ä¸Ž `HEX()` å‡æ­£ç¡®ï¼‰ã€‚
### 11.3 åˆ»æ„çš„设计取舍(都不是随手写的)
| # | å–舍 | ç†ç”± |
|---|---|---|
| 1 | **`generate` ä¸åŠ  `@Transactional`** | æ•´æ®µåªæœ‰ä¸€æ¬¡ insert,没有多语句一致性需求;反而加了事务之后,重试路径上被 `catch` æŽ‰çš„ `DuplicateKeyException` æœ‰è¢«æ ‡è®°æˆ rollback-only çš„风险,会把「重试」变成「必定失败」 |
| 2 | **自动编号撞号重试 5 æ¬¡** | å¹¶å‘下两个请求会算出同一序号,靠 `uk_report_no` å”¯ä¸€ç´¢å¼•挡下来重算即可;连撞 5 æ¬¡å·²è¿œè¶…「同时出一份报告」的正常并发,再失败就如实报错(文案带上重试次数与「可显式指定编号」),不做无限重试 |
| 3 | **显式编号不重试** | æŒ‡å®šäº†ç¼–号就是每次同一个号,重试没有意义;先查重,撞了直接报「报告编号「X」已被占用,请换一个编号,或留空由系统按 QR+日期+流水 è‡ªåŠ¨ç”Ÿæˆã€ |
| 4 | **`regenerate` æŸ¥ç‰ˆæœ¬ä¸è¦æ±‚已发布** | å·²å‘布版本的内容改不了(`updateVersion` æ‹¦ç€ï¼‰ï¼Œä½†å®ƒå¯èƒ½åŽæ¥è¢« `disableVersion` åœç”¨ï¼›ä¸€ä»½**已经出过**的历史报告不该因此变成打不开。用 `getPublishedVersion` ä¼šæŠŠè¿™ä¸ªåœºæ™¯å¡æ­»ï¼Œæ‰€ä»¥æ–°å¢žäº† `getVersionByTemplateIdAndVersion` |
| 5 | **`regenerate` ä¸è¦†ç›– `data_snapshot`** | å†»ç»“的数据是历史报告的依据,重新生成动的只是**排版产物**。引擎升级后重渲可能得到不同 HTML,那正是「重新生成」该有的效果 |
| 6 | **快照存「判定后」的上下文**(`outcome.context().toScope()`) | è¿™æ‰æ˜¯äº§å‡ºè¿™ä»½ HTML çš„那份数据,含算好的 PASS/FAIL ä¸Žåˆæ ¼çŽ‡ã€‚å­˜åˆ¤å®š**前**的原始数据,事后就看不出当时是怎么判的 |
| 7 | **生效编号无条件写回 context å†æ¸²æŸ“** | çº¸é¢ä¸Šçš„æŠ¥å‘Šç¼–号与实例编号必须是同一个,不能出现「报告上印着一个号、系统里存着另一个号」 |
| 8 | **`preview` ä¸ç”Ÿæˆã€ä¸æ¶ˆè€—序号** | ç¼–号是出件时才确定的流水号,预览不该消耗它;预览只用入参里带过来的编号 |
| 9 | **`resolveVersion` è¦æ±‚模板 `ENABLE`** | ä¸Ž `createVersion` ä¿æŒä¸€è‡´ï¼šåœç”¨çš„æ¨¡æ¿ä¸å†äº§å‡ºæ–°æŠ¥å‘Šï¼ˆåŽ†å²æŠ¥å‘Šé‡æ–°ç”Ÿæˆä¸èµ°è¿™æ¡è·¯å¾„ï¼‰ |
| 9b | **`resolveVersion` çš„ `requirePublished` å¼€å…³ï¼šå‡ºä»¶è¦ã€é¢„览不要** | å‡ºä»¶ä¼šæŠŠç‰ˆæœ¬å·å†»ç»“进实例、而实例只记版本号不记内容,用草稿出件等于埋一颗「版本号对得上、内容已经变了」的雷;预览不落库、不冻结数据、不消耗编号,草稿会变在这里没有后果,而「保存草稿 â†’ ç‚¹é¢„览」正是设计器主流程(见 11.7 ç¼ºé™· 2) |
| 9c | **编号只增不减,删掉的号不回收** | `uk_report_no` å”¯ä¸€ç´¢å¼•不认 `deleted`,若按「可见的最大号 +1」算,删掉当天最大号后必然反复撞号(见 11.7 ç¼ºé™· 1)。因此取最大号时**把软删除的行也算进来**,代价是当天流水会跳号 |
| 10 | **`prepareContext` å…œ `report == null`** | è¯·æ±‚体里显式传 `"report": null` ä¼šæŠŠå®ƒç½®ç©ºï¼Œä¸å…œçš„话 `reportMap()` ä¼šåœ¨æ¸²æŸ“深处抛 NPE,用户只看到「系统异常」 |
| 11 | **校验 `inspectionItems` éžç©º** | æ²¡æœ‰æ£€éªŒé¡¹çš„「报告」没有意义,报出来比给一张空表好 |
| 12 | **本轮不往 `system_menu` æ’权限行** | å‰ç«¯è¿˜æ²¡æœ‰å®žä¾‹åˆ—表页,插一个指向不存在组件的页面菜单会把导航打坏。当前 `admin` æ˜¯ `super_admin`,超管直接放行,联调不受影响。**前端实例页落地时一并补菜单 + æŒ‰é’®æƒé™**,并按 `.claude/rules/sql-config-export-init.md` å½’入阶段二 |
### 11.4 æœ¬è½®æ–°å¢ž/修改文件清单
**新增(后端 `yudao-module-qcreport`)**
```
service/instance/{QcReportInstanceService,QcReportInstanceServiceImpl,GenerateResult,ReportNoGenerator}.java
engine/context/ReportContextCodec.java
dal/mysql/instance/QcReportInstanceMapper.java
controller/admin/instance/QcReportInstanceController.java
controller/admin/instance/vo/{GenerateReqVO,PageReqVO,RespVO,PreviewRespVO,GenerateRespVO,RegenerateRespVO}.java
```
**新增(测试)**
```
src/test/java/.../service/instance/ReportNoGeneratorTest.java            (7 æ¡)
src/test/java/.../engine/context/ReportContextCodecTest.java             (7 æ¡)
```
**修改(后端 `yudao-module-qcreport`)**
| æ–‡ä»¶ | æ”¹åЍ |
|---|---|
| `enums/ErrorCodeConstants.java` | +2 ä¸ªé”™è¯¯ç ï¼ˆè§ 11.2) |
| `dal/dataobject/instance/QcReportInstanceDO.java` | åˆ  `pdfFileUrl` å­—段;修正 `id` çš„错误注释 |
| `service/version/QcReportTemplateVersionService(+Impl)` | +`getVersionByTemplateIdAndVersion` |
| `service/instance/ReportNoGenerator.java` | åŠ  `Locale.ROOT`(`ofPattern` ä¸Ž `String.format` ä¸¤å¤„,见下) |
| `dal/mysql/instance/QcReportInstanceMapper.java` | **联调后修缺陷 1**:`selectLatestByReportNoPrefix` â†’ æ‰‹å†™ `@Select selectLatestReportNoByPrefix`,绕过逻辑删除、只取编号列(见 11.7) |
| `service/instance/QcReportInstanceServiceImpl.java` | **联调后修缺陷 2**:`resolveVersion` åŠ  `requirePublished` å¼€å…³ï¼Œ`preview` æ”¾è¡Œè‰ç¨¿ï¼›è¡¥ `versionDO == null â†’ TEMPLATE_VERSION_NOT_EXISTS`(见 11.7) |
**新增(文档 / SQL)**
| æ–‡ä»¶ | è¯´æ˜Ž |
|---|---|
| `docs/qc_report_instance_frontend_integration.md` | å‰ç«¯è”调方案(`.claude/rules/frontend-docs.md` å¼ºåˆ¶ï¼‰ï¼Œ**无任何前端代码片段** |
| `docs/sql/config_export_all_20260918.sql` | åˆå¹¶åˆå§‹åŒ–脚本新版(见 11.2(9)) |
| `docs/sql/qc_report_platform_ddl.sql` | åˆ  `pdf_file_url` åˆ—定义;`id` æ³¨é‡Šæ”¹ã€Œå®žä¾‹ç¼–号」 |
| `docs/project-business/` | å¢žé‡ï¼š`01-business-mindmap` / `05-quality-flow` / `08-module-relation` / `09-data-flow` / `10-business-object` äº”张图 + `data/{objects,modules,flows,business}.json`(模块 12→13、对象 46→48),已跑 `sync_index.js`。**已在浏览器实测**:起静态服务打开 `index.html`,5 å¼ æ”¹åЍ图 `mermaid.parse` å…¨éƒ¨é€šè¿‡ã€è´¨é‡æµç¨‹å›¾æ¸²æŸ“成 SVG ä¸”步骤表 7 è¡Œï¼ˆå«æ–°å¢žã€Œå‡ºä»¶ã€ï¼‰ã€æ¨¡å—卡片 13 å¼ å«ã€Œæ™ºèƒ½è´¨æ£€æŠ¥å‘Šã€ã€å¯¹è±¡é¡µå‡ºçŽ°ã€ŒæŠ¥å‘Šæ¨¡æ¿/报告实例」、无任何 mermaid é”™è¯¯æ¨ªå¹… |
**数据库**
| åº“ | åŠ¨ä½œ |
|---|---|
| `ruoyi-vue-pro`(容器 `mysql8` / `2a8dea946ada`) | `ALTER TABLE qc_report_instance DROP COLUMN pdf_file_url`(已执行、已回读验证 14 åˆ—) |
| `mom-jhhg` | **有意不动**(历史遗留库,无 profile æŒ‡å‘) |
| `qc_init_check_tmp` | ä¸´æ—¶åº“,用于实跑合并脚本,**验证完已 drop** |
> âš  æäº¤æé†’:`.gitignore:64` æœ‰ `/docs/`,`docs/` ä¸‹çš„æ–°æ–‡ä»¶**默认是未跟踪且被忽略的**,
> éœ€è¦ `git add -f`(既有的 `docs/sql/config_export_all_20260904.sql`、`docs/智能质检报告*.md` éƒ½æ˜¯è¿™ä¹ˆè¿›æ¥çš„)。
> `yudao-module-qcreport/` æ•´ä¸ªæ¨¡å—目录也还是未跟踪状态。
**一个顺手修掉的隐性 bug**:`ReportNoGenerator` åŽŸæ¥æ˜¯ `DateTimeFormatter.ofPattern("yyyyMMdd")` +
`String.format("%04d", seq)`,两处都跟随**默认 locale**:
默认 locale å¸¦åŽ†æ³•æ—¶ï¼ˆå¦‚æ³°åŽ†ï¼‰å¹´ä»½ä¼šå†™æˆ 2569,数字字形非拉丁时会写出阿拉伯-印度数字。
已锁 `Locale.ROOT`,并补了对应的回归用例。
### 11.5 â¬œ æœªå®Œæˆé¡¹ï¼ˆæŽ¥æ‰‹å…ˆçœ‹è¿™é‡Œï¼‰
**后端接口这部分没有遗留项了** â€”— ä¸¤ä¸ªç¼ºé™·å·²ä¿®å·²å¤éªŒï¼Œè§ Â§11.7/§11.8。剩下的都是「下一轮」的事:
| # | äº‹é¡¹ | çŠ¶æ€ | è¯´æ˜Ž |
|---|---|---|---|
| 1 | **入站 HTML Sanitization** | â¬œ æœ¬è½®**有意不做** | æœ¬è½®æ²¡æœ‰å¤–来 HTML å…¥ç«™ï¼šæ¨¡æ¿ Schema åªå­˜ç”»å¸ƒ JSON,出站 HTML ç”± `HtmlRenderer` ç”Ÿæˆä¸”已剥离 `on*` ä¸Ž `javascript:`/`vbscript:`/`data:` åè®®ï¼ˆæ‹¦ä¸‹çš„会进缺口清单)。等 AI/OCR é‚£æ¡è·¯ï¼ˆä¼šå¸¦è¿›ä»»æ„ HTML)开工时再做,两者不重叠 |
| 2 | **PDF / Playwright / `system_storage_attachment` å½’æ¡£** | â¬œ æœ¬è½®æœªå¼€å§‹ | ä¸‹ä¸€è½®ï¼ˆéœ€è”网下载 Chromium + é‡å¯åŽç«¯ï¼‰ã€‚**禁止用截图方式生成 PDF** |
| â€” | å‰ç«¯å®žä¾‹åˆ—表/预览/详情页 | â¬œ æœªå¼€å§‹ | åŽç«¯æŽ¥å£ç¨³å®šåŽå†åšã€‚**落地时要一并补 `qc-report:instance:{query,create,delete}` çš„菜单与按钮权限并归入合并脚本阶段二**,否则非超管角色调这些接口会 403(详见 11.3 #12 ä¸Žè”调方案文档) |
### 11.6 æ˜Žç¡®ä¸åšï¼ˆé¿å…èŒƒå›´è”“延)
- `MesQcReportApi` + å››ç§è´¨æ£€å•归一化 â€”— ä¸‹ä¸€è½®ã€‚
- å‰ç«¯å®žä¾‹åˆ—表/预览/详情页 â€”— åŽç«¯æŽ¥å£ç¨³å®šåŽå†åšã€‚
- ç¼ºå£æ¸…单落库(不加 `render_errors` åˆ—)—— å·²æŒ‰ç”¨æˆ·é€‰æ‹©ï¼šåªåœ¨å“åº”里返回。
- `qc_report_data_source` / `qc_report_rule` / `qc_report_render_record` ä¸‰å¼ æ—  Java å¯¹åº”的表 â€”— ç»´æŒçŽ°çŠ¶ã€‚
### 11.7 é¦–è½® HTTP è”调记录(2026-09-18)—— 27/27 é€šè¿‡ï¼Œé™„ 2 ä¸ªçœŸå®žç¼ºé™·
联调脚本在 `D:/qcl-tmp/`(**仓库外**,一次性验收工具,不提交):
`e2e.mjs` 27 é¡¹éªŒæ”¶ + `preview2.mjs` é¢„览行为矩阵。凭证用 `POST /admin-api/system/auth/login`
(local profile å…éªŒè¯ç ï¼Œ`admin/admin123`)直接取 token,不走前端 localStorage。
**27/27 PASS,其中这些是之前只有单测、没有运行时证据的**:
| éªŒæ”¶ç‚¹ | å®žæµ‹ç»“æžœ |
|---|---|
| æ•´é“¾æ‰“通 | `generate` â†’ `code=0`,编号 `QR20260918-0001`、`id=1`、`status=1`、`templateVersion=v1.0` |
| ç¼–号格式 | æ­£åˆ™ `^QR\d{8}-\d{4}$` å‘½ä¸­ |
| åˆ¤å®šçœŸçš„算出来了 | `snapshot` é‡Œ å¤–è§‚=FAIL / é•¿åº¦=PASS / ç¡¬åº¦=FAIL,`passRate=33.33%`,`total=3 pass=1 fail=2` |
| **与前端引擎零漂移** | ä¸Šè¿°ä¸‰é¡¹åˆ¤å®šä¸Ž `passRate` ä¸Žå‰ç«¯å†»ç»“ fixture **逐值相同** â€”— ã€Œä¸¤å¥—引擎会漂」的风险就此从「单测保证」升级为「真实数据实测」 |
| å¿«ç…§ç»“æž„ | `snapshot` ä¸Ž `context.json` åŒæž„ï¼›`get` **不含** `dataSnapshot` |
| regenerate å¤çŽ°æ€§ | å‰åŽ `renderHtml` **逐字相同**,且编号不变、库行数不变 |
| é‡å¤æ˜¾å¼ç¼–号 | `code=1070102001`,msg **带出具体编号** `QR20260918-0001` |
| æ— å·²å‘布版本 / è‰ç¨¿ç‰ˆæœ¬ / æ¨¡æ¿ä¸å­˜åœ¨ | åˆ†åˆ« `1070101007` / `1070101003` / `1070100000` |
| ç©º `inspectionItems` / ç¼º `templateId` | `1070103000` / `400` |
| `page` è¿‡æ»¤ï¼ˆtemplateId+businessType+businessId / ä¸åŒ¹é… / ç¼–号模糊) | å…¨éƒ¨ç¬¦åˆé¢„期 |
| åˆ é™¤ | `delete` æˆåŠŸï¼›åˆ é™¤åŽ `get` è¿”回 `code=0, data=null`(**不报错**);`regenerate` æŠ¥ `1070102000` |
**另外确认**:`data_snapshot` å­˜çš„æ˜¯**判定后**的上下文;模板 5 v1.0 é‚£æ¡ `scope:"item"` è§„则
(`actualValue >= lowerLimit AND actualValue <= upperLimit`)对没有上下限的「外观」判 FAIL â€”— è¿™æ­£æ˜¯å‰ç«¯å¼•擎的行为,不是后端算错。
#### ç¼ºé™· 1:软删除的报告会毒死当天的自动编号(严重)
**现象**:`e2e.mjs` å‡ºä»¶å¾—到 `QR20260918-0001`(id=1)后软删除它,下一次 `generate` ç›´æŽ¥å¤±è´¥ï¼š
`1070102001 è‡ªåŠ¨ç”ŸæˆæŠ¥å‘Šç¼–å·æ—¶è¿žç»­ 5 æ¬¡ä¸Žå·²æœ‰æŠ¥å‘Šå†²çªï¼Œè¯·ç¨åŽé‡è¯•;也可在请求里显式指定一个未被占用的报告编号`。
**根因**:`selectLatestByReportNoPrefix` ç”¨çš„æ˜¯æ¡ä»¶æž„造器,MyBatis-Plus è‡ªåŠ¨è¿½åŠ  `deleted = 0`,
但 `uk_report_no` å”¯ä¸€ç´¢å¼•**不认 `deleted`**。软删除后:
1. æ¡ä»¶æž„造器看不到当天最大号那行 â†’ `nextSequence` æ¯è½®éƒ½ç®—回同一个 `0001`;
2. æ’入每次都撞唯一索引 â†’ 5 æ¬¡é‡è¯•是 5 æ¬¡åŒä¸€ä¸ªç»“果,不是「运气差」;
3. åŽæžœæ˜¯**删过一份报告,这一整天就再也出不了件**,而且报错文案把责任推给「并发冲突」,把人往错方向引。
**修复**(`QcReportInstanceMapper`):改手写 `@Select` ç»•过逻辑删除,并只取编号列本身
(不必把 JSON å¿«ç…§æ•´åˆ—拽出来):
```sql
SELECT report_no FROM qc_report_instance
WHERE report_no LIKE CONCAT(#{prefix}, '%')
ORDER BY CHAR_LENGTH(report_no) DESC, report_no DESC LIMIT 1
```
**行为变化**:编号**只增不减,删掉的号不回收**。删掉 `…-0001` åŽä¸‹ä¸€ä»½æ˜¯ `…-0002`。已写进联调方案。
并发重试路径仍然保留(真正的并发抢号还得靠它),只是不再是「删除后必定触发」。
**库上的硬证据**(容器 `mysql8`,`ruoyi-vue-pro`):
```
qc_report_instance:  id=1  report_no=QR20260918-0001  deleted=1        â† è½¯åˆ é™¤åŽä»åœ¨ç‰©ç†è¡¨é‡Œ
uk_report_no:        Non_unique=0  Column_name=report_no               â† å”¯ä¸€ç´¢å¼•只有 report_no,不含 deleted
```
同一条前缀查询,两种写法结果不同 â€”— è¿™å°±æ˜¯ç¼ºé™·çš„全部机制:
```sql
-- æ–° SQL(把软删除算进来)→ è¿”回 QR20260918-0001 â†’ nextSequence å¾— 2 â†’ æ’入成功
SELECT report_no FROM qc_report_instance WHERE report_no LIKE CONCAT(@prefix,'%')
ORDER BY CHAR_LENGTH(report_no) DESC, report_no DESC LIMIT 1;
-- æ—§å†™æ³•等价物(条件构造器自动加 deleted=0)→ ä¸€è¡Œéƒ½æŸ¥ä¸åˆ° â†’ nextSequence å¾— 1 â†’ æ’žå”¯ä¸€ç´¢å¼•
SELECT report_no FROM qc_report_instance WHERE report_no LIKE CONCAT(@prefix,'%') AND deleted = 0
ORDER BY CHAR_LENGTH(report_no) DESC, report_no DESC LIMIT 1;
```
**这条修复没有任何单测覆盖**(模块无 H2/DB æµ‹è¯•基座,缺陷在读 SQL è€Œä¸åœ¨ Java åˆ†æ”¯é‡Œï¼‰ï¼Œ
所以上面这条 SQL æ˜¯ç›´æŽ¥æ‹¿çœŸåº“验的;修复后的行为要靠 Â§11.5 ç¬¬ 1 é¡¹çš„ HTTP å¤éªŒã€‚
#### ç¼ºé™· 2:`preview` è¦æ±‚版本已发布,把设计器主流程卡死(严重)
**现象**:模板 5 çš„ `v1.1`(草稿)与模板 3 çš„ `v1.0`(草稿,且是**库里唯一一份真实画布**,14657 å­—节)
调 `preview` éƒ½è¿”回 `1070101003 æ¨¡æ¿ç‰ˆæœ¬æœªå‘布,无法生成报告`。
**根因**:`preview()` å¤ç”¨äº†ä¸ºå‡ºä»¶å†™çš„ `resolveVersion()`,里面调的是 `getPublishedVersion()`。
但设计器的主流程正是「保存草稿 â†’ ç‚¹é¢„览」,保存出来的必然是草稿 â€”— ä¹Ÿå°±æ˜¯è¯´è¿™æ¡è·¯å¾„**不可能成功**。
方法上的 javadoc å†™ç€ã€Œç”¨å“ªä¸ªæ¨¡æ¿ç‰ˆæœ¬**出件**」,本身就暴露了它是被借用的。
**修复**:`resolveVersion(reqVO, boolean requirePublished)` åŠ å¼€å…³ï¼Œ
`generate` ä¼  `true`、`preview` ä¼  `false`(走 `getVersionByTemplateIdAndVersion`)。
`getPublishedVersion` æŸ¥ä¸åˆ°ä¼šæŠ›é”™ã€`getVersionByTemplateIdAndVersion` è¿”回 `null`,所以补了一句
`versionDO == null â†’ TEMPLATE_VERSION_NOT_EXISTS`。
**为什么不放松 `generate`**:出件会把版本号冻结进实例,而实例里**只记版本号、不记内容**,
用草稿出件等于给历史报告埋一颗「版本号对得上、内容已经变了」的雷;
预览不落库、不冻结数据、不消耗编号,草稿会变在这里没有任何后果。
**顺带记一笔**:库里唯一一份有真实画布的版本是**草稿**(模板 3 v1.0),且模板 3 çš„
`current_version` ä¸º `NULL` â€”— æ‰€ä»¥é¦–轮联调的渲染验证只能用模板 5 çš„**空画布**(560 å­—节、body ä¸ºç©ºï¼‰ã€‚
非平凡 HTML çš„æ¸²æŸ“验证被缺陷 2 æŒ¡ç€ï¼Œä¿®å®ŒåŽæ‰åšå¾—了。
#### è”调里两个「假缺陷」,别重踩
- **`500 ç³»ç»Ÿå¼‚常` ä¸‰æ¬¡ï¼Œå®žé™…是我自己的锅**:bash `for` å¾ªçŽ¯é‡Œå†…è”ä¸­æ–‡ï¼Œshell æŒ‰ GBK ç¼–码发出去,
  åŽç«¯æŠ¥ `tools.jackson.core.exc.StreamReadException: Invalid UTF-8 middle byte 0xe2`
  ï¼ˆè·¯å¾„直指 `ReportContext["inspectionItems"][0]["itemName"]`)。换成 node è„šæœ¬å‘ UTF-8 JSON å³å¥½ã€‚
  **教训**:带中文的请求体别用 bash å†…联。
- **`file://` æ‰“不开 `docs/project-business/index.html`**:Playwright ç¦æ­¢ `file://` å¯¼èˆªï¼Œ
  èµ·äº†ä¸ªä¸´æ—¶é™æ€æœåŠ¡å™¨æ‰éªŒæˆã€‚éªŒè¯æœ¬èº«æ˜¯å¿…è¦çš„ â€”— 5 ä¸ªæ”¹åŠ¨çš„ `.mmd` ç”¨ mermaid 11 çš„ `mermaid.parse()`
  é€ä¸ªè§£æžé€šè¿‡ã€ç‚¹è¿›å¯¼èˆªæ¸²æŸ“出 SVG、无报错横幅,13 ä¸ªæ¨¡å—卡片与「报告模板/报告实例」对象都在。
### 11.8 ä¿®å¤åŽå¤éªŒï¼ˆåŒæ—¥ï¼Œé‡å¯åŽç«¯åŽï¼‰â€”— 24/24 + 27/27 å…¨ç»¿
新增 `D:/qcl-tmp/real-canvas.mjs`(仓库外,一次性脚本)。它要解决一个难题:**库里唯一有真实画布的版本是草稿**
(模板 3 v1.0,14657 å­—节,`rules: []`,且模板 3 çš„ `current_version` ä¸º `NULL`),
而 `generate` æŒ‰è®¾è®¡æ‹’绝草稿。脚本的做法是**建一个临时模板 â†’ æŠŠæ¨¡æ¿ 3 çš„画布复制成它的 v1.0 â†’ å‘布 â†’ è·‘全链**,
跑完删干净。**不碰模板 1/3/4/5/6 çš„任何既有数据。**
| éªŒæ”¶ç‚¹ | å®žæµ‹ç»“æžœ |
|---|---|
| ç¼ºé™· 2 ä¿®å¤ï¼š`preview` æ”¾è¡Œè‰ç¨¿ | æ¨¡æ¿ 3 v1.0(真实画布)→ `code=0`,**HTML 6719 å­—节**;模板 5 v1.1(草稿)→ `code=0` |
| ç¼ºé™· 2 çš„边界:`generate` ä»æ‹’草稿 | æ¨¡æ¿ 3 v1.0 ä¸Žæ¨¡æ¿ 5 v1.1 â†’ **都仍是 `1_070_101_003`**(有意的不对称,没被顺手放松) |
| ç¼ºé™· 2 çš„连带:已停用版本也可预览 | æ¨¡æ¿ 1 ä¸ä¼  `version`(`currentVersion=v1.0`,status=2 åœç”¨ï¼‰â†’ `code=0`,468 å­—节(首轮这里是 `1_070_101_003`) |
| **真实画布的 preview äº§ç‰©æ˜¯çœŸæ¸²æŸ“** | 6719 å­—节里含 `判定结论:不合格`、`合格率:33.33%`、三个检验项各一行(`data-qc-repeat` å±•开了) |
| **真实画布的 preview â†” generate ä¸€è‡´** | `preview.html === get.renderHtml`,两侧都是 6719 å­—节,**逐字相同** |
| **真实画布的 regenerate å¤çŽ°æ€§** | é‡æ¸²å‰åŽ `renderHtml` **逐字相同**(6719 â†’ 6719),编号不变 |
| çœŸå®žç”»å¸ƒçš„判定落进快照 | `passRate=33.33%  total=3 pass=1 fail=1`,`items=[外观:(空) é•¿åº¦:PASS ç¡¬åº¦:FAIL]` |
| ç¼ºé™· 1 ä¿®å¤ï¼šè½¯åˆ é™¤åŽä»èƒ½å‡ºä»¶ | å‡º `…-0003` â†’ è½¯åˆ é™¤å®ƒ â†’ å†å‡º `code=0`,得 `…-0004`(**修复前这里必定是 `1_070_102_001`**) |
| ç¼ºé™· 1 çš„行为:编号只增不减 | `QR20260918-0003 â†’ QR20260918-0004`,严格递增,没回收 |
| ç¼ºé™· 1 çš„一致:显式占用被软删除的号仍被拒 | æŒ‡å®š `…-0003` â†’ `1070102001`,msg å¸¦å‡ºå…·ä½“编号 |
| å›žå½’:首轮 27 é¡¹å…¨è¿‡ | `e2e.mjs` é‡è·‘ **27/27 PASS**(含编号格式 / å¿«ç…§åˆ¤å®š / regenerate é€å­— / å„错误码 / page / delete) |
| å›žå½’:预览行为矩阵 | `preview2.mjs` çŸ©é˜µå…¨éƒ¨ç¬¦åˆé¢„期;模板 3 ä¸ä¼ ç‰ˆæœ¬ä» `1_070_101_007`(`currentVersion` ä¸º NULL,**预览也得显式传版本**) |
**验证过程中纠正的一个误判**(记下来免得后人重踩):
`real-canvas.mjs` é¦–è·‘ `1.3` åˆ¤ FAIL,断言「产物 HTML é‡Œåº”含 `PASS`/`FAIL` å­—样」——是**我的断言写错了**,不是缺陷。
模板 3 çš„画布显示的是 `resultText`(中文 `合格` / `不合格` / `待判定`),
`result`(`PASS`/`FAIL`)是数据层机器值。已把断言改成检查中文文案 + ç¼ºå£æ¸…单,改成 24/24。
**结论**:产物 HTML é‡Œæœä¸åˆ° `PASS` ä¸ä»£è¡¨æ²¡åˆ¤å®šï¼›è¿™æ¡å·²å†™è¿›è”调方案,免得前端也这么误判。
**顺带确认的一条引擎语义**:既无规格上下限、又没命中任何规则的检验项 â†’ `result` ä¸ºç©ºã€`resultText` ä¸º `待判定`,
**不计入 `passCount`/`failCount` ä½†è®¡å…¥ `total`**(所以会出现 `total=3 pass=1 fail=1`),
同时进缺口清单(「第 1 é¡¹ã€Œå¤–观」没有规格上下限也没有判定规则,无法判定」)。模板 3 çš„ `rules` æ˜¯ç©ºæ•°ç»„,
正好撞上这个分支 â€”— æ¨¡æ¿ 5 æœ‰é‚£æ¡ `scope:"item"` è§„则,所以同样的三项在模板 5 ä¸Šæ˜¯ FAIL/PASS/FAIL。
**收尾(已执行)**:临时模板 8/9、临时版本 8/9、以及本轮产生的 9 è¡ŒæŠ¥å‘Šå®žä¾‹å…¨éƒ¨ç¡¬åˆ é™¤ã€‚
回读核对:`qc_report_template` **5 è¡Œ**、`qc_report_template_version` **7 è¡Œ**、`qc_report_instance` **0 è¡Œ**、
`qc_report_render_record` **0 è¡Œ** â€”— ä¸Žå¼€å·¥å‰ä¸€è‡´ï¼Œæ¨¡æ¿ 1/3/4/5/6 åŠå…¶ `current_version` åŽŸæ ·æœªåŠ¨ã€‚
### 11.9 âš  å·²éªŒè¯ / æœªéªŒè¯ï¼Œå¿…须分开说
**已经拿到实测证据的**(这些可以说「验证过」)
| ç»“论 | è¯æ® |
|---|---|
| ç¼–译通过 | `mvn compile -pl yudao-module-qcreport -am -q` exit 0(两个缺陷修完后重跑仍 exit 0) |
| 43 é¡¹æµ‹è¯•全绿 | `mvn -pl yudao-module-qcreport test` â†’ `Tests run: 43, Failures: 0, Errors: 0`(对拍 3 + åˆ¤å®š 23 + Codec 7 + ç¼–号 7 + å­˜é‡å…¼å®¹ 3) |
| åˆ¤å®šå¹‚等、快照往返可复现 | `ReportContextCodecTest.roundTripReproducesHtml` æ–­è¨€ä¸¤æ¬¡ HTML é€å­—相同 |
| ç¼–号生成 locale æ— å…³ | `ReportNoGeneratorTest.independentOfDefaultLocale` åœ¨æ³°åކ + æ³°æ–‡æ•°å­— locale ä¸‹ä»å¾— `QR20260918-0007` |
| **出件接口整链可用(两轮)** | é¦–è½® `e2e.mjs` 27/27;修复后重跑仍 27/27;`real-canvas.mjs` 24/24。每项都走真实 HTTP æ•´é“¾ï¼ˆé‰´æƒ â†’ å‚数绑定 â†’ ç¼–排 â†’ è½åº“ â†’ åºåˆ—化) |
| **后端判定与前端引擎零漂移** | æ¨¡æ¿ 5 v1.0(有规则)→ FAIL/PASS/FAIL、`passRate=33.33%`,与前端 fixture é€å€¼ç›¸åŒ |
| **非平凡 HTML çš„æ¸²æŸ“与复现** | æ¨¡æ¿ 3 çš„真实画布(14657 å­—节)→ 6719 å­—节产物;`preview === get.renderHtml` ä¸” `regenerate` å‰åŽé€å­—相同 |
| **两个缺陷的修复已被复验** | è½¯åˆ é™¤åŽä»èƒ½å‡ºä»¶ä¸”编号递增;`preview` æ”¾è¡Œè‰ç¨¿è€Œ `generate` ä»æ‹’草稿(见 Â§11.8) |
| `pdf_file_url` å·²ä»Žè¿è¡Œåº“删除 | å®¹å™¨ `mysql8`(`@@hostname=2a8dea946ada`)回读 `information_schema`:`ruoyi-vue-pro.qc_report_instance` **14 åˆ— / `has_pdf_url`=0** |
| åˆå¹¶åˆå§‹åŒ–脚本在空库能一键跑通 | ä¸´æ—¶åº“ `qc_init_check_tmp` å®žè·‘整份 1.2 MB æ–‡ä»¶ â†’ EXIT=0,419 è¡¨ / 6 å¼  `qc_report_*` / 6 å­—典类型 / 21 å­—典数据 / 1258 èœå• / 2349 `system_role_menu`,**验完已 drop** |
| ä¸šåŠ¡å¯è§†åŒ–å¢žé‡åœ¨æµè§ˆå™¨é‡ŒçœŸçš„æ¸²æŸ“å‡ºæ¥äº† | 5 ä¸ªæ”¹åŠ¨çš„ `.mmd` åœ¨ mermaid 11 ä¸‹ `parse()` å…¨ ok;质量流程多了「出件」一行;13 ä¸ªæ¨¡å—卡片;对象页有「报告模板/报告实例」 |
**还没有任何实测证据的**(这些**不能说「可用」**)
- **并发自动编号的重试路径没压过**。逻辑在(撞 `uk_report_no` å°±é‡ç®—序号再试,最多 5 æ¬¡ï¼‰ï¼Œä½†æ²¡æœ‰å¹¶å‘测试。
  ç¼ºé™· 1 ä¿®å¤åŽï¼Œè¿™æ¡è·¯å¾„只会在**真正的同时抢号**时触发,正常使用不该看到它。
- **缺陷 1 çš„修复没有单测覆盖**。模块没有 H2/DB æµ‹è¯•基座,而缺陷在读 SQL ä¸åœ¨ Java åˆ†æ”¯é‡Œ
  ï¼ˆ`@Select` ç»•过逻辑删除)。它现在只有「真库上跑两种写法的对比 + HTTP å¤éªŒã€ä¸¤ç±»è¯æ®ï¼Œ
  ä¸è¦åŽ» `ReportNoGeneratorTest` é‡Œæ‰¾å®ƒï¼Œé‚£é‡Œæµ‹ä¸åˆ°ã€‚
- **前端实例列表/预览/详情页没有做,所以没有任何 UI éªŒè¯**。业务可视化的浏览器验证不能算作「实例页被验证过」。
- **前端还没接 `preview`**(`grep -r "instance/preview" mom-pro2-before/src` æ— å‘½ä¸­ï¼‰ï¼Œè®¾è®¡å™¨çš„预览按钮仍走老逻辑。
- **`qc-report:instance:{query,create,delete}` æƒé™ç æ²¡å…¥åº“**,非超管角色调这些接口会 403(前端页面落地时一并补)。
- **级联删除 / æ•°æ®æƒé™ / å¯¼å‡º** éƒ½ä¸åœ¨æœ¬è½®èŒƒå›´ï¼›`GENERATING`/`FAILED` ä¸¤ä¸ªçŠ¶æ€æš‚æœªä½¿ç”¨ï¼›ç¼ºå£æ¸…å•ä¸è½åº“ã€‚
- PDF é“¾è·¯ï¼ˆPlaywright + `system_storage_attachment` å½’档)**未开始**。
> **准确的说法**:出件接口(只做 HTML)**后端部分已完成并两轮实测通过**;
> ä½†å®ƒ**还没有任何前端页面接入**,权限码也还没进 `system_menu`。
> ä¸è¦æŠŠã€ŒHTTP è”调全绿」说成「功能已上线」,也不要把「业务图渲染正常」说成「实例页验证过」。
## åäºŒã€PDF å‡ºä»¶ï¼ˆPhase 3 ç¬¬ 5 æ­¥ï¼‰â€”— âœ… çœŸå®ž HTTP è”è°ƒ + è§†è§‰éªŒæ”¶å·²å®Œæˆï¼ˆ8 æ¬¡çœŸå®žå¯¼å‡ºã€7 æ¡æ¸²æŸ“记录、3 é¡µä¸­æ–‡ PDF å·²çœ‹è¿‡ï¼‰ï¼›ðŸŸ¡ é¡µçœ‰ä¿®å¤ä¸Žä¸‰æ¡é”™è¯¯åˆ†æ”¯ä»å¾…复验
> ç›®æ ‡ï¼šæŠŠå®žä¾‹é‡Œå·²å­˜çš„产物 HTML **由后端调 Chromium æ‰“印成 PDF**,归档进附件库,前端只拿下载地址。
> Â§30 æ˜Žç¡®ç¦æ­¢ã€Œæ¯æ¬¡è¯·æ±‚ launch æµè§ˆå™¨ â†’ ç”Ÿæˆ PDF â†’ close æµè§ˆå™¨ã€ï¼ŒÂ§41 è¦æ±‚留渲染记录。
### 12.1 äº¤ä»˜ç‰©
| å±‚ | æ–‡ä»¶ | èŒè´£ |
|---|---|---|
| é…ç½® | `config/QcReportPdfProperties.java` | `yudao.qcreport.pdf` å‰ç¼€ï¼š`enabled` / `channel` / `executable-path` / `concurrency`(2) / `timeout-ms`(30s) / `show-page-number` / `browser-args` |
| çº¯æŽ¥ç¼ | `service/render/PdfPrintOptions.java` | **纯函数**:纸张配置 â†’ æ‰“印几何 + é¡µè„š/页眉模板 + ä»…供打印的加固 CSS。不依赖 Spring、不依赖 Playwright,因此可单测 |
| æµè§ˆå™¨æ±  | `service/render/BrowserManager.java` | Playwright ç”Ÿå‘½å‘¨æœŸï¼š`volatile Semaphore` é™å¹¶å‘、`synchronized(browserLock)` ä¿è¯æµè§ˆå™¨å®žä¾‹å”¯ä¸€ã€å´©äº†èƒ½é‡å»ºå¹¶æŠ¥ `RESTARTED` |
| æ‰“印 | `service/render/PdfRenderService.java` | è½½å…¥ HTML â†’ `addStyleTag` æ³¨å…¥æ‰“印 CSS â†’ `page.pdf()`。返回 `PdfResult(content, pdfDurationMs, browserStatus)` |
| å‡ºä»¶ä¾§ | `service/instance/QcReportInstanceServiceImpl#exportPdf` + `PdfArchiveResult` + `QcReportInstancePdfRespVO` | æ‰“实例已存 HTML â†’ å­˜ blob â†’ æ¢é™„ä»¶ â†’ å†™æ¸²æŸ“记录 |
| ç«¯ç‚¹ | `POST /qc-report/instance/pdf?id=` | æƒé™ç  `qc-report:instance:export` |
| ç•™ç—• | `QcReportRenderRecordDO` / `Mapper` / è¡¨ `qc_report_render_record` | Â§41:`reportId` `templateId` `businessId` `renderStartTime/EndTime` `renderDuration` `pdfDuration` `browserStatus` `errorStack` |
| ä¾èµ– | `yudao-module-qcreport/pom.xml` | `com.microsoft.playwright:playwright` |
| é…ç½® | `application-local.yaml` + `application-test.yaml` | `yudao.qcreport.pdf` æ•´æ®µï¼ˆ`channel: chrome`,本机已装 Chrome) |
### 12.2 ä¸ƒä¸ªåˆ»æ„çš„设计选择
| é€‰æ‹© | ç†ç”± |
|---|---|
| **打的是实例里已存的 HTML,不重渲** | ã€Œè¯¦æƒ…页看到的」与「打出来的」必然出自同一份字节。若重渲,页面上是新排版、下载到的是旧文件,会静默不一致 |
| **几何一律从 `PageSizes.resolve` å–** | ä¸Ž `HtmlRenderer` å†™è¿› HTML çš„那行 `@page { size: â€¦; margin: â€¦; }` **同源、同一个数字格式化函数**(`Numbers.toString`),因此不存在「预览 210mm、PDF æ‰“ 210.5mm」这类漂移。用 `String.format` ä¼šå¼•å…¥ locale å·®å¼‚并输出 `210.0` |
| **`PRINT_CSS` ä¸è¿›æ¸²æŸ“引擎的 `BASE_CSS`** | äº§ç‰© HTML ä¸Žå‰ç«¯å†»ç»“样例是**逐字比对**的(`FrontendConformanceTest`),动 `BASE_CSS` ä¼šè®©å­˜é‡æŠ¥å‘Šçš„「重新生成」结果变样。所以加固样式(表头跨页重复、避免元素被切断、强制打印背景色)走 Playwright `addStyleTag` å•独注入 |
| **页眉模板显式给一个空 `<div>`** | `displayHeaderFooter=true` è€Œ `headerTemplate` ç•™ç©ºæ—¶ï¼ŒChromium ä¼šç”¨å®ƒè‡ªå·±çš„默认页眉:左上角打印日期、右上角文档标题。那两串没人要过,页眉里的时间还容易被误当成报告出具时间 |
| **归档三步顺序:存新 blob â†’ åˆ æ—§é™„ä»¶ â†’ ç»‘新附件** | `bindAttachments` åªæ¢å…³è”、**不删旧 blob**,不先删就会每导一次在磁盘上多留一份没人认领的 PDF;而「先删后存」又会在存档失败时把还好的旧 PDF ä¸€èµ·å¼„丢。所以新 blob å…ˆè½åœ°ï¼Œå†åˆ æ—§çš„,最后绑定 |
| **`regenerate` å³ä½œåºŸå·²å½’æ¡£ PDF** | äº§ç‰© HTML æ¢äº†ï¼Œå†ç•™ç€æ—§ PDF å°±æ˜¯ã€Œé¡µé¢æ˜¯æ–°æŽ’版、下载到的是旧文件」的静默不一致。宁可作废(随时能再导一次) |
| **失败也写渲染记录** | `catch` é‡Œå…ˆå†™ä¸€æ¡å¸¦å¼‚常栈的记录再把异常原样抛出,不吞 |
`exportPdf` ç”¨çš„æ˜¯ `getVersionByTemplateIdAndVersion` è€Œ**不要求版本当前仍已发布** â€”— ä¸Ž `regenerate` åŒä¸€å£å¾„:历史报告不该因为版本后来被停用就导不出来。
### 12.3 å®žæµ‹è¯æ®
#### 12.3.1 ç¼–译 + å•测层
| ç»“论 | è¯æ® |
|---|---|
| ç¼–译通过 | `mvn compile -pl yudao-module-qcreport -am -q` exit 0 |
| 51 é¡¹æµ‹è¯•全绿 | `mvn -pl yudao-module-qcreport test` â†’ **51 é¡¹ 0 å¤±è´¥**(43 â†’ 51,新增 `PdfPrintOptionsTest` **8 é¡¹**) |
| çº¸å¼ å‡ ä½•与 HTML åŒæº | `PdfPrintOptionsTest` æ–­è¨€ width/height/margin çš„ CSS æ–‡æœ¬ä¸Ž `PageSizes.resolve` + `Numbers.toString` çš„产物逐字一致(含 A3/A5/Letter ä¸Žæ¨ªç«–向) |
| é¡µè„šæ¨¡æ¿è‡ªå¸¦å†…è”æ ·å¼ | `PdfPrintOptionsTest` æ–­è¨€å« `pageNumber` / `totalPages` å ä½ç¬¦ä¸Žå†…联 `font-size`(Playwright çš„ header/footer æ¨¡æ¿ä¸è¿›æµè§ˆå™¨é»˜è®¤æ ·å¼ï¼Œä¸å†™å†…联就是 0 å·å­—看不见) |
| é¡µçœ‰æ¨¡æ¿éž null | `PdfPrintOptionsTest` æ–­è¨€ `HEADER_TEMPLATE` éžç©ºå­—符串 |
| `PRINT_CSS` æœªæ³„进渲染引擎 | `FrontendConformanceTest` ä» 3/3 ç»¿ï¼ˆä¸Žå‰ç«¯å†»ç»“产物逐字比对),说明 `BASE_CSS` æ²¡è¢«åŠ¨è¿‡ |
#### 12.3.2 çœŸå®ž HTTP å±‚(8 æ¬¡çœŸå®žå¯¼å‡ºè¯·æ±‚)
全部取自 `logs/yudao-server.log` çš„ `ApiAccessLogInterceptor`,交叉核对 `qc_report_render_record`、`system_storage_attachment`、`system_storage_blob` ä¸Žç£ç›˜ç›®å½•:
| # | å®žä¾‹ | å‘起时刻 | è€—æ—¶ | ç»“æžœ |
|---|---|---|---|---|
| 1 | 22 `QR20260918-0001` | 21:14:17 | **611 249 ms** | âŒ å¤±è´¥ï¼š`java.lang.RuntimeException: Failed to create driver â€¦ com.microsoft.playwright.impl.driver.Driver.createAndInstall` |
| 2 | 22 `QR20260918-0001` | 21:21:28 | **628 741 ms** | âœ… æˆåŠŸï¼ˆé¦–æ¬¡æˆåŠŸï¼›Playwright é¦–次使用要装 driver + Chromium,所以慢到 10 åˆ†é’Ÿï¼‰ |
| 3 | 23 `QR20260918-0002` | 21:32:13 | 921 ms | âœ… æˆåŠŸ |
| 4 | 23 `QR20260918-0002` | 21:32:14 | 904 ms | âœ… æˆåŠŸï¼ˆé‡å¤å¯¼å‡ºï¼‰ |
| 5 | 23 `QR20260918-0002` | 21:32:15 | 871 ms | âœ… æˆåŠŸï¼ˆé‡å¤å¯¼å‡ºï¼‰ |
| 6 | 24 `QR20260918-0003` | 21:32:16 | 868 ms | âœ… æˆåŠŸ |
| 7 | 25 `PG20260918001` | 21:34:20 | 1 316 ms | âœ… æˆåŠŸï¼ˆ3 é¡µä¸­æ–‡ PDF) |
| 8 | 26 `ZZ-DIRTY-HTML-TEST` | 21:40:22 | 47 ms | âŒ å¤±è´¥ï¼šå®žä¾‹ `render_html` ä¸ºç©º â†’ `REPORT_INSTANCE_HTML_MISSING`,**在 try ä¹‹å‰**抛,故未写渲染记录 |
- **整条链路走通了**:鉴权 â†’ å‚数绑定 â†’ å–实例 â†’ å–版本 â†’ Playwright å¯åЍ â†’ æ‰“印 â†’ å­˜ blob â†’ åˆ æ—§é™„ä»¶ â†’ ç»‘新附件 â†’ å†™æ¸²æŸ“记录。
- **冷热启动耗时差 3 ä¸ªæ•°é‡çº§**:首次成功 628 741 ms(装 driver + Chromium),此后稳定在 **47–1 316 ms**。部署时若把这段算进请求超时,前端那条 axios ä¸Žç½‘关超时都要放宽(前端本轮的 AI é‚£æ¡å·²å•独放宽到 180 ç§’,PDF è¿™æ¡åŒæ ·éœ€è¦ï¼‰ã€‚
- **渲染记录确实写入了**:`qc_report_render_record` 7 è¡Œã€‚失败那次(#1)留了完整异常栈;`browser_status` å®žæµ‹è§‚测到 `RESTARTED`(#2,浏览器实例是重建出来的)与 `READY`(#3–#7)。#8 æ— è®°å½•是**设计如此**(校验在 try ä¹‹å¤–)。
- **渲染记录的排查价值被真实验证**:`Drivers.createAndInstall` è¿™æ¡æ ˆå°±æ˜¯é æ¸²æŸ“记录表拿到的,否则只有一行 500 æ—¥å¿—。
#### 12.3.3 è§†è§‰å±‚(3 é¡µä¸­æ–‡ PDF å·²é€é¡µçœ‹è¿‡ï¼‰
实例 25(`PG20260918001`,模板 11)是专门造的视觉 fixture:60 è¡Œè¡¨æ ¼ + å†…嵌红绿渐变图,导出后存为 `D:/qcl-tmp/verify-60.pdf`(87 842 å­—节),逐页截图 `pdf-60-p1.png` / `pdf-60-p2.png` / `pdf-60-p3-full.png`。
| ç»“论 | è¯æ® |
|---|---|
| ä¸­æ–‡æ­£å¸¸ | æ ‡é¢˜ã€Œæ¥æ–™æ£€éªŒæŠ¥å‘Šï¼ˆ60项·跨页验收)」与表内中文均为矢量可读文本,未出现方块/乱码(本机 Chrome + å¾®è½¯é›…黑) |
| èƒŒæ™¯è‰²/图片打出来了 | å†…嵌渐变图在 PDF é‡Œå¯è§ â†’ `print-color-adjust: exact` ç”Ÿæ•ˆ |
| è·¨é¡µä¸Žé¡µè„š | 3 é¡µï¼›è¡¨æ ¼ä»Žç¬¬ 2 é¡µæº¢åˆ°ç¬¬ 3 é¡µï¼›é¡µè„šé¡µç æ­£å¸¸å‡ºçް |
| âš  **长表会把首页顶空** | `PRINT_CSS` é‡Œ `table { break-inside: avoid }` ä½œç”¨äºŽæ•´å¼ è¡¨ï¼Œæµè§ˆå™¨ä¸ºäº†ã€Œä¸åˆ‡æ–­è¡¨æ ¼ã€æŠŠ 60 è¡Œè¡¨æ•´ä½“推到第 2 é¡µ â†’ **第 1 é¡µåªæœ‰æŠ¥å‘Šå¤´å’Œä¸€å¼ å›¾ï¼Œä¸‹é¢å¤§ç‰‡ç©ºç™½**。这是已观测到的排版缺陷(不是报错),长期表格报告需要把 `table` ä»Ž `break-inside: avoid` åå•里摘掉,只保留 `tr, td, th, img` |
| âš  **页眉修复没赶上这次导出** | ç¬¬ 1 é¡µé¡µçœ‰ä»æ˜¯ Chromium é»˜è®¤çš„「`2026/9/18 21:34` + æ–‡æ¡£æ ‡é¢˜ã€ã€‚`PdfPrintOptions` çš„ `HEADER_TEMPLATE = "<div></div>"` å†™äºŽ **21:36**,而最后一次导出是 **21:34:21** â€”— å³ä¿®å¤**尚未被验证过**(见 12.4) |
#### 12.3.4 å½’档幂等性(实例 23 è¿žå¯¼ 3 æ¬¡ï¼‰
| ç»“论 | è¯æ® |
|---|---|
| åº“里不累积生效附件 | `system_storage_attachment` ä¸­ `record_id=23` æœ‰ 3 è¡Œï¼Œä½†**前两行与第 3 è¡Œå…¨éƒ¨ `deleted=1`**(逻辑删除)→ ä»»ä¸€æ—¶åˆ»ç”Ÿæ•ˆçš„只有 1 æ¡ |
| æ—§ blob è¢«çœŸåˆ ï¼ˆä¸æ˜¯ç´¯ç§¯ï¼‰ | `system_storage_blob` 6 è¡Œå…¨ä¸º `deleted=1`;磁盘目录 `D:/uploads/2026/0918/` **为空** â†’ ã€Œå­˜æ–° blob â†’ åˆ æ—§é™„件(级联删旧 blob ä¸Žç£ç›˜æ–‡ä»¶ï¼‰â†’ ç»‘新附件」三步确实把上一份 PDF æ¸…掉了 |
| é‡å¤å¯¼å‡ºçš„语义被实测确认 | ä¸Ž `docs/qc_report_instance_frontend_integration.md` çš„表述一致:**不会越导越多、也不会留孤儿文件** |
| æ¸…理彻底 | å®žä¾‹ 22–26、模板 5/11 å‡å·²è½¯åˆ ï¼Œç£ç›˜æ— æ®‹ç•™ |
> **一度被我误判**:只按行数看会以为「实例 23 ç•™ä¸‹ 3 æ¡é™„ä»¶ + 3 ä¸ª blob = ç´¯ç§¯ bug」。实际是漏看了 `deleted` ä½ â€”— yudao çš„ `deleteByIds` æ˜¯é€»è¾‘删除,行还在、但已失效,物理文件已从磁盘删除。**查 yudao çš„任何删改结果都必须显式看 `deleted`**,否则会把软删误读成残留。
### 12.4 âš  å·²éªŒè¯ / æœªéªŒè¯ï¼ˆæœ¬èŠ‚çš„é‡ç‚¹ï¼‰
**已验证**(有实测证据,可以说「可用」):
- æ•´æ¡ HTTP é“¾è·¯ï¼š8 æ¬¡çœŸå®žè¯·æ±‚、7 æ¡æ¸²æŸ“记录,成功/失败两条路径都走过(12.3.2)。
- æµè§ˆå™¨å¯åŠ¨å¤±è´¥çš„**真实报错与留痕**(#1,611 249 ms,`Drivers.createAndInstall` æ ˆè¿›äº†æ¸²æŸ“记录表)。
- ç©º `render_html` çš„æ‹’绝路径(#8,47 ms)。
- ç¨³å®šæ€æ€§èƒ½ï¼š47–1 316 ms;冷启动 628 741 ms。
- `.doc` ä¹‹å¤–çš„**中文渲染、背景色/图片打印、跨页、页脚页码**(12.3.3)。
- å½’档三步顺序的幂等性(12.3.4)。
**未验证**(**不能说「可用」**):
- **`HEADER_TEMPLATE = "<div></div>"` ä¿®å¤å°šæœªå¤éªŒã€‚** ä»£ç å·²ç¼–译、单测只断言「非空」;最后一次导出(21:34:21)的 PDF ç¬¬ 1 é¡µ**仍然带着 Chromium é»˜è®¤é¡µçœ‰**,而修复写于 21:36 â€”— æ²¡æœ‰ä¸€æ¬¡å¯¼å‡ºæ˜¯åœ¨ä¿®å¤ä¹‹åŽè·‘的。**这是本轮最该先补的一条**:重启后端导一次 PDF,看第 1 é¡µå·¦ä¸Šè§’是否还有日期。
- **`yudao.qcreport.pdf.enabled=false â†’ RENDER_PDF_DISABLED(1_070_103_008)` æœªå®žæµ‹**(需临时改配置 + é‡å¯ï¼‰ã€‚
- **打印失败 / å­˜æ¡£å¤±è´¥ä¸¤æ¡é”™è¯¯åˆ†æ”¯æœªå®žæµ‹**(`1_070_103_005/006`)。报文带了实际原因,但没人跑过。
- **并发闸(`concurrency=2`)与打印超时(30s)未压过**。`BrowserManager` æœ‰ Semaphore,但没做并发测试;排队超 30 ç§’çš„ `1_070_103_007` ä»Žæœªè§¦å‘过。
- **`regenerate` ä½œåºŸå·²å½’æ¡£ PDF** æœªå®žæµ‹ï¼ˆ`deleteAttachmentsByRecord` çš„调用位置已核对;`deleteInstance` é‚£æ¡æ¸…理走通并留了痕)。
- **长表的首页留白**(12.3.3)已观测、未修。
> **准确的说法**:PDF å‡ºä»¶**代码、单测、真实 HTTP é“¾è·¯ä¸Žè§†è§‰ç»“果都已验过**;剩下的是**页眉修复复验**与三条未触发的错误分支。
> ä¸è¦æŠŠã€Œ12.3.4 çš„三步顺序验证过」推广成「所有并发/超时路径都验过」。
### 12.5 ä¸‹ä¸€æ­¥
1. **复验页眉修复**(最省事的一条):重启后端 â†’ å¯¼ä¸€æ¬¡ PDF â†’ çœ‹ç¬¬ 1 é¡µå·¦ä¸Šè§’是否还有 `2026/9/18 â€¦` ä¸Žæ–‡æ¡£æ ‡é¢˜ã€‚
2. **修长表首页留白**:把 `PdfPrintOptions.PRINT_CSS` çš„ `table` ä»Ž `break-inside: avoid` åå•里摘掉(保留 `tr, td, th, img`),再导一次 60 è¡Œ fixture ç¡®è®¤ç¬¬ 1 é¡µä¸å†ç©ºã€‚**注意 `PdfPrintOptionsTest` ä¸Žå‰ç«¯å†»ç»“产物都会受影响,改前先确认 `PRINT_CSS` ä¸è¿› `BASE_CSS` è¿™æ¡è¾¹ç•Œæ²¡è¢«ç¢°ã€‚**
3. **补三条未触发的错误分支**:`pdf.enabled: false` â†’ `1_070_103_008`;并发/超时(`1_070_103_007`)加压;存档失败(`1_070_103_006`)。
4. **`regenerate` ä½œåºŸå·²å½’æ¡£ PDF** å®žæµ‹ä¸€æ¬¡ã€‚
5. **部署注意项写进交付说明**:前端那条 axios ä¸Žç½‘关超时都要 â‰¥ å†·å¯åŠ¨é‚£æ¬¡ï¼ˆ628 741 ms + ä½™é‡ï¼‰ï¼Œå¦åˆ™é¦–次导出必被前端掐断。
## åä¸‰ã€AI å¯¼å…¥ï¼ˆPhase 4 Â· è‰ç¨¿æ€ï¼‰â€”— âœ… çœŸå®ž HTTP è”è°ƒ **54/54** + è®¾è®¡å™¨ã€Œä¿å­˜â†’刷新」往返验收通过;顺带复验 PDF é¡µçœ‰ä¿®å¤
> ç›®æ ‡ï¼šä¸Šä¼ ä¸€ä»½å·²æœ‰çš„æ£€éªŒæŠ¥å‘Šæ–‡ä»¶ï¼ˆæ‰«æä»¶/照片、电子版 PDF、Word/Excel)→ AI è¯»æ‡‚ â†’ äº§å‡º**模板草稿** â†’
> äººå·¥åˆ°è®¾è®¡å™¨ç¡®è®¤åŽæ‰ä¿å­˜ä¸ºæ­£å¼ç‰ˆæœ¬ã€‚**这是用户明确要求补上的能力。**
### 13.1 äº¤ä»˜ç‰©
| å±‚ | æ–‡ä»¶ | èŒè´£ |
|---|---|---|
| **AI æ¨¡å—收口**(跨模块) | `yudao-module-ai` çš„ `AiAutoConfiguration` / `YudaoAiProperties` / `AiModelFactory(+Impl)` / `AiModelServiceImpl` | åŠ  `timeout`(默认 60s);把 `AiModelDO.temperature` / `maxTokens` **真正读进去**(此前声明了但全项目无一处读取);**cacheKey æŠŠæ–°å‚数一起算进 key**,否则改了 DB é‡Œçš„ temperature ä¼šé™é»˜è¿”回旧模型 |
| è¾“入适配器 | `service/aiimport/document/`:`QcReportImportAdapter` / `QcReportDocumentExtract` / `QcReportDocumentExtractService` / `ImageImportAdapter` / `PdfImportAdapter` / `OfficeImportAdapter` | ä¸‰ç±»è¾“入统一产出「文本通道 æˆ– å›¾ç‰‡é€šé“」的中间结构。`@Order` å›ºå®š **Image â†’ Pdf â†’ Office**,消除「pdf åŒæ—¶è¢« Pdf ä¸Ž Office åŒ¹é…ã€è¿™ç±»æ­§ä¹‰ |
| çº¯å‡½æ•°å±‚ | `service/aiimport/llm/`:`QcReportTemplatePromptBuilder` / `QcReportAiDraftParser` / `QcReportAiDraftNormalizer` / `QcReportLlmCallPlanner` / `QcReportLlmCall` | æ‹¼ prompt、切片解析、归一过滤、**调用计划抽成纯函数** |
| å¥‘约 | `controller/admin/aiimport/` + `vo/`(`QcReportAiDraftReqVO` / `QcReportAiDraftRespVO` / `QcReportComponentSpecVO`) | `POST /qc-report/ai-import/draft`,权限码 `qc-report:template:ai-import` |
| ç¼–排 | `service/aiimport/QcReportAiImportService(+Impl)` | æ ¡éªŒ â†’ è¯» blob å­—节 â†’ é€æ–‡ä»¶èµ°é€‚配器 â†’ æ‰§è¡Œè°ƒç”¨è®¡åˆ’ â†’ è§£æžå½’一 â†’ åˆå¹¶åŽ»é‡ â†’ è¿”回。**全程不落库** |
| é…ç½® | `config/QcReportAiImportProperties.java` + ä¸¤ä¸ª profile | `enabled` / `maxFiles`(3) / `maxPagesPerFile`(5) / `maxPagesPerRequest`(8) / `maxFileSizeMb`(20) / `maxComponents`(200) / `pdfTextPageThreshold`(30) / `renderDpi`(200) / `maxXlsxRows`(300) |
| é”™è¯¯ç  | `enums/ErrorCodeConstants.java` æ–°å¢ž `1_070_104_000`~`014` | 15 æ¡ï¼Œæ¯æ¡æŒ‰ `error-message-precision.md` ç»™ã€Œç»´åº¦ + åŒæ–¹å®žé™…值 + å¯æ‰§è¡ŒåŠ¨ä½œã€ |
| æºæ–‡ä»¶å½’属 | `yudao-module-system` çš„ `StorageRecordTypeEnum` åŠ  `QC_REPORT_TEMPLATE` | ç±»æ³¨é‡Šå†™ç€ã€ŒæŒ‰éœ€æ‰©å±•」,是官方留的扩展点;`getByType` å¯¹æœªçŸ¥ç±»åž‹æŠ› `IllegalArgumentException`,不加就是 500 |
| è¿žå¸¦ | `QcReportTemplateServiceImpl.deleteTemplate` | åŠ  `deleteAttachmentsByRecord(QC_REPORT_TEMPLATE, id)`,否则模板删除后附件与磁盘文件永久孤儿 |
| **前端装配器**(本轮最大缺口) | `mom-pro2-before/src/components/quality/core/assembler.ts` | **此前语义层是「只写的」**:只能从画布收集出来,不能再装回去。这个文件是 `collectSchema` çš„纯函数逆运算 |
| å‰ç«¯ç§¯æœ¨æ¸…单 | `.../core/catalog.ts` | æ³¨å†Œè¡¨ â†’ å–‚ç»™ LLM çš„æ´¾ç”Ÿè§†å›¾ï¼ˆå‰¥æŽ‰ icon/buildContent/validate),纯函数 |
| å‰ç«¯æŽ¥å…¥ | `api/mes/qc/report/ai/index.ts` / `designer/modules/ai-import-modal.vue` / `designer/index.vue` / `use-designer.ts` | API å®šä¹‰ã€å¼¹çª—、顶栏按钮、`loadCanvasFromDraft` |
| è”调方案 | `docs/qc_report_ai_import_frontend_integration.md` | æŒ‰ `frontend-docs.md` å¼ºåˆ¶è¦æ±‚产出,不含任何前端代码片段 |
### 13.2 å…«å¤„刻意的取舍
| å–舍 | ç†ç”± |
|---|---|
| **积木清单由前端随请求传,后端不硬编码** | ç»„件注册表的唯一真相来源在前端。后端抄一份就是**第二个真相来源**,且是最坏的那种:前端加组件/改必填字段时前端立刻生效、后端副本静默过期,模型随即会编出注册表里根本不存在的 `type`。项目已在 `ReportTemplateSchema.QualityComponentNode` æ³¨é‡Šé‡Œæ˜Žç¡®åå¯¹è¿™ä»¶äº‹ |
| **边界没有被削弱** | æ¸…单进 prompt åŽçš„唯一出口是「被模型抄成 JSON å­—符串」。真正把关的是**前端装配器对着活注册表查 `getQualityComponent`**:未注册的 `type` ä¼šè¢«è·³è¿‡ï¼Œ**永远产不出任意 HTML**。就算有人伪造含 `type:'CustomHtml'` çš„æ¸…单,也只会被 skip |
| **POI ä¸ç”¨ Tika** | è¡¨æ ¼çš„行列结构是核心信息,Tika ä¼šæ‹å¹³æˆçº¯æ–‡æœ¬ï¼›ä¸” `tika-parsers-standard-package` ä¼šæ‹–进几百 MB è§£æžå™¨ï¼Œqcreport æ²¡å¿…要背 |
| **同步返回,不做异步任务 + è¿›åº¦è½®è¯¢ + ä»»åŠ¡è¡¨** | å·²å’Œç”¨æˆ·æ•²å®šçš„æ–¹æ¡ˆã€‚代价是接口最长挂 180 ç§’,所以前端那条 axios å•独放宽超时 |
| **错误用真正的 `ServiceException` + åˆ†é”™è¯¯ç ** | ERP / CRM å…ˆä¾‹æŠŠé”™è¯¯å¡žè¿› `respVO.rawText` ä¸” HTTP 200,让前端靠「rawText æœ‰æ²¡æœ‰å€¼ã€å—…探失败(`crm_sale_quotation_ocr_integration.md` é‚£å¼ è¡¨å°±æ˜¯è¿™ç§çº¦å®šçš„化石)。本方案刻意偏离 |
| **多页逐页识别后按 `(type, props)` åŽ»é‡åˆå¹¶** | é¡µçœ‰ã€æ ‡é¢˜ã€è¡¨å¤´åœ¨å¤šé¡µé‡å¤å‡ºçŽ°æ—¶åªç•™ç¬¬ä¸€ä»½ã€‚ä¸Šé™ 5 é¡µ/文件、8 é¡µ/请求 |
| **绝对不复制 ERP/CRM çš„ `tempFile.delete()` ç¼ºé™·** | `ErpPurchaseInvoiceAiServiceImpl` ä¸Ž `CrmSaleQuotationAiServiceImpl` åœ¨ `finally` é‡Œåˆ çš„æ˜¯ `getPublicFile` è¿”回的**真实存储 blob æ–‡ä»¶æœ¬èº«**,即删用户的真实上传数据。本轮不碰它们,但也绝不复制。建议单独开修复项(三处同构) |
| **完全解析不出时直接抛错,响应里不放 `rawText`** | å¡žä¸€ä¸ªæ’为 null çš„字段只会让后来人以为「解析失败时前端能拿到模型原文」。模型原文写进了服务端日志 |
### 13.3 å®žæµ‹è¯æ®
| ç»“论 | è¯æ® |
|---|---|
| **编译通过** | `mvn compile -pl yudao-module-qcreport -am -q` exit 0 |
| **93 é¡¹æµ‹è¯•全绿** | `mvn -pl yudao-module-qcreport test` â†’ **93 é¡¹ 0 å¤±è´¥**(51 â†’ 93,本轮新增 **42 é¡¹**:适配器 13 + å½’一 11 + è§£æž 9 + è°ƒç”¨è®¡åˆ’ 3 + æç¤ºè¯ 6),逐条来自 `target/surefire-reports/*.txt` |
| **装配器与 `collectSchema` äº’为逆运算(机器证明)** | `.qc-conformance/assemble-ts.ts`(`npx tsx` ç›´è·‘)**9 ç»„、约 60 æ¡æ–­è¨€å…¨è¿‡**:12 ä¸ªç»„件全部装配成功;每个顶层节点都带 `data-quality-type` ä¸”**顶层不含 `data-qc-repeat`/`repeat-row`**;**`attributesToProps âˆ˜ assemble === resolveQualityProps`(12/12 å¾€è¿”无损)**;每个字段的 `data-qc-<key>` **确实写进了画布**(防止靠两边同时取默认值而假绿);boolean `false/true` â†” `'false'/'true'`;`Divider.thickness` æ•°å­— 3 â†” `'3'` â†” æ•°å­— 3;enum æ•°å­—值收敛成字符串 `'3'`(与校验、`buildContent` çš„ `String()`/`Number()` æ¯”较口径一致);`CustomHtml` + ç©º type è¿› `skipped` ä¸” **`alert(1)` æ²¡æ¸—进画布数据**;结构化属性回落默认值并各产一条 problem;必填缺失(`itemsPath: ''`)产 problem、非必填(`title: ''`)不产;空/undefined è¾“入仍返回非空 `pages`;清单 12 é¡¹ã€åªå« `category/fields/label/type` å››ä¸ª key、**无 `buildContent`/`icon`/`validate`/`defaults` æ³„漏** |
| **前端零新增类型错误** | `vue-tsc --noEmit`(带 `NODE_OPTIONS=--max-old-space-size=8192`):改前 **316** â†’ æ”¹åŽ **316**,typecheck æ—¥å¿—里 `catalog.ts` / `assembler.ts` / `components/quality` **零命中** |
| **设计器 UI æŽ¥å…¥ + å…¨é“¾è·¯å¾€è¿”** | é‡å¯åŽå·²èµ°å®Œ**完整链路**(不再只到后端边界):见 13.5 â€”— 11 å¼ æˆªå›¾ `ai-02-designer.png` ï½ž `ai-11-published.png`,含刷新后**语义签名逐字一致**与发布成功 |
| **AI æ¨¡å—超时收口未破坏既有调用** | åªæ”¹äº† 1 ä¸ªè°ƒç”¨ç‚¹ï¼ˆ`AiModelServiceImpl:101`,手上正有 `AiModelDO`);ERP/CRM/MES ä¸‰å¤„ AI è°ƒç”¨èµ°åŒä¸€å·¥åŽ‚ï¼Œä¸€èµ·èŽ·å¾—è¶…æ—¶ä¸Ž `maxTokens` |
| **业务可视化的三份 mmd å¢žé‡è¯­æ³•有效** | æœ¬æœºæ—  `mmdc`、前端 `node_modules` é‡Œä¹Ÿæ²¡æœ‰ mermaid,故用无头 Chromium è½½å…¥ **mermaid 11(与 `index.html` åŒä¸€ä¸ª jsDelivr CDN ç‰ˆæœ¬ï¼‰** å¯¹æ”¹åŠ¨åŽçš„ä¸‰ä»½æ–‡ä»¶è·‘ `mermaid.parse()`:`05-quality-flow` / `09-data-flow` / `11-ai-business-flow` **全部 `ok: true`**。这一步补上了此前只做「编辑 + `sync_index.js` é•œåƒã€è€Œæ²¡åšè¯­æ³•校验的缺口 |
**验证过程中纠正的四处误判**(记下来免得后人重踩):
1. **`草稿 0 / èŠ‚ç‚¹ 0`**:`listQualityComponents()` è¿”回 0 â€”— æ³¨å†Œè¡¨**只有显式调用 `registerAllQualityComponents()` æ‰æœ‰å†…容**(设计器在 `init` æ—¶åšï¼‰ï¼Œå¯¼å…¥ barrel ä¸ä¼šæ³¨å†Œä»»ä½•东西。两处修复:给 `buildQualityComponentCatalog()` åŠ ç©ºæ³¨å†Œè¡¨**直接抛错**的守卫(我自己的测试脚本就踩了这个坑,说明这个场景是真实的),并在对拍脚本里补上注册调用。
2. **`number 3 è¿˜åŽŸä¸ºæ•°å­— 3` æŠ¥ `string 3`**:我误以为 `Heading.level` æ˜¯æ•°å­—字段。实际它是 `type: 'enum'` ä¸”**选项值是数字** â€”— æ‰€ä»¥å¾€è¿”成字符串 `'3'` æ˜¯**正确的**(校验用 `String()` æ¯”对、`buildContent` ç”¨ `Number()`)。**装配器是对的,我的测试是错的。** å·²æŠŠæ•°å­—往返改用 `Divider.thickness`(确认是真正的 `type: 'number'`),enum æ”¹ä¸ºæ–­è¨€ `String(back.level) === '3'`。
3. **`坏元素不影响其余:4 é¡¹ä¸­è£…è¿› 3 é¡¹` å®žé™…是 2**:我数错了。`QualityTable` / `CustomHtml` / `Heading` / `{type:''}` å››é¡¹é‡Œä¸¤é¡¹è¢« skip,所以装进 **2** é¡¹ â€”— `skipped.length === 2` æ—©å·²ç¡®è®¤è¿™ä¸€ç‚¹ã€‚顺带修掉了一处越界取值导致的 `TypeError`。
4. **`必填缺失产 problem` å¾—到 `[]`**:我用了 `QualityTable.props.title`,它**不是必填**。改用 `itemsPath`(唯一必填字段),并补了一条「非必填留空不产 problem」的对照断言。
### 13.4 çœŸå®ž HTTP è”è°ƒ â€”— âœ… **54/54 å…¨ç»¿**(后端重启后首跑)
脚本 `D:/qcl-tmp/ai-import-e2e.mjs`(风格照既有 `real-canvas.mjs`/`pdf-e2e.mjs`:登录取 token â†’ multipart ä¸Šä¼ æ‹¿ `blobId` â†’ `bind` å½’属到模板 â†’ `POST /qc-report/ai-import/draft` â†’ æ–­è¨€ï¼‰ã€‚
**两轮**:首轮 46/47,唯一那条 FAIL æ˜¯**我自己把断言写错了**(把「catalog ä¸ºç©ºã€å½“成 008 å¯è¾¾è·¯å¾„,实际被 VO ä¸Šçš„ `@NotEmpty` å…ˆæ‹¦æˆ HTTP 400,属框架校验,合理);改写成两条真正可达的 008 è·¯å¾„(41 é¡¹è¶…上限、缺 `type`)后**第二轮 54/54**。
| ç”¨ä¾‹ | å…³é”®æ–­è¨€ | å®žæµ‹ |
|---|---|---|
| ç”µå­ç‰ˆ PDF | `code=0`、组件非空、`type` âˆˆ 12 å†…ç½® | âœ… |
| æ‰«æç‰ˆ PDF(3 é¡µï¼‰ | åŒä¸Š + **多页去重生效** | âœ…(见下) |
| å•页扫描图片 | åŒä¸Š | âœ… |
| Word å«è¡¨æ ¼ | åŒä¸Šï¼Œè¡¨æ ¼è¡Œåˆ—未拍平 | âœ… |
| Excel æ£€éªŒé¡¹ç›® | åŒä¸Š | âœ… |
| çº¯æ–‡æœ¬ / CSV | åŒä¸Š | âœ… |
| æ—§ç‰ˆ `.doc` | `1_070_104_003` ä¸”文案含「另存为 .docx」 | âœ… |
| å‚数类错误 6 æ¡ | 001 / 005 / 008×2 / 009 / 400 | âœ… 6/6 |
**真实模型调用延时**:实测 `durationMs` è½åœ¨ **2 786 ï½ž 13 642 ms**,随文件页数与通道(文本 vs è§†è§‰ï¼‰æµ®åŠ¨ï¼Œä¸Žè„šæœ¬ä¾§ wall clock å»åˆ â€”— è¯´æ˜Žç¡®å®žæ˜¯æ‰“到了 DashScope,不是缓存或短路。
**多页去重(`scan-3page.pdf`)的 warnings åŽŸæ–‡**,两条都在:
```
文件「scan-3page.pdf」没有文本层(共 3 é¡µï¼‰ï¼Œå·²æŒ‰æ‰«æä»¶é€é¡µè¯†åˆ«ï¼Œä¼šæ¯”较慢
多页识别共得到 15 é¡¹ç»„件,其中有 6 é¡¹åœ¨å…¶å®ƒé¡µå·²å‡ºçŽ°ï¼ˆé¡µçœ‰ã€æ ‡é¢˜ã€è¡¨å¤´ç­‰é‡å¤å†…å®¹ï¼‰ï¼Œå·²åˆå¹¶
```
即 **15 â†’ 9**,去重按 `(type, props)` ç”Ÿæ•ˆï¼Œä¸æ˜¯åªå†™åœ¨æ³¨é‡Šé‡Œçš„æ‰¿è¯ºã€‚
**「AI äº§å‡ºåªä½œè‰ç¨¿ã€æœ‰ DB è¯æ®**:全部 AI è°ƒç”¨è·‘完,`qc_report_template_version` é‡Œæ¨¡æ¿ 5 **仍然只有 v1.0 / v1.1 ä¸¤è¡Œ**,没有任何一行是 AI ç›´æŽ¥å†™è¿›åŽ»çš„ã€‚è½åº“åªå‘ç”Ÿåœ¨äººå·¥ç‚¹ã€Œä¿å­˜ã€æ—¶ã€‚
**`type` è¶Šç•ŒçŽ‡ä¸º 0**:每个用例都把返回的 `type` é›†åˆä¸Ž 12 ä¸ªå†…ç½® type æ±‚差集,**无一越界**;`summary` å‡ä¸ºé€šé¡ºä¸­æ–‡ï¼ˆä¾‹ï¼šã€Œæœ¬æ–‡ä»¶ä¸ºå‡ºè´§æ£€éªŒæŠ¥å‘Šï¼ŒåŒ…含报告头、检验项目表格与结论」)。
### 13.5 è®¾è®¡å™¨ã€Œç”Ÿæˆåˆ°ç”»å¸ƒ â†’ ä¿å­˜ â†’ åˆ·æ–°ã€å¾€è¿”验收 â€”— âœ… æœ¬è½®çœŸæ­£çš„验收点
用 Playwright MCP æŠŠæ•´æ¡é“¾è·¯èµ°å®Œå¹¶é€æ­¥æˆªå›¾ï¼ˆ`.playwright-mcp/ai-02-designer.png` ï½ž `ai-11-published.png`):
| æ­¥éª¤ | ç»“æžœ |
|---|---|
| è¿›è®¾è®¡å™¨ â†’ ç‚¹é¡¶æ ã€ŒAI å¯¼å…¥ã€ | æŒ‰é’®ä¸Žå¼¹çª—正常(`ai-02`/`ai-03`) |
| é€‰å…¥çœŸå®žæ‰«æä»¶ â†’ ã€Œå¼€å§‹è¯†åˆ«ã€ | 5 ä¸ªç»„件,**4.5 s**(`ai-04`/`ai-05`) |
| ã€Œç”Ÿæˆåˆ°ç”»å¸ƒã€ | ç»„件按序出现,属性面板被 `restoreQualityTraits` é‡å»ºï¼ˆ`ai-06`) |
| **点「保存」→ ä¿å­˜ v1.1** | è½åº“成功(`ai-07`/`ai-08`) |
| **刷新页面 â†’ åˆ‡ç‰ˆæœ¬å†åˆ‡å›ž** | **语义签名逐字一致**(`ai-09`) |
| å‘布 v1.1 | toast「版本 v1.1 å·²å‘布」(`ai-10`/`ai-11`) |
> **为什么用「归一化语义签名」而不是裸字符串比对**:GrapesJS çš„节点 `id` æ¯æ¬¡ä¼šè¯é‡æ–°ç”Ÿæˆï¼Œä¸¤ä»½å†…容相同的 Schema å­—符串一定不同。比对单位取**顶层 `[data-gjs-type="wrapper"] > [data-quality-type]` èŠ‚ç‚¹åŠå…¶ `data-qc-*` å±žæ€§é›†åˆ**,这才是「画布内容」的正确表示。刷新前后该签名**完全一致**,即证明 `collectSchema` â†’ æ–°ç‰ˆæœ¬è¡Œ â†’ `loadSchema` å¾€è¿”无损,同时把装配器与 `restoreQualityTraits` ä¸€èµ·éªŒäº†ã€‚
两处附带确认:AI çŒœå‡ºçš„纸张被并入 `pageSetting` å¹¶**自动切到纵向**;`ReportHeader.reportNo` / `Result.conclusion` çš„ **`{{...}}` ç»‘定占位符原样保留**,没被替换成模型看到的字面值。
### 13.6 PDF é¡µçœ‰ä¿®å¤å¤éªŒ â€”— âœ… é—­çޝ Â§åäºŒ é—留第 1 æ¡
实例 30(`QR20260919-0003`)导出:**1 209 ms**、**59 809 å­—节**、`browserStatus: READY`。用 PDFBox æŠ½ç¬¬ 1 é¡µçº¯æ–‡æœ¬ + æ¸²æŸ“成 PNG åŒé‡ç¡®è®¤ï¼š
- ç¬¬ 1 é¡µæ–‡æœ¬**首行是报告正文**「来料检验报告」、**末行是我们自己的页脚**「第 1 é¡µ / å…± 1 é¡µã€ï¼›
- **通篇没有** Chromium è‡ªå¸¦çš„「打印日期 + æ–‡æ¡£æ ‡é¢˜ã€é¡µçœ‰ï¼›
- æ¸²æŸ“图 `D:/qcl-tmp/header-check-30-p1.png` è‚‰çœ¼å¤æ ¸ä¸€è‡´ã€‚
即 `PdfPrintOptions.HEADER_TEMPLATE = "<div></div>"` + `.setHeaderTemplate(...)` ç¡®å®žç”Ÿæ•ˆã€‚
### 13.7 æœ¬è½®æš´éœ²çš„ 1 ä¸ªçœŸå®žç¼ºé™·ï¼ˆå·²ä¿®ï¼‰+ 1 ä¸ªçœŸå®žé𐿂£ï¼ˆæœªä¿®ï¼Œå¦‚实记录)
**① ç¼ºé™·ï¼ˆå·²ä¿®ï¼‰â€”—方案文档里的 `application` å€¼ä¸åœ¨åŽç«¯æžšä¸¾å†…。**
首次 `bind` ç›´æŽ¥ **HTTP 500 ç³»ç»Ÿå¼‚常**,日志:
```
java.lang.IllegalArgumentException: æ— æ•ˆçš„æ–‡ä»¶ç”¨é€”类型: ai_import_source
    at cn.iocoder.yudao.module.system.enums.storage.StorageApplicationTypeEnum.getByType(:30)
```
根因不在前端、也不在测试:**`StorageApplicationTypeEnum` åªæœ‰ `file` / `image` / `avatar` ä¸‰ä¸ªå€¼**(`getByType` å¯¹æœªçŸ¥å€¼ç›´æŽ¥æŠ›ï¼‰ï¼Œè€Œ**批准方案里写的是 `ai_import_source`**。这个字面值没对着枚举核过,只有真跑 HTTP æ‰ä¼šæš´éœ² â€”— å•测和 typecheck éƒ½æ‹¦ä¸ä½å®ƒã€‚
已修 **3 å¤„**:`ai-import-modal.vue`、`docs/qc_report_ai_import_frontend_integration.md`(并加了一行 âš  è¯´æ˜Žæžšä¸¾é™åˆ¶ï¼‰ã€è”调脚本。`recordType` ä»æ˜¯æˆ‘们自己的 `qc_report_template`,不受影响。
**② é𐿂£ï¼ˆæœªä¿®ï¼‰â€”—对同一模板重复导入会留下孤儿 blob + ç£ç›˜æ–‡ä»¶ã€‚**
`StorageFileUtil.saveStorageAttachmentByRecordTypeAndRecordId` çš„æ—¢æœ‰è¯­ä¹‰æ˜¯ã€Œ**只删除旧的附件关联记录,保留 blob è®°å½•(文件本身不删除)**」。对 AI å¯¼å…¥è¿™æ¡æµï¼Œå«ä¹‰æ˜¯ï¼š**同一模板第二次导入源文件时,上一份源文件变成孤儿 blob,且磁盘文件不会删**,随重复导入无界增长。
连带一个清理上的坑:`deleteAttachments(ids)` ä¹Ÿæ¸…不掉「已被前一次 bind è½¯åˆ ã€çš„附件行 â€”— å®ƒå†…部走 `storageAttachmentMapper.selectByIds(...)`,**带逻辑删除过滤**,取不到那些行自然也就取不到对应 blob。本轮的临时数据最后是手工 `UPDATE system_storage_blob SET deleted=1 ...` + `rm` ç£ç›˜æ–‡ä»¶æ‰å¹²å‡€çš„。
**处置建议(留待下一轮定夺)**:要么让 AI å¯¼å…¥ã€Œæ›¿æ¢åŽé¡ºå¸¦æ¸…理被顶掉的 blob」,要么明确把源文件当**正式归档累积**(那就不算孤儿,但要有配额与自清理)。两条路都需要产品口径,不属本轮范围。
### 13.8 âš  å·²éªŒè¯ / æœªéªŒè¯ï¼ˆæœ¬èŠ‚çš„é‡ç‚¹ï¼‰
**已拿到实测证据、可以说「可用」的**:
- åŽç«¯çœŸå®ž HTTP é“¾è·¯ **54/54**(8 æ ¼å¼ç”¨ä¾‹ + `.doc` æ‹’æ”¶ + 6 æ¡å…¥å‚错误),含真实模型延时;
- **多页去重**、**`type` é›¶è¶Šç•Œ**、**AI ä¸è½åº“** ä¸‰é¡¹å‡æœ‰å®žè¯ï¼›
- è®¾è®¡å™¨ **UI å…¨é“¾è·¯ + åˆ·æ–°å¾€è¿”无损 + å‘布**;
- PDF é¡µçœ‰ä¿®å¤å¤éªŒï¼›
- ç¼–译通过、**93 é¡¹å•测**、装配器对拍 **约 60 æ¡æ–­è¨€**、前端 **零新增类型错误**(见 13.3)。
**仍未验证的(不能说「可用」)**:
- **`yudao.qcreport.ai-import.enabled=false â†’ 1_070_104_000`** â€”— æœªå®žæµ‹ï¼ˆéœ€ä¸´æ—¶æ”¹é…ç½® + é‡å¯ï¼‰ã€‚
- **超时分支 `1_070_104_011`** â€”— æœªå®žæµ‹ã€‚`yudao.ai.timeout` çš„ 60 s ä¸Žå‰ç«¯é‚£æ¡ 180 s æ˜¯**两道独立的闸**,都没触发过。最省事的验法是把 `yudao.ai.timeout` ä¸´æ—¶æ”¹æˆ `1s`,**没做**。
- **超页(`FILE_TOO_MANY_PAGES`)/ åˆè®¡è¶…页(`TOO_MANY_PAGES_TOTAL`)/ è¶…大小(`FILE_TOO_LARGE`)三条** â€”— æœªå®žæµ‹ã€‚同族里「超文件数」已被 `@Size` æ‹¦æˆ 400 å¹¶å®žæµ‹åˆ°ï¼Œä½†å¦å¤–三条没造过超限 fixture。
- **「模板删除清附件」的连带改动未实测** â€”— ä»£ç åœ¨ï¼ˆ`deleteTemplate` é‡ŒåŠ äº† `deleteAttachmentsByRecord`),**没跑过**。
- **没写 `@Disabled` çš„真实联调测试**(计划里提到可以写一个默认不跑的)—— æœ¬è½®**没写**。
- **AI çš„识别「准确率」未被度量** â€”— æˆ‘们验的是「链路通、结构合法、`type` ä¸è¶Šç•Œã€ï¼Œ**不是**「认出来的组件与原件逐项吻合」。这份判断需要人工对照真实报告,本轮只做到人工看了截图觉得合理。
- **Linux éƒ¨ç½²ä¸‹æœªéªŒ** â€”— AI å¯¼å…¥ä¸ä¾èµ–浏览器/字体(与 Phase 3 ä¸åŒï¼‰ï¼Œé¢„期无风险,但**未验**。
> **准确的说法**:AI å¯¼å…¥**后端 + å‰ç«¯ + çœŸå®žé“¾è·¯å…¨é€š**,核心口径(只作草稿、多页去重、`type` ä¸è¶Šç•Œï¼‰éƒ½æœ‰å®žæµ‹è¯æ®ã€‚
> ä½†**两个配置开关分支、三条输入上限、模板删除清理、识别准确率**仍属未验证 â€”—
> ä¸è¦æŠŠã€Œ54/54 é€šè¿‡ã€è¯´æˆã€Œæ‰€æœ‰é”™è¯¯åˆ†æ”¯éƒ½éªŒè¿‡ã€ï¼Œé‚£äº›æ•°å­—只覆盖了已构造的用例。
### 13.9 ä¿ç•™æ•°æ®ä¸Žå›žæ»šè¯´æ˜Ž
- **有意保留**:模板 5(`QC_OQC_UI` å‡ºè´§æ£€éªŒæŠ¥å‘Šæ¨¡æ¿ï¼‰çš„ **v1.1**,schema 22 541 å­—符,**已发布**且 `current_version = v1.1` â€”— è¿™æ˜¯ UI å¾€è¿”验收的产物,留着让功能可演示。它原先是一份**空画布草稿**,所以**完全可回滚**(把 v1.1 åœç”¨ / åˆ æŽ‰ï¼Œ`current_version` åˆ‡å›ž v1.0 å³å¯ï¼‰ã€‚**如需还原以便重做验收,说一声即可。**
- **已清理干净**:实例 9 è¡Œå…¨éƒ¨è½¯åˆ  / **0 å­˜æ´»**;`system_storage_blob` 19 è¡Œ / **0 å­˜æ´»**;`system_storage_attachment` 17 è¡Œ / **0 å­˜æ´»**;`D:/uploads/2026/0918/` ä¸Ž `D:/uploads/2026/0919/` **均为空**。
- **有意不删**:`qc_report_render_record` 10 è¡Œ â€”— `deleteInstance` æœ‰æ„ä¸åˆ æ¸²æŸ“记录(审计留痕)。
- **一条已重编译、待重启生效的文案微调**:`ErrorCodeConstants.AI_IMPORT_FILE_LEGACY_OFFICE` çš„æ¨¡æ¿é‡Œ `.{}` å‰å¤šäº†ä¸€ä¸ªç©ºæ ¼ï¼ˆåŒä¸€å¥é‡Œçš„ `.docx`/`.xlsx` éƒ½æ²¡æœ‰ï¼‰ï¼Œæ¸²æŸ“成「旧版 Office æ ¼å¼ **.doc**」,已去掉空格。`mvn compile -pl yudao-module-qcreport -am -q` **EXIT=0**,**重启后端后生效**;未生效不影响功能。
### 13.10 ä¸‹ä¸€æ­¥ï¼ˆæŒ‰ä¼˜å…ˆçº§ï¼‰
1. **重启后端**(让 `.doc` æ–‡æ¡ˆé‚£å¤„微调生效)—— `mvn compile -pl yudao-module-qcreport -am -q` **已跑、EXIT=0**,只剩手工重启。
2. **补三个配置/限制分支的实测**(都要临时改配置 + é‡å¯ï¼‰ï¼š`ai-import.enabled: false` â†’ `1_070_104_000`;`yudao.ai.timeout: 1s` â†’ `1_070_104_011`;超大小 fixture â†’ `1_070_104_004`。
3. **实测「删模板顺带清附件与 blob」**:建一个临时模板 â†’ å¯¼å…¥ä¸€æ¬¡æºæ–‡ä»¶ â†’ åˆ æ¨¡æ¿ â†’ å›žè¯»ä¸‰å¼ è¡¨ + ç£ç›˜ç›®å½•。
4. **定夺 13.7 â‘¡ çš„孤儿 blob å¤„ç½®**(清理 vs å½’档累积),定完再动手。
5. **ä¿® Â§12.3.3 çš„长表分页缺陷**:把 `PdfPrintOptions.PRINT_CSS` çš„ `table` ä»Ž `break-inside: avoid` åå•里摘掉,再导一次 60 è¡Œ fixture ç¡®è®¤ç¬¬ 1 é¡µä¸å†ç•™ç™½ã€‚
6. **权限码入 `system_menu`**:`qc-report:template:ai-import` ä¸Ž `qc-report:instance:*` ç›®å‰é è¶…管直通,正式启用前要落菜单行(属初始化脚本变更,按 `sql-config-export-init.md` èµ°ï¼‰ã€‚
---
## åå››ã€æ”¶å£ä¸‰ä»¶ï¼ˆ2026-09-19)—— Â§13.10 å¾…办 2/3/5/6 ä¸­çš„四项落地
> æœ¬èŠ‚æŠŠ Â§13.10 é‡Œã€Œå·²å†™ä»£ç ä½†æ²¡éªŒè¿‡ã€å’Œã€ŒåŽ‹æ ¹æ²¡åšã€çš„å‡ é¡¹ä¸€æ¬¡æ€§æ¸…æŽ‰ï¼Œå¹¶è¡¥ä¸Šå¹³å°çš„**出件闭环前端**。
> ä¸€å¥è¯ç»“论:**长表分页缺陷已修且有一条可复现的判据**;**AI å¯¼å…¥çš„孤儿 blob é𐿂£å·²ä¿®ä¸”有 15 æ¡æ–­è¨€**;
> **报告实例从「只有 8 ä¸ªåŽç«¯æŽ¥å£ã€å˜æˆã€Œé¡µé¢ä¸Šèƒ½ç‚¹ã€**,权限码也落库了。
### 14.1 #43 é•¿è¡¨åˆ†é¡µç¼ºé™· â€”— âœ… å·²ä¿®ï¼Œä¸”留下了一条机器可判的判据
**改的是什么**:`PdfPrintOptions.PRINT_CSS` é‡Œ **把 `table` ä»Ž `break-inside: avoid` åå•摘掉**,保留 `tr/td/th/img`。
```java
// æ”¹å‰ï¼štr, td, th, img, table { break-inside: avoid; ... }
// æ”¹åŽï¼š
tr, td, th, img { break-inside: avoid; page-break-inside: avoid; }
```
**为什么**:`break-inside: avoid` åŠ åœ¨ `table` ä¸Šæ˜¯ã€Œæ•´å¼ è¡¨ä¸è®¸è·¨é¡µã€ã€‚60 è¡Œæ£€éªŒé¡¹çš„表在一页放不下时,Chromium ä¼šæŠŠ**整张表**搬到下一页,第 1 é¡µåªå‰©é¡µçœ‰ä¸Žä¸€å¤§ç‰‡ç©ºç™½ã€‚长表跨页的正解是 `thead { display: table-header-group }` è®©è¡¨å¤´æ¯é¡µé‡å¤ + è¡Œå†…不断页,这两条本来就在。改前的实测现象记录在 Â§12.3.3。
**判据(这是本次最有价值的部分)**:造一份 60 è¡Œå¾ªçŽ¯è¡¨æ ¼ + å†…嵌图片的临时模板并出件导出(`D:/qcl-tmp/pdf-visual-fixture.mjs`),再用 `PageFillProbe.java` æŒ‰é¡µç»Ÿè®¡æ–‡æœ¬é‡ä¸Žå…³é”®è¯ã€Œé¡¹ç›®ã€çš„出现次数:
| æŒ‡æ ‡ | æ”¹å‰ï¼ˆÂ§12.3.3 è®°å½•) | æ”¹åŽï¼ˆæœ¬æ¬¡å®žæµ‹ï¼‰ |
|---|---|---|
| ç¬¬ 1 é¡µå…³é”®è¯å‘½ä¸­æ•° | **0**(`VERDICT=FIRST_PAGE_BLANK`) | **36**(`VERDICT=FIRST_PAGE_HAS_ROWS`) |
| åˆ†é¡µ | 1 é¡µï¼ˆè¡¨è¢«æ•´ä½“推到不存在的第 2 é¡µå¯¼è‡´æº¢å‡ºï¼‰ | 2 é¡µï¼Œpage1 36 è¡Œ / page2 26 è¡Œ |
`pdf-visual-fixture.mjs` æœ¬è½® **11/11 PASS**:60 é¡¹å‡ºä»¶åŽ `<tr>` æ•° = 61(60 æ•°æ® + 1 è¡¨å¤´ï¼‰ã€äº§ç‰© HTML ä¿ç•™ `data:image` å†…嵌图、`@page` æ˜¯ `210mm 297mm`、PDF 2 é¡µã€MediaBox `594.96 Ã— 841.92`(A4 ç«–版)、含 `/Subtype /Image`、含 `/FontFile2`。
> ä¿® `pdf-visual-fixture.mjs` çš„一处**断言自身写错**:媒体盒断言原写死 `/0 0 595\.\d+ 841\.\d+/`,而 Chromium å‡ºçš„ A4 æ˜¯ `594.95996 Ã— 841.91998`——差 0.05 pt æ˜¯æµ®ç‚¹èˆå…¥ï¼Œä¸æ˜¯çº¸åž‹ä¸å¯¹ã€‚已改成带容差的数值比较。**这不是后端缺陷。**
### 14.2 #44 AI å¯¼å…¥å­¤å„¿ blob é𐿂£ â€”— âœ… å·²ä¿®ï¼Œ15/15 æ–­è¨€
§13.7 â‘¡ æçš„隐患,处置口径定为 **「替换」而不是「累积」**:一次导入对应一份源文件,新的进来、旧的出去。源文件只是识别用的输入,模板真正的产物是版本 Schema,没有归档它们的必要。
**改的是什么**:`ai-import-modal.vue` åœ¨ `bind` **之前**加一步 `removeExistingSourceFiles(templateId)` â€”— å…ˆ `listAttachments(recordType=qc_report_template, recordId)` æ‹¿åˆ°æ—§é™„ä»¶ id,再 `deleteAttachments(ids)`,然后才上传→绑定新文件。
**为什么必须单独删**:`bind` çš„实现是「只删旧的**附件关联记录**,保留 **blob è®°å½•**(文件本身不删除)」。所以指望绑定顺手清理是错的——旧文件删不了行也删不了盘,重复导入几次磁盘上就多几份再没人引用的文件。
**实测(`D:/qcl-tmp/verify-44-blob-lifecycle.mjs`,15/15 PASS)**,复刻前端四步时序,直接比对 DB ä¸Žç£ç›˜ï¼š
| æ–­è¨€ | ç»“æžœ |
|---|---|
| ä¸Šä¼  A â†’ åˆ æ—§ï¼ˆæ­¤æ—¶ä¸ºç©ºï¼‰â†’ ç»‘定 A:附件列表 1 æ¡ã€ç£ç›˜æœ‰ A | âœ… |
| å†ä¸Šä¼  B â†’ åˆ æ—§ï¼ˆæ­¤æ—¶æ˜¯ A)→ ç»‘定 B | âœ… `code=0` |
| **A çš„磁盘文件随之删除(#44 çš„æ ¸å¿ƒï¼‰** | âœ… `...round-a.txt exists=false` |
| æ›¿æ¢åŽé™„件列表只剩 B,B çš„磁盘文件仍在 | âœ… |
| åˆ æ¨¡æ¿ â†’ é™„件列表清空、B çš„磁盘文件也被清掉 | âœ… |
最后一条同时把 Â§13.10 å¾…办 3「**实测删模板顺带清附件与 blob**」一起验了:`deleteTemplate` é‡Œçš„ `deleteAttachmentsByRecord(QC_REPORT_TEMPLATE, id)` ç¡®å®žç”Ÿæ•ˆï¼ˆ`live attachment for template 15 = 0`,两个 blob è¡Œå‡ `deleted=1`,磁盘无残留)。
### 14.3 #45 å‰ç«¯æŠ¥å‘Šå®žä¾‹é¡µ â€”— âœ… å·²è½åœ°ï¼Œæµè§ˆå™¨å…¨é“¾è·¯éªŒæ”¶é€šè¿‡
**这是 Â§13.10 é‡Œæ²¡æœ‰çš„一项**:此前平台的 8 ä¸ªå®žä¾‹æŽ¥å£**前端一个都没接**,「出件闭环」在页面上点不到。本轮补齐。
| å±‚ | æ–‡ä»¶ | çŠ¶æ€ |
|---|---|---|
| API | `mom-pro2-before/src/api/mes/qc/report/instance/index.ts` | æ–°å»ºï¼ˆæ­¤å‰å®Œå…¨ä¸å­˜åœ¨ï¼‰ |
| åˆ—表页 | `.../views/mes/qc/report/instance/index.vue` + `data.ts` | æ–°å»ºï¼Œè·¯ç”± `/qc/report-instance` |
| è¯¦æƒ…弹窗 | `.../instance/modules/detail.vue` | æ–°å»ºï¼šæŠ¥å‘Šé¢„览(iframe)/ æ•°æ®å¿«ç…§ / å¯¼å‡º PDF |
| æƒé™ | `system_menu` 6 è¡Œ | èœå•「报告实例」+ æŸ¥è¯¢/出件/导出PDF/删除 4 ä¸ªæŒ‰é’® + `qc-report:template:ai-import` |
| è”调方案 | `docs/qc_report_instance_frontend_integration.md` | å¢žé‡æ›´æ–°ï¼ˆåŽŸ PDF è½®å·²å»ºï¼‰ |
**权限链全程追过一遍**(这决定了要不要重启后端):后端 `@ss.hasPermission` å¯¹è¶…管短路放行;前端 `hasAccessByCodes` æ˜¯æ‹¿ `accessCodes` åš**精确集合匹配**,而它由 `/get-permission-info` çŽ°åœºä»Ž `menuService.getMenuList(menuIds)` æž„建,`getMenuList()` **不是 `@Cacheable`**。⇒ **只插 `system_menu` è¡Œï¼Œåˆ·æ–°é¡µé¢å³å¯ç”Ÿæ•ˆï¼Œä¸éœ€è¦é‡å¯åŽç«¯ã€‚** å·²å®žæµ‹ï¼šæ–°èœå•项出现在侧栏、`/qc/report-instance` è·¯ç”±å¯è¿›ã€æŒ‰é’®æ­£å¸¸æ¸²æŸ“。
**浏览器验收(Playwright MCP,6 å¼ æˆªå›¾ï¼‰**:
| éªŒè¯ç‚¹ | ç»“æžœ |
|---|---|
| åˆ—表渲染 | 5 è¡Œï¼›æ¨¡æ¿å / ä¸šåŠ¡ç±»åž‹ï¼ˆ`mes_qc_oqc`→出货检验)/ ç”Ÿæˆäººï¼ˆç¼–号→昵称)**都正确解析**,无 `模板 #id` æˆ–裸编号残留 |
| è¯¦æƒ…弹窗 | Descriptions é½å…¨ï¼Œæ¨¡æ¿ç‰ˆæœ¬ç»¿è‰² Tag、状态走 `DictTag` |
| **报告预览页签** | iframe(`sandbox=""`)**真渲出报告本体**,含内嵌图片的红/绿渐变;60 è¡Œè¡¨æ ¼å¯è§ |
| æ•°æ®å¿«ç…§é¡µç­¾ | å†»ç»“çš„ context JSON æ­£å¸¸å±•示 + è¯´æ˜Žæ€§ Alert |
| **导出 PDF æŒ‰é’®** | toast「已生成 VERIFY….pdf(44.25 KB,耗时 1615 æ¯«ç§’)」,弹窗内出现「在线预览 / ä¸‹è½½ PDF」 |
| **重复导出不堆文件** | åŒå®žä¾‹è¿žç‚¹ 3 æ¬¡åŽï¼Œ`system_storage_attachment` åªæœ‰ **1 æ¡æœ‰æ•ˆè¡Œ**(前两条 `deleted=1`)、`system_storage_blob` å¯¹åº”行同样软删、**磁盘目录里只留当前这一份 PDF** |
**前端零新增类型错误**:`vue-tsc --noEmit`(带 8 GB å †å‚数)总计 **316 é¡¹**(与既有基线完全一致),其中 `qc/report` ç›¸å…³æ–‡ä»¶ **0 é¡¹**。
### 14.4 åˆå§‹åŒ–脚本 â€”— ç»“论:**本次无需改动**
6 æ¡èœå•行只插进了运行库 `ruoyi-vue-pro`。按 `sql-config-export-init.md` æœ¬è¯¥é‡æ–°ç”Ÿæˆ `config_export_all_<日期>.sql`,但实际情况不同:
> **该文件的「阶段二」不是字面量 INSERT,而是执行期的跨库拷贝** â€”—
> `INSERT INTO system_menu SELECT * FROM \`ruoyi-vue-pro\`.system_menu WHERE deleted = b'0'`。
> åªè¦æºåº“有这几行,在新库执行时就会被带过去,**与文件内容无关**(阶段一的字面量 INSERT æ‰éœ€è¦é‡æ–°å¯¼å‡ºï¼‰ã€‚
所以**文件本身已经是最新的**。已在文件头「补丁说明」加了第 â‘¤ æ¡ç™»è®°è¿™ä»¶äº‹ï¼Œå¹¶å†™æ˜Žã€Œæ­¤å¤„无需改动、登记只为说明这版文件确实覆盖了该变更」,避免后来者误以为漏了。
### 14.5 æœ¬è½®å‘现的**两处存量数据不一致**(不是本轮代码引入,如实上报)
用 `pdf-e2e.mjs` å¤è·‘时出现 2 æ¡ FAIL(17/19),逐条追到的是**数据问题而非缺陷**:
| çŽ°è±¡ | æ ¹å›  |
|---|---|
| æ–­è¨€ã€Œçº¸é¢æ˜¯ A4 ç«–版」失败,实得 `MediaBox [0 0 841.92 594.96]`(**横向**) | æ¨¡æ¿ 5 çš„ `qc_report_template` è¡Œå†™çš„æ˜¯ A4/**portrait**,但它**版本 schema é‡Œçš„ `page.orientation` æ˜¯ landscape`**。**渲染器以 schema ä¸ºå‡†**(纸面配置的真相在版本里,不在模板行上)→ è¡Œä¸Ž schema ä¸ä¸€è‡´ï¼Œæ¸²æŸ“按 schema èµ° |
| æ–­è¨€ã€Œ60 é¡¹åº”打成多页」失败,实得 1 é¡µ | æ¨¡æ¿ 5 **v1.0 çš„画布是空的**(`{"components":[],…}`,schema ä»… 959 å­—符),60 è¡Œè¡¨æ ¼æ ¹æœ¬æ²¡æ¸²è¿›åŽ»ã€‚è¿™æ¡æ–­è¨€æ˜¯ v1.0 è¿˜æœ‰å†…容时写的,数据被清后断言就悬空了 |
**另一条值得记的规则性事实**:`uk_report_no` **不区分软删除**。一条实例被删除后它的报告编号仍被占用,再次出件同号会报 `1_070_102_001`。本轮造 fixture æ—¶å°±æ’žåˆ°è¿‡ä¸€æ¬¡ï¼ˆç¡¬ç¼–码的 `PG20260918001` ä¸Žå·²è½¯åˆ çš„实例 25 å†²çªï¼‰ï¼Œæ”¹æˆå¸¦æ—¶é—´æˆ³çš„编号才通过。写联调脚本时**不要硬编码报告编号**。
### 14.6 âš  å·²éªŒè¯ / æœªéªŒè¯ï¼ˆæ‰¿æŽ¥ Â§13.8)
**本轮新拿到实测证据的**:
- é•¿è¡¨åˆ†é¡µä¿®å¤ï¼ˆ`PageFillProbe` çš„ `FIRST_PAGE_HAS_ROWS`,含改前后对照);
- AI å¯¼å…¥æºæ–‡ä»¶æ›¿æ¢çš„ blob/磁盘生命周期(15 æ¡æ–­è¨€ï¼Œç›´æŸ¥ DB + ç£ç›˜ï¼‰ï¼›
- åˆ æ¨¡æ¿æ¸…附件与 blob(同日验证);
- æŠ¥å‘Šå®žä¾‹é¡µçš„列表 / è¯¦æƒ… / iframe é¢„览 / å¿«ç…§ / å¯¼å‡º PDF / é‡å¤å¯¼å‡ºä¸å †æ–‡ä»¶ï¼ˆ6 å¼ æˆªå›¾ï¼‰ï¼›
- æƒé™ç å…¥ `system_menu` åŽå‰ç«¯å³æ—¶ç”Ÿæ•ˆï¼ˆæ— éœ€é‡å¯ï¼‰ï¼›
- å‰ç«¯ç±»åž‹æ£€æŸ¥é›¶æ–°å¢žé”™è¯¯ï¼ˆ316 â†’ 316)。
**仍未验证的(承接 Â§13.8,本轮**没有**推进)**:
- **`yudao.qcreport.ai-import.enabled=false â†’ 1_070_104_000`** â€”— ä»æœªå®žæµ‹ã€‚
- **超时分支 `1_070_104_011`** â€”— ä»æœªå®žæµ‹ã€‚
- **超页 / åˆè®¡è¶…页 / è¶…大小三条(`FILE_TOO_MANY_PAGES` / `TOO_MANY_PAGES_TOTAL` / `FILE_TOO_LARGE`)** â€”— ä»æœªå®žæµ‹ï¼Œç¼ºè¶…限 fixture。
- **`yudao.qcreport.pdf.enabled=false â†’ 1_070_103_008`** â€”— ä»æœªå®žæµ‹ã€‚
- **AI è¯†åˆ«å‡†ç¡®çއ** â€”— ä»æœªåº¦é‡ã€‚
- **Linux éƒ¨ç½²** â€”— ä»æœªéªŒã€‚
> è¿™äº”项都需要**临时改配置 + é‡å¯åŽç«¯**,集中在下一轮一次做完(见 14.7)。
### 14.7 ä¸‹ä¸€æ­¥
1. **一次性补完配置分支实测**(改一次配置 + é‡å¯ä¸€æ¬¡ï¼ŒæŠŠå››æ¡ä¸€èµ·æ‰“掉):`ai-import.enabled=false` â†’ `104_000`;`yudao.ai.timeout=1s` â†’ `104_011`;超大小 fixture â†’ `104_004`;`pdf.enabled=false` â†’ `103_008`。
2. **定夺并上报模板 5 çš„「行 vs schema çº¸åž‹ä¸ä¸€è‡´ã€ä¸Žã€Œv1.0 ç©ºç”»å¸ƒã€**(见 14.5)—— å±žå­˜é‡æ•°æ®ï¼Œéœ€äººå·¥ç¡®è®¤æ˜¯ä¿®æ•°æ®è¿˜æ˜¯æ”¹ `pdf-e2e.mjs` çš„æ–­è¨€ã€‚
3. **`MesQcReportApi` + å››ç§è´¨æ£€å•归一化**:把「质检单 â†’ å‡ºæŠ¥å‘Šã€çš„入口接上,届时列表页的 `qc-report:instance:create` æƒé™ç æ‰çœŸæ­£ç”¨å¾—上(现已预置入库)。
4. **单独开修复项:ERP/CRM/MES ä¸‰å¤„ `tempFile.delete()` ç¼ºé™·**(同构三处,删的是用户真实上传的 blob æ–‡ä»¶æœ¬èº«ï¼‰ã€‚
5. å…¶ä½™ç»´æŒä¸å˜ï¼šå…¥ç«™ HTML å‡€åŒ–、二维码生成、`qc_report_data_source`/`qc_report_rule` ä¸¤å¼ æ—  Java å¯¹åº”的表。
---
## åäº”、限制与配置分支实测(2026-09-19)
§14.6 åˆ—的五项未验证,本轮全部打掉。**不改任何业务代码,只做验证**,所以本节没有交付物,只有证据与三个新暴露的问题。
### 15.1 æ–¹æ³•:把验证脚本化,而不是手点
三条脚本放在 `D:/qcl-tmp/`(不入库):
| è„šæœ¬ | è¦†ç›– | æ˜¯å¦éœ€é‡å¯ |
|---|---|---|
| `verify-46-limit-branches.mjs` | è¾“入校验类 10 ä¾‹ | å¦ |
| `verify-46-config-a.mjs` | ä¸¤ä¸ª `enabled=false` å¼€å…³ | æ˜¯ï¼ˆæ”¹é…ç½®ï¼‰ |
| `verify-46-config-b.mjs` | AI è°ƒç”¨è¶…æ—¶ | æ˜¯ï¼ˆæ”¹é…ç½®ï¼‰ |
多页 PDF fixture(`p6/p5a/p5b.pdf`)用 Playwright çŽ°é€ ã€‚**页数不由脚本自称、由后端自己报出来**("共 6 é¡µ"/"合计 10 é¡µ"),避免自证。
### 15.2 å®žæµ‹ç»“果:11 æ¡é”™è¯¯ç  + 1 æ¡ä¸Šä¼ å£æ‹¦æˆª
| åœºæ™¯ | é¢„期码 | ç»“æžœ |
|---|---|---|
| blobId ä¸å­˜åœ¨ | `1_070_104_001` | âœ… |
| `.exe` | `1_070_104_002` | âœ… |
| `.doc` æ—§æ ¼å¼ | `1_070_104_003` | âœ… |
| 21MB æ–‡ä»¶ | `1_070_104_004` | âœ… |
| åªæœ‰ç©ºç™½çš„ txt / æŸåçš„ xlsx | `1_070_104_005` | âœ… |
| ç»„件清单 41 é¡¹ / æŸé¡¹ç¼º type | `1_070_104_008` | âœ… |
| `schemaVersion=1.0` | `1_070_104_009` | âœ… |
| 6 é¡µ PDF(单文件超 5 é¡µï¼‰ | `1_070_104_007` | âœ… |
| ä¸¤ä¸ª 5 é¡µ PDF(合计 10 > 8) | `1_070_104_014` | âœ… |
| `ai-import.enabled=false` | `1_070_104_000` | âœ… |
| `yudao.ai.timeout=1s` | `1_070_104_011` | âœ… |
| `pdf.enabled=false` | `1_070_103_008` | âœ… |
| 4 ä¸ªæ–‡ä»¶ | â€” | âš  è§ 15.3 é—®é¢˜ä¸€ |
两条「未启用」提示都点名了具体配置项(`yudao.qcreport.ai-import.enabled` / `yudao.qcreport.pdf.enabled`),用户能照着改。`ai-import.enabled=false` é‚£ä¾‹**故意传不存在的 blobId**,仍拿到 `104_000` è€Œéž `104_001` â€”— é¡ºå¸¦è¯æ˜Žå¼€å…³æ£€æŸ¥æŽ’在文件读取之前。
**0 å­—节文件走不到业务层**:storage ä¸Šä¼ å£ç›´æŽ¥ä»¥ `上传文件不能为空`(code `1002029000`)拒绝,所以 `OfficeImportAdapter` é‡Œ `content.length == 0` é‚£æ¡åˆ†æ”¯ç» HTTP ä¸å¯è¾¾ï¼›`text.isBlank()` é‚£æ¡æ‰æ˜¯å®žé™…生效的(用只有空白的 txt éªŒåˆ°ï¼‰ã€‚属正常的纵深设防,仅记录。
### 15.3 ä¸‰ä¸ªæµ‹å‡ºæ¥çš„问题
**问题一:`max-files` è¿™ä¸ªé…ç½®é¡¹åªèƒ½å¾€ä¸‹è°ƒï¼Œä¸èƒ½å¾€ä¸Šè°ƒã€‚**
`QcReportAiImportReqVO.blobIds` ä¸Šå†™æ­»äº† `@Size(max = 3)`,Bean Validation å…ˆäºŽæœåŠ¡æ‰§è¡Œã€‚åŽæžœæœ‰ä¸¤å±‚ï¼š
1. `AI_IMPORT_TOO_MANY_FILES`(`104_006`)在默认配置下**不可达** â€”— ä¼  4 ä¸ªæ–‡ä»¶æ‹¿åˆ°çš„æ˜¯ HTTP 400「一次最多识别 3 ä¸ªæ–‡ä»¶ã€ï¼Œè€Œä¸æ˜¯è¿™ä¸ªé”™è¯¯ç ã€‚服务里的 `validateFileCount` ä¸Ž VO æ³¨è§£é™çš„æ˜¯åŒä¸€ä»¶äº‹ã€‚
2. æŠŠ YAML é‡Œçš„ `max-files` è°ƒåˆ° 5 ä¹Ÿ**不会生效**(仍被 `@Size(max=3)` å¡åœ¨ 3),只有调到 3 ä»¥ä¸‹æ‰æœ‰æ„ä¹‰ã€‚
代码里没有任何注释说明这两个数字是耦合的,后来的维护者大概率会以为改 YAML å°±èƒ½æ”¾å¼€ã€‚
**问题二:损坏的 Office æ–‡ä»¶ç»™å‡ºçš„æ˜¯ã€Œæ‰«æä»¶åˆ†è¾¨çŽ‡ã€çš„å»ºè®®ã€‚** âœ… **已修,见 Â§15.7**
传一个损坏的 `.xlsx`,返回 `104_005`:
> æ–‡ä»¶ã€Œbroken.xlsx」未解析出任何可用内容(共 1 é¡µï¼‰ã€‚**若是扫描件,请确认分辨率不低于 200 DPI、文字无严重倾斜或遮挡**;推荐改用电子版 PDF æˆ– Word/Excel
真实原因是**文件损坏或不是有效的 Excel**,与扫描分辨率和 PDF æ¯«æ— å…³ç³»ï¼ˆ`readXlsx`/`readDocx` çš„ catch å—统一抛 `FILE_EMPTY`)。按提示操作的用户会去调分辨率、换 PDF,怎么试都不会好。这违反 `.claude/rules/error-message-precision.md` çš„三要素(具体维度 / åŒæ–¹å€¼ / å¯æ‰§è¡ŒåŠ¨ä½œï¼‰â€”â€” ç»´åº¦æŒ‡é”™äº†ï¼ŒåŠ¨ä½œä¹Ÿå°±é”™äº†ã€‚**建议加一个专属错误码**(如 `AI_IMPORT_OFFICE_CORRUPT`),文案指向"文件可能已损坏,请用 Excel/Word é‡æ–°æ‰“开并另存为 .xlsx/.docx åŽé‡è¯•;若原文件有密码保护请先解除"。
**问题三(最重要):`yudao.ai.timeout` ä¸æ˜¯ç«¯åˆ°ç«¯è€—时上限。**
配 `1s`,实测端到端 **7967ms**,日志里的异常是 `OpenAIIoException â†’ InterruptedIOException: timeout â† SocketTimeoutException`(okhttp3 HTTP/2)。`isTimeout()` è®¤å‡ºäº†å®ƒï¼Œæ‰€ä»¥æ˜ å°„到 `104_011` æ˜¯å¯¹çš„ â€”— **但 1 ç§’变成了 8 ç§’**。
读 `openai-java-core` æºç ï¼ˆ`RetryingHttpClient.kt` / `ClientOptions.kt`)确认了三个乘数:
1. **SDK é»˜è®¤ `maxRetries = 2`**,即最多 3 æ¬¡å°è¯•。`shouldRetry()` æ˜Žç¡®æŠŠ `IOException` ä¸Ž `OpenAIIoException` åˆ—为可重试 â€”— **超时本身就会被重试**。
2. é€€é¿ `min(0.5 Ã— 2^(n-1), 8s)` å†ä¹˜ jitter,两次间隔约 1.1~1.5s。
3. `OpenAiChatOptions.timeout` è®¾çš„æ˜¯**读/流超时**,不覆盖 okhttp çš„ `connectTimeout`(默认 10s)。握手阶段不吃这个预算。
⇒ ç”Ÿäº§é…çš„ `60s`,**用户可见的最坏等待约 3 åˆ†é’Ÿ**(60×3 + é€€é¿ + é‡è¿žï¼‰ï¼Œä¸æ˜¯ 60 ç§’。界面提示写的是"预计 10–60 ç§’",差 3 å€ã€‚文案本身没骗人(它读的是真实 `elapsed`,如实写了"已等 7 ç§’"),但**等待时长与用户预期脱节**。
这不是当初那条 `timeout` ä¿®å¤æ²¡ç”Ÿæ•ˆï¼Œè€Œæ˜¯ä¿®å¤ç”Ÿæ•ˆåŽæš´éœ²å‡ºçš„第二层:把 per-attempt è¶…时当成了整体超时。可选处置(各有取舍,待定夺):给这个模型关掉重试(`maxRetries(0)`,但会失去对流控 429 çš„弹性);或保持重试、把 YAML å€¼æŒ‰ `预期 Ã· 3` è®¾ï¼›æˆ–接受现状仅把界面文案改成"最坏约 3 åˆ†é’Ÿ"。
### 15.4 é™„带的实证:附件 bind ä¼šæ°¸ä¹…孤儿化旧 blob
跑 `verify-46-limit-branches.mjs` æ—¶æˆ‘漏掉了前端 `removeExistingSourceFiles` é‚£ä¸€æ­¥ï¼Œç›´æŽ¥è¿žç»­ `bind`,结果 **7 ä¸ª blob(含一个 21MB çš„)行与磁盘文件全部失去引用**。
链路:`saveStorageAttachmentByRecordTypeAndRecordId` åªè½¯åˆ **旧的附件关联行**,不碰 blob。轮到下一个 `stage()` å† bind æ—¶ï¼Œä¸Šä¸€æ‰¹é™„件行被软删,而它们指向的 blob å†æ²¡æœ‰ä»»ä½•未删除的附件行引用 â€”— æ²¡æœ‰ä»»ä½•机制会回收它。
**这坐实了 `memory/qc-report-platform.md` é‡Œé‚£æ¡è­¦å‘Šï¼Œå¹¶è¡¥ä¸Šå®ƒçš„æŽ¨è®ºï¼šé˜²å­¤å„¿çš„那道闸门在调用方(前端 `removeExistingSourceFiles`),存储层本身没有任何兜底。** å‰ç«¯ç›®å‰åšå¯¹äº†ï¼›ä½†ä»»ä½•绕过前端的调用方(含联调脚本)都会静默漏文件。残留已用产品自身的级联(绑到临时模板 â†’ åˆ æ¨¡æ¿ï¼‰æ¸…干净,DB ä¸Žç£ç›˜éƒ½æ ¸å¯¹è¿‡ã€‚
### 15.5 âš  ä»æœªéªŒè¯ï¼ˆè¯šå®žå£°æ˜Žï¼‰
- **`104_010 AI_UNAVAILABLE`**:需要把 `ai_api_key`/`ai_model` æ”¹åæ‰èƒ½è§¦å‘,会影响 ERP/CRM/MES çš„全部 AI è°ƒç”¨ï¼Œé£Žé™©å¤§äºŽæ”¶ç›Šã€‚它是 `104_011` åŒä¸€ä¸ª try/catch çš„兜底分支,异常收口路径已被超时用例证明可用。
- **`104_012 RESPONSE_UNPARSEABLE` / `104_013 DRAFT_EMPTY`**:需要模型返回畸形或空结果,无法确定性触发。两者都有纯单测覆盖(`QcReportAiDraftParser` ç½å¤´å­—符串 / `Normalizer` ç©ºç»“果),缺的只是端到端那一步。
- **AI è¯†åˆ«å‡†ç¡®çއ**:仍未度量(需要人工标注样本,是独立课题)。
- **Linux éƒ¨ç½²**:仍未验。
### 15.6 æ”¶å°¾çŠ¶æ€
配置已还原:`application-local.yaml` çš„ `pdf.enabled`、`ai-import.enabled` å›žåˆ° `true`,`yudao.ai.timeout` å›žåˆ° `60s`(三处临时改动都留了 `【#46 ä¸´æ—¶ã€‘` æ ‡è®°ï¼Œå·²å…¨éƒ¨æ’¤é”€ï¼‰ã€‚测试期间建的 5 ä¸ªä¸´æ—¶æ¨¡æ¿ï¼ˆid 16~20)与 1 ä¸ªå®žä¾‹ï¼ˆid 37)连同其附件、blob è¡Œã€ç£ç›˜æ–‡ä»¶å…¨éƒ¨åˆ é™¤ã€‚
### 15.7 Â§15.3 ä¸¤ä¸ªå†³å®šçš„落地(2026-09-19 ç»­ï¼‰
§15.3 çš„三个问题里,问题一(`@Size(max=3)` ä¸Ž YAML è€¦åˆï¼‰ä¸Žé—®é¢˜ä¸‰ï¼ˆ`timeout` æ˜¯ per-attempt)已按用户拍板处置;问题二的修法如下。
**决定一:超时不改代码,只改界面文案。**(问题三)
用户口径是"不改代码,只把界面文案改成「最坏约 3 åˆ†é’Ÿã€"。改动只有一处:
`mom-pro2-before/src/views/mes/qc/report/template/designer/modules/ai-import-modal.vue` çš„等待提示,由
> æ­£åœ¨è¯†åˆ«ï¼Œé¢„计 10–60 ç§’;文件页数或数量较多时更久,请勿关闭页面。
改为
> æ­£åœ¨è¯†åˆ«ï¼Œé¢„计 10–60 ç§’。扫描件按页逐页识别,每页单独一次调用,页数多时会明显更久;最长等待约 3 åˆ†é’Ÿï¼Œè¶…时后请减少文件数量或页数再试。请勿关闭页面。
**没有**动 `src/api/mes/qc/report/ai/index.ts` é‡Œé‚£æ¡ axios çš„ `timeout: 180_000` â€”— å®ƒæ°å¥½ä¸Ž"用户可见的 3 åˆ†é’Ÿ"同值,是诚实的。
> âš  **§15.3 é—®é¢˜ä¸‰çš„规模需要修正(汇报口径)**:3 åˆ†é’Ÿæ˜¯**单次 LLM è°ƒç”¨**的最坏值。而 `QcReportLlmCallPlanner` å¯¹æ‰«æä»¶æ˜¯**每页一次调用**,单请求上限 8 é¡µ â€”— åŽç«¯æœ€åå¯ä»¥è¿žç»­çƒ§ 8 è½®ã€çº¦ 24 åˆ†é’Ÿã€‚前端 180 ç§’先掐断,所以**用户可见的就是 3 åˆ†é’Ÿ**(文案没骗人),但**浏览器放弃之后后端仍在继续跑并继续计费**。这是本轮未处置的残留,应单独开一项。
**决定二:为损坏的 Office æ–‡ä»¶åŠ ä¸“å±žé”™è¯¯ç ã€‚**(问题二)
`1_070_104_015 AI_IMPORT_OFFICE_CORRUPT`:
> æ–‡ä»¶ã€ŒX」无法作为 .Y æ‰“开,内容已损坏或受密码保护。请先用 Office æ‰“开该文件,确认能正常显示后「另存为」.docx / .xlsx å†é‡æ–°å¯¼å…¥ï¼›è‹¥æ–‡ä»¶æœ‰æ‰“开密码,请先解除密码保护
改动三处:
| æ–‡ä»¶ | æ”¹åЍ |
|---|---|
| `enums/ErrorCodeConstants.java` | æ–°å¢ž `AI_IMPORT_OFFICE_CORRUPT = 1_070_104_015`(该段原止于 `_014`) |
| `service/aiimport/document/OfficeImportAdapter.java` | `readDocx` / `readXlsx` ä¸¤ä¸ª catch å—由抛 `FILE_EMPTY` æ”¹ä¸ºæŠ›æ–°ç  |
| `docs/qc_report_ai_import_frontend_integration.md` | é”™è¯¯ç è¡¨å¢žè¡Œï¼›`_005` çš„触发场景收窄;业务规则表增「空白与损坏分开报」一行 |
**边界刻意划清**:`text.isBlank()` é‚£æ¡åˆ†æ”¯ä»æŠ› `FILE_EMPTY`。语义是——"扩展名合法但内容为**空**"(如只有空白字符的 `.docx`)走 `_005`,"扩展名合法但文件**打不开**"(损坏、加密)走 `_015`。两者给用户的动作完全不同(前者换文件,后者修复文件),不能合成一条。
`mvn compile -pl yudao-module-qcreport -am -q` å·²é€šè¿‡ï¼ˆ`MVN_EXIT=0`)。新码的端到端复验见 Â§15.8。
### 15.8 `1_070_104_015` ç«¯åˆ°ç«¯å¤éªŒï¼š**4/4 PASS**(2026-09-19 ç»­ï¼‰
重启后端后跑 `D:/qcl-tmp/verify-104-015.mjs`,四条用例全绿,实据如下:
| ç”¨ä¾‹ | æœŸæœ›ç  | å®žæµ‹ç  | æŠ¥æ–‡ï¼ˆæˆªæ–­ï¼‰ |
|---|---|---|---|
| æŸåçš„ `.xlsx`(有 PK å¤´ã€å†…容不是 zip) | `1_070_104_015` | âœ… `1070104015` | æ–‡ä»¶ã€Œbroken.xlsx」无法作为 .xlsx æ‰“开,内容已损坏或受密码保护…**另存为** .docx / .xlsx å†é‡æ–°å¯¼å…¥ |
| æŸåçš„ `.docx`(有 PK å¤´ã€å†…容不是 zip) | `1_070_104_015` | âœ… `1070104015` | åŒä¸Šï¼Œæ‰©å±•名替换为 .docx |
| æ ¹æœ¬ä¸æ˜¯è¡¨æ ¼çš„ `.xlsx`(无 PK å¤´ï¼‰ | `1_070_104_015` | âœ… `1070104015` | åŒä¸Š |
| åªæœ‰ç©ºç™½çš„ `.txt`(回归) | `1_070_104_005` | âœ… `1070104005` | æ–‡ä»¶ã€Œblank.txt」未解析出任何可用内容(共 1 é¡µï¼‰â€¦ |
**两种损坏形态都归到 `_015`**(有 zip å¤´ä½†å†…容坏 / è¿ž zip å¤´éƒ½æ²¡æœ‰ï¼‰ï¼Œè¯´æ˜Ž catch è¦†ç›–面与预期一致;
空白文件仍走 `_005`,**边界没有被这次改动放宽**。每个用例配一个一次性模板并在断言后当场删除,
事后核对 `qc_report_template.id >= 10` å…± 21 è¡Œ**全部 `deleted=1`**、`qc_report_instance` å…¨éƒ¨ `deleted=1`、
`mes_qc_oqc` æ—  `ZZ_E2E_%` â€”— æ— æ´»åŠ¨æ®‹ç•™ã€‚
### 15.9 âš  ä»æœªéªŒè¯ï¼ˆè¯šå®žå£°æ˜Žï¼‰
- **前端文案改动未经 typecheck**:`ai-import-modal.vue` åªæ”¹äº†æ¨¡æ¿é‡Œçš„字符串,理论无类型影响,但未跑 `vue-tsc` ç¡®è®¤ï¼ˆåŸºçº¿ 316 æ¡å­˜é‡é”™è¯¯ï¼‰ã€‚
- å‰ç«¯æ–‡æ¡ˆä¸Ž `timeout: 180_000` çš„一致性只是**人工核对同值**,未做浏览器实测。
- Â§15.3 é—®é¢˜ä¸‰çš„æ®‹ç•™ä»åœ¨ï¼ˆæµè§ˆå™¨ 180 ç§’放弃后,后端仍继续跑完并继续计费),本轮未处置。
## åå…­ã€è´¨æ£€å• â†’ æŠ¥å‘Šå¯¹æŽ¥ï¼ˆ2026-09-19 ç»­ï¼‰â€”— âœ… ç«¯åˆ°ç«¯ **19/19** å…¨ç»¿ + å‰ç«¯ã€Œå‡ºæŠ¥å‘Šã€å…¥å£å·²æŽ¥å…¥å¹¶ UI éªŒæ”¶é€šè¿‡ï¼ˆå‰åŽç«¯ä¸€å¹¶è½åœ°ï¼‰
用户原话:**「报告模板设计还是没有和质检关联吧,现在不知道怎么导出数据」**。本节解决的就是这句:
让 MES çš„ IQC/IPQC/OQC/RQC å››ç±»è´¨æ£€å•能一键出成报告,前端**不再自己拼数据**。
### 16.1 å…­ä¸ªå£å¾„(用户拍板,本轮据此实现)
方案文档 Â§12 åˆ—了六个待定问题,用户的答复是:`1.a, 2.生成时弹提示, 3.全部进, 4.会, 5.只能选择对应类型模板, 6.允许`。
| # | é—®é¢˜ | ç»“论 | è½åœ°æ–¹å¼ |
|---|---|---|---|
| 1 | è´¨æ£€å•的「特采 / ä¸åˆæ ¼é€€è´§ / ä¸åˆæ ¼æŠ¥åºŸã€å››å€¼åˆ¤å®šï¼ŒæŠ¥å‘Šçš„「合格/不合格」三态装不下 | **A:另存两个字段** | `ReportFields` åŠ  `qcResult` / `qcResultText`,与引擎判定**并存互不覆盖** |
| 2 | æ²¡æœ‰è§„格上下限的项怎么办 | **生成时弹提示**,不回填数据 | å“åº”带 `undecidableCount`,前端必须提示 |
| 3 | æ£€éªŒæŒ‡æ ‡æ˜¯å¦è¦è¿‡æ»¤ | **全部进**,不加 `judge_flag` è¿‡æ»¤ã€ä¸åŠ åˆ— | æ˜ å°„器不做指标筛选 |
| 4 | ä¸€ä¸ªæŒ‡æ ‡å¤šæ¡å®žæµ‹å€¼ï¼ˆå¤šæ ·å“ï¼‰æ€Žä¹ˆåŠž | **会:一条(样品 Ã— æŒ‡æ ‡ï¼‰ä¸€æ¡æŠ¥å‘Šé¡¹** | æ ·å“æ•° > 1 æ—¶æŠŠã€Œæ ·å“ X」折进 `remark`,不丢数据 |
| 5 | æ¨¡æ¿ç±»åž‹è¦ä¸è¦æ ¡éªŒ | **只能选对应类型模板** | åŽç«¯ `105_005` æ‹’绝(两边中文名都给),前端按 `reportType` è¿‡æ»¤ |
| 6 | åŒä¸€å¼ å•据能否出多份报告 | **允许**(历史留痕需要) | ä¸åšé‡å¤ç”Ÿæˆæ‹¦æˆª |
### 16.2 äº¤ä»˜ç‰©
**新增(MES ä¾§ï¼Œ`yudao-module-mes-api` / `yudao-module-mes`)**
| æ–‡ä»¶ | è¯´æ˜Ž |
|---|---|
| `api/qc/MesQcReportApi.java` | `getQcReportData(qcType, qcId)`,四类单据统一入口 |
| `api/qc/dto/MesQcReportRespDTO.java` | å•头 + `items` + `groupItemNames` + `missingIndicatorCount` + `finished` + `statusName` |
| `api/qc/dto/MesQcReportItemRespDTO.java` | å•条检验项(含 `itemsIndex` ç­‰ï¼‰ |
| `api/qc/MesQcReportApiImpl.java` | è£…配质检单四层结构(单头 / åˆ¤å®šä¾æ®è¡Œ / æ ·å“ / å®žæµ‹å€¼æ˜Žç»†ï¼‰ |
**新增(qcreport ä¾§ï¼‰**
| æ–‡ä»¶ | è¯´æ˜Ž |
|---|---|
| `enums/QcReportSourceTypeEnum.java` | å››ç±»å•据的枚举,带 `businessType` è¡¨åä¸Ž `matchesTemplateReportType` |
| `service/instance/MesQcReportContextMapper.java` | **纯函数**映射器:`MesQcReportRespDTO` â†’ `ReportContext` + `warnings` |
| `service/instance/GenerateFromQcResult.java` | å‡ºä»¶ç»“æžœ record |
| `controller/admin/instance/vo/QcReportInstanceGenerateFromQc{Req,Resp}VO.java` | å‡ºå…¥å‚ |
| `service/instance/MesQcReportContextMapperTest.java` | **12 æ¡çº¯å•测** |
**改(qcreport ä¾§ï¼‰**
| æ–‡ä»¶ | æ”¹åЍ |
|---|---|
| `pom.xml` | åŠ  `yudao-module-mes-api` ä¾èµ– |
| `enums/ErrorCodeConstants.java` | æ–°å¢ž `1_070_105_000`~`_006` å…­æ¡ |
| `engine/context/ReportFields.java` | åŠ  `qcResult` / `qcResultText` ä¸¤å­—段(`copy()` åŒæ­¥ï¼‰ |
| `engine/context/ReportContext.java` | `reportMap()` å¢žåŠ ä¸¤ä¸ª key |
| `engine/context/ReportContextCodec.java` | `reportOf()` å¢žåŠ ä¸¤ä¸ªå­—æ®µ â€”— **漏了这处 `regenerate` ä¼šé™é»˜ä¸¢å­—段** |
| `service/instance/QcReportInstanceService(+Impl).java` | `generateFromQc` + å››ä¸ªæ ¡éªŒå™¨ |
| `controller/admin/instance/QcReportInstanceController.java` | `POST /generate-from-qc` |
### 16.3 å››æ¡åˆ»æ„å–舍
**取舍一:不新开落库路径,内部就调 `generate`。**
`generateFromQc` æ ¡éªŒå®Œå°±ç»„装一个 `QcReportInstanceGenerateReqVO` è°ƒæ—¢æœ‰çš„ `generate`。
于是版本发布校验、编号生成与撞号重试、数据快照冻结、判定引擎**全部照旧生效,一条都没绕开**。
另写一条落库链路等于把五道闸门复制一遍,而副本一定会漂移。
**取舍二:校验按业务优先级只报第一处。**
类型 â†’ å•据存在 â†’ å·²å®Œæˆ â†’ å·²åˆ¤å®š â†’ æ¨¡æ¿ç±»åž‹ â†’ æœ‰æ£€éªŒé¡¹ã€‚一次抛多条会让用户抓不住重点
(`error-message-precision.md` æ˜Žç¡®è¦æ±‚)。六条文案都做到了「具体维度 + åŒæ–¹å®žé™…值 + å¯æ‰§è¡ŒåŠ¨ä½œã€ï¼Œ
且**两边值都是业务中文名**(「报告模板的报告类型是「出货检验」,与本次质检单的类型「来料检验」不一致」),
不是裸 ID。
**取舍三:`105_004`(模板不存在)与 `105_007`(模板无已发布版本)不实现。**
方案文档 Â§7 åˆ—了这两条,实际改为**复用既有的** `TEMPLATE_NOT_EXISTS` /
`TEMPLATE_STATUS_DISABLED` / `TEMPLATE_NO_PUBLISHED_VERSION`。那几条文案本来就是精确的,
再造一遍只会多两条会漂移的副本。**这是与方案文档的显式偏离,已在联调方案里写明。**
**取舍四:`sampleStrategy` å‚数不做。**
方案文档原本设想让调用方选「多样品怎么处理」,用户口径 4(「会」)直接定死了一件事,
选择权没必要暴露给前端,参数删掉。
### 16.4 `undecidableCount` æ€Žä¹ˆç®—(不重算判定条件)
数**快照里 `result` ä¸ºç©ºä¸²**的项:
```java
ReportContext frozen = ReportContextCodec.fromMap(result.instance().getDataSnapshot());
int undecidable = 0;
for (InspectionItem item : frozen.getInspectionItems()) {
    if (StrUtil.isBlank(item.getResult())) { undecidable++; }
}
```
「`result` ä¸ºç©ºã€æ­£æ˜¯å¼•擎对「待判定」的定义(`ReportEvaluator` æ‹¿ä¸åˆ° verdict æ—¶ `applyResult(null)`),
所以含义天然一致,还顺带把「卡在规则报错上」的项算进去。**不另写一遍判定条件** â€”—
那样既可能与引擎各说各话,又会漏掉规则报错这一类。
### 16.5 å®žæµ‹è¯æ®
**已验证**
| é¡¹ | ç»“æžœ |
|---|---|
| å…¨é¡¹ç›®ç¼–译 `mvn compile -q` | âœ… é€šè¿‡ï¼ˆè·¨æ”¹äº† `yudao-module-ai` / `-system` / `-mes-api` / `-mes` / `-qcreport`) |
| `mvn -pl yudao-module-qcreport test` | âœ… **Tests run: 106, Failures: 0, Errors: 0** â€” BUILD SUCCESS |
| å…¶ä¸­ `MesQcReportContextMapperTest` | âœ… 12/12 |
| å…¶ä¸­ `FrontendConformanceTest`(冻结对拍) | âœ… 3/3(**证明 `ReportFields` åŠ å­—æ®µæ²¡æœ‰ç ´åå‰ç«¯å†»ç»“äº§ç‰©**) |
| `ReportContextCodecTest` | âœ… 7/7 |
| å­˜é‡ç”¨ä¾‹ï¼ˆåˆ¤å®š 23 / AI å¯¼å…¥ 42 / PDF 9 / å‡ºä»¶ 7 â€¦ï¼‰ | âœ… å…¨éƒ¨ä»ç»¿ |
`MesQcReportContextMapperTest` çš„ 12 æ¡è¦†ç›–:单头映射(含 IQC å– `vendorBatch` ä½œæ‰¹æ¬¡å·ã€
日期 `2026-09-17 14:30:05` æ ¼å¼ã€`qcResult="2"`/`qcResultText="特采"`);引擎字段不被映射器污染;
缺上下限时**保持 null è€Œéž 0**(显式注释:变成 0 çš„话实测值 0.09 ä¼šè¢«åˆ¤æˆã€Œè¶…上限不合格」);
单样品不写 `remark`、多样品写「样品 S1」、超 3 ä¸ªæ ·å“ç•™ç©º + warning;未录实测值 warning;
分组标题与已删指标的 warning;日期格式**与 locale æ— å…³**(用 `th-TH-u-ca-buddhist-nu-thai` éªŒï¼‰ã€‚
**未验证(诚实声明)**
- ä¸Ž Â§16.9 åŒä¸€æ¡ï¼š**只验了 IQC ä¸€è·¯**,IPQC / OQC / RQC ä¸‰é¡µçš„æŒ‰é’®ä¸Žå¼¹çª—未逐一点过(四页代码同构,
  å·®å¼‚只有 `QC_TYPE` å¸¸é‡ä¸Žè¡Œæ“ä½œé”šç‚¹ï¼‰ã€‚
### 16.6 ç«¯åˆ°ç«¯ HTTP è”è°ƒ â€”— âœ… **19/19 PASS**
用户重启后端后跑 `node D:/qcl-tmp/verify-qc-generate.mjs`,**19 é¡¹æ–­è¨€å…¨éƒ¨é€šè¿‡**:
| # | ç”¨ä¾‹ | ç»“æžœ |
|---|---|---|
| 1 | `qcType=9` | âœ… `105_000`,报文含「只支持 IQC」 |
| 2 | `qcId=999999` | âœ… `105_001`,报文含该 ID |
| 3 | çœŸå®žå•据 1(草稿) | âœ… `105_002`,报文含「草稿」 |
| 4 | ç½® status=4 + check_result=NULL | âœ… `105_003`,报文含「尚未填写检验判定」 |
| 5 | æ¥æ–™å• + å‡ºè´§æ¨¡æ¿ | âœ… `105_005`,报文**同时**含「出货检验」与「来料检验」 |
| 6 | IQC happy path | âœ… `itemCount=14` / `undecidableCount=14`;含「3 ä¸ªæŒ‡æ ‡â€¦åªä½œåˆ†ç»„标题」;DB `business_type/business_id = 'mes_qc_iqc\t1'`;快照 `qcResult='1'`/`qcResultText='合格'`/`resultText='待判定'` |
| 7 | OQC å¤¹å…·ï¼ˆæ¨¡æ¿ 5 å¸¦ item çº§è§„则) | âœ… 3 é¡¹å…¨åˆ¤ï¼ˆ`undecidableCount=0`),快照带真实批次/客户/passRate,渲染 HTML å«å®žæµ‹å€¼ |
| 7b | OQC å¤¹å…· + **无规则临时模板** | âœ… `itemCount=4` / `undecidableCount=1`;**按指标名定位**:色度=PASS(30)、密度=PASS(850)、折光率=FAIL(1.3993)、外观=待判定 â€”— **这条是本轮最关键的证据:规格上下限分支确实在用真实 MES æ•°æ®ç®—** |
| 8 | åæŸ¥ `businessType=mes_qc_iqc&businessId=1` | âœ… æŸ¥å¾—到用例 6 çš„实例 |
| 9 | åªæœ‰åˆ†ç»„标题指标的 OQC å¤¹å…· | âœ… `105_006`,报文含「分组标题」 |
| 10 | å¯¹ç”¨ä¾‹ 7 çš„实例导 PDF | âœ… `code=0` ä¸” `byteSize>1000` |
| 11 | æ¸…理后无残渣 | âœ… å®žä¾‹ / å¤¹å…· / ä¸´æ—¶æ¨¡æ¿ / é™„件全部归零(**已按 `deleted=0` ç»Ÿè®¡**) |
**调试过程中的 5 ä¸ªã€Œå¤±è´¥ã€å…¨æ˜¯è„šæœ¬æ–­è¨€å†™é”™ï¼Œä¸æ˜¯äº§å“ç¼ºé™·**,逐条查库证伪后修正:
1. ã€Œè·³è¿‡åˆ†ç»„标题有提示」的 regex å†™å¾—太死(`/3 ä¸ªæŒ‡æ ‡åªä½œåˆ†ç»„标题/`),实际报文中间隔了「在质检单里」,
   æ”¹æˆ `/3 ä¸ªæŒ‡æ ‡.*只作分组标题/`。
2. **用例 7 çš„æœŸæœ›æœ¬èº«é”™äº†**:模板 5 v1.1 é‡Œå¸¦äº†ä¸€æ¡ item çº§è§„则
   ï¼ˆ`item.actualValue >= item.lowerLimit AND item.actualValue <= item.upperLimit`),
   å®ƒ**优先于**规格上下限分支,所以 3 é¡¹å…¨è¢«åˆ¤äº†ã€‚断言改名为它真正证明的事,并**新增用例 7b** ç”¨
   æ— è§„则的临时模板去真正隔离「规格上下限」这条分支。
3. ã€Œæ¸²æŸ“产物带真实数据」失败:`renderHtml` é‡Œæœ‰ `850`,但没有批次号/客户名。抽出占位符清单后确认
   æ¨¡æ¿ 5 v1.1 çš„画布**只绑了 `{{report.reportNo}}` ä¸Žé¡¹çº§å­—段**,压根没绑 `{{report.batchNo}}` /
   `{{report.customerName}}` â€”— æ˜¯æ¨¡æ¿å†…容问题,不是后端漏传,故把这两项断言移到快照(快照里确实有)。
4. ã€Œæ— æ®‹æ¸£ã€å¤±è´¥ï¼šæˆ‘自己的查询漏了 `AND deleted=0`,两条软删实例被算成残渣(这正是本项目
   æ–‡æ¡£é‡Œè®°è¿‡çš„坑)。补上条件后归零。
5. é¡ºå¸¦æ ¸å®ž `storage_blob_id=20` é‚£æ¡é™„件是**上一轮 AI å¯¼å…¥æŒ‰è®¾è®¡ç»‘定到模板 5 çš„æºæ–‡ä»¶**,不是本轮残留。
### 16.7 å‰ç«¯æŽ¥å…¥ï¼ˆç”¨æˆ·æŽˆæƒã€Œå‰åŽç«¯ä¸€èµ·æ”¹ã€åŽè½åœ°ï¼‰
| æ–‡ä»¶ | æ”¹åЍ |
|---|---|
| `src/components/quality/engine/context.ts` | `ReportContextReport` åŠ  `qcResult` / `qcResultText`,`emptyReport()` è¡¥ç©ºä¸²é»˜è®¤å€¼ï¼ˆå·²ç¡®è®¤ `emptyReport()` æ˜¯å”¯ä¸€æž„造点,改必填安全) |
| `src/api/mes/qc/report/instance/index.ts` | åŠ  `GenerateFromQcReq` / `GenerateFromQcResult` ä¸¤ä¸ª interface + `generateInstanceFromQc()` |
| `src/views/mes/qc/components/report-generate-modal.vue` | **新增**,四个质检单页共用:选模板(按 `reportType` è¿‡æ»¤ + åªç•™æœ‰ç”Ÿæ•ˆç‰ˆæœ¬çš„)→ ç”Ÿæˆ â†’ å°±åœ°å±•示结果与告警 â†’ ã€ŒæŸ¥çœ‹æŠ¥å‘Šåˆ—表」 |
| `src/views/mes/qc/{iqc,ipqc,oqc,rqc}/index.vue` | å„加 5 å¤„:import å¼¹çª—、`QC_TYPE` å¸¸é‡ + `useVbenModal`、`handleReport`、`<ReportModal />`、行操作「出报告」(`ifShow: status === å·²å®Œæˆ`) |
| `src/views/mes/qc/report/instance/data.ts` | æœç´¢è¡¨å•加「业务单据」字段(`businessId`,**后端是精确匹配**,placeholder å·²å†™æ˜Žï¼‰ |
| `src/views/mes/qc/report/instance/index.vue` | `onMounted` è¯»è·¯ç”± query é¢„å¡« `businessType` / `businessId` |
**两个刻意的处理**
- **弹窗标题不写质检类型中文名**(该做法保留):`mes_qc_rqc` è¿™ä¸€è·¯æ›¾æŽªè¾žä¸ä¸€è‡´â€”—后端枚举、表注释、
  èœå•权限子项写「退货检验」,菜单父项与 `mes_qc_type` å­—典写「退料检验」。**本轮已统一为「退货检验」**(见 Â§17.3)。
- **`catch {}` é‡Œä¸å†å¼¹æç¤º**:后端失败报文已由请求拦截器(`preset-interceptors.ts` æŠ›é”™ â†’
  `api/request.ts` çš„ `errorMessageResponseInterceptor`)自动 toast,业务代码再弹一次会出现两条一样的报错。
**权限码入库**:`system_menu` æ–°å¢ž `id=1075416`「报告实例质检单出件」,
`permission=qc-report:instance:generate`,`type=3`,`sort=5`,`parent_id=1075410`(报告实例)。
**不需要重新生成合并脚本** â€”— `config_export_all_20260918.sql` é˜¶æ®µäºŒæ˜¯æ‰§è¡Œæ—¶è·¨åº“
`INSERT INTO system_menu SELECT * FROM ruoyi-vue-pro.system_menu`,新行会自然被带过去。
### 16.9 å‰ç«¯ UI éªŒæ”¶ï¼ˆPlaywright,IFC ä¸€è·¯èµ°é€šï¼‰
用 Playwright MCP å¯¹ `http://localhost:5666` çœŸç‚¹äº†ä¸€éï¼Œç»“论:
| éªŒæ”¶ç‚¹ | ç»“æžœ |
|---|---|
| è‰ç¨¿å•**不显示**「出报告」 | âœ… å”¯ä¸€é‚£å¼  IQC å• `status=0` æ—¶ï¼Œè¡Œæ“ä½œåªæœ‰ ä¿®æ”¹/删除/详情,无「出报告」——`ifShow: status === å·²å®Œæˆ` ç”Ÿæ•ˆ |
| å·²å®Œæˆå•**只显示**「出报告」 | âœ… ä¸´æ—¶æŠŠ `status=4` åŽï¼Œä¿®æ”¹/删除消失、「出报告」出现(两者条件互斥,符合设计) |
| å¼¹çª—标题 | âœ… `出报告 - IQC_20260917001`(**不带类型中文名**,规避 RQC æŽªè¾žåˆ†æ­§ï¼‰ |
| æ¨¡æ¿**预过滤** | âœ… åº“里 3 å¼ æ¨¡æ¿ï¼Œåªåˆ—出 1 å¼ å¯é€‰é¡¹ã€Œæ¥æ–™æ£€éªŒæŠ¥å‘Šæ¨¡æ¿ v1.0」——模板 3(同类型但 `current_version` ä¸ºç©ºï¼‰ä¸Žæ¨¡æ¿ 5(OQC ç±»åž‹ï¼‰éƒ½è¢«æ­£ç¡®æŽ’除 |
| æœªé€‰æ¨¡æ¿æ—¶ | âœ… ã€Œç”ŸæˆæŠ¥å‘Šã€ç½®ç° |
| ç»“果就地展示 | âœ… `报告已生成:QR20260919-0012` + `模板版本 v1.1;写入检验项 14 æ¡` + **黄色告警「本次有 14 é¡¹ç¼ºå°‘规格上下限与规则,判定为「待判定」」** + ã€Œæ•°æ®å–舍提示(1)」+「不落库」说明 â€”— **这正是口径 2 è¦æ±‚的效果** |
| è·³è½¬ + ç­›é€‰ | âœ… è·³åˆ° `/qc/report-instance?businessType=mes_qc_iqc&businessId=1`,搜索表单预填「来料检验」+ ä¸šåŠ¡å•æ® `1`,列表**共 1 æ¡è®°å½•**(就是刚出的那份) |
**这轮验收抓到一个前端真实缺陷(已修)**:最初我用 `formApi.setFieldValue` é¢„填搜索条件,
读 `use-vxe-grid.vue` æ‰ç¡®è®¤è¡¨æ ¼å–搜索条件是读 `formApi.getLatestSubmissionValues()`
(`extends.ts` çš„ `extendProxyOption` åŒ…装了 ajax query,只吃**已提交**的值),
**只 set ä¸ submit çš„话条件填进表单但列表仍查全量**——等于没筛。
已改为 `setValues` + `submitForm()`,并加了注释说明为什么必须跟着 submit。
**顺带发现一处存量数据不一致(不是本轮引入,本轮已修 â†’ Â§17.2)**:模板 1 `QC_IQC_DEMO` çš„
`current_version = 'v1.0'`,但版本 v1.0 æ˜¯**已停用(status=2)**、真正已发布的是 v1.1。
于是**用模板 1 å‡ºæŠ¥å‘Šå¿…然失败**(`1_070_101_003 æ¨¡æ¿ç‰ˆæœ¬æœªå‘布,无法生成报告`),
而弹窗又会把它列成可选项(前端只判 `currentVersion` éžç©ºï¼Œæ¨¡æ¿åˆ†é¡µæŽ¥å£ä¸è¿”回该版本的状态,**前端无从判断**)。
当轮只临时改成 v1.1 èµ°å®ŒéªŒæ”¶ï¼Œ**已还原为 v1.0**,未擅自改数据。
验收过程临时改动全部已还原:IQC å• 1 æ¢å¤ `status=0` / `check_result=NULL`,
模板 1 æ¢å¤ `current_version='v1.0'`,生成的实例 `QR20260919-0012` å·²ç¡¬åˆ é™¤ï¼ˆ`qc_report_instance` è®¡æ•° 0)。
### 16.10 ä¸‹ä¸€æ­¥
1. ä¸Šä¸€è½®é—留(不属本节):`blobIds @Size(max=3)` ä¸Ž YAML `max-files` çš„耦合、
   `@Disabled` çœŸå®ž AI è”调测试。(「后端仍在烧 AI è°ƒç”¨ã€å·²åœ¨ Â§17.3 æ”¶å£ã€‚)
2. ~~模板 1 çš„ `current_version` æ•°æ®ä¸ä¸€è‡´~~ â†’ å·²åœ¨ Â§17.2 ä¿®å¤ã€‚
3. ~~RQC æŽªè¾žåˆ†æ­§~~ â†’ å·²åœ¨ Â§17.3 ç»Ÿä¸€ä¸ºã€Œé€€è´§æ£€éªŒã€ã€‚
4. IPQC / OQC / RQC ä¸‰é¡µçš„ UI æœªé€ä¸€ç‚¹è¿‡ï¼ˆåŒæž„代码,风险低)。
---
## åä¸ƒã€ä¼˜åŒ–轮:弹窗过滤 / è¯¯åˆ ä¸Šä¼ æ–‡ä»¶ / AI æ€»é¢„ç®— / ä¸¤å¤„待裁决项
四项一次性做完,均为对既有能力的补强或缺陷修复,无新增业务对象。
### 17.1 å‡ºæŠ¥å‘Šå¼¹çª—补「当前版本已发布」校验
**问题**:弹窗只按 `reportType` + æ¨¡æ¿ `status=启用` æŸ¥æ¨¡æ¿åˆ†é¡µï¼Œå†åœ¨å‰ç«¯åˆ¤ `currentVersion` éžç©ºã€‚
但「当前版本是否已发布」是**版本表**上的状态,分页接口不返回;而**停用一个版本不会清空模板的 `currentVersion`**,
于是 `currentVersion` æœ‰å€¼ã€é‚£ä¸ªç‰ˆæœ¬å·²åœç”¨çš„æ¨¡æ¿ç…§æ ·è¢«åˆ—出,选中后必然报 `1_070_101_003`。
模板 1 å°±æ˜¯è¿™ç§ï¼ˆè§ Â§16.9)。
**改法**:新增 `GET /qc-report/template/selectable?reportType=`(权限 `qc-report:template:query`),
在**后端**一次性做三重过滤:模板 `report_type` ä¸€è‡´ â†’ æ¨¡æ¿ `status=启用` â†’ `current_version` æŒ‡å‘的版本 `status=已发布`。
两条 SQL è§£å†³ï¼Œæ—  N+1(先查模板,再用 `templateId IN (...)` æ‰¹é‡æŸ¥å·²å‘布版本,在内存里按 `templateId@version` æ±‚交集)。
前端改用这个接口,不再自己推导规则。
**为什么不把版本状态塞进模板分页的响应**:那要在列表里对每行再查一次版本表(N+1),
而且「版本必须已发布」这条规则会跑到前端去,成为第二个真相来源。
**顺带记录一个代码层的洞(本轮未改,需决策)**:`QcReportTemplateVersionServiceImpl.disableVersion`
停用版本时**不清空 `template.currentVersion`**,所以「停用当前版本」这个操作本身就能造出上面那种不一致数据。
本轮只挡住了「用户看到并选中它」,没堵住「产生它」。要不要在停用当前版本时直接拒绝
(提示「请先把当前版本回滚到另一个已发布版本」)是业务口径问题,未擅自决定。
### 17.2 æ¨¡æ¿ 1 æ•°æ®ä¿®å¤
`qc_report_template` id=1:`current_version` ç”± `v1.0`(status=2 å·²åœç”¨ï¼‰æ”¹ä¸º `v1.1`(status=1 å·²å‘布)。
改后模板 1 æ—¢èƒ½è¢«æ–°æŽ¥å£åˆ—出、也真的能出报告。这是数据修复,不改代码。
### 17.3 RQC æŽªè¾žç»Ÿä¸€ä¸ºã€Œé€€è´§æ£€éªŒã€
原先的分歧面:
| ä½ç½® | åŽŸæ–‡ |
|---|---|
| `MesQcTypeEnum.RQC.name`、`QcReportSourceTypeEnum.RQC.name`、`MesQcReportContextMapper` ç›¸å…³æ–‡æ¡ˆ | é€€è´§æ£€éªŒ |
| `mes_qc_rqc` è¡¨æ³¨é‡Šã€`system_menu` id=5671~5675(权限子项) | é€€è´§æ£€éªŒ |
| `system_menu` id=5670(菜单父项)、`system_dict_data` id=3124 çš„ label | é€€æ–™æ£€éªŒ |
| å‰ç«¯ `instance/data.ts` çš„ `BUSINESS_TYPE_LABEL`、`pendinginspect/index.vue` ä¸¤å¤„ | é€€æ–™æ£€éªŒ |
**统一到「退货检验」**,理由:① å®ƒæ˜¯å¤šæ•°æ´¾ï¼ˆåŽç«¯ Java æžšä¸¾é›¶å¤„出现「退料检验」);
② RQC çš„对象同时覆盖生产退料、销售退货、售后退货,「退货检验」是能覆盖三者的说法,「退料检验」只覆盖其一;
③ `system_dict_data` id=3124 çš„ `remark` æœ¬æ¥å°±å†™ç€ã€Œé€€è´§è´¨é‡æ£€éªŒã€ï¼Œè¯´æ˜Žã€Œé€€æ–™ã€æ˜¯ label å•独写岔的。
**改动点**:`system_menu` id=5670 çš„ `name`、`system_dict_data` id=3124 çš„ `label`、
前端 `instance/data.ts` + `pendinginspect/index.vue`(`name` å‰ç½®ä¸²ä¸Žè¡Œæ“ä½œ label)、
`docs/sql/config_export_all_20260918.sql` é‡Œ id=3124 çš„字面量。
> èœå•名改动**不需要重新生成合并脚本**:阶段二是执行时跨库 `INSERT INTO system_menu SELECT * FROM ...`。
> å­—典是阶段一的字面量,所以手工改了那一行以保持一致(只改 label ä¸€ä¸ªå­—面量,不必整份重导)。
### 17.4 AI å¯¼å…¥åŠ è¯·æ±‚çº§æ€»è€—æ—¶é¢„ç®—
**问题**:`max-files` / `max-pages-per-file` / `max-pages-per-request` åªçº¦æŸè°ƒç”¨**次数**,不约束**时长**。
模型排队时每次调用都能耗满 `yudao.ai.timeout`(60s),8 æ¬¡å åŠ èƒ½æŠŠä¸€æ¬¡è¯¯æ“ä½œæ‹–æˆ 8 åˆ†é’Ÿçš„付费调用;
而前端 axios 180s å°±æ–­äº†ï¼Œç”¨æˆ·åœ¨æ–­çº¿åŽå®Œå…¨ä¸çŸ¥é“后端还在跑。
**改法**:新增 `yudao.qcreport.ai-import.max-duration-seconds`(默认 180,两个 profile åŒæ­¥ï¼‰ã€‚
在**两次模型调用之间**检查已耗时,超了就停止后续调用:
- å·²è¯†åˆ«åˆ°çš„部分**照常返回**,并在 `warnings` é‡Œæ˜Žå†™ã€Œå·²è¾¾åˆ°å•次导入耗时上限(180 ç§’,已用时 X ç§’),
  åŽç»­å†…容未再识别」——这是显式截断,不是静默截断,与模块既有口径一致。
- å¦‚果一个组件都没识别出来,改抛新错误码 `AI_IMPORT_AI_BUDGET_EXCEEDED`(`1_070_104_016`),
  è€Œä¸æ˜¯ `AI_IMPORT_DRAFT_EMPTY`——两者用户要采取的动作完全不同(前者分批,后者改用手工设计)。
- è¿›æ¯ä¸ªæ–‡ä»¶ä¹‹å‰ä¹Ÿæ£€æŸ¥ä¸€æ¬¡ï¼Œè¶…了就不再读下一份、不再渲染它的页(省掉白花的 CPU ä¸Žå†…存)。
**为什么只能在两次调用之间关门**:`AiChatApi` ä¸æŽ¥å—超时参数,单次调用无法中途打断,
所以最坏会多出一个 `yudao.ai.timeout`。因此**前端 axios è¶…时从 180s æåˆ° 300s**
(180 é¢„ç®— + 60 å•次超时 = 240 æœ€åï¼Œç•™ 60s ä½™é‡ï¼‰â€”—原先 180s åè€Œæ¯”后端最坏情况更短,
用户会先看到浏览器断开而不是后端那条讲清原因的提示。取 180 ç§’作为预算:
常规 8 æ¬¡ä»¥å†…的调用都跑得完,只有模型明显变慢时才触发。
### 17.5 ä¿®å¤ ERP / CRM ä¸¤å¤„误删用户上传文件
**问题**:`ErpPurchaseInvoiceAiServiceImpl` ä¸Ž `CrmSaleQuotationAiServiceImpl` åœ¨ `finally` é‡Œ
`tempFile.delete()`,而那个 `File` æ¥è‡ª `SystemStorageBlobService.getPublicFile(...)` â€”—
**它是磁盘上真实存储的那份 blob,不是临时副本**。等于每次 AI è¯†åˆ«å®Œå°±æŠŠç”¨æˆ·ä¸Šä¼ çš„原始发票/报价文件删了。
**改法**:去掉整个 `finally` åˆ é™¤å—,变量名 `tempFile` æ”¹ä¸º `blobFile` å¹¶åŠ ä¸€è¡Œæ³¨é‡Šè¯´æ˜Žã€Œè¿™ä¸æ˜¯ä¸´æ—¶å‰¯æœ¬ï¼Œè¯»å®Œä¸èƒ½åˆ ã€ã€‚
命名误导正是这个缺陷的成因,留着容易再犯。
> è®°çš„「三处」实为**两处**:`MesQcAiServiceImpl` æ˜¯çº¯æ–‡æœ¬åˆ¤å®šå»ºè®®ï¼ˆå…¥å‚是 VO,不碰文件),没有这个缺陷。
### 17.6 æœ¬è½®æœªåš
- `disableVersion` åœç”¨å½“前版本时的拦截(见 Â§17.1 æœ«ï¼‰â€”—需业务口径。
- AI å¯¼å…¥çš„ `blobIds @Size(max=3)` ä¸Ž YAML `max-files` çš„耦合。
- `@Disabled` çš„真实 AI è”调测试。
- IPQC / OQC / RQC ä¸‰é¡µçš„ UI é€ä¸€ç‚¹éªŒï¼ˆåŒæž„代码)。
## åå…«ã€ä¼˜åŒ–轮(二):画布写入侧校验 / åœç”¨å½“前版本拦截 / ç©ºç”»å¸ƒåˆ¤æ® / ä¸‰å¤„收尾(2026-09-19 ç»­ï¼‰
四项均是 Â§17.6 é—留项的落地或本轮新发现的缺陷修复,无新增业务对象、无建表、无渲染引擎改动。
**全部为后端改动,本轮未动任何前端文件。**
### 18.1 ç”»å¸ƒå†™å…¥ä¾§å®‰å…¨æ ¡éªŒï¼ˆæ–°å¢ž `CanvasSafety`)
**问题**:报告产物是拼字符串拼出来的,模板画布是用户可编辑数据。查 `HtmlRenderer` åŽç¡®è®¤æ³¨å…¥é¢**比预想的多两处**,
一共三处内容会**不经转义**进入产物:
| æ‹¼æŽ¥ç‚¹ | ä½ç½® | åŽæžœ |
|---|---|---|
| èŠ‚ç‚¹ `tagName` ç›´æŽ¥æ‹¼æˆ `<tag>` | `resolveTag` | å¯å†™ `<script>`;写 `"img src=x onerror=alert(1)"` è¿˜èƒ½æ•´æ®µå¡žè¿›ä¸€ä¸ªäº‹ä»¶å±žæ€§ |
| å±žæ€§**名**直接拼上(只有属性**值**转义) | `renderAttributes` | ä¸€ä¸ªå«å¼•号的属性名就能把后面的内容顶成新属性 |
| `styles` å†™æˆå­—符串时**原样**塞进 `<style>` | `buildCss` | å‡ºçް `</style>` å³æå‰é—­åˆï¼ŒåŽé¢å˜æˆçœŸ HTML |
> åŽŸè®¡åˆ’çš„â‘£åªæäº† `tagName` ä¸€æ¡ã€‚读渲染器后按实际注入面扩成三条——少堵两条,白名单就等于没做。
**改法**:新增 `engine/render/CanvasSafety.validate(grapes) â†’ List<String>`,在 `validateSchema` é‡Œè°ƒç”¨
(`createVersion` ä¸Ž `updateVersion` ä¸¤æ¡å…¥å£å…±ç”¨åŒä¸€æ®µæ ¡éªŒï¼Œä¸åˆæ ¼**一律不落库**):
- æ ‡ç­¾èµ°**白名单**(常规排版与文本标签),不用黑名单——黑名单永远漏,而报告排版用得到的标签是可枚举的;
- å±žæ€§åæŒ‰å­—符集校验 + `on` å‰ç¼€åˆ¤äº‹ä»¶å±žæ€§ï¼›
- `styles` å¿…须是规则数组,且 `selectors` / `mediaText` / æ ·å¼å±žæ€§åä¸Žå€¼é‡Œå‡ºçް `<` å³æ‹’绝;
- ä¸€æ¬¡æœ€å¤šæŠ¥ **8 æ¡**,每条带「位置 + è¿è§„内容」,多条以 `;` è¿žæŽ¥ã€‚
**为什么在保存时卡,而不是在渲染时卡**:渲染链路上的 `HtmlRenderer` ä¸Žå‰ç«¯ `engine/render.ts` å¿…é¡»**逐字一致**,
改它就要重算对拍产物、并让存量报告的 `regenerate` ä¸å†é€å­—复现。「入站内容是否可信」本来就是写入侧的问题,
在数据进库前拦掉,渲染侧可以继续与前端完全对称。代价是**对校验上线前已入库的脏数据没有兜底**。
**不误杀的三类**(实现里明确排除,均有单测):`textnode` ä¸Šçš„ `tagName`(渲染器不读)、
GrapesJS æŒ‚在节点上的 `docEl`/`head` å…ƒæ•°æ®ï¼ˆæ¸²æŸ“器不读,校验只沿 `components` èµ°ï¼‰ã€å­˜é‡çœŸå®žç”»å¸ƒã€‚
**新增测试** `CanvasSafetyTest`(17 é¡¹ï¼Œå« 6 ä¸ªçœŸå®žå­˜é‡ç”»å¸ƒçš„参数化用例)。
其中 `acceptsRealStoredCanvases` æ˜¯æŠŠåº“里全部版本导成夹具(`src/test/resources/qcreport/canvas/`),
用来守住「白名单不许收得过紧」——它比「拒绝脏内容」那几条更重要,因为误杀的后果是用户突然存不了模板。
> å†™è¿™æ‰¹ç”¨ä¾‹æ—¶è¸©è¿‡ä¸€æ¬¡å‘:最初把 `components` æ‘†åœ¨ `grapes` æ ¹ä¸Šï¼Œè€Œæ ¡éªŒå™¨ï¼ˆæ­£ç¡®åœ°ï¼Œä¸Žæ¸²æŸ“器一致)只沿
> `pages[0].frames[0].component` èµ°ï¼ŒäºŽæ˜¯ã€Œæ‹’绝」类断言全部**假通过**(什么都没检查)。已改为全部按真实画布形状写。
> å®‰å…¨æµ‹è¯•的假通过比不写更危险。
### 18.2 åœç”¨ã€Œå½“前版本」直接拒绝(`TEMPLATE_VERSION_CURRENT_CANNOT_DISABLE` = `1_070_101_010`)
**问题**(§17.1 æœ«è®°çš„那个「产生它」的洞):`disableVersion` åœç”¨ç‰ˆæœ¬æ—¶ä¸æ¸…空 `template.currentVersion`。
停用当前版本会造出自相矛盾的模板——`current_version` è¿˜æŒ‡ç€å®ƒã€å®ƒå´å·²ä¸å¯ç”¨ï¼Œè¯¥æ¨¡æ¿è‡ªæ­¤å‡ºä¸äº†æŠ¥å‘Šï¼Œ
且失败发生在**出件那一刻**(`1_070_101_003`),用户不会想到起因是之前那次停用。
**改法**:`disableVersion` é‡Œæ¯”对模板 `current_version` ä¸Žè¯¥ç‰ˆæœ¬ `version`,相等直接拒绝。
**不顺手替用户改 `current_version`**——「改哪个版本」是用户的决定,不该由「停用」这个动作悄悄代劳;
文案里给出两条出路(先回滚到另一个已发布版本 / æ”¹ä¸ºåœç”¨æ•´ä¸ªæ¨¡æ¿ï¼‰ã€‚
口径:只比字符串,**不看版本是否已发布**(草稿同样可能被指到);停用非当前版本的行为不变。
### 18.3 `hasCanvas` åˆ¤æ®å¼ºåŒ– + `/selectable` è¡¥ç¬¬ä¸‰é‡è¿‡æ»¤ï¼ˆæœ¬è½®è‡ªè¡Œè¿½åŠ ï¼ŒéžåŽŸå®šèŒƒå›´ï¼‰
**发现**:导出库中全部版本后核对,模板 1 çš„当前版本(id=2, v1.1, å·²å‘布)的 `grapes` æ˜¯
`{"assets":[],"styles":[]}`——**非空,却一个组件都没有**。而 `hasCanvas` å½“时的判据正是「grapes è¿™ä¸ª Map éžç©ºã€ï¼Œ
于是它被判为有内容;继续走渲染,`HtmlRenderer` å– `pages[0].frames[0].component` å–到 null,
**把 body æ¸²æŸ“成空串、不报错**,用户拿到一张**白纸 PDF**。这比报错更糟:用户不知道要改什么。
**改法**:把 `hasCanvas` çš„判据改成与渲染器读画布的那一行**严格一致**:
```
Paths.readPath(grapesOf(schema), "pages[0].frames[0].component") instanceof Map<?, ?>
```
它同时守着两处:`getSelectableTemplates`(要不要把这个模板摆进出报告弹窗)与
`resolveVersion`(出件前要不要直接拒绝,报既有的 `1_070_101_008`)。一个判据单点定义,两处口径不会漂。
**影响面已实测**:全库 6 ä¸ªç‰ˆæœ¬ä¸­ï¼Œåªæœ‰ id=1、id=2(都是模板 1 çš„空白画布)由「通过」变「拒绝」,
4 ä¸ªçœŸæ­£ç”»äº†ä¸œè¥¿çš„版本不受影响。报表实例全库只有 1 æ¡ï¼ˆæ¨¡æ¿ 3 v1.0),不被波及。
**`/selectable` éšä¹‹è¡¥ä¸Šç¬¬ä¸‰é‡è¿‡æ»¤**:候选版本行本来就整行读出来(含 `schema_json`),
拿它判 `hasCanvas` æ˜¯ç™½æ¡çš„,**没有额外查库**;改成 exists å­æŸ¥è¯¢åè€Œå¤šä¸€æ¬¡å¾€è¿”。
> è¿™ä¸€æ¡è¶…出了原定的四项范围,属读代码时发现并顺手堵掉的缺陷,在此单列说明。
### 18.4 åŽ»æŽ‰ AI å¯¼å…¥ `blobIds` ä¸Šå†™æ­»çš„ `@Size(max=3)`
**问题**:入参 VO ä¸ŠæŒ‚着 `@Size(max = 3)`,而上限真正由 `yudao.qcreport.ai-import.max-files` å†³å®šã€‚
两个数字并存的结果只会是**注解更严、配置失去可调性**(配置只敢往下调),且不同步时用户看到的上限与生效的上限对不上。
**改法**:删掉注解,把服务层的 `validateFileCount` å£°æ˜Žä¸º**唯一闸门**。
已确认它的调用位置在 `generateDraft` æœ€å‰æ®µã€**任何 blob è¯»å–之前**,所以放大文件数不带来额外的磁盘/内存开销,
注解那层拦截并无必要。
### 18.5 æ¨¡æ¿è¡Œçº¸åž‹å¯¹é½ + `pdf-e2e.mjs` ä¿®æ­£
**数据侧**:核对后,三张模板行里只有模板 1 çš„ `orientation`(portrait)与其当前版本(landscape)不一致,
模板 3、模板 5 æœ¬æ¥ä¸€è‡´ï¼ˆÂ§17 æ—¶ä»¥ä¸ºçš„「模板 5 ä¸ä¸€è‡´ã€æ˜¯å…¶ v1.0 çš„,而 v1.0 ä¸æ˜¯å½“前版本)。
已把模板 1 çš„ `page_size`/`orientation` å¯¹é½åˆ°å½“前版本,全表三行现在都与其当前版本一致。
> å¤‡æ³¨ï¼š`qc_report_template.page_size` / `orientation` **全项目无读取方**(只有 DO ä¸Ž RespVO å£°æ˜Žï¼‰ï¼Œ
> æ¸²æŸ“一律取 Schema é‡Œçš„ `page`。它们只是模板列表页的展示元数据——所以这次对齐只影响列表显示,
> ä¸æ”¹ä»»ä½•渲染结果。模板 1 çš„两个版本画布都是空的(见 Â§18.3),它在 `/selectable` é‡Œå·²è¢«è¿‡æ»¤æŽ‰ï¼Œ
> å› æ­¤è¿™æ¬¡å¯¹é½å¯¹å®ƒæ˜¯ã€Œæ˜¾ç¤ºä¸€è‡´ã€è€Œéžã€Œå¯ç”¨ã€ã€‚
**脚本侧**(`D:/qcl-tmp/pdf-e2e.mjs`,仓库外):
1. ä¸å†å†™æ­» `version: 'v1.0'`,改为先查模板的 `currentVersion` å†å¸¦ä¸Šâ€”—
   å†™æ­»çš„版本号会在模板发新版本后让脚本**悄悄去验一个过期的历史版本**,而我们以为它还在验当前模板。
2. æ–­è¨€ `3.4` åŽŸå…ˆå†™æ­»ã€ŒA4 çºµå‘ 595.28x841.89pt」。但 `orientation` **是生效的**
   ï¼ˆ`PageSizes.resolve` æ¨ªå‘会翻转宽高),目标版本改为当前版本后该断言必然误报。
   æ”¹ä¸ºä»Žå®žä¾‹ HTML çš„ `@page { size: Wmm Hmm }` å–真值、换算成 pt å†ä¸Ž PDF çš„ MediaBox æ¯”对——
   è‡ªæ´½ä¸”不随纸型变化而失效。
3. æ–°å¢žæ–­è¨€ `0.5.1`:模板行的纸型与当前版本 Schema çš„纸型一致。这正是 Â§18.5 æ•°æ®ä¾§é‚£æ¡ä¸å˜é‡çš„æœºå™¨æ£€æŸ¥ï¼Œ
   ä»¥åŽå†æœ‰æ¼‚移会被这条抓住。
### 18.6 éªŒè¯çŠ¶æ€ï¼ˆå·²åš / æœªåšåˆ†å¼€å£°æ˜Žï¼‰
**已验证**:
- `mvn compile -q -o` å…¨é¡¹ç›®ç¼–译通过(本轮改了 qcreport ä¸Ž ai、system ä¸‰ä¸ªæ¨¡å—)。
- `mvn -pl yudao-module-qcreport test -o` â†’ **`Tests run: 132, Failures: 0, Errors: 0`**
  ï¼ˆåŽŸ 106 é¡¹ + æ–°å¢ž `CanvasSafetyTest` 17 é¡¹ + æ–°å¢ž `QualityReportEngineTest` 9 é¡¹ï¼‰ï¼Œå…¨ç»¿ã€‚
- å…¨åº“ 6 ä¸ªç‰ˆæœ¬è·‘「强化前 / å¼ºåŒ–后」判据对照,确认影响面**只有模板 1 çš„两个空画布**。
- æ¨¡æ¿è¡Œçº¸åž‹å¯¹é½åŽå›žè¯»ä¸‰è¡Œï¼Œç¡®è®¤å…¨éƒ¨ä¸Žå½“前版本一致。
**HTTP å®žæµ‹å·²å®Œæˆï¼ˆ2026-09-19,用户两次重启后端后)**:
- `/d/qcl-tmp/verify-round2.mjs` â†’ **21/21 PASS**(A ç»„ 6 + B ç»„ 7 + C ç»„ 8)。
  - **A ç»„ `/selectable`**:先证明模板 1 åœ¨æ™®é€šåˆ—表里存在、`status=0`(启用)、`current_version=v1.1` çŠ¶æ€ `1`(已发布)、
    `grapes` é¡¶å±‚键只有 `assets,styles`(**非空但无 pages**)——即前两重过滤它过得去,
    å”¯ä¸€èƒ½æŒ¡å®ƒçš„就是第三重;随后 `/selectable?reportType=1` è¿”回 `[3]`,**模板 1 æ¶ˆå¤±**,
    è€Œ `reportType=3` ä»è¿”回模板 5(第三重没有一刀切误伤);`reportType=2` è¿”回空数组而非报错。
  - **B ç»„ `disableVersion`**(在临时模板上做,不碰真实数据):停用非当前版本成功且 `status` è½åˆ° 2;
    åœç”¨å½“前版本返回 `1070101010`,文案同时带出版本号与模板名,并给出「回滚切走 / åœç”¨æ•´ä¸ªæ¨¡æ¿ã€ä¸¤æ¡å‡ºè·¯ï¼›
    å¦åŠ ä¸€æ¡**「被拒后当前版本状态未被改动」**,证明拒绝是真没写库,而不是先写后抛。
  - **C ç»„ `CanvasSafety`**:六种注入形态(`<script>` æ ‡ç­¾ã€`tagName` å¤¹ `onerror`、`onclick` äº‹ä»¶å±žæ€§ã€
    å±žæ€§åå¤¹å¼•号、`styles` æ•´æ®µ CSS å­—符串、样式规则值含 `<`)**逐一返回 `1070101009` ä¸”文案各自点明原因**;
    æ­£å¸¸ç”»å¸ƒï¼ˆå«è¡¨æ ¼/图片/hr/文本节点)放行;`update` è·¯å¾„同样被拦(不只有 `create` æœ‰é—¸é—¨ï¼‰ã€‚
- `/d/qcl-tmp/pdf-e2e.mjs` é‡è·‘ â†’ **20/20 PASS**。本轮新加/改写的两条断言都过了:
  `0.5.1 æ¨¡æ¿è¡Œçš„纸型与当前版本 Schema çš„纸型一致 | æ¨¡æ¿è¡Œ=A4/portrait ç‰ˆæœ¬ v1.1=A4/portrait`;
  `3.4 çº¸é¢å‡ ä½•与渲染时声明的 @page ä¸€è‡´ | @page=210x297mm æœŸæœ›â‰ˆ595.28x841.89pt å®žé™…=594.96x841.92pt`
  ï¼ˆä¸å†å†™æ­»çºµå‘,横向版本也不会误报)。60 é¡¹é•¿æŠ¥å‘Š 3 é¡µã€ä¸­æ–‡å­—体已嵌入。
- `/d/qcl-tmp/verify-budget.mjs` â†’ **6/6 PASS**(`max-duration-seconds` ä¸´æ—¶æ”¹ 0 + é‡å¯åŽè·‘,跑完已还原为 180):
  `1070104016` è€Œéž `DRAFT_EMPTY`,文案带出生效预算(0 ç§’)与实际耗时,并给出「减少文件/查模型」两条动作;
  ç«¯åˆ°ç«¯ **29–84 ms** è¯æ˜Ž**一次模型调用都没发**(真调会是数千毫秒);
  å¯¹ç…§ç»„证明入参校验(`schemaVersion` â†’ `1070104009`)排在预算检查之前。
  - **一个只在极端配置下才出现的文案瑕疵(未修,判定为不值得修)**:预算为 0 æ—¶å¾ªçŽ¯åœ¨è¯»åˆ°ç¬¬ä¸€ä¸ªæ–‡ä»¶**之前**就跳出,
    `fileNames` ä¸ºç©ºï¼ŒäºŽæ˜¯æ–‡æ¡ˆå‡ºçŽ°ã€Œåœ¨æ–‡ä»¶ã€Œã€ä¸­æœªè¯†åˆ«å‡ºä»»ä½•å¯ç”¨ç»„ä»¶ã€ã€‚çœŸå®žé…ç½®ï¼ˆ180 ç§’)下必然已经读过至少一个文件,
    è¿™ä¸ªç©ºä¸²å½¢æ€é…ä¸å‡º 0 å°±å¤çŽ°ä¸äº†ï¼Œå±žæµ‹è¯•ä¸“ç”¨å½¢æ€ã€‚
- **两处 FAIL å…¨æ˜¯æ–­è¨€å†™é”™ã€ä¸æ˜¯äº§å“ç¼ºé™·**:`1_070_104_016` çš„æ•°å€¼æ˜¯ **`1070104016`**,
  æˆ‘第一版按 `1_070_104_016` ç›´å†™æˆ `10701010416`(多一位)。**段号拼接规则:`1|070|104|016` â†’ åŽ»æŽ‰åˆ†éš”ç¬¦**,
  å†™æ–­è¨€å‰å…ˆçœ‹æŽ¥å£çœŸå®žè¿”回值,别照抄错误码常量里的下划线形式。
**测试数据已清干净**:临时模板 32 åŠå…¶ 3 ä¸ªç‰ˆæœ¬ç¡¬åˆ ï¼›`pdf-e2e` é€ çš„实例 46/47、附件 69~72、blob 63~66
连同磁盘文件一并清掉;预算用例的 2 ä¸ªå ä½ blob(68/69)与磁盘文件同样清掉。
回读与开工前一致(`qc_report_template` 3 è¡Œã€`version` 6 è¡Œã€å®žä¾‹åªå‰© 44/45、blob ä¸Šé™ 67、附件上限 73)。
**用户自己的数据一律未动**:blob 67(`百事模版.docx`)与附件 73(绑在模板 1 ä¸Šï¼‰ã€å®žä¾‹ 44/45 å‡ä¿æŒåŽŸæ ·ã€‚
### 18.7 æœ¬è½®æœªåš
- å­˜é‡è„ç”»å¸ƒçš„**回溯核查**(`CanvasSafety` åªåœ¨ä¿å­˜æ—¶æ‰§è¡Œï¼Œä¸å›žæ‰«åŽ†å²ç‰ˆæœ¬ï¼‰ã€‚
- è®¾è®¡å™¨ã€Œé¢„览」按钮接 `instance/preview`(服务端渲染)——用户已明确选择**保持现状**。
- äºŒç»´ç ç”Ÿæˆèƒ½åŠ›ï¼›`GENERATING` / `FAILED` å®žä¾‹çŠ¶æ€ï¼›`qc_report_data_source` / `qc_report_rule` ä¸¤å¼ æ—  Java å¯¹åº”的表。
- `@Disabled` çš„真实 AI è”调测试;IPQC / OQC / RQC ä¸‰é¡µ UI é€ä¸€ç‚¹éªŒã€‚
## åä¹ã€AI å¯¼å…¥ä¿®æ­£ï¼šé¡µçœ‰/页脚被认成文本组件(2026-09-19 ç»­ï¼‰
**用户报的现象**:「ai è¯†åˆ«å‡ºç±»ä¼¼é¡µçœ‰æˆ–者页脚的时候使用对应组件,现在好像使用的是文本组件」。
排查后确认背后是**两层独立原因**,只修一层不够。本轮**前后端一并改**(用户已授权前端)。
无新增业务对象、无建表、无渲染引擎改动、无跨模块 API å˜åŒ–。
### 19.1 ç¬¬ä¸€å±‚:提示词里对「12 ä¸ªç»„件该选哪个」零指导
积木清单只带 `type` / `label` / `category` / `fields`,铁律 5 æ¡ + æŠ½å–规则 4 æ¡**没有一条讲选型**。
而组件之间大量可互相替代:
| æ–‡ä»¶é‡Œçš„内容 | å¯é€‰é¡¹ |
|---|---|
| æŠ¥å‘ŠæŠ¬å¤´ï¼ˆå…¬å¸å + æŠ¥å‘Šæ ‡é¢˜ï¼‰ | `Heading`「标题」/ `Text`「文本」/ `ReportHeader`「报告页眉」 |
| è½æ¬¾ç­¾ç½²è¡Œï¼ˆæ£€éªŒå‘˜/审核/日期) | `Text` / `ReportFooter`「报告页脚」 |
| æ ·å“/物料信息块 | `Table` / `Text` / `SampleInfo`「样品信息」 |
| æ£€éªŒæ•°æ®è¡¨ | `Table`「基础表格」/ `QualityTable`「检验项目表格」 |
| æ•´ä½“结论 | `Heading` / `Text` / `Result`「判定结果」 |
模型只看中文 `label`,没有理由不挑字段最简单、最通用的那个——这就是「用了文本组件」的直接原因。
**用户拍板的口径**:选型指引**放在前端注册表的字段里**(与组件定义同源,前端改组件时自动跟随),
**不后端硬编码**。一旦后端点名组件,组件语义就又变成第二个真相来源,正是本项目一直在避免的。
**改法**(前端 `mom-pro2-before/`,本轮唯一改前端的轮次):
| è½ç‚¹ | æ”¹åЍ |
|---|---|
| `components/quality/core/types.ts` | `QualityComponentDefinition` åŠ å¯é€‰æˆå‘˜ `aiHint?: string` |
| 12 ä¸ªç»„件文件 | å„补一条 `aiHint`:**「用来放什么」+「不要用来放什么」**。后者是关键,模型误用的根因正是不知道某个通用组件不该用在这里 |
| `components/quality/core/catalog.ts` | æ¸…单项带出 `hint?`(沿用既有「缺席即省略」风格) |
| `api/mes/qc/report/ai/index.ts` | catalog çš„ TS ç±»åž‹åŠ  `hint?` |
**为什么安全**:全 `src/` æ²¡æœ‰ä»»ä½• `Object.keys(definition)` / `for...in` / `{...definition}` /
`JSON.stringify(definition)` / æ·±æ¯”较。画布构造是逐字段显式挑;属性面板只从 `propertySchema` æ´¾ç”Ÿï¼›
序列化 / è£…配 / æ ¡éªŒä¸‰å¤„都只遍历 `propertySchema`;注册表是 Map æŒ‰ `type` ç´¢å¼•。唯一联动点是清单派生层。
**后端只负责序列化**:`QcReportComponentSpecVO` åŠ  `hint`(**组件级**,不加在嵌套的 `Field` ä¸Šï¼‰ï¼Œ
提示词里追加一条抽取规则要求「选型时以 hint ä¸ºå‡†ï¼Œæ¸…单里存在专门组件就不要退而用 Text / Heading è¿™ç±»é€šç”¨ç»„件凑数」。
**刻意不点名任何组件**——规则 5 å…¨æ–‡ä¸å«ç»„件名,语义仍只活在前端。
### 19.2 ç¬¬äºŒå±‚:`.docx` çš„ Word é¡µçœ‰/页脚压根没进提示词
`OfficeImportAdapter` èµ° `document.getBodyElements()`,只遍历**正文**;全模块 grep ç¡®è®¤
`getHeaderList()` / `getFooterList()` **一次都没调用过**。于是那份文件的页眉
(公司名 / è‹±æ–‡åï¼‰ä¸Žé¡µè„šï¼ˆåˆ¶é€ å•† / åŽ‚å€ / ç”µè¯ï¼‰**模型从来没看见过**,它想用 `ReportHeader` ä¹Ÿæ— ä»Žä¸‹æ‰‹ã€‚
**用户拍板的口径**:docx çš„ Word é¡µçœ‰é¡µè„š**读进来并加 `[页眉]` / `[页脚]` æ ‡è®°**。
**改法**(`OfficeImportAdapter`):
1. æŠŠæ­£æ–‡éåŽ†æŠ½æˆ `appendBodyElements(StringBuilder, List<IBodyElement>)`,正文/页眉/页脚**共用一份表格逻辑**,避免两套漂移;
2. æ–°å¢ž `collectHeaderFooter(...)`:逐个收集后**按归一化文本去重**(Word æœ‰é¦–页/奇数页/偶数页多套页眉,内容常逐字相同);
   **空白必须过滤**——文档没有页眉时 POI ä»å¯èƒ½è¿”回一个空 header,不过滤会让既有夹具凭空多出 `[页眉]` æ ‡è®°ï¼›
3. `readDocx` æ”¹ä¸ºè¿”回 `record DocxRead(String text, List<String> notes)`,拼装顺序为
   `[页眉] â†’ æ­£æ–‡ â†’ [页脚]`(与文档视觉顺序一致,且**仅当非空**);
4. å®žé™…读到内容时往 `notes` åŠ ä¸€æ¡è½¯æç¤ºï¼Œç» `QcReportImportServiceImpl` æ—¢æœ‰çš„
   `warnings.addAll(extract.notes())` ç›´æŽ¥è¿›å“åº” `warnings`:**前端无需任何新代码就能展示**。
**只有 docx éœ€è¦**:PDF / å›¾ç‰‡é€šé“里页眉页脚本来就是页面像素或文本的一部分,无需标记。
### 19.3 é¡ºå¸¦ä¿®æŽ‰çš„两个真实缺陷(用户逐条拍板后落地)
**(a) è¡¨æ ¼æœ«è¡Œçš„落款签署行被当成数据行丢掉。** æŠ½å–规则第 1 æ¡å†™ç€ã€Œå…·ä½“数据行不要写进模板」,
而签署行(检验员/审核人/日期)**就排在检验表格的最后一行**,于是被整行丢掉,落款再也找不回来。
改法:规则 6 æ˜¾å¼æŠŠå®ƒä»Žç¬¬ 1 æ¡é‡Œè±å…å‡ºæ¥ï¼Œå¹¶è¦æ±‚**与原件页脚合成一处**放进 `ReportFooter`;
同时把它写进 `ReportFooter` ä¸Ž `QualityTable` ä¸¤æ¡ `aiHint`(两侧各自声明边界,模型从任一侧看都一致)。
**要点名「即使它排在检验表格的最后一行也一样」这个位置**——不点名,模型仍会按第 1 æ¡æŠŠå®ƒå½“数据行。
**(b) æ¨¡åž‹ç»™ç­¾ç½²è¡Œç¼–造了不存在的绑定键。** ä¿®å¥½ (a) ä¹‹åŽå®žæµ‹è¾“出:
```
ReportFooter.props.text = "检验员:{{report.inspector}}  å®¡æ ¸äºº Auditor:{{report.auditor}}  æ—¥æœŸ Date:{{report.date}}"
```
`{{report.auditor}}` / `{{report.date}}` **在上下文里根本不存在**——查 `ReportFields`
(报告上下文字段的权威清单)确认只有 `inspector` ä¸Ž `inspectDate`,**没有 `auditor`、没有 `date`**。
查 `binding.ts` ç¡®è®¤å–不到的路径渲染成**空字符串**(报告上不会出现用户看不懂的 `{{}}`),
但会**留下空白并触发一条「绑定取不到值」告警**。
**用户指令**:**「审核人留空打印出来填」**。由此落地为规则 6 çš„后半 + æ–°å¢žè§„则 7:
- ç­¾ç½²è¡Œ**保留栏目名、值留空**,写成「检验员:\_\_\_\_ 审核人:\_\_\_\_ 日期:\_\_\_\_」这样的填写位,
  æ‰“印出来由人手填。**人名与日期是每一份报告各自的数据**,写死原件上的那几个人会让所有报告印成同一个检验员;
- è§„则 7 æ˜Žä»¤**不得自造绑定键**:模型并不知道上下文里有哪些字段,编出来的键渲染时只会留白加告警。
> è¿™ä¸€æ¡ä¸Žæˆ‘先前给用户看过的预览不一致(预览里是「检验员:漆兵霞 æ¨æ…§ã€è¿™ç±»**字面人名**)。
> ç”¨æˆ·çš„æŒ‡ä»¤è¦†ç›–了那个细节,改为留空——且它与规则 1 çš„前提(模板只留结构)本就自洽,
> `ReportFooter` çš„默认值也正是「检验员:\_\_\_\_ 审核人:\_\_\_\_ 日期:\_\_\_\_」。
> **留痕在此,避免以后有人以为字面人名才是预期。**
### 19.4 ä¸€å¤„一并说清的口径:正文抬头/落款 â‰  æ¯é¡µé‡å¤çš„页眉页脚
画布上的 `ReportHeader` / `ReportFooter` æ˜¯**正文流里**的抬头块与落款块;
PDF æ¯é¡µé‡å¤çš„页眉页脚是服务端 `PdfPrintOptions` åŠ çš„ï¼ˆç›®å‰åªæœ‰é¡µç ï¼‰ã€‚
所以「Word é¡µçœ‰ â†’ `ReportHeader`」是**把它挪进正文顶部**,**不是**还原每页重复的打印版式。
已写进前端联调文档,避免前端误以为导入后能得到每页重复的页眉。
### 19.5 æµ‹è¯•与验证状态(已做 / æœªåšåˆ†å¼€å£°æ˜Žï¼‰
**已验证(确定性)**:
- `mvn -pl yudao-module-qcreport test` â†’ **`Tests run: 137, Failures: 0, Errors: 0`**,
  å…¨ç»¿ï¼ˆ132 é¡¹å­˜é‡ + æœ¬è½®æ–°å¢žï¼›`QcReportTemplatePromptBuilderTest` 8 â†’ 9)。
- å‰ç«¯å¯¹æ‹ `npx tsx .qc-conformance/assemble-ts.ts` â†’ å…¨éƒ¨é€šè¿‡ã€‚清单新增一条断言
  **「每个组件都写了非空 aiHint」**(12/12),字段集合断言同步为 `category/fields/hint/label/type`。
- **提示词构造实测**(列仓库外探针 `D:/qcl-tmp/PromptProbe.java`):拿**真实前端导出的清单**反序列化后
  è°ƒçœŸå®ž `QcReportTemplatePromptBuilder.buildSystemPrompt`,提示词 5816 å­—符,
  **12 æ¡ `hint` é€æ¡åœ¨åœºï¼ˆç¼ºå¤± 0)**,规则 6/7 æ–‡æ¡ˆé€å¥åœ¨åœºã€‚
- **docx æŠ½å–实测**(列仓库外探针 `D:/qcl-tmp/HeaderFooterProbe.java`):对真实文件调**生产类**
  `OfficeImportAdapter`,确认页眉/页脚被读出并以 `[页眉]` / `[页脚]` æ ‡è®°å¹¶å…¥ï¼Œ`notes` éžç©ºã€‚
  > è¿™ä¸¤ä¸ªæŽ¢é’ˆçš„æ„ä¹‰æ˜¯ï¼šæŠŠã€ŒåŽç«¯é€»è¾‘对不对」与「模型选得对不对」**彻底分开**。
  > å‰è€…必须确定性断言,后者只能靠真实调用观察。
**HTTP å®žæµ‹ï¼ˆçœŸå®žè°ƒç”¨ï¼Œå¦‚实汇报波动)**:
- è§„则 6/7 è½åœ°**前**的状态已实测 3 æ¬¡ï¼ˆ2 æ¬¡è„šæœ¬ + 1 æ¬¡ UI),3 æ¬¡ç»“果一致:
  `ReportHeader â†’ SampleInfo â†’ QualityTable â†’ Result â†’ ReportFooter`,**`Text` æœªå‡ºçް**;
  5 é¡¹ç¡®å®šæ€§æ–­è¨€æ¯æ¬¡å…¨è¿‡ï¼ˆå« `warnings` å‡ºçŽ°ã€ŒåŽŸä»¶å¸¦æœ‰é¡µçœ‰/页脚」)。**顶部与底部都落到了专门组件上**。
- **规则 6/7 è½åœ°åŽï¼ˆç”¨æˆ·é‡å¯åŽç«¯åŽï¼‰å¤éªŒ 2 æ¬¡ï¼Œä¸¤æ¬¡ç»“果一致**,关键证据 â€”— `ReportFooter.props.text`:
  ç¬¬ 1 æ¬¡ï¼š
  ```
  æ£€éªŒå‘˜ ï¼š__________   å®¡æ ¸äºº Auditor:__________  æ—¥æœŸ Date:__________
  åˆ¶é€ å•†ï¼šæ–°ç–†å¤§ç½—素马铃薯制品有限公司
  åŽ‚å€ï¼šæ–°ç–†åŒ—å±¯å¸‚å·¥ä¸šå›­åŒºé‡‘è¾‰è·¯555号
  ç”µè¯å·ç ï¼š0906-7516688
  ```
  ç¬¬ 2 æ¬¡ï¼ˆæŽªè¾žä¸Žæ¢è¡Œæœ‰å·®å¼‚,形态相同):
  ```
  æ£€éªŒå‘˜ ï¼š__________   __________
  å®¡æ ¸äºº Auditor:__________
  æ—¥æœŸ Date:__________
  åˆ¶é€ å•†ï¼šæ–°ç–†å¤§ç½—素马铃薯制品有限公司
  åŽ‚å€ï¼šæ–°ç–†åŒ—å±¯å¸‚å·¥ä¸šå›­åŒºé‡‘è¾‰è·¯555号
  ç”µè¯å·ç ï¼š0906-7516688
  ```
  ä¸¤æ¬¡éƒ½æ»¡è¶³ä¸‰ä»¶äº‹ï¼š**① ç­¾ç½²è¡Œæ ç›®ååœ¨ã€å€¼ç•™ç©º**(规则 6 åŽåŠç”Ÿæ•ˆï¼‰ï¼›
  **② é€šç¯‡æ—  `{{report.auditor}}` / `{{report.date}}`**(规则 7 ç”Ÿæ•ˆï¼Œå¯¹æ¯”复验前那次的
  `审核人 Auditor:{{report.auditor}}  æ—¥æœŸ Date:{{report.date}}`);
  **③ Word é¡µè„šä¸‰è¡Œä¸Žç­¾ç½²è¡Œåˆæˆåœ¨åŒä¸€ä¸ª `ReportFooter` é‡Œ**(用户拍板的「A + B éƒ½è¦è¿›ã€ï¼‰ã€‚
  ä¸¤æ¬¡çš„ `type` åºåˆ—均为 `ReportHeader â†’ SampleInfo â†’ QualityTable â†’ Result â†’ ReportFooter`,
  **`Text` æœªå‡ºçް**,5 é¡¹ç¡®å®šæ€§æ–­è¨€ **5/5 PASS**,耗时 11.7 s / 10.4 s。
**未验证 / ä¸ä¸»å¼ **:
- **模型是否每次都遵守规则 6/7,没有确定性证据**。模型输出会波动,复验只作「观察记录」,
  **不写成「已验证正确」**。真正的闸门在写入侧与前端装配器(未注册 type ä¼šè¢«è·³è¿‡ï¼‰ã€‚
  ä¸¤æ¬¡å¤éªŒçš„æŽªè¾ž/换行都有出入(第 2 æ¬¡æŠŠã€Œæ£€éªŒå‘˜ã€åŽé¢çš„第二个填写位单独换行了),
  æ­£è¯´æ˜Žè¿™ç±»æ”¹åŠ¨åªèƒ½é çœŸå®žè°ƒç”¨è§‚å¯Ÿï¼Œä¸èƒ½ç”¨æ–­è¨€é’‰æ­»ã€‚
- **未做 UI éªŒæ”¶ï¼ˆç¬¬äºŒè½®ï¼‰**:本轮 UI éªŒæ”¶æ˜¯åœ¨è§„则 6/7 è½åœ°**前**做的(临时模板 34)。
  è½åœ°åŽåªåšäº† HTTP å¤éªŒï¼Œ**没有重跑设计器 UI é‚£ä¸€è·¯**。改动只影响提示词文本,
  è£…配器与设计器代码一行未动,因此判定为低风险;但**这一条是「没做」而不是「通过」**。
- æœªå†™ã€Œmock æ¨¡åž‹å–‚罐头 JSON æ–­è¨€å®ƒé€‰å¯¹äº†ç»„件」这类测试——那是在测 mock,不是测产品。
**测试数据清理(已回读比对)**:临时模板 34 / 35 / 36 å…¨éƒ¨è½¯åˆ ï¼›å¤éªŒä¸Šä¼ çš„孤儿 blob 76 / 77
走「先 bind åˆ°ä¸´æ—¶æ¨¡æ¿ 36 å†åˆ é™„件」的级联清掉(**blob æ²¡æœ‰ç‹¬ç«‹åˆ é™¤æŽ¥å£**);
`D:/uploads/2026/0919/` å›žåˆ°ç”¨æˆ·è‡ªå·±çš„ 4 ä¸ªæ–‡ä»¶ã€‚回读与开工前一致:
blobs `20/62/67/70`、attachments `18/68/73/74`。**用户自己上传的 `百事模版.docx`(blob 67/70、附件 73/74)全程未动。**
### 19.6 æœ¬è½®æœªåš / é—ç•™
- `docs/qc_report_ai_import_frontend_integration.md` å·²åŒæ­¥ï¼ˆ`hint` å­—段、`[页眉]`/`[页脚]` å£å¾„、
  æ­£æ–‡æŠ¬å¤´ä¸Žç‰ˆå¼é¡µçœ‰çš„区别、签署行两条规则)。**未写任何前端代码片段**(按项目规则)。
- é¢„留但未实施:`.qc-conformance/dump-catalog.ts`(本轮为导出真实清单临时加的脚本,**未被 git è·Ÿè¸ª**,
  ç”¨é€”是把清单 dump æˆ JSON ä¾›è”调脚本读取;保留与否待定)。
- ç”¨æˆ·å…ˆå‰æå‡ºçš„另一个问题「如何高度还原这种复杂的检验项」**仍未选定方案**,本轮未动。
## äºŒåã€è½æ¬¾æŠ˜è¡Œä¿®æ­£ï¼šç­¾ç½²è¡Œä¸Žåˆ¶é€ å•†è¢«æŠ˜æˆä¸€ç‰‡ï¼ˆ2026-09-19 ç»­ï¼‰
### 20.1 çŽ°è±¡ä¸Žæ ¹å› 
用户在渲染出来的报告上指出「**日期和制造商布局有点不行**」:落款应该是几行,实际是
`检验员:__________ å®¡æ ¸äºº Auditor:__________ æ—¥æœŸ Date:制造商:新疆大罗素马铃薯制品有限公司厂址:…电话号码:…`
**一整片连在一起**(只靠容器宽度折成两行,换行位置由宽度决定,不是内容本身的断点)。
根因是**渲染侧对换行的处理,不是 AI è¾“出**(AI ä¾§çš„输出是对的):规则 6 è¦æ±‚把签署行与原件页脚
**合成同一个 `ReportFooter`**,AI ç…§åšï¼Œå‡ æ®µä¹‹é—´ç”¨ `\n` åˆ†éš”。而
`ReportFooter.buildContent` åªåšäº† `escapeHtml(properties.text)`,
**`escapeHtml` åªå¤„理 `& < > " '`,完全不碰换行**(`core/html.ts`);
HTML çš„空白折叠规则把 `\n` æŠ˜æˆæ™®é€šç©ºæ ¼ï¼ŒäºŽæ˜¯å››è¡Œç²˜è¿žæˆä¸€æ®µã€‚
**同一张截图里的蓝色 `Table cell` ä¸æ˜¯ç¼ºé™·**:那是 GrapesJS é€‰ä¸­æ€çš„组件名徽标
(GrapesJS æŠŠ `<td>` å‘½åä¸º "Table cell"),已确认前端源码与全部已存画布里都不存在这个字符串。
### 20.2 æ”¹åŠ¨ï¼ˆä¸‰å¤„ï¼Œå…¨åœ¨å‰ç«¯ï¼‰
| æ–‡ä»¶ | æ”¹åЍ |
|---|---|
| `src/components/quality/core/html.ts` | æ–°å¢ž `escapeHtmlMultiline`:**先转义、再把 `\r\n?|\n` æŠ˜æˆ `<br>`** |
| `src/components/quality/header/report-footer.ts` | `escapeHtml` â†’ `escapeHtmlMultiline` |
| `src/components/quality/base/text.ts` | åŒä¸Šï¼ˆAI æœ‰æ—¶æŠŠç­¾ç½²è¡Œæ”¾è¿› `Text`,只修页脚会留漏洞) |
**折行放在设计期(`buildContent`)而不是渲染期**:`HtmlRenderer` ä¸Ž `BASE_CSS` æ˜¯å†»ç»“çš„
(`FrontendConformanceTest` ä¸Žå‰ç«¯å¼•擎逐字对拍),**红线不动**。`buildContent` äº§å‡ºçš„
`content` å°±æ˜¯ç”»å¸ƒå†…容,渲染引擎只做 `{{}}` å–值、原样输出(前后端两侧都已读码确认:
`render.ts:renderText` / `HtmlRenderer.renderText` â†’ `Bindings.resolveText`,**均不再转义**),
所以写进 `content` çš„ `<br>` èƒ½ä¸€è·¯æ´»åˆ° HTML ä¸Ž PDF。
`Heading` æœªæ”¹ï¼šæ ‡é¢˜æ˜¯çŸ­å•行,没有多行的证据。`Result` / `SampleInfo` / `ReportHeader`
是拼接式或键值式,不受影响。
### 20.3 éªŒè¯ï¼ˆå·²åšï¼‰
- **后端 `mvn -pl yudao-module-qcreport test`:137/137 å…¨ç»¿**。画布 fixture æ˜¯å†»ç»“的预构建 JSON,
  `buildContent` åªåœ¨è®¾è®¡æœŸè·‘,故一致性对拍不受影响。
- **前端对拍脚本新增第 10 èŠ‚**(`.qc-conformance/assemble-ts.ts`,脚本本体未被 git è·Ÿè¸ªï¼‰ï¼š
  `ReportFooter` / `Text` å„两条断言 â€”— `content` é‡Œå‡ºçް `<br>` ä¸”**不是被转义的 `&lt;br&gt;`**、
  `data-qc-text` å±žæ€§**保留原始 `\n`**(属性面板与绑定按纯文本处理,折行只发生在渲染产物这一侧)。
  è¿žåŽŸæœ‰å„èŠ‚ä¸€èµ· **全部通过**。
- **装配 â†’ æ¸²æŸ“探针**(临时 tsx è„šæœ¬ï¼Œç”¨å®Œå·²åˆ ï¼‰ï¼šå¤šè¡Œè½æ¬¾ç» `assembleQualityCanvas` +
  `renderReport` åŽäº§å‡º 3 ä¸ª `<br>`、四行文字全在场;`<script>` ä»è¢«è½¬ä¹‰æˆ `&lt;script&gt;`,
  **换行处理没有削弱注入防护**;单行落款逐字不变(无回归)。
- **浏览器验收(设计器画布)**:临时建模板 37 / è‰ç¨¿ç‰ˆæœ¬ 22,画布里的 `ReportFooter`
  å¸¦é‚£å››è¡Œè½æ¬¾ï¼Œæ‰“å¼€ `/qc/report-template/designer?id=37` è¯»ç”»å¸ƒ iframe çš„真实 DOM:
  `innerText` æ–­æˆ **4 è¡Œ**、`<br>` **3 ä¸ª**、`data-qc-text` é‡Œ `\n` åŽŸæ ·ä¿ç•™ï¼Œ
  é¡¶éƒ¨ `Heading`「检 éªŒ æŠ¥ å‘Šã€ä¹Ÿåœ¨ã€‚**这一屏就是用户看到出问题的那一屏,现在是对的。**
### 20.4 æœªåš / å·²çŸ¥è¾¹ç•Œï¼ˆå¦‚实声明)
- **存量已保存的画布不回溯**。`content` åªåœ¨ `buildContent`(新建组件 / AI è£…配)时生成,
  è®¾è®¡å™¨**载入**模板不会重算 `content`(`canvas-sync.ts` åªé‡å»º traits,不重算内容),
  æ‰€ä»¥**改动之前**就保存过的多行落款仍会是折在一起的。实测 7 ä¸ªå·²å­˜ç”»å¸ƒç‰ˆæœ¬é‡Œ
  **没有任何一个含「制造商」/「厂址」**,即没有存量受影响;仍值得日后一起清理脏画布时统一处理。
- **属性面板仍是单行输入**:可绑定字段统一用自定义控件 `qc-binding`,里面是
  `<input type="text">`(`trait-binding.ts`)。**人手在设计器里改落款,仍然打不出换行。**
  æœ¬è½®**未动**:把它换成 `<textarea>` ä¼šåŒæ—¶æ”¹å˜ 12 ä¸ªç»„件里全部可绑定字段的输入形态,
  å±žäºŽè¦å•独拍板的取舍,不夹带在布局修复里。**AI å¯¼å…¥å‡ºæ¥çš„多行落款不受此限**(值原样保留,
  åªè¦ä¸åŽ»ç¼–è¾‘é‚£ä¸ªè¾“å…¥æ¡†ï¼‰ã€‚
- **浏览器验收只覆盖设计器画布**,**没有重跑「真实 AI å¯¼å…¥ + æˆªå›¾ã€é‚£ä¸€è·¯**:
  æœ¬è½®å‰ç«¯æ”¹åŠ¨ä¸æ”¹å˜ AI çš„输出文本,用装配好的同一段多行落款即可确定性复现用户那一屏,
  å› æ­¤æ²¡æœ‰ä¸ºä¸€æ¬¡éžç¡®å®šæ€§çš„ AI è°ƒç”¨å†èŠ±ä¸€æ¬¡é¢„ç®—ã€‚**这一条是「没做」,不是「通过」。**
- æœ¬è½®**没有**改提示词、**没有**改 `HtmlRenderer` / `BASE_CSS`、**没有**改 SQL ä¸Žå»ºè¡¨ã€‚
  ä¸šåŠ¡å¯è§†åŒ–æ–‡æ¡£ `docs/project-business/` æœ¬è½®**未改**:这是渲染层布局修复,
  ä¸šåŠ¡æ¨¡å— / å¯¹è±¡ / æµç¨‹æ­¥éª¤ / çŠ¶æ€æµè½¬ / æƒé™å‡æ— å˜åŒ–(判据:AI çš„输出语义与上一轮完全一致)。
### 20.5 æµ‹è¯•数据清理
临时模板 37 ä¸Žè‰ç¨¿ç‰ˆæœ¬ 22 **已删除并回读确认**(版本列表为空、模板 `get` è¿”回 `data=null`);
本轮**没有上传文件**,不涉及附件与 `blob`;临时的装配脚本与截图已从工作区移除。
## äºŒåä¸€ã€å¤æ‚检验项还原:分组检验项(2026-09-19 ç»­ï¼‰
> æ–¹æ¡ˆä¸Žå®šç¨¿å£å¾„:`docs/qc_report_grouped_inspection_design.md`(**已定稿并开工**)。
> æ‰¿æŽ¥ Â§19.6 ç•™çš„那条「用户先前提出的另一个问题『如何高度还原这种复杂的检验项』仍未选定方案」。
### 21.1 è¦ä¿®çš„æ˜¯ä»€ä¹ˆ
原件(来料检验报告)里的检验项是**两层**的:`水分` æ˜¯ä¸€ä¸ªåˆ†ç»„项,它自己只有一条公式
`w = (m1 - m0) / m Ã— 100%`,实测值在它下面的子项 `试样质量 m` / `水分结果 w` ä¸Šã€‚
复杂质检单早已按这个结构存了数据(`mes_qc_indicator.parent_id` / `item_type` / `formula_text`),
**出报告那一侧把它压平了**:组名与公式整条丢掉,子项各占一行、看不出谁属于谁。
| ä½ç½® | æ”¹å‰ | æ”¹åŽ |
|---|---|---|
| å½’属信息 | æ—  | ç»„名合并(`rowspan`),子项行让位(`hidden`) |
| ç»„公式 `formula_text` | ä¸¢ | è¿›ç»„头行的「检测要求」格 |
| åˆ—æ•° | 6 åˆ— `QualityTable` | æ–°å¢ž **7 åˆ—** `GroupedQualityTable`(多一列「子项」) |
### 21.2 å½’属判据与合并规则(实测得出,不是推断)
**归属只看 `parent_id`,不看 `item_type`,也不看行序** â€”— è¡Œè¡¨é‡Œçˆ¶é¡¹é‚£ä¸€è¡Œä¸Žå®ƒçš„子项
**并不相邻**(同一份单里父项可能排在一组子项中间)。`item_type = 2` åªç”¨æ¥åˆ¤æ–­
「这个指标够不够格当组头」。
**合并口径统一成一条规则**:**组内第一行背 `span = è¯¥ç»„总行数`,同组其余行一律 `span = 1` + `hidden = hidden`。**
这样多样品的组头(一个指标多行)也自动落在规则里,不需要第二套分支。
落地手段是**由数据下发、不在渲染期计算**:合并属性写成
`rowspan="{{item.group.span}}" hidden="{{item.group.hidden}}"`。之所以算不出来 â€”—
绑定引擎在数组上会按元素 pluck,`{{item.group.children.length}}` å–不到值。
同理,**每一项都必须带 `group`**(独立项给空串与 `1`):字段缺席时绑定的属性会被跳过,
该隐藏的格子反而会露出来。**没有分组的报告整体不带 `group`**,合并属性一个都不出现。
### 21.3 åˆ—位是浏览器量出来的(本题唯一的真不确定点)
「隐藏一格会不会把整行剩余格子左移一列」在 HTML ç»“构层面验不出来(只能数 `td`),
真正的表格布局由浏览器定。所以用**真实渲染产物 + çœŸå®ž Chromium**(就是服务端 PDF ç”¨çš„那个)
逐格量 `getBoundingClientRect().left`,映射回表头列:
- è¡¨å¤´åˆ—左边界:`0 / 133 / 343 / 601 / 1154 / 1326 / 1458`;
- **组头行 â†’ åˆ— 1–7**(全占);
- **子项行 â†’ åˆ— 1、3、4、5、6、7** â€”— åºå·ä»åœ¨åˆ— 1,**列 2 è¢« `rowspan` å ä½è€Œè·³è¿‡**,
  `子项` è½åˆ— 3、`实测值` è½åˆ— 5、`判定` è½åˆ— 7。**没有左移、没有串位。**
- æ¯ä¸€è¡Œçš„列号序列都满足 `ascending: true, unique: true`。
- æˆªå›¾å­˜è¯ï¼š`mom-pro2-before/.qc-conformance/grouped-table-render.png`
  ï¼ˆå¯è§ `水分` è·¨ä½ 1–3 è¡Œï¼Œ`试样质量 m` / `水分结果 w` å¹¶æŽ’在「子项」列)。
> æˆ‘原先的心算是错的:我以为子项行的 6 ä¸ªæ ¼å­ä¼šè½åˆ°åˆ— 2–7。实测是**列 1 + åˆ— 3–7**。
> ç»“论(不错位)一致,但过程按实测更正,不按心算写进文档。
### 21.4 ç»„头在本单没有行时:**补一行**,不是「按独立项处理」
这是本轮唯一一处「改了早期设计」的地方。早期方案写的是:分组项在本单的检验明细里没有自己的行
⇒ åˆ¤ä¸ºã€Œç»„不成立、按独立项处理」。**那样会原样复现本次要修的 bug**(公式又丢了)。
定稿改为:**仍然为它补一行**来显示组名与公式,并在响应的 `synthesizedGroupNames` é‡Œè¯´æ˜Žã€‚
实测确认这条路径可达:`mes_qc_template_indicator` å†³å®šæœ¬å•有哪些行,
**检验模板完全可能只勾了子项、没勾父项**。
同时把这条 warning ä¸Žå¦ä¸€æ¡**分开**(早期是同一个字段,混在一起说不清):
| æƒ…况 | å­—段 | æç¤ºè¯­ä¹‰ |
|---|---|---|
| åˆ†ç»„项本单**没有自己的行** â‡’ åŽç«¯è¡¥äº†ä¸€è¡Œ | `synthesizedGroupNames` | ã€ŒæŠ¥å‘Šé‡Œä¸ºå®ƒä»¬å„补了一行来显示组名与公式」 |
| åˆ†ç»„项本单**没有属于它的子项** â‡’ æŒ‰ç‹¬ç«‹é¡¹åˆ—出、组名不合并 | `childlessGroupNames` | ã€ŒæŠ¥å‘Šé‡ŒæŒ‰ç‹¬ç«‹æ£€éªŒé¡¹åˆ—出、组名不合并」 |
| æŒ‡æ ‡å·²è¢«åˆ é™¤çš„æ˜Žç»†æ¡æ•° | `missingIndicatorCount` | ã€Œæœ‰ N æ¡æ˜Žç»†çš„æŒ‡æ ‡å·²è¢«åˆ é™¤ï¼Œå·²è·³è¿‡ã€ |
**组头的锚点行按定义没有实测值**(值在子项上),所以它**不报**「尚未录入实测值」——
不然每一份带分组的报告都会误报。
### 21.5 åˆ¤å®šå£å¾„:「无判定规则」是合法状态
按用户拍板:**没有上下限也没有判定规则的项显示「无判定规则」,且不计入合格率分母与报告结论**
(过程参数就是这一类的典型)。实测落地效果:
- `report.total = 4`、`passCount = 1`、`passRate = 100.00%`、`conclusion = åˆæ ¼`
  â€”— ç»„头锚点行与过程参数**没有把结论压下去**;
- åˆ¤å®šåˆ—照样渲染出「无判定规则」(不被分组结构吃掉),带规格的子项照常判「合格」。
`undecidableCount` çš„口径差一点没写清:它数的是**快照里 `result` ä¸ºç©ºä¸²çš„项**,
里面**混着两种状态** â€”— ã€Œæ— åˆ¤å®šè§„则」(合法)与「待判定」(规则执行失败,是真问题)。
所以前端**不要把它当故障报警**(见 `docs/qc_report_generate_from_qc_frontend_integration.md`
的展示规则:蓝色信息条而非黄色告警,因为几乎每一份报告都会有过程参数)。
### 21.6 æ”¹åŠ¨æ¸…å•
**后端 `yudao-module-mes`**
- `MesQcIndicatorDO`:补映射 `parentId` / `itemType` / `formulaText` / `unitText` / `sortOrder`
  ï¼ˆ**只加映射,列本来就在库里**,本轮没建表、没改 SQL)。
- æ–°å¢ž `MesQcIndicatorItemTypeEnum`(1=录入项,2=分组项)。
- æ–°å¢ž `api/qc/MesQcReportApiImpl` + API æŽ¥å£ï¼šå››å±‚取数归一后**按 `parent_id` è¿˜åŽŸä¸¤å±‚**,
  ç»„头的锚点行、子项行分别产出行;补齐「没成组 / è¡¥å‡ºæ¥çš„行 / æŒ‡æ ‡å·²åˆ ã€ä¸‰ç±» warning。
**后端 `yudao-module-qcreport`**
- `ReportContext`:新增 `group`(`childName` / `span` / `hidden`),**条件带出**(无分组就不给)。
- `ReportContextCodec`:**手写** Map↔对象转换里补 `group`(不用 Jackson åå°„,
  æ¼äº†è¿™ä¸€æ­¥å†»ç»“快照就会丢分组)。
- `InspectionItem`:新增 `requirement` ä¸ŽåµŒå¥— `Group`,**`copy()` ä¸€å¹¶å¸¦å‡º**
  ï¼ˆ`ReportEvaluator` ä¼šè°ƒ `copy()`)。
- `MesQcReportContextMapper`:分组时逐项下发 `group`、组头不报漏录实测值、三条 warning æ–‡æ¡ˆã€‚
- `QcReportInstanceServiceImpl`:`undecidableCount` å£å¾„与 `1_070_105_006` æ–‡æ¡ˆã€‚
**前端 `mom-pro2-before`**
- æ–°å¢žç¬¬ 13 ä¸ªç»„ä»¶ `inspection/grouped-quality-table.ts`(7 åˆ—)。
- `engine/context.ts`:新增 `ReportContextItemGroup` ä¸Ž `item.group?`。
- `engine/render.ts` æœªæ”¹ï¼ˆ`rowspan` / `hidden` èµ°ç»‘定属性这条路,渲染期不参与计算)。
### 21.7 éªŒè¯ï¼ˆå·²åš / æœªåšåˆ†å¼€å£°æ˜Žï¼‰
**已做(确定性)**
- **后端 `mvn -o -pl yudao-module-qcreport -am test`:`Tests run: 141, Failures: 0, Errors: 0` å…¨ç»¿**
  ï¼ˆÂ§19/§20 è®°å½•为 137;本轮新增 4 æ¡å…¨åœ¨ `MesQcReportContextMapperTest`,该类共 15 é¡¹ï¼š
  `mapsGroupStructure` / `groupAnchorDoesNotWarnAboutMissingActualValue` /
  `noGroupingLeavesGroupNull` / `warnsOnSkippedRows`)。
  âš  **必须带 `-am`**:不带会拿到本地仓库里旧的 `mes-api` jar,报
  `NoSuchMethodError â€¦ getGroupChildName()`。
- `mvn -o compile -pl yudao-module-mes -am -q` å¹²å‡€ã€‚
- **前端对拍 `npx tsx .qc-conformance/assemble-ts.ts`**:全绿,组件数断言由 12 æ”¹ä¸º **13**。
- **新增断言探针 `.qc-conformance/verify-grouped-table.ts`**(绿):三节 â€”—
  â‘  æ‰“在**真实 `buildContent` äº§ç‰©**上:表头/行模板各 7 æ ¼ã€`rowspan` **只挂在一格上**
  ï¼ˆæŒ‚错格 = ç»„名跨错列)、重复标记落在 `tbody/tr`;② ç»„头 + å­é¡¹ + ç‹¬ç«‹é¡¹æ··æŽ’的逐格断言;
  â‘¢ **降级**:整份数据都没有 `group` æ—¶é€€åŒ–成普通表而不是塌掉(无 `rowspan`/`hidden`、
  è¡Œä»å æ»¡ 7 åˆ—、内容照常渲染),且缺口以 `item.group.span` è¿› `errors` æš´éœ²ã€**不静默**。
- **浏览器实测**见 Â§21.3(列位、截图)。
**未做 / ä¸ä¸»å¼ **
- **`FrontendConformanceTest` æ²¡æœ‰åŠ åˆ†ç»„ç”¨ä¾‹**:前后端逐字对拍钉的是「渲染产物一字不差」,
  è€Œåˆ†ç»„这一题的关键是**浏览器表格布局**,`rowspan` ä¸Ž `hidden` çš„解析结果**不是字面量能钉住的**。
  é’‰å­—面量只能证明「两边都跳过/都输出同一个属性」,证明不了列没串位。有效的证据是上面的
  æŽ¢é’ˆ + çœŸå®žæµè§ˆå™¨é€æ ¼æµ‹é‡ï¼Œæ•…本轮**不做**这条对拍,**如实声明为未做**。
- **没有走真实 HTTP è”è°ƒ**(真实 MES æ•°æ® + é‡å¯åŽç«¯ + UI éªŒæ”¶ï¼‰ï¼šæœ¬è½®éªŒè¯å…¨éƒ¨åœ¨
  å•å…ƒ/对拍/探针层完成。**这一条是「没做」,不是「通过」。**
- **`GroupedQualityTable` çš„ AI é€‰åž‹å‡†ç¡®çŽ‡æœªæµ‹**:上一轮的实测都是针对
  `ReportHeader` / `ReportFooter` çš„选型,**没有观察过模型会不会正确挑中这第 13 ä¸ªç»„ä»¶**。
  æ¨¡åž‹é€‰åž‹åªèƒ½é çœŸå®žè°ƒç”¨è§‚察,不伪造确定性。
### 21.8 æœ¬è½®é¡ºå¸¦å‘现(**未修**,交用户判断)
- **`docs/sql/config_export_all_20260918.sql` å·²ä¸Žè¿è¡Œåº“漂移**(实测比对 information_schema ä¸Ž
  å¯¼å‡ºæ–‡ä»¶çš„ `CREATE TABLE` åˆ—集合):
  | é¡¹ | æ•°é‡ |
  |---|---|
  | è¿è¡Œåº“有、导出里**没有的表** | 3(`after_sale_problem` / `after_sale_visit` / `bpm_process_auto_approve_config`) |
  | è¿è¡Œåº“有、导出里**没有的列** | **86** |
  | å¯¼å‡ºé‡Œæœ‰ã€è¿è¡Œåº“**没有的表** | 6(`bpm_bak_20260904_*` å¤‡ä»½è¡¨å·²ä¸å­˜åœ¨ï¼‰ |
  å…¶ä¸­å±žæœ¬è½®ç›¸å…³çš„只有 `mes_qc_indicator` çš„ 5 åˆ—(`parent_id` / `item_type` / `formula_text` /
  `unit_text` / `sort_order`);**其余 80 ä½™åˆ—来自更早的轮次**
  ï¼ˆå”®åŽé—®é¢˜åº“、BPM å…å®¡æ‰¹å¼€å…³ã€IQC/IPQC/OQC/RQC çš„审核与执行标准字段等)。
  æŒ‰ `sql-config-export-init.md`,新库初始化会**缺这些列**。**本轮未动该文件**:
  è¡¥å…¨å®ƒç­‰äºŽæŠŠå…¶ä»–轮次的漂移一并快照进来,属于要单独拍板的操作,不夹带在本轮里。
- **指标主数据(`mes_qc_indicator`)的层级字段没有 CRUD ç•Œé¢**:
  `SaveReqVO` / `RespVO` é‡Œæ²¡æœ‰ `parent_id` / `item_type` / `formula_text` /
  `unit_text` / `sort_order` çš„æ˜ å°„,**现在只能靠 SQL é€ åˆ†ç»„数据**。
  æœ¬è½®åªåšäº†ã€Œè¯»å‡ºæ¥æ¸²æŸ“成报告」,**没有做「编辑分组」**。
### 21.9 æµ‹è¯•数据清理
本轮**未上传文件、未建模板、未生成报告实例**,不产生 `blob` / é™„ä»¶ / æ¨¡æ¿ç‰ˆæœ¬ï¼›
`docs/` ä¸‹çš„æ”¹åŠ¨ä¸ºæ–‡æ¡£ï¼Œ`mom-pro2-before/.qc-conformance/` ä¸‹çš„产物为探针输出
(`.html` / `.png`,不入库)。用户自己上传的 `百事模版.docx`(blob 67/70、附件 73/74)**全程未动**。
## äºŒåäºŒã€è´¨æ£€æŒ‡æ ‡å±‚级可维护:分组数据从「只能 SQL é€ ã€åˆ°ã€Œç•Œé¢èƒ½ç¼–辑」(2026-09-19 ç»­ï¼‰
> æ‰¿æŽ¥ Â§21.8 çš„第二条发现:「指标主数据的层级字段没有 CRUD ç•Œé¢ï¼ŒçŽ°åœ¨åªèƒ½é  SQL é€ åˆ†ç»„数据」。
### 22.1 è¦è¡¥çš„æ˜¯ã€Œå†™ã€ï¼Œä¸æ˜¯ã€Œè¯»ã€
§21 åªåšäº†ã€Œ**读**」:报告侧已能按 `mes_qc_indicator.parent_id` æŠŠåˆ†ç»„项与它的子项还原成两层表头。
但「**写**」是缺的 â€”— `MesQcIndicatorSaveReqVO` / `RespVO` å®Œå…¨æ²¡æœ‰å±‚级字段映射,
`mes_qc_indicator` çš„ `parent_id` / `item_type` / `formula_text` / `sort_order` / `unit_text`
五列**在业务界面上无处可填**。
后果:现网那 3 ä¸ªåˆ†ç»„(`水分` / `酸含量` / `正丁醇含量`)只能靠 SQL é€ ï¼›
运维想加一个分组、把某个指标挂进分组、调整分组顺序,全都得进数据库。
**报告侧一行未改**:已有的分组还原逻辑按 `parent_id` å–数,
界面造出来的分组与 SQL é€ å‡ºæ¥çš„分组在报告里完全同形。
### 22.2 ç”¨æˆ·æ‹æ¿çš„四条口径
| å²”è·¯ | æ‹æ¿ |
|---|---|
| å­—段范围 | **只加层级四项**(条目类型 / æ‰€å±žåˆ†ç»„ / å…¬å¼ / æŽ’序号)。`unit_text` **不进界面** |
| åˆ é™¤æœ‰å­é¡¹çš„分组 | **级联删除子项** |
| åˆ—表呈现 | **平铺表格 + ä¸¤åˆ—**(条目类型 / æ‰€å±žåˆ†ç»„),不改 vxe æ ‘表格 |
| å±‚级深度 | **只允许两层** |
`unit_text` ä¸è¿›çš„依据(实测,非推断):全模块 grep è¯¥åˆ—只出现在 `MesQcIndicatorDO.java` ä¸€å¤„声明,
**没有任何读取方**。报告的「单位」取自检验方案行的 `unit_measure_id` â†’ `mes_md_unit_measure.name`,
回落才是指标自带单位。界面放一个没人读的字段只会误导录入人。
### 22.3 æ•°æ®å®žè¯ï¼ˆå®¹å™¨ `mysql8` / `ruoyi-vue-pro`,`deleted=0`)
34 è¡Œæœ‰æ•ˆæ•°æ®ï¼š31 æ¡å½•入项 + 3 æ¡åˆ†ç»„项;**层级列一条 NULL éƒ½æ²¡æœ‰**。
| åˆ— | åˆ†ç»„项(`item_type=2`,3 è¡Œï¼‰ | å½•入项(`item_type=1`,31 è¡Œï¼‰ |
|---|---|---|
| `parent_id` | 3 æ¡å…¨ä¸º `0` | 9 æ¡ä¸ºåˆ†ç»„ id,22 æ¡ä¸º `0` |
| `result_type` | **全为 NULL** | å…¨æœ‰å€¼ï¼ˆ1=FLOAT) |
| `formula_text` | å…¨æœ‰å…¬å¼ï¼ˆå¦‚ `w = (m1 - m0) / m Ã— 100%`) | å…¨ä¸º NULL |
| `unit_text` | å…¨ä¸º NULL | æœ‰å€¼ï¼ˆ`g` / `%` / `mol/L` / `mL`) |
| `sort_order` | é¡¶çº§ 30 / 40 / 50 | é¡¶çº§ 10..70,子项 1..4 |
两条由此推出的硬口径:
1. **分组项的 `result_type` ä¸º NULL** â‡’ `SaveReqVO` ä¸Š `resultType` çš„ `@NotNull` å¿…须拿掉,
   æ”¹æˆã€Œä»…录入项必填」的服务端条件校验;落库时分组项强制清空 `resultType` / `resultSpecification`。
2. **`sort_order` æ˜¯ `NOT NULL DEFAULT 0`**,但没有「同级步长」约束(顶级步长 10,子项步长 1)
   â‡’ æ–°è®°å½•留空时由服务端按下发默认值补齐。
层级列与索引 `idx_qc_indicator_parent_id` **现网已存在**(另一开发者的
`feat(mes): æ–°å¢žè´¨æ£€æŒ‡æ ‡åˆ†ç»„树和平行测量功能` å¸¦è¿›æ¥çš„)⇒ **本轮无表结构变更,
不动 `docs/sql/config_export_all_*.sql`。**
### 22.4 å…­æ¡å±‚级校验规则(按业务优先级只抛第一处)
| é¡ºåº | æ¡ä»¶ | é”™è¯¯ç  |
|---|---|---|
| 1 | åˆ†ç»„项却带 `parentId != 0` | `1040601007` `QC_INDICATOR_GROUP_CANNOT_NEST` |
| 2 | `parentId == id` | `1040601006` `QC_INDICATOR_PARENT_SELF` |
| 3 | çˆ¶æŒ‡æ ‡ä¸å­˜åœ¨ | `1040601004` `QC_INDICATOR_PARENT_NOT_EXISTS` |
| 4 | çˆ¶æŒ‡æ ‡ä¸æ˜¯åˆ†ç»„项 | `1040601005` `QC_INDICATOR_PARENT_NOT_GROUP` |
| 5 | å½•入项而 `resultType` ä¸ºç©º | `1040601009` `QC_INDICATOR_RESULT_TYPE_REQUIRED` |
| 6 | æ”¹æŒ‚分组 / æ”¹æˆå½•入项,但该指标已有子项 | `1040601008` `QC_INDICATOR_HAS_CHILDREN`(两种场景**文案各自独立**) |
**「只允许两层」没有单独写一条校验** â€”— å®ƒç”±è§„则 1 + è§„则 4 è‡ªåŠ¨æŽ¨å‡ºï¼šçˆ¶å¿…é¡»æ˜¯åˆ†ç»„é¡¹ï¼Œ
而分组项只能顶级,所以第三层不可构造。**不写一条可达性为假的校验。**
抛错一律带具体值(分组名 / çˆ¶æŒ‡æ ‡å / å­é¡¹æ¡æ•°ï¼‰ä¸Žå¯æ‰§è¡ŒåŠ¨ä½œï¼Œ
遵守 `error-message-precision.md`,不写成笼统句。
### 22.5 ä¿®æŽ‰ä¸€ä¸ªçœŸå®žçš„写入缺陷(单测抓出来的,不是测试假象)
`testUpdateIndicator_toGroup_clearsResultFields` æ–­è¨€å¤±è´¥ï¼š`expected: <null> but was: <5>`。
根因:MyBatis-Plus çš„ `updateById` é»˜è®¤ç”¨ `FieldStrategy.NOT_NULL`,**会把 `null` å­—段从 UPDATE
语句里剔除**。所以「录入项改成分组项」时,`normalizeHierarchy` è™½ç„¶æŠŠ `resultType` ç½®äº† `null`,
**这个 null æ ¹æœ¬æ²¡å†™è¿›åº“**,旧的结果值 `5` æ®‹ç•™ä¸‹æ¥ã€‚
这是**生产缺陷**,不是测试写法问题:改成分组项后结果值会静默留着。
修法是 DO çº§å£°æ˜Žå¼ï¼š
```java
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Integer resultType;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String resultSpecification;
```
选这条而不是显式 `LambdaUpdateWrapper` çš„理由:仓库有现成先例
(`DictDataDO.java:60`、`CrmContractConfigDO.java`、`CrmCustomerPoolConfigDO.java`),
且该 DO åªç”± `MesQcIndicatorServiceImpl` å†™å…¥ï¼ŒåŠ æ³¨è§£çš„çˆ†ç‚¸åŠå¾„å¯æŽ§ã€‚
**这段价值在于:单测钉住了 `updateById` çš„ null å‰”除行为,而这在纯读代码时不会暴露。**
顺带修的另一处:`validateResultSpecification` åŽŸæœ¬æ˜¯ä¸¤å‚ï¼Œè¢«ç»§æ‰¿çš„ä»£ç ç”¨ä¸‰å‚è°ƒç”¨ï¼ˆç¼–è¯‘ä¸è¿‡ï¼‰ã€‚
改为按条目类型提前返回 â€”— **分组项的结果值落库前会被清空,所以不该被「结果值属性不能为空」拦住**。
### 22.6 æ”¹åŠ¨æ¸…å•
**后端 `yudao-module-mes`(改)**
- `MesQcIndicatorDO`:加常量 `PARENT_ID_ROOT = 0L`;`resultType` / `resultSpecification`
  åŠ  `@TableField(updateStrategy = FieldStrategy.ALWAYS)`(见 Â§22.5)。
- `MesQcIndicatorSaveReqVO`:加 `itemType` / `parentId` / `formulaText` / `sortOrder`;
  `resultType` åŽ»æŽ‰ `@NotNull`。
- `MesQcIndicatorRespVO`:加 `parentId` / `itemType` / `formulaText` / `sortOrder` / `parentName`;
  `@ExcelProperty` åªæ‰“ `所属分组` / `公式` / `排序号` ä¸‰å¤„(`itemType` ä¸æ‰“注解,
  ç±»ä¸Šå·²æœ‰ `@ExcelIgnoreUnannotated` â‡’ ä¸è¿› Excel)。
- `MesQcIndicatorPageReqVO`:加 `itemType`。
- `MesQcIndicatorMapper`:加 `selectListByItemType` / `selectListByParentId` /
  `selectCountByParentId` / `selectMaxSortOrder`;`selectPage` è¿½åŠ  `eqIfPresent(itemType)`。
- `MesQcIndicatorService` / `Impl`:加 `getIndicatorGroupList()`;新增 `validateHierarchy` /
  `normalizeHierarchy`;`deleteIndicator` æ”¹çº§è”删子项;`validateResultSpecification` æ”¹ä¸‰å‚。
- `MesQcIndicatorController`:`/page` ä¸Ž `/export-excel` ç”¨ç§æœ‰ `fillParentName` å›žå¡«
  `parentName`;新增 `GET /group-list`。
- `ErrorCodeConstants`:`_004` ~ `_009` å…­æ¡æ–°é”™è¯¯ç ã€‚
**后端(测试)**
- æ–°å¢ž `MesQcIndicatorServiceImplTest`(**14 ä¾‹**,`BaseDbUnitTest` é£Žæ ¼ï¼ŒH2 + çœŸå®ž Mapper)。
- `src/test/resources/sql/create_tables.sql` è¡¥ `CREATE TABLE IF NOT EXISTS "mes_qc_indicator"`
  ï¼ˆ**纯追加 26 è¡Œï¼Œ0 åˆ é™¤**);`clean.sql` è¡¥ `DELETE FROM "mes_qc_indicator";`。
**前端 `mom-pro2-before`(改)**
- `packages/constants/src/biz-mes-enum.ts`:加 `MesQcIndicatorItemType = { ENTRY: 1, GROUP: 2 }`。
  **不做字典** â€”— 1/2 ä¸¤ä¸ªå€¼åœ¨åŽç«¯æžšä¸¾ä¸ŽæŠ¥å‘Šè¿˜åŽŸé€»è¾‘é‡Œéƒ½æ˜¯ç¡¬ç¼–ç ï¼Œåšæˆå¯æ”¹å€¼å­—å…¸ä¼šä¸Žä»£ç è„±èŠ‚ï¼›
  åŒé¡µåŒè¡¨çš„ `MesQcResultValueType` å°±æ˜¯èµ°çš„常量路。**本轮不产生任何 DB / é…ç½®å˜æ›´ã€‚**
- `api/mes/qc/indicator/index.ts`:`Indicator` æŽ¥å£åŠ  5 ä¸ªå­—段;加 `getIndicatorGroupList()`。
- `views/mes/qc/indicator/data.ts`:表单加 æ¡ç›®ç±»åž‹ï¼ˆRadioGroup)/ æ‰€å±žåˆ†ç»„(`ApiTreeSelect`)/
  å…¬å¼ï¼ˆTextarea,仅分组项)/ æŽ’序号(InputNumber);搜索表单加「条目类型」;
  åˆ—表列加「条目类型」(slot Tag)与「所属分组」(`parentName`,顶级显示 `-`)。
- `views/mes/qc/indicator/index.vue`:加 `#itemType` / `#parentName` ä¸¤ä¸ªæ’槽。
**没有新增菜单与权限码**:`/group-list` å¤ç”¨æ—¢æœ‰çš„ `mes:qc-indicator:query`。
### 22.7 å‰ç«¯ä¸¤å¤„「必须这么写」的细节
1. **切换条目类型时主动清空录入项专属字段**:`itemType` çš„ `dependencies.trigger` é‡ŒæŠŠ
   `parentId` ç½® `0`、`resultType` / `resultSpecification` ç½® `undefined`。
   **为什么必须清**:vben çš„ `dependencies.if` æ˜¯ã€Œåˆ é™¤ DOM」语义,被移除的字段,
   å…¶å€¼**仍留在表单数据里**,会一起提交;残留的 `parentId` ä¼šè¢«åŽç«¯å±‚级校验顶回来。
2. **`resultType` çš„必填从静态 `rules: 'required'` æ”¹ä¸ºæŒ‰æ¡ç›®ç±»åž‹æ¡ä»¶åˆ¤æ–­**:
   åˆ†ç»„项在表单上不展示该字段(`dependencies.if`),也不参与校验;
   æœåŠ¡ç«¯åŒæ ·æŒ‰æ¡ç›®ç±»åž‹æ¡ä»¶æ ¡éªŒï¼ˆè§ Â§22.4 è§„则 5)。
### 22.8 éªŒè¯ï¼ˆå·²åš / æœªåšåˆ†å¼€å£°æ˜Žï¼‰
**已做(确定性)**
- `mvn compile -pl yudao-module-mes -am -q` â†’ å¹²å‡€é€€å‡ºã€‚
- `mvn -o -pl yudao-module-mes test -Dtest=MesQcIndicatorServiceImplTest` â†’
  **`Tests run: 14, Failures: 0, Errors: 0`,BUILD SUCCESS**。
  ç”¨ä¾‹é€æ¡è¦†ç›– Â§22.4 çš„规则:建分组成功(结果值被清空、`sortOrder` è‡ªåŠ¨è¡¥ 10)/
  å»ºå­é¡¹æˆåŠŸï¼ˆ`sortOrder` = åŒçº§ max+1)/ é¡¶çº§æ­¥é•¿ 10 / åˆ†ç»„带 `parentId` è¢«æ‹’ /
  çˆ¶ä¸æ˜¯åˆ†ç»„项被拒 / çˆ¶ä¸å­˜åœ¨è¢«æ‹’ / çˆ¶æ˜¯è‡ªå·±è¢«æ‹’ / å½•入项缺结果值被拒 /
  ç»“果值属性缺失被拒 / åˆ†ç»„项不可嵌套 / æ”¹æˆåˆ†ç»„项清结果值 / æœ‰å­é¡¹ä¸èƒ½æ”¹æŒ‚分组 /
  æœ‰å­é¡¹ä¸èƒ½æ”¹æˆå½•入项 / çº§è”删除 / æŒ‡æ ‡ä¸å­˜åœ¨ã€‚
- å‰ç«¯ `pnpm typecheck`:**本轮改动的文件 0 æŠ¥é”™**
  ï¼ˆ`wls/*` ç­‰æ—¢æœ‰æŠ¥é”™ä¸åœ¨æœ¬è½®èŒƒå›´ï¼‰ã€‚
**已做(真实 HTTP + UI éªŒæ”¶ï¼Œ2026-09-19 ç”¨æˆ·é‡å¯åŽç«¯åŽï¼‰**
重启前先核实了「跑的是不是新类」:`/group-list` å½“时返回 404、`/page` çš„五个新字段全 `null`,
而权威库(`@@hostname=2a8dea946ada`)这些列**一条 NULL éƒ½æ²¡æœ‰** â‡’ åˆ¤ä¸ºã€Œéƒ¨åˆ†æ—§ã€éƒ¨åˆ†æ–°ã€çš„
混合 classpath,请用户重启。重启后 `/group-list` è¿”回 3 ä¸ªåˆ†ç»„,验收随即跑通。
**HTTP éªŒæ”¶ï¼ˆ14 é¡¹ï¼Œå…¨è¿‡ï¼‰**
| # | ç”¨ä¾‹ | ç»“æžœ |
|---|---|---|
| 1 | `/page?itemType=2` è¿‡æ»¤ | `total=3`,只返回分组项 |
| 2 | å­é¡¹ `parentName` å›žå¡« | å­é¡¹ 22/23/24 çš„ `parentName` = `水分`(**父指标不在同一次筛选结果里也能解析**) |
| 3 | å»ºåˆ†ç»„(带 `resultType=1`、`sortOrder` ç•™ç©ºï¼‰ | æˆåŠŸï¼›è½åº“ `result_type=NULL`(被清空)、`sort_order=80`(同级 max 70 + 10) |
| 4 | å»ºé¡¶çº§å½•入项(`sortOrder` ç•™ç©ºï¼‰ | æˆåŠŸï¼›`sort_order=90`(max 80 + 10) |
| 5 | è´Ÿå‘:分组项带 `parentId` | `1040601007`,文案含`[ZZ非法嵌套分组]`+`[水分]`+改法 |
| 6 | è´Ÿå‘:父是录入项(id=1 é…¸å€¼ï¼‰ | `1040601005`,文案含父名 + å…¶æ¡ç›®ç±»åž‹ä¸­æ–‡å + æ”¹æ³• |
| 7 | è´Ÿå‘:父是自己 | `1040601006`「父指标不能是自己」 |
| 8 | è´Ÿå‘:录入项缺结果值类型 | `1040601009` |
| 9 | è´Ÿå‘:父指标不存在(`parentId=999999`) | `1040601004`,文案含编号 999999 + æ”¹æ³• |
| 10 | æŒ‚子项(`sortOrder` ç•™ç©ºï¼‰ | æˆåŠŸï¼›`sort_order=1`(**同级步长 1**,与顶级的 10 åŒºåˆ†å¼€ï¼‰ |
| 11 | è´Ÿå‘:有子项不能改成录入项 | `1040601008`,文案含子项条数 + ã€Œè¯·å…ˆåˆ é™¤æˆ–移走这 1 ä¸ªå­é¡¹ã€ |
| 12 | æ”¹æŒ‚到现网分组 21「水分」 | æˆåŠŸï¼Œå›žè¯» `parent_id=21` |
| 13 | åˆ åˆ†ç»„(带 2 ä¸ªå­é¡¹ï¼‰ | æˆåŠŸï¼›**回读分组与其 2 ä¸ªå­é¡¹ `deleted` å…¨éƒ¨ç¿»ä¸º 1**(级联) |
| 14 | å¯¼å‡º Excel | HTTP 200 / 6 350 å­—节;解包 `sheet1.xml` è¯»è¡¨å¤´ = `编号|检测项编码|检测项名称|检测项类型|检测工具|所属分组|公式|排序号|结果值类型|结果值属性|备注|创建时间` â€”— **三列新增都在、`条目类型` ç¡®å®žä¸åœ¨** |
**⚠ ä¸€æ¡å£å¾„需要说清(用例 11 çš„æ—æ”¯ï¼‰**:规则 6 çš„**第一个分支**(「有子项时不能挂到别的分组下」)
**经界面/接口不可达** â€”— èƒ½å¸¦å­é¡¹çš„只有分组项,而分组项想选 `parentId` ä¼šå…ˆè¢«è§„则 1
(`1040601007`)拦掉(实测用例「有子项不能挂到别的分组下」返回的正是 `1040601007` è€Œä¸æ˜¯ `1008`,
**这是设计使然,不是缺陷**:规则 1 ç»™çš„æ”¹æ³•更可执行)。该分支只在**库里存在「条目类型=录入项却有子项」
的 SQL é€ æ•°**时才可能命中的,单测 `testUpdateIndicator_hasChildrenCannotReparent`
正是**直接用 mapper é€ å‡ºè¿™ç§è¡Œ**去覆盖它。规则 6 çš„第二个分支(改成录入项)经界面完全可达(用例 11)。
**UI éªŒæ”¶ï¼ˆPlaywright,全过)**
- **列表两列**:表头为 `检测项编码 / æ£€æµ‹é¡¹åç§° / æ¡ç›®ç±»åž‹ / æ‰€å±žåˆ†ç»„ / æ£€æµ‹é¡¹ç±»åž‹ / â€¦`;
  åˆ†ç»„项显示 Tag `分组项` + æ‰€å±žåˆ†ç»„ `-`,子项显示 Tag `录入项` + æ‰€å±žåˆ†ç»„为**父分组名**。
- **新增表单默认态**:条目类型 RadioGroup é»˜è®¤é€‰ä¸­ã€Œå½•入项」(`z.number().default()` ç”Ÿæ•ˆï¼‰ï¼›
  å¯è§å­—段为 ç¼–码 / åç§° / æ¡ç›®ç±»åž‹ / æ‰€å±žåˆ†ç»„ / æ£€æµ‹é¡¹ç±»åž‹ / æ£€æµ‹å·¥å…· / æŽ’序号 / ç»“果值类型 / å¤‡æ³¨ï¼Œ**无公式**。
- **切到「分组项」**:`所属分组` ä¸Ž `结果值类型` **从 DOM æ¶ˆå¤±**,`公式` å‡ºçŽ°ä¸”å¸¦å¿…å¡«æ˜Ÿå·ã€‚
- **走完整条链路**:UI å»ºåˆ†ç»„(`ZZ-UI-GRP`)→ è½åº“ `item_type=2 / parent_id=0 / result_type=NULL / formula_text` é½å…¨ï¼›
  å†å»ºå½•入项并**从下拉树里选中新分组**(树节点列出 `水分 / é…¸å«é‡ / æ­£ä¸é†‡å«é‡ / ZZ界面分组` 4 é¡¹ï¼Œ
  æ–°å¢žçš„分组**立即可选**)→ è½åº“ `parent_id=40 / sort_order=1`。
- **UI åˆ é™¤åˆ†ç»„**:二次确认气泡文案为「确定删除 ZZ界面分组 å—?」;确认后**回读分组与其子项 `deleted` å…¨ä¸º 1**。
- **编辑分组(只读检查,未保存)**:打开现网分组 21「水分」的修改弹窗 â€”— `所属分组` **不在 DOM é‡Œ**、
  `公式` å¸¦å¿…填星号,且 `code / name / formulaText / sortOrder=30 / remark` å…¨éƒ¨æ­£ç¡®å›žå¡«ã€‚
**未做 / ä¸ä¸»å¼ **
- **`group-list` åœ¨åˆ†ç»„项数量很大时的表现未评估**:目前是全量返回、无分页。
- **没有在界面上逐一试过全部 6 æ¡é”™è¯¯ç çš„ toast å‘ˆçް**(错误码与文案在 HTTP å±‚已逐条验过;
  UI å±‚验的是表单形态与链路,未逐条制造错误看 Toast)。
### 22.9 æœ¬è½®é¡ºå¸¦è¸©åˆ°çš„工程坑(值得复用)
- **`mvn -o -pl yudao-module-mes test`(不带 `-am`)会拿到本地仓库里陈旧的 `mes-api` jar**,
  æŠ¥ `找不到符号 setGroupChildName/setGroupSpan/...` ä¹‹ç±»çš„假编译错误。
  ä¿®æ³•:先 `mvn -o -pl yudao-module-mes-api install -DskipTests -q` åˆ·æ–°ï¼Œæˆ–直接带 `-am`。
  **§21.7 è®°è¿‡åŒä¸€æ¡ï¼Œæœ¬è½®åˆå¤çŽ°ä¸€æ¬¡ã€‚**
- **全模块测试套件有 16 ä¸ªç±»ã€107 é¡¹å¤±è´¥ï¼Œä¸Žæœ¬è½®æ— å…³ï¼Œä¸”已用证据证伪「是我改坏的」**:
  æ ¹å› æ˜¯ä¸¤ç±»**存量**问题 â€”—
  â‘  `NoSuchBeanDefinitionException: SrmSupplierApi`(`MesQcIqcServiceImpl` æ³¨å…¥å®ƒï¼Œ
  ä½†å…¶å·²æäº¤çš„æµ‹è¯•没有 `@MockitoBean`);② H2 æµ‹è¯• DDL æ¼‚ç§»
  ï¼ˆ`Column "param_template_id"/"exclude_weekend"/"process_instance_id" not found`)。
  è¯æ®é“¾ï¼š`git status --short` å¯¹è¿™äº›è·¯å¾„**为空**(未被我触碰);
  `git diff --stat create_tables.sql` = **26 insertions(+), 0 deletions**(纯追加);
  `git show HEAD:...create_tables.sql | grep -c <列名>` **三列在 HEAD é‡Œå°±å·²æ˜¯ 0**。
  **这三条属于其他开发者的轮次,本轮只报告、不代修。**
- **判断「跑的是不是旧类」的可复用探针**:拿一个**只有新代码才有的接口**打一次
  ï¼ˆæœ¬è½®æ˜¯ `/group-list`)。返回 404 å³è¿›ç¨‹é‡Œæ²¡æœ‰æ–° Controller;
  è¿”回 200 ä½†å­—段全 `null` åˆ™æ˜¯ã€Œéƒ¨åˆ†æ—§ã€éƒ¨åˆ†æ–°ã€çš„æ··åˆ classpath â€”— **比看文件 mtime æ›´ç›´æŽ¥**。
### 22.10 æµ‹è¯•数据清理
验收期间共造 6 è¡Œï¼ˆHTTP:35/36/37/38/39;UI:40/41),**全部已从库中硬删除**,
回读与开工前逐项一致:
| é¡¹ | æ¸…理后 | å¼€å·¥å‰ |
|---|---|---|
| `code LIKE 'ZZ-%'` æ®‹ç•™ | `0` | 0 |
| `deleted=0` æ€»è¡Œæ•° | `34` | 34 |
| åˆ†ç»„项(`item_type=2`) | `3` | 3 |
| æœ‰çˆ¶çš„行(`parent_id<>0`) | `9` | 9 |
| ã€Œæ°´åˆ†ã€çš„子项数 | `4` | 4 |
| `MAX(id)` | `34` | 34 |
| åˆ†ç»„ 21 æœ¬èº«ï¼ˆ`SEED-BT-WATER|水分|parent_id=0|item_type=2|sort_order=30|公式`) | é€å­—段未变 | åŒ |
**现网那 34 è¡Œï¼ˆå« 21/26/31 ä¸‰ä¸ªåˆ†ç»„与子项 22–25 / 27–30 / 32)全程未动**;
单元测试跑在 H2 å†…存库(`jdbc:h2:mem:testdb`),每次用例后用 `clean.sql` æ¸…表,**不碰运行库**。
本轮无上传,不涉及 `blob` / é™„件。临时脚本与解包产物放在仓库外的
`D:/qcl-tmp/`(已 `mv` æ”¹åå½’档,`rm -rf` è¢«æƒé™æ‹’绝)。
## äºŒåä¸‰ã€AI å¯¼å…¥è®¤ä¸å‡ºã€Œåˆ†ç»„检验项表」:根因在抽取层丢合并信息(2026-09-19 ç»­ï¼‰
### 23.1 çŽ°è±¡
用户拿真实来料检验报告 `百事模版.docx` èµ° AI å¯¼å…¥ï¼Œè¯†åˆ«å‡ºæ¥çš„æ£€éªŒè¡¨æ˜¯**一层平铺**的
`QualityTable`,而原件的检验项是**两层结构**(一个检验项目下挂若干子项,项目名纵向合并)。
用户在硬刷新(Ctrl+F5)后复测仍是同样结果。
### 23.2 æˆ‘先误判过一次,记在这里避免重犯
第一次排查时我拿 `hint = "这是 IQC æ¥æ–™æ£€éªŒæŠ¥å‘Šï¼Œè¡¨æ ¼æœ‰åˆ†ç»„的检验项目"` å¤è·‘,
模型 3/3 éƒ½æŒ‘中了 `GroupedQualityTable`,于是我判定根因是「前端积木清单过期、少了这个组件」。
**这个结论是错的**,而且错的原因就在我自己:那句 hint ç­‰äºŽæŠŠç­”案写在了题干里,
模型不需要从表格里看出分组结构,只要读 hint å°±å¤Ÿäº†ã€‚用户硬刷新后现象依旧,证伪了这条判断。
**教训(可复用)**:复现「模型选错组件」这类问题时,`hint` **必须留空**(或原样照抄用户输入的),
否则测的是「提示词里有没有写答案」,不是「模型能不能自己看出来」。
### 23.3 çœŸæ ¹å› ï¼ˆå®žè¯ï¼ŒéžæŽ¨æ–­ï¼‰
拿空 hint å¤è·‘(清单 13 é¡¹ã€`GroupedQualityTable` åœ¨åœºï¼‰ï¼Œæ¨¡åž‹**每次都挑平铺的 `QualityTable`**。
于是转向看模型到底收到了什么——把真实 docx çš„æŠ½å–结果打出来,问题一目了然:
`OfficeImportAdapter` ç”¨ `row.getTableCells()` ä¸€æ ¼æ ¼å–文字再 Tab æ‹¼æŽ¥ï¼Œè€Œ POI å¯¹åˆå¹¶å•元格的行为是:
| Word é‡Œçš„写法 | POI ç»™ä½ ä»€ä¹ˆ | åŽæžœ |
|---|---|---|
| `w:gridSpan="2"`(横向合并) | åªè¿”回 1 ä¸ª `XWPFTableCell` | è¢«ç›–住的后几列**凭空消失**,该行列数与表头对不上 |
| `w:vMerge`(纵向合并续格) | æ–‡å­—是**空串** | åˆ†ç»„名只在第一行出现,后几行看起来是「没有分组的独立检验项」 |
同一次识别的抽取结果对照(真实文件,非构造样本):
```
改前:粒度-筛上物比例(%)Particle Size-% RemainsON    20目上    ANA.MTH-000034    0-5        åˆæ ¼Checkout
          40目上    ANA.MTH-000034    20-40
          60目上    ANA.MTH-000034    30-50
          80目上    ANA.MTH-000034    0-20
          100目上    ANA.MTH-000034    0-12
改后:粒度-筛上物比例(%)Particle Size-% RemainsON    20目上    ANA.MTH-000034    0-5        åˆæ ¼Checkout
      â†‘同上    40目上    ANA.MTH-000034    20-40        â†‘同上
      â†‘同上    60目上    ANA.MTH-000034    30-50        â†‘同上
      â†‘同上    80目上    ANA.MTH-000034    0-20        â†‘同上
      â†‘同上    100目上    ANA.MTH-000034    0-12        â†‘同上
```
改前的后四行是「首格空的平铺行」——**模型没有任何依据判断它们属于上面那一行**,
题目本身就无解;改后「与上一行同值」被明说出来,两层结构才第一次进入模型的视野。
### 23.4 æ”¹åŠ¨ï¼ˆ3 ä¸ªç”Ÿäº§æ–‡ä»¶ + 1 ä¸ªæµ‹è¯•文件)
| æ–‡ä»¶ | æ”¹åЍ |
|---|---|
| `OfficeImportAdapter.appendBodyElements()` | è¡¨æ ¼åˆ†æ”¯æ¢æˆ `appendTable()`:按 `w:gridSpan` è¡¥ç©ºå ä½ã€æŒ‰ `w:vMerge` æ ‡ç»­æ ¼ã€æ¯è¡ŒæŒ‰**整表网格宽度**(取所有行的最大值,不是表头行)补齐 |
| `QcReportDocumentExtract` | æ–°å¢žå¸¸é‡ `VERTICAL_MERGE_MARK = "↑同上"`。放在这里是因为它是「抽取出来的文本长什么样」这条契约的一部分,而 `llm` åŒ…已经依赖 `document` åŒ…(`QcReportLlmCallPlanner` å°±å¼•了 `QcReportDocumentExtract`),两边共用一份常量,不会各写一个字面量后各自漂移 |
| `QcReportTemplatePromptBuilder.EXTRACTION_RULES` | è¡¥ç¬¬ 8 æ¡ï¼Œè¯´æ˜Žã€Œâ†‘同上」表示「与上一行是同一个值」(纵向合并),不是四个字的普通内容;并指出同一列连续出现时通常就是两层结构——**但挑哪个组件仍以清单 hint ä¸ºå‡†**,不在这里替 `hint` åšå†³å®š |
| `AiImportFixtures` + `QcReportImportAdapterTest` | æ–°å¢žä¸¤ä¸ªå¤¹å…·ï¼ˆçºµå‘合并 / æ¨ªå‘合并)与两条用例 |
两处刻意的取舍:
- **续格自身有文字时以文字为准**,不覆盖成标记。续格在 Word é‡Œæœ¬ä¸è¯¥æœ‰å†…容,真有就说明制表不规范,
  æ­¤æ—¶ä¸¢æ–‡å­—比丢结构更可惜。
- **网格宽度取所有行的最大值**,不取表头行的列数:表头常常带横向合并,按表头宽度切会把下面几行截断。
### 23.5 éªŒè¯ï¼ˆå·²åš / æœªåšåˆ†å¼€å£°æ˜Žï¼‰
**已做**
- `mvn compile -pl yudao-module-qcreport -am -q` é€šè¿‡ã€‚
- `mvn -o -pl yudao-module-qcreport -am test` â†’ **143 / 143 å…¨ç»¿**(新增 2 æ¡åœ¨å†…)。
  å…¶ä¸­ `docxKeepsLayoutOrder` ç”¨æ— åˆå¹¶å¤¹å…·ï¼Œæ”¹åŽè¾“出逐字未变,证明「没有合并的表格不受影响」。
- **用改后的真实类跑真实文件**(`D:/qcl-tmp/MergeProbe.java`,classpath æŒ‡åˆ° `target/classes`,
  è¾“入用户那份 142373 å­—节的 `bs.docx`):抽取结果即 23.3 çš„「改后」形态,
  5 è¡Œ `粒度-筛上物比例` çš„子项全部带上 `↑同上`,表头行也从 5 æ ¼è¡¥æˆä¸Žæ•°æ®è¡Œå¯¹é½çš„ 6 æ ¼ã€‚
**未做(截至本节落笔)**
- **真实 HTTP å¤è·‘尚未执行**:运行中的后端仍是改前的类(可用 22.9 è®°å½•的判定探针确认),
  éœ€è¦ç”¨æˆ·**手工重启后端**。重启后要用 **空 hint** æ‰“一次 `/qc-report/ai-import/draft`(blob ä»¥å½“次上传为准,
  AI å¯¼å…¥å¼¹çª—每次上传都会清掉上一个 blob,id ä¼šå˜ï¼‰ï¼Œç¡®è®¤æ¨¡åž‹è‡ªå·±æ”¹æŒ‘ `GroupedQualityTable`。
  **在跑完这一步之前,只能声明「抽取层的输入已经具备判别两层结构所需的信息」,
  ä¸èƒ½å£°æ˜Žã€ŒAI å·²ç»èƒ½è®¤å‡ºå¤æ‚结构」**——后者的责任在模型,只有真实调用才能回答。
  > åŽç»­ï¼šé‡å¯åŽå¤è·‘结果是 **2/5**(不是全中),根因转到提示词措辞上,见**第二十四节**。
### 23.6 æœ¬è½®æœªåš / å·²çŸ¥è¾¹ç•Œï¼ˆå¦‚实声明)
- **语义层的缺口没动**:原件里「检测方法」这一列、以及「检验结果 / ç»“论」这种**两级表头**,
  13 ä¸ªç»„件里没有任何一个能表达。这不是抽取层的问题——文字已经完整抽出来了(见 23.3 çš„行内容),
  æ˜¯ã€Œå¯ç”¨ç§¯æœ¨ã€çš„表达力边界。要补就得新增一个「可自定列的检验表」组件,
  æŒ‰é¡¹ç›®è§„则属于**先出 `docs/` è®¾è®¡æ–‡æ¡£å†åŠ¨æ‰‹**的那类改动,本轮不碰。
- **清单大小没进日志**:`QcReportAiImportServiceImpl` çš„完成日志有 `files/pages/calls/components/budgetExceeded/durationMs`,
  ä½†æ²¡æœ‰ catalog é¡¹æ•°ã€‚下次再遇到「模型选了不存在的组件」这类问题时,这一项能一眼排除「清单传错了」。
  æ”¹åŠ¨å¾ˆå°ï¼Œä½†ä¸åœ¨ç”¨æˆ·æœ¬è½®ã€Œå¼€å§‹ä¼˜åŒ–ã€æŽˆæƒçš„èŒƒå›´å†…ï¼Œç•™å¾…ç”¨æˆ·ç‚¹å¤´ã€‚
### 23.7 ä¸€ä¸ªå€¼å¾—复用的工程坑
Java çš„ `@argfile` æ˜¯**按平台默认字符集**读的。我把含中文的路径(`D:/牛马/...`)写进 argfile åŽï¼Œ
`javac` æŠ¥ã€Œæ‰¾ä¸åˆ°ç¬¦å·ã€ï¼Œä½†é”™è¯¯ä¿¡æ¯æœ¬èº«æ˜¯ä¹±ç ï¼Œçœ‹èµ·æ¥åƒåˆ«çš„问题。
**解决办法**:把新编译的 `target/classes` æ‹·åˆ°ä¸€ä¸ªçº¯ ASCII è·¯å¾„(本轮 `/d/qcl-tmp/qcr-classes-new`)再拼进 classpath。
排查时如果 `java @argfile` çš„æŠ¥é”™é‡Œå‡ºçŽ°ä¹±ç ï¼Œå…ˆæ€€ç–‘å­—ç¬¦é›†ï¼Œåˆ«å…ˆæ€€ç–‘ä»£ç ã€‚
## äºŒåå››ã€åŒä¸Šç»­ï¼šæç¤ºè¯æœ¬èº«åœ¨ç»™å¹³é“ºè¡¨æ‰“广告(2026-09-19 ç»­ï¼‰
### 24.1 çŽ°è±¡ï¼šæŠ½å–å±‚ä¿®å®Œï¼Œç”¨æˆ·é‡å¯åŽã€Œè¿˜æ˜¯è¿™æ ·ã€
二十三节落地后用户重启了后端,重传同一份 `百事模版.docx`,设计器画布上仍是**平铺**四列
(检验项目/标准值/实测值/判定)。也就是说:**输入侧的信息已经够了,是模型没选对组件。**
### 24.2 ç©º hint å¤è·‘量化:不是「完全没用」,是「不稳定」
拿用户那份文件(blob 83,重启后上传,确认已走到新代码)用**空 hint** æ‰“ 5 æ¬¡ `/qc-report/ai-import/draft`:
| æ—¶ç‚¹ | å‘½ä¸­ `GroupedQualityTable` |
|---|---|
| æŠ½å–层修复前 | 0 / 5 |
| æŠ½å–层修复后、提示词未改 | **2 / 5** |
2/5 è¯´æ˜ŽæŠ½å–层修复是真的有效(从「一次都不中」到「能中」),但**不稳定**——用户看到的那一次大概率就是没中的那 3/5。
### 24.3 çœŸæ ¹å› ï¼šç³»ç»Ÿæç¤ºè¯é‡Œæœ‰ä¸‰å¤„在把模型往平铺表上推
审提示词全文后发现,真正压住模型的是措辞,而不是能力:
1. **抽取规则第 1 æ¡ç›´æŽ¥ç‚¹åäº† `QualityTable`**:「`QualityTable` çš„ `itemsPath` ä¸€å¾‹å¡«
   `inspectionItems`」。这条规则的本意是「检验表的数据源路径统一」,但字面写成了一个**具体组件名**,
   äºŽæ˜¯æ•´ä»½æç¤ºè¯é‡Œå”¯ä¸€è¢«è§„则正面点名的检验表就是平铺表——等于全程给它打广告。**嫌疑最大。**
2. **输出格式示例里第二个组件就是 `{"type": "QualityTable"}`**。示例是模型最容易照抄的东西。
3. ç¬¬ 8 æ¡åªè¯´äº†ã€Œ`↑同上` æ˜¯åˆå¹¶æ ‡è®°ã€ï¼Œ**没说这个标记意味着什么结构**,模型读到的是「一个要忽略的符号」,
   è€Œä¸æ˜¯ã€Œè¿™é‡Œå­˜åœ¨ä¸¤å±‚结构」。
### 24.4 æ”¹åЍ
**后端提示词(`QcReportTemplatePromptBuilder`)三处**
| ä½ç½® | æ”¹æ³• |
|---|---|
| `EXTRACTION_RULES` ç¬¬ 1 æ¡ | `QualityTable çš„ itemsPath` â†’ **「凡是带检验项的表格组件」**,规则不再点名任何一个具体组件 |
| `EXTRACTION_RULES` åŽŸç¬¬ 8 æ¡æ‹†æˆ 8 / 9 | ç¬¬ 8 æ¡åªè®²æ ‡è®°å«ä¹‰ï¼›**新增第 9 æ¡**给出推论:「同一列里连续出现 `↑同上`,这张表就是两层结构……必须当成事实参与选型」,并点明丢掉子项等于报告失真 |
| `OUTPUT_FORMAT` ç»“å°¾ | è¡¥ä¸€å¥ã€Œä¸Šé¢ç¤ºä¾‹é‡Œçš„ type **只是格式示意**,选型一律以《可用组件清单》里各组件 hint ä¸ºå‡†ï¼Œä¸è¦å› ä¸ºç¤ºä¾‹é‡Œå†™äº†æŸä¸ª type å°±ç…§æŠ„它」 |
**前端积木清单(`components/quality/inspection/quality-table.ts` çš„ `aiHint`,用户已批准)**
原文「……**报告正文最主要的组件。**」是一句会主动误导模型的话:它是 `QualityTable` è‡ªå·±çš„自述,
模型读到只会更倾向于选它。改成条件式:
```
多行检验数据的表格,检验项平铺一层、没有父子关系(序号/检验项目/标准值/实测值/单位/判定)。
一个检验项目下挂若干子项(原件里表现为该列纵向合并、后续行写着「↑同上」)时不要用我,
改用分组检验项表。
报告末尾的落款签署行(检验员/审核人/日期)不要放进我这里,那属于 ReportFooter。
```
改法是**把判别条件写进 hint æœ¬èº«**(`↑同上` = æœ‰å­é¡¹ï¼‰ï¼Œè€Œä¸æ˜¯åªç•™ä¸€å¥ã€Œæˆ‘不适合复杂情况」——
模型手上没有别的线索能判断「复杂」在哪。
### 24.5 éªŒè¯ï¼šç»•过运行中的后端,直接用新提示词打真模型
后端重启要用户操作,为了**在重启之前就能回答「新提示词到底有没有用」**,
用刚编译出的类把提示词落盘,再由脚本直接打 DashScope(`qwen-max`,`temperature=0.7` ä¸Žåº“中配置一致):
- `PromptDumpProbe.java`:读 `bs-catalog.json`(13 é¡¹æ¸…单)→ è°ƒ `buildSystemPrompt` /
  `buildUserMessage`(空 hint)→ å†™ UTF-8 æ–‡ä»¶ã€‚
- `bs-direct-run.mjs`:读提示词 + `bs-extract-new.txt`(**改后抽取层对用户那份 docx çš„真实输出**),
  POST `https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions`,跑 5 è½®ç»Ÿè®¡å‘½ä¸­ã€‚
| ç»„合 | å‘½ä¸­ `GroupedQualityTable` |
|---|---|
| æ–°æç¤ºè¯ + æ–°æ¸…单 hint | **5 / 5** |
| æ–°æç¤ºè¯ + **旧**清单 hint(消融对照) | **5 / 5** |
**结论**:本轮起决定作用的是**后端提示词那三处**;前端 hint çš„修改在这份输入上没有改变结果,
但它修掉的是一句**确实错误的自述**(「报告正文最主要的组件」),保留。
### 24.6 éªŒè¯çŠ¶æ€ï¼ˆåŠ¡å¿…åˆ†æ¸…ï¼‰
- **已验证**:新提示词 + æ–°æ¸…单,对用户这份真实文件、真实模型、空 hint,**5/5 ç¨³å®šå‘½ä¸­åˆ†ç»„表**。
  è¿™æ¡é“¾è·¯æ˜¯ã€Œæç¤ºè¯ â†’ æ¨¡åž‹å›žå¤ã€ï¼Œä¸­é—´æ²¡ç»è¿‡åŽç«¯çš„解析/归一化。
- **未验证**:整条 `/qc-report/ai-import/draft` HTTP é“¾è·¯ï¼ˆéœ€è¦ç”¨æˆ·**再重启一次后端**)。
  è§£æžå™¨ä¸Žå½’一化器本轮一字未改,且抽取层修复后已能产出过 `GroupedQualityTable`(即 2/5 é‚£ä¸¤æ¬¡ï¼‰ï¼Œ
  æ‰€ä»¥æ•´é“¾è·¯çš„风险很低——但没跑过就不写成「已通过」。
- **这一轮的教训(与 23.2 åŒæºï¼Œç¬¬äºŒæ¬¡è¸©ï¼‰**:判断「模型能不能做对」时,**输入里每一处
  å¯èƒ½è¯±å¯¼å®ƒçš„æŽªè¾žéƒ½è¦ä¸€èµ·æŸ¥**。上一轮是自己的 hint è¯±å¯¼ï¼Œè¿™ä¸€è½®æ˜¯æç¤ºè¯é‡Œç‚¹åçš„组件名与示例。
  ã€Œè¾“入信息够了」不等于「模型会选对」——中间的措辞本身就是变量。
## äºŒåäº”、选型修好之后:剩下的差距全在「表达力」(2026-09-19 ç»­ï¼‰
### 25.1 å…ˆç¡®è®¤é€‰åž‹ç¡®å®žä¿®å¥½äº†
用户重启后仍回「还是这样」。拿**表头**当判据即可区分两个组件:
| | è¡¨å¤´ |
|---|---|
| `QualityTable`(平铺) | æ£€éªŒé¡¹ç›® / **标准值** / å®žæµ‹å€¼ / åˆ¤å®š |
| `GroupedQualityTable`(分组) | æ£€éªŒé¡¹ç›® / **子项** / **检测要求** / å®žæµ‹å€¼ / åˆ¤å®š |
用户截图里出现了「子项」「检测要求」——**平铺表根本没有这两列** â‡’ ç»„件已经换过来了。
用空 hint å¤è·‘ blob 84 èµ°å®Œæ•´ HTTP é“¾è·¯ä¹Ÿç¡®è®¤è¿”回 `GroupedQualityTable`。
顺带澄清一个看着像 bug çš„东西:截图里单元格上的 `Table cell pec}}` **不是文档内容**。
`Table cell` æ˜¯ GrapesJS è‡ªå·±çš„组件类型名(`node_modules/grapesjs/locale/en.js:43` çš„ `cell: 'Table cell'`),
是编辑器画在单元格左上角的悬浮标签,盖住了 `{{report.s` å‰ 10 ä¸ªå­—符,剩下的 `pec}}` æ‰éœ²å‡ºæ¥ã€‚
### 25.2 å‰©ä¸‹çš„差距全部是表达力,不是识别
逐项比对原件后确认:`GroupedQualityTable` çš„ 7 åˆ—、`SampleInfo` çš„ 6 ä¸ªå­—段**都写死在代码常量里**,
原件里的「检测方法 / ç»“论」两列与「检验结果 | ç»“论」两级表头、以及原件的 7 ä¸ªæ ·å“å­—段
(产品数量 / ç”Ÿäº§æ—¥æœŸ / åœŸè±†å“ç§ / æœ‰æ•ˆæ—¥æœŸç­‰ï¼‰**没有任何组件装得下**。
### 25.3 ä¸€æ¡å¿…须先修、否则新功能形同虚设的数据侧缺口
`MesQcReportItemRespDTO` **已经有 `checkMethod`(检测方法)**,但 qcreport å¼•擎的 `InspectionItem`
把它丢了,`ReportContext.itemMap()` ä¸‹å‘的作用域里没有它。⇒ åˆ—定义里就算写
`检测方法={{item.checkMethod}}`,渲染期也只会留一片空白加一条「绑定取不到值」。
补这条链路要动 6 å¤„(mapper / DO / itemMap / Codec / å‰ç«¯ context.ts / fixture),见设计文档 Â§4。
### 25.4 æ–¹æ¡ˆæ–‡æ¡£å·²å‡ºï¼Œ**待用户确认后动手**
`docs/qc_report_configurable_columns_design.md`。核心結論:
- **不新增组件**,给 `GroupedQualityTable` åŠ å¯é€‰ `columns` / `headerSpans`、给 `SampleInfo` åŠ å¯é€‰ `fields`;
  ç¼ºå¸­æ—¶å›žè½çŽ°æœ‰å¸¸é‡ â‡’ å­˜é‡æ¨¡æ¿é›¶å½±å“ã€æ— æ•°æ®è¿ç§»ã€‚
- åˆ—定义编码成**单行字符串**(属性协议只认标量,`QualityProps` ä¸Ž `attributes` éƒ½æ˜¯å­—符串)。
- **不需要改渲染器**:已核查 `HtmlRenderer` æ— ä»»ä½•按组件类型分支的逻辑、`CanvasSafety` æ˜¯é»‘名单而非白名单
  â‡’ `colspan`/`rowspan` æœ¬æ¥å°±æ”¾è¡Œï¼Œschema ç‰ˆæœ¬å·ä¹Ÿä¸ç”¨åŠ¨ã€‚
- æœ€å¤§çš„风险已写进文档:再加第三个检验表会让选型回到不稳(§二十四 çš„æœºç†ï¼‰ï¼Œæ‰€ä»¥**不加**。
**已做 / æœªåš**:文档已落盘(`docs/` è¢« gitignore,需要 `git add -f` æ‰è¿›ç‰ˆæœ¬åº“);
**代码一行未动**,等用户对文档 Â§11 çš„四个待裁决点表态。
---
## äºŒåå…­ã€è¡¨è¾¾åŠ›è¡¥é½è½åœ°ï¼šåˆ—å®šä¹‰ / è¡¨å¤´è·¨åˆ— / æ ·å“å­—段(2026-09-19 ç»­ï¼‰
用户对 Â§äºŒåäº” çš„设计文档给出「按照你的想法来就行了」,即按我的判断落地 Â§11 çš„四个待裁决点。
### 26.1 å››ä¸ªå¾…裁决点的裁决
| å²”è·¯ | è£å†³ | ä¾æ® |
|---|---|---|
| ä¸¤çº§è¡¨å¤´ `headerSpans` è¿™è½®åšè¿˜æ˜¯ä¸‹è½®åš | **这轮做** | åŽŸä»¶ã€Œæ£€éªŒç»“æžœ \| ç»“论」确实是两层;先做后做都要动同一批文件,拆成两轮只是多一次编译与联调 |
| åŽŸä»¶çš„ã€Œç»“è®ºã€åˆ—æ˜ å°„åˆ°å“ªä¸ªç»‘å®š | å¼•擎的 `resultText` | `resultText` å°±æ˜¯é€é¡¹çš„判定文本(`合格`/`不合格`),与原件「结论」列的内容一致;`actualValue` æ˜¯å®žæµ‹å€¼ã€åŽŸä»¶é‚£ä¸€åˆ—æ˜¯ç©ºçš„ |
| åŠ ä¸€ä¸ª `tool`(工具/治具)字段 | **不加** | æ— åœºæ™¯ã€æ— è¯»å–方;加了就是第二个 `unit_text`(见 Â§22 çš„æ­»åˆ—教训) |
| `SampleInfo` é»˜è®¤å­—段是否扩到 7 é¡¹ | **保持 6 é¡¹** | æ‰©é»˜è®¤å€¼ä¼šæ”¹å­˜é‡æ¨¡æ¿çš„æ¸²æŸ“产物(违背「存量零影响」),而 `fields` è¦†ç›–已经能表达 7 é¡¹ |
| å¹³é“º `QualityTable` æ˜¯å¦ä¹ŸåŠ  `columns` | **不加** | å†åŠ ä¸€å¤„ã€Œåˆ—å¯ä»¥è‡ªå®šã€ä¼šè®©é€‰åž‹é‡æ–°å›žåˆ°ä¸ç¨³ï¼ˆÂ§äºŒåå›› çš„æœºç†ï¼‰ï¼Œæ”¶ç›Šåªæ˜¯å¹³é“ºè¡¨ä¹Ÿèƒ½å¤šåˆ— |
### 26.2 è½åœ°å†…容
**前端(`mom-pro2-before/src/components/quality/`)**
- `inspection/grouped-quality-table.ts`:新增 `columns` / `headerSpans` ä¸¤ä¸ªæ–‡æœ¬å±žæ€§ï¼ˆ**故意不给 `defaultValue`、不给 `bindable`**),`dataSchema` è¡¥ `item.checkMethod`,`aiHint` è¡¥ã€Œåˆ—比默认多时写 columns、两层表头写 headerSpans,不要换组件」。
- `header/sample-info.ts`:新增 `fields` æ–‡æœ¬å±žæ€§ã€‚
- `core/validator.ts`:**接上 `definition.validate`**(详见 26.3)。
- `engine/context.ts`:`ReportContextItem` è¡¥ `checkMethod`(检测方法逐行进上下文,与 `requirement` å¹¶å­˜ä¸åˆå¹¶ï¼‰ã€‚
**后端**
- `MesQcReportContextMapper` â†’ `checkMethod` è´¯é€šåˆ°æŠ¥å‘Šä¸Šä¸‹æ–‡ï¼ˆ`InspectionItem` ä¾§æ—©å·²æœ‰è¯¥å­—段)。
- `QcReportTemplatePromptBuilder`:新增抽取规则 10(`columns` + ç»‘定路径白名单)、11(`headerSpans`)、12(`fields`)、**【报告上下文的字段】白名单**(详见 26.4)。
### 26.3 é¡ºå¸¦ä¿®æŽ‰ä¸€å¤„死代码:`definition.validate` ä»Žæ¥æ²¡è¢«è°ƒç”¨
设计文档要求「非法列定义要在保存时报错」,但落到代码时发现:**`definition.validate` åœ¨æ•´å‰ç«¯ä¸€æ¬¡éƒ½æ²¡è¢«è°ƒç”¨è¿‡**。
只有 `validateQualityProps` è¢«è°ƒç”¨ï¼ˆè£…配器 `assembler.ts:82`),而它不碰 `definition.validate`。
也就是说这个钩子写了也不会拦住任何人——两个既有的 `validate` å®žçŽ°ä¸€ç›´æŒ‚åœ¨ç©ºæ¡£ä¸Šã€‚
已把 `definition.validate?.(resolved)` æŽ¥è¿› `validateQualityProps` æœ«å°¾ï¼Œæ–°å¢žçš„三条校验与既有的两条同时生效。
> **仍未覆盖**:设计器里**手工编辑后点保存**这条路目前不跑 `validateQualityProps`(只有 AI å¯¼å…¥è£…配那条路跑)。
> æ‰‹å·¥å¡«é”™çš„列定义不会在保存时被拦住,只会在出件时以「绑定取不到值」暴露。属既有缺口,本轮未改,已写进联调文档。
### 26.4 ç›´è¿žçœŸæ¨¡åž‹ä¸‰è½®å®žæµ‹ï¼šä¿®æŽ‰ä¸‰ä¸ªçœŸå®žç¼ºé™·
做法:把新提示词从新编译的 class é‡Œ dump å‡ºæ¥ï¼Œ**不走后端**直接打 DashScope
(`qwen-max`),喂真实的百事 docx æŠ½å–结果(`bs-extract-new.txt`),**hint ç•™ç©º**,每轮跑 5 æ¬¡çœ‹ç¨³å®šæ€§ã€‚
| è½®æ¬¡ | å‘现的问题 | ä¿®æ³• | ä¿®åŽ |
|---|---|---|---|
| ç¬¬ 1 è½® | æ¨¡åž‹ç»™åˆ†ç»„表写 `columns` æ—¶**把「子项」整列丢了**(3/3 è½®åªå†™ 4 åˆ—)。而分组表的**默认列本来就有子项** â‡’ è¦†ç›–反而比不覆盖更差 | è§„则 10 è¡¥ä¸€æ¡ç¡¬è¦æ±‚:分组表写 `columns` å¿…须保留 `子项={{item.group.childName}}`,并说清漏掉后子项名整列消失 | 5/5 ä¿ç•™ï¼Œä¸”与原件 6 åˆ—**逐列对齐** |
| ç¬¬ 2 è½® | `headerSpans` æŠŠã€Œç»“论」列的跨列数并进了「检验结果」(写成 `检验结果^6`),表头会把结论列画到检验结果底下;另有一轮合计 8 â‰  åˆ—æ•° 7 | è§„则 11 è¡¥ä¸€æ¡ï¼šç»“论/判定列在原件里不属于上层分组标题时**要单独成段** | 5/5 è¾“出 `检验结果^5|结论^1` |
| ç¬¬ 3 è½® | `SampleInfo.fields` é‡Œ**5/5 è½®éƒ½ç¼–造了报告字段**(`{{report.productionDate}}` / `{{report.expiryDate}}` / `{{report.potatoVariety}}`) | æ ¹å› æ˜¯è§„则 7 åªè¯´ã€Œä¸è®¸ç¼–造」却**不说有哪些键**。新增【报告上下文的字段】块,把 23 ä¸ªå¯ç»‘定键全量列出 | 5/5 å…¨éƒ¨åˆæ³•,不存在的栏目改为写进 `summary` è¯´æ˜Ž |
**三个缺陷的共同点**:都不是模型「不听话」,而是提示词**只给了禁令、没给可选项**。
列的白名单(规则 10)本来就给了,所以 `columns` çš„绑定路径 5/5 åˆæ³•——反证了「摊开可选集合」才是有效做法。
### 26.5 ä¸ºä»€ä¹ˆè¦ä»Ž `ReportFields` åå°„生成白名单,而不是手写常量
手写的清单会在有人给报告加字段时**静默过期**,而模型正是靠这份清单才知道 `{{report.xxx}}` èƒ½å†™ä»€ä¹ˆï¼š
清单里少一个键,用户就会在报告上看到一片空白加一条告警,且**看不出是提示词过期造成的**。
所以白名单由 `ReportFields` çš„字段名反射生成(同模块,无跨模块耦合),单测**逐个字段核对**而不是抽查两个。
### 26.6 éªŒè¯ç»“论
| éªŒè¯é¡¹ | ç»“æžœ |
|---|---|
| å­˜é‡æ¨¡æ¿å­—节一致 | **12 ç»„属性组合全部逐字节相同**(sha256 ä¸€è‡´ï¼‰ï¼Œå¹¶å†»ç»“成探针里的两个指纹常量 |
| å‰ç«¯æŽ¢é’ˆ `verify-columns.ts` | **83 æ¡æ–­è¨€å…¨ç»¿**(列覆盖的落地位置、两级表头的 colspan、验证文案、样品字段、XSS è½¬ä¹‰ã€è§£æžå™¨è¾¹ç•Œã€aiHint ä¸Žæ´»æ¸…单契约) |
| åŽç«¯å•测 | `yudao-module-qcreport` **147/147 å…¨ç»¿**(新增 4 æ¡ï¼š2 æ¡æç¤ºè¯è§„则断言 + 1 æ¡ç™½åå•全量核对 + 1 æ¡ checkMethod æ˜ å°„) |
| å‰ç«¯ç±»åž‹æ£€æŸ¥ | `components/quality` ä¸‹ **0 ä¸ªç±»åž‹é”™è¯¯**(既有 36 ä¸ªåœ¨ `src/views/wls|im|erp`,与本轮无关) |
| ç›´è¿žçœŸæ¨¡åž‹ | é€‰åž‹ **5/5**、列与原件的 6 åˆ—**逐列对齐 5/5**、`headerSpans` æ­£ç¡® **5/5**、样品字段键合法 **5/5** |
**已验证 / æœªéªŒè¯åˆ†å¼€å£°æ˜Ž**:
- âœ… å·²éªŒè¯ï¼šä¸Šè¿°äº”项;以及「提示词 + å‰ç«¯ç»„件」这条链路的**直连**行为。
- ðŸŸ¡ æœªéªŒè¯ï¼š**HTTP å…¨é“¾è·¯ä¸Ž UI éªŒæ”¶å°šæœªè·‘**——改的是提示词(后端)与前端组件,后端需**重启**才生效。
  é‡å¯åŽéœ€åœ¨çœŸå®ž AI å¯¼å…¥å¼¹çª—里确认:检验表出现「检测方法 / æ ‡å‡†è¦æ±‚ / ç»“论」列与两级表头、样品信息块按原件的字段展示、
  `problems` é‡Œ**没有**「绑定取不到值」告警。
- âš  å·²çŸ¥è¾¹ç•Œï¼šæ‰‹å·¥ä¿å­˜è·¯å¾„不跑校验(26.3);表格的「单位」列在原件里没有对应列时 AI å¶å°”会多加一列空列(无害,用户在设计器里删掉即可)。
### 26.7 æœªåš
- å¹³é“º `QualityTable` çš„ `columns`;`unit_text` ä¸æŽ¥ç•Œé¢ï¼›Excel「条目类型」列(均见 26.1 ä¸Ž Â§22)
- è®¾è®¡å™¨æ‰‹å·¥ä¿å­˜è·¯å¾„的校验闸口(既有缺口,需单独决策是否要做成全模板校验)
- æœªåЍ `HtmlRenderer` / `BASE_CSS` / `CanvasSafety`(本轮核查后确认无需改:渲染器无按组件类型分支、白名单本就放行 `colspan`/`rowspan`)
- æœªåЍ `docs/sql/config_export_all_*.sql`(无表结构变更)
## äºŒåä¸ƒã€ç”»å¸ƒæŽ’版与表头「多了一个结论」:两处独立缺陷(2026-09-19 ç»­ï¼‰
用户在真实 UI é‡Œè·‘完 AI å¯¼å…¥åŽåé¦ˆï¼š**「多了一个结论,优化一下排版」**。
这不是一条问题,是两条互不相干的缺陷叠在同一张表上。
### 27.1 çŽ°è±¡ä¸Žå–è¯
| # | çŽ°è±¡ | å–证 |
|---|---|---|
| A | è¡¨å¤´å³ç«¯ã€Œç»“论」二字**上下各印一次** | æ¨¡æ¿ 33 çš„ AI è‰ç¨¿é‡Œ `columns` æœ«åˆ—写的是 `结论={{item.resultText}}`,`headerSpans` æœ«æ®µå†™çš„æ˜¯ `结论^1` â€”— ä¸¤å±‚各出一格「结论」 |
| B | è¡¨ä½“**整体左移一格**,末列只剩表头、被挤成竖排细条 | å¤åˆ» `GroupedQualityTable.buildContent` çš„产物单独渲染:组名格 `display:none`、宽 0,末列宽 64px,后 5 æ ¼æ•´ä½“左移。列的错位与用户截图逐格一致 |
B çš„æˆå› ï¼šç»„名格写作 `hidden="{{item.group.hidden}}"`。**画布不跑绑定**,浏览器看到「有 `hidden` å±žæ€§ã€å°±æˆç«‹ â‡’ è¯¥æ ¼ `display:none`,这一行后面的格整体左移。这是个一直存在的老行为,只是从前 AI ä»Žæ²¡äº§å‡ºè¿‡åˆ†ç»„表,所以没暴露。
A çš„æˆå› æ˜¯è¯­æ³•缺口:原件的「结论」在原件里是**一格纵向合并、自己占满上下两层表头**(提取文本里表现为下层表头那一格写着「↑同上」)。但 `headerSpans` çš„语法**不允许空标题**(`parseHeaderSpans` å¯¹ç©ºæ ‡é¢˜ç›´æŽ¥æŠ¥é”™ï¼‰ï¼Œè¡¨è¾¾ä¸å‡ºã€Œä¸Šæ ¼ç•™ç©ºã€ï¼ŒäºŽæ˜¯æ¨¡åž‹åªèƒ½æŠŠåˆ—名再写一遍 â€”— å°±é‡å¤äº†ã€‚
### 27.2 ä¿®æ³•
| # | æ”¹åЍ | ä½ç½® |
|---|---|---|
| A | æ–°å¢žåˆ¤æ®ï¼šæŸæ®µè·¨ 1 åˆ—、且**该段标题与它正下方那一列的列标题一字不差** â‡’ è®¤å®šæ˜¯çºµå‘合并格,出 `rowspan="2"`,下层表头**跳过该列** | `components/quality/inspection/grouped-quality-table.ts` |
| B | åªåœ¨**画布文档内**注入一条引导样式 `td[hidden*="{{"] { display: table-cell; }`,把「值还停在占位符」的 `hidden` è¿˜åŽŸæˆå¯è§æ ¼ | `views/mes/qc/report/template/designer/use-designer.ts` |
| â€” | æç¤ºè¯è§„则 11 è¡¥ä¸¤æ¡ï¼šè¯¥æ®µæ ‡é¢˜å¿…须与列标题**一字不差**;**不要自作主张把该列改名成近义词**(并写明后果) | `QcReportTemplatePromptBuilder` è§„则 11 |
| â€” | åˆ†ç»„表的 `aiHint` ä¸Ž `headerSpans` çš„属性说明同步补上这条形态 | åŒä¸Šç»„ä»¶ |
**为什么判据用「标题一字不差」而不是「排在最后一列」**:排在最后只是巧合,判据本身得说明原件里那两格是同一个格。实测也证明这个判据可被模型稳定满足(见 27.4)。
B çš„æ ·å¼**只加在画布的 iframe é‡Œ**,不写进 Schema、不随模板保存;出件时该值已解成空串或 `hidden`,选择器不再命中,**报告渲染一个字都不受影响**。
样式收窄到 `td`:该属性目前只用在单元格上,哪天真用到别的标签也要按那个标签的 `display` å„写一条,不能一条通用规则糊过去。
### 27.3 æç¤ºè¯ä¸ºä»€ä¹ˆè¦å†™åˆ°ã€Œä¸è¦æ”¹åã€è¿™ä¹ˆç»†
补上「两边一字不差」后复跑,仍有 **1/5 è½®**出问题:模型把 `columns` é‡Œé‚£ä¸€åˆ—改名成「判定」,`headerSpans` å´è¿˜å†™ç€åŽŸä»¶ä¸Šçš„ã€Œç»“è®ºã€ï¼Œä¸¤è¾¹å¯¹ä¸ä¸Šï¼Œåˆé€€å›žåŽ»å¤šå°ä¸€ä¸ªè¡¨å¤´ã€‚
根因是同一条信息在提示词里出现两次(原件表头、`columns`)时模型会各写各的。补上「不要自作主张把它改名成近义词」并写明后果后,复跑 **5/5 è½®åˆæˆä¸ºä¸€æ ¼**。
### 27.4 éªŒè¯ç»“论
| éªŒè¯é¡¹ | ç»“æžœ |
|---|---|
| å‰ç«¯æŽ¢é’ˆ `verify-columns.ts` | å…¨ç»¿ï¼ˆæ–°å¢ž 7 æ¡çºµå‘合并断言 + 1 æ¡ hint æ–­è¨€ï¼‰ï¼šåˆæˆåŽ `rowspan="2"` åœ¨åœºã€ä¸å†å‡º `colspan="1"`、「结论」全文**只印一次**、上 2 æ ¼ + ä¸‹ 5 æ ¼ = 7;**反例**:上格与列名不同字时不出 `rowspan`(判据是「一字不差」而不是「在最后」) |
| åŽç«¯å•测 | `yudao-module-qcreport` **147/147 å…¨ç»¿**(新增 2 æ¡æç¤ºè¯æ–­è¨€ï¼‰ |
| å‰ç«¯ç±»åž‹æ£€æŸ¥ | æœ¬è½®ç›¸å…³æ–‡ä»¶ **0 ä¸ªç±»åž‹é”™è¯¯**(既有 36 ä¸ªä»åœ¨ `src/views/wls|im|erp`,与本轮无关) |
| æ¸²æŸ“复刻对照 | ä¿®å‰ï¼šç»“论印两次 + æ•´è¡Œå·¦ç§» + æœ«åˆ— 64px ç«–排;修后:`结论` ä¸€æ ¼è·¨ä¸¤è¡Œã€è¡¨ä½“逐格对齐 |
| ç›´è¿žçœŸæ¨¡åž‹ 5 è½® | é€‰åž‹ **5/5** åˆ†ç»„表;`columns` 6~7 åˆ—、跨列数合计=列数 **5/5**;末段标题与末列列名一致 **5/5** â‡’ **纵向合成 5/5**;样品字段键合法 **5/5** |
**已验证 / æœªéªŒè¯åˆ†å¼€å£°æ˜Ž**:
- âœ… å·²éªŒè¯ï¼šæŽ¢é’ˆã€åŽç«¯å•测、类型检查、渲染复刻对照、直连 5 è½®ã€‚
- ðŸŸ¡ æœªéªŒè¯ï¼š**HTTP å…¨é“¾è·¯ä¸Ž UI éªŒæ”¶æœªè·‘** â€”— æç¤ºè¯æ”¹åŠ¨åœ¨åŽç«¯ç±»é‡Œï¼Œéœ€**重启后端**才生效;组件与 `aiHint` æ”¹åŠ¨åœ¨å‰ç«¯ï¼Œçƒ­æ›´æ–°å³å¯ã€‚
  é‡å¯åŽéœ€åœ¨çœŸå®ž AI å¯¼å…¥å¼¹çª—里确认:表头「结论」只出现一次且为一格跨两行、表体与表头逐格对齐、末列不再被挤成细条。
  å¦å¤–**存量模板 33 çš„草稿不会自动变好**——它已经把 `结论^1` å­˜è¿›ç”»å¸ƒäº†ï¼Œéœ€é‡æ–°å¯¼å…¥æˆ–手工改;但纵向合并判据是渲染期生效的,只要 `columns` æœ«åˆ—与末段标题一致,**已保存的模板重新出件也会自动合成一格**。
### 27.5 æœªåš
- æ²¡åЍ `parseHeaderSpans` çš„「合计必须等于列数」这条硬规则(新增的纵向合并格按 1 åˆ—计入合计,规则不变)
- æ²¡åŠ¨æŠ¥å‘Šä¾§å–æ•°ã€`HtmlRenderer`、`BASE_CSS`、`CanvasSafety`
- æ²¡åšã€Œä¸Šæ ¼çœŸæ­£çš„ rowspan è¡¨å¤´ç¼–辑」——设计器属性面板仍是手写字符串,本轮只保证它渲染正确
## äºŒåå…«ã€è‡ªå®šåˆ—的列宽:长占位符把表格撑出纸张(2026-09-19 ç»­ï¼‰
### 28.1 çŽ°è±¡ä¸Žå–è¯
用户反馈(承接第二十七节,重启后端后复看同一张模板):「结论好像超出去了,像个办法优化一下」,附设计器截图。
截图里的表现与 27 èŠ‚ä¿®å®Œçš„å½¢æ€ä¸åŒï¼šè¡¨å¤´ã€Œç»“è®ºã€**只出现一次**(27 èŠ‚çš„çºµå‘åˆå¹¶å·²ç”Ÿæ•ˆï¼‰ï¼Œä½†é‚£ä¸€åˆ—è¢«æŒ¤æˆäºŒåå‡ ä¸ªåƒç´ ã€ä¸¤ä¸ªå­—ç«–ç€æŽ’ï¼Œæ•´åˆ—è½åˆ°çº¸å¼ å¤–é¢ã€‚
把模型实际产出的属性(`D:/qcl-tmp/bs-direct-out.json` ç¬¬ 5 è½®ï¼Œä¸Žæˆªå›¾é€å­—一致)编译成 HTML,在 Chromium é‡ŒæŒ‰ A4 æ­£æ–‡å®½ 672px é‡ï¼š
| åœºæ™¯ | æ•´è¡¨å®½ | æº¢å‡º | æœ«åˆ—宽 |
|---|---|---|---|
| è‡ªå®š 6 åˆ—,值为占位符 | **810px** | **138px** | **29px** |
| åŒä¸Šï¼Œå€¼æ¢æˆçœŸå®žæ•°æ®ï¼ˆæ°´åˆ†/20目上/烘箱干燥法/≤14.0/13.2/合格) | 672px | 0 | 86px |
| åˆ†ç»„表**默认** 7 åˆ—,值为占位符 | 807px | 135px | 116px |
根因两层:
1. **列宽由最小内容宽度倒推**。自定列一个宽度声明都没有(默认列有:序号 48px、检测要求 30%、实测值 26%……),整表列宽只能由各列的最小内容宽度决定;而值是 `{{item.standardValue}}` è¿™ç±»ä¸å«ç©ºæ ¼çš„长串,最小内容宽度就是整串长度,六列一加就超过 672px。
2. **出件时值本来就短**,所以这主要是设计器里的观感问题——但撑破纸张的临界点在真实长值(如「≤0.5 mg/kg(以干基计)」)上一样会踩到,不是纯设计器问题。
### 28.2 ä¿®æ³•:`overflow-wrap:anywhere`,只挂在自定列上
`overflow-wrap:anywhere` ä¼šå‚与最小内容宽度计算(`break-word` ä¸ä¼šï¼‰ï¼Œåˆ—宽因此不再被长串绑架。
关键取舍是**只给自定列加**:
| åŠ åœ¨å“ª | 6 åˆ—自定表 | é»˜è®¤ 7 åˆ—分组表 |
|---|---|---|
| ä¸åŠ  | 810px、溢出 138px | 807px、溢出 135px |
| åŠ  | 672px、不溢出 | 672px、**但「检验项目」「子项」被挤到 32/33px、行高 59→283px** |
默认列的宽度声明已经定住版面,再叠一条折行反而更差(实测)。所以这条只挂在自定列上:**没有 `columns` çš„存量模板产物逐字不变**(探针 sha256 å®ˆç€ï¼Œç¬¬äºŒèŠ‚çš„ `GROUPED_TABLE_BASELINE` æœªå˜ï¼‰ã€‚
顺带核实:全库 `qc_report_template_version` é‡Œ `data-qc-columns` / `data-qc-headerSpans` / `data-qc-fields` å‘½ä¸­æ•°**均为 0**(这三个属性是本轮才进的),所以这条改动对既有已发布报告零影响。
### 28.3 ä¸ºä»€ä¹ˆä¸æ˜¯ã€Œåœ¨ç”»å¸ƒä¸Šå†è¡¥ä¸€æ¡å¼•导样式」
第二十七节那条画布引导样式处理的是**绑定不求值**(`hidden` è¢«å½“真)——出件侧同一个属性会解成空串、行为一致,属于纯设计器假象,所以放画布上是干净的。
宽度这件事不一样:出件侧同样会踩(真实长值),两侧需要**同一套规则**才能所见即所得。若只在画布上加折行,画布按折行后的宽度分布、出件按最小内容宽度分布,反而制造出新的「设计器与出件不一样」。所以这次写进组件产物里。
### 28.4 éªŒè¯
| é¡¹ | ç»“æžœ |
|---|---|
| `.qc-conformance/verify-columns.ts` æ–°å¢ž 3c æ®µ | å…¨ç»¿ï¼šè‡ªå®šåˆ— 6 æ ¼å…¨å¸¦ `overflow-wrap:anywhere`;默认列不带 |
| `verify-columns.ts` ç¬¬ 1 èŠ‚å­—èŠ‚æŒ‡çº¹ | `GROUPED_TABLE_BASELINE` / `SAMPLE_INFO_BASELINE` **未变** â‡’ é»˜è®¤åˆ—产物逐字不变 |
| Chromium å®žæµ‹ï¼ˆ672px æ­£æ–‡å®½ï¼‰ | è‡ªå®š 6 åˆ—:修前 810px/溢出 138px/末列 29px â†’ ä¿®åŽ 672px/不溢出/末列 97px |
| çœŸå®žæ•°æ®å¯¹ç…§ | ä¿®å‰ä¿®åŽå‡ 672px,列宽分布不变 |
| æ¸²æŸ“复刻截图 | `结论` ä¸€æ ¼è·¨ä¸¤è¡Œã€è¡¨ä½“逐格对齐、表格落在纸张内(`D:/qcl-tmp/gqt-final.png`) |
| è£…配往返 | `assembleQualityCanvas` äº§ç‰©å†… `overflow-wrap` / `anywhere` å‡åœ¨ï¼Œ`skipped` / `problems` å‡ç©º â‡’ æ ·å¼ä¸ä¼šè¢« GrapesJS ä¸¢å¼ƒ |
| å‰ç«¯ç±»åž‹æ£€æŸ¥ | æœ¬è½®ç›¸å…³æ–‡ä»¶ **0 ä¸ªç±»åž‹é”™è¯¯**(既有 316 ä¸ªä¸Žå‰å‡ è½®åŒé‡çº§ï¼Œå‡åœ¨ `src/views/wls\|im\|erp` ç­‰æ— å…³ç›®å½•) |
**已验证 / æœªéªŒè¯åˆ†å¼€å£°æ˜Ž**:
- âœ… å·²éªŒè¯ï¼šæŽ¢é’ˆã€å­—节指纹、Chromium å®žæµ‹é‡å®½ã€æ¸²æŸ“复刻截图、装配往返、类型检查。GrapesJS è‡ªèº«ç¡®å®žæ”¶å½•了 `overflow-wrap`(dist å†…命中)。
- ðŸŸ¡ æœªéªŒè¯ï¼š**设计器里的真实观感未复跑**。本轮改动在前端(组件产物),热更新即可生效,无需重启后端;但截图里那份画布是**未保存的 AI å¯¼å…¥è‰ç¨¿**,刷新后看到的是库里的旧 v1.0(平铺 `QualityTable`,没有自定义列),需要**重新跑一次 AI å¯¼å…¥**才能复现新形态。
- å¦æ³¨ï¼šæ¨¡æ¿ 33 åº“里的 v1.0 è‰ç¨¿ï¼ˆ`qc_report_template_version.id=21`)仍是旧的平铺表,与本轮无关。
### 28.5 æœªåš
- æ²¡åŠ¨é»˜è®¤åˆ—çš„å®½åº¦å£°æ˜Žï¼ˆé‚£ä¼šè®©å­˜é‡æ¨¡æ¿çš„æŽ’ç‰ˆå˜åŒ–ï¼‰
- æ²¡ç»™è‡ªå®šåˆ—加宽度语法(如需「这一列窄一点」得再扩 `columns` çš„语法,是独立决策)
- æ²¡åЍ `HtmlRenderer` / `BASE_CSS` / `CanvasSafety` / æŠ¥å‘Šä¾§å–æ•°
- æ²¡ç®¡äºŒåä¸ƒèŠ‚é—ç•™çš„ã€Œæ‰‹å·¥ç¼–è¾‘åŽä¿å­˜ä¸è·‘æ ¡éªŒã€æ—¢æœ‰ç¼ºå£
## äºŒåä¹ã€ç¡®è®¤ã€ŒåŒç»„子项的项目列会不会合并」(2026-09-19 ç»­ï¼‰
用户问:属于同一个项目的子项,「项目」列会不会纵向合并。结论**会**,但只在**出件**(HTML / PDF)里合并,设计器画布上看不见。本轮**没改任何生产代码**,只补了对拍覆盖 + å–证。
### 29.1 åˆå¹¶æ€Žä¹ˆå‘生的(三段各归谁)
| çŽ¯èŠ‚ | è°åš | åšä»€ä¹ˆ |
|---|---|---|
| æ ‡å‡ºåˆå¹¶åˆ— | æ¨¡æ¿ | `columns` é‡Œè¯¥åˆ—标题带 `#`,`buildContent` åªåœ¨è¿™ä¸€æ ¼å†™ `rowspan="{{item.group.span}}"` ä¸Ž `hidden="{{item.group.hidden}}"` |
| ç®—出合并几行 | åŽç«¯ `MesQcReportApiImpl.java:331-351` | ç»„的第一行 `span = entryRows.size()`、`hidden = ""`;组内其余行 `span = 1`、`hidden = "hidden"` |
| æ¸²æˆè§†è§‰åˆå¹¶ | ä¸¤ä¾§æ¸²æŸ“器 `render.ts:242` / `HtmlRenderer.java:222` | éƒ½æŒ‰ã€Œæ±‚值结果为空串 â‡’ ä¸è¾“出该属性」处理:组头行 `hidden` æ¶ˆå¤±ã€å¸¦ `rowspan` æ˜¾ç¤ºï¼›å…¶ä½™è¡Œ `hidden="hidden"` è®©æ•´æ ¼ `display:none` |
三条口径:合并行数是**本单该组实际行数**而非主数据子项数;该组本单无子项行则 `span=1` ä¸åˆå¹¶ï¼ˆç‹¬ç«‹é¡¹åŒç†ï¼‰ï¼›`hidden` å¿…须是字符串,靠「属性在不在」起作用(`hidden="false"` ç…§æ ·éšè—ï¼‰ã€‚
### 29.2 ä¸ºä»€ä¹ˆè¿™è½®æ‰ç¡®è®¤ï¼šè‡ªå®šåˆ—分支一直是盲区
第 1~3 èŠ‚çš„æŽ¢é’ˆæ‰“çš„æ˜¯**默认列**分支(`tableSchema()` æ‰‹å·¥åŒæž„),而 **AI å¯¼å…¥ä¸€å¾‹å¡« `columns`**,走的是 `effectiveColumns` çš„另一条分支。两条分支各自拼 `<tr>`,合并属性有没有跟着走,默认列绿不代表自定列绿——`verify-grouped-table.ts` é‡ŒåŽŸå…ˆå¯¹è‡ªå®šåˆ—æ˜¯**零覆盖**。
实际读码看:自定列分支同样用 `column.merge ? GROUP_CELL_ATTRS : ''`,`merge` æ¥è‡ª `parseColumnSpec` çš„ `#` å‰ç¼€ï¼Œæ‰€ä»¥æ˜¯é€šçš„。但这是「读码推定」,不是取证,本轮补上。
### 29.3 æœ¬è½®çš„取证
| é¡¹ | åšæ³• | ç»“æžœ |
|---|---|---|
| è‡ªå®šåˆ—结构 | ç”¨æ¨¡åž‹çœŸå®žäº§å‡ºï¼ˆæ¨¡æ¿ 33 è‰ç¨¿æœ€åŽä¸€è½®ï¼Œ`#项目=…|…|结论=…`、`headerSpans=检验结果^5|结论^1`)逐字喂 `assembleQualityCanvas` | `[#项目]` æ ¼å¸¦ `rowspan="{{item.group.span}}"`+`hidden="{{item.group.hidden}}"`,全表只此一格带 `rowspan`;上层 `检验结果` `colspan=5`、`结论` `rowspan=2`、下层表头 5 åˆ— |
| è´Ÿå‘对照 | åŒä¸€ä»½ `columns` åŽ»æŽ‰ `#` | ä¸¤å¤„绑定整个消失、列数与绑定不变 â‡’ `#` ä¸æ˜¯è£…饰,有没有它结果确实不同 |
| æ¸²æŸ“语义 | `verify-grouped-table.ts` ç¬¬ 2/3 èŠ‚ï¼ˆæ—¢æœ‰ï¼Œæœ¬è½®é‡è·‘ï¼‰ | `hidden=""` çš„æ ¼å¯è§ã€`hidden="hidden"` çš„æ ¼ `display:none`、列数稳定 |
| Chromium å®žæµ‹ | æ‰“开第 2 èŠ‚æ¸²æŸ“äº§ç‰©ï¼Œè¯» `td.rowSpan` ä¸Ž `getBoundingClientRect()` | ç»„名格 `rowSpan=3`、像素高 93px = å…¶åŽä¸‰è¡Œï¼ˆå„ 31px)之和;组内两行的组名格 `display:none` |
| çŽ°ç½‘å®žä¾‹ | æŸ¥ `qc_report_instance`(仅 id 44/45,模板 3) | äº§ç‰©é‡Œ**没有** `rowspan`,快照里也没有 `"group":` å­—段 â‡’ è¿™ä¸¤å•本身不带分组结构,属正常 |
### 29.4 ç«¯åˆ°ç«¯å®žè·‘(真接口 + çœŸæ¸²æŸ“)
上一节只证到「属性落成、渲染器认得」。用户要求看一眼成品,于是走了一遍真实链路:
| æ­¥ | åŠ¨ä½œ | ç»“æžœ |
|---|---|---|
| 1 | æ‘¸æ¸…现状 | çŽ°ç½‘**没有**任何模板的画布放 `GroupedQualityTable`(1/3/5/33 å…¨æ˜¯æ‰å¹³ `QualityTable`),必须新建一个 |
| 2 | å‘现 IQC å• 1 **本身就挂上了分组指标**(21+22/23/24/25、26+27~30、31+32),状态 4 å·²å®Œæˆ | ä¸ç”¨é€ è´¨æ£€å• |
| 3 | ç”¨ `assembleQualityCanvas` æŠŠ AI è‰ç¨¿ï¼ˆæ¨¡æ¿ 33 é‚£ä»½ï¼Œ`#项目=…`)编译成画布 | è¸©åˆ°ä¸¤ä¸ªå‘,见下 |
| 4 | `POST /qc-report/template/create` å»ºæ¨¡æ¿ 38(reportType=1)→ `template-version/create` å†™ç”»å¸ƒ â†’ `publish` | v1.0 å»ºå¥½å¹¶å‘布 |
| 5 | `POST /qc-report/instance/generate-from-qc`(qcType=1, qcId=1, templateId=38) | å®žä¾‹ 48,17 é¡¹ï¼Œ`warnings` ä¸ºç©º |
| 6 | è¯»å®žä¾‹ 48 çš„ `render_html` | åªæœ‰ 5 ä¸ª**空 div**、0 ä¸ª `<tr>` â‡’ æ¸²æŸ“器没吃到组件的 children |
| 7 | æ”¹ç”¨ GrapesJS è§£æžåŽé‡å‘ v1.1 â†’ é‡æ–°å‡ºä»¶ | å®žä¾‹ 49,`render_html` 8356 å­—符、22 ä¸ª `<tr>` |
实例 49 äº§ç‰©é‡Œçš„合并(正则计数,非目测):
```
rowspan = ["2","1","1","5","1","1","1","1","6","1","1","1","1","1","2","1","1","1"]
hidden  = ["hidden" Ã— 10]
```
`5` / `6` / `2` åˆ†åˆ«æ˜¯æ°´åˆ†ï¼ˆ1 å¤´ + 4 å­é¡¹ï¼‰ã€é…¸å«é‡ï¼ˆ1 + 5)、正丁醇含量(1 + 1)的组名格;让位格 4+5+1 = 10 ä¸ªï¼Œä¸Ž `hidden` æ•°å¯¹ä¸Šï¼›æ”¶å°¾é‚£ä¸ª `2` æ˜¯è¡¨å¤´ã€Œç»“论」纵向跨两层。
同一份产物丢进 Chromium å®žæµ‹ï¼ˆè¯» `td.rowSpan` ä¸Žå®žé™…像素高):
| ç»„ | rowSpan | åƒç´ é«˜ | è¡Œé«˜å¯¹ç…§ |
|---|---|---|---|
| æ°´åˆ† | 5 | 155px | 5 Ã— 31px |
| é…¸å«é‡ | 6 | 186px | 6 Ã— 31px |
| æ­£ä¸é†‡å«é‡ | 2 | 62px | 2 Ã— 31px |
| è¯¥ 3 ç»„内的子项行(共 10 è¡Œï¼‰ | 1 | 0 | `display:none`,让位 |
| ç‹¬ç«‹é¡¹ï¼ˆå¤–è§‚/色度/密度/折光率) | 1 | 31px | å„占一行 |
渲染复刻图:`D:/qcl-tmp/merge-e2e.png`(「项目」列一列到底、表头「结论」只印一遍、表格落在纸张内不溢出)。
**建模板踩到的坑(程序化写模板都要注意)**
1. **落库的画布必须是解析后的节点树,不能是 HTML ä¸²ã€‚** `assembleQualityCanvas` äº§ç‰©é‡Œæ¯ä¸ªèŠ‚ç‚¹çš„ `components` æ˜¯**未解析的 HTML å­—符串**——GrapesJS åœ¨æµè§ˆå™¨é‡Œä¼šæŠŠå®ƒæ‹†æˆèŠ‚ç‚¹æ ‘ï¼Œä½†**后端渲染器不解析**。直接把装配产物塞进 `schema_json`,后端会忽略 children、渲出 5 ä¸ªç©º div(实例 48 å°±æ˜¯è¿™æ ·ï¼‰ã€‚
2. **喂 GrapesJS è¦ä¼ èŠ‚ç‚¹å¯¹è±¡ï¼Œä¸èƒ½è‡ªå·±æ‹¼ HTML ä¸²å† `setComponents`。** èµ°å­—符串解析时属性名会被 HTML è§£æžå™¨**转小写**(`data-qc-itemsPath` â†’ `data-qc-itemspath`、`data-qc-headerSpans` â†’ `data-qc-headerspans`),属性面板与渲染都会绑不上。设计器走的就是「直接传对象」这条路,所以库里存的一直是驼峰。
3. è§£æžåŽ `getProjectData()` çš„结构是 `{dataSources, assets, styles, pages, symbols}`,与存量画布**逐键一致**;内联 `style` ä¼šè¢«æå‡æˆ `styles` é‡Œçš„ `#id` è§„则。
### 29.5 å·²éªŒè¯ / æœªéªŒè¯
- âœ… å·²éªŒè¯ï¼ˆæœ¬è½®è¡¥é½ï¼‰ï¼šè‡ªå®šåˆ—的合并属性确实落成(装配产物实证);渲染器把 `span`/`hidden` æ¸²æˆçœŸåˆå¹¶ï¼ˆChromium å®žæµ‹ï¼‰ï¼›è´Ÿå‘对照;**真接口出件的成品里 `rowspan`/`hidden` ä¸Žæµè§ˆå™¨å®žæµ‹æŽ’版都对**(实例 49)。
- ðŸŸ¡ æœªéªŒè¯ï¼š**PDF å‡ºä»¶æœªè·‘**——本轮只验 HTML äº§ç‰©ã€‚PDF æ˜¯åŽç«¯è°ƒ Chromium æ‰“印同一份 HTML,属既有能力,未复跑。
- ä¸Žå‰ä¸€è½®ç›¸åŒçš„æç¤ºï¼šæˆªå›¾é‡Œé‚£ä»½ç”»å¸ƒæ˜¯**未保存的 AI å¯¼å…¥è‰ç¨¿**,刷新设计器看到的是库里旧的平铺 `QualityTable`(`id=21`,无 `columns`)。
### 29.6 æœ¬è½®é€ å‡ºçš„æµ‹è¯•数据(**未清理**,待用户定夺)
| ç±»åž‹ | ç¼–号 | è¯´æ˜Ž |
|---|---|---|
| æŠ¥å‘Šæ¨¡æ¿ | 38 `ZZ_VERIFY_MERGE`「验证-分组纵向合并」 | reportType=1、**启用中**、currentVersion=v1.1 â‡’ **会出现在 IQC å‡ºæŠ¥å‘Šçš„æ¨¡æ¿ä¸‹æ‹‰é‡Œ** |
| æ¨¡æ¿ç‰ˆæœ¬ | 23 / 24 | å‡å·²å‘布;24 æ˜¯è§£æžåŽçš„æ­£ç¡®ç”»å¸ƒï¼Œ23 æ˜¯åçš„那份 |
| æŠ¥å‘Šå®žä¾‹ | 48 / 49 | 48 æ˜¯åäº§ç‰©ï¼ˆç©º div),49 æ˜¯åˆå¹¶æ­£ç¡®çš„æˆå“ |
**没碰**:现网 34 è¡Œè´¨æ£€æŒ‡æ ‡ã€IQC å• 1 åŠå…¶ 16 è¡Œã€æ¨¡æ¿ 1/3/5/33 ä¸Žå…¶ç‰ˆæœ¬ã€å®žä¾‹ 44/45。
### 29.7 ç•™ç—•
- `mom-pro2-before/.qc-conformance/verify-grouped-table.ts`:新增第 4 èŠ‚ï¼ˆè‡ªå®šåˆ—çš„åˆå¹¶ç»“æž„ + è´Ÿå‘对照),文件从只覆盖默认列变成两条分支都覆盖。
- `docs/qc_report_ai_import_frontend_integration.md`:`columns` çš„写法之后补一小节「「#」那一列到底怎么合并的」,把三段职责、三条口径写清(原先只写了「加 `#` å°±åˆå¹¶ã€ï¼Œæ²¡è¯´åˆå¹¶å‡ è¡Œã€ä»€ä¹ˆæ—¶å€™ä¸åˆå¹¶ï¼‰ã€‚
- `docs/project-business/mermaid/05-quality-flow.mmd`:`RCTXG` èŠ‚ç‚¹åŽŸå…ˆå†™ã€Œåªåˆå¹¶æ£€éªŒé¡¹ç›®ä¸€åˆ—ã€â€”â€”è‡ªå®šåˆ—ä¹‹åŽåˆå¹¶åˆ—ç”± `#` å†³å®šï¼Œæ”¹ä¸ºæŒ‰ `#` é‚£ä¸€åˆ—描述并补上两条口径。
### 29.8 æœªåš
- æ²¡æ”¹ä»»ä½•生产代码(合并逻辑本来就通,缺的是取证)
- æ²¡è®©è®¾è®¡å™¨ç”»å¸ƒé¢„览合并(画布只有一行未展开的模板行且不求解绑定,`rowspan="{{…}}"` æ˜¯éžæ³•值按 1 å¤„理)——要做是独立决策
- æ²¡ä¸ºç«¯åˆ°ç«¯å¤çŽ°åŽ»é€ ä¸€å¼ å¸¦åˆ†ç»„æŒ‡æ ‡çš„è´¨æ£€å•
## ä¸‰åã€æ ·å“ä¿¡æ¯æœ«è¡Œè¡¥é½ç©ºä½ï¼ˆ2026-09-19 ç»­ï¼‰
**现象**(用户在设计器里看到的):模板 38 çš„「样品信息」表最后一行是 `检验日期 | {{report.inspectDate}} | ç©º | ç©º`——右侧秃出两个只有边框的空格子。
**根因**:`sample-info.ts` çš„ `buildRows` æŒ‰ `pairsPerRow` æ¯ç»„ 2 æ ¼åˆ‡å­—段,末行凑不满一组时照样把格子铺满,余下的列就空着。字段数不是「每行组数」整数倍时必然出现,而 **AI å¯¼å…¥æŒ‰åŽŸä»¶å­—æ®µå¡« `fields`,奇偶不由我们定**(本单原件 5 é¡¹ã€æ¯è¡Œ 2 ç»„,正是最典型的一种)。
**修法**:末行字段数 `< pairsPerRow` æ—¶ï¼Œè®©**最后一个「值」格**横向吃掉余下的列。跨列数 = `(pairsPerRow - æœ«è¡Œå­—段数) Ã— 2 + 1`。
补在值格而不是字段名格——字段名格带 `width:90px`,补上去这一列的宽度会跟着变,各行左边缘就错开了。
**为什么默认字段不受影响**:默认 6 é¡¹ Ã· 2 = 3 ä¸ªæ•´è¡Œï¼Œä¸€æ ¼éƒ½ä¸è¡¥ã€‚`verify-columns.ts` ç¬¬ 1 èŠ‚å†»ç»“çš„é€å­—æŒ‡çº¹ `SAMPLE_INFO_BASELINE` å› æ­¤åŽŸæ ·é€šè¿‡â€”â€”è¿™ä¹Ÿæ˜¯ã€Œæ”¹è¿™ä¸ªä¸ä¼šåŠ¨åˆ°å·²å‘å¸ƒæ¨¡æ¿ã€çš„æœºå™¨è¯æ®ã€‚
**探针**:`verify-columns.ts` ç¬¬ 5 èŠ‚è¡¥ 9 æ¡æ–­è¨€â€”—5 é¡¹ Ã· 2 å‡º 3 è¡Œ 10 æ ¼ / åªå¤š 1 ä¸ª `colspan` / è·¨ 3 åˆ— / è¡¥çš„æ˜¯å€¼æ ¼ï¼ˆå¯¹ç€å­—段名格断言 `width:90px` ä¸”æ—  `colspan`)/ æœ«è¡Œä»æ˜¯ã€Œæ£€éªŒæ—¥æœŸ + å®ƒçš„值」/ é»˜è®¤ 6 é¡¹æ—  `colspan` / `pairsPerRow=1` æ°¸ä¸è¡¥ / 4 é¡¹ Ã· 3 è·¨ 5 åˆ—。跑通;三个兄弟探针(分组表、重复行、待判定)与两个改动文件 typecheck å…¨ç»¿ã€‚
**端到端复看成品**:用更新后的组件重装配模板 38 çš„同一份草稿 â†’ GrapesJS è§£æž â†’ å‘ **v1.2**(版本 id 25)→ `generate-from-qc` å‡º**实例 50**(`QR20260919-0016`)。
实例 50 é‡Œæ ·å“ä¿¡æ¯æœ«è¡Œå€¼æ ¼ `colspan=3`、实测宽 809px = å…¶ä½™ä¸‰åˆ—之和(90 + 250 + 469),右侧再无空格;分组表的 `rowspan`(5/6/2)与 10 ä¸ªè®©ä½æ ¼åŽŸæ ·ä¿ç•™ã€‚æ¸²æŸ“å¤åˆ»å›¾ `D:/qcl-tmp/merge-v12.png`。
**留痕**:`sample-info.ts`(`buildRows`)、`.qc-conformance/verify-columns.ts` ç¬¬ 5 èŠ‚ã€‚ä¸€æ¬¡æ€§è£…é…è„šæœ¬è·‘å®Œå·²æ¬å‡ºä»“åº“ï¼ˆ`D:/qcl-tmp/build-merge-v12.ts`)。
**未做**:没改 `HtmlRenderer` / `BASE_CSS`;没动模板 33 ä¸Žå…¶å®ƒå­˜é‡æ¨¡æ¿çš„画布(新规则只在新装配或用户重存时生效);PDF æœªé‡è·‘。
业务可视化未更新——这是组件排版细节,不落在「业务模块 / ä¸šåŠ¡å¯¹è±¡ / æµç¨‹æ­¥éª¤ / çŠ¶æ€æµè½¬ / è·¨æ¨¡å—数据流 / AI èƒ½åŠ› / è§’色权限」任何一类。
docs/ÖÇÄÜÖʼ챨¸æÆ½Ì¨-·½°¸Éè¼Æ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,374 @@
# æ™ºèƒ½è´¨æ£€æŠ¥å‘Šè®¾è®¡å¹³å° â€” æ–¹æ¡ˆè®¾è®¡ï¼ˆPhase 0 é¡¹ç›®æ‰«æäº§å‡ºï¼‰
> çŠ¶æ€ï¼š**待用户确认**(确认后方可进入 Phase 1 ç¼–码)
> æ‰«ææ—¥æœŸï¼š2026-09-17
> ä¾æ®æ–‡æ¡£ï¼š`docs/智能质检报告设计平台——Claude Code Agent ä¸“业开发提示词.md`
> åŽŸåˆ™ï¼šå…ˆç†è§£çŽ°æœ‰ç³»ç»Ÿ â†’ æœ€å¤§åŒ–复用 â†’ æœ€å°åŒ–侵入
---
## ä¸€ã€å½“前项目技术栈
### åŽç«¯
| é¡¹ | çް值 |
|---|---|
| æ¡†æž¶ | Spring Boot 4.1(yudao ä½“系)+ Spring Security |
| ORM | MyBatis-Plus(`BaseMapperX` / `LambdaQueryWrapperX`) |
| æ•°æ®åº“ | MySQL 8(`application-local.yaml` æŒ‡å‘ `ruoyi-vue-pro`) |
| ç«¯å£ | 48080,profile = `local`(另有 dev/test/jhhg/wtxc/yzfx/zsjc) |
| å¤šç§Ÿæˆ· | **全局关闭**(`yudao.tenant.enable: false`)— æ–°è¡¨ä¸åŠ  `tenant_id` |
| æ–‡æ¡£ | springdoc OpenAPI3 + Knife4j 4.5.0 |
| Excel | FastExcel 1.3.0(`ExcelUtils`) |
| ç¼“å­˜ | Redis(db 5) |
### å‰ç«¯ï¼ˆ`mom-pro2-before`,**只读**)
| é¡¹ | çް值 |
|---|---|
| å½¢æ€ | Vben Admin **5.7.0 å•应用版**(`@vben/web-antd-standalone`),业务代码在根 `src/` |
| æ¡†æž¶ | Vue 3 + TypeScript + Vite + Pinia + Vue Router |
| UI åº“ | **Ant Design Vue 4.2.6**(唯一;**无 Element Plus**) |
| è¡¨æ ¼ | vxe-table(`useVbenVxeGrid`)+ `TableAction` |
| è¯·æ±‚ | `src/api/request.ts` çš„ `requestClient`,前缀 `/admin-api`,代理到 `192.168.0.10:48080` |
| åˆ«å | `#/* â†’ ./src/*` |
| å¼€å‘端口 | 5666 |
| å‘½ä»¤ | `pnpm dev` / `pnpm build` / `pnpm typecheck` |
---
## äºŒã€å½“前前端架构
- **路由与菜单由后端驱动**:`src/router/access.ts` ä»Ž `accessStore.accessMenus`(后端 `system_menu`)生成路由,用 `import.meta.glob('../views/**/*.vue')` æŠŠåŽç«¯ `component` å­—符串(如 `mes/qc/defect/index`)映射成组件。
  â†’ **新增页面只需放到 `src/views/**/index.vue` + æ’一条 `system_menu` è®°å½•,无需手工注册路由。**
- `src/router/routes/modules/*.ts` ä»…用于放隐藏页(`hideInMenu: true`),如详情/编辑页。
- **典型 CRUD å‚考样例**(新页面照抄结构):
  - `src/views/mes/qc/defect/index.vue`(列表)
  - `src/views/mes/qc/defect/data.ts`(`useGridColumns` / `useGridFormSchema` / `useFormSchema`)
  - `src/views/mes/qc/defect/modules/form.vue`(弹窗表单)
  - `src/api/mes/qc/defect/index.ts`(API åˆ†å±‚,`export namespace XxxApi`)
- ç›®å½•约定:`src/views`、`src/api`(镜像 views)、`src/store`、`src/components`、`src/types`、`src/utils`。
- æƒé™ï¼šæŒ‰é’®çº§ç”¨ `v-access:code` æˆ– `TableAction` çš„ `auth: [...]`,判权用 `useAccess().hasAccessByCodes`。
- å­—典:`@vben/constants` çš„ `DICT_TYPE`(`src/packages/constants/src/dict-enum.ts`),配 `getDictOptions` / `DictTag` / `cellRender: CellDict`。
---
## ä¸‰ã€å½“前后端架构
- **Maven å¤šæ¨¡å—**,包根 `cn.iocoder.yudao.module.{模块}`。新增模块需改 2 å¤„:根 `pom.xml` çš„ `<modules>`、`yudao-server/pom.xml` çš„ `<dependencies>`(`yudao-dependencies` åªç®¡ç¬¬ä¸‰æ–¹ç‰ˆæœ¬ï¼Œ`lombok.config` å…¨å±€ç”Ÿæ•ˆï¼Œæ—  `spring.factories` éœ€ç»´æŠ¤ï¼‰ã€‚
- å•模块内分层(以 MES ä¸ºä¾‹ï¼‰ï¼š
  | å±‚ | è·¯å¾„ |
  |---|---|
  | Controller | `controller/admin/{域}/{子域}/XxxController.java` |
  | VO | `controller/admin/{域}/{子域}/vo/Xxx{Save,Resp,Page}ReqVO.java` |
  | Service | `service/{域}/{子域}/XxxService.java` |
  | ServiceImpl | `service/{域}/{子域}/XxxServiceImpl.java` |
  | DO | `dal/dataobject/{域}/{子域}/XxxDO.java` |
  | Mapper | `dal/mysql/{域}/{子域}/XxxMapper.java` |
  | æžšä¸¾ | `enums/{域}/` |
  | é”™è¯¯ç  | `enums/ErrorCodeConstants.java` |
- é€šç”¨èƒ½åŠ›ï¼ˆç›´æŽ¥å¤ç”¨ï¼‰ï¼š`CommonResult<T>`、`PageResult<T>`/`PageParam`、`BaseDO`(`createTime/updateTime/creator/updater/deleted` è‡ªåŠ¨å¡«å……ï¼‰ã€`ServiceException` + `ServiceExceptionUtil.exception(ErrorCodeConstants.XXX)`、`BeanUtils`(MapStruct)、`BaseMapperX`、`LambdaQueryWrapperX`。
- **JSON åˆ—存储**:`@TableName(value="...", autoResultMap=true)` + `@TableField(typeHandler = Jackson3TypeHandler.class)`(见 `BpmFormDO`)——模板 Schema å¤§å­—段走这个模式。
- æµ‹è¯•:JUnit5 + `BaseDbUnitTest`(H2)/ `BaseMockitoUnitTest` + PODAM。
---
## å››ã€å½“前目录结构(关键部分)
```
mom-pro2-after/                          # åŽç«¯ä»“库根
├── pom.xml                              # <modules> æ³¨å†Œå¤„
├── yudao-server/                        # å¯åŠ¨æ¨¡å—ï¼ˆpom é‡Œèšåˆä¸šåŠ¡æ¨¡å—ï¼‰
├── yudao-dependencies/                  # ç¬¬ä¸‰æ–¹ç‰ˆæœ¬ç®¡ç†
├── yudao-framework/                     # æ¡†æž¶ starter
├── yudao-module-system|infra|bpm|member|
│   crm|erp|mes|mdm|hrm|srm|aftersales|ai|bi|im
├── yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/
│   â”œâ”€â”€ controller/admin/qc/{iqc,ipqc,oqc,rqc,ncr,defect,indicator,template}/
│   â”œâ”€â”€ service/qc/...
│   â”œâ”€â”€ dal/dataobject/qc/...
│   â””── enums/qc/  (MesQcTypeEnum / MesQcCheckResultEnum / MesQcResultValueTypeEnum ...)
├── docs/                                # æ–¹æ¡ˆ/联调文档
└── mom-pro2-before/                     # å‰ç«¯ä»“库(只读)
    â””── src/{api,views,router,store,components,packages}/
```
---
## äº”、当前 UI æ¡†æž¶åŠç‰ˆæœ¬
**Ant Design Vue 4.2.6 + Vben Admin 5.7.0**(无 Element Plus)。
→ æ–°æ¨¡å— UI å¿…须用 **Vben + Ant Design Vue**,**禁止引入 Element Plus**。
---
## å…­ã€å½“前可以复用的能力(重点)
| èƒ½åŠ› | çŽ°çŠ¶ | å¤ç”¨æ–¹å¼ |
|---|---|---|
| **质检业务数据** | MES æœ‰å®Œæ•´ qc åŸŸï¼š`mes_qc_iqc/ipqc/oqc/rqc` + `*_line` + `mes_qc_template/_indicator/_item` + `mes_qc_indicator_result(_detail)` | æ–°å¹³å°ç›´æŽ¥æ³¨å…¥ `MesQcIqcService` ç­‰å–数,**不重建质检模型** |
| è´¨æ£€æžšä¸¾ | `MesQcTypeEnum`、`MesQcCheckResultEnum`、`MesQcResultValueTypeEnum` ç­‰ | ç›´æŽ¥å¼•用 |
| æ–‡ä»¶ä¸Šä¼ ï¼ˆåŽç«¯ï¼‰ | infra `FileApi.createFile(bytes,name,dir,type)` â†’ è¿”回 URL;system `StorageFileUtil.saveStorageAttachment(...)` ç»‘定业务记录 | PDF ç”ŸæˆåŽä¸€è¡Œä¿å­˜ |
| æ–‡ä»¶ä¸Šä¼ ï¼ˆå‰ç«¯ï¼‰ | `src/api/system/storage/index.ts`:`/system/storage-blob/upload` + `/system/storage-attachment/bind` | æ¨¡æ¿ç¼©ç•¥å›¾/附件复用 |
| **二维码/条码(前端)** | `qrcode 1.5.4`、`jsbarcode 3.12.3`,已封装 `packages/effects/common-ui/src/components/barcode/barcode.vue`(含 `getImageBase64()`) | **直接复用,不新增库** |
| æ‰“印 | `vue3-print-nb 0.1.4`(范例 `views/bpm/processInstance/detail/modules/process-print.vue`) | å¤ç”¨ |
| ç­¾å | `vue3-signature 0.4.4`、`SignaturePad`(HRM å·²ç”¨ï¼‰ | å¤ç”¨ |
| æ‹–拽 | `vuedraggable 4.1.0`、`sortablejs 1.15.7` | å¤ç”¨ |
| å¯Œæ–‡æœ¬ | `tinymce 7.9.3` / `@tiptap/*` | å¤ç”¨ï¼ˆRichText ç»„件) |
| å›¾è¡¨ | `echarts 6.1.0` | å¤ç”¨ï¼ˆç»Ÿè®¡ç»„件) |
| è®¾è®¡å™¨å…ˆä¾‹ | `bpmn-js 18.16.1`(`views/bpm/model/`、`views/mes/process-design/`)为项目内唯一"画布+属性面板"先例;`@form-create/antd-designer 3.4.0` å·²è£… | å‚考布局组织 |
| æƒé™ä½“ç³» | `@PreAuthorize("@ss.hasPermission('x:y:z')")` + åŽç«¯èœå• `system_menu` | æ²¿ç”¨ï¼Œ**不新建权限系统** |
| å­—典体系 | `system_dict_type/data` + å‰ç«¯ `DICT_TYPE` | æ–°å¢ž `QC_REPORT_*` å­—典项即可 |
---
## ä¸ƒã€å½“前不能复用、需要新增的能力
| ç¼ºå£ | è¯´æ˜Ž | é£Žé™© |
|---|---|---|
| **HTML/PDF æ¸²æŸ“引擎** | å…¨é¡¹ç›®**零 PDF ç”Ÿæˆèƒ½åŠ›**。仅 PDFBox(CRM/ERP,只读解析)、FastExcel(Excel å¯¼å‡ºï¼‰ã€‚ | é«˜ â€” éœ€æ–°å¢žä¾èµ– |
| **Playwright / Chromium** | å®Œå…¨ä¸å­˜åœ¨ | **高 â€” éœ€ä¸‹è½½ Chromium(约 150MB),需确认网络/离线环境** |
| **GrapesJS** | å‰ç«¯**未安装**(无 `grapesjs` åŠä»»ä½• preset/plugin) | ä¸­ â€” éœ€æ–°å¢žä¾èµ–(文档 Â§54 è¦æ±‚先论证) |
| æ¨¡æ¿ Schema è®¾è®¡å™¨ | æ— ï¼ˆBPM è¡¨å•设计器是工作流用途,非报告排版) | ä¸­ |
| æŠ¥å‘Šå®žä¾‹/版本体系 | æ—  | ä½Ž |
| è§„则引擎 / æ•°æ®ç»‘定引擎 | æ— ï¼ˆ`MesQcAiController` æœ‰ AI åˆ¤å®šå»ºè®®ï¼Œéžé€šç”¨è§„则引擎) | ä¸­ |
| ç§¯æœ¨æŠ¥è¡¨ `yudao-module-report` | æ ¹ pom **注释禁用**(JimuReport 2.3.4 ç‰ˆæœ¬ä»åœ¨ dependencies ä¸­ï¼‰ | å‘½åå†²çªé£Žé™© |
---
## å…«ã€æ•°æ®åº“设计建议
**建议新建独立模块**,与行业业务解耦(文档 Â§35 è¦æ±‚),表名采用 `qc_report_*`(贴合项目"模块前缀"命名习惯;文档原文为 `quality_report_*`,语义一致):
| è¡¨ | ç”¨é€” | å…³é”®å­—段 |
|---|---|---|
| `qc_report_template` | æ¨¡æ¿ä¸»è¡¨ | `template_code`(uk), `template_name`, `industry`, `report_type`, `page_size/orientation`, `status`, `current_version`, `description`, `thumbnail_url` |
| `qc_report_template_version` | ç‰ˆæœ¬å¿«ç…§ï¼ˆ**发布后不可覆盖**) | `template_id`, `version`, `schema`(JSON longtext), `html`, `css`, `status`, `publish_time` |
| `qc_report_data_source` | æ•°æ®æºå®šä¹‰ | `template_id`, `source_key`, `source_type`(SQL/API/内置), `config`(JSON) |
| `qc_report_rule` | åˆ¤å®šè§„则 | `template_id`, `target_path`, `expression`, `pass_value/fail_value` |
| `qc_report_instance` | æŠ¥å‘Šå®žä¾‹ï¼ˆ**必须存 `data_snapshot`**) | `report_no`(uk), `template_id`, `template_version`, `business_id`, `business_type`, `data_snapshot`(JSON), `render_html`, `pdf_file_url`, `status` |
| `qc_report_render_record` | æ¸²æŸ“/PDF è€—时与异常记录(文档 Â§41 æ—¥å¿—要求) | `report_id`, `render_start/end/duration`, `pdf_duration`, `browser_status`, `error_stack` |
统一继承 `BaseDO`;不建 `tenant_id`;Schema/Config ç±»å­—段用 `Jackson3TypeHandler` + `autoResultMap=true`。
---
## ä¹ã€Quality Components æž¶æž„(前端)
按文档 Â§8/§9 å»ºç«‹ç‹¬ç«‹ç»„件协议层,**不把业务组件写死进 GrapesJS**:
```
src/components/quality/
├── core/{types.ts, registry.ts, factory.ts, serializer.ts, validator.ts}
├── base/ layout/ inspection/ table/ result/ statistics/ signature/ header/ footer/ barcode/ qrcode/
└── index.ts                     # ç»Ÿä¸€æ³¨å†Œå…¥å£ï¼ˆregisterAllQualityComponents(editor))
```
`QualityComponentDefinition` ç»Ÿä¸€åè®®ï¼š`type / name / label / category / icon / defaults / traits / dataSchema / styleSchema / propertySchema / render / validate / serialize / deserialize`。
**行业扩展**:`registerIndustryPack('chemical')` åªæ³¨å†Œæ–°ç»„件包,**不改核心 Designer**。
**组件**:首批 åŸºç¡€ï¼ˆText/Title/RichText/Label/Image/Logo/Container/Row/Column/Section/Grid/Divider/Spacer);质量专业(ReportHeader / SampleInfo / InspectionItem / QualityTable / Result / Statistics / Signature / Barcode / QRCode / PageHeader / PageFooter)。
**TypeScript**:禁用 `any`;第三方库无类型时才可局部隔离并注明原因。
---
## åã€GrapesJS é›†æˆæ–¹æ¡ˆ
- **必须新增依赖**:`grapesjs`(+ æŒ‰éœ€ `grapesjs-preset-webpage`)。项目现有 `@form-create/antd-designer` æ˜¯**表单设计器**,语义不符;`bpmn-js` æ˜¯æµç¨‹è®¾è®¡å™¨ï¼Œå‡ä¸èƒ½æ›¿ä»£æŠ¥å‘ŠæŽ’ç‰ˆç”»å¸ƒã€‚
- é›†æˆè¦ç‚¹ï¼š`BlockManager` æ³¨å†Œç»„件块;`Components.addType` æ³¨å†Œè‡ªå®šä¹‰ç»„件;自定义 `QualityPropertyPanel`(**不依赖默认 StyleManager**);`Commands` æŒ‚ `保存/预览/发布/PDF`;`StorageManager` ç¦ç”¨ï¼ˆæ”¹ç”±åŽç«¯ API å­˜å–);`UndoManager` æä¾›æ’¤é”€é‡åšï¼›`DeviceManager` æŒ‰çº¸å¼ å°ºå¯¸ï¼ˆA4/A3/A5 + æ¨ªå‘/纵向)注册设备。
- Designer å¿…须拆分组件(文档 Â§56,禁止 2000+ è¡Œå•文件):`DesignerToolbar / Sidebar / Canvas / PropertyPanel / DataPanel / RulePanel / PageSettings`。
- çŠ¶æ€ç”¨ Pinia åˆ†ç‰‡ï¼ˆ`useDesignerStore`),避免巨型 Store。
---
## åä¸€ã€Template Schema
**不保存裸 HTML**,保存结构化 Schema(文档 Â§21):
```json
{
  "schemaVersion": "1.0.0",
  "page": { "size": "A4", "orientation": "portrait",
            "margin": { "top": 20, "right": 15, "bottom": 20, "left": 15 } },
  "grapes": {},
  "components": [],
  "dataSources": [],
  "bindings": [],
  "rules": [],
  "styles": []
}
```
纸张支持:`A3 / A4 / A5 / Letter / è‡ªå®šä¹‰` Ã— `portrait / landscape`。
---
## åäºŒã€Data Binding æ–¹æ¡ˆ
统一 `{{path}}` åè®®ï¼ˆæ–‡æ¡£ Â§18):
```
{{report.reportNo}}  {{sample.sampleName}}  {{sample.batchNo}}
{{customer.name}}    {{supplier.name}}
{{inspectionItems[0].actualValue}}   {{inspectionItems[0].result}}
```
支持:对象 / æ•°ç»„ / åµŒå¥— / æ•°ç»„遍历(动态表格)/ è®¡ç®—字段 / è¡¨è¾¾å¼ / æ¡ä»¶å­—段 / æ ¼å¼åŒ– / é»˜è®¤å€¼ã€‚
**必须提供 `ContextBuilder`**:把 MES è´¨æ£€å•据(`MesQcIqcDO` + `*_line` + `mes_qc_indicator`)映射成标准 Context,保证可复用到 IQC/IPQC/OQC/RQC。
---
## åä¸‰ã€Rule Engine æ–¹æ¡ˆ
独立 `QualityRuleEngine`(**禁止写死在 Vue ç»„件里**,文档 Â§17):
- ç®—子:`> >= < <= = != BETWEEN IN NOT_IN` + `AND / OR`
- ç»“果:`PASS / FAIL`
- ç»Ÿè®¡å‡½æ•°ï¼ˆæ–‡æ¡£ Â§36):`AVG/MAX/MIN/COUNT/STDDEV/CP/CPK/PASS_RATE/FAIL_RATE`
- å‰åŽç«¯**各自实现一份**:前端用于设计期实时预览判定,后端用于正式渲染(以后端为准,消除漂移)。
- **安全**:表达式**不得用 `eval` / `ScriptEngine` ç›´æŽ¥æ‰§è¡Œ**。方案:自建词法/语法解析(递归下降)生成 AST æ±‚值,白名单函数表。
---
## åå››ã€HTML Render æ–¹æ¡ˆ
服务分层(文档 Â§29,Controller åªæ”¶å‘):
```
ReportRenderService(编排)
├── TemplateRenderService   # åŠ è½½ç‰ˆæœ¬ Schema
├── DataBindingService      # Context + ç»‘定求值
├── QualityRuleEngine       # è§„则计算 PASS/FAIL
├── HtmlRenderService       # Schema â†’ HTML + æ³¨å…¥ CSS(@page、分页)
└── PlaywrightRenderService â†’ PdfRenderService
```
- **HTML Sanitization(文档 Â§33,必须做)**:剥离 `<script>`、`<iframe>`、`on*=` äº‹ä»¶å±žæ€§ã€`javascript:` URL;渲染端对外部资源做白名单/内联化(本地字体、图片走内网 URL)。
- ä¸­æ–‡å­—体需内置/指定(PDF ä¸­æ–‡å¯ç”¨æ€§éªŒæ”¶é¡¹ï¼‰ã€‚
---
## åäº”、Playwright / Chromium æ–¹æ¡ˆ
- ä¾èµ–:`com.microsoft.playwright:playwright`(版本入 `yudao-dependencies`)。
- **`BrowserManager` å•例**(文档 Â§30):浏览器复用、Page ç”Ÿå‘½å‘¨æœŸã€å¹¶å‘信号量(PDF å¹¶å‘上限,建议 2~4)、超时、崩溃自动恢复、关闭钩子。**禁止每次请求 launch→close**。
- âš  **最大风险点**:需首次下载 Chromium(约 150MB)。**若部署环境无外网,Playwright æ–¹æ¡ˆä¸å¯è¡Œ**,必须改用纯 Java æ¸²æŸ“(openhtmltopdf + Flying Saucer æˆ– iText)。**此项需用户明确确认。**
---
## åå…­ã€PDF åˆ†é¡µæ–¹æ¡ˆ
- `@page { size: A4; margin: ... }` + `page-break-before/after/inside`。
- QualityTable:`thead { display: table-header-group }` å®žçް**跨页重复表头**;`tr { page-break-inside: avoid }` é¿å…è¡Œè¢«æ‹†å¼€ã€‚
- é¡µçœ‰/页脚/页码:Playwright `pdf()` çš„ `headerTemplate/footerTemplate`(`<span class="pageNumber">`)。
- æ€§èƒ½ï¼š1000+ è¡Œè¡¨æ ¼éœ€åˆ†å—渲染,避免一次性巨型 DOM(文档 Â§39)。
---
## åä¸ƒã€API è®¾è®¡ï¼ˆè‰æ¡ˆï¼Œå¾…确认前缀)
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|---|---|---|
| POST | `/qc-report/template/create` | æ–°å»ºæ¨¡æ¿ |
| PUT | `/qc-report/template/update` | ä¿®æ”¹æ¨¡æ¿ |
| DELETE | `/qc-report/template/delete` | åˆ é™¤ |
| GET | `/qc-report/template/page` | åˆ†é¡µï¼ˆåç§°/编码/行业/类型/状态/创建人/时间) |
| GET | `/qc-report/template/get` | è¯¦æƒ… |
| POST | `/qc-report/template/copy` | å¤åˆ¶ |
| POST | `/qc-report/version/create` | åˆ›å»ºç‰ˆæœ¬ |
| GET | `/qc-report/version/list` | ç‰ˆæœ¬åˆ—表 |
| POST | `/qc-report/version/publish` | å‘布(不可覆盖) |
| POST | `/qc-report/version/rollback` | å›žæ»š |
| POST | `/qc-report/render/preview` | é¢„览 â†’ HTML |
| POST | `/qc-report/render/html` | ç”Ÿæˆ HTML |
| POST | `/qc-report/render/pdf` | ç”Ÿæˆ PDF |
| GET | `/qc-report/instance/page` | æŠ¥å‘Šå®žä¾‹åˆ—表 |
| GET | `/qc-report/instance/download` | ä¸‹è½½ PDF |
> æ–‡æ¡£ç¤ºä¾‹ä¸º `/quality/report/*`;本项目约定为 `/{模块}/{域}/*`,故建议 `/qc-report/*`。**待确认。**
权限码:`qc-report:template:{query,create,update,delete,publish}`、`qc-report:instance:{query,create,download}`,沿用现有 `@ss.hasPermission` ä½“系。
---
## åå…«ã€ç›®å½•结构建议
后端(**新增模块** `yudao-module-qcreport` / åŒ…æ ¹ `cn.iocoder.yudao.module.qcreport`):
```
controller/admin/{template,version,instance,render}/...
service/{template,version,instance,render}/...
dal/dataobject|mysql/qcreport/...
engine/{binding,rule,render}/
engine/render/playwright/{BrowserManager,PdfRenderService}.java
config/  enums/
```
前端(**需授权后**再动):
```
src/views/mes/qc/report/{template,designer,preview,instance}/
src/components/quality/{core,base,inspection,table,result,statistics,signature,...}/
src/api/mes/qc/report/
src/store/qualityReport.ts
```
> æ¨¡å—命名备选:`yudao-module-report`(与注释中的积木报表模块**重名,不建议**)。
---
## åä¹ã€Phase 1~5 å®žæ–½è®¡åˆ’
| Phase | å†…容 | éªŒæ”¶ |
|---|---|---|
| **0** | é¡¹ç›®æ‰«æ + æ–¹æ¡ˆè®¾è®¡ï¼ˆæœ¬æ–‡æ¡£ï¼‰ | ç”¨æˆ·ç¡®è®¤ âœ…/❌ |
| **1** | åŽç«¯æ¨¡å—骨架 + æ¨¡æ¿ CRUD + ç‰ˆæœ¬è¡¨ + Schema å­˜å–ï¼›Designer åŸºç¡€æ¡†æž¶ + Component Registry + GrapesJS é›†æˆ + åŸºç¡€ç»„ä»¶ + ä¿å­˜/加载 | å»ºæ¨¡æ¿â†’拖组件→保存→重开状态一致 |
| **2** | QualityTable / ReportHeader / SampleInfo / InspectionItem / Result + æ•°æ®ç»‘定 + åŠ¨æ€è¡¨æ ¼ + è§„则引擎 | `inspectionItems` è‡ªåŠ¨ç”Ÿæˆè¡¨æ ¼å¹¶ç®—å‡º PASS/FAIL |
| **3** | HtmlRender + Preview + Playwright + Chromium + PDF + A4/分页/页眉页脚/页码 | A4 å°ºå¯¸/边距/中文/多级表头/重复表头/跨页正确 |
| **4** | ç‰ˆæœ¬ç®¡ç† + æŠ¥å‘Šå®žä¾‹ + æƒé™ + æ—¥å¿— + å¼‚常处理 | å‘布不可覆盖、`data_snapshot` å†»ç»“ |
| **5** | è‡ªåŠ¨åŒ–æµ‹è¯• + E2E + å¤æ‚表格/多页/100+ è¡Œ/图片/二维码/签名 | æ–‡æ¡£ Â§43 å…¨éƒ¨éªŒæ”¶é¡¹é€šè¿‡ |
---
## äºŒåã€é£Žé™©æ¸…单
| # | é£Žé™© | ç­‰çº§ | åº”对 |
|---|---|---|---|
| 1 | **后端 Playwright/Chromium éœ€è”网下载 ~150MB** | ðŸ”´ é«˜ | éœ€ç¡®è®¤éƒ¨ç½²çŽ¯å¢ƒå¤–ç½‘å¯è¾¾ï¼›å¦åˆ™æ”¹ openhtmltopdf çº¯ Java æ–¹æ¡ˆ |
| 2 | **前端属禁改区**(`.claude/rules/frontend-project.md`:未经明确允许禁止修改 `mom-pro2-before` ä»»ä½•文件) | ðŸ”´ é«˜ | **必须获得用户显式授权**,否则本项目无法交付 |
| 3 | **GrapesJS éœ€æ–°å¢žå‰ç«¯ä¾èµ–**(项目无) | ðŸŸ¡ ä¸­ | éœ€ç”¨æˆ·æ‰¹å‡†ï¼›ä½“积/构建影响需评估 |
| 4 | æ–°å¢ž Maven æ¨¡å—需改根 `pom.xml` + `yudao-server/pom.xml`(公共文件) | ðŸŸ¡ ä¸­ | æŒ‰æ–‡æ¡£ Â§53 è¯´æ˜Žå½±å“é¢å¹¶éªŒè¯ `mvn compile -q` |
| 5 | è¡¨è¾¾å¼å¼•擎安全(禁止 eval) | ðŸŸ¡ ä¸­ | è‡ªå»º AST è§£æžç™½åå•求值 |
| 6 | æ¨¡æ¿ HTML å±žç”¨æˆ·å¯ç¼–辑内容(XSS) | ðŸŸ¡ ä¸­ | æœåŠ¡ç«¯ Sanitization + PDF æ¸²æŸ“沙箱 |
| 7 | åŽ†å²æŠ¥å‘Šå› ä¸šåŠ¡æ•°æ®å˜åŒ–è€Œå˜ | ðŸŸ¡ ä¸­ | `data_snapshot` å¼ºåˆ¶è½åº“ |
| 8 | åŽ†å²åº“ `ruoyi-vue-pro` ä¸ºè¿è¡Œåº“,**新表 DDL ä¸å¾—在源库乱跑** | ðŸŸ¡ ä¸­ | èµ° `docs/sql/` åˆå§‹åŒ–脚本 + ç›®æ ‡åº“执行 |
| 9 | 100+ é¡µ PDF å†…å­˜/性能 | ðŸŸ¢ ä½Ž | BrowserManager å¹¶å‘限制 + æµå¼å†™å‡º |
---
## äºŒåä¸€ã€Phase 1 å¼€å§‹å‰éœ€è¦ç¡®è®¤çš„问题
1. **前端修改授权**:是否允许我修改 `mom-pro2-before`(新增页面/组件/API æ–‡ä»¶ï¼Œä¸é‡æž„既有代码)?
   - è‹¥ä¸å…è®¸ â†’ æœ¬é¡¹ç›®åªèƒ½äº¤ä»˜**后端** + å‰ç«¯è”调方案文档。
2. **Playwright å¯è¡Œæ€§**:后端运行/部署环境**能否访问外网**下载 Chromium?若不能,是否接受改用纯 Java æ¸²æŸ“(openhtmltopdf)作为降级方案?
3. **GrapesJS ä¾èµ–**:是否批准新增 `grapesjs`(及可选 preset)?
4. **模块命名**:新后端模块用 `yudao-module-qcreport`(表 `qc_report_*`)是否可以?还是沿用文档的 `quality_report_*` / `/quality/report/*` è·¯å¾„?
5. **首批范围**:今天是否先只做 **Phase 1**(模板 CRUD + Schema å­˜å– + Designer éª¨æž¶ï¼‰ï¼Œè¿˜æ˜¯å¸Œæœ›ä¼˜å…ˆåšåŽç«¯ API + æ¸²æŸ“链路?
6. **数据源范围**:报告主要面向 **MES è´¨æ£€å•(IQC/IPQC/OQC/RQC)**,是否还有 ERP é‡‡è´­å…¥åº“检验 / CRM ç­‰å…¶ä»–来源?
---
## é™„录:本次扫描的关键事实出处
- æ ¹æ¨¡å—注册:`pom.xml:10-49`
- MES è´¨æ£€åŸŸï¼š`yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/qc/{iqc,ipqc,oqc,rqc,ncr,defect,indicator,template,indicatorresult,defectrecord}`
- è´¨æ£€æžšä¸¾ï¼š`yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/qc/`
- JSON åˆ—范例:`yudao-module-bpm/.../definition/BpmFormDO.java:53`
- å‰ç«¯ CRUD èŒƒä¾‹ï¼š`mom-pro2-before/src/views/mes/qc/defect/{index.vue,data.ts,modules/form.vue}`、`src/api/mes/qc/defect/index.ts`
- å‰ç«¯ä¸Šä¼  API:`mom-pro2-before/src/api/system/storage/index.ts`
- åŽç«¯æ–‡ä»¶ API:`yudao-module-infra/.../api/file/FileApi.java`
docs/ÖÇÄÜÖʼ챨¸æÆ½Ì¨-Öʼ쵥¶Ô½Ó·½°¸.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,572 @@
# æ™ºèƒ½è´¨æ£€æŠ¥å‘Šå¹³å° â€” è´¨æ£€å•对接方案(已确认)
> çŠ¶æ€ï¼š**已获用户确认(2026-09-19)**,§6 äº”个口径全部有结论,按 Â§11 é¡ºåºå®žæ–½ã€‚
> æ—¥æœŸï¼š2026-09-19
> ç›¸å…³æ–‡æ¡£ï¼š`智能质检报告平台-方案设计.md`(21 ç« æ€»æ–¹æ¡ˆï¼‰ã€`qc_report_ai_import_frontend_integration.md`、`qc_report_instance_frontend_integration.md`、`智能质检报告平台-开发进度.md`
---
## é›¶ã€ç”¨æˆ·ç¡®è®¤ç»“论(2026-09-19,本次实施依据)
| ç¼–号 | é—®é¢˜ | ç”¨æˆ·ç»“论 | è½åœ°å½±å“ |
|---|---|---|---|
| Â§6.1 | æŠ¥å‘Šçš„合格结论由谁定 | **A** | `ReportFields` å¢ž `qcResult` / `qcResultText`,透传 MES åŽŸåˆ¤å®šï¼›å¼•æ“Žé€é¡¹åˆ¤å®šç…§å¸¸ç®—ï¼ŒæŠ¥å‘Šçº§ç»“è®ºä»¥è´¨æ£€å•ä¸ºå‡† |
| Â§6.3 | ä¸Šä¸‹é™ä¸ºç©º | **生成时弹提示** | å“åº”带回 `undecidableCount`;前端必须提示「N é¡¹å› ç¼ºå°‘规格上下限被判为待判定」 |
| Â§6.2 | å“ªäº›æŒ‡æ ‡è¿›æŠ¥å‘Š | **全部进** | ä¸æŸ¥ `judge_flag`、不给行表补列;质检单行表有什么就出什么 |
| Â§6.4 | å¤šæ ·å“ / åŒæŒ‡æ ‡å¤šæ¬¡å½•å…¥ | **会** | å–方案 **A**:一个 (样品, æŒ‡æ ‡) ç»„合 = ä¸€è¡Œæ£€éªŒé¡¹ï¼Œæ ·å“å·å†™è¿› `remark`,不丢数据 |
| Â§7.3 | æ¨¡æ¿ç±»åž‹ä¸Žè´¨æ£€å•不符 | **只能选对应类型模板** | åŽç«¯ `105_005` æ‹’绝;前端选模板时按 `reportType` è¿‡æ»¤ï¼Œè®©ç”¨æˆ·é€‰ä¸åˆ°é”™ |
| Â§6.5 | ä¸€å¼ è´¨æ£€å•能否出多份报告 | **允许** | åæŸ¥è¿”回列表,UI å±•示「查看报告 (N)」 |
> âš  Â§6.3 çš„「先补数据」一步(§11 æ­¥ 1)**用户未选**,即不强制补数据;但库里现存的检验模板上下限全为空,
> ç…§çŽ°çŠ¶ç”Ÿæˆä¼šæ•´ä»½ã€Œå¾…åˆ¤å®šã€ã€‚**落地时靠 `undecidableCount` æç¤ºå¦‚实告知用户**,不代其补数据。
---
## ä¸€ã€æœ¬æ–‡æ¡£è¦è§£å†³çš„问题
### 1.1 å·²å®šå£å¾„(用户已拍板,不再讨论)
| å†³ç­– | é€‰æ‹© |
|---|---|
| æŠ¥å‘Šæ¨¡æ¿ä¸Žè´¨æ£€å•的关联方式 | **每次生成时人工选模板**。不引入「默认模板」概念 |
| ç¬¬ä¸€æ­¥åšä»€ä¹ˆ | **先出这份方案文档待确认**,确认后再动代码 |
### 1.2 ç”¨æˆ·åŽŸè¯
> æŠ¥å‘Šæ¨¡æ¿è®¾è®¡è¿˜æ˜¯æ²¡æœ‰å’Œè´¨æ£€å…³è”吧,现在不知道怎么导出数据
**这个判断是对的。** çŽ°çŠ¶ä¸æ˜¯ã€Œå…³è”åšå¾—ä¸å¥½ã€ï¼Œè€Œæ˜¯**根本没有关联**。
---
## äºŒã€çŽ°çŠ¶æ ¸å¯¹ï¼ˆç»“è®ºï¼šæ²¡æŽ¥ä¸Šï¼Œä½†æœ‰åœ°åŸºï¼‰
### 2.1 å·²ç»æœ‰çš„
| èƒ½åŠ› | ä½ç½® | çŠ¶æ€ |
|---|---|---|
| æŠ¥å‘Šæ¨¡æ¿ CRUD + ç‰ˆæœ¬ç®¡ç† | `yudao-module-qcreport` | å¯ç”¨ |
| GrapesJS è®¾è®¡å™¨ + 12 ä¸ªè´¨æ£€ç»„ä»¶ | å‰ç«¯ `views/mes/qc/report/template/designer` | å¯ç”¨ |
| AI å¯¼å…¥ï¼ˆä¸Šä¼ æ–‡ä»¶ â†’ æ¨¡æ¿è‰ç¨¿ï¼‰ | `service/aiimport/**` | å¯ç”¨ï¼Œ54/54 è”调已过 |
| æœåŠ¡ç«¯æ¸²æŸ“å¼•æ“Ž + åˆ¤å®šå™¨ + è§„则引擎 | `engine/**` | å¯ç”¨ï¼Œä¸Žå‰ç«¯é€å­—对拍 |
| å‡ºä»¶ï¼šæ¸²æŸ“ + è½åº“ + å†»ç»“å¿«ç…§ | `POST /qc-report/instance/generate` | **可用,但数据必须由调用方传入** |
| å¯¼å‡º PDF + å½’档附件 | `POST /qc-report/instance/pdf` | å¯ç”¨ |
| å®žä¾‹åˆ—表 / è¯¦æƒ… / åˆ é™¤ | å‰ç«¯ `views/mes/qc/report/instance` | å¯ç”¨ï¼ˆ#45 è½åœ°ï¼‰ |
### 2.2 ç¼ºçš„三块
| # | ç¼ºä»€ä¹ˆ | è¯æ® |
|---|---|---|
| 1 | **后端映射**:质检单 â†’ `ReportContext` | å…¨é¡¹ç›®æœä¸åˆ° `MesQcReportApi`;`yudao-module-mes/pom.xml` ä¸ä¾èµ– qcreport;qcreport è¿ž `-api` å­æ¨¡å—都没有 |
| 2 | **后端入口**:一个「给我这张质检单的报告」的接口 | åªæœ‰ `/qc-report/instance/generate`,且它要求调用方把整个 `ReportContext` æ‹¼å¥½ä¼ è¿›æ¥ |
| 3 | **前端入口**:质检单页面上的「生成报告」按钮 | å‰ç«¯åªè°ƒäº†å®žä¾‹çš„ page / get / snapshot / pdf / delete;**`instance/generate` ä¸Ž `instance/preview` å‰ç«¯ä»Žæœªè°ƒç”¨** |
`QcReportInstanceController` çš„类注释自己写着:
> è¿™é‡Œæ˜¯ã€Œæ¨¡æ¿ â†’ æŠ¥å‘Šã€çš„对外入口……**数据由调用方传入,本模块不认识任何业务单据。**
所以你在界面上找不到出口是正常的——**现在要出一份报告,只能拿 curl æ‰‹æ‹¼ JSON**。
### 2.3 åœ°åŸºå…¶å®žç•™å¥½äº†
| å·²æœ‰çš„设计 | ä½ç½® | è¯´æ˜Ž |
|---|---|---|
| æ¨¡æ¿å£°æ˜Žè‡ªå·±æœåŠ¡å“ªç±»è´¨æ£€ | `qc_report_template.report_type` | ç”¨çš„就是 MES çš„ `MES_QC_TYPE` å­—典(IQC/IPQC/OQC/RQC),**与质检单同一套枚举** |
| å®žä¾‹å¯åæŸ¥ä¸šåŠ¡å•æ® | `qc_report_instance.business_id` / `business_type` | å­—段注释写的就是「业务单据编号,如质检单 ID」「业务单据类型,如 mes_qc_iqc」 |
| åˆ†é¡µæ”¯æŒæŒ‰å•据反查 | `QcReportInstancePageReqVO.businessType` / `businessId` | æŸ¥è¯¢æ¡ä»¶**已经做好了**,`GET /qc-report/instance/page?businessType=mes_qc_iqc&businessId=1` ç›´æŽ¥å¯ç”¨ |
**换句话说:关联键、反查条件当初都设计好了,只是没有任何代码去用它们。**
### 2.4 ä¸¤ä¸ªã€Œæ¨¡æ¿ã€ä¸è¦æ··
| | æ£€éªŒæ¨¡æ¿ | æŠ¥å‘Šæ¨¡æ¿ |
|---|---|---|
| è¡¨ | `mes_qc_template` | `qc_report_template` |
| å½’属模块 | MES | qcreport |
| ç®¡ä»€ä¹ˆ | **检什么**:检验指标清单、判定上下限、检测方法 | **长什么样**:版面、组件、绑定、判定规则、纸张 |
| è°ç»´æŠ¤ | è´¨é‡å·¥ç¨‹å¸ˆ | æŠ¥å‘Šè®¾è®¡äººå‘˜ |
| å‰ç«¯é¡µé¢ | MES è´¨æ£€ â†’ æ£€éªŒæ¨¡æ¿ | MES è´¨æ£€ â†’ æŠ¥å‘Šæ¨¡æ¿ |
本方案**不合并、不迁移**这两个概念。一个质检单引用一个检验模板(`mes_qc_iqc.template_id`),生成报告时人工再选一个报告模板——两者独立。
> **另注**:MES è´¨æ£€å•**已经有** Excel å¯¼å‡ºï¼ˆ`GET /mes/qc-iqc/export-excel` ç­‰å››ç±»ï¼Œæƒé™ `mes:qc-*-:export`),导出的是固定列的 `.xls`(单号 / ç‰©æ–™ / æ‰¹å· / æ£€æµ‹æ—¥æœŸ / æ£€éªŒæŒ‡æ ‡ / åˆ¤å®šä¾æ® / æ£€éªŒç»“æžœ / æ£€éªŒäºº / å®¡æ ¸äºº / å¤‡æ³¨ï¼Œè§ `MesQcQualityExportVO`)。那是「数据表导出」,与本方案的「按设计好的模板出报告」是两条独立的线,互不替代。
---
## ä¸‰ã€ä¸€å¼ çœŸå®žçš„质检单长什么样
> ä»¥ä¸‹å…¨éƒ¨æ¥è‡ªå½“前数据库实测(`ruoyi-vue-pro` åº“,2026-09-19),不是推测。
> åº“里目前只有 **1 å¼  IQC å•**(IPQC/OQC/RQC å‡ä¸º 0 æ¡ï¼‰ï¼Œæ‰€ä»¥ä¸‹é¢è¿™å¼ å•是唯一的样本。
### 3.1 å•头 `mes_qc_iqc`(id=1)
| å­—段 | å€¼ |
|---|---|
| code | `IQC_20260917001` |
| name | `精萘来料检验-0917` |
| template_id | 6(检验模板「正丁醇检验(GB/T 6027)」) |
| item_id | 2(物料「精萘」) |
| vendor_id / source_doc_type / source_doc_code | **全为 NULL** |
| vendor_batch | **NULL** |
| received / check / qualified / unqualified_quantity | 100 / 100 / 100 / 0 |
| **check_result** | **NULL** |
| inspect_date | 2026-09-17 |
| inspector_user_id | 1 |
| **status** | **0(草稿)** |
### 3.2 ä¸‰å±‚数据结构
```
mes_qc_iqc (1 æ¡)                    è´¨æ£€å•主体:对方、数量、结论、日期、检验人
   â””── mes_qc_indicator_result (1 æ¡)  æ ·å“è®°å½•:qc_id + qc_type å…³è”主体,带样品编号 code
          â””── mes_qc_indicator_result_detail (14 æ¡)  å®žæµ‹å€¼ï¼šindicator_id + value
   â””── mes_qc_iqc_line (16 æ¡)         åˆ¤å®šä¾æ®ï¼šindicator_id + æ ‡å‡†å€¼ + ä¸Šä¸‹é™ï¼ˆæ³¨æ„ï¼šä¸å«å®žæµ‹å€¼ï¼‰
```
### 3.3 å®žæµ‹å€¼ä»Žå“ªæ¥ï¼ˆå…³é”®ï¼‰
**实测值不在 `mes_qc_iqc_line` é‡Œ**,在 `mes_qc_indicator_result_detail.value`(字符串)。
`mes_qc_iqc_line` åªæ‰¿è½½**判定依据**:`standard_value`、`max_threshold`、`min_threshold`、`check_method`、`tool`、缺陷数量。
所以构造报告里的一行检验项,需要**把行表与结果明细按 `indicator_id` æ‹¼èµ·æ¥**。
### 3.4 å®žæµ‹æ­ç¤ºäº†å››ä¸ªã€Œä¸æŽ¥ä¸çŸ¥é“」的事实
#### äº‹å®ž 1:16 æ¡æ£€éªŒè¡Œé‡Œï¼Œåªæœ‰ 13 æ¡æœ‰å®žæµ‹å€¼
| æŒ‡æ ‡ | ç»“果值类型 | æœ‰æ— å®žæµ‹å€¼ |
|---|---|---|
| æ°´åˆ† / é…¸å«é‡ / æ­£ä¸é†‡å«é‡ | **NULL** | **无** |
| å¤–观、色度、标液浓度 c、消耗滴定液体积 V、试样质量 m Ã—2、称量瓶 m0、试样+称量瓶 m1、酸含量结果 w3、水分结果 w、正丁醇含量 /%、密度、折光率 | 1(浮点) / 3(文本) | æœ‰ |
那 3 ä¸ª `result_type=NULL`、既无上下限又无实测值的是**分组标题项**(「水分」「酸含量」「正丁醇含量」是结果项的小标题)。
#### äº‹å®ž 2:同一个指标可以有多条实测值
`酸含量结果 w3`(indicator 30)在同一份样品下有 **两条明细:0.09 ä¸Ž 0.095**。
算术自洽:16 è¡Œ âˆ’ 3 ä¸ªåˆ†ç»„项 = 13 ä¸ªæœ‰å®žæµ‹å€¼çš„æŒ‡æ ‡ï¼Œ13 + 1 æ¡é‡å¤ = 14 æ¡æ˜Žç»†ï¼Œæ­£å¥½å¯¹ä¸Š `mes_qc_indicator_result_detail` çš„ 14 æ¡ã€‚
⇒ æ˜ å°„不能假设「一个指标一个值」。
#### äº‹å®ž 3:上下限在源头就是空的,不只是没复制
| ä½ç½® | standard_value | ä¸Šä¸‹é™ |
|---|---|---|
| `mes_qc_template_indicator`(模板,16 è¡Œï¼‰ | **全 NULL** | **全 NULL** |
| `mes_qc_iqc_line`(单据,16 è¡Œï¼‰ | **全 NULL** | **全 NULL** |
所以不是「生成单据时丢了上下限」,而是**压根没录**。
**后果(最重要)**:报告判定器的单项判定优先级是「逐项规则 â†’ è§„格上下限 â†’ æ•°æ®è‡ªå¸¦ç»“果」(`ReportEvaluator.evaluateByLimit`)。上下限为空 â†’ å‰ä¸¤æ¡éƒ½è½ç©º â†’ **每一项都变成「待判定」**,进而 `passCount != total` â†’ **报告结论也是「待判定」、合格率空白**。
**即:现在把链路接通,出来的报告会是一整页「待判定」。用户会以为功能坏了。**
#### äº‹å®ž 4:文本型指标占多数
| `mes_qc_indicator.result_type` | æ•°é‡ | èƒ½å¦è¢«åŒºé—´åˆ¤å®š |
|---|---|---|
| 3 = æ–‡æœ¬ | **19** | ä¸èƒ½ |
| 1 = æµ®ç‚¹ | 12 | èƒ½ï¼ˆä½†éœ€ä¸Šä¸‹é™ï¼‰ |
| NULL(分组项) | 3 | ä¸é€‚用 |
外形如「外观 = æ¾„清透明液体,无机械杂质」是文本值,没有上下限,判定器给不出 PASS/FAIL。
#### äº‹å®ž 5:`judge_flag`(是否参与合格判定)在单据侧丢了
| è¡¨ | æ˜¯å¦æœ‰ `judge_flag` |
|---|---|
| `mes_qc_template_indicator` | **有**(`tinyint`,注释「是否参与合格判定:1=是,0=否」) |
| `mes_qc_iqc_line` | **没有这一列** |
模板 6 é‡Œ 7 é¡¹ `judge_flag=1`、9 é¡¹ `judge_flag=0`。但生成单据后这个信息**不可恢复**——单据行只剩 `indicator_id / æ ‡å‡†å€¼ / ä¸Šä¸‹é™ / ç¼ºé™·æ•°é‡ / å¤‡æ³¨`。
⇒ æŠ¥å‘Šè‹¥æƒ³ã€Œåªç»Ÿè®¡å‚与判定的项」,**不能只读单据,必须回查检验模板**;或者给单据行加一列(改表)。
#### äº‹å®ž 6:行序信息也在单据侧丢了
模板有 `sort_order`(10/20/40/50/60/70 è¿™æ ·çš„分组排序),单据行**没有**。所以报告里检验项的顺序只能按 `line.id`,与检验模板上的排列顺序可能不一致。
---
## å››ã€ç›®æ ‡æµç¨‹
```
质检单列表 / è¯¦æƒ…
   â”‚
   â”œâ”€ ç‚¹ã€Œç”ŸæˆæŠ¥å‘Šã€
   â”‚      â”‚
   â”‚      â”œâ”€ 1. é€‰æŠ¥å‘Šæ¨¡æ¿ï¼ˆäººå·¥é€‰ï¼Œå·²å®šå£å¾„)
   â”‚      â”‚       â””─ åˆ—表按 qc_report_template.report_type = è¯¥è´¨æ£€ç±»åž‹ è¿‡æ»¤
   â”‚      â”‚
   â”‚      â”œâ”€ 2. åŽç«¯ï¼šæ‹‰è´¨æ£€å•主体 + è¡Œæ˜Žç»† + æ ·å“å®žæµ‹å€¼  â†’  æ‹¼æˆ ReportContext
   â”‚      â”‚
   â”‚      â”œâ”€ 3. åŽç«¯ï¼šè°ƒæ—¢æœ‰ instanceService.generate(渲染 + è½åº“ + å†»ç»“快照)
   â”‚      â”‚       â””─ businessId = è´¨æ£€å• ID,businessType = mes_qc_iqc / ipqc / oqc / rqc
   â”‚      â”‚
   â”‚      â””─ 4. è¿”回报告实例 ID ä¸ŽæŠ¥å‘Šç¼–号
   â”‚
   â”œâ”€ æŠ¥å‘Šå·²ç”Ÿæˆ â†’ åˆ—表/详情可直接跳转报告实例(按 businessType + businessId åæŸ¥ï¼ŒæŸ¥è¯¢æ¡ä»¶å·²å°±ç»ªï¼‰
   â”‚
   â””─ æŠ¥å‘Šå®žä¾‹é¡µ â†’ ã€Œå¯¼å‡º PDF」→ å½’档附件库(既有能力,不动)
```
**唯一落库点是既有的模板版本 + å®žä¾‹**,本方案不新增任何保存路径。
---
## äº”、字段映射(核心)
### 5.1 æŠ¥å‘Šçº§å­—段:`ReportContext.report`
| `ReportContext` å­—段 | å››ç±»å•据来源 | å¤‡æ³¨ |
|---|---|---|
| `reportNo` | **不取质检单 code**,由 `ReportNoGenerator` æŒ‰ `QR+日期+流水` ç”Ÿæˆ | ç”¨æˆ·å¯åœ¨ç”Ÿæˆæ—¶è¦†ç›– |
| `reportName` | `name`(检验单名称) | å››ç±»éƒ½æœ‰ |
| `sampleNo` | `mes_qc_indicator_result.code`(样品编号,实测 `IR_20260917001`) | å¤šæ ·å“è§ Â§6.4 |
| `productCode` | `mes_md_item.code`(经 `item_id`) | å®žæµ‹å€¼ `2` |
| `productName` | `mes_md_item.name` | å®žæµ‹å€¼ `精萘` |
| `spec` | `mes_md_item.specification` | **实测为 NULL**,报告上会是空 |
| `batchNo` | IQC â†’ `vendor_batch`;OQC / RQC â†’ `batch_code`;**IPQC â†’ æ— æ­¤å­—段** | å®žæµ‹ IQC çš„ `vendor_batch` ä¸º NULL |
| `workOrderNo` | IPQC â†’ `work_order_id` æŸ¥å·¥å•编号;其余三类无 | å»ºè®®å›žé€€ç”¨ `source_doc_code` |
| `inspectType` | ç”±è´¨æ£€ç±»åž‹æ˜ å°„:IQC→来料检验 / IPQC→过程检验 / OQC→出货检验 / RQC→退货检验 | å– `MesQcTypeEnum` çš„ name |
| `inspector` | `inspector_user_id` â†’ `system_users.nickname` | å››ç±»éƒ½æœ‰ |
| `inspectDate` | `inspect_date` | å››ç±»éƒ½æœ‰ |
| `department` | **四类单据都没有部门字段** | è§ Â§6.1 å†³ç­– |
| `customerName` | OQC â†’ `client_id` æŸ¥ CRM å®¢æˆ·åï¼›å…¶ä½™ä¸‰ç±»æ—  | |
| `supplierName` | IQC â†’ `vendor_id` æŸ¥ SRM ä¾›åº”商名;其余三类无 | å®žæµ‹ä¸º NULL |
| `result` / `resultText` / `conclusion` / `passRate` / `total` / `passCount` / `failCount` | **由 `ReportEvaluator` ç®—出并覆盖**,调用方不必填 | è§ Â§6.1 |
### 5.2 æ£€éªŒé¡¹ï¼š`ReportContext.inspectionItems[]`
| `InspectionItem` å­—段 | æ¥æº | å¤‡æ³¨ |
|---|---|---|
| `index` | ç”Ÿæˆæ—¶æŒ‰é¡ºåº 1..N | |
| `itemCode` | `mes_qc_indicator.code` | |
| `itemName` | `mes_qc_indicator.name` | |
| `standardValue` | `mes_qc_xxx_line.standard_value` | BigDecimal â†’ åŽ»å°¾é›¶å­—ç¬¦ä¸²ï¼ˆ**实测全 NULL**) |
| `actualValue` | `mes_qc_indicator_result_detail.value`(String) | ç» `result_id` â†’ `result.qc_id` å…³è” |
| `unit` | `mes_qc_xxx_line.unit_measure_id` â†’ `mes_md_unit_measure.name` | **注意**:模板侧也有 `unit_measure_id`;实测两者都是 NULL |
| `upperLimit` | `mes_qc_xxx_line.max_threshold` | Double(**实测全 NULL**) |
| `lowerLimit` | `mes_qc_xxx_line.min_threshold` | Double(**实测全 NULL**) |
| `result` / `resultText` | ç”± `ReportEvaluator` å†™å›ž | ä¸å– MES çš„ `check_result` |
| `remark` | `line.remark` æˆ– `detail.remark`(**两处都有**) | éœ€å®šå£å¾„,见 Â§6.5 |
> âš  **列名不一致的坑**:单据行表用 `max_threshold` / `min_threshold`,检验模板表用 `threshold_max` / `threshold_min`。写映射时别弄混。
### 5.3 å››ç±»å•据的差异
| | IQC æ¥æ–™æ£€éªŒ | IPQC è¿‡ç¨‹æ£€éªŒ | OQC å‡ºè´§æ£€éªŒ | RQC é€€è´§æ£€éªŒ |
|---|---|---|---|---|
| ä¸»ä½“表 | `mes_qc_iqc` | `mes_qc_ipqc` | `mes_qc_oqc` | `mes_qc_rqc` |
| è¡Œè¡¨ | `mes_qc_iqc_line` | `mes_qc_ipqc_line` | `mes_qc_oqc_line` | `mes_qc_rqc_line` |
| **行表列结构** | **四表完全同构**(13 åˆ—一致,仅父外键列名不同:`iqc_id` / `ipqc_id` / `oqc_id` / `rqc_id`) | | | |
| å¯¹æ–¹ | `vendor_id`(SRM ä¾›åº”商) | æ—  | `client_id`(CRM å®¢æˆ·ï¼‰ | æ—  |
| æ‰¹æ¬¡ | `vendor_batch` | **无** | `batch_code` | `batch_code` |
| ç”Ÿäº§å…³è” | æ—  | `work_order_id` / `task_id` / `workstation_id` / `process_id` | æ—  | æ—  |
| æ—¥æœŸ | `receive_date` + `inspect_date` | `inspect_date` | `out_date` + `inspect_date` | `inspect_date` |
| æ•°é‡ | æŽ¥æ”¶ / æ£€æµ‹ / åˆæ ¼ / ä¸åˆæ ¼ | æ£€æµ‹ / åˆæ ¼ / ä¸åˆæ ¼ + å·¥åºŸ / æ–™åºŸ / å…¶ä»–废 | å‡ºè´§ / æ£€æµ‹ / åˆæ ¼ / ä¸åˆæ ¼ + æœ€ä½Žæ£€æµ‹æ•° / æœ€å¤§ä¸åˆæ ¼æ•° | æ£€æµ‹ / åˆæ ¼ / ä¸åˆæ ¼ |
| è‡ªæœ‰åˆ†ç±» | æ—  | `type`(`MES_IPQC_TYPE`) | æ—  | `type`(`mes_rqc_type`) |
| ç»“论 | `check_result`(合格 / ç‰¹é‡‡ / ä¸åˆæ ¼é€€è´§ / ä¸åˆæ ¼æŠ¥åºŸï¼‰ | åŒ | åŒ | åŒ |
**收获**:行表四表同构,意味着**映射逻辑可以只写一份**,用泛型或统一接口承接,不必写四套。
---
## å…­ã€å¿…须先拍板的口径(按重要性排序)
### 6.1 ã€æœ€é‡è¦ã€‘报告的合格结论由谁定?
**冲突点**:
| | MES è´¨æ£€å• | æŠ¥å‘Šå¼•擎 |
|---|---|---|
| ç»“论取值 | `check_result`:合格(1) / **特采(2)** / ä¸åˆæ ¼é€€è´§(3) / ä¸åˆæ ¼æŠ¥åºŸ(4) | `QualityResult`:**只有 PASS / FAIL**,外加「待判定」占位 |
| è°å¡« | **人填的** â€”— äººå¯èƒ½åˆ¤ã€Œç‰¹é‡‡ã€ï¼ˆå¯å…¥åº“但非合格) | **算出来的** â€”— ä¸Šä¸‹é™æˆ–规则 |
| ä¼šä¸ä¼šè¢«è¦†ç›– | â€” | `ReportEvaluator` æ— æ¡ä»¶æ‰§è¡Œ `ReportFields.applyResult(verdict)`,**覆盖**调用方传入的 result / resultText / conclusion |
**必然出现的矛盾**:质检单判「特采」,报告引擎只认 PASS/FAIL,多半算出「不合格」。报告与质检单结论打架,客户拿到报告会质疑。
**三个选择**:
| æ–¹æ¡ˆ | åšæ³• | ä»£ä»· |
|---|---|---|
| **A(推荐)** | æŠ¥å‘Šç»“论以**引擎**为准;MES åŽŸåˆ¤å®šå¦å­˜åˆ°ä¸€ä¸ª**新增的报告级字段**(如 `report.qcResult` / `report.qcResultText`),模板里可用 `{{report.qcResult}}` æ˜¾ç¤ºã€Œè´¨æ£€å•原判:特采」 | **动数据契约**:要改 `ReportFields` + `ReportContext.reportMap()` + å‰ç«¯ `engine/context.ts`,且 `FrontendConformanceTest` ä¼šé€å­—对拍,需同步冻结产物 |
| B | åªå‘ˆçŽ°å¼•æ“Žåˆ¤å®šï¼Œ**完全不体现** MES çš„ `check_result` | é›¶æ”¹åŠ¨ï¼Œä½†æŠ¥å‘Šå¯èƒ½ä¸Žè´¨æ£€å•ç»“è®ºä¸ä¸€è‡´ï¼ˆç‰¹é‡‡è¢«åˆ¤æˆä¸åˆæ ¼ï¼‰ï¼Œä¸”ç”¨æˆ·æ— ä»Žå¯Ÿè§‰ |
| C | **以质检单为准**,让引擎别判 | å¼•擎没有「跳过判定」开关。要么不传上下限(则单项全变待判定)、要么每个报告模板都配 report çº§è§„则——**依赖人工配模板,容易漏**,不能只靠代码保证 |
> æˆ‘的建议是 **A**。理由是「特采」这类中间态是质量业务的真实状态,报告上直接抹掉会造成报告与单据对不上;而 B çš„不一致是**静默**的,用户不会发现,问题会流到客户那边。A çš„代价是可控的——只是加两个字段,不是改判定逻辑。
>
> ä½†å¦‚果希望**这一轮零契约改动、先跑通链路**,可以先按 **B** ä¸Šçº¿å¹¶æ˜Žç¡®å‘ŠçŸ¥ã€ŒæŠ¥å‘Šç»“论以报告模板的判定规则为准,与质检单的人工判定可能不同」,把 A ç•™ä½œä¸‹ä¸€è½®ã€‚
### 6.2 ã€å†³å®šæŠ¥å‘Šé•¿ä»€ä¹ˆæ ·ã€‘哪些指标该出现在报告上?
实测那张单 16 è¡Œé‡Œï¼Œ**9 è¡Œæ˜¯ `judge_flag=0`**,其中包含:
- 3 ä¸ªåˆ†ç»„标题项(水分 / é…¸å«é‡ / æ­£ä¸é†‡å«é‡ï¼Œæ— å®žæµ‹å€¼ï¼‰
- ä¸­é—´è®¡ç®—量:标液浓度 c、消耗滴定液体积 V、试样质量 m Ã—2、称量瓶 m0、试样+称量瓶 m1
**「消耗滴定液体积」和「称量瓶质量」是化验室内部的计算过程,不该出现在给客户看的成品检验报告上。**
三个选择:
| æ–¹æ¡ˆ | åšæ³• |
|---|---|
| **A(推荐)** | **只取 `judge_flag=1` çš„项**(该模板下 7 é¡¹ï¼‰ä½œä¸ºæŠ¥å‘Šçš„「检验项目表」;`judge_flag=0` çš„项不进报告 |
| B | å…¨éƒ¨è¿›æŠ¥å‘Šï¼Œåˆ†ç»„项当小标题、中间量当明细行 |
| C | å…¨éƒ¨è¿›æŠ¥å‘Šï¼Œä½†æŒ‰ `judge_flag` åˆ†æˆã€Œåˆ¤å®šé¡¹ã€å’Œã€Œå‚考项」两个区块 |
**代价(A çš„问题)**:`judge_flag` **在单据行上不存在**(事实 5),必须**回查 `mes_qc_template_indicator`**(用 `mes_qc_iqc.template_id`)。这有两个隐患:
1. æ£€éªŒæ¨¡æ¿å¯èƒ½è¢«æ”¹è¿‡ï¼ˆæ”¹äº† `judge_flag`),报告会跟着变——而报告本应基于**质检单当时**的口径。好在质检单有冻结快照机制(`dataSnapshot`),落库后不受影响,但**生成的那一刻**取的是模板当前值。
2. è‹¥è¯¥è´¨æ£€å•的检验模板被删除,回查拿不到 â†’ éœ€è¦å…œåº•(退化到方案 B æˆ–报错)。
替代做法:**给四张行表各加一列 `judge_flag`**,生成单据时从模板带过来。这样「单据自包含」,不再依赖回查。需要改 4 å¼ è¡¨ + ç”Ÿæˆå•据的代码(属于 MES ä¾§æ”¹åŠ¨ï¼‰ã€‚
> **需要你定**:(A) å›žæŸ¥æ£€éªŒæ¨¡æ¿ / (A′) ç»™è¡Œè¡¨åŠ åˆ—è®©å•æ®è‡ªåŒ…å« / (B) å…¨éƒ¨è¿›æŠ¥å‘Šã€‚
### 6.3 ã€å‰ç½®æ•°æ®é—®é¢˜ã€‘上下限为空 â†’ æŠ¥å‘Šä¼šå…¨ã€Œå¾…判定」
**这与代码无关,是数据问题,但会直接决定用户看到的效果。**
实测:检验模板与质检单行的 `standard_value` / ä¸Šä¸‹é™**全部为 NULL**。报告判定器的优先级是「逐项规则 â†’ è§„格上下限 â†’ æ•°æ®è‡ªå¸¦ç»“果」,前两条都落空 â†’ **每一项都是「待判定」**,报告结论为「待判定」、合格率空白。
三个选择:
| æ–¹æ¡ˆ | åšæ³• |
|---|---|
| **A(推荐)** | **先补数据再上功能**:把这套检验模板的上下限录进去(至少给 7 ä¸ª `judge_flag=1` çš„项录)。功能照做,但**验收时必须用录了上下限的模板**,否则看到的全是「待判定」,会误判为功能坏 |
| B | åŠŸèƒ½å…ˆä¸Šï¼Œç•Œé¢æ˜Žç¡®æç¤ºã€Œæœ‰ N é¡¹å› ç¼ºå°‘规格上下限而无法判定」,引导用户去补 |
| C | é æŠ¥å‘Šæ¨¡æ¿çš„判定规则兜底(规则可以从指标编码 / æ–‡æœ¬å€¼åˆ¤æ–­ï¼‰ |
> **建议 A + B åŒæ—¶åš**:代码照做,但当「无任何一项可判定」时,生成结果里回一条明确提示——「本次报告有 N é¡¹å› ç¼ºå°‘规格上下限被判为『待判定』,请到检验模板中补充上下限」,并透传到前端。这样既不假装成功,也不静默。
### 6.4 ä¸€ä¸ªè´¨æ£€å•多份样品 / åŒä¸€æŒ‡æ ‡å¤šæ¡å®žæµ‹å€¼
**实测到两种复杂度**:
1. åŒä¸€æŒ‡æ ‡å¤šæ¡å®žæµ‹å€¼ï¼š`酸含量结果 w3` åœ¨åŒä¸€ä»½æ ·å“ä¸‹æœ‰ 0.09 ä¸Ž 0.095 ä¸¤æ¡ã€‚
2. å¤šæ ·å“ï¼š`mes_qc_indicator_result` ç»“构上支持一个质检单挂 N æ¡æ ·å“è®°å½•(`qc_id` + `qc_type` å…³è”),但**实测库里只有 1 æ¡**,无法证实实际业务会不会出现。
`InspectionItem` æ˜¯**扁平一层**,无法表达「样品」这一层。
| æ–¹æ¡ˆ | åšæ³• |
|---|---|
| **A(推荐)** | ä¸€ä¸ª (样品, æŒ‡æ ‡) ç»„合 = ä¸€è¡Œæ£€éªŒé¡¹ï¼›`sampleNo` æ‹¼è¿› `itemCode` æˆ– `remark` ä»¥åŒºåˆ†ã€‚多样品时报告变长但信息不丢 |
| B | åªå–**第一份样品**,其余忽略并在报告 warnings é‡Œæç¤º |
| C | åŒä¸€æŒ‡æ ‡å¤šæ¡å®žæµ‹å€¼å–**最后一条**(视为复测覆盖) |
> **需要你确认业务事实**:**IQC / IPQC / OQC / RQC å®žé™…会不会录多份样品?同一指标会不会录多次?** è¿™å†³å®šäº†é€‰ A è¿˜æ˜¯ B/C。目前库里只有 1 ä»½æ ·å“ï¼Œæˆ‘给不出结论。
### 6.5 å…¶ä½™éœ€è¦å®šçš„小口径
| é—®é¢˜ | è¯´æ˜Ž | å»ºè®® |
|---|---|---|
| `remark` å–哪个 | è¡Œè¡¨ä¸Žç»“果明细**都有** `remark` | æ‹¼æŽ¥ï¼š`line.remark` ä½œåˆ¤å®šä¾æ®å¤‡æ³¨ï¼Œ`detail.remark` ä½œå®žæµ‹å¤‡æ³¨ |
| æ–‡æœ¬åž‹æŒ‡æ ‡æ€Žä¹ˆåŠž | 19/34 æ˜¯æ–‡æœ¬åž‹ï¼Œæ— ä¸Šä¸‹é™ â†’ å¿…然「待判定」 | æŠ¥å‘Šä¸Šå¦‚常展示文本值,结论标注而不是报错;需要判定就配模板规则 |
| æŠ¥å‘Šé‡Œçš„æ£€éªŒé¡¹é¡ºåº | å•据行无 `sort_order`(事实 6) | æŒ‰ `line.id`;若要按模板顺序需回查 `mes_qc_template_indicator.sort_order` |
| è´¨æ£€å•什么时候允许出报告 | åº“里唯一那张单 `status=0`(草稿)且 `check_result=NULL` | **建议:`status != å·²å®Œæˆ` æˆ– `check_result` ä¸ºç©ºæ—¶æ‹’绝**,报精确错误码(见 Â§7.3),不生成半成品报告 |
| `department` ä»Žå“ªæ¥ | å››ç±»å•据都没有部门字段 | ç”±æ£€éªŒäººæ‰€å±žéƒ¨é—¨å¸¦å‡ºï¼›æˆ–留空由模板上写死 |
| ä¸€å¼ è´¨æ£€å•能否出多份报告 | ç»“构上允许(实例表无唯一约束) | **建议允许**:同一张单用不同模板出不同用途的报告(内部版 / å®¢æˆ·ç‰ˆï¼‰ã€‚反查列表自然支持 |
---
## ä¸ƒã€æŽ¥å£å¥‘约
### 7.1 æ–°å¢žæŽ¥å£
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž | æƒé™ç  |
|---|---|---|---|
| POST | `/qc-report/instance/generate-from-qc` | ç”±è´¨æ£€å•生成报告 | `qc-report:instance:generate`(新增) |
| GET | `/qc-report/instance/page-by-business` | æŒ‰è´¨æ£€å•反查已生成的报告 | `qc-report:instance:query`(复用) |
> åæŸ¥å…¶å®žå¯ä»¥ç›´æŽ¥å¤ç”¨æ—¢æœ‰çš„ `GET /qc-report/instance/page?businessType=...&businessId=...`(查询条件已存在),
> ä¸Šè¡¨çš„ `page-by-business` åªæ˜¯ä¸ºäº†è®©å‰ç«¯å°‘传两个参数,**可选,不必要**。
### 7.2 è¯·æ±‚ / å“åº”
**请求** `POST /qc-report/instance/generate-from-qc`
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|---|---|---|---|
| `qcType` | String | æ˜¯ | `IQC` / `IPQC` / `OQC` / `RQC`,决定读哪张表 |
| `qcId` | Long | æ˜¯ | è´¨æ£€å• ID |
| `templateId` | Long | æ˜¯ | **报告模板 ID,人工选**(已定口径) |
| `version` | String | å¦ | æŠ¥å‘Šæ¨¡æ¿ç‰ˆæœ¬ï¼›ä¸ä¼ ç”¨å½“前已发布版本 |
| `reportNo` | String | å¦ | æŠ¥å‘Šç¼–号;不传自动生成 |
| `sampleStrategy` | String | å¦ | å¤šæ ·å“å£å¾„,见 Â§6.4。默认 `FIRST` |
**响应**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|---|---|---|
| `id` | Long | æŠ¥å‘Šå®žä¾‹ ID |
| `reportNo` | String | æŠ¥å‘Šç¼–号 |
| `templateVersion` | String | å®žé™…使用的模板版本 |
| `itemCount` | Integer | æœ¬æ¬¡å†™å…¥çš„æ£€éªŒé¡¹æ¡æ•° |
| `undecidableCount` | Integer | **因缺少上下限 / è§„则而「待判定」的项数**(§6.3-B çš„落地) |
| `warnings` | String[] | è½¯æç¤ºï¼šè·³è¿‡çš„分组项、被截断的多余实测值、样品的取舍 |
| `errors` | String[] | æ¸²æŸ“期数据缺口(既有机制) |
### 7.3 é”™è¯¯ç ï¼ˆæ‹Ÿï¼Œ`1_070_105_xxx` æ®µï¼‰
> âš  **本节是方案阶段拟稿,实际实现已偏离——以 `docs/qc_report_generate_from_qc_frontend_integration.md` ä¸Žä»£ç ä¸ºå‡†**:
> `105_004`(模板不存在/已停用)与 `105_007`(模板无已发布版本)**未实现**,改为复用既有错误码
> `1_070_100_000`/`1_070_100_002`/`1_070_101_007`/`1_070_101_008`。理由:那几条文案本来就精确,
> å†é€ ä¸€éåªä¼šå¤šä¸¤æ¡ä¼šæ¼‚移的副本。实际落地的是下面标 âœ… çš„六条。
| ç  | åœºæ™¯ | æ–‡æ¡ˆè¦ç‚¹ï¼ˆéµå®ˆ `error-message-precision.md`) | è½åœ° |
|---|---|---|---|
| `105_000` | è´¨æ£€ç±»åž‹ä¸åˆæ³• | æŒ‡æ˜Žæ”¶åˆ°çš„值与允许的四值 | âœ… å·²å®žçް |
| `105_001` | è´¨æ£€å•不存在 | æŒ‡æ˜Žç±»åž‹ + ID | âœ… å·²å®žçް |
| `105_002` | è´¨æ£€å•未完成,不能出报告 | ç»™å‡ºå½“前状态名 + å»ºè®®åŠ¨ä½œï¼ˆå…ˆæŠŠå•æ®èµ°å®Œï¼‰ | âœ… å·²å®žçް |
| `105_003` | è´¨æ£€å•未判定(`check_result` ä¸ºç©ºï¼‰ | ç»™å‡ºå½“前值 + å»ºè®®åŠ¨ä½œ | âœ… å·²å®žçް |
| ~~`105_004`~~ | ~~报告模板不存在 / å·²åœç”¨~~ | ~~指明模板 ID ä¸ŽçŠ¶æ€~~ | âŒ **未实现**,复用 `1_070_100_000` / `1_070_100_002` |
| `105_005` | æŠ¥å‘Šæ¨¡æ¿çš„æŠ¥å‘Šç±»åž‹ä¸Žè´¨æ£€å•不符 | **给出双方值**(模板是 IPQC,质检单是 IQC) | âœ… å·²å®žçŽ°ï¼ˆæ¨¡æ¿æœªé…ç±»åž‹æ—¶å¤ç”¨åŒç ã€æ¢æ–‡æ¡ˆï¼‰ |
| `105_006` | è´¨æ£€å•没有可用的检验项 | è¯´æ˜ŽåŽŸå› ï¼ˆæ— è¡Œæ˜Žç»† / å…¨éƒ¨ä¸ºåˆ†ç»„项) | âœ… å·²å®žçް |
| ~~`105_007`~~ | ~~报告模板无已发布版本~~ | ~~建议先去设计器发布~~ | âŒ **未实现**,复用 `1_070_101_007` / `1_070_101_008` |
> **`105_005` è¯´æ˜Ž**:模板的 `report_type` ä¸Žè´¨æ£€å•类型不符时**拒绝**,还是**允许但警告**?
> å·²æŒ‰**拒绝**落地(防止 IQC å•出成出货报告这种低级错误),并在前端选模板时**只列同类型的模板**,
> è®©ç”¨æˆ·æ ¹æœ¬é€‰ä¸åˆ°é”™çš„。
### 7.4 è½åº“约定
| å®žä¾‹å­—段 | å€¼ |
|---|---|
| `businessId` | `String.valueOf(qcId)` |
| `businessType` | `mes_qc_iqc` / `mes_qc_ipqc` / `mes_qc_oqc` / `mes_qc_rqc` |
| `dataSnapshot` | æ—¢æœ‰æœºåˆ¶è‡ªåŠ¨å†»ç»“ï¼Œ**不动** |
> âš  **`businessType` å­—符串必须与 `QcReportInstanceGenerateReqVO` é‡Œå·²æœ‰çš„示例值完全一致**(`mes_qc_iqc` ç­‰ï¼‰ï¼Œ
> å¦åˆ™åæŸ¥ä¼šæŸ¥ä¸åˆ°ã€‚这四个字符串是事实上的契约。
---
## å…«ã€å‰ç«¯è”调方案
### 8.1 æ¶‰åŠé¡µé¢
| é¡µé¢ | æ”¹åЍ |
|---|---|
| MES è´¨æ£€ â†’ æ¥æ–™æ£€éªŒï¼ˆIQC)列表 / è¯¦æƒ… | åŠ ã€Œç”ŸæˆæŠ¥å‘Šã€æŒ‰é’® + é€‰æ¨¡æ¿å¼¹çª— + ã€ŒæŸ¥çœ‹æŠ¥å‘Šã€å…¥å£ |
| MES è´¨æ£€ â†’ è¿‡ç¨‹æ£€éªŒï¼ˆIPQC) | åŒä¸Š |
| MES è´¨æ£€ â†’ å‡ºè´§æ£€éªŒï¼ˆOQC) | åŒä¸Š |
| MES è´¨æ£€ â†’ é€€è´§æ£€éªŒï¼ˆRQC) | åŒä¸Š |
| MES è´¨æ£€ â†’ æŠ¥å‘Šå®žä¾‹ï¼ˆæ—¢æœ‰ï¼‰ | æ— éœ€æ”¹åŠ¨ï¼Œä½†ä¼šå¼€å§‹å‡ºçŽ°çœŸå®žæ•°æ® |
### 8.2 æŒ‰é’®ä¸Žæƒé™
| æŒ‰é’® | æƒé™ç  | ä½ç½® |
|---|---|---|
| ç”ŸæˆæŠ¥å‘Š | `qc-report:instance:generate` | è´¨æ£€å•列表操作列、详情页 |
| æŸ¥çœ‹æŠ¥å‘Š | `qc-report:instance:query`(复用) | åŒä¸€ä½ç½®ï¼Œä»…在已有报告时显示 |
### 8.3 é€‰æ¨¡æ¿å¼¹çª—
| é¡¹ | è¯´æ˜Ž |
|---|---|
| æ¨¡æ¿åˆ—表数据源 | `GET /qc-report/template/page`,前端**必须带 `reportType` = å½“前质检类型** çš„过滤条件 |
| å±•示字段 | æ¨¡æ¿åç§°ã€ç¼–码、当前版本、状态 |
| è¿‡æ»¤ | åªåˆ—**启用**且**有已发布版本**的模板 |
| é»˜è®¤ä¸é€‰ä¸­ | å·²å®šå£å¾„是「人工选」,不要预选,避免误点 |
| æ— å¯ç”¨æ¨¡æ¿æ—¶ | æ˜Žç¡®æç¤ºã€Œè¯¥æŠ¥å‘Šç±»åž‹ä¸‹è¿˜æ²¡æœ‰å¯ç”¨æ¨¡æ¿ï¼Œè¯·å…ˆåˆ°æŠ¥å‘Šæ¨¡æ¿ä¸­è®¾è®¡å¹¶å‘布」,并给出跳转入口 |
### 8.4 ç”ŸæˆåŽçš„表现
| åœºæ™¯ | è¡¨çް |
|---|---|
| ç”ŸæˆæˆåŠŸ | æç¤ºæŠ¥å‘Šç¼–号;把「生成报告」按钮换成「查看报告」;若 `warnings` éžç©ºï¼Œé€æ¡æç¤º |
| `undecidableCount > 0` | **必须提示**:「本次报告有 N é¡¹å› ç¼ºå°‘规格上下限被判为『待判定』,请到检验模板中补充后重新生成」 |
| ç”Ÿæˆå¤±è´¥ | æŒ‰ Â§7.3 çš„错误码原样展示后端文案,前端不要再包一层(既有口径) |
| è´¨æ£€å•未完成 | æŒ‰é’®ç½®ç°å¹¶ç»™å‡ºåŽŸå› ï¼Œä¸è¦è®©ç”¨æˆ·ç‚¹äº†æ‰æŠ¥é”™ |
### 8.5 è°ƒç”¨æ—¶åºï¼ˆå…³é”®ï¼šä¿¡æ¯å¸¦å…¥ï¼‰
```
打开质检单列表
   â†’ GET /mes/qc-iqc/page                        ï¼ˆæ—¢æœ‰ï¼‰
   â†’ å¯¹å·²å®Œæˆçš„单:GET /qc-report/instance/page?businessType=mes_qc_iqc&businessId={id}
       â””─ æœ‰æ•°æ® â†’ æ˜¾ç¤ºã€ŒæŸ¥çœ‹æŠ¥å‘Šã€ï¼›æ— æ•°æ® â†’ æ˜¾ç¤ºã€Œç”ŸæˆæŠ¥å‘Šã€
点「生成报告」
   â†’ GET /qc-report/template/page?reportType=IQC&status=0   ï¼ˆåªåˆ—可用模板)
   â†’ ç”¨æˆ·é€‰æ¨¡æ¿
   â†’ POST /qc-report/instance/generate-from-qc  { qcType, qcId, templateId }
   â†’ æ‹¿åˆ° reportId / reportNo / undecidableCount / warnings
点「查看报告」
   â†’ è·³è½¬æ—¢æœ‰æŠ¥å‘Šå®žä¾‹è¯¦æƒ…页(用 reportId)
```
**不带入的东西**:报告模板**不记忆**质检单的任何信息,`templateId` æ¯æ¬¡ç”±ç”¨æˆ·é€‰ï¼ˆå·²å®šå£å¾„);质检单也**不存**报告 ID,反查靠 `businessType + businessId` æŸ¥è¯¢ã€‚
### 8.6 æ³¨æ„äº‹é¡¹
- **反查是查询而不是字段**:不要在质检单表上加「报告 ID」列(违反 `file-upload.md` åŒç±»ç²¾ç¥žâ€”—业务表不存派生引用),用实例表的 `businessType/businessId` åæŸ¥ã€‚
- **一个质检单可能有多份报告**:反查返回列表,UI è¦èƒ½å±•示多份(如「查看报告 (2)」)。
- **报告模板的类型过滤必须由前端传**:后端也会校验(§7.3 `105_005`),但前端过滤能让用户根本选不到错的。
- **建议同时校验质检单状态**:`status != å·²å®Œæˆ` æ—¶æŒ‰é’®ç½®ç°ï¼Œåˆ«è®©ç”¨æˆ·ç™½ç‚¹ã€‚
- **`mes:qc-*-:query` ä¸Ž `qc-report:instance:generate` æ˜¯ä¸¤ä¸ªç‹¬ç«‹æƒé™**:一个控制能不能看质检单,一个控制能不能出报告。给车间班组长只开前者即可。
---
## ä¹ã€æ˜Žç¡®ä¸åšï¼ˆé˜²èŒƒå›´è”“延)
1. **不合并「检验模板」与「报告模板」**——两者职责不同(检什么 vs é•¿ä»€ä¹ˆæ ·ï¼‰ã€‚
2. **不做「默认模板」**——已定口径是每次人工选。
3. **不自动生成报告**——质检单完成时自动出报告,需要先定「用哪个模板」,与已定口径冲突。
4. **不做批量生成**(一次给一批质检单出报告)。
5. **不改报告实例的导出 / å½’档链路**——PDF å‡ºä»¶å·²éªŒæ”¶ï¼ŒåŽŸæ ·å¤ç”¨ã€‚
6. **不做「报告回写质检单」**——报告结论不改写 `mes_qc_*.check_result`。
7. **不在质检单表加报告引用列**(见 Â§8.6)。
8. **不改 `HtmlRenderer` / `BASE_CSS`**——会破坏 `FrontendConformanceTest` ä¸Žå­˜é‡æŠ¥å‘Šçš„可复现性(既有红线)。
9. **不动 ERP / CRM / MES ä¸‰å¤„ `tempFile.delete()` ç¼ºé™·**(三处同构,应单独开修复项)。
10. **不顺手补 ERP/CRM çš„同类对接**。
11. **不做二维码生成**、`GENERATING`/`FAILED` å®žä¾‹çŠ¶æ€æµè½¬ã€`qc_report_data_source` / `qc_report_rule` ä¸¤å¼ æ—  Java å¯¹åº”的表——维持现状。
---
## åã€é£Žé™©ä¸Žå‰ç½®
| é£Žé™© | è¯´æ˜Ž | å¤„ç½® |
|---|---|---|
| **上下限为空,报告全「待判定」**(最高优先级) | Â§6.3 å·²å®žæµ‹ã€‚链路通了但效果像坏了 | ä¸Šçº¿å‰å…ˆè¡¥ä¸€å¥—检验模板的上下限;同时落地 `undecidableCount` æç¤º |
| **报告结论与质检单结论不一致** | Â§6.1。「特采」无法在报告上表达 | æŒ‰ Â§6.1 é€‰ A(加字段)或明确按 B(书面告知口径) |
| **`judge_flag` å•据侧丢失** | Â§6.2 äº‹å®ž 5。报告要「只统计判定项」就得回查模板 | é€‰ Â§6.2 çš„ A(回查)或 A′(给 4 å¼ è¡Œè¡¨åŠ åˆ—ï¼‰ |
| **多样品口径未定** | Â§6.4。库里只有 1 ä»½æ ·å“ï¼Œæ— æ³•证实业务实况 | **需你确认业务事实**再定 |
| **一个指标多条实测值** | å·²å®žæµ‹åˆ°ï¼ˆ`酸含量结果 w3` ä¸¤æ¡ï¼‰ | å¿…须在映射里处理,不能假设一对一 |
| **IPQC æ²¡æœ‰æ‰¹æ¬¡å·** | Â§5.3。报告上 `batchNo` ä¼šæ˜¯ç©º | æ¨¡æ¿è®¾è®¡æ—¶æ³¨æ„ï¼›æˆ–回退取工单号 |
| **行序与检验模板不一致** | Â§5.3 äº‹å®ž 6。单据无 `sort_order` | æŒ‰ `line.id` æŽ’;或回查模板排序 |
| **四类单据字段差异** | å¯¹æ–¹å­—段、批次字段、生产关联各不相同 | è¡Œè¡¨åŒæž„(利好),单头差异用一个转换接口承接 |
| **跨模块依赖方向** | MES è¦è°ƒ qcreport,需要 `yudao-module-qcreport-api` å­æ¨¡å— + MES åŠ ä¾èµ– | ç…§ ERP/CRM æ—¢æœ‰ `-api` å­æ¨¡å—先例;**注意不要造成循环依赖** |
| **`FrontendConformanceTest` é€å­—对拍** | è‹¥ Â§6.1 é€‰ A(加字段),要同步前端冻结产物 | æå‰è¯„估,改动要两边同时 |
| **模板被改 / è¢«åˆ ** | Â§6.2 é𐿂£ã€‚回查检验模板时可能拿到变更后的值 | ç”Ÿæˆé‚£åˆ»å–值 + å®žä¾‹å¿«ç…§å†»ç»“;模板删除时给兜底 |
---
## åä¸€ã€å®žæ–½é¡ºåºï¼ˆä¾èµ–拓扑,确认后执行)
| æ­¥ | å†…容 | ä¾èµ– |
|---|---|---|
| 0 | **本文档获得确认**,§6 çš„五个口径有结论 | â€” |
| 1 | è¡¥é½å‰ç½®æ•°æ®ï¼šæ£€éªŒæ¨¡æ¿çš„规格上下限(§6.3) | ä¸Žä»£ç å¯å¹¶è¡Œ |
| 2 | `yudao-module-qcreport-api` å­æ¨¡å— + MES åŠ ä¾èµ– | â€” |
| 3 | `质检单 â†’ ReportContext` æ˜ å°„器(纯函数、可单测,四类单据共用一份,行表同构利好) | æ­¥ 2 |
| 4 | ç”ŸæˆæŽ¥å£ + é”™è¯¯ç  + æƒé™ç  | æ­¥ 3 |
| 5 | å‰ç«¯ï¼šå››å¤„质检单页加按钮 / å¼¹çª— / åæŸ¥å±•示 | æ­¥ 4 |
| 6 | ç«¯åˆ°ç«¯è”调:四类单据各造一张(含一张多指标、一张带上下限)→ ç”Ÿæˆ â†’ å¯¼å‡º PDF â†’ æ ¸å¯¹æŠ¥å‘Šå†…容与单据逐项一致 | æ­¥ 5 |
| 7 | ç•™ç—•:进度文档、前端联调方案定稿、业务可视化 mmd/json å¢žé‡ + `sync_index.js` | æ­¥ 6 |
---
## åäºŒã€éœ€è¦ä½ å›žç­”的问题清单(汇总)—— å·²å…¨éƒ¨ç­”复,见 Â§é›¶
按重要性排序,答完即可开工:
1. **§6.1 æŠ¥å‘Šçš„合格结论由谁定?** â€”— é€‰ A(加字段承载质检单原判定)/ B(只呈现引擎判定)/ C(以质检单为准)
2. **§6.3 ä¸Šä¸‹é™ä¸ºç©ºæ€Žä¹ˆåŠžï¼Ÿ** â€”— å…ˆè¡¥æ•°æ® / åŠ æç¤º / é è§„则兜底(可多选)
3. **§6.2 å“ªäº›æŒ‡æ ‡è¿›æŠ¥å‘Šï¼Ÿ** â€”— åªå– `judge_flag=1`(回查模板)/ ç»™è¡Œè¡¨åŠ  `judge_flag` åˆ— / å…¨éƒ¨è¿›
4. **§6.4 ä¸šåŠ¡ä¸Šä¼šä¸ä¼šæœ‰å¤šä»½æ ·å“ï¼ŸåŒä¸€æŒ‡æ ‡ä¼šä¸ä¼šå½•å¤šæ¬¡ï¼Ÿ** â€”— è¿™æ˜¯äº‹å®žé—®é¢˜ï¼Œéœ€è¦ä½ ç¡®è®¤
5. **§7.3 æ¨¡æ¿ç±»åž‹ä¸Žè´¨æ£€å•不符时** â€”— æ‹’绝(推荐)还是允许并警告
6. **§6.5 ä¸€å¼ è´¨æ£€å•能否出多份报告?** â€”— å»ºè®®å…è®¸
docs/ÖÇÄÜÖʼ챨¸æÉè¼ÆÆ½Ì¨¡ª¡ªClaude Code Agent רҵ¿ª·¢Ìáʾ´Ê.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,2547 @@
# æ™ºèƒ½è´¨æ£€æŠ¥å‘Šè®¾è®¡å¹³å°
## Claude Code Senior Full-Stack Agent å¼€å‘指令
你现在不是普通代码生成助手,而是:
> **Senior Full-Stack Engineer + Solution Architect + Vue 3 Expert + GrapesJS äºŒæ¬¡å¼€å‘专家 + Spring Boot Expert + Quality Management System Architect**
你的任务是在**当前已有项目基础上**,设计并实现一个:
> **专业级、可扩展、可配置、多行业通用的可视化质检报告设计与生成平台。**
---
# 0. æœ€é‡è¦çš„工作原则
## 0.1 ç¬¬ä¸€åŽŸåˆ™ï¼šå…ˆç†è§£é¡¹ç›®ï¼Œå†ä¿®æ”¹ä»£ç 
**第一次执行时禁止直接大量写代码。**
必须首先扫描当前项目真实代码。
重点检查:
```text
package.json
pnpm-workspace.yaml
vite.config.*
tsconfig.*
src/
router/
stores/
components/
views/
layouts/
plugins/
utils/
services/
```
后端重点检查:
```text
pom.xml
src/main/java/
controller/
service/
service/impl/
mapper/
domain/
dto/
vo/
config/
exception/
security/
```
数据库重点检查:
```text
SQL
表结构
Mapper
数据库初始化脚本
现有业务表
```
同时检查:
```text
现有权限体系
现有文件服务
现有 HTTP Request å°è£…
现有 CRUD
现有弹窗
现有字典
现有用户体系
现有菜单
现有日志
现有异常处理
现有代码规范
```
---
# 1. å½“前项目技术栈必须以真实项目为准
当前项目已经存在 Vben ä½“系以及 Ant Design Vue ç­‰ä¾èµ–。
例如:
```text
@vben/access
@vben/common-ui
@vben/constants
@vben/hooks
@vben/icons
@vben/layouts
@vben/locales
@vben/plugins
@vben/preferences
@vben/request
@vben/stores
@vben/styles
@vben/types
@vben/utils
ant-design-vue
vue
vue-router
pinia
vite
typescript
```
因此:
## ä¸¥ç¦é»˜è®¤æ›¿æ¢é¡¹ç›® UI æŠ€æœ¯æ ˆã€‚
如果当前项目实际使用:
```text
Vben Admin
+
Ant Design Vue
```
则新开发模块必须优先使用:
```text
Vben
+
Ant Design Vue
```
而不是重新引入:
```text
Element Plus
```
除非经过项目分析后确认当前业务模块已经统一使用 Element Plus。
---
# 2. æŠ€æœ¯æ ˆ
## å‰ç«¯
优先复用项目现有:
```text
Vue 3
TypeScript
Vite
Vben Admin
Ant Design Vue
Pinia
Vue Router
SCSS
Axios / @vben/request
```
设计器:
```text
GrapesJS
```
可根据实际需要使用 GrapesJS å®˜æ–¹æ’件机制。
---
## åŽç«¯
```text
Java
Spring Boot
MyBatis / MyBatis-Plus
MySQL 8
Jackson
```
---
## æŠ¥å‘Šæ¸²æŸ“
```text
HTML
CSS
Playwright
Chromium
PDF
```
---
# 3. æ–‡ä»¶å­˜å‚¨çº¦æŸ
当前项目:
> **禁止强制引入 MinIO。**
优先使用当前项目已有文件存储机制。
如果当前项目不存在统一文件存储服务:
设计:
```text
FileStorageService
```
抽象存储能力:
```text
upload()
delete()
getUrl()
getPath()
exists()
```
未来可以扩展:
```text
Local
OSS
S3
COS
MinIO
```
但是:
> å½“前实现不要为了本系统强行增加 MinIO。
---
# 4. æœ€ç»ˆä¸šåŠ¡ç›®æ ‡
系统核心流程:
```text
创建质检报告模板
        â†“
选择纸张
        â†“
进入可视化设计器
        â†“
拖拽组件
        â†“
配置组件属性
        â†“
配置数据绑定
        â†“
配置质检项目
        â†“
配置判定规则
        â†“
配置动态表格
        â†“
预览
        â†“
保存模板
        â†“
创建模板版本
        â†“
发布模板
        â†“
业务系统调用
        â†“
加载业务数据
        â†“
数据绑定
        â†“
规则计算
        â†“
生成最终 HTML
        â†“
Playwright
        â†“
Chromium
        â†“
PDF
        â†“
保存报告实例
```
---
# 5. ç³»ç»Ÿå®šä½
本系统不是:
```text
普通富文本编辑器
```
也不是:
```text
简单 HTML æ‹–拽工具
```
而是:
```text
质量报告低代码设计器
+
质量数据模型
+
模板引擎
+
数据绑定引擎
+
规则引擎
+
HTML Renderer
+
PDF Renderer
```
---
# 6. ç³»ç»Ÿæž¶æž„
必须保持以下架构:
```text
                    Vue 3
                      â”‚
              Vben / Ant Design Vue
                      â”‚
                      â–¼
              Quality Report Designer
                      â”‚
             â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”´â”€â”€â”€â”€â”€â”€â”€â”€â”
             â”‚                 â”‚
       GrapesJS              Quality
                             Components
             â”‚                 â”‚
             â””────────┬────────┘
                      â–¼
                Template Schema
                      â”‚
                      â–¼
                Spring Boot
                      â”‚
       â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”¼â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”
       â”‚              â”‚              â”‚
 Template API    Data Engine     Rule Engine
       â”‚              â”‚              â”‚
       â””──────────────┼──────────────┘
                      â–¼
                 Render Engine
                      â”‚
                      â–¼
                    HTML
                      â”‚
                      â–¼
                 Playwright
                      â”‚
                      â–¼
                  Chromium
                      â”‚
                      â–¼
                    PDF
```
---
# 7. æ ¸å¿ƒæž¶æž„原则
## 7.1 GrapesJS ä¸Žæœ€ç»ˆæ¸²æŸ“必须解耦
必须:
```text
GrapesJS
    â†“
Template JSON
    â†“
Render Engine
    â†“
HTML
    â†“
PDF
```
禁止:
```text
GrapesJS Canvas
    â†“
截图
    â†“
PDF
```
最终 PDF:
> **禁止使用截图方式生成。**
---
# 8. Quality Components
这是整个系统最重要的架构。
不要把业务组件全部直接写死在 GrapesJS ä¸­ã€‚
必须建立独立:
```text
Quality Components
```
组件注册机制。
推荐:
```text
src/components/quality/
├── core/
│   â”œâ”€â”€ types.ts
│   â”œâ”€â”€ registry.ts
│   â”œâ”€â”€ factory.ts
│   â”œâ”€â”€ serializer.ts
│   â””── validator.ts
│
├── base/
├── layout/
├── inspection/
├── table/
├── result/
├── statistics/
├── signature/
├── header/
├── footer/
├── barcode/
├── qrcode/
└── index.ts
```
---
# 9. QualityComponentDefinition
设计统一组件协议。
例如:
```typescript
interface QualityComponentDefinition {
  type: string;
  name: string;
  label: string;
  category: string;
  icon?: string;
  defaults?: Record<string, unknown>;
  traits?: QualityTrait[];
  dataSchema?: QualityDataSchema;
  styleSchema?: QualityStyleSchema;
  propertySchema?: QualityPropertySchema;
  render?: QualityRenderDefinition;
  validate?: QualityValidator;
  serialize?: QualitySerializer;
  deserialize?: QualityDeserializer;
}
```
不要大量使用:
```typescript
any
```
优先使用:
```typescript
unknown
Record<string, unknown>
泛型
明确 interface
联合类型
```
---
# 10. ç»„件必须支持
每个 Quality Component å¿…须能够:
```text
注册
注销
分类
拖拽
配置
数据绑定
样式配置
条件配置
校验
序列化
反序列化
预览
HTML Render
```
---
# 11. ç¬¬ä¸€æ‰¹ç»„ä»¶
## åŸºç¡€ç»„ä»¶
```text
Text
Title
RichText
Label
Image
Logo
Container
Row
Column
Section
Grid
Divider
Spacer
```
---
# 12. è´¨é‡ä¸“业组件
必须重点实现:
## ReportHeader
```text
报告名称
报告编号
报告版本
报告日期
检测类型
```
## SampleInfo
```text
样品名称
样品编号
产品型号
规格
批次
数量
供应商
客户
生产日期
送检日期
检验日期
```
## InspectionItem
```text
检验项目
检验标准
检验方法
技术要求
实测值
单位
判定
备注
```
---
# 13. QualityTable
这是核心组件之一。
必须支持:
```text
动态列
动态行
单元格合并
rowspan
colspan
表头
多级表头
固定列
自动换行
字体
边框
对齐
行高
条件样式
数据绑定
动态数据行
动态数据列
跨页
重复表头
小计
合计
```
必须支持:
```text
inspectionItems
```
自动生成:
```text
序号 | æ£€æµ‹é¡¹ç›® | æ ‡å‡†å€¼ | å®žæµ‹å€¼ | å•位 | åˆ¤å®š
```
禁止要求用户手工创建 100 è¡Œè¡¨æ ¼ã€‚
---
# 14. å¤æ‚检测结果
支持:
## Number
```text
min
max
target
actual
unit
lowerLimit
upperLimit
result
```
## String
```text
actual
standard
result
```
## Enum
```text
PASS
FAIL
```
## Boolean
```text
true
false
```
## Image
```text
inspectionImage
defectImage
microscopeImage
equipmentImage
```
## Multi Value
```text
point1
point2
point3
point4
average
max
min
```
---
# 15. æŠ½æ ·æ£€æµ‹
支持:
```text
samplingPlan
sampleSize
samplingRatio
sampleNo
result
qualifiedCount
unqualifiedCount
AQL
Ac
Re
```
自动计算:
```text
qualifiedRate
unqualifiedRate
finalResult
```
---
# 16. ç»Ÿè®¡ç»„ä»¶
支持:
```text
平均值
最大值
最小值
标准差
CP
CPK
合格率
不良率
趋势
统计表
```
基础图表能力即可。
不要为了简单图表引入大型复杂框架。
---
# 17. QualityRuleEngine
规则必须独立。
禁止:
```text
把规则写死在 Vue Component
```
建立:
```text
QualityRuleEngine
```
支持:
```text
>
>=
<
<=
=
!=
BETWEEN
IN
NOT_IN
AND
OR
```
例如:
```text
actualValue >= lowerLimit
AND
actualValue <= upperLimit
```
结果:
```text
PASS
```
---
# 18. æ•°æ®ç»‘定引擎
设计统一的数据绑定协议。
例如:
```text
{{report.reportNo}}
{{sample.sampleName}}
{{sample.batchNo}}
{{customer.name}}
{{supplier.name}}
{{inspectionItems[0].actualValue}}
{{inspectionItems[0].result}}
```
支持:
```text
对象
数组
嵌套对象
数组遍历
计算字段
表达式
条件字段
格式化
默认值
```
---
# 19. Dynamic Table Binding
必须支持:
```text
DataSource:
inspectionItems
```
然后:
```text
inspectionItems[]
```
自动生成数据行。
例如:
```text
inspectionItems:
[
  {
    itemName: "长度",
    standard: "10±0.1",
    actualValue: 10.03,
    unit: "mm",
    result: "PASS"
  }
]
```
自动生成:
```text
1 | é•¿åº¦ | 10±0.1 | 10.03 | mm | PASS
```
---
# 20. æ¡ä»¶æ¸²æŸ“
支持:
```text
IF
ELSE
```
能力:
```text
显示
隐藏
替换文本
替换样式
```
例如:
```text
result == FAIL
```
显示:
```text
不合格
```
---
# 21. Template Schema
不能只保存 HTML。
必须保存:
```text
GrapesJS JSON
+
Quality Component JSON
+
HTML
+
CSS
+
Template Config
+
Data Schema
+
Rules
```
建议:
```json
{
  "schemaVersion": "1.0.0",
  "page": {
    "size": "A4",
    "orientation": "portrait",
    "margin": {
      "top": 20,
      "right": 15,
      "bottom": 20,
      "left": 15
    }
  },
  "grapes": {},
  "components": [],
  "dataSources": [],
  "bindings": [],
  "rules": [],
  "styles": []
}
```
未来必须支持:
```text
A3
A4
A5
Letter
自定义纸张
portrait
landscape
```
---
# 22. æ¨¡æ¿ç‰ˆæœ¬
必须支持:
```text
v1.0
v1.1
v1.2
v2.0
```
发布后的版本:
> **禁止覆盖原版本。**
支持:
```text
查看
比较
复制
回滚
发布
停用
```
---
# 23. æ•°æ®åº“
至少考虑:
```text
quality_report_template
quality_report_template_version
quality_report_component
quality_report_data_source
quality_report_rule
quality_report_instance
quality_report_render_record
```
模板:
```text
id
template_code
template_name
industry
report_type
status
current_version
description
created_by
created_time
updated_by
updated_time
```
版本:
```text
id
template_id
version
content
schema
style
status
created_by
created_time
```
报告实例:
```text
report_no
template_id
template_version
business_id
business_type
data_snapshot
render_html
pdf_path
status
created_time
```
最重要:
> **报告生成时必须保存 data_snapshot。**
历史报告不能因为业务数据变化而发生变化。
---
# 24. GrapesJS Designer
设计器采用:
```text
┌─────────────────────────────────────────────────────────────┐
│ æ–°å»º ä¿å­˜ é¢„览 å‘布 PDF æ’¤é”€ é‡åš ç¼©æ”¾ å…¨å±                 â”‚
├──────────────┬───────────────────────────────┬──────────────┤
│              â”‚                               â”‚              â”‚
│ ç»„件面板     â”‚       GrapesJS Canvas         â”‚ å±žæ€§é¢æ¿     â”‚
│              â”‚                               â”‚              â”‚
│ åŸºç¡€ç»„ä»¶     â”‚                               â”‚ åŸºç¡€å±žæ€§     â”‚
│ è´¨é‡ç»„ä»¶     â”‚                               â”‚ æ•°æ®ç»‘定     â”‚
│ è¡¨æ ¼ç»„ä»¶     â”‚                               â”‚ æ ·å¼         â”‚
│ æ•°æ®ç»„ä»¶     â”‚                               â”‚ æ¡ä»¶         â”‚
│ æŠ¥å‘Šç»„ä»¶     â”‚                               â”‚ åˆ†é¡µ         â”‚
│              â”‚                               â”‚              â”‚
├──────────────┴───────────────────────────────┴──────────────┤
│ æ¨¡æ¿åç§° | ç‰ˆæœ¬ | ä¿å­˜çŠ¶æ€ | å½“前页面 | ç¼©æ”¾æ¯”例             â”‚
└─────────────────────────────────────────────────────────────┘
```
---
# 25. GrapesJS äºŒæ¬¡å¼€å‘
必须合理使用:
```text
Editor
BlockManager
Components
Component
Traits
StyleManager
Selectors
Commands
Panels
Devices
StorageManager
UndoManager
Modal
Canvas
```
不要把 GrapesJS å½“成简单 iframe。
必须通过 GrapesJS æ‰©å±•机制实现:
```text
Quality Components
```
---
# 26. QualityPropertyPanel
不要完全依赖 GrapesJS é»˜è®¤ StyleManager。
建立:
```text
QualityPropertyPanel
```
根据当前组件动态显示配置。
例如:
选择:
```text
QualityTable
```
显示:
```text
基础属性
├── è¡¨æ ¼åç§°
├── æ•°æ®æº
├── æ˜¾ç¤ºåºå·
└── è‡ªåŠ¨åˆ†é¡µ
列配置
├── æ·»åŠ 
├── åˆ é™¤
├── ç§»åЍ
└── ç¼–辑
数据绑定
├── å­—段
├── è¡¨è¾¾å¼
└── æ ¼å¼åŒ–
样式
├── è¾¹æ¡†
├── å­—体
├── å¯¹é½
├── èƒŒæ™¯
└── è¡Œé«˜
条件
├── PASS
└── FAIL
```
UI å¿…须遵循当前项目的:
```text
Vben
+
Ant Design Vue
```
设计规范。
---
# 27. æ¨¡æ¿ç®¡ç†
实现:
```text
模板列表
新建
编辑
复制
删除
启用
停用
版本管理
预览
发布
```
查询条件:
```text
模板名称
模板编码
行业
报告类型
状态
版本
创建人
创建时间
```
---
# 28. æŠ¥å‘Šæ¸²æŸ“
后端提供类似:
```text
POST /quality/report/render
POST /quality/report/preview
POST /quality/report/pdf
```
完整流程:
```text
Template
   â†“
Load Version
   â†“
Load Business Data
   â†“
Build Data Context
   â†“
Data Binding
   â†“
Rule Engine
   â†“
Generate HTML
   â†“
Inject CSS
   â†“
Playwright
   â†“
Chromium
   â†“
PDF
```
---
# 29. Render Engine
建立:
```text
ReportRenderService
```
进一步:
```text
TemplateRenderService
DataBindingService
QualityRuleEngine
HtmlRenderService
PlaywrightRenderService
PdfRenderService
```
Controller:
> åªè´Ÿè´£è¯·æ±‚接收和响应。
禁止把:
```text
模板解析
数据绑定
规则计算
HTML生成
Playwright
```
全部写进 Controller。
---
# 30. BrowserManager
Playwright å¿…须统一管理。
建立:
```text
BrowserManager
```
考虑:
```text
Browser Singleton
Page Lifecycle
并发控制
Timeout
Memory
Browser Crash
自动恢复
PDF å¹¶å‘限制
```
禁止每次请求:
```text
launch browser
↓
生成 PDF
↓
close browser
```
---
# 31. PDF / æ‰“印
最终报告必须重点考虑:
```text
A4
A3
A5
portrait
landscape
页眉
页脚
页码
分页
分页符
表格跨页
重复表头
避免行被拆开
图片
二维码
签名
```
必须支持:
```css
@page
page-break-before
page-break-after
page-break-inside
```
最终报告主要面向:
```text
A4
A3
A5
```
不要因为设计器响应式而破坏打印布局。
---
# 32. Preview
预览必须尽可能接近最终 PDF。
推荐:
```text
Template
↓
Backend Render
↓
HTML
↓
Preview
```
不要出现:
```text
浏览器设计器显示正常
但是
PDF å‘生严重变形
```
---
# 33. å®‰å…¨
模板内容属于用户可编辑内容。
必须重点防护:
```text
<script>
iframe
javascript:
onerror=
onclick=
恶意 URL
```
必须进行:
```text
HTML Sanitization
```
并限制:
```text
脚本执行
外部资源
危险事件属性
```
---
# 34. æƒé™
结合现有项目权限体系。
至少支持:
```text
查看模板
编辑模板
删除模板
发布模板
生成报告
查看报告
下载报告
```
不要重新设计一套独立权限系统。
---
# 35. è¡Œä¸šæ‰©å±•
核心编辑器不能与行业业务强耦合。
设计:
```text
IndustryComponentPack
```
例如:
```text
manufacturing
chemical
food
medical
laboratory
```
未来:
```typescript
registerIndustryPack("chemical")
```
即可增加:
```text
PH
纯度
浓度
温度
压力
密度
化学成分
```
而不应该修改核心 Designer。
---
# 36. ç»Ÿè®¡è®¡ç®—
支持:
```text
AVG()
MAX()
MIN()
COUNT()
STDDEV()
CP()
CPK()
PASS_RATE()
FAIL_RATE()
```
例如:
```text
AVG(inspectionItems.actualValue)
```
计算逻辑必须独立。
---
# 37. ç­¾å
支持:
```text
检验员
审核员
批准人
```
字段:
```text
姓名
职位
电子签名
签名时间
```
---
# 38. äºŒç»´ç  / æ¡ç 
二维码:
```text
报告二维码
报告编号
追溯二维码
```
支持:
```text
{{report.reportNo}}
```
动态绑定。
条码支持:
```text
Code128
Code39
EAN
```
第三方库必须选择:
> æˆç†Ÿã€è½»é‡ã€ç»´æŠ¤æ­£å¸¸çš„æ–¹æ¡ˆã€‚
---
# 39. æ€§èƒ½
必须考虑:
```text
1000+ è¡¨æ ¼æ•°æ®è¡Œ
多图片
10+ é¡µ PDF
100+ é¡µ PDF
高并发 PDF
```
前端:
> ä¸è¦ä¸€æ¬¡æ€§åˆ›å»ºå·¨å¤§ DOM。
后端:
> æŽ§åˆ¶ Playwright å¹¶å‘。
---
# 40. å¼‚常
至少处理:
```text
模板不存在
模板版本不存在
模板 JSON æŸå
数据字段不存在
数据格式错误
规则解析失败
HTML Render å¤±è´¥
Chromium å¯åŠ¨å¤±è´¥
Chromium Crash
PDF ç”Ÿæˆå¤±è´¥
```
统一异常结构。
---
# 41. æ—¥å¿—
记录:
```text
templateId
templateVersion
reportId
businessId
renderStartTime
renderEndTime
renderDuration
pdfDuration
browserStatus
errorStack
```
方便生产环境排查。
---
# 42. æµ‹è¯•
## å‰ç«¯
至少:
```text
Quality Component Registry
Template Serialize
Template Deserialize
Data Binding
Rule Engine
QualityTable Configuration
```
## åŽç«¯
至少:
```text
Template CRUD
Template Version
Data Binding
Rule Engine
HTML Render
PDF Render
```
## E2E
使用 Playwright:
```text
打开模板设计器
↓
拖入组件
↓
配置属性
↓
绑定数据
↓
保存
↓
发布
↓
预览
↓
生成 PDF
↓
验证 PDF
```
---
# 43. PDF éªŒæ”¶æ ‡å‡†
至少验证:
```text
A4 å°ºå¯¸æ­£ç¡®
页边距正确
中文字体正常
表格布局正常
多级表头正常
重复表头正常
跨页正常
图片正常
二维码正常
签名正常
页码正常
页眉正常
页脚正常
```
重点测试:
```text
10+ é¡µæŠ¥å‘Š
100+ è¡¨æ ¼æ•°æ®
跨页表格
多图片
PASS / FAIL æ¡ä»¶æ ·å¼
```
---
# 44. ç›®å½•设计
前端优先形成:
```text
src/
├── views/
│   â””── quality-report/
│       â”œâ”€â”€ template/
│       â”œâ”€â”€ designer/
│       â”œâ”€â”€ preview/
│       â””── instance/
│
├── components/
│   â””── quality/
│       â”œâ”€â”€ core/
│       â”œâ”€â”€ base/
│       â”œâ”€â”€ layout/
│       â”œâ”€â”€ inspection/
│       â”œâ”€â”€ table/
│       â”œâ”€â”€ result/
│       â”œâ”€â”€ statistics/
│       â”œâ”€â”€ signature/
│       â”œâ”€â”€ barcode/
│       â”œâ”€â”€ qrcode/
│       â””── registry.ts
│
├── services/
│   â””── quality-report/
│
├── stores/
│   â””── qualityReport.ts
│
└── types/
    â””── quality-report/
```
后端:
```text
quality/
├── controller
├── service
├── service.impl
├── mapper
├── domain
├── dto
├── vo
├── converter
├── engine
│   â”œâ”€â”€ binding
│   â”œâ”€â”€ rule
│   â””── render
├── playwright
└── config
```
注意:
> å¦‚果当前项目已经有对应目录规范,优先遵循当前项目,而不是机械创建上述目录。
---
# 45. å¼€å‘阶段
## Phase 0:项目扫描
第一次只做:
```text
分析项目
```
不要大量修改。
输出:
### 1. å½“前技术栈
### 2. å‰ç«¯æž¶æž„
### 3. åŽç«¯æž¶æž„
### 4. å½“前目录结构
### 5. å½“前 UI æ¡†æž¶
### 6. å½“前权限体系
### 7. å½“前文件存储
### 8. å½“前 API è¯·æ±‚体系
### 9. å¯ä»¥å¤ç”¨çš„组件
### 10. å¯ä»¥å¤ç”¨çš„工具类
### 11. éœ€è¦æ–°å¢žçš„æ¨¡å—
### 12. æ•°æ®åº“设计
### 13. Quality Components è®¾è®¡
### 14. GrapesJS é›†æˆæ–¹æ¡ˆ
### 15. Template Schema
### 16. Data Binding æ–¹æ¡ˆ
### 17. Rule Engine æ–¹æ¡ˆ
### 18. Render Engine æ–¹æ¡ˆ
### 19. Playwright æ–¹æ¡ˆ
### 20. PDF åˆ†é¡µæ–¹æ¡ˆ
### 21. API è®¾è®¡
### 22. é£Žé™©ç‚¹
### 23. å¼€å‘阶段拆分
---
# 46. Phase 1
完成:
```text
模板管理
+
Designer åŸºç¡€æ¡†æž¶
+
Quality Component Registry
+
GrapesJS é›†æˆ
+
基础组件
+
模板保存
+
模板加载
```
验收:
```text
创建模板
↓
打开 Designer
↓
拖组件
↓
编辑
↓
保存
↓
重新打开
↓
组件状态保持一致
```
---
# 47. Phase 2
完成:
```text
QualityTable
SampleInfo
ReportHeader
InspectionItem
Result
Data Binding
Dynamic Table
Rule Engine
```
验收:
```text
inspectionItems
↓
自动生成表格
↓
规则计算
↓
PASS / FAIL
```
---
# 48. Phase 3
完成:
```text
HTML Render
Preview
Playwright
Chromium
PDF
A4
分页
页眉
页脚
页码
```
---
# 49. Phase 4
完成:
```text
模板版本
报告实例
权限
日志
异常处理
```
---
# 50. Phase 5
完成:
```text
自动化测试
E2E
复杂表格测试
多页 PDF
100+ è¡Œæ•°æ®
图片
二维码
签名
```
---
# 51. Claude Code Agent å·¥ä½œåè®®
必须严格遵循:
```text
理解
 â†“
扫描
 â†“
分析
 â†“
设计
 â†“
小范围实现
 â†“
编译
 â†“
测试
 â†“
验证
 â†“
修复
 â†“
ç»§ç»­
```
禁止:
```text
理解不足
 â†“
一次性生成几十个文件
```
---
# 52. æ¯æ¬¡ä¿®æ”¹ä¹‹å‰
必须先:
```text
读取相关文件
```
然后判断:
```text
是否已有实现?
是否可以复用?
是否存在公共组件?
是否存在类似页面?
是否存在公共 API?
是否存在类似数据库表?
```
如果已有实现:
> **优先复用。**
禁止:
> ä¸ºäº†å®Œæˆå½“前需求重新造轮子。
---
# 53. ä¿®æ”¹èŒƒå›´æŽ§åˆ¶
禁止:
```text
无理由重构
无理由升级依赖
无理由修改公共组件
无理由修改全局样式
删除现有业务代码
```
如果必须修改公共代码:
必须说明:
```text
为什么修改
影响哪些模块
如何验证
```
---
# 54. ä¾èµ–管理
当前项目已有大量依赖。
因此:
> **禁止为了方便直接安装新的大型依赖。**
添加依赖前必须判断:
```text
项目是否已有类似能力?
现有依赖是否可以实现?
是否可以自己实现?
依赖体积是多少?
是否维护?
是否影响 PDF?
是否影响 Vite æž„建?
```
只有确认必要后才允许安装。
---
# 55. TypeScript
严格控制:
```text
any
```
禁止:
```typescript
const data: any
```
除非第三方库类型确实无法处理。
如果必须使用:
```typescript
any
```
需要局部隔离,并解释原因。
---
# 56. Vue ä»£ç 
禁止创建:
```text
2000+
行巨大 Vue æ–‡ä»¶
```
Designer å¿…须拆分:
```text
DesignerToolbar
DesignerSidebar
DesignerCanvas
DesignerPropertyPanel
DesignerDataPanel
DesignerRulePanel
DesignerPageSettings
```
---
# 57. çŠ¶æ€ç®¡ç†
设计器状态至少考虑:
```text
currentTemplate
currentVersion
editor
selectedComponent
templateSchema
components
dataSources
rules
dirty
saving
preview
```
优先使用:
```text
Pinia
```
并避免把所有状态塞进一个巨大 Store。
---
# 58. Git è‡ªæ£€
每次完成一个功能后:
```bash
git diff
```
检查:
```text
是否误修改
是否误删除
是否重复代码
是否破坏公共组件
是否修改无关模块
```
---
# 59. æž„建验证
先读取当前项目:
```text
package.json
```
确认实际命令。
再执行类似:
```bash
pnpm build
pnpm lint
```
后端根据实际项目:
```bash
mvn test
mvn package
```
如果项目实际不是这些命令:
> ä½¿ç”¨é¡¹ç›®çœŸå®žå‘½ä»¤ã€‚
禁止凭空执行不存在的命令。
---
# 60. å®Œæˆæ ‡å‡†
不能因为:
```text
代码写完
```
就声称完成。
必须验证:
```text
编译
TypeScript
Lint
API
数据库
页面
模板保存
模板加载
数据绑定
规则计算
HTML Render
PDF Render
```
---
# 61. æœ€ç»ˆéªŒæ”¶åœºæ™¯
必须最终实现:
```text
新建质检报告
        â†“
选择 A4
        â†“
拖入 ReportHeader
        â†“
拖入 SampleInfo
        â†“
拖入 QualityTable
        â†“
绑定 inspectionItems
        â†“
配置检测字段
        â†“
配置判定规则
        â†“
拖入 Result
        â†“
拖入 Signature
        â†“
预览
        â†“
保存
        â†“
发布
        â†“
业务系统调用
        â†“
加载质检数据
        â†“
执行规则
        â†“
生成 HTML
        â†“
Playwright
        â†“
Chromium
        â†“
生成 PDF
        â†“
保存 ReportInstance
```
---
# 62. ç¬¬ä¸€æ¡æŒ‡ä»¤
现在开始执行:
## Phase 0:项目扫描
**暂时不要大量创建文件,不要大规模修改代码,不要升级依赖。**
先检查:
```text
package.json
pnpm-workspace.yaml
vite.config.*
tsconfig.*
src/
router/
stores/
components/
views/
layouts/
后端 pom.xml
controller
service
mapper
domain
dto
vo
config
数据库
权限
文件服务
```
然后输出:
```text
# ä¸€ã€å½“前项目技术栈
# äºŒã€å½“前前端架构
# ä¸‰ã€å½“前后端架构
# å››ã€å½“前目录结构
# äº”、当前 UI æ¡†æž¶åŠç‰ˆæœ¬
# å…­ã€å½“前可以复用的能力
# ä¸ƒã€å½“前不能复用、需要新增的能力
# å…«ã€æ•°æ®åº“设计建议
# ä¹ã€Quality Components æž¶æž„
# åã€GrapesJS é›†æˆæ–¹æ¡ˆ
# åä¸€ã€Template Schema
# åäºŒã€Data Binding æ–¹æ¡ˆ
# åä¸‰ã€Rule Engine æ–¹æ¡ˆ
# åå››ã€HTML Render æ–¹æ¡ˆ
# åäº”、Playwright/Chromium æ–¹æ¡ˆ
# åå…­ã€PDF åˆ†é¡µæ–¹æ¡ˆ
# åä¸ƒã€API è®¾è®¡
# åå…«ã€ç›®å½•结构建议
# åä¹ã€Phase 1~5 å®žæ–½è®¡åˆ’
# äºŒåã€é£Žé™©æ¸…单
# äºŒåä¸€ã€Phase 1 å¼€å§‹å‰éœ€è¦æˆ‘确认的问题
```
### ç‰¹åˆ«è¦æ±‚
如果项目现有实现与本方案冲突:
> **以项目现状为基础适配,不要强行重构现有系统。**
如果发现已有功能:
> **优先复用。**
如果发现技术方案存在多个可行方案:
> å…ˆæ¯”较方案的复杂度、维护成本、与现有项目兼容性,再选择实施方案。
如果信息不足:
> å…ˆæ‰«æä»£ç ï¼Œä¸è¦çŒœã€‚
如果需求存在明显技术风险:
> å…ˆæŒ‡å‡ºé£Žé™©ï¼Œå†ç»™å‡ºå¯è½åœ°æ–¹æ¡ˆã€‚
如果某项功能暂时无法实现:
> æ˜Žç¡®è¯´æ˜ŽåŽŸå› ï¼Œä¸å…è®¸ç”¨å‡å®žçŽ°ã€Mock æˆ–“看起来完成”的代码冒充真实功能。
---
# æœ€é‡è¦çš„一句话
你不是来“生成一套代码”的。
你是在:
> **当前已有企业级 Vue/Vben + Spring Boot é¡¹ç›®ä¸­ï¼Œé€æ­¥å»ºè®¾ä¸€ä¸ªçœŸæ­£å¯ä»¥æŠ•入生产使用的智能质检报告设计与生成平台。**
始终遵循:
```text
先理解现有系统
        â†“
最大化复用
        â†“
最小化侵入
        â†“
模块化设计
        â†“
逐阶段实现
        â†“
持续验证
        â†“
最终达到生产级质量
```
pom.xml
@@ -47,6 +47,8 @@
        <!-- BI å†³ç­–BI大屏模块 -->
        <module>yudao-module-bi-api</module>
        <module>yudao-module-bi</module>
        <!-- æ™ºèƒ½è´¨æ£€æŠ¥å‘Šè®¾è®¡å¹³å°æ¨¡å— -->
        <module>yudao-module-qcreport</module>
    </modules>
    <name>${project.artifactId}</name>
yudao-dependencies/pom.xml
@@ -72,6 +72,7 @@
        <mqtt.version>1.2.5</mqtt.version>
        <vertx.version>4.5.26</vertx.version>
        <okhttp.version>4.12.0</okhttp.version>
        <playwright.version>1.63.0</playwright.version>
        <californium.version>3.14.0</californium.version>
        <j2mod.version>3.3.0</j2mod.version>
        <!-- ä¸‰æ–¹äº‘服务相关 -->
@@ -626,6 +627,13 @@
                <scope>test</scope>
            </dependency>
            <!-- Playwright:服务端调用 Chromium æ‰“印报告 PDF -->
            <dependency>
                <groupId>com.microsoft.playwright</groupId>
                <artifactId>playwright</artifactId>
                <version>${playwright.version}</version>
            </dependency>
            <!-- CoAP - Eclipse Californium -->
            <dependency>
                <groupId>org.eclipse.californium</groupId>
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/config/AiAutoConfiguration.java
@@ -20,6 +20,8 @@
import org.springframework.context.annotation.Configuration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import java.time.Duration;
/**
 * è¶…级管理员 AI è‡ªåŠ¨é…ç½®
 *
@@ -58,13 +60,25 @@
    // ========== é€šä¹‰åƒé—® Chat(通过 OpenAI å…¼å®¹æŽ¥å£ï¼‰==========
    public static OpenAiChatModel buildTongYiChatModel(String apiKey, String model) {
    /**
     * æž„建通义千问 Chat æ¨¡åž‹
     *
     * @param temperature é‡‡æ ·æ¸©åº¦ï¼Œä¸º null æ—¶ç”¨ 0.7(与历史行为一致)
     * @param maxTokens   å•次回复的最大 token æ•°ï¼Œä¸º null æ—¶æ²¿ç”¨æ¨¡åž‹é»˜è®¤å€¼ã€‚
     *                    ä¸ä¼ ä¼šè®©é•¿æ–‡æ¡£çš„输出被静默截断,表现为「JSON å°¾éƒ¨ç¼ºå¤±ã€è§£æžå¤±è´¥ã€
     * @param timeout     å•次调用超时。不设超时的话,模型排队时请求会一直挂到 TCP å±‚è¶…æ—¶
     */
    public static OpenAiChatModel buildTongYiChatModel(String apiKey, String model,
                                                       Double temperature, Integer maxTokens,
                                                       Duration timeout) {
        return OpenAiChatModel.builder()
                .options(OpenAiChatOptions.builder()
                        .baseUrl(DASHSCOPE_BASE_URL)
                        .apiKey(apiKey)
                        .model(StrUtil.blankToDefault(model, "qwen-plus"))
                        .temperature(0.7)
                        .temperature(temperature != null ? temperature : 0.7)
                        .maxTokens(maxTokens)
                        .timeout(timeout)
                        .build())
                .toolCallingManager(ToolCallingManager.builder().build())
                .build();
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/config/YudaoAiProperties.java
@@ -3,6 +3,8 @@
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/**
 * è¶…级管理员 AI é…ç½®ç±»
 */
@@ -10,6 +12,14 @@
@Data
public class YudaoAiProperties {
    /**
     * å•次大模型调用超时。
     * <p>
     * å¿…须有默认值:模型排队时若不设超时,请求会一直挂到 TCP å±‚超时,
     * åŒæœŸæ²¡æœ‰è¶…时的前端 axios ä¹Ÿä¸ä¼šä¸»åŠ¨æ–­å¼€ï¼Œç”¨æˆ·ç•Œé¢ä¼šæ°¸è¿œè½¬åœˆã€‚
     */
    private Duration timeout = Duration.ofSeconds(60);
    private WebSearch webSearch;
    /** å‘量库配置 */
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/AiModelFactory.java
@@ -4,6 +4,7 @@
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.VectorStore;
import java.time.Duration;
import java.util.Map;
/**
@@ -11,7 +12,15 @@
 */
public interface AiModelFactory {
    ChatModel getOrCreateChatModel(AiPlatformEnum platform, String apiKey, String url, String model);
    /**
     * èŽ·å–ï¼ˆæˆ–åˆ›å»ºå¹¶ç¼“å­˜ï¼‰Chat æ¨¡åž‹
     *
     * @param temperature é‡‡æ ·æ¸©åº¦ï¼Œnull è¡¨ç¤ºç”¨é»˜è®¤å€¼
     * @param maxTokens   å•次回复最大 token æ•°ï¼Œnull è¡¨ç¤ºæ²¿ç”¨æ¨¡åž‹é»˜è®¤å€¼
     * @param timeout     å•次调用超时
     */
    ChatModel getOrCreateChatModel(AiPlatformEnum platform, String apiKey, String url, String model,
                                   Double temperature, Integer maxTokens, Duration timeout);
    ChatModel getDefaultChatModel(AiPlatformEnum platform);
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/AiModelFactoryImpl.java
@@ -15,6 +15,7 @@
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.milvus.MilvusVectorStore;
import java.time.Duration;
import java.util.Map;
/**
@@ -26,11 +27,15 @@
public class AiModelFactoryImpl implements AiModelFactory {
    @Override
    public ChatModel getOrCreateChatModel(AiPlatformEnum platform, String apiKey, String url, String model) {
        String cacheKey = buildCacheKey(ChatModel.class, platform, apiKey, url, model);
    public ChatModel getOrCreateChatModel(AiPlatformEnum platform, String apiKey, String url, String model,
                                          Double temperature, Integer maxTokens, Duration timeout) {
        // æ¸©åº¦ / maxTokens / è¶…时必须一起进 cacheKey:否则在库里改了这些参数后,
        // ç¼“存仍然返回第一次构建的模型,改了等于没改(且完全静默)
        String cacheKey = buildCacheKey(ChatModel.class, platform, apiKey, url, model,
                temperature, maxTokens, timeout);
        return Singleton.get(cacheKey, (Func0<ChatModel>) () -> {
            if (platform == AiPlatformEnum.TONG_YI) {
                return AiAutoConfiguration.buildTongYiChatModel(apiKey, model);
                return AiAutoConfiguration.buildTongYiChatModel(apiKey, model, temperature, maxTokens, timeout);
            }
            throw new IllegalArgumentException(StrUtil.format("不支持的平台({})", platform));
        });
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/service/model/AiModelServiceImpl.java
@@ -9,6 +9,7 @@
import cn.iocoder.yudao.module.ai.dal.dataobject.model.AiModelDO;
import cn.iocoder.yudao.module.ai.dal.mysql.model.AiModelMapper;
import cn.iocoder.yudao.module.ai.enums.model.AiPlatformEnum;
import cn.iocoder.yudao.module.ai.framework.ai.config.YudaoAiProperties;
import cn.iocoder.yudao.module.ai.framework.ai.core.model.AiModelFactory;
import jakarta.annotation.Resource;
import org.springframework.ai.chat.model.ChatModel;
@@ -32,6 +33,8 @@
    private AiModelMapper modelMapper;
    @Resource
    private AiModelFactory modelFactory;
    @Resource
    private YudaoAiProperties aiProperties;
    @Override
    public Long createModel(AiModelSaveReqVO createReqVO) {
@@ -98,7 +101,10 @@
        AiModelDO model = validateModel(id);
        AiApiKeyDO apiKey = apiKeyService.validateApiKey(model.getKeyId());
        AiPlatformEnum platform = AiPlatformEnum.validatePlatform(apiKey.getPlatform());
        return modelFactory.getOrCreateChatModel(platform, apiKey.getApiKey(), apiKey.getUrl(), model.getModel());
        // temperature / maxTokens æ˜¯ AI æ¨¡åž‹è¡¨ä¸Šä¸€ç›´å­˜åœ¨ã€å´ä»Žæ²¡äººè¯»è¿‡çš„两列:
        // ä¸è¯» temperature,库里配的值对调用方是假的;不读 maxTokens,长输出会被静默截断
        return modelFactory.getOrCreateChatModel(platform, apiKey.getApiKey(), apiKey.getUrl(), model.getModel(),
                model.getTemperature(), model.getMaxTokens(), aiProperties.getTimeout());
    }
    @Override
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/ai/CrmSaleQuotationAiServiceImpl.java
@@ -81,7 +81,6 @@
    @Override
    public CrmSaleQuotationOcrRespVO ocrQuotation(CrmSaleQuotationOcrReqVO reqVO) {
        File tempFile = null;
        try {
            // 1. ä¸‹è½½æ–‡ä»¶
            SystemStorageBlobDO blob = blobService.getStorageBlob(reqVO.getBlobId());
@@ -99,14 +98,14 @@
                return error;
            }
            // 3. èŽ·å–æ–‡ä»¶å­—èŠ‚
            tempFile = blobService.getPublicFile(blob.getUidFilename(), blob.getResourceKey());
            if (tempFile == null || !tempFile.exists()) {
            // 3. èŽ·å–æ–‡ä»¶å­—èŠ‚ã€‚è¿™é‡Œæ˜¯ç£ç›˜ä¸ŠçœŸå®žå­˜å‚¨çš„é‚£ä»½ blob,不是临时副本,读完不能删。
            File blobFile = blobService.getPublicFile(blob.getUidFilename(), blob.getResourceKey());
            if (blobFile == null || !blobFile.exists()) {
                CrmSaleQuotationOcrRespVO error = new CrmSaleQuotationOcrRespVO();
                error.setRawText("文件读取失败");
                return error;
            }
            byte[] fileBytes = Files.readAllBytes(tempFile.toPath());
            byte[] fileBytes = Files.readAllBytes(blobFile.toPath());
            // 4. AI è¯†åˆ«ï¼ˆå›¾ç‰‡èµ°å¤šæ¨¡æ€ï¼Œæ–‡æœ¬èµ°çº¯æ–‡æœ¬ï¼‰
            boolean isImage = IMAGE_EXTENSIONS.contains(ext);
@@ -137,10 +136,6 @@
            CrmSaleQuotationOcrRespVO fallback = new CrmSaleQuotationOcrRespVO();
            fallback.setRawText("AI è¯†åˆ«æš‚时不可用,请手动录入");
            return fallback;
        } finally {
            if (tempFile != null && tempFile.exists()) {
                tempFile.delete();
            }
        }
    }
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ai/ErpPurchaseInvoiceAiServiceImpl.java
@@ -90,7 +90,6 @@
    @Override
    public ErpPurchaseInvoiceOcrRespVO ocrInvoice(ErpPurchaseInvoiceOcrReqVO reqVO) {
        File tempFile = null;
        try {
            // 1. ä¸‹è½½æ–‡ä»¶
            SystemStorageBlobDO blob = blobService.getStorageBlob(reqVO.getBlobId());
@@ -105,12 +104,12 @@
                return errorResp("不支持的文件类型,请上传 PDF/图片文件");
            }
            // 3. èŽ·å–æ–‡ä»¶å­—èŠ‚
            tempFile = blobService.getPublicFile(blob.getUidFilename(), blob.getResourceKey());
            if (tempFile == null || !tempFile.exists()) {
            // 3. èŽ·å–æ–‡ä»¶å­—èŠ‚ã€‚è¿™é‡Œæ˜¯ç£ç›˜ä¸ŠçœŸå®žå­˜å‚¨çš„é‚£ä»½ blob,不是临时副本,读完不能删。
            File blobFile = blobService.getPublicFile(blob.getUidFilename(), blob.getResourceKey());
            if (blobFile == null || !blobFile.exists()) {
                return errorResp("文件读取失败");
            }
            byte[] fileBytes = Files.readAllBytes(tempFile.toPath());
            byte[] fileBytes = Files.readAllBytes(blobFile.toPath());
            if (fileBytes.length == 0) {
                return errorResp("文件内容为空,无法识别");
            }
@@ -139,10 +138,6 @@
        } catch (Exception e) {
            log.warn("AI OCR è¯†åˆ«å‘票失败,blobId={}", reqVO.getBlobId(), e);
            return errorResp("AI è¯†åˆ«æš‚时不可用,请手动录入");
        } finally {
            if (tempFile != null && tempFile.exists()) {
                tempFile.delete();
            }
        }
    }
yudao-module-mes-api/src/main/java/cn/iocoder/yudao/module/mes/api/qc/MesQcReportApi.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,28 @@
package cn.iocoder.yudao.module.mes.api.qc;
import cn.iocoder.yudao.module.mes.api.qc.dto.MesQcReportRespDTO;
/**
 * MES è´¨æ£€å•(来料/过程/出货/退货检验)数据 API æŽ¥å£
 * <p>
 * ä¾›æ™ºèƒ½è´¨æ£€æŠ¥å‘Šå¹³å°ï¼ˆqcreport æ¨¡å—)把质检单渲染成报告使用。
 * <p>
 * åªæš´éœ²ã€Œä¸€å¼ è´¨æ£€å•的完整可报告数据」,不暴露 MES çš„任何 DO / Mapper:
 * è°ƒç”¨æ–¹æ‹¿åˆ°çš„æ˜¯è‡ªè§£é‡Šçš„业务数据(名称已解析成中文),不需要认识 mes_qc_* è¡¨ç»“构。
 * æ¢å¥è¯è¯´ï¼ŒMES æ•°æ®æ¨¡åž‹æ€Žä¹ˆæ”¹ï¼Œéƒ½åªå½±å“æœ¬æŽ¥å£çš„实现,不影响调用方。
 */
public interface MesQcReportApi {
    /**
     * èŽ·å–ä¸€å¼ è´¨æ£€å•çš„å¯æŠ¥å‘Šæ•°æ®
     * <p>
     * ä¸åœ¨è¿™é‡Œåšã€Œèƒ½ä¸èƒ½å‡ºæŠ¥å‘Šã€çš„业务校验(未完成、未判定等),只如实返回数据:
     * æ ¡éªŒä¸Žæ‹’绝文案属于报告平台的口径,由调用方按 status / checkResult åˆ¤æ–­ã€‚
     *
     * @param qcType è´¨æ£€ç±»åž‹ï¼Œè§ MesQcTypeEnum:1 æ¥æ–™ 2 è¿‡ç¨‹ 3 å‡ºè´§ 4 é€€è´§
     * @param qcId   è´¨æ£€å•编号
     * @return è´¨æ£€å•数据;单据不存在时返回 null
     */
    MesQcReportRespDTO getQcReportData(Integer qcType, Long qcId);
}
yudao-module-mes-api/src/main/java/cn/iocoder/yudao/module/mes/api/qc/dto/MesQcReportItemRespDTO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,89 @@
package cn.iocoder.yudao.module.mes.api.qc.dto;
import lombok.Data;
import lombok.experimental.Accessors;
import java.math.BigDecimal;
/**
 * è´¨æ£€å•的单个检验项(一个「样品 Ã— æŒ‡æ ‡ã€ç»„合一行)
 * <p>
 * ä¸Šæ¸¸æŠŠã€Œä¸€ä¸ªæŒ‡æ ‡è¢«å½•了多次实测值」「一张单有多份样品」都摊平成多行,
 * æŠ¥å‘Šä¾§ä¸éœ€è¦çŸ¥é“样品与实测值的层级关系,逐行渲染即可。
 * <p>
 * ä½†ã€Œçˆ¶é¡¹ + å­é¡¹ã€è¿™å±‚分组关系必须带上:报告要用合并单元格把组名跨住整组,
 * è€Œæ¸²æŸ“期算不出「这一组有几行」(绑定的路径在数组上取属性会按元素 pluck,
 * `group.children.length` è¯»ä¸åˆ°ï¼‰ï¼Œåªèƒ½ç”±è¿™è¾¹é€é¡¹ç»™å‡ºã€‚
 */
@Data
@Accessors(chain = true)
public class MesQcReportItemRespDTO {
    /** è¡Œåºå·ï¼Œä»Ž 1 å¼€å§‹ï¼Œä¸Žå•据明细顺序一致 */
    private Integer index;
    /** MES æŒ‡æ ‡ç¼–号 */
    private Long indicatorId;
    private String indicatorCode;
    private String indicatorName;
    /** æ£€æµ‹è¦æ±‚(取单据行,开单时从检验模板复制) */
    private String checkMethod;
    /** æ£€æµ‹å·¥å…· */
    private String tool;
    /** æ ‡å‡†å€¼ï¼ˆæ–‡æœ¬ï¼Œå¯èƒ½ä¸æ˜¯æ•°å­—,如「澄清透明液体」) */
    private String standardValue;
    /** å®žæµ‹å€¼ï¼ˆåŽŸæ ·å­—ç¬¦ä¸²ï¼‰ */
    private String actualValue;
    /** è®¡é‡å•位名称 */
    private String unit;
    /**
     * æŠ¥å‘Šã€Œæ£€æµ‹è¦æ±‚」列的文本。
     * <p>
     * åˆ†ç»„项(组头)= æŒ‡æ ‡ä¸Šçš„公式,其余 = å•据行上的标准要求。
     * æ‰€æœ‰è¡Œéƒ½å¿…须有值(哪怕是空串):绑定的字段缺席时该单元格会渲染为空并记一条数据缺口。
     */
    private String requirement;
    /**
     * æŠ¥å‘Šã€Œå­é¡¹ã€åˆ—的文本:组内子项 = è‡ªå·±çš„名字,组头行与独立项 = ç©ºä¸²ã€‚
     * <p>
     * ç»„头行与独立项的名字走「检验项目」列,这一列留空。
     */
    private String groupChildName;
    /**
     * ã€Œæ£€éªŒé¡¹ç›®ã€åˆ—的纵向合并行数:组头行 = æ•´ç»„的行数(1 + ç»„内子项行数),组内其余行与独立项 = 1。
     * <p>
     * ç»„内第二行起这一格会被隐藏(见 {@link #groupHidden}),由组头那一行的 rowspan è·¨ä½ï¼Œ
     * æ‰€ä»¥åªæœ‰ç»„头行需要真正的行数。
     */
    private Integer groupSpan;
    /**
     * ã€Œæ£€éªŒé¡¹ç›®ã€æ ¼å­çš„隐藏值:组内第二行起 = `hidden`,组头行与独立项 = ç©ºä¸²ã€‚
     * <p>
     * ç”¨å­—符串而不是布尔是因为这个属性靠「在不在」起作用(`hidden="false"` ç…§æ ·éšè—ï¼‰ï¼Œ
     * åªæœ‰ç©ºä¸²èƒ½è¡¨è¾¾ã€Œä¸è¾“出这个属性 = è¿™ä¸€æ ¼å¯è§ã€ã€‚
     */
    private String groupHidden;
    /** è§„格上限,缺失为 null */
    private BigDecimal upperLimit;
    /** è§„格下限,缺失为 null */
    private BigDecimal lowerLimit;
    /** å€¼ç±»åž‹ï¼Œè§ MesQcResultValueTypeEnum:1 æµ®ç‚¹ 2 æ•´æ•° 3 æ–‡æœ¬ 4 å­—å…¸ 5 æ–‡ä»¶ */
    private Integer valueType;
    /** å€¼ç±»åž‹åç§°ï¼Œå¦‚「浮点」 */
    private String valueTypeName;
    /** æ ·å“å·ï¼ˆå•据未填样品号时为空) */
    private String sampleNo;
    /** å¤‡æ³¨ */
    private String remark;
}
yudao-module-mes-api/src/main/java/cn/iocoder/yudao/module/mes/api/qc/dto/MesQcReportRespDTO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,116 @@
package cn.iocoder.yudao.module.mes.api.qc.dto;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
 * è´¨æ£€å•的可报告数据(单头 + æ£€éªŒé¡¹ï¼‰
 * <p>
 * å››ç±»è´¨æ£€å•(来料/过程/出货/退货)共用本结构:各自独有的字段(供应商、客户、工单、批次)
 * éƒ½æ”¾è¿›åŒä¸€ç»„字段里,用不上的留空,调用方不必按类型分支。
 */
@Data
public class MesQcReportRespDTO {
    // ==================== å•据标识 ====================
    /** è´¨æ£€ç±»åž‹ï¼š1 æ¥æ–™ 2 è¿‡ç¨‹ 3 å‡ºè´§ 4 é€€è´§ */
    private Integer qcType;
    /** è´¨æ£€ç±»åž‹åç§°ï¼Œå¦‚「来料检验」 */
    private String qcTypeName;
    private Long qcId;
    /** å•号,如 IQC_20260917001 */
    private String qcCode;
    /** å•据名称,如「精萘来料检验-0917」 */
    private String qcName;
    /** å•据状态:见 MesQcStatusEnum */
    private Integer status;
    private String statusName;
    /**
     * æ˜¯å¦å·²å®Œæˆæ£€éªŒã€‚
     * <p>
     * ç›´æŽ¥ç»™ç»“论而不是让调用方去比 {@code status} çš„æ•°å€¼ï¼šé‚£ä¸ªæ•°å€¼çš„含义只在 MES ä¾§æˆç«‹ï¼Œ
     * è°ƒç”¨æ–¹ä¸è¯¥ä¸ºäº†åˆ¤æ–­ã€Œèƒ½ä¸èƒ½å‡ºæŠ¥å‘Šã€åŽ»è®¤è¯† MES çš„状态码表。
     */
    private boolean finished;
    /** æ£€éªŒåˆ¤å®šï¼š1 åˆæ ¼ 2 ç‰¹é‡‡ 3 ä¸åˆæ ¼é€€è´§ 4 ä¸åˆæ ¼æŠ¥åºŸï¼›æœªåˆ¤å®šä¸º null */
    private Integer checkResult;
    private String checkResultText;
    /** å¯¹åº”çš„ MES æ£€éªŒæ¨¡æ¿ç¼–号(不是报告模板) */
    private Long templateId;
    /** æ¥æºå•据类型与单号 */
    private Integer sourceDocType;
    private String sourceDocCode;
    // ==================== ç‰©æ–™ ====================
    private Long itemId;
    private String itemCode;
    private String itemName;
    private String itemSpecification;
    /** ç‰©æ–™çš„计量单位名称 */
    private String unitName;
    // ==================== å„类型独有 ====================
    /** ä¾›åº”商名称(来料检验) */
    private String vendorName;
    /** ä¾›åº”商批次(来料检验) */
    private String vendorBatch;
    /** å®¢æˆ·åç§°ï¼ˆå‡ºè´§æ£€éªŒï¼‰ */
    private String clientName;
    /** æ‰¹æ¬¡å·ï¼ˆå‡ºè´§ / é€€è´§æ£€éªŒï¼‰ */
    private String batchCode;
    /** ç”Ÿäº§å·¥å•号(过程检验) */
    private String workOrderCode;
    // ==================== æ•°é‡ä¸Žåˆ¤å®š ====================
    private BigDecimal checkQuantity;
    private BigDecimal qualifiedQuantity;
    private BigDecimal unqualifiedQuantity;
    private LocalDateTime inspectDate;
    /** æ£€éªŒå‘˜å§“名 */
    private String inspectorName;
    private String remark;
    // ==================== æ˜Žç»† ====================
    /**
     * æ£€éªŒé¡¹åˆ—表,已按「样品 Ã— æŒ‡æ ‡ã€æ‘Šå¹³ï¼Œå¹¶æŒ‰åˆ†ç»„结构排好序
     * ï¼ˆç»„头行在前、组内子项紧跟其后,组与独立项之间按指标的排序号)。
     */
    private List<MesQcReportItemRespDTO> items = new ArrayList<>();
    /**
     * åœ¨æœ¬å•里没有子项、因而没能合并成组的分组项名称(如「正丁醇含量」但只选了它自己)。
     * <p>
     * è¿™ç±»æŒ‡æ ‡æŒ‰ç‹¬ç«‹æ£€éªŒé¡¹ç…§å¸¸å‡ºåœ¨æŠ¥å‘Šé‡Œï¼Œåªæ˜¯ç»„名跨不住任何行;
     * ä½†ã€Œæœ¬è¯¥æ˜¯ç»„却没组起来」必须让调用方知道。
     */
    private List<String> childlessGroupNames = new ArrayList<>();
    /**
     * åœ¨æœ¬å•里没有自己的检验行、被补出一行来放组名与公式的分组项名称。
     * <p>
     * æ£€éªŒè¡Œæ˜¯ä»Žæ£€éªŒæ¨¡æ¿ç”Ÿæˆçš„,模板只选了子项没选组头时就会这样。
     * è¡¥ä¸€è¡Œæ˜¯ä¸ºäº†ä¸æŠŠç»„名和公式丢掉(那正是分组检验项要还原的东西),
     * ä½†ã€Œè¿™ä¸€è¡Œä¸æ˜¯å•据上的原始行」得让调用方知道。
     */
    private List<String> synthesizedGroupNames = new ArrayList<>();
    /** å•据行引用了已被删除的指标、因而跳过的条数 */
    private int missingIndicatorCount;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/api/qc/MesQcReportApiImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,632 @@
package cn.iocoder.yudao.module.mes.api.qc;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.module.crm.api.customer.CrmCustomerApi;
import cn.iocoder.yudao.module.crm.api.customer.dto.CrmCustomerRespDTO;
import cn.iocoder.yudao.module.mes.api.qc.dto.MesQcReportItemRespDTO;
import cn.iocoder.yudao.module.mes.api.qc.dto.MesQcReportRespDTO;
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.dataobject.qc.indicator.MesQcIndicatorDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.indicatorresult.MesQcIndicatorResultDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.indicatorresult.MesQcIndicatorResultDetailDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.iqc.MesQcIqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.iqc.MesQcIqcLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ipqc.MesQcIpqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ipqc.MesQcIpqcLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.oqc.MesQcOqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.oqc.MesQcOqcLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.rqc.MesQcRqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.rqc.MesQcRqcLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderDO;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcCheckResultEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcIndicatorItemTypeEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcResultValueTypeEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcStatusEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcTypeEnum;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemService;
import cn.iocoder.yudao.module.mes.service.md.unitmeasure.MesMdUnitMeasureService;
import cn.iocoder.yudao.module.mes.service.pro.workorder.MesProWorkOrderService;
import cn.iocoder.yudao.module.mes.service.qc.indicator.MesQcIndicatorService;
import cn.iocoder.yudao.module.mes.service.qc.indicatorresult.MesQcIndicatorResultService;
import cn.iocoder.yudao.module.mes.service.qc.iqc.MesQcIqcLineService;
import cn.iocoder.yudao.module.mes.service.qc.iqc.MesQcIqcService;
import cn.iocoder.yudao.module.mes.service.qc.ipqc.MesQcIpqcLineService;
import cn.iocoder.yudao.module.mes.service.qc.ipqc.MesQcIpqcService;
import cn.iocoder.yudao.module.mes.service.qc.oqc.MesQcOqcLineService;
import cn.iocoder.yudao.module.mes.service.qc.oqc.MesQcOqcService;
import cn.iocoder.yudao.module.mes.service.qc.rqc.MesQcRqcLineService;
import cn.iocoder.yudao.module.mes.service.qc.rqc.MesQcRqcService;
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.Service;
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
/**
 * MES è´¨æ£€å•数据 API å®žçŽ°ç±»
 * <p>
 * å››ç±»è´¨æ£€å•的行表结构完全一致(仅父键名不同:iqc_id / ipqc_id / oqc_id / rqc_id),
 * æ‰€ä»¥ã€Œå–行」只写四个薄适配方法,映射逻辑一份共用。
 */
@Service
@Validated
public class MesQcReportApiImpl implements MesQcReportApi {
    /**
     * ç»„名合并格里「这一格让位给上面那一行」的标记值。
     * <p>
     * æŠ¥å‘Šä¾§æŠŠå®ƒåŽŸæ ·å†™æˆ HTML çš„ {@code hidden} å±žæ€§ï¼Œæ‰€ä»¥å€¼æœ¬èº«æ˜¯ä»€ä¹ˆä¸é‡è¦ï¼Œ
     * é‡è¦çš„æ˜¯å®ƒéžç©º â€”— ç©ºä¸²åœ¨ä¸¤ä¾§æ¸²æŸ“器里都表示「不输出这个属性」,也就是这一格照常可见。
     */
    private static final String GROUP_CELL_HIDDEN = "hidden";
    @Resource
    private MesQcIqcService iqcService;
    @Resource
    private MesQcIpqcService ipqcService;
    @Resource
    private MesQcOqcService oqcService;
    @Resource
    private MesQcRqcService rqcService;
    @Resource
    private MesQcIqcLineService iqcLineService;
    @Resource
    private MesQcIpqcLineService ipqcLineService;
    @Resource
    private MesQcOqcLineService oqcLineService;
    @Resource
    private MesQcRqcLineService rqcLineService;
    @Resource
    private MesQcIndicatorResultService indicatorResultService;
    @Resource
    private MesQcIndicatorService indicatorService;
    @Resource
    private MesMdItemService itemService;
    @Resource
    private MesMdUnitMeasureService unitMeasureService;
    @Resource
    private MesProWorkOrderService workOrderService;
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private SrmSupplierApi srmSupplierApi;
    @Resource
    private CrmCustomerApi customerApi;
    @Override
    public MesQcReportRespDTO getQcReportData(Integer qcType, Long qcId) {
        if (qcType == null || qcId == null) {
            return null;
        }
        MesQcReportRespDTO resp;
        List<LineRow> lines;
        Long inspectorUserId;
        if (Objects.equals(qcType, MesQcTypeEnum.IQC.getType())) {
            MesQcIqcDO order = iqcService.getIqc(qcId);
            if (order == null) {
                return null;
            }
            resp = common(qcType, order.getId(), order.getCode(), order.getName(), order.getTemplateId(),
                    order.getSourceDocType(), order.getSourceDocCode(), order.getItemId(), order.getCheckQuantity(),
                    order.getQualifiedQuantity(), order.getUnqualifiedQuantity(), order.getCheckResult(),
                    order.getInspectDate(), order.getStatus(), order.getRemark());
            resp.setVendorBatch(defaultStr(order.getVendorBatch()));
            resp.setVendorName(supplierName(order.getVendorId()));
            lines = loadIqcLines(qcId);
            inspectorUserId = order.getInspectorUserId();
        } else if (Objects.equals(qcType, MesQcTypeEnum.IPQC.getType())) {
            MesQcIpqcDO order = ipqcService.getIpqc(qcId);
            if (order == null) {
                return null;
            }
            resp = common(qcType, order.getId(), order.getCode(), order.getName(), order.getTemplateId(),
                    order.getSourceDocType(), order.getSourceDocCode(), order.getItemId(), order.getCheckQuantity(),
                    order.getQualifiedQuantity(), order.getUnqualifiedQuantity(), order.getCheckResult(),
                    order.getInspectDate(), order.getStatus(), order.getRemark());
            resp.setWorkOrderCode(workOrderCode(order.getWorkOrderId()));
            lines = loadIpqcLines(qcId);
            inspectorUserId = order.getInspectorUserId();
        } else if (Objects.equals(qcType, MesQcTypeEnum.OQC.getType())) {
            MesQcOqcDO order = oqcService.getOqc(qcId);
            if (order == null) {
                return null;
            }
            resp = common(qcType, order.getId(), order.getCode(), order.getName(), order.getTemplateId(),
                    order.getSourceDocType(), order.getSourceDocCode(), order.getItemId(), order.getCheckQuantity(),
                    order.getQualifiedQuantity(), order.getUnqualifiedQuantity(), order.getCheckResult(),
                    order.getInspectDate(), order.getStatus(), order.getRemark());
            resp.setBatchCode(defaultStr(order.getBatchCode()));
            resp.setClientName(clientName(order.getClientId()));
            lines = loadOqcLines(qcId);
            inspectorUserId = order.getInspectorUserId();
        } else if (Objects.equals(qcType, MesQcTypeEnum.RQC.getType())) {
            MesQcRqcDO order = rqcService.getRqc(qcId);
            if (order == null) {
                return null;
            }
            resp = common(qcType, order.getId(), order.getCode(), order.getName(), order.getTemplateId(),
                    order.getSourceDocType(), order.getSourceDocCode(), order.getItemId(), order.getCheckQuantity(),
                    order.getQualifiedQuantity(), order.getUnqualifiedQuantity(), order.getCheckResult(),
                    order.getInspectDate(), order.getStatus(), order.getRemark());
            resp.setBatchCode(defaultStr(order.getBatchCode()));
            lines = loadRqcLines(qcId);
            inspectorUserId = order.getInspectorUserId();
        } else {
            throw new IllegalArgumentException("暂不支持 qcType=" + qcType);
        }
        // ç‰©æ–™ä¸Žå•位:单位优先取行上的(检验时实际用的单位),缺失才回退物料的默认单位
        MesMdItemDO item = resp.getItemId() == null ? null
                : itemService.getItemMap(Collections.singletonList(resp.getItemId())).get(resp.getItemId());
        if (item != null) {
            resp.setItemCode(defaultStr(item.getCode()));
            resp.setItemName(defaultStr(item.getName()));
            resp.setItemSpecification(defaultStr(item.getSpecification()));
        }
        Map<Long, MesMdUnitMeasureDO> unitMap = unitMapOf(lines, item);
        if (item != null) {
            MesMdUnitMeasureDO itemUnit = unitMap.get(item.getUnitMeasureId());
            resp.setUnitName(itemUnit == null ? "" : defaultStr(itemUnit.getName()));
        }
        resp.setInspectorName(userName(inspectorUserId));
        BuiltItems built = buildItems(resp.getQcId(), qcType, lines, unitMap, item);
        resp.setItems(built.items());
        resp.setChildlessGroupNames(built.childlessGroupNames());
        resp.setSynthesizedGroupNames(built.synthesizedGroupNames());
        resp.setMissingIndicatorCount(built.missingIndicatorCount());
        return resp;
    }
    /** å››ç±»å•据共有的单头字段 */
    private MesQcReportRespDTO common(Integer qcType, Long qcId, String code, String name, Long templateId,
                                      Integer sourceDocType, String sourceDocCode, Long itemId, BigDecimal checkQuantity,
                                      BigDecimal qualifiedQuantity, BigDecimal unqualifiedQuantity,
                                      Integer checkResult, LocalDateTime inspectDate, Integer status, String remark) {
        return new MesQcReportRespDTO()
                .setQcType(qcType)
                .setQcTypeName(qcTypeName(qcType))
                .setQcId(qcId)
                .setQcCode(defaultStr(code))
                .setQcName(defaultStr(name))
                .setTemplateId(templateId)
                .setSourceDocType(sourceDocType)
                .setSourceDocCode(defaultStr(sourceDocCode))
                .setItemId(itemId)
                .setCheckQuantity(checkQuantity)
                .setQualifiedQuantity(qualifiedQuantity)
                .setUnqualifiedQuantity(unqualifiedQuantity)
                .setCheckResult(checkResult)
                .setCheckResultText(checkResultName(checkResult))
                .setInspectDate(inspectDate)
                .setStatus(status)
                .setStatusName(statusName(status))
                .setFinished(Objects.equals(status, MesQcStatusEnum.FINISHED.getStatus()))
                .setRemark(defaultStr(remark));
    }
    /**
     * æ£€éªŒé¡¹ = ã€Œæ ·å“ Ã— æŒ‡æ ‡ã€ä¸€è¡Œï¼ŒæŒ‰åˆ†ç»„结构排好序。
     * <p>
     * å½’属只看指标的 {@code parent_id}:被本单里别的行用 parent_id æŒ‡è®¤çš„分组项就是组头,
     * å®ƒè‡ªå·±é‚£ä¸€è¡Œè¿žåŒæ‰€æœ‰å­é¡¹è¡Œåˆæˆä¸€ç»„,组名在报告里纵向合并。三处刻意不这么做:
     * ä¸ç”¨ {@code result_type IS NULL} åˆ¤åˆ†ç»„(方向是反的,丢掉的恰好是带公式的组名、
     * ç•™ä¸‹çš„æ°å¥½æ˜¯è¿‡ç¨‹å‚数),不靠行序(行表里组头与它的子项并不相邻),也不把组头丢掉
     * ï¼ˆç»„名与公式是分组检验项最不该丢的两样东西)。
     */
    private BuiltItems buildItems(Long qcId, Integer qcType, List<LineRow> lines,
                                  Map<Long, MesMdUnitMeasureDO> unitMap, MesMdItemDO item) {
        if (CollUtil.isEmpty(lines)) {
            return new BuiltItems(Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), 0);
        }
        Map<Long, MesQcIndicatorDO> indicatorMap = indicatorService.getIndicatorMap(
                convertSet(lines, LineRow::indicatorId));
        // æ ·å“ â†’ è¯¥æ ·å“çš„实测值明细
        Map<Long, List<MesQcIndicatorResultDetailDO>> detailMap = Collections.emptyMap();
        List<MesQcIndicatorResultDO> samples = indicatorResultService.getIndicatorResultListByQcIdAndType(qcId, qcType);
        if (CollUtil.isNotEmpty(samples)) {
            List<MesQcIndicatorResultDetailDO> details = indicatorResultService.getIndicatorResultDetailListByResultIds(
                    new ArrayList<>(convertSet(samples, MesQcIndicatorResultDO::getId)));
            detailMap = new LinkedHashMap<>();
            for (MesQcIndicatorResultDetailDO detail : details) {
                detailMap.computeIfAbsent(detail.getResultId(), k -> new ArrayList<>()).add(detail);
            }
        }
        // â‘  è¡Œ â†’ æŒ‡æ ‡ï¼›æŒ‡æ ‡è¢«åˆ æŽ‰çš„行还原不出任何东西,只计数
        List<LineRow> liveLines = new ArrayList<>(lines.size());
        List<MesQcIndicatorDO> liveIndicators = new ArrayList<>(lines.size());
        int missingIndicatorCount = 0;
        for (LineRow line : lines) {
            MesQcIndicatorDO indicator = indicatorMap.get(line.indicatorId());
            if (indicator == null) {
                missingIndicatorCount++;
                continue;
            }
            liveLines.add(line);
            liveIndicators.add(indicator);
        }
        // â‘¡ ç»„头指标可能压根没排进本单(模板只选了子项),补取出来,否则整组的组名与公式会丢
        Map<Long, MesQcIndicatorDO> allIndicators = new LinkedHashMap<>(indicatorMap);
        Set<Long> extraIds = new LinkedHashSet<>();
        for (MesQcIndicatorDO indicator : liveIndicators) {
            Long parentId = indicator.getParentId();
            if (parentId != null && parentId != 0 && !allIndicators.containsKey(parentId)) {
                extraIds.add(parentId);
            }
        }
        if (CollUtil.isNotEmpty(extraIds)) {
            allIndicators.putAll(indicatorService.getIndicatorMap(extraIds));
        }
        // â‘¢ æ¯ä¸€è¡Œå½’到「组头自己的行」或「某个组的子项行」
        Map<Long, List<Integer>> ownLinesOf = new LinkedHashMap<>();
        Map<Long, List<Integer>> childLinesOf = new LinkedHashMap<>();
        for (int i = 0; i < liveLines.size(); i++) {
            Long headId = groupHeadId(liveIndicators.get(i), allIndicators);
            if (headId == null) {
                ownLinesOf.computeIfAbsent(liveLines.get(i).indicatorId(), k -> new ArrayList<>()).add(i);
            } else {
                childLinesOf.computeIfAbsent(headId, k -> new ArrayList<>()).add(i);
            }
        }
        // â‘£ é¡¶å±‚条目 = ç»„ + ç‹¬ç«‹é¡¹ï¼›ç»„头那一行从独立项里摘出来当作组的锚点行
        List<String> childlessGroupNames = new ArrayList<>();
        List<String> synthesizedGroupNames = new ArrayList<>();
        List<Entry> entries = new ArrayList<>();
        for (Map.Entry<Long, List<Integer>> each : childLinesOf.entrySet()) {
            Long headId = each.getKey();
            MesQcIndicatorDO head = allIndicators.get(headId);
            List<Integer> children = sortByIndicatorOrder(each.getValue(), liveIndicators);
            List<Integer> own = ownLinesOf.remove(headId);
            if (CollUtil.isEmpty(own)) {
                synthesizedGroupNames.add(defaultStr(head.getName()));
            }
            entries.add(new Entry(head, own == null ? Collections.emptyList() : own, children,
                    children.get(0)));
        }
        for (Map.Entry<Long, List<Integer>> each : ownLinesOf.entrySet()) {
            MesQcIndicatorDO indicator = allIndicators.get(each.getKey());
            if (MesQcIndicatorItemTypeEnum.isGroup(indicator.getItemType())) {
                // æŒ‡æ ‡ä¸»æ•°æ®é‡Œæ˜¯åˆ†ç»„项,本单却没有属于它的子项:照常出成独立检验项,只是组不起来
                childlessGroupNames.add(defaultStr(indicator.getName()));
            }
            entries.add(new Entry(indicator, each.getValue(), Collections.emptyList(), each.getValue().get(0)));
        }
        // æŽ’序号缺失的排最后;同值保持行序(List.sort ç¨³å®šï¼Œæ¯”较键里再带上首行下标兜底)
        entries.sort(Comparator.comparingInt((Entry entry) -> sortKey(entry.indicator()))
                .thenComparingInt(Entry::firstLine));
        // â‘¤ é€æ¡ç›®äº§å‡ºè¡Œï¼Œå¹¶æŠŠåˆ†ç»„信息下发到每一行
        List<MesQcReportItemRespDTO> items = new ArrayList<>();
        int index = 0;
        for (Entry entry : entries) {
            List<MesQcReportItemRespDTO> groupRows = new ArrayList<>();
            for (Integer lineIndex : entry.ownLines()) {
                groupRows.addAll(expandRows(liveIndicators.get(lineIndex), liveLines.get(lineIndex),
                        samples, detailMap, unitMap, item));
            }
            boolean grouped = CollUtil.isNotEmpty(entry.childLines());
            if (grouped && groupRows.isEmpty()) {
                // ç»„头没有自己的行:补一行,否则组名与公式无处可放
                groupRows.add(toItem(entry.indicator(), emptyLine(entry.indicator()), "", "", "", unitMap, item));
            }
            int anchorCount = groupRows.size();
            List<MesQcReportItemRespDTO> entryRows = new ArrayList<>(groupRows);
            for (Integer lineIndex : entry.childLines()) {
                for (MesQcReportItemRespDTO row : expandRows(liveIndicators.get(lineIndex), liveLines.get(lineIndex),
                        samples, detailMap, unitMap, item)) {
                    row.setGroupChildName(defaultStr(liveIndicators.get(lineIndex).getName()));
                    entryRows.add(row);
                }
            }
            int span = grouped ? entryRows.size() : 1;
            for (int i = 0; i < entryRows.size(); i++) {
                MesQcReportItemRespDTO row = entryRows.get(i);
                row.setIndex(++index);
                // ç»„名的合并格只挂在组的第一行,组内其余行整格隐藏、让位给它
                row.setGroupSpan(i == 0 ? span : 1);
                row.setGroupHidden(i == 0 ? "" : GROUP_CELL_HIDDEN);
                row.setRequirement(i < anchorCount
                        ? anchorRequirement(entry.indicator(), row)
                        : defaultStr(row.getStandardValue()));
                items.add(row);
            }
        }
        return new BuiltItems(items, childlessGroupNames, synthesizedGroupNames, missingIndicatorCount);
    }
    /** è£…配结果:检验项本身,以及没能成组、被补了行而必须让调用方知道的那些 */
    private record BuiltItems(List<MesQcReportItemRespDTO> items, List<String> childlessGroupNames,
                              List<String> synthesizedGroupNames, int missingIndicatorCount) {
    }
    /**
     * ä¸€ä¸ªé¡¶å±‚条目:一个分组(组头指标 + å®ƒçš„子项行),或一个独立检验项。
     *
     * @param indicator  ç»„头的指标 / ç‹¬ç«‹é¡¹çš„æŒ‡æ ‡
     * @param ownLines   ç»„头自己那些行(独立项时就是它全部的行)在行表里的下标
     * @param childLines ç»„内子项行的下标,独立项恒为空
     * @param firstLine  æ¡ç›®ç¬¬ä¸€è¡Œåœ¨è¡Œè¡¨é‡Œçš„下标,排序号相同时靠它保持行序
     */
    private record Entry(MesQcIndicatorDO indicator, List<Integer> ownLines, List<Integer> childLines,
                         int firstLine) {
    }
    /**
     * è¿™ä¸€è¡Œçš„组头指标编号:{@code parent_id} æŒ‡å‘一个「分组项」才算数。
     * <p>
     * æŒ‡å‘录入项属于指标主数据配置有问题,不能拿它去合并单元格;
     * æŒ‡å‘的指标已被删除同样不成组。
     */
    private static Long groupHeadId(MesQcIndicatorDO indicator, Map<Long, MesQcIndicatorDO> allIndicators) {
        Long parentId = indicator.getParentId();
        if (parentId == null || parentId == 0) {
            return null;
        }
        MesQcIndicatorDO head = allIndicators.get(parentId);
        return head != null && MesQcIndicatorItemTypeEnum.isGroup(head.getItemType()) ? parentId : null;
    }
    /** ç»„头的「检测要求」放公式(分组项只有公式、没有规格),没配公式就照常放标准要求 */
    private static String anchorRequirement(MesQcIndicatorDO head, MesQcReportItemRespDTO row) {
        String formula = defaultStr(head.getFormulaText());
        return formula.isEmpty() ? defaultStr(row.getStandardValue()) : formula;
    }
    /** ç»„头没排进本单时补出来的那一行:只有指标本身,没有单据行 */
    private static LineRow emptyLine(MesQcIndicatorDO head) {
        return new LineRow(head.getId(), head.getTool(), null, null, null, null, null, null);
    }
    /**
     * ä¸€ä¸ªæ£€éªŒé¡¹æ‘Šå¹³æˆè‹¥å¹²è¡Œï¼šå¤šæ ·å“ã€ä¸€ä¸ªæŒ‡æ ‡è¢«å½•了多次实测值都会摊平。
     * ä¸€è¡Œéƒ½æ²¡æœ‰ï¼ˆå°šæœªå½•入实测值)时仍出一行,避免报告漏项。
     */
    private List<MesQcReportItemRespDTO> expandRows(MesQcIndicatorDO indicator, LineRow line,
                                                    List<MesQcIndicatorResultDO> samples,
                                                    Map<Long, List<MesQcIndicatorResultDetailDO>> detailMap,
                                                    Map<Long, MesMdUnitMeasureDO> unitMap, MesMdItemDO item) {
        List<MesQcReportItemRespDTO> rows = new ArrayList<>();
        for (MesQcIndicatorResultDO sample : samples) {
            for (MesQcIndicatorResultDetailDO detail : detailMap.getOrDefault(sample.getId(),
                    Collections.emptyList())) {
                if (Objects.equals(detail.getIndicatorId(), line.indicatorId())) {
                    rows.add(toItem(indicator, line, sample.getCode(), detail.getValue(), detail.getRemark(),
                            unitMap, item));
                }
            }
        }
        if (rows.isEmpty()) {
            rows.add(toItem(indicator, line, "", "", "", unitMap, item));
        }
        return rows;
    }
    /** æŽ’序键:指标没设排序号的一律排最后 */
    private static int sortKey(MesQcIndicatorDO indicator) {
        Integer sortOrder = indicator.getSortOrder();
        return sortOrder == null ? Integer.MAX_VALUE : sortOrder;
    }
    /** æŒ‰å„行的指标排序号排,同值保持行序 */
    private static List<Integer> sortByIndicatorOrder(List<Integer> lineIndexes,
                                                      List<MesQcIndicatorDO> indicators) {
        List<Integer> sorted = new ArrayList<>(lineIndexes);
        sorted.sort(Comparator.comparingInt((Integer i) -> sortKey(indicators.get(i)))
                .thenComparingInt(Integer::intValue));
        return sorted;
    }
    private MesQcReportItemRespDTO toItem(MesQcIndicatorDO indicator, LineRow line, String sampleCode, String value,
                                          String detailRemark, Map<Long, MesMdUnitMeasureDO> unitMap,
                                          MesMdItemDO item) {
        MesMdUnitMeasureDO unit = unitMap.get(line.unitMeasureId());
        if (unit == null && item != null) {
            unit = unitMap.get(item.getUnitMeasureId());
        }
        return new MesQcReportItemRespDTO()
                .setIndicatorId(indicator.getId())
                .setIndicatorCode(defaultStr(indicator.getCode()))
                .setIndicatorName(defaultStr(indicator.getName()))
                .setCheckMethod(defaultStr(line.checkMethod()))
                .setTool(defaultStr(line.tool()))
                .setStandardValue(plain(line.standardValue()))
                .setActualValue(defaultStr(value))
                .setUnit(unit == null ? "" : defaultStr(unit.getName()))
                .setUpperLimit(line.maxThreshold())
                .setLowerLimit(line.minThreshold())
                .setValueType(indicator.getResultType())
                .setValueTypeName(valueTypeName(indicator.getResultType()))
                .setSampleNo(defaultStr(sampleCode))
                // é»˜è®¤ã€Œä¸æ˜¯å­é¡¹ã€ä¸å‚与合并」;真子项由装配那一步改写
                .setGroupChildName("")
                .setGroupSpan(1)
                .setGroupHidden("")
                .setRemark(mergeRemark(line.remark(), detailRemark));
    }
    // ==================== å–行(四类同构) ====================
    private List<LineRow> loadIqcLines(Long qcId) {
        List<MesQcIqcLineDO> lines = iqcLineService.getIqcLineListByIqcId(qcId);
        if (CollUtil.isEmpty(lines)) {
            return Collections.emptyList();
        }
        List<LineRow> rows = new ArrayList<>(lines.size());
        for (MesQcIqcLineDO l : lines) {
            rows.add(new LineRow(l.getIndicatorId(), l.getTool(), l.getCheckMethod(), l.getStandardValue(),
                    l.getUnitMeasureId(), l.getMaxThreshold(), l.getMinThreshold(), l.getRemark()));
        }
        return rows;
    }
    private List<LineRow> loadIpqcLines(Long qcId) {
        List<MesQcIpqcLineDO> lines = ipqcLineService.getIpqcLineListByIpqcId(qcId);
        if (CollUtil.isEmpty(lines)) {
            return Collections.emptyList();
        }
        List<LineRow> rows = new ArrayList<>(lines.size());
        for (MesQcIpqcLineDO l : lines) {
            rows.add(new LineRow(l.getIndicatorId(), l.getTool(), l.getCheckMethod(), l.getStandardValue(),
                    l.getUnitMeasureId(), l.getMaxThreshold(), l.getMinThreshold(), l.getRemark()));
        }
        return rows;
    }
    private List<LineRow> loadOqcLines(Long qcId) {
        List<MesQcOqcLineDO> lines = oqcLineService.getOqcLineListByOqcId(qcId);
        if (CollUtil.isEmpty(lines)) {
            return Collections.emptyList();
        }
        List<LineRow> rows = new ArrayList<>(lines.size());
        for (MesQcOqcLineDO l : lines) {
            rows.add(new LineRow(l.getIndicatorId(), l.getTool(), l.getCheckMethod(), l.getStandardValue(),
                    l.getUnitMeasureId(), l.getMaxThreshold(), l.getMinThreshold(), l.getRemark()));
        }
        return rows;
    }
    private List<LineRow> loadRqcLines(Long qcId) {
        List<MesQcRqcLineDO> lines = rqcLineService.getRqcLineListByRqcId(qcId);
        if (CollUtil.isEmpty(lines)) {
            return Collections.emptyList();
        }
        List<LineRow> rows = new ArrayList<>(lines.size());
        for (MesQcRqcLineDO l : lines) {
            rows.add(new LineRow(l.getIndicatorId(), l.getTool(), l.getCheckMethod(), l.getStandardValue(),
                    l.getUnitMeasureId(), l.getMaxThreshold(), l.getMinThreshold(), l.getRemark()));
        }
        return rows;
    }
    // ==================== å…³è”名称 ====================
    private Map<Long, MesMdUnitMeasureDO> unitMapOf(List<LineRow> lines, MesMdItemDO item) {
        List<Long> unitIds = new ArrayList<>();
        for (LineRow line : lines) {
            if (line.unitMeasureId() != null) {
                unitIds.add(line.unitMeasureId());
            }
        }
        if (item != null && item.getUnitMeasureId() != null) {
            unitIds.add(item.getUnitMeasureId());
        }
        return unitIds.isEmpty() ? Collections.emptyMap() : unitMeasureService.getUnitMeasureMap(unitIds);
    }
    private String supplierName(Long vendorId) {
        if (vendorId == null) {
            return "";
        }
        List<SrmSupplierRespDTO> suppliers = srmSupplierApi.getSupplierList(Collections.singleton(vendorId))
                .getCheckedData();
        return CollUtil.isEmpty(suppliers) ? "" : defaultStr(suppliers.get(0).getName());
    }
    private String clientName(Long clientId) {
        if (clientId == null) {
            return "";
        }
        CrmCustomerRespDTO client = customerApi.getCustomerMap(Collections.singleton(clientId)).get(clientId);
        return client == null ? "" : defaultStr(client.getName());
    }
    private String workOrderCode(Long workOrderId) {
        if (workOrderId == null) {
            return "";
        }
        MesProWorkOrderDO workOrder = workOrderService.getWorkOrder(workOrderId);
        return workOrder == null ? "" : defaultStr(workOrder.getCode());
    }
    private String userName(Long userId) {
        if (userId == null) {
            return "";
        }
        AdminUserRespDTO user = adminUserApi.getUserMap(Collections.singleton(userId)).get(userId);
        return user == null ? "" : defaultStr(user.getNickname());
    }
    // ==================== å°å·¥å…· ====================
    /** åŽ»æŽ‰æ— æ„ä¹‰çš„å°æ•°å°¾é›¶ï¼Œé¿å…æŠ¥å‘Šé‡Œå‡ºçŽ° 5.0000 è¿™ç§ç”±æ•°æ®åº“精度带出来的尾巴 */
    private static String plain(BigDecimal value) {
        return value == null ? "" : value.stripTrailingZeros().toPlainString();
    }
    private static String mergeRemark(String lineRemark, String detailRemark) {
        if (StrUtil.isBlank(lineRemark)) {
            return defaultStr(detailRemark);
        }
        return StrUtil.isBlank(detailRemark) ? lineRemark : lineRemark + ";" + detailRemark;
    }
    private static String defaultStr(String value) {
        return value == null ? "" : value;
    }
    private static String qcTypeName(Integer qcType) {
        for (MesQcTypeEnum e : MesQcTypeEnum.values()) {
            if (Objects.equals(e.getType(), qcType)) {
                return e.getName();
            }
        }
        return "";
    }
    private static String statusName(Integer status) {
        for (MesQcStatusEnum e : MesQcStatusEnum.values()) {
            if (Objects.equals(e.getStatus(), status)) {
                return e.getName();
            }
        }
        return "";
    }
    private static String checkResultName(Integer checkResult) {
        for (MesQcCheckResultEnum e : MesQcCheckResultEnum.values()) {
            if (Objects.equals(e.getType(), checkResult)) {
                return e.getName();
            }
        }
        return "";
    }
    private static String valueTypeName(Integer resultType) {
        for (MesQcResultValueTypeEnum e : MesQcResultValueTypeEnum.values()) {
            if (Objects.equals(e.getType(), resultType)) {
                return e.getName();
            }
        }
        return "";
    }
    /** å››ç±»è¡Œè¡¨å­—段完全一致,抽成统一形态后共用映射 */
    private record LineRow(Long indicatorId, String tool, String checkMethod, BigDecimal standardValue,
                           Long unitMeasureId, BigDecimal maxThreshold, BigDecimal minThreshold, String remark) {
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/indicator/MesQcIndicatorController.java
@@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.mes.controller.admin.qc.indicator;
import cn.hutool.core.collection.CollUtil;
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;
@@ -23,9 +24,13 @@
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.EXPORT;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
@Tag(name = "管理后台 - MES è´¨æ£€æŒ‡æ ‡")
@RestController
@@ -74,7 +79,17 @@
    @PreAuthorize("@ss.hasPermission('mes:qc-indicator:query')")
    public CommonResult<PageResult<MesQcIndicatorRespVO>> getIndicatorPage(@Valid MesQcIndicatorPageReqVO pageReqVO) {
        PageResult<MesQcIndicatorDO> pageResult = indicatorService.getIndicatorPage(pageReqVO);
        return success(BeanUtils.toBean(pageResult, MesQcIndicatorRespVO.class));
        PageResult<MesQcIndicatorRespVO> voPageResult = BeanUtils.toBean(pageResult, MesQcIndicatorRespVO.class);
        fillParentName(voPageResult.getList());
        return success(voPageResult);
    }
    @GetMapping("/group-list")
    @Operation(summary = "获得分组项精简列表", description = "只包含分组项,主要用于「所属分组」的下拉选项")
    @PreAuthorize("@ss.hasPermission('mes:qc-indicator:query')")
    public CommonResult<List<MesQcIndicatorRespVO>> getIndicatorGroupList() {
        List<MesQcIndicatorDO> list = indicatorService.getIndicatorGroupList();
        return success(BeanUtils.toBean(list, MesQcIndicatorRespVO.class));
    }
    @GetMapping("/export-excel")
@@ -85,9 +100,25 @@
                                     HttpServletResponse response) throws IOException {
        pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE);
        List<MesQcIndicatorDO> list = indicatorService.getIndicatorPage(pageReqVO).getList();
        // ç»„装导出 VO(补齐所属分组名称)
        List<MesQcIndicatorRespVO> excelList = BeanUtils.toBean(list, MesQcIndicatorRespVO.class);
        fillParentName(excelList);
        // å¯¼å‡º Excel
        ExcelUtils.write(response, "质检指标.xls", "数据", MesQcIndicatorRespVO.class,
                BeanUtils.toBean(list, MesQcIndicatorRespVO.class));
        ExcelUtils.write(response, "质检指标.xls", "数据", MesQcIndicatorRespVO.class, excelList);
    }
    /**
     * å›žå¡«ã€Œæ‰€å±žåˆ†ç»„」名称。父指标可能不在当前页(甚至不在筛选结果内),所以单独按 id æ‰¹é‡å–一次
     */
    private void fillParentName(List<MesQcIndicatorRespVO> list) {
        Set<Long> parentIds = convertSet(list, MesQcIndicatorRespVO::getParentId,
                vo -> vo.getParentId() != null && !MesQcIndicatorDO.PARENT_ID_ROOT.equals(vo.getParentId()));
        if (CollUtil.isEmpty(parentIds)) {
            return;
        }
        Map<Long, String> parentNameMap = convertMap(indicatorService.getIndicatorList(parentIds),
                MesQcIndicatorDO::getId, MesQcIndicatorDO::getName);
        list.forEach(vo -> vo.setParentName(parentNameMap.get(vo.getParentId())));
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/indicator/vo/MesQcIndicatorPageReqVO.java
@@ -24,4 +24,7 @@
    @Schema(description = "结果值类型", example = "1")
    private Integer resultType;
    @Schema(description = "条目类型:1=录入项,2=分组项", example = "2")
    private Integer itemType;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/indicator/vo/MesQcIndicatorRespVO.java
@@ -36,7 +36,25 @@
    @ExcelProperty("检测工具")
    private String tool;
    @Schema(description = "结果值类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @Schema(description = "条目类型:1=录入项,2=分组项", example = "1")
    private Integer itemType;
    @Schema(description = "父指标编号(0=顶级)", example = "0")
    private Long parentId;
    @Schema(description = "父指标名称(即所属分组名,顶级时为空)", example = "水分")
    @ExcelProperty("所属分组")
    private String parentName;
    @Schema(description = "公式展示文本(分组项的「检测要求」就是这条公式)", example = "w = (m1 - m0) / m Ã— 100%")
    @ExcelProperty("公式")
    private String formulaText;
    @Schema(description = "同级排序号", example = "10")
    @ExcelProperty("排序号")
    private Integer sortOrder;
    @Schema(description = "结果值类型。分组项没有结果值,可为空", example = "1")
    @ExcelProperty(value = "结果值类型", converter = DictConvert.class)
    @DictFormat(DictTypeConstants.MES_QC_RESULT_TYPE)
    private Integer resultType;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/indicator/vo/MesQcIndicatorSaveReqVO.java
@@ -27,8 +27,19 @@
    @Schema(description = "检测工具", example = "卡尺")
    private String tool;
    @Schema(description = "结果值类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "结果值类型不能为空")
    @Schema(description = "条目类型:1=录入项,2=分组项", example = "1")
    private Integer itemType;
    @Schema(description = "父指标编号(0=顶级)", example = "0")
    private Long parentId;
    @Schema(description = "公式展示文本(分组项的「检测要求」就是这条公式)", example = "w = (m1 - m0) / m Ã— 100%")
    private String formulaText;
    @Schema(description = "同级排序号,留空则自动排到同级末尾", example = "10")
    private Integer sortOrder;
    @Schema(description = "结果值类型。分组项没有结果值,可不填", example = "1")
    private Integer resultType;
    @Schema(description = "结果值属性", example = "IMG")
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/qc/indicator/MesQcIndicatorDO.java
@@ -1,7 +1,9 @@
package cn.iocoder.yudao.module.mes.dal.dataobject.qc.indicator;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
@@ -22,6 +24,8 @@
@AllArgsConstructor
public class MesQcIndicatorDO extends BaseDO {
    public static final Long PARENT_ID_ROOT = 0L;
    /**
     * ç¼–号
     */
@@ -35,6 +39,35 @@
     * æ£€æµ‹é¡¹åç§°
     */
    private String name;
    /**
     * çˆ¶æŒ‡æ ‡ç¼–号(0=顶级)
     *
     * æŠ¥å‘Šä¾§é å®ƒè¿˜åŽŸã€Œç»„ååˆå¹¶ã€å­é¡¹åˆ†è¡Œã€çš„ä¸¤å±‚ç»“æž„ â€”— å½’属只看这个字段,
     * ä¸çœ‹ {@link #type}、也不看行序(行表里父项那一行与它的子项并不相邻)。
     */
    private Long parentId;
    /**
     * æ¡ç›®ç±»åž‹ï¼š1=录入项,2=分组项
     *
     * åªç”¨æ¥åˆ¤æ–­ã€Œè¿™ä¸ªæŒ‡æ ‡å¤Ÿä¸å¤Ÿæ ¼å½“组头」,归属本身仍看 {@link #parentId}
     */
    private Integer itemType;
    /**
     * å…¬å¼å±•示文本(打印报告用,不参与计算)
     *
     * åˆ†ç»„项没有规格上下限,它的「检测要求」就是这条公式
     */
    private String formulaText;
    /**
     * å•位文本(如 kg/m³、/%,打印展示用)
     */
    private String unitText;
    /**
     * åŒçº§æŽ’序号
     *
     * æŠ¥å‘Šçš„æ£€éªŒé¡¹é¡ºåºæŒ‰å®ƒæŽ’:分组项与独立项同属「同级」占一个位置,组内子项各自再排
     */
    private Integer sortOrder;
    /**
     * æ£€æµ‹é¡¹ç±»åž‹
     *
@@ -50,14 +83,21 @@
     *
     * å­—å…¸ {@link DictTypeConstants#MES_QC_RESULT_TYPE}
     * æžšä¸¾ {@link cn.iocoder.yudao.module.mes.enums.qc.MesQcResultValueTypeEnum}
     *
     * updateStrategy=ALWAYS:录入项改成分组项时要能把这个值写回 NULL,
     * é»˜è®¤çš„ NOT_NULL ç­–略会把 null å­—段从 UPDATE é‡Œå‰”除,导致旧的结果值残留
     */
    @TableField(updateStrategy = FieldStrategy.ALWAYS)
    private Integer resultType;
    /**
     * ç»“果值属性
     *
     * 1. FILE æ—¶ï¼šå­˜ IMG/FILE
     * 2. DICT æ—¶ï¼šå­˜å­—典类型名
     *
     * updateStrategy åŒ {@link #resultType}
     */
    @TableField(updateStrategy = FieldStrategy.ALWAYS)
    private String resultSpecification;
    /**
     * å¤‡æ³¨
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/qc/indicator/MesQcIndicatorMapper.java
@@ -8,6 +8,7 @@
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
import java.util.Objects;
/**
 * MES è´¨æ£€æŒ‡æ ‡ Mapper
@@ -23,6 +24,7 @@
                .likeIfPresent(MesQcIndicatorDO::getName, reqVO.getName())
                .eqIfPresent(MesQcIndicatorDO::getType, reqVO.getType())
                .eqIfPresent(MesQcIndicatorDO::getResultType, reqVO.getResultType())
                .eqIfPresent(MesQcIndicatorDO::getItemType, reqVO.getItemType())
                .orderByDesc(MesQcIndicatorDO::getId));
    }
@@ -39,4 +41,34 @@
                .orderByDesc(MesQcIndicatorDO::getId));
    }
    default List<MesQcIndicatorDO> selectListByItemType(Integer itemType) {
        return selectList(new LambdaQueryWrapperX<MesQcIndicatorDO>()
                .eq(MesQcIndicatorDO::getItemType, itemType)
                .orderByAsc(MesQcIndicatorDO::getSortOrder)
                .orderByAsc(MesQcIndicatorDO::getId));
    }
    default List<MesQcIndicatorDO> selectListByParentId(Long parentId) {
        return selectList(new LambdaQueryWrapperX<MesQcIndicatorDO>()
                .eq(MesQcIndicatorDO::getParentId, parentId)
                .orderByAsc(MesQcIndicatorDO::getSortOrder)
                .orderByAsc(MesQcIndicatorDO::getId));
    }
    default Long selectCountByParentId(Long parentId) {
        return selectCount(new LambdaQueryWrapperX<MesQcIndicatorDO>()
                .eq(MesQcIndicatorDO::getParentId, parentId));
    }
    /**
     * å–同级最大的排序号;同级没有记录时返回 null
     */
    default Integer selectMaxSortOrder(Long parentId) {
        return selectListByParentId(parentId).stream()
                .map(MesQcIndicatorDO::getSortOrder)
                .filter(Objects::nonNull)
                .max(Integer::compareTo)
                .orElse(null);
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java
@@ -372,6 +372,12 @@
    ErrorCode QC_INDICATOR_CODE_DUPLICATE = new ErrorCode(1_040_601_001, "质检指标编码已存在");
    ErrorCode QC_INDICATOR_NAME_DUPLICATE = new ErrorCode(1_040_601_002, "质检指标名称已存在");
    ErrorCode QC_INDICATOR_RESULT_SPECIFICATION_REQUIRED = new ErrorCode(1_040_601_003, "结果值属性不能为空");
    ErrorCode QC_INDICATOR_PARENT_NOT_EXISTS = new ErrorCode(1_040_601_004, "父指标不存在");
    ErrorCode QC_INDICATOR_PARENT_NOT_GROUP = new ErrorCode(1_040_601_005, "父指标不是分组项");
    ErrorCode QC_INDICATOR_PARENT_SELF = new ErrorCode(1_040_601_006, "父指标不能是自己");
    ErrorCode QC_INDICATOR_GROUP_CANNOT_NEST = new ErrorCode(1_040_601_007, "分组项不能挂在其它指标下");
    ErrorCode QC_INDICATOR_HAS_CHILDREN = new ErrorCode(1_040_601_008, "该指标下已有子项");
    ErrorCode QC_INDICATOR_RESULT_TYPE_REQUIRED = new ErrorCode(1_040_601_009, "录入项的结果值类型不能为空");
    // ========== MES è´¨é‡ç®¡ç†-缺陷类型(1-040-602-000) ==========
    ErrorCode QC_DEFECT_NOT_EXISTS = new ErrorCode(1_040_602_000, "缺陷类型不存在");
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/qc/MesQcIndicatorItemTypeEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,44 @@
package cn.iocoder.yudao.module.mes.enums.qc;
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
import java.util.Objects;
/**
 * MES è´¨æ£€æŒ‡æ ‡æ¡ç›®ç±»åž‹æžšä¸¾
 * <p>
 * ä¸€ä¸ªåˆ†ç»„的组名与公式挂在「分组项」上,实测值挂在它下面的「录入项」上。
 *
 * @author è¶…级管理员
 */
@Getter
@AllArgsConstructor
public enum MesQcIndicatorItemTypeEnum implements ArrayValuable<Integer> {
    ENTRY(1, "录入项"),
    GROUP(2, "分组项");
    public static final Integer[] ARRAYS = Arrays.stream(values()).map(MesQcIndicatorItemTypeEnum::getType).toArray(Integer[]::new);
    /**
     * ç±»åž‹å€¼
     */
    private final Integer type;
    /**
     * ç±»åž‹å
     */
    private final String name;
    @Override
    public Integer[] array() {
        return ARRAYS;
    }
    public static boolean isGroup(Integer type) {
        return Objects.equals(GROUP.getType(), type);
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/indicator/MesQcIndicatorService.java
@@ -65,6 +65,13 @@
    List<MesQcIndicatorDO> getIndicatorList();
    /**
     * èŽ·å¾—åˆ†ç»„é¡¹åˆ—è¡¨ï¼ˆæ¡ç›®ç±»åž‹ä¸ºåˆ†ç»„é¡¹ï¼‰ï¼Œä¸»è¦ç”¨äºŽã€Œæ‰€å±žåˆ†ç»„ã€ä¸‹æ‹‰é€‰é¡¹
     *
     * @return åˆ†ç»„项列表
     */
    List<MesQcIndicatorDO> getIndicatorGroupList();
    /**
     * èŽ·å¾—è´¨æ£€æŒ‡æ ‡åˆ—è¡¨
     *
     * @param ids ç¼–号数组
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/indicator/MesQcIndicatorServiceImpl.java
@@ -3,6 +3,7 @@
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.framework.common.util.object.ObjectUtils;
@@ -10,6 +11,7 @@
import cn.iocoder.yudao.module.mes.controller.admin.qc.indicator.vo.MesQcIndicatorSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.indicator.MesQcIndicatorDO;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.indicator.MesQcIndicatorMapper;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcIndicatorItemTypeEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcResultValueTypeEnum;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
@@ -19,8 +21,10 @@
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.*;
@@ -43,6 +47,7 @@
        // æ’å…¥
        MesQcIndicatorDO indicator = BeanUtils.toBean(createReqVO, MesQcIndicatorDO.class);
        normalizeHierarchy(indicator);
        indicatorMapper.insert(indicator);
        return indicator.getId();
    }
@@ -56,6 +61,7 @@
        // æ›´æ–°
        MesQcIndicatorDO updateObj = BeanUtils.toBean(updateReqVO, MesQcIndicatorDO.class);
        normalizeHierarchy(updateObj);
        indicatorMapper.updateById(updateObj);
    }
@@ -63,6 +69,11 @@
    public void deleteIndicator(Long id) {
        // æ ¡éªŒå­˜åœ¨
        validateIndicatorExists(id);
        // çº§è”删除子项:指标只支持两层,挂在它下面的都是它的明细项,父删了子无处可挂
        List<MesQcIndicatorDO> children = indicatorMapper.selectListByParentId(id);
        if (CollUtil.isNotEmpty(children)) {
            indicatorMapper.deleteByIds(convertList(children, MesQcIndicatorDO::getId));
        }
        // åˆ é™¤
        indicatorMapper.deleteById(id);
    }
@@ -72,8 +83,100 @@
        validateIndicatorCodeUnique(id, saveReqVO.getCode());
        // æ ¡éªŒåç§°å”¯ä¸€
        validateIndicatorNameUnique(id, saveReqVO.getName());
        // æ ¡éªŒå±‚级归属(只允许两层)
        validateHierarchy(id, saveReqVO);
        // æ ¡éªŒç»“果值属性
        validateResultSpecification(saveReqVO.getResultType(), saveReqVO.getResultSpecification());
        validateResultSpecification(saveReqVO.getItemType(), saveReqVO.getResultType(), saveReqVO.getResultSpecification());
    }
    /**
     * æ ¡éªŒå±‚级归属。只允许两层:分组项必须顶级,录入项可挂到分组项下。
     * <p>
     * ã€Œçˆ¶å¿…须是分组项」这条本身就掐死了第三层,所以不再单独写一条「父必须顶级」的校验。
     * æŒ‰ä¸šåŠ¡ä¼˜å…ˆçº§åªæŠ›ç¬¬ä¸€å¤„ä¸æ»¡è¶³ï¼Œé¿å…ä¸€æ¬¡æŠ›å¤šæ¡è®©ç”¨æˆ·æŠ“ä¸ä½é‡ç‚¹ã€‚
     */
    private void validateHierarchy(Long id, MesQcIndicatorSaveReqVO reqVO) {
        Long parentId = ObjectUtils.defaultIfNull(reqVO.getParentId(), MesQcIndicatorDO.PARENT_ID_ROOT);
        Integer itemType = ObjectUtils.defaultIfNull(reqVO.getItemType(), MesQcIndicatorItemTypeEnum.ENTRY.getType());
        // 1. åˆ†ç»„项只能顶级
        if (MesQcIndicatorItemTypeEnum.isGroup(itemType) && !MesQcIndicatorDO.PARENT_ID_ROOT.equals(parentId)) {
            throw new ServiceException(QC_INDICATOR_GROUP_CANNOT_NEST.getCode(),
                    String.format("分组项[%s]必须作为顶级指标,不能挂到其它指标下(当前选择了父指标[%s])。"
                                    + "如需调整层级,请把「条目类型」改为录入项后再选择所属分组。",
                            defaultStr(reqVO.getName()), nameOf(parentId)));
        }
        // 2. çˆ¶æŒ‡æ ‡å¿…须存在且是分组项
        if (!MesQcIndicatorDO.PARENT_ID_ROOT.equals(parentId)) {
            if (Objects.equals(id, parentId)) {
                throw exception(QC_INDICATOR_PARENT_SELF);
            }
            MesQcIndicatorDO parent = indicatorMapper.selectById(parentId);
            if (parent == null) {
                throw new ServiceException(QC_INDICATOR_PARENT_NOT_EXISTS.getCode(),
                        String.format("所属分组不存在(指标编号 %d æŸ¥æ— æ­¤æŒ‡æ ‡ï¼Œå¯èƒ½å·²è¢«åˆ é™¤ï¼‰ã€‚请重新选择所属分组,或清空该项作为顶级指标。",
                                parentId));
            }
            if (!MesQcIndicatorItemTypeEnum.isGroup(parent.getItemType())) {
                throw new ServiceException(QC_INDICATOR_PARENT_NOT_GROUP.getCode(),
                        String.format("所选父指标[%s]不是分组项(当前条目类型为[%s]),不能作为所属分组。"
                                        + "请改选一个分组项,或先在指标列表把[%s]的条目类型改为分组项。",
                                defaultStr(parent.getName()), itemTypeNameOf(parent.getItemType()), defaultStr(parent.getName())));
            }
        }
        // 3. å½•入项必须有结果值类型(分组项没有结果值,现网分组行 result_type å°±æ˜¯ NULL)
        if (!MesQcIndicatorItemTypeEnum.isGroup(itemType) && reqVO.getResultType() == null) {
            throw exception(QC_INDICATOR_RESULT_TYPE_REQUIRED);
        }
        // 4. è‡ªå·±å·²ç»æœ‰å­é¡¹æ—¶ï¼Œä¸èƒ½å†è¢«åˆ«äººæ”¶ç¼–、也不能反过来变成子项
        if (id == null) {
            return;
        }
        Long childCount = indicatorMapper.selectCountByParentId(id);
        if (childCount <= 0) {
            return;
        }
        if (!MesQcIndicatorDO.PARENT_ID_ROOT.equals(parentId)) {
            throw new ServiceException(QC_INDICATOR_HAS_CHILDREN.getCode(),
                    String.format("指标[%s]下已挂有 %d ä¸ªå½•入项,不能再挂到其它分组下(指标只支持两层)。"
                                    + "请先删除或移走这 %d ä¸ªå­é¡¹ï¼Œå†ä¿®æ”¹å®ƒçš„æ‰€å±žåˆ†ç»„。",
                            defaultStr(reqVO.getName()), childCount, childCount));
        }
        if (!MesQcIndicatorItemTypeEnum.isGroup(itemType)) {
            throw new ServiceException(QC_INDICATOR_HAS_CHILDREN.getCode(),
                    String.format("指标[%s]下已挂有 %d ä¸ªå½•入项,不能再把它的条目类型改为录入项,否则这些子项会失去所属分组。"
                                    + "请先删除或移走这 %d ä¸ªå­é¡¹ï¼Œå†ä¿®æ”¹æ¡ç›®ç±»åž‹ã€‚",
                            defaultStr(reqVO.getName()), childCount, childCount));
        }
    }
    /**
     * è½åº“前补齐层级字段:分组项清空结果值(现网分组行没有结果值),排序号留空则排到同级末尾
     */
    private void normalizeHierarchy(MesQcIndicatorDO indicator) {
        indicator.setParentId(ObjectUtils.defaultIfNull(indicator.getParentId(), MesQcIndicatorDO.PARENT_ID_ROOT));
        indicator.setItemType(ObjectUtils.defaultIfNull(indicator.getItemType(), MesQcIndicatorItemTypeEnum.ENTRY.getType()));
        if (MesQcIndicatorItemTypeEnum.isGroup(indicator.getItemType())) {
            indicator.setResultType(null);
            indicator.setResultSpecification(null);
        }
        if (indicator.getSortOrder() == null) {
            Integer max = indicatorMapper.selectMaxSortOrder(indicator.getParentId());
            indicator.setSortOrder((max == null ? 0 : max) + (MesQcIndicatorDO.PARENT_ID_ROOT.equals(indicator.getParentId()) ? 10 : 1));
        }
    }
    private String nameOf(Long id) {
        MesQcIndicatorDO indicator = id == null ? null : indicatorMapper.selectById(id);
        return indicator == null ? String.valueOf(id) : defaultStr(indicator.getName());
    }
    private static String itemTypeNameOf(Integer itemType) {
        return MesQcIndicatorItemTypeEnum.isGroup(itemType)
                ? MesQcIndicatorItemTypeEnum.GROUP.getName() : MesQcIndicatorItemTypeEnum.ENTRY.getName();
    }
    private static String defaultStr(String value) {
        return StrUtil.blankToDefault(value, "");
    }
    private void validateIndicatorExists(Long id) {
@@ -102,7 +205,11 @@
        }
    }
    private void validateResultSpecification(Integer resultType, String resultSpecification) {
    private void validateResultSpecification(Integer itemType, Integer resultType, String resultSpecification) {
        // åˆ†ç»„项没有结果值,落库前会被清空,这里不校验
        if (MesQcIndicatorItemTypeEnum.isGroup(ObjectUtils.defaultIfNull(itemType, MesQcIndicatorItemTypeEnum.ENTRY.getType()))) {
            return;
        }
        if (ObjectUtils.equalsAny(resultType, MesQcResultValueTypeEnum.FILE.getType(),
                MesQcResultValueTypeEnum.DICT.getType())) {
            if (StrUtil.isBlank(resultSpecification)) {
@@ -127,6 +234,11 @@
    }
    @Override
    public List<MesQcIndicatorDO> getIndicatorGroupList() {
        return indicatorMapper.selectListByItemType(MesQcIndicatorItemTypeEnum.GROUP.getType());
    }
    @Override
    public List<MesQcIndicatorDO> getIndicatorList(Collection<Long> ids) {
        if (CollUtil.isEmpty(ids)) {
            return Collections.emptyList();
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/indicator/MesQcIndicatorServiceImplTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,323 @@
package cn.iocoder.yudao.module.mes.service.qc.indicator;
import cn.iocoder.yudao.framework.common.exception.ErrorCode;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
import cn.iocoder.yudao.module.mes.controller.admin.qc.indicator.vo.MesQcIndicatorSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.indicator.MesQcIndicatorDO;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.indicator.MesQcIndicatorMapper;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcIndicatorItemTypeEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcResultValueTypeEnum;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.function.Executable;
import org.springframework.context.annotation.Import;
import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
/**
 * {@link MesQcIndicatorServiceImpl} çš„单元测试
 * <p>
 * è¦†ç›–分组层级:层级校验、排序号补齐、结果值字段清空、级联删除。
 *
 * @author è¶…级管理员
 */
@Import(MesQcIndicatorServiceImpl.class)
public class MesQcIndicatorServiceImplTest extends BaseDbUnitTest {
    /**
     * æ£€æµ‹é¡¹ç±»åž‹ï¼ˆå­—å…¸ mes_indicator_type çš„「性能」,现网分组行用的就是这个值)
     */
    private static final Integer TYPE_PERFORMANCE = 4;
    @Resource
    private MesQcIndicatorServiceImpl indicatorService;
    @Resource
    private MesQcIndicatorMapper indicatorMapper;
    // ==================== åˆ›å»ºï¼šåˆ†ç»„项 ====================
    @Test
    public void testCreateGroup_clearsResultFields() {
        // å‡†å¤‡å‚数:分组项即使带了 FILE ç±»åž‹ä¸Žç©ºçš„结果值属性,也不应被「结果值属性不能为空」拦住
        MesQcIndicatorSaveReqVO reqVO = buildReqVO("T01-G", "水分", MesQcIndicatorItemTypeEnum.GROUP.getType(),
                null, MesQcResultValueTypeEnum.FILE.getType());
        reqVO.setFormulaText("w = (m1 - m0) / m Ã— 100%");
        // è°ƒç”¨
        Long id = indicatorService.createIndicator(reqVO);
        // æ–­è¨€ï¼šåˆ†ç»„项落库时结果值字段被清空,父级归 0,排序号自动补 10(首个顶级)
        MesQcIndicatorDO dbIndicator = indicatorMapper.selectById(id);
        assertNotNull(dbIndicator);
        assertEquals(MesQcIndicatorItemTypeEnum.GROUP.getType(), dbIndicator.getItemType());
        assertEquals(MesQcIndicatorDO.PARENT_ID_ROOT, dbIndicator.getParentId());
        assertNull(dbIndicator.getResultType());
        assertNull(dbIndicator.getResultSpecification());
        assertEquals("w = (m1 - m0) / m Ã— 100%", dbIndicator.getFormulaText());
        assertEquals(10, dbIndicator.getSortOrder());
    }
    @Test
    public void testCreateGroup_cannotNest() {
        // mock æ•°æ®ï¼šä¸€ä¸ªå·²å­˜åœ¨çš„分组
        MesQcIndicatorDO parent = insertIndicator("T02-G1", "水分", MesQcIndicatorItemTypeEnum.GROUP.getType(),
                MesQcIndicatorDO.PARENT_ID_ROOT, null, 10);
        // å‡†å¤‡å‚数:分组项挂到另一个分组下
        MesQcIndicatorSaveReqVO reqVO = buildReqVO("T02-G2", "灰分", MesQcIndicatorItemTypeEnum.GROUP.getType(),
                parent.getId(), null);
        // è°ƒç”¨ï¼Œå¹¶æ–­è¨€å¼‚常
        assertMessageException(() -> indicatorService.createIndicator(reqVO),
                QC_INDICATOR_GROUP_CANNOT_NEST, "灰分", "必须作为顶级指标", "水分");
        // æ–­è¨€ï¼šæœªå…¥åº“
        assertEquals(1, indicatorMapper.selectCount());
    }
    // ==================== åˆ›å»ºï¼šå½•入项 ====================
    @Test
    public void testCreateEntry_defaultSortOrder() {
        // mock æ•°æ®ï¼šä¸€ä¸ªé¡¶çº§åˆ†ç»„,排序号 30
        MesQcIndicatorDO group = insertIndicator("T03-G", "水分", MesQcIndicatorItemTypeEnum.GROUP.getType(),
                MesQcIndicatorDO.PARENT_ID_ROOT, null, 30);
        // è°ƒç”¨ï¼šç»„内第一条子项,排序号留空
        Long firstId = indicatorService.createIndicator(buildReqVO("T03-E1", "游离水",
                MesQcIndicatorItemTypeEnum.ENTRY.getType(), group.getId(), MesQcResultValueTypeEnum.FLOAT.getType()));
        // è°ƒç”¨ï¼šç»„内第二条子项,排序号留空
        Long secondId = indicatorService.createIndicator(buildReqVO("T03-E2", "结合水",
                MesQcIndicatorItemTypeEnum.ENTRY.getType(), group.getId(), MesQcResultValueTypeEnum.FLOAT.getType()));
        // æ–­è¨€ï¼šå­é¡¹æ­¥é•¿ä¸º 1,依次排到同级末尾
        assertEquals(1, indicatorMapper.selectById(firstId).getSortOrder());
        assertEquals(2, indicatorMapper.selectById(secondId).getSortOrder());
        assertEquals(group.getId(), indicatorMapper.selectById(firstId).getParentId());
    }
    @Test
    public void testCreateIndicator_defaultSortOrder_topLevelBlockStep() {
        // mock æ•°æ®ï¼šé¡¶çº§åˆ†ç»„排序号 30
        insertIndicator("T04-G", "水分", MesQcIndicatorItemTypeEnum.GROUP.getType(),
                MesQcIndicatorDO.PARENT_ID_ROOT, null, 30);
        // è°ƒç”¨ï¼šé¡¶çº§æ–°æŒ‡æ ‡æŽ’序号留空,顶级步长为 10
        Long id = indicatorService.createIndicator(buildReqVO("T04-E", "外观",
                MesQcIndicatorItemTypeEnum.ENTRY.getType(), null, MesQcResultValueTypeEnum.TEXT.getType()));
        // æ–­è¨€
        MesQcIndicatorDO dbIndicator = indicatorMapper.selectById(id);
        assertEquals(MesQcIndicatorDO.PARENT_ID_ROOT, dbIndicator.getParentId());
        assertEquals(40, dbIndicator.getSortOrder());
    }
    @Test
    public void testCreateEntry_parentNotGroup() {
        // mock æ•°æ®ï¼šä¸€ä¸ªå½•入项被拿来当父级
        MesQcIndicatorDO entry = insertIndicator("T05-E1", "外观", MesQcIndicatorItemTypeEnum.ENTRY.getType(),
                MesQcIndicatorDO.PARENT_ID_ROOT, MesQcResultValueTypeEnum.TEXT.getType(), 10);
        // å‡†å¤‡å‚æ•°
        MesQcIndicatorSaveReqVO reqVO = buildReqVO("T05-E2", "色泽", MesQcIndicatorItemTypeEnum.ENTRY.getType(),
                entry.getId(), MesQcResultValueTypeEnum.TEXT.getType());
        // è°ƒç”¨ï¼Œå¹¶æ–­è¨€å¼‚常
        assertMessageException(() -> indicatorService.createIndicator(reqVO),
                QC_INDICATOR_PARENT_NOT_GROUP, "外观", "不是分组项", "录入项");
    }
    @Test
    public void testCreateEntry_parentNotExists() {
        // å‡†å¤‡å‚数:父级编号不存在
        Long notExistsParentId = randomLongId();
        MesQcIndicatorSaveReqVO reqVO = buildReqVO("T06-E", "色泽", MesQcIndicatorItemTypeEnum.ENTRY.getType(),
                notExistsParentId, MesQcResultValueTypeEnum.TEXT.getType());
        // è°ƒç”¨ï¼Œå¹¶æ–­è¨€å¼‚常
        assertMessageException(() -> indicatorService.createIndicator(reqVO),
                QC_INDICATOR_PARENT_NOT_EXISTS, "所属分组不存在", String.valueOf(notExistsParentId));
    }
    @Test
    public void testCreateEntry_resultTypeRequired() {
        // å‡†å¤‡å‚数:录入项没有结果值类型
        MesQcIndicatorSaveReqVO reqVO = buildReqVO("T07-E", "色泽", MesQcIndicatorItemTypeEnum.ENTRY.getType(),
                null, null);
        // è°ƒç”¨ï¼Œå¹¶æ–­è¨€å¼‚常
        assertServiceException(() -> indicatorService.createIndicator(reqVO), QC_INDICATOR_RESULT_TYPE_REQUIRED);
        // æ–­è¨€ï¼šæœªå…¥åº“
        assertEquals(0, indicatorMapper.selectCount());
    }
    @Test
    public void testCreateEntry_fileResultSpecificationRequired() {
        // å‡†å¤‡å‚数:录入项为 FILE ç±»åž‹ä½†æ²¡å¡«ç»“果值属性
        MesQcIndicatorSaveReqVO reqVO = buildReqVO("T08-E", "外观照片", MesQcIndicatorItemTypeEnum.ENTRY.getType(),
                null, MesQcResultValueTypeEnum.FILE.getType());
        // è°ƒç”¨ï¼Œå¹¶æ–­è¨€å¼‚常
        assertServiceException(() -> indicatorService.createIndicator(reqVO), QC_INDICATOR_RESULT_SPECIFICATION_REQUIRED);
    }
    // ==================== ä¿®æ”¹ ====================
    @Test
    public void testUpdateEntry_parentSelf() {
        // mock æ•°æ®ï¼šä¸€ä¸ªå½•入项
        MesQcIndicatorDO entry = insertIndicator("T09-E", "外观", MesQcIndicatorItemTypeEnum.ENTRY.getType(),
                MesQcIndicatorDO.PARENT_ID_ROOT, MesQcResultValueTypeEnum.TEXT.getType(), 10);
        // å‡†å¤‡å‚数:把自己当父级
        MesQcIndicatorSaveReqVO reqVO = buildReqVO("T09-E", "外观", MesQcIndicatorItemTypeEnum.ENTRY.getType(),
                entry.getId(), MesQcResultValueTypeEnum.TEXT.getType());
        reqVO.setId(entry.getId());
        // è°ƒç”¨ï¼Œå¹¶æ–­è¨€å¼‚常
        assertServiceException(() -> indicatorService.updateIndicator(reqVO), QC_INDICATOR_PARENT_SELF);
    }
    @Test
    public void testUpdateIndicator_toGroup_clearsResultFields() {
        // mock æ•°æ®ï¼šä¸€ä¸ª FILE ç±»åž‹çš„录入项
        MesQcIndicatorDO entry = insertIndicator("T10-E", "酸含量", MesQcIndicatorItemTypeEnum.ENTRY.getType(),
                MesQcIndicatorDO.PARENT_ID_ROOT, MesQcResultValueTypeEnum.FILE.getType(), 10);
        entry.setResultSpecification("IMG");
        indicatorMapper.updateById(entry);
        // å‡†å¤‡å‚数:把它改成分组项
        MesQcIndicatorSaveReqVO reqVO = buildReqVO("T10-E", "酸含量", MesQcIndicatorItemTypeEnum.GROUP.getType(),
                null, MesQcResultValueTypeEnum.FILE.getType());
        reqVO.setId(entry.getId());
        reqVO.setFormulaText("c = V Ã— N / m");
        // è°ƒç”¨
        indicatorService.updateIndicator(reqVO);
        // æ–­è¨€ï¼šæ¡ç›®ç±»åž‹å·²æ”¹ï¼Œç»“果值字段被清空,公式写入
        MesQcIndicatorDO dbIndicator = indicatorMapper.selectById(entry.getId());
        assertEquals(MesQcIndicatorItemTypeEnum.GROUP.getType(), dbIndicator.getItemType());
        assertNull(dbIndicator.getResultType());
        assertNull(dbIndicator.getResultSpecification());
        assertEquals("c = V Ã— N / m", dbIndicator.getFormulaText());
    }
    @Test
    public void testUpdateIndicator_hasChildrenCannotChangeToEntry() {
        // mock æ•°æ®ï¼šä¸€ä¸ªå¸¦å­é¡¹çš„分组
        MesQcIndicatorDO group = insertIndicator("T11-G", "水分", MesQcIndicatorItemTypeEnum.GROUP.getType(),
                MesQcIndicatorDO.PARENT_ID_ROOT, null, 10);
        insertIndicator("T11-E1", "游离水", MesQcIndicatorItemTypeEnum.ENTRY.getType(), group.getId(),
                MesQcResultValueTypeEnum.FLOAT.getType(), 1);
        // å‡†å¤‡å‚数:把分组改成录入项
        MesQcIndicatorSaveReqVO reqVO = buildReqVO("T11-G", "水分", MesQcIndicatorItemTypeEnum.ENTRY.getType(),
                null, MesQcResultValueTypeEnum.FLOAT.getType());
        reqVO.setId(group.getId());
        // è°ƒç”¨ï¼Œå¹¶æ–­è¨€å¼‚常
        assertMessageException(() -> indicatorService.updateIndicator(reqVO),
                QC_INDICATOR_HAS_CHILDREN, "不能再把它的条目类型改为录入项", "水分");
        // æ–­è¨€ï¼šæ¡ç›®ç±»åž‹æœªè¢«æ”¹åЍ
        assertEquals(MesQcIndicatorItemTypeEnum.GROUP.getType(), indicatorMapper.selectById(group.getId()).getItemType());
    }
    @Test
    public void testUpdateIndicator_hasChildrenCannotReparent() {
        // mock æ•°æ®ï¼šä¸€ä¸ªå¸¦å­é¡¹çš„æ¡ç›®ï¼ˆåŽ†å²è„æ•°æ®ï¼šå½•å…¥é¡¹ä¹Ÿå¯èƒ½è¢«æŒ‚è¿‡å­é¡¹ï¼ŒæŠ¥å‘Šä¾§åªè®¤ parent_id)
        MesQcIndicatorDO entry = insertIndicator("T12-E", "杂质", MesQcIndicatorItemTypeEnum.ENTRY.getType(),
                MesQcIndicatorDO.PARENT_ID_ROOT, MesQcResultValueTypeEnum.FLOAT.getType(), 10);
        insertIndicator("T12-E1", "不溶物", MesQcIndicatorItemTypeEnum.ENTRY.getType(), entry.getId(),
                MesQcResultValueTypeEnum.FLOAT.getType(), 1);
        MesQcIndicatorDO group = insertIndicator("T12-G", "水分", MesQcIndicatorItemTypeEnum.GROUP.getType(),
                MesQcIndicatorDO.PARENT_ID_ROOT, null, 20);
        // å‡†å¤‡å‚数:把带子项的指标挂到分组下
        MesQcIndicatorSaveReqVO reqVO = buildReqVO("T12-E", "杂质", MesQcIndicatorItemTypeEnum.ENTRY.getType(),
                group.getId(), MesQcResultValueTypeEnum.FLOAT.getType());
        reqVO.setId(entry.getId());
        // è°ƒç”¨ï¼Œå¹¶æ–­è¨€å¼‚常
        assertMessageException(() -> indicatorService.updateIndicator(reqVO),
                QC_INDICATOR_HAS_CHILDREN, "不能再挂到其它分组下", "杂质");
        // æ–­è¨€ï¼šçˆ¶çº§æœªè¢«æ”¹åЍ
        assertEquals(MesQcIndicatorDO.PARENT_ID_ROOT, indicatorMapper.selectById(entry.getId()).getParentId());
    }
    // ==================== åˆ é™¤ ====================
    @Test
    public void testDeleteGroup_cascadeChildren() {
        // mock æ•°æ®ï¼šä¸€ä¸ªåˆ†ç»„ + ä¸¤ä¸ªå­é¡¹ + ä¸€ä¸ªä¸ç›¸å…³çš„顶级指标
        MesQcIndicatorDO group = insertIndicator("T13-G", "水分", MesQcIndicatorItemTypeEnum.GROUP.getType(),
                MesQcIndicatorDO.PARENT_ID_ROOT, null, 10);
        Long child1Id = insertIndicator("T13-E1", "游离水", MesQcIndicatorItemTypeEnum.ENTRY.getType(),
                group.getId(), MesQcResultValueTypeEnum.FLOAT.getType(), 1).getId();
        Long child2Id = insertIndicator("T13-E2", "结合水", MesQcIndicatorItemTypeEnum.ENTRY.getType(),
                group.getId(), MesQcResultValueTypeEnum.FLOAT.getType(), 2).getId();
        Long otherId = insertIndicator("T13-E3", "外观", MesQcIndicatorItemTypeEnum.ENTRY.getType(),
                MesQcIndicatorDO.PARENT_ID_ROOT, MesQcResultValueTypeEnum.TEXT.getType(), 20).getId();
        // è°ƒç”¨
        indicatorService.deleteIndicator(group.getId());
        // æ–­è¨€ï¼šåˆ†ç»„与其子项一并删除,不相关的指标保留
        assertNull(indicatorMapper.selectById(group.getId()));
        assertNull(indicatorMapper.selectById(child1Id));
        assertNull(indicatorMapper.selectById(child2Id));
        assertNotNull(indicatorMapper.selectById(otherId));
    }
    @Test
    public void testDeleteIndicator_notExists() {
        // è°ƒç”¨ï¼Œå¹¶æ–­è¨€å¼‚常
        assertServiceException(() -> indicatorService.deleteIndicator(randomLongId()), QC_INDICATOR_NOT_EXISTS);
    }
    // ==================== è¾…助方法 ====================
    private MesQcIndicatorSaveReqVO buildReqVO(String code, String name, Integer itemType, Long parentId,
                                               Integer resultType) {
        MesQcIndicatorSaveReqVO reqVO = new MesQcIndicatorSaveReqVO();
        reqVO.setCode(code);
        reqVO.setName(name);
        reqVO.setType(TYPE_PERFORMANCE);
        reqVO.setItemType(itemType);
        reqVO.setParentId(parentId);
        reqVO.setResultType(resultType);
        return reqVO;
    }
    private MesQcIndicatorDO insertIndicator(String code, String name, Integer itemType, Long parentId,
                                             Integer resultType, Integer sortOrder) {
        MesQcIndicatorDO indicator = new MesQcIndicatorDO();
        indicator.setCode(code);
        indicator.setName(name);
        indicator.setType(TYPE_PERFORMANCE);
        indicator.setItemType(itemType);
        indicator.setParentId(parentId);
        indicator.setResultType(resultType);
        indicator.setSortOrder(sortOrder);
        indicatorMapper.insert(indicator);
        return indicator;
    }
    /**
     * æ–­è¨€æŠ›å‡ºçš„业务异常错误码正确,且提示文案包含给定的关键片段。
     * <p>
     * å±‚级校验的文案带分组名 / çˆ¶æŒ‡æ ‡å / å­é¡¹æ¡æ•°ï¼Œæ— æ³•用
     * {@link cn.iocoder.yudao.framework.test.core.util.AssertUtils#assertServiceException} é€å­—比对。
     */
    private void assertMessageException(Executable executable, ErrorCode errorCode, String... messageFragments) {
        ServiceException exception = assertThrows(ServiceException.class, executable);
        assertEquals(errorCode.getCode(), exception.getCode(), "错误码不匹配");
        for (String fragment : messageFragments) {
            assertTrue(exception.getMessage().contains(fragment),
                    String.format("错误提示[%s]未包含[%s]", exception.getMessage(), fragment));
        }
    }
}
yudao-module-mes/src/test/resources/sql/clean.sql
@@ -21,3 +21,4 @@
DELETE FROM "mes_pro_task";
DELETE FROM "mes_pro_route_process";
DELETE FROM "mes_qc_indicator_result";
DELETE FROM "mes_qc_indicator";
yudao-module-mes/src/test/resources/sql/create_tables.sql
@@ -830,3 +830,29 @@
    "tenant_id" bigint NOT NULL DEFAULT 0,
    PRIMARY KEY ("id")
);
-- ----------------------------
-- MES è´¨æ£€æŒ‡æ ‡
-- ----------------------------
CREATE TABLE IF NOT EXISTS "mes_qc_indicator" (
    "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
    "code" varchar(100) DEFAULT NULL,
    "name" varchar(100) DEFAULT NULL,
    "parent_id" bigint NOT NULL DEFAULT 0,
    "item_type" tinyint NOT NULL DEFAULT 1,
    "formula_text" varchar(500) DEFAULT NULL,
    "unit_text" varchar(50) DEFAULT NULL,
    "sort_order" int NOT NULL DEFAULT 0,
    "type" int DEFAULT NULL,
    "tool" varchar(255) DEFAULT NULL,
    "result_type" int DEFAULT NULL,
    "result_specification" varchar(255) DEFAULT NULL,
    "remark" varchar(255) DEFAULT NULL,
    "creator" varchar(64) DEFAULT '',
    "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "updater" varchar(64) DEFAULT '',
    "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "deleted" bit NOT NULL DEFAULT FALSE,
    "tenant_id" bigint NOT NULL DEFAULT 0,
    PRIMARY KEY ("id")
);
yudao-module-qcreport/pom.xml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,98 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <groupId>cn.iocoder.boot</groupId>
        <artifactId>yudao</artifactId>
        <version>${revision}</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>
    <artifactId>yudao-module-qcreport</artifactId>
    <packaging>jar</packaging>
    <name>${project.artifactId}</name>
    <description>
        qcreport æ¨¡å—下,智能质检报告设计平台。
        å¯è§†åŒ–报告模板设计器 + æ¨¡æ¿ Schema + æ•°æ®ç»‘定引擎 + åˆ¤å®šè§„则引擎 + HTML/PDF æ¸²æŸ“引擎。
    </description>
    <dependencies>
        <!-- System:权限、用户、文件存储(storage-blob / storage-attachment) -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-module-system</artifactId>
            <version>${revision}</version>
        </dependency>
        <!-- MES:质检单(来料/过程/出货/退货)可报告数据接口 -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-module-mes-api</artifactId>
            <version>${revision}</version>
        </dependency>
        <!-- Web ç›¸å…³ -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-spring-boot-starter-security</artifactId>
        </dependency>
        <!-- DB ç›¸å…³ -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-spring-boot-starter-mybatis</artifactId>
        </dependency>
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-spring-boot-starter-redis</artifactId>
        </dependency>
        <!-- å·¥å…·ç±»ç›¸å…³ -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-spring-boot-starter-excel</artifactId>
        </dependency>
        <!-- PDF å‡ºä»¶ï¼šæœåŠ¡ç«¯è°ƒç”¨ Chromium æŠŠæŠ¥å‘Š HTML æ‰“印成 PDF -->
        <dependency>
            <groupId>com.microsoft.playwright</groupId>
            <artifactId>playwright</artifactId>
        </dependency>
        <!-- AI å¯¼å…¥ï¼šåªä¾èµ– -api(里面只有 AiChatApi),不依赖 yudao-module-ai æœ¬ä½“ -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-module-ai-api</artifactId>
            <version>${revision}</version>
        </dependency>
        <!-- AI å¯¼å…¥ï¼šPDF æ–‡æœ¬æŠ½å–与扫描页渲染 -->
        <dependency>
            <groupId>org.apache.pdfbox</groupId>
            <artifactId>pdfbox</artifactId>
            <version>3.0.4</version>
        </dependency>
        <!-- AI å¯¼å…¥ï¼šWord(.docx) / Excel(.xlsx) æ–‡æœ¬æŠ½å– -->
        <dependency>
            <groupId>org.apache.poi</groupId>
            <artifactId>poi-ooxml</artifactId>
            <version>5.4.0</version>
        </dependency>
        <!-- Test æµ‹è¯•相关 -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-spring-boot-starter-test</artifactId>
        </dependency>
    </dependencies>
</project>
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/config/QcReportAiImportProperties.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,66 @@
package cn.iocoder.yudao.module.qcreport.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
 * AI å¯¼å…¥æ¨¡æ¿è‰ç¨¿é…ç½®
 * <p>
 * å¯¹åº” application.yml ä¸­çš„ yudao.qcreport.ai-import å‰ç¼€é…ç½®ã€‚
 * <p>
 * è¿™é‡Œçš„æ¯ä¸€é¡¹éƒ½æ˜¯**硬上限**,不是性能调优参数:每次导入会真的发出 1~5 æ¬¡å¤§æ¨¡åž‹è°ƒç”¨ï¼Œ
 * æ‰«æä»¶æŒ‰å›¾è®¡è´¹ï¼Œæ— ä¸Šé™æ„å‘³ç€ä¸€æ¬¡è¯¯æ“ä½œå°±èƒ½çƒ§æŽ‰ä¸€æ•´å¤©çš„额度。所以宁可报错让用户分批,
 * ä¹Ÿä¸è¦é™é»˜æˆªæ–­â€”—静默截断还会让用户以为整份文件都识别过了。
 */
@Component
@ConfigurationProperties(prefix = "yudao.qcreport.ai-import", ignoreUnknownFields = true)
@Data
public class QcReportAiImportProperties {
    /** æ˜¯å¦å¯ç”¨ AI å¯¼å…¥ã€‚需要能出网访问大模型服务;内网部署时关掉它,接口会明确报「未启用」而不是挂住 */
    private Boolean enabled = true;
    /** ä¸€æ¬¡è¯·æ±‚最多几个文件 */
    private Integer maxFiles = 3;
    /** å•个文件最多几页。超出直接报错,不静默截断 */
    private Integer maxPagesPerFile = 5;
    /** ä¸€æ¬¡è¯·æ±‚的累计页数上限。多文件叠加后的总闸,防止「每个文件都没超、加起来却很多」 */
    private Integer maxPagesPerRequest = 8;
    /**
     * ä¸€æ¬¡å¯¼å…¥çš„æ€»è€—时上限(秒),在两次模型调用之间检查。
     * <p>
     * é¡µæ•°ä¸Žæ–‡ä»¶æ•°ä¸Šé™åªçº¦æŸäº†è°ƒç”¨**次数**,没约束**时长**:模型排队时每次调用都能耗满
     * {@code yudao.ai.timeout},8 æ¬¡å åŠ èƒ½æŠŠä¸€æ¬¡è¯¯æ“ä½œæ‹–æˆå¥½å‡ åˆ†é’Ÿçš„ä»˜è´¹è°ƒç”¨ã€‚
     * <p>
     * å•次调用无法中途打断({@code AiChatApi} ä¸æŽ¥å—超时参数),所以只能在两次调用之间关门,
     * æœ€åä¼šå¤šå‡ºä¸€ä¸ªå•次调用超时。前端 axios è¶…时必须比「本项 + yudao.ai.timeout」更宽,
     * å¦åˆ™ç”¨æˆ·å…ˆçœ‹åˆ°æµè§ˆå™¨æ–­å¼€ï¼Œè€Œä¸æ˜¯è¿™é‡Œçš„æ˜Žç¡®æç¤ºã€‚取 180 ç§’:常规 8 æ¬¡ä»¥å†…的调用都跑得完,
     * åªæœ‰æ¨¡åž‹æ˜Žæ˜¾å˜æ…¢æ—¶æ‰ä¼šè§¦å‘。
     */
    private Integer maxDurationSeconds = 180;
    /** å•个文件大小上限(MB) */
    private Integer maxFileSizeMb = 20;
    /** è¯†åˆ«å‡ºçš„组件总数上限,超出截断并在 warnings é‡Œæç¤ºè¡¥å½• */
    private Integer maxComponents = 200;
    /**
     * PDF åˆ¤å®šèµ°æ–‡æœ¬é€šé“还是图片通道的阈值:平均每页字符数。
     * <p>
     * ä¾æ®æ˜¯ã€Œè¿™ä»½ PDF æœ‰æ²¡æœ‰æ–‡æœ¬å±‚」而不是「它是不是 PDF」。阈值取 30 æ˜¯ç»éªŒå€¼ï¼š
     * çœŸæ­£æœ‰æ–‡æœ¬å±‚的报告每页至少几十字,而扫描件即便带上 OCR å±‚也往往只有零星几个字符。
     */
    private Integer pdfTextPageThreshold = 30;
    /** æ‰«æé¡µè½¬å›¾æ—¶çš„ DPI。低于 200 æ—¶ä¸­æ–‡å­—形会糊,多模态模型容易认错;调高则每次调用更贵 */
    private Integer renderDpi = 200;
    /** Excel å•表最多读取的行数,防止超大表把提示词撑爆 */
    private Integer maxXlsxRows = 300;
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/config/QcReportPdfProperties.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,44 @@
package cn.iocoder.yudao.module.qcreport.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
 * PDF å‡ºä»¶é…ç½®
 * <p>
 * å¯¹åº” application.yml ä¸­çš„ yudao.qcreport.pdf å‰ç¼€é…ç½®ã€‚
 */
@Component
@ConfigurationProperties(prefix = "yudao.qcreport.pdf", ignoreUnknownFields = true)
@Data
public class QcReportPdfProperties {
    /** æ˜¯å¦å¯ç”¨ PDF å‡ºä»¶ã€‚部署环境没有浏览器时关掉它,接口会明确报「未启用」而不是抛 500 */
    private Boolean enabled = true;
    /**
     * æµè§ˆå™¨é€šé“,如 chrome / msedge;留空则用 Playwright è‡ªå¸¦çš„ Chromium
     * ï¼ˆåŽè€…需要先执行 {@code playwright install chromium} ä¸‹è½½ï¼Œçº¦ 150MB)。
     */
    private String channel = "chrome";
    /** æµè§ˆå™¨å¯æ‰§è¡Œæ–‡ä»¶è·¯å¾„。配了就优先于 {@link #channel},用于装在非标准位置的浏览器 */
    private String executablePath = "";
    /** åŒæ—¶è¿›è¡Œçš„ PDF æ¸²æŸ“上限。Chromium å¾ˆåƒå†…存,并发必须设闸 */
    private Integer concurrency = 2;
    /** å•次渲染超时(毫秒),同时用于等待页面资源与等待并发名额 */
    private Long timeoutMs = 30000L;
    /** é¡µè„šæ˜¯å¦æ‰“å°ã€Œç¬¬ N é¡µ / å…± M é¡µã€ */
    private Boolean showPageNumber = true;
    /** é¢å¤–的浏览器启动参数,如 Linux ä»¥ root è¿è¡Œéœ€åŠ  --no-sandbox */
    private List<String> browserArgs = new ArrayList<>();
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/aiimport/QcReportAiImportController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,44 @@
package cn.iocoder.yudao.module.qcreport.controller.admin.aiimport;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.qcreport.controller.admin.aiimport.vo.QcReportAiDraftReqVO;
import cn.iocoder.yudao.module.qcreport.controller.admin.aiimport.vo.QcReportAiDraftRespVO;
import cn.iocoder.yudao.module.qcreport.service.aiimport.QcReportAiImportService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/**
 * æ™ºèƒ½è´¨æ£€æŠ¥å‘Š AI å¯¼å…¥ Controller。
 * <p>
 * åªæœ‰ä¸€ä¸ªç«¯ç‚¹ï¼Œä¸”<b>不接收文件</b>:文件先由前端传到 system æ¨¡å—拿到 blobId,再连同组件清单一起提交。
 * ä¸šåŠ¡æ¨¡å—è‡ªå»ºä¸Šä¼ é€šé“ä¼šç»•å¼€ç»Ÿä¸€çš„å­˜å‚¨é…ç½®ä¸Žé™„ä»¶å½’å±žï¼ˆè§ file-upload.md),
 * æ‰€ä»¥è¿™é‡Œè¿ž {@code MultipartFile} éƒ½ä¸å‡ºçŽ°åœ¨ç­¾åé‡Œã€‚
 */
@Tag(name = "管理后台 - æ™ºèƒ½è´¨æ£€æŠ¥å‘Š AI å¯¼å…¥")
@RestController
@RequestMapping("/qc-report/ai-import")
@Validated
public class QcReportAiImportController {
    @Resource
    private QcReportAiImportService aiImportService;
    @PostMapping("/draft")
    @Operation(summary = "识别文件生成模板草稿",
            description = "同步返回,不落库。识别出的只是草稿,需在设计器人工确认后由「保存」落为正式版本")
    @PreAuthorize("@ss.hasPermission('qc-report:template:ai-import')")
    public CommonResult<QcReportAiDraftRespVO> generateDraft(@Valid @RequestBody QcReportAiDraftReqVO reqVO) {
        return success(aiImportService.generateDraft(reqVO));
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/aiimport/vo/QcReportAiDraftReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,52 @@
package cn.iocoder.yudao.module.qcreport.controller.admin.aiimport.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
/**
 * AI è¯†åˆ«æ–‡ä»¶ç”Ÿæˆæ¨¡æ¿è‰ç¨¿çš„入参。
 * <p>
 * æ–‡ä»¶ä¸èµ°è¿™é‡Œä¸Šä¼ ï¼šå…ˆç”±å‰ç«¯è°ƒ system æ¨¡å—çš„ {@code /system/storage-blob/upload} æ‹¿åˆ° blobId,
 * å†æŠŠ blobId äº¤ç»™æœ¬æŽ¥å£ã€‚业务模块不自建上传通道,也不碰 MultipartFile(见 file-upload.md)。
 * <p>
 * è¿”回的只是**草稿**,不落库。人工在设计器里确认、调整并点保存,才会真正写入模板版本。
 */
@Schema(description = "管理后台 - æ™ºèƒ½è´¨æ£€æŠ¥å‘Š AI æ¨¡æ¿è‰ç¨¿ Request VO")
@Data
public class QcReportAiDraftReqVO {
    /**
     * æ–‡ä»¶æ•°é‡ä¸Šé™**不在注解里写死**:它由 {@code yudao.qcreport.ai-import.max-files} å†³å®šï¼Œ
     * æœåŠ¡å±‚åœ¨è¯»å–ä»»ä½•æ–‡ä»¶ä¹‹å‰å…ˆæ¯”å¯¹é…ç½®å€¼å¹¶ç»™å‡ºå¸¦ä¸¤ä¸ªå®žé™…å€¼çš„æç¤ºã€‚
     * è¿™é‡Œå†å†™ä¸€ä¸ªæ•°å­—就是第二个真相来源——它与配置不同步时,用户看到的上限与真正生效的上限对不上,
     * è€Œä¸”注解永远只会更严,配置便只敢往下调。
     */
    @Schema(description = "上传文件对应的 blobId åˆ—表,顺序即文档页序。个数上限见 yudao.qcreport.ai-import.max-files",
            requiredMode = Schema.RequiredMode.REQUIRED, example = "[2048, 2049]")
    @NotEmpty(message = "请至少上传一个文件")
    private List<Long> blobIds;
    @Schema(description = "目标模板编号,用于日志留痕与附件归属", requiredMode = Schema.RequiredMode.REQUIRED,
            example = "5")
    @NotNull(message = "模板编号不能为空")
    private Long templateId;
    @Schema(description = "前端组件清单的 Schema ç‰ˆæœ¬ã€‚两端不一致时直接报错,"
            + "避免模型基于过期的积木清单编出注册表里不存在的组件",
            requiredMode = Schema.RequiredMode.REQUIRED, example = "1.1")
    @NotBlank(message = "Schema ç‰ˆæœ¬ä¸èƒ½ä¸ºç©º")
    private String schemaVersion;
    @Schema(description = "可用组件积木清单,由前端从活注册表生成", requiredMode = Schema.RequiredMode.REQUIRED)
    @NotEmpty(message = "组件清单不能为空")
    private List<QcReportComponentSpecVO> catalog;
    @Schema(description = "用户补充说明,会拼进提示词帮助模型理解文档", example = "这是 IQC æ¥æ–™æ£€éªŒæŠ¥å‘Š")
    private String hint;
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/aiimport/vo/QcReportAiDraftRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,69 @@
package cn.iocoder.yudao.module.qcreport.controller.admin.aiimport.vo;
import cn.iocoder.yudao.module.qcreport.dal.dataobject.version.ReportTemplateSchema;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
 * AI è¯†åˆ«ç»“果:一份**模板草稿**,不落库。
 * <p>
 * åˆ»æ„ä¸è¿”回 {@link ReportTemplateSchema}:草稿只是「一串组件 + å„自的属性」这个更小的物化形态,
 * ç”±å‰ç«¯è£…配器配上活注册表才能编译成画布数据({@code grapes})。让后端产出完整 Schema
 * å°±å¾—在这里再实现一遍组件的 canvas ç»“构,等于把渲染引擎的职责抄进 AI æ¨¡å—,还会让
 * Schema å¥‘约为了 AI è€Œè†¨èƒ€â€”—语义层是刻意收窄的子集,不该为这条路开口子。
 * <p>
 * å› æ­¤è‰ç¨¿çš„æ¶ˆè´¹è€…只有前端装配器:它逐项查注册表,未注册的 type ç›´æŽ¥è·³è¿‡ã€‚
 * å³ä¾¿æ¨¡åž‹ç¼–出清单外的组件,也永远变不成任意 HTML。
 *
 * <h3>为什么没有 rawText å­—段</h3>
 * æ²¡è§£æžå‡ºæ¥æ—¶æœ¬æŽ¥å£ç›´æŽ¥æŠ›é”™ï¼ˆ{@code AI_IMPORT_RESPONSE_UNPARSEABLE}),响应体是错误结构而非本 VO,
 * å¡žä¸€ä¸ªæ’为 null çš„字段只会让后来人以为「解析失败时前端能拿到原文」。模型原文写进了服务端日志。
 */
@Schema(description = "管理后台 - æ™ºèƒ½è´¨æ£€æŠ¥å‘Š AI æ¨¡æ¿è‰ç¨¿ Response VO")
@Data
public class QcReportAiDraftRespVO {
    @Schema(description = "AI è¯†åˆ«å‡ºçš„组件草稿,按报告从上到下的顺序;已做过合并去重与非法项过滤",
            requiredMode = Schema.RequiredMode.REQUIRED)
    private List<DraftComponent> components;
    @Schema(description = "AI çŒœæµ‹çš„纸张配置,可能为空(为空表示按当前模板的纸张走)")
    private ReportTemplateSchema.Page page;
    @Schema(description = "AI å¯¹è¿™ä»½æ–‡æ¡£çš„一句话说明,展示在预览弹窗顶部")
    private String summary;
    /**
     * è½¯å¤±è´¥æ¸…单:被跳过的页、无法表达的区块、被丢弃的未知组件等。
     * <p>
     * ä¸Žã€Œæ•´ä½“失败抛异常」是两回事:这些情况不影响草稿可用,但必须让用户看见,
     * å¦åˆ™ä¼šä»¥ä¸ºæ–‡ä»¶é‡Œçš„内容都识别到了。
     */
    @Schema(description = "识别过程中的软提示,需在界面上展示给用户")
    private List<String> warnings;
    @Schema(description = "识别总耗时(毫秒),含多次模型调用", example = "12400")
    private Long durationMs;
    /**
     * ä¸€ä¸ªç»„件草稿。
     * <p>
     * ç»“构完全扁平:没有 children、没有嵌套。props çš„ key ç”±å‰ç«¯ç§¯æœ¨æ¸…单定义,
     * å› æ­¤è¿™é‡Œç”¨ {@code Map} è€Œä¸æ˜¯å¼ºç±»åž‹â€”—合法 key çš„æƒå¨åœ¨å‰ç«¯æ³¨å†Œè¡¨ï¼ŒåŽç«¯ä¸è¯¥é‡å¤å£°æ˜Žä¸€éã€‚
     */
    @Schema(description = "管理后台 - AI è‰ç¨¿é‡Œçš„一个组件")
    @Data
    public static class DraftComponent {
        @Schema(description = "组件类型,必须是积木清单里声明过的 type", example = "QualityTable")
        private String type;
        @Schema(description = "组件属性;清单里未声明的 key å·²è¢«åŽç«¯ä¸¢å¼ƒ")
        private Map<String, Object> props;
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/aiimport/vo/QcReportComponentSpecVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,67 @@
package cn.iocoder.yudao.module.qcreport.controller.admin.aiimport.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
/**
 * ç»„件积木清单的一项:前端注册表的**派生视图**,喂给大模型当「可用积木」。
 * <p>
 * ç”±å‰ç«¯ä»Žæ´»æ³¨å†Œè¡¨ç”Ÿæˆï¼ˆå‰¥æŽ‰ icon / buildContent / validate è¿™äº›ä¸å¯åºåˆ—化的成员)后随请求上传。
 * <b>后端刻意不硬编码这份清单</b>:注册表在前端是唯一真相来源,后端抄一份就是第二个真相来源,
 * å‰ç«¯åŠ äº†ç»„ä»¶ã€æ”¹äº†å¿…å¡«å­—æ®µæ—¶åŽç«¯å‰¯æœ¬ä¼šé™é»˜è¿‡æœŸï¼Œå¤§æ¨¡åž‹éšå³ä¼šç¼–å‡ºæ³¨å†Œè¡¨é‡Œæ ¹æœ¬ä¸å­˜åœ¨çš„ type。
 * <p>
 * ç§¯æœ¨æ¸…单只用于拼 prompt。后端**不据此做任何渲染或合法性推断**——
 * çœŸæ­£æŠŠå…³çš„æ˜¯å‰ç«¯è£…配器查询活注册表;未注册的 type åœ¨é‚£é‡Œä¼šè¢«ç›´æŽ¥è·³è¿‡ï¼Œäº§ä¸å‡ºä»»æ„ HTML。
 */
@Schema(description = "管理后台 - æ™ºèƒ½è´¨æ£€æŠ¥å‘Š AI ç§¯æœ¨æ¸…单项")
@Data
public class QcReportComponentSpecVO {
    @Schema(description = "组件类型,如 ReportHeader / QualityTable", requiredMode = Schema.RequiredMode.REQUIRED,
            example = "QualityTable")
    private String type;
    @Schema(description = "组件显示名,供模型理解语义", example = "检验项目表格")
    private String label;
    @Schema(description = "组件分类,如 basic / data / result", example = "data")
    private String category;
    @Schema(description = "选型说明:这个组件用来放什么、不要用来放什么",
            example = "文档最顶部的抬头块:公司名称、报告标题、报告编号")
    private String hint;
    @Schema(description = "该组件允许填写的属性字段;未在此声明的 key ä¸€å¾‹ä¼šè¢«ä¸¢å¼ƒ")
    private List<Field> fields;
    /**
     * ä¸€ä¸ªå¯å¡«å±žæ€§ã€‚只保留模型需要知道的部分,字段类型同样由前端注册表给出。
     */
    @Schema(description = "管理后台 - AI ç§¯æœ¨æ¸…单的字段项")
    @Data
    public static class Field {
        @Schema(description = "属性 key,如 itemsPath / title", requiredMode = Schema.RequiredMode.REQUIRED,
                example = "itemsPath")
        private String key;
        @Schema(description = "字段显示名", example = "检验项数据源")
        private String label;
        @Schema(description = "字段类型,如 text / number / boolean / enum / dataPath", example = "dataPath")
        private String type;
        @Schema(description = "是否必填;必填未给时后端会在 warnings é‡Œæç¤º")
        private Boolean required;
        @Schema(description = "是否支持数据绑定", example = "true")
        private Boolean bindable;
        @Schema(description = "枚举可选值,仅在 type=enum æ—¶æœ‰æ„ä¹‰")
        private List<Object> enumOptions;
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/instance/QcReportInstanceController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,172 @@
package cn.iocoder.yudao.module.qcreport.controller.admin.instance;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.qcreport.controller.admin.instance.vo.QcReportInstanceGenerateFromQcReqVO;
import cn.iocoder.yudao.module.qcreport.controller.admin.instance.vo.QcReportInstanceGenerateFromQcRespVO;
import cn.iocoder.yudao.module.qcreport.controller.admin.instance.vo.QcReportInstanceGenerateReqVO;
import cn.iocoder.yudao.module.qcreport.controller.admin.instance.vo.QcReportInstanceGenerateRespVO;
import cn.iocoder.yudao.module.qcreport.controller.admin.instance.vo.QcReportInstancePageReqVO;
import cn.iocoder.yudao.module.qcreport.controller.admin.instance.vo.QcReportInstancePdfRespVO;
import cn.iocoder.yudao.module.qcreport.controller.admin.instance.vo.QcReportInstancePreviewRespVO;
import cn.iocoder.yudao.module.qcreport.controller.admin.instance.vo.QcReportInstanceRegenerateRespVO;
import cn.iocoder.yudao.module.qcreport.controller.admin.instance.vo.QcReportInstanceRespVO;
import cn.iocoder.yudao.module.qcreport.dal.dataobject.instance.QcReportInstanceDO;
import cn.iocoder.yudao.module.qcreport.engine.render.RenderOutcome;
import cn.iocoder.yudao.module.qcreport.service.instance.GenerateFromQcResult;
import cn.iocoder.yudao.module.qcreport.service.instance.GenerateResult;
import cn.iocoder.yudao.module.qcreport.service.instance.PdfArchiveResult;
import cn.iocoder.yudao.module.qcreport.service.instance.QcReportInstanceService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/**
 * æ™ºèƒ½è´¨æ£€æŠ¥å‘Šå®žä¾‹ Controller。
 * <p>
 * è¿™é‡Œæ˜¯ã€Œæ¨¡æ¿ â†’ æŠ¥å‘Šã€çš„对外入口:预览只渲不存,出件才落库并冻结数据快照。
 * æ•°æ®ç”±è°ƒç”¨æ–¹ä¼ å…¥ï¼Œæœ¬æ¨¡å—不认识任何业务单据。
 */
@Tag(name = "管理后台 - æ™ºèƒ½è´¨æ£€æŠ¥å‘Šå®žä¾‹")
@RestController
@RequestMapping("/qc-report/instance")
@Validated
public class QcReportInstanceController {
    @Resource
    private QcReportInstanceService instanceService;
    @PostMapping("/preview")
    @Operation(summary = "预览报告(只渲染,不落库、不消耗报告编号)")
    @PreAuthorize("@ss.hasPermission('qc-report:instance:query')")
    public CommonResult<QcReportInstancePreviewRespVO> preview(@Valid @RequestBody QcReportInstanceGenerateReqVO reqVO) {
        RenderOutcome outcome = instanceService.preview(reqVO);
        QcReportInstancePreviewRespVO respVO = new QcReportInstancePreviewRespVO();
        respVO.setReportNo(outcome.context().getReport() == null ? null : outcome.context().getReport().getReportNo());
        respVO.setHtml(outcome.html());
        respVO.setErrors(outcome.errors());
        return success(respVO);
    }
    @PostMapping("/generate")
    @Operation(summary = "出件:渲染 + è½åº“ + å†»ç»“数据快照")
    @PreAuthorize("@ss.hasPermission('qc-report:instance:create')")
    public CommonResult<QcReportInstanceGenerateRespVO> generate(
            @Valid @RequestBody QcReportInstanceGenerateReqVO reqVO) {
        return success(toGenerateRespVO(instanceService.generate(reqVO)));
    }
    @PostMapping("/generate-from-qc")
    @Operation(summary = "由质检单生成报告(数据从 MES å–,模板由用户选择)")
    @PreAuthorize("@ss.hasPermission('qc-report:instance:generate')")
    public CommonResult<QcReportInstanceGenerateFromQcRespVO> generateFromQc(
            @Valid @RequestBody QcReportInstanceGenerateFromQcReqVO reqVO) {
        GenerateFromQcResult result = instanceService.generateFromQc(reqVO);
        QcReportInstanceGenerateFromQcRespVO respVO = new QcReportInstanceGenerateFromQcRespVO();
        respVO.setId(result.instance().getId());
        respVO.setReportNo(result.instance().getReportNo());
        respVO.setTemplateVersion(result.instance().getTemplateVersion());
        respVO.setItemCount(result.itemCount());
        respVO.setUndecidableCount(result.undecidableCount());
        respVO.setWarnings(result.warnings());
        respVO.setErrors(result.errors());
        return success(respVO);
    }
    @GetMapping("/get")
    @Operation(summary = "获得报告实例(含渲染产物 HTML,不含数据快照)")
    @Parameter(name = "id", description = "实例编号", required = true, example = "1024")
    @PreAuthorize("@ss.hasPermission('qc-report:instance:query')")
    public CommonResult<QcReportInstanceRespVO> getInstance(@RequestParam("id") Long id) {
        QcReportInstanceDO instance = instanceService.getInstance(id);
        return success(BeanUtils.toBean(instance, QcReportInstanceRespVO.class));
    }
    @GetMapping("/snapshot")
    @Operation(summary = "获得报告实例冻结的数据快照")
    @Parameter(name = "id", description = "实例编号", required = true, example = "1024")
    @PreAuthorize("@ss.hasPermission('qc-report:instance:query')")
    public CommonResult<Map<String, Object>> getSnapshot(@RequestParam("id") Long id) {
        return success(instanceService.getSnapshot(id));
    }
    @GetMapping("/page")
    @Operation(summary = "获得报告实例分页")
    @PreAuthorize("@ss.hasPermission('qc-report:instance:query')")
    public CommonResult<PageResult<QcReportInstanceRespVO>> getInstancePage(
            @Valid QcReportInstancePageReqVO pageReqVO) {
        PageResult<QcReportInstanceDO> pageResult = instanceService.getInstancePage(pageReqVO);
        return success(BeanUtils.toBean(pageResult, QcReportInstanceRespVO.class));
    }
    @PostMapping("/regenerate")
    @Operation(summary = "重新生成报告(用冻结的数据快照 + åŒä¸€æ¨¡æ¿ç‰ˆæœ¬é‡æ¸²ï¼Œè¦†ç›–产物 HTML)")
    @Parameter(name = "id", description = "实例编号", required = true, example = "1024")
    @PreAuthorize("@ss.hasPermission('qc-report:instance:create')")
    public CommonResult<QcReportInstanceRegenerateRespVO> regenerate(@RequestParam("id") Long id) {
        GenerateResult result = instanceService.regenerate(id);
        QcReportInstanceRegenerateRespVO respVO = new QcReportInstanceRegenerateRespVO();
        respVO.setId(result.instance().getId());
        respVO.setReportNo(result.instance().getReportNo());
        respVO.setErrors(result.errors());
        return success(respVO);
    }
    @PostMapping("/pdf")
    @Operation(summary = "导出 PDF(打印实例已存的产物 HTML å¹¶å½’档到附件库,可重复调用)")
    @Parameter(name = "id", description = "实例编号", required = true, example = "1024")
    @PreAuthorize("@ss.hasPermission('qc-report:instance:export')")
    public CommonResult<QcReportInstancePdfRespVO> exportPdf(@RequestParam("id") Long id) {
        PdfArchiveResult result = instanceService.exportPdf(id);
        QcReportInstancePdfRespVO respVO = new QcReportInstancePdfRespVO();
        respVO.setId(result.instanceId());
        respVO.setReportNo(result.reportNo());
        respVO.setFileName(result.fileName());
        respVO.setByteSize(result.byteSize());
        respVO.setBlobId(result.blobId());
        respVO.setAttachmentId(result.attachmentId());
        respVO.setPreviewURL(result.previewURL());
        respVO.setDownloadURL(result.downloadURL());
        respVO.setDurationMs(result.durationMs());
        respVO.setPdfDurationMs(result.pdfDurationMs());
        respVO.setBrowserStatus(result.browserStatus());
        return success(respVO);
    }
    @DeleteMapping("/delete")
    @Operation(summary = "删除报告实例")
    @Parameter(name = "id", description = "实例编号", required = true, example = "1024")
    @PreAuthorize("@ss.hasPermission('qc-report:instance:delete')")
    public CommonResult<Boolean> deleteInstance(@RequestParam("id") Long id) {
        instanceService.deleteInstance(id);
        return success(true);
    }
    private QcReportInstanceGenerateRespVO toGenerateRespVO(GenerateResult result) {
        QcReportInstanceDO instance = result.instance();
        QcReportInstanceGenerateRespVO respVO = new QcReportInstanceGenerateRespVO();
        respVO.setId(instance.getId());
        respVO.setReportNo(instance.getReportNo());
        respVO.setTemplateVersion(instance.getTemplateVersion());
        respVO.setStatus(instance.getStatus());
        respVO.setErrors(result.errors());
        return respVO;
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/instance/vo/QcReportInstanceGenerateFromQcReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,37 @@
package cn.iocoder.yudao.module.qcreport.controller.admin.instance.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
 * ç”±è´¨æ£€å•生成报告的入参。
 * <p>
 * ä¸Ž {@link QcReportInstanceGenerateReqVO} çš„区别:那个收的是已经拼好的报告上下文,
 * è¿™ä¸ªåªæ”¶ã€Œå“ªå¼ è´¨æ£€å• + ç”¨å“ªä¸ªæ¨¡æ¿ã€ï¼Œæ•°æ®ç”±æœåŠ¡ç«¯ä»Ž MES å–。
 * äººå·¥é€‰æ¨¡æ¿æ˜¯æ—¢å®šå£å¾„ â€”— åŒä¸€å¼ è´¨æ£€å•可以用不同模板出不同用途的报告(内部版 / å®¢æˆ·ç‰ˆï¼‰ã€‚
 */
@Schema(description = "管理后台 - ç”±è´¨æ£€å•生成报告 Request VO")
@Data
public class QcReportInstanceGenerateFromQcReqVO {
    @Schema(description = "质检类型:1 æ¥æ–™ 2 è¿‡ç¨‹ 3 å‡ºè´§ 4 é€€è´§", requiredMode = Schema.RequiredMode.REQUIRED,
            example = "1")
    @NotNull(message = "质检类型不能为空")
    private Integer qcType;
    @Schema(description = "质检单编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
    @NotNull(message = "质检单编号不能为空")
    private Long qcId;
    @Schema(description = "报告模板编号,由用户选择", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "报告模板编号不能为空")
    private Long templateId;
    @Schema(description = "报告模板版本号。不传则用模板当前已发布的版本", example = "v1.0")
    private String version;
    @Schema(description = "报告编号。不传则按 QR+日期+流水 è‡ªåŠ¨ç”Ÿæˆ", example = "QR20260919-0001")
    private String reportNo;
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/instance/vo/QcReportInstanceGenerateFromQcRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,41 @@
package cn.iocoder.yudao.module.qcreport.controller.admin.instance.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
 * ç”±è´¨æ£€å•生成报告的响应。
 * <p>
 * é™¤äº†ã€Œç”Ÿæˆäº†å“ªä»½æŠ¥å‘Šã€ï¼Œè¿˜å¿…须把「这份报告有什么毛病」一起带回来:
 * {@link #undecidableCount} æ˜¯å…¶ä¸­æœ€å…³é”®çš„一条 â€”— æ£€éªŒæ¨¡æ¿é‡Œæ²¡é…è§„格上下限时,
 * æ£€éªŒé¡¹ä¼šæˆç‰‡åˆ¤ä¸å‡ºæ¥ï¼ŒæŠ¥å‘Šçœ‹èµ·æ¥åƒåäº†ï¼Œå…¶å®žæ˜¯æ•°æ®æ²¡é…å…¨ã€‚前端必须据此提示。
 */
@Schema(description = "管理后台 - ç”±è´¨æ£€å•生成报告 Response VO")
@Data
public class QcReportInstanceGenerateFromQcRespVO {
    @Schema(description = "报告实例编号", example = "2048")
    private Long id;
    @Schema(description = "报告编号", example = "QR20260919-0001")
    private String reportNo;
    @Schema(description = "实际使用的模板版本", example = "v1.0")
    private String templateVersion;
    @Schema(description = "本次写入的检验项条数", example = "13")
    private Integer itemCount;
    @Schema(description = "没有判定结论的项数(「无判定规则」+ è§„则执行失败的「待判定」)", example = "13")
    private Integer undecidableCount;
    @Schema(description = "软提示:样品的取舍、跳过的分组项、尚未录入实测值的项")
    private List<String> warnings = new ArrayList<>();
    @Schema(description = "渲染期数据缺口:模板绑定了但当时没有值的字段")
    private List<String> errors = new ArrayList<>();
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/instance/vo/QcReportInstanceGenerateReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,39 @@
package cn.iocoder.yudao.module.qcreport.controller.admin.instance.vo;
import cn.iocoder.yudao.module.qcreport.engine.context.ReportContext;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
 * å‡ºä»¶ï¼ˆæ¸²æŸ“并落库报告实例)的入参,预览接口也复用它。
 * <p>
 * {@link #context} ç›´æŽ¥ç”¨å¼•擎的 {@link ReportContext},不另造一个镜像 VO:
 * é‚£å°±æ˜¯è¿™ä¸ªå¹³å°å¯¹å¤–唯一的数据契约,镜像出来只是多一份需要同步维护的字段清单。
 * å­—段级的边界校验放在 Service é‡Œåšï¼Œå¼•擎实体本身不挂校验注解。
 */
@Schema(description = "管理后台 - æ™ºèƒ½è´¨æ£€æŠ¥å‘Šå‡ºä»¶ Request VO")
@Data
public class QcReportInstanceGenerateReqVO {
    @Schema(description = "模板编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "5")
    @NotNull(message = "模板编号不能为空")
    private Long templateId;
    @Schema(description = "模板版本号。不传则用模板当前已发布的版本", example = "v1.0")
    private String version;
    @Schema(description = "报告编号。不传则按 QR+日期+流水 è‡ªåŠ¨ç”Ÿæˆ", example = "QR20260918-0001")
    private String reportNo;
    @Schema(description = "业务单据编号,用于将来按单据反查报告", example = "1024")
    private String businessId;
    @Schema(description = "业务单据类型,如 mes_qc_iqc / mes_qc_ipqc / mes_qc_oqc / mes_qc_rqc", example = "mes_qc_oqc")
    private String businessType;
    @Schema(description = "报告数据上下文:报告级字段 + æ£€éªŒé¡¹åˆ—表", requiredMode = Schema.RequiredMode.REQUIRED)
    @NotNull(message = "报告数据不能为空")
    private ReportContext context;
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/instance/vo/QcReportInstanceGenerateRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,32 @@
package cn.iocoder.yudao.module.qcreport.controller.admin.instance.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
/**
 * å‡ºä»¶ç»“果。
 * <p>
 * ä¸å›ž HTML(可能很长),要正文请拿 {@link #id} åŽ»æŸ¥è¯¦æƒ…ã€‚
 */
@Schema(description = "管理后台 - æ™ºèƒ½è´¨æ£€æŠ¥å‘Šå‡ºä»¶ Response VO")
@Data
public class QcReportInstanceGenerateRespVO {
    @Schema(description = "实例编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
    private Long id;
    @Schema(description = "报告编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "QR20260918-0001")
    private String reportNo;
    @Schema(description = "生成时使用的模板版本号", requiredMode = Schema.RequiredMode.REQUIRED, example = "v1.0")
    private String templateVersion;
    @Schema(description = "报告状态:0生成中 1生成成功 2生成失败", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    private Integer status;
    @Schema(description = "数据缺口清单:报告已经出来了,但这些字段当时没有值")
    private List<String> errors;
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/instance/vo/QcReportInstancePageReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,39 @@
package cn.iocoder.yudao.module.qcreport.controller.admin.instance.vo;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
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 = "管理后台 - æ™ºèƒ½è´¨æ£€æŠ¥å‘Šå®žä¾‹åˆ†é¡µ Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class QcReportInstancePageReqVO extends PageParam {
    @Schema(description = "模板编号", example = "5")
    private Long templateId;
    @Schema(description = "报告编号(模糊匹配)", example = "QR20260918")
    private String reportNo;
    @Schema(description = "业务单据类型", example = "mes_qc_oqc")
    private String businessType;
    @Schema(description = "业务单据编号", example = "1024")
    private String businessId;
    @Schema(description = "报告状态:0生成中 1生成成功 2生成失败", example = "1")
    private Integer status;
    @Schema(description = "创建时间")
    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
    private LocalDateTime[] createTime;
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/instance/vo/QcReportInstancePdfRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,46 @@
package cn.iocoder.yudao.module.qcreport.controller.admin.instance.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
 * PDF å‡ºä»¶ç»“果:把实例里已存的渲染产物 HTML æ‰“印成 PDF å¹¶å½’档到附件库。
 */
@Schema(description = "管理后台 - æ™ºèƒ½è´¨æ£€æŠ¥å‘Š PDF å‡ºä»¶ Response VO")
@Data
public class QcReportInstancePdfRespVO {
    @Schema(description = "实例编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
    private Long id;
    @Schema(description = "报告编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "QR20260918-0001")
    private String reportNo;
    @Schema(description = "PDF æ–‡ä»¶å", requiredMode = Schema.RequiredMode.REQUIRED, example = "QR20260918-0001.pdf")
    private String fileName;
    @Schema(description = "PDF å­—节数", requiredMode = Schema.RequiredMode.REQUIRED, example = "58214")
    private Long byteSize;
    @Schema(description = "文件编号(system_storage_blob ä¸»é”®ï¼‰", example = "2048")
    private Long blobId;
    @Schema(description = "附件关联编号(system_storage_attachment ä¸»é”®ï¼‰", example = "4096")
    private Long attachmentId;
    @Schema(description = "预览地址(临时签名,会过期;要长期可用请重新查询附件列表)")
    private String previewURL;
    @Schema(description = "下载地址(临时签名,会过期;要长期可用请重新查询附件列表)")
    private String downloadURL;
    @Schema(description = "本次出件总耗时(毫秒)", example = "1820")
    private Long durationMs;
    @Schema(description = "打印耗时(毫秒),仅含等页面资源与打印,与总耗时之差为排队/启动等待", example = "940")
    private Long pdfDurationMs;
    @Schema(description = "浏览器状态:READY(复用已有实例)/ RESTARTED(本次重启了浏览器)", example = "READY")
    private String browserStatus;
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/instance/vo/QcReportInstancePreviewRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,27 @@
package cn.iocoder.yudao.module.qcreport.controller.admin.instance.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
/**
 * é¢„览结果:只渲染,不落库。
 * <p>
 * é¢„览**不生成、不消耗**报告编号流水:拿到的 {@link #reportNo} æ˜¯å…¥å‚里带过来的那个,
 * æ²¡å¸¦å°±æ˜¯ç©ºã€‚真正的编号在生成时确定。
 */
@Schema(description = "管理后台 - æ™ºèƒ½è´¨æ£€æŠ¥å‘Šé¢„览 Response VO")
@Data
public class QcReportInstancePreviewRespVO {
    @Schema(description = "本次渲染使用的报告编号(来自入参,预览不生成编号)", example = "QR20260918-0001")
    private String reportNo;
    @Schema(description = "渲染产物 HTML")
    private String html;
    @Schema(description = "数据缺口清单:模板绑定了但数据里没有的字段、被拦下的不安全属性等")
    private List<String> errors;
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/instance/vo/QcReportInstanceRegenerateRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
package cn.iocoder.yudao.module.qcreport.controller.admin.instance.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
/**
 * é‡æ–°ç”Ÿæˆç»“果:用冻结的数据快照 + åŒä¸€æ¨¡æ¿ç‰ˆæœ¬é‡æ¸²ã€‚
 * <p>
 * æŠ¥å‘Šç¼–号与数据都不变,产物按复现性应当与首次生成逐字相同。
 */
@Schema(description = "管理后台 - æ™ºèƒ½è´¨æ£€æŠ¥å‘Šé‡æ–°ç”Ÿæˆ Response VO")
@Data
public class QcReportInstanceRegenerateRespVO {
    @Schema(description = "实例编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
    private Long id;
    @Schema(description = "报告编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "QR20260918-0001")
    private String reportNo;
    @Schema(description = "数据缺口清单")
    private List<String> errors;
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/instance/vo/QcReportInstanceRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,51 @@
package cn.iocoder.yudao.module.qcreport.controller.admin.instance.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
 * æŠ¥å‘Šå®žä¾‹çš„展示字段。
 * <p>
 * æœ‰æ„ä¸å« {@code dataSnapshot}:那是排查「这份历史报告当时用的什么数据」用的,
 * ä½“积大、前端也不需要,取它请走 {@code /qc-report/instance/snapshot}。
 */
@Schema(description = "管理后台 - æ™ºèƒ½è´¨æ£€æŠ¥å‘Šå®žä¾‹ Response VO")
@Data
public class QcReportInstanceRespVO {
    @Schema(description = "实例编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
    private Long id;
    @Schema(description = "报告编号(业务唯一)", requiredMode = Schema.RequiredMode.REQUIRED, example = "QR20260918-0001")
    private String reportNo;
    @Schema(description = "模板编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "5")
    private Long templateId;
    @Schema(description = "生成时使用的模板版本号", requiredMode = Schema.RequiredMode.REQUIRED, example = "v1.0")
    private String templateVersion;
    @Schema(description = "业务单据编号", example = "1024")
    private String businessId;
    @Schema(description = "业务单据类型", example = "mes_qc_oqc")
    private String businessType;
    @Schema(description = "渲染产物 HTML")
    private String renderHtml;
    @Schema(description = "报告状态:0生成中 1生成成功 2生成失败", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    private Integer status;
    @Schema(description = "创建人", example = "1")
    private String creator;
    @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
    private LocalDateTime createTime;
    @Schema(description = "更新时间")
    private LocalDateTime updateTime;
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/template/QcReportTemplateController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,94 @@
package cn.iocoder.yudao.module.qcreport.controller.admin.template;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.qcreport.controller.admin.template.vo.QcReportTemplatePageReqVO;
import cn.iocoder.yudao.module.qcreport.controller.admin.template.vo.QcReportTemplateRespVO;
import cn.iocoder.yudao.module.qcreport.controller.admin.template.vo.QcReportTemplateSaveReqVO;
import cn.iocoder.yudao.module.qcreport.dal.dataobject.template.QcReportTemplateDO;
import cn.iocoder.yudao.module.qcreport.service.template.QcReportTemplateService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@Tag(name = "管理后台 - æ™ºèƒ½è´¨æ£€æŠ¥å‘Šæ¨¡æ¿")
@RestController
@RequestMapping("/qc-report/template")
@Validated
public class QcReportTemplateController {
    @Resource
    private QcReportTemplateService templateService;
    @PostMapping("/create")
    @Operation(summary = "创建报告模板")
    @PreAuthorize("@ss.hasPermission('qc-report:template:create')")
    public CommonResult<Long> createTemplate(@Valid @RequestBody QcReportTemplateSaveReqVO createReqVO) {
        return success(templateService.createTemplate(createReqVO));
    }
    @PutMapping("/update")
    @Operation(summary = "更新报告模板")
    @PreAuthorize("@ss.hasPermission('qc-report:template:update')")
    public CommonResult<Boolean> updateTemplate(@Valid @RequestBody QcReportTemplateSaveReqVO updateReqVO) {
        templateService.updateTemplate(updateReqVO);
        return success(true);
    }
    @DeleteMapping("/delete")
    @Operation(summary = "删除报告模板")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('qc-report:template:delete')")
    public CommonResult<Boolean> deleteTemplate(@RequestParam("id") Long id) {
        templateService.deleteTemplate(id);
        return success(true);
    }
    @PostMapping("/copy")
    @Operation(summary = "复制报告模板")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('qc-report:template:create')")
    public CommonResult<Long> copyTemplate(@RequestParam("id") Long id) {
        return success(templateService.copyTemplate(id));
    }
    @GetMapping("/get")
    @Operation(summary = "获得报告模板")
    @Parameter(name = "id", description = "编号", required = true, example = "1024")
    @PreAuthorize("@ss.hasPermission('qc-report:template:query')")
    public CommonResult<QcReportTemplateRespVO> getTemplate(@RequestParam("id") Long id) {
        QcReportTemplateDO template = templateService.getTemplate(id);
        return success(BeanUtils.toBean(template, QcReportTemplateRespVO.class));
    }
    @GetMapping("/page")
    @Operation(summary = "获得报告模板分页")
    @PreAuthorize("@ss.hasPermission('qc-report:template:query')")
    public CommonResult<PageResult<QcReportTemplateRespVO>> getTemplatePage(@Valid QcReportTemplatePageReqVO pageReqVO) {
        PageResult<QcReportTemplateDO> pageResult = templateService.getTemplatePage(pageReqVO);
        return success(BeanUtils.toBean(pageResult, QcReportTemplateRespVO.class));
    }
    @GetMapping("/selectable")
    @Operation(summary = "获得可用于出件的模板列表",
            description = "三个条件同时满足才会返回:报告类型一致、模板已启用、当前版本已发布且画布有可渲染的内容。"
                    + "最后一条挡的是「打开过设计器但没放组件」的版本——它渲染出来是一张白纸且不报错。")
    @Parameter(name = "reportType", description = "报告类型,即 mes_qc_type çš„æ•°å€¼å­—符串", required = true, example = "1")
    @PreAuthorize("@ss.hasPermission('qc-report:template:query')")
    public CommonResult<List<QcReportTemplateRespVO>> getSelectableTemplateList(
            @RequestParam("reportType") String reportType) {
        List<QcReportTemplateDO> list = templateService.getSelectableTemplates(reportType);
        return success(BeanUtils.toBean(list, QcReportTemplateRespVO.class));
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/template/vo/QcReportTemplatePageReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,45 @@
package cn.iocoder.yudao.module.qcreport.controller.admin.template.vo;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
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 = "管理后台 - æ™ºèƒ½è´¨æ£€æŠ¥å‘Šæ¨¡æ¿åˆ†é¡µ Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class QcReportTemplatePageReqVO extends PageParam {
    @Schema(description = "模板编码", example = "QC_REPORT_IQC")
    private String templateCode;
    @Schema(description = "模板名称", example = "来料检验报告")
    private String templateName;
    @Schema(description = "所属行业", example = "manufacturing")
    private String industry;
    @Schema(description = "报告类型", example = "IQC")
    private String reportType;
    @Schema(description = "模板状态:0启用 1停用", example = "0")
    private Integer status;
    @Schema(description = "当前版本号", example = "v1.0")
    private String currentVersion;
    @Schema(description = "创建人", example = "1")
    private String creator;
    @Schema(description = "创建时间")
    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
    private LocalDateTime[] createTime;
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/template/vo/QcReportTemplateRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,51 @@
package cn.iocoder.yudao.module.qcreport.controller.admin.template.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - æ™ºèƒ½è´¨æ£€æŠ¥å‘Šæ¨¡æ¿ Response VO")
@Data
public class QcReportTemplateRespVO {
    @Schema(description = "模板编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
    private Long id;
    @Schema(description = "模板编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "QC_REPORT_IQC_A4")
    private String templateCode;
    @Schema(description = "模板名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "来料检验报告")
    private String templateName;
    @Schema(description = "所属行业", example = "manufacturing")
    private String industry;
    @Schema(description = "报告类型", example = "IQC")
    private String reportType;
    @Schema(description = "纸张尺寸", example = "A4")
    private String pageSize;
    @Schema(description = "纸张方向", example = "portrait")
    private String orientation;
    @Schema(description = "模板状态:0启用 1停用", example = "0")
    private Integer status;
    @Schema(description = "当前版本号", example = "v1.0")
    private String currentVersion;
    @Schema(description = "模板描述", example = "用于来料检验单的 A4 æŠ¥å‘Šæ¨¡æ¿")
    private String description;
    @Schema(description = "创建人", example = "1")
    private String creator;
    @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
    private LocalDateTime createTime;
    @Schema(description = "更新时间")
    private LocalDateTime updateTime;
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/template/vo/QcReportTemplateSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,57 @@
package cn.iocoder.yudao.module.qcreport.controller.admin.template.vo;
import cn.iocoder.yudao.framework.common.validation.InEnum;
import cn.iocoder.yudao.module.qcreport.enums.QcReportEnums.OrientationEnum;
import cn.iocoder.yudao.module.qcreport.enums.QcReportEnums.PageSizeEnum;
import cn.iocoder.yudao.module.qcreport.enums.QcReportEnums.TemplateStatusEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Schema(description = "管理后台 - æ™ºèƒ½è´¨æ£€æŠ¥å‘Šæ¨¡æ¿ åˆ›å»º/修改 Request VO")
@Data
public class QcReportTemplateSaveReqVO {
    @Schema(description = "模板编号", example = "1024")
    private Long id;
    @Schema(description = "模板编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "QC_REPORT_IQC_A4")
    @NotEmpty(message = "模板编码不能为空")
    @Size(max = 64, message = "模板编码长度不能超过 64 ä¸ªå­—符")
    @Pattern(regexp = "^[A-Za-z0-9_-]+$", message = "模板编码只能包含字母、数字、下划线和短横线")
    private String templateCode;
    @Schema(description = "模板名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "来料检验报告")
    @NotEmpty(message = "模板名称不能为空")
    @Size(max = 128, message = "模板名称长度不能超过 128 ä¸ªå­—符")
    private String templateName;
    @Schema(description = "所属行业", example = "manufacturing")
    @Size(max = 32, message = "所属行业长度不能超过 32 ä¸ªå­—符")
    private String industry;
    @Schema(description = "报告类型", example = "IQC")
    @Size(max = 32, message = "报告类型长度不能超过 32 ä¸ªå­—符")
    private String reportType;
    @Schema(description = "纸张尺寸", requiredMode = Schema.RequiredMode.REQUIRED, example = "A4")
    @NotEmpty(message = "纸张尺寸不能为空")
    @InEnum(PageSizeEnum.class)
    private String pageSize;
    @Schema(description = "纸张方向", requiredMode = Schema.RequiredMode.REQUIRED, example = "portrait")
    @NotEmpty(message = "纸张方向不能为空")
    @InEnum(OrientationEnum.class)
    private String orientation;
    @Schema(description = "模板状态:0启用 1停用", example = "0")
    @InEnum(TemplateStatusEnum.class)
    private Integer status;
    @Schema(description = "模板描述", example = "用于来料检验单的 A4 æŠ¥å‘Šæ¨¡æ¿")
    @Size(max = 512, message = "模板描述长度不能超过 512 ä¸ªå­—符")
    private String description;
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/version/QcReportTemplateVersionController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,113 @@
package cn.iocoder.yudao.module.qcreport.controller.admin.version;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.qcreport.controller.admin.version.vo.QcReportTemplateVersionPageReqVO;
import cn.iocoder.yudao.module.qcreport.controller.admin.version.vo.QcReportTemplateVersionRespVO;
import cn.iocoder.yudao.module.qcreport.controller.admin.version.vo.QcReportTemplateVersionSaveReqVO;
import cn.iocoder.yudao.module.qcreport.controller.admin.version.vo.QcReportTemplateVersionUpdateReqVO;
import cn.iocoder.yudao.module.qcreport.dal.dataobject.version.QcReportTemplateVersionDO;
import cn.iocoder.yudao.module.qcreport.service.version.QcReportTemplateVersionService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@Tag(name = "管理后台 - æ™ºèƒ½è´¨æ£€æŠ¥å‘Šæ¨¡æ¿ç‰ˆæœ¬")
@RestController
@RequestMapping("/qc-report/template-version")
@Validated
public class QcReportTemplateVersionController {
    @Resource
    private QcReportTemplateVersionService versionService;
    @PostMapping("/create")
    @Operation(summary = "创建模板版本")
    @PreAuthorize("@ss.hasPermission('qc-report:template:update')")
    public CommonResult<Long> createVersion(@Valid @RequestBody QcReportTemplateVersionSaveReqVO createReqVO) {
        return success(versionService.createVersion(createReqVO));
    }
    @PutMapping("/update")
    @Operation(summary = "更新模板版本(已发布的版本不可修改)")
    @PreAuthorize("@ss.hasPermission('qc-report:template:update')")
    public CommonResult<Boolean> updateVersion(@Valid @RequestBody QcReportTemplateVersionUpdateReqVO updateReqVO) {
        versionService.updateVersion(updateReqVO);
        return success(true);
    }
    @DeleteMapping("/delete")
    @Operation(summary = "删除模板版本(仅草稿可删除)")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('qc-report:template:delete')")
    public CommonResult<Boolean> deleteVersion(@RequestParam("id") Long id) {
        versionService.deleteVersion(id);
        return success(true);
    }
    @PostMapping("/publish")
    @Operation(summary = "发布模板版本")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('qc-report:template:publish')")
    public CommonResult<Boolean> publishVersion(@RequestParam("id") Long id) {
        versionService.publishVersion(id);
        return success(true);
    }
    @PostMapping("/rollback")
    @Operation(summary = "回滚到指定历史版本")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('qc-report:template:publish')")
    public CommonResult<Boolean> rollbackVersion(@RequestParam("id") Long id) {
        versionService.rollbackVersion(id);
        return success(true);
    }
    @PostMapping("/disable")
    @Operation(summary = "停用模板版本")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('qc-report:template:publish')")
    public CommonResult<Boolean> disableVersion(@RequestParam("id") Long id) {
        versionService.disableVersion(id);
        return success(true);
    }
    @GetMapping("/get")
    @Operation(summary = "获得模板版本")
    @Parameter(name = "id", description = "编号", required = true, example = "1024")
    @PreAuthorize("@ss.hasPermission('qc-report:template:query')")
    public CommonResult<QcReportTemplateVersionRespVO> getVersion(@RequestParam("id") Long id) {
        QcReportTemplateVersionDO version = versionService.getVersion(id);
        return success(BeanUtils.toBean(version, QcReportTemplateVersionRespVO.class));
    }
    @GetMapping("/list-by-template")
    @Operation(summary = "获得模板的所有版本")
    @Parameter(name = "templateId", description = "模板编号", required = true)
    @PreAuthorize("@ss.hasPermission('qc-report:template:query')")
    public CommonResult<List<QcReportTemplateVersionRespVO>> getVersionListByTemplateId(
            @RequestParam("templateId") Long templateId) {
        List<QcReportTemplateVersionDO> list = versionService.getVersionListByTemplateId(templateId);
        return success(BeanUtils.toBean(list, QcReportTemplateVersionRespVO.class));
    }
    @GetMapping("/page")
    @Operation(summary = "获得模板版本分页")
    @PreAuthorize("@ss.hasPermission('qc-report:template:query')")
    public CommonResult<PageResult<QcReportTemplateVersionRespVO>> getVersionPage(
            @Valid QcReportTemplateVersionPageReqVO pageReqVO) {
        PageResult<QcReportTemplateVersionDO> pageResult = versionService.getVersionPage(pageReqVO);
        return success(BeanUtils.toBean(pageResult, QcReportTemplateVersionRespVO.class));
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/version/vo/QcReportTemplateVersionPageReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,33 @@
package cn.iocoder.yudao.module.qcreport.controller.admin.version.vo;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
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 = "管理后台 - æ™ºèƒ½è´¨æ£€æŠ¥å‘Šæ¨¡æ¿ç‰ˆæœ¬åˆ†é¡µ Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class QcReportTemplateVersionPageReqVO extends PageParam {
    @Schema(description = "模板编号", example = "1024")
    private Long templateId;
    @Schema(description = "版本号", example = "v1.0")
    private String version;
    @Schema(description = "版本状态:0草稿 1已发布 2已停用", example = "1")
    private Integer status;
    @Schema(description = "创建时间")
    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
    private LocalDateTime[] createTime;
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/version/vo/QcReportTemplateVersionRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,40 @@
package cn.iocoder.yudao.module.qcreport.controller.admin.version.vo;
import cn.iocoder.yudao.module.qcreport.dal.dataobject.version.ReportTemplateSchema;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - æ™ºèƒ½è´¨æ£€æŠ¥å‘Šæ¨¡æ¿ç‰ˆæœ¬ Response VO")
@Data
public class QcReportTemplateVersionRespVO {
    @Schema(description = "版本编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
    private Long id;
    @Schema(description = "模板编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
    private Long templateId;
    @Schema(description = "版本号", requiredMode = Schema.RequiredMode.REQUIRED, example = "v1.0")
    private String version;
    @Schema(description = "模板 Schema(设计器产物)")
    private ReportTemplateSchema schema;
    @Schema(description = "版本状态:0草稿 1已发布 2已停用", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
    private Integer status;
    @Schema(description = "版本说明", example = "增加抽样检测表格")
    private String description;
    @Schema(description = "创建人", example = "1")
    private String creator;
    @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
    private LocalDateTime createTime;
    @Schema(description = "更新时间")
    private LocalDateTime updateTime;
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/version/vo/QcReportTemplateVersionSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,33 @@
package cn.iocoder.yudao.module.qcreport.controller.admin.version.vo;
import cn.iocoder.yudao.module.qcreport.dal.dataobject.version.ReportTemplateSchema;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Schema(description = "管理后台 - æ™ºèƒ½è´¨æ£€æŠ¥å‘Šæ¨¡æ¿ç‰ˆæœ¬ åˆ›å»º Request VO")
@Data
public class QcReportTemplateVersionSaveReqVO {
    @Schema(description = "版本编号", example = "1024")
    private Long id;
    @Schema(description = "模板编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
    @NotNull(message = "模板编号不能为空")
    private Long templateId;
    @Schema(description = "版本号,留空则按最新版本自动递增(如 v1.0 -> v1.1)", example = "v1.0")
    @Pattern(regexp = "^v\\d+(\\.\\d+)*$", message = "版本号格式必须形如 v1.0")
    @Size(max = 32, message = "版本号长度不能超过 32 ä¸ªå­—符")
    private String version;
    @Schema(description = "模板 Schema(设计器产物)")
    private ReportTemplateSchema schema;
    @Schema(description = "版本说明", example = "增加抽样检测表格")
    @Size(max = 512, message = "版本说明长度不能超过 512 ä¸ªå­—符")
    private String description;
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/controller/admin/version/vo/QcReportTemplateVersionUpdateReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
package cn.iocoder.yudao.module.qcreport.controller.admin.version.vo;
import cn.iocoder.yudao.module.qcreport.dal.dataobject.version.ReportTemplateSchema;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
 * æ¨¡æ¿ç‰ˆæœ¬ä¿®æ”¹ Request VO
 * <p>
 * ä¸Žåˆ›å»º VO åˆ†ç¦»ï¼šå½’属模板与版本号在创建后不可变更,更新只允许改设计内容与说明
 */
@Schema(description = "管理后台 - æ™ºèƒ½è´¨æ£€æŠ¥å‘Šæ¨¡æ¿ç‰ˆæœ¬ ä¿®æ”¹ Request VO")
@Data
public class QcReportTemplateVersionUpdateReqVO {
    @Schema(description = "版本编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
    @NotNull(message = "版本编号不能为空")
    private Long id;
    @Schema(description = "模板 Schema(设计器产物)")
    private ReportTemplateSchema schema;
    @Schema(description = "版本说明", example = "增加抽样检测表格")
    @Size(max = 512, message = "版本说明长度不能超过 512 ä¸ªå­—符")
    private String description;
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/dal/dataobject/instance/QcReportInstanceDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,72 @@
package cn.iocoder.yudao.module.qcreport.dal.dataobject.instance;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.Jackson3TypeHandler;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.ToString;
import java.util.Map;
/**
 * æŠ¥å‘Šå®žä¾‹ DO
 * <p>
 * ç”ŸæˆæŠ¥å‘Šæ—¶å¿…须保存 dataSnapshot:历史报告不能因为业务数据变化而发生变化。
 */
@TableName(value = "qc_report_instance", autoResultMap = true)
@KeySequence("qc_report_instance_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class QcReportInstanceDO extends BaseDO {
    /**
     * å®žä¾‹ç¼–号
     */
    @TableId
    private Long id;
    /**
     * æŠ¥å‘Šç¼–号(业务唯一)
     */
    private String reportNo;
    /**
     * æ¨¡æ¿ç¼–号
     */
    private Long templateId;
    /**
     * ç”Ÿæˆæ—¶ä½¿ç”¨çš„æ¨¡æ¿ç‰ˆæœ¬å·
     */
    private String templateVersion;
    /**
     * ä¸šåŠ¡å•æ®ç¼–å·ï¼Œå¦‚è´¨æ£€å• ID
     */
    private String businessId;
    /**
     * ä¸šåŠ¡å•æ®ç±»åž‹ï¼Œå¦‚ mes_qc_iqc
     */
    private String businessType;
    /**
     * æ•°æ®å¿«ç…§ï¼šæ¸²æŸ“时的完整数据上下文,保证历史报告可复现
     */
    @TableField(typeHandler = Jackson3TypeHandler.class)
    private Map<String, Object> dataSnapshot;
    /**
     * æ¸²æŸ“产物 HTML
     */
    private String renderHtml;
    /**
     * æŠ¥å‘ŠçŠ¶æ€ã€‚è§ QcReportInstanceStatusEnum
     */
    private Integer status;
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/dal/dataobject/render/QcReportRenderRecordDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,63 @@
package cn.iocoder.yudao.module.qcreport.dal.dataobject.render;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.ToString;
import java.time.LocalDateTime;
/**
 * æ™ºèƒ½è´¨æ£€æŠ¥å‘Šæ¸²æŸ“记录 DO
 * <p>
 * åªä¸ºæŽ’查用:一次 PDF å‡ºä»¶å äº†å¤šä¹…、浏览器什么状态、失败时异常栈是什么。
 * æŠ¥å‘Šæ­£æ–‡ä¸åœ¨è¿™é‡Œï¼ŒæˆåŠŸä¸Žå¦ä¹Ÿä¸å½±å“æŠ¥å‘Šæœ¬èº«ã€‚
 */
@TableName("qc_report_render_record")
@KeySequence("qc_report_render_record_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class QcReportRenderRecordDO extends BaseDO {
    /** ç¼–号 */
    @TableId
    private Long id;
    /** æŠ¥å‘Šå®žä¾‹ç¼–号 */
    private Long reportId;
    /** æ¨¡æ¿ç¼–号 */
    private Long templateId;
    /** ä¸šåŠ¡å•æ®ç¼–å· */
    private String businessId;
    /** æ¸²æŸ“开始时间 */
    private LocalDateTime renderStartTime;
    /** æ¸²æŸ“结束时间 */
    private LocalDateTime renderEndTime;
    /** æ¸²æŸ“耗时(毫秒):从开始打印到 PDF å­—节到手 */
    private Long renderDuration;
    /** PDF ç”Ÿæˆè€—时(毫秒) */
    private Long pdfDuration;
    /** æµè§ˆå™¨çŠ¶æ€ï¼šREADY(复用已有实例)/ RESTARTED(本次重启了浏览器)/ FAILED(启动失败) */
    private String browserStatus;
    /** å¼‚常堆栈,成功时为空 */
    private String errorStack;
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/dal/dataobject/template/QcReportTemplateDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,69 @@
package cn.iocoder.yudao.module.qcreport.dal.dataobject.template;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.ToString;
/**
 * æŠ¥å‘Šæ¨¡æ¿ DO
 */
@TableName("qc_report_template")
@KeySequence("qc_report_template_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class QcReportTemplateDO extends BaseDO {
    /**
     * æ¨¡æ¿ç¼–号
     */
    @TableId
    private Long id;
    /**
     * æ¨¡æ¿ç¼–码
     */
    private String templateCode;
    /**
     * æ¨¡æ¿åç§°
     */
    private String templateName;
    /**
     * æ‰€å±žè¡Œä¸šã€‚见 QcReportIndustryEnum
     */
    private String industry;
    /**
     * æŠ¥å‘Šç±»åž‹ã€‚见 QcReportTypeEnum
     */
    private String reportType;
    /**
     * çº¸å¼ å°ºå¯¸ã€‚见 QcReportPageSizeEnum,如 A4
     */
    private String pageSize;
    /**
     * çº¸å¼ æ–¹å‘。见 QcReportOrientationEnum:portrait / landscape
     */
    private String orientation;
    /**
     * æ¨¡æ¿çŠ¶æ€ã€‚è§ QcReportTemplateStatusEnum
     */
    private Integer status;
    /**
     * å½“前版本号,如 v1.0
     */
    private String currentVersion;
    /**
     * æ¨¡æ¿æè¿°
     */
    private String description;
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/dal/dataobject/version/QcReportTemplateVersionDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,58 @@
package cn.iocoder.yudao.module.qcreport.dal.dataobject.version;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.Jackson3TypeHandler;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.ToString;
/**
 * æŠ¥å‘Šæ¨¡æ¿ç‰ˆæœ¬ DO
 * <p>
 * ç‰ˆæœ¬å‘布后不允许覆盖,只能新建版本,保证历史报告可复现。
 */
@TableName(value = "qc_report_template_version", autoResultMap = true)
@KeySequence("qc_report_template_version_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class QcReportTemplateVersionDO extends BaseDO {
    /**
     * ç‰ˆæœ¬ç¼–号
     */
    @TableId
    private Long id;
    /**
     * æ¨¡æ¿ç¼–号
     */
    private Long templateId;
    /**
     * ç‰ˆæœ¬å·ï¼Œå¦‚ v1.0
     */
    private String version;
    /**
     * æ¨¡æ¿ Schema(GrapesJS JSON + Quality Component JSON + æ•°æ®æº + ç»‘定 + è§„则 + æ ·å¼ï¼‰
     */
    @TableField(value = "schema_json", typeHandler = Jackson3TypeHandler.class)
    private ReportTemplateSchema schema;
    /**
     * ç‰ˆæœ¬çŠ¶æ€ã€‚è§ QcReportVersionStatusEnum
     */
    private Integer status;
    /**
     * ç‰ˆæœ¬è¯´æ˜Ž
     */
    private String description;
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/dal/dataobject/version/ReportTemplateSchema.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,145 @@
package cn.iocoder.yudao.module.qcreport.dal.dataobject.version;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
 * æŠ¥å‘Šæ¨¡æ¿ Schema â€”— è®¾è®¡å™¨äº§ç‰©ï¼Œä¹Ÿæ˜¯æŠ¥å‘Šå‡ºä»¶çš„唯一输入。
 * <p>
 * æ¨¡æ¿ä¸ä¿å­˜è£¸ HTML。保存的是「画布数据 + ä¸šåŠ¡è¯­ä¹‰ã€ï¼Œç”±æ¸²æŸ“å¼•æ“Žæ®æ­¤ç”Ÿæˆ HTML,
 * è¿™æ ·åŒä¸€ä»½æ¨¡æ¿åœ¨ä¸åŒæ¸²æŸ“环境下产物一致,也才有可能做服务端 Sanitization。
 *
 * <h3>字段分工</h3>
 * <table>
 *   <tr><td>{@link #grapes}</td><td>GrapesJS åŽŸå§‹é¡¹ç›®æ•°æ®ã€‚è®¾è®¡å™¨é å®ƒæ— æŸè¿˜åŽŸç”»å¸ƒï¼Œ<b>渲染引擎也读它</b>(正文结构在这里)</td></tr>
 *   <tr><td>{@link #components}</td><td>质量组件业务语义清单。渲染不读,供设计器定位组件、供后续 AI ç”Ÿæˆæ¨¡æ¿æ—¶ç†è§£ç»“æž„</td></tr>
 *   <tr><td>{@link #rules}</td><td>判定规则。渲染前由判定器求值,决定每项 PASS/FAIL ä¸ŽæŠ¥å‘Šç»“论</td></tr>
 *   <tr><td>{@link #page}</td><td>纸张与页边距,渲染时换算成 CSS {@code @page}</td></tr>
 * </table>
 *
 * <h3>语义层是<strong>有意的子集</strong>(重要)</h3>
 * {@link #components} åªè¡¨è¾¾ã€Œç”¨å·²æ³¨å†Œçš„质量组件搭出来的扁平结构」,
 * <b>不表达任意 HTML</b>——用户手工拖进画布的裸表格、自定义 div ä¸åœ¨å…¶å†…。
 * è¿™æ˜¯æœ‰æ„æ”¶çª„的边界,不是实现疏漏:有这条边界,AI ç”Ÿæˆæ¨¡æ¿æ—¶æ‰èƒ½è¢«çº¦æŸæˆ
 * ã€Œåªèƒ½ç”¨å·²æ³¨å†Œç»„件当积木」,而不是吐一段谁也不敢渲染的 HTML。
 * ä»»ä½•「给语义层加个万能 customHtml å­—段」的改动都在拆这条边界,需要先讨论。
 *
 * <h3>兼容性</h3>
 * <p>
 * {@code schema_json} åˆ—里已经存着含 1.0 å­—段(dataSources / bindings / styles)的历史 JSON,
 * æ‰€ä»¥è¿™é‡Œæ˜¾å¼æ ‡æ³¨ {@link JsonIgnoreProperties} å®¹é”™ã€‚
 * <p>
 * è¯´æ˜Žä¸€å¥ï¼Œå…å¾—后人以为它必不可少:<b>去掉这个注解,兼容性测试也是绿的</b>——
 * è¯»åº“那条链路用的 {@code Jackson3TypeHandler} è‡ªå·± new çš„ ObjectMapper,
 * è€Œ Jackson 3 çš„ {@code FAIL_ON_UNKNOWN_PROPERTIES} é»˜è®¤å°±æ˜¯å…³çš„。
 * çœŸæ­£éœ€è¦å®ƒé˜²çš„æ˜¯å¦å¤–两条:Spring ååºåˆ—化请求体时用的 ObjectMapper(配置不受本模块控制),
 * ä»¥åŠå°†æ¥æœ‰äººç»™ç±»åž‹å¤„理器换一个更严格的 Mapper。留着是把意图写明,不是摆设。
 *
 * <h3>版本历史</h3>
 * <ul>
 *   <li><b>1.0</b>:初版。含 dataSources / bindings / styles ä¸‰ä¸ªå­—段,但它们始终是空数组,
 *       æ—¢æ— äººå†™å…¥ä¹Ÿæ— äººè¯»å–,属死数据。</li>
 *   <li><b>1.1</b>:删除上述三个死字段,{@code components} ç”± {@code Map} æ”¹ä¸ºå¼ºç±»åž‹
 *       {@link QualityComponentNode}。因死字段原本就恒为空数组,1.0 â†’ 1.1 æ— éœ€æ•°æ®è¿ç§»ã€‚</li>
 * </ul>
 */
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class ReportTemplateSchema {
    /**
     * å½“前 Schema ç‰ˆæœ¬å·ã€‚
     * <p>
     * ä¸Žå‰ç«¯ {@code designer/constants.ts} çš„ {@code SCHEMA_VERSION} å¿…须一致:
     * ä¸¤ç«¯å„写一份是历史原因(后端不参与设计器构建),改这里就要同步改那边。
     */
    public static final String SCHEMA_VERSION = "1.1";
    /**
     * Schema ç‰ˆæœ¬ï¼Œç”¨äºŽåŽç»­å…¼å®¹å‡çº§
     */
    private String schemaVersion;
    /**
     * çº¸å¼ ä¸Žé¡µè¾¹è·é…ç½®
     */
    private Page page;
    /**
     * GrapesJS åŽŸå§‹ç”»å¸ƒæ•°æ®ï¼ˆä¿ç•™ä»¥ä¾¿è®¾è®¡å™¨æ— æŸè¿˜åŽŸï¼‰
     */
    private Map<String, Object> grapes;
    /**
     * è´¨é‡ç»„件业务语义清单,按文档顺序排列
     */
    private List<QualityComponentNode> components;
    /**
     * åˆ¤å®šè§„则定义。
     * <p>
     * å…ƒç´ å½¢çŠ¶ä¸º {@code {id, name, scope, expression, enabled}},由
     * {@code QualityRuleDefinition.from(Map)} å®½æ¾è¯»å–——历史数据里可能有缺字段的规则,
     * è¯»ä¸åŠ¨çš„è·³è¿‡è€Œä¸æ˜¯è®©æ•´ä»½æ¨¡æ¿æ‰“ä¸å¼€ã€‚
     */
    private List<Map<String, Object>> rules;
    /**
     * çº¸å¼ ä¸Žé¡µè¾¹è·é…ç½®
     */
    @Data
    public static class Page {
        /**
         * çº¸å¼ å°ºå¯¸ï¼šA3 / A4 / A5 / Letter
         */
        private String size;
        /**
         * çº¸å¼ æ–¹å‘:portrait / landscape
         */
        private String orientation;
        /**
         * é¡µè¾¹è·ï¼Œå•位 mm
         */
        private Margin margin;
    }
    /**
     * é¡µè¾¹è·ï¼Œå•位 mm
     */
    @Data
    public static class Margin {
        private Double top;
        private Double right;
        private Double bottom;
        private Double left;
    }
    /**
     * ç”»å¸ƒä¸Šçš„一个质量组件节点。
     * <p>
     * åªè®°ã€Œæ˜¯è°ã€åœ¨å“ªã€ä»€ä¹ˆå±žæ€§ã€ï¼Œä¸è®°å­èŠ‚ç‚¹â€”â€”ç”»å¸ƒçš„åµŒå¥—ç»“æž„ä»¥ {@link #grapes} ä¸ºå‡†ï¼Œ
     * è¿™é‡Œå†å­˜ä¸€ä»½æ ‘就出现两个真相来源,迟早对不上。
     */
    @Data
    public static class QualityComponentNode {
        /** ç”»å¸ƒå†…组件 id */
        private String id;
        /** ç»„件在文档中的顺序,供绑定与定位使用 */
        private Integer index;
        /** ç»„件类型,即画布节点上的 data-quality-type */
        private String qualityType;
        /**
         * ç»„件的业务属性。
         * <p>
         * å–值统一是 {@code data-qc-*} / {@code data-quality-type} è¿™ç±»å­—符串属性,
         * ç”¨ Object æ˜¯å› ä¸º GrapesJS çš„ getAttributes() åŽŸæ ·å¸¦å‡ºï¼Œå¯èƒ½æœ‰éžå­—ç¬¦ä¸²å€¼ã€‚
         */
        private Map<String, Object> attributes;
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/dal/mysql/instance/QcReportInstanceMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,49 @@
package cn.iocoder.yudao.module.qcreport.dal.mysql.instance;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.qcreport.controller.admin.instance.vo.QcReportInstancePageReqVO;
import cn.iocoder.yudao.module.qcreport.dal.dataobject.instance.QcReportInstanceDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface QcReportInstanceMapper extends BaseMapperX<QcReportInstanceDO> {
    default PageResult<QcReportInstanceDO> selectPage(QcReportInstancePageReqVO reqVO) {
        return selectPage(reqVO, new LambdaQueryWrapperX<QcReportInstanceDO>()
                .eqIfPresent(QcReportInstanceDO::getTemplateId, reqVO.getTemplateId())
                .likeIfPresent(QcReportInstanceDO::getReportNo, reqVO.getReportNo())
                .eqIfPresent(QcReportInstanceDO::getBusinessType, reqVO.getBusinessType())
                .eqIfPresent(QcReportInstanceDO::getBusinessId, reqVO.getBusinessId())
                .eqIfPresent(QcReportInstanceDO::getStatus, reqVO.getStatus())
                .betweenIfPresent(QcReportInstanceDO::getCreateTime, reqVO.getCreateTime())
                .orderByDesc(QcReportInstanceDO::getId));
    }
    default QcReportInstanceDO selectByReportNo(String reportNo) {
        return selectOne(new LambdaQueryWrapperX<QcReportInstanceDO>()
                .eq(QcReportInstanceDO::getReportNo, reportNo));
    }
    /**
     * å–当天流水最大的那条编号,用于推出下一个序号。
     * <p>
     * **必须走手写 SQL、把软删除的行也算进来**:条件构造器会自动带上 {@code deleted = 0},
     * è€Œ {@code uk_report_no} å”¯ä¸€ç´¢å¼•不认 {@code deleted}。删掉当天最大号的那份报告后,
     * æ¡ä»¶æž„造器看不到它、算回同一个号、插入必然撞唯一索引;重试又是同一套查询同一个结果,
     * è¿žæ’ž 5 æ¬¡åŽæŠ¥ã€Œç¼–号冲突」——用户会以为是自己手气差,实际是删过一份报告就再出不了这一天的件。
     * <p>
     * æŽ’序**不能**只写 {@code ORDER BY report_no DESC}:序号补零到 4 ä½ï¼Œ
     * å½“天一旦超过 9999 æ¡ä½å®½å°±ä¼šå˜é•¿ï¼Œå­—典序会把 {@code ...-9999} æŽ’到 {@code ...-10000} å‰é¢ã€‚
     * å…ˆæ¯”长度再比字典序,两端对齐才是真的「最大」。
     * <p>
     * åªå–编号本身:调用方要的就是它的尾部流水,不必把 JSON å¿«ç…§ä¸€æ•´ä¸ªåˆ—拽出来。
     */
    @Select("SELECT report_no FROM qc_report_instance WHERE report_no LIKE CONCAT(#{prefix}, '%') "
            + "ORDER BY CHAR_LENGTH(report_no) DESC, report_no DESC LIMIT 1")
    String selectLatestReportNoByPrefix(@Param("prefix") String prefix);
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/dal/mysql/render/QcReportRenderRecordMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,14 @@
package cn.iocoder.yudao.module.qcreport.dal.mysql.render;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.module.qcreport.dal.dataobject.render.QcReportRenderRecordDO;
import org.apache.ibatis.annotations.Mapper;
/**
 * æ™ºèƒ½è´¨æ£€æŠ¥å‘Šæ¸²æŸ“记录 Mapper
 * <p>
 * åªå†™ä¸è¯»ï¼šæŽ’查时直接用 SQL æŸ¥åº“,不为此做接口。
 */
@Mapper
public interface QcReportRenderRecordMapper extends BaseMapperX<QcReportRenderRecordDO> {
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/dal/mysql/template/QcReportTemplateMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,43 @@
package cn.iocoder.yudao.module.qcreport.dal.mysql.template;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.qcreport.controller.admin.template.vo.QcReportTemplatePageReqVO;
import cn.iocoder.yudao.module.qcreport.dal.dataobject.template.QcReportTemplateDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface QcReportTemplateMapper extends BaseMapperX<QcReportTemplateDO> {
    default PageResult<QcReportTemplateDO> selectPage(QcReportTemplatePageReqVO reqVO) {
        return selectPage(reqVO, new LambdaQueryWrapperX<QcReportTemplateDO>()
                .likeIfPresent(QcReportTemplateDO::getTemplateCode, reqVO.getTemplateCode())
                .likeIfPresent(QcReportTemplateDO::getTemplateName, reqVO.getTemplateName())
                .eqIfPresent(QcReportTemplateDO::getIndustry, reqVO.getIndustry())
                .eqIfPresent(QcReportTemplateDO::getReportType, reqVO.getReportType())
                .eqIfPresent(QcReportTemplateDO::getStatus, reqVO.getStatus())
                .eqIfPresent(QcReportTemplateDO::getCurrentVersion, reqVO.getCurrentVersion())
                .eqIfPresent(QcReportTemplateDO::getCreator, reqVO.getCreator())
                .betweenIfPresent(QcReportTemplateDO::getCreateTime, reqVO.getCreateTime())
                .orderByDesc(QcReportTemplateDO::getId));
    }
    default List<QcReportTemplateDO> selectListByReportTypeAndStatus(String reportType, Integer status) {
        return selectList(new LambdaQueryWrapperX<QcReportTemplateDO>()
                .eq(QcReportTemplateDO::getReportType, reportType)
                .eq(QcReportTemplateDO::getStatus, status)
                .orderByDesc(QcReportTemplateDO::getId));
    }
    default QcReportTemplateDO selectByCode(String templateCode) {
        return selectOne(QcReportTemplateDO::getTemplateCode, templateCode);
    }
    default QcReportTemplateDO selectByName(String templateName) {
        return selectOne(QcReportTemplateDO::getTemplateName, templateName);
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/dal/mysql/version/QcReportTemplateVersionMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,55 @@
package cn.iocoder.yudao.module.qcreport.dal.mysql.version;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.qcreport.controller.admin.version.vo.QcReportTemplateVersionPageReqVO;
import cn.iocoder.yudao.module.qcreport.dal.dataobject.version.QcReportTemplateVersionDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.Collection;
import java.util.List;
@Mapper
public interface QcReportTemplateVersionMapper extends BaseMapperX<QcReportTemplateVersionDO> {
    default PageResult<QcReportTemplateVersionDO> selectPage(QcReportTemplateVersionPageReqVO reqVO) {
        return selectPage(reqVO, new LambdaQueryWrapperX<QcReportTemplateVersionDO>()
                .eqIfPresent(QcReportTemplateVersionDO::getTemplateId, reqVO.getTemplateId())
                .likeIfPresent(QcReportTemplateVersionDO::getVersion, reqVO.getVersion())
                .eqIfPresent(QcReportTemplateVersionDO::getStatus, reqVO.getStatus())
                .betweenIfPresent(QcReportTemplateVersionDO::getCreateTime, reqVO.getCreateTime())
                .orderByDesc(QcReportTemplateVersionDO::getId));
    }
    default List<QcReportTemplateVersionDO> selectListByTemplateId(Long templateId) {
        return selectList(new LambdaQueryWrapperX<QcReportTemplateVersionDO>()
                .eq(QcReportTemplateVersionDO::getTemplateId, templateId)
                .orderByDesc(QcReportTemplateVersionDO::getId));
    }
    default List<QcReportTemplateVersionDO> selectListByTemplateIdsAndStatus(Collection<Long> templateIds,
                                                                            Integer status) {
        return selectList(new LambdaQueryWrapperX<QcReportTemplateVersionDO>()
                .in(QcReportTemplateVersionDO::getTemplateId, templateIds)
                .eq(QcReportTemplateVersionDO::getStatus, status));
    }
    default QcReportTemplateVersionDO selectByTemplateIdAndVersion(Long templateId, String version) {
        return selectOne(new LambdaQueryWrapperX<QcReportTemplateVersionDO>()
                .eq(QcReportTemplateVersionDO::getTemplateId, templateId)
                .eq(QcReportTemplateVersionDO::getVersion, version));
    }
    /**
     * æŸ¥è¯¢æ¨¡æ¿çš„æœ€æ–°ä¸€ä¸ªç‰ˆæœ¬ï¼ˆæŒ‰ id å€’序取第一条)
     */
    default QcReportTemplateVersionDO selectLatestByTemplateId(Long templateId) {
        List<QcReportTemplateVersionDO> list = selectList(new LambdaQueryWrapperX<QcReportTemplateVersionDO>()
                .eq(QcReportTemplateVersionDO::getTemplateId, templateId)
                .orderByDesc(QcReportTemplateVersionDO::getId)
                .last("LIMIT 1"));
        return list.isEmpty() ? null : list.get(0);
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/Bindings.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,88 @@
package cn.iocoder.yudao.module.qcreport.engine;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
 * æ•°æ®ç»‘定:模板文本里的 {@code {{path}}}。
 * <p>
 * åªåšå–值与替换,不做任何表达式求值;需要计算的场景交给规则引擎。
 * å–不到的路径渲染为空字符串——报告上不该出现用户看不懂的 {@code {{}}}。
 * <p>
 * ä¸Žå‰ç«¯ {@code engine/binding.ts} ä¸€ä¸€å¯¹åº”,两边必须同语义。
 */
public final class Bindings {
    /** ç»‘定表达式:{{ report.reportNo }} */
    private static final Pattern BINDING_PATTERN = Pattern.compile("\\{\\{\\s*([^{}]+?)\\s*\\}\\}");
    /** æ•´æ®µå°±æ˜¯ä¸€ä¸ªç»‘定表达式 */
    private static final Pattern SINGLE_BINDING_PATTERN = Pattern.compile("^\\{\\{\\s*([^{}]+?)\\s*\\}\\}$");
    private Bindings() {
    }
    /** æå–文本里出现的所有绑定路径(去重、保持出现顺序),用于渲染前的数据依赖检查 */
    public static List<String> listPaths(String template) {
        Set<String> paths = new LinkedHashSet<>();
        if (template == null) {
            return List.of();
        }
        Matcher matcher = BINDING_PATTERN.matcher(template);
        while (matcher.find()) {
            String path = matcher.group(1).trim();
            if (!path.isEmpty()) {
                paths.add(path);
            }
        }
        return List.copyOf(paths);
    }
    /** è§£æžå•个绑定路径,取不到返回 null */
    public static Object resolve(String path, Object context) {
        return path == null ? null : Paths.readPath(context, path.trim());
    }
    /**
     * æ›¿æ¢æ–‡æœ¬é‡Œçš„全部绑定表达式,并回报取不到值的路径。
     * <p>
     * ç»‘定不上通常意味着数据缺口,静默渲染成空会让报告看起来「正常但缺内容」,
     * æ‰€ä»¥æŠŠç¼ºå£äº¤ç»™è°ƒç”¨æ–¹ï¼Œç”±æ¸²æŸ“引擎汇总成可见的问题清单。
     */
    public static String resolveText(String template, Object context, Consumer<String> onMissing) {
        if (template == null) {
            return "";
        }
        Matcher matcher = BINDING_PATTERN.matcher(template);
        StringBuilder result = new StringBuilder();
        while (matcher.find()) {
            String path = matcher.group(1).trim();
            Object value = Paths.readPath(context, path);
            if (value == null && onMissing != null) {
                onMissing.accept(path);
            }
            matcher.appendReplacement(result, Matcher.quoteReplacement(Paths.formatValue(value)));
        }
        matcher.appendTail(result);
        return result.toString();
    }
    /** æ›¿æ¢æ–‡æœ¬é‡Œçš„全部绑定表达式;未绑定的路径渲染为空 */
    public static String resolveText(String template, Object context) {
        return resolveText(template, context, null);
    }
    /** åˆ¤æ–­ä¸€æ®µæ–‡æœ¬æ˜¯å¦æ˜¯çº¯ç»‘定表达式({{path}} å•占一段),是则返回路径,否则返回 null */
    public static String asSingleBinding(String template) {
        if (template == null) {
            return null;
        }
        Matcher matcher = SINGLE_BINDING_PATTERN.matcher(template.trim());
        return matcher.matches() ? matcher.group(1).trim() : null;
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/JsValues.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,43 @@
package cn.iocoder.yudao.module.qcreport.engine;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
/**
 * JS å€¼è¯­ä¹‰ã€‚
 * <p>
 * æ¸²æŸ“与规则求值的产物要跟前端逐字一致,而 Java çš„默认类型转换和 JS æœ‰å‡ å¤„对不上
 * ï¼ˆ{@code String.valueOf(60.0D)} æ˜¯ "60.0"、{@code String.valueOf(true)} æ˜¯ "true"、
 * æ•°ç»„ toString æ˜¯ "[1, 2]")。凡是拿值当文本用的地方都走这里,别用 Java çš„默认转换。
 */
public final class JsValues {
    private JsValues() {
    }
    /** {@code String(value)}:null å¾—空串、布尔得 true/false、数组按逗号连接 */
    public static String asText(Object value) {
        if (value == null) {
            return "";
        }
        if (value instanceof Boolean bool) {
            return bool ? "true" : "false";
        }
        if (value instanceof Double || value instanceof Float || value instanceof BigDecimal) {
            return Numbers.toString(((Number) value).doubleValue());
        }
        if (value instanceof Integer || value instanceof Long) {
            return String.valueOf(value);
        }
        if (value instanceof List<?> list) {
            List<String> parts = new ArrayList<>(list.size());
            for (Object element : list) {
                parts.add(element == null ? "" : asText(element));
            }
            return String.join(",", parts);
        }
        return String.valueOf(value);
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/Numbers.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
package cn.iocoder.yudao.module.qcreport.engine;
/**
 * æ•°å€¼è¾“出格式。
 * <p>
 * Java çš„ {@code String.valueOf(210.0D)} å¾—到 "210.0",而 JS çš„ {@code String(210)} æ˜¯ "210"。
 * æ¸²æŸ“产物({@code @page} å°ºå¯¸ã€ç»‘定值、比较时的文本形态)必须与前端逐字一致,
 * å¦åˆ™åŒä¸€ä»½æ¨¡æ¿åœ¨ä¸¤ç«¯ä¼šè¾“出不同的 HTML。
 */
public final class Numbers {
    /** åŒç²¾åº¦èƒ½ç²¾ç¡®è¡¨ç¤ºæˆæ•´æ•°çš„上界,超过它就不再当整数处理 */
    private static final double MAX_SAFE_INTEGER = 9.007199254740992E15;
    private Numbers() {
    }
    /** æ•´æ•°å€¼çš„æµ®ç‚¹æ•°æ”¶æˆæ•´æ•°è¾“出(60.0 â†’ "60"),其余原样输出 */
    public static String toString(double value) {
        if (Double.isFinite(value) && value == Math.rint(value) && Math.abs(value) <= MAX_SAFE_INTEGER) {
            return String.valueOf((long) value);
        }
        return String.valueOf(value);
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/PageMargin.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,16 @@
package cn.iocoder.yudao.module.qcreport.engine;
/**
 * é¡µè¾¹è·ï¼ˆæ¯«ç±³ï¼‰ï¼Œå•位与前端 {@code engine/page.ts} ä¸€è‡´ã€‚
 * <p>
 * å­—段用包装类型:为 null è¡¨ç¤ºè¯¥é¡¹æ²¡é…è¿‡ï¼Œç”± {@link PageSizes} è½åˆ°é»˜è®¤è¾¹è·ï¼Œ
 * ä¸Žå‰ç«¯ {@code margin?.top ?? DEFAULT_MARGIN_MM} çš„语义保持一致。
 */
public record PageMargin(Double top, Double right, Double bottom, Double left) {
    /** å…¨éƒ¨æœªé…ç½® */
    public static PageMargin none() {
        return new PageMargin(null, null, null, null);
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/PageSetting.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,15 @@
package cn.iocoder.yudao.module.qcreport.engine;
/**
 * çº¸å¼ é…ç½®ï¼Œç»“构等价于 Schema.page,但不依赖具体接口类型定义。
 * <p>
 * å‰ç«¯ {@code engine/page.ts} çš„ PageSetting ä¸Žä¹‹ä¸€ä¸€å¯¹åº”。
 */
public record PageSetting(String size, String orientation, PageMargin margin) {
    /** æœªé…ç½®ä»»ä½•纸张信息时使用 */
    public static PageSetting defaultSetting() {
        return new PageSetting(null, null, null);
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/PageSizes.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
package cn.iocoder.yudao.module.qcreport.engine;
import java.util.Map;
/**
 * çº¸å¼ å°ºå¯¸æ¢ç®—。
 * <p>
 * ä¸Žå‰ç«¯ {@code engine/page.ts} ä¸€ä¸€å¯¹åº”,取值同 {@code QcReportEnums.PageSizeEnum}。
 * è®¾è®¡å™¨æ¢ç®—画布像素、渲染引擎换算 {@code @page} éƒ½ç”¨è¿™ä¸€ä»½ï¼Œé¿å…ä¸¤å¤„各写一遍后对不上。
 */
public final class PageSizes {
    /** çº¸å¼ å°ºå¯¸ï¼ˆæ¯«ç±³ï¼‰ï¼Œæ•°ç»„为 {宽, é«˜} */
    private static final Map<String, double[]> SIZE_MM = Map.of(
            "A3", new double[]{297, 420},
            "A4", new double[]{210, 297},
            "A5", new double[]{148, 210},
            "Letter", new double[]{215.9, 279.4});
    /** é»˜è®¤çº¸å¼ ï¼Œæœªåˆ—出的尺寸回退到它 */
    public static final String DEFAULT_SIZE = "A4";
    /** é»˜è®¤é¡µè¾¹è·ï¼ˆæ¯«ç±³ï¼‰ */
    public static final double DEFAULT_MARGIN_MM = 10;
    private PageSizes() {
    }
    /** è§£æžçº¸å¼ å°ºå¯¸ä¸Žé¡µè¾¹è·ï¼ˆæ¯«ç±³ï¼‰ï¼Œæ¨ªå‘时翻转宽高 */
    public static ResolvedPage resolve(PageSetting page) {
        String size = page == null || page.size() == null ? DEFAULT_SIZE : page.size();
        double[] base = SIZE_MM.getOrDefault(size, SIZE_MM.get(DEFAULT_SIZE));
        boolean landscape = page != null && "landscape".equals(page.orientation());
        PageMargin margin = page == null ? null : page.margin();
        return new ResolvedPage(
                landscape ? base[1] : base[0],
                landscape ? base[0] : base[1],
                marginOf(margin == null ? null : margin.top()),
                marginOf(margin == null ? null : margin.right()),
                marginOf(margin == null ? null : margin.bottom()),
                marginOf(margin == null ? null : margin.left()));
    }
    private static double marginOf(Double value) {
        return value == null ? DEFAULT_MARGIN_MM : value;
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/Paths.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,199 @@
package cn.iocoder.yudao.module.qcreport.engine;
import java.math.BigDecimal;
import java.time.temporal.TemporalAccessor;
import java.time.format.DateTimeFormatter;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
 * ä¸Šä¸‹æ–‡å–值。
 * <p>
 * åªåšç™½åå•式的属性导航,不解析、不执行任何代码:
 * æ”¯æŒ a.b.c ä¸Ž a.b[0].c ä¸¤ç§å†™æ³•,路径在数组上继续取属性时按元素逐个取值(pluck)。
 * <p>
 * ä¸Žå‰ç«¯ {@code src/components/quality/engine/path.ts} ä¸€ä¸€å¯¹åº”,两边必须同语义。
 */
public final class Paths {
    /** è·¯å¾„片段:普通属性名或数组下标 */
    private static final Pattern TOKEN_PATTERN = Pattern.compile("[^.\\[\\]]+");
    /** ç¦æ­¢è®¿é—®çš„属性名,避免顺着原型链读到构造器 */
    private static final Set<String> BLOCKED_KEYS = Set.of("__proto__", "constructor", "prototype");
    private static final Pattern INTEGER_TEXT = Pattern.compile("^\\d+$");
    private static final DateTimeFormatter DATE_TIME_FORMAT =
            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
    private Paths() {
    }
    /**
     * æŠŠè·¯å¾„拆成片段:inspectionItems[0].actualValue â†’ [inspectionItems, 0, actualValue]
     */
    public static List<String> parsePath(String path) {
        List<String> tokens = new ArrayList<>();
        if (path == null) {
            return tokens;
        }
        Matcher matcher = TOKEN_PATTERN.matcher(path);
        while (matcher.find()) {
            tokens.add(matcher.group());
        }
        return tokens;
    }
    /**
     * æŒ‰è·¯å¾„取值,取不到返回 null。
     */
    public static Object readPath(Object source, String path) {
        return readTokens(source, parsePath(path), 0);
    }
    private static Object readTokens(Object source, List<String> tokens, int start) {
        Object current = source;
        for (int index = start; index < tokens.size(); index++) {
            String token = tokens.get(index);
            if (current == null) {
                return null;
            }
            if (current instanceof List<?> list) {
                Integer arrayIndex = toIndex(token);
                if (arrayIndex == null) {
                    // æ•°ç»„上继续取属性:逐元素取值,得到同长度的数组
                    List<Object> plucked = new ArrayList<>(list.size());
                    for (Object element : list) {
                        plucked.add(readTokens(element, tokens, index));
                    }
                    return plucked;
                }
                current = arrayIndex < list.size() ? list.get(arrayIndex) : null;
                continue;
            }
            if (current instanceof Map<?, ?> map) {
                if (BLOCKED_KEYS.contains(token)) {
                    return null;
                }
                current = map.get(token);
                continue;
            }
            // æ ‡é‡åŽé¢è¿˜æœ‰è·¯å¾„片段,说明路径写错了
            return null;
        }
        return current;
    }
    private static Integer toIndex(String token) {
        return INTEGER_TEXT.matcher(token).matches() ? Integer.valueOf(token) : null;
    }
    /**
     * å±•示用格式化:空值渲染为空字符串,布尔值转中文,日期只保留到秒。
     * <p>
     * æ•´æ•°å€¼çš„双精度数(如 60.0)按整数输出,与 JS çš„ {@code String(60)} ä¿æŒä¸€è‡´ï¼Œ
     * å¦åˆ™æŠ¥å‘Šä¸Šä¼šå‡ºçŽ°ã€Œæ ‡å‡†å€¼ 60.0」这种不该有的尾数。
     */
    public static String formatValue(Object value) {
        if (value == null) {
            return "";
        }
        if (value instanceof Boolean bool) {
            return bool ? "是" : "否";
        }
        if (value instanceof Date date) {
            return DATE_TIME_FORMAT.format(date.toInstant().atZone(ZoneId.systemDefault()));
        }
        if (value instanceof TemporalAccessor temporal) {
            return DATE_TIME_FORMAT.format(temporal);
        }
        if (value instanceof Collection<?> collection) {
            List<String> parts = new ArrayList<>(collection.size());
            for (Object element : collection) {
                parts.add(formatValue(element));
            }
            return String.join("、", parts);
        }
        return formatNumberAware(value);
    }
    private static String formatNumberAware(Object value) {
        if (value instanceof Double || value instanceof Float || value instanceof BigDecimal) {
            return Numbers.toString(((Number) value).doubleValue());
        }
        return String.valueOf(value);
    }
    /**
     * æ•°å€¼åŒ–:非数值返回 NaN,由调用方决定如何处理。
     */
    public static double toNumber(Object value) {
        if (value instanceof Number number) {
            return number.doubleValue();
        }
        if (value instanceof Boolean bool) {
            return bool ? 1D : 0D;
        }
        if (value instanceof String text && !text.isBlank()) {
            try {
                return Double.parseDouble(text.trim());
            } catch (NumberFormatException ignored) {
                return Double.NaN;
            }
        }
        return Double.NaN;
    }
    /**
     * æŠŠä»»æ„å–值收敛成数值数组,供统计函数使用。
     */
    public static List<Double> toNumberArray(Object value) {
        List<Double> numbers = new ArrayList<>();
        if (value instanceof Collection<?> collection) {
            flattenInto(collection, numbers);
            return numbers;
        }
        double single = toNumber(value);
        if (Double.isFinite(single)) {
            numbers.add(single);
        }
        return numbers;
    }
    private static void flattenInto(Object value, List<Double> target) {
        if (value instanceof Collection<?> collection) {
            for (Object element : collection) {
                flattenInto(element, target);
            }
            return;
        }
        double numeric = toNumber(value);
        if (Double.isFinite(numeric)) {
            target.add(numeric);
        }
    }
    /**
     * æŠŠä»»æ„å–值按一层一层摊平成列表,保留原始元素(不做数值转换),供 PASS_RATE è¿™ç±»å‡½æ•°ä½¿ç”¨ã€‚
     */
    public static List<Object> flatten(Object value) {
        List<Object> result = new ArrayList<>();
        if (value instanceof Collection<?> collection) {
            for (Object element : collection) {
                result.addAll(flatten(element));
            }
            return result;
        }
        result.add(value);
        return result;
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/QualityReportEngine.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,115 @@
package cn.iocoder.yudao.module.qcreport.engine;
import cn.iocoder.yudao.module.qcreport.dal.dataobject.version.ReportTemplateSchema;
import cn.iocoder.yudao.module.qcreport.engine.context.ReportContext;
import cn.iocoder.yudao.module.qcreport.engine.render.HtmlRenderer;
import cn.iocoder.yudao.module.qcreport.engine.render.RenderOutcome;
import cn.iocoder.yudao.module.qcreport.engine.rule.RuleEngine;
import cn.iocoder.yudao.module.qcreport.engine.rule.RuleEvaluator;
import cn.iocoder.yudao.module.qcreport.engine.rule.QualityRuleDefinition;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
 * æ¸²æŸ“引擎入口。
 * <p>
 * åªåšä¸€ä»¶äº‹ï¼šæŠŠè½åº“的模板 Schema ç¿»è¯‘成引擎的内部结构,然后渲染。
 * å¼•擎本身不依赖 Spring、不落库、不做权限——它只吃「Schema + ä¸Šä¸‹æ–‡ã€å HTML,
 * è¿™æ ·æ‰¹é‡ç”Ÿæˆã€å®šæ—¶ä»»åŠ¡ã€å…¶å®ƒæ¨¡å—è°ƒç”¨éƒ½èƒ½èµ°åŒä¸€ä¸ªå…¥å£ã€‚
 */
public final class QualityReportEngine {
    private QualityReportEngine() {
    }
    /** æ¸²æŸ“报告:判定 â†’ ç”Ÿæˆæ­£æ–‡ â†’ ç»„装文档 */
    public static RenderOutcome render(ReportTemplateSchema schema, ReportContext context) {
        return HtmlRenderer.render(grapesOf(schema), pageOf(schema), context, rulesOf(schema));
    }
    /**
     * æ¨¡æ¿æ˜¯å¦å·²ç»æœ‰<b>可渲染的</b>画布内容。
     * <p>
     * åˆ¤æ®ä¸Žæ¸²æŸ“器读画布的那一行严格一致——{@link HtmlRenderer#render} å–的是
     * {@code pages[0].frames[0].component},这里就用 {@link Paths#readPath} å–同一个位置,
     * è€Œä¸æ˜¯é€€åŒ–成「grapes è¿™ä¸ª Map éžç©ºã€ã€‚
     * <p>
     * <b>为什么不能只看非空</b>:GrapesJS æŠŠã€Œæ‰“开过但一个组件都没放」的项目存成
     * {@code {"assets":[],"styles":[]}},它非空、却没有 frames;渲染器取不到根组件时
     * ç›´æŽ¥æŠŠ body æ¸²æŸ“成空串——不报错,用户拿到一张白纸 PDF。出件前的这个检查就是为了
     * æŠŠè¿™ç§ç‰ˆæœ¬æŒ¡åœ¨æ¸²æŸ“之前,判据必须和渲染器对齐,否则挡不住。
     */
    public static boolean hasCanvas(ReportTemplateSchema schema) {
        return Paths.readPath(grapesOf(schema), "pages[0].frames[0].component") instanceof Map<?, ?>;
    }
    /** GrapesJS é¡¹ç›®æ•°æ® */
    public static Map<String, Object> grapesOf(ReportTemplateSchema schema) {
        return schema == null ? null : schema.getGrapes();
    }
    /** çº¸å¼ ä¸Žé¡µè¾¹è·ï¼Œæœªé…ç½®æ—¶è½åˆ°é»˜è®¤ A4 ä¸Žé»˜è®¤è¾¹è· */
    public static PageSetting pageOf(ReportTemplateSchema schema) {
        ReportTemplateSchema.Page page = schema == null ? null : schema.getPage();
        if (page == null) {
            return PageSetting.defaultSetting();
        }
        ReportTemplateSchema.Margin margin = page.getMargin();
        PageMargin pageMargin = margin == null
                ? PageMargin.none()
                : new PageMargin(margin.getTop(), margin.getRight(), margin.getBottom(), margin.getLeft());
        return new PageSetting(page.getSize(), page.getOrientation(), pageMargin);
    }
    /** åˆ¤å®šè§„则定义,Schema é‡Œæ²¡é…æ—¶è¿”回空清单 */
    public static List<QualityRuleDefinition> rulesOf(ReportTemplateSchema schema) {
        List<QualityRuleDefinition> rules = new ArrayList<>();
        if (schema == null || schema.getRules() == null) {
            return rules;
        }
        for (Map<String, Object> raw : schema.getRules()) {
            QualityRuleDefinition rule = QualityRuleDefinition.from(raw);
            if (rule != null) {
                rules.add(rule);
            }
        }
        return rules;
    }
    /**
     * ä¿å­˜å‰æ ¡éªŒæ¨¡æ¿é‡Œçš„全部判定规则,返回问题清单,空清单表示通过。
     * <p>
     * é™¤äº†è¯­æ³•,还拦白名单外的函数调用:这类规则一旦存下去,
     * æ¯å¼ æŠ¥å‘Šéƒ½ä¼šå¸¦ä¸Šä¸€æ¡ã€Œæ— æ³•执行」,不如在设计阶段就退回去。
     * <p>
     * <b>空表达式的规则也退回去。</b>这条曾经是放行的——当时设计器还没有规则编辑界面,
     * æŠ¥äº†é”™ç”¨æˆ·æ— å¤„可改,只会卡在保存不了,等于把「一条坏数据」升级成「整份模板不能保存」。
     * è§„则编辑界面做出来之后这个理由不成立了:用户在设计器里删掉这条规则就能继续保存,
     * æ‰€ä»¥æ”¹æˆæ˜Žç¡®æŠ¥é”™ã€‚读取仍然宽容({@link #rulesOf} ä¸ä¼šå› ä¸ºä¸€æ¡åè§„则让整份模板打不开),
     * æ‰§è¡ŒæœŸä¹Ÿç…§å¸¸æŠŠå®ƒæŠ¥å‡ºæ¥ï¼ˆã€Œç¬¬ N é¡¹ã€ŒX」的规则「Y」无法执行:规则表达式为空」)。
     */
    public static List<String> validateRules(ReportTemplateSchema schema) {
        List<String> errors = new ArrayList<>();
        for (QualityRuleDefinition rule : rulesOf(schema)) {
            errors.addAll(RuleEngine.validate(rule));
            String expression = rule.expression();
            if (expression == null || expression.trim().isEmpty()) {
                // ç©ºè¡¨è¾¾å¼ä¸Šä¸€æ­¥å·²ç»æŠ¥è¿‡ï¼Œä¹Ÿæ²¡ä»€ä¹ˆå¯é™æ€æ£€æŸ¥çš„
                continue;
            }
            try {
                String unknown = RuleEvaluator.findUnknownFunction(RuleEngine.parse(expression));
                if (unknown != null) {
                    errors.add("规则「" + rule.label() + "」调用了不支持的函数 " + unknown
                            + ",可用函数:" + String.join("、", RuleEvaluator.functionNames()));
                }
            } catch (RuntimeException ignored) {
                // è¯­æ³•错误已经在上一步报过,不重复刷屏
            }
        }
        return errors;
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/QualityResult.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,55 @@
package cn.iocoder.yudao.module.qcreport.engine;
/**
 * åˆ¤å®šç»“果。
 * <p>
 * ä¸Žå‰ç«¯ {@code engine/context.ts} çš„ QUALITY_RESULT ä¸€ä¸€å¯¹åº”,取值同时是
 * {@code QcReportEnums.CheckResultEnum} çš„落库值。
 */
public enum QualityResult {
    PASS("合格"),
    FAIL("不合格");
    /**
     * è§„则本身出错时的占位文案。
     * <p>
     * æ²¡éªŒè¿‡ä¸èƒ½è¯´åˆæ ¼ï¼Œæ²¡è¯æ®ä¹Ÿä¸èƒ½è¯´ä¸åˆæ ¼ï¼Œæ‰€ä»¥ã€Œæ— ç»“论」必须是一个和 PASS/FAIL å¹¶åˆ—的显式状态。
     * è¿™ç±»é¡¹ä»ç•™åœ¨åˆæ ¼çŽ‡åˆ†æ¯é‡Œï¼Œä¸€ä¸ªè·‘æŒ‚çš„è§„åˆ™åº”è¯¥æŠŠåˆæ ¼ç»“è®ºåŽ‹ä½ã€‚
     */
    public static final String PENDING_TEXT = "待判定";
    /**
     * æ—¢æ²¡æœ‰è§„则、也没有规格上下限时的占位文案。
     * <p>
     * å’Œ {@link #PENDING_TEXT} åˆ»æ„åˆ†å¼€ï¼šæŠ¥å‘Šé‡Œå¸¸æœ‰ã€Œè¯•样质量 m」「称量瓶 m0」这类只供公式取数的过程参数,
     * å®ƒä»¬æ²¡æœ‰è§„格也就无从判定;这类项不计入合格率分母,也不参与报告结论。
     */
    public static final String UNDECIDABLE_TEXT = "无判定规则";
    /** åˆ¤å®šç»“果对应的展示文字 */
    private final String text;
    QualityResult(String text) {
        this.text = text;
    }
    public String text() {
        return text;
    }
    /** å±•示文字/落库值 â†’ æžšä¸¾ï¼Œè¯†åˆ«ä¸å‡ºè¿”回 null */
    public static QualityResult of(String value) {
        if (value == null) {
            return null;
        }
        String normalized = value.trim().toUpperCase();
        for (QualityResult result : values()) {
            if (result.name().equals(normalized)) {
                return result;
            }
        }
        return null;
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/ResolvedPage.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,21 @@
package cn.iocoder.yudao.module.qcreport.engine;
/**
 * è§£æžåŽçš„纸张尺寸与页边距(毫米),横向已翻转宽高。
 */
public record ResolvedPage(double widthMm, double heightMm,
                           double marginTopMm, double marginRightMm,
                           double marginBottomMm, double marginLeftMm) {
    /** {@code @page { size: ... } } çš„值 */
    public String cssSize() {
        return Numbers.toString(widthMm) + "mm " + Numbers.toString(heightMm) + "mm";
    }
    /** {@code @page { margin: ... } } çš„值,顺序为 ä¸Š å³ ä¸‹ å·¦ */
    public String cssMargin() {
        return Numbers.toString(marginTopMm) + "mm " + Numbers.toString(marginRightMm) + "mm "
                + Numbers.toString(marginBottomMm) + "mm " + Numbers.toString(marginLeftMm) + "mm";
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/context/InspectionItem.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,113 @@
package cn.iocoder.yudao.module.qcreport.engine.context;
import cn.iocoder.yudao.module.qcreport.engine.QualityResult;
import lombok.Data;
import lombok.experimental.Accessors;
/**
 * å•个检验项。
 * <p>
 * å­—段名与前端 {@code engine/context.ts} çš„ ReportContextItem ä¿æŒä¸€è‡´ï¼Œ
 * é‡å¤è¡Œçš„行内绑定写作 {@code {{item.itemName}}}。
 */
@Data
@Accessors(chain = true)
public class InspectionItem {
    private int index;
    private String itemCode = "";
    private String itemName = "";
    private String standardValue = "";
    private String actualValue = "";
    private String unit = "";
    /**
     * ã€Œæ£€æµ‹è¦æ±‚」文本:分组父项 = æŒ‡æ ‡ä¸Šçš„公式,其余 = å•据上的标准要求。
     * <p>
     * ä¸Ž {@link #standardValue} å¹¶å­˜è€Œä¸åˆå¹¶ï¼šä¸¤è€…的来源不同(单据行 vs æŒ‡æ ‡ä¸»æ•°æ®ï¼‰ï¼Œ
     * è€Œåˆ†ç»„父子项压根没有规格,只有公式。
     */
    private String requirement = "";
    /**
     * æ£€æµ‹æ–¹æ³•,来自单据行(如 {@code GB 5009.3-2016} / {@code ANA.MTH-000036})。
     * <p>
     * ä¸Ž {@link #requirement} å¹¶å­˜è€Œä¸åˆå¹¶ï¼šæ£€æµ‹è¦æ±‚说的是「这一项要达到什么」,
     * æ£€æµ‹æ–¹æ³•说的是「用什么办法测的」,原件里常各占一列。
     */
    private String checkMethod = "";
    /**
     * åˆ†ç»„信息,只有检验单带分组结构时才有值。
     * <p>
     * null è¡¨ç¤ºã€Œè¿™ä»½æŠ¥å‘Šæ²¡æœ‰åˆ†ç»„」,{@link ReportContext#itemMap} åœ¨è¿™ç§æƒ…况下不放这个键,
     * å…å¾—给没有分组的报告(以及冻结的对拍 fixture)带上一个恒为空的字段。
     */
    private Group group;
    /** è§„格上限,判定器做区间判定时使用 */
    private Double upperLimit;
    /** è§„格下限 */
    private Double lowerLimit;
    /** PASS / FAIL */
    private String result = "";
    /** åˆæ ¼ / ä¸åˆæ ¼ / å¾…判定 / æ— åˆ¤å®šè§„则 */
    private String resultText = "";
    private String remark = "";
    /**
     * åˆ†ç»„信息:报告要把组名跨住整组所需的三个值。
     * <p>
     * æ¸²æŸ“期算不出这些:绑定的路径在数组上继续取属性会按元素 pluck(`group.children.length`
     * å–不到值),所以「这一组有几行」「这一格要不要让位」只能由上游逐项下发。
     * å­—段在**每一个**检验项上都要有值(哪怕是空串):绑定的字段缺席时属性会被跳过,
     * æœ¬è¯¥éšè—çš„合并格反而会露出来。
     */
    @Data
    @Accessors(chain = true)
    public static class Group {
        /** ã€Œå­é¡¹ã€åˆ—文本:组内子项 = è‡ªå·±çš„名字,组头行与独立项 = ç©ºä¸² */
        private String childName = "";
        /** ç»„名格的纵向合并行数:组的第一行 = æ•´ç»„的行数,组内其余行与独立项 = 1 */
        private int span = 1;
        /**
         * ç»„名格的隐藏值:组内第一行 = ç©ºä¸²ï¼ˆå¯è§ï¼Œå¸¦åˆå¹¶è¡Œæ•°ï¼‰ï¼Œå…¶ä½™è¡Œ = `hidden`。
         * <p>
         * å­—符串而不是布尔:`hidden` å±žæ€§é ã€Œåœ¨ä¸åœ¨ã€èµ·ä½œç”¨ï¼ˆ`hidden="false"` ç…§æ ·éšè—ï¼‰ï¼Œ
         * åªæœ‰ç©ºä¸²èƒ½è¡¨è¾¾ã€Œä¸è¾“出这个属性 = è¿™ä¸€æ ¼å¯è§ã€ã€‚
         */
        private String hidden = "";
        public Group copy() {
            return new Group().setChildName(childName).setSpan(span).setHidden(hidden);
        }
    }
    public InspectionItem copy() {
        return new InspectionItem()
                .setIndex(index).setItemCode(itemCode).setItemName(itemName)
                .setStandardValue(standardValue).setActualValue(actualValue).setUnit(unit)
                .setRequirement(requirement)
                .setCheckMethod(checkMethod)
                .setGroup(group == null ? null : group.copy())
                .setUpperLimit(upperLimit).setLowerLimit(lowerLimit)
                .setResult(result).setResultText(resultText).setRemark(remark);
    }
    /** æŒ‰åˆ¤å®šç»“果写回 result / resultText,verdict ä¸º null æ—¶ç½®ä¸ºå¾…判定 */
    public InspectionItem applyResult(QualityResult verdict) {
        this.result = verdict == null ? "" : verdict.name();
        this.resultText = verdict == null ? QualityResult.PENDING_TEXT : verdict.text();
        return this;
    }
    /**
     * æ²¡æœ‰åˆ¤å®šä¾æ®ï¼šç½®ä¸ºã€Œæ— åˆ¤å®šè§„则」。
     * <p>
     * æŠ¥å‘Šé‡Œå¸¸æœ‰ã€Œè¯•样质量 m」「称量瓶 m0」这类只供公式取数的过程参数,它们没有规格也就无从判定;
     * è‹¥ç…§å¸¸ç®—进分母,一份本来全合格的报告会因为几个过程参数被压成「待判定」。
     */
    public InspectionItem applyUndecidable() {
        this.result = "";
        this.resultText = QualityResult.UNDECIDABLE_TEXT;
        return this;
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/context/ReportContext.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,120 @@
package cn.iocoder.yudao.module.qcreport.engine.context;
import lombok.Data;
import lombok.experimental.Accessors;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
 * æŠ¥å‘Šä¸Šä¸‹æ–‡ã€‚
 * <p>
 * æ¨¡æ¿é‡Œçš„ {@code {{path}}} å…¨éƒ¨ç›¸å¯¹è¿™ä¸ªç»“构解析:report æ˜¯æŠ¥å‘Šçº§å­—段,inspectionItems æ˜¯æ£€éªŒé¡¹æ•°ç»„。
 * ä¸šåŠ¡å•æ® â†’ ä¸Šä¸‹æ–‡çš„æ˜ å°„由调用方负责,模板本身只认这个标准结构,
 * æ¢è¡Œä¸šã€æ¢å•据来源都不需要改模板与组件。
 * <p>
 * å–值时先摊平成 Map({@link #toScope()}),不靠反射读 POJO å±žæ€§ï¼š
 * åªæš´éœ²è¿™é‡Œæ˜¾å¼æ”¾è¿›åŽ»çš„å­—æ®µï¼Œæ¨¡æ¿è·¯å¾„ä¸å¯èƒ½é¡ºç€ getClass ä¹‹ç±»è¯»åˆ°ä¸è¯¥ç»™çš„东西。
 */
@Data
@Accessors(chain = true)
public class ReportContext {
    private ReportFields report = new ReportFields();
    private List<InspectionItem> inspectionItems = new ArrayList<>();
    /** ç©ºç™½ä¸Šä¸‹æ–‡ï¼šå­—段齐全但没有值,便于逐项填充 */
    public static ReportContext empty() {
        return new ReportContext();
    }
    /** æŠ¥å‘Šçº§å­—段 â†’ Map */
    public Map<String, Object> reportMap() {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("reportNo", report.getReportNo());
        map.put("reportName", report.getReportName());
        map.put("sampleNo", report.getSampleNo());
        map.put("productCode", report.getProductCode());
        map.put("productName", report.getProductName());
        map.put("spec", report.getSpec());
        map.put("batchNo", report.getBatchNo());
        map.put("workOrderNo", report.getWorkOrderNo());
        map.put("inspectType", report.getInspectType());
        map.put("inspector", report.getInspector());
        map.put("inspectDate", report.getInspectDate());
        map.put("department", report.getDepartment());
        map.put("customerName", report.getCustomerName());
        map.put("supplierName", report.getSupplierName());
        map.put("result", report.getResult());
        map.put("resultText", report.getResultText());
        map.put("conclusion", report.getConclusion());
        map.put("passRate", report.getPassRate());
        map.put("qcResult", report.getQcResult());
        map.put("qcResultText", report.getQcResultText());
        map.put("total", report.getTotal());
        map.put("passCount", report.getPassCount());
        map.put("failCount", report.getFailCount());
        return map;
    }
    /** æ£€éªŒé¡¹åˆ—表 â†’ Map,渲染与规则求值都从这里取值 */
    public static List<Object> itemMaps(List<InspectionItem> items) {
        List<Object> maps = new ArrayList<>(items.size());
        for (InspectionItem item : items) {
            maps.add(itemMap(item));
        }
        return maps;
    }
    /** æ¸²æŸ“作用域的根:{ report, inspectionItems } */
    public Map<String, Object> toScope() {
        return scope(inspectionItems);
    }
    /**
     * æŒ‡å®šæ£€éªŒé¡¹åˆ—表的作用域:{ report, inspectionItems }。
     * <p>
     * é€é¡¹åˆ¤å®šè¦ç”¨åŽŸå§‹åˆ—è¡¨ã€æŠ¥å‘Šçº§åˆ¤å®šè¦ç”¨åˆ¤å®šåŽçš„åˆ—è¡¨ï¼Œä¸¤è€…ä¸èƒ½å…±ç”¨ä¸€ä»½ï¼Œ
     * æ‰€ä»¥è¿™é‡ŒæŠŠã€Œç”¨å“ªä»½åˆ—表」做成显式参数,避免取错。
     */
    public Map<String, Object> scope(List<InspectionItem> items) {
        Map<String, Object> scope = new LinkedHashMap<>();
        scope.put("report", reportMap());
        scope.put("inspectionItems", itemMaps(items));
        return scope;
    }
    public static Map<String, Object> itemMap(InspectionItem item) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("index", item.getIndex());
        map.put("itemCode", item.getItemCode());
        map.put("itemName", item.getItemName());
        map.put("standardValue", item.getStandardValue());
        map.put("actualValue", item.getActualValue());
        map.put("unit", item.getUnit());
        map.put("requirement", item.getRequirement());
        map.put("checkMethod", item.getCheckMethod());
        // æ²¡æœ‰åˆ†ç»„的报告不放这个键:模板里一个 {{item.group.x}} éƒ½æ²¡æœ‰ï¼Œæ”¾ä¸ªç©ºçš„空格子只会白占作用域
        if (item.getGroup() != null) {
            map.put("group", groupMap(item.getGroup()));
        }
        map.put("upperLimit", item.getUpperLimit());
        map.put("lowerLimit", item.getLowerLimit());
        map.put("result", item.getResult());
        map.put("resultText", item.getResultText());
        map.put("remark", item.getRemark());
        return map;
    }
    /** åˆ†ç»„信息 â†’ Map:三个字段一个都不能少,否则模板里对应的绑定会取不到值 */
    public static Map<String, Object> groupMap(InspectionItem.Group group) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("childName", group.getChildName());
        map.put("span", group.getSpan());
        map.put("hidden", group.getHidden());
        return map;
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/context/ReportContextCodec.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,137 @@
package cn.iocoder.yudao.module.qcreport.engine.context;
import cn.iocoder.yudao.module.qcreport.engine.JsValues;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
 * æŠ¥å‘Šä¸Šä¸‹æ–‡ä¸Žã€Œæ•°æ®å¿«ç…§ã€ä¹‹é—´çš„编解码。
 * <p>
 * æŠ¥å‘Šå®žä¾‹è½åº“时要冻结一份 {@code data_snapshot},保证历史报告不因业务数据变化而改变。
 * å¿«ç…§å°±æ˜¯ {@link ReportContext#toScope()} é‚£ä»½ç»“构({@code {report, inspectionItems}}),
 * èƒ½ç›´æŽ¥äº¤ç»™ JSON åˆ—的类型处理器;反过来要重新生成时,得把它读回 {@link ReportContext}。
 * <p>
 * <b>为什么手写 fromMap è€Œä¸ç”¨ Jackson ååºåˆ—化:</b>一是与引擎其余部分风格一致
 * ï¼ˆ{@link ReportContext#reportMap()}、{@link ReportContext#itemMap} éƒ½æ˜¯æ˜¾å¼å­—段清单,
 * åŠ å­—æ®µæ—¶æ”¹ä¸€å¤„å°±èƒ½çœ‹å‡ºæ¥ï¼‰ï¼›äºŒæ˜¯è¿™ä¸ªæ¨¡å—é‡Œ Jackson 3(JSON åˆ—类型处理器)与 Jackson 2
 * ï¼ˆSpring è¯·æ±‚体)并存,靠反射映射等于把「取哪套配置」变成一件碰运气的事。
 * è¿™é‡Œæ˜¯åŽ†å²æŠ¥å‘Šèƒ½å¦å¤çŽ°çš„å”¯ä¸€è·¯å¾„ï¼Œå€¼å¾—å†™æ­»ã€‚
 */
public final class ReportContextCodec {
    private static final String REPORT = "report";
    private static final String INSPECTION_ITEMS = "inspectionItems";
    private ReportContextCodec() {
    }
    /**
     * ä¸Šä¸‹æ–‡ â†’ å¿«ç…§ã€‚
     * <p>
     * ä¼ è¿›æ¥çš„通常已经是判定后的上下文({@code RenderOutcome.context()}),
     * å†»ç»“的应当是「产出这份 HTML çš„那份数据」,包含算好的 PASS/FAIL ä¸Žåˆæ ¼çŽ‡ã€‚
     */
    public static Map<String, Object> toMap(ReportContext context) {
        return context == null ? ReportContext.empty().toScope() : context.toScope();
    }
    /** å¿«ç…§ â†’ ä¸Šä¸‹æ–‡ã€‚缺字段、类型不对都按「这个字段没有值」处理,不抛异常 */
    public static ReportContext fromMap(Map<String, Object> snapshot) {
        ReportContext context = ReportContext.empty();
        if (snapshot == null) {
            return context;
        }
        if (snapshot.get(REPORT) instanceof Map<?, ?> report) {
            context.setReport(reportOf(report));
        }
        if (snapshot.get(INSPECTION_ITEMS) instanceof List<?> items) {
            context.setInspectionItems(itemsOf(items));
        }
        return context;
    }
    private static ReportFields reportOf(Map<?, ?> report) {
        return new ReportFields()
                .setReportNo(text(report.get("reportNo")))
                .setReportName(text(report.get("reportName")))
                .setSampleNo(text(report.get("sampleNo")))
                .setProductCode(text(report.get("productCode")))
                .setProductName(text(report.get("productName")))
                .setSpec(text(report.get("spec")))
                .setBatchNo(text(report.get("batchNo")))
                .setWorkOrderNo(text(report.get("workOrderNo")))
                .setInspectType(text(report.get("inspectType")))
                .setInspector(text(report.get("inspector")))
                .setInspectDate(text(report.get("inspectDate")))
                .setDepartment(text(report.get("department")))
                .setCustomerName(text(report.get("customerName")))
                .setSupplierName(text(report.get("supplierName")))
                .setResult(text(report.get("result")))
                .setResultText(text(report.get("resultText")))
                .setConclusion(text(report.get("conclusion")))
                .setPassRate(text(report.get("passRate")))
                .setQcResult(text(report.get("qcResult")))
                .setQcResultText(text(report.get("qcResultText")))
                .setTotal(integer(report.get("total")))
                .setPassCount(integer(report.get("passCount")))
                .setFailCount(integer(report.get("failCount")));
    }
    private static List<InspectionItem> itemsOf(List<?> items) {
        List<InspectionItem> result = new ArrayList<>(items.size());
        for (Object element : items) {
            if (element instanceof Map<?, ?> item) {
                result.add(itemOf(item));
            }
        }
        return result;
    }
    private static InspectionItem itemOf(Map<?, ?> item) {
        return new InspectionItem()
                .setIndex(integer(item.get("index")))
                .setItemCode(text(item.get("itemCode")))
                .setItemName(text(item.get("itemName")))
                .setStandardValue(text(item.get("standardValue")))
                .setActualValue(text(item.get("actualValue")))
                .setUnit(text(item.get("unit")))
                .setRequirement(text(item.get("requirement")))
                .setCheckMethod(text(item.get("checkMethod")))
                .setGroup(item.get("group") instanceof Map<?, ?> group ? groupOf(group) : null)
                .setUpperLimit(decimal(item.get("upperLimit")))
                .setLowerLimit(decimal(item.get("lowerLimit")))
                .setResult(text(item.get("result")))
                .setResultText(text(item.get("resultText")))
                .setRemark(text(item.get("remark")));
    }
    /** åˆ†ç»„信息回读:丢了它,历史报告重出时组名就跨不住整组了 */
    private static InspectionItem.Group groupOf(Map<?, ?> group) {
        return new InspectionItem.Group()
                .setChildName(text(group.get("childName")))
                .setSpan(span(group.get("span")))
                .setHidden(text(group.get("hidden")));
    }
    /** æ–‡æœ¬å­—段:null å¾—空串。与 {@link ReportFields} çš„默认值语义一致——空串是「没值」,不是「缺字段」 */
    private static String text(Object value) {
        return JsValues.asText(value);
    }
    private static int integer(Object value) {
        return value instanceof Number number ? number.intValue() : 0;
    }
    /** åˆå¹¶è¡Œæ•°å–不到值时按 1 ç®—:0 ä¼šæ¸²æŸ“成 rowspan="0",把整行的列对齐搞坏 */
    private static int span(Object value) {
        return value instanceof Number number ? number.intValue() : 1;
    }
    /** ä¸Šä¸‹é™å¯ä»¥ä¸ºç©ºï¼ˆæ²¡æœ‰è§„格区间),空值要原样保留成 null,不能变成 0 */
    private static Double decimal(Object value) {
        return value instanceof Number number ? number.doubleValue() : null;
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/context/ReportFields.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,85 @@
package cn.iocoder.yudao.module.qcreport.engine.context;
import cn.iocoder.yudao.module.qcreport.engine.QualityResult;
import lombok.Data;
import lombok.experimental.Accessors;
/**
 * æŠ¥å‘Šçº§å­—段。
 * <p>
 * æ¨¡æ¿é‡Œçš„ {@code {{report.xxx}}} å…¨éƒ¨ç›¸å¯¹å®ƒå–值,字段名与前端
 * {@code engine/context.ts} çš„ ReportContextReport ä¿æŒä¸€è‡´ã€‚
 * <p>
 * å…¶ä¸­çš„ result / resultText / conclusion / passRate / total / passCount / failCount
 * ç”±åˆ¤å®šå™¨ç®—出后写回,调用方不必自己填。
 * <p>
 * æ–‡æœ¬å­—段默认空串而不是 null:模板里 {@code {{report.xxx}}} å–到空串是「这个字段没有值」,
 * å–到 null æ˜¯ã€Œè·¯å¾„不存在」,后者会被渲染引擎当成数据缺口报出来。
 * æ²¡å¡«çš„字段应该走前者,否则每张报告都会刷一堆并不存在的缺口。
 */
@Data
@Accessors(chain = true)
public class ReportFields {
    private String reportNo = "";
    private String reportName = "";
    private String sampleNo = "";
    private String productCode = "";
    private String productName = "";
    private String spec = "";
    private String batchNo = "";
    private String workOrderNo = "";
    private String inspectType = "";
    private String inspector = "";
    private String inspectDate = "";
    private String department = "";
    private String customerName = "";
    private String supplierName = "";
    /** PASS / FAIL */
    private String result = "";
    /** åˆæ ¼ / ä¸åˆæ ¼ / å¾…判定 */
    private String resultText = "";
    /** æŠ¥å‘Šç»“论,与 {@link #resultText} åŒæºï¼›æ— ç»“论时为「待判定」 */
    private String conclusion = "";
    /** åˆæ ¼çŽ‡ï¼Œå½¢å¦‚ 96.67% */
    private String passRate = "";
    /**
     * ä¸šåŠ¡å•æ®è‡ªèº«çš„æ£€éªŒåˆ¤å®šï¼ˆMES çš„四值:合格 / ç‰¹é‡‡ / ä¸åˆæ ¼é€€è´§ / ä¸åˆæ ¼æŠ¥åºŸï¼‰ï¼Œéžåˆ¤å®šå¼•擎产出。
     * <p>
     * ä¸Žä¸Šé¢çš„ result / resultText / conclusion æ˜¯ä¸¤å›žäº‹ï¼Œåˆ»æ„åˆ†å¼€ï¼š
     * å¼•擎只有「合格 / ä¸åˆæ ¼ / å¾…判定」三态,装不下「特采」「不合格退货」这类处置口径;
     * è€Œè´¨æ£€å•上的判定是人工拍的板,报告要如实照登。两者都可能出现在同一张报告上。
     */
    private String qcResult = "";
    private String qcResultText = "";
    private int total;
    private int passCount;
    private int failCount;
    /** å¤åˆ¶ä¸€ä»½ï¼Œåˆ¤å®šå™¨ä¸ä¿®æ”¹è°ƒç”¨æ–¹ä¼ è¿›æ¥çš„上下文 */
    public ReportFields copy() {
        return new ReportFields()
                .setReportNo(reportNo).setReportName(reportName).setSampleNo(sampleNo)
                .setProductCode(productCode).setProductName(productName).setSpec(spec)
                .setBatchNo(batchNo).setWorkOrderNo(workOrderNo).setInspectType(inspectType)
                .setInspector(inspector).setInspectDate(inspectDate).setDepartment(department)
                .setCustomerName(customerName).setSupplierName(supplierName)
                .setResult(result).setResultText(resultText).setConclusion(conclusion)
                .setPassRate(passRate)
                .setQcResult(qcResult).setQcResultText(qcResultText)
                .setTotal(total).setPassCount(passCount).setFailCount(failCount);
    }
    /** æŒ‰åˆ¤å®šç»“果写回 result / resultText / conclusion,result ä¸º null æ—¶ç½®ä¸ºå¾…判定 */
    public ReportFields applyResult(QualityResult verdict) {
        this.result = verdict == null ? "" : verdict.name();
        String text = verdict == null ? QualityResult.PENDING_TEXT : verdict.text();
        this.resultText = text;
        this.conclusion = text;
        return this;
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/render/CanvasSafety.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,268 @@
package cn.iocoder.yudao.module.qcreport.engine.render;
import cn.hutool.core.util.StrUtil;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
 * ç”»å¸ƒå®‰å…¨æ€§æ ¡éªŒï¼šæ¨¡æ¿ä¿å­˜æ—¶æŠŠã€Œæ¸²æŸ“时会原样拼进 HTML çš„内容」先卡一遍。
 * <p>
 * æŠ¥å‘Šäº§ç‰©æ˜¯æ‹¼å­—符串拼出来的({@link HtmlRenderer}),模板画布又是用户可编辑数据,
 * æ‰€ä»¥ç”»å¸ƒé‡Œå‡¡æ˜¯<b>未经转义就进入产物</b>的内容都是注入面。逐条列明它们,改动渲染或本类时两边一起看:
 * <ul>
 *   <li>{@code resolveTag} â€”— èŠ‚ç‚¹çš„ {@code tagName} ç›´æŽ¥æ‹¼æˆ {@code <tag>},本类按<b>白名单</b>卡。</li>
 *   <li>{@code renderAttributes} â€”— åªå¯¹å±žæ€§<b>值</b>做转义,属性<b>名</b>是直接拼上去的,
 *       ä¸€ä¸ªå«å¼•号的属性名就能把后面的内容顶成新属性,本类按字符集卡。</li>
 *   <li>{@code buildCss} â€”— {@code styles} è‹¥æ˜¯å­—符串会被<b>原样返回</b>(整个 {@code <style>} çš„内容),
 *       è€Œ {@code <style>} å—里出现 {@code </style} å°±èƒ½æå‰é—­åˆã€æŠŠåŽé¢å˜æˆçœŸ HTML,
 *       æœ¬ç±»è¦æ±‚ {@code styles} å¿…须是规则数组,并禁止选择器/属性名/属性值/mediaText é‡Œå‡ºçް {@code <}。</li>
 * </ul>
 * <p>
 * <b>为什么在保存时卡而不是渲染时卡</b>:渲染链路上的 {@link HtmlRenderer} ä¸Žå‰ç«¯
 * {@code engine/render.ts} å¿…须逐字一致,改它就要重算对拍产物、并让存量报告的 {@code regenerate}
 * ä¸å†é€å­—复现;而「入站内容是否可信」本来就是写入侧的问题,在数据进库前拦掉,渲染侧可以继续
 * ä¿æŒä¸Žå‰ç«¯å®Œå…¨å¯¹ç§°ã€‚代价是<b>对校验上线前就已入库的脏数据没有兜底</b>——存量版本需要另行核查。
 * <p>
 * <b>为什么走的是白名单而不是过滤危险标签</b>:黑名单永远漏,而报告排版用得到的标签是有限的、
 * å¯æžšä¸¾çš„。正常模板不会因为这条白名单被拒(已对库里全部存量版本实测过)。
 */
public final class CanvasSafety {
    /**
     * å…è®¸å‡ºçŽ°åœ¨ç”»å¸ƒé‡Œçš„æ ‡ç­¾ã€‚
     * <p>
     * åªæ”¶ã€ŒæŽ’版与文本」类标签:组件注册表能产出的({@code div/p/span/hr/img/h1~h6} ä¸Žè¡¨æ ¼ä¸€æ—ï¼‰
     * åŠ ä¸Š GrapesJS åŸºç¡€ç»„件与常见行内语义标签。刻意排除四类——
     * å¯æ‰§è¡Œï¼ˆ{@code script})、可注入样式({@code style})、可嵌入外部内容({@code iframe/object/embed})、
     * å¯æäº¤æ•°æ®ï¼ˆ{@code form} åŠå…¶æŽ§ä»¶ï¼‰ï¼Œä»¥åŠ {@code svg/math} è¿™ç±»è‡ªå¸¦è„šæœ¬èƒ½åŠ›çš„å‘½åç©ºé—´æ ‡ç­¾ã€‚
     * <p>
     * GrapesJS ä¼šåœ¨ç»„件上挂 {@code docEl: {tagName: "html"}}、{@code head: {type: "head"}} è¿™ç±»
     * <b>不会渲染</b>的元数据,所以本类只沿 {@code components} èµ°ï¼ˆä¸Ž {@code RenderNode.children()} ä¸€è‡´ï¼‰ï¼Œ
     * ç™½åå•里也就不需要 {@code html}/{@code head}。
     */
    private static final Set<String> ALLOWED_TAGS = Set.of(
            // åŒºå—与容器
            "div", "span", "section", "article", "header", "footer", "main", "aside", "nav",
            "figure", "figcaption", "blockquote", "pre", "address", "center",
            // æ–‡æœ¬ä¸Žæ ‡é¢˜
            "p", "br", "hr", "h1", "h2", "h3", "h4", "h5", "h6",
            "b", "i", "u", "s", "strong", "em", "small", "big", "sub", "sup", "mark",
            "code", "kbd", "samp", "var", "tt", "strike", "font", "wbr", "bdi", "bdo", "ruby", "rt", "rp",
            "abbr", "cite", "q", "time", "del", "ins",
            // åˆ—表
            "ul", "ol", "li", "dl", "dt", "dd",
            // è¡¨æ ¼
            "table", "thead", "tbody", "tfoot", "tr", "td", "th", "caption", "col", "colgroup",
            // å›¾ç‰‡ä¸Žé“¾æŽ¥
            "img", "a", "label");
    /** åˆæ³•的属性名:字母/下划线/冒号开头,其后字母数字与 {@code - _ : .}。含引号或空格的属性名一律拒绝 */
    private static final Pattern ATTRIBUTE_NAME = Pattern.compile("^[A-Za-z_:][-A-Za-z0-9_:.]*$");
    /** æœ€å¤šæŠ¥å‡ æ¡ï¼šä¸€æ¡æŠ¥é”™ä¿¡æ¯é‡Œå †å‡ åæ¡æ²¡äººçœ‹å¾—下去,改完再存一次就能看到下一批 */
    private static final int MAX_PROBLEMS = 8;
    /** è·¯å¾„展示的最大层数,再深就折叠成 â€¦ï¼Œé¿å…ä¸€æ¡æç¤ºé‡Œå‡ºçŽ°ä¸€é•¿ä¸²ã€Œç¬¬ N ä¸ªç»„件」 */
    private static final int MAX_PATH_DEPTH = 6;
    private static final String UNSAFE_LT = "<";
    private CanvasSafety() {
    }
    /**
     * æ ¡éªŒä¸€ä»½ç”»å¸ƒã€‚
     *
     * @param grapes GradesJS é¡¹ç›®æ•°æ®ï¼ˆæ¨¡æ¿ Schema çš„ {@code grapes} å­—段),可为 null
     * @return é—®é¢˜æ¸…单,空列表代表通过。文案已可直接拼给用户看
     */
    public static List<String> validate(Map<String, Object> grapes) {
        List<String> problems = new ArrayList<>();
        if (grapes == null) {
            return problems;
        }
        validateComponentTree(grapes, problems);
        validateStyles(grapes.get("styles"), problems);
        return problems;
    }
    /* ------------------------------ ç»„ä»¶æ ‘ ------------------------------ */
    private static void validateComponentTree(Map<String, Object> grapes, List<String> problems) {
        for (Map<String, Object> frameComponent : frameComponents(grapes)) {
            walk(frameComponent, "æ ¹", 0, problems);
        }
    }
    /** å–所有页所有 frame çš„æ ¹ç»„件。渲染器只读 {@code pages[0].frames[0]},这里全查一遍:多出来的部分也不能藏脏东西 */
    private static List<Map<String, Object>> frameComponents(Map<String, Object> grapes) {
        List<Map<String, Object>> result = new ArrayList<>();
        collectFrames(grapes.get("pages"), result);
        return result;
    }
    @SuppressWarnings("unchecked")
    private static void collectFrames(Object pages, List<Map<String, Object>> result) {
        if (!(pages instanceof List<?> pageList)) {
            return;
        }
        for (Object rawPage : pageList) {
            if (!(rawPage instanceof Map<?, ?> page)) {
                continue;
            }
            Object frames = ((Map<String, Object>) page).get("frames");
            if (!(frames instanceof List<?> frameList)) {
                continue;
            }
            for (Object rawFrame : frameList) {
                if (rawFrame instanceof Map<?, ?> frame
                        && ((Map<String, Object>) frame).get("component") instanceof Map<?, ?> component) {
                    result.add((Map<String, Object>) component);
                }
            }
        }
    }
    @SuppressWarnings("unchecked")
    private static void walk(Map<String, Object> node, String path, int depth, List<String> problems) {
        if (problems.size() >= MAX_PROBLEMS) {
            return;
        }
        validateNodeTag(node, path, problems);
        validateNodeAttributes(node, path, problems);
        if (!(node.get("components") instanceof List<?> children)) {
            return;
        }
        int index = 0;
        for (Object raw : children) {
            if (!(raw instanceof Map<?, ?> child)) {
                continue;
            }
            index++;
            walk((Map<String, Object>) child, path + " > ç¬¬ " + index + " ä¸ªç»„ä»¶", depth + 1, problems);
            if (problems.size() >= MAX_PROBLEMS) {
                return;
            }
        }
    }
    private static void validateNodeTag(Map<String, Object> node, String path, List<String> problems) {
        Object raw = node.get("tagName");
        String tagName = raw == null ? null : String.valueOf(raw).trim();
        // textnode ä¸äº§ç”Ÿæ ‡ç­¾ï¼ˆHtmlRenderer å¯¹å®ƒå•独处理),它身上的 tagName æ¸²æŸ“时被忽略,不必拦
        if (StrUtil.isBlank(tagName) || "textnode".equals(node.get("type"))) {
            return;
        }
        if (ALLOWED_TAGS.contains(tagName.toLowerCase(Locale.ROOT))) {
            return;
        }
        problems.add(StrUtil.format(
                "{} çš„æ ‡ç­¾æ˜¯ã€Œ{}」,报告不支持该标签。可用的只有常规排版标签"
                        + "(div/p/span/h1~h6/img/hr ä¸Žè¡¨æ ¼ã€åˆ—表、行内文本标签),"
                        + "脚本、样式、内嵌页面、表单类标签一律不允许",
                displayPath(path), tagName));
    }
    @SuppressWarnings("unchecked")
    private static void validateNodeAttributes(Map<String, Object> node, String path, List<String> problems) {
        if (!(node.get("attributes") instanceof Map<?, ?> attributes)) {
            return;
        }
        for (Object key : ((Map<String, Object>) attributes).keySet()) {
            String name = String.valueOf(key);
            if (!ATTRIBUTE_NAME.matcher(name).matches()) {
                problems.add(StrUtil.format(
                        "{} çš„属性名「{}」不是合法的属性名(只能由字母、数字、- _ : . ç»„成,且不能以数字开头),"
                                + "该属性会让报告产物结构错乱",
                        displayPath(path), name));
                continue;
            }
            if (name.toLowerCase(Locale.ROOT).startsWith("on")) {
                problems.add(StrUtil.format(
                        "{} çš„属性「{}」是事件属性,报告不支持",
                        displayPath(path), name));
            }
        }
    }
    /* ------------------------------ æ ·å¼ ------------------------------ */
    @SuppressWarnings("unchecked")
    private static void validateStyles(Object styles, List<String> problems) {
        if (styles == null) {
            return;
        }
        if (styles instanceof String text) {
            if (StrUtil.isNotBlank(text)) {
                // buildCss ä¼šæŠŠå®ƒåŽŸæ ·å¡žè¿› <style>,等于让模板自带一整段不受控的 CSS(还能提前闭合 <style>)
                problems.add("画布的 styles æ˜¯ä¸€æ®µ CSS æ–‡æœ¬ï¼ŒæŠ¥å‘Šä¸æŽ¥å—整段 CSS,"
                        + "请改用设计器的样式面板逐条设置样式(保存后是样式规则数组)");
            }
            return;
        }
        if (!(styles instanceof List<?> rules)) {
            problems.add("画布的 styles æ—¢ä¸æ˜¯æ ·å¼è§„则数组也不是 CSS æ–‡æœ¬ï¼Œæ— æ³•识别,请重新保存模板");
            return;
        }
        for (int i = 0; i < rules.size() && problems.size() < MAX_PROBLEMS; i++) {
            if (!(rules.get(i) instanceof Map<?, ?> rule)) {
                continue;
            }
            Map<String, Object> ruleMap = (Map<String, Object>) rule;
            validateStyleFragments(ruleMap.get("selectors"), "样式规则 " + (i + 1) + " çš„选择器", problems);
            validateStyleFragments(ruleMap.get("mediaText"), "样式规则 " + (i + 1) + " çš„媒体查询条件", problems);
            Object style = ruleMap.get("style");
            if (style instanceof Map<?, ?> styleMap) {
                for (Map.Entry<?, ?> entry : ((Map<String, Object>) styleMap).entrySet()) {
                    String where = StrUtil.format("样式规则 {} çš„属性「{}」",
                            i + 1, String.valueOf(entry.getKey()));
                    if (String.valueOf(entry.getKey()).contains(UNSAFE_LT)) {
                        problems.add(where + "名字里含有「<」,报告不支持");
                        continue;
                    }
                    validateStyleFragments(entry.getValue(), where, problems);
                }
            }
        }
    }
    /** é€‰æ‹©å™¨/媒体查询/样式值都进 {@code <style>},出现 {@code <} å°±å¯èƒ½é—­åˆ style å— */
    private static void validateStyleFragments(Object raw, String where, List<String> problems) {
        if (raw instanceof List<?> list) {
            for (Object item : list) {
                validateStyleFragments(item, where, problems);
            }
            return;
        }
        if (raw == null || problems.size() >= MAX_PROBLEMS) {
            return;
        }
        String text = String.valueOf(raw);
        if (text.contains(UNSAFE_LT)) {
            problems.add(StrUtil.format("{}「{}」里含有「<」,报告不支持,请在设计器里改掉",
                    where, StrUtil.maxLength(text, 60)));
        }
    }
    /* ------------------------------ å±•示 ------------------------------ */
    /** è·¯å¾„太深就折叠中段,报错信息里出现一长串「第 N ä¸ªç»„件」对定位没有帮助 */
    private static String displayPath(String path) {
        String[] segments = path.split(" > ");
        if (segments.length <= MAX_PATH_DEPTH) {
            return "画布「" + path + "」";
        }
        List<String> shown = new ArrayList<>(List.of(segments[0], segments[1], "…"));
        for (int i = segments.length - 3; i < segments.length; i++) {
            shown.add(segments[i]);
        }
        return "画布「" + String.join(" > ", shown) + "」";
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/render/HtmlRenderer.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,408 @@
package cn.iocoder.yudao.module.qcreport.engine.render;
import cn.iocoder.yudao.module.qcreport.engine.Bindings;
import cn.iocoder.yudao.module.qcreport.engine.PageSetting;
import cn.iocoder.yudao.module.qcreport.engine.PageSizes;
import cn.iocoder.yudao.module.qcreport.engine.Paths;
import cn.iocoder.yudao.module.qcreport.engine.ResolvedPage;
import cn.iocoder.yudao.module.qcreport.engine.context.ReportContext;
import cn.iocoder.yudao.module.qcreport.engine.report.EvaluationOutcome;
import cn.iocoder.yudao.module.qcreport.engine.report.ReportEvaluator;
import cn.iocoder.yudao.module.qcreport.engine.rule.QualityRuleDefinition;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
 * æŠ¥å‘Šæ¸²æŸ“引擎。
 * <p>
 * è¾“入「模板 Schema + æŠ¥å‘Šä¸Šä¸‹æ–‡ã€ï¼Œè¾“出可直接打印的 HTML æ–‡æ¡£ã€‚
 * æ¸²æŸ“顺序固定为「先判定、后渲染」:PASS/FAIL ç”± {@link ReportEvaluator} ç®—好写进上下文,
 * æ¨¡æ¿é‡Œåªåšå–值,因此同一份数据在任何模板下判定一致,模板也无法左右判定结果。
 * <p>
 * è¿™é‡Œä¸è®¤è¯†å…·ä½“组件类型,只吃 GrapesJS å¯¼å‡ºçš„项目数据(组件树 + æ ·å¼ï¼‰ï¼Œ
 * åªè®¤ä¸‰å¥—协议:{@code data-qc-repeat} é‡å¤è¡Œã€{@code {{路径}}} ç»‘定、{@code schema.rules} åˆ¤å®šè§„则。
 * ä¸Žå‰ç«¯ {@code engine/render.ts} ä¸€ä¸€å¯¹åº”,两边产出的 HTML å¿…须一致。
 */
public final class HtmlRenderer {
    /** è®¾è®¡å™¨æ ‡è®°ï¼Œä¸è¿›å…¥äº§ç‰© */
    private static final String QUALITY_TYPE_ATTR = "data-quality-type";
    private static final String QUALITY_PROP_PREFIX = "data-qc-";
    private static final String QUALITY_REPEAT_ATTR = "data-qc-repeat";
    private static final String QUALITY_REPEAT_ROW_ATTR = "data-qc-repeat-row";
    /** GrapesJS çš„组件 id æ¢æˆ data å±žæ€§ï¼šé‡å¤è¡Œé‡Œ id ä¼šé‡åï¼Œdata å±žæ€§ä¸ä¼š */
    private static final String QUALITY_ID_ATTR = "data-qc-id";
    /** è‡ªé—­åˆæ ‡ç­¾ï¼Œä¸èƒ½ç”Ÿæˆç»“束标签 */
    private static final Set<String> VOID_TAGS = Set.of(
            "br", "col", "hr", "img", "input", "link", "meta", "source", "track", "wbr");
    /**
     * GrapesJS ç»„件类型 â†’ HTML æ ‡ç­¾ã€‚
     * <p>
     * é¡¹ç›®æ•°æ®åªåœ¨æ ‡ç­¾ä¸Žç±»åž‹é»˜è®¤å€¼ä¸åŒæ—¶æ‰å†™ tagName(例如 th、span、h2),
     * è¡¨æ ¼ç»“构(table/thead/tbody/row/cell)在数据里都只有 type,
     * æ‰€ä»¥æ¼æŽ‰è¿™å¼ è¡¨å°±ä¼šæŠŠæ•´å¼ è¡¨æ ¼æ¸²æŸ“成一堆 div。未收录的类型回退到 div,
     * ä¸Ž GrapesJS åŸºç¡€ç»„件的默认标签一致。
     */
    private static final Map<String, String> TYPE_TAGS = Map.of(
            "cell", "td",
            "row", "tr",
            "table", "table",
            "tbody", "tbody",
            "tfoot", "tfoot",
            "thead", "thead",
            "text", "div",
            "wrapper", "div");
    /** æ¸²æŸ“产物的基础排版。浏览器默认的表格与页边距会让报告走样,这里收敛成打印友好的基线 */
    private static final String BASE_CSS = """
            * { box-sizing: border-box; }
            body { margin: 0; color: #000; font-family: "Microsoft YaHei", "PingFang SC", sans-serif; font-size: 12px; line-height: 1.5; }
            table { border-collapse: collapse; width: 100%; }
            img { max-width: 100%; }
            tr, td, th { page-break-inside: avoid; }""";
    /** æ‰¿è½½å¤–部地址的属性,值里出现可执行协议一律拦掉 */
    private static final Set<String> URL_ATTRIBUTES = Set.of(
            "href", "src", "xlink:href", "action", "formaction", "background", "poster");
    /** å¯æ‰§è¡Œ / å¯æ³¨å…¥çš„ URL åè®® */
    private static final Pattern DANGEROUS_SCHEME =
            Pattern.compile("^\\s*(?:javascript|vbscript|data)\\s*:", Pattern.CASE_INSENSITIVE);
    /** data: é‡Œåªæœ‰å›¾ç‰‡æ˜¯æ­£å½“用途,其余(text/html ç­‰ï¼‰ä¼šå˜æˆæ³¨å…¥é¢ */
    private static final Pattern DATA_IMAGE = Pattern.compile("^\\s*data:image/", Pattern.CASE_INSENSITIVE);
    /** GrapesJS ç”¨ #组件id é€‰æ‹©å™¨ï¼Œè¿™é‡Œæ”¹å†™æˆ data å±žæ€§é€‰æ‹©å™¨ */
    private static final Pattern ID_SELECTOR = Pattern.compile("#([\\w-]+)");
    private HtmlRenderer() {
    }
    /**
     * æ¸²æŸ“报告:判定 â†’ ç”Ÿæˆæ­£æ–‡ â†’ æ‹¼æ ·å¼ â†’ ç»„装文档。
     *
     * @param grapes  GrapesJS é¡¹ç›®æ•°æ®ï¼ˆæ¨¡æ¿ Schema çš„ grapes å­—段)
     * @param page    çº¸å¼ ä¸Žé¡µè¾¹è·é…ç½®
     * @param context æŠ¥å‘Šä¸Šä¸‹æ–‡
     * @param rules   åˆ¤å®šè§„则,可为空
     */
    public static RenderOutcome render(Map<String, Object> grapes, PageSetting page,
                                       ReportContext context, List<QualityRuleDefinition> rules) {
        EvaluationOutcome evaluated = ReportEvaluator.evaluate(context, rules);
        RenderState state = new RenderState(evaluated.context().toScope(), "模板",
                new ArrayList<>(evaluated.errors()), new LinkedHashSet<>());
        RenderNode root = RenderNode.of(Paths.readPath(grapes, "pages[0].frames[0].component"));
        String body = root == null ? "" : renderNode(root, state);
        String css = buildCss(grapes == null ? null : grapes.get("styles"));
        String html = buildDocument(page, body, css, evaluated.context());
        return new RenderOutcome(html, body, evaluated.context(), state.errors);
    }
    /* ------------------------------ èŠ‚ç‚¹å±•å¼€ ------------------------------ */
    /** æ¸²æŸ“单个节点:文本节点与重复容器单独处理,其余按「标签 + å±žæ€§ + å­èŠ‚ç‚¹ã€å±•å¼€ */
    private static String renderNode(RenderNode node, RenderState state) {
        // æ–‡æœ¬èŠ‚ç‚¹åªè´¡çŒ®æ–‡æœ¬ï¼Œè‡ªèº«ä¸äº§ç”Ÿæ ‡ç­¾
        if ("textnode".equals(node.type())) {
            return renderText(node.content() == null ? "" : node.content(), state);
        }
        String repeatPath = node.attribute(QUALITY_REPEAT_ATTR);
        if (repeatPath != null && !repeatPath.isEmpty()) {
            return renderRepeat(node, repeatPath, state);
        }
        String tag = resolveTag(node);
        String attributes = renderAttributes(node, state);
        String style = renderStyle(node);
        if (VOID_TAGS.contains(tag)) {
            return "<" + tag + attributes + style + ">";
        }
        return "<" + tag + attributes + style + ">" + renderChildren(node, state) + "</" + tag + ">";
    }
    /** æ ‡ç­¾ä¼˜å…ˆå–数据里的 tagName,其次按组件类型推断 */
    private static String resolveTag(RenderNode node) {
        String tagName = node.tagName();
        if (tagName != null && !tagName.isEmpty()) {
            return tagName;
        }
        String type = node.type();
        String byType = type == null ? null : TYPE_TAGS.get(type);
        return byType == null ? "div" : byType;
    }
    /**
     * å­èŠ‚ç‚¹æŒ‰å£°æ˜Žé¡ºåºå±•å¼€ã€‚
     * <p>
     * æœ‰å­ç»„件时忽略 content——与 GrapesJS ä¸€è‡´ï¼šå¾€ä¸€ä¸ªå¸¦æ–‡æœ¬çš„组件里再拖入组件后,
     * æ–‡æœ¬å°±è®©ä½ç»™å­ç»„件,这里必须同规则,否则渲染产物会和设计器看到的不一样。
     */
    private static String renderChildren(RenderNode node, RenderState state) {
        List<RenderNode> children = node.children();
        if (!children.isEmpty()) {
            StringBuilder builder = new StringBuilder();
            for (RenderNode child : children) {
                builder.append(renderNode(child, state));
            }
            return builder.toString();
        }
        String content = node.content();
        return content == null ? "" : renderText(content, state);
    }
    /**
     * é‡å¤å®¹å™¨ï¼šæŒ‰æ•°ç»„路径展开行模板,其余子节点在原位置渲染一次。
     * <p>
     * è¡Œæ¨¡æ¿ä¸Šçš„ {@code data-qc-repeat-row} å£°æ˜Žäº†è¡Œå†…的循环变量名(如 item),
     * è¡Œå†…容用 {@code {{item.xxx}}} å–值,与设计期在组件里约定的绑定路径一致。
     */
    private static String renderRepeat(RenderNode node, String path, RenderState state) {
        List<Object> rows = toRowList(Paths.readPath(state.scope, path));
        StringBuilder inner = new StringBuilder();
        for (RenderNode child : node.children()) {
            String rowVar = child.attribute(QUALITY_REPEAT_ROW_ATTR);
            if (rowVar == null || rowVar.isEmpty()) {
                inner.append(renderNode(child, state));
                continue;
            }
            for (int index = 0; index < rows.size(); index++) {
                Map<String, Object> rowScope = new LinkedHashMap<>(state.scope);
                rowScope.put(rowVar, rows.get(index));
                // index ç”±é‡å¤å®¹å™¨æ³¨å…¥ï¼Œè¡Œå†…可写 {{index}},从 1 å¼€å§‹
                rowScope.put("index", index + 1);
                inner.append(renderNode(child, state.withScope(rowScope, "第 " + (index + 1) + " è¡Œ")));
            }
        }
        String tag = resolveTag(node);
        return "<" + tag + renderAttributes(node, state) + renderStyle(node) + ">" + inner + "</" + tag + ">";
    }
    /** éžæ•°ç»„按单行处理,空值得到空表;比抛错更贴合「数据没填全」的报告场景 */
    private static List<Object> toRowList(Object value) {
        if (value instanceof List<?> list) {
            return new ArrayList<>(list);
        }
        if (value == null) {
            return List.of();
        }
        return List.of(value);
    }
    /* ------------------------------ å±žæ€§ä¸Žæ ·å¼ ------------------------------ */
    /**
     * æ¸²æŸ“节点属性。
     * <p>
     * è®¾è®¡å™¨æ ‡è®°ï¼ˆdata-quality-type / data-qc-*)不进入产物;
     * å±žæ€§å€¼é‡Œçš„ {@code {{path}}} ä¸€å¹¶è§£æžï¼Œå›¾ç‰‡åœ°å€ã€é“¾æŽ¥ç­‰ä¹Ÿèƒ½ç»‘定数据。
     * <p>
     * æ¯”前端多一道闸:事件属性与可执行协议不进产物。产物是给人打印的文档,
     * on* ä¹‹ç±»çš„属性没有正当用途,放行等于给模板开了个执行口子。
     */
    private static String renderAttributes(RenderNode node, RenderState state) {
        StringBuilder builder = new StringBuilder();
        for (Map.Entry<String, String> entry : node.attributes().entrySet()) {
            String name = entry.getKey();
            if (QUALITY_TYPE_ATTR.equals(name) || name.startsWith(QUALITY_PROP_PREFIX)) {
                continue;
            }
            String text = renderText(entry.getValue() == null ? "" : entry.getValue(), state);
            if (text.isEmpty()) {
                continue;
            }
            if (!isSafeAttribute(name, text, state)) {
                continue;
            }
            builder.append(' ').append("id".equals(name) ? QUALITY_ID_ATTR : name)
                    .append("=\"").append(escapeHtml(text)).append('"');
        }
        return builder.toString();
    }
    /** å±žæ€§å/值安全闸,拦下的写进问题清单,让模板作者看得见而不是悄悄少个属性 */
    private static boolean isSafeAttribute(String name, String value, RenderState state) {
        String lower = name.toLowerCase(Locale.ROOT);
        if (lower.length() > 2 && lower.startsWith("on")) {
            state.errors.add(state.where + "属性「" + name + "」是事件属性,报告产物不输出,已忽略");
            return false;
        }
        if (URL_ATTRIBUTES.contains(lower) && DANGEROUS_SCHEME.matcher(value).find()
                && !DATA_IMAGE.matcher(value).find()) {
            state.errors.add(state.where + "属性「" + name + "」的地址协议不安全,报告产物不输出,已忽略");
            return false;
        }
        return true;
    }
    /** èŠ‚ç‚¹è‡ªèº«çš„è¡Œå†…æ ·å¼ */
    private static String renderStyle(RenderNode node) {
        String style = cssStyle(node.style());
        return style.isEmpty() ? "" : " style=\"" + escapeHtml(style) + "\"";
    }
    /** å–文本里的绑定值,取不到的路径记进问题清单(未解析的 {{}} ä¸ä¼šç•™åœ¨äº§ç‰©é‡Œï¼‰ */
    private static String renderText(String template, RenderState state) {
        return Bindings.resolveText(template, state.scope, path -> {
            if (state.reported.add(state.where + "|" + path)) {
                state.errors.add(state.where + "绑定「{{" + path + "}}」在当前数据中取不到值,已渲染为空");
            }
        });
    }
    /** æ‹¼ä¸€æ®µå†…联样式,过滤掉空值 */
    private static String cssStyle(Map<String, String> style) {
        List<String> parts = new ArrayList<>(style.size());
        for (Map.Entry<String, String> entry : style.entrySet()) {
            String value = entry.getValue();
            if (value == null || value.isEmpty()) {
                continue;
            }
            parts.add(entry.getKey() + ":" + value);
        }
        return String.join(";", parts);
    }
    /* ------------------------------ æ ·å¼ä¸Žæ–‡æ¡£ ------------------------------ */
    /** æ ·å¼è§„则数组 â†’ CSS æ–‡æœ¬ï¼›GrapesJS ç”¨ #组件id é€‰æ‹©å™¨ï¼Œè¿™é‡Œæ”¹å†™æˆ data å±žæ€§é€‰æ‹©å™¨ */
    private static String buildCss(Object styles) {
        if (styles instanceof String text) {
            return text;
        }
        if (!(styles instanceof List<?> rules)) {
            return "";
        }
        List<String> blocks = new ArrayList<>();
        Map<String, List<String>> mediaBlocks = new LinkedHashMap<>();
        for (Object raw : rules) {
            if (!(raw instanceof Map<?, ?> rule)) {
                continue;
            }
            String selector = buildSelector(rule.get("selectors"));
            String body = cssStyle(textMap(rule.get("style")));
            if (selector.isEmpty() || body.isEmpty()) {
                continue;
            }
            String line = selector + " { " + body + " }";
            String mediaText = textOf(rule.get("mediaText")).trim();
            if (!mediaText.isEmpty()) {
                mediaBlocks.computeIfAbsent(mediaText, key -> new ArrayList<>()).add(line);
                continue;
            }
            blocks.add(line);
        }
        mediaBlocks.forEach((mediaText, lines) -> {
            String atRule = mediaText.startsWith("@") ? mediaText : "@media " + mediaText;
            blocks.add(atRule + " { " + String.join(" ", lines) + " }");
        });
        return String.join("\n", blocks);
    }
    private static String buildSelector(Object rawSelectors) {
        if (!(rawSelectors instanceof List<?> selectors)) {
            return "";
        }
        List<String> parts = new ArrayList<>(selectors.size());
        for (Object raw : selectors) {
            String selector = toSafeSelector(textOf(raw));
            if (!selector.isEmpty()) {
                parts.add(selector);
            }
        }
        return String.join(", ", parts);
    }
    /** #组件id â†’ [data-qc-id="组件id"],重复行复制后样式仍然命中 */
    private static String toSafeSelector(String selector) {
        return ID_SELECTOR.matcher(selector).replaceAll("[" + QUALITY_ID_ATTR + "=\"$1\"]");
    }
    private static Map<String, String> textMap(Object raw) {
        Map<String, String> result = new LinkedHashMap<>();
        if (raw instanceof Map<?, ?> map) {
            for (Map.Entry<?, ?> entry : map.entrySet()) {
                result.put(String.valueOf(entry.getKey()),
                        entry.getValue() == null ? null : String.valueOf(entry.getValue()));
            }
        }
        return result;
    }
    private static String textOf(Object value) {
        return value == null ? "" : String.valueOf(value);
    }
    /** ç»„装最终文档:纸张与页边距交给 @page,正文里不再重复留白,才能保证每页都有边距 */
    private static String buildDocument(PageSetting page, String body, String css, ReportContext context) {
        ResolvedPage resolved = PageSizes.resolve(page);
        String title = context.getReport().getReportName();
        if (title == null || title.isEmpty()) {
            title = context.getReport().getReportNo();
        }
        if (title == null || title.isEmpty()) {
            title = "质检报告";
        }
        return String.join("\n",
                "<!doctype html>",
                "<html lang=\"zh-CN\">",
                "<head>",
                "<meta charset=\"utf-8\" />",
                "<title>" + escapeHtml(title) + "</title>",
                "<style>",
                "@page { size: " + resolved.cssSize() + "; margin: " + resolved.cssMargin() + "; }",
                BASE_CSS,
                css,
                "</style>",
                "</head>",
                "<body>" + body + "</body>",
                "</html>");
    }
    /** ä»»ä½•来自用户(设计器属性面板)的文本都必须先转义再拼进标签,否则渲染期就是注入点 */
    private static String escapeHtml(String value) {
        return value.replace("&", "&amp;")
                .replace("<", "&lt;")
                .replace(">", "&gt;")
                .replace("\"", "&quot;")
                .replace("'", "&#39;");
    }
    /** ä¸€æ¬¡æ¸²æŸ“的行走状态:作用域 + å½“前位置 + é—®é¢˜æ¸…单,行内复制时共享后两者 */
    private static final class RenderState {
        private final Map<String, Object> scope;
        /** å½“前渲染位置的描述,用于把问题定位到具体行 */
        private final String where;
        private final List<String> errors;
        /** å·²ä¸ŠæŠ¥çš„缺口,同一处只提示一次 */
        private final Set<String> reported;
        private RenderState(Map<String, Object> scope, String where,
                            List<String> errors, Set<String> reported) {
            this.scope = scope;
            this.where = where;
            this.errors = errors;
            this.reported = reported;
        }
        private RenderState withScope(Map<String, Object> scope, String where) {
            return new RenderState(scope, where, errors, reported);
        }
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/render/RenderNode.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,88 @@
package cn.iocoder.yudao.module.qcreport.engine.render;
import cn.iocoder.yudao.module.qcreport.engine.JsValues;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
 * GrapesJS é¡¹ç›®æ•°æ®èŠ‚ç‚¹ã€‚
 * <p>
 * åªå£°æ˜Žæ¸²æŸ“需要的字段,直接读原始 Map:{@code components} æ—¢å¯èƒ½æ˜¯æ•°ç»„也可能是字符串,
 * ç”¨ POJO ååºåˆ—化要额外处理联合类型,不如按需取值来得直白,也省一次拷贝。
 */
public final class RenderNode {
    private final Map<String, Object> data;
    private RenderNode(Map<String, Object> data) {
        this.data = data;
    }
    /** ä¸æ˜¯å¯¹è±¡èŠ‚ç‚¹æ—¶è¿”å›ž null(如数组里混入了字符串) */
    @SuppressWarnings("unchecked")
    public static RenderNode of(Object raw) {
        return raw instanceof Map<?, ?> ? new RenderNode((Map<String, Object>) raw) : null;
    }
    public String type() {
        return text(data.get("type"));
    }
    public String tagName() {
        return text(data.get("tagName"));
    }
    /** åªåœ¨ content æœ¬èº«æ˜¯å­—符串时返回,与前端 {@code typeof node.content === 'string'} ä¸€è‡´ */
    public String content() {
        return data.get("content") instanceof String content ? content : null;
    }
    /** å–一个属性值,取不到返回 null */
    public String attribute(String name) {
        return attributes().get(name);
    }
    /** å±žæ€§è¡¨ï¼Œå€¼ç»Ÿä¸€æˆæ–‡æœ¬ */
    public Map<String, String> attributes() {
        Map<String, String> result = new LinkedHashMap<>();
        if (data.get("attributes") instanceof Map<?, ?> attributes) {
            for (Map.Entry<?, ?> entry : attributes.entrySet()) {
                result.put(String.valueOf(entry.getKey()), JsValues.asText(entry.getValue()));
            }
        }
        return result;
    }
    /** è¡Œå†…样式表,值统一成文本 */
    public Map<String, String> style() {
        Map<String, String> result = new LinkedHashMap<>();
        if (data.get("style") instanceof Map<?, ?> style) {
            for (Map.Entry<?, ?> entry : style.entrySet()) {
                result.put(String.valueOf(entry.getKey()), JsValues.asText(entry.getValue()));
            }
        }
        return result;
    }
    /** å­ç»„ä»¶ï¼›components æ˜¯å­—符串(旧数据/纯文本组件)时返回空列表 */
    public List<RenderNode> children() {
        List<RenderNode> result = new ArrayList<>();
        if (data.get("components") instanceof List<?> components) {
            for (Object raw : components) {
                RenderNode child = of(raw);
                if (child != null) {
                    result.add(child);
                }
            }
        }
        return result;
    }
    private static String text(Object value) {
        return value == null ? null : String.valueOf(value);
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/render/RenderOutcome.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,16 @@
package cn.iocoder.yudao.module.qcreport.engine.render;
import cn.iocoder.yudao.module.qcreport.engine.context.ReportContext;
import java.util.List;
/**
 * æ¸²æŸ“结果。
 *
 * @param html    å®Œæ•´ HTML æ–‡æ¡£ï¼Œæ‰“印/PDF ç›´æŽ¥ä½¿ç”¨
 * @param body    æ­£æ–‡ç‰‡æ®µï¼Œä¾¿äºŽåµŒå…¥é¡µé¢é¢„览
 * @param context åˆ¤å®šåŽçš„上下文(含 PASS/FAIL åˆ¤å®šä¸Žåˆæ ¼çŽ‡ï¼‰
 * @param errors  è§„则与绑定的问题清单,调用方必须显式暴露,不能悄悄吞掉
 */
public record RenderOutcome(String html, String body, ReportContext context, List<String> errors) {
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/report/EvaluationOutcome.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,14 @@
package cn.iocoder.yudao.module.qcreport.engine.report;
import cn.iocoder.yudao.module.qcreport.engine.context.ReportContext;
import java.util.List;
/**
 * åˆ¤å®šç»“果。
 *
 * @param context åˆ¤å®šåŽçš„上下文(含各项 PASS/FAIL ä¸ŽæŠ¥å‘Šç»“论、合格率)
 * @param errors  è§„则的问题清单,渲染时必须在报告上显式暴露,不能悄悄吞掉
 */
public record EvaluationOutcome(ReportContext context, List<String> errors) {
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/report/ReportEvaluator.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,190 @@
package cn.iocoder.yudao.module.qcreport.engine.report;
import cn.iocoder.yudao.module.qcreport.engine.Paths;
import cn.iocoder.yudao.module.qcreport.engine.QualityResult;
import cn.iocoder.yudao.module.qcreport.engine.context.InspectionItem;
import cn.iocoder.yudao.module.qcreport.engine.context.ReportContext;
import cn.iocoder.yudao.module.qcreport.engine.context.ReportFields;
import cn.iocoder.yudao.module.qcreport.engine.rule.QualityRuleDefinition;
import cn.iocoder.yudao.module.qcreport.engine.rule.RuleEngine;
import cn.iocoder.yudao.module.qcreport.engine.rule.RuleScope;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
 * æŠ¥å‘Šåˆ¤å®šã€‚
 * <p>
 * åœ¨æ¸²æŸ“前把检验项算成 PASS/FAIL,并汇总出报告结论与合格率。
 * åˆ¤å®šåªå‘生在这里,组件与模板都不做计算,保证同一份数据在任何模板下判定一致。
 * <p>
 * ä¸Žå‰ç«¯ {@code engine/report-evaluator.ts} ä¸€ä¸€å¯¹åº”,两边必须算出同一结果。
 */
public final class ReportEvaluator {
    private ReportEvaluator() {
    }
    /**
     * è®¡ç®—检验项判定与报告汇总。
     * <p>
     * å•个检验项的判定优先级:item ä½œç”¨åŸŸè§„则 â†’ è§„格上下限 â†’ æ•°æ®è‡ªå¸¦ç»“果。
     * æŠ¥å‘Šçº§åˆ¤å®šé»˜è®¤å–「全部检验项合格」,有 report ä½œç”¨åŸŸè§„则时以规则为准。
     * <p>
     * ä¸ä¿®æ”¹ä¼ å…¥çš„上下文,判定后的结果放在返回值里。
     */
    public static EvaluationOutcome evaluate(ReportContext context, List<QualityRuleDefinition> rules) {
        List<String> errors = new ArrayList<>();
        List<QualityRuleDefinition> itemRules = new ArrayList<>();
        List<QualityRuleDefinition> reportRules = new ArrayList<>();
        for (QualityRuleDefinition rule : rules == null ? List.<QualityRuleDefinition>of() : rules) {
            if (rule == null || !rule.active()) {
                continue;
            }
            if (rule.scope() == RuleScope.REPORT) {
                reportRules.add(rule);
            } else {
                itemRules.add(rule);
            }
        }
        List<InspectionItem> source = context.getInspectionItems() == null ? List.of() : context.getInspectionItems();
        List<InspectionItem> items = new ArrayList<>(source.size());
        for (InspectionItem item : source) {
            items.add(item.copy());
        }
        // æ— åˆ¤å®šä¾æ®çš„项数,只用来把这类项从合格率分母与报告结论里摘出去
        int undecidableCount = 0;
        for (int index = 0; index < items.size(); index++) {
            InspectionItem item = items.get(index);
            if (!itemRules.isEmpty()) {
                // é€é¡¹è§„则在「单项上下文」里求值:item æŒ‡å‘当前行,index ä»Ž 1 å¼€å§‹ä¸ŽæŠ¥å‘Šåºå·ä¸€è‡´
                Map<String, Object> scoped = context.toScope();
                scoped.put("item", ReportContext.itemMap(item));
                scoped.put("index", index + 1);
                boolean passed = true;
                boolean failed = false;
                for (QualityRuleDefinition rule : itemRules) {
                    try {
                        if (!RuleEngine.test(rule.expression(), scoped)) {
                            passed = false;
                        }
                    } catch (RuntimeException error) {
                        failed = true;
                        errors.add("第 " + (index + 1) + " é¡¹ã€Œ" + orEmpty(item.getItemName()) + "」的规则「"
                                + rule.rawLabel() + "」无法执行:" + error.getMessage());
                    }
                }
                item.applyResult(failed ? null : (passed ? QualityResult.PASS : QualityResult.FAIL));
                continue;
            }
            QualityResult byLimit = evaluateByLimit(item);
            if (byLimit != null) {
                item.applyResult(byLimit);
                continue;
            }
            QualityResult existing = QualityResult.of(item.getResult());
            if (existing == null) {
                // æ—¢æ²¡æœ‰è§„则、也没有判定依据:如实标出来,不要默认合格
                item.applyUndecidable();
                undecidableCount++;
                errors.add("第 " + (index + 1) + " é¡¹ã€Œ"
                        + (isBlank(item.getItemName()) ? "未命名" : item.getItemName())
                        + "」没有规格上下限也没有判定规则,无法判定");
                continue;
            }
            item.applyResult(existing);
        }
        int passCount = 0;
        int failCount = 0;
        for (InspectionItem item : items) {
            if (QualityResult.PASS.name().equals(item.getResult())) {
                passCount++;
            } else if (QualityResult.FAIL.name().equals(item.getResult())) {
                failCount++;
            }
        }
        int total = items.size();
        // åˆæ ¼çŽ‡çš„åˆ†æ¯åªç®—ã€Œèƒ½åˆ¤å®šã€çš„é¡¹ï¼Œæ— åˆ¤å®šè§„åˆ™çš„é¡¹æ—¢ä¸åŠ åˆ†ä¹Ÿä¸å‡åˆ†
        int decidableCount = total - undecidableCount;
        QualityResult verdict = null;
        if (!reportRules.isEmpty()) {
            Map<String, Object> scoped = context.scope(items);
            scoped.put("index", 0);
            boolean passed = true;
            boolean failed = false;
            for (QualityRuleDefinition rule : reportRules) {
                try {
                    if (!RuleEngine.test(rule.expression(), scoped)) {
                        passed = false;
                    }
                } catch (RuntimeException error) {
                    failed = true;
                    errors.add("报告级规则「" + rule.rawLabel() + "」无法执行:" + error.getMessage());
                }
            }
            verdict = failed ? null : (passed ? QualityResult.PASS : QualityResult.FAIL);
        } else if (decidableCount > 0) {
            // åªæœ‰ã€Œå…¨éƒ¨å¯åˆ¤å®šé¡¹éƒ½åˆæ ¼ã€æ‰ç»™åˆæ ¼ç»“论。无判定规则的项不参与,它们没有对错可言。
            // å­˜åœ¨å¾…判定项(规则跑挂)时不给结论:没验过不能说合格,没证据也不能说不合格,
            // å¦åˆ™ä¼šå‡ºçŽ°ã€Œç»“è®ºåˆæ ¼ã€åˆæ ¼çŽ‡ 0%」这种自相矛盾的报告。
            if (failCount > 0) {
                verdict = QualityResult.FAIL;
            } else if (passCount == decidableCount) {
                verdict = QualityResult.PASS;
            }
        }
        ReportFields report = context.getReport().copy()
                .setTotal(total)
                .setPassCount(passCount)
                .setFailCount(failCount)
                .setPassRate(formatPassRate(passCount, decidableCount))
                .applyResult(verdict);
        return new EvaluationOutcome(new ReportContext().setReport(report).setInspectionItems(items), errors);
    }
    /** ç”¨è§„格上下限判定单项:实测值不在区间内即不合格 */
    private static QualityResult evaluateByLimit(InspectionItem item) {
        double actual = Paths.toNumber(item.getActualValue());
        if (!Double.isFinite(actual)) {
            return null;
        }
        boolean hasUpper = item.getUpperLimit() != null && Double.isFinite(item.getUpperLimit());
        boolean hasLower = item.getLowerLimit() != null && Double.isFinite(item.getLowerLimit());
        if (!hasUpper && !hasLower) {
            return null;
        }
        if (hasUpper && actual > item.getUpperLimit()) {
            return QualityResult.FAIL;
        }
        if (hasLower && actual < item.getLowerLimit()) {
            return QualityResult.FAIL;
        }
        return QualityResult.PASS;
    }
    private static String formatPassRate(int passCount, int total) {
        if (total == 0) {
            return "";
        }
        return String.format(Locale.ROOT, "%.2f%%", (double) passCount / total * 100);
    }
    private static String orEmpty(String value) {
        return value == null ? "" : value;
    }
    private static boolean isBlank(String value) {
        return value == null || value.isEmpty();
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/rule/QualityRuleDefinition.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,79 @@
package cn.iocoder.yudao.module.qcreport.engine.rule;
import java.util.Map;
/**
 * åˆ¤å®šè§„则定义。
 * <p>
 * ä¸Žå‰ç«¯ {@code engine/report-evaluator.ts} çš„ QualityRuleDefinition ä¸€ä¸€å¯¹åº”,
 * ä¹Ÿå°±æ˜¯æ¨¡æ¿ Schema çš„ {@code rules} æ•°ç»„里每一项的契约:
 * {@code { id, name, scope: 'item' | 'report', expression, enabled }}。
 *
 * @param id         è§„则标识,用于出错时定位
 * @param name       è§„则名称
 * @param scope      ä½œç”¨åŸŸï¼šé€é¡¹åˆ¤å®š / æŠ¥å‘Šçº§æ±‡æ€»
 * @param expression åˆ¤å®šè¡¨è¾¾å¼ï¼Œæ±‚值为真视为合格
 * @param enabled    æ˜¯å¦å‚与判定,null ç­‰åŒ true
 */
public record QualityRuleDefinition(String id, String name, RuleScope scope,
                                    String expression, Boolean enabled) {
    /** æ˜¯å¦å‚与判定,与前端 {@code rule.enabled !== false} åŒè¯­ä¹‰ */
    public boolean active() {
        return enabled == null || enabled;
    }
    /** å‡ºé”™æç¤ºé‡Œç”¨çš„原始名,两者都缺时为空串(与前端 {@code rule.name ?? rule.id ?? ''} ä¸€è‡´ï¼‰ */
    public String rawLabel() {
        if (name != null) {
            return name;
        }
        return id == null ? "" : id;
    }
    /** å‡ºé”™æç¤ºé‡Œç”¨çš„规则名 */
    public String label() {
        String trimmed = name == null ? "" : name.trim();
        if (!trimmed.isEmpty()) {
            return trimmed;
        }
        return id == null || id.isEmpty() ? "未命名规则" : id;
    }
    /**
     * ä»Ž Schema çš„ rules é¡¹è§£æžï¼Œç¼ºå­—段的按默认值兜底。
     * <p>
     * åªåšå–值不做校验:Schema æ˜¯è®¾è®¡å™¨å­˜çš„,格式问题会在执行时报出来,
     * è¯»çš„æ—¶å€™å†æŠ›ä¸€æ¬¡å¼‚常只会把「一处坏数据」放大成「整份模板打不开」。
     */
    public static QualityRuleDefinition from(Map<?, ?> raw) {
        if (raw == null) {
            return null;
        }
        return new QualityRuleDefinition(
                text(raw.get("id")),
                text(raw.get("name")),
                RuleScope.of(text(raw.get("scope"))),
                text(raw.get("expression")),
                flag(raw.get("enabled")));
    }
    private static String text(Object value) {
        return value == null ? null : String.valueOf(value);
    }
    /** åªæŠŠæ˜Žç¡®çš„ false / "false" / 0 å½“作停用,缺省视为启用 */
    private static Boolean flag(Object value) {
        if (value instanceof Boolean bool) {
            return bool;
        }
        if (value instanceof Number number) {
            return number.intValue() != 0;
        }
        if (value instanceof String text) {
            return !"false".equalsIgnoreCase(text.trim());
        }
        return null;
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/rule/RuleEngine.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,101 @@
package cn.iocoder.yudao.module.qcreport.engine.rule;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
 * åˆ¤å®šè§„则引擎入口。
 * <p>
 * è§„则文本来自用户,全程只用自建的词法/语法分析与白名单函数求值,
 * ä¸ç¢° eval、不碰脚本引擎——这是硬约束,不是风格偏好。
 * <p>
 * ä¸Žå‰ç«¯ {@code engine/rule-engine.ts} ä¸€ä¸€å¯¹åº”,同一份规则两端必须算出同一结果。
 */
public final class RuleEngine {
    /** å·²è§£æžçš„语法树缓存:同一条规则要在多行检验项上重复求值,不必反复解析 */
    private static final Map<String, RuleNode> CACHE = new ConcurrentHashMap<>();
    /**
     * ç¼“存条数上限。
     * <p>
     * è§„则来自保存下来的模板,条数天然有限;但校验接口会被反复调用,
     * æ²¡æœ‰ä¸Šé™çš„静态缓存等于把用户输入攒在堆里,这里到量就不再往里放。
     */
    private static final int MAX_CACHE_SIZE = 512;
    private RuleEngine() {
    }
    /** è§£æžè§„则文本,语法错误抛 {@link RuleSyntaxException} */
    public static RuleNode parse(String expression) {
        return RuleParser.parse(expression);
    }
    /** è§£æžå¹¶ç¼“存,供渲染时反复求值使用 */
    public static RuleNode compiled(String expression) {
        if (expression == null) {
            throw new RuleSyntaxException("规则表达式为空", 0);
        }
        RuleNode cached = CACHE.get(expression);
        if (cached != null) {
            return cached;
        }
        RuleNode node = RuleParser.parse(expression);
        if (CACHE.size() < MAX_CACHE_SIZE) {
            CACHE.put(expression, node);
        }
        return node;
    }
    /**
     * æ ¡éªŒè§„则文本的语法,返回错误清单,空列表表示通过。
     * <p>
     * è¿™é‡Œ<b>只查语法</b>;函数白名单交给 {@link RuleEvaluator#findUnknownFunction},
     * ç”± {@link cn.iocoder.yudao.module.qcreport.engine.QualityReportEngine#validateRules}
     * æŠŠä¸¤è€…拼成完整的保存前校验。
     * å‰ç«¯æŠŠè¿™ä¸¤æ­¥åˆåœ¨äº† {@code validateRule} ä¸€ä¸ªå‡½æ•°é‡Œï¼Œä½†æŠ¥é”™æ–‡æ¡ˆä¸¤ç«¯é€å­—一致——
     * å£å¾„不一致会变成「设计器能存、后端存不了」这种互相打架的场面。
     */
    public static List<String> validate(QualityRuleDefinition rule) {
        List<String> errors = new ArrayList<>();
        String label = rule.label();
        String expression = rule.expression();
        if (expression == null || expression.trim().isEmpty()) {
            errors.add("规则「" + label + "」的表达式为空");
            return errors;
        }
        try {
            RuleParser.parse(expression);
        } catch (RuleSyntaxException error) {
            errors.add("规则「" + label + "」语法错误:" + error.getMessage()
                    + "(第 " + (error.position() + 1) + " ä¸ªå­—符)");
        } catch (RuntimeException error) {
            errors.add("规则「" + label + "」无法解析:" + error.getMessage());
        }
        return errors;
    }
    /** æ±‚值,返回原始结果(可能是数字、字符串、布尔) */
    public static Object evaluate(String expression, Object context) {
        return RuleEvaluator.evaluateNode(parse(expression), context);
    }
    /** æ±‚值并转成布尔判定,用于「是否合格」这类条件 */
    public static boolean test(String expression, Object context) {
        return RuleEvaluator.evaluateCondition(parse(expression), context);
    }
    /** é™æ€æ£€æŸ¥è¡¨è¾¾å¼é‡Œè°ƒç”¨çš„函数是否在白名单内,不在则返回函数名 */
    public static String findUnknownFunction(String expression) {
        return RuleEvaluator.findUnknownFunction(parse(expression));
    }
    /** è¡¨è¾¾å¼ä¾èµ–的上下文路径,渲染前可据此检查数据是否齐备 */
    public static List<String> listPaths(String expression) {
        return RuleEvaluator.listPaths(parse(expression));
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/rule/RuleEvaluator.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,415 @@
package cn.iocoder.yudao.module.qcreport.engine.rule;
import cn.iocoder.yudao.module.qcreport.engine.JsValues;
import cn.iocoder.yudao.module.qcreport.engine.Paths;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
 * è§„则求值。
 * <p>
 * åªè®¤ {@link RuleNode} ä¸Žç™½åå•函数,规则文本里的任何内容都不会变成可执行代码。
 * ä¸Žå‰ç«¯ {@code engine/rule-engine.ts} çš„æ±‚值段一一对应,两边结果必须一致。
 */
public final class RuleEvaluator {
    /** åˆ¤å®šç»“果类取值统一成 PASS / FAIL:兼容中文、布尔与数字 1/0 */
    private static final Set<String> PASS_TOKENS = Set.of("PASS", "TRUE", "合格", "OK", "1", "是");
    private static final Set<String> FAIL_TOKENS = Set.of("FAIL", "FALSE", "不合格", "NG", "0", "否");
    private RuleEvaluator() {
    }
    /* ------------------------------ åŸºç¡€åˆ¤å®š ------------------------------ */
    /**
     * JS {@code String(value)} çš„等价实现。
     * <p>
     * è§„则里的文本比较与拼接都要走这里,否则两端会算出不同结果。
     */
    public static String asText(Object value) {
        return JsValues.asText(value);
    }
    /** ç©ºå€¼ï¼šnull æˆ–空串(空数组不算空) */
    public static boolean isBlank(Object value) {
        return value == null || "".equals(value);
    }
    /** çœŸå€¼åˆ¤å®šï¼Œä¸Ž JS ç‰ˆ truthy åŒè¯­ä¹‰ */
    public static boolean truthy(Object value) {
        if (value instanceof List<?> list) {
            return !list.isEmpty();
        }
        if (isBlank(value)) {
            return false;
        }
        if (value instanceof Boolean bool) {
            return bool;
        }
        double numeric = Paths.toNumber(value);
        if (Double.isFinite(numeric)) {
            return numeric != 0;
        }
        return true;
    }
    /** ä¸¤ä¾§éƒ½èƒ½å½“数字时按数值比较,否则按字符串比较 */
    public static int compare(Object left, Object right) {
        double leftNumber = Paths.toNumber(left);
        double rightNumber = Paths.toNumber(right);
        if (Double.isFinite(leftNumber) && Double.isFinite(rightNumber)) {
            return Double.compare(leftNumber, rightNumber);
        }
        return asText(left).compareTo(asText(right));
    }
    /** åˆ¤å®šç»“果类取值统一成 PASS / FAIL,识别不出返回空串 */
    public static String normalizeResultToken(Object value) {
        if (value instanceof Boolean bool) {
            return bool ? "PASS" : "FAIL";
        }
        String text = asText(value).trim().toUpperCase(Locale.ROOT);
        if (PASS_TOKENS.contains(text)) {
            return "PASS";
        }
        return FAIL_TOKENS.contains(text) ? "FAIL" : "";
    }
    /* ------------------------------ ç™½åå•函数 ------------------------------ */
    /** ç™½åå•函数表,规则文本只能调用这里面的函数 */
    private static final Map<String, RuleFunction> FUNCTIONS = buildFunctions();
    private static Map<String, RuleFunction> buildFunctions() {
        Map<String, RuleFunction> functions = new LinkedHashMap<>();
        functions.put("ABS", args -> Math.abs(requireNumber("ABS", arg(args, 0))));
        functions.put("AVG", args -> mean(requireNumbers("AVG", arg(args, 0))));
        functions.put("COUNT", args -> arg(args, 0) instanceof List<?> list
                ? list.size()
                : Paths.toNumberArray(arg(args, 0)).size());
        // è¿‡ç¨‹èƒ½åŠ›æŒ‡æ•° CP=(USL-LSL)/(6σ)
        functions.put("CP", args -> {
            List<Double> values = requireNumbers("CP", arg(args, 0));
            double deviation = standardDeviation(values);
            if (!Double.isFinite(deviation) || deviation == 0) {
                return Double.NaN;
            }
            return (requireNumber("CP", arg(args, 1)) - requireNumber("CP", arg(args, 2))) / (6 * deviation);
        });
        // è¿‡ç¨‹èƒ½åŠ›æŒ‡æ•° CPK=min(USL-μ, Î¼-LSL)/(3σ)
        functions.put("CPK", args -> {
            List<Double> values = requireNumbers("CPK", arg(args, 0));
            double deviation = standardDeviation(values);
            if (!Double.isFinite(deviation) || deviation == 0) {
                return Double.NaN;
            }
            double upper = requireNumber("CPK", arg(args, 1));
            double lower = requireNumber("CPK", arg(args, 2));
            double average = mean(values);
            return Math.min(upper - average, average - lower) / (3 * deviation);
        });
        // ä¸åˆæ ¼çŽ‡ï¼ˆç™¾åˆ†æ¯”ï¼‰ï¼Œå£å¾„ä¸Ž PASS_RATE ä¸€è‡´
        functions.put("FAIL_RATE", args -> rate(arg(args, 0), "FAIL"));
        functions.put("MAX", args -> extreme(requireNumbers("MAX", arg(args, 0)), true));
        functions.put("MIN", args -> extreme(requireNumbers("MIN", arg(args, 0)), false));
        // åˆæ ¼çŽ‡ï¼ˆç™¾åˆ†æ¯”ï¼Œä¿ç•™ä¸¤ä½ï¼‰
        functions.put("PASS_RATE", args -> rate(arg(args, 0), "PASS"));
        functions.put("ROUND", args -> {
            double digits = args.size() > 1 ? requireNumber("ROUND", arg(args, 1)) : 0;
            double factor = Math.pow(10, digits);
            return Math.round(requireNumber("ROUND", arg(args, 0)) * factor) / factor;
        });
        functions.put("STDDEV", args -> standardDeviation(requireNumbers("STDDEV", arg(args, 0))));
        functions.put("SUM", args -> {
            double sum = 0;
            for (Double value : requireNumbers("SUM", arg(args, 0))) {
                sum += value;
            }
            return sum;
        });
        // ä¸èƒ½æ¢æˆ Map.copyOf:它不保证遍历顺序,报错里列出的可用函数会随机排
        return Collections.unmodifiableMap(functions);
    }
    /**
     * å–第 index ä¸ªå®žå‚,缺参返回 null。
     * <p>
     * å°‘了这个兜底,{@code SUM()} è¿™ç§å†™é”™çš„规则会直接抛 IndexOutOfBoundsException ç©¿é€åˆ°æŽ¥å£å±‚,
     * ç”¨æˆ·çœ‹åˆ°çš„æ˜¯ã€Œç³»ç»Ÿå¼‚常」而不是「这条规则写错了」。
     */
    private static Object arg(List<Object> args, int index) {
        return index < args.size() ? args.get(index) : null;
    }
    /** ç™½åå•函数名,顺序稳定,用于报错时列出可用函数 */
    public static List<String> functionNames() {
        return List.copyOf(FUNCTIONS.keySet());
    }
    /** ä¸€æ¬¡æ€§æ±‚值全部合格/不合格占比,口径与前端一致 */
    private static double rate(Object value, String expected) {
        List<Object> values = Paths.flatten(value);
        if (values.isEmpty()) {
            return Double.NaN;
        }
        int hit = 0;
        for (Object element : values) {
            if (expected.equals(normalizeResultToken(element))) {
                hit++;
            }
        }
        return toFixed2((double) hit / values.size() * 100);
    }
    /** JS toFixed(2) åŽå†å–回数值:保留两位的百分比 */
    private static double toFixed2(double value) {
        return Math.round(value * 100) / 100.0;
    }
    private static double extreme(List<Double> values, boolean max) {
        double result = values.get(0);
        for (Double value : values) {
            result = max ? Math.max(result, value) : Math.min(result, value);
        }
        return result;
    }
    private static double mean(List<Double> values) {
        double sum = 0;
        for (Double value : values) {
            sum += value;
        }
        return sum / values.size();
    }
    /** æ ·æœ¬æ ‡å‡†å·®ï¼ˆn-1),SPC è®¡ç®— CP/CPK ç”¨è¿™ä¸ªå£å¾„ */
    private static double standardDeviation(List<Double> values) {
        if (values.size() < 2) {
            return Double.NaN;
        }
        double average = mean(values);
        double variance = 0;
        for (Double value : values) {
            variance += Math.pow(value - average, 2);
        }
        return Math.sqrt(variance / (values.size() - 1));
    }
    private static List<Double> requireNumbers(String name, Object value) {
        List<Double> values = Paths.toNumberArray(value);
        if (values.isEmpty()) {
            throw new RuleRuntimeException("函数 " + name + " éœ€è¦æ•°å€¼åž‹å‚数,实际取到「" + asText(value) + "」");
        }
        return values;
    }
    private static double requireNumber(String name, Object value) {
        double numeric = Paths.toNumber(value);
        if (!Double.isFinite(numeric)) {
            throw new RuleRuntimeException("函数 " + name + " éœ€è¦æ•°å­—参数,实际取到「" + asText(value) + "」");
        }
        return numeric;
    }
    /* ------------------------------ æ±‚值 ------------------------------ */
    /** æ±‚值,返回原始结果(可能是数字、字符串、布尔) */
    public static Object evaluateNode(RuleNode node, Object context) {
        return switch (node) {
            case RuleNode.Literal literal -> literal.value();
            case RuleNode.Path path -> Paths.readPath(context, path.path());
            case RuleNode.Call call -> evaluateCall(call, context);
            case RuleNode.Unary unary -> evaluateUnary(unary, context);
            case RuleNode.Between between -> evaluateBetween(between, context);
            case RuleNode.In inNode -> evaluateIn(inNode, context);
            case RuleNode.Binary binary -> evaluateBinary(binary, context);
        };
    }
    /** æ±‚值并转成布尔判定,用于「是否合格」这类条件 */
    public static boolean evaluateCondition(RuleNode node, Object context) {
        return truthy(evaluateNode(node, context));
    }
    private static Object evaluateCall(RuleNode.Call call, Object context) {
        RuleFunction handler = FUNCTIONS.get(call.name());
        if (handler == null) {
            throw new RuleRuntimeException(
                    "不支持函数 " + call.name() + ",可用函数:" + String.join("、", FUNCTIONS.keySet()));
        }
        List<Object> args = new ArrayList<>(call.args().size());
        for (RuleNode arg : call.args()) {
            args.add(evaluateNode(arg, context));
        }
        return handler.apply(args);
    }
    private static Object evaluateUnary(RuleNode.Unary unary, Object context) {
        Object value = evaluateNode(unary.operand(), context);
        return "!".equals(unary.operator()) ? !truthy(value) : -Paths.toNumber(value);
    }
    private static Object evaluateBetween(RuleNode.Between between, Object context) {
        Object value = evaluateNode(between.value(), context);
        boolean hit = compare(value, evaluateNode(between.lower(), context)) >= 0
                && compare(value, evaluateNode(between.upper(), context)) <= 0;
        return between.negated() != hit;
    }
    private static Object evaluateIn(RuleNode.In inNode, Object context) {
        Object value = evaluateNode(inNode.value(), context);
        boolean hit = false;
        for (RuleNode item : inNode.items()) {
            if (compare(value, evaluateNode(item, context)) == 0) {
                hit = true;
                break;
            }
        }
        return inNode.negated() != hit;
    }
    private static Object evaluateBinary(RuleNode.Binary binary, Object context) {
        if ("AND".equals(binary.operator())) {
            return truthy(evaluateNode(binary.left(), context)) && truthy(evaluateNode(binary.right(), context));
        }
        if ("OR".equals(binary.operator())) {
            return truthy(evaluateNode(binary.left(), context)) || truthy(evaluateNode(binary.right(), context));
        }
        Object left = evaluateNode(binary.left(), context);
        Object right = evaluateNode(binary.right(), context);
        return switch (binary.operator()) {
            case "=" -> compare(left, right) == 0;
            case "!=" -> compare(left, right) != 0;
            case ">" -> compare(left, right) > 0;
            case ">=" -> compare(left, right) >= 0;
            case "<" -> compare(left, right) < 0;
            case "<=" -> compare(left, right) <= 0;
            case "+" -> add(left, right);
            case "-" -> Paths.toNumber(left) - Paths.toNumber(right);
            case "*" -> Paths.toNumber(left) * Paths.toNumber(right);
            case "/" -> divide(left, right);
            default -> throw new RuleRuntimeException("不支持的运算符 " + binary.operator());
        };
    }
    /** ä¸¤ä¾§éƒ½æ˜¯æ•°å€¼æ‰åšåŠ æ³•ï¼Œå¦åˆ™æŒ‰æ–‡æœ¬æ‹¼æŽ¥ï¼ˆç¼–å·ç±»å­—æ®µå¸¸ç”¨ï¼‰ */
    private static Object add(Object left, Object right) {
        double leftNumber = Paths.toNumber(left);
        double rightNumber = Paths.toNumber(right);
        if (Double.isFinite(leftNumber) && Double.isFinite(rightNumber)) {
            return leftNumber + rightNumber;
        }
        return asText(left) + asText(right);
    }
    private static Object divide(Object left, Object right) {
        double divisor = Paths.toNumber(right);
        if (divisor == 0) {
            throw new RuleRuntimeException("规则里出现了除以 0");
        }
        return Paths.toNumber(left) / divisor;
    }
    /* ------------------------------ é™æ€åˆ†æž ------------------------------ */
    /** è¡¨è¾¾å¼é‡Œè°ƒç”¨çš„函数名(含嵌套),用于保存前校验白名单 */
    public static List<String> listFunctionNames(RuleNode node) {
        List<String> names = new ArrayList<>();
        collectFunctionNames(node, names);
        return names;
    }
    private static void collectFunctionNames(RuleNode node, List<String> names) {
        switch (node) {
            case RuleNode.Call call -> {
                names.add(call.name());
                call.args().forEach(arg -> collectFunctionNames(arg, names));
            }
            case RuleNode.Unary unary -> collectFunctionNames(unary.operand(), names);
            case RuleNode.Binary binary -> {
                collectFunctionNames(binary.left(), names);
                collectFunctionNames(binary.right(), names);
            }
            case RuleNode.Between between -> {
                collectFunctionNames(between.value(), names);
                collectFunctionNames(between.lower(), names);
                collectFunctionNames(between.upper(), names);
            }
            case RuleNode.In inNode -> {
                collectFunctionNames(inNode.value(), names);
                inNode.items().forEach(item -> collectFunctionNames(item, names));
            }
            default -> {
            }
        }
    }
    /** æœªçŸ¥å‡½æ•°åè¿”回 null,供调用方给出可读报错 */
    public static String findUnknownFunction(RuleNode node) {
        for (String name : listFunctionNames(node)) {
            if (!FUNCTIONS.containsKey(name)) {
                return name;
            }
        }
        return null;
    }
    /**
     * è§£æžåŽçš„路径依赖,渲染前可据此检查数据是否齐备。
     * <p>
     * ç»Ÿè®¡å‡½æ•°ä½œç”¨åœ¨æ•°ç»„路径上,这里去掉末端字段只保留数组本身。
     */
    public static List<String> listPaths(RuleNode node) {
        Set<String> paths = new LinkedHashSet<>();
        collectPaths(node, paths);
        Set<String> normalized = new LinkedHashSet<>();
        for (String path : paths) {
            normalized.add(String.join(".", Paths.parsePath(path)));
        }
        return List.copyOf(normalized);
    }
    private static void collectPaths(RuleNode node, Set<String> paths) {
        switch (node) {
            case RuleNode.Path path -> {
                paths.add(path.path());
            }
            case RuleNode.Call call -> call.args().forEach(arg -> collectPaths(arg, paths));
            case RuleNode.Unary unary -> collectPaths(unary.operand(), paths);
            case RuleNode.Binary binary -> {
                collectPaths(binary.left(), paths);
                collectPaths(binary.right(), paths);
            }
            case RuleNode.Between between -> {
                collectPaths(between.value(), paths);
                collectPaths(between.lower(), paths);
                collectPaths(between.upper(), paths);
            }
            case RuleNode.In inNode -> {
                collectPaths(inNode.value(), paths);
                inNode.items().forEach(item -> collectPaths(item, paths));
            }
            default -> {
            }
        }
    }
    /** ç™½åå•函数 */
    @FunctionalInterface
    public interface RuleFunction {
        Object apply(List<Object> args);
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/rule/RuleNode.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,42 @@
package cn.iocoder.yudao.module.qcreport.engine.rule;
import java.util.List;
/**
 * è§„则语法树节点。
 * <p>
 * ä¸Žå‰ç«¯ {@code engine/rule-engine.ts} çš„ RuleNode è”合类型一一对应:
 * é€’归下降解析成这棵树后自己求值,全程不碰 eval / è„šæœ¬å¼•æ“Žâ€”â€”
 * è§„则文本来自用户,一旦能被当作代码执行就是注入口子。
 */
public sealed interface RuleNode {
    /** å­—面量:数字(Double)/ å­—符串 / å¸ƒå°” / null */
    record Literal(Object value) implements RuleNode {
    }
    /** ä¸Šä¸‹æ–‡è·¯å¾„取值,如 item.actualValue */
    record Path(String path) implements RuleNode {
    }
    /** ç™½åå•函数调用 */
    record Call(String name, List<RuleNode> args) implements RuleNode {
    }
    /** ä¸€å…ƒè¿ç®—:- æˆ– ! */
    record Unary(String operator, RuleNode operand) implements RuleNode {
    }
    /** äºŒå…ƒè¿ç®—:AND / OR / = / != / > / >= / < / <= / + / - / * / / */
    record Binary(String operator, RuleNode left, RuleNode right) implements RuleNode {
    }
    /** value BETWEEN lower AND upper */
    record Between(RuleNode value, RuleNode lower, RuleNode upper, boolean negated) implements RuleNode {
    }
    /** value IN (items) / value NOT IN (items) */
    record In(RuleNode value, List<RuleNode> items, boolean negated) implements RuleNode {
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/rule/RuleParser.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,372 @@
package cn.iocoder.yudao.module.qcreport.engine.rule;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
/**
 * è§„则文本 â†’ è¯­æ³•树。
 * <p>
 * ä¸Žå‰ç«¯ {@code engine/rule-engine.ts} çš„ tokenize + Parser ä¸€ä¸€å¯¹åº”,
 * æ”¯æŒçš„语法见 docs/智能质检报告平台-方案设计.md Â§13。
 */
public final class RuleParser {
    private static final Set<String> KEYWORDS = Set.of(
            "AND", "BETWEEN", "FALSE", "IN", "NOT", "NOT_IN", "NULL", "OR", "TRUE");
    private static final Set<String> TWO_CHAR_OPERATORS = Set.of("<=", ">=", "!=", "<>", "==");
    private static final String SINGLE_CHAR_OPERATORS = "><=+-*/!";
    private static final Pattern NUMBER_TEXT = Pattern.compile("^\\d+(?:\\.\\d+)?$");
    private static final Pattern DIGIT_TEXT = Pattern.compile("^\\d+$");
    private RuleParser() {
    }
    /** è§£æžè§„则文本,语法错误抛 {@link RuleSyntaxException} */
    public static RuleNode parse(String expression) {
        String trimmed = expression == null ? "" : expression.trim();
        if (trimmed.isEmpty()) {
            throw new RuleSyntaxException("规则表达式为空", 0);
        }
        return new Parser(tokenize(trimmed)).parse();
    }
    /* ------------------------------ è¯æ³•分析 ------------------------------ */
    private static List<RuleToken> tokenize(String source) {
        List<RuleToken> tokens = new ArrayList<>();
        int length = source.length();
        int index = 0;
        while (index < length) {
            char ch = source.charAt(index);
            if (Character.isWhitespace(ch)) {
                index++;
                continue;
            }
            if (ch == '\'' || ch == '"') {
                index = readString(source, index, ch, tokens);
                continue;
            }
            if (ch >= '0' && ch <= '9') {
                int cursor = index;
                while (cursor < length && isDigitOrDot(source.charAt(cursor))) {
                    cursor++;
                }
                String text = source.substring(index, cursor);
                if (!NUMBER_TEXT.matcher(text).matches()) {
                    throw new RuleSyntaxException("数字格式不正确:" + text, index);
                }
                tokens.add(new RuleToken(TokenType.NUMBER, text, index));
                index = cursor;
                continue;
            }
            if (isIdentStart(ch)) {
                index = readWord(source, index, tokens);
                continue;
            }
            if (index + 2 <= length && TWO_CHAR_OPERATORS.contains(source.substring(index, index + 2))) {
                tokens.add(new RuleToken(TokenType.OPERATOR, source.substring(index, index + 2), index));
                index += 2;
                continue;
            }
            if (ch == '(' || ch == ')') {
                tokens.add(new RuleToken(ch == '(' ? TokenType.LPAREN : TokenType.RPAREN, String.valueOf(ch), index));
                index++;
                continue;
            }
            if (ch == ',') {
                tokens.add(new RuleToken(TokenType.COMMA, String.valueOf(ch), index));
                index++;
                continue;
            }
            if (SINGLE_CHAR_OPERATORS.indexOf(ch) >= 0) {
                tokens.add(new RuleToken(TokenType.OPERATOR, String.valueOf(ch), index));
                index++;
                continue;
            }
            throw new RuleSyntaxException("无法识别的字符「" + ch + "」", index);
        }
        tokens.add(new RuleToken(TokenType.EOF, "", length));
        return tokens;
    }
    /** è¯»ä¸€ä¸ªå­—符串字面量,返回结束后的下标 */
    private static int readString(String source, int start, char quote, List<RuleToken> tokens) {
        int length = source.length();
        StringBuilder value = new StringBuilder();
        int cursor = start + 1;
        while (cursor < length && source.charAt(cursor) != quote) {
            if (source.charAt(cursor) == '\\' && cursor + 1 < length) {
                value.append(source.charAt(cursor + 1));
                cursor += 2;
                continue;
            }
            value.append(source.charAt(cursor));
            cursor++;
        }
        if (cursor >= length) {
            throw new RuleSyntaxException("字符串缺少结尾引号", start);
        }
        tokens.add(new RuleToken(TokenType.STRING, value.toString(), start));
        return cursor + 1;
    }
    /**
     * è¯»ä¸€ä¸ªæ ‡è¯†ç¬¦/路径,返回结束后的下标。
     * <p>
     * è·¯å¾„尾巴(.属性 ä¸Ž [下标])在词法阶段就并进同一个词,取值时整条路径一次解析。
     */
    private static int readWord(String source, int start, List<RuleToken> tokens) {
        int length = source.length();
        int cursor = start;
        while (cursor < length && isIdentPart(source.charAt(cursor))) {
            cursor++;
        }
        StringBuilder value = new StringBuilder(source.substring(start, cursor));
        while (cursor < length) {
            char next = source.charAt(cursor);
            if (next == '.' && cursor + 1 < length && isIdentStart(source.charAt(cursor + 1))) {
                int end = cursor + 1;
                while (end < length && isIdentPart(source.charAt(end))) {
                    end++;
                }
                value.append(source, cursor, end);
                cursor = end;
                continue;
            }
            if (next == '[') {
                int close = source.indexOf(']', cursor);
                if (close == -1) {
                    throw new RuleSyntaxException("数组下标缺少 ]", cursor);
                }
                String inner = source.substring(cursor + 1, close).trim();
                if (!DIGIT_TEXT.matcher(inner).matches()) {
                    throw new RuleSyntaxException("数组下标只能是数字:" + inner, cursor);
                }
                value.append('[').append(inner).append(']');
                cursor = close + 1;
                continue;
            }
            break;
        }
        tokens.add(new RuleToken(TokenType.WORD, value.toString(), start));
        return cursor;
    }
    private static boolean isIdentStart(char ch) {
        return ch >= 'A' && ch <= 'Z' || ch >= 'a' && ch <= 'z' || ch == '_' || ch == '$';
    }
    private static boolean isIdentPart(char ch) {
        return isIdentStart(ch) || ch >= '0' && ch <= '9';
    }
    private static boolean isDigitOrDot(char ch) {
        return ch >= '0' && ch <= '9' || ch == '.';
    }
    /* ------------------------------ è¯­æ³•分析 ------------------------------ */
    private static final class Parser {
        private final List<RuleToken> tokens;
        private int cursor;
        private Parser(List<RuleToken> tokens) {
            this.tokens = tokens;
        }
        private RuleNode parse() {
            RuleNode node = parseOr();
            RuleToken token = peek();
            if (token.type() != TokenType.EOF) {
                throw new RuleSyntaxException("多余的内容:" + token.value(), token.position());
            }
            return node;
        }
        private RuleNode parseOr() {
            RuleNode left = parseAnd();
            while (matchWord("OR")) {
                left = new RuleNode.Binary("OR", left, parseAnd());
            }
            return left;
        }
        private RuleNode parseAnd() {
            RuleNode left = parseComparison();
            while (matchWord("AND")) {
                left = new RuleNode.Binary("AND", left, parseComparison());
            }
            return left;
        }
        private RuleNode parseComparison() {
            RuleNode left = parseAdditive();
            if (matchWord("BETWEEN")) {
                RuleNode lower = parseAdditive();
                if (!matchWord("AND")) {
                    throw new RuleSyntaxException("BETWEEN ç¼ºå°‘ AND ä¸Žä¸Šç•Œ", peek().position());
                }
                RuleNode upper = parseAdditive();
                return new RuleNode.Between(left, lower, upper, false);
            }
            if (matchWord("NOT")) {
                if (matchWord("IN") || matchWord("NOT_IN")) {
                    return new RuleNode.In(left, parseList(), true);
                }
                throw new RuleSyntaxException("NOT åªèƒ½ç”¨äºŽ NOT IN", peek().position());
            }
            if (matchWord("IN")) {
                return new RuleNode.In(left, parseList(), false);
            }
            RuleToken token = peek();
            if (token.type() == TokenType.OPERATOR
                    && "< <= = == != <> > >=".contains(token.value())) {
                cursor++;
                return new RuleNode.Binary(normalizeOperator(token.value()), left, parseAdditive());
            }
            return left;
        }
        /** == ç»Ÿä¸€æˆ =、<> ç»Ÿä¸€æˆ !=,求值阶段只认一种写法 */
        private static String normalizeOperator(String operator) {
            if ("==".equals(operator)) {
                return "=";
            }
            return "<>".equals(operator) ? "!=" : operator;
        }
        private List<RuleNode> parseList() {
            expect(TokenType.LPAREN);
            List<RuleNode> items = new ArrayList<>();
            if (peek().type() != TokenType.RPAREN) {
                items.add(parseOr());
                while (peek().type() == TokenType.COMMA) {
                    cursor++;
                    items.add(parseOr());
                }
            }
            expect(TokenType.RPAREN);
            return items;
        }
        private RuleNode parseAdditive() {
            RuleNode left = parseMultiplicative();
            for (; ; ) {
                RuleToken token = peek();
                if (token.type() != TokenType.OPERATOR || !"+".equals(token.value()) && !"-".equals(token.value())) {
                    return left;
                }
                cursor++;
                left = new RuleNode.Binary(token.value(), left, parseMultiplicative());
            }
        }
        private RuleNode parseMultiplicative() {
            RuleNode left = parseUnary();
            for (; ; ) {
                RuleToken token = peek();
                if (token.type() != TokenType.OPERATOR || !"*".equals(token.value()) && !"/".equals(token.value())) {
                    return left;
                }
                cursor++;
                left = new RuleNode.Binary(token.value(), left, parseUnary());
            }
        }
        private RuleNode parseUnary() {
            RuleToken token = peek();
            if (token.type() == TokenType.OPERATOR && ("-".equals(token.value()) || "!".equals(token.value()))) {
                cursor++;
                return new RuleNode.Unary(token.value(), parseUnary());
            }
            return parsePrimary();
        }
        private RuleNode parsePrimary() {
            RuleToken token = peek();
            if (token.type() == TokenType.NUMBER) {
                cursor++;
                return new RuleNode.Literal(Double.valueOf(token.value()));
            }
            if (token.type() == TokenType.STRING) {
                cursor++;
                return new RuleNode.Literal(token.value());
            }
            if (token.type() == TokenType.LPAREN) {
                cursor++;
                RuleNode node = parseOr();
                expect(TokenType.RPAREN);
                return node;
            }
            if (token.type() == TokenType.WORD) {
                cursor++;
                String upper = token.value().toUpperCase();
                if ("TRUE".equals(upper)) {
                    return new RuleNode.Literal(Boolean.TRUE);
                }
                if ("FALSE".equals(upper)) {
                    return new RuleNode.Literal(Boolean.FALSE);
                }
                if ("NULL".equals(upper)) {
                    return new RuleNode.Literal(null);
                }
                if (peek().type() == TokenType.LPAREN) {
                    return new RuleNode.Call(upper, parseList());
                }
                if (KEYWORDS.contains(upper)) {
                    throw new RuleSyntaxException("关键字 " + upper + " çš„位置不正确", token.position());
                }
                return new RuleNode.Path(token.value());
            }
            throw new RuleSyntaxException(
                    token.type() == TokenType.EOF ? "表达式不完整" : "无法解析的内容:" + token.value(),
                    token.position());
        }
        private RuleToken peek() {
            return tokens.get(cursor);
        }
        private boolean matchWord(String word) {
            RuleToken token = peek();
            if (token.type() == TokenType.WORD && token.value().toUpperCase().equals(word)) {
                cursor++;
                return true;
            }
            return false;
        }
        private void expect(TokenType type) {
            RuleToken token = peek();
            if (token.type() != type) {
                throw new RuleSyntaxException(
                        "应该是 " + type.label() + ",实际是「" + token.value() + "」", token.position());
            }
            cursor++;
        }
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/rule/RuleRuntimeException.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
package cn.iocoder.yudao.module.qcreport.engine.rule;
/**
 * è§„则运行期错误:函数不在白名单、参数类型不对、除以 0 ç­‰ã€‚
 */
public class RuleRuntimeException extends RuntimeException {
    public RuleRuntimeException(String message) {
        super(message);
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/rule/RuleScope.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,28 @@
package cn.iocoder.yudao.module.qcreport.engine.rule;
/**
 * è§„则作用域。
 */
public enum RuleScope {
    /** é€é¡¹åˆ¤å®šï¼šå¯¹æ¯ä¸€è¡Œæ£€éªŒé¡¹åˆ†åˆ«æ±‚值 */
    ITEM("item"),
    /** æŠ¥å‘Šçº§æ±‡æ€»åˆ¤å®š */
    REPORT("report");
    private final String label;
    RuleScope(String label) {
        this.label = label;
    }
    public String label() {
        return label;
    }
    /** è½åº“/前端取值 â†’ æžšä¸¾ï¼Œè¯†åˆ«ä¸å‡ºæŒ‰é€é¡¹å¤„理 */
    public static RuleScope of(String value) {
        return REPORT.label.equalsIgnoreCase(value == null ? "" : value.trim()) ? REPORT : ITEM;
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/rule/RuleSyntaxException.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,20 @@
package cn.iocoder.yudao.module.qcreport.engine.rule;
/**
 * è§„则文本存在语法错误。
 */
public class RuleSyntaxException extends RuntimeException {
    /** å‡ºé”™ä½ç½®åœ¨è§„则文本中的下标(从 0 å¼€å§‹ï¼‰ */
    private final transient int position;
    public RuleSyntaxException(String message, int position) {
        super(message);
        this.position = position;
    }
    public int position() {
        return position;
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/rule/RuleToken.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,11 @@
package cn.iocoder.yudao.module.qcreport.engine.rule;
/**
 * è¯æ³•单元。
 *
 * @param type     ç±»åž‹
 * @param value    åŽŸå§‹æ–‡æœ¬ï¼ˆå­—ç¬¦ä¸²å·²åŽ»æŽ‰å¼•å·å¹¶è§£è½¬ä¹‰ï¼‰
 * @param position åœ¨è§„则文本中的起始下标,出错时用于定位
 */
public record RuleToken(TokenType type, String value, int position) {
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/engine/rule/TokenType.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,30 @@
package cn.iocoder.yudao.module.qcreport.engine.rule;
/**
 * è¯æ³•单元类型。
 * <p>
 * label ä¿ç•™å‰ç«¯ {@code engine/rule-engine.ts} çš„小写写法,
 * è¯­æ³•错误提示要在两端逐字一致,用户才不会对同一份规则看到两种说法。
 */
public enum TokenType {
    COMMA("comma"),
    EOF("eof"),
    LPAREN("lparen"),
    NUMBER("number"),
    OPERATOR("operator"),
    RPAREN("rparen"),
    STRING("string"),
    WORD("word");
    private final String label;
    TokenType(String label) {
        this.label = label;
    }
    public String label() {
        return label;
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/enums/ErrorCodeConstants.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,117 @@
package cn.iocoder.yudao.module.qcreport.enums;
import cn.iocoder.yudao.framework.common.exception.ErrorCode;
/**
 * qcreport æ™ºèƒ½è´¨æ£€æŠ¥å‘Šè®¾è®¡å¹³å° é”™è¯¯ç æžšä¸¾ç±»
 * <p>
 * qcreport ç³»ç»Ÿï¼Œä½¿ç”¨ 1-070-000-000 æ®µ
 */
public interface ErrorCodeConstants {
    // ========== æŠ¥å‘Šæ¨¡æ¿ï¼ˆ1-070-100-000) ==========
    ErrorCode TEMPLATE_NOT_EXISTS = new ErrorCode(1_070_100_000, "报告模板不存在");
    ErrorCode TEMPLATE_CODE_DUPLICATE = new ErrorCode(1_070_100_001, "报告模板编码已存在");
    ErrorCode TEMPLATE_STATUS_DISABLED = new ErrorCode(1_070_100_002, "报告模板已停用,无法执行该操作");
    // ========== æ¨¡æ¿ç‰ˆæœ¬ï¼ˆ1-070-101-000) ==========
    ErrorCode TEMPLATE_VERSION_NOT_EXISTS = new ErrorCode(1_070_101_000, "模板版本不存在");
    ErrorCode TEMPLATE_VERSION_DUPLICATE = new ErrorCode(1_070_101_001, "该版本号已存在,请使用新的版本号");
    ErrorCode TEMPLATE_VERSION_PUBLISHED_CANNOT_MODIFY = new ErrorCode(1_070_101_002, "模板版本已发布,不允许修改,请创建新版本");
    ErrorCode TEMPLATE_VERSION_NOT_PUBLISHED = new ErrorCode(1_070_101_003, "模板版本未发布,无法生成报告");
    ErrorCode TEMPLATE_VERSION_SCHEMA_INVALID = new ErrorCode(1_070_101_004, "模板 Schema æ ¼å¼ä¸æ­£ç¡®ï¼Œè¯·æ£€æŸ¥è®¾è®¡å™¨ä¿å­˜çš„æ¨¡æ¿å†…容");
    ErrorCode TEMPLATE_VERSION_SCHEMA_EMPTY = new ErrorCode(1_070_101_005, "模板 Schema ä¸ºç©ºï¼Œè¯·å…ˆåœ¨è®¾è®¡å™¨ä¸­è®¾è®¡æ¨¡æ¿å†…容并保存,再发布版本");
    ErrorCode TEMPLATE_VERSION_RULE_INVALID = new ErrorCode(1_070_101_006, "模板里的判定规则不合法,请修改后重试");
    ErrorCode TEMPLATE_NO_PUBLISHED_VERSION = new ErrorCode(1_070_101_007, "该模板还没有已发布的版本,无法生成报告,请先在设计器中发布一个版本");
    ErrorCode TEMPLATE_VERSION_CANVAS_EMPTY = new ErrorCode(1_070_101_008, "该模板版本没有画布内容,无法生成报告,请先在设计器中设计并保存模板内容");
    ErrorCode TEMPLATE_VERSION_CANVAS_UNSAFE = new ErrorCode(1_070_101_009,
            "模板画布里有不允许出现的内容,已拒绝保存:{}。请在设计器中删掉这些内容后重新保存");
    ErrorCode TEMPLATE_VERSION_CURRENT_CANNOT_DISABLE = new ErrorCode(1_070_101_010,
            "版本「{}」是模板「{}」当前正在使用的版本,不能停用。"
                    + "请先到该模板的版本列表里对另一个已发布版本执行「回滚」,把当前版本切走,再停用本版本;"
                    + "若要让整个模板停止使用,请改为停用模板本身");
    // ========== æŠ¥å‘Šå®žä¾‹ï¼ˆ1-070-102-000) ==========
    ErrorCode REPORT_INSTANCE_NOT_EXISTS = new ErrorCode(1_070_102_000, "报告实例不存在");
    ErrorCode REPORT_INSTANCE_NO_DUPLICATE = new ErrorCode(1_070_102_001, "报告编号已存在");
    ErrorCode REPORT_INSTANCE_DATA_SNAPSHOT_MISSING = new ErrorCode(1_070_102_002, "报告实例缺少数据快照,无法重新渲染");
    ErrorCode REPORT_INSTANCE_HTML_MISSING = new ErrorCode(1_070_102_003, "报告实例没有渲染产物 HTML,无法生成 PDF,请先对该报告执行「重新生成」");
    // ========== æ¸²æŸ“与规则(1-070-103-000) ==========
    ErrorCode RENDER_BUSINESS_DATA_MISSING = new ErrorCode(1_070_103_000, "业务数据为空,无法渲染报告");
    ErrorCode RENDER_DATA_FIELD_NOT_EXISTS = new ErrorCode(1_070_103_001, "数据绑定字段不存在");
    ErrorCode RENDER_RULE_PARSE_FAILED = new ErrorCode(1_070_103_002, "判定规则解析失败");
    ErrorCode RENDER_HTML_FAILED = new ErrorCode(1_070_103_003, "报告 HTML æ¸²æŸ“失败");
    ErrorCode RENDER_PDF_BROWSER_LAUNCH_FAILED = new ErrorCode(1_070_103_004, "PDF æ¸²æŸ“浏览器启动失败,请检查 Chromium æ˜¯å¦å·²å®‰è£…");
    ErrorCode RENDER_PDF_FAILED = new ErrorCode(1_070_103_005, "PDF ç”Ÿæˆå¤±è´¥");
    ErrorCode RENDER_PDF_ARCHIVE_FAILED = new ErrorCode(1_070_103_006, "PDF å·²ç”Ÿæˆï¼Œä½†å½’档到附件库失败");
    ErrorCode RENDER_PDF_BUSY = new ErrorCode(1_070_103_007, "当前 PDF æ¸²æŸ“任务已达上限,请稍后重试");
    ErrorCode RENDER_PDF_DISABLED = new ErrorCode(1_070_103_008, "PDF å‡ºä»¶åŠŸèƒ½æœªå¯ç”¨ï¼Œè¯·è”ç³»ç®¡ç†å‘˜");
    // ========== AI å¯¼å…¥æ¨¡æ¿è‰ç¨¿ï¼ˆ1-070-104-000) ==========
    ErrorCode AI_IMPORT_DISABLED = new ErrorCode(1_070_104_000,
            "AI å¯¼å…¥åŠŸèƒ½æœªå¯ç”¨ï¼ˆyudao.qcreport.ai-import.enabled å½“前为 false),请联系管理员开启后再试");
    ErrorCode AI_IMPORT_BLOB_NOT_FOUND = new ErrorCode(1_070_104_001,
            "上传的文件(blobId={})不存在或已被清理,请重新上传后再试");
    ErrorCode AI_IMPORT_FILE_UNSUPPORTED = new ErrorCode(1_070_104_002,
            "不支持的文件「{}」(扩展名:{})。仅支持 PDF、Word(.docx)、Excel(.xlsx)、"
                    + "图片(png/jpg/jpeg/bmp/gif/webp) ä¸Žæ–‡æœ¬(txt/csv),请转换格式后重新导入");
    ErrorCode AI_IMPORT_FILE_LEGACY_OFFICE = new ErrorCode(1_070_104_003,
            "不支持的文件「{}」(旧版 Office æ ¼å¼.{})。请用 Office æ‰“开后「另存为」.docx æˆ– .xlsx å†é‡æ–°å¯¼å…¥");
    ErrorCode AI_IMPORT_FILE_TOO_LARGE = new ErrorCode(1_070_104_004,
            "文件「{}」大小 {}MB,超过单文件上限 {}MB,请压缩或拆分后重新导入");
    ErrorCode AI_IMPORT_FILE_EMPTY = new ErrorCode(1_070_104_005,
            "文件「{}」未解析出任何可用内容(共 {} é¡µï¼‰ã€‚若是扫描件,请确认分辨率不低于 200 DPI、"
                    + "文字无严重倾斜或遮挡;推荐改用电子版 PDF æˆ– Word/Excel");
    ErrorCode AI_IMPORT_TOO_MANY_FILES = new ErrorCode(1_070_104_006,
            "一次最多导入 {} ä¸ªæ–‡ä»¶ï¼Œæœ¬æ¬¡æäº¤äº† {} ä¸ªï¼Œè¯·åˆ†æ‰¹å¯¼å…¥");
    ErrorCode AI_IMPORT_TOO_MANY_PAGES = new ErrorCode(1_070_104_007,
            "文件「{}」共 {} é¡µï¼Œè¶…过单次识别上限 {} é¡µï¼Œè¯·æ‹†åˆ†åŽåˆ†æ‰¹å¯¼å…¥");
    ErrorCode AI_IMPORT_CATALOG_INVALID = new ErrorCode(1_070_104_008,
            "组件清单参数不合法({}),请刷新页面后重试");
    ErrorCode AI_IMPORT_SCHEMA_VERSION_UNSUPPORTED = new ErrorCode(1_070_104_009,
            "组件清单的 Schema ç‰ˆæœ¬ä¸ºã€Œ{}」,服务端只接受「{}」,请刷新页面后重试");
    ErrorCode AI_IMPORT_AI_UNAVAILABLE = new ErrorCode(1_070_104_010,
            "AI è¯†åˆ«è°ƒç”¨å¤±è´¥ï¼ˆå·²è€—æ—¶ {}ms):{}。请到「AI å¤§æ¨¡åž‹ã€ä¸­ç¡®è®¤å¯¹è¯æ¨¡åž‹å·²å¯ç”¨ä¸”密钥有效,或稍后重试");
    ErrorCode AI_IMPORT_AI_TIMEOUT = new ErrorCode(1_070_104_011,
            "AI è¯†åˆ«è¶…时(本次已等 {} ç§’)。文件页数或数量较多时耗时更长,"
                    + "请减少文件数量,或改用电子版 PDF / Word / Excel");
    ErrorCode AI_IMPORT_RESPONSE_UNPARSEABLE = new ErrorCode(1_070_104_012,
            "AI è¿”回的内容无法解析为模板草稿(已收到 {} ä¸ªå­—符)。若文件内容很长,请拆分后分批导入;"
                    + "若持续失败,请改用手工设计");
    ErrorCode AI_IMPORT_DRAFT_EMPTY = new ErrorCode(1_070_104_013,
            "AI æœªèƒ½ä»Žæ–‡ä»¶ã€Œ{}」中识别出任何可用组件。请确认该文件是检验报告;"
                    + "若确实是,请改用手工设计模板");
    ErrorCode AI_IMPORT_TOO_MANY_PAGES_TOTAL = new ErrorCode(1_070_104_014,
            "本次提交的文件合计 {} é¡µï¼Œè¶…过单次识别上限 {} é¡µï¼ˆå•文件上限 {} é¡µï¼‰ï¼Œ"
                    + "请减少文件数量或分批导入");
    ErrorCode AI_IMPORT_OFFICE_CORRUPT = new ErrorCode(1_070_104_015,
            "文件「{}」无法作为 .{} æ‰“开,内容已损坏或受密码保护。"
                    + "请先用 Office æ‰“开该文件,确认能正常显示后「另存为」.docx / .xlsx å†é‡æ–°å¯¼å…¥ï¼›"
                    + "若文件有打开密码,请先解除密码保护");
    ErrorCode AI_IMPORT_AI_BUDGET_EXCEEDED = new ErrorCode(1_070_104_016,
            "本次识别已达到单次导入耗时上限({} ç§’,已用时 {} ç§’)并提前停止,"
                    + "在文件「{}」中未识别出任何可用组件。请减少文件数量或页数后分批导入;"
                    + "若单个文件也不大,请到「AI å¤§æ¨¡åž‹ã€ä¸­æ£€æŸ¥å¯¹è¯æ¨¡åž‹æ˜¯å¦å“åº”缓慢");
    // ========== ç”±è´¨æ£€å•生成报告(1-070-105-000) ==========
    ErrorCode QC_GENERATE_TYPE_INVALID = new ErrorCode(1_070_105_000,
            "质检类型「{}」不合法,只支持 IQC(来料检验)、IPQC(过程检验)、OQC(出货检验)、RQC(退货检验)");
    ErrorCode QC_GENERATE_ORDER_NOT_EXISTS = new ErrorCode(1_070_105_001,
            "{}的质检单(ID={})不存在或已被删除,请刷新列表后重新选择");
    ErrorCode QC_GENERATE_ORDER_NOT_FINISHED = new ErrorCode(1_070_105_002,
            "{}的质检单「{}」当前状态为「{}」,尚未完成检验,不能生成报告。"
                    + "请先把该单据提交并完成检验判定后再生成");
    ErrorCode QC_GENERATE_ORDER_NOT_JUDGED = new ErrorCode(1_070_105_003,
            "{}的质检单「{}」尚未填写检验判定(判定为空),不能生成报告。"
                    + "请先在该单据上填写判定结论(合格 / ç‰¹é‡‡ / ä¸åˆæ ¼é€€è´§ / ä¸åˆæ ¼æŠ¥åºŸï¼‰åŽå†ç”Ÿæˆ");
    // 105_004 / 105_007 ä¸¤æ¡è·¯å¾„复用既有的 TEMPLATE_NOT_EXISTS / TEMPLATE_STATUS_DISABLED /
    // TEMPLATE_NO_PUBLISHED_VERSION:那几条文案本来就是精确的,再造一遍只会多两条会漂移的副本
    ErrorCode QC_GENERATE_TEMPLATE_TYPE_MISMATCH = new ErrorCode(1_070_105_005,
            "报告模板的报告类型是「{}」,与本次质检单的类型「{}」不一致,无法生成报告。"
                    + "请重新选择一张{}的报告模板");
    ErrorCode QC_GENERATE_NO_ITEM = new ErrorCode(1_070_105_006,
            "{}的质检单「{}」没有可用于出报告的检验项({}),无法生成报告。"
                    + "请先在该单据上录入检验指标与实测值后再生成");
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/enums/QcReportEnums.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,156 @@
package cn.iocoder.yudao.module.qcreport.enums;
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
 * æ™ºèƒ½è´¨æ£€æŠ¥å‘Šè®¾è®¡å¹³å° æžšä¸¾é›†åˆ
 */
public interface QcReportEnums {
    /**
     * æ¨¡æ¿çŠ¶æ€
     */
    @Getter
    @AllArgsConstructor
    enum TemplateStatusEnum implements ArrayValuable<Integer> {
        ENABLE(0, "启用"),
        DISABLE(1, "停用");
        private final Integer status;
        private final String name;
        public static final Integer[] ARRAYS = Arrays.stream(values())
                .map(TemplateStatusEnum::getStatus).toArray(Integer[]::new);
        @Override
        public Integer[] array() {
            return ARRAYS;
        }
    }
    /**
     * æ¨¡æ¿ç‰ˆæœ¬çŠ¶æ€
     */
    @Getter
    @AllArgsConstructor
    enum VersionStatusEnum implements ArrayValuable<Integer> {
        DRAFT(0, "草稿"),
        PUBLISHED(1, "已发布"),
        DISABLED(2, "已停用");
        private final Integer status;
        private final String name;
        public static final Integer[] ARRAYS = Arrays.stream(values())
                .map(VersionStatusEnum::getStatus).toArray(Integer[]::new);
        @Override
        public Integer[] array() {
            return ARRAYS;
        }
    }
    /**
     * æŠ¥å‘Šå®žä¾‹çŠ¶æ€
     */
    @Getter
    @AllArgsConstructor
    enum InstanceStatusEnum implements ArrayValuable<Integer> {
        GENERATING(0, "生成中"),
        SUCCESS(1, "生成成功"),
        FAILED(2, "生成失败");
        private final Integer status;
        private final String name;
        public static final Integer[] ARRAYS = Arrays.stream(values())
                .map(InstanceStatusEnum::getStatus).toArray(Integer[]::new);
        @Override
        public Integer[] array() {
            return ARRAYS;
        }
    }
    /**
     * çº¸å¼ å°ºå¯¸
     */
    @Getter
    @AllArgsConstructor
    enum PageSizeEnum implements ArrayValuable<String> {
        A3("A3", "A3"),
        A4("A4", "A4"),
        A5("A5", "A5"),
        LETTER("Letter", "Letter");
        private final String size;
        private final String name;
        public static final String[] ARRAYS = Arrays.stream(values())
                .map(PageSizeEnum::getSize).toArray(String[]::new);
        @Override
        public String[] array() {
            return ARRAYS;
        }
    }
    /**
     * çº¸å¼ æ–¹å‘
     */
    @Getter
    @AllArgsConstructor
    enum OrientationEnum implements ArrayValuable<String> {
        PORTRAIT("portrait", "纵向"),
        LANDSCAPE("landscape", "横向");
        private final String orientation;
        private final String name;
        public static final String[] ARRAYS = Arrays.stream(values())
                .map(OrientationEnum::getOrientation).toArray(String[]::new);
        @Override
        public String[] array() {
            return ARRAYS;
        }
    }
    /**
     * åˆ¤å®šç»“æžœ
     */
    @Getter
    @AllArgsConstructor
    enum CheckResultEnum implements ArrayValuable<String> {
        PASS("PASS", "合格"),
        FAIL("FAIL", "不合格");
        private final String result;
        private final String name;
        public static final String[] ARRAYS = Arrays.stream(values())
                .map(CheckResultEnum::getResult).toArray(String[]::new);
        @Override
        public String[] array() {
            return ARRAYS;
        }
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/enums/QcReportSourceTypeEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,54 @@
package cn.iocoder.yudao.module.qcreport.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Objects;
/**
 * æŠ¥å‘Šæ¥æºå•据类型(MES çš„四类质检单)。
 * <p>
 * è¿™é‡ŒåŒæ—¶é’‰æ­»ä¸‰ä»¶äº’相绑定的事,避免散落成三处字符串字面量:
 * è´¨æ£€å•的类型值(传给 MES æŽ¥å£ç”¨ï¼‰ã€æ¨¡æ¿ {@code report_type} çš„取值(与前端下拉同源,是数字字符串)、
 * ä»¥åŠæŠ¥å‘Šå®žä¾‹è½åº“æ—¶çš„ {@code business_type}。
 * <p>
 * {@code businessType} ç”¨çš„ {@code mes_qc_*} æ˜¯äº‹å®žä¸Šçš„契约 â€”—
 * ã€ŒæŒ‰å•据反查报告」靠它过滤,改一个字符就查不到历史报告。
 */
@Getter
@AllArgsConstructor
public enum QcReportSourceTypeEnum {
    IQC(1, "来料检验", "mes_qc_iqc"),
    IPQC(2, "过程检验", "mes_qc_ipqc"),
    OQC(3, "出货检验", "mes_qc_oqc"),
    RQC(4, "退货检验", "mes_qc_rqc");
    /** è´¨æ£€ç±»åž‹å€¼ï¼Œè§ MES çš„ MesQcTypeEnum */
    private final Integer type;
    /** ç±»åž‹åï¼Œä¸Ž MES åˆ—表上的叫法一致 */
    private final String name;
    /** æŠ¥å‘Šå®žä¾‹çš„ business_type */
    private final String businessType;
    /** è¯†åˆ«ä¸å‡ºè¿”回 null,由调用方决定怎么报错 */
    public static QcReportSourceTypeEnum of(Integer type) {
        for (QcReportSourceTypeEnum value : values()) {
            if (Objects.equals(value.type, type)) {
                return value;
            }
        }
        return null;
    }
    /**
     * æ¨¡æ¿ä¸Šçš„æŠ¥å‘Šç±»åž‹æ˜¯å¦æŒ‡å‘本类型。
     * <p>
     * æ¨¡æ¿çš„ {@code report_type} å­˜çš„æ˜¯æ•°å­—字符串(前端下拉取 mes_qc_type å­—典的 string å€¼ï¼‰ï¼Œ
     * æ‰€ä»¥æ¯”的是 {@code "1"} è€Œä¸æ˜¯ {@code "IQC"}。
     */
    public boolean matchesTemplateReportType(String reportType) {
        return reportType != null && reportType.trim().equals(String.valueOf(type));
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/aiimport/QcReportAiImportService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
package cn.iocoder.yudao.module.qcreport.service.aiimport;
import cn.iocoder.yudao.module.qcreport.controller.admin.aiimport.vo.QcReportAiDraftReqVO;
import cn.iocoder.yudao.module.qcreport.controller.admin.aiimport.vo.QcReportAiDraftRespVO;
/**
 * AI å¯¼å…¥ Service æŽ¥å£ï¼šè¯»æ–‡ä»¶ â†’ å‡ºæ¨¡æ¿è‰ç¨¿ã€‚
 *
 * <h3>为什么是同步接口</h3>
 * ä¸€æ¬¡å¯¼å…¥è¦ç­‰ 10~60 ç§’。做成异步需要任务表 + è½®è¯¢ + çº¿ç¨‹æ± ï¼Œè€Œå®ƒæ¢æ¥çš„只是「用户可以先去干别的」——
 * ä½†è‰ç¨¿åªä½œå‚考,用户等结果和去干别的再回来,拿到的行为完全一样。为一个不改变结果的体验
 * å¼•入一张表和一个线程池,不划算。
 *
 * <h3>为什么不落库</h3>
 * è‰ç¨¿åªç»™äººåœ¨è®¾è®¡å™¨é‡Œç¡®è®¤ï¼Œä¸éœ€è¦ä¸€ä¸ªèƒ½æŒ‰ ID å›žæŸ¥çš„实体。不落库意味着失败、重试、放弃
 * éƒ½ä¸ç•™ä¸‹ä»»ä½•需要清理的行;而「保存」仍然是既有的 {@code createVersion} / {@code updateVersion},
 * Schema æ ¡éªŒï¼ˆå«åˆ¤å®šè§„则)那道闸门自动生效,不存在第二条落库路径。
 */
public interface QcReportAiImportService {
    /**
     * è¯†åˆ«æ–‡ä»¶ï¼Œè¿”回模板草稿。
     *
     * @param reqVO è¯·æ±‚参数(文件 blobId、组件清单、补充说明)
     * @return æ¨¡æ¿è‰ç¨¿ï¼Œå«è½¯æç¤º
     */
    QcReportAiDraftRespVO generateDraft(QcReportAiDraftReqVO reqVO);
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/aiimport/QcReportAiImportServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,323 @@
package cn.iocoder.yudao.module.qcreport.service.aiimport;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.ai.api.chat.AiChatApi;
import cn.iocoder.yudao.module.qcreport.config.QcReportAiImportProperties;
import cn.iocoder.yudao.module.qcreport.controller.admin.aiimport.vo.QcReportAiDraftReqVO;
import cn.iocoder.yudao.module.qcreport.controller.admin.aiimport.vo.QcReportAiDraftRespVO;
import cn.iocoder.yudao.module.qcreport.controller.admin.aiimport.vo.QcReportComponentSpecVO;
import cn.iocoder.yudao.module.qcreport.dal.dataobject.version.ReportTemplateSchema;
import cn.iocoder.yudao.module.qcreport.service.aiimport.document.QcReportDocumentExtract;
import cn.iocoder.yudao.module.qcreport.service.aiimport.document.QcReportDocumentExtractService;
import cn.iocoder.yudao.module.qcreport.service.aiimport.llm.QcReportAiDraftNormalizer;
import cn.iocoder.yudao.module.qcreport.service.aiimport.llm.QcReportAiDraftParser;
import cn.iocoder.yudao.module.qcreport.service.aiimport.llm.QcReportLlmCall;
import cn.iocoder.yudao.module.qcreport.service.aiimport.llm.QcReportLlmCallPlanner;
import cn.iocoder.yudao.module.qcreport.service.aiimport.llm.QcReportTemplatePromptBuilder;
import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageBlobDO;
import cn.iocoder.yudao.module.system.service.storage.SystemStorageBlobService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.io.File;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_AI_BUDGET_EXCEEDED;
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_AI_TIMEOUT;
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_AI_UNAVAILABLE;
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_BLOB_NOT_FOUND;
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_CATALOG_INVALID;
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_DISABLED;
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_DRAFT_EMPTY;
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_FILE_TOO_LARGE;
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_SCHEMA_VERSION_UNSUPPORTED;
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_TOO_MANY_FILES;
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_TOO_MANY_PAGES_TOTAL;
/**
 * AI å¯¼å…¥ Service å®žçŽ°ç±»ã€‚
 * <p>
 * æµç¨‹å›ºå®šä¸ºï¼šæ ¡éªŒ â†’ é€ä¸ªæ–‡ä»¶æŠ½å– â†’ æŽ’调用计划 â†’ æ‰§è¡Œ â†’ é€æ¬¡å½’一 â†’ åˆå¹¶åŽ»é‡ â†’ ç»„装。
 * æ¯ä¸€æ­¥çš„失败都对应一个确定的错误码,前端拿到的永远是「哪一步、为什么、怎么办」,
 * è€Œä¸æ˜¯ä¸€ä¸ª 500 åŠ ä¸€å¥ã€ŒAI è¯†åˆ«å¤±è´¥ã€ã€‚
 * <p>
 * <b>与 ERP / CRM é‚£ä¸‰å¤„ AI è°ƒç”¨çš„一处刻意偏离</b>:它们把错误塞进响应体的 {@code rawText} ä¸”返回 HTTP 200,
 * å‰ç«¯é ã€ŒrawText æœ‰æ²¡æœ‰å€¼ã€å—…探失败。本实现改用真正的 ServiceException + åˆ†é”™è¯¯ç â€”—
 * é å­—段有没有值来判断成败,一旦某天正常返回也带上了 rawText,前端会立刻误判。
 */
@Slf4j
@Service
@Validated
public class QcReportAiImportServiceImpl implements QcReportAiImportService {
    /**
     * ç»„件清单的项数上限。
     * <p>
     * å‰ç«¯æ³¨å†Œè¡¨çŽ°æœ‰ 12 ä¸ªç»„件,留出三倍余量。真正的防线是积木清单的序列化长度上限
     * ï¼ˆåœ¨ {@link QcReportTemplatePromptBuilder} é‡Œï¼‰ï¼Œè¿™æ¡åªæ˜¯æå‰æ‹¦ä½æ˜Žæ˜¾å¼‚常的入参。
     */
    private static final int MAX_CATALOG_ITEMS = 40;
    @Resource
    private QcReportAiImportProperties properties;
    @Resource
    private QcReportDocumentExtractService extractService;
    @Resource
    private AiChatApi aiChatApi;
    @Resource
    private SystemStorageBlobService storageBlobService;
    @Override
    public QcReportAiDraftRespVO generateDraft(QcReportAiDraftReqVO reqVO) {
        long startedAt = System.currentTimeMillis();
        validateEnabled();
        validateCatalog(reqVO);
        validateSchemaVersion(reqVO.getSchemaVersion());
        validateFileCount(reqVO.getBlobIds().size());
        String systemPrompt = QcReportTemplatePromptBuilder.buildSystemPrompt(reqVO.getCatalog());
        List<QcReportAiDraftNormalizer.Result> perCall = new ArrayList<>();
        List<String> warnings = new ArrayList<>();
        List<String> fileNames = new ArrayList<>();
        ReportTemplateSchema.Page page = null;
        String summary = null;
        int totalPages = 0;
        int callCount = 0;
        boolean budgetExceeded = false;
        long budgetMs = properties.getMaxDurationSeconds() * 1000L;
        for (Long blobId : reqVO.getBlobIds()) {
            // è¿›æ–‡ä»¶å‰å…ˆçœ‹é¢„算:已经超了就不要再把下一份文件读进来、更不要渲染它的页,
            // é‚£äº›éƒ½æ˜¯ç™½èŠ±çš„ CPU å’Œå†…å­˜
            if (System.currentTimeMillis() - startedAt >= budgetMs) {
                budgetExceeded = true;
                break;
            }
            LoadedBlob blob = loadBlob(blobId);
            QcReportDocumentExtract extract = extractService.extract(blob.content(), blob.originalFilename(),
                    blob.contentType());
            fileNames.add(blob.originalFilename());
            totalPages += extract.pageCount();
            validateTotalPages(totalPages);
            warnings.addAll(extract.notes());
            for (QcReportLlmCall call : QcReportLlmCallPlanner.plan(extract, blob.originalFilename())) {
                // ä¸¤æ¬¡è°ƒç”¨ä¹‹é—´å…³é—¨ã€‚单次调用无法打断(AiChatApi ä¸æ”¶è¶…时参数),
                // æ‰€ä»¥è¿™é‡Œæ‹¦ä¸ä½ã€Œæœ€åŽä¸€æ¬¡è°ƒç”¨åˆè€—满一个 yudao.ai.timeout」——
                // å‰ç«¯è¶…时因此必须比「本预算 + å•次超时」更宽,否则用户看到的是浏览器断开而非这条提示
                if (System.currentTimeMillis() - startedAt >= budgetMs) {
                    budgetExceeded = true;
                    break;
                }
                QcReportAiDraftRespVO raw = execute(systemPrompt, reqVO.getHint(), call, startedAt);
                callCount++;
                if (page == null) {
                    page = raw.getPage();
                }
                if (summary == null && StrUtil.isNotBlank(raw.getSummary())) {
                    summary = raw.getSummary();
                }
                perCall.add(QcReportAiDraftNormalizer.normalize(
                        raw.getComponents(), reqVO.getCatalog(), call.sourceLabel()));
            }
            if (budgetExceeded) {
                break;
            }
        }
        long elapsedSeconds = (System.currentTimeMillis() - startedAt) / 1000;
        if (budgetExceeded) {
            // å·²ç»è¯†åˆ«å‡ºæ¥çš„部分照常返回:半份草稿对用户仍然有用,
            // ä¸”这里把「后续没识别」明写出来,不是静默截断
            warnings.add(StrUtil.format(
                    "本次识别已达到单次导入耗时上限({} ç§’,已用时 {} ç§’),后续内容未再识别,"
                            + "以上是已识别到的部分。请减少文件数量或页数后分批导入",
                    properties.getMaxDurationSeconds(), elapsedSeconds));
        }
        QcReportAiDraftNormalizer.Result merged =
                QcReportAiDraftNormalizer.mergeAndDedup(perCall, properties.getMaxComponents());
        warnings.addAll(merged.warnings());
        if (merged.components().isEmpty()) {
            // ä¸€ä¸ªç»„件都没识别出来时,报错文案要说清到底是因为耗尽了预算,
            // è¿˜æ˜¯æ¨¡åž‹ç¡®å®žè®¤ä¸å‡ºâ€”—两者用户要采取的动作完全不同
            if (budgetExceeded) {
                throw exception(AI_IMPORT_AI_BUDGET_EXCEEDED, properties.getMaxDurationSeconds(),
                        elapsedSeconds, String.join("、", fileNames));
            }
            throw exception(AI_IMPORT_DRAFT_EMPTY, String.join("、", fileNames));
        }
        QcReportAiDraftRespVO resp = new QcReportAiDraftRespVO();
        resp.setComponents(merged.components());
        resp.setPage(page);
        resp.setSummary(summary);
        resp.setWarnings(warnings);
        resp.setDurationMs(System.currentTimeMillis() - startedAt);
        log.info("AI å¯¼å…¥è¯†åˆ«å®Œæˆï¼šfiles={}, pages={}, calls={}, components={}, budgetExceeded={}, durationMs={}",
                fileNames, totalPages, callCount, merged.components().size(), budgetExceeded, resp.getDurationMs());
        return resp;
    }
    /* ------------------------------ è°ƒç”¨æ‰§è¡Œ ------------------------------ */
    /**
     * å‘一次模型调用并解析回复。
     * <p>
     * æ‰€æœ‰å¼‚常都在这里收口成一个确定的错误码:超时与其它失败分开,因为用户的下一步动作完全不同——
     * å‰è€…该减文件,后者该去查 AI æ¨¡åž‹é…ç½®ã€‚把它们混成「AI è¯†åˆ«å¤±è´¥ã€ï¼Œç”¨æˆ·åªèƒ½ä¸¤ä»¶äº‹éƒ½è¯•一遍。
     */
    private QcReportAiDraftRespVO execute(String systemPrompt, String hint, QcReportLlmCall call, long startedAt) {
        String userMessage = QcReportTemplatePromptBuilder.buildUserMessage(hint, call.sourceLabel(), call.text());
        String reply;
        try {
            reply = call.kind() == QcReportLlmCall.Kind.IMAGE
                    ? aiChatApi.chatWithImage(systemPrompt, userMessage, call.imageBase64(), call.mimeType())
                    : aiChatApi.chat(systemPrompt, userMessage);
        } catch (Exception e) {
            long elapsed = System.currentTimeMillis() - startedAt;
            log.warn("AI å¯¼å…¥è°ƒç”¨å¤±è´¥ï¼šsource={}, kind={}, elapsedMs={}", call.sourceLabel(), call.kind(), elapsed, e);
            if (isTimeout(e)) {
                throw exception(AI_IMPORT_AI_TIMEOUT, elapsed / 1000);
            }
            throw exception(AI_IMPORT_AI_UNAVAILABLE, elapsed, StrUtil.maxLength(String.valueOf(e.getMessage()), 200));
        }
        try {
            return QcReportAiDraftParser.parse(reply);
        } catch (ServiceException e) {
            // æ¨¡åž‹è¿”回了什么,是排查这条路上唯一的线索。写进日志而不是塞进响应:
            // ä¸€å¨æ¨¡åž‹åŽŸæ–‡ç»™ç”¨æˆ·çœ‹æ²¡æœ‰æ„ä¹‰ï¼Œè€Œé”™è¯¯ç å·²ç»è®²æ¸…äº†ã€Œæ€Žä¹ˆåŠžã€
            log.warn("AI å¯¼å…¥è§£æžå›žå¤å¤±è´¥ï¼šsource={}, reply={}",
                    call.sourceLabel(), QcReportAiDraftParser.truncate(reply), e);
            throw e;
        }
    }
    /**
     * åˆ¤æ–­å¼‚常链里有没有超时。
     * <p>
     * {@code AiChatApi} ä¸æš´éœ²åº•层 HTTP å®¢æˆ·ç«¯ï¼Œè¶…时最终会以 {@link InterruptedIOException}
     * ï¼ˆOkHttp çš„读写超时都是它的子类)或带有 "timeout" å­—样的异常出现在原因链上。
     * æ²¿é“¾æ‰¾è€Œä¸æ˜¯åªçœ‹æœ€å¤–层:中间隔了几层包装是常态。
     */
    private static boolean isTimeout(Throwable e) {
        for (Throwable cause = e; cause != null; cause = cause.getCause()) {
            if (cause instanceof InterruptedIOException) {
                return true;
            }
            if (cause.getMessage() != null && cause.getMessage().toLowerCase().contains("timeout")) {
                return true;
            }
            if (cause.getCause() == cause) {
                break;
            }
        }
        return false;
    }
    /* ------------------------------ è¯»å–文件 ------------------------------ */
    /**
     * è¯»ä¸€ä¸ª blob çš„字节。
     * <p>
     * <b>刻意不删除取到的文件。</b> {@code getPublicFile} è¿”回的是磁盘上真实存储的那份 blob,
     * ä¸æ˜¯ä¸´æ—¶å‰¯æœ¬â€”—删了就是删用户的真实上传数据。
     * ï¼ˆERP é‡‡è´­æ¥ç¥¨ AI ä¸Ž CRM æŠ¥ä»· AI æ›¾åœ¨ {@code finally} é‡ŒçŠ¯è¿™ä¸ªé”™ï¼Œå·²ä¿®ï¼›æœ¬å®žçŽ°ç»ä¸å¤åˆ¶ã€‚ï¼‰
     */
    private LoadedBlob loadBlob(Long blobId) {
        SystemStorageBlobDO blob = storageBlobService.getStorageBlob(blobId);
        if (blob == null) {
            throw exception(AI_IMPORT_BLOB_NOT_FOUND, blobId);
        }
        String originalFilename = StrUtil.blankToDefault(blob.getOriginalFilename(), "file");
        try {
            File file = storageBlobService.getPublicFile(blob.getUidFilename(), blob.getResourceKey());
            byte[] content = Files.readAllBytes(file.toPath());
            long maxBytes = properties.getMaxFileSizeMb() * 1024L * 1024L;
            if (content.length > maxBytes) {
                throw exception(AI_IMPORT_FILE_TOO_LARGE, originalFilename,
                        content.length / 1024L / 1024L, properties.getMaxFileSizeMb());
            }
            return new LoadedBlob(content, originalFilename, blob.getContentType());
        } catch (IOException e) {
            log.warn("读取上传文件失败,blobId={}", blobId, e);
            throw exception(AI_IMPORT_BLOB_NOT_FOUND, blobId);
        }
    }
    private record LoadedBlob(byte[] content, String originalFilename, String contentType) {
    }
    /* ------------------------------ æ ¡éªŒ ------------------------------ */
    private void validateEnabled() {
        if (!Boolean.TRUE.equals(properties.getEnabled())) {
            throw exception(AI_IMPORT_DISABLED);
        }
    }
    private void validateCatalog(QcReportAiDraftReqVO reqVO) {
        if (reqVO.getCatalog() == null || reqVO.getCatalog().isEmpty()) {
            throw exception(AI_IMPORT_CATALOG_INVALID, "组件清单为空");
        }
        if (reqVO.getCatalog().size() > MAX_CATALOG_ITEMS) {
            throw exception(AI_IMPORT_CATALOG_INVALID,
                    StrUtil.format("组件清单共 {} é¡¹ï¼Œè¶…过上限 {} é¡¹", reqVO.getCatalog().size(), MAX_CATALOG_ITEMS));
        }
        for (QcReportComponentSpecVO spec : reqVO.getCatalog()) {
            if (spec == null || StrUtil.isBlank(spec.getType())) {
                throw exception(AI_IMPORT_CATALOG_INVALID, "存在没有 type çš„组件项");
            }
        }
    }
    /**
     * æ ¡éªŒ Schema ç‰ˆæœ¬ã€‚
     * <p>
     * è¿™æ¡æ£€æŸ¥æŠŠã€Œç§¯æœ¨æ¸…单来自 1.1 è¯­ä¹‰å±‚」这个隐式耦合变成显式失败:版本不一致说明前端是旧包,
     * å®ƒç”Ÿæˆå‡ºæ¥çš„æ¸…单可能与服务端理解的语义层对不上,此时报错让用户刷新,比让模型基于过期清单编组件安全。
     */
    private void validateSchemaVersion(String schemaVersion) {
        if (!ReportTemplateSchema.SCHEMA_VERSION.equals(schemaVersion)) {
            throw exception(AI_IMPORT_SCHEMA_VERSION_UNSUPPORTED, schemaVersion,
                    ReportTemplateSchema.SCHEMA_VERSION);
        }
    }
    /**
     * æ ¡éªŒæ–‡ä»¶ä¸ªæ•°ã€‚**这是文件数上限的唯一闸门**,入参 VO ä¸Šåˆ»æ„ä¸å†æŒ‚ {@code @Size}。
     * <p>
     * è°ƒç”¨ä½ç½®åœ¨ {@link #generateDraft} çš„æœ€å‰æ®µã€ä»»ä½• blob è¯»å–之前,所以放大文件数不会带来
     * é¢å¤–的磁盘/内存开销,注解那层拦截并无必要;而两个数字并存的结果只会是注解更严、配置失去可调性。
     */
    private void validateFileCount(int fileCount) {
        if (fileCount > properties.getMaxFiles()) {
            throw exception(AI_IMPORT_TOO_MANY_FILES, properties.getMaxFiles(), fileCount);
        }
    }
    /**
     * æ ¡éªŒç´¯è®¡é¡µæ•°ã€‚
     * <p>
     * å•文件页数在适配器里已经卡过了,这里卡的是「每个文件都没超、加起来却很多」——
     * ä¸‰ä¸ªå„ 5 é¡µçš„æ‰«æä»¶ä¼šå˜æˆ 15 æ¬¡å¤šæ¨¡æ€è°ƒç”¨ï¼Œä»£ä»·ä¸Žè€—时都远超预期。
     */
    private void validateTotalPages(int totalPages) {
        if (totalPages > properties.getMaxPagesPerRequest()) {
            throw exception(AI_IMPORT_TOO_MANY_PAGES_TOTAL, totalPages,
                    properties.getMaxPagesPerRequest(), properties.getMaxPagesPerFile());
        }
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/aiimport/document/ImageImportAdapter.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,62 @@
package cn.iocoder.yudao.module.qcreport.service.aiimport.document;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Set;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_FILE_EMPTY;
/**
 * å›¾ç‰‡ï¼ˆç…§ç‰‡ / æˆªå›¾ï¼‰æŠ½å–器。
 * <p>
 * ä¸‰ç§é€‚配器里唯一「什么都不用做」的一个:字节原样交给多模态模型,不解码、不缩放、不转码。
 * åˆ»æ„ä¸åœ¨è¿™é‡Œç”¨ {@code ImageIO} è¯»ä¸€éâ€”—那不仅多一次内存拷贝,还会在遇到 webp è¿™ç±»
 * JDK åŽŸç”Ÿä¸æ”¯æŒçš„æ ¼å¼æ—¶ç™½ç™½å¤±è´¥ï¼Œè€Œæ¨¡åž‹é‚£è¾¹å…¶å®žå®Œå…¨èƒ½è®¤ã€‚
 * <p>
 * æŽ’在第一位:它只认图片扩展名,与另外两个的匹配集不相交,放哪都不会引起歧义,
 * ä½†æ”¾åœ¨æœ€å‰é¢èƒ½è®©ã€Œä¸€å¼ ç…§ç‰‡ã€è¿™æ¡æœ€å¸¸è§çš„路径以最短的路走到头。
 */
@Component
@Order(1)
public class ImageImportAdapter implements QcReportImportAdapter {
    private static final Set<String> EXTENSIONS = Set.of("png", "jpg", "jpeg", "bmp", "gif", "webp");
    @Override
    public boolean supports(String extension, String contentType) {
        return EXTENSIONS.contains(extension);
    }
    @Override
    public QcReportDocumentExtract extract(byte[] content, String originalFilename) {
        if (content == null || content.length == 0) {
            throw exception(AI_IMPORT_FILE_EMPTY, originalFilename, 1);
        }
        String mimeType = mimeTypeOf(extensionOf(originalFilename));
        return QcReportDocumentExtract.ofImages(
                List.of(new QcReportDocumentExtract.ImagePart(content, mimeType, "第 1 é¡µ")),
                1, "raw-image", List.of());
    }
    /**
     * PDF é€‚配器需要同一套映射来标注渲染出来的 PNG,所以公开出来复用。
     */
    static String mimeTypeOf(String extension) {
        return switch (extension) {
            case "jpg", "jpeg" -> "image/jpeg";
            case "gif" -> "image/gif";
            case "bmp" -> "image/bmp";
            case "webp" -> "image/webp";
            default -> "image/png";
        };
    }
    private static String extensionOf(String filename) {
        int dot = filename == null ? -1 : filename.lastIndexOf('.');
        return dot < 0 ? "" : filename.substring(dot + 1).toLowerCase();
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/aiimport/document/OfficeImportAdapter.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,372 @@
package cn.iocoder.yudao.module.qcreport.service.aiimport.document;
import cn.iocoder.yudao.module.qcreport.config.QcReportAiImportProperties;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.apache.poi.xwpf.usermodel.IBodyElement;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFHeaderFooter;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFTable;
import org.apache.poi.xwpf.usermodel.XWPFTableCell;
import org.apache.poi.xwpf.usermodel.XWPFTableRow;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTc;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcPr;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTVMerge;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.STMerge;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_FILE_EMPTY;
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_FILE_LEGACY_OFFICE;
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_OFFICE_CORRUPT;
/**
 * Word / Excel / çº¯æ–‡æœ¬æŠ½å–器。
 * <p>
 * <b>用 POI è€Œä¸æ˜¯ Tika</b>,两个原因,缺一不可:
 * æ£€éªŒæŠ¥å‘Šçš„æ ¸å¿ƒä¿¡æ¯åœ¨è¡¨æ ¼é‡Œï¼Œè¡Œåˆ—结构一旦被 Tika æ‹å¹³æˆä¸€æ®µæ–‡å­—就再也找不回来;
 * è€Œ {@code tika-parsers-standard-package} ä¼šæ‹–进几百 MB çš„解析器依赖,
 * ä¸€ä¸ªåªå¤„理四种格式的模块没必要背这个包袱。
 *
 * <h3>页眉页脚</h3>
 * .docx çš„ page header / footer å­˜åœ¨æ­£æ–‡ä¹‹å¤–,{@code getBodyElements()} çœ‹ä¸åˆ°å®ƒä»¬ã€‚
 * åªçœ‹æ­£æ–‡çš„æ–‡ä»¶ï¼Œã€Œå…¬å¸æŠ¬å¤´åœ¨é¡µçœ‰ã€åŽ‚å€ç”µè¯åœ¨é¡µè„šã€è¿™ç±»æŠ¥å‘Šä¼šæŠŠä¸¤å¤´éƒ½ä¸¢æŽ‰â€”â€”
 * æ¨¡åž‹è¿žæ–‡å­—都没见过,自然也不会想到用 {@code ReportHeader} / {@code ReportFooter}。
 * æ‰€ä»¥é¡µçœ‰é¡µè„šä¸€å¹¶è¯»å…¥ï¼Œç”¨ {@code [页眉]} / {@code [页脚]} æ ‡è®°ä¸Žæ­£æ–‡åŒºåˆ†ã€‚
 * PDF ä¸Žå›¾ç‰‡ä¸éœ€è¦è¿™å¥—标记:那两种通道里页眉页脚本来就是页面内容的一部分。
 *
 * <h3>旧格式 .doc / .xls</h3>
 * åœ¨ {@link #supports} é‡Œ<b>认领</b>、在 {@link #extract} é‡Œ<b>拒绝</b>。
 * è®©å®ƒèµ°åˆ°ã€Œæ ¼å¼ä¸æ”¯æŒã€é‚£æ¡é€šç”¨é”™è¯¯åŽ»æ˜¯ä¸è¡Œçš„ï¼šé‚£è¾¹åªä¼šå‘Šè¯‰ç”¨æˆ·ã€Œä»…æ”¯æŒ PDF / Word / Excel」,
 * è€Œç”¨æˆ·æ‰‹ä¸Šçš„æ–‡ä»¶æ‰©å±•名看起来完全合规(.doc ä¹Ÿæ˜¯ Word),提示等于没说。
 * è®¤é¢†ä¹‹åŽå°±èƒ½ç»™å‡ºçœŸæ­£çš„出路——另存为 .docx。
 */
@Slf4j
@Component
@Order(3)
public class OfficeImportAdapter implements QcReportImportAdapter {
    private static final Set<String> EXTENSIONS = Set.of("docx", "xlsx", "txt", "csv", "doc", "xls");
    /** åªå«ç©ºç™½å­—符的 UTF-8 BOM,解码前要先剥掉,否则第一行的第一个单元格会带一个不可见字符 */
    private static final byte[] BOM_UTF_8 = {(byte) 0xEF, (byte) 0xBB, (byte) 0xBF};
    /** é¡µçœ‰/页脚标记:不标记的话,模型无从判断这段是每页重复的抬头还是正文里的普通文本 */
    private static final String HEADER_MARK = "[页眉]";
    private static final String FOOTER_MARK = "[页脚]";
    /** é¡µçœ‰/页脚只是软提示(进 warnings),要不要变成组件由模型按组件 hint åˆ¤æ–­ï¼Œç”¨æˆ·å†ç¡®è®¤ */
    private static final String HEADER_FOOTER_NOTE =
            "原件带有页眉/页脚,已用 [页眉] / [页脚] æ ‡è®°å¹¶å…¥è¯†åˆ«å†…容";
    @Resource
    private QcReportAiImportProperties properties;
    @Override
    public boolean supports(String extension, String contentType) {
        return EXTENSIONS.contains(extension);
    }
    @Override
    public QcReportDocumentExtract extract(byte[] content, String originalFilename) {
        String extension = extensionOf(originalFilename);
        if ("doc".equals(extension) || "xls".equals(extension)) {
            throw exception(AI_IMPORT_FILE_LEGACY_OFFICE, originalFilename, extension);
        }
        if (content == null || content.length == 0) {
            throw exception(AI_IMPORT_FILE_EMPTY, originalFilename, 1);
        }
        List<String> notes = List.of();
        String text;
        if ("docx".equals(extension)) {
            DocxRead read = readDocx(content, originalFilename);
            text = read.text();
            notes = read.notes();
        } else {
            text = switch (extension) {
                case "xlsx" -> readXlsx(content, originalFilename);
                case "txt", "csv" -> readPlainText(content);
                default -> "";
            };
        }
        if (text.isBlank()) {
            throw exception(AI_IMPORT_FILE_EMPTY, originalFilename, 1);
        }
        return QcReportDocumentExtract.ofText(text, 1, extractorNameOf(extension), notes);
    }
    /**
     * è¯» .docx,正文与页眉/页脚一起读。
     * <p>
     * æ­£æ–‡æŒ‰ {@code getBodyElements()} è¿­ä»£è€Œä¸æ˜¯ã€Œå…ˆæ‰€æœ‰æ®µè½ã€å†æ‰€æœ‰è¡¨æ ¼ã€ï¼šæŠ¥å‘Šé‡Œè¡¨æ ¼å¤¹åœ¨æ®µè½ä¹‹é—´ï¼Œ
     * åˆ†å¼€æ”¶é›†ä¼šæŠŠç‰ˆé¢é¡ºåºå½»åº•打乱,模型看到的是一堆标题堆在开头、表格堆在结尾。
     * <p>
     * è¡¨æ ¼ç”¨ Tab åˆ†éš”单元格、换行分隔行,并在前后加标记——标记的作用是让模型知道
     * ã€Œè¿™å‡ è¡Œå±žäºŽåŒä¸€ä¸ªè¡¨æ ¼ã€ï¼Œæ²¡æœ‰å®ƒï¼Œä¸€æ®µç”¨ Tab æ‹¼èµ·æ¥çš„æ–‡æœ¬å’Œæ™®é€šæ®µè½æ— ä»ŽåŒºåˆ†ã€‚
     * è¡¨æ ¼å†…部的合并单元格由 {@link #appendTable} å½’一化成对齐的网格,合并信息因此不会丢失。
     * <p>
     * <b>页眉/页脚必须读</b>:报告的抬头(公司名、报告名)和落款(厂址、电话)通常就住在这里,
     * è€Œ {@code getBodyElements()} åªçœ‹æ­£æ–‡ï¼Œä¸è¯»çš„话模型压根没见过这些文字,
     * ä¹Ÿå°±ä¸å¯èƒ½æŠŠå®ƒä»¬è®¤æˆ {@code ReportHeader} / {@code ReportFooter}。读到的内容用
     * {@code [页眉]} / {@code [页脚]} åŒ…住,顺序与文档视觉顺序一致(页眉在前、页脚在后)。
     */
    private DocxRead readDocx(byte[] content, String originalFilename) {
        try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(content))) {
            StringBuilder sb = new StringBuilder();
            String header = collectHeaderFooter(document.getHeaderList());
            String footer = collectHeaderFooter(document.getFooterList());
            if (!header.isBlank()) {
                sb.append(HEADER_MARK).append('\n').append(header);
            }
            appendBodyElements(sb, document.getBodyElements());
            if (!footer.isBlank()) {
                sb.append(FOOTER_MARK).append('\n').append(footer);
            }
            List<String> notes = header.isBlank() && footer.isBlank() ? List.of() : List.of(HEADER_FOOTER_NOTE);
            return new DocxRead(sb.toString(), notes);
        } catch (IOException | RuntimeException e) {
            log.warn("docx è§£æžå¤±è´¥ï¼Œfile={}", originalFilename, e);
            throw exception(AI_IMPORT_OFFICE_CORRUPT, originalFilename, "docx");
        }
    }
    /**
     * æ”¶ä¸€ä¸ªæ–‡æ¡£çš„页眉或页脚。
     * <p>
     * ä¸¤ä»¶äº‹å¿…须做,少一件都会把噪声当信号:
     * <ul>
     *   <li><b>空白过滤</b>:文档根本没有页眉时,POI çš„默认策略仍会返回一个空 header,
     *       ä¸è¿‡æ»¤å°±ä¼šå‡­ç©ºå¤šå‡ºä¸€ä¸ª {@code [页眉]} æ ‡è®°ï¼Œè®©æ¨¡åž‹ä»¥ä¸ºåŽŸä»¶æœ‰æŠ¬å¤´ã€‚</li>
     *   <li><b>去重</b>:Word å…è®¸é¦–页/奇数页/偶数页各配一套,实际文件里这几套常常逐字相同,
     *       ä¸åŽ»é‡ä¼šæŠŠåŒä¸€æ®µæŠ¬å¤´é‡å¤å‡ éå–‚ç»™æ¨¡åž‹ï¼Œæ—¢æµªè´¹ä¸Šä¸‹æ–‡åˆåƒæ˜¯åœ¨å¼ºè°ƒå®ƒã€‚</li>
     * </ul>
     */
    private static String collectHeaderFooter(List<? extends XWPFHeaderFooter> parts) {
        Set<String> distinct = new LinkedHashSet<>();
        for (XWPFHeaderFooter part : parts) {
            StringBuilder sb = new StringBuilder();
            appendBodyElements(sb, part.getBodyElements());
            String text = sb.toString().trim();
            if (!text.isEmpty()) {
                distinct.add(text);
            }
        }
        return String.join("\n", distinct);
    }
    /**
     * æŒ‰é¡ºåºæŠŠæ®µè½ä¸Žè¡¨æ ¼å†™è¿›ç¼“冲区。正文、页眉、页脚共用一份,避免两套表格逻辑各自漂移。
     */
    private static void appendBodyElements(StringBuilder sb, List<IBodyElement> elements) {
        for (IBodyElement element : elements) {
            if (element instanceof XWPFParagraph paragraph) {
                appendLine(sb, paragraph.getText());
            } else if (element instanceof XWPFTable table) {
                appendTable(sb, table);
            }
        }
    }
    /**
     * æŠŠä¸€å¼ è¡¨å½’一化成稳定网格后再输出。
     * <p>
     * ç›´æŽ¥æŒ‰ {@code row.getTableCells()} æ‹¼ Tab æ˜¯ä¸å¤Ÿçš„:合并单元格会让每行的列数各不相同,
     * æ¨¡åž‹æ”¶åˆ°çš„æ˜¯ä¸€å¼ è¡Œåˆ—错位、参差不齐的「表」。
     * <ul>
     *   <li>{@code w:gridSpan} æ¨ªå‘合并的格子,POI åªè¿”回一个 {@code XWPFTableCell},
     *       è¢«å®ƒç›–住的后几列凭空消失 â‡’ åŒä¸€è¡Œçš„列与表头对不上;</li>
     *   <li>{@code w:vMerge} çºµå‘合并的续格,POI å–到的文字是空串 â‡’ åˆ†ç»„名只在第一行出现,
     *       åŽé¢å‡ è¡Œçœ‹èµ·æ¥æ˜¯ã€Œæ²¡æœ‰åˆ†ç»„的独立检验项」,模型自然想不到这是两层结构。</li>
     * </ul>
     * åŽæžœä¸åªæ˜¯æŽ’版难看:模型看不到两层结构,就不会去挑「分组检验项表」组件,
     * åªèƒ½é€€è€Œæ±‚其次挑平铺的检验表,把分组信息整个丢掉。
     * <p>
     * æ‰€ä»¥è¿™é‡ŒæŠŠåˆå¹¶ä¿¡æ¯ç¿»è¯‘成模型看得见的字符:
     * <ul>
     *   <li>{@code gridSpan=N} çš„æ–‡å­—落在该组第 1 æ ¼ï¼Œå…¶åŽè¡¥ N-1 ä¸ªç©ºå ä½ï¼Œåˆ—位与表头对齐;</li>
     *   <li>{@code vMerge} ç»­æ ¼è¾“出 {@link QcReportDocumentExtract#VERTICAL_MERGE_MARK},明说「同上一行」;</li>
     *   <li>每行尾部补齐到整表网格宽度,短行不再被当成「列数就这么多」。</li>
     * </ul>
     * ç»­æ ¼è‡ªèº«å¸¦æ–‡å­—时以文字为准:续格在 Word é‡Œæœ¬ä¸è¯¥æœ‰å†…容,真有就说明制表不规范,
     * æ­¤æ—¶ä¸¢æ–‡å­—比丢结构更可惜。整表网格宽度取所有行的最大值,而不是第一行的列数——
     * è¡¨å¤´å¸¸å¸¸å¸¦æ¨ªå‘合并,按表头宽度切会把下面几行截断。
     */
    private static void appendTable(StringBuilder sb, XWPFTable table) {
        List<List<String>> gridRows = new ArrayList<>();
        int width = 0;
        for (XWPFTableRow row : table.getRows()) {
            List<String> grid = new ArrayList<>();
            for (XWPFTableCell cell : row.getTableCells()) {
                grid.add(cellText(cell));
                for (int i = 1; i < gridSpanOf(cell); i++) {
                    grid.add("");
                }
            }
            width = Math.max(width, grid.size());
            gridRows.add(grid);
        }
        sb.append("[表格开始]\n");
        for (List<String> grid : gridRows) {
            while (grid.size() < width) {
                grid.add("");
            }
            appendLine(sb, String.join("\t", grid));
        }
        sb.append("[表格结束]\n");
    }
    /** å•元格文字;空白且是纵向合并续格时给出 {@code â†‘同上} è€Œä¸æ˜¯ç©ºä¸² */
    private static String cellText(XWPFTableCell cell) {
        String text = cell.getText().replaceAll("[\\r\\n]+", " ").trim();
        if (text.isEmpty() && isVerticalMergeContinuation(cell)) {
            return QcReportDocumentExtract.VERTICAL_MERGE_MARK;
        }
        return text;
    }
    /**
     * æ˜¯å¦æ˜¯çºµå‘合并的「续格」。
     * <p>
     * {@code <w:vMerge/>} ä¸å¸¦ val ä¸Ž {@code w:val="continue"} éƒ½è¡¨ç¤ºç»­æ ¼ï¼ˆå‰è€…是 Word çš„常见写法);
     * {@code w:val="restart"} æ˜¯åˆå¹¶çš„起始格,它带着真正的文字,不算续格。
     */
    private static boolean isVerticalMergeContinuation(XWPFTableCell cell) {
        CTTcPr tcPr = cellPropertiesOf(cell);
        if (tcPr == null || !tcPr.isSetVMerge()) {
            return false;
        }
        CTVMerge vMerge = tcPr.getVMerge();
        return !vMerge.isSetVal() || vMerge.getVal() == STMerge.CONTINUE;
    }
    /** æ¨ªå‘合并的列数,非合并格为 1;值异常时一律按 1 å¤„理,宁可少补占位也不要凭空多出列 */
    private static int gridSpanOf(XWPFTableCell cell) {
        CTTcPr tcPr = cellPropertiesOf(cell);
        if (tcPr == null || !tcPr.isSetGridSpan()) {
            return 1;
        }
        BigInteger span = tcPr.getGridSpan().getVal();
        return span == null || span.intValue() < 1 ? 1 : span.intValue();
    }
    /** å–格属性;POI å¯¹æ²¡æœ‰ {@code <w:tcPr>} çš„单元格返回 null,不能直接链式调用 */
    private static CTTcPr cellPropertiesOf(XWPFTableCell cell) {
        CTTc ctTc = cell.getCTTc();
        return ctTc == null ? null : ctTc.getTcPr();
    }
    /** docx æŠ½å–结果:正文文本 + è¦è¿› warnings çš„软提示 */
    private record DocxRead(String text, List<String> notes) {
    }
    /**
     * è¯» .xlsx。
     * <p>
     * ç”¨ {@link DataFormatter} å–<b>显示值</b>而不是原始值:原始值里 {@code 1} ä¼šå˜æˆ {@code 1.0}、
     * æ—¥æœŸä¼šå˜æˆä¸€ä¸²åºåˆ—号,模型看到这些基本只能瞎猜。用户眼里看到的是什么,就该给模型什么。
     * <p>
     * è¡Œæ•°æŒ‰ {@code maxXlsxRows} å°é¡¶ï¼Œç©ºç™½è¡Œæ•´è¡Œä¸¢å¼ƒâ€”—超大表里大量空白行会把提示词灌满噪声。
     */
    private String readXlsx(byte[] content, String originalFilename) {
        try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) {
            StringBuilder sb = new StringBuilder();
            DataFormatter formatter = new DataFormatter();
            for (int sheetIndex = 0; sheetIndex < workbook.getNumberOfSheets(); sheetIndex++) {
                Sheet sheet = workbook.getSheetAt(sheetIndex);
                sb.append("[工作表: ").append(sheet.getSheetName()).append("]\n");
                int lastRow = sheet.getLastRowNum();
                int written = 0;
                for (int rowIndex = 0; rowIndex <= lastRow && written < properties.getMaxXlsxRows(); rowIndex++) {
                    Row row = sheet.getRow(rowIndex);
                    if (row == null) {
                        continue;
                    }
                    List<String> cells = new ArrayList<>();
                    boolean hasValue = false;
                    for (int cellIndex = 0; cellIndex < row.getLastCellNum(); cellIndex++) {
                        Cell cell = row.getCell(cellIndex);
                        String value = cell == null ? "" : formatter.formatCellValue(cell).trim();
                        if (!value.isEmpty()) {
                            hasValue = true;
                        }
                        cells.add(value);
                    }
                    if (!hasValue) {
                        continue;
                    }
                    appendLine(sb, String.join("\t", cells));
                    written++;
                }
                if (written >= properties.getMaxXlsxRows() && lastRow + 1 > written) {
                    sb.append("[本工作表超过 ").append(properties.getMaxXlsxRows())
                            .append(" è¡Œï¼ŒåŽç»­å†…容未读取]\n");
                }
            }
            return sb.toString();
        } catch (IOException | RuntimeException e) {
            log.warn("xlsx è§£æžå¤±è´¥ï¼Œfile={}", originalFilename, e);
            throw exception(AI_IMPORT_OFFICE_CORRUPT, originalFilename, "xlsx");
        }
    }
    private String readPlainText(byte[] content) {
        if (startsWith(content, BOM_UTF_8)) {
            return new String(content, BOM_UTF_8.length, content.length - BOM_UTF_8.length,
                    StandardCharsets.UTF_8);
        }
        if (content.length >= 2 && (content[0] & 0xFF) == 0xFF && (content[1] & 0xFF) == 0xFE) {
            return new String(content, 2, content.length - 2, StandardCharsets.UTF_16LE);
        }
        if (content.length >= 2 && (content[0] & 0xFF) == 0xFE && (content[1] & 0xFF) == 0xFF) {
            return new String(content, 2, content.length - 2, StandardCharsets.UTF_16BE);
        }
        // æ²¡æœ‰ BOM æ—¶ä¸€å¾‹æŒ‰ UTF-8 è¯»ã€‚GBK åœ¨è¿™é‡Œä¼šè¢«è¯»æˆä¹±ç ï¼Œä½†é‚£æ˜¯ã€Œè¯»å‡ºä¸€å †é—®å·ã€è€Œä¸æ˜¯å¤±è´¥ï¼Œ
        // ç”¨æˆ·èƒ½ä»Žé¢„览里看出来,比强行猜编码猜错更可控
        return new String(content, StandardCharsets.UTF_8);
    }
    private static void appendLine(StringBuilder sb, String line) {
        if (line != null && !line.isBlank()) {
            sb.append(line).append('\n');
        }
    }
    private static String extractorNameOf(String extension) {
        return "txt".equals(extension) || "csv".equals(extension) ? "plain-text" : "poi-" + extension;
    }
    private static boolean startsWith(byte[] content, byte[] prefix) {
        if (content.length < prefix.length) {
            return false;
        }
        for (int i = 0; i < prefix.length; i++) {
            if (content[i] != prefix[i]) {
                return false;
            }
        }
        return true;
    }
    private static String extensionOf(String filename) {
        int dot = filename == null ? -1 : filename.lastIndexOf('.');
        return dot < 0 ? "" : filename.substring(dot + 1).toLowerCase();
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/aiimport/document/PdfImportAdapter.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,146 @@
package cn.iocoder.yudao.module.qcreport.service.aiimport.document;
import cn.iocoder.yudao.module.qcreport.config.QcReportAiImportProperties;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.pdfbox.text.PDFTextStripper;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_FILE_EMPTY;
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_TOO_MANY_PAGES;
/**
 * PDF æŠ½å–器,双通道。
 * <p>
 * <b>通道判定依据是「这份 PDF æœ‰æ²¡æœ‰æ–‡æœ¬å±‚」,不是「它是不是 PDF」。</b>
 * è¿™ä¸ªåŒºåˆ«å¾ˆå®žé™…:电子版 PDF æŠ½å‡ºæ–‡æœ¬åªè¦å‡ å KB、读得也准;扫描版 PDF æŠ½å‡ºæ¥æ˜¯ç©ºçš„,
 * åªèƒ½æŠŠé¡µé¢æ¸²æŸ“成图交给多模态模型——成本高一个数量级,但这是唯一能读出字的办法。
 * åˆ¤æ®æ˜¯å¹³å‡æ¯é¡µå­—符数超过阈值。
 * <p>
 * åˆ»æ„<b>不</b>做混合型 PDF çš„逐页通路(有的页有文本层、有的没有):那需要按页决定通道,
 * è°ƒç”¨æ¬¡æ•°ç¿»å€è€Œæ”¶ç›Šåªåœ¨å°‘数文件上。整份文件按一个通道走,行为可预期。
 */
@Slf4j
@Component
@Order(2)
public class PdfImportAdapter implements QcReportImportAdapter {
    @Resource
    private QcReportAiImportProperties properties;
    @Override
    public boolean supports(String extension, String contentType) {
        return "pdf".equals(extension) || "application/pdf".equalsIgnoreCase(contentType);
    }
    @Override
    public QcReportDocumentExtract extract(byte[] content, String originalFilename) {
        if (content == null || content.length == 0) {
            throw exception(AI_IMPORT_FILE_EMPTY, originalFilename, 0);
        }
        try (PDDocument document = Loader.loadPDF(content)) {
            int pageCount = document.getNumberOfPages();
            if (pageCount <= 0) {
                throw exception(AI_IMPORT_FILE_EMPTY, originalFilename, 0);
            }
            // è¶…限直接报错而不是截断:静默截断会让用户以为整份文件都识别过了,
            // æ‹¿åˆ°çš„æ¨¡æ¿å°‘了几页却没有任何提示,比报错危险得多
            if (pageCount > properties.getMaxPagesPerFile()) {
                throw exception(AI_IMPORT_TOO_MANY_PAGES, originalFilename, pageCount,
                        properties.getMaxPagesPerFile());
            }
            List<String> pageTexts = extractPageTexts(document, pageCount);
            int totalChars = pageTexts.stream().mapToInt(t -> t.replaceAll("\\s", "").length()).sum();
            int threshold = properties.getPdfTextPageThreshold() * pageCount;
            if (totalChars >= threshold) {
                return QcReportDocumentExtract.ofText(joinPages(pageTexts), pageCount, "pdfbox-text", List.of());
            }
            // èµ°å›¾ç‰‡é€šé“:把「为什么慢/为什么贵」讲清楚,用户才知道换电子版 PDF èƒ½å¿«å¾ˆå¤š
            return renderPages(document, pageCount, originalFilename,
                    List.of("文件「" + originalFilename + "」没有文本层(共 " + pageCount
                            + " é¡µï¼‰ï¼Œå·²æŒ‰æ‰«æä»¶é€é¡µè¯†åˆ«ï¼Œä¼šæ¯”较慢"));
        } catch (IOException e) {
            log.warn("PDF è§£æžå¤±è´¥ï¼Œfile={}", originalFilename, e);
            throw exception(AI_IMPORT_FILE_EMPTY, originalFilename, 0);
        }
    }
    /**
     * é€é¡µæŠ½æ–‡æœ¬ã€‚用 {@code setSortByPosition(true)} è®©é˜…读顺序按版面坐标排,
     * å¦åˆ™è¡¨æ ¼é‡Œçš„æ–‡å­—会按 PDF å†…部绘制顺序乱序抽出,模型看到的列就错位了。
     */
    private List<String> extractPageTexts(PDDocument document, int pageCount) throws IOException {
        List<String> pageTexts = new ArrayList<>(pageCount);
        for (int i = 0; i < pageCount; i++) {
            PDFTextStripper stripper = new PDFTextStripper();
            stripper.setSortByPosition(true);
            stripper.setStartPage(i + 1);
            stripper.setEndPage(i + 1);
            pageTexts.add(stripper.getText(document).trim());
        }
        return pageTexts;
    }
    private String joinPages(List<String> pageTexts) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < pageTexts.size(); i++) {
            if (i > 0) {
                sb.append("\n--- ç¬¬ ").append(i + 1).append(" é¡µ ---\n");
            }
            sb.append(pageTexts.get(i));
        }
        return sb.toString();
    }
    /**
     * é€é¡µæ¸²æŸ“成 PNG。
     * <p>
     * æ¸²æŸ“失败不吞:回落去试文本通道,两条路都不通才报「未解析出任何可用内容」。
     * ä¹‹æ‰€ä»¥è¦å›žè½ï¼Œæ˜¯å› ä¸ºã€Œå­—符数低于阈值」可能只是文件用的字体没带 ToUnicode è¡¨
     * ï¼ˆæŠ½å‡ºæ¥æ˜¯ä¹±ç æˆ–空),但渲染本身还是好的——不过那种情况下回落取到的文本也是空的,
     * æ‰€ä»¥çœŸæ­£ä¼šè½åˆ° {@link #extractPageTexts} çš„场景是渲染器对这个文件不工作。
     */
    private QcReportDocumentExtract renderPages(PDDocument document, int pageCount,
                                                String originalFilename, List<String> notes) {
        List<QcReportDocumentExtract.ImagePart> images = new ArrayList<>(pageCount);
        try {
            PDFRenderer renderer = new PDFRenderer(document);
            for (int i = 0; i < pageCount; i++) {
                BufferedImage image = renderer.renderImageWithDPI(i, properties.getRenderDpi(), ImageType.RGB);
                ByteArrayOutputStream out = new ByteArrayOutputStream();
                ImageIO.write(image, "png", out);
                images.add(new QcReportDocumentExtract.ImagePart(
                        out.toByteArray(), "image/png", "第 " + (i + 1) + " é¡µ"));
            }
            return QcReportDocumentExtract.ofImages(images, pageCount, "pdfbox-render", notes);
        } catch (IOException | RuntimeException e) {
            log.warn("PDF æ¸²æŸ“扫描页失败,尝试回落文本通道,file={}", originalFilename, e);
            try {
                List<String> pageTexts = extractPageTexts(document, pageCount);
                if (pageTexts.stream().anyMatch(t -> !t.isBlank())) {
                    List<String> fallbackNotes = new ArrayList<>(notes);
                    fallbackNotes.add("文件「" + originalFilename + "」的页面无法渲染成图片,已改为读取其文本层");
                    return QcReportDocumentExtract.ofText(joinPages(pageTexts), pageCount,
                            "pdfbox-text-fallback", fallbackNotes);
                }
            } catch (IOException ignored) {
                // å›žè½ä¹Ÿè¯»ä¸å‡ºæ¥ï¼Œèµ°ä¸‹é¢çš„统一报错
            }
            throw exception(AI_IMPORT_FILE_EMPTY, originalFilename, pageCount);
        }
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/aiimport/document/QcReportDocumentExtract.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,83 @@
package cn.iocoder.yudao.module.qcreport.service.aiimport.document;
import java.util.List;
/**
 * ä¸€ä¸ªæ–‡ä»¶è¢«é€‚配器抽取后的中间结果。
 * <p>
 * <b>刻意不是树、也不是 HTML</b>:它只是「这份文件能交给模型的东西是什么」这个更小的问题的答案。
 * æ ‘要表达版式,HTML è¦è¡¨è¾¾æ ·å¼ï¼Œä¸¤è€…都会把「模型能看见什么」放大到不可控;这里只留两个通道,
 * è¿«ä½¿ä¸‹æ¸¸ï¼ˆæç¤ºè¯æž„造、调用计划)不必再考虑第三种形态。
 *
 * <h3>为什么是双通道而不是一种</h3>
 * ç”µå­ç‰ˆ PDF / Word / Excel æœ‰çœŸå®žæ–‡æœ¬å±‚,抽取成字符串最省 token,模型也读得最准;
 * æ‰«æä»¶ä¸Žç…§ç‰‡æ²¡æœ‰æ–‡æœ¬å±‚,只能把页面渲染成图交给多模态模型。
 * è¿™ä¸¤æ¡è·¯çš„调用方式({@code chat} ä¸Ž {@code chatWithImage})、计费口径、失败模式都不同,
 * æ‰€ä»¥å¿…须在类型上分开,而不是塞进一个字段里靠猜。
 *
 * @param channel   é€šé“类型,见 {@link Channel}
 * @param text      TEXT é€šé“的正文,多页时页间以 {@code --- ç¬¬ N é¡µ ---} åˆ†éš”ï¼›IMAGES é€šé“恒为空串
 * @param images    IMAGES é€šé“的图片,每页一张;TEXT é€šé“恒为空列表
 * @param pageCount é¡µæ•°ï¼Œç”¨äºŽè¶…限校验与提示文案
 * @param extractor å®žé™…生效的抽取器标识(如 {@code pdfbox-text}),会写进日志便于排查
 * @param notes     æŠ½å–阶段的软提示,直接进响应的 warnings å±•示给用户
 */
public record QcReportDocumentExtract(
        Channel channel,
        String text,
        List<ImagePart> images,
        int pageCount,
        String extractor,
        List<String> notes) {
    /**
     * çºµå‘合并续格的占位标记。
     * <p>
     * å®ƒå±žäºŽã€ŒæŠ½å–出来的文本长什么样」这条契约,所以和 {@code text} æ”¾åœ¨ä¸€å¤„,
     * ç”±ç”Ÿäº§è€…({@link OfficeImportAdapter})与解释者({@code QcReportTemplatePromptBuilder})
     * å…±ç”¨ä¸€ä»½ï¼Œé¿å…ä¸¤è¾¹å„写一个字面量后各自漂移。
     * <p>
     * ä¸ºä»€ä¹ˆä¸èƒ½ç•™ç©ºï¼š{@code w:vMerge} çš„续格在 Word é‡Œè¡¨ç¤ºã€Œè¿™ä¸€æ ¼ä¸Žä¸Šä¸€è¡Œæ˜¯åŒä¸€ä¸ªå€¼ã€ï¼Œ
     * ä½† POI å–出来就是空串。留空的话,模型区分不出「合并续格」与「原件本来就没填」,
     * äºŽæ˜¯æ•´å¼ è¡¨çœ‹èµ·æ¥æ˜¯ä¸€å±‚平铺的检验项,看不出分组结构。
     */
    public static final String VERTICAL_MERGE_MARK = "↑同上";
    /**
     * æŠ½å–通道。
     */
    public enum Channel {
        /** æœ‰æ–‡æœ¬å±‚,整份文件拼成一段文本,一次模型调用 */
        TEXT,
        /** æ— æ–‡æœ¬å±‚(扫描件/照片),逐页转图,每页一次模型调用 */
        IMAGES
    }
    /**
     * IMAGES é€šé“里的一页图。
     *
     * @param bytes    å›¾ç‰‡å­—节,执行器负责转成裸 base64
     * @param mimeType å½¢å¦‚ {@code image/png} çš„裸类型,不带 {@code data:} å‰ç¼€
     * @param label    é¡µæ ‡ç­¾ï¼Œç”¨äºŽæ‹¼é”™è¯¯ä¸Žæç¤ºæ–‡æ¡ˆï¼Œä¾‹å¦‚「扫描件.pdf ç¬¬ 2 é¡µã€
     */
    public record ImagePart(byte[] bytes, String mimeType, String label) {
    }
    /**
     * æ–‡æœ¬é€šé“的工厂。{@code notes} å…è®¸ä¸ºç©ºè¡¨ç¤ºæ²¡æœ‰è½¯æç¤ºã€‚
     */
    public static QcReportDocumentExtract ofText(String text, int pageCount, String extractor,
                                                 List<String> notes) {
        return new QcReportDocumentExtract(Channel.TEXT, text, List.of(), pageCount, extractor, notes);
    }
    /**
     * å›¾ç‰‡é€šé“的工厂。
     */
    public static QcReportDocumentExtract ofImages(List<ImagePart> images, int pageCount,
                                                   String extractor, List<String> notes) {
        return new QcReportDocumentExtract(Channel.IMAGES, "", List.copyOf(images), pageCount,
                extractor, notes);
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/aiimport/document/QcReportDocumentExtractService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,52 @@
package cn.iocoder.yudao.module.qcreport.service.aiimport.document;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.List;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_FILE_UNSUPPORTED;
/**
 * æŠ½å–门面:按固定顺序找第一个能处理该文件的适配器。
 * <p>
 * é¡ºåºç”±å„适配器的 {@code @Order} å†³å®šï¼ˆå›¾ç‰‡ â†’ PDF â†’ Office),不是「谁更合适」的择优,
 * è€Œæ˜¯æ¶ˆé™¤æ­§ä¹‰çš„定序。真实的歧义是 PDF:它既可能被 PDF é€‚配器认领,
 * ä¹Ÿå¯èƒ½å› ä¸º {@code contentType} ä¸ŠæŠ¥æˆ {@code application/pdf} ä¹‹å¤–的怪值而落到 Office çš„兜底判断上,
 * å®šåºä¹‹åŽè°å…ˆè°åŽæ˜¯ç¡®å®šçš„,不会随 Bean æ³¨å†Œé¡ºåºæ¼‚移。
 * <p>
 * åªåšåˆ†å‘,不做「读不出来就换个适配器再试」的兜底——每个适配器内部已经把该试的路径试过了
 * ï¼ˆPDF çš„æ¸²æŸ“失败会回落文本通道),在这里再叠一层只会让失败原因变得说不清。
 */
@Service
public class QcReportDocumentExtractService {
    @Resource
    private List<QcReportImportAdapter> adapters;
    /**
     * æŠ½å–文件内容。
     *
     * @param content          æ–‡ä»¶å­—节
     * @param originalFilename åŽŸå§‹æ–‡ä»¶åï¼ˆå«æ‰©å±•åï¼‰
     * @param contentType      ä¸Šä¼ æ—¶è®°å½•çš„ MIME ç±»åž‹ï¼Œå¯èƒ½ä¸º null
     * @throws cn.iocoder.yudao.framework.common.exception.ServiceException æ²¡æœ‰é€‚配器认领该格式时
     */
    public QcReportDocumentExtract extract(byte[] content, String originalFilename, String contentType) {
        String extension = extensionOf(originalFilename);
        for (QcReportImportAdapter adapter : adapters) {
            if (adapter.supports(extension, contentType)) {
                return adapter.extract(content, originalFilename);
            }
        }
        throw exception(AI_IMPORT_FILE_UNSUPPORTED, originalFilename,
                extension.isBlank() ? "无" : "." + extension);
    }
    private static String extensionOf(String filename) {
        int dot = filename == null ? -1 : filename.lastIndexOf('.');
        return dot < 0 ? "" : filename.substring(dot + 1).toLowerCase();
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/aiimport/document/QcReportImportAdapter.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,36 @@
package cn.iocoder.yudao.module.qcreport.service.aiimport.document;
/**
 * ä¸€ç§è¾“入格式的抽取器。
 * <p>
 * ä¹‹æ‰€ä»¥åšé€‚配器而不是在一个大类里 {@code switch (extension)}:三类输入的抽法毫无共同点
 * ï¼ˆå›¾ç‰‡ä»€ä¹ˆéƒ½ä¸ç”¨åšã€PDF è¦å…ˆåˆ¤æ–­æœ‰æ²¡æœ‰æ–‡æœ¬å±‚、Office è¦æŒ‰æ ¼å¼è§£æžè¡¨æ ¼ï¼‰ï¼Œ
 * å¡žè¿›ä¸€ä¸ªç±»ä¼šå¾—到一个既读不了也改不动的分支塔。
 * <p>
 * å®žçŽ°æŒ‰ {@code @Order} å›ºå®šé¡ºåºæŽ’列,门面取<b>第一个</b> {@link #supports} ä¸ºçœŸçš„——
 * é¡ºåºå›ºå®šæ˜¯ä¸ºäº†æ¶ˆé™¤æ­§ä¹‰ï¼ˆPDF å¯èƒ½åŒæ—¶è¢« PDF é€‚配器和 Office é€‚配器「认领」),
 * è€Œä¸æ˜¯ä¸ºäº†æ‹©ä¼˜ã€‚
 */
public interface QcReportImportAdapter {
    /**
     * æ˜¯å¦èƒ½å¤„理这个文件。
     *
     * @param extension å°å†™ã€åŽ»ç‚¹çš„æ‰©å±•åï¼Œå¦‚ {@code pdf};没有扩展名时为空串
     * @param contentType æµè§ˆå™¨ä¸ŠæŠ¥çš„ MIME ç±»åž‹ï¼Œå¯èƒ½ä¸º null(本地文件常见)
     */
    boolean supports(String extension, String contentType);
    /**
     * æŠ½å–。
     * <p>
     * ä¸åšã€Œè¿”回 null è¡¨ç¤ºå¤±è´¥ã€è¿™ç§çº¦å®šï¼šè¯»ä¸å‡ºå†…容、格式不支持、页数超限都是**确定的失败原因**,
     * å„自对应一个错误码,让用户知道下一步该做什么(换个格式 / æ‹†é¡µ / é‡æ–°æ‰«æï¼‰ã€‚
     *
     * @param content          æ–‡ä»¶å­—节
     * @param originalFilename åŽŸå§‹æ–‡ä»¶åï¼Œç”¨äºŽé”™è¯¯ä¸Žæç¤ºæ–‡æ¡ˆ
     * @return æŠ½å–结果
     */
    QcReportDocumentExtract extract(byte[] content, String originalFilename);
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/aiimport/llm/QcReportAiDraftNormalizer.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,227 @@
package cn.iocoder.yudao.module.qcreport.service.aiimport.llm;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import cn.iocoder.yudao.module.qcreport.controller.admin.aiimport.vo.QcReportAiDraftRespVO;
import cn.iocoder.yudao.module.qcreport.controller.admin.aiimport.vo.QcReportComponentSpecVO;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
/**
 * æŠŠæ¨¡åž‹è§£æžå‡ºæ¥çš„草稿归一成「只含合法组件、合法属性」的清单。
 * <p>
 * ä¸‰çº§é˜²çº¿çš„第三级,也是既有范式(ERP / CRM / MES çš„ AI è°ƒç”¨ï¼‰<b>没有</b>的一段。
 * é‚£ä¸‰å¤„是扁平单层结构、字段写死在代码里,错了就是错了;这次要喂给不可信的模型去产出嵌套结构,
 * å°‘这一层就会出现「模型编了个组件类型,前端装配器跳过它,用户看到的画布和预览对不上」。
 *
 * <h3>原则:单个元素坏掉不能让整个请求失败</h3>
 * åå…ƒç´ è¿› warnings、其余的照常返回。整体失败只在服务层判断(最终一项都不剩 â†’ DRAFT_EMPTY),
 * å› ä¸ºã€Œå¤§éƒ¨åˆ†è¯†åˆ«å¯¹äº†ã€å’Œã€Œå…¨éƒ½æ²¡è¯†åˆ«å‡ºæ¥ã€å¯¹ç”¨æˆ·çš„含义完全不同。
 *
 * <h3>为什么后端也要过滤一遍(前端装配器已经过滤了)</h3>
 * å‰ç«¯è¿‡æ»¤å‘生在装配那一刻,用户看到的是「我上传了 5 ä¸ªç»„件,画布上只出来 3 ä¸ªã€â€”—没有解释。
 * åŽç«¯åœ¨é¢„览阶段就把「哪一项被丢了、为什么」讲出来,用户才知道该补什么。
 * è¿™ä¸æ˜¯é‡å¤é˜²çº¿ï¼Œæ˜¯æŠŠé™é»˜å¤±è´¥å˜æˆå¯è§å¤±è´¥ã€‚
 */
public class QcReportAiDraftNormalizer {
    private QcReportAiDraftNormalizer() {
    }
    /**
     * å½’一结果。
     *
     * @param components è¿‡æ»¤åŽçš„组件,可能为空(空的最终由服务层报 DRAFT_EMPTY)
     * @param warnings   éœ€è¦å±•示给用户的软提示
     */
    public record Result(List<QcReportAiDraftRespVO.DraftComponent> components, List<String> warnings) {
    }
    /**
     * å½’一一次模型回复得到的组件。
     *
     * @param raw         è§£æžå‡ºæ¥çš„原始组件,允许含 null å…ƒç´ 
     * @param catalog     å¯ç”¨ç»„件清单,决定哪些 type ä¸Ž prop key åˆæ³•
     * @param sourceLabel æ¥æºæ ‡ç­¾ï¼ˆæ–‡ä»¶å / æ–‡ä»¶å+页码),会拼进提示文案
     */
    public static Result normalize(List<QcReportAiDraftRespVO.DraftComponent> raw,
                                   List<QcReportComponentSpecVO> catalog,
                                   String sourceLabel) {
        Map<String, QcReportComponentSpecVO> specByType = new TreeMap<>();
        for (QcReportComponentSpecVO spec : catalog) {
            if (spec != null && StrUtil.isNotBlank(spec.getType())) {
                specByType.put(spec.getType(), spec);
            }
        }
        List<QcReportAiDraftRespVO.DraftComponent> kept = new ArrayList<>();
        List<String> warnings = new ArrayList<>();
        String prefix = StrUtil.isBlank(sourceLabel) ? "" : sourceLabel + ":";
        if (raw == null) {
            return new Result(kept, warnings);
        }
        for (int i = 0; i < raw.size(); i++) {
            QcReportAiDraftRespVO.DraftComponent item = raw.get(i);
            int no = i + 1;
            if (item == null) {
                warnings.add(prefix + "第 " + no + " é¡¹ä¸æ˜¯ä¸€ä¸ªæœ‰æ•ˆçš„组件对象,已忽略");
                continue;
            }
            String type = item.getType();
            if (StrUtil.isBlank(type)) {
                warnings.add(prefix + "第 " + no + " é¡¹ç¼ºå°‘组件类型,已忽略");
                continue;
            }
            QcReportComponentSpecVO spec = specByType.get(type);
            if (spec == null) {
                warnings.add(prefix + "AI è¾“出了组件清单外的组件类型「" + type + "」,已忽略");
                continue;
            }
            Map<String, Object> props = item.getProps();
            if (props == null || props.isEmpty()) {
                warnings.add(prefix + "组件「" + displayName(spec) + "」未识别出任何属性,已忽略");
                continue;
            }
            Map<String, Object> filtered = filterProps(props, spec, prefix, warnings);
            if (filtered.isEmpty()) {
                warnings.add(prefix + "组件「" + displayName(spec)
                        + "」的属性都不在清单声明范围内,已忽略");
                continue;
            }
            warnMissingRequired(filtered, spec, prefix, warnings);
            QcReportAiDraftRespVO.DraftComponent draft = new QcReportAiDraftRespVO.DraftComponent();
            draft.setType(type);
            draft.setProps(filtered);
            kept.add(draft);
        }
        return new Result(kept, warnings);
    }
    /**
     * åˆå¹¶å¤šæ¬¡è°ƒç”¨çš„结果并去重,最后按上限截断。
     * <p>
     * åŽ»é‡æ˜¯å¤šé¡µæ‰«æä»¶çš„å¿…éœ€å“ï¼šé¡µçœ‰ã€æŠ¥å‘Šæ ‡é¢˜ã€è¡¨å¤´åœ¨æ¯ä¸€é¡µéƒ½ä¼šå‡ºçŽ°ï¼Œé€é¡µè¯†åˆ«å°±ä¼šå¾—åˆ° N ä»½ï¼Œ
     * ä¸åŽ»é‡çš„è¯ç”»å¸ƒä¸Šä¼šæœ‰ 5 ä¸ªä¸€æ ·çš„æŠ¬å¤´ã€‚判定同一是
     * {@code (type, props çš„稳定 JSON)}——props å…ˆæŒ‰ key æŽ’序,因为不同页返回的 key é¡ºåº
     * ä¸ä¿è¯ä¸€è‡´ï¼Œç›´æŽ¥æ¯”原始顺序会把同一个组件当成两个。
     *
     * @param perCallResults å„次调用归一后的结果,按执行顺序
     * @param maxComponents  ç»„件总数上限,超出截断
     */
    public static Result mergeAndDedup(List<Result> perCallResults, int maxComponents) {
        List<QcReportAiDraftRespVO.DraftComponent> merged = new ArrayList<>();
        Set<String> seen = new LinkedHashSet<>();
        for (Result result : perCallResults) {
            for (QcReportAiDraftRespVO.DraftComponent component : result.components()) {
                if (seen.add(dedupKey(component))) {
                    merged.add(component);
                }
            }
        }
        int total = merged.size();
        List<String> warnings = new ArrayList<>();
        for (Result result : perCallResults) {
            warnings.addAll(result.warnings());
        }
        if (perCallResults.size() > 1 && total < countRaw(perCallResults)) {
            warnings.add(StrUtil.format(
                    "多页识别共得到 {} é¡¹ç»„件,其中有 {} é¡¹åœ¨å…¶å®ƒé¡µå·²å‡ºçŽ°ï¼ˆé¡µçœ‰ã€æ ‡é¢˜ã€è¡¨å¤´ç­‰é‡å¤å†…å®¹ï¼‰ï¼Œå·²åˆå¹¶",
                    countRaw(perCallResults), countRaw(perCallResults) - total));
        }
        if (maxComponents > 0 && total > maxComponents) {
            warnings.add(StrUtil.format("识别出的组件共 {} é¡¹ï¼Œè¶…过单次上限 {} é¡¹ï¼Œå·²ä¿ç•™å‰ {} é¡¹ï¼Œå…¶ä½™è¯·æ‰‹å·¥è¡¥å……",
                    total, maxComponents, maxComponents));
            merged = new ArrayList<>(merged.subList(0, maxComponents));
        }
        return new Result(merged, warnings);
    }
    private static int countRaw(List<Result> results) {
        return results.stream().mapToInt(r -> r.components().size()).sum();
    }
    /**
     * ä¸¢æŽ‰æ¸…单未声明的 prop key。
     * <p>
     * {@code fields} ä¸º <b>null</b>(清单压根没声明)与为 <b>空列表</b>(明确声明「这个组件不吃任何属性」)
     * æ˜¯ä¸¤å›žäº‹ï¼šå‰è€…无从判断,原样放行;后者才是真的要一个个丢掉。
     * æ··ä¸ºä¸€è°ˆä¼šåœ¨å‰ç«¯æ¼ä¼  fields æ—¶æŠŠç”¨æˆ·è¯†åˆ«å‡ºæ¥çš„属性静默抹平。
     */
    private static Map<String, Object> filterProps(Map<String, Object> props, QcReportComponentSpecVO spec,
                                                   String prefix, List<String> warnings) {
        if (spec.getFields() == null) {
            return props;
        }
        Set<String> allowed = new LinkedHashSet<>();
        for (QcReportComponentSpecVO.Field field : spec.getFields()) {
            if (field != null && StrUtil.isNotBlank(field.getKey())) {
                allowed.add(field.getKey());
            }
        }
        Map<String, Object> filtered = new TreeMap<>();
        List<String> dropped = new ArrayList<>();
        for (Map.Entry<String, Object> entry : props.entrySet()) {
            if (allowed.contains(entry.getKey())) {
                filtered.put(entry.getKey(), entry.getValue());
            } else {
                dropped.add(entry.getKey());
            }
        }
        if (!dropped.isEmpty()) {
            warnings.add(prefix + "组件「" + displayName(spec) + "」中不属于该组件的属性 "
                    + String.join("、", dropped) + " å·²ä¸¢å¼ƒ");
        }
        return filtered;
    }
    private static void warnMissingRequired(Map<String, Object> props, QcReportComponentSpecVO spec,
                                            String prefix, List<String> warnings) {
        if (spec.getFields() == null) {
            return;
        }
        for (QcReportComponentSpecVO.Field field : spec.getFields()) {
            if (field == null || !Boolean.TRUE.equals(field.getRequired())) {
                continue;
            }
            if (isBlankValue(props.get(field.getKey()))) {
                warnings.add(prefix + "组件「" + displayName(spec) + "」缺少必填属性「"
                        + fieldLabel(field) + "」,请在设计器属性面板中补上后再保存");
            }
        }
    }
    private static boolean isBlankValue(Object value) {
        if (value == null) {
            return true;
        }
        if (value instanceof CharSequence cs) {
            return StrUtil.isBlank(cs.toString());
        }
        return value instanceof Collection<?> collection && collection.isEmpty();
    }
    /**
     * åŽ»é‡é”®ï¼štype + props çš„稳定序列化。
     * <p>
     * props å…ˆæŒ‰ key æŽ’序再用 {@link TreeMap},保证「同样的组件、不同的 key é¡ºåºã€ä¹Ÿèƒ½åˆ¤ä¸ºåŒä¸€ä¸ªã€‚
     */
    private static String dedupKey(QcReportAiDraftRespVO.DraftComponent component) {
        Map<String, Object> props = component.getProps();
        return component.getType() + "" + JSONUtil.toJsonStr(props == null ? Map.of() : new TreeMap<>(props));
    }
    private static String displayName(QcReportComponentSpecVO spec) {
        return StrUtil.isNotBlank(spec.getLabel()) ? spec.getLabel() : spec.getType();
    }
    private static String fieldLabel(QcReportComponentSpecVO.Field field) {
        return StrUtil.isNotBlank(field.getLabel()) ? field.getLabel() : field.getKey();
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/aiimport/llm/QcReportAiDraftParser.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,140 @@
package cn.iocoder.yudao.module.qcreport.service.aiimport.llm;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import cn.iocoder.yudao.module.qcreport.controller.admin.aiimport.vo.QcReportAiDraftRespVO;
import cn.iocoder.yudao.module.qcreport.dal.dataobject.version.ReportTemplateSchema;
import java.util.ArrayList;
import java.util.List;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_RESPONSE_UNPARSEABLE;
/**
 * æŠŠæ¨¡åž‹å›žå¤çš„字符串解析成草稿。
 * <p>
 * ä¸‰çº§é˜²çº¿é‡Œçš„第一、二级:<b>切片</b>(拿第一个 {@code &#123;} åˆ°æœ€åŽä¸€ä¸ª {@code &#125;},
 * å¤©ç„¶å¤„理 markdown å›´æ ä¸Žå‰åŽå¯’暄)+ <b>反序列化</b>。
 * ç¬¬ä¸‰çº§ã€Œå½’一」在 {@link QcReportAiDraftNormalizer}。
 * <p>
 * ç”¨å­—符串切片而不是让模型输出严格 JSON,是因为本项目从 {@code -api} å¤Ÿä¸ç€ Spring AI çš„
 * {@code BeanOutputConverter}({@code ChatClient} / {@code ChatModel} éƒ½åœ¨ {@code yudao-module-ai}
 * å†…部),只能走这条路——这是既有范式,不是偷懒。
 * <p>
 * <b>这一层不做任何过滤</b>:模型给什么就解析出什么,合法性判断全部留给归一器,
 * è¿™æ ·ã€Œè§£æžå¤±è´¥ã€ä¸Žã€Œè§£æžæˆåŠŸä½†å†…å®¹ä¸åˆè§„ã€æ˜¯ä¸¤ä»¶å¯åˆ†è¾¨çš„äº‹ï¼Œé”™è¯¯ç ä¹Ÿæ‰åˆ†å¾—å¼€ã€‚
 */
public class QcReportAiDraftParser {
    /** å›žå¡« {@code rawText} æ—¶çš„æˆªæ–­é•¿åº¦ï¼šå¤ŸæŽ’查,又不会把整段回复灌进响应体 */
    private static final int RAW_TEXT_LIMIT = 4000;
    private QcReportAiDraftParser() {
    }
    /**
     * è§£æžæ¨¡åž‹å›žå¤ã€‚
     *
     * @param aiResponse æ¨¡åž‹åŽŸå§‹å›žå¤
     * @return æœªè¿‡æ»¤çš„草稿,至少 components å­—段非 null
     * @throws cn.iocoder.yudao.framework.common.exception.ServiceException å®Œå…¨è§£æžä¸å‡º JSON å¯¹è±¡æ—¶
     */
    public static QcReportAiDraftRespVO parse(String aiResponse) {
        String sliced = sliceJsonObject(aiResponse);
        JSONObject json;
        try {
            json = JSONUtil.parseObj(sliced);
        } catch (Exception e) {
            throw exception(AI_IMPORT_RESPONSE_UNPARSEABLE, StrUtil.length(aiResponse));
        }
        QcReportAiDraftRespVO resp = new QcReportAiDraftRespVO();
        resp.setSummary(json.getStr("summary"));
        resp.setComponents(readComponents(json));
        resp.setPage(readPage(json));
        return resp;
    }
    /**
     * æ•´ä½“解析失败时回填给前端的截断原文,便于排查模型到底返回了什么。
     */
    public static String truncate(String raw) {
        if (raw == null) {
            return null;
        }
        return raw.length() <= RAW_TEXT_LIMIT ? raw : raw.substring(0, RAW_TEXT_LIMIT) + "…(已截断)";
    }
    /**
     * å–第一个 <code>{</code> åˆ°æœ€åŽä¸€ä¸ª <code>}</code>。
     */
    private static String sliceJsonObject(String aiResponse) {
        if (StrUtil.isBlank(aiResponse)) {
            throw exception(AI_IMPORT_RESPONSE_UNPARSEABLE, 0);
        }
        int start = aiResponse.indexOf('{');
        int end = aiResponse.lastIndexOf('}');
        if (start < 0 || end <= start) {
            throw exception(AI_IMPORT_RESPONSE_UNPARSEABLE, aiResponse.length());
        }
        return aiResponse.substring(start, end + 1);
    }
    /**
     * è¯» components æ•°ç»„。
     * <p>
     * å®¹å¿æ¨¡åž‹æŠŠæ•°ç»„二次编码成字符串(「回复里套一层 JSON å­—符串」是常见失败模式),
     * å› ä¸ºå®ƒä¾¿å®œä¸”无损;除此之外的任何形状都只是「这一项没有」,不在这里抛错——
     * ç©ºæ•°ç»„最终由服务层的 {@code AI_IMPORT_DRAFT_EMPTY} æŠ¥å‡ºï¼Œæ¯”在这里报错文案更贴切。
     */
    private static List<QcReportAiDraftRespVO.DraftComponent> readComponents(JSONObject json) {
        Object raw = json.get("components");
        JSONArray array = null;
        if (raw instanceof CharSequence cs && StrUtil.isNotBlank(cs)) {
            try {
                array = JSONUtil.parseArray(cs.toString());
            } catch (Exception ignored) {
                // è§£æžä¸å‡ºæ¥å°±æŒ‰ã€Œæ²¡æœ‰ç»„件」处理,最终由 DRAFT_EMPTY ç»Ÿä¸€æŠ¥å‡º
            }
        } else if (raw instanceof JSONArray ja) {
            array = ja;
        }
        if (array == null) {
            return new ArrayList<>();
        }
        List<QcReportAiDraftRespVO.DraftComponent> components = new ArrayList<>(array.size());
        for (Object item : array) {
            if (item instanceof JSONObject obj) {
                components.add(obj.toBean(QcReportAiDraftRespVO.DraftComponent.class));
            }
        }
        return components;
    }
    /**
     * è¯» page。模型只给 size æˆ–只给 orientation éƒ½ç®—数,缺的部分让设计器按当前模板走。
     */
    private static ReportTemplateSchema.Page readPage(JSONObject json) {
        Object raw = json.get("page");
        if (!(raw instanceof JSONObject pageObj)) {
            return null;
        }
        ReportTemplateSchema.Page page = new ReportTemplateSchema.Page();
        page.setSize(pageObj.getStr("size"));
        page.setOrientation(pageObj.getStr("orientation"));
        Object margin = pageObj.get("margin");
        if (margin instanceof JSONObject marginObj) {
            ReportTemplateSchema.Margin m = new ReportTemplateSchema.Margin();
            m.setTop(marginObj.getDouble("top"));
            m.setRight(marginObj.getDouble("right"));
            m.setBottom(marginObj.getDouble("bottom"));
            m.setLeft(marginObj.getDouble("left"));
            page.setMargin(m);
        }
        return (page.getSize() == null && page.getOrientation() == null && page.getMargin() == null)
                ? null : page;
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/aiimport/llm/QcReportLlmCall.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,44 @@
package cn.iocoder.yudao.module.qcreport.service.aiimport.llm;
/**
 * ä¸€æ¬¡å¾…执行的大模型调用。
 * <p>
 * ç”± {@link QcReportLlmCallPlanner} çº¯å‡½æ•°ç®—出,交给薄执行器真正发出去。
 * æ‹†æˆã€Œå…ˆç®—计划、再执行」是为了让多页 / å¤šæ–‡ä»¶ / è¶…预算这些控制流可以被单测完整覆盖——
 * å¦åˆ™åªèƒ½é  mock æŽ‰ {@code AiChatApi} æ‰èƒ½æµ‹ï¼Œé‚£æµ‹çš„æ˜¯ mock ä¸æ˜¯é€»è¾‘。
 * <p>
 * æ¯ä¸ªè°ƒç”¨è‡ªå¸¦å…¨éƒ¨å…¥å‚(含 TEXT é€šé“的正文),执行器不需要回看抽取结果。
 *
 * @param kind        è°ƒç”¨æ–¹å¼ï¼Œå†³å®šèµ° {@code chat} è¿˜æ˜¯ {@code chatWithImage}
 * @param sourceLabel æ¥æºæ ‡ç­¾ï¼Œå½¢å¦‚「扫描件.pdf ç¬¬ 2 é¡µã€ï¼Œç”¨äºŽè½¯æç¤ºä¸Žé”™è¯¯æ–‡æ¡ˆ
 * @param text        TEXT é€šé“的正文;IMAGE é€šé“恒为空串
 * @param imageBase64 IMAGE é€šé“çš„**裸 base64**(不含 {@code data:image/png;base64,} å‰ç¼€ï¼Œ
 *                    å› ä¸º {@code AiChatApiImpl} ç›´æŽ¥å¯¹å®ƒåš Base64 è§£ç ï¼‰ï¼›TEXT é€šé“恒为空串
 * @param mimeType    IMAGE é€šé“的裸 MIME ç±»åž‹ï¼Œå¦‚ {@code image/png}
 */
public record QcReportLlmCall(
        Kind kind,
        String sourceLabel,
        String text,
        String imageBase64,
        String mimeType) {
    /**
     * è°ƒç”¨æ–¹å¼ã€‚
     */
    public enum Kind {
        /** çº¯æ–‡æœ¬ï¼Œä¸€æ¬¡è°ƒç”¨æžå®šæ•´ä»½æ–‡ä»¶ */
        TEXT,
        /** å¤šæ¨¡æ€ï¼Œä¸€æ¬¡è°ƒç”¨åªè®¤ä¸€å¼ å›¾ï¼Œæ‰€ä»¥é€é¡µä¸€è°ƒ */
        IMAGE
    }
    public static QcReportLlmCall ofText(String sourceLabel, String text) {
        return new QcReportLlmCall(Kind.TEXT, sourceLabel, text, "", "");
    }
    public static QcReportLlmCall ofImage(String sourceLabel, String imageBase64, String mimeType) {
        return new QcReportLlmCall(Kind.IMAGE, sourceLabel, "", imageBase64, mimeType);
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/aiimport/llm/QcReportLlmCallPlanner.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,47 @@
package cn.iocoder.yudao.module.qcreport.service.aiimport.llm;
import cn.iocoder.yudao.module.qcreport.service.aiimport.document.QcReportDocumentExtract;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
/**
 * æŠŠæŠ½å–结果翻译成「该发几次模型调用、每次发什么」。
 * <p>
 * çº¯å‡½æ•°ï¼šæ²¡æœ‰ IO、没有 Spring、没有 {@code AiChatApi}。多页 / å¤šæ–‡ä»¶ / è¶…预算这些控制流
 * å› æ­¤èƒ½åœ¨å•测里被完整覆盖,而不必 mock æŽ‰çœŸæ­£çš„外部依赖。
 */
public class QcReportLlmCallPlanner {
    private QcReportLlmCallPlanner() {
    }
    /**
     * ä¸ºä¸€ä¸ªæ–‡ä»¶æŽ’调用计划。
     * <p>
     * TEXT é€šé“整份文件一次调用;IMAGES é€šé“逐页一次——多模态接口一次只收一张图,
     * è¿™æ˜¯æŽ¥å£çº¦æŸè€Œä¸æ˜¯å–舍。
     * <p>
     * è¿™é‡Œ**不做页数上限判断**:超限由适配器在抽取阶段就抛错,因为静默截断会让用户
     * ä»¥ä¸ºæ•´ä»½æ–‡ä»¶éƒ½è¯†åˆ«è¿‡äº†ï¼Œæ¯”直接报错危险得多。
     *
     * @param extract     æŠ½å–结果
     * @param sourceLabel æ¥æºæ ‡ç­¾ï¼Œé€šå¸¸æ˜¯åŽŸå§‹æ–‡ä»¶åï¼Œä¼šæ‹¼è¿›æ¯é¡µçš„æ ‡ç­¾é‡Œ
     * @return æŒ‰æ‰§è¡Œé¡ºåºæŽ’列的调用列表,至少一项
     */
    public static List<QcReportLlmCall> plan(QcReportDocumentExtract extract, String sourceLabel) {
        if (extract.channel() == QcReportDocumentExtract.Channel.TEXT) {
            return List.of(QcReportLlmCall.ofText(sourceLabel, extract.text()));
        }
        List<QcReportLlmCall> calls = new ArrayList<>(extract.images().size());
        for (QcReportDocumentExtract.ImagePart image : extract.images()) {
            calls.add(QcReportLlmCall.ofImage(
                    sourceLabel + " " + image.label(),
                    Base64.getEncoder().encodeToString(image.bytes()),
                    image.mimeType()));
        }
        return calls;
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/aiimport/llm/QcReportTemplatePromptBuilder.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,247 @@
package cn.iocoder.yudao.module.qcreport.service.aiimport.llm;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import cn.iocoder.yudao.module.qcreport.controller.admin.aiimport.vo.QcReportComponentSpecVO;
import cn.iocoder.yudao.module.qcreport.engine.context.ReportFields;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.List;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_CATALOG_INVALID;
/**
 * æ‹¼ã€Œæ–‡ä»¶ â†’ æ¨¡æ¿è‰ç¨¿ã€çš„æç¤ºè¯ã€‚
 * <p>
 * çº¯å‡½æ•°ï¼šåŒæ ·çš„入参永远给同样的字符串,因此提示词里该有的约束是否在场可以被单测逐条断言,
 * è€Œä¸å¿…真的调一次模型去「感觉一下」。
 *
 * <h3>为什么积木清单是入参而不是常量</h3>
 * ç»„件注册表的唯一真相来源在前端({@code components/quality/index.ts})。后端硬编码一份
 * å°±æ˜¯ç¬¬äºŒä¸ªçœŸç›¸æ¥æºï¼Œä¸”是最坏的那种:前端加组件、改必填字段时前端立刻生效、后端副本静默过期,
 * æ¨¡åž‹éšå³ä¼šç¼–出注册表里根本不存在的 type。所以清单随请求传上来,这里只负责序列化进提示词。
 *
 * <h3>边界没有因此被削弱</h3>
 * æ¸…单进提示词后的唯一出口是「被模型抄成 JSON å­—符串」。真正把关的是前端装配器对着活注册表查
 * {@code getQualityComponent}:未注册的 type åœ¨é‚£è¾¹ä¼šè¢«è·³è¿‡ï¼Œäº§ä¸å‡ºä»»æ„ HTML。就算有人伪造一份
 * å« {@code CustomHtml} çš„æ¸…单,也只会在装配阶段被丢弃。
 */
public class QcReportTemplatePromptBuilder {
    /**
     * ç§¯æœ¨æ¸…单序列化后的长度上限。
     * <p>
     * æ¸…单是「模型能用的积木」的完整描述,正常十几项组件也就几 KB。超过 32KB è¯´æ˜Žè°ƒç”¨æ–¹ä¼ é”™äº†ä¸œè¥¿
     * ï¼ˆæ¯”如把整个注册表连同实现一起序列化上来),此时直接报错比让提示词悄悄撑爆上下文安全。
     */
    public static final int MAX_CATALOG_JSON_LENGTH = 32 * 1024;
    /**
     * é“å¾‹ã€‚这是整套设计的地基,不是「建议」——它把模型限制在「只能用已注册组件搭扁平结构」之内。
     */
    private static final String IRON_RULES = """
            ã€å¿…须遵守的硬性约束】
            1. ä½ åªèƒ½ä½¿ç”¨ã€Šå¯ç”¨ç»„件清单》里列出的 type,大小写敏感,一个字都不能改。
            2. ä¸¥ç¦è¾“出 HTML / CSS / script / style / è‡ªå®šä¹‰ div、table æ ‡ç­¾ã€‚你产出的不是网页,是组件清单。
            3. ç»“构必须完全扁平:一个 components æ•°ç»„,元素没有 children、没有嵌套。数组顺序即报告从上到下的顺序。
            4. æ¯ä¸ªç»„件只能填清单里为该 type å£°æ˜Žè¿‡çš„ props çš„ key。未声明的 key ä¼šè¢«ä¸¢å¼ƒï¼›
               ä¸ç¡®å®šçš„字段不要编造,整个 key çœç•¥å³å¯ã€‚
            5. æ— æ³•用清单组件表达的内容(自由排版的图形、非标准表格的具体数据行)请丢弃,
               å¹¶åœ¨ summary é‡Œè¯´æ˜Žä¸¢å¼ƒäº†ä»€ä¹ˆï¼Œä¸è¦ç”¨ HTML ç¡¬å‡‘。
            """;
    /**
     * æŠ½å–规则。前两条直接来自「语义层是刻意收窄的子集」这条边界。
     */
    private static final String EXTRACTION_RULES = """
            ã€æŠ½å–规则】
            1. åªä¿ç•™æŠ¥å‘Šçš„**结构**:标题、栏目、表头、字段名。文件里的**具体数据行不要写进模板**——
               å‡¡æ˜¯å¸¦æ£€éªŒé¡¹çš„表格组件,itemsPath ä¸€å¾‹å¡« "inspectionItems",
               çœŸå®žæ•°æ®åœ¨å‡ºä»¶æ—¶ç”±ä¸šåŠ¡å•æ®æä¾›ã€‚
            2. å½¢å¦‚ {{report.reportNo}}、{{report.conclusion}} çš„æ˜¯**数据绑定占位符**,请原样保留,
               ä¸è¦æ›¿æ¢æˆä½ åœ¨æ–‡ä»¶é‡Œçœ‹åˆ°çš„字面值。
            3. æ‰«æä»¶/照片可能有多页,页眉、标题、表头在多页重复出现时只保留一份。
            4. è®¤ä¸å‡ºçš„内容宁可省略也不要猜:省略只会少一个组件,猜错会让用户以为识别对了。
            5. æ¸…单里每一项的 hint å­—段写明该组件用来放什么、不要用来放什么,选型时以 hint ä¸ºå‡†ï¼š
               å‡¡æ˜¯æ¸…单里存在专门组件的内容,就不要退而用 Text / Heading è¿™ç±»é€šç”¨ç»„件凑数。
               æ–‡ä»¶é‡Œå¸¦ [页眉] / [页脚] æ ‡è®°çš„部分是原件每页重复的抬头与落款,同样按 hint åˆ¤æ–­ã€‚
            6. æŠ¥å‘Šæœ«å°¾çš„落款**签署行**(检验员、审核人、批准人、日期等栏目)不是检验数据,
               å³ä½¿å®ƒæŽ’在检验表格的最后一行也一样:请把它取出来放进 ReportFooter,与原件页脚合成一处,
               ä¸è¦å› ä¸ºã€Œå®ƒå¤¹åœ¨è¡¨æ ¼é‡Œã€å°±æŒ‰ç¬¬ 1 æ¡å½“成数据行丢掉。
               ä½†ç­¾ç½²è¡Œçš„人名与日期属于每一份报告各自的数据,不要写死原件上的那几个人——
               è¯·**保留栏目名、值留空**,写成「检验员:__________ 审核人:__________ 日期:__________」
               è¿™æ ·ç•™å‡ºå¡«å†™ä½å³å¯ï¼Œæ‰“印出来由人手填。
            7. åªä¿ç•™åŽŸä»¶é‡Œ**本来就有**的 {{...}} å ä½ç¬¦ï¼Œä¸è¦è‡ªå·±å‘明新的绑定键:
               æŠ¥å‘Šä¸Šä¸‹æ–‡é‡Œæœ‰å“ªäº›å­—段见上面【报告上下文的字段】,编出来的键渲染时取不到值,
               æŠ¥å‘Šä¸Šåªä¼šç•™ä¸€ç‰‡ç©ºç™½å’Œä¸€æ¡ã€Œç»‘定取不到值」的告警。
            8. è¡¨æ ¼é‡Œçš„「↑同上」不是内容,而是标注「这一格与**上一行是同一个值」——原件里它是一个
               çºµå‘合并的单元格。请把它当作「合并」来读,不要当成四个字的普通文字写进模板。
            9. ç”±æ­¤å¯ä»¥æ–­å®šï¼š**同一列里连续出现「↑同上」,这张表就是两层结构**——最上面那个值是
               æ£€éªŒé¡¹ç›®åï¼Œæ ‡äº†ã€Œâ†‘同上」的这几行都是它的子项。这不是猜测,是原件里真实存在的层级,
               å› è€Œå¿…须当成事实参与选型:清单里哪一种表格能表达「项目 + å­é¡¹ã€ä¸¤çº§ï¼Œå°±é€‰å“ªä¸€ç§
               ï¼ˆä»¥è¯¥ç»„ä»¶çš„ hint ä¸ºå‡†ï¼‰ã€‚只放得下项目名的单层检验表会把子项整条丢掉,
               è€Œå­é¡¹å¾€å¾€æ‰æ˜¯æ ‡å‡†çœŸæ­£è€ƒæ ¸çš„对象,丢掉等于报告失真。
               å“ªæ€•表里只有一部分项目带子项、另一部分不带,同样按两层结构处理。
            10. åŽŸä»¶çš„æ£€éªŒè¡¨**列比组件默认的多**时(例如还有「检测方法」「结论」「备注」列),
                ä¸è¦ä¸¢åˆ—、也不要为了凑列去换组件:把这张表的全部列写进该组件 columns å±žæ€§çš„值里。
                å€¼æ˜¯ä¸€è¡Œå­—符串,每列写作 `列标题=绑定表达式`,列与列之间用 `|` åˆ†éš”,例如:
                åºå·={{index}}|检验项目={{item.itemName}}|检测方法={{item.checkMethod}}|标准要求={{item.standardValue}}|实测值={{item.actualValue}}|单位={{item.unit}}|判定={{item.resultText}}
                åˆ—标题前加 `#` è¡¨ç¤ºè¿™ä¸€åˆ—要纵向合并跨住整组(分组表里放组名的那一列)。
                columns çš„绑定表达式**只允许用下面这些路径,不许发明新的**:
                {{index}} è¡Œåºå·ã€{{item.itemName}} æ£€éªŒé¡¹ç›®åã€{{item.group.childName}} å­é¡¹åã€
                {{item.checkMethod}} æ£€æµ‹æ–¹æ³•、{{item.requirement}} æ£€æµ‹è¦æ±‚、{{item.standardValue}} æ ‡å‡†å€¼ã€
                {{item.actualValue}} å®žæµ‹å€¼ã€{{item.unit}} å•位、{{item.resultText}} åˆ¤å®šç»“论、{{item.remark}} å¤‡æ³¨ã€‚
                åŽŸä»¶çš„åˆ—ä¸Žæ¸…å•é‡Œè¯¥ç»„ä»¶ hint å†™æ˜Žçš„默认列一致时**不要写 columns**,让它用默认列即可。
                ç»™**分组检验项表**写 columns æ—¶å¦æœ‰ä¸€æ¡ç¡¬è¦æ±‚:原件里放子项名的那一列
                ï¼ˆæ ‡äº†ã€Œâ†‘同上」的那几行旁边、写着「20目上」「40目上」这类子项名的列)
                **必须**保留,写成 `子项={{item.group.childName}}`,且**不要**加 `#`;
                åˆ†ç»„表的默认列里本来就有这一列,把 columns å†™å…¨æ—¶æœ€å®¹æ˜“漏掉它,
                ä¸€æ¼å­é¡¹åæ•´åˆ—从报告上消失——而子项往往才是标准真正考核的对象。例如:
                #检验项目={{item.itemName}}|子项={{item.group.childName}}|检测方法={{item.checkMethod}}|标准要求={{item.standardValue}}|结果={{item.actualValue}}|判定={{item.resultText}}
            11. è¡¨å¤´æ˜¯**两层**的(上面一层是「检验结果」这类分组标题、下面一层才是各列名)时,
                æŠŠä¸Šé¢é‚£ä¸€å±‚写进该组件 headerSpans å±žæ€§çš„值里,格式同样是 `|` åˆ†éš”、
                æ¯ä¸ªå•元格写作 `标题^跨越列数`,例如:检验结果^5|结论^1
                ä¸‹é¢é‚£ä¸€å±‚ç”± columns å„列的标题自动拼出,不要重复写。
                å„段 `^` åŽé¢çš„æ•°å­—之和必须等于列数,对不上就说明写错了,请重新数一遍。
                æœ€å³è¾¹é‚£ä¸€åˆ—「结论」/「判定」在原件里不属于上层那个分组标题(它的上方就是「结论」
                äºŒå­—,不是「检验结果」)时要**单独写成一段**(如 `结论^1`),
                ä¸è¦å›¾çœäº‹æŠŠå®ƒçš„列数并进「检验结果」,那样表头会把结论列画进检验结果底下。
                è¿™ç§åˆ—在原件里是**一格纵向合并、自己占满上下两层表头**(提取文本里表现为下层
                è¡¨å¤´é‚£ä¸€æ ¼å†™ç€ã€Œâ†‘同上」)——它自己既是列名又是上格。写它时该段的标题必须与
                columns é‡Œè¿™ä¸€åˆ—的列标题**一字不差**:报告认出两者相同时会把它合出一格跨两行;
                ä¸€æ—¦æ ‡é¢˜ä¸Žåˆ—名不一致,就会被当成另一层的新分组标题,「结论」二字在表头上印两遍。
                æ‰€ä»¥åŽŸä»¶é‚£ä¸€åˆ—å«ã€Œç»“è®ºã€ï¼Œcolumns é‡Œå°±ç…§å†™ã€Œç»“论」,这一段的标题也写「结论」,
                ä¸è¦è‡ªä½œä¸»å¼ æŠŠå®ƒæ”¹åæˆã€Œåˆ¤å®šã€è¿™ç±»è¿‘义词——改名之后两边对不上,就会多印一个表头。
            12. æ ·å“ä¿¡æ¯å—(SampleInfo)要展示的字段与清单里该组件的默认字段不一致时,
                æŠŠå…¨éƒ¨å­—段写进它的 fields å±žæ€§ï¼Œè¯­æ³•与 columns ç›¸åŒï¼ˆå­—段名=绑定表达式,
                å¤šæ®µç”¨ `|` åˆ†éš”),例如:
                æ ·å“ç¼–号={{report.sampleNo}}|产品名称={{report.productName}}|规格={{report.spec}}|批号={{report.batchNo}}|检验日期={{report.inspectDate}}|检验员={{report.inspector}}
                å­—段名前**不允许**加 `#`(那是分组表放组名那一列专用的)。
                fields çš„绑定表达式同样受第 7 æ¡çº¦æŸï¼šåªèƒ½å†™æŠ¥å‘Šä¸Šä¸‹æ–‡é‡Œç¡®å®žå­˜åœ¨çš„字段;
                åŽŸä»¶é‡Œæœ‰ã€ä¸Šä¸‹æ–‡é‡Œæ²¡æœ‰çš„ï¼ˆå¦‚ã€Œäº§å“æ•°é‡ã€ã€ŒåœŸè±†å“ç§ã€ï¼‰è¯·æŒ‰ç¬¬ 5 æ¡ä¸¢å¼ƒå¹¶åœ¨ summary é‡Œè¯´æ˜Žã€‚
            """;
    /**
     * æŠ¥å‘Šçº§å¯ç»‘定字段的白名单,取自 {@link ReportFields} çš„字段名。
     * <p>
     * ä¸æ‰‹å†™å¸¸é‡ï¼šæ‰‹å†™çš„æ¸…单会在别人给报告加字段时静默过期,而模型正是靠这份清单才知道
     * {@code {{report.xxx}}} é‡Œèƒ½å†™ä»€ä¹ˆã€‚清单里少一个键,用户就会在报告上看到一片空白和
     * ä¸€æ¡ã€Œç»‘定取不到值」的告警,且看不出是提示词过期造成的。
     */
    private static final String REPORT_FIELD_KEYS = reportFieldKeys();
    /**
     * æŠ¥å‘Šå­—段白名单。第 7 æ¡åªå†™ã€Œä¸è¦è‡ªå·±å‘明新的绑定键」,却不说究竟有哪些键,
     * æ¨¡åž‹å°±åªèƒ½é çŒœâ€”—实测直连 5 è½®ï¼Œ5 è½®éƒ½ç¼–出了 {@code {{report.productionDate}}} /
     * {@code {{report.expiryDate}}} è¿™ç±»ä¸Šä¸‹æ–‡é‡Œä¸å­˜åœ¨çš„键。禁止编造的前提是把可选集合摊开给它看。
     */
    private static final String REPORT_FIELDS_RULE = """
            ã€æŠ¥å‘Šä¸Šä¸‹æ–‡çš„字段】
            æŠ¥å‘Šä¸Šä¸‹æ–‡é‡Œç¡®å®žå­˜åœ¨çš„字段只有下面这些,不多不少:
            %s
            ç¬¬ 7 æ¡è¦æ±‚「不要自己发明新的绑定键」,说的就是只能从上面这些里挑。
            åŽŸä»¶é‡Œå‡ºçŽ°ã€ä¸Šé¢æ²¡æœ‰çš„æ ç›®ï¼ˆå¦‚ã€Œäº§å“æ•°é‡ã€ã€ŒåœŸè±†å“ç§ã€ã€Œç”Ÿäº§æ—¥æœŸã€ã€Œæœ‰æ•ˆæ—¥æœŸã€ï¼‰
            ä¸€å¾‹ä¸è¦å†™è¿›æ¨¡æ¿ï¼ŒæŒ‰ç¬¬ 5 æ¡ä¸¢å¼ƒå¹¶åœ¨ summary é‡Œè¯´æ˜Žã€‚
            """;
    private static String reportFieldKeys() {
        List<String> keys = new ArrayList<>();
        // ä¸æŒ‰ç±»åž‹ç­›ï¼šæ¼æŽ‰ä¸€ä¸ªå¯ç»‘定的字段(如原始 int çš„ total)比多列一个不能绑的字段危险得多——
        // å‰è€…让模型以为这个键不存在、只能靠猜,后者只是多给一个用不上的名字
        for (Field field : ReportFields.class.getDeclaredFields()) {
            if (Modifier.isStatic(field.getModifiers())) {
                continue;
            }
            keys.add("{{report." + field.getName() + "}}");
        }
        return String.join("、", keys);
    }
    /**
     * è¾“出格式的字面示例。
     * <p>
     * ç”¨ç¤ºä¾‹è€Œä¸æ˜¯ JSON Schema:本项目从 {@code -api} å¤Ÿä¸ç€ Spring AI çš„
     * {@code BeanOutputConverter},只能靠「提示词里给足样子 + æ‹¿åˆ°å›žå¤åŽåˆ‡ç‰‡ååºåˆ—化」这条路,
     * è¿™æ˜¯æ—¢æœ‰èŒƒå¼ï¼ˆERP / CRM / MES ä¸‰å¤„ AI è°ƒç”¨ï¼‰çš„统一做法。
     */
    private static final String OUTPUT_FORMAT = """
            ã€è¾“出格式】
            åªè¾“出一个 JSON å¯¹è±¡ï¼Œä¸è¦æœ‰ä»»ä½•解释文字、不要用 markdown ä»£ç å—包裹。字段如下:
            {
              "summary": "一句话说明这份文件是什么报告,以及有没有丢弃无法表达的内容",
              "page": { "size": "A4", "orientation": "portrait" },
              "components": [
                { "type": "ReportHeader", "props": { "title": "来料检验报告" } },
                { "type": "QualityTable", "props": { "itemsPath": "inspectionItems" } },
                { "type": "ReportFooter", "props": {} }
              ]
            }
            è¯´æ˜Žï¼špage å¯ä»¥çœç•¥ï¼ˆçœç•¥è¡¨ç¤ºæ²¿ç”¨å½“前模板的纸张);size å–值 A3/A4/A5/Letter,
            orientation å–值 portrait/landscape;components è‡³å°‘要有一项。
            ä¸Šé¢ç¤ºä¾‹é‡Œçš„ type **只是格式示意**,选型一律以《可用组件清单》里各组件 hint ä¸ºå‡†ï¼Œ
            ä¸è¦å› ä¸ºç¤ºä¾‹é‡Œå†™äº†æŸä¸ª type å°±ç…§æŠ„它。
            """;
    private QcReportTemplatePromptBuilder() {
    }
    /**
     * æ‹¼ç³»ç»Ÿæç¤ºè¯ã€‚
     *
     * @param catalog å¯ç”¨ç»„件积木清单,由前端从活注册表生成
     * @return ç³»ç»Ÿæç¤ºè¯
     * @throws cn.iocoder.yudao.framework.common.exception.ServiceException æ¸…单为空或序列化后超长时
     */
    public static String buildSystemPrompt(List<QcReportComponentSpecVO> catalog) {
        if (catalog == null || catalog.isEmpty()) {
            throw exception(AI_IMPORT_CATALOG_INVALID, "组件清单为空");
        }
        String catalogJson = JSONUtil.toJsonStr(catalog);
        if (catalogJson.length() > MAX_CATALOG_JSON_LENGTH) {
            throw exception(AI_IMPORT_CATALOG_INVALID,
                    StrUtil.format("组件清单序列化后 {} å­—符,超过上限 {} å­—符",
                            catalogJson.length(), MAX_CATALOG_JSON_LENGTH));
        }
        return """
                ä½ æ˜¯ä¸€åè´¨æ£€æŠ¥å‘Šæ¨¡æ¿ç»“构分析助手。用户会给你一份已有的检验报告文件内容(可能是文本,
                ä¹Ÿå¯èƒ½æ˜¯æ‰«æä»¶/照片),你要把它拆解成《可用组件清单》里的组件,供用户在设计器里确认后使用。
                %s
                ã€å¯ç”¨ç»„件清单】
                ä¸‹é¢æ¯ä¸ª type å°±æ˜¯ä¸€ä¸ªå¯ç”¨ç§¯æœ¨ï¼›fields é‡Œå£°æ˜Žäº†è¯¥ç§¯æœ¨å…è®¸å¡«å†™çš„属性。
                %s
                %s
                %s
                %s
                """.formatted(IRON_RULES, catalogJson,
                REPORT_FIELDS_RULE.formatted(REPORT_FIELD_KEYS), EXTRACTION_RULES, OUTPUT_FORMAT);
    }
    /**
     * æ‹¼ç”¨æˆ·æ¶ˆæ¯ã€‚
     *
     * @param hint        ç”¨æˆ·è¡¥å……说明,可为空
     * @param sourceLabel æ¥æºæ ‡ç­¾ï¼ˆæ–‡ä»¶å / æ–‡ä»¶å+页码),便于模型理解上下文
     * @param content     æ–‡æ¡£æ­£æ–‡ï¼ˆTEXT é€šé“)或对图片的识别指令(IMAGE é€šé“)
     * @return ç”¨æˆ·æ¶ˆæ¯
     */
    public static String buildUserMessage(String hint, String sourceLabel, String content) {
        StringBuilder sb = new StringBuilder();
        if (StrUtil.isNotBlank(sourceLabel)) {
            sb.append("来源:").append(sourceLabel).append('\n');
        }
        if (StrUtil.isNotBlank(hint)) {
            sb.append("用户补充说明:").append(hint).append('\n');
        }
        sb.append("以下是文件内容,请按系统提示的规则输出 JSON:\n");
        sb.append(content);
        return sb.toString();
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/instance/GenerateFromQcResult.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,19 @@
package cn.iocoder.yudao.module.qcreport.service.instance;
import cn.iocoder.yudao.module.qcreport.dal.dataobject.instance.QcReportInstanceDO;
import java.util.List;
/**
 * ç”±è´¨æ£€å•出件的结果。
 *
 * @param instance         è½åº“的报告实例
 * @param itemCount        å†™å…¥çš„æ£€éªŒé¡¹æ¡æ•°
 * @param undecidableCount æ²¡æœ‰åˆ¤å®šç»“论的项数:「无判定规则」(缺规格上下限又没规则)
 *                         åŠ ä¸Šè§„åˆ™æ‰§è¡Œå¤±è´¥çš„ã€Œå¾…åˆ¤å®šã€
 * @param warnings         è½¯æç¤ºï¼šæ ·å“çš„取舍、跳过的分组项、尚未录入实测值的项
 * @param errors           æ¸²æŸ“期数据缺口(既有机制)
 */
public record GenerateFromQcResult(QcReportInstanceDO instance, int itemCount, int undecidableCount,
                                   List<String> warnings, List<String> errors) {
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/instance/GenerateResult.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,14 @@
package cn.iocoder.yudao.module.qcreport.service.instance;
import cn.iocoder.yudao.module.qcreport.dal.dataobject.instance.QcReportInstanceDO;
import java.util.List;
/**
 * å‡ºä»¶ç»“果。
 *
 * @param instance è½åº“的报告实例(重新生成时是更新前的实例)
 * @param errors   æ•°æ®ç¼ºå£æ¸…单——报告出来了,但这些字段当时没有值
 */
public record GenerateResult(QcReportInstanceDO instance, List<String> errors) {
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/instance/MesQcReportContextMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,218 @@
package cn.iocoder.yudao.module.qcreport.service.instance;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.module.mes.api.qc.dto.MesQcReportItemRespDTO;
import cn.iocoder.yudao.module.mes.api.qc.dto.MesQcReportRespDTO;
import cn.iocoder.yudao.module.qcreport.engine.context.InspectionItem;
import cn.iocoder.yudao.module.qcreport.engine.context.ReportContext;
import cn.iocoder.yudao.module.qcreport.engine.context.ReportFields;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
/**
 * è´¨æ£€å•数据 â†’ æŠ¥å‘Šä¸Šä¸‹æ–‡ã€‚
 * <p>
 * çº¯å‡½æ•°ï¼šåªåšå­—段搬运与文案拼装,不查库、不渲染、不知道模板长什么样。
 * ä¹‹æ‰€ä»¥æŠ½å‡ºæ¥ï¼Œæ˜¯å› ä¸ºã€Œå“ªäº›å­—段到哪儿去」是这次对接最容易出错也最值得单测的部分,
 * æ”¾åœ¨ Service é‡Œå°±åªèƒ½é ç«¯åˆ°ç«¯è”调才发现搬错了列。
 * <p>
 * <b>不修改引擎</b>:报告结论仍由 {@code ReportEvaluator} æŒ‰è§„则/规格上下限算,
 * è´¨æ£€å•上人工拍的那个判定走 {@code report.qcResult} å•独承载,两者互不覆盖。
 */
public final class MesQcReportContextMapper {
    /** æ£€éªŒæ—¥æœŸæ²¿ç”¨å¼•擎其余地方的写法,避免同一份报告里出现两种日期格式 */
    private static final DateTimeFormatter INSPECT_DATE_FORMAT =
            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss", Locale.ROOT);
    /** æŠ¥å‘Šå¤´ã€Œæ ·å“ç¼–号」只在一个样品时才有确定含义;超过这个数就不往头上堆了 */
    private static final int SAMPLE_NO_JOIN_LIMIT = 3;
    private MesQcReportContextMapper() {
    }
    /**
     * æ˜ å°„结果。
     *
     * @param context  æŠ¥å‘Šä¸Šä¸‹æ–‡ï¼ˆæ£€éªŒé¡¹å·²å°±ä½ï¼Œåˆ¤å®šç»“果由引擎填)
     * @param warnings è½¯æç¤ºï¼šå–样品的取舍、没成组的分组项、为组头补出来的行、尚未录入实测值的项
     */
    public record Mapped(ReportContext context, List<String> warnings) {
    }
    /**
     * @param data      è´¨æ£€å•数据,来自 MES
     * @param reportNo  æœ¬æ¬¡æŠ¥å‘Šç¼–号(出件时会换成最终生效编号,这里只为预览/渲染时纸面上有个号)
     */
    public static Mapped map(MesQcReportRespDTO data, String reportNo) {
        List<String> warnings = new ArrayList<>();
        List<MesQcReportItemRespDTO> sourceItems =
                data.getItems() == null ? List.of() : data.getItems();
        // å•据上可能有多个样品(同一指标在不同样品上各测一次),报告头只放得下一个「样品编号」
        List<String> samples = distinctSamples(sourceItems);
        ReportFields report = new ReportFields()
                .setReportNo(StrUtil.nullToEmpty(reportNo))
                .setReportName(StrUtil.nullToEmpty(data.getQcName()))
                .setSampleNo(reportSampleNo(samples, data.getQcCode(), warnings))
                .setProductCode(StrUtil.nullToEmpty(data.getItemCode()))
                .setProductName(StrUtil.nullToEmpty(data.getItemName()))
                .setSpec(StrUtil.nullToEmpty(data.getItemSpecification()))
                // æ¥æ–™æ£€éªŒçš„æ‰¹æ¬¡åœ¨ä¾›åº”商批号上,出货/退货在批次号上,过程检验两者都没有(只有工单)
                .setBatchNo(firstNonBlank(data.getBatchCode(), data.getVendorBatch()))
                .setWorkOrderNo(StrUtil.nullToEmpty(data.getWorkOrderCode()))
                .setInspectType(StrUtil.nullToEmpty(data.getQcTypeName()))
                .setInspector(StrUtil.nullToEmpty(data.getInspectorName()))
                .setInspectDate(formatDate(data.getInspectDate()))
                // è´¨æ£€å•上没有「部门」这一维度,留空而不是拿别的字段硬凑
                .setCustomerName(StrUtil.nullToEmpty(data.getClientName()))
                .setSupplierName(StrUtil.nullToEmpty(data.getVendorName()))
                // äººå·¥æ‹çš„æ£€éªŒåˆ¤å®šï¼Œä¸Žå¼•擎算出的 result/resultText/conclusion åˆ†åˆ—两处
                .setQcResult(data.getCheckResult() == null ? "" : String.valueOf(data.getCheckResult()))
                .setQcResultText(StrUtil.nullToEmpty(data.getCheckResultText()));
        boolean foldSampleIntoRemark = samples.size() > 1;
        // æ•´ä»½æŠ¥å‘Šæœ‰æ²¡æœ‰åˆ†ç»„是个整体判断:分组信息要逐项下发(渲染期算不出「这一组几行」),
        // æ²¡æœ‰åˆ†ç»„的报告一个键都不放,别让模板里没写的路径凭空多出一层空格子
        boolean grouped = sourceItems.stream().anyMatch(MesQcReportContextMapper::grouped);
        List<InspectionItem> items = new ArrayList<>(sourceItems.size());
        for (MesQcReportItemRespDTO source : sourceItems) {
            items.add(toItem(source, foldSampleIntoRemark, grouped, warnings));
        }
        appendSkipWarnings(data, warnings);
        return new Mapped(new ReportContext().setReport(report).setInspectionItems(items), warnings);
    }
    /** ä¸€ä¸ª (样品, æŒ‡æ ‡) ç»„合 = ä¸€è¡Œæ£€éªŒé¡¹ï¼›æ ·å“å·åœ¨å¤šæ ·å“æ—¶å¹¶è¿›å¤‡æ³¨ï¼Œä¸ä¸¢æ•°æ® */
    private static InspectionItem toItem(MesQcReportItemRespDTO source, boolean foldSampleIntoRemark,
                                         boolean grouped, List<String> warnings) {
        int index = source.getIndex() == null ? 0 : source.getIndex();
        String itemName = StrUtil.nullToEmpty(source.getIndicatorName());
        if (StrUtil.isBlank(source.getActualValue()) && !isGroupAnchor(source)) {
            // å•据行建好了但实测值还没录:这一行在报告上必然是「待判定」,先说清楚是哪一项。
            // ç»„头的锚点行除外 â€”— ç»„头按定义没有实测值,值都在它的子项行上,报了就是误报
            warnings.add("第 " + index + " é¡¹ã€Œ" + (StrUtil.isBlank(itemName) ? "未命名" : itemName)
                    + "」尚未录入实测值,报告中该项将无判定结论");
        }
        return new InspectionItem()
                .setIndex(index)
                .setItemCode(StrUtil.nullToEmpty(source.getIndicatorCode()))
                .setItemName(itemName)
                .setStandardValue(StrUtil.nullToEmpty(source.getStandardValue()))
                .setActualValue(StrUtil.nullToEmpty(source.getActualValue()))
                .setUnit(StrUtil.nullToEmpty(source.getUnit()))
                .setRequirement(StrUtil.nullToEmpty(source.getRequirement()))
                .setCheckMethod(StrUtil.nullToEmpty(source.getCheckMethod()))
                .setGroup(grouped ? groupOf(source) : null)
                .setUpperLimit(toDouble(source.getUpperLimit()))
                .setLowerLimit(toDouble(source.getLowerLimit()))
                .setRemark(mergeRemark(source, foldSampleIntoRemark));
    }
    /** è¿™ä¸€è¡Œæ˜¯ä¸æ˜¯æŸä¸ªåˆ†ç»„的子项行(子项列上有名字) */
    private static boolean grouped(MesQcReportItemRespDTO source) {
        return StrUtil.isNotBlank(source.getGroupChildName()) || isGroupAnchor(source);
    }
    /**
     * è¿™ä¸€è¡Œæ˜¯ä¸æ˜¯åˆ†ç»„的锚点行(组名合并格的落点)。
     * <p>
     * åˆ¤æ®æ˜¯åˆå¹¶è¡Œæ•°å¤§äºŽ 1:独立项与组内其余行的这个值都是 1。
     */
    private static boolean isGroupAnchor(MesQcReportItemRespDTO source) {
        return source.getGroupSpan() != null && source.getGroupSpan() > 1;
    }
    /** åˆ†ç»„信息逐项下发:三个字段缺一个,模板里对应的绑定就会取不到值 */
    private static InspectionItem.Group groupOf(MesQcReportItemRespDTO source) {
        return new InspectionItem.Group()
                .setChildName(StrUtil.nullToEmpty(source.getGroupChildName()))
                .setSpan(source.getGroupSpan() == null ? 1 : source.getGroupSpan())
                .setHidden(StrUtil.nullToEmpty(source.getGroupHidden()));
    }
    /**
     * æ£€éªŒé¡¹å¤‡æ³¨ = å•据行备注 + å®žæµ‹å€¼å¤‡æ³¨ + ï¼ˆå¤šæ ·å“æ—¶ï¼‰æ ·å“å·ã€‚
     * <p>
     * æ ·å“å·åªåœ¨å¤šæ ·å“æ—¶æ‰å¹¶è¿›æ¥ï¼šå•样品时报告头已经写了,每行再重复一遍纯属噪声。
     */
    private static String mergeRemark(MesQcReportItemRespDTO source, boolean foldSampleIntoRemark) {
        List<String> parts = new ArrayList<>(3);
        if (foldSampleIntoRemark && StrUtil.isNotBlank(source.getSampleNo())) {
            parts.add("样品 " + source.getSampleNo());
        }
        if (StrUtil.isNotBlank(source.getRemark())) {
            parts.add(source.getRemark().trim());
        }
        return String.join(";", parts);
    }
    /** æŠ¥å‘Šå¤´ã€Œæ ·å“ç¼–号」:单样品直接放,多样品放得下就并列,放不下就交给备注并在提示里说明 */
    private static String reportSampleNo(List<String> samples, String qcCode, List<String> warnings) {
        if (samples.isEmpty()) {
            return "";
        }
        if (samples.size() == 1) {
            return samples.get(0);
        }
        if (samples.size() <= SAMPLE_NO_JOIN_LIMIT) {
            return String.join("、", samples);
        }
        warnings.add("质检单「" + StrUtil.nullToEmpty(qcCode) + "」共有 " + samples.size()
                + " ä¸ªæ ·å“ï¼ˆ" + String.join("、", samples.subList(0, SAMPLE_NO_JOIN_LIMIT)) + " ç­‰ï¼‰ï¼Œ"
                + "报告头「样品编号」留空,各样品的实测值见对应检验项的备注");
        return "";
    }
    /** åˆ†ç»„没成组、被补了行、指标已被删除这三种情况:报告上多了什么少了什么都要如实说 */
    private static void appendSkipWarnings(MesQcReportRespDTO data, List<String> warnings) {
        if (CollUtil.isNotEmpty(data.getChildlessGroupNames())) {
            warnings.add("以下 " + data.getChildlessGroupNames().size() + " ä¸ªæŒ‡æ ‡æ˜¯æŒ‡æ ‡åº“里的分组项,"
                    + "但本单没有属于它们的子项,报告里按独立检验项列出、组名不合并:「"
                    + String.join("、", data.getChildlessGroupNames()) + "」");
        }
        if (CollUtil.isNotEmpty(data.getSynthesizedGroupNames())) {
            warnings.add("以下 " + data.getSynthesizedGroupNames().size() + " ä¸ªåˆ†ç»„项在本单的检验明细里没有自己的行"
                    + "(多半是检验模板只选了子项),报告里为它们各补了一行来显示组名与公式:「"
                    + String.join("、", data.getSynthesizedGroupNames()) + "」");
        }
        if (data.getMissingIndicatorCount() > 0) {
            warnings.add("单据中有 " + data.getMissingIndicatorCount()
                    + " æ¡æ˜Žç»†æ‰€å¼•用的检验指标已被删除,无法还原成检验项,已跳过");
        }
    }
    private static List<String> distinctSamples(List<MesQcReportItemRespDTO> items) {
        Set<String> samples = new LinkedHashSet<>();
        for (MesQcReportItemRespDTO item : items) {
            if (StrUtil.isNotBlank(item.getSampleNo())) {
                samples.add(item.getSampleNo().trim());
            }
        }
        return new ArrayList<>(samples);
    }
    private static String formatDate(LocalDateTime value) {
        return value == null ? "" : INSPECT_DATE_FORMAT.format(value);
    }
    /** è§„格上下限可以为空(没有区间要求),空值保持 null,不能变成 0 */
    private static Double toDouble(BigDecimal value) {
        return value == null ? null : value.doubleValue();
    }
    private static String firstNonBlank(String first, String second) {
        return StrUtil.isNotBlank(first) ? first.trim() : StrUtil.nullToEmpty(second);
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/instance/PdfArchiveResult.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,22 @@
package cn.iocoder.yudao.module.qcreport.service.instance;
/**
 * PDF å‡ºä»¶ç»“果。
 *
 * @param instanceId    æŠ¥å‘Šå®žä¾‹ç¼–号
 * @param reportNo      æŠ¥å‘Šç¼–号(也就是 PDF æ–‡ä»¶åï¼‰
 * @param fileName      PDF æ–‡ä»¶å
 * @param byteSize      PDF å­—节数
 * @param blobId        æ–‡ä»¶ç¼–号(system_storage_blob),换绑/排查时用
 * @param attachmentId  é™„件关联编号(system_storage_attachment)
 * @param previewURL    é¢„览地址(临时签名,会过期)
 * @param downloadURL   ä¸‹è½½åœ°å€ï¼ˆä¸´æ—¶ç­¾åï¼Œä¼šè¿‡æœŸï¼‰
 * @param durationMs    æœ¬æ¬¡å‡ºä»¶æ€»è€—时(毫秒):含排队等并发名额、必要时重启浏览器的等待
 * @param pdfDurationMs æ‰“印耗时(毫秒):仅含等页面资源与 {@code page.pdf()},
 *                      ä¸Ž {@code durationMs} ä¹‹å·®å³ç­‰å¾…成本
 * @param browserStatus æœ¬æ¬¡æµè§ˆå™¨çŠ¶æ€ï¼šREADY(复用实例)/ RESTARTED(本次重启了浏览器)
 */
public record PdfArchiveResult(Long instanceId, String reportNo, String fileName, Long byteSize,
                               Long blobId, Long attachmentId, String previewURL, String downloadURL,
                               Long durationMs, Long pdfDurationMs, String browserStatus) {
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/instance/QcReportInstanceService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,117 @@
package cn.iocoder.yudao.module.qcreport.service.instance;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.qcreport.controller.admin.instance.vo.QcReportInstanceGenerateFromQcReqVO;
import cn.iocoder.yudao.module.qcreport.controller.admin.instance.vo.QcReportInstanceGenerateReqVO;
import cn.iocoder.yudao.module.qcreport.controller.admin.instance.vo.QcReportInstancePageReqVO;
import cn.iocoder.yudao.module.qcreport.dal.dataobject.instance.QcReportInstanceDO;
import cn.iocoder.yudao.module.qcreport.engine.render.RenderOutcome;
import java.util.Map;
/**
 * æ™ºèƒ½è´¨æ£€æŠ¥å‘Šå®žä¾‹ Service æŽ¥å£ã€‚
 * <p>
 * è¿™é‡Œæ˜¯ã€Œæ¨¡æ¿ â†’ æŠ¥å‘Šã€çš„出件链路:取已发布版本 â†’ æ¸²æŸ“ â†’ è½åº“并冻结数据快照。
 * æ•°æ®ç”±è°ƒç”¨æ–¹ä¼ è¿›æ¥ï¼Œæœ¬æ¨¡å—不认识任何业务单据(MES è´¨æ£€å•的归一化由 MES ä¾§æä¾›ï¼‰ã€‚
 */
public interface QcReportInstanceService {
    /**
     * é¢„览:只渲染,不落库、不生成编号。
     *
     * @param reqVO å‡ºä»¶å…¥å‚
     * @return æ¸²æŸ“产物;报告编号取入参里带过来的那个,没带则为空
     */
    RenderOutcome preview(QcReportInstanceGenerateReqVO reqVO);
    /**
     * å‡ºä»¶ï¼šæ¸²æŸ“ + è½åº“ + å†»ç»“数据快照。
     *
     * @param reqVO å‡ºä»¶å…¥å‚
     * @return æŠ¥å‘Šå®žä¾‹ä¸Žæ•°æ®ç¼ºå£æ¸…单
     */
    GenerateResult generate(QcReportInstanceGenerateReqVO reqVO);
    /**
     * ç”±è´¨æ£€å•出件:从 MES å–质检单数据 â†’ æ˜ å°„成报告上下文 â†’ èµ°æ—¢æœ‰çš„出件流程。
     * <p>
     * åˆ»æ„ä¸æ–°å¼€ä¸€æ¡è½åº“路径:内部就是调 {@link #generate},于是版本发布校验、
     * ç¼–号生成与撞号重试、数据快照冻结、判定引擎全部照旧生效,一条都没绕开。
     * <p>
     * å‡ºä»¶å‰çš„æ ¡éªŒæŒ‰ä¸šåŠ¡ä¼˜å…ˆçº§åªæŠ¥ç¬¬ä¸€å¤„ï¼šç±»åž‹ä¸åˆæ³• â†’ å•据不存在 â†’ æœªå®Œæˆ â†’ æœªåˆ¤å®š â†’
     * æ¨¡æ¿ç±»åž‹ä¸ç¬¦ â†’ æ²¡æœ‰å¯ç”¨æ£€éªŒé¡¹ã€‚一次抛多条会让用户抓不住重点。
     *
     * @param reqVO è´¨æ£€å• + æ¨¡æ¿é€‰æ‹©
     * @return æŠ¥å‘Šå®žä¾‹ä¸Žæœ¬æ¬¡å‡ºä»¶çš„æç¤ºä¿¡æ¯
     */
    GenerateFromQcResult generateFromQc(QcReportInstanceGenerateFromQcReqVO reqVO);
    /**
     * é‡æ–°ç”Ÿæˆï¼šç”¨å®žä¾‹é‡Œå†»ç»“的数据快照与同一模板版本重渲,覆盖产物 HTML。
     * <p>
     * å¿«ç…§ä¸ŽæŠ¥å‘Šç¼–号都不变。重新生成的是「排版」,不是「数据」——数据以快照为准,
     * ä¸šåŠ¡ç³»ç»Ÿé‡Œçš„æ•°æ®åŽæ¥æ€Žä¹ˆå˜éƒ½ä¸ä¼šå½±å“è¿™ä»½åŽ†å²æŠ¥å‘Šã€‚
     * <p>
     * <b>已归档的 PDF ä¼šè¢«ä¸€å¹¶ä½œåºŸ</b>:产物 HTML æ¢äº†ï¼Œå†ç•™ç€æ—§ PDF å°±æ˜¯ã€Œé¡µé¢æ˜¯æ–°æŽ’版、
     * ä¸‹è½½åˆ°çš„æ˜¯æ—§æ–‡ä»¶ã€çš„静默不一致。宁可让产物消失(随时可再导一次),也不留这种错配。
     *
     * @param id å®žä¾‹ç¼–号
     * @return æŠ¥å‘Šå®žä¾‹ä¸Žæ•°æ®ç¼ºå£æ¸…单
     */
    GenerateResult regenerate(Long id);
    /**
     * å¯¼å‡º PDF:把实例里**已存的**渲染产物 HTML äº¤ç»™ Chromium æ‰“印,并归档到附件库。
     * <p>
     * åˆ»æ„ä¸é‡æ¸²ï¼šè®¾è®¡å™¨/详情页看到的 HTML ä¸Ž PDF å¿…须出自同一份字节,
     * å¦åˆ™å°±ä¼šå‡ºçŽ°ã€Œé¡µé¢ä¸Šå¥½å¥½çš„ã€æ‰“å‡ºæ¥å˜å½¢ã€è¿™ç±»å¯¹ä¸ä¸Šè´¦çš„é—®é¢˜ã€‚è¦æ¢æŽ’ç‰ˆè¯·å…ˆã€Œé‡æ–°ç”Ÿæˆã€ã€‚
     * <p>
     * å½’档走 system æ¨¡å—的附件中间表({@code recordType = qc_report_instance}),
     * å› æ­¤ã€Œè¿™ä»½æŠ¥å‘Šæœ‰æ²¡æœ‰ PDF、去哪儿下载」用标准的附件列表接口即可查到。
     *
     * @param id å®žä¾‹ç¼–号
     * @return å½’档后的 PDF ä¿¡æ¯ä¸Žè€—æ—¶
     */
    PdfArchiveResult exportPdf(Long id);
    /**
     * æ ¡éªŒå®žä¾‹å­˜åœ¨ï¼Œå¹¶è¿”回
     *
     * @param id å®žä¾‹ç¼–号
     * @return æŠ¥å‘Šå®žä¾‹
     */
    QcReportInstanceDO validateInstanceExists(Long id);
    /**
     * èŽ·å¾—æŠ¥å‘Šå®žä¾‹
     *
     * @param id å®žä¾‹ç¼–号
     * @return æŠ¥å‘Šå®žä¾‹
     */
    QcReportInstanceDO getInstance(Long id);
    /**
     * èŽ·å¾—å®žä¾‹å†»ç»“çš„æ•°æ®å¿«ç…§
     *
     * @param id å®žä¾‹ç¼–号
     * @return æ•°æ®å¿«ç…§ï¼Œå½¢å¦‚ {@code {report, inspectionItems}}
     */
    Map<String, Object> getSnapshot(Long id);
    /**
     * èŽ·å¾—æŠ¥å‘Šå®žä¾‹åˆ†é¡µ
     *
     * @param pageReqVO åˆ†é¡µæŸ¥è¯¢
     * @return å®žä¾‹åˆ†é¡µ
     */
    PageResult<QcReportInstanceDO> getInstancePage(QcReportInstancePageReqVO pageReqVO);
    /**
     * åˆ é™¤æŠ¥å‘Šå®žä¾‹ã€‚已归档的 PDF ä¸€å¹¶æ¸…理,不留下查不到主人的文件与附件记录。
     *
     * @param id å®žä¾‹ç¼–号
     */
    void deleteInstance(Long id);
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/instance/QcReportInstanceServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,531 @@
package cn.iocoder.yudao.module.qcreport.service.instance;
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.common.pojo.PageResult;
import cn.iocoder.yudao.module.mes.api.qc.MesQcReportApi;
import cn.iocoder.yudao.module.mes.api.qc.dto.MesQcReportRespDTO;
import cn.iocoder.yudao.module.qcreport.config.QcReportPdfProperties;
import cn.iocoder.yudao.module.qcreport.controller.admin.instance.vo.QcReportInstanceGenerateFromQcReqVO;
import cn.iocoder.yudao.module.qcreport.controller.admin.instance.vo.QcReportInstanceGenerateReqVO;
import cn.iocoder.yudao.module.qcreport.controller.admin.instance.vo.QcReportInstancePageReqVO;
import cn.iocoder.yudao.module.qcreport.dal.dataobject.instance.QcReportInstanceDO;
import cn.iocoder.yudao.module.qcreport.dal.dataobject.render.QcReportRenderRecordDO;
import cn.iocoder.yudao.module.qcreport.dal.dataobject.template.QcReportTemplateDO;
import cn.iocoder.yudao.module.qcreport.dal.dataobject.version.QcReportTemplateVersionDO;
import cn.iocoder.yudao.module.qcreport.dal.mysql.instance.QcReportInstanceMapper;
import cn.iocoder.yudao.module.qcreport.dal.mysql.render.QcReportRenderRecordMapper;
import cn.iocoder.yudao.module.qcreport.engine.PageSetting;
import cn.iocoder.yudao.module.qcreport.engine.QualityReportEngine;
import cn.iocoder.yudao.module.qcreport.engine.context.InspectionItem;
import cn.iocoder.yudao.module.qcreport.engine.context.ReportContext;
import cn.iocoder.yudao.module.qcreport.engine.context.ReportContextCodec;
import cn.iocoder.yudao.module.qcreport.engine.context.ReportFields;
import cn.iocoder.yudao.module.qcreport.enums.QcReportSourceTypeEnum;
import cn.iocoder.yudao.module.qcreport.engine.render.RenderOutcome;
import cn.iocoder.yudao.module.qcreport.service.render.PdfRenderService;
import cn.iocoder.yudao.module.qcreport.service.render.PdfRenderService.PdfResult;
import cn.iocoder.yudao.module.qcreport.service.template.QcReportTemplateService;
import cn.iocoder.yudao.module.qcreport.service.version.QcReportTemplateVersionService;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import cn.iocoder.yudao.module.system.api.storage.StorageBlobApi;
import cn.iocoder.yudao.module.system.api.storage.dto.StorageBlobRespDTO;
import cn.iocoder.yudao.module.system.enums.storage.StorageApplicationTypeEnum;
import cn.iocoder.yudao.module.system.enums.storage.StorageRecordTypeEnum;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.*;
import static cn.iocoder.yudao.module.qcreport.enums.QcReportEnums.InstanceStatusEnum.SUCCESS;
import static cn.iocoder.yudao.module.qcreport.enums.QcReportEnums.TemplateStatusEnum.ENABLE;
/**
 * æ™ºèƒ½è´¨æ£€æŠ¥å‘Šå®žä¾‹ Service å®žçŽ°ç±»
 */
@Slf4j
@Service
@Validated
public class QcReportInstanceServiceImpl implements QcReportInstanceService {
    /**
     * è‡ªåŠ¨ç¼–å·æ’žå·åŽçš„é‡è¯•æ¬¡æ•°ã€‚
     * <p>
     * å¹¶å‘下两个请求会算出同一个序号,靠 {@code uk_report_no} å”¯ä¸€ç´¢å¼•挡下来重算即可;
     * è¿žæ’ž 5 æ¬¡å·²ç»è¿œè¶…「同时出一份报告」的正常并发,再失败就如实报错,不做无限重试。
     */
    private static final int REPORT_NO_MAX_ATTEMPTS = 5;
    /** PDF å‡ºä»¶çš„æ–‡ä»¶ç±»åž‹ */
    private static final String PDF_CONTENT_TYPE = "application/pdf";
    /** æŠ¥å‘Š PDF æŒ‚在附件库里的业务记录类型:与前端查询附件用的是同一个值 */
    private static final String RECORD_TYPE = StorageRecordTypeEnum.QC_REPORT_INSTANCE.getType();
    /** é™„件用途:与前端上传走同一个默认值,附件列表接口也是按它过滤的 */
    private static final String APPLICATION = StorageApplicationTypeEnum.FILE.getType();
    @Resource
    private QcReportInstanceMapper instanceMapper;
    @Resource
    private QcReportTemplateService templateService;
    @Resource
    private QcReportTemplateVersionService versionService;
    @Resource
    private QcReportRenderRecordMapper renderRecordMapper;
    @Resource
    private PdfRenderService pdfRenderService;
    @Resource
    private QcReportPdfProperties pdfProperties;
    @Resource
    private StorageBlobApi storageBlobApi;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @Resource
    private MesQcReportApi mesQcReportApi;
    @Override
    public RenderOutcome preview(QcReportInstanceGenerateReqVO reqVO) {
        QcReportTemplateVersionDO version = resolveVersion(reqVO, false);
        ReportContext context = prepareContext(reqVO);
        // é¢„览不生成编号:编号是出件时才确定的流水号,预览不该消耗它
        String reportNo = resolveExplicitReportNo(reqVO);
        if (reportNo != null) {
            context.getReport().setReportNo(reportNo);
        }
        return QualityReportEngine.render(version.getSchema(), context);
    }
    /**
     * å‡ºä»¶ã€‚
     * <p>
     * åˆ»æ„**不加 {@code @Transactional}**:整段只有一次 insert,没有多语句一致性需求;
     * ä¸€æ—¦åŒ…上事务,下面重试路径里被 catch æŽ‰çš„ {@link DuplicateKeyException}
     * å¯èƒ½æŠŠäº‹åŠ¡æ ‡è®°æˆ rollback-only,反而把「重试」变成「必定失败」。
     */
    @Override
    public GenerateResult generate(QcReportInstanceGenerateReqVO reqVO) {
        QcReportTemplateVersionDO version = resolveVersion(reqVO, true);
        ReportContext context = prepareContext(reqVO);
        String explicitNo = resolveExplicitReportNo(reqVO);
        if (explicitNo != null) {
            // æŒ‡å®šäº†ç¼–号:每次都是同一个号,重试没有意义,撞号就明确报出来
            if (instanceMapper.selectByReportNo(explicitNo) != null) {
                throw reportNoTaken(explicitNo);
            }
            try {
                return renderAndPersist(reqVO, version, context, explicitNo);
            } catch (DuplicateKeyException race) {
                throw reportNoTaken(explicitNo);
            }
        }
        LocalDate day = LocalDate.now();
        String prefix = ReportNoGenerator.prefix(day);
        for (int attempt = 0; attempt < REPORT_NO_MAX_ATTEMPTS; attempt++) {
            String latest = instanceMapper.selectLatestReportNoByPrefix(prefix);
            String reportNo = ReportNoGenerator.format(day,
                    ReportNoGenerator.nextSequence(latest, day));
            try {
                return renderAndPersist(reqVO, version, context, reportNo);
            } catch (DuplicateKeyException conflict) {
                // å¹¶å‘下别的请求刚抢走这个号,重算序号再试
            }
        }
        throw new ServiceException(REPORT_INSTANCE_NO_DUPLICATE.getCode(),
                "自动生成报告编号时连续 " + REPORT_NO_MAX_ATTEMPTS + " æ¬¡ä¸Žå·²æœ‰æŠ¥å‘Šå†²çªï¼Œè¯·ç¨åŽé‡è¯•ï¼›"
                        + "也可在请求里显式指定一个未被占用的报告编号");
    }
    @Override
    public GenerateFromQcResult generateFromQc(QcReportInstanceGenerateFromQcReqVO reqVO) {
        QcReportSourceTypeEnum sourceType = validateQcType(reqVO.getQcType());
        MesQcReportRespDTO data = mesQcReportApi.getQcReportData(reqVO.getQcType(), reqVO.getQcId());
        if (data == null) {
            throw new ServiceException(QC_GENERATE_ORDER_NOT_EXISTS.getCode(),
                    sourceType.getName() + "的质检单(ID=" + reqVO.getQcId() + ")不存在或已被删除,"
                            + "请刷新列表后重新选择");
        }
        validateOrderUsable(data, sourceType);
        validateTemplateType(reqVO.getTemplateId(), sourceType);
        if (CollUtil.isEmpty(data.getItems())) {
            throw new ServiceException(QC_GENERATE_NO_ITEM.getCode(),
                    sourceType.getName() + "的质检单「" + data.getQcCode() + "」"
                            + "(ID=" + data.getQcId() + ")没有任何检验指标"
                            + (data.getMissingIndicatorCount() > 0
                                    ? "(其中 " + data.getMissingIndicatorCount()
                                            + " æ¡æ˜Žç»†æ‰€å¼•用的检验指标已被删除)" : "")
                            + ",无法生成报告。请先在该单据上录入检验指标与实测值后再生成");
        }
        MesQcReportContextMapper.Mapped mapped =
                MesQcReportContextMapper.map(data, StrUtil.trimToNull(reqVO.getReportNo()));
        QcReportInstanceGenerateReqVO generateReq = new QcReportInstanceGenerateReqVO()
                .setTemplateId(reqVO.getTemplateId())
                .setVersion(reqVO.getVersion())
                .setReportNo(reqVO.getReportNo())
                .setBusinessId(String.valueOf(data.getQcId()))
                .setBusinessType(sourceType.getBusinessType())
                .setContext(mapped.context());
        GenerateResult result = generate(generateReq);
        // ã€Œæ— åˆ¤å®šè§„则」与「待判定」在快照里都表现为 result ä¸ºç©ºä¸²ï¼ˆå¼•擎没算出结论),据此如实计数。
        // ä¸å¦ç®—一遍判定条件:那样会和引擎的判定依据各说各话,还漏掉了「卡在规则报错上」的项。
        ReportContext frozen = ReportContextCodec.fromMap(result.instance().getDataSnapshot());
        List<InspectionItem> items = frozen.getInspectionItems();
        int undecidable = 0;
        for (InspectionItem item : items) {
            if (StrUtil.isBlank(item.getResult())) {
                undecidable++;
            }
        }
        return new GenerateFromQcResult(result.instance(), items.size(), undecidable,
                mapped.warnings(), result.errors());
    }
    /** è´¨æ£€ç±»åž‹å¿…须是四类之一;不合法时把收到的值和允许的值都摆出来 */
    private QcReportSourceTypeEnum validateQcType(Integer qcType) {
        QcReportSourceTypeEnum sourceType = QcReportSourceTypeEnum.of(qcType);
        if (sourceType == null) {
            throw new ServiceException(QC_GENERATE_TYPE_INVALID.getCode(),
                    "质检类型「" + qcType + "」不合法,只支持 IQC(来料检验,值 1)、IPQC(过程检验,值 2)、"
                            + "OQC(出货检验,值 3)、RQC(退货检验,值 4)");
        }
        return sourceType;
    }
    /** å•据得走完检验并且有人拍过判定,报告才有内容可登 */
    private void validateOrderUsable(MesQcReportRespDTO data, QcReportSourceTypeEnum sourceType) {
        if (!data.isFinished()) {
            throw new ServiceException(QC_GENERATE_ORDER_NOT_FINISHED.getCode(),
                    sourceType.getName() + "的质检单「" + data.getQcCode() + "」(ID=" + data.getQcId()
                            + ")当前状态为「" + data.getStatusName() + "」,尚未完成检验,不能生成报告。"
                            + "请先把该单据提交并完成检验判定后再生成");
        }
        if (data.getCheckResult() == null) {
            throw new ServiceException(QC_GENERATE_ORDER_NOT_JUDGED.getCode(),
                    sourceType.getName() + "的质检单「" + data.getQcCode() + "」(ID=" + data.getQcId()
                            + ")尚未填写检验判定(当前为空),不能生成报告。"
                            + "请先在该单据上填写判定结论(合格 / ç‰¹é‡‡ / ä¸åˆæ ¼é€€è´§ / ä¸åˆæ ¼æŠ¥åºŸï¼‰åŽå†ç”Ÿæˆ");
        }
    }
    /**
     * æ¨¡æ¿çš„æŠ¥å‘Šç±»åž‹å¿…须与质检单一致。
     * <p>
     * æ‹¦åœ¨è¿™é‡Œæ˜¯ä¸ºäº†é˜²æ­¢ã€Œæ¥æ–™æ£€éªŒå•出成出货报告」这种一眼错到客户手里的低级错误;
     * å‰ç«¯é€‰æ¨¡æ¿æ—¶ä¹Ÿä¼šæŒ‰ç±»åž‹è¿‡æ»¤ï¼Œä½†è¿‡æ»¤æ˜¯ä½“验、校验才是底线。
     */
    private void validateTemplateType(Long templateId, QcReportSourceTypeEnum sourceType) {
        QcReportTemplateDO template = templateService.validateTemplateExists(templateId);
        String reportType = StrUtil.trimToNull(template.getReportType());
        if (reportType == null) {
            throw new ServiceException(QC_GENERATE_TEMPLATE_TYPE_MISMATCH.getCode(),
                    "报告模板「" + template.getTemplateName() + "」(ID=" + templateId
                            + ")没有设置报告类型,无法确认它是否适用于" + sourceType.getName() + "。"
                            + "请先到报告模板中把「报告类型」设为「" + sourceType.getName() + "」后再生成");
        }
        if (!sourceType.matchesTemplateReportType(reportType)) {
            QcReportSourceTypeEnum templateType = QcReportSourceTypeEnum.of(parseType(reportType));
            throw new ServiceException(QC_GENERATE_TEMPLATE_TYPE_MISMATCH.getCode(),
                    "报告模板「" + template.getTemplateName() + "」(ID=" + templateId + ")的报告类型是「"
                            + (templateType == null ? reportType : templateType.getName())
                            + "」,与本次质检单的类型「" + sourceType.getName() + "」不一致,无法生成报告。"
                            + "请重新选择一张" + sourceType.getName() + "的报告模板");
        }
    }
    /** æ¨¡æ¿ä¸Šçš„æŠ¥å‘Šç±»åž‹æ˜¯æ•°å­—字符串;解析不出(历史脏数据)就返回 null,交给调用方回退展示原文 */
    private Integer parseType(String reportType) {
        try {
            return Integer.valueOf(reportType);
        } catch (NumberFormatException ignored) {
            return null;
        }
    }
    @Override
    public GenerateResult regenerate(Long id) {
        QcReportInstanceDO instance = validateInstanceExists(id);
        if (CollUtil.isEmpty(instance.getDataSnapshot())) {
            throw exception(REPORT_INSTANCE_DATA_SNAPSHOT_MISSING);
        }
        // ç”¨ç”Ÿæˆæ—¶çš„那个版本,且**不要求它现在仍是已发布**:已发布版本的内容改不了,
        // ä½†å®ƒå¯èƒ½åŽæ¥è¢«åœç”¨ï¼›ä¸€ä»½å·²ç»å‡ºè¿‡çš„历史报告不该因此变成打不开。
        QcReportTemplateVersionDO version = versionService.getVersionByTemplateIdAndVersion(
                instance.getTemplateId(), instance.getTemplateVersion());
        if (version == null) {
            throw exception(TEMPLATE_VERSION_NOT_EXISTS);
        }
        ReportContext context = ReportContextCodec.fromMap(instance.getDataSnapshot());
        RenderOutcome outcome = QualityReportEngine.render(version.getSchema(), context);
        QcReportInstanceDO updateObj = new QcReportInstanceDO();
        updateObj.setId(id);
        updateObj.setRenderHtml(outcome.html());
        // æœ‰æ„ä¸è¦†ç›– data_snapshot:冻结的数据是历史报告的依据,
        // é‡æ–°ç”ŸæˆåŠ¨çš„åªæ˜¯æŽ’ç‰ˆäº§ç‰©ã€‚å¼•æ“Žå‡çº§åŽé‡æ¸²å¯èƒ½å¾—åˆ°ä¸åŒçš„ HTML,那正是「重新生成」该有的效果。
        instanceMapper.updateById(updateObj);
        // æŽ’版产物换了,之前归档的 PDF å°±æ˜¯ç”¨æ—§ HTML æ‰“的:留着它等于让用户下载到一份与页面对不上的文件。
        // å®å¯ä½œåºŸï¼ˆéšæ—¶èƒ½å†å¯¼ä¸€æ¬¡ï¼‰ï¼Œä¹Ÿä¸ç•™è¿™ç§é™é»˜ä¸ä¸€è‡´ã€‚
        storageAttachmentApi.deleteAttachmentsByRecord(RECORD_TYPE, id);
        return new GenerateResult(instance, outcome.errors());
    }
    @Override
    public PdfArchiveResult exportPdf(Long id) {
        QcReportInstanceDO instance = validateInstanceExists(id);
        if (StrUtil.isBlank(instance.getRenderHtml())) {
            throw exception(REPORT_INSTANCE_HTML_MISSING);
        }
        // ä¸Ž regenerate åŒä¸€å£å¾„:用生成时的那个版本,且不要求它现在仍是已发布,
        // åŽ†å²æŠ¥å‘Šä¸è¯¥å› ä¸ºç‰ˆæœ¬åŽæ¥è¢«åœç”¨å°±å¯¼ä¸å‡º
        QcReportTemplateVersionDO version = versionService.getVersionByTemplateIdAndVersion(
                instance.getTemplateId(), instance.getTemplateVersion());
        if (version == null) {
            throw exception(TEMPLATE_VERSION_NOT_EXISTS);
        }
        // çº¸å¼ å‡ ä½•与 HTML é‡Œé‚£è¡Œ @page åŒæºï¼ŒPDF çº¸é¢ä¸å¯èƒ½å’Œé¡µé¢é¢„览对不上
        PageSetting page = QualityReportEngine.pageOf(version.getSchema());
        LocalDateTime startTime = LocalDateTime.now();
        long startedAt = System.currentTimeMillis();
        try {
            // æ‰“的是实例里已存的 HTML,不重渲:这样「详情页看到的」与「打出来的」必然出自同一份字节
            PdfResult pdf = pdfRenderService.render(instance.getRenderHtml(), page,
                    Boolean.TRUE.equals(pdfProperties.getShowPageNumber()));
            String fileName = instance.getReportNo() + ".pdf";
            Long blobId = storageBlobApi.saveBlob(pdf.content(), fileName, PDF_CONTENT_TYPE);
            if (blobId == null) {
                throw new ServiceException(RENDER_PDF_ARCHIVE_FAILED.getCode(),
                        RENDER_PDF_ARCHIVE_FAILED.getMsg() + ":文件名「" + fileName
                                + "」,存档后未取得文件编号,请稍后重试");
            }
            // ä¸‰æ­¥é¡ºåºæ˜¯åˆ»æ„çš„:bindAttachments åªæ¢å…³è”、不删旧 blob,不先删就会每导一次
            // åœ¨ç£ç›˜ä¸Šå¤šç•™ä¸€ä»½æ²¡äººè®¤é¢†çš„ PDF;而「先删后存」又会在存档失败时把还好的旧 PDF ä¸€èµ·å¼„丢。
            // æ‰€ä»¥è®©æ–° blob å…ˆè½åœ°ï¼Œå†åˆ æ—§çš„,最后绑定。
            storageAttachmentApi.deleteAttachmentsByRecord(RECORD_TYPE, id);
            storageAttachmentApi.bindAttachments(APPLICATION, RECORD_TYPE, id, List.of(blobId));
            long durationMs = System.currentTimeMillis() - startedAt;
            StorageBlobRespDTO archived = findArchived(id, blobId);
            saveRenderRecord(instance, startTime, durationMs, pdf.pdfDurationMs(), pdf.browserStatus(), null);
            return new PdfArchiveResult(id, instance.getReportNo(),
                    archived != null && StrUtil.isNotBlank(archived.getName()) ? archived.getName() : fileName,
                    (long) pdf.content().length,
                    blobId,
                    archived == null ? null : archived.getStorageAttachmentId(),
                    archived == null ? null : archived.getPreviewURL(),
                    archived == null ? null : archived.getDownloadURL(),
                    durationMs, pdf.pdfDurationMs(), pdf.browserStatus());
        } catch (RuntimeException e) {
            // å¤±è´¥ä¹Ÿè¦ç•™ç—•(§41):先写一条带异常栈的记录,再把异常原样抛出,不吞
            saveRenderRecord(instance, startTime,
                    System.currentTimeMillis() - startedAt, null, null, stackTrace(e));
            throw e;
        }
    }
    @Override
    public QcReportInstanceDO validateInstanceExists(Long id) {
        QcReportInstanceDO instance = instanceMapper.selectById(id);
        if (instance == null) {
            throw exception(REPORT_INSTANCE_NOT_EXISTS);
        }
        return instance;
    }
    @Override
    public QcReportInstanceDO getInstance(Long id) {
        return instanceMapper.selectById(id);
    }
    @Override
    public Map<String, Object> getSnapshot(Long id) {
        return validateInstanceExists(id).getDataSnapshot();
    }
    @Override
    public PageResult<QcReportInstanceDO> getInstancePage(QcReportInstancePageReqVO pageReqVO) {
        return instanceMapper.selectPage(pageReqVO);
    }
    @Override
    public void deleteInstance(Long id) {
        validateInstanceExists(id);
        // å…ˆæ¸…附件再删实例:实例一没,归档的 PDF å°±æˆäº†æŸ¥ä¸åˆ°ä¸»äººçš„æ–‡ä»¶ï¼ˆé™„件行 + ç£ç›˜æ–‡ä»¶åŒä»½å­¤å„¿ï¼‰ã€‚
        // é¡ºåºåè¿‡æ¥çš„话,清理失败就再也没机会补救了(实例已不存在,不知道要删谁的附件)。
        storageAttachmentApi.deleteAttachmentsByRecord(RECORD_TYPE, id);
        instanceMapper.deleteById(id);
    }
    /* ------------------------------ å†…部 ------------------------------ */
    /**
     * å–刚绑定的那条附件。
     * <p>
     * é¢„览/下载地址由 system æ¨¡å—签名生成,本模块不自己拼 URL,用完即可(签名会过期,
     * å‰ç«¯è¦é•¿æœŸå¯ç”¨çš„地址应重新查附件列表)。
     */
    private StorageBlobRespDTO findArchived(Long id, Long blobId) {
        List<StorageBlobRespDTO> attachments = storageAttachmentApi.listAttachments(RECORD_TYPE, id);
        if (CollUtil.isEmpty(attachments)) {
            return null;
        }
        return attachments.stream()
                .filter(blob -> blobId.equals(blob.getId()))
                .findFirst()
                .orElse(attachments.get(0));
    }
    /**
     * å†™æ¸²æŸ“记录(§41)。
     * <p>
     * åªä¸ºæŽ’查用:一次出件占了多久、浏览器什么状态、失败时异常栈是什么。
     * å› æ­¤**写不进去不该让出件失败**,这里吞掉并记日志。
     */
    private void saveRenderRecord(QcReportInstanceDO instance, LocalDateTime startTime, long durationMs,
                                  Long pdfDurationMs, String browserStatus, String errorStack) {
        try {
            renderRecordMapper.insert(QcReportRenderRecordDO.builder()
                    .reportId(instance.getId())
                    .templateId(instance.getTemplateId())
                    .businessId(instance.getBusinessId())
                    .renderStartTime(startTime)
                    .renderEndTime(LocalDateTime.now())
                    .renderDuration(durationMs)
                    .pdfDuration(pdfDurationMs)
                    .browserStatus(browserStatus)
                    .errorStack(errorStack)
                    .build());
        } catch (Exception e) {
            log.warn("[saveRenderRecord][报告实例({}) çš„æ¸²æŸ“记录写入失败,忽略]", instance.getId(), e);
        }
    }
    private String stackTrace(Throwable e) {
        StringWriter writer = new StringWriter();
        e.printStackTrace(new PrintWriter(writer));
        return writer.toString();
    }
    /**
     * å®šå‡ºã€Œç”¨å“ªä¸ªæ¨¡æ¿ç‰ˆæœ¬æ¸²æŸ“」,并校验它真的有内容可渲。
     *
     * @param requirePublished æ˜¯å¦è¦æ±‚版本必须已发布。**出件要,预览不要**:
     *        å‡ºä»¶ä¼šæŠŠç‰ˆæœ¬å·å†»ç»“进报告实例,而实例里只记版本号、不记内容,
     *        ç”¨è‰ç¨¿å‡ºä»¶ç­‰äºŽç»™åŽ†å²æŠ¥å‘ŠåŸ‹ä¸€é¢—ã€Œç‰ˆæœ¬å·å¯¹å¾—ä¸Šã€å†…å®¹å·²ç»å˜äº†ã€çš„é›·ï¼›
     *        é¢„览不落库、不冻结数据、不消耗编号,草稿会变在这里没有任何后果,
     *        åè€Œã€Œä¿å­˜è‰ç¨¿ â†’ ç‚¹é¢„览」正是设计器的主流程。
     */
    private QcReportTemplateVersionDO resolveVersion(QcReportInstanceGenerateReqVO reqVO, boolean requirePublished) {
        QcReportTemplateDO template = templateService.validateTemplateExists(reqVO.getTemplateId());
        if (!ENABLE.getStatus().equals(template.getStatus())) {
            // ä¸Ž createVersion ä¿æŒä¸€è‡´ï¼šåœç”¨çš„æ¨¡æ¿ä¸å†äº§å‡ºæ–°æŠ¥å‘Šï¼ˆåŽ†å²æŠ¥å‘Šçš„é‡æ–°ç”Ÿæˆä¸èµ°è¿™æ¡è·¯å¾„ï¼‰
            throw exception(TEMPLATE_STATUS_DISABLED);
        }
        String version = StrUtil.trimToNull(reqVO.getVersion());
        if (version == null) {
            version = StrUtil.trimToNull(template.getCurrentVersion());
            if (version == null) {
                throw exception(TEMPLATE_NO_PUBLISHED_VERSION);
            }
        }
        QcReportTemplateVersionDO versionDO = requirePublished
                ? versionService.getPublishedVersion(template.getId(), version)
                // ä¸æ ¡éªŒå‘布状态时按「模板+版本号」直接取;取不到会返回 null(getPublishedVersion åˆ™æ˜¯æŠ›é”™ï¼‰
                : versionService.getVersionByTemplateIdAndVersion(template.getId(), version);
        if (versionDO == null) {
            throw exception(TEMPLATE_VERSION_NOT_EXISTS);
        }
        if (!QualityReportEngine.hasCanvas(versionDO.getSchema())) {
            // ç”»å¸ƒæ˜¯ç©ºçš„:渲染出来会是一张白纸,不如现在就说清楚
            throw exception(TEMPLATE_VERSION_CANVAS_EMPTY);
        }
        return versionDO;
    }
    /** æ ¡éªŒæ¸²æŸ“入参:没有检验项的「报告」没有意义,报出来比给一张空表好 */
    private ReportContext prepareContext(QcReportInstanceGenerateReqVO reqVO) {
        ReportContext context = reqVO.getContext();
        // è¯·æ±‚体里显式传 "report": null ä¼šæŠŠå®ƒç½®ç©ºï¼Œè¿™é‡Œå…œå›žé»˜è®¤å­—段集,
        // å¦åˆ™ reportMap() ä¼šåœ¨æ¸²æŸ“深处抛 NPE,用户只看到「系统异常」
        if (context.getReport() == null) {
            context.setReport(new ReportFields());
        }
        if (CollUtil.isEmpty(context.getInspectionItems())) {
            throw new ServiceException(RENDER_BUSINESS_DATA_MISSING.getCode(),
                    "检验项列表为空,没有可判定的内容:请至少传入一个检验项(inspectionItems)再出件");
        }
        return context;
    }
    /** è°ƒç”¨æ–¹æŒ‡å®šçš„编号:顶层 reportNo ä¼˜å…ˆï¼Œå…¶æ¬¡æŠ¥å‘Šå­—段里的 reportNo */
    private String resolveExplicitReportNo(QcReportInstanceGenerateReqVO reqVO) {
        String fromRequest = StrUtil.trimToNull(reqVO.getReportNo());
        if (fromRequest != null) {
            return fromRequest;
        }
        ReportFields report = reqVO.getContext().getReport();
        return report == null ? null : StrUtil.trimToNull(report.getReportNo());
    }
    /**
     * æ¸²æŸ“并落库。
     * <p>
     * ç”Ÿæ•ˆç¼–号要写回上下文再渲染——纸面上的报告编号与实例编号必须是同一个,
     * ä¸èƒ½å‡ºçŽ°ã€ŒæŠ¥å‘Šä¸Šå°ç€ä¸€ä¸ªå·ã€ç³»ç»Ÿé‡Œå­˜ç€å¦ä¸€ä¸ªå·ã€ã€‚
     */
    private GenerateResult renderAndPersist(QcReportInstanceGenerateReqVO reqVO, QcReportTemplateVersionDO version,
                                           ReportContext context, String reportNo) {
        context.getReport().setReportNo(reportNo);
        RenderOutcome outcome = QualityReportEngine.render(version.getSchema(), context);
        QcReportInstanceDO instance = QcReportInstanceDO.builder()
                .reportNo(reportNo)
                .templateId(version.getTemplateId())
                .templateVersion(version.getVersion())
                .businessId(reqVO.getBusinessId())
                .businessType(reqVO.getBusinessType())
                // å†»ç»“「判定后」的上下文:这才是产出这份 HTML çš„那份数据,含算好的 PASS/FAIL ä¸Žåˆæ ¼çއ
                .dataSnapshot(outcome.context().toScope())
                .renderHtml(outcome.html())
                .status(SUCCESS.getStatus())
                .build();
        instanceMapper.insert(instance);
        return new GenerateResult(instance, outcome.errors());
    }
    private ServiceException reportNoTaken(String reportNo) {
        return new ServiceException(REPORT_INSTANCE_NO_DUPLICATE.getCode(),
                "报告编号「" + reportNo + "」已被占用,请换一个编号,或留空由系统按 QR+日期+流水 è‡ªåŠ¨ç”Ÿæˆ");
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/instance/ReportNoGenerator.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,85 @@
package cn.iocoder.yudao.module.qcreport.service.instance;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
/**
 * æŠ¥å‘Šç¼–号生成。
 * <p>
 * æ ¼å¼ {@code QR + yyyyMMdd + "-" + 4 ä½æµæ°´},例如 {@code QR20260918-0001}。
 * æµæ°´æŒ‰å¤©ç‹¬ç«‹ï¼Œè·¨å¤©ä»Ž 0001 é‡æ–°å¼€å§‹â€”—报告编号是给人看的单据号,按天分段最好念、最好找。
 * <p>
 * è¿™é‡Œåªè´Ÿè´£ã€Œç®—」:取当天流水、拼字符串。落库时的并发冲突由调用方重试兜底,
 * æ‰€ä»¥è¿™ä¸ªç±»ä¸ä¾èµ–数据库、不依赖 Spring,可以直接单测。
 */
public final class ReportNoGenerator {
    /** ç¼–号前缀。QR = Quality Report */
    private static final String PREFIX = "QR";
    /**
     * åŒæ ·é” {@link Locale#ROOT}:{@code ofPattern} å–默认 locale,
     * è€Œé»˜è®¤ locale è‡ªå¸¦åŽ†æ³•ï¼ˆå¦‚æ³°åŽ†ï¼‰ï¼Œä¼šè®©å¹´ä»½æ•´ä½“åç§»ã€‚
     */
    private static final DateTimeFormatter DAY_FORMAT =
            DateTimeFormatter.ofPattern("yyyyMMdd", Locale.ROOT);
    /** åºå·ä½æ•°ä¸è¶³æ—¶è¡¥åˆ°è¿™ä¸ªé•¿åº¦ */
    private static final int SEQUENCE_WIDTH = 4;
    /**
     * åºå·æœ€å¤šè®¤è¿™ä¹ˆé•¿ã€‚编号由本类生成,正常就是 4~6 ä½ï¼›
     * å‡ºçŽ°æ›´é•¿çš„å°¾å·´è¯´æ˜Žå­˜è¿›åŽ»çš„ä¸æ˜¯æœ¬ç”Ÿæˆå™¨çš„äº§ç‰©ï¼Œå½“ä½œã€Œä¸è®¤è¯†ã€è€Œä¸æ˜¯ç¡¬è§£æžã€‚
     */
    private static final int MAX_SEQUENCE_DIGITS = 9;
    private ReportNoGenerator() {
    }
    /** æŸå¤©çš„编号前缀,形如 {@code QR20260918-} */
    public static String prefix(LocalDate day) {
        return PREFIX + DAY_FORMAT.format(day) + "-";
    }
    /**
     * æ‹¼å‡ºå®Œæ•´æŠ¥å‘Šç¼–号,形如 {@code QR20260918-0001};序号超过 9999 æ—¶è‡ªç„¶åŠ å®½ã€‚
     * <p>
     * é” {@link Locale#ROOT}:{@code %d} ä¼šè·Ÿéšé»˜è®¤ locale é€‰æ•°å­—字形,
     * æœåС噍 locale ä¸€æ—¦ä¸æ˜¯æ‹‰ä¸è¯­ç³»ï¼Œç¼–号里就会出现阿拉伯-印度数字之类的非 ASCII å­—符。
     */
    public static String format(LocalDate day, int sequence) {
        return prefix(day) + String.format(Locale.ROOT, "%0" + SEQUENCE_WIDTH + "d", sequence);
    }
    /**
     * ç”±ã€Œå½“天已有的最大编号」推出下一个序号。
     * <p>
     * ä¸æ˜¯åŒä¸€å¤©ã€æ ¼å¼ä¸è®¤è¯†ã€æˆ–还没有任何编号时,都从 1 å¼€å§‹ï¼šè¿™äº›æƒ…况都意味着
     * ã€Œä»Šå¤©æ˜¯ç¬¬ä¸€å¤©ã€ï¼Œè€Œä¸æ˜¯å‡ºé”™â€”—比如传进来的是昨天的号。
     *
     * @param latestReportNo å½“天已有的最大报告编号,可为 null
     * @param day            å‡ºä»¶å½“天
     * @return ä¸‹ä¸€ä¸ªåºå·ï¼Œè‡³å°‘为 1
     */
    public static int nextSequence(String latestReportNo, LocalDate day) {
        if (latestReportNo == null) {
            return 1;
        }
        String prefix = prefix(day);
        if (!latestReportNo.startsWith(prefix)) {
            return 1;
        }
        String tail = latestReportNo.substring(prefix.length());
        if (tail.length() < SEQUENCE_WIDTH || tail.length() > MAX_SEQUENCE_DIGITS) {
            return 1;
        }
        for (int i = 0; i < tail.length(); i++) {
            if (!Character.isDigit(tail.charAt(i))) {
                return 1;
            }
        }
        return Integer.parseInt(tail) + 1;
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/render/BrowserManager.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,229 @@
package cn.iocoder.yudao.module.qcreport.service.render;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.qcreport.config.QcReportPdfProperties;
import com.microsoft.playwright.Browser;
import com.microsoft.playwright.BrowserType;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Playwright;
import com.microsoft.playwright.PlaywrightException;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.RENDER_PDF_BROWSER_LAUNCH_FAILED;
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.RENDER_PDF_BUSY;
/**
 * PDF æ¸²æŸ“用的浏览器持有者
 * <p>
 * æ•´ä¸ªè¿›ç¨‹åªå¯ä¸€ä¸ª {@link Playwright} ä¸Žä¸€ä¸ª {@link Browser}:设计文档 Â§30 æ˜Žæ–‡ç¦æ­¢
 * ã€Œæ¯æ¬¡è¯·æ±‚ launch â†’ ç”Ÿæˆ â†’ close」——Chromium å†·å¯åŠ¨ä»¥ç§’è®¡ï¼Œæ¯æ¬¡é‡å¯ä¼šè®©å‡ºä»¶æ…¢åˆ°ä¸å¯ç”¨ï¼Œ
 * ä¹Ÿå®¹æ˜“把内存吃光。启动是懒的:没有 PDF éœ€æ±‚就不该拉起一个浏览器进程。
 * <p>
 * å€Ÿå‡ºæœŸé—´ç”¨ {@link Semaphore} è®¾é—¸ï¼ˆÂ§30/§39 è¦æ±‚控制 Playwright å¹¶å‘),
 * æŽ’队超过 {@code timeoutMs} ç›´æŽ¥æŠ¥å¿™ï¼Œä¸æ— é™å †ç§¯ï¼›æ¯æ¬¡å€Ÿå‡ºå‰æ£€æŸ¥ {@link Browser#isConnected()},
 * æ–­è¿žï¼ˆæµè§ˆå™¨å´©æºƒ/被外部杀掉)就重启一个,不让一次崩溃把后续所有出件都拖死。
 */
@Slf4j
@Component
public class BrowserManager {
    /** æœ¬æ¬¡å¤ç”¨äº†å·²åœ¨è¿è¡Œçš„æµè§ˆå™¨å®žä¾‹ */
    public static final String STATUS_READY = "READY";
    /** æœ¬æ¬¡å‘现浏览器不可用(还没启过,或已断开),重新启动了一个 */
    public static final String STATUS_RESTARTED = "RESTARTED";
    /**
     * Playwright è®¤çš„「别下载自带浏览器」开关。
     * <p>
     * é€šè¿‡ {@link Playwright.CreateOptions#setEnv} ä¼ ç»™é©±åŠ¨è¿›ç¨‹ï¼Œæ•ˆæžœç­‰åŒäºŽåœ¨æœºå™¨ä¸Šè®¾è¿™ä¸ªçŽ¯å¢ƒå˜é‡ï¼Œ
     * ä½†ä¸éœ€è¦è¿ç»´åŽ»æ”¹å¯åŠ¨è„šæœ¬ â€”— é¡¹ç›®åœ¨å“ªå°æœºå™¨ä¸Šè·‘都一致。
     */
    private static final String SKIP_BROWSER_DOWNLOAD_ENV = "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD";
    @Resource
    private QcReportPdfProperties properties;
    /** å¹¶å‘名额。借出期间占一个,用完归还 */
    private volatile Semaphore slots;
    /** ä¿æŠ¤ {@link #playwright} / {@link #browser} çš„启动、重启与关闭 */
    private final Object browserLock = new Object();
    private Playwright playwright;
    private Browser browser;
    @PostConstruct
    public void init() {
        Integer concurrency = properties.getConcurrency();
        this.slots = new Semaphore(concurrency == null || concurrency < 1 ? 1 : concurrency);
    }
    /**
     * å€Ÿä¸€ä¸ªé¡µé¢æ‰§è¡Œ {@code action}:负责并发设闸、浏览器保活、页面关闭与名额归还。
     *
     * @return åŠ¨ä½œè¿”å›žå€¼ + æœ¬æ¬¡æµè§ˆå™¨çŠ¶æ€ï¼ˆ{@link #STATUS_READY} / {@link #STATUS_RESTARTED}),后者供渲染记录留痕
     */
    public <T> PageResult<T> withPage(Function<Page, T> action) {
        if (!tryAcquire()) {
            throw new ServiceException(RENDER_PDF_BUSY.getCode(),
                    RENDER_PDF_BUSY.getMsg() + "(当前并发上限 " + properties.getConcurrency()
                            + ",请等前一个任务结束后重试)");
        }
        try {
            boolean restarted = false;
            Browser target;
            synchronized (browserLock) {
                if (browser != null && browser.isConnected()) {
                    target = browser;
                } else {
                    target = launchBrowser();
                    restarted = true;
                }
            }
            Page page = target.newPage();
            try {
                return new PageResult<>(action.apply(page), restarted ? STATUS_RESTARTED : STATUS_READY);
            } finally {
                closePage(page);
            }
        } finally {
            slots.release();
        }
    }
    @PreDestroy
    public void destroy() {
        synchronized (browserLock) {
            closeBrowser();
            if (playwright != null) {
                try {
                    playwright.close();
                } catch (Exception e) {
                    log.warn("[destroy][关闭 Playwright å¤±è´¥]", e);
                }
                playwright = null;
            }
        }
    }
    private boolean tryAcquire() {
        Long configured = properties.getTimeoutMs();
        long timeoutMs = configured == null || configured < 1 ? 30000L : configured;
        try {
            return slots.tryAcquire(timeoutMs, TimeUnit.MILLISECONDS);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return false;
        }
    }
    /**
     * å¯åŠ¨æµè§ˆå™¨ã€‚è°ƒç”¨æ–¹å¿…é¡»æŒæœ‰ {@link #browserLock}。
     * <p>
     * å¯åŠ¨å‚æ•°ä¼˜å…ˆçº§ï¼š{@code executable-path} > {@code channel} > Playwright è‡ªå¸¦çš„ Chromium。
     */
    private Browser launchBrowser() {
        closeBrowser();
        BrowserType.LaunchOptions options = new BrowserType.LaunchOptions().setHeadless(true);
        String executablePath = text(properties.getExecutablePath());
        String channel = text(properties.getChannel());
        if (executablePath != null) {
            options.setExecutablePath(Path.of(executablePath));
        } else if (channel != null) {
            options.setChannel(channel);
        }
        List<String> args = properties.getBrowserArgs();
        if (args != null && !args.isEmpty()) {
            options.setArgs(args);
        }
        try {
            if (playwright == null) {
                playwright = createPlaywright(channel, executablePath);
            }
            Browser launched = playwright.chromium().launch(options);
            this.browser = launched;
            log.info("[launchBrowser][PDF æ¸²æŸ“浏览器已启动,来源({}),版本({})]",
                    describeSource(channel, executablePath), launched.version());
            return launched;
        } catch (PlaywrightException e) {
            closeBrowser();
            throw new ServiceException(RENDER_PDF_BROWSER_LAUNCH_FAILED.getCode(),
                    RENDER_PDF_BROWSER_LAUNCH_FAILED.getMsg() + ":浏览器来源为"
                            + describeSource(channel, executablePath) + ",实际原因是「" + e.getMessage()
                            + "」。请确认该浏览器已安装在该机器上并在 yudao.qcreport.pdf.executable-path ä¸­æŒ‡å‘其可执行文件;"
                            + "或把 channel ç•™ç©ºã€æ‰§è¡Œ playwright install chromium åŽä½¿ç”¨ Playwright è‡ªå¸¦çš„ Chromium。");
        }
    }
    /**
     * å¯åЍ Playwright é©±åŠ¨ã€‚
     * <p>
     * <b>用机器自带的浏览器时,必须让它跳过「下载 Playwright è‡ªå¸¦æµè§ˆå™¨ã€è¿™ä¸€æ­¥ã€‚</b>
     * å¦åˆ™ {@code Playwright.create()} ä¼šåŒæ­¥æ‰§è¡Œä¸€æ¬¡ {@code playwright install}(chromium + firefox + webkit,
     * å‡ ç™¾ MB);外网不通的环境下它会先卡满 10 åˆ†é’Ÿå†æŠ›
     * {@code Timed out waiting for browsers to install} â€”— é¦–次导出直接变成一次十分钟的挂起。
     * æˆ‘们既然已经有 Chrome å¯æ‰“({@code executable-path} æˆ– {@code channel}),这份自带浏览器就是多余的。
     * <p>
     * åä¹‹ï¼Œä¸¤è€…都没配(即明确要用自带 Chromium)时不加这个变量:那时确实需要 Playwright è‡ªå·±åŽ»è£…ï¼Œ
     * è£…不上会在 {@link com.microsoft.playwright.BrowserType#launch} å¤„报明确错误。
     */
    private Playwright createPlaywright(String channel, String executablePath) {
        Playwright.CreateOptions options = new Playwright.CreateOptions();
        if (channel != null || executablePath != null) {
            options.setEnv(Map.of(SKIP_BROWSER_DOWNLOAD_ENV, "1"));
        }
        return Playwright.create(options);
    }
    private String describeSource(String channel, String executablePath) {
        if (executablePath != null) {
            return "executable-path=" + executablePath;
        }
        if (channel != null) {
            return "channel=" + channel;
        }
        return "Playwright è‡ªå¸¦ Chromium";
    }
    private static String text(String value) {
        return StringUtils.hasText(value) ? value.trim() : null;
    }
    private void closeBrowser() {
        if (browser != null) {
            try {
                browser.close();
            } catch (Exception e) {
                log.warn("[closeBrowser][关闭浏览器失败,忽略并重建]", e);
            }
            browser = null;
        }
    }
    private void closePage(Page page) {
        try {
            page.close();
        } catch (Exception e) {
            log.warn("[closePage][关闭页面失败,忽略]", e);
        }
    }
    /**
     * å€Ÿå‡ºç»“果:动作返回值 + æœ¬æ¬¡æµè§ˆå™¨çŠ¶æ€ã€‚
     */
    public record PageResult<T>(T value, String browserStatus) {
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/render/PdfPrintOptions.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,76 @@
package cn.iocoder.yudao.module.qcreport.service.render;
import cn.iocoder.yudao.module.qcreport.engine.Numbers;
import cn.iocoder.yudao.module.qcreport.engine.PageSetting;
import cn.iocoder.yudao.module.qcreport.engine.PageSizes;
import cn.iocoder.yudao.module.qcreport.engine.ResolvedPage;
/**
 * æ‰“印参数:把纸张配置换算成 Chromium æ‰“印 PDF éœ€è¦çš„几何与样式。
 * <p>
 * çº¯å‡½æ•°ã€ä¸ä¾èµ– Spring ä¸Ž Playwright,便于单测。
 * <p>
 * å‡ ä½•**一律**从 {@link PageSizes#resolve} å–,与 {@code HtmlRenderer} å†™è¿› HTML çš„那行
 * {@code @page { size: â€¦; margin: â€¦; }} åŒä¸€ä¸ªæ¥æºã€åŒä¸€ä¸ªæ•°å­—格式化函数
 * ï¼ˆ{@link Numbers#toString(double)}),因此不存在「PDF çº¸é¢ä¸Žé¡µé¢é¢„览对不上」的可能。
 * ç”¨ {@code String.format} ä¼šå¼•å…¥ locale å·®å¼‚并输出 {@code 210.0} è¿™ç±»ä¸Žå‰ç«¯ä¸ä¸€è‡´çš„æ–‡æœ¬ã€‚
 */
public record PdfPrintOptions(String widthCss, String heightCss,
                              String marginTopCss, String marginRightCss,
                              String marginBottomCss, String marginLeftCss,
                              String footerTemplate, String printCss) {
    /**
     * ä»…供打印的加固样式,由 Playwright {@code addStyleTag} æ³¨å…¥ã€‚
     * <p>
     * <b>不能写进渲染引擎的 BASE_CSS</b>:产物 HTML ä¸Žå‰ç«¯å†»ç»“样例是逐字比对的,
     * åŠ¨å®ƒä¼šè®©å­˜é‡æŠ¥å‘Šçš„ã€Œé‡æ–°ç”Ÿæˆã€ç»“æžœå˜æ ·ã€‚
     * <p>
     * <b>{@code break-inside: avoid} ç»ä¸èƒ½åŠ åœ¨ {@code table} ä¸Š</b>:那是「整张表不许跨页」,
     * é•¿è¡¨åœ¨ä¸€é¡µæ”¾ä¸ä¸‹æ—¶ä¼šè¢«æ•´ä½“搬到下一页,第 1 é¡µåªå‰©é¡µçœ‰ä¸Žå¤§ç‰‡ç©ºç™½ï¼ˆå·²å®žé™…观测到,
     * 60 è¡Œæ£€éªŒé¡¹çš„表就是如此)。长表跨页靠的是 {@code thead} åœ¨æ¯é¡µé‡å¤è¡¨å¤´ + è¡Œå†…不断页,
     * æ‰€ä»¥åå•里保留 {@code tr/td/th/img}、去掉 {@code table}。
     */
    public static final String PRINT_CSS = """
            thead { display: table-header-group; }
            tfoot { display: table-footer-group; }
            tr, td, th, img { break-inside: avoid; page-break-inside: avoid; }
            body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }""";
    /**
     * é¡µè„šæ¨¡æ¿ã€‚Playwright çš„ header/footer æ¨¡æ¿ä¸è¿›æµè§ˆå™¨é»˜è®¤æ ·å¼ï¼Œ
     * å¿…须自带内联样式与字号,否则是 0 å·å­—看不见。
     */
    private static final String FOOTER_TEMPLATE = """
            <div style="width:100%;font-size:9px;font-family:'Microsoft YaHei',sans-serif;\
            color:#333;text-align:center;padding:0 10mm;">第 <span class="pageNumber"></span> \
            é¡µ / å…± <span class="totalPages"></span> é¡µ</div>""";
    /**
     * é¡µçœ‰æ¨¡æ¿ï¼šä¸€ä¸ªç©º div。
     * <p>
     * å¿…须显式给一个<b>非 null</b> çš„值。开了 {@code displayHeaderFooter} å´æŠŠ headerTemplate
     * ç•™ç©ºæ—¶ï¼ŒChromium ä¼šç”¨å®ƒè‡ªå·±çš„默认页眉 â€”— å·¦ä¸Šè§’打印日期({@code 2026/9/18 21:34})、
     * å³ä¸Šè§’文档标题。那两串东西没人要过,页眉时间还容易被误当成报告出具时间,
     * æ‰€ä»¥è¿™é‡Œç”¨ä¸€ä¸ªç©º div æŠŠå®ƒé¡¶æŽ‰ï¼Œé¡µé¢ä¸Šåªä¿ç•™æˆ‘们自己的页脚页码。
     */
    public static final String HEADER_TEMPLATE = "<div></div>";
    public static PdfPrintOptions of(PageSetting page, boolean withPageNumber) {
        ResolvedPage resolved = PageSizes.resolve(page);
        return new PdfPrintOptions(
                mm(resolved.widthMm()),
                mm(resolved.heightMm()),
                mm(resolved.marginTopMm()),
                mm(resolved.marginRightMm()),
                mm(resolved.marginBottomMm()),
                mm(resolved.marginLeftMm()),
                withPageNumber ? FOOTER_TEMPLATE : "",
                PRINT_CSS);
    }
    private static String mm(double value) {
        return Numbers.toString(value) + "mm";
    }
}
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/render/PdfRenderService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,104 @@
package cn.iocoder.yudao.module.qcreport.service.render;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.qcreport.config.QcReportPdfProperties;
import cn.iocoder.yudao.module.qcreport.engine.PageSetting;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.PlaywrightException;
import com.microsoft.playwright.options.Margin;
import com.microsoft.playwright.options.WaitUntilState;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.RENDER_PDF_DISABLED;
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.RENDER_PDF_FAILED;
/**
 * PDF æ¸²æŸ“:把报告 HTML äº¤ç»™ Chromium æ‰“印
 * <p>
 * æ‰“印的是**调用方给的 HTML åŽŸæ–‡**,这里不重渲、不碰渲染引擎。报告实例存着什么 HTML,
 * PDF å°±æ˜¯ä»€ä¹ˆæ ·å­ â€”— è®¾è®¡æ–‡æ¡£ Â§32 è¦æ±‚「不要出现设计器显示正常但 PDF ä¸¥é‡å˜å½¢ã€ï¼Œ
 * è€Œä¿è¯è¿™ä¸€ç‚¹çš„唯一办法就是让两者出自同一份字节;HTML éœ€è¦é‡æŽ’是「重新生成」的职责。
 */
@Service
public class PdfRenderService {
    @Resource
    private QcReportPdfProperties properties;
    @Resource
    private BrowserManager browserManager;
    /**
     * æ¸²æŸ“ PDF
     *
     * @param html           æŠ¥å‘Š HTML åŽŸæ–‡ï¼ˆå®žä¾‹é‡Œå·²å­˜çš„æ¸²æŸ“äº§ç‰©ï¼‰
     * @param page           çº¸å¼ é…ç½®ï¼Œçª—口大小与页边距由它换算而来,与 HTML é‡Œçš„ {@code @page} åŒæº
     * @param withPageNumber æ˜¯å¦æ‰“印页脚「第 N é¡µ / å…± M é¡µã€
     */
    public PdfResult render(String html, PageSetting page, boolean withPageNumber) {
        if (!Boolean.TRUE.equals(properties.getEnabled())) {
            throw new ServiceException(RENDER_PDF_DISABLED.getCode(),
                    RENDER_PDF_DISABLED.getMsg() + "(yudao.qcreport.pdf.enabled å½“前为 false)");
        }
        PdfPrintOptions options = PdfPrintOptions.of(page, withPageNumber);
        long start = System.currentTimeMillis();
        BrowserManager.PageResult<byte[]> result =
                browserManager.withPage(p -> print(p, html, options, withPageNumber));
        long pdfDuration = System.currentTimeMillis() - start;
        byte[] content = result.value();
        if (content == null || content.length == 0) {
            throw new ServiceException(RENDER_PDF_FAILED.getCode(),
                    RENDER_PDF_FAILED.getMsg() + ":浏览器未返回任何 PDF å­—节,请检查该报告实例的 HTML æ˜¯å¦å¯ç”¨åŽé‡è¯•。");
        }
        return new PdfResult(content, pdfDuration, result.browserStatus());
    }
    private byte[] print(Page page, String html, PdfPrintOptions options, boolean withPageNumber) {
        try {
            page.setContent(html, new Page.SetContentOptions()
                    .setWaitUntil(WaitUntilState.NETWORKIDLE)
                    .setTimeout(timeout()));
            // åŠ å›ºæ ·å¼åœ¨è¿™é‡Œæ³¨å…¥ï¼Œä¸å†™è¿›æ¸²æŸ“å¼•æ“Žçš„ BASE_CSS:产物 HTML ä¸Žå‰ç«¯å†»ç»“样例是逐字比对的
            page.addStyleTag(new Page.AddStyleTagOptions().setContent(options.printCss()));
            return page.pdf(new Page.PdfOptions()
                    .setWidth(options.widthCss())
                    .setHeight(options.heightCss())
                    .setMargin(new Margin()
                            .setTop(options.marginTopCss())
                            .setRight(options.marginRightCss())
                            .setBottom(options.marginBottomCss())
                            .setLeft(options.marginLeftCss()))
                    // PASS/FAIL è¿™ç±»åˆ¤å®šåº•色是靠背景色表达的,不打背景色就全白
                    .setPrintBackground(true)
                    .setDisplayHeaderFooter(withPageNumber)
                    // å¿…须显式给页眉,否则 Chromium ä¼šè¡¥ä¸Šå®ƒè‡ªå¸¦çš„「打印日期 + æ–‡æ¡£æ ‡é¢˜ã€
                    .setHeaderTemplate(PdfPrintOptions.HEADER_TEMPLATE)
                    .setFooterTemplate(options.footerTemplate())
                    // è®© HTML è‡ªå¸¦çš„ @page åšçº¸é¢å‡ ä½•的唯一权威,上面的显式宽高只作兜底
                    .setPreferCSSPageSize(true));
        } catch (PlaywrightException e) {
            throw new ServiceException(RENDER_PDF_FAILED.getCode(),
                    RENDER_PDF_FAILED.getMsg() + ":打印过程中浏览器报错,实际原因是「" + e.getMessage()
                            + "」。可先对该报告执行「重新生成」再导出,若仍失败请联系管理员查看渲染记录。");
        }
    }
    private double timeout() {
        Long configured = properties.getTimeoutMs();
        return configured == null || configured < 1 ? 30000L : configured;
    }
    /**
     * PDF æ¸²æŸ“结果。
     *
     * @param content       PDF å­—节
     * @param pdfDurationMs æ‰“印耗时(毫秒):仅含等页面资源与 {@code page.pdf()},
     *                      ä¸Žè°ƒç”¨æ–¹ç»Ÿè®¡çš„「总耗时」之差即排队与浏览器启动的等待
     * @param browserStatus æœ¬æ¬¡æµè§ˆå™¨çŠ¶æ€ï¼ˆ{@code READY} / {@code RESTARTED})
     */
    public record PdfResult(byte[] content, long pdfDurationMs, String browserStatus) {
    }
}
在上述文件截断后对比
yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/template/QcReportTemplateService.java yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/template/QcReportTemplateServiceImpl.java yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/version/QcReportTemplateVersionService.java yudao-module-qcreport/src/main/java/cn/iocoder/yudao/module/qcreport/service/version/QcReportTemplateVersionServiceImpl.java yudao-module-qcreport/src/test/java/cn/iocoder/yudao/module/qcreport/dal/dataobject/version/ReportTemplateSchemaCompatibilityTest.java yudao-module-qcreport/src/test/java/cn/iocoder/yudao/module/qcreport/engine/FrontendConformanceTest.java yudao-module-qcreport/src/test/java/cn/iocoder/yudao/module/qcreport/engine/QualityReportEngineTest.java yudao-module-qcreport/src/test/java/cn/iocoder/yudao/module/qcreport/engine/ReportEvaluatorTest.java yudao-module-qcreport/src/test/java/cn/iocoder/yudao/module/qcreport/engine/context/ReportContextCodecTest.java yudao-module-qcreport/src/test/java/cn/iocoder/yudao/module/qcreport/engine/render/CanvasSafetyTest.java yudao-module-qcreport/src/test/java/cn/iocoder/yudao/module/qcreport/service/aiimport/document/AiImportFixtures.java yudao-module-qcreport/src/test/java/cn/iocoder/yudao/module/qcreport/service/aiimport/document/QcReportImportAdapterTest.java yudao-module-qcreport/src/test/java/cn/iocoder/yudao/module/qcreport/service/aiimport/llm/QcReportAiDraftNormalizerTest.java yudao-module-qcreport/src/test/java/cn/iocoder/yudao/module/qcreport/service/aiimport/llm/QcReportAiDraftParserTest.java yudao-module-qcreport/src/test/java/cn/iocoder/yudao/module/qcreport/service/aiimport/llm/QcReportLlmCallPlannerTest.java yudao-module-qcreport/src/test/java/cn/iocoder/yudao/module/qcreport/service/aiimport/llm/QcReportTemplatePromptBuilderTest.java yudao-module-qcreport/src/test/java/cn/iocoder/yudao/module/qcreport/service/instance/MesQcReportContextMapperTest.java yudao-module-qcreport/src/test/java/cn/iocoder/yudao/module/qcreport/service/instance/ReportNoGeneratorTest.java yudao-module-qcreport/src/test/java/cn/iocoder/yudao/module/qcreport/service/render/PdfPrintOptionsTest.java yudao-module-qcreport/src/test/resources/qcreport/canvas/canvas-t1-v1.0.json yudao-module-qcreport/src/test/resources/qcreport/canvas/canvas-t1-v1.1.json yudao-module-qcreport/src/test/resources/qcreport/canvas/canvas-t3-v1.0.json yudao-module-qcreport/src/test/resources/qcreport/canvas/canvas-t5-v1.0.json yudao-module-qcreport/src/test/resources/qcreport/canvas/canvas-t5-v1.1.json yudao-module-qcreport/src/test/resources/qcreport/canvas/canvas-t5-v1.2.json yudao-module-qcreport/src/test/resources/qcreport/context.json yudao-module-qcreport/src/test/resources/qcreport/expected-html.html yudao-module-qcreport/src/test/resources/qcreport/expected-rules.json yudao-module-qcreport/src/test/resources/qcreport/schema.json yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/storage/StorageBlobApi.java yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/storage/StorageBlobApiImpl.java yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/storage/SystemStorageBlobService.java yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/storage/SystemStorageBlobServiceImpl.java yudao-server/pom.xml yudao-server/src/main/resources/application-local.yaml yudao-server/src/main/resources/application-test.yaml