ACT-WORK-05:标准化参数集改子表 parts.parameter,与目标件页面结构对齐

用户核对实跑页面后指出:P-08 数据工单做出来的页面结构必须与目标件
《数据工单-01-芯片主数据创建》一致——目标件本就是两张表(表 1 主记录 /
表 2 标准化参数子表),而实现把参数集塞成了表 1 的一个文本列,违反
《SS02-SS03-SS04_Demo台账字段细化与统一方案》§六「重复明细用子表承载,
不在单一文本字段堆叠」。

五处联动改齐:
- 平台基线新增 parts.parameter 主数据表(内部料号+参数名复合主键),
  两颗样例料的参数集从 normalized_parameter_set 文本列拆成 7 行子表数据
  (数值原样搬,一个字未改);parts.profile 物理列保留、不再赋值。
- 活动清单:parts.profile 撤掉该字段声明,新增 parts.parameter 声明
  (internal_pn 引用墙、param_value/constraint_type 必填);版本号
  1.1.1 → 1.2.0。
- 案例模型:S0d_p08_master/S1_input_confirm/S2_normalize 的读写面与
  guide 全部改指向新子表;顺带修正候选计数措辞歧义(实测样例0一张该发
  的决策卡被跳过——"候选absent且候选数≥2"被模型读成"不在库的候选≥2颗",
  改成先分别数"候选总数"与"不在库的候选数"两个数、显式说清楚判据认哪个)。
- caseFileModel 补 parts_parameter_missing 读数,两条哨兵(s_p08_created /
  s_pns_resolved)一并要求这一列不缺必填。
- 报告模版补齐表 2(逐块搬自目标件),主键列挂 data-col,约束类型走
  data-class-of 徽标。
- e2e 套件加性支持"一张卡填两张表"(stage.extraTables),buildEdits
  的 rowKey 泛化支持复合主键数组形态(不破坏既有八套件)。

