From c6b02dfd393cf6be2289bb8b5c5e085338d488b9 Mon Sep 17 00:00:00 2001 From: xcs Date: Thu, 27 Aug 2026 02:19:39 +0800 Subject: [PATCH] =?UTF-8?q?ACT-WORK-05:=E6=A0=87=E5=87=86=E5=8C=96?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E9=9B=86=E6=94=B9=E5=AD=90=E8=A1=A8=20parts.?= =?UTF-8?q?parameter,=E4=B8=8E=E7=9B=AE=E6=A0=87=E4=BB=B6=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E7=BB=93=E6=9E=84=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户核对实跑页面后指出: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) Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh --- .../__tests__/activity.test.ts | 36 ++-- .../__tests__/steps.test.ts | 13 +- .../src/case-model.ts | 80 +++++---- .../src/manifest.ts | 33 +++- .../src/skills.ts | 10 +- .../src/views/report.ts | 53 +++++- .../src/app/__tests__/seed-mvp-data.test.ts | 18 +- apps/api/src/app/seed-mvp-data.ts | 59 ++++--- tools/e2e-a2.mjs | 163 ++++++++++++------ 9 files changed, 324 insertions(+), 141 deletions(-) diff --git a/activities/part-substitution-assessment/__tests__/activity.test.ts b/activities/part-substitution-assessment/__tests__/activity.test.ts index 2cd42c2..73dd907 100644 --- a/activities/part-substitution-assessment/__tests__/activity.test.ts +++ b/activities/part-substitution-assessment/__tests__/activity.test.ts @@ -76,11 +76,17 @@ describe('清单本身合规', () => { }); describe('台账声明:授权面与《统一方案》口径', () => { - it('P-08 一张表:主键 internal_pn,参数集是它的一个必填字段,没有 parts.parameter 子表', () => { + it('P-08 主键 internal_pn,标准化参数集拆成独立子表 parts.parameter(2026-08-27 结构改:与目标件一致)', () => { const p08 = byAddress('parts', 'profile'); expect(p08?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['internal_pn']); - expect(p08?.fields.find((f) => f.name === 'normalized_parameter_set')?.required).toBe(true); - expect(byAddress('parts', 'parameter')).toBeUndefined(); + expect(p08?.fields.find((f) => f.name === 'normalized_parameter_set')).toBeUndefined(); + const param = byAddress('parts', 'parameter'); + expect(param?.kind).toBe('master'); + expect(param?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['internal_pn', 'param_name']); + expect(param?.fields.find((f) => f.name === 'internal_pn')?.refersTo).toBe('parts.profile.internal_pn'); + expect(param?.fields.find((f) => f.name === 'param_value')?.required).toBe(true); + expect(param?.fields.find((f) => f.name === 'constraint_type')?.tally).toEqual(['hard', 'soft']); + expect(param?.fields.find((f) => f.name === 'constraint_type')?.required).toBe(true); }); it('P-11 主记录的原 PN / 候选 PN 都是引用墙(查无此料当场拒 —— 存在性检查的硬门)', () => { @@ -186,12 +192,16 @@ describe('案例模型与清单的对齐', () => { }); describe('报告模版:编辑面与只读面各就各位', () => { - it('两张数据工单件可加行可编辑:data-edit / data-edit-add 与主键 data-col 都在', () => { + it('P-08 数据工单件的表 1 + 表 2(标准化参数子表)都可加行可编辑:data-edit / data-edit-add 与主键 data-col 都在', () => { const p08 = viewHtml('p08-master-create'); expect(p08).toContain('data-edit="parts.profile"'); expect(p08).toContain('data-edit-add="parts.profile"'); expect(p08).toContain('data-col="internal_pn"'); - expect(p08).toContain('data-col="normalized_parameter_set"'); + expect(p08).toContain('data-edit="parts.parameter"'); + expect(p08).toContain('data-edit-add="parts.parameter"'); + expect(p08).toContain('data-col="param_name"'); + expect(p08).toContain('data-col="param_value"'); + expect(p08).toContain('data-col="constraint_type"'); const p10 = viewHtml('p10-document-create'); expect(p10).toContain('data-edit="parts.document"'); expect(p10).toContain('data-col="doc_seq"'); @@ -211,15 +221,15 @@ describe('报告模版:编辑面与只读面各就各位', () => { } }); - it('每个可编辑格子的 data-col 都在对应台账对象的声明里', () => { - const cases: Array<[string, string, string]> = [ - ['p08-master-create', 'parts', 'profile'], - ['p10-document-create', 'parts', 'document'], - ['eval-input-list', D, 'input_gap'], + it('每个可编辑格子的 data-col 都在对应台账对象的声明里(p08-master-create 一屏两张表,两张台账对象的声明并起来看)', () => { + const cases: Array<[string, Array<[string, string]>]> = [ + ['p08-master-create', [['parts', 'profile'], ['parts', 'parameter']]], + ['p10-document-create', [['parts', 'document']]], + ['eval-input-list', [[D, 'input_gap']]], ]; - for (const [view, domain, object] of cases) { + for (const [view, objects] of cases) { const cols = new Set([...viewHtml(view).matchAll(/data-col="([^"]+)"/g)].map((m) => m[1] ?? '')); - const declared = new Set(byAddress(domain, object)?.fields.map((f) => f.name)); + const declared = new Set(objects.flatMap(([domain, object]) => byAddress(domain, object)?.fields.map((f) => f.name) ?? [])); for (const c of cols) expect(declared.has(c), `${view}: ${c}`).toBe(true); } }); @@ -268,6 +278,7 @@ describe('徽标别名与值域相关的坑(2026-08-27 实测账,回归网)', () /** 每张模版真的摆了徽标的那几列(带下划线的 tally 值最容易漏,漏了就是「待定」那次事故) */ const BADGED: ReadonlyArray<{ view: string; domain: string; object: string; column: string }> = [ { view: 'p08-master-create', domain: 'parts', object: 'profile', column: 'lifecycle_status' }, + { view: 'p08-master-create', domain: 'parts', object: 'parameter', column: 'constraint_type' }, { view: 'p10-document-create', domain: 'parts', object: 'document', column: 'document_type' }, { view: 'p10-document-create', domain: 'parts', object: 'document', column: 'document_status' }, { view: 'eval-input-list', domain: D, object: 'pn_check', column: 'exists_status' }, @@ -319,6 +330,7 @@ describe('可编辑件的主键与必填列(2026-08-27 复审:主键丢钥匙 / /** 三张真能编辑的件与它们各自绑定的台账对象 */ const EDITABLE: ReadonlyArray<{ view: string; domain: string; object: string }> = [ { view: 'p08-master-create', domain: 'parts', object: 'profile' }, + { view: 'p08-master-create', domain: 'parts', object: 'parameter' }, { view: 'p10-document-create', domain: 'parts', object: 'document' }, { view: 'eval-input-list', domain: D, object: 'input_gap' }, ]; diff --git a/activities/part-substitution-assessment/__tests__/steps.test.ts b/activities/part-substitution-assessment/__tests__/steps.test.ts index a6a68f2..e50ab49 100644 --- a/activities/part-substitution-assessment/__tests__/steps.test.ts +++ b/activities/part-substitution-assessment/__tests__/steps.test.ts @@ -154,13 +154,22 @@ describe('说明书与剧本的骨架', () => { expect(s3).toContain('工具调用后同拍其余动作作废'); }); - it('S0 剧本写明都在库时数据工单两项本就不会进「当下可为」;S0d_p08_master 剧本写明候选 ≥2 才问、=1 必建、原 PN 硬阻塞', () => { + it('S0 剧本写明都在库时数据工单两项本就不会进「当下可为」;S0d_p08_master 剧本按候选总数判断该不该问人(=1 必建、≥2 才问、原 PN 硬阻塞)', () => { const s0 = ASSESSMENT_STEPS.find((s) => s.item === 'S0_bind')?.guide ?? ''; expect(s0).toContain('当下可为'); expect(s0).toContain('不要对计划项用'); const s0d = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? ''; expect(s0d).toContain('硬阻塞'); - expect(s0d).toContain('候选只有 1 颗'); + expect(s0d).toContain('候选总数 = 1'); + }); + + it('S0d_p08_master 剧本按候选总数(不是「不在库的颗数」)判断要不要问人 —— 候选总数 ≥2 时逐颗不在库的都要问(2026-08-27 实测逼出的措辞修正)', () => { + const s0d = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? ''; + expect(s0d).toContain('候选总数'); + expect(s0d).toContain('不在库'); + expect(s0d).toMatch(/候选总数\s*≥\s*2/); + // 反面提示:「不在库的只有一颗」不该被读成跳过决策卡的理由 + expect(s0d).toContain('不是跳过决策卡的理由'); }); it('s_bound 哨兵真的检查了「有料号不在库」这件事(都在库时门不该开;退回旧写法会红)', () => { diff --git a/activities/part-substitution-assessment/src/case-model.ts b/activities/part-substitution-assessment/src/case-model.ts index a70c194..1c7b176 100644 --- a/activities/part-substitution-assessment/src/case-model.ts +++ b/activities/part-substitution-assessment/src/case-model.ts @@ -8,7 +8,8 @@ * view 恒压过模型当拍报的 say.view,一个计划项只能绑一份画面,P-08 与 P-10 各自要一份可编辑的 * 工作区件,不能共用同一个 view): * 启动期 → S0_bind(AI:铸案号、建 P-01 V1、锁规则版本、逐个 PN 查库、写存在性核对表) - * 0.5a 等待数据(建主数据)→ S0d_p08_master(人:数据工单 P-08;候选 ≥2 缺库先决策卡; + * 0.5a 等待数据(建主数据)→ S0d_p08_master(人:数据工单 P-08,件上表 1 主记录 + 表 2 标准化 + * 参数子表 `parts.parameter`;候选总数 ≥2 时缺库先决策卡; * 都在库时哨兵不开,这一项与下一项都不会进「当下可为」) * 0.5b 等待数据(建资料)→ S0d_p10_document(人:数据工单 P-10;都在库时同样不会进「当下可为」) * 1 建立评估上下文 → S1_input_confirm(人:执行工单 01,评估输入清单,件上补缺口) @@ -41,6 +42,7 @@ export const CMMN_XML = ` + @@ -66,18 +68,18 @@ export const CMMN_XML = ` update demand_request_rows > 0 and sub_eval_pn_check_rows > 0 and sub_eval_pn_check_exists_status_absent > 0 - + update - parts_profile_rows > 0 and parts_profile_missing = 0 + parts_profile_rows > 0 and parts_profile_missing = 0 and parts_parameter_missing = 0 update - sub_eval_pn_check_rows = count(candidateInternalPns) + 1 and sub_eval_pn_check_missing = 0 and sub_eval_pn_check_exists_status_absent = 0 and sub_eval_pn_check_exists_status_abandoned < count(candidateInternalPns) and parts_profile_missing = 0 and parts_document_missing = 0 + sub_eval_pn_check_rows = count(candidateInternalPns) + 1 and sub_eval_pn_check_missing = 0 and sub_eval_pn_check_exists_status_absent = 0 and sub_eval_pn_check_exists_status_abandoned < count(candidateInternalPns) and parts_profile_missing = 0 and parts_parameter_missing = 0 and parts_document_missing = 0 @@ -179,8 +181,9 @@ export const NARRATIVE = `§0 开案:**判断候选料号在技术上能不能 (挂验证任务号或补来源动作)。硬约束 MISMATCH 不许改判,要改只能补证据后重比。 **存在性检查与数据工单**:启动时逐个料号在 \`parts.profile\` 里查(原 PN + 每个候选)。查无记录 = 「完全没有」, -要人从零建整条主数据 —— 发**数据工单**(一张表一张单:P-08 一张、P-10 一张);PN 在库但个别字段缺 = 「有而不全」, -归执行工单 01 补。原 PN 缺 = 硬阻塞必须建;候选缺:候选 ≥2 时先问人放弃还是创建,候选 = 1 时必须建。 +要人从零建整条主数据(表 1 主记录 + 表 2 标准化参数子表 \`parts.parameter\`)—— 发**数据工单**(一张单:P-08 一张、P-10 一张); +PN 在库但个别字段缺 = 「有而不全」,归执行工单 01 补。**该不该问人,按候选总数判断,不是按不在库的颗数判断**: +原 PN 缺 = 硬阻塞必须建、不问;候选总数 = 1 时那一颗必须建、不问;候选总数 ≥ 2 时,每一颗不在库的候选都要先问人放弃还是创建。 **库里都有的话这两项的入场前提本就不成立**(此单不走这条),不会出现在你的「当下可为」里,别发空单。 **六步走**:绑定与存在性核对 → (等待数据)→ 人补评估输入 → 标准化对照表 → 逐项评估 → 人审结论(可循环)→ 拟落账 → 人放行。 @@ -197,7 +200,7 @@ export const NARRATIVE = `§0 开案:**判断候选料号在技术上能不能 ]} 人确认那一下基座已把这一行落进草稿层,**你不许再 write 一遍 customer.profile**。 -② 候选不在库、候选 ≥2 时,一颗一张决策卡(按候选数组顺序,一次只发一张;候选只有一颗时不问、直接建): +② 候选不在库、且候选总数 ≥2 时,一颗一张决策卡(按候选数组顺序,一次只发一张;候选总数 = 1 时不问、直接建): {"kind":"decide","itemId":"S0d_p08_master", "ask":"候选 在芯片主数据里查无记录。是从零建它的主数据(派数据工单),还是本轮放弃这颗候选?", "options":[{"value":"create","label":"创建主数据(派数据工单)","recommended":true}, @@ -209,11 +212,11 @@ export const NARRATIVE = `§0 开案:**判断候选料号在技术上能不能 {"kind":"human","itemId":"S0d_p08_master","workOrder":"execution","card":{ "title":"数据工单 · 芯片主数据创建(P-08):<料号列表,顿号分隔>", "ask":"这些料号在芯片主数据里查无记录,请在工作区的件上把整条记录建齐(一颗一行,可加行)。", - "steps":["每一行:品牌、规格描述、封装、生命周期状态、标准化参数集为必填,缺一格确认不了", - "标准化参数集一行一参数:参数名 | 值 | 单位 | hard/soft | 说明;原厂未披露的值写「未披露」,它会进入 UNKNOWN 并挂验证任务", + "steps":["表 1 每一行:品牌、规格描述、封装、生命周期状态为必填,缺一格确认不了", + "表 2 一行一个参数:所属料号、参数名、值、单位、约束(硬/软)必填,说明是只读注释;原厂未披露的值写「未披露」,它会进入 UNKNOWN 并挂验证任务", "有 datasheet 的把引用与版本一起填(成对);制造商与品牌不一致时分开写", "改完保存,回这张卡确认 —— 确认后这批主数据即入库,活动从等待数据唤醒"], - "standard":"每颗料号一行,必填列全齐;参数集至少含输入电压范围 / 输出电压 / 输出电流 / 工作温度 / 封装相关的硬约束项。", + "standard":"每颗料号表 1 一行,必填列全齐;表 2 至少含输入电压范围 / 输出电压 / 输出电流 / 工作温度 / 封装相关的硬约束项各一行。", "basis":[{"k":"触发原因","v":"按 internal_pn 查 P-08 查无记录(完全没有,不是字段缺失)"}, {"k":"AI 预填","v":"<有 datasheet 就写「已按 datasheet 预填 N 项,请核对」,没有写「未上传 datasheet,请全填」>"}]}} @@ -305,31 +308,39 @@ current_stage = "assessing"。application_scenario / bom_position **这一步不 }, { item: 'S0d_p08_master', - intent: '料号不在库:派 P-08 数据工单让人从零建芯片主数据;候选 ≥2 先问放弃还是创建', - writes: ['parts.profile', 'parts.document', `${D}.pn_check`], - reads: [`${D}.pn_check`, 'candidateInternalPns', 'originalInternalPn', 'datasheets', 'parts.profile', 'parts.document'], + intent: '料号不在库:派 P-08 数据工单让人从零建芯片主数据 + 标准化参数子表;候选总数 ≥2 才逐颗问放弃还是创建', + writes: ['parts.profile', 'parts.parameter', 'parts.document', `${D}.pn_check`], + reads: [`${D}.pn_check`, 'candidateInternalPns', 'originalInternalPn', 'datasheets', 'parts.profile', 'parts.parameter', 'parts.document'], view: 'p08-master-create', tools: [], /* 触达人,不给 doneWhen;人工激活:发卡就算启动 */ guide: `这一项只在 \`${D}.pn_check\` 里有 absent 行时走 —— 都在库时入场前提本就不成立,这一项与下一项 S0d_p10_document 都不会进你的「当下可为」。**先分岔,再动作**: -【先看轨迹】这一项里已经发过哪些卡、人答了什么(【当前这一步】下面那行引擎事实说得清楚): +【先数两个数,再分岔】(先看轨迹:这一项里已经发过哪些卡、人答了什么,【当前这一步】下面那行引擎事实说得清楚) +· **候选总数**:入参 \`candidateInternalPns\` 的长度(\`${D}.pn_check\` 里 role = "candidate" 的行数也是它,不随本单进展变化); +· **不在库的候选数**:\`${D}.pn_check\` 里 role = "candidate" 且 exists_status = "absent" 的行数(这个数会随着人放弃/创建而变小)。 +**该不该问人,看的是候选总数,不是不在库的颗数**——**「不在库的只有一颗」不是跳过决策卡的理由**: · 原 PN absent → **硬阻塞**,不问人,直接进入「建」的名单; -· 候选 absent 且候选数 ≥2 → 每颗 absent 的候选照说明书②各发一张决策卡(一次一张,按数组顺序);人选 "abandon"/"custom:" 的, - 把那一行 \`${D}.pn_check\` 改成 exists_status = "abandoned" + abandon_reason(同 PK 覆盖写),这颗不再评估;选 "create" 的进「建」的名单; -· 候选 absent 且候选只有 1 颗 → 不问,必须建。 -**至少留一颗候选**:放弃到只剩最后一颗还是 absent 的候选时,那一颗按「候选只有一颗」办 —— 不再发决策卡问,直接进「建」的名单 +· **候选总数 = 1** → 那一颗不许放弃,不问人,直接进入「建」的名单; +· **候选总数 ≥ 2** → **每一颗 absent 的候选都要先发一张决策卡问人**(一次一张,按候选数组顺序;哪怕这一单不在库的候选只有一颗, + 只要候选总数 ≥ 2 就要问)照说明书②;人选 "abandon"/"custom:" 的,把那一行 \`${D}.pn_check\` 改成 + exists_status = "abandoned" + abandon_reason(同 PK 覆盖写),这颗不再评估;选 "create" 的进「建」的名单。 +**至少留一颗候选**:放弃到只剩最后一颗还是 absent 的候选时,那一颗按「候选总数 = 1」办 —— 不再发决策卡问,直接进「建」的名单 (候选全被放弃,存在性核对永远凑不齐、单子会死锁,这一条就是防它)。 **别把同一颗候选问两遍**:决策卡答过就按答案办。 -【「建」的名单定了 → 写骨架行,发 P-08 数据工单】 -1. 给名单里每颗料写一行 \`parts.profile\` 骨架:key {internal_pn},patch 只带你确有依据的列 —— 有 \`datasheets[].text\` 就从里面读 - brand_name / specification_description / package / lifecycle_status(active/nrnd/eol/discontinued/risk 之一)/ normalized_parameter_set - (一行一参数:参数名 | 值 | 单位 | hard/soft | 说明,原厂没写的值写「未披露」)/ datasheet_ref + datasheet_version;没有 datasheet 就只写 internal_pn - 这一列(其余留给人填,**别编**)。 -2. 同一拍给每颗料写一行 \`parts.document\` 骨架:key {internal_pn, doc_seq: "01"},patch {document_type: "datasheet", document_status: "pending", +【「建」的名单定了 → 写骨架行,发 P-08 数据工单(一张单,件上两张表)】 +1. 给名单里每颗料写一行 \`parts.profile\` 骨架(表 1):key {internal_pn},patch 只带你确有依据的列 —— 有 \`datasheets[].text\` 就从里面读 + brand_name / specification_description / package / lifecycle_status(active/nrnd/eol/discontinued/risk 之一)/ datasheet_ref + datasheet_version; + 没有 datasheet 就只写 internal_pn 这一列(其余留给人填,**别编**)。 +2. 同一拍给每颗料写标准化参数集骨架(表 2,\`parts.parameter\`,一行一参数):有 \`datasheets[].text\` 就从里面读出典型参数 + (如输入电压范围 / 输出电压 / 输出电流 / 开关频率 / 静态电流 / 工作温度 / 使能阈值等,按品类而定)各写一行, + key {internal_pn, param_name},patch 带 param_value / param_unit / constraint_type(hard/soft)/ param_note(一句话说清这项为什么要卡, + 如「须覆盖客户输入上限」);原厂没写的值写「未披露」(它会进入 UNKNOWN 并挂验证任务)。没有 datasheet 就只写 + internal_pn + param_name 骨架(把你判断这颗料该评估的参数名列出来,**param_value 留空给人填,别编**)。 +3. 同一拍给每颗料写一行 \`parts.document\` 骨架:key {internal_pn, doc_seq: "01"},patch {document_type: "datasheet", document_status: "pending", brand_name 有就写}(这一行是骨架,下一项 S0d_p10_document 会把它填成正式记录)。 -3. 同一拍发说明书③那张 P-08 数据工单(title 里列全料号),发完这一拍结束(挂起)。 +4. 同一拍发说明书③那张 P-08 数据工单(title 里列全料号),发完这一拍结束(挂起)。 【P-08 那张确认回来了】finish 这一项 —— 下一项是 S0d_p10_document,同一批料号接着补资料记录。 @@ -346,7 +357,7 @@ current_stage = "assessing"。application_scenario / bom_position **这一步不 guide: `这一项只在 \`${D}.pn_check\` 里有 absent 行时走(要走到这一步,S0d_p08_master 必然已经收完);都在库时入场前提本就不成立, 这一项与 S0d_p08_master 都不会进你的「当下可为」。 -【发 P-10 数据工单】「建」的名单就是 \`${D}.pn_check\` 里仍是 exists_status = "absent" 的那几行(候选 ≥2 时被放弃的那些, +【发 P-10 数据工单】「建」的名单就是 \`${D}.pn_check\` 里仍是 exists_status = "absent" 的那几行(候选总数 ≥2 时被放弃的那些, S0d_p08_master 那一步已经把它们改成 "abandoned",不在这份名单里)。同一拍照说明书④发那张 P-10 数据工单(title 里列全料号,只发一次), 发完这一拍结束(挂起)。 @@ -359,7 +370,7 @@ S0d_p08_master 那一步已经把它们改成 "abandoned",不在这份名单里) item: 'S1_input_confirm', intent: '评估输入清单:绑定四项只读核对,缺口行交 PW 在件上补', writes: [`${D}.input_gap`, `${D}.verdict`, 'demand.request'], - reads: [`${D}.input_gap`, `${D}.pn_check`, 'demand.request', 'parts.profile', 'parts.document', 'candidateInternalPns', 'originalInternalPn'], + reads: [`${D}.input_gap`, `${D}.pn_check`, 'demand.request', 'parts.profile', 'parts.parameter', 'parts.document', 'candidateInternalPns', 'originalInternalPn'], view: 'eval-input-list', tools: [], /* 交人补缺口,不给 doneWhen;人工激活:发卡就算启动 */ @@ -371,7 +382,7 @@ S0d_p08_master 那一步已经把它们改成 "abandoned",不在这份名单里) (候选多颗就 candidate_1、candidate_2… 一颗一行,detail 写料号与在库状态); · 待补两项(status = "gap",detail 写「启动时未收」):application_scenario「应用场景」(source_ledger = "P-01")、bom_position「BOM 位」(source_ledger = "P-09"); · 默认可改三项(status = "default",detail 写默认值):substitution_type「替代类型」= "Pin-to-Pin"、rule_version「规则版本」= "${RULE_VERSION}"、owner「负责人 / 期限」= "按客户线默认 · 1 个工作日"; -· 待确认(status = "gap"):逐颗候选看 \`parts.profile.normalized_parameter_set\`,值是「未披露」的参数各一行(item_key = unknown_<料号>_<序号>, +· 待确认(status = "gap"):逐颗候选看它名下 \`parts.parameter\` 的那几行,param_value 是「未披露」的各一行(item_key = unknown_<料号>_<序号>, input_item 如「XL2596S-5.0 使能阈值」,source_ledger = "P-08",detail 写「原厂未披露(有而不全)」); · 齐备项(status = "ok"):原 PN 资料版本(source_ledger = "P-10",detail 写 datasheet 版本)。 写完同一拍照说明书⑤发执行工单,发完这一拍结束(挂起)。 @@ -388,7 +399,7 @@ S0d_p08_master 那一步已经把它们改成 "abandoned",不在这份名单里) item: 'S2_normalize', intent: '读取并标准化:每个未放弃的候选一条 P-11 主记录骨架 + 逐参数对照行', writes: ['substitution.relation', 'substitution.difference_item'], - reads: ['parts.profile', 'parts.document', 'demand.request', `${D}.pn_check`, `${D}.input_gap`, `${D}.verdict`, 'candidateInternalPns', 'originalInternalPn'], + reads: ['parts.profile', 'parts.parameter', 'parts.document', 'demand.request', `${D}.pn_check`, `${D}.input_gap`, `${D}.verdict`, 'candidateInternalPns', 'originalInternalPn'], view: 'difference-review', tools: [], doneWhen: @@ -402,10 +413,11 @@ substitution_type = "pin_to_pin"(人在评估输入清单里改了替代类型 analysis_status = "ai_draft"、rule_version = "${RULE_VERSION}"、bom_position(照 demand.request 现值抄,没有不写)。 recommendation_level / recommendation_reason / difference_summary / risk_level **这一步一格都不写**(S3 的活)。 -【每条关系逐参数写 \`substitution.difference_item\` 对照行】骨架按**原 PN 的参数集**(\`parts.profile.normalized_parameter_set\`,一行一参数 -「参数名 | 值 | 单位 | hard/soft | 说明」):原 PN 有的每个参数一行 —— difference_item_id = "<关系id>-P01/P02…"、parameter_name、 -original_value、candidate_value(候选参数集里同名那一行的值;候选没有这个参数 → 写「未披露」)、parameter_unit(以原 PN 为准;候选单位不同先换算, -换算不了原值照写、evidence_ref 里注明)、constraint_type(原 PN 那一行的 hard/soft;没标的按口径:封装 / 工艺 / 电压 / 电流 / 温度 / 阈值 hard,其余 soft)、 +【每条关系逐参数写 \`substitution.difference_item\` 对照行】骨架按**原 PN 在 \`parts.parameter\` 里的那几行**(它是独立子表,一行一参数, +按 internal_pn 归属):原 PN 有的每个参数一行 —— difference_item_id = "<关系id>-P01/P02…"、parameter_name(取原 PN 那一行的 param_name)、 +original_value(取原 PN 那一行的 param_value)、candidate_value(候选在 \`parts.parameter\` 里同 param_name 那一行的 param_value; +候选没有这个参数 → 写「未披露」)、parameter_unit(以原 PN 那一行的 param_unit 为准;候选单位不同先换算, +换算不了原值照写、evidence_ref 里注明)、constraint_type(取原 PN 那一行的 constraint_type;没标的按口径:封装 / 工艺 / 电压 / 电流 / 温度 / 阈值 hard,其余 soft)、 rule_version = "${RULE_VERSION}"、evidence_ref(值从哪来:「P-08 · P-10 <版本>」;本单数据工单建的料号写「人工录入 · 数据工单 · P-08」)。 另加两行**封装**与**生命周期**(constraint_type = hard / soft,值分别取 \`package\` 与 \`lifecycle_status\`)。 difference_status / compare_result / risk_level / impact_statement / unknown_disposition **这一步不写**。 diff --git a/activities/part-substitution-assessment/src/manifest.ts b/activities/part-substitution-assessment/src/manifest.ts index b3d2d8f..83000e0 100644 --- a/activities/part-substitution-assessment/src/manifest.ts +++ b/activities/part-substitution-assessment/src/manifest.ts @@ -5,12 +5,17 @@ * 《SS02-SS03-SS04_Demo台账字段细化与统一方案》/ 四份目标件 / 全流程分支图)。 * 骨架照 E04 / I01 已验证的样子:双级入参 → 活动自持单据 + 共享主数据 → decide 循环做中间审核 → * 终局放行。三处与前两个活动刻意不同,写在这里免得下一个读者当成疏漏: - * ① **主数据是六张(P-08/P-10/P-01/P-11 主+子/P-06)**,物理表由平台基线 + * ① **主数据是七张(P-08 主 + P-08 参数子表 / P-10 / P-01 / P-11 主+子 / P-06)**,物理表由平台基线 * (`apps/api/src/app/seed-mvp-data.ts`)物化,这里只**追加**本活动要用的列与标记 * (required / tally / refersTo / requiredWhen 是活动的授权面;声明合并是纯 add-column)。 + * **P-08 的标准化参数集是独立子表 `parts.parameter`**(2026-08-27 结构改:用户核对实跑 + * 页面后指出必须与目标件结构一致——目标件《数据工单-01》本就是两张表,塞进一个文本列 + * 违反《统一方案》§六「重复明细用子表承载,不在单一文本字段堆叠」)。 * ② **数据工单**(PN 不在库时让人从零建整条主数据)= 零输入执行工单 + 工作区的件 - * (`mine:parts.profile` / `mine:parts.document` 可加行),确认落草稿层、放行并入正式表 - * (守铁律②;文档里「确认即入库」在本平台落成「无审核环节、放行并入」)。一张表一张单。 + * (`mine:parts.profile` / `mine:parts.parameter` / `mine:parts.document` 可加行), + * 确认落草稿层、放行并入正式表(守铁律②;文档里「确认即入库」在本平台落成「无审核环节、 + * 放行并入」)。一张台账表一张单(P-08 主记录 + 参数子表算同一张数据工单 —— 目标件是 + * 同一份工单卡里的两张表)。 * ③ 逐项四态与推荐等级**由两枚纯工具算**(规则同时以 D01/D02 两张 DMN 随包冻结当文档), * 模型只负责对照表、证据与影响说明 —— 硬约束 MISMATCH 不可能被措辞改判。 */ @@ -84,7 +89,7 @@ export const manifest: ActivityManifest = { identity: { activityId: ACTIVITY_ID, name: 'ACT-WORK-05 · 差异评估', - packageVersion: '1.1.1', + packageVersion: '1.2.0', category: '需求分析与方案匹配', sdkVersion: '0.1.0-m5', checksum: 'dev-checksum-part-substitution-assessment', @@ -274,7 +279,9 @@ export const manifest: ActivityManifest = { col({ name: 'region', type: 'text', label: '区域', expect: true }), ], }, - /* P-08 芯片主数据:数据工单建整条;参数集是它的一个字段(一张表,不建子表) */ + /* P-08 芯片主数据:数据工单建整条;标准化参数集已改子表 parts.parameter(2026-08-27, + 用户看了实跑页面指出「必须与目标件结构一致」——目标件本就是两张表,一张主记录一张 + 参数子表,塞进一个文本列违反《统一方案》§六「重复明细用子表承载」) */ { domain: 'parts', object: 'profile', @@ -285,7 +292,6 @@ export const manifest: ActivityManifest = { col({ name: 'brand_name', type: 'text', label: '品牌名称', required: true }), col({ name: 'manufacturer_name', type: 'text', label: '制造商名称', expect: true }), col({ name: 'specification_description', type: 'text', label: '规格描述', required: true }), - col({ name: 'normalized_parameter_set', type: 'text', label: '标准化参数集(一行一参数:参数名 | 值 | 单位 | hard/soft | 说明)', required: true }), col({ name: 'package', type: 'text', label: '封装', required: true }), col({ name: 'process_technology', type: 'text', label: '工艺', expect: true }), col({ name: 'lifecycle_status', type: 'text', label: '生命周期状态', required: true, tally: ['active', 'nrnd', 'eol', 'discontinued', 'risk'] }), @@ -295,6 +301,21 @@ export const manifest: ActivityManifest = { col({ name: 'application_scenarios', type: 'text', label: '适配应用场景', expect: true }), ], }, + /* P-08 子表:标准化参数集,一行一参数,internal_pn 是引用墙(查无那颗主记录当场拒) */ + { + domain: 'parts', + object: 'parameter', + kind: 'master', + label: '标准化参数集(P-08 子表)', + fields: [ + col({ name: 'internal_pn', type: 'text', label: '所属料号', primaryKey: true, refersTo: 'parts.profile.internal_pn' }), + col({ name: 'param_name', type: 'text', label: '参数名', primaryKey: true }), + col({ name: 'param_value', type: 'text', label: '值', required: true }), + col({ name: 'param_unit', type: 'text', label: '单位', expect: true }), + col({ name: 'constraint_type', type: 'text', label: '约束类型', required: true, tally: ['hard', 'soft'] }), + col({ name: 'param_note', type: 'text', label: '说明', expect: true }), + ], + }, /* P-10 原厂技术资料库:数据工单建;评估时只读做证据回链 */ { domain: 'parts', diff --git a/activities/part-substitution-assessment/src/skills.ts b/activities/part-substitution-assessment/src/skills.ts index f271e7f..03f6989 100644 --- a/activities/part-substitution-assessment/src/skills.ts +++ b/activities/part-substitution-assessment/src/skills.ts @@ -11,15 +11,15 @@ export const ASSESSMENT_SKILLS: Skill[] = [ label: '把多来源资料整成可比对的标准参数对照表', applicability: '输入确认之后、逐项比对之前:原 PN 与每个候选的参数集要摆成同名同单位的一对一。', method: [ - '参数集在 `parts.profile.normalized_parameter_set` 里,一行一参数:`参数名 | 值 | 单位 | hard/soft | 说明`。', - '对照表按**原 PN 的参数名**为骨架:原 PN 有的参数,候选没有对应行 → 候选值写「未披露」(它会成为 UNKNOWN);', + '参数集是独立子表 `parts.parameter`(P-08 的结构子表),一行一参数,按 `internal_pn` 归属主记录:列是 param_name / param_value / param_unit / constraint_type / param_note。', + '对照表按**原 PN 名下的那几行参数名**为骨架:原 PN 有的参数,候选名下没有同名那一行 → 候选值写「未披露」(它会成为 UNKNOWN);', '候选多出来的参数不比(比对只回答「能不能替」,多出来的能力不构成差异)。', - '单位以原 PN 为准:候选单位不同先换算(mV→V、kHz→MHz),换算不了就不改值、在说明里写明 —— 不许静默改数。', - '约束类型以原 PN 那一行的 hard/soft 为准;原 PN 没标的按 GOV 口径:封装 / 工艺 / 电气边界(电压 / 电流 / 温度 / 阈值)一律 hard,其余 soft。', + '单位以原 PN 那一行的 param_unit 为准:候选单位不同先换算(mV→V、kHz→MHz),换算不了就不改值、在说明里写明 —— 不许静默改数。', + '约束类型以原 PN 那一行的 constraint_type 为准;原 PN 没标的按 GOV 口径:封装 / 工艺 / 电气边界(电压 / 电流 / 温度 / 阈值)一律 hard,其余 soft。', '**缺参数不得伪造**:查不到的写「未披露」,不写估计值、不写「约」。', ].join('\n'), crystallized: [], - knowledgeRefs: ['标准化参数集行语法', '硬/软约束口径'], + knowledgeRefs: ['标准化参数子表结构', '硬/软约束口径'], version: '1.0.0', }, { diff --git a/activities/part-substitution-assessment/src/views/report.ts b/activities/part-substitution-assessment/src/views/report.ts index 115ab82..d3b355e 100644 --- a/activities/part-substitution-assessment/src/views/report.ts +++ b/activities/part-substitution-assessment/src/views/report.ts @@ -4,7 +4,9 @@ * 五个局面各一张,版面语言**逐块搬自** `docs/SS02-ACT-WORK-05-html/` 的四份目标件 * (《HTML报告生成规范》§7:有目标件时「搬」,不要「照着做」——样式表整段搬,标记逐块搬, * 只把写死的数据换成绑定): - * · `p08-master-create` —— S0d 摆,数据工单(P-08 芯片主数据),搬自「数据工单-01」; + * · `p08-master-create` —— S0d 摆,数据工单(P-08 芯片主数据),搬自「数据工单-01」 + * (2026-08-27:补齐目标件里原本就有、之前漏搬的【创建什么 · 表 2】标准化参数子表 `parts.parameter` + * —— 用户核对实跑页面后指出「页面结构必须与目标件一致」,目标件本就是两张表); * · `p10-document-create` —— S0d 摆,数据工单(P-10 原厂技术资料),**没有自己的目标件** * (规格里数据工单是「一个统一模板按目标台账实例化」),复用「数据工单-01」的样式表与版面骨架; * · `eval-input-list` —— S0/S1 摆,评估输入清单,搬自「执行工单-01」; @@ -108,6 +110,16 @@ ${badgeCss('.stx', [ ['pending', '待核实', AMBER], ['void', '作废', MUTED], ])} +/* + * 表 2(标准化参数集)的约束类型(硬/软)—— 目标件里这一格是字面类名 .ct.hard / .ct.soft + * (P08_CSS 已带这两条颜色声明,.ct 基座形状也在);平台的 data-class-of 走的是 + * v- 别名机制,不是字面枚举值,这里用 badgeCss 生成 .ct.v-hard / .ct.v-soft, + * 颜色原样照抄目标件那两条声明的数值(DANGER/MUTED 与 .ct.hard / .ct.soft 同一份值)。 + */ +${badgeCss('.ct', [ + ['hard', '硬约束', DANGER], + ['soft', '软约束', MUTED], +])} `; /* ==================== 执行工单(评估输入清单):徽标别名 ==================== */ @@ -315,7 +327,6 @@ const P08_TABLE = `
- @@ -330,7 +341,34 @@ const P08_TABLE = `
Datasheet 引用 datasheet_ref Datasheet 版本 datasheet_version与引用成对 适配场景 application_scenarios判断实际适用性标准化参数集 normalized_parameter_set一行一参数:参数名|值|单位|hard/soft|说明
- + +
${cell('datasheet_ref', 'mono')} ${cell('datasheet_version')} ${cell('application_scenarios')}
`; + +/* + * 表 2 · 标准化参数集(P-08 子表 `parts.parameter`)—— 粒度与表 1 不同(一个参数一行, + * 行数不固定),不能塞进表 1 的一格(《统一方案》§六:重复明细用子表承载), + * 逐块搬自目标件《数据工单-01》的【创建什么 · 表 2】,`id="tbl2"` 沿用目标件自己的 + * 列宽声明(P08_CSS 里 `#tbl2 th:nth-child(n)` 那几条,不必在这里重复)。 + * 「说明」列是系统只读注释(目标件原话:「说明列为系统注释,无需填写」),不给 data-col。 + */ +const P08_PARAMS_TABLE = `
+ + + + + + + + + + + + + + + + +
#所属料号 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')}
`; @@ -347,16 +385,21 @@ export const P08_MASTER_CREATE = doc({ body: zone('做什么', '批量创建 P-08 主数据记录', ZIC_TASK) + note([ - '模板定位:本单是「数据工单」统一模板的实例——一单一类(可多条):目标台账 = 芯片主数据(P-08);字段即表头、每行一条新记录;标准化参数集是这条记录的一个字段(一行一参数)。', + '模板定位:本单是「数据工单」统一模板的实例——一单一类(可多条):目标台账 = 芯片主数据(P-08);字段即表头、每行一条新记录;标准化参数集是它的结构子表(表 2,一行一参数,按所属料号归属)。', '触发原因:系统按内部料号查芯片主数据查无记录——是「完全没有」,不是字段缺失(那归执行工单)。', 'AI 预填:上传了 datasheet 的料号 AI 已按资料预填,标着值的格子请核对;没上传的料号全部手填,同为合法路径。', '纪律:必填列全齐才允许确认;确认后这批记录进本单草稿,放行时并入正式库并留痕(确认人 = 数据责任人);只建主数据,验证记录等事实类不在此建。', ]) + - zone('创建什么', '主数据记录(每行一条,*为必填列)', ZIC_TABLE) + + zone('创建什么 · 表 1', '主数据记录(每行一条,*为必填列)', ZIC_TABLE) + gapbar('parts_profile', '必填') + editGate('parts.profile', `待建 条记录,点「编辑」核对预填、补齐空缺;「+ 加一行」= 再建一条`) + P08_TABLE + addrow('parts.profile', '+ 加一行(再建一条记录)', '新行的内部料号必须写(它是这一行的钥匙);其余必填列全部填写后才允许确认;删一行 = 该记录不建') + + `

${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);