| Datasheet 引用 datasheet_ref | Datasheet 版本 datasheet_version与引用成对 | 适配场景 application_scenarios判断实际适用性 | -标准化参数集 normalized_parameter_set一行一参数:参数名|值|单位|hard/soft|说明 | |||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -330,7 +341,34 @@ const P08_TABLE = ` |
| ${cell('datasheet_ref', 'mono')} | ${cell('datasheet_version')} | ${cell('application_scenarios')} | -+ + |
| # | +所属料号 internal_pn归属表 1 哪条记录 | +参数名 | +值 | +单位 | +约束(硬/软) | +说明 | +
|---|---|---|---|---|---|---|
| + | ${cell('internal_pn', 'mono')} | +${cell('param_name')} | +${cell('param_value', 'mono')} | +${cell('param_unit')} | +${badge('ct', 'constraint_type', true)}${select('constraint_type', [['hard', '硬约束'], ['soft', '软约束']], true)} | +${ro('param_note')} |
${ZIC_TABLE}【创建什么 · 表 2】—— 标准化参数集(每行一个参数,按所属料号归属,*必填)
` + + gapbar('parts_parameter', '必填') + + editGate('parts.parameter', `已铺 行参数(有 datasheet 的已按资料预填),点「编辑」核对修正;「+ 加一行」补充未列出的参数`) + + P08_PARAMS_TABLE + + addrow('parts.parameter', '+ 加一行(补充参数)', '所属料号 / 参数名 / 值 / 单位 / 约束类型齐全;「未披露」请直接写未披露,将进入 UNKNOWN 并挂验证任务;「说明」列为系统注释,无需填写') + flow('提交 / 确认:本工单只承载内容;确认请在对话区的【执行卡】中进行。确认后活动自动从「等待数据」唤醒。'), }); diff --git a/apps/api/src/app/__tests__/seed-mvp-data.test.ts b/apps/api/src/app/__tests__/seed-mvp-data.test.ts index 1b326d0..45a85a2 100644 --- a/apps/api/src/app/__tests__/seed-mvp-data.test.ts +++ b/apps/api/src/app/__tests__/seed-mvp-data.test.ts @@ -19,10 +19,11 @@ describe('主数据四域', () => { expect([...new Set(MASTER_OBJECTS.map((o) => o.domain))].sort()).toEqual([...MASTER_DOMAINS].sort()); }); - it('SS02 差异评估要读写的六张主数据表都在基线里,主键照《统一方案》', () => { + it('SS02 差异评估要读写的七张主数据表都在基线里,主键照《统一方案》', () => { const pk = (domain: string, object: string) => MASTER_OBJECTS.find((o) => o.domain === domain && o.object === object)?.fields.filter((f) => f.primaryKey).map((f) => f.name); expect(pk('parts', 'profile')).toEqual(['internal_pn']); + expect(pk('parts', 'parameter')).toEqual(['internal_pn', 'param_name']); expect(pk('parts', 'document')).toEqual(['internal_pn', 'doc_seq']); expect(pk('demand', 'request')).toEqual(['request_id']); expect(pk('substitution', 'relation')).toEqual(['substitution_relation_id']); @@ -36,6 +37,21 @@ describe('主数据四域', () => { expect(parts).not.toContain('XL2596S-5.0'); }); + it('样例料号的标准化参数集是独立子表 parts.parameter:两颗料各 7 行,主键是 (internal_pn, param_name)', () => { + const params = MASTER_SAMPLE_ROWS.filter((r) => r.address.domain === 'parts' && r.address.object === 'parameter'); + const byPn = (pn: string) => params.filter((r) => r.key['internal_pn'] === pn); + expect(byPn('LM2596S-5.0')).toHaveLength(7); + expect(byPn('LM2596S-ADJ')).toHaveLength(7); + for (const row of params) { + expect(Object.keys(row.key).sort()).toEqual(['internal_pn', 'param_name']); + expect(row.values['param_value'], JSON.stringify(row.key)).toBeTruthy(); + expect(row.values['constraint_type']).toMatch(/^(hard|soft)$/); + } + // parts.profile 不再声明这一列的值 —— 两行样例主记录里它应为空(物理列留在基线,不再写) + const profiles = MASTER_SAMPLE_ROWS.filter((r) => r.address.domain === 'parts' && r.address.object === 'profile'); + for (const row of profiles) expect(row.values['normalized_parameter_set']).toBeUndefined(); + }); + it('四个域各自都有表 —— 空占位也得立得住', () => { for (const d of MASTER_DOMAINS) { expect(MASTER_OBJECTS.filter((o) => o.domain === d).length, `${d} 域一张表都没有`).toBeGreaterThan(0); diff --git a/apps/api/src/app/seed-mvp-data.ts b/apps/api/src/app/seed-mvp-data.ts index 5bb8d2a..0a544d8 100644 --- a/apps/api/src/app/seed-mvp-data.ts +++ b/apps/api/src/app/seed-mvp-data.ts @@ -143,17 +143,22 @@ export const MASTER_OBJECTS: readonly ObjectDeclaration[] = [ f('trend', 'text', '走势'), ]), - /* ── 料件:P-08 芯片主数据 / P-10 原厂技术资料库(SS02 差异评估 2026-08-26 加)── + /* ── 料件:P-08 芯片主数据 / P-10 原厂技术资料库(SS02 差异评估 2026-08-26 加,2026-08-27 结构改)── 字段名与《SS02-SS03-SS04_Demo台账字段细化与统一方案》§4.2 一字不差;全链路只认 internal_pn。 - 参数集是 P-08 的一个字段(用户定:一张表,不建子表),一行一参数: - `参数名 | 值 | 单位 | hard/soft | 说明`。老的 market.part(主键 pn)不复用,记 debt 待并。 */ + 标准化参数集**改为独立子表 `parts.parameter`**(2026-08-27 用户当场核对实跑页面后指出: + 「做出来的页面结构必须与目标件一致」——目标件《ACT-WORK-05-数据工单-01》本就是两张表, + 表 1 一个料一行(主记录)、表 2 一个参数一行(子表,按所属料号归属);塞进主记录的一个文本 + 列违反《统一方案》§六「重复明细用子表承载,不在单一文本字段堆叠」,§4.2 里 + `normalized_parameter_set` 的数据类型本就写的是 `Structure`。物理列 `parts.profile. + normalized_parameter_set` 仍留在基线里(不删列),只是活动清单不再声明它、不再写它。 + 老的 market.part(主键 pn)不复用,记 debt 待并。 */ master('parts', 'profile', '芯片主数据(P-08)', [ f('internal_pn', 'text', '内部料号', true), f('customer_pn', 'text', '客户料号'), f('brand_name', 'text', '品牌名称'), f('manufacturer_name', 'text', '制造商名称'), f('specification_description', 'text', '规格描述'), - f('normalized_parameter_set', 'text', '标准化参数集(一行一参数:参数名 | 值 | 单位 | hard/soft | 说明)'), + f('normalized_parameter_set', 'text', '标准化参数集(已改子表 parts.parameter,这一列不再声明/不再写)'), f('package', 'text', '封装'), f('process_technology', 'text', '工艺'), f('lifecycle_status', 'text', '生命周期状态'), @@ -163,6 +168,15 @@ export const MASTER_OBJECTS: readonly ObjectDeclaration[] = [ f('application_scenarios', 'text', '适配应用场景'), f('pin_to_pin_relation_ref', 'text', 'Pin-to-Pin 关系引用'), ]), + /* P-08 子表:标准化参数集,一行一参数,按 internal_pn 归属主记录(《统一方案》§六:重复明细用子表) */ + master('parts', 'parameter', '标准化参数集(P-08 子表)', [ + f('internal_pn', 'text', '所属料号', true), + f('param_name', 'text', '参数名', true), + f('param_value', 'text', '值'), + f('param_unit', 'text', '单位'), + f('constraint_type', 'text', '约束类型'), + f('param_note', 'text', '说明'), + ]), master('parts', 'document', '原厂技术资料库(P-10)', [ f('internal_pn', 'text', '关联内部料号', true), f('doc_seq', 'text', '资料序号(同一料号下 01/02…)', true), @@ -462,10 +476,11 @@ export const MASTER_SAMPLE_ROWS: readonly { address: LedgerAddress; key: LedgerK }, }, /* - * ── SS02 差异评估的样例料号(2026-08-26)── + * ── SS02 差异评估的样例料号(2026-08-26,2026-08-27 参数集改子表)── * 只播**原 PN**与**同族可调版**两颗:「都在库」那条路靠它们;候选 XL2596S-5.0 与国产 DCDC * **刻意不播** —— 「候选缺库 → 派数据工单」「硬约束不推荐」两条路要试得出来,库里得真没有。 - * 参数集一行一参数(参数名 | 值 | 单位 | hard/soft | 说明),与 P-08 声明的行语法同一份。 + * 标准化参数集是独立子表 `parts.parameter`,一颗料 7 行(与目标件《数据工单-01》的 + * 表 2 示例预填同形);数值照旧版 `normalized_parameter_set` 文本行逐条搬,一个字不改。 */ { address: { domain: 'parts', object: 'profile' }, @@ -475,15 +490,6 @@ export const MASTER_SAMPLE_ROWS: readonly { address: LedgerAddress; key: LedgerK brand_name: 'TI', manufacturer_name: 'Texas Instruments', specification_description: '降压型 DCDC 稳压器,3A,固定 5.0V,150kHz', - normalized_parameter_set: [ - '输入电压范围 | 4.5~40 | V | hard | 客户输入上限 40V', - '输出电压 | 5.0 | V | hard | 固定版', - '输出电流 | 3 | A | hard | 满载能力', - '开关频率 | 150 | kHz | hard | 影响外围电感/电容', - '静态电流 | 5 | mA | soft | 待机功耗', - '工作温度 | -40~+85 | ℃ | hard | 工业温域', - '使能阈值 | 1.4 | V | hard | ON/OFF 引脚阈值', - ].join('\n'), package: 'TO-263-5', process_technology: 'BCD', lifecycle_status: 'nrnd', @@ -493,6 +499,13 @@ export const MASTER_SAMPLE_ROWS: readonly { address: LedgerAddress; key: LedgerK application_scenarios: '工业电源;消费电源', }, }, + { address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '输入电压范围' }, values: { internal_pn: 'LM2596S-5.0', param_name: '输入电压范围', param_value: '4.5~40', param_unit: 'V', constraint_type: 'hard', param_note: '客户输入上限 40V' } }, + { address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '输出电压' }, values: { internal_pn: 'LM2596S-5.0', param_name: '输出电压', param_value: '5.0', param_unit: 'V', constraint_type: 'hard', param_note: '固定版' } }, + { address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '输出电流' }, values: { internal_pn: 'LM2596S-5.0', param_name: '输出电流', param_value: '3', param_unit: 'A', constraint_type: 'hard', param_note: '满载能力' } }, + { address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '开关频率' }, values: { internal_pn: 'LM2596S-5.0', param_name: '开关频率', param_value: '150', param_unit: 'kHz', constraint_type: 'hard', param_note: '影响外围电感/电容' } }, + { address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '静态电流' }, values: { internal_pn: 'LM2596S-5.0', param_name: '静态电流', param_value: '5', param_unit: 'mA', constraint_type: 'soft', param_note: '待机功耗' } }, + { address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '工作温度' }, values: { internal_pn: 'LM2596S-5.0', param_name: '工作温度', param_value: '-40~+85', param_unit: '℃', constraint_type: 'hard', param_note: '工业温域' } }, + { address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '使能阈值' }, values: { internal_pn: 'LM2596S-5.0', param_name: '使能阈值', param_value: '1.4', param_unit: 'V', constraint_type: 'hard', param_note: 'ON/OFF 引脚阈值' } }, { address: { domain: 'parts', object: 'profile' }, key: { internal_pn: 'LM2596S-ADJ' }, @@ -501,15 +514,6 @@ export const MASTER_SAMPLE_ROWS: readonly { address: LedgerAddress; key: LedgerK brand_name: 'TI', manufacturer_name: 'Texas Instruments', specification_description: '降压型 DCDC 稳压器,3A,可调输出 1.23~37V,150kHz(LM2596 同族)', - normalized_parameter_set: [ - '输入电压范围 | 4.5~40 | V | hard | 与固定版同', - '输出电压 | 1.23~37 | V | hard | 可调版,靠外围反馈网络定', - '输出电流 | 3 | A | hard | 满载能力', - '开关频率 | 150 | kHz | hard | 影响外围电感/电容', - '静态电流 | 5 | mA | soft | 待机功耗', - '工作温度 | -40~+85 | ℃ | hard | 工业温域', - '使能阈值 | 1.4 | V | hard | ON/OFF 引脚阈值', - ].join('\n'), package: 'TO-263-5', process_technology: 'BCD', lifecycle_status: 'active', @@ -518,6 +522,13 @@ export const MASTER_SAMPLE_ROWS: readonly { address: LedgerAddress; key: LedgerK application_scenarios: '工业电源;消费电源', }, }, + { address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '输入电压范围' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '输入电压范围', param_value: '4.5~40', param_unit: 'V', constraint_type: 'hard', param_note: '与固定版同' } }, + { address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '输出电压' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '输出电压', param_value: '1.23~37', param_unit: 'V', constraint_type: 'hard', param_note: '可调版,靠外围反馈网络定' } }, + { address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '输出电流' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '输出电流', param_value: '3', param_unit: 'A', constraint_type: 'hard', param_note: '满载能力' } }, + { address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '开关频率' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '开关频率', param_value: '150', param_unit: 'kHz', constraint_type: 'hard', param_note: '影响外围电感/电容' } }, + { address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '静态电流' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '静态电流', param_value: '5', param_unit: 'mA', constraint_type: 'soft', param_note: '待机功耗' } }, + { address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '工作温度' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '工作温度', param_value: '-40~+85', param_unit: '℃', constraint_type: 'hard', param_note: '工业温域' } }, + { address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '使能阈值' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '使能阈值', param_value: '1.4', param_unit: 'V', constraint_type: 'hard', param_note: 'ON/OFF 引脚阈值' } }, { address: { domain: 'parts', object: 'document' }, key: { internal_pn: 'LM2596S-5.0', doc_seq: '01' }, diff --git a/tools/e2e-a2.mjs b/tools/e2e-a2.mjs index e609ca2..418f80f 100644 --- a/tools/e2e-a2.mjs +++ b/tools/e2e-a2.mjs @@ -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);