This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/docs/superpowers/plans/2026-08-26-act-work-05-part-substitution-assessment.md
T
xcsandClaude Fable 5 bd86dd6088 docs(w05): 差异评估的目标件五份 HTML + 三份说明 + 实施计划;三张图
活动包 views/target-style.ts / report.ts 与两份 W05 文档都指着 docs/SS02-ACT-WORK-05-html ——
模版「逐字节搬自目标件」的那份目标件此前没入库。docs/img/cy 三张 png 现无引用,随批入库。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
2026-08-27 11:44:35 +08:00

249 KiB
Raw Blame History

ACT-WORK-05 差异评估(part-substitution-assessment)实施计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 把 SS02 的 ACT-WORK-05「差异评估」做成本仓第三个真活动包:台账建进库、活动装进系统、脚本 e2e 五案例全绿,再在浏览器里把全流程全分支走一遍,每一轮的结论与修改记进 docs/ACT-WORK-05-端到端测试记录.md。

Architecture: 照 E04 / I01 已验证的骨架 —— 清单(契约七件 + 台账声明 + 样例)、案例模型(CMMN 8 项 + 说明书 + 逐步剧本 + 5 段进度)、两枚纯工具(逐参数比对 / 推荐定级,规则同时以 DMN 表随包冻结)、五张 HTML 模版(两张数据工单件 + 执行单件 + 两张验收件)。主数据(P-01/P-08/P-10/P-11/P-06)进平台基线 MASTER_OBJECTS 由安装通道物化到 ledger schema,活动清单只追加自己要用的列与标记;活动自持单据归 sub_eval.*。数据工单 = 零输入执行工单 + 工作区可增删行的件(每张单只对一张表);验收 01 = 决策卡循环;验收 02 = 终局放行。

Tech Stack: TypeScript(活动包只 import @sdk / @contracts / @sfeel)、vitest、node-postgres(ledger 动态 DDL)、Hono API、React 工作台、tools/e2e-a2.mjs(服务账号 e2e)、claude-in-chrome(浏览器走查)。

Spec: docs/SS02-ACT-WORK-05-html/ACT-WORK-05-活动流程与工单说明.md · ACT-WORK-05-一键启动入参说明.md · SS02-SS03-SS04_Demo台账字段细化与统一方案.md(字段口径唯一权威)· 四份目标件 HTML · ACT-WORK-05-全流程分支图.html(18 条分支)。开发纪律:docs/活动开发规范.md、docs/HTML报告生成规范.md、CLAUDE.md 铁规矩。

Global Constraints

  • 五道门禁以退出码验:pnpm gates(= gen:tiers → typecheck → lint → depcruise → api:check → test);不许以 echo 验。
  • §12.5 禁止清单:no any / as unknown as / @ts-ignore;domain 零 IO;不删测试;不动门禁配置。
  • 活动包只准 import @sdk / @contracts / @sfeel;产物文本黑名单(process.env / fetch( / node: / eval();模版含 <script 即拒装。
  • 安装恒新增:改了包内容必升 identity.packageVersion,同版重装静默 no-op。
  • 机检(L1/L2、哨兵、doneWhen)只读基座读数(域_对象_rows / _missing / _<列>_<值> / _distinct_<列> / tool_<名>_calls);模型自写的旗只能占谓词一半。
  • 触达世界的项(交人核对 / 拍板 / 放行)标 manualActivationRule 且不给 doneWhen;issueAcceptance 一任务只能发一次且即终局 → 非终局审核用 decide 循环。
  • 执行卡零输入:不摆 fields / blocks,填报全在工作区的件上;一单同一张卡只发一次。
  • 报告模版不算不判:data-slot / data-rows="mine:域.对象" / data-col / data-edit / data-class-of / data-blank;主键列必挂 data-col;枚举列存 ASCII、人读名由样式表出。
  • 样例:第一条满载;样例名 ≤14 字。
  • 试跑一律用服务账号 bpos-e2e(KEYCLOAK_E2E_CLIENT_ID/SECRET),测完 pnpm --filter @app/api reset-runs;浏览器走查用当前 Chrome 已登录会话。
  • 提交只 git add 本任务点名的文件(工作树里有一批未提交的鉴权改动,别顺手带走);提交信息末尾带: Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 与 Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh。
  • 数据工单口径(用户 2026-08-26 定):读数点概念作废,一张表一张单;P-08 是一张表(参数集是它的一个字段);本活动最多 2 张数据工单(P-08 / P-10);S-03 / L-02(启动前)/ P-06(事实类)/ P-09 不派。
  • 本活动只答技术可替代性:不做价格 / 交期 / 数量判断;customer_approval_status 只读边界,活动不声明其写权。

0. 已锁定的设计决定(执行者不必再问;要改先改这里)

# 决定 依据
D1 守铁律②:数据工单确认落本单草稿层,放行时并入正式表(试跑单 landMaster 默认开,放行后主数据真落库);本单后续步骤读得到本单草稿,所以「建了就能拿来评」不受影响。文档里「确认即正式入库、无草稿态」落成「无审核环节、放行并入」。 docs/活动开发规范.md:45 铁律②;E04 录入卡同款
D2 数据工单 = 零输入执行工单 + 件(data-edit 表绑 mine:parts.profile / mine:parts.document,可加行,提交闸 = 声明的必填);卡标题以「数据工单 ·」开头;一张表一张单(P-08 一张、P-10 一张);对话区叫「数据卡」的专属卡皮记 debt。 现成机制零基座改动
D3 P-08 一张表:normalized_parameter_set 是 parts.profile 的 text 列,行语法 参数名 | 值 | 单位 | hard/soft | 说明(一行一参数),件上用 textarea 编辑;不建 parts.parameter 子表,不用 json(json 列对人填的字符串不归型,textarea 存进去是 JSON 字符串,读写两侧形状不一)。 用户 2026-08-26「都是一张表」;engines.ts coerceToColumn json 不归型
D4 datasheet 作起单选填文件入参 datasheets(format:'file',与 A1 attachments 同形);传了 AI 在数据工单件上预填,没传人全填(文档 0c:同为合法路径);「件上上传」记 debt。 平台文件入口只有起单 format:'file'
D5 一人兼 PW/FAE:所有卡发给任务负责人;roles: ['FAE/RI'];派给第二个人记 debt。 平台一任务恰一名负责人
D6 存在性检查 = S0 读 parts.profile 整表(剧本 reads)逐个候选核对 + refersTo 墙(substitution.relation.original/candidate_internal_pn → parts.profile.internal_pn;查无当场拒并指路);不另做基座读数。 E04 contact_code 同款
D7 主数据物化走平台基线(seed-mvp-data.ts 新增 4 域 6 表)+ 清单追加声明(必填 / tally / refersTo / requiredWhen 是活动的授权面);不写 SQL 迁移(ledger 表由声明动态物化)。 seed-activities.ts 主数据先于活动物化
D8 枚举列一律 ASCII tally,人读名由样式表出(推荐等级 recommended/conditional/alternate/not_recommended;差异状态 match/mismatch/unknown/conditional;约束 hard/soft;生命周期 active/nrnd/eol/discontinued/risk)。 data-class-of 只认 ASCII
D9 验收 01 非终局 → decide 循环(E04 S4 同款);驳回意见可写 P-06「验证要求」行;验收 02 = submit 终局,件上摆落库预览(touched: 数据源)。 issueAcceptance 只能一次
D10 GOV 规则 = 包内两张 DMN(文档)+ 两枚纯工具(求值),rule_version = 'R-2026.07' 随包冻结。 quote-intake P-03 同款

1. 现有数据库架构与「把台账建进库」的含义

一库四 schema(db/init/001-schemas.sql):sys(系统自持,Drizzle 迁移 0001–0033)· ledger(台账自生长,唯一写者 apps/api/src/modules/ledger/internal/adapter/ledger-store.ts,动态 DDL)· mastra · queue。连接 postgres://jcai:jcai@localhost:54329/jcai_wos(容器 jcai-postgres,已在跑;本机没有 psql,一律 docker exec -i jcai-postgres psql -U jcai -d jcai_wos -Atc "<sql>")。

台账怎么物化(不写迁移,声明即建表):

声明 物理落点
{domain, object} 表 ledger.<域>__<对象>(tableNameOf),表注释 = label
fields[].type text / number / integer / boolean / date / timestamp / json text / double precision / bigint / boolean / date / timestamptz / jsonb;列注释 = label
primaryKey: true 各列 primary key (...);主键一经建立不得由声明变更
kind / caseKey ledger.__object_meta(domain, object, kind, case_key);reset-runs 只清 kind <> 'master' 的表
同名对象多份声明 schema-merge.ts 最大化合并、只增不破坏(add-column / 放宽类型);json 与标量互斥即拦
任务草稿 ledger.__draft(task_id, domain, object, key_hash, key, patch, baseline, tombstone, updated_at);taskView = 正式版 + 本单草稿;放行 releaseWrite 合并进正式表(试跑单 land_master=false 时 master 行只留缓存)

谁在什么时候调 applyDeclarations:installRealActivities()(apps/api/src/app/seed-activities.ts,pnpm activities:install 的真身)先物化 MASTER_OBJECTS(平台基线,不挂活动),再逐包 registry.install(manifest)(活动声明合并进同名表 + 建自持表 + 写 __object_meta),最后 systemWrite 播 MASTER_SAMPLE_ROWS(按主键幂等)。

现状(2026-08-26 实查):ledger 里 33 个对象;主数据四域 customer / project / supplier / market(market.part 是空占位的老料号表,主键 pn,与 P-08 的 internal_pn 口径不同,不复用,记 debt 待并);E04/I01 各自的 record 表 caseKey=round_ref;A1–A7 的表还在但已卸载。

本活动要建进库的(全部经上面这条路,零 SQL):

全景 物理表 kind 主键 由谁物化
P-08 ledger.parts__profile master internal_pn 基线 + 清单追加
P-10 ledger.parts__document master internal_pn, doc_seq 基线 + 清单追加
P-01 ledger.demand__request master request_id 基线 + 清单追加
P-11 主 ledger.substitution__relation master substitution_relation_id 基线 + 清单追加
P-11 子 ledger.substitution__difference_item master substitution_relation_id, difference_item_id 基线 + 清单追加(compare_result / unknown_disposition 两列只在清单)
P-06 ledger.validation__record master validation_id 基线 + 清单追加
自持 ledger.sub_eval__pn_check / __input_gap / __verdict record(caseKey eval_ref) 见 Task 3 清单

验收 SQL(Task 6 用):

select domain||'.'||object||' '||kind||' '||coalesce(case_key,'') from ledger.__object_meta
 where domain in ('parts','demand','substitution','validation','sub_eval') order by 1;
select string_agg(column_name||':'||data_type, ', ' order by ordinal_position)
  from information_schema.columns where table_schema='ledger' and table_name='parts__profile';
select conname||' '||pg_get_constraintdef(oid) from pg_constraint where conrelid='ledger.substitution__difference_item'::regclass;
select internal_pn, brand_name, lifecycle_status from ledger.parts__profile order by 1;

2. 文件结构

新建

  • activities/part-substitution-assessment/package.json · src/index.ts · src/manifest.ts(清单 + DMN)· src/case-model.ts(CMMN / NARRATIVE / STEPS / PHASES)· src/skills.ts · src/views/prd-style.ts(逐字节抄 I01)· src/views/report.ts(五张模版)· __tests__/activity.test.ts · __tests__/steps.test.ts
  • apps/api/src/app/__tests__/first-party-substitution.test.ts(两枚纯工具)
  • docs/ACT-WORK-05-端到端测试记录.md(Task 8 逐轮追加)· docs/数据域字段说明-W05.md

修改

  • apps/api/src/app/seed-mvp-data.ts(+4 域 6 表 + 样例料号)· apps/api/src/app/__tests__/seed-mvp-data.test.ts(四域 → 八域)
  • apps/api/src/app/tools/first-party.ts(+2 handler)· apps/api/src/app/seed-mvp.ts(+2 工具契约)
  • tools/build-activities.ts(PACKAGES)· apps/api/src/app/seed-activities.ts(REAL_PACKAGES)
  • tools/e2e-a2.mjs(+w05 套件、stageEdits、decide 值)
  • CLAUDE.md(现状快照 + 文档地图)· docs/debt.md

Task 0: 开分支、确认环境

Files: 无(只动 git 与环境)

  • Step 1: 从 mvp-foundation 开功能分支(工作树里已有的未提交改动跟着走,不碰它们)
cd /Users/xcs/project2/tmp/BPOS-MVP
git checkout -b feat/act-work-05
git status --short | wc -l   # 记下这个数:本计划的提交只 add 自己点名的文件
  • Step 2: 确认库与两套服务在跑
docker ps --format '{{.Names}} {{.Status}}' | grep jcai-postgres
lsof -nP -iTCP:8787 -iTCP:5173 -iTCP:8790 -iTCP:5180 -sTCP:LISTEN | awk '{print $1,$9}' | sort -u

Expected: jcai-postgres Up;8787(dev api)与 5173(dev web)在;8790/5180 是 ./stable.sh 起的稳定版(与 dev 连同一个库,推进器靠跨进程租约互斥 —— 跑 e2e / 浏览器走查时先停它,避免两台交替推同一单干扰观察:./stable.sh stop;走查完 ./stable.sh start)。

  • Step 3: 确认 e2e 凭证在环境里(值不打印)
for k in KEYCLOAK_URL KEYCLOAK_REALM KEYCLOAK_E2E_CLIENT_ID KEYCLOAK_E2E_CLIENT_SECRET; do printf '%s=%s\n' "$k" "${(P)k:+set}"; done

Expected: 四个都 set。缺了就从开发会话 / .stable/env 抄进当前 shell(.stable/env 只有 KEYCLOAK_URL/REALM/CLIENT_ID,E2E 那两个要问用户要;拿不到就停在这一步,e2e 跑不了)。


Task 1: 主数据基线 —— P-08 / P-10 / P-01 / P-11 / P-06 进平台声明,加样例料号

Files:

  • Modify: apps/api/src/app/seed-mvp-data.ts(MASTER_OBJECTS 末尾追加 6 表;MASTER_DOMAINS 8 域;MASTER_SAMPLE_ROWS 末尾追加 4 行)
  • Modify: apps/api/src/app/__tests__/seed-mvp-data.test.ts:18-19(四域 → 八域)
  • Test: apps/api/src/app/__tests__/seed-mvp-data.test.ts

Interfaces:

  • Produces: 物理表 ledger.parts__profile / parts__document / demand__request / substitution__relation / substitution__difference_item / validation__record(列名与类型见下);样例行 parts.profile × 2(LM2596S-5.0, LM2596S-ADJ)、parts.document × 2。Task 3 的清单只追加列,列名与类型必须与这里一字不差(同名异型会被 schema-merge 放宽或拦下)。

  • Step 1: 改测试 —— 八域

在 apps/api/src/app/__tests__/seed-mvp-data.test.ts 把 '只有客户 / 项目 / 供应商 / 市场四个域' 那条改成:

  it('八个域:客户 / 项目 / 供应商 / 市场 + 料件 / 需求 / 替代 / 验证(SS02 差异评估 2026-08-26 加后四域)', () => {
    expect([...MASTER_DOMAINS]).toEqual(['customer', 'project', 'supplier', 'market', 'parts', 'demand', 'substitution', 'validation']);
    expect([...new Set(MASTER_OBJECTS.map((o) => o.domain))].sort()).toEqual([...MASTER_DOMAINS].sort());
  });

  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', 'document')).toEqual(['internal_pn', 'doc_seq']);
    expect(pk('demand', 'request')).toEqual(['request_id']);
    expect(pk('substitution', 'relation')).toEqual(['substitution_relation_id']);
    expect(pk('substitution', 'difference_item')).toEqual(['substitution_relation_id', 'difference_item_id']);
    expect(pk('validation', 'record')).toEqual(['validation_id']);
  });

  it('样例料号:原 PN 与同族可调版在库,候选 XL2596S-5.0 刻意不播(数据工单那条路要试得出来)', () => {
    const parts = MASTER_SAMPLE_ROWS.filter((r) => r.address.domain === 'parts' && r.address.object === 'profile').map((r) => r.key['internal_pn']);
    expect(parts).toEqual(['LM2596S-5.0', 'LM2596S-ADJ']);
    expect(parts).not.toContain('XL2596S-5.0');
  });

文件头 import 改成 import { MASTER_DOMAINS, MASTER_OBJECTS, MASTER_SAMPLE_ROWS } from '../seed-mvp-data.ts';,并把文件头注释里「只有这四个域,不多不少」改为「八个域,多一个就说明有人把单据塞进主数据了」。

  • Step 2: 跑测试确认它红

Run: pnpm vitest run apps/api/src/app/__tests__/seed-mvp-data.test.ts Expected: FAIL(MASTER_DOMAINS 仍是四域;pk('parts','profile') 为 undefined)。

  • Step 3: 在 seed-mvp-data.ts 的 MASTER_OBJECTS 数组末尾(market.quote_index 之后、]; 之前)追加六张表
  /* ── 料件:P-08 芯片主数据 / P-10 原厂技术资料库(SS02 差异评估 2026-08-26 加)──
     字段名与《SS02-SS03-SS04_Demo台账字段细化与统一方案》§4.2 一字不差;全链路只认 internal_pn。
     参数集是 P-08 的一个字段(用户定:一张表,不建子表),一行一参数:
     `参数名 | 值 | 单位 | hard/soft | 说明`。老的 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('package', 'text', '封装'),
    f('process_technology', 'text', '工艺'),
    f('lifecycle_status', 'text', '生命周期状态'),
    f('eol_pcn_status', 'text', 'EOL/PCN 状态'),
    f('datasheet_ref', 'text', 'Datasheet 引用'),
    f('datasheet_version', 'text', 'Datasheet 版本'),
    f('application_scenarios', 'text', '适配应用场景'),
    f('pin_to_pin_relation_ref', 'text', 'Pin-to-Pin 关系引用'),
  ]),
  master('parts', 'document', '原厂技术资料库(P-10)', [
    f('internal_pn', 'text', '关联内部料号', true),
    f('doc_seq', 'text', '资料序号(同一料号下 01/02…)', true),
    f('document_type', 'text', '资料类型'),
    f('brand_name', 'text', '品牌名称'),
    f('document_title', 'text', '资料标题'),
    f('document_version', 'text', '资料版本'),
    f('document_date', 'date', '资料发布日期'),
    f('document_ref', 'text', '资料文件链接'),
    f('citation_locator', 'text', '证据定位(页码/章节/表格)'),
    f('source_contact_ref', 'text', '原厂支持联系人'),
    f('document_status', 'text', '资料状态'),
  ]),

  /* ── 需求:P-01 客户具体需求与项目任务主台账 ── */
  master('demand', 'request', '客户需求主记录(P-01)', [
    f('request_id', 'text', '需求编号', true),
    f('customer_code', 'text', '客户编码'),
    f('project_code', 'text', '项目编码'),
    f('requirement_version', 'text', '需求版本'),
    f('request_type', 'text', '需求类型'),
    f('request_description', 'text', '需求描述'),
    f('input_snapshot_ref', 'text', '输入快照'),
    f('bom_id', 'text', 'BOM ID'),
    f('bom_position', 'text', 'BOM 位'),
    f('original_internal_pn', 'text', '原内部料号'),
    f('customer_pn', 'text', '客户料号'),
    f('application_scenario', 'text', '应用场景'),
    f('technical_constraints', 'text', '技术约束'),
    f('risk_level', 'text', '风险等级'),
    f('verification_requirement', 'text', '验证/复测/确认要求'),
    f('request_status', 'text', '需求状态'),
    f('current_activity', 'text', '当前 Activity'),
    f('current_stage', 'text', '当前阶段'),
    f('next_action', 'text', '下一步动作'),
    f('output_ref', 'text', '标准输出链接'),
  ]),

  /* ── 替代:P-11 替代选型与批准关系库 —— 主记录 + 差异明细子表(《统一方案》§六:重复明细用子表)── */
  master('substitution', 'relation', '替代关系主记录(P-11)', [
    f('substitution_relation_id', 'text', '替代关系ID', true),
    f('request_id', 'text', '关联需求编号'),
    f('project_code', 'text', '关联项目编码'),
    f('bom_position', 'text', '关联 BOM 位'),
    f('original_internal_pn', 'text', '原内部料号'),
    f('candidate_internal_pn', 'text', '候选内部料号'),
    f('substitution_type', 'text', '替代类型'),
    f('recommendation_level', 'text', '推荐等级'),
    f('recommendation_reason', 'text', '推荐理由'),
    f('difference_summary', 'text', '差异摘要'),
    f('risk_level', 'text', '替代风险等级'),
    f('verification_requirement', 'text', '验证要求'),
    f('validation_id', 'text', '关联验证ID'),
    f('analysis_status', 'text', '分析状态'),
    /* 客户批准三列由独立审批流维护 —— 基线建列,差异评估活动**不声明写权** */
    f('customer_approval_status', 'text', '客户批准状态'),
    f('customer_approval_at', 'timestamp', '客户批准时间'),
    f('customer_approval_ref', 'text', '客户批准凭证'),
    f('customer_feedback', 'text', '客户反馈'),
    f('analysis_report_ref', 'text', '替代分析报告'),
    f('confidence_score', 'number', '证据可信度'),
    f('rule_version', 'text', '差异规则版本'),
  ]),
  master('substitution', 'difference_item', '差异明细(P-11 子表)', [
    f('substitution_relation_id', 'text', '替代关系ID', true),
    f('difference_item_id', 'text', '差异明细ID', true),
    f('parameter_name', 'text', '标准参数名'),
    f('original_value', 'text', '原物料参数值'),
    f('candidate_value', 'text', '候选物料参数值'),
    f('parameter_unit', 'text', '参数单位'),
    f('constraint_type', 'text', '约束类型'),
    f('difference_status', 'text', '差异状态'),
    f('impact_statement', 'text', '影响说明'),
    f('risk_level', 'text', '风险等级'),
    f('evidence_ref', 'text', '差异证据引用'),
    f('confidence', 'number', 'AI 置信度'),
    f('rule_version', 'text', '差异规则版本'),
  ]),

  /* ── 验证:P-06 样品交付与芯片验证记录(事实类:只能实测产生,数据工单不许建)── */
  master('validation', 'record', '芯片验证记录(P-06)', [
    f('validation_id', 'text', '验证ID', true),
    f('request_id', 'text', '关联需求编号'),
    f('customer_code', 'text', '客户编码'),
    f('validation_internal_pn', 'text', '验证内部料号'),
    f('validation_type', 'text', '验证类型'),
    f('test_condition', 'text', '测试条件'),
    f('test_parameters', 'text', '测试参数'),
    f('test_result', 'text', '测试结果'),
    f('validation_status', 'text', '验证状态'),
    f('issue_risk', 'text', '问题与风险'),
    f('sample_version', 'text', '样品版本'),
    f('sample_lot', 'text', '样品批次'),
    f('evidence_ref', 'text', '验证证据引用'),
    f('next_validation_action', 'text', '后续验证动作'),
  ]),

并把 MASTER_DOMAINS 改为:

/** 八域的呈现次序 —— 谁在买 → 为什么买 → 从哪买 → 买什么 → 料件 → 需求 → 替代 → 验证 */
export const MASTER_DOMAINS = ['customer', 'project', 'supplier', 'market', 'parts', 'demand', 'substitution', 'validation'] as const;
  • Step 4: 在 MASTER_SAMPLE_ROWS 数组末尾追加样例料号(现有最后一个元素之后、]; 之前)
  /*
   * ── SS02 差异评估的样例料号(2026-08-26)──
   * 只播**原 PN**与**同族可调版**两颗:「都在库」那条路靠它们;候选 XL2596S-5.0 与国产 DCDC
   * **刻意不播** —— 「候选缺库 → 派数据工单」「硬约束不推荐」两条路要试得出来,库里得真没有。
   * 参数集一行一参数(参数名 | 值 | 单位 | hard/soft | 说明),与 P-08 声明的行语法同一份。
   */
  {
    address: { domain: 'parts', object: 'profile' },
    key: { internal_pn: 'LM2596S-5.0' },
    values: {
      internal_pn: 'LM2596S-5.0',
      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',
      eol_pcn_status: 'PCN-2026-07:转 NRND,2027-01 起不接新设计',
      datasheet_ref: 'https://www.ti.com/lit/ds/symlink/lm2596.pdf',
      datasheet_version: 'Rev H (2023-09)',
      application_scenarios: '工业电源;消费电源',
    },
  },
  {
    address: { domain: 'parts', object: 'profile' },
    key: { internal_pn: 'LM2596S-ADJ' },
    values: {
      internal_pn: 'LM2596S-ADJ',
      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',
      datasheet_ref: 'https://www.ti.com/lit/ds/symlink/lm2596.pdf',
      datasheet_version: 'Rev H (2023-09)',
      application_scenarios: '工业电源;消费电源',
    },
  },
  {
    address: { domain: 'parts', object: 'document' },
    key: { internal_pn: 'LM2596S-5.0', doc_seq: '01' },
    values: {
      internal_pn: 'LM2596S-5.0',
      doc_seq: '01',
      document_type: 'datasheet',
      brand_name: 'TI',
      document_title: 'LM2596 SIMPLE SWITCHER Power Converter 150-kHz 3-A Step-Down Voltage Regulator',
      document_version: 'Rev H (2023-09)',
      document_ref: 'https://www.ti.com/lit/ds/symlink/lm2596.pdf',
      citation_locator: '§6.5 Electrical Characteristics 表 / §6.3 Recommended Operating Conditions',
      document_status: 'valid',
    },
  },
  {
    address: { domain: 'parts', object: 'document' },
    key: { internal_pn: 'LM2596S-ADJ', doc_seq: '01' },
    values: {
      internal_pn: 'LM2596S-ADJ',
      doc_seq: '01',
      document_type: 'datasheet',
      brand_name: 'TI',
      document_title: 'LM2596 SIMPLE SWITCHER Power Converter 150-kHz 3-A Step-Down Voltage Regulator',
      document_version: 'Rev H (2023-09)',
      document_ref: 'https://www.ti.com/lit/ds/symlink/lm2596.pdf',
      citation_locator: '§6.5 Electrical Characteristics 表(ADJ 列)',
      document_status: 'valid',
    },
  },

同时把 seed-activities.ts:143 那行日志改成不依赖下标(新行插在数组末尾,[1] 仍是样例客户,不改也不炸;但下一个人再往前插就错)——改为:

  log(`   样例主数据已就位:${MASTER_SAMPLE_ROWS.length} 行(客户 / 项目 / 联系人 / 料号)`);
  • Step 5: 跑测试确认绿

Run: pnpm vitest run apps/api/src/app/__tests__/seed-mvp-data.test.ts Expected: PASS(含新加 3 条)。

  • Step 6: 类型检查 + 提交
pnpm -s typecheck
git add apps/api/src/app/seed-mvp-data.ts apps/api/src/app/__tests__/seed-mvp-data.test.ts apps/api/src/app/seed-activities.ts
git commit -m "feat(w05): 主数据基线加料件/需求/替代/验证四域六表(P-08/P-10/P-01/P-11/P-06)与样例料号

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh"

Task 2: 两枚纯工具 —— 逐参数比对(compare-parameters)与推荐定级(recommend)

Files:

  • Modify: apps/api/src/app/tools/first-party.ts(在 FIRST_PARTY_TOOLS 之前加两段实现;数组末尾登记两条)
  • Create: apps/api/src/app/tools-w05.ts(两条工具契约 + 试运行样例)
  • Modify: apps/api/src/app/seed-mvp.ts(注册循环的数组加进 W05_TOOLS)
  • Test: apps/api/src/app/__tests__/first-party-substitution.test.ts(新建)

Interfaces:

  • Produces 工具 part-substitution-assessment.compare-parameters: 入参 { candidates: Array<{ candidateInternalPn: string; pairs: Array<{ parameterName: string; originalValue: string; candidateValue: string; unit?: string; constraintType: 'hard'|'soft'; requirement?: string; evidenceRef?: string }> }> } 出参 { ruleVersion: 'R-2026.07'; results: Array<{ candidateInternalPn; items: Array<{ parameterName; compareResult; differenceStatus: 'match'|'mismatch'|'unknown'|'conditional'; riskLevel: 'low'|'medium'|'high'; note }>; counts: { match; mismatch; unknown; conditional; hardMismatch; softMismatch; configurable } }> }

  • Produces 工具 part-substitution-assessment.recommend: 入参 { candidates: Array<{ candidateInternalPn: string; counts: { match; mismatch; unknown; conditional; hardMismatch; softMismatch; configurable } }> } 出参 { ruleVersion; results: Array<{ candidateInternalPn; recommendationLevel: 'recommended'|'conditional'|'alternate'|'not_recommended'; riskLevel; reason }> }

  • 出参键与台账列同名之处:differenceStatus → difference_status、riskLevel → risk_level、recommendationLevel → recommendation_level(模型照抄,不必改名)。

  • Step 1: 写测试

apps/api/src/app/__tests__/first-party-substitution.test.ts:

/**
 * ACT-WORK-05 差异评估的两枚纯工具 —— 「能算的不问 LLM」:同输入必同结论。
 * 用例逐条对应《ACT-WORK-05-活动流程与工单说明》§八的示例矩阵:
 * A XL2596S-5.0 条件推荐(使能阈值未披露 → UNKNOWN)/ B LM2596S-ADJ 备选(可调版靠外围配成 5V)/
 * C 国产 DCDC 不推荐(输入上限 32V < 客户 40V,硬约束 MISMATCH)。
 */
import { describe, expect, it } from 'vitest';
import { FIRST_PARTY_TOOLS } from '../tools/first-party.ts';

const handler = (name: string) => {
  const found = FIRST_PARTY_TOOLS.find(([n]) => n === name);
  if (found === undefined) throw new Error(`没有工具 ${name}`);
  return found[1];
};
/** ToolHandler 的入参形状见 `apps/api/src/modules/toolbox/internal/adapter/toolbox-store.ts`:纯工具不用连接目标 */
const call = async (name: string, input: Record<string, unknown>) => {
  const out = await handler(name)({ input, connection: {}, operation: undefined, idempotencyKey: undefined });
  if (!out.ok) throw new Error(`工具失败:${JSON.stringify(out)}`);
  return out.output as Record<string, unknown>;
};

type Item = { parameterName: string; compareResult: string; differenceStatus: string; riskLevel: string };
type Result = { candidateInternalPn: string; items: Item[]; counts: Record<string, number> };

const pair = (parameterName: string, originalValue: string, candidateValue: string, constraintType: 'hard' | 'soft', extra: Record<string, string> = {}) => ({
  parameterName,
  originalValue,
  candidateValue,
  constraintType,
  evidenceRef: 'P-08 · P-10 Rev H',
  ...extra,
});

