From b9951483bce7079265c987fd67b24d3aad1f189b Mon Sep 17 00:00:00 2001 From: xcs Date: Sat, 29 Aug 2026 02:56:39 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20ADR-014=20=E7=A1=AE=E5=AE=9A=E6=80=A7?= =?UTF-8?q?=E6=AD=A5=E9=AA=A4=20+=20W05=20=E9=87=8D=E5=86=99=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=20spec=20+=20=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92(?= =?UTF-8?q?=E7=AC=AC=E4=B8=80=E4=BB=BD:=E5=B9=B3=E5=8F=B0=E6=9C=BA?= =?UTF-8?q?=E5=88=B6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spec 定死八条:配方驱动零工具 · 六张台账 + P-06 按结论入账 · 四态 AI 定等级规则算 · 保存即铺格 · 类型不一致硬拦 · 审核卡只有确认 · 只做真用得到的那几式。 计划十三个任务,机制先在纯函数与假端口上钉死,再去接真活动。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet --- CLAUDE.md | 13 + docs/adr/ADR-014-deterministic-steps.md | 340 ++ docs/debt.md | 2 + ...03-04-台账体系全景-18Activity关联矩阵.html | 1455 ++++++++ ...02-SS03-SS04_Demo台账字段细化与统一方案.md | 632 ++++ ...T-WORK-05_差异评估_E04-I01架构版_V1.2.html | 4 + ...6-08-29-deterministic-steps-auto-recipe.md | 3163 +++++++++++++++++ .../2026-08-29-act-work-05-rewrite-design.md | 572 +++ 8 files changed, 6181 insertions(+) create mode 100644 docs/adr/ADR-014-deterministic-steps.md create mode 100644 docs/ss02-act-05-new/SS01-02-03-04-台账体系全景-18Activity关联矩阵.html create mode 100644 docs/ss02-act-05-new/SS02-SS03-SS04_Demo台账字段细化与统一方案.md create mode 100644 docs/ss02-act-05-new/SS02_ACT-WORK-05_差异评估_E04-I01架构版_V1.2.html create mode 100644 docs/superpowers/plans/2026-08-29-deterministic-steps-auto-recipe.md create mode 100644 docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md diff --git a/CLAUDE.md b/CLAUDE.md index 10c944d..b5843ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1622,6 +1622,18 @@ 平台缺「确定性步骤」(纯工具只读、判定表不写台账、doneWhen 只收步),所以只能写进 guide; 引擎三样能力都有(taskView / draftWrite / issueExecution),缺的只是声明面。 +- **确定性步骤已出设计,未动代码**(2026-08-28,ADR-014):盘完 W05 九步 —— + **三步整个确定**(S0_bind / S0d_p10 / S5)、四步八成确定、只有 S3 是 agent 主场;一单十来拍里七八拍在做确定的活。 + agent 真正不可替代的只有四类:自然语言→结构化 · 读人的意见落到具体行 · 写给人看的话 · 非结构化抽取(实跑中一次没发生)。 + 设计要点:**声明式配方进 manifest,不执行包代码**(ADR-004「不执行代码即知包为何物」;artifact 至今零执行, + 不为这个功能开沙箱面);三个动词 `project` / `card` / `say`,条件复用 S-FEEL、聚合复用基座读数,不发明第二套求值; + `auto` 是「问模型之前先把确定的事做了」,作用域到交出控制权为止;**一步要么确定要么不是**(判断点先切出去, + 不做「一半 auto」);出错交人不重试不退回模型;静态审把地址/列名/S-FEEL 在装的时候审死。 + **回溯验算:已记录的八条事故落在会被 auto 掉的三步半上**(编号抄别单 / 判在库判错 / 派给在库的料 / + 没写骨架就发卡 / 清单漏列 / 库里有值当缺口 / 抄别的料的资料 / 撞墙五拍熔断)。 + 立场要改口:**agent 只在真需要判断的地方出现,与「人只在责任点与放行点出现」是同一条原则。** + 五条待定见 ADR §11(决策卡给不给配方 / 工具串接 / humanWrites 细到列 / S2 与比对工具的 join 重复 / T_release 第三条裁量该删)。 + - **运营中心活动详情沙箱换成「关键台账数据」看板,数据接真台账**(2026-08-27 晚,用户定:只摆最近一次已放行 实例、替换掉全流程分支图):模版搬自 `docs/ss02-act-05/ACT-WORK-05-关键台账数据.html`(P-01 KV / P-08 逐颗 + 参数瓷贴 / P-11 主记录 + 差异明细 / P-10 / P-06 本单 + 同料号历史),徽标文案全由 `data-class-of` 投影。 @@ -1648,6 +1660,7 @@ | | `docs/API文档-鉴权与人员.md` | 鉴权 / 人员 / 角色→端接口的人话版 + 全部路由归属总表;契约不一致时以 `packages/contracts` 为准 | | | `docs/ACT-WORK-05-端到端测试记录.md` | **ACT-WORK-05 差异评估的走查账**:六轮逐轮结论 + A–K 十一条发现总表(现象 / 病根 / 治法 / 层) | | | `docs/数据域字段说明-W05.md` | ACT-WORK-05 的十张台账 / 110 列 / 人填列 27 —— 建活动时照它对字段 | +| | `docs/adr/ADR-014-deterministic-steps.md` | **确定性步骤的设计(提案,未实现)**:W05 九步的确定/判断盘点、`PlanStep.auto` 声明式配方(三动词)、引擎拍内次序、回溯验算、迁移次序、五条待定 | | | `docs/debt.md` | 欠账台账(只增,销账划线) | | | `docs/adr/` · 各模块 `CONTRACT.md` | 架构决策与模块级不变量 | | 业务样例(活) | `docs/MVP示例工作流.html` | 七节点 SOP + 判定表 + 台账域 —— 开发活动的业务素材 | diff --git a/docs/adr/ADR-014-deterministic-steps.md b/docs/adr/ADR-014-deterministic-steps.md new file mode 100644 index 0000000..66ab53e --- /dev/null +++ b/docs/adr/ADR-014-deterministic-steps.md @@ -0,0 +1,340 @@ +# ADR-014 确定性步骤:确定的活由引擎按声明做,不问模型 + +状态:**提案**(2026-08-28,待用户定) +背景出处:ACT-WORK-05 差异评估九步流程盘点(§2)、`docs/ACT-WORK-05-端到端测试记录.md` 发现表 A–AE。 +关联:ADR-004(活动包 ABI:**系统不执行代码即知包为何物**)、`docs/活动开发规范.md` §4.4(自动收步)、 +`PlanStep.humanWrites`(2026-08-28 立的墙)、ADR-005(工具契约与落地预检)。 + +--- + +## 1. 问题 + +用户 2026-08-28 的原话: + +> 「这个不是一个确定的流程吗?根本不需要交给 ai,你直接查库不就知道有没有了吗?」 + +指的是 W05 的「逐个料号查库 → 不在库就铺骨架、发数据工单」。这一段没有任何判断余地,却写成了模型的 guide。 +不是活动作者偷懒 —— **平台里没有别的落点**: + +| 现有机制 | 能做 | 不能做 | +|---|---|---| +| 纯工具(`pure`) | 算、读台账(1.2.0 起) | 写台账、发卡 | +| 判定表(DMN) | 出结论 | 写台账、发卡 | +| `doneWhen` | 判「办完没有」,引擎自己收步 | **做事** | +| 哨兵(`entryCriteria`) | 判「能不能进这一步」 | **做事** | +| 视图绑定语言 | 把台账取出来**摆到屏上** | 写回去 | + +「读台账 + 写草稿 + 发人卡」跨了三样,没有一个机制能整段承接。而引擎这三样能力**全有** +(`data.view` / `data.draftWrite` / `humanTool.issueExecution`)—— 缺的只是**声明面**。 + +`packages/activity-sdk/src/define.ts` 里 `run?` 的注释早就写着: + +> 留着它是为**确定性子序列(结晶片段)**将来有个落点;不写就是不写,不是缺件。 + +这份 ADR 就是给那个落点定形。 + +**代价已经付过。** W05 的事故里,至少八条落在「本来就该是确定的」那几步上(§7 逐条验算)。 + +--- + +## 2. 盘点:W05 九步里哪些是确定的 + +| 步 | 确定的部分 | 需判断的部分 | +|---|---|---| +| **S0_bind** 建上下文 | 铸案号 · 查客户在不在库 · 写需求单(入参搬运+常量)· 逐料号查库写核对表 · 写结论行 | **零** | +| **S0d_p08** 派主数据单 | 定名单 · 铺三张表骨架 · 发卡 · 人交回后改旗 | 有 datasheet 附件时从原文抽参数值 | +| **S0d_p10** 派资料单 | 定名单 · 发卡 · 人交回后改旗 | **零** | +| **S1** 评估输入清单 | 清单九行(四行绑定投影 + 两行固定缺口 + 三行常量默认)· 发执行单 · 把 supplement 抄进需求单 | 人补的自由文本**归纳**成技术约束 | +| **S2** 标准化对照表 | 关系骨架 · 逐参数对照行(原 PN 参数集 × 候选同名行的 join)· 补封装/生命周期两行 | 单位换算;`constraint_type` 未标时归类(**应进 DMN,不进配方**) | +| **S3** 逐项差异评估 | 工具串接 · 四态照抄 · 计数 · `difference_summary` · 置信取最小 | **客户原话 → 约束式** · 影响陈述 · UNKNOWN 去向与验证条件 · 推荐理由扩写 | +| **S4** 结论审核 | 发决策卡(内容是计数投影)· approve 收步 | **读人的自由文本意见 → 定位到具体行 → 改实** | +| **S5** 拟落账 | 分析状态推 FAE 确认 · 回写需求单五项(风险取最高档、VAL 号拼接、编号、常量三项) | 只有 summary 一句人话(可模板化) | +| **T_release** 放行 | submit · 无分支去向 | 两条裁量判据 · 打回后修订 | + +**agent 真正不可替代的只有四类**:①自然语言 → 结构化;②读人的意见并落到具体行;③写给人看的话; +④非结构化来源抽取(datasheet 抽值、单位换算)。第四类在实跑中一次都没发生过(没传附件)。 + +**结论:九步里三步整个确定(S0_bind / S0d_p10 / S5),四步八成确定,只有 S3 是 agent 的主场。** +按拍数算,一单十来拍里有七八拍在做确定性的活 —— 而每一拍都是一次采样,都有编错号、抄错行、漏一列的机会。 + +--- + +## 3. 决策 + +### 3.1 声明式配方,**不执行活动包代码** + +`sys.activity_package.artifact`(包的 `index.js`)存了库,但**至今一次都没被执行过**。这份设计**不开这条路**: + +- ADR-004 的立场是「系统不执行代码即知包为何物」;配方是**数据**,装进来仍然只是数据, + 可静态审、可回放、可在治理台上摆出「这一步系统会做什么」。执行代码则相反。 +- 执行第三方包要配沙箱、超时、内存墙,而 `scanArtifact` 只是文本扫描,拦不住混淆。 +- 配方要表达的形状**很窄**(§3.2 三个动词),不值得为它开一个能跑任意代码的面。 + +**代价**:表达力有上限。上限之外的**不要硬塞** —— 退回模型,或者把那一段切成独立的 agent 步(§3.4)。 + +### 3.2 三个动词,复用已有的两套求值 + +配方只有三个动词: + +| 动词 | 做什么 | +|---|---| +| `project` | 从「某个来源的每一行」投影出台账行,`draftWrite` 落草稿层 | +| `card` | 发一张人类工具的卡(执行工单 / 数据工单 / 数据录入卡),**至多一张** | +| `say` | 一句给人看的交代(模板串,不是模型生成) | + +条件与聚合**不发明第二套求值**: +- 条件用 **S-FEEL**(`when`,对任务视图求值)—— 与哨兵、`doneWhen` 同一个求值器; +- 计数/聚合用**基座读数**(`_rows` / `_missing` / `_distinct_x` / tally 计数)—— 本来就在作用域里。 + +### 3.3 `auto` 是「问模型之前先把确定的事做了」,不是「整步替代」 + +`PlanStep.auto` 是一个属性。引擎在这一步的**每一拍拍首**先跑配方,然后: + +- 配方发了卡 → 这一拍到此为止(**零 token**,不问模型); +- 没发卡且这一步**没有 `guide`** → 求 `doneWhen`,真了收步、进下一步(下一步若也是 auto 就接着跑); +- 没发卡且这一步**有 `guide`** → 照常问模型(半 auto:确定的已经做完,模型只面对剩下的判断)。 + +这样老步骤一个字不改,可以一步一步搬。 + +### 3.4 作用域:**一个计划项内,到交出控制权为止** + +配方跑到「发卡」就结束 —— 与现有 `handsOff` 语义(ask/human/decide/listen/submit 是交出控制权)对齐。 +人答回来之后,那一步若还有活,归 `guide`。 + +**推论(重要)**:一步要么确定,要么不是。**不做「一半 auto」的复杂机制** —— 判断点要先从那一步里切出去。 +例:S0d_p08 的「datasheet 抽值」应当移出这一步(归到起单读来件),这一步才变成纯 auto。 +**这条逼着活动作者把判断点想清楚,是特性不是限制。** + +### 3.5 幂等 + +配方会被反复进入(人答卡后再 tick),必须幂等: + +- **写**:`draftWrite` 本来就是同 PK 覆盖。值没变 → **静默跳过,不留痕、不算空转被拒** + (「写了等于没写」那条判据是拦模型空转的,配方不是模型); +- **发卡**:先看 `worldTouched` —— 发过就不再发。人工激活的 auto 步,**配方发卡就是启动**。 + +### 3.6 出错 = 交人,不重试、不退回模型 + +配方抛错(地址不存在、S-FEEL 算不出、卡骨架不合契约)是**活动包的 bug**:如实留痕 + 交人。 +**不重试五遍,也不退回模型** —— 退回模型就是让它去凑,那正是这份 ADR 要消灭的东西。 + +### 3.7 静态审在装的时候把住 + +`staticAudit` 扩到配方(与 `refersTo` 目标检查同款,写错地址 = 每一单都错): + +- `into` / `each` / `lookup` / `joinRows` 的台账地址必须在 `declarations.ledger` 里(与写的授权面同一份名单); +- `fields` 的列名必须在该对象的列声明里; +- `when` 的 S-FEEL 必须能解析,引用的名字要绑得上(`boundNames`,已有); +- 卡骨架的 `view` 必须在 `viewTemplates` 里; +- 同一张表既在 `auto.project` 里又在 `humanWrites` 里 → **警告**(铺骨架合法,但要作者确认列不重叠)。 + +### 3.8 观测与度量 + +- 配方的每一笔照常进 `sys.task_memory`,`detail.auto = '<步名>#<第几笔>'`; + 工作日志三色里归**系统灰**(已有档,不必新增); +- 治理台的活动详情能摆出「这一步系统会做什么」(配方是数据,渲染得出来)—— 这是相对提示词的一大好处; +- **自主率的分法要认第三档**:现在两档(AI / 人),配方做的事既不是 AI 判的也不是人办的,是系统做的。 + +--- + +## 4. 声明形状 + +```ts +// PlanStep 新增(可选,老活动一个字不改) +auto?: AutoRecipe; + +type AutoRecipe = { + /** 配方级局部值:先算一遍,后面所有表达式都能引用({in:'evalRef'})—— 整单只有一个案号,靠它 */ + readonly let?: Record; + /** 按序执行;每一笔可带 when(S-FEEL),假就跳过这一笔 */ + readonly do: ReadonlyArray; +}; + +type AutoAction = + | { kind: 'project'; when?: string; into: string; from: AutoSource; fields: Record } + | { kind: 'card'; when?: string; workOrder: 'execution' | 'entry'; variant?: 'data'; + title: AutoExpr; gateLedgers?: string[]; view?: string; /* 指导书等静态文本直接写 */ } + | { kind: 'say'; when?: string; text: AutoExpr }; + +type AutoSource = + | { once: true } // 一行 + | { rows: ReadonlyArray> } // 固定几行(S1 的默认三项) + | { each: string; where?: Record; // 'in:candidateInternalPns' | 'mine:域.对象' | 'ledger:域.对象' + then?: { each: string; where?: Record } }; // 第二层,**最多两层** + +type AutoExpr = + | string | number | boolean // 常量 + | { in: string } // 入参 / case:* / 基座读数 / let + | { self: true } // each 是数组时的本项 + | { col: string } | { outer: string } // 本行的列 / 外层那一行的列 + | { cat: AutoExpr[] } // 拼接 + | { fmt: AutoExpr; as: 'digits' } // 只留数字(铸号) + | { lookup: string; by: Record; take: string; else?: AutoExpr } // 查另一张表的一列 + | { joinRows: string; where?: Record; take: string; sep?: string } // 多行一列拼成串 + | { when: string; then: AutoExpr; else: AutoExpr }; // S-FEEL 条件(可嵌套) +``` + +**字段按声明顺序求值,已算出的字段进同一行的 S-FEEL 作用域** —— 「先 lookup 出依据,再按依据判 found/absent」 +这种写法靠它成立。 + +### 4.1 例:S0d_p10_document(整步确定,最小的那个) + +```ts +auto: { + do: [ + { kind: 'card', + when: 'sub_eval_pn_check_exists_status_absent > 0', + workOrder: 'execution', variant: 'data', view: 'p10-document-create', + gateLedgers: ['parts.document'], + title: { cat: ['数据工单 · 原厂技术资料创建(P-10):', + { joinRows: 'mine:sub_eval.pn_check', where: { exists_status: 'absent' }, + take: 'internal_pn', sep: '、' }] } }, + { kind: 'say', text: '这几颗料的原厂技术资料库里查无记录,已派数据工单请你登记。' }, + ], +} +// guide 不写。doneWhen 照旧:parts_document_rows >= parts_profile_rows and parts_document_missing = 0 +// 人在件上填完确认 → 数据即入库 → 下一拍 doneWhen 成立 → 引擎收步。**模型在这一步一次都不出现。** +``` + +### 4.2 例:S0_bind 的铸号与存在性核对 + +```ts +auto: { + let: { + digits: { fmt: { in: 'case:openedAt' }, as: 'digits' }, + evalRef: { cat: ['W05-', { in: 'digits' }] }, + requestId:{ cat: ['REQ-', { in: 'digits' }] }, + }, + do: [ + // 客户不在库 → 发数据录入卡,这一拍到此为止 + { kind: 'card', when: 'customer_profile_rows = 0', workOrder: 'entry', /* … */ }, + { kind: 'project', into: 'demand.request', from: { once: true }, + fields: { request_id: { in: 'requestId' }, customer_code: { in: 'customerCode' }, + requirement_version: 'V1', request_type: '技术需求', + request_description: { in: 'requestDescription' }, + input_snapshot_ref: { cat: ['SNAP-', { in: 'evalRef' }] }, + original_internal_pn: { in: 'originalInternalPn' }, + request_status: 'pending', current_activity: 'ACT-WORK-05', current_stage: 'assessing' } }, + { kind: 'project', into: 'sub_eval.pn_check', from: { each: 'in:candidateInternalPns' }, + fields: { eval_ref: { in: 'evalRef' }, internal_pn: { self: true }, role: 'candidate', + found_pn_ref: { lookup: 'ledger:parts.profile', by: { internal_pn: { self: true } }, + take: 'internal_pn', else: '' }, + exists_status: { when: 'found_pn_ref != ""', then: 'found', else: 'absent' } } }, + // 原 PN 那一行同理(from: { once: true },role: 'original') + { kind: 'project', into: 'sub_eval.verdict', from: { once: true }, fields: { /* … */ } }, + ], +} +``` + +「在不在库」从此是一次 `lookup`,不是模型的一句断言 —— 08-27 那条「原 PN 不在库却判在库」结构性消失。 + +--- + +## 5. 引擎行为(拍内次序) + +``` +tick 开始 + ├─ ① 拍首自动收步(现有:doneWhen 为真的项,人工激活项需 worldTouched) + ├─ ② 算 current(当下在办的那一步) + ├─ ③ current.auto 存在 → 跑配方 + │ ├─ 求 let + │ ├─ 按序执行每一笔(when 假则跳过) + │ │ · project → 逐行 draftWrite(同 PK 覆盖;值没变静默跳过) + │ │ · card → 先看 worldTouched,发过就跳过;发了标 worldTouched、烧 humanCalls + │ │ · say → 进对话区 + │ ├─ 每一笔留痕(by=系统,detail.auto) + │ └─ 抛错 → 留痕 + 交人(不重试、不退回模型) + ├─ ④ 发了卡 → status awaiting,收拍(零 token) + ├─ ⑤ 没发卡 → 重求 doneWhen;真了收步,回 ② (封顶 8 轮,与现有自动收步同款) + ├─ ⑥ 这一步有 guide → 照常问模型(半 auto) + └─ ⑦ 无 guide、doneWhen 不成立、也没有卡在等 → **交人**(配方没做到位,是包的 bug) +``` + +**预算**:配方不烧 token;发卡烧 `humanCalls`;配方段在同一 tick 内,不额外计 step。 + +--- + +## 6. 与现有机制的衔接 + +| 机制 | 处置 | +|---|---| +| `doneWhen` | 不变。auto 步几乎都该有 —— 它做的事是可验的 | +| 哨兵 `entryCriteria` | 不变。auto 步照样要满足入场前提才在场 | +| `worldTouched` | 配方发的卡同样标 —— 它是真发了 | +| `humanWrites`(2026-08-28) | **配方豁免**:那道墙防的是模型即兴,配方是审过的声明。铺骨架合法,人填内容不冲突(静态审提示列不要重叠) | +| 写三分 | 不变:配方只写**草稿层**,落正式表仍靠放行 / 数据工单确认入库 | +| 「一单一卡」 | 不变:配方至多一张卡 | +| 熔断 | 配方不会「被拒」,不进 `stalledTurns` | +| 试跑 / 回放 | 配方是纯数据求值,同输入同结果 —— 可回放。**配方里没有时钟与随机**(时刻取 `case:openedAt`) | +| 记账旗 | **该删的删,不是该 auto** —— `input_confirmed_total` 这类基座本就数得出来 | + +--- + +## 7. 回溯验算:历史事故有几条会结构性消失 + +| # | 事故 | 落在哪 | auto 后 | +|---|---|---|---| +| 1 | 编号从正式表里**别单的案号**里挑 | S0_bind 铸号 | 消失(模板铸号) | +| 2 | 原 PN 不在库却判「在库」(发现 I) | S0_bind 查库 | 消失(lookup 现查) | +| 3 | 数据工单派给了**在库**的那颗(发现 J) | S0d_p08 名单 | 消失(where absent) | +| 4 | 压拍没写骨架就发卡,人打开是空表(发现 Q) | S0d_p08/p10 | 消失(配方按序) | +| 5 | S1 清单 11 行全漏 `input_item`,人无处可填 | S1 前半 | 消失(字段声明齐全) | +| 6 | 库里有值的参数被 S1 当缺口再问一遍(发现 AB,三次复现) | S1 前半 | 消失(清单是投影) | +| 7 | P-10 抄**原 PN 的规格书**安在候选头上(发现 AE) | S0d_p10 | 消失(模型不出现) | +| 8 | `finish` 撞 worldTouched 墙五拍熔断(发现 AE 同单) | S0d_p10 | 消失(那一步不问模型) | + +**八条落在会被 auto 掉的三步半上。** 剩下的事故(工具没调、比对入参抄错、验收卡面孔、重做循环) +不在这个范围内 —— 它们该由别的机制治,这份 ADR 不承诺。 + +--- + +## 8. 不做什么 + +- **不调工具**:工具有副作用面与预算面,先不给(S3 的 `compare → recommend` 串接留作待定) +- **不 `finish`、不 `submit`、不发决策卡**:收步归 `doneWhen`,终局归 submit,岔路口本就是判断 +- **不写正式表**:仍走草稿层 +- **不做三层以上的嵌套**:两层(关系 × 参数)是 W05 的上限;超出就别 auto +- **不在治理台上编辑配方**:那就成了低代码平台;配方随包走,改配方 = 升版本重装 +- **不承诺消灭 agent**:§2 的四类判断照旧归它 + +--- + +## 9. 代价与风险 + +1. **活动作者要写结构化声明,不能再靠散文。** 但那些散文本来就在(而且更长、还不能测); + 配方**能单测**(纯数据求值,包内自测钉死)。 +2. **代码错是必然的错,模型错是概率的错。** 配方写错 = 每一单都错。 + 对策:静态审(§3.7)+ 包内自测 + 安装两审的回放审拿样例入参跑一遍配方。 + **但必然的错比概率的错好查** —— 这正是把它从模型手里拿走的理由。 +3. **立场要改口。** 「组织的工作定义为可编排的工作流……活动是完成一项任务的 Agent 机制」—— + 加一句:**agent 只在真需要判断的地方出现,与「人只在责任点与放行点出现」是同一条原则。** + 确定的事交系统,判断交 AI,责任交人。 +4. **把有判断的地方硬写成规则的风险。** `constraint_type` 的 hard/soft 归类按品类会变 —— + 这类**进 DMN 判定表,不进配方**;配方里只许出现「取值、拼装、投影、发卡」。 +5. **现在的「确定」有一半是靠强制命名一致换来的。** S2 能纯 join,前提是「候选与原 PN 参数名一模一样」 + (P-08 建骨架时用中文名硬压 + 门上的行数对账)。**一旦料号来自外部导入**,同义归一就回来了 —— + 那是判断,是 agent 的活。**auto 化不等于永远不需要 agent。** + +--- + +## 10. 迁移次序(每搬一步都要五案例 e2e:released + 被拒 0 才算数) + +1. **`S0d_p10_document`** —— 最小、纯粹、刚出过事;拿它验证机制(配方 + 静态审 + 留痕 + 观测) +2. **`S5_prepare_landing`** —— 整步确定、不发卡;验证「不发卡时接着走 doneWhen 收步」那条路 +3. **`S0_bind`** —— 整步确定、含一张录入卡的分岔;验证 `let` 铸号与 `lookup` 存在性 +4. **`S1_input_confirm` 前半** —— 清单投影 + 发卡;半 auto(收口仍归 guide) +5. **`S0d_p08_master`** —— **先把 datasheet 抽值切出去**,再 auto +6. **`S2_normalize`** —— 两层嵌套,最难;搬之前先确认它与比对工具的 join 是否重复(§11) + +--- + +## 11. 待定(要用户定的) + +1. **决策卡要不要给配方**:S4 第一张卡的 ask 内容是计数投影,确定;但决策卡是岔路口,给了会不会滑坡? +2. **工具串接要不要做第四个动词**:S3 的 `compare → recommend` 之间模型只是转手 counts,却占掉整整一拍。 +3. **`humanWrites` 该不该细到列**:现在是表级。auto 铺骨架 + 人填内容之后,列级更准。 +4. **S2 与比对工具的 join 是否重复**:`compare-parameters` 1.2.0 起自己从台账取参数对, + 而 S2 让模型抄的 `difference_item` 对照表是**同一份 join**。一份事实做了两遍,一遍还是手抄的 —— + 要么 S2 auto 化,要么让工具的出参直接落 `difference_item`(那是另一个机制:工具出参绑台账)。 +5. **`T_release` 第三条裁量判据**「推荐等级与差异明细一致」就是工具的定级规则本身, + 让模型再裁一次是给自己盖章 —— 该删(与 08-01 在 A1 上拆掉 L4 同款)。 diff --git a/docs/debt.md b/docs/debt.md index 3a5bd37..6209757 100644 --- a/docs/debt.md +++ b/docs/debt.md @@ -1318,3 +1318,5 @@ pnpm 硬链到全局 store,真实占盘远小于这个数,但 `du` 看着吓人 「说不在库」那一侧至今没有校验);08-28 模型把原 PN 的规格书抄到候选头上(已用 `humanWrites` 立墙)。 真治法:剧本加 `auto` 步(声明「这一步引擎按规则做,不问模型」)。**代价**:活动模型往「配置化流程」挪一格, 与「活动是 Agent 机制」的立场有张力 —— 但这一段本来就没有判断余地。真需要模型的只剩「有 datasheet 附件时从原文抽值预填」。 + **2026-08-28 已出设计:`docs/adr/ADR-014-deterministic-steps.md`**(声明式配方、三动词、迁移次序、回溯验算八条、 + 五条待定)—— 待用户定后实现。 diff --git a/docs/ss02-act-05-new/SS01-02-03-04-台账体系全景-18Activity关联矩阵.html b/docs/ss02-act-05-new/SS01-02-03-04-台账体系全景-18Activity关联矩阵.html new file mode 100644 index 0000000..13136bd --- /dev/null +++ b/docs/ss02-act-05-new/SS01-02-03-04-台账体系全景-18Activity关联矩阵.html @@ -0,0 +1,1455 @@ + + + + +SS01-02-03-04 台账体系全景:18 Activity × 44 台账关联矩阵 + + + + +
+

SS01-02-03-04 台账体系全景:18 Activity × 44 台账关联矩阵

+
CloudCore 云拓芯科 · AI-Native 业务操作系统 · 四子系统(SS01-04)数据底座全景 · 单一关联矩阵
+
+ 版本:v6.0 (Draft) + 日期:2026-08-11 + 作者:丁子乔 (Zane Ding) + 覆盖:全部 18 个 Activity(E8 + T4 + I4 + X2)× 44 台账 +
+
+ +
+ v6.0 变更(SS03 S 系列重构,8/14 按《SS03-供应链子系统-台账体系关联矩阵》): +
• S 系列按能力分层四段——战略层 S-01 全球市场供应链战略与能力规划 / S-02 品类·品牌·产品线供应覆盖(货架地图);主数据层 S-03 供应商主台账(统一 Supplier ID);动态事实层 S-04 询价、报价、比价与采购决策(RFQ/Quote/Decision 三层)/ S-05 采购基准与供应能力(报价与履约直接调用的供应答案);经验层 S-06 供应链知识库(原 K-01 改名); +
• 编号语义全面更新——原 S-01 采购 Price List → 采购基准 S-05;原 S-03 询价记录 → 询价决策 S-04;原 S-04 供应商主 → S-03;原 S-05 品牌覆盖 → 品类覆盖 S-02;采购实时台账并入 SS04 交易事实(D-16/D-17),SS03 无 PO 台账; +
• 系统边界:SS03 沉淀可重复调用的供应链能力/供应商主数据/询价事实/采购决策/采购基准;采购订单、付款、入库、运输、发货等交易事实由 SS04 记录,SS03 读取结果反哺供应商绩效与供应能力; +
• 台账总数保持 44(L 8 + P 12 + S 6 + D 18),编号连续无空号。 +
+ + +

一、台账清单:44 个数据底座

+ +
+ 台账分类:本子系统属主 8 个(L 系列)+ SS02 需求转换 12 个(P 系列)+ SS03 采购 6 个(S 系列)+ SS04 交易履约 18 个(D 系列,执行段 D-01~07 / 责任合规段 D-08~14 / 供应商侧 D-15~18)。
v3.0:按 8/11 会议决议,原 P-09/P-13 已合并、S-01 拆三、原 D-04 移回 SS03。 +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
台账名称定位属主核心内容
本子系统属主台账(SS01 维护)
L-01宏观市场台账市场层数据底座:外部信号与市场评分沉淀SS01 · AM市场信号(宏观/产业/竞争)、历史绩效参考、市场评分排序、竞品动态、政策法规变化、版本号
L-02客户主台账唯一客户 ID 锚点:画像 + 决策链 + 全生命周期状态SS01 · AM Lead唯一 ID、主体信息、360° 画像、组织架构、决策链地图、关系覆盖、互动时间线、健康状态、状态机(联系人明细在 L-08)
L-03触达管理台账触达全生命周期过程数据SS01 · AM触达方案、材料清单、触达轮次、结果状态、意图分级(A/B/C/D)、复盘指标、模板库版本
L-04攻坚项目台账战略客户专项作战全记录SS01 · AM Lead立项书、攻坚计划、打法组合、里程碑、卡点分析、复盘案例
L-05品牌内容库对外呈现内容资产SS01 · AM公司介绍、能力声明、销售工具包、案例集、多语言版本、品牌调性标准
L-06知识沉淀库SS01 组织知识资产:客户情报与销售 Best PracticeSS01 · AM客户异常处理案例、触达有效做法、攻坚 Best Practice、避坑清单、Win-Loss 复盘
L-07情报源台账情报采集源注册与质量评估SS01 · AM公开数据库、行业报告订阅、私有数据库、人脉网络、数据源质量评级
L-08客户联系人信息台账联系人(Contact)明细:一人一记录,一客户多人SS01 · AM联系人 ID、所属客户(L-02)、姓名/多语言、职位、部门、角色(采购/研发/质量/决策者)、影响力评级、关系亲密度、沟通偏好、联系方式、政治文化背景、关联决策链节点
SS02 需求分析与方案匹配 · 商务段 P-01~P-05(BDPM 主导)
P-01客户具体需求与项目任务主台账 ▲ 原 P-07+P-08 合并客户具体需求(RFQ/询价/技术需求)原始输入 + 咨询选型过程 + 技术问题案例的统一主记录SS02 · BDPM/FAE需求编号、客户 ID、料号、数量、目标价、需求时间窗、需求类型(RFQ/询价/技术需求)、原始需求快照、咨询记录、选型推荐、技术问题案例、关联报价(P-02)/项目(P-05)、状态机(待处理→已响应→已成交→已放弃)
P-02报价与商务记录台账历史报价记录(正式 + 非正式)+ 商务往来记录SS02 · BDPM报价单号、客户 ID、料号、价格、币种、生效期、报价状态、关联需求号(P-01)/项目(P-05)、商务往来记录
P-03Price List 台账正式 Release 给客户的报价表SS02 · BDPM料号、客户专属价、币种、Release 版本、生效期、折扣条款
P-04交易框架与承诺台账与客户签署的销售框架合同 + 交易承诺SS02 · BDPM Lead合同版本、账期条款、信用额度、合作范围、生效期、签署方、交易承诺记录
P-05客户项目主台账客户关键项目全生命周期(客户侧主记录)SS02 · BDPM/FAE项目名称、客户 ID、SOP 时间、预估用量、技术负责人、终端客户状态、Design-in 阶段
SS02 需求分析与方案匹配 · 技术段 P-06~P-12(FAE 主导)
P-06样品交付与芯片验证记录台账 ▲ 原 P-13 并入样品交付过程 + 芯片验证结果(商务侧 + 技术侧合一)SS02 · BDPM/FAE样品申请、样品交付、样品报价、关联项目/料号/需求号、客户 ID、验证编号、验证类型(Pin-to-Pin/功能/可靠性/认证)、测试条件、测试结果、通过状态
P-07FAE知识资产库技术方案与选型知识统一资产库SS02 · FAE技术方案模板、Pin-to-Pin 对照、参考设计、应用笔记、知识资产统一管理
P-08芯片主数据台账料号主数据:全链路料号引用的统一基准SS02 · FAE料号、规格参数、封装、原厂、Pin-to-Pin 替代关系、EoL/PCN 状态、生命周期阶段
P-09客户技术与机会档案客户技术侧画像 + 机会档案(区别于 L-02 商务关系视角)SS02 · FAE/BDPM客户 ID、技术栈、产品线技术方向、BOM 品牌使用、验证流程、技术决策人(关联 L-08)、研发能力评估、技术偏好标签、机会记录
P-10原厂技术资料库 ▲ 新增原厂官方技术资料统一存档SS02 · FAE原厂 Datasheet、应用笔记、参考设计、白皮书、样片资料、原厂支持联系人、版本与更新记录
P-11替代选型与批准关系库 ▲ 新增候选芯片 推荐 → 客户验证 → 客户批准 的完整信息链条SS02 · FAE/BDPM候选料号、目标替代料号、推荐理由、验证类型与结果、客户反馈、批准状态、批准时间、关联项目(P-05)/需求(P-01)
P-12技术知识沉淀与复盘库SS02 组织知识资产:方案/选型/报价经验SS02 · BDPM/FAE方案生成 Best Practice、选型推荐经验、报价策略、Design-in 打法、Win-Loss 技术复盘
SS03 供应链 / 采购 · 战略层(供应链负责人 / Sourcing)· v6.0 重构
S-01全球市场供应链战略与能力规划台账市场与供应能力的战略层数据底座:回答「去哪个国家/行业建设什么供应能力、当前建到了哪里」SS03 · 供应链负责人国家/区域、细分行业、目标客户类型、典型应用、重点芯片品类、BT 战略重点、供应链建设目标、成熟度、能力缺口、主推中国品牌方向、必要欧美日系能力、阶段目标、复盘时间
S-02品类·品牌·产品线供应覆盖台账供应链「货架地图」:管理具体应覆盖什么产品资源、已有资源和能力缺口SS03 · Sourcing品类、Brand、Product Line、代表型号、战略角色(主推/必须覆盖/备选/按需)、目标/当前覆盖深度、原厂关系、主渠道、Backup 渠道、价格能力、样品能力、FAE 支持、海外合作状态、成熟度、缺口与建设优先级
SS03 供应链 / 采购 · 主数据层(Sourcing)
S-03供应商主台账供应商长期主数据的统一主体库:统一 Supplier ID,覆盖原厂、授权代理、分销商及贸易资源SS03 · Sourcing基础信息与合作状态、联系人与组织关系、品牌授权与渠道关系、资质/准入/合规、长期商务规则与供应政策、海外战略合作、供应商绩效与健康度(从 SS04 读取后计算)
SS03 供应链 / 采购 · 动态事实层(Sourcing / 采购负责人)
S-04询价、报价、比价与采购决策记录台账采购动态事实库与决策过程库:记录市场真实反馈,保留本次采购定点的完整依据SS03 · Sourcing / 采购负责人RFQ 层(需求来源、客户/项目、PN、数量、目标时间、询价类型)+ Quote 层(供应商、价格、数量阶梯、LT、MOQ、SPQ、有效期、特殊条款、可信度)+ Decision 层(多家比价、评分/建议、RI 定点、推荐供应商、未选原因、审批结论)——紧急现货、缺货、临时涨价等异常价格均记录
S-05采购基准与供应能力台账经采购确认、供报价与履约等后续 Activity 直接调用的「供应答案」SS03 · 采购负责人报价锁定采购基准(客户/项目报价依据)、当前采购基准(供应商、价格、数量阶梯及适用范围)、当前供应能力状态(LT、MOQ、SPQ、供应风险、可供状态)——S-04 定点后按需生成或更新;历史询价和一次性异常价格不直接覆盖正常基准
SS03 供应链 / 采购 · 经验层(全体采购)
S-06供应链知识库 原 K-01 改名结构化业务事实之外的可复用经验与 Best Practice,供后续 Activity 生成建议时调用SS03 · 全体采购供应商开发与关系推进方法、谈判与议价经验、品牌/渠道认知、异常与供应风险案例、海外战略合作案例——业务事实状态仍分别写入 S-01~S-05
SS04 交易与履约 · 执行段 D-01~D-07(履约运营 · 客户端 + 仓库端)· v5.0 扩展
D-01交易与执行台账订单 + 承诺 + 跟单 + 物流 + 开票 + 验收,一单一行SS04 · 履约运营订单号、客户 ID、料号、需求编号(P-01)、项目(P-05)、承诺交期、跟单里程碑状态、物流节点(提货/报关/放行/离港)、发货/开票/验收状态、BT 类型、关闭状态
D-02Forecast 台账客户需求预测(多版本)SS04 · 履约运营客户 ID、料号×月份×数量、版本号、偏差分析、时间窗——分析未来需求营收与趋势,形成销售策略
D-03库存与在途台账库存 + 在途 + 备货决策SS04 · 履约运营料号、可用/待检/冻结、在途(D-16)、MOQ、交期、备货决策记录与退出方案——分析异常 PN,支撑 BDPM 与客户沟通
D-04回款与信用台账应收 + 账期 + 信用 + 现金流预测SS04 · 履约运营账期条款(P-04)、应收/已回款、逾期天数、信用评级、预警状态、未来 3 月回款预测
D-05质量与退换货台账(RMA)投诉事件 + 到货差异SS04 · 履约运营RMA 编号、客户 ID、料号、问题类型、投诉日期、处理状态(处理中→退换→结案)、涉及金额、根因分类、到货差异记录
D-06知识沉淀库 结论与案例沉淀中心履约 BP + 异常根因与规则更新 + 结论/案例沉淀SS04 · 履约运营回款催收 BP、RMA 案例、履约异常案例、库存调整经验、信用风险案例、异常根因库与规则更新记录;挂 3 子表:改进动作跟踪 / 经营分析报告(月度快照)/ 退出善后案例
D-07样品库存台账 ▲ 新增所有样品(免费/收费)出入库实物管理SS04 · 履约运营样品批次、料号、数量、免费/收费标签、入库来源(原厂/采购/自备)、出库去向(交付 P-06/领用)、状态(在库/已出/已消耗/退回)、样品成本——不论收费免费,所有出入库必记录
SS04 交易与履约 · 责任与合规段 D-08~D-14(履约运营)
D-08质保期与产品责任台账 ▲ 新增批次 × 质保期监控SS04 · 履约运营批次号、客户 ID、质保到期日、质保事件、到期预警、赔付决策、责任延续
D-09条款执行监控台账 ▲ 新增条款 vs 事实比对 + 违约预警(AI 自动比对)SS04 · 履约运营条款基线(P-04,结构化:账期/交期窗口/数量/质量/罚则/责任上限/币种)、各节点执行事实、偏差、违约预警、月度执行率
D-10客户履约主数据台账 ▲ 新增执行参数 + 料号映射(挂 2 子表)SS04 · 履约运营20.1 执行参数:贸易术语/包装/标签/单证要求/物流偏好/服务等级/预警阈值;20.2 料号映射:客户料号 ↔ 内部料号(差异标注、版本受控)
D-11出口单证台账 ▲ 新增单证包 + 证据归档SS04 · 履约运营发票/箱单/COC/产地证/质保书、客户格式要求(D-10)、一致性审核记录、证据包齐套、归档期限
D-12出口退税台账 ▲ 新增退税组包 + 到账跟踪SS04 · 履约运营报关单号(D-11)、退税依据、申报状态、到账记录、政策版本
D-13出口管制与制裁筛查台账 ▲ 新增筛查记录 + 拦截SS04 · 履约运营客户/最终用户(L-02)、筛查时间、名单版本、命中结果、拦截报告、判定结论——接单/出运/收款三节点
D-14客户服务水平台账 ▲ 新增服务水平评分与改进闭环SS04 · 履约运营客户 ID、月份、OTD、承诺达成率、响应时效、质量表现、双基准(客户要求/公司承诺值)、评分、差距分析、改进动作与复查记录
SS04 交易与履约 · 供应商侧 D-15~D-18(采购主责)
D-15采购计划与执行需求台账 ▲ 新增采购执行的正式入口SS04 · 采购需求计划 ID、客户/项目、PN、Forecast 版本、客户提货需求、需求日期、库存/在途引用、建议采购量、采购触发数量、采购类型(量产/样品/现货/异常补货)、计划状态、触发时间、关联后续 PO——由 SS04 内部读取 Forecast/订单/样品/库存统一形成
D-16采购订单与履约台账 ▲ 新增 · PO 主对象在 D-16PO 主对象 + 发货前履约关联记录合一SS04 · 采购PO 主记录:PO 号、供应商、PN、数量、价格、币种、价格来源(SS03 采购基准/专项询价)、付款条件、交货条款、需求日期、承诺交期、状态、关联 D-15;履约关联记录:供应商确认时间、交期变更历史、催交承诺、发货前风险状态、交接结果(PO 主对象在本台账,SS03 侧 S-05 采购基准为报价依据,不重复建 PO 台账)
D-17采购结算与核销台账 ▲ 新增PO/入库/发票/付款财务闭环SS04 · 采购结算/核销 ID、Supplier、PO 号、入库批次/明细引用、Invoice 号/日期/含税金额/币种、发票数量与 PO/入库匹配状态、金额匹配状态与差异原因、付款条件/申请/状态/日期、核销状态(待匹配/待付款/部分核销/已核销/异常)——发票匹配为核心(多对多)
D-18采购异常事件台账 ▲ 新增由正常交易台账自动识别抽取的异常事件中心SS04 · 采购异常 ID、来源台账/对象 ID(D-15 偏差/超提;D-16 延期/少交/条件变更;D-17 发票不匹配/核销差异)、异常类型、触发时间/规则、原计划/新状态、影响、责任归属、风险等级、处置方案、RI 判断、最终结果、是否反哺 SS03 供应商绩效——一条记录 = 一个明确异常事件
+ +

二、Activity × 台账关联矩阵(全 44 台账 · 列首颜色区分子系统)

+ +
+
R 读取
+
W 写入
+
R+W 读写
+
— 不涉及
+
L SS01 客户情报
+
P SS02 需求转换(商务 P-01~05 · 技术 P-06~12)
+
S SS03 采购(战略 S-01~02 · 主数据 S-03 · 动态 S-04~05 · 经验 S-06)
+
D SS04 交易履约(执行 D-01~07 · 合规 D-08~14 · 供应商 D-15~18)
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ActivityL-01
市场
L-02
客户
L-03
触达
L-04
攻坚
L-05
品牌
L-06
知识
L-07
情报源
L-08
联系人
P-01
需求
任务
P-02
报价
商务
P-03
Price
List
P-04
框架
承诺
P-05
客户
项目
P-06
样品
验证
P-07
FAE
资产
P-08
芯片
主数据
P-09
技术
档案
P-10
原厂
资料
P-11
替代
批准
P-12
技术
沉淀
S-01
供应链
战略
S-02
品类
覆盖
S-03
供应商
主数据
S-04
询价
决策
S-05
采购
基准
S-06
知识库
D-01
交易
执行
D-02
Forecast
D-03
库存
D-04
回款
D-05
RMA
D-06
知识
D-07
样品
库存
D-08
质保
D-09
条款
监控
D-10
履约
主数据
D-11
出口
单证
D-12
出口
退税
D-13
制裁
筛查
D-14
服务
水平
D-15
采购
计划
D-16
采购
订单
D-17
结算
核销
D-18
采购
异常
E — 现有客户
E01 全景画像与决策链—R+W—————R+WRRRRRR—RR—R———————RR—RR——RRR——RR————
E02 情报事件监测核实WW————RW——————————————————————————————R—————
E03 需求商机发现 BT 判定RR——————RRR—WRRRRRR+WRRRRR+WRR——R———————————————
E04 关系覆盖与关键人—R+W—————R+W————————————————————————————————————
E05 互动与拜访管理—W——RW—W————————————————————————————————————
E06 账户计划与战略评估RR+W—————RRRRRR———R—R———————RR—R—————————R————
E07 健康监控与流失管理—R+WR————RR———R—————————————RR—RRR———————R———R
E08 升降级与退出管理—W———W—————R——————————————R——R————R—————————
T — 目标客户
T01 目标市场与客户池R+WW————R—————————————R—————R—————————————————
T02 建档与资格评估—W——R—RW————————————————————————————————————
T03 触达策略与执行—RR+W—RW—RR—R———————RR—RRRR———R———————————————
T04 战略客户攻坚—R+WRR+WRW—R+WR—R—RRRRRRR+WRRRRRR———R———————————————
I — 公司内部
I01 品牌与工具包R———R+W———————————————————————————————————————
I02 市场竞品监控W—————R———————WW—W———W——————————————————————
I03 情报输出与管线RRRR———RRRRRR——RR—R——RR—R—RRRRR—————————————
I04 知识沉淀与迭代—————R+W———————————RRR—————R—————R————————————
X — 贯穿性
X01 情报源管理——————R+W—————————————————————————————————————
X02 数据治理与脱敏R+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+W
+
+ +
+ 矩阵要点 · 本子系统 L 系列(SS01 8 台账): +
• L-02 客户主台账 + L-08 联系人台账形成客户对象双核心——L-02 管客户主体,L-08 管客户里的人; +
• L-08 联系人被 E 端最密集消费——E01 画像、E04 关系覆盖(核心载体)、E05 互动、T04 攻坚、T03 触达、T02 建档; +
• E02 情报监测捕捉关键人变动写入 L-08; +
• X02 数据治理对全部 8 个台账执行 R+W,联系人隐私数据受脱敏与权限管控; +
• I03 管线可视只读 L-01~L-08 聚合管线视图。 +

+ 矩阵要点 · 跨子系统 P/S/D(SS02/03/04 36 台账): +
• P 系列按业务分段——P-01~P-05 商务段(需求→报价→价格→承诺→项目,BDPM 主导)、P-06~P-12 技术段(样品验证→FAE 资产→芯片主数据→技术档案→原厂资料→替代批准→知识沉淀,FAE 主导); +
• S 系列按能力分层——战略层 S-01 供应链战略与能力规划 / S-02 品类品牌覆盖(货架地图);主数据层 S-03 供应商主(统一 Supplier ID);动态事实层 S-04 询价比价与采购决策 / S-05 采购基准与供应能力(报价与履约直接调用的供应答案);经验层 S-06 供应链知识库;采购订单等交易事实由 SS04 记录、SS03 读取反哺供应商绩效; +
• E01 画像是跨子系统读取大户——商务侧读需求/报价/Price List/框架承诺/项目 + 技术侧读样品验证/FAE 资产/芯片主数据/技术档案/替代批准 + 履约侧读交易/Forecast/回款/RMA/质保/条款/履约主数据/制裁筛查/服务水平,把外部子系统事实聚合进客户 360° 视图(商务 + 技术 + 履约三维); +
• E03 商机发现读 P-01(需求与任务记录)、P-09(客户技术与机会档案)、P-11(替代选型与批准进展)、P-06(验证进展)+ S-01~S-05(供应战略/覆盖/供应商/询价/采购基准,商机可行性五问),并向 S-04 询价决策台账写入前端询价需求; +
• T03/T04 触达攻坚读 P-01/P-09/P-11/P-06 + S-02~S-05(品类覆盖/供应商/询价/采购基准)——对外商务与技术承诺有证据支撑; +
• E06 账户计划读 P-09 + D-14(BT 升级路径与服务策略); +
• E07 健康监控读 D-04 回款 / D-05 RMA / D-14 服务水平 / D-18 采购异常——客户健康覆盖资金、质量、服务、供应链四维信号; +
• E08 升降级读 D-09 条款执行监控(升降级依据条款履行事实); +
• I03 管线读 P-09 机会档案 + S-03 供应商 / S-05 采购基准(供应可行性维度); +
• I02 市场竞品写入 S-02 品类覆盖(新品牌/产品线情报); +
• D 系列 18 台账——执行段 D-01~D-07、责任合规段 D-08~D-14、供应商侧 D-15~D-18(采购主责);SS01 只读执行段与合规段核心数据,供应商侧由采购消费; +
• X02 对全部 36 个跨子系统台账执行治理,保证接口数据合规。 +
+ +

四、四端数据流:E/T/I/X 如何消费台账

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
端核心数据需求主要台账数据角色
E 现有客户画像、联系人、决策链、关系、健康、账户、交易、Forecast、回款、质量、合同、技术L-02/L-08(核心)+ P-01~P-12 + D-01~D-05/D-08~D-10/D-13~D-14深度消费端:聚合内外部台账形成客户 360° 视图(商务 + 技术),是数据使用最密集的端
T 目标客户市场、候选池、触达、攻坚、报价基准、需求、供货能力、品牌覆盖、技术验证L-01/L-02/L-03/L-04(核心)+ P-01/P-03/P-05~P-11 + S-01~S-06 + D-03主生产端:从 0 到 1 构建客户数据,生成 L-01~L-04 全部业务台账
I 公司内部品牌、市场情报、管线、知识L-05(产出)+ L-01/L-06(产出)+ 全台账(只读聚合)+ P-12/S-06/D-06(知识借鉴)内容与决策端:I01/I02/I04 生产内容资产,I03 聚合全台账供决策,I04 借鉴各子系统知识
X 贯穿性情报源、数据质量、脱敏、权限L-07(产出)+ 全部 44 台账(治理)基础设施端:X01 供给情报源,X02 治理全量数据(含跨子系统)
+ + +

五、数据治理原则(全 44 台账)

+
+ 1. 单一属主原则:每个台账只有一个属主子系统——本子系统 8 个(L-01~L-08),SS02 12 个(P-01~P-12,BDPM/FAE 属主),SS03 6 个(S-01~S-06,采购属主),SS04 18 个(D-01~D-18,履约运营 + 采购属主)。本子系统对跨子系统台账只做接口读写,不重复建账。 +

+ 2. 唯一 ID 贯穿原则:L-02 客户主台账的唯一客户 ID 是整条数据链的锚点——联系人(L-08)、需求与任务(P-01)、报价(P-02)、样品(P-06)、项目(P-05)、Price List(P-03)、框架承诺(P-04)、交易(D-01)、Forecast(D-02)、回款(D-04)、RMA(D-05)、技术档案(P-09)全部通过客户 ID 关联,保证跨台账聚合零断裂。S-03 供应商主台账(统一 Supplier ID)同理作为采购侧的 ID 锚点。 +

+ 3. 料号主数据基准原则:P-08 芯片主数据台账是料号引用的统一基准——报价、样品、项目、Price List、采购价格、交易、RMA、样品验证记录(P-06)全部引用同一料号主数据,规格/封装/替代/EoL 状态单一维护,杜绝"同一料号多头定义"。 +

+ 4. 需求驱动原则:P-01 客户具体需求与项目任务主台账是需求转换链路的源头——报价(P-02)响应需求、样品(P-06)验证需求、项目(P-05)承载任务,需求编号贯穿报价/样品/项目,支持询价→报价→成交的转化漏斗分析。 +

+ 5. 画像聚合原则:E01 是唯一的"数据聚合点"——把分散在各子系统的事实(需求/项目/报价/样品/价格/合同/交易/Forecast/回款/质量/技术)聚合进客户画像,对外呈现统一视图(商务 + 技术);其他 Activity 不直接读散落的跨子系统台账,统一通过画像获取。 +

+ 6. 商务依据原则:SS01 对外的商务动作(触达承诺、攻坚方案、商机评估)统一以 P-03 Price List(BDPM 客户报价)和 P-04 框架与承诺条款为基准;内部成本评估以 S-05 采购基准(进价)+ S-04 询价决策(过程询价)为参考——客户报价域(P-03)与采购进价域(S-05/S-04)严格隔离,不混用;采购订单等交易事实由 SS04 记录(D-16/D-17),SS03 按需读取反哺基准。 +

+ 7. 能力可答原则:S-03 供应商主台账 + S-02 品类覆盖台账是"我们能不能做"的标准答案源——客户点名品牌、商机可行性、触达供货承诺、攻坚方案评估统一从这两个台账取数;采购基准与供应能力(S-05)提供"能怎么供"的答案,杜绝人肉问采购。 +

+ 8. 动作与台账分离原则:业务动作与台账属主可以分离——E03 商机发现动作在 SS01,但写入 P-05 客户项目主台账(SS02 属主);采购下单动作在 SS03,采购订单事实由 SS04 D-16 记录(PO 主对象在 D-16,SS03 无 PO 台账,S-05 采购基准为报价依据);SS04 供应商侧 D-16 记录 PO 主对象与履约关联(采购订单事实归 SS04,SS03 无 PO 台账);前端询价动作在 SS01/SS02,询价记录写入 S-04 询价决策台账(SS03 属主)。台账属主负责数据质量,动作方负责数据产生。 +

+ 9. 风险显性化原则:D-04 回款信用 + D-05 RMA + D-18 采购异常是客户风险的三大显性信号——E07 健康监控直接读取,回款逾期、质量投诉与采购异常(延期/少交/断供)即时反映到客户健康分,支撑流失预警、信用管控与供应风险预判。 +

+ 10. 联系人隐私保护原则:L-08 联系人台账含联系方式等个人数据——X02 脱敏规则(对外输出隐藏电话/邮箱)、权限分级管控(仅相关 BDPM/FAE 可见),关键人信息变更留痕(离职/调岗记录)。 +

+ 11. 技术证据原则:P-01 需求与任务记录 + P-09 技术与机会档案 + P-11 替代选型批准 + P-06 样品验证记录是 SS02 对外技术承诺的证据链——商机判断、攻坚方案、触达话术中的技术主张都必须有这三类记录背书,杜绝无据承诺。 +

+ 12. 知识分层沉淀原则:各子系统知识沉淀库(L-06/P-12/S-06/D-06)由对应子系统治理,沉淀本域 Best Practice;I04 负责跨子系统知识借鉴与汇总,形成全局知识视图——知识不越界沉淀,避免职责混乱。 +

+ 13. 沉淀回流原则:执行中的有效做法与失败教训持续沉淀到本域知识库,形成"异常→迭代→Best Practice"自进化闭环——每个子系统都是自进化引擎,不只依赖 SS01。 +

+ 14. 只读引用原则:跨子系统事实数据(交易、Forecast、库存、采购价/询价、合同、回款、RMA、质保、单证、退税、制裁、服务水平)只读不写,保持各子系统数据单一属主;本子系统通过 E01 画像聚合 + I03 管线展示消费它们。 +

+ 15. 治理全覆盖原则:X02 数据治理作用于全部 44 个台账(含跨子系统)——脱敏规则(俄罗斯客户→欧洲 OEM)、一致性校验、权限管控、时效性检查,保证数据可信与合规输出。 +

+ 16. 版本追溯原则:L-01 市场判断、L-02 客户状态、P-03 Price List、D-02 Forecast、P-06 样品验证记录都记录版本号,支持季度对比、趋势分析和历史回溯。

+ 17. 静态沉淀原则:现阶段不拆分静态 / 动态数据——触达过程材料、过程性询价、验证过程等全部按静态沉淀记录;动态中转数据是系统内部过程数据(人不可读),对人有效的是静态沉淀数据(8/11 会议决议 R8)。 +
+ + + + + diff --git a/docs/ss02-act-05-new/SS02-SS03-SS04_Demo台账字段细化与统一方案.md b/docs/ss02-act-05-new/SS02-SS03-SS04_Demo台账字段细化与统一方案.md new file mode 100644 index 0000000..3800a1a --- /dev/null +++ b/docs/ss02-act-05-new/SS02-SS03-SS04_Demo台账字段细化与统一方案.md @@ -0,0 +1,632 @@ +# SS02—SS03—SS04 Demo台账字段细化与统一方案 + +## 一、结论摘要 + +本成果以《SS01-02-03-04-台账体系全景-18Activity关联矩阵》的台账编号为唯一编号标准,对六组Demo Activity进行字段级反推和补齐:[1] [2] [3] [4] [5] [6]。结论是:**当前全景体系的台账编号总体可承载Demo,但核心内容描述仍不足以直接支撑执行,必须补充字段级定义、条件必填规则和主记录/子表结构。** + +本方案将Demo中的细分编号按全景标准归并,例如P-01-01→P-01、P-08-01→P-08、P-11-01/P-11-02→P-11的不同关系状态或子对象;不新增与全景体系冲突的台账编号。[1] [2] [3] 对于逐参数差异、逐报价阶梯和逐份单证等重复明细,采用子表承载,避免在单一文本字段中堆叠数据。 + +## 二、编号归并与建模原则 + +> **编号原则:**全景矩阵的44个台账编号是主键标准;Demo中的-01/-02属于局部架构或对象层标识,除非全景矩阵明确存在对应台账,否则不作为新的全景台账编号。 + +|Demo原编号/写法|统一全景编号|统一后的对象/说明| +|---|---|---| +|P-01-01|P-01|客户具体需求与项目任务主台账;任务、需求版本、报价上下文和Activity回链统一归入。| +|P-06-01|P-06|样品交付与芯片验证记录台账;验证事实与样品追溯统一归入。| +|P-08-01|P-08|芯片主数据台账;全链路只认internal_pn。| +|P-09-02|P-09|客户技术与机会档案;技术档案字段统一挂客户ID。| +|P-10-01/02/03|P-10|原厂技术资料库;资料类型由document_type区分。| +|P-11-01/P-11-02|P-11|替代选型与批准关系库;analysis_status与customer_approval_status分离,差异明细设子表。| +|P-04-01/P-04-02|P-04|交易框架与承诺台账;框架主记录与承诺子记录分层。| +|D-01物流F组/交接E组|D-01|交易与执行台账;交接事实与物流节点作为不同对象层,但共享order_id。| + +## 三、Activity覆盖校验 + +下表说明每个Demo的关键读写台账、字段补足点和覆盖结论。[1] [2] [3] [4] [5] [6] + +|Activity|原矩阵台账编号|统一台账编号|主写入台账|关键新增/细化字段|覆盖结论| +|---|---|---|---|---|---| +|ACT-WORK-05 差异评估|P-01-01、P-06-01、P-08-01、P-09-02、P-10-01/02/03、P-11-01、P-11-02、GOV|P-01、P-06、P-08、P-09、P-10、P-11、GOV|P-01、P-11;条件写P-06|requirement_version、input_snapshot_ref、bom_position、normalized_parameter_set、difference_item_id、difference_status、impact_statement、constraint_type、rule_version、confidence_score|已覆盖;差异明细由P-11差异明细子表承载,客户批准状态与差异分析状态分离。| +|ACT-WORK-24 报价方案制定|P-01-01、P-02-01、P-03-01、P-04-01/02、P-06-01、P-08-01、P-11-02、GOV|P-01、P-02、P-03、P-04、P-06、P-08、P-11、GOV|P-02;回写P-01|quote_version、quote_type、quote_valid_until、delivery_commitment、payment_terms、tax_terms、incoterms、exchange_rate、gross_margin_status、rule_version、QuoteLine、blocking_reason、substitution_approval_status|已覆盖;P-02采用主记录+QuoteLine明细结构,正式发布与AI草稿以状态和版本区分。| +|SS03 Activity 1 发起询价|P-01、D-15、P-08、S-03、S-05、S-04|P-01、D-15、P-08、S-03、S-05、S-04|S-04 RFQ层;条件写S-03|source_type/source_id、rfq_batch_id、rfq_line_id、quote_due_at、outbound_content_ref、sent_evidence_ref、reminder_count、last_reminded_at、overdue_flag、temporary_supplier_flag|已覆盖;最小粒度固定为询价批次×供应商×内部料号。| +|SS03 Activity 2 回填报价/比价/定点|P-01、D-15、P-08、S-03、S-05、S-04|P-01、D-15、P-08、S-03、S-05、S-04|S-04 Quote/Decision层|supplier_quote_id、quote_revision_no、comparability_status、comparison_basis_snapshot_ref、baseline_match_status、baseline_unit_price、comparison_currency_code、conversion_rate、price_variance_pct、selected_supplier_id、selected_quote_id、unselected_reason、notification_recipient_ref、score_rule_version|已覆盖;跨币种、无基准、不可比、涨幅>10%、代理证及临时供应商门禁均有字段承载。| +|ACT-11-A2 提货交接与风险转移|D-01、D-03、D-10、D-11、L-02、P-08、D-04|D-01、D-03、D-10、D-11、L-02、P-08、D-04|D-01、D-03;触发D-04相关门禁|allocation_ref、handover_no、receiver_name、sign_time、risk_transfer_time、handover_diff、handover_file_ref、handover_doc_status、handover_notes、document_set_complete、consistency_check_status、allocation_status、last_ship_date|已覆盖;交接事实、风险转移派生、文件状态、单证门禁和库存同步均已落台账。| +|ACT-11-A3 物流执行信息跟踪|D-01、D-10|D-01、D-10|D-01物流节点事实层|logi_pickup/logi_clearance/logi_release/logi_departure(统一节点结构)、track_note、track_visible、logistics_confirmation_channel、logistics_confirmation_interval_days、logistics_max_reminder_count、alert_thresholds|已覆盖;每个节点包含预计时间、实际时间、来源、采集方式、确认状态、异常标记和证据引用。| + +## 四、字段统一总表 + +### 4.1 通用治理字段 + +以下字段建议作为所有涉及Demo台账的通用治理字段继承,但不要求在每张业务台账中重复手工维护。`record_status`只管理记录生命周期;`*_status`管理具体业务过程;`evidence_ref`用于AI和人工结果的可审计回链。 + +|统一字段名|中文字段名|数据类型|必填性|维护/来源|Demo使用|定义/取值/校验规则| +|---|---|---|---|---|---|---| +|record_id|记录ID|Text/AutoNumber|是|系统|所有Demo|每条台账记录的唯一主键;业务编号不能替代数据库主键。| +|record_status|记录状态|Enum|是|台账Owner|所有Demo|有效/草稿/归档/作废;不得用业务过程状态替代记录生命周期状态。| +|record_version|记录版本|Text/Integer|是|系统/Owner|W05/W24/SS03/11-A2/11-A3|每次影响业务判断的修改递增;报价、价格、规则、客户映射必须可回溯。| +|source_type|来源类型|Enum|条件必填|系统/上游Owner|W05/W24/SS03/11-A2|来源对象的类型,如客户需求、采购计划、价格维护、异常补货、订单、外部回复。| +|source_id|来源对象ID|Text/Link|条件必填|系统/上游Owner|W05/W24/SS03/11-A2|与source_type成对出现;不得把说明文字写入ID字段。| +|data_owner|数据Owner|Link|是|台账Owner|所有Demo|负责定义、质量和变更审批的角色或人员。| +|evidence_ref|证据引用|URL/Attachment/Structure|条件必填|AI/PW/Owner|W05/W24/SS03/11-A2/11-A3|指向原始邮件、聊天记录、签署件、数据表、资料页码或其他证据;可多值。| +|created_at|创建时间|DateTime|系统|系统|所有Demo|系统生成。| +|updated_at|更新时间|DateTime|系统|系统|所有Demo|系统生成。| +|last_verified_at|最后核验时间|DateTime|条件必填|Owner/PW/FAE/采购|所有Demo|对影响下游决策的事实标记最近一次人工或规则核验时间。| + +### 4.2 按全景台账编号展开的字段 + +#### P-01 客户具体需求与项目任务主台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|request_id|需求编号|Text/AutoNumber|是|主记录|SS02|W05/W24/SS03-1/SS03-2/D-01|客户需求与项目任务主记录的统一业务键;SS03写入S-04.source_id。|重命名/统一|任务ID、request_id、需求编号| +|customer_id|客户ID|Link|是|主记录|L-02/SS02|W05/W24/SS03-1/SS03-2|统一关联L-02;需求没有客户时需明确内部需求标识。|保留/细化|customer_id、客户| +|project_id|项目ID|Link|条件必填|主记录|P-05/SS02|W05/W24/SS03-1/SS03-2|有项目场景必填;与客户ID共同确定需求归属和结果通知对象。|新增/细化|project_id、客户/项目| +|requirement_version|需求版本|Text|是|主记录|SS02/系统|W05/W24|需求基线版本;差异评估与报价均按版本回溯。|新增/统一|任务描述/需求版本、版本| +|request_type|需求类型|Enum|是|主记录|SS02/系统|W05/W24/SS03|客户需求/RFQ/询价/技术需求/价格维护/其他;采购来源可由source_type补充。|新增/统一|需求类型、RFQ类型| +|request_description|需求描述|LongText|是|主记录|SS02/需求Owner|W05/W24|保留原始需求语义,不能只保留结构化字段。|新增/细化|任务描述| +|input_snapshot_ref|输入快照|URL/Attachment/Structure|W05必填;W24条件|主记录|系统/AI|W05/W24|记录本次评估或报价运行时的输入快照,支持版本回溯。|新增|输入快照| +|bom_id|BOM ID|Link|条件必填|主记录|SS02|W05/W24|有BOM场景必填;与bom_position一并确定物料位置。|新增|BOM范围| +|bom_position|BOM位|Text|条件必填|主记录|SS02/FAE|W05/W24|按BOM位记录原物料、替代物料和报价明细;不同BOM位批准边界不得混用。|新增|BOM位、PN/BOM范围| +|original_internal_pn|原内部料号|Link(P-08)|W05必填;其他条件|主记录|P-08/FAE|W05/W24|差异评估基准;引用P-08.internal_pn。|重命名/统一|原型号/原PN、原PN| +|candidate_internal_pn|候选内部料号|Link(P-08)|W05条件;W24条件|主记录|P-08/FAE|W05/W24|候选替代物料;多个候选应进入P-11子记录而非逗号拼接。|重命名/统一|候选PN、Candidate_PN| +|customer_pn|客户料号|Text|条件必填|主记录|L-02/D-10映射|W24/SS03/D-01|若客户使用客户料号,保留并与internal_pn分开。|新增/统一|customer_mpn、客户料号| +|application_scenario|应用场景|LongText/Enum|W05必填;W24条件|主记录|FAE/客户|W05/W24|解释需求约束、验证条件与报价适用场景。|保留/细化|应用场景、适配场景| +|target_qty|目标数量|Number/Structure|W24/SS03必填|主记录|需求Owner/采购|W24/SS03-1/SS03-2|正式报价、询价与比价的数量基准;阶梯放入数量阶梯子表。|重命名/统一|数量、target_qty、报价类型/数量| +|target_delivery_date|目标交期|Date|W24/SS03必填|主记录|需求Owner/采购|W24/SS03-1/SS03-2|正式报价、询价与比价的日期基准。|重命名/统一|target_delivery_date、目标交期| +|target_price_internal|内部目标价|Currency|否|主记录|SS02|W24/SS03|仅内部判断;默认不对外发送;币种必须同时记录。|保留/细化|target_price_internal、目标价| +|target_price_currency_code|内部目标价币种|ISO 4217 Enum|target_price有值时必填|主记录|SS02|W24/SS03|与target_price_internal成对出现。|新增/统一|currency| +|requested_price_types|所需价格类型|MultiEnum|SS03必填;W24条件|主记录|需求Owner/采购|W24/SS03-1/SS03-2|参考价/量产价/现货价/样品价;上游默认值可补充,删除或替换需留痕。|重命名/统一|price_types_default、price_types_requested、报价类型| +|quote_due_at|期望报价返回时间|DateTime|SS03必填;W24条件|主记录|需求Owner/采购|W24/SS03-1/SS03-2|客户需求来源从P-01读取;内部或维护型询价由采购填写。|重命名/统一|quote_return_due_at、quote_due_at、截止时间| +|technical_constraints|技术约束|Structure|W05/W24条件|主记录|FAE/AI|W05/W24|包含参数、单位、版本、硬约束/软约束,供差异评估和报价前提检查。|新增|参数边界、硬/软约束| +|risk_level|风险等级|Enum|W05/W24条件|主记录|FAE/BDPM|W05/W24|低/中/高/关键;必须记录确认人和时间。|统一|FAE人工判断/风险等级、风险等级| +|verification_requirement|验证/复测/确认要求|LongText/Structure|W05条件;W24条件|主记录|FAE|W05/W24|用于关闭UNKNOWN或CONDITIONAL,并可回链P-06验证记录。|重命名/统一|验证/复测/确认要求、验证要求| +|latest_quote_id|最新报价ID|Link(P-02)|W24/SS03条件|主记录|系统|W24/SS03-2|聚合P-02最新有效报价,不直接复制价格。|新增|关联报价、最新报价状态| +|latest_quote_status|最新报价状态|Lookup/Enum|W24/SS03条件|主记录|系统|W24/SS03-2|由P-02.quote_status/publish_status聚合。|重命名/统一|最新报价状态、报价状态| +|request_status|需求状态|Enum|是|主记录|SS02|W05/W24/SS03|待处理/已响应/已成交/已放弃/关闭;不得与Activity运行状态混用。|重命名/统一|状态机、需求状态| +|current_activity|当前Activity|Link/Enum|W05/W24条件|主记录|系统|W05/W24|如ACT-WORK-05、ACT-WORK-24;用于流程位置显示。|重命名/统一|当前Activity| +|current_stage|当前阶段|Enum/Text|W05/W24条件|主记录|系统/Owner|W05/W24|评估、报价、审批、发布等阶段。|新增/统一|当前阶段| +|next_action|下一步动作|LongText|W05/W24条件|主记录|FAE/BDPM/系统|W05/W24|明确责任人、动作和截止时间。|统一|下一步动作| +|output_ref|标准输出链接|URL/Attachment|W05/W24条件|主记录|系统/FAE/BDPM|W05/W24|差异报告或报价文件的正式输出链接。|重命名/统一|标准输出类型/输出文件链接、输出链接| + +#### P-02 报价与商务记录台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|quote_id|报价ID|Text/AutoNumber|是|报价主记录|系统|W24/SS03-2|报价主记录唯一标识。|保留/细化|报价ID| +|request_id|关联需求编号|Link(P-01)|是|报价主记录|系统|W24/SS03-2|回链P-01.request_id。|重命名/统一|关联任务、关联需求号| +|quote_version|报价版本|Text|是|报价主记录|系统/BDPM|W24|同一需求条件变化创建新版本;旧版本保留不可覆盖。|新增/统一|报价版本、版本| +|quote_type|报价类型|Enum|是|报价主记录|BDPM|W24|正式报价/参考价/项目报价/其他;与price_type(明细价格类型)区分。|新增/统一|报价类型| +|quote_status|报价状态|Enum|是|报价主记录|系统/BDPM|W24/SS03-2|草稿/待审核/已批准/已发布/已过期/已撤回。|重命名/统一|报价发布状态、报价状态| +|quote_currency_code|报价币种|ISO 4217 Enum|是|报价主记录|系统/BDPM|W24|报价金额的统一币种;明细价格若币种不同必须换算并留痕。|重命名/统一|报价价格/币种、currency| +|valid_from|报价生效时间|Date/DateTime|条件必填|报价主记录|BDPM|W24|正式发布报价必须有生效时间。|新增/统一|生效期| +|quote_valid_until|报价有效期至|Date|条件必填|报价主记录|BDPM|W24|不得与P-03价格来源有效期混用。|重命名/统一|报价有效期、valid_until| +|delivery_commitment|报价交期承诺|Date/Text/Structure|是|报价主记录|PW/BDPM|W24|记录交期数值、计算口径、来源和是否为承诺。|重命名/统一|报价交期、交期| +|payment_terms|付款条件|Text/Structure|条件必填|报价主记录|BDPM/P-04|W24|来源于P-04或本次商务确认。|新增/统一|付款| +|tax_terms|税费条件|Text/Structure|条件必填|报价主记录|BDPM|W24|税率、含税/未税口径必须明确。|新增|税费| +|incoterms|贸易术语|Enum|条件必填|报价主记录|BDPM/P-04|W24|使用标准Incoterms枚举并记录版本/地点。|新增/统一|Incoterms、交货条款| +|quote_assumptions|报价前提条件|LongText/Structure|是|报价主记录|BDPM|W24|含付款、税费、Incoterms、MOQ/SPQ、供货边界等。|重命名/细化|报价前提条件| +|technical_boundary|技术边界|LongText/Structure|条件必填|报价主记录|FAE/BDPM|W24|记录替代、验证、规格限制与客户批准状态。|新增|技术边界| +|cost_basis|成本基准|Currency/Structure|条件必填|报价主记录|BDPM/采购|W24|记录成本来源、币种、数量口径和时点。|新增|成本| +|exchange_rate|报价汇率|Number|跨币种时必填|报价主记录|系统/BDPM|W24|统一比较币种时使用;必须关联exchange_rate_source与as_of。|新增|汇率| +|exchange_rate_source|汇率来源|Text/URL|exchange_rate有值时必填|报价主记录|系统/BDPM|W24|记录汇率来源。|新增|汇率| +|exchange_rate_as_of|汇率时点|Date|exchange_rate有值时必填|报价主记录|系统|W24|统一币种计算基准日。|新增|汇率| +|gross_margin_status|毛利状态|Enum/Structure|条件必填|报价主记录|系统/BDPM|W24|未计算/通过/低于阈值/缺成本;口径由GOV规则维护。|新增|毛利状态| +|rule_version|报价规则版本|Text/Link|是|报价主记录|GOV/系统|W24|记录计算、毛利、价格优先级使用的规则版本。|新增/统一|规则版本| +|approval_status|商务审批状态|Enum|是|报价主记录|Gate/审批人|W24|未提交/待审批/已通过/已驳回;通过前不得发布。|重命名/统一|商务联审结论/审批记录| +|approval_comment|商务审批意见|LongText|条件必填|报价主记录|审批人|W24|驳回或有条件通过时必须填写。|新增/统一|审批记录、审批意见| +|publish_status|报价发布状态|Enum|是|报价主记录|系统/Gate|W24|草稿/待审批/已批准/已发布/过期/撤回。|重命名/统一|报价发布状态| +|quote_document_ref|正式报价文件链接|URL/Attachment|已发布时必填|报价主记录|系统/BDPM|W24|只指向正式发布版本。|重命名/统一|报价文件、台账链接| +|blocking_reason|阻断原因|LongText/Structure|条件必填|报价主记录|AI/BDPM|W24|价格过期、冲突、阶梯缺失、技术批准缺失等逐项记录。|新增|阻断原因| +|quote_line_id|报价明细ID|Text/AutoNumber|是|报价明细QuoteLine|系统|W24|最小粒度为报价版本×PN×BOM位×数量阶梯。|新增|QuoteLine| +|internal_pn|内部料号|Link(P-08)|是|报价明细QuoteLine|P-08/FAE|W24|报价物料统一引用P-08。|统一|PN| +|bom_position|BOM位|Text|条件必填|报价明细QuoteLine|FAE|W24|同一报价中不同BOM位不能混淆。|统一|BOM位| +|quantity_tier|数量阶梯|Structure|是|报价明细QuoteLine|AI/BDPM|W24|含min_qty、max_qty或阶梯描述。|拆分/结构化|数量阶梯| +|price_type|价格类型|Enum|是|报价明细QuoteLine|BDPM|W24|参考价/量产价/现货价/样品价等。|新增/统一|price_type| +|unit_price|报价单价|Currency|是|报价明细QuoteLine|系统/BDPM|W24|与currency_code成对存在。|重命名/统一|报价价格、单价| +|currency_code|明细币种|ISO 4217 Enum|是|报价明细QuoteLine|系统/BDPM|W24|明细币种;若与主报价币种不同必须记录换算。|重命名/统一|币种| +|lead_time_days|供货交期天数|Number|条件必填|报价明细QuoteLine|供应商/采购/BDPM|W24|统一为自然日或工作日一种口径,口径写入字段说明。|重命名/统一|LT、报价交期| +|moq_qty|最小起订量|Number|条件必填|报价明细QuoteLine|P-08/S-05/采购|W24|数量比较与前提条件校验。|拆分/统一|MOQ| +|spq_qty|标准包装量|Number|条件必填|报价明细QuoteLine|P-08/S-05/采购|W24|数量比较与包装约束校验。|拆分/统一|SPQ| +|price_source_ref|价格来源引用|Link(P-03/S-05/S-04)|是|报价明细QuoteLine|系统|W24|指向Price List、采购基准或专项询价记录。|新增/统一|来源报价、价格来源| +|substitution_approval_status|替代批准状态|Enum|条件必填|报价明细QuoteLine|P-11|W24|未检查/待验证/已批准/不批准;未满足时阻断发布。|新增|技术替代批准状态| + +#### P-03 Price List台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|price_list_id|PriceList ID|Text/AutoNumber|是|价格记录|系统/BDPM|W24|价格来源唯一标识。|重命名/统一|PriceListID| +|internal_pn|内部料号|Link(P-08)|是|价格记录|P-08/价格Owner|W24|统一引用P-08。|重命名/统一|PN/料号| +|customer_id|适用客户ID|Link(L-02)|条件必填|价格记录|价格Owner|W24|客户专属价必填;公共渠道价可用scope_type标识。|新增|客户或渠道| +|price_scope_type|价格适用范围|Enum|是|价格记录|BDPM|W24|客户专属/渠道/公共/项目。|新增|客户或渠道| +|price_type|价格类型|Enum|是|价格记录|BDPM|W24|与报价所需价格类型对齐。|新增/统一|价格类型| +|list_unit_price|Price List单价|Currency|是|价格记录|价格Owner|W24|与currency_code成对。|重命名/统一|客户专属价/单价| +|currency_code|价格币种|ISO 4217 Enum|是|价格记录|价格Owner|W24|统一币种编码。|重命名/统一|币种| +|release_version|Release版本|Text|是|价格记录|价格Owner|W24|报价时按as_of选取有效版本。|重命名/统一|Release版本、版本| +|valid_from|生效时间|Date|是|价格记录|价格Owner|W24|与valid_to组成有效区间。|保留/细化|生效期| +|valid_to|失效时间|Date|是|价格记录|价格Owner|W24|报价时点必须落在有效区间内。|重命名/统一|失效期、有效期| +|quantity_tier|数量阶梯|Structure|条件必填|价格记录|价格Owner|W24|含适用数量和折扣条件。|拆分/结构化|数量阶梯/折扣条款| +|discount_terms|折扣条款|LongText/Structure|条件必填|价格记录|价格Owner|W24|结构化记录折扣条件和例外。|细化|折扣条款| +|source_quote_id|来源报价ID|Link(P-02/S-04)|条件必填|价格记录|系统/BDPM|W24|能追溯价格来源。|重命名/统一|来源报价| +|price_status|价格状态|Enum|是|价格记录|价格Owner|W24|草稿/有效/暂停/过期/替代。|新增/统一|状态| + +#### P-04 交易框架与承诺台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|framework_id|交易框架ID|Text/AutoNumber|是|框架主记录|商务Owner|W24|统一框架合同主键。|重命名/统一|框架ID| +|customer_id|客户ID|Link(L-02)|是|框架主记录|商务Owner|W24|交易框架归属客户。|保留/统一|客户名称| +|contract_version|合同版本|Text|是|框架主记录|商务Owner|W24|报价采用的合同/框架版本。|重命名/统一|合同版本| +|contract_status|合同状态|Enum|是|框架主记录|商务Owner|W24|草稿/有效/到期/终止。|保留/细化|合同状态| +|account_status|交易账户状态|Enum|是|框架主记录|商务Owner|W24|正常/冻结/待准入/关闭。|重命名/统一|交易账户状态| +|payment_terms|账期与付款条款|Text/Structure|条件必填|框架主记录|商务Owner|W24|供报价继承;与D-04信用台账区分。|重命名/统一|账期条款、付款条件| +|credit_limit|信用额度|Currency|条件必填|框架主记录|商务Owner|W24/11-A2条件|可供信用门禁使用。|保留/细化|信用额度| +|cooperation_scope|合作范围|LongText/Structure|条件必填|框架主记录|商务Owner|W24|定义品牌、产品线、区域等边界。|保留/细化|合作范围| +|incoterms|默认贸易术语|Enum|条件必填|框架主记录|商务Owner|W24|报价若覆盖本条件,回链本字段。|新增/统一|交易条款、Incoterms| +|effective_from|框架生效时间|Date|是|框架主记录|商务Owner|W24|有效区间。|新增/统一|生效期| +|effective_to|框架失效时间|Date|条件必填|框架主记录|商务Owner|W24|有效区间。|新增/统一|生效期| +|commitment_id|承诺ID|Text/AutoNumber|条件必填|承诺子记录|系统/商务Owner|W24|每条价格、交期、数量或条件承诺一个ID。|重命名/统一|承诺ID| +|commitment_party|承诺方|Enum/Link|是|承诺子记录|商务Owner|W24|我方/客户/供应商/其他。|统一|承诺方| +|commitment_type|承诺类型|Enum|是|承诺子记录|商务Owner|W24|价格/交期/数量/质量/付款/其他。|新增/统一|承诺内容| +|commitment_content|承诺内容|LongText/Structure|是|承诺子记录|商务Owner|W24|结构化值+说明,避免只写自由文本。|细化|承诺内容| +|commitment_due_date|承诺期限|Date|条件必填|承诺子记录|商务Owner|W24|到期前可触发提醒。|重命名/统一|期限| +|commitment_status|承诺状态|Enum|是|承诺子记录|商务Owner/系统|W24|未生效/生效/已兑现/逾期/取消。|重命名/统一|状态| +|fulfillment_record_ref|兑现记录引用|Link/URL|条件必填|承诺子记录|系统/履约|W24|回链D-01/D-09等执行事实。|重命名/统一|兑现记录| +|unresolved_risk_list|未决商务风险清单|LongText/Structure|条件必填|主记录|商务Owner|W24|报价前置风险;未关闭时可阻断审批。|重命名/细化|未决风险清单、商务风险| + +#### P-06 样品交付与芯片验证记录台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|validation_id|验证ID|Text/AutoNumber|是|验证主记录|系统|W05/W24|验证记录唯一标识。|保留/统一|Validation_ID、验证编号| +|request_id|关联需求编号|Link(P-01)|条件必填|验证主记录|系统/FAE|W05/W24|回链需求与验证目的。|新增/统一|需求号| +|project_id|关联项目ID|Link(P-05)|条件必填|验证主记录|系统/FAE|W05/W24|项目场景验证必填。|新增/统一|项目| +|customer_id|客户ID|Link(L-02)|条件必填|验证主记录|系统/FAE|W05/W24|客户验证必填。|新增/统一|客户ID| +|validation_internal_pn|验证内部料号|Link(P-08)|是|验证主记录|FAE|W05/W24|统一引用P-08.internal_pn。|重命名/统一|Validation_PN、验证PN| +|validation_type|验证类型|Enum|是|验证主记录|FAE|W05/W24|Pin-to-Pin/功能/可靠性/认证/复测。|新增/统一|验证类型| +|test_condition|测试条件|LongText/Structure|是|验证主记录|FAE/验证Owner|W05|环境、输入、负载、样品状态等。|重命名/统一|测试条件| +|test_parameters|测试参数|Structure|是|验证主记录|FAE/验证Owner|W05|参数名、值、单位、版本必须结构化。|重命名/统一|测试参数| +|test_result|测试结果|LongText/Structure|是|验证主记录|验证Owner|W05|实测数据与结论,必要时附原始数据。|重命名/统一|测试结果| +|validation_status|验证状态|Enum|是|验证主记录|FAE/验证Owner|W05/W24|未开始/进行中/通过/不通过/条件通过/待补证。|新增/统一|通过状态、验证状态| +|issue_risk|问题与风险|LongText/Structure|条件必填|验证主记录|AI/FAE|W05/W24|记录未关闭问题、风险等级、责任人和关闭条件。|重命名/统一|问题与风险| +|sample_version|样品版本|Text|条件必填|验证主记录|验证Owner|W05|样品追溯。|重命名/统一|样品版本| +|sample_lot|样品批次|Text|条件必填|验证主记录|验证Owner|W05|与D-07或样品出库记录回链。|重命名/统一|样品批次、批次| +|evidence_ref|验证证据引用|URL/Attachment/Structure|是|验证主记录|验证Owner/FAE|W05/W24|原始数据、照片、报告、资料页码等。|统一|证据引用| +|next_validation_action|后续验证动作|LongText/Structure|条件必填|验证主记录|FAE|W05/W24|UNKNOWN/CONDITIONAL时必须有关闭动作。|新增|验证建议/缺口| + +#### P-08 芯片主数据台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|internal_pn|内部料号|Text|是|物料主记录|SS02/FAE|W05/W24/SS03/11-A2|全链路料号统一基准;不可使用自由文本替代。|重命名/统一|标准型号PN、pn、mpn| +|customer_pn|客户料号|Text|条件必填|物料主记录|SS02/D-10映射|W24/SS03/11-A2|客户料号映射优先维护在D-10,P-08可作为别名索引。|新增/统一|customer_mpn| +|brand_name|品牌名称|Text/Link|是|物料主记录|FAE/数据Owner|W05/W24/SS03/11-A2|品牌统一引用;外部供应商品牌与原厂制造商分开。|重命名/统一|brand、manufacturer_brand、原厂或品牌| +|manufacturer_name|制造商名称|Text/Link|条件必填|物料主记录|数据Owner|W05/W24|品牌与制造商不一致时分开记录。|新增/统一|原厂| +|specification_description|规格描述|LongText|是|物料主记录|数据Owner/原厂资料|W05/W24|标准规格文本。|重命名/统一|Specification Description、产品描述| +|normalized_parameter_set|标准化参数集|Structure|W05必填;W24条件|物料主记录|FAE/数据Owner|W05/W24|参数名、值、单位、精度、版本,用于硬/软约束比较。|新增|参数名称、单位、版本| +|package|封装|Enum/Text|条件必填|物料主记录|数据Owner|W05/W24/SS03|影响硬约束和报价识别。|保留/细化|封装| +|process_technology|工艺|Text/Enum|条件必填|物料主记录|数据Owner|W05|硬约束比较项。|重命名/统一|工艺| +|lifecycle_status|生命周期状态|Enum|是|物料主记录|数据Owner|W05/W24/SS03|Active/NRND/EOL/停产/风险。|重命名/统一|生命周期状态、lifecycle_status、产品状态| +|eol_pcn_status|EOL/PCN状态|Enum/Structure|条件必填|物料主记录|数据Owner/原厂|W05/W24|记录通知状态、影响和生效时间。|新增/统一|EoL/PCN状态| +|datasheet_ref|Datasheet引用|URL/Link(P-10)|条件必填|物料主记录|P-10/原厂|W05/W24|官方资料链接。|重命名/统一|Datasheet链接| +|datasheet_version|Datasheet版本|Text|datasheet_ref有值时必填|物料主记录|P-10/原厂|W05/W24|用于证据版本回链。|重命名/统一|Datasheet版本/版本| +|application_scenarios|适配应用场景|MultiEnum/Structure|条件必填|物料主记录|FAE/数据Owner|W05/W24|判断物料实际适用性。|重命名/统一|应用场景、适配场景| +|pin_to_pin_relation_ref|Pin-to-Pin关系引用|Link(P-11)|条件必填|物料主记录|FAE|W05/W24|引用P-11关系记录,避免在P-08复制批准结论。|新增/统一|Pin-to-Pin替代关系| + +#### P-09 客户技术与机会档案 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|customer_id|客户ID|Link(L-02)|是|客户技术档案|SS02/FAE|W05/W24|区别于L-02的商务画像,技术档案以客户ID为主键。|保留/统一|客户 ID| +|technical_stack|客户技术栈|Structure|条件必填|客户技术档案|FAE|W05/W24|记录平台、架构、关键技术约束。|重命名/统一|技术栈| +|product_line_direction|产品线技术方向|Text/Structure|条件必填|客户技术档案|FAE/BDPM|W24|报价上下文与应用场景。|重命名/统一|产品线方向| +|bom_brand_usage|BOM品牌使用情况|Structure|条件必填|客户技术档案|FAE|W05/W24|记录客户当前BOM品牌、料号及替代空间。|重命名/统一|BOM品牌| +|validation_process|客户验证流程|LongText/Structure|条件必填|客户技术档案|FAE/客户|W05/W24|支持验证要求和报价前提判断。|重命名/统一|验证流程| +|technical_decision_maker_ref|技术决策人引用|Link(L-08)|条件必填|客户技术档案|FAE|W05/W24|引用联系人,不复制姓名。|重命名/统一|技术决策人| +|rd_capability_assessment|研发能力评估|Enum/LongText|条件必填|客户技术档案|FAE|W05/W24|用于技术支持与验证路径判断。|重命名/统一|研发能力| +|technical_preference_tags|技术偏好标签|MultiEnum|条件必填|客户技术档案|FAE|W05/W24|用于推荐和方案匹配。|重命名/统一|技术偏好标签| +|opportunity_id|机会ID|Text/AutoNumber|条件必填|机会子记录|系统/BDPM|W05/W24|需求或报价对应的机会对象。|新增/统一|机会记录| + +#### P-10 原厂技术资料库 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|document_id|资料ID|Text/AutoNumber|是|资料主记录|系统|W05/W24|资料唯一标识。|新增/统一|资料记录| +|document_type|资料类型|Enum|是|资料主记录|FAE|W05/W24|Datasheet/应用笔记/参考设计/白皮书/样片资料/其他。|新增/统一|资料类型| +|internal_pn|关联内部料号|Link(P-08)|条件必填|资料主记录|P-08/FAE|W05/W24|料号相关资料必须回链P-08。|新增/统一|PN| +|brand_name|品牌名称|Text/Link|是|资料主记录|数据Owner|W05/W24|统一引用P-08.brand_name。|重命名/统一|原厂| +|document_title|资料标题|Text|是|资料主记录|资料Owner|W05/W24|资料可识别名称。|新增|资料| +|document_version|资料版本|Text|是|资料主记录|资料Owner|W05/W24|评估必须回链版本。|重命名/统一|版本| +|document_date|资料发布日期|Date|条件必填|资料主记录|资料Owner|W05/W24|用于时效判断。|新增|更新记录| +|document_ref|资料文件链接|URL/Attachment|是|资料主记录|资料Owner|W05/W24|官方资料文件。|重命名/统一|Datasheet链接、资料| +|citation_locator|证据定位|Text/Structure|W05必填;W24条件|资料主记录|AI/FAE|W05/W24|页码、章节、表格或段落,支持参数证据引用。|新增|证据引用、页码或证据段落| +|source_contact_ref|原厂支持联系人|Link(L-08)/Text|条件必填|资料主记录|FAE|W05/W24|需要补证时的联系人。|重命名/统一|原厂支持联系人| +|document_status|资料状态|Enum|是|资料主记录|资料Owner|W05/W24|有效/过期/待核实/作废。|新增/统一|状态| + +#### P-11 替代选型与批准关系库 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|substitution_relation_id|替代关系ID|Text/AutoNumber|是|替代关系主记录|系统|W05/W24|原P-11-01与P-11-02统一后的关系主键。|新增/统一|P-11-01/P-11-02记录ID| +|original_internal_pn|原内部料号|Link(P-08)|是|替代关系主记录|P-08/FAE|W05/W24|评估基准物料。|重命名/统一|Original_PN、原PN、目标替代料号| +|candidate_internal_pn|候选内部料号|Link(P-08)|是|替代关系主记录|P-08/FAE|W05/W24|被评估物料。|重命名/统一|Candidate_PN、候选PN| +|substitution_type|替代类型|Enum|是|替代关系主记录|FAE|W05/W24|Pin-to-Pin/功能替代/参数替代/其他。|重命名/统一|替代类型、关系类型| +|recommendation_level|推荐等级|Enum|是|替代关系主记录|AI/FAE|W05/W24|推荐/条件推荐/备选/不推荐。|重命名/统一|替代等级、推荐| +|recommendation_reason|推荐理由|LongText|是|替代关系主记录|AI/FAE|W05/W24|说明技术和商务依据。|新增/统一|推荐理由| +|difference_summary|差异摘要|LongText|是|替代关系主记录|AI/FAE|W05|供P-01和后续流程消费。|新增/统一|评论、差异摘要| +|difference_item_id|差异明细ID|Text/AutoNumber|是|差异明细子表|系统|W05|一条参数差异一条记录。|新增|逐参数差异明细| +|parameter_name|标准参数名|Text/Link(GOV)|是|差异明细子表|AI/FAE|W05|参数名称标准化。|新增/统一|参数名称| +|original_value|原物料参数值|Text/Number/Structure|是|差异明细子表|AI|W05|引用P-08或资料证据。|新增|参数A| +|candidate_value|候选物料参数值|Text/Number/Structure|是|差异明细子表|AI|W05|引用P-08或资料证据。|新增|参数B| +|parameter_unit|参数单位|Text/Link(GOV)|条件必填|差异明细子表|AI/FAE|W05|统一单位后比较。|新增/统一|单位| +|constraint_type|约束类型|Enum|是|差异明细子表|GOV/AI|W05|硬约束/软约束。|新增/统一|硬/软约束标识| +|difference_status|差异状态|Enum|是|差异明细子表|AI/FAE|W05|MATCH/MISMATCH/UNKNOWN/CONDITIONAL。|新增/统一|Status、差异状态| +|impact_statement|影响说明|LongText|MISMATCH/CONDITIONAL必填|差异明细子表|AI/FAE|W05|说明对应用、验证、报价的影响。|新增|Impact、影响说明| +|evidence_ref|差异证据引用|URL/Attachment/Structure|是|差异明细子表|AI/FAE|W05|具体资料、页码、测试记录。|统一|证据引用| +|rule_version|差异规则版本|Text/Link(GOV)|是|差异明细子表|GOV/系统|W05|记录评估时采用的硬/软约束规则版本。|新增/统一|GOV规则版本| +|risk_level|替代风险等级|Enum|是|替代关系主记录|FAE/RI|W05/W24|低/中/高/关键。|统一|风险等级| +|verification_requirement|验证要求|LongText/Structure|CONDITIONAL/UNKNOWN必填|替代关系主记录|FAE|W05/W24|与P-06.validation_id或待验证任务回链。|统一|验证要求| +|validation_id|关联验证ID|Link(P-06)|条件必填|替代关系主记录|系统/FAE|W05/W24|验证完成后回链事实。|新增/统一|验证编号、验证结果| +|analysis_status|分析状态|Enum|是|替代关系主记录|系统/FAE|W05/W24|AI草稿/FAE确认/待客户验证/客户已批准/不批准/作废。|新增/统一|写入权限与时机、批准状态| +|customer_approval_status|客户批准状态|Enum|是|替代关系主记录|审批流程|W05/W24|未提交/待批准/已批准/不批准/有条件批准;不由差异评估自动产生。|重命名/统一|批准状态、客户批准| +|customer_approval_at|客户批准时间|DateTime|客户已批准时必填|替代关系主记录|审批流程|W05/W24|客户批准事实时间。|新增/统一|批准时间| +|customer_approval_ref|客户批准凭证|URL/Attachment|客户已批准时必填|替代关系主记录|审批流程|W05/W24|签核邮件、系统审批单或客户文件。|新增/统一|批准凭证| +|customer_feedback|客户反馈|LongText|条件必填|替代关系主记录|FAE/BDPM|W05/W24|记录客户对候选物料的反馈。|保留/细化|客户反馈| +|request_id|关联需求编号|Link(P-01)|条件必填|替代关系主记录|系统|W05/W24|明确候选关系适用的需求。|新增/统一|关联需求| +|project_id|关联项目ID|Link(P-05)|条件必填|替代关系主记录|系统|W05/W24|不同项目批准边界可不同。|新增/统一|关联项目| +|bom_position|关联BOM位|Text|条件必填|替代关系主记录|FAE|W05/W24|批准边界定位。|新增/统一|BOM位| +|analysis_report_ref|替代分析报告|URL/Attachment|条件必填|替代关系主记录|系统/FAE|W05|正式结构化结果或附件回链。|重命名/统一|替代分析报告、报告链接| +|confidence_score|证据可信度|Percent/Enum|条件必填|替代关系主记录|AI/FAE|W05|AI输出可信度,需与人工确认区分。|新增/统一|证据可信度| + +#### S-03 供应商主台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|supplier_id|供应商ID|Text/AutoNumber|是|供应商主记录|SS03|SS03-1/SS03-2|统一供应商主体键;临时供应商定点前必须转正式ID。|保留/统一|supplier_id、Supplier ID| +|supplier_name|供应商名称|Text|是|供应商主记录|采购/SS03|SS03-1/SS03-2|临时供应商可先占位,定点前补齐正式名称。|保留/统一|supplier_name、供应商名称| +|supplier_type|供应商分类|Enum|是|供应商主记录|SS03|SS03-1/SS03-2|原厂/授权代理/贸易/分销/其他。|保留/细化|supplier_type、供应商分类| +|supplier_status|供应商状态|Enum|是|供应商主记录|SS03|SS03-1/SS03-2|临时/正式/停用;临时状态不可定点。|新增/统一|supplier_status| +|temporary_supplier_flag|临时供应商标记|Boolean|是|供应商主记录|采购|SS03-1/SS03-2|临时新增立即可询价,但必须受定点门禁约束。|新增|临时供应商| +|sales_contact_ref|业务联系人引用|Link/Text|SS03-1必填|供应商主记录|采购/供应商Owner|SS03-1|至少一名联系人,用于邮件或即时通讯外发。|重命名/统一|sales_contact、联系人| +|contact_channel|首选联系渠道|Enum|条件必填|供应商主记录|供应商Owner|SS03-1|邮件/即时通讯/电话;需与外发记录一致。|新增/统一|send_channel、确认通道| +|rfq_product_scope|当前询价品牌/料号范围|Structure|临时供应商定点前必填|供应商主记录|采购|SS03-1/SS03-2|允许不预建完整品牌覆盖,但必须覆盖当前询价。|新增/统一|rfq_product_scope| +|authorized_brand_scope|授权品牌范围|Structure|代理/原厂条件必填|供应商主记录|SS03|SS03-1/SS03-2|判断品牌授权和可供范围。|新增/统一|品牌授权| +|agency_certificate_ref|代理证凭证|URL/Attachment|supplier_type=授权代理时必填|供应商主记录|供应商Owner/采购|SS03-2|无有效代理证不得提交定点审批。|保留/细化|agency_certificate_ref、代理证| +|agency_certificate_valid_to|代理证有效期至|Date|代理证有值时必填|供应商主记录|供应商Owner|SS03-2|定点前检查有效期。|新增|代理证| +|qualification_status|准入资质状态|Enum|定点前必填|供应商主记录|SS03|SS03-2|待准入/合格/限制/不合格。|新增/统一|资质/准入| +|compliance_status|合规状态|Enum|定点前必填|供应商主记录|SS03|SS03-2|通过/待核查/不通过。|新增/统一|合规| +|commercial_terms_ref|长期商务规则引用|Link/Structure|条件必填|供应商主记录|SS03|SS03-2|长期条款不在本次询价Activity维护,但应有引用。|新增/统一|商务规则、长期条款| +|supplier_health_score|供应商健康度|Percent/Enum|条件必填|供应商主记录|SS03/SS04|SS03-2|由履约数据计算,不作为单次询价事实覆盖。|新增/统一|供应商绩效/健康度| + +#### S-04 询价、报价、比价与采购决策记录台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|rfq_batch_id|询价批次号|Text/AutoNumber|是|RFQ层|系统/采购|SS03-1/SS03-2|同一批次可含多料号、多供应商;品牌或料号变化必须新开批次。|保留/统一|rfq_batch_id、询价批次号| +|rfq_line_id|询价行ID|Text/AutoNumber|是|RFQ层|系统|SS03-1/SS03-2|最小粒度为询价批次×供应商×内部料号。|保留/统一|rfq_line_id、询价行ID| +|source_type|需求来源类型|Enum|是|RFQ层|采购/上游台账|SS03-1/SS03-2|P-01客户需求/D-15采购计划/价格维护/异常补货。|统一|source_type| +|source_id|来源对象ID|Text/Link|条件必填|RFQ层|系统/上游台账|SS03-1/SS03-2|与source_type成对;P-01写request_id,D-15写purchase_plan_id。|统一|source_id| +|customer_id|客户ID|Link(L-02)|条件必填|RFQ层|P-01/D-15|SS03-1/SS03-2|用于需求归属与通知。|新增/统一|customer_id| +|project_id|项目ID|Link(P-05)|条件必填|RFQ层|P-01/D-15|SS03-1/SS03-2|用于需求归属与通知。|新增/统一|project_id| +|brand_name|询价品牌|Text/Link(P-08)|是|RFQ层|P-08/采购|SS03-1/SS03-2|与internal_pn精确匹配;品牌变化新开批次。|重命名/统一|brand、manufacturer_brand| +|internal_pn|询价内部料号|Link(P-08)|是|RFQ层|P-08/采购|SS03-1/SS03-2|统一引用P-08.internal_pn。|重命名/统一|pn、料号| +|target_qty|目标数量|Number|是|RFQ层|P-01/D-15/采购|SS03-1/SS03-2|比价基准;来自P-01.target_qty或D-15.trigger_qty。|统一|target_qty、purchase_trigger_qty| +|target_delivery_date|目标交期|Date|是|RFQ层|P-01/D-15/采购|SS03-1/SS03-2|比价基准;来自P-01或D-15.requirement_date。|统一|target_delivery_date、requirement_date| +|requested_price_types|所需价格类型|MultiEnum|是|RFQ层|P-01/采购|SS03-1|参考价/量产价/现货价/样品价。|重命名/统一|price_types_requested| +|quote_due_at|报价回复截止时间|DateTime|是|RFQ层|P-01/采购|SS03-1|供应商回复时效基准。|重命名/统一|quote_due_at、期望报价返回时间| +|target_price_internal|内部目标价|Currency|否|RFQ层|P-01|SS03-1/SS03-2|仅内部判断;如实际披露需留痕。|统一|target_price_internal| +|supplier_id|供应商ID|Link(S-03)|是|RFQ层|S-03/采购|SS03-1/SS03-2|临时供应商定点前转正式ID。|统一|supplier_id| +|send_channel|询价发送渠道|Enum|是|RFQ层|采购/系统|SS03-1|邮件/即时通讯/电话。|统一|send_channel| +|sent_at|询价发送时间|DateTime|是|RFQ层|系统|SS03-1|系统生成;未发送不得标记已发送。|保留/细化|sent_at| +|outbound_content_ref|外发内容引用|URL/Attachment|是|RFQ层|系统/采购|SS03-1|记录对外披露内容和附件。|新增/统一|对外披露内容、附件| +|sent_evidence_ref|外发凭证|URL/Attachment|是|RFQ层|系统|SS03-1|原始邮件、聊天发送记录等。|新增/统一|发送凭证、evidence| +|rfq_status|询价状态|Enum|是|RFQ层|系统/采购|SS03-1|草稿/已发送/部分回复/全部回复/已到期未回复/催询中/关闭。|保留/细化|rfq_status| +|reminder_count|催询次数|Integer|是|RFQ层|系统|SS03-1|自动提醒与人工催询均计数。|新增|跟进回复时效| +|last_reminded_at|最后催询时间|DateTime|条件必填|RFQ层|系统/采购|SS03-1|有催询时必填。|新增|催询| +|overdue_flag|询价超期标记|Boolean|是|RFQ层|系统|SS03-1|quote_due_at已过且未完成回复或未发出时触发。|新增|已到期未回复、发起超期| +|supplier_quote_id|供应商报价ID|Text/AutoNumber|是|Quote层|系统|SS03-2|同一询价行可有报价修订历史。|新增/统一|报价记录ID| +|quote_status|供应商报价状态|Enum|是|Quote层|采购|SS03-2|已报价/拒绝报价/未回复/待确认。|保留/细化|quote_status| +|price_type|报价价格类型|Enum|条件必填|Quote层|采购|SS03-2|必须与requested_price_types匹配。|保留/统一|price_type| +|unit_price|报价单价|Currency|已报价时必填|Quote层|采购|SS03-2|与currency_code成对。|重命名/统一|unit_price、价格| +|currency_code|报价币种|ISO 4217 Enum|已报价时必填|Quote层|采购|SS03-2|统一币种比较前保留原币种。|重命名/统一|currency| +|actual_qty|报价适用数量|Number|已报价时条件必填|Quote层|采购|SS03-2|判断与target_qty可比性。|重命名/统一|actual_qty| +|lead_time_days|报价交期天数|Number|已报价时条件必填|Quote层|采购|SS03-2|统一工作日/自然日口径;原始文字可放lead_time_note。|重命名/统一|lead_time、LT| +|lead_time_note|交期说明|LongText|条件必填|Quote层|采购|SS03-2|无法标准化时保留供应商原文。|新增|lead_time| +|moq_qty|MOQ|Number|条件必填|Quote层|采购|SS03-2|最小起订量。|拆分/统一|MOQ-SPQ、MOQ| +|spq_qty|SPQ|Number|条件必填|Quote层|采购|SS03-2|标准包装量。|拆分/统一|MOQ-SPQ、SPQ| +|quote_valid_until|报价有效期至|Date|条件必填|Quote层|采购|SS03-2|报价是否仍可用。|重命名/统一|valid_until、报价有效期| +|special_terms|本次特殊商务条款|LongText/Structure|否|Quote层|采购|SS03-2|一次性条款;长期供应商条款回链S-03。|重命名/细化|special_terms、特殊条款| +|response_at|实际回复时间|DateTime|已回复时必填|Quote层|系统/采购|SS03-2|回复时间事实。|保留/细化|response_at| +|no_quote_reason|未报价原因|LongText|拒绝/未回复时必填|Quote层|采购|SS03-2|拒绝报价或未回复必须填写。|保留/细化|no_quote_reason| +|quote_evidence_ref|原始报价凭证|URL/Attachment|报价/拒绝均必填|Quote层|系统/采购|SS03-2|邮件、聊天截图、供应商报价单。|重命名/统一|evidence、原始沟通凭证| +|quote_revision_no|同轮报价修订号|Integer|是|Quote层|系统/采购|SS03-2|同轮修改保留修订历史,当前报价可聚合展示。|新增|报价与版本| +|comparison_group_id|比价组ID|Text/AutoNumber|是|Decision层|系统|SS03-2|由rfq_batch_id+internal_pn+target_qty+target_delivery_date组成。|重命名/统一|comparison_group| +|comparability_status|可比性状态|Enum|是|Decision层|AI/采购|SS03-2|可直接比较/条件可比/不可比;不可比必须说明原因。|新增|正式比价组、不可直接比较| +|comparison_basis_snapshot_ref|比价基准快照|Structure/URL|是|Decision层|系统|SS03-2|固化目标数量、目标交期、价格类型、汇率和基准版本。|新增|比价组| +|system_score|系统评分|Number|是|Decision层|系统|SS03-2|按询价类型权重计算,0-100。|重命名/统一|score| +|system_recommendation|系统推荐|Enum|是|Decision层|系统|SS03-2|推荐/备选/不推荐。|重命名/统一|recommendation| +|procurement_confirm|采购确认意见|Enum|意见确认时必填|Decision层|采购|SS03-2|认可/调整/否决。|保留/细化|procurement_confirm| +|confirm_reason|确认或调整理由|LongText|与系统推荐不一致时必填|Decision层|采购|SS03-2|采购调整或否决系统建议时必填。|重命名/统一|confirm_reason| +|baseline_id|采购基准ID|Link(S-05)|有基准时必填|Decision层|系统|SS03-2|与品牌、料号、价格类型、数量和有效期匹配。|新增/统一|baseline_id| +|baseline_match_status|基准匹配状态|Enum|是|Decision层|系统|SS03-2|已匹配/无基准/条件匹配/不匹配。|新增|无基准可比、基准比较| +|baseline_unit_price|比较基准单价|Currency|有基准时必填|Decision层|系统|SS03-2|复制当次采用的S-05基准快照,避免历史重算漂移。|新增/统一|benchmark_unit_price| +|comparison_currency_code|比价统一币种|ISO 4217 Enum|跨币种时必填|Decision层|系统|SS03-2|统一币种并回链汇率。|新增|汇率/统一币种| +|conversion_rate|币种换算汇率|Number|跨币种时必填|Decision层|系统|SS03-2|记录换算基准和来源。|新增|汇率| +|price_variance_pct|相对基准涨幅|Percent|有基准时派生|Decision层|系统|SS03-2|(报价-基准)/基准;涨幅>10%标红。|重命名/统一|price_variance_pct、涨幅| +|variance_reason|涨价原因|LongText|涨幅>10%时必填|Decision层|采购|SS03-2|说明供应商涨价、市场、交期或其他原因。|保留/细化|variance_reason、涨价原因| +|selected_supplier_id|中选供应商ID|Link(S-03)|定点通过时必填|Decision层|系统/采购|SS03-2|审批通过的定点对象。|新增/统一|推荐供应商、中选供应商| +|selected_quote_id|中选报价ID|Link(S-04 Quote)|定点通过时必填|Decision层|系统|SS03-2|关联被选中的供应商报价。|新增/统一|定点结论| +|approval_status|定点审批状态|Enum|是|Decision层|审批流|SS03-2|待审批/已通过/已驳回。|重命名/统一|approval_status| +|approval_comment|定点审批意见|LongText|驳回/条件通过时必填|Decision层|审批人|SS03-2|审批意见。|重命名/统一|approval_comment| +|decision_status|定点结论状态|Enum|是|Decision层|系统/采购|SS03-2|待定点/已定点/无法定点/建议关闭/已变更。|重命名/统一|decision_status、定点结论| +|unselected_reason|未选/无法定点原因|LongText|无法定点或未选时必填|Decision层|采购|SS03-2|完整说明比较、准入或供应条件原因。|新增/统一|未选原因、无法定点原因| +|notification_status|结果通知状态|Enum|是|Decision层|系统|SS03-2|待通知/已通知/通知失败。|重命名/统一|notification_status| +|notification_recipient_ref|结果通知对象|Link/Text|定点结论产生时必填|Decision层|系统|SS03-2|按source_type通知P-01负责人、D-15负责人或其他发起人。|新增/统一|通知需求发起人| +|score_rule_version|评分规则版本|Text/Link(GOV)|是|Decision层|GOV/系统|SS03-2|记录不同询价类型的权重版本。|新增/统一|规则版本| + +#### S-05 采购基准与供应能力台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|baseline_id|采购基准ID|Text/AutoNumber|是|基准主记录|系统|SS03-1/SS03-2|基准唯一标识。|保留/统一|baseline_id| +|brand_name|基准品牌|Text/Link(P-08)|是|基准主记录|采购/SS03|SS03-1/SS03-2|与internal_pn精确匹配。|重命名/统一|brand| +|internal_pn|基准内部料号|Link(P-08)|是|基准主记录|P-08/采购|SS03-1/SS03-2|统一引用P-08。|重命名/统一|pn| +|supplier_id|基准供应商ID|Link(S-03)|是|基准主记录|S-03/采购|SS03-1/SS03-2|当前采购基准的供应商。|统一|supplier_id| +|price_type|基准价格类型|Enum|是|基准主记录|采购|SS03-2|必须与本次报价价格类型一致后比较。|统一|price_type| +|baseline_unit_price|采购基准单价|Currency|是|基准主记录|采购|SS03-2|历史/当前有效采购基准,不被单次询价直接覆盖。|重命名/统一|benchmark_unit_price| +|currency_code|基准币种|ISO 4217 Enum|是|基准主记录|采购|SS03-2|与单价成对。|重命名/统一|currency| +|quantity_tier|适用数量阶梯|Structure|是|基准主记录|采购|SS03-2|匹配目标数量。|拆分/结构化|applicable_qty、数量阶梯| +|valid_from|基准生效时间|Date|是|基准主记录|采购|SS03-2|有效期判断。|重命名/统一|effective_from| +|valid_to|基准失效时间|Date|是|基准主记录|采购|SS03-2|有效期判断。|重命名/统一|effective_to| +|lead_time_days|基准交期天数|Number|条件必填|基准主记录|采购|SS03-1/SS03-2|供应能力比较。|重命名/统一|lead_time、LT| +|moq_qty|基准MOQ|Number|条件必填|基准主记录|采购|SS03-2|供应能力比较。|拆分/统一|MOQ| +|spq_qty|基准SPQ|Number|条件必填|基准主记录|采购|SS03-2|供应能力比较。|拆分/统一|SPQ| +|availability_status|可供状态|Enum|是|基准主记录|采购|SS03-1/SS03-2|可供/部分可供/不可供/待确认。|重命名/统一|availability_status、可供状态| +|supply_risk_level|供应风险等级|Enum|条件必填|基准主记录|采购|SS03-2|低/中/高/关键。|新增/统一|供应风险| +|baseline_status|基准状态|Enum|是|基准主记录|采购|SS03-2|Active/暂停/过期/替代。|保留/细化|baseline_status| +|source_quote_ref|基准来源报价引用|Link(S-04/P-02)|条件必填|基准主记录|系统/采购|SS03-2|记录基准形成依据。|重命名/统一|来源报价| +|locked_at|基准锁定时间|DateTime|条件必填|基准主记录|采购|SS03-2|锁定后单次询价不得直接覆盖。|新增|报价锁定基准| + +#### D-15 采购计划与执行需求台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|purchase_plan_id|采购计划ID|Text/AutoNumber|是|采购计划主记录|SS04|SS03-1/SS03-2|内部采购需求唯一引用;写入S-04.source_id。|保留/统一|purchase_plan_id| +|customer_id|客户ID|Link(L-02)|条件必填|采购计划主记录|SS04|SS03-1/SS03-2|客户需求型采购必填。|统一|customer_id| +|project_id|项目ID|Link(P-05)|条件必填|采购计划主记录|SS04|SS03-1/SS03-2|项目型采购必填。|统一|project_id| +|internal_pn|内部料号|Link(P-08)|是|采购计划主记录|P-08/SS04|SS03-1/SS03-2|统一引用P-08。|重命名/统一|pn| +|forecast_version|Forecast版本|Text/Link(D-02)|条件必填|采购计划主记录|D-02/SS04|SS03-1/SS03-2|Forecast来源时必填。|统一|Forecast版本、版本号| +|customer_pickup_demand|客户提货需求|Number/Date/Structure|条件必填|采购计划主记录|SS04|SS03-1/SS03-2|客户提货需求的数量和时间。|重命名/统一|提货需求| +|requirement_date|内部需求日期|Date|是|采购计划主记录|SS04|SS03-1/SS03-2|作为询价目标交期或内部需求时点。|重命名/统一|requirement_date、需求日期| +|inventory_ref|库存引用|Link(D-03)|条件必填|采购计划主记录|系统|SS03-1/SS03-2|用于采购建议量判断。|新增/统一|库存引用| +|in_transit_ref|在途引用|Link(D-03/D-16)|条件必填|采购计划主记录|系统|SS03-1/SS03-2|用于采购建议量判断。|新增/统一|在途引用| +|recommended_purchase_qty|建议采购量|Number|是|采购计划主记录|系统/采购|SS03-1/SS03-2|形成询价目标数量的依据。|新增/统一|建议采购量| +|trigger_qty|采购触发数量|Number|是|采购计划主记录|SS04|SS03-1/SS03-2|进入询价的正式数量输入。|重命名/统一|purchase_trigger_qty、触发数量| +|purchase_type|采购类型|Enum|是|采购计划主记录|SS04|SS03-1/SS03-2|量产/样品/现货/异常补货。|保留/细化|purchase_type| +|urgency_flag|紧急程度|Enum/Boolean|是|采购计划主记录|SS04|SS03-1/SS03-2|普通/紧急/特急;影响评分权重。|保留/细化|urgency_flag| +|plan_status|采购计划状态|Enum|是|采购计划主记录|SS04|SS03-1/SS03-2|有效/待执行/询价中/已满足/关闭。|保留/细化|plan_status| +|triggered_at|触发询价时间|DateTime|条件必填|采购计划主记录|系统|SS03-1|记录进入询价流程的时间。|新增/统一|触发时间| +|po_ref|关联采购订单引用|Link(D-16)|条件必填|采购计划主记录|系统|SS03-2|定点/执行后回链D-16。|重命名/统一|关联后续PO| + +#### L-02 客户主台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|customer_id|客户ID|Text/AutoNumber|是|客户主记录|SS01|W24/SS03/11-A2|所有客户侧台账统一锚点。|保留/统一|唯一ID、customer_id| +|customer_name|客户名称|Text|是|客户主记录|SS01|W05/W24/11-A2|展示名称;法律主体与展示名称如不同需分开。|新增/统一|客户名称、客户| +|legal_entity_name|客户法律主体|Text|条件必填|客户主记录|SS01|11-A2条件|单证、合同或制裁筛查需要时必填。|新增|主体信息| +|customer_status|客户状态|Enum|是|客户主记录|SS01|W24/11-A2|有效/暂停/终止。|新增/统一|状态机| +|default_contact_ref|默认联系人引用|Link(L-08)|条件必填|客户主记录|SS01|11-A2|与D-10提货联系人区分;D-10可覆盖为履约联系人。|新增/统一|联系人| + +#### D-01 交易与执行台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|order_id|订单号|Text/AutoNumber|是|订单主记录|SS04|11-A2/11-A3|订单执行主键。|保留/统一|order_id、订单号| +|request_id|需求编号|Link(P-01)|条件必填|订单主记录|系统|11-A2|回链P-01需求。|统一|需求编号| +|customer_id|客户ID|Link(L-02)|是|订单主记录|系统|11-A2/11-A3|客户名称通过L-02展示。|统一|customer_id| +|project_id|项目ID|Link(P-05)|条件必填|订单主记录|系统|11-A2|项目追溯。|统一|项目| +|internal_pn|内部料号|Link(P-08)|是|订单主记录|P-08/SS04|11-A2|内部料号统一引用P-08。|重命名/统一|mpn、料号| +|customer_pn|客户料号|Text/Link(D-10)|条件必填|订单主记录|D-10/SS04|11-A2|通过D-10映射获得。|重命名/统一|customer_mpn| +|order_qty|订单数量|Number|是|订单主记录|订单/系统|11-A2|交接实发数量核对基准。|保留/统一|order_qty| +|allocation_ref|分配批次引用|Link(D-03)|条件必填|订单主记录|D-03/SS04|11-A2|指向D-03批次分配记录。|新增/统一|inventory_lot| +|promised_delivery_date|承诺交期|Date|条件必填|订单主记录|P-04/SS04|11-A2|订单承诺交期。|重命名/统一|承诺交期| +|trade_term|贸易术语|Enum|条件必填|订单主记录|D-10/P-04|11-A2|EXW等;风险转移逻辑依赖。|新增/统一|BT类型、贸易术语| +|milestones|履约里程碑|Structure|是|订单主记录|系统/SS04|11-A2/11-A3|至少含交接;每个节点有状态、时间、来源、证据。|细化|milestones、里程碑| +|pickup_status|提货状态|Enum|是|提货交接事实层|PW/系统|11-A2|待提货/已预约/已提货/待重新预约/取消。|保留/细化|pickup_status| +|ship_date|发货日期|Date|现场交接时必填|提货交接事实层|PW|11-A2|现场事实;由PW唯一录入。|保留/细化|ship_date| +|shipped_qty|实发数量|Number|现场交接时必填|提货交接事实层|PW|11-A2|现场事实;与order_qty及D-03同步校验。|保留/细化|shipped_qty| +|handover_no|交接单号|Text|交接完成时必填|提货交接事实层|系统|11-A2|格式HD-YYYYMMDD-NNN。|保留/细化|handover_no| +|receiver_name|签收人姓名|Text|交接完成时必填|提货交接事实层|PW|11-A2|双方签署事实。|重命名/统一|receiver、签收人姓名| +|sign_time|交接签署时间|DateTime|交接完成时必填|提货交接事实层|PW|11-A2|EXW风险转移时点的事实基础。|保留/细化|sign_time、签署时间| +|risk_transfer_time|风险转移时点|DateTime|签署后派生|提货交接事实层|系统/AI/PW确认|11-A2/11-A3|EXW场景默认等于sign_time;作为开票和后续追踪锚点。|保留/细化|risk_transfer_time| +|handover_diff|交接差异|Enum/Structure|条件必填|提货交接事实层|PW/RI|11-A2|无差异/数量差异/型号差异/批次差异/包装差异/其他;关联差异单。|保留/细化|handover_diff| +|handover_file_ref|交接单影像/文件|URL/Attachment|交接完成时必填|提货交接事实层|PW|11-A2|必须区分草稿、已签署、已归档。|重命名/统一|handover_file| +|handover_doc_status|交接单文件状态|Enum|有handover_file_ref时必填|提货交接事实层|PW/系统|11-A2|草稿/已签署/已归档/作废;下游只消费已签署或已归档。|新增/统一|文档状态| +|handover_notes|交接注意事项|LongText|条件必填|提货交接事实层|D-10/PW|11-A2|模板或现场特殊注意事项。|新增|交接注意事项| +|logi_pickup|提货节点事实|Structure|11-A3必填|物流节点事实层|AI/PW|11-A3|含estimated_time、actual_time、source、collection_method、confirmation_status、anomaly_flag、evidence_ref。|保留/结构化|logi_pickup| +|logi_clearance|报关节点事实|Structure|11-A3必填|物流节点事实层|AI/PW|11-A3|同上;异常如报关受阻、查验。|保留/结构化|logi_clearance| +|logi_release|放行节点事实|Structure|11-A3必填|物流节点事实层|AI/PW|11-A3|同上。|保留/结构化|logi_release| +|logi_departure|离港节点事实|Structure|11-A3必填|物流节点事实层|AI/PW|11-A3|同上;异常如离港延误。|保留/结构化|logi_departure| +|track_note|物流跟踪/决策备注|LongText|条件必填|物流节点事实层|PW/RI|11-A3|货代不配合时记录降级为到货确认或销售协调等决策。|重命名/统一|track_note、决策记录| +|track_visible|物流信息可查状态|Boolean|四节点完成后必填|物流节点事实层|系统|11-A3|四节点实际事实齐备后为True;不是人工主观勾选。|保留/细化|track_visible| +|invoice_status|开票状态|Enum|是|订单状态层|系统/财务|11-A2|待开票/已触发/已开票/异常;risk_transfer_time为触发锚点。|新增/统一|开票状态| +|acceptance_status|验收状态|Enum|是|订单状态层|履约Owner|11-A2|待验收/已确认/争议/不适用。|新增/统一|验收状态| +|order_status|订单状态|Enum|是|订单状态层|系统/SS04|11-A2/11-A3|已放行/已交接/物流跟踪中/已完成/异常/关闭。|重命名/统一|订单状态机| +|closed_flag|关闭标记|Boolean|是|订单状态层|系统/SS04|11-A2/11-A3|所有必需节点或异常处置完结后关闭。|新增/统一|关闭状态| +|close_reason|关闭原因|LongText|关闭时必填|订单状态层|SS04|11-A2/11-A3|正常完成/取消/转异常流程等。|新增|关闭状态| +|pickup_appointment_time|预约提货时间|DateTime|分支2场景必填|提货交接事实层|D-10/PW|11-A2|用于预约提醒、未按预约提货判断及重新预约。|新增/统一|提货预约时间| +|physical_check_status|实物核对状态|Enum|交接前必填|提货交接事实层|PW|11-A2|未核对/已核对/不一致;核对数量、型号、批次标签和包装。|新增|实物一致性核对| +|diff_handling_result|差异处理结果|Enum/Structure|有差异时必填|提货交接事实层|RI/PW|11-A2|补齐后交接/按实发量交接并标注/暂停并转后续Activity。|新增/统一|差异处理结果| +|ri_gate_decision|RI门禁判断|Enum/LongText|差异或超时升级时必填|提货交接事实层|RI|11-A2/11-A3|放行/补齐后放行/暂停/转异常/降级追踪/联系客户核实。|新增/统一|RI门禁判断、决策记录| +|ri_escalated_at|RI升级时间|DateTime|发生升级时必填|提货交接事实层|系统|11-A2/11-A3|差异、预约超时或节点反馈超时的升级事实。|新增|升级时限| +|node_type|物流节点类型|Enum|是|物流节点子表(适用于logi_*)|系统|11-A3|提货/报关/放行/离港;对应logi_*字段。|新增/统一|四个关键节点| +|estimated_time|节点预计时间|DateTime|是|物流节点子表(适用于logi_*)|系统/AI|11-A3|到达预计时间后触发确认请求。|新增/统一|节点预计时间| +|actual_time|节点实际时间|DateTime|收到事实时必填|物流节点子表(适用于logi_*)|AI/PW|11-A3|收到反馈或人工电话确认的实际时间。|新增/统一|时间| +|source|节点事实来源|Text/Link|是|物流节点子表(适用于logi_*)|AI/PW|11-A3|回复单位、系统或具体联系人;不得只写“外部”。|重命名/统一|来源、Source| +|collection_method|节点采集方式|Enum|是|物流节点子表(适用于logi_*)|AI/PW|11-A3|邮件/微信/即时通讯/电话/人工录入。|重命名/统一|采集方式、Method| +|confirmation_status|节点确认状态|Enum|是|物流节点子表(适用于logi_*)|系统/PW|11-A3|待确认/已确认/超时/异常/已升级。|新增/统一|确认状态| +|anomaly_flag|节点异常标记|Boolean|是|物流节点子表(适用于logi_*)|AI/PW|11-A3|报关受阻、查验、离港延误等为True。|新增/统一|Anomaly Flag、异常节点标记| +|anomaly_type|节点异常类型|Enum/Text|anomaly_flag=True时必填|物流节点子表(适用于logi_*)|AI/PW|11-A3|报关受阻/查验/离港延误/其他。|新增/统一|异常节点| +|node_evidence_ref|节点事实证据|URL/Attachment|已确认或异常时必填|物流节点子表(适用于logi_*)|AI/PW|11-A3|回复原文、电话记录或其他节点证据。|新增/统一|证据引用| +|reminder_count|节点提醒次数|Integer|是|物流节点子表(适用于logi_*)|系统|11-A3|按D-10最大提醒次数控制。|新增/统一|最大提醒次数| +|last_request_at|最后请求时间|DateTime|有请求时必填|物流节点子表(适用于logi_*)|系统|11-A3|支持间隔天数和超时判断。|新增|确认请求| +|node_ri_escalation_status|节点RI升级状态|Enum|超时/异常时必填|物流节点子表(适用于logi_*)|系统/RI|11-A3|未升级/已升级/已核实/已降级。|新增/统一|升级RI门禁| + +#### D-04 回款与信用台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|credit_record_id|信用记录ID|Text/AutoNumber|是|信用门禁主记录|系统|11-A2|回款信用门禁记录主键。|新增/统一|信用记录| +|customer_id|客户ID|Link(L-02)|是|信用门禁主记录|SS04|11-A2|关联客户信用主体。|统一|客户ID| +|payment_terms|账期条款|Text/Structure|是|信用门禁主记录|P-04/SS04|11-A2|从交易框架读取,作为信用判断依据。|重命名/统一|账期条款| +|credit_limit|信用额度|Currency|条件必填|信用门禁主记录|商务/财务|11-A2|与currency_code成对。|统一|信用额度| +|outstanding_amount|应收金额|Currency|是|信用门禁主记录|财务|11-A2|当前应收,需记录币种。|新增/统一|应收| +|paid_amount|已回款金额|Currency|是|信用门禁主记录|财务|11-A2|当前已回款,需记录币种。|新增/统一|已回款| +|overdue_days|逾期天数|Integer|是|信用门禁主记录|系统/财务|11-A2|信用预警触发依据。|保留/细化|逾期天数| +|credit_rating|信用评级|Enum|是|信用门禁主记录|财务/商务|11-A2|正常/关注/高风险/冻结。|保留/细化|信用评级| +|warning_status|信用预警状态|Enum|是|信用门禁主记录|系统/财务|11-A2|无预警/预警/拦截/已解除。|保留/细化|预警状态| + +#### D-03 库存与在途台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|inventory_record_id|库存记录ID|Text/AutoNumber|是|库存批次记录|系统|11-A2|库存事实主键。|新增/统一|库存记录| +|internal_pn|内部料号|Link(P-08)|是|库存批次记录|P-08/SS04|11-A2|统一引用P-08。|重命名/统一|料号| +|manufacturer_lot|原厂批次|Text|条件必填|库存批次记录|供应商/仓库|11-A2|交接单/COC披露。|重命名/统一|mfr_lot、lot| +|date_code|日期码DC|Text|条件必填|库存批次记录|供应商/仓库|11-A2|交接单/COC披露。|重命名/统一|dc、Date Code| +|available_qty|可用数量|Number|是|库存批次记录|仓库|11-A2|可出库库存。|新增/统一|可用| +|inspection_qty|待检数量|Number|是|库存批次记录|仓库|11-A2|状态数量。|新增/统一|待检| +|frozen_qty|冻结数量|Number|是|库存批次记录|仓库|11-A2|状态数量。|新增/统一|冻结| +|in_transit_qty|在途数量|Number|是|库存批次记录|系统|11-A2|与D-16履约关联。|新增/统一|在途| +|allocation_id|分配记录ID|Text/AutoNumber|是|库存批次记录|系统|11-A2|批次占用与订单绑定。|新增/统一|子表2| +|allocated_qty|已分配数量|Number|是|库存批次记录|系统|11-A2|与allocation_status配套。|新增/统一|分配数量| +|allocation_status|分配状态|Enum|是|库存批次记录|系统/PW确认|11-A2|已分配/部分出库/已出库/释放。|重命名/统一|alloc_status| +|shipped_qty|已出库数量|Number|是|库存批次记录|系统/PW确认|11-A2|与D-01.shipped_qty同步。|保留/细化|shipped_qty| +|last_ship_date|最后出库日期|Date|条件必填|库存批次记录|系统/PW确认|11-A2|该批次最后一次出库事实。|保留/细化|last_ship_date| +|moq_qty|库存批次MOQ|Number|条件必填|库存批次记录|S-05/供应商|11-A2|备货决策参考。|新增/统一|MOQ| +|lead_time_days|库存批次交期天数|Number|条件必填|库存批次记录|S-05/供应商|11-A2|备货与在途判断。|新增/统一|交期| +|stocking_decision|备货决策|Enum/Structure|条件必填|库存批次记录|履约Owner|11-A2|维持/补货/冻结/退出。|新增/统一|备货决策| +|exit_plan|退出方案|LongText/Structure|stocking_decision=退出时必填|库存批次记录|履约Owner|11-A2|异常PN或库存退出动作。|新增/统一|退出方案| + +#### D-10 客户履约主数据台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|execution_profile_id|履约参数档案ID|Text/AutoNumber|是|执行参数主记录|SS04|11-A2/11-A3|客户履约参数的版本化主键。|新增/统一|执行参数| +|customer_id|客户ID|Link(L-02)|是|执行参数主记录|SS04|11-A2/11-A3|履约参数按客户维护。|统一|客户| +|incoterms|贸易术语|Enum|是|执行参数主记录|履约Owner|11-A2|EXW等;影响风险转移规则。|新增/统一|贸易术语| +|packaging_requirements|包装要求|LongText/Structure|条件必填|执行参数主记录|履约Owner|11-A2|生成交接单和实物核对依据。|重命名/统一|包装| +|label_requirements|标签要求|LongText/Structure|条件必填|执行参数主记录|履约Owner|11-A2|实物核对依据。|重命名/统一|标签| +|document_requirements|单证要求|Structure|条件必填|执行参数主记录|履约Owner|11-A2|客户格式与单证清单要求。|重命名/统一|单证要求| +|logistics_preference|物流偏好|Structure|条件必填|执行参数主记录|履约Owner|11-A3|节点采集偏好。|重命名/统一|物流偏好| +|service_level|服务等级|Enum/Structure|条件必填|执行参数主记录|履约Owner|11-A3|节点可见性和响应要求。|新增/统一|服务等级| +|alert_thresholds|预警阈值|Structure|条件必填|执行参数主记录|履约Owner|11-A3|节点超时、异常延误等阈值。|新增/统一|预警阈值| +|pickup_contact_ref|提货联系人引用|Link(L-08)/Text|是|执行参数主记录|履约Owner|11-A2/11-A3|用于交接单和未按预约提货提醒。|重命名/统一|pickup_contact| +|pickup_appointment_mode|预约提货方式|Enum|是|执行参数主记录|履约Owner|11-A2|预约/临时/固定窗口等。|重命名/统一|pickup_appt_mode| +|logistics_confirmation_channel|物流节点确认渠道|Enum|是|执行参数主记录|履约Owner|11-A3|邮件/微信/即时通讯/电话。|重命名/统一|logi_channel、确认通道| +|logistics_confirmation_interval_days|节点确认间隔天数|Integer|是|执行参数主记录|履约Owner|11-A3|默认3天;可按客户配置。|重命名/统一|logi_interval| +|logistics_max_reminder_count|最大提醒次数|Integer|是|执行参数主记录|履约Owner|11-A3|默认3次;达到后升级RI。|重命名/统一|logi_max_retry| +|handover_template_ref|交接单模板引用|URL/Attachment|是|执行参数主记录|履约Owner|11-A2|生成交接单草稿。|重命名/统一|handover_tpl| +|handover_notes|交接注意事项|LongText|条件必填|执行参数主记录|履约Owner|11-A2|客户特殊注意事项;生成交接单。|新增/统一|交接注意事项| +|mapping_id|料号映射ID|Text/AutoNumber|是|料号映射子表|系统|11-A2|客户料号与内部料号一对一版本记录。|新增|料号映射| +|customer_pn|客户料号|Text|是|料号映射子表|履约Owner|11-A2|客户侧料号。|重命名/统一|客户料号、customer_mpn| +|internal_pn|内部料号|Link(P-08)|是|料号映射子表|履约Owner|11-A2|内部统一料号。|重命名/统一|internal_pn| +|mapping_version|映射版本|Text|是|料号映射子表|履约Owner|11-A2|料号映射版本受控。|新增/统一|版本| +|mapping_status|映射状态|Enum|是|料号映射子表|履约Owner|11-A2|有效/待确认/过期/作废。|新增/统一|状态| +|difference_flag|映射差异标记|Boolean/Structure|是|料号映射子表|系统/履约Owner|11-A2|客户料号与内部料号无法一一对应时标记并说明。|新增/统一|差异标注| + +#### D-11 出口单证台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|document_set_id|单证包ID|Text/AutoNumber|是|单证包主记录|系统|11-A2|订单级单证包唯一标识。|新增/统一|证据包| +|order_id|订单号|Link(D-01)|是|单证包主记录|系统|11-A2|回链D-01订单。|新增/统一|订单号| +|invoice_ref|发票文件引用|URL/Attachment|条件必填|单证包主记录|履约/财务|11-A2|随货单证之一。|重命名/统一|发票| +|packing_list_ref|箱单文件引用|URL/Attachment|条件必填|单证包主记录|履约|11-A2|随货单证之一。|重命名/统一|箱单| +|coc_ref|COC文件引用|URL/Attachment|条件必填|单证包主记录|履约/质量|11-A2|随货单证之一。|重命名/统一|COC| +|certificate_of_origin_ref|产地证文件引用|URL/Attachment|条件必填|单证包主记录|履约|11-A2|按客户或贸易场景要求。|重命名/统一|产地证| +|warranty_certificate_ref|质保书文件引用|URL/Attachment|条件必填|单证包主记录|履约|11-A2|随货单证之一。|重命名/统一|质保书| +|required_document_types|应提供单证类型|MultiEnum|是|单证包主记录|D-10/履约|11-A2|根据D-10.document_requirements生成。|新增/统一|单证清单、客户格式要求| +|document_set_complete|单证包齐套状态|Enum|是|单证包主记录|系统/PW|11-A2|未齐套/已齐套;已齐套至少满足本订单required_document_types。|保留/细化|doc_set_complete| +|consistency_check_status|单证一致性审核状态|Enum|是|单证包主记录|系统/PW|11-A2|未审核/通过/不通过/待补正;核对订单、料号、数量、批次、客户信息。|新增/统一|一致性审核| +|consistency_check_result|单证一致性审核结果|LongText/Structure|审核后必填|单证包主记录|系统/PW|11-A2|记录差异项、责任人和补正动作。|新增/统一|一致性审核记录| +|document_archive_ref|单证包归档引用|URL/Attachment|归档时必填|单证包主记录|系统/履约|11-A2|正式证据包统一归档。|新增/统一|证据包| +|archive_due_date|归档期限|Date|条件必填|单证包主记录|履约Owner|11-A2|依据客户或合规要求。|新增/统一|归档期限| +|document_type|单证类型|Enum|是|单证明细子表|系统|11-A2|发票/箱单/COC/产地证/质保书/其他。|新增/统一|doc_list| +|document_ref|单证文件引用|URL/Attachment|是|单证明细子表|履约|11-A2|单证文件。|重命名/统一|doc_list| +|document_status|单证文件状态|Enum|是|单证明细子表|系统/履约|11-A2|草稿/已核验/已归档/作废。|新增/统一|文档状态| +|document_version|单证版本|Text|是|单证明细子表|系统/履约|11-A2|单证修改必须递增。|新增/统一|版本| + + +## 五、重叠字段与统一命名清单 + +下表列出不同Activity或台账中“含义相同但写法不同”“层级不同却使用同名”“同一字段承载多个语义”的主要问题,以及本次建议的统一口径。 + +|冲突主题|涉及台账/Activity|原有写法|统一字段名|统一口径| +|---|---|---|---|---| +|客户ID|P-01/P-02/P-04/P-06/P-09/S-04/D-01/D-10/L-02;W05/W24/SS03/11-A2|customer_id、客户ID、客户/项目、客户名称|customer_id;展示字段customer_name|ID只存L-02.customer_id;名称通过关联展示,不在业务台账重复维护。| +|项目关联|P-01/P-02/P-04/P-06/P-09/S-04/D-15/D-01;W05/W24/SS03/11-A2|project_id、客户/项目、项目|project_id|项目对象只保留ID关联;项目名称由P-05展示。| +|内部料号|P-01/P-02/P-03/P-06/P-08/P-11/S-04/S-05/D-15/D-01/D-03/D-10;W05/W24/SS03/11-A2|pn、mpn、标准型号PN、Validation_PN、Original_PN、Candidate_PN|internal_pn;原物料用original_internal_pn,候选物料用candidate_internal_pn|全链路只认P-08.internal_pn;不得将mpn、pn、型号混作同一字段。| +|客户料号|P-01/P-08/D-01/D-10;W05/W24/11-A2|customer_mpn、客户料号|customer_pn|客户料号与internal_pn分开;正式映射落D-10料号映射子表。| +|品牌/原厂|P-08/P-10/S-04/S-05/SS03|brand、manufacturer_brand、原厂或品牌|brand_name;制造商另用manufacturer_name|品牌、制造商、供应商不是同一对象;S-04/S-05引用brand_name与supplier_id。| +|数量|P-01/S-04/D-15/D-01/D-03/W24/SS03/11-A2|数量、target_qty、purchase_trigger_qty、order_qty、shipped_qty、actual_qty|target_qty、trigger_qty、order_qty、actual_qty、shipped_qty、allocated_qty|按业务语义分开;数量阶梯统一用quantity_tier子表,不用文本拼接。| +|交期/时间窗|P-01/S-04/D-15/P-02/S-05/W24/SS03|target_delivery_date、requirement_date、quote_return_due_at、quote_due_at、lead_time/LT|target_delivery_date、requirement_date、quote_due_at、lead_time_days|目标日期、需求日期、报价截止时间、供货时长四类概念分开;原始说明写lead_time_note。| +|价格类型|P-01/P-02/P-03/S-04/S-05/W24/SS03|price_types_default、price_types_requested、price_type、报价类型|requested_price_types(需求层);price_type(价格明细层);quote_type(报价主记录层)|需求要什么价格、这一行是什么价格、这份报价属于什么业务类型分别建字段。| +|价格/币种|P-01/P-02/P-03/S-04/S-05/W24/SS03|报价价格/币种、unit_price、benchmark_unit_price、currency|金额字段按对象命名,币种统一currency_code;跨币种另记comparison_currency_code/conversion_rate|金额与币种成对出现;currency不再作为模糊字段名。| +|有效期|P-02/P-03/S-04/S-05/W24/SS03|生效期、失效期、报价有效期、valid_until、effective_from/to|valid_from、valid_to;报价主记录用quote_valid_until|Price List/采购基准的有效区间用valid_from/valid_to;报价对外有效期用quote_valid_until。| +|状态|P-01/P-02/P-03/P-06/P-11/S-04/S-05/D-01/D-03/D-10/D-11/W05/W24/SS03/11-A2/11-A3|状态机、状态、approval_status、publish_status、doc_set_complete|对象业务状态与record_status分离;各对象保留*_status命名|record_status只管记录生命周期;quote_status、approval_status等只管业务流程。| +|版本|P-01/P-02/P-03/P-06/P-08/P-10/P-11/D-10/S-04/W05/W24/SS03/11-A2|版本、需求版本、Release版本、报价版本、Datasheet版本、规则版本|对象字段用requirement_version/quote_version/release_version/document_version/mapping_version;通用用record_version;规则用rule_version|版本必须绑定对象和用途,不再使用裸字段version。| +|证据/附件/链接|P-01/P-02/P-06/P-10/P-11/S-04/D-01/D-11/W05/W24/SS03/11-A2/11-A3|evidence、证据引用、输出文件链接、handover_file、doc_list|evidence_ref;对象文件用*_ref;输出用output_ref;交接单用handover_file_ref|证据引用可多值且必须可定位;业务对象文件链接按对象命名。| +|供应商主体/联系人|S-03/S-04/S-05/SS03|supplier_id、供应商ID、sales_contact、联系人|supplier_id;sales_contact_ref;contact_channel|供应商主体与联系人分离;联系渠道记录在供应商主数据,实际发送渠道记录在S-04.send_channel。| +|询价批次/比价组|S-04/SS03|rfq_batch_id、comparison_group|rfq_batch_id;comparison_group_id|批次是询价过程容器;比价组是同条件可比集合,两者不可互代。| +|MOQ/SPQ|P-02/S-04/S-05/D-03/W24/SS03/11-A2|MOQ-SPQ、MOQ、SPQ|moq_qty、spq_qty|拆分为两个数值字段,适用对象前缀由台账区分。| +|交期LT|S-04/S-05/P-02/SS03/W24|LT、lead_time、交期|lead_time_days;lead_time_note|标准化时长与原始说明分开;统一日历口径由GOV定义。| +|替代关系与批准|P-01/P-02/P-06/P-08/P-11/W05/W24|替代等级、推荐、批准状态、验证结果、P-11-01/P-11-02|recommendation_level、analysis_status、customer_approval_status、validation_status|推荐、分析、验证、客户批准四个阶段分开,P-11作为统一关系主对象。| +|风险等级|P-01/P-02/P-06/P-11/S-05/D-18/W05/W24/SS03|风险等级、供应风险、问题与风险|risk_level(通用决策风险);supply_risk_level(供应能力风险);issue_risk(问题明细)|不同风险对象不共用一个自由文本字段。| +|交接/物流节点|D-01/D-03/D-10/D-11/11-A2/11-A3|milestones、ship_date、logi_pickup、track_note、交接|milestones;交接事实字段;logi_*节点结构;track_note|交接是法律/责任事实;物流节点是后续过程事实,统一回链同一order_id但不混成一个字段。| + +## 六、必须落地的结构化规则 + +|标准项|标准定义|示例| +|---|---|---| +|ID命名|业务对象主键使用<对象>_id;跨台账引用保留同名ID并采用Link。|request_id、supplier_id、order_id| +|料号命名|internal_pn表示内部标准料号;customer_pn表示客户料号;original_internal_pn/candidate_internal_pn只用于替代关系上下文。|P-08.internal_pn、D-10.customer_pn| +|金额与币种|所有金额字段必须与currency_code成对;跨币种比较在决策记录中另记comparison_currency_code与conversion_rate。|unit_price + currency_code| +|数量阶梯|数量阶梯使用结构化子表,至少包含min_qty、max_qty、price或discount;禁止把多个阶梯拼接为自由文本。|P-02 QuoteLine.quantity_tier| +|时间字段|发生时点用*_at(DateTime),业务日期用*_date(Date),持续时长用*_days(Number)。|quote_due_at、target_delivery_date、lead_time_days| +|版本字段|不使用裸version;必须指明对象:requirement_version、quote_version、release_version、document_version、record_version、rule_version。|P-02.quote_version| +|状态字段|record_status管记录生命周期;对象业务流程用*_status;派生可见性用*_flag或*_visible。|record_status、approval_status、track_visible| +|证据字段|evidence_ref表示证据集合;对象文件按对象命名为*_ref;证据必须支持URL/附件/定位信息。|quote_evidence_ref、handover_file_ref、citation_locator| +|节点结构|物流节点统一包含estimated_time、actual_time、source、collection_method、confirmation_status、anomaly_flag、evidence_ref。|D-01.logi_pickup| +|主记录与子表|主记录保存上下文、状态和汇总;逐PN/BOM位/阶梯/参数/单证的重复明细使用子表。|P-02 QuoteLine、P-11差异明细、D-11单证明细| + +## 七、实施优先级与待确认事项 + +**P0:**先落实P-01、P-02、P-11、S-04、D-01的主记录与子表结构,并建立统一ID、料号、版本、状态和证据字段。P-02如果不建立QuoteLine,PN×BOM位×数量阶梯无法稳定支撑报价和版本管理;P-11如果不建立差异明细,差异评估只能保留摘要,无法被AI复核或下游消费;S-04如果不拆RFQ/Quote/Decision三层,询价事实、供应商回复和定点结论会互相覆盖。 + +**P1:**落实条件必填与校验规则,包括代理证、涨价原因、跨币种换算、报价/基准有效期、风险转移时点、交接文件状态、物流节点异常及提醒次数。 + +**P1:**明确以下业务口径后固化到GOV规则:lead_time_days按自然日还是工作日;毛利计算公式和阈值;价格来源优先级;汇率来源与as_of;Incoterms及地点字段;客户批准与FAE分析结论的权限边界;D-10履约参数的客户级/物流公司级配置粒度。 + +**P2:**将S-05基准、D-03库存与D-16采购履约的数据同步关系做成系统接口;将D-01物流节点结构接入客户可查询视图,并将异常节点回链D-18或既有异常归集Activity。 + + +## 八、交付物说明 + +本Markdown用于评审和阅读;配套Excel提供可直接继续维护的“字段总表”“Activity覆盖矩阵”“重叠字段统一”“字段标准”四张工作表,并保留“说明”页作为使用口径。 + + +## 参考附件 + +[1]: /home/ubuntu/upload/SS01-02-03-04-台账体系全景-18Activity关联矩阵(2).html "SS01-02-03-04-台账体系全景:18 Activity关联矩阵" + +[2]: /home/ubuntu/upload/SS02_ACT-WORK-05_差异评估_E04-I01架构版_V1.2(1).html "SS02 ACT-WORK-05 差异评估" + +[3]: /home/ubuntu/upload/SS02_ACT-WORK-24_报价方案制定_E04-I01架构版_V1.2(1).html "SS02 ACT-WORK-24 报价方案制定" + +[4]: /home/ubuntu/upload/SS03询价ActivityDemo(1).html "SS03 询价 Activity Demo" + +[5]: /home/ubuntu/upload/20260826-11-A2架构版(E04-I01v1.2).html "SS04 ACT-11-A2 提货交接与风险转移" + +[6]: /home/ubuntu/upload/20260826-SS04-SOP-11活动描述样板(11-A3).html "SS04 11-A3 物流执行信息跟踪活动描述样板" diff --git a/docs/ss02-act-05-new/SS02_ACT-WORK-05_差异评估_E04-I01架构版_V1.2.html b/docs/ss02-act-05-new/SS02_ACT-WORK-05_差异评估_E04-I01架构版_V1.2.html new file mode 100644 index 0000000..efb8a84 --- /dev/null +++ b/docs/ss02-act-05-new/SS02_ACT-WORK-05_差异评估_E04-I01架构版_V1.2.html @@ -0,0 +1,4 @@ +ACT-WORK-05|差异评估|Activity台账关联与设计

ACT-WORK-05 差异评估

CloudCore 云拓芯科 · AI-Native 业务操作系统 · SS02 需求分析与方案匹配子系统 · Activity 台账关联与字段设计
版本:v1.2 Demo架构版日期:2026-08-25基准:E04-I01 Activity台账关联与设计 Demo核心关联台账:4项

一、Demo目标与范围

Demo目的:按照E04-I01 Demo的方式,先说明Activity业务边界,再展开Activity与台账的读写关系、核心字段和工作链条,为后端开发和业务确认提供同一份结构化依据。
本Activity范围:围绕客户需求版本、原PN和候选PN,形成逐项可复核的差异结论。输出AI分析草稿和FAE审核结论,不自动批准替代关系。
ACT-WORK-05 差异评估 P0 · 19/20
将“候选PN是否可替代”拆解为参数、封装、工艺、生命周期、应用边界和验证条件的逐项比较,并保留证据、风险和下一步动作。
所属:SS02 / BT-02替代选型执行ActivityAI建议 + FAE/RI审核关键结论需人工签发

二、Activity × 台账关联矩阵(ACT-WORK-05抽取)

R读取
W写入
R+W读写
C条件读写/待确认
—不涉及
ActivityP-01-01任务P-06-01验证P-08-01物料P-09-02技术档案P-10原厂资料P-11-01替代选型P-11-02客户批准GOV规则
ACT-WORK-05
差异评估
R+WR/CRRRR+WR/CR
矩阵说明:R/W表示Activity与对象的业务关联,不等于AI可以直接写入。正式结论、客户批准、替代关系和报价发布均需要对应人工责任人或门禁Activity确认。

三、关联台账字段设计(4个核心台账)

字段设计范围:本节只展开本Activity最核心的正式台账,避免把全量字段字典复制进Activity文档;其余辅助台账在第二节和第四节标注读写关系。字段来源为“AI建议”时只形成草稿,正式写入仍由业务Owner确认。
P-01-01项目任务主台账属主:SS02 · FAE/RI | 本Activity:R+W
定位:任务与需求基线——本Activity的上下文锚点
读取任务、需求版本、客户/项目、原PN、候选PN和应用场景;审核后回写风险、验证要求、输出链接和下一步动作。
字段名中文名类型说明维护/写入来源
任务ID任务唯一标识AutoNumber关联本次差异评估运行系统自动
任务描述/需求版本需求基线Text/Link明确参数边界和版本AI读取/FAE确认
原型号/原PN原物料对象SingleLink评估基准物料FAE确认
候选PN候选物料对象SingleLink待评估替代物料AI建议/FAE确认
应用场景应用场景Text解释需求约束和验证条件AI草稿/FAE确认
FAE人工判断/风险等级审核结论Text/Select记录采纳、修正和风险FAE人工
验证/复测/确认要求后续动作TextUNKNOWN或CONDITIONAL的关闭动作FAE确认
标准输出类型/输出文件链接产出回链Select/Url关联差异结果或报告系统/FAE确认
P-08-01PN内部物料库属主:SS02 · 数据Owner | 本Activity:R
定位:原PN与候选PN的标准物料基准
提供规范PN、规格描述、封装/工艺、品牌、生命周期、Datasheet和应用属性,支持参数比对与证据回链。
字段名中文名类型说明维护/写入来源
标准型号 PN标准型号Text参数比较的对象键数据Owner
Specification Description规格描述Text标准规格文本数据Owner/原厂资料
封装/工艺封装与工艺Select硬约束比较项数据Owner
生命周期状态生命周期Select量产/停产/风险等数据Owner
Datasheet链接/版本资料引用Url/Text参数证据与版本回链原厂资料
应用场景适配场景Select/Text判断实际适用性数据Owner/FAE
P-11-01替代物料选型库属主:SS02 · FAE/RI | 本Activity:R+W
定位:替代候选与分析结论的正式关系对象
读取原PN、候选PN、替代类型和历史分析;FAE确认后回写分析报告、评论和替代等级。客户批准另见P-11-02。
字段名中文名类型说明维护/写入来源
替代类型关系类型SelectPin-to-pin/功能替代/参数替代等FAE确认
Original_PN原PNText评估基准PN系统/FAE确认
Candidate_PN候选PNText被评估PN系统/FAE确认
替代分析报告报告链接Url结构化结果或附件回链系统/FAE确认
评论评估备注Text差异摘要、排除理由和限制条件FAE确认
替代等级替代等级Select推荐/条件/不推荐等,枚举待确认FAE确认
P-06-01芯片验证记录库属主:SS02 · FAE/验证Owner | 本Activity:R/C
定位:实测事实——关闭UNKNOWN与CONDITIONAL
读取历史验证结论;资料不足时形成验证建议或关联验证任务,不能由AI虚构测试结果。
字段名中文名类型说明维护/写入来源
Validation_ID验证IDAutoNumber验证记录唯一标识系统自动
Validation_PN验证PNText验证对象FAE确认
测试条件/测试参数测试设计Text复测条件和参数FAE/验证Owner
测试结果验证结果Text实测结果和结论验证Owner
问题与风险问题风险Text待关闭风险AI建议/FAE确认
样品版本/批次样品追溯Text样品和批次条件验证Owner

四、Activity × 台账读写关系与工作链条入口

业务主线
  • 读 P-01-01:任务、需求版本、客户/项目、原PN、候选PN和应用场景。
  • 写 P-01-01:FAE判断、风险、验证要求、输出链接和下一步动作。
物料与替代关系
  • 读 P-08-01 / P-10-01/02/03:主数据、Datasheet和原厂补证。
  • 读写 P-11-01:读取候选关系,FAE确认后沉淀分析结果。
  • 读 P-11-02:仅检查客户批准边界,不自动产生批准。
验证闭环
  • 读 P-06-01:读取已有验证事实。
  • 条件写 P-06-01:生成待验证项或关联任务。
  • 下游:ACT-WORK-08证据归集、ACT-GATE-03评审放行。

五、Activity工作链条

工作链条说明:以下为从触发到完结的执行链条,重点标注负责人、触发方式、动作内容和台账读写。AI负责汇总、比对和初稿;PW负责资料补齐与状态维护;FAE/RI或BDPM负责业务判断、审核和正式签发。
ACT-WORK-05
差异评估
负责人:FAE/RI触发:ACT-WORK-03检索调用或ACT-GATE-02路径判定后门禁:ACT-GATE-03评审放行
进入条件:任务、需求版本、原PN、候选PN和应用场景已绑定,且至少有一份可信参数或Datasheet来源。
退出条件:每个候选PN都有差异状态、证据/缺口、风险和验证建议,并完成FAE/RI审核。
  1. 1
    建立评估上下文PW/AI
    绑定任务、需求版本、客户/项目、原PN、候选PN和BOM位,生成输入快照。
    读 P-01-01、P-09-02、EXT-02。
  2. 2
    读取并标准化资料AI
    读取物料、原厂和验证资料,统一参数名称、单位、版本和证据引用;缺失项标记UNKNOWN。
    读 P-08-01、P-10-01/02/03、P-06-01、GOV规则。
  3. 3
    逐项差异评估AI
    按硬约束与软约束输出MATCH、MISMATCH、UNKNOWN或CONDITIONAL,并说明影响和证据。
    形成差异结果,不改变客户批准关系。
  4. 4
    FAE/RI审核与补证FAE/RI
    确认参数解释、风险等级、证据可信度和验证要求,必要时退回补证。
    条件写 P-06-01;审核后写 P-01-01、P-11-01。
  5. 5
    提交评审放行FAE/RI + Gate
    提交ACT-GATE-03;通过后供证据归集和后续替代/方案流程消费。
    更新P-01-01状态,P-11-02仍由批准流程维护。
主产出:差异评估结果、FAE结论、P-01-01状态更新,以及必要时P-11-01分析报告链接。
阻断/回退:关键参数缺失、单位冲突、证据版本冲突、硬约束失败或客户批准范围不明时,进入补证、验证或人工复核。

六、当前待讨论与确认事项

说明:这些问题不影响Demo版用于业务梳理,但会影响正式数据模型、字段权限、状态机和门禁边界,建议在进入开发排期前确认。
序号待确认问题影响优先级
1差异明细正式归属是什么?当前P-01-01/P-11-01适合保存摘要和报告链接,逐参数明细对象是否正式建模需确认。P0
2P-11-01何时允许写入?需要区分AI草稿、FAE确认和客户批准,避免评估结果被误当成批准关系。P0
3硬约束规则由哪个GOV对象维护?规则版本、适用场景和参数边界不清,AI无法稳定判断。P0
4是否按BOM位独立评估?不同BOM位或客户项目的批准边界可能不同。P1
\ No newline at end of file diff --git a/docs/superpowers/plans/2026-08-29-deterministic-steps-auto-recipe.md b/docs/superpowers/plans/2026-08-29-deterministic-steps-auto-recipe.md new file mode 100644 index 0000000..198e216 --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-deterministic-steps-auto-recipe.md @@ -0,0 +1,3163 @@ +# 确定性步骤(auto 配方)平台机制 实施计划 + +> **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:** 给 `PlanStep` 加一个声明式配方 `auto`,让活动的一步可以由引擎按声明**确定性地**做完(投影台账 / 发卡 / 一次结构化 AI 调用 / 提交放行 / 说一句话),不问模型。 + +**Architecture:** 配方是**纯数据**(装进来仍然只是数据,可静态审、可回放),不执行活动包代码(ADR-004)。求值分三层:表达式(纯函数)→ 数据源展开(纯函数)→ 执行器(拿端口)。引擎在每一拍拍首跑当前这一步的配方;发了卡就收拍,没发卡就重求 `doneWhen` 收步。有 `guide` 的老活动一个字不改。 + +**Tech Stack:** TypeScript(`exactOptionalPropertyTypes`)· zod(契约)· vitest · Hono(路由)· 现有 `@sfeel` 求值器 · 现有 `ModelGateway`(结构化出参) + +**Spec:** `docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md`(本计划只实现该 spec 的 §5;§3/§4/§6/§7 归第二份计划) + +## Global Constraints + +- **五道门禁以退出码验**,不许以 echo 验:`pnpm gates`(= `gen:tiers` → `typecheck` → `lint` → `depcruise` → `api:check` → `test`)。 +- **契约变更四步**:depcruise 反查下游 → 同步对应模块的 `CONTRACT.md` → `pnpm api:check --update && git add api-report` → 破坏性变更走三步弃用。 +- **禁止清单**:不许 `any` / `as unknown as` / `@ts-ignore`;domain 层零 IO;不删测试;不动门禁配置。 +- **mock 只许出现在外界**(邮件通道、外部系统替身);系统内部机制不得 mock —— 单测里用**手写的假端口对象**(普通对象字面量)是允许的,那是依赖注入不是 mock 框架。 +- **模块分层**:`internal/domain` 只准纯函数(零 IO、零端口);`internal/usecase` 可拿端口;`@mastra` / 数据库只准出现在 `internal/adapter` 与 `apps/api/src/app`。 +- **别动稳定版**:不要跑 `./stable.sh update`,不要改库里的数据。稳定版与开发共用 `jcai_wos` 库。 +- 本计划**不改任何现有活动包**;E04 / I01 / W05 的行为必须逐字不变(回归靠 `pnpm test`)。 +- 提交信息用中文,句式照仓里现有的(`feat(loop-engine): …`),并在末尾附上: + +``` +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet +``` + +--- + +## 前置:先开一条 feature 分支 + +当前 HEAD 在 **主支 `mvp-foundation`** 上。这份计划有十三次提交,不许直接堆在主支上。 + +```bash +git switch -c feat/deterministic-steps +``` + +后面每一条「提交」都落在这条分支上;全部做完之后**由主支发起合并**(切回 `mvp-foundation` +再 merge 这条分支),不要反过来把主支合进 feature。 + +--- + +## 文件结构 + +| 文件 | 职责 | +|---|---| +| `packages/contracts/src/auto-recipe.ts`(新) | 配方的 zod schema 与类型 —— 唯一真源 | +| `packages/contracts/src/manifest.ts`(改) | `planStepSchema` 加可选 `auto` | +| `packages/contracts/src/index.ts`(改) | 转出 auto-recipe | +| `apps/api/src/modules/loop-engine/internal/domain/auto-expr.ts`(新) | 表达式求值 + 地址取行(纯函数) | +| `apps/api/src/modules/loop-engine/internal/domain/auto-source.ts`(新) | 数据源展开成行(纯函数,两层封顶) | +| `apps/api/src/modules/loop-engine/internal/domain/live-step.ts`(新) | 「这一单此刻在办哪一步」(从 turn-runner 提纯出来的纯函数) | +| `apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts`(新) | 配方执行器 —— 逐笔执行、刷读数、留痕 | +| `apps/api/src/modules/loop-engine/port.ts`(改) | 新增 `AnalyzeGateway`;`CardGateway.decide` 已够用 | +| `apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts`(改) | 两处插入配方段 + 卡答复即收步 | +| `apps/api/src/modules/loop-engine/CONTRACT.md`(改) | 记不变量 | +| `apps/api/src/modules/human-tool/internal/domain/card-rules.ts`(改) | 审核卡放宽到单选项 | +| `apps/api/src/modules/activity-registry/internal/domain/static-audit.ts`(改) | `autoRecipeFindings` | +| `apps/api/src/modules/interaction/internal/domain/view-render.ts`(改) | `data-options` 下拉选项从台账现读 | +| `apps/api/src/app/wiring/engines.ts`(改) | 装配 `AnalyzeGateway`(走 `ModelGateway`) | +| `apps/api/src/app/routes/desk.ts`(改) | 件上保存后 `wake` | + +--- + +### Task 1: 配方契约 + +**Files:** +- Create: `packages/contracts/src/auto-recipe.ts` +- Create: `packages/contracts/src/__tests__/auto-recipe.test.ts` +- Modify: `packages/contracts/src/manifest.ts`(`planStepSchema` 末尾加 `auto`) +- Modify: `packages/contracts/src/index.ts`(加一行 `export * from './auto-recipe.ts';`) + +**Interfaces:** +- Consumes: 无 +- Produces: `AutoExpr` · `AutoSource` · `AutoAction` · `AutoRecipe` 四个类型;`autoRecipeSchema`;`PlanStep.auto?: AutoRecipe` + +- [ ] **Step 1: 写失败的测试** + +```ts +// packages/contracts/src/__tests__/auto-recipe.test.ts +import { describe, expect, it } from 'vitest'; +import { autoRecipeSchema } from '../auto-recipe.ts'; + +describe('auto 配方契约', () => { + it('收下一份用全了五个动词的配方', () => { + const parsed = autoRecipeSchema.safeParse({ + let: { evalRef: { cat: ['W05-', { fmt: { in: 'case:openedAt' }, as: 'digits' }] } }, + do: [ + { kind: 'project', into: 'sub_eval.part', prune: true, from: { rows: [{ pn: { in: 'originalInternalPn' } }] }, fields: { internal_pn: { col: 'pn' } } }, + { kind: 'analyze', model: 'deepseek-v4-flash', into: 'items', instruction: '逐条判四态', input: { from: { each: 'parts.type_param' }, fields: { name: { col: 'param_name' } } }, output: { type: 'object' } }, + { kind: 'card', as: 'execution', variant: 'data', title: '补数据', basis: [{ k: '原 PN', v: { in: 'originalInternalPn' } }], gateLedgers: ['parts.profile'] }, + { kind: 'submit', rationale: '结论已备好' }, + { kind: 'say', text: '这一步办完了' }, + ], + }); + expect(parsed.success).toBe(true); + }); + + it('不认识的动词当场拒 —— 没实现的形状不许装进来', () => { + const parsed = autoRecipeSchema.safeParse({ do: [{ kind: 'drop', into: 'a.b' }] }); + expect(parsed.success).toBe(false); + }); + + it('嵌套只许两层:第三层当场拒', () => { + const parsed = autoRecipeSchema.safeParse({ + do: [{ kind: 'project', into: 'a.b', from: { each: 'a.b', then: { each: 'c.d', then: { each: 'e.f' } } }, fields: {} }], + }); + expect(parsed.success).toBe(false); + }); + + it('prune 只许挂在 project 上', () => { + const parsed = autoRecipeSchema.safeParse({ do: [{ kind: 'say', text: 'x', prune: true }] }); + expect(parsed.success).toBe(false); + }); +}); +``` + +- [ ] **Step 2: 跑一遍,确认它失败** + +Run: `pnpm exec vitest run packages/contracts/src/__tests__/auto-recipe.test.ts` +Expected: FAIL —— `Failed to resolve import "../auto-recipe.ts"` + +- [ ] **Step 3: 写契约** + +```ts +// packages/contracts/src/auto-recipe.ts +/** + * 确定性步骤的**声明式配方**(ADR-014 / spec 2026-08-29 §5.1)。 + * + * 配方是**数据不是代码**:装进来仍然只是数据,可静态审、可回放、可在治理台上摆出 + * 「这一步系统会做什么」。执行第三方代码则相反 —— ADR-004 的立场是「不执行包代码即知包为何物」。 + * + * **只声明真实现了的那几式。** 表达力有上限是刻意的:上限之外的不要硬塞, + * 退回模型,或者把那一段判断从这一步里切出去。 + */ +import { z } from 'zod'; + +/** 表达式 —— 递归,`z.lazy` 是唯一写法(zod 的自引用) */ +export type AutoExpr = + | string + | number + | boolean + /** 入参 / 基座读数 / `let` 里算好的值 —— 三者同一个命名空间,`let` 优先 */ + | { readonly in: string } + /** 本行的列 */ + | { readonly col: string } + /** 外层那一行的列(两层嵌套时) */ + | { readonly outer: string } + /** 拼接,一律按字符串 */ + | { readonly cat: readonly AutoExpr[] } + /** 只留数字(铸号:`case:openedAt` → `20260829215103`) */ + | { readonly fmt: AutoExpr; readonly as: 'digits' } + /** 查另一张表的一列;查不到给 `else`(缺省空串) */ + | { + readonly lookup: string; + readonly by: Readonly>; + readonly take: string; + readonly else?: AutoExpr; + } + /** S-FEEL 条件(可嵌套)—— 已算出的同行字段进它的作用域 */ + | { readonly when: string; readonly then: AutoExpr; readonly else: AutoExpr }; + +export const autoExprSchema: z.ZodType = z.lazy(() => + z.union([ + z.string(), + z.number(), + z.boolean(), + z.object({ in: z.string().min(1) }).strict(), + z.object({ col: z.string().min(1) }).strict(), + z.object({ outer: z.string().min(1) }).strict(), + z.object({ cat: z.array(autoExprSchema).min(1) }).strict(), + z.object({ fmt: autoExprSchema, as: z.literal('digits') }).strict(), + z + .object({ + lookup: z.string().min(1), + by: z.record(z.string(), autoExprSchema), + take: z.string().min(1), + else: autoExprSchema.optional(), + }) + .strict(), + z.object({ when: z.string().min(1), then: autoExprSchema, else: autoExprSchema }).strict(), + ]), +); + +/** + * 数据源 —— 这一笔要对着几行办。 + * + * 地址只有四种(spec §5.1):`域.对象`(**任务视图 = 正式表 + 本单草稿叠加**,默认该用的那一种)· + * `mine:域.对象`(只有本单草稿写下的行)· `in:入参数组` · `result:xxx`(本拍 analyze 的出参)。 + * **没有「纯正式表」那一种** —— 要区分「本单写的」与「别人放行的」,`mine:` 就够了。 + */ +export type AutoSource = + | { readonly once: true } + | { readonly rows: readonly Readonly>[] } + | { + readonly each: string; + readonly where?: Readonly>; + /** 第二层,**到此为止** —— 三层以上就别 auto 了 */ + readonly then?: { readonly each: string; readonly where?: Readonly> }; + }; + +export const autoSourceSchema: z.ZodType = z.union([ + z.object({ once: z.literal(true) }).strict(), + z.object({ rows: z.array(z.record(z.string(), autoExprSchema)).min(1) }).strict(), + z + .object({ + each: z.string().min(1), + where: z.record(z.string(), autoExprSchema).optional(), + then: z + .object({ each: z.string().min(1), where: z.record(z.string(), autoExprSchema).optional() }) + .strict() + .optional(), + }) + .strict(), +]); + +/** 五个动词 —— `when` 为假就跳过这一笔 */ +export type AutoAction = + | { + readonly kind: 'project'; + readonly when?: string; + /** 台账地址「域.对象」—— 必须在 `declarations.ledger` 里(静态审) */ + readonly into: string; + /** 这一笔投影出来的行**就是这张表在本单草稿里的全部**,多出来的撤掉。只动草稿层 */ + readonly prune?: boolean; + readonly from: AutoSource; + readonly fields: Readonly>; + } + | { + readonly kind: 'analyze'; + readonly when?: string; + /** 活动声明过的模型名 */ + readonly model: string; + /** 出参落进拍内作用域 `result:`,供后面的 project 用 `each`/`lookup` 消费 */ + readonly into: string; + /** 给模型的 system 段 —— 说清判什么、四态各是什么意思 */ + readonly instruction: string; + /** 入参由声明组装:一行一条,`fields` 决定每条摆哪几个键 */ + readonly input: { readonly from: AutoSource; readonly fields: Readonly> }; + /** 出参 JSON Schema —— 顶层必须是 object */ + readonly output: Readonly>; + } + | { + readonly kind: 'card'; + readonly when?: string; + readonly as: 'execution' | 'decide'; + readonly variant?: 'data' | 'review'; + readonly title: AutoExpr; + /** 决策卡问的那句话 */ + readonly ask?: AutoExpr; + /** 作业指导书 —— 一条一句,静态文本 */ + readonly guide?: readonly string[]; + readonly standard?: AutoExpr; + /** 调用必附上下文 —— **基座拒发 basis 为空的工单**,别指望兜底(它摆的是内部家具) */ + readonly basis?: readonly { readonly k: string; readonly v: AutoExpr }[]; + /** 这张卡的提交闸盯哪几张表 */ + readonly gateLedgers?: readonly string[]; + /** 决策卡的选项;`variant:'review'` 时允许只有一个 */ + readonly options?: readonly { readonly value: string; readonly label: string; readonly detail?: string }[]; + } + | { readonly kind: 'submit'; readonly when?: string; readonly rationale: AutoExpr } + | { readonly kind: 'say'; readonly when?: string; readonly text: AutoExpr }; + +export const autoActionSchema: z.ZodType = z.discriminatedUnion('kind', [ + z + .object({ + kind: z.literal('project'), + when: z.string().min(1).optional(), + into: z.string().regex(/^[a-z_]+\.[a-z_]+$/, '台账地址写成「域.对象」'), + prune: z.boolean().optional(), + from: autoSourceSchema, + fields: z.record(z.string(), autoExprSchema), + }) + .strict(), + z + .object({ + kind: z.literal('analyze'), + when: z.string().min(1).optional(), + model: z.string().min(1), + into: z.string().min(1), + instruction: z.string().min(1), + input: z.object({ from: autoSourceSchema, fields: z.record(z.string(), autoExprSchema) }).strict(), + output: z.record(z.string(), z.unknown()), + }) + .strict(), + z + .object({ + kind: z.literal('card'), + when: z.string().min(1).optional(), + as: z.enum(['execution', 'decide']), + variant: z.enum(['data', 'review']).optional(), + title: autoExprSchema, + ask: autoExprSchema.optional(), + guide: z.array(z.string().min(1)).optional(), + standard: autoExprSchema.optional(), + basis: z.array(z.object({ k: z.string().min(1), v: autoExprSchema }).strict()).optional(), + gateLedgers: z.array(z.string().min(1)).optional(), + options: z + .array(z.object({ value: z.string().min(1), label: z.string().min(1), detail: z.string().optional() }).strict()) + .optional(), + }) + .strict(), + z.object({ kind: z.literal('submit'), when: z.string().min(1).optional(), rationale: autoExprSchema }).strict(), + z.object({ kind: z.literal('say'), when: z.string().min(1).optional(), text: autoExprSchema }).strict(), +]); + +export type AutoRecipe = { + /** 配方级局部值:先算一遍,后面所有表达式都能 `{in:'名'}` 引用 —— 整单只有一个案号,靠它 */ + readonly let?: Readonly>; + /** 按序执行 */ + readonly do: readonly AutoAction[]; +}; + +export const autoRecipeSchema: z.ZodType = z + .object({ + let: z.record(z.string(), autoExprSchema).optional(), + do: z.array(autoActionSchema).min(1), + }) + .strict(); +``` + +- [ ] **Step 4: 挂到 `PlanStep` 上** + +在 `packages/contracts/src/manifest.ts` 里,`planStepSchema` 的 `humanWrites` 那一行**之后**、`});` 之前插入: + +```ts + /** + * **这一步系统按声明自己做**(2026-08-29,ADR-014 / spec §5)。 + * + * 引擎在这一步的每一拍拍首跑配方:发了卡就收拍(零 token);没发卡且这一步没有 `guide` + * 就重求 `doneWhen` 收步;有 `guide` 的照常问模型(半 auto)。 + * **老活动一个字不用改** —— 不写就是不写,不是缺件。 + */ + auto: autoRecipeSchema.optional(), +``` + +并在该文件顶部的 import 段加: + +```ts +import { autoRecipeSchema } from './auto-recipe.ts'; +``` + +- [ ] **Step 5: 转出契约** + +在 `packages/contracts/src/index.ts` 里加一行(按文件里既有的字母序位置放): + +```ts +export * from './auto-recipe.ts'; +``` + +- [ ] **Step 6: 跑测试确认通过** + +Run: `pnpm exec vitest run packages/contracts/src/__tests__/auto-recipe.test.ts` +Expected: PASS(4 个用例全绿) + +- [ ] **Step 7: 刷 api-report 并跑全量门禁** + +Run: +```bash +pnpm api:check --update && git add api-report +pnpm gates +``` +Expected: 全绿。`api:check` 若报「契约变了没入库」,说明上一条没跑或没 `git add`。 + +- [ ] **Step 8: 提交** + +```bash +git add packages/contracts api-report +git commit -m "$(cat <<'EOF' +feat(contracts): auto 配方契约 —— 确定的活由引擎按声明做,五个动词四种地址 + +配方是数据不是代码(ADR-004:不执行包代码即知包为何物),装进来可静态审、可回放。 +只声明真实现了的那几式;嵌套两层封顶,没实现的形状 zod 当场拒。 + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet +EOF +)" +``` + +--- + +### Task 2: 表达式求值器(纯函数) + +**Files:** +- Create: `apps/api/src/modules/loop-engine/internal/domain/auto-expr.ts` +- Create: `apps/api/src/modules/loop-engine/__tests__/auto-expr.test.ts` + +**Interfaces:** +- Consumes: Task 1 的 `AutoExpr` +- Produces: + - `type Row = Readonly>` + - `type ExprCtx = { scope: Readonly>; lets: Readonly>; row?: Row; outer?: Row; computed?: Row; condition: (expr: string, scope: Readonly>) => boolean }` + - `function rowsAt(scope: Readonly>, address: string): readonly Row[]` + - `function evalExpr(expr: AutoExpr, ctx: ExprCtx): unknown` + - `function evalText(expr: AutoExpr, ctx: ExprCtx): string` + +- [ ] **Step 1: 写失败的测试** + +```ts +// apps/api/src/modules/loop-engine/__tests__/auto-expr.test.ts +import { describe, expect, it } from 'vitest'; +import { evalExpr, evalText, rowsAt } from '../internal/domain/auto-expr.ts'; + +/** 假的 S-FEEL:只认这份测试用得到的两句,够验证「条件走的是注入进来的求值器」 */ +const condition = (expr: string, scope: Readonly>): boolean => { + if (expr === 'found_ref != ""') return String(scope['found_ref'] ?? '') !== ''; + if (expr === 'n > 0') return Number(scope['n'] ?? 0) > 0; + throw new Error(`没教过这句:${expr}`); +}; +const base = { scope: {}, lets: {}, condition }; + +describe('rowsAt:地址取行', () => { + const scope = { + 'parts.profile': [{ internal_pn: 'A', chip_type_id: 1 }], + 'mine:sub_eval.part': [{ internal_pn: 'B' }], + pns: ['X', 'Y'], + 'result:items': [{ param_name: 'p' }], + }; + it('域.对象 取任务视图整表', () => { + expect(rowsAt(scope, 'parts.profile')).toEqual([{ internal_pn: 'A', chip_type_id: 1 }]); + }); + it('mine: 只取本单草稿', () => { + expect(rowsAt(scope, 'mine:sub_eval.part')).toEqual([{ internal_pn: 'B' }]); + }); + it('in: 取入参数组;字符串元素归一成 { value }', () => { + expect(rowsAt(scope, 'in:pns')).toEqual([{ value: 'X' }, { value: 'Y' }]); + }); + it('result: 取本拍 AI 出参', () => { + expect(rowsAt(scope, 'result:items')).toEqual([{ param_name: 'p' }]); + }); + it('取不到就是空数组,不抛 —— 首拍很多表本来就还没有行', () => { + expect(rowsAt(scope, 'nope.nope')).toEqual([]); + }); +}); + +describe('evalExpr:八种形状', () => { + it('常量原样', () => { + expect(evalExpr('W05', base)).toBe('W05'); + expect(evalExpr(7, base)).toBe(7); + expect(evalExpr(true, base)).toBe(true); + }); + it('{in} 先看 let,再看作用域', () => { + const ctx = { ...base, scope: { pn: 'FROM-SCOPE', other: 'S' }, lets: { pn: 'FROM-LET' } }; + expect(evalExpr({ in: 'pn' }, ctx)).toBe('FROM-LET'); + expect(evalExpr({ in: 'other' }, ctx)).toBe('S'); + }); + it('{col} 取本行,{outer} 取外层那一行', () => { + const ctx = { ...base, row: { a: 1 }, outer: { b: 2 } }; + expect(evalExpr({ col: 'a' }, ctx)).toBe(1); + expect(evalExpr({ outer: 'b' }, ctx)).toBe(2); + }); + it('取不到的列是空串,不是 undefined —— 空串写进草稿层等于「这一列没写」', () => { + expect(evalExpr({ col: 'nope' }, { ...base, row: {} })).toBe(''); + }); + it('{cat} 按字符串拼', () => { + expect(evalExpr({ cat: ['W05-', { in: 'd' }] }, { ...base, lets: { d: 20260829 } })).toBe('W05-20260829'); + }); + it('{fmt as digits} 只留数字', () => { + expect(evalExpr({ fmt: { in: 't' }, as: 'digits' }, { ...base, lets: { t: '2026-08-29T21:51:03Z' } })).toBe('20260829215103'); + }); + it('{lookup} 查另一张表的一列', () => { + const ctx = { ...base, scope: { 'parts.profile': [{ internal_pn: 'A', chip_type_id: 3 }] }, row: { pn: 'A' } }; + expect(evalExpr({ lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'chip_type_id' }, ctx)).toBe(3); + }); + it('{lookup} 查不到给 else,缺省空串', () => { + const ctx = { ...base, scope: { 'parts.profile': [] }, row: { pn: 'A' } }; + expect(evalExpr({ lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'chip_type_id' }, ctx)).toBe(''); + expect(evalExpr({ lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'x', else: 'absent' }, ctx)).toBe('absent'); + }); + it('{lookup} 比值按字符串 —— 台账里数字与文本混着存', () => { + const ctx = { ...base, scope: { 'a.b': [{ k: 1, v: 'hit' }] }, row: { k: '1' } }; + expect(evalExpr({ lookup: 'a.b', by: { k: { col: 'k' } }, take: 'v' }, ctx)).toBe('hit'); + }); + it('{when} 走注入的 S-FEEL,已算出的同行字段进它的作用域', () => { + const ctx = { ...base, computed: { found_ref: 'LM2596S-5.0' } }; + expect(evalExpr({ when: 'found_ref != ""', then: 'found', else: 'absent' }, ctx)).toBe('found'); + expect(evalExpr({ when: 'found_ref != ""', then: 'found', else: 'absent' }, { ...base, computed: { found_ref: '' } })).toBe('absent'); + }); + it('{when} 谓词算不出来时走 else,不把整拍拖垮', () => { + expect(evalExpr({ when: '这句没教过', then: 'a', else: 'b' }, base)).toBe('b'); + }); +}); + +describe('evalText:一律出字符串', () => { + it('数字与布尔照实转', () => { + expect(evalText(3, base)).toBe('3'); + expect(evalText(true, base)).toBe('true'); + }); + it('空与 undefined 都是空串', () => { + expect(evalText({ col: 'nope' }, { ...base, row: {} })).toBe(''); + }); +}); +``` + +- [ ] **Step 2: 跑一遍,确认它失败** + +Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/auto-expr.test.ts` +Expected: FAIL —— `Failed to resolve import "../internal/domain/auto-expr.ts"` + +- [ ] **Step 3: 写实现** + +```ts +// apps/api/src/modules/loop-engine/internal/domain/auto-expr.ts +/** + * 配方表达式求值 —— **纯函数,零 IO**(编码规范:domain 层不碰端口)。 + * + * S-FEEL 不在这里实现:`condition` 由调用方注入(与哨兵、`doneWhen` 同一个求值器)。 + * **不发明第二套求值** 是 ADR-014 的一条硬约束。 + */ +import type { AutoExpr } from '@contracts'; + +export type Row = Readonly>; + +export type ExprCtx = { + /** 任务视图(正式 + 本单草稿叠加)+ 基座读数 + 起单入参,外加本拍的 `result:*` */ + readonly scope: Readonly>; + /** 配方级 `let` 算好的值 —— 与 scope 同一命名空间,它优先 */ + readonly lets: Readonly>; + readonly row?: Row; + readonly outer?: Row; + /** 同一行里**已经算出来**的字段 —— 「先 lookup 出依据,再按依据判 found/absent」靠它 */ + readonly computed?: Row; + readonly condition: (expr: string, scope: Readonly>) => boolean; +}; + +/** + * 地址取行 —— 四种地址(spec §5.1),取不到一律空数组。 + * + * **取不到不抛**:首拍很多表本来就一行都还没有,抛了等于每一单开局就交人。 + */ +export function rowsAt(scope: Readonly>, address: string): readonly Row[] { + const raw = address.startsWith('in:') ? scope[address.slice('in:'.length)] : scope[address]; + if (!Array.isArray(raw)) return []; + return raw.map((item) => + typeof item === 'object' && item !== null ? (item as Row) : ({ value: item } satisfies Row), + ); +} + +/** 一个值参与比较时的归一:台账里数字与文本混着存,一律按字符串比 */ +function asKey(value: unknown): string { + return value === null || value === undefined ? '' : String(value); +} + +export function evalExpr(expr: AutoExpr, ctx: ExprCtx): unknown { + if (typeof expr === 'string' || typeof expr === 'number' || typeof expr === 'boolean') return expr; + + if ('in' in expr) { + const fromLet = ctx.lets[expr.in]; + return fromLet !== undefined ? fromLet : (ctx.scope[expr.in] ?? ''); + } + if ('col' in expr) return ctx.row?.[expr.col] ?? ''; + if ('outer' in expr) return ctx.outer?.[expr.outer] ?? ''; + if ('cat' in expr) return expr.cat.map((part) => evalText(part, ctx)).join(''); + if ('fmt' in expr) return evalText(expr.fmt, ctx).replace(/\D/g, ''); + + if ('lookup' in expr) { + const pairs = Object.entries(expr.by).map(([column, valueExpr]) => [column, asKey(evalExpr(valueExpr, ctx))] as const); + const hit = rowsAt(ctx.scope, expr.lookup).find((row) => pairs.every(([column, want]) => asKey(row[column]) === want)); + if (hit === undefined) return expr.else === undefined ? '' : evalExpr(expr.else, ctx); + return hit[expr.take] ?? (expr.else === undefined ? '' : evalExpr(expr.else, ctx)); + } + + /* + * 条件走注入的 S-FEEL。谓词写坏了**不该拖垮整拍** —— 与 `doneWhen` 同一条处置: + * 当作「没算出来」走 else。真写错了,静态审在装包时就该拦下(Task 9)。 + */ + const scope = { ...ctx.scope, ...ctx.lets, ...(ctx.outer ?? {}), ...(ctx.row ?? {}), ...(ctx.computed ?? {}) }; + let hit = false; + try { + hit = ctx.condition(expr.when, scope); + } catch { + hit = false; + } + return evalExpr(hit ? expr.then : expr.else, ctx); +} + +/** 出字符串的那一路 —— 拼接、标题、留痕都要它 */ +export function evalText(expr: AutoExpr, ctx: ExprCtx): string { + const value = evalExpr(expr, ctx); + return value === null || value === undefined ? '' : String(value); +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/auto-expr.test.ts` +Expected: PASS(18 个用例) + +- [ ] **Step 5: 提交** + +```bash +git add apps/api/src/modules/loop-engine/internal/domain/auto-expr.ts apps/api/src/modules/loop-engine/__tests__/auto-expr.test.ts +git commit -m "$(cat <<'EOF' +feat(loop-engine): 配方表达式求值 —— 八种形状,S-FEEL 仍由外面注入 + +地址四种、取不到一律空数组(首拍很多表本来就没行,抛了等于开局交人); +比值一律按字符串(台账里数字与文本混着存);谓词算不出来走 else,不拖垮整拍。 + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet +EOF +)" +``` + +--- + +### Task 3: 数据源展开(纯函数) + +**Files:** +- Create: `apps/api/src/modules/loop-engine/internal/domain/auto-source.ts` +- Create: `apps/api/src/modules/loop-engine/__tests__/auto-source.test.ts` + +**Interfaces:** +- Consumes: Task 1 的 `AutoSource`;Task 2 的 `ExprCtx` / `Row` / `evalExpr` / `rowsAt` +- Produces: `type ExpandedRow = { readonly row: Row; readonly outer?: Row }`;`function expandSource(source: AutoSource, ctx: ExprCtx): readonly ExpandedRow[]` + +- [ ] **Step 1: 写失败的测试** + +```ts +// apps/api/src/modules/loop-engine/__tests__/auto-source.test.ts +import { describe, expect, it } from 'vitest'; +import { expandSource } from '../internal/domain/auto-source.ts'; + +const condition = (): boolean => false; +const ctxWith = (scope: Record) => ({ scope, lets: {}, condition }); + +describe('expandSource', () => { + it('once:恰一行,行是空的', () => { + expect(expandSource({ once: true }, ctxWith({}))).toEqual([{ row: {} }]); + }); + + it('rows:字面几行,每格现算', () => { + const out = expandSource( + { rows: [{ pn: { in: 'a' }, role: 'original' }, { pn: { in: 'b' }, role: 'candidate' }] }, + { scope: { a: 'LM2596S-5.0', b: 'XL2596S-5.0' }, lets: {}, condition }, + ); + expect(out).toEqual([ + { row: { pn: 'LM2596S-5.0', role: 'original' } }, + { row: { pn: 'XL2596S-5.0', role: 'candidate' } }, + ]); + }); + + it('each:按地址逐行', () => { + const out = expandSource({ each: 'mine:sub_eval.part' }, ctxWith({ 'mine:sub_eval.part': [{ pn: 'A' }, { pn: 'B' }] })); + expect(out.map((e) => e.row)).toEqual([{ pn: 'A' }, { pn: 'B' }]); + }); + + it('each + where:等值过滤,比值按字符串', () => { + const out = expandSource( + { each: 'mine:sub_eval.part', where: { exists_status: 'absent' } }, + ctxWith({ 'mine:sub_eval.part': [{ pn: 'A', exists_status: 'found' }, { pn: 'B', exists_status: 'absent' }] }), + ); + expect(out.map((e) => e.row['pn'])).toEqual(['B']); + }); + + it('两层:里层的 where 认得出外层那一行', () => { + const out = expandSource( + { each: 'mine:sub_eval.part', then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } } }, + ctxWith({ + 'mine:sub_eval.part': [{ pn: 'A', chip_type_id: 1 }, { pn: 'B', chip_type_id: 2 }], + 'parts.type_param': [ + { type_id: 1, param_name: '输入电压范围' }, + { type_id: 1, param_name: '输出电流' }, + { type_id: 2, param_name: '压差' }, + ], + }), + ); + expect(out).toEqual([ + { outer: { pn: 'A', chip_type_id: 1 }, row: { type_id: 1, param_name: '输入电压范围' } }, + { outer: { pn: 'A', chip_type_id: 1 }, row: { type_id: 1, param_name: '输出电流' } }, + { outer: { pn: 'B', chip_type_id: 2 }, row: { type_id: 2, param_name: '压差' } }, + ]); + }); + + it('外层的 chip_type_id 还是空的时候,里层一行都不命中 —— 「保存即铺格」靠的就是这一条', () => { + const out = expandSource( + { each: 'mine:sub_eval.part', then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } } }, + ctxWith({ + 'mine:sub_eval.part': [{ pn: 'A', chip_type_id: '' }], + 'parts.type_param': [{ type_id: 1, param_name: '输入电压范围' }], + }), + ); + expect(out).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: 跑一遍,确认它失败** + +Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/auto-source.test.ts` +Expected: FAIL —— `Failed to resolve import "../internal/domain/auto-source.ts"` + +- [ ] **Step 3: 写实现** + +```ts +// apps/api/src/modules/loop-engine/internal/domain/auto-source.ts +/** + * 配方数据源展开 —— **纯函数,零 IO**。 + * + * 嵌套**两层封顶**:两层(某几颗料 × 该类型的参数字段)是已知业务的上限; + * 超出就别 auto —— 退回模型,或者把那一段切出去。 + */ +import type { AutoSource } from '@contracts'; +import { evalExpr, rowsAt, type ExprCtx, type Row } from './auto-expr.ts'; + +export type ExpandedRow = { readonly row: Row; readonly outer?: Row }; + +/** where 是等值过滤;比值按字符串(台账里数字与文本混着存) */ +function matches(row: Row, where: Readonly> | undefined, ctx: ExprCtx, outer?: Row): boolean { + if (where === undefined) return true; + return Object.entries(where).every(([column, valueExpr]) => { + const want = evalExpr(valueExpr as never, { ...ctx, row, ...(outer === undefined ? {} : { outer }) }); + const got = row[column]; + return (got === null || got === undefined ? '' : String(got)) === (want === null || want === undefined ? '' : String(want)); + }); +} + +export function expandSource(source: AutoSource, ctx: ExprCtx): readonly ExpandedRow[] { + if ('once' in source) return [{ row: {} }]; + + if ('rows' in source) { + return source.rows.map((literal) => { + const row: Record = {}; + for (const [name, expr] of Object.entries(literal)) row[name] = evalExpr(expr, { ...ctx, row }); + return { row }; + }); + } + + const outers = rowsAt(ctx.scope, source.each).filter((row) => matches(row, source.where, ctx)); + if (source.then === undefined) return outers.map((row) => ({ row })); + + const inner = source.then; + const out: ExpandedRow[] = []; + for (const outer of outers) { + for (const row of rowsAt(ctx.scope, inner.each)) { + if (matches(row, inner.where, ctx, outer)) out.push({ row, outer }); + } + } + return out; +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/auto-source.test.ts` +Expected: PASS(6 个用例) + +- [ ] **Step 5: 提交** + +```bash +git add apps/api/src/modules/loop-engine/internal/domain/auto-source.ts apps/api/src/modules/loop-engine/__tests__/auto-source.test.ts +git commit -m "$(cat <<'EOF' +feat(loop-engine): 配方数据源展开 —— once / rows / each,嵌套两层封顶 + +里层的 where 认得出外层那一行:外层字段还是空的时候一行都不命中 —— +「人挑了类型、按了保存,参数格才出来」不是特判,是这一条的自然结果。 + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet +EOF +)" +``` + +--- + +### Task 4: 配方执行器 —— `project` / `prune` / `say` + +**Files:** +- Create: `apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts` +- Create: `apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts` +- Modify: `apps/api/src/modules/loop-engine/port.ts`(新增 `AnalyzeGateway` 类型;实现留到 Task 5) + +**Interfaces:** +- Consumes: Task 2 的 `evalExpr` / `evalText` / `rowsAt`;Task 3 的 `expandSource`;现有 `TaskDataGateway`(`view` / `writable` / `draftWrite` / `draftDrop`)、`CardGateway`、`MemoryRecorder`、`Sfeel` +- Produces: + - `class AutoRecipeError extends Error`(带 `actionIndex`) + - `type AnalyzeGateway = { run(input: { taskId: TaskId; model: string; instruction: string; payload: readonly unknown[]; outputSchema: Readonly>; label: string }): Promise<{ readonly object: Readonly> }> }` + - `type AutoContext = { taskId: TaskId; stepId: string; ownerId: StaffId; humanCallCap: number; quiet: boolean; carded: boolean }` + - `type AutoOutcome = { wrote: number; dropped: number; issuedCardId?: string; submit?: { rationale: string }; failedAt?: { index: number; message: string } }` + - `function createAutoRunner(deps: AutoRunnerDeps): { run(recipe: AutoRecipe, ctx: AutoContext): Promise }` + +- [ ] **Step 1: 先把 `AnalyzeGateway` 加进 port.ts** + +在 `apps/api/src/modules/loop-engine/port.ts` 末尾(`ViewGateway` 之后)加: + +```ts +/** + * **一次结构化 AI 调用** —— 配方的 `analyze` 动词(spec §5.2)。 + * + * 与 `AgentPort` 的分工是死的:`AgentPort` 是「问它下一步干什么」,它能调工具、能写台账; + * 这一件**只是一个纯函数**:系统组装好入参,它出一份结构化判断,系统把结果落表。 + * **不给工具、不给记忆、不多轮。** 出参不合 schema 由实现方就地重问一次,再不成就抛。 + */ +export type AnalyzeGateway = { + run(input: { + readonly taskId: TaskId; + /** 活动声明过的模型名 */ + readonly model: string; + readonly instruction: string; + /** 系统组装好的入参 —— 一行一条 */ + readonly payload: readonly unknown[]; + readonly outputSchema: Readonly>; + /** 归因:`<步名>#<第几笔>` */ + readonly label: string; + }): Promise<{ readonly object: Readonly> }>; +}; +``` + +并把 `AnalyzeGateway` 加进 `TurnRunnerDeps`(`apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts` 第 43 行起的那个类型)最后一行之前: + +```ts + readonly analyze: AnalyzeGateway; +``` + +以及在该文件顶部的 import 里补上 `AnalyzeGateway`(与 `AgentPort` 同一行 import)。 + +> 这一步会让 `apps/api/src/app/wiring/engines.ts` 装配处**类型报错**(少传一个依赖)—— +> 这是对的,Task 5 会补上。为了让本任务能独立跑测,先在 `engines.ts` 里传一个**当场抛错**的占位: +> ```ts +> analyze: { run: () => Promise.reject(new Error('analyze 尚未装配(Task 5)')) }, +> ``` + +- [ ] **Step 2: 写失败的测试** + +```ts +// apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts +import { describe, expect, it } from 'vitest'; +import type { AutoRecipe } from '@contracts'; +import { taskIdSchema, staffIdSchema } from '@contracts'; +import { createAutoRunner } from '../internal/usecase/auto-runner.ts'; + +const TASK = taskIdSchema.parse('11111111-1111-4111-8111-111111111111'); +const OWNER = staffIdSchema.parse('22222222-2222-4222-8222-222222222222'); + +/** 假端口:一个内存草稿层 + 一本台账声明。**不是 mock 框架,是手写的依赖注入替身。** */ +function harness(seed: Record = {}) { + const drafts = new Map; patch: Record }>(); + const notes: string[] = []; + const view = (): Record => { + const mine: Record = {}; + for (const d of drafts.values()) (mine[`mine:${d.domain}.${d.object}`] ??= []).push({ ...d.key, ...d.patch }); + return { ...seed, ...mine }; + }; + const deps = { + data: { + view: () => Promise.resolve(view()), + writable: () => + Promise.resolve([ + { domain: 'sub_eval', object: 'part', label: '本单两颗料', keys: ['eval_ref', 'internal_pn'], fields: [] }, + { domain: 'parts', object: 'parameter', label: '参数', keys: ['internal_pn', 'param_name'], fields: [] }, + ]), + draftWrite: (i: { domain: string; object: string; key: Record; patch: Record }) => { + const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`; + const before = JSON.stringify(drafts.get(id)?.patch ?? null); + drafts.set(id, { domain: i.domain, object: i.object, key: i.key, patch: i.patch }); + return Promise.resolve({ changed: before !== JSON.stringify(i.patch) }); + }, + draftDrop: (i: { domain: string; object: string; key: Record }) => { + const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`; + return Promise.resolve({ dropped: drafts.delete(id) }); + }, + }, + cards: { + issue: () => Promise.resolve({ id: 'card-1' }), + decide: () => Promise.resolve({ id: 'card-2' }), + entry: () => Promise.resolve({ id: 'card-3' }), + ask: () => Promise.resolve({ id: 'card-4' }), + unanswered: () => Promise.resolve([]), + }, + memory: { append: (m: { title: string }) => { notes.push(m.title); return Promise.resolve(); } }, + analyze: { run: () => Promise.reject(new Error('本用例不该调 analyze')) }, + sfeel: { + condition: (expr: string, scope: Readonly>) => { + if (expr === 'never') return false; + if (expr === 'always') return true; + if (expr === 'found_ref != ""') return String(scope['found_ref'] ?? '') !== ''; + throw new Error(`没教过这句:${expr}`); + }, + expression: () => undefined, + unaryTest: () => false, + }, + }; + const ctx = { taskId: TASK, stepId: 'S1', ownerId: OWNER, humanCallCap: 8, quiet: false, carded: false }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 假端口只实现用得到的那几个方法 + return { runner: createAutoRunner(deps as never), drafts, notes, ctx }; +} + +describe('配方执行器', () => { + it('project once:写一行,主键与其余列分开', async () => { + const { runner, drafts, ctx } = harness({ pn: 'LM2596S-5.0' }); + const recipe: AutoRecipe = { + let: { evalRef: 'W05-1' }, + do: [ + { + kind: 'project', + into: 'sub_eval.part', + from: { once: true }, + fields: { eval_ref: { in: 'evalRef' }, internal_pn: { in: 'pn' }, role: 'original' }, + }, + ], + }; + const out = await runner.run(recipe, ctx); + expect(out.wrote).toBe(1); + expect([...drafts.values()][0]).toEqual({ + domain: 'sub_eval', + object: 'part', + key: { eval_ref: 'W05-1', internal_pn: 'LM2596S-5.0' }, + patch: { role: 'original' }, + }); + }); + + it('主键算不出来的那一行**先不写** —— 空主键写下去就是一行永远对不上的垃圾', async () => { + const { runner, drafts, ctx } = harness({}); + const out = await runner.run( + { do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'W05-1', internal_pn: { in: 'nope' }, role: 'x' } }] }, + ctx, + ); + expect(out.wrote).toBe(0); + expect(drafts.size).toBe(0); + }); + + it('when 为假就跳过这一笔', async () => { + const { runner, drafts, ctx } = harness({}); + await runner.run({ do: [{ kind: 'project', when: 'never', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'a', internal_pn: 'b' } }] }, ctx); + expect(drafts.size).toBe(0); + }); + + it('**每一笔之前刷读数**:第二笔 each 得看得见第一笔刚写下的行', async () => { + const { runner, drafts, ctx } = harness({}); + await runner.run( + { + do: [ + { kind: 'project', into: 'sub_eval.part', from: { rows: [{ pn: 'A' }, { pn: 'B' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }, + { kind: 'project', into: 'parts.parameter', from: { each: 'mine:sub_eval.part' }, fields: { internal_pn: { col: 'internal_pn' }, param_name: '输入电压范围', param_value: '' } }, + ], + }, + ctx, + ); + const params = [...drafts.values()].filter((d) => d.object === 'parameter'); + expect(params.map((p) => p.key['internal_pn']).sort()).toEqual(['A', 'B']); + }); + + it('值没变的写是空转:**不留痕、不计数**(配方不是模型,原地打转不必被看见)', async () => { + const { runner, ctx } = harness({}); + const recipe: AutoRecipe = { do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'a', internal_pn: 'b', role: 'original' } }] }; + await runner.run(recipe, ctx); + const second = await runner.run(recipe, ctx); + expect(second.wrote).toBe(0); + }); + + it('prune:这一笔没投影出来的行,从草稿层撤掉', async () => { + const { runner, drafts, ctx } = harness({}); + await runner.run({ do: [{ kind: 'project', into: 'sub_eval.part', from: { rows: [{ pn: 'A' }, { pn: 'B' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }] }, ctx); + expect(drafts.size).toBe(2); + const out = await runner.run( + { do: [{ kind: 'project', into: 'sub_eval.part', prune: true, from: { rows: [{ pn: 'A' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }] }, + ctx, + ); + expect(out.dropped).toBe(1); + expect([...drafts.values()].map((d) => d.key['internal_pn'])).toEqual(['A']); + }); + + it('say:进工作日志,归系统', async () => { + const { runner, notes, ctx } = harness({ n: 2 }); + await runner.run({ do: [{ kind: 'say', text: { cat: ['已核对 ', { in: 'n' }, ' 颗料'] } }] }, ctx); + expect(notes).toContain('已核对 2 颗料'); + }); + + it('地址不在台账声明里 → 当场交人,不重试', async () => { + const { runner, ctx } = harness({}); + const out = await runner.run({ do: [{ kind: 'project', into: 'nope.nope', from: { once: true }, fields: { a: 'b' } }] }, ctx); + expect(out.failedAt?.index).toBe(0); + expect(out.failedAt?.message).toContain('nope.nope'); + }); +}); +``` + +- [ ] **Step 3: 跑一遍,确认它失败** + +Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts` +Expected: FAIL —— `Failed to resolve import "../internal/usecase/auto-runner.ts"` + +- [ ] **Step 4: 写执行器(本任务只实现 project / prune / say;analyze / card / submit 留占位抛错)** + +```ts +// apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts +/** + * 配方执行器 —— 逐笔执行 `PlanStep.auto`(spec §5.1)。 + * + * 三条硬规矩: + * ① **每一笔之前刷读数**:后一笔常常要数前一笔刚写下的行。不刷新就恒为 0 —— + * 推荐等级会永远算成「全 MATCH」,而屏上明明摆着两条不符。 + * ② **幂等**:`draftWrite` 同 PK 覆盖,值没变**静默跳过**(不留痕、不计数)—— + * 「写了等于没写」那条判据是拦模型空转的,配方不是模型; + * 发卡先看 `carded`(worldTouched),发过就不再发。 + * ③ **出错 = 交人,不重试、不退回模型**:配方抛错是活动包的 bug。 + * 退回模型就是让它去凑,那正是这套机制要消灭的东西。 + */ +import type { AutoAction, AutoRecipe, StaffId, TaskId } from '@contracts'; +import { evalExpr, evalText, rowsAt, type ExprCtx, type Row } from '../domain/auto-expr.ts'; +import { expandSource } from '../domain/auto-source.ts'; +import type { AnalyzeGateway, CardGateway, MemoryRecorder, Sfeel, TaskDataGateway } from '../../port.ts'; + +export class AutoRecipeError extends Error { + constructor( + readonly actionIndex: number, + message: string, + ) { + super(message); + this.name = 'AutoRecipeError'; + } +} + +export type AutoRunnerDeps = { + readonly data: TaskDataGateway; + readonly cards: CardGateway; + readonly memory: MemoryRecorder; + readonly analyze: AnalyzeGateway; + readonly sfeel: Sfeel; +}; + +export type AutoContext = { + readonly taskId: TaskId; + readonly stepId: string; + readonly ownerId: StaffId; + readonly humanCallCap: number; + /** 有未答的卡在场:**只跑 project / say** —— 只算、只写,不发新卡、不 submit、不 analyze */ + readonly quiet: boolean; + /** 这一步已经发过卡(worldTouched) */ + readonly carded: boolean; +}; + +export type AutoOutcome = { + readonly wrote: number; + readonly dropped: number; + readonly issuedCardId?: string; + readonly submit?: { readonly rationale: string }; + readonly failedAt?: { readonly index: number; readonly message: string }; +}; + +const keyText = (key: Readonly>): string => Object.values(key).join('|'); + +export function createAutoRunner(deps: AutoRunnerDeps): { + run(recipe: AutoRecipe, ctx: AutoContext): Promise; +} { + return { + async run(recipe, ctx) { + const writable = await deps.data.writable(ctx.taskId); + const results: Record = {}; + let scope: Record = { ...(await deps.data.view(ctx.taskId)), ...results }; + let dirty = false; + let wrote = 0; + let dropped = 0; + let issuedCardId: string | undefined; + let submit: { rationale: string } | undefined; + + const condition = deps.sfeel.condition; + const lets: Record = {}; + for (const [name, expr] of Object.entries(recipe.let ?? {})) { + lets[name] = evalExpr(expr, { scope, lets, condition }); + } + + async function runAction( + action: AutoAction, + index: number, + base: ExprCtx, + context: AutoContext, + ): Promise<{ wrote: number; dropped: number; cardId?: string; submit?: { rationale: string }; stop?: boolean }> { + if (action.kind === 'say') { + await deps.memory.append({ + taskId: context.taskId, + kind: 'result', + title: evalText(action.text, base), + detail: { auto: `${context.stepId}#${index + 1}` }, + }); + return { wrote: 0, dropped: 0 }; + } + + if (action.kind === 'project') { + const [domain, object] = action.into.split('.'); + const decl = writable.find((w) => w.domain === domain && w.object === object); + if (decl === undefined) { + throw new AutoRecipeError(index, `台账地址不在本活动的声明里:${action.into}(写的授权面是台账声明)`); + } + const before = rowsAt(base.scope, `mine:${action.into}`); + const kept = new Set(); + let localWrote = 0; + for (const { row, outer } of expandSource(action.from, base)) { + const computed: Record = {}; + for (const [name, expr] of Object.entries(action.fields)) { + computed[name] = evalExpr(expr, { ...base, row, ...(outer === undefined ? {} : { outer }), computed }); + } + const key: Record = {}; + for (const column of decl.keys) key[column] = String(computed[column] ?? ''); + /* 主键算不出来就先不写 —— 空主键写下去是一行永远对不上的垃圾 */ + if (decl.keys.some((column) => key[column] === '')) continue; + const patch: Record = {}; + for (const [name, value] of Object.entries(computed)) if (!decl.keys.includes(name)) patch[name] = value; + const { changed } = await deps.data.draftWrite({ taskId: context.taskId, domain: decl.domain, object: decl.object, key, patch }); + if (changed) localWrote += 1; + kept.add(keyText(key)); + } + let localDropped = 0; + if (action.prune === true) { + for (const stale of before) { + const key: Record = {}; + for (const column of decl.keys) key[column] = String(stale[column] ?? ''); + if (kept.has(keyText(key))) continue; + const { dropped: gone } = await deps.data.draftDrop({ taskId: context.taskId, domain: decl.domain, object: decl.object, key }); + if (gone) localDropped += 1; + } + } + if (localWrote > 0 || localDropped > 0) { + await deps.memory.append({ + taskId: context.taskId, + kind: 'result', + title: `${decl.label}:写下 ${localWrote} 行${localDropped === 0 ? '' : `,撤掉 ${localDropped} 行`}`, + detail: { auto: `${context.stepId}#${index + 1}`, internal: true, ledger: action.into }, + }); + } + return { wrote: localWrote, dropped: localDropped }; + } + + throw new AutoRecipeError(index, `动词尚未实现:${action.kind}`); + } + + for (const [index, action] of recipe.do.entries()) { + try { + if (dirty) { + scope = { ...(await deps.data.view(ctx.taskId)), ...results }; + dirty = false; + } + const base: ExprCtx = { scope, lets, condition }; + if (action.when !== undefined && !condition(action.when, { ...scope, ...lets })) continue; + /* 有卡在等人交回:只算、只写 —— 不发新卡、不 submit、不 analyze */ + if (ctx.quiet && action.kind !== 'project' && action.kind !== 'say') continue; + + const done = await runAction(action, index, base, ctx); + wrote += done.wrote; + dropped += done.dropped; + if (done.wrote > 0 || done.dropped > 0) dirty = true; + if (done.cardId !== undefined) issuedCardId = done.cardId; + if (done.submit !== undefined) submit = done.submit; + if (done.stop) break; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await deps.memory.append({ + taskId: ctx.taskId, + kind: 'result', + title: `配方第 ${index + 1} 笔办不下去:${message} —— 交给你处理`, + detail: { auto: `${ctx.stepId}#${index + 1}`, internal: false }, + }); + return { wrote, dropped, ...(issuedCardId === undefined ? {} : { issuedCardId }), failedAt: { index, message } }; + } + } + return { + wrote, + dropped, + ...(issuedCardId === undefined ? {} : { issuedCardId }), + ...(submit === undefined ? {} : { submit }), + }; + }, + }; +} +``` + +- [ ] **Step 5: 跑测试确认通过** + +Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts` +Expected: PASS(8 个用例) + +- [ ] **Step 6: 跑全量,确认没碰坏别人** + +Run: `pnpm exec vitest run` +Expected: 全绿(现有 1600+ 用例) + +- [ ] **Step 7: 提交** + +```bash +git add apps/api/src/modules/loop-engine +git commit -m "$(cat <<'EOF' +feat(loop-engine): 配方执行器 —— project / prune / say,每一笔之前刷读数 + +三条硬规矩:每一笔之前刷读数(后一笔要数前一笔刚写下的行,不刷新就恒为 0); +值没变的写静默跳过(配方不是模型,原地打转不必被看见); +出错交人不重试不退回模型(配方抛错是包的 bug,退回模型就是让它去凑)。 +主键算不出来的那一行先不写 —— 空主键写下去是一行永远对不上的垃圾。 + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet +EOF +)" +``` + +--- + +### Task 5: `analyze` 动词 —— 一次结构化 AI 调用 + +**Files:** +- Modify: `apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts`(补 `analyze` 分支) +- Modify: `apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts`(补 3 个用例) +- Create: `apps/api/src/app/wiring/analyze-gateway.ts` +- Create: `apps/api/src/app/__tests__/analyze-gateway.test.ts` +- Modify: `apps/api/src/app/wiring/engines.ts`(把 Task 4 的占位换成真实现) + +**Interfaces:** +- Consumes: Task 4 的 `AnalyzeGateway` 类型;现有 `ModelGateway.generate(call: ModelCall): Promise`(`ModelCall` 有 `model` / `system` / `prompt` / `temperature` / `reasoning` / `outputSchema` / `taskId` / `label`;`ModelResult.object` 是 schema 给定时的解析结果) +- Produces: `function createAnalyzeGateway(llm: ModelGateway): AnalyzeGateway` + +- [ ] **Step 1: 先把 `harness()` 改成可以换端口** + +`analyze` 的用例要能换掉那个端口,所以先改 Task 4 写的 `harness()`:签名加第二个参数, +`deps.analyze` 那一行改成读它。 + +把 `auto-runner.test.ts` 里这一行: + +```ts +function harness(seed: Record = {}) { +``` + +改成: + +```ts +type AnalyzeStub = { run: (i: { payload: readonly unknown[]; model: string; instruction: string }) => Promise<{ object: Record }> }; + +function harness(seed: Record = {}, override: { analyze?: AnalyzeStub } = {}) { +``` + +并把 `const deps = { ... }` 里的这一行: + +```ts + analyze: { run: () => Promise.reject(new Error('本用例不该调 analyze')) }, +``` + +改成: + +```ts + analyze: override.analyze ?? { run: () => Promise.reject(new Error('本用例不该调 analyze')) }, +``` + +- [ ] **Step 2: 写 analyze 的三个失败用例** + +在 `auto-runner.test.ts` 末尾追加: + +```ts +describe('analyze 动词', () => { + it('入参由声明组装,出参落进 result: 供后一笔 each 消费', async () => { + let seen: readonly unknown[] = []; + const { runner, drafts, ctx } = harness( + { + 'parts.type_param': [ + { type_id: 1, param_name: '输入电压范围', constraint_type: 'hard' }, + { type_id: 1, param_name: '输出电流', constraint_type: 'soft' }, + ], + typeId: 1, + }, + { + analyze: { + run: (i) => { + seen = i.payload; + return Promise.resolve({ + object: { + items: [ + { param_name: '输入电压范围', difference_status: 'match' }, + { param_name: '输出电流', difference_status: 'unknown' }, + ], + }, + }); + }, + }, + }, + ); + await runner.run( + { + do: [ + { + kind: 'analyze', + model: 'deepseek-v4-flash', + into: 'items', + instruction: '逐条判四态', + input: { + from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } }, + fields: { param_name: { col: 'param_name' }, constraint_type: { col: 'constraint_type' } }, + }, + output: { type: 'object' }, + }, + { + kind: 'project', + into: 'parts.parameter', + from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } }, + fields: { + internal_pn: 'XL2596S-5.0', + param_name: { col: 'param_name' }, + /* **行由字典决定,AI 出参只是查表** —— 它漏一条,那一格就是空 */ + param_value: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'difference_status' }, + }, + }, + ], + }, + ctx, + ); + expect(seen).toEqual([ + { param_name: '输入电压范围', constraint_type: 'hard' }, + { param_name: '输出电流', constraint_type: 'soft' }, + ]); + const values = [...drafts.values()].filter((d) => d.object === 'parameter').map((d) => d.patch['param_value']); + expect(values.sort()).toEqual(['match', 'unknown']); + }); + + it('AI 漏了一条:那一行的格子是空的 —— 行数不由它决定', async () => { + const { runner, drafts, ctx } = harness( + { 'parts.type_param': [{ type_id: 1, param_name: 'A' }, { type_id: 1, param_name: 'B' }], typeId: 1 }, + { analyze: { run: () => Promise.resolve({ object: { items: [{ param_name: 'A', difference_status: 'match' }] } }) } }, + ); + await runner.run( + { + do: [ + { kind: 'analyze', model: 'm', into: 'items', instruction: 'x', input: { from: { each: 'parts.type_param' }, fields: { param_name: { col: 'param_name' } } }, output: { type: 'object' } }, + { kind: 'project', into: 'parts.parameter', from: { each: 'parts.type_param' }, fields: { internal_pn: 'P', param_name: { col: 'param_name' }, param_value: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'difference_status' } } }, + ], + }, + ctx, + ); + const rows = [...drafts.values()].filter((d) => d.object === 'parameter'); + expect(rows).toHaveLength(2); + expect(rows.find((r) => r.key['param_name'] === 'B')?.patch['param_value']).toBe(''); + }); + + it('有卡在等人交回时不调 AI —— 只算、只写', async () => { + let called = 0; + const { runner, ctx } = harness( + { 'parts.type_param': [{ type_id: 1, param_name: 'A' }] }, + { analyze: { run: () => { called += 1; return Promise.resolve({ object: { items: [] } }); } } }, + ); + await runner.run( + { do: [{ kind: 'analyze', model: 'm', into: 'items', instruction: 'x', input: { from: { each: 'parts.type_param' }, fields: { p: { col: 'param_name' } } }, output: { type: 'object' } }] }, + { ...ctx, quiet: true }, + ); + expect(called).toBe(0); + }); +}); +``` + +- [ ] **Step 3: 跑一遍,确认它失败** + +Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts -t analyze` +Expected: FAIL —— `动词尚未实现:analyze` + +- [ ] **Step 4: 在执行器里实现 analyze 分支** + +在 `auto-runner.ts` 的 `runAction` 里,`if (action.kind === 'project') { … }` 之后、最后那句 `throw` 之前插入: + +```ts + if (action.kind === 'analyze') { + const payload = expandSource(action.input.from, base).map(({ row, outer }) => { + const item: Record = {}; + for (const [name, expr] of Object.entries(action.input.fields)) { + item[name] = evalExpr(expr, { ...base, row, ...(outer === undefined ? {} : { outer }), computed: item }); + } + return item; + }); + const { object } = await deps.analyze.run({ + taskId: context.taskId, + model: action.model, + instruction: action.instruction, + payload, + outputSchema: action.output, + label: `${context.stepId}#${index + 1}`, + }); + /* `into` 既是出参里那个数组字段的名字,也是拍内作用域的键 —— 一处声明,两处认 */ + const rows = object[action.into]; + results[`result:${action.into}`] = Array.isArray(rows) ? rows : []; + await deps.memory.append({ + taskId: context.taskId, + kind: 'result', + title: `已把 ${payload.length} 条参数交给模型逐条判定,拿回 ${results[`result:${action.into}`]?.length ?? 0} 条结论`, + detail: { auto: `${context.stepId}#${index + 1}`, internal: true, model: action.model }, + }); + /* 出参只活在拍内作用域里,不进草稿层 —— 下一笔要能看见它,所以这里也算「脏」 */ + scope = { ...scope, ...results }; + return { wrote: 0, dropped: 0 }; + } +``` + +> 注意:`results` 与 `scope` 是闭包里的变量;`analyze` 之后**不要**把 `dirty` 置真 +> (它没写草稿层,刷新反而会把 `result:*` 冲掉;上面那句 `scope = { ...scope, ...results }` 已经够了)。 + +- [ ] **Step 5: 跑测试确认通过** + +Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts` +Expected: PASS(11 个用例) + +- [ ] **Step 6: 写网关的失败测试** + +```ts +// apps/api/src/app/__tests__/analyze-gateway.test.ts +import { describe, expect, it } from 'vitest'; +import { taskIdSchema } from '@contracts'; +import { createAnalyzeGateway } from '../wiring/analyze-gateway.ts'; + +const TASK = taskIdSchema.parse('11111111-1111-4111-8111-111111111111'); +const SCHEMA = { type: 'object', required: ['items'], properties: { items: { type: 'array' } } }; +const call = { taskId: TASK, model: 'deepseek-v4-flash', instruction: '逐条判四态', payload: [{ p: 'A' }], outputSchema: SCHEMA, label: 'S2#1' }; + +describe('analyze 网关', () => { + it('出参是对象就直接回,入参 JSON 进 prompt、instruction 进 system', async () => { + const seen: Array> = []; + const gateway = createAnalyzeGateway({ + generate: (c: Record) => { + seen.push(c); + return Promise.resolve({ text: '', object: { items: [{ p: 'A' }] }, usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' }); + }, + } as never); + const out = await gateway.run(call); + expect(out.object).toEqual({ items: [{ p: 'A' }] }); + expect(seen[0]?.['system']).toBe('逐条判四态'); + expect(String(seen[0]?.['prompt'])).toContain('"p": "A"'); + expect(seen[0]?.['reasoning']).toBe('off'); + }); + + it('出参不成形 → **就地重问一次**,第二次成了就用它', async () => { + let n = 0; + const gateway = createAnalyzeGateway({ + generate: () => { + n += 1; + return Promise.resolve( + n === 1 + ? { text: 'not json', usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' } + : { text: '', object: { items: [] }, usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' }, + ); + }, + } as never); + const out = await gateway.run(call); + expect(n).toBe(2); + expect(out.object).toEqual({ items: [] }); + }); + + it('重问了还是不成形 → 抛,交人;**不重问第三次**', async () => { + let n = 0; + const gateway = createAnalyzeGateway({ + generate: () => { + n += 1; + return Promise.resolve({ text: 'nope', usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' }); + }, + } as never); + await expect(gateway.run(call)).rejects.toThrow(/出参不成形/); + expect(n).toBe(2); + }); +}); +``` + +- [ ] **Step 7: 跑一遍,确认它失败** + +Run: `pnpm exec vitest run apps/api/src/app/__tests__/analyze-gateway.test.ts` +Expected: FAIL —— `Failed to resolve import "../wiring/analyze-gateway.ts"` + +- [ ] **Step 8: 写网关** + +```ts +// apps/api/src/app/wiring/analyze-gateway.ts +/** + * `analyze` 动词的实现 —— **一次结构化调用,没有第二次自由**(spec §5.2)。 + * + * 与开环 Agent 的分工是死的:那一件能调工具、能写台账;这一件只是个纯函数。 + * 出参不合 schema 就地重问一次(平台在 agent-runner 上早有这条路),再不成就抛 —— + * 抛出去由配方执行器留痕交人,**不重试五遍、不退回模型自由发挥**。 + */ +import type { ModelGateway } from '@modules/llm'; +import type { AnalyzeGateway } from '@modules/loop-engine'; + +const RETRY_HINT = + '上一次的回答没有按要求给出 JSON。只输出一个 JSON 对象,不要任何解释、不要 markdown 代码围栏。'; + +export function createAnalyzeGateway(llm: Pick): AnalyzeGateway { + return { + async run(input) { + const prompt = JSON.stringify(input.payload, null, 2); + const call = { + model: input.model, + system: input.instruction, + prompt, + /* 判断归判定表与规则,模型只做归类 —— 隐藏推理按秒计,这一档一律关 */ + reasoning: 'off' as const, + temperature: 0.2, + outputSchema: input.outputSchema as Record, + taskId: input.taskId, + label: input.label, + }; + const first = await llm.generate(call); + if (typeof first.object === 'object' && first.object !== null) { + return { object: first.object as Record }; + } + const second = await llm.generate({ ...call, system: `${input.instruction}\n\n${RETRY_HINT}` }); + if (typeof second.object === 'object' && second.object !== null) { + return { object: second.object as Record }; + } + throw new Error(`${input.label}:模型出参不成形,重问一次仍不成 —— 交给你处理`); + }, + }; +} +``` + +- [ ] **Step 9: 换掉 engines.ts 里的占位** + +在 `apps/api/src/app/wiring/engines.ts` 里把 Task 4 加的那行占位 + +```ts +analyze: { run: () => Promise.reject(new Error('analyze 尚未装配(Task 5)')) }, +``` + +换成: + +```ts +analyze: createAnalyzeGateway(deps.llm), +``` + +并在该文件顶部 import 段加: + +```ts +import { createAnalyzeGateway } from './analyze-gateway.ts'; +``` + +> `deps.llm` 就是装配层已有的 `ModelGateway`。若该变量在你手上叫别的名字, +> 用 `grep -n "ModelGateway" apps/api/src/app/wiring/engines.ts` 找它。 + +- [ ] **Step 10: 跑测试与全量门禁** + +Run: +```bash +pnpm exec vitest run apps/api/src/app/__tests__/analyze-gateway.test.ts +pnpm gates +``` +Expected: 前者 3 个用例 PASS;`pnpm gates` 全绿。 + +- [ ] **Step 11: 提交** + +```bash +git add apps/api/src/modules/loop-engine apps/api/src/app/wiring/analyze-gateway.ts apps/api/src/app/wiring/engines.ts apps/api/src/app/__tests__/analyze-gateway.test.ts +git commit -m "$(cat <<'EOF' +feat(loop-engine): analyze 动词 —— 系统组装入参、声明出参 schema、系统把结果落表 + +AI 在这里只是一个纯函数:不给工具、不给记忆、不多轮。 +行数不由它决定 —— 出参是拿主键去 lookup 的一张查表,它漏一条那一格就是空, +required 立刻把它变成 _missing > 0,提交闸与放行门当场拦住。 +出参不成形就地重问一次,再不成抛出去交人。 + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet +EOF +)" +``` + +--- + +### Task 6: `card` 与 `submit` 两个动词 + +**Files:** +- Modify: `apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts`(补两个分支) +- Modify: `apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts`(补 5 个用例) +- Modify: `apps/api/src/modules/human-tool/internal/domain/card-rules.ts:92`(审核卡放宽到单选项) +- Modify: `apps/api/src/modules/human-tool/__tests__/`(该目录下测 `card-rules` 的那个文件,补 2 个用例) + +**Interfaces:** +- Consumes: Task 4 的 `runAction` / `AutoOutcome`;现有 `CardGateway.issue` / `CardGateway.decide` +- Produces: `AutoOutcome.issuedCardId` 与 `AutoOutcome.submit` 两个字段真的会被填上(Task 7 的 turn-runner 读它们) + +- [ ] **Step 1: 写失败的测试** + +在 `auto-runner.test.ts` 末尾追加: + +```ts +describe('card 与 submit', () => { + it('execution:发一张数据工单,itemId 挂在这一步上,basis 现算', async () => { + const { runner, cards, ctx } = harness({ pn: 'XL2596S-5.0' }); + const out = await runner.run( + { + do: [ + { + kind: 'card', + as: 'execution', + variant: 'data', + title: { cat: ['数据工单 · 补齐 ', { in: 'pn' }] }, + guide: ['选一个芯片类型', '把参数值填齐'], + standard: '必填格全部填上,红带消失', + basis: [{ k: '候选料号', v: { in: 'pn' } }], + gateLedgers: ['parts.profile', 'parts.parameter'], + }, + ], + }, + ctx, + ); + expect(out.issuedCardId).toBe('card-1'); + expect(cards.issued[0]).toMatchObject({ + workOrder: 'execution', + itemId: 'S1', + card: { + mode: 'execution', + title: '数据工单 · 补齐 XL2596S-5.0', + variant: 'data', + steps: ['选一个芯片类型', '把参数值填齐'], + standard: '必填格全部填上,红带消失', + basis: [{ k: '候选料号', v: 'XL2596S-5.0' }], + gateLedgers: ['parts.profile', 'parts.parameter'], + fields: [], + }, + }); + }); + + it('这一步已经发过卡:**不再发第二张**,如实留一条观察', async () => { + const { runner, notes, ctx } = harness({}); + const out = await runner.run( + { do: [{ kind: 'card', as: 'execution', title: 'x', basis: [{ k: 'a', v: 'b' }] }] }, + { ...ctx, carded: true }, + ); + expect(out.issuedCardId).toBeUndefined(); + expect(notes.some((n) => n.includes('已经发过'))).toBe(true); + }); + + it('decide + review:摆成验收工单,单一选项也发得出去', async () => { + const { runner, ctx } = harness({}); + const out = await runner.run( + { + do: [ + { + kind: 'card', + as: 'decide', + variant: 'review', + title: '差异评估结论审核', + ask: '逐条核对过了吗?', + options: [{ value: 'approve', label: '确认通过' }], + basis: [{ k: '候选', v: 'XL2596S-5.0' }], + }, + ], + }, + ctx, + ); + expect(out.issuedCardId).toBe('card-2'); + }); + + it('发了卡就**收拍**:排在后面的笔一律不跑', async () => { + const { runner, drafts, ctx } = harness({}); + await runner.run( + { + do: [ + { kind: 'card', as: 'execution', title: 'x', basis: [{ k: 'a', v: 'b' }] }, + { kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'a', internal_pn: 'b' } }, + ], + }, + ctx, + ); + expect(drafts.size).toBe(0); + }); + + it('submit:回给 turn-runner,理由现算;有卡在等人时不 submit', async () => { + const { runner, ctx } = harness({ n: 3 }); + const out = await runner.run({ do: [{ kind: 'submit', rationale: { cat: ['已判定 ', { in: 'n' }, ' 条参数'] } }] }, ctx); + expect(out.submit).toEqual({ rationale: '已判定 3 条参数' }); + + const quiet = await runner.run({ do: [{ kind: 'submit', rationale: 'x' }] }, { ...ctx, quiet: true }); + expect(quiet.submit).toBeUndefined(); + }); +}); +``` + +上面第一个用例要断言卡骨架,所以先把 Task 4 写的 `harness()` 里那两个发卡端口改成**记录入参**: + +```ts + const cards = { issued: [] as Array> }; +``` + +(放在 `const notes: string[] = [];` 旁边),并把 `deps.cards` 里这两行: + +```ts + issue: () => Promise.resolve({ id: 'card-1' }), + decide: () => Promise.resolve({ id: 'card-2' }), +``` + +改成: + +```ts + issue: (i: Record) => { cards.issued.push({ ...i, kind: 'issue' }); return Promise.resolve({ id: 'card-1' }); }, + decide: (i: Record) => { cards.issued.push({ ...i, kind: 'decide' }); return Promise.resolve({ id: 'card-2' }); }, +``` + +最后把 `harness` 的返回值加上 `cards`: + +```ts + return { runner: createAutoRunner(deps as never), drafts, notes, cards, ctx }; +``` + +- [ ] **Step 2: 跑一遍,确认它失败** + +Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts -t "card 与 submit"` +Expected: FAIL —— `动词尚未实现:card` + +- [ ] **Step 3: 实现两个分支** + +在 `auto-runner.ts` 的 `runAction` 里,`analyze` 分支之后、最后那句 `throw` 之前插入: + +```ts + if (action.kind === 'card') { + /* + * **发过就不再发**(幂等)。人工激活的 auto 步,**配方发卡就是启动** —— + * 不必再有一个「先 start」的礼仪:学不稳的礼仪就是死法(2026-08-02 已拆过一次)。 + */ + if (context.carded) { + await deps.memory.append({ + taskId: context.taskId, + kind: 'observation', + title: `这一步已经发过卡,不再发第二张`, + detail: { auto: `${context.stepId}#${index + 1}`, internal: true }, + }); + return { wrote: 0, dropped: 0, stop: true }; + } + const basis = (action.basis ?? []).map((b) => ({ k: b.k, v: evalText(b.v, base) })); + if (action.as === 'decide') { + const options = action.options ?? []; + if (options.length === 0) throw new AutoRecipeError(index, '决策卡一个选项都没有'); + const { id } = await deps.cards.decide({ + taskId: context.taskId, + ownerId: context.ownerId, + humanCallCap: context.humanCallCap, + ask: evalText(action.ask ?? action.title, base), + options: options.map((o) => ({ value: o.value, label: o.label, ...(o.detail === undefined ? {} : { detail: o.detail }) })), + basis, + itemId: context.stepId, + ...(action.variant === undefined ? {} : { variant: action.variant }), + }); + return { wrote: 0, dropped: 0, cardId: id, stop: true }; + } + const { id } = await deps.cards.issue({ + taskId: context.taskId, + ownerId: context.ownerId, + humanCallCap: context.humanCallCap, + workOrder: 'execution', + itemId: context.stepId, + card: { + mode: 'execution', + title: evalText(action.title, base), + ask: evalText(action.title, base), + basis, + /* 零输入:填报全在工作区的件上(2026-08-02 用户定) */ + fields: [], + ...(action.variant === 'data' ? { variant: 'data' } : {}), + ...(action.guide === undefined ? {} : { steps: [...action.guide] }), + ...(action.standard === undefined ? {} : { standard: evalText(action.standard, base) }), + ...(action.gateLedgers === undefined ? {} : { gateLedgers: [...action.gateLedgers] }), + }, + }); + return { wrote: 0, dropped: 0, cardId: id, stop: true }; + } + + if (action.kind === 'submit') { + return { wrote: 0, dropped: 0, submit: { rationale: evalText(action.rationale, base) }, stop: true }; + } +``` + +> `variant` 只在 `CardGateway.decide` 上是显式参数;执行工单的 `variant:'data'` 是**卡骨架里的一个键** +> (见 `executionWorkOrderSchema.variant`),所以走 `card` 那个对象。这两处不对称是既有契约的样子,别改它。 + +- [ ] **Step 4: 跑测试确认通过** + +Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts` +Expected: PASS(16 个用例) + +- [ ] **Step 5: 给审核卡放宽单选项写失败的测试** + +先找到测 `card-rules` 的那个文件: + +Run: `grep -rln "options-missing" apps/api/src/modules/human-tool/__tests__/` + +在它末尾追加(`describe` 的名字照该文件既有的风格): + +```ts +describe('审核卡的选项数', () => { + it('普通决策卡仍要两个分支 —— 一个选项的岔路口不是岔路口', () => { + const findings = validateCard({ k: 'decision', ask: '往哪走', options: [{ value: 'a', label: 'A' }] } as never); + expect(findings.some((f) => f.code === 'options-missing')).toBe(true); + }); + + it('审核卡(variant:review)只有「确认通过」一个选项也发得出去 —— 改在件上,卡上只签字', () => { + const findings = validateCard({ k: 'decision', variant: 'review', ask: '核对过了吗', options: [{ value: 'approve', label: '确认通过' }] } as never); + expect(findings.some((f) => f.code === 'options-missing')).toBe(false); + }); +}); +``` + +> `validateCard` 是那个文件里已经在用的入口函数名;若它叫别的名字, +> 用 `grep -n "options-missing" apps/api/src/modules/human-tool/internal/domain/card-rules.ts` 往上找到它所在的导出函数。 + +- [ ] **Step 6: 跑一遍,确认第二条失败** + +Run: `pnpm exec vitest run apps/api/src/modules/human-tool/__tests__/ -t "审核卡的选项数"` +Expected: 第一条 PASS,第二条 FAIL(`options-missing` 仍被报出来) + +- [ ] **Step 7: 放宽** + +把 `apps/api/src/modules/human-tool/internal/domain/card-rules.ts:92` 那一行: + +```ts + if (card.options.length < 2) out.push({ code: 'options-missing', message: '决策卡至少两个分支' }); +``` + +改成: + +```ts + /* + * **审核卡是例外**(2026-08-29):它不是岔路口,是一道签字关 —— + * 改全在件上就地做,卡上只有「确认通过」一个动作。 + * 普通决策卡照旧要两个分支:一个选项的岔路口不是岔路口。 + */ + const floor = card.variant === 'review' ? 1 : 2; + if (card.options.length < floor) { + out.push({ code: 'options-missing', message: floor === 1 ? '审核卡至少一个选项' : '决策卡至少两个分支' }); + } +``` + +- [ ] **Step 8: 跑测试与全量门禁** + +Run: +```bash +pnpm exec vitest run apps/api/src/modules/human-tool/__tests__/ +pnpm gates +``` +Expected: 全绿。 + +- [ ] **Step 9: 提交** + +```bash +git add apps/api/src/modules/loop-engine apps/api/src/modules/human-tool +git commit -m "$(cat <<'EOF' +feat(loop-engine): card 与 submit 两个动词;审核卡放宽到单选项 + +ADR-014 §8 原写「不发决策卡、不 submit」—— 那是假设还有 agent 在场时的保守话。 +全程无 agent 的活动没有别人来干这两件事,不开口最后两步就没人做。 +护栏没松:submit 仍要过提交门(判据全绿),系统提交不比模型提交宽松。 +发过的卡不再发第二张;发了卡就收拍,排在后面的笔一律不跑。 + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet +EOF +)" +``` + +--- + +### Task 7: turn-runner 接线 —— 拍首跑配方 + +**Files:** +- Create: `apps/api/src/modules/loop-engine/internal/domain/live-step.ts` +- Create: `apps/api/src/modules/loop-engine/__tests__/live-step.test.ts` +- Modify: `apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts`(三处) +- Modify: `apps/api/src/modules/loop-engine/CONTRACT.md` + +**Interfaces:** +- Consumes: Task 4-6 的 `createAutoRunner` / `AutoOutcome` +- Produces: `function liveStepOf(steps: readonly S[], settledIds: ReadonlySet, onStage: readonly { id: string }[]): S | undefined` + +- [ ] **Step 1: 写 `liveStepOf` 的失败测试** + +```ts +// apps/api/src/modules/loop-engine/__tests__/live-step.test.ts +import { describe, expect, it } from 'vitest'; +import { liveStepOf } from '../internal/domain/live-step.ts'; + +const steps = [ + { item: 'S1', guide: '' }, + { item: 'S2', guide: '' }, + { item: 'T_release', guide: '' }, +]; + +describe('liveStepOf:这一单此刻在办哪一步', () => { + it('在场的第一个还没收掉的项', () => { + expect(liveStepOf(steps, new Set(), [{ id: 'S1' }, { id: 'S2' }])?.item).toBe('S1'); + }); + + it('**收掉的项要跳过** —— 带 repetitionRule 的项收了之后仍是 Enabled,不跳就整单钉在第一步', () => { + expect(liveStepOf(steps, new Set(['S1']), [{ id: 'S1' }, { id: 'S2' }])?.item).toBe('S2'); + }); + + it('在场的都收完了 → undefined', () => { + expect(liveStepOf(steps, new Set(['S1', 'S2']), [{ id: 'S1' }, { id: 'S2' }])).toBeUndefined(); + }); + + it('在场的项没有剧本 → undefined(没写剧本的活动照旧跑)', () => { + expect(liveStepOf(steps, new Set(), [{ id: 'X' }])).toBeUndefined(); + }); + + it('次序按 onStage 给的次序,不按 steps 的次序', () => { + expect(liveStepOf(steps, new Set(), [{ id: 'S2' }, { id: 'S1' }])?.item).toBe('S2'); + }); +}); +``` + +- [ ] **Step 2: 跑一遍,确认它失败** + +Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/live-step.test.ts` +Expected: FAIL —— `Failed to resolve import "../internal/domain/live-step.ts"` + +- [ ] **Step 3: 提纯出 `liveStepOf`** + +```ts +// apps/api/src/modules/loop-engine/internal/domain/live-step.ts +/** + * **这一单此刻在办哪一步** —— 从 turn-runner 里提纯出来的一句话(纯函数,好测)。 + * + * 为什么要跳过已收掉的项:带 `repetitionRule` 的项被收掉之后**仍是 Enabled**, + * 「在场的第一个有剧本的项」于是永远是第一步 —— 实测:跑到判定去向了, + * 工作区还停在阶段二那份件,喂给模型的做法也还是第一步的。 + */ +export function liveStepOf( + steps: readonly S[], + settledIds: ReadonlySet, + onStage: readonly { readonly id: string }[], +): S | undefined { + for (const entry of onStage) { + if (settledIds.has(entry.id)) continue; + const step = steps.find((s) => s.item === entry.id); + if (step !== undefined) return step; + } + return undefined; +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/live-step.test.ts` +Expected: PASS(5 个用例) + +- [ ] **Step 5: 让 turn-runner 用它(等价替换,行为不变)** + +在 `turn-runner.ts` 里找到这一段(约 1927-1935 行): + +```ts + const liveStep = [...stateEnabled.inProgress, ...stateEnabled.entries] + .filter((e) => !settledIds.has(e.id)) + .map((e) => stepOf(e.id)) + .find((x) => x !== undefined); +``` + +替换为: + +```ts + const liveStep = liveStepOf(steps, settledIds, [...stateEnabled.inProgress, ...stateEnabled.entries]); +``` + +并在文件顶部 import 段加: + +```ts +import { liveStepOf } from '../domain/live-step.ts'; +``` + +- [ ] **Step 6: 跑全量确认等价** + +Run: `pnpm exec vitest run apps/api/src/modules/loop-engine` +Expected: 全绿(这一步只是提纯,行为一个字不变) + +- [ ] **Step 7: 在「有卡在等人」那条路上插入静默配方段** + +在 `turn-runner.ts` 里找到这一段(约 1682 行): + +```ts + const waiting = await deps.cards.unanswered(taskId); + if (waiting.length > 0) { + await deps.memory.append({ +``` + +在 `if (waiting.length > 0) {` 之后、`await deps.memory.append({` 之前插入: + +```ts + /* + * **有卡在等人,配方照样跑 —— 只算、只写**(spec §5.4)。 + * 人在件上按了保存,系统就该按最新的草稿把该铺的行铺出来(「保存即铺格」); + * 但不发新卡、不 submit、不调 AI —— 叫了人就等人。 + */ + /* `steps` 要到第 1842 行才 const 出来,这儿够不着 —— 从 model 上现取 */ + const quietStep = liveStepOf( + model.steps ?? [], + new Set(runtime.items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id)), + [...enabled.inProgress, ...enabled.entries], + ); + if (quietStep?.auto !== undefined) { + await autoRunner.run(quietStep.auto, { + taskId, + stepId: quietStep.item, + ownerId: (await deps.tasks.of(taskId)).ownerId, + humanCallCap: (await deps.tasks.of(taskId)).humanCallCap, + quiet: true, + carded: true, + }); + } +``` + +`deps.tasks.of(taskId)` 回的是 `{ taskId, activityId, ownerId, humanCallCap }` —— 该文件第 675 / 1535 行 +已经这么取过两次(`const context = await deps.tasks.of(taskId);`)。若这一段所在的作用域里已经有 +`context` 这个变量,直接用 `context.ownerId` / `context.humanCallCap`,别再取一遍。 + +- [ ] **Step 8: 在正常那条路上插入完整配方段** + +在 `turn-runner.ts` 里找到 `const owesCard =` 那一段(约 1948 行)**之前**插入: + +```ts + /* + * **配方段**(spec §5.8 ③④⑤):当前这一步声明了 `auto` 就先把确定的事做完。 + * · 发了卡 / submit → 这一拍到此为止(零 token,不问模型); + * · 没发卡且这一步没有 `guide` → 重求 doneWhen 收步(下面的 sweepRipe 会做); + * · 有 `guide` → 照常往下问模型(半 auto)。 + * 出错 → 留痕交人,不重试、不退回模型。 + */ + if (current?.auto !== undefined) { + const auto = await autoRunner.run(current.auto, { + taskId, + stepId: current.item, + ownerId: (await deps.tasks.of(taskId)).ownerId, + humanCallCap: (await deps.tasks.of(taskId)).humanCallCap, + quiet: false, + carded: stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true, + }); + if (auto.failedAt !== undefined) { + return { runtime: stateRuntime, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' }; + } + if (auto.submit !== undefined) { + const raised = await raiseAcceptance(taskId, stateRuntime, auto.submit.rationale); + return { ...raised, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' }; + } + if (auto.issuedCardId !== undefined) { + /* 卡发出去了 = 这一步真动过世界 —— 标 worldTouched,自动收步那道门才诚实 */ + const touched = markWorldTouched(stateRuntime, current.item); + await deps.state.save(taskId, model, parsed, touched); + await showView(taskId, undefined, touched, current.view); + return { runtime: touched, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' }; + } + if (auto.wrote > 0 || auto.dropped > 0) { + /* 写过东西:重新求一遍 doneWhen —— 这一步可能已经办完了 */ + const freshScope = await deps.data.view(taskId); + const reswept = await sweepRipe(stateRuntime, stateEnabled, freshScope); + if (reswept.runtime !== stateRuntime) { + await deps.state.save(taskId, model, parsed, reswept.runtime); + return { runtime: reswept.runtime, enabled: reswept.enabled, navigation: { kind: 'progressed' }, status: 'running' }; + } + } + if (current.guide.trim() === '') { + /* 没有 guide、没发卡、doneWhen 也不成立 → 配方没做到位,是包的 bug,交人 */ + await deps.memory.append({ + taskId, + kind: 'result', + title: `「${current.item}」的配方跑完了,可这一步还没算办完 —— 交给你看一眼`, + detail: { auto: current.item }, + }); + return { runtime: stateRuntime, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' }; + } + } +``` + +> 三个你要照该文件既有写法对齐的名字: +> - `markWorldTouched` —— 用 `grep -n "worldTouched" apps/api/src/modules/loop-engine/internal/domain/lifecycle.ts` 找那个把项标成「动过世界」的函数;它已存在(`human`/`decide`/`entry` 三条路都在用)。 +> - `showView` —— 该文件里推帧那一段用的函数,签名照它。 +> - `raiseAcceptance` —— 第 1500 行定义,签名 `(taskId, runtime, rationale, fields?)`。 + +- [ ] **Step 9: 在 `createTurnRunner` 里造出 `autoRunner`** + +在 `createTurnRunner(deps)` 函数体开头(`return { ... }` 之前)加: + +```ts + const autoRunner = createAutoRunner({ + data: deps.data, + cards: deps.cards, + memory: deps.memory, + analyze: deps.analyze, + sfeel: deps.sfeel, + }); +``` + +并在文件顶部 import 段加: + +```ts +import { createAutoRunner } from './auto-runner.ts'; +``` + +- [ ] **Step 10: 给 turn-runner 的测试脚手架补 `analyze` 端口** + +`TurnRunnerDeps` 多了一个依赖,`turn-runner-acceptance.test.ts` 的 `harness()` 要跟上。 +在 `const deps: TurnRunnerDeps = {` 里,`agent: { … } as never,` 之后插入: + +```ts + analyze: { run: async () => ({ object: {} }) } as never, +``` + +Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts` +Expected: 现有用例全绿(这一步只是补一个没人调的端口) + +- [ ] **Step 11: 写接线的五个失败用例** + +追加到 `turn-runner-acceptance.test.ts` 末尾: + +```ts +describe('auto 步:配方在拍首跑', () => { + /** 一步的活动:那一步声明 auto */ + const AUTO_MODEL: CaseModel = { + name: '一步的活动', + autoComplete: false, + items: [{ id: 'S1', name: '补数据', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] }], + sentries: [], + } as never; + const AUTO_RUNTIME: CaseRuntime = { items: [{ id: 'S1', state: 'Available' }] } as never; + + it('配方发了卡 → 这一拍一次都不问模型,状态 awaiting', async () => { + const h = harness({ + parsed: AUTO_MODEL, + runtime: AUTO_RUNTIME, + sfeelCondition: () => false, + steps: [ + { + item: 'S1', + guide: '', + writes: [], + reads: [], + tools: [], + auto: { do: [{ kind: 'card', as: 'execution', title: '补数据', basis: [{ k: '料号', v: 'XL2596S-5.0' }] }] }, + }, + ], + }); + const out = await h.runner.runTurn(TASK); + expect(h.decided).toHaveLength(0); + expect(out.status).toBe('awaiting'); + expect(h.issued.map((i) => i.workOrder)).toEqual(['execution']); + }); + + it('配方只写不发卡、这一步因此办完 → 收步,还是不问模型', async () => { + const h = harness({ + parsed: AUTO_MODEL, + runtime: AUTO_RUNTIME, + sfeelCondition: (_expr, scope) => Number(scope['quote_intake_record_rows'] ?? 0) > 0, + viewFn: (written) => ({ quote_intake_record_rows: written.length }), + steps: [ + { + item: 'S1', + guide: '', + writes: [], + reads: [], + tools: [], + doneWhen: 'quote_intake_record_rows > 0', + auto: { do: [{ kind: 'project', into: 'quote_intake.record', from: { once: true }, fields: { pn: 'LM2596S-5.0', eau: 1000 } }] }, + }, + ], + }); + const out = await h.runner.runTurn(TASK); + expect(h.decided).toHaveLength(0); + expect(h.written.map((w) => w.key)).toEqual([{ pn: 'LM2596S-5.0' }]); + expect(out.runtime.items.find((i) => i.id === 'S1')?.state).toBe('Completed'); + }); + + it('有卡在等人交回:配方照样写,但一张新卡都不发', async () => { + const h = harness({ + parsed: AUTO_MODEL, + runtime: AUTO_RUNTIME, + unanswered: [{ id: 'card-9', title: '补数据' }], + sfeelCondition: () => false, + viewFn: (written) => ({ n: written.length }), + steps: [ + { + item: 'S1', + guide: '', + writes: [], + reads: [], + tools: [], + auto: { + do: [ + { kind: 'project', into: 'quote_intake.record', from: { once: true }, fields: { pn: 'A', eau: 1 } }, + { kind: 'card', as: 'execution', title: '再来一张', basis: [{ k: 'a', v: 'b' }] }, + ], + }, + }, + ], + }); + await h.runner.runTurn(TASK); + expect(h.written).toHaveLength(1); + expect(h.issued).toHaveLength(0); + }); + + it('配方抛错(地址不在台账声明里)→ 交人,不问模型、不重试', async () => { + const h = harness({ + parsed: AUTO_MODEL, + runtime: AUTO_RUNTIME, + sfeelCondition: () => false, + steps: [ + { + item: 'S1', + guide: '', + writes: [], + reads: [], + tools: [], + auto: { do: [{ kind: 'project', into: 'nope.nope', from: { once: true }, fields: { a: 'b' } }] }, + }, + ], + }); + const out = await h.runner.runTurn(TASK); + expect(h.decided).toHaveLength(0); + expect(out.status).toBe('awaiting'); + expect(h.appended.some((a) => a.title.includes('nope.nope'))).toBe(true); + }); + + it('这一步有 guide:配方跑完照常问模型(半 auto)', async () => { + const h = harness({ + parsed: OPEN_MODEL, + runtime: OPEN_RUNTIME, + sfeelCondition: () => false, + viewFn: (written) => ({ n: written.length }), + steps: [ + { + item: 'i1', + guide: '把结论写实', + writes: [], + reads: [], + tools: [], + auto: { do: [{ kind: 'project', into: 'quote_intake.record', from: { once: true }, fields: { pn: 'A', eau: 1 } }] }, + }, + ], + action: { kind: 'idle', rationale: '看过了' }, + }); + await h.runner.runTurn(TASK); + expect(h.written).toHaveLength(1); + expect(h.decided).toHaveLength(1); + }); +}); +``` + +- [ ] **Step 12: 跑这五条,确认通过** + +Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts -t "auto 步"` +Expected: 5 个用例全 PASS。若第二条报「S1 还是 Available」,多半是配方段插在了 `sweepRipe` 之前 —— +它必须在 `const swept = await sweepRipe(runtime, enabled, scope);` **之后**(那时才有 `stateRuntime` / `current`)。 + +- [ ] **Step 13: 跑全量门禁** + +Run: `pnpm gates` +Expected: 全绿。**E04 / I01 / W05 的既有用例必须一条不改就过** —— 它们的剧本里没有 `auto`,这一段整个不进。 + +- [ ] **Step 14: 补 CONTRACT.md** + +在 `apps/api/src/modules/loop-engine/CONTRACT.md` 的不变量清单里加三条: + +```markdown +- **auto 步不问模型**:`PlanStep.auto` 存在时,拍首先跑配方;发了卡 / submit 就收拍(零 token)。 + 有 `guide` 的照常问模型(半 auto);两者都没有且 `doneWhen` 不成立 → 交人(配方没做到位,是包的 bug)。 +- **有未答的卡在场时,配方只跑 `project` / `say`** —— 只算、只写,不发新卡、不 submit、不调 AI。 +- **配方每一笔之前刷新任务视图**:后一笔常要数前一笔刚写下的行,不刷新就恒为 0。 +``` + +- [ ] **Step 15: 提交** + +```bash +git add apps/api/src/modules/loop-engine +git commit -m "$(cat <<'EOF' +feat(loop-engine): 拍首跑配方 —— 确定的活由引擎做完,剩下的才问模型 + +liveStepOf 从 turn-runner 提纯成纯函数(带 repetitionRule 的项收了仍 Enabled, +不跳过已收掉的就整单钉在第一步 —— 这条实测栽过)。 +有卡在等人时配方照样跑但只算只写:人在件上按了保存,该铺的行就铺出来。 +老活动一个字不用改:剧本里没有 auto,这一段整个不进。 + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet +EOF +)" +``` + +--- + +### Task 8: 卡答复即收步 + +**Files:** +- Modify: `apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts`(导出一个纯函数) +- Modify: `apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts`(配方段末尾加一段) +- Modify: `apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts`(补 2 个用例) + +**Interfaces:** +- Consumes: Task 7 的配方段 +- Produces: `function recipeIssuesCard(recipe: AutoRecipe): boolean` + +- [ ] **Step 1: 写失败的测试** + +追加到 `turn-runner-acceptance.test.ts` 的 `describe('auto 步:配方在拍首跑', …)` 里: + +```ts + it('卡答过了、这一步又没有 doneWhen → 收步(人已经签了,收步是照实记录)', async () => { + const h = harness({ + parsed: AUTO_MODEL, + runtime: { items: [{ id: 'S1', state: 'Active', worldTouched: true }] } as never, + unanswered: [], + sfeelCondition: () => false, + steps: [ + { + item: 'S1', + guide: '', + writes: [], + reads: [], + tools: [], + auto: { do: [{ kind: 'card', as: 'decide', variant: 'review', title: '结论审核', options: [{ value: 'approve', label: '确认通过' }], basis: [{ k: 'a', v: 'b' }] }] }, + }, + ], + }); + const out = await h.runner.runTurn(TASK); + expect(h.issued).toHaveLength(0); + expect(out.runtime.items.find((i) => i.id === 'S1')?.state).toBe('Completed'); + }); + + it('卡还没发过就不收 —— 那才是替人签字', async () => { + const h = harness({ + parsed: AUTO_MODEL, + runtime: AUTO_RUNTIME, + sfeelCondition: () => false, + steps: [ + { + item: 'S1', + guide: '', + writes: [], + reads: [], + tools: [], + auto: { do: [{ kind: 'card', as: 'decide', variant: 'review', title: '结论审核', options: [{ value: 'approve', label: '确认通过' }], basis: [{ k: 'a', v: 'b' }] }] }, + }, + ], + }); + const out = await h.runner.runTurn(TASK); + expect(h.issued).toHaveLength(1); + expect(out.runtime.items.find((i) => i.id === 'S1')?.state).not.toBe('Completed'); + }); +``` + +- [ ] **Step 2: 跑一遍,确认第一条失败** + +Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts -t "卡答过了"` +Expected: FAIL —— S1 仍是 `Active` + +- [ ] **Step 3: 导出纯函数** + +在 `auto-runner.ts` 的 `createAutoRunner` **之前**加: + +```ts +/** 这份配方会不会发卡 —— 「卡答过了就收步」那道门要问它 */ +export function recipeIssuesCard(recipe: AutoRecipe): boolean { + return recipe.do.some((action) => action.kind === 'card'); +} +``` + +- [ ] **Step 4: 在 turn-runner 的配方段末尾加收步** + +在 Task 7 Step 8 插入的那一段里,`if (current.guide.trim() === '') { … }` **之前**插入: + +```ts + /* + * **卡答过了就收步**(spec §5.5)。走到这儿的四个前提都成立: + * 这一步声明了 auto、卡发过(worldTouched)、这一拍没有未答的卡(前面 unanswered 把住)、 + * 配方这一拍也没再发新卡。**人已经签了字,收步是照实记录,不是替人签字** —— + * 「人工激活项不给 doneWhen」那条纪律防的是引擎抢在人前面,这里人在前面。 + */ + const noDoneWhen = current.doneWhen === undefined || current.doneWhen.trim() === ''; + const carded = stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true; + if (noDoneWhen && carded && recipeIssuesCard(current.auto)) { + const active = readyToWork(parsed, stateRuntime, current.item); + const settled = transitionItem(active, current.item, 'Completed'); + await deps.memory.append({ + taskId, + kind: 'engine-transition', + /* 标题别以「完成」开头 —— `task-log.actorOf` 按 /^(完成|开始办)/ 认 AI,而这一步是引擎收的 */ + title: `自动收步:${current.item} —— 卡已交回,这一步到此为止`, + detail: { internal: true, item: current.item, auto: current.item }, + }); + await deps.state.save(taskId, model, parsed, settled); + return { runtime: settled, enabled: stateEnabled, navigation: { kind: 'progressed' }, status: 'running' }; + } +``` + +并在 `auto-runner.ts` 的 import 那一行加上 `recipeIssuesCard`: + +```ts +import { createAutoRunner, recipeIssuesCard } from './auto-runner.ts'; +``` + +> `readyToWork` 与 `transitionItem` 是 `sweepRipe` 里已经在用的两个函数(同文件已 import)。 + +- [ ] **Step 5: 跑测试确认通过** + +Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts` +Expected: 全绿(含新加的 7 条 auto 用例) + +- [ ] **Step 6: 提交** + +```bash +git add apps/api/src/modules/loop-engine +git commit -m "$(cat <<'EOF' +feat(loop-engine): auto 步的卡答过了就收步 + +四个前提都成立才收:声明了 auto、卡发过、这一拍没有未答的卡、配方也没再发新卡。 +卡还没发过就不收 —— 那才是替人签字。 + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet +EOF +)" +``` + +--- + +### Task 9: 静态审 —— 配方写错在装包时就拒 + +**Files:** +- Modify: `apps/api/src/modules/activity-registry/internal/domain/static-audit.ts` +- Modify: 该模块 `__tests__/` 下测 `staticAudit` 的那个文件(用 `grep -rln "staticAudit" apps/api/src/modules/activity-registry/__tests__/` 找它) + +**Interfaces:** +- Consumes: Task 1 的 `AutoRecipe` +- Produces: `function autoRecipeFindings(manifest: ActivityManifest): StaticAuditFinding[]`,并由 `staticAudit` 汇总 + +- [ ] **Step 1: 写失败的测试** + +追加到 static-audit 的测试文件末尾(`manifestFixture()` 用该文件里既有的那个造清单的 helper; +若它叫别的名字,`grep -n "ActivityManifest = " ` 找它): + +```ts +describe('配方静态审:写错地址 = 每一单都错,装的时候就拒', () => { + const withRecipe = (auto: unknown) => { + const base = manifestFixture(); + return { + ...base, + assets: { + ...base.assets, + businessModel: { + ...base.assets.businessModel, + steps: [{ item: 'S1', guide: '', writes: [], reads: [], tools: [], auto }], + }, + }, + } as never; + }; + + it('台账地址不在 declarations.ledger 里 → error', () => { + const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'project', into: 'nope.nope', from: { once: true }, fields: { a: 'b' } }] })); + expect(findings.some((f) => f.severity === 'error' && f.message.includes('nope.nope'))).toBe(true); + }); + + it('列名不在该对象的声明里 → error(列名写错 = 那一格永远是空的)', () => { + const findings = autoRecipeFindings( + withRecipe({ do: [{ kind: 'project', into: 'quote_intake.record', from: { once: true }, fields: { 不存在的列: 'x' } }] }), + ); + expect(findings.some((f) => f.severity === 'error' && f.message.includes('不存在的列'))).toBe(true); + }); + + it('each / lookup 的地址同样要在声明里', () => { + const findings = autoRecipeFindings( + withRecipe({ do: [{ kind: 'project', into: 'quote_intake.record', from: { each: 'mine:nope.nope' }, fields: {} }] }), + ); + expect(findings.some((f) => f.message.includes('nope.nope'))).toBe(true); + }); + + it('in: 与 result: 不是台账地址,不该被当成台账来审', () => { + const findings = autoRecipeFindings( + withRecipe({ do: [{ kind: 'project', into: 'quote_intake.record', from: { each: 'in:pns' }, fields: {} }] }), + ); + expect(findings).toEqual([]); + }); + + it('analyze 的出参没人消费 → warning(白烧一次模型调用)', () => { + const findings = autoRecipeFindings( + withRecipe({ do: [{ kind: 'analyze', model: 'm', into: 'items', instruction: 'x', input: { from: { once: true }, fields: {} }, output: { type: 'object' } }] }), + ); + expect(findings.some((f) => f.severity === 'warning' && f.message.includes('items'))).toBe(true); + }); + + it('submit 那一笔所在的步没有人工验收清单 → error(系统提交不比模型提交宽松)', () => { + const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'submit', rationale: 'x' }] })); + expect(findings.some((f) => f.severity === 'error' && f.message.includes('验收清单'))).toBe(true); + }); +}); +``` + +- [ ] **Step 2: 跑一遍,确认它失败** + +Run: `pnpm exec vitest run apps/api/src/modules/activity-registry/__tests__/ -t "配方静态审"` +Expected: FAIL —— `autoRecipeFindings is not defined` + +- [ ] **Step 3: 写审计** + +在 `static-audit.ts` 的 `loopFindings` **之后**插入: + +```ts +/** + * **配方静态审**(2026-08-29)—— 与 `refersTo` 目标检查同款:**写错地址 = 每一单都错**。 + * + * 代码错是必然的错,模型错是概率的错。必然的错好查 —— 前提是它在装包那一刻就被拦下, + * 而不是等到第一单跑起来才现形。 + */ +export function autoRecipeFindings(manifest: ActivityManifest): StaticAuditFinding[] { + const out: StaticAuditFinding[] = []; + const ledgers = new Map( + manifest.declarations.ledger.map((l) => [`${l.domain}.${l.object}`, new Set(l.fields.map((f) => f.name))]), + ); + const templates = new Set(manifest.assets.viewTemplates.map((t) => t.name)); + + /** 台账地址:剥掉 `mine:` 前缀;`in:` / `result:` 不是台账,跳过 */ + const checkAddress = (raw: string, at: string): void => { + if (raw.startsWith('in:') || raw.startsWith('result:')) return; + const address = raw.startsWith('mine:') ? raw.slice('mine:'.length) : raw; + if (!ledgers.has(address)) { + out.push({ + code: 'schema-invalid', + severity: 'error', + message: `配方引的台账地址不在声明里:${address}(写的授权面是台账声明)`, + at, + }); + } + }; + + /** 表达式里也可能藏着 lookup 地址,递归找一遍 */ + const walkExpr = (expr: unknown, at: string): void => { + if (typeof expr !== 'object' || expr === null) return; + const node = expr as Record; + if (typeof node['lookup'] === 'string') { + checkAddress(node['lookup'], at); + for (const value of Object.values(node['by'] ?? {})) walkExpr(value, at); + if (node['else'] !== undefined) walkExpr(node['else'], at); + } + if (Array.isArray(node['cat'])) for (const part of node['cat']) walkExpr(part, at); + if (node['fmt'] !== undefined) walkExpr(node['fmt'], at); + if (node['then'] !== undefined) walkExpr(node['then'], at); + if (typeof node['when'] === 'string' && node['else'] !== undefined) walkExpr(node['else'], at); + }; + + const walkSource = (source: unknown, at: string): void => { + if (typeof source !== 'object' || source === null) return; + const node = source as Record; + if (typeof node['each'] === 'string') checkAddress(node['each'], at); + for (const value of Object.values(node['where'] ?? {})) walkExpr(value, at); + if (typeof node['then'] === 'object' && node['then'] !== null) walkSource(node['then'], at); + if (Array.isArray(node['rows'])) { + for (const row of node['rows']) for (const value of Object.values(row as Record)) walkExpr(value, at); + } + }; + + for (const step of manifest.assets.businessModel.steps ?? []) { + const recipe = step.auto; + if (recipe === undefined) continue; + const at = `assets.businessModel.steps[${step.item}].auto`; + const consumed = new Set(); + + for (const [index, action] of recipe.do.entries()) { + const where = `${at}.do[${index}]`; + if (action.kind === 'project') { + checkAddress(action.into, where); + walkSource(action.from, where); + const columns = ledgers.get(action.into); + for (const [name, expr] of Object.entries(action.fields)) { + walkExpr(expr, where); + if (columns !== undefined && !columns.has(name)) { + out.push({ code: 'schema-invalid', severity: 'error', message: `配方写的列不在 ${action.into} 的声明里:${name}`, at: where }); + } + } + } + if (action.kind === 'analyze') { + walkSource(action.input.from, where); + for (const expr of Object.values(action.input.fields)) walkExpr(expr, where); + if (!manifest.declarations.models.includes(action.model)) { + out.push({ code: 'schema-invalid', severity: 'error', message: `配方用的模型没在 declarations.models 里声明:${action.model}`, at: where }); + } + } + if (action.kind === 'card') { + for (const address of action.gateLedgers ?? []) checkAddress(address, where); + if (action.as === 'decide' && (action.options ?? []).length === 0) { + out.push({ code: 'schema-invalid', severity: 'error', message: '决策卡一个选项都没有', at: where }); + } + } + if (action.kind === 'submit' && (step.acceptanceChecklist ?? []).length === 0) { + out.push({ + code: 'schema-invalid', + severity: 'error', + message: 'submit 那一步必须声明人工验收清单(acceptanceChecklist)—— 系统提交不比模型提交宽松', + at: where, + }); + } + /* 找出 `result:xxx` 被谁消费了 —— analyze 的出参没人用,就是白烧一次模型调用 */ + const text = JSON.stringify(action); + for (const hit of text.matchAll(/"result:([A-Za-z0-9_]+)"/g)) consumed.add(hit[1] ?? ''); + } + + for (const action of recipe.do) { + if (action.kind !== 'analyze') continue; + if (!consumed.has(action.into)) { + out.push({ + code: 'schema-invalid', + severity: 'warning', + message: `analyze 的出参 result:${action.into} 没有任何一笔消费它 —— 白烧一次模型调用`, + at, + }); + } + } + + if (step.view !== undefined && step.view !== '' && !templates.has(step.view)) { + out.push({ code: 'schema-invalid', severity: 'error', message: `剧本引的视图模版不存在:${step.view}`, at }); + } + } + return out; +} +``` + +- [ ] **Step 4: 挂进 `staticAudit`** + +在 `staticAudit(manifest, external)` 函数体里,找到它 `out.push(...loopFindings(manifest))` 那一行(或等价写法), +在它之后加一行: + +```ts + out.push(...autoRecipeFindings(manifest)); +``` + +- [ ] **Step 5: 跑测试与全量门禁** + +Run: +```bash +pnpm exec vitest run apps/api/src/modules/activity-registry +pnpm gates +``` +Expected: 全绿。**现有三个活动包(E04 / I01 / W05)必须照装不误** —— 它们没有 `auto`,这一段整个不进。 + +- [ ] **Step 6: 提交** + +```bash +git add apps/api/src/modules/activity-registry +git commit -m "$(cat <<'EOF' +feat(activity-registry): 配方静态审 —— 写错地址在装包那一刻就拒 + +与 refersTo 目标检查同款:代码错是必然的错,而必然的错只有在装的时候被拦下才算好查。 +台账地址 / 列名 / 模型名 / 视图模版 / submit 的验收清单逐条核; +analyze 的出参没人消费报 warning —— 那是白烧一次模型调用。 + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet +EOF +)" +``` + +--- + +### Task 10: `data-options` —— 下拉的选项从台账现读 + +**Files:** +- Modify: `apps/api/src/modules/interaction/internal/domain/view-render.ts` +- Modify: `apps/api/src/modules/interaction/__tests__/view-render.test.ts` +- Modify: `docs/HTML报告生成规范.md`(§4 绑定全表加一行) + +**Interfaces:** +- Consumes: 现有 `resolveSlot` / `escapeHtml` / `displayValue` +- Produces: `function fillOptions(html: string, scope: Readonly>): string`,并由 `renderView` 在**最前面**调用 + +- [ ] **Step 1: 写失败的测试** + +追加到 `apps/api/src/modules/interaction/__tests__/view-render.test.ts` 末尾: + +```ts +describe('data-options:下拉的选项从台账现读', () => { + const scope = { + 'parts.chip_type': [ + { type_id: 1, type_name: 'DC-DC 降压稳压器' }, + { type_id: 2, type_name: 'LDO 线性稳压器' }, + ], + chip_type_id: 2, + }; + + it('把选项铺进去,并留一个空选项(「还没选」要说得出来)', () => { + const html = ''; + const out = renderView(html, scope); + expect(out).toContain(''); + expect(out).toContain(''); + expect(out).toContain(''); + }); + + it('选项文字要转义 —— 台账里的值是人填的', () => { + const out = renderView( + '', + { 't.t': [{ v: 'x', l: '坏' }], c: '' }, + ); + expect(out).toContain('<b>坏</b>'); + expect(out).not.toContain('坏'); + }); + + it('地址取不到 → 只留那个空选项,不塞假数据(界面不撒谎)', () => { + const out = renderView('', {}); + expect(out).toContain(''); + expect(out).not.toContain('undefined'); + }); + + it('行内的下拉:每一行都铺同一份选项,选中的按各行自己的值', () => { + const html = + '' + + '' + + ''; + const out = renderView(html, { ...scope, 'mine:a.b': [{ chip_type_id: 1 }, { chip_type_id: 2 }] }); + expect(out.match(/