状态:**已定稿,开工**。契约与结构见第四节,实现清单见第五节。
关联: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 —— 过程参数没有规格,组头更是只有公式。这条决定了「检测要求列放公式」不会覆盖掉任何已有内容。
三处断层:
MesQcIndicatorDO 只映射 id/code/name/type/tool/resultType/resultSpecification/remark —— parent_id、item_type、formula_text、unit_text、sort_order 一个都没映射,层级到 DO 就丢。MesQcReportItemRespDTO 没有任何父/组字段,类注释明写「报告侧不需要知道样品与实测值的层级关系,逐行渲染即可」。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 行之和,合并成立;display:none 剔除后,这些行的可见单元格 left 与表头第 3–7 列**逐列相等** → 列对齐无损。探针当时并了**两**格(组名 + 公式);最终结构只并组名一格(见 §4.1 的口径说明),是探针结论的真子集 —— 机制相同,只是少并一格,第 4 列照常逐行取值。
初稿写「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。再加两条实测把它的行为钉死:
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,属性会被跳过 → 该隐藏的行反而露出来。在**现有 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" 照样隐藏,所以只能用「空串 = 不输出」这一个可判定的形态。名字取得直白,值就是该属性的值。
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 本就是这份主数据里表意「显示顺序」的字段,且本单里独立项两者恰好一致,实际影响面很小。若业务要求严格按行序,改回一行即可(排序键换成行序)。requirement 取公式、span 背整组行数(见 §4.1 的合并口径),并在响应的 synthesizedGroupNames 里说明为哪几个组头补了行、为什么。mes_qc_template_indicator 决定本单有哪些行,模板可能只勾子项),所以按「补行」实现。parent_id 指向它)—— 按独立检验项列出、组名不合并,并把它的名字收进 childlessGroupNames 说明。新增组件 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}} |
无判定规则 | 自己的判定 | 自己的判定 |
结构要点:
repeat,路径 inspectionItems(默认值,属性面板可改)。QualityTable 的静态单元格样式(components 返回 HTML 字符串,与其余 11 个组件同款写法)。rowspan / hidden 必须写在**属性**上,不能写进行内 style(行内样式会被 GrapesJS 解析成节点 style 对象,而 renderStyle 不跑绑定)。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 让位后列是否串位)取决于浏览器表格布局,逐字比对固定不住这一条,探针 + 浏览器实测才是有效证据。
total 仍是真实项数。QualityTable 与 GroupedQualityTable 之间选对,依赖 aiHint 与提示词规则;识别准确率无法先验保证,需真实调用观察后如实汇报。HtmlRenderer / BASE_CSS(P5 已验证零改动可行)。tbody 嵌套(P4 可行但属降级兼容路径,且污染统计,不采用)。parent_id 已能表达,但渲染结构要重新设计)。