describe('compare-parameters:逐参数四态', () => {
  it('候选温域更宽 → covers → MATCH 低风险;固定 vs 可调 → configurable → CONDITIONAL;未披露 → UNKNOWN', async () => {
    const out = await call('part-substitution-assessment.compare-parameters', {
      candidates: [
        {
          candidateInternalPn: 'XL2596S-5.0',
          pairs: [
            pair('工作温度', '-40~+85', '-40~+125', 'hard', { unit: '℃' }),
            pair('输出电压', '5.0', '5.0', 'hard', { unit: 'V' }),
            pair('使能阈值', '1.4', '未披露', 'hard', { unit: 'V' }),
            pair('静态电流', '5', '4.2', 'soft', { unit: 'mA' }),
          ],
        },
        {
          candidateInternalPn: 'LM2596S-ADJ',
          pairs: [pair('输出电压', '5.0', '1.23~37', 'hard', { unit: 'V' })],
        },
      ],
    });
    const [a, b] = out['results'] as Result[];
    expect(out['ruleVersion']).toBe('R-2026.07');
    const by = (r: Result, name: string) => r.items.find((i) => i.parameterName === name);
    expect(by(a, '工作温度')).toMatchObject({ compareResult: 'covers', differenceStatus: 'match', riskLevel: 'low' });
    expect(by(a, '输出电压')).toMatchObject({ compareResult: 'equal', differenceStatus: 'match' });
    expect(by(a, '使能阈值')).toMatchObject({ compareResult: 'unknown', differenceStatus: 'unknown', riskLevel: 'medium' });
    expect(by(a, '静态电流')).toMatchObject({ differenceStatus: 'conditional' }); // 软约束数值不同 → 条件
    expect(a.counts).toMatchObject({ match: 2, unknown: 1, conditional: 1, mismatch: 0, hardMismatch: 0 });
    expect(by(b, '输出电压')).toMatchObject({ compareResult: 'configurable', differenceStatus: 'conditional' });
    expect(b.counts['configurable']).toBe(1);
  });

  it('硬约束范围变窄 → MISMATCH 高风险;客户约束(requirement)不满足也是 MISMATCH', async () => {
    const out = await call('part-substitution-assessment.compare-parameters', {
      candidates: [
        {
          candidateInternalPn: 'CN-DCDC-32V',
          pairs: [
            pair('输入电压范围', '4.5~40', '4.5~32', 'hard', { unit: 'V', requirement: '>= 40' }),
            pair('封装', 'TO-263-5', 'TO-263-5', 'hard'),
          ],
        },
      ],
    });
    const [c] = out['results'] as Result[];
    expect(c.items[0]).toMatchObject({ compareResult: 'narrower', differenceStatus: 'mismatch', riskLevel: 'high' });
    expect(c.items[1]).toMatchObject({ compareResult: 'equal', differenceStatus: 'match' });
    expect(c.counts).toMatchObject({ mismatch: 1, hardMismatch: 1, match: 1 });
  });

  it('硬约束相等但没有证据引用 → 不判 MATCH,降为 CONDITIONAL(硬约束项未经证据支持不得判 MATCH)', async () => {
    const out = await call('part-substitution-assessment.compare-parameters', {
      candidates: [{ candidateInternalPn: 'X', pairs: [{ parameterName: '封装', originalValue: 'TO-263-5', candidateValue: 'TO-263-5', constraintType: 'hard' }] }],
    });
    const [x] = out['results'] as Result[];
    expect(x.items[0]).toMatchObject({ compareResult: 'equal', differenceStatus: 'conditional' });
  });

  it('数值带单位/括号注记也认得:「5.0(固定)」「3A」「150 kHz」;生命周期按活跃度比', async () => {
    const out = await call('part-substitution-assessment.compare-parameters', {
      candidates: [{ candidateInternalPn: 'X', pairs: [pair('输出电压', '5.0(固定)', '5.0 V', 'hard'), pair('输出电流', '3A', '3', 'hard'), pair('开关频率', '150 kHz', '150', 'hard'), pair('生命周期', 'nrnd', 'active', 'soft')] }],
    });
    const [x] = out['results'] as Result[];
    // 生命周期不是数值:候选比原料更活(nrnd → active)算 covers → MATCH,替 NRND 料本就是为了这个
    expect(x.items.map((i) => i.differenceStatus)).toEqual(['match', 'match', 'match', 'match']);
    expect(x.items[3]?.compareResult).toBe('covers');
  });
});

describe('recommend:推荐等级四档', () => {
  it('硬约束 MISMATCH → 不推荐;有 UNKNOWN → 条件推荐;只有 configurable → 备选;全 MATCH → 推荐', async () => {
    const out = await call('part-substitution-assessment.recommend', {
      candidates: [
        { candidateInternalPn: 'C', counts: { match: 5, mismatch: 1, unknown: 0, conditional: 0, hardMismatch: 1, softMismatch: 0, configurable: 0 } },
        { candidateInternalPn: 'A', counts: { match: 5, mismatch: 0, unknown: 1, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 } },
        { candidateInternalPn: 'B', counts: { match: 6, mismatch: 0, unknown: 0, conditional: 1, hardMismatch: 0, softMismatch: 0, configurable: 1 } },
        { candidateInternalPn: 'D', counts: { match: 7, mismatch: 0, unknown: 0, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 } },
      ],
    });
    const results = out['results'] as Array<{ candidateInternalPn: string; recommendationLevel: string; riskLevel: string }>;
    expect(results.map((r) => [r.candidateInternalPn, r.recommendationLevel, r.riskLevel])).toEqual([
      ['C', 'not_recommended', 'high'],
      ['A', 'conditional', 'medium'],
      ['B', 'alternate', 'medium'],
      ['D', 'recommended', 'low'],
    ]);
  });

  it('既有 UNKNOWN 又有 configurable:UNKNOWN 优先 → 条件推荐(验证闭环前不给备选)', async () => {
    const out = await call('part-substitution-assessment.recommend', {
      candidates: [{ candidateInternalPn: 'E', counts: { match: 1, mismatch: 0, unknown: 1, conditional: 1, hardMismatch: 0, softMismatch: 0, configurable: 1 } }],
    });
    expect((out['results'] as Array<{ recommendationLevel: string }>)[0]?.recommendationLevel).toBe('conditional');
  });
});
  • Step 2: 跑测试确认红

Run: pnpm vitest run apps/api/src/app/__tests__/first-party-substitution.test.ts Expected: FAIL(没有工具 part-substitution-assessment.compare-parameters)。

  • Step 3: 在 first-party.ts 的 export const FIRST_PARTY_TOOLS 之前加实现
/* ── ACT-WORK-05 差异评估:逐参数比对 —— 纯工具(活动私有作用域)─────────────────
 *
 * 「能算的不问 LLM」:同输入必同结论。规则与活动包 manifest 里的 D01/D02 两张 DMN 同一份;
 * 这里是求值器,那里是随包冻结的文档。`RULE_VERSION` 随结论落账(rule_version)。
 *
 * 比对语义(按硬/软约束):
 *   equal / covers(候选范围 ⊇ 原范围) → match,低风险;
 *   configurable(原是定值、候选是含它的可调范围)→ conditional(要改外围);
 *   narrower / differs / partial → 硬:mismatch 高风险;软:conditional 中风险;
 *   任一侧未披露 → unknown 中风险;
 *   硬约束 equal/covers 但没给 evidenceRef → conditional(硬约束未经证据支持不得判 MATCH);
 *   requirement(客户约束,如 ">= 40")候选不满足 → 一律 mismatch 高风险。
 */
export const SUBSTITUTION_RULE_VERSION = 'R-2026.07';

type Parsed = { kind: 'number'; value: number } | { kind: 'range'; lo: number; hi: number } | { kind: 'text'; value: string } | { kind: 'unknown' };
type CompareResult = 'equal' | 'covers' | 'configurable' | 'narrower' | 'partial' | 'differs' | 'unknown';
type DifferenceStatus = 'match' | 'mismatch' | 'unknown' | 'conditional';
type RiskLevel = 'low' | 'medium' | 'high';
type ParamPair = {
  parameterName: string;
  originalValue: string;
  candidateValue: string;
  unit?: string;
  constraintType: 'hard' | 'soft';
  requirement?: string;
  evidenceRef?: string;
};
type CompareCounts = { match: number; mismatch: number; unknown: number; conditional: number; hardMismatch: number; softMismatch: number; configurable: number };

const UNKNOWN_WORDS = /^(未披露|未知|未给出|unknown|n\/?a|tbd|待定|-|—|–)$/i;
const NUM = '[-+]?\\d+(?:\\.\\d+)?';
const RANGE_RE = new RegExp(`^(${NUM})\\s*(?:~|~|-|–|—|至|to)\\s*(${NUM})`, 'i');
const NUM_RE = new RegExp(`^(${NUM})`);

export function parseParamValue(raw: unknown): Parsed {
  const s = String(raw ?? '').trim().replace(/\s+/g, ' ');
  if (s === '' || UNKNOWN_WORDS.test(s)) return { kind: 'unknown' };
  const r = RANGE_RE.exec(s);
  if (r !== null) {
    const lo = Number(r[1]);
    const hi = Number(r[2]);
    if (Number.isFinite(lo) && Number.isFinite(hi)) return { kind: 'range', lo: Math.min(lo, hi), hi: Math.max(lo, hi) };
  }
  const n = NUM_RE.exec(s);
  if (n !== null && Number.isFinite(Number(n[1]))) return { kind: 'number', value: Number(n[1]) };
  return { kind: 'text', value: s.toLowerCase() };
}

function compareParsed(o: Parsed, c: Parsed): CompareResult {
  if (o.kind === 'unknown' || c.kind === 'unknown') return 'unknown';
  if (o.kind === 'range' && c.kind === 'range') {
    if (c.lo === o.lo && c.hi === o.hi) return 'equal';
    if (c.lo <= o.lo && c.hi >= o.hi) return 'covers';
    if (c.lo >= o.lo && c.hi <= o.hi) return 'narrower';
    return c.hi < o.lo || c.lo > o.hi ? 'differs' : 'partial';
  }
  if (o.kind === 'number' && c.kind === 'number') return Math.abs(o.value - c.value) <= 1e-9 ? 'equal' : 'differs';
  if (o.kind === 'number' && c.kind === 'range') return c.lo <= o.value && o.value <= c.hi ? 'configurable' : 'differs';
  if (o.kind === 'range' && c.kind === 'number') return o.lo <= c.value && c.value <= o.hi ? 'narrower' : 'differs';
  if (o.kind === 'text' && c.kind === 'text') return o.value === c.value ? 'equal' : 'differs';
  return 'differs';
}

/** 客户约束:">= 40" / "<= 85" / "-40~85"(候选必须盖住);解析不了当没给 */
function satisfiesRequirement(requirement: string | undefined, c: Parsed): boolean | undefined {
  if (requirement === undefined || requirement.trim() === '' || c.kind === 'unknown' || c.kind === 'text') return undefined;
  const s = requirement.trim();
  const ge = new RegExp(`^(?:>=|≥)\\s*(${NUM})$`).exec(s);
  const le = new RegExp(`^(?:<=|≤)\\s*(${NUM})$`).exec(s);
  const hi = c.kind === 'range' ? c.hi : c.value;
  const lo = c.kind === 'range' ? c.lo : c.value;
  if (ge !== null) return hi >= Number(ge[1]);
  if (le !== null) return lo <= Number(le[1]);
  const req = parseParamValue(s);
  if (req.kind === 'range') return lo <= req.lo && hi >= req.hi;
  if (req.kind === 'number') return lo <= req.value && hi >= req.value;
  return undefined;
}

/** 生命周期不是数值:候选比原料「更活」算 covers(替 NRND 料本就是为了这个),更差才是 differs */
const LIFECYCLE_ORDER: Record<string, number> = { active: 3, nrnd: 2, risk: 2, eol: 1, discontinued: 0 };
function compareLifecycle(o: string, c: string): CompareResult | undefined {
  const ro = LIFECYCLE_ORDER[o.trim().toLowerCase()];
  const rc = LIFECYCLE_ORDER[c.trim().toLowerCase()];
  if (ro === undefined || rc === undefined) return undefined;
  return rc === ro ? 'equal' : rc > ro ? 'covers' : 'differs';
}

function judgePair(p: ParamPair): { compareResult: CompareResult; differenceStatus: DifferenceStatus; riskLevel: RiskLevel; note: string } {
  const o = parseParamValue(p.originalValue);
  const c = parseParamValue(p.candidateValue);
  const hard = p.constraintType === 'hard';
  let compareResult = compareParsed(o, c);
  if (/生命周期|lifecycle/i.test(p.parameterName)) compareResult = compareLifecycle(p.originalValue, p.candidateValue) ?? compareResult;
  const req = satisfiesRequirement(p.requirement, c);
  if (req === false) {
    // 客户约束不满足:比对结果照实留(narrower/differs),结论一律 MISMATCH 高风险
    return { compareResult: compareResult === 'unknown' ? 'differs' : compareResult, differenceStatus: 'mismatch', riskLevel: 'high', note: `不满足客户约束 ${p.requirement ?? ''}` };
  }
  if (compareResult === 'unknown') return { compareResult, differenceStatus: 'unknown', riskLevel: 'medium', note: '一侧未披露:标 UNKNOWN,挂验证任务或补来源' };
  if (compareResult === 'equal' || compareResult === 'covers') {
    if (hard && (p.evidenceRef === undefined || p.evidenceRef.trim() === '')) {
      return { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '硬约束缺证据引用:未经证据支持不判 MATCH' };
    }
    return { compareResult, differenceStatus: 'match', riskLevel: 'low', note: compareResult === 'covers' ? '候选范围盖住原范围' : '一致' };
  }
  if (compareResult === 'configurable') return { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '候选可调,须改外围把它配成原值' };
  if (compareResult === 'partial') return { compareResult, differenceStatus: 'conditional', riskLevel: hard ? 'medium' : 'low', note: '范围部分重叠,须按应用边界确认' };
  compareResult = compareResult === 'narrower' ? 'narrower' : 'differs';
  return hard
    ? { compareResult, differenceStatus: 'mismatch', riskLevel: 'high', note: '硬约束不一致' }
    : { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '软约束不一致,须评估影响' };
}

const compareParameters: ToolHandler = async ({ input }) => {
  const candidates = Array.isArray(input['candidates']) ? (input['candidates'] as Array<{ candidateInternalPn: string; pairs: ParamPair[] }>) : [];
  const results = candidates.map((cand) => {
    const counts: CompareCounts = { match: 0, mismatch: 0, unknown: 0, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 };
    const items = (Array.isArray(cand.pairs) ? cand.pairs : []).map((p) => {
      const j = judgePair(p);
      counts[j.differenceStatus] += 1;
      if (j.differenceStatus === 'mismatch') counts[p.constraintType === 'hard' ? 'hardMismatch' : 'softMismatch'] += 1;
      if (j.compareResult === 'configurable') counts.configurable += 1;
      return { parameterName: p.parameterName, unit: p.unit ?? '', constraintType: p.constraintType, ...j };
    });
    return { candidateInternalPn: cand.candidateInternalPn, items, counts };
  });
  return { ok: true, output: { ruleVersion: SUBSTITUTION_RULE_VERSION, results } };
};

/**
 * 推荐定级(D02,FIRST):硬 MISMATCH → 不推荐;有 UNKNOWN → 条件推荐(等验证闭环);
 * 有 configurable(改外围配得成)→ 备选;其余 conditional → 条件推荐;全 MATCH → 推荐。与 manifest 里的 D02 表同一份规则。
 */
const recommend: ToolHandler = async ({ input }) => {
  const candidates = Array.isArray(input['candidates']) ? (input['candidates'] as Array<{ candidateInternalPn: string; counts: Partial<CompareCounts> }>) : [];
  const results = candidates.map((cand) => {
    const n = (k: keyof CompareCounts) => Number(cand.counts?.[k] ?? 0);
    if (n('hardMismatch') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'not_recommended', riskLevel: 'high', reason: `硬约束 MISMATCH ${n('hardMismatch')} 项:硬约束失败直接不推荐` };
    if (n('unknown') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'conditional', riskLevel: 'medium', reason: `UNKNOWN ${n('unknown')} 项待验证闭环,通过后可升级推荐` };
    if (n('configurable') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'alternate', riskLevel: 'medium', reason: `${n('configurable')} 项须改外围配成原值(另有 ${n('conditional') - n('configurable')} 项条件差异):客户接受改设计则启用` };
    if (n('conditional') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'conditional', riskLevel: 'medium', reason: `CONDITIONAL ${n('conditional')} 项须按应用边界确认` };
    return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'recommended', riskLevel: 'low', reason: `${n('match')} 项全部 MATCH` };
  });
  return { ok: true, output: { ruleVersion: SUBSTITUTION_RULE_VERSION, results } };
};

在 FIRST_PARTY_TOOLS 数组末尾登记:

  ['part-substitution-assessment.compare-parameters', compareParameters],
  ['part-substitution-assessment.recommend', recommend],
  • Step 4: 跑测试确认绿

Run: pnpm vitest run apps/api/src/app/__tests__/first-party-substitution.test.ts Expected: PASS 6 条。

  • Step 5: 工具契约放独立文件 apps/api/src/app/tools-w05.ts(seed-mvp 与 seed-activities 都要用,放 seed-mvp 里会撞循环 import)
/**
 * ACT-WORK-05 差异评估的两枚纯工具契约(活动私有作用域)。
 * 两处消费:`seed-mvp.ts`(db:seed 全套播种)与 `seed-activities.ts`(activities:install 装活动时顺带注册 ——
 * 「安装即用」包含它声明过的纯工具能调得动;从前 E04 的工具只在 db:seed 里注册,开发机上只跑
 * activities:install 的人第一次跑活动就撞「工具不存在」)。
 */
const PURE = {
  effect: 'read' as const,
  errors: [] as Array<{ code: string; retryable: boolean; message: string }>,
  idempotent: true,
  timeoutMs: 5_000,
  retries: 0,
  connectionKeys: [] as string[],
  pure: true,
  ownerActivityId: 'part-substitution-assessment',
};

/** 逐参数比对 —— 出参键与台账列同名(difference_status / risk_level),模型照抄 */
export const COMPARE_PARAMETERS_TOOL = {
  ...PURE,
  name: 'part-substitution-assessment.compare-parameters',
  displayName: '替代料逐参数比对',
  version: '1.0.0',
  description:
    '按硬/软约束逐参数比对原 PN 与候选 PN,输出四态 difference_status(match/mismatch/unknown/conditional)、' +
    'compare_result、risk_level 与各候选的计数。一次可比一批候选(candidates 数组),每对参数可带客户约束 requirement(如 ">= 40")与证据引用 evidenceRef;' +
    '硬约束没有证据引用不判 match。规则版本 R-2026.07。',
  input: {
    type: 'object',
    properties: {
      candidates: {
        type: 'array',
        description: '[{candidateInternalPn, pairs:[{parameterName, originalValue, candidateValue, unit?, constraintType:"hard"|"soft", requirement?, evidenceRef?}]}]',
      },
    },
  },
  output: {
    type: 'object',
    properties: {
      ruleVersion: { type: 'string' },
      results: {
        type: 'array',
        description: '[{candidateInternalPn, items:[{parameterName, unit, constraintType, compareResult, differenceStatus, riskLevel, note}], counts:{match,mismatch,unknown,conditional,hardMismatch,softMismatch,configurable}}]',
      },
    },
  },
};

/** 推荐定级 —— 出参 recommendationLevel / riskLevel / reason 直接落 P-11 */
export const RECOMMEND_TOOL = {
  ...PURE,
  name: 'part-substitution-assessment.recommend',
  displayName: '替代推荐定级',
  version: '1.0.0',
  description:
    '按每个候选的比对计数定推荐等级:硬约束 mismatch>0 → not_recommended;unknown>0 → conditional;' +
    '只有可调配成(configurable)→ alternate;其余 conditional → conditional;全 match → recommended。',
  input: {
    type: 'object',
    properties: {
      candidates: { type: 'array', description: '[{candidateInternalPn, counts:{match,mismatch,unknown,conditional,hardMismatch,softMismatch,configurable}}]' },
    },
  },
  output: {
    type: 'object',
    properties: {
      ruleVersion: { type: 'string' },
      results: { type: 'array', description: '[{candidateInternalPn, recommendationLevel, riskLevel, reason}]' },
    },
  },
};

export const W05_TOOLS = [COMPARE_PARAMETERS_TOOL, RECOMMEND_TOOL] as const;

/** 试运行审的样例(两件共用一份:并集覆盖各家入参) */
export const W05_TOOL_SAMPLE = {
  input: {
    candidates: [
      {
        candidateInternalPn: 'XL2596S-5.0',
        pairs: [{ parameterName: '输出电压', originalValue: '5.0', candidateValue: '5.0', unit: 'V', constraintType: 'hard', evidenceRef: 'P-08' }],
        counts: { match: 1, mismatch: 0, unknown: 0, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 },
      },
    ],
  },
  expectShape: { type: 'object' },
};

然后在 seed-mvp.ts:顶部加 import { W05_TOOLS } from './tools-w05.ts';,把 for (const contract of [HISTORY_TOOL, MAIL_TOOL, CLASSIFY_TOOL, PLACEHOLDER_TOOL, COVERAGE_SCORE_TOOL, RANK_KEY_PERSONS_TOOL]) 改为 for (const contract of [HISTORY_TOOL, MAIL_TOOL, CLASSIFY_TOOL, PLACEHOLDER_TOOL, COVERAGE_SCORE_TOOL, RANK_KEY_PERSONS_TOOL, ...W05_TOOLS])(那条「读类 1 · 写类 1 · 纯工具 4」的日志改成「纯工具 6」)。

  • Step 6: 门禁子集 + 提交

Run: pnpm -s typecheck && pnpm -s lint && pnpm vitest run apps/api/src/app/__tests__ Expected: 全绿。

git add apps/api/src/app/tools/first-party.ts apps/api/src/app/seed-mvp.ts apps/api/src/app/tools-w05.ts apps/api/src/app/__tests__/first-party-substitution.test.ts
git commit -m "feat(w05): 差异评估两枚纯工具 —— 逐参数四态比对与推荐定级(R-2026.07)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh"

Task 3: 活动包骨架 + 清单(manifest.ts)+ 结构自测

Files:

  • Create: activities/part-substitution-assessment/package.json · src/index.ts · src/constants.ts · src/skills.ts · src/views/prd-style.ts · src/manifest.ts
  • Create: activities/part-substitution-assessment/__tests__/activity.test.ts
  • 依赖(本任务先以占位存在,Task 4/5 替换):src/case-model.ts、src/views/report.ts

Interfaces:

  • Produces:manifest(ActivityManifest)、常量 RULE_VERSION = 'R-2026.07'、D = 'sub_eval';台账地址与列名(Task 4 剧本 / Task 5 模版 / Task 7 e2e 全部照这里抄):

    • customer.profile(customer_code PK;name required;industry/region expect)
    • parts.profile(internal_pn PK;brand_name★ specification_description★ normalized_parameter_set★ package★ lifecycle_status★ tally active/nrnd/eol/discontinued/risk;manufacturer_name / process_technology / eol_pcn_status / datasheet_ref / datasheet_version / application_scenarios expect)
    • parts.document(internal_pn + doc_seq PK;document_type★ tally datasheet/app_note/reference_design/white_paper/sample_doc/commitment_letter/other;document_title★ document_version★ document_ref★ document_status★ tally valid/expired/pending/void;citation_locator / brand_name expect)
    • demand.request(request_id PK;customer_code★ refersTo customer.profile.customer_code;requirement_version★ request_type★ request_description★ input_snapshot_ref★ original_internal_pn★ request_status★ tally pending/responded/won/abandoned/closed;application_scenario / bom_position expect;risk_level tally low/medium/high/critical、verification_requirement、output_ref、next_action、current_activity 均 requiredWhen {current_stage:'gate03'};current_stage 自由文本)
    • substitution.relation(substitution_relation_id PK;request_id★;original_internal_pn★ / candidate_internal_pn★ 均 refersTo parts.profile.internal_pn;substitution_type★ tally pin_to_pin/functional/parametric/other;recommendation_level★ tally recommended/conditional/alternate/not_recommended;recommendation_reason★ difference_summary★;risk_level★ tally low/medium/high/critical;verification_requirement requiredWhen {recommendation_level:'conditional'};validation_id;analysis_status★ tally ai_draft/fae_confirmed/pending_customer/customer_approved/rejected/void;bom_position / analysis_report_ref / confidence_score expect;rule_version★;eval_ref★)
    • substitution.difference_item(substitution_relation_id PK distinct refersTo substitution.relation.substitution_relation_id;difference_item_id PK;parameter_name★ original_value★ candidate_value★;parameter_unit expect;constraint_type★ tally hard/soft;compare_result expect;difference_status★ tally match/mismatch/unknown/conditional;impact_statement★;unknown_disposition requiredWhen {difference_status:'unknown'};risk_level★ tally low/medium/high;evidence_ref★;confidence expect;rule_version★)
    • validation.record(validation_id PK;request_id★;validation_internal_pn★ refersTo parts.profile.internal_pn;validation_type★ tally pin_to_pin/functional/reliability/certification/retest;validation_status★ tally not_started/in_progress/passed/failed/conditional/pending_evidence;next_validation_action★;test_condition / evidence_ref expect)
    • sub_eval.pn_check(record,caseKey eval_ref;eval_ref + internal_pn PK;role★ tally original/candidate;exists_status★ tally found/absent/created/abandoned;abandon_reason requiredWhen {exists_status:'abandoned'};note)
    • sub_eval.input_gap(record;eval_ref + item_key PK;input_item★;source_ledger expect;status★ tally gap/default/ok;detail expect;supplement requiredWhen {status:'gap'};origin)
    • sub_eval.verdict(record;eval_ref PK;request_id、rule_version、original_internal_pn、candidate_total integer、input_confirmed_total、review_approved_total、landing_prepared_total、relation_total、recommended_total、conditional_total、alternate_total、not_recommended_total、summary)
  • Produces 出口 assessment-released,载荷 { evalRef, requestId, relationTotal, recommendedTotal, conditionalTotal, alternateTotal, notRecommendedTotal }(全部由 sub_eval.verdict 同名列承载)。

  • Produces 视图名:p08-master-create / p10-document-create / eval-input-list / difference-review / landing-confirm(Task 5 必须导出同名常量 P08_MASTER_CREATE / P10_DOCUMENT_CREATE / EVAL_INPUT_LIST / DIFFERENCE_REVIEW / LANDING_CONFIRM)。

  • Produces 计划项 id(Task 4 的 CMMN / STEPS 必须一字不差):S0_bind S0d_master_data S1_input_confirm S2_normalize S3_assess S4_review S5_prepare_landing T_release。

  • Step 1: 建包骨架

mkdir -p activities/part-substitution-assessment/src/views activities/part-substitution-assessment/__tests__
cat > activities/part-substitution-assessment/package.json <<'EOF'
{ "name": "@activities/part-substitution-assessment", "version": "0.1.0", "private": true, "type": "module", "exports": { ".": "./src/index.ts" } }
EOF
cat > activities/part-substitution-assessment/src/index.ts <<'EOF'
/**
 * ACT-WORK-05 · 差异评估 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
 *
 * 这个活动没有 `run`。案例活动的推进由 Loop 驱动,行为全部写在清单里:
 * 案例模型 + 说明书 + 步骤剧本 + 三条技能 + 台账声明 + 五张 HTML 报告模版。
 * 这个文件之所以还存在,是因为清单的 `entry` 指着它 ——
 * 它是**被静态审扫描的产物文本**(查 import 与全局引用,证明活动包零环境能力)。
 */
import { defineActivity } from '@sdk';

export default defineActivity({ name: 'part-substitution-assessment' });
EOF
# 共享常量单独一个文件:manifest.ts 与 case-model.ts 互相引用会撞 TDZ(E04 的 case-model 不 import manifest,这里两边都要用同一批常量)
cat > activities/part-substitution-assessment/src/constants.ts <<'EOF'
/** 清单与案例模型共用的常量 —— 单独成文件,免得 manifest ↔ case-model 循环 import 撞 TDZ。 */
export const ACTIVITY_ID_TEXT = 'part-substitution-assessment';
/** 活动自持单据的域名 */
export const D = 'sub_eval';
/** GOV 差异规则版本 —— 随包冻结,随每条结论落账(rule_version) */
export const RULE_VERSION = 'R-2026.07';
export const TOOL_COMPARE = 'part-substitution-assessment.compare-parameters';
export const TOOL_RECOMMEND = 'part-substitution-assessment.recommend';
EOF
# 视觉底逐字节抄 I01(两包之间没有代码通道,只能各持一份),只换头注
cp activities/brand-content-toolkit/src/views/prd-style.ts activities/part-substitution-assessment/src/views/prd-style.ts
python3 - <<'PY'
p='activities/part-substitution-assessment/src/views/prd-style.ts'
s=open(p,encoding='utf-8').read()
head_end=s.index('export const PRD_CSS')
new_head='''/**
 * ACT-WORK-05 差异评估 · 视觉底 —— 逐字节抄自 I01(`brand-content-toolkit/src/views/prd-style.ts`)。
 *
 * 本活动的四份目标件(`docs/SS02-ACT-WORK-05-html/*.html`)与 E04 / I01 的目标件是同一套
 * 设计语言产出的(卡壳 / 卡头渐变 / 判据框 / 表格排版全同);`activities/*` 只准 import
 * `@sdk` / `@contracts`,包之间没有代码通道,只能各自持有一份。改这份不要改口径:
 * 数据驱动的开关(三档占位 / 缺项红带 / 状态徽 / 编辑闸)在 `report.ts` 的 `BIND_CSS`。
 */

'''
open(p,'w',encoding='utf-8').write(new_head+s[head_end:])
PY
  • Step 2: 写技能 src/skills.ts
/**
 * ACT-WORK-05 差异评估的技能 —— 载方法,不载权力。
 * 剧本写「这一步做什么、写哪几列」,技能写「为什么这么判、拿不准时按哪条走」。
 */
import { skillIdSchema } from '@contracts';
import type { Skill } from '@contracts';

export const ASSESSMENT_SKILLS: Skill[] = [
  {
    id: skillIdSchema.parse('substitution.normalize-parameters'),
    label: '把多来源资料整成可比对的标准参数对照表',
    applicability: '输入确认之后、逐项比对之前:原 PN 与每个候选的参数集要摆成同名同单位的一对一。',
    method: [
      '参数集在 `parts.profile.normalized_parameter_set` 里,一行一参数:`参数名 | 值 | 单位 | hard/soft | 说明`。',
      '对照表按**原 PN 的参数名**为骨架:原 PN 有的参数,候选没有对应行 → 候选值写「未披露」(它会成为 UNKNOWN);',
      '候选多出来的参数不比(比对只回答「能不能替」,多出来的能力不构成差异)。',
      '单位以原 PN 为准:候选单位不同先换算(mV→V、kHz→MHz),换算不了就不改值、在说明里写明 —— 不许静默改数。',
      '约束类型以原 PN 那一行的 hard/soft 为准;原 PN 没标的按 GOV 口径:封装 / 工艺 / 电气边界(电压 / 电流 / 温度 / 阈值)一律 hard,其余 soft。',
      '**缺参数不得伪造**:查不到的写「未披露」,不写估计值、不写「约」。',
    ].join('\n'),
    crystallized: [],
    knowledgeRefs: ['标准化参数集行语法', '硬/软约束口径'],
    version: '1.0.0',
  },
  {
    id: skillIdSchema.parse('substitution.difference-judgement'),
    label: '逐项四态由工具算,证据与影响由你写',
    applicability: '对照表齐了,要出 MATCH / MISMATCH / UNKNOWN / CONDITIONAL 时。',
    method: [
      '四态**不许自己判**:调 `part-substitution-assessment.compare-parameters`,把每个候选的全部参数对一次性传进去(candidates 数组,一次一批)。',
      '每对参数都带 `evidenceRef`(值从哪份记录来:「P-08」「P-10 Rev H §6.5」「数据工单 <单号> · 确认人 <人>」)——硬约束没有证据引用,工具不判 MATCH。',
      '客户在应用场景里给了边界(如「输入上限 40V」)就写进那一对的 `requirement`(`>= 40`),候选不满足直接 MISMATCH。',
      '工具出参照抄进 `substitution.difference_item`:difference_status / compare_result / risk_level 一字不改;',
      '`impact_statement` 你写:MATCH 写「无」,其余说清对应用 / 验证 / 报价的影响;UNKNOWN 必须写 `unknown_disposition`(挂验证任务号或补来源动作),不许悬空。',
      '推荐等级同样由工具定(`part-substitution-assessment.recommend`,传每个候选的 counts),`recommendation_reason` 照工具的 reason 扩写,不许把不推荐改成推荐。',
    ].join('\n'),
    crystallized: [],
    knowledgeRefs: ['四态口径', '推荐等级四档'],
    version: '1.0.0',
  },
  {
    id: skillIdSchema.parse('substitution.landing'),
    label: '落账只写分析层,客户批准不碰',
    applicability: '审核通过之后拟 P-11 / 回写 P-01 时。',
    method: [
      '`analysis_status` 从 `ai_draft` 推到 `fae_confirmed`;`customer_approval_status` 由独立审批流维护 —— 本活动**没有这一列的写权**,写了会被拒。',
      'P-01 回写五项:risk_level(取各候选里最高的)、verification_requirement(把所有验证任务号列全)、output_ref(替代分析报告号)、next_action(「提交 ACT-GATE-03 评审放行」)、current_stage 写 `gate03`。',
      '规则版本随结论落账:每条 P-11 主记录与差异明细都写 rule_version(工具出参的 ruleVersion),中途规则更新不影响进行中的评估。',
      '被打回重做时改的是**台账本身**(理由 / 依据 / 去向),不是把同一份原样再递一遍。',
    ].join('\n'),
    crystallized: [],
    knowledgeRefs: ['analysis_status 与 customer_approval_status 分离'],
    version: '1.0.0',
  },
];
  • Step 3: 写清单 src/manifest.ts
