Archived
Compare commits
126
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ed997187e | ||
|
|
0e02bb9411 | ||
|
|
3b9c559a47 | ||
|
|
4174c5f194 | ||
|
|
3c563525f1 | ||
|
|
e474275b28 | ||
|
|
cec19adc85 | ||
|
|
8314148ebc | ||
|
|
4d2c3657f9 | ||
|
|
f5435f4c9c | ||
|
|
445aa5ef22 | ||
|
|
ce72da77eb | ||
|
|
e47ce92702 | ||
|
|
8c5f3a7736 | ||
|
|
04ec718a66 | ||
|
|
228739eaca | ||
|
|
9412dd7aa8 | ||
|
|
68faeb4f50 | ||
|
|
1de4ab9862 | ||
|
|
c4219a7b7d | ||
|
|
7515885afa | ||
|
|
26db6c685b | ||
|
|
05ebecb541 | ||
|
|
51914292dc | ||
|
|
f6c8dccb4f | ||
|
|
66be5bab6e | ||
|
|
794af58902 | ||
|
|
11375eaf93 | ||
|
|
5a7bb9b58f | ||
|
|
e2db7cfa01 | ||
|
|
e18611006b | ||
|
|
1e3e5d40c6 | ||
|
|
e5ca9caaa6 | ||
|
|
2265ab7833 | ||
|
|
c8988c912f | ||
|
|
11e41b79c5 | ||
|
|
1373eb4980 | ||
|
|
f0789a0f13 | ||
|
|
18de7653bd | ||
|
|
64fe4b43c9 | ||
|
|
0f40bae0d1 | ||
|
|
e38631224a | ||
|
|
4076621e04 | ||
|
|
45cec82984 | ||
|
|
9f5b1c99d3 | ||
|
|
764dab342d | ||
|
|
9e2f7387b2 | ||
|
|
8586f7dac8 | ||
|
|
d420284887 | ||
|
|
6636abfc39 | ||
|
|
67fa226380 | ||
|
|
832c662fdb | ||
|
|
57870329e6 | ||
|
|
d9bcce9516 | ||
|
|
a408542bed | ||
|
|
882f22a01e | ||
|
|
716f17ee2a | ||
|
|
77df79d6fb | ||
|
|
4bd1a04511 | ||
|
|
04675e814d | ||
|
|
59afcbfdbd | ||
|
|
10f529d03c | ||
|
|
abe92e0531 | ||
|
|
40daa6c9f7 | ||
|
|
fdcd1f88a7 | ||
|
|
c60a2adf43 | ||
|
|
b2159022bc | ||
|
|
392d085f51 | ||
|
|
725f1ecb0c | ||
|
|
cd88d0e026 | ||
|
|
8c125042ba | ||
|
|
1c6978818c | ||
|
|
89f0be8b81 | ||
|
|
60955171da | ||
|
|
18d7147ebd | ||
|
|
02e0d1206d | ||
|
|
defa048270 | ||
|
|
ab0dfd8b49 | ||
|
|
f22402e155 | ||
|
|
f00ed5c011 | ||
|
|
d244864317 | ||
|
|
7b8d3b559b | ||
|
|
94b664bc85 | ||
|
|
65a4c7a7dd | ||
|
|
c3fed573ee | ||
|
|
c60c882f06 | ||
|
|
642bdcef3b | ||
|
|
588d5c2dd4 | ||
|
|
2d20202b1d | ||
|
|
647b86753a | ||
|
|
a09acc7504 | ||
|
|
198d12af1b | ||
|
|
6212b34e39 | ||
|
|
3190392a1d | ||
|
|
fcf1822233 | ||
|
|
bd26756da9 | ||
|
|
6cbac3220c | ||
|
|
dba6071772 | ||
|
|
2bb974a10d | ||
|
|
fc48920c32 | ||
|
|
154b222860 | ||
|
|
09885c3dbd | ||
|
|
35ef0f1790 | ||
|
|
6f721ffc6d | ||
|
|
81fb8179c2 | ||
|
|
9ab2f4b695 | ||
|
|
3636496774 | ||
|
|
f59e237035 | ||
|
|
343a281af5 | ||
|
|
80e9b01f84 | ||
|
|
fbd5867206 | ||
|
|
e9da8d25f5 | ||
|
|
fe8de5319c | ||
|
|
b0a3cc3a55 | ||
|
|
58ff4f1b90 | ||
|
|
f950a4419d | ||
|
|
fc8b3c504b | ||
|
|
16fe8ba71b | ||
|
|
04234d2fda | ||
|
|
b9951483bc | ||
|
|
2d1a2ff442 | ||
|
|
91840067d4 | ||
|
|
a370616f01 | ||
|
|
2cef6f7489 | ||
|
|
e1d8d86b16 | ||
|
|
c1137fb09a |
@@ -1607,6 +1607,33 @@
|
|||||||
比对依据可见,模型照它写影响、不自己重新解释值;契约描述与 S3 / P-08 剧本明写读法(示例用中性数 1~10,
|
比对依据可见,模型照它写影响、不自己重新解释值;契约描述与 S3 / P-08 剧本明写读法(示例用中性数 1~10,
|
||||||
不用真数据里的 4.5~40 —— 模型会把示例当这单的值)。W05 升版装上。3 测(11+7 种写法)。
|
不用真数据里的 4.5~40 —— 模型会把示例当这单的值)。W05 升版装上。3 测(11+7 种写法)。
|
||||||
|
|
||||||
|
- **AI 把原 PN 的规格书填到了候选料号头上:人填的表要声明,判据不摆给还欠卡的那一步**(2026-08-28
|
||||||
|
柴浩实测单 6ddcecf8,连撞五拍熔断):P-10 资料记录的标题 / 版本 / 链接与台账里 `LM2596S-5.0` 那一行**逐字相同**
|
||||||
|
—— 不是幻觉,是「手边有一行看着对的就拿来用」(那一步 reads 摊开了 `parts.document`,视野里唯一一份真资料
|
||||||
|
就是原 PN 的)。三层合谋:①把 P-08 的回卡认成 P-10 的(P-10 的卡从没发过);②我 08-27 给这一步加的 doneWhen
|
||||||
|
把「资料必填不缺」摆成目标、后面还跟「成立了就 finish」,**与同一份提示词里「唯一合法的动作是先发那一张卡」
|
||||||
|
直接打架**;③它顺手把 `exists_status` 改成 created,按剧本发卡名单自己空了 —— 一个合法动作都不剩。
|
||||||
|
止血两刀:**①`PlanStep.humanWrites`**(契约)—— 声明的台账 `write` 当场拒并指路「去发那张卡,值由人填」,
|
||||||
|
`drop` 不拦(写错了得撤得掉);判法认点名的项、点不出退回当下在办的那一步(压拍时骨架写在上一步名下正好放行)。
|
||||||
|
**②还欠一张卡的那一步不摆 doneWhen** —— 判据同时是摆在模型眼前的目标,而它此刻只许发卡。
|
||||||
|
W05@1.6.5 声明 `humanWrites: ['parts.document']` 并从 writes 去掉。4 测(墙 / 放行 / drop / 声明)。
|
||||||
|
**一个闲着的模型 + 一个它够得着的判据 = 它一定会去凑那个判据。**
|
||||||
|
用户同时点出更大的一条(记 debt,未做):**存在性核对 + 派数据工单是确定性流程,根本不该交给模型** ——
|
||||||
|
平台缺「确定性步骤」(纯工具只读、判定表不写台账、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 晚,用户定:只摆最近一次已放行
|
- **运营中心活动详情沙箱换成「关键台账数据」看板,数据接真台账**(2026-08-27 晚,用户定:只摆最近一次已放行
|
||||||
实例、替换掉全流程分支图):模版搬自 `docs/ss02-act-05/ACT-WORK-05-关键台账数据.html`(P-01 KV / P-08 逐颗 +
|
实例、替换掉全流程分支图):模版搬自 `docs/ss02-act-05/ACT-WORK-05-关键台账数据.html`(P-01 KV / P-08 逐颗 +
|
||||||
参数瓷贴 / P-11 主记录 + 差异明细 / P-10 / P-06 本单 + 同料号历史),徽标文案全由 `data-class-of` 投影。
|
参数瓷贴 / P-11 主记录 + 差异明细 / P-10 / P-06 本单 + 同料号历史),徽标文案全由 `data-class-of` 投影。
|
||||||
@@ -1619,6 +1646,420 @@
|
|||||||
service 3 + store 1 + 包内 4;真库渲染 `v-ready`、INS-21 / `W05-20260827215148`。欠账见 debt(挑单 / 行级来源 /
|
service 3 + store 1 + 包内 4;真库渲染 `v-ready`、INS-21 / `W05-20260827215148`。欠账见 debt(挑单 / 行级来源 /
|
||||||
`vtok` 三份 / `data` 装包静态审)。
|
`vtok` 三份 / `data` 装包静态审)。
|
||||||
|
|
||||||
|
- **确定性步骤已落地**(2026-08-29,ADR-014):`PlanStep.auto` 声明式配方,五个动词
|
||||||
|
(`project` / `analyze` / `card` / `submit` / `say`)、四种地址、嵌套两层封顶;引擎在拍首跑,
|
||||||
|
**发了卡就收拍(零 token)**,有 `guide` 的照常问模型。`analyze` 是**一次结构化 AI 调用** ——
|
||||||
|
系统组装入参、声明出参 schema、系统落表,**不给工具不给记忆不多轮**。
|
||||||
|
三条纪律进了规范 §4.6:**行由确定的那一侧决定不由 AI 决定** / 判据不许编数不出来的数 /
|
||||||
|
每一笔之前刷读数。静态审在装包时把地址、列名、模型名、视图、submit 的验收清单逐条核死。
|
||||||
|
配套四件:`data-options`(下拉选项从台账现读)· 件上保存后 `wake`(「保存即铺格」)·
|
||||||
|
审核卡放宽到单选项 · 卡答复即收步。**E04 / I01 / W05 一个字没改** —— 它们的剧本里没有 `auto`。
|
||||||
|
- **ACT-WORK-05 从『什么都丢给 AI』重写成『系统确定性取数 → AI 只在真需要判断处出场一次』**
|
||||||
|
(2026-08-29/30,`part-substitution-assessment@2.1.6`,design spec
|
||||||
|
`docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md`,用户原话立场:「不是把
|
||||||
|
所有东西都丢给 ai 来处理…一定是系统先确定性的从数据库中取出数据后交给 ai 分析」)。
|
||||||
|
九步砍成**四步**(S1_inputs 核对入参与补数据 → S2_assess **整个活动唯一一次** `analyze` →
|
||||||
|
S3_review 结论审核 → T_release 落账放行),**全程零 `guide`**,行为整体搬进 `PlanStep.auto`
|
||||||
|
声明式配方(ADR-014 第一个真实用户);零工具、零技能,两枚旧工具与两张 DMN 一并退役 ——
|
||||||
|
规则只许有一处真源,活在配方的 `when` 链里。新建两张类型字典
|
||||||
|
(`parts.chip_type`/`parts.type_param`):按芯片类型(不是按料号)标一次核心参数字段与
|
||||||
|
硬软约束,「两颗料必须用同一套参数名」从此是**结构保证**,原先靠测三次都没拦住的
|
||||||
|
自指引用墙退休。三张件(数据工单 · 结论审核 · 落账确认),数据工单按缺口条件发卡,
|
||||||
|
零卡直通是常态路径之一。
|
||||||
|
**e2e 五案例的真实战果**(2026-08-30,连续多轮真跑,详见
|
||||||
|
`.superpowers/sdd/2026-08-29-act-work-05-rewrite/task-11-report.md`):候选缺库·新建类型 /
|
||||||
|
都在库·零卡直分析 / 原PN缺库三例 `released`、**被拒 0**、**模型调用恰 1 次**、发口
|
||||||
|
`assessment-released` 全对——**「候选缺库·新建类型」这条记录后来被全分支终审推翻**
|
||||||
|
(见下方「全分支终审」条):那次 `released` 靠脚本走 API **显式传 `type_id:'90'`**,
|
||||||
|
没有走真人在浏览器里点「新建类型」那条路;`e2e-a2.mjs` 自己的注释原话是
|
||||||
|
「这条路径连真人在浏览器里都走不通」。**该走的那条路当时藏着 Critical 1**(见下方条目),
|
||||||
|
这里的「released」只证明了 API 直传能通,不证明「新建类型端到端通了」。
|
||||||
|
类型不一致·硬拦那例撞上**已记入 `docs/debt.md`** 的平台死锁
|
||||||
|
(`prune` 与「数据工单确认即入库」的架构性冲突:人先交一次、字段已落地,再改类型选择,
|
||||||
|
`prune` 想撤已落地的陈旧行被显式挡住,两条机制各自都对、合起来死锁);两颗都缺库那例
|
||||||
|
卡在「决策卡答完之后不再推进」,`advancing_until` 反复核过是空、租约与陈旧进程两种假说
|
||||||
|
均已排除,**真因未定位**,如实记录未解决,不强装成功。**`judgement_class` 逐行核对通过**
|
||||||
|
(7/7:使能阈值 / 工作温度 / 开关频率 / 输入电压范围 / 输出电压 / 输出电流 / 静态电流,
|
||||||
|
AI 判的 `difference_status` 与规则链算出的 `judgement_class` 逐行对上,`risk_level`
|
||||||
|
联动也对)—— 这条验证之所以要点名贴出来,是因为它曾经**逐行全错**。
|
||||||
|
**地基缺陷**:活动清单存进 `sys.activity_package.manifest` 这个 jsonb 列后,PostgreSQL
|
||||||
|
按「先短后长、同长字典序」重排了所有嵌套对象的键,而 `project`/`analyze` 的 `fields`
|
||||||
|
原来靠 `Object.entries` **声明顺序**求值(后一字段靠 `ctx.computed` 看前一字段已算出的
|
||||||
|
值)——「声明序 = 依赖序」这条从没写下来的假设一进数据库就被打破,且**不报错**、
|
||||||
|
隔离测试也复现不出来(它直接读 `dist/manifest.json`,不经数据库往返),只有真跑 e2e
|
||||||
|
才现形。W05 实证:合成列 `judgement_class` 被排到它依赖的 `difference_status` 前面,
|
||||||
|
读到 `undefined`,七行**全部**误判(`difference_status` 本身走 `lookup` 不依赖字段序,
|
||||||
|
所以是对的 ——「一个对一个全错」的怪现象由此而来)。**修法是把字段求值从『声明序』
|
||||||
|
改成『依赖拓扑排序』**(Kahn 算法,新增 `packages/utils` 的 `topoOrder`,契约纯新增);
|
||||||
|
真正的环在装包静态审当场拒装,不兜底、不静默放行。四条新纪律入规范 §4.6(六条→十条):
|
||||||
|
`fields` 顺序不可依赖拓扑排序之外的任何假设 / 跨笔顺序仍要人自己排(一笔读的是另一笔
|
||||||
|
写出来的数就得排在后面)/ 方向相反的依赖拆两笔写、中间夹子表 / `gateLedgers` 同时管
|
||||||
|
「闸要求非空」与「确认时并进正式表」两件事,漏想一半就会悬空引用或永久卡死。
|
||||||
|
`pnpm gates` 退出码 0(2447 passed | 34 skipped)。
|
||||||
|
- **W05 数据工单换版面已退回**(2026-08-31 用户:「改了 ui 之后这个版本我感觉有点问题」):
|
||||||
|
`report.ts` / `target-style.ts` / `activity.test.ts` 三个文件**逐字节恢复**到换版面之前
|
||||||
|
(45cec82 的样子),包版本 **2.3.0**(内容是旧版面 —— `store.manifest` 不传版本时取
|
||||||
|
**最近安装**的那一条,不是版本号最大的,所以退源码必须配一个新号装进去才真生效)。
|
||||||
|
**换版面那一版原样活在分支 `feat/w05-view-swap`**(HEAD `1373eb4`),随时切得回去;
|
||||||
|
下面这条记的是它的账,留着当索引 —— 里面三条渲染机制的实锚(宿主闭合标签 / 行内
|
||||||
|
class-of 只认行作用域 / data-by 回落)与平台侧探针测试**不随退回作废**,它们钉的是
|
||||||
|
平台行为,测试与目标件 html 都还在主支上。
|
||||||
|
- **(已退回,见上条)W05 数据工单换版面 + 两条渲染机制的实锚**(2026-08-30,`part-substitution-assessment@2.2.2`,
|
||||||
|
用户给了新目标件 `docs/ss02-act-05-new/ACT-WORK-05-数据工单-PN主数据创建.html`):
|
||||||
|
`input-supplement` 整张照它重排 —— **两条并列轨道**(每颗料一条:档案卡 + 主数据 + 类型选择)
|
||||||
|
+ `<details>` 新增类型面板(chip_type / type_param 两张表原样搬入,独立「核心参数字段」区退役)
|
||||||
|
+ 核心参数值**瓷贴单块平铺**;绑定面一列不差(四张台账 / 四道闸 / 两处加行 / 终审 C1 的
|
||||||
|
disabled type_id / 主键钥匙)。目标件脚本驱动的状态全部无脚本翻译:保存即铺格顶掉「选类型
|
||||||
|
即时加载」、details 顶掉 `.newtype.show`、步骤圆点按基座数点亮、gateprog 进度条如实退掉
|
||||||
|
(分母不是基座现有的数)。**三条机制账**,全有测钉死:
|
||||||
|
①**`data-rows` 宿主的闭合标签就是行模版的截刀** —— ROWS 正则按宿主自己的闭合标签非贪婪
|
||||||
|
截取,`<div class="lanes">` 被行模版里第一个 `</div>` 截半,第二行嵌进第一行没闭合的标签里
|
||||||
|
档案卡当场串行(A2 立的「行容器用行内不会再出现的标签」说的正是**宿主**,它自己就是
|
||||||
|
`<article>` 包 `<section>`);防再犯钉用**配平判法**(「不含闭合标签」是空话 —— 非贪婪截取
|
||||||
|
本来就停在闭合标签之前,那条断言永远不会红);
|
||||||
|
②**行内 `data-class-of` 只认行作用域**(renderRows 末段 renderTemplate 拿的是行对象),
|
||||||
|
作用域级读数在行内投不出 —— 圆点的投影挂在行外的 `.pj` 包裹层,后代选择器点亮;
|
||||||
|
③**`data-by` 行缺键落回外层同名字段**(终审 I3)真咬人:XL 没选类型,`sub_eval.case`
|
||||||
|
摊平的 `chip_type_id` 顶进来,查名 pill 摆出它没有的类型 —— 守卫认 `selectCurrent` 按
|
||||||
|
**这一行自己的值**写下的 `option[selected]`,行里没真选上名字不摆(判断全在样式表)。
|
||||||
|
同批逮到并修死:profile 的编辑闸在第一版 body 里**整个漏掉**(宿主在而闸不在,XL 必填
|
||||||
|
永远补不上 —— 「四道闸」旧断言只查 data-edit 宿主没查 [data-edit-for] 闸,补强)。
|
||||||
|
另一半探针钉进平台测试:嵌套 data-rows 在唯一 data-edit 宿主里**必然把一颗料的格子并成
|
||||||
|
一条记录**(rowOf 认宿主直接子元素;真实 renderView+collect 实测,
|
||||||
|
`view-container-collect-div-rows.test.ts`)—— 步骤③不按轨道分两栏是机制不是偏好。
|
||||||
|
**验收**:活动自测 62 → 74(新钉逐条变异验证);`pnpm gates` 退出码 0(2497 测);
|
||||||
|
2.2.0/2.2.1/2.2.2 三连装库 SQL 逐次核过;浏览器全流程真人路 —— 起单 → 两轨道核对 →
|
||||||
|
保存即铺格(XL 七格当场铺出)→ 填参数 → 确认写入办结 → S2 分析推进;新建类型走种子行
|
||||||
|
续号 `03`、DC-DC 原行未被覆盖、下拉即时长出;类型不一致护栏真亮 v-2 再熄灭。
|
||||||
|
终审评审 0 Critical / 0 Important / 1 Minor(注释漂移,已修)。跑完 reset-runs,
|
||||||
|
主数据与活动缓存零残留。
|
||||||
|
|
||||||
|
- **W05 三张工单件视图层按 C 端重设计**(2026-08-31 用户定:「不要后台管理端的样子,
|
||||||
|
要给客户用的 C 端界面;可加主题 SVG 与动画;只保留功能,视图层可以推翻重新设计」,
|
||||||
|
`part-substitution-assessment@2.6.0`):「精密检测报告」语言 —— 深色电路板夜景 hero
|
||||||
|
三张各一幕(建档台:两颗料的芯片铭牌由 `mine:sub_eval.part` 铺行、不在库虚线描边 /
|
||||||
|
对决量尺:原 PN→候选 PN + 推荐等级徽章 / 落账席:慢转印章环 + 一句话结论),正文瓷白
|
||||||
|
报告版面(丝印小标 / 比对光谱分布带 / 落账三卡大数);动画全 transform/opacity、
|
||||||
|
reduced-motion 全静;验收两件仍是验收绿 #0aa294。**功能绑定逐项保留**:可编辑表 DOM
|
||||||
|
结构逐字节不动(收值链路按结构认元素),机制规则(计数器/种子行显隐/gate-bar :has/
|
||||||
|
overflow-x)逐字保留仍被测试钉着。设计系统收进 `views/style.ts`,`target-style.ts`
|
||||||
|
退役(目标件时代的锚),report.ts 的 vtok/badgeCss 收归 badge.ts(销一笔 debt)。
|
||||||
|
离线预览自审(真 renderView + 造景数据,不碰测试台)逮到并修掉三洞:kv 占位破折号
|
||||||
|
连嵌套徽标一起吃(改只认直接子级)、光谱条里渗计数字、加行钮没 base display:none。
|
||||||
|
104 包内测 + 2516 全仓绿,两条新锚变异验证过,2.6.0 装库 SQL 核过。
|
||||||
|
(2.5.0 是同日另一会话的启动表单芯片类型联动 —— 两会话同 checkout,版本号先问库。)
|
||||||
|
|
||||||
|
- **W05 数据工单三病一改:档案卡上桌,prune 跳过已落地**(2026-08-31 用户实测三连 +
|
||||||
|
「设计只是头上加了动画」,`part-substitution-assessment@2.7.0`):
|
||||||
|
①「类型自己给上了」= data-by 行缺键落回外层(debt I3 原样咬人)—— 查名 pill 摆出案卷锚
|
||||||
|
(原 PN)的类型;治:守卫认 `selectCurrent` 按**行作用域**写的 `option[selected]`,
|
||||||
|
真选上才摆名、没选摆「待选择」胶囊。**守卫必须单层 `:has`** —— `:has()` 里嵌 `:has()`
|
||||||
|
是非法选择器,整条被浏览器静默丢弃(第一版就栽在这,预览截图逮回),挂共同祖先上写。
|
||||||
|
②「品牌…点保存根本不会保存」= 块① 行源是 `sub_eval.part` 而那张案卷表没有主数据列,
|
||||||
|
`renderTemplate` 只认行作用域 —— 保存真落草稿、显示与孪生件永远空白(四步重写后的潜伏缺陷,
|
||||||
|
真人一填才现形;e2e 走 API、旧浏览器走查在 swap 版上,两条线都照不到)。治:行源改
|
||||||
|
`mine:parts.profile` 本身(S1 ④ prune:true 恒有两行),行里天生有全部列。
|
||||||
|
③「改回同一类型,下面还是两个类型」= ②③ 自投影为绕「已落地行撤不掉」的死锁特意不带
|
||||||
|
prune,没落地的陈旧行也跟着积。治在平台:`draftDrop` 加配方专用旗 `skipLanded`
|
||||||
|
(跳过并出声「N 行已随数据工单入库、这一笔不清它」,不炸),②③ 恢复 prune:true ——
|
||||||
|
H35 案例 2 不再死锁,debt「prune 与确认即入库的架构性冲突」销账。quiet 拍仍不撤行(F3),
|
||||||
|
清理落在每次确认工单那一拍。
|
||||||
|
④设计:块① 从 11 列横滑表推翻成**电路板工作台** —— 深色板底上两张可就地编辑的芯片
|
||||||
|
档案卡(SOIC 封装 SVG + 角色/在库徽 + 类型选择行 + 双栏主数据格),编辑态整卡变表单;
|
||||||
|
三级占位(必填红/建议橘)上卡与参数值表(徽标包装的必填格用单层 `:has` 认裸 class 补胶囊);
|
||||||
|
参数值表所属料号列加角色徽。测:107 包内(守卫/行源/嵌套零 data-col 三锚变异验证)+
|
||||||
|
auto-runner skipLanded(变异验证)+ 2520 全仓;2.7.0 装库 SQL 核过。
|
||||||
|
另:1838474「在库」是真的 —— 用户此前某单 land_master 落过它(正式表 chip_type_id 为空)。
|
||||||
|
|
||||||
|
- **W05 第二轮:比对基准合一卡 + `prune:'live'` + 蓝图板底**(2026-08-31 用户:「类型和字段
|
||||||
|
合并成一个卡…两个芯片同类型就只显示一种,现在只变多不变少」「卡片容器黑色和页面不搭」,
|
||||||
|
`part-substitution-assessment@2.8.1`):①类型+字段并成**比对基准卡**(只读、跟手)——
|
||||||
|
不列 mine: 镜像(积旧选择,撤行只在确认拍跑),按案卷锚嵌套查字典,保存即换脸、同类型
|
||||||
|
恰显一种;新建类型收进 details 抽屉(零脚本),编辑表原样在抽屉里。②契约新增
|
||||||
|
`prune:'live'`(quiet 拍也撤,**只给人加不了行的表**用 —— F3 防「人刚加的行被撤」,
|
||||||
|
这种表没有该风险;有加行钮的表永远不许用):W05 参数骨架改 'live',改类型参数行跟手换;
|
||||||
|
static-audit 盖住 'live',规范 §4.6 纪律 5 补反面特例。③档案卡板底深色→**浅色蓝图**
|
||||||
|
(强调色晕染+淡格线),整页只剩 hero 一处深色。锚测各变异验证;2.8.1 装库核过;
|
||||||
|
:8787 watcher 又断,应急手起 node dist/main.js(14:41)。
|
||||||
|
|
||||||
|
- **W05 第三轮:数据工单去表格化 —— 翻面芯片卡 + 平台多宿主收值**(2026-08-31 用户:
|
||||||
|
「界面东西太多做成几个区域;类型与字段做成和芯片卡差不多的卡片;芯片卡与核心参数值
|
||||||
|
合并,点击翻转、背面写参数、悬浮看得出能翻;这个界面都不再需要表格展示数据」,
|
||||||
|
`part-substitution-assessment@2.9.0`):主画面零表格 —— 基准卡瓷贴化(类型丸+字段
|
||||||
|
瓷贴),芯片卡纯 CSS 翻面(checkbox+:has 零脚本,悬浮提示丸+侧倾;主数据编辑锁正面、
|
||||||
|
参数编辑自动翻背面),背面参数瓷贴就地可改;说明四条收折叠;表格只剩新建类型抽屉
|
||||||
|
(加行机制认 tr)。**平台扩面:ui-kit ViewContainer 多宿主收值** —— 同一地址
|
||||||
|
querySelectorAll 全数进来,**格子只归最近的 data-edit 祖先**(嵌套异地址排除;不带
|
||||||
|
data-edit 的嵌套 data-rows 行为原样,两条 collect 旧钉测不动);addRow 仍单宿主。
|
||||||
|
三脚各留一条:CSS 注释写「data-col=」原文会踩翻别件的字面断言(选择器与注释都不许);
|
||||||
|
`.flip-cue i` 特异度压过 `.cue-back{display:none}`;host 计数断言别拿渲染后的数去数
|
||||||
|
模版源码。ui-kit multihost 3 测(变异验证)+ 包内 110 + 2528 全仓;2.9.0 装库核过;
|
||||||
|
浏览器实测点卡真翻、参数编辑双卡自动翻背成表单。
|
||||||
|
|
||||||
|
- **W05 第四轮九连改:笔↔软盘卡角编辑 + 只选不建**(2026-08-31 用户一次点齐九件,
|
||||||
|
`part-substitution-assessment@2.10.0`):①编辑闸两行退役 → 卡右上角笔(开当前面)↔
|
||||||
|
软盘(存)+叉(弃),按钮即 data-edit-open/save/cancel 触发器,按面显隐全在样式表;
|
||||||
|
②ui-kit `mirrorTwins` 修「保存一瞬间下拉变数字」—— 镜选项文案不镜 value,顺手把
|
||||||
|
selected 拨到人选项(守卫 CSS 认它);③渲染器 twinInputs 认 `data-ph` 落 placeholder;
|
||||||
|
④参数说明空着什么都不摆;⑤芯片档案区提到比对基准前;⑥**新建芯片类型整条路移除**
|
||||||
|
(只选已有):抽屉/种子行/Critical-1 修法/thead 回归网退役,S1 配方八笔→六笔(②③
|
||||||
|
自投影删),gateLedgers 收窄 profile+parameter,e2e 案例 0 改选既有 type_1,七条退役
|
||||||
|
钉测 it.skip 留墓志铭;⑦题头回原目标件浅蓝渐变(hd-light,夜景只留验收两件);
|
||||||
|
⑧对话区执行卡不摆「参考信息」(basis 随卡数据照旧);⑨比对基准降噪:字段丸 +
|
||||||
|
说明悬浮气泡。2525 全仓四门绿,2.10.0 装库六探针核过,:8787 重启载入新渲染器。
|
||||||
|
|
||||||
|
- **W05 第五轮:按卡独立编辑 + 顶条合并点名**(2026-08-31 用户四连,
|
||||||
|
`part-substitution-assessment@2.11.0`):①「不要做联动」—— ui-kit 新增 `scopedTargets`:
|
||||||
|
开合作用域跟着触发钮走(钮在本地址宿主的行里→只这一行;钮在别的宿主行里→行内的
|
||||||
|
本地址宿主;外部闸→全宿主老行为),收值仍按地址全量、「只写真改了的行」服务端把关;
|
||||||
|
编辑态 CSS 全改按卡(.dossier.on),同地址同时只开一张卡(快照键按地址,双开互踩)。
|
||||||
|
②逐块「还差 N 行必填」红带撤下,_missing/_missing_keys 并进顶条「还差什么」两行子行
|
||||||
|
(v-0 自收)。③比对基准/芯片档案区序换回(基准在前)。④比对基准换层叠双封装 SVG
|
||||||
|
(t-ghost 淡剪影 —— 一类而非一颗)。ui-kit 35 测 + 包内 105 + 2528 全仓;2.11.0 装库
|
||||||
|
五探针核过;浏览器实证单卡独立翻背编辑。
|
||||||
|
|
||||||
|
- **W05 第六轮:确认闸条件档 + 量高可缩 + 互换动画**(2026-08-31 用户三连,
|
||||||
|
`part-substitution-assessment@2.12.0`):①「类型不一致还能确认」—— 平台加**确认闸
|
||||||
|
条件档**:配方 card 声明 `gateWhen`(S-FEEL 只读基座读数)+`gateWhenSay`,受理回传前
|
||||||
|
`submitGate` 按声明表现读 rows/missing/tally/distinct 现算,为假 400 用发卡人那句话;
|
||||||
|
静态审同 when 一套;评估不出的条件不成墙。W05 钉 `parts_profile_distinct_chip_type_id
|
||||||
|
<= 1`(钉 profile 本表零滞后 —— 钉 sub_eval.part 要等配方 tick 会误拦刚存完就确认的人),
|
||||||
|
profile.chip_type_id 加 distinct。②「底部没东西还能滑一截」—— ViewContainer 量高
|
||||||
|
documentElement.scrollHeight 以 iframe 高为下限,**只涨不缩**;改 body 优先(margin 0
|
||||||
|
的 body 才是内容自己的高)。③卡间动画换「可替换」语义:对向双信号点互穿(卡后走、
|
||||||
|
缝里现身)+ 中央 ⇄ 徽呼吸。2531 全仓;2.12.0 装库核过;:8787 重建重启。
|
||||||
|
|
||||||
|
- **W05 互换动画第二稿:与卡同级的实体动画道**(2026-08-31 用户:「不要做到背景板上会被
|
||||||
|
卡片覆盖,和卡片做到一级,压缩一点卡片」,`part-substitution-assessment@2.12.1`):
|
||||||
|
.dossiers 三列网格(1fr·76px·1fr),中间列是实体动画道 —— ⇄ 徽呼吸 + 双点绕徽轨道
|
||||||
|
对转(sw-orbit),总线虚线在道里连两卡;窄屏单列收道。浏览器两帧相位实证。
|
||||||
|
|
||||||
|
- **复核轮审核人说了算 —— AI 不许拿硬规矩顶回去**(2026-09-01 用户第二次实测:「重做还是没有
|
||||||
|
修改掉内容」):他写「静态电流结论改成不匹配」,而两颗料的静态电流都是 5mA,AI 于是搬出
|
||||||
|
第一轮那份规矩里的第 3 条(硬约束不因措辞变成 match)把他顶了回去 ——
|
||||||
|
`evidence_note` 白纸黑字:「维持上一轮 match…硬规矩要求按实际值判定,不能因措辞改变结论」。
|
||||||
|
**行的范围这次是对的**(只把那一条交给 AI、只写回那一条,其余六条一个字没动),错在**谁说了算**。
|
||||||
|
两处想岔了:①那条规矩管的是**它自己判**的时候别被措辞带偏,不是用来否决审核人的;
|
||||||
|
②方向也反了 —— 那条守的是「不许把不满足说成满足」(危险方向),而他要的是**收紧**结论;
|
||||||
|
③更根本的是:审核人在件上**本来就能亲手改任何一条四态**,AI 在重判这条路上顶回去,
|
||||||
|
只是让同一件事在两条路上给出相反的答案。
|
||||||
|
改:复核指令明写这条覆盖 —— **他点名要什么结论就落什么**(「不匹配」= mismatch、「有条件」=
|
||||||
|
conditional…,不要跟他讲道理、不要顶回去);落的同时 `evidence_note` 记两句
|
||||||
|
「按谁的意见」+「数据面是什么」(例:「按审核人意见调整为 mismatch:<原话>。数据面:两侧均为 5mA。」)
|
||||||
|
—— 下一个人回看时要能一眼分清**这是数据判出来的**还是**这是人定的**;
|
||||||
|
他只是提疑问 / 要补依据的,照旧由 AI 复核后自判。
|
||||||
|
e2e 案例 0 的打回意见同批换成**点名要结论**那一种,新断言逐字核那一行真变成了他要的值
|
||||||
|
(「打回点名要的结论真落下去了(使能阈值.difference_status = conditional)」)。
|
||||||
|
**同批把这一轮的交代摆进对话区**(用户接着提:「需要把 ai 的修改意见通过右边 ai 聊天窗口
|
||||||
|
展示出来,不然都不知道发生了什么」):复核完说一句「按你的意见复核了 N 条 —— <参数名>:<判断依据>」,
|
||||||
|
摆的是**件上那一行自己的话**,不另编一句(对话区与件上说的是同一件事,就该是同一句话)。
|
||||||
|
**它必须排在卡之前** —— 发了卡这一拍就收了,排在卡后面的 `say` 要等下一拍才跑:
|
||||||
|
原来那句「差异结论已送审,请核对」实测正是在**人答完之后**才出现的,而且它与卡自己的 `ask`
|
||||||
|
本来就是同一件事,一并退役。
|
||||||
|
|
||||||
|
- **落账席那枚印章把旁边的结论压住了**(2026-09-01 用户:「有条件推荐这个 svg 挡住旁边的字了」,
|
||||||
|
`@2.24.0`):**在浏览器里量出来的账** —— 徽章那一坨 `.seal-core` **139px**,而印章圈定死 **112px**,
|
||||||
|
溢出 26px;右边那句一句话结论从 603px 起,正好被压住。病根是徽章按自己的字宽撑开、
|
||||||
|
又带着 `white-space:nowrap` 缩不了,而圈是定死的圆(`place-items:center` 只会让它对称地溢出去)。
|
||||||
|
治法两条一起:核心不许超过圈的内径(`max-width:calc(100% - 16px)`)、圈里那一枚字号收小并
|
||||||
|
**允许折行**(以后有更长的等级名就两行,圆里两行照样是圆)。改完再量:核心 94px、圈内余 9px、
|
||||||
|
「有条件推荐」一行放得下、结论那段完全露出来。**视觉的账在真 DOM 上量,不在脑子里推**
|
||||||
|
—— 离线渲一份真件挂本地服务、浏览器里 `getBoundingClientRect` 取数,前后各一次。
|
||||||
|
|
||||||
|
- **「最后落账的步骤直接没了」= 单子先被烧死了**(2026-09-01 用户送来的 `INS-80`,终态 `exhausted`):
|
||||||
|
他在结论审核上连打回四次,每打回一次配方就重发一张审核卡、每张 +1 `humanCalls`,第八次触顶 ——
|
||||||
|
放行那一步再没机会出现。屏上看着像「那一步没了」,实际是**预算见底**。两处一起治:
|
||||||
|
①**预算提高**(用户当场定):8 → 24(授权面与资源面同一个数,测里改成「两处一致 + 下限 12」,
|
||||||
|
不再把某个具体数字钉死)。**这是配置不是机制** —— 机制上的正解是「人自己打回引出的那张卡
|
||||||
|
不算打扰」(他不是被打扰,是他叫的),记在 debt。
|
||||||
|
②**「一条都没点到」不再整表重判**(当天上线、当天撤):当时想的是「说的是整批」,
|
||||||
|
而他那句实际是 **「收口结论里面的推荐理由改为非常不推荐」** —— 说的根本不是某条参数,
|
||||||
|
是收口那四格(本来就能在件上直接改)。整表重判的下场:AI 拿这句话把 **5 条参数**的结论
|
||||||
|
改成「不匹配」,每条依据都写着「按审核人意见调整为不匹配:推荐理由改为非常不推荐」——
|
||||||
|
**一句与它们无关的话,改了五行**。现在点不到名就**一条都不改**,并照实说:
|
||||||
|
「你这句意见我没能对上具体哪一条参数,这一轮一条都没改;要重判某一条请写上参数名,
|
||||||
|
收口那四格在件上直接改就行。」—— **「猜他指的是全部」比「承认没对上」贵得多。**
|
||||||
|
|
||||||
|
- **「怎么还有重做??你没改上吗?」—— 手写字段清单第三次把值扔了**(2026-09-01):
|
||||||
|
上一条算对了、release 转对了、契约也声明了,而**容器那个显式对象**只转它点到名的那几样,
|
||||||
|
`redoable` 没被点名就在这最后一关静默落地(编译不报、运行时无声)。**同一处已经吃过三次**:
|
||||||
|
①`fields` 写死成 `[]`(验收报告只能看不能改)②`blocks` 漏一行(卡面正文块恒空)③这次。
|
||||||
|
那几行旁边就写着两条「少写一行就静默落地」的警告 —— **措辞治不了的结构治**:
|
||||||
|
改成把 context 三样拆出去、**其余整包转交**,以后端口上新增可选字段不改这里也到得了;
|
||||||
|
剩下两处 `as never` 是类型收窄,不是丢值。断言钉在**最末端**:试跑观测面的卡带上 `redoable`,
|
||||||
|
e2e 断言「放行验收卡不摆重做(redoable=false 一路到了卡上)」—— 这类丢值只有末端断言拦得住。
|
||||||
|
- **人答得比这一拍收尾还快,这一拍就别报「等人」**(2026-09-01,同一夜第二种时序,又停掉一整例):
|
||||||
|
**决策卡的回传不发事件、只调 `wake`**,而 `wake` 只翻停着的单子 —— 卡刚发出那一刻这一拍还在
|
||||||
|
`running`,它空转了一次;随后这一拍把任务 park 回 `awaiting-event`,再没有第三样东西会来叫
|
||||||
|
(工单回传那一路还有人事件兜底 + 昨夜补的 stale 那一下,决策卡这一路**一样都没有**)。
|
||||||
|
实测:卡 02:06:13.250 发出、**219ms 后**被答,这一拍 .636 才收尾,单子停到 20 分钟超时。
|
||||||
|
修:配方发卡收尾前再看一眼**刚发的这张卡还在不在等** —— 已经答了就照实说「还在跑」,
|
||||||
|
推进器下一轮接着处理那份答复;**只认这一张卡**,别的「等外部回音」照旧是等,不会空转成热循环。
|
||||||
|
同批把假端口改老实:**发出去的卡默认就在等**(从前发完卡还回「一张待答的都没有」,
|
||||||
|
那是生产里不会有的局面 —— 新那道门正是靠这个分辨的)。
|
||||||
|
|
||||||
|
- **打不回的验收卡就别摆那颗钮**(2026-09-01 用户定:「评审放行与落账确认这一步的验收卡不应该
|
||||||
|
有重做的选项,因为这里都没有接入 ai」):W05 的 T_release 整个由配方跑完(`guide` 是空串,
|
||||||
|
不问模型),件又是只读的 —— 打回之后**没有任何东西能接住那句意见**:配方只会原样再提交一次,
|
||||||
|
撞在「打回之后台账没动过就不许再提交」那道闸上,连撞两拍落成一张「连续撞在同一条判据上」的
|
||||||
|
兜底卡。**一颗按下去只会把单子推进死胡同的钮,不如不摆**(墙要配路;没有路就别立那扇门)。
|
||||||
|
契约加 `AcceptanceWorkOrder.redoable`(不写 = 打得回,老卡老行为);装配层按放行那一步的声明现算
|
||||||
|
(`releaseRedoable` 纯函数:有 `auto` 且 `guide` 是空串 → 打不回;半 auto 与模型驱动照旧打得回);
|
||||||
|
卡面收起理由框与那颗钮,换一句**真路**:「这一步只落账,退不回上一步 —— 有异议就别确认,
|
||||||
|
去终止这一单重开」;**服务端才是门**:`redoable === false` 的卡收到非 pass 的回传当场拒
|
||||||
|
(`redo-unavailable`),直调 API 也绕不过。两处变异验证;S3 结论审核那张审核卡不受影响 ——
|
||||||
|
它的重做现在真会带着意见重判(见上一条)。
|
||||||
|
|
||||||
|
- **答得太快就没人再叫醒它 —— stale 那条守卫补最后一下**(2026-09-01,一夜之内撞停两次,
|
||||||
|
第二次直接把我自己的验证卡住 15 分钟):卡发出 **270ms** 后人就答了,而**把 `lastProgressAt`
|
||||||
|
往前挪的那一拍,正是发出这张卡的那一拍** —— 它不可能消费一条当时还不存在的回传,
|
||||||
|
2026-08-27 那条 stale 守卫的前提(「推进过 = 回传已被消费」)在这种时序下不成立;
|
||||||
|
回传口的 `wake` 也白做了(那一拍随后收尾,把任务 park 回 `awaiting-event`,晚到的写盖了早到的)。
|
||||||
|
三样凑齐:卡答完了、没有新卡在等、单子停着 —— **再没有第三样东西会叫醒它**
|
||||||
|
(手工把 runtime 拨回 running,同一份代码立刻把该走的几拍走完)。
|
||||||
|
修:`deliver` 判了 stale **照旧不跑这一拍**(它的本意不变),但把停着的单子拨回 `running`
|
||||||
|
交给推进器;真没事可干,它自己再停回去(代价一拍空转)。两条测钉死 + 变异验证。
|
||||||
|
真人点卡要几秒,撞不上这条窗口;**e2e 是 3 秒轮询 + 秒答,专把这种窗口撞出来** ——
|
||||||
|
它替人踩的正是这一类。
|
||||||
|
|
||||||
|
- **「我打回让改工作温度,它给我改了静态电流」—— 重判的行由确定的一侧圈,不由 AI 决定**
|
||||||
|
(2026-09-01 用户实测,`part-substitution-assessment@2.18.0`)。上一条刚把意见交回给 AI 重判,
|
||||||
|
当天就被这一单打脸,而库里的证据一字不差:**工作温度它没改**(还给了理由:候选值 1℃ 远窄于
|
||||||
|
-40~+85℃,硬约束不满足 —— 这半截是对的),而**没人问过的静态电流**被它从 soft_mismatch
|
||||||
|
翻成 match,`evidence_note` 里却写着「**维持原判**」。两张审核卡的「本轮结论」都还停在
|
||||||
|
S2 那一版(0 项一致 / 5 硬 / 2 软),而草稿此刻是 1 match / 5 硬 / 1 软 —— 差的那一项就是它。
|
||||||
|
**根因不是提示词不够严,是整表交出去、整表写回来**:没有任何东西限制它只动被点名的那一行,
|
||||||
|
而「我只改了那一条」这句话本身也出自它嘴里(它说了「维持原判」,可它改了)。规范 §4.6 纪律一
|
||||||
|
——**行由确定的那一侧决定,不由 AI 决定** —— 这次栽在自己手上。
|
||||||
|
治法(S3 复核轮从两笔变四笔,全部只在 `case_redo_note != ""` 那一轮跑):
|
||||||
|
①**圈范围**:按字面点名(`contains(case_redo_note, parameter_name)`,与基座给模型摆的
|
||||||
|
「意见点名对上了哪几行」同一种认法)把行标成 `named` / `kept`(明细表新增 `redo_scope` 列,tally);
|
||||||
|
②**兜底**:一条都没点到(「结论整体判得太松」这类意见说的是整批)→ 整表算被点名,
|
||||||
|
读的是上一笔刚写下的 tally 读数,不重新算一遍;③**重判只喂 `named` 的行** ——
|
||||||
|
没点名的行 AI 连见都见不到,自然改不着(也省 token);④**写回也只写这几行**,
|
||||||
|
模型漏答某一条时 `else` 兜回它上一轮的值(漏答不该变成「把它清空」)。
|
||||||
|
两处变异各自验证(写回不过滤 / 圈范围时一律标 named,都当场变红)。
|
||||||
|
e2e 案例 0 加一条**跨轮比对**断言:打回前存一份明细,放行前留最后一份,
|
||||||
|
逐行比「名字没出现在意见里的行,四态与三段叙述一个字都不许变」——
|
||||||
|
途中还逼出脚本自己的一个坑:**放行会清空草稿层**,收工时再取明细什么都没有,快照得留在放行之前。
|
||||||
|
|
||||||
|
- **重做的意见现在真交回给 AI 重跑那一步**(2026-09-01 用户:「唯一调用 ai 的这一步,使用重做后
|
||||||
|
是否没有将重做原因带回去重新让 ai 重跑?」——**确实没有**:S3 那一轮只把派生列重算一遍,
|
||||||
|
人写的那句话除了留痕之外无人读,AI 判过的四态一个字不会变,除非他自己在件上逐格改)。
|
||||||
|
平台加一样东西:引擎把「人这次打回写了什么」注给配方,名字 **`case_redo_note`** ——
|
||||||
|
下划线不是 `case:` 前缀,因为 S-FEEL 的名字不许带冒号(`lexer.ts` 的 NAME_PART),
|
||||||
|
而这句话最要紧的用法恰恰是当 `when` 条件用;**没打回也永远绑着空串** ——
|
||||||
|
未绑定的名字 `!= ""` 求值为**真**,不绑就等于「只在被打回时才跑」的那几笔每一轮都跑
|
||||||
|
(拿真 @sfeel 钉了这条,桩也照真语义写,否则钉不住)。静态审同批认这个名字
|
||||||
|
(它当场拦下过一次:「配方的 when 引用了 case_redo_note,而台账、案卷、起单入参里都没有这个名字」——
|
||||||
|
这道门做对了事)。
|
||||||
|
W05@2.17.0:S3 多两笔,都钉在 `case_redo_note != ""` 上 —— **带意见重判**(入参比第一轮多两样:
|
||||||
|
上一轮的四态/影响/依据/UNKNOWN 去向,以及他写的那句话;判法与第一轮**同一份** `ASSESS_RULES`,
|
||||||
|
后面只接一段复核纪律:**没被点到、也确实没判错的行原样带回**,别趁重判把人改过的东西悄悄冲掉)
|
||||||
|
+ **判完落回明细**(与第一轮同一笔投影,只多一个 `when`)。为此把判法、出参形状、落明细那一笔
|
||||||
|
抽成三个共享常量,S2 与 S3 共用 —— 各写一份早晚分叉。
|
||||||
|
**验收**:e2e w05 **5/5 released、被拒 0**,案例 0(先点名驳回、再通过)**模型调用恰 2 次**,
|
||||||
|
其余四例仍是 1 次 —— 没被打回就不多花一次模型。
|
||||||
|
|
||||||
|
- **「我重做后怎么直接推进了?」—— 打回与通过在收步那道门眼里一模一样**(2026-08-31 夜,
|
||||||
|
柴浩实测的那一单逐条对得上):审核卡发出 → 人带意见交回 →「这一步已经发过卡,不再发第二张」
|
||||||
|
→「**自动收步:S3_review —— 卡已交回,这一步到此为止**」→ 下一拍就发出了落账放行的验收卡。
|
||||||
|
意见落进了轨迹(还被 note-anchors 逐行对上了),而再没有任何东西会去读它。
|
||||||
|
**这条缺陷 2026-08-30 全分支终审时已被记录成 Important 2、还留着一条坐实病灶的测试**,
|
||||||
|
连「半个修法会把单子卡死」都预判对了 —— 这次照它的方向修完:
|
||||||
|
①收步那道门认答复(`answerSettlesStep`:`custom:`(人没选任何选项、写了自己的话)与约定的
|
||||||
|
`reject` 都不算办完;空答复照旧算「照原样确认」);②**同一句判断也让 `carded` 让位** ——
|
||||||
|
幂等挡的是「同一问被重复发出去」,而人点重做正是说「这一轮不算,改了再问我一遍」,
|
||||||
|
那不是重复,是下一轮:配方重跑(该重算的重算)再发一张,并在日志里说一句「人把这一步打回重做:…」。
|
||||||
|
只做①就是那个被预判过的半修法:挡住误收,单子卡死在「答过但没收步」的死胡同。
|
||||||
|
三条引擎测(重做→重开 / 重做但发不出卡→也不收 / 通过→照旧收)+ 3 条纯函数测,
|
||||||
|
两个半边各自变异验证;**e2e w05 5/5 released**,案例 0 现在真的发出**两张**审核卡
|
||||||
|
(先带意见驳回、再确认通过)。
|
||||||
|
**顺带撞见一条独立的老毛病并记账**(未修,见 debt.md):人答得比发卡那一拍收尾还快时,
|
||||||
|
回传被判 `stale`、`wake` 又被那一拍的 park 覆盖,单子停在 `awaiting-event` 再没人叫醒 ——
|
||||||
|
手工拨回 `running` 立刻走完。2026-08-27 那条 stale 守卫的前提(「推进过 = 回传已被消费」)
|
||||||
|
在这种时序下不成立:把时刻往前挪的那一拍,正是发出这张卡的那一拍。
|
||||||
|
|
||||||
|
- **「你怎么给我发成了执行工单」—— 面孔在真发卡那条路上被丢掉**(2026-08-31 用户实测,
|
||||||
|
`part-substitution-assessment@2.16.0`):装配层发执行工单那一处是一句**写死只认 `'data'`**
|
||||||
|
的三目(`engines.ts`),配方递下来的 `variant: 'accept'` 静默丢掉 —— 对话区照旧摆「执行工单」。
|
||||||
|
**两头都有测,唯独中间没有**:配方那一头断言了「variant 原样递出去」,前端那一头断言了
|
||||||
|
「accept 该穿哪副面孔」,e2e 按卡的**标题**驱动也照样全绿 —— 三处都对,而它们中间那一段把值扔了。
|
||||||
|
修:抽成纯函数 `executionCardVariant`(与旁边的 `decisionCardFace` 同一种做法、同一个测试位),
|
||||||
|
认 data / accept、拒 review、认不出的当没写;新测 4 条变异验证过。
|
||||||
|
e2e 同批**改认面孔不认标题**(试跑观测面的 `cards` 补 `variant`,契约变更):标题是活动自己写的
|
||||||
|
字符串,卡穿哪副面孔由装配层决定,两者可以对不上 —— 那一次正是「标题叫验收工单、发出去是执行工单」。
|
||||||
|
**面孔要在真发卡那条路上断言。**
|
||||||
|
同批(用户第二句「标题那一块需要是验收卡的那种绿色」):件① 验收工单那一副整套强调色换成
|
||||||
|
**验收绿 #0aa294**(令牌从 `ACCENT_LEDGER` 现改选择器得来,数值与件②③、对话区验收卡一处真源),
|
||||||
|
题头那条写死的浅蓝渐变单独给一条绿的;数据工单那一副仍是蓝。真渲染核过两副 class 与两套令牌。
|
||||||
|
|
||||||
|
- **起单不许选到同一颗料;S1 数据齐了也叫人一次**(2026-08-31 用户两条,
|
||||||
|
`part-substitution-assessment@2.15.0`):
|
||||||
|
①**两格互斥**(「创建活动的启动参数里面不应该能选到同样的两个芯片」)—— 查下来不只是没意义,
|
||||||
|
是**把单子做死**:`sub_eval.part` 主键 `eval_ref + internal_pn`(role 不在主键里),配方铸的两行
|
||||||
|
撞成同一个主键、后一笔覆盖前一笔,两行收成一行 → `sub_eval_part_exists_status_found = 2` 永不成立、
|
||||||
|
S1 收不了步,而件上那两行人加不出来(行源 `mine:`,没有「+加一行」)—— 唯一出路是终止重开
|
||||||
|
(与「两格留空」同一条链,清单头注早写着)。平台加互斥声明 `x-distinct`(契约 `distinctGroup`)——
|
||||||
|
与 `x-link` 正相反:那个是引导,**这个是墙**。前端把别的格占着的候选从这一格下拉里抽掉
|
||||||
|
(唯独自己填着的那个永远留着,否则 `<select>` 当场变空白),走「新增」自己写一个同样的 →
|
||||||
|
小红字 + 启动按不下去;服务端 `validateLaunchParams` 独立拦(脚本 / API 绕不过),400 点名后填那格。
|
||||||
|
「算不算同一个」= 去空白 + 不分大小写(`launchDistinctKey`,前后端同一把尺;只挡精确相等的话
|
||||||
|
`lm2596s-5.0` 照样进去,混一颗垃圾主数据出来)。
|
||||||
|
②**数据齐了也叫人一次**(「就算数据是足的也进入这个数据工单,就派发一个验收工单…把芯片卡片上的
|
||||||
|
编辑按钮去掉,只展示不能编辑,标题旁边的数据工单改成验收工单」)——病根在**引擎拍首那道
|
||||||
|
`doneWhen` 扫描**:输入本来就齐的单,S1 的 doneWhen 第一拍就为真,配方一笔都还没跑,步骤当场被收掉,
|
||||||
|
卡永远发不出去。治法是 CMMN 给 S1 标 **`manualActivation`** —— 那道扫描多一个门槛(`worldTouched`),
|
||||||
|
次序变成「配方发卡 → 人答 → 下一拍 doneWhen 为真才收」;doneWhen 照旧是真门,只是不抢在人前面。
|
||||||
|
平台加**执行工单第三副面孔** `variant: 'accept'`(验收工单:件只读、人只确认,「确认无误」)——
|
||||||
|
与 `mode:'acceptance'`(submit 抬起来的收口验收,带打回重做)分清;三副面孔的判断抽成纯函数
|
||||||
|
`desk-task/internal/order-face.ts`(有测)。活动侧:S1 配方两笔互斥的 `card`(引擎 `carded` 幂等
|
||||||
|
保证一步只发一张 —— 数据工单发过之后验收工单永远轮不到,人不会被叫第二次),案卷加**粘着的旗**
|
||||||
|
`order_kind`(`data`/`accept`,第一档读上一拍写下的自己;不粘着的话人把最后一格填上、还没点确认,
|
||||||
|
件的题头就当场改名、笔也没了);**一张件两副面孔** —— 题头 / 标题 / 页脚三处名字与卡角编辑钮
|
||||||
|
按 `data-class-of="order_kind"` 投出的 `v-data`/`v-accept` 由样式表挑(藏的是容器 `.fabs`,
|
||||||
|
不是逐枚钮:那几枚各自的 display 由 `html.editable` 一串更特异的规则给着)。
|
||||||
|
**验收**:5 处变异验证;引擎测钉死那一对(标了人工激活 → 卡发得出去、步不收;没标 → 步当场被收、
|
||||||
|
卡一张发不出);e2e w05 **5/5 released、被拒 0、模型调用恰 1 次** —— 都在库那例摆的正是
|
||||||
|
「验收工单 · 差异评估的输入核对」,缺东西那三例照旧是数据工单;真渲染核过 `.card` 两副 class。
|
||||||
|
2558 测 / 五门禁绿;2.15.0 装库六探针核过。
|
||||||
|
**副产**:e2e 样例 3「原PN缺库」的前提已被污染(`LM2596S-3.3` 现在真在库里,多半是白天 ops 起单
|
||||||
|
落的主数据),那一例实际走的也是验收工单那条路 —— 名字与实测不符,清库存那件事记在这儿。
|
||||||
|
|
||||||
|
- **「类型不一致还能点下一步」的病根:确认闸条件在发卡装配被丢 —— 手写字段清单第四次丢值**
|
||||||
|
(2026-09-01 用户:「两个芯片类型不一样,页面上方有红色提示,右边派发的卡片还可以点击下一步」):
|
||||||
|
活动白纸黑字声明了 `gateWhen`(2026-08-31 加的确认闸条件档),而**发卡装配那段手写清单**
|
||||||
|
(engines.ts 的 human 口 —— `variant` 上次被丢的同一段)逐项点名转交 `gateLedgers`/`variant`,
|
||||||
|
没点 `gateWhen`/`gateWhenSay` 的名:落库工单载荷里它是 undefined(稳定库任务 43 法证),
|
||||||
|
按钮禁用与服务端拒收读的都是它,**一起无声失效**;红带是视图画的所以照亮 ——
|
||||||
|
屏上两处说着相反的话。此前 fields / blocks / redoable 三次同款,这次的教训:
|
||||||
|
**中段是不可信卡骨架的缝,不能整包 spread,那就抽纯函数校验着转交并在 variant 的
|
||||||
|
测试位旁边钉死**(`cardConditionGate`,5 测:转交原样 / 不发明键 / 脏值不端 /
|
||||||
|
say 单独无意义 / when 可无 say)。**末端断言照 redoable 那课补上**:e2e w05 数据卡
|
||||||
|
阶段 `expectCardGateWhen` —— 卡一到手查 desk 载荷,丢了当场判失败。
|
||||||
|
活体验证(测试台真单):载荷带 `gateWhen` → detail 闸 `ready:false` 带活动那句话 →
|
||||||
|
API 强行确认 **400**;e2e w05 案例 0 全绿(released、被拒 0、模型 2 次)。
|
||||||
|
活动侧零改动(`GATE_OPEN_WHEN` 含类型不一致,mismatch 时发的必是带闸的数据卡;
|
||||||
|
⑧ 验收卡的件只读、碰不上这形)。门禁 exit 0(2595 测)。
|
||||||
|
|
||||||
|
- **「终止活动」接口被蒸馏吊 13–45 秒到报错:收尾不等模型**(2026-09-01 用户:「点击终止后
|
||||||
|
接口迟迟不返回,还会报错,但实例确实终止了」):法证在库里 —— 四单被终止的任务,
|
||||||
|
`finished_at` 与蒸馏条目 `created_at` 差 13.2 / 36.3 / 43.4 / 44.6 秒:`finish` 把终态
|
||||||
|
落库并广播**之后**,同步 `await` 了 `memory.distill`(整单记忆 + JSON schema 结构化输出的
|
||||||
|
一次真模型调用),HTTP 响应吊在半空 —— 屏上早显示已终止(SSE 那一刻就发了),响应拖到
|
||||||
|
任何一环断连(watcher 重启进程 / 代理 / 人重试)就成一次报错。吊着的不止终止接口:
|
||||||
|
**放行收尾那一拍同样走 `finish`,推进器与任务租约陪模型等 40 秒**。修:蒸馏后台跑
|
||||||
|
(`void …distill().catch()`),封卷仍在前、失败照旧吞掉不掀终态;三测钉死(悬着的蒸馏不挡
|
||||||
|
control 返回 / 拒绝不外泄 / seal 在 distill 前),改前第一条即 30 秒超时 —— 变异天然验证。
|
||||||
|
CONTRACT.md 同步。门禁 exit 0(2590 测);:8787 已换上(watcher 只在重建时拉起、不守护
|
||||||
|
死进程 —— 杀了要 touch 源码触发重建才回来)。
|
||||||
|
|
||||||
|
- **W05 红条可灭:gate_state 开关列**(2026-08-31 用户:「没有必填了红色区域就不要显示」,
|
||||||
|
`part-substitution-assessment@2.13.0`):病根 —— gate_reason 清空串按列归型不落笔
|
||||||
|
(jsonb 保旧值),红条亮过灭不了。治:案卷加 `gate_state`(tally open/clear,双向真写),
|
||||||
|
与发卡条件共用 GATE_OPEN_WHEN 一处真源;红条 data-class-of 投影 v-clear 收起。
|
||||||
|
**教训:要用「清成空串」表达状态翻转的列,空值不落笔会把它焊死 —— 开关就该是开关列。**
|
||||||
|
|
||||||
## 文档权威地图
|
## 文档权威地图
|
||||||
|
|
||||||
| 层 | 文件 | 说明 |
|
| 层 | 文件 | 说明 |
|
||||||
@@ -1631,8 +2072,9 @@
|
|||||||
| | `docs/前端对接说明-邮件收发.md` | **前端接邮箱配置 / 来信 / 发信记录照这份**(ADR-013):账号表单两形、四个动作、投递状态、附件与原件的票据下载、SSE 失效、腾讯侧前置 |
|
| | `docs/前端对接说明-邮件收发.md` | **前端接邮箱配置 / 来信 / 发信记录照这份**(ADR-013):账号表单两形、四个动作、投递状态、附件与原件的票据下载、SSE 失效、腾讯侧前置 |
|
||||||
| | `docs/API文档-邮件收发.md` | 邮件收发 15 条接口的人话版 + schema 附录 + curl;活动怎么收(事件链)怎么发(`mail.send` 2.0.0) |
|
| | `docs/API文档-邮件收发.md` | 邮件收发 15 条接口的人话版 + schema 附录 + curl;活动怎么收(事件链)怎么发(`mail.send` 2.0.0) |
|
||||||
| | `docs/API文档-鉴权与人员.md` | 鉴权 / 人员 / 角色→端接口的人话版 + 全部路由归属总表;契约不一致时以 `packages/contracts` 为准 |
|
| | `docs/API文档-鉴权与人员.md` | 鉴权 / 人员 / 角色→端接口的人话版 + 全部路由归属总表;契约不一致时以 `packages/contracts` 为准 |
|
||||||
| | `docs/ACT-WORK-05-端到端测试记录.md` | **ACT-WORK-05 差异评估的走查账**:六轮逐轮结论 + A–K 十一条发现总表(现象 / 病根 / 治法 / 层) |
|
| | `docs/ACT-WORK-05-端到端测试记录.md` | **历史记录,已停止更新**(2026-08-30 标注):旧九步设计从零到 released 的走查账,六轮逐轮结论 + A–Z 二十六条发现总表(现象 / 病根 / 治法 / 层);四步重写(2026-08-29)之后的发现不再往里追加,散落在源码注释与 `docs/debt.md` |
|
||||||
| | `docs/数据域字段说明-W05.md` | ACT-WORK-05 的十张台账 / 110 列 / 人填列 27 —— 建活动时照它对字段 |
|
| | `docs/数据域字段说明-W05.md` | ACT-WORK-05 重写后九张台账 / 66 列 / 人填列 20 —— 建活动时照它对字段 |
|
||||||
|
| | `docs/adr/ADR-014-deterministic-steps.md` | **确定性步骤(已实现,2026-08-29)**:W05 九步的确定/判断盘点、`PlanStep.auto` 声明式配方(五动词:`project`/`analyze`/`card`/`submit`/`say`)、引擎拍内次序、回溯验算、迁移次序、§11 待定前两条已定(决策卡给了 / 工具串接换成 `analyze`) |
|
||||||
| | `docs/debt.md` | 欠账台账(只增,销账划线) |
|
| | `docs/debt.md` | 欠账台账(只增,销账划线) |
|
||||||
| | `docs/adr/` · 各模块 `CONTRACT.md` | 架构决策与模块级不变量 |
|
| | `docs/adr/` · 各模块 `CONTRACT.md` | 架构决策与模块级不变量 |
|
||||||
| 业务样例(活) | `docs/MVP示例工作流.html` | 七节点 SOP + 判定表 + 台账域 —— 开发活动的业务素材 |
|
| 业务样例(活) | `docs/MVP示例工作流.html` | 七节点 SOP + 判定表 + 台账域 —— 开发活动的业务素材 |
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,13 @@
|
|||||||
/** 清单与案例模型共用的常量 —— 单独成文件,免得 manifest ↔ case-model 循环 import 撞 TDZ。 */
|
/** 清单与案例模型共用的常量 —— 单独成文件,免得 manifest ↔ case-model 循环 import 撞 TDZ。 */
|
||||||
export const ACTIVITY_ID_TEXT = 'part-substitution-assessment';
|
export const ACTIVITY_ID_TEXT = 'part-substitution-assessment';
|
||||||
/** 活动自持单据的域名 */
|
/** 活动自持单据(案卷)的域名 */
|
||||||
export const D = 'sub_eval';
|
export const D = 'sub_eval';
|
||||||
/** GOV 差异规则版本 —— 随包冻结,随每条结论落账(rule_version) */
|
/** GOV 差异规则版本 —— 随包冻结,随每条结论落账(rule_version) */
|
||||||
export const RULE_VERSION = 'R-2026.07';
|
export const RULE_VERSION = 'R-2026.07';
|
||||||
export const TOOL_COMPARE = 'part-substitution-assessment.compare-parameters';
|
/*
|
||||||
export const TOOL_RECOMMEND = 'part-substitution-assessment.recommend';
|
* `TOOL_COMPARE` / `TOOL_RECOMMEND` 两个常量已随 2026-08-29 的整体重写退役
|
||||||
|
* (spec `docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md` §1/§8):
|
||||||
|
* 逐项四态改由 S2 唯一一次 `analyze` 出场判定,推荐等级改由配方按 §3.5 的规则算 ——
|
||||||
|
* 两枚工具已在 Task 10 于工具注册表里停用(墓志铭见 `apps/api/src/app/tools/first-party.ts`)。
|
||||||
|
* 案例模型(`case-model.ts`)全程零工具调用,没有第二处字面量引用留着。
|
||||||
|
*/
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
* ACT-WORK-05 · 差异评估 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
|
* ACT-WORK-05 · 差异评估 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
|
||||||
*
|
*
|
||||||
* 这个活动没有 `run`。案例活动的推进由 Loop 驱动,行为全部写在清单里:
|
* 这个活动没有 `run`。案例活动的推进由 Loop 驱动,行为全部写在清单里:
|
||||||
* 案例模型 + 说明书 + 步骤剧本 + 三条技能 + 台账声明 + 五张 HTML 报告模版。
|
* 案例模型 + 说明书 + 步骤剧本(全 `auto`,零 `guide`)+ 台账声明 + 三张 HTML 报告模版
|
||||||
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 ——
|
* (**0 条技能** —— 2026-08-29 整体重写后全程无 agent 临场编排,技能戳随两枚工具、
|
||||||
|
* 两张 DMN 一并退役)。这个文件之所以还存在,是因为清单的 `entry` 指着它 ——
|
||||||
* 它是**被静态审扫描的产物文本**(查 import 与全局引用,证明活动包零环境能力)。
|
* 它是**被静态审扫描的产物文本**(查 import 与全局引用,证明活动包零环境能力)。
|
||||||
*/
|
*/
|
||||||
import { defineActivity } from '@sdk';
|
import { defineActivity } from '@sdk';
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,55 +0,0 @@
|
|||||||
/**
|
|
||||||
* ACT-WORK-05 差异评估的技能 —— 载方法,不载权力。
|
|
||||||
* 剧本写「这一步做什么、写哪几列」,技能写「为什么这么判、拿不准时按哪条走」。
|
|
||||||
*/
|
|
||||||
import { skillIdSchema } from '@contracts';
|
|
||||||
import type { Skill } from '@contracts';
|
|
||||||
|
|
||||||
export const ASSESSMENT_SKILLS: Skill[] = [
|
|
||||||
{
|
|
||||||
id: skillIdSchema.parse('substitution.normalize-parameters'),
|
|
||||||
label: '把多来源资料整成可比对的标准参数对照表',
|
|
||||||
applicability: '输入确认之后、逐项比对之前:原 PN 与每个候选的参数集要摆成同名同单位的一对一。',
|
|
||||||
method: [
|
|
||||||
'参数集是独立子表 `parts.parameter`(P-08 的结构子表),一行一参数,按 `internal_pn` 归属主记录:列是 param_name / param_value / param_unit / constraint_type / param_note。',
|
|
||||||
'对照表按**原 PN 名下的那几行参数名**为骨架:原 PN 有的参数,候选名下没有同名那一行 → 候选值写「未披露」(它会成为 UNKNOWN);',
|
|
||||||
'候选多出来的参数不比(比对只回答「能不能替」,多出来的能力不构成差异)。',
|
|
||||||
'单位以原 PN 那一行的 param_unit 为准:候选单位不同先换算(mV→V、kHz→MHz),换算不了就不改值、在说明里写明 —— 不许静默改数。',
|
|
||||||
'约束类型以原 PN 那一行的 constraint_type 为准;原 PN 没标的按 GOV 口径:封装 / 工艺 / 电气边界(电压 / 电流 / 温度 / 阈值)一律 hard,其余 soft。',
|
|
||||||
'**缺参数不得伪造**:查不到的写「未披露」,不写估计值、不写「约」。',
|
|
||||||
].join('\n'),
|
|
||||||
crystallized: [],
|
|
||||||
knowledgeRefs: ['标准化参数子表结构', '硬/软约束口径'],
|
|
||||||
version: '1.0.0',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: skillIdSchema.parse('substitution.difference-judgement'),
|
|
||||||
label: '逐项四态由工具算,证据与影响由你写',
|
|
||||||
applicability: '对照表齐了,要出 MATCH / MISMATCH / UNKNOWN / CONDITIONAL 时。',
|
|
||||||
method: [
|
|
||||||
'四态**不许自己判**:调 `part-substitution-assessment.compare-parameters`,把每个候选的全部参数对一次性传进去(candidates 数组,一次一批)。',
|
|
||||||
'每对参数都带 `evidenceRef`(值从哪份记录来:「P-08」「P-10 Rev H §6.5」「数据工单 <单号> · 确认人 <人>」)——硬约束没有证据引用,工具不判 MATCH。',
|
|
||||||
'客户在应用场景里给了边界(如「输入上限 40V」)就写进那一对的 `requirement`(`>= 40`),候选不满足直接 MISMATCH。',
|
|
||||||
'工具出参照抄进 `substitution.difference_item`:difference_status / compare_result / risk_level 一字不改;',
|
|
||||||
'`impact_statement` 你写:MATCH 写「无」,其余说清对应用 / 验证 / 报价的影响;UNKNOWN 必须写 `unknown_disposition`(挂验证任务号或补来源动作),不许悬空。',
|
|
||||||
'推荐等级同样由工具定(`part-substitution-assessment.recommend`,传每个候选的 counts),`recommendation_reason` 照工具的 reason 扩写,不许把不推荐改成推荐。',
|
|
||||||
].join('\n'),
|
|
||||||
crystallized: [],
|
|
||||||
knowledgeRefs: ['四态口径', '推荐等级四档'],
|
|
||||||
version: '1.0.0',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: skillIdSchema.parse('substitution.landing'),
|
|
||||||
label: '落账只写分析层,客户批准不碰',
|
|
||||||
applicability: '审核通过之后拟 P-11 / 回写 P-01 时。',
|
|
||||||
method: [
|
|
||||||
'`analysis_status` 从 `ai_draft` 推到 `fae_confirmed`;`customer_approval_status` 由独立审批流维护 —— 本活动**没有这一列的写权**,写了会被拒。',
|
|
||||||
'P-01 回写五项:risk_level(取各候选里最高的)、verification_requirement(把所有验证任务号列全)、output_ref(替代分析报告号)、next_action(「提交 ACT-GATE-03 评审放行」)、current_stage 写 `gate03`。',
|
|
||||||
'规则版本随结论落账:每条 P-11 主记录与差异明细都写 rule_version(工具出参的 ruleVersion),中途规则更新不影响进行中的评估。',
|
|
||||||
'被打回重做时改的是**台账本身**(理由 / 依据 / 去向),不是把同一份原样再递一遍。',
|
|
||||||
].join('\n'),
|
|
||||||
crystallized: [],
|
|
||||||
knowledgeRefs: ['analysis_status 与 customer_approval_status 分离'],
|
|
||||||
version: '1.0.0',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,52 +1,74 @@
|
|||||||
/**
|
/**
|
||||||
* 运营中心 L2 沙箱视图 —— ACT-WORK-05「关键台账数据」看板(2026-08-27)。
|
* 运营中心 L2 沙箱视图 —— ACT-WORK-05「关键台账数据」看板(2026-08-27 初版,2026-08-29 Task 9 重绑)。
|
||||||
*
|
*
|
||||||
* 版式与样式逐字节搬自 `docs/ss02-act-05/ACT-WORK-05-关键台账数据.html`(只改两处:`.tbl-wrap` 放开横滑;
|
* **2026-08-29 Task 9「运营中心看板重绑」**:2026-08-29 整体重写(spec
|
||||||
* 徽标文案由 `data-class-of` 投影 + 生成式 `::after` 出,不再写死)。溯源口径照 `…-数据溯源.md`:
|
* `docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md`)把九步案例模型砍成四步、
|
||||||
* 页头 / P-01 一条 / P-08 逐颗 + 参数瓷贴(一格一列,不叠灰字次列 —— 2026-08-27 用户定)/ P-11 主记录 + 差异明细 / P-10(同样一格一列)/ P-06(本单 + 同料号历史)。
|
* 台账从旧的 `sub_eval.pn_check`/`sub_eval.verdict` 三张单据 + `demand.request`/`customer.profile`/
|
||||||
|
* `parts.document` 五张外域表,换成本活动自持的 `sub_eval.case`/`sub_eval.part` 两张 + 新增的
|
||||||
|
* `parts.chip_type`/`parts.type_param` 两张类型字典 —— 这份看板原样引着已退役的五张地址,
|
||||||
|
* 装包会被静态审拦下。本轮把 `LEDGER_BOARD_DATA` 与 `LEDGER_BOARD` 一并**重绑**到新九张表:
|
||||||
|
* 案卷(`sub_eval.case`)→ 两颗料(`sub_eval.part`)→ 类型与字段字典(`parts.chip_type`/
|
||||||
|
* `parts.type_param`)→ 参数对照(`parts.profile`/`parts.parameter`)→ 关系与明细
|
||||||
|
* (`substitution.relation`/`substitution.difference_item`)→ P-06(`validation.record`,
|
||||||
|
* 含同料号历史 `validation.history`)。P-01 需求主台账 / P-10 原厂资料两个板块随之整段撤下 ——
|
||||||
|
* 本活动这次重写已经不碰这两张表(见 manifest.ts「这个活动不碰 P-01 / P-10 / 客户档案」)。
|
||||||
|
* 新设计是**一次评估恰一对一候选**(不再是「多候选,部分放弃」),`substitution.relation`
|
||||||
|
* 一单一行、`sub_eval.part` 一单两行(原/候选各一),「候选」不再是需要 `data-rows` 逐个复制的
|
||||||
|
* 集合,页头 / P-08 两处原来的 `.cands`(多候选并排)、`.aband`(放弃候选的候选行)两个概念
|
||||||
|
* 一并撤下 —— 新模型里没有「本轮放弃的候选」这件事。
|
||||||
|
*
|
||||||
|
* 版式与样式**沿用**(重绑不是重做):页头 / 数据来源图例 / 分层图例 / 表格样式 / 三态横幅
|
||||||
|
* 这几段版面语言原样保留;`.tbl-wrap` 放开横滑、徽标文案由 `data-class-of` 投影 + 生成式
|
||||||
|
* `::after` 出的既有约定不变。溯源口径参照 `…-数据溯源.md`(该文档写在旧九步年代,
|
||||||
|
* 域名对照以本文件为准)。
|
||||||
*
|
*
|
||||||
* **它摆的是最近一次已放行实例的落账快照**(用户定):行由基座按 `LEDGER_BOARD_DATA` 的声明从**正式表**圈出 ——
|
* **它摆的是最近一次已放行实例的落账快照**(用户定):行由基座按 `LEDGER_BOARD_DATA` 的声明从**正式表**圈出 ——
|
||||||
* 每个 `data-rows` 键都是声明里的一个 `as`,不是整表;`<as>.<列>` 是那一集合首行的标量,`<as 去符号>_rows` /
|
* 每个 `data-rows` 键都是声明里的一个 `as`,不是整表;`<as>.<列>` 是那一集合首行的标量,`<as 去符号>_rows` /
|
||||||
* `_<列>_<值>` 是基座数出来的读数(`apps/api/src/app/activity-ops/view-scope.ts`)。模版仍**不算不判**:
|
* `_<列>_<值>` 是基座数出来的读数(`apps/api/src/app/activity-ops/view-scope.ts`)。模版仍**不算不判**:
|
||||||
* MATCH 行淡显、放弃的候选、三态横幅,全是 `data-class-of` 投出的 class 交样式表。
|
* MATCH 行淡显、三态横幅,全是 `data-class-of` 投出的 class 交样式表。
|
||||||
* 无脚本、无外链(过安装静态审);报高脚本由服务端出口注入。
|
* 无脚本、无外链(过安装静态审);报高脚本由服务端出口注入。
|
||||||
*/
|
*/
|
||||||
import type { ViewData } from '@contracts';
|
import type { ViewData } from '@contracts';
|
||||||
import { badgeCss } from './badge.ts';
|
import { badgeCss } from './badge.ts';
|
||||||
import { D } from '../constants.ts';
|
import { D } from '../constants.ts';
|
||||||
|
|
||||||
/** 案号 `W05-<开案时刻 14 位>`(与说明书让模型铸 `eval_ref` 的写法同源);行集按案号 → 核对表 → 料号 / 需求逐表圈 */
|
/**
|
||||||
|
* 案号 `W05-<开案时刻 14 位>`(与说明书让模型铸 `eval_ref` 的写法同源);行集按案号 → 案卷 →
|
||||||
|
* 两颗料 → 类型字典(按案卷锁定的 `chip_type_id` 查)→ 主数据 / 参数(按两颗料的 `internal_pn` 查)→
|
||||||
|
* 关系(按案号查,一单一行)→ 明细(按关系的主键查,`by.from` 必须指向**前面**已求出的集合 ——
|
||||||
|
* 明细依赖关系的主键,所以 `rows` 数组里关系排在明细**之前**,即便「关系」是这一签最终的结论、
|
||||||
|
* 「明细」是支撑结论的逐条证据,版面上想先摆哪个都行,数据依赖顺序是另一件事)→ P-06(按案号查,
|
||||||
|
* 一单固定一条;同料号历史另开一集,按两颗料的 `internal_pn` 查、排除本单自己那一条)。
|
||||||
|
*/
|
||||||
export const LEDGER_BOARD_DATA: ViewData = {
|
export const LEDGER_BOARD_DATA: ViewData = {
|
||||||
case: { ledger: `${D}.verdict`, key: 'eval_ref', prefix: 'W05-' },
|
case: { ledger: `${D}.case`, key: 'eval_ref', prefix: 'W05-' },
|
||||||
rows: [
|
rows: [
|
||||||
{ as: `${D}.verdict`, ledger: `${D}.verdict`, by: { column: 'eval_ref', from: 'case' } },
|
{ as: `${D}.case`, ledger: `${D}.case`, by: { column: 'eval_ref', from: 'case' } },
|
||||||
/* 原 PN 排第一(original > candidate),下游按料号圈的表都随这个顺序 */
|
{ as: `${D}.part`, ledger: `${D}.part`, by: { column: 'eval_ref', from: 'case' }, orderBy: [{ field: 'role', dir: 'desc' }, { field: 'internal_pn', dir: 'asc' }] },
|
||||||
{ as: `${D}.pn_check`, ledger: `${D}.pn_check`, by: { column: 'eval_ref', from: 'case' }, orderBy: [{ field: 'role', dir: 'desc' }, { field: 'internal_pn', dir: 'asc' }] },
|
{ as: 'parts.chip_type', ledger: 'parts.chip_type', by: { column: 'type_id', from: `${D}.case.chip_type_id` } },
|
||||||
{ as: 'demand.request', ledger: 'demand.request', by: { column: 'request_id', from: `${D}.verdict.request_id` } },
|
{ as: 'parts.type_param', ledger: 'parts.type_param', by: { column: 'type_id', from: `${D}.case.chip_type_id` }, orderBy: [{ field: 'param_name', dir: 'asc' }] },
|
||||||
{ as: 'customer.profile', ledger: 'customer.profile', by: { column: 'customer_code', from: 'demand.request.customer_code' } },
|
{ as: 'parts.profile', ledger: 'parts.profile', by: { column: 'internal_pn', from: `${D}.part.internal_pn` } },
|
||||||
{ as: 'parts.profile', ledger: 'parts.profile', by: { column: 'internal_pn', from: `${D}.pn_check.internal_pn` } },
|
{ as: 'parts.parameter', ledger: 'parts.parameter', by: { column: 'internal_pn', from: `${D}.part.internal_pn` }, orderBy: [{ field: 'param_name', dir: 'asc' }] },
|
||||||
{ as: 'parts.parameter', ledger: 'parts.parameter', by: { column: 'internal_pn', from: `${D}.pn_check.internal_pn` }, orderBy: [{ field: 'param_name', dir: 'asc' }] },
|
{ as: 'substitution.relation', ledger: 'substitution.relation', by: { column: 'eval_ref', from: 'case' } },
|
||||||
{ as: 'parts.document', ledger: 'parts.document', by: { column: 'internal_pn', from: `${D}.pn_check.internal_pn` }, orderBy: [{ field: 'doc_seq', dir: 'asc' }] },
|
|
||||||
{ as: 'substitution.relation', ledger: 'substitution.relation', by: { column: 'eval_ref', from: 'case' }, orderBy: [{ field: 'substitution_relation_id', dir: 'asc' }] },
|
|
||||||
{
|
{
|
||||||
as: 'substitution.difference_item',
|
as: 'substitution.difference_item',
|
||||||
ledger: 'substitution.difference_item',
|
ledger: 'substitution.difference_item',
|
||||||
by: { column: 'substitution_relation_id', from: 'substitution.relation.substitution_relation_id' },
|
by: { column: 'substitution_relation_id', from: 'substitution.relation.substitution_relation_id' },
|
||||||
orderBy: [{ field: 'difference_item_id', dir: 'asc' }],
|
orderBy: [{ field: 'difference_item_id', dir: 'asc' }],
|
||||||
},
|
},
|
||||||
{ as: 'validation.record', ledger: 'validation.record', by: { column: 'request_id', from: `${D}.verdict.request_id` }, orderBy: [{ field: 'validation_id', dir: 'asc' }] },
|
{ as: 'validation.record', ledger: 'validation.record', by: { column: 'eval_ref', from: 'case' } },
|
||||||
/* 同料号在别单 / 别处留下的验证记录:本次评估的历史证据引用(目标件 P-06 里 VAL-2026-0040 / 0032 那两行的位置) */
|
/* 同料号在别单 / 别处留下的验证记录:本次评估的历史证据引用,排除本单自己那一条 */
|
||||||
{
|
{
|
||||||
as: 'validation.history',
|
as: 'validation.history',
|
||||||
ledger: 'validation.record',
|
ledger: 'validation.record',
|
||||||
by: { column: 'validation_internal_pn', from: `${D}.pn_check.internal_pn` },
|
by: { column: 'validation_internal_pn', from: `${D}.part.internal_pn` },
|
||||||
not: { column: 'request_id', from: `${D}.verdict.request_id` },
|
not: { column: 'eval_ref', from: 'case' },
|
||||||
orderBy: [{ field: 'validation_id', dir: 'desc' }],
|
orderBy: [{ field: 'validation_id', dir: 'desc' }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ==================== 目标件样式(逐字节搬;两处改动标了 [改]) ==================== */
|
/* ==================== 目标件样式(基座壳与版面语言沿用旧版;域名相关内容按新九张表改写) ==================== */
|
||||||
const TARGET_CSS = `
|
const TARGET_CSS = `
|
||||||
:root{
|
:root{
|
||||||
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
|
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
|
||||||
@@ -80,15 +102,14 @@ body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,
|
|||||||
.dot.full{background:var(--brand)}
|
.dot.full{background:var(--brand)}
|
||||||
.dot.half{background:var(--amber)}
|
.dot.half{background:var(--amber)}
|
||||||
|
|
||||||
/* 分层图例 */
|
/* 分层图例 —— 新九张表收成四层(P-01/P-10 两层随重写撤下) */
|
||||||
.layers{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin:14px 0 18px}
|
.layers{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:14px 0 18px}
|
||||||
.layer{background:#fff;border:1px solid var(--line-soft);border-radius:11px;padding:11px 14px;font-size:12px;color:var(--ink-2);border-top:3px solid var(--brand)}
|
.layer{background:#fff;border:1px solid var(--line-soft);border-radius:11px;padding:11px 14px;font-size:12px;color:var(--ink-2);border-top:3px solid var(--brand)}
|
||||||
.layer b{display:block;font-size:12.5px;color:var(--ink);margin-bottom:2px}
|
.layer b{display:block;font-size:12.5px;color:var(--ink);margin-bottom:2px}
|
||||||
.layer .lg{font-family:var(--mono);font-weight:800;color:var(--brand-deep)}
|
.layer .lg{font-family:var(--mono);font-weight:800;color:var(--brand-deep)}
|
||||||
.layer.core-out{border-top-color:var(--ledger)} .layer.core-out .lg{color:var(--ledger)}
|
.layer.core-out{border-top-color:var(--ledger)} .layer.core-out .lg{color:var(--ledger)}
|
||||||
.layer.evid{border-top-color:var(--human)} .layer.evid .lg{color:var(--human)}
|
.layer.evid{border-top-color:var(--human)} .layer.evid .lg{color:var(--human)}
|
||||||
.layer.loop{border-top-color:var(--amber)} .layer.loop .lg{color:var(--amber)}
|
.layer.loop{border-top-color:var(--amber)} .layer.loop .lg{color:var(--amber)}
|
||||||
.layer.ctx{border-top-color:var(--ink-4)} .layer.ctx .lg{color:var(--ink-3)}
|
|
||||||
|
|
||||||
/* 区块 */
|
/* 区块 */
|
||||||
section{background:#fff;border:1px solid var(--line-soft);border-radius:14px;padding:18px 22px;margin-bottom:16px}
|
section{background:#fff;border:1px solid var(--line-soft);border-radius:14px;padding:18px 22px;margin-bottom:16px}
|
||||||
@@ -100,11 +121,10 @@ section{background:#fff;border:1px solid var(--line-soft);border-radius:14px;pad
|
|||||||
.ltag.out{color:var(--ledger);background:var(--ledger-soft);border-color:var(--ledger-line)}
|
.ltag.out{color:var(--ledger);background:var(--ledger-soft);border-color:var(--ledger-line)}
|
||||||
.ltag.evid{color:var(--human);background:var(--human-soft);border-color:var(--human-line)}
|
.ltag.evid{color:var(--human);background:var(--human-soft);border-color:var(--human-line)}
|
||||||
.ltag.loop{color:var(--amber);background:var(--amber-soft);border-color:var(--amber-line)}
|
.ltag.loop{color:var(--amber);background:var(--amber-soft);border-color:var(--amber-line)}
|
||||||
.ltag.ctx{color:var(--ink-3);background:var(--soft);border-color:var(--line)}
|
|
||||||
.sec-head .why{font-size:12px;color:var(--ink-4)}
|
.sec-head .why{font-size:12px;color:var(--ink-4)}
|
||||||
.sec-head .cnt{margin-left:auto;font-size:11.5px;color:var(--ink-4);font-family:var(--mono)}
|
.sec-head .cnt{margin-left:auto;font-size:11.5px;color:var(--ink-4);font-family:var(--mono)}
|
||||||
|
|
||||||
/* 表格 —— [改] 目标件写 overflow:hidden,列一多右边被裁且滑不动;本仓 2026-08-23 定:表宽超出就得能横滑 */
|
/* 表格 —— 表宽超出就得能横滑(本仓 2026-08-23 定) */
|
||||||
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow-x:auto;overflow-y:hidden}
|
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow-x:auto;overflow-y:hidden}
|
||||||
.jc-tbl-wrap, .tbl-wrap { overflow-x: auto; overflow-y: hidden; }
|
.jc-tbl-wrap, .tbl-wrap { overflow-x: auto; overflow-y: hidden; }
|
||||||
table{width:100%;border-collapse:collapse;font-size:12.5px}
|
table{width:100%;border-collapse:collapse;font-size:12.5px}
|
||||||
@@ -115,6 +135,7 @@ tbody tr{transition:background .15s}
|
|||||||
tbody tr:hover{background:var(--brand-wash)}
|
tbody tr:hover{background:var(--brand-wash)}
|
||||||
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
|
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
|
||||||
.pn{font-weight:700}
|
.pn{font-weight:700}
|
||||||
|
.cust{font-weight:700}
|
||||||
|
|
||||||
/* 状态 pill */
|
/* 状态 pill */
|
||||||
.st{display:inline-block;padding:1px 9px;border-radius:999px;font-size:11px;font-weight:800;white-space:nowrap;border:1px solid transparent}
|
.st{display:inline-block;padding:1px 9px;border-radius:999px;font-size:11px;font-weight:800;white-space:nowrap;border:1px solid transparent}
|
||||||
@@ -125,13 +146,6 @@ tbody tr:hover{background:var(--brand-wash)}
|
|||||||
.st.gray{color:var(--ink-3);background:var(--soft);border-color:var(--line)}
|
.st.gray{color:var(--ink-3);background:var(--soft);border-color:var(--line)}
|
||||||
.st.warn{color:var(--human);background:var(--human-soft);border-color:var(--human-line)}
|
.st.warn{color:var(--human);background:var(--human-soft);border-color:var(--human-line)}
|
||||||
|
|
||||||
/* P-01 单记录 KV 卡 */
|
|
||||||
.kv{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--line-soft);border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
|
|
||||||
.kv .cell{background:#fff;padding:10px 14px}
|
|
||||||
.kv .k{font-size:11px;font-weight:800;color:var(--ink-4);margin-bottom:2px}
|
|
||||||
.kv .v{font-size:12.5px;color:var(--ink);line-height:1.5}
|
|
||||||
.kv .span2{grid-column:span 2}
|
|
||||||
|
|
||||||
/* 参数集摘要 chips */
|
/* 参数集摘要 chips */
|
||||||
.pchips{display:flex;flex-wrap:wrap;gap:4px}
|
.pchips{display:flex;flex-wrap:wrap;gap:4px}
|
||||||
.pchips span{font-size:11px;font-family:var(--mono);background:var(--soft);border:1px solid var(--line-soft);border-radius:6px;padding:1px 7px;color:var(--ink-2);white-space:nowrap}
|
.pchips span{font-size:11px;font-family:var(--mono);background:var(--soft);border:1px solid var(--line-soft);border-radius:6px;padding:1px 7px;color:var(--ink-2);white-space:nowrap}
|
||||||
@@ -143,48 +157,40 @@ tbody tr:hover{background:var(--brand-wash)}
|
|||||||
.minor .m .lid{font-family:var(--mono);color:var(--ink-4);font-weight:700;margin-right:6px}
|
.minor .m .lid{font-family:var(--mono);color:var(--ink-4);font-weight:700;margin-right:6px}
|
||||||
|
|
||||||
footer{margin-top:18px;font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
|
footer{margin-top:18px;font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
|
||||||
@media(max-width:900px){.layers{grid-template-columns:repeat(2,1fr)}.kv{grid-template-columns:1fr 1fr}.kv .span2{grid-column:span 2}}
|
@media(max-width:900px){.layers{grid-template-columns:repeat(2,1fr)}}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
/* ==================== 绑定层:徽标别名 / 三态横幅 / 淡显 —— 只取值,不算不判 ==================== */
|
/* ==================== 绑定层:徽标别名 —— 只取值,不算不判 ==================== */
|
||||||
const OK = 'color:var(--ok);background:var(--ok-soft);border-color:#bfe6b4;';
|
const OK = 'color:var(--ok);background:var(--ok-soft);border-color:#bfe6b4;';
|
||||||
const MISS = 'color:var(--danger);background:var(--danger-soft);border-color:#f3b9b5;';
|
const MISS = 'color:var(--danger);background:var(--danger-soft);border-color:#f3b9b5;';
|
||||||
const UNKNOWN = 'color:var(--amber);background:var(--amber-soft);border-color:var(--amber-line);';
|
const UNKNOWN = 'color:var(--amber);background:var(--amber-soft);border-color:var(--amber-line);';
|
||||||
const COND = 'color:var(--brand-deep);background:var(--brand-wash);border-color:var(--brand-soft);';
|
const COND = 'color:var(--brand-deep);background:var(--brand-wash);border-color:var(--brand-soft);';
|
||||||
const GRAY = 'color:var(--ink-3);background:var(--soft);border-color:var(--line);';
|
const GRAY = 'color:var(--ink-3);background:var(--soft);border-color:var(--line);';
|
||||||
const WARN = 'color:var(--human);background:var(--human-soft);border-color:var(--human-line);';
|
|
||||||
|
|
||||||
const BIND_CSS = `
|
const BIND_CSS = `
|
||||||
/* 徽标:值域各归各族(同一个词在不同列意思不同,如 pending 在资料状态是待生效、在需求状态是待处理),
|
/* 徽标:值域各归各族(同一个词在不同列意思不同),文案与颜色由 data-class-of 投出的 v-<token> 决定;
|
||||||
文案与颜色由 data-class-of 投出的 v-<token> 决定;没投出 class 的格子摆「—」,客户批准那格摆「未提交」 */
|
没投出 class 的格子摆「—」 */
|
||||||
.st::after{content:"—"}
|
.st::after{content:"—"}
|
||||||
.st.ap::after{content:"未提交"}
|
|
||||||
${badgeCss('.st.lc', [
|
${badgeCss('.st.lc', [
|
||||||
['active', 'Active', OK],
|
['active', 'Active', OK],
|
||||||
['nrnd', 'NRND', MISS],
|
['nrnd', 'NRND', MISS],
|
||||||
['eol', 'EOL', MISS],
|
['eol', 'EOL', MISS],
|
||||||
['discontinued', '停产', MISS],
|
['discontinued', '停产', MISS],
|
||||||
['risk', '风险', WARN],
|
['risk', '风险', UNKNOWN],
|
||||||
])}
|
])}
|
||||||
${badgeCss('.st.rec', [
|
${badgeCss('.st.rec', [
|
||||||
['recommended', '推荐', OK],
|
['recommended', '推荐', OK],
|
||||||
['conditional', '条件推荐', WARN],
|
['conditional', '条件推荐', UNKNOWN],
|
||||||
['alternate', '备选', COND],
|
|
||||||
['not_recommended', '不推荐', MISS],
|
['not_recommended', '不推荐', MISS],
|
||||||
])}
|
])}
|
||||||
|
/* analysis_status 这次重写没有再声明 tally(本活动只写这一个固定值,分析层与客户批准层分离) */
|
||||||
${badgeCss('.st.an', [
|
${badgeCss('.st.an', [
|
||||||
['ai_draft', 'AI 草稿', GRAY],
|
['fae_confirmed', 'FAE已确认', COND],
|
||||||
['fae_confirmed', 'FAE确认', COND],
|
|
||||||
['pending_customer', '待客户验证', WARN],
|
|
||||||
['customer_approved', '客户已批准', OK],
|
|
||||||
['rejected', '不批准', MISS],
|
|
||||||
['void', '作废', GRAY],
|
|
||||||
])}
|
])}
|
||||||
${badgeCss('.st.rk', [
|
${badgeCss('.st.rk', [
|
||||||
['low', '低', OK],
|
['low', '低', OK],
|
||||||
['medium', '中', WARN],
|
['medium', '中', UNKNOWN],
|
||||||
['high', '高', MISS],
|
['high', '高', MISS],
|
||||||
['critical', '危急', MISS],
|
|
||||||
])}
|
])}
|
||||||
${badgeCss('.st.dst', [
|
${badgeCss('.st.dst', [
|
||||||
['match', 'MATCH', OK],
|
['match', 'MATCH', OK],
|
||||||
@@ -192,36 +198,15 @@ ${badgeCss('.st.dst', [
|
|||||||
['unknown', 'UNKNOWN', UNKNOWN],
|
['unknown', 'UNKNOWN', UNKNOWN],
|
||||||
['conditional', 'CONDITIONAL', COND],
|
['conditional', 'CONDITIONAL', COND],
|
||||||
])}
|
])}
|
||||||
|
/* validation_status 同样没有声明 tally(本活动只写 not_started —— 验证是否通过不是这个活动的事) */
|
||||||
${badgeCss('.st.vs', [
|
${badgeCss('.st.vs', [
|
||||||
['not_started', '未开始', GRAY],
|
['not_started', '未开始', GRAY],
|
||||||
['in_progress', '进行中', COND],
|
|
||||||
['passed', '通过', OK],
|
|
||||||
['failed', '未通过', MISS],
|
|
||||||
['conditional', '条件通过', WARN],
|
|
||||||
['pending_evidence', '待证据', UNKNOWN],
|
|
||||||
])}
|
|
||||||
${badgeCss('.st.ds', [
|
|
||||||
['valid', '有效', OK],
|
|
||||||
['expired', '过期', MISS],
|
|
||||||
['pending', '待生效', WARN],
|
|
||||||
['void', '作废', GRAY],
|
|
||||||
])}
|
|
||||||
${badgeCss('.st.rq', [
|
|
||||||
['pending', '待处理', GRAY],
|
|
||||||
['responded', '已响应', COND],
|
|
||||||
['won', '已成交', OK],
|
|
||||||
['abandoned', '已放弃', MISS],
|
|
||||||
['closed', '已关闭', GRAY],
|
|
||||||
])}
|
|
||||||
${badgeCss('.st.stg', [
|
|
||||||
['assessing', '评估中', GRAY],
|
|
||||||
['gate03', '提交 ACT-GATE-03', OK],
|
|
||||||
])}
|
])}
|
||||||
|
/* 在库状态(sub_eval.part.exists_status)—— 这次重写只剩两档(found/absent),
|
||||||
|
「本单建档」「已放弃」两档随旧的多候选设计一起退役 */
|
||||||
${badgeCss('.st.ex', [
|
${badgeCss('.st.ex', [
|
||||||
['found', '在库直读', COND],
|
['found', '在库直读', COND],
|
||||||
['created', '本单建档', OK],
|
|
||||||
['absent', '缺库', MISS],
|
['absent', '缺库', MISS],
|
||||||
['abandoned', '已放弃', GRAY],
|
|
||||||
])}
|
])}
|
||||||
${badgeCss('.st.rl', [
|
${badgeCss('.st.rl', [
|
||||||
['original', '原 PN', COND],
|
['original', '原 PN', COND],
|
||||||
@@ -229,36 +214,20 @@ ${badgeCss('.st.rl', [
|
|||||||
])}
|
])}
|
||||||
/* 纯文字的枚举列:只补文案 */
|
/* 纯文字的枚举列:只补文案 */
|
||||||
.enumtxt::after{content:"—"}
|
.enumtxt::after{content:"—"}
|
||||||
${badgeCss('.enumtxt.ty', [
|
|
||||||
['pin_to_pin', 'Pin-to-Pin'],
|
|
||||||
['functional', '功能替代'],
|
|
||||||
['parametric', '参数替代'],
|
|
||||||
['other', '其他'],
|
|
||||||
])}
|
|
||||||
${badgeCss('.enumtxt.con', [
|
${badgeCss('.enumtxt.con', [
|
||||||
['hard', '硬约束'],
|
['hard', '硬约束'],
|
||||||
['soft', '软约束'],
|
['soft', '软约束'],
|
||||||
])}
|
])}
|
||||||
|
/* validation_type 这次重写多了 parametric 一档(S2 的验证记录投影固定写它) */
|
||||||
${badgeCss('.enumtxt.vt', [
|
${badgeCss('.enumtxt.vt', [
|
||||||
['pin_to_pin', 'Pin-to-Pin'],
|
['pin_to_pin', 'Pin-to-Pin'],
|
||||||
['functional', '功能'],
|
['functional', '功能'],
|
||||||
['reliability', '可靠性'],
|
['reliability', '可靠性'],
|
||||||
['certification', '认证'],
|
['certification', '认证'],
|
||||||
['retest', '复测'],
|
['retest', '复测'],
|
||||||
])}
|
['parametric', '参数验证'],
|
||||||
${badgeCss('.enumtxt.dt', [
|
|
||||||
['datasheet', 'Datasheet'],
|
|
||||||
['app_note', '应用笔记'],
|
|
||||||
['reference_design', '参考设计'],
|
|
||||||
['white_paper', '白皮书'],
|
|
||||||
['sample_doc', '样品资料'],
|
|
||||||
['commitment_letter', '承诺函'],
|
|
||||||
['other', '其他'],
|
|
||||||
])}
|
])}
|
||||||
|
|
||||||
/* 页头候选料号:一行内逐个摆,用顿号连;一个都没有就摆「—」 */
|
|
||||||
.cands b+b::before{content:"、";font-weight:400;color:var(--ink-3)}
|
|
||||||
.cands:empty::after{content:"—"}
|
|
||||||
/* 参数瓷贴:参数名淡、值粗、单位紧跟 */
|
/* 参数瓷贴:参数名淡、值粗、单位紧跟 */
|
||||||
.pchips i{font-style:normal;color:var(--ink-4);margin-right:3px}
|
.pchips i{font-style:normal;color:var(--ink-4);margin-right:3px}
|
||||||
.pchips b{font-weight:700}
|
.pchips b{font-weight:700}
|
||||||
@@ -269,12 +238,6 @@ ${badgeCss('.enumtxt.dt', [
|
|||||||
.pnrow .st{font-size:10px;padding:0 7px}
|
.pnrow .st{font-size:10px;padding:0 7px}
|
||||||
/* 差异明细:MATCH 行淡显(不藏:那也是比对过的事实),非 MATCH 才是要看的 */
|
/* 差异明细:MATCH 行淡显(不藏:那也是比对过的事实),非 MATCH 才是要看的 */
|
||||||
tr.v-match td{opacity:.55}
|
tr.v-match td{opacity:.55}
|
||||||
/* 已放弃的候选:只在有 abandoned 行时露面,别的核对行不显 */
|
|
||||||
.aband{display:none;font-size:12px;color:var(--ink-3);margin:8px 0 0}
|
|
||||||
.aband i{font-style:normal;font-family:var(--mono);color:var(--ink-2)}
|
|
||||||
.aband i:not(.v-abandoned){display:none}
|
|
||||||
.aband i+i.v-abandoned::before{content:"、"}
|
|
||||||
.aband:has(i.v-abandoned){display:block}
|
|
||||||
/* P-06 历史行:首格挂一枚「历史」灰签 */
|
/* P-06 历史行:首格挂一枚「历史」灰签 */
|
||||||
tbody.hist td:first-child::after{content:"历史";display:inline-block;margin-left:6px;font-size:10px;font-weight:800;padding:0 6px;border-radius:999px;color:var(--ink-3);background:var(--soft);border:1px solid var(--line);font-family:Inter,ui-sans-serif,system-ui,sans-serif}
|
tbody.hist td:first-child::after{content:"历史";display:inline-block;margin-left:6px;font-size:10px;font-weight:800;padding:0 6px;border-radius:999px;color:var(--ink-3);background:var(--soft);border:1px solid var(--line);font-family:Inter,ui-sans-serif,system-ui,sans-serif}
|
||||||
td .sub{display:block;font-size:11px;color:var(--ink-3);font-weight:400}
|
td .sub{display:block;font-size:11px;color:var(--ink-3);font-weight:400}
|
||||||
@@ -305,8 +268,8 @@ export const LEDGER_BOARD = `<!doctype html>
|
|||||||
<header class="mast">
|
<header class="mast">
|
||||||
<div class="kick">SS02 需求分析与方案匹配 · 活动台账视图</div>
|
<div class="kick">SS02 需求分析与方案匹配 · 活动台账视图</div>
|
||||||
<h1><span class="code">ACT-WORK-05</span>关键台账数据</h1>
|
<h1><span class="code">ACT-WORK-05</span>关键台账数据</h1>
|
||||||
<p class="sub case">视角:本活动最近一次已放行实例 <b data-slot="case:instanceNo"></b>(<b data-slot="case:openedAt"></b> 开案 · <b data-slot="case:releasedAt"></b> 放行 · 负责人 <b data-slot="case:owner" data-blank></b>)的落账快照 · 案号 <b class="mono" data-slot="case:ref"></b> · 关联需求 <b data-slot="demand.request.request_id"></b>(客户 <b data-slot="demand.request.customer_code"></b> <span data-slot="customer.profile.name" data-blank></span>)· 评估基准 <b data-slot="${D}.verdict.original_internal_pn"></b>(<span data-slot="parts.profile.brand_name" data-blank></span>)→ 候选 <span class="cands" data-rows="substitution.relation"><b data-slot="candidate_internal_pn"></b></span> · 规则版本 <b data-slot="${D}.verdict.rule_version"></b></p>
|
<p class="sub case">视角:本活动最近一次已放行实例 <b data-slot="case:instanceNo"></b>(<b data-slot="case:openedAt"></b> 开案 · <b data-slot="case:releasedAt"></b> 放行 · 负责人 <b data-slot="case:owner" data-blank></b>)的落账快照 · 案号 <b class="mono" data-slot="case:ref"></b> · 评估基准 <b data-slot="substitution.relation.original_internal_pn"></b> → 候选 <b data-slot="substitution.relation.candidate_internal_pn"></b> · 芯片类型 <span data-slot="parts.chip_type.type_name" data-blank></span> · 规则版本 <b data-slot="substitution.relation.rule_version"></b></p>
|
||||||
<p class="sub verdict">评估结论:<span data-slot="${D}.verdict.summary" data-blank></span></p>
|
<p class="sub verdict">评估结论:<span data-slot="${D}.case.summary" data-blank></span></p>
|
||||||
<div class="legend">
|
<div class="legend">
|
||||||
<span class="li"><span class="dot full"></span>正式台账直读 —— 放行落地后的事实,按案号圈出这一单的行</span>
|
<span class="li"><span class="dot full"></span>正式台账直读 —— 放行落地后的事实,按案号圈出这一单的行</span>
|
||||||
<span class="li"><span class="dot half"></span>同料号的历史验证记录(别单 / 线下留下的,作本次评估的证据引用)</span>
|
<span class="li"><span class="dot half"></span>同料号的历史验证记录(别单 / 线下留下的,作本次评估的证据引用)</span>
|
||||||
@@ -319,64 +282,57 @@ export const LEDGER_BOARD = `<!doctype html>
|
|||||||
<!-- 重要性分层 -->
|
<!-- 重要性分层 -->
|
||||||
<div class="layers">
|
<div class="layers">
|
||||||
<div class="layer"><b><span class="lg">P-08</span> 芯片主数据</b>核心输入 · 评估原材料,查无即硬阻塞</div>
|
<div class="layer"><b><span class="lg">P-08</span> 芯片主数据</b>核心输入 · 评估原材料,查无即硬阻塞</div>
|
||||||
|
<div class="layer evid"><b><span class="lg">字段字典</span> 类型与核心参数</b>比对基准 · 按芯片类型定义要比的字段与硬软约束</div>
|
||||||
<div class="layer core-out"><b><span class="lg">P-11</span> 替代批准关系库</b>核心产出 · 结论与明细的落账地</div>
|
<div class="layer core-out"><b><span class="lg">P-11</span> 替代批准关系库</b>核心产出 · 结论与明细的落账地</div>
|
||||||
<div class="layer evid"><b><span class="lg">P-10</span> 原厂技术资料库</b>证据链 · 每项结论的版本化出处</div>
|
|
||||||
<div class="layer loop"><b><span class="lg">P-06</span> 样品验证记录</b>闭环去向 · UNKNOWN 的实测收口</div>
|
<div class="layer loop"><b><span class="lg">P-06</span> 样品验证记录</b>闭环去向 · UNKNOWN 的实测收口</div>
|
||||||
<div class="layer ctx"><b><span class="lg">P-01</span> 客户需求主台账</b>上下文 · 需求源头与回写对象</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- P-01 -->
|
|
||||||
<section id="p01">
|
|
||||||
<div class="sec-head">
|
|
||||||
<h2><span class="lid">P-01</span>客户需求主台账</h2>
|
|
||||||
<span class="ltag ctx">上下文 · R+W</span>
|
|
||||||
<span class="why">启动时新建需求;结束时回写风险 / 验证要求 / 输出链接 / 下一步 / 当前阶段</span>
|
|
||||||
<span class="cnt">正式表 · <i data-slot="demand_request_rows"></i> 条</span>
|
|
||||||
</div>
|
|
||||||
<div class="kv">
|
|
||||||
<div class="cell"><div class="k">需求编号</div><div class="v mono" data-slot="demand.request.request_id"></div></div>
|
|
||||||
<div class="cell"><div class="k">客户</div><div class="v mono"><span data-slot="demand.request.customer_code"></span> <span class="sub" data-slot="customer.profile.name" data-blank></span></div></div>
|
|
||||||
<div class="cell"><div class="k">需求版本</div><div class="v mono" data-slot="demand.request.requirement_version"></div></div>
|
|
||||||
<div class="cell"><div class="k">需求状态</div><div class="v"><span class="st rq" data-class-of="demand.request.request_status"></span></div></div>
|
|
||||||
<div class="cell span2"><div class="k">需求描述</div><div class="v" data-slot="demand.request.request_description"></div></div>
|
|
||||||
<div class="cell"><div class="k">应用场景</div><div class="v" data-slot="demand.request.application_scenario" data-blank></div></div>
|
|
||||||
<div class="cell"><div class="k">BOM 位</div><div class="v mono" data-slot="demand.request.bom_position" data-blank></div></div>
|
|
||||||
<div class="cell"><div class="k">原内部料号</div><div class="v mono" data-slot="demand.request.original_internal_pn"></div></div>
|
|
||||||
<div class="cell"><div class="k">候选内部料号</div><div class="v mono cands" data-rows="substitution.relation"><b data-slot="candidate_internal_pn"></b></div></div>
|
|
||||||
<div class="cell"><div class="k">风险等级(回写)</div><div class="v"><span class="st rk" data-class-of="demand.request.risk_level"></span></div></div>
|
|
||||||
<div class="cell"><div class="k">验证要求(回写)</div><div class="v mono" data-slot="demand.request.verification_requirement" data-blank></div></div>
|
|
||||||
<div class="cell"><div class="k">当前阶段</div><div class="v"><span class="st stg" data-class-of="demand.request.current_stage"></span></div></div>
|
|
||||||
<div class="cell"><div class="k">标准输出链接</div><div class="v mono" data-slot="demand.request.output_ref" data-blank></div></div>
|
|
||||||
<div class="cell span2"><div class="k">下一步动作</div><div class="v" data-slot="demand.request.next_action" data-blank></div></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- P-08 -->
|
<!-- P-08 -->
|
||||||
<section id="p08">
|
<section id="p08">
|
||||||
<div class="sec-head">
|
<div class="sec-head">
|
||||||
<h2><span class="lid">P-08</span>芯片主数据台账</h2>
|
<h2><span class="lid">P-08</span>芯片主数据台账</h2>
|
||||||
<span class="ltag in">核心输入 · R(查无 → 数据工单创建)</span>
|
<span class="ltag in">核心输入 · R(查无 → 数据工单创建)</span>
|
||||||
<span class="why">原 PN 与候选 PN 的物料基准 + 标准化参数集(子表 P-08 参数),逐项比对的原材料</span>
|
<span class="why">原 PN 与候选 PN 的物料基准 + 标准化参数集(子表 P-08 参数),逐项比对的原材料</span>
|
||||||
<span class="cnt">正式表 · <i data-slot="parts_profile_rows"></i> 条(本活动相关)</span>
|
<span class="cnt">正式表 · <i data-slot="parts_profile_rows"></i> 条(本次评估相关)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tbl-wrap">
|
<div class="tbl-wrap">
|
||||||
<table>
|
<table>
|
||||||
${th('内部料号', '品牌', '规格描述', '封装', '生命周期', 'Datasheet 版本', '标准化参数集', '适配场景')}
|
${th('内部料号', '品牌', '规格描述', '生命周期', 'Datasheet 版本', '标准化参数集')}
|
||||||
<tbody data-rows="parts.profile">
|
<tbody data-rows="parts.profile">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="pn mono"><span data-slot="internal_pn"></span><span class="pnrow" data-rows="${D}.pn_check" data-by="internal_pn"><i class="st rl" data-class-of="role"></i><i class="st ex" data-class-of="exists_status"></i></span></td>
|
<td class="pn mono"><span data-slot="internal_pn"></span><span class="pnrow" data-rows="${D}.part" data-by="internal_pn"><i class="st rl" data-class-of="role"></i><i class="st ex" data-class-of="exists_status"></i></span></td>
|
||||||
<td data-slot="brand_name" data-blank></td>
|
<td data-slot="brand_name" data-blank></td>
|
||||||
<td data-slot="specification_description" data-blank></td>
|
<td data-slot="specification_description" data-blank></td>
|
||||||
<td class="mono" data-slot="package" data-blank></td>
|
|
||||||
<td><span class="st lc" data-class-of="lifecycle_status"></span></td>
|
<td><span class="st lc" data-class-of="lifecycle_status"></span></td>
|
||||||
<td class="mono" data-slot="datasheet_version" data-blank></td>
|
<td class="mono" data-slot="datasheet_version" data-blank></td>
|
||||||
<td><div class="pchips" data-rows="parts.parameter" data-by="internal_pn"><span><i data-slot="param_name"></i><b data-slot="param_value" data-blank></b><em data-slot="param_unit" data-blank></em></span></div></td>
|
<td><div class="pchips" data-rows="parts.parameter" data-by="internal_pn"><span><i data-slot="param_name"></i><b data-slot="param_value" data-blank></b><em data-slot="param_unit" data-blank></em></span></div></td>
|
||||||
<td data-slot="application_scenarios" data-blank></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p class="aband">本轮放弃的候选(未建档、未评估):<span data-rows="${D}.pn_check"><i data-class-of="exists_status" data-slot="internal_pn"></i></span></p>
|
</section>
|
||||||
|
|
||||||
|
<!-- 字段字典 -->
|
||||||
|
<section id="dict">
|
||||||
|
<div class="sec-head">
|
||||||
|
<h2><span class="lid">字段字典</span>芯片类型与核心参数</h2>
|
||||||
|
<span class="ltag evid">比对基准 · R</span>
|
||||||
|
<span class="why">两颗料共同归属的芯片类型,及该类型定义的核心参数字段(名字 / 说明 / 硬软约束 / 单位)</span>
|
||||||
|
<span class="cnt">类型 · <b data-slot="parts.chip_type.type_name" data-blank></b> · 字段 <i data-slot="parts_type_param_rows"></i> 条</span>
|
||||||
|
</div>
|
||||||
|
<div class="tbl-wrap">
|
||||||
|
<table>
|
||||||
|
${th('参数名', '参数说明', '约束', '参考单位')}
|
||||||
|
<tbody data-rows="parts.type_param">
|
||||||
|
<tr>
|
||||||
|
<td class="cust" data-slot="param_name"></td>
|
||||||
|
<td class="sub" data-slot="param_desc" data-blank></td>
|
||||||
|
<td><span class="enumtxt con" data-class-of="constraint_type"></span></td>
|
||||||
|
<td class="mono" data-slot="param_unit" data-blank></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- P-11 -->
|
<!-- P-11 -->
|
||||||
@@ -384,71 +340,44 @@ export const LEDGER_BOARD = `<!doctype html>
|
|||||||
<div class="sec-head">
|
<div class="sec-head">
|
||||||
<h2><span class="lid">P-11</span>替代选型与批准关系库 · 替代关系主记录</h2>
|
<h2><span class="lid">P-11</span>替代选型与批准关系库 · 替代关系主记录</h2>
|
||||||
<span class="ltag out">核心产出 · R+W</span>
|
<span class="ltag out">核心产出 · R+W</span>
|
||||||
<span class="why">每个候选一条关系记录;客户批准状态为只读边界,本活动不写</span>
|
<span class="why">原 PN → 候选 PN 一条关系记录;推荐等级 / 风险 / 摘要由规则算出,客户批准状态为只读边界,本活动不写</span>
|
||||||
<span class="cnt">正式表 · <i data-slot="substitution_relation_rows"></i> 条</span>
|
<span class="cnt">正式表 · <i data-slot="substitution_relation_rows"></i> 条</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tbl-wrap" style="margin-bottom:14px">
|
<div class="tbl-wrap" style="margin-bottom:14px">
|
||||||
<table>
|
<table>
|
||||||
${th('替代关系 ID', '原 → 候选', '替代类型', '推荐等级', '分析状态', '风险', '差异摘要', '验证要求 / 回链', '规则版本', '客户批准(只读)')}
|
${th('替代关系 ID', '原 → 候选', '推荐等级', '分析状态', '风险', '差异摘要 / 理由', '验证要求 / 回链', '规则版本')}
|
||||||
<tbody data-rows="substitution.relation">
|
<tbody data-rows="substitution.relation">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="mono" data-slot="substitution_relation_id"></td>
|
<td class="mono" data-slot="substitution_relation_id"></td>
|
||||||
<td class="mono"><span data-slot="original_internal_pn"></span> → <b data-slot="candidate_internal_pn"></b></td>
|
<td class="mono"><span data-slot="original_internal_pn"></span> → <b data-slot="candidate_internal_pn"></b></td>
|
||||||
<td><span class="enumtxt ty" data-class-of="substitution_type"></span></td>
|
|
||||||
<td><span class="st rec" data-class-of="recommendation_level"></span></td>
|
<td><span class="st rec" data-class-of="recommendation_level"></span></td>
|
||||||
<td><span class="st an" data-class-of="analysis_status"></span></td>
|
<td><span class="st an" data-class-of="analysis_status"></span></td>
|
||||||
<td><span class="st rk" data-class-of="risk_level"></span></td>
|
<td><span class="st rk" data-class-of="risk_level"></span></td>
|
||||||
<td><span data-slot="difference_summary" data-blank></span><span class="sub" data-slot="recommendation_reason" data-blank></span></td>
|
<td><span data-slot="difference_summary" data-blank></span><span class="sub" data-slot="recommendation_reason" data-blank></span></td>
|
||||||
<td class="mono"><span data-slot="validation_id" data-blank></span><span class="sub" data-slot="verification_requirement" data-blank></span></td>
|
<td class="mono"><span data-slot="validation_id" data-blank></span><span class="sub" data-slot="verification_requirement" data-blank></span></td>
|
||||||
<td class="mono" data-slot="rule_version" data-blank></td>
|
<td class="mono" data-slot="rule_version" data-blank></td>
|
||||||
<td><span class="st ap" data-class-of="customer_approval_status"></span></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sec-head" style="margin-bottom:10px">
|
<div class="sec-head" style="margin-bottom:10px">
|
||||||
<h2 style="font-size:13.5px"><span class="lid">P-11</span>差异明细子表(由双方 P-08 标准化参数集逐项比对得出;MATCH 项淡显)</h2>
|
<h2 style="font-size:13.5px"><span class="lid">P-11</span>差异明细子表(按字段字典逐项比对得出;MATCH 项淡显)</h2>
|
||||||
<span class="cnt"><i data-slot="substitution_difference_item_rows"></i> 条 · MATCH <i data-slot="substitution_difference_item_difference_status_match"></i> · MISMATCH <i data-slot="substitution_difference_item_difference_status_mismatch"></i> · UNKNOWN <i data-slot="substitution_difference_item_difference_status_unknown"></i> · CONDITIONAL <i data-slot="substitution_difference_item_difference_status_conditional"></i></span>
|
<span class="cnt"><i data-slot="substitution_difference_item_rows"></i> 条 · MATCH <i data-slot="substitution_difference_item_difference_status_match"></i> · MISMATCH <i data-slot="substitution_difference_item_difference_status_mismatch"></i> · UNKNOWN <i data-slot="substitution_difference_item_difference_status_unknown"></i> · CONDITIONAL <i data-slot="substitution_difference_item_difference_status_conditional"></i></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tbl-wrap">
|
<div class="tbl-wrap">
|
||||||
<table>
|
<table>
|
||||||
${th('所属关系', '参数项', '原值 → 候选值', '约束', '结论', '风险', '证据引用', '影响说明')}
|
${th('参数项', '约束', '原值 → 候选值', '单位', '结论', '风险', '判断依据', '影响说明')}
|
||||||
<tbody data-rows="substitution.difference_item">
|
<tbody data-rows="substitution.difference_item">
|
||||||
<tr data-class-of="difference_status">
|
<tr data-class-of="difference_status">
|
||||||
<td class="mono" data-slot="substitution_relation_id"></td>
|
|
||||||
<td data-slot="parameter_name"></td>
|
<td data-slot="parameter_name"></td>
|
||||||
<td class="mono"><span data-slot="original_value" data-blank></span> → <span data-slot="candidate_value" data-blank></span> <span data-slot="parameter_unit" data-blank></span></td>
|
|
||||||
<td><span class="enumtxt con" data-class-of="constraint_type"></span></td>
|
<td><span class="enumtxt con" data-class-of="constraint_type"></span></td>
|
||||||
|
<td class="mono"><span data-slot="original_value" data-blank></span> → <span data-slot="candidate_value" data-blank></span></td>
|
||||||
|
<td data-slot="parameter_unit" data-blank></td>
|
||||||
<td><span class="st dst" data-class-of="difference_status"></span></td>
|
<td><span class="st dst" data-class-of="difference_status"></span></td>
|
||||||
<td><span class="st rk" data-class-of="risk_level"></span></td>
|
<td><span class="st rk" data-class-of="risk_level"></span></td>
|
||||||
<td class="mono" data-slot="evidence_ref" data-blank></td>
|
<td class="mono" data-slot="evidence_note" data-blank></td>
|
||||||
<td><span data-slot="impact_statement" data-blank></span><span class="sub" data-slot="unknown_disposition" data-blank></span></td>
|
<td data-slot="impact_statement" data-blank></td>
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- P-10 -->
|
|
||||||
<section id="p10">
|
|
||||||
<div class="sec-head">
|
|
||||||
<h2><span class="lid">P-10</span>原厂技术资料库</h2>
|
|
||||||
<span class="ltag evid">证据链 · R(查无 → 数据工单创建)</span>
|
|
||||||
<span class="why">每项结论的 datasheet 出处,版本回链</span>
|
|
||||||
<span class="cnt">正式表 · <i data-slot="parts_document_rows"></i> 条(本活动相关料号)</span>
|
|
||||||
</div>
|
|
||||||
<div class="tbl-wrap">
|
|
||||||
<table>
|
|
||||||
${th('关联料号', '品牌', '资料类型', '资料文件', '版本', '状态')}
|
|
||||||
<tbody data-rows="parts.document">
|
|
||||||
<tr>
|
|
||||||
<td class="mono"><span data-slot="internal_pn"></span> · <span data-slot="doc_seq" data-blank></span></td>
|
|
||||||
<td data-slot="brand_name" data-blank></td>
|
|
||||||
<td><span class="enumtxt dt" data-class-of="document_type"></span></td>
|
|
||||||
<td class="mono" data-slot="document_ref" data-blank></td>
|
|
||||||
<td class="mono" data-slot="document_version" data-blank></td>
|
|
||||||
<td><span class="st ds" data-class-of="document_status"></span></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -460,33 +389,29 @@ export const LEDGER_BOARD = `<!doctype html>
|
|||||||
<div class="sec-head">
|
<div class="sec-head">
|
||||||
<h2><span class="lid">P-06</span>样品交付与芯片验证记录台账</h2>
|
<h2><span class="lid">P-06</span>样品交付与芯片验证记录台账</h2>
|
||||||
<span class="ltag loop">闭环去向 · R + 条件写(不可创建,只能实测产生)</span>
|
<span class="ltag loop">闭环去向 · R + 条件写(不可创建,只能实测产生)</span>
|
||||||
<span class="why">UNKNOWN / CONDITIONAL 的收口处;验证完成后回链 P-11</span>
|
<span class="why">UNKNOWN / CONDITIONAL 的收口处;一单固定一条</span>
|
||||||
<span class="cnt">本单挂的 · <i data-slot="validation_record_rows"></i> 条 · 同料号历史 · <i data-slot="validation_history_rows"></i> 条</span>
|
<span class="cnt">本单挂的 · <i data-slot="validation_record_rows"></i> 条 · 同料号历史 · <i data-slot="validation_history_rows"></i> 条</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tbl-wrap">
|
<div class="tbl-wrap">
|
||||||
<table>
|
<table>
|
||||||
${th('验证 ID', '关联需求', '验证料号', '验证类型', '测试条件', '结果 / 状态', '问题与风险', '后续动作')}
|
${th('验证 ID', '验证料号', '验证类型', '测试条件', '结果 / 状态', '后续动作')}
|
||||||
<tbody data-rows="validation.record">
|
<tbody data-rows="validation.record">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="mono" data-slot="validation_id"></td>
|
<td class="mono" data-slot="validation_id"></td>
|
||||||
<td class="mono" data-slot="request_id" data-blank></td>
|
|
||||||
<td class="mono" data-slot="validation_internal_pn"></td>
|
<td class="mono" data-slot="validation_internal_pn"></td>
|
||||||
<td><span class="enumtxt vt" data-class-of="validation_type"></span></td>
|
<td><span class="enumtxt vt" data-class-of="validation_type"></span></td>
|
||||||
<td data-slot="test_condition" data-blank></td>
|
<td data-slot="test_condition" data-blank></td>
|
||||||
<td><span class="st vs" data-class-of="validation_status"></span><span class="sub" data-slot="test_result" data-blank></span><span class="sub mono" data-slot="evidence_ref" data-blank></span></td>
|
<td><span class="st vs" data-class-of="validation_status"></span></td>
|
||||||
<td data-slot="issue_risk" data-blank></td>
|
|
||||||
<td data-slot="next_validation_action" data-blank></td>
|
<td data-slot="next_validation_action" data-blank></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
<tbody class="hist" data-rows="validation.history">
|
<tbody class="hist" data-rows="validation.history">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="mono" data-slot="validation_id"></td>
|
<td class="mono" data-slot="validation_id"></td>
|
||||||
<td class="mono" data-slot="request_id" data-blank></td>
|
|
||||||
<td class="mono" data-slot="validation_internal_pn"></td>
|
<td class="mono" data-slot="validation_internal_pn"></td>
|
||||||
<td><span class="enumtxt vt" data-class-of="validation_type"></span></td>
|
<td><span class="enumtxt vt" data-class-of="validation_type"></span></td>
|
||||||
<td data-slot="test_condition" data-blank></td>
|
<td data-slot="test_condition" data-blank></td>
|
||||||
<td><span class="st vs" data-class-of="validation_status"></span><span class="sub" data-slot="test_result" data-blank></span><span class="sub mono" data-slot="evidence_ref" data-blank></span></td>
|
<td><span class="st vs" data-class-of="validation_status"></span></td>
|
||||||
<td data-slot="issue_risk" data-blank></td>
|
|
||||||
<td data-slot="next_validation_action" data-blank></td>
|
<td data-slot="next_validation_action" data-blank></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -500,15 +425,13 @@ export const LEDGER_BOARD = `<!doctype html>
|
|||||||
<h2>次要台账(只读引用,不进主看板)</h2>
|
<h2>次要台账(只读引用,不进主看板)</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="minor">
|
<div class="minor">
|
||||||
<div class="m"><b><span class="lid">P-09</span>客户技术与机会档案</b>提供 BOM 位与客户技术栈背景;缺口由执行工单补齐。</div>
|
<div class="m"><b><span class="lid">GOV</span>评估规则库</b>四态判定与推荐等级口径;本次锁定版本 <span class="mono" data-slot="substitution.relation.rule_version"></span>,随结论一并落账。</div>
|
||||||
<div class="m"><b><span class="lid">EXT-02</span>外部来源清单</b>登记本次评估用到的可信外部来源(原厂官网 datasheet 等)。</div>
|
|
||||||
<div class="m"><b><span class="lid">GOV</span>评估规则库</b>四态判定口径;本次锁定版本 <span class="mono" data-slot="${D}.verdict.rule_version"></span>,随结论一并落账。</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<span>SS02 · ACT-WORK-05 差异评估 · 关键台账数据视图</span>
|
<span>SS02 · ACT-WORK-05 差异评估 · 关键台账数据视图</span>
|
||||||
<span style="font-family:var(--mono)">数据来源:正式台账(parts / demand / substitution / validation / sub_eval)· 实例 <span data-slot="case:instanceNo"></span> · 案号 <span data-slot="case:ref"></span></span>
|
<span style="font-family:var(--mono)">数据来源:正式台账(parts / substitution / validation / sub_eval)· 实例 <span data-slot="case:instanceNo"></span> · 案号 <span data-slot="case:ref"></span></span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,481 @@
|
|||||||
|
/**
|
||||||
|
* ACT-WORK-05 三张工单件的设计系统(2026-08-31 视图层整体重设计。用户定:
|
||||||
|
* 「中间这一块不要搞的像后台管理端,要像给客户使用的 C 端界面;可以按主题加 SVG 图形,
|
||||||
|
* 可以有动画,加强交互 —— 只保留功能,视图层可以推翻重新设计」)。
|
||||||
|
*
|
||||||
|
* 设计语言:「精密检测报告」——
|
||||||
|
* · 题头是一块深色电路板夜景(hero):芯片封装、铜引脚、流动的走线信号,
|
||||||
|
* 三张件各有一幕对应自己职能的场景(建档台 / 对决量尺 / 落账席);
|
||||||
|
* · 正文回到瓷白纸面,报告化排版:丝印体小标(silkscreen)、mono 料号、
|
||||||
|
* 引脚号式的序号列 —— 用电子行业自己的版面语汇,不借后台管理端的灰表格;
|
||||||
|
* · 动画只做 transform / opacity(信号点流动、晶粒呼吸、印章环慢转、入场浮起),
|
||||||
|
* prefers-reduced-motion 下全部静止 —— 与工作台动画同一条纪律。
|
||||||
|
*
|
||||||
|
* 旧的 target-style.ts(逐字节抽自目标件的三段样式)随本次重设计退役:目标件是
|
||||||
|
* 「照着做」时代的锚,这一版是活动自己的设计,样式只此一处真源。
|
||||||
|
*
|
||||||
|
* **这一整段活在 TS 模板字符串里 —— 不许出现反引号**(一个反引号就把串截断,
|
||||||
|
* esbuild 报在几行之外;本仓踩过)。注释里也别写星号斜杠连排。
|
||||||
|
*
|
||||||
|
* 几条被包内测试钉死的**机制规则逐字保留**,别「顺手整理」:
|
||||||
|
* · 序号计数器五连(.jc-tbl-wrap table, .tbl-wrap table 成对出现);
|
||||||
|
* · table:not(.on) tr.jc-row-static { display: none; }(种子行只在编辑态露面);
|
||||||
|
* · .jc-gate-bar:has(.jc-gate-text:empty) 显隐(gate_reason 是中文自由文本,
|
||||||
|
* data-class-of 在它上面永远算不出 v-token,显隐只认 data-blank 出的空文本节点);
|
||||||
|
* · .jc-tbl-wrap 不许 overflow:hidden(表宽超出要能横着滑)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 件① 数据工单的强调色 —— 数据蓝(与治理台「数据」语义同族) */
|
||||||
|
export const ACCENT_DATA = `
|
||||||
|
:root{
|
||||||
|
--acc:#2e6bff; --acc-deep:#1f52d6; --acc-soft:#e3ecff; --acc-line:#c2d5ff; --acc-wash:#f3f7ff;
|
||||||
|
--glow:#6fa1ff; --glow-line:rgba(111,161,255,.36); --hero-tint:rgba(46,107,255,.34);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** 件②③ 验收工单的强调色 —— 验收绿 #0aa294(2026-08-27 用户定:与对话区验收卡同色) */
|
||||||
|
export const ACCENT_LEDGER = `
|
||||||
|
:root{
|
||||||
|
--acc:#0aa294; --acc-deep:#07857a; --acc-soft:#dcf5f1; --acc-line:#aee5dd; --acc-wash:#f0fbf9;
|
||||||
|
--glow:#2fd4c2; --glow-line:rgba(47,212,194,.34); --hero-tint:rgba(10,162,148,.32);
|
||||||
|
--ledger:#0aa294;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **验收工单那一副的强调色**(2026-08-31 用户:「标题那一块需要是验收卡的那种绿色」)——
|
||||||
|
* 件① 一张两副面孔:数据工单蓝、验收工单绿。整套令牌从 `ACCENT_LEDGER` **现改选择器**得来,
|
||||||
|
* 数值只有一处真源(件②③ 与对话区验收卡同一枚 #0aa294);题头那条浅色渐变是写死的蓝白,
|
||||||
|
* 单独给一条绿的override —— `.card.v-accept`(0,2,0)比 `:root`(0,1,0)特异,先后顺序不吃亏。
|
||||||
|
*/
|
||||||
|
export const ACCENT_ACCEPT = `${ACCENT_LEDGER.replace(':root{', '.card.v-accept{')}
|
||||||
|
.card.v-accept.hd-light .card-head{background:linear-gradient(120deg,#e9f9f5 0%,#fff 55%,#e3f7f1 100%);border-bottom-color:var(--acc-deep)}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SHELL_CSS = `
|
||||||
|
/* ================= 令牌与底 ================= */
|
||||||
|
:root{
|
||||||
|
--ink:#182430; --ink-2:#3d4c5c; --ink-3:#67788a; --ink-4:#97a5b3;
|
||||||
|
--line:#dbe3ea; --line-soft:#e8edf3; --soft:#f5f8fa; --paper:#edf1f5;
|
||||||
|
--board:#0e1b27; --board-2:#15263a;
|
||||||
|
--cu:#d99a5b; --cu-deep:#b57632;
|
||||||
|
--ok:#1e9e56; --ok-soft:#e4f7ec;
|
||||||
|
--danger:#dd4a3a; --danger-soft:#feecea;
|
||||||
|
--amber:#b07a10; --amber-soft:#fdf3d8;
|
||||||
|
--cond:#5661f0; --cond-soft:#e9ebfe;
|
||||||
|
--hero-ink:#f4f8fc; --hero-dim:rgba(226,238,248,.62);
|
||||||
|
--mono:"SF Mono",Menlo,Consolas,monospace;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
html,body{margin:0;padding:0}
|
||||||
|
body{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:var(--paper);color:var(--ink);font-size:14px;line-height:1.7;-webkit-font-smoothing:antialiased}
|
||||||
|
.page{margin:0;padding:0}
|
||||||
|
.card{background:#fff;overflow:hidden;border:0;border-radius:0}
|
||||||
|
.mono{font-family:var(--mono)}
|
||||||
|
i{font-style:normal}
|
||||||
|
|
||||||
|
/* ================= 题头:电路板夜景 hero ================= */
|
||||||
|
.card-head{
|
||||||
|
position:relative;display:flex;flex-direction:column;gap:12px;padding:20px 22px 22px;color:var(--hero-ink);
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(0deg, rgba(255,255,255,.03) 0 1px, transparent 1px 28px),
|
||||||
|
repeating-linear-gradient(90deg, rgba(255,255,255,.03) 0 1px, transparent 1px 28px),
|
||||||
|
radial-gradient(900px 340px at 18% -40%, var(--hero-tint), transparent 62%),
|
||||||
|
linear-gradient(158deg, #13212f 0%, var(--board) 56%, #0f1d2b 100%);
|
||||||
|
}
|
||||||
|
.hero-top{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||||||
|
.hero-sys{flex:1 1 auto;min-width:0;font-size:10.5px;font-weight:800;letter-spacing:.17em;color:var(--hero-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.hero-chips{flex:none;display:flex;gap:6px}
|
||||||
|
.hero-tag{font-size:11px;font-weight:800;padding:2px 11px;border-radius:999px;color:#fff;background:var(--acc);box-shadow:0 4px 14px var(--glow-line);white-space:nowrap}
|
||||||
|
.hero-step{font-size:11px;font-weight:700;padding:2px 11px;border-radius:999px;color:var(--hero-dim);border:1px solid rgba(255,255,255,.22);white-space:nowrap}
|
||||||
|
.hero-title{margin:0;font-size:23px;font-weight:800;line-height:1.25;letter-spacing:.01em;color:#fff}
|
||||||
|
.hero-meta{font-size:11.5px;color:var(--hero-dim)}
|
||||||
|
.hero-meta b{font-weight:700;color:#fff}
|
||||||
|
|
||||||
|
/* ── hero 舞台:芯片铭牌 + 信号走线 ── */
|
||||||
|
.stage{position:relative;display:flex;align-items:stretch;justify-content:center;gap:clamp(20px,7vw,72px);padding:14px 6px 6px}
|
||||||
|
.stage::before{content:"";position:absolute;left:5%;right:5%;top:52%;border-top:2px dashed var(--glow-line)}
|
||||||
|
.stage::after{content:"";position:absolute;top:52%;left:5%;width:9px;height:9px;margin-top:-4px;border-radius:999px;background:var(--glow);box-shadow:0 0 14px 3px var(--glow-line),0 0 4px 1px var(--glow);animation:sig 3.4s linear infinite}
|
||||||
|
.stage-lab{padding-bottom:8px}
|
||||||
|
.stage-duel{padding-top:28px}
|
||||||
|
.hchip{position:relative;z-index:1;display:flex;flex-direction:column;align-items:center;gap:6px;flex:0 1 176px;min-width:128px;padding:13px 12px 11px;border-radius:14px;background:rgba(255,255,255,.97);color:var(--ink);box-shadow:0 14px 30px rgba(3,10,18,.5),inset 0 1px 0 rgba(255,255,255,.7)}
|
||||||
|
.hchip.v-absent{background:rgba(255,252,246,.97);outline:2px dashed var(--cu);outline-offset:-6px}
|
||||||
|
.hchip-role{font-size:10.5px;font-weight:800;letter-spacing:.08em;color:var(--ink-4)}
|
||||||
|
.hchip-pn{font-size:14.5px;font-weight:800;color:var(--ink);overflow-wrap:anywhere;text-align:center;line-height:1.3}
|
||||||
|
.hchip-badges{display:flex;gap:6px;flex-wrap:wrap;justify-content:center}
|
||||||
|
.csvg{width:86px;height:auto;display:block}
|
||||||
|
.c-body{fill:var(--board-2);stroke:rgba(217,154,91,.55);stroke-width:1.4}
|
||||||
|
.c-pin{fill:var(--cu)}
|
||||||
|
.c-die{fill:var(--acc);opacity:.9;animation:breathe 2.8s ease-in-out infinite}
|
||||||
|
.c-dot{fill:rgba(255,255,255,.85)}
|
||||||
|
.duel-type{position:absolute;z-index:1;left:50%;top:-4px;transform:translateX(-50%);font-size:10.5px;font-weight:800;letter-spacing:.06em;color:var(--hero-dim);border:1px solid rgba(255,255,255,.2);border-radius:999px;padding:2px 12px;background:rgba(10,20,30,.55);white-space:nowrap}
|
||||||
|
.duel-type i{font-style:normal;color:#fff}
|
||||||
|
.hero-verdict{display:flex;justify-content:center;padding:2px 0 0}
|
||||||
|
|
||||||
|
/* ── hero 落账席(件③):印章 + 一句话结论 ── */
|
||||||
|
.stage-seal{position:relative;display:flex;align-items:center;justify-content:center;gap:22px;padding:12px 4px 4px;flex-wrap:wrap}
|
||||||
|
.seal{position:relative;flex:none;width:112px;height:112px;border-radius:999px;display:grid;place-items:center}
|
||||||
|
.seal::before{content:"";position:absolute;inset:0;border:1.6px dashed rgba(255,255,255,.34);border-radius:999px;animation:sealspin 16s linear infinite}
|
||||||
|
.seal-core{display:grid;place-items:center;padding:7px 11px;border-radius:999px;background:rgba(255,255,255,.97);box-shadow:0 12px 26px rgba(3,10,18,.5)}
|
||||||
|
/*
|
||||||
|
* **印章里的徽章按圈的内径收**(2026-09-01 用户:「有条件推荐这个 svg 挡住旁边的字了」——
|
||||||
|
* 浏览器里量的账:seal-core 139px 宽,而印章圈只有 112px,**溢出 26px**,
|
||||||
|
* 右边那句结论从 603px 起,正好被压住)。
|
||||||
|
* 病根是徽章按自己的字宽撑开、又带着 white-space:nowrap 缩不了,而印章圈是定死的圆。
|
||||||
|
* 治法两条一起:核心不许超过圈的内径;圈里的徽章字号收小、允许折行(圆里两行照样是圆)。
|
||||||
|
*/
|
||||||
|
.seal-core{max-width:calc(100% - 16px);padding:6px 8px}
|
||||||
|
.seal .rec.rec-lg{font-size:12px;letter-spacing:0;padding:5px 8px;white-space:normal;text-align:center;line-height:1.3}
|
||||||
|
.seal-sum{flex:1 1 240px;min-width:200px;max-width:420px;margin:0;font-size:13px;line-height:1.85;color:var(--hero-dim)}
|
||||||
|
.seal-sum:empty::after{content:"—"}
|
||||||
|
|
||||||
|
/* ── 浅色题头变体(件① 2026-08-31 用户:「标题背景还是改成之前的那种蓝色渐变」——
|
||||||
|
渐变取自原目标件 card-head 那一条,深色电路夜景只留给验收两件)── */
|
||||||
|
.hd-light .card-head{background:linear-gradient(120deg,#f0f5ff 0%,#fff 55%,#eef4ff 100%);color:var(--ink);border-bottom:2px solid var(--ink)}
|
||||||
|
.hd-light .hero-sys{color:var(--ink-4)}
|
||||||
|
.hd-light .hero-title{color:var(--ink)}
|
||||||
|
.hd-light .hero-step{color:var(--ink-3);border-color:var(--line)}
|
||||||
|
.hd-light .hero-meta{color:var(--ink-3)}
|
||||||
|
.hd-light .hero-meta b{color:var(--ink)}
|
||||||
|
|
||||||
|
/* ================= 正文分区 ================= */
|
||||||
|
.card-bd{padding:20px 22px 8px}
|
||||||
|
.sec{display:flex;align-items:baseline;gap:9px;margin:26px 0 10px;flex-wrap:wrap}
|
||||||
|
.sec:first-child{margin-top:4px}
|
||||||
|
.sec-pin{flex:none;align-self:center;width:24px;height:24px;border-radius:8px;display:grid;place-items:center;background:var(--acc-soft);color:var(--acc-deep)}
|
||||||
|
.zic{width:13px;height:13px;display:block}
|
||||||
|
.sec-t{font-size:13.5px;font-weight:800;color:var(--ink);letter-spacing:.02em}
|
||||||
|
.sec-d{flex:1 1 200px;min-width:0;font-size:11.5px;color:var(--ink-3)}
|
||||||
|
|
||||||
|
/* 简报卡(做什么 / 纪律…) */
|
||||||
|
.brief{border:1px solid var(--line-soft);border-left:3px solid var(--acc);border-radius:10px;background:var(--acc-wash);padding:13px 16px;margin:0 0 4px}
|
||||||
|
.brief p{margin:0 0 7px;font-size:12.5px;line-height:1.75;color:var(--ink-2)}
|
||||||
|
.brief p:last-child{margin-bottom:0}
|
||||||
|
.brief b{color:var(--ink)}
|
||||||
|
.done,.need{display:inline-block;font-size:10.5px;font-weight:800;padding:0 8px;border-radius:999px;vertical-align:1px;margin-right:2px}
|
||||||
|
.done{color:var(--ok);background:var(--ok-soft)}
|
||||||
|
.need{color:var(--acc-deep);background:var(--acc-soft)}
|
||||||
|
|
||||||
|
/* 收尾指引 */
|
||||||
|
.flow{margin:24px 0 14px;padding:12px 16px;border-radius:10px;border:1px dashed var(--acc-line);background:var(--acc-wash);font-size:12.5px;color:var(--ink-2);line-height:1.8}
|
||||||
|
.flow b{color:var(--acc-deep)}
|
||||||
|
|
||||||
|
/* 页脚:丝印条 */
|
||||||
|
.card-foot{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:12px 22px;border-top:1px solid var(--line-soft);background:var(--soft);font-size:11px;color:var(--ink-4);flex-wrap:wrap}
|
||||||
|
.foot-r{letter-spacing:.04em}
|
||||||
|
|
||||||
|
/* ================= 顶部「还差什么」红带(机制:显隐认 data-blank 的空文本节点) ================= */
|
||||||
|
.jc-gate-bar{display:flex;flex-direction:column;gap:5px;margin:0 0 16px;padding:11px 14px;border-radius:10px;background:var(--danger-soft);border:1px solid #f5c0ba;color:var(--danger);font-size:12.5px;line-height:1.7}
|
||||||
|
.jc-gate-bar b{font-weight:800}
|
||||||
|
.jc-gate-bar:has(.jc-gate-text:empty){display:none}
|
||||||
|
/* 都齐了就整条收起(2026-08-31 用户定)—— 开关是案卷真数据 gate_state(v-clear),
|
||||||
|
不看那句话空不空:gate_reason 清空时按列归型不落笔,旧话会一直留在草稿里 */
|
||||||
|
.jc-gate-bar.v-clear{display:none}
|
||||||
|
/* 逐块的缺项点名并进这一条(2026-08-31 用户:「页面上所有的问题都合并到最上面的还差什么」)——
|
||||||
|
与提交闸同源的基座读数,数值为 0 的那一行自己消失,只在待处理态出现 */
|
||||||
|
.jc-gate-sub{display:none;font-size:12px;color:#a03d34;padding-left:2px}
|
||||||
|
html.editable .jc-gate-sub{display:block}
|
||||||
|
html.editable .jc-gate-sub.v-0{display:none}
|
||||||
|
|
||||||
|
/* 缺项红带 —— 与提交闸同源的必填提示,只在待处理态出现 */
|
||||||
|
.jc-gapbar{display:none}
|
||||||
|
html.editable .jc-gapbar{display:flex;align-items:flex-start;gap:8px;margin:0 0 10px;padding:9px 13px;border-radius:9px;background:var(--danger-soft);border:1px solid #f5c0ba;color:var(--danger);font-size:12px;line-height:1.7}
|
||||||
|
.jc-gapbar b{font-weight:800}
|
||||||
|
.jc-gapbar span{color:#a03d34}
|
||||||
|
html.editable .jc-gapbar.v-0{display:none}
|
||||||
|
|
||||||
|
/* ================= 编辑闸与加行(只在待处理态露面) ================= */
|
||||||
|
html:not(.editable) .jc-editgate, html:not(.editable) .jc-addrow { display: none !important; }
|
||||||
|
.jc-editgate{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:0 0 10px;padding:8px 12px;border-radius:10px;background:var(--soft);border:1px solid var(--line-soft);font-size:12px;color:var(--ink-3)}
|
||||||
|
.jc-editgate-t{flex:1 1 auto;min-width:0}
|
||||||
|
.jc-editgate-t b{color:var(--ink-2);font-weight:700}
|
||||||
|
.jc-egbtn{flex:none;display:inline-flex;align-items:center;gap:5px;cursor:pointer;border:1px solid var(--line);border-radius:999px;padding:6px 15px;font:inherit;font-size:12px;font-weight:700;color:var(--ink-2);background:#fff;transition:border-color .16s,color .16s,background .16s,box-shadow .16s}
|
||||||
|
.jc-egbtn:hover{border-color:var(--acc);color:var(--acc-deep);box-shadow:0 0 0 3px var(--acc-soft)}
|
||||||
|
.jc-egbtn:active{transform:scale(.96)}
|
||||||
|
.jc-egbtn.save{color:#fff;background:var(--acc);border-color:var(--acc)}
|
||||||
|
.jc-egbtn.save:hover{filter:brightness(1.06);color:#fff}
|
||||||
|
.jc-editgate .eg-cancel,.jc-editgate .eg-save{display:none}
|
||||||
|
.jc-editgate.on{background:var(--acc-wash);border-color:var(--acc-line);color:var(--acc-deep)}
|
||||||
|
.jc-editgate.on .eg-open{display:none}
|
||||||
|
.jc-editgate.on .eg-cancel,.jc-editgate.on .eg-save{display:inline-flex}
|
||||||
|
.jc-addrow{display:none;margin:8px 0 0}
|
||||||
|
.jc-tbl-wrap:has(table.on) + .jc-addrow { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.jc-addrow button{cursor:pointer;border:1px dashed var(--acc-line);border-radius:999px;background:var(--acc-wash);color:var(--acc-deep);font:inherit;font-size:12px;font-weight:700;padding:6px 15px;transition:border-color .16s,box-shadow .16s}
|
||||||
|
.jc-addrow button:hover{border-color:var(--acc);box-shadow:0 0 0 3px var(--acc-soft)}
|
||||||
|
.jc-addrow button:active{transform:scale(.96)}
|
||||||
|
.jc-addrow em{font-style:normal;font-size:11px;color:var(--ink-4)}
|
||||||
|
|
||||||
|
/* ================= 报告表格 ================= */
|
||||||
|
.jc-tbl-wrap{border:1px solid var(--line-soft);border-radius:12px;background:#fff;overflow-x:auto;overflow-y:hidden}
|
||||||
|
.jc-tbl-wrap, .tbl-wrap { overflow-x: auto; overflow-y: hidden; }
|
||||||
|
table{width:100%;border-collapse:collapse;font-size:12.5px}
|
||||||
|
thead th{position:static;text-align:left;font-size:10.5px;font-weight:800;letter-spacing:.07em;color:var(--ink-4);background:var(--soft);padding:9px 12px;border-bottom:1px solid var(--line);white-space:nowrap}
|
||||||
|
th.req::after{content:" *";color:var(--danger);font-size:10px}
|
||||||
|
tbody td{padding:10px 12px;border-bottom:1px solid var(--line-soft);vertical-align:top;color:var(--ink-2)}
|
||||||
|
tbody tr:last-child td{border-bottom:0}
|
||||||
|
tbody tr:hover td{background:var(--acc-wash)}
|
||||||
|
td.rn,td.num{font-family:var(--mono);font-size:11px;color:var(--ink-4);white-space:nowrap}
|
||||||
|
td.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
|
||||||
|
td.cust{font-weight:700;color:var(--ink)}
|
||||||
|
td.desc,.desc{color:var(--ink-3);font-size:12px;line-height:1.6}
|
||||||
|
|
||||||
|
/* 序号列交给 CSS 计数器(沙箱零脚本,增删行后自己数对;两种列名、两种包装类都接) */
|
||||||
|
.jc-tbl-wrap table, .tbl-wrap table { counter-reset: rn; }
|
||||||
|
.jc-tbl-wrap table td.rn, .tbl-wrap table td.rn { counter-increment: rn; }
|
||||||
|
.jc-tbl-wrap table td.rn .jc-rn-num::before, .tbl-wrap table td.rn .jc-rn-num::before { content: counter(rn); }
|
||||||
|
.jc-tbl-wrap table td.num, .tbl-wrap table td.num { counter-increment: rn; }
|
||||||
|
.jc-tbl-wrap table td.num::before, .tbl-wrap table td.num::before { content: counter(rn); }
|
||||||
|
|
||||||
|
/* ── 看与改是两件事:只读份 / 输入孪生件由 .on 二选一 ── */
|
||||||
|
.jc-vcell{display:block;font-size:12.5px;color:var(--ink);line-height:1.55;overflow-wrap:anywhere}
|
||||||
|
.jc-vcell.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
|
||||||
|
.jc-vcell:empty::after{content:"—";color:var(--ink-4)}
|
||||||
|
.jc-cell{display:none;width:100%;min-width:72px;border:1px solid var(--line);border-radius:8px;padding:6px 9px;font:inherit;font-size:12.5px;color:var(--ink);background:#fff}
|
||||||
|
.jc-cell:focus{border-color:var(--acc);box-shadow:0 0 0 3px var(--acc-soft);outline:none}
|
||||||
|
.jc-cell.mono{font-family:var(--mono);font-size:12px}
|
||||||
|
input.jc-cell, select.jc-cell { height: 32px; box-sizing: border-box; padding-top: 0; padding-bottom: 0; vertical-align: middle; }
|
||||||
|
table.on td>.jc-vcell{display:none}
|
||||||
|
table.on td>.jc-vcell.ro{display:block}
|
||||||
|
table.on td>.jc-cell{display:block}
|
||||||
|
table.on td>.jc-vcell:empty::after{content:none}
|
||||||
|
|
||||||
|
/* 增删行的痕(取消还原三件套的可视面) */
|
||||||
|
table.on tr.jc-row-new td { background: #f0fbf5; }
|
||||||
|
table.on tr.jc-row-gone td { background: #fff6f5; opacity: .55; }
|
||||||
|
table.on tr.jc-row-gone .jc-cell { text-decoration: line-through; color: var(--ink-3); }
|
||||||
|
.jc-rowdel{display:none;cursor:pointer;border:1px solid var(--line);border-radius:999px;background:#fff;color:var(--ink-3);width:20px;height:20px;line-height:1;font-size:12px;padding:0;margin-left:5px;vertical-align:middle}
|
||||||
|
table.on .jc-rowdel{display:inline-grid;place-items:center}
|
||||||
|
.jc-rowdel:hover{border-color:var(--danger);color:var(--danger)}
|
||||||
|
table.on tr.jc-row-gone .jc-rowdel { border-color: var(--danger); color: var(--danger); background: #fff; }
|
||||||
|
|
||||||
|
/* 静态种子行:零行也加得出第一行 —— 只在编辑态露面(读态看见空壳子是界面撒谎) */
|
||||||
|
table:not(.on) tr.jc-row-static { display: none; }
|
||||||
|
table.on tr.jc-row-static td{background:var(--acc-wash);border-top:1px dashed var(--acc-line)}
|
||||||
|
|
||||||
|
/* 主键钥匙格:不给人看,但真挂 data-col(漏了钥匙,这一行凑不出主键) */
|
||||||
|
.rowkey{display:none}
|
||||||
|
|
||||||
|
/* ================= 徽标基座(取值上色一律 data-class-of 投影,选择器从值算) ================= */
|
||||||
|
.stx,.ct,.rl,.st,.risk-badge{display:inline-block;font-size:11px;font-weight:700;padding:1px 9px;border-radius:999px;border:1px solid transparent;white-space:nowrap}
|
||||||
|
.rl{background:var(--soft);border-color:var(--line-soft);color:var(--ink-3);font-size:10.5px;letter-spacing:.03em}
|
||||||
|
.rec{display:inline-block;font-size:11.5px;font-weight:800;padding:2px 13px;border-radius:999px;border:1px solid transparent;white-space:nowrap}
|
||||||
|
.rec.rec-lg{font-size:14.5px;padding:7px 20px;letter-spacing:.03em}
|
||||||
|
|
||||||
|
/* kv 行(收口结论 / 待验项共用的键值版式) */
|
||||||
|
.kv{display:flex;gap:12px;padding:10px 0;border-bottom:1px solid var(--line-soft);font-size:12.5px}
|
||||||
|
.kv:last-child{border-bottom:0}
|
||||||
|
.kv i{flex:0 0 84px;font-style:normal;color:var(--ink-4);font-size:11px;font-weight:700;letter-spacing:.05em;padding-top:2px}
|
||||||
|
.kv > span{flex:1 1 auto;color:var(--ink-2);line-height:1.7;overflow-wrap:anywhere}
|
||||||
|
.kv > span:empty::after{content:"—";color:var(--ink-4)}
|
||||||
|
.kv-box{border:1px solid var(--line-soft);border-radius:12px;background:var(--soft);padding:4px 16px;margin-bottom:4px}
|
||||||
|
|
||||||
|
/* ================= 动画(全部 transform / opacity;reduced-motion 全静) ================= */
|
||||||
|
@keyframes sig{0%{transform:translateX(0);opacity:0}7%{opacity:1}93%{opacity:1}100%{transform:translateX(min(86vw,760px));opacity:0}}
|
||||||
|
@keyframes breathe{0%,100%{opacity:.55}50%{opacity:1}}
|
||||||
|
@keyframes sealspin{to{transform:rotate(360deg)}}
|
||||||
|
@keyframes rise{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
|
||||||
|
.hero-top{animation:rise .5s cubic-bezier(.2,.7,.3,1) both}
|
||||||
|
.hero-title{animation:rise .5s .06s cubic-bezier(.2,.7,.3,1) both}
|
||||||
|
.stage,.stage-seal{animation:rise .55s .14s cubic-bezier(.2,.7,.3,1) both}
|
||||||
|
.hero-verdict,.hero-meta{animation:rise .55s .22s cubic-bezier(.2,.7,.3,1) both}
|
||||||
|
@media (prefers-reduced-motion: reduce){
|
||||||
|
*{animation:none !important;transition:none !important}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= 电路蓝图工作台(件① 的两张档案卡)=================
|
||||||
|
2026-08-31 用户:「卡片下面那个容器黑色和页面背景不搭」—— 深色板底退场,换**浅色蓝图**:
|
||||||
|
底是强调色的极浅晕染 + 淡格线,走线与信号点用强调色 —— 电路语汇留着,纸面色阶不断裂。 */
|
||||||
|
.board{position:relative;margin:0 0 6px;padding:18px 14px 16px;border-radius:16px;border:1px solid var(--acc-line);
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(0deg, rgba(46,107,255,.045) 0 1px, transparent 1px 26px),
|
||||||
|
repeating-linear-gradient(90deg, rgba(46,107,255,.045) 0 1px, transparent 1px 26px),
|
||||||
|
linear-gradient(158deg, var(--acc-wash) 0%, #fff 62%, var(--acc-wash) 100%)}
|
||||||
|
.board::before{content:"";position:absolute;left:4%;right:4%;top:50%;border-top:2px dashed var(--acc-line)}
|
||||||
|
/* 「可替换」动画(2026-08-31 用户第二稿:「不要做到背景板上,背景板会被卡片覆盖;
|
||||||
|
和卡片做到一级,可以压缩一点卡片的大小」)—— .dossiers 改三列网格,中间留一条
|
||||||
|
**实体动画道**(卡片各占左右列、相应收窄,谁也盖不住它):中央 ⇄ 徽轻呼吸,
|
||||||
|
两枚信号点绕徽**轨道对转**(此上彼下地绕圈 = 两颗料换位),底下横穿的虚线总线
|
||||||
|
在道里露出来把两张卡接起来。 */
|
||||||
|
.swapfx{position:absolute;left:50%;top:50%;width:76px;height:76px;transform:translate(-50%,-50%);z-index:1;pointer-events:none}
|
||||||
|
.sw-ic{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:36px;height:36px;padding:8px;box-sizing:border-box;color:var(--acc);background:#fff;border:1px solid var(--acc-line);border-radius:999px;box-shadow:0 4px 12px rgba(18,38,63,.16);animation:swbreathe 2.8s ease-in-out infinite}
|
||||||
|
.sw-orbit{position:absolute;left:50%;top:50%;width:64px;height:64px;margin:-32px 0 0 -32px;animation:sworbit 4.5s linear infinite}
|
||||||
|
.sw-dot{position:absolute;left:50%;width:8px;height:8px;margin-left:-4px;border-radius:999px;background:var(--acc);box-shadow:0 0 8px 2px var(--acc-soft)}
|
||||||
|
.sw-d1{top:-4px}
|
||||||
|
.sw-d2{bottom:-4px}
|
||||||
|
@keyframes sworbit{to{transform:rotate(360deg)}}
|
||||||
|
@keyframes swbreathe{0%,100%{transform:translate(-50%,-50%) scale(1)}50%{transform:translate(-50%,-50%) scale(1.1)}}
|
||||||
|
/* 三列网格:左右两列给卡,中间 76px 是动画道 —— 与卡同级的真实空间,不被覆盖 */
|
||||||
|
.dossiers{position:relative;z-index:1;display:grid;grid-template-columns:minmax(0,1fr) 76px minmax(0,1fr);row-gap:16px}
|
||||||
|
.dossiers > .dossier:nth-of-type(odd){grid-column:1}
|
||||||
|
.dossiers > .dossier:nth-of-type(even){grid-column:3}
|
||||||
|
.dossier{display:flex;flex-direction:column;gap:11px;background:#fff;border:1px solid var(--line-soft);border-radius:16px;padding:15px 16px 14px;box-shadow:0 8px 22px rgba(18,38,63,.09);transition:box-shadow .18s,transform .18s}
|
||||||
|
.dossier:hover{transform:translateY(-2px);box-shadow:0 12px 28px rgba(18,38,63,.13)}
|
||||||
|
.dz-top{display:flex;align-items:center;gap:12px}
|
||||||
|
.dz-top .csvg{width:64px;flex:none}
|
||||||
|
.dz-id{display:flex;flex-direction:column;gap:4px;min-width:0}
|
||||||
|
.dz-role{display:flex;gap:6px;flex-wrap:wrap}
|
||||||
|
.dz-pn{font-size:15px;font-weight:800;overflow-wrap:anywhere;line-height:1.3}
|
||||||
|
.dz-pn .jc-vcell{font-size:15px;font-weight:800;color:var(--ink)}
|
||||||
|
.dz-type{display:flex;align-items:center;gap:9px;flex-wrap:wrap;padding:8px 10px;border-radius:10px;background:var(--acc-wash);border:1px solid var(--acc-line)}
|
||||||
|
.dz-lb{flex:none;font-style:normal;font-size:10.5px;font-weight:800;letter-spacing:.05em;color:var(--ink-4)}
|
||||||
|
.dz-type .jc-cell{flex:1 1 140px;width:auto}
|
||||||
|
/* 类型名守卫:这一行的下拉真有选中项(selectCurrent 按行作用域写的 option[selected])才摆名 ——
|
||||||
|
行里没选,data-by 落回外层的名一个字都不摆,换「待选择」胶囊。
|
||||||
|
守卫挂在共同祖先 .dz-type 上、单层 :has —— :has() 里再嵌 :has() 是非法选择器,整条会被浏览器
|
||||||
|
静默丢弃(第一版就栽在这:守卫写了等于没写,XL 那张卡照样摆出它没有的类型)。 */
|
||||||
|
.tsel{flex:1 1 auto;min-width:0}
|
||||||
|
.tsel > span > i{display:inline-block;font-size:11.5px;font-weight:800;color:var(--acc-deep);background:var(--acc-soft);border-radius:999px;padding:1px 11px}
|
||||||
|
.dz-type:not(:has(option[selected])) .tsel > span{display:none}
|
||||||
|
.dz-type:not(:has(option[selected])) .tsel::after{content:"待选择 · 点「编辑」挑一个";display:inline-block;font-size:11px;font-weight:700;color:var(--amber);background:var(--amber-soft);border:1px dashed #eeda9e;border-radius:999px;padding:1px 10px}
|
||||||
|
.dz-grid{display:grid;grid-template-columns:1fr 1fr;gap:9px 12px}
|
||||||
|
.fcell{display:flex;flex-direction:column;gap:3px;min-width:0}
|
||||||
|
.fcell.wide{grid-column:1 / -1}
|
||||||
|
.fcell > i{font-style:normal;font-size:10.5px;font-weight:800;letter-spacing:.05em;color:var(--ink-4)}
|
||||||
|
.fcell.req > i::after{content:" *";color:var(--danger)}
|
||||||
|
.fcell .jc-vcell{font-size:12.5px}
|
||||||
|
/* 三级占位:必填红 / 建议橘(空格子直说要不要填,不摆一枚含糊的破折号) */
|
||||||
|
.fcell.req .jc-vcell:empty::after{content:"必填";font-size:10.5px;font-weight:800;color:var(--danger);background:var(--danger-soft);border-radius:999px;padding:0 9px}
|
||||||
|
/* 徽标包装的必填格(如生命周期):没值时徽标 class 恰为裸 "stx"(class-of 没投出 v-*),
|
||||||
|
wrapper 不为 :empty,上一条够不着 —— 认裸 class 补同一枚胶囊(单层 :has) */
|
||||||
|
.fcell.req .jc-vcell:has(> [class="stx"])::after{content:"必填";font-size:10.5px;font-weight:800;color:var(--danger);background:var(--danger-soft);border-radius:999px;padding:0 9px}
|
||||||
|
.fcell.sug .jc-vcell:empty::after{content:"建议";font-size:10.5px;font-weight:800;color:var(--amber);background:var(--amber-soft);border-radius:999px;padding:0 9px}
|
||||||
|
/* 档案卡的编辑态 —— **按卡开,只点亮这张卡的正面**(2026-08-31 用户:「各自编辑各自的部分,
|
||||||
|
不要做联动」:.on 由 scopedTargets 落在被点那张卡的行 section 上,不再落整个宿主);
|
||||||
|
只圈 .front 是因为卡背住着另一地址的宿主,不圈的话参数输入会被主数据的编辑顺手点亮 */
|
||||||
|
.dossier.on .front .jc-vcell{display:none}
|
||||||
|
.dossier.on .front .jc-vcell.ro{display:block}
|
||||||
|
.dossier.on .front .jc-cell{display:block}
|
||||||
|
.dossier.on .front .jc-vcell:empty::after{content:none}
|
||||||
|
.dossier.on .dz-role{display:flex}
|
||||||
|
|
||||||
|
/* ── 翻面(纯 CSS:checkbox + :has,零脚本;reduced-motion 下瞬时切换)── */
|
||||||
|
.dossier{position:relative;perspective:1200px;padding:0;background:none;border:0;box-shadow:none}
|
||||||
|
.dossier:hover{transform:none;box-shadow:none}
|
||||||
|
.flip-in{display:grid;transform-style:preserve-3d;transition:transform .55s cubic-bezier(.3,.7,.3,1)}
|
||||||
|
.face{grid-area:1 / 1;display:flex;flex-direction:column;gap:11px;background:#fff;border:1px solid var(--line-soft);border-radius:16px;padding:15px 16px 14px;box-shadow:0 8px 22px rgba(18,38,63,.09);backface-visibility:hidden;min-width:0}
|
||||||
|
.face.back{transform:rotateY(180deg)}
|
||||||
|
.flip-tg{position:absolute;opacity:0;pointer-events:none}
|
||||||
|
.flip-hit{position:absolute;inset:0;z-index:3;cursor:pointer}
|
||||||
|
.flip-cue{position:absolute;z-index:4;bottom:10px;right:10px;pointer-events:none;opacity:.6;transition:opacity .16s,transform .16s}
|
||||||
|
.flip-cue i{font-style:normal;display:inline-block;font-size:10.5px;font-weight:800;color:var(--acc-deep);background:var(--acc-soft);border:1px solid var(--acc-line);border-radius:999px;padding:2px 10px}
|
||||||
|
.dossier:hover .flip-cue{opacity:1;transform:translateY(-2px)}
|
||||||
|
/* 两句提示按面切换 —— 选择器都写到 .flip-cue i 之下的同层级,别被基座那条特异度压回来(踩过) */
|
||||||
|
.flip-cue .cue-back{display:none}
|
||||||
|
/* 翻转三态,后写的赢:人点的 < 主数据编辑锁正面 < 参数编辑翻背面 */
|
||||||
|
.dossier:has(.flip-tg:checked) .flip-in{transform:rotateY(180deg)}
|
||||||
|
.dossier:has(.flip-tg:checked) .flip-cue .cue-front{display:none}
|
||||||
|
.dossier:has(.flip-tg:checked) .flip-cue .cue-back{display:inline-block}
|
||||||
|
.dossier.on .flip-in{transform:rotateY(0deg)}
|
||||||
|
.dossier:has(.pback.on) .flip-in{transform:rotateY(180deg)}
|
||||||
|
/* 编辑中的**这张卡**不许翻(哪面在编辑锁哪面),点击层与提示丸一起收;另一张卡不受影响 */
|
||||||
|
.dossier.on .flip-hit,.dossier:has(.pback.on) .flip-hit{display:none}
|
||||||
|
.dossier.on .flip-cue,.dossier:has(.pback.on) .flip-cue{display:none}
|
||||||
|
/* 悬浮侧倾:预告「这张卡点了会翻」—— 只在读态、只在真有悬浮的设备、且不嫌动画的人那里 */
|
||||||
|
@media (hover:hover) and (prefers-reduced-motion: no-preference){
|
||||||
|
.dossier:hover:not(.on):not(:has(.flip-tg:checked)):not(:has(.pback.on)) .flip-in{transform:rotateY(-12deg)}
|
||||||
|
.dossier:hover:not(.on):has(.flip-tg:checked):not(:has(.pback.on)) .flip-in{transform:rotateY(168deg)}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 卡角编辑钮:笔=编辑当前这一面,软盘=保存,叉=放弃(2026-08-31 用户定,顶掉两行编辑闸)──
|
||||||
|
笔只在待处理态露面(html.editable,与从前的闸同一条纪律);软盘/叉只在对应面的编辑态露面。 */
|
||||||
|
.fabs{position:absolute;top:10px;right:10px;z-index:5;display:flex;gap:6px}
|
||||||
|
.fab{display:none;width:32px;height:32px;border-radius:999px;border:1px solid var(--acc-line);background:#fff;color:var(--acc-deep);cursor:pointer;place-items:center;padding:0;box-shadow:0 4px 12px rgba(18,38,63,.14);transition:border-color .16s,color .16s,box-shadow .16s}
|
||||||
|
.fab:hover{border-color:var(--acc);box-shadow:0 6px 16px rgba(18,38,63,.22)}
|
||||||
|
.fab:active{transform:scale(.94)}
|
||||||
|
.fic{width:15px;height:15px;display:block;margin:auto}
|
||||||
|
.fab.pen{color:var(--acc-deep)}
|
||||||
|
.fab.disk{background:var(--acc);border-color:var(--acc);color:#fff}
|
||||||
|
.fab.xbtn{color:var(--ink-3)}
|
||||||
|
/* 笔:这张卡当下这一面、且这张卡没在编辑 —— 各卡各开,互不联动 */
|
||||||
|
html.editable .dossier:not(.on):not(:has(.pback.on)):not(:has(.flip-tg:checked)) .pen-f{display:grid}
|
||||||
|
html.editable .dossier:not(.on):not(:has(.pback.on)):has(.flip-tg:checked) .pen-b{display:grid}
|
||||||
|
.dossier.on .disk-f{display:grid}
|
||||||
|
.dossier.on .x-f{display:grid}
|
||||||
|
.dossier:has(.pback.on) .disk-b{display:grid}
|
||||||
|
.dossier:has(.pback.on) .x-b{display:grid}
|
||||||
|
|
||||||
|
/* ── 卡背:参数瓷贴 ── */
|
||||||
|
.pb-hd{display:flex;align-items:baseline;gap:9px;flex-wrap:wrap}
|
||||||
|
.pb-hd b{font-size:14px;font-weight:800;color:var(--ink)}
|
||||||
|
.pb-lb{font-size:10.5px;font-weight:800;letter-spacing:.08em;color:var(--ink-4)}
|
||||||
|
.pback{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
||||||
|
.ptile{display:flex;flex-direction:column;gap:2px;border:1px solid var(--line-soft);border-radius:10px;background:var(--soft);padding:8px 10px;min-width:0}
|
||||||
|
.pt-hd{display:flex;align-items:baseline;gap:6px;min-width:0}
|
||||||
|
.pt-hd .jc-vcell{font-size:11px;font-weight:800;color:var(--ink-3)}
|
||||||
|
.pt-unit{font-style:normal;font-size:10px;color:var(--ink-4);font-family:var(--mono)}
|
||||||
|
.pt-val .jc-vcell{font-size:14px;font-weight:800;color:var(--ink)}
|
||||||
|
.pt-note .jc-vcell{font-size:10.5px;color:var(--ink-3)}
|
||||||
|
/* 三级占位:值必填红 / 说明建议橘 */
|
||||||
|
.ptile .pv .jc-vcell:empty::after{content:"必填";font-size:10.5px;font-weight:800;color:var(--danger);background:var(--danger-soft);border-radius:999px;padding:0 9px}
|
||||||
|
/* 说明空着**什么都不摆**(2026-08-31 用户:「没写就什么都不要展示」)—— 连兜底破折号一起收 */
|
||||||
|
.ptile .sg .jc-vcell:empty{display:none}
|
||||||
|
/* 参数编辑态:值与说明换输入,参数名照旧当标签摆着;两枚主键孪生件藏死(钥匙不是编辑面)。
|
||||||
|
藏钥匙用包装类(.pt-name / .rowkey),不用「data-col 属性选择器」—— 那段选择器文本会原样
|
||||||
|
出现在共享样式表里,把「落账确认全只读」那类字面断言踩翻(2026-08-31 踩过,连注释也不许写)。 */
|
||||||
|
.pback.on .pv > .jc-vcell,.pback.on .sg > .jc-vcell{display:none}
|
||||||
|
.pback.on .pv > .jc-cell,.pback.on .sg > .jc-cell{display:block}
|
||||||
|
.pback .pt-name .jc-cell{display:none}
|
||||||
|
.pt-name{display:inline-flex;min-width:0}
|
||||||
|
|
||||||
|
/* ================= 比对基准卡(类型 + 字段合一,只读、跟手)================= */
|
||||||
|
.basis{display:block;margin:0 0 10px}
|
||||||
|
.basis:empty{border:1.5px dashed var(--line);border-radius:12px;padding:16px;display:block}
|
||||||
|
.basis:empty::after{content:"还没定比对基准 —— 先在上面任一张档案卡上挑一个芯片类型,或展开下面的抽屉新建一个。";font-size:12.5px;color:var(--ink-3)}
|
||||||
|
.basis-in{display:flex;flex-direction:column;gap:10px;border:1px solid var(--acc-line);border-radius:12px;background:var(--acc-wash);padding:12px 14px 14px}
|
||||||
|
.basis-hd{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||||||
|
.basis-lb{flex:none;font-size:10.5px;font-weight:800;letter-spacing:.08em;color:var(--acc-deep)}
|
||||||
|
.basis-chip b{display:inline-block;font-size:13px;font-weight:800;color:#fff;background:var(--acc);border-radius:999px;padding:2px 14px;margin-right:8px}
|
||||||
|
.basis-desc{font-style:normal;font-size:11.5px;color:var(--ink-3)}
|
||||||
|
.basis-hd:has(.basis-chip:empty)::after{content:"待选择";font-size:11px;font-weight:700;color:var(--amber);background:var(--amber-soft);border:1px dashed #eeda9e;border-radius:999px;padding:1px 10px}
|
||||||
|
.basis-hd .csvg{width:56px;flex:none}
|
||||||
|
/* 类型图:层叠双封装 —— 后面那枚是「同类的别的芯片」,淡出成剪影 */
|
||||||
|
.basis-hd .tsvg{width:60px;flex:none}
|
||||||
|
.tsvg{display:block}
|
||||||
|
.t-ghost{opacity:.32}
|
||||||
|
.basis-id{display:flex;flex-direction:column;gap:5px;min-width:0;flex:1 1 auto}
|
||||||
|
.basis-in:has(.ftiles:empty) .ftiles{display:none}
|
||||||
|
/* 降噪(2026-08-31 用户:「乱糟糟…没必要显示全就不要显示完全,鼠标指上去后再显示」):
|
||||||
|
字段收成一排丸(名 + 硬/软徽 + 单位),说明藏进悬浮气泡 —— 指上去才现身 */
|
||||||
|
.ftiles{display:flex;flex-wrap:wrap;gap:8px}
|
||||||
|
.ftile{position:relative;display:flex;flex-direction:row;align-items:center;gap:7px;border:1px solid var(--line-soft);border-radius:999px;background:#fff;padding:6px 13px;min-width:0;transition:border-color .16s,box-shadow .16s}
|
||||||
|
.ftile:hover{border-color:var(--acc-line);box-shadow:0 4px 12px rgba(18,38,63,.1)}
|
||||||
|
.ft-hd{display:flex;align-items:baseline;gap:7px;white-space:nowrap}
|
||||||
|
.ft-hd b{font-size:12px;font-weight:800;color:var(--ink)}
|
||||||
|
.ft-unit{font-style:normal;font-size:10px;color:var(--ink-4);font-family:var(--mono)}
|
||||||
|
.ft-desc{display:none;position:absolute;z-index:6;top:calc(100% + 7px);left:0;width:max-content;max-width:280px;margin:0;font-size:11px;line-height:1.65;color:var(--ink-2);background:#fff;border:1px solid var(--line);border-radius:9px;padding:8px 11px;box-shadow:0 10px 24px rgba(18,38,63,.16)}
|
||||||
|
.ftile:hover .ft-desc:not(:empty){display:block}
|
||||||
|
/* 类型说明:默认一行截断,悬浮题头才放开 */
|
||||||
|
.basis-desc{display:block;max-width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.basis-hd:hover .basis-desc{white-space:normal}
|
||||||
|
|
||||||
|
/* ================= 新建类型工作抽屉(details,零脚本)================= */
|
||||||
|
.lab{margin:0 0 6px;border:1.5px dashed var(--line);border-radius:12px;background:var(--soft)}
|
||||||
|
.lab[open]{border-style:solid;background:#fff}
|
||||||
|
.lab-sm{cursor:pointer;list-style:none;display:flex;align-items:center;gap:8px;padding:11px 14px;font-size:12.5px;color:var(--ink-3);user-select:none}
|
||||||
|
.lab-sm::-webkit-details-marker{display:none}
|
||||||
|
.lab-sm b{color:var(--ink-2)}
|
||||||
|
.lab-sm:hover{color:var(--acc-deep)}
|
||||||
|
.lab-caret{margin-left:auto;flex:none;width:8px;height:8px;border-right:2px solid var(--ink-4);border-bottom:2px solid var(--ink-4);transform:rotate(45deg);transition:transform .16s}
|
||||||
|
.lab[open] .lab-caret{transform:rotate(-135deg)}
|
||||||
|
.lab-bd{padding:2px 14px 14px}
|
||||||
|
.lab-brief{margin:0 0 16px}
|
||||||
|
.lab-brief .brief{margin:2px 14px 14px;border-left-width:3px}
|
||||||
|
|
||||||
|
/* ================= 窄屏 ================= */
|
||||||
|
@media(max-width:620px){
|
||||||
|
.card-head{padding:16px 14px 18px}
|
||||||
|
.dz-grid{grid-template-columns:1fr}
|
||||||
|
.swapfx{display:none}
|
||||||
|
.dossiers{grid-template-columns:1fr}
|
||||||
|
.dossiers > .dossier:nth-of-type(odd),.dossiers > .dossier:nth-of-type(even){grid-column:1}
|
||||||
|
.pback{grid-template-columns:1fr}
|
||||||
|
.ftiles{grid-template-columns:1fr}
|
||||||
|
.card-bd{padding:16px 14px 6px}
|
||||||
|
.hero-title{font-size:19.5px}
|
||||||
|
.stage{gap:14px}
|
||||||
|
.hchip{min-width:108px;padding:10px 8px 9px}
|
||||||
|
.csvg{width:68px}
|
||||||
|
.kv i{flex-basis:72px}
|
||||||
|
.card-foot{padding:10px 14px}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -1,361 +0,0 @@
|
|||||||
/**
|
|
||||||
* ACT-WORK-05 差异评估的四份目标件样式表 —— **机械抽取,一个数值都不许改**
|
|
||||||
* (《HTML报告生成规范》§7:有目标件时「搬」,不要「照着做」;样式表整段搬,标记逐块搬)。
|
|
||||||
*
|
|
||||||
* 来源(`docs/SS02-ACT-WORK-05-html/`):
|
|
||||||
* · `P08_CSS` ← `ACT-WORK-05-数据工单-01-芯片主数据创建.html` 的两段 `<style>`
|
|
||||||
* (该文件头部连续放了两个 `<style>` 元素:第一段是基座壳,第二段标着
|
|
||||||
* 「视觉补充段 v3」——`.hic`/`.zic`/`.thd`/`#tbl1`/`#tbl2` 等数据工单专属样式。
|
|
||||||
* 两段按文档原有顺序首尾相接存成一个常量;等价于浏览器解析两个 `<style>` 标签,
|
|
||||||
* 级联结果与原文件逐字节一致,已用 diff 验证,见 task-5-report.md);
|
|
||||||
* · `EXEC_CSS` ← `ACT-WORK-05-执行工单-01-评估输入与资料补齐确认.html` 的 `<style>`;
|
|
||||||
* · `ACC1_CSS` ← `ACT-WORK-05-验收工单-01-差异评估结论审核.html` 的 `<style>`;
|
|
||||||
* · `ACC2_CSS` ← `ACT-WORK-05-验收工单-02-评审放行与落账确认.html` 的 `<style>`。
|
|
||||||
*
|
|
||||||
* `p10-document-create`(P-10 原厂技术资料创建)**没有自己的目标件**——规格里数据工单是
|
|
||||||
* 「一个统一模板按目标台账实例化」,复用 `P08_CSS`(同一套壳,只是表头列换成 P-10 的字段)。
|
|
||||||
*
|
|
||||||
* 抽取用 `sed -n` 按行号截取 `<style>`/`</style>` 之间的原文,再拿抽出来的内容包回
|
|
||||||
* `<style>…</style>` 与源文件同一行区间做 `diff`,四份全部「零差异」——过程写在
|
|
||||||
* task-5-report.md,这里不重复贴证据。
|
|
||||||
*
|
|
||||||
* 这份文件只装**目标件自己的样式**,不装平台绑定用的开关(三态占位、缺项红带、
|
|
||||||
* `data-class-of` 的 `v-*` 别名这类)——那些是本活动自己加出来的东西,活在
|
|
||||||
* `report.ts` 的 `BIND_CSS_*` 常量里,两处不要混。
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const P08_CSS = `
|
|
||||||
:root{
|
|
||||||
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
|
|
||||||
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
|
|
||||||
--brand:#3370ff; --brand-deep:#245bdb; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
|
|
||||||
--data:#3370ff; --data-soft:#e1eaff; --data-line:#c3d6ff;
|
|
||||||
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
|
|
||||||
--mono:"SF Mono",Menlo,Consolas,monospace;
|
|
||||||
}
|
|
||||||
*{box-sizing:border-box}
|
|
||||||
html,body{margin:0;padding:0}
|
|
||||||
body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#eef1f6;color:var(--ink);font-size:14px;line-height:1.7;-webkit-font-smoothing:antialiased}
|
|
||||||
.page{margin:0;padding:0}
|
|
||||||
.card{background:#fff;overflow:hidden;border:0;border-radius:0;box-shadow:none}
|
|
||||||
.card-head{display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:2px solid var(--ink);background:linear-gradient(120deg,#f0f5ff 0%,#fff 55%,#eef4ff 100%)}
|
|
||||||
.badge{flex:none;width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--data);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(51,112,255,.25)}
|
|
||||||
.tit{flex:1;min-width:0}
|
|
||||||
.tit .kick{font-size:11px;letter-spacing:.08em;color:var(--ink-4);font-weight:700}
|
|
||||||
.tit h1{margin:2px 0 0;font-size:19px;font-weight:800;line-height:1.3}
|
|
||||||
.tit .sub{font-size:12px;color:var(--ink-3);margin-top:3px}
|
|
||||||
.side{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
|
|
||||||
.tag{font-size:11px;font-weight:800;padding:2px 10px;border-radius:999px;color:var(--data);background:var(--data-soft);border:1px solid var(--data-line)}
|
|
||||||
.step{font-size:12px;color:var(--ink-3)}
|
|
||||||
.deadline{font-size:12px;color:var(--ink-3)}
|
|
||||||
.deadline b{color:var(--danger)}
|
|
||||||
.card-bd{padding:20px 22px}
|
|
||||||
.zone{font-size:11px;font-weight:800;letter-spacing:.12em;color:var(--ink-4);margin:0 0 8px}
|
|
||||||
.zone b{color:var(--ink-3)}
|
|
||||||
.note{border:1px solid var(--line);border-left:3px solid var(--data);border-radius:8px;background:var(--soft);padding:14px 16px;margin-bottom:16px}
|
|
||||||
.note p{margin:0 0 6px;font-size:13px;color:var(--ink-2)}
|
|
||||||
.note p:last-child{margin-bottom:0}
|
|
||||||
.note b{color:var(--ink)}
|
|
||||||
.note .done{color:var(--ok);font-weight:700}
|
|
||||||
.note .need{color:var(--data);font-weight:700}
|
|
||||||
.jc-editgate{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:14px 0 10px;padding:9px 12px;border-radius:9px;background:var(--soft);border:1px solid var(--line-soft);font-size:12px;color:var(--ink-3)}
|
|
||||||
.jc-editgate-t{flex:1 1 auto;min-width:0}
|
|
||||||
.jc-editgate-t b{color:var(--ink-2);font-weight:700}
|
|
||||||
.jc-egbtn{flex:none;display:inline-flex;align-items:center;gap:5px;cursor:pointer;border:1px solid var(--line);border-radius:7px;padding:6px 13px;font:inherit;font-size:12px;font-weight:700;color:var(--ink-2);background:#fff;transition:border-color .16s,color .16s,background .16s}
|
|
||||||
.jc-egbtn:hover{border-color:var(--brand);color:var(--brand)}
|
|
||||||
.jc-egbtn.save{color:#fff;background:var(--data);border-color:var(--data)}
|
|
||||||
.jc-egbtn.save:hover{filter:brightness(1.06);color:#fff}
|
|
||||||
.jc-editgate .eg-cancel,.jc-editgate .eg-save{display:none}
|
|
||||||
.jc-editgate.on{background:var(--brand-wash);border-color:var(--data-line);color:#1e40af}
|
|
||||||
.jc-editgate.on .eg-open{display:none}
|
|
||||||
.jc-editgate.on .eg-cancel,.jc-editgate.on .eg-save{display:inline-flex}
|
|
||||||
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
|
|
||||||
table{width:100%;border-collapse:collapse;font-size:12.5px}
|
|
||||||
th{text-align:left;background:var(--soft);font-size:11px;letter-spacing:.03em;font-weight:800;color:var(--ink-3);padding:9px 10px;border-bottom:1px solid var(--line)}
|
|
||||||
th.req::after{content:" *";color:var(--danger);font-size:11px}
|
|
||||||
td{padding:8px 10px;border-bottom:1px solid var(--line-soft);vertical-align:middle}
|
|
||||||
tr:last-child td{border-bottom:0}
|
|
||||||
td.rn{position:relative;width:38px;white-space:nowrap;font-family:var(--mono);font-size:11px;color:var(--ink-4);text-align:center}
|
|
||||||
.jc-vcell{display:block;font-size:12.5px;color:var(--ink);line-height:1.5;overflow-wrap:anywhere}
|
|
||||||
.jc-vcell.mono{font-family:var(--mono);font-size:11.5px;color:var(--ink-2)}
|
|
||||||
.jc-cell{display:none;width:100%;min-width:60px;border:1px solid var(--line);border-radius:6px;padding:6px 8px;font:inherit;font-size:12.5px;color:var(--ink);background:#fff}
|
|
||||||
.jc-cell.mono{font-family:var(--mono);font-size:11.5px}
|
|
||||||
.jc-cell:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--brand-wash);outline:none}
|
|
||||||
table.on td>.jc-vcell{display:none}
|
|
||||||
table.on td>.jc-cell{display:block}
|
|
||||||
td.rn .jc-rowdel{display:none;width:22px;height:22px;margin:0 auto;cursor:pointer;border:1px solid var(--line);border-radius:6px;padding:0;font:inherit;font-size:14px;line-height:1;color:var(--ink-3);background:#fff}
|
|
||||||
td.rn .jc-rowdel:hover{border-color:var(--danger);color:var(--danger);background:var(--danger-soft)}
|
|
||||||
table.on td.rn .jc-rn-num{display:none}
|
|
||||||
table.on td.rn .jc-rowdel{display:block}
|
|
||||||
.jc-addrow{display:none;margin:8px 0 0;padding:8px 12px;border-radius:9px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
|
|
||||||
.jc-addrow.show{display:flex;align-items:center;gap:8px}
|
|
||||||
.jc-addrow button{cursor:pointer;border:1px solid var(--brand-soft);border-radius:7px;padding:5px 12px;font:inherit;font-size:12px;font-weight:700;color:var(--brand-deep);background:var(--brand-wash)}
|
|
||||||
.jc-addrow button:hover{background:var(--brand-soft)}
|
|
||||||
.jc-addrow em{font-style:normal;font-size:11.5px;color:var(--ink-4)}
|
|
||||||
.flow{margin-top:16px;padding:9px 12px;border-radius:8px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
|
|
||||||
.flow b{color:var(--ink)}
|
|
||||||
.card-foot{padding:13px 22px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
|
|
||||||
@media(max-width:600px){.card-head{flex-wrap:wrap}.side{align-items:flex-start}}
|
|
||||||
/* 补充:数据工单专用标识(仅追加,不改动基件样式) */
|
|
||||||
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
|
|
||||||
.pre{font-style:normal;font-size:10.5px;font-weight:800;color:var(--data);background:var(--data-soft);border:1px solid var(--data-line);border-radius:999px;padding:0 7px;margin-left:6px;white-space:nowrap}
|
|
||||||
table.on td>.pre{display:none}
|
|
||||||
.ct{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:999px;border:1px solid transparent;white-space:nowrap}
|
|
||||||
.ct.hard{color:var(--danger);background:var(--danger-soft);border-color:#f3b9b5}
|
|
||||||
.ct.soft{color:var(--ink-3);background:#eef1f6;border-color:var(--line)}
|
|
||||||
.tbl-gap{margin-top:18px}
|
|
||||||
/* ===== 视觉补充段 v3(通用批量模板:字段即表头、每行一条;不改基件样式与 JS 钩子) ===== */
|
|
||||||
.hic{flex:none;width:26px;height:26px;color:var(--data)}
|
|
||||||
.zic{width:14px;height:14px;color:var(--ink-3);vertical-align:-2px;margin-right:3px}
|
|
||||||
/* 表头内字段说明(第二行小字) */
|
|
||||||
.thd{display:block;font-size:10px;font-weight:600;color:var(--ink-4);letter-spacing:0;margin-top:1px;line-height:1.4}
|
|
||||||
/* 表 1:字段即表头,内容多列,允许横向滚动 */
|
|
||||||
#tbl1{min-width:1240px}
|
|
||||||
#tbl1 .jc-vcell{display:inline}
|
|
||||||
/* 表 2 列宽 */
|
|
||||||
#tbl2 th:nth-child(2){width:150px}
|
|
||||||
#tbl2 th:nth-child(3){width:18%}
|
|
||||||
#tbl2 th:nth-child(4){width:14%}
|
|
||||||
#tbl2 th:nth-child(5){width:8%;text-align:center;white-space:nowrap}
|
|
||||||
#tbl2 th:nth-child(6){width:13%;text-align:center;white-space:nowrap}
|
|
||||||
#tbl2 td:nth-child(5),#tbl2 td:nth-child(6){text-align:center;white-space:nowrap}
|
|
||||||
/* 说明列(只读注释,编辑态保持可见) */
|
|
||||||
.desc{color:var(--ink-3);font-size:12px;line-height:1.5}
|
|
||||||
table.on td>.jc-vcell.ro{display:block}
|
|
||||||
.tbl-wrap{overflow-x:auto}
|
|
||||||
@media (prefers-reduced-motion: reduce){*{animation:none!important;transition:none!important}}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const EXEC_CSS = `
|
|
||||||
:root{
|
|
||||||
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
|
|
||||||
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
|
|
||||||
--brand:#3370ff; --brand-deep:#245bdb; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
|
|
||||||
--human:#a9711e; --human-soft:#f6ecd8; --human-line:#e6d3a8;
|
|
||||||
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
|
|
||||||
--mono:"SF Mono",Menlo,Consolas,monospace;
|
|
||||||
}
|
|
||||||
*{box-sizing:border-box}
|
|
||||||
html,body{margin:0;padding:0}
|
|
||||||
body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#eef1f6;color:var(--ink);font-size:14px;line-height:1.7;-webkit-font-smoothing:antialiased}
|
|
||||||
.page{margin:0;padding:0}
|
|
||||||
.card{background:#fff;overflow:hidden;border:0;border-radius:0;box-shadow:none}
|
|
||||||
.card-head{display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:2px solid var(--ink);background:linear-gradient(120deg,#fffaf1 0%,#fff 55%,#fbf6ea 100%)}
|
|
||||||
.badge{flex:none;width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--human);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(169,113,30,.25)}
|
|
||||||
.tit{flex:1;min-width:0}
|
|
||||||
.tit .kick{font-size:11px;letter-spacing:.08em;color:var(--ink-4);font-weight:700}
|
|
||||||
.tit h1{margin:2px 0 0;font-size:19px;font-weight:800;line-height:1.3}
|
|
||||||
.tit .sub{font-size:12px;color:var(--ink-3);margin-top:3px}
|
|
||||||
.side{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
|
|
||||||
.tag{font-size:11px;font-weight:800;padding:2px 10px;border-radius:999px;color:var(--human);background:var(--human-soft);border:1px solid var(--human-line)}
|
|
||||||
.step{font-size:12px;color:var(--ink-3)}
|
|
||||||
.deadline{font-size:12px;color:var(--ink-3)}
|
|
||||||
.deadline b{color:var(--danger)}
|
|
||||||
.card-bd{padding:20px 22px}
|
|
||||||
.zone{font-size:11px;font-weight:800;letter-spacing:.12em;color:var(--ink-4);margin:0 0 8px}
|
|
||||||
.zone b{color:var(--ink-3)}
|
|
||||||
.note{border:1px solid var(--line);border-left:3px solid var(--human);border-radius:8px;background:var(--soft);padding:14px 16px;margin-bottom:16px}
|
|
||||||
.note p{margin:0;font-size:13px;color:var(--ink-2)}
|
|
||||||
.note b{color:var(--ink)}
|
|
||||||
.note .done{color:var(--ok);font-weight:700}
|
|
||||||
.note .need{color:var(--human);font-weight:700}
|
|
||||||
.jc-editgate{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:14px 0 10px;padding:9px 12px;border-radius:9px;background:var(--soft);border:1px solid var(--line-soft);font-size:12px;color:var(--ink-3)}
|
|
||||||
.jc-editgate-t{flex:1 1 auto;min-width:0}
|
|
||||||
.jc-editgate-t b{color:var(--ink-2);font-weight:700}
|
|
||||||
.jc-egbtn{flex:none;display:inline-flex;align-items:center;gap:5px;cursor:pointer;border:1px solid var(--line);border-radius:7px;padding:6px 13px;font:inherit;font-size:12px;font-weight:700;color:var(--ink-2);background:#fff;transition:border-color .16s,color .16s,background .16s}
|
|
||||||
.jc-egbtn:hover{border-color:var(--brand);color:var(--brand)}
|
|
||||||
.jc-egbtn.save{color:#fff;background:var(--human);border-color:var(--human)}
|
|
||||||
.jc-egbtn.save:hover{filter:brightness(1.06);color:#fff}
|
|
||||||
.jc-editgate .eg-cancel,.jc-editgate .eg-save{display:none}
|
|
||||||
.jc-editgate.on{background:var(--human-soft);border-color:var(--human-line);color:#7c4a12}
|
|
||||||
.jc-editgate.on .eg-open{display:none}
|
|
||||||
.jc-editgate.on .eg-cancel,.jc-editgate.on .eg-save{display:inline-flex}
|
|
||||||
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
|
|
||||||
table{width:100%;border-collapse:collapse;font-size:12.5px}
|
|
||||||
th{text-align:left;background:var(--soft);font-size:11px;letter-spacing:.03em;font-weight:800;color:var(--ink-3);padding:9px 10px;border-bottom:1px solid var(--line)}
|
|
||||||
th.req::after{content:" *";color:var(--danger);font-size:11px}
|
|
||||||
td{padding:8px 10px;border-bottom:1px solid var(--line-soft);vertical-align:middle}
|
|
||||||
tr:last-child td{border-bottom:0}
|
|
||||||
td.rn{position:relative;width:38px;white-space:nowrap;font-family:var(--mono);font-size:11px;color:var(--ink-4);text-align:center}
|
|
||||||
.jc-vcell{display:block;font-size:12.5px;color:var(--ink);line-height:1.5;overflow-wrap:anywhere}
|
|
||||||
.jc-vcell.mono{font-family:var(--mono);font-size:11.5px;color:var(--ink-2)}
|
|
||||||
.jc-cell{display:none;width:100%;min-width:60px;border:1px solid var(--line);border-radius:6px;padding:6px 8px;font:inherit;font-size:12.5px;color:var(--ink);background:#fff}
|
|
||||||
.jc-cell.mono{font-family:var(--mono);font-size:11.5px}
|
|
||||||
.jc-cell:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--brand-wash);outline:none}
|
|
||||||
table.on td>.jc-vcell{display:none}
|
|
||||||
table.on td>.jc-cell{display:block}
|
|
||||||
td.rn .jc-rowdel{display:none;width:22px;height:22px;margin:0 auto;cursor:pointer;border:1px solid var(--line);border-radius:6px;padding:0;font:inherit;font-size:14px;line-height:1;color:var(--ink-3);background:#fff}
|
|
||||||
td.rn .jc-rowdel:hover{border-color:var(--danger);color:var(--danger);background:var(--danger-soft)}
|
|
||||||
table.on td.rn .jc-rn-num{display:none}
|
|
||||||
table.on td.rn .jc-rowdel{display:block}
|
|
||||||
.jc-addrow{display:none;margin:8px 0 0;padding:8px 12px;border-radius:9px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
|
|
||||||
.jc-addrow.show{display:flex;align-items:center;gap:8px}
|
|
||||||
.jc-addrow button{cursor:pointer;border:1px solid var(--brand-soft);border-radius:7px;padding:5px 12px;font:inherit;font-size:12px;font-weight:700;color:var(--brand-deep);background:var(--brand-wash)}
|
|
||||||
.jc-addrow button:hover{background:var(--brand-soft)}
|
|
||||||
.jc-addrow em{font-style:normal;font-size:11.5px;color:var(--ink-4)}
|
|
||||||
.flow{margin-top:16px;padding:9px 12px;border-radius:8px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
|
|
||||||
.flow b{color:var(--ink)}
|
|
||||||
.card-foot{padding:13px 22px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
|
|
||||||
@media(max-width:600px){.card-head{flex-wrap:wrap}.side{align-items:flex-start}}
|
|
||||||
/* 补充:评估输入清单状态标识(仅追加,不改动基件样式) */
|
|
||||||
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
|
|
||||||
.st{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:999px;border:1px solid transparent;white-space:nowrap}
|
|
||||||
.st.bound{color:var(--ink-3);background:#eef1f6;border-color:var(--line)}
|
|
||||||
.st.ok{color:var(--ok);background:var(--ok-soft);border-color:#c9ecc2}
|
|
||||||
.st.miss{color:var(--human);background:var(--human-soft);border-color:var(--human-line)}
|
|
||||||
.st.def{color:var(--brand-deep);background:var(--brand-wash);border-color:var(--brand-soft)}
|
|
||||||
tr.ro td{background:var(--soft)}
|
|
||||||
table.on td>.jc-vcell.ro{display:block}
|
|
||||||
`;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 两张验收件(ACC1 差异评估结论审核 / ACC2 评审放行与落账确认)的主题色**不再逐字节等于目标件**:
|
|
||||||
* 目标件的题头用 #b23a2e 红(--ledger),2026-08-27 用户定「中央 HTML 模版区域如果是验收工单,
|
|
||||||
* 标题部分的红色改为和验收卡一样的绿色」—— 与对话区验收卡同一枚 #0aa294(app.css --st-collab)。
|
|
||||||
* 只换主题变量与题头渐变 / 徽章投影;ACC2 的 CONDITIONAL 状态徽标是语义色,保留原红。
|
|
||||||
* ACC1 于 2026-08-27 下午随目标件改版整段重搬(「一个候选一张表」:.cand* / .rec* / .stat*,CONDITIONAL 改蓝),
|
|
||||||
* 主题色仍取卡的那一枚绿。
|
|
||||||
*/
|
|
||||||
export const ACC1_CSS = `
|
|
||||||
:root{
|
|
||||||
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
|
|
||||||
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
|
|
||||||
--brand:#3370ff; --brand-deep:#245bdb; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
|
|
||||||
--ledger:#0aa294; --ledger-soft:#e4f8f5; --ledger-line:#b5e6df; /* 验收绿(2026-08-27 用户定:与对话区验收卡同色;目标件 2026-08-27 改版写的是 #16a34a,这里取卡的那一枚) */
|
|
||||||
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
|
|
||||||
--amber:#8f6a12; --amber-soft:#fdf3d7; --amber-line:#ecd9a0;
|
|
||||||
--mono:"SF Mono",Menlo,Consolas,monospace;
|
|
||||||
}
|
|
||||||
*{box-sizing:border-box}
|
|
||||||
html,body{margin:0;padding:0}
|
|
||||||
body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#eef1f6;color:var(--ink);font-size:14px;line-height:1.7;-webkit-font-smoothing:antialiased}
|
|
||||||
.page{margin:0;padding:0}
|
|
||||||
.card{background:#fff;overflow:hidden;border:0;border-radius:0;box-shadow:none}
|
|
||||||
.card-head{display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:2px solid var(--ink);background:linear-gradient(120deg,#eefaf8 0%,#fff 55%,#ecf8f5 100%)}
|
|
||||||
.badge{flex:none;width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--ledger);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(10,162,148,.25)}
|
|
||||||
.tit{flex:1;min-width:0}
|
|
||||||
.tit .kick{font-size:11px;letter-spacing:.08em;color:var(--ink-4);font-weight:700}
|
|
||||||
.tit h1{margin:2px 0 0;font-size:19px;font-weight:800;line-height:1.3}
|
|
||||||
.tit .sub{font-size:12px;color:var(--ink-3);margin-top:3px}
|
|
||||||
.side{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
|
|
||||||
.tag{font-size:11px;font-weight:800;padding:2px 10px;border-radius:999px;color:var(--ledger);background:var(--ledger-soft);border:1px solid var(--ledger-line)}
|
|
||||||
.step{font-size:12px;color:var(--ink-3)}
|
|
||||||
.deadline{font-size:12px;color:var(--ink-3)}
|
|
||||||
.deadline b{color:var(--danger)}
|
|
||||||
.card-bd{padding:20px 22px}
|
|
||||||
.zone{font-size:11px;font-weight:800;letter-spacing:.12em;color:var(--ink-4);margin:0 0 8px}
|
|
||||||
.zone b{color:var(--ink-3)}
|
|
||||||
.note{border:1px solid var(--line);border-left:3px solid var(--ledger);border-radius:8px;background:var(--soft);padding:14px 16px;margin-bottom:16px}
|
|
||||||
.note p{margin:0;font-size:13px;color:var(--ink-2)}
|
|
||||||
.note b{color:var(--ink)}
|
|
||||||
.criteria{border:1px solid var(--ledger-line);border-radius:10px;background:var(--ledger-soft);padding:13px 16px;margin-bottom:18px}
|
|
||||||
.criteria .c-title{font-size:11px;font-weight:800;letter-spacing:.08em;color:var(--ledger);margin-bottom:7px}
|
|
||||||
.criteria ul{margin:0;padding-left:18px;font-size:12.5px;color:var(--ink-2)}
|
|
||||||
.criteria li{margin:4px 0}
|
|
||||||
.criteria b{color:var(--ink)}
|
|
||||||
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
|
|
||||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
|
||||||
th{text-align:left;background:var(--soft);font-size:11px;letter-spacing:.05em;font-weight:800;color:var(--ink-3);padding:9px 12px;border-bottom:1px solid var(--line)}
|
|
||||||
td{padding:11px 12px;border-bottom:1px solid var(--line-soft);vertical-align:top}
|
|
||||||
tr:last-child td{border-bottom:0}
|
|
||||||
.cand .tbl-wrap{border:0;border-radius:0}
|
|
||||||
.num{font-family:var(--mono);color:var(--ink-4);white-space:nowrap}
|
|
||||||
.cust{font-weight:700}
|
|
||||||
.reason{font-size:12.5px;color:var(--ink-2)}
|
|
||||||
.flow{margin-top:16px;padding:9px 12px;border-radius:8px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
|
|
||||||
.flow b{color:var(--ink)}
|
|
||||||
.card-foot{padding:13px 22px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
|
|
||||||
@media(max-width:600px){.card-head{flex-wrap:wrap}.side{align-items:flex-start}}
|
|
||||||
/* 四态结论 pill 与风险等级 */
|
|
||||||
.st{display:inline-block;padding:1px 9px;border-radius:999px;font-size:11px;font-weight:800;letter-spacing:.04em;font-family:var(--mono);white-space:nowrap;border:1px solid transparent}
|
|
||||||
.st.ok{color:var(--ok);background:var(--ok-soft);border-color:#bfe6b4}
|
|
||||||
.st.miss{color:var(--danger);background:#feeae9;border-color:#f3b9b5}
|
|
||||||
.st.unknown{color:var(--amber);background:var(--amber-soft);border-color:var(--amber-line)}
|
|
||||||
.st.cond{color:var(--brand-deep);background:var(--brand-wash);border-color:var(--brand-soft)}
|
|
||||||
.risk{font-weight:800;font-size:12.5px}
|
|
||||||
.risk.low{color:var(--ok)}
|
|
||||||
.risk.mid{color:var(--amber)}
|
|
||||||
.risk.hi{color:var(--danger)}
|
|
||||||
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
|
|
||||||
/* 推荐程度 pill */
|
|
||||||
.rec{display:inline-block;padding:2px 11px;border-radius:999px;font-size:11.5px;font-weight:800;white-space:nowrap;border:1px solid transparent}
|
|
||||||
.rec.yes{color:var(--ok);background:var(--ok-soft);border-color:#bfe6b4}
|
|
||||||
.rec.cond{color:var(--amber);background:var(--amber-soft);border-color:var(--amber-line)}
|
|
||||||
.rec.no{color:var(--danger);background:#feeae9;border-color:#f3b9b5}
|
|
||||||
/* 差异统计(结论表内) */
|
|
||||||
.stat{font-family:var(--mono);font-size:11.5px;color:var(--ink-3);white-space:nowrap}
|
|
||||||
.stat b{font-weight:800}
|
|
||||||
.stat .m{color:var(--ok)} .stat .x{color:var(--danger)} .stat .u{color:var(--amber)} .stat .c{color:var(--brand-deep)}
|
|
||||||
/* 候选 PN 明细块:一个候选一张表 */
|
|
||||||
.cand{border:1px solid var(--line-soft);border-radius:10px;overflow:hidden;margin-bottom:16px}
|
|
||||||
.cand:last-of-type{margin-bottom:0}
|
|
||||||
.cand-head{display:flex;align-items:center;gap:10px;padding:11px 14px;background:linear-gradient(120deg,#f6f8fc 0%,#fff 70%);border-bottom:1px solid var(--line);flex-wrap:wrap}
|
|
||||||
.cand-idx{flex:none;width:22px;height:22px;border-radius:6px;display:grid;place-items:center;background:var(--ink);color:#fff;font-size:11px;font-weight:800;font-family:var(--mono)}
|
|
||||||
.cand-name{font-weight:800;font-size:14px}
|
|
||||||
.cand-brand{color:var(--ink-3);font-size:12px}
|
|
||||||
.cand-vs{font-family:var(--mono);font-size:11.5px;color:var(--ink-4)}
|
|
||||||
.cand-con{margin-left:auto;display:flex;align-items:center;gap:8px}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ACC2_CSS = `
|
|
||||||
:root{
|
|
||||||
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
|
|
||||||
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
|
|
||||||
--brand:#3370ff; --brand-deep:#245bdb; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
|
|
||||||
--ledger:#0aa294; --ledger-soft:#e4f8f5; --ledger-line:#b5e6df; /* 验收绿(2026-08-27 用户定:与对话区验收卡同色,原目标件是 #b23a2e 红) */
|
|
||||||
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
|
|
||||||
--mono:"SF Mono",Menlo,Consolas,monospace;
|
|
||||||
}
|
|
||||||
*{box-sizing:border-box}
|
|
||||||
html,body{margin:0;padding:0}
|
|
||||||
body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#eef1f6;color:var(--ink);font-size:14px;line-height:1.7;-webkit-font-smoothing:antialiased}
|
|
||||||
.page{margin:0;padding:0}
|
|
||||||
.card{background:#fff;overflow:hidden;border:0;border-radius:0;box-shadow:none}
|
|
||||||
.card-head{display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:2px solid var(--ink);background:linear-gradient(120deg,#eefaf8 0%,#fff 55%,#ecf8f5 100%)}
|
|
||||||
.badge{flex:none;width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--ledger);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(10,162,148,.25)}
|
|
||||||
.tit{flex:1;min-width:0}
|
|
||||||
.tit .kick{font-size:11px;letter-spacing:.08em;color:var(--ink-4);font-weight:700}
|
|
||||||
.tit h1{margin:2px 0 0;font-size:19px;font-weight:800;line-height:1.3}
|
|
||||||
.tit .sub{font-size:12px;color:var(--ink-3);margin-top:3px}
|
|
||||||
.side{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
|
|
||||||
.tag{font-size:11px;font-weight:800;padding:2px 10px;border-radius:999px;color:var(--ledger);background:var(--ledger-soft);border:1px solid var(--ledger-line)}
|
|
||||||
.step{font-size:12px;color:var(--ink-3)}
|
|
||||||
.deadline{font-size:12px;color:var(--ink-3)}
|
|
||||||
.deadline b{color:var(--danger)}
|
|
||||||
.card-bd{padding:20px 22px}
|
|
||||||
.zone{font-size:11px;font-weight:800;letter-spacing:.12em;color:var(--ink-4);margin:0 0 8px}
|
|
||||||
.zone b{color:var(--ink-3)}
|
|
||||||
.note{border:1px solid var(--line);border-left:3px solid var(--ledger);border-radius:8px;background:var(--soft);padding:14px 16px;margin-bottom:16px}
|
|
||||||
.note p{margin:0;font-size:13px;color:var(--ink-2)}
|
|
||||||
.note b{color:var(--ink)}
|
|
||||||
.criteria{border:1px solid var(--ledger-line);border-radius:10px;background:var(--ledger-soft);padding:13px 16px;margin-bottom:18px}
|
|
||||||
.criteria .c-title{font-size:11px;font-weight:800;letter-spacing:.08em;color:var(--ledger);margin-bottom:7px}
|
|
||||||
.criteria ul{margin:0;padding-left:18px;font-size:12.5px;color:var(--ink-2)}
|
|
||||||
.criteria li{margin:4px 0}
|
|
||||||
.criteria b{color:var(--ink)}
|
|
||||||
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
|
|
||||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
|
||||||
th{text-align:left;background:var(--soft);font-size:11px;letter-spacing:.05em;font-weight:800;color:var(--ink-3);padding:9px 12px;border-bottom:1px solid var(--line)}
|
|
||||||
td{padding:11px 12px;border-bottom:1px solid var(--line-soft);vertical-align:top}
|
|
||||||
tr:last-child td{border-bottom:0}
|
|
||||||
.num{font-family:var(--mono);color:var(--ink-4);white-space:nowrap}
|
|
||||||
.cust{font-weight:700}
|
|
||||||
.prio{font-family:var(--mono);font-weight:800}
|
|
||||||
.prio.hi{color:var(--danger)}
|
|
||||||
.prio.mid{color:var(--ink-2)}
|
|
||||||
.reason{font-size:12.5px;color:var(--ink-2)}
|
|
||||||
.flow{margin-top:16px;padding:9px 12px;border-radius:8px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
|
|
||||||
.flow b{color:var(--ink)}
|
|
||||||
.card-foot{padding:13px 22px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
|
|
||||||
@media(max-width:600px){.card-head{flex-wrap:wrap}.side{align-items:flex-start}}
|
|
||||||
/* 补充:推荐等级 pill 与状态强调(仅追加,不改动基件样式) */
|
|
||||||
.st{display:inline-block;padding:1px 9px;border-radius:999px;font-size:11px;font-weight:800;letter-spacing:.04em;font-family:var(--mono);white-space:nowrap;border:1px solid transparent}
|
|
||||||
.st.ok{color:var(--ok);background:var(--ok-soft);border-color:#bfe6b4}
|
|
||||||
.st.miss{color:var(--danger);background:var(--danger-soft);border-color:#f3b9b5}
|
|
||||||
.st.unknown{color:#8f6a12;background:#fdf3d7;border-color:#ecd9a0}
|
|
||||||
.st.cond{color:#b23a2e;background:#f7e4e0;border-color:#ecc5bd}
|
|
||||||
.risk{font-weight:800;font-size:12.5px}
|
|
||||||
.risk.mid{color:#8f6a12}
|
|
||||||
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
|
|
||||||
.tbl-gap{margin-top:18px}
|
|
||||||
`;
|
|
||||||
+1
-1
@@ -7,6 +7,6 @@ export type { Audience, Clock, HumanEventPublisher, IdGenerator, InteractionStor
|
|||||||
export type { InteractionServiceDeps } from './internal/usecase/interaction-service.ts';
|
export type { InteractionServiceDeps } from './internal/usecase/interaction-service.ts';
|
||||||
export { createInteractionService, visibleTo } from './internal/usecase/interaction-service.ts';
|
export { createInteractionService, visibleTo } from './internal/usecase/interaction-service.ts';
|
||||||
export { CONTROL_HOLDERS, checkTemplate, handoverHappens, inputControl, INTERACTION_PHASES, outputControl, templateIsSafe, } from './internal/domain/control-handover.ts';
|
export { CONTROL_HOLDERS, checkTemplate, handoverHappens, inputControl, INTERACTION_PHASES, outputControl, templateIsSafe, } from './internal/domain/control-handover.ts';
|
||||||
export { renderView, renderTemplate, renderRows, escapeHtml, displayValue } from './internal/domain/view-render.ts';
|
export { renderView, renderTemplate, renderRows, fillOptions, escapeHtml, displayValue } from './internal/domain/view-render.ts';
|
||||||
export { createInteractionStore } from './internal/adapter/interaction-store.ts';
|
export { createInteractionStore } from './internal/adapter/interaction-store.ts';
|
||||||
export { chatMessageTable, taskViewTable } from './internal/adapter/interaction-schema.ts';
|
export { chatMessageTable, taskViewTable } from './internal/adapter/interaction-schema.ts';
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
export type { AgentAction, AgentTurn, Narration, CaseModel, CaseRuntime, CaseState, DecisionResult, DecisionTrace, EnabledEntry, EnabledSet, Gap, ItemRuntime, ItemState, LoopEngineService, LoopModel, Navigation, PlanItem, PlanItemType, TerminalAuthority, TurnOutcome, } from './contract.ts';
|
export type { AgentAction, AgentTurn, Narration, CaseModel, CaseRuntime, CaseState, DecisionResult, DecisionTrace, EnabledEntry, EnabledSet, Gap, ItemRuntime, ItemState, LoopEngineService, LoopModel, Navigation, PlanItem, PlanItemType, TerminalAuthority, TurnOutcome, } from './contract.ts';
|
||||||
export { AutomaticOnWorldTouchingError, CmmnParseError, ForbiddenTerminalError, IllegalTransitionError, TURN_ACTION_CAP, } from './contract.ts';
|
export { AutomaticOnWorldTouchingError, CmmnParseError, ForbiddenTerminalError, IllegalTransitionError, TURN_ACTION_CAP, } from './contract.ts';
|
||||||
export type { AgentPort, CardGateway, Clock, ListenerRegistrar, LoopStateStore, MemoryRecorder, Sfeel, TaskContext, TaskDataGateway, ToolGateway, } from './port.ts';
|
export type { AgentPort, AnalyzeGateway, CardGateway, Clock, ListenerRegistrar, LoopStateStore, MemoryRecorder, Sfeel, TaskContext, TaskDataGateway, ToolGateway, } from './port.ts';
|
||||||
export type { TurnRunnerDeps } from './internal/usecase/turn-runner.ts';
|
export type { TurnRunnerDeps } from './internal/usecase/turn-runner.ts';
|
||||||
export { createTurnRunner } from './internal/usecase/turn-runner.ts';
|
export { createTurnRunner } from './internal/usecase/turn-runner.ts';
|
||||||
export { parseCmmn, parseXml, PLAN_ITEM_TYPES } from './internal/domain/cmmn-parse.ts';
|
export { parseCmmn, parseXml, PLAN_ITEM_TYPES } from './internal/domain/cmmn-parse.ts';
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ export type TaskDataGateway = {
|
|||||||
object: string;
|
object: string;
|
||||||
label: string;
|
label: string;
|
||||||
keys: string[];
|
keys: string[];
|
||||||
|
/**
|
||||||
|
* 单标识列(`declarations.ledger[].caseKey`)—— **摆给人看的行标识要去掉这一段**
|
||||||
|
* (2026-08-25 柴浩:「COV-…/CU-0142-01 只保留斜杠右边」):人正在这一单里看,
|
||||||
|
* 单号是脚下的地面,不是区分行的信息。基座那一侧同款做法见 `rowLabel`。
|
||||||
|
*/
|
||||||
|
caseKey?: string | undefined;
|
||||||
fields: Array<{
|
fields: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -56,8 +62,23 @@ export type TaskDataGateway = {
|
|||||||
object: string;
|
object: string;
|
||||||
key: Record<string, string | number>;
|
key: Record<string, string | number>;
|
||||||
patch: Record<string, unknown>;
|
patch: Record<string, unknown>;
|
||||||
|
/**
|
||||||
|
* **值没变就别落笔**(2026-08-29 终审 I2)—— 配方每一拍都把同一张表重投一遍,
|
||||||
|
* 而草稿层的 upsert 尾巴上有 `landed_at = null`(「再写一笔就不再是已落地」)。
|
||||||
|
* 照写的话:数据工单确认入库之后,下一拍配方一重跑,那些行的「已落地」记号就被**悄悄擦掉**,
|
||||||
|
* 整单放行时会被再写一遍正式表、再抛一遍 data 事件 —— 而承诺是「整单放行不再写它、不抛事件」。
|
||||||
|
* 模型那一路不传这个旗:它的「空转写」要被看见(如实回 `changed:false`),行为一个字不变。
|
||||||
|
*/
|
||||||
|
skipUnchanged?: boolean | undefined;
|
||||||
|
/**
|
||||||
|
* **「只建不改」的表碰到已在库的键:跳过,不拒**(2026-08-29 终审 I2)。
|
||||||
|
* 那句拒回是写给模型看的(「别再写它,用 drop 把骨架撤掉」)—— 摆到人面前毫无意义,
|
||||||
|
* 而配方抛出去就是永久停单。对配方来说「这颗料已经在库了」是**常态**,不是错。
|
||||||
|
*/
|
||||||
|
skipExisting?: boolean | undefined;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
changed: boolean;
|
changed: boolean;
|
||||||
|
skipped?: boolean;
|
||||||
}>;
|
}>;
|
||||||
/**
|
/**
|
||||||
* **撤掉本单草稿层的一行** —— 写错了得有退路。
|
* **撤掉本单草稿层的一行** —— 写错了得有退路。
|
||||||
@@ -71,8 +92,21 @@ export type TaskDataGateway = {
|
|||||||
domain: string;
|
domain: string;
|
||||||
object: string;
|
object: string;
|
||||||
key: Record<string, string | number>;
|
key: Record<string, string | number>;
|
||||||
|
/**
|
||||||
|
* **已落地的行:跳过,不拒**(2026-08-31,配方专用 —— 与 `draftWrite` 的两个旗同族)。
|
||||||
|
* 「数据工单确认即入库」把行落进正式表后,草稿层那一笔只是「本单写过它」的记号;
|
||||||
|
* 撤记号正式表一个字不动,读面反而少一行 —— 所以对人/模型那一路它是硬拒(拒回是
|
||||||
|
* 行动指南)。而配方 `prune` 的语义是「陈旧的投影行清掉」:已落地 = 已经是世界的
|
||||||
|
* 一部分,不是陈旧草稿 —— 对配方来说跳过是常态,抛错就是整单停摆(2026-08-30
|
||||||
|
* e2e 案例 2 的死锁:人确认入库后改了类型选择,prune 想撤已落地的旧类型行,当场炸;
|
||||||
|
* 当时以「②③ 不带 prune」绕开,代价是**没落地的**陈旧行也跟着不清了 ——
|
||||||
|
* 2026-08-31 用户实测点名的「改回同一类型,下面还是两个类型」正是这半截代价)。
|
||||||
|
* 模型那一路不传这个旗:行为一个字不变。
|
||||||
|
*/
|
||||||
|
skipLanded?: boolean | undefined;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
dropped: boolean;
|
dropped: boolean;
|
||||||
|
landed?: boolean;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
@@ -389,3 +423,25 @@ export type TaskContext = {
|
|||||||
*/
|
*/
|
||||||
machineCriteria(taskId: TaskId): Promise<ReadonlySet<string>>;
|
machineCriteria(taskId: TaskId): Promise<ReadonlySet<string>>;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* **一次结构化 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<Record<string, unknown>>;
|
||||||
|
/** 归因:`<步名>#<第几笔>` */
|
||||||
|
readonly label: string;
|
||||||
|
}): Promise<{
|
||||||
|
readonly object: Readonly<Record<string, unknown>>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export type SubmitForReleaseInput = {
|
|||||||
readonly criteria: readonly CriterionCheck[];
|
readonly criteria: readonly CriterionCheck[];
|
||||||
/** 人工验收清单(剧本声明,固定):卡上一条一枚开关,全部达标才能通过 */
|
/** 人工验收清单(剧本声明,固定):卡上一条一枚开关,全部达标才能通过 */
|
||||||
readonly checklist?: ReadonlyArray<string>;
|
readonly checklist?: ReadonlyArray<string>;
|
||||||
|
/** 这张卡打回得了吗 —— `false` = 打回之后没人接得住(见契约 `AcceptanceWorkOrder.redoable`) */
|
||||||
|
readonly redoable?: boolean;
|
||||||
/** 报告可修订:AI 拟值已填的表单字段 */
|
/** 报告可修订:AI 拟值已填的表单字段 */
|
||||||
readonly fields?: ReadonlyArray<Record<string, unknown>>;
|
readonly fields?: ReadonlyArray<Record<string, unknown>>;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ export type CardGateway = {
|
|||||||
}>;
|
}>;
|
||||||
/** 人工验收清单 —— 原样带上卡 */
|
/** 人工验收清单 —— 原样带上卡 */
|
||||||
checklist?: ReadonlyArray<string> | undefined;
|
checklist?: ReadonlyArray<string> | undefined;
|
||||||
|
/** 这张卡打回得了吗(`false` = 那颗钮不摆)—— 端口不声明它,值就在装配层被丢掉,这行不能省 */
|
||||||
|
redoable?: boolean | undefined;
|
||||||
/**
|
/**
|
||||||
* 可修订字段 —— 验收报告「AI 已填好、可编辑」改的就是它们(带 `ai` 拟值与 `ledger` 落回地址)。
|
* 可修订字段 —— 验收报告「AI 已填好、可编辑」改的就是它们(带 `ai` 拟值与 `ledger` 落回地址)。
|
||||||
* **端口不声明它,值就在装配层被丢掉**:上游一路传下来,而 TypeScript 对条件展开
|
* **端口不声明它,值就在装配层被丢掉**:上游一路传下来,而 TypeScript 对条件展开
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
@@ -296,9 +296,16 @@ export declare const launchParamTypeSchema: z.ZodEnum<{
|
|||||||
list: "list";
|
list: "list";
|
||||||
}>;
|
}>;
|
||||||
export type LaunchParamType = z.infer<typeof launchParamTypeSchema>;
|
export type LaunchParamType = z.infer<typeof launchParamTypeSchema>;
|
||||||
|
/**
|
||||||
|
* 一条候选。`group` 是**联动分组键**(2026-08-31 用户定):清单 `x-options.group` 指了哪一列,
|
||||||
|
* 这颗候选就带上它在那一列的值(如料号的 `chip_type_id`);空串 = 那一列没值(「未标类型」)。
|
||||||
|
* `groupLabel` 是这枚键的人读名(从字典表现读,如「DC-DC 降压稳压器」),读不到就没有 —— 界面退回摆键本身。
|
||||||
|
*/
|
||||||
export declare const launchOptionSchema: z.ZodObject<{
|
export declare const launchOptionSchema: z.ZodObject<{
|
||||||
value: z.ZodString;
|
value: z.ZodString;
|
||||||
label: z.ZodString;
|
label: z.ZodString;
|
||||||
|
group: z.ZodOptional<z.ZodString>;
|
||||||
|
groupLabel: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
export type LaunchOption = z.infer<typeof launchOptionSchema>;
|
export type LaunchOption = z.infer<typeof launchOptionSchema>;
|
||||||
export declare const launchParamSchema: z.ZodObject<{
|
export declare const launchParamSchema: z.ZodObject<{
|
||||||
@@ -317,10 +324,22 @@ export declare const launchParamSchema: z.ZodObject<{
|
|||||||
options: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
options: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
value: z.ZodString;
|
value: z.ZodString;
|
||||||
label: z.ZodString;
|
label: z.ZodString;
|
||||||
|
group: z.ZodOptional<z.ZodString>;
|
||||||
|
groupLabel: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
||||||
|
linkGroup: z.ZodOptional<z.ZodString>;
|
||||||
|
distinctGroup: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
export type LaunchParam = z.infer<typeof launchParamSchema>;
|
export type LaunchParam = z.infer<typeof launchParamSchema>;
|
||||||
|
/**
|
||||||
|
* 「算不算同一个」的归一:去空白 + 不分大小写 —— **前后端同一把尺**。
|
||||||
|
* 大小写要归:料号这类身份不因字母大小写而不同,只挡精确相等的话,`lm2596s-5.0` 会照样进去,
|
||||||
|
* 混一颗垃圾主数据出来,人还得到一份自己跟自己比的评估。
|
||||||
|
*/
|
||||||
|
export declare function launchDistinctKey(value: string): string;
|
||||||
|
/** 撞上了那一格的话 —— 前端(填的当口)与服务端(400 的 detail)同一句,不许分叉 */
|
||||||
|
export declare function launchDistinctMessage(otherLabel: string): string;
|
||||||
export declare const launchParamsSchema: z.ZodObject<{
|
export declare const launchParamsSchema: z.ZodObject<{
|
||||||
activityId: z.ZodString;
|
activityId: z.ZodString;
|
||||||
version: z.ZodString;
|
version: z.ZodString;
|
||||||
@@ -340,8 +359,12 @@ export declare const launchParamsSchema: z.ZodObject<{
|
|||||||
options: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
options: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
value: z.ZodString;
|
value: z.ZodString;
|
||||||
label: z.ZodString;
|
label: z.ZodString;
|
||||||
|
group: z.ZodOptional<z.ZodString>;
|
||||||
|
groupLabel: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
||||||
|
linkGroup: z.ZodOptional<z.ZodString>;
|
||||||
|
distinctGroup: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
export type LaunchParams = z.infer<typeof launchParamsSchema>;
|
export type LaunchParams = z.infer<typeof launchParamsSchema>;
|
||||||
|
|||||||
@@ -176,8 +176,12 @@ export declare const opsLaunchParamsRoute: {
|
|||||||
options: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
options: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
value: z.ZodString;
|
value: z.ZodString;
|
||||||
label: z.ZodString;
|
label: z.ZodString;
|
||||||
|
group: z.ZodOptional<z.ZodString>;
|
||||||
|
groupLabel: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
||||||
|
linkGroup: z.ZodOptional<z.ZodString>;
|
||||||
|
distinctGroup: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2237,6 +2237,8 @@ export declare const installActivityRoute: {
|
|||||||
review: "review";
|
review: "review";
|
||||||
}>>;
|
}>>;
|
||||||
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
|
humanWrites: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
|
auto: z.ZodOptional<z.ZodType<import("../auto-recipe.ts").AutoRecipe, unknown, z.core.$ZodTypeInternals<import("../auto-recipe.ts").AutoRecipe, unknown>>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
@@ -2833,6 +2835,11 @@ export declare const testActivityStateRoute: {
|
|||||||
execution: "execution";
|
execution: "execution";
|
||||||
acceptance: "acceptance";
|
acceptance: "acceptance";
|
||||||
}>;
|
}>;
|
||||||
|
variant: z.ZodOptional<z.ZodEnum<{
|
||||||
|
data: "data";
|
||||||
|
accept: "accept";
|
||||||
|
}>>;
|
||||||
|
redoable: z.ZodOptional<z.ZodBoolean>;
|
||||||
title: z.ZodString;
|
title: z.ZodString;
|
||||||
done: z.ZodBoolean;
|
done: z.ZodBoolean;
|
||||||
result: z.ZodOptional<z.ZodString>;
|
result: z.ZodOptional<z.ZodString>;
|
||||||
|
|||||||
@@ -660,6 +660,7 @@ export declare const taskDetailSchema: z.ZodObject<{
|
|||||||
}>>;
|
}>>;
|
||||||
variant: z.ZodOptional<z.ZodEnum<{
|
variant: z.ZodOptional<z.ZodEnum<{
|
||||||
data: "data";
|
data: "data";
|
||||||
|
accept: "accept";
|
||||||
}>>;
|
}>>;
|
||||||
ask: z.ZodString;
|
ask: z.ZodString;
|
||||||
standard: z.ZodOptional<z.ZodString>;
|
standard: z.ZodOptional<z.ZodString>;
|
||||||
@@ -907,6 +908,8 @@ export declare const taskDetailSchema: z.ZodObject<{
|
|||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
submitLabel: z.ZodOptional<z.ZodString>;
|
submitLabel: z.ZodOptional<z.ZodString>;
|
||||||
gateLedgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
gateLedgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
|
gateWhen: z.ZodOptional<z.ZodString>;
|
||||||
|
gateWhenSay: z.ZodOptional<z.ZodString>;
|
||||||
gate: z.ZodOptional<z.ZodObject<{
|
gate: z.ZodOptional<z.ZodObject<{
|
||||||
ready: z.ZodBoolean;
|
ready: z.ZodBoolean;
|
||||||
why: z.ZodString;
|
why: z.ZodString;
|
||||||
@@ -1222,6 +1225,7 @@ export declare const taskDetailSchema: z.ZodObject<{
|
|||||||
because: z.ZodString;
|
because: z.ZodString;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
passLabel: z.ZodOptional<z.ZodString>;
|
passLabel: z.ZodOptional<z.ZodString>;
|
||||||
|
redoable: z.ZodOptional<z.ZodBoolean>;
|
||||||
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
||||||
at: z.ZodISODateTime;
|
at: z.ZodISODateTime;
|
||||||
title: z.ZodString;
|
title: z.ZodString;
|
||||||
@@ -1251,6 +1255,7 @@ export declare const taskDetailSchema: z.ZodObject<{
|
|||||||
}>>;
|
}>>;
|
||||||
variant: z.ZodOptional<z.ZodEnum<{
|
variant: z.ZodOptional<z.ZodEnum<{
|
||||||
data: "data";
|
data: "data";
|
||||||
|
accept: "accept";
|
||||||
}>>;
|
}>>;
|
||||||
ask: z.ZodString;
|
ask: z.ZodString;
|
||||||
standard: z.ZodOptional<z.ZodString>;
|
standard: z.ZodOptional<z.ZodString>;
|
||||||
@@ -1498,6 +1503,8 @@ export declare const taskDetailSchema: z.ZodObject<{
|
|||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
submitLabel: z.ZodOptional<z.ZodString>;
|
submitLabel: z.ZodOptional<z.ZodString>;
|
||||||
gateLedgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
gateLedgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
|
gateWhen: z.ZodOptional<z.ZodString>;
|
||||||
|
gateWhenSay: z.ZodOptional<z.ZodString>;
|
||||||
gate: z.ZodOptional<z.ZodObject<{
|
gate: z.ZodOptional<z.ZodObject<{
|
||||||
ready: z.ZodBoolean;
|
ready: z.ZodBoolean;
|
||||||
why: z.ZodString;
|
why: z.ZodString;
|
||||||
@@ -1813,6 +1820,7 @@ export declare const taskDetailSchema: z.ZodObject<{
|
|||||||
because: z.ZodString;
|
because: z.ZodString;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
passLabel: z.ZodOptional<z.ZodString>;
|
passLabel: z.ZodOptional<z.ZodString>;
|
||||||
|
redoable: z.ZodOptional<z.ZodBoolean>;
|
||||||
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
||||||
at: z.ZodISODateTime;
|
at: z.ZodISODateTime;
|
||||||
title: z.ZodString;
|
title: z.ZodString;
|
||||||
@@ -2584,6 +2592,7 @@ export declare const taskDetailRoute: {
|
|||||||
}>>;
|
}>>;
|
||||||
variant: z.ZodOptional<z.ZodEnum<{
|
variant: z.ZodOptional<z.ZodEnum<{
|
||||||
data: "data";
|
data: "data";
|
||||||
|
accept: "accept";
|
||||||
}>>;
|
}>>;
|
||||||
ask: z.ZodString;
|
ask: z.ZodString;
|
||||||
standard: z.ZodOptional<z.ZodString>;
|
standard: z.ZodOptional<z.ZodString>;
|
||||||
@@ -2831,6 +2840,8 @@ export declare const taskDetailRoute: {
|
|||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
submitLabel: z.ZodOptional<z.ZodString>;
|
submitLabel: z.ZodOptional<z.ZodString>;
|
||||||
gateLedgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
gateLedgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
|
gateWhen: z.ZodOptional<z.ZodString>;
|
||||||
|
gateWhenSay: z.ZodOptional<z.ZodString>;
|
||||||
gate: z.ZodOptional<z.ZodObject<{
|
gate: z.ZodOptional<z.ZodObject<{
|
||||||
ready: z.ZodBoolean;
|
ready: z.ZodBoolean;
|
||||||
why: z.ZodString;
|
why: z.ZodString;
|
||||||
@@ -3146,6 +3157,7 @@ export declare const taskDetailRoute: {
|
|||||||
because: z.ZodString;
|
because: z.ZodString;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
passLabel: z.ZodOptional<z.ZodString>;
|
passLabel: z.ZodOptional<z.ZodString>;
|
||||||
|
redoable: z.ZodOptional<z.ZodBoolean>;
|
||||||
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
||||||
at: z.ZodISODateTime;
|
at: z.ZodISODateTime;
|
||||||
title: z.ZodString;
|
title: z.ZodString;
|
||||||
@@ -3175,6 +3187,7 @@ export declare const taskDetailRoute: {
|
|||||||
}>>;
|
}>>;
|
||||||
variant: z.ZodOptional<z.ZodEnum<{
|
variant: z.ZodOptional<z.ZodEnum<{
|
||||||
data: "data";
|
data: "data";
|
||||||
|
accept: "accept";
|
||||||
}>>;
|
}>>;
|
||||||
ask: z.ZodString;
|
ask: z.ZodString;
|
||||||
standard: z.ZodOptional<z.ZodString>;
|
standard: z.ZodOptional<z.ZodString>;
|
||||||
@@ -3422,6 +3435,8 @@ export declare const taskDetailRoute: {
|
|||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
submitLabel: z.ZodOptional<z.ZodString>;
|
submitLabel: z.ZodOptional<z.ZodString>;
|
||||||
gateLedgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
gateLedgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
|
gateWhen: z.ZodOptional<z.ZodString>;
|
||||||
|
gateWhenSay: z.ZodOptional<z.ZodString>;
|
||||||
gate: z.ZodOptional<z.ZodObject<{
|
gate: z.ZodOptional<z.ZodObject<{
|
||||||
ready: z.ZodBoolean;
|
ready: z.ZodBoolean;
|
||||||
why: z.ZodString;
|
why: z.ZodString;
|
||||||
@@ -3737,6 +3752,7 @@ export declare const taskDetailRoute: {
|
|||||||
because: z.ZodString;
|
because: z.ZodString;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
passLabel: z.ZodOptional<z.ZodString>;
|
passLabel: z.ZodOptional<z.ZodString>;
|
||||||
|
redoable: z.ZodOptional<z.ZodBoolean>;
|
||||||
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
||||||
at: z.ZodISODateTime;
|
at: z.ZodISODateTime;
|
||||||
title: z.ZodString;
|
title: z.ZodString;
|
||||||
|
|||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* 确定性步骤的**声明式配方**(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<Record<string, AutoExpr>>;
|
||||||
|
readonly take: string;
|
||||||
|
readonly else?: AutoExpr | undefined;
|
||||||
|
}
|
||||||
|
/** S-FEEL 条件(可嵌套)—— 已算出的同行字段进它的作用域 */
|
||||||
|
| {
|
||||||
|
readonly when: string;
|
||||||
|
readonly then: AutoExpr;
|
||||||
|
readonly else: AutoExpr;
|
||||||
|
};
|
||||||
|
export declare const autoExprSchema: z.ZodType<AutoExpr>;
|
||||||
|
/**
|
||||||
|
* 数据源 —— 这一笔要对着几行办。
|
||||||
|
*
|
||||||
|
* 地址只有四种(spec §5.1):`域.对象`(**任务视图 = 正式表 + 本单草稿叠加**,默认该用的那一种)·
|
||||||
|
* `mine:域.对象`(只有本单草稿写下的行)· `in:入参数组` · `result:xxx`(本拍 analyze 的出参)。
|
||||||
|
* **没有「纯正式表」那一种** —— 要区分「本单写的」与「别人放行的」,`mine:` 就够了。
|
||||||
|
*/
|
||||||
|
export type AutoSource = {
|
||||||
|
readonly once: true;
|
||||||
|
} | {
|
||||||
|
readonly rows: readonly Readonly<Record<string, AutoExpr>>[];
|
||||||
|
} | {
|
||||||
|
readonly each: string;
|
||||||
|
readonly where?: Readonly<Record<string, AutoExpr>> | undefined;
|
||||||
|
/** 第二层,**到此为止** —— 三层以上就别 auto 了 */
|
||||||
|
readonly then?: {
|
||||||
|
readonly each: string;
|
||||||
|
readonly where?: Readonly<Record<string, AutoExpr>> | undefined;
|
||||||
|
} | undefined;
|
||||||
|
};
|
||||||
|
export declare const autoSourceSchema: z.ZodType<AutoSource>;
|
||||||
|
/** 五个动词 —— `when` 为假就跳过这一笔 */
|
||||||
|
export type AutoAction = {
|
||||||
|
readonly kind: 'project';
|
||||||
|
readonly when?: string | undefined;
|
||||||
|
/** 台账地址「域.对象」—— 必须在 `declarations.ledger` 里(静态审) */
|
||||||
|
readonly into: string;
|
||||||
|
/**
|
||||||
|
* 这一笔投影出来的行**就是这张表在本单草稿里的全部**,多出来的撤掉。只动草稿层。
|
||||||
|
* `true`:只在非 quiet 拍撤 —— 人正对着件编辑的那几拍不动他的行(2026-08-29 终审 F3:
|
||||||
|
* 人刚在件上加的行,配方的数据源里当然没有,quiet 撤行就是静默数据丢失)。
|
||||||
|
* `'live'`(2026-08-31):quiet 拍也撤 —— **只给人不会在件上加行的表用**(行集全由配方铺,
|
||||||
|
* 如 W05 的参数骨架):F3 防的是「人刚加的行被撤」,人加不了行的表没有这个风险,
|
||||||
|
* 而「改了类型,旧类型的参数行还摆在屏上」是另一种撒谎 —— 跟手比守候更诚实。
|
||||||
|
* 已落地的行两档都跳过并出声(draftDrop 的 skipLanded)。
|
||||||
|
*/
|
||||||
|
readonly prune?: boolean | 'live' | undefined;
|
||||||
|
readonly from: AutoSource;
|
||||||
|
readonly fields: Readonly<Record<string, AutoExpr>>;
|
||||||
|
} | {
|
||||||
|
readonly kind: 'analyze';
|
||||||
|
readonly when?: string | undefined;
|
||||||
|
/** 活动声明过的模型名 */
|
||||||
|
readonly model: string;
|
||||||
|
/** 出参落进拍内作用域 `result:<into>`,供后面的 project 用 `each`/`lookup` 消费 */
|
||||||
|
readonly into: string;
|
||||||
|
/** 给模型的 system 段 —— 说清判什么、四态各是什么意思 */
|
||||||
|
readonly instruction: string;
|
||||||
|
/** 入参由声明组装:一行一条,`fields` 决定每条摆哪几个键 */
|
||||||
|
readonly input: {
|
||||||
|
readonly from: AutoSource;
|
||||||
|
readonly fields: Readonly<Record<string, AutoExpr>>;
|
||||||
|
};
|
||||||
|
/** 出参 JSON Schema —— 顶层必须是 object */
|
||||||
|
readonly output: Readonly<Record<string, unknown>>;
|
||||||
|
} | {
|
||||||
|
readonly kind: 'card';
|
||||||
|
readonly when?: string | undefined;
|
||||||
|
readonly as: 'execution' | 'decide';
|
||||||
|
/**
|
||||||
|
* 卡的面孔:`data` 数据工单 / `accept` 验收工单(都是执行工单的变体)· `review` 审核卡(决策卡的)。
|
||||||
|
* 不写就是普通执行工单 / 普通决策卡。
|
||||||
|
*/
|
||||||
|
readonly variant?: ('data' | 'review' | 'accept') | undefined;
|
||||||
|
readonly title: AutoExpr;
|
||||||
|
/** 决策卡问的那句话 */
|
||||||
|
readonly ask?: AutoExpr | undefined;
|
||||||
|
/** 作业指导书 —— 一条一句,静态文本 */
|
||||||
|
readonly guide?: readonly string[] | undefined;
|
||||||
|
readonly standard?: AutoExpr | undefined;
|
||||||
|
/** 调用必附上下文 —— **基座拒发 basis 为空的工单**,别指望兜底(它摆的是内部家具) */
|
||||||
|
readonly basis?: readonly {
|
||||||
|
readonly k: string;
|
||||||
|
readonly v: AutoExpr;
|
||||||
|
}[] | undefined;
|
||||||
|
/** 这张卡的提交闸盯哪几张表 */
|
||||||
|
readonly gateLedgers?: readonly string[] | undefined;
|
||||||
|
/** 确认闸的条件档(S-FEEL,只读基座读数;为假不受理确认)+ 拒回那句话 —— 见 card.ts 同名字段 */
|
||||||
|
readonly gateWhen?: string | undefined;
|
||||||
|
readonly gateWhenSay?: string | undefined;
|
||||||
|
/** 决策卡的选项;`variant:'review'` 时允许只有一个 */
|
||||||
|
readonly options?: readonly {
|
||||||
|
readonly value: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly detail?: string | undefined;
|
||||||
|
}[] | undefined;
|
||||||
|
} | {
|
||||||
|
readonly kind: 'submit';
|
||||||
|
readonly when?: string | undefined;
|
||||||
|
readonly rationale: AutoExpr;
|
||||||
|
} | {
|
||||||
|
readonly kind: 'say';
|
||||||
|
readonly when?: string | undefined;
|
||||||
|
readonly text: AutoExpr;
|
||||||
|
};
|
||||||
|
export declare const autoActionSchema: z.ZodType<AutoAction>;
|
||||||
|
export type AutoRecipe = {
|
||||||
|
/** 配方级局部值:先算一遍,后面所有表达式都能 `{in:'名'}` 引用 —— 整单只有一个案号,靠它 */
|
||||||
|
readonly let?: Readonly<Record<string, AutoExpr>> | undefined;
|
||||||
|
/** 按序执行 */
|
||||||
|
readonly do: readonly AutoAction[];
|
||||||
|
};
|
||||||
|
export declare const autoRecipeSchema: z.ZodType<AutoRecipe>;
|
||||||
+8
@@ -615,6 +615,7 @@ export declare const executionWorkOrderSchema: z.ZodObject<{
|
|||||||
}>>;
|
}>>;
|
||||||
variant: z.ZodOptional<z.ZodEnum<{
|
variant: z.ZodOptional<z.ZodEnum<{
|
||||||
data: "data";
|
data: "data";
|
||||||
|
accept: "accept";
|
||||||
}>>;
|
}>>;
|
||||||
ask: z.ZodString;
|
ask: z.ZodString;
|
||||||
standard: z.ZodOptional<z.ZodString>;
|
standard: z.ZodOptional<z.ZodString>;
|
||||||
@@ -862,6 +863,8 @@ export declare const executionWorkOrderSchema: z.ZodObject<{
|
|||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
submitLabel: z.ZodOptional<z.ZodString>;
|
submitLabel: z.ZodOptional<z.ZodString>;
|
||||||
gateLedgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
gateLedgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
|
gateWhen: z.ZodOptional<z.ZodString>;
|
||||||
|
gateWhenSay: z.ZodOptional<z.ZodString>;
|
||||||
gate: z.ZodOptional<z.ZodObject<{
|
gate: z.ZodOptional<z.ZodObject<{
|
||||||
ready: z.ZodBoolean;
|
ready: z.ZodBoolean;
|
||||||
why: z.ZodString;
|
why: z.ZodString;
|
||||||
@@ -1205,6 +1208,7 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{
|
|||||||
because: z.ZodString;
|
because: z.ZodString;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
passLabel: z.ZodOptional<z.ZodString>;
|
passLabel: z.ZodOptional<z.ZodString>;
|
||||||
|
redoable: z.ZodOptional<z.ZodBoolean>;
|
||||||
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
||||||
at: z.ZodISODateTime;
|
at: z.ZodISODateTime;
|
||||||
title: z.ZodString;
|
title: z.ZodString;
|
||||||
@@ -1235,6 +1239,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
}>>;
|
}>>;
|
||||||
variant: z.ZodOptional<z.ZodEnum<{
|
variant: z.ZodOptional<z.ZodEnum<{
|
||||||
data: "data";
|
data: "data";
|
||||||
|
accept: "accept";
|
||||||
}>>;
|
}>>;
|
||||||
ask: z.ZodString;
|
ask: z.ZodString;
|
||||||
standard: z.ZodOptional<z.ZodString>;
|
standard: z.ZodOptional<z.ZodString>;
|
||||||
@@ -1482,6 +1487,8 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
submitLabel: z.ZodOptional<z.ZodString>;
|
submitLabel: z.ZodOptional<z.ZodString>;
|
||||||
gateLedgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
gateLedgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
|
gateWhen: z.ZodOptional<z.ZodString>;
|
||||||
|
gateWhenSay: z.ZodOptional<z.ZodString>;
|
||||||
gate: z.ZodOptional<z.ZodObject<{
|
gate: z.ZodOptional<z.ZodObject<{
|
||||||
ready: z.ZodBoolean;
|
ready: z.ZodBoolean;
|
||||||
why: z.ZodString;
|
why: z.ZodString;
|
||||||
@@ -1797,6 +1804,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
because: z.ZodString;
|
because: z.ZodString;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
passLabel: z.ZodOptional<z.ZodString>;
|
passLabel: z.ZodOptional<z.ZodString>;
|
||||||
|
redoable: z.ZodOptional<z.ZodBoolean>;
|
||||||
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
||||||
at: z.ZodISODateTime;
|
at: z.ZodISODateTime;
|
||||||
title: z.ZodString;
|
title: z.ZodString;
|
||||||
|
|||||||
@@ -15,4 +15,5 @@ export * from './manifest.ts';
|
|||||||
export * from './org.ts';
|
export * from './org.ts';
|
||||||
export * from './mail.ts';
|
export * from './mail.ts';
|
||||||
export * from './activity-ops.ts';
|
export * from './activity-ops.ts';
|
||||||
|
export * from './auto-recipe.ts';
|
||||||
export * from './api/index.ts';
|
export * from './api/index.ts';
|
||||||
|
|||||||
+10
@@ -207,6 +207,8 @@ export declare const planStepSchema: z.ZodObject<{
|
|||||||
review: "review";
|
review: "review";
|
||||||
}>>;
|
}>>;
|
||||||
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
|
humanWrites: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
|
auto: z.ZodOptional<z.ZodType<import("./auto-recipe.ts").AutoRecipe, unknown, z.core.$ZodTypeInternals<import("./auto-recipe.ts").AutoRecipe, unknown>>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
export type PlanStep = z.infer<typeof planStepSchema>;
|
export type PlanStep = z.infer<typeof planStepSchema>;
|
||||||
/**
|
/**
|
||||||
@@ -272,6 +274,8 @@ export declare const loopModelSchema: z.ZodObject<{
|
|||||||
review: "review";
|
review: "review";
|
||||||
}>>;
|
}>>;
|
||||||
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
|
humanWrites: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
|
auto: z.ZodOptional<z.ZodType<import("./auto-recipe.ts").AutoRecipe, unknown, z.core.$ZodTypeInternals<import("./auto-recipe.ts").AutoRecipe, unknown>>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
@@ -331,6 +335,8 @@ export declare const businessModelSchema: z.ZodObject<{
|
|||||||
review: "review";
|
review: "review";
|
||||||
}>>;
|
}>>;
|
||||||
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
|
humanWrites: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
|
auto: z.ZodOptional<z.ZodType<import("./auto-recipe.ts").AutoRecipe, unknown, z.core.$ZodTypeInternals<import("./auto-recipe.ts").AutoRecipe, unknown>>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
@@ -488,6 +494,8 @@ export declare const manifestAssetsSchema: z.ZodObject<{
|
|||||||
review: "review";
|
review: "review";
|
||||||
}>>;
|
}>>;
|
||||||
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
|
humanWrites: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
|
auto: z.ZodOptional<z.ZodType<import("./auto-recipe.ts").AutoRecipe, unknown, z.core.$ZodTypeInternals<import("./auto-recipe.ts").AutoRecipe, unknown>>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
@@ -793,6 +801,8 @@ export declare const activityManifestSchema: z.ZodObject<{
|
|||||||
review: "review";
|
review: "review";
|
||||||
}>>;
|
}>>;
|
||||||
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
|
humanWrites: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
|
auto: z.ZodOptional<z.ZodType<import("./auto-recipe.ts").AutoRecipe, unknown, z.core.$ZodTypeInternals<import("./auto-recipe.ts").AutoRecipe, unknown>>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
|
|||||||
+22
@@ -10,3 +10,25 @@ export declare function isDefined<T>(value: T | null | undefined): value is T;
|
|||||||
export declare function groupBy<T, K extends string>(items: readonly T[], key: (item: T) => K): Record<K, T[]>;
|
export declare function groupBy<T, K extends string>(items: readonly T[], key: (item: T) => K): Record<K, T[]>;
|
||||||
/** 稳定排序的键值对遍历(避免 Object.keys 顺序依赖) */
|
/** 稳定排序的键值对遍历(避免 Object.keys 顺序依赖) */
|
||||||
export declare function sortedEntries<V>(record: Readonly<Record<string, V>>): Array<[string, V]>;
|
export declare function sortedEntries<V>(record: Readonly<Record<string, V>>): Array<[string, V]>;
|
||||||
|
export type TopoOrderResult = {
|
||||||
|
readonly ok: true;
|
||||||
|
readonly order: readonly string[];
|
||||||
|
} | {
|
||||||
|
readonly ok: false;
|
||||||
|
readonly cycle: readonly string[];
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 拓扑排序(Kahn 算法)—— 给定节点集合与「这个节点依赖谁」,排出一个安全的处理序
|
||||||
|
* (被依赖的排前面)。
|
||||||
|
*
|
||||||
|
* 同族算法已有一份在 `apps/api/src/modules/process/internal/domain/routing.ts` 的
|
||||||
|
* `advanceOrder`(那是流程图专用的,找不齐拓扑序时把剩下的节点原样缀在末尾 ——
|
||||||
|
* 流程图的容错策略)。这里是它的**通用版**,故意搬到叶子包:两个不同层级的模块
|
||||||
|
* (`activity-registry` 装包静态审 / `loop-engine` 配方求值)都要用同一个算法判「排不排得出来」,
|
||||||
|
* 而模块分层不许它们互相 import ——图算法本身不含业务语义,该住在 `@utils`。
|
||||||
|
*
|
||||||
|
* **不兜底**:排不出来就是这几个节点之间有环,原样报出来,由调用方决定怎么处置
|
||||||
|
* (装包时机检报错 / 运行时交人),不在这一层替调用方决定「怎么办」。
|
||||||
|
* `dependsOn` 返回的名字如果不在 `nodes` 里,当作没这条依赖(忽略,不会因此误报成环)。
|
||||||
|
*/
|
||||||
|
export declare function topoOrder(nodes: readonly string[], dependsOn: (node: string) => readonly string[]): TopoOrderResult;
|
||||||
|
|||||||
@@ -208,3 +208,146 @@ describe('validateLaunchParams', () => {
|
|||||||
expect(nan.ok).toBe(false);
|
expect(nan.ok).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ── 按分组键联动(2026-08-31 用户定:原料号 / 候选料号按芯片类型互相收窄)────────────
|
||||||
|
派生层只负责把两样东西送到前端:①候选带上它的分组键与人读名(`x-options.group`);
|
||||||
|
②这一格属于哪个联动组(`x-link`)。收窄怎么摆是前端的事,**服务端不据此立墙**。 */
|
||||||
|
describe('x-options.group / x-link', () => {
|
||||||
|
const LINKED = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['originalInternalPn', 'candidateInternalPn'],
|
||||||
|
properties: {
|
||||||
|
originalInternalPn: {
|
||||||
|
type: 'string',
|
||||||
|
title: '原内部料号',
|
||||||
|
'x-control': 'combo',
|
||||||
|
'x-options': {
|
||||||
|
ledger: 'parts.profile',
|
||||||
|
value: 'internal_pn',
|
||||||
|
label: 'internal_pn',
|
||||||
|
group: { column: 'chip_type_id', ledger: 'parts.chip_type', value: 'type_id', label: 'type_name' },
|
||||||
|
},
|
||||||
|
'x-link': 'chip-type',
|
||||||
|
'x-order': 1,
|
||||||
|
},
|
||||||
|
candidateInternalPn: {
|
||||||
|
type: 'string',
|
||||||
|
title: '候选内部料号',
|
||||||
|
'x-control': 'combo',
|
||||||
|
'x-options': { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn', group: 'chip_type_id' },
|
||||||
|
'x-link': 'chip-type',
|
||||||
|
'x-order': 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('分组键两种写法都认:整份声明(带字典)与只写列名;x-link 读成联动组名', () => {
|
||||||
|
const specs = fieldSpecsOf(LINKED);
|
||||||
|
expect(specs[0]?.optionsSource?.group).toEqual({ column: 'chip_type_id', dict: { ledger: 'parts.chip_type', value: 'type_id', label: 'type_name' } });
|
||||||
|
// 只写列名 = 有分组键、没有人读名字典
|
||||||
|
expect(specs[1]?.optionsSource?.group).toEqual({ column: 'chip_type_id', dict: undefined });
|
||||||
|
expect(specs.map((s) => s.linkGroup)).toEqual(['chip-type', 'chip-type']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('分组键写残(没有 column,或字典缺 ledger/value)就当没声明 —— 候选照出,只是不联动', () => {
|
||||||
|
const bad = fieldSpecsOf({
|
||||||
|
properties: {
|
||||||
|
a: { type: 'string', 'x-control': 'combo', 'x-options': { ledger: 'l.o', value: 'v', group: {} } },
|
||||||
|
b: { type: 'string', 'x-control': 'combo', 'x-options': { ledger: 'l.o', value: 'v', group: { column: 'c', ledger: 'd.t' } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(bad[0]?.optionsSource).toEqual({ ledger: 'l.o', value: 'v', label: 'v', group: undefined });
|
||||||
|
// 字典写残 → 只留分组键,人读名没有(不炸)
|
||||||
|
expect(bad[1]?.optionsSource?.group).toEqual({ column: 'c', dict: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('候选带 group / groupLabel 原样交给前端;有候选才给 linkGroup(一个候选都没有,联动无从谈起)', async () => {
|
||||||
|
const params = await launchParamsOf(LINKED, async (src) =>
|
||||||
|
src.group === undefined
|
||||||
|
? [{ value: 'AMS1117-3.3', label: 'AMS1117-3.3' }]
|
||||||
|
: [
|
||||||
|
{ value: 'LM2596S-5.0', label: 'LM2596S-5.0', group: '1', groupLabel: 'DC-DC 降压稳压器' },
|
||||||
|
{ value: 'OLD-001', label: 'OLD-001', group: '' },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(params[0]).toMatchObject({
|
||||||
|
key: 'originalInternalPn',
|
||||||
|
linkGroup: 'chip-type',
|
||||||
|
options: [
|
||||||
|
{ value: 'LM2596S-5.0', label: 'LM2596S-5.0', group: '1', groupLabel: 'DC-DC 降压稳压器' },
|
||||||
|
{ value: 'OLD-001', label: 'OLD-001', group: '' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// 候选读不到 → 仍是 combo,但不摆联动组(前端没有可收窄的东西)
|
||||||
|
const empty = await launchParamsOf(LINKED, noResolve);
|
||||||
|
expect(empty[0]?.linkGroup).toBeUndefined();
|
||||||
|
expect(empty[0]?.type).toBe('combo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('服务端不据分组立墙:两格填不同类型的料号照样收(类型一致归活动的收步判据管)', async () => {
|
||||||
|
const params = await launchParamsOf(LINKED, async () => [
|
||||||
|
{ value: 'LM2596S-5.0', label: 'LM2596S-5.0', group: '1' },
|
||||||
|
{ value: 'AMS1117-3.3', label: 'AMS1117-3.3', group: '2' },
|
||||||
|
]);
|
||||||
|
const out = validateLaunchParams(params, { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'AMS1117-3.3' }, fieldSpecsOf(LINKED));
|
||||||
|
expect(out).toEqual({ ok: true, data: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'AMS1117-3.3' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 互斥组(2026-08-31 用户定:「不应该能选到同样的两个芯片」)。
|
||||||
|
* 与 `x-link` 正相反 —— **这一条是墙**:W05 两格同一颗料会把单子做死(两行撞同一个主键收成一行,
|
||||||
|
* S1 的 `exists_status_found = 2` 永不成立,而件上那两行人加不出来),而唯一能改的时机就是起单这一刻。
|
||||||
|
*/
|
||||||
|
describe('x-distinct', () => {
|
||||||
|
const DISTINCT = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['originalInternalPn', 'candidateInternalPn'],
|
||||||
|
properties: {
|
||||||
|
originalInternalPn: { type: 'string', title: '原内部料号', 'x-control': 'combo', 'x-options': { ledger: 'parts.profile', value: 'internal_pn' }, 'x-distinct': 'part', 'x-order': 1 },
|
||||||
|
candidateInternalPn: { type: 'string', title: '候选内部料号', 'x-control': 'combo', 'x-options': { ledger: 'parts.profile', value: 'internal_pn' }, 'x-distinct': 'part', 'x-order': 2 },
|
||||||
|
/* 同一张表上不在这一组的格子:填成一样也与它无关 */
|
||||||
|
note: { type: 'string', title: '备注' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const paramsOf = async () => launchParamsOf(DISTINCT, async () => [{ value: 'LM2596S-5.0', label: 'LM2596S-5.0' }, { value: 'XL2596S-5.0', label: 'XL2596S-5.0' }]);
|
||||||
|
|
||||||
|
it('读得到组名;**不以有候选为前提** —— 自由输入的两格同样归它管(墙管的是值,不是候选)', async () => {
|
||||||
|
expect(fieldSpecsOf(DISTINCT).map((s) => s.distinctGroup)).toEqual(['part', 'part', undefined]);
|
||||||
|
const bare = await launchParamsOf({ properties: { a: { type: 'string', 'x-distinct': 'g' }, b: { type: 'string', 'x-distinct': '' } } }, noResolve);
|
||||||
|
expect(bare.map((p) => [p.type, p.distinctGroup])).toEqual([
|
||||||
|
['text', 'g'],
|
||||||
|
['text', undefined],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('两格同一个值 → 拒,点名**后填的那格**,话里指向它撞上了谁', async () => {
|
||||||
|
const out = validateLaunchParams(await paramsOf(), { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'LM2596S-5.0' }, fieldSpecsOf(DISTINCT));
|
||||||
|
expect(out).toEqual({ ok: false, issues: [{ key: 'candidateInternalPn', message: '与「原内部料号」填的是同一个 —— 这两格不能一样,换一个再启动' }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('**去空白 + 不分大小写**:只挡精确相等的话,`lm2596s-5.0` 会照样进去,混一颗垃圾主数据出来', async () => {
|
||||||
|
const out = validateLaunchParams(await paramsOf(), { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: ' lm2596s-5.0 ' }, fieldSpecsOf(DISTINCT));
|
||||||
|
expect(out.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('两颗不同的料照收;不在这一组的格子填成一样也不管', async () => {
|
||||||
|
const out = validateLaunchParams(
|
||||||
|
await paramsOf(),
|
||||||
|
{ originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'XL2596S-5.0', note: 'LM2596S-5.0' },
|
||||||
|
fieldSpecsOf(DISTINCT),
|
||||||
|
);
|
||||||
|
expect(out).toEqual({ ok: true, data: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'XL2596S-5.0', note: 'LM2596S-5.0' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空的不算撞上 —— 两格都没填该报的是必填,不是「填了同一个」', async () => {
|
||||||
|
const out = validateLaunchParams(await paramsOf(), { originalInternalPn: '', candidateInternalPn: '' }, fieldSpecsOf(DISTINCT));
|
||||||
|
expect(out).toEqual({ ok: false, issues: [{ key: 'originalInternalPn', message: '必填' }, { key: 'candidateInternalPn', message: '必填' }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('数组格不参与(契约写死的边界:本期没有这种形状)', async () => {
|
||||||
|
const arrays = { properties: { a: { type: 'array', items: { type: 'string' }, title: 'A', 'x-distinct': 'g' }, b: { type: 'array', items: { type: 'string' }, title: 'B', 'x-distinct': 'g' } } };
|
||||||
|
const out = validateLaunchParams(await launchParamsOf(arrays, noResolve), { a: ['X'], b: ['X'] }, fieldSpecsOf(arrays));
|
||||||
|
expect(out).toEqual({ ok: true, data: { a: ['X'], b: ['X'] } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -374,6 +374,80 @@ describe('启动', () => {
|
|||||||
expect(table?.params[0]?.options).toEqual([{ value: 'CU-0142', label: 'Itelma' }, { value: 'CU-0001', label: 'CU-0001' }]);
|
expect(table?.params[0]?.options).toEqual([{ value: 'CU-0142', label: 'Itelma' }, { value: 'CU-0001', label: 'CU-0001' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* 联动(2026-08-31 用户定):候选行上多取一列当分组键,再从字典表换人读名交给前端 */
|
||||||
|
const LINKED_MANIFEST = (): ManifestFace =>
|
||||||
|
manifest({
|
||||||
|
contract: {
|
||||||
|
input: {
|
||||||
|
data: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['originalInternalPn', 'candidateInternalPn'],
|
||||||
|
properties: {
|
||||||
|
originalInternalPn: {
|
||||||
|
type: 'string',
|
||||||
|
title: '原内部料号',
|
||||||
|
'x-control': 'combo',
|
||||||
|
'x-options': { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn', group: { column: 'chip_type_id', ledger: 'parts.chip_type', value: 'type_id', label: 'type_name' } },
|
||||||
|
'x-order': 1,
|
||||||
|
'x-link': 'chip-type',
|
||||||
|
},
|
||||||
|
candidateInternalPn: {
|
||||||
|
type: 'string',
|
||||||
|
title: '候选内部料号',
|
||||||
|
'x-control': 'combo',
|
||||||
|
'x-options': { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn', group: { column: 'chip_type_id', ledger: 'parts.chip_type', value: 'type_id', label: 'type_name' } },
|
||||||
|
'x-order': 2,
|
||||||
|
'x-link': 'chip-type',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const PARTS: Record<string, Record<string, unknown>[]> = {
|
||||||
|
'parts.profile': [
|
||||||
|
{ internal_pn: 'AMS1117-3.3', chip_type_id: '2' },
|
||||||
|
{ internal_pn: 'LM2596S-5.0', chip_type_id: '1' },
|
||||||
|
{ internal_pn: 'OLD-001', chip_type_id: null },
|
||||||
|
],
|
||||||
|
'parts.chip_type': [
|
||||||
|
{ type_id: '1', type_name: 'DC-DC 降压稳压器' },
|
||||||
|
{ type_id: '2', type_name: 'LDO 线性稳压器' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const partsLedger = (over: Partial<ActivityOpsDeps['ledger']> = {}): ActivityOpsDeps['ledger'] => ({
|
||||||
|
describe: async (address) => {
|
||||||
|
const rows = PARTS[`${address.domain}.${address.object}`];
|
||||||
|
return rows === undefined ? undefined : { columns: Object.keys(rows[0] ?? {}).map((name) => ({ name })) };
|
||||||
|
},
|
||||||
|
query: async (q) => ({ rows: PARTS[`${q.address.domain}.${q.address.object}`] ?? [] }),
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('候选带分组键 + 字典换来的人读名;那一列没值就是空串(未标类型),联动组名随参数下发', async () => {
|
||||||
|
const { ops } = harness({ manifest: LINKED_MANIFEST(), ledger: partsLedger() });
|
||||||
|
const table = await ops.launchParams(W05);
|
||||||
|
expect(table?.params.map((p) => p.linkGroup)).toEqual(['chip-type', 'chip-type']);
|
||||||
|
expect(table?.params[0]?.options).toEqual([
|
||||||
|
{ value: 'AMS1117-3.3', label: 'AMS1117-3.3', group: '2', groupLabel: 'LDO 线性稳压器' },
|
||||||
|
{ value: 'LM2596S-5.0', label: 'LM2596S-5.0', group: '1', groupLabel: 'DC-DC 降压稳压器' },
|
||||||
|
{ value: 'OLD-001', label: 'OLD-001', group: '' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('分组那一列不存在 / 字典读不到 → 候选照出,只是没有键或没有名(不炸)', async () => {
|
||||||
|
const noColumn = harness({
|
||||||
|
manifest: LINKED_MANIFEST(),
|
||||||
|
ledger: partsLedger({ describe: async (a) => (a.object === 'profile' ? { columns: [{ name: 'internal_pn' }] } : { columns: [{ name: 'type_id' }, { name: 'type_name' }] }) }),
|
||||||
|
});
|
||||||
|
const t1 = await noColumn.ops.launchParams(W05);
|
||||||
|
expect(t1?.params[0]?.options?.every((o) => o.group === undefined)).toBe(true);
|
||||||
|
// 分组键在、字典表没物化 → 有键没名
|
||||||
|
const noDict = harness({ manifest: LINKED_MANIFEST(), ledger: partsLedger({ describe: async (a) => (a.object === 'chip_type' ? undefined : { columns: [{ name: 'internal_pn' }, { name: 'chip_type_id' }] }) }) });
|
||||||
|
const t2 = await noDict.ops.launchParams(W05);
|
||||||
|
expect(t2?.params[0]?.options?.[0]).toEqual({ value: 'AMS1117-3.3', label: 'AMS1117-3.3', group: '2' });
|
||||||
|
});
|
||||||
|
|
||||||
it('禁用 412 / 责任人不在职 400 / 参数不合 400 —— 都不起单', async () => {
|
it('禁用 412 / 责任人不在职 400 / 参数不合 400 —— 都不起单', async () => {
|
||||||
const { ops, started } = harness({ enabled: false });
|
const { ops, started } = harness({ enabled: false });
|
||||||
expect((await ops.launch(W05, launchInput(), ME)).kind).toBe('disabled');
|
expect((await ops.launch(W05, launchInput(), ME)).kind).toBe('disabled');
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { parseCmmn } from '@modules/loop-engine';
|
import { parseCmmn } from '@modules/loop-engine';
|
||||||
import type { ItemRuntime } from '@modules/loop-engine';
|
import type { ItemRuntime } from '@modules/loop-engine';
|
||||||
import { stepsOf } from '../activity-ops/instance-steps.ts';
|
import { followRes, stepsOf } from '../activity-ops/instance-steps.ts';
|
||||||
|
|
||||||
const XML = `<?xml version="1.0" encoding="UTF-8"?>
|
const XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL" id="d">
|
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL" id="d">
|
||||||
@@ -84,3 +84,18 @@ describe('stepsOf', () => {
|
|||||||
expect(steps[1]?.res).toBe('current');
|
expect(steps[1]?.res).toBe('current');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('followRes:第 9 步跟第 8 步(放行那一步没有自己的收步事件)', () => {
|
||||||
|
const step = (seq: number, res: 'done' | 'current' | 'pending' | 'fail') => ({ seq, name: `S${seq}`, by: 'agent' as const, byName: 'llm', at: null, durationMs: null, res, note: '' });
|
||||||
|
it('已放行的单:第 8 步 done → 第 9 步也 done、时刻跟第 8 步;别的字段不动', () => {
|
||||||
|
const steps = [...Array.from({ length: 7 }, (_, i) => step(i + 1, 'done')), { ...step(8, 'done'), at: '2026-08-27T14:30:26.080Z' }, { ...step(9, 'pending'), by: 'human' as const, byName: '柴浩' }];
|
||||||
|
const out = followRes(steps, { seq: 9, from: 8 });
|
||||||
|
expect(out[8]).toEqual({ ...step(9, 'done'), at: '2026-08-27T14:30:26.080Z', by: 'human', byName: '柴浩' });
|
||||||
|
expect(out.slice(0, 8)).toEqual(steps.slice(0, 8));
|
||||||
|
});
|
||||||
|
it('第 8 步 fail / pending 时第 9 步照样跟;步数不够 9 的活动什么都不动', () => {
|
||||||
|
expect(followRes([step(1, 'done'), ...Array.from({ length: 7 }, (_, i) => step(i + 2, 'pending')), step(9, 'pending')].map((s) => (s.seq === 8 ? { ...s, res: 'fail' as const } : s)), { seq: 9, from: 8 })[8]?.res).toBe('fail');
|
||||||
|
const seven = Array.from({ length: 7 }, (_, i) => step(i + 1, 'done'));
|
||||||
|
expect(followRes(seven, { seq: 9, from: 8 })).toEqual(seven);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
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<Record<string, unknown>> = [];
|
||||||
|
const gateway = createAnalyzeGateway({
|
||||||
|
generate: (c: Record<string, unknown>) => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **两次调用的用量都要交回去记账**(2026-08-29 终审 C1)。
|
||||||
|
* `createModelGateway` 自己不做 `addConsumption` —— 不给 `onUsage`,配方里的 analyze
|
||||||
|
* 就是一条问模型不花钱的路,活动预算对它形同虚设。重问那一次照样烧真钱,一并记。
|
||||||
|
*/
|
||||||
|
it('用量交回 onUsage:第一次成的记一次,重问了记两次', async () => {
|
||||||
|
const billed: Array<{ taskId: string; tokens: number }> = [];
|
||||||
|
const once = createAnalyzeGateway(
|
||||||
|
{
|
||||||
|
generate: () =>
|
||||||
|
Promise.resolve({ text: '', object: { items: [] }, usage: { inputTokens: 30, outputTokens: 12, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' }),
|
||||||
|
} as never,
|
||||||
|
(taskId, tokens) => { billed.push({ taskId, tokens }); return Promise.resolve(); },
|
||||||
|
);
|
||||||
|
await once.run(call);
|
||||||
|
expect(billed).toEqual([{ taskId: TASK, tokens: 42 }]);
|
||||||
|
|
||||||
|
billed.length = 0;
|
||||||
|
let n = 0;
|
||||||
|
const twice = createAnalyzeGateway(
|
||||||
|
{
|
||||||
|
generate: () => {
|
||||||
|
n += 1;
|
||||||
|
return Promise.resolve(
|
||||||
|
n === 1
|
||||||
|
? { text: 'not json', usage: { inputTokens: 5, outputTokens: 5, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' }
|
||||||
|
: { text: '', object: { items: [] }, usage: { inputTokens: 7, outputTokens: 3, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
(taskId, tokens) => { billed.push({ taskId, tokens }); return Promise.resolve(); },
|
||||||
|
);
|
||||||
|
await twice.run(call);
|
||||||
|
expect(billed.map((b) => b.tokens)).toEqual([10, 10]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('两次都不成形也照样记两笔 —— 失败的调用不是免费的', async () => {
|
||||||
|
const billed: number[] = [];
|
||||||
|
const gateway = createAnalyzeGateway(
|
||||||
|
{
|
||||||
|
generate: () =>
|
||||||
|
Promise.resolve({ text: 'nope', usage: { inputTokens: 4, outputTokens: 6, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' }),
|
||||||
|
} as never,
|
||||||
|
(_taskId, tokens) => { billed.push(tokens); return Promise.resolve(); },
|
||||||
|
);
|
||||||
|
await expect(gateway.run(call)).rejects.toThrow(/出参不成形/);
|
||||||
|
expect(billed).toEqual([10, 10]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* **确认闸的条件档要在真发卡那条路上转交**(2026-09-01 用户实测:「两个芯片类型不一样,
|
||||||
|
* 页面上方有红色提示,右边派发的卡片还可以点击下一步」)。
|
||||||
|
*
|
||||||
|
* 病:发卡装配那段手写清单逐项点名转交 `gateLedgers`/`variant`,而 `gateWhen`/`gateWhenSay`
|
||||||
|
* 没被点名 —— 配方白纸黑字声明了「类型不一致不许确认」,落库的工单载荷里那两个字段是
|
||||||
|
* undefined(任务 43 法证)。红带是视图画的所以照亮;按钮禁用与服务端拒收读的都是
|
||||||
|
* 存下的 `order.gateWhen`,一起无声失效。**手写字段清单第四次丢值**(fields / blocks /
|
||||||
|
* redoable 之后),而且丢在 `variant` 上次被丢的同一段。
|
||||||
|
*
|
||||||
|
* 这个缝取的是模型也会路过的不可信卡骨架,逐项校验转交是设计 —— 所以治法不是整包 spread,
|
||||||
|
* 是把这两项的取用抽成纯函数、在 variant 的测试位旁边钉死。
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { cardConditionGate } from '../wiring/engines.ts';
|
||||||
|
|
||||||
|
describe('cardConditionGate —— 条件档从卡骨架到工单载荷', () => {
|
||||||
|
it('声明了就转交,一个字不改', () => {
|
||||||
|
expect(cardConditionGate({ gateWhen: 'parts_profile_distinct_chip_type_id <= 1', gateWhenSay: '两颗料不是同一个芯片类型' })).toEqual({
|
||||||
|
gateWhen: 'parts_profile_distinct_chip_type_id <= 1',
|
||||||
|
gateWhenSay: '两颗料不是同一个芯片类型',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('没声明就什么都不带 —— 不发明键', () => {
|
||||||
|
expect(cardConditionGate({})).toEqual({});
|
||||||
|
expect(cardConditionGate({ gateLedgers: ['parts.profile'] })).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('脏值不端下去:非字符串 / 空白串一律丢', () => {
|
||||||
|
expect(cardConditionGate({ gateWhen: 42, gateWhenSay: '话' })).toEqual({});
|
||||||
|
expect(cardConditionGate({ gateWhen: ' ', gateWhenSay: '话' })).toEqual({});
|
||||||
|
expect(cardConditionGate({ gateWhen: null })).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('只有那句话没有条件 = 什么都不带(say 单独存在没有意义,评估端只认 when)', () => {
|
||||||
|
expect(cardConditionGate({ gateWhenSay: '两颗料不是同一个芯片类型' })).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('条件在、话没写:条件照转交,拒回用兜底话术', () => {
|
||||||
|
expect(cardConditionGate({ gateWhen: 'a > 1' })).toEqual({ gateWhen: 'a > 1' });
|
||||||
|
expect(cardConditionGate({ gateWhen: 'a > 1', gateWhenSay: '' })).toEqual({ gateWhen: 'a > 1' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* **决策卡的面孔与人工验收清单,兜底在装配层这一处**(2026-08-29 终审 C2)。
|
||||||
|
*
|
||||||
|
* 病:配方(`auto-runner`)发审核卡时只递了 `variant`,没递 `checklist` ——
|
||||||
|
* 而装配层从前只给 `variant` 兜底。于是那张卡穿着验收工单的衣服、`allowCustom` 也对,
|
||||||
|
* **一条待确认都没有**:回传门的 `checklistMissing` 在 checklist 缺席时恒过,
|
||||||
|
* 「确认通过」当场可按,2026-08-27 用户当面定的那道人工验收闸凭空没了。
|
||||||
|
* 屏上还看不出来 —— 清单区是空的,不是红的。
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { checkChatCardReply } from '@modules/human-tool';
|
||||||
|
import { cardIdSchema } from '@contracts';
|
||||||
|
import type { ChatCard, ChatCardReply } from '@contracts';
|
||||||
|
import { decisionCardFace, needsStepLookup } from '../wiring/engines.ts';
|
||||||
|
|
||||||
|
const RELEASE_STEP = {
|
||||||
|
cardVariant: 'review' as const,
|
||||||
|
acceptanceChecklist: ['逐条核对差异明细', '确认推荐等级与依据一致'],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('决策卡的面孔与清单(装配层兜底)', () => {
|
||||||
|
it('调用方递了清单就用它的 —— 引擎按剧本定好递过来,兜底不抢', () => {
|
||||||
|
const face = decisionCardFace({ variant: 'review', checklist: ['我说了算'] }, RELEASE_STEP);
|
||||||
|
expect(face.checklist).toEqual(['我说了算']);
|
||||||
|
expect(face.reviewFace).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('**配方那一路只递了 variant:清单退回剧本声明的**(C2 的正主)', () => {
|
||||||
|
const face = decisionCardFace({ variant: 'review' }, RELEASE_STEP);
|
||||||
|
expect(face.reviewFace).toBe(true);
|
||||||
|
expect(face.checklist).toEqual(RELEASE_STEP.acceptanceChecklist);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('面孔也照旧退回剧本(2026-08-27 那条不许回退)', () => {
|
||||||
|
const face = decisionCardFace({}, RELEASE_STEP);
|
||||||
|
expect(face.reviewFace).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('剧本没声明清单:就是没有 —— 不发明一条', () => {
|
||||||
|
const face = decisionCardFace({ variant: 'review' }, { cardVariant: 'review' });
|
||||||
|
expect(face.checklist).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空清单当没给:退回剧本(空数组在回传门那儿等于没有闸)', () => {
|
||||||
|
const face = decisionCardFace({ variant: 'review', checklist: [] }, RELEASE_STEP);
|
||||||
|
expect(face.checklist).toEqual(RELEASE_STEP.acceptanceChecklist);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **清单兜底得看 `reviewFace`**(2026-08-29 复审 I2 二审)。
|
||||||
|
*
|
||||||
|
* `isReleaseStep`(turn-runner.ts)认的形状是:一步声明了 `acceptanceChecklist`(给终局
|
||||||
|
* `AcceptForm` 用)而 `cardVariant` 不是 `review`(T_release 本身就是这种形状)。配方若在
|
||||||
|
* **这一步**发一张普通 `as:'decide'` 卡(没传 variant),从前这里照样把清单兜上去 ——
|
||||||
|
* 前端只在 `variant === 'review'` 时才渲染清单,人看不到勾可打,回传门却要求
|
||||||
|
* `checked ⊇ checklist`:卡按不下去。
|
||||||
|
*/
|
||||||
|
const NON_REVIEW_STEP_WITH_CHECKLIST = { acceptanceChecklist: ['逐条核对差异明细'] };
|
||||||
|
|
||||||
|
it('**非审核面孔的步声明了清单(T_release 那种形状),配方发普通决策卡:清单不兜上去**(I2 的二审正主)', () => {
|
||||||
|
const face = decisionCardFace({}, NON_REVIEW_STEP_WITH_CHECKLIST);
|
||||||
|
expect(face.reviewFace).toBe(false);
|
||||||
|
expect(face.checklist).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('同一步:调用方明确递了 variant:review,清单才兜上去 —— reviewFace 是唯一的开关', () => {
|
||||||
|
const face = decisionCardFace({ variant: 'review' }, NON_REVIEW_STEP_WITH_CHECKLIST);
|
||||||
|
expect(face.reviewFace).toBe(true);
|
||||||
|
expect(face.checklist).toEqual(NON_REVIEW_STEP_WITH_CHECKLIST.acceptanceChecklist);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('查不查剧本:没 itemId 查不了;两样都递全了不必查', () => {
|
||||||
|
expect(needsStepLookup({})).toBe(false);
|
||||||
|
expect(needsStepLookup({ itemId: 'T_release' })).toBe(true);
|
||||||
|
expect(needsStepLookup({ itemId: 'T_release', variant: 'review' })).toBe(true);
|
||||||
|
expect(needsStepLookup({ itemId: 'T_release', variant: 'review', checklist: ['a'] })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **闸真的因此有牙** —— 上面挑出来的清单接到回传门上,「确认通过」才拦得住。
|
||||||
|
* 没有这一条,`decisionCardFace` 的断言只证明「函数回了个数组」。
|
||||||
|
*/
|
||||||
|
describe('清单缺席 = 闸恒过(这就是 C2 的后果)', () => {
|
||||||
|
const CARD_ID = cardIdSchema.parse('11111111-1111-4111-8111-111111111111');
|
||||||
|
const card = (checklist?: ReadonlyArray<string>): ChatCard => ({
|
||||||
|
k: 'decision',
|
||||||
|
id: CARD_ID,
|
||||||
|
at: '2026-08-29T00:00:00.000Z',
|
||||||
|
ask: '差异评估结论审核',
|
||||||
|
basis: [],
|
||||||
|
variant: 'review',
|
||||||
|
allowCustom: false,
|
||||||
|
options: [{ value: 'approve', label: '确认通过' }],
|
||||||
|
...(checklist === undefined ? {} : { checklist: [...checklist] }),
|
||||||
|
});
|
||||||
|
const reply: ChatCardReply = { k: 'decision', cardId: CARD_ID, value: 'approve' };
|
||||||
|
|
||||||
|
it('没有清单:一条都没点也通得过 —— 这正是配方发的那张卡从前的样子', () => {
|
||||||
|
expect(checkChatCardReply(card(), reply)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('清单兜底进来之后:一条没点就拦', () => {
|
||||||
|
const face = decisionCardFace({ variant: 'review' }, RELEASE_STEP);
|
||||||
|
const violations = checkChatCardReply(card(face.checklist), reply);
|
||||||
|
expect(violations.map((v) => v.code)).toEqual(['checklist-incomplete']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('清单点全了才放行', () => {
|
||||||
|
const face = decisionCardFace({ variant: 'review' }, RELEASE_STEP);
|
||||||
|
const violations = checkChatCardReply(card(face.checklist), { ...reply, checked: [...RELEASE_STEP.acceptanceChecklist] });
|
||||||
|
expect(violations).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* **执行工单的面孔在装配层这一处定**(2026-08-31,用户实测:「你怎么给我发成了执行工单,
|
||||||
|
* 我需要的是验收工单」)。
|
||||||
|
*
|
||||||
|
* 病:配方递下来 `variant: 'accept'`(验收工单 —— 件只读、人只确认),而装配层这一处
|
||||||
|
* 是一句写死只认 `'data'` 的三目,别的值**静默丢掉** —— 对话区照旧摆「执行工单」。
|
||||||
|
*
|
||||||
|
* 更值得记的是**两头都有测,唯独中间没有**:配方那一头断言了「variant 原样递出去」,
|
||||||
|
* 前端那一头断言了「accept 该穿哪副面孔」,e2e 按卡的**标题**驱动也照样绿 ——
|
||||||
|
* 三处都对,而它们中间这一段把值扔了。**面孔要在真发卡那条路上断言。**
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { executionCardVariant } from '../wiring/engines.ts';
|
||||||
|
|
||||||
|
describe('执行工单的两副变体面孔', () => {
|
||||||
|
it('data 数据工单 / accept 验收工单,两个都认', () => {
|
||||||
|
expect(executionCardVariant('data', undefined)).toBe('data');
|
||||||
|
expect(executionCardVariant('accept', undefined)).toBe('accept');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('剧本声明优先于卡骨架 —— 模型抄卡会把 variant 抄丢(2026-08-27 实测)', () => {
|
||||||
|
expect(executionCardVariant(undefined, 'data')).toBe('data');
|
||||||
|
expect(executionCardVariant('data', 'accept')).toBe('accept');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('`review` 是决策卡那一路的面孔,不许串到执行工单上', () => {
|
||||||
|
expect(executionCardVariant('review', undefined)).toBeUndefined();
|
||||||
|
expect(executionCardVariant(undefined, 'review')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('没写 / 认不出的值:普通执行工单,不把脏东西端下去', () => {
|
||||||
|
expect(executionCardVariant(undefined, undefined)).toBeUndefined();
|
||||||
|
expect(executionCardVariant('', undefined)).toBeUndefined();
|
||||||
|
expect(executionCardVariant({ variant: 'accept' }, undefined)).toBeUndefined();
|
||||||
|
expect(executionCardVariant(42, undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,307 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* ACT-WORK-05 差异评估的两枚纯工具 —— 「能算的不问 LLM」:同输入必同结论。
|
* 【ACT-WORK-05 两枚工具的墓志铭】`part-substitution-assessment.compare-parameters`
|
||||||
* 用例逐条对应《ACT-WORK-05-活动流程与工单说明》§八的示例矩阵:
|
* (逐参数四态判定)与 `part-substitution-assessment.recommend`(推荐等级定级)曾经在
|
||||||
* A XL2596S-5.0 条件推荐(使能阈值未披露 → UNKNOWN)/ B LM2596S-ADJ 备选(可调版靠外围配成 5V)/
|
* `../tools/first-party.ts` 里跑,这份文件曾逐条钉死它们的比对语义(硬/软约束、范围
|
||||||
* C 国产 DCDC 不推荐(输入上限 32V < 客户 40V,硬约束 MISMATCH)。
|
* 解析、客户约束边界、四档推荐等级……)。
|
||||||
|
*
|
||||||
|
* ADR-014(确定性步骤)落地后,这两步不再需要工具调用:四态判定改成
|
||||||
|
* `activities/part-substitution-assessment` 的 `case-model.ts` 里 `S2_assess.auto`
|
||||||
|
* 唯一一次 `analyze`(系统组装入参、模型只答判断),推荐等级改成同一步 `do` 链里的
|
||||||
|
* 投影按 spec §3.5 的 S-FEEL when 链现算 —— 规则只许有一处真源,这两枚工具与它们
|
||||||
|
* 对应的 D01/D02 两张 DMN 已经同批退役(见 `first-party.ts` 里留的墓志铭)。
|
||||||
|
*
|
||||||
|
* 这份文件不删(§12.5「不删测试」的铁律拦的是「删测试让门禁变绿」),但逐条比对
|
||||||
|
* 用例测的是已经不存在的代码,只能跟着它测的东西一起退场,缩成一条退役断言 ——
|
||||||
|
* 拦住的是「哪天有人手滑又把这两枚工具注册回去」。完整的历史用例见 git 历史。
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { FIRST_PARTY_TOOLS, compareParametersWith, describeParsed, parseParamValue } from '../tools/first-party.ts';
|
import { FIRST_PARTY_TOOLS } from '../tools/first-party.ts';
|
||||||
import type { ParameterLedger } from '../tools/first-party.ts';
|
|
||||||
import type { TaskId } from '@contracts';
|
|
||||||
|
|
||||||
const handler = (name: string) => {
|
describe('ACT-WORK-05 两枚工具已退役', () => {
|
||||||
const found = FIRST_PARTY_TOOLS.find(([n]) => n === name);
|
it('不在注册表里 —— 规则活在 S2 的配方里,不再有第二处真源', () => {
|
||||||
if (found === undefined) throw new Error(`没有工具 ${name}`);
|
const names = FIRST_PARTY_TOOLS.map(([n]) => n);
|
||||||
return found[1];
|
expect(names).not.toContain('part-substitution-assessment.compare-parameters');
|
||||||
};
|
expect(names).not.toContain('part-substitution-assessment.recommend');
|
||||||
/** ToolHandler 的入参形状见 `apps/api/src/modules/toolbox/internal/adapter/toolbox-store.ts`:纯工具不用连接目标 */
|
|
||||||
const call = async (name: string, input: Record<string, unknown>) => {
|
|
||||||
const out = await handler(name)({ input, connection: {}, operation: undefined, idempotencyKey: undefined });
|
|
||||||
if (!out.ok) throw new Error(`工具失败:${JSON.stringify(out)}`);
|
|
||||||
return out.output as Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Item = { parameterName: string; compareResult: string; differenceStatus: string; riskLevel: string };
|
|
||||||
type Result = { candidateInternalPn: string; items: Item[]; counts: Record<string, number> };
|
|
||||||
|
|
||||||
const pair = (parameterName: string, originalValue: string, candidateValue: string, constraintType: 'hard' | 'soft', extra: Record<string, string> = {}) => ({
|
|
||||||
parameterName,
|
|
||||||
originalValue,
|
|
||||||
candidateValue,
|
|
||||||
constraintType,
|
|
||||||
evidenceRef: 'P-08 · P-10 Rev H',
|
|
||||||
...extra,
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('边界上的 null:模型给 null 不该把工具打崩', () => {
|
|
||||||
it('**requirement / evidenceRef 给 null 照常算**(2026-08-27 W05 e2e 实测:模型写 `requirement: null` 而不是省掉,`null.trim()` 当场崩,整次调用记成「行动被拒」)', async () => {
|
|
||||||
const out = await call('part-substitution-assessment.compare-parameters', {
|
|
||||||
candidates: [
|
|
||||||
{
|
|
||||||
candidateInternalPn: 'LM2596S-ADJ',
|
|
||||||
pairs: [
|
|
||||||
{ parameterName: '输出电压', originalValue: '5.0', candidateValue: '1.23~37', constraintType: 'hard', unit: 'V', requirement: null, evidenceRef: null },
|
|
||||||
{ parameterName: '输入电压范围', originalValue: '4.5~40', candidateValue: '4.5~40', constraintType: 'hard', unit: 'V', requirement: '>= 40', evidenceRef: 'P-10 Rev H' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const items = (out as { results: Array<{ items: Array<{ parameterName: string; differenceStatus: string }> }> }).results[0]!.items;
|
|
||||||
expect(items.find((i) => i.parameterName === '输出电压')?.differenceStatus).toBe('conditional');
|
|
||||||
expect(items.find((i) => i.parameterName === '输入电压范围')?.differenceStatus).toBe('match');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('硬约束的 evidenceRef 给 null = 没给证据 → 不判 MATCH,退成 CONDITIONAL', async () => {
|
|
||||||
const out = await call('part-substitution-assessment.compare-parameters', {
|
|
||||||
candidates: [
|
|
||||||
{
|
|
||||||
candidateInternalPn: 'X',
|
|
||||||
pairs: [{ parameterName: '输出电压', originalValue: '5.0', candidateValue: '5.0', constraintType: 'hard', evidenceRef: null }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect((out as { results: Array<{ items: Array<{ differenceStatus: string }> }> }).results[0]!.items[0]!.differenceStatus).toBe('conditional');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('compare-parameters:逐参数四态', () => {
|
|
||||||
it('候选温域更宽 → covers → MATCH 低风险;固定 vs 可调 → configurable → CONDITIONAL;未披露 → UNKNOWN', async () => {
|
|
||||||
const out = await call('part-substitution-assessment.compare-parameters', {
|
|
||||||
candidates: [
|
|
||||||
{
|
|
||||||
candidateInternalPn: 'XL2596S-5.0',
|
|
||||||
pairs: [
|
|
||||||
pair('工作温度', '-40~+85', '-40~+125', 'hard', { unit: '℃' }),
|
|
||||||
pair('输出电压', '5.0', '5.0', 'hard', { unit: 'V' }),
|
|
||||||
pair('使能阈值', '1.4', '未披露', 'hard', { unit: 'V' }),
|
|
||||||
pair('静态电流', '5', '4.2', 'soft', { unit: 'mA' }),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
candidateInternalPn: 'LM2596S-ADJ',
|
|
||||||
pairs: [pair('输出电压', '5.0', '1.23~37', 'hard', { unit: 'V' })],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const results = out['results'] as Result[];
|
|
||||||
const a = results[0]!;
|
|
||||||
const b = results[1]!;
|
|
||||||
expect(out['ruleVersion']).toBe('R-2026.07');
|
|
||||||
const by = (r: Result, name: string) => r.items.find((i) => i.parameterName === name);
|
|
||||||
expect(by(a, '工作温度')).toMatchObject({ compareResult: 'covers', differenceStatus: 'match', riskLevel: 'low' });
|
|
||||||
expect(by(a, '输出电压')).toMatchObject({ compareResult: 'equal', differenceStatus: 'match' });
|
|
||||||
expect(by(a, '使能阈值')).toMatchObject({ compareResult: 'unknown', differenceStatus: 'unknown', riskLevel: 'medium' });
|
|
||||||
expect(by(a, '静态电流')).toMatchObject({ differenceStatus: 'conditional' }); // 软约束数值不同 → 条件
|
|
||||||
expect(a.counts).toMatchObject({ match: 2, unknown: 1, conditional: 1, mismatch: 0, hardMismatch: 0 });
|
|
||||||
expect(by(b, '输出电压')).toMatchObject({ compareResult: 'configurable', differenceStatus: 'conditional' });
|
|
||||||
expect(b.counts['configurable']).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('硬约束范围变窄 → MISMATCH 高风险;客户约束(requirement)不满足也是 MISMATCH', async () => {
|
|
||||||
const out = await call('part-substitution-assessment.compare-parameters', {
|
|
||||||
candidates: [
|
|
||||||
{
|
|
||||||
candidateInternalPn: 'CN-DCDC-32V',
|
|
||||||
pairs: [
|
|
||||||
pair('输入电压范围', '4.5~40', '4.5~32', 'hard', { unit: 'V', requirement: '>= 40' }),
|
|
||||||
pair('封装', 'TO-263-5', 'TO-263-5', 'hard'),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const c = (out['results'] as Result[])[0]!;
|
|
||||||
expect(c.items[0]).toMatchObject({ compareResult: 'narrower', differenceStatus: 'mismatch', riskLevel: 'high' });
|
|
||||||
expect(c.items[1]).toMatchObject({ compareResult: 'equal', differenceStatus: 'match' });
|
|
||||||
expect(c.counts).toMatchObject({ mismatch: 1, hardMismatch: 1, match: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 2026-08-27 晚,用户送来 0bddd862:客户写「输入电压在 45V 以内」→ requirement ">= 45",而原 PN 与候选的输入
|
|
||||||
* 上限都是 40V。从前客户约束一票否决:两颗一模一样却判硬约束 MISMATCH、整颗不推荐。差异评估比的是
|
|
||||||
* 「换上去会不会比现在差」—— 原 PN 自己也过不了的客户线,是需求边界的事,不是替代差异。
|
|
||||||
*/
|
|
||||||
it('候选与原 PN 相等、客户约束两颗都不满足 → 仍是 MATCH(风险中、备注点名需求边界),不记在候选头上', async () => {
|
|
||||||
const out = await call('part-substitution-assessment.compare-parameters', {
|
|
||||||
candidates: [
|
|
||||||
{
|
|
||||||
candidateInternalPn: 'LM2596S-ADJ',
|
|
||||||
pairs: [
|
|
||||||
pair('输入电压范围', '4.5~40', '4.5~40', 'hard', { unit: 'V', requirement: '>= 45' }),
|
|
||||||
// 候选盖住原范围、客户线两颗都过不去 —— 同理
|
|
||||||
pair('工作温度', '-40~85', '-40~125', 'hard', { unit: '℃', requirement: '-55~125' }),
|
|
||||||
// 候选比原 PN 窄且过不了客户线 —— 仍是 MISMATCH
|
|
||||||
pair('输出电流', '3', '2', 'hard', { unit: 'A', requirement: '>= 3' }),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const c = (out['results'] as Result[])[0]!;
|
|
||||||
expect(c.items[0]).toMatchObject({ compareResult: 'equal', differenceStatus: 'match', riskLevel: 'medium' });
|
|
||||||
expect((c.items[0] as Item & { note: string }).note).toMatch(/需求边界/);
|
|
||||||
expect(c.items[1]).toMatchObject({ compareResult: 'covers', differenceStatus: 'match' });
|
|
||||||
expect(c.items[2]).toMatchObject({ compareResult: 'differs', differenceStatus: 'mismatch', riskLevel: 'high' });
|
|
||||||
expect(c.counts).toMatchObject({ match: 2, mismatch: 1, hardMismatch: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('硬约束相等但没有证据引用 → 不判 MATCH,降为 CONDITIONAL(硬约束项未经证据支持不得判 MATCH)', async () => {
|
|
||||||
const out = await call('part-substitution-assessment.compare-parameters', {
|
|
||||||
candidates: [{ candidateInternalPn: 'X', pairs: [{ parameterName: '封装', originalValue: 'TO-263-5', candidateValue: 'TO-263-5', constraintType: 'hard' }] }],
|
|
||||||
});
|
|
||||||
const x = (out['results'] as Result[])[0]!;
|
|
||||||
expect(x.items[0]).toMatchObject({ compareResult: 'equal', differenceStatus: 'conditional' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('数值带单位/括号注记也认得:「5.0(固定)」「3A」「150 kHz」;生命周期按活跃度比', async () => {
|
|
||||||
const out = await call('part-substitution-assessment.compare-parameters', {
|
|
||||||
candidates: [{ candidateInternalPn: 'X', pairs: [pair('输出电压', '5.0(固定)', '5.0 V', 'hard'), pair('输出电流', '3A', '3', 'hard'), pair('开关频率', '150 kHz', '150', 'hard'), pair('生命周期', 'nrnd', 'active', 'soft')] }],
|
|
||||||
});
|
|
||||||
const x = (out['results'] as Result[])[0]!;
|
|
||||||
// 生命周期不是数值:候选比原料更活(nrnd → active)算 covers → MATCH,替 NRND 料本就是为了这个
|
|
||||||
expect(x.items.map((i) => i.differenceStatus)).toEqual(['match', 'match', 'match', 'match']);
|
|
||||||
expect(x.items[3]?.compareResult).toBe('covers');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/*
|
|
||||||
* **比对入参由基座从台账取**(2026-08-27 晚,用户:「要让它认真比对每一条,然后再输出结论」):
|
|
||||||
* 模型只报料号与客户约束;参数 / 封装 / 生命周期 / 证据引用由工具从 parts.* 现读。
|
|
||||||
*/
|
|
||||||
describe('compare-parameters:按料号从台账取参数', () => {
|
|
||||||
const rows: Record<string, Array<Record<string, unknown>>> = {
|
|
||||||
'parts.parameter|LM2596S-5.0': [
|
|
||||||
{ internal_pn: 'LM2596S-5.0', param_name: '输入电压范围', param_value: '4.5~40', param_unit: 'V', constraint_type: 'hard' },
|
|
||||||
{ internal_pn: 'LM2596S-5.0', param_name: '输出电压', param_value: '5.0', param_unit: 'V', constraint_type: 'hard' },
|
|
||||||
{ internal_pn: 'LM2596S-5.0', param_name: '使能阈值', param_value: '1.4', param_unit: 'V', constraint_type: 'hard' },
|
|
||||||
],
|
|
||||||
'parts.parameter|LM2596S-ADJ': [
|
|
||||||
{ internal_pn: 'LM2596S-ADJ', param_name: '输入电压范围', param_value: '4.5~40', param_unit: 'V', constraint_type: 'hard' },
|
|
||||||
{ internal_pn: 'LM2596S-ADJ', param_name: '输出电压', param_value: '1.23~37', param_unit: 'V', constraint_type: 'hard' },
|
|
||||||
// 候选多出来的参数不进比对(原 PN 定义比对集),只在 extraCandidateParams 里点名
|
|
||||||
{ internal_pn: 'LM2596S-ADJ', param_name: '反馈电压', param_value: '1.23', param_unit: 'V', constraint_type: 'soft' },
|
|
||||||
],
|
|
||||||
'parts.profile|LM2596S-5.0': [{ internal_pn: 'LM2596S-5.0', package: 'TO-263-5', lifecycle_status: 'nrnd' }],
|
|
||||||
'parts.profile|LM2596S-ADJ': [{ internal_pn: 'LM2596S-ADJ', package: 'TO-263-5', lifecycle_status: 'active' }],
|
|
||||||
'parts.document|LM2596S-ADJ': [{ internal_pn: 'LM2596S-ADJ', document_type: 'datasheet', document_version: 'Rev H (2023-09)', document_ref: 'https://x/lm2596.pdf' }],
|
|
||||||
};
|
|
||||||
const seen: string[] = [];
|
|
||||||
const ledger: ParameterLedger = {
|
|
||||||
rows: async (taskId, address, where) => {
|
|
||||||
seen.push(`${taskId ?? '-'}:${address.domain}.${address.object}|${where['internal_pn']}`);
|
|
||||||
return rows[`${address.domain}.${address.object}|${where['internal_pn']}`] ?? [];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const tool = compareParametersWith(ledger);
|
|
||||||
const run = async (input: Record<string, unknown>, taskId?: TaskId) => {
|
|
||||||
const out = await tool({ input, connection: {}, operation: undefined, idempotencyKey: undefined, taskId });
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
|
|
||||||
it('只给料号 + 客户约束 → 每一对都是台账原值;封装 / 生命周期从档案来;证据引用从资料记录来;读的是**这一单**的任务视图', async () => {
|
|
||||||
const out = await run({ originalInternalPn: 'LM2596S-5.0', candidates: [{ candidateInternalPn: 'LM2596S-ADJ', requirements: { 输入电压范围: '>= 45' } }] }, 't-1' as TaskId);
|
|
||||||
expect(out.ok).toBe(true);
|
|
||||||
const r = (out.output!['results'] as Array<Record<string, unknown>>)[0]!;
|
|
||||||
expect(r['source']).toBe('ledger');
|
|
||||||
const items = r['items'] as Array<Record<string, unknown>>;
|
|
||||||
const byName = Object.fromEntries(items.map((i) => [i['parameterName'] as string, i]));
|
|
||||||
expect(byName['输入电压范围']).toMatchObject({ originalValue: '4.5~40', candidateValue: '4.5~40', unit: 'V', constraintType: 'hard', requirement: '>= 45', evidenceRef: 'P-08 · P-10 Rev H (2023-09)', differenceStatus: 'match' });
|
|
||||||
expect(byName['输出电压']).toMatchObject({ originalValue: '5.0', candidateValue: '1.23~37', compareResult: 'configurable', differenceStatus: 'conditional' });
|
|
||||||
// 原 PN 有、候选没有 → 候选值空 → UNKNOWN
|
|
||||||
expect(byName['使能阈值']).toMatchObject({ originalValue: '1.4', candidateValue: '', differenceStatus: 'unknown' });
|
|
||||||
expect(byName['封装']).toMatchObject({ originalValue: 'TO-263-5', candidateValue: 'TO-263-5', constraintType: 'hard', differenceStatus: 'match' });
|
|
||||||
expect(byName['生命周期']).toMatchObject({ originalValue: 'nrnd', candidateValue: 'active', constraintType: 'soft', compareResult: 'covers', differenceStatus: 'match' });
|
|
||||||
expect(r['extraCandidateParams']).toEqual(['反馈电压']);
|
|
||||||
expect(r['counts']).toMatchObject({ match: 3, conditional: 1, unknown: 1, mismatch: 0 });
|
|
||||||
expect(seen.every((s) => s.startsWith('t-1:'))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('原 PN 台账里没有 → 如实报错,不比一半;没给 originalInternalPn → 拒', async () => {
|
|
||||||
const missing = await run({ originalInternalPn: 'NOPE', candidates: [{ candidateInternalPn: 'LM2596S-ADJ' }] });
|
|
||||||
expect(missing.ok).toBe(false);
|
|
||||||
expect(missing.error?.message).toMatch(/NOPE/);
|
|
||||||
const noOriginal = await run({ candidates: [{ candidateInternalPn: 'LM2596S-ADJ' }] });
|
|
||||||
expect(noOriginal.ok).toBe(false);
|
|
||||||
expect(noOriginal.error?.message).toMatch(/originalInternalPn/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('自带 pairs 的老形状照旧接(试运行 / 老调用方),source 标 pairs', async () => {
|
|
||||||
const out = await run({ candidates: [{ candidateInternalPn: 'X', pairs: [pair('输出电压', '5.0', '5.0', 'hard')] }] });
|
|
||||||
expect(out.ok).toBe(true);
|
|
||||||
const r = (out.output!['results'] as Array<Record<string, unknown>>)[0]!;
|
|
||||||
expect(r['source']).toBe('pairs');
|
|
||||||
expect((r['items'] as Array<Record<string, unknown>>)[0]).toMatchObject({ originalValue: '5.0', candidateValue: '5.0', differenceStatus: 'match' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('没有台账的那份(静态名单)遇到不带 pairs 的入参 → 如实说这条通道读不到台账', async () => {
|
|
||||||
const out = await compareParametersWith()({ input: { originalInternalPn: 'A', candidates: [{ candidateInternalPn: 'B' }] }, connection: {}, operation: undefined, idempotencyKey: undefined });
|
|
||||||
expect(out.ok).toBe(false);
|
|
||||||
expect(out.error?.message).toMatch(/读不到台账/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 2026-08-28 用户:「4.5~40 / -40~+85 这种我怕模型搞不清楚,harness 里强调一下是范围值」。
|
|
||||||
* 解析器把各种写法都认成范围,出参 readAs 把「读作什么」摆回去 —— 比对依据可见,模型不必自己再解释一遍值。
|
|
||||||
*/
|
|
||||||
describe('值的读法:「下限~上限」一律是范围', () => {
|
|
||||||
it('分隔符 / 正负号 / 单位写进值里 / Unicode 减号 / 省略号 / 中文「至到」都认成范围;单边界读成半开范围;定值一个数', () => {
|
|
||||||
const range = (v: string) => parseParamValue(v);
|
|
||||||
for (const v of ['4.5~40', '4.5~40', '4.5-40', '4.5 ~ 40', '4.5V~40V', '4.5V ~ 40V (典型)', '4.5…40', '4.5...40', '4.5至40', '4.5到40', '4.5 to 40']) {
|
|
||||||
expect(range(v), v).toEqual({ kind: 'range', lo: 4.5, hi: 40 });
|
|
||||||
}
|
|
||||||
for (const v of ['-40~+85', '-40~85', '−40~+85', '-40℃~+85℃', '−40 ℃ … +85 ℃', '-40 - 85', '-40 to +85']) {
|
|
||||||
expect(range(v), v).toEqual({ kind: 'range', lo: -40, hi: 85 });
|
|
||||||
}
|
|
||||||
expect(range('≥45')).toEqual({ kind: 'range', lo: 45, hi: Number.POSITIVE_INFINITY });
|
|
||||||
expect(range('45 以上')).toEqual({ kind: 'range', lo: 45, hi: Number.POSITIVE_INFINITY });
|
|
||||||
expect(range('<= 40')).toEqual({ kind: 'range', lo: Number.NEGATIVE_INFINITY, hi: 40 });
|
|
||||||
expect(range('40V 以内')).toEqual({ kind: 'range', lo: Number.NEGATIVE_INFINITY, hi: 40 });
|
|
||||||
expect(range('40')).toEqual({ kind: 'number', value: 40 });
|
|
||||||
expect(range('5.0(固定)')).toEqual({ kind: 'number', value: 5 });
|
|
||||||
expect(range('150 kHz')).toEqual({ kind: 'number', value: 150 });
|
|
||||||
expect(range('TO-263-5')).toEqual({ kind: 'text', value: 'to-263-5' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('readAs 说人话:范围 / 定值 / 单边界 / 文本;纯文本(封装)不啰嗦,像范围却没读成的才提示怎么写', () => {
|
|
||||||
expect(describeParsed(parseParamValue('-40~+85'))).toBe('范围 -40~85');
|
|
||||||
expect(describeParsed(parseParamValue('5.0'))).toBe('定值 5');
|
|
||||||
expect(describeParsed(parseParamValue('≥45'))).toBe('下界 ≥ 45');
|
|
||||||
expect(describeParsed(parseParamValue('TO-263-5'))).toBe('文本「to-263-5」');
|
|
||||||
expect(describeParsed(parseParamValue('约 4.5 ~ 40'))).toMatch(/请写成 下限~上限/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('两颗都写 4.5V~40V(单位在值里)→ 读成同一段范围 → equal / MATCH,出参 readAs 摆出「范围 4.5~40」', async () => {
|
|
||||||
const out = await call('part-substitution-assessment.compare-parameters', {
|
|
||||||
candidates: [{ candidateInternalPn: 'X', pairs: [pair('输入电压范围', '4.5V~40V', '4.5 ~ 40 V', 'hard', { unit: 'V' }), pair('工作温度', '-40℃~+85℃', '−40~+85', 'hard', { unit: '℃' })] }],
|
|
||||||
});
|
|
||||||
const items = (out['results'] as Array<{ items: Array<Item & { readAs: { original: string; candidate: string } }> }>)[0]!.items;
|
|
||||||
expect(items[0]).toMatchObject({ compareResult: 'equal', differenceStatus: 'match', readAs: { original: '范围 4.5~40', candidate: '范围 4.5~40' } });
|
|
||||||
expect(items[1]).toMatchObject({ compareResult: 'equal', differenceStatus: 'match', readAs: { original: '范围 -40~85', candidate: '范围 -40~85' } });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('recommend:推荐等级四档', () => {
|
|
||||||
it('硬约束 MISMATCH → 不推荐;有 UNKNOWN → 条件推荐;只有 configurable → 备选;全 MATCH → 推荐', async () => {
|
|
||||||
const out = await call('part-substitution-assessment.recommend', {
|
|
||||||
candidates: [
|
|
||||||
{ candidateInternalPn: 'C', counts: { match: 5, mismatch: 1, unknown: 0, conditional: 0, hardMismatch: 1, softMismatch: 0, configurable: 0 } },
|
|
||||||
{ candidateInternalPn: 'A', counts: { match: 5, mismatch: 0, unknown: 1, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 } },
|
|
||||||
{ candidateInternalPn: 'B', counts: { match: 6, mismatch: 0, unknown: 0, conditional: 1, hardMismatch: 0, softMismatch: 0, configurable: 1 } },
|
|
||||||
{ candidateInternalPn: 'D', counts: { match: 7, mismatch: 0, unknown: 0, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 } },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const results = out['results'] as Array<{ candidateInternalPn: string; recommendationLevel: string; riskLevel: string }>;
|
|
||||||
expect(results.map((r) => [r.candidateInternalPn, r.recommendationLevel, r.riskLevel])).toEqual([
|
|
||||||
['C', 'not_recommended', 'high'],
|
|
||||||
['A', 'conditional', 'medium'],
|
|
||||||
['B', 'alternate', 'medium'],
|
|
||||||
['D', 'recommended', 'low'],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('既有 UNKNOWN 又有 configurable:UNKNOWN 优先 → 条件推荐(验证闭环前不给备选)', async () => {
|
|
||||||
const out = await call('part-substitution-assessment.recommend', {
|
|
||||||
candidates: [{ candidateInternalPn: 'E', counts: { match: 1, mismatch: 0, unknown: 1, conditional: 1, hardMismatch: 0, softMismatch: 0, configurable: 1 } }],
|
|
||||||
});
|
|
||||||
expect((out['results'] as Array<{ recommendationLevel: string }>)[0]?.recommendationLevel).toBe('conditional');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* **打不回的放行卡**(2026-09-01 用户定:「评审放行与落账确认这一步的验收卡不应该有重做的选项,
|
||||||
|
* 因为这里都没有接入 ai」)。
|
||||||
|
*
|
||||||
|
* 判据只有一条:这一步整个由配方跑完、一个字的 `guide` 都没有 —— 那时打回的意见没有模型会去读,
|
||||||
|
* 配方只会原样再提交一次,撞在「打回之后台账没动过就不许再提交」那道闸上,最后落成一张
|
||||||
|
* 「连续撞在同一条判据上」的兜底卡。**一颗按下去只会把单子推进死胡同的钮,不如不摆。**
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { releaseRedoable } from '../wiring/engines.ts';
|
||||||
|
|
||||||
|
describe('放行卡打回得了吗', () => {
|
||||||
|
it('全 auto(有配方、guide 是空串)→ 打不回', () => {
|
||||||
|
expect(releaseRedoable({ auto: { do: [] }, guide: '' })).toBe(false);
|
||||||
|
expect(releaseRedoable({ auto: { do: [] }, guide: ' ' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('半 auto(guide 非空)→ 照旧打得回:那儿真有模型会读那句意见', () => {
|
||||||
|
expect(releaseRedoable({ auto: { do: [] }, guide: '按意见修订台账,改完重新提交' })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('模型驱动(压根没声明配方)→ 照旧打得回', () => {
|
||||||
|
expect(releaseRedoable({ guide: '把这一签下去会发生什么摆清楚' })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('认不出那一步(没声明人工验收清单的老活动)→ 老行为', () => {
|
||||||
|
expect(releaseRedoable(undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { taskIdSchema } from '@contracts';
|
||||||
|
import { afterSave } from '../save-wake.ts';
|
||||||
|
|
||||||
|
const TASK = taskIdSchema.parse('11111111-1111-4111-8111-111111111111');
|
||||||
|
|
||||||
|
/** 缺省有一张卡在等人 —— 「保存即铺格」的场景恰恰都是这一种 */
|
||||||
|
function spy(pending: ReadonlyArray<unknown> = [{ id: 'card-1' }]) {
|
||||||
|
const calls: string[] = [];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
deps: {
|
||||||
|
views: { repush: () => { calls.push('repush'); return Promise.resolve(true); } },
|
||||||
|
task: { wake: () => { calls.push('wake'); return Promise.resolve(true); } },
|
||||||
|
humanTool: { pendingCards: () => { calls.push('pending'); return Promise.resolve(pending); } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('件上保存之后', () => {
|
||||||
|
it('真落下了行、且有卡在等人:先重推件,再叫醒任务 —— 配方这才有机会按最新的草稿铺格', async () => {
|
||||||
|
const { calls, deps } = spy();
|
||||||
|
await afterSave(deps, TASK, 3);
|
||||||
|
expect(calls).toEqual(['repush', 'pending', 'wake']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('一行都没落下:什么也不做 —— 没变的东西不值得叫醒谁', async () => {
|
||||||
|
const { calls, deps } = spy();
|
||||||
|
await afterSave(deps, TASK, 0);
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **没有卡在等人就别叫醒**(2026-08-29 终审 F1):`wake` 对 awaiting-event / awaiting-human
|
||||||
|
* 一律拨成 running,推进器随即推一拍并记一步预算 —— 而现有九个活动包一个 auto 都没有,
|
||||||
|
* 人每按一次保存就白烧一步、界面闪一次「AI 正在推进这单」;任务本来 awaiting-human
|
||||||
|
* (等人裁决)时被叫醒,那一拍结束还会把它降级成 awaiting-event:等的明明还是人。
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
* **这条用例测的是 `pendingCards` 现读为空的那一种局面 —— 不是验收态**(2026-08-29 复审
|
||||||
|
* A 项二审:这里原话写着「验收态就是这一种」,是错的)。`pendingCards`
|
||||||
|
* (`human-tool-service.ts:374`)把**未办结工单**也算在内,而验收态恰恰有一张开着的
|
||||||
|
* 验收工单 —— 真在验收态时 `pendingCards` 不空,`wake` 照样会发,不落进这条分支。
|
||||||
|
*/
|
||||||
|
it('没有卡在等人(pendingCards 现读为空):件照旧重推,**不叫醒**', async () => {
|
||||||
|
const { calls, deps } = spy([]);
|
||||||
|
await afterSave(deps, TASK, 3);
|
||||||
|
expect(calls).toEqual(['repush', 'pending']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('叫醒失败不抬手,但**不再静默** —— 它是「保存即铺格」的唯一触发器', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const said: string[] = [];
|
||||||
|
const write = vi.spyOn(globalThis.process.stderr, 'write').mockImplementation((chunk) => { said.push(String(chunk)); return true; });
|
||||||
|
const deps = {
|
||||||
|
views: { repush: () => { calls.push('repush'); return Promise.resolve(true); } },
|
||||||
|
task: { wake: () => Promise.reject(new Error('库连不上')) },
|
||||||
|
humanTool: { pendingCards: () => Promise.resolve([{ id: 'card-1' }]) },
|
||||||
|
};
|
||||||
|
await expect(afterSave(deps, TASK, 1)).resolves.toBeUndefined();
|
||||||
|
write.mockRestore();
|
||||||
|
expect(calls).toEqual(['repush']);
|
||||||
|
expect(said.join('')).toContain('库连不上');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('问不出有没有卡在等人:也不叫醒,照样说一声', async () => {
|
||||||
|
const said: string[] = [];
|
||||||
|
const write = vi.spyOn(globalThis.process.stderr, 'write').mockImplementation((chunk) => { said.push(String(chunk)); return true; });
|
||||||
|
const calls: string[] = [];
|
||||||
|
const deps = {
|
||||||
|
views: { repush: () => Promise.resolve(true) },
|
||||||
|
task: { wake: () => { calls.push('wake'); return Promise.resolve(true); } },
|
||||||
|
humanTool: { pendingCards: () => Promise.reject(new Error('库连不上')) },
|
||||||
|
};
|
||||||
|
await expect(afterSave(deps, TASK, 1)).resolves.toBeUndefined();
|
||||||
|
write.mockRestore();
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
expect(said.join('')).toContain('库连不上');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,10 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { MASTER_DOMAINS, MASTER_OBJECTS, MASTER_SAMPLE_ROWS } from '../seed-mvp-data.ts';
|
import { MASTER_DOMAINS, MASTER_OBJECTS, MASTER_SAMPLE_ROWS } from '../seed-mvp-data.ts';
|
||||||
|
|
||||||
|
const tableOf = (domain: string, object: string) => MASTER_OBJECTS.find((o) => o.domain === domain && o.object === object);
|
||||||
|
const seedRowsOf = (domain: string, object: string) =>
|
||||||
|
MASTER_SAMPLE_ROWS.filter((r) => r.address.domain === domain && r.address.object === object);
|
||||||
|
|
||||||
describe('主数据四域', () => {
|
describe('主数据四域', () => {
|
||||||
it('八个域:客户 / 项目 / 供应商 / 市场 + 料件 / 需求 / 替代 / 验证(SS02 差异评估 2026-08-26 加后四域)', () => {
|
it('八个域:客户 / 项目 / 供应商 / 市场 + 料件 / 需求 / 替代 / 验证(SS02 差异评估 2026-08-26 加后四域)', () => {
|
||||||
expect([...MASTER_DOMAINS]).toEqual(['customer', 'project', 'supplier', 'market', 'parts', 'demand', 'substitution', 'validation']);
|
expect([...MASTER_DOMAINS]).toEqual(['customer', 'project', 'supplier', 'market', 'parts', 'demand', 'substitution', 'validation']);
|
||||||
@@ -33,7 +37,8 @@ describe('主数据四域', () => {
|
|||||||
|
|
||||||
it('样例料号:原 PN 与同族可调版在库,候选 XL2596S-5.0 刻意不播(数据工单那条路要试得出来)', () => {
|
it('样例料号:原 PN 与同族可调版在库,候选 XL2596S-5.0 刻意不播(数据工单那条路要试得出来)', () => {
|
||||||
const parts = MASTER_SAMPLE_ROWS.filter((r) => r.address.domain === 'parts' && r.address.object === 'profile').map((r) => r.key['internal_pn']);
|
const parts = MASTER_SAMPLE_ROWS.filter((r) => r.address.domain === 'parts' && r.address.object === 'profile').map((r) => r.key['internal_pn']);
|
||||||
expect(parts).toEqual(['LM2596S-5.0', 'LM2596S-ADJ']);
|
// AMS1117-3.3(LDO)是 W05 重写 Task 1 新播的第三颗 —— 「两颗料类型不一致 → 硬拦」那条路的唯一素材
|
||||||
|
expect(parts).toEqual(['LM2596S-5.0', 'LM2596S-ADJ', 'AMS1117-3.3']);
|
||||||
expect(parts).not.toContain('XL2596S-5.0');
|
expect(parts).not.toContain('XL2596S-5.0');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,7 +50,9 @@ describe('主数据四域', () => {
|
|||||||
for (const row of params) {
|
for (const row of params) {
|
||||||
expect(Object.keys(row.key).sort()).toEqual(['internal_pn', 'param_name']);
|
expect(Object.keys(row.key).sort()).toEqual(['internal_pn', 'param_name']);
|
||||||
expect(row.values['param_value'], JSON.stringify(row.key)).toBeTruthy();
|
expect(row.values['param_value'], JSON.stringify(row.key)).toBeTruthy();
|
||||||
expect(row.values['constraint_type']).toMatch(/^(hard|soft)$/);
|
// 约束类型已搬到 parts.type_param 字典上(W05 重写 Task 1)—— 这张子表不再带这一列,
|
||||||
|
// 留着旧断言会重新允许「同一个参数在两颗料上各标一次约束」,正是这次改造要消灭的东西。
|
||||||
|
expect(row.values['constraint_type']).toBeUndefined();
|
||||||
}
|
}
|
||||||
// parts.profile 不再声明这一列的值 —— 两行样例主记录里它应为空(物理列留在基线,不再写)
|
// parts.profile 不再声明这一列的值 —— 两行样例主记录里它应为空(物理列留在基线,不再写)
|
||||||
const profiles = MASTER_SAMPLE_ROWS.filter((r) => r.address.domain === 'parts' && r.address.object === 'profile');
|
const profiles = MASTER_SAMPLE_ROWS.filter((r) => r.address.domain === 'parts' && r.address.object === 'profile');
|
||||||
@@ -98,3 +105,58 @@ describe('主数据四域', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('芯片类型字典(W05 重写)', () => {
|
||||||
|
it('两张新表都物化了,主键形状对', () => {
|
||||||
|
const chipType = tableOf('parts', 'chip_type');
|
||||||
|
expect(chipType?.fields.find((f) => f.name === 'type_id')).toMatchObject({ type: 'integer', primaryKey: true });
|
||||||
|
expect(chipType?.fields.map((f) => f.name)).toEqual(expect.arrayContaining(['type_id', 'type_name', 'description']));
|
||||||
|
|
||||||
|
const typeParam = tableOf('parts', 'type_param');
|
||||||
|
/* 复合主键:一个类型下多个参数名 —— 主键少一半,同一类型的第二个参数就会覆盖第一个 */
|
||||||
|
expect(typeParam?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['type_id', 'param_name']);
|
||||||
|
expect(typeParam?.fields.map((f) => f.name)).toEqual(
|
||||||
|
expect.arrayContaining(['type_id', 'param_name', 'param_desc', 'constraint_type', 'param_unit']),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('P-08 上有 chip_type_id,类型与字典的主键对得上(integer)', () => {
|
||||||
|
expect(tableOf('parts', 'profile')?.fields.find((f) => f.name === 'chip_type_id')).toMatchObject({ type: 'integer' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('种子:两个类型,DC-DC 七个核心参数字段、LDO 五个', () => {
|
||||||
|
const types = seedRowsOf('parts', 'chip_type');
|
||||||
|
expect(types.map((r) => r.values['type_name']).sort()).toEqual(['DC-DC 降压稳压器', 'LDO 线性稳压器']);
|
||||||
|
|
||||||
|
const dcdc = types.find((r) => r.values['type_name'] === 'DC-DC 降压稳压器')?.values['type_id'];
|
||||||
|
const params = seedRowsOf('parts', 'type_param').filter((r) => r.values['type_id'] === dcdc);
|
||||||
|
expect(params.map((r) => r.values['param_name'])).toEqual([
|
||||||
|
'输入电压范围', '输出电压', '输出电流', '开关频率', '静态电流', '工作温度', '使能阈值',
|
||||||
|
]);
|
||||||
|
/* 约束类型搬到字典上了 —— 同一个参数在两颗料上标法不一致这件事从结构上消失 */
|
||||||
|
expect(params.find((r) => r.values['param_name'] === '静态电流')?.values['constraint_type']).toBe('soft');
|
||||||
|
expect(params.find((r) => r.values['param_name'] === '输入电压范围')?.values['constraint_type']).toBe('hard');
|
||||||
|
/* 每个字段都要有说明 —— AI 分析时读的就是它 */
|
||||||
|
expect(params.every((r) => String(r.values['param_desc'] ?? '') !== '')).toBe(true);
|
||||||
|
|
||||||
|
const ldo = types.find((r) => r.values['type_name'] === 'LDO 线性稳压器')?.values['type_id'];
|
||||||
|
expect(seedRowsOf('parts', 'type_param').filter((r) => r.values['type_id'] === ldo)).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('三颗既有料都归了 DC-DC 类型,新播一颗 LDO 供「类型不一致」那条用例用', () => {
|
||||||
|
const dcdc = seedRowsOf('parts', 'chip_type').find((r) => r.values['type_name'] === 'DC-DC 降压稳压器')?.values['type_id'];
|
||||||
|
const ldo = seedRowsOf('parts', 'chip_type').find((r) => r.values['type_name'] === 'LDO 线性稳压器')?.values['type_id'];
|
||||||
|
const parts = seedRowsOf('parts', 'profile');
|
||||||
|
for (const pn of ['LM2596S-5.0', 'LM2596S-ADJ']) {
|
||||||
|
expect(parts.find((r) => r.key['internal_pn'] === pn)?.values['chip_type_id']).toBe(dcdc);
|
||||||
|
}
|
||||||
|
expect(parts.find((r) => r.key['internal_pn'] === 'AMS1117-3.3')?.values['chip_type_id']).toBe(ldo);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AMS1117-3.3 的参数行按 LDO 的字段清单播齐 —— 它要能被真拿来比', () => {
|
||||||
|
const ldo = seedRowsOf('parts', 'chip_type').find((r) => r.values['type_name'] === 'LDO 线性稳压器')?.values['type_id'];
|
||||||
|
const names = seedRowsOf('parts', 'type_param').filter((r) => r.values['type_id'] === ldo).map((r) => r.values['param_name']);
|
||||||
|
const rows = seedRowsOf('parts', 'parameter').filter((r) => r.key['internal_pn'] === 'AMS1117-3.3');
|
||||||
|
expect(rows.map((r) => r.key['param_name']).sort()).toEqual([...names].sort());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -171,6 +171,60 @@ describe('件上加行:主键凑得齐才落得下', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **Critical 1(2026-08-30 全分支终审逐行核实的链条)**:一张表**唯一**的主键
|
||||||
|
* 同时是 `auto:true` 又是 `text` 类型(W05 的 `parts.chip_type.type_id` —— 与
|
||||||
|
* `contact_code` 那种「auto 但有前缀主键陪衬」的形状不同,这里没有别的主键列可以
|
||||||
|
* 「凑出半把钥匙」)。这个组合把两条各自成立的保护同时绕开了:
|
||||||
|
* ① `barred` 认的是 `k in cells`(这一列在回传里露没露过面),不是「值是否非空」——
|
||||||
|
* 视图若干脆不给这一列挂 `data-col`,`collect()` 连这一列的空字符串都拿不到,
|
||||||
|
* `barred` 因此永远不会把它算进去;
|
||||||
|
* ② `isSeq` 认的是 declared type 是不是 `integer`/`number` —— 这一列声明成了 `text`
|
||||||
|
* (为了绕开另一条已记 debt 的坑,见 `docs/debt.md`),「序号型主键不走全表一致」
|
||||||
|
* 那条 2026-08-23 的保护对它不成立。
|
||||||
|
* 两条一起缺位,`resolveRowKey` 落到「全表一致」:表里此刻**恰好一行**(活动侧的
|
||||||
|
* 自投影机制保证了这一点)时,新建的那一行会被误判成「就是那唯一一行」,把它的名字
|
||||||
|
* /说明**静默覆盖**到既有那一行头上。
|
||||||
|
*
|
||||||
|
* 这两条用例用**真实、未改动的 `resolveRowKey`**逐字重放两种 `cells` 形状 ——
|
||||||
|
* 「视图漏挂 data-col」(旧版 `part-substitution-assessment/src/views/report.ts`
|
||||||
|
* 的形状)与「视图给了一个禁用、恒空的 data-col」(修复后的形状),证明因果链条本身:
|
||||||
|
* `resolveRowKey` 这个函数一个字没改,活动侧只是换了一种能让平台既有保护生效的
|
||||||
|
* 写法。平台侧的根治(`resolveRowKey` 该把声明了 `auto` 的列直接排除在「全表一致」
|
||||||
|
* 之外,不必等 `k in cells` 这道旁门)仍记在 `docs/debt.md`,下一个建「单一主键又是
|
||||||
|
* auto 列」的活动仍会踩上第一条用例这个坑。
|
||||||
|
*/
|
||||||
|
describe('Critical 1:唯一主键又是 auto 列(无前缀陪衬)——视图漏挂 data-col 会让新建行被误判成改既有行', () => {
|
||||||
|
const KEYS = ['type_id'];
|
||||||
|
const TYPES = { type_id: 'text' };
|
||||||
|
// 活动侧的自投影机制保证此刻 parts.chip_type 恰好一行 —— 种子里的既有类型
|
||||||
|
const ONE_EXISTING = [{ key: { type_id: '1' } }];
|
||||||
|
|
||||||
|
it('**病灶重放**:视图漏挂 data-col → type_id 压根不在 cells 里 → barred 落空 → 「全表一致」把新类型误判成既有那一行的号', () => {
|
||||||
|
const out = resolveRowKey({
|
||||||
|
keys: KEYS,
|
||||||
|
cells: { type_name: '新类型', description: '这是什么' }, // 没有 type_id 这个键 —— 与旧版 ro('type_id') 的产出一致
|
||||||
|
drafts: ONE_EXISTING,
|
||||||
|
types: TYPES,
|
||||||
|
autos: ['type_id'],
|
||||||
|
});
|
||||||
|
// 这正是 Critical 1 的病灶:落下的键是既有那一行的号,`writeEditedRows` 接下来会当成「改行」处理,
|
||||||
|
// 把新类型的 type_name/description 写进 type_id='1' 那一行,静默覆盖种子字典。
|
||||||
|
expect(out).toEqual({ type_id: '1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('**修法重放**:视图挂了 data-col、值恒空(禁用输入格)→ type_id 在 cells 里但是空串 → barred 命中,交回 undefined 让 mintable/autoKeys 续新号', () => {
|
||||||
|
const out = resolveRowKey({
|
||||||
|
keys: KEYS,
|
||||||
|
cells: { type_id: '', type_name: '新类型', description: '这是什么' }, // 与修复后 <input data-col="type_id" disabled> 的产出一致
|
||||||
|
drafts: ONE_EXISTING,
|
||||||
|
types: TYPES,
|
||||||
|
autos: ['type_id'],
|
||||||
|
});
|
||||||
|
expect(out).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('提交闸:必填没填就交不了', () => {
|
describe('提交闸:必填没填就交不了', () => {
|
||||||
const gate = (rows: Array<Record<string, unknown>>) =>
|
const gate = (rows: Array<Record<string, unknown>>) =>
|
||||||
gateOf(missingReadings({ 'mine:quote_intake.record': rows }, [RECORD]), [RECORD]);
|
gateOf(missingReadings({ 'mine:quote_intake.record': rows }, [RECORD]), [RECORD]);
|
||||||
@@ -308,6 +362,43 @@ describe('闸看哪几张表:卡上绑的优先,没绑回落到必交付物声
|
|||||||
expect(emptyGate({}, [{ domain: 'a', object: 'b' }], [])).toBeUndefined();
|
expect(emptyGate({}, [{ domain: 'a', object: 'b' }], [])).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **W05 `input-supplement` 回归网**(2026-08-29 评审 Critical:确认钮永久按不下去)。
|
||||||
|
*
|
||||||
|
* 那张数据工单卡的 `gateLedgers` 一度带着 `parts.chip_type`,而件上没有任何
|
||||||
|
* `data-edit="parts.chip_type"`(选类型走的是 `data-options` 下拉现读,不写这张表)——
|
||||||
|
* `mine:parts.chip_type` 因而永远是空数组,`emptyGate` 永远判「这一单还一行都没有」,
|
||||||
|
* 人把另外三张表填得再全,提交钮也按不下去。这条钉死两件事:
|
||||||
|
* ①**复现旧配置确实会永久卡死**(不是猜的);②**修完(去掉 chip_type)之后闸真的开得了**——
|
||||||
|
* 同一份 `mine`(chip_type 全程零行,profile/type_param/parameter 都已填好)在新
|
||||||
|
* `gateLedgers` 下必须放行。ledger 的 domain/object/label 抄自
|
||||||
|
* `activities/part-substitution-assessment/src/manifest.ts` 的声明原文。
|
||||||
|
*/
|
||||||
|
it('W05 input-supplement:gateLedgers 若带 parts.chip_type 会永久卡死(它没有编辑手段可写)——去掉之后,三张真被编辑的表填齐即放行', () => {
|
||||||
|
const watched = [
|
||||||
|
{ domain: 'parts', object: 'chip_type', label: '芯片类型字典' },
|
||||||
|
{ domain: 'parts', object: 'type_param', label: '芯片类型核心参数字段' },
|
||||||
|
{ domain: 'parts', object: 'profile', label: '芯片主数据(P-08)' },
|
||||||
|
{ domain: 'parts', object: 'parameter', label: '标准化参数集(P-08 子表)' },
|
||||||
|
];
|
||||||
|
// parts.chip_type 从未被这张件写过 —— 无论闸怎么配,它在 mine 里恒是空数组
|
||||||
|
const filledExceptChipType = {
|
||||||
|
'mine:parts.type_param': [{ type_id: '1', param_name: '输入电压范围' }],
|
||||||
|
'mine:parts.profile': [{ internal_pn: 'LM2596S-5.0' }, { internal_pn: 'XL2596S-5.0' }],
|
||||||
|
'mine:parts.parameter': [{ internal_pn: 'LM2596S-5.0', param_name: '输入电压范围' }],
|
||||||
|
};
|
||||||
|
// 旧配置(已删除,这里复现只为证明它确实会卡死):chip_type 也在闸的名单里
|
||||||
|
const buggyDeclared = ['parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter'];
|
||||||
|
const stuck = emptyGate(filledExceptChipType, watched, buggyDeclared);
|
||||||
|
expect(stuck?.ready).toBe(false);
|
||||||
|
expect(stuck?.why).toContain('芯片类型字典');
|
||||||
|
expect(stuck?.why).toContain('这张卡就是来建它的');
|
||||||
|
|
||||||
|
// 修完(case-model.ts 现在的真实值):chip_type 不列入闸的名单
|
||||||
|
const fixedDeclared = ['parts.type_param', 'parts.profile', 'parts.parameter'];
|
||||||
|
expect(emptyGate(filledExceptChipType, watched, fixedDeclared)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('优先级:绑的表 > 卡自报 > 交付物;空串与空白不算数', () => {
|
it('优先级:绑的表 > 卡自报 > 交付物;空串与空白不算数', () => {
|
||||||
expect(gateAddressesOf([table('quote_intake.record')], [{ ledgers: ['a.b'] }], ['parts.profile'])).toEqual(['quote_intake.record']);
|
expect(gateAddressesOf([table('quote_intake.record')], [{ ledgers: ['a.b'] }], ['parts.profile'])).toEqual(['quote_intake.record']);
|
||||||
expect(gateAddressesOf(undefined, [{ ledgers: ['a.b'] }], ['', ' '])).toEqual(['a.b']);
|
expect(gateAddressesOf(undefined, [{ ledgers: ['a.b'] }], ['', ' '])).toEqual(['a.b']);
|
||||||
@@ -598,3 +689,20 @@ describe('decisionReadings:按 card-reply 数', () => {
|
|||||||
expect(decisionReadings([])).toEqual({ decision_last: '', decision_approve_total: 0, decision_reject_total: 0, decision_custom_total: 0 });
|
expect(decisionReadings([])).toEqual({ decision_last: '', decision_approve_total: 0, decision_reject_total: 0, decision_custom_total: 0 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('conditionGateOf:确认闸的条件档(2026-08-31,「类型不一致还能确认」逼出)', () => {
|
||||||
|
it('条件为假 → 不受理,拒回用发卡人那句话;为真 / 没声明 → 不拦', async () => {
|
||||||
|
const { conditionGateOf } = await import('../routes/desk.ts');
|
||||||
|
const readings = { parts_profile_distinct_chip_type_id: 2 };
|
||||||
|
const blocked = conditionGateOf('parts_profile_distinct_chip_type_id <= 1', '两颗料不是同一个芯片类型 —— 先改对再确认。', readings);
|
||||||
|
expect(blocked?.ready).toBe(false);
|
||||||
|
expect(blocked?.why).toContain('不是同一个芯片类型');
|
||||||
|
expect(conditionGateOf('parts_profile_distinct_chip_type_id <= 1', '话', { parts_profile_distinct_chip_type_id: 1 })).toBeUndefined();
|
||||||
|
expect(conditionGateOf(undefined, undefined, readings)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('表达式炸了不成墙(装包静态审管坏表达式;运行时评估不出的条件不该锁死一单)', async () => {
|
||||||
|
const { conditionGateOf } = await import('../routes/desk.ts');
|
||||||
|
expect(conditionGateOf('((((', '话', {})).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -99,3 +99,16 @@ export function stepsOf(f: StepFacts): InstanceStep[] {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 让某一步的结果**跟随**前面那一步(2026-08-28 用户定:详情接口交给前端之前,第 9 步的 res 跟第 8 步走)。
|
||||||
|
*
|
||||||
|
* 为什么会需要:放行那一步(T_release,人工激活)没有自己的收步事件 —— 人签字后 `submit` 直接把整单落终态,
|
||||||
|
* 计划项本身从没被 finish 过,于是一单已 `released` 的详情里第 9 步永远是 `pending`,而它前面拟落账那一步早就 `done`。
|
||||||
|
* 改 `res` 与 `at`(2026-08-28 用户定,`at` 也跟);用时不编(那一步没有自己的起止痕)。两步有一步不在(别的活动步数不同)就什么都不动。
|
||||||
|
*/
|
||||||
|
export function followRes(steps: readonly InstanceStep[], rule: { readonly seq: number; readonly from: number }): InstanceStep[] {
|
||||||
|
const source = steps.find((s) => s.seq === rule.from);
|
||||||
|
if (source === undefined) return [...steps];
|
||||||
|
return steps.map((s) => (s.seq === rule.seq ? { ...s, res: source.res, at: source.at } : s));
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,13 @@
|
|||||||
* · `x-control`:`text | textarea | select | combo` —— 不写就按类型推:enum → select、`array<string>` → list、string → text;
|
* · `x-control`:`text | textarea | select | combo` —— 不写就按类型推:enum → select、`array<string>` → list、string → text;
|
||||||
* **数组字段也可以标 combo / select**(表单只选一颗,提交单个字符串,后端包成一元数组;传数组也照收)
|
* **数组字段也可以标 combo / select**(表单只选一颗,提交单个字符串,后端包成一元数组;传数组也照收)
|
||||||
* · `x-options`:`{ ledger: '域.对象', value: '列', label: '列' }` —— combo / select 的候选从正式台账现读(客户 / 料号这类「台账里可能有」的)
|
* · `x-options`:`{ ledger: '域.对象', value: '列', label: '列' }` —— combo / select 的候选从正式台账现读(客户 / 料号这类「台账里可能有」的)
|
||||||
|
* · `x-options.group`:候选的**分组键**(2026-08-31 用户定)—— 只写列名(`'chip_type_id'`)= 有键没名;
|
||||||
|
* 整份写 `{ column, ledger, value, label }` 再从字典表换人读名(`parts.chip_type.type_id → type_name`)
|
||||||
|
* · `x-link`:联动组名 —— 同名的几格按候选的分组键互相收窄(组内先填的那格是锚,其余按锚的键收窄)。
|
||||||
|
* **服务端不据此立墙**:combo 本就收候选之外的值(新增料号),类型一致归活动自己的收步判据管
|
||||||
|
* · `x-distinct`:互斥组名 —— 同名的几格**不能填同一个值**。这条**是墙**(与 `x-link` 正相反):
|
||||||
|
* 前端不把别的格占了的候选列出来,自己写进来的由这里拒。理由见契约 `distinctGroup` 的注释:
|
||||||
|
* W05 两格同一颗料会把单子做死,而唯一能改的时机就是起单这一刻
|
||||||
* · `format: 'file'` 的字段**本期不上表单**(文档没有文件控件;W05 的 datasheet 在数据工单里传)
|
* · `format: 'file'` 的字段**本期不上表单**(文档没有文件控件;W05 的 datasheet 在数据工单里传)
|
||||||
* · `x-order`:表单里的次序(数字,小的在前)。**必须声明**才有稳定次序 —— 清单存在 jsonb 里,
|
* · `x-order`:表单里的次序(数字,小的在前)。**必须声明**才有稳定次序 —— 清单存在 jsonb 里,
|
||||||
* PG 按键长再按字节序重排对象键,`properties` 的书写顺序到不了这里(实测:原料号跑到了客户需求前面)
|
* PG 按键长再按字节序重排对象键,`properties` 的书写顺序到不了这里(实测:原料号跑到了客户需求前面)
|
||||||
@@ -15,9 +22,15 @@
|
|||||||
* 纯函数、零 IO;候选的读取由调用方以 `resolve` 注入。
|
* 纯函数、零 IO;候选的读取由调用方以 `resolve` 注入。
|
||||||
*/
|
*/
|
||||||
import type { LaunchOption, LaunchParam, LaunchParamType } from '@contracts';
|
import type { LaunchOption, LaunchParam, LaunchParamType } from '@contracts';
|
||||||
import { LAUNCH_PARAM_TYPES } from '@contracts';
|
import { LAUNCH_PARAM_TYPES, launchDistinctKey, launchDistinctMessage } from '@contracts';
|
||||||
|
|
||||||
export type OptionsSource = { readonly ledger: string; readonly value: string; readonly label: string };
|
/**
|
||||||
|
* 候选的**分组键**(2026-08-31 用户定的联动):`column` 是候选行上取哪一列当键(如 `chip_type_id`),
|
||||||
|
* `dict` 是把这枚键换成人读名的字典表(如 `parts.chip_type.type_id → type_name`);
|
||||||
|
* 字典没声明 / 写残 → 只有键没有名(界面退回摆键本身,不炸)。
|
||||||
|
*/
|
||||||
|
export type GroupSource = { readonly column: string; readonly dict: { readonly ledger: string; readonly value: string; readonly label: string } | undefined };
|
||||||
|
export type OptionsSource = { readonly ledger: string; readonly value: string; readonly label: string; readonly group: GroupSource | undefined };
|
||||||
|
|
||||||
/** 一个入参字段的规格 —— JSON Schema 里我们认的那几个词 */
|
/** 一个入参字段的规格 —— JSON Schema 里我们认的那几个词 */
|
||||||
export type FieldSpec = {
|
export type FieldSpec = {
|
||||||
@@ -34,6 +47,10 @@ export type FieldSpec = {
|
|||||||
readonly example: unknown;
|
readonly example: unknown;
|
||||||
readonly defaultValue: unknown;
|
readonly defaultValue: unknown;
|
||||||
readonly optionsSource: OptionsSource | undefined;
|
readonly optionsSource: OptionsSource | undefined;
|
||||||
|
/** 联动组名(`x-link`);同名的几格按候选的分组键互相收窄 —— 摆法在前端,服务端不据此立墙 */
|
||||||
|
readonly linkGroup: string | undefined;
|
||||||
|
/** 互斥组名(`x-distinct`);同名的几格不能填同一个值 —— 这一条服务端真拦 */
|
||||||
|
readonly distinctGroup: string | undefined;
|
||||||
/** 表单次序(`x-order`);没声明的排在声明了的后面、彼此保持读到的顺序 */
|
/** 表单次序(`x-order`);没声明的排在声明了的后面、彼此保持读到的顺序 */
|
||||||
readonly order: number | undefined;
|
readonly order: number | undefined;
|
||||||
};
|
};
|
||||||
@@ -46,6 +63,24 @@ function asString(value: unknown): string | undefined {
|
|||||||
return typeof value === 'string' ? value : undefined;
|
return typeof value === 'string' ? value : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `x-options.group`:只写列名(字符串)= 有键没名;整份声明 = 键 + 换名字典。写残当没声明 —— 候选照出,只是不联动 */
|
||||||
|
function groupSourceOf(value: unknown): GroupSource | undefined {
|
||||||
|
const asColumn = asString(value);
|
||||||
|
if (asColumn !== undefined) return asColumn === '' ? undefined : { column: asColumn, dict: undefined };
|
||||||
|
const raw = asRecord(value);
|
||||||
|
if (raw === undefined) return undefined;
|
||||||
|
const column = asString(raw['column']);
|
||||||
|
if (column === undefined || column === '') return undefined;
|
||||||
|
const ledger = asString(raw['ledger']);
|
||||||
|
const valueCol = asString(raw['value']);
|
||||||
|
const labelCol = asString(raw['label']);
|
||||||
|
const dict =
|
||||||
|
ledger === undefined || !ledger.includes('.') || valueCol === undefined || valueCol === ''
|
||||||
|
? undefined
|
||||||
|
: { ledger, value: valueCol, label: labelCol === undefined || labelCol === '' ? valueCol : labelCol };
|
||||||
|
return { column, dict };
|
||||||
|
}
|
||||||
|
|
||||||
function optionsSourceOf(value: unknown): OptionsSource | undefined {
|
function optionsSourceOf(value: unknown): OptionsSource | undefined {
|
||||||
const raw = asRecord(value);
|
const raw = asRecord(value);
|
||||||
if (raw === undefined) return undefined;
|
if (raw === undefined) return undefined;
|
||||||
@@ -53,7 +88,7 @@ function optionsSourceOf(value: unknown): OptionsSource | undefined {
|
|||||||
const valueCol = asString(raw['value']);
|
const valueCol = asString(raw['value']);
|
||||||
const labelCol = asString(raw['label']);
|
const labelCol = asString(raw['label']);
|
||||||
if (ledger === undefined || !ledger.includes('.') || valueCol === undefined || valueCol === '') return undefined;
|
if (ledger === undefined || !ledger.includes('.') || valueCol === undefined || valueCol === '') return undefined;
|
||||||
return { ledger, value: valueCol, label: labelCol === undefined || labelCol === '' ? valueCol : labelCol };
|
return { ledger, value: valueCol, label: labelCol === undefined || labelCol === '' ? valueCol : labelCol, group: groupSourceOf(raw['group']) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `input.data` 的 properties 逐个读成规格,保属性序(表单顺序就是清单顺序) */
|
/** `input.data` 的 properties 逐个读成规格,保属性序(表单顺序就是清单顺序) */
|
||||||
@@ -81,6 +116,8 @@ export function fieldSpecsOf(inputSchema: Readonly<Record<string, unknown>>): Fi
|
|||||||
example: Array.isArray(examples) ? examples[0] : undefined,
|
example: Array.isArray(examples) ? examples[0] : undefined,
|
||||||
defaultValue: spec['default'],
|
defaultValue: spec['default'],
|
||||||
optionsSource: optionsSourceOf(spec['x-options']),
|
optionsSource: optionsSourceOf(spec['x-options']),
|
||||||
|
linkGroup: asString(spec['x-link']) === '' ? undefined : asString(spec['x-link']),
|
||||||
|
distinctGroup: asString(spec['x-distinct']) === '' ? undefined : asString(spec['x-distinct']),
|
||||||
order: typeof spec['x-order'] === 'number' && Number.isFinite(spec['x-order']) ? spec['x-order'] : undefined,
|
order: typeof spec['x-order'] === 'number' && Number.isFinite(spec['x-order']) ? spec['x-order'] : undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -159,6 +196,10 @@ export function launchParamOf(spec: FieldSpec, resolved: readonly LaunchOption[]
|
|||||||
...(placeholder === undefined ? {} : { placeholder }),
|
...(placeholder === undefined ? {} : { placeholder }),
|
||||||
...(withOptions ? { options } : {}),
|
...(withOptions ? { options } : {}),
|
||||||
...(fallback === undefined ? {} : { default: fallback }),
|
...(fallback === undefined ? {} : { default: fallback }),
|
||||||
|
/* 联动只在真有候选时才谈得上 —— 一个候选都没有的 combo 摆了组名,前端也没有可收窄的东西 */
|
||||||
|
...(withOptions && spec.linkGroup !== undefined ? { linkGroup: spec.linkGroup } : {}),
|
||||||
|
/* 互斥**不以有候选为前提**:两格都自己写、写成同一个,照样要拦 —— 墙管的是值,不是候选 */
|
||||||
|
...(spec.distinctGroup === undefined ? {} : { distinctGroup: spec.distinctGroup }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,9 +238,34 @@ function coerceScalar(value: string, spec: FieldSpec | undefined): { value: unkn
|
|||||||
return { value };
|
return { value };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同一互斥组(`x-distinct`)里撞了同一个值的那几格 —— 点名的是**后填的那格**(表单次序在后的),
|
||||||
|
* 话里指向它撞上了谁。只认标量:数组格不参与(见契约 `distinctGroup` 的注释)。
|
||||||
|
* 「算不算同一个」由 `launchDistinctKey` 说了算 —— 前端用的是同一把尺。
|
||||||
|
*/
|
||||||
|
function distinctIssuesOf(params: readonly LaunchParam[], data: Readonly<Record<string, unknown>>): LaunchIssue[] {
|
||||||
|
const issues: LaunchIssue[] = [];
|
||||||
|
const seenPerGroup = new Map<string, Map<string, LaunchParam>>();
|
||||||
|
for (const param of params) {
|
||||||
|
const group = param.distinctGroup;
|
||||||
|
const value = data[param.key];
|
||||||
|
if (group === undefined || value === undefined || value === null || typeof value === 'object') continue;
|
||||||
|
const key = launchDistinctKey(String(value));
|
||||||
|
if (key === '') continue;
|
||||||
|
const seen = seenPerGroup.get(group) ?? new Map<string, LaunchParam>();
|
||||||
|
seenPerGroup.set(group, seen);
|
||||||
|
const first = seen.get(key);
|
||||||
|
if (first === undefined) seen.set(key, param);
|
||||||
|
else issues.push({ key: param.key, message: launchDistinctMessage(first.label) });
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 服务端的那一道校验:未知键 / 必填空 / 类型不符 / select 值不在候选 → issues(`key` 点名是哪一格);
|
* 服务端的那一道校验:未知键 / 必填空 / 类型不符 / select 值不在候选 → issues(`key` 点名是哪一格);
|
||||||
* combo 候选之外的值合法(存在性检查在启动后由活动做);可选的空值不落入参。
|
* combo 候选之外的值合法(存在性检查在启动后由活动做);可选的空值不落入参。
|
||||||
|
* **互斥组(`x-distinct`)是这里唯一的跨格校验**:同组两格填了同一个值就拒 —— 前端不列出别人占了的
|
||||||
|
* 候选只是礼貌,自己写一个同样的值、或压根不经这个表单起单(脚本 / API),拦住的得是这里。
|
||||||
* 通过时返回归一后的 `data`(按清单类型把数字 / 布尔转回真类型),直接交 `task.start`。
|
* 通过时返回归一后的 `data`(按清单类型把数字 / 布尔转回真类型),直接交 `task.start`。
|
||||||
*/
|
*/
|
||||||
export function validateLaunchParams(params: readonly LaunchParam[], input: LaunchWire, specs: readonly FieldSpec[] = []): LaunchValidation {
|
export function validateLaunchParams(params: readonly LaunchParam[], input: LaunchWire, specs: readonly FieldSpec[] = []): LaunchValidation {
|
||||||
@@ -254,5 +320,6 @@ export function validateLaunchParams(params: readonly LaunchParam[], input: Laun
|
|||||||
}
|
}
|
||||||
data[param.key] = coerced.value;
|
data[param.key] = coerced.value;
|
||||||
}
|
}
|
||||||
|
issues.push(...distinctIssuesOf(params, data));
|
||||||
return issues.length === 0 ? { ok: true, data } : { ok: false, issues };
|
return issues.length === 0 ? { ok: true, data } : { ok: false, issues };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,10 +38,10 @@ import type { StartTaskInput } from '@modules/task';
|
|||||||
import { MIME_OF } from '../file-compose.ts';
|
import { MIME_OF } from '../file-compose.ts';
|
||||||
import type { FileFormat } from '../file-compose.ts';
|
import type { FileFormat } from '../file-compose.ts';
|
||||||
import { fieldSpecsOf, launchParamsOf, validateLaunchParams } from './launch-params.ts';
|
import { fieldSpecsOf, launchParamsOf, validateLaunchParams } from './launch-params.ts';
|
||||||
import type { FieldSpec, LaunchIssue, OptionsSource } from './launch-params.ts';
|
import type { FieldSpec, GroupSource, LaunchIssue, OptionsSource } from './launch-params.ts';
|
||||||
import { instanceStateOf, verdictFactOf } from './instance-state.ts';
|
import { instanceStateOf, verdictFactOf } from './instance-state.ts';
|
||||||
import type { StateVerdict } from './instance-state.ts';
|
import type { StateVerdict } from './instance-state.ts';
|
||||||
import { stepsOf } from './instance-steps.ts';
|
import { followRes, stepsOf } from './instance-steps.ts';
|
||||||
import { applyInstanceFilters, inputFilesOf, inputSummaryOf, outputSummaryOf, pageOf, statsOf } from './summaries.ts';
|
import { applyInstanceFilters, inputFilesOf, inputSummaryOf, outputSummaryOf, pageOf, statsOf } from './summaries.ts';
|
||||||
import type { InstanceQuery } from './summaries.ts';
|
import type { InstanceQuery } from './summaries.ts';
|
||||||
import { LEDGER_TEMPLATE, injectHeightScript } from './view-html.ts';
|
import { LEDGER_TEMPLATE, injectHeightScript } from './view-html.ts';
|
||||||
@@ -54,6 +54,8 @@ import type { ActivityOpsStore, TaskRow } from './store.ts';
|
|||||||
export const LAUNCH_CLAIM_STALE_MS = 60_000;
|
export const LAUNCH_CLAIM_STALE_MS = 60_000;
|
||||||
/** combo 候选内联上限(文档 §4:本期一律内联;更大的表以后走检索接口) */
|
/** combo 候选内联上限(文档 §4:本期一律内联;更大的表以后走检索接口) */
|
||||||
export const OPTIONS_CAP = 500;
|
export const OPTIONS_CAP = 500;
|
||||||
|
/** 详情时间线交给前端之前:第 9 步(放行)的结果跟第 8 步(拟落账)走 —— 见 `followRes` 的注释(2026-08-28 用户定) */
|
||||||
|
export const STEP_FOLLOW_RULE = { seq: 9, from: 8 } as const;
|
||||||
|
|
||||||
/** 输入 / 产出文件的存取 —— app 里现成的两段(`upload-store.ts` / `asset-archive.ts`),按接口注入好替身 */
|
/** 输入 / 产出文件的存取 —— app 里现成的两段(`upload-store.ts` / `asset-archive.ts`),按接口注入好替身 */
|
||||||
export type InstanceFileStore = {
|
export type InstanceFileStore = {
|
||||||
@@ -227,6 +229,34 @@ export function createActivityOps(deps: ActivityOpsDeps): ActivityOps {
|
|||||||
return { ...rest, desc: profile?.description ?? '', view: hasView ? { url: `/api/gov/activities/${card.activityId}/view` } : null };
|
return { ...rest, desc: profile?.description ?? '', view: hasView ? { url: `/api/gov/activities/${card.activityId}/view` } : null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分组键的人读名字典(`x-options.group.dict`)—— 键 → 名。
|
||||||
|
* 表没物化 / 列不在 / 查不动 → 空表(候选仍带键、只是没有名;界面退回摆键本身,不炸)。
|
||||||
|
*/
|
||||||
|
async function groupLabels(dict: NonNullable<GroupSource['dict']>): Promise<ReadonlyMap<string, string>> {
|
||||||
|
const [domain, object] = dict.ledger.split('.', 2);
|
||||||
|
if (domain === undefined || object === undefined || domain === '' || object === '') return new Map();
|
||||||
|
const address = { domain, object };
|
||||||
|
const described = await deps.ledger.describe(address).catch(() => undefined);
|
||||||
|
if (described === undefined || !described.columns.some((c) => c.name === dict.value) || !described.columns.some((c) => c.name === dict.label)) {
|
||||||
|
warn(`[activity-ops] 分组人读名读不到:${dict.ledger} 未物化或没有列 ${dict.value} / ${dict.label}`);
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
const page = await deps.ledger.query({ address, where: {}, orderBy: [], limit: OPTIONS_CAP, offset: 0 }).catch((error: unknown) => {
|
||||||
|
warn(`[activity-ops] 分组人读名查询失败:${dict.ledger} —— ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
const out = new Map<string, string>();
|
||||||
|
for (const row of page?.rows ?? []) {
|
||||||
|
const key = row[dict.value];
|
||||||
|
const name = row[dict.label];
|
||||||
|
if (key === undefined || key === null || String(key) === '') continue;
|
||||||
|
if (name === undefined || name === null || String(name) === '') continue;
|
||||||
|
out.set(String(key), String(name));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** combo / select 的候选从正式台账现读;表没物化、列不存在、查询失败 → 没有候选(不炸,说一声) */
|
/** combo / select 的候选从正式台账现读;表没物化、列不存在、查询失败 → 没有候选(不炸,说一声) */
|
||||||
async function resolveOptions(source: OptionsSource): Promise<readonly LaunchOption[] | undefined> {
|
async function resolveOptions(source: OptionsSource): Promise<readonly LaunchOption[] | undefined> {
|
||||||
const [domain, object] = source.ledger.split('.', 2);
|
const [domain, object] = source.ledger.split('.', 2);
|
||||||
@@ -249,13 +279,22 @@ export function createActivityOps(deps: ActivityOpsDeps): ActivityOps {
|
|||||||
return undefined;
|
return undefined;
|
||||||
});
|
});
|
||||||
if (page === undefined) return undefined;
|
if (page === undefined) return undefined;
|
||||||
|
/* 分组键(联动用):声明了、且候选表真有这一列才取 —— 列不在就当没声明(候选照出,只是不联动) */
|
||||||
|
const group = source.group !== undefined && columns.has(source.group.column) ? source.group : undefined;
|
||||||
|
if (source.group !== undefined && group === undefined) warn(`[activity-ops] 分组键读不到:${source.ledger} 没有列 ${source.group.column}`);
|
||||||
|
const names = group?.dict === undefined ? new Map<string, string>() : await groupLabels(group.dict);
|
||||||
return page.rows
|
return page.rows
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
const value = row[source.value];
|
const value = row[source.value];
|
||||||
const label = row[source.label];
|
const label = row[source.label];
|
||||||
const v = value === undefined || value === null ? '' : String(value);
|
const v = value === undefined || value === null ? '' : String(value);
|
||||||
const l = label === undefined || label === null || String(label) === '' ? v : String(label);
|
const l = label === undefined || label === null || String(label) === '' ? v : String(label);
|
||||||
return { value: v, label: l };
|
if (group === undefined) return { value: v, label: l };
|
||||||
|
const raw = row[group.column];
|
||||||
|
/* 那一列没值 = 「未标类型」:空串是它自己的一档,不是「没有分组键这回事」 */
|
||||||
|
const key = raw === undefined || raw === null ? '' : String(raw);
|
||||||
|
const name = names.get(key);
|
||||||
|
return { value: v, label: l, group: key, ...(name === undefined ? {} : { groupLabel: name }) };
|
||||||
})
|
})
|
||||||
.filter((o) => o.value !== '');
|
.filter((o) => o.value !== '');
|
||||||
}
|
}
|
||||||
@@ -387,7 +426,8 @@ export function createActivityOps(deps: ActivityOpsDeps): ActivityOps {
|
|||||||
deps.files.listArchived(taskId),
|
deps.files.listArchived(taskId),
|
||||||
]);
|
]);
|
||||||
const llm = llmOf(manifest);
|
const llm = llmOf(manifest);
|
||||||
const steps = stepsOf({
|
const steps = followRes(
|
||||||
|
stepsOf({
|
||||||
items: loop?.parsed.items ?? [],
|
items: loop?.parsed.items ?? [],
|
||||||
runtimeItems: loop?.runtime.items ?? [],
|
runtimeItems: loop?.runtime.items ?? [],
|
||||||
intents: new Map((loop?.model.steps ?? []).map((s) => [s.item, s.intent])),
|
intents: new Map((loop?.model.steps ?? []).map((s) => [s.item, s.intent])),
|
||||||
@@ -395,7 +435,9 @@ export function createActivityOps(deps: ActivityOpsDeps): ActivityOps {
|
|||||||
state: verdict.state,
|
state: verdict.state,
|
||||||
ownerName: summary.owner.name,
|
ownerName: summary.owner.name,
|
||||||
llm,
|
llm,
|
||||||
});
|
}),
|
||||||
|
STEP_FOLLOW_RULE,
|
||||||
|
);
|
||||||
const startedMs = Date.parse(row.startedAt);
|
const startedMs = Date.parse(row.startedAt);
|
||||||
const finishedMs = row.finishedAt === null ? undefined : Date.parse(row.finishedAt);
|
const finishedMs = row.finishedAt === null ? undefined : Date.parse(row.finishedAt);
|
||||||
const durationMs = finishedMs !== undefined && Number.isFinite(startedMs) && Number.isFinite(finishedMs) && finishedMs >= startedMs ? finishedMs - startedMs : null;
|
const durationMs = finishedMs !== undefined && Number.isFinite(startedMs) && Number.isFinite(finishedMs) && finishedMs >= startedMs ? finishedMs - startedMs : null;
|
||||||
|
|||||||
@@ -67,8 +67,7 @@ import type { ProcessService } from '@modules/process';
|
|||||||
import type { TriggerService } from '@modules/trigger';
|
import type { TriggerService } from '@modules/trigger';
|
||||||
import type { MinuteService } from '@modules/minute';
|
import type { MinuteService } from '@modules/minute';
|
||||||
import type { ReportService } from '@modules/report';
|
import type { ReportService } from '@modules/report';
|
||||||
import { FIRST_PARTY_TOOLS, compareParametersWith } from './tools/first-party.ts';
|
import { FIRST_PARTY_TOOLS } from './tools/first-party.ts';
|
||||||
import type { ParameterLedger } from './tools/first-party.ts';
|
|
||||||
import { httpToolHandler } from './tools/http-tool.ts';
|
import { httpToolHandler } from './tools/http-tool.ts';
|
||||||
import { createMastraSpanFactory } from './mastra-spans.ts';
|
import { createMastraSpanFactory } from './mastra-spans.ts';
|
||||||
import { createTraceService, createTraceStore } from '@modules/trace';
|
import { createTraceService, createTraceStore } from '@modules/trace';
|
||||||
@@ -418,22 +417,12 @@ export async function createContainer(config: AppConfig, overrides: ContainerOve
|
|||||||
/** 第一方工具实现 —— `mail.send` 真发信,处理器在这儿闭包持 mail(放行时经 ToolInvoker 执行) */
|
/** 第一方工具实现 —— `mail.send` 真发信,处理器在这儿闭包持 mail(放行时经 ToolInvoker 执行) */
|
||||||
// 导入的自定义工具走通用 HTTP 实现:契约申报 endpoint,运行时只把那一个连接目标交出去
|
// 导入的自定义工具走通用 HTTP 实现:契约申报 endpoint,运行时只把那一个连接目标交出去
|
||||||
/*
|
/*
|
||||||
* 差异评估的比对工具**从台账取参数**(2026-08-27 晚):静态名单里那份只认 pairs;这里用 ledger 顶掉 ——
|
* 差异评估的两枚比对工具已退役(2026-08-29,墓志铭见 `tools/first-party.ts`)——
|
||||||
* 有 taskId 读任务视图(正式版 + 本单草稿),没有(试运行)只读正式版。只读。
|
* 规则改由 `part-substitution-assessment` 的 `S2_assess.auto` 配方现算,这里的
|
||||||
|
* `parameterLedger`(从台账取参数的只读口)随之一并撤,不再有第二处消费方。
|
||||||
*/
|
*/
|
||||||
const parameterLedger: ParameterLedger = {
|
|
||||||
rows: async (taskId, address, where) => {
|
|
||||||
const query = { address, where, orderBy: [], limit: 500, offset: 0 };
|
|
||||||
const page = taskId === undefined ? await ledger.query(query) : await ledger.taskView(taskId, query);
|
|
||||||
return page.rows;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const toolRuntime = createToolRuntime(
|
const toolRuntime = createToolRuntime(
|
||||||
new Map<string, ToolHandler>([
|
new Map<string, ToolHandler>([...FIRST_PARTY_TOOLS, ['mail.send', mailSendTool(mail)]]),
|
||||||
...FIRST_PARTY_TOOLS,
|
|
||||||
['part-substitution-assessment.compare-parameters', compareParametersWith(parameterLedger)],
|
|
||||||
['mail.send', mailSendTool(mail)],
|
|
||||||
]),
|
|
||||||
httpToolHandler,
|
httpToolHandler,
|
||||||
);
|
);
|
||||||
const schemas = createSchemaValidator();
|
const schemas = createSchemaValidator();
|
||||||
@@ -685,30 +674,33 @@ export async function createContainer(config: AppConfig, overrides: ContainerOve
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
cards: {
|
cards: {
|
||||||
issueAcceptance: async (input) => {
|
/*
|
||||||
|
* **这条链的最后一关:默认是「传过去」,不是「丢掉」**(2026-09-01 第三次踩同一个坑改的结构)。
|
||||||
|
*
|
||||||
|
* 从前这儿是一个**手写字段清单**的显式对象:上游(契约 → submit → release)一路传下来的东西,
|
||||||
|
* 只要这里没被点到名就在这一行静默落地,编译不报、运行时无声。已经吃过三次:
|
||||||
|
* ① `fields` 被写死成 `[]` —— 验收报告只能看不能改(第 12 章③);
|
||||||
|
* ② `blocks` 漏了一行 —— 卡面正文块恒空;
|
||||||
|
* ③ `redoable` 漏了一行(2026-09-01 用户:「怎么还有重做??你没改上吗?」)——
|
||||||
|
* 装配层算对了、release 转对了、契约也声明了,到这儿被抹平,那颗钮照旧摆着。
|
||||||
|
* 两条注释就写在那几行旁边警告「少写一行就静默落地」,照样第三次。**措辞治不了的结构治**:
|
||||||
|
* 把 context 三样拆出去,**其余整包转交** —— 以后端口上新增一个可选字段,这里不改也到得了。
|
||||||
|
* 剩下的两处 `as never` 是类型收窄(品牌类型 / 结构宽松的块),不是丢值。
|
||||||
|
*/
|
||||||
|
issueAcceptance: async ({ taskId, ownerId, humanCallCap, blocks, checklist, ...draft }) => {
|
||||||
const issued = await humanTool.issueAcceptance(
|
const issued = await humanTool.issueAcceptance(
|
||||||
{ taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap },
|
{ taskId, ownerId, humanCallCap },
|
||||||
{
|
{
|
||||||
title: input.title,
|
/* **整包转交** —— 端口上新增一个可选字段,这里不改也到得了(见上面那段头注) */
|
||||||
basis: [...input.basis],
|
...draft,
|
||||||
deliverable: input.deliverable as never,
|
/* 下面几行不是「挑字段」,是**类型收窄**:只读转可变、品牌类型、结构宽松的块各要一次 */
|
||||||
criteria: [...input.criteria],
|
basis: [...draft.basis],
|
||||||
actions: input.actions.map((a) => ({ ...a })),
|
deliverable: draft.deliverable as never,
|
||||||
// 人工验收清单照传 —— 与 fields / blocks 同一个坑:这里少一行,上游传了一路的东西就静默落地
|
criteria: [...draft.criteria],
|
||||||
...(input.checklist === undefined || input.checklist.length === 0 ? {} : { checklist: [...input.checklist] }),
|
actions: draft.actions.map((a) => ({ ...a })),
|
||||||
/*
|
fields: (draft.fields ?? []).map((f) => ({ ...f })) as never,
|
||||||
* 可修订字段照传 —— 从前这里写死 `fields: []`,是这条链上的最后一处丢值:
|
...(checklist === undefined || checklist.length === 0 ? {} : { checklist: [...checklist] }),
|
||||||
* 契约声明了、submit 传了、release 转了,到这儿被一句空数组抹平,
|
...(blocks === undefined || blocks.length === 0 ? {} : { blocks: blocks.map((b) => ({ ...b })) as never }),
|
||||||
* 验收报告于是只能看不能改(第 12 章③)。
|
|
||||||
*/
|
|
||||||
fields: (input.fields ?? []).map((f) => ({ ...f })) as never,
|
|
||||||
/*
|
|
||||||
* 正文块照传 —— 与 `fields` 同一个坑:这个显式对象是这条链的最后一关,
|
|
||||||
* 少写一行,上游传了一路的东西就在这里静默落地(卡面 blocks 恒空,踩过两次)。
|
|
||||||
*/
|
|
||||||
...(input.blocks === undefined || input.blocks.length === 0
|
|
||||||
? {}
|
|
||||||
: { blocks: input.blocks.map((b) => ({ ...b })) as never }),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return { id: issued.order.id };
|
return { id: issued.order.id };
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { assetRowsOf, keyText, listArchived, readArchived } from '../asset-archi
|
|||||||
import { landDataOrder } from '../data-order-landing.ts';
|
import { landDataOrder } from '../data-order-landing.ts';
|
||||||
import { composeFile, isFileFormat, MIME_OF, safeFileName, type FileFormat } from '../file-compose.ts';
|
import { composeFile, isFileFormat, MIME_OF, safeFileName, type FileFormat } from '../file-compose.ts';
|
||||||
import { toFileDoc } from '../file-doc.ts';
|
import { toFileDoc } from '../file-doc.ts';
|
||||||
|
import { afterSave } from '../save-wake.ts';
|
||||||
import { displayLog } from './log-lines.ts';
|
import { displayLog } from './log-lines.ts';
|
||||||
import { pulse } from '../workers/pulse.ts';
|
import { pulse } from '../workers/pulse.ts';
|
||||||
import type { Container } from '../container.ts';
|
import type { Container } from '../container.ts';
|
||||||
@@ -44,7 +45,8 @@ function rowsOf<T>(result: unknown): T[] {
|
|||||||
}
|
}
|
||||||
import { staffOf } from '../http-env.ts';
|
import { staffOf } from '../http-env.ts';
|
||||||
import type { AppEnv } from '../http-env.ts';
|
import type { AppEnv } from '../http-env.ts';
|
||||||
import { coerceToColumn, missingReadings, rowLabel } from '../wiring/engines.ts';
|
import { evaluateCondition } from '@sfeel';
|
||||||
|
import { coerceToColumn, distinctReadings, missingReadings, rowLabel, rowReadings, tallyReadings } from '../wiring/engines.ts';
|
||||||
import { orderChat } from '../chat-order.ts';
|
import { orderChat } from '../chat-order.ts';
|
||||||
|
|
||||||
|
|
||||||
@@ -815,7 +817,41 @@ async function submitGate(
|
|||||||
for (const draft of await container.ledger.taskDrafts(taskId)) {
|
for (const draft of await container.ledger.taskDrafts(taskId)) {
|
||||||
(mine[`mine:${draft.domain}.${draft.object}`] ??= []).push({ ...draft.key, ...draft.patch });
|
(mine[`mine:${draft.domain}.${draft.object}`] ??= []).push({ ...draft.key, ...draft.patch });
|
||||||
}
|
}
|
||||||
return emptyGate(mine, watched, declared) ?? gateOf(missingReadings(mine, watched), watched);
|
const base = emptyGate(mine, watched, declared) ?? gateOf(missingReadings(mine, watched), watched);
|
||||||
|
if (!base.ready) return base;
|
||||||
|
/*
|
||||||
|
* **条件档**(2026-08-31,W05「两颗料不是同一个芯片类型还能确认」逼出):缺格闸都过了,
|
||||||
|
* 发卡的人还声明了一条 S-FEEL 条件的,再按**全部声明表的基座读数**现算一遍 —— 为假就
|
||||||
|
* 不受理。读数从本单草稿现读(rows/missing/tally/distinct 同一套函数),没有 tick 滞后:
|
||||||
|
* 人刚在件上改完类型、点确认,算的就是改完之后的样子。
|
||||||
|
*/
|
||||||
|
return (
|
||||||
|
conditionGateOf(order.gateWhen, order.gateWhenSay, {
|
||||||
|
...rowReadings(mine, manifest.declarations.ledger),
|
||||||
|
...missingReadings(mine, manifest.declarations.ledger),
|
||||||
|
...tallyReadings(mine, manifest.declarations.ledger),
|
||||||
|
...distinctReadings(mine, manifest.declarations.ledger),
|
||||||
|
}) ?? base
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确认闸的条件档 —— S-FEEL 为假就交不了,拒回用发卡人写的那句话(display 与服务端同一段)。
|
||||||
|
* 条件只读基座读数(机检纪律)。表达式炸了按「不拦」:坏表达式装包时静态审就该拦
|
||||||
|
* (checkWhen 已审),运行时评估不出的条件不该成墙(「没有依据的时候不拦人」)。
|
||||||
|
*/
|
||||||
|
export function conditionGateOf(
|
||||||
|
when: string | undefined,
|
||||||
|
say: string | undefined,
|
||||||
|
readings: Readonly<Record<string, string | number>>,
|
||||||
|
): { ready: boolean; why: string; blanks: string[] } | undefined {
|
||||||
|
if (when === undefined || when.trim() === '') return undefined;
|
||||||
|
try {
|
||||||
|
if (evaluateCondition(when, readings)) return undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { ready: false, why: say ?? '这张卡声明的确认条件还不成立 —— 回件上把它办对再来确认。', blanks: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1361,8 +1397,8 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
return c.json(body, 409);
|
return c.json(body, 409);
|
||||||
}
|
}
|
||||||
const saved = await applyViewTableEdits(container, taskId, parsed.data);
|
const saved = await applyViewTableEdits(container, taskId, parsed.data);
|
||||||
// 数据变了,件跟着变 —— 算出来的那些(类徽 / 分布条 / 去向)由这一次重渲重新求值
|
// 数据变了,件跟着变;并叫醒任务 —— auto 步的配方这才有机会按最新的草稿铺格
|
||||||
if (saved.rows > 0) await container.views.repush(taskId);
|
await afterSave(container, taskId, saved.rows);
|
||||||
/*
|
/*
|
||||||
* **有行没落下就如实说** —— 保存说成功、东西却不在,是这一屏最伤人的一种谎
|
* **有行没落下就如实说** —— 保存说成功、东西却不在,是这一屏最伤人的一种谎
|
||||||
* (2026-08-24 用户实测:件上加的那个客户推到下一步就不见了)。
|
* (2026-08-24 用户实测:件上加的那个客户推到下一步就不见了)。
|
||||||
|
|||||||
@@ -712,6 +712,8 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
cards: orders.map((o) => ({
|
cards: orders.map((o) => ({
|
||||||
id: o.id,
|
id: o.id,
|
||||||
mode: o.mode,
|
mode: o.mode,
|
||||||
|
...(o.mode === 'execution' && o.variant !== undefined ? { variant: o.variant } : {}),
|
||||||
|
...(o.mode === 'acceptance' && o.redoable !== undefined ? { redoable: o.redoable } : {}),
|
||||||
title: o.title,
|
title: o.title,
|
||||||
done: o.done !== undefined,
|
done: o.done !== undefined,
|
||||||
...(o.done?.result === undefined ? {} : { result: String(o.done.result) }),
|
...(o.done?.result === undefined ? {} : { result: String(o.done.result) }),
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* **件上按了保存之后**(2026-08-29,spec §5.4)。
|
||||||
|
*
|
||||||
|
* 两件事按序:
|
||||||
|
* ① 重推件 —— 数据变了,算出来的那些(红带缺几格 / 分布条 / 去向)得重新求值;
|
||||||
|
* ② **叫醒任务** —— auto 步的配方这才有机会按最新的草稿把该铺的行铺出来。
|
||||||
|
* 人挑了芯片类型、按了保存,参数格就在这一下之后出现。
|
||||||
|
*
|
||||||
|
* 从前只有两条回传路由调 `wake`;保存不是回传,于是「保存即铺格」永远等不到那一拍。
|
||||||
|
*
|
||||||
|
* **只在这一单有未答的卡时才叫醒**(2026-08-29 终审 F1)。`TaskService.wake` 对
|
||||||
|
* `awaiting-event` / `awaiting-human` 一律拨成 `running`,推进器随即推一拍并**记一步预算**。
|
||||||
|
* 而现有九个活动包一个 `auto` 都没有:人每按一次保存就烧掉一步预算、界面闪一次
|
||||||
|
* 「AI 正在推进这单」,那一拍走到 `cards.unanswered` 就原路返回;更糟的是任务本来
|
||||||
|
* `awaiting-human`(等人裁决)时被叫醒,那一拍结束会把状态写成 `awaiting-event` ——
|
||||||
|
* 从「等人办事」降级成「等外部回音」,而实际等的仍是人。**这是界面撒谎。**
|
||||||
|
* 而「保存即铺格」的场景恰恰都有卡在场(人正对着那张卡在件上填),验收态一点都不碰。
|
||||||
|
*
|
||||||
|
* **叫不醒不抬手**:保存本身是成功的,不该因为叫不醒把 200 变成 500 ——
|
||||||
|
* 下一次推进器扫到它照样会跑。但**不再静默**:它是「保存即铺格」的唯一触发器,
|
||||||
|
* 吞掉就等于人按了保存、格子没出现、系统一个字不说。
|
||||||
|
*/
|
||||||
|
import type { TaskId } from '@contracts';
|
||||||
|
|
||||||
|
function grumble(what: string, error: unknown): void {
|
||||||
|
const why = error instanceof Error ? error.message : String(error);
|
||||||
|
globalThis.process.stderr.write(`[save-wake] ${what}:${why}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function afterSave(
|
||||||
|
deps: {
|
||||||
|
readonly views: { repush(taskId: TaskId): Promise<unknown> };
|
||||||
|
readonly task: { wake(taskId: TaskId): Promise<unknown> };
|
||||||
|
/** 这一单正等人交回的卡(工单没办结的 + 对话卡没作答的)—— 用既有那条,不新造 */
|
||||||
|
readonly humanTool: { pendingCards(taskId: TaskId): Promise<ReadonlyArray<unknown>> };
|
||||||
|
},
|
||||||
|
taskId: TaskId,
|
||||||
|
rows: number,
|
||||||
|
): Promise<void> {
|
||||||
|
if (rows <= 0) return;
|
||||||
|
await deps.views.repush(taskId);
|
||||||
|
/* 问不出来就不叫醒:宁可少推一拍(推进器下一轮照样扫到它),也不平白烧一步预算 */
|
||||||
|
const pending = await deps.humanTool.pendingCards(taskId).catch((error: unknown) => {
|
||||||
|
grumble('问不出这一单有没有卡在等人,这次不叫醒', error);
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
if (pending.length === 0) return;
|
||||||
|
await deps.task.wake(taskId).catch((error: unknown) => grumble('叫醒失败,件已重推,等推进器下一轮', error));
|
||||||
|
}
|
||||||
@@ -16,7 +16,6 @@ import { join, resolve } from 'node:path';
|
|||||||
import { activityManifestSchema } from '@contracts';
|
import { activityManifestSchema } from '@contracts';
|
||||||
import type { Container } from './container.ts';
|
import type { Container } from './container.ts';
|
||||||
import { MASTER_DOMAINS, MASTER_OBJECTS, MASTER_SAMPLE_ROWS } from './seed-mvp-data.ts';
|
import { MASTER_DOMAINS, MASTER_OBJECTS, MASTER_SAMPLE_ROWS } from './seed-mvp-data.ts';
|
||||||
import { W05_TOOLS, W05_TOOL_SAMPLE } from './tools-w05.ts';
|
|
||||||
import { createActivityOpsStore } from './activity-ops/store.ts';
|
import { createActivityOpsStore } from './activity-ops/store.ts';
|
||||||
|
|
||||||
/** 已开发完成、要随播种装上的真活动包(目录名 = 包目录,不是 activityId) */
|
/** 已开发完成、要随播种装上的真活动包(目录名 = 包目录,不是 activityId) */
|
||||||
@@ -96,16 +95,6 @@ export async function installRealActivities(container: Container): Promise<void>
|
|||||||
(conflicts.length === 0 ? '' : ` —— 合并冲突 ${conflicts.length} 处:${conflicts.map((c) => c.field).join('、')}`),
|
(conflicts.length === 0 ? '' : ` —— 合并冲突 ${conflicts.length} 处:${conflicts.map((c) => c.field).join('、')}`),
|
||||||
);
|
);
|
||||||
|
|
||||||
/*
|
|
||||||
* **纯工具随活动一起注册**(2026-08-26):从前工具契约只在 db:seed 里注册,只跑 activities:install 的
|
|
||||||
* 开发机上,活动清单声明了工具、白名单也发了,`sys.tool` 里却没有那件工具 —— 第一次跑就撞「工具不存在」。
|
|
||||||
* 「安装即用」包含它声明过的纯工具能调得动。register 按 name+version 幂等,重复装是 no-op。
|
|
||||||
*/
|
|
||||||
for (const contract of W05_TOOLS) {
|
|
||||||
const verdict = await container.toolbox.register({ contract, samples: [W05_TOOL_SAMPLE] });
|
|
||||||
if (!verdict.accepted) log(` 工具 ${contract.name} 未过两审:${verdict.staticFindings.map((f) => f.message).join('; ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = activitiesRoot();
|
const root = activitiesRoot();
|
||||||
|
|
||||||
for (const dir of REAL_PACKAGES) {
|
for (const dir of REAL_PACKAGES) {
|
||||||
|
|||||||
@@ -152,6 +152,27 @@ export const MASTER_OBJECTS: readonly ObjectDeclaration[] = [
|
|||||||
`normalized_parameter_set` 的数据类型本就写的是 `Structure`。物理列 `parts.profile.
|
`normalized_parameter_set` 的数据类型本就写的是 `Structure`。物理列 `parts.profile.
|
||||||
normalized_parameter_set` 仍留在基线里(不删列),只是活动清单不再声明它、不再写它。
|
normalized_parameter_set` 仍留在基线里(不删列),只是活动清单不再声明它、不再写它。
|
||||||
老的 market.part(主键 pn)不复用,记 debt 待并。 */
|
老的 market.part(主键 pn)不复用,记 debt 待并。 */
|
||||||
|
master('parts', 'chip_type', '芯片类型', [
|
||||||
|
/*
|
||||||
|
* **整数自增主键**:人在件上「+新建类型」时这一格留空,由基座按「现有最大 + 1」续号
|
||||||
|
* (与 I01 的序号主键同一条路)。**空着的自增列不是身份线索,是「给我续个号」。**
|
||||||
|
*/
|
||||||
|
f('type_id', 'integer', '类型ID', true),
|
||||||
|
f('type_name', 'text', '类型名称'),
|
||||||
|
f('description', 'text', '类型说明'),
|
||||||
|
]),
|
||||||
|
master('parts', 'type_param', '芯片类型核心参数字段', [
|
||||||
|
f('type_id', 'integer', '所属类型', true),
|
||||||
|
f('param_name', 'text', '核心参数名', true),
|
||||||
|
/* **AI 分析时读的就是这一列** —— 参数名本身说不清「它为什么要紧」 */
|
||||||
|
f('param_desc', 'text', '参数说明'),
|
||||||
|
/*
|
||||||
|
* 约束类型从 `parts.parameter`(每颗料各标一次)搬到这里(按类型标一次)——
|
||||||
|
* 「同一个参数在两颗料上标法不一致」这件事从此在结构上不可能发生。
|
||||||
|
*/
|
||||||
|
f('constraint_type', 'text', '约束类型'),
|
||||||
|
f('param_unit', 'text', '参考单位'),
|
||||||
|
]),
|
||||||
master('parts', 'profile', '芯片主数据(P-08)', [
|
master('parts', 'profile', '芯片主数据(P-08)', [
|
||||||
f('internal_pn', 'text', '内部料号', true),
|
f('internal_pn', 'text', '内部料号', true),
|
||||||
f('customer_pn', 'text', '客户料号'),
|
f('customer_pn', 'text', '客户料号'),
|
||||||
@@ -167,6 +188,7 @@ export const MASTER_OBJECTS: readonly ObjectDeclaration[] = [
|
|||||||
f('datasheet_version', 'text', 'Datasheet 版本'),
|
f('datasheet_version', 'text', 'Datasheet 版本'),
|
||||||
f('application_scenarios', 'text', '适配应用场景'),
|
f('application_scenarios', 'text', '适配应用场景'),
|
||||||
f('pin_to_pin_relation_ref', 'text', 'Pin-to-Pin 关系引用'),
|
f('pin_to_pin_relation_ref', 'text', 'Pin-to-Pin 关系引用'),
|
||||||
|
f('chip_type_id', 'integer', '芯片类型'),
|
||||||
]),
|
]),
|
||||||
/* P-08 子表:标准化参数集,一行一参数,按 internal_pn 归属主记录(《统一方案》§六:重复明细用子表) */
|
/* P-08 子表:标准化参数集,一行一参数,按 internal_pn 归属主记录(《统一方案》§六:重复明细用子表) */
|
||||||
master('parts', 'parameter', '标准化参数集(P-08 子表)', [
|
master('parts', 'parameter', '标准化参数集(P-08 子表)', [
|
||||||
@@ -475,6 +497,23 @@ export const MASTER_SAMPLE_ROWS: readonly { address: LedgerAddress; key: LedgerK
|
|||||||
review_note: '',
|
review_note: '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
/* 芯片类型字典 —— 字段清单挂在类型上,下面 parts.profile 的 chip_type_id 要指向这里的 type_id,故字典先播。 */
|
||||||
|
{ address: { domain: 'parts', object: 'chip_type' }, key: { type_id: 1 }, values: { type_id: 1, type_name: 'DC-DC 降压稳压器', description: '开关式降压转换器:靠电感储能与高频开关把输入电压降到目标输出,效率高、发热小,外围需要电感与输出电容。' } },
|
||||||
|
{ address: { domain: 'parts', object: 'chip_type' }, key: { type_id: 2 }, values: { type_id: 2, type_name: 'LDO 线性稳压器', description: '低压差线性稳压器:靠调整管压降稳压,外围简单、噪声低,压差与电流之积全部转成热耗。' } },
|
||||||
|
|
||||||
|
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '输入电压范围' }, values: { type_id: 1, param_name: '输入电压范围', param_desc: '芯片允许的输入直流电压区间。候选料的上限低于应用输入上限就是硬伤,烧的是芯片。', constraint_type: 'hard', param_unit: 'V' } },
|
||||||
|
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '输出电压' }, values: { type_id: 1, param_name: '输出电压', param_desc: '固定版给定值;可调版给可调区间,需外围反馈电阻网络设定。固定换可调要动外围。', constraint_type: 'hard', param_unit: 'V' } },
|
||||||
|
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '输出电流' }, values: { type_id: 1, param_name: '输出电流', param_desc: '满载持续输出能力。低于原料即降额,负载重时会掉压或过热保护。', constraint_type: 'hard', param_unit: 'A' } },
|
||||||
|
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '开关频率' }, values: { type_id: 1, param_name: '开关频率', param_desc: '决定外围电感与输出电容的取值。频率变了外围多半要跟着换,不是换颗料就完。', constraint_type: 'soft', param_unit: 'kHz' } },
|
||||||
|
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '静态电流' }, values: { type_id: 1, param_name: '静态电流', param_desc: '空载待机时自身消耗。电池供电场景敏感,市电场景通常不敏感。', constraint_type: 'soft', param_unit: 'mA' } },
|
||||||
|
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '工作温度' }, values: { type_id: 1, param_name: '工作温度', param_desc: '保证参数指标的环境温度区间。工业温域窄于应用要求就是硬伤。', constraint_type: 'hard', param_unit: '℃' } },
|
||||||
|
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '使能阈值' }, values: { type_id: 1, param_name: '使能阈值', param_desc: 'ON/OFF 引脚的开关门限。上游用 MCU 电平直驱时,阈值对不上会关不掉或开不起来。', constraint_type: 'hard', param_unit: 'V' } },
|
||||||
|
|
||||||
|
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 2, param_name: '输入电压范围' }, values: { type_id: 2, param_name: '输入电压范围', param_desc: '允许的输入直流电压区间。', constraint_type: 'hard', param_unit: 'V' } },
|
||||||
|
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 2, param_name: '输出电压' }, values: { type_id: 2, param_name: '输出电压', param_desc: '固定版给定值;可调版需外围分压设定。', constraint_type: 'hard', param_unit: 'V' } },
|
||||||
|
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 2, param_name: '输出电流' }, values: { type_id: 2, param_name: '输出电流', param_desc: '满载持续输出能力;线性稳压器受热耗限制,实际可用电流常低于标称。', constraint_type: 'hard', param_unit: 'A' } },
|
||||||
|
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 2, param_name: '压差' }, values: { type_id: 2, param_name: '压差', param_desc: '维持稳压所需的最小输入输出电压差。压差不够就稳不住,输出跟着输入掉。', constraint_type: 'hard', param_unit: 'V' } },
|
||||||
|
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 2, param_name: '工作温度' }, values: { type_id: 2, param_name: '工作温度', param_desc: '保证参数指标的环境温度区间。', constraint_type: 'hard', param_unit: '℃' } },
|
||||||
/*
|
/*
|
||||||
* ── SS02 差异评估的样例料号(2026-08-26,2026-08-27 参数集改子表)──
|
* ── SS02 差异评估的样例料号(2026-08-26,2026-08-27 参数集改子表)──
|
||||||
* 只播**原 PN**与**同族可调版**两颗:「都在库」那条路靠它们;候选 XL2596S-5.0 与国产 DCDC
|
* 只播**原 PN**与**同族可调版**两颗:「都在库」那条路靠它们;候选 XL2596S-5.0 与国产 DCDC
|
||||||
@@ -487,6 +526,7 @@ export const MASTER_SAMPLE_ROWS: readonly { address: LedgerAddress; key: LedgerK
|
|||||||
key: { internal_pn: 'LM2596S-5.0' },
|
key: { internal_pn: 'LM2596S-5.0' },
|
||||||
values: {
|
values: {
|
||||||
internal_pn: 'LM2596S-5.0',
|
internal_pn: 'LM2596S-5.0',
|
||||||
|
chip_type_id: 1,
|
||||||
brand_name: 'TI',
|
brand_name: 'TI',
|
||||||
manufacturer_name: 'Texas Instruments',
|
manufacturer_name: 'Texas Instruments',
|
||||||
specification_description: '降压型 DCDC 稳压器,3A,固定 5.0V,150kHz',
|
specification_description: '降压型 DCDC 稳压器,3A,固定 5.0V,150kHz',
|
||||||
@@ -499,18 +539,19 @@ export const MASTER_SAMPLE_ROWS: readonly { address: LedgerAddress; key: LedgerK
|
|||||||
application_scenarios: '工业电源;消费电源',
|
application_scenarios: '工业电源;消费电源',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '输入电压范围' }, values: { internal_pn: 'LM2596S-5.0', param_name: '输入电压范围', param_value: '4.5~40', param_unit: 'V', constraint_type: 'hard', param_note: '客户输入上限 40V' } },
|
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '输入电压范围' }, values: { internal_pn: 'LM2596S-5.0', param_name: '输入电压范围', param_value: '4.5~40', param_unit: 'V', param_note: '客户输入上限 40V' } },
|
||||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '输出电压' }, values: { internal_pn: 'LM2596S-5.0', param_name: '输出电压', param_value: '5.0', param_unit: 'V', constraint_type: 'hard', param_note: '固定版' } },
|
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '输出电压' }, values: { internal_pn: 'LM2596S-5.0', param_name: '输出电压', param_value: '5.0', param_unit: 'V', param_note: '固定版' } },
|
||||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '输出电流' }, values: { internal_pn: 'LM2596S-5.0', param_name: '输出电流', param_value: '3', param_unit: 'A', constraint_type: 'hard', param_note: '满载能力' } },
|
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '输出电流' }, values: { internal_pn: 'LM2596S-5.0', param_name: '输出电流', param_value: '3', param_unit: 'A', param_note: '满载能力' } },
|
||||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '开关频率' }, values: { internal_pn: 'LM2596S-5.0', param_name: '开关频率', param_value: '150', param_unit: 'kHz', constraint_type: 'hard', param_note: '影响外围电感/电容' } },
|
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '开关频率' }, values: { internal_pn: 'LM2596S-5.0', param_name: '开关频率', param_value: '150', param_unit: 'kHz', param_note: '影响外围电感/电容' } },
|
||||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '静态电流' }, values: { internal_pn: 'LM2596S-5.0', param_name: '静态电流', param_value: '5', param_unit: 'mA', constraint_type: 'soft', param_note: '待机功耗' } },
|
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '静态电流' }, values: { internal_pn: 'LM2596S-5.0', param_name: '静态电流', param_value: '5', param_unit: 'mA', param_note: '待机功耗' } },
|
||||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '工作温度' }, values: { internal_pn: 'LM2596S-5.0', param_name: '工作温度', param_value: '-40~+85', param_unit: '℃', constraint_type: 'hard', param_note: '工业温域' } },
|
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '工作温度' }, values: { internal_pn: 'LM2596S-5.0', param_name: '工作温度', param_value: '-40~+85', param_unit: '℃', param_note: '工业温域' } },
|
||||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '使能阈值' }, values: { internal_pn: 'LM2596S-5.0', param_name: '使能阈值', param_value: '1.4', param_unit: 'V', constraint_type: 'hard', param_note: 'ON/OFF 引脚阈值' } },
|
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '使能阈值' }, values: { internal_pn: 'LM2596S-5.0', param_name: '使能阈值', param_value: '1.4', param_unit: 'V', param_note: 'ON/OFF 引脚阈值' } },
|
||||||
{
|
{
|
||||||
address: { domain: 'parts', object: 'profile' },
|
address: { domain: 'parts', object: 'profile' },
|
||||||
key: { internal_pn: 'LM2596S-ADJ' },
|
key: { internal_pn: 'LM2596S-ADJ' },
|
||||||
values: {
|
values: {
|
||||||
internal_pn: 'LM2596S-ADJ',
|
internal_pn: 'LM2596S-ADJ',
|
||||||
|
chip_type_id: 1,
|
||||||
brand_name: 'TI',
|
brand_name: 'TI',
|
||||||
manufacturer_name: 'Texas Instruments',
|
manufacturer_name: 'Texas Instruments',
|
||||||
specification_description: '降压型 DCDC 稳压器,3A,可调输出 1.23~37V,150kHz(LM2596 同族)',
|
specification_description: '降压型 DCDC 稳压器,3A,可调输出 1.23~37V,150kHz(LM2596 同族)',
|
||||||
@@ -522,13 +563,31 @@ export const MASTER_SAMPLE_ROWS: readonly { address: LedgerAddress; key: LedgerK
|
|||||||
application_scenarios: '工业电源;消费电源',
|
application_scenarios: '工业电源;消费电源',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '输入电压范围' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '输入电压范围', param_value: '4.5~40', param_unit: 'V', constraint_type: 'hard', param_note: '与固定版同' } },
|
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '输入电压范围' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '输入电压范围', param_value: '4.5~40', param_unit: 'V', param_note: '与固定版同' } },
|
||||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '输出电压' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '输出电压', param_value: '1.23~37', param_unit: 'V', constraint_type: 'hard', param_note: '可调版,靠外围反馈网络定' } },
|
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '输出电压' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '输出电压', param_value: '1.23~37', param_unit: 'V', param_note: '可调版,靠外围反馈网络定' } },
|
||||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '输出电流' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '输出电流', param_value: '3', param_unit: 'A', constraint_type: 'hard', param_note: '满载能力' } },
|
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '输出电流' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '输出电流', param_value: '3', param_unit: 'A', param_note: '满载能力' } },
|
||||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '开关频率' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '开关频率', param_value: '150', param_unit: 'kHz', constraint_type: 'hard', param_note: '影响外围电感/电容' } },
|
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '开关频率' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '开关频率', param_value: '150', param_unit: 'kHz', param_note: '影响外围电感/电容' } },
|
||||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '静态电流' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '静态电流', param_value: '5', param_unit: 'mA', constraint_type: 'soft', param_note: '待机功耗' } },
|
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '静态电流' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '静态电流', param_value: '5', param_unit: 'mA', param_note: '待机功耗' } },
|
||||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '工作温度' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '工作温度', param_value: '-40~+85', param_unit: '℃', constraint_type: 'hard', param_note: '工业温域' } },
|
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '工作温度' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '工作温度', param_value: '-40~+85', param_unit: '℃', param_note: '工业温域' } },
|
||||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '使能阈值' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '使能阈值', param_value: '1.4', param_unit: 'V', constraint_type: 'hard', param_note: 'ON/OFF 引脚阈值' } },
|
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '使能阈值' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '使能阈值', param_value: '1.4', param_unit: 'V', param_note: 'ON/OFF 引脚阈值' } },
|
||||||
|
/*
|
||||||
|
* **AMS1117-3.3**(LDO 类型,chip_type_id: 2)—— 「两颗料类型不一致 → 硬拦」那条路的
|
||||||
|
* 唯一素材:不播这颗,「原 PN 是 DC-DC、候选是 LDO,字段清单对不上」这条判断就没有真数据可用。
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
address: { domain: 'parts', object: 'profile' },
|
||||||
|
key: { internal_pn: 'AMS1117-3.3' },
|
||||||
|
values: {
|
||||||
|
internal_pn: 'AMS1117-3.3', chip_type_id: 2, brand_name: 'AMS',
|
||||||
|
manufacturer_name: 'Advanced Monolithic Systems', specification_description: '3.3V 固定输出低压差线性稳压器,SOT-223',
|
||||||
|
lifecycle_status: 'active', datasheet_ref: 'https://example.invalid/ams1117', datasheet_version: 'Rev.C',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'AMS1117-3.3', param_name: '输入电压范围' }, values: { internal_pn: 'AMS1117-3.3', param_name: '输入电压范围', param_value: '4.75~15', param_unit: 'V', param_note: '最低受压差限制' } },
|
||||||
|
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'AMS1117-3.3', param_name: '输出电压' }, values: { internal_pn: 'AMS1117-3.3', param_name: '输出电压', param_value: '3.3', param_unit: 'V', param_note: '固定版' } },
|
||||||
|
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'AMS1117-3.3', param_name: '输出电流' }, values: { internal_pn: 'AMS1117-3.3', param_name: '输出电流', param_value: '1', param_unit: 'A', param_note: '受散热限制' } },
|
||||||
|
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'AMS1117-3.3', param_name: '压差' }, values: { internal_pn: 'AMS1117-3.3', param_name: '压差', param_value: '1.3', param_unit: 'V', param_note: '满载时' } },
|
||||||
|
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'AMS1117-3.3', param_name: '工作温度' }, values: { internal_pn: 'AMS1117-3.3', param_name: '工作温度', param_value: '-40~+125', param_unit: '℃', param_note: '' } },
|
||||||
{
|
{
|
||||||
address: { domain: 'parts', object: 'document' },
|
address: { domain: 'parts', object: 'document' },
|
||||||
key: { internal_pn: 'LM2596S-5.0', doc_seq: '01' },
|
key: { internal_pn: 'LM2596S-5.0', doc_seq: '01' },
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import type { Container } from './container.ts';
|
|||||||
import { installRealActivities } from './seed-activities.ts';
|
import { installRealActivities } from './seed-activities.ts';
|
||||||
import { installSlots } from './seed-mvp-slots.ts';
|
import { installSlots } from './seed-mvp-slots.ts';
|
||||||
import { seedMvpFlow } from './seed-mvp-flow.ts';
|
import { seedMvpFlow } from './seed-mvp-flow.ts';
|
||||||
import { W05_TOOLS } from './tools-w05.ts';
|
|
||||||
|
|
||||||
/** 第一方功能工具:查历史价 —— MVP 用录制回放替身代真 CRM(README:mock 只在外界) */
|
/** 第一方功能工具:查历史价 —— MVP 用录制回放替身代真 CRM(README:mock 只在外界) */
|
||||||
const HISTORY_TOOL = {
|
const HISTORY_TOOL = {
|
||||||
@@ -329,7 +328,7 @@ export async function seedMvp(container: Container): Promise<void> {
|
|||||||
log(`① LLM 实例已绑定:deepseek-v4-flash · deepseek-v4-pro${apiKey === undefined ? '(环境无密钥 —— 两条都是未绑定,去治理台补)' : ''}`);
|
log(`① LLM 实例已绑定:deepseek-v4-flash · deepseek-v4-pro${apiKey === undefined ? '(环境无密钥 —— 两条都是未绑定,去治理台补)' : ''}`);
|
||||||
|
|
||||||
/* ② 功能工具注册两审 + 逐槽位白名单 */
|
/* ② 功能工具注册两审 + 逐槽位白名单 */
|
||||||
for (const contract of [HISTORY_TOOL, MAIL_TOOL, CLASSIFY_TOOL, PLACEHOLDER_TOOL, COVERAGE_SCORE_TOOL, RANK_KEY_PERSONS_TOOL, ...W05_TOOLS]) {
|
for (const contract of [HISTORY_TOOL, MAIL_TOOL, CLASSIFY_TOOL, PLACEHOLDER_TOOL, COVERAGE_SCORE_TOOL, RANK_KEY_PERSONS_TOOL]) {
|
||||||
const verdict = await container.toolbox.register({
|
const verdict = await container.toolbox.register({
|
||||||
contract,
|
contract,
|
||||||
samples: [
|
samples: [
|
||||||
@@ -360,7 +359,7 @@ export async function seedMvp(container: Container): Promise<void> {
|
|||||||
for (const [activityId, tools] of Object.entries(ALLOWLIST)) {
|
for (const [activityId, tools] of Object.entries(ALLOWLIST)) {
|
||||||
await container.toolbox.setAllowlist(activityId, tools);
|
await container.toolbox.setAllowlist(activityId, tools);
|
||||||
}
|
}
|
||||||
log('② 功能工具已注册并入白名单(读类 1 · 写类 1 · 纯工具 6,逐活动授权)');
|
log('② 功能工具已注册并入白名单(读类 1 · 写类 1 · 纯工具 4,逐活动授权)');
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ③ 活动装入 —— 两批:
|
* ③ 活动装入 —— 两批:
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
/**
|
|
||||||
* ACT-WORK-05 差异评估的两枚纯工具契约(活动私有作用域)。
|
|
||||||
* 两处消费:`seed-mvp.ts`(db:seed 全套播种)与 `seed-activities.ts`(activities:install 装活动时顺带注册 ——
|
|
||||||
* 「安装即用」包含它声明过的纯工具能调得动;从前 E04 的工具只在 db:seed 里注册,开发机上只跑
|
|
||||||
* activities:install 的人第一次跑活动就撞「工具不存在」)。
|
|
||||||
*/
|
|
||||||
const PURE = {
|
|
||||||
effect: 'read' as const,
|
|
||||||
errors: [] as Array<{ code: string; retryable: boolean; message: string }>,
|
|
||||||
idempotent: true,
|
|
||||||
timeoutMs: 5_000,
|
|
||||||
retries: 0,
|
|
||||||
connectionKeys: [] as string[],
|
|
||||||
pure: true,
|
|
||||||
ownerActivityId: 'part-substitution-assessment',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 逐参数比对 —— 出参键与台账列同名(difference_status / risk_level),模型照抄 */
|
|
||||||
export const COMPARE_PARAMETERS_TOOL = {
|
|
||||||
...PURE,
|
|
||||||
name: 'part-substitution-assessment.compare-parameters',
|
|
||||||
displayName: '替代料逐参数比对',
|
|
||||||
/*
|
|
||||||
* 1.1.0(2026-08-27):客户约束原 PN 自己也不满足、候选与原 PN 相等或盖住时不再判 mismatch —— 那是需求边界,不是替代差异。
|
|
||||||
* 1.2.0(同日):**比对入参由基座从台账取** —— 给 originalInternalPn + candidates[].candidateInternalPn,参数 / 封装 / 生命周期 /
|
|
||||||
* 证据引用从 parts.* 现读,模型只译客户约束(requirements);老形状(自带 pairs)照旧接。出参每项带 originalValue / candidateValue / evidenceRef。
|
|
||||||
*/
|
|
||||||
version: '1.3.0',
|
|
||||||
description:
|
|
||||||
'按硬/软约束逐参数比对原 PN 与候选 PN,输出四态 difference_status(match/mismatch/unknown/conditional)、' +
|
|
||||||
'**值的读法**:「下限~上限」一律是范围(4.5~40 = 4.5 到 40 的整段;-40~+85 = -40 到 85 的整段,不是两个数),' +
|
|
||||||
'分隔符认 ~ - … 至 到 to,单位写进值里也认;「≥45 / 45 以上」「≤40 / 40 以内」是单边界;只有一个数是定值。出参每项 readAs 摆出两边各读成了什么。' +
|
|
||||||
'compare_result、risk_level 与各候选的计数。**推荐用法**:只给 originalInternalPn 与 candidates[].candidateInternalPn,' +
|
|
||||||
'参数行从 parts.parameter、封装与生命周期从 parts.profile、证据引用从 parts.document 由工具现读(本单草稿层也算),模型不抄值;' +
|
|
||||||
'candidates[].requirements 是客户给了边界的参数 {参数名: ">= 40" / "-40~85"}。也接老形状(candidates[].pairs 自带值)。' +
|
|
||||||
'硬约束没有证据引用不判 match。候选不比原 PN 差(相等 / 盖住)而客户约束原 PN 自己也不满足的,不算替代差异:仍按比对结果判、备注点名需求边界。规则版本 R-2026.07。',
|
|
||||||
input: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
originalInternalPn: { type: 'string', description: '原 PN(内部料号);按料号从台账取参数时必填' },
|
|
||||||
candidates: {
|
|
||||||
type: 'array',
|
|
||||||
description:
|
|
||||||
'[{candidateInternalPn, requirements?: {参数名: 约束}, evidenceRef?}](从台账取值) 或 ' +
|
|
||||||
'[{candidateInternalPn, pairs:[{parameterName, originalValue, candidateValue, unit?, constraintType:"hard"|"soft", requirement?, evidenceRef?}]}](自带值)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
output: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
ruleVersion: { type: 'string' },
|
|
||||||
results: {
|
|
||||||
type: 'array',
|
|
||||||
description: '[{candidateInternalPn, source:"ledger"|"pairs", items:[{parameterName, originalValue, candidateValue, readAs:{original,candidate}, unit, constraintType, requirement, evidenceRef, compareResult, differenceStatus, riskLevel, note}], counts:{match,mismatch,unknown,conditional,hardMismatch,softMismatch,configurable}, extraCandidateParams?}]',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 推荐定级 —— 出参 recommendationLevel / riskLevel / reason 直接落 P-11 */
|
|
||||||
export const RECOMMEND_TOOL = {
|
|
||||||
...PURE,
|
|
||||||
name: 'part-substitution-assessment.recommend',
|
|
||||||
displayName: '替代推荐定级',
|
|
||||||
version: '1.0.0',
|
|
||||||
description:
|
|
||||||
'按每个候选的比对计数定推荐等级:硬约束 mismatch>0 → not_recommended;unknown>0 → conditional;' +
|
|
||||||
'只有可调配成(configurable)→ alternate;其余 conditional → conditional;全 match → recommended。',
|
|
||||||
input: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
candidates: { type: 'array', description: '[{candidateInternalPn, counts:{match,mismatch,unknown,conditional,hardMismatch,softMismatch,configurable}}]' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
output: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
ruleVersion: { type: 'string' },
|
|
||||||
results: { type: 'array', description: '[{candidateInternalPn, recommendationLevel, riskLevel, reason}]' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const W05_TOOLS = [COMPARE_PARAMETERS_TOOL, RECOMMEND_TOOL] as const;
|
|
||||||
|
|
||||||
/** 试运行审的样例(两件共用一份:并集覆盖各家入参) */
|
|
||||||
export const W05_TOOL_SAMPLE = {
|
|
||||||
input: {
|
|
||||||
candidates: [
|
|
||||||
{
|
|
||||||
candidateInternalPn: 'XL2596S-5.0',
|
|
||||||
pairs: [{ parameterName: '输出电压', originalValue: '5.0', candidateValue: '5.0', unit: 'V', constraintType: 'hard', evidenceRef: 'P-08' }],
|
|
||||||
counts: { match: 1, mismatch: 0, unknown: 0, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
expectShape: { type: 'object' },
|
|
||||||
};
|
|
||||||
@@ -4,7 +4,6 @@
|
|||||||
* 运行时受限:只拿得到**注册申报的连接目标**,没有其它环境能力 —— 这是 `ToolRuntime` 保证的。
|
* 运行时受限:只拿得到**注册申报的连接目标**,没有其它环境能力 —— 这是 `ToolRuntime` 保证的。
|
||||||
*/
|
*/
|
||||||
import { evaluateUnaryTest } from '@sfeel';
|
import { evaluateUnaryTest } from '@sfeel';
|
||||||
import type { TaskId } from '@contracts';
|
|
||||||
import type { ToolHandler } from '@modules/toolbox';
|
import type { ToolHandler } from '@modules/toolbox';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -333,338 +332,24 @@ const slotPlaceholder: ToolHandler = async ({ input }) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ── ACT-WORK-05 差异评估:逐参数比对 —— 纯工具(活动私有作用域)─────────────────
|
/* ── ACT-WORK-05 差异评估:逐参数比对与推荐定级 —— 已退役(2026-08-29)──────────
|
||||||
*
|
*
|
||||||
* 「能算的不问 LLM」:同输入必同结论。规则与活动包 manifest 里的 D01/D02 两张 DMN 同一份;
|
* 【两枚工具的墓志铭】`part-substitution-assessment.compare-parameters`(逐参数四态判定:
|
||||||
* 这里是求值器,那里是随包冻结的文档。`RULE_VERSION` 随结论落账(rule_version)。
|
* MATCH / MISMATCH / UNKNOWN / CONDITIONAL,按硬/软约束与客户需求边界比对原 PN 与候选料)
|
||||||
|
* 与 `part-substitution-assessment.recommend`(按四态命中条数定推荐等级:推荐 / 条件推荐 /
|
||||||
|
* 备选 / 不推荐)曾经跑在这里,与活动包 manifest 里冻结的 D01(逐参数四态)/ D02(推荐等级)
|
||||||
|
* 两张 DMN 是同一份规则的两处真源。
|
||||||
*
|
*
|
||||||
* 比对语义(按硬/软约束):
|
* ADR-014(确定性步骤)落地后,这两步不再需要工具调用:四态判定是**整个活动唯一一次
|
||||||
* equal / covers(候选范围 ⊇ 原范围) → match,低风险;
|
* 模型调用**(`case-model.ts` 的 `S2_assess.auto` 用 `analyze` 动词,系统组装入参、模型只答
|
||||||
* configurable(原是定值、候选是含它的可调范围)→ conditional(要改外围);
|
* 判断,不转述台账里已有的值),推荐等级是**能算的事**,由同一步 `do` 链里的投影按
|
||||||
* narrower / differs / partial → 硬:mismatch 高风险;软:conditional 中风险;
|
* spec §3.5 的 S-FEEL when 链现算(`judgement_class` / `recommendation_level` 两条合成列)。
|
||||||
* 任一侧未披露 → unknown 中风险;
|
* `D01` / `D02` 两张 DMN 同批退役,理由相同 —— 再留一份 DMN 或一枚工具,就是同一件事
|
||||||
* 硬约束 equal/covers 但没给 evidenceRef → conditional(硬约束未经证据支持不得判 MATCH);
|
* 两处各存一份,改一处就分叉而没人看得出以哪份为准。
|
||||||
* requirement(客户约束,如 ">= 40")候选不满足 → 一律 mismatch 高风险。
|
*
|
||||||
|
* 完整实现见 git 历史(本文件此段落之前的版本);规则表见活动包 spec §3.5,
|
||||||
|
* 求值逻辑见 `activities/part-substitution-assessment/src/case-model.ts`。
|
||||||
*/
|
*/
|
||||||
export const SUBSTITUTION_RULE_VERSION = 'R-2026.07';
|
|
||||||
|
|
||||||
type Parsed = { kind: 'number'; value: number } | { kind: 'range'; lo: number; hi: number } | { kind: 'text'; value: string } | { kind: 'unknown' };
|
|
||||||
type CompareResult = 'equal' | 'covers' | 'configurable' | 'narrower' | 'partial' | 'differs' | 'unknown';
|
|
||||||
type DifferenceStatus = 'match' | 'mismatch' | 'unknown' | 'conditional';
|
|
||||||
type RiskLevel = 'low' | 'medium' | 'high';
|
|
||||||
type ParamPair = {
|
|
||||||
parameterName: string;
|
|
||||||
originalValue: string;
|
|
||||||
candidateValue: string;
|
|
||||||
unit?: string;
|
|
||||||
constraintType: 'hard' | 'soft';
|
|
||||||
/**
|
|
||||||
* 这两格**模型爱给 null**(「这一项没有客户约束」它写 `requirement: null` 而不是省掉)——
|
|
||||||
* 工具入参是宽松描述式 schema,null 一路落到这里,`null.trim()` 当场崩,
|
|
||||||
* 整个工具调用记成「行动被拒」(2026-08-27 W05 e2e 实测,一单撞两次)。
|
|
||||||
* 类型如实写成可空,取值一律经 `text()`:**边界上的东西按它真会来的样子接**。
|
|
||||||
*/
|
|
||||||
requirement?: string | null;
|
|
||||||
evidenceRef?: string | null;
|
|
||||||
};
|
|
||||||
/** 边界上的字符串:null / undefined / 非字符串一律归成空串(模型给 null 是常态,见 ParamPair 上的注) */
|
|
||||||
function text(v: unknown): string {
|
|
||||||
return typeof v === 'string' ? v.trim() : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
type CompareCounts = { match: number; mismatch: number; unknown: number; conditional: number; hardMismatch: number; softMismatch: number; configurable: number };
|
|
||||||
|
|
||||||
const UNKNOWN_WORDS = /^(未披露|未知|未给出|unknown|n\/?a|tbd|待定|-|—|–)$/i;
|
|
||||||
const NUM = '[-+]?\\d+(?:\\.\\d+)?';
|
|
||||||
/** 数字后面允许跟一段单位(V / ℃ / °C / kHz / % / mA…)—— 「4.5V~40V」「-40℃~+85℃」这种把单位写进值里的形状要认成范围 */
|
|
||||||
const UNIT = '(?:\\s*[A-Za-z℃°%μµΩ][A-Za-z℃°%μµΩ0-9/]*)?';
|
|
||||||
const RANGE_RE = new RegExp(`^(${NUM})${UNIT}\\s*(?:~|~|-|–|—|…|\\.\\.\\.|至|到|to)\\s*(${NUM})`, 'i');
|
|
||||||
const NUM_RE = new RegExp(`^(${NUM})`);
|
|
||||||
/** 单边界:「≥45」「>= 45」「45 以上」→ [45, ∞);「≤40」「<= 40」「40 以内 / 以下」→ (-∞, 40] */
|
|
||||||
const GE_RE = new RegExp(`^(?:>=|≥)\\s*(${NUM})|^(${NUM})${UNIT}\\s*(?:以上|及以上|起)$`);
|
|
||||||
const LE_RE = new RegExp(`^(?:<=|≤)\\s*(${NUM})|^(${NUM})${UNIT}\\s*(?:以内|以下|及以下|以内)$`);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 参数值怎么读(2026-08-28 用户:「4.5~40 / -40~+85 这种我怕模型搞不清楚,harness 里强调一下是范围值」):
|
|
||||||
* 「下限~上限」一律是**范围**——分隔符认 ~ ~ - – — … ... 至 到 to,正负号认 + - 与 Unicode 减号(−),
|
|
||||||
* 单位写进值里(4.5V~40V / -40℃~+85℃)也认;「≥45 / 45 以上」「≤40 / 40 以内」是单边界,读成半开范围;
|
|
||||||
* 只有一个数是定值;读不出数的按文本原样比。出参会把「读作什么」摆回去,模型与人都看得见。
|
|
||||||
*/
|
|
||||||
export function parseParamValue(raw: unknown): Parsed {
|
|
||||||
const s = String(raw ?? '')
|
|
||||||
.trim()
|
|
||||||
.replace(/[\u2212\u2010\u2011\u2012]/g, '-') // Unicode 减号 / 连字号 → ASCII 减号
|
|
||||||
.replace(/\s+/g, ' ');
|
|
||||||
if (s === '' || UNKNOWN_WORDS.test(s)) return { kind: 'unknown' };
|
|
||||||
const r = RANGE_RE.exec(s);
|
|
||||||
if (r !== null) {
|
|
||||||
const lo = Number(r[1]);
|
|
||||||
const hi = Number(r[2]);
|
|
||||||
if (Number.isFinite(lo) && Number.isFinite(hi)) return { kind: 'range', lo: Math.min(lo, hi), hi: Math.max(lo, hi) };
|
|
||||||
}
|
|
||||||
const ge = GE_RE.exec(s);
|
|
||||||
if (ge !== null) return { kind: 'range', lo: Number(ge[1] ?? ge[2]), hi: Number.POSITIVE_INFINITY };
|
|
||||||
const le = LE_RE.exec(s);
|
|
||||||
if (le !== null) return { kind: 'range', lo: Number.NEGATIVE_INFINITY, hi: Number(le[1] ?? le[2]) };
|
|
||||||
const n = NUM_RE.exec(s);
|
|
||||||
if (n !== null && Number.isFinite(Number(n[1]))) return { kind: 'number', value: Number(n[1]) };
|
|
||||||
return { kind: 'text', value: s.toLowerCase() };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 「读作什么」—— 回给模型与人看的一句话,不是给机器再解析的 */
|
|
||||||
export function describeParsed(p: Parsed): string {
|
|
||||||
if (p.kind === 'range') {
|
|
||||||
if (p.lo === Number.NEGATIVE_INFINITY) return `上界 ≤ ${p.hi}`;
|
|
||||||
if (p.hi === Number.POSITIVE_INFINITY) return `下界 ≥ ${p.lo}`;
|
|
||||||
return `范围 ${p.lo}~${p.hi}`;
|
|
||||||
}
|
|
||||||
if (p.kind === 'number') return `定值 ${p.value}`;
|
|
||||||
if (p.kind === 'unknown') return '未披露';
|
|
||||||
// 带数字又带范围分隔符却没读成范围的(如「4.5V 到 40 伏」),提示怎么写;纯文本(封装 TO-263-5)不啰嗦
|
|
||||||
const looksLikeRange = /\d[^\d]{0,6}(?:[~~…]|至|到|\bto\b)[^\d]{0,6}\d/.test(p.value) || /^[^\d]+\d.*(?:[~~…]|至|到)/.test(p.value);
|
|
||||||
return `文本「${p.value}」${looksLikeRange ? '(没读成范围;本意是范围请写成 下限~上限,单位放单位列)' : ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareParsed(o: Parsed, c: Parsed): CompareResult {
|
|
||||||
if (o.kind === 'unknown' || c.kind === 'unknown') return 'unknown';
|
|
||||||
if (o.kind === 'range' && c.kind === 'range') {
|
|
||||||
if (c.lo === o.lo && c.hi === o.hi) return 'equal';
|
|
||||||
if (c.lo <= o.lo && c.hi >= o.hi) return 'covers';
|
|
||||||
if (c.lo >= o.lo && c.hi <= o.hi) return 'narrower';
|
|
||||||
return c.hi < o.lo || c.lo > o.hi ? 'differs' : 'partial';
|
|
||||||
}
|
|
||||||
if (o.kind === 'number' && c.kind === 'number') return Math.abs(o.value - c.value) <= 1e-9 ? 'equal' : 'differs';
|
|
||||||
if (o.kind === 'number' && c.kind === 'range') return c.lo <= o.value && o.value <= c.hi ? 'configurable' : 'differs';
|
|
||||||
if (o.kind === 'range' && c.kind === 'number') return o.lo <= c.value && c.value <= o.hi ? 'narrower' : 'differs';
|
|
||||||
if (o.kind === 'text' && c.kind === 'text') return o.value === c.value ? 'equal' : 'differs';
|
|
||||||
return 'differs';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 客户约束:">= 40" / "<= 85" / "-40~85"(候选必须盖住);解析不了当没给 */
|
|
||||||
function satisfiesRequirement(requirement: string | null | undefined, c: Parsed): boolean | undefined {
|
|
||||||
const s = text(requirement);
|
|
||||||
if (s === '' || c.kind === 'unknown' || c.kind === 'text') return undefined;
|
|
||||||
const ge = new RegExp(`^(?:>=|≥)\\s*(${NUM})$`).exec(s);
|
|
||||||
const le = new RegExp(`^(?:<=|≤)\\s*(${NUM})$`).exec(s);
|
|
||||||
const hi = c.kind === 'range' ? c.hi : c.value;
|
|
||||||
const lo = c.kind === 'range' ? c.lo : c.value;
|
|
||||||
if (ge !== null) return hi >= Number(ge[1]);
|
|
||||||
if (le !== null) return lo <= Number(le[1]);
|
|
||||||
const req = parseParamValue(s);
|
|
||||||
if (req.kind === 'range') return lo <= req.lo && hi >= req.hi;
|
|
||||||
if (req.kind === 'number') return lo <= req.value && hi >= req.value;
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 生命周期不是数值:候选比原料「更活」算 covers(替 NRND 料本就是为了这个),更差才是 differs */
|
|
||||||
const LIFECYCLE_ORDER: Record<string, number> = { active: 3, nrnd: 2, risk: 2, eol: 1, discontinued: 0 };
|
|
||||||
function compareLifecycle(o: string, c: string): CompareResult | undefined {
|
|
||||||
const ro = LIFECYCLE_ORDER[text(o).toLowerCase()];
|
|
||||||
const rc = LIFECYCLE_ORDER[text(c).toLowerCase()];
|
|
||||||
if (ro === undefined || rc === undefined) return undefined;
|
|
||||||
return rc === ro ? 'equal' : rc > ro ? 'covers' : 'differs';
|
|
||||||
}
|
|
||||||
|
|
||||||
function judgePair(p: ParamPair): { compareResult: CompareResult; differenceStatus: DifferenceStatus; riskLevel: RiskLevel; note: string } {
|
|
||||||
const o = parseParamValue(p.originalValue);
|
|
||||||
const c = parseParamValue(p.candidateValue);
|
|
||||||
const hard = p.constraintType === 'hard';
|
|
||||||
let compareResult = compareParsed(o, c);
|
|
||||||
if (/生命周期|lifecycle/i.test(p.parameterName)) compareResult = compareLifecycle(p.originalValue, p.candidateValue) ?? compareResult;
|
|
||||||
const req = satisfiesRequirement(p.requirement, c);
|
|
||||||
/*
|
|
||||||
* **客户约束原 PN 自己也不满足时,不是替代差异**(2026-08-27 晚,用户送来 0bddd862:「输入电压范围两个
|
|
||||||
* 不是一样的吗为什么不匹配」):客户写「输入电压在 45V 以内」,模型译成 `>= 45`,而原 PN 与候选的输入上限
|
|
||||||
* 都是 40V —— 从前这里一票否决,候选与在用料一模一样却被记成硬约束 MISMATCH、整颗不推荐。
|
|
||||||
* 差异评估比的是「换上去会不会比现在差」:候选与原 PN 相等 / 盖住原范围,且这条客户边界**原 PN 一样过不去**,
|
|
||||||
* 那是需求边界与在用料之间的差距(该回到需求核对 / 验证),不该记在候选头上。
|
|
||||||
* 候选比原 PN 窄 / 不同而过不了客户线,仍是 MISMATCH;原 PN 一侧算不出(未披露 / 文本)也仍按候选过不了线判。
|
|
||||||
*/
|
|
||||||
if (req === false) {
|
|
||||||
const parity = compareResult === 'equal' || compareResult === 'covers';
|
|
||||||
const originalFailsToo = parity && satisfiesRequirement(p.requirement, o) === false;
|
|
||||||
if (!originalFailsToo) {
|
|
||||||
// 客户约束不满足:比对结果照实留(narrower/differs),结论一律 MISMATCH 高风险
|
|
||||||
return { compareResult: compareResult === 'unknown' ? 'differs' : compareResult, differenceStatus: 'mismatch', riskLevel: 'high', note: `不满足客户约束 ${text(p.requirement)}` };
|
|
||||||
}
|
|
||||||
const boundary = `客户约束 ${text(p.requirement)} 原 PN 与候选都不满足 —— 是需求边界与在用料的差距,不是替代差异,请回头核对需求`;
|
|
||||||
if (hard && text(p.evidenceRef) === '') {
|
|
||||||
return { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: `硬约束缺证据引用:未经证据支持不判 MATCH;${boundary}` };
|
|
||||||
}
|
|
||||||
return { compareResult, differenceStatus: 'match', riskLevel: 'medium', note: `${compareResult === 'covers' ? '候选范围盖住原范围' : '与原 PN 一致'};${boundary}` };
|
|
||||||
}
|
|
||||||
if (compareResult === 'unknown') return { compareResult, differenceStatus: 'unknown', riskLevel: 'medium', note: '一侧未披露:标 UNKNOWN,挂验证任务或补来源' };
|
|
||||||
if (compareResult === 'equal' || compareResult === 'covers') {
|
|
||||||
if (hard && text(p.evidenceRef) === '') {
|
|
||||||
return { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '硬约束缺证据引用:未经证据支持不判 MATCH' };
|
|
||||||
}
|
|
||||||
return { compareResult, differenceStatus: 'match', riskLevel: 'low', note: compareResult === 'covers' ? '候选范围盖住原范围' : '一致' };
|
|
||||||
}
|
|
||||||
if (compareResult === 'configurable') return { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '候选可调,须改外围把它配成原值' };
|
|
||||||
if (compareResult === 'partial') return { compareResult, differenceStatus: 'conditional', riskLevel: hard ? 'medium' : 'low', note: '范围部分重叠,须按应用边界确认' };
|
|
||||||
compareResult = compareResult === 'narrower' ? 'narrower' : 'differs';
|
|
||||||
return hard
|
|
||||||
? { compareResult, differenceStatus: 'mismatch', riskLevel: 'high', note: '硬约束不一致' }
|
|
||||||
: { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '软约束不一致,须评估影响' };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 比对工具读台账用的口 —— 按地址与等值条件取行(taskId 有就取任务视图 = 正式版 + 本单草稿,没有就只读正式版)。
|
|
||||||
* 只读、不写;由 app 用 ledger 服务实装,测试给假的。
|
|
||||||
*/
|
|
||||||
export type ParameterLedger = {
|
|
||||||
rows(taskId: TaskId | undefined, address: { domain: string; object: string }, where: Record<string, string>): Promise<ReadonlyArray<Record<string, unknown>>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CandidateInput = {
|
|
||||||
candidateInternalPn: string;
|
|
||||||
pairs?: ParamPair[] | null;
|
|
||||||
/** 客户给了边界的参数:{ 参数名: ">= 40" / "-40~85" } —— 客户约束在需求原文里,归模型译;值不归它 */
|
|
||||||
requirements?: Record<string, unknown> | null;
|
|
||||||
evidenceRef?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* **比对入参由基座从台账取,模型只报料号与客户约束**(2026-08-27 晚,用户:「这个做差异评估比对的 harness 肯定有问题,
|
|
||||||
* 要让它认真比对每一条」)。从前 pairs 由模型从 `substitution.difference_item` 抄给工具:实测它把剧本示例里的
|
|
||||||
* 「4.5~32 / >= 40」当成这单的值念出来,而抄了什么**不留痕**(只存 inputHash),事后没法审。
|
|
||||||
* 现在给 originalInternalPn + candidateInternalPn,参数行从 `parts.parameter` 现读、封装与生命周期从 `parts.profile` 现读、
|
|
||||||
* 证据引用没给就从 `parts.document` 取 —— 每一对都是台账里的原值;出参把 originalValue / candidateValue / evidenceRef
|
|
||||||
* 一并摆出来,写盘照抄即可。老形状(自带 pairs)照旧接,试运行与老调用方不受影响。
|
|
||||||
*/
|
|
||||||
async function pairsFromLedger(ledger: ParameterLedger, taskId: TaskId | undefined, original: string, candidate: string, cand: CandidateInput) {
|
|
||||||
const PARAM = { domain: 'parts', object: 'parameter' };
|
|
||||||
const PROFILE = { domain: 'parts', object: 'profile' };
|
|
||||||
const DOC = { domain: 'parts', object: 'document' };
|
|
||||||
const [oParams, cParams, oProfiles, cProfiles] = await Promise.all([
|
|
||||||
ledger.rows(taskId, PARAM, { internal_pn: original }),
|
|
||||||
ledger.rows(taskId, PARAM, { internal_pn: candidate }),
|
|
||||||
ledger.rows(taskId, PROFILE, { internal_pn: original }),
|
|
||||||
ledger.rows(taskId, PROFILE, { internal_pn: candidate }),
|
|
||||||
]);
|
|
||||||
const oProfile = oProfiles[0];
|
|
||||||
const cProfile = cProfiles[0];
|
|
||||||
if (oParams.length === 0 && oProfile === undefined) {
|
|
||||||
return { error: `原 PN ${original} 在台账里没有主数据也没有参数行 —— 先把它建进 parts.profile / parts.parameter(数据工单),再比` };
|
|
||||||
}
|
|
||||||
if (cParams.length === 0 && cProfile === undefined) {
|
|
||||||
return { error: `候选 ${candidate} 在台账里没有主数据也没有参数行 —— 先建进 parts.profile / parts.parameter,再比` };
|
|
||||||
}
|
|
||||||
let evidenceRef = text(cand.evidenceRef);
|
|
||||||
if (evidenceRef === '') {
|
|
||||||
const docs = await ledger.rows(taskId, DOC, { internal_pn: candidate });
|
|
||||||
const sheet = docs.find((d) => text(d['document_type']) === 'datasheet' && text(d['document_ref']) !== '') ?? docs.find((d) => text(d['document_ref']) !== '');
|
|
||||||
if (sheet !== undefined) evidenceRef = `P-08 · P-10 ${text(sheet['document_version'])}`.trim();
|
|
||||||
}
|
|
||||||
const requirements = cand.requirements !== null && typeof cand.requirements === 'object' ? cand.requirements : {};
|
|
||||||
const requirementOf = (name: string) => text(requirements[name]);
|
|
||||||
const byName = new Map(cParams.map((r) => [text(r['param_name']), r] as const));
|
|
||||||
const sorted = [...oParams].sort((a, b) => text(a['param_name']).localeCompare(text(b['param_name']), 'zh-Hans-CN'));
|
|
||||||
const pairs: ParamPair[] = sorted
|
|
||||||
.filter((o) => text(o['param_name']) !== '')
|
|
||||||
.map((o) => {
|
|
||||||
const name = text(o['param_name']);
|
|
||||||
const c = byName.get(name);
|
|
||||||
return {
|
|
||||||
parameterName: name,
|
|
||||||
originalValue: text(o['param_value']),
|
|
||||||
candidateValue: c === undefined ? '' : text(c['param_value']),
|
|
||||||
unit: text(o['param_unit']) || (c === undefined ? '' : text(c['param_unit'])),
|
|
||||||
constraintType: text(o['constraint_type']) === 'soft' ? ('soft' as const) : ('hard' as const),
|
|
||||||
requirement: requirementOf(name),
|
|
||||||
evidenceRef,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
// 封装(硬)与生命周期(软)不是参数,住在主数据上 —— 两颗都有档案时才比
|
|
||||||
if (oProfile !== undefined && cProfile !== undefined) {
|
|
||||||
if (text(oProfile['package']) !== '' || text(cProfile['package']) !== '') {
|
|
||||||
pairs.push({ parameterName: '封装', originalValue: text(oProfile['package']), candidateValue: text(cProfile['package']), unit: '', constraintType: 'hard', requirement: requirementOf('封装'), evidenceRef });
|
|
||||||
}
|
|
||||||
if (text(oProfile['lifecycle_status']) !== '' || text(cProfile['lifecycle_status']) !== '') {
|
|
||||||
pairs.push({ parameterName: '生命周期', originalValue: text(oProfile['lifecycle_status']), candidateValue: text(cProfile['lifecycle_status']), unit: '', constraintType: 'soft', requirement: requirementOf('生命周期'), evidenceRef });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const known = new Set(pairs.map((p) => p.parameterName));
|
|
||||||
const extraCandidateParams = [...byName.keys()].filter((n) => n !== '' && !known.has(n));
|
|
||||||
return { pairs, extraCandidateParams };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function compareParametersWith(ledger?: ParameterLedger): ToolHandler {
|
|
||||||
return async ({ input, taskId }) => {
|
|
||||||
const original = text(input['originalInternalPn']);
|
|
||||||
const candidates = Array.isArray(input['candidates']) ? (input['candidates'] as CandidateInput[]) : [];
|
|
||||||
const results: Array<Record<string, unknown>> = [];
|
|
||||||
for (const cand of candidates) {
|
|
||||||
const candidatePn = text(cand.candidateInternalPn);
|
|
||||||
let pairs: ParamPair[];
|
|
||||||
let source: 'pairs' | 'ledger';
|
|
||||||
let extraCandidateParams: string[] = [];
|
|
||||||
if (Array.isArray(cand.pairs) && cand.pairs.length > 0) {
|
|
||||||
pairs = cand.pairs;
|
|
||||||
source = 'pairs';
|
|
||||||
} else {
|
|
||||||
if (ledger === undefined) {
|
|
||||||
return { ok: false, error: { code: 'invalid-input', retryable: false, message: `候选 ${candidatePn} 没给 pairs,而这条通道读不到台账 —— 给 pairs,或经任务里调用` } };
|
|
||||||
}
|
|
||||||
if (original === '') {
|
|
||||||
return { ok: false, error: { code: 'invalid-input', retryable: false, message: '缺 originalInternalPn:按料号从台账取参数,得先说原 PN 是谁' } };
|
|
||||||
}
|
|
||||||
const fetched = await pairsFromLedger(ledger, taskId, original, candidatePn, cand);
|
|
||||||
if ('error' in fetched) return { ok: false, error: { code: 'not-found', retryable: false, message: fetched.error } };
|
|
||||||
pairs = fetched.pairs;
|
|
||||||
extraCandidateParams = fetched.extraCandidateParams;
|
|
||||||
source = 'ledger';
|
|
||||||
}
|
|
||||||
const counts: CompareCounts = { match: 0, mismatch: 0, unknown: 0, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 };
|
|
||||||
const items = pairs.map((p) => {
|
|
||||||
const j = judgePair(p);
|
|
||||||
counts[j.differenceStatus] += 1;
|
|
||||||
if (j.differenceStatus === 'mismatch') counts[p.constraintType === 'hard' ? 'hardMismatch' : 'softMismatch'] += 1;
|
|
||||||
if (j.compareResult === 'configurable') counts.configurable += 1;
|
|
||||||
return {
|
|
||||||
parameterName: p.parameterName,
|
|
||||||
originalValue: text(p.originalValue),
|
|
||||||
candidateValue: text(p.candidateValue),
|
|
||||||
// 两边各读成了什么(范围 / 定值 / 单边界 / 文本)—— 比对的依据摆出来,「4.5~40」是范围不是两个数
|
|
||||||
readAs: { original: describeParsed(parseParamValue(p.originalValue)), candidate: describeParsed(parseParamValue(p.candidateValue)) },
|
|
||||||
unit: p.unit ?? '',
|
|
||||||
constraintType: p.constraintType,
|
|
||||||
requirement: text(p.requirement),
|
|
||||||
evidenceRef: text(p.evidenceRef),
|
|
||||||
...j,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
results.push({
|
|
||||||
candidateInternalPn: candidatePn,
|
|
||||||
source,
|
|
||||||
items,
|
|
||||||
counts,
|
|
||||||
...(extraCandidateParams.length === 0 ? {} : { extraCandidateParams }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { ok: true, output: { ruleVersion: SUBSTITUTION_RULE_VERSION, results } };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 不带台账的那份(pairs 模式):静态名单里挂它,app 用 `compareParametersWith(ledger)` 顶掉 */
|
|
||||||
const compareParameters: ToolHandler = compareParametersWith();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 推荐定级(D02,FIRST):硬 MISMATCH → 不推荐;有 UNKNOWN → 条件推荐(等验证闭环);
|
|
||||||
* 有 configurable(改外围配得成)→ 备选;其余 conditional → 条件推荐;全 MATCH → 推荐。与 manifest 里的 D02 表同一份规则。
|
|
||||||
*/
|
|
||||||
const recommend: ToolHandler = async ({ input }) => {
|
|
||||||
const candidates = Array.isArray(input['candidates']) ? (input['candidates'] as Array<{ candidateInternalPn: string; counts: Partial<CompareCounts> }>) : [];
|
|
||||||
const results = candidates.map((cand) => {
|
|
||||||
const n = (k: keyof CompareCounts) => Number(cand.counts?.[k] ?? 0);
|
|
||||||
if (n('hardMismatch') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'not_recommended', riskLevel: 'high', reason: `硬约束 MISMATCH ${n('hardMismatch')} 项:硬约束失败直接不推荐` };
|
|
||||||
if (n('unknown') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'conditional', riskLevel: 'medium', reason: `UNKNOWN ${n('unknown')} 项待验证闭环,通过后可升级推荐` };
|
|
||||||
if (n('configurable') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'alternate', riskLevel: 'medium', reason: `${n('configurable')} 项须改外围配成原值(另有 ${n('conditional') - n('configurable')} 项条件差异):客户接受改设计则启用` };
|
|
||||||
if (n('conditional') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'conditional', riskLevel: 'medium', reason: `CONDITIONAL ${n('conditional')} 项须按应用边界确认` };
|
|
||||||
return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'recommended', riskLevel: 'low', reason: `${n('match')} 项全部 MATCH` };
|
|
||||||
});
|
|
||||||
return { ok: true, output: { ruleVersion: SUBSTITUTION_RULE_VERSION, results } };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const FIRST_PARTY_TOOLS: ReadonlyArray<readonly [string, ToolHandler]> = [
|
export const FIRST_PARTY_TOOLS: ReadonlyArray<readonly [string, ToolHandler]> = [
|
||||||
['crm.history-price-read', historyPriceRead],
|
['crm.history-price-read', historyPriceRead],
|
||||||
@@ -677,6 +362,4 @@ export const FIRST_PARTY_TOOLS: ReadonlyArray<readonly [string, ToolHandler]> =
|
|||||||
['slot.placeholder', slotPlaceholder],
|
['slot.placeholder', slotPlaceholder],
|
||||||
['crm-relationship-coverage.recompute-score', recomputeScore],
|
['crm-relationship-coverage.recompute-score', recomputeScore],
|
||||||
['crm-relationship-coverage.rank-key-persons', rankKeyPersons],
|
['crm-relationship-coverage.rank-key-persons', rankKeyPersons],
|
||||||
['part-substitution-assessment.compare-parameters', compareParameters],
|
|
||||||
['part-substitution-assessment.recommend', recommend],
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* `analyze` 动词的实现 —— **一次结构化调用,没有第二次自由**(spec §5.2)。
|
||||||
|
*
|
||||||
|
* 与开环 Agent 的分工是死的:那一件能调工具、能写台账;这一件只是个纯函数。
|
||||||
|
* 出参不合 schema 就地重问一次(平台在 agent-runner 上早有这条路),再不成就抛 ——
|
||||||
|
* 抛出去由配方执行器留痕交人,**不重试五遍、不退回模型自由发挥**。
|
||||||
|
*
|
||||||
|
* **用量必须交回去记账**(2026-08-29 终审 C1):`createModelGateway` 自己不做
|
||||||
|
* `addConsumption`,计量全靠调用方给的 `onUsage` —— agent 那两条路都接了,
|
||||||
|
* 只有这一条从前接的是**裸网关**:配方里 analyze 烧的 token 一个数都不计入活动预算,
|
||||||
|
* 时间轴上也没有模型 span(那要 `tracedGateway`,由装配层决定传谁进来)。
|
||||||
|
* spec §5.2 写的是「走现有模型网关(四元绑定、凭证隔离、录制回放、**用量计入预算**)」——
|
||||||
|
* 少了这一条,那句话就只是文档里的一厢情愿。
|
||||||
|
*
|
||||||
|
* **两次都要记**:重问那一次照样烧真钱,漏记等于「失败的调用不要钱」。
|
||||||
|
*/
|
||||||
|
import type { ModelGateway } from '@modules/llm';
|
||||||
|
import type { AnalyzeGateway } from '@modules/loop-engine';
|
||||||
|
import type { TaskId } from '@contracts';
|
||||||
|
|
||||||
|
const RETRY_HINT =
|
||||||
|
'上一次的回答没有按要求给出 JSON。只输出一个 JSON 对象,不要任何解释、不要 markdown 代码围栏。';
|
||||||
|
|
||||||
|
export function createAnalyzeGateway(
|
||||||
|
llm: Pick<ModelGateway, 'generate'>,
|
||||||
|
/** 用量回账 —— 与 `engines.ts` 里 agent 那两条路的 `onUsage` 同一个函数 */
|
||||||
|
onUsage?: (taskId: TaskId, tokens: number) => Promise<void>,
|
||||||
|
): 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<string, unknown>,
|
||||||
|
taskId: input.taskId,
|
||||||
|
label: input.label,
|
||||||
|
};
|
||||||
|
/* 记账**不吞异常**:记不上账 = 预算这道闸失效,宁可当场停下由配方留痕交人,也不要一单无声无息烧穿 */
|
||||||
|
const bill = async (usage: { inputTokens: number; outputTokens: number }): Promise<void> => {
|
||||||
|
if (onUsage === undefined) return;
|
||||||
|
await onUsage(input.taskId, usage.inputTokens + usage.outputTokens);
|
||||||
|
};
|
||||||
|
const first = await llm.generate(call);
|
||||||
|
await bill(first.usage);
|
||||||
|
if (typeof first.object === 'object' && first.object !== null) {
|
||||||
|
return { object: first.object as Record<string, unknown> };
|
||||||
|
}
|
||||||
|
const second = await llm.generate({ ...call, system: `${input.instruction}\n\n${RETRY_HINT}` });
|
||||||
|
await bill(second.usage);
|
||||||
|
if (typeof second.object === 'object' && second.object !== null) {
|
||||||
|
return { object: second.object as Record<string, unknown> };
|
||||||
|
}
|
||||||
|
throw new Error(`${input.label}:模型出参不成形,重问一次仍不成 —— 交给你处理`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import { idempotencyKey } from '@modules/release';
|
|||||||
|
|
||||||
import { createLoopStateStore, createTurnRunner, criterionKey, readingsOf } from '@modules/loop-engine';
|
import { createLoopStateStore, createTurnRunner, criterionKey, readingsOf } from '@modules/loop-engine';
|
||||||
import type { LoopEngineService } from '@modules/loop-engine';
|
import type { LoopEngineService } from '@modules/loop-engine';
|
||||||
|
import { createAnalyzeGateway } from './analyze-gateway.ts';
|
||||||
import { createSkillEngineService } from '@modules/skill-engine';
|
import { createSkillEngineService } from '@modules/skill-engine';
|
||||||
import { createTaskService, createTaskStore } from '@modules/task';
|
import { createTaskService, createTaskStore } from '@modules/task';
|
||||||
import type { TaskService } from '@modules/task';
|
import type { TaskService } from '@modules/task';
|
||||||
@@ -222,6 +223,104 @@ export function createOnlyViolation(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 决策卡的**面孔与人工验收清单**:调用方递了就用它的,没递才退回剧本声明的(按 itemId 查到的那一步)。
|
||||||
|
*
|
||||||
|
* 纯函数:剧本那一步由调用方查好递进来,这里只挑值。
|
||||||
|
*
|
||||||
|
* **清单也要这道兜底**(2026-08-29 终审 C2):从前只有 `variant` 有兜底,`checklist` 没有 ——
|
||||||
|
* 配方(`auto-runner`)发审核卡时只递了 variant,于是那张卡穿着验收工单的衣服、`allowCustom` 也对,
|
||||||
|
* **一条待确认都没有**:回传门 `checkChatCardReply` 的 `checklistMissing` 在 checklist 缺席时**恒过**,
|
||||||
|
* 「确认通过」当场可按 —— 2026-08-27 用户当面定的那道人工验收闸凭空没了,而屏上还看不出来
|
||||||
|
* (清单区是空的,不是红的)。兜在这一处而不是让每个调用方自己记得:**模型路径将来漏传也一并保住**。
|
||||||
|
*
|
||||||
|
* **清单兜底得看 `reviewFace`**(2026-08-29 复审 I2 二审):`isReleaseStep` 那条口径决定了
|
||||||
|
* 一步可以只声明 `acceptanceChecklist`(给终局那张 `AcceptForm` 用)而不带 `cardVariant:'review'`
|
||||||
|
* (T_release 本身就是这种形状)。配方若在**这一步**发一张普通的 `as:'decide'` 卡(既没传
|
||||||
|
* `variant`,`step.cardVariant` 也不是 `review`),从前这里照样把 `acceptanceChecklist` 兜上去 ——
|
||||||
|
* 前端只在 `variant === 'review'` 时才渲染清单(`chat-bubbles.tsx`),人看不到勾可打,
|
||||||
|
* 而 `checkChatCardReply` 又要求 `checked ⊇ checklist`:卡按不下去。**只在 `reviewFace` 为真时**
|
||||||
|
* 才退回剧本声明的清单 —— 非审核面孔的卡,清单就是没有,不发明一条按不下去的。
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* **执行工单的面孔** —— 两副变体:`data` 数据工单(件上的表就是要建的记录,确认即入库)/
|
||||||
|
* `accept` 验收工单(件只读,人只核对确认)。`review` 是决策卡那一路的面孔,不许串到这儿来;
|
||||||
|
* 认不出的值一律当没写(普通执行工单),不把脏东西端下去。
|
||||||
|
*
|
||||||
|
* **剧本声明(`PlanStep.cardVariant`)优先于卡骨架**:模型抄卡时会把 `variant` 抄丢
|
||||||
|
* (2026-08-27 实测:同一张卡 gateLedgers 抄到了、variant 没有)。但卡自己带的值同样作数 ——
|
||||||
|
* 配方那一路没有模型可抄丢,而**一个步骤里两笔条件互斥的 card**(缺东西派数据工单 /
|
||||||
|
* 都齐了派验收工单,2026-08-31)本来就不可能由一个 step 级的 `cardVariant` 说清。
|
||||||
|
*
|
||||||
|
* **这一处此前是内联的三目,写死只认 `'data'`**(2026-08-31 用户实测:「你怎么给我发成了
|
||||||
|
* 执行工单」)—— 配方递下来的 `accept` 在这儿被静默丢掉,对话区照旧摆「执行工单」。
|
||||||
|
* 配方那一头与前端那一头都有测,唯独**真发卡这条路**上没有:面孔要在这里断言。
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* **这张放行卡打回得了吗**(2026-09-01 用户定:「评审放行与落账确认这一步的验收卡不应该有
|
||||||
|
* 重做的选项,因为这里都没有接入 ai」)。
|
||||||
|
*
|
||||||
|
* 打不回的判据只有一条:**这一步整个由配方跑完、一个字的 `guide` 都没有**(全 auto = 不问模型)。
|
||||||
|
* 那时打回的意见没有模型会去读,配方只会原样再提交一次,撞在「打回之后台账没动过就不许再提交」
|
||||||
|
* 那道闸上,连撞两拍之后落成一张「连续撞在同一条判据上」的兜底卡 —— 一颗按下去只会把单子
|
||||||
|
* 推进死胡同的钮,不如不摆(墙要配路;没有路就别立那扇门)。有异议的人还有真路:别签,终止重开。
|
||||||
|
*
|
||||||
|
* 半 auto(`guide` 非空)与模型驱动的放行步照旧打得回 —— 那儿真有模型会读那句意见。
|
||||||
|
* 认不出那一步(没声明人工验收清单的老活动)也照旧 —— 不写这面旗就是老行为。
|
||||||
|
*/
|
||||||
|
export function releaseRedoable(step: { readonly auto?: unknown; readonly guide?: string } | undefined): boolean {
|
||||||
|
return !(step?.auto !== undefined && (step.guide ?? '').trim() === '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executionCardVariant(fromCard: unknown, fromStep: unknown): 'data' | 'accept' | undefined {
|
||||||
|
for (const value of [fromStep, fromCard]) {
|
||||||
|
if (value === 'data' || value === 'accept') return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **确认闸的条件档从卡骨架到工单载荷**(2026-09-01 用户实测:「两个芯片类型不一样…
|
||||||
|
* 右边派发的卡片还可以点击下一步」)。配方声明了 `gateWhen`/`gateWhenSay`,发卡装配的
|
||||||
|
* 手写清单没点它们的名 —— 落库的工单载荷里两个字段是 undefined,按钮禁用与服务端拒收
|
||||||
|
* 读的都是它,一起无声失效(**手写字段清单第四次丢值**,且丢在 `variant` 上次被丢的同一段)。
|
||||||
|
* 这个缝取的是模型也会路过的不可信卡骨架,不能整包 spread —— 校验着转交:
|
||||||
|
* 条件必须是非空字符串;那句话只在有条件时才有意义(评估端只认 `when`)。
|
||||||
|
*/
|
||||||
|
export function cardConditionGate(card: Readonly<Record<string, unknown>>): { gateWhen?: string; gateWhenSay?: string } {
|
||||||
|
const when = card['gateWhen'];
|
||||||
|
if (typeof when !== 'string' || when.trim() === '') return {};
|
||||||
|
const say = card['gateWhenSay'];
|
||||||
|
return {
|
||||||
|
gateWhen: when,
|
||||||
|
...(typeof say === 'string' && say.trim() !== '' ? { gateWhenSay: say } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decisionCardFace(
|
||||||
|
input: { readonly variant?: 'review' | undefined; readonly checklist?: ReadonlyArray<string> | undefined },
|
||||||
|
step: { readonly cardVariant?: string | undefined; readonly acceptanceChecklist?: ReadonlyArray<string> | undefined } | undefined,
|
||||||
|
): { reviewFace: boolean; checklist: ReadonlyArray<string> | undefined } {
|
||||||
|
const reviewFace = input.variant === 'review' || step?.cardVariant === 'review';
|
||||||
|
const checklist =
|
||||||
|
input.checklist !== undefined && input.checklist.length > 0
|
||||||
|
? input.checklist
|
||||||
|
: reviewFace
|
||||||
|
? step?.acceptanceChecklist
|
||||||
|
: undefined;
|
||||||
|
return { reviewFace, checklist };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 要不要去清单里查那一步:没给 itemId 查不了;面孔与清单都递全了不必查(省一次 manifest 取数) */
|
||||||
|
export function needsStepLookup(input: {
|
||||||
|
readonly itemId?: string | undefined;
|
||||||
|
readonly variant?: 'review' | undefined;
|
||||||
|
readonly checklist?: ReadonlyArray<string> | undefined;
|
||||||
|
}): boolean {
|
||||||
|
if (input.itemId === undefined) return false;
|
||||||
|
return input.variant === undefined || input.checklist === undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function rowReadings(
|
export function rowReadings(
|
||||||
mine: Readonly<Record<string, readonly unknown[]>>,
|
mine: Readonly<Record<string, readonly unknown[]>>,
|
||||||
declared: ReadonlyArray<{ domain: string; object: string }> = [],
|
declared: ReadonlyArray<{ domain: string; object: string }> = [],
|
||||||
@@ -990,6 +1089,8 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
object: d.object,
|
object: d.object,
|
||||||
label: d.label ?? `${d.domain}.${d.object}`,
|
label: d.label ?? `${d.domain}.${d.object}`,
|
||||||
keys: d.fields.filter((f) => f.primaryKey).map((f) => f.name),
|
keys: d.fields.filter((f) => f.primaryKey).map((f) => f.name),
|
||||||
|
/* 单标识列 —— 配方留痕点名行时按它削掉单号那一段(与 `rowLabel` 同一条口径) */
|
||||||
|
...(d.caseKey === undefined ? {} : { caseKey: d.caseKey }),
|
||||||
fields: d.fields.map((f) => ({ name: f.name, label: f.label ?? f.name, type: f.type, refersTo: f.refersTo })),
|
fields: d.fields.map((f) => ({ name: f.name, label: f.label ?? f.name, type: f.type, refersTo: f.refersTo })),
|
||||||
createOnly: d.createOnly === true,
|
createOnly: d.createOnly === true,
|
||||||
}));
|
}));
|
||||||
@@ -1014,7 +1115,11 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
patch: Record<string, unknown>;
|
patch: Record<string, unknown>;
|
||||||
/** 只验不写 —— 多行 write 先整批过门再落笔(见 loop 那一侧的两遍) */
|
/** 只验不写 —— 多行 write 先整批过门再落笔(见 loop 那一侧的两遍) */
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
}) {
|
/** 值没变就不落笔(配方专用,见 `TaskDataGateway.draftWrite` 上的注释:落笔会擦掉「已落地」记号) */
|
||||||
|
skipUnchanged?: boolean;
|
||||||
|
/** 「只建不改」的表碰到已在库的键:跳过而不是拒(配方专用,同上) */
|
||||||
|
skipExisting?: boolean;
|
||||||
|
}): Promise<{ changed: boolean; skipped?: boolean }> {
|
||||||
const allowed = await taskData.writable(input.taskId);
|
const allowed = await taskData.writable(input.taskId);
|
||||||
const target = allowed.find((a) => a.domain === input.domain && a.object === input.object);
|
const target = allowed.find((a) => a.domain === input.domain && a.object === input.object);
|
||||||
if (target === undefined) {
|
if (target === undefined) {
|
||||||
@@ -1047,7 +1152,16 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
.filter((d) => d.landed && d.domain === input.domain && d.object === input.object)
|
.filter((d) => d.landed && d.domain === input.domain && d.object === input.object)
|
||||||
.map((d) => d.key);
|
.map((d) => d.key);
|
||||||
const why = createOnlyViolation(target, input.key, formal.rows as ReadonlyArray<Record<string, unknown>>, landedKeys);
|
const why = createOnlyViolation(target, input.key, formal.rows as ReadonlyArray<Record<string, unknown>>, landedKeys);
|
||||||
if (why !== undefined) throw new Error(why);
|
/*
|
||||||
|
* **对模型是拒回,对配方是常态**(2026-08-29 终审 I2):那句话写的是
|
||||||
|
* 「别再写它,要是本单草稿里已经替它铺了骨架,用 drop 把那一行撤掉」——
|
||||||
|
* 说给模型听的。配方没有「换个做法」这回事,抛出去就是一单永久停在那儿,
|
||||||
|
* 而人看见的是一句他既看不懂也办不了的拒回。它要的答案很简单:这一颗不用建。
|
||||||
|
*/
|
||||||
|
if (why !== undefined) {
|
||||||
|
if (input.skipExisting === true) return { changed: false, skipped: true };
|
||||||
|
throw new Error(why);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -1123,6 +1237,12 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
Object.entries(patch).some(([k, v]) => JSON.stringify(before.patch[k]) !== JSON.stringify(v));
|
Object.entries(patch).some(([k, v]) => JSON.stringify(before.patch[k]) !== JSON.stringify(v));
|
||||||
|
|
||||||
if (input.dryRun === true) return { changed };
|
if (input.dryRun === true) return { changed };
|
||||||
|
/*
|
||||||
|
* **值没变就不落笔**(2026-08-29 终审 I2,配方专用旗)—— `DraftStore.put` 的 upsert
|
||||||
|
* 尾巴上有 `landed_at = null`:照写会把数据工单已确认入库的那些行**悄悄 un-land**。
|
||||||
|
* 模型那一路不传这个旗(它的空转写要被看见),行为一个字不变。
|
||||||
|
*/
|
||||||
|
if (input.skipUnchanged === true && !changed) return { changed };
|
||||||
await deps.ledger.draftWrite({
|
await deps.ledger.draftWrite({
|
||||||
taskId: input.taskId,
|
taskId: input.taskId,
|
||||||
address: { domain: input.domain, object: input.object },
|
address: { domain: input.domain, object: input.object },
|
||||||
@@ -1138,7 +1258,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
* 回 `dropped: false` = 草稿层里根本没有这一行 —— 多半是主键写法对不上
|
* 回 `dropped: false` = 草稿层里根本没有这一行 —— 多半是主键写法对不上
|
||||||
* (`DAG…` 与 `DAG…-R0` 是两行);如实说比静默成功有用。
|
* (`DAG…` 与 `DAG…-R0` 是两行);如实说比静默成功有用。
|
||||||
*/
|
*/
|
||||||
async draftDrop(input: { taskId: TaskId; domain: string; object: string; key: Record<string, string | number> }) {
|
async draftDrop(input: { taskId: TaskId; domain: string; object: string; key: Record<string, string | number>; skipLanded?: boolean | undefined }) {
|
||||||
const allowed = await taskData.writable(input.taskId);
|
const allowed = await taskData.writable(input.taskId);
|
||||||
const target = allowed.find((a) => a.domain === input.domain && a.object === input.object);
|
const target = allowed.find((a) => a.domain === input.domain && a.object === input.object);
|
||||||
if (target === undefined) {
|
if (target === undefined) {
|
||||||
@@ -1161,6 +1281,12 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
Object.keys(input.key).every((k) => String(d.key[k] ?? '') === String(input.key[k] ?? '')),
|
Object.keys(input.key).every((k) => String(d.key[k] ?? '') === String(input.key[k] ?? '')),
|
||||||
);
|
);
|
||||||
if (landedHit !== undefined) {
|
if (landedHit !== undefined) {
|
||||||
|
/*
|
||||||
|
* 配方 `prune` 传 `skipLanded`:已落地 = 已经是世界的一部分,不是陈旧草稿 ——
|
||||||
|
* 跳过是常态,不该整单炸掉(port.ts 上的大注释)。模型那一路不传,照旧硬拒:
|
||||||
|
* 那句拒回是它的行动指南。
|
||||||
|
*/
|
||||||
|
if (input.skipLanded === true) return { dropped: false, landed: true };
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${target.label}里这一行(${Object.entries(input.key).map(([k, v]) => `${k}=${String(v)}`).join('、')})` +
|
`${target.label}里这一行(${Object.entries(input.key).map(([k, v]) => `${k}=${String(v)}`).join('、')})` +
|
||||||
'是本单在数据工单里建好、人确认后已入库的,撤不掉;要改由人在件上改,你接着办下一件事。',
|
'是本单在数据工单里建好、人确认后已入库的,撤不掉;要改由人在件上改,你接着办下一件事。',
|
||||||
@@ -1399,21 +1525,29 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
const recommended = input.options.find((o) => o.recommended === true);
|
const recommended = input.options.find((o) => o.recommended === true);
|
||||||
/*
|
/*
|
||||||
* 审核卡(剧本 cardVariant:'review'):按验收工单的样子摆,机制仍是决策卡 —— 剧本说了算,不靠模型抄。
|
* 审核卡(剧本 cardVariant:'review'):按验收工单的样子摆,机制仍是决策卡 —— 剧本说了算,不靠模型抄。
|
||||||
* 面孔**引擎按当下那一步定好递过来**(`input.variant`,2026-08-27:模型漏了 itemId,卡退成「需要你定」);
|
* 面孔与**人工验收清单**都由调用方(引擎按当下那一步)定好递过来
|
||||||
|
* (`input.variant` / `input.checklist`,2026-08-27:模型漏了 itemId,卡退成「需要你定」);
|
||||||
* 没递才退回按 itemId 查剧本。
|
* 没递才退回按 itemId 查剧本。
|
||||||
|
*
|
||||||
|
* **清单也要这道兜底**(2026-08-29 终审 C2):从前只有 `variant` 有,`checklist` 没有 ——
|
||||||
|
* 配方发的审核卡(`auto-runner` 那一路只递了 variant)于是穿着验收工单的衣服、
|
||||||
|
* `allowCustom` 也对,**一条待确认都没有**:回传门 `checklistMissing` 在 checklist 缺席时恒过,
|
||||||
|
* 「确认通过」当场可按 —— 2026-08-27 用户当面定的那道人工验收闸凭空没了,
|
||||||
|
* 而屏上还看不出来(清单区是空的,不是红的)。
|
||||||
|
* 兜在这一处而不是各调用方自己记得:**模型路径将来漏传也一并保住**。
|
||||||
*/
|
*/
|
||||||
const reviewFace =
|
const declaredStep = needsStepLookup(input)
|
||||||
input.variant === 'review' ||
|
? (await deps.registry.manifestOf((await taskContext.of(input.taskId)).activityId)).assets.businessModel.steps?.find(
|
||||||
(input.itemId !== undefined &&
|
|
||||||
(await deps.registry.manifestOf((await taskContext.of(input.taskId)).activityId)).assets.businessModel.steps?.find(
|
|
||||||
(st) => st.item === input.itemId,
|
(st) => st.item === input.itemId,
|
||||||
)?.cardVariant === 'review');
|
)
|
||||||
|
: undefined;
|
||||||
|
const { reviewFace, checklist } = decisionCardFace(input, declaredStep);
|
||||||
const issued = await deps.humanTool.issueChatCard(
|
const issued = await deps.humanTool.issueChatCard(
|
||||||
{ taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap },
|
{ taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap },
|
||||||
{
|
{
|
||||||
k: 'decision',
|
k: 'decision',
|
||||||
...(reviewFace ? { variant: 'review' as const } : {}),
|
...(reviewFace ? { variant: 'review' as const } : {}),
|
||||||
...(input.checklist === undefined || input.checklist.length === 0 ? {} : { checklist: [...input.checklist] }),
|
...(checklist === undefined || checklist.length === 0 ? {} : { checklist: [...checklist] }),
|
||||||
id: cardIdSchema.parse(ids.next()),
|
id: cardIdSchema.parse(ids.next()),
|
||||||
at: clock.now().toISOString(),
|
at: clock.now().toISOString(),
|
||||||
ask: input.ask,
|
ask: input.ask,
|
||||||
@@ -1726,14 +1860,27 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
* 终局验收卡的人工验收清单:剧本里声明了 `acceptanceChecklist` 且不是审核卡那一步的(放行那一步)。
|
* 终局验收卡的人工验收清单:剧本里声明了 `acceptanceChecklist` 且不是审核卡那一步的(放行那一步)。
|
||||||
* 人核这份工单要走的步骤是固定的,由活动声明、这里带上卡,不靠模型抄。
|
* 人核这份工单要走的步骤是固定的,由活动声明、这里带上卡,不靠模型抄。
|
||||||
*/
|
*/
|
||||||
const releaseChecklist = manifest.assets.businessModel.steps?.find(
|
const releaseStepDecl = manifest.assets.businessModel.steps?.find(
|
||||||
(st) => st.acceptanceChecklist !== undefined && st.acceptanceChecklist.length > 0 && st.cardVariant !== 'review',
|
(st) => st.acceptanceChecklist !== undefined && st.acceptanceChecklist.length > 0 && st.cardVariant !== 'review',
|
||||||
)?.acceptanceChecklist;
|
);
|
||||||
|
const releaseChecklist = releaseStepDecl?.acceptanceChecklist;
|
||||||
|
/*
|
||||||
|
* **打回之后没人接得住的,就别摆那颗钮**(2026-09-01 用户定:「评审放行与落账确认这一步的
|
||||||
|
* 验收卡不应该有重做的选项,因为这里都没有接入 ai」)。
|
||||||
|
*
|
||||||
|
* 判据是这一步**整个由配方跑完、一个字的 `guide` 都没有**(全 auto = 不问模型):
|
||||||
|
* 那时打回的意见没有模型会去读,配方只会原样再提交一次,撞在「打回之后台账没动过就不许
|
||||||
|
* 再提交」那道闸上,连撞两拍之后落成一张「连续撞在同一条判据上」的兜底卡 —— 一颗按下去
|
||||||
|
* 只会把单子推进死胡同的钮,不如不摆(墙要配路;没有路就别立那扇门)。
|
||||||
|
* 半 auto(`guide` 非空)与模型驱动的放行步照旧摆着 —— 那儿真有模型会读那句意见。
|
||||||
|
*/
|
||||||
|
const redoable = releaseRedoable(releaseStepDecl);
|
||||||
const submitted = await deps.release.submitForRelease({
|
const submitted = await deps.release.submitForRelease({
|
||||||
taskId: input.taskId,
|
taskId: input.taskId,
|
||||||
ownerId: input.ownerId,
|
ownerId: input.ownerId,
|
||||||
humanCallCap: input.humanCallCap,
|
humanCallCap: input.humanCallCap,
|
||||||
...(releaseChecklist === undefined ? {} : { checklist: releaseChecklist }),
|
...(releaseChecklist === undefined ? {} : { checklist: releaseChecklist }),
|
||||||
|
...(redoable ? {} : { redoable: false }),
|
||||||
deliverableId: String(input.card['deliverable'] ?? manifest.contract.requiredDeliverables[0]?.key ?? 'deliverable'),
|
deliverableId: String(input.card['deliverable'] ?? manifest.contract.requiredDeliverables[0]?.key ?? 'deliverable'),
|
||||||
title,
|
title,
|
||||||
basis: basis.length > 0 ? basis : [{ k: '来源', v: '案例模型收口' }],
|
basis: basis.length > 0 ? basis : [{ k: '来源', v: '案例模型收口' }],
|
||||||
@@ -1834,8 +1981,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
: (await deps.registry.manifestOf((await taskContext.of(input.taskId)).activityId)).assets.businessModel.steps?.find(
|
: (await deps.registry.manifestOf((await taskContext.of(input.taskId)).activityId)).assets.businessModel.steps?.find(
|
||||||
(s) => s.item === input.itemId,
|
(s) => s.item === input.itemId,
|
||||||
)?.cardVariant;
|
)?.cardVariant;
|
||||||
// 执行工单这一路只认 data(review 是决策卡那一路的面孔)
|
const variant = executionCardVariant(input.card['variant'], stepVariant);
|
||||||
const variant = stepVariant === 'data' || input.card['variant'] === 'data' ? ('data' as const) : undefined;
|
|
||||||
const issued = await deps.humanTool.issueExecution(context, {
|
const issued = await deps.humanTool.issueExecution(context, {
|
||||||
title,
|
title,
|
||||||
basis: basis.length > 0 ? basis : [{ k: '来源', v: '案例模型当下局面' }],
|
basis: basis.length > 0 ? basis : [{ k: '来源', v: '案例模型当下局面' }],
|
||||||
@@ -1846,6 +1992,8 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
...(blocks === undefined ? {} : { blocks: blocks as ExecutionWorkOrderDraft['blocks'] }),
|
...(blocks === undefined ? {} : { blocks: blocks as ExecutionWorkOrderDraft['blocks'] }),
|
||||||
...(gateLedgers.length === 0 ? {} : { gateLedgers }),
|
...(gateLedgers.length === 0 ? {} : { gateLedgers }),
|
||||||
...(variant === undefined ? {} : { variant }),
|
...(variant === undefined ? {} : { variant }),
|
||||||
|
/* 条件档(gateWhen/gateWhenSay)—— 校验着转交,见 cardConditionGate 的账 */
|
||||||
|
...cardConditionGate(input.card),
|
||||||
});
|
});
|
||||||
// 打扰率的计量对象:每发一张卡就是调了一次人 —— 不计就等于没有上限
|
// 打扰率的计量对象:每发一张卡就是调了一次人 —— 不计就等于没有上限
|
||||||
await taskStore.addConsumption(input.taskId, { humanCalls: 1 });
|
await taskStore.addConsumption(input.taskId, { humanCalls: 1 });
|
||||||
@@ -2289,6 +2437,13 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
},
|
},
|
||||||
tasks: taskContext,
|
tasks: taskContext,
|
||||||
clock,
|
clock,
|
||||||
|
/*
|
||||||
|
* **裸网关不行**(2026-08-29 终审 C1):从前这里传的是 `deps.modelGateway` ——
|
||||||
|
* 配方里 `analyze` 烧的 token 一个数都不计入活动预算(计量全靠调用方给的 `onUsage`),
|
||||||
|
* 时间轴上也不产生模型 span。agent 那两条路(:2194 / :2242)一直是 `tracedGateway` + `onAgentUsage`,
|
||||||
|
* 只有这一条漏了 —— 一个能问模型却不花钱的口子,预算这道闸对它就是不存在的。
|
||||||
|
*/
|
||||||
|
analyze: createAnalyzeGateway(tracedGateway, onAgentUsage),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -348,3 +348,477 @@ describe('refersTo 目标要在本活动的声明里 —— 写错的地址会
|
|||||||
expect(found.map((f) => f.code)).toEqual(['reference-target-unbound']);
|
expect(found.map((f) => f.code)).toEqual(['reference-target-unbound']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ── 配方静态审(2026-08-29)──────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* `autoRecipeFindings` 读的是**整份** `ActivityManifest`(不像本文件其余审读只收窄到
|
||||||
|
* 字段级),所以这里不能再用局部的 `manifest()` / `table()` 这类窄类型 helper ——
|
||||||
|
* 借 `activityManifestSchema.parse()` 把六区的默认值一次性填满,只手写真正必填的那几处。
|
||||||
|
*/
|
||||||
|
import { activityManifestSchema } from '@contracts';
|
||||||
|
import { autoRecipeFindings } from '../internal/domain/static-audit.ts';
|
||||||
|
|
||||||
|
/** 台账地址与列名全部真实声明过 —— `demo.record` 只有 `case_ref` / `note` 两列 */
|
||||||
|
function manifestFixture(): ActivityManifest {
|
||||||
|
return activityManifestSchema.parse({
|
||||||
|
identity: {
|
||||||
|
activityId: 'demo-activity',
|
||||||
|
name: '演示活动',
|
||||||
|
packageVersion: '1.0.0',
|
||||||
|
category: '演示',
|
||||||
|
sdkVersion: '1.0.0',
|
||||||
|
checksum: 'chk',
|
||||||
|
},
|
||||||
|
contract: {
|
||||||
|
activityId: 'demo-activity',
|
||||||
|
version: '1.0.0',
|
||||||
|
input: { data: { type: 'object', properties: {} } },
|
||||||
|
output: {},
|
||||||
|
authorization: { humanCallBudget: 0 },
|
||||||
|
resources: { budget: { steps: 10, tokens: 10000, activeMinutes: 10, humanCalls: 0 } },
|
||||||
|
},
|
||||||
|
declarations: {
|
||||||
|
models: ['m'],
|
||||||
|
ledger: [
|
||||||
|
{
|
||||||
|
domain: 'demo',
|
||||||
|
object: 'record',
|
||||||
|
kind: 'record',
|
||||||
|
caseKey: 'case_ref',
|
||||||
|
label: '演示记录',
|
||||||
|
fields: [
|
||||||
|
{ name: 'case_ref', type: 'text', primaryKey: true },
|
||||||
|
{ name: 'note', type: 'text' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
harness: {},
|
||||||
|
},
|
||||||
|
assets: {
|
||||||
|
businessModel: {
|
||||||
|
cmmnXml: '<caseFileItem id="S1" name="x"/>',
|
||||||
|
narrative: '演示说明书',
|
||||||
|
dmn: { drd: [], tables: [] },
|
||||||
|
steps: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
samples: [{ name: '样例', input: {}, expect: { ports: ['done'] } }],
|
||||||
|
entry: { file: 'index.js', checksum: 'chk' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 'demo.record', from: { once: true }, fields: { 不存在的列: 'x' } }] }),
|
||||||
|
);
|
||||||
|
expect(findings.some((f) => f.severity === 'error' && f.message.includes('不存在的列'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each 的地址同样要在声明里', () => {
|
||||||
|
const findings = autoRecipeFindings(
|
||||||
|
withRecipe({ do: [{ kind: 'project', into: 'demo.record', from: { each: 'mine:nope.nope' }, fields: {} }] }),
|
||||||
|
);
|
||||||
|
expect(findings.some((f) => f.message.includes('nope.nope'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 2026-08-29 评审(Minor):上面那条「each / lookup 的地址同样要在声明里」的名字曾经名不副实 ——
|
||||||
|
* 用例体只测了 `from.each`,从没有一条真的在 `project.fields` 里放过 `{ lookup, by, take }` 节点。
|
||||||
|
* `walkExpr` 的 lookup 递归分支因此只被间接触到,card/say 那个洞就是这么躲过测试套件的(见下方)。
|
||||||
|
*/
|
||||||
|
it('project.fields 里的 lookup 地址同样要在声明里', () => {
|
||||||
|
const findings = autoRecipeFindings(
|
||||||
|
withRecipe({
|
||||||
|
do: [
|
||||||
|
{
|
||||||
|
kind: 'project',
|
||||||
|
into: 'demo.record',
|
||||||
|
from: { once: true },
|
||||||
|
fields: { note: { lookup: 'nope.nope', by: {}, take: 'x' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(findings.some((f) => f.severity === 'error' && f.message.includes('nope.nope'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 2026-08-29 评审(Important):`card.title` / `card.ask` / `card.standard` / `card.basis[].v`
|
||||||
|
* 全是 AutoExpr,合法地可以含 lookup —— 从前只核了 `gateLedgers`,这四处没被 `walkExpr` 走过,
|
||||||
|
* 卡面上的一处笔误因此能一声不响地装进来。`title` 这里刻意嵌一层 `when/then` ——
|
||||||
|
* 顺手核实 `walkExpr` 对嵌套结构是不是真的递归到底(不是只查表层)。
|
||||||
|
*/
|
||||||
|
it('card 的 title / ask / standard / basis 里藏的 lookup 地址也要核(含嵌套在 when/then 里的)', () => {
|
||||||
|
const findings = autoRecipeFindings(
|
||||||
|
withRecipe({
|
||||||
|
do: [
|
||||||
|
{
|
||||||
|
kind: 'card',
|
||||||
|
as: 'execution',
|
||||||
|
title: { when: 'a = 1', then: { lookup: 'title.nope', by: {}, take: 'x' }, else: 'x' },
|
||||||
|
ask: { lookup: 'ask.nope', by: {}, take: 'x' },
|
||||||
|
standard: { lookup: 'standard.nope', by: {}, take: 'x' },
|
||||||
|
basis: [{ k: 'b', v: { lookup: 'basis.nope', by: {}, take: 'x' } }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const messages = findings.map((f) => f.message).join('\n');
|
||||||
|
expect(messages).toContain('title.nope');
|
||||||
|
expect(messages).toContain('ask.nope');
|
||||||
|
expect(messages).toContain('standard.nope');
|
||||||
|
expect(messages).toContain('basis.nope');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* 2026-08-29 评审(Important):say.text 同样是 AutoExpr —— 从前压根没有 say 分支 */
|
||||||
|
it('say.text 里藏的 lookup 地址也要核', () => {
|
||||||
|
const findings = autoRecipeFindings(
|
||||||
|
withRecipe({ do: [{ kind: 'say', text: { lookup: 'say.nope', by: {}, take: 'x' } }] }),
|
||||||
|
);
|
||||||
|
expect(findings.some((f) => f.severity === 'error' && f.message.includes('say.nope'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('in: 与 result: 不是台账地址,不该被当成台账来审', () => {
|
||||||
|
/* 主键列照给 —— 少了它这份配方本来就是坏的(2026-08-29 新增的那条审读),与本条要验的事无关 */
|
||||||
|
const findings = autoRecipeFindings(
|
||||||
|
withRecipe({ do: [{ kind: 'project', into: 'demo.record', from: { each: 'in:pns' }, fields: { case_ref: { col: 'pn' } } }] }),
|
||||||
|
);
|
||||||
|
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 === 'warn' && 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **card 后面还有笔 → warn**(2026-08-29 终审 I3 附带):卡真发出去那一拍到此为止,
|
||||||
|
* 排在后面的笔要等人交回之后的那一拍才跑。写配方的人常按顺序读成「铺格 → 发卡 → 收口整理」
|
||||||
|
* 一拍连做 —— 那是读错的,装的时候提前说一声。
|
||||||
|
*/
|
||||||
|
it('card 后面还有笔 → warn(它们要等人交回之后的那一拍才跑)', () => {
|
||||||
|
const findings = autoRecipeFindings(
|
||||||
|
withRecipe({
|
||||||
|
do: [
|
||||||
|
{ kind: 'card', as: 'execution', title: 'x' },
|
||||||
|
{ kind: 'project', into: 'demo.record', from: { once: true }, fields: { case_ref: 'a' } },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(findings.some((f) => f.severity === 'warn' && f.message.includes('card 后面还有 1 笔'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('card 是最后一笔:不出这条 warn', () => {
|
||||||
|
const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'card', as: 'execution', title: 'x', basis: [{ k: 'a', v: 'b' }] }] }));
|
||||||
|
expect(findings.some((f) => f.message.includes('card 后面还有'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── 2026-08-29 终审 I4:spec §5.7 明列却一条都没审的几处 ────────────────── */
|
||||||
|
|
||||||
|
it('**动作级 when 写不成 S-FEEL → error**(运行时它会抛,整步交人)', () => {
|
||||||
|
const findings = autoRecipeFindings(
|
||||||
|
withRecipe({ do: [{ kind: 'say', when: 'note >>= (', text: 'x' }] }),
|
||||||
|
);
|
||||||
|
expect(findings.some((f) => f.severity === 'error' && f.message.includes('写不成 S-FEEL'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('**嵌在表达式里的 when 也审语法** —— 它出错是静默走 else,更难查', () => {
|
||||||
|
const findings = autoRecipeFindings(
|
||||||
|
withRecipe({
|
||||||
|
do: [
|
||||||
|
{
|
||||||
|
kind: 'project',
|
||||||
|
into: 'demo.record',
|
||||||
|
from: { once: true },
|
||||||
|
fields: { case_ref: 'a', note: { when: 'note = = 1', then: 'x', else: 'y' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(findings.some((f) => f.severity === 'error' && f.message.includes('写不成 S-FEEL'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('动作级 when 引用了台账上没有的名字 → error(S-FEEL 求值为假,这一笔永远被跳过)', () => {
|
||||||
|
const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'say', when: 'candidatesConfirmed > 0', text: 'x' }] }));
|
||||||
|
expect(findings.some((f) => f.severity === 'error' && f.message.includes('candidatesConfirmed'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('内建函数名不算未绑定的台账名 —— 名字取 AST 的 references,不是正则扫标识符', () => {
|
||||||
|
const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'say', when: 'count(note) > 0', text: 'x' }] }));
|
||||||
|
expect(findings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('动作级 when 引用台账列 / 基座读数 / 配方 let 都算绑得上', () => {
|
||||||
|
const findings = autoRecipeFindings(
|
||||||
|
withRecipe({
|
||||||
|
let: { evalRef: 'W05-1' },
|
||||||
|
do: [{ kind: 'say', when: 'demo_record_rows > 0 and note != "" and evalRef != ""', text: 'x' }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(findings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('**嵌套的 when 不审名字** —— 它看得见行内的列,拿 boundNames 去卡会误报', () => {
|
||||||
|
const findings = autoRecipeFindings(
|
||||||
|
withRecipe({
|
||||||
|
do: [
|
||||||
|
{
|
||||||
|
kind: 'project',
|
||||||
|
into: 'demo.record',
|
||||||
|
from: { each: 'demo.record' },
|
||||||
|
fields: { case_ref: { col: 'case_ref' }, note: { when: '本行的列 = "x"', then: 'a', else: 'b' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(findings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submit.rationale 里藏的 lookup 地址也要核', () => {
|
||||||
|
const base = manifestFixture();
|
||||||
|
const manifest = {
|
||||||
|
...base,
|
||||||
|
assets: {
|
||||||
|
...base.assets,
|
||||||
|
businessModel: {
|
||||||
|
...base.assets.businessModel,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
item: 'S1',
|
||||||
|
guide: '',
|
||||||
|
writes: [],
|
||||||
|
reads: [],
|
||||||
|
tools: [],
|
||||||
|
acceptanceChecklist: ['核一遍'],
|
||||||
|
auto: { do: [{ kind: 'submit', rationale: { lookup: 'rationale.nope', by: {}, take: 'x' } }] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never;
|
||||||
|
expect(autoRecipeFindings(manifest).some((f) => f.message.includes('rationale.nope'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recipe.let 里藏的 lookup 地址也要核(那一圈从前根本没被 walkExpr 走过)', () => {
|
||||||
|
const findings = autoRecipeFindings(
|
||||||
|
withRecipe({ let: { ref: { lookup: 'let.nope', by: {}, take: 'x' } }, do: [{ kind: 'say', text: 'x' }] }),
|
||||||
|
);
|
||||||
|
expect(findings.some((f) => f.severity === 'error' && f.message.includes('let.nope'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('project.fields 少了目标表的主键列 → error(运行时那一行会被静默跳过)', () => {
|
||||||
|
const findings = autoRecipeFindings(
|
||||||
|
withRecipe({ do: [{ kind: 'project', into: 'demo.record', from: { once: true }, fields: { note: 'x' } }] }),
|
||||||
|
);
|
||||||
|
expect(findings.some((f) => f.severity === 'error' && f.message.includes('没给主键列:case_ref'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prune 的表又列在 humanWrites 里 → warn(人加的行会被下一拍静默撤掉)', () => {
|
||||||
|
const base = manifestFixture();
|
||||||
|
const manifest = {
|
||||||
|
...base,
|
||||||
|
assets: {
|
||||||
|
...base.assets,
|
||||||
|
businessModel: {
|
||||||
|
...base.assets.businessModel,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
item: 'S1',
|
||||||
|
guide: '',
|
||||||
|
writes: [],
|
||||||
|
reads: [],
|
||||||
|
tools: [],
|
||||||
|
humanWrites: ['demo.record'],
|
||||||
|
auto: { do: [{ kind: 'project', into: 'demo.record', prune: true, from: { once: true }, fields: { case_ref: 'a' } }] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never;
|
||||||
|
expect(autoRecipeFindings(manifest).some((f) => f.severity === 'warn' && f.message.includes('humanWrites'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('card 没有 basis → error(基座拒发 basis 为空的工单,跑到那儿就停了)', () => {
|
||||||
|
const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'card', as: 'execution', title: 'x' }] }));
|
||||||
|
expect(findings.some((f) => f.severity === 'error' && f.message.includes('没有 basis'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ── 字段求值依赖成环(2026-08-30)──────────────────────────────────────
|
||||||
|
*
|
||||||
|
* `fields` 存进 jsonb 之后键序不再保证:`judgement_class` 依赖同笔内的
|
||||||
|
* `difference_status`,一旦被排到它前面,`when` 链求值恒读到 `undefined`,
|
||||||
|
* 三级判断全部落空却不报错(见 `judgement-class-probe.md`)。运行时已经改成
|
||||||
|
* 按依赖排序求值,但 A 依赖 B、B 又依赖 A 排不出任何安全序 —— 那是配方写错了,
|
||||||
|
* 装的时候就该拒。`demo.record` 默认只有 case_ref/note 两列,这里额外声明 a/b
|
||||||
|
* 两列,专给这组用例摆弄依赖关系(不然「列名不在声明里」那条审读会先报,污染断言)。
|
||||||
|
*/
|
||||||
|
const withExtraColumns = (auto: unknown) => {
|
||||||
|
const base = manifestFixture();
|
||||||
|
const [record] = base.declarations.ledger;
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
declarations: {
|
||||||
|
...base.declarations,
|
||||||
|
ledger: [
|
||||||
|
{
|
||||||
|
...record,
|
||||||
|
fields: [...(record?.fields ?? []), { name: 'a', type: 'text', primaryKey: false }, { name: 'b', type: 'text', primaryKey: false }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
assets: {
|
||||||
|
...base.assets,
|
||||||
|
businessModel: {
|
||||||
|
...base.assets.businessModel,
|
||||||
|
steps: [{ item: 'S1', guide: '', writes: [], reads: [], tools: [], auto }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('project.fields 两个字段互相依赖(a 的 when 提到 b、b 的 when 提到 a)→ error,点名两个字段', () => {
|
||||||
|
const findings = autoRecipeFindings(
|
||||||
|
withExtraColumns({
|
||||||
|
do: [
|
||||||
|
{
|
||||||
|
kind: 'project',
|
||||||
|
into: 'demo.record',
|
||||||
|
from: { once: true },
|
||||||
|
fields: { case_ref: 'x', a: { when: 'b = "y"', then: '1', else: '0' }, b: { when: 'a = "y"', then: '1', else: '0' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const hit = findings.find((f) => f.severity === 'error' && f.message.includes('循环依赖'));
|
||||||
|
expect(hit).toBeDefined();
|
||||||
|
expect(hit?.message).toContain('a');
|
||||||
|
expect(hit?.message).toContain('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('project.fields 链式依赖(不是环,只是 b 引用 a)→ 不报循环依赖', () => {
|
||||||
|
const findings = autoRecipeFindings(
|
||||||
|
withExtraColumns({
|
||||||
|
do: [
|
||||||
|
{
|
||||||
|
kind: 'project',
|
||||||
|
into: 'demo.record',
|
||||||
|
from: { once: true },
|
||||||
|
fields: { case_ref: 'x', a: 'seed', b: { when: 'a = "seed"', then: '1', else: '0' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(findings.some((f) => f.message.includes('循环依赖'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('analyze 入参字段成环 → error(与 project.fields 同一条审读)', () => {
|
||||||
|
const findings = autoRecipeFindings(
|
||||||
|
withExtraColumns({
|
||||||
|
do: [
|
||||||
|
{
|
||||||
|
kind: 'analyze',
|
||||||
|
model: 'm',
|
||||||
|
into: 'items',
|
||||||
|
instruction: 'x',
|
||||||
|
input: {
|
||||||
|
from: { once: true },
|
||||||
|
fields: { a: { when: 'b = "y"', then: '1', else: '0' }, b: { when: 'a = "y"', then: '1', else: '0' } },
|
||||||
|
},
|
||||||
|
output: { type: 'object' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(findings.some((f) => f.severity === 'error' && f.message.includes('循环依赖'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('三级依赖链(与真实 W05 的 difference_status → judgement_class → risk_level 同形)→ 不报循环依赖', () => {
|
||||||
|
const findings = autoRecipeFindings(
|
||||||
|
withExtraColumns({
|
||||||
|
do: [
|
||||||
|
{
|
||||||
|
kind: 'project',
|
||||||
|
into: 'demo.record',
|
||||||
|
from: { once: true },
|
||||||
|
fields: {
|
||||||
|
case_ref: 'x',
|
||||||
|
note: { when: 'a = "hit"', then: '1', else: '0' },
|
||||||
|
a: { when: 'b = "hit"', then: 'hit', else: 'miss' },
|
||||||
|
b: 'hit',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(findings.some((f) => f.message.includes('循环依赖'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **`recipe.let` 同一条审读**(2026-08-30 全分支终审 Important 1)——`let` 与 `fields`
|
||||||
|
* 的依赖读法不一样(`let` 的官方跨条目读法是 `{in:X}`,不是 `when` 里的裸标识符),
|
||||||
|
* 所以静态审那边是单独一份 `letsCycle`/`letReferences`,这里单独一组测试钉住它,
|
||||||
|
* 不是「已经被 fields 那组测过了」。
|
||||||
|
*/
|
||||||
|
it('recipe.let 两条互相依赖({in:X} 互指)→ error,点名两个 let', () => {
|
||||||
|
const findings = autoRecipeFindings(
|
||||||
|
withRecipe({
|
||||||
|
let: { a: { in: 'b' }, b: { in: 'a' } },
|
||||||
|
do: [{ kind: 'project', into: 'demo.record', from: { once: true }, fields: { case_ref: 'x' } }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const hit = findings.find((f) => f.severity === 'error' && f.message.includes('循环依赖'));
|
||||||
|
expect(hit).toBeDefined();
|
||||||
|
expect(hit?.message).toContain('a');
|
||||||
|
expect(hit?.message).toContain('b');
|
||||||
|
expect(hit?.message).toContain('let');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recipe.let 链式依赖(不是环,只是 tag 用 {in:"stamp"} 读 stamp)→ 不报循环依赖', () => {
|
||||||
|
const findings = autoRecipeFindings(
|
||||||
|
withRecipe({
|
||||||
|
let: { stamp: 'W05-', tag: { cat: [{ in: 'stamp' }, 'X'] } },
|
||||||
|
do: [{ kind: 'project', into: 'demo.record', from: { once: true }, fields: { case_ref: { in: 'tag' } } }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(findings.some((f) => f.message.includes('循环依赖'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recipe.let 引用起单入参 / case: 这类全局名字不算依赖,不误报循环依赖', () => {
|
||||||
|
const findings = autoRecipeFindings(
|
||||||
|
withRecipe({
|
||||||
|
let: { a: { in: 'someInput' }, b: { in: 'case:openedAt' } },
|
||||||
|
do: [{ kind: 'project', into: 'demo.record', from: { once: true }, fields: { case_ref: 'x' } }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(findings.some((f) => f.message.includes('循环依赖'))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,7 +7,10 @@
|
|||||||
* 需要查库的两项(工具契约是否已注册、模型名是否已绑定)由 usecase 补进来 ——
|
* 需要查库的两项(工具契约是否已注册、模型名是否已绑定)由 usecase 补进来 ——
|
||||||
* 本文件只做**纯**判定。
|
* 本文件只做**纯**判定。
|
||||||
*/
|
*/
|
||||||
import type { ActivityManifest, StaticAuditFinding } from '@contracts';
|
import type { ActivityManifest, AutoExpr, StaticAuditFinding } from '@contracts';
|
||||||
|
/* domain 只准 @contracts / @sfeel / @utils(编码规范 §5.3)—— S-FEEL 是纯函数包,零 IO */
|
||||||
|
import { staticCheck } from '@sfeel';
|
||||||
|
import { topoOrder } from '@utils';
|
||||||
|
|
||||||
export type ExternalChecks = {
|
export type ExternalChecks = {
|
||||||
/** 声明引用但工具箱里没有的工具 */
|
/** 声明引用但工具箱里没有的工具 */
|
||||||
@@ -313,6 +316,12 @@ function boundNames(manifest: AcceptanceAuditInput): ReadonlySet<string> {
|
|||||||
}
|
}
|
||||||
// 决策卡回答读数(2026-08-27):人在决策卡上选了什么,基座按留痕数 —— 审核通过与否不再由模型自写的旗说了算
|
// 决策卡回答读数(2026-08-27):人在决策卡上选了什么,基座按留痕数 —— 审核通过与否不再由模型自写的旗说了算
|
||||||
for (const name of ['decision_last', 'decision_approve_total', 'decision_reject_total', 'decision_custom_total']) out.add(name);
|
for (const name of ['decision_last', 'decision_approve_total', 'decision_reject_total', 'decision_custom_total']) out.add(name);
|
||||||
|
/*
|
||||||
|
* **人把这一步打回重做时写下的那句话**(2026-09-01)—— 引擎每一拍都注给配方(没打回就是空串,
|
||||||
|
* 见 `auto-runner.ts` 的 `AutoContext.redoNote`)。活动据它决定「这一轮要不要把意见交回给 AI 重判」。
|
||||||
|
* 名字用下划线而不是 `case:` 前缀:S-FEEL 的名字不许带冒号,而它最要紧的用法就是当 `when` 条件用。
|
||||||
|
*/
|
||||||
|
out.add('case_redo_note');
|
||||||
for (const id of manifest.assets.businessModel.cmmnXml.match(/caseFileItem\s+id="([^"]+)"/g) ?? []) {
|
for (const id of manifest.assets.businessModel.cmmnXml.match(/caseFileItem\s+id="([^"]+)"/g) ?? []) {
|
||||||
const m = /id="([^"]+)"/.exec(id);
|
const m = /id="([^"]+)"/.exec(id);
|
||||||
if (m?.[1] !== undefined) out.add(m[1]);
|
if (m?.[1] !== undefined) out.add(m[1]);
|
||||||
@@ -376,6 +385,403 @@ export function loopFindings(manifest: ActivityManifest): StaticAuditFinding[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归找出表达式里全部 `when` 字符串引用的根路径名(用于判「这个字段依赖同笔内的哪几个字段」)。
|
||||||
|
*
|
||||||
|
* 名字取 S-FEEL AST 的 `references`,不用正则扫标识符 —— 与 `checkWhen` 同一个理由:
|
||||||
|
* 正则会把 `count(...)` 这类内建函数名也当成字段名。`when` 语法本该被 `checkWhen` 先一步
|
||||||
|
* 拦住,这里万一撞见解析不了的表达式,当作没有引用处理(宁可漏报不可误报)。
|
||||||
|
*
|
||||||
|
* **与 `loop-engine/internal/domain/auto-order.ts` 里的 `whenReferences` 是同一个算法,
|
||||||
|
* 写了两遍**:装包静态审(L4 `activity-registry`)不能 import 配方执行器(L5
|
||||||
|
* `loop-engine`,模块分层只准低层被高层引用,§4),这一段小小的、纯粹依附 `AutoExpr`
|
||||||
|
* 形状的逻辑只能各自留一份;真正通用、不含业务语义的图算法(`topoOrder`)已经
|
||||||
|
* 提到 `@utils` 共用了。改动这个函数时,记得看看那边要不要一起改。
|
||||||
|
*/
|
||||||
|
function fieldReferences(expr: AutoExpr): readonly string[] {
|
||||||
|
if (typeof expr !== 'object' || expr === null) return [];
|
||||||
|
if ('when' in expr) {
|
||||||
|
const verdict = staticCheck(expr.when);
|
||||||
|
const names = verdict.references.map((path) => path.split('.')[0] ?? path);
|
||||||
|
return [...names, ...fieldReferences(expr.then), ...fieldReferences(expr.else)];
|
||||||
|
}
|
||||||
|
if ('lookup' in expr) {
|
||||||
|
const byNames = Object.values(expr.by).flatMap((value) => fieldReferences(value));
|
||||||
|
return expr.else === undefined ? byNames : [...byNames, ...fieldReferences(expr.else)];
|
||||||
|
}
|
||||||
|
if ('cat' in expr) return expr.cat.flatMap((part) => fieldReferences(part));
|
||||||
|
if ('fmt' in expr) return fieldReferences(expr.fmt);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fields` 内部字段求值依赖能不能排出安全序(与运行时 `orderFields` 同一件事,见上面
|
||||||
|
* `fieldReferences` 的头注)。排得出来给 `undefined`;排不出来(真正的环)把牵涉的字段名
|
||||||
|
* 摆出来 —— **这条错误 2026-08-30 才补上**:jsonb 会把 `fields` 对象的键序打乱,
|
||||||
|
* `judgement_class`/`risk_level` 这类依赖同笔内另一个字段的写法一旦被排到依赖对象前面,
|
||||||
|
* 求值时读到的是 `undefined`,`when` 链全部落空却不报错(详见
|
||||||
|
* `.superpowers/sdd/2026-08-29-act-work-05-rewrite/judgement-class-probe.md`);
|
||||||
|
* 运行时已经改成按依赖排序求值,但**真正的环**(A 依赖 B、B 又依赖 A,排不出任何安全序)
|
||||||
|
* 是配方写错了 —— 别留到运行时才炸,装包这一刻就能看出来。
|
||||||
|
*/
|
||||||
|
function fieldsCycle(fields: Readonly<Record<string, AutoExpr>>): readonly string[] | undefined {
|
||||||
|
const entries = Object.entries(fields);
|
||||||
|
const names = new Set(entries.map(([name]) => name));
|
||||||
|
const deps = new Map<string, readonly string[]>(
|
||||||
|
entries.map(([name, expr]) => [name, [...new Set(fieldReferences(expr))].filter((ref) => ref !== name && names.has(ref))]),
|
||||||
|
);
|
||||||
|
const result = topoOrder(
|
||||||
|
entries.map(([name]) => name),
|
||||||
|
(name) => deps.get(name) ?? [],
|
||||||
|
);
|
||||||
|
return result.ok ? undefined : result.cycle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同上,但专为 `recipe.let` 用(2026-08-30 全分支终审 Important 1)——多认一种依赖:
|
||||||
|
* **裸 `{in:X}` 本身就是依赖**。`let` 的官方跨条目读法就是 `{in:X}`(见
|
||||||
|
* `packages/contracts/src/auto-recipe.ts` 的注释「先算一遍,后面所有表达式都能
|
||||||
|
* {in:'名'} 引用」,运行时 `evalExpr` 的 `'in' in expr` 分支优先读 `ctx.lets[X]`)——
|
||||||
|
* 与 `fields` 相反(`fieldReferences` 故意不认 `{in:X}`:那边的 `{in:X}` 只读
|
||||||
|
* `lets`/`scope`,永远不会命中同一个 `fields` 对象里的另一个字段,认了反而误报)。
|
||||||
|
* `when`/`lookup.by`/`cat`/`fmt` 四种嵌套形态与 `fieldReferences` 完全一致。
|
||||||
|
*
|
||||||
|
* **与 `loop-engine/internal/domain/auto-order.ts` 里的 `letReferences` 是同一个算法,
|
||||||
|
* 写了两遍**——理由同 `fieldReferences` 头注(L4 装包静态审不能 import L5 配方执行器)。
|
||||||
|
* 改动这个函数时,记得看看那边要不要一起改。
|
||||||
|
*/
|
||||||
|
function letReferences(expr: AutoExpr): readonly string[] {
|
||||||
|
if (typeof expr !== 'object' || expr === null) return [];
|
||||||
|
if ('in' in expr) return [expr.in];
|
||||||
|
if ('when' in expr) {
|
||||||
|
const verdict = staticCheck(expr.when);
|
||||||
|
const names = verdict.references.map((path) => path.split('.')[0] ?? path);
|
||||||
|
return [...names, ...letReferences(expr.then), ...letReferences(expr.else)];
|
||||||
|
}
|
||||||
|
if ('lookup' in expr) {
|
||||||
|
const byNames = Object.values(expr.by).flatMap((value) => letReferences(value));
|
||||||
|
return expr.else === undefined ? byNames : [...byNames, ...letReferences(expr.else)];
|
||||||
|
}
|
||||||
|
if ('cat' in expr) return expr.cat.flatMap((part) => letReferences(part));
|
||||||
|
if ('fmt' in expr) return letReferences(expr.fmt);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `recipe.let` 内部求值依赖能不能排出安全序(与运行时 `orderLets` 同一件事)。装的时候就该看出来,不留到运行时才炸。 */
|
||||||
|
function letsCycle(lets: Readonly<Record<string, AutoExpr>>): readonly string[] | undefined {
|
||||||
|
const entries = Object.entries(lets);
|
||||||
|
const names = new Set(entries.map(([name]) => name));
|
||||||
|
const deps = new Map<string, readonly string[]>(
|
||||||
|
entries.map(([name, expr]) => [name, [...new Set(letReferences(expr))].filter((ref) => ref !== name && names.has(ref))]),
|
||||||
|
);
|
||||||
|
const result = topoOrder(
|
||||||
|
entries.map(([name]) => name),
|
||||||
|
(name) => deps.get(name) ?? [],
|
||||||
|
);
|
||||||
|
return result.ok ? undefined : result.cycle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **配方静态审**(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}`,
|
||||||
|
{
|
||||||
|
columns: new Set(l.fields.map((f) => f.name)),
|
||||||
|
/* 主键列 —— 与运行时 `writable()` 同一口径(`fields.filter(primaryKey)`) */
|
||||||
|
keys: l.fields.filter((f) => f.primaryKey).map((f) => f.name),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
/**
|
||||||
|
* 动作级 `when` 求值时的作用域 = 任务视图 + 基座读数 + 起单入参(`boundNames`)+ 配方的 `let`。
|
||||||
|
* **不含**行内的列(`col` / `outer` / `computed`)—— 那些只有嵌在表达式里的 `when` 看得见,
|
||||||
|
* 所以名字绑定只审动作级那一层,嵌套的那些只审语法(见下面 `checkWhen`)。
|
||||||
|
*/
|
||||||
|
const declaredNames = boundNames(manifest);
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **`when` 的 S-FEEL 要能解析**(spec §5.7 第三条,2026-08-29 终审 I4 补 —— 从前一条都没审)。
|
||||||
|
* 运行时两种下场都不好:嵌在表达式里的 `{when}` 出错 → `evalExpr` catch 掉、**静默走 else**;
|
||||||
|
* 动作级的 `when` 出错 → 抛 → 整步交人。写错一个字的代价不该是一单跑不动。
|
||||||
|
*
|
||||||
|
* 给了 `lets` 就连**名字绑得上**一起审(只有动作级那一层能这么审:嵌套的 `when`
|
||||||
|
* 还看得见行内的 `col` / `outer` / `computed`,拿 `boundNames` 去卡会误报)。
|
||||||
|
*/
|
||||||
|
const checkWhen = (expr: string, at: string, lets?: ReadonlySet<string>): void => {
|
||||||
|
const verdict = staticCheck(expr);
|
||||||
|
if (!verdict.ok) {
|
||||||
|
out.push({
|
||||||
|
code: 'schema-invalid',
|
||||||
|
severity: 'error',
|
||||||
|
message: `配方的 when 过不了 S-FEEL 静态检(写不成 S-FEEL):${expr} —— ${verdict.issues.map((i) => i.message).join(';')}`,
|
||||||
|
at,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lets === undefined) return;
|
||||||
|
/*
|
||||||
|
* 名字取 `staticCheck` 的 `references`(AST 里的路径节点),不用本文件那个正则 `predicateNames` ——
|
||||||
|
* 正则会把 `count(...)` 这类**内建函数名**也当成台账名报未绑定;AST 分得清函数与路径。
|
||||||
|
*/
|
||||||
|
for (const path of verdict.references) {
|
||||||
|
const name = path.split('.')[0] ?? path;
|
||||||
|
if (declaredNames.has(name) || lets.has(name)) continue;
|
||||||
|
out.push({
|
||||||
|
code: 'schema-invalid',
|
||||||
|
severity: 'error',
|
||||||
|
message:
|
||||||
|
`配方的 when 引用了 \`${name}\`,而台账、案卷、起单入参与本配方的 let 里都没有这个名字 —— ` +
|
||||||
|
'S-FEEL 对未绑定的名字求值为假,这一笔将**永远被跳过**(而且一声不响)',
|
||||||
|
at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 表达式里也可能藏着 lookup 地址与 when,递归找一遍 */
|
||||||
|
const walkExpr = (expr: unknown, at: string): void => {
|
||||||
|
if (typeof expr !== 'object' || expr === null) return;
|
||||||
|
const node = expr as Record<string, unknown>;
|
||||||
|
if (typeof node['when'] === 'string') checkWhen(node['when'], at);
|
||||||
|
if (typeof node['lookup'] === 'string') {
|
||||||
|
checkAddress(node['lookup'], at);
|
||||||
|
for (const value of Object.values((node['by'] ?? {}) as Record<string, unknown>)) walkExpr(value, at);
|
||||||
|
if (node['else'] !== undefined) walkExpr(node['else'], at);
|
||||||
|
}
|
||||||
|
if (Array.isArray(node['cat'])) for (const part of node['cat'] as unknown[]) 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<string, unknown>;
|
||||||
|
if (typeof node['each'] === 'string') checkAddress(node['each'], at);
|
||||||
|
for (const value of Object.values((node['where'] ?? {}) as Record<string, unknown>)) 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'] as unknown[]) {
|
||||||
|
for (const value of Object.values(row as Record<string, unknown>)) 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<string>();
|
||||||
|
/* 配方级 `let`:名字进动作级 `when` 的作用域;**它自己的表达式也是 AutoExpr,一样要走一遍** */
|
||||||
|
const letNames = new Set(Object.keys(recipe.let ?? {}));
|
||||||
|
for (const [name, expr] of Object.entries(recipe.let ?? {})) walkExpr(expr, `${at}.let.${name}`);
|
||||||
|
/*
|
||||||
|
* **`let` 之间的求值依赖不能成环**(2026-08-30 全分支终审 Important 1,与下面 `fields`
|
||||||
|
* 那条 `fieldsCycle` 同一课):`let` 存进 jsonb 之后键序不再保证,运行时已经按依赖
|
||||||
|
* (`{in:X}`)排出安全序求值(见 `orderLets` 头注),但 A 依赖 B、B 又依赖 A 排不出
|
||||||
|
* 任何安全序 —— 那是配方写错了,装的时候就该拒,不留到运行时才炸。
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
const cycle = letsCycle(recipe.let ?? {});
|
||||||
|
if (cycle !== undefined) {
|
||||||
|
out.push({
|
||||||
|
code: 'schema-invalid',
|
||||||
|
severity: 'error',
|
||||||
|
message: `let 之间存在循环依赖,求不出安全的求值序:${cycle.join('、')}(检查这几个 let 是否互相用 {in:...} 或 when 引用了对方)`,
|
||||||
|
at: `${at}.let`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [index, action] of recipe.do.entries()) {
|
||||||
|
const where = `${at}.do[${index}]`;
|
||||||
|
/* 动作级 `when`:语法 + 名字都审(它看得见的只有 scope 与 let,没有行内列) */
|
||||||
|
if (action.when !== undefined) checkWhen(action.when, where, letNames);
|
||||||
|
/* 确认闸的条件档与动作级 when 同一作用域(基座读数),同一套审 */
|
||||||
|
if (action.kind === 'card' && action.gateWhen !== undefined) checkWhen(action.gateWhen, where, letNames);
|
||||||
|
if (action.kind === 'project') {
|
||||||
|
checkAddress(action.into, where);
|
||||||
|
walkSource(action.from, where);
|
||||||
|
const target = ledgers.get(action.into);
|
||||||
|
for (const [name, expr] of Object.entries(action.fields)) {
|
||||||
|
walkExpr(expr, where);
|
||||||
|
if (target !== undefined && !target.columns.has(name)) {
|
||||||
|
out.push({ code: 'schema-invalid', severity: 'error', message: `配方写的列不在 ${action.into} 的声明里:${name}`, at: where });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* **prune 的表不该是人在件上能加行的表**(2026-08-29 终审 F3)。
|
||||||
|
* `prune` 说的是「这一笔投影出来的行就是全部,多出来的撤掉」;而 `humanWrites`
|
||||||
|
* 说的是「这张表归人填,模型没有来源」—— 人加的行配方的数据源里当然没有,
|
||||||
|
* 下一拍对齐就把它撤了。两条声明放在同一张表上,意思是相反的。
|
||||||
|
*/
|
||||||
|
if ((action.prune === true || action.prune === 'live') && (step.humanWrites ?? []).includes(action.into)) {
|
||||||
|
out.push({
|
||||||
|
code: 'schema-invalid',
|
||||||
|
severity: 'warn',
|
||||||
|
message:
|
||||||
|
`${action.into} 既被这一笔 prune 投影、又列在这一步的 humanWrites 里 —— ` +
|
||||||
|
'人在件上加的行配方的数据源里没有,下一拍对齐会把它撤掉(静默);' +
|
||||||
|
'要么别 prune 这张表,要么它就不该是人能加行的表',
|
||||||
|
at: where,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* **主键列一个都不能少**(2026-08-29 终审 I4)。运行时 `auto-runner` 拿
|
||||||
|
* `decl.keys` 逐列取值,取不到就当空主键 —— 那一行**静默 `continue`**:
|
||||||
|
* 一行不写、一条痕都没有。表里少了行,人和判据都查不出为什么。
|
||||||
|
* 装包时判得出来的事,不留到运行时。
|
||||||
|
*/
|
||||||
|
if (target !== undefined) {
|
||||||
|
const missing = target.keys.filter((k) => !(k in action.fields));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
out.push({
|
||||||
|
code: 'schema-invalid',
|
||||||
|
severity: 'error',
|
||||||
|
message:
|
||||||
|
`配方往 ${action.into} 投影,却没给主键列:${missing.join('、')}(主键:${target.keys.join('、')})—— ` +
|
||||||
|
'运行时主键算不出来的行会被静默跳过,一行不写、一条痕都没有',
|
||||||
|
at: where,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* **字段求值依赖不能成环**(2026-08-30)。`fields` 存进 jsonb 之后键序不再保证,
|
||||||
|
* 运行时已经按依赖排出安全序求值(见上面 `fieldsCycle` 的头注),但 A 依赖 B、
|
||||||
|
* B 又依赖 A 排不出任何安全序 —— 那是配方写错了,装的时候就该拒,不留到运行时才炸。
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
const cycle = fieldsCycle(action.fields);
|
||||||
|
if (cycle !== undefined) {
|
||||||
|
out.push({
|
||||||
|
code: 'schema-invalid',
|
||||||
|
severity: 'error',
|
||||||
|
message: `${action.into} 的 fields 之间存在循环依赖,求不出安全的求值序:${cycle.join('、')}(检查这几个字段的 when 是否互相引用了对方)`,
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const cycle = fieldsCycle(action.input.fields);
|
||||||
|
if (cycle !== undefined) {
|
||||||
|
out.push({
|
||||||
|
code: 'schema-invalid',
|
||||||
|
severity: 'error',
|
||||||
|
message: `analyze 入参字段之间存在循环依赖,求不出安全的求值序:${cycle.join('、')}(检查这几个字段的 when 是否互相引用了对方)`,
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* **basis 空着的卡基座直接拒发**(契约 `auto-recipe.ts` 的注释自己写着「别指望兜底」)——
|
||||||
|
* 运行时那一拒是抛,抛出去就是整步交人。装包时看得见的事不留到运行时。
|
||||||
|
*/
|
||||||
|
if ((action.basis ?? []).length === 0) {
|
||||||
|
out.push({
|
||||||
|
code: 'schema-invalid',
|
||||||
|
severity: 'error',
|
||||||
|
message: '配方发的卡没有 basis(调用必附上下文)—— 基座拒发 basis 为空的工单,这一步跑到这儿就停了',
|
||||||
|
at: where,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* **`title`/`ask`/`standard`/`basis[].v` 都是 AutoExpr,合法地可以含 lookup**
|
||||||
|
* (2026-08-29 评审补,原实现只核了 `gateLedgers`,这四处从没被 `walkExpr` 走过 ——
|
||||||
|
* 卡面上的一处笔误因此能一声不响地装进来,只在这一步第一次真跑起来时才现形)。
|
||||||
|
*/
|
||||||
|
walkExpr(action.title, where);
|
||||||
|
if (action.ask !== undefined) walkExpr(action.ask, where);
|
||||||
|
if (action.standard !== undefined) walkExpr(action.standard, where);
|
||||||
|
for (const b of action.basis ?? []) walkExpr(b.v, where);
|
||||||
|
/*
|
||||||
|
* **card 后面还有笔:说清它们什么时候跑**(2026-08-29 终审 I3 附带的一条 warn)。
|
||||||
|
* 卡真发出去那一拍到此为止(`stop`);排在后面的笔要等**人交回之后**的那一拍才跑
|
||||||
|
* (那一拍这一笔 `card` 因为 `carded` 被跳过)。写配方的人常按顺序读成
|
||||||
|
* 「铺格 → 发卡 → 收口整理」一拍连做 —— 那是读错的,提前说一声。
|
||||||
|
*/
|
||||||
|
if (index < recipe.do.length - 1) {
|
||||||
|
out.push({
|
||||||
|
code: 'schema-invalid',
|
||||||
|
severity: 'warn',
|
||||||
|
message:
|
||||||
|
`card 后面还有 ${recipe.do.length - index - 1} 笔:卡发出去那一拍到此为止,` +
|
||||||
|
'这几笔要等人交回之后的那一拍才跑(那一拍 card 这一笔被跳过)—— 确认这就是你要的次序',
|
||||||
|
at: where,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* `say.text` 同样是 AutoExpr,同一个理由要核(2026-08-29 评审补) */
|
||||||
|
if (action.kind === 'say') {
|
||||||
|
walkExpr(action.text, where);
|
||||||
|
}
|
||||||
|
/* `submit.rationale` 同样是 AutoExpr,合法地可以含 lookup —— 从前这一支只查 acceptanceChecklist */
|
||||||
|
if (action.kind === 'submit') walkExpr(action.rationale, 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: 'warn',
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
export function staticAudit(manifest: ActivityManifest, external: ExternalChecks): StaticAuditFinding[] {
|
export function staticAudit(manifest: ActivityManifest, external: ExternalChecks): StaticAuditFinding[] {
|
||||||
const out: StaticAuditFinding[] = [];
|
const out: StaticAuditFinding[] = [];
|
||||||
|
|
||||||
@@ -425,6 +831,7 @@ export function staticAudit(manifest: ActivityManifest, external: ExternalChecks
|
|||||||
out.push(...ledgerKindFindings(manifest));
|
out.push(...ledgerKindFindings(manifest));
|
||||||
out.push(...acceptanceLevelFindings(manifest));
|
out.push(...acceptanceLevelFindings(manifest));
|
||||||
out.push(...loopFindings(manifest));
|
out.push(...loopFindings(manifest));
|
||||||
|
out.push(...autoRecipeFindings(manifest));
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
- **调人预算环外强制**:超过契约声明的打扰上限即 `HumanCallBudgetExceededError`,
|
- **调人预算环外强制**:超过契约声明的打扰上限即 `HumanCallBudgetExceededError`,
|
||||||
活动代码无从自查自守
|
活动代码无从自查自守
|
||||||
- **落地失败卡是执行工单的 preset**,不构成第五种卡
|
- **落地失败卡是执行工单的 preset**,不构成第五种卡
|
||||||
|
- **打不回的验收卡**(2026-09-01 用户定):`AcceptanceWorkOrder.redoable === false` 时,`verdict !== 'pass'` 的回传当场拒(`redo-unavailable`)。什么时候是 false:那一步整个由配方跑完、一个字的 `guide` 都没有 —— 打回之后没有模型会读那句意见、件也只读,配方只会原样重提并撞在「打回之后台账没动过」那道闸上。卡面把钮收起来只是给人看的,**这里才是门**;拒回把真路指出来(别签,去终止重开)。判断在装配层 `releaseRedoable`(纯函数,有测)。
|
||||||
- **人工验收清单点全才能通过**(2026-08-27 用户定):验收工单 / 审核卡带 `checklist`(剧本 `acceptanceChecklist`,
|
- **人工验收清单点全才能通过**(2026-08-27 用户定):验收工单 / 审核卡带 `checklist`(剧本 `acceptanceChecklist`,
|
||||||
每份工单固定)时,「通过」的回传必须带 `checked ⊇ checklist`,否则 `checklist-incomplete` 拒回;驳回不看清单。
|
每份工单固定)时,「通过」的回传必须带 `checked ⊇ checklist`,否则 `checklist-incomplete` 拒回;驳回不看清单。
|
||||||
审核卡只拦「通过」那一个选项(`reviewApproveValue`:值叫 approve 的 / 推荐的 / 第一个)。点过的条目落进
|
审核卡只拦「通过」那一个选项(`reviewApproveValue`:值叫 approve 的 / 推荐的 / 第一个)。点过的条目落进
|
||||||
@@ -39,5 +40,6 @@
|
|||||||
- **`pendingCards(taskId)`**(2026-08-27):这一单正等人交回的卡 = 没办结的工单 + 没作答、没被顶替的对话卡
|
- **`pendingCards(taskId)`**(2026-08-27):这一单正等人交回的卡 = 没办结的工单 + 没作答、没被顶替的对话卡
|
||||||
(`CardStore.listOpenChatCards` 按 `superseded_by is null` 取)。引擎每拍开头问它:有卡没答就不问模型
|
(`CardStore.listOpenChatCards` 按 `superseded_by is null` 取)。引擎每拍开头问它:有卡没答就不问模型
|
||||||
- **数据工单是执行工单的 `variant:'data'`**,同样不构成新的 mode:发卡 / 闸 / 回传全走执行工单那条路,只是对话区换一副面孔(2026-08-27)。
|
- **数据工单是执行工单的 `variant:'data'`**,同样不构成新的 mode:发卡 / 闸 / 回传全走执行工单那条路,只是对话区换一副面孔(2026-08-27)。
|
||||||
|
- **验收工单是执行工单的 `variant:'accept'`**(2026-08-31):件只读、人只核对确认(「确认无误」),同样不构成新的 mode。**与 `mode:'acceptance'` 分清**:那个是 `submit` 抬起来的收口验收(签下去落账发口、带打回重做);这个只是「这一步的活干完了,请你过目」,回传即办结。三副面孔的判断在 `apps/web/.../desk-task/internal/order-face.ts`(纯函数,有测)。
|
||||||
**确认即入库**(2026-08-27 下午)不在本模块:回传落定之后由 app 的回传路由调 `landDataOrder`
|
**确认即入库**(2026-08-27 下午)不在本模块:回传落定之后由 app 的回传路由调 `landDataOrder`
|
||||||
(`apps/api/src/app/data-order-landing.ts`)把这张卡 `gateLedgers` / 绑的表并进正式表 —— 本模块只管回传本身
|
(`apps/api/src/app/data-order-landing.ts`)把这张卡 `gateLedgers` / 绑的表并进正式表 —— 本模块只管回传本身
|
||||||
|
|||||||
@@ -95,6 +95,30 @@ describe('工单形态机检', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **打不回的验收卡**(2026-09-01 用户定:「评审放行与落账确认这一步的验收卡不应该有重做的选项,
|
||||||
|
* 因为这里都没有接入 ai」)。卡面把钮收起来只是给人看的,这里才是门 —— 直调 API 交「重做」,
|
||||||
|
* 单子会被推进死胡同:没有模型读那句意见,配方只会原样重提,撞在「打回之后台账没动过」那道闸上。
|
||||||
|
*/
|
||||||
|
describe('打不回的验收卡', () => {
|
||||||
|
const redo: WorkOrderReply = { mode: 'acceptance', workOrderId: OID, verdict: 'redo', values: {}, comment: '再看看', selectedPorts: {} };
|
||||||
|
|
||||||
|
it('redoable:false → 交「重做」当场拒,拒回把真路指出来', () => {
|
||||||
|
const v = checkWorkOrderReply(acceptance({ redoable: false }), redo);
|
||||||
|
expect(v.map((i) => i.code)).toContain('redo-unavailable');
|
||||||
|
expect(v.find((i) => i.code === 'redo-unavailable')?.message).toContain('终止');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('同一张卡「通过」照收 —— 拦的是打回,不是这张卡', () => {
|
||||||
|
const pass: WorkOrderReply = { mode: 'acceptance', workOrderId: OID, verdict: 'pass', values: {}, selectedPorts: {} };
|
||||||
|
expect(checkWorkOrderReply(acceptance({ redoable: false }), pass).map((i) => i.code)).not.toContain('redo-unavailable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('没写这面旗(老卡)照旧打得回', () => {
|
||||||
|
expect(checkWorkOrderReply(acceptance(), redo).map((i) => i.code)).not.toContain('redo-unavailable');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('到达面两路', () => {
|
describe('到达面两路', () => {
|
||||||
it('负责人的卡进任务对话流', () => {
|
it('负责人的卡进任务对话流', () => {
|
||||||
expect(arrivalOf('s1', 's1')).toBe('task-chat');
|
expect(arrivalOf('s1', 's1')).toBe('task-chat');
|
||||||
@@ -333,3 +357,21 @@ describe('人工验收清单:点全才能通过', () => {
|
|||||||
expect(checkChatCardReply(card, reply('custom:意见', undefined, '意见'))).toEqual([]);
|
expect(checkChatCardReply(card, reply('custom:意见', undefined, '意见'))).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('审核卡的选项数', () => {
|
||||||
|
it('普通决策卡仍要两个分支 —— 一个选项的岔路口不是岔路口', () => {
|
||||||
|
const findings = checkChatCard({ k: 'decision', ask: '往哪走', basis: [{ k: 'a', v: 'b' }], options: [{ value: 'a', label: 'A' }] } as never);
|
||||||
|
expect(findings.some((f) => f.code === 'options-missing')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('审核卡(variant:review)只有「确认通过」一个选项也发得出去 —— 改在件上,卡上只签字', () => {
|
||||||
|
const findings = checkChatCard({
|
||||||
|
k: 'decision',
|
||||||
|
variant: 'review',
|
||||||
|
ask: '核对过了吗',
|
||||||
|
basis: [{ k: 'a', v: 'b' }],
|
||||||
|
options: [{ value: 'approve', label: '确认通过' }],
|
||||||
|
} as never);
|
||||||
|
expect(findings.some((f) => f.code === 'options-missing')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -89,7 +89,17 @@ export function checkChatCard(card: ChatCard): CardViolation[] {
|
|||||||
switch (card.k) {
|
switch (card.k) {
|
||||||
case 'decision':
|
case 'decision':
|
||||||
if (card.basis.length === 0) out.push({ code: 'bare-call', message: '决策卡必附依据快照,基座拒发' });
|
if (card.basis.length === 0) out.push({ code: 'bare-call', message: '决策卡必附依据快照,基座拒发' });
|
||||||
if (card.options.length < 2) out.push({ code: 'options-missing', message: '决策卡至少两个分支' });
|
/*
|
||||||
|
* **审核卡是例外**(2026-08-29):它不是岔路口,是一道签字关 ——
|
||||||
|
* 改全在件上就地做,卡上只有「确认通过」一个动作。
|
||||||
|
* 普通决策卡照旧要两个分支:一个选项的岔路口不是岔路口。
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
const floor = card.variant === 'review' ? 1 : 2;
|
||||||
|
if (card.options.length < floor) {
|
||||||
|
out.push({ code: 'options-missing', message: floor === 1 ? '审核卡至少一个选项' : '决策卡至少两个分支' });
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'elicitation':
|
case 'elicitation':
|
||||||
if (card.fields.length === 0) out.push({ code: 'fields-missing', message: '补全卡必须给出要填的字段' });
|
if (card.fields.length === 0) out.push({ code: 'fields-missing', message: '补全卡必须给出要填的字段' });
|
||||||
@@ -123,6 +133,18 @@ export function checkWorkOrderReply(order: WorkOrder, reply: WorkOrderReply): Ca
|
|||||||
if (rejectionNeedsComment(reply)) {
|
if (rejectionNeedsComment(reply)) {
|
||||||
out.push({ code: 'comment-required', message: '驳回必附意见(OS 05)' });
|
out.push({ code: 'comment-required', message: '驳回必附意见(OS 05)' });
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
* **打不回的卡就是打不回**(2026-09-01 用户定:「评审放行与落账确认这一步的验收卡不应该有
|
||||||
|
* 重做的选项,因为这里都没有接入 ai」)。卡面把那颗钮收起来只是给人看的,**这里才是门** ——
|
||||||
|
* 直调 API 把「重做」交回来,单子会被推进死胡同(没有模型读那句意见,配方只会原样重提,
|
||||||
|
* 撞在「打回之后台账没动过」那道闸上)。拒回把真路指出来:别签、去终止重开。
|
||||||
|
*/
|
||||||
|
if (reply.verdict !== 'pass' && (order as AcceptanceWorkOrder).redoable === false) {
|
||||||
|
out.push({
|
||||||
|
code: 'redo-unavailable',
|
||||||
|
message: '这一步打不回:它整个由配方跑完,没有模型会读你的意见,件也只读 —— 有异议就别确认,去终止这一单重开。',
|
||||||
|
});
|
||||||
|
}
|
||||||
/*
|
/*
|
||||||
* **恰选一口,随放行一并确认**(AM 1.2)—— 与「驳回必附意见」同族的收单规矩。
|
* **恰选一口,随放行一并确认**(AM 1.2)—— 与「驳回必附意见」同族的收单规矩。
|
||||||
* 工单投影自己声明了选择组(portChoice),放行回传就必须带组内恰一口:
|
* 工单投影自己声明了选择组(portChoice),放行回传就必须带组内恰一口:
|
||||||
|
|||||||
@@ -266,3 +266,32 @@ describe('回执与内部观察(PRD:页面交互是三类唤醒事件之一)', (
|
|||||||
expect(projectChat([entry('observation', { id: 'oa', title: '事件到达(human):abcd-123', detail: { kind: 'human' } })])).toHaveLength(0);
|
expect(projectChat([entry('observation', { id: 'oa', title: '事件到达(human):abcd-123', detail: { kind: 'human' } })])).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **配方的机械痕不进对话区**(2026-08-29 终审 I5,控制端裁定 F2)。
|
||||||
|
*
|
||||||
|
* 上一轮为了让配方留痕进工作日志,把 `internal` 整个去掉了 —— 而 `internal` 是
|
||||||
|
* `machinery()`(工作日志)与本文件(对话区)**共用的一个旗**,去掉它等于同时对两个投影开门:
|
||||||
|
* 「芯片主数据:写下 2 行」「已把 8 条参数交给模型逐条判定」这类一笔一个气泡跑进了对话区,
|
||||||
|
* 而这个仓花了很大代价立起「对话区一轮一句,不是一拍一句」。
|
||||||
|
* 现在配方那几笔照旧标 `internal`(这里挡住),工作日志那一侧另开 `detail.auto` 的例外。
|
||||||
|
* 形状取自 `auto-runner.ts` 真实写下的 detail。
|
||||||
|
*/
|
||||||
|
describe('配方留痕:进工作日志,不进对话区(I5)', () => {
|
||||||
|
it('project / analyze 那两笔标着 internal —— 一个气泡都不该冒出来', () => {
|
||||||
|
const out = projectChat([
|
||||||
|
entry('observation', { id: 'p1', title: '芯片主数据:写下 2 行', detail: { auto: 'S0_bind#1', ledger: 'parts.profile', internal: true } }),
|
||||||
|
entry('observation', { id: 'p2', title: '已把 8 条参数交给模型逐条判定,拿回 8 条结论', detail: { auto: 'S3#2', model: 'm', internal: true } }),
|
||||||
|
entry('observation', { id: 'p3', title: '这一步已经发过卡,不再发第二张', detail: { auto: 'S1#2', internal: true } }),
|
||||||
|
]);
|
||||||
|
expect(out).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('say 与「办不下去」那两笔不标 internal —— 它们是说给人听的,照进', () => {
|
||||||
|
const out = projectChat([
|
||||||
|
entry('observation', { id: 's1', title: '已核对 3 颗料,2 颗要建档', detail: { auto: 'S0#3' } }),
|
||||||
|
entry('observation', { id: 's2', title: '配方第 2 笔办不下去:台账地址不在本活动的声明里 —— 交给你处理', detail: { auto: 'S1#2' } }),
|
||||||
|
]);
|
||||||
|
expect(out.map((m) => m.k)).toEqual(['system', 'system']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
/**
|
||||||
|
* **「加一行」克隆到的必须是数据行,不是表头行**(2026-08-30,评审逮到的 Critical 回归)。
|
||||||
|
*
|
||||||
|
* 背景(ACT-WORK-05 差异评估 Task 7b):`parts.chip_type` / `parts.type_param` 两张表
|
||||||
|
* 需要在**零草稿行**时也能「加一行」(`activities/part-substitution-assessment/src/views/
|
||||||
|
* report.ts` 的 `CHIP_TYPE_TABLE`/`TYPE_PARAM_TABLE`)。为了让 `ViewContainer.collect()`
|
||||||
|
* 同时收到「已有的动态行(`data-rows`)」与「一条不受行数所限的静态种子行」两处的输入,
|
||||||
|
* `data-edit="域.对象"` 挂在了外层 `<table>` 上(而不是只挂在内层 `data-rows` 的
|
||||||
|
* `<tbody>` 上)。
|
||||||
|
*
|
||||||
|
* 副作用:`ViewContainer.bodyOf()`(`packages/ui-kit/src/ViewContainer.tsx:164-167`,
|
||||||
|
* 单个 `document.querySelector`,按文档序返回第一个匹配)从此解析到**外层 `<table>`**;
|
||||||
|
* `addRow()`(`:303-323`)接着在这个 host 里找 `tr:last-of-type` —— CSS `:last-of-type`
|
||||||
|
* 是**相对各自父元素**判定的,`<thead>` 里那个孤零零的 `<tr>` 在它自己的父元素(`thead`)
|
||||||
|
* 里必然是「最后一个」,若 `<thead>` 在文档序上排在数据行之前,它就会被
|
||||||
|
* `querySelectorAll('tr:last-of-type')` 第一个命中 —— 克隆出来的是四个 `<th>` 表头文字、
|
||||||
|
* **零个** `input[data-col]`,还被 `appendChild` 到 `<table>` 底下、不在任何 `<tbody>` 里。
|
||||||
|
* 按钮点了有反应,结果完全不可用,且不报错。
|
||||||
|
*
|
||||||
|
* 修法(`report.ts`,不改 `ViewContainer.tsx`/`view-render.ts` 这两个共享面):把 `<thead>`
|
||||||
|
* 挪到**两个 `<tbody>` 之后**、`</table>` 之前。CSS 表格布局规范里
|
||||||
|
* `table-header-group`(`<thead>` 的默认 display)**始终排在最前面渲染,不看它在 DOM 里
|
||||||
|
* 排第几**(这是 `tfoot` 允许写在 `thead` 后面、`tbody` 前面这条 HTML5 规则背后同一条
|
||||||
|
* 视觉排版原理)——视觉呈现不变,而 `document.querySelector`/`querySelectorAll` 认的是
|
||||||
|
* **文档序**,不是视觉序:`<thead>` 排在两个 `<tbody>` 后面,`tr:last-of-type` 的搜索
|
||||||
|
* 就再也碰不到表头行(表头行在文档序上比所有数据行/种子行都晚,只有当两个 `<tbody>`
|
||||||
|
* 都彻底没有 `<tr>` 时才轮得到它——而静态种子行保证了这种情况不会发生)。
|
||||||
|
*
|
||||||
|
* **这份测试守的是「W05 这两张表此刻的具体 HTML 形状是对的」,不是「addRow/bodyOf 这两个
|
||||||
|
* 函数本身对不对」——两件事分给了两处测试,缺一不可**(2026-08-30 复审第二轮逼出来的教训:
|
||||||
|
* 第一版这里的 `findAddRowTarget` 是纯手抄的选择器复刻,复审把真实 `ViewContainer.tsx:305`
|
||||||
|
* 的 `tr:last-of-type` 换成明摆着错的 `tr:first-of-type` 重跑这 6 条,6/6 全绿零反应——
|
||||||
|
* 全仓当时没有任何测试真的执行过 `bodyOf`/`addRow`)。
|
||||||
|
*
|
||||||
|
* `bodyOf`/`addRow` 现在**已经 `export`**(`packages/ui-kit/src/ViewContainer.tsx`,
|
||||||
|
* 零行为改动,只是加宽可见性)——**真实函数的行为**由
|
||||||
|
* `packages/ui-kit/__tests__/view-container-addrow.test.ts` 直接 `import` 它们去测
|
||||||
|
* (合成的最小 fixture,不碰任何活动的 HTML;那份文件的变异验证证明了它真的会因为
|
||||||
|
* `ViewContainer.tsx` 里选错选择器而变红)。**这份文件依然不 `import` 那两个函数**——
|
||||||
|
* 不是 depcruise 拦着(`packages-are-leaves` 只禁 `packages → apps` 这一个方向,没禁
|
||||||
|
* `apps → packages`),是**类型系统**拦着:`ViewContainer.tsx` 是 `.tsx`(含 JSX),
|
||||||
|
* 编译它要 `"jsx": "react-jsx"` 这个 compiler option,而 `apps/api/**` 只受
|
||||||
|
* `tsconfig.node.json` 管——那份配置没有 `jsx` 选项(它是 Node 后端,本不该解析 JSX)。
|
||||||
|
* 所以这里仍然用**真实的 `renderView()`**(与 report.ts 的 `CHIP_TYPE_TABLE`/
|
||||||
|
* `TYPE_PARAM_TABLE` 结构逐字节对齐的镜像 fixture,见下面 `CHIP_TYPE_FRAGMENT`/
|
||||||
|
* `TYPE_PARAM_FRAGMENT` 头注)跑出「服务端会真的吐出什么 HTML」,再在 jsdom 里镜像
|
||||||
|
* `bodyOf`/`addRow` 那两行选择器(标了对应的源码行号,与 `view-container-addrow.test.ts`
|
||||||
|
* 里那份文本上完全一致)去读它——这里测的是**渲染结果的形状**,选择器路径本身的正确性已经
|
||||||
|
* 交给上面那份文件去守,这里不重复承担那份责任。
|
||||||
|
*/
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
/*
|
||||||
|
* apps/api/** 只受 tsconfig.node.json 管(lib: ES2023,无 DOM——它是 Node 后端,本不该有
|
||||||
|
* DOM 全局)。这份测试需要真实 DOM(document/HTMLElement)去挂载 renderView() 的输出、
|
||||||
|
* 复刻 bodyOf()/addRow() 的选择器链——挪去 packages/ui-kit(有 DOM lib)又会撞
|
||||||
|
* packages-are-leaves(packages/* 不许反向 import apps/*),renderView 只活在 apps/api
|
||||||
|
* 里。两难之下用 TS 标准机制解:per-file 的三斜线 lib 引用只给这一份文件额外挂上 DOM 的
|
||||||
|
* 环境类型,不改 tsconfig.node.json 本身(该配置对 apps/api 其余所有文件原样零 DOM)。
|
||||||
|
*/
|
||||||
|
/// <reference lib="dom" />
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { renderView } from '../internal/domain/view-render.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复刻 `ViewContainer.bodyOf()` + `addRow()` 的选择器链(逐行对照
|
||||||
|
* `packages/ui-kit/src/ViewContainer.tsx:164-167` 与 `:303-306`)——两函数现在已经
|
||||||
|
* `export` 了,但这份文件仍不 `import` 它们:`ViewContainer.tsx` 是 `.tsx`(含 JSX),
|
||||||
|
* 编译要 `"jsx": "react-jsx"`,而 `apps/api/**` 只受 `tsconfig.node.json` 管、没有这个
|
||||||
|
* compiler option——类型系统这一关过不去,不是 depcruise 拦着。真实函数的行为已经交给
|
||||||
|
* `packages/ui-kit/__tests__/view-container-addrow.test.ts` 去直接 import 测了,这里
|
||||||
|
* 只镜像**选择器本身**(与那份文件里的写法逐字一致),在真实 jsdom Document 上跑,
|
||||||
|
* 用来验证 `renderView()` 吐出来的 HTML 形状。
|
||||||
|
*/
|
||||||
|
function findAddRowTarget(doc: Document, address: string): HTMLElement | null {
|
||||||
|
// 镜像 bodyOf():单个 querySelector,按文档序返回第一个匹配
|
||||||
|
const host = doc.querySelector<HTMLElement>(`[data-edit="${address}"]`);
|
||||||
|
if (host === null) return null;
|
||||||
|
// 镜像 addRow() 的取「克隆样本」那一步:host 内找 tr:last-of-type
|
||||||
|
return host.querySelector<HTMLElement>('tr:last-of-type');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 与 `report.ts` 的 `CHIP_TYPE_TABLE` 逐字节对齐的镜像(4 列:# / 编号 / 类型名称 / 类型说明;
|
||||||
|
* `type_id` 只读不挂 `data-col`——自增列不问人;种子行两格 `data-col`:type_name / description)。
|
||||||
|
* `thead` 排在两个 `tbody` **之后**——这是本测试要钉死的结构。
|
||||||
|
*/
|
||||||
|
const CHIP_TYPE_FRAGMENT = `<div class="jc-tbl-wrap"><table data-edit="parts.chip_type">
|
||||||
|
<tbody data-rows="mine:parts.chip_type" data-edit="parts.chip_type"><tr>
|
||||||
|
<td class="rn"><span class="jc-rn-num"></span></td>
|
||||||
|
<td><span class="jc-vcell ro mono" data-slot="type_id" data-blank></span></td>
|
||||||
|
<td><span class="jc-vcell" data-slot="type_name" data-col="type_name" data-blank></span></td>
|
||||||
|
<td><span class="jc-vcell" data-slot="description" data-col="description" data-blank></span></td>
|
||||||
|
</tr></tbody>
|
||||||
|
<tbody><tr class="jc-row-static">
|
||||||
|
<td class="rn"><span class="jc-rn-num"></span></td>
|
||||||
|
<td><span class="jc-vcell ro">系统生成</span></td>
|
||||||
|
<td><input class="jc-cell" data-col="type_name" placeholder="新类型名称"></td>
|
||||||
|
<td><input class="jc-cell" data-col="description" placeholder="这个类型是什么(供选型参考)"></td>
|
||||||
|
</tr></tbody>
|
||||||
|
<thead><tr>
|
||||||
|
<th style="width:38px">#</th>
|
||||||
|
<th data-h="类型编号">编号</th>
|
||||||
|
<th class="req" data-h="类型名称">类型名称</th>
|
||||||
|
<th data-h="类型说明">类型说明</th>
|
||||||
|
</tr></thead>
|
||||||
|
</table></div>`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 与 `report.ts` 的 `TYPE_PARAM_TABLE` 逐字节对齐的镜像(6 列;`type_id` 是下拉
|
||||||
|
* `data-options`,不是裸文本 —— Task 7b 的另一条修改)。同样把 `thead` 挪到两个 `tbody` 之后。
|
||||||
|
*/
|
||||||
|
const TYPE_PARAM_FRAGMENT = `<div class="jc-tbl-wrap"><table data-edit="parts.type_param">
|
||||||
|
<tbody data-rows="mine:parts.type_param" data-edit="parts.type_param"><tr>
|
||||||
|
<td class="rn"><span class="jc-rn-num"></span><button class="jc-rowdel" type="button" data-edit-del="parts.type_param">×</button></td>
|
||||||
|
<td>
|
||||||
|
<span class="jc-vcell"><span data-rows="parts.chip_type" data-by="type_id=type_id"><i data-slot="type_name" data-blank></i></span></span>
|
||||||
|
<select class="jc-cell" data-col="type_id" required data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select>
|
||||||
|
</td>
|
||||||
|
<td><span class="jc-vcell" data-slot="param_name" data-col="param_name" data-blank></span></td>
|
||||||
|
<td><span class="jc-vcell" data-slot="param_desc" data-col="param_desc" data-blank></span></td>
|
||||||
|
<td><select class="jc-cell" data-col="constraint_type"><option value=""></option><option value="hard">硬约束</option><option value="soft">软约束</option></select></td>
|
||||||
|
<td><span class="jc-vcell" data-slot="param_unit" data-col="param_unit" data-blank></span></td>
|
||||||
|
</tr></tbody>
|
||||||
|
<tbody><tr class="jc-row-static">
|
||||||
|
<td class="rn"><span class="jc-rn-num"></span></td>
|
||||||
|
<td><select class="jc-cell" data-col="type_id" required data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select></td>
|
||||||
|
<td><input class="jc-cell" data-col="param_name" placeholder="参数名"></td>
|
||||||
|
<td><input class="jc-cell" data-col="param_desc" placeholder="这个参数为什么要紧"></td>
|
||||||
|
<td><select class="jc-cell" data-col="constraint_type"><option value=""></option><option value="hard">硬约束</option><option value="soft">软约束</option></select></td>
|
||||||
|
<td><input class="jc-cell" data-col="param_unit" placeholder="单位(选填)"></td>
|
||||||
|
</tr></tbody>
|
||||||
|
<thead><tr>
|
||||||
|
<th style="width:38px">#</th>
|
||||||
|
<th class="req" data-h="所属类型">所属类型</th>
|
||||||
|
<th class="req" data-h="参数名">参数名</th>
|
||||||
|
<th class="req" data-h="参数说明">参数说明</th>
|
||||||
|
<th class="req" data-h="约束类型">约束(硬/软)</th>
|
||||||
|
<th data-h="参考单位">参考单位</th>
|
||||||
|
</tr></thead>
|
||||||
|
</table></div>`;
|
||||||
|
|
||||||
|
describe('parts.chip_type(零行时):「加一行」必须落在种子行上,不是表头', () => {
|
||||||
|
it('renderView() 真实输出,零草稿行 —— 动态 tbody 空,种子行还在', () => {
|
||||||
|
const html = renderView(CHIP_TYPE_FRAGMENT, { 'mine:parts.chip_type': [] });
|
||||||
|
const doc = document.implementation.createHTMLDocument('');
|
||||||
|
doc.body.innerHTML = html;
|
||||||
|
|
||||||
|
// renderRows() 消费掉 data-rows 属性(只留 data-edit),动态 tbody 按位置认(文档序第一个 <tbody>)
|
||||||
|
const dynamicTbody = doc.querySelectorAll('tbody')[0];
|
||||||
|
expect(dynamicTbody?.querySelectorAll('tr').length).toBe(0);
|
||||||
|
expect(doc.querySelectorAll('tr.jc-row-static')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('走一遍 bodyOf()/addRow() 的真实选择器路径:克隆样本落在 tbody 里,不是 thead', () => {
|
||||||
|
const html = renderView(CHIP_TYPE_FRAGMENT, { 'mine:parts.chip_type': [] });
|
||||||
|
const doc = document.implementation.createHTMLDocument('');
|
||||||
|
doc.body.innerHTML = html;
|
||||||
|
|
||||||
|
const target = findAddRowTarget(doc, 'parts.chip_type');
|
||||||
|
expect(target).not.toBeNull();
|
||||||
|
// 命中的必须是 <tbody> 的直接子行 —— 不是 <thead> 那一行
|
||||||
|
expect(target?.parentElement?.tagName).toBe('TBODY');
|
||||||
|
expect(target?.closest('thead')).toBeNull();
|
||||||
|
// 且它就是那条种子行(class 对得上)
|
||||||
|
expect(target?.classList.contains('jc-row-static')).toBe(true);
|
||||||
|
// 含 2 个 input[data-col](type_name / description),不是 0 个(表头行会是 0)
|
||||||
|
const inputs = Array.from(target?.querySelectorAll('input[data-col]') ?? []);
|
||||||
|
expect(inputs.map((i) => i.getAttribute('data-col')).sort()).toEqual(['description', 'type_name']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parts.chip_type(已有 1 行时):「加一行」克隆的是那条真数据行', () => {
|
||||||
|
it('走一遍选择器路径:命中动态 tbody 的最后一条真实数据行,不是种子行也不是表头', () => {
|
||||||
|
const html = renderView(CHIP_TYPE_FRAGMENT, {
|
||||||
|
'mine:parts.chip_type': [{ type_id: '1', type_name: 'DC-DC 降压稳压器', description: '开关式降压转换器' }],
|
||||||
|
});
|
||||||
|
const doc = document.implementation.createHTMLDocument('');
|
||||||
|
doc.body.innerHTML = html;
|
||||||
|
|
||||||
|
const target = findAddRowTarget(doc, 'parts.chip_type');
|
||||||
|
expect(target).not.toBeNull();
|
||||||
|
// renderRows() 消费掉 data-rows 属性 —— 认「不是种子行、且是文档序第一个 tbody(动态那个)的行」
|
||||||
|
expect(target?.parentElement).toBe(doc.querySelectorAll('tbody')[0]);
|
||||||
|
expect(target?.classList.contains('jc-row-static')).toBe(false);
|
||||||
|
expect(target?.closest('thead')).toBeNull();
|
||||||
|
// 这一行真的带着那条料的值(证明不是表头、不是种子行)
|
||||||
|
expect(target?.textContent).toContain('DC-DC 降压稳压器');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parts.type_param(零行时):同款结构,同样要验', () => {
|
||||||
|
it('renderView() 真实输出,零草稿行 —— 动态 tbody 空,种子行还在', () => {
|
||||||
|
const html = renderView(TYPE_PARAM_FRAGMENT, { 'mine:parts.type_param': [] });
|
||||||
|
const doc = document.implementation.createHTMLDocument('');
|
||||||
|
doc.body.innerHTML = html;
|
||||||
|
|
||||||
|
// renderRows() 消费掉 data-rows 属性(只留 data-edit),动态 tbody 按位置认(文档序第一个 <tbody>)
|
||||||
|
const dynamicTbody = doc.querySelectorAll('tbody')[0];
|
||||||
|
expect(dynamicTbody?.querySelectorAll('tr').length).toBe(0);
|
||||||
|
expect(doc.querySelectorAll('tr.jc-row-static')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('走一遍选择器路径:克隆样本落在 tbody 里、6 个可填格子都在,不是表头(表头会是 0 个)', () => {
|
||||||
|
const html = renderView(TYPE_PARAM_FRAGMENT, { 'mine:parts.type_param': [] });
|
||||||
|
const doc = document.implementation.createHTMLDocument('');
|
||||||
|
doc.body.innerHTML = html;
|
||||||
|
|
||||||
|
const target = findAddRowTarget(doc, 'parts.type_param');
|
||||||
|
expect(target).not.toBeNull();
|
||||||
|
expect(target?.parentElement?.tagName).toBe('TBODY');
|
||||||
|
expect(target?.closest('thead')).toBeNull();
|
||||||
|
expect(target?.classList.contains('jc-row-static')).toBe(true);
|
||||||
|
const cols = Array.from(target?.querySelectorAll('input[data-col], select[data-col]') ?? [])
|
||||||
|
.map((i) => i.getAttribute('data-col'))
|
||||||
|
.sort();
|
||||||
|
expect(cols).toEqual(['constraint_type', 'param_desc', 'param_name', 'param_unit', 'type_id']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **这是 Task 7b 修复之前的真实回归**(评审逮到:「parts.type_param 那张更要紧:它的
|
||||||
|
* 加行功能在你改动之前是好的」)——`data-edit` 只挂在内层 `<tbody data-rows>` 上(不挂
|
||||||
|
* 外层 `<table>`),`thead` 排在最前面(改回归修复前的原始顺序)。这条测试**不是**用来
|
||||||
|
* 证明当前代码正确——它证明的是「如果 `data-edit` 回到只挂 `<table>` 且 `thead` 排在最前」
|
||||||
|
* 这个错误形状,选择器确实会命中表头(0 个 `input[data-col]`)。留着当变异验证的可读记录:
|
||||||
|
* 不必真的改 `report.ts` 再改回来,这条测试本身就是「变了会怎样」的固化证据。
|
||||||
|
*/
|
||||||
|
describe('回归记录:data-edit 挂外层 table 且 thead 排最前 —— 这个错误形状会命中表头', () => {
|
||||||
|
it('(此形状已不出现在 report.ts 里,这里独立复现,证明它确实是错的)', () => {
|
||||||
|
const brokenFragment = `<table data-edit="parts.chip_type">
|
||||||
|
<thead><tr><th>#</th><th>编号</th><th>类型名称</th><th>类型说明</th></tr></thead>
|
||||||
|
<tbody data-rows="mine:parts.chip_type" data-edit="parts.chip_type"></tbody>
|
||||||
|
<tbody><tr class="jc-row-static"><td class="rn"></td><td><span class="jc-vcell ro">系统生成</span></td><td><input class="jc-cell" data-col="type_name"></td><td><input class="jc-cell" data-col="description"></td></tr></tbody>
|
||||||
|
</table>`;
|
||||||
|
const html = renderView(brokenFragment, { 'mine:parts.chip_type': [] });
|
||||||
|
const doc = document.implementation.createHTMLDocument('');
|
||||||
|
doc.body.innerHTML = html;
|
||||||
|
|
||||||
|
const target = findAddRowTarget(doc, 'parts.chip_type');
|
||||||
|
expect(target).not.toBeNull();
|
||||||
|
// 命中的是表头那一行:落在 thead 里,零个 input[data-col]
|
||||||
|
expect(target?.closest('thead')).not.toBeNull();
|
||||||
|
expect(target?.querySelectorAll('input[data-col]')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* **可编辑区域套两层 `data-rows` 时,渲染器真产出什么** —— W05 数据工单版面探针
|
||||||
|
* (2026-08-30,「核心参数值」想做成"一个候选一个 section、section 里铺参数瓷贴"这形状,
|
||||||
|
* 先问渲染管线真产出什么,再问 `collect()` 拿它怎么办)。
|
||||||
|
*
|
||||||
|
* `collect()` 住在 `packages/ui-kit`,这份文件住在 `apps/api`——`ui-kit` 是 tsconfig.web.json
|
||||||
|
* 那一支(`jsx: react-jsx` + DOM lib),`apps/api` 归 tsconfig.node.json(两者都没有对方要的
|
||||||
|
* 编译选项);`packages-are-leaves` 也不许 `packages/*` 反向 import `apps/*`。两侧各钉一条、
|
||||||
|
* 中间用**同一段渲染出来的 HTML 字符串**接力,防止两份夹具各写各的、悄悄漂移。
|
||||||
|
*
|
||||||
|
* **这一条只钉 `renderView` 的真实输出**——collect() 拿这段 HTML 怎么归行,
|
||||||
|
* 见 `packages/ui-kit/__tests__/view-container-collect-nested.test.ts`
|
||||||
|
* (把下面 `NESTED_RENDERED` 这段字符串原样复制过去当夹具)。
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { renderView } from '../internal/domain/view-render.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 外层 `data-edit="parts.parameter"` 与外层 `data-rows="mine:sub_eval.part"` 共处一个标签
|
||||||
|
* (照仓里既有的可编辑表格惯例,如 `<tbody data-rows="..." data-edit="...">`)——
|
||||||
|
* 每颗候选料复制一个 `<section>`;section 里再嵌一层 `data-rows="mine:parts.parameter"
|
||||||
|
* data-by="internal_pn=internal_pn"` 铺这颗料自己的参数瓷贴,每瓷贴三格
|
||||||
|
* (`param_key` / `param_value` / 主键 `internal_pn`)。
|
||||||
|
*
|
||||||
|
* 行容器标签特意避开重名(`article` 外层、`div` 内层、瓷贴用 `span`)——
|
||||||
|
* `data-rows` 的正则按标签名非贪婪找**同名**闭合标签,内层若也叫 `article`
|
||||||
|
* 会被外层第一个提前撞见的闭合标签截断(`view-render.test.ts` 379 行那条注释记过同一个坑)。
|
||||||
|
*/
|
||||||
|
const NESTED_TPL =
|
||||||
|
'<article data-edit="parts.parameter" data-rows="mine:sub_eval.part">' +
|
||||||
|
'<section>' +
|
||||||
|
'<h3 data-slot="internal_pn"></h3>' +
|
||||||
|
'<div data-rows="mine:parts.parameter" data-by="internal_pn=internal_pn">' +
|
||||||
|
'<span class="pv">' +
|
||||||
|
'<input data-col="param_key" data-slot="param_key" data-value/>' +
|
||||||
|
'<input data-col="param_value" data-slot="param_value" data-value/>' +
|
||||||
|
'<input data-col="internal_pn" data-slot="internal_pn" data-value/>' +
|
||||||
|
'</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'</section>' +
|
||||||
|
'</article>';
|
||||||
|
|
||||||
|
const NESTED_SCOPE = {
|
||||||
|
'mine:sub_eval.part': [{ internal_pn: 'LM2596S-5.0' }, { internal_pn: 'XL2596S-5.0' }],
|
||||||
|
'mine:parts.parameter': [
|
||||||
|
{ internal_pn: 'LM2596S-5.0', param_key: 'input_voltage', param_value: '4.5~40' },
|
||||||
|
{ internal_pn: 'LM2596S-5.0', param_key: 'output_voltage', param_value: '5' },
|
||||||
|
{ internal_pn: 'LM2596S-5.0', param_key: 'enable_threshold', param_value: '2.5' },
|
||||||
|
{ internal_pn: 'XL2596S-5.0', param_key: 'input_voltage', param_value: '4.5~40' },
|
||||||
|
{ internal_pn: 'XL2596S-5.0', param_key: 'output_voltage', param_value: '5' },
|
||||||
|
{ internal_pn: 'XL2596S-5.0', param_key: 'enable_threshold', param_value: '3.0' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `renderView` 真实产出的逐字 HTML —— 与下面第一条用例的断言必须**逐字节一致**。
|
||||||
|
* 这段字符串原样复制进 `view-container-collect-nested.test.ts` 当夹具(注释互相点名)。
|
||||||
|
*/
|
||||||
|
export const NESTED_RENDERED =
|
||||||
|
'<article data-edit="parts.parameter"><section><h3>LM2596S-5.0</h3><div>' +
|
||||||
|
'<span class="pv"><input data-col="param_key" value="input_voltage"/>' +
|
||||||
|
'<input data-col="param_value" value="4.5~40"/>' +
|
||||||
|
'<input data-col="internal_pn" value="LM2596S-5.0"/></span>' +
|
||||||
|
'<span class="pv"><input data-col="param_key" value="output_voltage"/>' +
|
||||||
|
'<input data-col="param_value" value="5"/>' +
|
||||||
|
'<input data-col="internal_pn" value="LM2596S-5.0"/></span>' +
|
||||||
|
'<span class="pv"><input data-col="param_key" value="enable_threshold"/>' +
|
||||||
|
'<input data-col="param_value" value="2.5"/>' +
|
||||||
|
'<input data-col="internal_pn" value="LM2596S-5.0"/></span>' +
|
||||||
|
'</div></section><section><h3>XL2596S-5.0</h3><div>' +
|
||||||
|
'<span class="pv"><input data-col="param_key" value="input_voltage"/>' +
|
||||||
|
'<input data-col="param_value" value="4.5~40"/>' +
|
||||||
|
'<input data-col="internal_pn" value="XL2596S-5.0"/></span>' +
|
||||||
|
'<span class="pv"><input data-col="param_key" value="output_voltage"/>' +
|
||||||
|
'<input data-col="param_value" value="5"/>' +
|
||||||
|
'<input data-col="internal_pn" value="XL2596S-5.0"/></span>' +
|
||||||
|
'<span class="pv"><input data-col="param_key" value="enable_threshold"/>' +
|
||||||
|
'<input data-col="param_value" value="3.0"/>' +
|
||||||
|
'<input data-col="internal_pn" value="XL2596S-5.0"/></span>' +
|
||||||
|
'</div></section></article>';
|
||||||
|
|
||||||
|
describe('renderView:外层 data-edit+data-rows 共处一个标签,内层 data-rows+data-by 铺瓷贴', () => {
|
||||||
|
it('产出两个 <section>(host = <article> 的直接子元素),各自嵌一层 <div> 才到瓷贴 —— 逐字节核对', () => {
|
||||||
|
const out = renderView(NESTED_TPL, NESTED_SCOPE);
|
||||||
|
expect(out).toBe(NESTED_RENDERED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('变异验证:换成 3 颗候选料,输出的 <section> 数跟着变成 3 —— 证明上面的断言真的在读结构,不是凑巧写对了字符串', () => {
|
||||||
|
const out = renderView(NESTED_TPL, {
|
||||||
|
'mine:sub_eval.part': [{ internal_pn: 'A' }, { internal_pn: 'B' }, { internal_pn: 'C' }],
|
||||||
|
'mine:parts.parameter': [
|
||||||
|
{ internal_pn: 'A', param_key: 'k1', param_value: 'v1' },
|
||||||
|
{ internal_pn: 'B', param_key: 'k1', param_value: 'v1' },
|
||||||
|
{ internal_pn: 'C', param_key: 'k1', param_value: 'v1' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(out.split('<section>')).toHaveLength(4); // split 出 [头, s1, s2, s3]
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -434,4 +434,125 @@ describe('嵌套行模版与 data-by —— 一个候选一张表', () => {
|
|||||||
const all = renderView(html.replace(' data-by="rel_id"', ''), scope);
|
const all = renderView(html.replace(' data-by="rel_id"', ''), scope);
|
||||||
expect(all.split('使能逻辑').length - 1).toBe(2);
|
expect(all.split('使能逻辑').length - 1).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **已知问题,尚未修 —— 2026-08-30(ACT-WORK-05 task-12,缺陷①,浏览器真人走查逮到)**。
|
||||||
|
* 现场报的是「候选料一行的芯片类型只读态显示着一个值,而那一格实际是空的,编辑态与 SQL
|
||||||
|
* 双重核实过是空串」。原本怀疑是 `byParent` 对空父键的处理退化成显示第一行 —— 查下来这
|
||||||
|
* 个猜测是错的:上面「父值缺席一行不留」那条已经钉死了"这一行自己把父键显式写成空字符串"
|
||||||
|
* 这个场景(`rel_id: ''`),`byParent` 的 `parent === ''` 守卫恒定正确,不是这次的病根。
|
||||||
|
*
|
||||||
|
* 真正能复现的是**另一种缺席**:这一行压根**没有**这个字段(不是空字符串,是键都不存在)——
|
||||||
|
* 常见于必填但还没填的草稿行(如 ACT-WORK-05 的 input-supplement 件:候选料还没定型号时,
|
||||||
|
* `sub_eval.part` 那一行不会带 `chip_type_id` 这个键)。`renderRows` 递归处理嵌套
|
||||||
|
* `data-rows` 时用 `{ ...scope, ...one }` 合并外层与本行 —— `one` 缺席的键会**悄悄落回
|
||||||
|
* 外层 scope 里同名的字段**。而外层 scope 常常真的有一个同名字段:`engines.ts` 的
|
||||||
|
* `taskData.view()` 会把「各对象最新一行的字段名」铺到顶层(`task.data`),
|
||||||
|
* `part-substitution-assessment` 的 `case-model.ts` 就因为撞过这同一个坑,自己的 `auto`
|
||||||
|
* 配方特地避免用 `{in:'chip_type_id'}`(该文件 134 行注释:「这个名字在三张表上都有,
|
||||||
|
* 顶层同名以后写的赢」)—— 但那条护栏只保护了 case-model 自己的 S-FEEL 求值,没有覆盖到
|
||||||
|
* 这里的 `data-by` 嵌套查表,于是同一类撞名在 `view-render.ts` 这一侧原样重演。
|
||||||
|
*
|
||||||
|
* 用 `it.fails`:这条断言此刻确实会失败(已用最小复现核对过,不是这条测试自己写错)——
|
||||||
|
* 先把复现钉进套件,不在这一轮改 `view-render.ts`(共享面,E04/I01 也用 `data-by`,
|
||||||
|
* 改动需要先报给设计者裁定修法);等修完翻正、去掉 `.fails`,它就是永久回归测试。
|
||||||
|
*/
|
||||||
|
it.fails('父键在本行里压根缺席(不是空字符串)时,不该被外层作用域里恰好同名的字段顶替', () => {
|
||||||
|
const withNestedLookup =
|
||||||
|
'<tbody data-rows="parts"><tr><td data-slot="pn"></td>' +
|
||||||
|
'<span data-rows="dict" data-by="id=type_id"><i data-slot="name"></i></span>' +
|
||||||
|
'</tr></tbody>';
|
||||||
|
const out = renderView(withNestedLookup, {
|
||||||
|
type_id: '5', // 页面顶层恰好有个同名字段(如 sub_eval.case.chip_type_id 铺到顶层的产物)
|
||||||
|
parts: [{ pn: 'XL2596S-5.0' /* 这一行没有 type_id 这个键 —— 该型号还没定型 */ }],
|
||||||
|
dict: [{ id: '5', name: 'DC-DC 降压稳压器' }],
|
||||||
|
});
|
||||||
|
expect(out).not.toContain('DC-DC 降压稳压器');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 = '<select data-col="chip_type_id" data-slot="chip_type_id" data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select>';
|
||||||
|
const out = renderView(html, scope);
|
||||||
|
expect(out).toContain('<option value=""></option>');
|
||||||
|
expect(out).toContain('<option value="1">DC-DC 降压稳压器</option>');
|
||||||
|
expect(out).toContain('<option value="2" selected>LDO 线性稳压器</option>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('选项文字要转义 —— 台账里的值是人填的', () => {
|
||||||
|
const out = renderView(
|
||||||
|
'<select data-col="c" data-slot="c" data-options="t.t" data-option-value="v" data-option-label="l"></select>',
|
||||||
|
{ 't.t': [{ v: 'x', l: '<b>坏</b>' }], c: '' },
|
||||||
|
);
|
||||||
|
expect(out).toContain('<b>坏</b>');
|
||||||
|
expect(out).not.toContain('<b>坏</b>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('地址取不到 → 只留那个空选项,不塞假数据(界面不撒谎)', () => {
|
||||||
|
const out = renderView('<select data-col="c" data-options="nope.nope" data-option-value="v" data-option-label="l"></select>', {});
|
||||||
|
expect(out).toContain('<option value=""></option>');
|
||||||
|
expect(out).not.toContain('undefined');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('行内的下拉:每一行都铺同一份选项,选中的按各行自己的值', () => {
|
||||||
|
const html =
|
||||||
|
'<tbody data-rows="mine:a.b" data-edit="a.b">' +
|
||||||
|
'<tr><select data-col="chip_type_id" data-slot="chip_type_id" data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select></tr>' +
|
||||||
|
'</tbody>';
|
||||||
|
const out = renderView(html, { ...scope, 'mine:a.b': [{ chip_type_id: 1 }, { chip_type_id: 2 }] });
|
||||||
|
expect(out.match(/<option value="1">DC-DC 降压稳压器<\/option>/g)).toHaveLength(1);
|
||||||
|
expect(out.match(/<option value="1" selected>DC-DC 降压稳压器<\/option>/g)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 评审逮到的渗透:「块里有没有 selected 这个词」防不住重复处理 ——
|
||||||
|
* 一行被正确处理过、且合法地落在「没有选中项」上(值不在选项里 / 这一格还没填),
|
||||||
|
* 跟「压根没被处理过」在字面上是同一个样子:都没有 selected 这个词。
|
||||||
|
* `renderView` 收尾那一遍拿的是外层作用域,这里的外层 `chip_type_id: 2` 恰好命中
|
||||||
|
* 选项列表里的 LDO —— 若防重失灵,它会被错误地渗进这一行,而这一行自己的数据
|
||||||
|
* 说的明明是「没有选中」。
|
||||||
|
*/
|
||||||
|
const rowHtml =
|
||||||
|
'<tbody data-rows="mine:a.b" data-edit="a.b">' +
|
||||||
|
'<tr><select data-col="chip_type_id" data-slot="chip_type_id" data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select></tr>' +
|
||||||
|
'</tbody>';
|
||||||
|
|
||||||
|
it('行内下拉:该行的值不在选项里 —— 不许被外层同名的值渗进来标 selected', () => {
|
||||||
|
const out = renderView(rowHtml, { ...scope, 'mine:a.b': [{ chip_type_id: 99 }] });
|
||||||
|
expect(out).not.toContain('selected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('行内下拉:新加的空行(这一列还没填)—— 同样不许被外层同名的值渗进来', () => {
|
||||||
|
const out = renderView(rowHtml, { ...scope, 'mine:a.b': [{}] });
|
||||||
|
expect(out).not.toContain('selected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('行外的独立下拉仍按顶层作用域正确标好 selected(防重不能把这条也防没了)', () => {
|
||||||
|
const html = '<select data-col="chip_type_id" data-slot="chip_type_id" data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select>';
|
||||||
|
const out = renderView(html, scope);
|
||||||
|
expect(out).toContain('<option value="2" selected>LDO 线性稳压器</option>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('孪生件占位提示:data-ph 落成 input 的 placeholder(2026-08-31,W05 参数瓷贴)', () => {
|
||||||
|
it('带 data-ph 的可改格,孪生 input 有 placeholder;不带的没有', () => {
|
||||||
|
const html =
|
||||||
|
'<div data-rows="mine:parts.parameter" data-edit="parts.parameter"><p>' +
|
||||||
|
'<span class="jc-vcell" data-slot="param_value" data-col="param_value" data-ph="规格书上的值,查不到留空" data-blank></span>' +
|
||||||
|
'<span class="jc-vcell" data-slot="param_note" data-col="param_note" data-blank></span>' +
|
||||||
|
'</p></div>';
|
||||||
|
const out = renderView(html, { 'mine:parts.parameter': [{ param_value: '', param_note: '' }] });
|
||||||
|
expect(out).toContain('placeholder="规格书上的值,查不到留空"');
|
||||||
|
const notePart = out.slice(out.indexOf('data-col="param_note"'));
|
||||||
|
expect(notePart.slice(0, notePart.indexOf('/>'))).not.toContain('placeholder=');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export {
|
|||||||
outputControl,
|
outputControl,
|
||||||
templateIsSafe,
|
templateIsSafe,
|
||||||
} from './internal/domain/control-handover.ts';
|
} from './internal/domain/control-handover.ts';
|
||||||
export { renderView, renderTemplate, renderRows, escapeHtml, displayValue } from './internal/domain/view-render.ts';
|
export { renderView, renderTemplate, renderRows, fillOptions, escapeHtml, displayValue } from './internal/domain/view-render.ts';
|
||||||
|
|
||||||
export { createInteractionStore } from './internal/adapter/interaction-store.ts';
|
export { createInteractionStore } from './internal/adapter/interaction-store.ts';
|
||||||
export { chatMessageTable, taskViewTable } from './internal/adapter/interaction-schema.ts';
|
export { chatMessageTable, taskViewTable } from './internal/adapter/interaction-schema.ts';
|
||||||
|
|||||||
@@ -263,7 +263,9 @@ function twinInputs(inner: string, rowIndex: number): string {
|
|||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
.filter((c) => c === 'mono' || c === 'num')
|
.filter((c) => c === 'mono' || c === 'num')
|
||||||
.join(' ');
|
.join(' ');
|
||||||
const input = `<input class="jc-cell${style === '' ? '' : ` ${style}`}" data-col="${col}" data-row="${rowIndex}" data-slot="${slot}" data-value/>`;
|
/* `data-ph`:模版给这一格的输入孪生件配一句占位提示(2026-08-31,W05 参数瓷贴要「短短一句说清这格填什么」) */
|
||||||
|
const ph = /\bdata-ph\s*=\s*"([^"]*)"/.exec(attrs)?.[1];
|
||||||
|
const input = `<input class="jc-cell${style === '' ? '' : ` ${style}`}" data-col="${col}" data-row="${rowIndex}" data-slot="${slot}"${ph === undefined || ph === '' ? '' : ` placeholder="${ph}"`} data-value/>`;
|
||||||
// 只读那一份把 `data-col` 交出去 —— 它的活已经干完(告诉这里是哪一列),留着只是噪音
|
// 只读那一份把 `data-col` 交出去 —— 它的活已经干完(告诉这里是哪一列),留着只是噪音
|
||||||
return `${all.replace(/\s*\bdata-col\s*=\s*"[^"]*"/, '')}${input}`;
|
return `${all.replace(/\s*\bdata-col\s*=\s*"[^"]*"/, '')}${input}`;
|
||||||
});
|
});
|
||||||
@@ -272,6 +274,28 @@ function twinInputs(inner: string, rowIndex: number): string {
|
|||||||
/** 手写的下拉孪生件:`<select … data-col="depth">…</select>` */
|
/** 手写的下拉孪生件:`<select … data-col="depth">…</select>` */
|
||||||
const SELECT_CELL = /<select\b[^>]*\bdata-col\s*=\s*"([^"]+)"[^>]*>[\s\S]*?<\/select>/g;
|
const SELECT_CELL = /<select\b[^>]*\bdata-col\s*=\s*"([^"]+)"[^>]*>[\s\S]*?<\/select>/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **收尾防重的记号**(2026-08-29,评审逮到:「有没有 `selected` 这个词」防不住重复处理)。
|
||||||
|
*
|
||||||
|
* `renderRows` 逐行展开时用**这一行自己的作用域**先调一遍 `selectCurrent`;`renderView`
|
||||||
|
* 收尾时还会再拿**外层作用域**兜底调一遍,专为处理那些根本不在任何 `data-rows` 里的下拉
|
||||||
|
* (见 `selectCurrent` 的文档注释)。两次谁先谁后要能分清「这块处理过没有」——
|
||||||
|
* **不能拿「块里有没有 `selected` 这个词」当代理**:一行被正确处理过、且合法地落在
|
||||||
|
* 「没有选中项」上(这一行的值不在选项里,或这一格还没填),跟「压根没被处理过」在字面上
|
||||||
|
* 是同一个样子,都没有这个词。若外层作用域里恰好有一个同名的裸键、且它的值命中了某个选项
|
||||||
|
* (「行内 `chip_type_id` 与外层同名」这种情况在这仓的用法下并不罕见),收尾那一遍会把它
|
||||||
|
* **错误地** 渗进这一行 —— 界面撒了谎:下拉显示一个这一行的数据里根本不存在的选中态,
|
||||||
|
* 值是从一个无关字段借来的。这个词也可能出现在 `<option>` 自己的**显示文字**里
|
||||||
|
* (枚举标签写成「Selected Value」照样会误判)。
|
||||||
|
*
|
||||||
|
* 所以改记一个**显式的、跟内容无关的标记属性**:处理过(不管有没有真标出选中项)就打上,
|
||||||
|
* 下一遍见到标记就跳过。它是渲染期的内部记号,不该出现在交给沙箱的成品里 ——
|
||||||
|
* `renderView` 收尾前把它整个剥掉(`stripSelScoped`)。
|
||||||
|
*/
|
||||||
|
const SEL_SCOPED = /\bdata-sel-scoped\b/;
|
||||||
|
/** 剥掉 `SEL_SCOPED` 标记 —— 连同它前面那个空格一起,免得成品里留一段多余空白 */
|
||||||
|
const SEL_SCOPED_ATTR = /\s*\bdata-sel-scoped\b/g;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* **下拉的孪生件要带上当下的值**(2026-08-21 用户实测截图:「点了编辑后直接就变白了」)。
|
* **下拉的孪生件要带上当下的值**(2026-08-21 用户实测截图:「点了编辑后直接就变白了」)。
|
||||||
*
|
*
|
||||||
@@ -283,17 +307,29 @@ const SELECT_CELL = /<select\b[^>]*\bdata-col\s*=\s*"([^"]+)"[^>]*>[\s\S]*?<\/se
|
|||||||
* 找到 `value` 与当下取值相等的那个 `<option>`,补一个 `selected`。**没有值就照旧留白**
|
* 找到 `value` 与当下取值相等的那个 `<option>`,补一个 `selected`。**没有值就照旧留白**
|
||||||
* (原样交给「没答=不改」那条规矩——这里只补「有值就该看得见」,不改「没答不算话」的语义:
|
* (原样交给「没答=不改」那条规矩——这里只补「有值就该看得见」,不改「没答不算话」的语义:
|
||||||
* 人如果确实没碰这一格,交出去的还是当下这个值,原样写回等于没变)。
|
* 人如果确实没碰这一格,交出去的还是当下这个值,原样写回等于没变)。
|
||||||
|
*
|
||||||
|
* 处理完(不管有没有真标出 `selected`)都打上 `SEL_SCOPED` 标记 —— 已经打过标记的块原样放过,
|
||||||
|
* 见 `SEL_SCOPED` 的文档注释。
|
||||||
*/
|
*/
|
||||||
function selectCurrent(inner: string, scope: Readonly<Record<string, unknown>>): string {
|
function selectCurrent(inner: string, scope: Readonly<Record<string, unknown>>): string {
|
||||||
return inner.replace(SELECT_CELL, (block, col: string) => {
|
return inner.replace(SELECT_CELL, (block, col: string) => {
|
||||||
|
if (SEL_SCOPED.test(block)) return block;
|
||||||
const raw = resolveSlot(scope, col);
|
const raw = resolveSlot(scope, col);
|
||||||
if (raw === undefined || raw === null || raw === '') return block;
|
let marked = block;
|
||||||
|
if (raw !== undefined && raw !== null && raw !== '') {
|
||||||
const value = String(raw).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const value = String(raw).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
const optionRe = new RegExp(`(<option\\b[^>]*\\bvalue\\s*=\\s*"${value}"[^>]*?)(/?>)`);
|
const optionRe = new RegExp(`(<option\\b[^>]*\\bvalue\\s*=\\s*"${value}"[^>]*?)(/?>)`);
|
||||||
return optionRe.test(block) ? block.replace(optionRe, (_m, attrs: string, close: string) => `${attrs} selected${close}`) : block;
|
if (optionRe.test(marked)) marked = marked.replace(optionRe, (_m, attrs: string, close: string) => `${attrs} selected${close}`);
|
||||||
|
}
|
||||||
|
return marked.replace(/^(<select\b[^>]*?)>/, (_m, open: string) => `${open} data-sel-scoped>`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `renderView` 收尾前剥掉 `selectCurrent` 留下的内部记号 —— 交出去的是干净 HTML */
|
||||||
|
function stripSelScoped(html: string): string {
|
||||||
|
return html.replace(SEL_SCOPED_ATTR, '');
|
||||||
|
}
|
||||||
|
|
||||||
/** 手写的多行文本孪生件:`<textarea … data-col="result_summary">…</textarea>` */
|
/** 手写的多行文本孪生件:`<textarea … data-col="result_summary">…</textarea>` */
|
||||||
const TEXTAREA_CELL = /<textarea\b([^>]*\bdata-col\s*=\s*"([^"]+)"[^>]*)>[\s\S]*?<\/textarea>/g;
|
const TEXTAREA_CELL = /<textarea\b([^>]*\bdata-col\s*=\s*"([^"]+)"[^>]*)>[\s\S]*?<\/textarea>/g;
|
||||||
|
|
||||||
@@ -312,7 +348,59 @@ function textareaCurrent(inner: string, scope: Readonly<Record<string, unknown>>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 先展开行,再填标量槽 —— 顺序不能反,否则行内槽位会被外层作用域填掉 */
|
/** `<select … data-options="域.对象" data-option-value="列" data-option-label="列">` */
|
||||||
|
const OPTIONS_SELECT = /<select\b([^>]*?)\bdata-options\s*=\s*"([^"]+)"([^>]*)>([\s\S]*?)<\/select>/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **下拉的选项从台账现读**(2026-08-29,spec §5.3)。
|
||||||
|
*
|
||||||
|
* 与 `data-rows` 同一层机制:**只取值,不判断**。地址写法也一致
|
||||||
|
* (`域.对象` = 任务视图,`mine:域.对象` = 本单草稿)。
|
||||||
|
*
|
||||||
|
* 为什么必须是现读而不是写死在模版里:芯片类型是**人在件上现造**的,
|
||||||
|
* 写死等于「只能选我出包那天就有的那几种」。
|
||||||
|
*
|
||||||
|
* **在行展开之前跑一遍**(`renderView` 的最内层):选项与行无关,一次铺好,
|
||||||
|
* 行展开时整段复制进每一行;哪一个 `selected` 由 `selectCurrent` 按各行自己的值定。
|
||||||
|
* 空选项一律留着 ——「还没选」得说得出来。
|
||||||
|
*
|
||||||
|
* `data-slot` 若与 `data-options` 同时出现在同一个 `<select>` 上,这里要把它摘掉:
|
||||||
|
* `renderTemplate` 的 `SLOT` 规则认标签名不认标签种类,凡带 `data-slot` 的元素一律
|
||||||
|
* 把**整段内容**换成取值的文本 —— `<select>` 的内容是一串 `<option>`,真被当成文本槽
|
||||||
|
* 处理,选项会被冲成一行数字/文字(踩过一次:`<select>2</select>`)。
|
||||||
|
*/
|
||||||
|
export function fillOptions(html: string, scope: Readonly<Record<string, unknown>>): string {
|
||||||
|
return html.replace(OPTIONS_SELECT, (_all, before: string, address: string, after: string) => {
|
||||||
|
const raw = `${before}${after}`;
|
||||||
|
const valueCol = /\bdata-option-value\s*=\s*"([^"]+)"/.exec(raw)?.[1] ?? 'value';
|
||||||
|
const labelCol = /\bdata-option-label\s*=\s*"([^"]+)"/.exec(raw)?.[1] ?? valueCol;
|
||||||
|
const attrs = raw
|
||||||
|
.replace(/\s*\bdata-option-value\s*=\s*"[^"]*"/, '')
|
||||||
|
.replace(/\s*\bdata-option-label\s*=\s*"[^"]*"/, '')
|
||||||
|
.replace(/\s*\bdata-slot\s*=\s*"[^"]*"/, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
const rows = resolveSlot(scope, address);
|
||||||
|
const list = Array.isArray(rows) ? rows : [];
|
||||||
|
const options = list
|
||||||
|
.map((row) => {
|
||||||
|
const one = (typeof row === 'object' && row !== null ? row : {}) as Record<string, unknown>;
|
||||||
|
const value = escapeHtml(displayValue(one[valueCol]));
|
||||||
|
const label = escapeHtml(displayValue(one[labelCol]));
|
||||||
|
return `<option value="${value}">${label}</option>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
return `<select${attrs === '' ? '' : ` ${attrs}`}><option value=""></option>${options}</select>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 先铺下拉选项(行展开之前,一次铺好)→ 展开行(行内的下拉照各行自己的值定 `selected`)
|
||||||
|
* → **收尾再兜底调一遍 `selectCurrent`**(那些压根不在任何 `data-rows` 里的下拉,
|
||||||
|
* 没有「行」这一步替它调,靠这里用外层作用域补上;已经被行内那一遍定过的原样放过,
|
||||||
|
* 见 `SEL_SCOPED` 的文档注释)→ 填标量槽 → **剥掉 `selectCurrent` 留下的内部记号**
|
||||||
|
* (`stripSelScoped`,渲染期的判重记号,不该出现在交给沙箱的成品里)。顺序不能反。
|
||||||
|
*/
|
||||||
export function renderView(html: string, scope: Readonly<Record<string, unknown>>): string {
|
export function renderView(html: string, scope: Readonly<Record<string, unknown>>): string {
|
||||||
return renderTemplate(renderRows(html, scope), scope);
|
return stripSelScoped(renderTemplate(selectCurrent(renderRows(fillOptions(html, scope), scope), scope), scope));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const T6 = 't-it-6' as TaskId;
|
|||||||
const T7 = 't-it-7' as TaskId;
|
const T7 = 't-it-7' as TaskId;
|
||||||
const T8 = 't-it-8' as TaskId;
|
const T8 = 't-it-8' as TaskId;
|
||||||
const T9 = 't-it-9' as TaskId;
|
const T9 = 't-it-9' as TaskId;
|
||||||
|
const T10 = 't-it-10' as TaskId;
|
||||||
|
|
||||||
/** 哪几单算试跑 —— 由用例现填,替代 app 里那条查 `task.origin` 的 SQL */
|
/** 哪几单算试跑 —— 由用例现填,替代 app 里那条查 `task.origin` 的 SQL */
|
||||||
const SIMULATED = new Set<string>();
|
const SIMULATED = new Set<string>();
|
||||||
@@ -76,7 +77,7 @@ afterAll(async () => {
|
|||||||
.query(`delete from ledger.__object_meta where domain in ($1, $2)`, [DOMAIN, MASTER_DOMAIN])
|
.query(`delete from ledger.__object_meta where domain in ($1, $2)`, [DOMAIN, MASTER_DOMAIN])
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
await pool
|
await pool
|
||||||
.query(`delete from ledger.__draft where task_id = any($1)`, [[T1, T2, T3, T4, T5, T6, T7, T8, T9]])
|
.query(`delete from ledger.__draft where task_id = any($1)`, [[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10]])
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
await pool.end();
|
await pool.end();
|
||||||
}
|
}
|
||||||
@@ -463,3 +464,47 @@ describe('ledger · 数据工单确认即入库(范围放行)', () => {
|
|||||||
expect((await ledger.taskDrafts(T9))[0]).toMatchObject({ landed: false });
|
expect((await ledger.taskDrafts(T9))[0]).toMatchObject({ landed: false });
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **保存过的行不许跳位**(2026-08-31 用户:「点击保存后,列表里面的元素每次排列顺序
|
||||||
|
* 都不一致,需要保持顺序的一致性」)。
|
||||||
|
*
|
||||||
|
* 病根是草稿层从前按 `updated_at` 读:`put` 的 upsert 分支每次都写 `updated_at = now()`,
|
||||||
|
* 人保存了哪几行、哪几行就跳到列表末尾;同一批写下的行(配方一次 project 铺 7 行)
|
||||||
|
* `updated_at` 又完全相同,并列之间没有次级键 —— PostgreSQL 更新一行是「旧版本作废 +
|
||||||
|
* 新版本插到堆尾」,物理顺序本来就会变,于是每保存一次屏上的表就重排一次。
|
||||||
|
*
|
||||||
|
* 这一条**只有对真库才测得出来**:假 store 的 Map 天然按插入序,永远看不到这个毛病。
|
||||||
|
* 断言两件事:①同一批写下的多行,读出来就是写下的顺序;②改中间那一行(件上按保存)
|
||||||
|
* 之后**顺序一个字不变** —— 用真的 `draftWrite`(它走的就是那条 upsert)。
|
||||||
|
*/
|
||||||
|
describe('ledger · 草稿层读出来的顺序稳定(件上的列表不许每次保存都重排)', () => {
|
||||||
|
const REF = { domain: DOMAIN, object: OBJECT };
|
||||||
|
const keysOf = async (taskId: TaskId): Promise<string[]> =>
|
||||||
|
(await ledger.taskDrafts(taskId)).map((d) => String((d.key as Record<string, unknown>)['pn']));
|
||||||
|
|
||||||
|
itDb()('按诞生序读:同批写下的顺序原样保留,改了中间那一行也不跳位', async () => {
|
||||||
|
const pns = ['PN-A', 'PN-B', 'PN-C', 'PN-D', 'PN-E'];
|
||||||
|
for (const pn of pns) {
|
||||||
|
await ledger.draftWrite({ taskId: T10, address: REF, key: { pn }, patch: { pn, eau: 1 } });
|
||||||
|
}
|
||||||
|
expect(await keysOf(T10)).toEqual(pns);
|
||||||
|
|
||||||
|
/* 件上按保存 = 对已有的那一行再写一笔(走 upsert 的 update 分支) */
|
||||||
|
await ledger.draftWrite({ taskId: T10, address: REF, key: { pn: 'PN-B' }, patch: { eau: 999 } });
|
||||||
|
expect(await keysOf(T10)).toEqual(pns);
|
||||||
|
|
||||||
|
/* 再保存两次、换不同的行 —— 顺序仍是诞生序,不是「最近改过的在最后」 */
|
||||||
|
await ledger.draftWrite({ taskId: T10, address: REF, key: { pn: 'PN-D' }, patch: { eau: 7 } });
|
||||||
|
await ledger.draftWrite({ taskId: T10, address: REF, key: { pn: 'PN-A' }, patch: { eau: 8 } });
|
||||||
|
expect(await keysOf(T10)).toEqual(pns);
|
||||||
|
|
||||||
|
/* 值确实写进去了 —— 不是靠「没写成功」换来的稳定 */
|
||||||
|
const rows = await ledger.taskDrafts(T10);
|
||||||
|
expect(rows.find((d) => (d.key as Record<string, unknown>)['pn'] === 'PN-B')?.patch).toMatchObject({ eau: 999 });
|
||||||
|
|
||||||
|
/* 新写的行排在末尾(诞生序的另一半:后来的就该在后面) */
|
||||||
|
await ledger.draftWrite({ taskId: T10, address: REF, key: { pn: 'PN-F' }, patch: { pn: 'PN-F', eau: 2 } });
|
||||||
|
expect(await keysOf(T10)).toEqual([...pns, 'PN-F']);
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
|
|||||||
@@ -319,7 +319,16 @@ create table if not exists ${DRAFT_TABLE} (
|
|||||||
);
|
);
|
||||||
-- 已落地记号(2026-08-27,数据工单确认即入库):非空 = 这一行已在一道更早的门上并进正式表,
|
-- 已落地记号(2026-08-27,数据工单确认即入库):非空 = 这一行已在一道更早的门上并进正式表,
|
||||||
-- 放行时不再写它。加列走 if not exists —— 这张表由启动时的 DDL 自建,不走迁移。
|
-- 放行时不再写它。加列走 if not exists —— 这张表由启动时的 DDL 自建,不走迁移。
|
||||||
alter table ${DRAFT_TABLE} add column if not exists landed_at timestamptz;`;
|
alter table ${DRAFT_TABLE} add column if not exists landed_at timestamptz;
|
||||||
|
-- **诞生序**(2026-08-31 用户:「点击保存后,列表里面的元素每次排列顺序都不一致」):
|
||||||
|
-- 读草稿层从前按 updated_at 排,而 upsert 每次都写 updated_at = now() —— 人保存了哪几行,
|
||||||
|
-- 哪几行就跳到列表末尾;同一批写下的行(配方一次 project 铺 7 行)updated_at 又完全相同,
|
||||||
|
-- 并列之间没有次级键,PostgreSQL 返回什么顺序都算数(更新一行 = 旧版本作废 + 新版本插到
|
||||||
|
-- 堆尾,物理顺序本来就会变),于是每保存一次屏上就重排一次。
|
||||||
|
-- bigserial:**insert 那一刻取号,upsert 的 update 分支不碰它** —— 记的是「这一行是第几个
|
||||||
|
-- 被写下的」,而配方铺行的顺序正是人期望在件上看到的顺序。单键、严格全序、没有并列。
|
||||||
|
-- 与 landed_at 同款走 DDL 加列(这张表不走迁移,见上一句)。
|
||||||
|
alter table ${DRAFT_TABLE} add column if not exists seq bigserial;`;
|
||||||
|
|
||||||
type DraftRow = {
|
type DraftRow = {
|
||||||
task_id: string;
|
task_id: string;
|
||||||
@@ -346,16 +355,31 @@ function toEntry(row: DraftRow): DraftEntry & ObjectRef {
|
|||||||
|
|
||||||
export function createDraftStore(runner: Queryable): DraftStore {
|
export function createDraftStore(runner: Queryable): DraftStore {
|
||||||
return {
|
return {
|
||||||
|
/*
|
||||||
|
* **按诞生序读,不按更新时刻读**(2026-08-31 用户:「点击保存后,列表里面的元素
|
||||||
|
* 每次排列顺序都不一致,需要保持顺序的一致性」)。
|
||||||
|
*
|
||||||
|
* 从前是 `order by updated_at`,而 `put` 的 upsert 分支每次都写 `updated_at = now()`
|
||||||
|
* —— **人保存了哪几行,哪几行就跳到列表末尾**;同一批写下的行(配方一次 project
|
||||||
|
* 铺 7 行)`updated_at` 又完全相同,并列之间没有次级键,PostgreSQL 返回什么顺序都
|
||||||
|
* 算数(更新一行 = 旧版本作废 + 新版本插到堆尾,物理顺序本来就会变),于是每保存
|
||||||
|
* 一次,屏上的表就重排一次。
|
||||||
|
*
|
||||||
|
* `seq` 是 `bigserial`(迁移 0036):**insert 那一刻取号,upsert 的 update 分支不碰它**
|
||||||
|
* —— 记的正是「这一行是第几个被写下的」。而配方铺行的顺序就是人期望在件上看到的
|
||||||
|
* 顺序(原 PN 的参数在前、候选的在后,组内按字典序 —— 配方那么遍历的)。
|
||||||
|
* 单键、严格全序、没有并列,也就没有「并列之间谁先谁后」这种不确定。
|
||||||
|
*/
|
||||||
async list(taskId) {
|
async list(taskId) {
|
||||||
const res = await runner.query<DraftRow>(
|
const res = await runner.query<DraftRow>(
|
||||||
`select * from ${DRAFT_TABLE} where task_id = $1 order by updated_at`,
|
`select * from ${DRAFT_TABLE} where task_id = $1 order by seq`,
|
||||||
[taskId],
|
[taskId],
|
||||||
);
|
);
|
||||||
return res.rows.map(toEntry);
|
return res.rows.map(toEntry);
|
||||||
},
|
},
|
||||||
async listForObject(taskId, ref) {
|
async listForObject(taskId, ref) {
|
||||||
const res = await runner.query<DraftRow>(
|
const res = await runner.query<DraftRow>(
|
||||||
`select * from ${DRAFT_TABLE} where task_id = $1 and domain = $2 and object = $3 order by updated_at`,
|
`select * from ${DRAFT_TABLE} where task_id = $1 and domain = $2 and object = $3 order by seq`,
|
||||||
[taskId, ref.domain, ref.object],
|
[taskId, ref.domain, ref.object],
|
||||||
);
|
);
|
||||||
return res.rows.map(toEntry);
|
return res.rows.map(toEntry);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
- 错误:`CmmnParseError` `IllegalTransitionError` `ForbiddenTerminalError`
|
- 错误:`CmmnParseError` `IllegalTransitionError` `ForbiddenTerminalError`
|
||||||
`AutomaticOnWorldTouchingError`
|
`AutomaticOnWorldTouchingError`
|
||||||
- 端口类型:`LoopStateStore` `Sfeel` `TaskDataGateway`(含 `writable` 授权面)`AgentPort` `ToolGateway`
|
- 端口类型:`LoopStateStore` `Sfeel` `TaskDataGateway`(含 `writable` 授权面)`AgentPort` `ToolGateway`
|
||||||
`CardGateway` `ListenerRegistrar` `MemoryRecorder` `TaskContext`
|
`CardGateway` `ListenerRegistrar` `MemoryRecorder` `TaskContext` `AnalyzeGateway`
|
||||||
- 用例工厂:`createTurnRunner(deps): LoopEngineService`
|
- 用例工厂:`createTurnRunner(deps): LoopEngineService`
|
||||||
- 纯函数:`parseCmmn` `parseXml` `publishEnabled` `navigate` `evaluateCriterion`
|
- 纯函数:`parseCmmn` `parseXml` `publishEnabled` `navigate` `evaluateCriterion`
|
||||||
`transitionItem` `occur` `achieveMilestones` `reenter` `autoCompleteReady` `canTransitionItem`
|
`transitionItem` `occur` `achieveMilestones` `reenter` `autoCompleteReady` `canTransitionItem`
|
||||||
@@ -67,8 +67,20 @@
|
|||||||
(剧本两处写死「先 finish 再发下一张」,模型照样忘,两步同时 Active、件停在上一步)。
|
(剧本两处写死「先 finish 再发下一张」,模型照样忘,两步同时 Active、件停在上一步)。
|
||||||
拍首扫一遍,**拍尾再扫一遍**(人答卡那一拍里模型写完东西 doneWhen 才真;这一拍刚发卡的项排除;
|
拍首扫一遍,**拍尾再扫一遍**(人答卡那一拍里模型写完东西 doneWhen 才真;这一拍刚发卡的项排除;
|
||||||
提交放行那一拍不扫,免得把在途验收信封擦掉)。没声明 doneWhen 的人工激活项一个字不变。
|
提交放行那一拍不扫,免得把在途验收信封擦掉)。没声明 doneWhen 的人工激活项一个字不变。
|
||||||
|
- **人填的表模型不许写**(2026-08-28):剧本 `PlanStep.humanWrites` 声明的台账地址,`write` 当场拒并指路
|
||||||
|
「去发那张数据工单,值由人填」;`drop` 不拦(写错了得撤得掉)。判法认行动点名的项,点不出退回当下在办的那一步。
|
||||||
|
起因:P-10 资料记录那三格只可能从人那儿来,而模型把台账里**原 PN 那一行**的规格书逐字抄到了候选料号头上 ——
|
||||||
|
行找对了、料找错了,这份假证据会喂进差异比对当「硬约束有证据支持」。**只声明「模型永远没有来源」的表**。
|
||||||
|
- **还欠一张卡的那一步,提示词不摆 `doneWhen`**(同日):判据同时也是**摆在模型眼前的一个目标**,后面还跟着
|
||||||
|
「成立了就 finish」—— 而人工激活的项在卡发出去之前根本不许 finish。同一份提示词里两句话打架,实测它选了判据那句、
|
||||||
|
去凑数据,然后 finish 连撞五拍墙熔断。卡发出去、人答过之后判据照常摆。
|
||||||
- **画面跟着这一拍发出去的卡走**(同日):human / entry / decide 把「卡是哪一步的」交回拍内,
|
- **画面跟着这一拍发出去的卡走**(同日):human / entry / decide 把「卡是哪一步的」交回拍内,
|
||||||
那一步声明了画面就推它,压过「第一步没干完的」—— 卡是发给人的那件事,件必须是那件事的件。
|
那一步声明了画面就推它,压过「第一步没干完的」—— 卡是发给人的那件事,件必须是那件事的件。
|
||||||
|
- **auto 步不问模型**(2026-08-29,`turn-runner` 拍首接线 `createAutoRunner`):`PlanStep.auto` 存在时,
|
||||||
|
拍首先跑配方;发了卡 / submit 就收拍(零 token)。有 `guide` 的照常问模型(半 auto);
|
||||||
|
两者都没有且 `doneWhen` 不成立 → 交人(配方没做到位,是包的 bug)。
|
||||||
|
- **有未答的卡在场时,配方只跑 `project` / `say`** —— 只算、只写,不发新卡、不 submit、不调 AI。
|
||||||
|
- **配方每一笔之前刷新任务视图**:后一笔常要数前一笔刚写下的行,不刷新就恒为 0。
|
||||||
- **里程碑前提达成即达成**(2026-07-30,`achieveMilestones`):CMMN 的里程碑没有执行体,
|
- **里程碑前提达成即达成**(2026-07-30,`achieveMilestones`):CMMN 的里程碑没有执行体,
|
||||||
也没有谁去按它。从前只有 `onEvent(ref = 里程碑 id)` 会 occur,而系统里没有任何东西
|
也没有谁去按它。从前只有 `onEvent(ref = 里程碑 id)` 会 occur,而系统里没有任何东西
|
||||||
发这种事件 —— 于是里程碑永远 `Available`:既是必做项(堵死 autoComplete),
|
发这种事件 —— 于是里程碑永远 `Available`:既是必做项(堵死 autoComplete),
|
||||||
@@ -146,9 +158,43 @@
|
|||||||
|
|
||||||
近变(2026-08-27):`cards.issue` 的入参多一项可选 `itemId`(这张卡挂在哪个计划项上)—— 组装层据此查剧本声明
|
近变(2026-08-27):`cards.issue` 的入参多一项可选 `itemId`(这张卡挂在哪个计划项上)—— 组装层据此查剧本声明
|
||||||
(`PlanStep.cardVariant`:这一步派的是数据工单),**不靠模型抄卡骨架**(实测它会把 `variant` 抄丢)。
|
(`PlanStep.cardVariant`:这一步派的是数据工单),**不靠模型抄卡骨架**(实测它会把 `variant` 抄丢)。
|
||||||
|
`auto` 配方的 `card` 动作也带 `variant`:执行工单认 `data`(数据工单)与 `accept`(验收工单 —— 件只读、人只确认,2026-08-31),决策卡认 `review`(审核卡);按值窄化,别串台。
|
||||||
引擎自己发的卡(验收 / 兜底)没有项,不传。
|
引擎自己发的卡(验收 / 兜底)没有项,不传。
|
||||||
|
|
||||||
近变(2026-08-27):`cards.decide` 同样多一项可选 `itemId`(`cardVariant: 'review'` = 审核卡摆成验收工单的样子,机制仍是决策卡);
|
近变(2026-08-27):`cards.decide` 同样多一项可选 `itemId`(`cardVariant: 'review'` = 审核卡摆成验收工单的样子,机制仍是决策卡);
|
||||||
决策卡的回答进基座读数 `decision_last` / `decision_<值>_total`(按 card-reply 数)—— 审核通过与否不再由模型自写的旗说了算;
|
决策卡的回答进基座读数 `decision_last` / `decision_<值>_total`(按 card-reply 数)—— 审核通过与否不再由模型自写的旗说了算;
|
||||||
拒回 / 观察 / 事实行按答案分岔(`chosenGuidance`):`custom:` 不是通过,不许写通过旗、不许 finish。
|
拒回 / 观察 / 事实行按答案分岔(`chosenGuidance`):`custom:` 不是通过,不许写通过旗、不许 finish。
|
||||||
|
|
||||||
|
近变(2026-08-29,终审修复波):`TaskDataGateway` 两处扩面,都是**配方专用**,模型那一路一个字不变 ——
|
||||||
|
|
||||||
|
- `draftWrite` 多两个可选入参与一个可选出参:
|
||||||
|
- `skipUnchanged` —— 值没变就**不落笔**。`DraftStore.put` 的 upsert 尾巴上有 `landed_at = null`
|
||||||
|
(「再写一笔就不再是已落地」),而配方每一拍都把同一张表重投一遍:照写会把数据工单确认入库的那些行
|
||||||
|
**悄悄 un-land**,整单放行时再写一遍正式表、再抛一遍 data 事件 —— 而对 landed 行的承诺是
|
||||||
|
「整单放行不再写它、不抛事件」。模型不传这个旗:它的「空转写」要被看见(如实回 `changed:false`)。
|
||||||
|
- `skipExisting` —— 「只建不改」(`createOnly`)的表碰到已在正式表里的键,回 `{changed:false, skipped:true}`
|
||||||
|
而不是抛。那句拒回是**写给模型看的**(「别再写它,用 drop 把骨架撤掉」),摆到人面前毫无意义;
|
||||||
|
而配方抛出去就是一单永久停在那儿。对配方来说「这颗料已经在库了」是常态,不是错。
|
||||||
|
跳过的行照旧算「这一笔要的行」,`prune` 不许撤它。
|
||||||
|
- `writable` 多一个可选 `caseKey`(单标识列):摆给人看的行标识要**去掉单号那一段**
|
||||||
|
(2026-08-25 那条口径,基座那一侧是 `rowLabel`)—— 人正在这一单里看,单号是脚下的地面。
|
||||||
|
- `draftDrop` 多一个可选入参 `skipLanded` 与可选出参 `landed`(2026-08-31,同族第三个旗):
|
||||||
|
已随「数据工单确认即入库」落地的行,配方 `prune` 传 `skipLanded` 时回 `{dropped:false, landed:true}`
|
||||||
|
而不是抛 —— 已落地 = 已是世界的一部分,不是陈旧草稿,跳过是配方的常态;抛出去就是整单停摆
|
||||||
|
(2026-08-30 e2e 案例 2 的死锁,当时以「W05 ②③ 两笔不带 prune」绕开,代价是**没落地的**
|
||||||
|
陈旧行也不清 —— 2026-08-31 用户实测点名的「改回同一类型,下面还是两个类型」)。
|
||||||
|
模型那一路不传:那句「已入库撤不掉」的拒回是它的行动指南,一个字不变。
|
||||||
|
- 配方 `card` 多两个可选键 `gateWhen` / `gateWhenSay`(2026-08-31):确认闸的**条件档** ——
|
||||||
|
除了「必填缺几格」,发卡的人还可声明一条 S-FEEL 条件(只读基座读数,装包静态审同
|
||||||
|
`when` 一套审),受理回传前在 app 层 `submitGate` 现算,为假不受理、拒回用发卡人那句话
|
||||||
|
(W05 第一例:两颗料不是同一个芯片类型不许确认)。运行时评估不出的条件不成墙。
|
||||||
|
|
||||||
|
同批三条不变量:
|
||||||
|
- **配方由 `liveStep` 驱动,不由 `current` 的兜底驱动**:兜底(在场的第一个有剧本的项)不过滤
|
||||||
|
`settledIds`,它只服务提示词;让它驱动副作用 = 已收掉的步再写一遍台账、再 prune 一遍、甚至再发一张卡。
|
||||||
|
- **配方的 `submit` 与模型的 submit 同一条处置**:`raiseAcceptance` 抛了不许让回合崩(留痕 + `running`)——
|
||||||
|
裸着调的话,`engine.advance` / `task-service.tick` / `task-advancer` 一路都不捕获,任务留在 `running`,
|
||||||
|
推进器无限热循环:不烧预算、不换状态、界面纹丝不动。重做闸的「改过台账」也认配方的写
|
||||||
|
(`observation` + `detail.auto` + `detail.ledger`),否则那堵墙对配方永远开不了。
|
||||||
|
- **配方办不下去 = 发一张卡交人**,不是 `awaiting-event` 干等:推进器只捡 `running` 的单,
|
||||||
|
卡已答完、没立监听、没有时限时再没有任何东西会叫醒它,而界面写着「等外部回音」。
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { evalExpr, evalText, rowsAt } from '../internal/domain/auto-expr.ts';
|
||||||
|
|
||||||
|
/** 假的 S-FEEL:只认这份测试用得到的两句,够验证「条件走的是注入进来的求值器」 */
|
||||||
|
const condition = (expr: string, scope: Readonly<Record<string, unknown>>): 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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
/**
|
||||||
|
* 配方字段求值序 —— `orderFields` 的单元测试。
|
||||||
|
*
|
||||||
|
* 背景见 `.superpowers/sdd/2026-08-29-act-work-05-rewrite/judgement-class-probe.md`:
|
||||||
|
* `fields: {...}` 经一趟 jsonb 存取后键序不再保证,`auto-runner` 逐字段求值本依赖
|
||||||
|
* 「声明序 = 依赖序」这条隐藏假设。这里直接测 `orderFields` 本身(端到端的
|
||||||
|
* 「模拟 jsonb 重排 + 真跑 auto-runner」回归测试在 `auto-runner.test.ts`)。
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { AutoExpr } from '@contracts';
|
||||||
|
import { AutoFieldCycleError, orderFields, orderLets } from '../internal/domain/auto-order.ts';
|
||||||
|
|
||||||
|
/** 只看排出来的字段名序列,不看表达式本身 */
|
||||||
|
const names = (fields: Readonly<Record<string, AutoExpr>>) => orderFields(fields).map(([name]) => name);
|
||||||
|
/** 同上,给 `orderLets` 用 */
|
||||||
|
const letNames = (lets: Readonly<Record<string, AutoExpr>>) => orderLets(lets).map(([name]) => name);
|
||||||
|
|
||||||
|
describe('orderFields', () => {
|
||||||
|
it('没有 when 的字段:谁先谁后都行,数量对得上就好', () => {
|
||||||
|
const fields: Record<string, AutoExpr> = { b: { col: 'x' }, a: 'literal', c: { in: 'y' } };
|
||||||
|
expect(names(fields).sort()).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('B 依赖 A(when 里提到 A 的名字):A 排到 B 前面,不管声明序', () => {
|
||||||
|
// 声明序故意把依赖方 b 摆在被依赖方 a 前面 —— 就是 jsonb 重排会制造出的形状
|
||||||
|
const fields: Record<string, AutoExpr> = {
|
||||||
|
b: { when: 'a = "x"', then: 'hit', else: 'miss' },
|
||||||
|
a: 'x',
|
||||||
|
};
|
||||||
|
const order = names(fields);
|
||||||
|
expect(order.indexOf('a')).toBeLessThan(order.indexOf('b'));
|
||||||
|
});
|
||||||
|
|
||||||
|
/* 逐字照抄 activities/part-substitution-assessment 的 S2_assess 那笔真实配方的依赖形状:
|
||||||
|
difference_status(叶子)→ judgement_class(依赖 difference_status)→ risk_level(依赖 judgement_class) */
|
||||||
|
it('三级链:C 依赖 B、B 依赖 A —— 排出 A、B、C,不管输入是哪种乱序', () => {
|
||||||
|
const chain: Record<string, AutoExpr> = {
|
||||||
|
/* A:不依赖同笔任何字段 */
|
||||||
|
difference_status: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'difference_status', else: '' },
|
||||||
|
/* B:when 链条里提到 A */
|
||||||
|
judgement_class: {
|
||||||
|
when: 'difference_status = "match"',
|
||||||
|
then: 'match',
|
||||||
|
else: { when: 'difference_status = "unknown"', then: 'unknown', else: 'mismatch' },
|
||||||
|
},
|
||||||
|
/* C:when 里提到 B */
|
||||||
|
risk_level: { when: 'judgement_class = "match"', then: 'low', else: 'high' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 用「先短后长、同长字典序」的 jsonb 重排顺序喂进去(与生产实测的重排结果同构)
|
||||||
|
const jsonbLike: Record<string, AutoExpr> = {};
|
||||||
|
for (const key of ['risk_level', 'judgement_class', 'difference_status']) jsonbLike[key] = chain[key] as AutoExpr;
|
||||||
|
|
||||||
|
const order = names(jsonbLike);
|
||||||
|
expect(order.indexOf('difference_status')).toBeLessThan(order.indexOf('judgement_class'));
|
||||||
|
expect(order.indexOf('judgement_class')).toBeLessThan(order.indexOf('risk_level'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('依赖藏在 lookup.by / else 里也认得出', () => {
|
||||||
|
const fields: Record<string, AutoExpr> = {
|
||||||
|
target: { when: 'source = "x"', then: 'hit', else: 'miss' },
|
||||||
|
source: { lookup: 'result:items', by: { k: 'v' }, take: 't', else: { when: 'flag = "y"', then: 'a', else: 'b' } },
|
||||||
|
flag: 'y',
|
||||||
|
};
|
||||||
|
const order = names(fields);
|
||||||
|
expect(order.indexOf('source')).toBeLessThan(order.indexOf('target'));
|
||||||
|
expect(order.indexOf('flag')).toBeLessThan(order.indexOf('source'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('依赖藏在 cat / fmt 里也认得出', () => {
|
||||||
|
const fields: Record<string, AutoExpr> = {
|
||||||
|
label: { cat: ['前缀-', { when: 'base != ""', then: { fmt: { in: 'x' }, as: 'digits' }, else: 'x' }] },
|
||||||
|
base: '5',
|
||||||
|
};
|
||||||
|
const order = names(fields);
|
||||||
|
expect(order.indexOf('base')).toBeLessThan(order.indexOf('label'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('只认同一个 fields 对象内的名字 —— 引用台账列名 / 基座读数这类全局名字不算依赖,不参与排序', () => {
|
||||||
|
const fields: Record<string, AutoExpr> = {
|
||||||
|
a: { when: 'some_ledger_column = "x"', then: '1', else: '0' },
|
||||||
|
b: { when: 'sub_eval_part_rows > 0', then: '1', else: '0' },
|
||||||
|
};
|
||||||
|
// 两个字段互不相干,不该抛错,数量对得上即可
|
||||||
|
expect(names(fields).sort()).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('真正的环(A 依赖 B、B 依赖 A):抛 AutoFieldCycleError,不默默给错答案', () => {
|
||||||
|
const fields: Record<string, AutoExpr> = {
|
||||||
|
a: { when: 'b = "x"', then: '1', else: '0' },
|
||||||
|
b: { when: 'a = "x"', then: '1', else: '0' },
|
||||||
|
};
|
||||||
|
expect(() => orderFields(fields)).toThrow(AutoFieldCycleError);
|
||||||
|
try {
|
||||||
|
orderFields(fields);
|
||||||
|
expect.unreachable();
|
||||||
|
} catch (err) {
|
||||||
|
expect(err).toBeInstanceOf(AutoFieldCycleError);
|
||||||
|
expect([...(err as AutoFieldCycleError).cycle].sort()).toEqual(['a', 'b']);
|
||||||
|
expect((err as Error).message).toContain('循环依赖');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **自引用不算环,故意的**:字段 `a` 的 `when` 提到 `a` 时,那个名字未必是「我自己算出来的值」——
|
||||||
|
* `evalExpr` 的合并作用域里 `row`(源行本身的列)也在,常见写法是「拿源行同名列做校验后原样投影」
|
||||||
|
* (如 `status: { when: 'status = "active"', then: 'A', else: 'B' }`,这里的 `status` 指的是
|
||||||
|
* `ctx.row.status`)。把自引用当环拦下会把这种常见写法误判成装不上的坏配方 ——
|
||||||
|
* 排序对它也没有意义(不管排在哪一位,求值那一刻自己的 `computed` 值必然还没写进去,
|
||||||
|
* 这是求值时序的必然,不是「顺序没排对」)。
|
||||||
|
*/
|
||||||
|
it('自己依赖自己(when 里提到自己的名字)不算环 —— 常见写法是拿源行同名列做校验', () => {
|
||||||
|
const fields: Record<string, AutoExpr> = { a: { when: 'a = "x"', then: '1', else: '0' } };
|
||||||
|
expect(() => orderFields(fields)).not.toThrow();
|
||||||
|
expect(names(fields)).toEqual(['a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('返回的 [name, expr] 就是原表达式(调用方拿它当 Object.entries 的替代品)', () => {
|
||||||
|
const expr: AutoExpr = { col: 'pn' };
|
||||||
|
const out = orderFields({ only: expr });
|
||||||
|
expect(out).toEqual([['only', expr]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `orderLets` —— `recipe.let` 版的求值序(2026-08-30 全分支终审 Important 1)。
|
||||||
|
*
|
||||||
|
* `let` 与 `fields` 的依赖读法**不一样**:`fields` 的字段互相看见只能靠 `when` 字符串
|
||||||
|
* 里的裸标识符(`{in:X}` 在 `fields` 里读 `lets`/`scope`,读不到同一个 `fields` 对象里
|
||||||
|
* 的另一个字段);`let` 的官方跨条目读法**就是** `{in:X}`(`packages/contracts/src/
|
||||||
|
* auto-recipe.ts` 的注释「先算一遍,后面所有表达式都能 {in:'名'} 引用」)。所以这里
|
||||||
|
* 单独一组测试,不是照抄 `orderFields` 那组把函数名换一下 —— 头两条专门钉住这个差异。
|
||||||
|
*/
|
||||||
|
describe('orderLets', () => {
|
||||||
|
it('**`{in:X}` 本身就是依赖**(let 与 fields 的关键差异):B 用 {in:"a"} 读 A,A 必须排到 B 前面', () => {
|
||||||
|
// 声明序故意把依赖方 b 摆在被依赖方 a 前面 —— 就是 jsonb 重排会制造出的形状
|
||||||
|
const lets: Record<string, AutoExpr> = {
|
||||||
|
b: { cat: [{ in: 'a' }, '-suffix'] },
|
||||||
|
a: 'x',
|
||||||
|
};
|
||||||
|
const order = letNames(lets);
|
||||||
|
expect(order.indexOf('a')).toBeLessThan(order.indexOf('b'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('三级链,全靠 {in:X} 串起来:C 依赖 B、B 依赖 A —— 排出 A、B、C,不管输入是哪种乱序', () => {
|
||||||
|
const chain: Record<string, AutoExpr> = {
|
||||||
|
evalRef: { in: 'case:openedAt' },
|
||||||
|
/* B:{in:X} 提到 A */
|
||||||
|
stamp: { cat: ['W05-', { in: 'evalRef' }] },
|
||||||
|
/* C:{in:X} 提到 B */
|
||||||
|
tag: { cat: [{ in: 'stamp' }, '-X'] },
|
||||||
|
};
|
||||||
|
// 用「先短后长、同长字典序」的 jsonb 重排顺序喂进去
|
||||||
|
const jsonbLike: Record<string, AutoExpr> = {};
|
||||||
|
for (const key of ['tag', 'stamp', 'evalRef']) jsonbLike[key] = chain[key] as AutoExpr;
|
||||||
|
|
||||||
|
const order = letNames(jsonbLike);
|
||||||
|
expect(order.indexOf('evalRef')).toBeLessThan(order.indexOf('stamp'));
|
||||||
|
expect(order.indexOf('stamp')).toBeLessThan(order.indexOf('tag'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('when 字符串里的裸标识符同样算依赖(与 fields 那半条规矩一致,let 的 when 也进含 ...lets 的合并作用域)', () => {
|
||||||
|
const lets: Record<string, AutoExpr> = {
|
||||||
|
b: { when: 'a = "x"', then: 'hit', else: 'miss' },
|
||||||
|
a: 'x',
|
||||||
|
};
|
||||||
|
const order = letNames(lets);
|
||||||
|
expect(order.indexOf('a')).toBeLessThan(order.indexOf('b'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('依赖藏在 lookup.by / else / cat / fmt 里也认得出(与 fields 那组同款覆盖)', () => {
|
||||||
|
const lets: Record<string, AutoExpr> = {
|
||||||
|
target: { lookup: 'parts.chip_type', by: { type_id: { in: 'source' } }, take: 'type_name', else: '' },
|
||||||
|
source: { fmt: { in: 'raw' }, as: 'digits' },
|
||||||
|
raw: '5',
|
||||||
|
};
|
||||||
|
const order = letNames(lets);
|
||||||
|
expect(order.indexOf('source')).toBeLessThan(order.indexOf('target'));
|
||||||
|
expect(order.indexOf('raw')).toBeLessThan(order.indexOf('source'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('没有互相依赖的 let:谁先谁后都行,数量对得上就好', () => {
|
||||||
|
const lets: Record<string, AutoExpr> = { b: { in: 'x' }, a: 'literal', c: { in: 'y' } };
|
||||||
|
expect(letNames(lets).sort()).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('只认同一个 let 对象内的名字 —— {in:X} 引用起单入参 / case: / 基座读数这类全局名字不算依赖,不参与排序', () => {
|
||||||
|
const lets: Record<string, AutoExpr> = {
|
||||||
|
a: { in: 'originalInternalPn' },
|
||||||
|
b: { in: 'case:openedAt' },
|
||||||
|
};
|
||||||
|
// 两条互不相干,不该抛错,数量对得上即可
|
||||||
|
expect(letNames(lets).sort()).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('真正的环({in:X} 互指):抛 AutoFieldCycleError,话里点名是 let(noun 参数),不默默给错答案', () => {
|
||||||
|
const lets: Record<string, AutoExpr> = { a: { in: 'b' }, b: { in: 'a' } };
|
||||||
|
expect(() => orderLets(lets)).toThrow(AutoFieldCycleError);
|
||||||
|
try {
|
||||||
|
orderLets(lets);
|
||||||
|
expect.unreachable();
|
||||||
|
} catch (err) {
|
||||||
|
expect(err).toBeInstanceOf(AutoFieldCycleError);
|
||||||
|
expect([...(err as AutoFieldCycleError).cycle].sort()).toEqual(['a', 'b']);
|
||||||
|
expect((err as Error).message).toContain('循环依赖');
|
||||||
|
expect((err as Error).message).toContain('let');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('自己依赖自己({in:X} 提到自己的名字)不算环 —— 常见写法是把同名入参"提升"进 let 命名空间', () => {
|
||||||
|
const lets: Record<string, AutoExpr> = { chip_type_id: { in: 'chip_type_id' } };
|
||||||
|
expect(() => orderLets(lets)).not.toThrow();
|
||||||
|
expect(letNames(lets)).toEqual(['chip_type_id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('返回的 [name, expr] 就是原表达式(调用方拿它当 Object.entries 的替代品)', () => {
|
||||||
|
const expr: AutoExpr = { in: 'evalRef' };
|
||||||
|
const out = orderLets({ only: expr });
|
||||||
|
expect(out).toEqual([['only', expr]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { expandSource } from '../internal/domain/auto-source.ts';
|
||||||
|
|
||||||
|
const condition = (): boolean => false;
|
||||||
|
const ctxWith = (scope: Record<string, unknown>) => ({ 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,349 @@
|
|||||||
|
/**
|
||||||
|
* **这份文件测的是什么、不测什么**(2026-08-29,评审二轮把靶子写准)。
|
||||||
|
*
|
||||||
|
* 测:**引擎逐 action 刷新时机**的回归 —— 一笔 `project` 若读了另一笔 `project` 才会撑出来的
|
||||||
|
* 基座读数(`_missing` / `_rows` / `_distinct_` 这类),它必须排在被读的那几笔**之后**,否则读到
|
||||||
|
* 的是这一拍刷新之前的旧数。这一条只有把真引擎(`createAutoRunner`)跑起来才测得出 ——
|
||||||
|
* 纯结构断言看不出"次序对不对会不会导致某个值算错"。
|
||||||
|
*
|
||||||
|
* 不测:`case-model.ts` 里 `S1_inputs.auto.do` 六笔**当前逐字长什么样**。那件事已经由
|
||||||
|
* `activities/part-substitution-assessment/__tests__/steps.test.ts` 的"六笔,次序是…"那条用例
|
||||||
|
* 用精确数组相等钉在**真** `ASSESSMENT_STEPS` 上了。下面手写的六个 `ACT_*` 只是**在形状上有
|
||||||
|
* 代表性**的样本(覆盖 S1 实际用到的四种地址写法:字面 `rows` / 单层 `each+where` / 两层
|
||||||
|
* `each+where+outer` / `once`)——它们不随 `case-model.ts` 自动同步,也**不必**逐字同步:
|
||||||
|
* 那是个没人能守的承诺。谁改了 S1 配方的**形状**(不是次序),该顺手核一遍这份文件还有没有
|
||||||
|
* 代表性,欠账记在 `docs/debt.md`。
|
||||||
|
*
|
||||||
|
* **为什么手写这六笔,不直接 import 真配方**:`activities/*` 与 `apps/*` 互相不许 import
|
||||||
|
* (`.dependency-cruiser.cjs` 的 `activities-isolated` / `apps-not-into-activities`,ADR-004)——
|
||||||
|
* 测试文件不例外。这一条是真的躲不掉。
|
||||||
|
*
|
||||||
|
* **读数层四个函数不手写,直接 import 真身**(评审二轮指出的修正)——`rowReadings` /
|
||||||
|
* `missingReadings` / `tallyReadings` / `distinctReadings` 是 `apps/api/src/app/wiring/engines.ts`
|
||||||
|
* 的具名导出;本文件住在 `apps/api/src/modules/loop-engine/__tests__/`,import 它们是
|
||||||
|
* **`apps` → `apps`**,`activities-isolated` 与 `apps-not-into-activities` 一条都不沾,
|
||||||
|
* `modules/loop-engine` 也不会因此反向依赖 `app/wiring`(这份 import 只在测试文件里,
|
||||||
|
* `depcruise apps packages activities` 的产出模块图会照实反映出来)。第一版误判"读数层也躲不掉"
|
||||||
|
* 而手抄了一份,结果就在同一笔提交里漂了:`missingReadings` 拼缺项名的真身是
|
||||||
|
* `gaps.map((f) => f.label ?? f.name)`(`engines.ts:378`),手抄件漏了 `label ?? ` 那半句 ——
|
||||||
|
* 这个仓已经有四个既有测试文件在做同样的事(`apps/api/src/app/__tests__/{ledger-coerce,
|
||||||
|
* decision-card-face,view-table-edit,acceptance-gate}.test.ts` 都 import 自 `../wiring/engines.ts`),
|
||||||
|
* 抄反而是舍近求远。
|
||||||
|
*
|
||||||
|
* **路径走的是 `@app/*` 别名,不是相对路径**:那四个既有测试文件住在 `apps/api/src/app/__tests__/`,
|
||||||
|
* 与 `engines.ts` 只隔一层(`../wiring/engines.ts`);本文件住在 `apps/api/src/modules/loop-engine/
|
||||||
|
* __tests__/`,相对路径要爬三层(`../../../app/wiring/engines.ts`),撞上 `eslint.config.js` 的
|
||||||
|
* `no-restricted-imports`(`'../../../*'` 一律拒,提示"跨模块请用路径别名")—— 仓里此前没有别名
|
||||||
|
* 能到 `apps/api/src/app/*`(该目录只被同目录下的测试用一层相对路径引用过),所以新增了
|
||||||
|
* `@app/*`(`tsconfig.base.json` + `vitest.config.ts` 各一行,与既有的 `@modules/*` / `@shared/*`
|
||||||
|
* 同一种写法、同一种粒度)。取名 `@app` 而不撞现有的 pnpm 工作区包名 `@app/api` / `@app/web`——
|
||||||
|
* 那两个名字只在 `package.json` scripts 里当 `pnpm --filter` 的过滤参数用,从未在任何 `.ts` 源码里
|
||||||
|
* 被 `import` 过(已用 grep 核过),且 `apps/api/package.json` 的 `exports` 只暴露了 `"."` 这一个
|
||||||
|
* 入口(整个 server 的 `main.ts`),没有暴露任何子路径,两套解析各管各的,不会互相截胡。
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { AutoRecipe } from '@contracts';
|
||||||
|
import { taskIdSchema, staffIdSchema } from '@contracts';
|
||||||
|
import { evaluateCondition } from '@sfeel';
|
||||||
|
import { distinctReadings, missingReadings, rowReadings, tallyReadings } from '@app/wiring/engines.ts';
|
||||||
|
import { createAutoRunner } from '../internal/usecase/auto-runner.ts';
|
||||||
|
|
||||||
|
const TASK = taskIdSchema.parse('33333333-3333-4333-8333-333333333333');
|
||||||
|
const OWNER = staffIdSchema.parse('44444444-4444-4444-8444-444444444444');
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **四张台账的字段元数据**(形状与 `manifest.ts` 一致,与真配方同款"镜像而非 import"取舍)——
|
||||||
|
* 写法照抄 `apps/api/src/app/__tests__/ledger-coerce.test.ts` 里 `DECL` 那几个 fixture 的样子:
|
||||||
|
* 不需要显式类型标注,直接把这份数据交给 import 进来的真函数,由调用点结构匹配。
|
||||||
|
*/
|
||||||
|
const DECLARED = [
|
||||||
|
{
|
||||||
|
domain: 'sub_eval',
|
||||||
|
object: 'part',
|
||||||
|
caseKey: 'eval_ref',
|
||||||
|
fields: [
|
||||||
|
{ name: 'eval_ref', primaryKey: true },
|
||||||
|
{ name: 'internal_pn', primaryKey: true },
|
||||||
|
{ name: 'role', primaryKey: false, required: true, label: '角色', tally: ['original', 'candidate'] },
|
||||||
|
{ name: 'found_pn_ref', primaryKey: false, label: '在库核对依据', requiredWhen: { column: 'exists_status', value: 'found' } },
|
||||||
|
{ name: 'exists_status', primaryKey: false, required: true, label: '在库状态', tally: ['found', 'absent'] },
|
||||||
|
{ name: 'chip_type_id', primaryKey: false, required: true, label: '芯片类型', distinct: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
domain: 'sub_eval',
|
||||||
|
object: 'case',
|
||||||
|
caseKey: 'eval_ref',
|
||||||
|
fields: [
|
||||||
|
{ name: 'eval_ref', primaryKey: true },
|
||||||
|
{ name: 'chip_type_id', primaryKey: false },
|
||||||
|
{ name: 'gate_reason', primaryKey: false },
|
||||||
|
{ name: 'summary', primaryKey: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
domain: 'parts',
|
||||||
|
object: 'profile',
|
||||||
|
fields: [
|
||||||
|
{ name: 'internal_pn', primaryKey: true },
|
||||||
|
{ name: 'chip_type_id', primaryKey: false, required: true, label: '芯片类型' },
|
||||||
|
{ name: 'brand_name', primaryKey: false, required: true, label: '品牌名称' },
|
||||||
|
{ name: 'lifecycle_status', primaryKey: false, required: true, label: '生命周期状态', tally: ['active', 'nrnd', 'eol', 'discontinued', 'risk'] },
|
||||||
|
{ name: 'specification_description', primaryKey: false, expect: true, label: '规格描述' },
|
||||||
|
{ name: 'manufacturer_name', primaryKey: false, expect: true, label: '制造商名称' },
|
||||||
|
{ name: 'datasheet_ref', primaryKey: false, expect: true, label: 'Datasheet 引用' },
|
||||||
|
{ name: 'datasheet_version', primaryKey: false, expect: true, label: 'Datasheet 版本' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
domain: 'parts',
|
||||||
|
object: 'parameter',
|
||||||
|
fields: [
|
||||||
|
{ name: 'internal_pn', primaryKey: true },
|
||||||
|
{ name: 'param_name', primaryKey: true },
|
||||||
|
{ name: 'param_value', primaryKey: false, required: true, label: '值' },
|
||||||
|
{ name: 'param_unit', primaryKey: false, expect: true, label: '单位' },
|
||||||
|
{ name: 'param_note', primaryKey: false, expect: true, label: '说明' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 假端口 —— 手写的依赖注入替身(不是 mock 框架),`view()` 按生产 `taskData.view()` 同一份配方
|
||||||
|
* 拼作用域:`{...seed, ...读数四件套(真函数), ...mine, ...flattened}`。这一层组合逻辑本身
|
||||||
|
* (`engines.ts` 里内嵌在 `wireEngines(deps).taskData.view` 闭包中的那一段)没有被抽成可 import
|
||||||
|
* 的纯函数、且与 `deps.ledger`/`deps.registry` 等真实 IO 端口绑在一起 —— 手写这部分组合与
|
||||||
|
* `ACT_*` 六个常量是同一类"躲不掉的镜像"(纯读数计算才是真正能 import 的那一半)。
|
||||||
|
*/
|
||||||
|
function harness(seed: Record<string, unknown>) {
|
||||||
|
const drafts = new Map<string, { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown> }>();
|
||||||
|
const cards: Array<Record<string, unknown>> = [];
|
||||||
|
const notes: string[] = [];
|
||||||
|
|
||||||
|
const view = (): Record<string, unknown> => {
|
||||||
|
const mine: Record<string, unknown[]> = {};
|
||||||
|
for (const d of drafts.values()) (mine[`mine:${d.domain}.${d.object}`] ??= []).push({ ...d.key, ...d.patch });
|
||||||
|
/* 草稿字段展平到顶层(与生产 `taskData.view()` 同一条:`{in:'gate_reason'}` 靠它读到值) */
|
||||||
|
const flattened: Record<string, unknown> = {};
|
||||||
|
for (const d of drafts.values()) {
|
||||||
|
for (const [field, value] of Object.entries({ ...d.key, ...d.patch })) {
|
||||||
|
if (value !== null && value !== undefined) flattened[field] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...seed,
|
||||||
|
...rowReadings(mine, DECLARED),
|
||||||
|
...missingReadings(mine, DECLARED),
|
||||||
|
...tallyReadings(mine, DECLARED),
|
||||||
|
...distinctReadings(mine, DECLARED),
|
||||||
|
...mine,
|
||||||
|
...flattened,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const deps = {
|
||||||
|
data: {
|
||||||
|
view: () => Promise.resolve(view()),
|
||||||
|
writable: () =>
|
||||||
|
Promise.resolve(
|
||||||
|
DECLARED.map((d) => ({
|
||||||
|
domain: d.domain,
|
||||||
|
object: d.object,
|
||||||
|
label: `${d.domain}.${d.object}`,
|
||||||
|
keys: d.fields.filter((f) => f.primaryKey).map((f) => f.name),
|
||||||
|
...(d.caseKey === undefined ? {} : { caseKey: d.caseKey }),
|
||||||
|
fields: d.fields.map((f) => ({ name: f.name, label: f.label ?? f.name })),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
draftWrite: (i: { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown>; skipUnchanged?: boolean; skipExisting?: boolean }) => {
|
||||||
|
const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`;
|
||||||
|
const before = drafts.get(id);
|
||||||
|
const changed = before === undefined || Object.entries(i.patch).some(([k, v]) => JSON.stringify(before.patch[k]) !== JSON.stringify(v));
|
||||||
|
if (i.skipUnchanged === true && !changed) return Promise.resolve({ changed });
|
||||||
|
drafts.set(id, { domain: i.domain, object: i.object, key: i.key, patch: { ...(before?.patch ?? {}), ...i.patch } });
|
||||||
|
return Promise.resolve({ changed });
|
||||||
|
},
|
||||||
|
draftDrop: (i: { domain: string; object: string; key: Record<string, string> }) => {
|
||||||
|
const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`;
|
||||||
|
return Promise.resolve({ dropped: drafts.delete(id) });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cards: {
|
||||||
|
issue: (i: Record<string, unknown>) => {
|
||||||
|
cards.push(i);
|
||||||
|
return Promise.resolve({ id: 'card-1' });
|
||||||
|
},
|
||||||
|
decide: () => Promise.reject(new Error('本用例不该发决策卡')),
|
||||||
|
entry: () => Promise.reject(new Error('本用例不该发录入卡')),
|
||||||
|
ask: () => Promise.reject(new Error('本用例不该发补全卡')),
|
||||||
|
unanswered: () => Promise.resolve([]),
|
||||||
|
},
|
||||||
|
memory: {
|
||||||
|
append: (m: { title: string }) => {
|
||||||
|
notes.push(m.title);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
analyze: { run: () => Promise.reject(new Error('本用例不该调 analyze')) },
|
||||||
|
sfeel: {
|
||||||
|
/* 真实 S-FEEL 求值器(与生产 `sfeel.condition` 同一条:异常按不成立处置) */
|
||||||
|
condition: (expr: string, scope: Readonly<Record<string, unknown>>) => {
|
||||||
|
try {
|
||||||
|
return evaluateCondition(expr, scope);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
expression: () => undefined,
|
||||||
|
unaryTest: () => false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ctx = { taskId: TASK, stepId: 'S1_inputs', ownerId: OWNER, humanCallCap: 8, quiet: false, carded: false };
|
||||||
|
// 假端口只实现用得到的那几个方法,形状对不上 AutoRunnerDeps 的全貌 —— 与 auto-runner.test.ts 同款既有做法
|
||||||
|
return { runner: createAutoRunner(deps as never), drafts, cards, notes, ctx };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 起单入参 + 台账种子:LM2596S-5.0 原本就在库且齐全,XL2596S-5.0 已归同一类型但一条参数都没录过 */
|
||||||
|
function seedScenario(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
originalInternalPn: 'LM2596S-5.0',
|
||||||
|
candidateInternalPn: 'XL2596S-5.0',
|
||||||
|
'case:openedAt': '2026-08-29T00:00:00.000Z',
|
||||||
|
'parts.type_param': [{ type_id: 1, param_name: '输入电压范围', param_desc: '决定能否直接替换而不改外围', constraint_type: 'hard', param_unit: 'V' }],
|
||||||
|
'parts.profile': [
|
||||||
|
{ internal_pn: 'LM2596S-5.0', chip_type_id: 1, brand_name: 'TI', lifecycle_status: 'active', specification_description: '同步降压', manufacturer_name: 'TI', datasheet_ref: 'https://example.com/lm2596.pdf', datasheet_version: 'Rev-J' },
|
||||||
|
/* candidate 的类型已经归好(chip_type_id=1)—— 与原厂同类型,才走得到 gate_reason 最深那层判据 */
|
||||||
|
{ internal_pn: 'XL2596S-5.0', chip_type_id: 1, brand_name: 'XLSEMI', lifecycle_status: 'active', specification_description: '同步降压', manufacturer_name: 'XLSEMI', datasheet_ref: 'https://example.com/xl2596.pdf', datasheet_version: 'Rev-1' },
|
||||||
|
],
|
||||||
|
/* candidate 一条参数都没有 —— 铺骨架那一笔跑之前,mine:parts.parameter 里压根没有它的行 */
|
||||||
|
'parts.parameter': [{ internal_pn: 'LM2596S-5.0', param_name: '输入电压范围', param_value: '4.5V~40V', param_unit: 'V', param_note: '' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **六笔,在形状上代表 `S1_inputs.auto` —— 覆盖它用到的四种地址写法**(字面 rows / 单层
|
||||||
|
* each+where / 两层 each+where+outer / once),不是逐字同步的承诺(见文件头注)。
|
||||||
|
*/
|
||||||
|
const ACT_PART = {
|
||||||
|
kind: 'project',
|
||||||
|
into: 'sub_eval.part',
|
||||||
|
from: {
|
||||||
|
rows: [
|
||||||
|
{ pn: { in: 'originalInternalPn' }, role: 'original' },
|
||||||
|
{ pn: { in: 'candidateInternalPn' }, role: 'candidate' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
eval_ref: { in: 'evalRef' },
|
||||||
|
internal_pn: { col: 'pn' },
|
||||||
|
role: { col: 'role' },
|
||||||
|
found_pn_ref: { lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'internal_pn', else: '' },
|
||||||
|
exists_status: { when: 'found_pn_ref != ""', then: 'found', else: 'absent' },
|
||||||
|
chip_type_id: { lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'chip_type_id', else: '' },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const ACT_PROFILE = {
|
||||||
|
kind: 'project',
|
||||||
|
into: 'parts.profile',
|
||||||
|
prune: true,
|
||||||
|
from: { each: 'mine:sub_eval.part' },
|
||||||
|
fields: {
|
||||||
|
internal_pn: { col: 'internal_pn' },
|
||||||
|
chip_type_id: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'chip_type_id', else: '' },
|
||||||
|
brand_name: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'brand_name', else: '' },
|
||||||
|
lifecycle_status: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'lifecycle_status', else: '' },
|
||||||
|
specification_description: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'specification_description', else: '' },
|
||||||
|
manufacturer_name: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'manufacturer_name', else: '' },
|
||||||
|
datasheet_ref: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'datasheet_ref', else: '' },
|
||||||
|
datasheet_version: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'datasheet_version', else: '' },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const ACT_PARAMETER = {
|
||||||
|
kind: 'project',
|
||||||
|
into: 'parts.parameter',
|
||||||
|
prune: true,
|
||||||
|
from: { each: 'mine:sub_eval.part', then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } } },
|
||||||
|
fields: {
|
||||||
|
internal_pn: { outer: 'internal_pn' },
|
||||||
|
param_name: { col: 'param_name' },
|
||||||
|
param_unit: { col: 'param_unit' },
|
||||||
|
param_value: { lookup: 'parts.parameter', by: { internal_pn: { outer: 'internal_pn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '' },
|
||||||
|
param_note: { lookup: 'parts.parameter', by: { internal_pn: { outer: 'internal_pn' }, param_name: { col: 'param_name' } }, take: 'param_note', else: '' },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const ACT_CASE = {
|
||||||
|
kind: 'project',
|
||||||
|
into: 'sub_eval.case',
|
||||||
|
from: { once: true },
|
||||||
|
fields: {
|
||||||
|
eval_ref: { in: 'evalRef' },
|
||||||
|
chip_type_id: { lookup: 'mine:sub_eval.part', by: { role: 'original' }, take: 'chip_type_id', else: '' },
|
||||||
|
gate_reason: {
|
||||||
|
when: 'sub_eval_part_rows < 2',
|
||||||
|
then: '原料号与候选料号还没填全 —— 在下面把两格补上。',
|
||||||
|
else: {
|
||||||
|
when: 'sub_eval_part_exists_status_absent > 0',
|
||||||
|
then: '有料号库里查不到 —— 在下面把它的主数据建起来。',
|
||||||
|
else: {
|
||||||
|
when: 'sub_eval_part_missing > 0',
|
||||||
|
then: '有料号还没归芯片类型 —— 在下面给它选一个;没有合适的就新建一个,并把它的核心参数字段一并定下来。',
|
||||||
|
else: {
|
||||||
|
when: 'sub_eval_part_distinct_chip_type_id > 1',
|
||||||
|
then: '两颗料不是同一个芯片类型,逐项比对无从谈起 —— 换一颗料,或者把归错的那颗类型改对。',
|
||||||
|
else: {
|
||||||
|
when: 'parts_profile_missing + parts_parameter_missing > 0',
|
||||||
|
then: '还有必填格空着 —— 红带点名的那几格填上就能提交。',
|
||||||
|
else: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const ACT_CARD = {
|
||||||
|
kind: 'card',
|
||||||
|
when: 'sub_eval_part_exists_status_found < 2 or sub_eval_part_missing > 0 or sub_eval_part_distinct_chip_type_id > 1 or parts_profile_missing > 0 or parts_parameter_missing > 0',
|
||||||
|
as: 'execution',
|
||||||
|
variant: 'data',
|
||||||
|
title: '数据工单 · 差异评估的输入还差几格',
|
||||||
|
basis: [
|
||||||
|
{ k: '原料号', v: { in: 'originalInternalPn' } },
|
||||||
|
{ k: '候选料号', v: { in: 'candidateInternalPn' } },
|
||||||
|
{ k: '还差什么', v: { in: 'gate_reason' } },
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const ACT_SAY = { kind: 'say', text: '已核对两颗料的在库状态与参数齐备情况。' } as const;
|
||||||
|
|
||||||
|
/** 修复后的正确次序(与 `case-model.ts` 当前的 `S1_inputs.auto.do` 一致) */
|
||||||
|
const FIXED_ORDER: AutoRecipe = {
|
||||||
|
let: { evalRef: 'W05-20260829000000' },
|
||||||
|
do: [ACT_PART, ACT_PROFILE, ACT_PARAMETER, ACT_CASE, ACT_CARD, ACT_SAY],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('S1 配方次序:gate_reason 必须读到这一拍刷新之后的缺口读数', () => {
|
||||||
|
it('新次序(part→profile→parameter→case→card→say):gate_reason 读到真实缺口,不是空串', async () => {
|
||||||
|
const { runner, drafts, cards, ctx } = harness(seedScenario());
|
||||||
|
const out = await runner.run(FIXED_ORDER, ctx);
|
||||||
|
|
||||||
|
// 卡确实发了出去(与旧次序下的行为一致 —— 收步与发卡的把关本身没坏)
|
||||||
|
expect(out.issuedCardId).toBe('card-1');
|
||||||
|
expect(cards).toHaveLength(1);
|
||||||
|
|
||||||
|
// 案卷里存的 gate_reason:必须非空、且点到真实缺口(不是「一切正常」的假象)
|
||||||
|
const caseRow = [...drafts.values()].find((d) => d.object === 'case');
|
||||||
|
expect(caseRow?.patch['gate_reason']).toBe('还有必填格空着 —— 红带点名的那几格填上就能提交。');
|
||||||
|
|
||||||
|
// 卡面 basis 里「还差什么」那一格 —— 评审报告里点名的那个空字符串,这里必须不是空的
|
||||||
|
const card = cards[0];
|
||||||
|
const basis = (card?.['card'] as { basis?: Array<{ k: string; v: string }> } | undefined)?.basis ?? [];
|
||||||
|
const gapLine = basis.find((b) => b.k === '还差什么');
|
||||||
|
expect(gapLine?.v).toBe('还有必填格空着 —— 红带点名的那几格填上就能提交。');
|
||||||
|
expect(gapLine?.v).not.toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,638 @@
|
|||||||
|
/**
|
||||||
|
* **这份文件测的是什么**(2026-08-29,评审 Critical 逼出来的执行级回归)。
|
||||||
|
*
|
||||||
|
* `S3_review.auto` 复用 S2 算好的两笔投影(`RELATION_PROJECTION`/`VALIDATION_PROJECTION`,
|
||||||
|
* `case-model.ts`)—— 它们的主键 / 外键全靠 `{in:'relationId'}`/`{in:'evalRef'}`/
|
||||||
|
* `{in:'originalPn'}`/`{in:'candidatePn'}`/`{in:'typeId'}` 这几个名字,而这几个名字**只在
|
||||||
|
* `let` 里绑定**。`createAutoRunner.run()` 每次调用都从**它收到的那份** `recipe.let` 现算一遍
|
||||||
|
* `lets`(`auto-runner.ts:93-97`)——步骤之间不传递 `let`。S3 若没有自己的 `let`,这几个名字
|
||||||
|
* 在 S3 那一拍就是未绑定,`{in:X}` 退回 `''`(`auto-expr.ts:44-47`)。后果两条:
|
||||||
|
* ① 关系表 `substitution_relation_id` 算成空串 → 整笔写被「主键算不出来先不写」悄悄丢掉 ——
|
||||||
|
* **人在审核件上改了四态,那一行永远不会更新**,这正是 S3 存在的全部理由;
|
||||||
|
* ② 验证记录的主键 `cat:['VAL-', {in:'evalRef'}]` 反而拼出非空的 `'VAL-'`,被当成一个新主键
|
||||||
|
* 写出**第二行垃圾**,破了「P-06 一单固定一条」的不变量。
|
||||||
|
* (① ② 两条已修:S3 有了自己的 `let`,见 `S3_LET_MIRROR`/`S3_RECIPE`。)
|
||||||
|
*
|
||||||
|
* **2026-08-30 task-12 加第二条覆盖,同一份文件、同一套机制,但是另一层缺陷**(浏览器走查
|
||||||
|
* 发现⑤,`docs/superpowers/specs/.../progress.md` H 系列 ruling 之后的那条):①②修好之后,
|
||||||
|
* `RELATION_PROJECTION`/`VALIDATION_PROJECTION` 的主键与外键**绑得上了**,可它们算结论用的
|
||||||
|
* `substitution_difference_item_judgement_class_*` tally 读的是**判定分类**列,不是四态列 ——
|
||||||
|
* 人在审核件上把某一行的 `difference_status` 从 `mismatch` 改成 `match` 并保存,
|
||||||
|
* **没有任何一笔把这个新值翻译成 `judgement_class`**,tally 纹丝不动,两笔投影算出同样的结论。
|
||||||
|
* 件上白纸黑字承诺「你改动下表里任何一条四态,这五项当场重算」——这句话曾经是假的。
|
||||||
|
*
|
||||||
|
* **这份文件原本的 `HUMAN_EDIT` 恰好把这层缺陷藏起来了**:它在同一笔 `project` 里**同时**
|
||||||
|
* 写了 `difference_status`(`{col:'status'}`)**和** `judgement_class`(`{col:'jclass'}`)——
|
||||||
|
* 等于替被测系统把「四态 → 判定分类」的翻译**预先做完**,断言看到的「结论正确重算」其实是
|
||||||
|
* 这笔 setup 自己写对的,不是 S3 配方推出来的。人在真实审核件上**只能改
|
||||||
|
* `difference_status`/`impact_statement`/`evidence_note`/`unknown_disposition` 这几列**
|
||||||
|
* (`difference-review` 目标件的 `data-edit` 绑定只挂了这几个 `data-col`)——`judgement_class`
|
||||||
|
* 从来不在人能碰的字段里。现在的 `HUMAN_EDIT` 已改为只写 `difference_status` + `evidence_note`
|
||||||
|
* 两列,与真实的就地编辑同源;`S3_RECIPE` 补上 `RECOMPUTE_MIRROR`(镜像 S2/S3 新共用的
|
||||||
|
* `DIFFERENCE_ITEM_RECOMPUTE`),让「翻译」这一步真的在被测流程里发生一遍。
|
||||||
|
*
|
||||||
|
* 只有把真引擎(`createAutoRunner`)跑起来才测得出 ——
|
||||||
|
* `activities/part-substitution-assessment/__tests__/steps.test.ts` 的用例全是结构断言
|
||||||
|
* (查 `kind`、查 `into`、`toBe` 引用相等),从来没有对配方求过值,漏不出这一类"名字没绑上"
|
||||||
|
* 或"两张表的值没对齐"的洞。
|
||||||
|
*
|
||||||
|
* **手写镜像,不 import 真配方** —— 与 `w05-gate-reason-order.test.ts` 同一条理由:
|
||||||
|
* `activities/*` 与 `apps/*` 互相不许 import(ADR-004,`.dependency-cruiser.cjs` 的
|
||||||
|
* `activities-isolated` / `apps-not-into-activities`)。
|
||||||
|
* `REL_MIRROR`/`VAL_MIRROR`/`S3_LET_MIRROR`/`JUDGEMENT_FIELDS_MIRROR`/`RECOMPUTE_MIRROR`
|
||||||
|
* 逐字取自 `case-model.ts` 当前的
|
||||||
|
* `RELATION_PROJECTION`/`VALIDATION_PROJECTION`/`SHARED_LET`/`JUDGEMENT_DERIVED_FIELDS`/
|
||||||
|
* `DIFFERENCE_ITEM_RECOMPUTE` ——在形状上代表,不是逐字同步的承诺(谁改了这五者的**形状**,
|
||||||
|
* 该顺手核一遍这份文件还有没有代表性,欠账记在 `docs/debt.md`,与 S1 那份镜像同一条纪律)。
|
||||||
|
*
|
||||||
|
* **读数层四个函数直接 import 真身**,理由与路径别名 `@app/*` 同 `w05-gate-reason-order.test.ts`
|
||||||
|
* 头注:`apps/api/src/modules/loop-engine/__tests__/` import `apps/api/src/app/wiring/engines.ts`
|
||||||
|
* 是 `apps → apps`,两条隔离规则都不沾。
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { AutoAction, AutoRecipe } from '@contracts';
|
||||||
|
import { taskIdSchema, staffIdSchema } from '@contracts';
|
||||||
|
import { evaluateCondition } from '@sfeel';
|
||||||
|
import { distinctReadings, missingReadings, rowReadings, tallyReadings } from '@app/wiring/engines.ts';
|
||||||
|
import { createAutoRunner } from '../internal/usecase/auto-runner.ts';
|
||||||
|
|
||||||
|
const TASK = taskIdSchema.parse('55555555-5555-4555-8555-555555555555');
|
||||||
|
const OWNER = staffIdSchema.parse('66666666-6666-4666-8666-666666666666');
|
||||||
|
|
||||||
|
/** 五张台账的字段元数据 —— 写法与 `w05-gate-reason-order.test.ts` 的 `DECLARED` 同款镜像取舍。 */
|
||||||
|
const DECLARED = [
|
||||||
|
{
|
||||||
|
domain: 'sub_eval',
|
||||||
|
object: 'case',
|
||||||
|
caseKey: 'eval_ref',
|
||||||
|
fields: [
|
||||||
|
{ name: 'eval_ref', primaryKey: true },
|
||||||
|
{ name: 'chip_type_id', primaryKey: false },
|
||||||
|
{ name: 'gate_reason', primaryKey: false },
|
||||||
|
{ name: 'summary', primaryKey: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
domain: 'sub_eval',
|
||||||
|
object: 'part',
|
||||||
|
caseKey: 'eval_ref',
|
||||||
|
fields: [
|
||||||
|
{ name: 'eval_ref', primaryKey: true },
|
||||||
|
{ name: 'internal_pn', primaryKey: true },
|
||||||
|
{ name: 'role', primaryKey: false, required: true, label: '角色', tally: ['original', 'candidate'] },
|
||||||
|
{ name: 'found_pn_ref', primaryKey: false, label: '在库核对依据' },
|
||||||
|
{ name: 'exists_status', primaryKey: false, required: true, label: '在库状态', tally: ['found', 'absent'] },
|
||||||
|
{ name: 'chip_type_id', primaryKey: false, required: true, label: '芯片类型', distinct: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
domain: 'substitution',
|
||||||
|
object: 'relation',
|
||||||
|
fields: [
|
||||||
|
{ name: 'substitution_relation_id', primaryKey: true },
|
||||||
|
{ name: 'eval_ref', primaryKey: false, required: true, label: '评估案号' },
|
||||||
|
{ name: 'original_internal_pn', primaryKey: false, required: true, label: '原内部料号' },
|
||||||
|
{ name: 'candidate_internal_pn', primaryKey: false, required: true, label: '候选内部料号' },
|
||||||
|
{ name: 'chip_type_id', primaryKey: false, required: true, label: '芯片类型' },
|
||||||
|
{ name: 'recommendation_level', primaryKey: false, required: true, label: '推荐等级', tally: ['recommended', 'conditional', 'not_recommended'] },
|
||||||
|
{ name: 'recommendation_reason', primaryKey: false, required: true, label: '推荐理由' },
|
||||||
|
{ name: 'difference_summary', primaryKey: false, required: true, label: '差异摘要' },
|
||||||
|
{ name: 'risk_level', primaryKey: false, required: true, label: '替代风险等级', tally: ['low', 'medium', 'high'] },
|
||||||
|
{ name: 'verification_requirement', primaryKey: false, label: '验证要求' },
|
||||||
|
{ name: 'validation_id', primaryKey: false, label: '关联验证ID' },
|
||||||
|
{ name: 'analysis_status', primaryKey: false, required: true, label: '分析状态' },
|
||||||
|
{ name: 'rule_version', primaryKey: false, required: true, label: '差异规则版本' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
domain: 'substitution',
|
||||||
|
object: 'difference_item',
|
||||||
|
fields: [
|
||||||
|
{ name: 'substitution_relation_id', primaryKey: true, distinct: true, label: '替代关系ID' },
|
||||||
|
{ name: 'difference_item_id', primaryKey: true },
|
||||||
|
{ name: 'parameter_name', primaryKey: false, required: true, label: '标准参数名' },
|
||||||
|
{ name: 'parameter_desc', primaryKey: false, expect: true, label: '参数说明' },
|
||||||
|
{ name: 'constraint_type', primaryKey: false, required: true, label: '约束类型', tally: ['hard', 'soft'] },
|
||||||
|
{ name: 'original_value', primaryKey: false, required: true, label: '原物料参数值' },
|
||||||
|
{ name: 'candidate_value', primaryKey: false, required: true, label: '候选物料参数值' },
|
||||||
|
{ name: 'parameter_unit', primaryKey: false, expect: true, label: '参数单位' },
|
||||||
|
{ name: 'difference_status', primaryKey: false, required: true, label: '差异状态', tally: ['match', 'mismatch', 'unknown', 'conditional'] },
|
||||||
|
{ name: 'judgement_class', primaryKey: false, required: true, label: '判定分类', tally: ['match', 'soft_mismatch', 'hard_mismatch', 'unknown', 'conditional'] },
|
||||||
|
{ name: 'impact_statement', primaryKey: false, required: true, label: '影响说明' },
|
||||||
|
{ name: 'evidence_note', primaryKey: false, required: true, label: '判断依据' },
|
||||||
|
{ name: 'unknown_disposition', primaryKey: false, label: 'UNKNOWN 去向' },
|
||||||
|
{ name: 'risk_level', primaryKey: false, required: true, label: '风险等级', tally: ['low', 'medium', 'high'] },
|
||||||
|
{ name: 'rule_version', primaryKey: false, required: true, label: '差异规则版本' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
domain: 'validation',
|
||||||
|
object: 'record',
|
||||||
|
fields: [
|
||||||
|
{ name: 'validation_id', primaryKey: true },
|
||||||
|
{ name: 'eval_ref', primaryKey: false, required: true, label: '评估案号' },
|
||||||
|
{ name: 'validation_internal_pn', primaryKey: false, required: true, label: '验证内部料号' },
|
||||||
|
{ name: 'validation_type', primaryKey: false, required: true, label: '验证类型', tally: ['pin_to_pin', 'functional', 'reliability', 'certification', 'retest', 'parametric'] },
|
||||||
|
{ name: 'validation_status', primaryKey: false, required: true, label: '验证状态' },
|
||||||
|
{ name: 'test_condition', primaryKey: false, expect: true, label: '测试条件' },
|
||||||
|
{ name: 'next_validation_action', primaryKey: false, required: true, label: '后续验证动作' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 假端口 —— 手写依赖注入替身,`view()` 拼作用域的配方与 `w05-gate-reason-order.test.ts` 一致。 */
|
||||||
|
function harness() {
|
||||||
|
const drafts = new Map<string, { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown> }>();
|
||||||
|
const cards: Array<Record<string, unknown>> = [];
|
||||||
|
const notes: string[] = [];
|
||||||
|
|
||||||
|
const view = (): Record<string, unknown> => {
|
||||||
|
const mine: Record<string, unknown[]> = {};
|
||||||
|
for (const d of drafts.values()) (mine[`mine:${d.domain}.${d.object}`] ??= []).push({ ...d.key, ...d.patch });
|
||||||
|
const flattened: Record<string, unknown> = {};
|
||||||
|
for (const d of drafts.values()) {
|
||||||
|
for (const [field, value] of Object.entries({ ...d.key, ...d.patch })) {
|
||||||
|
if (value !== null && value !== undefined) flattened[field] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...rowReadings(mine, DECLARED),
|
||||||
|
...missingReadings(mine, DECLARED),
|
||||||
|
...tallyReadings(mine, DECLARED),
|
||||||
|
...distinctReadings(mine, DECLARED),
|
||||||
|
...mine,
|
||||||
|
...flattened,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const deps = {
|
||||||
|
data: {
|
||||||
|
view: () => Promise.resolve(view()),
|
||||||
|
writable: () =>
|
||||||
|
Promise.resolve(
|
||||||
|
DECLARED.map((d) => ({
|
||||||
|
domain: d.domain,
|
||||||
|
object: d.object,
|
||||||
|
label: `${d.domain}.${d.object}`,
|
||||||
|
keys: d.fields.filter((f) => f.primaryKey).map((f) => f.name),
|
||||||
|
...(d.caseKey === undefined ? {} : { caseKey: d.caseKey }),
|
||||||
|
fields: d.fields.map((f) => ({ name: f.name, label: f.label ?? f.name })),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
draftWrite: (i: { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown>; skipUnchanged?: boolean; skipExisting?: boolean }) => {
|
||||||
|
const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`;
|
||||||
|
const before = drafts.get(id);
|
||||||
|
const changed = before === undefined || Object.entries(i.patch).some(([k, v]) => JSON.stringify(before.patch[k]) !== JSON.stringify(v));
|
||||||
|
if (i.skipUnchanged === true && !changed) return Promise.resolve({ changed });
|
||||||
|
drafts.set(id, { domain: i.domain, object: i.object, key: i.key, patch: { ...(before?.patch ?? {}), ...i.patch } });
|
||||||
|
return Promise.resolve({ changed });
|
||||||
|
},
|
||||||
|
draftDrop: (i: { domain: string; object: string; key: Record<string, string> }) => {
|
||||||
|
const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`;
|
||||||
|
return Promise.resolve({ dropped: drafts.delete(id) });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cards: {
|
||||||
|
issue: () => Promise.reject(new Error('本用例不该发执行工单')),
|
||||||
|
decide: () => Promise.reject(new Error('quiet 拍不该发决策卡 —— 卡这一笔该被跳过')),
|
||||||
|
entry: () => Promise.reject(new Error('本用例不该发录入卡')),
|
||||||
|
ask: () => Promise.reject(new Error('本用例不该发补全卡')),
|
||||||
|
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<Record<string, unknown>>) => {
|
||||||
|
try {
|
||||||
|
return evaluateCondition(expr, scope);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
expression: () => undefined,
|
||||||
|
unaryTest: () => false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ctx = { taskId: TASK, stepId: 'S3_review', ownerId: OWNER, humanCallCap: 8, quiet: true, carded: true };
|
||||||
|
// 假端口只实现用得到的那几个方法,形状对不上 AutoRunnerDeps 的全貌 —— 与 auto-runner.test.ts 同款既有做法
|
||||||
|
return { runner: createAutoRunner(deps as never), drafts, cards, notes, ctx };
|
||||||
|
}
|
||||||
|
|
||||||
|
const EVAL_REF = 'W05-20260829000000';
|
||||||
|
const RELATION_ID = `SUB-${EVAL_REF}`;
|
||||||
|
const VALIDATION_ID = `VAL-${EVAL_REF}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 阶段一(setup,镜像 S1+S2 已经跑完的样子):两颗料在库、两条差异明细都判 `match`、
|
||||||
|
* 关系表的初版结论是 `recommended`、验证记录已经落了一行 `not_started`。
|
||||||
|
*/
|
||||||
|
const SETUP_S1S2: AutoRecipe = {
|
||||||
|
do: [
|
||||||
|
{
|
||||||
|
kind: 'project',
|
||||||
|
into: 'sub_eval.case',
|
||||||
|
from: { once: true },
|
||||||
|
fields: { eval_ref: EVAL_REF, chip_type_id: 1, gate_reason: '', summary: '' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'project',
|
||||||
|
into: 'sub_eval.part',
|
||||||
|
from: {
|
||||||
|
rows: [
|
||||||
|
{ pn: 'LM2596S-5.0', role: 'original' },
|
||||||
|
{ pn: 'XL2596S-5.0', role: 'candidate' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
eval_ref: EVAL_REF,
|
||||||
|
internal_pn: { col: 'pn' },
|
||||||
|
role: { col: 'role' },
|
||||||
|
found_pn_ref: { col: 'pn' },
|
||||||
|
exists_status: 'found',
|
||||||
|
chip_type_id: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'project',
|
||||||
|
into: 'substitution.difference_item',
|
||||||
|
from: {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
item_id: `${RELATION_ID}-输入电压范围`, name: '输入电压范围', desc: '决定能否直接替换而不改外围',
|
||||||
|
status: 'match', jclass: 'match', orig: '4.5V~40V', cand: '4.5V~40V', unit: 'V', note: '范围相同', risk: 'low',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
item_id: `${RELATION_ID}-静态电流`, name: '静态电流', desc: '影响待机功耗',
|
||||||
|
status: 'match', jclass: 'match', orig: '50uA', cand: '50uA', unit: 'uA', note: '数值相同', risk: 'low',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
substitution_relation_id: RELATION_ID,
|
||||||
|
difference_item_id: { col: 'item_id' },
|
||||||
|
parameter_name: { col: 'name' },
|
||||||
|
parameter_desc: { col: 'desc' },
|
||||||
|
constraint_type: 'hard',
|
||||||
|
original_value: { col: 'orig' },
|
||||||
|
candidate_value: { col: 'cand' },
|
||||||
|
parameter_unit: { col: 'unit' },
|
||||||
|
difference_status: { col: 'status' },
|
||||||
|
judgement_class: { col: 'jclass' },
|
||||||
|
impact_statement: '无',
|
||||||
|
evidence_note: { col: 'note' },
|
||||||
|
unknown_disposition: '',
|
||||||
|
risk_level: { col: 'risk' },
|
||||||
|
rule_version: 'R-TEST',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'project',
|
||||||
|
into: 'substitution.relation',
|
||||||
|
from: { once: true },
|
||||||
|
fields: {
|
||||||
|
substitution_relation_id: RELATION_ID,
|
||||||
|
eval_ref: EVAL_REF,
|
||||||
|
original_internal_pn: 'LM2596S-5.0',
|
||||||
|
candidate_internal_pn: 'XL2596S-5.0',
|
||||||
|
chip_type_id: 1,
|
||||||
|
recommendation_level: 'recommended',
|
||||||
|
recommendation_reason: '每一项核心参数都判为一致,没有待确认项,可直接替代。',
|
||||||
|
difference_summary: '共 2 项核心参数:2 项一致 / 0 项硬约束不符 / 0 项软约束不符 / 0 项待确认 / 0 项有条件。',
|
||||||
|
risk_level: 'low',
|
||||||
|
verification_requirement: '',
|
||||||
|
validation_id: VALIDATION_ID,
|
||||||
|
analysis_status: 'fae_confirmed',
|
||||||
|
rule_version: 'R-TEST',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'project',
|
||||||
|
into: 'validation.record',
|
||||||
|
from: { once: true },
|
||||||
|
fields: {
|
||||||
|
validation_id: VALIDATION_ID,
|
||||||
|
eval_ref: EVAL_REF,
|
||||||
|
validation_internal_pn: 'XL2596S-5.0',
|
||||||
|
validation_type: 'parametric',
|
||||||
|
validation_status: 'not_started',
|
||||||
|
test_condition: '无',
|
||||||
|
next_validation_action: '本轮无待验项。',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 阶段二(setup,镜像人在审核件上就地改了一格):把「静态电流」那一行的四态从 `match`
|
||||||
|
* 改成 `mismatch`(审核人对着候选规格书发现待机功耗超预算,原厂的硬约束没扛住),
|
||||||
|
* 并顺手把判断依据写清楚。
|
||||||
|
*
|
||||||
|
* **只写人在审核件上真正碰得到的那几列**(2026-08-30 task-12 修正,见文件头注)——
|
||||||
|
* `difference_status` + `evidence_note`。**绝不写 `judgement_class`/`risk_level`**:
|
||||||
|
* 那是 S3 配方该重算的两列,人手上的表单压根没有这两格(`difference-review` 目标件的
|
||||||
|
* `data-edit` 绑定只挂了 `difference_status`/`impact_statement`/`evidence_note`/
|
||||||
|
* `unknown_disposition` 四个 `data-col`)。写它们等于替被测系统预先把「四态 → 判定分类」
|
||||||
|
* 的翻译做完,会让本用例测不出「S3 到底有没有真的重算」这件事本身 ——
|
||||||
|
* 这正是这份文件原本的写法把 task-12 那层缺陷藏起来的原因。
|
||||||
|
*
|
||||||
|
* 与真实的就地编辑同源:只改这一行,不碰另一行,不用 `prune`。
|
||||||
|
*/
|
||||||
|
const HUMAN_EDIT: AutoRecipe = {
|
||||||
|
do: [
|
||||||
|
{
|
||||||
|
kind: 'project',
|
||||||
|
into: 'substitution.difference_item',
|
||||||
|
from: {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
item_id: `${RELATION_ID}-静态电流`,
|
||||||
|
status: 'mismatch',
|
||||||
|
note: '审核人核对候选规格书 §6.2 实测值,静态电流是原厂 4 倍,超待机功耗预算',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
substitution_relation_id: RELATION_ID,
|
||||||
|
difference_item_id: { col: 'item_id' },
|
||||||
|
difference_status: { col: 'status' },
|
||||||
|
evidence_note: { col: 'note' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `JUDGEMENT_DERIVED_FIELDS` 的镜像 —— 逐字取自 `case-model.ts` 当前的常量。
|
||||||
|
* S2 的差异明细创建笔与 S3 的重算笔(`RECOMPUTE_MIRROR`,下面)共用同一份 ——
|
||||||
|
* 判定规则只许有一处真源,各写一份早晚分叉。
|
||||||
|
*/
|
||||||
|
const JUDGEMENT_FIELDS_MIRROR = {
|
||||||
|
judgement_class: {
|
||||||
|
when: 'difference_status = "match"', then: 'match',
|
||||||
|
else: {
|
||||||
|
when: 'difference_status = "unknown"', then: 'unknown',
|
||||||
|
else: {
|
||||||
|
when: 'difference_status = "conditional"', then: 'conditional',
|
||||||
|
else: { when: 'constraint_type = "hard"', then: 'hard_mismatch', else: 'soft_mismatch' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
risk_level: {
|
||||||
|
when: 'judgement_class = "hard_mismatch"', then: 'high',
|
||||||
|
else: { when: 'judgement_class = "match"', then: 'low', else: 'medium' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `DIFFERENCE_ITEM_RECOMPUTE` 的镜像 —— **这是本用例要钉的核心新增笔**(2026-08-30
|
||||||
|
* task-12):S3 若没有这一笔(或它排在 `REL_MIRROR`/`VAL_MIRROR` 之后),人改的
|
||||||
|
* `difference_status` 就没有任何桥转成 `judgement_class`,两笔投影读到的 tally 纹丝不动 ——
|
||||||
|
* 这正是「结论纹丝不动」那条要害缺陷的机制。只列主键 + 两个派生列,不碰
|
||||||
|
* `difference_status` 自己、也不碰人可以改的叙事列(`impact_statement`/`evidence_note`/
|
||||||
|
* `unknown_disposition`)。
|
||||||
|
*/
|
||||||
|
const RECOMPUTE_MIRROR: AutoAction = {
|
||||||
|
kind: 'project',
|
||||||
|
into: 'substitution.difference_item',
|
||||||
|
from: { each: 'mine:substitution.difference_item' },
|
||||||
|
fields: {
|
||||||
|
substitution_relation_id: { col: 'substitution_relation_id' },
|
||||||
|
difference_item_id: { col: 'difference_item_id' },
|
||||||
|
...JUDGEMENT_FIELDS_MIRROR,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `RELATION_PROJECTION` 的镜像 —— 逐字取自 `case-model.ts` 当前的常量(不代表逐字同步承诺,
|
||||||
|
* 见文件头注)。**主键 / 外键全靠 `let` 绑定的名字**,这正是这份测试要钉住的那一段。
|
||||||
|
*/
|
||||||
|
const REL_MIRROR: AutoAction = {
|
||||||
|
kind: 'project',
|
||||||
|
into: 'substitution.relation',
|
||||||
|
from: { once: true },
|
||||||
|
fields: {
|
||||||
|
substitution_relation_id: { in: 'relationId' },
|
||||||
|
eval_ref: { in: 'evalRef' },
|
||||||
|
original_internal_pn: { in: 'originalPn' },
|
||||||
|
candidate_internal_pn: { in: 'candidatePn' },
|
||||||
|
chip_type_id: { in: 'typeId' },
|
||||||
|
recommendation_level: {
|
||||||
|
when: 'substitution_difference_item_judgement_class_hard_mismatch > 0', then: 'not_recommended',
|
||||||
|
else: {
|
||||||
|
when: 'substitution_difference_item_judgement_class_unknown > 0', then: 'conditional',
|
||||||
|
else: {
|
||||||
|
when: 'substitution_difference_item_judgement_class_conditional > 0', then: 'conditional',
|
||||||
|
else: { when: 'substitution_difference_item_judgement_class_soft_mismatch > 0', then: 'conditional', else: 'recommended' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
risk_level: {
|
||||||
|
when: 'recommendation_level = "not_recommended"', then: 'high',
|
||||||
|
else: { when: 'recommendation_level = "recommended"', then: 'low', else: 'medium' },
|
||||||
|
},
|
||||||
|
difference_summary: {
|
||||||
|
cat: [
|
||||||
|
'共 ', { in: 'substitution_difference_item_rows' }, ' 项核心参数:',
|
||||||
|
{ in: 'substitution_difference_item_judgement_class_match' }, ' 项一致 / ',
|
||||||
|
{ in: 'substitution_difference_item_judgement_class_hard_mismatch' }, ' 项硬约束不符 / ',
|
||||||
|
{ in: 'substitution_difference_item_judgement_class_soft_mismatch' }, ' 项软约束不符 / ',
|
||||||
|
{ in: 'substitution_difference_item_judgement_class_unknown' }, ' 项待确认 / ',
|
||||||
|
{ in: 'substitution_difference_item_judgement_class_conditional' }, ' 项有条件。',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
recommendation_reason: {
|
||||||
|
when: 'recommendation_level = "not_recommended"',
|
||||||
|
then: '有硬约束不满足 —— 逐条见差异明细里判为 MISMATCH 且约束类型为硬的那几行;硬性边界不满足,不因措辞改判。',
|
||||||
|
else: {
|
||||||
|
when: 'recommendation_level = "recommended"',
|
||||||
|
then: '每一项核心参数都判为一致,没有待确认项,可直接替代。',
|
||||||
|
else: '没有硬约束不满足,但仍有待确认或需附条件的项 —— 逐条见差异明细;闭环之后方可采用。',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
verification_requirement: {
|
||||||
|
when: 'recommendation_level = "conditional"',
|
||||||
|
then: '实测差异明细里判为 UNKNOWN 与 CONDITIONAL 的每一项,并把实测值回填 P-08 标准化参数集;回填后重跑本评估。',
|
||||||
|
else: '',
|
||||||
|
},
|
||||||
|
validation_id: { cat: ['VAL-', { in: 'evalRef' }] },
|
||||||
|
analysis_status: 'fae_confirmed',
|
||||||
|
rule_version: 'R-TEST',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `VALIDATION_PROJECTION` 的镜像 —— 同上取舍;这是拼出 `'VAL-'` 垃圾行那半个 bug 的主角。 */
|
||||||
|
const VAL_MIRROR: AutoAction = {
|
||||||
|
kind: 'project',
|
||||||
|
into: 'validation.record',
|
||||||
|
from: { once: true },
|
||||||
|
fields: {
|
||||||
|
validation_id: { cat: ['VAL-', { in: 'evalRef' }] },
|
||||||
|
eval_ref: { in: 'evalRef' },
|
||||||
|
validation_internal_pn: { in: 'candidatePn' },
|
||||||
|
validation_type: 'parametric',
|
||||||
|
validation_status: 'not_started',
|
||||||
|
test_condition: {
|
||||||
|
when: 'substitution_difference_item_judgement_class_unknown + substitution_difference_item_judgement_class_conditional > 0',
|
||||||
|
then: '差异明细里判为 UNKNOWN 与 CONDITIONAL 的每一项,按原厂规格书条件实测。',
|
||||||
|
else: '无',
|
||||||
|
},
|
||||||
|
next_validation_action: {
|
||||||
|
when: 'substitution_difference_item_judgement_class_unknown + substitution_difference_item_judgement_class_conditional > 0',
|
||||||
|
then: '实测上述各项并把实测值回填 P-08 标准化参数集,回填后重跑本评估。',
|
||||||
|
else: '本轮无待验项。',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** S3 的卡与交代 —— `quiet:true` 时两者都不会真正生效(卡被外层过滤,`say` 会跑但不影响断言)。 */
|
||||||
|
const CARD_MIRROR: AutoAction = {
|
||||||
|
kind: 'card',
|
||||||
|
as: 'decide',
|
||||||
|
variant: 'review',
|
||||||
|
title: '差异评估结论审核',
|
||||||
|
ask: '逐条差异结论已备好。请在工作区的件上逐条核对;要改的就地改(四态、影响说明、判断依据都可改),改完回到这里确认。',
|
||||||
|
options: [{ value: 'approve', label: '确认通过', detail: '逐条核对无误,可以进入落账放行' }],
|
||||||
|
basis: [
|
||||||
|
{ k: '原料号', v: { lookup: 'mine:sub_eval.part', by: { role: 'original' }, take: 'internal_pn', else: '' } },
|
||||||
|
{ k: '候选料号', v: { lookup: 'mine:sub_eval.part', by: { role: 'candidate' }, take: 'internal_pn', else: '' } },
|
||||||
|
{ k: '本轮结论', v: { lookup: 'mine:substitution.relation', by: {}, take: 'difference_summary', else: '' } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const SAY_MIRROR: AutoAction = { kind: 'say', text: '差异结论已送审,请在件上逐条核对。' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `SHARED_LET` 的镜像 —— 逐字取自 `case-model.ts`。**这是本用例要钉的核心**:
|
||||||
|
* S3 recipe 若不带这份 `let`(或带一份不完整的),`REL_MIRROR`/`VAL_MIRROR` 里
|
||||||
|
* `{in:X}` 引用的名字就是未绑定的。
|
||||||
|
*/
|
||||||
|
const S3_LET_MIRROR = {
|
||||||
|
evalRef: { lookup: 'mine:sub_eval.case', by: {}, take: 'eval_ref', else: '' },
|
||||||
|
typeId: { lookup: 'mine:sub_eval.case', by: {}, take: 'chip_type_id', else: '' },
|
||||||
|
originalPn: { lookup: 'mine:sub_eval.part', by: { role: 'original' }, take: 'internal_pn', else: '' },
|
||||||
|
candidatePn: { lookup: 'mine:sub_eval.part', by: { role: 'candidate' }, take: 'internal_pn', else: '' },
|
||||||
|
relationId: { cat: ['SUB-', { lookup: 'mine:sub_eval.case', by: {}, take: 'eval_ref', else: '' }] },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **S3 当前(修复后)的样子**:带上自己的 `let`,并且在关系 / 验证记录投影之前先跑一遍
|
||||||
|
* 差异明细重算笔(`RECOMPUTE_MIRROR`)。case-model.ts 现在就是这样写的 ——
|
||||||
|
* `let: SHARED_LET, do: [DIFFERENCE_ITEM_RECOMPUTE, RELATION_PROJECTION, VALIDATION_PROJECTION, card, say]`
|
||||||
|
* (2026-08-30 task-12 在原有的 `REL_MIRROR, VAL_MIRROR, ...` 前面插入了 `RECOMPUTE_MIRROR`)。
|
||||||
|
*/
|
||||||
|
const S3_RECIPE: AutoRecipe = {
|
||||||
|
let: S3_LET_MIRROR,
|
||||||
|
do: [RECOMPUTE_MIRROR, REL_MIRROR, VAL_MIRROR, CARD_MIRROR, SAY_MIRROR],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **S3 修复前的样子**(2026-08-30 task-12 之前 `case-model.ts` 实际写的):没有
|
||||||
|
* `RECOMPUTE_MIRROR` 那一笔,直接从关系 / 验证记录投影起步。留着它专门给下面「同一行
|
||||||
|
* 自相矛盾」那条用例做对照 —— 不靠临时改文件做变异验证,这份对照本身就是常驻回归。
|
||||||
|
*/
|
||||||
|
const S3_RECIPE_PRE_FIX: AutoRecipe = {
|
||||||
|
let: S3_LET_MIRROR,
|
||||||
|
do: [REL_MIRROR, VAL_MIRROR, CARD_MIRROR, SAY_MIRROR],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('S3 审核卡配方:复用 S2 投影时必须自带 let,否则两笔投影的主键在 S3 那一拍是空的', () => {
|
||||||
|
it('S3 recipe 带 SHARED_LET:关系表那一行真的被重算更新,验证记录仍然只有一行', async () => {
|
||||||
|
const { runner, drafts, cards, ctx } = harness();
|
||||||
|
await runner.run(SETUP_S1S2, ctx);
|
||||||
|
await runner.run(HUMAN_EDIT, ctx);
|
||||||
|
|
||||||
|
// 重算之前:关系表还是 AI 的初版结论(recommended),这是本用例要推翻的起点
|
||||||
|
const beforeRelation = [...drafts.values()].find((d) => d.domain === 'substitution' && d.object === 'relation');
|
||||||
|
expect(beforeRelation?.patch['recommendation_level']).toBe('recommended');
|
||||||
|
|
||||||
|
const out = await runner.run(S3_RECIPE, ctx);
|
||||||
|
|
||||||
|
// ① 关系表那一行真的被重算更新了 —— 不是「写下 0 行」,推荐等级跟着人改的四态翻了
|
||||||
|
const relationRows = [...drafts.values()].filter((d) => d.domain === 'substitution' && d.object === 'relation');
|
||||||
|
expect(relationRows).toHaveLength(1);
|
||||||
|
expect(relationRows[0]?.key['substitution_relation_id']).toBe(RELATION_ID);
|
||||||
|
expect(relationRows[0]?.patch['recommendation_level']).toBe('not_recommended');
|
||||||
|
expect(relationRows[0]?.patch['risk_level']).toBe('high');
|
||||||
|
|
||||||
|
// ② 验证记录仍然只有一行,没有 `validation_id:'VAL-'` 那种拼出来的垃圾行
|
||||||
|
const validationRows = [...drafts.values()].filter((d) => d.domain === 'validation' && d.object === 'record');
|
||||||
|
expect(validationRows).toHaveLength(1);
|
||||||
|
expect(validationRows[0]?.key['validation_id']).toBe(VALIDATION_ID);
|
||||||
|
expect(validationRows.some((d) => d.key['validation_id'] === 'VAL-')).toBe(false);
|
||||||
|
|
||||||
|
// quiet:true 时卡不该真正发出去(外层过滤;cards.decide 若被调用会直接 reject 炸掉这条用例)
|
||||||
|
expect(cards).toHaveLength(0);
|
||||||
|
expect(out.issuedCardId).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **2026-08-30 task-12 加的一组**:钉住「结论纹丝不动」那条要害缺陷本身 ——
|
||||||
|
* 人改了 `difference_status`,**这一行自己的** `judgement_class`/`risk_level` 有没有真的
|
||||||
|
* 跟着重算,以及重算有没有误伤人可以改的叙事列。上一组的断言只看关系表 / 验证记录的
|
||||||
|
* 结果,没有直接看差异明细这一行自己;而「同一行自相矛盾」正是浏览器走查实测到的现象
|
||||||
|
* ——`difference_status=match` 与 `judgement_class=hard_mismatch` 同时摆在一行上。
|
||||||
|
*
|
||||||
|
* 第一条用 `S3_RECIPE_PRE_FIX`(没有 `RECOMPUTE_MIRROR` 那一笔)重现修复前的样子,
|
||||||
|
* 第二条用 `S3_RECIPE`(现在的样子)证明修复生效 —— 两条对照就是常驻的变异验证,
|
||||||
|
* 不必临时改这份文件本身来复现「摘掉修复」的红。
|
||||||
|
*/
|
||||||
|
describe('S3 审核卡配方:差异明细重算 —— 人改的四态要转成判定分类,不能只停在人写的那一列', () => {
|
||||||
|
it('修复前的形状(S3_RECIPE_PRE_FIX,没有差异明细重算笔):同一行自相矛盾 —— difference_status 变了,judgement_class 纹丝不动', async () => {
|
||||||
|
const { runner, drafts, ctx } = harness();
|
||||||
|
await runner.run(SETUP_S1S2, ctx);
|
||||||
|
await runner.run(HUMAN_EDIT, ctx);
|
||||||
|
await runner.run(S3_RECIPE_PRE_FIX, ctx);
|
||||||
|
|
||||||
|
const edited = [...drafts.values()].find(
|
||||||
|
(d) =>
|
||||||
|
d.domain === 'substitution' &&
|
||||||
|
d.object === 'difference_item' &&
|
||||||
|
d.key['difference_item_id'] === `${RELATION_ID}-静态电流`,
|
||||||
|
);
|
||||||
|
// 人改的那一列如实落了草稿
|
||||||
|
expect(edited?.patch['difference_status']).toBe('mismatch');
|
||||||
|
// 但没有任何一笔把它翻译成 judgement_class —— 停在 setup 阶段写下的旧值,同一行自相矛盾
|
||||||
|
expect(edited?.patch['judgement_class']).toBe('match');
|
||||||
|
expect(edited?.patch['risk_level']).toBe('low');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('修复后(S3_RECIPE):差异明细这一行自己的 judgement_class / risk_level 真的跟着 difference_status 翻了,且叙事列一个字没被覆盖', async () => {
|
||||||
|
const { runner, drafts, ctx } = harness();
|
||||||
|
await runner.run(SETUP_S1S2, ctx);
|
||||||
|
await runner.run(HUMAN_EDIT, ctx);
|
||||||
|
await runner.run(S3_RECIPE, ctx);
|
||||||
|
|
||||||
|
const edited = [...drafts.values()].find(
|
||||||
|
(d) =>
|
||||||
|
d.domain === 'substitution' &&
|
||||||
|
d.object === 'difference_item' &&
|
||||||
|
d.key['difference_item_id'] === `${RELATION_ID}-静态电流`,
|
||||||
|
);
|
||||||
|
expect(edited?.patch['difference_status']).toBe('mismatch');
|
||||||
|
expect(edited?.patch['judgement_class']).toBe('hard_mismatch');
|
||||||
|
expect(edited?.patch['risk_level']).toBe('high');
|
||||||
|
|
||||||
|
// 人在这一行写的判断依据一个字没被重算笔覆盖(它只列了两个派生列)
|
||||||
|
expect(edited?.patch['evidence_note']).toBe(
|
||||||
|
'审核人核对候选规格书 §6.2 实测值,静态电流是原厂 4 倍,超待机功耗预算',
|
||||||
|
);
|
||||||
|
// 人没碰过的 impact_statement 仍是 SETUP_S1S2 写的那句 —— 证明重算笔没有清空叙事列
|
||||||
|
expect(edited?.patch['impact_statement']).toBe('无');
|
||||||
|
|
||||||
|
// 没被人改过的那一行(输入电压范围)保持原样 —— 重算笔不该误伤没变过的行
|
||||||
|
const untouched = [...drafts.values()].find(
|
||||||
|
(d) =>
|
||||||
|
d.domain === 'substitution' &&
|
||||||
|
d.object === 'difference_item' &&
|
||||||
|
d.key['difference_item_id'] === `${RELATION_ID}-输入电压范围`,
|
||||||
|
);
|
||||||
|
expect(untouched?.patch['difference_status']).toBe('match');
|
||||||
|
expect(untouched?.patch['judgement_class']).toBe('match');
|
||||||
|
expect(untouched?.patch['risk_level']).toBe('low');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,6 +33,7 @@ export {
|
|||||||
|
|
||||||
export type {
|
export type {
|
||||||
AgentPort,
|
AgentPort,
|
||||||
|
AnalyzeGateway,
|
||||||
CardGateway,
|
CardGateway,
|
||||||
Clock,
|
Clock,
|
||||||
ListenerRegistrar,
|
ListenerRegistrar,
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* 配方表达式求值 —— **纯函数,零 IO**(编码规范:domain 层不碰端口)。
|
||||||
|
*
|
||||||
|
* S-FEEL 不在这里实现:`condition` 由调用方注入(与哨兵、`doneWhen` 同一个求值器)。
|
||||||
|
* **不发明第二套求值** 是 ADR-014 的一条硬约束。
|
||||||
|
*/
|
||||||
|
import type { AutoExpr } from '@contracts';
|
||||||
|
|
||||||
|
export type Row = Readonly<Record<string, unknown>>;
|
||||||
|
|
||||||
|
export type ExprCtx = {
|
||||||
|
/** 任务视图(正式 + 本单草稿叠加)+ 基座读数 + 起单入参,外加本拍的 `result:*` */
|
||||||
|
readonly scope: Readonly<Record<string, unknown>>;
|
||||||
|
/** 配方级 `let` 算好的值 —— 与 scope 同一命名空间,它优先 */
|
||||||
|
readonly lets: Readonly<Record<string, unknown>>;
|
||||||
|
readonly row?: Row | undefined;
|
||||||
|
readonly outer?: Row | undefined;
|
||||||
|
/** 同一行里**已经算出来**的字段 —— 「先 lookup 出依据,再按依据判 found/absent」靠它 */
|
||||||
|
readonly computed?: Row | undefined;
|
||||||
|
readonly condition: (expr: string, scope: Readonly<Record<string, unknown>>) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 地址取行 —— 四种地址(spec §5.1),取不到一律空数组。
|
||||||
|
*
|
||||||
|
* **取不到不抛**:首拍很多表本来就一行都还没有,抛了等于每一单开局就交人。
|
||||||
|
*/
|
||||||
|
export function rowsAt(scope: Readonly<Record<string, unknown>>, 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* 配方字段(与 `let`)求值序 —— 纯函数,零 IO(编码规范 §5.3)。
|
||||||
|
*
|
||||||
|
* `AutoAction.fields` / `analyze.input.fields` / `AutoRecipe.let` 存进
|
||||||
|
* `sys.activity_package.manifest`(`jsonb` 列)之后,对象键的**原始书写顺序不再保证**:
|
||||||
|
* PostgreSQL 的 `jsonb` 按「先短后长、同长字典序」把每一层嵌套对象的键重新排列(实测见
|
||||||
|
* `.superpowers/sdd/2026-08-29-act-work-05-rewrite/judgement-class-probe.md`)。
|
||||||
|
*
|
||||||
|
* `auto-runner` 逐字段求值时把已经算出来的值放进 `ctx.computed`,后面的字段能通过
|
||||||
|
* S-FEEL `when` 链读到前面字段的结果(`evalExpr` 唯一把 `computed` 摊进求值作用域的
|
||||||
|
* 分支 —— 见 `auto-expr.ts`)。这条依赖因此**只可能藏在 `when` 字符串里**:字段 A 的
|
||||||
|
* `when`(或它嵌套的 `then`/`else`/`lookup.by`/`cat`/`fmt`)引用了同一个 `fields` 对象
|
||||||
|
* 里另一个字段的名字,A 就依赖那个字段先算。
|
||||||
|
*
|
||||||
|
* 这个文件按**字段名之间的真实依赖**(而不是声明顺序 / jsonb 存取后的顺序)排出一个
|
||||||
|
* 安全的求值序 —— 与存储格式无关,`fields` 不必改成数组(那是破坏性契约变更)。
|
||||||
|
* 真正的环(A 依赖 B、B 依赖 A)排不出来,直接抛错 —— 不默默兜底,兜底正是这次出事的形态。
|
||||||
|
*
|
||||||
|
* **`AutoRecipe.let` 是同一个地基缺陷的第二个受害者**(2026-08-30 全分支终审 Important 1,
|
||||||
|
* H47 当时只治了 `fields` 那一半):`auto-runner.ts` 从前逐 `Object.entries(recipe.let)`
|
||||||
|
* 按声明顺序求值,而 `let` 同样住在这份 jsonb 里、同样会被重排。且 `let` 与 `fields` 的
|
||||||
|
* 依赖读法**不一样**——`fields` 的字段之间只能通过 `when` 字符串里的裸标识符互相看见
|
||||||
|
* (`{in:X}` 在 `fields` 里读的是 `lets`/`scope`,永远读不到同一个 `fields` 对象里的
|
||||||
|
* 另一个字段);而 `let` 的**官方读法就是 `{in:X}`**(见 `packages/contracts/src/
|
||||||
|
* auto-recipe.ts` 的注释:「先算一遍,后面所有表达式都能 {in:'名'} 引用」,运行时
|
||||||
|
* `evalExpr` 的 `'in' in expr` 分支优先读 `ctx.lets[X]`)——`whenReferences` 故意不认
|
||||||
|
* 裸 `{in:X}` 是对的(对 `fields` 而言),但直接照搬给 `let` 用就会**漏掉最主要的那种
|
||||||
|
* 依赖**,排序等于没排。所以 `let` 用的是下面单独一份 `letReferences`/`orderLets`,
|
||||||
|
* 不是简单复用 `whenReferences`/`orderFields`。
|
||||||
|
*/
|
||||||
|
import type { AutoExpr } from '@contracts';
|
||||||
|
import { staticCheck } from '@sfeel';
|
||||||
|
import { topoOrder } from '@utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `noun` 让同一个错误类型给 `fields`(默认「字段」)与 `let`(见下面 `orderLets`)
|
||||||
|
* 各自说准话:两者的依赖读法不一样(`let` 多认 `{in:X}`,提示语也得多提这一句),
|
||||||
|
* 但都是"排不出安全求值序"这同一件事,不必另开一个类。
|
||||||
|
*/
|
||||||
|
export class AutoFieldCycleError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly cycle: readonly string[],
|
||||||
|
noun: '字段' | 'let' = '字段',
|
||||||
|
) {
|
||||||
|
const how = noun === 'let' ? '互相用 `{in:…}` 或 when 引用了对方' : '互相引用了对方';
|
||||||
|
super(`${noun}之间存在循环依赖,求不出安全的求值序:${cycle.join('、')}(检查这几个${noun}是否${how})`);
|
||||||
|
this.name = 'AutoFieldCycleError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归找出表达式里全部 `when` 字符串引用的根路径名。
|
||||||
|
*
|
||||||
|
* 名字取 S-FEEL AST 的 `references`,不用正则扫标识符 —— 与 `static-audit.ts` 的
|
||||||
|
* `checkWhen` 同一个理由:正则会把 `count(...)` 这类内建函数名也当成字段名。
|
||||||
|
* `when` 语法本该在装包静态审那关就被拦住(`autoRecipeFindings` 的 `checkWhen`),
|
||||||
|
* 这里万一撞见解析不了的表达式,当作没有引用处理(宁可漏报不可误报)。
|
||||||
|
*
|
||||||
|
* **与 `activity-registry/internal/domain/static-audit.ts` 里的 `fieldReferences`
|
||||||
|
* 是同一个算法,写了两遍**:装包静态审(L4)不能 import 配方执行器(L5,模块分层
|
||||||
|
* 只准低层被高层引用),这一段小小的、纯粹依附 `AutoExpr` 形状的逻辑只能各自留一份;
|
||||||
|
* 真正通用、不含业务语义的图算法(`topoOrder`)已经提到 `@utils` 共用了。
|
||||||
|
* 改动这个函数时,记得看看那边要不要一起改。
|
||||||
|
*/
|
||||||
|
function whenReferences(expr: AutoExpr): readonly string[] {
|
||||||
|
if (typeof expr !== 'object' || expr === null) return [];
|
||||||
|
if ('when' in expr) {
|
||||||
|
const verdict = staticCheck(expr.when);
|
||||||
|
const names = verdict.references.map((path) => path.split('.')[0] ?? path);
|
||||||
|
return [...names, ...whenReferences(expr.then), ...whenReferences(expr.else)];
|
||||||
|
}
|
||||||
|
if ('lookup' in expr) {
|
||||||
|
const byNames = Object.values(expr.by).flatMap((value) => whenReferences(value));
|
||||||
|
return expr.else === undefined ? byNames : [...byNames, ...whenReferences(expr.else)];
|
||||||
|
}
|
||||||
|
if ('cat' in expr) return expr.cat.flatMap((part) => whenReferences(part));
|
||||||
|
if ('fmt' in expr) return whenReferences(expr.fmt);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 `fields` 排出一个安全的求值序:被依赖的字段排前面,返回 `[name, expr]` 有序对
|
||||||
|
* (调用方直接拿它替掉 `Object.entries(fields)`,别的都不用变)。
|
||||||
|
*
|
||||||
|
* 只认**同一个 fields 对象内**的字段名当依赖 —— 台账列名、`case:` 这类全局名字与
|
||||||
|
* 求值顺序无关(它们一直都在 `scope` 里,不经 `computed` 这条通道),排了反而可能把
|
||||||
|
* 「本笔内」不存在的依赖当真。找不出安全序(真正的环)抛 `AutoFieldCycleError`。
|
||||||
|
*/
|
||||||
|
export function orderFields(fields: Readonly<Record<string, AutoExpr>>): ReadonlyArray<readonly [string, AutoExpr]> {
|
||||||
|
const entries = Object.entries(fields);
|
||||||
|
const names = new Set(entries.map(([name]) => name));
|
||||||
|
const deps = new Map<string, readonly string[]>(
|
||||||
|
entries.map(([name, expr]) => [name, [...new Set(whenReferences(expr))].filter((ref) => ref !== name && names.has(ref))]),
|
||||||
|
);
|
||||||
|
const byName = new Map(entries);
|
||||||
|
|
||||||
|
const result = topoOrder(
|
||||||
|
entries.map(([name]) => name),
|
||||||
|
(name) => deps.get(name) ?? [],
|
||||||
|
);
|
||||||
|
if (!result.ok) throw new AutoFieldCycleError(result.cycle);
|
||||||
|
return result.order.map((name) => [name, byName.get(name)!] as const);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `letReferences` 与 `whenReferences` 几乎同一套遍历,只多认一种入口:
|
||||||
|
* **裸 `{in:X}` 本身就是依赖**——`let` 唯一的官方跨条目读法(见文件头注)。
|
||||||
|
* `when`/`lookup.by`/`cat`/`fmt` 四种嵌套形态与 `whenReferences` 完全一致
|
||||||
|
* (`let` 的 `when` 子句同样进的是含 `...ctx.lets` 的合并作用域,见 `auto-expr.ts`
|
||||||
|
* `evalExpr` 最后那段 `scope` 拼装,裸标识符引用另一个 `let` 的名字同样成立)。
|
||||||
|
*
|
||||||
|
* **与 `activity-registry/internal/domain/static-audit.ts` 里的 `letReferences` 是
|
||||||
|
* 同一个算法,写了两遍**——理由与 `whenReferences` 头注同款(L4 装包静态审不能 import
|
||||||
|
* L5 配方执行器)。改动这个函数时,记得看看那边要不要一起改。
|
||||||
|
*/
|
||||||
|
function letReferences(expr: AutoExpr): readonly string[] {
|
||||||
|
if (typeof expr !== 'object' || expr === null) return [];
|
||||||
|
if ('in' in expr) return [expr.in];
|
||||||
|
if ('when' in expr) {
|
||||||
|
const verdict = staticCheck(expr.when);
|
||||||
|
const names = verdict.references.map((path) => path.split('.')[0] ?? path);
|
||||||
|
return [...names, ...letReferences(expr.then), ...letReferences(expr.else)];
|
||||||
|
}
|
||||||
|
if ('lookup' in expr) {
|
||||||
|
const byNames = Object.values(expr.by).flatMap((value) => letReferences(value));
|
||||||
|
return expr.else === undefined ? byNames : [...byNames, ...letReferences(expr.else)];
|
||||||
|
}
|
||||||
|
if ('cat' in expr) return expr.cat.flatMap((part) => letReferences(part));
|
||||||
|
if ('fmt' in expr) return letReferences(expr.fmt);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 `recipe.let` 排出一个安全的求值序 —— 与 `orderFields` 同一个用法(调用方直接拿它
|
||||||
|
* 替掉 `Object.entries(recipe.let)`),只是依赖读法换成 `letReferences`(认 `{in:X}`)。
|
||||||
|
* 找不出安全序(真正的环,如 `a: {in:'b'}, b: {in:'a'}`)抛 `AutoFieldCycleError`
|
||||||
|
* (`noun: 'let'`,提示语说的是 `let` 不是字段)。
|
||||||
|
*/
|
||||||
|
export function orderLets(lets: Readonly<Record<string, AutoExpr>>): ReadonlyArray<readonly [string, AutoExpr]> {
|
||||||
|
const entries = Object.entries(lets);
|
||||||
|
const names = new Set(entries.map(([name]) => name));
|
||||||
|
const deps = new Map<string, readonly string[]>(
|
||||||
|
entries.map(([name, expr]) => [name, [...new Set(letReferences(expr))].filter((ref) => ref !== name && names.has(ref))]),
|
||||||
|
);
|
||||||
|
const byName = new Map(entries);
|
||||||
|
|
||||||
|
const result = topoOrder(
|
||||||
|
entries.map(([name]) => name),
|
||||||
|
(name) => deps.get(name) ?? [],
|
||||||
|
);
|
||||||
|
if (!result.ok) throw new AutoFieldCycleError(result.cycle, 'let');
|
||||||
|
return result.order.map((name) => [name, byName.get(name)!] as const);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* 配方数据源展开 —— **纯函数,零 IO**。
|
||||||
|
*
|
||||||
|
* 嵌套**两层封顶**:两层(某几颗料 × 该类型的参数字段)是已知业务的上限;
|
||||||
|
* 超出就别 auto —— 退回模型,或者把那一段切出去。
|
||||||
|
*/
|
||||||
|
import type { AutoExpr, AutoSource } from '@contracts';
|
||||||
|
import { evalExpr, rowsAt, type ExprCtx, type Row } from './auto-expr.ts';
|
||||||
|
|
||||||
|
export type ExpandedRow = { readonly row: Row; readonly outer?: Row | undefined };
|
||||||
|
|
||||||
|
/** where 是等值过滤;比值按字符串(台账里数字与文本混着存) */
|
||||||
|
function matches(
|
||||||
|
row: Row,
|
||||||
|
where: Readonly<Record<string, AutoExpr>> | undefined,
|
||||||
|
ctx: ExprCtx,
|
||||||
|
outer?: Row | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (where === undefined) return true;
|
||||||
|
return Object.entries(where).every(([column, valueExpr]) => {
|
||||||
|
const want = evalExpr(valueExpr, { ...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<string, unknown> = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* **这一单此刻在办哪一步** —— 从 turn-runner 里提纯出来的一句话(纯函数,好测)。
|
||||||
|
*
|
||||||
|
* 为什么要跳过已收掉的项:带 `repetitionRule` 的项被收掉之后**仍是 Enabled**,
|
||||||
|
* 「在场的第一个有剧本的项」于是永远是第一步 —— 实测:跑到判定去向了,
|
||||||
|
* 工作区还停在阶段二那份件,喂给模型的做法也还是第一步的。
|
||||||
|
*/
|
||||||
|
export function liveStepOf<S extends { readonly item: string }>(
|
||||||
|
steps: readonly S[],
|
||||||
|
settledIds: ReadonlySet<string>,
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
/**
|
||||||
|
* 配方执行器 —— 逐笔执行 `PlanStep.auto`(spec §5.1)。
|
||||||
|
*
|
||||||
|
* 三条硬规矩:
|
||||||
|
* ① **每一笔之前刷读数**:后一笔常常要数前一笔刚写下的行。不刷新就恒为 0 ——
|
||||||
|
* 推荐等级会永远算成「全 MATCH」,而屏上明明摆着两条不符。
|
||||||
|
* ② **幂等**:`draftWrite` 同 PK 覆盖,值没变**静默跳过**(不留痕、不计数)——
|
||||||
|
* 「写了等于没写」那条判据是拦模型空转的,配方不是模型;
|
||||||
|
* 发卡先看 `carded`(worldTouched),发过就不再发。
|
||||||
|
* ③ **出错 = 交人,不重试、不退回模型**:配方抛错是活动包的 bug。
|
||||||
|
* 退回模型就是让它去凑,那正是这套机制要消灭的东西。
|
||||||
|
*/
|
||||||
|
import type { AutoAction, AutoExpr, AutoRecipe, StaffId, TaskId } from '@contracts';
|
||||||
|
import { AutoFieldCycleError, orderFields, orderLets } from '../domain/auto-order.ts';
|
||||||
|
import { evalExpr, evalText, rowsAt, type ExprCtx } 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;
|
||||||
|
/**
|
||||||
|
* **人把这一步打回重做时写下的那句话**(2026-08-31 用户定:「重做后要把原因带回去,
|
||||||
|
* 让 AI 重跑这一步」)—— 没有打回就是空串,**永远绑得上**:S-FEEL 里未绑定的名字参与
|
||||||
|
* 比较会翻车(`!= ""` 求值为真),那正好会让「只在被打回时才跑」的那几笔每一轮都跑。
|
||||||
|
*
|
||||||
|
* 配方里的名字是 **`case_redo_note`**(下划线,不是 `case:` 前缀)—— S-FEEL 的名字只认
|
||||||
|
* `[A-Za-z0-9_一-龥]`(`packages/sfeel/src/lexer.ts` 的 NAME_PART),带冒号的名字
|
||||||
|
* 写不进 `when` 条件;而这句话最要紧的用法恰恰是当条件用。
|
||||||
|
*/
|
||||||
|
readonly redoNote?: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<Record<string, string>>): string => Object.values(key).join('|');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 摆给人看的行标识 —— **去掉单号那一段**(2026-08-25 柴浩:「只保留斜杠右边」):
|
||||||
|
* 人正在这一单里看,单号是脚下的地面,不是区分行的信息。与基座的 `rowLabel` 同一条口径。
|
||||||
|
* 去完一段都不剩(主键只有单号)就退回整段。
|
||||||
|
*/
|
||||||
|
function rowName(key: Readonly<Record<string, string>>, caseKey: string | undefined): string {
|
||||||
|
const rest = Object.entries(key)
|
||||||
|
.filter(([column]) => column !== caseKey)
|
||||||
|
.map(([, value]) => value);
|
||||||
|
return (rest.length > 0 ? rest : Object.values(key)).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点名封顶 8 个 —— 再多就不是「点名」而是刷屏(拒回主键名单封顶 12 是同一条理由) */
|
||||||
|
function nameList(names: readonly string[]): string {
|
||||||
|
return names.length <= 8 ? names.join('、') : `${names.slice(0, 8).join('、')}…等 ${names.length} 行`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `orderFields` 的环报错翻成 `AutoRecipeError`(与本文件其余显式抛错同一个类 ——
|
||||||
|
* 外层 try/catch 只按 §头注③「出错 = 交人」一条处置,不分错误类型)。
|
||||||
|
* 真正的环本该在装包静态审那关就被拦下(`autoRecipeFindings`),这里只是最后一道兜底。
|
||||||
|
*/
|
||||||
|
function safeFieldOrder(fields: Readonly<Record<string, AutoExpr>>, index: number): ReadonlyArray<readonly [string, AutoExpr]> {
|
||||||
|
try {
|
||||||
|
return orderFields(fields);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AutoFieldCycleError) throw new AutoRecipeError(index, err.message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 这份配方会不会发卡 —— 「卡答过了就收步」那道门要问它 */
|
||||||
|
export function recipeIssuesCard(recipe: AutoRecipe): boolean {
|
||||||
|
return recipe.do.some((action) => action.kind === 'card');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAutoRunner(deps: AutoRunnerDeps): {
|
||||||
|
run(recipe: AutoRecipe, ctx: AutoContext): Promise<AutoOutcome>;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
async run(recipe, ctx) {
|
||||||
|
const writable = await deps.data.writable(ctx.taskId);
|
||||||
|
const results: Record<string, unknown[]> = {};
|
||||||
|
/* 人打回时写的那句话:没有就是空串(见 `AutoContext.redoNote` 的头注 —— 必须永远绑得上) */
|
||||||
|
const redoNote = { case_redo_note: ctx.redoNote ?? '' };
|
||||||
|
let scope: Record<string, unknown> = { ...(await deps.data.view(ctx.taskId)), ...redoNote, ...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<string, unknown> = {};
|
||||||
|
/*
|
||||||
|
* **`let` 算好之前不许任何一笔动作跑**(2026-08-30 终审 Important 1)—— 计算挪到了
|
||||||
|
* 下面主循环第一次迭代的 try 里(见那一段注释),`letsReady` 只是标一下算过没有。
|
||||||
|
*/
|
||||||
|
let letsReady = false;
|
||||||
|
|
||||||
|
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') {
|
||||||
|
/*
|
||||||
|
* **kind: 'observation'**(2026-08-29,R10)—— 从前是 `kind: 'result'`,
|
||||||
|
* 而 `result` 不在 `task-log.ts` 的 `SKELETON` 里,这一笔永远进不了工作日志
|
||||||
|
* (spec §5.9 承诺「配方每一笔照常进 task_memory,工作日志里归系统灰」落空)。
|
||||||
|
* `observation` 在骨架里;不带 `detail.say` 时 `actorOf` 本就归系统。
|
||||||
|
*
|
||||||
|
* **人正在件上填的那几拍(quiet),这句话不上对话区**(2026-08-31 用户:
|
||||||
|
* 「每次保存的时候右边也会出现 ai 推进中,然后会说一句话,能不能把这个也取消掉」)。
|
||||||
|
* 对话区的纪律是**一轮一句,不是一拍一句**(2026-08-01):只有**交出控制权**
|
||||||
|
* 那一拍的交代摆到人面前。而 quiet 拍恰恰没有交出控制权 —— 控制权本来就在
|
||||||
|
* 人手上(他正对着这张件填),配方这一趟只是按最新草稿把行铺好;人按几次保存
|
||||||
|
* 就跑几趟,同一句话跟着说几遍,那是纯噪音。
|
||||||
|
* 压下去不是丢掉:`internal` 是两个投影共用的一个旗(终审 I5/F2)——
|
||||||
|
* `chat-projection` 据此挡住对话区,而 `task-log.machinery()` 对 `detail.auto`
|
||||||
|
* 开了例外,这一笔在**工作日志里照旧看得见**(话有插值时值的变化也查得到)。
|
||||||
|
*/
|
||||||
|
await deps.memory.append({
|
||||||
|
taskId: context.taskId,
|
||||||
|
kind: 'observation',
|
||||||
|
title: evalText(action.text, base),
|
||||||
|
detail: context.quiet
|
||||||
|
? { auto: `${context.stepId}#${index + 1}`, internal: true }
|
||||||
|
: { 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<string>();
|
||||||
|
let localWrote = 0;
|
||||||
|
/** 「只建不改」的表里已经在库、这一笔不用建的行数 */
|
||||||
|
let localExisting = 0;
|
||||||
|
/** 主键算不出来、这一笔没写下去的行数 —— 不出声的话表里少了行谁也查不出为什么(M1) */
|
||||||
|
let localUnkeyed = 0;
|
||||||
|
/** 撤掉的是哪几行 —— 「撤要说出来、要点名」(2026-08-02 为人工撤行立的规矩,配方这一侧跟上,M2) */
|
||||||
|
const dropNames: string[] = [];
|
||||||
|
/*
|
||||||
|
* **字段按依赖排序求值,不按声明/存储顺序**(2026-08-30,修 jsonb 重排 bug ——
|
||||||
|
* 见 `.superpowers/sdd/2026-08-29-act-work-05-rewrite/judgement-class-probe.md`)。
|
||||||
|
* `fields` 经一趟 `sys.activity_package.manifest`(jsonb 列)存取,对象键的原始
|
||||||
|
* 书写顺序不再保证;`orderFields` 按字段名之间的真实依赖(`when` 链互相引用)
|
||||||
|
* 排出一个安全序,与存储格式无关。只算一次 —— 排序结果与 `row` 无关,
|
||||||
|
* `action.fields` 的结构对每一行都一样。
|
||||||
|
*/
|
||||||
|
const fieldOrder = safeFieldOrder(action.fields, index);
|
||||||
|
for (const { row, outer } of expandSource(action.from, base)) {
|
||||||
|
const computed: Record<string, unknown> = {};
|
||||||
|
for (const [name, expr] of fieldOrder) {
|
||||||
|
computed[name] = evalExpr(expr, { ...base, row, ...(outer === undefined ? {} : { outer }), computed });
|
||||||
|
}
|
||||||
|
const key: Record<string, string> = {};
|
||||||
|
for (const column of decl.keys) key[column] = String(computed[column] ?? '');
|
||||||
|
/* 主键算不出来就先不写 —— 空主键写下去是一行永远对不上的垃圾。**但要数一笔**(M1) */
|
||||||
|
if (decl.keys.some((column) => key[column] === '')) {
|
||||||
|
localUnkeyed += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const patch: Record<string, unknown> = {};
|
||||||
|
for (const [name, value] of Object.entries(computed)) if (!decl.keys.includes(name)) patch[name] = value;
|
||||||
|
/*
|
||||||
|
* 两个旗都是**配方专用**(2026-08-29 终审 I2),见 `TaskDataGateway.draftWrite` 上的注释:
|
||||||
|
* · `skipUnchanged` —— 值没变就别落笔。草稿层的 upsert 尾巴上有 `landed_at = null`,
|
||||||
|
* 而配方每一拍都把同一张表重投一遍:照写会把数据工单已确认入库的那些行**悄悄 un-land**,
|
||||||
|
* 整单放行时再写一遍正式表、再抛一遍 data 事件。
|
||||||
|
* · `skipExisting` —— 「只建不改」的表碰到已在库的键当「不用建」跳过。
|
||||||
|
* 那句拒回是写给模型看的;配方抛出去就是永久停单,而人看见一句他办不了的话。
|
||||||
|
*/
|
||||||
|
const { changed, skipped } = await deps.data.draftWrite({
|
||||||
|
taskId: context.taskId,
|
||||||
|
domain: decl.domain,
|
||||||
|
object: decl.object,
|
||||||
|
key,
|
||||||
|
patch,
|
||||||
|
skipUnchanged: true,
|
||||||
|
skipExisting: true,
|
||||||
|
});
|
||||||
|
if (skipped === true) localExisting += 1;
|
||||||
|
else if (changed) localWrote += 1;
|
||||||
|
/* 已在库的那一颗也算「这一笔要的行」—— prune 不许把它从草稿层撤掉 */
|
||||||
|
kept.add(keyText(key));
|
||||||
|
}
|
||||||
|
let localDropped = 0;
|
||||||
|
/** 已随「数据工单确认」落地、prune 不动它的行数 —— 跳过要说给人听(debt 那条的原话要求) */
|
||||||
|
let localLandedKept = 0;
|
||||||
|
/*
|
||||||
|
* **人正在件上编辑的时候不 prune**(2026-08-29 终审 F3)。
|
||||||
|
* `prune` 的语义是「这一笔投影出来的行就是这张表的全部,多出来的撤掉」——
|
||||||
|
* 而人在件上「加一行」加出来的行,配方的数据源里当然没有:
|
||||||
|
* 编辑中途把人刚加的那一行悄悄撤掉,是最不能接受的一种静默数据丢失
|
||||||
|
* (2026-08-02 为人工撤行立过「撤要说出来、要点名哪几行」的规矩,这里连说都没说)。
|
||||||
|
* 有卡在等人(quiet)= 人正对着这张件填,这一拍只铺不撤;等他交回之后那一拍再对齐。
|
||||||
|
*/
|
||||||
|
/* true:非 quiet 拍才撤(F3,人正编辑不动他的行);'live':quiet 也撤 ——
|
||||||
|
只给人不会在件上加行的表用(契约注释与静态审都写着),跟手比守候更诚实 */
|
||||||
|
if (action.prune === 'live' || (action.prune === true && !context.quiet)) {
|
||||||
|
for (const stale of before) {
|
||||||
|
const key: Record<string, string> = {};
|
||||||
|
for (const column of decl.keys) key[column] = String(stale[column] ?? '');
|
||||||
|
if (kept.has(keyText(key))) continue;
|
||||||
|
/*
|
||||||
|
* `skipLanded`:已随「数据工单确认」落进正式表的行不撤、也不炸(2026-08-31)。
|
||||||
|
* 从前这里不传,已落地的陈旧行让整个配方抛错停摆(2026-08-30 e2e 案例 2),
|
||||||
|
* ②③ 那两笔被迫整个放弃 prune —— 连**没落地的**陈旧行(人改了类型选择,
|
||||||
|
* 旧类型的字典/字段行)也跟着积在屏上。现在已落地的原地留下(它是世界的
|
||||||
|
* 一部分),没落地的照撤 —— 两种行各得其所。
|
||||||
|
*/
|
||||||
|
const { dropped: gone, landed } = await deps.data.draftDrop({ taskId: context.taskId, domain: decl.domain, object: decl.object, key, skipLanded: true });
|
||||||
|
if (gone) {
|
||||||
|
localDropped += 1;
|
||||||
|
dropNames.push(rowName(key, decl.caseKey));
|
||||||
|
} else if (landed === true) {
|
||||||
|
localLandedKept += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (localWrote > 0 || localDropped > 0 || localExisting > 0 || localUnkeyed > 0 || localLandedKept > 0) {
|
||||||
|
/*
|
||||||
|
* **kind: 'observation' + `internal: true`**(2026-08-29 终审 I5,控制端裁定 F2)。
|
||||||
|
* `kind` 必须是 `observation`:`result` 不在 `task-log.ts` 的 `SKELETON` 里,进不了工作日志(R10)。
|
||||||
|
* `internal` 必须留着:它是**对话区**那一侧的开关 —— 去掉之后这类机械痕
|
||||||
|
* (「芯片主数据:写下 2 行」)会一笔一个气泡跑进对话区,而对话区的口径是
|
||||||
|
* 「一轮一句,不是一拍一句」。工作日志那一侧由 `machinery()` 对 `detail.auto`
|
||||||
|
* 开的例外放行(与 `detail.say` 同款)——**两件事各有各的开关**。
|
||||||
|
*
|
||||||
|
* **`detail.changed`**(2026-08-29 复审 M1/M2 二审):这一笔只要有 `localExisting`
|
||||||
|
* 或 `localUnkeyed` 就会留痕(M1/M2 立的规矩——「一行没写也要出声」),而这两种局面
|
||||||
|
* **台账其实一个字没动**。重做闸(`raiseAcceptance` 的 `revised`)认的是
|
||||||
|
* 「配方写下的这一笔」,不点名区分就会把「全表已在库、这一笔不用建」这类空转
|
||||||
|
* 也当成「改过了」放行 —— 人打回重做,配方原样重跑一遍、一个字没改,闸却开了。
|
||||||
|
* 只有真写了或真撤了才算改过世界。
|
||||||
|
*/
|
||||||
|
await deps.memory.append({
|
||||||
|
taskId: context.taskId,
|
||||||
|
kind: 'observation',
|
||||||
|
title:
|
||||||
|
`${decl.label}:写下 ${localWrote} 行` +
|
||||||
|
(localExisting === 0 ? '' : `,${localExisting} 行已在库、这一笔不用建`) +
|
||||||
|
/* 撤了要点名(M2):不点名的话,下一拍的读者(人或模型)对不上是哪几行没了 */
|
||||||
|
(localDropped === 0 ? '' : `,撤掉 ${localDropped} 行(${nameList(dropNames)})`) +
|
||||||
|
/* 少写了几行要出声(M1):静默 continue 的那几行,表里少了谁也查不出为什么 */
|
||||||
|
(localUnkeyed === 0 ? '' : `,${localUnkeyed} 行因主键算不出来没写(主键列:${decl.keys.join('、')})`) +
|
||||||
|
/* 已落地的陈旧行跳过也要出声 —— 「这几行已经入库、这一笔没法帮你清」的实情说给人听 */
|
||||||
|
(localLandedKept === 0 ? '' : `,${localLandedKept} 行已随数据工单入库、这一笔不清它`),
|
||||||
|
detail: {
|
||||||
|
auto: `${context.stepId}#${index + 1}`,
|
||||||
|
ledger: action.into,
|
||||||
|
internal: true,
|
||||||
|
changed: localWrote > 0 || localDropped > 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { wrote: localWrote, dropped: localDropped };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.kind === 'analyze') {
|
||||||
|
/* 同一个 jsonb 重排问题也可能出在 analyze 的入参字段上(同一种「对象字面量当求值计划」的写法) */
|
||||||
|
const inputFieldOrder = safeFieldOrder(action.input.fields, index);
|
||||||
|
const payload = expandSource(action.input.from, base).map(({ row, outer }) => {
|
||||||
|
const item: Record<string, unknown> = {};
|
||||||
|
for (const [name, expr] of inputFieldOrder) {
|
||||||
|
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 : [];
|
||||||
|
/* kind: 'observation' + internal —— 理由同上(project 分支那条注释) */
|
||||||
|
await deps.memory.append({
|
||||||
|
taskId: context.taskId,
|
||||||
|
kind: 'observation',
|
||||||
|
title: `已把 ${payload.length} 条参数交给模型逐条判定,拿回 ${results[`result:${action.into}`]?.length ?? 0} 条结论`,
|
||||||
|
detail: { auto: `${context.stepId}#${index + 1}`, model: action.model, internal: true },
|
||||||
|
});
|
||||||
|
/* 出参只活在拍内作用域里,不进草稿层 —— 下一笔要能看见它,所以这里也算「脏」 */
|
||||||
|
scope = { ...scope, ...results };
|
||||||
|
return { wrote: 0, dropped: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.kind === 'card') {
|
||||||
|
/*
|
||||||
|
* **发过就不再发**(幂等)。人工激活的 auto 步,**配方发卡就是启动** ——
|
||||||
|
* 不必再有一个「先 start」的礼仪:学不稳的礼仪就是死法(2026-08-02 已拆过一次)。
|
||||||
|
*/
|
||||||
|
if (context.carded) {
|
||||||
|
/*
|
||||||
|
* **只跳过这一笔,不收拍**(2026-08-29 终审 I3)。从前这里 `stop: true`,
|
||||||
|
* 于是同一份配方 `[project, card, project]` 在两种局面下行为相反:
|
||||||
|
* 人还在填(quiet)时 card 被上面那道 quiet 过滤 `continue` 掉 → **第三笔照跑**;
|
||||||
|
* 人交回之后(非 quiet、carded)→ 走到这儿 break,**第三笔不跑**。
|
||||||
|
* 也就是说唯一一个「人的答案已经在草稿里」的时刻,配方的后半截偏偏不执行 ——
|
||||||
|
* 写配方的人会很自然地写「铺格 → 发卡 → 收口整理」,然后发现第三笔从来没生效过,
|
||||||
|
* 而且静默、没有痕。**「收拍」该留给真发出去了卡那一次**(下面两条 return 带 stop)。
|
||||||
|
*/
|
||||||
|
await deps.memory.append({
|
||||||
|
taskId: context.taskId,
|
||||||
|
kind: 'observation',
|
||||||
|
title: `这一步已经发过卡,不再发第二张`,
|
||||||
|
detail: { auto: `${context.stepId}#${index + 1}`, internal: true },
|
||||||
|
});
|
||||||
|
return { wrote: 0, dropped: 0 };
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
/* `CardGateway.decide` 的 variant 只认 'review'(审核卡);card.variant 的类型口子更宽(还有 'data'),
|
||||||
|
那是给执行工单用的 —— 这里按值窄化,别把 'data' 误传给决策卡 */
|
||||||
|
...(action.variant === 'review' ? { 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: [],
|
||||||
|
/* 执行工单认两副变体面孔:`data` 数据工单 / `accept` 验收工单;`review` 是决策卡那边的,按值窄化别串台 */
|
||||||
|
...(action.variant === 'data' || action.variant === 'accept' ? { variant: action.variant } : {}),
|
||||||
|
...(action.guide === undefined ? {} : { steps: [...action.guide] }),
|
||||||
|
...(action.standard === undefined ? {} : { standard: evalText(action.standard, base) }),
|
||||||
|
...(action.gateLedgers === undefined ? {} : { gateLedgers: [...action.gateLedgers] }),
|
||||||
|
...(action.gateWhen === undefined ? {} : { gateWhen: action.gateWhen }),
|
||||||
|
|
||||||
|
...(action.gateWhenSay === undefined ? {} : { gateWhenSay: action.gateWhenSay }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 五个动词(say/project/analyze/card/submit)上面已全部处理,这里在类型上不可达 —— 留作穷尽性兜底 */
|
||||||
|
throw new AutoRecipeError(index, '动词尚未实现');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [index, action] of recipe.do.entries()) {
|
||||||
|
try {
|
||||||
|
if (dirty) {
|
||||||
|
scope = { ...(await deps.data.view(ctx.taskId)), ...redoNote, ...results };
|
||||||
|
dirty = false;
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* **`let` 与 `fields` 同一个 jsonb 重排隐患,同一套治法**(2026-08-30 终审
|
||||||
|
* Important 1,H47 当时只治了 `fields` 那一半 —— 见 `auto-order.ts` 头注)。
|
||||||
|
* `{in:X}` 是 `let` 唯一的官方写法,后一条 let 靠它读前一条已经算出的值,
|
||||||
|
* 键序被 jsonb 打乱同样会让这条依赖读到 `undefined`(未绑定的名字,不报错、
|
||||||
|
* 静默算错)。改按 `orderLets` 的依赖拓扑序求值,只算一次(`letsReady`)。
|
||||||
|
*
|
||||||
|
* **放进第一次循环的 try 里,不是单独一段**——唯一目的是**复用同一个 catch**:
|
||||||
|
* 真撞上环(该在装包静态审就被拦下,见 `static-audit.ts` 新增的 `letsCycle`
|
||||||
|
* 检查,这里是最后一道兜底)交人的路子,与某一笔配方动作本身办不下去完全一样,
|
||||||
|
* 不必另开一条"let 专属"的失败通道(`failedAt.index` 报的是 0,即"第 1 笔"——
|
||||||
|
* 不完全精确,但 `failed.message` 里的原话已经说清是 let 算不出来,够用)。
|
||||||
|
*/
|
||||||
|
if (!letsReady) {
|
||||||
|
for (const [name, expr] of orderLets(recipe.let ?? {})) {
|
||||||
|
lets[name] = evalExpr(expr, { scope, lets, condition });
|
||||||
|
}
|
||||||
|
letsReady = true;
|
||||||
|
}
|
||||||
|
const base: ExprCtx = { scope, lets, condition };
|
||||||
|
if (action.when !== undefined && !condition(action.when, { ...scope, ...lets })) continue;
|
||||||
|
/*
|
||||||
|
* 有卡在等人交回:只算、只写 —— 不发新卡、不 submit、不 analyze。
|
||||||
|
* `say` 照跑但**落成 internal**(见 runAction 里 say 那一段):话要留痕,
|
||||||
|
* 只是不摆到对话区上去 —— 人正在填,这一趟没有新一轮可言。
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
/*
|
||||||
|
* **失败这一笔不标 internal** —— 它是说给人听的(「交给你处理」),
|
||||||
|
* 该出现在对话区。`say` 同理(它本来就是人话)。(2026-08-29 终审 I5)
|
||||||
|
*/
|
||||||
|
await deps.memory.append({
|
||||||
|
taskId: ctx.taskId,
|
||||||
|
kind: 'observation',
|
||||||
|
title: `配方第 ${index + 1} 笔办不下去:${message} —— 交给你处理`,
|
||||||
|
detail: { auto: `${ctx.stepId}#${index + 1}` },
|
||||||
|
});
|
||||||
|
return { wrote, dropped, ...(issuedCardId === undefined ? {} : { issuedCardId }), failedAt: { index, message } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
wrote,
|
||||||
|
dropped,
|
||||||
|
...(issuedCardId === undefined ? {} : { issuedCardId }),
|
||||||
|
...(submit === undefined ? {} : { submit }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import type { AgentAction, LoopEngineService, TurnOutcome } from '../../contract
|
|||||||
import { ForbiddenTerminalError, TURN_ACTION_CAP } from '../../contract.ts';
|
import { ForbiddenTerminalError, TURN_ACTION_CAP } from '../../contract.ts';
|
||||||
import type {
|
import type {
|
||||||
AgentPort,
|
AgentPort,
|
||||||
|
AnalyzeGateway,
|
||||||
CardGateway,
|
CardGateway,
|
||||||
Clock,
|
Clock,
|
||||||
ListenerRegistrar,
|
ListenerRegistrar,
|
||||||
@@ -24,6 +25,7 @@ import type {
|
|||||||
import { parseCmmn } from '../domain/cmmn-parse.ts';
|
import { parseCmmn } from '../domain/cmmn-parse.ts';
|
||||||
import type { CaseModel } from '../domain/cmmn-parse.ts';
|
import type { CaseModel } from '../domain/cmmn-parse.ts';
|
||||||
import type { CaseRuntime } from '../domain/lifecycle.ts';
|
import type { CaseRuntime } from '../domain/lifecycle.ts';
|
||||||
|
import { createAutoRunner, recipeIssuesCard } from './auto-runner.ts';
|
||||||
import {
|
import {
|
||||||
achieveMilestones,
|
achieveMilestones,
|
||||||
autoCompleteReady,
|
autoCompleteReady,
|
||||||
@@ -38,6 +40,7 @@ import {
|
|||||||
transitionItem,
|
transitionItem,
|
||||||
} from '../domain/lifecycle.ts';
|
} from '../domain/lifecycle.ts';
|
||||||
import { evaluateTable, evaluationOrder } from '../domain/dmn.ts';
|
import { evaluateTable, evaluationOrder } from '../domain/dmn.ts';
|
||||||
|
import { liveStepOf } from '../domain/live-step.ts';
|
||||||
import { phaseProgress } from '../domain/phase.ts';
|
import { phaseProgress } from '../domain/phase.ts';
|
||||||
|
|
||||||
export type TurnRunnerDeps = {
|
export type TurnRunnerDeps = {
|
||||||
@@ -53,6 +56,7 @@ export type TurnRunnerDeps = {
|
|||||||
readonly skills: SkillGateway;
|
readonly skills: SkillGateway;
|
||||||
readonly tasks: TaskContext;
|
readonly tasks: TaskContext;
|
||||||
readonly clock: Clock;
|
readonly clock: Clock;
|
||||||
|
readonly analyze: AnalyzeGateway;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -153,6 +157,15 @@ const REDO_MARK = '验收被打回重做';
|
|||||||
* (模型 submit 那条是「行动被拒:…」,判齐快道那条是「提交放行被拒:…」)。
|
* (模型 submit 那条是「行动被拒:…」,判齐快道那条是「提交放行被拒:…」)。
|
||||||
*/
|
*/
|
||||||
const REFUSE_MARK = '打回之后台账一个字没改';
|
const REFUSE_MARK = '打回之后台账一个字没改';
|
||||||
|
/** 配方办不下去那张兜底卡的题头 —— 认它靠这个前缀,不靠措辞 */
|
||||||
|
const AUTO_FAIL_MARK = '配方办不下去';
|
||||||
|
/**
|
||||||
|
* 「提交放行被拒」那条痕的抬头(2026-08-30 终审 I4 补的常量,行为不是新写的 ——
|
||||||
|
* 这句话原来就是硬编码在两处:`autoCompleteReady` 判齐快道 / `auto.submit` 那一支,
|
||||||
|
* 见 :156 那条既有注释「判齐快道那条是『提交放行被拒:…』」)。命名它是为了下面
|
||||||
|
* `auto.submit` 那一支新增的「同一堵墙撞第二次就交人」能扫痕认出「这是不是同一句话」。
|
||||||
|
*/
|
||||||
|
const AUTO_SUBMIT_REFUSE_MARK = '提交放行被拒';
|
||||||
|
|
||||||
export function toolInputHash(input: unknown): string {
|
export function toolInputHash(input: unknown): string {
|
||||||
const text = JSON.stringify(input) ?? '';
|
const text = JSON.stringify(input) ?? '';
|
||||||
@@ -311,9 +324,9 @@ export function resendVerdict(
|
|||||||
* 只在「最后一次卡的往来是回传」时摆(后面又发了别的卡就说明有新问题在等,不摆);
|
* 只在「最后一次卡的往来是回传」时摆(后面又发了别的卡就说明有新问题在等,不摆);
|
||||||
* 决策卡摆出选中的值,执行工单如实说「空答复 = 照原样确认」。
|
* 决策卡摆出选中的值,执行工单如实说「空答复 = 照原样确认」。
|
||||||
*/
|
*/
|
||||||
export function answeredCardFact(
|
export function lastCardAnswer(
|
||||||
window: ReadonlyArray<{ readonly kind: string; readonly title: string; readonly detail?: Record<string, unknown> | undefined }>,
|
window: ReadonlyArray<{ readonly kind: string; readonly title: string; readonly detail?: Record<string, unknown> | undefined }>,
|
||||||
): string {
|
): { readonly title: string; readonly chose: string | undefined } | undefined {
|
||||||
let at = -1;
|
let at = -1;
|
||||||
for (let i = window.length - 1; i >= 0; i -= 1) {
|
for (let i = window.length - 1; i >= 0; i -= 1) {
|
||||||
if (window[i]?.kind === 'card-reply') {
|
if (window[i]?.kind === 'card-reply') {
|
||||||
@@ -322,15 +335,40 @@ export function answeredCardFact(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const reply = at < 0 ? undefined : window[at];
|
const reply = at < 0 ? undefined : window[at];
|
||||||
if (reply === undefined) return '';
|
if (reply === undefined) return undefined;
|
||||||
const newer = window.slice(at + 1).some((t) => t.kind === 'card-sent' && !sameQuestion(t.title, reply.title));
|
/* 后面又发了别的卡 = 有新问题在等,这条回传已经翻篇 */
|
||||||
if (newer) return '';
|
if (window.slice(at + 1).some((t) => t.kind === 'card-sent' && !sameQuestion(t.title, reply.title))) return undefined;
|
||||||
const value = reply.detail?.['value'];
|
const value = reply.detail?.['value'];
|
||||||
const chose = typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
|
return { title: reply.title, chose: typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined };
|
||||||
const head = `【这张卡人已经答过了】「${reply.title.length <= 60 ? reply.title : `${reply.title.slice(0, 60)}…`}」`;
|
}
|
||||||
return chose === undefined
|
|
||||||
|
/**
|
||||||
|
* **人的这条答复算不算「这一步办完了」**(2026-08-31 用户实测:「我重做后怎么直接推进了?
|
||||||
|
* 不应该是重做刚才那一步吗?」)。
|
||||||
|
*
|
||||||
|
* 病:「卡答过了就收步」那条近道(全 auto 的步、人答完就收)**不看人答的是什么** ——
|
||||||
|
* 审核卡上点「重做」并写了意见,与点「确认通过」在它眼里一模一样:当场收掉审核那一步、
|
||||||
|
* 发出落账放行的验收卡。意见倒是落进了轨迹,可再没有任何东西会去读它。
|
||||||
|
*
|
||||||
|
* 平台只判它知道的那两件(选项值的语义平台不知道 —— 见 `chosenGuidance`):
|
||||||
|
* · `custom:` 开头 = 他没选任何一个选项、写的是自己的话 —— 「重做」那一钮交上来的正是它;
|
||||||
|
* · `reject` = 验收面孔约定的驳回项(`review-face.ts` 按这个值找那一钮)。
|
||||||
|
* 空答复照旧算**照原样确认**(既有口径,见下面 `answeredCardFact` 那句话)。
|
||||||
|
*/
|
||||||
|
export function answerSettlesStep(chose: string | undefined): boolean {
|
||||||
|
const value = chose?.trim() ?? '';
|
||||||
|
return value !== 'reject' && !value.startsWith('custom:');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function answeredCardFact(
|
||||||
|
window: ReadonlyArray<{ readonly kind: string; readonly title: string; readonly detail?: Record<string, unknown> | undefined }>,
|
||||||
|
): string {
|
||||||
|
const answer = lastCardAnswer(window);
|
||||||
|
if (answer === undefined) return '';
|
||||||
|
const head = `【这张卡人已经答过了】「${answer.title.length <= 60 ? answer.title : `${answer.title.slice(0, 60)}…`}」`;
|
||||||
|
return answer.chose === undefined
|
||||||
? `${head} → 人已交回(**空答复 = 照原样确认**,不是没答)。别再发一张一样的卡 —— 照说明书里「人交回之后」那一段办,办完 finish 这一项。`
|
? `${head} → 人已交回(**空答复 = 照原样确认**,不是没答)。别再发一张一样的卡 —— 照说明书里「人交回之后」那一段办,办完 finish 这一项。`
|
||||||
: `${head} → ${chosenGuidance(chose)}`;
|
: `${head} → ${chosenGuidance(answer.chose)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -424,6 +462,14 @@ const STALL_LIMIT = 5;
|
|||||||
const STALL_WINDOW = 60;
|
const STALL_WINDOW = 60;
|
||||||
|
|
||||||
export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||||
|
const autoRunner = createAutoRunner({
|
||||||
|
data: deps.data,
|
||||||
|
cards: deps.cards,
|
||||||
|
memory: deps.memory,
|
||||||
|
analyze: deps.analyze,
|
||||||
|
sfeel: deps.sfeel,
|
||||||
|
});
|
||||||
|
|
||||||
async function loaded(taskId: TaskId) {
|
async function loaded(taskId: TaskId) {
|
||||||
const found = await deps.state.load(taskId);
|
const found = await deps.state.load(taskId);
|
||||||
if (found === undefined) throw new Error(`loop 未装载:${taskId}`);
|
if (found === undefined) throw new Error(`loop 未装载:${taskId}`);
|
||||||
@@ -656,6 +702,11 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
* **这一步派的卡穿哪副面孔**(剧本 `cardVariant`,2026-08-27):按行动里的项号查,项号没给 / 挂不上就按
|
* **这一步派的卡穿哪副面孔**(剧本 `cardVariant`,2026-08-27):按行动里的项号查,项号没给 / 挂不上就按
|
||||||
* 当下在办的那一步 —— 从前只按项号查,模型漏了 itemId,审核卡就退成「需要你定」。
|
* 当下在办的那一步 —— 从前只按项号查,模型漏了 itemId,审核卡就退成「需要你定」。
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 这张台账「归人填」吗 —— 归人填就返回拒绝理由(剧本 `humanWrites`,见 contracts)。
|
||||||
|
* 只拦 `write`:`drop` 得留着,不然模型写错了撤不掉。
|
||||||
|
*/
|
||||||
|
humanWritesOf?: (itemId: string | undefined, address: string) => string | undefined,
|
||||||
faceOf?: (itemId: string | undefined) => { variant?: 'review'; checklist?: ReadonlyArray<string>; release?: true } | undefined,
|
faceOf?: (itemId: string | undefined) => { variant?: 'review'; checklist?: ReadonlyArray<string>; release?: true } | undefined,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
runtime: CaseRuntime;
|
runtime: CaseRuntime;
|
||||||
@@ -920,6 +971,14 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
const domain = action.address.slice(0, at);
|
const domain = action.address.slice(0, at);
|
||||||
const object = action.address.slice(at + 1);
|
const object = action.address.slice(at + 1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **人填的表,模型不许写**(2026-08-28,用户送来的实测单):数据工单要人在件上补的那几张表,
|
||||||
|
* 值只可能从人那儿来。实测模型把台账里原 PN 那一行的规格书逐字抄到了候选料号头上 ——
|
||||||
|
* 它不是凭空编,是「手边有一行看着对的就拿来用」。墙立在这儿,路在拒回里(去发那张卡)。
|
||||||
|
*/
|
||||||
|
const humanOnly = humanWritesOf?.(action.itemId, action.address);
|
||||||
|
if (humanOnly !== undefined) throw new Error(humanOnly);
|
||||||
|
|
||||||
// 挂项(尽力):阶段不挂(容器)、挂不上不拦 —— 理由随留痕带出,模型下一拍自己修记账
|
// 挂项(尽力):阶段不挂(容器)、挂不上不拦 —— 理由随留痕带出,模型下一拍自己修记账
|
||||||
const { parsed: model } = await loaded(taskId);
|
const { parsed: model } = await loaded(taskId);
|
||||||
const target = model.items.find((i) => i.id === action.itemId);
|
const target = model.items.find((i) => i.id === action.itemId);
|
||||||
@@ -1508,7 +1567,28 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
for (const [at, entry] of window.entries()) if (entry.title.startsWith(REDO_MARK)) lastRedo = at;
|
for (const [at, entry] of window.entries()) if (entry.title.startsWith(REDO_MARK)) lastRedo = at;
|
||||||
if (lastRedo >= 0) {
|
if (lastRedo >= 0) {
|
||||||
const after = window.slice(lastRedo + 1);
|
const after = window.slice(lastRedo + 1);
|
||||||
const revised = after.some((e) => e.kind === 'action' && typeof e.detail?.['ledger'] === 'string');
|
/*
|
||||||
|
* **配方写下的那一笔也算「改过台账」**(2026-08-29 终审 C3)。
|
||||||
|
* `kind: 'action'` 只有模型的 write / drop 才写;配方的写留痕是
|
||||||
|
* `kind: 'observation'` + `detail.auto` + `detail.ledger`(见 auto-runner 的 project 分支)。
|
||||||
|
* 只认 action 的话,一个全 auto 的放行步在被打回之后**改了台账这道闸也看不见** ——
|
||||||
|
* 每一拍都抛,而下面那条软化路(`refusedBefore`)靠的是模型路径 catch 里写的痕,
|
||||||
|
* 配方路径从前没有 catch,那条痕永远不写:**这堵墙对配方是永远开不了的**。
|
||||||
|
*
|
||||||
|
* **配方那一路要求真写了或真撤了**(2026-08-29 复审 M1/M2 二审):`project` 动作里
|
||||||
|
* `localExisting`(已在库、这一笔不用建)与 `localUnkeyed`(主键算不出来没写)也会
|
||||||
|
* 留痕(M1/M2 立的「一行没写也要出声」),两者台账其实一个字没动。只看
|
||||||
|
* `detail.auto` + `detail.ledger` 这两个键在的话,一颗料 skipExisting、或一行主键
|
||||||
|
* 算不出来,这条痕就照样解锁 —— 人打回重做,配方原样重跑,闸却开了(正是这道闸
|
||||||
|
* 要拦的空转重提)。auto-runner 现在把「真改过」摆成 `detail.changed`,这里照它认;
|
||||||
|
* `kind:'action'` 那一支不必看这个键 —— 模型的 write/drop 一行没变就直接拒(见
|
||||||
|
* :980/:1075),能落成 `kind:'action'` 的一定是真改过的。
|
||||||
|
*/
|
||||||
|
const revised = after.some((e) => {
|
||||||
|
if (typeof e.detail?.['ledger'] !== 'string') return false;
|
||||||
|
if (e.kind === 'action') return true;
|
||||||
|
return typeof e.detail?.['auto'] === 'string' && e.detail?.['changed'] === true;
|
||||||
|
});
|
||||||
const refusedBefore = after.some((e) => e.kind === 'result' && e.title.includes(REFUSE_MARK));
|
const refusedBefore = after.some((e) => e.kind === 'result' && e.title.includes(REFUSE_MARK));
|
||||||
if (!revised && !refusedBefore) {
|
if (!revised && !refusedBefore) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -1668,6 +1748,49 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
*/
|
*/
|
||||||
const waiting = await deps.cards.unanswered(taskId);
|
const waiting = await deps.cards.unanswered(taskId);
|
||||||
if (waiting.length > 0) {
|
if (waiting.length > 0) {
|
||||||
|
/*
|
||||||
|
* **有卡在等人,配方照样跑 —— 只算、只写**(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],
|
||||||
|
);
|
||||||
|
let quietWrote = false;
|
||||||
|
if (quietStep?.auto !== undefined) {
|
||||||
|
const outcome = 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,
|
||||||
|
});
|
||||||
|
quietWrote = outcome.wrote > 0 || outcome.dropped > 0;
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* **这一拍真写下过东西,收拍前把画面重推一遍**(2026-08-30,W05 数据工单浏览器走查
|
||||||
|
* 实测:人给候选料选好芯片类型、按保存,上面的配方铺出 7 行新参数、红带也点名要填
|
||||||
|
* 这 7 格,而这条路从不推帧 —— 任务停在 `awaiting-event`,人刷新页面看到的仍是保存前
|
||||||
|
* 那份快照(件是服务端渲好推来的快照,重放不重渲),被点名的格子屏上根本不存在,
|
||||||
|
* 走查到此卡死。**这极可能与 e2e 反复撞见的「答完卡不推进」异常同一个短路**:
|
||||||
|
* 数据工单的下一轮填报要看这一步刚铺出来的新字段,画面不重推,人 / 脚本都无从下手,
|
||||||
|
* 之后再没有任何事件会把它唤醒。
|
||||||
|
*
|
||||||
|
* 走到这儿状态机什么都没变(quiet 只跑 project/say,不迁移计划项),`runtime` 仍是
|
||||||
|
* 拍首那份,画面按 `quietStep` 声明的那张推就够 —— 与「拍首跑配方」发卡那条同一份
|
||||||
|
* 判断(下面 `issuedCardId` 那处:「这一步声明了画面才推,没声明就沿用上一帧」)。
|
||||||
|
*/
|
||||||
|
if (quietWrote && quietStep?.view !== undefined && quietStep.view !== '') {
|
||||||
|
try {
|
||||||
|
await showView(taskId, quietStep.view, runtime, quietStep.view);
|
||||||
|
} catch {
|
||||||
|
// 呈现失败不影响回路:画面丢一帧,事实一件不少(与末尾推帧同一条纪律)
|
||||||
|
}
|
||||||
|
}
|
||||||
await deps.memory.append({
|
await deps.memory.append({
|
||||||
taskId,
|
taskId,
|
||||||
kind: 'observation',
|
kind: 'observation',
|
||||||
@@ -1909,14 +2032,24 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
* 都把 S1 当成当前这一步:喂给模型的做法是 S1 的、推给人的画面也是 S1 的。
|
* 都把 S1 当成当前这一步:喂给模型的做法是 S1 的、推给人的画面也是 S1 的。
|
||||||
* 实测:跑到判定去向了,工作区还停在阶段二那份询价受理需求。
|
* 实测:跑到判定去向了,工作区还停在阶段二那份询价受理需求。
|
||||||
*/
|
*/
|
||||||
|
/*
|
||||||
|
* **取 sweep 之后的那份局面**(2026-08-29 终审 I6)。从前取的是 `runtime`(sweep 之前),
|
||||||
|
* 而 `liveStepOf` 吃的是 sweep 之后的 `stateEnabled` —— 这一拍刚被 `sweepRipe` 收掉的那一步
|
||||||
|
* 不在 `settledIds` 里,照旧被当成「还没干完的那一步」。从前这只影响提示词内容;
|
||||||
|
* 配方接进来之后它驱动**副作用**(再写一遍台账、再 prune 一遍、甚至再发一张卡)。
|
||||||
|
*/
|
||||||
const settledIds = new Set(
|
const settledIds = new Set(
|
||||||
runtime.items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id),
|
stateRuntime.items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id),
|
||||||
);
|
);
|
||||||
/** 还没干完的那一步 —— 「这一单此刻在办什么」的唯一诚实答案;都干完了就是 undefined */
|
/** 还没干完的那一步 —— 「这一单此刻在办什么」的唯一诚实答案;都干完了就是 undefined */
|
||||||
const liveStep = [...stateEnabled.inProgress, ...stateEnabled.entries]
|
const liveStep = liveStepOf(steps, settledIds, [...stateEnabled.inProgress, ...stateEnabled.entries]);
|
||||||
.filter((e) => !settledIds.has(e.id))
|
/**
|
||||||
.map((e) => stepOf(e.id))
|
* 认不出在办哪一步时退回「在场的第一个有剧本的项」—— **这个兜底只服务提示词**
|
||||||
.find((x) => x !== undefined);
|
* (给模型一份做法总比整本说明书强),**不驱动配方**(2026-08-29 终审 I6)。
|
||||||
|
* 带 `repetitionRule` 的项收掉之后仍是 Enabled,于是活步都收完的那一拍这里会退回一个
|
||||||
|
* **已经 Completed 的步** —— 让它的配方再跑一遍就是再写一遍台账、再 prune 一遍,
|
||||||
|
* carded 为假时甚至再发一张卡。提示词读错一段无非白费一点上下文,副作用可不是。
|
||||||
|
*/
|
||||||
const current =
|
const current =
|
||||||
liveStep ??
|
liveStep ??
|
||||||
[...stateEnabled.inProgress, ...stateEnabled.entries].map((e) => stepOf(e.id)).find((x) => x !== undefined);
|
[...stateEnabled.inProgress, ...stateEnabled.entries].map((e) => stepOf(e.id)).find((x) => x !== undefined);
|
||||||
@@ -1929,11 +2062,322 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
* worldTouched(见 lifecycle.ts)在引擎这一侧本来就是现成的布尔,直接摆成一句
|
* worldTouched(见 lifecycle.ts)在引擎这一侧本来就是现成的布尔,直接摆成一句
|
||||||
* 事实,模型只需要读,不需要从一堆轨迹文本里自己判断「这条回复是不是答的是这一项」。
|
* 事实,模型只需要读,不需要从一堆轨迹文本里自己判断「这条回复是不是答的是这一项」。
|
||||||
*/
|
*/
|
||||||
|
/*
|
||||||
|
* **配方段**(spec §5.8 ③④⑤):当前这一步声明了 `auto` 就先把确定的事做完。
|
||||||
|
* · 发了卡 / submit → 这一拍到此为止(零 token,不问模型);
|
||||||
|
* · 没发卡且这一步没有 `guide` → 重求 doneWhen 收步(下面的 sweepRipe 会做);
|
||||||
|
* · 有 `guide` → 照常往下问模型(半 auto)。
|
||||||
|
* 出错 → 留痕交人,不重试、不退回模型。
|
||||||
|
*/
|
||||||
|
if (liveStep !== undefined && liveStep.auto !== undefined) {
|
||||||
|
/* 配方**只认 `liveStep`** —— 见上面 `current` 兜底那条注释(I6) */
|
||||||
|
const current = liveStep;
|
||||||
|
const recipe = liveStep.auto;
|
||||||
|
/*
|
||||||
|
* **被打回就重开一轮**(2026-08-31 用户实测:「我重做后怎么直接推进了?」)。
|
||||||
|
*
|
||||||
|
* 「发过就不再发」的幂等挡的是**同一问被重复发出去**;而人点「重做」正是说
|
||||||
|
* 「这一轮不算,按我的意见改了再问我一遍」—— 那不是重复,是下一轮。
|
||||||
|
* 于是这一拍的 `carded` 让位:配方重新跑一遍(该重算的重算),再把卡发出去。
|
||||||
|
* 收步那条近道下面也认同一件事 —— 两处共用 `answerSettlesStep`,一处真源。
|
||||||
|
*/
|
||||||
|
const answer = lastCardAnswer(trail);
|
||||||
|
const settled = answerSettlesStep(answer?.chose);
|
||||||
|
const touchedItem = stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true;
|
||||||
|
if (touchedItem && !settled) {
|
||||||
|
await deps.memory.append({
|
||||||
|
taskId,
|
||||||
|
kind: 'observation',
|
||||||
|
title: `人把这一步打回重做${answer?.chose?.startsWith('custom:') === true ? `:${answer.chose.slice(7).trim()}` : ''}`,
|
||||||
|
detail: {
|
||||||
|
item: current.item,
|
||||||
|
hint: '这一步不算办完 —— 按他的意见重算之后再问他一遍(卡会重新发一张,不是同一张的复读)。',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const auto = await autoRunner.run(recipe, {
|
||||||
|
taskId,
|
||||||
|
stepId: current.item,
|
||||||
|
ownerId: (await deps.tasks.of(taskId)).ownerId,
|
||||||
|
humanCallCap: (await deps.tasks.of(taskId)).humanCallCap,
|
||||||
|
quiet: false,
|
||||||
|
carded: touchedItem && settled,
|
||||||
|
/* 打回那句话交给配方(`case_redo_note`)—— 活动据它决定这一轮要不要把意见交回给 AI 重跑 */
|
||||||
|
...(settled || answer?.chose === undefined ? {} : { redoNote: answer.chose.startsWith('custom:') ? answer.chose.slice(7).trim() : answer.chose }),
|
||||||
|
});
|
||||||
|
if (auto.failedAt !== undefined) {
|
||||||
|
/*
|
||||||
|
* **配方办不下去 = 交人,而「交人」得真有一张卡**(2026-08-29 终审 I1)。
|
||||||
|
*
|
||||||
|
* 从前这里只 `status: 'awaiting'` 就走了 —— 映射成 `awaiting-event`,而推进器只捡
|
||||||
|
* `running` 的单:卡已答完、没立监听、没有时限,**再没有任何东西会叫醒它**。
|
||||||
|
* 人在工作台上看到的是「等外部回音」,而外面没有任何回音会来 —— 界面撒了谎。
|
||||||
|
* 照熔断兜底卡的成例:发一张卡把办不下去的那一笔原话摆给负责人,立人事件监听。
|
||||||
|
* 走到这儿时这一单**没有**未答的卡(runTurn 开头 `cards.unanswered` 把住了),
|
||||||
|
* 所以不会堆卡;人答完之后若仍失败,再发一张 —— 那是实情,由调人预算兜底。
|
||||||
|
*
|
||||||
|
* **这一句「由调人预算兜底」本身就是引爆点**(2026-08-29 复审 I1 二审):
|
||||||
|
* `deps.cards.issue` 走的是 `human-tool-service.assertBudget`,调人预算一旦触顶
|
||||||
|
* 就抛 `HumanCallBudgetExceededError` —— 而这一段从前是**裸着**调的,一抛就精确
|
||||||
|
* 复现 C3 那种无限热循环(`engine.advance` / `task-service.tick` 都不捕获,
|
||||||
|
* `task-advancer` 只写 stderr 就 break,任务停在 `running`、界面纹丝不动、每 2 秒重来)。
|
||||||
|
* 与 :2073(C3 那处)同款收场:抛了不能让回合崩,退回旧行为(留痕 + `awaiting`)
|
||||||
|
* 总好过热循环。
|
||||||
|
*/
|
||||||
|
const stuck = await deps.tasks.of(taskId);
|
||||||
|
const failed = auto.failedAt;
|
||||||
|
try {
|
||||||
|
const card = await deps.cards.issue({
|
||||||
|
taskId,
|
||||||
|
ownerId: stuck.ownerId,
|
||||||
|
humanCallCap: stuck.humanCallCap,
|
||||||
|
workOrder: 'execution',
|
||||||
|
card: {
|
||||||
|
title: `${AUTO_FAIL_MARK}:「${current.item}」第 ${failed.index + 1} 笔`,
|
||||||
|
ask:
|
||||||
|
'这一步由系统按配方确定性执行,它停在了下面这一笔上 —— 不重试、不退回模型(那正是这套机制要消灭的东西)。' +
|
||||||
|
'看原因判断:是数据还差点什么(补齐了这一单就能接着走),还是活动包的配方写错了(去设计台改完重跑)。',
|
||||||
|
basis: [
|
||||||
|
{ k: '这一步', v: current.item },
|
||||||
|
{ k: '第几笔', v: `${failed.index + 1}` },
|
||||||
|
{ k: '办不下去的原因', v: failed.message },
|
||||||
|
],
|
||||||
|
fields: ['decision'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await deps.listeners.register({ taskId, eventType: 'human', keyFilter: { cardId: card.id }, deadline: 'P3D' });
|
||||||
|
/*
|
||||||
|
* 这里**不再补一句交代**:办不下去的那句话 auto-runner 已经落成系统观察进了对话区
|
||||||
|
* (「配方第 N 笔办不下去:… —— 交给你处理」),发卡本身也落 `card-sent`。
|
||||||
|
* 再写一条 narration 就是同一件事说三遍,而且 narration 是 AI 蓝 ——
|
||||||
|
* 停下来的是系统按配方走不下去,不是 AI 判断如此(spec §5.9:配方那一笔归系统灰)。
|
||||||
|
*/
|
||||||
|
} catch (error) {
|
||||||
|
/* 兜底卡本身发不出去 —— 人要知道为什么停了,痕里把两层原因都摆出来 */
|
||||||
|
const reason = error instanceof Error ? error.message : String(error);
|
||||||
|
await deps.memory.append({
|
||||||
|
taskId,
|
||||||
|
kind: 'result',
|
||||||
|
title: `配方办不下去,连兜底卡都没能发出去:${reason}`,
|
||||||
|
detail: {
|
||||||
|
auto: current.item,
|
||||||
|
hint:
|
||||||
|
`本该交给人的那句话:「第 ${failed.index + 1} 笔 —— ${failed.message}」。` +
|
||||||
|
'发不出去多半是调人预算已经触顶(去治理台把这一单的调人预算提上去),' +
|
||||||
|
'也可能是活动包配方写错了(去设计台改完重跑)。',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
runtime: stateRuntime,
|
||||||
|
enabled: stateEnabled,
|
||||||
|
navigation: { kind: 'breakpoint', gaps: [...stateEnabled.gaps] },
|
||||||
|
status: 'awaiting',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (auto.submit !== undefined) {
|
||||||
|
/*
|
||||||
|
* **抛了不能让回合崩**(2026-08-29 终审 C3)—— 与 :1738 模型判齐那处同一条理由,
|
||||||
|
* 那儿的注释白纸黑字写着:「推进器会一遍遍重来,每遍再撞一次同一道门」。
|
||||||
|
* 而这一处从前是**裸着**的:`engine.advance` 不捕获、`task-service.tick` 也不捕获
|
||||||
|
* 且 `addConsumption` 排在 advance 之后(一步都不记)、`task-advancer` 只写 stderr 就 break,
|
||||||
|
* 任务留在 `running` —— 下一个 interval 再来一遍:**无限热循环,不烧预算、不换状态、界面纹丝不动**。
|
||||||
|
* 处置与模型路径一致:拒绝 + 留痕(那条痕同时是重做闸的解锁凭据)+ 这一拍到此为止。
|
||||||
|
*/
|
||||||
|
try {
|
||||||
|
const raised = await raiseAcceptance(taskId, stateRuntime, auto.submit.rationale);
|
||||||
|
return { ...raised, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' };
|
||||||
|
} catch (error) {
|
||||||
|
const reason = error instanceof Error ? error.message : String(error);
|
||||||
|
/*
|
||||||
|
* **同一堵墙撞第二次就交人,别空转到 exhausted**(2026-08-30 终审 I4)。
|
||||||
|
*
|
||||||
|
* 这一支从没有任何模型会读到「提交放行被拒」这句话:全 auto 的步
|
||||||
|
* (`current.guide.trim() === ''`)压根不问模型;半 auto 的步即便有 `guide`,
|
||||||
|
* `auto.submit !== undefined` 也会在问模型**之前**直接 `return`(见上面这个
|
||||||
|
* `if` 分支所在的位置——它在"半 auto 落回问模型"那段代码之前)。而失败的原因
|
||||||
|
* (`acceptanceCriteria` 里未过的机检/裁量)在**这一步自己**这一层无法自愈:
|
||||||
|
* 台账已经不会再变(往前一步的项都已收掉,没有活的编辑面),下一拍重跑同一份
|
||||||
|
* 配方只会拿到同一份数据、算出同一个 `rationale`、撞回同一堵墙 —— 纯粹烧步数预算,
|
||||||
|
* 直到 `caps.steps` 耗尽才停(task-service 的 `markProgress` 每拍都记,
|
||||||
|
* `stallAfterMs` 那道熔断因此永远不会提前触发)。
|
||||||
|
*
|
||||||
|
* **头一次只留痕,当一次软化**(与「重发已答过的卡」「同一份入参重调」同族的
|
||||||
|
* 软硬两段):万一真有什么会在两拍之间变(如另一进程写了同一份台账),给它一次
|
||||||
|
* 机会。**同一句话第二次出现**(靠 `AUTO_SUBMIT_REFUSE_MARK` 前缀 + 原话逐字比对,
|
||||||
|
* 不是靠拍数计数——串位/措辞抖动过一次就会被误判成"不同的墙")就当死墙:
|
||||||
|
* 发一张兜底卡把原话摆给负责人、立监听,照 `auto.failedAt` 那条(上面)同一个模式——
|
||||||
|
* 两者都是「配方自己走不下去,得请人来看」,卡的形状理应一致。
|
||||||
|
*/
|
||||||
|
const already = (await deps.memory.window(taskId, STALL_WINDOW)).some(
|
||||||
|
(t) => t.kind === 'result' && t.title === `${AUTO_SUBMIT_REFUSE_MARK}:${reason}`,
|
||||||
|
);
|
||||||
|
await deps.memory.append({
|
||||||
|
taskId,
|
||||||
|
kind: 'result',
|
||||||
|
title: `${AUTO_SUBMIT_REFUSE_MARK}:${reason}`,
|
||||||
|
detail: { auto: current.item, hint: '配方的 submit 没过落地预检 —— 按理由补齐必交付物或修正台账,下一拍再提交' },
|
||||||
|
});
|
||||||
|
if (!already) {
|
||||||
|
return { runtime: stateRuntime, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'running' };
|
||||||
|
}
|
||||||
|
const stuck = await deps.tasks.of(taskId);
|
||||||
|
try {
|
||||||
|
const card = await deps.cards.issue({
|
||||||
|
taskId,
|
||||||
|
ownerId: stuck.ownerId,
|
||||||
|
humanCallCap: stuck.humanCallCap,
|
||||||
|
workOrder: 'execution',
|
||||||
|
card: {
|
||||||
|
title: `${AUTO_FAIL_MARK}:「${current.item}」提交放行连续撞在同一条判据上`,
|
||||||
|
ask:
|
||||||
|
'这一步由系统按配方确定性提交,连续两拍撞在同一条判据上 —— 这一步没有模型会读这条拒回、' +
|
||||||
|
'也没有可编辑的画面能让你就地改(台账在更早一步就已经收口)。看下面的原话判断该怎么办:' +
|
||||||
|
'数据确实还缺什么就去别处把它补齐(这一单仍开着,补完下一拍会自动重试);' +
|
||||||
|
'判据或配方本身有问题就找开发改完重跑。',
|
||||||
|
basis: [
|
||||||
|
{ k: '这一步', v: current.item },
|
||||||
|
{ k: '拒回原话', v: reason },
|
||||||
|
],
|
||||||
|
fields: ['decision'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await deps.listeners.register({ taskId, eventType: 'human', keyFilter: { cardId: card.id }, deadline: 'P3D' });
|
||||||
|
} catch (issueError) {
|
||||||
|
const issueReason = issueError instanceof Error ? issueError.message : String(issueError);
|
||||||
|
await deps.memory.append({
|
||||||
|
taskId,
|
||||||
|
kind: 'result',
|
||||||
|
title: `提交放行连续被拒,连兜底卡都没能发出去:${issueReason}`,
|
||||||
|
detail: {
|
||||||
|
auto: current.item,
|
||||||
|
hint: `本该交给人的那句话:「${reason}」。发不出去多半是调人预算已经触顶(去治理台把这一单的调人预算提上去)。`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
runtime: stateRuntime,
|
||||||
|
enabled: stateEnabled,
|
||||||
|
navigation: { kind: 'breakpoint', gaps: [...stateEnabled.gaps] },
|
||||||
|
status: 'awaiting',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (auto.issuedCardId !== undefined) {
|
||||||
|
/* 卡发出去了 = 这一步真动过世界 —— 标 worldTouched,自动收步那道门才诚实 */
|
||||||
|
const touched = markWorldTouched(stateRuntime, current.item);
|
||||||
|
await deps.state.save(taskId, model, parsed, touched);
|
||||||
|
/* 这一步声明了画面才推 —— `showView` 的第二参不可选,没声明就沿用上一帧(与既有行为一致) */
|
||||||
|
if (current.view !== undefined && current.view !== '') {
|
||||||
|
await showView(taskId, current.view, touched, current.view);
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* **人答得比这一拍收尾还快,就别报「等人」**(2026-09-01 实测两次撞停,第二次卡了一整例:
|
||||||
|
* 卡 02:06:13.250 发出、**219ms 后**就被答了,而这一拍 .636 才收尾)。
|
||||||
|
*
|
||||||
|
* 为什么会没人叫醒:**决策卡的回传不发事件,只调 `wake`** —— 而 `wake` 只翻停着的单子,
|
||||||
|
* 那一刻这一拍还在 `running`,它空转了一次;随后这一拍把任务 park 回 `awaiting-event`,
|
||||||
|
* 再没有第三样东西会来叫(工单回传那一路还有人事件兜底,决策卡这一路一样都没有)。
|
||||||
|
*
|
||||||
|
* 所以收尾前再看一眼**刚发的这张卡还在不在等**:已经答了就照实说「还在跑」,
|
||||||
|
* 推进器下一轮接着处理那份答复。**只认这一张卡** —— 别的「等外部回音」照旧是等,
|
||||||
|
* 不会因此空转成热循环。
|
||||||
|
*/
|
||||||
|
const stillPending = await deps.cards.unanswered(taskId);
|
||||||
|
const answeredAlready = !stillPending.some((c) => c.id === auto.issuedCardId);
|
||||||
|
if (answeredAlready) {
|
||||||
|
await deps.memory.append({
|
||||||
|
taskId,
|
||||||
|
kind: 'observation',
|
||||||
|
title: '这张卡刚发出去就被答了 —— 这一拍不算等人,接着跑',
|
||||||
|
detail: { cardId: auto.issuedCardId, item: current.item, internal: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { runtime: touched, enabled: stateEnabled, navigation: { kind: 'converged' }, status: answeredAlready ? 'running' : '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);
|
||||||
|
/* `Navigation.kind` 没有 'progressed' 这一档(只有 proceed/breakpoint/converged)——
|
||||||
|
这一拍到此为止、下一拍重新导航,与「判齐失败仍要重来」那处(1749 行)同一种写法 */
|
||||||
|
return { runtime: reswept.runtime, enabled: reswept.enabled, navigation: { kind: 'converged' }, status: 'running' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* **卡答过了就收步**(spec §5.5)。走到这儿的四个前提都成立:
|
||||||
|
* 这一步声明了 auto、卡发过(worldTouched)、这一拍没有未答的卡(前面 unanswered 把住)、
|
||||||
|
* 配方这一拍也没再发新卡。**人已经签了字,收步是照实记录,不是替人签字** ——
|
||||||
|
* 「人工激活项不给 doneWhen」那条纪律防的是引擎抢在人前面,这里人在前面。
|
||||||
|
*
|
||||||
|
* **半 auto(声明了非空 `guide`)的步不走这条近道**(R9,评审 Minor #1 逼出来的裁定):
|
||||||
|
* 半 auto 存在的意义就是「确定的先做完,剩下的判断仍问模型」—— 人答完卡之后那份判断
|
||||||
|
* (读人写的自由文本、落到具体的行上)恰恰是模型该干的活。收在它前面,等于把半 auto
|
||||||
|
* 的后半截整个删掉。当前没有活动踩到这个组合,但机制不该埋这个坑。
|
||||||
|
*/
|
||||||
|
const noDoneWhen = current.doneWhen === undefined || current.doneWhen.trim() === '';
|
||||||
|
const carded = stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true;
|
||||||
|
/*
|
||||||
|
* **人签了字才叫办完 —— 他说重做就不是**(2026-08-31,见 `answerSettlesStep` 的头注:
|
||||||
|
* 打回与通过从前在这儿一模一样,于是「重做」当场变成了「推进」)。
|
||||||
|
* 走到这儿说明配方这一拍没能重新发出卡(条件不成立 / 预算见底)——
|
||||||
|
* 那就停在这一步不收,下面那条「配方跑完了,可这一步还没算办完」把话说给人听,
|
||||||
|
* 而不是替他把字签了。
|
||||||
|
*/
|
||||||
|
if (noDoneWhen && carded && settled && recipeIssuesCard(recipe) && current.guide.trim() === '') {
|
||||||
|
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} —— 卡已交回,这一步到此为止`,
|
||||||
|
/*
|
||||||
|
* **不再标 internal**(2026-08-29,R10)—— 从前的 `internal: true` 会被
|
||||||
|
* `task-log.ts` 的 `machinery()` 挡下,这一笔永远进不了工作日志。
|
||||||
|
* `engine-transition` 本就在骨架里,标题也不匹配「完成/开始办」的正则,
|
||||||
|
* 只要不被 `machinery()` 滤掉,它就会正确地归系统 —— 不必藏成内部痕。
|
||||||
|
*/
|
||||||
|
detail: { item: current.item, auto: current.item },
|
||||||
|
});
|
||||||
|
await deps.state.save(taskId, model, parsed, settled);
|
||||||
|
/* `Navigation.kind` 没有 'progressed' 这一档(只有 proceed/breakpoint/converged,
|
||||||
|
见上面 2014 行那条注释)—— 这一拍到此为止、下一拍重新导航,同一种写法 */
|
||||||
|
return { runtime: settled, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'running' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.guide.trim() === '') {
|
||||||
|
/*
|
||||||
|
* 没有 guide、没发卡、doneWhen 也不成立 → 配方没做到位,是包的 bug,交人。
|
||||||
|
* **kind: 'observation'**(2026-08-29,R10 第六处)—— 从前是 `kind: 'result'`,
|
||||||
|
* 不在 `SKELETON` 里,这条**交人**的消息永远进不了工作日志,单子看着像凭空卡住,
|
||||||
|
* 而人打开日志想知道的第一件事就是「为什么停了」。只改 kind,detail 不动。
|
||||||
|
*/
|
||||||
|
await deps.memory.append({
|
||||||
|
taskId,
|
||||||
|
kind: 'observation',
|
||||||
|
title: `「${current.item}」的配方跑完了,可这一步还没算办完 —— 交给你看一眼`,
|
||||||
|
detail: { auto: current.item },
|
||||||
|
});
|
||||||
|
return { runtime: stateRuntime, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **这一步还欠一张卡** —— 标了人工激活、而这一单里还没给它发过卡 / 立过监听。
|
||||||
|
* 此刻它唯一能做的就是把那张卡发出去:别的话都不该说给它听(见下面 doneWhen 那段)。
|
||||||
|
*/
|
||||||
|
const owesCard =
|
||||||
|
current !== undefined &&
|
||||||
|
parsed.items.find((i) => i.id === current.item)?.manualActivation === true &&
|
||||||
|
stateRuntime.items.find((i) => i.id === current.item)?.worldTouched !== true;
|
||||||
const currentTouch = ((): string => {
|
const currentTouch = ((): string => {
|
||||||
if (current === undefined) return '';
|
if (current === undefined) return '';
|
||||||
const declared = parsed.items.find((i) => i.id === current.item);
|
const declared = parsed.items.find((i) => i.id === current.item);
|
||||||
if (declared?.manualActivation !== true) return '';
|
if (declared?.manualActivation !== true) return '';
|
||||||
const touched = stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true;
|
const touched = !owesCard;
|
||||||
/*
|
/*
|
||||||
* 终局放行那一步的事实行**只指向 submit**(2026-08-27 W05 实测):下面那句「唯一合法的动作是先发
|
* 终局放行那一步的事实行**只指向 submit**(2026-08-27 W05 实测):下面那句「唯一合法的动作是先发
|
||||||
* 那一张卡」本是给人工激活的执行 / 数据 / 决策步写的,放行步读到它就去发了一张执行工单。
|
* 那一张卡」本是给人工激活的执行 / 数据 / 决策步写的,放行步读到它就去发了一张执行工单。
|
||||||
@@ -1973,7 +2417,17 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
* 差的那个数摆出来,这一步就从死胡同变成一句话能办的事:
|
* 差的那个数摆出来,这一步就从死胡同变成一句话能办的事:
|
||||||
* 「missing_key_total 现在是 1,要它变成 0」。
|
* 「missing_key_total 现在是 1,要它变成 0」。
|
||||||
*/
|
*/
|
||||||
current.doneWhen === undefined
|
/*
|
||||||
|
* **还欠一张卡的那一步,不摆干完的判据**(2026-08-28,用户送来的实测单)。
|
||||||
|
*
|
||||||
|
* 判据本意是「引擎自己核,省一拍」,可它同时是**摆在模型眼前的一个目标**,
|
||||||
|
* 后面还跟着一句「成立了就 finish」—— 而人工激活的项在卡发出去之前根本不许 finish
|
||||||
|
* (上面 currentTouch 那句写着「唯一合法的动作是先发那一张卡」)。同一份提示词里两句话
|
||||||
|
* 直接打架:实测它选了判据那句,把台账里原 PN 的资料抄到候选头上去凑「必填不缺」,
|
||||||
|
* 然后 finish 连撞五拍墙、熔断。**一个闲着的模型 + 一个它够得着的判据 = 它一定会去凑。**
|
||||||
|
* 卡发出去、人答过之后判据照常摆(那时它本来就该收步)。
|
||||||
|
*/
|
||||||
|
current.doneWhen === undefined || owesCard
|
||||||
? ''
|
? ''
|
||||||
: `干完的判据(引擎会自己核):${current.doneWhen}` +
|
: `干完的判据(引擎会自己核):${current.doneWhen}` +
|
||||||
`${readingsOf(current.doneWhen, scope)} —— 成立了就 finish ${current.item},` +
|
`${readingsOf(current.doneWhen, scope)} —— 成立了就 finish ${current.item},` +
|
||||||
@@ -2201,6 +2655,21 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
index < planned.length - 1,
|
index < planned.length - 1,
|
||||||
|
/*
|
||||||
|
* 归人填的台账:**认行动点名的那一项**,点不出(或那一项没剧本)才退回当下在办的那一步。
|
||||||
|
* 与 faceOf 同一套判法。压拍时骨架写在上一步名下、填值写在这一步名下,正好各归各的。
|
||||||
|
*/
|
||||||
|
(itemId, address) => {
|
||||||
|
const named = itemId === undefined ? undefined : stepOf(itemId);
|
||||||
|
const step = named ?? current;
|
||||||
|
if (step === undefined || !(step.humanWrites ?? []).includes(address)) return undefined;
|
||||||
|
return (
|
||||||
|
`${address} 这张表归人填 —— 它是「${step.item}」这一步的数据工单要人在件上补的表(剧本声明 humanWrites),` +
|
||||||
|
'值只能从人那儿来,你没有来源:别拿别的料号 / 别的行的值顶上(那是假数据,后面会被当证据用)。' +
|
||||||
|
'这一步该做的是把那张数据工单发出去(human + execution + 数据工单面孔),人填完交回,判据自己就成立了。' +
|
||||||
|
'已经写进去的错值用 drop 撤掉 —— drop 不拦。'
|
||||||
|
);
|
||||||
|
},
|
||||||
// 面孔与人工验收清单按剧本定:行动点名的项声明了就用它的,没声明就按当下在办的那一步
|
// 面孔与人工验收清单按剧本定:行动点名的项声明了就用它的,没声明就按当下在办的那一步
|
||||||
(itemId) => {
|
(itemId) => {
|
||||||
const named = itemId === undefined ? undefined : stepOf(itemId);
|
const named = itemId === undefined ? undefined : stepOf(itemId);
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ export type TaskDataGateway = {
|
|||||||
object: string;
|
object: string;
|
||||||
label: string;
|
label: string;
|
||||||
keys: string[];
|
keys: string[];
|
||||||
|
/**
|
||||||
|
* 单标识列(`declarations.ledger[].caseKey`)—— **摆给人看的行标识要去掉这一段**
|
||||||
|
* (2026-08-25 柴浩:「COV-…/CU-0142-01 只保留斜杠右边」):人正在这一单里看,
|
||||||
|
* 单号是脚下的地面,不是区分行的信息。基座那一侧同款做法见 `rowLabel`。
|
||||||
|
*/
|
||||||
|
caseKey?: string | undefined;
|
||||||
fields: Array<{ name: string; label: string; type?: string }>;
|
fields: Array<{ name: string; label: string; type?: string }>;
|
||||||
}>
|
}>
|
||||||
>;
|
>;
|
||||||
@@ -59,7 +65,21 @@ export type TaskDataGateway = {
|
|||||||
object: string;
|
object: string;
|
||||||
key: Record<string, string | number>;
|
key: Record<string, string | number>;
|
||||||
patch: Record<string, unknown>;
|
patch: Record<string, unknown>;
|
||||||
}): Promise<{ changed: boolean }>;
|
/**
|
||||||
|
* **值没变就别落笔**(2026-08-29 终审 I2)—— 配方每一拍都把同一张表重投一遍,
|
||||||
|
* 而草稿层的 upsert 尾巴上有 `landed_at = null`(「再写一笔就不再是已落地」)。
|
||||||
|
* 照写的话:数据工单确认入库之后,下一拍配方一重跑,那些行的「已落地」记号就被**悄悄擦掉**,
|
||||||
|
* 整单放行时会被再写一遍正式表、再抛一遍 data 事件 —— 而承诺是「整单放行不再写它、不抛事件」。
|
||||||
|
* 模型那一路不传这个旗:它的「空转写」要被看见(如实回 `changed:false`),行为一个字不变。
|
||||||
|
*/
|
||||||
|
skipUnchanged?: boolean | undefined;
|
||||||
|
/**
|
||||||
|
* **「只建不改」的表碰到已在库的键:跳过,不拒**(2026-08-29 终审 I2)。
|
||||||
|
* 那句拒回是写给模型看的(「别再写它,用 drop 把骨架撤掉」)—— 摆到人面前毫无意义,
|
||||||
|
* 而配方抛出去就是永久停单。对配方来说「这颗料已经在库了」是**常态**,不是错。
|
||||||
|
*/
|
||||||
|
skipExisting?: boolean | undefined;
|
||||||
|
}): Promise<{ changed: boolean; skipped?: boolean }>;
|
||||||
/**
|
/**
|
||||||
* **撤掉本单草稿层的一行** —— 写错了得有退路。
|
* **撤掉本单草稿层的一行** —— 写错了得有退路。
|
||||||
*
|
*
|
||||||
@@ -72,7 +92,19 @@ export type TaskDataGateway = {
|
|||||||
domain: string;
|
domain: string;
|
||||||
object: string;
|
object: string;
|
||||||
key: Record<string, string | number>;
|
key: Record<string, string | number>;
|
||||||
}): Promise<{ dropped: boolean }>;
|
/**
|
||||||
|
* **已落地的行:跳过,不拒**(2026-08-31,配方专用 —— 与 `draftWrite` 的两个旗同族)。
|
||||||
|
* 「数据工单确认即入库」把行落进正式表后,草稿层那一笔只是「本单写过它」的记号;
|
||||||
|
* 撤记号正式表一个字不动,读面反而少一行 —— 所以对人/模型那一路它是硬拒(拒回是
|
||||||
|
* 行动指南)。而配方 `prune` 的语义是「陈旧的投影行清掉」:已落地 = 已经是世界的
|
||||||
|
* 一部分,不是陈旧草稿 —— 对配方来说跳过是常态,抛错就是整单停摆(2026-08-30
|
||||||
|
* e2e 案例 2 的死锁:人确认入库后改了类型选择,prune 想撤已落地的旧类型行,当场炸;
|
||||||
|
* 当时以「②③ 不带 prune」绕开,代价是**没落地的**陈旧行也跟着不清了 ——
|
||||||
|
* 2026-08-31 用户实测点名的「改回同一类型,下面还是两个类型」正是这半截代价)。
|
||||||
|
* 模型那一路不传这个旗:行为一个字不变。
|
||||||
|
*/
|
||||||
|
skipLanded?: boolean | undefined;
|
||||||
|
}): Promise<{ dropped: boolean; landed?: boolean }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -314,3 +346,24 @@ export type TaskContext = {
|
|||||||
*/
|
*/
|
||||||
machineCriteria(taskId: TaskId): Promise<ReadonlySet<string>>;
|
machineCriteria(taskId: TaskId): Promise<ReadonlySet<string>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **一次结构化 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<Record<string, unknown>>;
|
||||||
|
/** 归因:`<步名>#<第几笔>` */
|
||||||
|
readonly label: string;
|
||||||
|
}): Promise<{ readonly object: Readonly<Record<string, unknown>> }>;
|
||||||
|
};
|
||||||
|
|||||||
@@ -450,7 +450,15 @@ describe('日志分得清谁干的', () => {
|
|||||||
it('引擎自己做主的仍署「系统」—— 起单不是谁办的,是流程走到了这儿', () => {
|
it('引擎自己做主的仍署「系统」—— 起单不是谁办的,是流程走到了这儿', () => {
|
||||||
const lines = structuredLog([
|
const lines = structuredLog([
|
||||||
entry('engine-transition', { seq: 1, title: '起单:A1 · 询价受理' }),
|
entry('engine-transition', { seq: 1, title: '起单:A1 · 询价受理' }),
|
||||||
entry('engine-transition', { seq: 2, title: '自动收步:查历史价' }),
|
/*
|
||||||
|
* **补上生产环境真实的 detail 形状**(2026-08-29,R10,评审 Important)——
|
||||||
|
* `entry()` 工厂不设默认 `detail`,从前这一条造出来的入参是 `{}`,
|
||||||
|
* 根本没走到 `machinery()` 那道 `internal` 过滤,是因为无关的原因才绿的。
|
||||||
|
* `turn-runner.ts` 真实留下的这一笔带 `item` / `auto` 两个键(见 R10 之后的
|
||||||
|
* `turn-runner.ts:2039` 一带),这里照实给上,让这条测试真的守住「不带 internal
|
||||||
|
* 也能显示」这件事,而不是凑巧躲过了过滤。
|
||||||
|
*/
|
||||||
|
entry('engine-transition', { seq: 2, title: '自动收步:查历史价', detail: { item: '查历史价', auto: '查历史价' } }),
|
||||||
]);
|
]);
|
||||||
expect(lines.map((l) => l.actor)).toEqual(['系统', '系统']);
|
expect(lines.map((l) => l.actor)).toEqual(['系统', '系统']);
|
||||||
});
|
});
|
||||||
@@ -600,6 +608,30 @@ describe('压下的交代不该消失', () => {
|
|||||||
it('其余内部痕(排障用的原文留档)照旧不摆', () => {
|
it('其余内部痕(排障用的原文留档)照旧不摆', () => {
|
||||||
expect(structuredLog([entry('observation', { detail: { internal: true, raw: '{坏 JSON}' } })])).toHaveLength(0);
|
expect(structuredLog([entry('observation', { detail: { internal: true, raw: '{坏 JSON}' } })])).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **配方那几笔另开一道例外**(2026-08-29 终审 I5,控制端裁定 F2)。
|
||||||
|
* `internal` 是 `machinery()`(工作日志)与 `chat-projection`(对话区)**共用的一个旗** ——
|
||||||
|
* 上一轮为了让配方留痕进工作日志把它整个去掉了,副作用是那些机械痕一笔一个气泡
|
||||||
|
* 跑进了对话区。现在配方那几笔照旧标 `internal`(对话区据此挡住),
|
||||||
|
* 工作日志这一侧按 `detail.auto` 放行(与 `detail.say` 同款)。
|
||||||
|
*/
|
||||||
|
it('**带 auto 的内部痕进日志** —— 配方做的事正是人回看时最想看的', () => {
|
||||||
|
const line = structuredLog([
|
||||||
|
entry('observation', {
|
||||||
|
seq: 11,
|
||||||
|
title: '芯片主数据:写下 2 行',
|
||||||
|
detail: { internal: true, auto: 'S0_bind#1', ledger: 'parts.profile' },
|
||||||
|
}),
|
||||||
|
])[0];
|
||||||
|
expect(line?.title).toBe('芯片主数据:写下 2 行');
|
||||||
|
// 配方不是 AI 判的,也不是人办的 —— 归系统
|
||||||
|
expect(line?.who).toBe('system');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto 不是字符串就不算例外 —— 只认结构化的那个键,不靠有没有这个词', () => {
|
||||||
|
expect(structuredLog([entry('observation', { detail: { internal: true, auto: true } })])).toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -642,3 +674,26 @@ describe('进对话区的交代也只摆首句', () => {
|
|||||||
expect(detailOf(one)).toEqual(['这句已进对话区。']);
|
expect(detailOf(one)).toEqual(['这句已进对话区。']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志三色:配方做的事归系统(2026-08-29,ADR-014)。
|
||||||
|
*
|
||||||
|
* 用 `action-intent` 造例 —— 它在 `SKELETON` 里(能真的走完 `structuredLog` 全程,
|
||||||
|
* 不像 `result` 那样在骨架这一关就被滤掉、永远到不了 `actorOf`),
|
||||||
|
* 且不带 `detail.auto` 时会落进 `actorOf` 的 `default` 分支(署「任务助手」),
|
||||||
|
* 正好能对照出「加了 `detail.auto` 之后改署系统」这条判定是否生效。
|
||||||
|
*/
|
||||||
|
describe('日志三色:配方做的事归系统', () => {
|
||||||
|
it('带 detail.auto 的那几行是**系统**做的 —— 既不是 AI 判的,也不是人办的', () => {
|
||||||
|
const lines = structuredLog([
|
||||||
|
entry('action-intent', { title: '芯片主数据:写下 2 行', detail: { auto: 'S1#3' } }),
|
||||||
|
]);
|
||||||
|
expect(lines[0]?.who).toBe('system');
|
||||||
|
expect(lines[0]?.actor).toBe('系统');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('没有 detail.auto 的 action-intent 照旧记在 AI 名下 —— 老行为一个字不变', () => {
|
||||||
|
const lines = structuredLog([entry('action-intent', { title: '我判齐了' })]);
|
||||||
|
expect(lines[0]?.who).toBe('agent');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -269,6 +269,13 @@ function actionOf(e: MemoryEntry): string {
|
|||||||
* 由上层换成名字;换不到就退回「员工」,不编名字。
|
* 由上层换成名字;换不到就退回「员工」,不编名字。
|
||||||
*/
|
*/
|
||||||
function actorOf(e: MemoryEntry): { actor: string; actorId: string | undefined; who: 'owner' | 'agent' | 'system' } {
|
function actorOf(e: MemoryEntry): { actor: string; actorId: string | undefined; who: 'owner' | 'agent' | 'system' } {
|
||||||
|
/*
|
||||||
|
* **配方做的事归系统**(2026-08-29,ADR-014)。自主率的分法要认第三档:
|
||||||
|
* 它既不是 AI 判出来的,也不是人办的 —— 是系统按声明做的。
|
||||||
|
* 不加这一条的话,`kind: 'result'` 会掉进 `default` 分支被记在「任务助手」名下,
|
||||||
|
* 日志上看着像 AI 干的 —— **界面不撒谎**。
|
||||||
|
*/
|
||||||
|
if (typeof e.detail?.['auto'] === 'string') return { actor: '系统', actorId: undefined, who: 'system' as const };
|
||||||
const by = e.detail?.['by'];
|
const by = e.detail?.['by'];
|
||||||
const id = typeof by === 'string' && by !== '' ? by : undefined;
|
const id = typeof by === 'string' && by !== '' ? by : undefined;
|
||||||
switch (e.kind) {
|
switch (e.kind) {
|
||||||
@@ -349,7 +356,22 @@ function machinery(e: MemoryEntry): boolean {
|
|||||||
* 现在带 `say` 的放行(它是人话,正是要留的那样东西);
|
* 现在带 `say` 的放行(它是人话,正是要留的那样东西);
|
||||||
* 其余内部痕(重问原文这类给排障用的)照旧不摆。
|
* 其余内部痕(重问原文这类给排障用的)照旧不摆。
|
||||||
*/
|
*/
|
||||||
if (e.detail?.['internal'] === true && typeof e.detail?.['say'] !== 'string') return true;
|
/*
|
||||||
|
* **`internal` 是两个投影共用的一个旗**(2026-08-29 终审 I5,控制端裁定 F2):
|
||||||
|
* `machinery()` 拿它决定「进不进工作日志」,`chat-projection` 拿同一个键决定「进不进对话区」。
|
||||||
|
* 上一轮为了让配方留痕进工作日志,把它整个去掉了 —— 副作用是「芯片主数据:写下 2 行」
|
||||||
|
* 「已把 8 条参数交给模型逐条判定」这类**机械痕跑进了对话区**,一笔一个气泡,
|
||||||
|
* 而这个仓花了很大代价立起「对话区一轮一句,不是一拍一句」。
|
||||||
|
* 治法与 `say` 同款:给 `detail.auto` 开一个例外 —— 配方那几笔照旧标 `internal`
|
||||||
|
* (对话区据此挡住),工作日志这一侧按 `auto` 放行。**两件事各有各的开关。**
|
||||||
|
*/
|
||||||
|
if (
|
||||||
|
e.detail?.['internal'] === true &&
|
||||||
|
typeof e.detail?.['say'] !== 'string' &&
|
||||||
|
typeof e.detail?.['auto'] !== 'string'
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (RESTATED.some((re) => re.test(e.title))) return true;
|
if (RESTATED.some((re) => re.test(e.title))) return true;
|
||||||
if (e.kind === 'thought') return true;
|
if (e.kind === 'thought') return true;
|
||||||
if (e.kind === 'engine-transition' && e.title.startsWith('装载案例模型')) return true;
|
if (e.kind === 'engine-transition' && e.title.startsWith('装载案例模型')) return true;
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export type SubmitForReleaseInput = {
|
|||||||
readonly criteria: readonly CriterionCheck[];
|
readonly criteria: readonly CriterionCheck[];
|
||||||
/** 人工验收清单(剧本声明,固定):卡上一条一枚开关,全部达标才能通过 */
|
/** 人工验收清单(剧本声明,固定):卡上一条一枚开关,全部达标才能通过 */
|
||||||
readonly checklist?: ReadonlyArray<string>;
|
readonly checklist?: ReadonlyArray<string>;
|
||||||
|
/** 这张卡打回得了吗 —— `false` = 打回之后没人接得住(见契约 `AcceptanceWorkOrder.redoable`) */
|
||||||
|
readonly redoable?: boolean;
|
||||||
/** 报告可修订:AI 拟值已填的表单字段 */
|
/** 报告可修订:AI 拟值已填的表单字段 */
|
||||||
readonly fields?: ReadonlyArray<Record<string, unknown>>;
|
readonly fields?: ReadonlyArray<Record<string, unknown>>;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -144,6 +144,8 @@ export function createReleaseService(deps: ReleaseServiceDeps): ReleaseService {
|
|||||||
actions: report.verified.map((v) => ({ label: v.label, effect: v.effect, detail: v.detail })),
|
actions: report.verified.map((v) => ({ label: v.label, effect: v.effect, detail: v.detail })),
|
||||||
// 人工验收清单:人核这份工单要走的步骤(剧本声明,固定)
|
// 人工验收清单:人核这份工单要走的步骤(剧本声明,固定)
|
||||||
...(input.checklist === undefined || input.checklist.length === 0 ? {} : { checklist: input.checklist }),
|
...(input.checklist === undefined || input.checklist.length === 0 ? {} : { checklist: input.checklist }),
|
||||||
|
/* 打回得了吗 —— 只在明确说「打不回」时才带下去,老调用方一个字不用改 */
|
||||||
|
...(input.redoable === false ? { redoable: false } : {}),
|
||||||
/*
|
/*
|
||||||
* 可修订字段 —— 验收报告「AI 已填好、可编辑」改的就是它们。
|
* 可修订字段 —— 验收报告「AI 已填好、可编辑」改的就是它们。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ export type CardGateway = {
|
|||||||
actions: ReadonlyArray<{ label: string; effect: 'read' | 'write'; detail: string }>;
|
actions: ReadonlyArray<{ label: string; effect: 'read' | 'write'; detail: string }>;
|
||||||
/** 人工验收清单 —— 原样带上卡 */
|
/** 人工验收清单 —— 原样带上卡 */
|
||||||
checklist?: ReadonlyArray<string> | undefined;
|
checklist?: ReadonlyArray<string> | undefined;
|
||||||
|
/** 这张卡打回得了吗(`false` = 那颗钮不摆)—— 端口不声明它,值就在装配层被丢掉,这行不能省 */
|
||||||
|
redoable?: boolean | undefined;
|
||||||
/**
|
/**
|
||||||
* 可修订字段 —— 验收报告「AI 已填好、可编辑」改的就是它们(带 `ai` 拟值与 `ledger` 落回地址)。
|
* 可修订字段 —— 验收报告「AI 已填好、可编辑」改的就是它们(带 `ai` 拟值与 `ledger` 落回地址)。
|
||||||
* **端口不声明它,值就在装配层被丢掉**:上游一路传下来,而 TypeScript 对条件展开
|
* **端口不声明它,值就在装配层被丢掉**:上游一路传下来,而 TypeScript 对条件展开
|
||||||
|
|||||||
@@ -54,5 +54,6 @@
|
|||||||
- **已终结不得再推进**:`tick` / `deliver` 对终态任务抛 `TaskAlreadyTerminalError` ——
|
- **已终结不得再推进**:`tick` / `deliver` 对终态任务抛 `TaskAlreadyTerminalError` ——
|
||||||
流级重做一律重开新实例,不复活旧任务
|
流级重做一律重开新实例,不复活旧任务
|
||||||
- **叫停不是终态**:转挂起待纠偏,并记**最重档纠正信号**
|
- **叫停不是终态**:转挂起待纠偏,并记**最重档纠正信号**
|
||||||
- **封卷触发蒸馏**:终态后 `seal` + `distill`,产出活动记忆候选条目
|
- **封卷触发蒸馏**:终态后 `seal` + `distill`,产出活动记忆候选条目;蒸馏是一次真模型调用,
|
||||||
|
**后台跑、不吊着收尾的调用方**(2026-09-01:终止接口曾被它吊 13-45 秒到报错),失败吞掉不掀终态
|
||||||
- **断点续跑靠三份持久状态**:任务记忆 / 草稿层 / 运行时实例状态,缺一即不可续
|
- **断点续跑靠三份持久状态**:任务记忆 / 草稿层 / 运行时实例状态,缺一即不可续
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ import type { LandedFacts, Task } from '../contract.ts';
|
|||||||
import { createTaskService } from '../internal/usecase/task-service.ts';
|
import { createTaskService } from '../internal/usecase/task-service.ts';
|
||||||
import type { TaskServiceDeps } from '../internal/usecase/task-service.ts';
|
import type { TaskServiceDeps } from '../internal/usecase/task-service.ts';
|
||||||
|
|
||||||
function harness(lastProgressAt: Date | undefined) {
|
function harness(lastProgressAt: Date | undefined, runtime: Task['runtime'] = 'awaiting-human') {
|
||||||
const task: Task = {
|
const task: Task = {
|
||||||
id: 't-1' as TaskId,
|
id: 't-1' as TaskId,
|
||||||
activityId: 'w05',
|
activityId: 'w05',
|
||||||
ownerId: 's-1' as StaffId,
|
ownerId: 's-1' as StaffId,
|
||||||
title: 'x',
|
title: 'x',
|
||||||
runtime: 'awaiting-human',
|
runtime,
|
||||||
envelope: {},
|
envelope: {},
|
||||||
data: {},
|
data: {},
|
||||||
caps: { steps: 40, tokens: 200000, humanCalls: 4, activeMinutes: 60 },
|
caps: { steps: 40, tokens: 200000, humanCalls: 4, activeMinutes: 60 },
|
||||||
@@ -88,6 +88,29 @@ describe('迟到的人事件', () => {
|
|||||||
expect(arrival?.detail?.['stale']).toBe(true);
|
expect(arrival?.detail?.['stale']).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **判了 stale 还得管它有没有人叫醒**(2026-09-01,两次实测撞停):卡发出 270ms 后人就答了 ——
|
||||||
|
* 把 `lastProgressAt` 往前挪的那一拍,**正是发出这张卡的那一拍**,它不可能消费一条当时还不存在的回传;
|
||||||
|
* 而回传口的 `wake` 也白做了(那一拍随后收尾,把任务 park 回 `awaiting-event`,晚到的写盖了早到的)。
|
||||||
|
* 于是卡答完了、没有新卡在等、单子停着,**再没有第三样东西会叫醒它** —— 实测停了 15 分钟,
|
||||||
|
* 手工把 runtime 拨回 running,同一份代码立刻把该走的几拍走完。
|
||||||
|
* 这一拍照旧不跑(stale 的本意不变),但停着的单子要拨回 running 交给推进器。
|
||||||
|
*/
|
||||||
|
it('**stale 但单子停着** → 这一拍照旧不跑,可要把它拨回 running,不然再没人叫醒它', async () => {
|
||||||
|
const h = harness(new Date('2026-08-27T09:48:00.200Z'));
|
||||||
|
const out = await h.service.deliver('t-1' as TaskId, { kind: 'human', ref: 'card-p10', payload: {}, occurredAt: REPLY_AT });
|
||||||
|
expect(h.advanced()).toBe(0);
|
||||||
|
expect(h.onEvents()).toBe(0);
|
||||||
|
expect(out.task.runtime).toBe('running');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stale 而单子本来就在跑 → 一个字不动(推进器自己会跑到它)', async () => {
|
||||||
|
const h = harness(new Date('2026-08-27T09:48:00.200Z'), 'running');
|
||||||
|
const out = await h.service.deliver('t-1' as TaskId, { kind: 'human', ref: 'card-p10', payload: {}, occurredAt: REPLY_AT });
|
||||||
|
expect(out.task.runtime).toBe('running');
|
||||||
|
expect(h.advanced()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('回传之后没推进过 → 照常投递并推一拍(这就是叫醒它的那一下)', async () => {
|
it('回传之后没推进过 → 照常投递并推一拍(这就是叫醒它的那一下)', async () => {
|
||||||
const h = harness(new Date('2026-08-27T09:47:40.000Z'));
|
const h = harness(new Date('2026-08-27T09:47:40.000Z'));
|
||||||
await h.service.deliver('t-1' as TaskId, { kind: 'human', ref: 'card-s1', payload: {}, occurredAt: REPLY_AT });
|
await h.service.deliver('t-1' as TaskId, { kind: 'human', ref: 'card-s1', payload: {}, occurredAt: REPLY_AT });
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* 终态收尾不等蒸馏(2026-09-01 用户实测:运营中心点「终止活动」,接口迟迟不返回最后报错,
|
||||||
|
* 而实例确实终止了)。
|
||||||
|
*
|
||||||
|
* 病根:`finish` 在返回前同步 `await` 了 `memory.distill` —— 那是一次真模型调用
|
||||||
|
* (整单记忆 + JSON schema 结构化输出,四单法证 13.2s / 36.3s / 43.4s / 44.6s:
|
||||||
|
* `sys.task.finished_at` 与蒸馏条目 `created_at` 的差就是接口被吊的时长)。
|
||||||
|
* 终态在它**之前**已落库并广播,所以屏上早就显示已终止,响应却被吊在半空 ——
|
||||||
|
* 拖到任何一环断连(watcher 重启进程 / 代理 / 人重试)就成一次报错。
|
||||||
|
* 吊着的不止终止接口:放行收尾那一拍同样走 `finish`,推进器与任务租约陪着蒸馏等。
|
||||||
|
*
|
||||||
|
* 蒸馏本来就是**进化管线**的入口、不是终态的一部分(`finish` 里的注释白纸黑字),
|
||||||
|
* 它的失败早已被容忍 —— 那它的**时长**同样不该由收尾的调用方来付。
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { StaffId, TaskId } from '@contracts';
|
||||||
|
import type { LandedFacts, Task } from '../contract.ts';
|
||||||
|
import { createTaskService } from '../internal/usecase/task-service.ts';
|
||||||
|
import type { TaskServiceDeps } from '../internal/usecase/task-service.ts';
|
||||||
|
|
||||||
|
function harness(distill: TaskServiceDeps['memory']['distill']) {
|
||||||
|
const trail: string[] = [];
|
||||||
|
const task: Task = {
|
||||||
|
id: 't-1' as TaskId,
|
||||||
|
activityId: 'w05',
|
||||||
|
ownerId: 's-1' as StaffId,
|
||||||
|
title: 'x',
|
||||||
|
runtime: 'awaiting-human',
|
||||||
|
envelope: {},
|
||||||
|
data: {},
|
||||||
|
caps: { steps: 40, tokens: 200000, humanCalls: 2, activeMinutes: 60 },
|
||||||
|
used: { steps: 0, tokens: 0, humanCalls: 0, activeMs: 0 },
|
||||||
|
startedAt: '2026-09-01T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
const tasks = new Map<string, Task>([[task.id, task]]);
|
||||||
|
const deps: TaskServiceDeps = {
|
||||||
|
store: {
|
||||||
|
get: async (id) => tasks.get(id),
|
||||||
|
insert: async (t) => void tasks.set(t.id, t),
|
||||||
|
update: async (id, patch) => {
|
||||||
|
const next = { ...tasks.get(id)!, ...patch };
|
||||||
|
tasks.set(id, next);
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
addConsumption: async (id) => tasks.get(id)!.used,
|
||||||
|
lastProgressAt: async () => new Date(),
|
||||||
|
markProgress: async () => undefined,
|
||||||
|
listByOwner: async () => [],
|
||||||
|
},
|
||||||
|
activities: { load: async () => ({ caps: task.caps, envelope: {}, inputSchema: {}, businessModel: {}, stallAfter: 'PT30M' }) },
|
||||||
|
schemas: { validate: () => ({ ok: true, errors: [] }) },
|
||||||
|
engine: { load: async () => undefined, advance: async () => ({ status: 'running' }), onEvent: async () => ({ status: 'running' }) },
|
||||||
|
memory: {
|
||||||
|
append: async () => undefined,
|
||||||
|
hasHistory: async () => true,
|
||||||
|
seal: async () => void trail.push('seal'),
|
||||||
|
distill: (input) => {
|
||||||
|
trail.push('distill');
|
||||||
|
return distill(input);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
landing: { land: async () => ({ converged: true, emittedPorts: [] as string[], ledgerRowsMerged: 0, facts: {} as LandedFacts }) },
|
||||||
|
listeners: { hasArmedListener: async () => true },
|
||||||
|
persistence: { hasDrafts: async () => false, hasRuntimeState: async () => true },
|
||||||
|
events: { publishTerminal: async () => undefined, publishControl: async () => undefined },
|
||||||
|
notifier: { notify: async () => undefined },
|
||||||
|
interaction: { setPhase: async () => undefined },
|
||||||
|
intake: { askCompletion: async () => undefined },
|
||||||
|
comms: { record: async () => undefined },
|
||||||
|
clock: { now: () => new Date('2026-09-01T00:00:00.000Z') },
|
||||||
|
ids: { next: () => 't-x' },
|
||||||
|
};
|
||||||
|
return { service: createTaskService(deps), tasks, trail };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('finish 不把收尾吊在蒸馏上', () => {
|
||||||
|
it('蒸馏还悬着,terminate 已经返回、终态已落 —— 接口不等模型', async () => {
|
||||||
|
let release: (() => void) | undefined;
|
||||||
|
const gate = new Promise<never[]>((resolve) => {
|
||||||
|
release = () => resolve([]);
|
||||||
|
});
|
||||||
|
const h = harness(() => gate as Promise<never[]>);
|
||||||
|
|
||||||
|
const outcome = await h.service.control('t-1' as TaskId, 'terminate', 's-1' as StaffId, '手动终止');
|
||||||
|
expect(outcome.kind).toBe('terminated');
|
||||||
|
expect(h.tasks.get('t-1')?.terminal).toBe('terminated');
|
||||||
|
/* 走到这儿 gate 还没放行 —— 蒸馏在后台悬着,control 没有等它 */
|
||||||
|
expect(h.trail).toEqual(['seal', 'distill']);
|
||||||
|
release?.();
|
||||||
|
await gate;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('蒸馏抛了也不掀翻终态,更不外泄成未处理拒绝', async () => {
|
||||||
|
const h = harness(async () => {
|
||||||
|
throw new Error('Insufficient Balance');
|
||||||
|
});
|
||||||
|
const outcome = await h.service.control('t-1' as TaskId, 'terminate', 's-1' as StaffId, '手动终止');
|
||||||
|
expect(outcome.kind).toBe('terminated');
|
||||||
|
expect(h.tasks.get('t-1')?.terminal).toBe('terminated');
|
||||||
|
/* 让后台那口拒绝走完微任务 —— vitest 对未处理拒绝会当场炸,这里干净跑完即是断言 */
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('封卷在蒸馏之前 —— 只追加不改的守卫先立起来,蒸馏读的是封好的卷', async () => {
|
||||||
|
const h = harness(async () => []);
|
||||||
|
await h.service.control('t-1' as TaskId, 'terminate', 's-1' as StaffId, '手动终止');
|
||||||
|
expect(h.trail).toEqual(['seal', 'distill']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -350,7 +350,26 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
|
|||||||
detail: { ...event.payload, kind: event.kind, ...(stale ? { stale: true, note: '这条回传在到达前已被推进过的那一拍处理,这次不再推' } : {}) },
|
detail: { ...event.payload, kind: event.kind, ...(stale ? { stale: true, note: '这条回传在到达前已被推进过的那一拍处理,这次不再推' } : {}) },
|
||||||
});
|
});
|
||||||
if (stale) {
|
if (stale) {
|
||||||
|
/*
|
||||||
|
* **判了 stale 还得管它有没有人叫醒** —— 不然就是把单子锁死(2026-09-01 实测两次:
|
||||||
|
* 卡发出 270ms 后人就答了,而**把 `lastProgressAt` 往前挪的那一拍,正是发出这张卡的那一拍**
|
||||||
|
* ——它不可能消费一条当时还不存在的回传。上面那条 stale 判据的前提在这种时序下不成立。
|
||||||
|
*
|
||||||
|
* 更糟的是回传路由的 `wake` 也白做了:它先把任务拨成 `running`,而发卡那一拍**随后**收尾时
|
||||||
|
* 又把它 park 回 `awaiting-event`(晚到的写覆盖了早到的 wake)。于是三样东西凑齐:
|
||||||
|
* 卡答完了、没有新卡在等、单子停着,**再没有第三样东西会叫醒它**(实测停了 15 分钟,
|
||||||
|
* 手工把 runtime 拨回 running,同一份代码立刻把该走的几拍走完)。
|
||||||
|
*
|
||||||
|
* 这里只补最后一下:**不跑这一拍**(stale 的本意照旧 —— 这条回传不该再推一遍),
|
||||||
|
* 但把停着的单子拨回 `running`,交给推进器按它自己的节奏跑。真没事可干,它自己再停回去
|
||||||
|
* (推进器那一拍会走「有卡在等人 → 只算不问模型」或直接原地返回,代价是一拍空转)。
|
||||||
|
*/
|
||||||
const held = await require(taskId);
|
const held = await require(taskId);
|
||||||
|
if (held.terminal === undefined && (held.runtime === 'awaiting-event' || held.runtime === 'awaiting-human')) {
|
||||||
|
await deps.store.update(taskId, { runtime: 'running' });
|
||||||
|
await deps.interaction.setPhase(taskId, 'active');
|
||||||
|
return { task: await require(taskId), verdict: { kind: 'ok' }, progressed: false };
|
||||||
|
}
|
||||||
return { task: held, verdict: { kind: 'ok' }, progressed: false };
|
return { task: held, verdict: { kind: 'ok' }, progressed: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,23 +498,27 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
|
|||||||
|
|
||||||
// 终态封卷 → 归档 → 蒸馏钩子产出活动记忆候选条目
|
// 终态封卷 → 归档 → 蒸馏钩子产出活动记忆候选条目
|
||||||
await deps.memory.seal(taskId);
|
await deps.memory.seal(taskId);
|
||||||
try {
|
/*
|
||||||
await deps.memory.distill({
|
* 蒸馏**后台跑,不吊着收尾的调用方**(2026-09-01 用户实测:运营中心点「终止活动」,
|
||||||
|
* 接口迟迟不返回最后报错,而实例确实终止了)。它是一次真模型调用 —— 整单记忆 +
|
||||||
|
* JSON schema 结构化输出,四单法证 13-45 秒(`finished_at` 与蒸馏条目 `created_at`
|
||||||
|
* 的差)。终态在上面几行已落库并广播,把响应吊在这儿,拖到任何一环断连
|
||||||
|
* (进程重启 / 代理 / 人重试)就成一次报错;放行收尾那一拍同样走这里,
|
||||||
|
* 推进器与任务租约陪着模型等。封卷仍在前(只追加不改的守卫先立起来)。
|
||||||
|
*
|
||||||
|
* 失败照旧吞掉:蒸馏是**进化管线**的入口,不是终态的一部分 —— 它产出的是
|
||||||
|
* 待回放待放行的候选增量,失败(模型不可达等)绝不能把已经落定的终态掀翻。
|
||||||
|
* 也不能把失败记进任务记忆:此刻记忆**已封卷**,追加会被只追加不改的守卫拒掉
|
||||||
|
* (那正是它该做的)。蒸馏的成败属于进化管线的账,不属于这一单的过程留痕。
|
||||||
|
*/
|
||||||
|
void deps.memory
|
||||||
|
.distill({
|
||||||
taskId,
|
taskId,
|
||||||
activityId: task.activityId,
|
activityId: task.activityId,
|
||||||
trigger: 'terminal',
|
trigger: 'terminal',
|
||||||
prompt: `任务「${task.title}」以 ${terminal} 收场(${reason})。归纳可复用的事实、模式、裁决与例外。`,
|
prompt: `任务「${task.title}」以 ${terminal} 收场(${reason})。归纳可复用的事实、模式、裁决与例外。`,
|
||||||
});
|
})
|
||||||
} catch {
|
.catch(() => undefined);
|
||||||
/*
|
|
||||||
* 蒸馏是**进化管线**的入口,不是终态的一部分:它产出的是待回放待放行的候选增量。
|
|
||||||
* 它失败(模型不可达等)绝不能把已经落定的终态掀翻 —— 那会让任务卡在
|
|
||||||
* 「实际已终、状态却没落」的中间态,治理台也看不到。
|
|
||||||
*
|
|
||||||
* 也不能把失败记进任务记忆:此刻记忆**已封卷**,追加会被只追加不改的守卫拒掉
|
|
||||||
* (那正是它该做的)。蒸馏的成败属于进化管线的账,不属于这一单的过程留痕。
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* 工单穿哪副面孔(2026-08-31 用户定「数据齐了也叫人一次,派验收工单」逼出的第三副)。
|
||||||
|
*
|
||||||
|
* 三副面孔都是**执行工单**的变体,回传即办结;收口验收单(`mode:'acceptance'`)是另一回事,
|
||||||
|
* 它有自己的裁决表单(AcceptForm)—— `faceOf` 对它返回 undefined,别顶着执行工单的牌子摆出来。
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { ACCEPT_FACE, DATA_FACE, EXEC_FACE, chipOf, faceOf } from '../internal/order-face.ts';
|
||||||
|
|
||||||
|
describe('faceOf —— 没办结的执行工单穿哪副', () => {
|
||||||
|
it('不写变体 = 执行工单;data = 数据工单;accept = 验收工单', () => {
|
||||||
|
expect(faceOf('execution', undefined)).toBe(EXEC_FACE);
|
||||||
|
expect(faceOf('execution', 'data')).toBe(DATA_FACE);
|
||||||
|
expect(faceOf('execution', 'accept')).toBe(ACCEPT_FACE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('收口验收单不走这儿 —— 它是 submit 抬起来的裁决,不是「这一步要你办的事」', () => {
|
||||||
|
expect(faceOf('acceptance', undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('三副面孔的四字与按钮字各不相同 —— 人一眼要能认出这张卡要他干什么', () => {
|
||||||
|
const faces = [EXEC_FACE, DATA_FACE, ACCEPT_FACE];
|
||||||
|
expect(new Set(faces.map((f) => f.docTitle)).size).toBe(3);
|
||||||
|
expect(new Set(faces.map((f) => f.submit)).size).toBe(3);
|
||||||
|
expect(new Set(faces.map((f) => f.klass)).size).toBe(3);
|
||||||
|
/* 验收工单的件是只读的,按钮说的就得是「确认」而不是「提交/写入」 */
|
||||||
|
expect(ACCEPT_FACE.submit).toBe('确认无误');
|
||||||
|
expect(ACCEPT_FACE.docHint).toContain('只读');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('chipOf —— 已办结 / 没有指导书的那张小卡', () => {
|
||||||
|
it('数据工单摆「数据」,验收工单与收口验收单同摆「验收」,其余「执行」', () => {
|
||||||
|
expect(chipOf('execution', 'data')).toEqual({ klass: 't-data', ico: 'database', label: '数据' });
|
||||||
|
expect(chipOf('execution', 'accept')).toEqual({ klass: 't-acc', ico: 'shield-check', label: '验收' });
|
||||||
|
expect(chipOf('acceptance', undefined)).toEqual({ klass: 't-acc', ico: 'shield-check', label: '验收' });
|
||||||
|
expect(chipOf('execution', undefined)).toEqual({ klass: 't-exe', ico: 'clipboard-check', label: '执行' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { dataEntryCardSchema, decisionCardSchema } from '@contracts';
|
import { dataEntryCardSchema, decisionCardSchema } from '@contracts';
|
||||||
import type { TaskDetail } from '@contracts';
|
import type { TaskDetail } from '@contracts';
|
||||||
import { pendingCardsOf } from '../internal/pending-cards.ts';
|
import { awaitingHuman, pendingCardsOf } from '../internal/pending-cards.ts';
|
||||||
|
|
||||||
const AT = '2026-08-28T00:55:30.054Z';
|
const AT = '2026-08-28T00:55:30.054Z';
|
||||||
|
|
||||||
@@ -66,3 +66,40 @@ describe('pendingCardsOf', () => {
|
|||||||
expect(pendingCardsOf(chat(entry('c-1'), later)).entryLedger).toBe('客户档案');
|
expect(pendingCardsOf(chat(entry('c-1'), later)).entryLedger).toBe('客户档案');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **有活等着人办就不摆推进场景**(2026-08-31 用户:「在中央工作区修改了内容点击保存后,
|
||||||
|
* 为什么会出现『AI 正在推进这单』」)。
|
||||||
|
*
|
||||||
|
* 件上按保存走 `afterSave`:**先重推件、再叫醒任务**(「保存即铺格」)。叫醒把 runtime
|
||||||
|
* 拨成 `running`,而工作区的 `running` 判据压过 `viewHtml` —— 人刚填完按保存,屏上那份
|
||||||
|
* 刚重推的件被推进场景顶掉几秒;而那一拍按「叫了人就等人」的闸根本不问模型,那一屏
|
||||||
|
* 写的「看材料、写台账、调工具」一件没发生。这一组钉的就是那道新条件。
|
||||||
|
*/
|
||||||
|
describe('awaitingHuman:这一单还有活等着人办吗', () => {
|
||||||
|
const none = { decision: false, entry: false } as const;
|
||||||
|
|
||||||
|
it('没工单没卡 —— AI 真在埋头算,推进场景照旧摆(2026-08-21 要的正是它)', () => {
|
||||||
|
expect(awaitingHuman({ openOrders: 0, cards: none })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('有工单没办结 —— 人正要在件上填,不许拿推进场景盖掉件', () => {
|
||||||
|
expect(awaitingHuman({ openOrders: 1, cards: none })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('决策卡还没选 —— 人要决定的对象常常就是件上那份稿,件得留着', () => {
|
||||||
|
expect(awaitingHuman({ openOrders: 0, cards: { decision: true, entry: false } })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('录入卡还没确认 —— 让位给录入占位页那一支(它排在 running 之后)', () => {
|
||||||
|
expect(awaitingHuman({ openOrders: 0, cards: { decision: false, entry: true, entryLedger: '客户档案' } })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('工单全办结、卡全答过 —— 回到「AI 真在算」,推进场景该摆', () => {
|
||||||
|
expect(awaitingHuman({ openOrders: 0, cards: none })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('三样都在 —— 仍是「等人」,不是「AI 在推进」', () => {
|
||||||
|
expect(awaitingHuman({ openOrders: 2, cards: { decision: true, entry: true, entryLedger: '客户档案' } })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { call } from '@web-shared/typed-fetch.ts';
|
|||||||
import { Ico } from '@web-shared/Ico.tsx';
|
import { Ico } from '@web-shared/Ico.tsx';
|
||||||
import { EmptyState } from '@web-shared/desk/module-kit.tsx';
|
import { EmptyState } from '@web-shared/desk/module-kit.tsx';
|
||||||
import { ChatStream, WorkingStrip } from './chat-bubbles.tsx';
|
import { ChatStream, WorkingStrip } from './chat-bubbles.tsx';
|
||||||
|
import { awaitingHuman, pendingCardsOf } from './pending-cards.ts';
|
||||||
import { Btn } from '@web-shared/Btn.tsx';
|
import { Btn } from '@web-shared/Btn.tsx';
|
||||||
|
|
||||||
export function TaskChat({
|
export function TaskChat({
|
||||||
@@ -43,8 +44,20 @@ export function TaskChat({
|
|||||||
* AI 正在办这单 = Loop 在自己转(runtime running)。
|
* AI 正在办这单 = Loop 在自己转(runtime running)。
|
||||||
* 等人(awaiting-human)、等外部(awaiting-event)、已收尾都不算 ——
|
* 等人(awaiting-human)、等外部(awaiting-event)、已收尾都不算 ——
|
||||||
* 那时亮「工作中」就是撒谎:环没在转,是在等。
|
* 那时亮「工作中」就是撒谎:环没在转,是在等。
|
||||||
|
*
|
||||||
|
* **`&& !awaitingHuman(...)` 是 2026-08-31 补的**(用户:「每次保存的时候右边也会
|
||||||
|
* 出现 ai 推进中」):件上按保存会 `wake` 这一单(「保存即铺格」,不叫醒参数格永远
|
||||||
|
* 不出现)—— runtime 就此被拨成 `running`,而人其实正对着卡在填。上面这三句话说的
|
||||||
|
* 原则没变,变的是「等人」这件事从此不只看 runtime:**有未办结的工单 / 未回的卡,
|
||||||
|
* 就是在等人**,哪怕 runtime 写着 running。工作区那一侧同一个判断、同一个函数。
|
||||||
*/
|
*/
|
||||||
const working = detail.card.terminal === undefined && detail.card.runtime === 'running';
|
const working =
|
||||||
|
detail.card.terminal === undefined &&
|
||||||
|
detail.card.runtime === 'running' &&
|
||||||
|
!awaitingHuman({
|
||||||
|
openOrders: detail.workOrders.filter((o) => o.done === undefined).length,
|
||||||
|
cards: pendingCardsOf(detail.chat),
|
||||||
|
});
|
||||||
|
|
||||||
/** 回传后一律重取任务投影 —— 卡与工单的状态由服务端说,前端不自行推演 */
|
/** 回传后一律重取任务投影 —— 卡与工单的状态由服务端说,前端不自行推演 */
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import ScrollArea from '@web-shared/ScrollArea.tsx';
|
|||||||
import { EmptyState } from '@web-shared/desk/module-kit.tsx';
|
import { EmptyState } from '@web-shared/desk/module-kit.tsx';
|
||||||
import { ViewBlocks, ViewContainer } from '@ui';
|
import { ViewBlocks, ViewContainer } from '@ui';
|
||||||
import { pagerFace, pagerText } from './pager.ts';
|
import { pagerFace, pagerText } from './pager.ts';
|
||||||
import { pendingCardsOf } from './pending-cards.ts';
|
import { awaitingHuman, pendingCardsOf } from './pending-cards.ts';
|
||||||
import type { ViewEdits, ViewSave } from '@ui';
|
import type { ViewEdits, ViewSave } from '@ui';
|
||||||
import { GuideCard, WorkOrderReport } from './work-order-report.tsx';
|
import { GuideCard, WorkOrderReport } from './work-order-report.tsx';
|
||||||
import { FileViewer } from './FileViewer.tsx';
|
import { FileViewer } from './FileViewer.tsx';
|
||||||
@@ -176,6 +176,8 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
|
|||||||
*/
|
*/
|
||||||
const pending = pendingCardsOf(detail.chat);
|
const pending = pendingCardsOf(detail.chat);
|
||||||
const pendingChatCard = pending.decision || pending.entry;
|
const pendingChatCard = pending.decision || pending.entry;
|
||||||
|
/* 这一单还有活等着人办吗 —— 推进场景要不要摆看它(缘由见 `awaitingHuman` 的头注) */
|
||||||
|
const holding = awaitingHuman({ openOrders: openList.length, cards: pending });
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 标记已读 / 未读 —— 乐观更新交给失效:标完服务端广播 task 触点,
|
* 标记已读 / 未读 —— 乐观更新交给失效:标完服务端广播 task 触点,
|
||||||
@@ -763,7 +765,7 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
|
|||||||
desc="成果正在并进正式表、按去向发往下游。落完这一屏会换成收尾结果 —— 不用刷新。"
|
desc="成果正在并进正式表、按去向发往下游。落完这一屏会换成收尾结果 —— 不用刷新。"
|
||||||
hint="通常几秒钟"
|
hint="通常几秒钟"
|
||||||
/>
|
/>
|
||||||
) : detail.card.runtime === 'running' ? (
|
) : detail.card.runtime === 'running' && !holding ? (
|
||||||
/*
|
/*
|
||||||
* **AI 正在算这一拍,不是「已经有一份件摆着」**(2026-08-21 用户:
|
* **AI 正在算这一拍,不是「已经有一份件摆着」**(2026-08-21 用户:
|
||||||
* 「AI 持续推进中时中间的页面不变成持续推进的页面了,而是卡在 html
|
* 「AI 持续推进中时中间的页面不变成持续推进的页面了,而是卡在 html
|
||||||
@@ -774,6 +776,14 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
|
|||||||
* 空待处理就被这份**陈旧**的件抢先占住,推进中动画再也显示不出来。
|
* 空待处理就被这份**陈旧**的件抢先占住,推进中动画再也显示不出来。
|
||||||
* **正在跑的这一拍,永远比上一拍摆下的旧件优先**:running 判据挪到
|
* **正在跑的这一拍,永远比上一拍摆下的旧件优先**:running 判据挪到
|
||||||
* viewHtml 前面,陈旧的件只在「AI 这会儿不在算」时才配拿来顶上。
|
* viewHtml 前面,陈旧的件只在「AI 这会儿不在算」时才配拿来顶上。
|
||||||
|
*
|
||||||
|
* **`&& !holding` 是 2026-08-31 补的**(用户:「在中央工作区修改了内容点击
|
||||||
|
* 保存后,为什么会出现『AI 正在推进这单』」):上面那段的前提是「viewHtml 是
|
||||||
|
* 上一次推的,可能陈旧」。而件上按保存走的 `afterSave` 是**先重推件、再叫醒
|
||||||
|
* 任务**(「保存即铺格」)—— 那条路上件比 `running` 还新,顶掉它没有道理;
|
||||||
|
* 且那一拍按「叫了人就等人」的闸根本不问模型,这一屏写的「看材料、写台账、
|
||||||
|
* 调工具都由它来」一件没发生。**有活等着人办就不摆推进场景**,判断收在
|
||||||
|
* `awaitingHuman` 里(与服务端 `humanTool.pendingCards` 同一句话)。
|
||||||
*/
|
*/
|
||||||
<WorkingScene doneN={doneList.length} />
|
<WorkingScene doneN={doneList.length} />
|
||||||
) : pending.entry ? (
|
) : pending.entry ? (
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import { EVENT_TYPE_LABEL, isRejection } from '@contracts';
|
|||||||
import { Ico } from '@web-shared/Ico.tsx';
|
import { Ico } from '@web-shared/Ico.tsx';
|
||||||
import { Btn } from '@web-shared/Btn.tsx';
|
import { Btn } from '@web-shared/Btn.tsx';
|
||||||
import { approveReply, checklistMissing, redoReply, reviewChoices } from './review-face.ts';
|
import { approveReply, checklistMissing, redoReply, reviewChoices } from './review-face.ts';
|
||||||
|
import { ACCEPT_FACE, DATA_FACE, EXEC_FACE, chipOf } from './order-face.ts';
|
||||||
|
import type { FormFace } from './order-face.ts';
|
||||||
import type { DecisionMessage } from './review-face.ts';
|
import type { DecisionMessage } from './review-face.ts';
|
||||||
import { ChatAvatar } from '@web-shared/ChatAvatar.tsx';
|
import { ChatAvatar } from '@web-shared/ChatAvatar.tsx';
|
||||||
import { atClock } from '@web-shared/at.ts';
|
import { atClock } from '@web-shared/at.ts';
|
||||||
@@ -359,6 +361,15 @@ function Msg({
|
|||||||
onLocate={onLocate}
|
onLocate={onLocate}
|
||||||
{...(orderValues === undefined ? {} : { orderValues })}
|
{...(orderValues === undefined ? {} : { orderValues })}
|
||||||
/>
|
/>
|
||||||
|
) : order.mode === 'execution' && order.variant === 'accept' && !settled ? (
|
||||||
|
/* 验收工单 = 执行工单的变体(契约 `variant`):件只读,人只确认 */
|
||||||
|
<AcceptCheckForm
|
||||||
|
order={order}
|
||||||
|
onReplyOrder={onReplyOrder}
|
||||||
|
onLocate={onLocate}
|
||||||
|
onField={onField}
|
||||||
|
{...(orderValues === undefined ? {} : { orderValues })}
|
||||||
|
/>
|
||||||
) : order.mode === 'execution' && order.variant === 'data' && !settled ? (
|
) : order.mode === 'execution' && order.variant === 'data' && !settled ? (
|
||||||
/* 数据工单 = 执行工单的变体(契约 `variant`):同一套办法,另一副面孔 */
|
/* 数据工单 = 执行工单的变体(契约 `variant`):同一套办法,另一副面孔 */
|
||||||
<DataForm
|
<DataForm
|
||||||
@@ -765,13 +776,13 @@ function Kv({ items }: { items: ReadonlyArray<{ k: string; v: string }> }) {
|
|||||||
function WoCard({ order, onLocate }: { order: WorkOrder; onLocate: (id: string) => void }) {
|
function WoCard({ order, onLocate }: { order: WorkOrder; onLocate: (id: string) => void }) {
|
||||||
const result = order.done?.result;
|
const result = order.done?.result;
|
||||||
const bad = result !== undefined && isRejection(result);
|
const bad = result !== undefined && isRejection(result);
|
||||||
const exe = order.mode === 'execution';
|
/* 三副面孔 + 收口验收单,哪一副由纯函数说了算(`order-face.ts`,有测)—— 这里只摆 */
|
||||||
const data = order.mode === 'execution' && order.variant === 'data';
|
const chip = chipOf(order.mode, order.mode === 'execution' ? order.variant : undefined);
|
||||||
return (
|
return (
|
||||||
<div className={'jc-fc ' + (data ? 't-data' : exe ? 't-exe' : 't-acc')}>
|
<div className={'jc-fc ' + chip.klass}>
|
||||||
<div className="jc-fc-h">
|
<div className="jc-fc-h">
|
||||||
<Ico name={data ? 'database' : exe ? 'file-text' : 'shield-check'} s={13} />
|
<Ico name={chip.ico} s={13} />
|
||||||
<b>工单 · {data ? '数据' : exe ? '执行' : '验收'}</b>
|
<b>工单 · {chip.label}</b>
|
||||||
<em className={result === undefined ? '' : bad ? 'bad' : 'ok'}>
|
<em className={result === undefined ? '' : bad ? 'bad' : 'ok'}>
|
||||||
{result === undefined ? '待处理' : bad ? '已退回' : '已办结'}
|
{result === undefined ? '待处理' : bad ? '已退回' : '已办结'}
|
||||||
</em>
|
</em>
|
||||||
@@ -962,40 +973,6 @@ function Guide({ steps, standard }: { steps?: readonly string[] | undefined; sta
|
|||||||
* 表格那一份值不在这儿收 —— 人是在工作区的件上改的,由外层缓冲带过来
|
* 表格那一份值不在这儿收 —— 人是在工作区的件上改的,由外层缓冲带过来
|
||||||
* (与验收裁决同一条路子:编辑与提交分在两个区,缓冲得由壳层持有)。
|
* (与验收裁决同一条路子:编辑与提交分在两个区,缓冲得由壳层持有)。
|
||||||
*/
|
*/
|
||||||
/** 执行工单与数据工单是同一套办法、两副面孔 —— 面孔在这儿定,办法只写一份 */
|
|
||||||
type FormFace = {
|
|
||||||
readonly klass: 't-exe' | 't-data';
|
|
||||||
readonly headIcon: 'clipboard-check' | 'database';
|
|
||||||
readonly head: string;
|
|
||||||
readonly docIcon: 'file-text' | 'table';
|
|
||||||
readonly docTitle: string;
|
|
||||||
readonly docHint: string;
|
|
||||||
readonly submit: string;
|
|
||||||
};
|
|
||||||
const EXEC_FACE: FormFace = {
|
|
||||||
klass: 't-exe',
|
|
||||||
headIcon: 'clipboard-check',
|
|
||||||
head: '工单 · 执行',
|
|
||||||
docIcon: 'file-text',
|
|
||||||
docTitle: '执行工单',
|
|
||||||
docHint: '整份清单在工作区,每一格都能就地改;改完回这里提交',
|
|
||||||
submit: '提交',
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* 数据工单(2026-08-27 用户:「工单类型新加入了一类数据工单…照着这个大概的样式为数据工单单独做一个组件」):
|
|
||||||
* 让人**从零建一张主数据表** —— 件上的表就是要建的记录,填齐、保存,回这里确认即入库,活动从「等待数据」唤醒。
|
|
||||||
* 引擎那一侧与执行工单无异(契约 `ExecutionWorkOrder.variant = 'data'`),所以只换面孔:色、图标、四字、按钮字。
|
|
||||||
*/
|
|
||||||
const DATA_FACE: FormFace = {
|
|
||||||
klass: 't-data',
|
|
||||||
headIcon: 'database',
|
|
||||||
head: '工单 · 数据',
|
|
||||||
docIcon: 'table',
|
|
||||||
docTitle: '数据工单',
|
|
||||||
docHint: '件上的表就是要建的记录:每格就地填、保存;填齐后回这里确认,确认即入库',
|
|
||||||
submit: '确认写入',
|
|
||||||
};
|
|
||||||
|
|
||||||
type ExecFormProps = {
|
type ExecFormProps = {
|
||||||
order: Extract<WorkOrder, { mode: 'execution' }>;
|
order: Extract<WorkOrder, { mode: 'execution' }>;
|
||||||
onReplyOrder: (reply: WorkOrderReply) => void;
|
onReplyOrder: (reply: WorkOrderReply) => void;
|
||||||
@@ -1009,6 +986,11 @@ function DataForm(props: ExecFormProps) {
|
|||||||
return <ExecForm {...props} face={DATA_FACE} />;
|
return <ExecForm {...props} face={DATA_FACE} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 验收工单卡 —— 执行工单的第三副面孔(见 ACCEPT_FACE) */
|
||||||
|
function AcceptCheckForm(props: ExecFormProps) {
|
||||||
|
return <ExecForm {...props} face={ACCEPT_FACE} />;
|
||||||
|
}
|
||||||
|
|
||||||
function ExecForm({ order, onReplyOrder, onLocate, orderValues, onField, face = EXEC_FACE }: ExecFormProps & { face?: FormFace }) {
|
function ExecForm({ order, onReplyOrder, onLocate, orderValues, onField, face = EXEC_FACE }: ExecFormProps & { face?: FormFace }) {
|
||||||
const busy = useReplyBusy();
|
const busy = useReplyBusy();
|
||||||
const val = (f: FormField) => orderValues?.[f.key] ?? f.ai ?? '';
|
const val = (f: FormField) => orderValues?.[f.key] ?? f.ai ?? '';
|
||||||
@@ -1051,7 +1033,8 @@ function ExecForm({ order, onReplyOrder, onLocate, orderValues, onField, face =
|
|||||||
</button>
|
</button>
|
||||||
<p className="jc-fc-q">{order.ask}</p>
|
<p className="jc-fc-q">{order.ask}</p>
|
||||||
<Guide steps={order.steps} standard={order.standard} />
|
<Guide steps={order.steps} standard={order.standard} />
|
||||||
{order.basis.length > 0 && <Kv items={order.basis} />}
|
{/* 参考信息不再上卡(2026-08-31 用户定:「派发的工单参考信息这个板块不要显示」)——
|
||||||
|
basis 数据照旧随卡走(工单详情 / 归档件仍有),只是对话区这张卡不摆 */}
|
||||||
{order.fields.map((f) => (
|
{order.fields.map((f) => (
|
||||||
<label key={f.key} className="jc-fc-fld">
|
<label key={f.key} className="jc-fc-fld">
|
||||||
<span>
|
<span>
|
||||||
@@ -1147,6 +1130,8 @@ function AcceptForm({
|
|||||||
});
|
});
|
||||||
const unchecked = checklistMissing(checklist, checked);
|
const unchecked = checklistMissing(checklist, checked);
|
||||||
const passN = order.criteria.filter((c) => c.pass).length;
|
const passN = order.criteria.filter((c) => c.pass).length;
|
||||||
|
/* 不写 = 打得回(老卡照旧);写了 false 才收起那颗钮 —— 见契约 `AcceptanceWorkOrder.redoable` */
|
||||||
|
const redoable = order.redoable !== false;
|
||||||
return (
|
return (
|
||||||
<div className="jc-fc t-acc">
|
<div className="jc-fc t-acc">
|
||||||
<div className="jc-fc-h">
|
<div className="jc-fc-h">
|
||||||
@@ -1230,14 +1215,29 @@ function AcceptForm({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/*
|
||||||
|
* **打不回的卡不摆理由框、也不摆那颗钮**(2026-09-01 用户定:「评审放行与落账确认这一步的
|
||||||
|
* 验收卡不应该有重做的选项,因为这里都没有接入 ai」)。这一步整个由配方跑完,打回之后
|
||||||
|
* 没有模型会读那句意见、件也只读 —— 那颗钮按下去只会把单子推进死胡同。
|
||||||
|
* 有异议的人还有真路可走:别签,去终止这一单重开 —— 这句话得写在他眼前,
|
||||||
|
* 不然收起一颗钮就成了「少了个选项,不知道该怎么办」。
|
||||||
|
*/}
|
||||||
|
{redoable ? (
|
||||||
<textarea
|
<textarea
|
||||||
className="jc-note"
|
className="jc-note"
|
||||||
placeholder="要重做?写明原因(也可以在下面对话里聊)"
|
placeholder="要重做?写明原因(也可以在下面对话里聊)"
|
||||||
value={why}
|
value={why}
|
||||||
onChange={(e) => setWhy(e.target.value)}
|
onChange={(e) => setWhy(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="jc-fc-note">
|
||||||
|
<Ico name="info" s={13} />
|
||||||
|
<span>这一步只落账,退不回上一步 —— 有异议就别确认,去终止这一单重开。</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="jc-fc-ft">
|
<div className="jc-fc-ft">
|
||||||
|
{redoable && (
|
||||||
<Btn
|
<Btn
|
||||||
className="jc-btn jc-btn-bad"
|
className="jc-btn jc-btn-bad"
|
||||||
disabled={why.trim() === ''}
|
disabled={why.trim() === ''}
|
||||||
@@ -1256,6 +1256,7 @@ function AcceptForm({
|
|||||||
>
|
>
|
||||||
重做
|
重做
|
||||||
</Btn>
|
</Btn>
|
||||||
|
)}
|
||||||
<span className="jc-fc-sp" />
|
<span className="jc-fc-sp" />
|
||||||
<Btn
|
<Btn
|
||||||
className="jc-btn jc-btn-ok"
|
className="jc-btn jc-btn-ok"
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* 工单穿哪副面孔 —— 纯函数,零 React(与 `review-face.ts` 同一条理由:**判断要能钉测**)。
|
||||||
|
*
|
||||||
|
* 执行工单一套办法、**三副面孔**(契约 `ExecutionWorkOrder.variant`):
|
||||||
|
* · 不写 → 执行工单:作业指导书 + 件上就地办,「提交」;
|
||||||
|
* · `data` → 数据工单(2026-08-27 用户定):件上的表就是要建的记录,「确认写入」即入库;
|
||||||
|
* · `accept` → 验收工单(2026-08-31 用户定):件只读,人只核对,「确认无误」。
|
||||||
|
*
|
||||||
|
* **`accept` 与 `mode:'acceptance'` 不是一回事**:后者是 `submit` 抬起来的收口验收
|
||||||
|
* (签下去就落账发口,带打回重做那一路,对话区走 `AcceptForm`),这里的三副面孔都只是
|
||||||
|
* 「这一步要你办的事」—— 回传即办结,放行不了任何东西。
|
||||||
|
*/
|
||||||
|
import type { WorkOrderMode } from '@contracts';
|
||||||
|
|
||||||
|
export type FormFace = {
|
||||||
|
readonly klass: 't-exe' | 't-data' | 't-acc';
|
||||||
|
readonly headIcon: 'clipboard-check' | 'database' | 'shield-check';
|
||||||
|
readonly head: string;
|
||||||
|
readonly docIcon: 'file-text' | 'table';
|
||||||
|
readonly docTitle: string;
|
||||||
|
readonly docHint: string;
|
||||||
|
readonly submit: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EXEC_FACE: FormFace = {
|
||||||
|
klass: 't-exe',
|
||||||
|
headIcon: 'clipboard-check',
|
||||||
|
head: '工单 · 执行',
|
||||||
|
docIcon: 'file-text',
|
||||||
|
docTitle: '执行工单',
|
||||||
|
docHint: '整份清单在工作区,每一格都能就地改;改完回这里提交',
|
||||||
|
submit: '提交',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据工单(2026-08-27 用户:「工单类型新加入了一类数据工单…照着这个大概的样式为数据工单单独做一个组件」):
|
||||||
|
* 让人**从零建一张主数据表** —— 件上的表就是要建的记录,填齐、保存,回这里确认即入库,活动从「等待数据」唤醒。
|
||||||
|
*/
|
||||||
|
export const DATA_FACE: FormFace = {
|
||||||
|
klass: 't-data',
|
||||||
|
headIcon: 'database',
|
||||||
|
head: '工单 · 数据',
|
||||||
|
docIcon: 'table',
|
||||||
|
docTitle: '数据工单',
|
||||||
|
docHint: '件上的表就是要建的记录:每格就地填、保存;填齐后回这里确认,确认即入库',
|
||||||
|
submit: '确认写入',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验收工单(2026-08-31 用户定:「就算数据是足的也进入这个数据工单,就派发一个验收工单…
|
||||||
|
* 把芯片卡片上的编辑按钮去掉,只展示不能编辑」):这一步的活已经齐了,件只读,人过目、确认。
|
||||||
|
*/
|
||||||
|
export const ACCEPT_FACE: FormFace = {
|
||||||
|
klass: 't-acc',
|
||||||
|
headIcon: 'shield-check',
|
||||||
|
head: '工单 · 验收',
|
||||||
|
docIcon: 'file-text',
|
||||||
|
docTitle: '验收工单',
|
||||||
|
docHint: '整份内容在工作区,只读不可改;核对无误后回这里确认',
|
||||||
|
submit: '确认无误',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 一张**没办结的执行工单**该穿哪副面孔;收口验收单(mode: 'acceptance')不走这儿 —— 它有自己的裁决表单 */
|
||||||
|
export function faceOf(mode: WorkOrderMode, variant: 'data' | 'accept' | undefined): FormFace | undefined {
|
||||||
|
if (mode !== 'execution') return undefined;
|
||||||
|
if (variant === 'data') return DATA_FACE;
|
||||||
|
if (variant === 'accept') return ACCEPT_FACE;
|
||||||
|
return EXEC_FACE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已办结的、以及没有作业指导书的那张小卡上的三样东西。
|
||||||
|
* **验收工单与收口验收单在这张小卡上同一副样子** —— 人读的都是「验收」,
|
||||||
|
* 至于签下去会不会落账,不是这张小卡要答的问题。
|
||||||
|
*/
|
||||||
|
export function chipOf(
|
||||||
|
mode: WorkOrderMode,
|
||||||
|
variant: 'data' | 'accept' | undefined,
|
||||||
|
): { readonly klass: FormFace['klass']; readonly ico: FormFace['headIcon']; readonly label: string } {
|
||||||
|
const face = mode === 'execution' && variant === 'data' ? DATA_FACE : mode === 'execution' && variant !== 'accept' ? EXEC_FACE : ACCEPT_FACE;
|
||||||
|
return { klass: face.klass, ico: face.headIcon, label: face.head.replace('工单 · ', '') };
|
||||||
|
}
|
||||||
@@ -23,6 +23,32 @@ export type PendingCards = {
|
|||||||
readonly entryLedger?: string;
|
readonly entryLedger?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **这一单还有活等着人办吗** —— 未办结的工单,或未回的决策卡 / 录入卡。
|
||||||
|
*
|
||||||
|
* 与服务端 `humanTool.pendingCards`(没办结工单 + 没作答且没被顶替的对话卡)说的是
|
||||||
|
* 同一句话,两侧各按自己手上的数据算。
|
||||||
|
*
|
||||||
|
* 用处(2026-08-31 用户:「在中央工作区修改了内容点击保存后,为什么会出现『AI 正在
|
||||||
|
* 推进这单』」):件上按保存会走 `afterSave` —— **先重推件、再叫醒任务**
|
||||||
|
* (「保存即铺格」:人选了芯片类型按保存,配方这才有机会把参数行铺出来;不叫醒
|
||||||
|
* 格子永远不出现,2026-08-30 走查在这儿卡死过)。叫醒把 runtime 拨成 `running`,
|
||||||
|
* 而工作区的 `running` 判据排在 `viewHtml` 前面(2026-08-21:正在跑的这一拍要压过
|
||||||
|
* 上一拍摆下的**陈旧**的件)—— 于是人刚填完按下保存,屏上那份**刚重推、含着他刚填
|
||||||
|
* 的值**的件被「AI 正在推进这单」顶掉几秒。
|
||||||
|
* 那条 2026-08-21 的规则前提是「viewHtml 是上一次推的,可能陈旧」,而这条路上件比
|
||||||
|
* `running` 还新,前提不成立;更要紧的是那一拍按「叫了人就等人」的闸
|
||||||
|
* (`turn-runner`,2026-08-27)**根本不问模型**,推进场景写着「看材料、写台账、调工具
|
||||||
|
* 都由它来」——那些事一件没发生,**界面在撒谎**。
|
||||||
|
*
|
||||||
|
* 所以:**有活等着人办时不摆推进场景**(件 / 录入占位页照旧摆,人正要在上面办事);
|
||||||
|
* 没有任何卡在等人、AI 真在埋头算下一拍时,推进场景照旧 —— 那正是 2026-08-21 要的。
|
||||||
|
* 与服务端那条闸是同一条道理的两侧:**有未答的卡,服务端就不问模型,前端就不说 AI 在推进。**
|
||||||
|
*/
|
||||||
|
export function awaitingHuman(input: { readonly openOrders: number; readonly cards: PendingCards }): boolean {
|
||||||
|
return input.openOrders > 0 || input.cards.decision || input.cards.entry;
|
||||||
|
}
|
||||||
|
|
||||||
export function pendingCardsOf(chat: TaskDetail['chat']): PendingCards {
|
export function pendingCardsOf(chat: TaskDetail['chat']): PendingCards {
|
||||||
let decision = false;
|
let decision = false;
|
||||||
let entryLedger: string | undefined;
|
let entryLedger: string | undefined;
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* 启动参数里格与格之间的两件事(2026-08-31 用户定,W05 的原料号 / 候选料号两格两样都有):
|
||||||
|
* · **按分组联动**(按芯片类型互相收窄)—— 引导,三条口径见下;
|
||||||
|
* · **互斥**(不能选到同一颗料)—— 墙,末尾那一个 describe。
|
||||||
|
*
|
||||||
|
* 联动的三条口径由用户当场定死:
|
||||||
|
* ①**组内第一个填上值的那格是锚**,锚自己永远摆全量;锚粘着不让位 —— 把它清空,收窄整组解除
|
||||||
|
* (不把锚顺手交给另一格,否则「清空锚再改主意」当场被另一格锁住);整组都空了才重新认锚。
|
||||||
|
* ②未标类型的老料**列在末尾单独一组**,不是不列。
|
||||||
|
* ③锚改了类型之后,另一格里失配的值**清空**(人自己新增的值不动 —— 它压根不在候选里)。
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { LaunchParam } from '@contracts';
|
||||||
|
import { comboFace, distinctIssues, nextAnchors, staleLinked } from '../internal/launch-link.ts';
|
||||||
|
|
||||||
|
const OPTIONS = [
|
||||||
|
{ value: 'LM2596S-5.0', label: 'LM2596S-5.0', group: '1', groupLabel: 'DC-DC 降压稳压器' },
|
||||||
|
{ value: 'XL2596S-5.0', label: 'XL2596S-5.0', group: '1', groupLabel: 'DC-DC 降压稳压器' },
|
||||||
|
{ value: 'AMS1117-3.3', label: 'AMS1117-3.3', group: '2', groupLabel: 'LDO 线性稳压器' },
|
||||||
|
{ value: 'OLD-001', label: 'OLD-001', group: '' },
|
||||||
|
];
|
||||||
|
const combo = (key: string, label: string): LaunchParam => ({ key, label, type: 'combo', required: true, options: OPTIONS, linkGroup: 'chip-type' });
|
||||||
|
const PARAMS: LaunchParam[] = [combo('originalInternalPn', '原内部料号'), combo('candidateInternalPn', '候选内部料号')];
|
||||||
|
/** 不联动的一格:同一张表上还有别的 combo,不该被卷进来 */
|
||||||
|
const LOOSE: LaunchParam = { key: 'customerCode', label: '客户', type: 'combo', options: [{ value: 'CU-0142', label: 'Itelma' }] };
|
||||||
|
|
||||||
|
const face = (key: string, values: Record<string, string>, anchors: Record<string, string> = {}) =>
|
||||||
|
comboFace(PARAMS.find((p) => p.key === key)!, [...PARAMS, LOOSE], values, anchors);
|
||||||
|
|
||||||
|
describe('nextAnchors —— 谁是锚', () => {
|
||||||
|
it('组内第一个填上值的那格入座;另一格随后填上也不改朝换代', () => {
|
||||||
|
const first = nextAnchors(PARAMS, { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: '' }, {});
|
||||||
|
expect(first).toEqual({ 'chip-type': 'originalInternalPn' });
|
||||||
|
expect(nextAnchors(PARAMS, { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'XL2596S-5.0' }, first)).toEqual(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('先填候选那格,锚就是它(反过来一样)', () => {
|
||||||
|
expect(nextAnchors(PARAMS, { originalInternalPn: '', candidateInternalPn: 'XL2596S-5.0' }, {})).toEqual({ 'chip-type': 'candidateInternalPn' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('**锚粘着**:把锚清空,收窄整组解除 —— 不把锚顺手交给还填着值的另一格', () => {
|
||||||
|
const anchors = { 'chip-type': 'originalInternalPn' };
|
||||||
|
const after = nextAnchors(PARAMS, { originalInternalPn: '', candidateInternalPn: 'XL2596S-5.0' }, anchors);
|
||||||
|
expect(after).toEqual(anchors);
|
||||||
|
// 锚空着 = 没有可收窄的依据:另一格摆全量(四条,分两类 + 未标类型)
|
||||||
|
expect(face('candidateInternalPn', { originalInternalPn: '', candidateInternalPn: 'XL2596S-5.0' }, after).narrow).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('整组都空了才重新认锚 —— 这次谁先填谁当', () => {
|
||||||
|
const anchors = { 'chip-type': 'originalInternalPn' };
|
||||||
|
const cleared = nextAnchors(PARAMS, { originalInternalPn: '', candidateInternalPn: '' }, anchors);
|
||||||
|
expect(cleared).toEqual({});
|
||||||
|
expect(nextAnchors(PARAMS, { originalInternalPn: '', candidateInternalPn: 'AMS1117-3.3' }, cleared)).toEqual({ 'chip-type': 'candidateInternalPn' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('comboFace —— 这一格摆哪些候选', () => {
|
||||||
|
it('没有锚:全量,按类型分组,**未标类型殿后**', () => {
|
||||||
|
const f = face('originalInternalPn', {});
|
||||||
|
expect(f.narrow).toBeUndefined();
|
||||||
|
expect(f.groups.map((g) => [g.label, g.options.map((o) => o.value)])).toEqual([
|
||||||
|
['DC-DC 降压稳压器', ['LM2596S-5.0', 'XL2596S-5.0']],
|
||||||
|
['LDO 线性稳压器', ['AMS1117-3.3']],
|
||||||
|
['未标类型', ['OLD-001']],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('锚自己永远摆全量 —— 人想换类型,回去改锚就行', () => {
|
||||||
|
const anchors = { 'chip-type': 'originalInternalPn' };
|
||||||
|
const f = face('originalInternalPn', { originalInternalPn: 'LM2596S-5.0' }, anchors);
|
||||||
|
expect(f.narrow).toBeUndefined();
|
||||||
|
expect(f.groups.flatMap((g) => g.options.map((o) => o.value))).toEqual(['LM2596S-5.0', 'XL2596S-5.0', 'AMS1117-3.3', 'OLD-001']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('另一格按锚的类型收窄:同类型在前、未标类型殿后;点名按谁的什么类型收的', () => {
|
||||||
|
const f = face('candidateInternalPn', { originalInternalPn: 'LM2596S-5.0' }, { 'chip-type': 'originalInternalPn' });
|
||||||
|
expect(f.narrow).toEqual({ by: '原内部料号', label: 'DC-DC 降压稳压器' });
|
||||||
|
expect(f.groups.map((g) => [g.label, g.options.map((o) => o.value)])).toEqual([
|
||||||
|
['DC-DC 降压稳压器', ['LM2596S-5.0', 'XL2596S-5.0']],
|
||||||
|
['未标类型', ['OLD-001']],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('**锚本身没有类型**(未标类型的老料)→ 另一格摆全部(用户原话)', () => {
|
||||||
|
const f = face('candidateInternalPn', { originalInternalPn: 'OLD-001' }, { 'chip-type': 'originalInternalPn' });
|
||||||
|
expect(f.narrow).toBeUndefined();
|
||||||
|
expect(f.groups.flatMap((g) => g.options)).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('**锚填的是新增料号**(库里没有这个值)→ 收窄无从谈起,另一格摆全部', () => {
|
||||||
|
const f = face('candidateInternalPn', { originalInternalPn: 'BRAND-NEW-PN' }, { 'chip-type': 'originalInternalPn' });
|
||||||
|
expect(f.narrow).toBeUndefined();
|
||||||
|
expect(f.groups.flatMap((g) => g.options)).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('类型只有键没有名(字典读不到)→ 分组标题退回摆键本身,不编一个名字', () => {
|
||||||
|
const bare: LaunchParam = { ...PARAMS[1]!, options: [{ value: 'A', label: 'A', group: '7' }] };
|
||||||
|
const f = comboFace(bare, [PARAMS[0]!, bare], {}, {});
|
||||||
|
expect(f.groups.map((g) => g.label)).toEqual(['7']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不联动的 combo(没有 linkGroup / 候选不带分组键)→ 一组无标题,谁也不管它', () => {
|
||||||
|
const f = comboFace(LOOSE, [...PARAMS, LOOSE], { originalInternalPn: 'LM2596S-5.0' }, { 'chip-type': 'originalInternalPn' });
|
||||||
|
expect(f.narrow).toBeUndefined();
|
||||||
|
expect(f.groups).toEqual([{ label: undefined, options: LOOSE.options }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('staleLinked —— 锚换了类型,另一格里失配的值', () => {
|
||||||
|
it('在全量候选里、却不在收窄后的候选里 → 点名清空', () => {
|
||||||
|
expect(staleLinked(PARAMS, { originalInternalPn: 'AMS1117-3.3', candidateInternalPn: 'XL2596S-5.0' }, { 'chip-type': 'originalInternalPn' })).toEqual(['candidateInternalPn']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('同类型的、未标类型的、人自己新增的、空的 —— 一个都不动', () => {
|
||||||
|
const anchors = { 'chip-type': 'originalInternalPn' };
|
||||||
|
const keep = (candidate: string) => staleLinked(PARAMS, { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: candidate }, anchors);
|
||||||
|
expect(keep('XL2596S-5.0')).toEqual([]);
|
||||||
|
expect(keep('OLD-001')).toEqual([]);
|
||||||
|
// 新增的值压根不在候选里 —— 它是人自己写的新料号,不是失配的旧选择
|
||||||
|
expect(keep('BRAND-NEW-PN')).toEqual([]);
|
||||||
|
expect(keep('')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('锚自己不算失配(它摆的就是全量)', () => {
|
||||||
|
expect(staleLinked(PARAMS, { originalInternalPn: 'AMS1117-3.3', candidateInternalPn: '' }, { 'chip-type': 'originalInternalPn' })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 互斥(2026-08-31 用户定:「不应该能选到同样的两个芯片」)—— 与联动是两件事:
|
||||||
|
* 那个是引导(收窄),**这个是墙**(别的格占了的候选这里不列;自己写一个同样的就按不下启动)。
|
||||||
|
* W05 那两格**两样都声明了**,所以最后一条专测合起来是什么样。
|
||||||
|
*/
|
||||||
|
describe('互斥:别的格占走的候选不列', () => {
|
||||||
|
const both = (key: string, label: string): LaunchParam => ({ ...combo(key, label), distinctGroup: 'part' });
|
||||||
|
const W05_PARAMS: LaunchParam[] = [both('originalInternalPn', '原内部料号'), both('candidateInternalPn', '候选内部料号')];
|
||||||
|
const w05Face = (key: string, values: Record<string, string>, anchors: Record<string, string> = {}) =>
|
||||||
|
comboFace(W05_PARAMS.find((p) => p.key === key)!, [...W05_PARAMS, LOOSE], values, anchors);
|
||||||
|
|
||||||
|
it('原料号选了 LM2596S-5.0 → 候选那格不再列它,并说清是谁占了', () => {
|
||||||
|
const f = w05Face('candidateInternalPn', { originalInternalPn: 'LM2596S-5.0' });
|
||||||
|
expect(f.groups.flatMap((g) => g.options.map((o) => o.value))).toEqual(['XL2596S-5.0', 'AMS1117-3.3', 'OLD-001']);
|
||||||
|
expect(f.taken).toEqual([{ by: '原内部料号', value: 'LM2596S-5.0' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('**反过来也一样**(不分先后,谁占了谁的都不列)—— 候选先填,原料号那格就少它一条', () => {
|
||||||
|
const f = w05Face('originalInternalPn', { candidateInternalPn: 'XL2596S-5.0' });
|
||||||
|
expect(f.groups.flatMap((g) => g.options.map((o) => o.value))).toEqual(['LM2596S-5.0', 'AMS1117-3.3', 'OLD-001']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('**自己此刻填着的那个永远留着** —— 抽掉正在显示的值,下拉当场变空白,人看着像被谁清空了', () => {
|
||||||
|
const f = w05Face('candidateInternalPn', { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'LM2596S-5.0' });
|
||||||
|
expect(f.groups.flatMap((g) => g.options.map((o) => o.value))).toContain('LM2596S-5.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不在互斥组里的格子(没有 distinctGroup)谁也不管', () => {
|
||||||
|
const f = comboFace(LOOSE, [...W05_PARAMS, LOOSE], { originalInternalPn: 'LM2596S-5.0', customerCode: 'CU-0142' }, {});
|
||||||
|
expect(f.taken).toEqual([]);
|
||||||
|
expect(f.groups).toEqual([{ label: undefined, options: LOOSE.options }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('**联动与互斥合起来**(W05 的真形状):按锚的类型收窄之后,锚占着的那颗照样不列', () => {
|
||||||
|
const f = w05Face('candidateInternalPn', { originalInternalPn: 'LM2596S-5.0' }, { 'chip-type': 'originalInternalPn' });
|
||||||
|
expect(f.narrow).toEqual({ by: '原内部料号', label: 'DC-DC 降压稳压器' });
|
||||||
|
// 收窄留下 LM / XL / OLD-001,互斥再抽掉锚占着的 LM
|
||||||
|
expect(f.groups.map((g) => [g.label, g.options.map((o) => o.value)])).toEqual([
|
||||||
|
['DC-DC 降压稳压器', ['XL2596S-5.0']],
|
||||||
|
['未标类型', ['OLD-001']],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('distinctIssues —— 还能撞上的那条路(自己「新增」写了一个同样的)', () => {
|
||||||
|
it('点名**后填的那格**,说清撞上了谁', () => {
|
||||||
|
expect(distinctIssues(W05_PARAMS, { originalInternalPn: 'BRAND-NEW', candidateInternalPn: 'BRAND-NEW' })).toEqual([
|
||||||
|
{ key: 'candidateInternalPn', by: '原内部料号' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('去空白、不分大小写 —— 与服务端同一把尺', () => {
|
||||||
|
expect(distinctIssues(W05_PARAMS, { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: ' lm2596s-5.0 ' })).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('两颗不同的、空的、不同组的 —— 一个都不算撞上', () => {
|
||||||
|
expect(distinctIssues(W05_PARAMS, { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'XL2596S-5.0' })).toEqual([]);
|
||||||
|
expect(distinctIssues(W05_PARAMS, { originalInternalPn: '', candidateInternalPn: '' })).toEqual([]);
|
||||||
|
expect(distinctIssues([...W05_PARAMS, LOOSE], { originalInternalPn: 'CU-0142', customerCode: 'CU-0142' })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user