# 复杂检验项(分组检验项)还原 — 需求与方案 > 状态:**已定稿,开工**。契约与结构见第四节,实现清单见第五节。 > 关联:`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`): - 首行两格 `水分w = …`,高度 = 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` 已能表达,但渲染结构要重新设计)。