/**
 * 活动包清单 —— ACT-WORK-05 差异评估(案例活动)。
 *
 * 业务出处:`docs/SS02-ACT-WORK-05-html/`(活动流程与工单说明 / 一键启动入参说明 /
 * 《SS02-SS03-SS04_Demo台账字段细化与统一方案》/ 四份目标件 / 全流程分支图)。
 * 骨架照 E04 / I01 已验证的样子:双级入参 → 活动自持单据 + 共享主数据 → decide 循环做中间审核 →
 * 终局放行。三处与前两个活动刻意不同,写在这里免得下一个读者当成疏漏:
 *   ① **主数据是六张(P-08/P-10/P-01/P-11 主+子/P-06)**,物理表由平台基线
 *      (`apps/api/src/app/seed-mvp-data.ts`)物化,这里只**追加**本活动要用的列与标记
 *      (required / tally / refersTo / requiredWhen 是活动的授权面;声明合并是纯 add-column)。
 *   ② **数据工单**(PN 不在库时让人从零建整条主数据)= 零输入执行工单 + 工作区的件
 *      (`mine:parts.profile` / `mine:parts.document` 可加行),确认落草稿层、放行并入正式表
 *      (守铁律②;文档里「确认即入库」在本平台落成「无审核环节、放行并入」)。一张表一张单。
 *   ③ 逐项四态与推荐等级**由两枚纯工具算**(规则同时以 D01/D02 两张 DMN 随包冻结当文档),
 *      模型只负责对照表、证据与影响说明 —— 硬约束 MISMATCH 不可能被措辞改判。
 */
import type { ActivityManifest, DmnModel } from '@contracts';
import { activityIdSchema } from '@contracts';
import { ASSESSMENT_SKILLS } from './skills.ts';
import { CMMN_XML, NARRATIVE, ASSESSMENT_PHASES, ASSESSMENT_STEPS } from './case-model.ts';
import { P08_MASTER_CREATE, P10_DOCUMENT_CREATE, EVAL_INPUT_LIST, DIFFERENCE_REVIEW, LANDING_CONFIRM } from './views/report.ts';
import { ACTIVITY_ID_TEXT, D, RULE_VERSION, TOOL_COMPARE, TOOL_RECOMMEND } from './constants.ts';

export { D, RULE_VERSION, TOOL_COMPARE, TOOL_RECOMMEND } from './constants.ts';
const ACTIVITY_ID = activityIdSchema.parse(ACTIVITY_ID_TEXT);

/**
 * 两张判定表 —— **文档形态**(求值器在 `apps/api/src/app/tools/first-party.ts`,与这里同一份规则):
 * D01 逐参数四态、D02 推荐等级。都是 FIRST:规则从上往下第一条命中即出结论,顺序就是优先级。
 */
export const D01_DMN: DmnModel['tables'][number] = {
  id: 'd01-difference-status',
  label: '差异四态 D01(R-2026.07)',
  hitPolicy: 'FIRST',
  inputs: [
    { name: 'compare_result', expression: 'compare_result' },
    { name: 'constraint_type', expression: 'constraint_type' },
    { name: 'evidence_present', expression: 'evidence_present' },
  ],
  outputs: [{ name: 'difference_status' }, { name: 'risk_level' }],
  rules: [
    { id: 'd01-u', when: ['"unknown"', '-', '-'], then: ['unknown', 'medium'], annotation: '一侧未披露 → UNKNOWN,挂验证任务' },
    { id: 'd01-m0', when: ['"equal","covers"', '"hard"', 'false'], then: ['conditional', 'medium'], annotation: '硬约束缺证据不判 MATCH' },
    { id: 'd01-m', when: ['"equal","covers"', '-', '-'], then: ['match', 'low'], annotation: '一致或候选范围盖住原范围' },
    { id: 'd01-c', when: ['"configurable","partial"', '-', '-'], then: ['conditional', 'medium'], annotation: '可调配成 / 部分重叠 → 条件' },
    { id: 'd01-x', when: ['"narrower","differs"', '"hard"', '-'], then: ['mismatch', 'high'], annotation: '硬约束不一致' },
    { id: 'd01-s', when: ['"narrower","differs"', '"soft"', '-'], then: ['conditional', 'medium'], annotation: '软约束不一致 → 评估影响' },
  ],
};

export const D02_DMN: DmnModel['tables'][number] = {
  id: 'd02-recommendation',
  label: '推荐等级 D02(R-2026.07)',
  hitPolicy: 'FIRST',
  inputs: [
    { name: 'hard_mismatch_total', expression: 'hard_mismatch_total' },
    { name: 'unknown_total', expression: 'unknown_total' },
    { name: 'configurable_total', expression: 'configurable_total' },
    { name: 'conditional_total', expression: 'conditional_total' },
  ],
  outputs: [{ name: 'recommendation_level' }],
  rules: [
    { id: 'd02-n', when: ['> 0', '-', '-', '-'], then: ['not_recommended'], annotation: '硬约束失败直接不推荐' },
    { id: 'd02-u', when: ['0', '> 0', '-', '-'], then: ['conditional'], annotation: '有 UNKNOWN → 等验证闭环' },
    { id: 'd02-a', when: ['0', '0', '> 0', '-'], then: ['alternate'], annotation: '只靠改外围配得成 → 备选' },
    { id: 'd02-c', when: ['0', '0', '0', '> 0'], then: ['conditional'], annotation: '其余条件项 → 条件推荐' },
    { id: 'd02-r', when: ['0', '0', '0', '0'], then: ['recommended'], annotation: '全 MATCH → 推荐' },
  ],
};

export const ASSESSMENT_DMN: DmnModel = {
  drd: [
    { decision: 'd01-difference-status', requires: [], inputs: ['compare_result', 'constraint_type', 'evidence_present'] },
    { decision: 'd02-recommendation', requires: [], inputs: ['hard_mismatch_total', 'unknown_total', 'configurable_total', 'conditional_total'] },
  ],
  tables: [D01_DMN, D02_DMN],
};

/** 字段声明的短写(与 E04 同一套写法:显式 primaryKey / masked,其余按需) */
type F = { name: string; type: 'text' | 'number' | 'integer' | 'boolean' | 'date' | 'timestamp' | 'json'; label: string; primaryKey?: boolean; required?: boolean; expect?: boolean; tally?: string[]; auto?: boolean; distinct?: boolean; refersTo?: string; requiredWhen?: { column: string; value: string } };
const col = (f: F) => ({ primaryKey: false, masked: false, ...f });

export const manifest: ActivityManifest = {
  identity: {
    activityId: ACTIVITY_ID,
    name: 'ACT-WORK-05 · 差异评估',
    packageVersion: '1.0.0',
    category: '需求分析与方案匹配',
    sdkVersion: '0.1.0-m5',
    checksum: 'dev-checksum-part-substitution-assessment',
  },

  /* ── 契约七件 ────────────────────────────────────────────────── */
  contract: {
    activityId: ACTIVITY_ID,
    version: '1.0.0',
    input: {
      /*
       * 一键启动只收 4 项(文档《一键启动入参说明》极简原则):客户 / 客户需求 / 原 PN / 候选 PN 数组。
       * 应用场景、BOM 位、替代类型、规则版本、负责人期限**不是入参** —— 启动后在执行工单 01 里补。
       * datasheet 选填文件:传了 AI 在数据工单件上预填,没传人全填(文档 0c:同为合法路径)。
       * `customerName` 只在客户不在库(分支 0a)时用:S0 发数据录入卡建档,名字先摆上去让人核。
       */
      data: {
        type: 'object',
        required: ['customerCode', 'requestDescription', 'originalInternalPn', 'candidateInternalPns'],
        properties: {
          customerCode: { type: 'string', title: '客户编码(L-02 客户主台账)', examples: ['CU-0142'] },
          customerName: { type: 'string', title: '客户名称(仅客户不在库时填,启动后先建档)', examples: ['深圳恒星电源有限公司'] },
          requestDescription: {
            type: 'string',
            title: '客户需求(原始描述 —— 据此新建 P-01 需求主记录 · 版本 V1)',
            examples: ['工业电源项目在用 LM2596S-5.0(TI,12V→5V),原厂 PCN 转 NRND,客户要求验证替代料'],
          },
          originalInternalPn: { type: 'string', title: '原内部料号(P-08)', examples: ['LM2596S-5.0'] },
          candidateInternalPns: {
            type: 'array',
            title: '候选内部料号(数组,≥1;不在库的启动后经数据工单创建)',
            items: { type: 'string' },
            examples: [['XL2596S-5.0', 'LM2596S-ADJ']],
          },
          datasheets: {
            type: 'array',
            format: 'file',
            title: '候选 datasheet(选填;传了 AI 在数据工单里预填,没传人全填)',
            items: {
              type: 'object',
              properties: {
                name: { type: 'string', title: '文件名' },
                contentType: { type: 'string', title: '类型' },
                size: { type: 'integer', title: '字节数' },
                ref: { type: 'string', title: '存档号' },
                text: { type: 'string', title: '读出来的文本' },
                note: { type: 'string', title: '读取说明' },
              },
            },
          },
        },
      },
      messageAllowed: true,
      messageHint: 'FAE 可在此说明客户的应用边界、验证条件或本轮评估的特殊要求',
    },
    output: {
      /* 单一必发口:本活动终点是 ACT-GATE-03 评审放行;下游 ACT-WORK-08 尚未实现,按「契约先立」声明 */
      mandatory: [
        {
          name: 'assessment-released',
          description: '差异评估结论已落账(P-11 替代关系主记录 + 差异明细、P-01 回写)—— 供 ACT-WORK-08 证据归集消费;当前无消费者,载荷 = 已落地事实',
          payload: {
            type: 'object',
            required: ['evalRef', 'requestId', 'relationTotal'],
            properties: {
              evalRef: { type: 'string', title: '评估案号' },
              requestId: { type: 'string', title: '需求编号(P-01)' },
              relationTotal: { type: 'integer', title: '落账的替代关系条数' },
              recommendedTotal: { type: 'integer', title: '推荐' },
              conditionalTotal: { type: 'integer', title: '条件推荐' },
              alternateTotal: { type: 'integer', title: '备选' },
              notRecommendedTotal: { type: 'integer', title: '不推荐' },
            },
          },
        },
      ],
      selective: [],
    },
    requiredDeliverables: [
      { key: 'eval-input', label: '评估输入清单', ledger: `${D}.input_gap`, ledgers: [`${D}.input_gap`, `${D}.pn_check`, `${D}.verdict`], editableAtRelease: [] },
      { key: 'difference-items', label: '差异评估结论(P-11 差异明细)', ledger: 'substitution.difference_item', ledgers: ['substitution.difference_item', 'substitution.relation'], editableAtRelease: [] },
      { key: 'landing', label: '拟落账内容(P-11 主记录 + P-01 回写)', ledger: 'substitution.relation', ledgers: ['substitution.relation', 'demand.request'], editableAtRelease: [] },
    ],
    /*
     * 验收判据 —— L1/L2 只读基座读数;L4 三条是文档验收工单 01/02 里留给人的裁量;
     * 「推荐等级与明细一致」「硬约束不判 MATCH」机器已由工具守住,这里仍留 L4 让签字的人过目。
     */
    acceptanceCriteria: [
      {
        key: 'input-confirmed',
        label: '评估输入已经 PW 补齐确认:执行工单 01 已交回,缺口行的补充内容都有,补不了的留了 UNKNOWN',
        level: 'L1',
        predicate: `input_confirmed_total > 0 and ${D}_input_gap_rows > 0 and ${D}_input_gap_missing = 0`,
      },
      {
        key: 'review-approved',
        label: '差异评估结论已经 FAE/RI 审核通过:验收工单 01 已过,不处在驳回待改状态',
        level: 'L1',
        predicate: 'review_approved_total > 0 and substitution_difference_item_rows > 0 and substitution_difference_item_missing = 0',
      },
      {
        key: 'every-candidate-assessed',
        label: '每个未放弃的候选都有一条替代关系记录(N 候选 = N 条 P-11,并行独立落账)',
        level: 'L2',
        predicate: `substitution_relation_rows > 0 and substitution_relation_rows = count(candidateInternalPns) - ${D}_pn_check_exists_status_abandoned`,
      },
      {
        key: 'every-item-has-status',
        label: '关键参数差异均有明确状态:每条明细都是 MATCH / MISMATCH / UNKNOWN / CONDITIONAL 之一',
        level: 'L2',
        predicate:
          'substitution_difference_item_rows > 0 and substitution_difference_item_difference_status_match + substitution_difference_item_difference_status_mismatch + ' +
          'substitution_difference_item_difference_status_unknown + substitution_difference_item_difference_status_conditional = substitution_difference_item_rows',
      },
      {
        key: 'unknown-has-disposition',
        label: 'UNKNOWN 项均有去向:每个 UNKNOWN 都挂了验证任务或补来源动作,影响说明与证据引用逐条齐全',
        level: 'L2',
        predicate: 'substitution_difference_item_missing = 0',
      },
      {
        key: 'judged-by-rule',
        label: '四态与推荐等级由规则算出:逐参数比对工具与推荐定级工具都真调过',
        level: 'L2',
        predicate: `tool_part_substitution_assessment_compare_parameters_calls > 0 and tool_part_substitution_assessment_recommend_calls > 0`,
      },
      {
        key: 'analysis-status-confirmed',
        label: '分析状态与客户批准状态分离:每条 P-11 只写分析层 analysis_status = FAE 确认,规则版本已锁定随结论落账',
        level: 'L2',
        predicate: 'substitution_relation_analysis_status_fae_confirmed = substitution_relation_rows and substitution_relation_missing = 0',
      },
      {
        key: 'p01-writeback-complete',
        label: '回写 P-01 字段完整:风险等级、验证要求、输出链接、下一步动作、当前阶段五项齐全',
        level: 'L2',
        predicate: 'demand_request_rows > 0 and demand_request_missing = 0',
      },
      { key: 'evidence-credible', label: '证据链可信:每条结论都能追溯到 P-08 / P-10 / P-06 的具体记录,或本单数据工单的创建记录(人工录入)', level: 'L4' },
      { key: 'hard-not-auto-match', label: '硬约束项未被自动判 MATCH:封装 / 工艺 / 电气边界等硬约束的 MATCH 都有证据支持', level: 'L4' },
      { key: 'recommendation-consistent', label: '推荐等级与差异明细一致:存在 UNKNOWN / CONDITIONAL 的候选没有给「推荐」', level: 'L4' },
      /* L5 是责任关:那张卡本身就是这道关,卡面上不摆它(基座统一滤) */
      { key: 'gate03-release', label: 'ACT-GATE-03 评审放行:签下去 P-11 落账、P-01 回写,结论供下游消费', level: 'L5' },
    ],
    revisableAtRelease: [],
    prohibitions: [
      { key: 'no-fabricated-parameter', statement: 'UNKNOWN 是一等公民:查不到的参数值必须显式写「未披露」并记去向,不得伪造、不得用估计值或空串冒充' },
      { key: 'no-override-hard-mismatch', statement: '硬约束 MISMATCH 不得被任何措辞改判 MATCH;要改只能补证据后重比' },
      { key: 'no-customer-approval-write', statement: '客户批准状态由独立审批流维护,本活动不写 customer_approval_status(没有这一列的写权)' },
      { key: 'no-commercial-judgement', statement: '本活动只答技术可替代性:不做数量、价格、交期、采购判断' },
      { key: 'no-rebind', statement: '启动绑定的 4 项(客户 / 需求 / 原 PN / 候选数组)工单内不可改;绑错只能退回触发方重新启动' },
      { key: 'no-external-write', statement: '放行前不得触达外部系统 —— 触达世界的动作集中在放行那一刻' },
    ],
    authorization: {
      discretion: [],
      preauthorizedActions: [],
      /*
       * 基线:数据工单 ≤2 + 候选放弃/创建决策 ≤2 + 新客户录入卡 1 + 执行单 01 + 审核决策 1 + 放行 1 = 8;
       * 审核循环预留两轮(+4)= 12。cap 必须严格大于最后一次拍板时的实际次数(E04 实测栽过),留到 16。
       */
      humanCallBudget: 16,
    },
    resources: {
      tools: [{ name: TOOL_COMPARE }, { name: TOOL_RECOMMEND }],
      skills: ASSESSMENT_SKILLS.map((s) => s.id),
      models: ['deepseek-v4-flash'],
      roles: ['FAE/RI'],
      budget: { steps: 90, tokens: 600_000, activeMinutes: 120, humanCalls: 16 },
    },
  },

  /* ── 声明 ────────────────────────────────────────────────────── */
  declarations: {
    models: ['deepseek-v4-flash'],
    tools: [{ name: TOOL_COMPARE }, { name: TOOL_RECOMMEND }],
    ledger: [
      /* L-02 客户主台账:只在分支 0a(客户不在库)由数据录入卡建一行 —— 录入卡写的列都得声明 */
      {
        domain: 'customer',
        object: 'profile',
        kind: 'master',
        label: '客户档案',
        fields: [
          col({ name: 'customer_code', type: 'text', label: '客户编码', primaryKey: true }),
          col({ name: 'name', type: 'text', label: '客户名称', required: true }),
          col({ name: 'industry', type: 'text', label: '所属行业', expect: true }),
          col({ name: 'region', type: 'text', label: '区域', expect: true }),
        ],
      },
      /* P-08 芯片主数据:数据工单建整条;参数集是它的一个字段(一张表,不建子表) */
      {
        domain: 'parts',
        object: 'profile',
        kind: 'master',
        label: '芯片主数据(P-08)',
        fields: [
          col({ name: 'internal_pn', type: 'text', label: '内部料号', primaryKey: true }),
          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'] }),
          col({ name: 'eol_pcn_status', type: 'text', label: 'EOL/PCN 状态', expect: true }),
          col({ name: 'datasheet_ref', type: 'text', label: 'Datasheet 引用', expect: true }),
          col({ name: 'datasheet_version', type: 'text', label: 'Datasheet 版本', expect: true }),
          col({ name: 'application_scenarios', type: 'text', label: '适配应用场景', expect: true }),
        ],
      },
      /* P-10 原厂技术资料库:数据工单建;评估时只读做证据回链 */
      {
        domain: 'parts',
        object: 'document',
        kind: 'master',
        label: '原厂技术资料库(P-10)',
        fields: [
          col({ name: 'internal_pn', type: 'text', label: '关联内部料号', primaryKey: true }),
          col({ name: 'doc_seq', type: 'text', label: '资料序号(同一料号下 01/02…)', primaryKey: true }),
          col({ name: 'document_type', type: 'text', label: '资料类型', required: true, tally: ['datasheet', 'app_note', 'reference_design', 'white_paper', 'sample_doc', 'commitment_letter', 'other'] }),
          col({ name: 'brand_name', type: 'text', label: '品牌名称', expect: true }),
          col({ name: 'document_title', type: 'text', label: '资料标题', required: true }),
          col({ name: 'document_version', type: 'text', label: '资料版本', required: true }),
          col({ name: 'document_ref', type: 'text', label: '资料文件链接', required: true }),
          col({ name: 'citation_locator', type: 'text', label: '证据定位(页码/章节/表格)', expect: true }),
          col({ name: 'document_status', type: 'text', label: '资料状态', required: true, tally: ['valid', 'expired', 'pending', 'void'] }),
        ],
      },
      /* P-01 需求主记录:S0 新建 V1,放行时回写五项(current_stage = gate03 那一刻五项必填) */
      {
        domain: 'demand',
        object: 'request',
        kind: 'master',
        label: '客户需求主记录(P-01)',
        fields: [
          col({ name: 'request_id', type: 'text', label: '需求编号', primaryKey: true }),
          col({ name: 'customer_code', type: 'text', label: '客户编码', required: true, refersTo: 'customer.profile.customer_code' }),
          col({ name: 'requirement_version', type: 'text', label: '需求版本', required: true }),
          col({ name: 'request_type', type: 'text', label: '需求类型', required: true }),
          col({ name: 'request_description', type: 'text', label: '需求描述', required: true }),
          col({ name: 'input_snapshot_ref', type: 'text', label: '输入快照', required: true }),
          col({ name: 'original_internal_pn', type: 'text', label: '原内部料号', required: true }),
          col({ name: 'bom_position', type: 'text', label: 'BOM 位', expect: true }),
          col({ name: 'application_scenario', type: 'text', label: '应用场景', expect: true }),
          col({ name: 'technical_constraints', type: 'text', label: '技术约束' }),
          col({ name: 'risk_level', type: 'text', label: '风险等级', tally: ['low', 'medium', 'high', 'critical'], requiredWhen: { column: 'current_stage', value: 'gate03' } }),
          col({ name: 'verification_requirement', type: 'text', label: '验证/复测/确认要求', requiredWhen: { column: 'current_stage', value: 'gate03' } }),
          col({ name: 'request_status', type: 'text', label: '需求状态', required: true, tally: ['pending', 'responded', 'won', 'abandoned', 'closed'] }),
          col({ name: 'current_activity', type: 'text', label: '当前 Activity', requiredWhen: { column: 'current_stage', value: 'gate03' } }),
          col({ name: 'current_stage', type: 'text', label: '当前阶段' }),
          col({ name: 'next_action', type: 'text', label: '下一步动作', requiredWhen: { column: 'current_stage', value: 'gate03' } }),
          col({ name: 'output_ref', type: 'text', label: '标准输出链接', requiredWhen: { column: 'current_stage', value: 'gate03' } }),
        ],
      },
      /* P-11 替代关系主记录:一候选一条;两枚 PN 都是引用墙(查无此料当场拒 —— 存在性检查的硬门) */
      {
        domain: 'substitution',
        object: 'relation',
        kind: 'master',
        label: '替代关系主记录(P-11)',
        fields: [
          col({ name: 'substitution_relation_id', type: 'text', label: '替代关系ID', primaryKey: true }),
          col({ name: 'eval_ref', type: 'text', label: '评估案号', required: true }),
          col({ name: 'request_id', type: 'text', label: '关联需求编号', required: true }),
          col({ name: 'bom_position', type: 'text', label: '关联 BOM 位', expect: true }),
          col({ name: 'original_internal_pn', type: 'text', label: '原内部料号', required: true, refersTo: 'parts.profile.internal_pn' }),
          col({ name: 'candidate_internal_pn', type: 'text', label: '候选内部料号', required: true, refersTo: 'parts.profile.internal_pn' }),
          col({ name: 'substitution_type', type: 'text', label: '替代类型', required: true, tally: ['pin_to_pin', 'functional', 'parametric', 'other'] }),
          col({ name: 'recommendation_level', type: 'text', label: '推荐等级', required: true, tally: ['recommended', 'conditional', 'alternate', 'not_recommended'] }),
          col({ name: 'recommendation_reason', type: 'text', label: '推荐理由', required: true }),
          col({ name: 'difference_summary', type: 'text', label: '差异摘要', required: true }),
          col({ name: 'risk_level', type: 'text', label: '替代风险等级', required: true, tally: ['low', 'medium', 'high', 'critical'] }),
          col({ name: 'verification_requirement', type: 'text', label: '验证要求', requiredWhen: { column: 'recommendation_level', value: 'conditional' } }),
          col({ name: 'validation_id', type: 'text', label: '关联验证ID' }),
          col({ name: 'analysis_status', type: 'text', label: '分析状态', required: true, tally: ['ai_draft', 'fae_confirmed', 'pending_customer', 'customer_approved', 'rejected', 'void'] }),
          col({ name: 'analysis_report_ref', type: 'text', label: '替代分析报告', expect: true }),
          col({ name: 'confidence_score', type: 'number', label: '证据可信度', expect: true }),
          col({ name: 'rule_version', type: 'text', label: '差异规则版本', required: true }),
        ],
      },
      /* P-11 差异明细子表:一条参数一条记录;relation id 上 distinct 让基座数「几条关系有明细」 */
      {
        domain: 'substitution',
        object: 'difference_item',
        kind: 'master',
        label: '差异明细(P-11 子表)',
        fields: [
          col({ name: 'substitution_relation_id', type: 'text', label: '替代关系ID', primaryKey: true, distinct: true, refersTo: 'substitution.relation.substitution_relation_id' }),
          col({ name: 'difference_item_id', type: 'text', label: '差异明细ID', primaryKey: true }),
          col({ name: 'parameter_name', type: 'text', label: '标准参数名', required: true }),
          col({ name: 'original_value', type: 'text', label: '原物料参数值', required: true }),
          col({ name: 'candidate_value', type: 'text', label: '候选物料参数值', required: true }),
          col({ name: 'parameter_unit', type: 'text', label: '参数单位', expect: true }),
          col({ name: 'constraint_type', type: 'text', label: '约束类型', required: true, tally: ['hard', 'soft'] }),
          col({ name: 'compare_result', type: 'text', label: '比对结果(工具出参)', expect: true }),
          col({ name: 'difference_status', type: 'text', label: '差异状态', required: true, tally: ['match', 'mismatch', 'unknown', 'conditional'] }),
          col({ name: 'impact_statement', type: 'text', label: '影响说明(MATCH 写「无」)', required: true }),
          col({ name: 'unknown_disposition', type: 'text', label: 'UNKNOWN 去向(验证任务号 / 补来源动作)', requiredWhen: { column: 'difference_status', value: 'unknown' } }),
          col({ name: 'risk_level', type: 'text', label: '风险等级', required: true, tally: ['low', 'medium', 'high'] }),
          col({ name: 'evidence_ref', type: 'text', label: '差异证据引用', required: true }),
          col({ name: 'confidence', type: 'number', label: 'AI 置信度', expect: true }),
          col({ name: 'rule_version', type: 'text', label: '差异规则版本', required: true }),
        ],
      },
      /* P-06 验证记录:事实类 —— 本活动只写「验证要求」(状态 not_started),不经数据工单建 */
      {
        domain: 'validation',
        object: 'record',
        kind: 'master',
        label: '芯片验证记录(P-06)',
        fields: [
          col({ name: 'validation_id', type: 'text', label: '验证ID', primaryKey: true }),
          col({ name: 'request_id', type: 'text', label: '关联需求编号', required: true }),
          col({ name: 'validation_internal_pn', type: 'text', label: '验证内部料号', required: true, refersTo: 'parts.profile.internal_pn' }),
          col({ name: 'validation_type', type: 'text', label: '验证类型', required: true, tally: ['pin_to_pin', 'functional', 'reliability', 'certification', 'retest'] }),
          col({ name: 'validation_status', type: 'text', label: '验证状态', required: true, tally: ['not_started', 'in_progress', 'passed', 'failed', 'conditional', 'pending_evidence'] }),
          col({ name: 'test_condition', type: 'text', label: '测试条件', expect: true }),
          col({ name: 'next_validation_action', type: 'text', label: '后续验证动作', required: true }),
          col({ name: 'evidence_ref', type: 'text', label: '验证证据引用', expect: true }),
        ],
      },
      /* ── 活动自持单据(record,caseKey eval_ref)── */
      {
        domain: D,
        object: 'pn_check',
        kind: 'record',
        caseKey: 'eval_ref',
        label: '料号存在性核对',
        fields: [
          col({ name: 'eval_ref', type: 'text', label: '评估案号', primaryKey: true }),
          col({ name: 'internal_pn', type: 'text', label: '内部料号', primaryKey: true }),
          col({ name: 'role', type: 'text', label: '角色', required: true, tally: ['original', 'candidate'] }),
          col({ name: 'exists_status', type: 'text', label: '在库状态', required: true, tally: ['found', 'absent', 'created', 'abandoned'] }),
          col({ name: 'abandon_reason', type: 'text', label: '放弃原因', requiredWhen: { column: 'exists_status', value: 'abandoned' } }),
          col({ name: 'note', type: 'text', label: '备注' }),
        ],
      },
      {
        domain: D,
        object: 'input_gap',
        kind: 'record',
        caseKey: 'eval_ref',
        label: '评估输入清单',
        fields: [
          col({ name: 'eval_ref', type: 'text', label: '评估案号', primaryKey: true }),
          col({ name: 'item_key', type: 'text', label: '输入项键', primaryKey: true }),
          col({ name: 'input_item', type: 'text', label: '输入项', required: true }),
          col({ name: 'source_ledger', type: 'text', label: '来源台账', expect: true }),
          col({ name: 'status', type: 'text', label: '状态', required: true, tally: ['gap', 'default', 'ok'] }),
          col({ name: 'detail', type: 'text', label: '绑定值 / 缺口说明', expect: true }),
          col({ name: 'supplement', type: 'text', label: '补充内容', requiredWhen: { column: 'status', value: 'gap' } }),
          col({ name: 'origin', type: 'text', label: '来源(ai/human-added)' }),
        ],
      },
      {
        domain: D,
        object: 'verdict',
        kind: 'record',
        caseKey: 'eval_ref',
        label: '本次评估结论',
        fields: [
          col({ name: 'eval_ref', type: 'text', label: '评估案号', primaryKey: true }),
          col({ name: 'request_id', type: 'text', label: '需求编号' }),
          col({ name: 'rule_version', type: 'text', label: '规则版本' }),
          col({ name: 'original_internal_pn', type: 'text', label: '原内部料号' }),
          col({ name: 'candidate_total', type: 'integer', label: '候选数' }),
          col({ name: 'input_confirmed_total', type: 'integer', label: '评估输入已确认' }),
          col({ name: 'review_approved_total', type: 'integer', label: '差异结论已审核通过' }),
          col({ name: 'landing_prepared_total', type: 'integer', label: '拟落账已备好' }),
          col({ name: 'relation_total', type: 'integer', label: '替代关系条数' }),
          col({ name: 'recommended_total', type: 'integer', label: '推荐' }),
          col({ name: 'conditional_total', type: 'integer', label: '条件推荐' }),
          col({ name: 'alternate_total', type: 'integer', label: '备选' }),
          col({ name: 'not_recommended_total', type: 'integer', label: '不推荐' }),
          col({ name: 'summary', type: 'text', label: '本次评估结论' }),
        ],
      },
    ],
    listeners: [],
    harness: {
      memoryWindow: 24,
      stallAfter: 'PT4H',
      nudge: [{ after: 'PT2H', action: 'nudge' }],
      retry: { times: 2, backoff: 'PT30S' },
    },
  },

  /* ── 资产初始版 ──────────────────────────────────────────────── */
  assets: {
    businessModel: { cmmnXml: CMMN_XML, narrative: NARRATIVE, dmn: ASSESSMENT_DMN, steps: ASSESSMENT_STEPS, phases: ASSESSMENT_PHASES },
    skills: ASSESSMENT_SKILLS,
    /*
     * 五个局面各一张:
     * · `p08-master-create` —— S0d 摆,数据工单(P-08),件上可加行、可编辑;
     * · `p10-document-create` —— S0d 摆,数据工单(P-10),同上;
     * · `eval-input-list` —— S1 摆,评估输入清单:绑定区只读 + 缺口表可编辑;
     * · `difference-review` —— S2/S3/S4 摆,差异明细 + 各候选推荐等级,只读;
     * · `landing-confirm` —— S5/T_release 摆,拟写 P-11 / 拟回写 P-01,只读;放行后即归档件。
     */
    viewTemplates: [
      { name: 'p08-master-create', html: P08_MASTER_CREATE },
      { name: 'p10-document-create', html: P10_DOCUMENT_CREATE },
      { name: 'eval-input-list', html: EVAL_INPUT_LIST },
      { name: 'difference-review', html: DIFFERENCE_REVIEW },
      { name: 'landing-confirm', html: LANDING_CONFIRM },
    ],
    cardSchemas: {},
    distillHooks: [{ on: 'terminal', prompt: '归纳本次差异评估:哪些候选、各自推荐等级与关键依据、UNKNOWN 挂了什么验证任务、审核是否被驳回过(为什么)、数据工单建了哪些主数据。' }],
  },

  /*
   * ── 样例集:五条形状真不同(常备案例即验收案例)──
   * 第一条满载且走「候选缺库 → 数据工单」那条最长的路(测试台落地即预填的是它);样例名 ≤14 字。
   * 库里只播了 LM2596S-5.0 / LM2596S-ADJ 两颗(见 seed-mvp-data.ts),XL2596S-5.0 / CN-DCDC-32V / LM2596S-3.3 刻意不在库。
   */
  samples: [
    {
      name: '候选缺库·派数据工单',
      input: {
        customerCode: 'CU-0142',
        requestDescription: '工业电源项目在用 LM2596S-5.0(TI,12V→5V),原厂 PCN 转 NRND,客户要求验证替代料;应用 12V→5V、工业温域、输入上限 40V',
        originalInternalPn: 'LM2596S-5.0',
        candidateInternalPns: ['XL2596S-5.0', 'LM2596S-ADJ'],
      },
      expect: { ports: ['assessment-released'] },
    },
    {
      name: '都在库·常路',
      input: {
        customerCode: 'CU-0142',
        requestDescription: 'LM2596S-5.0 转 NRND,客户先评估同族可调版 LM2596S-ADJ 能否替代(愿意改外围);应用 12V→5V、工业温域',
        originalInternalPn: 'LM2596S-5.0',
        candidateInternalPns: ['LM2596S-ADJ'],
      },
      expect: { ports: ['assessment-released'] },
    },
    {
      name: '原PN缺库·硬阻塞',
      input: {
        customerCode: 'CU-0142',
        requestDescription: '客户另一块板在用 LM2596S-3.3(3.3V 固定版),同批 PCN,要求评估 LM2596S-ADJ 替代;应用 12V→3.3V',
        originalInternalPn: 'LM2596S-3.3',
        candidateInternalPns: ['LM2596S-ADJ'],
      },
      expect: { ports: ['assessment-released'] },
    },
    {
      name: '三候选·含硬约束不推荐',
      input: {
        customerCode: 'CU-0142',
        requestDescription: '工业电源 12V→5V,输入上限 40V,工业温域;三个候选:XL2596S-5.0、LM2596S-ADJ、国产 CN-DCDC-32V(输入上限只有 32V)',
        originalInternalPn: 'LM2596S-5.0',
        candidateInternalPns: ['XL2596S-5.0', 'LM2596S-ADJ', 'CN-DCDC-32V'],
      },
      expect: { ports: ['assessment-released'] },
    },
    {
      name: '新客户·先建档',
      input: {
        customerCode: 'CU-9001',
        customerName: '深圳恒星电源有限公司',
        requestDescription: '新客户口述:工业电源 12V→5V 在用 LM2596S-5.0,想评估 LM2596S-ADJ',
        originalInternalPn: 'LM2596S-5.0',
        candidateInternalPns: ['LM2596S-ADJ'],
      },
      expect: { ports: ['assessment-released'] },
    },
  ],

  entry: { file: 'index.js', checksum: 'dev-entry-part-substitution-assessment' },
};
  • Step 4: 先放两份占位(让 Task 3 的测试能跑;Task 4/5 整份替换)

