编辑 | blame | 历史 | 原始文档

复杂检验项(分组检验项)还原 — 需求与方案

状态:**已定稿,开工**。契约与结构见第四节,实现清单见第五节。
关联: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_iditem_typeformula_textunit_textsort_order 一个都没映射,层级到 DO 就丢。
  2. MesQcReportItemRespDTO 没有任何父/组字段,类注释明写「报告侧不需要知道样品与实测值的层级关系,逐行渲染即可」。
  3. MesQcReportApiImpl.buildItemsindicator.getResultType() == null 当「这是分组标题」的判据,把父项丢进 groupItemNames 走 warnings 而不是入报告。**方向是反的**:被丢掉的恰好是带公式的组名(水分/酸含量/正丁醇含量),留下来的恰好是过程参数行。

三、引擎能表达什么:探针实测结论

脚本 .qc-conformance/verify-group-repeat.ts(只读,不改代码不动库),产物 group-probe-p4.htmlgroup-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 绑定生效、顺序正确;但组必须作为一项进数组 → 同样污染统计;且需要 tbodytbody
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.initgetWrapper().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.javaInspectionItemrequirement 与嵌套 ItemGroup groupitemMap() 显式放入(该方法逐字段枚举,不靠反射;group 为 null 时不放,避免污染冻结 fixture 的作用域)。
  • 前端 components/quality/engine/context.tsReportContextItem 加同名的可选 requirementgroup

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. GroupedQualityTableQualityTable 二者用其一:前者用于有分组的单据,后者用于纯平的检验项表。

五、落地清单

后端 yudao-module-mes
- MesQcIndicatorDO:补映射 parent_id / item_type / formula_text / unit_text / sort_order
- MesQcReportItemRespDTO:加 requirementgroupChildName / 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.javaInspectionItemrequirementgroupitemMap() 带上。
- MesQcReportContextMapper:把 MES 的 requirement / group* 透进报告上下文;组头锚点行不再报「尚未录入实测值」(它按定义没有实测值);appendSkipWarnings 文案随父项不再被丢弃而调整。

前端 mom-pro2-before
- engine/context.tsReportContextItemrequirement / group
- 新增 inspection/grouped-quality-table.ts 并注册;aiHint 写清「有分组时用它,无分组用 QualityTable」。
- .qc-conformance/assemble-ts.tsdefinitions.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 导入 —— 模型要能在 QualityTableGroupedQualityTable 之间选对,依赖 aiHint 与提示词规则;识别准确率无法先验保证,需真实调用观察后如实汇报。

七、明确不做

  • 不改 HtmlRenderer / BASE_CSS(P5 已验证零改动可行)。
  • 不改 Schema 契约 1.1、不新增顶层上下文数组。
  • 不为「合并单元格」引入 tbody 嵌套(P4 可行但属降级兼容路径,且污染统计,不采用)。
  • 不在属性面板引入结构化 props(既有能力不支持,且无必要)。
  • 不把组内子项再向下嵌套(当前数据只有两层;真要更深的层级,parent_id 已能表达,但渲染结构要重新设计)。