|
@@ -423,6 +441,14 @@ const TYPE_PARAM_TABLE = ``;
/**
diff --git a/apps/api/src/modules/interaction/__tests__/view-render-addrow-thead.test.ts b/apps/api/src/modules/interaction/__tests__/view-render-addrow-thead.test.ts
new file mode 100644
index 0000000..3ec348b
--- /dev/null
+++ b/apps/api/src/modules/interaction/__tests__/view-render-addrow-thead.test.ts
@@ -0,0 +1,229 @@
+/**
+ * **「加一行」克隆到的必须是数据行,不是表头行**(2026-08-30,评审逮到的 Critical 回归)。
+ *
+ * 背景(ACT-WORK-05 差异评估 Task 7b):`parts.chip_type` / `parts.type_param` 两张表
+ * 需要在**零草稿行**时也能「加一行」(`activities/part-substitution-assessment/src/views/
+ * report.ts` 的 `CHIP_TYPE_TABLE`/`TYPE_PARAM_TABLE`)。为了让 `ViewContainer.collect()`
+ * 同时收到「已有的动态行(`data-rows`)」与「一条不受行数所限的静态种子行」两处的输入,
+ * `data-edit="域.对象"` 挂在了外层 `` 上(而不是只挂在内层 `data-rows` 的
+ * `` 上)。
+ *
+ * 副作用:`ViewContainer.bodyOf()`(`packages/ui-kit/src/ViewContainer.tsx:164-167`,
+ * 单个 `document.querySelector`,按文档序返回第一个匹配)从此解析到**外层 ``**;
+ * `addRow()`(`:303-323`)接着在这个 host 里找 `tr:last-of-type` —— CSS `:last-of-type`
+ * 是**相对各自父元素**判定的,`` 里那个孤零零的 `` 在它自己的父元素(`thead`)
+ * 里必然是「最后一个」,若 `` 在文档序上排在数据行之前,它就会被
+ * `querySelectorAll('tr:last-of-type')` 第一个命中 —— 克隆出来的是四个 `` 表头文字、
+ * **零个** `input[data-col]`,还被 `appendChild` 到 `` 底下、不在任何 `` 里。
+ * 按钮点了有反应,结果完全不可用,且不报错。
+ *
+ * 修法(`report.ts`,不改 `ViewContainer.tsx`/`view-render.ts` 这两个共享面):把 ``
+ * 挪到**两个 `` 之后**、` ` 之前。CSS 表格布局规范里
+ * `table-header-group`(`` 的默认 display)**始终排在最前面渲染,不看它在 DOM 里
+ * 排第几**(这是 `tfoot` 允许写在 `thead` 后面、`tbody` 前面这条 HTML5 规则背后同一条
+ * 视觉排版原理)——视觉呈现不变,而 `document.querySelector`/`querySelectorAll` 认的是
+ * **文档序**,不是视觉序:`` 排在两个 ` | ` 后面,`tr:last-of-type` 的搜索
+ * 就再也碰不到表头行(表头行在文档序上比所有数据行/种子行都晚,只有当两个 ``
+ * 都彻底没有 `` 时才轮得到它——而静态种子行保证了这种情况不会发生)。
+ *
+ * 这份测试**不能** `import` `packages/ui-kit/src/ViewContainer.tsx` 的 `bodyOf`/`addRow`——
+ * 它们没有 `export`,而且 `activities/part-substitution-assessment` 自己的包内测试按
+ * ADR-004 的 `activities-isolated` depcruise 规则只准 `import @sdk/@contracts/@sfeel`,
+ * 够不着 `apps/api` 这一侧的 `renderView`;反过来 `apps-not-into-activities` 规则也不许
+ * `apps/api` 侧的文件静态 `import activities/**`。所以这里用**真实的 `renderView()`**
+ * (与 report.ts 的 `CHIP_TYPE_TABLE`/`TYPE_PARAM_TABLE` 结构逐字节对齐的镜像 fixture,
+ * 见下面 `CHIP_TYPE_FRAGMENT`/`TYPE_PARAM_FRAGMENT` 头注)跑出「服务端会真的吐出什么
+ * HTML」,再在 jsdom 里**逐行复刻** `bodyOf`/`addRow` 那两行选择器(标了对应的源码行号)
+ * —— 测的是选择器路径本身的行为,不是字符串包含。
+ */
+// @vitest-environment jsdom
+/*
+ * apps/api/** 只受 tsconfig.node.json 管(lib: ES2023,无 DOM——它是 Node 后端,本不该有
+ * DOM 全局)。这份测试需要真实 DOM(document/HTMLElement)去挂载 renderView() 的输出、
+ * 复刻 bodyOf()/addRow() 的选择器链——挪去 packages/ui-kit(有 DOM lib)又会撞
+ * packages-are-leaves(packages/* 不许反向 import apps/*),renderView 只活在 apps/api
+ * 里。两难之下用 TS 标准机制解:per-file 的三斜线 lib 引用只给这一份文件额外挂上 DOM 的
+ * 环境类型,不改 tsconfig.node.json 本身(该配置对 apps/api 其余所有文件原样零 DOM)。
+ */
+///
+import { describe, expect, it } from 'vitest';
+import { renderView } from '../internal/domain/view-render.ts';
+
+/**
+ * 复刻 `ViewContainer.bodyOf()` + `addRow()` 的选择器链(逐行对照
+ * `packages/ui-kit/src/ViewContainer.tsx:164-167` 与 `:303-306`)——不 import 私有函数
+ * (它们没有 `export`,且 `apps/api` 不许静态 import `activities/**`,这份测试也够不着
+ * `packages/ui-kit`),只镜像**选择器本身**,在真实 jsdom Document 上跑。
+ */
+function findAddRowTarget(doc: Document, address: string): HTMLElement | null {
+ // 镜像 bodyOf():单个 querySelector,按文档序返回第一个匹配
+ const host = doc.querySelector(`[data-edit="${address}"]`);
+ if (host === null) return null;
+ // 镜像 addRow() 的取「克隆样本」那一步:host 内找 tr:last-of-type
+ return host.querySelector('tr:last-of-type');
+}
+
+/**
+ * 与 `report.ts` 的 `CHIP_TYPE_TABLE` 逐字节对齐的镜像(4 列:# / 编号 / 类型名称 / 类型说明;
+ * `type_id` 只读不挂 `data-col`——自增列不问人;种子行两格 `data-col`:type_name / description)。
+ * `thead` 排在两个 `tbody` **之后**——这是本测试要钉死的结构。
+ */
+const CHIP_TYPE_FRAGMENT = ``;
+
+/**
+ * 与 `report.ts` 的 `TYPE_PARAM_TABLE` 逐字节对齐的镜像(6 列;`type_id` 是下拉
+ * `data-options`,不是裸文本 —— Task 7b 的另一条修改)。同样把 `thead` 挪到两个 `tbody` 之后。
+ */
+const TYPE_PARAM_FRAGMENT = ``;
+
+describe('parts.chip_type(零行时):「加一行」必须落在种子行上,不是表头', () => {
+ it('renderView() 真实输出,零草稿行 —— 动态 tbody 空,种子行还在', () => {
+ const html = renderView(CHIP_TYPE_FRAGMENT, { 'mine:parts.chip_type': [] });
+ const doc = document.implementation.createHTMLDocument('');
+ doc.body.innerHTML = html;
+
+ // renderRows() 消费掉 data-rows 属性(只留 data-edit),动态 tbody 按位置认(文档序第一个 )
+ const dynamicTbody = doc.querySelectorAll('tbody')[0];
+ expect(dynamicTbody?.querySelectorAll('tr').length).toBe(0);
+ expect(doc.querySelectorAll('tr.jc-row-static')).toHaveLength(1);
+ });
+
+ it('走一遍 bodyOf()/addRow() 的真实选择器路径:克隆样本落在 tbody 里,不是 thead', () => {
+ const html = renderView(CHIP_TYPE_FRAGMENT, { 'mine:parts.chip_type': [] });
+ const doc = document.implementation.createHTMLDocument('');
+ doc.body.innerHTML = html;
+
+ const target = findAddRowTarget(doc, 'parts.chip_type');
+ expect(target).not.toBeNull();
+ // 命中的必须是 的直接子行 —— 不是 那一行
+ expect(target?.parentElement?.tagName).toBe('TBODY');
+ expect(target?.closest('thead')).toBeNull();
+ // 且它就是那条种子行(class 对得上)
+ expect(target?.classList.contains('jc-row-static')).toBe(true);
+ // 含 2 个 input[data-col](type_name / description),不是 0 个(表头行会是 0)
+ const inputs = Array.from(target?.querySelectorAll('input[data-col]') ?? []);
+ expect(inputs.map((i) => i.getAttribute('data-col')).sort()).toEqual(['description', 'type_name']);
+ });
+});
+
+describe('parts.chip_type(已有 1 行时):「加一行」克隆的是那条真数据行', () => {
+ it('走一遍选择器路径:命中动态 tbody 的最后一条真实数据行,不是种子行也不是表头', () => {
+ const html = renderView(CHIP_TYPE_FRAGMENT, {
+ 'mine:parts.chip_type': [{ type_id: '1', type_name: 'DC-DC 降压稳压器', description: '开关式降压转换器' }],
+ });
+ const doc = document.implementation.createHTMLDocument('');
+ doc.body.innerHTML = html;
+
+ const target = findAddRowTarget(doc, 'parts.chip_type');
+ expect(target).not.toBeNull();
+ // renderRows() 消费掉 data-rows 属性 —— 认「不是种子行、且是文档序第一个 tbody(动态那个)的行」
+ expect(target?.parentElement).toBe(doc.querySelectorAll('tbody')[0]);
+ expect(target?.classList.contains('jc-row-static')).toBe(false);
+ expect(target?.closest('thead')).toBeNull();
+ // 这一行真的带着那条料的值(证明不是表头、不是种子行)
+ expect(target?.textContent).toContain('DC-DC 降压稳压器');
+ });
+});
+
+describe('parts.type_param(零行时):同款结构,同样要验', () => {
+ it('renderView() 真实输出,零草稿行 —— 动态 tbody 空,种子行还在', () => {
+ const html = renderView(TYPE_PARAM_FRAGMENT, { 'mine:parts.type_param': [] });
+ const doc = document.implementation.createHTMLDocument('');
+ doc.body.innerHTML = html;
+
+ // renderRows() 消费掉 data-rows 属性(只留 data-edit),动态 tbody 按位置认(文档序第一个 )
+ const dynamicTbody = doc.querySelectorAll('tbody')[0];
+ expect(dynamicTbody?.querySelectorAll('tr').length).toBe(0);
+ expect(doc.querySelectorAll('tr.jc-row-static')).toHaveLength(1);
+ });
+
+ it('走一遍选择器路径:克隆样本落在 tbody 里、6 个可填格子都在,不是表头(表头会是 0 个)', () => {
+ const html = renderView(TYPE_PARAM_FRAGMENT, { 'mine:parts.type_param': [] });
+ const doc = document.implementation.createHTMLDocument('');
+ doc.body.innerHTML = html;
+
+ const target = findAddRowTarget(doc, 'parts.type_param');
+ expect(target).not.toBeNull();
+ expect(target?.parentElement?.tagName).toBe('TBODY');
+ expect(target?.closest('thead')).toBeNull();
+ expect(target?.classList.contains('jc-row-static')).toBe(true);
+ const cols = Array.from(target?.querySelectorAll('input[data-col], select[data-col]') ?? [])
+ .map((i) => i.getAttribute('data-col'))
+ .sort();
+ expect(cols).toEqual(['constraint_type', 'param_desc', 'param_name', 'param_unit', 'type_id']);
+ });
+});
+
+/**
+ * **这是 Task 7b 修复之前的真实回归**(评审逮到:「parts.type_param 那张更要紧:它的
+ * 加行功能在你改动之前是好的」)——`data-edit` 只挂在内层 `` 上(不挂
+ * 外层 ``),`thead` 排在最前面(改回归修复前的原始顺序)。这条测试**不是**用来
+ * 证明当前代码正确——它证明的是「如果 `data-edit` 回到只挂 `` 且 `thead` 排在最前」
+ * 这个错误形状,选择器确实会命中表头(0 个 `input[data-col]`)。留着当变异验证的可读记录:
+ * 不必真的改 `report.ts` 再改回来,这条测试本身就是「变了会怎样」的固化证据。
+ */
+describe('回归记录:data-edit 挂外层 table 且 thead 排最前 —— 这个错误形状会命中表头', () => {
+ it('(此形状已不出现在 report.ts 里,这里独立复现,证明它确实是错的)', () => {
+ const brokenFragment = ``;
+ const html = renderView(brokenFragment, { 'mine:parts.chip_type': [] });
+ const doc = document.implementation.createHTMLDocument('');
+ doc.body.innerHTML = html;
+
+ const target = findAddRowTarget(doc, 'parts.chip_type');
+ expect(target).not.toBeNull();
+ // 命中的是表头那一行:落在 thead 里,零个 input[data-col]
+ expect(target?.closest('thead')).not.toBeNull();
+ expect(target?.querySelectorAll('input[data-col]')).toHaveLength(0);
+ });
+});
diff --git a/docs/debt.md b/docs/debt.md
index d9b7bfe..263069b 100644
--- a/docs/debt.md
+++ b/docs/debt.md
@@ -1458,3 +1458,44 @@ nudge:[{after:'PT2H',action:'nudge'}], retry:{times:2,backoff:'PT30S'}`)慢慢
与 `checkWhen` 现有的「S-FEEL 对未绑定的名字求值为假,这一笔将永远被跳过」同一条纪律,只是
`{in:X}` 的失败面目更隐蔽(空串会被拼接、被当合法值继续往下算,不像 S-FEEL 谓词那样直接短路)。
`auto` 是 ADR-014 新落地的机制,这个洞会在下一个用到跨步共享投影的活动上重复咬人。
+
+## `auto:true` 又是整数型的主键,平台两处独立机制都会踩(2026-08-30,Task 7b 评审 Critical 逼出来的发现)
+
+`resolveRowKey`(`apps/api/src/app/routes/desk.ts:111-181`)第③段「续号」只判断
+declared type 是不是 `integer`/`number`(`isSeq`),**不看这一列是否 `auto:true`** ——
+它是为 `req_seq` 这类「非自增、按行数续的序号型主键」(I01 的 `req_seq`)设计的,却对**任何**
+整数型主键一视同仁。若某张表的主键**恰好**既是 `auto:true` 又是 `type:'integer'`,这一段会
+**抢在** `writeEditedRows` 的 `mintable`/`autoKeys()`(真正为 `auto:true` 设计的续号器,数
+`taskView` 正式版+草稿叠加、按声明的前缀+两位补零规则铸号)**前面**动手,拿「这一单自己的
+草稿行数+1」铸一个号——这个数不数**正式表**里已有的行,新建的第一行几乎必然撞上任何已存在的
+`type_id=1`(种子数据、或早前任务放行落地的行),**静默覆盖**那一行的其余字段。
+
+`autoKeys()` 补零续出的号(如 `'03'`)还有第二个坑:**引用这一列的其它表**(外键列)若也声明
+`type:'integer'`,写入时会被 `coerceToColumn`(`apps/api/src/app/wiring/engines.ts:590-598`)
+按 `Number()` 解析,**削掉前导零**——`'03'` 存成整数 `3`,而被引用的原表仍是字符串 `'03'`,
+`refersTo` 校验(`engines.ts:1119-1143`)与 `data-by` 嵌套查表(`view-render.ts` 的
+`byParent`)全按 `String(...)` 比对,两边永远对不上:引用列的写入会被 `refersTo` 拒(「在字典里
+不存在」),或下拉/查表显示不出刚建的那一行。
+
+**本仓迄今唯一一个 `auto:true` 又是 `integer` 的主键是 `part-substitution-assessment`(W05)的
+`parts.chip_type.type_id`**(`auto:true` 的既有先例 `contact_code`——E04/quote-intake 两处——
+从来都是 `type:'text'`,没人先撞上这个组合)。Task 7b 补「件① 新建芯片类型」时踩上了(种子数据
+`type_id: 1/2` 会被第一次新建的类型静默覆盖;引用列若也是 `integer` 会导致新建的类型写不进
+`parts.profile.chip_type_id` 等)。**这次只是把 `type_id` 自身与五处引用列(`type_param.type_id`
+/ `profile.chip_type_id` / `sub_eval.part.chip_type_id` / `sub_eval.case.chip_type_id` /
+`substitution.relation.chip_type_id`)在活动自己的声明层统一改成 `text` 绕开——`resolveRowKey`
+与 `coerceToColumn` 这两处平台代码本身一行没改,坑原样留着**。
+
+**要治**(两处,各自独立就够,不必都做):
+- `resolveRowKey` 第③段续号加一条 `if (input.autos?.includes(k)) continue;`(与第②段
+ `barred` 检查同一条精神:`auto:true` 的列有自己专用的续号器,不该被这段通用的
+ 「整数型主键续号」抢跑)。
+- 或者更保守地,给 `writeEditedRows`/`static-audit` 加一条装包期检查:某张表的主键**同时**
+ `primaryKey:true`、`auto:true`、`type:'integer'|'number'` 时报一条 warning,提醒作者
+ 「这个组合会撞上 `resolveRowKey` 的续号步骤,改成 `text` 或联系平台修 `resolveRowKey`」——
+ 比事后重新发现这条坑更便宜。
+
+**排查法留一条**:某张 `auto:true` 主键表的**种子/早前数据在没人手动改过的情况下自己变了
+名字/内容**,且改动发生在**该表第一次被本单新建一行**前后,先怀疑这条坑,查该主键是否
+`type:'integer'` 且没有其它非 auto 主键列(单主键、无前缀的场景下,`resolveRowKey` 续出的
+号从 `1` 起,最容易撞上种子数据里最早的几行)。
|