src/case-model.ts 占位:

export const CMMN_XML = '';
export const NARRATIVE = '';
export const ASSESSMENT_STEPS: ReadonlyArray<{ item: string; intent: string; reads: string[]; writes: string[]; tools: string[]; view?: string; doneWhen?: string; guide: string }> = [];
export const ASSESSMENT_PHASES: ReadonlyArray<{ name: string; items: string[] }> = [];

src/views/report.ts 占位:

export const P08_MASTER_CREATE = '<div class="proto-root"></div>';
export const P10_DOCUMENT_CREATE = '<div class="proto-root"></div>';
export const EVAL_INPUT_LIST = '<div class="proto-root"></div>';
export const DIFFERENCE_REVIEW = '<div class="proto-root"></div>';
export const LANDING_CONFIRM = '<div class="proto-root"></div>';
  • Step 5: 写结构自测 __tests__/activity.test.ts(Task 4/5 落地前,案例模型与模版相关的用例会红 —— 这是预期,Task 5 收尾时全绿)
/**
 * ACT-WORK-05 差异评估的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。
 * 案例活动没有 `run`,自测不跑回合;测的是清单本身与四处对齐
 * (判据谓词 / 台账声明的授权面 / 决策卡循环骨架 / 报告模版的读写面),结构照 E04 / I01。
 */
import { describe, expect, it } from 'vitest';
import { activityManifestSchema } from '@contracts';
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 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 ?? '';

describe('清单本身合规', () => {
  it('过 @contracts 的 schema', () => {
    expect(() => activityManifestSchema.parse(manifest)).not.toThrow();
  });

  it('没有静态监听(等外部回音一律落在执行工单上,listen 在本仓不可用)', () => {
    expect(manifest.declarations.listeners).toEqual([]);
  });

  it('L1/L2 判据都给了谓词;L4 三条不给(裁量项);L5 恰一条', () => {
    for (const c of manifest.contract.acceptanceCriteria) {
      if (c.level === 'L1' || c.level === 'L2') expect(c.predicate, c.key).toBeDefined();
      if (c.level === 'L4') expect(c.predicate, c.key).toBeUndefined();
    }
    expect(manifest.contract.acceptanceCriteria.filter((c) => c.level === 'L4')).toHaveLength(3);
    expect(manifest.contract.acceptanceCriteria.filter((c) => c.level === 'L5')).toHaveLength(1);
  });

  it('机检谓词只读基座读数与记账旗:不出现活动自报的汇总列名', () => {
    const forbidden = ['relation_total', 'candidate_total', 'recommended_total', 'not_recommended_total'];
    for (const c of manifest.contract.acceptanceCriteria) {
      for (const name of forbidden) expect(c.predicate ?? '', `${c.key} 读了自报数 ${name}`).not.toContain(name);
    }
  });

  it('三件必交付物都有台账地址;人环预算严格大于「基线 8 + 两轮审核循环 4」', () => {
    expect(manifest.contract.requiredDeliverables).toHaveLength(3);
    for (const d of manifest.contract.requiredDeliverables) expect(d.ledger, d.key).toBeDefined();
    expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(12);
    expect(manifest.contract.resources.budget.humanCalls).toBeGreaterThan(12);
  });

  it('起单只必填 4 项(客户 / 需求 / 原 PN / 候选数组);datasheet 是选填文件', () => {
    expect(manifest.contract.input.data['required']).toEqual(['customerCode', 'requestDescription', 'originalInternalPn', 'candidateInternalPns']);
    const properties = manifest.contract.input.data['properties'] as Record<string, { type?: string; format?: string }>;
    expect(properties['candidateInternalPns']?.type).toBe('array');
    expect(properties['datasheets']?.format).toBe('file');
  });

  it('五条样例:第一条满载走数据工单那条路,名字 ≤14 字,形状各不相同', () => {
    expect(manifest.samples).toHaveLength(5);
    for (const s of manifest.samples) expect(s.name.length, s.name).toBeLessThanOrEqual(14);
    expect(new Set(manifest.samples.map((s) => JSON.stringify(s.input))).size).toBe(5);
    const first = manifest.samples[0]?.input as { candidateInternalPns?: string[] };
    expect(first.candidateInternalPns).toContain('XL2596S-5.0');
    const newCustomer = manifest.samples.find((s) => (s.input as { customerName?: string }).customerName !== undefined);
    expect(newCustomer).toBeDefined();
  });

  it('五张视图模版都声明了,剧本每一步引用的 view 都在其中', () => {
    expect(new Set(manifest.assets.viewTemplates.map((v) => v.name))).toEqual(new Set(VIEWS));
    for (const step of manifest.assets.businessModel.steps as ReadonlyArray<{ view?: string }>) {
      if (step.view !== undefined) expect(VIEWS, step.view).toContain(step.view);
    }
  });
});

describe('台账声明:授权面与《统一方案》口径', () => {
  it('P-08 一张表:主键 internal_pn,参数集是它的一个必填字段,没有 parts.parameter 子表', () => {
    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();
  });

  it('P-11 主记录的原 PN / 候选 PN 都是引用墙(查无此料当场拒 —— 存在性检查的硬门)', () => {
    const rel = byAddress('substitution', 'relation');
    for (const name of ['original_internal_pn', 'candidate_internal_pn']) {
      expect(rel?.fields.find((f) => f.name === name)?.refersTo, name).toBe('parts.profile.internal_pn');
    }
  });

  it('客户批准状态**不在声明里**:本活动没有它的写权(只读边界)', () => {
    const rel = byAddress('substitution', 'relation');
    expect(rel?.fields.map((f) => f.name)).not.toContain('customer_approval_status');
  });

  it('UNKNOWN 必有去向:unknown_disposition 钉在 difference_status = unknown 的行上;四态是 ASCII tally', () => {
    const item = byAddress('substitution', 'difference_item');
    expect(item?.fields.find((f) => f.name === 'unknown_disposition')?.requiredWhen).toEqual({ column: 'difference_status', value: 'unknown' });
    expect(item?.fields.find((f) => f.name === 'difference_status')?.tally).toEqual(['match', 'mismatch', 'unknown', 'conditional']);
    expect(item?.fields.find((f) => f.name === 'substitution_relation_id')?.distinct).toBe(true);
  });

  it('P-01 回写五项钉在 current_stage = gate03 那一刻(放行前不拦,放行门 L2 数得出来)', () => {
    const p01 = byAddress('demand', 'request');
    for (const name of ['risk_level', 'verification_requirement', 'output_ref', 'next_action', 'current_activity']) {
      expect(p01?.fields.find((f) => f.name === name)?.requiredWhen, name).toEqual({ column: 'current_stage', value: 'gate03' });
    }
  });

  it('自持单据都带 caseKey eval_ref 且在主键里;放弃候选必须写原因', () => {
    for (const object of ['pn_check', 'input_gap', 'verdict']) {
      const t = byAddress(D, object);
      expect(t?.kind, object).toBe('record');
      expect(t?.caseKey, object).toBe('eval_ref');
      expect(t?.fields.find((f) => f.name === 'eval_ref')?.primaryKey, object).toBe(true);
    }
    expect(byAddress(D, 'pn_check')?.fields.find((f) => f.name === 'abandon_reason')?.requiredWhen).toEqual({ column: 'exists_status', value: 'abandoned' });
  });

  it('规则版本常量随包冻结', () => {
    expect(RULE_VERSION).toBe('R-2026.07');
    expect(manifest.assets.businessModel.dmn.tables.map((t) => t.id)).toEqual(['d01-difference-status', 'd02-recommendation']);
  });
});

describe('案例模型与清单的对齐', () => {
  it('八个计划项,剧本 item 与 CMMN humanTask id 一一对应', () => {
    const cmmnIds = [...manifest.assets.businessModel.cmmnXml.matchAll(/<humanTask id="([^"]+)"/g)].map((m) => m[1] ?? '');
    expect(cmmnIds).toEqual(ITEMS);
    expect((manifest.assets.businessModel.steps as ReadonlyArray<{ item: string }>).map((s) => s.item)).toEqual(ITEMS);
  });

  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 里都标了人工激活', () => {
    for (const id of NO_DONE_WHEN) {
      const block = new RegExp(`<humanTask id="${id}"[\\s\\S]*?</humanTask>`).exec(manifest.assets.businessModel.cmmnXml)?.[0] ?? '';
      expect(block, id).toContain('manualActivationRule');
    }
  });

  it('除放行外每一项恰好属于一个阶段;5 段 + 放行 = x/6,与文档 0.5/1/2/3/4/5 六步对齐', () => {
    const phases = manifest.assets.businessModel.phases ?? [];
    expect(phases).toHaveLength(5);
    const phaseItems = phases.flatMap((p) => p.items);
    expect([...phaseItems].sort()).toEqual([...ITEMS.filter((i) => i !== 'T_release')].sort());
    expect(new Set(phaseItems).size).toBe(phaseItems.length);
  });

  it('说明书点名的计划项都真实存在', () => {
    const mentioned = [...manifest.assets.businessModel.narrative.matchAll(/\b(S\d[a-z]?_[a-zA-Z_]+|T_release)\b/g)].map((m) => m[1] ?? '');
    for (const name of mentioned) expect(ITEMS, `说明书提到但不存在的计划项: ${name}`).toContain(name);
  });

  it('两种决策卡骨架都在说明书里:候选放弃/创建(create/abandon)与审核(approve/reject/custom:)', () => {
    const n = manifest.assets.businessModel.narrative;
    expect(n).toContain('"kind":"decide","itemId":"S0d_master_data"');
    expect(n).toContain('"value":"create"');
    expect(n).toContain('"value":"abandon"');
    expect(n).toContain('"kind":"decide","itemId":"S4_review"');
    expect(n).toContain('"approve"');
    expect(n).toContain('"reject"');
    expect(n).toContain('"custom:"');
  });

  it('T_release 剧本里 judge criterion 的原文逐字对得上 acceptanceCriteria 的 label', () => {
    const tRelease = (manifest.assets.businessModel.steps as ReadonlyArray<{ item: string; guide?: string }>).find((s) => s.item === 'T_release');
    const quoted = [...(tRelease?.guide ?? '').matchAll(/judge criterion="([^"]+)"/g)].map((m) => m[1] ?? '');
    expect(quoted).toHaveLength(3);
    const labels = new Set(manifest.contract.acceptanceCriteria.map((c) => c.label));
    for (const q of quoted) expect(labels.has(q), q).toBe(true);
  });
});

describe('报告模版:编辑面与只读面各就各位', () => {
  it('两张数据工单件可加行可编辑: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"');
    const p10 = viewHtml('p10-document-create');
    expect(p10).toContain('data-edit="parts.document"');
    expect(p10).toContain('data-col="doc_seq"');
  });

  it('评估输入清单:缺口表可编辑(supplement),绑定区只读', () => {
    const html = viewHtml('eval-input-list');
    expect(html).toContain(`data-edit="${D}.input_gap"`);
    expect(html).toContain('data-col="supplement"');
    expect(html).toContain(`data-rows="mine:${D}.pn_check"`);
  });

  it('差异审核与落账确认只读:没有任何 data-edit / data-col', () => {
    for (const name of ['difference-review', 'landing-confirm']) {
      expect(viewHtml(name), name).not.toContain('data-edit=');
      expect(viewHtml(name), name).not.toContain('data-col=');
    }
  });

  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'],
    ];
    for (const [view, domain, object] 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));
      for (const c of cols) expect(declared.has(c), `${view}: ${c}`).toBe(true);
    }
  });

  it('枚举列只用 data-class-of 投影,不摆 ASCII 原值', () => {
    expect(viewHtml('difference-review')).toContain('data-class-of="difference_status"');
    expect(viewHtml('difference-review')).toContain('data-class-of="recommendation_level"');
    expect(viewHtml('difference-review')).not.toContain('data-slot="difference_status"');
  });

  it('宿主拒推含脚本的模版', async () => {
    const host = createMockHost({ contract: CONTRACT });
    await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
  });
});

describe('活动入口', () => {
  it('没有 run —— 行为全在清单里', () => {
    expect(activity.run).toBeUndefined();
  });
});

const CONTRACT = {
  activityId: 'part-substitution-assessment',
  version: '1.0.0',
  data: {},
  message: undefined,
  outputPorts: { mandatory: ['assessment-released'], selective: [] },
  prohibitions: [],
  discretion: [],
} as const;
  • Step 6: 跑一遍,记下哪些红(只许是案例模型 / 模版相关的那几条)

Run: pnpm vitest run activities/part-substitution-assessment Expected:「清单本身合规」「台账声明」两组绿;「案例模型」「报告模版」组红(占位为空)。若「过 @contracts 的 schema」红,先修清单再往下。

  • Step 7: 提交骨架
git add activities/part-substitution-assessment
git commit -m "feat(w05): 差异评估活动包骨架与清单 —— 六张主数据追加声明、三张自持单据、两张 DMN、五条样例

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh"

Task 4: 案例模型 —— CMMN 八项、说明书、逐步剧本、五段进度 + 剧本自测

Files:

  • Replace: activities/part-substitution-assessment/src/case-model.ts(整份替换 Task 3 的占位)
  • Create: activities/part-substitution-assessment/__tests__/steps.test.ts

Interfaces:

  • Consumes:Task 3 的台账地址 / 列名 / tally 值、工具名 TOOL_COMPARE / TOOL_RECOMMEND、视图名。

  • Produces:CMMN_XML、NARRATIVE、ASSESSMENT_STEPS(8 条,item 顺序 = CMMN humanTask 顺序)、ASSESSMENT_PHASES(5 段)。

  • 案号约定(剧本与 e2e 共用):eval_ref = 'W05-' + case:openedAt 去分隔符;request_id = 'REQ-' + 同一串数字;input_snapshot_ref = 'SNAP-' + eval_ref;关系 id REL-<数字串>-01/02…(按候选数组顺序);明细 id <关系id>-P01/P02…;验证 id VAL-<数字串>-01/02…;报告号 RPT-<数字串>。

  • 卡标题约定(e2e 按标题对号):数据工单 P-08 以「数据工单 · 芯片主数据创建」开头;P-10 以「数据工单 · 原厂技术资料创建」开头;执行单以「评估输入与资料补齐确认」开头。

  • Step 1: 写 src/case-model.ts

/**
 * ACT-WORK-05 差异评估的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
 *
 * 业务出处:`docs/SS02-ACT-WORK-05-html/ACT-WORK-05-活动流程与工单说明.md`(步骤 0.5 + 5 步,
 * 1 数据(条件)+ 1 执行 + 2 验收)与《SS02-SS03-SS04_Demo台账字段细化与统一方案》(字段口径)。
 *
 * 文档六步收成 8 个案例模型项:
 *   启动期 → S0_bind(AI:铸案号、建 P-01 V1、锁规则版本、逐个 PN 查库、写存在性核对表)
 *   0.5 等待数据 → S0d_master_data(人:数据工单 —— P-08 一张、P-10 一张;候选 ≥2 缺库先决策卡;
 *                 库里都有就 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(签下去)。
 */
import { D, RULE_VERSION, TOOL_COMPARE, TOOL_RECOMMEND } from './constants.ts';

export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
  <case id="part-substitution-assessment">
    <!--
      案卷项的 id 就是任务视图里的键名。入参顶层字段(customerCode / customerName / requestDescription /
      originalInternalPn / candidateInternalPns / datasheets)不进这份 caseFileModel —— 谓词里只用
      count(candidateInternalPns)。其余全是基座读数(行数 / 缺必填 / 按值计数 / 工具调用数)与
      sub_eval.verdict 里的三面记账旗(每面旗都配着一半基座数)。
    -->
    <caseFileModel>
      <caseFileItem id="demand_request_rows" name="本单写下的需求主记录行数(基座现数)"/>
      <caseFileItem id="demand_request_missing" name="需求主记录缺必填的行数(基座现数)"/>
      <caseFileItem id="sub_eval_pn_check_rows" name="存在性核对行数(基座现数)"/>
      <caseFileItem id="sub_eval_pn_check_missing" name="存在性核对缺必填的行数(基座现数)"/>
      <caseFileItem id="sub_eval_pn_check_exists_status_absent" name="仍不在库的料号数(基座现数)"/>
      <caseFileItem id="sub_eval_pn_check_exists_status_abandoned" name="已放弃的候选数(基座现数)"/>
      <caseFileItem id="sub_eval_verdict_rows" name="结论行数(基座现数)"/>
      <caseFileItem id="parts_profile_missing" name="本单建的芯片主数据缺必填的行数(基座现数)"/>
      <caseFileItem id="parts_document_missing" name="本单建的资料记录缺必填的行数(基座现数)"/>
      <caseFileItem id="input_confirmed_total" name="评估输入已确认(人环凭据)"/>
      <caseFileItem id="sub_eval_input_gap_rows" name="评估输入清单行数(基座现数)"/>
      <caseFileItem id="sub_eval_input_gap_missing" name="评估输入清单缺必填的行数(基座现数)"/>
      <caseFileItem id="substitution_relation_rows" name="替代关系行数(基座现数)"/>
      <caseFileItem id="substitution_relation_missing" name="替代关系缺必填的行数(基座现数)"/>
      <caseFileItem id="substitution_relation_analysis_status_fae_confirmed" name="分析状态已 FAE 确认的关系数(基座现数)"/>
      <caseFileItem id="substitution_difference_item_rows" name="差异明细行数(基座现数)"/>
      <caseFileItem id="substitution_difference_item_missing" name="差异明细缺必填的行数(基座现数)"/>
      <caseFileItem id="substitution_difference_item_distinct_substitution_relation_id" name="有明细的关系数(基座现数不同值)"/>
      <caseFileItem id="substitution_difference_item_difference_status_match" name="MATCH 条数(基座现数)"/>
      <caseFileItem id="substitution_difference_item_difference_status_mismatch" name="MISMATCH 条数(基座现数)"/>
      <caseFileItem id="substitution_difference_item_difference_status_unknown" name="UNKNOWN 条数(基座现数)"/>
      <caseFileItem id="substitution_difference_item_difference_status_conditional" name="CONDITIONAL 条数(基座现数)"/>
      <caseFileItem id="tool_part_substitution_assessment_compare_parameters_calls" name="比对工具成功调用次数(基座现数)"/>
      <caseFileItem id="tool_part_substitution_assessment_recommend_calls" name="定级工具成功调用次数(基座现数)"/>
      <caseFileItem id="review_approved_total" name="差异结论已审核通过(仅通过时置一)"/>
      <caseFileItem id="landing_prepared_total" name="拟落账已备好(S5 写完时置一)"/>
    </caseFileModel>
    <casePlanModel id="plan" name="差异评估" autoComplete="true">
      <!-- ① 已绑定:需求主记录与存在性核对都写下了 —— 数据工单那一项的入场前提 -->
      <sentry id="s_bound">
        <onPart id="op_b" caseFileItemRef="sub_eval_pn_check_rows"><standardEvent>update</standardEvent></onPart>
        <ifPart id="if_b"><condition>demand_request_rows &gt; 0 and sub_eval_pn_check_rows &gt; 0</condition></ifPart>
      </sentry>
      <!--
        ② 料号全部落定:原 PN + 每个候选各一行核对,没有一颗还「不在库」,放弃的少于候选数,
        本单建的主数据 / 资料不缺必填 —— 评估输入确认那一步的入场前提。全是基座现数。
      -->
      <sentry id="s_pns_resolved">
        <onPart id="op_pr" caseFileItemRef="sub_eval_pn_check_exists_status_absent"><standardEvent>update</standardEvent></onPart>
        <ifPart id="if_pr"><condition>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 &lt; count(candidateInternalPns) and parts_profile_missing = 0 and parts_document_missing = 0</condition></ifPart>
      </sentry>
      <!-- ③ 评估输入已确认(旗 + 清单有行且必填齐)—— 标准化那一步的入场前提 -->
      <sentry id="s_input_confirmed">
        <onPart id="op_ic" caseFileItemRef="input_confirmed_total"><standardEvent>update</standardEvent></onPart>
        <ifPart id="if_ic"><condition>input_confirmed_total &gt; 0 and sub_eval_input_gap_rows &gt; 0 and sub_eval_input_gap_missing = 0</condition></ifPart>
      </sentry>
      <!-- ④ 对照表已铺好:每个未放弃的候选一条关系、每条关系都有明细 —— 逐项评估的入场前提 -->
      <sentry id="s_normalized">
        <onPart id="op_n" caseFileItemRef="substitution_difference_item_rows"><standardEvent>update</standardEvent></onPart>
        <ifPart id="if_n"><condition>substitution_relation_rows &gt; 0 and substitution_relation_rows = count(candidateInternalPns) - sub_eval_pn_check_exists_status_abandoned and substitution_difference_item_rows &gt; 0 and substitution_difference_item_distinct_substitution_relation_id = substitution_relation_rows</condition></ifPart>
      </sentry>
      <!-- ⑤ 逐项评估已完成:明细与关系必填齐、四态之和 = 明细行数、两枚工具都真调过 —— 审核的入场前提 -->
      <sentry id="s_assessed">
        <onPart id="op_a" caseFileItemRef="substitution_difference_item_missing"><standardEvent>update</standardEvent></onPart>
        <ifPart id="if_a"><condition>substitution_difference_item_missing = 0 and substitution_relation_missing = 0 and substitution_difference_item_difference_status_match + substitution_difference_item_difference_status_mismatch + substitution_difference_item_difference_status_unknown + substitution_difference_item_difference_status_conditional = substitution_difference_item_rows and tool_part_substitution_assessment_compare_parameters_calls &gt; 0 and tool_part_substitution_assessment_recommend_calls &gt; 0</condition></ifPart>
      </sentry>
      <!-- ⑥ 审核已通过 —— 拟落账的入场前提 -->
      <sentry id="s_reviewed">
        <onPart id="op_r" caseFileItemRef="review_approved_total"><standardEvent>update</standardEvent></onPart>
        <ifPart id="if_r"><condition>review_approved_total &gt; 0 and substitution_difference_item_missing = 0</condition></ifPart>
      </sentry>
      <!-- ⑦ 拟落账已备好:每条关系都到 FAE 确认、P-01 回写五项齐 —— 放行门的入场前提 -->
      <sentry id="s_landing_ready">
        <onPart id="op_l" caseFileItemRef="landing_prepared_total"><standardEvent>update</standardEvent></onPart>
        <ifPart id="if_l"><condition>landing_prepared_total &gt; 0 and substitution_relation_analysis_status_fae_confirmed = substitution_relation_rows and substitution_relation_missing = 0 and demand_request_missing = 0</condition></ifPart>
      </sentry>

      <!-- 启动期:铸案号、建需求 V1、锁规则版本、逐个料号查库(能算的不问人) -->
      <humanTask id="S0_bind" name="建立评估上下文:绑定四项、新建需求、存在性核对"/>

      <!-- 0.5 等待数据:PN 不在库 → 数据工单(P-08 / P-10 各一张);触达人,不给 doneWhen;库里都有就 disable -->
      <humanTask id="S0d_master_data" name="数据工单:补建芯片主数据与原厂资料">
        <entryCriterion id="ec_s0d" sentryRef="s_bound"/>
        <itemControl><manualActivationRule id="m_s0d"/></itemControl>
      </humanTask>

      <!-- 1 建立评估上下文:交 PW 在件上补缺口(执行工单 01)—— 不给 doneWhen -->
      <humanTask id="S1_input_confirm" name="评估输入与资料补齐确认">
        <entryCriterion id="ec_s1" sentryRef="s_pns_resolved"/>
        <itemControl><manualActivationRule id="m_s1"/></itemControl>
      </humanTask>

      <!-- 2 读取并标准化资料(纯 AI) -->
      <humanTask id="S2_normalize" name="标准化参数对照表:P-11 主记录骨架 + 差异明细对照行">
        <entryCriterion id="ec_s2" sentryRef="s_input_confirmed"/>
      </humanTask>

      <!-- 3 逐项差异评估(AI + 两枚工具) -->
      <humanTask id="S3_assess" name="逐项差异评估:四态、风险、证据、推荐等级、验证任务">
        <entryCriterion id="ec_s3" sentryRef="s_normalized"/>
      </humanTask>

      <!-- 4 FAE/RI 审核:决策卡循环,通过或驳回 + AI 改台账重发 —— 不给 doneWhen -->
      <humanTask id="S4_review" name="差异评估结论审核:通过或驳回(驳回则 AI 修订重提交)">
        <entryCriterion id="ec_s4" sentryRef="s_assessed"/>
        <itemControl><manualActivationRule id="m_s4"/></itemControl>
      </humanTask>

      <!-- 5(前半)拟落账:分析状态推到 FAE 确认、回写 P-01(不触达世界,给 doneWhen) -->
      <humanTask id="S5_prepare_landing" name="拟落账:P-11 分析状态 → FAE 确认,回写 P-01">
        <entryCriterion id="ec_s5" sentryRef="s_reviewed"/>
      </humanTask>

      <!-- 5(后半)提交 ACT-GATE-03:终局唯放行,必须人工激活 -->
      <humanTask id="T_release" name="评审放行与落账确认(ACT-GATE-03)">
        <entryCriterion id="ec_rel" sentryRef="s_landing_ready"/>
        <itemControl><manualActivationRule id="m_rl"/></itemControl>
      </humanTask>
    </casePlanModel>
  </case>
