From 0629a7f05418ad05b29cf0cdda000bc43d36b1ff Mon Sep 17 00:00:00 2001 From: xcs Date: Thu, 27 Aug 2026 00:01:39 +0800 Subject: [PATCH] =?UTF-8?q?fix(w05):=20S0d=20=E6=8B=86=E6=88=90=20P-08/P-1?= =?UTF-8?q?0=20=E4=B8=A4=E4=B8=AA=E8=AE=A1=E5=88=92=E9=A1=B9=E5=90=84?= =?UTF-8?q?=E8=87=AA=E7=BB=91=20view;=E8=A1=A5=E9=BD=90=20fresh()=20?= =?UTF-8?q?=E4=B8=89=E9=9D=A2=E6=97=97;=E5=80=99=E9=80=89=E5=85=A8?= =?UTF-8?q?=E6=94=BE=E5=BC=83=E9=98=B2=E6=AD=BB=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 4 审查发现 S0d_master_data 固定单一 view 导致 P-10 那份件在引擎里永不可达 (剧本声明的 view 恒压过模型的 say.view),按裁决拆成 S0d_p08_master / S0d_p10_document 两个计划项,各自绑 p08-master-create / p10-document-create;caseFileModel 补 parts_profile_rows、新增哨兵 s_p08_created 串起两步的入场前提;说明书三处 itemId、 S0_bind 的 disable 提示、ASSESSMENT_PHASES 第一段随之更新。steps.test.ts 的 fresh() 补齐 input_confirmed_total / review_approved_total / landing_prepared_total 三面旗 (此前因未绑定名比较求值为假才没暴露)。S0d_p08_master 剧本加「至少留一颗候选」规则, 防止候选全被放弃导致 s_pns_resolved 永远不成立的死锁。46/46 测试与 typecheck 全绿。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh --- .../__tests__/activity.test.ts | 18 ++-- .../__tests__/steps.test.ts | 25 +++++- .../src/case-model.ts | 82 +++++++++++++------ 3 files changed, 92 insertions(+), 33 deletions(-) diff --git a/activities/part-substitution-assessment/__tests__/activity.test.ts b/activities/part-substitution-assessment/__tests__/activity.test.ts index 90f9f14..e1770a8 100644 --- a/activities/part-substitution-assessment/__tests__/activity.test.ts +++ b/activities/part-substitution-assessment/__tests__/activity.test.ts @@ -10,8 +10,8 @@ import { createMockHost } from '@sdk'; import { manifest, D, RULE_VERSION } from '../src/manifest.ts'; import activity from '../src/index.ts'; -const ITEMS = ['S0_bind', 'S0d_master_data', 'S1_input_confirm', 'S2_normalize', 'S3_assess', 'S4_review', 'S5_prepare_landing', 'T_release']; -const NO_DONE_WHEN = new Set(['S0d_master_data', 'S1_input_confirm', 'S4_review', 'T_release']); +const ITEMS = ['S0_bind', 'S0d_p08_master', 'S0d_p10_document', 'S1_input_confirm', 'S2_normalize', 'S3_assess', 'S4_review', 'S5_prepare_landing', 'T_release']; +const NO_DONE_WHEN = new Set(['S0d_p08_master', 'S0d_p10_document', 'S1_input_confirm', 'S4_review', 'T_release']); const VIEWS = ['p08-master-create', 'p10-document-create', 'eval-input-list', 'difference-review', 'landing-confirm']; const byAddress = (domain: string, object: string) => manifest.declarations.ledger.find((l) => l.domain === domain && l.object === object); const viewHtml = (name: string) => manifest.assets.viewTemplates.find((t) => t.name === name)?.html ?? ''; @@ -124,20 +124,26 @@ describe('台账声明:授权面与《统一方案》口径', () => { }); describe('案例模型与清单的对齐', () => { - it('八个计划项,剧本 item 与 CMMN humanTask id 一一对应', () => { + it('九个计划项,剧本 item 与 CMMN humanTask id 一一对应', () => { const cmmnIds = [...manifest.assets.businessModel.cmmnXml.matchAll(/ m[1] ?? ''); expect(cmmnIds).toEqual(ITEMS); expect((manifest.assets.businessModel.steps as ReadonlyArray<{ item: string }>).map((s) => s.item)).toEqual(ITEMS); }); - it('四处不给 doneWhen(数据工单 / 输入确认 / 审核 / 放行),其余四处都给', () => { + it('五处不给 doneWhen(两张数据工单 / 输入确认 / 审核 / 放行),其余四处都给', () => { for (const s of manifest.assets.businessModel.steps as ReadonlyArray<{ item: string; doneWhen?: string }>) { if (NO_DONE_WHEN.has(s.item)) expect(s.doneWhen, s.item).toBeUndefined(); else expect(s.doneWhen, s.item).toBeDefined(); } }); - it('那四项在 CMMN 里都标了人工激活', () => { + it('两张数据工单件各自绑自己的 view(P-08 与 P-10 不共用同一份画面)', () => { + const steps = manifest.assets.businessModel.steps as ReadonlyArray<{ item: string; view?: string }>; + expect(steps.find((s) => s.item === 'S0d_p08_master')?.view).toBe('p08-master-create'); + expect(steps.find((s) => s.item === 'S0d_p10_document')?.view).toBe('p10-document-create'); + }); + + it('那五项在 CMMN 里都标了人工激活', () => { for (const id of NO_DONE_WHEN) { const block = new RegExp(``).exec(manifest.assets.businessModel.cmmnXml)?.[0] ?? ''; expect(block, id).toContain('manualActivationRule'); @@ -159,7 +165,7 @@ describe('案例模型与清单的对齐', () => { it('两种决策卡骨架都在说明书里:候选放弃/创建(create/abandon)与审核(approve/reject/custom:)', () => { const n = manifest.assets.businessModel.narrative; - expect(n).toContain('"kind":"decide","itemId":"S0d_master_data"'); + expect(n).toContain('"kind":"decide","itemId":"S0d_p08_master"'); expect(n).toContain('"value":"create"'); expect(n).toContain('"value":"abandon"'); expect(n).toContain('"kind":"decide","itemId":"S4_review"'); diff --git a/activities/part-substitution-assessment/__tests__/steps.test.ts b/activities/part-substitution-assessment/__tests__/steps.test.ts index b9df010..41549fc 100644 --- a/activities/part-substitution-assessment/__tests__/steps.test.ts +++ b/activities/part-substitution-assessment/__tests__/steps.test.ts @@ -33,6 +33,9 @@ const fresh = (input: Record): Record => ({ [`${D}_pn_check_exists_status_abandoned`]: 0, [`${D}_input_gap_rows`]: 0, [`${D}_input_gap_missing`]: 0, + input_confirmed_total: 0, + review_approved_total: 0, + landing_prepared_total: 0, substitution_relation_rows: 0, substitution_relation_missing: 0, substitution_relation_analysis_status_fae_confirmed: 0, @@ -136,7 +139,7 @@ describe('doneWhen —— 开局不许真,干完必须真', () => { describe('说明书与剧本的骨架', () => { it('五张卡的骨架都住在说明书里(压拍之后只有说明书还在)', () => { expect(NARRATIVE).toContain('"kind":"entry","itemId":"S0_bind"'); - expect(NARRATIVE).toContain('"kind":"decide","itemId":"S0d_master_data"'); + expect(NARRATIVE).toContain('"kind":"decide","itemId":"S0d_p08_master"'); expect(NARRATIVE).toContain('"title":"数据工单 · 芯片主数据创建(P-08)'); expect(NARRATIVE).toContain('"title":"数据工单 · 原厂技术资料创建(P-10)'); expect(NARRATIVE).toContain('"title":"评估输入与资料补齐确认'); @@ -151,14 +154,28 @@ describe('说明书与剧本的骨架', () => { expect(s3).toContain('工具调用后同拍其余动作作废'); }); - it('S0 剧本写明库里都有就 disable S0d;S0d 剧本写明候选 ≥2 才问、=1 必建、原 PN 硬阻塞', () => { + it('S0 剧本写明库里都有就 disable S0d 两项;S0d_p08_master 剧本写明候选 ≥2 才问、=1 必建、原 PN 硬阻塞', () => { const s0 = ASSESSMENT_STEPS.find((s) => s.item === 'S0_bind')?.guide ?? ''; - expect(s0).toContain('disable S0d_master_data'); - const s0d = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_master_data')?.guide ?? ''; + expect(s0).toContain('disable S0d_p08_master'); + const s0d = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? ''; expect(s0d).toContain('硬阻塞'); expect(s0d).toContain('候选只有 1 颗'); }); + it('S0d 拆成两项各自绑自己的 view(P-10 那份件不会永远推不出来),两项都没有 doneWhen', () => { + const p08 = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master'); + const p10 = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p10_document'); + expect(p08?.view).toBe('p08-master-create'); + expect(p10?.view).toBe('p10-document-create'); + expect(p08?.doneWhen).toBeUndefined(); + expect(p10?.doneWhen).toBeUndefined(); + }); + + it('候选放弃到只剩最后一颗时不许再放弃(至少留一颗,防止全放弃死锁)', () => { + const s0d = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? ''; + expect(s0d).toContain('至少留一颗候选'); + }); + it('五段进度,每段名 ≤10 字;除放行外每项恰属一段', () => { expect(ASSESSMENT_PHASES).toHaveLength(5); for (const p of ASSESSMENT_PHASES) expect(p.name.length, p.name).toBeLessThanOrEqual(10); diff --git a/activities/part-substitution-assessment/src/case-model.ts b/activities/part-substitution-assessment/src/case-model.ts index 3afede6..701ec80 100644 --- a/activities/part-substitution-assessment/src/case-model.ts +++ b/activities/part-substitution-assessment/src/case-model.ts @@ -4,17 +4,21 @@ * 业务出处:`docs/SS02-ACT-WORK-05-html/ACT-WORK-05-活动流程与工单说明.md`(步骤 0.5 + 5 步, * 1 数据(条件)+ 1 执行 + 2 验收)与《SS02-SS03-SS04_Demo台账字段细化与统一方案》(字段口径)。 * - * 文档六步收成 8 个案例模型项: + * 文档六步收成 9 个案例模型项(0.5 那一步因 view 各不相同拆成两项 —— 引擎里剧本声明的 + * view 恒压过模型当拍报的 say.view,一个计划项只能绑一份画面,P-08 与 P-10 各自要一份可编辑的 + * 工作区件,不能共用同一个 view): * 启动期 → S0_bind(AI:铸案号、建 P-01 V1、锁规则版本、逐个 PN 查库、写存在性核对表) - * 0.5 等待数据 → S0d_master_data(人:数据工单 —— P-08 一张、P-10 一张;候选 ≥2 缺库先决策卡; + * 0.5a 等待数据(建主数据)→ S0d_p08_master(人:数据工单 P-08;候选 ≥2 缺库先决策卡; * 库里都有就 disable 这一项) + * 0.5b 等待数据(建资料)→ S0d_p10_document(人:数据工单 P-10;库里都有就 disable 这一项) * 1 建立评估上下文 → S1_input_confirm(人:执行工单 01,评估输入清单,件上补缺口) * 2 读取并标准化 → S2_normalize(AI:P-11 主记录骨架 + 差异明细对照行) * 3 逐项差异评估 → S3_assess(AI + 两枚工具:四态 / 风险 / 证据 / 影响 / 推荐等级 / 验证任务) * 4 FAE/RI 审核 → S4_review(人:验收工单 01 = 决策卡循环,驳回则 AI 改台账再发) * 5 提交评审放行 → S5_prepare_landing(AI:analysis_status 推 fae_confirmed、回写 P-01)+ T_release(人:验收工单 02,终局放行) * - * 四处不给 doneWhen:S0d(触达人)、S1(交人补缺口)、S4(拍板且可循环)、T_release(签下去)。 + * 五处不给 doneWhen:S0d_p08_master / S0d_p10_document(触达人)、S1(交人补缺口)、 + * S4(拍板且可循环)、T_release(签下去)。 */ import { D, RULE_VERSION, TOOL_COMPARE, TOOL_RECOMMEND } from './constants.ts'; @@ -35,6 +39,7 @@ export const CMMN_XML = ` + @@ -61,6 +66,11 @@ export const CMMN_XML = ` update demand_request_rows > 0 and sub_eval_pn_check_rows > 0 + + + update + parts_profile_rows > 0 and parts_profile_missing = 0 + - - - - + + + + + + + + + + @@ -165,7 +181,7 @@ export const NARRATIVE = `§0 开案:**判断候选料号在技术上能不能 **存在性检查与数据工单**:启动时逐个料号在 \`parts.profile\` 里查(原 PN + 每个候选)。查无记录 = 「完全没有」, 要人从零建整条主数据 —— 发**数据工单**(一张表一张单:P-08 一张、P-10 一张);PN 在库但个别字段缺 = 「有而不全」, 归执行工单 01 补。原 PN 缺 = 硬阻塞必须建;候选缺:候选 ≥2 时先问人放弃还是创建,候选 = 1 时必须建。 -**库里都有就把 S0d_master_data disable 掉**(此单不走这条),别发空单。 +**库里都有就把 S0d_p08_master 与 S0d_p10_document 两项都 disable 掉**(此单不走这条),别发空单。 **六步走**:绑定与存在性核对 → (等待数据)→ 人补评估输入 → 标准化对照表 → 逐项评估 → 人审结论(可循环)→ 拟落账 → 人放行。 @@ -182,7 +198,7 @@ export const NARRATIVE = `§0 开案:**判断候选料号在技术上能不能 人确认那一下基座已把这一行落进草稿层,**你不许再 write 一遍 customer.profile**。 ② 候选不在库、候选 ≥2 时,一颗一张决策卡(按候选数组顺序,一次只发一张;候选只有一颗时不问、直接建): -{"kind":"decide","itemId":"S0d_master_data", +{"kind":"decide","itemId":"S0d_p08_master", "ask":"候选 在芯片主数据里查无记录。是从零建它的主数据(派数据工单),还是本轮放弃这颗候选?", "options":[{"value":"create","label":"创建主数据(派数据工单)","recommended":true}, {"value":"abandon","label":"放弃这颗候选"}],"allowCustom":true} @@ -190,7 +206,7 @@ export const NARRATIVE = `§0 开案:**判断候选料号在技术上能不能 点了 \`"abandon"\` 没写原因,\`abandon_reason\` 写「负责人本轮放弃(未注明原因)」。 ③ 数据工单 · P-08(所有要建的料号一起,一张单;零输入,填报全在工作区的件上): -{"kind":"human","itemId":"S0d_master_data","workOrder":"execution","card":{ +{"kind":"human","itemId":"S0d_p08_master","workOrder":"execution","card":{ "title":"数据工单 · 芯片主数据创建(P-08):<料号列表,顿号分隔>", "ask":"这些料号在芯片主数据里查无记录,请在工作区的件上把整条记录建齐(一颗一行,可加行)。", "steps":["每一行:品牌、规格描述、封装、生命周期状态、标准化参数集为必填,缺一格确认不了", @@ -202,7 +218,7 @@ export const NARRATIVE = `§0 开案:**判断候选料号在技术上能不能 {"k":"AI 预填","v":"<有 datasheet 就写「已按 datasheet 预填 N 项,请核对」,没有写「未上传 datasheet,请全填」>"}]}} ④ 数据工单 · P-10(同一批料号的资料记录,一张单;在 P-08 那张确认之后再发): -{"kind":"human","itemId":"S0d_master_data","workOrder":"execution","card":{ +{"kind":"human","itemId":"S0d_p10_document","workOrder":"execution","card":{ "title":"数据工单 · 原厂技术资料创建(P-10):<料号列表>", "ask":"请把这些料号的 datasheet / 承诺函等资料记录补进原厂技术资料库(一份资料一行,可加行)。", "steps":["资料类型、标题、版本、文件链接、资料状态为必填;证据定位写页码或章节,差异评估的证据引用会指向它", @@ -246,7 +262,8 @@ export const NARRATIVE = `§0 开案:**判断候选料号在技术上能不能 /** * 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。 - * 四处不给 doneWhen:S0d(发数据工单触达人)、S1(交人补缺口)、S4(拍板且循环)、T_release(签下去)。 + * 五处不给 doneWhen:S0d_p08_master / S0d_p10_document(发数据工单触达人)、S1(交人补缺口)、 + * S4(拍板且循环)、T_release(签下去)。 */ export const ASSESSMENT_STEPS = [ { @@ -281,41 +298,60 @@ current_stage = "assessing"。application_scenario / bom_position **这一步不 【写 \`${D}.verdict\` 一行】eval_ref、request_id、rule_version = "${RULE_VERSION}"、original_internal_pn、candidate_total = 候选数组长度。 -写完引擎自己收这一项。收完先看 \`${D}.pn_check\`:有 absent 的 → 下一项是 S0d_master_data(数据工单);一个 absent 都没有 → -**disable S0d_master_data**(reason 写「原 PN 与全部候选都在库,无需建主数据」),然后等 S1 那一项 Enabled。`, +写完引擎自己收这一项。收完先看 \`${D}.pn_check\`:有 absent 的 → 下一项是 S0d_p08_master(数据工单);一个 absent 都没有 → +**disable S0d_p08_master 与 S0d_p10_document 两项**(reason 写「原 PN 与全部候选都在库,无需建主数据」),然后等 S1 那一项 Enabled。`, }, { - item: 'S0d_master_data', - intent: '料号不在库:派数据工单让人从零建整条主数据(P-08 一张、P-10 一张);候选 ≥2 先问放弃还是创建', + 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'], view: 'p08-master-create', tools: [], /* 触达人,不给 doneWhen;人工激活:发卡就算启动 */ - guide: `这一项只在 \`${D}.pn_check\` 里有 absent 行时走;都在库就 disable 它(见 S0 剧本)。**先分岔,再动作**: + guide: `这一项只在 \`${D}.pn_check\` 里有 absent 行时走;都在库就由 S0_bind 把这一项与下一项 S0d_p10_document 一起 disable 掉。**先分岔,再动作**: 【先看轨迹】这一项里已经发过哪些卡、人答了什么(【当前这一步】下面那行引擎事实说得清楚): · 原 PN absent → **硬阻塞**,不问人,直接进入「建」的名单; · 候选 absent 且候选数 ≥2 → 每颗 absent 的候选照说明书②各发一张决策卡(一次一张,按数组顺序);人选 "abandon"/"custom:" 的, 把那一行 \`${D}.pn_check\` 改成 exists_status = "abandoned" + abandon_reason(同 PK 覆盖写),这颗不再评估;选 "create" 的进「建」的名单; · 候选 absent 且候选只有 1 颗 → 不问,必须建。 +**至少留一颗候选**:放弃到只剩最后一颗还是 absent 的候选时,那一颗按「候选只有一颗」办 —— 不再发决策卡问,直接进「建」的名单 +(候选全被放弃,存在性核对永远凑不齐、单子会死锁,这一条就是防它)。 **别把同一颗候选问两遍**:决策卡答过就按答案办。 -【「建」的名单定了 → 先写骨架行,再发 P-08 数据工单】 +【「建」的名单定了 → 写骨架行,发 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", - brand_name 有就写}。 + brand_name 有就写}(这一行是骨架,下一项 S0d_p10_document 会把它填成正式记录)。 3. 同一拍发说明书③那张 P-08 数据工单(title 里列全料号),发完这一拍结束(挂起)。 -【P-08 那张确认回来了】看轨迹里的 [card-reply]:再发说明书④那张 P-10 数据工单(只发一次),发完这一拍结束。 +【P-08 那张确认回来了】finish 这一项 —— 下一项是 S0d_p10_document,同一批料号接着补资料记录。 -【P-10 那张也确认回来了】把名单里每颗料的 \`${D}.pn_check\` 改成 exists_status = "created"(同 PK 覆盖写),然后 finish 这一项。 +【这张卡一单只发一次】人交回来之后不许再发;答复里没有值(空 values)也算答过 —— 人在件上填的东西已经落草稿层。`, + }, + { + item: 'S0d_p10_document', + intent: '料号不在库:派 P-10 数据工单让人从零建原厂技术资料', + writes: ['parts.document', `${D}.pn_check`], + reads: [`${D}.pn_check`, 'parts.profile', 'parts.document'], + view: 'p10-document-create', + tools: [], + /* 触达人,不给 doneWhen;人工激活:发卡就算启动 */ + guide: `这一项只在 \`${D}.pn_check\` 里有 absent 行时走(要走到这一步,S0d_p08_master 必然已经收完);都在库就由 S0_bind 把这一项与 +S0d_p08_master 一起 disable 掉。 + +【发 P-10 数据工单】「建」的名单就是 \`${D}.pn_check\` 里仍是 exists_status = "absent" 的那几行(候选 ≥2 时被放弃的那些, +S0d_p08_master 那一步已经把它们改成 "abandoned",不在这份名单里)。同一拍照说明书④发那张 P-10 数据工单(title 里列全料号,只发一次), +发完这一拍结束(挂起)。 + +【P-10 那张确认回来了】把名单里每颗料的 \`${D}.pn_check\` 改成 exists_status = "created"(同 PK 覆盖写),然后 finish 这一项。 这时 S1_input_confirm 的入场前提(全部料号落定、本单建的主数据不缺必填)自己就成立了 —— 缺必填由提交闸兜着,卡回来时一定是齐的。 -【这两张卡一单各只发一次】人交回来之后不许再发;答复里没有值(空 values)也算答过 —— 人在件上填的东西已经落草稿层。`, +【这张卡一单只发一次】人交回来之后不许再发;答复里没有值(空 values)也算答过 —— 人在件上填的东西已经落草稿层。`, }, { item: 'S1_input_confirm', @@ -481,7 +517,7 @@ difference_status / compare_result / risk_level / impact_statement / unknown_dis * 0.5 启动与存在性检查(等待数据)· 1 建立评估上下文 · 2 读取并标准化 · 3 逐项差异评估 · 4 FAE 审核与补证(含拟落账)· 5 提交评审放行(基座那格) */ export const ASSESSMENT_PHASES = [ - { name: '启动与存在性检查', items: ['S0_bind', 'S0d_master_data'] }, + { name: '启动与存在性检查', items: ['S0_bind', 'S0d_p08_master', 'S0d_p10_document'] }, { name: '建立评估上下文', items: ['S1_input_confirm'] }, { name: '读取并标准化资料', items: ['S2_normalize'] }, { name: '逐项差异评估', items: ['S3_assess'] },