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

3776 lines
249 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 用):
```sql
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 开功能分支**(工作树里已有的未提交改动跟着走,不碰它们)
```bash
cd /Users/xcs/project2/tmp/BPOS-MVP
git checkout -b feat/act-work-05
git status --short | wc -l # 记下这个数:本计划的提交只 add 自己点名的文件
```
- [ ] **Step 2: 确认库与两套服务在跑**
```bash
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 凭证在环境里**(值不打印)
```bash
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` 把 `'只有客户 / 项目 / 供应商 / 市场四个域'` 那条改成:
```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` 之后、`];` 之前)追加六张表**
```ts
/* ── 料件: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` 改为:
```ts
/** 八域的呈现次序 —— 谁在买 → 为什么买 → 从哪买 → 买什么 → 料件 → 需求 → 替代 → 验证 */
export const MASTER_DOMAINS = ['customer', 'project', 'supplier', 'market', 'parts', 'demand', 'substitution', 'validation'] as const;
```
- [ ] **Step 4: 在 `MASTER_SAMPLE_ROWS` 数组末尾追加样例料号**(现有最后一个元素之后、`];` 之前)
```ts
/*
* ── 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]` 仍是样例客户,不改也不炸;但下一个人再往前插就错)——改为:
```ts
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: 类型检查 + 提交**
```bash
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`:
```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` 之前加实现**
```ts
/* ── 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` 数组末尾登记:
```ts
['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)**
```ts
/**
* 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: 全绿。
```bash
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: 建包骨架**
```bash
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`**
```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`**
```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` 占位:
```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` 占位:
```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 收尾时全绿)
```ts
/**
* 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: 提交骨架**
```bash
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`**
```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`**
```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: 提交**
```bash
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`(数据驱动的开关全在那里:三档占位 / 缺项红带 / 编辑闸 / 增删行 / 状态徽基座);然后按下面写其余内容:
```ts
/**
* 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` 与包内文件)。
```bash
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 区加:
```ts
import { manifest as partSubstitutionAssessment } from '../activities/part-substitution-assessment/src/manifest.ts';
```
`PACKAGES` 数组末尾加:
```ts
{ dir: 'part-substitution-assessment', manifest: partSubstitutionAssessment },
```
- [ ] **Step 2: 登记到安装通道并顺带注册纯工具**
`apps/api/src/app/seed-activities.ts`:
```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();` 之前插入:
```ts
/*
* **纯工具随活动一起注册**(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: 构建 + 装包(这一步同时物化六张主数据表、三张自持表,并播样例料号)**
```bash
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: 库内验收(表、主键、性质、注释、样例行)**
```bash
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: 全绿。
```bash
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()) {` 改为
```js
// 案例级按表的编辑叠在套件级之上(同一套件不同案例要建的料号不同 —— 参数集不能是同一份)
const edits = [...(stage.edits ?? []), ...((kase.stageEdits ?? {})[stage.address] ?? [])];
for (const [i, edit] of edits.entries()) {
```
在 `if (stage.fillAllBlank) { … }` 之后加:
```js
/*
* 按条件整行扫空:只给某一档状态的行填(评估输入清单里只有 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` 里
```js
const rows = draftRows(body.drafts ?? [], stage.address);
const blank = rows.filter((r) => String(r[column] ?? '').trim() === '').length;
```
改为
```js
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: {…}` 之后、`};` 之前)**
```js
/*
* 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: 跑套件(先停稳定版,免得两台后端交替推同一单)**
```bash
./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 才算),然后清运行数据、提交**
```bash
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: 建记录文档 + 走查前置**
```bash
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 登录页就停下来请用户在浏览器里登录一次**(凭证只有他有),登录后继续。每轮之间跑「轮间清场」:
```bash
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**。
8. 工作成果:定稿两份(执行单快照 / 验收单快照)+ 决策卡快照,翻份翻页正常。
9. 落库验证:
```bash
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。落库验证:
```bash
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:
```bash
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。走查中改过的活动包随本任务提交:
```bash
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` 同一套:一览表 + 每张表「重点字段(按重要程度)」+「数据结构」;★ = 重点,【人填】= 人在件上 / 录入卡填)
```markdown
# 数据域字段说明 —— 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**
「现状快照」末尾加一条(照既有条目的写法:事实 + 账):
```markdown
- **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` 追加一节**
```markdown
## 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: 全量门禁(退出码)**
```bash
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: 提交、收尾**
```bash
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` 与提交列表报给用户。
---
## 自检清单(写完计划后过一遍)
- [x] 规格覆盖:启动 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 五案例 / 浏览器五轮 + 分支覆盖表 / 测试记录文档 —— 各有任务落点。
- [x] 未覆盖且已明示:2a / 2b / 6c(夹具缺)、6d(范围外)、数据卡卡皮、件上上传、双负责人。
- [x] 名字一致性:活动 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`)。
- [x] 每个代码步骤给了完整代码;每个验证步骤给了命令与期望输出;没有「TBD / 后补 / 类似 Task N」。