</definitions>`;

/**
 * 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本)。
 * 卡骨架住在这里而不是剧本里(模型会压拍,压过之后只有说明书还在 —— A1/A6/I01 同一课)。
 */
export const NARRATIVE = `§0 开案:**判断候选料号在技术上能不能替代原料号,逐参数给出可复核的差异结论,审核后落账**。
入参:
· \`customerCode\` —— 客户编码(L-02);\`customerName\` 只在客户不在库时给。
· \`requestDescription\` —— 客户需求原话,据它新建 P-01 需求主记录(版本 V1)。
· \`originalInternalPn\` —— 原内部料号;\`candidateInternalPns\` —— 候选内部料号数组(≥1,逐个评估、各一条 P-11)。
· \`datasheets\` —— 选填的真文件,基座已读成文本(每件 \`{name, text?, note}\`);有就拿它在数据工单里预填,没有就人全填。

**本活动只回答一件事:技术上哪种料能替**。不做数量 / 价格 / 交期 / 采购判断;客户批准状态由审批流维护,你没有那一列的写权。

**四项绑定后不可改**(客户 / 需求 / 原 PN / 候选数组):绑错只能退回启动人重新启动。

**能算的不问人也不自己判**:四态(MATCH / MISMATCH / UNKNOWN / CONDITIONAL)与推荐等级
(推荐 / 条件推荐 / 备选 / 不推荐)全由两枚工具算 —— \`${TOOL_COMPARE}\`、\`${TOOL_RECOMMEND}\`,
一批一次。你负责对照表、证据引用、影响说明、UNKNOWN 的去向。**规则版本 \`${RULE_VERSION}\` 随每条结论落账**。

**UNKNOWN 是一等公民**:查不到的参数值写「未披露」,不许估、不许留空串冒充;每个 UNKNOWN 必须写去向
(挂验证任务号或补来源动作)。硬约束 MISMATCH 不许改判,要改只能补证据后重比。

**存在性检查与数据工单**:启动时逐个料号在 \`parts.profile\` 里查(原 PN + 每个候选)。查无记录 = 「完全没有」,
要人从零建整条主数据 —— 发**数据工单**(一张表一张单:P-08 一张、P-10 一张);PN 在库但个别字段缺 = 「有而不全」,
归执行工单 01 补。原 PN 缺 = 硬阻塞必须建;候选缺:候选 ≥2 时先问人放弃还是创建,候选 = 1 时必须建。
**库里都有就把 S0d_master_data disable 掉**(此单不走这条),别发空单。

**六步走**:绑定与存在性核对 → (等待数据)→ 人补评估输入 → 标准化对照表 → 逐项评估 → 人审结论(可循环)→ 拟落账 → 人放行。

【本单会用到的卡,全部照下面的骨架发,别自己另编】

① 新客户建档(只在 \`customerCode\` 在 \`customer.profile\` 里查无时,S0_bind 里发;编码用入参给的那个,人核名):
{"kind":"entry","itemId":"S0_bind","ask":"客户 <customerCode> 不在客户档案里,请核对建档信息后确认",
 "ledger":"customer.profile","key":{"customer_code":"<customerCode>"},
 "fields":[
   {"key":"name","label":"客户名称","value":"<customerName,没有就整格不写 value>","required":true},
   {"key":"industry","label":"所属行业"},
   {"key":"region","label":"区域"}
 ]}
人确认那一下基座已把这一行落进草稿层,**你不许再 write 一遍 customer.profile**。

② 候选不在库、候选 ≥2 时,一颗一张决策卡(按候选数组顺序,一次只发一张;候选只有一颗时不问、直接建):
{"kind":"decide","itemId":"S0d_master_data",
 "ask":"候选 <internal_pn> 在芯片主数据里查无记录。是从零建它的主数据(派数据工单),还是本轮放弃这颗候选?",
 "options":[{"value":"create","label":"创建主数据(派数据工单)","recommended":true},
            {"value":"abandon","label":"放弃这颗候选"}],"allowCustom":true}
\`value\` 只有 \`"create"\` / \`"abandon"\` / \`"custom:"\` 开头三种:\`"custom:"\` 开头按放弃处理、\`note\` 就是放弃原因;
点了 \`"abandon"\` 没写原因,\`abandon_reason\` 写「负责人本轮放弃(未注明原因)」。

③ 数据工单 · P-08(所有要建的料号一起,一张单;零输入,填报全在工作区的件上):
{"kind":"human","itemId":"S0d_master_data","workOrder":"execution","card":{
  "title":"数据工单 · 芯片主数据创建(P-08):<料号列表,顿号分隔>",
  "ask":"这些料号在芯片主数据里查无记录,请在工作区的件上把整条记录建齐(一颗一行,可加行)。",
  "steps":["每一行:品牌、规格描述、封装、生命周期状态、标准化参数集为必填,缺一格确认不了",
           "标准化参数集一行一参数:参数名 | 值 | 单位 | hard/soft | 说明;原厂未披露的值写「未披露」,它会进入 UNKNOWN 并挂验证任务",
           "有 datasheet 的把引用与版本一起填(成对);制造商与品牌不一致时分开写",
           "改完保存,回这张卡确认 —— 确认后这批主数据即入库,活动从等待数据唤醒"],
  "standard":"每颗料号一行,必填列全齐;参数集至少含输入电压范围 / 输出电压 / 输出电流 / 工作温度 / 封装相关的硬约束项。",
  "basis":[{"k":"触发原因","v":"按 internal_pn 查 P-08 查无记录(完全没有,不是字段缺失)"},
           {"k":"AI 预填","v":"<有 datasheet 就写「已按 datasheet 预填 N 项,请核对」,没有写「未上传 datasheet,请全填」>"}]}}

④ 数据工单 · P-10(同一批料号的资料记录,一张单;在 P-08 那张确认之后再发):
{"kind":"human","itemId":"S0d_master_data","workOrder":"execution","card":{
  "title":"数据工单 · 原厂技术资料创建(P-10):<料号列表>",
  "ask":"请把这些料号的 datasheet / 承诺函等资料记录补进原厂技术资料库(一份资料一行,可加行)。",
  "steps":["资料类型、标题、版本、文件链接、资料状态为必填;证据定位写页码或章节,差异评估的证据引用会指向它",
           "同一料号多份资料就多加几行,序号 01/02… 自己续",
           "改完保存,回这张卡确认"],
  "standard":"每颗料号至少一行 datasheet 记录,必填列全齐。",
  "basis":[{"k":"触发原因","v":"按 internal_pn 查 P-10 查无记录"}]}}

⑤ 执行工单 01 · 评估输入与资料补齐确认(S1_input_confirm;零输入):
{"kind":"human","itemId":"S1_input_confirm","workOrder":"execution","card":{
  "title":"评估输入与资料补齐确认:<原 PN> → <N> 个候选",
  "ask":"启动绑定的四项已锁定。请在工作区的件上补齐「待补 / 待确认」行的补充内容,默认值可改可确认。",
  "steps":["应用场景、BOM 位是启动时未收的:必须补(写在「补充内容」列)",
           "原厂未披露的参数(如使能阈值)补不到就写「挂验证任务」或「留 UNKNOWN」—— 补不了也是合法答案,不拦流程",
           "替代类型 / 规则版本 / 负责人期限三行是系统默认,可改可不改",
           "加一行 = 补充 AI 没列出的资料项;删一行 = 该项不适用;已绑定的四项不可改",
           "改完保存,回这张卡提交"],
  "standard":"每一条「待补」行的补充内容都有;补不了的写明理由。",
  "basis":[{"k":"已绑定","v":"客户 <customerCode> · 需求 <request_id> V1 · 原 PN <原 PN> · 候选 <N> 颗"},
           {"k":"缺口","v":"<N> 项待补 / 待确认"}]}}

⑥ 验收工单 01 · 差异评估结论审核(S4_review;决策卡循环,不是终局验收)—— 发之前先在轨迹里找有没有已答过、还没处理的 \`[card-reply]\`:
· \`value\` 是 \`"approve"\` → 写 \`${D}.verdict.review_approved_total = 1\`,finish 这一项,别再发卡;
· \`value\` 是 \`"reject"\` 或以 \`"custom:"\` 开头 → 都算驳回:\`"reject"\` 没写意见,**不许再发卡追问**;\`"custom:"\` 的 \`note\` 就是他的意见,
  按意见改 \`substitution.difference_item\` / \`substitution.relation\`(同 PK 覆盖写,不开新行),意见要挂验证的写一行 \`validation.record\`,
  然后**换一种问法**再发一张(措辞不换会被当成同一张卡静默吞掉);
· 没有 → 才发第一张:
{"kind":"decide","itemId":"S4_review","allowCustom":true,
 "ask":"差异评估结论已算出:<N> 个候选、共 <M> 条参数比对(MATCH a / MISMATCH b / UNKNOWN c / CONDITIONAL d),推荐等级 <逐候选一句>。请审核。",
 "options":[{"value":"approve","label":"通过","recommended":true},
            {"value":"reject","label":"驳回(不写具体意见)"}]}
三个 \`value\` 只有 \`"approve"\` / \`"reject"\` / \`"custom:"\` 开头三种,别造第四种。

【一路上要说话】每一拍带一句 say,说业务的话:查了几颗料、缺几颗、对照了几项、四态各几条、推荐等级各是什么。到了要找人的那一拍,先说清这一轮办完了什么、为什么现在需要他。用件上那三个词(必填 / 建议 / 选填)说缺口,只说事实。

【怎么把一项干完 · 一拍连做】write 和 tool 都不会自动收步 —— 做完用 finish 宣告(有 doneWhen 的项引擎自己收)。
动作排进同一拍的 actions 串一口气给;只有发卡 / 提交(挂起)必须停拍;tool 之后同拍其余动作作废。
**常路 9–12 拍收口**(候选缺库那条路多两张数据工单、多两拍)。

§Z 结案:FAE/RI 在验收单上签字,系统把草稿合并进 P-08 / P-10 / P-01 / P-11 / P-06 正式版并发射 \`assessment-released\` 口。`;

/**
 * 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。
 * 四处不给 doneWhen:S0d(发数据工单触达人)、S1(交人补缺口)、S4(拍板且循环)、T_release(签下去)。
 */
export const ASSESSMENT_STEPS = [
  {
    item: 'S0_bind',
    intent: '铸案号、建需求 V1、锁规则版本、逐个料号查库、写存在性核对表',
    writes: ['demand.request', `${D}.pn_check`, `${D}.verdict`, 'customer.profile'],
    reads: ['customerCode', 'customerName', 'requestDescription', 'originalInternalPn', 'candidateInternalPns', 'datasheets', 'case:openedAt', 'customer.profile', 'parts.profile'],
    view: 'eval-input-list',
    tools: [],
    /*
     * 「该有几行」也进判据:原 PN + 每个候选各一行核对(count(candidateInternalPns) + 1);
     * 少一行 = 一颗料从这一单里凭空消失。count() 对未绑定名回 1 —— candidateInternalPns 是必填入参,恒绑定。
     */
    doneWhen:
      `demand_request_rows > 0 and demand_request_missing = 0 and ${D}_verdict_rows > 0 and ` +
      `${D}_pn_check_rows = count(candidateInternalPns) + 1 and ${D}_pn_check_missing = 0`,
    guide: `第一件事:**把这一单的上下文立起来**,一拍连做(客户不在库时多一张录入卡、多一拍)。

【先铸案号】\`eval_ref = 'W05-' + case:openedAt 按 YYYYMMDDHHmmss 格式化\`(\`case:openedAt\` 形如 "2026-08-26 21:03:15",去掉分隔符即得);
\`request_id = 'REQ-' + 同一串数字\`;\`input_snapshot_ref = 'SNAP-' + eval_ref\`。**不许从正式表里别单的案号里挑**,整单只有这一个 eval_ref。

【客户在不在库】在 \`customer.profile\` 里找 \`customerCode\`:在 → 直接用;不在 → 照说明书①发数据录入卡(名字用 \`customerName\`),
发完这一拍结束;人确认后这家客户就在草稿层里了,下一拍接着做。**客户不在库也不许自己 write customer.profile**。

【写 \`demand.request\` 一行(V1)】request_id、customer_code(= customerCode)、requirement_version = "V1"、request_type = "技术需求"、
request_description(入参原话,不改写)、input_snapshot_ref、original_internal_pn、request_status = "pending"、current_activity = "ACT-WORK-05"、
current_stage = "assessing"。application_scenario / bom_position **这一步不写**(启动时未收,S1 由人补)。

【逐个料号查库,写 \`${D}.pn_check\`】\`parts.profile\` 整表在你的作用域里,按 \`internal_pn\` 逐个核对:原 PN 一行(role = "original"),
每个候选一行(role = "candidate");在 → exists_status = "found";不在 → exists_status = "absent"(**这一格先写 absent,等数据工单确认后再改**)。
一颗都不能少:数组里点了几颗候选,就写几行 + 原 PN 一行。note 选填(如「PCN 转 NRND」)。

【写 \`${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。`,
  },
  {
    item: 'S0d_master_data',
    intent: '料号不在库:派数据工单让人从零建整条主数据(P-08 一张、P-10 一张);候选 ≥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 剧本)。**先分岔,再动作**:

【先看轨迹】这一项里已经发过哪些卡、人答了什么(【当前这一步】下面那行引擎事实说得清楚):
· 原 PN absent → **硬阻塞**,不问人,直接进入「建」的名单;
· 候选 absent 且候选数 ≥2 → 每颗 absent 的候选照说明书②各发一张决策卡(一次一张,按数组顺序);人选 "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",
   brand_name 有就写}。
3. 同一拍发说明书③那张 P-08 数据工单(title 里列全料号),发完这一拍结束(挂起)。

【P-08 那张确认回来了】看轨迹里的 [card-reply]:再发说明书④那张 P-10 数据工单(只发一次),发完这一拍结束。

【P-10 那张也确认回来了】把名单里每颗料的 \`${D}.pn_check\` 改成 exists_status = "created"(同 PK 覆盖写),然后 finish 这一项。
这时 S1_input_confirm 的入场前提(全部料号落定、本单建的主数据不缺必填)自己就成立了 —— 缺必填由提交闸兜着,卡回来时一定是齐的。

【这两张卡一单各只发一次】人交回来之后不许再发;答复里没有值(空 values)也算答过 —— 人在件上填的东西已经落草稿层。`,
  },
  {
    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'],
    view: 'eval-input-list',
    tools: [],
    /* 交人补缺口,不给 doneWhen;人工激活:发卡就算启动 */
    guide: `料号全部落定了,**该 PW 补评估输入了** —— 这是本活动唯一的执行工单,零输入。**先分岔**:【当前这一步】那行引擎事实说
「从没给它发过卡」→ 走【发卡】;说「人已经被问过了」→ 走【收口】。

【发卡】先把评估输入清单写下来(\`${D}.input_gap\`,一行一项,item_key 用英文短键,input_item 用人读名),一拍连做:
· 已绑定四项(status = "ok",detail 写绑定值,origin = "ai"):item_key = customer / request / original_pn / candidates
  (候选多颗就 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_<料号>_<序号>,
  input_item 如「XL2596S-5.0 使能阈值」,source_ledger = "P-08",detail 写「原厂未披露(有而不全)」);
· 齐备项(status = "ok"):原 PN 资料版本(source_ledger = "P-10",detail 写 datasheet 版本)。
写完同一拍照说明书⑤发执行工单,发完这一拍结束(挂起)。

【收口】人交回之后只做三件事,然后 finish:
① 读 \`${D}.input_gap\` 现值:把 application_scenario / bom_position 两行的 supplement 抄进 \`demand.request\`(同 PK 覆盖写这两列;
   supplement 写「补不了」「UNKNOWN」之类的,这两列不写);人加的行(origin = "human-added")同等作数,写进 technical_constraints 一句汇总。
② 写 \`${D}.verdict.input_confirmed_total = 1\`。
③ finish 这一项。**别在这一拍做对照表** —— 那是下一步的活。

【这张卡一单只发一次】人交回来之后不许再发;缺必填有提交闸兜着。`,
  },
  {
    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'],
    view: 'difference-review',
    tools: [],
    doneWhen:
      `substitution_relation_rows > 0 and substitution_relation_rows = count(candidateInternalPns) - ${D}_pn_check_exists_status_abandoned and ` +
      'substitution_difference_item_rows > 0 and substitution_difference_item_distinct_substitution_relation_id = substitution_relation_rows',
    guide: `输入齐了,**把多来源资料整成可比对的对照表**,一拍连做(先写关系,再写明细 —— 明细引用关系 id,顺序反了会被拒)。

【每个未放弃的候选一条 \`substitution.relation\` 骨架】(\`${D}.pn_check\` 里 exists_status 是 found / created 的候选;abandoned 的不写)
substitution_relation_id = "REL-<案号数字串>-01/02…"(按候选数组顺序)、eval_ref、request_id、original_internal_pn、candidate_internal_pn、
substitution_type = "pin_to_pin"(人在评估输入清单里改了替代类型就照改:功能替代 → functional、参数替代 → parametric)、
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)、
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 **这一步不写**。

**缺参数不得伪造**:候选那一侧查不到就写「未披露」。写完引擎自己收这一项(每条关系都有明细行才算)。`,
  },
  {
    item: 'S3_assess',
    intent: '逐项差异评估:四态与推荐等级由工具算,证据 / 影响 / UNKNOWN 去向由你写',
    writes: ['substitution.relation', 'substitution.difference_item', 'validation.record', `${D}.verdict`],
    reads: ['substitution.relation', 'substitution.difference_item', 'demand.request', `${D}.input_gap`, `${D}.verdict`],
    view: 'difference-review',
    tools: [TOOL_COMPARE, TOOL_RECOMMEND],
    doneWhen:
      'substitution_difference_item_missing = 0 and substitution_relation_missing = 0 and ' +
      'substitution_difference_item_difference_status_match + substitution_difference_item_difference_status_mismatch + ' +
      'substitution_difference_item_difference_status_unknown + substitution_difference_item_difference_status_conditional = substitution_difference_item_rows and ' +
      'tool_part_substitution_assessment_compare_parameters_calls > 0 and tool_part_substitution_assessment_recommend_calls > 0',
    guide: `对照表铺好了,**固定三拍**:比对 → 定级 → 写盘。**工具调用后同拍其余动作作废**,别把工具和写台账串在一起;两枚工具各只调一次。

【第一拍:只调 \`${TOOL_COMPARE}\`,别的什么都不写】把**全部**关系一次打包:
{"candidates":[{"candidateInternalPn":"<候选>","pairs":[{"parameterName":"输入电压范围","originalValue":"4.5~40","candidateValue":"4.5~32",
  "unit":"V","constraintType":"hard","requirement":">= 40","evidenceRef":"P-08 · P-10 Rev H"}, …]}, …]}
· pairs 逐条照 \`substitution.difference_item\` 抄(parameterName / originalValue / candidateValue / unit / constraintType / evidenceRef);
· **客户在应用场景里给了边界的参数才写 requirement**(如「输入上限 40V」→ 输入电压范围那一对写 ">= 40";「工业温域」→ 工作温度写 "-40~85");
· 每一对都带 evidenceRef —— 硬约束没有证据引用工具不判 MATCH,那正是要的。
调完这一拍结束。

【第二拍:只调 \`${TOOL_RECOMMEND}\`】把第一拍出参里每个候选的 counts 原样传:{"candidates":[{"candidateInternalPn":"…","counts":{…}}, …]}。调完结束。

【第三拍:写盘,一拍连做,一次都不再调工具】
① 逐条 \`substitution.difference_item\` 同 PK 覆盖写:difference_status / compare_result / risk_level 照第一拍出参**一字不改**;
   impact_statement(MATCH 写「无」;其余说清对应用 / 验证 / 报价的影响,如「输入上限 32V < 客户 40V,不能上板」);
   difference_status = unknown 的行**必须**写 unknown_disposition(「挂验证任务 VAL-<案号数字串>-01:实测使能阈值」或「向原厂补来源」);confidence(0~1)。
② 每个 UNKNOWN 写一行 \`validation.record\`:validation_id = "VAL-<案号数字串>-01/02…"、request_id、validation_internal_pn(候选)、
   validation_type = "pin_to_pin"、validation_status = "not_started"、next_validation_action(要测什么、什么条件)、test_condition。
③ 逐条 \`substitution.relation\` 同 PK 覆盖写:recommendation_level / risk_level 照第二拍出参、recommendation_reason(工具 reason 扩写成人话,
   引具体参数)、difference_summary(「7 项比对:4 MATCH / 1 MISMATCH / 1 UNKNOWN / 1 CONDITIONAL」)、analysis_report_ref = "RPT-<案号数字串>"、
   confidence_score(取该候选各行 confidence 的最小值);recommendation_level = conditional 的行**必须**写 verification_requirement(列出 VAL 号)、validation_id。
④ \`${D}.verdict\` 同 PK 覆盖写:relation_total、recommended_total / conditional_total / alternate_total / not_recommended_total(数关系表)。
写完引擎自己收这一项;下一项是审核(S4),**这一拍不要发卡**。

**不许把工具的结论改成别的**:不推荐就是不推荐;要改只能补证据后重比。`,
  },
  {
    item: 'S4_review',
    intent: '差异评估结论审核:决策卡循环,通过或驳回 + AI 按意见改台账重发',
    writes: ['substitution.relation', 'substitution.difference_item', 'validation.record', `${D}.verdict`],
    reads: ['substitution.relation', 'substitution.difference_item', 'validation.record', `${D}.verdict`],
    view: 'difference-review',
    tools: [],
    /* 要人拍板,且这一项内部可能循环 —— 不给 doneWhen */
    guide: `结论算出来了,**该 FAE/RI 审核了** —— 这是验收工单 01,走决策卡(不是终局验收)。**先分岔,再动作**:
轨迹里找这一项已答过、还没处理的 [card-reply]:
· 没有 → 照说明书⑥发第一张决策卡(ask 里把候选数、比对条数、四态各几条、逐候选推荐等级说全),发完这一拍结束。
· "approve" → 写 \`${D}.verdict.review_approved_total = 1\`,finish 这一项。别再发卡。
· "reject" / "custom:…" → **驳回**。按意见改台账(同 PK 覆盖写,不开新行、不改行数):
  · 意见点名某个参数 → 改那一行 \`substitution.difference_item\` 的 impact_statement / unknown_disposition / evidence_ref;要挂验证的补一行 \`validation.record\`;
  · 意见说推荐理由站不住 → 改 \`substitution.relation.recommendation_reason\`(等级不许改 —— 那是工具算的;要改先回 S3 补证据重比,本活动不回头,写清楚为什么维持);
  · 没写具体意见("reject")→ 自己挑最站不住的一处改实(证据最含糊的那条 evidence_ref、去向最空的那条 unknown_disposition),**至少改对一处**;
  然后**换一种问法**再发一张决策卡(把改了什么写进 ask),发完结束。
**基座对着字面比对意见与草稿**:观察里点名了哪一行,就改那一行,不挪到别的行头上。`,
  },
  {
    item: 'S5_prepare_landing',
    intent: '拟落账:P-11 分析状态推到 FAE 确认,回写 P-01 五项',
    writes: ['substitution.relation', 'demand.request', `${D}.verdict`],
    reads: ['substitution.relation', 'substitution.difference_item', 'validation.record', 'demand.request', `${D}.verdict`],
    view: 'landing-confirm',
    tools: [],
    doneWhen:
      'landing_prepared_total > 0 and substitution_relation_analysis_status_fae_confirmed = substitution_relation_rows and ' +
      'substitution_relation_missing = 0 and demand_request_missing = 0',
    guide: `审核通过了,**把拟落账的内容备好**,一拍连做:
① 每条 \`substitution.relation\` 同 PK 覆盖写 analysis_status = "fae_confirmed"(只推分析层;客户批准状态你没有写权,别碰)。
② \`demand.request\` 同 PK 覆盖写五项 + 状态:risk_level(取各候选 risk_level 里最高的一档)、verification_requirement(把所有 VAL 号与要测什么列全;
   没有 UNKNOWN 写「无需验证」)、output_ref = "RPT-<案号数字串>"、next_action = "提交 ACT-GATE-03 评审放行"、current_activity = "ACT-WORK-05"、
   current_stage = "gate03"、request_status = "responded"。
③ \`${D}.verdict\` 同 PK 覆盖写:landing_prepared_total = 1、summary 一句人话(几个候选各什么等级、挂了几个验证任务)。
写完引擎自己收这一项,下一项是放行。**这一拍不要 submit**。`,
  },
  {
    item: 'T_release',
    intent: '拟落账内容已备好,提交 ACT-GATE-03 交 FAE/RI 放行;被打回就改台账再交',
    /* 被打回重做时要改的就是这几张 —— 不声明就写不进去 */
    writes: ['substitution.relation', 'substitution.difference_item', 'validation.record', 'demand.request', `${D}.verdict`],
    reads: ['substitution.relation', 'substitution.difference_item', 'validation.record', 'demand.request', `${D}.verdict`],
    view: 'landing-confirm',
    tools: [],
    /* 放行触达世界,必须人按一下 —— 引擎不自动收它 */
    guide: `落账确认那一屏摆着拟写 P-11 主记录与拟回写 P-01。**先看轨迹里有没有「验收被打回重做」**:
有 → 这一拍不是再提交一遍。照意见改**台账本身**(同 PK 覆盖写,不开新行):
· 他说推荐等级与明细不符 → 核对 \`substitution.relation.recommendation_level\` 与该关系明细的四态计数,不符的把 recommendation_reason 改实、把漏挂的验证要求补上;
· 他说 P-01 回写不全 → 补 \`demand.request\` 那五项;
· 他说证据不可信 → 改那几行 \`substitution.difference_item.evidence_ref\`(指到具体资料版本与页码);
· 没写具体意见 → 自己挑最站不住的一处改实,至少改对一处;
改完这一拍再 judge + submit。**基座那道闸:打回之后台账一个字没改就不许再提交。**

没被打回(或已改过)→ 先裁三条裁量判据,然后 submit,同一拍连做:
· judge criterion="证据链可信:每条结论都能追溯到 P-08 / P-10 / P-06 的具体记录,或本单数据工单的创建记录(人工录入)" —— 逐条看 evidence_ref 指不指得到具体记录;
· judge criterion="硬约束项未被自动判 MATCH:封装 / 工艺 / 电气边界等硬约束的 MATCH 都有证据支持" —— 看 hard 且 match 的行 evidence_ref 是否非空且具体;
· judge criterion="推荐等级与差异明细一致:存在 UNKNOWN / CONDITIONAL 的候选没有给「推荐」" —— 逐候选核对;
**裁成不满足照样能提交** —— 理由连着卡一起交给签字的人。criterion 照判据名整句原样抄。
这一单没有分支去向,只发一口 \`assessment-released\`,不必你选出口。终局唯放行:你不能自己宣布完成。`,
  },
];

/**
 * 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段;
 * 放行那一格由基座统一加,人看到的是 **x/6** —— 与文档的步骤 0.5 / 1 / 2 / 3 / 4 / 5 六步逐格对齐:
 *   0.5 启动与存在性检查(等待数据)· 1 建立评估上下文 · 2 读取并标准化 · 3 逐项差异评估 · 4 FAE 审核与补证(含拟落账)· 5 提交评审放行(基座那格)
 */
export const ASSESSMENT_PHASES = [
  { name: '启动与存在性检查', items: ['S0_bind', 'S0d_master_data'] },
  { name: '建立评估上下文', items: ['S1_input_confirm'] },
  { name: '读取并标准化资料', items: ['S2_normalize'] },
  { name: '逐项差异评估', items: ['S3_assess'] },
  { name: 'FAE 审核与补证', items: ['S4_review', 'S5_prepare_landing'] },
];
  • Step 2: 写剧本自测 __tests__/steps.test.ts
/**
 * 步骤剧本与呈现面 —— 这段错了不报错,只是活动开始转圈 / 界面开始撒谎(与 E04 / I01 同一套惯例)。
 */
import { describe, expect, it } from 'vitest';
import { evaluateCondition } from '@sfeel';
import { ASSESSMENT_PHASES, ASSESSMENT_STEPS, CMMN_XML, NARRATIVE } from '../src/case-model.ts';
import { manifest, D } from '../src/manifest.ts';

const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map((m) => m[1] ?? '');

function doneOf(item: string): string {
  const found = ASSESSMENT_STEPS.find((s) => s.item === item)?.doneWhen;
  if (found === undefined) throw new Error(`${item} 没有 doneWhen`);
  return found;
}
/** 谓词写坏了引擎当作「没算出来」——测里也照这个口径 */
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
  try {
    return evaluateCondition(expression, scope);
  } catch {
    return false;
  }
}
/** 开局局面:入参 + 所有基座读数归零 */
const fresh = (input: Record<string, unknown>): Record<string, unknown> => ({
  ...input,
  demand_request_rows: 0,
  demand_request_missing: 0,
  [`${D}_verdict_rows`]: 0,
  [`${D}_pn_check_rows`]: 0,
  [`${D}_pn_check_missing`]: 0,
  [`${D}_pn_check_exists_status_absent`]: 0,
  [`${D}_pn_check_exists_status_abandoned`]: 0,
  [`${D}_input_gap_rows`]: 0,
  [`${D}_input_gap_missing`]: 0,
  substitution_relation_rows: 0,
  substitution_relation_missing: 0,
  substitution_relation_analysis_status_fae_confirmed: 0,
  substitution_difference_item_rows: 0,
  substitution_difference_item_missing: 0,
  substitution_difference_item_distinct_substitution_relation_id: 0,
  substitution_difference_item_difference_status_match: 0,
  substitution_difference_item_difference_status_mismatch: 0,
  substitution_difference_item_difference_status_unknown: 0,
  substitution_difference_item_difference_status_conditional: 0,
  tool_part_substitution_assessment_compare_parameters_calls: 0,
  tool_part_substitution_assessment_recommend_calls: 0,
});