验证:vitest 413 passed(活动包+平台种子测试全绿)、typecheck/lint/
depcruise 三绿、activities:install 零静态审发现、SQL 核对
parts.parameter 主键与行数(两颗料各7行)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh
This commit is contained in:
xcs
2026-08-27 02:19:39 +08:00
co-authored by Claude Opus 5
parent 02ef572470
commit c6b02dfd39
9 changed files with 324 additions and 141 deletions
+111 -52
View File
@@ -34,6 +34,22 @@ const E2E_CLIENT_SECRET = process.env.KEYCLOAK_E2E_CLIENT_SECRET ?? '';
/** access token 只活 5 分钟:超过 4 分钟就换一枚,免得长案例中途 401 */
const TOKEN_REFRESH_MS = 4 * 60 * 1000;
/**
* 标准化参数子表 `parts.parameter` 的行(w05 专用)—— 按 [参数名, 值, 单位, 约束, 说明]
* 五元组批量转成保存载荷的行对象;数值照抄各案例原先写在 `normalized_parameter_set`
* 那个文本列里的那几行,一个数都不改(2026-08-27 结构改:参数集拆成独立子表)。
*/
function paramRows(internalPn, rows) {
return rows.map(([param_name, param_value, param_unit, constraint_type, param_note]) => ({
internal_pn: internalPn,
param_name,
param_value,
param_unit,
constraint_type,
param_note,
}));
}
/** 每套活动的案例:样例序号 + 人在件上要填的格子(按 pn 对行) */
const SUITES = {
a1: {
@@ -519,6 +535,8 @@ const SUITES = {
blankCol: 'supplement',
stages: [
{
// 这张卡摆两张表(表 1 主记录 + 表 2 标准化参数子表)—— 主表照旧,子表挂在 extraTables 里
// 加性表达(见 buildEdits 顶注),保存时两张表的行一次并进同一个 `tables` 载荷。
match: /数据工单 · 芯片主数据创建/,
address: 'parts.profile',
rowKey: 'internal_pn',
@@ -531,8 +549,19 @@ const SUITES = {
specification_description: '兜底规格(案例没给参数集)',
package: 'TO-263-5',
lifecycle_status: 'active',
normalized_parameter_set: '输入电压范围 | 4.5~40 | V | hard | 兜底',
},
extraTables: [
{
// 主键是 (internal_pn, param_name) 复合列 —— 两颗料同名参数(如都有「工作温度」)
// 不这样区分,rows.find 会永远命中第一条同名行、把不同料的值互相顶掉。
address: 'parts.parameter',
rowKey: ['internal_pn', 'param_name'],
blankCol: 'param_value',
edits: [],
// 案例没点名的参数行兜底(模型骨架只给了 internal_pn+param_name、案例没接住时才会用到)
fillAllBlank: { param_value: '未披露', constraint_type: 'hard', param_note: '兜底(案例没给这一条)' },
},
],
},
{
match: /数据工单 · 原厂技术资料创建/,
@@ -586,17 +615,17 @@ const SUITES = {
datasheet_ref: 'https://www.xlsemi.com/datasheet/XL2596S.pdf',
datasheet_version: 'Rev C',
application_scenarios: '工业电源;消费电源',
normalized_parameter_set: [
'输入电压范围 | 4.5~40 | V | hard | 客户上限 40V',
'输出电压 | 5.0 | V | hard | 固定版',
'输出电流 | 3 | A | hard | 满载能力',
'开关频率 | 150 | kHz | hard | 影响外围',
'静态电流 | 4.2 | mA | soft | 待机功耗',
'工作温度 | -40~+125 | ℃ | hard | 工业温域',
'使能阈值 | 未披露 | V | hard | 原厂未给',
].join('\n'),
},
],
'parts.parameter': paramRows('XL2596S-5.0', [
['输入电压范围', '4.5~40', 'V', 'hard', '客户上限 40V'],
['输出电压', '5.0', 'V', 'hard', '固定版'],
['输出电流', '3', 'A', 'hard', '满载能力'],
['开关频率', '150', 'kHz', 'hard', '影响外围'],
['静态电流', '4.2', 'mA', 'soft', '待机功耗'],
['工作温度', '-40~+125', '℃', 'hard', '工业温域'],
['使能阈值', '未披露', 'V', 'hard', '原厂未给'],
]),
'parts.document': [
{
internal_pn: 'XL2596S-5.0',
@@ -638,17 +667,17 @@ const SUITES = {
lifecycle_status: 'nrnd',
datasheet_ref: 'https://www.ti.com/lit/ds/symlink/lm2596.pdf',
datasheet_version: 'Rev H (2023-09)',
normalized_parameter_set: [
'输入电压范围 | 4.5~40 | V | hard | 客户上限 40V',
'输出电压 | 3.3 | V | hard | 固定版',
'输出电流 | 3 | A | hard | 满载能力',
'开关频率 | 150 | kHz | hard | 影响外围',
'静态电流 | 5 | mA | soft | 待机功耗',
'工作温度 | -40~+85 | ℃ | hard | 工业温域',
'使能阈值 | 1.4 | V | hard | ON/OFF 阈值',
].join('\n'),
},
],
'parts.parameter': paramRows('LM2596S-3.3', [
['输入电压范围', '4.5~40', 'V', 'hard', '客户上限 40V'],
['输出电压', '3.3', 'V', 'hard', '固定版'],
['输出电流', '3', 'A', 'hard', '满载能力'],
['开关频率', '150', 'kHz', 'hard', '影响外围'],
['静态电流', '5', 'mA', 'soft', '待机功耗'],
['工作温度', '-40~+85', '℃', 'hard', '工业温域'],
['使能阈值', '1.4', 'V', 'hard', 'ON/OFF 阈值'],
]),
'parts.document': [
{
internal_pn: 'LM2596S-3.3',
@@ -678,15 +707,6 @@ const SUITES = {
specification_description: '降压型 DCDC 稳压器,3A,固定 5.0V,150kHz',
package: 'TO-263-5',
lifecycle_status: 'active',
normalized_parameter_set: [
'输入电压范围 | 4.5~40 | V | hard | 客户上限 40V',
'输出电压 | 5.0 | V | hard | 固定版',
'输出电流 | 3 | A | hard | 满载能力',
'开关频率 | 150 | kHz | hard | 影响外围',
'静态电流 | 4.2 | mA | soft | 待机功耗',
'工作温度 | -40~+125 | ℃ | hard | 工业温域',
'使能阈值 | 未披露 | V | hard | 原厂未给',
].join('\n'),
},
{
internal_pn: 'CN-DCDC-32V',
@@ -694,17 +714,28 @@ const SUITES = {
specification_description: '降压型 DCDC,3A,固定 5.0V,输入上限 32V',
package: 'TO-263-5',
lifecycle_status: 'active',
normalized_parameter_set: [
'输入电压范围 | 4.5~32 | V | hard | 上限 32V',
'输出电压 | 5.0 | V | hard | 固定版',
'输出电流 | 3 | A | hard | 满载能力',
'开关频率 | 150 | kHz | hard | 影响外围',
'静态电流 | 6 | mA | soft | 待机功耗',
'工作温度 | -40~+85 | ℃ | hard | 工业温域',
'使能阈值 | 1.3 | V | hard | ON/OFF 阈值',
].join('\n'),
},
],
'parts.parameter': [
...paramRows('XL2596S-5.0', [
['输入电压范围', '4.5~40', 'V', 'hard', '客户上限 40V'],
['输出电压', '5.0', 'V', 'hard', '固定版'],
['输出电流', '3', 'A', 'hard', '满载能力'],
['开关频率', '150', 'kHz', 'hard', '影响外围'],
['静态电流', '4.2', 'mA', 'soft', '待机功耗'],
['工作温度', '-40~+125', '℃', 'hard', '工业温域'],
['使能阈值', '未披露', 'V', 'hard', '原厂未给'],
]),
...paramRows('CN-DCDC-32V', [
['输入电压范围', '4.5~32', 'V', 'hard', '上限 32V'],
['输出电压', '5.0', 'V', 'hard', '固定版'],
['输出电流', '3', 'A', 'hard', '满载能力'],
['开关频率', '150', 'kHz', 'hard', '影响外围'],
['静态电流', '6', 'mA', 'soft', '待机功耗'],
['工作温度', '-40~+85', '℃', 'hard', '工业温域'],
['使能阈值', '1.3', 'V', 'hard', 'ON/OFF 阈值'],
]),
],
'parts.document': [
{ internal_pn: 'XL2596S-5.0', doc_seq: '01', document_type: 'datasheet', document_title: 'XL2596S Datasheet', document_version: 'Rev C', document_ref: 'https://www.xlsemi.com/datasheet/XL2596S.pdf', citation_locator: 'p.3', document_status: 'valid' },
{ internal_pn: 'CN-DCDC-32V', doc_seq: '01', document_type: 'datasheet', document_title: 'CN-DCDC-32V Datasheet', document_version: 'V1.0', document_ref: 'https://example.com/cn-dcdc-32v.pdf', citation_locator: 'p.2', document_status: 'valid' },
@@ -888,38 +919,53 @@ async function answerEntries(taskId, kase, answered) {
* 把案例声明的格子对齐成保存载荷。行键随套件走(`rowKey`,老套件默认 `pn`):
* 键为空串 = 按序发给待填行;`fillMissing` 单列兜底扫空;
* `fillAllBlank` 整行兜底扫空(回执这类「几行由模型定、每行要填一把」的表用它)。
*
* **复合主键**(2026-08-27,w05 的 `parts.parameter` 主键是 `(internal_pn, param_name)`)——
* `rowKey` 除了旧的单列字符串形态,也接受一个列名数组:两颗料同名参数(如都有「工作温度」)
* 若只按 `param_name` 单列匹配,`rows.find` 会永远命中第一条同名行、把不同料的值互相顶掉;
* 数组形态按**全部列同时相等**才算命中同一行,单列字符串形态原样保留旧行为(不影响既有八套件)。
*/
function buildEdits(stage, drafts, kase = {}) {
const rows = draftRows(drafts, stage.address);
const rowKey = stage.rowKey ?? 'pn';
const keyCols = Array.isArray(stage.rowKey) ? stage.rowKey : [stage.rowKey ?? 'pn'];
const blankCol = stage.blankCol ?? 'base_price';
const pending = rows.filter((r) => String(r[blankCol] ?? '') === '');
const lines = [];
const taken = new Set();
const sig = (obj) => keyCols.map((k) => String(obj[k] ?? '')).join('');
const keyOf = (obj) => Object.fromEntries(keyCols.map((k) => [k, String(obj[k] ?? '')]));
// 案例级按表的编辑叠在套件级之上(同一套件不同案例要建的料号不同 —— 参数集不能是同一份)
const edits = [...(stage.edits ?? []), ...((kase.stageEdits ?? {})[stage.address] ?? [])];
for (const [i, edit] of edits.entries()) {
const { [rowKey]: keyed, ...cells } = edit;
const target = keyed !== '' ? rows.find((r) => String(r[rowKey]) === keyed) : pending[i];
const cells = { ...edit };
const keyVals = {};
for (const k of keyCols) {
keyVals[k] = cells[k];
delete cells[k];
}
const allGiven = keyCols.every((k) => keyVals[k] !== undefined && keyVals[k] !== '');
const target = allGiven ? rows.find((r) => keyCols.every((k) => String(r[k]) === String(keyVals[k]))) : pending[i];
if (!target) {
console.log(` ⚠️ 找不到要填的行(${rowKey}=${keyed || `待填第${i}行`});现有行:${rows.map((r) => r[rowKey]).join(', ')}`);
const wanted = allGiven ? keyCols.map((k) => keyVals[k]).join('/') : `待填第${i}行`;
const have = rows.map((r) => keyCols.map((k) => r[k]).join('/')).join(', ');
console.log(` ⚠️ 找不到要填的行(${keyCols.join('+')}=${wanted});现有行:${have}`);
continue;
}
taken.add(String(target[rowKey]));
lines.push({ [rowKey]: String(target[rowKey]), ...cells });
taken.add(sig(target));
lines.push({ ...keyOf(target), ...cells });
}
if (stage.fillMissing) {
const { column, value } = stage.fillMissing;
for (const r of rows) {
if (String(r[column] ?? '') === '' && !taken.has(String(r[rowKey]))) {
lines.push({ [rowKey]: String(r[rowKey]), [column]: value });
if (String(r[column] ?? '') === '' && !taken.has(sig(r))) {
lines.push({ ...keyOf(r), [column]: value });
}
}
}
if (stage.fillAllBlank) {
for (const r of rows) {
if (String(r[blankCol] ?? '') === '' && !taken.has(String(r[rowKey]))) {
lines.push({ [rowKey]: String(r[rowKey]), ...stage.fillAllBlank });
if (String(r[blankCol] ?? '') === '' && !taken.has(sig(r))) {
lines.push({ ...keyOf(r), ...stage.fillAllBlank });
}
}
}
@@ -930,8 +976,8 @@ function buildEdits(stage, drafts, kase = {}) {
if (stage.fillWhere) {
const { column, equals, values } = stage.fillWhere;
for (const r of rows) {
if (String(r[column] ?? '') === equals && !taken.has(String(r[rowKey]))) {
lines.push({ [rowKey]: String(r[rowKey]), ...values });
if (String(r[column] ?? '') === equals && !taken.has(sig(r))) {
lines.push({ ...keyOf(r), ...values });
}
}
}
@@ -1195,12 +1241,25 @@ async function runCase(suite, kase) {
addedRows = true;
console.log(` 件上加 ${kase.adds.rows.length} 行(${kase.adds.rows.map((r) => Object.values(r)[0]).join('、')})`);
}
if (lines.length > 0) {
/*
* **一张卡填两张表**(w05:P-08 数据工单同一张卡上摆着表 1 主记录 + 表 2 标准化参数子表)——
* 加性写法:`stage.extraTables`(可选)是同形状的段描述数组,各自按自己的 address 走
* 同一套 `buildEdits`;保存载荷 `{tables:{地址:行[]}}` 本就支持多地址一次发,不必分两次保存、
* 也不影响没有 `extraTables` 的老套件(a1/a2/a3/a4/a6/a7/e04/i01 的 stage 都没有这个键)。
*/
const tables = {};
if (lines.length > 0) tables[stage.address] = lines;
for (const extra of stage.extraTables ?? []) {
const extraLines = buildEdits(extra, body.drafts ?? [], kase);
if (extraLines.length > 0) tables[extra.address] = extraLines;
}
const tableAddresses = Object.keys(tables);
if (tableAddresses.length > 0) {
await api(`/api/desk/tasks/${taskId}/table`, {
method: 'POST',
body: JSON.stringify({ tables: { [stage.address]: lines } }),
body: JSON.stringify({ tables }),
});
console.log(` 件上保存 ${lines.length} 行(${card.title})`);
console.log(` 件上保存 ${tableAddresses.map((a) => `${a}:${tables[a].length}行`).join(' + ')}(${card.title})`);
}
try {
await submitExecution(card.id);