describe('剧本与案例模型对得上', () => {
  it('每一条剧本都指向一个真实存在的计划项,图上每一步也都有剧本', () => {
    for (const step of ASSESSMENT_STEPS) expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
    for (const id of ITEM_IDS) expect(ASSESSMENT_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
  });

  it('清单里挂的就是这份剧本与这份阶段', () => {
    expect(manifest.assets.businessModel.steps).toBe(ASSESSMENT_STEPS);
    expect(manifest.assets.businessModel.phases).toBe(ASSESSMENT_PHASES);
  });
});

describe('剧本声明的读写面 ⊆ 清单声明', () => {
  const declaredAddresses = new Set(manifest.declarations.ledger.map((d) => `${d.domain}.${d.object}`));
  const declaredTools = new Set(manifest.declarations.tools.map((t) => t.name));

  it('steps[].writes / reads(台账那几个)全落在声明过的对象上', () => {
    for (const step of ASSESSMENT_STEPS) {
      for (const address of step.writes) expect(declaredAddresses.has(address), `${step.item} 要写 ${address}`).toBe(true);
      for (const address of step.reads.filter((r) => r.includes('.') && !r.startsWith('case:'))) {
        expect(declaredAddresses.has(address), `${step.item} 要读 ${address}`).toBe(true);
      }
    }
  });

  it('steps[].tools 全部是声明过的工具;只有 S3 调工具', () => {
    for (const step of ASSESSMENT_STEPS) {
      for (const tool of step.tools) expect(declaredTools.has(tool), `${step.item} 要调 ${tool}`).toBe(true);
      if (step.item !== 'S3_assess') expect(step.tools, step.item).toEqual([]);
    }
    expect(ASSESSMENT_STEPS.find((s) => s.item === 'S3_assess')?.tools).toHaveLength(2);
  });

  it('customer_approval_status 在任何剧本里都不许写(只读边界)', () => {
    for (const step of ASSESSMENT_STEPS) expect(step.guide, step.item).not.toMatch(/customer_approval_status\s*=/);
  });
});

describe('doneWhen —— 开局不许真,干完必须真', () => {
  it('**开局一步都不该被自动收掉** —— 五条样例的入参形状逐一试', () => {
    for (const sample of manifest.samples) {
      for (const step of ASSESSMENT_STEPS) {
        if (step.doneWhen === undefined) continue;
        expect(safeEval(step.doneWhen, fresh(sample.input as Record<string, unknown>)), `样例「${sample.name}」下 ${step.item} 开局就为真`).toBe(false);
      }
    }
  });

  it('S0:两颗候选 + 原 PN = 3 行核对齐、需求与结论都写了 → 收得掉;少写一颗 → 收不掉', () => {
    const base = { candidateInternalPns: ['XL2596S-5.0', 'LM2596S-ADJ'], demand_request_rows: 1, demand_request_missing: 0, [`${D}_verdict_rows`]: 1, [`${D}_pn_check_missing`]: 0 };
    expect(safeEval(doneOf('S0_bind'), { ...base, [`${D}_pn_check_rows`]: 3 })).toBe(true);
    expect(safeEval(doneOf('S0_bind'), { ...base, [`${D}_pn_check_rows`]: 2 })).toBe(false);
  });

  it('S2:未放弃的候选各一条关系且每条都有明细 → 收得掉;放弃一颗后关系数随之减一', () => {
    const two = { candidateInternalPns: ['A', 'B'], [`${D}_pn_check_exists_status_abandoned`]: 0, substitution_difference_item_rows: 14 };
    expect(safeEval(doneOf('S2_normalize'), { ...two, substitution_relation_rows: 2, substitution_difference_item_distinct_substitution_relation_id: 2 })).toBe(true);
    expect(safeEval(doneOf('S2_normalize'), { ...two, substitution_relation_rows: 2, substitution_difference_item_distinct_substitution_relation_id: 1 })).toBe(false);
    expect(safeEval(doneOf('S2_normalize'), { ...two, [`${D}_pn_check_exists_status_abandoned`]: 1, substitution_relation_rows: 1, substitution_difference_item_distinct_substitution_relation_id: 1 })).toBe(true);
  });

  it('S3:四态之和 = 明细行数、必填齐、两枚工具都调过 → 收得掉;工具没调过 → 收不掉', () => {
    const ok = {
      substitution_difference_item_missing: 0,
      substitution_relation_missing: 0,
      substitution_difference_item_rows: 7,
      substitution_difference_item_difference_status_match: 4,
      substitution_difference_item_difference_status_mismatch: 1,
      substitution_difference_item_difference_status_unknown: 1,
      substitution_difference_item_difference_status_conditional: 1,
      tool_part_substitution_assessment_compare_parameters_calls: 1,
      tool_part_substitution_assessment_recommend_calls: 1,
    };
    expect(safeEval(doneOf('S3_assess'), ok)).toBe(true);
    expect(safeEval(doneOf('S3_assess'), { ...ok, tool_part_substitution_assessment_recommend_calls: 0 })).toBe(false);
    expect(safeEval(doneOf('S3_assess'), { ...ok, substitution_difference_item_difference_status_match: 3 })).toBe(false);
  });

  it('S5:旗 + 全部关系 FAE 确认 + P-01 回写齐 → 收得掉;缺一头都收不掉', () => {
    const ok = { landing_prepared_total: 1, substitution_relation_analysis_status_fae_confirmed: 2, substitution_relation_rows: 2, substitution_relation_missing: 0, demand_request_missing: 0 };
    expect(safeEval(doneOf('S5_prepare_landing'), ok)).toBe(true);
    expect(safeEval(doneOf('S5_prepare_landing'), { ...ok, substitution_relation_analysis_status_fae_confirmed: 1 })).toBe(false);
    expect(safeEval(doneOf('S5_prepare_landing'), { ...ok, demand_request_missing: 1 })).toBe(false);
  });
});

describe('说明书与剧本的骨架', () => {
  it('五张卡的骨架都住在说明书里(压拍之后只有说明书还在)', () => {
    expect(NARRATIVE).toContain('"kind":"entry","itemId":"S0_bind"');
    expect(NARRATIVE).toContain('"kind":"decide","itemId":"S0d_master_data"');
    expect(NARRATIVE).toContain('"title":"数据工单 · 芯片主数据创建(P-08)');
    expect(NARRATIVE).toContain('"title":"数据工单 · 原厂技术资料创建(P-10)');
    expect(NARRATIVE).toContain('"title":"评估输入与资料补齐确认');
    expect(NARRATIVE).toContain('"kind":"decide","itemId":"S4_review"');
  });

  it('剧本里不引反例原话;S3 固定三拍且写明「工具后同拍作废」', () => {
    const s3 = ASSESSMENT_STEPS.find((s) => s.item === 'S3_assess')?.guide ?? '';
    expect(s3).toContain('第一拍');
    expect(s3).toContain('第二拍');
    expect(s3).toContain('第三拍');
    expect(s3).toContain('工具调用后同拍其余动作作废');
  });

  it('S0 剧本写明库里都有就 disable S0d;S0d 剧本写明候选 ≥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(s0d).toContain('硬阻塞');
    expect(s0d).toContain('候选只有 1 颗');
  });

  it('五段进度,每段名 ≤10 字;除放行外每项恰属一段', () => {
    expect(ASSESSMENT_PHASES).toHaveLength(5);
    for (const p of ASSESSMENT_PHASES) expect(p.name.length, p.name).toBeLessThanOrEqual(10);
    const all = ASSESSMENT_PHASES.flatMap((p) => p.items);
    expect(new Set(all).size).toBe(all.length);
    expect([...all].sort()).toEqual([...ITEM_IDS.filter((i) => i !== 'T_release')].sort());
  });
});
  • Step 3: 跑包内测试

Run: pnpm vitest run activities/part-substitution-assessment Expected:steps.test 全绿;activity.test 里除「报告模版」组外全绿(模版仍是占位)。

  • Step 4: 提交
git add activities/part-substitution-assessment
git commit -m "feat(w05): 差异评估案例模型 —— CMMN 八项七哨兵、说明书五卡骨架、八条剧本、五段进度

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh"

Task 5: 五张 HTML 模版(目标件平移)

Files:

  • Replace: activities/part-substitution-assessment/src/views/report.ts(整份替换 Task 3 的占位)
  • Test: activities/part-substitution-assessment/__tests__/activity.test.ts(Task 3 已写好的「报告模版」组此时转绿)

Interfaces:

  • Consumes:Task 3 的台账列名 / tally 值;基座读数名 parts_profile_rows / parts_profile_missing / parts_profile_missing_keys、parts_document_*、sub_eval_input_gap_*、substitution_relation_rows、substitution_difference_item_rows、四态计数;引擎注入 case:owner / case:taskId;摊平列 eval_ref / request_id / rule_version / original_internal_pn;入参 customerCode / requestDescription / originalInternalPn。

  • Produces:P08_MASTER_CREATE / P10_DOCUMENT_CREATE / EVAL_INPUT_LIST / DIFFERENCE_REVIEW / LANDING_CONFIRM(字符串)。

  • 平移法(《HTML报告生成规范》§7):目标件 docs/SS02-ACT-WORK-05-html/*.html 的版面语言原样搬(它与 E04/I01 目标件同一套壳,prd-style.ts 已逐字节持有);把设计稿记号换成平台绑定:data-rows="in.*/out.*" → data-rows="mine:域.对象";data-field → data-slot + 可改格加 data-col;meta.task.owner/id → case:owner/case:taskId;sd.* 读数 → 基座读数;card.deadline(本仓没有工单期限这个事实)与 card.id 单号不摆;目标件里 JS 造的行 / 算的计数一律换成 data-rows + 基座读数。

  • Step 1: 写 src/views/report.ts

先把 I01 activities/brand-content-toolkit/src/views/report.ts 里 const BIND_CSS = \`` 到它的闭合反引号(/** 三层壳照目标件 */之前)整段**原样复制**过来作为本文件的BIND_CSS`(数据驱动的开关全在那里:三档占位 / 缺项红带 / 编辑闸 / 增删行 / 状态徽基座);然后按下面写其余内容:

/**
 * ACT-WORK-05 差异评估的 HTML 报告模版 —— **活动自己持有的资产**,随包版本冻结。
 *
 * 五个局面各一张:
 * · `p08-master-create` —— S0d 摆,数据工单(P-08 芯片主数据),可加行、可编辑;
 * · `p10-document-create` —— S0d 摆,数据工单(P-10 原厂技术资料),可加行、可编辑;
 * · `eval-input-list` —— S0/S1 摆,评估输入清单:绑定区只读 + 缺口表可编辑;
 * · `difference-review` —— S2/S3/S4 摆,各候选推荐等级 + 差异明细,**只读**(通过/驳回在对话区决策卡);
 * · `landing-confirm` —— S5/T_release 摆,拟写 P-11 / 拟回写 P-01,**只读**,放行后即归档件。
 *
 * 版面语言逐字量自 `docs/SS02-ACT-WORK-05-html/` 四份目标件;取值三纪律:顶层只取汇总列与单值列,
 * 逐条走 `mine:域.对象`,列名逐字照 `declarations.ledger` 抄。**模版不算不判**。
 */
import { PRD_CSS } from './prd-style.ts';
import { D } from '../constants.ts';

/* ==================== 绑定期补的样式(从 I01 report.ts 的 BIND_CSS 原样复制)==================== */
const BIND_CSS = `…(I01 那一段,原样)…`;

/* ==================== 本活动的枚举徽标与参数集格子 ==================== */
const EXTRA_CSS = `
/* 参数集一行一参数:只读那一份按行换行显示 */
.proto-root .jc-pre { white-space: pre-line; font-family: var(--mono); font-size: 12px; }
.proto-root textarea.jc-cell { min-height: 120px; font-family: var(--mono); font-size: 12px; }
/* 徽标按列作用域取人读名(同一个 v-conditional 在推荐等级与四态里是两个词) */
.proto-root .jc-tag.t-role.v-original::after { content: "原 PN"; }
.proto-root .jc-tag.t-role.v-candidate::after { content: "候选"; }
.proto-root .jc-tag.t-exist.v-found::after { content: "在库"; }
.proto-root .jc-tag.t-exist.v-absent::after { content: "不在库"; }
.proto-root .jc-tag.t-exist.v-created::after { content: "本单新建"; }
.proto-root .jc-tag.t-exist.v-abandoned::after { content: "已放弃"; }
.proto-root .jc-tag.t-exist.v-absent { color: var(--danger); background: var(--danger-soft); }
.proto-root .jc-tag.t-exist.v-created { color: var(--ok); background: var(--ok-soft); }
.proto-root .jc-tag.t-gap.v-gap::after { content: "待补*"; }
.proto-root .jc-tag.t-gap.v-default::after { content: "默认·可改"; }
.proto-root .jc-tag.t-gap.v-ok::after { content: "齐备"; }
.proto-root .jc-tag.t-gap.v-gap { color: var(--danger); background: var(--danger-soft); }
.proto-root .jc-tag.t-gap.v-ok { color: var(--ok); background: var(--ok-soft); }
.proto-root .jc-tag.t-life.v-active::after { content: "Active"; }
.proto-root .jc-tag.t-life.v-nrnd::after { content: "NRND"; }
.proto-root .jc-tag.t-life.v-eol::after { content: "EOL"; }
.proto-root .jc-tag.t-life.v-discontinued::after { content: "停产"; }
.proto-root .jc-tag.t-life.v-risk::after { content: "风险"; }
.proto-root .jc-tag.t-doc.v-datasheet::after { content: "Datasheet"; }
.proto-root .jc-tag.t-doc.v-app_note::after { content: "应用笔记"; }
.proto-root .jc-tag.t-doc.v-reference_design::after { content: "参考设计"; }
.proto-root .jc-tag.t-doc.v-white_paper::after { content: "白皮书"; }
.proto-root .jc-tag.t-doc.v-sample_doc::after { content: "样片资料"; }
.proto-root .jc-tag.t-doc.v-commitment_letter::after { content: "承诺函"; }
.proto-root .jc-tag.t-doc.v-other::after { content: "其他"; }
.proto-root .jc-tag.t-docst.v-valid::after { content: "有效"; }
.proto-root .jc-tag.t-docst.v-expired::after { content: "过期"; }
.proto-root .jc-tag.t-docst.v-pending::after { content: "待核实"; }
.proto-root .jc-tag.t-docst.v-void::after { content: "作废"; }
.proto-root .jc-tag.t-sub.v-pin_to_pin::after { content: "Pin-to-Pin"; }
.proto-root .jc-tag.t-sub.v-functional::after { content: "功能替代"; }
.proto-root .jc-tag.t-sub.v-parametric::after { content: "参数替代"; }
.proto-root .jc-tag.t-sub.v-other::after { content: "其他"; }
.proto-root .jc-tag.t-rec.v-recommended::after { content: "推荐"; }
.proto-root .jc-tag.t-rec.v-conditional::after { content: "条件推荐"; }
.proto-root .jc-tag.t-rec.v-alternate::after { content: "备选"; }
.proto-root .jc-tag.t-rec.v-not_recommended::after { content: "不推荐"; }
.proto-root .jc-tag.t-rec.v-recommended { color: var(--ok); background: var(--ok-soft); }
.proto-root .jc-tag.t-rec.v-conditional { color: var(--warn-accent); background: var(--warn-soft); }
.proto-root .jc-tag.t-rec.v-not_recommended { color: var(--danger); background: var(--danger-soft); }
.proto-root .jc-tag.t-anal.v-ai_draft::after { content: "AI 草稿"; }
.proto-root .jc-tag.t-anal.v-fae_confirmed::after { content: "FAE 确认"; }
.proto-root .jc-tag.t-anal.v-pending_customer::after { content: "待客户验证"; }
.proto-root .jc-tag.t-anal.v-customer_approved::after { content: "客户已批准"; }
.proto-root .jc-tag.t-anal.v-rejected::after { content: "不批准"; }
.proto-root .jc-tag.t-anal.v-void::after { content: "作废"; }
.proto-root .jc-tag.t-anal.v-fae_confirmed { color: var(--ok); background: var(--ok-soft); }
.proto-root .jc-tag.t-diff.v-match::after { content: "MATCH"; }
.proto-root .jc-tag.t-diff.v-mismatch::after { content: "MISMATCH"; }
.proto-root .jc-tag.t-diff.v-unknown::after { content: "UNKNOWN"; }
.proto-root .jc-tag.t-diff.v-conditional::after { content: "CONDITIONAL"; }
.proto-root .jc-tag.t-diff.v-match { color: var(--ok); background: var(--ok-soft); }
.proto-root .jc-tag.t-diff.v-mismatch { color: var(--danger); background: var(--danger-soft); }
.proto-root .jc-tag.t-diff.v-unknown { color: var(--ink-3); background: var(--soft); }
.proto-root .jc-tag.t-diff.v-conditional { color: var(--warn-accent); background: var(--warn-soft); }
.proto-root .jc-tag.t-con.v-hard::after { content: "硬约束"; }
.proto-root .jc-tag.t-con.v-soft::after { content: "软约束"; }
.proto-root .jc-tag.t-con.v-hard { color: var(--danger); background: var(--danger-soft); }
.proto-root .jc-tag.t-risk.v-low::after { content: "低"; }
.proto-root .jc-tag.t-risk.v-medium::after { content: "中"; }
.proto-root .jc-tag.t-risk.v-high::after { content: "高"; }
.proto-root .jc-tag.t-risk.v-critical::after { content: "关键"; }
.proto-root .jc-tag.t-risk.v-low { color: var(--ok); background: var(--ok-soft); }
.proto-root .jc-tag.t-risk.v-medium { color: var(--warn-accent); background: var(--warn-soft); }
.proto-root .jc-tag.t-risk.v-high, .proto-root .jc-tag.t-risk.v-critical { color: var(--danger); background: var(--danger-soft); }
/* 绑定区:四项锁定的灰底只读行 */
.proto-root .jc-bound { display: grid; grid-template-columns: 140px 1fr; gap: 6px 14px; padding: 10px 14px; margin: 8px 0 14px; background: var(--soft); border: 1px solid var(--line-soft); border-radius: 9px; font-size: 13px; }
.proto-root .jc-bound b { color: var(--ink-3); font-weight: 600; }
`;

/** 三层壳照目标件(收件人 / 关联任务两格由引擎注入;目标件的「最迟交回」与单号本仓没有真数据源,不摆) */
const doc = (input: { tone: 'human' | 'ledger'; badge: string; tag: string; kicker: string; title: string; step: string; state: string; body: string; foot: string }): string =>
  `<style>${PRD_CSS}${BIND_CSS}${EXTRA_CSS}</style>
<div class="proto-root"><div class="app-shell"><div class="jc-stage">
  <article class="jc-report tone-${input.tone}">
    <header class="jc-rp-head">
      <div class="jc-rp-badge">${input.badge}</div>
      <div class="jc-rp-tit">
        <em>${input.kicker}</em>
        <h3>${input.title}</h3>
        <div class="jc-rp-sub">收件人:<span data-slot="case:owner"></span> · 关联任务:<span class="jc-rp-taskid" data-slot="case:taskId" data-blank></span></div>
      </div>
      <div class="jc-rp-side">
        <span class="jc-rp-tag">${input.tag}</span>
        <span class="jc-rp-step">${input.step}</span>
        ${input.state}
      </div>
    </header>
    <div class="jc-rp-bd">${input.body}</div>
    <footer class="jc-rp-foot">${input.foot}</footer>
  </article>
</div></div></div>`;

const part = (label: string, aside = ''): string => `<p class="jc-rp-part"><b>【${label}】</b>${aside === '' ? '' : ` —— ${aside}`}</p>`;
const criteria = (title: string, items: readonly string[]): string =>
  `<div class="jc-criteria"><div class="jc-criteria-t">${title}</div><ul>${items.map((i) => `<li>${i}</li>`).join('')}</ul></div>`;
const flow = (text: string): string => `<div class="jc-flow">${text}</div>`;
/** 页脚右侧「工单类型:× · <案号>」 */
const sign = (note: string, tag: string): string => `<span>${note}</span><span>${tag} · <span data-slot="eval_ref" data-blank></span></span>`;
const editGate = (address: string, label: string): string => `<div class="jc-editgate" data-edit-for="${address}">
  <span class="jc-editgate-t">${label}</span>
  <button type="button" class="jc-egbtn open" data-edit-open="${address}">编辑</button>
  <button type="button" class="jc-egbtn ghost" data-edit-cancel="${address}">取消</button>
  <button type="button" class="jc-egbtn save" data-edit-save="${address}">保存</button>
</div>`;
const gapbar = (object: string, what: string): string => `<div class="jc-gapbar" data-class-of="${object}_missing">
  <span><b>还差 <span data-slot="${object}_missing"></span> 行${what}没填</b> —— <span data-slot="${object}_missing_keys"></span>。按「编辑」开表,红框那几格就是要填的。补齐了才确认得了。</span>
</div>`;
const tag = (scope: string, col: string): string => `<span class="jc-tag ${scope}" data-class-of="${col}"></span>`;
const cell = (col: string, cls = ''): string => `<span class="jc-vcell${cls === '' ? '' : ` ${cls}`}" data-slot="${col}" data-col="${col}" data-blank></span>`;
const ro = (col: string, cls = ''): string => `<span class="jc-vcell${cls === '' ? '' : ` ${cls}`}" data-slot="${col}" data-blank></span>`;
const select = (col: string, options: ReadonlyArray<readonly [string, string]>, required = false): string =>
  `<select class="jc-cell" data-col="${col}"${required ? ' required' : ''}><option value=""></option>${options.map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}</select>`;

/* ==================== 局面 0 · 数据工单 P-08 ==================== */

const P08_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
  <thead><tr>
    <th style="width:36px;min-width:36px"></th>
    <th class="req" style="min-width:140px">内部料号</th>
    <th class="req" style="min-width:100px">品牌</th>
    <th class="sug" style="min-width:120px">制造商</th>
    <th class="req" style="min-width:220px">规格描述</th>
    <th class="req" style="min-width:110px">封装</th>
    <th class="sug" style="min-width:90px">工艺</th>
    <th class="req" style="min-width:120px">生命周期</th>
    <th class="sug" style="min-width:150px">EOL / PCN</th>
    <th class="sug" style="min-width:200px">Datasheet 引用</th>
    <th class="sug" style="min-width:110px">Datasheet 版本</th>
    <th class="sug" style="min-width:140px">适配场景</th>
    <th class="req" style="min-width:320px">标准化参数集(一行一参数:参数名 | 值 | 单位 | hard/soft | 说明)</th>
  </tr></thead>
  <tbody data-rows="mine:parts.profile" data-edit="parts.profile"><tr>
    <td class="jc-rn"><button type="button" class="jc-rowdel" data-edit-del="parts.profile" title="删一行 = 该记录不建">×</button></td>
    <td class="req" data-h="内部料号">${cell('internal_pn', 'mono')}</td>
    <td class="req" data-h="品牌">${cell('brand_name')}</td>
    <td class="sug" data-h="制造商">${cell('manufacturer_name')}</td>
    <td class="req" data-h="规格描述">${cell('specification_description')}</td>
    <td class="req" data-h="封装">${cell('package', 'mono')}</td>
    <td class="sug" data-h="工艺">${cell('process_technology')}</td>
    <td class="req" data-h="生命周期">${select('lifecycle_status', [['active', 'Active'], ['nrnd', 'NRND'], ['eol', 'EOL'], ['discontinued', '停产'], ['risk', '风险']], true)}${tag('t-life', 'lifecycle_status')}</td>
    <td class="sug" data-h="EOL / PCN">${cell('eol_pcn_status')}</td>
    <td class="sug" data-h="Datasheet 引用">${cell('datasheet_ref', 'mono')}</td>
    <td class="sug" data-h="Datasheet 版本">${cell('datasheet_version')}</td>
    <td class="sug" data-h="适配场景">${cell('application_scenarios')}</td>
    <td class="req" data-h="标准化参数集"><span class="jc-vcell jc-pre" data-slot="normalized_parameter_set" data-blank></span><textarea class="jc-cell" data-col="normalized_parameter_set" placeholder="输入电压范围 | 4.5~40 | V | hard | 客户上限 40V&#10;输出电压 | 5.0 | V | hard | 固定版&#10;使能阈值 | 未披露 | V | hard | 原厂未给"></textarea></td>
  </tr></tbody>
</table></div>`;

export const P08_MASTER_CREATE = doc({
  tone: 'human',
  badge: '数据',
  tag: '数据工单',
  kicker: 'SS02 · ACT-WORK-05 差异评估 · 数据工单(P-08)',
  title: '芯片主数据创建',
  step: '步骤 0.5 · 等待数据',
  state: `<span class="jc-rp-wait warn">待建 <b data-slot="parts_profile_missing"></b> / <b data-slot="parts_profile_rows"></b> 行</span>`,
  body:
    part('做什么', '批量创建 P-08 主数据记录') +
    `<div class="jc-derive">
      <p><span class="need">模板定位</span>:本单是「数据工单」的实例 —— <b>一单一类(可多条)</b>:目标台账 = <b>芯片主数据(P-08)</b>;
      <b>字段即表头、每行一条新记录</b>;标准化参数集是这条记录的一个字段(一行一参数)。</p>
      <p><span class="need">触发原因</span>:系统按内部料号查芯片主数据<b>查无记录</b> —— 是「完全没有」,不是字段缺失(那归执行工单)。</p>
      <p><span class="done">AI 预填</span>:上传了 datasheet 的料号 AI 已按资料预填,标着值的格子请核对;没上传的料号全部手填,同为合法路径。</p>
      <p><span class="need">纪律</span>:必填列全齐才允许确认;确认后这批记录进本单草稿,放行时并入正式库并留痕(确认人 = 数据责任人);只建主数据,验证记录等事实类不在此建。</p>
    </div>` +
    part('创建什么', '主数据记录(每行一条,*为必填列)') +
    gapbar('parts_profile', '必填') +
    editGate('parts.profile', `<b>待建 <span data-slot="parts_profile_rows"></span> 条记录</b>,点「编辑」核对预填、补齐空缺;「+ 加一行」= 再建一条`) +
    `<div class="jc-editbar"><span>必填列(*)全齐才允许确认;原厂未披露的参数值请直接写「未披露」,它会进入 UNKNOWN 并挂验证任务;删一行 = 该记录不建。改动只落任务草稿层,<b>保存不等于放行</b>。</span></div>` +
    P08_TABLE +
    `<div class="jc-addrow">
      <button type="button" class="jc-egbtn ghost" data-edit-add="parts.profile">+ 加一行(再建一条记录)</button>
      <em>新行的内部料号必须写(它是这一行的钥匙);其余必填列全部填写后才允许确认。</em>
    </div>` +
    flow('<b>提交 / 确认:</b>本工单只承载内容;确认请在对话区的【执行卡】中进行。确认后活动自动从「等待数据」唤醒。'),
  foot: sign('SS02 · ACT-WORK-05 差异评估 · 数据工单 · 芯片主数据创建(P-08)', '工单类型:数据'),
});

/* ==================== 局面 0′ · 数据工单 P-10 ==================== */

const P10_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
  <thead><tr>
    <th style="width:36px;min-width:36px"></th>
    <th class="req" style="min-width:140px">关联料号</th>
    <th class="req" style="width:70px;min-width:70px">序号</th>
    <th class="req" style="min-width:120px">资料类型</th>
    <th class="sug" style="min-width:90px">品牌</th>
    <th class="req" style="min-width:220px">资料标题</th>
    <th class="req" style="min-width:110px">版本</th>
    <th class="req" style="min-width:220px">文件链接</th>
    <th class="sug" style="min-width:180px">证据定位</th>
    <th class="req" style="min-width:100px">状态</th>
  </tr></thead>
  <tbody data-rows="mine:parts.document" data-edit="parts.document"><tr>
    <td class="jc-rn"><button type="button" class="jc-rowdel" data-edit-del="parts.document" title="删一行 = 该资料不建">×</button></td>
    <td class="req" data-h="关联料号">${cell('internal_pn', 'mono')}</td>
    <td class="req" data-h="序号">${cell('doc_seq', 'mono')}</td>
    <td class="req" data-h="资料类型">${select('document_type', [['datasheet', 'Datasheet'], ['app_note', '应用笔记'], ['reference_design', '参考设计'], ['white_paper', '白皮书'], ['sample_doc', '样片资料'], ['commitment_letter', '承诺函'], ['other', '其他']], true)}${tag('t-doc', 'document_type')}</td>
    <td class="sug" data-h="品牌">${cell('brand_name')}</td>
    <td class="req" data-h="资料标题">${cell('document_title')}</td>
    <td class="req" data-h="版本">${cell('document_version')}</td>
    <td class="req" data-h="文件链接">${cell('document_ref', 'mono')}</td>
    <td class="sug" data-h="证据定位">${cell('citation_locator')}</td>
    <td class="req" data-h="状态">${select('document_status', [['valid', '有效'], ['expired', '过期'], ['pending', '待核实'], ['void', '作废']], true)}${tag('t-docst', 'document_status')}</td>
  </tr></tbody>
</table></div>`;

export const P10_DOCUMENT_CREATE = doc({
  tone: 'human',
  badge: '数据',
  tag: '数据工单',
  kicker: 'SS02 · ACT-WORK-05 差异评估 · 数据工单(P-10)',
  title: '原厂技术资料创建',
  step: '步骤 0.5 · 等待数据',
  state: `<span class="jc-rp-wait warn">待建 <b data-slot="parts_document_missing"></b> / <b data-slot="parts_document_rows"></b> 行</span>`,
  body:
    part('做什么', '为本单新建的料号登记原厂资料(P-10)') +
    `<div class="jc-derive">
      <p><span class="need">触发原因</span>:按内部料号查原厂技术资料库<b>查无记录</b>。一份资料一行;同一料号多份资料序号 01/02… 自己续。</p>
      <p><span class="need">为什么要它</span>:差异明细的证据引用要指到具体资料的版本与页码 —— 没有这一行,结论就追溯不到来源。</p>
    </div>` +
    part('创建什么', '资料记录(每行一条,*为必填列)') +
    gapbar('parts_document', '必填') +
    editGate('parts.document', `<b>待建 <span data-slot="parts_document_rows"></span> 条资料记录</b>,点「编辑」补齐`) +
    `<div class="jc-editbar"><span>资料类型、标题、版本、文件链接、状态为必填;证据定位写页码或章节。改动只落任务草稿层,<b>保存不等于放行</b>。</span></div>` +
    P10_TABLE +
    `<div class="jc-addrow">
      <button type="button" class="jc-egbtn ghost" data-edit-add="parts.document">+ 加一行(再登记一份资料)</button>
      <em>关联料号与序号是这一行的钥匙,新行都要写;必填列全齐才允许确认。</em>
    </div>` +
    flow('<b>提交 / 确认:</b>本工单只承载内容;确认请在对话区的【执行卡】中进行。'),
  foot: sign('SS02 · ACT-WORK-05 差异评估 · 数据工单 · 原厂技术资料创建(P-10)', '工单类型:数据'),
});

/* ==================== 局面 1 · 评估输入清单(S0/S1)==================== */

const PN_CHECK_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
  <thead><tr>
    <th style="width:36px;min-width:36px"></th>
    <th style="min-width:160px">料号</th>
    <th style="width:90px;min-width:90px">角色</th>
    <th style="width:110px;min-width:110px">在库状态</th>
    <th style="min-width:200px">备注 / 放弃原因</th>
  </tr></thead>
  <tbody data-rows="mine:${D}.pn_check"><tr>
    <td class="jc-rn"></td>
    <td data-h="料号">${ro('internal_pn', 'mono')}</td>
    <td data-h="角色">${tag('t-role', 'role')}</td>
    <td data-h="在库状态">${tag('t-exist', 'exists_status')}</td>
    <td class="dimempty" data-h="备注"><span class="jc-vcell" data-slot="note" data-blank></span> <span class="jc-vcell" data-slot="abandon_reason" data-blank></span></td>
  </tr></tbody>
</table></div>`;

const GAP_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
  <thead><tr>
    <th style="width:36px;min-width:36px"></th>
    <th style="width:120px;min-width:120px">键</th>
    <th class="req" style="min-width:170px">输入项</th>
    <th class="sug" style="width:80px;min-width:80px">来源</th>
    <th style="width:100px;min-width:100px">状态</th>
    <th class="sug" style="min-width:220px">绑定值 / 缺口说明</th>
    <th class="req" style="min-width:260px">补充内容</th>
  </tr></thead>
  <tbody data-rows="mine:${D}.input_gap" data-edit="${D}.input_gap"><tr>
    <td class="jc-rn"><button type="button" class="jc-rowdel" data-edit-del="${D}.input_gap" title="删一行 = 该输入项不适用">×</button></td>
    <td data-h="键">${cell('item_key', 'mono')}</td>
    <td class="req" data-h="输入项">${cell('input_item')}</td>
    <td class="sug" data-h="来源">${cell('source_ledger', 'mono')}</td>
    <td data-h="状态">${select('status', [['gap', '待补'], ['default', '默认·可改'], ['ok', '齐备']])}${tag('t-gap', 'status')}</td>
    <td class="sug" data-h="绑定值 / 缺口说明">${cell('detail')}</td>
    <td class="req" data-h="补充内容"><span class="jc-vcell" data-slot="supplement" data-blank></span><textarea class="jc-cell" data-col="supplement" placeholder="资料链接 / 任务编号 / 具体值;补不了写「留 UNKNOWN」或「挂验证任务」"></textarea></td>
  </tr></tbody>
</table></div>`;

export const EVAL_INPUT_LIST = doc({
  tone: 'human',
  badge: '执行',
  tag: '执行工单',
  kicker: 'SS02 · ACT-WORK-05 差异评估 · 执行工单 1/1',
  title: '评估输入与资料补齐确认',
  step: '步骤 1/5 · 建立评估上下文',
  state: `<span class="jc-rp-wait warn">待补 <b data-slot="${D}_input_gap_missing"></b> 项</span>`,
  body:
    part('做什么', '启动 4 项已绑定,其余信息在工单里补齐') +
    `<div class="jc-derive">
      <p><span class="done">AI 已完成</span>:系统已按启动传入的 <b>4 项</b>完成绑定 —— 客户、客户需求(新建 P-01 · 版本 V1)、原 PN、候选 PN(数组,逐个评估)—— 并生成输入快照。</p>
      <p><span class="need">需要你补齐</span>:启动时未收的信息(应用场景、BOM 位)<b>直接在下表补</b>;默认值(替代类型 / 规则版本 / 负责人期限)可确认可改。本单缺口只覆盖「<b>有而不全</b>」;「完全没有」(料号不在库)已由数据工单创建。未补齐的缺口项将按 <b>UNKNOWN</b> 进入差异评估。</p>
    </div>` +
    part('已绑定', '启动时冻结,工单内不可改 —— 绑错请退回启动人重新启动') +
    `<div class="jc-bound">
      <b>客户</b><span data-slot="customerCode" data-blank></span>
      <b>客户需求</b><span><span class="mono" data-slot="request_id" data-blank></span> · V1 · <span data-slot="requestDescription" data-blank></span></span>
      <b>原内部料号</b><span class="mono" data-slot="originalInternalPn" data-blank></span>
      <b>规则版本</b><span class="mono" data-slot="rule_version" data-blank></span>
    </div>` +
    PN_CHECK_TABLE +
    part('交回什么', '评估输入清单(已绑定项只读核对,缺口项补齐,*为必填)') +
    gapbar(`${D}_input_gap`, '补充内容') +
    editGate(`${D}.input_gap`, `<b>还有 <span data-slot="${D}_input_gap_missing"></span> 项待补 / 待确认</b>,点「编辑」在「补充内容」列填入`) +
    `<div class="jc-editbar"><span>加一行 = 补充 AI 未列出的资料项(新行的「输入项」与「补充内容」必填);删一行 = 该输入项不适用;补不了的写「留 UNKNOWN」或「挂验证任务」—— 那也是一个答案。改动只落任务草稿层,<b>保存不等于放行</b>。</span></div>` +
    GAP_TABLE +
    `<div class="jc-addrow">
      <button type="button" class="jc-egbtn ghost" data-edit-add="${D}.input_gap">+ 加一行(补充资料项)</button>
      <em>键写一个英文短名(如 thermal_note);「绑定值 / 缺口说明」栏写清这项资料是什么、AI 为什么拿不到。</em>
    </div>` +
    flow('<b>审批 / 提交:</b>本工单只承载内容;「提交」请在对话区的【执行卡】中进行。<b>提交后去向:</b>进入步骤 2 · AI 读取与标准化,仍缺失的项将按 UNKNOWN 进入差异评估。'),
  foot: sign('SS02 · ACT-WORK-05 差异评估 · 执行工单 1/1 · 评估输入与资料补齐确认', '工单类型:执行'),
});

/* ==================== 局面 2 · 差异评估结论审核(S2/S3/S4,只读)==================== */

const RELATION_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
  <thead><tr>
    <th style="width:36px;min-width:36px"></th>
    <th style="min-width:150px">候选料号</th>
    <th style="width:100px;min-width:100px">替代类型</th>
    <th style="width:100px;min-width:100px">推荐等级</th>
    <th style="min-width:260px">推荐理由</th>
    <th style="min-width:200px">差异摘要</th>
    <th style="width:70px;min-width:70px">风险</th>
    <th style="min-width:180px">验证要求</th>
    <th style="width:80px;min-width:80px">置信度</th>
  </tr></thead>
  <tbody data-rows="mine:substitution.relation"><tr>
    <td class="jc-rn"></td>
    <td data-h="候选料号">${ro('candidate_internal_pn', 'mono')}</td>
    <td data-h="替代类型">${tag('t-sub', 'substitution_type')}</td>
    <td data-h="推荐等级">${tag('t-rec', 'recommendation_level')}</td>
    <td class="dimempty" data-h="推荐理由">${ro('recommendation_reason')}</td>
    <td class="dimempty" data-h="差异摘要">${ro('difference_summary')}</td>
    <td data-h="风险">${tag('t-risk', 'risk_level')}</td>
    <td class="dimempty" data-h="验证要求">${ro('verification_requirement')}</td>
    <td class="dimempty" data-h="置信度">${ro('confidence_score', 'mono')}</td>
  </tr></tbody>
</table></div>`;

const ITEM_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
  <thead><tr>
    <th style="width:36px;min-width:36px"></th>
    <th style="min-width:130px">关系</th>
    <th style="min-width:110px">参数项</th>
    <th style="min-width:90px">原 PN 值</th>
    <th style="min-width:90px">候选 PN 值</th>
    <th style="width:60px;min-width:60px">单位</th>
    <th style="width:80px;min-width:80px">约束类型</th>
    <th style="width:110px;min-width:110px">结论</th>
    <th style="width:60px;min-width:60px">风险</th>
    <th style="min-width:160px">证据引用</th>
    <th style="min-width:200px">影响说明</th>
    <th style="min-width:160px">UNKNOWN 去向</th>
    <th style="width:70px;min-width:70px">置信度</th>
  </tr></thead>
  <tbody data-rows="mine:substitution.difference_item"><tr>
    <td class="jc-rn"></td>
    <td data-h="关系">${ro('substitution_relation_id', 'mono')}</td>
    <td data-h="参数项"><b class="cust" data-slot="parameter_name" data-blank></b></td>
    <td data-h="原 PN 值">${ro('original_value', 'mono')}</td>
    <td data-h="候选 PN 值">${ro('candidate_value', 'mono')}</td>
    <td class="dimempty" data-h="单位">${ro('parameter_unit')}</td>
    <td data-h="约束类型">${tag('t-con', 'constraint_type')}</td>
    <td data-h="结论">${tag('t-diff', 'difference_status')}</td>
    <td data-h="风险">${tag('t-risk', 'risk_level')}</td>
    <td class="dimempty" data-h="证据引用">${ro('evidence_ref', 'mono')}</td>
    <td class="dimempty" data-h="影响说明">${ro('impact_statement')}</td>
    <td class="dimempty" data-h="UNKNOWN 去向">${ro('unknown_disposition')}</td>
    <td class="dimempty" data-h="置信度">${ro('confidence', 'mono')}</td>
  </tr></tbody>
</table></div>`;

export const DIFFERENCE_REVIEW = doc({
  tone: 'ledger',
  badge: '验收',
  tag: '验收工单',
  kicker: 'SS02 · ACT-WORK-05 差异评估 · 验收工单 1/2',
  title: '差异评估结论审核',
  step: '步骤 3~4/5 · 逐项差异评估 + FAE/RI 审核',
  state: `<span class="jc-rp-wait">共 <b data-slot="substitution_relation_rows"></b> 个候选 · <b data-slot="substitution_difference_item_rows"></b> 条比对</span>`,
  body:
    part('AI 结论说明', '结论是怎么算出来的') +
    `<div class="jc-derive">
      <p><b>AI 已完成的计算</b>:按 GOV 评估规则(锁定版本 <b class="mono" data-slot="rule_version" data-blank></b>),对原 PN <b class="mono" data-slot="original_internal_pn" data-blank></b>
      与每个候选逐项比对,四态结论 —— MATCH <b data-slot="substitution_difference_item_difference_status_match"></b> /
      MISMATCH <b data-slot="substitution_difference_item_difference_status_mismatch"></b> /
      UNKNOWN <b data-slot="substitution_difference_item_difference_status_unknown"></b> /
      CONDITIONAL <b data-slot="substitution_difference_item_difference_status_conditional"></b>,并给出风险等级、证据引用与置信度。
      差异明细 = <b>P-11 差异明细子表</b>(一条参数一条记录);当前为 AI 草稿,<b>审核通过前不落账</b>。本单数据工单创建的主数据,其参数证据标注「人工录入」。</p>
    </div>` +
    part('验收判据', '你据此判断「通过 / 驳回」') +
    criteria('判断这份差异评估结论是否可信,请逐项核对', [
      '<b>关键参数差异均有明确状态</b>:逐项结论无漏项,每个参数都有四态之一的判定;',
      '<b>UNKNOWN 项均有去向</b>:每个 UNKNOWN 都挂了验证任务或补来源动作,不允许悬空;',
      '<b>证据链完整</b>:每项结论可追溯至 P-08 / P-10 / P-06 的具体记录,或本单数据工单的创建记录(人工录入);',
      '<b>硬约束项未被自动判 MATCH</b>:封装 / 工艺等硬约束未经证据支持不得判 MATCH。',
    ]) +
    part('各候选推荐等级', '只读展示 · 每个候选一条 P-11 主记录草稿') +
    RELATION_TABLE +
    part('AI 产出的差异明细', '只读展示 · P-11 差异明细子表草稿') +
    ITEM_TABLE +
    flow('<b>审批:</b>本工单只承载内容;「通过 / 驳回」请在对话区的【验收卡】中进行。审核意见与补充条件写入 P-06(验证要求);<b>通过后去向:</b>进入步骤 5 · 评审放行与落账确认。'),
  foot: sign('SS02 · ACT-WORK-05 差异评估 · 验收工单 1/2 · 差异评估结论审核', '工单类型:验收'),
});

/* ==================== 局面 3 · 评审放行与落账确认(S5/T_release,只读)==================== */

const P11_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
  <thead><tr>
    <th style="width:36px;min-width:36px"></th>
    <th style="min-width:150px">替代关系ID</th>
    <th style="width:100px;min-width:100px">替代类型</th>
    <th style="min-width:130px">原内部料号</th>
    <th style="min-width:130px">候选内部料号</th>
    <th style="width:100px;min-width:100px">推荐等级</th>
    <th style="min-width:220px">推荐理由</th>
    <th style="min-width:180px">差异摘要</th>
    <th style="min-width:120px">替代分析报告</th>
    <th style="width:100px;min-width:100px">分析状态</th>
    <th style="width:120px;min-width:120px">客户批准状态</th>
    <th style="width:70px;min-width:70px">风险</th>
    <th style="min-width:180px">验证要求</th>
    <th style="width:80px;min-width:80px">证据可信度</th>
    <th style="width:100px;min-width:100px">规则版本</th>
  </tr></thead>
  <tbody data-rows="mine:substitution.relation"><tr>
    <td class="jc-rn"></td>
    <td data-h="替代关系ID">${ro('substitution_relation_id', 'mono')}</td>
    <td data-h="替代类型">${tag('t-sub', 'substitution_type')}</td>
    <td data-h="原内部料号">${ro('original_internal_pn', 'mono')}</td>
    <td data-h="候选内部料号">${ro('candidate_internal_pn', 'mono')}</td>
    <td data-h="推荐等级">${tag('t-rec', 'recommendation_level')}</td>
    <td class="dimempty" data-h="推荐理由">${ro('recommendation_reason')}</td>
    <td class="dimempty" data-h="差异摘要">${ro('difference_summary')}</td>
    <td class="dimempty" data-h="替代分析报告">${ro('analysis_report_ref', 'mono')}</td>
    <td data-h="分析状态">${tag('t-anal', 'analysis_status')}</td>
    <td data-h="客户批准状态"><span class="jc-vcell">未提交 <i class="jc-sub">由审批流程维护,本活动不产生</i></span></td>
    <td data-h="风险">${tag('t-risk', 'risk_level')}</td>
    <td class="dimempty" data-h="验证要求">${ro('verification_requirement')}</td>
    <td class="dimempty" data-h="证据可信度">${ro('confidence_score', 'mono')}</td>
    <td data-h="规则版本">${ro('rule_version', 'mono')}</td>
  </tr></tbody>
</table></div>`;

const P01_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
  <thead><tr>
    <th style="width:36px;min-width:36px"></th>
    <th style="min-width:150px">需求编号</th>
    <th style="width:80px;min-width:80px">风险等级</th>
    <th style="min-width:220px">验证要求</th>
    <th style="min-width:130px">输出链接</th>
    <th style="min-width:180px">下一步动作</th>
    <th style="width:100px;min-width:100px">当前阶段</th>
    <th style="width:100px;min-width:100px">需求状态</th>
  </tr></thead>
  <tbody data-rows="mine:demand.request"><tr>
    <td class="jc-rn"></td>
    <td data-h="需求编号">${ro('request_id', 'mono')}</td>
    <td data-h="风险等级">${tag('t-risk', 'risk_level')}</td>
    <td class="dimempty" data-h="验证要求">${ro('verification_requirement')}</td>
    <td class="dimempty" data-h="输出链接">${ro('output_ref', 'mono')}</td>
    <td class="dimempty" data-h="下一步动作">${ro('next_action')}</td>
    <td class="dimempty" data-h="当前阶段">${ro('current_stage', 'mono')}</td>
    <td class="dimempty" data-h="需求状态">${ro('request_status', 'mono')}</td>
  </tr></tbody>
</table></div>`;

export const LANDING_CONFIRM = doc({
  tone: 'ledger',
  badge: '验收',
  tag: '验收工单',
  kicker: 'SS02 · ACT-WORK-05 差异评估 · 验收工单 2/2',
  title: '评审放行与落账确认',
  step: '步骤 5/5 · 提交 ACT-GATE-03',
  state: `<span class="jc-rp-wait">拟落账 <b data-slot="substitution_relation_rows"></b> 条 P-11</span>`,
  body:
    part('AI 结论说明', '本单确认什么') +
    `<div class="jc-derive">
      <p><b>本活动以 ACT-GATE-03 评审放行为终点。</b>本单确认拟落账内容:评估结论层写 <b>P-11 替代关系主记录</b>(每个候选一条)与差异明细子表,需求状态回写 <b>P-01</b>(风险等级 / 验证要求 / 输出链接 / 下一步动作 / 当前阶段)。
      <b>客户批准不属于本活动</b> —— 客户批准状态由审批流程维护,本单只写分析层(分析状态 = FAE 确认)。</p>
    </div>` +
    part('验收判据', '你据此判断「通过 / 驳回」') +
    criteria('放行与落账前,请逐项核对', [
      '<b>推荐等级与差异明细一致</b>:存在 UNKNOWN / CONDITIONAL 的候选,推荐等级不得给「推荐」;',
      '<b>分析状态与客户批准状态分离</b>:本单只写分析层(FAE 确认),客户批准状态保持「未提交」;',
      '<b>GOV 规则版本已锁定</b>:规则版本随结论一并落账;',
      '<b>回写 P-01 字段完整</b>:风险等级、验证要求、输出链接、下一步动作、当前阶段五项齐全。',
    ]) +
    part('拟写 P-11 · 替代关系主记录', '只读展示') +
    P11_TABLE +
    part('拟回写 P-01 · 需求状态', '只读展示') +
    P01_TABLE +
    flow('<b>审批:</b>本工单只承载内容;「通过 / 驳回」请在对话区的【验收卡】中进行。<b>通过后去向:</b>P-11 落账、P-01 回写、放行;结论供 ACT-WORK-08 证据归集消费。'),
  foot: sign('SS02 · ACT-WORK-05 差异评估 · 验收工单 2/2 · 评审放行与落账确认', '工单类型:验收'),
});
  • Step 2: 跑包内测试,全绿

Run: pnpm vitest run activities/part-substitution-assessment Expected: activity.test + steps.test 全部 PASS(含「报告模版」组 7 条)。

  • Step 3: 全仓门禁子集 + 提交

Run: pnpm -s typecheck && pnpm -s lint && pnpm -s depcruise Expected: 全绿(活动包只 import @sdk/@contracts/@sfeel 与包内文件)。

git add activities/part-substitution-assessment
git commit -m "feat(w05): 五张报告模版 —— 两张数据工单件、评估输入清单、差异审核件、落账确认件(目标件平移)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh"

Task 6: 登记进构建与安装通道、装进库、库内验收、测试台冒烟

Files:

  • Modify: tools/build-activities.ts(import + PACKAGES 加一条)
  • Modify: apps/api/src/app/seed-activities.ts(REAL_PACKAGES 加一条;装包之前注册 W05_TOOLS)
  • Test: apps/api/src/app/__tests__/seed-mvp-data.test.ts(已过);docker exec … psql 验收 SQL

Interfaces:

  • Consumes:Task 2 的 W05_TOOLS / W05_TOOL_SAMPLE;Task 3–5 的活动包。

  • Produces:库里装好的 part-substitution-assessment@1.0.0、六张主数据表 + 三张自持表、样例料号;测试台「活动」页签里能选到它并起单。

  • Step 1: 登记到构建脚本

在 tools/build-activities.ts 的 import 区加:

import { manifest as partSubstitutionAssessment } from '../activities/part-substitution-assessment/src/manifest.ts';

PACKAGES 数组末尾加:

  { dir: 'part-substitution-assessment', manifest: partSubstitutionAssessment },
  • Step 2: 登记到安装通道并顺带注册纯工具

apps/api/src/app/seed-activities.ts:

const REAL_PACKAGES = ['crm-relationship-coverage', 'brand-content-toolkit', 'part-substitution-assessment'] as const;

顶部加 import { W05_TOOLS, W05_TOOL_SAMPLE } from './tools-w05.ts';,在 installRealActivities 里 const root = activitiesRoot(); 之前插入:

  /*
   * **纯工具随活动一起注册**(2026-08-26):从前工具契约只在 db:seed 里注册,只跑 activities:install 的
   * 开发机上,活动清单声明了工具、白名单也发了,`sys.tool` 里却没有那件工具 —— 第一次跑就撞「工具不存在」。
   * 「安装即用」包含它声明过的纯工具能调得动。register 按 name+version 幂等,重复装是 no-op。
   */
  for (const contract of W05_TOOLS) {
    const verdict = await container.toolbox.register({ contract, samples: [W05_TOOL_SAMPLE] });
    if (!verdict.accepted) log(`   工具 ${contract.name} 未过两审:${verdict.staticFindings.map((f) => f.message).join('; ')}`);
  }

同文件那句 主数据四域已物化:${MASTER_OBJECTS.length} 张表 改成 主数据 ${MASTER_DOMAINS.length} 域已物化:${MASTER_OBJECTS.length} 张表(import 里把 MASTER_DOMAINS 一并从 ./seed-mvp-data.ts 引进来)—— 日志说「四域」而库里八域,就是一处会撒谎的话。

  • Step 3: 构建 + 装包(这一步同时物化六张主数据表、三张自持表,并播样例料号)
pnpm activities:install 2>&1 | tail -20

Expected 日志里有:主数据 8 域已物化:17 张表(原 11 张 + 6)、工具 … 无「未过两审」、活动包已装:ACT-WORK-05 · 差异评估(part-substitution-assessment@1.0.0) · 已授权工具 2 件、静态审无 error。若出现 静态审提示:[error] …:

  • predicate-unbound 指名 candidateInternalPns → 把该谓词里的 count(candidateInternalPns) 换成读 sub_eval.verdict.candidate_total(模型写)并在 doneWhen/哨兵里保留 _rows 那一半;升版本重装;

  • reference-target-unbound → 检查 refersTo 的三个目标列是否与清单里声明的列名一字不差;

  • ledger-kind-invalid → 自持表的 caseKey 必须在主键里。

  • Step 4: 库内验收(表、主键、性质、注释、样例行)

docker exec -i jcai-postgres psql -U jcai -d jcai_wos -Atc "select domain||'.'||object||' | '||kind||' | '||coalesce(case_key,'-') from ledger.__object_meta where domain in ('parts','demand','substitution','validation','sub_eval') order by 1"
docker exec -i jcai-postgres psql -U jcai -d jcai_wos -Atc "select conrelid::regclass||' '||pg_get_constraintdef(oid) from pg_constraint where contype='p' and conrelid::regclass::text ~ '^ledger\\.(parts|demand|substitution|validation|sub_eval)__' order by 1"
docker exec -i jcai-postgres psql -U jcai -d jcai_wos -Atc "select string_agg(column_name||':'||data_type, ', ' order by ordinal_position) from information_schema.columns where table_schema='ledger' and table_name='substitution__difference_item'"
docker exec -i jcai-postgres psql -U jcai -d jcai_wos -Atc "select obj_description('ledger.parts__profile'::regclass)||' / '||col_description('ledger.parts__profile'::regclass, 6)"
docker exec -i jcai-postgres psql -U jcai -d jcai_wos -Atc "select internal_pn||' | '||brand_name||' | '||lifecycle_status||' | '||left(normalized_parameter_set, 40) from ledger.parts__profile order by 1"
docker exec -i jcai-postgres psql -U jcai -d jcai_wos -Atc "select internal_pn||'/'||doc_seq||' '||document_type||' '||document_version from ledger.parts__document order by 1"
docker exec -i jcai-postgres psql -U jcai -d jcai_wos -Atc "select activity_id||' @'||version||' '||state from sys.activity_package where activity_id='part-substitution-assessment'"
docker exec -i jcai-postgres psql -U jcai -d jcai_wos -Atc "select name||' v'||version from sys.tool where name like 'part-substitution-assessment.%'"

Expected:

  • __object_meta 9 行:parts.profile master、parts.document master、demand.request master、substitution.relation master、substitution.difference_item master、validation.record master、sub_eval.pn_check record eval_ref、sub_eval.input_gap record eval_ref、sub_eval.verdict record eval_ref;

  • 主键:parts__profile (internal_pn)、parts__document (internal_pn, doc_seq)、substitution__relation (substitution_relation_id)、substitution__difference_item (substitution_relation_id, difference_item_id)、sub_eval__pn_check (eval_ref, internal_pn)、sub_eval__input_gap (eval_ref, item_key)、sub_eval__verdict (eval_ref);

  • substitution__difference_item 的列里含 compare_result:text 与 unknown_disposition:text(清单追加的两列真长出来了);

  • 表注释 芯片主数据(P-08),第 6 列注释是参数集的人读名;

  • parts__profile 两行(LM2596S-5.0 | TI | nrnd | 输入电压范围 | 4.5~40 …、LM2596S-ADJ | TI | active | …);parts__document 两行;

  • sys.activity_package 有 part-substitution-assessment @1.0.0 installed(state 值以库里 E04 那一行为准,同样即可);sys.tool 两件 v1.0.0。 把这几条输出原样贴进 docs/ACT-WORK-05-端到端测试记录.md 的「0. 库内验收」节(Task 8 Step 1 建文件时一起补)。

  • Step 5: 测试台冒烟(不起单,只看装没装对)

用 claude-in-chrome 打开 http://localhost:5173/design#/design/testbed/act:活动下拉里有「ACT-WORK-05 · 差异评估」;选中后表单预填的是样例 0(客户 CU-0142、候选 ["XL2596S-5.0","LM2596S-ADJ"]),样例钮五枚都显示名字(没有退成「样例 N」),「落真库」勾着。再打开 http://localhost:5173/design#/design/ledgers 数据域那一屏,按活动分组里能看到 ACT-WORK-05 名下 9 张表、parts.profile 正式 2 行。截图存 docs/img/w05-testbed-smoke.png(用 computer 工具 screenshot 后保存)。

  • Step 6: 门禁子集 + 提交

Run: pnpm -s typecheck && pnpm -s lint && pnpm -s depcruise && pnpm vitest run apps/api/src/app/__tests__ activities/part-substitution-assessment Expected: 全绿。

git add tools/build-activities.ts apps/api/src/app/seed-activities.ts activities/part-substitution-assessment/dist/manifest.json activities/part-substitution-assessment/dist/index.js
git commit -m "feat(w05): 差异评估活动登记进构建与安装通道;装包时顺带注册纯工具

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh"

(dist/ 是否入库以 E04 现状为准:git ls-files activities/crm-relationship-coverage/dist 有输出就一起 add,没有就不 add。)


Task 7: 脚本 e2e 套件 w05(五案例)

Files:

  • Modify: tools/e2e-a2.mjs(SUITES 加 w05;buildEdits 加 stageEdits / fillWhere;expectGateBlocks 认 fillWhere;main() 套件名单加 w05)

Interfaces:

  • Consumes:Task 4 的卡标题约定(P-08 单 数据工单 · 芯片主数据创建、P-10 单 数据工单 · 原厂技术资料创建、执行单 评估输入与资料补齐确认)、决策卡 value(create / abandon / approve / reject / custom:)、样例序号 0–4。

  • Produces:node tools/e2e-a2.mjs w05 5/5 通过;每案例 released、被拒 0。

  • Step 1: 扩 buildEdits —— 案例级按表的编辑(stageEdits)与按条件整行扫空(fillWhere)

把 function buildEdits(stage, drafts) { 改为 function buildEdits(stage, drafts, kase = {}) {,并把里面 for (const [i, edit] of (stage.edits ?? []).entries()) { 改为

  // 案例级按表的编辑叠在套件级之上(同一套件不同案例要建的料号不同 —— 参数集不能是同一份)
  const edits = [...(stage.edits ?? []), ...((kase.stageEdits ?? {})[stage.address] ?? [])];
  for (const [i, edit] of edits.entries()) {

在 if (stage.fillAllBlank) { … } 之后加:

  /*
   * 按条件整行扫空:只给某一档状态的行填(评估输入清单里只有 status = gap 的行要补充内容,
   * default / ok 的行本就不该填 —— fillAllBlank 会把它们也填上,那是替人说了他没说的话)。
   */
  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 });
      }
    }
  }

runCase 里 const lines = buildEdits(stage, body.drafts ?? []); 改为 const lines = buildEdits(stage, body.drafts ?? [], kase);。

expectGateBlocks 里

  const rows = draftRows(body.drafts ?? [], stage.address);
  const blank = rows.filter((r) => String(r[column] ?? '').trim() === '').length;

改为

  const rows = draftRows(body.drafts ?? [], stage.address);
  // 有 fillWhere 的段只数该档的行:default / ok 行空着是本分,不是「闸没拦」
  const scoped = stage.fillWhere ? rows.filter((r) => String(r[stage.fillWhere.column] ?? '') === stage.fillWhere.equals) : rows;
  const blank = scoped.filter((r) => String(r[column] ?? '').trim() === '').length;
  • Step 2: 加套件(放在 i01: {…} 之后、}; 之前)
  /*
   * ACT-WORK-05 差异评估 —— 1 数据(条件,P-08 / P-10 各一张)+ 1 执行 + 2 验收(审核走决策卡循环、放行终局)。
   * 库里只播了 LM2596S-5.0 / LM2596S-ADJ:XL2596S-5.0、CN-DCDC-32V、LM2596S-3.3 刻意不在,
   * 「候选缺库 / 原 PN 缺库 / 硬约束不推荐」三条路靠它们。e2e 关落真库,主数据不落正式表,案例可反复跑。
   */
  w05: {
    activityId: 'part-substitution-assessment',
    address: 'sub_eval.input_gap',
    blankCol: 'supplement',
    stages: [
      {
        match: /数据工单 · 芯片主数据创建/,
        address: 'parts.profile',
        rowKey: 'internal_pn',
        blankCol: 'brand_name',
        expectGateBlock: true,
        edits: [],
        // 案例没点名的料号兜底(不该走到这里 —— 每个案例都在 stageEdits 里逐颗给了)
        fillAllBlank: {
          brand_name: '兜底品牌',
          specification_description: '兜底规格(案例没给参数集)',
          package: 'TO-263-5',
          lifecycle_status: 'active',
          normalized_parameter_set: '输入电压范围 | 4.5~40 | V | hard | 兜底',
        },
      },
      {
        match: /数据工单 · 原厂技术资料创建/,
        address: 'parts.document',
        rowKey: 'internal_pn',
        blankCol: 'document_title',
        expectGateBlock: true,
        edits: [],
        fillAllBlank: {
          doc_seq: '01',
          document_type: 'datasheet',
          document_title: 'Datasheet(e2e 兜底)',
          document_version: 'Rev A',
          document_ref: 'https://example.com/datasheet.pdf',
          citation_locator: 'p.1',
          document_status: 'valid',
        },
      },
      {
        match: /评估输入与资料补齐确认/,
        address: 'sub_eval.input_gap',
        rowKey: 'item_key',
        blankCol: 'supplement',
        expectGateBlock: true,
        edits: [
          { item_key: 'application_scenario', supplement: '12V→5V、工业温域(-40~85℃)、输入上限 40V' },
          { item_key: 'bom_position', supplement: 'U3 电源位' },
        ],
        // 其余 gap 行(原厂未披露的参数)统一「留 UNKNOWN」—— 补不了也是合法答案
        fillWhere: { column: 'status', equals: 'gap', values: { supplement: '原厂未披露,留 UNKNOWN 并挂验证任务' } },
      },
    ],
    cases: [
      {
        sample: 0,
        name: '候选缺库 · XL2596S-5.0 派数据工单(P-08 + P-10)· 审核先点名驳回再通过',
        // 决策卡序:候选 ≥2 缺库先问 → create;审核第一张点名意见驳回;第二张通过
        decideSeq: ['create', 'custom:使能阈值那条 UNKNOWN 的去向请写明验证任务号与测试条件', 'approve'],
        expectDecision: true,
        expectPort: 'assessment-released',
        stageEdits: {
          'parts.profile': [
            {
              internal_pn: 'XL2596S-5.0',
              brand_name: 'XLSEMI',
              manufacturer_name: 'XLSEMI',
              specification_description: '降压型 DCDC 稳压器,3A,固定 5.0V,150kHz',
              package: 'TO-263-5',
              process_technology: 'BCD',
              lifecycle_status: 'active',
              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.document': [
            {
              internal_pn: 'XL2596S-5.0',
              doc_seq: '01',
              document_type: 'datasheet',
              brand_name: 'XLSEMI',
              document_title: 'XL2596S 150KHz 3A Buck DC/DC Converter',
              document_version: 'Rev C',
              document_ref: 'https://www.xlsemi.com/datasheet/XL2596S.pdf',
              citation_locator: 'p.3 Electrical Characteristics',
              document_status: 'valid',
            },
          ],
        },
        // 使能阈值未披露 → UNKNOWN → 条件推荐(工具算的,不是模型说的)
        expectRevised: { address: 'substitution.relation', where: 'candidate_internal_pn', equals: 'XL2596S-5.0', column: 'recommendation_level', contains: 'conditional' },
      },
      {
        sample: 1,
        name: '都在库 · 常路 · 可调版 → 备选 · 审核一次通过',
        decide: 'approve',
        expectPort: 'assessment-released',
        expectRevised: { address: 'substitution.relation', where: 'candidate_internal_pn', equals: 'LM2596S-ADJ', column: 'recommendation_level', contains: 'alternate' },
      },
      {
        sample: 2,
        name: '原 PN 缺库 · 硬阻塞建档(不问人)· 审核通过',
        decide: 'approve',
        expectPort: 'assessment-released',
        stageEdits: {
          'parts.profile': [
            {
              internal_pn: 'LM2596S-3.3',
              brand_name: 'TI',
              manufacturer_name: 'Texas Instruments',
              specification_description: '降压型 DCDC 稳压器,3A,固定 3.3V,150kHz',
              package: 'TO-263-5',
              process_technology: 'BCD',
              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.document': [
            {
              internal_pn: 'LM2596S-3.3',
              doc_seq: '01',
              document_type: 'datasheet',
              brand_name: 'TI',
              document_title: 'LM2596 SIMPLE SWITCHER Power Converter',
              document_version: 'Rev H (2023-09)',
              document_ref: 'https://www.ti.com/lit/ds/symlink/lm2596.pdf',
              citation_locator: '§6.5 Electrical Characteristics(3.3V 列)',
              document_status: 'valid',
            },
          ],
        },
      },
      {
        sample: 3,
        name: '三候选 · 两颗缺库各问一次 · 国产 32V 硬约束不推荐',
        decideSeq: ['create', 'create', 'approve'],
        expectDecision: true,
        expectPort: 'assessment-released',
        stageEdits: {
          'parts.profile': [
            {
              internal_pn: 'XL2596S-5.0',
              brand_name: 'XLSEMI',
              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',
              brand_name: '国产某厂',
              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.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' },
          ],
        },
        expectRevised: { address: 'substitution.relation', where: 'candidate_internal_pn', equals: 'CN-DCDC-32V', column: 'recommendation_level', contains: 'not_recommended' },
      },
      {
        sample: 4,
        name: '新客户 · 录入卡先建档 · 审核通过',
        entryValues: { name: '深圳恒星电源有限公司', industry: '工业电源', region: '广东' },
        decide: 'approve',
        expectPort: 'assessment-released',
      },
    ],
  },

main() 里 ['a1', 'a2', 'a3', 'a4', 'a6', 'a7', 'e04', 'i01'] 改为 ['a1', 'a2', 'a3', 'a4', 'a6', 'a7', 'e04', 'i01', 'w05']。

  • Step 3: 跑套件(先停稳定版,免得两台后端交替推同一单)
./stable.sh stop
pnpm --filter @app/api reset-runs
node tools/e2e-a2.mjs w05 2>&1 | tee /tmp/w05-e2e-round1.log | tail -60

Expected: 5/5 通过;每案例一行 → released · 被拒 0 · N 拍 / Mk token · 决策卡 K 张 · 发口 [assessment-released],案例 0/3 的 闸拦住空手交卷(400)—— 如设计 至少各出现两次(P-08 / P-10 / 执行单),案例 4 有 录入卡代答。 红了怎么办(这是设计欠账不是模型的错,修清单 / 剧本 / 说明书,升 packageVersion 重装再跑;每一轮都记进 Task 8 的测试记录):

  • 熔断交人 / 被拒 > 0 → 看 轨迹末 12 条:拒回指向哪一步 — 写了等于没写 多半是剧本让它重写已有的值;要先… 类是墙,照设计律「墙要配路」改;查无此值(refersTo)= 存在性核对没做对,看 S0/S0d 剧本;

  • 需要你判断下一步 兜底卡 → 模型输出不成形两次,看 agent-runner 重问日志,多半是 S3 第三拍写盘太长(考虑把 ① 明细与 ③ 关系拆成两拍写);

  • 认不出这张执行卡属于哪一段 → 卡标题没按说明书骨架,改说明书③④⑤的 title 或 stages[].match;

  • 期望口 assessment-released 未在发射名单里 → 放行门某条 L2 没绿,看验收卡判据哪条红(测试台观测面 cards 里的判据表)。

  • Step 4: 再跑一遍确认稳定(两轮都 5/5 才算),然后清运行数据、提交

node tools/e2e-a2.mjs w05 2>&1 | tee /tmp/w05-e2e-round2.log | tail -20
pnpm --filter @app/api reset-runs
git add tools/e2e-a2.mjs
git commit -m "test(w05): e2e 套件 —— 五案例(候选缺库 / 常路 / 原 PN 硬阻塞 / 三候选含不推荐 / 新客户建档)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh"

(活动包若在本任务里改过版本,git add activities/part-substitution-assessment 一并提交。)


Task 8: 浏览器全流程全分支走查 + 测试记录文档

Files:

  • Create: docs/ACT-WORK-05-端到端测试记录.md(每一轮追加一节;Task 6 的库内验收与 Task 7 的两轮脚本结果也补进来)
  • 可能 Modify: activities/part-substitution-assessment/src/*(走查逼出的修改,每改必升 packageVersion 并 pnpm activities:install)

Interfaces:

  • Consumes:测试台 http://localhost:5173/design#/design/testbed/act(活动页签;「起单」钮;样例钮;「落真库」勾选框默认勾着;「日志」页签看逐拍轨迹)、工作台 http://localhost:5173/(左栏任务列表 → 中间工作区的件 → 右侧对话区卡;执行卡钮「提交」,验收卡「重做」/「确认通过」,录入卡「确认」,决策卡按钮 = 选项文案;件上「编辑 / 取消 / 保存 / + 加一行 / ×」)、Chrome 当前已登录会话。

  • Produces:五轮走查记录、18 条分支的覆盖表、逼出的修改清单。

  • Step 1: 建记录文档 + 走查前置

cat > docs/ACT-WORK-05-端到端测试记录.md <<'EOF'
# ACT-WORK-05 差异评估 · 端到端测试记录

> 活动包 `part-substitution-assessment`;实施计划 `docs/superpowers/plans/2026-08-26-act-work-05-part-substitution-assessment.md`。
> 每一轮一节:**日期 · 案例 · 走的路径 · 结果(终态 / 被拒 / 拍数 / token / 人环次数)· 观察到的问题 · 修改(文件 @ 版本)· 复测结论**。
> 脚本 e2e 与浏览器走查都记;「界面不撒谎」:没跑到的分支写「未覆盖」并说原因,不写「通过」。

## 0. 库内验收(2026-08-26)

(Task 6 Step 4 的 SQL 输出原样贴这里)

## 1. 脚本 e2e(`node tools/e2e-a2.mjs w05`)

### 第 1 轮
(贴 tail -60;每案例一行结论;红了写原因与修改)

### 第 2 轮(稳定性复跑)

## 2. 浏览器全流程全分支走查

### 分支覆盖表

| 分支 | 触发条件 | 覆盖轮次 | 结果 | 备注 |
|---|---|---|---|---|
| 0a | 启动期:新客户无档案 | R5 | | 数据录入卡建档 |
| 0b / 1d | 候选无 P-08 主数据 → 派数据工单 | R2 / R3 | | P-08 + P-10 各一张 |
| 0c | 未传 datasheet → 人全填 | R2 / R3 / R4 | | R2b 另试「传了 datasheet → AI 预填」 |
| 1a | 绑定参数有误 → 退回重启 | R1 | | 工单内四项不可改(件上没有编辑面) |
| 1b | 输入存在缺口(有而不全) | R1 / R2 | | 应用场景 / BOM 位 / 未披露参数 |
| 1c | 缺口补不了 → 留 UNKNOWN | R2 | | 补充内容写「留 UNKNOWN」 |
| 1d(原 PN) | 原 PN 无数据 → 硬阻塞必须建 | R4 | | 不问人 |
| 1d(放弃) | 候选 ≥2 缺库 → 放弃并记原因 | R3 | | |
| 2a | 单位冲突 → 阻断回步骤 1 | — | 未覆盖 | 需要冲突单位的夹具料号;记 debt |
| 2b | 证据版本冲突 → 人工复核 | — | 未覆盖 | 同上 |
| 3a | 硬约束 MISMATCH → 不推荐 | R3 | | CN-DCDC-32V |
| 4a | UNKNOWN 无去向 → 驳回补证 | R2 | | 审核卡点名驳回 |
| 4b | 证据链不全 → 驳回 | R2 | | 同一张卡的第二种意见 |
| 5a | GATE-03 放行 | R1–R5 | | |
| 5b | GATE-03 驳回 → REWORK | R4 | | 验收卡「重做」 |
| 6a | 至少一个推荐 / 备选 | R1 | | 备选 |
| 6b | 全是条件推荐 | R2 | | XL 条件推荐 |
| 6c | 全不推荐 | — | 未覆盖 | 需要一颗全硬约束失败的原 PN 组合;记 debt |
| 6d | 客户不批准 | — | 范围外 | 独立审批流 |

### R1 · 都在库 · 常路(样例 1)
### R2 · 候选缺库 · 数据工单 · 审核驳回循环(样例 0)
### R2b · 传了 datasheet → AI 预填(样例 0 + 上传)
### R3 · 三候选 · 一颗放弃一颗新建 · 硬约束不推荐(样例 3)
### R4 · 原 PN 缺库 · 硬阻塞 · 放行驳回重做(样例 2)
### R5 · 新客户 · 录入卡建档(样例 4)

## 3. 结论与欠账
EOF
./stable.sh stop          # 走查期间只留 dev(8787 / 5173),避免两台交替推同一单
pnpm --filter @app/api reset-runs

用 claude-in-chrome:ToolSearch "select:mcp__claude-in-chrome__tabs_context_mcp,mcp__claude-in-chrome__navigate,mcp__claude-in-chrome__computer,mcp__claude-in-chrome__read_page,mcp__claude-in-chrome__find,mcp__claude-in-chrome__form_input,mcp__claude-in-chrome__get_page_text,mcp__claude-in-chrome__tabs_create_mcp,mcp__claude-in-chrome__file_upload,mcp__claude-in-chrome__read_console_messages" → tabs_context_mcp → 新开一页 http://localhost:5173/。出现 Keycloak 登录页就停下来请用户在浏览器里登录一次(凭证只有他有),登录后继续。每轮之间跑「轮间清场」:

pnpm --filter @app/api reset-runs
docker exec -i jcai-postgres psql -U jcai -d jcai_wos -c "
delete from ledger.substitution__difference_item where substitution_relation_id like 'REL-%';
delete from ledger.substitution__relation where substitution_relation_id like 'REL-%';
delete from ledger.validation__record where validation_id like 'VAL-%';
delete from ledger.demand__request where request_id like 'REQ-%';
delete from ledger.parts__document where internal_pn in ('XL2596S-5.0','CN-DCDC-32V','LM2596S-3.3');
delete from ledger.parts__profile where internal_pn in ('XL2596S-5.0','CN-DCDC-32V','LM2596S-3.3');
delete from ledger.customer__profile where customer_code = 'CU-9001';"

(浏览器走查「落真库」默认勾着,放行后主数据真落正式表 —— 不清掉,下一轮「候选缺库」那条路就试不出来。)

每一轮记录的固定项:起单时刻与 taskId(测试台起单后地址栏 / 观测面顶部);进度条刻度(应是 x/6,每个人环时条上有格);每张卡的文案是否人话、指导书是否显示;件上编辑 → 保存 → 闸 / 红带是否同源(空着必填按「提交」要被拦并点名行列);对话区 say 是否一轮一句;工作成果快照份数(执行单 / 验收单各一份 + 决策卡);终态与 被拒 / 拍数 / token(测试台「日志」页签或 curl -s -H "authorization: Bearer $TOKEN" http://localhost:8787/api/gov/testbed/activity/<taskId> | jq '{terminal,used,rejects:[.trail[]|select(.kind=="action-rejected")|.title]}',token 用 e2e 的 login() 同款 client_credentials 取);落库验证 SQL(见各轮)。发现问题 → 改清单 / 剧本 / 模版 → 升 packageVersion → pnpm activities:install → 记录「修改」→ 复跑该轮直到 released 且被拒 0。

  • Step 2: R1 · 都在库 · 常路(样例 1)
  1. 测试台 → 活动页签 → 活动「ACT-WORK-05 · 差异评估」→ 样例「都在库·常路」→「起单」。记 taskId。
  2. 工作台:左栏点开这一单。等 AI 跑 S0(对话区应出一句「已绑定…原 PN 与候选都在库,不建主数据」之类的话;进度条 1/6 → 2/6)。分支 1a:确认工作区「已绑定」区四项灰底只读、没有编辑钮。
  3. 执行卡「评估输入与资料补齐确认」到达:看指导书(五条 steps)与依据;工作区件:料号核对表两行(原 PN 在库 / 候选 在库 徽标),评估输入清单里 application_scenario / bom_position 两行状态「待补*」、三行「默认·可改」、齐备行。先按「提交」不填 → 应被闸拦,提示点名缺哪几行(记原文)。
  4. 件上「编辑」→ 两行补充内容各填一句(12V→5V、工业温域、40V 输入上限 / U3 电源位)→「保存」→ 红带消失、闸文案变;再「提交」→ 通过;对话区出现「人已交回」痕。
  5. AI 跑 S2/S3(三拍:比对 → 定级 → 写盘)。工作区换成差异审核件:关系表 1 行(LM2596S-ADJ 推荐等级「备选」),明细表 ≥8 行(输出电压 CONDITIONAL,其余 MATCH)。分支 6a。
  6. 审核决策卡到达 →「通过」。AI 跑 S5 → 工作区换成落账确认件(拟写 P-11 一行 analysis_status「FAE 确认」,拟回写 P-01 一行 current_stage gate03)→ 验收卡到达:判据表 L1/L2 全绿、L4 三条带 AI 裁断与理由。 7.「确认通过」→ 中间态「字已签下,正在落地」→ 落地结果屏(进了哪几张表各几行、转出 assessment-released)→ 终态 released。分支 5a。
  7. 工作成果:定稿两份(执行单快照 / 验收单快照)+ 决策卡快照,翻份翻页正常。
  8. 落库验证:
docker exec -i jcai-postgres psql -U jcai -d jcai_wos -Atc "select substitution_relation_id||' '||candidate_internal_pn||' '||recommendation_level||' '||analysis_status||' '||rule_version from ledger.substitution__relation"
docker exec -i jcai-postgres psql -U jcai -d jcai_wos -Atc "select count(*)||' 条明细;UNKNOWN '||sum(case when difference_status='unknown' then 1 else 0 end) from ledger.substitution__difference_item"
docker exec -i jcai-postgres psql -U jcai -d jcai_wos -Atc "select request_id||' '||request_status||' '||current_stage||' '||coalesce(risk_level,'')||' '||coalesce(output_ref,'') from ledger.demand__request"

Expected:一条关系 alternate fae_confirmed R-2026.07;P-01 一行 responded gate03 … RPT-…。 10. 记进 R1 节;有问题按固定流程修、升版、复跑。

  • Step 3: R2 · 候选缺库 · 数据工单 · 审核驳回循环(样例 0)

轮间清场 → 起单样例「候选缺库·派数据工单」。

  1. S0 后对话区应说「XL2596S-5.0 不在库」;进度条停在 1/6「启动与存在性检查」。决策卡「候选 XL2596S-5.0 在芯片主数据里查无记录…」→ 点「创建主数据(派数据工单)」。分支 0b / 1d。
  2. 执行卡「数据工单 · 芯片主数据创建(P-08):XL2596S-5.0」到达;工作区 = P-08 件,一行骨架只有料号(分支 0c 人全填)。空手「提交」→ 闸拦并点名(记原文)。「编辑」→ 填品牌 / 规格 / 封装 / 生命周期(下拉 Active)/ 参数集(textarea 七行,使能阈值写「未披露」)/ Datasheet 引用与版本 →「保存」→ 红带收 →「+ 加一行」加一颗假料再「×」撤掉(增删行链)→「保存」→「提交」。
  3. 执行卡「数据工单 · 原厂技术资料创建(P-10):XL2596S-5.0」到达;件上一行骨架(类型 Datasheet、状态待核实);填标题 / 版本 / 链接 / 证据定位、状态改「有效」→ 保存 → 提交。进度条进 2/6。
  4. 执行卡「评估输入与资料补齐确认」:清单里多一行「XL2596S-5.0 使能阈值」待补(分支 1b);应用场景 / BOM 位填值,使能阈值那行补充内容写「原厂未披露,留 UNKNOWN 挂验证任务」(分支 1c)→ 保存 → 提交。
  5. S2/S3 后审核件:两条关系(XL 条件推荐 / ADJ 备选),明细里「使能阈值 UNKNOWN」那行 UNKNOWN 去向写着 VAL 号。分支 6b。
  6. 审核决策卡 → 在输入框写意见「使能阈值那条 UNKNOWN 的去向请写明验证任务号与测试条件」提交(= custom 驳回,分支 4a)。看:对话区观察「意见按字面点名对上了…」,AI 改那一行(unknown_disposition / validation.record 的 test_condition),换问法再发一张卡;件上那一行确实变了。再驳回一次,意见「证据引用请指到 datasheet 的页码」(分支 4b),看 evidence_ref 变化;第三张卡「通过」。
  7. 拟落账 → 验收卡 → 确认通过 → released。落库验证:
docker exec -i jcai-postgres psql -U jcai -d jcai_wos -Atc "select internal_pn||' '||brand_name||' '||lifecycle_status from ledger.parts__profile where internal_pn='XL2596S-5.0'"
docker exec -i jcai-postgres psql -U jcai -d jcai_wos -Atc "select validation_id||' '||validation_internal_pn||' '||validation_status||' '||left(next_validation_action,40) from ledger.validation__record"
docker exec -i jcai-postgres psql -U jcai -d jcai_wos -Atc "select candidate_internal_pn||' '||recommendation_level||' '||coalesce(verification_requirement,'') from ledger.substitution__relation order by 1"

Expected:XL 真落了 P-08;一条 VAL not_started;XL conditional 且验证要求含 VAL 号;ADJ alternate。 8. 记录:人环次数(决策 3 + 数据 2 + 执行 1 + 放行 1 = 7)、拍数、被拒。

  • Step 4: R2b · 传了 datasheet → AI 预填(样例 0 + 上传)

轮间清场。先造一份文本 datasheet:

cat > "$TMPDIR/XL2596S-5.0-datasheet.txt" <<'EOF'
XL2596S-5.0 XLSEMI 150KHz 3A Buck DC/DC Converter Rev C
Package TO-263-5, BCD process, Active
Input voltage range 4.5V to 40V
Output voltage 5.0V fixed, output current 3A
Switching frequency 150kHz, quiescent current 4.2mA
Operating temperature -40 to +125 C
Enable threshold: not specified
EOF

测试台样例 0 → 「候选 datasheet」文件选择器上传这份 txt(claude-in-chrome file_upload)→ 起单。看 P-08 数据工单的件:品牌 / 规格 / 封装 / 生命周期 / 参数集已预填(参数集里使能阈值是「未披露」),卡上依据写「已按 datasheet 预填 N 项」。核对后直接提交(闸应判「无缺」放行)。其余同 R2,审核直接通过。记录预填了几格、有没有编造(分支 0c 的另一半)。

  • Step 5: R3 · 三候选 · 一颗放弃一颗新建 · 硬约束不推荐(样例 3)

轮间清场 → 起单样例「三候选·含硬约束不推荐」。

  1. 两张决策卡按数组顺序来:XL2596S-5.0 → 点「放弃这颗候选」(不写原因,看 abandon_reason 是否落了「负责人本轮放弃(未注明原因)」)(分支 1d 放弃);CN-DCDC-32V →「创建主数据」。
  2. P-08 数据工单只有 CN 一行 → 填(输入电压范围 4.5~32)→ 提交;P-10 同。
  3. 执行单 → 补两行 → 提交。审核件:两条关系(ADJ 备选、CN 不推荐),CN 的「输入电压范围」行 MISMATCH 高风险、影响说明写着 32V < 40V(分支 3a);XL 不出现在关系表里、核对表里标「已放弃」。
  4. 审核通过 → 放行 → released。落库:select candidate_internal_pn||' '||recommendation_level from ledger.substitution__relation order by 1 → CN-DCDC-32V not_recommended + LM2596S-ADJ alternate;select internal_pn||' '||exists_status from ledger.sub_eval__pn_check 在放行前查(record 表放行后仍在,reset-runs 才清)→ XL abandoned。
  • Step 6: R4 · 原 PN 缺库 · 硬阻塞 · 放行驳回重做(样例 2)

轮间清场 → 起单样例「原PN缺库·硬阻塞」。

  1. S0 后不出决策卡,直接来 P-08 数据工单「LM2596S-3.3」(分支 1d 原 PN);填(输出电压 3.3)→ 提交;P-10 → 提交。
  2. 执行单 → 审核通过 → 落账确认件 → 验收卡:点「重做」,意见写「P-01 的验证要求请把测试条件也写进去」(分支 5b)。看:对话区「验收被打回重做:…」,AI 改 demand.request.verification_requirement(件上那一格变了),再次提交 → 新验收卡 →「确认通过」→ released。
  3. 若 AI 原样重提(基座闸「打回之后台账一个字没改」拒回)→ 这是剧本 T_release 的欠账,改剧本、升版、复跑。
  4. 落库:select original_internal_pn||' → '||candidate_internal_pn||' '||recommendation_level from ledger.substitution__relation;select internal_pn from ledger.parts__profile where internal_pn='LM2596S-3.3'。
  • Step 7: R5 · 新客户 · 录入卡建档(样例 4)

轮间清场 → 起单样例「新客户·先建档」。

  1. S0 发数据录入卡「客户 CU-9001 不在客户档案里…」:名称预填「深圳恒星电源有限公司」,行业 / 区域空 → 填「工业电源 / 广东」→「确认」(分支 0a)。对话区痕「已写入客户档案」;AI 接着写 P-01(customer_code 引用墙这时应过)。
  2. 其余同 R1 → released。落库:select customer_code||' '||name||' '||coalesce(industry,'') from ledger.customer__profile where customer_code='CU-9001' → 一行。
  3. 轮间清场(把 CU-9001 删掉)。
  • Step 8: 收口记录

每轮结束把「结果 / 问题 / 修改 / 复测」填进对应节;分支覆盖表逐行填「结果」(通过 / 未覆盖 + 原因);「3. 结论与欠账」写:五轮终态与被拒数汇总、逼出的修改列表(文件 @ 版本)、未覆盖分支(2a / 2b / 6c)与建议夹具、留给 Task 9 的 debt。走查中改过的活动包随本任务提交:

git add activities/part-substitution-assessment docs/ACT-WORK-05-端到端测试记录.md
git commit -m "test(w05): 浏览器全流程全分支走查五轮(R1–R5 + R2b)与测试记录;走查逼出的活动包修订

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh"

Task 9: 文档、欠账、全量门禁、收尾

Files:

  • Create: docs/数据域字段说明-W05.md

  • Modify: CLAUDE.md(现状快照加一条;文档权威地图加两行)· docs/debt.md(追加一节)

  • Step 1: 写 docs/数据域字段说明-W05.md(口径与 docs/数据域字段说明-E04-I01.md 同一套:一览表 + 每张表「重点字段(按重要程度)」+「数据结构」;★ = 重点,【人填】= 人在件上 / 录入卡填)

# 数据域字段说明 —— ACT-WORK-05 差异评估

> 对应设计中心 → 数据域「ACT-WORK-05 · 差异评估」分组。字段以活动清单的台账声明为准
> (`activities/part-substitution-assessment/src/manifest.ts` 的 `declarations.ledger`);
> 主数据六张的**物理结构**由平台基线 `apps/api/src/app/seed-mvp-data.ts` 物化(《SS02-SS03-SS04_Demo台账字段细化与统一方案》§4.2 全列),
> 活动只追加自己要用的列与标记。约束词表见 `docs/数据域字段说明-E04-I01.md`「怎么读这份文档」。

### 一览

| 全景 | 台账地址 | 人读名 | 性质 | 本活动用到的列 | 人填列 |
|---|---|---|---|---|---|
| L-02 | `customer.profile` | 客户档案 | 主数据 · 共享 | 4 | 3(录入卡,仅新客户) |
| P-08 | `parts.profile` | 芯片主数据 | 主数据 · 共享 | 12 | 12(数据工单件上) |
| P-10 | `parts.document` | 原厂技术资料库 | 主数据 · 共享 | 9 | 9(数据工单件上) |
| P-01 | `demand.request` | 客户需求主记录 | 主数据 · 共享 | 17 | 0(应用场景 / BOM 位经执行单由 AI 抄入) |
| P-11 | `substitution.relation` | 替代关系主记录 | 主数据 · 共享 | 17 | 0(只审不改) |
| P-11 子 | `substitution.difference_item` | 差异明细 | 主数据 · 共享 | 15 | 0 |
| P-06 | `validation.record` | 芯片验证记录 | 主数据 · 共享 | 8 | 0(只写验证要求) |
| — | `sub_eval.pn_check` | 料号存在性核对 | 本活动工作台账 | 6 | 0(决策卡定放弃) |
| — | `sub_eval.input_gap` | 评估输入清单 | 本活动工作台账 | 8 | 3(补充内容 / 加行) |
| — | `sub_eval.verdict` | 本次评估结论 | 本活动工作台账 | 14 | 0 |

## 一、`parts.profile` 芯片主数据(P-08)

一张表:料号一行,标准化参数集是它的一个字段(一行一参数 `参数名 | 值 | 单位 | hard/soft | 说明`)。
库里查无记录 → 数据工单,人在件上从零建整条(可加行);确认落草稿层,放行并入正式表。

**重点字段(按重要程度)**
1. `internal_pn` ★【人填】主键 —— 全链路只认它,不可自由文本
2. `normalized_parameter_set` ★【人填】必填 —— 差异评估逐项比对的唯一输入;「未披露」进 UNKNOWN
3. `lifecycle_status` ★【人填】必填 · 取值域 `active/nrnd/eol/discontinued/risk`
4. `package` ★【人填】必填 —— 硬约束
5. `specification_description` ★【人填】必填
6. `brand_name` ★【人填】必填
7. `datasheet_ref` / `datasheet_version` ★【人填】建议填(成对)—— 证据回链

**数据结构**

| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|---|---|---|---|---|---|
| `internal_pn` ★ | 内部料号 | text | 主键 | 人(件上)/ 种子 | |
| `brand_name` ★ | 品牌名称 | text | 必填 | 人(件上)/ AI 预填 | |
| `manufacturer_name` | 制造商名称 | text | 建议填 | 人(件上) | 与品牌不一致时分开记 |
| `specification_description` ★ | 规格描述 | text | 必填 | 人(件上)/ AI 预填 | |
| `normalized_parameter_set` ★ | 标准化参数集 | text | 必填 | 人(件上)/ AI 预填 | 一行一参数 |
| `package` ★ | 封装 | text | 必填 | 人(件上)/ AI 预填 | 硬约束 |
| `process_technology` | 工艺 | text | 建议填 | 人(件上) | 硬约束 |
| `lifecycle_status` ★ | 生命周期状态 | text | 必填 · 取值域 | 人(件上,下拉) | |
| `eol_pcn_status` | EOL/PCN 状态 | text | 建议填 | 人(件上) | |
| `datasheet_ref` ★ | Datasheet 引用 | text | 建议填 | 人(件上)/ AI 预填 | |
| `datasheet_version` ★ | Datasheet 版本 | text | 建议填 | 人(件上)/ AI 预填 | 有引用就该有版本 |
| `application_scenarios` | 适配应用场景 | text | 建议填 | 人(件上) | |

(其余九张表照这个格式写全:列名逐字照清单抄,「写入者」按剧本 —— S0 写 P-01 / pn_check / verdict;S0d 人建 P-08 / P-10;S1 人补 input_gap、AI 抄两列进 P-01;S2/S3 写 relation / difference_item / validation;S5 写 relation.analysis_status 与 P-01 五项。customer_approval_status 一栏写「审批流维护,本活动无写权」。)

  • Step 2: CLAUDE.md

「现状快照」末尾加一条(照既有条目的写法:事实 + 账):

- **ACT-WORK-05 差异评估补齐到验收线**(2026-08-26,`part-substitution-assessment@<最终版本>`):本仓第三个真活动包,
  也是第一个把 SS02 主数据(P-08 / P-10 / P-01 / P-11 主+子 / P-06)建进库的活动 —— 六张表进平台基线
  `MASTER_OBJECTS`(四域 → 八域),活动只追加列与标记;P-08 一张表(参数集是一个 text 字段,一行一参数)。
  **数据工单** = 零输入执行工单 + 件上可加行(一张表一张单:P-08 / P-10),确认落草稿、放行并入(守铁律②)。
  四态与推荐等级由两枚纯工具算(`compare-parameters` / `recommend`,规则 R-2026.07 同时以 DMN 随包冻结);
  审核走决策卡循环,放行终局。**e2e 五案例 5/5 released、被拒 0**;浏览器五轮(R1–R5 + R2b)走查见
  `docs/ACT-WORK-05-端到端测试记录.md`(18 条分支覆盖表:2a / 2b / 6c 未覆盖,记 debt)。
  <走查逼出的修改各一句>

「文档权威地图」表里加两行:docs/数据域字段说明-W05.md(ACT-WORK-05 十张表字段口径)与 docs/ACT-WORK-05-端到端测试记录.md(逐轮测试与修改结论)。

  • Step 3: docs/debt.md 追加一节
## 2026-08-26 · ACT-WORK-05 差异评估落地时留下的欠账

- **数据工单还没有自己的卡皮**:对话区里它是一张标题带「数据工单 ·」的执行卡;文档要的「数据卡」
  (`workOrder:'data'`)要走契约四步 + 前端卡皮,MVP 先不做。
- **datasheet 只能在起单时上传**:平台文件入口只有起单 `format:'file'`;文档说「在数据工单里上传」,
  件上 / 卡上的上传控件是新基座能力。
- **PW 与 FAE 一人兼**:平台一任务恰一名负责人,执行单与验收单都发给 owner;派给第二个人是新能力。
- **参数集是 text 列的行语法**,不是《统一方案》字面上的 Structure / 子表:件上用 textarea 一行一参数;
  要按行编辑就得物理拆 `parts.parameter`(用户定「一张表」,先不拆)。
- **`market.part`(老料号占位表,主键 `pn`)与 `parts.profile`(P-08,主键 `internal_pn`)重叠**:
  前者空且无人读写,待并或退役。
- **分支 2a 单位冲突 / 2b 证据版本冲突 / 6c 全不推荐 未覆盖**:需要冲突单位与冲突版本的夹具料号、
  以及一颗全硬约束失败的组合;补夹具后加 e2e 案例。
- **存在性检查靠模型读整表 + refersTo 墙**:没有「这颗料在不在库」的基座读数;料号表大了整表进上下文会撑爆
  截断阈值,届时要补一枚 `parts.exists` 读工具或基座读数。
- **`customer_approval_status` 不在活动声明里**,所以件上那一格是静态文案「未提交」而不是取值 —— 审批流做起来后改成 data-slot。
  • Step 4: 全量门禁(退出码)
pnpm gates; echo "gates exit=$?"

Expected: gates exit=0。红了逐个修(常见:lint 的 §12.5 禁止项、depcruise 活动包越界 import、api:check —— 本计划没改 packages/contracts,api-report 不该变;若变了说明改了契约,补 pnpm api:check --update && git add api-report)。

  • Step 5: 提交、收尾
git add docs/数据域字段说明-W05.md docs/debt.md CLAUDE.md
git commit -m "docs(w05): 数据域字段说明、现状快照、欠账登记

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh"
git log --oneline mvp-foundation..HEAD
./stable.sh start      # 走查完把稳定版起回来(要它带上新活动就 ./stable.sh update)

合并与推送按用户的规矩由用户在终端做(主支发起合并再推主支;本会话无 TTY 推不了 Gitea)—— 收尾时把分支名 feat/act-work-05 与提交列表报给用户。


自检清单(写完计划后过一遍)

  • 规格覆盖:启动 4 项 / 双通道无感知(渠道落 origin)/ 存在性检查 / 数据工单 P-08 + P-10 一表一单 / 候选 ≥2 放弃决策 / 原 PN 硬阻塞 / 执行单 01 缺口补齐与 UNKNOWN 合法放行 / 步骤 2–3 纯 AI 不派单 / 四态 + 风险 + 证据 + 置信度 / 推荐四档 / 验收 01 四判据(决策卡循环、意见写 P-06)/ 验收 02 四判据(终局放行)/ P-11 主 + 子落账 / P-01 回写五项 / analysis_status 与 customer_approval_status 分离 / 规则版本锁定 / 多候选并行各一条 P-11 / 台账建进库(基线 + 清单)/ e2e 五案例 / 浏览器五轮 + 分支覆盖表 / 测试记录文档 —— 各有任务落点。
  • 未覆盖且已明示:2a / 2b / 6c(夹具缺)、6d(范围外)、数据卡卡皮、件上上传、双负责人。
  • 名字一致性:活动 id part-substitution-assessment;域 sub_eval / parts / demand / substitution / validation;视图名五个;计划项八个;工具两个;出口 assessment-released;读数名与 tally 值在 Task 3/4/5/7 之间逐字相同(exists_status: found/absent/created/abandoned,status: gap/default/ok,四态 match/mismatch/unknown/conditional,推荐 recommended/conditional/alternate/not_recommended)。
  • 每个代码步骤给了完整代码;每个验证步骤给了命令与期望输出;没有「TBD / 后补 / 类似 Task N」。