Compare commits

..
Author SHA1 Message Date
xcsandClaude Fable 5 7ed997187e fix(engine): 确认闸条件档在发卡装配被丢 —— 类型不一致的卡从前点得动
CI / ci (push) Failing after 11s
活动声明了 gateWhen,发卡装配的手写清单没点它的名:落库载荷 undefined,
按钮禁用与服务端拒收一起无声失效(手写字段清单第四次丢值,与 variant
上次被丢的同一段)。抽 cardConditionGate 校验着转交(5 测);e2e w05
数据卡阶段补末端断言 expectCardGateWhen。活体验证:载荷带条件 → 闸关 →
API 强行确认 400;e2e 案例 0 全绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 11:09:48 +08:00
xcsandClaude Fable 5 0e02bb9411 fix(task): 终态收尾不等蒸馏 —— 终止接口曾被模型调用吊 13-45 秒到报错
CI / ci (push) Failing after 10s
四单法证:finished_at 与蒸馏条目 created_at 差 13.2/36.3/43.4/44.6 秒。
finish 把终态落库并广播之后同步 await memory.distill(一次真模型调用),
终止路由与放行收尾拍(连带推进器租约)都陪着模型等。改后台跑:
封卷仍在前,失败照旧吞掉不掀终态;三测钉死,改前即 30s 超时。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 03:36:53 +08:00
xcsandClaude Fable 5 3b9c559a47 fix(w05): 落账席那枚印章不再压住旁边的结论 —— 徽章按圈的内径收
用户:「评审放行与落账确认,中间这个有条件推荐这个 svg 挡住旁边的字了」。

在浏览器里量的账:徽章那一坨 .seal-core 139px 宽,而印章圈定死 112px —— 溢出 26px;
右边那句一句话结论从 603px 起,正好被压住。病根是徽章按自己的字宽撑开、又带着
white-space:nowrap 缩不了,而圈是定死的圆(place-items:center 只让它对称地溢出去)。

两条一起治:核心不许超过圈的内径(max-width:calc(100% - 16px));圈里那一枚字号收小、
去掉字距、允许折行(以后有更长的等级名就两行,圆里两行照样是圆)。
改完再量:核心 94px、圈内还余 9px、「有条件推荐」一行放得下、结论那段完全露出来。

验收法记一笔:视觉的账在**真 DOM** 上量 —— 离线渲一份真件挂本地服务,
浏览器里 getBoundingClientRect 前后各取一次,不在脑子里推。包内 +1 测(变异验证过)。
2.24.0 已装库。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 03:06:42 +08:00
xcsandClaude Fable 5 4174c5f194 fix(w05): 调人预算提到 24;「一条都没点到」的打回不再整表重判
用户:「怎么直接最后落帐的步骤直接没了?」—— 那一单终态是 exhausted:结论审核连打回四次,
每打回一次配方就重发一张审核卡、每张 +1 humanCalls,第八次触顶,放行那一步再没机会出现。
屏上看着像「那一步没了」,其实是单子先被烧死了。

① 预算 8 → 24(用户当场定「那你就把预算提高」):常路只要 3 次(补数据 / 审核 / 放行),
   而审核每打回一次就多叫一次人。授权面与资源面同一个数;测里改成「两处一致 + 下限 12」,
   不再钉死某个具体数字。**这是配置不是机制** —— 机制上的正解是「人自己打回引出的那张卡
   不算打扰」(他不是被打扰,是他叫的),已记 debt。

② 「一条都没点到 → 整表算被点名」当天上线、当天撤掉:他那句是「收口结论里面的推荐理由
   改为非常不推荐」—— 说的根本不是某条参数,是收口那四格(本来就能在件上直接改)。
   整表重判的下场:AI 拿这句话把 5 条参数的结论改成「不匹配」,每条依据都写着
   「按审核人意见调整为不匹配:推荐理由改为非常不推荐」——一句与它们无关的话,改了五行。
   现在点不到名就一条都不改,并照实说清怎么才办得成(点参数名 / 收口四格在件上改)。
   重判与写回同批加「真点到了名」的前提,免得白烧一次模型。

验收:e2e w05 5/5 released、被拒 0,案例 0 四条断言全绿;2.23.0 装库核过(humanCalls=24)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 02:55:05 +08:00
xcsandClaude Fable 5 3c563525f1 fix(engine): 那面「打不回」的旗在容器里被扔了 —— 手写字段清单第三次丢值,改成整包转交
用户:「评审放行与落账确认这一步的验收卡怎么还有重做??你没改上吗?」——
库里的卡确实没有那面旗:装配层算对了、release 转对了、契约也声明了,而**容器那个显式对象**
只转它点到名的那几样,`redoable` 没被点名就在这最后一关静默落地(编译不报、运行时无声)。

同一处已经吃过三次:①fields 写死成 [](验收报告只能看不能改)②blocks 漏一行(卡面块恒空)
③这次。那几行旁边就写着两条「少写一行就静默落地」的警告 —— 措辞治不了的结构治:
context 三样拆出去、**其余整包转交**,以后端口新增可选字段不改这里也到得了。
断言钉在最末端:试跑观测面的卡带上 redoable,e2e 断言「一路到了卡上」。

同批修另一种时序的卡死(同一夜第二次,停掉了一整个 e2e 案例):
**决策卡的回传不发事件、只调 wake**,而 wake 只翻停着的单子 —— 卡刚发出那一刻这一拍还在
running,它空转了一次;随后这一拍把任务 park 回 awaiting,再没有第三样东西会来叫
(实测:卡 02:06:13.250 发出、219ms 后被答,这一拍 .636 才收尾,停到 20 分钟超时)。
修:配方发卡收尾前再看一眼刚发的那张卡还在不在等 —— 已经答了就照实说「还在跑」;
只认这一张卡,别的「等外部回音」照旧是等,不会空转成热循环。
假端口同批改老实:发出去的卡默认就在等(从前发完卡还回「一张待答的都没有」)。

验收:e2e w05 5/5 released、被拒 0,案例 0 四条断言全绿(不摆重做 / 对话区说了话 /
点名要的结论落下去了 / 只动被点名的行);两处变异验证;契约变更 api-report 已刷新。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 02:36:53 +08:00
xcsandClaude Fable 5 e474275b28 fix(w05): 复核轮审核人说了算,并把这一轮改了什么说进对话区
用户第二次实测:「重做还是没有修改掉内容」—— 他写「静态电流结论改成不匹配」,而两颗料的
静态电流都是 5mA,AI 搬出第一轮那份规矩里的第 3 条(硬约束不因措辞变成 match)把他顶了回去:
evidence_note 白纸黑字「维持上一轮 match…硬规矩要求按实际值判定,不能因措辞改变结论」。

**行的范围这次是对的**(只把那一条交给 AI、只写回那一条),错在**谁说了算**。三处想岔了:
① 那条规矩管的是它自己判的时候别被措辞带偏,不是用来否决审核人的;
② 方向也反了 —— 它守的是「不许把不满足说成满足」(危险方向),而他要的是收紧结论;
③ 更根本:审核人在件上本来就能亲手改任何一条四态,AI 在重判这条路上顶回去,
   等于同一件事在两条路上给出相反的答案。
改:复核指令明写这条覆盖 —— 他点名要什么结论就落什么,不要顶回去;落的同时 evidence_note
记两句「按谁的意见」+「数据面是什么」,下一个人回看能一眼分清数据判的还是人定的。

同批(用户接着提:「需要把 ai 的修改意见通过右边 ai 聊天窗口展示出来,不然都不知道发生了什么」):
复核完在对话区说一句「按你的意见复核了 N 条 —— <参数名>:<判断依据>」,摆的是件上那一行
自己的话,不另编一句。**它必须排在卡之前** —— 发了卡这一拍就收了,排在卡后面的 say 要等
下一拍才跑:原来那句「差异结论已送审,请核对」实测正是在人答完之后才出现的;而且它与卡自己的
ask 本来就是同一件事,一并退役。

验收:e2e w05 5/5 released、被拒 0;案例 0 三条断言全绿 ——
「复核那一轮在对话区说了话」「打回点名要的结论真落下去了(使能阈值 = conditional)」
「打回只动了被点名的那几条」。包内 +2 测。W05@2.21.0 已装库。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 01:57:52 +08:00
xcsandClaude Fable 5 cec19adc85 fix(engine): 打不回的验收卡不摆那颗钮 —— 全 auto 的放行步没有模型能接住那句意见
用户:「评审放行与落账确认这一步的验收卡不应该有重做的选项,因为这里都没有接入 ai」。

W05 的 T_release 整个由配方跑完(guide 是空串,不问模型),件又是只读的 ——
打回之后没有任何东西能接住那句意见:配方只会原样再提交一次,撞在「打回之后台账没动过
就不许再提交」那道闸上,连撞两拍落成一张「连续撞在同一条判据上」的兜底卡。
一颗按下去只会把单子推进死胡同的钮,不如不摆(墙要配路;没有路就别立那扇门)。

· 契约:AcceptanceWorkOrder.redoable(不写 = 打得回,老卡老行为);
· 装配层按放行那一步的声明现算 —— releaseRedoable 纯函数(有 auto 且 guide 空 → 打不回;
  半 auto 与模型驱动照旧打得回,那儿真有模型会读意见),与 decisionCardFace /
  executionCardVariant 同一种做法、同一个测试位;
· 卡面收起理由框与那颗钮,换一句真路:「这一步只落账,退不回上一步 —— 有异议就别确认,
  去终止这一单重开」;
· **服务端才是门**:redoable:false 的卡收到非 pass 的回传当场拒(redo-unavailable),
  直调 API 绕不过;拒回把真路指出来。

S3 结论审核那张审核卡不受影响 —— 它的重做真会带着意见重判(昨夜那一轮)。
测:card-rules 3 条 + releaseRedoable 4 条,两处变异验证;e2e w05 5/5 released、被拒 0。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 01:34:16 +08:00
xcsandClaude Fable 5 8314148ebc fix(w05): 重判的行由确定的一侧圈 —— 打回点名工作温度,不许去改静态电流
用户实测:「我打回让修改工作温度的结论,给我改了静态电流的结论」。库里的证据一字不差:
工作温度它没改(还给了理由:候选值 1℃ 远窄于 -40~+85℃,硬约束不满足 —— 这半截是对的),
而没人问过的静态电流被它从 soft_mismatch 翻成 match,evidence_note 里却写着「维持原判」。

根因不是提示词不够严,是**整表交出去、整表写回来**:没有任何东西限制它只动被点名的那一行,
而「我只改了那一条」这句话本身也出自它嘴里。规范 §4.6 纪律一(行由确定的那一侧决定,
不由 AI 决定)—— 昨天刚加的重判轮自己栽在这条上。

S3 复核轮两笔变四笔(全部只在 case_redo_note != "" 那一轮跑):
① 圈范围:按字面点名(contains,与基座给模型摆的「意见点名对上了哪几行」同一种认法)标
   named / kept(明细表新增 redo_scope 列);
② 兜底:一条都没点到(意见说的是整批)→ 整表算被点名,读上一笔刚写下的 tally,不重算;
③ 重判只喂 named 的行 —— 没点名的行 AI 连见都见不到,自然改不着(也省 token);
④ 写回也只写这几行;模型漏答某一条时 else 兜回它上一轮的值。

同批修一条挡了两次路的老毛病(昨夜刚记 debt,今晚又撞停、还卡住了我自己的验证):
人答得比发卡那一拍收尾还快时,回传被判 stale、wake 又被那一拍的 park 覆盖,单子停在
awaiting-event 再没人叫醒。deliver 判了 stale 照旧不跑这一拍,但把停着的单子拨回 running。

验收:e2e w05 5/5 released、被拒 0;案例 0 新断言「打回只动了被点名的那几条,其余行一个字没变」
真跑通过(途中逼出脚本自己的坑:放行会清空草稿层,快照得留在放行之前);
模型调用 案例 0 = 2 次、其余 = 1 次。四处变异验证。W05@2.18.0 已装库。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 01:17:09 +08:00
xcsandClaude Fable 5 4d2c3657f9 feat(w05): 重做的意见交回给 AI 重跑那一步 —— 不再只是把派生列重算一遍
用户:「唯一调用 ai 的这一步,使用重做后是否没有将重做原因带回去重新让 ai 重跑这一步?」
确实没有:S3 那一轮只跑三笔纯投影重算,人写的那句话除了留痕之外无人读 ——
AI 判过的四态一个字不会变,除非他自己在件上逐格改。

平台:引擎把「人这次打回写了什么」注给配方(`case_redo_note`)。
· 下划线不是 case: 前缀 —— S-FEEL 的名字不许带冒号,而它最要紧的用法就是当 when 条件用;
· **没打回也永远绑着空串**:未绑定的名字 `!= ""` 求值为真,不绑就等于那几笔每一轮都跑
  (拿真 @sfeel 钉了这条,桩也照真语义写,否则钉不住);
· 静态审认这个名字 —— 它当场拦下过一次未登记的引用,这道门做对了事。

W05@2.17.0:S3 多两笔,都钉在 `case_redo_note != ""` 上 ——
带意见重判(入参多带上一轮四态/影响/依据/UNKNOWN 去向 + 他那句话;判法与第一轮同一份
ASSESS_RULES,后接复核纪律:没被点到、也确实没判错的行原样带回,别趁重判把人改过的冲掉)
+ 判完落回明细(与第一轮同一笔投影,只多一个 when)。判法、出参形状、落明细抽成三个
共享常量,S2 与 S3 共用 —— 各写一份早晚分叉。

验收:e2e w05 5/5 released、被拒 0;案例 0(先点名驳回、再通过)模型调用恰 2 次,
其余四例仍是 1 次 —— 没被打回就不多花一次模型。auto-runner 3 测(含变异验证)、包内 +1。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 00:33:23 +08:00
xcsandClaude Fable 5 f5435f4c9c fix(engine): 人点「重做」不是「办完了」—— 打回即重开一轮,不再一路推到放行
用户实测:「我重做后怎么直接推进了?不应该是重做刚才那一步吗?」那一单的轨迹逐条对得上:
审核卡发出 → 人带意见交回 →「这一步已经发过卡,不再发第二张」→「自动收步:S3_review」
→ 下一拍发出落账放行的验收卡。意见落进了轨迹(还被 note-anchors 逐行对上了),
而再没有任何东西会去读它。

这条缺陷 2026-08-30 全分支终审已记录成 Important 2、并留着一条坐实病灶的测试,
连「半个修法会把单子卡死」都预判对了。照那个方向修完,两半缺一不可:
① 收步那道门认答复(answerSettlesStep:custom:(人没选任何选项、写了自己的话)与
   约定的 reject 都不算办完;空答复照旧算「照原样确认」);
② 同一句判断也让 carded 让位 —— 幂等挡的是「同一问被重复发出去」,而人点重做正是说
   「这一轮不算,改了再问我一遍」:配方重跑(该重算的重算)再发一张,日志里说一句
   「人把这一步打回重做:<意见>」。

测:坐实那条改写成钉死修复后的行为(历史诊断原样留着);新增「重做但发不出新卡也不收步」
(这一条专管收步门那一半)与「通过照旧收步」;3 条纯函数测。两半各自变异验证。
e2e w05 5/5 released —— 案例 0 现在真的发出两张审核卡(先带意见驳回、再确认通过)。

顺带撞见一条独立的老毛病,已记 debt 未修:人答得比发卡那一拍收尾还快时,回传被判 stale、
wake 又被那一拍的 park 覆盖,单子停在 awaiting-event 再没人叫醒(手工拨回 running 立刻走完)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 00:03:33 +08:00
xcsandClaude Fable 5 445aa5ef22 fix(w05): 验收工单的面孔在装配层被丢掉 —— 两头都有测,中间那一段把值扔了
用户实测:「你怎么给我发成了执行工单,我需要的是验收工单,然后标题那一块需要是
验收卡的那种绿色」。

一、面孔:engines.ts 发执行工单那一处是一句写死只认 'data' 的三目,配方递下来的
'accept' 静默丢掉 —— 对话区照旧摆「执行工单」。**配方那一头有测(variant 原样递出去)、
前端那一头有测(accept 该穿哪副面孔)、e2e 按标题驱动也全绿,唯独中间没有。**
修:抽成纯函数 executionCardVariant(与旁边 decisionCardFace 同一种做法、同一个测试位),
认 data / accept、拒 review、认不出的当没写;4 测变异验证过。
e2e 同批改**认面孔不认标题**(试跑观测面的 cards 补 variant,契约变更)——
标题是活动自己写的字符串,卡穿哪副面孔由装配层决定,那一次正是两者对不上。

二、颜色:件① 验收工单那一副整套强调色换成验收绿 #0aa294(令牌从 ACCENT_LEDGER
现改选择器得来,与件②③、对话区验收卡一处真源),题头那条写死的浅蓝渐变单独给一条绿的;
数据工单那一副仍是蓝。真渲染核过两副 class 与两套令牌。

验收:e2e w05 5/5 released、被拒 0(面孔断言这一轮真在跑);2563 测、四门禁绿;
2.16.0 装库。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 23:26:09 +08:00
xcsandClaude Fable 5 ce72da77eb feat(w05): 数据齐了也叫人一次 —— S1 改派只读的验收工单,件同一份、编辑钮收起
用户:「就算数据是足的也进入这个数据工单,就派发一个验收工单,界面和数据要和现在的
第一个数据工单一致,只不过把芯片卡片上的编辑按钮去掉,只展示不能编辑,然后把标题旁边的
数据工单改成验收工单」。

病根在引擎拍首那道 doneWhen 扫描:输入本来就齐的单,S1 的 doneWhen 第一拍就为真,
配方一笔都还没跑,这一步当场被收掉 —— 卡永远发不出去,人一眼没看过就进了 S2。
治法:CMMN 给 S1 标 manualActivation,那道扫描多一个门槛(worldTouched),次序变成
「配方发卡 → 人答 → 下一拍 doneWhen 为真才收」;doneWhen 仍是真门,只是不抢在人前面。

平台:执行工单第三副面孔 variant:'accept'(件只读、人只确认「确认无误」)——
与 mode:'acceptance'(submit 抬起来的收口验收,带打回重做)分清;三副面孔的判断
抽成纯函数 order-face.ts(有测),chat-bubbles 只摆。
活动:S1 两笔互斥的 card(carded 幂等保证一步只发一张)+ 案卷粘着的旗 order_kind;
一张件两副面孔 —— 题头/标题/页脚三处名字与卡角编辑钮按 v-data / v-accept 由样式表挑
(藏的是容器 .fabs,逐枚去盖打不完)。

测:引擎测钉死那一对(标了人工激活 → 卡发得出去、步不收;没标 → 步当场被收、卡发不出);
auto-runner variant 透传;order-face 4 条;包内 2 条(两笔卡 + 粘旗)、view 1 条;
两处变异验证。e2e w05 5/5 released、被拒 0、模型调用恰 1 次 —— 都在库那例摆的正是验收工单。
2558 测、四门禁绿;2.15.0 装库核过。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 22:44:00 +08:00
xcsandClaude Fable 5 e47ce92702 fix(w05): 两颗料不能是同一颗 —— 起单这一刻立墙,因为路只在这里
用户:「创建活动的启动参数里面不应该能选到同样的两个芯片」。
查下来这不只是没意义,是**把单子做死**:sub_eval.part 的主键是 eval_ref+internal_pn
(role 不在主键里),S1 配方铸的两行撞成同一个主键、后一笔覆盖前一笔,两行收成一行 ——
sub_eval_part_exists_status_found = 2 永不成立,S1 收不了步,而件上那两行人加不出来
(行源 mine:,没有「+加一行」),唯一出路是终止重开。与「两格留空」是同一条链。

平台加互斥声明 x-distinct(契约 distinctGroup)——与 x-link 正相反:那个是引导,这个是墙。
· 前端:别的格占着的候选这一格不列(自己填着的那个永远留着,否则 select 当场变空白),
  并说清是谁占了哪个;走「新增」自己写一个同样的 → 小红字 + 启动按不下去;
· 服务端 validateLaunchParams 独立拦(脚本 / API 绕不过),400 点名后填的那格;
· 「算不算同一个」= 去空白 + 不分大小写(launchDistinctKey,前后端同一把尺)——
  只挡精确相等的话 lm2596s-5.0 照样进去,混一颗垃圾主数据出来。
W05 两格挂 x-distinct: 'part',2.14.0 已装库核过。

测:服务端 6 条 + 前端 8 条(含联动与互斥合起来的真形状)+ 包内 1 条,三处变异验证过;
四道门禁绿(2548 测)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 22:03:56 +08:00
xcsandClaude Fable 5 8c5f3a7736 feat(activity-ops): 启动表单两格按芯片类型联动 —— 候选带分组键,先填的那格是锚
CI / ci (push) Failing after 10s
契约:launchOption 加 group/groupLabel(x-options.group 从字典表现读人读名)、
launchParam 加 linkGroup(x-link 结组);api-report 已随 --update 入库。
前端 launch-link.ts 纯函数管三条口径:锚摆全量、其余按锚的分组键收窄
(未标类型殿后一组,新增照走);锚粘着 —— 清空只解除收窄不换锚,人改得了主意。
服务端不据此立墙:combo 本就收候选之外的值,类型一致归 S1 收步判据
sub_eval_part_distinct_chip_type_id = 1(数得出来的门)。
W05 清单两格挂上 x-link: chip-type;文档同步。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 18:57:29 +08:00
xcsandClaude Fable 5 04ec718a66 docs(w05): gate_state 开关列入快照
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 18:05:54 +08:00
xcsandClaude Fable 5 228739eaca fix(w05): 都齐了红条整条收起 —— 开关是案卷真数据 gate_state,不看那句话空不空
2026-08-31 用户:「没有必填字段不需要警告了,还差什么这个红色区域就不要显示」。
病根在一层机制之下:配方把 gate_reason 清成空串时,**空值按列归型不落笔**
(jsonb 合并保旧值)—— 那句话一旦写下就永远清不掉,红条亮过就灭不了。
治法:案卷加开关列 gate_state(tally open/clear,两个值都非空、两个方向都真写),
与发卡条件共用同一句判定(GATE_OPEN_WHEN 一处真源);红条挂 data-class-of=gate_state,
v-clear 整条收起(判断留在样式表);人补齐最后一格保存,quiet 拍照跑 project、
重推件,红条当场消失。空文本 :has(:empty) 兜底保留(首拍还没写过的形)。
107 包内(开关双向 + 一处真源两锚,else 侧变异红过)+ 定向 1109 测;
2.13.0 装库核过(开关/CSS/else=clear)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 18:05:53 +08:00
xcsandClaude Fable 5 9412dd7aa8 docs(w05): 互换动画第二稿入快照
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 17:53:31 +08:00
xcsandClaude Fable 5 68faeb4f50 fix(w05): 互换动画提到与卡同级 —— 三列网格中间留实体动画道,卡片相应收窄
2026-08-31 用户:「不要把这个动画做到背景板上,会被卡片覆盖;和卡片做到一级,
可以压缩一点卡片的大小」—— 上一稿的对向穿行点走在卡后,只在缝里闪现,基本看不见。
.dossiers 改三列网格(minmax(0,1fr) 76px minmax(0,1fr)):卡片各占左右列自然收窄,
中间 76px 是**谁也盖不住的实体动画道** —— ⇄ 徽居中轻呼吸,两枚信号点绕徽轨道对转
(此上彼下绕圈 = 两颗料换位),底下的虚线总线在道里露出来把两张卡接起来。
窄屏(<620px)卡片竖排,动画道随之收起。浏览器两帧相位实证在转;
105 包内 + 2531 全仓;2.12.1 装库核过(orbit/lane)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 17:53:31 +08:00
xcsandClaude Fable 5 1de4ab9862 docs(w05): 第六轮入快照 —— gateWhen 条件档 / 量高可缩 / 互换动画
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 17:44:03 +08:00
xcsandClaude Fable 5 c4219a7b7d feat(w05+platform): 确认闸条件档 gateWhen —— 类型不一致不许确认;量高改可缩;卡间互换动画
2026-08-31 用户三连:

① 「提示两颗料不是同一个芯片类型…就不应该能写入并继续下一步」——
  缺格闸只数必填空不空,管不了跨行一致性。平台加**确认闸条件档**:配方 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 声明。e2e 案例 2 的空手探针从此吃的就是这道闸。

② 「页面底部没东西还能向下滑一截」—— ViewContainer 量高从
  max(documentElement.scrollHeight, body) 改 body 优先:documentElement 以 iframe 自身
  高度为下限,内容缩回后它缩不回去,量高**只涨不缩**(参数编辑态把卡背拉长过一次,
  底部从此留一截空白滚动)。body(margin 0)才是内容自己的高。

③ 「两张卡片中间做出可替换的动画」—— 单点单向信号换成**对向双信号点**(左右互穿,
  走在卡后、只在缝里现身)+ 中央 ⇄ 徽轻呼吸 —— 这界面是判「可否替换」的,动画说的
  就是这件事。reduced-motion 全静。

测:契约 gateWhen 解析 + runner 透传(变异红)+ conditionGateOf 3 例(含坏表达式不成墙)
+ steps/activity 锚;2531 全仓四门绿;2.12.0 装库核过(gateWhen 入库/swapfx);
:8787 已重建重启。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 17:43:08 +08:00
xcsandClaude Fable 5 7515885afa docs(w05): 第五轮入快照 —— scopedTargets 按卡编辑 / 顶条合并点名
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 17:01:40 +08:00
xcsandClaude Fable 5 26db6c685b feat(w05): 按卡独立编辑 + 缺项点名并进顶条 + 区序换回 + 类型层叠图
2026-08-31 用户四连:
① 「各自编辑各自的部分,不要做联动」—— 平台新增 scopedTargets(ui-kit):开合作用域
  跟着触发钮走,三档 —— 钮在本地址宿主的行里→只这一行(A 卡的笔只开 A 卡);钮在别的
  宿主的行里→行内属于本地址的宿主(这张卡的卡背);外部闸行→全宿主(老行为,② 的闸
  不受影响)。收值与保存不随作用域收窄(collect 仍按地址全量,「只写真改了的行」在
  服务端把关)。编辑态/锁面/笔盘显隐 CSS 全部改成按卡(.dossier.on);同地址同时只开
  一张卡(笔在别卡编辑中自动隐去,快照键按地址,双开会互相踩)。2 测 + 变异验证。
② 逐块「还差 N 行必填」红带撤下,点名并进顶部「还差什么」—— 同一批基座读数
  (_missing/_missing_keys)在顶条出两行子行,数值 0 自己消失;gate_reason 改口。
③ 比对基准与芯片档案区序换回(基准在前)。
④ 比对基准换「层叠双封装」SVG(后面那枚淡出成剪影 —— 一类而非一颗),不再与芯片卡
  共用同一枚封装图。

测:ui-kit 35(scoped 2 新,变异红过)+ 包内 105 + 2528 全仓;2.11.0 装库五探针核过
(合并条/无旧红带/类型图/按卡 CSS/基准在前);浏览器实测 XL 卡独自翻背进编辑、LM 卡纹丝不动。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 17:01:06 +08:00
xcsandClaude Fable 5 05ebecb541 docs(w05): 第四轮入快照 —— 笔↔软盘 / 只选不建 / 浅蓝题头
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 16:42:10 +08:00
xcsandClaude Fable 5 51914292dc feat(w05): 数据工单第四轮九连改 —— 笔↔软盘卡角编辑、只选不建、浅蓝题头、镜像修数字
2026-08-31 用户一次点齐九件,逐条落:
① 两行编辑闸退役 → 卡右上角笔(开当前这一面的编辑)↔ 软盘(保存)+ 叉(放弃);
  按钮就是平台的 data-edit-open/save/cancel 触发器,快照/取消机制原样,按面显隐全在样式表。
② 保存一瞬间下拉变数字 → ui-kit mirrorTwins:下拉镜像**选项文案**不镜 value,并把
  selected 属性拨到人选的那一项(守卫 CSS 与取消回显都认它)。2 测。
③ 参数输入框占位提示 → 渲染器 twinInputs 认 data-ph 落成 placeholder(平台小扩面,1 测);
  值格「规格书上的值 · 查不到留空」,说明格「补充说明 · 选填」。
④ 参数说明空着不再摆「建议」→ 什么都不摆(连兜底破折号一起收)。
⑤ 区序对调:芯片档案在前,比对基准在后。
⑥ **新建芯片类型整条路移除**(只能选已有类型):抽屉与两张编辑表(种子行/Critical-1
  修法/thead 回归网)退役,S1 配方 ②③ 自投影删除(八笔→六笔),gateLedgers 收窄到
  profile+parameter,writes 同步,gate_reason 与指导书改口「只选不建」;样例改名
  「候选缺库·建档定型」;e2e 案例 0 改走选既有 type_1(type_id=90 那套绕坑法随路退役)。
  退役面的七条钉测按仓例 it.skip 留墓志铭(钉的是已移除的路径,不是放松断言)。
⑦ 题头回浅蓝渐变(原目标件那条 120°),深色夜景只留验收两件。
⑧ 对话区执行卡不再摆「参考信息」板块(basis 数据照旧随卡走,详情/归档仍有)。
⑨ 比对基准降噪:字段收成丸(名+硬软徽+单位),说明藏进悬浮气泡指上即看;
  类型说明一行截断、悬浮展开。

测:ui-kit 33(mirror 2 新)+ 渲染器 61(ph 1 新)+ 包内 104(38 steps 重排)+ 2525 全仓
四门绿;2.10.0 装库 SQL 六探针全中(hd-light/软盘/data-ph/零表格/无 chip_type 编辑面/
gateLedgers 两张);:8787 已重启载入 data-ph 渲染器。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 16:41:14 +08:00
xcsandClaude Fable 5 f6c8dccb4f docs(w05): 第三轮入快照 —— 去表格化翻面卡 / 多宿主收值
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 15:37:28 +08:00
xcsandClaude Fable 5 66be5bab6e feat(w05): 数据工单去表格化 —— 翻面芯片卡(背面参数就地改)+ 基准瓷贴卡;平台多宿主收值
2026-08-31 用户第三轮:「界面上东西太多,做成几个区域;类型与核心参数字段做成和芯片卡
差不多的卡片;芯片卡与它的核心参数值合并 —— 点击翻转,背面写核心参数,悬浮要看得出能翻;
这个界面都不再需要表格来展示数据了」。

视图(主画面零表格,表格只活在新建类型抽屉里 —— 表单要加行,加行机制认 tr):
· 比对基准卡:类型丸 + 描述 + 字段瓷贴(名/硬软徽/单位/说明),与芯片卡同语言;
· 芯片卡翻面:正面主数据、背面本颗芯片的核心参数瓷贴(值必填红 / 说明建议橘)。
  翻转纯 CSS(checkbox + :has,零脚本;checkbox 无 data-col,collect 不认它),
  悬浮出「点击翻面 · 核心参数」提示丸 + 侧倾预告;主数据编辑锁正面、参数编辑自动
  翻背面(reduced-motion 下瞬时切换);说明四条收进折叠。

平台(ui-kit ViewContainer,多宿主收值):「参数值印在每张卡背、就地可改」= 同一张表
的行分居两张卡 —— 一个地址一个宿主装不下。hostsOf(querySelectorAll)+ 收值/开合/
镜像/快照全部按宿主清单走;**格子只归最近的 data-edit 祖先**(不然卡背参数会被主数据
的闸收走并进主数据行 —— collect-nested 钉过的静默丢数据换脸回来)。嵌套 data-rows
不带 data-edit 时行为原样(两条旧钉测不动);addRow 仍单宿主(会加行的表都是单宿主)。

途中踩了三脚都逮回:CSS 注释里写了「data-col=」原文把 ②③ 的「全只读」字面断言踩翻
(选择器与注释都不许出现);.flip-cue i 特异度压过 .cue-back{display:none} 两枚提示
丸同时冒出;host 计数断言错把渲染后的数当模版源码的数。

测:ui-kit multihost 3 条(排除规则变异验证)+ 包内 110(翻面/无表格/瓷贴新锚)+
2528 全仓四门绿;2.9.0 装库 SQL 核过(flip/back_host/ftiles/cue/主画面表数=2);
浏览器实测:点卡真翻(背面 5 瓷贴)、参数编辑自动翻背双卡成表单、LM 全值回显。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 15:36:39 +08:00
xcsandClaude Fable 5 794af58902 docs(w05): 第二轮入快照 —— 基准合一卡 / prune:'live' / 蓝图板底
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 14:48:23 +08:00
xcsandClaude Fable 5 11375eaf93 feat(w05): 比对基准合一卡 + prune:'live' —— 类型与字段跟手,只显当前那一种
2026-08-31 用户第二轮:「芯片类型列表还是有问题…和核心参数字段不能合并成一个卡片吗…
两个芯片都选一样的就应该只显示一种,现在只会跟着变多不会跟着变少」+
「卡片下面那个容器黑色和页面背景不搭」。

① 比对基准卡(类型 + 字段合一,只读、跟手):不再列 mine: 草稿镜像(镜像里积着改选前的
  旧类型 —— 撤行只在确认那一拍跑,F3 不许在人编辑时静默删行),按案卷锚
  (sub_eval.case.chip_type_id,配方每拍现算)嵌套查字典:卡上改选、按保存,quiet 拍
  project 照跑,这张卡当场换脸 —— 两颗料同类型时恰好只有那一种;没人选时空态指路。
  新建类型收进 details 抽屉(零脚本),两张编辑表原样在抽屉里(种子行/加行机制不动)。

② 契约新增 prune:'live'(quiet 拍也撤):只给**人不会在件上加行**的表用 —— F3 防的是
  「人刚加的行被撤」,人加不了行的表没有这个风险,而「改了类型,旧类型的参数行还摆在
  屏上」是另一种撒谎。W05 参数骨架 ⑤ 改 'live':改选类型,参数行跟手换。
  static-audit 的 prune×humanWrites 审计盖住 'live';规范 §4.6 纪律 5 补这条反面特例;
  两档都跳过已落地的行并出声(skipLanded)。

③ 板底改浅色蓝图:深色容器与纸面色阶断裂(用户点名),换强调色极浅晕染 + 淡格线,
  走线与信号点用强调色 —— 电路语汇留着,整页只剩 hero 一处深色。

测:契约 'live' 解析 + runner quiet-live 对齐(变异验证)+ 基准卡锚(变异验证)+
steps ⑤ 钉 'live';108 包内 + 全仓四门绿;2.8.1 装库 SQL 核过;:8787 已跑新构建
(watcher 又断,按应急程序手起 node dist/main.js)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 14:47:25 +08:00
xcsandClaude Fable 5 5a7bb9b58f fix(w05): 数据工单三病一改 —— 档案卡上桌、类型守卫、prune 跳过已落地
2026-08-31 用户实测三连 + 「设计只是头上加了动画,操作感没变」:

① 类型「自己给上了」: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 恒有两行),行里天生有全部列。

③ 「改回同一类型,下面还是两个类型」:②③ 自投影为绕「已落地行撤不掉」的死锁
  (2026-08-30 e2e 案例 2)特意不带 prune,没落地的陈旧行也跟着积在屏上。
  治在平台:draftDrop 加配方专用旗 skipLanded(跳过并出声「N 行已随数据工单入库、
  这一笔不清它」,不炸;模型那一路的硬拒一个字不变),②③ 恢复 prune:true ——
  死锁与积行两头都收,debt「prune 与确认即入库的架构性冲突」销账。
  quiet 拍仍不撤行(F3),清理落在每次确认工单那一拍。

④ 设计:块① 从 11 列横滑表推翻成电路板工作台 —— 深色板底上两张可就地编辑的
  芯片档案卡(SOIC 封装 SVG + 角色/在库徽 + 类型行 + 双栏主数据格),编辑态整卡
  变表单;三级占位(必填红/建议橘)上卡与参数值表;参数值表所属料号列加角色徽。
  嵌套的角色/在库查表全只读(嵌套行里有 data-col 会被 collect 并成一条,测钉死)。

契约:loop-engine port draftDrop +skipLanded/+landed(纯新增,CONTRACT.md 已记,
api-report 已刷)。测:107 包内(三锚变异验证)+ auto-runner skipLanded(变异验证)
+ 2520 全仓;2.7.0 装库 SQL 核过(档案卡宿主/守卫/胶囊/S1 四笔 prune)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 14:12:10 +08:00
xcsandClaude Fable 5 e2db7cfa01 docs(w05): 视图层重设计入快照 + 销 badgeCss 三处一份的债
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 13:06:10 +08:00
xcsandClaude Fable 5 e18611006b feat(w05): 三张工单件视图层重设计 —— 电路板夜景 hero + 精密检测报告版面,功能绑定逐项保留
用户定:中央工作区不要后台管理端的样子,按 C 端界面重设计,可加主题 SVG 与动画,
只保留功能。三张件换成「精密检测报告」语言:
· 题头深色电路板 hero,三张各一幕 —— 建档台(两颗料的芯片铭牌,由 mine:sub_eval.part
  铺行,不在库虚线描边)/ 对决量尺(原 PN → 候选 PN + 推荐等级徽章)/ 落账席(慢转
  印章环 + 一句话结论);信号点流动、晶粒呼吸、入场浮起,全部 transform/opacity,
  prefers-reduced-motion 全静。
· 正文瓷白报告版面:丝印小标 + 简报卡 + 报告表格;比对光谱(四态分布带 + 刻度)、
  落账三卡(大数)、四态计数丸。验收两件仍是用户定的验收绿 #0aa294,数据工单数据蓝。
· 设计系统收进 views/style.ts;目标件时代的 target-style.ts 退役(报表样式只此一处真源);
  report.ts 里与 badge.ts 重复的 vtok/badgeCss 收归一处(销一笔 debt)。

功能一个不动:可编辑表的 DOM 结构逐字节保留(data-rows/data-edit/data-col/编辑闸/
种子行/主键钥匙/thead 后置/红带),机制规则(序号计数器、jc-row-static 显隐、
gate-bar :has(:empty)、overflow-x)逐字保留并仍被包内测试钉着。

修掉三个自审逮到的洞:kv 占位破折号只认直接子级(嵌套徽标被「—」盖掉)、光谱条里
不渗计数字、加行钮补 base display:none(从前没开编辑就露着)。

104 包内测 + 2516 全仓测绿;两条新锚(主题色/hero 外壳)变异验证各红过一次;
2.6.0 已装库并 SQL 核过(hero/主题色/种子行/vtok/零脚本逐件中)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 13:03:36 +08:00
xcsandClaude Opus 5 1e3e5d40c6 feat(w05): 差异评估结论审核的收口四格可就地改 —— 判定归规则,话归人
用户:「差异评估结论审核这个界面的收口结论下面这 4 个字段也需要可以编辑」

这四格(推荐理由 / 差异摘要 / 替代风险 / 验证要求)原本是**故意**只读的,理由写在源码里:
「让人直接改这几格,等于让他给自己没做的判断盖章」。用户改了这个口径 —— 审核人对结论
有最终发言权。原理由留在注释里备查。

**光把格子打开等于没开**:`RELATION_PROJECTION` 每跑一次就按规则把这四列重写一遍,
而人在件上按保存会触发 quiet 拍、quiet 恰恰跑 project —— 人刚写下的话当场被规则值冲掉,
那是「让你改、改完被冲掉」,比不给改更糟(基座不记「谁写的」,配方分不清一格是人改的
还是自己写的,所以只能从配方这一侧下手)。

改法:S3 换用新的 `RELATION_RECOMPUTE` —— 与 `DIFFERENCE_ITEM_RECOMPUTE` 同一手法,
**只列主键 + recommendation_level**。patch 是稀疏的、DB 侧是 jsonb 浅合并,没列的列原样
留着,人改过的四格因此保得住。**推荐等级仍然重算且不容手改**:它是规则按四态分布算出来
的判据核心(件② 判据表 / 件③ 落账结论 / 出口谓词都读它),「人改四态 → 等级跟着变」
这条链不能断。分工因此是:**判定归规则,话归人**。

代价说在明处,件上那段说明已改口:改完四态,这四格不会自动跟着改口(否则刚写的话就没了)
——「四态改完,记得回头看看这四格还对不对」。界面不撒谎优先于自动。

件上:三句话走 cell(只读份 + 孪生输入),风险只读态摆徽标、编辑态换 select(枚举列不许
直显 low/medium/high),整块一个宿主一道闸,主键钥匙藏在 .rowkey 里但真挂 data-col。
.summary-box 不是表格 —— setOpen 对非表格容器把 .on 落在 data-edit 元素自身,CSS 按
.summary-box.on 写。

测:件上四格可改 + 一个宿主一道闸 + 钥匙在场;S3 重算笔四列一个都不许出现、判定仍在且
与 S2 共用同一条 when 链。四条变异各红一条(S3 退回用 RELATION_PROJECTION / 混进
difference_summary / 混进 risk_level / 判定不再重算)。pnpm gates 退出码 0(2495 测)。
包版本 2.3.0 -> 2.4.0。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 11:53:33 +08:00
xcsandClaude Opus 5 e5ca9caaa6 fix(ledger): 草稿层按诞生序读 —— 保存过的行不再跳到列表末尾
用户:「点击保存后,列表里面的元素每次排列顺序都不一致,需要保持顺序的一致性」

病根在读:`select * from ledger.__draft ... order by updated_at`,而 `put` 的 upsert
分支每次都写 `updated_at = now()` —— **人保存了哪几行,哪几行就跳到列表末尾**;
同一批写下的行(配方一次 project 铺 7 行)`updated_at` 又完全相同,并列之间没有次级键,
PostgreSQL 返回什么顺序都算数(更新一行 = 旧版本作废 + 新版本插到堆尾,物理顺序本来
就会变),于是每保存一次屏上的表就重排一次。

加 `seq bigserial`:insert 那一刻取号、upsert 的 update 分支不碰它 —— 记的正是「这一行
是第几个被写下的」,而配方铺行的顺序就是人期望在件上看到的顺序(原 PN 的参数在前、
候选的在后,组内按字典序 —— 配方那么遍历的)。单键、严格全序、没有并列。

**列加在 DRAFT_DDL 里,不写迁移**:这张表由启动时的 DDL 自建(`container.ts` 调
`DRAFT_DDL`),`landed_at` 立的就是这个先例 —— 头一版我没读到那行注释,写了迁移 0036
又撤掉(journal 已回退;开发库那条孤儿记录无害,drizzle 只按 when 比大小)。

测:ledger 集成测试补一条(对真库),同批写下五行 → 顺序原样;改中间那行、再改另外两行
→ 顺序一个字不变;值确实写进去了(不是靠没写成功换来的稳定);新写的行排末尾。
变异验证:读序退回 updated_at,这一条真红(也证明它连的是真库,不是被 skip 掉)。
pnpm gates 退出码 0(2495 测)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 11:35:03 +08:00
xcsandClaude Opus 5 2265ab7833 fix(desk): 保存那一拍对话区也别出声 —— 「在办事」不亮、say 落工作日志不上对话区
用户:「每次保存的时候右边也会出现 ai 推进中,然后会说一句话,能不能把这个也取消掉」

两处,同一个根:件上按保存会 wake 这一单(「保存即铺格」),runtime 被拨成 running。

① 右边那条办事条:`TaskChat` 的 `working` 只看 runtime === 'running'。它自己的注释
早写着「等人、等外部、已收尾都不算 —— 那时亮『工作中』就是撒谎:环没在转,是在等」——
save-wake 恰恰把「在等人」的单拨成了 running,把这条原则架空了。加 `!awaitingHuman(...)`
是**恢复**那句注释说的语义:有未办结工单 / 未回的卡就是在等人,哪怕 runtime 写着 running。
判断与工作区那一侧同一个函数。

② 那句话:quiet 拍只跑 project 与 say,于是人按几次保存,同一句交代往对话区说几遍。
对话区的纪律是**一轮一句,不是一拍一句**(2026-08-01):只有交出控制权那一拍的交代
摆到人面前;而 quiet 拍压根没交出控制权(人正对着卡填),算不上新一轮。
改:quiet 拍的 say 标 `internal`。压下去不是丢掉 —— `internal` 是两个投影共用的一个旗
(终审 I5/F2):`chat-projection` 据此挡住对话区,`task-log.machinery()` 对 `detail.auto`
开了例外,这一笔在工作日志里照旧看得见(话有插值时值的变化也查得到)。

测:quiet 的 say 标 internal 且工作日志仍在(3 条变异各红一条 —— 退回原样 / 反过来连
非 quiet 也标 / 连 auto 一起去掉;其中「反过来标」红的是既有那条「非 quiet 不标」,
两条互为护栏)。后一半(标了 internal 就进不了对话区)由 chat-projection 既有测钉着,
projectChat 没出模块边界,两半各钉在自己模块里、注释互指。
pnpm gates 退出码 0(2494 测)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 11:16:09 +08:00
xcsandClaude Opus 5 c8988c912f fix(desk): 有活等着人办就不摆「AI 正在推进这单」—— 保存后那一闪盖掉的是刚重推的件
用户:「在中央工作区修改了内容点击保存后,为什么会出现『AI 正在推进这单』的提示?」

因果链:件上按保存 → afterSave **先重推件、再叫醒任务**(「保存即铺格」,2026-08-29:
人选了芯片类型按保存,配方这才有机会把参数行铺出来;不叫醒格子永远不出现,08-30
走查在这儿卡死过)→ wake 把 runtime 拨成 running → 工作区的 running 判据排在 viewHtml
前面(2026-08-21:正在跑的这一拍要压过上一拍摆下的**陈旧**的件)→ 人刚填完按下保存,
屏上那份刚重推、含着他刚填的值的件被推进场景顶掉几秒。

08-21 那条规则的前提是「viewHtml 是上一次推的,可能陈旧」,而这条路上件比 running
还新,前提不成立;更要紧的是那一拍按「叫了人就等人」的闸(turn-runner,08-27)
**根本不问模型**,而那一屏写着「看材料、写台账、调工具都由它来」——一件没发生,
界面在撒谎。

修:running 那一支加 `&& !holding`。判断收进纯函数 `awaitingHuman`(未办结工单 /
未选决策卡 / 未确认录入卡),与服务端 humanTool.pendingCards 说的是同一句话 ——
**有未答的卡,服务端就不问模型,前端就不说 AI 在推进**,两侧同一条道理的两面。
没有任何卡在等人、AI 真在埋头算时,推进场景照旧摆(2026-08-21 要的正是它)。

6 条新测,四项各做变异验证(去掉工单项 / 决策卡项 / 录入卡项各红一条,恒真红两条 ——
恒真等于把推进场景整个废掉);pnpm gates 退出码 0(2493 测)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 10:59:33 +08:00
xcsandClaude Opus 5 11e41b79c5 revert(w05): 数据工单退回换版面之前的旧版面(2.3.0),新版面存在 feat/w05-view-swap
用户实测新版面「感觉有点问题」,先退回稳定的旧版面,新版面原样留在分支上随时可切。

退法不是 reset —— 历史与素材都留着:report.ts / target-style.ts / activity.test.ts
三个文件逐字节恢复到 45cec82 的样子(已 diff 核对为零差异),平台侧的探针测试
(view-container-collect-div-rows / -nested / view-render-nested-editable)、目标件 html、
交接件文档全部留在主支 —— 它们钉的是平台行为与素材,不随这次退回作废。

版本号 2.2.2 -> 2.3.0 而不是退回 2.1.9:store.manifest 不传版本时按 installedAt 取
**最近安装**的那一条(不是版本号最大的),库里 2.1.9 那行的 installedAt 比 2.2.2 早,
同版重装又是 no-op —— 只有拿一个新号把旧版面装进去,注册表才真的换回旧版面。

CLAUDE.md 那条战果记录改口标注「已退回」并指向分支,原文留着当索引。
pnpm gates 退出码 0(2487 测;比换版面那版少的 10 条正是随 UI 一起退掉的新版面钉子)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 10:31:59 +08:00
71 changed files with 5256 additions and 1928 deletions
+335 -1
View File
@@ -1696,7 +1696,15 @@
写出来的数就得排在后面)/ 方向相反的依赖拆两笔写、中间夹子表 / `gateLedgers` 同时管 写出来的数就得排在后面)/ 方向相反的依赖拆两笔写、中间夹子表 / `gateLedgers` 同时管
「闸要求非空」与「确认时并进正式表」两件事,漏想一半就会悬空引用或永久卡死。 「闸要求非空」与「确认时并进正式表」两件事,漏想一半就会悬空引用或永久卡死。
`pnpm gates` 退出码 0(2447 passed | 34 skipped)。 `pnpm gates` 退出码 0(2447 passed | 34 skipped)。
- **W05 数据工单换版面 + 两条渲染机制的实锚**(2026-08-30,`part-substitution-assessment@2.2.2`, - **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`): 用户给了新目标件 `docs/ss02-act-05-new/ACT-WORK-05-数据工单-PN主数据创建.html`):
`input-supplement` 整张照它重排 —— **两条并列轨道**(每颗料一条:档案卡 + 主数据 + 类型选择) `input-supplement` 整张照它重排 —— **两条并列轨道**(每颗料一条:档案卡 + 主数据 + 类型选择)
+ `<details>` 新增类型面板(chip_type / type_param 两张表原样搬入,独立「核心参数字段」区退役) + `<details>` 新增类型面板(chip_type / type_param 两张表原样搬入,独立「核心参数字段」区退役)
@@ -1726,6 +1734,332 @@
终审评审 0 Critical / 0 Important / 1 Minor(注释漂移,已修)。跑完 reset-runs, 终审评审 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 收起。
**教训:要用「清成空串」表达状态翻转的列,空值不落笔会把它焊死 —— 开关就该是开关列。**
## 文档权威地图 ## 文档权威地图
| 层 | 文件 | 说明 | | 层 | 文件 | 说明 |
@@ -113,14 +113,14 @@ describe('清单本身合规', () => {
/* /*
* 2026-08-29 重写:必交付物从三件(评估输入清单 / 差异评估结论 / 拟落账内容)收到两件 —— * 2026-08-29 重写:必交付物从三件(评估输入清单 / 差异评估结论 / 拟落账内容)收到两件 ——
* 「评估输入清单」那件连同它绑的三张旧单据(pn_check/input_gap/verdict)一起退休。 * 「评估输入清单」那件连同它绑的三张旧单据(pn_check/input_gap/verdict)一起退休。
* 人环预算从 16(候选放弃/创建决策等岔路)收到 8(补数据 / 审核 / 放行三次调人,零工具、 * 人环预算:常路只要 3 次(补数据 / 审核 / 放行),**而审核每打回一次就多叫一次** ——
* AI 只出场一次)—— 见「契约 > 预算」那条把 8 钉死;这里只需两个数上下一致。 * 8 次实测三四轮就见底(2026-09-01 用户连打回四次,单子落 exhausted,屏上看着像「落账那一步没了」),
* 用户当场定「那你就把预算提高」→ 24。这里只需两个数上下一致(授权面与资源面不许分叉)。
*/ */
it('两件必交付物都有台账地址;人环预算恰为 8', () => { it('两件必交付物都有台账地址;授权面与资源面的调人预算是同一个数', () => {
expect(manifest.contract.requiredDeliverables).toHaveLength(2); expect(manifest.contract.requiredDeliverables).toHaveLength(2);
for (const d of manifest.contract.requiredDeliverables) expect(d.ledger, d.key).toBeDefined(); for (const d of manifest.contract.requiredDeliverables) expect(d.ledger, d.key).toBeDefined();
expect(manifest.contract.authorization.humanCallBudget).toBe(8); expect(manifest.contract.authorization.humanCallBudget).toBe(manifest.contract.resources.budget.humanCalls);
expect(manifest.contract.resources.budget.humanCalls).toBe(8);
}); });
/* /*
@@ -287,6 +287,42 @@ describe('契约', () => {
} }
}); });
/*
* 按芯片类型联动(2026-08-31 用户定):候选带上分组键与人读名,两格结成一组。
* 分组键指的那一列必须是候选表**真有**的列(`parts.profile.chip_type_id`),
* 字典指的那一张表也得是本活动声明过的 —— 写错地址不会炸,只会静默不联动(服务端 warn 一句),
* 而「静默不联动」正是没人会发现的那种坏。
*/
it('两格按芯片类型联动:候选带分组键 + 字典换人读名,x-link 同名结成一组', () => {
const props = manifest.contract.input.data['properties'] as Record<string, Record<string, unknown>>;
const ledgerOf = (address: string) => manifest.declarations.ledger.find((l) => `${l.domain}.${l.object}` === address);
for (const key of ['originalInternalPn', 'candidateInternalPn']) {
expect(props[key]?.['x-link']).toBe('chip-type');
const options = props[key]?.['x-options'] as { group?: { column?: string; ledger?: string; value?: string; label?: string } };
expect(options.group).toEqual({ column: 'chip_type_id', ledger: 'parts.chip_type', value: 'type_id', label: 'type_name' });
// 分组键那一列在候选表上真的存在
expect(ledgerOf('parts.profile')?.fields.some((f) => f.name === options.group?.column)).toBe(true);
// 换名字典的两列也真的存在
const dict = ledgerOf('parts.chip_type');
expect(dict?.fields.some((f) => f.name === options.group?.value)).toBe(true);
expect(dict?.fields.some((f) => f.name === options.group?.label)).toBe(true);
}
});
/*
* 两格不能是同一颗料(2026-08-31 用户定:「不应该能选到同样的两个芯片」)—— 与联动不同,
* 这一条**是墙**,且理由与上面「留空会卡死」那条是同一条链:同一个料号 = 同一个主键
* (`eval_ref + internal_pn`,`role` 不在主键里),两行撞成一行,`exists_status_found = 2`
* 永不成立,而件上那两行人加不出来 —— 唯一出路是终止重开。**墙立在起单这一刻,因为路只在这里。**
*/
it('两格互斥:同一颗料不能填两遍(x-distinct 同名 = 一组,服务端真拦)', () => {
const props = manifest.contract.input.data['properties'] as Record<string, Record<string, unknown>>;
for (const key of ['originalInternalPn', 'candidateInternalPn']) expect(props[key]?.['x-distinct']).toBe('part');
/* 主键真是「案号 + 料号」——「同一个料号 = 同一个主键」这条链的第一环,它变了这道墙就该重新论证 */
const part = manifest.declarations.ledger.find((l) => `${l.domain}.${l.object}` === 'sub_eval.part');
expect(part?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['eval_ref', 'internal_pn']);
});
it('零工具 —— 这是这次重写的立场,不是疏漏', () => { it('零工具 —— 这是这次重写的立场,不是疏漏', () => {
expect(manifest.contract.resources.tools).toEqual([]); expect(manifest.contract.resources.tools).toEqual([]);
expect(manifest.declarations.tools).toEqual([]); expect(manifest.declarations.tools).toEqual([]);
@@ -307,9 +343,14 @@ describe('契约', () => {
} }
}); });
it('预算:调人三次(补数据 / 审核 / 放行),模型只出场一次', () => { /*
expect(manifest.contract.resources.budget.humanCalls).toBe(8); * 常路调人三次(补数据 / 审核 / 放行),模型只出场一次 —— 而**审核可以打回,每打回一次就多叫一次人**,
expect(manifest.contract.authorization.humanCallBudget).toBe(8); * 所以预算按「常路 3 + 二十来轮打回」给(2026-09-01 用户定,见 manifest 那一行的注释)。
* 下限钉在 12:低于它,人点几次重做就把单子烧成 exhausted —— 落账那一步再也没机会出现。
*/
it('预算:常路调人三次,留足打回的余量;模型只出场一次', () => {
expect(manifest.contract.resources.budget.humanCalls).toBeGreaterThanOrEqual(12);
expect(manifest.contract.authorization.humanCallBudget).toBe(manifest.contract.resources.budget.humanCalls);
expect(manifest.contract.resources.models).toEqual(['deepseek-v4-flash']); expect(manifest.contract.resources.models).toEqual(['deepseek-v4-flash']);
}); });
}); });
@@ -448,10 +489,25 @@ describe('报告模版:编辑面与只读面各就各位', () => {
expect(viewHtml('landing-confirm')).not.toContain('data-edit-add='); expect(viewHtml('landing-confirm')).not.toContain('data-edit-add=');
}); });
/*
* **印章里的徽章不许溢出圈外**(2026-09-01 用户:「有条件推荐这个 svg 挡住旁边的字了」)。
* 浏览器里量的账:徽章那一坨 139px 宽,而印章圈定死 112px —— 溢出 26px,
* 右边那句一句话结论从 603px 起,正好被压住;病根是徽章按自己的字宽撑开、
* 又带着 white-space:nowrap 缩不了。两条一起治,改完再量:核心 94px、圈内还余 9px、
* 「有条件推荐」一行放得下(以后有更长的等级名就折行,圆里两行照样是圆)。
*/
it('落账席的印章:徽章按圈的内径收,不许再溢出去压住那句结论', () => {
const html = viewHtml('landing-confirm');
expect(html).toContain('.seal-core{max-width:calc(100% - 16px)');
/* 缩不了才会溢出 —— 圈里这一枚必须允许折行 */
expect(html).toMatch(/\.seal \.rec\.rec-lg\{[^}]*white-space:normal/);
});
it('每个可编辑格子的 data-col 都在对应台账对象的声明里(input-supplement 一屏四张表,四张台账对象的声明并起来看;difference-review 一张表)', () => { it('每个可编辑格子的 data-col 都在对应台账对象的声明里(input-supplement 一屏四张表,四张台账对象的声明并起来看;difference-review 一张表)', () => {
const cases: Array<[string, Array<[string, string]>]> = [ const cases: Array<[string, Array<[string, string]>]> = [
['input-supplement', [['parts', 'profile'], ['parts', 'chip_type'], ['parts', 'type_param'], ['parts', 'parameter']]], ['input-supplement', [['parts', 'profile'], ['parts', 'parameter']]],
['difference-review', [['substitution', 'difference_item']]], /* 件② 2026-08-31 起多一张:收口四格可就地改,写向 substitution.relation */
['difference-review', [['substitution', 'difference_item'], ['substitution', 'relation']]],
]; ];
for (const [view, objects] of cases) { for (const [view, objects] of cases) {
const cols = new Set([...viewHtml(view).matchAll(/data-col="([^"]+)"/g)].map((m) => m[1] ?? '')); const cols = new Set([...viewHtml(view).matchAll(/data-col="([^"]+)"/g)].map((m) => m[1] ?? ''));
@@ -485,12 +541,17 @@ describe('报告模版:编辑面与只读面各就各位', () => {
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/); await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
}); });
/* 解封(2026-08-29,Task 9):`landing-confirm` 补齐,三张全能验了 —— 都走同一个 `doc()` 壳。 */ /*
it('三张模版的外壳都是搬自目标件的 page/card,不再含 E04/I01 那套视觉底(.proto-root)的痕迹', () => { * 解封(2026-08-29,Task 9):三张全走同一个 `doc()` 壳。2026-08-31 视图层重设计后,
* 壳不再「搬自目标件」而是活动自己的设计系统(style.ts),但骨架类名不变:
* card-head 是电路板夜景 hero,card-foot 是丝印条;E04/I01 那套视觉底(.proto-root)仍不许出现。
*/
it('三张模版共用同一个 page/card 外壳(card-head hero + card-foot),不含 E04/I01 视觉底(.proto-root)', () => {
for (const view of VIEWS) { for (const view of VIEWS) {
const html = viewHtml(view); const html = viewHtml(view);
expect(html, view).toContain('class="card-head"'); expect(html, view).toContain('class="card-head"');
expect(html, view).toContain('class="card-foot"'); expect(html, view).toContain('class="card-foot"');
expect(html, view).toContain('class="hero-title"');
expect(html, view).not.toContain('proto-root'); expect(html, view).not.toContain('proto-root');
} }
}); });
@@ -527,11 +588,10 @@ describe('报告模版:编辑面与只读面各就各位', () => {
}); });
/* /*
* 件① 评估输入补全(2026-08-29 Task 7/7b;**2026-08-30 照新目标件换版面**):S1 的配方 * 件① 评估输入补全(2026-08-29,Task 7;Task 7b 补②a 块):S1 的配方缺什么就发一张数据工单,
* 缺什么就发一张数据工单,`view: 'input-supplement'` 指向它。新版面 —— 两条并列轨道 * `view: 'input-supplement'` 指向它。四块竖排——要评估什么(两颗料的存在性 + 主数据)/
* (每颗料一条:档案卡 + 主数据 + 类型选择)/ `<details>` 新增类型面板(chip_type + * 芯片类型(选型 + 新建类型)/ 核心参数字段清单(新建类型时要填)/ 参数值(逐条实测/规格值);
* type_param 两张表,即「每行四格」的核心参数字段清单)/ 核心参数值瓷贴(单块平铺); * 顶部一条红带摆配方算好的 `gate_reason`。
* 顶部一条红带摆配方算好的 `gate_reason`。台账绑定面与换版面前一个不差。
* 复用文件顶部已有的 `viewHtml` 短写(与 brief 草稿里的 `viewOf` 同一件事,不重复定义一个)。 * 复用文件顶部已有的 `viewHtml` 短写(与 brief 草稿里的 `viewOf` 同一件事,不重复定义一个)。
*/ */
describe('件① 评估输入补全', () => { describe('件① 评估输入补全', () => {
@@ -543,29 +603,117 @@ describe('件① 评估输入补全', () => {
expect(steps.find((s) => s.item === 'S1_inputs')?.view).toBe('input-supplement'); expect(steps.find((s) => s.item === 'S1_inputs')?.view).toBe('input-supplement');
}); });
it('四块都在,行都绑本单草稿(不是整表)', () => { it('三块都在,行都绑本单草稿(不是整表);档案卡行源是 parts.profile 本身(类型字典只读整表,不再有 mine: 镜像)', () => {
expect(html).toContain('data-rows="mine:sub_eval.part"'); expect(html).toContain('data-rows="mine:sub_eval.part"');
expect(html).toContain('data-rows="mine:parts.profile"');
expect(html).toContain('data-rows="mine:parts.parameter"'); expect(html).toContain('data-rows="mine:parts.parameter"');
expect(html).toContain('data-rows="mine:parts.chip_type"'); expect(html).not.toContain('data-rows="mine:parts.chip_type"');
expect(html).toContain('data-rows="mine:parts.type_param"'); expect(html).not.toContain('data-rows="mine:parts.type_param"');
}); });
it('芯片类型是从台账现读的下拉 —— 写死等于「只能选出包那天就有的那几种」(块①的 chip_type_id 与块②b 的 type_id 都是)', () => { /*
* 2026-08-31 用户实测三连的回归网(视图侧两条,配方侧第三条在 steps.test.ts):
* ① 「品牌…点保存根本不会保存」—— 病根:块① 行源曾是 `mine:sub_eval.part`,那张案卷表
* 没有品牌这些列,renderTemplate 只认行作用域,显示与孪生件的值永远空白。行源必须是
* `mine:parts.profile` 本身(S1 第④笔 prune:true 保证恒有两行)。
* ② 「明明没选类型,界面自己给上了」—— data-by 行里缺键落回外层同名字段(debt I3),
* 查名 pill 摆出这一行没有的类型。守卫认 selectCurrent 按这一行自己的值写下的
* option[selected]:真选上才摆名,没选摆「待选择」。
*/
it('档案卡:行源 = mine:parts.profile、一个宿主一道闸;嵌套的角色/在库查表全只读(嵌套行里有 data-col 会被 collect 并成一条)', () => {
expect((html.match(/data-edit="parts\.profile"/g) ?? []).length).toBe(1);
expect(html).toContain('data-rows="mine:parts.profile" data-edit="parts.profile"');
expect(html).not.toContain('data-rows="mine:sub_eval.part" data-edit="parts.profile"');
const nested = [...html.matchAll(/<span data-rows="mine:sub_eval\.part" data-by="internal_pn=internal_pn">((?:(?!<\/span>)[\s\S])*?)<\/span>/g)];
expect(nested.length).toBeGreaterThanOrEqual(1);
for (const m of nested) expect(m[1] ?? '').not.toContain('data-col');
});
it('类型名带守卫:这一行的下拉真有选中项才摆名,没选摆「待选择」—— data-by 外层回落的谎话进不了屏(单层 :has,嵌套 :has 是非法选择器会被整条丢弃)', () => {
expect(html).toContain('.dz-type:not(:has(option[selected])) .tsel > span{display:none}');
expect(html).toContain('待选择');
/* 回归网:不许再写回嵌套 :has 的形 —— 那条选择器非法,浏览器静默丢弃,守卫等于没有 */
expect(html).not.toContain(':has(+ select.jc-cell:not(:has(');
});
/*
* 2026-08-31 用户第二轮:「芯片类型列表和核心参数字段,不能合并成一个卡片吗…两个芯片都
* 选一样的,就应该只显示一种」。合并 = 比对基准卡(类型名 + 字段清单一张只读卡);
* 「只显示一种、跟手」= 不列 mine: 草稿镜像(会积旧选择),按案卷锚(case.chip_type_id,
* 配方每拍现算)嵌套查字典 —— quiet 拍 project 照跑,保存即换脸。
* 新建类型收进 details 抽屉(零脚本),两张编辑表原样在抽屉里(种子行/加行机制不动)。
*/
it('比对基准卡:case 锚嵌套查字典,类型与字段合一、全只读;编辑表收进抽屉', () => {
expect(html).toContain('data-rows="mine:sub_eval.case"');
const basis = /<article class="basis"[\s\S]*?<\/article>/.exec(html)?.[0] ?? '';
expect(basis).not.toBe('');
expect(basis).toContain('data-rows="parts.chip_type" data-by="type_id=chip_type_id"');
expect(basis).toContain('data-rows="parts.type_param" data-by="type_id=chip_type_id"');
/* 全只读:选类型在档案卡,建类型在抽屉 —— 基准卡上一个编辑面都不许有 */
expect(basis).not.toContain('data-col');
expect(basis).not.toContain('data-edit');
/* 基准卡不再有表格:类型 + 字段全是瓷贴;说明藏进悬浮气泡(降噪,2026-08-31) */
expect(basis).not.toContain('<table');
expect(basis).toContain('class="ftiles"');
expect(html).toContain('.ftile:hover .ft-desc');
/* 新建类型抽屉已随「只选不建」退役(2026-08-31 用户定)—— 只剩说明折叠那一只 details */
expect(html).not.toContain('<details class="lab">');
expect(html).not.toContain('data-edit="parts.chip_type"');
expect(html).not.toContain('data-edit="parts.type_param"');
});
/*
* 2026-08-31 用户第三轮:「芯片卡片需要和该芯片的核心参数值合并…点击后卡片翻转,背面写
* 核心参数,悬浮要看得出可以翻;这个界面都不再需要表格来展示数据」。
* 背面就地可改 = 两张卡背各住一个 parts.parameter 宿主 —— 多宿主收值是同日的平台扩面
* (ViewContainer hostsOf + 格子只归最近的 data-edit 祖先,ui-kit multihost 测试钉着)。
*/
it('翻面卡:背面住着参数宿主、按料过滤(渲染时随行复制成每卡一个 —— 多宿主收值由 ui-kit multihost 测试钉);翻转是无 data-col 的 checkbox;编辑态锁面', () => {
expect((html.match(/data-edit="parts\.parameter"/g) ?? []).length).toBe(1);
expect(html).toContain('data-rows="mine:parts.parameter" data-by="internal_pn=internal_pn" data-edit="parts.parameter"');
const flip = /<input class="flip-tg"[^>]*>/.exec(html)?.[0] ?? '';
expect(flip).not.toBe('');
expect(flip).toContain('type="checkbox"');
expect(flip).not.toContain('data-col');
expect(html).toContain('class="face front"');
expect(html).toContain('class="face back"');
/* 编辑锁面:主数据编辑停正面,参数编辑翻背面 —— 判断全在样式表 */
/* 按卡锁面(2026-08-31「不要做联动」):.on 由 scopedTargets 落在这张卡的行上 */
expect(html).toContain('.dossier.on .flip-in{transform:rotateY(0deg)}');
expect(html).toContain('.dossier:has(.pback.on) .flip-in{transform:rotateY(180deg)}');
/* 悬浮提示丸:看得出点击可翻 */
expect(html).toContain('点击翻面');
/* 「可替换」动画与卡同级(2026-08-31 第二稿:不做背景板 —— 会被卡盖住):
三列网格中间留实体动画道,⇄ 徽 + 双点轨道对转 */
expect(html).toContain('class="swapfx"');
expect(html).toContain('class="sw-orbit"');
expect(html).toContain('@keyframes sworbit');
expect(html).toContain('grid-template-columns:minmax(0,1fr) 76px minmax(0,1fr)');
expect(html).toContain('.dossiers > .dossier:nth-of-type(even){grid-column:3}');
});
it('整件零表格(2026-08-31 用户:「这个界面都不再需要表格来展示数据了」;抽屉连表带功能一并退役)', () => {
expect(html).not.toContain('<table');
expect(html).not.toContain('class="jc-tbl-wrap"');
});
it('占位口径:正面必填红/建议橘;卡背值必填红,说明空着**什么都不摆**(2026-08-31 用户:「没写就什么都不要展示」)', () => {
expect(html).toContain('.fcell.req .jc-vcell:empty::after{content:"必填"');
expect(html).toContain('.fcell.sug .jc-vcell:empty::after{content:"建议"');
expect(html).toContain('.ptile .pv .jc-vcell:empty::after{content:"必填"');
expect(html).toContain('.ptile .sg .jc-vcell:empty{display:none}');
expect(html).not.toContain('.ptile .sg .jc-vcell:empty::after');
});
it('芯片类型是从台账现读的下拉 —— 写死等于「只能选出包那天就有的那几种」', () => {
expect(html).toContain('data-options="parts.chip_type"'); expect(html).toContain('data-options="parts.chip_type"');
expect(html).toContain('data-option-value="type_id"'); expect(html).toContain('data-option-value="type_id"');
expect(html).toContain('data-option-label="type_name"'); expect(html).toContain('data-option-label="type_name"');
/* data-options 与 data-slot 不能共存(渲染时 data-slot 会被剥掉)—— 当下值由 data-col 定 */ /* data-options 与 data-slot 不能共存(渲染时 data-slot 会被剥掉)—— 当下值由 data-col 定 */
expect(/data-options="[^"]*"[^>]*data-slot=/.test(html)).toBe(false); expect(/data-options="[^"]*"[^>]*data-slot=/.test(html)).toBe(false);
/* 块①(sub_eval.part 行模版,只有一份行模版,被 data-rows 迭代)绑 chip_type_id 下拉 */ /* 档案卡正面绑 chip_type_id 下拉 —— 只选不建(2026-08-31 用户定),type_id 的写入下拉已随抽屉退役 */
expect(html).toContain('data-col="chip_type_id" required data-options="parts.chip_type"'); expect(html).toContain('data-col="chip_type_id" required data-options="parts.chip_type"');
/* expect(html).not.toContain('data-col="type_id"');
* 块②b(type_param)绑 type_id 下拉的地方**该出现两次**——已有行的行模版(data-rows
* 迭代)一份、种子行(字面量静态行,不受行数所限)一份;只数子串存在会被「只剩其中一份」
* 这种退化蒙混过去(已用变异验证逮到:只把动态行模版那份换回裸文本输入,这条子串断言
* 仍然为真,因为种子行那份还在——按出现次数数才钉得住两处都在)。
*/
const typeIdSelectCount = (html.match(/data-col="type_id" required data-options="parts\.chip_type"/g) ?? []).length;
expect(typeIdSelectCount).toBe(2);
}); });
/* /*
@@ -574,18 +722,14 @@ describe('件① 评估输入补全', () => {
* (查证见 Task 7b 报告)。补一条**不受 `data-rows` 管辖的静态种子行**——两张表各一条, * (查证见 Task 7b 报告)。补一条**不受 `data-rows` 管辖的静态种子行**——两张表各一条,
* 字面量手写 `data-col` 输入格,不经 `cell()`/`data-slot`,行数再少也在。 * 字面量手写 `data-col` 输入格,不经 `cell()`/`data-slot`,行数再少也在。
*/ */
it('**零行也加得出第一行**:两张表各带一条静态种子行(class="jc-row-static"),不靠 data-rows 生成', () => { /*
const staticRows = html.match(/<tr class="jc-row-static">/g) ?? []; * SKIP(2026-08-31 用户定「把添加芯片类型的功能从这个活动中移除,只能选择已有类型」):
expect(staticRows).toHaveLength(2); * 新建类型抽屉连同两张编辑表(静态种子行 / type_id 禁用 data-col 的 Critical-1 修法 /
/* 种子行里的输入格是手写的,不经 twinInputs —— 直接断言 data-col 存在即可 */ * thead 后置回归网 / data-edit 双写)整体退役 —— 这些钉的是已移除的创建路径,不是被放松的
expect(html).toContain('<input class="jc-cell" data-col="type_name" placeholder="新类型名称">'); * 断言。平台侧机制钉测(view-render-addrow-thead / view-table-edit)不随本活动退役。
expect(html).toContain('<input class="jc-cell" data-col="description"'); */
expect(html).toContain('<input class="jc-cell" data-col="param_name" placeholder="参数名">'); it.skip('**零行也加得出第一行**:两张表各带一条静态种子行(class="jc-row-static"),不靠 data-rows 生成', () => {
expect(html).toContain('<input class="jc-cell" data-col="param_desc"'); expect(true).toBe(true);
expect(html).toContain('<select class="jc-cell" data-col="constraint_type">');
expect(html).toContain('<input class="jc-cell" data-col="param_unit"');
/* 种子行只在编辑态露面 —— 读的时候不该看见一行空壳子(界面不撒谎) */
expect(html).toContain('table:not(.on) tr.jc-row-static { display: none; }');
}); });
/* /*
@@ -603,17 +747,14 @@ describe('件① 评估输入补全', () => {
* 钉住修法:`type_id` 必须挂 `data-col`,且必须是禁用的 —— 人看得见「由系统生成」 * 钉住修法:`type_id` 必须挂 `data-col`,且必须是禁用的 —— 人看得见「由系统生成」
* 这句占位,但怎么点都改不了它的值。 * 这句占位,但怎么点都改不了它的值。
*/ */
it('**新建类型的种子行:type_id 挂 data-col、但禁用恒空**(2026-08-30 修 Critical 1 —— 漏挂会让 resolveRowKey 的"全表一致"把新建的类型误判成既有那一行,静默覆盖它)', () => { /*
expect(html).toContain('由系统生成'); * SKIP(2026-08-31 用户定「把添加芯片类型的功能从这个活动中移除,只能选择已有类型」):
// 「由系统生成」只出现在 chip_type 那条种子行里 —— 精确抓出那一行 * 新建类型抽屉连同两张编辑表(静态种子行 / type_id 禁用 data-col 的 Critical-1 修法 /
const chipTypeStaticRow = /<tr class="jc-row-static">(?:(?!<\/tr>)[\s\S])*?由系统生成(?:(?!<\/tr>)[\s\S])*?<\/tr>/.exec(html)?.[0] ?? ''; * thead 后置回归网 / data-edit 双写)整体退役 —— 这些钉的是已移除的创建路径,不是被放松的
expect(chipTypeStaticRow).not.toBe(''); * 断言。平台侧机制钉测(view-render-addrow-thead / view-table-edit)不随本活动退役。
/* 必须是一个真的 <input data-col>(collect() 只认 input/select/textarea[data-col]), */
不能是纯文本 <span> —— 否则这一格永远不会出现在回传的 cells 里,barred 的保护无从谈起 */ it.skip('**新建类型的种子行:type_id 挂 data-col、但禁用恒空**(2026-08-30 修 Critical 1 —— 漏挂会让 resolveRowKey 的"全表一致"把新建的类型误判成既有那一行,静默覆盖它)', () => {
expect(chipTypeStaticRow).toMatch(/<input[^>]*data-col="type_id"[^>]*disabled/); expect(true).toBe(true);
/* disabled 意味着人怎么点都改不了它的值 —— 保存时这一格恒是空串,才谈得上「barred」 */
expect(chipTypeStaticRow).toContain('data-col="type_name"');
expect(chipTypeStaticRow).toContain('data-col="description"');
}); });
it('**主键列必挂 `data-col`** —— 它不是编辑面,是行的钥匙(漏了会把人改的行错配到别行上)', () => { it('**主键列必挂 `data-col`** —— 它不是编辑面,是行的钥匙(漏了会把人改的行错配到别行上)', () => {
@@ -621,12 +762,28 @@ describe('件① 评估输入补全', () => {
expect(html).toContain('data-col="param_name"'); expect(html).toContain('data-col="param_name"');
}); });
it('可改的四张表各有一道编辑闸 —— 查的是闸([data-edit-for] + 开钮),不是宿主:2026-08-30 换版面第一版恰好漏掉 profile 的闸,宿主在而闸不在,轨道打不开编辑,XL 的必填永远补不上(Task 7 那颗「按不下去的确认钮」同款),旧断言只查宿主没逮到', () => { /*
for (const address of ['parts.profile', 'parts.chip_type', 'parts.type_param', 'parts.parameter']) { * 2026-08-31 用户定:两行编辑闸退役,换成卡右上角的笔↔软盘 —— 笔开当前这一面的编辑
expect(html).toContain(`data-edit="${address}"`); * (正面=主数据 / 背面=参数值),软盘保存,叉放弃。按钮就是平台认的 data-edit-open /
expect(html).toContain(`data-edit-for="${address}"`); * data-edit-save / data-edit-cancel 触发器,快照与取消机制原样。
*/
it('编辑收进卡角:笔(open)/软盘(save)/叉(cancel)正反两面各一套;地址只剩 profile 与 parameter', () => {
for (const address of ['parts.profile', 'parts.parameter']) {
expect(html).toContain(`data-edit-open="${address}"`); expect(html).toContain(`data-edit-open="${address}"`);
expect(html).toContain(`data-edit-save="${address}"`);
expect(html).toContain(`data-edit-cancel="${address}"`);
} }
/* 老的闸行不再出现在正文里(样式表里的基座规则给 ② 用,不算) */
expect(html.slice(html.indexOf('</style>'))).not.toContain('jc-editgate');
/* 笔↔软盘的按面显隐由样式表判 */
expect(html).toContain('.dossier.on .disk-f{display:grid}');
expect(html).toContain(".dossier:has(.pback.on) .disk-b{display:grid}");
/* 参数瓷贴的输入孪生件带占位提示(渲染器认 data-ph) */
expect(html).toContain('data-ph="规格书上的值 · 查不到留空"');
expect(html).toContain('data-ph="补充说明 · 选填"');
/* 题头回浅蓝渐变(2026-08-31 用户:「标题背景还是改成之前的那种蓝色渐变」) */
expect(html).toContain('class="card hd-light"');
expect(html).toContain('linear-gradient(120deg,#f0f5ff 0%,#fff 55%,#eef4ff 100%)');
}); });
/* /*
@@ -636,11 +793,14 @@ describe('件① 评估输入补全', () => {
* 内层 `data-rows` 那个 `<tbody>` 一处(给 `renderRows` 的 `editable` 检测看, * 内层 `data-rows` 那个 `<tbody>` 一处(给 `renderRows` 的 `editable` 检测看,
* 它只认与 `data-rows` 同一个标签自带的属性,不看祖先)。 * 它只认与 `data-rows` 同一个标签自带的属性,不看祖先)。
*/ */
it('新建类型 / 新建字段两张表的 data-edit 各出现两次(外层 table + 内层 data-rows tbody)', () => { /*
for (const address of ['parts.chip_type', 'parts.type_param']) { * SKIP(2026-08-31 用户定「把添加芯片类型的功能从这个活动中移除,只能选择已有类型」):
const count = (html.match(new RegExp(`data-edit="${address}"`, 'g')) ?? []).length; * 新建类型抽屉连同两张编辑表(静态种子行 / type_id 禁用 data-col 的 Critical-1 修法 /
expect(count, address).toBe(2); * thead 后置回归网 / data-edit 双写)整体退役 —— 这些钉的是已移除的创建路径,不是被放松的
} * 断言。平台侧机制钉测(view-render-addrow-thead / view-table-edit)不随本活动退役。
*/
it.skip('新建类型 / 新建字段两张表的 data-edit 各出现两次(外层 table + 内层 data-rows tbody)', () => {
expect(true).toBe(true);
}); });
/* /*
@@ -667,20 +827,14 @@ describe('件① 评估输入补全', () => {
* 谁若把 `<thead>` 挪回两个 `<tbody>` 之前(哪怕只改一张表),这条测试立刻标红, * 谁若把 `<thead>` 挪回两个 `<tbody>` 之前(哪怕只改一张表),这条测试立刻标红,
* 提醒去核对 `view-render-addrow-thead.test.ts` 是否也需要同步。 * 提醒去核对 `view-render-addrow-thead.test.ts` 是否也需要同步。
*/ */
it('**回归安全网**:两张新表的 <thead> 都排在两个 <tbody> 之后(不是最前面)—— 与 view-render-addrow-thead.test.ts 的假设锁在一起', () => { /*
for (const address of ['parts.chip_type', 'parts.type_param']) { * SKIP(2026-08-31 用户定「把添加芯片类型的功能从这个活动中移除,只能选择已有类型」):
const tableStart = html.indexOf(`<table data-edit="${address}">`); * 新建类型抽屉连同两张编辑表(静态种子行 / type_id 禁用 data-col 的 Critical-1 修法 /
expect(tableStart, address).toBeGreaterThanOrEqual(0); * thead 后置回归网 / data-edit 双写)整体退役 —— 这些钉的是已移除的创建路径,不是被放松的
const tableEnd = html.indexOf('</table>', tableStart); * 断言。平台侧机制钉测(view-render-addrow-thead / view-table-edit)不随本活动退役。
const tableHtml = html.slice(tableStart, tableEnd); */
const theadAt = tableHtml.indexOf('<thead>'); it.skip('**回归安全网**:两张新表的 <thead> 都排在两个 <tbody> 之后(不是最前面)—— 与 view-render-addrow-thead.test.ts 的假设锁在一起', () => {
const tbodyPositions = [...tableHtml.matchAll(/<tbody/g)].map((m) => m.index ?? -1); expect(true).toBe(true);
expect(theadAt, `${address}: 找不到 <thead>`).toBeGreaterThanOrEqual(0);
expect(tbodyPositions.length, `${address}: 应有两个 <tbody>`).toBe(2);
// <thead> 必须排在两个 <tbody> 的开标签**之后**(源码序;视觉呈现由 CSS 的
// table-header-group 保证仍在最前,与源码序无关)
for (const pos of tbodyPositions) expect(theadAt, address).toBeGreaterThan(pos);
}
}); });
/* /*
@@ -697,113 +851,80 @@ describe('件① 评估输入补全', () => {
expect(html).toContain('.jc-gate-bar:has(.jc-gate-text:empty)'); expect(html).toContain('.jc-gate-bar:has(.jc-gate-text:empty)');
}); });
/*
* 2026-08-31 用户:「页面上所有的问题都合并到最上面的还差什么,不要在某一个板块显示
* 类似还差 1 行必填没填这种提示」—— 逐块 gapbar 撤下,点名并进顶条(同一批基座读数,
* 数值 0 的那行由样式表自己收起来)。
*/
it('都齐了红条整条收起:开关是案卷真数据 gate_state(v-clear),不看那句话空不空', () => {
expect(html).toContain('class="jc-gate-bar" data-class-of="gate_state"');
expect(html).toContain('.jc-gate-bar.v-clear{display:none}');
expect(colOf('sub_eval.case', 'gate_state')?.tally).toEqual(['open', 'clear']);
});
/*
* 一张件两副面孔(2026-08-31 用户定:「就算数据是足的也进入这个数据工单,就派发一个验收工单,
* 界面和数据要和现在的第一个数据工单一致,只不过把芯片卡片上的编辑按钮去掉…标题旁边的
* 数据工单改成验收工单」)—— 数据与版面一个字不改,只有题头/标题/页脚三处名字与卡角那组
* 编辑钮随案卷的 `order_kind` 换。**收的是容器 `.fabs`**,不是逐枚钮:那几枚各自的 display
* 由 `html.editable …` 一串更特异的规则给着,逐枚去盖打不完。
*/
it('验收工单那一副:题头/标题/页脚换名 + 卡角编辑钮整组收起(同一张件,数据不动)', () => {
/* 换脸的钩子挂在最外层 .card 上 —— 判断留在样式表,模版只投类 */
expect(html).toContain('data-class-of="order_kind"');
for (const pair of ['<span class="k-data">数据工单</span><span class="k-acc">验收工单</span>', '<span class="k-data">数据</span><span class="k-acc">验收</span>']) {
expect(html).toContain(pair);
}
expect(html).toContain('<span class="k-data">差异评估的输入还差几格</span><span class="k-acc">差异评估的输入核对</span>');
/* 默认(旗还没写过 / 老单)是数据工单那一副 —— 与从前一模一样 */
expect(html).toContain('.k-acc { display: none; }');
expect(html).toContain('.v-accept .k-data { display: none; }');
expect(html).toContain('.v-accept .fabs { display: none; }');
expect(colOf('sub_eval.case', 'order_kind')?.tally).toEqual(['data', 'accept']);
});
/*
* 2026-08-31 用户:「标题那一块需要是验收卡的那种绿色」—— 验收工单那一副整套强调色换成
* 验收绿 #0aa294(与件②③、对话区验收卡同一枚),题头那条写死的浅蓝渐变单独给一条绿的。
* 令牌从 `ACCENT_LEDGER` 现改选择器得来:**数值只有一处真源**,件②③ 改色时这一副跟着走。
*/
it('验收工单那一副是验收绿:整套令牌换成 #0aa294,题头渐变也换掉(数值与件②③ 同源)', () => {
expect(html).toContain('.card.v-accept{');
expect(html).toContain('.card.v-accept.hd-light .card-head{background:linear-gradient(');
/* 蓝那一套仍在(数据工单那一副),两套各就各位 */
expect(html).toContain('--acc:#2e6bff');
const acceptBlock = html.slice(html.indexOf('.card.v-accept{'), html.indexOf('.card.v-accept.hd-light'));
expect(acceptBlock).toContain('--acc:#0aa294');
expect(acceptBlock).toContain('--ledger:#0aa294');
});
it('缺项点名全并进顶条「还差什么」,正文不再有逐块红带', () => {
expect(html).toContain('class="jc-gate-sub" data-class-of="parts_profile_missing"');
expect(html).toContain('data-slot="parts_profile_missing_keys"');
expect(html).toContain('data-slot="parts_parameter_missing_keys"');
expect(html).toContain('html.editable .jc-gate-sub.v-0{display:none}');
expect(html.slice(html.indexOf('</style>'))).not.toContain('jc-gapbar');
});
it('表宽超出要能横着滑 —— 不许写成 overflow:hidden(E04/I01 抄错过一次)', () => { it('表宽超出要能横着滑 —— 不许写成 overflow:hidden(E04/I01 抄错过一次)', () => {
expect(html).not.toMatch(/\.jc-tbl-wrap\s*\{[^}]*overflow\s*:\s*hidden/); expect(html).not.toMatch(/\.jc-tbl-wrap\s*\{[^}]*overflow\s*:\s*hidden/);
}); });
/* /*
* 2026-08-29 评审 Important:BIND_COMMON 的序号计数器规则原来只认 `.tbl-wrap`,而工单件的表 * 2026-08-29 评审 Important:BIND_COMMON 的序号计数器规则原来只认 `.tbl-wrap`
* 走平台的 `.jc-tbl-wrap` —— 两个 class 对不上,计数器永不触发,「#」列逐行空白。 * (继承自已删除的旧 P08_MASTER_CREATE,同一个 wrapper class,同一个坑),
* 2026-08-30 换版面后这张件只剩面板里两张表(chip_type / type_param),包装是 * 而这张件的三张表全用平台的 `.jc-tbl-wrap` —— 两个 class 对不上,计数器永不触发,
* `jc-tbl-wrap tbl-wrap` 双类(平台钩子 + 目标件的边框圆角);轨道与瓷贴没有 # 列, * 只读态与编辑态下「#」列逐行都是空白。钉死 `.jc-tbl-wrap` 现在也接了这五条规则,
* 不走计数器。钉死:五条计数器规则还在,且两张表的双类包装恰好两处。 * 且这张件确实只用 `.jc-tbl-wrap`(没有表在用没打这条补丁就会数不出来的 `.tbl-wrap`)。
*/ */
it('面板两张表的序号列真的会数:计数器规则在,双类包装恰两处', () => { /*
expect(html).toMatch(/\.jc-tbl-wrap\s+table\s*,\s*\.tbl-wrap\s+table\s*\{\s*counter-reset:\s*rn;?\s*\}/); * SKIP(2026-08-31 用户定「把添加芯片类型的功能从这个活动中移除,只能选择已有类型」):
expect(html).toMatch(/\.jc-tbl-wrap\s+table\s+td\.rn\s*,\s*\.tbl-wrap\s+table\s+td\.rn\s*\{\s*counter-increment:\s*rn;?\s*\}/); * 新建类型抽屉连同两张编辑表(静态种子行 / type_id 禁用 data-col 的 Critical-1 修法 /
expect(html).toMatch(/\.jc-tbl-wrap\s+table\s+td\.rn\s+\.jc-rn-num::before\s*,\s*\.tbl-wrap\s+table\s+td\.rn\s+\.jc-rn-num::before\s*\{\s*content:\s*counter\(rn\);?\s*\}/); * thead 后置回归网 / data-edit 双写)整体退役 —— 这些钉的是已移除的创建路径,不是被放松的
const wrapCount = html.match(/class="jc-tbl-wrap tbl-wrap"/g)?.length ?? 0; * 断言。平台侧机制钉测(view-render-addrow-thead / view-table-edit)不随本活动退役。
expect(wrapCount).toBe(2); */
// 不许再有单类的表包装 —— 单 .jc-tbl-wrap 缺目标件的边框,单 .tbl-wrap 缺平台的 :has 加行钩子 it.skip('抽屉里两张表的序号列真的会数:.jc-tbl-wrap 也接了 CSS 计数器(不再只认 .tbl-wrap)', () => {
expect(html).not.toMatch(/class="jc-tbl-wrap"/); expect(true).toBe(true);
expect(html).not.toMatch(/class="tbl-wrap"/);
});
/* ==================== 2026-08-30 换版面(照 docs/ss02-act-05-new 新目标件)专属的钉子 ==================== */
it('**每个台账地址恰一个收值宿主**(chip_type/type_param 的两处是「外层 table + 内层 tbody」同宿主写法):bodyOf 是单 querySelector,同一地址挂两个各自独立的宿主,第二个的值一格都收不上去且不报错(终审 C1 / Task 7b 的族坑)', () => {
const countOf = (addr: string): number => (html.match(new RegExp(`data-edit="${addr.replace('.', '\\.')}"`, 'g')) ?? []).length;
expect(countOf('parts.profile')).toBe(1);
expect(countOf('parts.parameter')).toBe(1);
expect(countOf('parts.chip_type')).toBe(2);
expect(countOf('parts.type_param')).toBe(2);
});
it('**两条轨道:行源与写向同一元素,行模版是宿主直接子元素**(rowOf 认宿主直接子元素 —— 一条 lane = 一行;行模版里不许再有 <section>,行展开按第一个同名闭合标签截行内容)', () => {
expect(html).toMatch(/<article class="lanes" data-rows="mine:sub_eval\.part" data-edit="parts\.profile">\s*<section class="lane">/);
const laneBody = /<section class="lane">([\s\S]*?)<\/section>/.exec(html)?.[1] ?? '';
expect(laneBody).not.toBe('');
expect(laneBody).not.toContain('<section');
});
it('**参数瓷贴单块平铺,瓷贴是宿主直接子元素** —— 按料嵌一层分栏会把一颗料的所有参数格并成一条记录(ui-kit view-container-collect-div-rows.test.ts 用真实 renderView+collect 钉死的「必然归并」)', () => {
expect(html).toMatch(/<article class="pvgrid" data-rows="mine:parts\.parameter" data-edit="parts\.parameter"><section class="pv">/);
const pvBody = /<section class="pv">([\s\S]*?)<\/section>/.exec(html)?.[1] ?? '';
expect(pvBody).not.toBe('');
expect(pvBody).not.toContain('<section');
/* 瓷贴的主键钥匙(internal_pn / param_name)收在 .rowkey 里 —— 藏起来但必须真挂 data-col */
expect(pvBody).toContain('class="rowkey"');
expect(pvBody).toContain('data-col="internal_pn"');
expect(pvBody).toContain('data-col="param_name"');
expect(html).toContain('.pv .rowkey{display:none}');
});
it('**data-rows 宿主的闭合标签不许出现在自己的行模版里** —— renderRows 的正则按宿主闭合标签非贪婪截取,撞上就把行模版截半、后一行嵌进前一行没闭合的标签里(2026-08-30 浏览器走查逮到:<div class="lanes"> 被 lab 那格的 </div> 截断,档案卡串行)。判法:照 ROWS 正则同款截取,截出来的行模版必须**标签配平**(截断必然留下没闭合的标签 —— 直接查「不含闭合标签」是空话,非贪婪截取本来就停在闭合标签之前)', () => {
const hosts = [...html.matchAll(/<([a-zA-Z][\w-]*)\b[^>]*\bdata-rows="[^"]+"[^>]*>([\s\S]*?)<\/\1>/g)];
expect(hosts.length).toBeGreaterThanOrEqual(2);
const VOID = new Set(['input', 'br', 'img', 'hr', 'meta', 'link']);
for (const m of hosts) {
const inner = m[2] ?? '';
const opens = new Map<string, number>();
for (const tm of inner.matchAll(/<(\/?)([a-zA-Z][\w-]*)\b/g)) {
const tag = (tm[2] ?? '').toLowerCase();
if (VOID.has(tag)) continue;
opens.set(tag, (opens.get(tag) ?? 0) + (tm[1] === '/' ? -1 : 1));
}
for (const [tag, n] of opens) {
expect(n, `data-rows 宿主 <${m[1]}> 截出的行模版里 <${tag}> 开 ${n} 个没闭合 —— 宿主的闭合标签把模版截半了`).toBe(0);
}
}
});
it('**类型不一致护栏**:distinct 是基座数,红带只在 v-2 档现身,模版不算不判', () => {
expect(html).toMatch(/class="jc-typewarn" data-class-of="sub_eval_part_distinct_chip_type_id"/);
expect(html).toContain('.jc-typewarn{display:none}');
expect(html).toContain('.jc-typewarn.v-2{display:flex');
});
it('**步骤圆点只按基座数点亮,投影挂在行外的包裹层** —— 行内 data-class-of 只认行作用域,作用域级读数在行内投不出(实测圆点永远不亮);圆点自己不许再挂 data-class-of(挂了也是死属性)', () => {
expect(html).toContain('class="pj pj-profile" data-class-of="parts_profile_missing"');
expect(html).toContain('class="pj pj-type" data-class-of="sub_eval_part_distinct_chip_type_id"');
expect(html).toContain('class="pj pj-param" data-class-of="parts_parameter_missing"');
expect(html).toContain('.pj-profile.v-0 .sdot.sd-1,.pj-type.v-1 .sdot.sd-2,.pj-param.v-0 .sdot.sd-3{background:var(--data)');
expect(html).not.toMatch(/class="sdot[^"]*" data-class-of=/);
});
it('**类型查名有诚实守卫**(data-by 行缺键落回外层同名字段,终审 I3):行里没真选上类型(selectCurrent 没写下 option[selected]),查名 pill 与字段行的名字都不摆', () => {
expect(html).toContain('.lane:not(:has(select[data-col="chip_type_id"] option[selected])) .idcard .tpill{display:none}');
expect(html).toContain('.lane:not(:has(select[data-col="chip_type_id"] option[selected])) .tywrap span{display:none}');
expect(html).toContain('class="jc-vcell tywrap"');
});
it('**新增类型面板是 <details>,且绑定层把抽取样式的 display:none 让开** —— 少了这条让位规则,整个面板(含两道闸与两张表)永远不可见', () => {
expect(html).toContain('<details class="newtype">');
expect(html).toContain('details.newtype{display:block');
});
it('**目标件里靠脚本驱动的两样,搬过来时如实退掉**:进度条 gateprog 的分母不是基座现有的数(编一个就是界面撒谎);viewarea/editarea 双区靠 body.editing 切换,沙箱零脚本切不动 —— 抽取的 CSS 里留着它们的规则,但标记一处都不许用', () => {
const body = html.slice(html.indexOf('</style>'));
expect(body).not.toContain('gateprog');
expect(body).not.toContain('class="viewarea"');
expect(body).not.toContain('class="editarea"');
/* .editarea{display:none} 还在 CSS 里 —— 谁把内容包进它,那块就永远消失 */
expect(html).toContain('.editarea{display:none}');
});
it('**保存即铺格之前的空态**:pvnull 按 parts_parameter_rows 的 v-0 档现身(零行才摆提示,有行不摆 —— 界面不撒谎)', () => {
expect(html).toContain('class="pv-empty pvnull" data-class-of="parts_parameter_rows"');
expect(html).toContain('.pvnull{display:none}');
expect(html).toContain('.pv-empty.pvnull.v-0{display:block}');
}); });
}); });
@@ -835,12 +956,30 @@ describe('件② 差异评估结论审核', () => {
expect(html).toContain('data-col="difference_item_id"'); expect(html).toContain('data-col="difference_item_id"');
}); });
it('**推荐理由 / 摘要 / 风险 / 验证要求只读** —— 让人直接改摘要,等于让他给自己没做的判断盖章', () => { /**
* **收口四格可就地改**(2026-08-31 用户:「差异评估结论审核这个界面的收口结论下面这 4 个
* 字段也需要可以编辑」)。此前是只读的,理由是「让人直接改,等于给自己没做的判断盖章」——
* 用户改了这个口径:审核人对结论有最终发言权。
*
* **配方那一侧必须一起改,否则等于没开**:`RELATION_PROJECTION` 每跑一次就把这四列按规则
* 重写一遍,而人在件上按保存会触发 quiet 拍、quiet 恰恰跑 project —— 人改完一保存就被冲掉。
* S3 因此换成只重算判定的 `RELATION_RECOMPUTE`(那一组断言在「S3 只重算判定」里)。
*/
it('**收口四格可就地改**:三句话走 cell(只读份 + 孪生输入)、风险走徽标 + select,整块一个宿主一道闸', () => {
for (const col of ['recommendation_reason', 'difference_summary', 'verification_requirement']) { for (const col of ['recommendation_reason', 'difference_summary', 'verification_requirement']) {
expect(html).toContain(`data-slot="${col}"`); expect(html, col).toContain(`data-slot="${col}"`);
expect(html).not.toContain(`data-col="${col}"`); expect(html, col).toContain(`data-col="${col}"`);
} }
expect(html).not.toContain('data-edit="substitution.relation"'); /* 风险是枚举:只读态摆徽标(不裸显 low/medium/high),编辑态换 select */
expect(html).toContain('class="risk-badge" data-class-of="risk_level"');
expect(html).toMatch(/<select class="jc-cell" data-col="risk_level" required>/);
/* 一个地址一个宿主(bodyOf 是单 querySelector),且必须配一道闸 —— 闸不在就打不开(Task 7 那颗按不下去的钮) */
expect((html.match(/data-edit="substitution\.relation"/g) ?? []).length).toBe(1);
expect(html).toContain('data-edit-for="substitution.relation"');
expect(html).toContain('data-edit-open="substitution.relation"');
/* 主键钥匙在场且藏起来 —— 漏了钥匙这一行凑不出主键(2026-08-02 栽过) */
expect(html).toContain('data-col="substitution_relation_id"');
expect(html).toContain('.summary-box .rowkey{display:none}');
}); });
it('等级与四态用 data-class-of 上色 —— 判断留在样式表,模版不判', () => { it('等级与四态用 data-class-of 上色 —— 判断留在样式表,模版不判', () => {
@@ -862,7 +1001,9 @@ describe('件② 差异评估结论审核', () => {
for (const col of [ for (const col of [
'parameter_name', 'parameter_desc', 'constraint_type', 'parameter_name', 'parameter_desc', 'constraint_type',
'original_value', 'candidate_value', 'parameter_unit', 'original_value', 'candidate_value', 'parameter_unit',
'recommendation_level', 'risk_level', /* 推荐等级**仍然只读**:它是规则按四态分布算出来的判据核心,人改四态即改它,不容手改。
风险(risk_level)2026-08-31 起交给人,已从这份名单里移出 —— 见「收口四格可就地改」。 */
'recommendation_level',
]) { ]) {
expect(html, col).not.toContain(`data-col="${col}"`); expect(html, col).not.toContain(`data-col="${col}"`);
} }
@@ -1045,10 +1186,9 @@ describe.skip('徽标别名与值域相关的坑(2026-08-27 实测账,回归网)
*/ */
describe('可编辑件的主键与必填列(2026-08-27 复审:主键丢钥匙 / 必填填不了,两条 Critical 回归;Task 7b 补 parts.chip_type)', () => { describe('可编辑件的主键与必填列(2026-08-27 复审:主键丢钥匙 / 必填填不了,两条 Critical 回归;Task 7b 补 parts.chip_type)', () => {
/** input-supplement 一屏四张真能编辑的表与它们各自绑定的台账对象(Task 7b 加入 parts.chip_type) */ /** input-supplement 一屏四张真能编辑的表与它们各自绑定的台账对象(Task 7b 加入 parts.chip_type) */
/* 2026-08-31「只选不建」:chip_type / type_param 不再是可编辑面,从名单撤下 */
const EDITABLE: ReadonlyArray<{ view: string; domain: string; object: string }> = [ const EDITABLE: ReadonlyArray<{ view: string; domain: string; object: string }> = [
{ view: 'input-supplement', domain: 'parts', object: 'profile' }, { view: 'input-supplement', domain: 'parts', object: 'profile' },
{ view: 'input-supplement', domain: 'parts', object: 'chip_type' },
{ view: 'input-supplement', domain: 'parts', object: 'type_param' },
{ view: 'input-supplement', domain: 'parts', object: 'parameter' }, { view: 'input-supplement', domain: 'parts', object: 'parameter' },
]; ];
@@ -1109,19 +1249,25 @@ const CONTRACT = {
discretion: [], discretion: [],
} as const; } as const;
/* 验收件题头是验收绿(2026-08-27 用户定:与对话区验收卡同色,不再是目标件的红) */ /*
describe('两张验收件的主题色', () => { * 验收件的强调色是验收绿(2026-08-27 用户定:与对话区验收卡同色,不再是目标件的红)。
it('ACC1 / ACC2 的 --ledger 是验收绿 #0aa294,题头渐变不再带红;CONDITIONAL 徽标保留语义红', async () => { * 2026-08-31 视图层重设计后,样式真源从 target-style.ts(逐字节抽自目标件,随重设计退役)
const { ACC1_CSS, ACC2_CSS } = await import('../src/views/target-style.ts'); * 换成 style.ts 的强调色令牌 —— 这条钉的用户口径不变:验收两件 #0aa294、数据工单数据蓝、
for (const css of [ACC1_CSS, ACC2_CSS]) { * CONDITIONAL 保持独立语义色(第四态,不与 MATCH 绿 / MISMATCH 红 / UNKNOWN 琥珀混用)、
expect(css).toContain('--ledger:#0aa294'); * 目标件时代的题头红 #b23a2e 不再出现。
expect(css).not.toContain('--ledger:#b23a2e'); */
expect(css).not.toContain('#fdf3f1'); describe('三张件的主题锚', () => {
expect(css).toContain('rgba(10,162,148,.25)'); it('验收两件强调色 #0aa294;数据工单数据蓝;CONDITIONAL 独立语义色;目标件的红不再出现', () => {
const review = viewHtml('difference-review');
const landing = viewHtml('landing-confirm');
for (const html of [review, landing]) {
expect(html).toContain('--acc:#0aa294');
expect(html).toContain('--ledger:#0aa294');
expect(html).not.toContain('#b23a2e');
} }
// ACC2 的 CONDITIONAL 徽标保留目标件的语义红;ACC1 目标件 2026-08-27 改版把它改成了品牌蓝,照抄 expect(viewHtml('input-supplement')).toContain('--acc:#2e6bff');
expect(ACC2_CSS).toContain('.st.cond{color:#b23a2e'); expect(review).toContain('--cond:#5661f0');
expect(ACC1_CSS).toContain('.st.cond{color:var(--brand-deep)'); expect(review).toContain('.st.v-conditional');
}); });
}); });
@@ -146,39 +146,66 @@ describe('S1 配方', () => {
* 变成空字符串)见 `apps/api/src/modules/loop-engine/__tests__/w05-gate-reason-order.test.ts` * 变成空字符串)见 `apps/api/src/modules/loop-engine/__tests__/w05-gate-reason-order.test.ts`
* ——这份文件只做结构断言,不跑引擎(活动包不许 import apps/*)。 * ——这份文件只做结构断言,不跑引擎(活动包不许 import apps/*)。
*/ */
it('八笔,次序是:两颗料 → 类型字典自投 → 字段清单自投 → 主数据骨架 → 参数骨架 → 案卷 → 卡 → 交代', () => { it('七笔,次序是:两颗料 → 主数据骨架 → 参数骨架 → 案卷 → 数据工单 → 验收工单 → 交代', () => {
expect(kinds).toEqual(['project', 'project', 'project', 'project', 'project', 'project', 'card', 'say']); expect(kinds).toEqual(['project', 'project', 'project', 'project', 'card', 'card', 'say']);
expect(acts.map((a) => (a.kind === 'project' ? a.into : undefined)).filter(Boolean)).toEqual([ expect(acts.map((a) => (a.kind === 'project' ? a.into : undefined)).filter(Boolean)).toEqual([
'sub_eval.part', 'parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter', 'sub_eval.case', 'sub_eval.part', 'parts.profile', 'parts.parameter', 'sub_eval.case',
]); ]);
}); });
/*
* **数据齐了也叫人一次**(2026-08-31 用户定:「就算数据是足的也进入这个数据工单,就派发一个
* 验收工单…把芯片卡片上的编辑按钮去掉,只展示不能编辑」)。两张卡是互斥的两条路,不是先后:
* 引擎的 `carded` 幂等保证一步只发一张 —— 数据工单发过之后,验收工单这一笔永远轮不到
* (补完数据不会再被叫第二次)。
*/
it('两张卡:缺东西派数据工单(读闸)· 都齐了派只读验收工单(读上一笔刚写下的粘旗)', () => {
const cards = acts.filter((a) => a.kind === 'card');
expect(cards.map((c) => (c.kind === 'card' ? c.variant : undefined))).toEqual(['data', 'accept']);
/* 验收工单不重算一遍闸 —— 读的是案卷刚写下的旗,同一件事只许有一处判定 */
expect(cards[1]?.kind === 'card' && cards[1].when).toBe('sub_eval_case_order_kind_accept > 0');
/* 两张都是执行工单(验收工单是它的变体,不是第三种 mode);零输入,填报/核对都在件上 */
for (const c of cards) expect(c.kind === 'card' && c.as).toBe('execution');
/* 闸同一份:件只读也要在确认那一刻再数一遍「数据是齐的」 */
for (const c of cards) expect(c.kind === 'card' && c.gateLedgers).toEqual(['parts.profile', 'parts.parameter']);
});
/*
* 旗**粘着**:第一档读的是上一拍写下的自己(tally 读数)。不粘着的后果实打实 ——
* 人在数据工单上把最后一格填上、还没点确认,闸当场变 clear,件的题头会当场改名叫
* 「验收工单」、卡角的笔也没了,而他手里那张卡还开着。
*/
it('order_kind 粘着:已经是 data 就一直是 data,否则按此刻的闸决定(与发卡条件同一句)', () => {
const caseAct = acts.find((a) => a.kind === 'project' && a.into === 'sub_eval.case');
const kind = caseAct?.kind === 'project' ? caseAct.fields['order_kind'] : undefined;
const gate = acts[4]?.kind === 'card' ? acts[4].when : undefined;
expect(kind).toEqual({
when: 'sub_eval_case_order_kind_data > 0',
then: 'data',
/* 第二档与数据工单那张卡读的是同一句闸(一处真源) */
else: { when: gate, then: 'data', else: 'accept' },
});
});
/* /*
* 2026-08-30,Ruling H35:`parts.chip_type`/`parts.type_param` 各自把「选中类型的那一行」 * 2026-08-30,Ruling H35:`parts.chip_type`/`parts.type_param` 各自把「选中类型的那一行」
* 原样投一遍(值不变,幂等)——目的不是产出新数据,是让 `mine:` 这两张表不再天然为零行, * 原样投一遍(值不变,幂等)——目的不是产出新数据,是让 `mine:` 这两张表不再天然为零行,
* 好让它们能安全回到下面 ⑦ 卡的 `gateLedgers` 上(闸不会再拿「零行」误判)。 * 好让它们能安全回到下面 ⑦ 卡的 `gateLedgers` 上(闸不会再拿「零行」误判)。
* *
* **不带 `prune`**(2026-08-30 第二处修正,e2e 案例 2 撞出来的):这两张表在 ⑦ 的 * **带 `prune`,已落地的行由基座 `skipLanded` 跳过**(2026-08-31 改回)。曾特意不带
* `gateLedgers` 上,一旦人交回数据工单就随「数据工单确认即入库」落进正式表——已落地的行 * `prune`(2026-08-30 e2e 案例 2:已落地的旧类型行撤不掉,配方整单炸)—— 代价是
* `draftDrop`(`prune` 的撤行机制)撤不掉,配方原样抛错。字典类的表允许历史选型留在 * **没落地的**陈旧行也不清:人把候选从 LDO 改回 DC-DC,LDO 的字典/字段行积在屏上
* 草稿层(不算错),不像 `parts.profile`/`parts.parameter` 那几笔(它们仍然 `prune:true`, * (2026-08-31 用户实测点名「下面还是显示两个类型」)。现在 `draftDrop` 对配方这一路
* 见下面「主数据骨架」那笔)必须清掉不再相关的陈旧行。 * 跳过已落地的行(loop-engine/port.ts),prune 回来:没落地的陈旧选型照撤,
* 已落地的原地留下 —— 案例 2 不再炸,用户那条路清得干净。
*/ */
it('②③ 两笔自投影都读 sub_eval.part 的 chip_type_id 去查字典/字段清单,原样抄回自己(不带 prune)', () => { /*
const chipType = acts[1]; * SKIP(2026-08-31 用户定「只选不建」):②③ 两笔自投影连同 gateLedgers 上那两张表
expect(chipType?.kind === 'project' && chipType.into).toBe('parts.chip_type'); * 一并退役 —— 类型只许从字典里选,选中的行本来就在正式表里,镜像与落地都失去了对象。
expect(chipType?.kind === 'project' && chipType.prune).toBeUndefined(); * 这条钉的机制(prune + skipLanded)仍活在 auto-runner 测试里,不随本活动退役。
expect(chipType?.kind === 'project' && chipType.from).toMatchObject({ */
each: 'mine:sub_eval.part', it.skip('②③ 两笔自投影都读 sub_eval.part 的 chip_type_id 去查字典/字段清单,原样抄回自己(带 prune,已落地由基座跳过)', () => {
then: { each: 'parts.chip_type', where: { type_id: { outer: 'chip_type_id' } } }, expect(true).toBe(true);
});
const typeParam = acts[2];
expect(typeParam?.kind === 'project' && typeParam.into).toBe('parts.type_param');
expect(typeParam?.kind === 'project' && typeParam.prune).toBeUndefined();
expect(typeParam?.kind === 'project' && typeParam.from).toMatchObject({
each: 'mine:sub_eval.part',
then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } },
});
}); });
it('案号由 let 铸,只从本单开案时刻取材', () => { it('案号由 let 铸,只从本单开案时刻取材', () => {
@@ -202,7 +229,7 @@ describe('S1 配方', () => {
* 「加一行」),必须换成说实情、给真出路的话:没法比出差异,终止重开。 * 「加一行」),必须换成说实情、给真出路的话:没法比出差异,终止重开。
*/ */
it('gate_reason 第一档:两参必填之后只剩"填成同一个号"这条残余路径,文案不再指向不存在的"补两格"', () => { it('gate_reason 第一档:两参必填之后只剩"填成同一个号"这条残余路径,文案不再指向不存在的"补两格"', () => {
const kase = acts[5]; const kase = acts[3];
const fields = kase?.kind === 'project' ? kase.fields : undefined; const fields = kase?.kind === 'project' ? kase.fields : undefined;
const gateReason = fields?.['gate_reason'] as { when?: string; then?: string } | undefined; const gateReason = fields?.['gate_reason'] as { when?: string; then?: string } | undefined;
expect(gateReason?.when).toBe('sub_eval_part_rows < 2'); expect(gateReason?.when).toBe('sub_eval_part_rows < 2');
@@ -212,8 +239,9 @@ describe('S1 配方', () => {
}); });
it('参数骨架:两颗料都铺、值回填现值 —— 「在库但当年参数没录全」也要被拦住', () => { it('参数骨架:两颗料都铺、值回填现值 —— 「在库但当年参数没录全」也要被拦住', () => {
const params = acts[4]; const params = acts[2];
expect(params?.kind === 'project' && params.prune).toBe(true); /* 'live':人加不了行的表,quiet 拍也撤 —— 改类型后旧参数行跟手消失(2026-08-31 用户点名「只会变多不会变少」) */
expect(params?.kind === 'project' && params.prune).toBe('live');
const from = params?.kind === 'project' ? params.from : undefined; const from = params?.kind === 'project' ? params.from : undefined;
/* 外层是本单两颗料,里层是该类型的字段清单 —— 人没挑类型时 where 一行不命中,什么也不铺 */ /* 外层是本单两颗料,里层是该类型的字段清单 —— 人没挑类型时 where 一行不命中,什么也不铺 */
expect(from).toMatchObject({ each: 'mine:sub_eval.part', then: { each: 'parts.type_param' } }); expect(from).toMatchObject({ each: 'mine:sub_eval.part', then: { each: 'parts.type_param' } });
@@ -222,7 +250,7 @@ describe('S1 配方', () => {
}); });
it('**发卡的条件全是基座读数** —— 不许拿 `gate_reason != ""` 当条件', () => { it('**发卡的条件全是基座读数** —— 不许拿 `gate_reason != ""` 当条件', () => {
const card = acts[6]; const card = acts[4];
const when = card?.kind === 'card' ? (card.when ?? '') : ''; const when = card?.kind === 'card' ? (card.when ?? '') : '';
/* /*
* S-FEEL 里**未绑定的名字 `!= ""` 求值为 true**,而 `gate_reason` 为空串时那一列压根不写、 * S-FEEL 里**未绑定的名字 `!= ""` 求值为 true**,而 `gate_reason` 为空串时那一列压根不写、
@@ -243,16 +271,32 @@ describe('S1 配方', () => {
* (2026-08-30 真跑 e2e 撞见)。现在四张表都在闸上、也都会一起落地。 * (2026-08-30 真跑 e2e 撞见)。现在四张表都在闸上、也都会一起落地。
*/ */
it('卡:数据工单、四张表都在闸上(含 parts.chip_type/parts.type_param,Ruling H35)、且有 basis(基座拒发 basis 为空的工单)', () => { it('卡:数据工单、四张表都在闸上(含 parts.chip_type/parts.type_param,Ruling H35)、且有 basis(基座拒发 basis 为空的工单)', () => {
const card = acts[6]; const card = acts[4];
expect(card?.kind === 'card' && card.as).toBe('execution'); expect(card?.kind === 'card' && card.as).toBe('execution');
expect(card?.kind === 'card' && card.variant).toBe('data'); expect(card?.kind === 'card' && card.variant).toBe('data');
expect(card?.kind === 'card' && card.gateLedgers).toEqual(['parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter']); expect(card?.kind === 'card' && card.gateLedgers).toEqual(['parts.profile', 'parts.parameter']);
/*
* 类型不一致不许确认(2026-08-31 用户定)。钉 parts.profile 本表的 distinct ——
* 提交闸从草稿现读零滞后;钉 sub_eval.part 那份要等配方 tick,人存完就点确认会被误拦。
*/
expect(card?.kind === 'card' && card.gateWhen).toBe('parts_profile_distinct_chip_type_id <= 1');
expect(card?.kind === 'card' && typeof card.gateWhenSay === 'string' && card.gateWhenSay.includes('不是同一个芯片类型')).toBe(true);
expect((card?.kind === 'card' ? card.basis ?? [] : []).length).toBeGreaterThan(0); expect((card?.kind === 'card' ? card.basis ?? [] : []).length).toBeGreaterThan(0);
expect((card?.kind === 'card' ? card.guide ?? [] : []).length).toBeGreaterThanOrEqual(3); expect((card?.kind === 'card' ? card.guide ?? [] : []).length).toBeGreaterThanOrEqual(3);
}); });
it('红条开关 gate_state 与发卡条件同一句判定(一处真源)—— gate_reason 清空不落笔,开关列两个方向都真写', () => {
const kase = acts[3];
const fields = kase?.kind === 'project' ? kase.fields : undefined;
const gs = fields?.['gate_state'] as { when?: string; then?: string; else?: string } | undefined;
const card = acts[4];
expect(gs?.then).toBe('open');
expect(gs?.else).toBe('clear');
expect(card?.kind === 'card' && card.when).toBe(gs?.when);
});
it('发卡条件与 doneWhen 是同一件事的正反面', () => { it('发卡条件与 doneWhen 是同一件事的正反面', () => {
const when = acts[6]?.kind === 'card' ? (acts[6].when ?? '') : ''; const when = acts[4]?.kind === 'card' ? (acts[4].when ?? '') : '';
for (const reading of [ for (const reading of [
'sub_eval_part_exists_status_found', 'sub_eval_part_missing', 'sub_eval_part_exists_status_found', 'sub_eval_part_missing',
'sub_eval_part_distinct_chip_type_id', 'parts_profile_missing', 'parts_parameter_missing', 'sub_eval_part_distinct_chip_type_id', 'parts_profile_missing', 'parts_parameter_missing',
@@ -362,11 +406,96 @@ describe('S3 / S4 配方', () => {
const s4 = stepOf('T_release')?.auto?.do ?? []; const s4 = stepOf('T_release')?.auto?.do ?? [];
const s2 = stepOf('S2_assess')?.auto?.do ?? []; const s2 = stepOf('S2_assess')?.auto?.do ?? [];
it('S3 五笔:差异明细重算 → 关系 → 验证记录 → 卡 → 交代 —— 人改的四态先落地,结论才有真数据可读', () => { it('S3 九笔:圈范围 → 复核重判 → 落明细 → 明细重算 → 关系 → 验证记录 → 两句交代 → 卡', () => {
expect(s3.map((a) => a.kind)).toEqual(['project', 'project', 'project', 'card', 'say']); expect(s3.map((a) => a.kind)).toEqual(['project', 'analyze', 'project', 'project', 'project', 'project', 'say', 'say', 'card']);
expect(s3[0]?.kind === 'project' && s3[0].into).toBe('substitution.difference_item'); for (const at of [0, 2, 3]) expect(s3[at]?.kind === 'project' && s3[at].into).toBe('substitution.difference_item');
expect(s3[1]?.kind === 'project' && s3[1].into).toBe('substitution.relation'); expect(s3[4]?.kind === 'project' && s3[4].into).toBe('substitution.relation');
expect(s3[2]?.kind === 'project' && s3[2].into).toBe('validation.record'); expect(s3[5]?.kind === 'project' && s3[5].into).toBe('validation.record');
});
/*
* **人打回时,意见要交回给 AI 重判**(2026-09-01 用户定:「重做后是否没有将重做原因带回去
* 重新让 ai 重跑这一步?」)—— 在此之前 S3 那一轮只把派生列重算一遍,人写的那句话除了留痕
* 之外**无人读**:AI 判过的四态一个字不会变,除非人自己在件上逐格改。
*
* 只在被打回的那一轮跑:`case_redo_note` 是引擎注给配方的那句话(没打回就是空串)——
* 常路(第一次送审)这两笔跳过,整个活动仍然只调一次模型。
*/
it('复核轮四笔都钉在「人打回了才跑」上 —— 常路一笔都不跑,整单仍只调一次模型', () => {
/* `when` 不是每一种笔都有的键 —— 按「有没有这个键」取,不做属性访问(与本文件既有的 `'prune' in …` 同一写法) */
const whenOf = (at: number): unknown => {
const act = s3[at];
return act !== undefined && 'when' in act ? act.when : undefined;
};
for (const at of [0, 1, 2]) expect(String(whenOf(at) ?? ''), `第 ${at} 笔`).toContain('case_redo_note != ""');
/* 后面那几笔(重算 / 关系 / 验证)照旧每一轮都跑 —— 人就地改了四态也要跟着重算 */
for (const at of [3, 4, 5]) expect(whenOf(at), `第 ${at} 笔`).toBeUndefined();
/*
* **点不到名就一条都不改**(2026-09-01 当天上线当天撤的那一笔:整表重判)——
* 重判与写回都多一个前提「真点到了名」,免得一句与参数无关的话(实测:「收口结论里面的
* 推荐理由改为非常不推荐」)把 5 条参数的结论一起改掉,还白烧一次模型。
*/
for (const at of [1, 2]) {
expect(String(whenOf(at) ?? ''), `第 ${at} 笔`).toContain('substitution_difference_item_redo_scope_named > 0');
}
});
/*
* **谁可以变,由确定的一侧圈**(2026-09-01 用户实测:「我打回让修改工作温度的结论,给我改了
* 静态电流的结论」)—— 第一版把整表交给 AI 重判、整表写回,没有任何东西限制它只动被点名的
* 那一行;它改了没人问过的那条,`evidence_note` 里还写着「维持原判」。
* 现在:①按字面点名圈出 `named`;③入参**只有** `named` 的行;④写回也只写这几行。
*/
it('圈范围:点名的标 named,其余 kept —— 一条都没点到时什么都不改,只把话说清楚', () => {
const mark = s3[0];
const markFields: Readonly<Record<string, unknown>> = mark?.kind === 'project' ? mark.fields : {};
expect(markFields['redo_scope']).toEqual({ when: 'contains(case_redo_note, parameter_name)', then: 'named', else: 'kept' });
/* 没点到名的那一轮:一句交代,告诉他怎么才办得成 —— 而不是猜「他指的是全部」 */
const noHit = s3[7];
expect(noHit?.kind === 'say' && noHit.when).toBe('case_redo_note != "" and substitution_difference_item_redo_scope_named = 0');
const text = noHit?.kind === 'say' ? String(noHit.text) : '';
expect(text).toContain('一条都没改');
expect(text).toContain('参数名');
/* 收口四格不归 AI 判 —— 那几格人在件上直接改(2026-08-31 立的口径) */
expect(text).toContain('件上直接改');
});
it('重判与写回都只认 named 的行 —— 没点名的行 AI 连见都见不到,自然改不着', () => {
const redo = s3[1];
const land = s3[2];
const named = { each: 'mine:substitution.difference_item', where: { redo_scope: 'named' } };
expect(redo?.kind === 'analyze' ? redo.input.from : undefined).toEqual(named);
expect(land?.kind === 'project' ? land.from : undefined).toEqual(named);
/* 判法一处真源:复核指令以第一轮那份规矩打头,后面才接复核纪律 */
const rules = s2[0]?.kind === 'analyze' ? s2[0].instruction : '';
expect(redo?.kind === 'analyze' && redo.instruction.startsWith(rules)).toBe(true);
/*
* **复核轮审核人说了算**(2026-09-01 用户第二次实测:「重做还是没有修改掉内容」)——
* 他写「静态电流结论改成不匹配」,而两颗料的静态电流都是 5mA,AI 搬出第一轮那条
* 「硬约束不因措辞变成 match」把他顶了回去。那条规矩管的是**它自己判**的时候别被措辞带偏;
* 审核人是这一步的责任人(要签他的字,他在件上本来就能亲手改任何一条四态),不归它管。
* 指令里必须**明写这条覆盖**,否则前面那份规矩排在前面、模型照旧照它办。
*/
const tail = redo?.kind === 'analyze' ? redo.instruction.slice(rules.length) : '';
expect(tail).toContain('这一轮不归它管');
expect(tail).toContain('他点名要什么结论,就落什么');
expect(tail).toContain('不要顶回去');
/* 落的同时要把「按谁的意见、数据面是什么」记进依据 —— 下一个人得能分清人定的与数据判的 */
expect(tail).toContain('数据面');
/* 入参:上一轮结论 + 他写的意见 —— 少了任何一样,重判要么无据可依,要么读不到意见 */
const fields: Readonly<Record<string, unknown>> = redo?.kind === 'analyze' ? redo.input.fields : {};
for (const key of ['last_status', 'last_impact', 'last_evidence', 'last_unknown_disposition', 'reviewer_note']) {
expect(Object.keys(fields)).toContain(key);
}
expect(fields['reviewer_note']).toEqual({ in: 'case_redo_note' });
/* 模型漏答某一条:兜回这一行上一轮的值 —— 漏答不该变成「把它清空」 */
const landFields: Readonly<Record<string, unknown>> = land?.kind === 'project' ? land.fields : {};
for (const col of ['difference_status', 'impact_statement', 'evidence_note', 'unknown_disposition']) {
expect(landFields[col]).toMatchObject({ lookup: 'result:items', else: { col } });
}
/* 派生列与两处创建笔共用同一份 when 链 */
const s2Fields: Readonly<Record<string, unknown>> = s2[2]?.kind === 'project' ? s2[2].fields : {};
expect(landFields['judgement_class']).toBe(s2Fields['judgement_class']);
}); });
/* /*
@@ -376,7 +505,7 @@ describe('S3 / S4 配方', () => {
* `s3[0]` 必须是**排在关系 / 验证记录投影之前**的差异明细重算笔。 * `s3[0]` 必须是**排在关系 / 验证记录投影之前**的差异明细重算笔。
*/ */
it('差异明细重算笔:只列主键与两个派生列,不碰 difference_status 自己、也不碰人能改的叙事列', () => { it('差异明细重算笔:只列主键与两个派生列,不碰 difference_status 自己、也不碰人能改的叙事列', () => {
const recompute = s3[0]; const recompute = s3[3];
expect(recompute?.kind === 'project' && recompute.from).toEqual({ each: 'mine:substitution.difference_item' }); expect(recompute?.kind === 'project' && recompute.from).toEqual({ each: 'mine:substitution.difference_item' });
/* `recompute` 经 `s3[0]` 的 tuple 定位精确 narrow 到这一笔自己的字面量类型,它压根没有 /* `recompute` 经 `s3[0]` 的 tuple 定位精确 narrow 到这一笔自己的字面量类型,它压根没有
`prune` 这个键(不是「值为 undefined」,是键不存在)—— `in` 判存在性,不做属性访问 */ `prune` 这个键(不是「值为 undefined」,是键不存在)—— `in` 判存在性,不做属性访问 */
@@ -394,18 +523,43 @@ describe('S3 / S4 配方', () => {
* 展开)——各写一份早晚分叉,而分叉的那一刻屏上会同时摆着两个互相矛盾的规则。 * 展开)——各写一份早晚分叉,而分叉的那一刻屏上会同时摆着两个互相矛盾的规则。
*/ */
it('S3 重算笔与 S2 差异明细创建笔共用同一份 judgement_class / risk_level when 链', () => { it('S3 重算笔与 S2 差异明细创建笔共用同一份 judgement_class / risk_level when 链', () => {
const s3Fields = s3[0]?.kind === 'project' ? s3[0].fields : undefined; const s3Fields = s3[3]?.kind === 'project' ? s3[3].fields : undefined;
const s2Fields = s2[2]?.kind === 'project' ? s2[2].fields : undefined; const s2Fields = s2[2]?.kind === 'project' ? s2[2].fields : undefined;
expect(s2Fields?.['judgement_class']).toBeDefined(); expect(s2Fields?.['judgement_class']).toBeDefined();
expect(s3Fields?.['judgement_class']).toBe(s2Fields?.['judgement_class']); expect(s3Fields?.['judgement_class']).toBe(s2Fields?.['judgement_class']);
expect(s3Fields?.['risk_level']).toBe(s2Fields?.['risk_level']); expect(s3Fields?.['risk_level']).toBe(s2Fields?.['risk_level']);
}); });
it('**S2 与 S3 用的是同一份投影** —— 各写一份早晚分叉', () => { /**
// S2 的 do 数组里 RELATION_PROJECTION 出现两次(acts[1]/acts[3],同一个引用)—— * **S3 的关系笔只重算判定,收口四格一个字不碰**(2026-08-31 用户:「差异评估结论审核这个
// 与 S3 比对随便挑一个都行,这里挑 acts[3](紧跟在差异明细之后、值是真的那一次) * 界面的收口结论下面这 4 个字段也需要可以编辑」)。
expect(s3[1]).toBe(s2[3]); *
expect(s3[2]).toBe(s2[4]); * 此前 S2 与 S3 共用同一份 `RELATION_PROJECTION`(「各写一份早晚分叉」)。件上把四格打开
* 之后那条口径不能再照搬:那一笔每跑一次就按规则重写这四列,而人在件上按保存会触发
* quiet 拍、quiet 恰恰跑 `project` —— 人刚写下的话当场被冲掉,等于格子白开。
*
* 现在 S3 换成 `RELATION_RECOMPUTE`:只列主键 + `recommendation_level`。`patch` 是稀疏的、
* DB 侧是 jsonb 浅合并,没列的列原样留着 —— **这条断言的要害就是「没列」**,列了就等于没改。
* 而 `recommendation_level` 必须仍在(判据核心:件② 判据表 / 件③ 落账结论 / 出口谓词都读它,
* 「人改四态 → 等级跟着变」这条链不能断),且与 S2 那份是**同一个 when 链引用**(不许分叉)。
*/
it('**S3 的关系笔只重算判定** —— 收口四格(理由/摘要/风险/验证要求)一列都不许出现,否则人改完一保存就被冲掉', () => {
const relation = s3[4];
expect(relation?.kind).toBe('project');
const fieldsOf = (act: (typeof s3)[number] | undefined): Readonly<Record<string, unknown>> =>
act?.kind === 'project' ? act.fields : {};
const fields = fieldsOf(relation);
for (const col of ['recommendation_reason', 'difference_summary', 'risk_level', 'verification_requirement']) {
expect(Object.keys(fields), col).not.toContain(col);
}
/* 判定仍要重算,且与 S2 那份共用同一条 when 链 —— 两处各写一份早晚分叉 */
const s2Relation = fieldsOf(s2[3]);
expect(fields['recommendation_level']).toBeDefined();
expect(fields['recommendation_level']).toBe(s2Relation['recommendation_level']);
/* 主键带回:同 PK 覆盖,不是新开一行 */
expect(fields['substitution_relation_id']).toBeDefined();
/* 验证记录那一笔仍与 S2 同一份(它不涉及人可改的格子) */
expect(s3[5]).toBe(s2[4]);
}); });
/* /*
@@ -420,8 +574,31 @@ describe('S3 / S4 配方', () => {
expect(stepOf('S3_review')?.auto?.let).toBe(stepOf('S2_assess')?.auto?.let); expect(stepOf('S3_review')?.auto?.let).toBe(stepOf('S2_assess')?.auto?.let);
}); });
/*
* **交代必须排在卡之前**(2026-09-01 用户:「需要把 ai 的修改意见通过右边 ai 聊天窗口展示出来,
* 不然都不知道发生了什么」)—— 发了卡这一拍就收了,排在卡后面的 `say` 要等**下一拍**才跑,
* 那时人早看完卡了。实测:原来那句「差异结论已送审,请核对」出现在人答完之后。
* 两句互斥:被打回那一轮说「按你的意见改了哪一条、改成什么」(摆的是件上那一行自己的
* `evidence_note`,不另编一句);第一轮说「结论已备好,请核对」。
*/
it('打回那一轮的交代排在卡之前,摆的是件上那一行的判断依据', () => {
const kinds = s3.map((a) => a.kind);
/* **排在卡之前**:发了卡这一拍就收了,排在后面的 say 要等下一拍 —— 那时人早看完卡了 */
expect(kinds.lastIndexOf('say')).toBeLessThan(kinds.indexOf('card'));
/* 只在被打回那一轮说 —— 第一轮那句与卡自己的 ask 是同一件事,退役了 */
const redoSay = s3[6];
expect(redoSay?.kind === 'say' && redoSay.when).toBe('case_redo_note != "" and substitution_difference_item_redo_scope_named > 0');
const text = redoSay?.kind === 'say' ? redoSay.text : undefined;
const parts = JSON.stringify(text);
expect(parts).toContain('按你的意见复核了');
/* 摆的是件上那一行自己的话与名字 —— 对话区与件上说的是同一件事,就该是同一句话 */
expect(parts).toContain('evidence_note');
expect(parts).toContain('parameter_name');
expect(parts).toContain('substitution_difference_item_redo_scope_named');
});
it('S3 的卡:审核面孔、单一选项、挂本步的件', () => { it('S3 的卡:审核面孔、单一选项、挂本步的件', () => {
const card = s3[3]; const card = s3[8];
expect(card?.kind === 'card' && card.as).toBe('decide'); expect(card?.kind === 'card' && card.as).toBe('decide');
expect(card?.kind === 'card' && card.variant).toBe('review'); expect(card?.kind === 'card' && card.variant).toBe('review');
expect(card?.kind === 'card' ? card.options ?? [] : []).toHaveLength(1); expect(card?.kind === 'card' ? card.options ?? [] : []).toHaveLength(1);
@@ -57,8 +57,18 @@ export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<ifPart id="if_r"><condition>substitution_relation_rows = 1 and substitution_relation_missing = 0 and validation_record_missing = 0</condition></ifPart> <ifPart id="if_r"><condition>substitution_relation_rows = 1 and substitution_relation_missing = 0 and validation_record_missing = 0</condition></ifPart>
</sentry> </sentry>
<!-- 1 校验入参与补数据:核对两颗料在不在库、归没归同一类型、参数齐不齐;缺什么派数据工单让人补(全 auto,doneWhen 收步) --> <!--
<humanTask id="S1_inputs" name="校验入参与补数据"/> 1 校验入参与补数据:核对两颗料在不在库、归没归同一类型、参数齐不齐。
缺什么派**数据工单**让人补;都齐了派一张只读的**验收工单**交人核对(2026-08-31 用户定)。
**manualActivation 是这条「都齐了也叫人」的命门**:引擎在拍首先扫一遍 doneWhen,
为真就自己收步(turn-runner 的 sweepRipe)—— 数据本来就齐的单,S1 的 doneWhen
第一拍就为真,配方一笔都还没跑,这一步当场被收掉,卡永远发不出去。
标了人工激活,那道扫描就多一个门槛:worldTouched(卡真发过)—— 于是次序变成
「配方发卡 → 人答 → 下一拍 doneWhen 为真才收」。doneWhen 照旧保留:它仍是这一步的真门,
只是不再抢在人前面(2026-08-27 立的口径:人工激活项在 worldTouched 之后照常求 doneWhen)。
-->
<humanTask id="S1_inputs" name="校验入参与补数据" manualActivation="true"/>
<!-- 2 逐项差异分析:核心参数逐条比对,四态、推荐等级、风险由系统按规则算(全 auto,doneWhen 收步) --> <!-- 2 逐项差异分析:核心参数逐条比对,四态、推荐等级、风险由系统按规则算(全 auto,doneWhen 收步) -->
<humanTask id="S2_assess" name="逐项差异分析"> <humanTask id="S2_assess" name="逐项差异分析">
@@ -95,7 +105,8 @@ export const NARRATIVE = `§0 本活动只回答一件事:**候选内部料号
**四步各自的产出**: **四步各自的产出**:
① 校验入参与补数据(S1_inputs)—— 核对两颗料在不在库、是否归了同一个芯片类型、核心参数 ① 校验入参与补数据(S1_inputs)—— 核对两颗料在不在库、是否归了同一个芯片类型、核心参数
(如 <参数名>)是否齐备;缺什么就派数据工单让人补。产出:两颗料的存在性与类型核对、 (如 <参数名>)是否齐备;缺什么就派**数据工单**让人补,都齐了就派一张只读的**验收工单**
交人核对 —— 两颗料的资料总要经人过目一次,再交给系统逐项比。产出:两颗料的存在性与类型核对、
补齐的芯片主数据与标准化参数。 补齐的芯片主数据与标准化参数。
② 逐项差异分析(S2_assess)—— 把两颗料的核心参数逐条比对,判定 MATCH / MISMATCH / UNKNOWN / ② 逐项差异分析(S2_assess)—— 把两颗料的核心参数逐条比对,判定 MATCH / MISMATCH / UNKNOWN /
CONDITIONAL 四态,依规则算出推荐等级与风险。产出:替代关系一行 + 差异明细逐参数一行 + CONDITIONAL 四态,依规则算出推荐等级与风险。产出:替代关系一行 + 差异明细逐参数一行 +
@@ -267,6 +278,37 @@ const RELATION_PROJECTION = {
}, },
} as const; } as const;
/**
* **S3 专用的关系重算笔** —— 只重算「判定」,**收口四格一个字不碰**
* (2026-08-31 用户:「差异评估结论审核这个界面的收口结论下面这 4 个字段也需要可以编辑」)。
*
* 那四格(推荐理由 / 差异摘要 / 替代风险 / 验证要求)此前只读,件上打开可编辑之后,
* **光开格子等于没开**:`RELATION_PROJECTION` 每跑一次就按规则把它们重写一遍,而人在件上
* 按保存会触发 quiet 拍、quiet 恰恰跑 `project` —— 人刚写下的话当场被规则值冲掉。
* 那是「让你改、改完被冲掉」,比不给改更糟。
*
* 与 `DIFFERENCE_ITEM_RECOMPUTE` 同一手法:**只列要重算的列,一个都不多写**。
* `draftWrite` 的 `patch` 是稀疏的(只含显式给出的列),DB 侧 `patch = patch || excluded.patch`
* 是 jsonb **浅合并** —— 没列的列原样留着,人改过的四格因此保得住。
*
* **`recommendation_level` 仍然重算,而且不容手改**:它是规则按四态分布算出来的判据核心,
* 「人改四态 → 等级跟着变」这条链不能断(件② 的判据表、件③ 的落账结论、出口谓词都读它)。
* 于是这一步的分工是:**判定归规则,话归人**。
*
* 代价说在明处:人改完四态,这四格**不会**自动跟着改口(否则他刚写的话就没了)——
* 件上那段说明已如实写清「四态改完,记得回头看看这四格还对不对」。界面不撒谎优先于自动。
*/
const RELATION_RECOMPUTE = {
kind: 'project' as const,
into: 'substitution.relation',
from: { once: true as const },
fields: {
/* 主键原样带回,认的是这一行自己 —— 不是新建,是同 PK 覆盖 */
substitution_relation_id: { in: 'relationId' },
recommendation_level: RELATION_PROJECTION.fields.recommendation_level,
},
} as const;
/* P-06 **一单固定一条**(用户定)。本活动只写「还得去验什么」,状态一律 `not_started`:它不是实测结果,**不许虚构实测**。 */ /* P-06 **一单固定一条**(用户定)。本活动只写「还得去验什么」,状态一律 `not_started`:它不是实测结果,**不许虚构实测**。 */
const VALIDATION_PROJECTION = { const VALIDATION_PROJECTION = {
kind: 'project' as const, kind: 'project' as const,
@@ -299,16 +341,219 @@ const VALIDATION_PROJECTION = {
* 两处不给 `doneWhen`:`S3_review`(人拍板且可循环)、`T_release`(签下去)—— * 两处不给 `doneWhen`:`S3_review`(人拍板且可循环)、`T_release`(签下去)——
* 判据不许摆在还欠一张卡的那一步眼前(会诱使它去凑判据,2026-08-28 用户实测逼出的立墙)。 * 判据不许摆在还欠一张卡的那一步眼前(会诱使它去凑判据,2026-08-28 用户实测逼出的立墙)。
*/ */
/**
* 「输入还差东西」的判定 —— 发卡条件与案卷 `gate_state` 共用同一句(一处真源):
* 条件只用基座读数(gate_reason 为空串时那一列压根不写、未绑定名参与比较会翻车)。
*/
const GATE_OPEN_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';
/**
* **逐参数差异判定的规矩** —— S2 第一轮判、S3 被人打回后复核,**两处共用同一份**:
* 各写一份早晚分叉,而分叉的那天,屏上会同时摆着两套判法判出来的结论。
*/
const ASSESS_RULES = [
'你在做芯片替代的**逐参数差异判定**。输入是一组核心参数,每一条给了:参数名、这个参数为什么要紧(说明)、',
'硬约束还是软约束、单位,以及原料与候选料各自的值和值的备注。',
'',
'对**每一条**给出四态之一:',
'· match —— 候选满足原料在这个参数上的要求(相等,或候选的范围盖住原料的范围);',
'· mismatch —— 候选达不到(范围更窄、方向相反、量级不足);',
'· unknown —— 任一侧的值缺失或语义不明,判不了;',
'· conditional —— 改外围、限定工况、或加一道验证之后可以成立。',
'',
'三条硬规矩:',
'1. **值是多少不要复述** —— 系统手里已经有值了。影响说明要答的是「换上去会不会比现在差、差在哪儿」。',
'2. **判不了就判 unknown,不要猜**;判了 unknown 就必须在 unknown_disposition 里写清楚:实测哪一项、或去哪儿补这个值。',
'3. **硬约束不因为措辞变成 match** —— 硬约束不满足就是 mismatch,证据不足就是 unknown。',
'',
'范围值的读法:「下限~上限」是一个区间(分隔符可能是 ~ 或 - 或 至);只给一边的是半开区间;',
'单位可能写在值里。判「盖不盖得住」按区间比,不要拿端点当定值。',
'',
'只输出 JSON,不要解释、不要 markdown 代码围栏。',
].join('\n');
/** 判定出参的形状 —— 同上,两处共用一份 */
const ASSESS_OUTPUT = {
type: 'object',
required: ['items'],
properties: {
items: {
type: 'array',
items: {
type: 'object',
required: ['param_name', 'difference_status', 'impact_statement', 'evidence_note'],
properties: {
param_name: { type: 'string' },
difference_status: { type: 'string', enum: ['match', 'mismatch', 'unknown', 'conditional'] },
impact_statement: { type: 'string' },
evidence_note: { type: 'string' },
unknown_disposition: { type: 'string' },
},
},
},
},
} as const;
/**
* **AI 判完之后落明细的那一笔** —— S2 第一轮、S3 被人打回后的复核轮,**共用同一份**。
* 行由字典 `each` 出来,AI 的出参只是拿 `param_name` 去查的一张表:它漏一条,那一行的四态
* 就是空 → `required` 立刻变成 `_missing > 0`,提交闸与放行门当场拦住(从前 `from each(AI 出参)`,
* 它少给两条就少两行,**而每一行看着都合规**)。
*/
const DIFFERENCE_ITEM_FROM_RESULT = {
kind: 'project' as const,
into: 'substitution.difference_item',
prune: true,
from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } },
fields: {
substitution_relation_id: { in: 'relationId' },
difference_item_id: { cat: [{ in: 'relationId' }, '-', { col: 'param_name' }] },
parameter_name: { col: 'param_name' },
parameter_desc: { col: 'param_desc' },
parameter_unit: { col: 'param_unit' },
/* 约束来自字典这一行本身;值一律 lookup 台账 —— 基座读得到的事实不让模型转述 */
constraint_type: { col: 'constraint_type' },
original_value: { lookup: 'parts.parameter', by: { internal_pn: { in: 'originalPn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '' },
candidate_value: { lookup: 'parts.parameter', by: { internal_pn: { in: 'candidatePn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '' },
difference_status: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'difference_status', else: '' },
impact_statement: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'impact_statement', else: '' },
evidence_note: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'evidence_note', else: '' },
unknown_disposition: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'unknown_disposition', else: '' },
/* 合成列:先认四态,最后才按约束类型把 mismatch 分成硬 / 软 —— 与 S3 重算那一笔共用同一份 when 链,不许各写一份 */
...JUDGEMENT_DERIVED_FIELDS,
rule_version: RULE_VERSION,
},
} as const;
/**
* ── 复核轮(人把结论打回那一轮)的四笔 ──────────────────────────────────
*
* **背景**:2026-09-01 第一版做成了「把意见连同整表交回给 AI 重判」,当天就被用户实测打脸:
* 「我打回让修改**工作温度**的结论,给我改了**静态电流**的结论」。库里的证据一字不差 ——
* 工作温度它没改(还给了理由:候选值 1℃ 远窄于 -40~+85℃,硬约束不满足),
* 而没人问过的静态电流被它从 soft_mismatch 翻成了 match,`evidence_note` 里却写着「**维持原判**」。
*
* **根因不是提示词不够严,是行由 AI 决定**:整表交出去、整表写回来,没有任何一样东西限制它
* 只动被点名的那一行;「我只改了那一条」这句话本身也出自它嘴里,而它说的不作数。
* 这正是规范 §4.6 纪律一 —— **行由确定的那一侧决定,不由 AI 决定**。
*
* **治法**:先由确定的一侧圈出「这一轮谁可以变」(①②),再把**只有这几行**交给 AI(③),
* 写回也只写这几行(④)。没被点名的行 AI 连见都见不到,自然也就改不着。
*/
/** ① 圈范围:人打回的那句话里点了名的行标 `named`,其余 `kept` —— 与基座给模型摆的「意见点名对上了哪几行」同一种认法(字面包含) */
const REDO_SCOPE_MARK = {
kind: 'project' as const,
when: 'case_redo_note != ""',
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' },
redo_scope: { when: 'contains(case_redo_note, parameter_name)', then: 'named', else: 'kept' },
},
} as const;
/*
* ~~② 一条都没点到 → 整表都算被点名~~(2026-09-01 当天上线、当天撤掉)。
*
* 当时想的是「『结论整体判得太松』这类意见说的是整批」。实测打脸(用户那一单):他写的是
* **「收口结论里面的推荐理由改为非常不推荐」** —— 说的根本不是某一条参数,而是收口那四格
* (推荐理由 / 差异摘要 / 风险 / 验证要求,那几格本来就能在件上直接改)。整表重判的下场:
* AI 拿着这句话把 **5 条参数**的结论改成了「不匹配」,每一条的依据都写着
* 「按审核人意见调整为不匹配:推荐理由改为非常不推荐。数据面:原料 4.5~40V…」——
* 一句与它们无关的话,改了五行。
*
* **点不到名的意见,宁可什么都不改**:这一轮不重判,把话说清楚(见下面那句 `say`)——
* 要重判某一条就点它的名,收口四格在件上直接改。**「猜他指的是全部」比「承认没对上」贵得多。**
*/
/**
* ③ **带着意见重判 —— 只判被点名的那几条**(整个活动第二次、也是最后一次模型出场)。
*
* 入参直接来自明细表自己的行(参数名/说明/约束/单位/两侧的值/上一轮结论都在上面),
* 外加人写的那句话;**没被点名的行压根不在这一批里**,所以「别去动它」不需要靠提示词求它。
* 判法与第一轮**同一份** `ASSESS_RULES`,后面只接一段复核纪律。
*/
const REVIEW_REDO_ANALYZE = {
kind: 'analyze' as const,
when: 'case_redo_note != "" and substitution_difference_item_redo_scope_named > 0',
model: 'deepseek-v4-flash',
into: 'items',
instruction: [
ASSESS_RULES,
'',
'【这一轮是复核,审核人说了算】下面**只列了他这次点到的那几条**(他写的话在 reviewer_note,每条都带着上一轮的结论 last_*)。',
'',
'**上面第 3 条(硬约束不因措辞变成 match)管的是你自己判的时候别被措辞带偏;这一轮不归它管** ——',
'审核人是这一步的责任人,这份结论要签他的字,而且他在界面上本来就能亲手改任何一条的四态。',
'',
'· **他点名要什么结论,就落什么**:「改成不匹配 / 不符」= mismatch,「一致 / 匹配」= match,',
' 「判不了 / 待确认」= unknown,「有条件 / 加验证后可用」= conditional。不要跟他讲道理、不要顶回去。',
'· 落的同时把实情记在 evidence_note 里,**一句按谁的意见、一句数据面是什么**,例如:',
' 「按审核人意见调整为 X:<他的原话>。数据面:原料 5mA / 候选 5mA,两侧一致。」',
' —— 下一个人回看时要能一眼分清「这是数据判出来的」还是「这是人定的」。',
'· 他只是提疑问、要补依据、要改写措辞(没点名要哪个结论):你复核后自己判,evidence_note 写清这一轮改了什么。',
'· **列出来的每一条都要回**,一条不多、一条不少。',
].join('\n'),
input: {
from: { each: 'mine:substitution.difference_item', where: { redo_scope: 'named' } },
fields: {
param_name: { col: 'parameter_name' },
param_desc: { col: 'parameter_desc' },
constraint_type: { col: 'constraint_type' },
unit: { col: 'parameter_unit' },
original_value: { col: 'original_value' },
candidate_value: { col: 'candidate_value' },
/* 值的备注不在明细表上,回台账现取 —— 与第一轮同一份事实来源 */
original_note: { lookup: 'parts.parameter', by: { internal_pn: { in: 'originalPn' }, param_name: { col: 'parameter_name' } }, take: 'param_note', else: '' },
candidate_note: { lookup: 'parts.parameter', by: { internal_pn: { in: 'candidatePn' }, param_name: { col: 'parameter_name' } }, take: 'param_note', else: '' },
/* 上一轮的结论 —— 含人就地改过的那几行(读的是本单草稿的当下值) */
last_status: { col: 'difference_status' },
last_impact: { col: 'impact_statement' },
last_evidence: { col: 'evidence_note' },
last_unknown_disposition: { col: 'unknown_disposition' },
/* 人这次打回写的那句话 —— 每一行都带一份(入参只有行这一种形状) */
reviewer_note: { in: 'case_redo_note' },
},
},
output: ASSESS_OUTPUT,
} as const;
/**
* ④ 判完写回 —— **同样只写被点名的那几行**(`from` 里压根没有别的行)。
* 模型漏答某一条时 `else` 兜回这一行上一轮的值:漏答不该变成「把它清空」。
*/
const DIFFERENCE_ITEM_FROM_REDO = {
kind: 'project' as const,
when: 'case_redo_note != "" and substitution_difference_item_redo_scope_named > 0',
into: 'substitution.difference_item',
from: { each: 'mine:substitution.difference_item', where: { redo_scope: 'named' } },
fields: {
substitution_relation_id: { col: 'substitution_relation_id' },
difference_item_id: { col: 'difference_item_id' },
constraint_type: { col: 'constraint_type' },
difference_status: { lookup: 'result:items', by: { param_name: { col: 'parameter_name' } }, take: 'difference_status', else: { col: 'difference_status' } },
impact_statement: { lookup: 'result:items', by: { param_name: { col: 'parameter_name' } }, take: 'impact_statement', else: { col: 'impact_statement' } },
evidence_note: { lookup: 'result:items', by: { param_name: { col: 'parameter_name' } }, take: 'evidence_note', else: { col: 'evidence_note' } },
unknown_disposition: { lookup: 'result:items', by: { param_name: { col: 'parameter_name' } }, take: 'unknown_disposition', else: { col: 'unknown_disposition' } },
/* 派生列与两处创建笔共用同一份 when 链 —— 四态一变,判定分类与风险跟着变 */
...JUDGEMENT_DERIVED_FIELDS,
},
} as const;
export const ASSESSMENT_STEPS = [ export const ASSESSMENT_STEPS = [
{ {
item: 'S1_inputs' as const, item: 'S1_inputs' as const,
intent: '核对两颗料在不在库、归没归同一个类型、参数值齐不齐;缺什么就派一张数据工单让人补', intent: '核对两颗料在不在库、归没归同一个类型、参数值齐不齐;缺什么派数据工单让人补,都齐了派只读的验收工单交人核对',
guide: '', guide: '',
/* Task 7:发给人的数据工单摆的就是这一张 —— 三块竖排(存在性与主数据 / 芯片类型字段 / 参数值), /* Task 7:发给人的数据工单摆的就是这一张 —— 三块竖排(存在性与主数据 / 芯片类型字段 / 参数值),
与 auto.do 第 ⑥ 笔 `card` 的 `gateLedgers` 同源(parts.chip_type / parts.type_param / 与 auto.do 第 ⑥ 笔 `card` 的 `gateLedgers` 同源(parts.chip_type / parts.type_param /
parts.profile / parts.parameter —— 四张表都在闸上,理由见那笔 `card` 定义处的注释)。 */ parts.profile / parts.parameter —— 四张表都在闸上,理由见那笔 `card` 定义处的注释)。 */
view: 'input-supplement', view: 'input-supplement',
writes: ['sub_eval.part', 'sub_eval.case', 'parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter'], writes: ['sub_eval.part', 'sub_eval.case', 'parts.profile', 'parts.parameter'],
reads: ['originalInternalPn', 'candidateInternalPn', 'case:openedAt', 'parts.profile', 'parts.parameter', 'parts.chip_type', 'parts.type_param'], reads: ['originalInternalPn', 'candidateInternalPn', 'case:openedAt', 'parts.profile', 'parts.parameter', 'parts.chip_type', 'parts.type_param'],
tools: [], tools: [],
doneWhen: doneWhen:
@@ -347,85 +592,12 @@ export const ASSESSMENT_STEPS = [
}, },
}, },
/* /*
* ② **选中的那一行类型字典,原样投一遍**(2026-08-30,Ruling H35 —— 真跑 e2e 撞见的 * ~~② 类型字典自投影 / ③ 字段清单自投影~~(2026-08-31 用户定「本活动只选类型、
* 平台侧连带问题:「数据工单确认即入库」按 `gateLedgers` 整表并入正式表,而 * 不新增类型」后退役):这两笔当年(Ruling H35)是为「人新建的类型/字段要随数据
* `parts.chip_type` 从 Task 7 起就没列在 `gateLedgers` 里 —— 人一旦真的新建一个类型, * 工单确认一起落地、且 mine: 不为空好让闸不误拦」而生 —— 类型只许从字典里选之后,
* `parts.profile`/`parts.type_param` 引用着它一起落地,**字典行本身**却只留在这一单 * 选中的类型本来就在正式表里,镜像与落地都失去了对象;`gateLedgers` 同批收窄到
* 草稿里、从没落地过。下一个任务读到同一颗料,查这个 `chip_type_id` 时字典里空空如也, * `parts.profile` / `parts.parameter` 两张真被人编辑的表。历史见 git(2.9.0 及更早)。
* `refersTo` 当场拒——是一条悬空引用,不是「查无此值」那种输入错误)。
*
* **这一笔不看「新建还是已有」,统统投一遍**(不选型的选也一样、新建的选也一样):
* 读的是 `sub_eval.part` 每一行当下的 `chip_type_id`(空串时下面这层 `each` 天然查
* 不到、不产生任何行 —— 人还没选类型的那颗不会被提前铺出假行),按它去
* `parts.chip_type` 里找到那一行、原样把 `type_name`/`description` 抄回自己身上。
* 值没变,`draftWrite` 同 PK 覆盖静默跳过(幂等,不产生多余的写入痕迹)——
* 但**这一单的草稿层从此真的有这一行**,`mine:parts.chip_type` 不再天然为空。
*
* 这条把 `parts.chip_type` 重新放回下面 ⑥ 的 `gateLedgers` 铺平了路:闸不会再拿
* 「零行」的旧理由误拦(那个前提不成立了),而选中的类型(新建的也好、已有的也好)
* 会随「数据工单确认入库」一起落进正式表 —— 不再留悬空引用。
*/ */
{
kind: 'project' as const,
into: 'parts.chip_type',
/*
* **不带 `prune`**(2026-08-30 第二处修正,e2e 案例 2 撞出来的第二个坑)——
* `parts.chip_type` 在 ⑦ 的 `gateLedgers` 上,人交回数据工单那一刻这张表就跟着
* 「数据工单确认即入库」落进正式表(`landsOnConfirm`);已落地的行 `draftDrop`
* (`prune` 的撤行机制)撤不掉,配方原样抛 `AutoRecipeError`(原文:「这一行是本单
* 在数据工单里建好、人确认后已入库的,撤不掉」)。案例 2(类型不一致·硬拦)第一次
* 提交时 LM2596S-5.0 还在 type_id=1、随首次确认落地;人接着把它改挂到 type_2 后,
* 这一笔重投只剩 type_2 一行,`prune:true` 想把已经落地的 type_1 那行撤掉 —— 直接炸。
* 去掉 `prune` 就不会再触发这条撤行:陈旧的选型历史留在草稿层不是错误(字典行本就
* 该积累、不该因为这一单后来改了主意就被追溯撤销),`parts.profile`/`parts.parameter`
* 那几笔**仍然** `prune:true`(它们是这一单自己的骨架,换料/换类型确实要清旧行,
* 且它们不会像字典这样跨料共享、也不会被单独确认入库)。
*/
from: {
each: 'mine:sub_eval.part',
then: { each: 'parts.chip_type', where: { type_id: { outer: 'chip_type_id' } } },
},
fields: {
type_id: { col: 'type_id' },
type_name: { col: 'type_name' },
description: { col: 'description' },
},
},
/*
* ③ **选中类型的核心参数字段清单,同一套道理原样投一遍**(2026-08-30,与 ② 同一次
* 发现、同一个技法):`parts.type_param` 只有人在件上「新建类型时加字段」才会写它 ——
* 挑一个已有类型时,这张表天然一行都没有,而它此刻仍在 ⑥ 的 `gateLedgers` 上,
* 「零行 = 没干完」的闸对这条路一样是误判(2026-08-30 真跑 e2e 五案例实测坐实:
* 「归到已有类型」的候选缺库 / 类型不一致 / 原PN缺库 / 两颗都缺库四例,全部卡死在
* 同一条「芯片类型核心参数字段 这一单还一行都没有」)。
*
* 修法与 ② 完全同构:按当下选中的 `chip_type_id` 把那个类型的核心参数字段清单
* 整套原样抄回自己身上(值没变,幂等,不产生多余写入)——**新建的类型也走这条路**:
* 人在件上新增的字段行本就已经在 `mine:parts.type_param` 里,这一笔重新投一遍
* 原样命中、照样幂等跳过,不会覆盖人刚填的内容。
*
* **同样不带 `prune`**(与 ② 同一处修正,同一个理由:字段清单也在 ⑥ 的
* `gateLedgers` 上,人交回数据工单那一刻就会跟着落地;已落地的行 `prune` 撤不掉,
* 带上 `prune:true` 会在「先前定过某个类型的字段清单、后来改选了另一个类型」这条
* 路上原样复现 H35 e2e 案例 2 那个死锁)。历史选型的陈旧字段行留在草稿层不算错,
* 与 ② 的字典行是同一条道理。
*/
{
kind: 'project' as const,
into: 'parts.type_param',
/* 同上一笔同一个理由,同一次修正(含"不带 prune") —— 见 ② 那笔头上的大注释,不重复贴 */
from: {
each: 'mine:sub_eval.part',
then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } },
},
fields: {
type_id: { col: 'type_id' },
param_name: { col: 'param_name' },
param_desc: { col: 'param_desc' },
constraint_type: { col: 'constraint_type' },
param_unit: { col: 'param_unit' },
},
},
/* /*
* ④ 主数据骨架:**两颗料都铺、值回填现值**。 * ④ 主数据骨架:**两颗料都铺、值回填现值**。
* 在库那颗回填出它此刻的样子(人不必重填);要建那颗全空等人填。 * 在库那颗回填出它此刻的样子(人不必重填);要建那颗全空等人填。
@@ -464,7 +636,13 @@ export const ASSESSMENT_STEPS = [
{ {
kind: 'project' as const, kind: 'project' as const,
into: 'parts.parameter', into: 'parts.parameter',
prune: true, /*
* **`prune: 'live'`(2026-08-31 用户:「切换料号类型时…只会跟着变多,不会跟着变少」)**:
* 参数骨架的行集全由这一笔按「料 × 类型字段」铺,人在件上**加不了行**(没有加行钮),
* F3 那条「quiet 拍不撤行」防的风险在这张表上不存在 —— 人改了类型、按保存,旧类型的
* 参数行当场撤掉,屏上跟手。已落地的行照旧跳过并出声(skipLanded)。
*/
prune: 'live' as const,
from: { from: {
each: 'mine:sub_eval.part', each: 'mine:sub_eval.part',
then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } }, then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } },
@@ -524,6 +702,27 @@ export const ASSESSMENT_STEPS = [
fields: { fields: {
eval_ref: { in: 'evalRef' }, eval_ref: { in: 'evalRef' },
chip_type_id: { lookup: 'mine:sub_eval.part', by: { role: 'original' }, take: 'chip_type_id', else: '' }, chip_type_id: { lookup: 'mine:sub_eval.part', by: { role: 'original' }, take: 'chip_type_id', else: '' },
/*
* 红条的开关(2026-08-31):gate_reason 清空时按列归型不落笔(旧话留在草稿里),
* 这面开关两个值都非空、两个方向都真写 —— 件上按它收红条,与发卡条件同一句判定。
*/
gate_state: { when: GATE_OPEN_WHEN, then: 'open', else: 'clear' },
/*
* **这一步派的是哪种工单**(2026-08-31 用户定:「就算数据是足的也进入这个数据工单,
* 就派发一个验收工单…把芯片卡片上的编辑按钮去掉,只展示不能编辑」)。
* 件按它换脸:`data` → 题头「数据工单」+ 卡角笔/软盘;`accept` → 题头「验收工单」、
* 编辑钮整组收起(只读)。
*
* **必须粘着**(第一档读的是上一拍写下的自己):一步只发一张卡(引擎 `carded` 幂等),
* 派出去是哪种,这一步到底就是哪种。不粘着的后果实打实:人在数据工单上把最后一格
* 填上、还没点确认,闸当场变 clear —— 旗跟着翻,他手里那张卡还开着,而屏上的件
* 已经改名叫「验收工单」、笔也没了,想改回一个填错的值都没有入口。
*/
order_kind: {
when: 'sub_eval_case_order_kind_data > 0',
then: 'data',
else: { when: GATE_OPEN_WHEN, then: 'data', else: 'accept' },
},
gate_reason: { gate_reason: {
when: 'sub_eval_part_rows < 2', when: 'sub_eval_part_rows < 2',
then: '原料号与候选料号填成了同一个 —— 没法比出差异,请终止本单,换两个不同的料号重开。', then: '原料号与候选料号填成了同一个 —— 没法比出差异,请终止本单,换两个不同的料号重开。',
@@ -532,13 +731,13 @@ export const ASSESSMENT_STEPS = [
then: '有料号库里查不到 —— 在下面把它的主数据建起来。', then: '有料号库里查不到 —— 在下面把它的主数据建起来。',
else: { else: {
when: 'sub_eval_part_missing > 0', when: 'sub_eval_part_missing > 0',
then: '有料号还没归芯片类型 —— 在下面给它选一个;没有合适的就新建一个,并把它的核心参数字段一并定下来。', then: '有料号还没归芯片类型 —— 在档案卡上给它选一个;字典里没有合适的,先请数据管理员补进类型字典(本活动只选不建)。',
else: { else: {
when: 'sub_eval_part_distinct_chip_type_id > 1', when: 'sub_eval_part_distinct_chip_type_id > 1',
then: '两颗料不是同一个芯片类型,逐项比对无从谈起 —— 换一颗料,或者把归错的那颗类型改对。', then: '两颗料不是同一个芯片类型,逐项比对无从谈起 —— 换一颗料,或者把归错的那颗类型改对。',
else: { else: {
when: 'parts_profile_missing + parts_parameter_missing > 0', when: 'parts_profile_missing + parts_parameter_missing > 0',
then: '还有必填格空着 —— 红带点名的那几格填上就能提交。', then: '还有必填格空着 —— 缺哪几格就点名在下面两行,填上就能提交。',
else: '', else: '',
}, },
}, },
@@ -554,9 +753,7 @@ export const ASSESSMENT_STEPS = [
*/ */
{ {
kind: 'card' as const, kind: 'card' as const,
when: when: GATE_OPEN_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' as const, as: 'execution' as const,
variant: 'data' as const, variant: 'data' as const,
title: '数据工单 · 差异评估的输入还差几格', title: '数据工单 · 差异评估的输入还差几格',
@@ -567,29 +764,58 @@ export const ASSESSMENT_STEPS = [
], ],
guide: [ guide: [
'两颗料必须归到**同一个芯片类型**下 —— 要比的核心参数字段由类型定,类型不同就没有可比的字段。', '两颗料必须归到**同一个芯片类型**下 —— 要比的核心参数字段由类型定,类型不同就没有可比的字段。',
'类型下拉里没有合适的,就新建一个,并把它的**核心参数字段**一并定下来:每个字段要有名字、一句说明、以及硬约束还是软约束。', '类型**只从字典里选,本活动不新增类型**(2026-08-31 用户定)—— 字典里没有合适的,先请数据管理员补进字典,再回来选。',
'说明写「这个参数为什么要紧」——**它是后面判定差异时唯一的解释来源**,写成参数名的复述等于没写。', '硬约束 = 不满足就不能用(烧芯片、超温域、装不上);软约束 = 影响表现但可权衡(功耗、噪声)—— 字段的硬软在字典上定死,件上「比对基准」卡可查。',
'硬约束 = 不满足就不能用(烧芯片、超温域、装不上);软约束 = 影响表现但可权衡(功耗、噪声)。',
'参数值填**原厂规格书上的值**,查不到就留空 —— 留空会被红带点名,但**编一个值比留空坏得多**。', '参数值填**原厂规格书上的值**,查不到就留空 —— 留空会被红带点名,但**编一个值比留空坏得多**。',
], ],
standard: '红带消失、提交钮变绿:两颗料都在库、归了同一个类型、每个核心参数字段都有值。', standard: '红带消失、提交钮变绿:两颗料都在库、归了同一个类型、每个核心参数字段都有值。',
/* /*
* **`parts.chip_type` 与 `parts.type_param` 都在闸上**(2026-08-30 改,Ruling H35)—— * **闸只盯人真编辑的两张表**(2026-08-31 随「只选不建」收窄):从前 `parts.chip_type`/
* 从前 `parts.chip_type` 被永久排除在外,理由是「它没有编辑手段,`mine:` 天然为空, * `parts.type_param` 也在名单上 —— 那是给「人新建的类型要随确认一起落地」铺的路
* 列了就永久卡死」;那条理由**从 ②③ 两笔自我投影加上之后就不再成立**—— * (Ruling H35,史见 git)。类型只许从字典里选之后,字典行本来就在正式表里,
* `mine:parts.chip_type`/`mine:parts.type_param` 现在恒不为空(只要 `sub_eval.part` * 落地无对象、mine: 恒为空 —— 留在名单上反而会被「零行 = 没干完」的闸误拦死。
* 里已经选定了类型),闸不会再误判「一行都没有」。
*
* **既然要落地就必须在名单里,这不是碰运气**:「数据工单确认即入库」把 `gateLedgers`
* 列出的表整批并入正式表;`parts.chip_type` 若不在这份名单里,人新建的类型字典行
* 就只留在草稿层,而引用它的 `parts.profile`/`parts.type_param` 却随名单一起落地 ——
* 落地那一刻起,正式表里就有一行指着字典里查无此号的悬空引用(2026-08-30 真跑
* e2e 撞见的平台侧连带发现:范围放行没有对 `refersTo` 做传递闭包,并一张表却不
* 并它引用的表,必然留下悬空引用;那条更普遍的平台缺陷已记 `docs/debt.md`,
* 这里只在活动自己的声明层堵住这一处具体触发点)。四张表都在闸上、也都会一起
* 落地,才是「选类型」这件事完整、闭环的样子。
*/ */
gateLedgers: ['parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter'], gateLedgers: ['parts.profile', 'parts.parameter'],
/*
* **类型不一致不许确认**(2026-08-31 用户:「提示两颗料不是同一个芯片类型…就不应该
* 能写入并继续下一步」)。缺格闸管不了这件事(它只数必填空不空),条件档在受理
* 回传前现算 —— 钉 **parts.profile 本表**的 distinct(提交闸从草稿现读,零滞后;
* 钉 sub_eval.part 的那份要等配方 tick 同步,人存完就点确认会被误拦)。
* <= 1:两颗同型(1)或都还没选(0,那时缺格闸已经拦着)都放;>= 2 才是这堵墙。
*/
gateWhen: 'parts_profile_distinct_chip_type_id <= 1',
gateWhenSay: '两颗料不是同一个芯片类型 —— 逐项比对无从谈起:换一颗料,或把归错的那颗类型改对,再来确认。',
},
/*
* ⑧ **数据本来就齐的单,也要经人过目一次**(2026-08-31 用户定)——
* 派一张**验收工单**:同一份件、同一批数据,只是卡角的编辑钮整组收起,题头改叫「验收工单」。
*
* 与 ⑦ 是互斥的两条路,而不是先后:引擎 `carded` 幂等保证**一步只发一张卡** ——
* ⑦ 发过数据工单之后这一笔永远轮不到(人补完数据不会再被叫第二次)。
* 条件读上一笔刚写下的旗(每一笔之前刷读数),不重新算一遍闸:同一件事只许有一处判定。
*
* `gateLedgers` 与 ⑦ 同一份:件是只读的,这道闸在确认那一刻再数一遍
* ——「我核过了,数据是齐的」这句话得有人替它数,不能只凭人点了一下。
*/
{
kind: 'card' as const,
when: 'sub_eval_case_order_kind_accept > 0',
as: 'execution' as const,
variant: 'accept' as const,
title: '验收工单 · 差异评估的输入核对',
basis: [
{ k: '原料号', v: { in: 'originalInternalPn' } },
{ k: '候选料号', v: { in: 'candidateInternalPn' } },
{ k: '本单结论', v: '两颗料都在库、归了同一个芯片类型、核心参数值齐备 —— 没有要补的格子' },
],
guide: [
'两张芯片档案卡上的**主数据**核一遍:品牌 / 生命周期 / 制造商 / 封装 —— 库里的现值就是马上要拿去比对的值。',
'点卡翻到背面,把**核心参数值**核一遍:值取自原厂规格书;有存疑的先别确认,去主数据里改对再回来。',
'两颗料必须归在**同一个芯片类型**下 —— 顶部「比对基准」摆的就是本轮要逐项比的字段清单。',
'这一屏只读:确认无误后回对话区按「确认无误」,系统随即开始逐项差异分析。',
],
standard: '两张卡的主数据与核心参数值都核过、类型一致 —— 没有异议就确认。',
gateLedgers: ['parts.profile', 'parts.parameter'],
}, },
{ kind: 'say' as const, text: '已核对两颗料的在库状态与参数齐备情况。' }, { kind: 'say' as const, text: '已核对两颗料的在库状态与参数齐备情况。' },
], ],
@@ -617,26 +843,7 @@ export const ASSESSMENT_STEPS = [
kind: 'analyze' as const, kind: 'analyze' as const,
model: 'deepseek-v4-flash', model: 'deepseek-v4-flash',
into: 'items', into: 'items',
instruction: [ instruction: ASSESS_RULES,
'你在做芯片替代的**逐参数差异判定**。输入是一组核心参数,每一条给了:参数名、这个参数为什么要紧(说明)、',
'硬约束还是软约束、单位,以及原料与候选料各自的值和值的备注。',
'',
'对**每一条**给出四态之一:',
'· match —— 候选满足原料在这个参数上的要求(相等,或候选的范围盖住原料的范围);',
'· mismatch —— 候选达不到(范围更窄、方向相反、量级不足);',
'· unknown —— 任一侧的值缺失或语义不明,判不了;',
'· conditional —— 改外围、限定工况、或加一道验证之后可以成立。',
'',
'三条硬规矩:',
'1. **值是多少不要复述** —— 系统手里已经有值了。影响说明要答的是「换上去会不会比现在差、差在哪儿」。',
'2. **判不了就判 unknown,不要猜**;判了 unknown 就必须在 unknown_disposition 里写清楚:实测哪一项、或去哪儿补这个值。',
'3. **硬约束不因为措辞变成 match** —— 硬约束不满足就是 mismatch,证据不足就是 unknown。',
'',
'范围值的读法:「下限~上限」是一个区间(分隔符可能是 ~ 或 - 或 至);只给一边的是半开区间;',
'单位可能写在值里。判「盖不盖得住」按区间比,不要拿端点当定值。',
'',
'只输出 JSON,不要解释、不要 markdown 代码围栏。',
].join('\n'),
input: { input: {
from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } }, from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } },
fields: { fields: {
@@ -651,26 +858,7 @@ export const ASSESSMENT_STEPS = [
candidate_note: { lookup: 'parts.parameter', by: { internal_pn: { in: 'candidatePn' }, param_name: { col: 'param_name' } }, take: 'param_note', else: '' }, candidate_note: { lookup: 'parts.parameter', by: { internal_pn: { in: 'candidatePn' }, param_name: { col: 'param_name' } }, take: 'param_note', else: '' },
}, },
}, },
output: { output: ASSESS_OUTPUT,
type: 'object',
required: ['items'],
properties: {
items: {
type: 'array',
items: {
type: 'object',
required: ['param_name', 'difference_status', 'impact_statement', 'evidence_note'],
properties: {
param_name: { type: 'string' },
difference_status: { type: 'string', enum: ['match', 'mismatch', 'unknown', 'conditional'] },
impact_statement: { type: 'string' },
evidence_note: { type: 'string' },
unknown_disposition: { type: 'string' },
},
},
},
},
},
}, },
/* /*
* ②→④ **`RELATION_PROJECTION` 写两遍,中间夹着差异明细** —— 不是笔误 * ②→④ **`RELATION_PROJECTION` 写两遍,中间夹着差异明细** —— 不是笔误
@@ -699,30 +887,7 @@ export const ASSESSMENT_STEPS = [
* 它漏一条,那一行的四态就是空 → `required` 立刻变成 `_missing > 0`,提交闸与放行门当场拦住。 * 它漏一条,那一行的四态就是空 → `required` 立刻变成 `_missing > 0`,提交闸与放行门当场拦住。
* 从前是 `from each(AI 出参)`:它少给两条就少两行,**而每一行看着都合规**。 * 从前是 `from each(AI 出参)`:它少给两条就少两行,**而每一行看着都合规**。
*/ */
{ DIFFERENCE_ITEM_FROM_RESULT,
kind: 'project' as const,
into: 'substitution.difference_item',
prune: true,
from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } },
fields: {
substitution_relation_id: { in: 'relationId' },
difference_item_id: { cat: [{ in: 'relationId' }, '-', { col: 'param_name' }] },
parameter_name: { col: 'param_name' },
parameter_desc: { col: 'param_desc' },
parameter_unit: { col: 'param_unit' },
/* 约束来自字典这一行本身;值一律 lookup 台账 —— 基座读得到的事实不让模型转述 */
constraint_type: { col: 'constraint_type' },
original_value: { lookup: 'parts.parameter', by: { internal_pn: { in: 'originalPn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '' },
candidate_value: { lookup: 'parts.parameter', by: { internal_pn: { in: 'candidatePn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '' },
difference_status: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'difference_status', else: '' },
impact_statement: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'impact_statement', else: '' },
evidence_note: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'evidence_note', else: '' },
unknown_disposition: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'unknown_disposition', else: '' },
/* 合成列:先认四态,最后才按约束类型把 mismatch 分成硬 / 软 —— 与 S3 重算那一笔共用同一份 when 链,不许各写一份 */
...JUDGEMENT_DERIVED_FIELDS,
rule_version: RULE_VERSION,
},
},
RELATION_PROJECTION, RELATION_PROJECTION,
VALIDATION_PROJECTION, VALIDATION_PROJECTION,
{ kind: 'say' as const, text: '逐条差异结论已备好,推荐等级由规则按四态算出。' }, { kind: 'say' as const, text: '逐条差异结论已备好,推荐等级由规则按四态算出。' },
@@ -742,10 +907,11 @@ export const ASSESSMENT_STEPS = [
item: 'S3_review' as const, item: 'S3_review' as const,
intent: '把逐条结论摆给人过目,他就地改四态与影响说明,改到没问题为止', intent: '把逐条结论摆给人过目,他就地改四态与影响说明,改到没问题为止',
guide: '', guide: '',
/* Task 8:人核对结论用的验收工单 —— 对决头(原 PN → 候选 PN)+ 四态分布带 + 推荐等级大徽章 /* Task 8:人核对结论用的验收工单 —— 对决头(原 PN → 候选 PN)+ 四态分布带只读,
只读,逐条明细可就地改(四态 / 影响说明 / 判断依据 / UNKNOWN 去向),收口四格 推荐等级大徽章只读(判据核心,改四态即改它),逐条明细可就地改(四态 / 影响说明 /
(推荐理由 / 差异摘要 / 风险 / 验证要求)只读——它们是配方按规则算的,人改了上面的四态, 判断依据 / UNKNOWN 去向);**收口四格(推荐理由 / 差异摘要 / 风险 / 验证要求)
这一步的 auto.do 里那两笔投影(RELATION_PROJECTION / VALIDATION_PROJECTION)会重算它们。 */ 2026-08-31 起可就地改**,写下的以人为准 —— 这一步因此用 `RELATION_RECOMPUTE`
(只重算判定)而不是 `RELATION_PROJECTION`(会把四格按规则重写、冲掉人写的话)。 */
view: 'difference-review', view: 'difference-review',
writes: ['substitution.difference_item', 'substitution.relation', 'validation.record'], writes: ['substitution.difference_item', 'substitution.relation', 'validation.record'],
reads: ['substitution.difference_item', 'substitution.relation'], reads: ['substitution.difference_item', 'substitution.relation'],
@@ -766,6 +932,15 @@ export const ASSESSMENT_STEPS = [
*/ */
let: SHARED_LET, let: SHARED_LET,
do: [ do: [
/*
* ①-④ **人打回那一轮:先圈出他点名的是哪几条,只把这几条交回给 AI 重判**
* (2026-09-01;第一版整表重判,当天被实测打脸 —— 见 `REDO_SCOPE_MARK` 上面那段头注)。
* 常路(第一次送审)这四笔全跳过(`case_redo_note` 是空串),整单仍然只调一次模型。
* 判完落回明细,后面三笔重算照旧走:派生列跟着新四态变,等级、风险、验证记录跟着变。
*/
REDO_SCOPE_MARK,
REVIEW_REDO_ANALYZE,
DIFFERENCE_ITEM_FROM_REDO,
/* /*
* **人改了四态,先把这一行自己的判定分类 / 风险等级重算,关系与验证记录的投影才有 * **人改了四态,先把这一行自己的判定分类 / 风险等级重算,关系与验证记录的投影才有
* 真数据可读**(2026-08-30 修「结论纹丝不动」的要害缺陷,详见 `DIFFERENCE_ITEM_RECOMPUTE` * 真数据可读**(2026-08-30 修「结论纹丝不动」的要害缺陷,详见 `DIFFERENCE_ITEM_RECOMPUTE`
@@ -773,8 +948,55 @@ export const ASSESSMENT_STEPS = [
* 那一笔后面」的又一例 —— 摘要只读,改的是四态。 * 那一笔后面」的又一例 —— 摘要只读,改的是四态。
*/ */
DIFFERENCE_ITEM_RECOMPUTE, DIFFERENCE_ITEM_RECOMPUTE,
RELATION_PROJECTION, /* **不是 `RELATION_PROJECTION`** —— 那一笔会把收口四格按规则重写,把人刚改的话冲掉。
这一步只重算判定(推荐等级跟着四态走),四格交给人,见 `RELATION_RECOMPUTE` 的头注。 */
RELATION_RECOMPUTE,
VALIDATION_PROJECTION, VALIDATION_PROJECTION,
/*
* **重判完要在对话区说清「按你的意见改了哪一条、改成什么」**(2026-09-01 用户:
* 「需要把 ai 的修改意见通过右边 ai 聊天窗口展示出来,不然都不知道发生了什么」)。
*
* **必须排在卡之前** —— 发了卡这一拍就收了,排在卡后面的笔要等下一拍才跑
* (那时人早就看完卡了,这句话就成了马后炮:原来那句「差异结论已送审,请核对」
* 实测正是在**人答完之后**才出现的)。
*
* 摆的是件上那一行自己的话(`evidence_note` —— 复核指令要求它写成
* 「按审核人意见调整为 X:<原话>。数据面:…」),不是另编一句:
* 对话区与件上说的是同一件事,就该是同一句话。
*/
{
kind: 'say' as const,
when: 'case_redo_note != "" and substitution_difference_item_redo_scope_named > 0',
text: {
cat: [
'按你的意见复核了 ',
{ in: 'substitution_difference_item_redo_scope_named' },
' 条 —— ',
{ lookup: 'mine:substitution.difference_item', by: { redo_scope: 'named' }, take: 'parameter_name', else: '' },
':',
{ lookup: 'mine:substitution.difference_item', by: { redo_scope: 'named' }, take: 'evidence_note', else: '' },
' 件上那几行已经改好,再核一遍,没问题就点「确认通过」。',
],
},
},
/*
* **点不到名就照实说** —— 这一轮一个字都没改,得让他知道为什么,以及怎么才办得成
* (2026-09-01 实测:他写「收口结论里面的推荐理由改为非常不推荐」,而那不是任何一条参数)。
*/
{
kind: 'say' as const,
when: 'case_redo_note != "" and substitution_difference_item_redo_scope_named = 0',
text:
'你这句意见我没能对上具体哪一条参数,所以这一轮**一条都没改**。' +
'要我重判某一条,请在意见里写上它的参数名(如「工作温度」);' +
'收口结论那四格(推荐理由 / 差异摘要 / 风险等级 / 验证要求)不归 AI 判 —— 在工作区的件上直接改就行,改完点「确认通过」。',
},
/*
* ~~第一轮那句「差异结论已送审,请在件上逐条核对」~~(2026-09-01 退役):
* 它与卡自己的 `ask` 说的是同一件事(「逐条差异结论已备好。请在工作区的件上逐条核对…」)——
* 摆两遍是复读。而且它排在卡后面,要等下一拍才跑:实测那句话出现在**人答完之后**。
* 打回那一轮不同 —— 那时有新东西要交代(改了哪一条、改成什么),所以只留上面那一句。
*/
{ {
kind: 'card' as const, kind: 'card' as const,
as: 'decide' as const, as: 'decide' as const,
@@ -788,7 +1010,8 @@ export const ASSESSMENT_STEPS = [
{ k: '本轮结论', v: { lookup: 'mine:substitution.relation', by: {}, take: 'difference_summary', else: '' } }, { k: '本轮结论', v: { lookup: 'mine:substitution.relation', by: {}, take: 'difference_summary', else: '' } },
], ],
}, },
{ kind: 'say' as const, text: '差异结论已送审,请在件上逐条核对。' }, /* ~~卡后面那一句 `say`~~(2026-09-01 退役):发了卡这一拍就收了,它要等下一拍才跑 ——
实测「差异结论已送审,请在件上逐条核对」出现在**人答完之后**。两句交代都提到卡之前去了。 */
/* `] as const`:这个数组也引用了 RELATION_PROJECTION/VALIDATION_PROJECTION —— 理由同 S2 的 do 数组头上那条注释 */ /* `] as const`:这个数组也引用了 RELATION_PROJECTION/VALIDATION_PROJECTION —— 理由同 S2 的 do 数组头上那条注释 */
] as const, ] as const,
}, },
@@ -54,7 +54,7 @@ export const manifest: ActivityManifest = {
activityId: ACTIVITY_ID, activityId: ACTIVITY_ID,
name: 'ACT-WORK-05 · 差异评估', name: 'ACT-WORK-05 · 差异评估',
/* 重写不是改 —— 同版重装是 no-op(静默丢失,踩过) */ /* 重写不是改 —— 同版重装是 no-op(静默丢失,踩过) */
packageVersion: '2.2.2', packageVersion: '2.24.0',
category: '需求分析与方案匹配', category: '需求分析与方案匹配',
sdkVersion: '0.1.0-m5', sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-part-substitution-assessment', checksum: 'dev-checksum-part-substitution-assessment',
@@ -89,7 +89,24 @@ export const manifest: ActivityManifest = {
* *
* 运营端启动表单渲染这两格为「选择 + 新增」下拉(`x-control: combo`),候选从 * 运营端启动表单渲染这两格为「选择 + 新增」下拉(`x-control: combo`),候选从
* `parts.profile.internal_pn` 现读(`x-options`);`x-order` 定表单次序(清单存在 * `parts.profile.internal_pn` 现读(`x-options`);`x-order` 定表单次序(清单存在
* jsonb 里,书写顺序到不了运营端)。requestDescription / candidateInternalPns(数组)/ * jsonb 里,书写顺序到不了运营端)。
*
* **两格按芯片类型联动**(2026-08-31 用户定):`x-options.group` 让候选带上各自的
* `chip_type_id` 与它在 `parts.chip_type` 里的人读名,`x-link: 'chip-type'` 把两格结成一组 ——
* 组内**先填的那格是锚**(摆全量),另一格只列同类型的料 + 库里未标类型的(殿后一组)+「新增」。
* 锚没有类型、或锚填的是库里没有的新料号 → 不收窄(两颗都还没建档时不该拦着人)。
* **这只是表单的引导,不是墙**:服务端照收两格不同类型的组合(`launch-params.ts` 头注),
* 类型一致由 S1 的收步判据 `sub_eval_part_distinct_chip_type_id = 1` 管 —— 那才是数得出来的门。
*
* **两格不能是同一颗料**(`x-distinct: 'part'`,2026-08-31 用户定:「不应该能选到同样的两个芯片」)——
* 这一条**是墙**,不是引导,理由与上面「留空」那段是同一条链:两格同一个料号 → 上面那笔 `project`
* 铸的两行撞上同一个主键(`eval_ref + internal_pn`,`role` 不在主键里)→ 后一笔覆盖前一笔 →
* `sub_eval.part` 收成一行 → `sub_eval_part_exists_status_found = 2` 永不成立,S1 收不了步;
* 而件上那两行人加不出来(块① 行源 `mine:parts.profile`,没有「+加一行」),**唯一出路是终止重开**。
* 墙立在起单这一刻,是因为**路只在这里** —— 那时换一格就好了。运营端表单据此不把另一格已经选走的
* 料号列出来;自己写一个同样的、或脚本 / API 直接起单,由服务端 `validateLaunchParams` 拒。
*
* requestDescription / candidateInternalPns(数组)/
* datasheets 三键已随重写撤下 —— 这一版没有「需求主记录」「N 候选并行」「预填 * datasheets 三键已随重写撤下 —— 这一版没有「需求主记录」「N 候选并行」「预填
* datasheet」这几件事。 * datasheet」这几件事。
*/ */
@@ -103,8 +120,10 @@ export const manifest: ActivityManifest = {
description: '差异评估基准料号(必填);库里有就选,没有就自己写一个新的 —— 它的主数据(品牌/类型/参数)缺了才由第一步派数据工单补齐。', description: '差异评估基准料号(必填);库里有就选,没有就自己写一个新的 —— 它的主数据(品牌/类型/参数)缺了才由第一步派数据工单补齐。',
examples: ['LM2596S-5.0'], examples: ['LM2596S-5.0'],
'x-control': 'combo', 'x-control': 'combo',
'x-options': { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn' }, '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-order': 1,
'x-link': 'chip-type',
'x-distinct': 'part',
}, },
candidateInternalPn: { candidateInternalPn: {
type: 'string', type: 'string',
@@ -112,8 +131,10 @@ export const manifest: ActivityManifest = {
description: '被评估的候选料号(必填);库里有就选,没有就自己写一个新的 —— 它的主数据(品牌/类型/参数)缺了才由第一步派数据工单补齐。', description: '被评估的候选料号(必填);库里有就选,没有就自己写一个新的 —— 它的主数据(品牌/类型/参数)缺了才由第一步派数据工单补齐。',
examples: ['XL2596S-5.0'], examples: ['XL2596S-5.0'],
'x-control': 'combo', 'x-control': 'combo',
'x-options': { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn' }, '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-order': 2,
'x-link': 'chip-type',
'x-distinct': 'part',
}, },
}, },
}, },
@@ -248,15 +269,25 @@ export const manifest: ActivityManifest = {
authorization: { authorization: {
discretion: [], discretion: [],
preauthorizedActions: [], preauthorizedActions: [],
/* 调人三次(补数据 / 审核 / 放行);零工具、AI 只出场一次 —— 预算随之从 16/90 步收紧到 8/40 步 */ /* 与下面 `resources.budget.humanCalls` 同一个数 —— 常路调人三次(补数据 / 审核 / 放行),
humanCallBudget: 8, 余量给审核打回:每打回一次就多叫一次人(2026-09-01 用户定,见那一行的注释) */
humanCallBudget: 24,
}, },
resources: { resources: {
tools: [], tools: [],
skills: [], skills: [],
models: ['deepseek-v4-flash'], models: ['deepseek-v4-flash'],
roles: ['FAE/RI'], roles: ['FAE/RI'],
budget: { steps: 40, tokens: 300_000, activeMinutes: 60, humanCalls: 8 }, /*
* **调人次数给足**(2026-09-01 用户定「那你就把预算提高」)—— 8 次在真人手里三四轮就见底:
* 常路已经要 3 次(数据/验收工单 1 + 结论审核 1 + 落账放行 1),而**审核每打回一次就多叫一次**,
* 实测他连打回四次,单子落 `exhausted` —— 屏上看着像「最后落账那一步直接没了」。
* 24 次 = 常路 3 + 二十来轮打回,人自己点得累了也到不了顶。
*
* **这是配置,不是机制**:机制上的正解是「人自己打回引出的那张卡不算打扰」(他不是被打扰,
* 是他叫的)—— 那要在发卡那条路上分「主动叫人」与「应他要求再问一遍」,记在 debt。
*/
budget: { steps: 40, tokens: 300_000, activeMinutes: 60, humanCalls: 24 },
}, },
}, },
@@ -347,7 +378,7 @@ export const manifest: ActivityManifest = {
fields: [ fields: [
col({ name: 'internal_pn', type: 'text', label: '内部料号', primaryKey: true }), col({ name: 'internal_pn', type: 'text', label: '内部料号', primaryKey: true }),
/* 与 `parts.chip_type.type_id` 同一条身份链路,同一个理由改成 text —— 见该字段的大注释 */ /* 与 `parts.chip_type.type_id` 同一条身份链路,同一个理由改成 text —— 见该字段的大注释 */
col({ name: 'chip_type_id', type: 'text', label: '芯片类型', required: true, refersTo: 'parts.chip_type.type_id' }), col({ name: 'chip_type_id', type: 'text', label: '芯片类型', required: true, distinct: true, refersTo: 'parts.chip_type.type_id' }),
col({ name: 'brand_name', type: 'text', label: '品牌名称', required: true }), col({ name: 'brand_name', type: 'text', label: '品牌名称', required: true }),
col({ name: 'lifecycle_status', type: 'text', label: '生命周期状态', required: true, tally: ['active', 'nrnd', 'eol', 'discontinued', 'risk'] }), col({ name: 'lifecycle_status', type: 'text', label: '生命周期状态', required: true, tally: ['active', 'nrnd', 'eol', 'discontinued', 'risk'] }),
col({ name: 'specification_description', type: 'text', label: '规格描述', expect: true }), col({ name: 'specification_description', type: 'text', label: '规格描述', expect: true }),
@@ -431,6 +462,20 @@ export const manifest: ActivityManifest = {
/* 与 `parts.chip_type.type_id` 同一条身份链路,同一个理由改成 text —— 见该字段的大注释 */ /* 与 `parts.chip_type.type_id` 同一条身份链路,同一个理由改成 text —— 见该字段的大注释 */
col({ name: 'chip_type_id', type: 'text', label: '本单锁定的芯片类型' }), col({ name: 'chip_type_id', type: 'text', label: '本单锁定的芯片类型' }),
col({ name: 'gate_reason', type: 'text', label: '入参未就绪原因(件上摆的就是它)' }), col({ name: 'gate_reason', type: 'text', label: '入参未就绪原因(件上摆的就是它)' }),
/*
* **红条的开关是真数据,不是那句话空不空**(2026-08-31 用户:「没有必填要警告了,
* 红色区域就不要显示」):gate_reason 清成空串时按列归型**不落笔**(空值=不写这一列,
* jsonb 合并保旧值)—— 那句话一旦写下就清不掉,红条永远亮着。开关列两个值都非空,
* 两个方向都写得动;件上 data-class-of 投影 v-clear 收红条,判断留在样式表。
*/
col({ name: 'gate_state', type: 'text', label: '入参闸门状态', tally: ['open', 'clear'] }),
/*
* **这一步派的是哪种工单**(2026-08-31 用户定「数据齐了也叫人一次」):
* `data` = 数据工单(有格子要填)· `accept` = 验收工单(只读,只核对)。
* 件按它换脸(题头四字 + 卡角编辑钮显隐),配方按它决定发哪张卡 —— 见 case-model
* 那一笔 `order_kind` 的注释(**粘着**:派出去是哪种,这一步到底就是哪种)。
*/
col({ name: 'order_kind', type: 'text', label: '这一步派的工单种类', tally: ['data', 'accept'] }),
col({ name: 'summary', type: 'text', label: '结论摘要' }), col({ name: 'summary', type: 'text', label: '结论摘要' }),
], ],
}, },
@@ -494,6 +539,14 @@ export const manifest: ActivityManifest = {
col({ name: 'unknown_disposition', type: 'text', label: 'UNKNOWN 去向(验证任务号 / 补来源动作)', requiredWhen: { column: 'difference_status', value: 'unknown' } }), col({ name: 'unknown_disposition', type: 'text', label: 'UNKNOWN 去向(验证任务号 / 补来源动作)', requiredWhen: { column: 'difference_status', value: 'unknown' } }),
col({ name: 'risk_level', type: 'text', label: '风险等级', required: true, tally: ['low', 'medium', 'high'] }), col({ name: 'risk_level', type: 'text', label: '风险等级', required: true, tally: ['low', 'medium', 'high'] }),
col({ name: 'rule_version', type: 'text', label: '差异规则版本', required: true }), col({ name: 'rule_version', type: 'text', label: '差异规则版本', required: true }),
/*
* **这一轮复核点到了哪几条**(2026-09-01 用户实测:「我打回让修改工作温度的结论,
* 给我改了静态电流的结论」)—— 人打回时写的那句话里点了名的行标 `named`,其余 `kept`。
* 谁可以变由这一列说了算(**行由确定的那一侧决定,不由 AI 决定** —— 规范 §4.6 纪律一):
* 只有 `named` 的行才进重判的入参、也只有它们会被写回;`kept` 的行 AI 连见都见不到。
* 意见一条都没点到时另有一笔把整表标 `named`(那种意见说的是整批,不是某一条)。
*/
col({ name: 'redo_scope', type: 'text', label: '本轮复核范围', tally: ['named', 'kept'] }),
], ],
}, },
/* P-06 验证记录:一单一行,关联的是本单案号(P-01 不在本活动里,顶掉 request_id) */ /* P-06 验证记录:一单一行,关联的是本单案号(P-01 不在本活动里,顶掉 request_id) */
@@ -568,7 +621,7 @@ export const manifest: ActivityManifest = {
* LM2596S-3.3 刻意不在库。 * LM2596S-3.3 刻意不在库。
*/ */
samples: [ samples: [
{ name: '候选缺库·新建类型', input: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'XL2596S-5.0' }, expect: { ports: ['assessment-released'] } }, { name: '候选缺库·建档定型', input: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'XL2596S-5.0' }, expect: { ports: ['assessment-released'] } },
{ name: '都在库·零卡直分析', input: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'LM2596S-ADJ' }, expect: { ports: ['assessment-released'] } }, { name: '都在库·零卡直分析', input: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'LM2596S-ADJ' }, expect: { ports: ['assessment-released'] } },
{ name: '类型不一致·硬拦', input: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'AMS1117-3.3' }, expect: { ports: ['assessment-released'] } }, { name: '类型不一致·硬拦', input: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'AMS1117-3.3' }, expect: { ports: ['assessment-released'] } },
{ name: '原PN缺库', input: { originalInternalPn: 'LM2596S-3.3', candidateInternalPn: 'LM2596S-ADJ' }, expect: { ports: ['assessment-released'] } }, { name: '原PN缺库', input: { originalInternalPn: 'LM2596S-3.3', candidateInternalPn: 'LM2596S-ADJ' }, expect: { ports: ['assessment-released'] } },
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,365 +0,0 @@
/**
* ACT-WORK-05 差异评估的目标件样式表 —— **机械抽取,一个数值都不许改**
* (《HTML报告生成规范》§7:有目标件时「搬」,不要「照着做」;样式表整段搬,标记逐块搬)。
*
* 来源:
* · `P08_CSS` ← `docs/ss02-act-05-new/ACT-WORK-05-数据工单-PN主数据创建.html` 的
* 整段 `<style>`(**2026-08-30 换版面**:用户给了新目标件,`input-supplement`
* 整张照它重排 —— 步骤轨道 `.steps`/`.stepbox`、档案卡 `.idcard`、参数瓷贴
* `.pvgrid`/`.pv`、新增类型面板 `.newtype`。旧来源
* `docs/SS02-ACT-WORK-05-html/ACT-WORK-05-数据工单-01-芯片主数据创建.html`
* 的两段 `<style>` 随之整段退役 —— 那一版带的 `.jc-vcell`/`.jc-cell` 孪生件
* 显隐规则不在新目标件里,平台绑定所需的那部分改由 `report.ts` 的 `BIND_*`
* 承载,见那边「编辑机制」一节);抽取与源文件 `<style>` 区间 diff 零差异;
* · `ACC1_CSS` ← `ACT-WORK-05-验收工单-01-差异评估结论审核.html` 的 `<style>`;
* · `ACC2_CSS` ← `ACT-WORK-05-验收工单-02-评审放行与落账确认.html` 的 `<style>`。
*
* `EXEC_CSS`(执行工单-01 的样式)已随 Task 7 删除:它原来专属的 `eval-input-list`
* 局面已并入 `input-supplement`(改用 `P08_CSS`),此后再没有消费方 —— target-style.ts
* 只留「没有消费方就删」这条规矩下**还有人用**的常量,ACC1_CSS / ACC2_CSS 虽然此刻
* 也没有工单件在用,但被 `activity.test.ts`「两张验收件的主题色」那条测试直接导入校验
* (Task 8 / 9 会把它们重新接回 `difference-review` / `landing-confirm`),留着。
*
* `p10-document-create`(P-10 原厂技术资料创建)原来**没有自己的目标件**、复用 `P08_CSS` ——
* Task 7 重写后 P-10 不再是这个活动的数据工单范围(本活动不碰 P-10),这句历史说明留作背景。
*
* 抽取用 `sed -n` 按行号截取 `<style>`/`</style>` 之间的原文,再拿抽出来的内容与
* 源文件同一行区间做 `diff` 验「零差异」(ACC1/ACC2 的过程在 task-5-report.md;
* P08 新版 2026-08-30 换版面时同法重验)。
*
* 这份文件只装**目标件自己的样式**,不装平台绑定用的开关(三态占位、缺项红带、
* `data-class-of` 的 `v-*` 别名这类)——那些是本活动自己加出来的东西,活在
* `report.ts` 的 `BIND_*` 常量里,两处不要混。
*/
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;
--data:#3370ff; --data-deep:#245bdb; --data-soft:#e1eaff; --data-line:#c3d6ff; --data-wash:#f0f4ff;
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
--amber:#8f6a12; --amber-soft:#fdf3d7; --amber-line:#ecd9a0;
--mono:"SF Mono",Menlo,Consolas,monospace;
/* 8px 间距节奏(dense) */
--s1:8px; --s2:16px; --s3:24px; --s4:32px;
--r-sm:8px; --r-md:12px;
--tr:.22s cubic-bezier(.2,.7,.3,1);
}
*{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:#fff;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:20px 24px;border-bottom:1px solid var(--line-soft);background:linear-gradient(120deg,#f0f4ff 0%,#fff 55%,#eef3ff 100%)}
.badge{flex:none;width:44px;height:44px;border-radius:11px;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,.28)}
.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:20px;font-weight:800;line-height:1.3;letter-spacing:-.01em}
.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-deep);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:var(--s3) 24px var(--s4)}
/* ===== 区标 / 说明带 ===== */
.zone{font-size:11px;font-weight:800;letter-spacing:.12em;color:var(--ink-4);margin:0 0 var(--s1)}
.zone b{color:var(--ink-3)}
.note{border:1px solid var(--line);border-left:3px solid var(--data);border-radius:var(--r-sm);background:var(--soft);padding:14px 16px;margin-bottom:var(--s2)}
.note p{margin:0;font-size:13px;color:var(--ink-2)}
.note p+p{margin-top:6px}
.note b{color:var(--ink)}
.note .need{color:var(--data-deep);font-weight:700}
.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.ok{color:var(--ok);background:var(--ok-soft);border-color:#c9ecc2}
.st.new{color:var(--data-deep);background:var(--data-soft);border-color:var(--data-line)}
.st.lock{color:var(--ink-3);background:#eef1f6;border-color:var(--line)}
/* ===== 编辑闸 + 必填进度 ===== */
.jc-editgate{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:0 0 var(--s2);padding:10px 14px;border-radius:10px;background:var(--soft);border:1px solid var(--line-soft);font-size:12px;color:var(--ink-3);transition:background var(--tr),border-color var(--tr)}
.jc-editgate-t{flex:1 1 auto;min-width:0}
.jc-editgate-t b{color:var(--ink-2);font-weight:700}
.gateprog{flex:1 1 100%;display:flex;align-items:center;gap:10px;margin-top:6px}
.gateprog .track{flex:1;height:5px;border-radius:3px;background:var(--line-soft);overflow:hidden}
.gateprog .fill{display:block;height:100%;width:0;border-radius:3px;background:linear-gradient(90deg,var(--data),var(--data-deep));transition:width .35s cubic-bezier(.2,.7,.3,1)}
.gateprog .pct{font-family:var(--mono);font-size:11px;color:var(--ink-4);min-width:64px;text-align:right}
.jc-egbtn{flex:none;display:inline-flex;align-items:center;gap:5px;cursor:pointer;border:1px solid var(--line);border-radius:8px;padding:7px 14px;font:inherit;font-size:12px;font-weight:700;color:var(--ink-2);background:#fff;transition:border-color var(--tr),color var(--tr),background var(--tr),transform .12s}
.jc-egbtn:hover{border-color:var(--data);color:var(--data)}
.jc-egbtn:active{transform:scale(.97)}
.jc-egbtn.save{color:#fff;background:var(--data);border-color:var(--data)}
.jc-egbtn.save:hover{filter:brightness(1.06);color:#fff}
.jc-egbtn:focus-visible{outline:2px solid var(--data-deep);outline-offset:2px}
.jc-editgate .eg-cancel,.jc-editgate .eg-save{display:none}
.jc-editgate.on{background:var(--data-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}
/* ===== 渲染态:档案卡 ===== */
.idcard{border:1px solid var(--line-soft);border-radius:var(--r-md);margin-bottom:var(--s2);background:linear-gradient(150deg,#fff 60%,var(--data-wash));display:grid;grid-template-columns:1.2fr 1fr;overflow:hidden}
.idcard .cell{padding:18px 22px}
.idcard .cell+.cell{border-left:1px solid var(--line-soft);background:rgba(255,255,255,.55)}
.idcard .lab{font-size:11.5px;font-weight:800;color:var(--ink-3);letter-spacing:.08em;display:flex;align-items:center;gap:6px}
.idcard .lab::before{content:"";width:8px;height:8px;border-radius:2.5px;background:var(--data)}
.idcard .val{margin-top:8px;font-size:24px;font-weight:800;font-family:var(--mono);letter-spacing:-.01em;line-height:1.25;color:var(--ink)}
.idcard .val.empty{font-size:14px;font-family:inherit;font-weight:400;color:var(--ink-4)}
.idcard .val .tpill{display:inline-block;font-family:inherit;font-size:15px;font-weight:800;color:var(--data-deep);background:var(--data-soft);border:1px solid var(--data-line);border-radius:999px;padding:4px 14px;vertical-align:2px}
.idcard .val .st{margin-left:8px;vertical-align:3px}
/* ===== 参数表(渲染态) ===== */
.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:.04em;font-weight:800;color:var(--ink-3);padding:10px 14px;border-bottom:1px solid var(--line)}
td{padding:10px 14px;border-bottom:1px solid var(--line-soft);vertical-align:middle}
tr:last-child td{border-bottom:0}
tbody tr{transition:background .15s}
tbody tr:hover{background:var(--data-wash)}
.empty-row td{color:var(--ink-4);text-align:center;padding:22px}
.empty-row td:hover{background:#fff}
/* ===== 编辑态:步骤轨道 ===== */
.editarea{display:none}
body.editing .editarea{display:block}
body.editing .viewarea{display:none}
.steps{position:relative}
.steps::before{content:"";position:absolute;left:12px;top:20px;bottom:20px;width:2px;background:var(--line-soft);border-radius:1px}
.stepbox{position:relative;padding-left:36px;margin-bottom:var(--s3)}
.stepbox:last-child{margin-bottom:0}
.sdot{position:absolute;left:0;top:0;width:26px;height:26px;border-radius:50%;background:#fff;border:2px solid var(--data-line);color:var(--data-deep);font-size:12px;font-weight:800;display:grid;place-items:center;transition:background var(--tr),border-color var(--tr),color var(--tr)}
.stepbox.done .sdot{background:var(--data);border-color:var(--data);color:#fff}
.scard{border:1px solid var(--line-soft);border-radius:var(--r-md);background:#fff;transition:border-color var(--tr),box-shadow var(--tr)}
.stepbox.done .scard{border-color:var(--data-line)}
.scard .sh{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--line-soft);font-size:13.5px;font-weight:800;letter-spacing:-.01em}
.scard .sh .req-note{margin-left:auto;font-size:11px;color:var(--ink-4);font-weight:400}
.scard .sb{padding:16px}
.fld{display:flex;align-items:flex-start;gap:12px;font-size:12.5px}
.fld .fk{width:88px;flex:none;color:var(--ink-3);font-weight:700;font-size:12px;padding-top:9px}
.fld .fk.req::after{content:" *";color:var(--danger)}
.fld .fv{flex:1;min-width:0}
.fld input,.fld select{width:100%;border:1px solid var(--line);border-radius:var(--r-sm);padding:9px 11px;font:inherit;font-size:13px;color:var(--ink);background:#fff;transition:border-color var(--tr),box-shadow var(--tr)}
.fld input:focus,.fld select:focus{border-color:var(--data);box-shadow:0 0 0 3px var(--data-wash);outline:none}
.hint{font-size:11.5px;color:var(--ink-4);margin-top:6px}
/* ===== 新增类型面板 ===== */
.newtype{display:none;margin-top:12px;border:1px dashed var(--data-line);border-radius:10px;background:var(--data-wash);padding:14px 16px}
.newtype.show{display:block;animation:rise .25s cubic-bezier(.2,.7,.3,1)}
@keyframes rise{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
.newtype h4{margin:0 0 10px;font-size:12.5px;color:var(--data-deep)}
.prow{display:flex;gap:8px;align-items:center;margin-top:8px;animation:rise .2s cubic-bezier(.2,.7,.3,1)}
.prow input{flex:1;border:1px solid var(--line);border-radius:7px;padding:8px 10px;font:inherit;font-size:12.5px;background:#fff;transition:border-color var(--tr),box-shadow var(--tr)}
.prow input:focus{border-color:var(--data);box-shadow:0 0 0 3px rgba(51,112,255,.12);outline:none}
.prow .del{flex:none;width:28px;height:28px;cursor:pointer;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink-3);font-size:14px;line-height:1;transition:border-color var(--tr),color var(--tr),background var(--tr)}
.prow .del:hover{border-color:var(--danger);color:var(--danger);background:var(--danger-soft)}
.nt-actions{display:flex;gap:8px;margin-top:12px;align-items:center}
.btn{cursor:pointer;border:1px solid var(--data-line);border-radius:8px;padding:7px 14px;font:inherit;font-size:12px;font-weight:700;color:var(--data-deep);background:#fff;transition:background var(--tr),transform .12s}
.btn:hover{background:var(--data-soft)}
.btn:active{transform:scale(.97)}
.btn.primary{color:#fff;background:var(--data);border-color:var(--data)}
.btn.primary:hover{filter:brightness(1.07)}
.btn:focus-visible{outline:2px solid var(--data-deep);outline-offset:2px}
/* ===== 参数值瓷贴 ===== */
.pvgrid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}
.pv{border:1px solid var(--line-soft);border-radius:10px;padding:10px 12px;background:#fff;transition:border-color var(--tr),box-shadow var(--tr)}
.pv:focus-within{border-color:var(--data-line);box-shadow:0 0 0 3px rgba(51,112,255,.08)}
.pv .pn2{font-size:12px;font-weight:800;color:var(--ink-2);margin-bottom:6px;display:flex;align-items:baseline;gap:6px}
.pv .pn2::after{content:"*";color:var(--danger);font-size:11px}
.pv input{width:100%;border:1px solid var(--line);border-radius:7px;padding:7px 10px;font:inherit;font-size:12.5px;background:#fff;transition:border-color var(--tr),box-shadow var(--tr)}
.pv input:focus{border-color:var(--data);box-shadow:0 0 0 3px rgba(51,112,255,.12);outline:none}
.pv-empty{border:1px dashed var(--line);border-radius:10px;padding:20px;text-align:center;color:var(--ink-4);font-size:12px}
/* ===== 底 ===== */
.flow{margin-top:var(--s2);padding:10px 14px;border-radius:var(--r-sm);background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
.flow b{color:var(--ink)}
.card-foot{padding:14px 24px;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:760px){.pvgrid{grid-template-columns:1fr}.card-head{flex-wrap:wrap}.side{align-items:flex-start}.idcard{grid-template-columns:1fr}.idcard .cell+.cell{border-left:0;border-top:1px solid var(--line-soft)}}
@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
`;
/*
* 两张验收件(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)}
/*
* 补三条:report.ts 的 zone()/note() 助手把 P08 目标件「视觉补充段 v3」的图标
* (.zic)与强调色(.note .done / .note .need)跨文档复用到了这两张验收件的
* 区块头与提示文案上(见 report.ts 的 ZIC_TASK / ZIC_TABLE 常量与 note() 调用处)——
* 原验收工单目标件自己的 style 段从未定义过它们(核对过
* docs/SS02-ACT-WORK-05-html/ 下两份验收工单目标件,原型的 zone 段从没带过图标,
* note 里也没有 done/need 分级)——补两条,值照 P08_CSS 抄
* (2026-08-30 task-12,缺陷③;--data 这份 :root 没有,用同色的 --brand 顶替,
* 数值仍是 #3370ff,不算改动目标件写死的颜色)。
* 漏掉 .zic 的后果曾经是灾难性的:三枚 viewBox="0 0 24 24" 的图标在没有 .zic 约束
* 宽高时会撑满容器宽、再按比例撑高,量出来是 946×946 像素、把整份文档撑到 4625px 高,
* 正文全被挤出可视区(人打开只看见三块灰色巨块加大片空白)。
*/
.zic{width:14px;height:14px;color:var(--ink-3);vertical-align:-2px;margin-right:3px}
.note .done{color:var(--ok);font-weight:700}
.note .need{color:var(--brand);font-weight:700}
.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)}
/*
* 补三条:report.ts 的 zone()/note() 助手把 P08 目标件「视觉补充段 v3」的图标
* (.zic)与强调色(.note .done / .note .need)跨文档复用到了这两张验收件的
* 区块头与提示文案上(见 report.ts 的 ZIC_TASK / ZIC_TABLE 常量与 note() 调用处)——
* 原验收工单目标件自己的 style 段从未定义过它们(核对过
* docs/SS02-ACT-WORK-05-html/ 下两份验收工单目标件,原型的 zone 段从没带过图标,
* note 里也没有 done/need 分级)——补两条,值照 P08_CSS 抄
* (2026-08-30 task-12,缺陷③;--data 这份 :root 没有,用同色的 --brand 顶替,
* 数值仍是 #3370ff,不算改动目标件写死的颜色)。
* 漏掉 .zic 的后果曾经是灾难性的:三枚 viewBox="0 0 24 24" 的图标在没有 .zic 约束
* 宽高时会撑满容器宽、再按比例撑高,量出来是 946×946 像素、把整份文档撑到 4625px 高,
* 正文全被挤出可视区(人打开只看见三块灰色巨块加大片空白)。
*/
.zic{width:14px;height:14px;color:var(--ink-3);vertical-align:-2px;margin-right:3px}
.note .done{color:var(--ok);font-weight:700}
.note .need{color:var(--brand);font-weight:700}
.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}
`;
+13
View File
@@ -92,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;
}>; }>;
}; };
/** /**
+2
View File
@@ -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>>;
/** /**
+2
View File
@@ -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 对条件展开
+23
View File
@@ -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>;
}; };
+5
View File
@@ -2835,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>;
+16
View File
@@ -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;
+18 -3
View File
@@ -72,8 +72,16 @@ export type AutoAction = {
readonly when?: string | undefined; readonly when?: string | undefined;
/** 台账地址「域.对象」—— 必须在 `declarations.ledger` 里(静态审) */ /** 台账地址「域.对象」—— 必须在 `declarations.ledger` 里(静态审) */
readonly into: string; readonly into: string;
/** 这一笔投影出来的行**就是这张表在本单草稿里的全部**,多出来的撤掉。只动草稿层 */ /**
readonly prune?: boolean | undefined; * 这一笔投影出来的行**就是这张表在本单草稿里的全部**,多出来的撤掉。只动草稿层。
* `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 from: AutoSource;
readonly fields: Readonly<Record<string, AutoExpr>>; readonly fields: Readonly<Record<string, AutoExpr>>;
} | { } | {
@@ -96,7 +104,11 @@ export type AutoAction = {
readonly kind: 'card'; readonly kind: 'card';
readonly when?: string | undefined; readonly when?: string | undefined;
readonly as: 'execution' | 'decide'; readonly as: 'execution' | 'decide';
readonly variant?: ('data' | 'review') | undefined; /**
* 卡的面孔:`data` 数据工单 / `accept` 验收工单(都是执行工单的变体)· `review` 审核卡(决策卡的)。
* 不写就是普通执行工单 / 普通决策卡。
*/
readonly variant?: ('data' | 'review' | 'accept') | undefined;
readonly title: AutoExpr; readonly title: AutoExpr;
/** 决策卡问的那句话 */ /** 决策卡问的那句话 */
readonly ask?: AutoExpr | undefined; readonly ask?: AutoExpr | undefined;
@@ -110,6 +122,9 @@ export type AutoAction = {
}[] | undefined; }[] | undefined;
/** 这张卡的提交闸盯哪几张表 */ /** 这张卡的提交闸盯哪几张表 */
readonly gateLedgers?: readonly string[] | undefined; readonly gateLedgers?: readonly string[] | undefined;
/** 确认闸的条件档(S-FEEL,只读基座读数;为假不受理确认)+ 拒回那句话 —— 见 card.ts 同名字段 */
readonly gateWhen?: string | undefined;
readonly gateWhenSay?: string | undefined;
/** 决策卡的选项;`variant:'review'` 时允许只有一个 */ /** 决策卡的选项;`variant:'review'` 时允许只有一个 */
readonly options?: readonly { readonly options?: readonly {
readonly value: string; readonly value: string;
+8
View File
@@ -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;
@@ -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');
@@ -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,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();
});
});
@@ -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);
});
});
@@ -689,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();
});
});
+70 -3
View File
@@ -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 };
} }
+39 -2
View File
@@ -38,7 +38,7 @@ 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 { followRes, stepsOf } from './instance-steps.ts'; import { followRes, stepsOf } from './instance-steps.ts';
@@ -229,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);
@@ -251,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 !== '');
} }
+25 -22
View File
@@ -674,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 };
+37 -2
View File
@@ -45,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';
@@ -816,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: [] };
} }
/** /**
@@ -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) }),
+80 -5
View File
@@ -242,6 +242,61 @@ export function createOnlyViolation(
* 而 `checkChatCardReply` 又要求 `checked ⊇ checklist`:卡按不下去。**只在 `reviewFace` 为真时** * 而 `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( export function decisionCardFace(
input: { readonly variant?: 'review' | undefined; readonly checklist?: ReadonlyArray<string> | undefined }, input: { readonly variant?: 'review' | undefined; readonly checklist?: ReadonlyArray<string> | undefined },
step: { readonly cardVariant?: string | undefined; readonly acceptanceChecklist?: ReadonlyArray<string> | undefined } | undefined, step: { readonly cardVariant?: string | undefined; readonly acceptanceChecklist?: ReadonlyArray<string> | undefined } | undefined,
@@ -1203,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) {
@@ -1226,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('、')})` +
'是本单在数据工单里建好、人确认后已入库的,撤不掉;要改由人在件上改,你接着办下一件事。', '是本单在数据工单里建好、人确认后已入库的,撤不掉;要改由人在件上改,你接着办下一件事。',
@@ -1799,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: '案例模型收口' }],
@@ -1907,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: '案例模型当下局面' }],
@@ -1919,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 });
@@ -316,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]);
@@ -612,6 +618,8 @@ export function autoRecipeFindings(manifest: ActivityManifest): StaticAuditFindi
const where = `${at}.do[${index}]`; const where = `${at}.do[${index}]`;
/* 动作级 `when`:语法 + 名字都审(它看得见的只有 scope 与 let,没有行内列) */ /* 动作级 `when`:语法 + 名字都审(它看得见的只有 scope 与 let,没有行内列) */
if (action.when !== undefined) checkWhen(action.when, where, letNames); 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') { if (action.kind === 'project') {
checkAddress(action.into, where); checkAddress(action.into, where);
walkSource(action.from, where); walkSource(action.from, where);
@@ -628,7 +636,7 @@ export function autoRecipeFindings(manifest: ActivityManifest): StaticAuditFindi
* 说的是「这张表归人填,模型没有来源」—— 人加的行配方的数据源里当然没有, * 说的是「这张表归人填,模型没有来源」—— 人加的行配方的数据源里当然没有,
* 下一拍对齐就把它撤了。两条声明放在同一张表上,意思是相反的。 * 下一拍对齐就把它撤了。两条声明放在同一张表上,意思是相反的。
*/ */
if (action.prune === true && (step.humanWrites ?? []).includes(action.into)) { if ((action.prune === true || action.prune === 'live') && (step.humanWrites ?? []).includes(action.into)) {
out.push({ out.push({
code: 'schema-invalid', code: 'schema-invalid',
severity: 'warn', severity: 'warn',
@@ -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');
@@ -133,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),放行回传就必须带组内恰一口:
@@ -542,3 +542,17 @@ describe('data-options:下拉的选项从台账现读', () => {
expect(out).toContain('<option value="2" selected>LDO 线性稳压器</option>'); 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=');
});
});
@@ -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}`;
}); });
@@ -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);
@@ -158,6 +158,7 @@
近变(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'` = 审核卡摆成验收工单的样子,机制仍是决策卡);
@@ -177,6 +178,16 @@
跳过的行照旧算「这一笔要的行」,`prune` 不许撤它。 跳过的行照旧算「这一笔要的行」,`prune` 不许撤它。
- `writable` 多一个可选 `caseKey`(单标识列):摆给人看的行标识要**去掉单号那一段** - `writable` 多一个可选 `caseKey`(单标识列):摆给人看的行标识要**去掉单号那一段**
(2026-08-25 那条口径,基座那一侧是 `rowLabel`)—— 人正在这一单里看,单号是脚下的地面。 (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` 的兜底驱动**:兜底(在场的第一个有剧本的项)不过滤 - **配方由 `liveStep` 驱动,不由 `current` 的兜底驱动**:兜底(在场的第一个有剧本的项)不过滤
@@ -103,8 +103,14 @@ function harness(seed: Record<string, unknown> = {}, override: { analyze?: Analy
}); });
return Promise.resolve({ changed }); return Promise.resolve({ changed });
}, },
draftDrop: (i: { domain: string; object: string; key: Record<string, string> }) => { draftDrop: (i: { domain: string; object: string; key: Record<string, string>; skipLanded?: boolean }) => {
const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`; const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`;
/* 生产真有的落地守卫(engines.ts):已落地的行,配方带 skipLanded 就跳过,别的路硬拒 */
const row = drafts.get(id);
if (row?.landed === true) {
if (i.skipLanded === true) return Promise.resolve({ dropped: false, landed: true });
return Promise.reject(new Error('这一行是本单在数据工单里建好、人确认后已入库的,撤不掉'));
}
return Promise.resolve({ dropped: drafts.delete(id) }); return Promise.resolve({ dropped: drafts.delete(id) });
}, },
}, },
@@ -128,6 +134,12 @@ function harness(seed: Record<string, unknown> = {}, override: { analyze?: Analy
if (expr === 'never') return false; if (expr === 'never') return false;
if (expr === 'always') return true; if (expr === 'always') return true;
if (expr === 'found_ref != ""') return String(scope['found_ref'] ?? '') !== ''; if (expr === 'found_ref != ""') return String(scope['found_ref'] ?? '') !== '';
/*
* 人打回时写的那句话:空串 = 没打回(2026-09-01)。**照真 S-FEEL 的语义写**——
* 未绑定的名字 `!= ""` 求值为 **true**(见本 describe 里那条直接拿 @sfeel 验的用例);
* 桩若在这儿手滑写成「没有就当空串」,「必须永远绑上」这条就永远钉不住。
*/
if (expr === 'case_redo_note != ""') return !('case_redo_note' in scope) || String(scope['case_redo_note']) !== '';
throw new Error(`没教过这句:${expr}`); throw new Error(`没教过这句:${expr}`);
}, },
expression: () => undefined, expression: () => undefined,
@@ -244,6 +256,28 @@ describe('配方执行器', () => {
expect(line).not.toContain('W05-1'); expect(line).not.toContain('W05-1');
}); });
it('prune:已落地的陈旧行跳过不炸,没落地的照撤(2026-08-31 skipLanded)', async () => {
const { runner, drafts, notes, ctx } = harness({});
/* 铺 A、B、C 三行;B 随「数据工单确认即入库」落了地(landed),B、C 随后都不在投影源里了 */
await runner.run(
{ do: [{ kind: 'project', into: 'sub_eval.part', from: { rows: [{ pn: 'A' }, { pn: 'B' }, { pn: 'C' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }] },
ctx,
);
for (const d of drafts.values()) if (d.key['internal_pn'] === 'B') d.landed = true;
const out = await runner.run(
{ do: [{ kind: 'project', into: 'sub_eval.part', prune: true, from: { rows: [{ pn: 'A' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }] },
ctx,
);
/* 不炸(从前这里整个配方抛错停摆);C(没落地)撤掉,B(已落地)原地留下 */
expect(out.failedAt).toBeUndefined();
expect(out.dropped).toBe(1);
expect([...drafts.values()].map((d) => d.key['internal_pn']).sort()).toEqual(['A', 'B']);
const line = notes.find((n) => n.includes('撤掉 1 行'));
expect(line).toContain('撤掉 1 行(C)');
/* 跳过也要出声:B 已入库、这一笔不清它 —— 实情说给人听(debt 那条的原话要求) */
expect(line).toContain('1 行已随数据工单入库、这一笔不清它');
});
it('say:进工作日志,归系统', async () => { it('say:进工作日志,归系统', async () => {
const { runner, notes, ctx } = harness({ n: 2 }); const { runner, notes, ctx } = harness({ n: 2 });
await runner.run({ do: [{ kind: 'say', text: { cat: ['已核对 ', { in: 'n' }, ' 颗料'] } }] }, ctx); await runner.run({ do: [{ kind: 'say', text: { cat: ['已核对 ', { in: 'n' }, ' 颗料'] } }] }, ctx);
@@ -355,6 +389,57 @@ describe('analyze 动词', () => {
}); });
}); });
/*
* **人打回时写的那句话要到得了配方**(2026-09-01 用户定:「重做后是否没有将重做原因带回去
* 重新让 ai 重跑这一步?」)—— 配方据它决定这一轮要不要把意见交回给 AI 重判。
* 名字是 `case_redo_note`(下划线,不是 `case:` 前缀):S-FEEL 的名字不许带冒号,
* 而这句话最要紧的用法恰恰是当 `when` 条件用。
*/
describe('case_redo_note —— 人打回写的那句话', () => {
const ROW = { kind: 'project' as const, into: 'sub_eval.part', from: { once: true as const } };
it('打回了:那句话进作用域,`when` 与 `{in:}` 都读得到', async () => {
const { runner, drafts, ctx } = harness({});
const out = await runner.run(
{
do: [{ ...ROW, when: 'case_redo_note != ""', fields: { eval_ref: 'W05-1', internal_pn: 'LM2596S-5.0', note: { in: 'case_redo_note' } } }],
},
{ ...ctx, redoNote: '使能阈值那条请写明验证任务号' },
);
expect(out.wrote).toBe(1);
expect([...drafts.values()][0]?.patch['note']).toBe('使能阈值那条请写明验证任务号');
});
it('没打回:这个名字**仍然绑着**(空串)—— 只在被打回时才跑的那几笔因此关得住', async () => {
const { runner, drafts, ctx } = harness({});
const out = await runner.run(
{
do: [
{ ...ROW, when: 'case_redo_note != ""', fields: { eval_ref: 'W05-1', internal_pn: 'REDO-ONLY', role: 'x' } },
{ ...ROW, fields: { eval_ref: 'W05-1', internal_pn: 'ALWAYS', note: { in: 'case_redo_note' } } },
],
},
ctx,
);
/* 第一笔被 when 挡住(绑了空串);第二笔照跑,那句话取出来是空串 */
expect(out.wrote).toBe(1);
const rows = [...drafts.values()];
expect(rows.map((d) => d.key['internal_pn'])).toEqual(['ALWAYS']);
expect(rows[0]?.patch['note']).toBe('');
});
/*
* **为什么非绑不可**——用**真** S-FEEL(不是本文件顶部那个只认几句话的桩)把两种下场摆出来:
* 不绑,「只在被打回时才跑」的那几笔**每一轮都会跑**(未绑定的名字 `!= ""` 求值为真);
* 绑上空串才关得住。本仓踩过同一个坑不止一次(S1 的 `doneWhen`、`gate_reason` 那条)。
*/
it('不绑的下场:真 S-FEEL 里未绑定的名字 `!= ""` 求值为 **true**', () => {
expect(evaluateCondition('case_redo_note != ""', {})).toBe(true);
expect(evaluateCondition('case_redo_note != ""', { case_redo_note: '' })).toBe(false);
expect(evaluateCondition('case_redo_note != ""', { case_redo_note: '有意见' })).toBe(true);
});
});
describe('card 与 submit', () => { describe('card 与 submit', () => {
it('execution:发一张数据工单,itemId 挂在这一步上,basis 现算', async () => { it('execution:发一张数据工单,itemId 挂在这一步上,basis 现算', async () => {
const { runner, cards, ctx } = harness({ pn: 'XL2596S-5.0' }); const { runner, cards, ctx } = harness({ pn: 'XL2596S-5.0' });
@@ -370,6 +455,8 @@ describe('card 与 submit', () => {
standard: '必填格全部填上,红带消失', standard: '必填格全部填上,红带消失',
basis: [{ k: '候选料号', v: { in: 'pn' } }], basis: [{ k: '候选料号', v: { in: 'pn' } }],
gateLedgers: ['parts.profile', 'parts.parameter'], gateLedgers: ['parts.profile', 'parts.parameter'],
gateWhen: 'parts_profile_distinct_chip_type_id <= 1',
gateWhenSay: '两颗料不是同一个芯片类型 —— 先改对再确认。',
}, },
], ],
}, },
@@ -387,11 +474,43 @@ describe('card 与 submit', () => {
standard: '必填格全部填上,红带消失', standard: '必填格全部填上,红带消失',
basis: [{ k: '候选料号', v: 'XL2596S-5.0' }], basis: [{ k: '候选料号', v: 'XL2596S-5.0' }],
gateLedgers: ['parts.profile', 'parts.parameter'], gateLedgers: ['parts.profile', 'parts.parameter'],
/* 确认闸条件档随卡走(2026-08-31)—— 服务端 submitGate 靠它拦「类型不一致还确认」 */
gateWhen: 'parts_profile_distinct_chip_type_id <= 1',
gateWhenSay: '两颗料不是同一个芯片类型 —— 先改对再确认。',
fields: [], fields: [],
}, },
}); });
}); });
/*
* **验收工单 = 执行工单的第三副面孔**(2026-08-31 用户定:「数据是足的也进入这个数据工单,
* 就派发一个验收工单…只展示不能编辑」)。引擎这一侧与数据工单一模一样,只把 `variant`
* 原样带下去 —— 面孔归对话区,件的只读归模版(它按同一面旗换脸)。
*/
it('execution + accept:验收工单,variant 原样带到卡上', async () => {
const { runner, cards, ctx } = harness({ pn: 'XL2596S-5.0' });
await runner.run(
{
do: [
{
kind: 'card',
as: 'execution',
variant: 'accept',
title: '验收工单 · 差异评估的输入核对',
guide: ['两张卡的主数据核一遍'],
basis: [{ k: '候选料号', v: { in: 'pn' } }],
gateLedgers: ['parts.profile', 'parts.parameter'],
},
],
},
ctx,
);
expect(cards.issued[0]).toMatchObject({
workOrder: 'execution',
card: { mode: 'execution', variant: 'accept', title: '验收工单 · 差异评估的输入核对', fields: [] },
});
});
it('这一步已经发过卡:**不再发第二张**,如实留一条观察', async () => { it('这一步已经发过卡:**不再发第二张**,如实留一条观察', async () => {
const { runner, notes, ctx } = harness({}); const { runner, notes, ctx } = harness({});
const out = await runner.run( const out = await runner.run(
@@ -465,6 +584,38 @@ describe('配方留痕真的到得了工作日志(spec §5.9 验收 · R10)', ()
expect(appended[0]?.detail?.['internal']).toBeUndefined(); expect(appended[0]?.detail?.['internal']).toBeUndefined();
}); });
/**
* **人正在件上填的那几拍(quiet),say 不上对话区**(2026-08-31 用户:「每次保存的时候
* 右边也会出现 ai 推进中,然后会说一句话,能不能把这个也取消掉」)。
*
* 件上按保存会 `wake` 这一单(「保存即铺格」),配方跟着跑一趟 quiet —— 而 quiet 只跑
* project 与 say,于是人按几次保存,同一句交代就往对话区说几遍。对话区的纪律是
* **一轮一句,不是一拍一句**(2026-08-01),而 quiet 拍压根没有交出控制权(人正对着
* 那张卡填),算不上新一轮。
*
* 压下去不是丢掉:`internal` 是两个投影共用的一个旗(终审 I5/F2)。这一条钉住的是
* **前一半**(quiet → 标 internal,而工作日志按 `detail.auto` 照旧放行);
* **后一半**(标了 internal 就进不了对话区)钉在
* `apps/api/src/modules/interaction/__tests__/chat-projection.test.ts`
* 「引擎内部观察(internal 标)进日志不进对话」那一条 —— `projectChat` 没出模块边界,
* 跨模块深入 internal 会被 depcruise 拦,两半各钉在自己模块里。
*/
it('**quiet 拍的 say:标 internal(对话区据此挡住),而工作日志照旧看得见**', async () => {
const { runner, appended, ctx } = harness({ n: 2 });
await runner.run(
{ do: [{ kind: 'say', text: { cat: ['已核对 ', { in: 'n' }, ' 颗料'] } }] },
{ ...ctx, quiet: true, carded: true },
);
expect(appended).toHaveLength(1);
expect(appended[0]?.detail?.['internal']).toBe(true);
/* 话本身没丢:`detail.auto` 那条例外让它留在工作日志里(值有插值时也查得到) */
expect(appended[0]?.title).toBe('已核对 2 颗料');
expect(typeof appended[0]?.detail?.['auto']).toBe('string');
const lines = structuredLog([asLogEntry(appended[0]!)]);
expect(lines).toHaveLength(1);
expect(lines[0]?.who).toBe('system');
});
it('project 那一笔:进日志且归系统,**而且标着 internal(对话区那一侧据此挡住)**', async () => { it('project 那一笔:进日志且归系统,**而且标着 internal(对话区那一侧据此挡住)**', async () => {
const { runner, appended, ctx } = harness({}); const { runner, appended, ctx } = harness({});
await runner.run( await runner.run(
@@ -735,6 +886,23 @@ describe('prune:人在填的时候不撤行(F3)', () => {
expect(out.dropped).toBe(1); expect(out.dropped).toBe(1);
expect([...drafts.values()].map((d) => d.key['internal_pn'])).toEqual(['A']); expect([...drafts.values()].map((d) => d.key['internal_pn'])).toEqual(['A']);
}); });
/*
* `prune: 'live'`(2026-08-31):quiet 拍也撤 —— 只给**人不会在件上加行**的表用
* (行集全由配方铺,如 W05 参数骨架):F3 防的是「人刚加的行被撤」,人加不了行的表
* 没有这个风险;而「改了类型,旧类型的参数行还摆在屏上」是另一种撒谎(用户实测点名
* 「只会跟着变多,不会跟着变少」)—— 跟手比守候更诚实。
*/
it("prune:'live' 在 quiet 拍也对齐 —— 人加不了行的表,改选后旧行跟手消失", async () => {
const { runner, drafts, ctx } = harness({});
await runner.run(SEED, ctx);
const out = await runner.run(
{ do: [{ kind: 'project', into: 'sub_eval.part', prune: 'live', from: { rows: [{ pn: 'A' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }] },
{ ...ctx, quiet: true },
);
expect(out.dropped).toBe(1);
expect([...drafts.values()].map((d) => d.key['internal_pn'])).toEqual(['A']);
});
}); });
/** /**
@@ -8,7 +8,7 @@ import { describe, expect, it } from 'vitest';
import type { TaskId } from '@contracts'; import type { TaskId } from '@contracts';
import { deriveSide, structuredLog } from '@modules/memory'; import { deriveSide, structuredLog } from '@modules/memory';
import type { EntryKind, GovernanceMark, MemoryEntry, VersionStamp } from '@modules/memory'; import type { EntryKind, GovernanceMark, MemoryEntry, VersionStamp } from '@modules/memory';
import { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns, toolInputHash, unmetWhy, answeredCardFact, earlyFinishTitleOf } from '../internal/usecase/turn-runner.ts'; import { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns, toolInputHash, unmetWhy, answeredCardFact, answerSettlesStep, lastCardAnswer, earlyFinishTitleOf } from '../internal/usecase/turn-runner.ts';
import type { TurnRunnerDeps } from '../internal/usecase/turn-runner.ts'; import type { TurnRunnerDeps } from '../internal/usecase/turn-runner.ts';
import type { PendingAcceptance } from '../port.ts'; import type { PendingAcceptance } from '../port.ts';
import type { CaseModel } from '../internal/domain/cmmn-parse.ts'; import type { CaseModel } from '../internal/domain/cmmn-parse.ts';
@@ -101,6 +101,12 @@ function harness(
declaredListeners?: ReadonlyArray<{ eventType: string; keyFilter: Record<string, string> }>; declaredListeners?: ReadonlyArray<{ eventType: string; keyFilter: Record<string, string> }>;
/** 这一单还有哪几张卡在等人交回(不给 = 一张都没有 —— 老测试照跑) */ /** 这一单还有哪几张卡在等人交回(不给 = 一张都没有 —— 老测试照跑) */
unanswered?: ReadonlyArray<{ id: string; title: string }>; unanswered?: ReadonlyArray<{ id: string; title: string }>;
/**
* **发卡之后**那一次 `unanswered` 查询的答案(不给就沿用上面那一份)。
* 「人答得比这一拍收尾还快」测的正是这两次查询给出不同答案的那一刻:
* 发卡时没有待答的卡(所以发得出去),发完再查 —— 那张卡在不在名单里,决定这一拍报「等人」还是「还在跑」。
*/
unansweredAfterIssue?: ReadonlyArray<{ id: string; title: string }>;
/** `deps.cards.issue` 这一句改抛(测「兜底卡自己也发不出去」——2026-08-29 复审 I1 二审) */ /** `deps.cards.issue` 这一句改抛(测「兜底卡自己也发不出去」——2026-08-29 复审 I1 二审) */
issueRejects?: string; issueRejects?: string;
/** /**
@@ -207,7 +213,17 @@ function harness(
issued.push({ workOrder: 'decision', card: { ask: input.ask, itemId: input.itemId, variant: input.variant, checklist: input.checklist } }); issued.push({ workOrder: 'decision', card: { ask: input.ask, itemId: input.itemId, variant: input.variant, checklist: input.checklist } });
return { id: `card-${issued.length}` }; return { id: `card-${issued.length}` };
}, },
unanswered: async () => over.unanswered ?? [], /*
* **发出去的卡默认就在等** —— 假端口从前发完卡还回「一张待答的都没有」,
* 那是生产里不会有的局面(2026-09-01「人答得比这一拍收尾还快」那条新门就靠它分辨)。
* 要测「人答得飞快」的,显式给 `unansweredAfterIssue: []`。
*/
unanswered: async () => {
const first = over.unanswered ?? [];
if (issued.length === 0) return first;
if (over.unansweredAfterIssue !== undefined) return over.unansweredAfterIssue;
return [...first, ...issued.map((_, at) => ({ id: `card-${at + 1}`, title: '(刚发出去的卡)' }))];
},
} as never, } as never,
listeners: { listeners: {
register: async (input: { eventType: string }) => { register: async (input: { eventType: string }) => {
@@ -2197,6 +2213,32 @@ describe('终局放行那一步:human 被拒并指向 submit', () => {
}); });
}); });
/*
* **人答的这句话算不算「办完了」**(2026-08-31)—— 平台只判它知道的两件:
* `custom:`(他没选任何一个选项、写的是自己的话)与约定的 `reject`;选项值的语义平台不知道。
*/
describe('answerSettlesStep / lastCardAnswer', () => {
it('重做的两种形状都不算办完:写了理由的 custom:、以及验收面孔约定的 reject', () => {
expect(answerSettlesStep('custom:结论算错了,请重算')).toBe(false);
expect(answerSettlesStep('reject')).toBe(false);
});
it('通过、空答复(照原样确认)、别的选项值 —— 都算办完', () => {
expect(answerSettlesStep('approve')).toBe(true);
expect(answerSettlesStep(undefined)).toBe(true);
expect(answerSettlesStep('to-sourcing')).toBe(true);
});
it('后面又发了别的卡 = 这条回传已经翻篇,不再算数', () => {
const replied = [
{ kind: 'card-sent', title: '结论审核' },
{ kind: 'card-reply', title: '结论审核', detail: { value: 'custom:重算' } },
];
expect(lastCardAnswer(replied)?.chose).toBe('custom:重算');
expect(lastCardAnswer([...replied, { kind: 'card-sent', title: '落账放行' }])).toBeUndefined();
});
});
describe('auto 步:配方在拍首跑', () => { describe('auto 步:配方在拍首跑', () => {
/** 一步的活动:那一步声明 auto */ /** 一步的活动:那一步声明 auto */
const AUTO_MODEL: CaseModel = { const AUTO_MODEL: CaseModel = {
@@ -2253,6 +2295,99 @@ describe('auto 步:配方在拍首跑', () => {
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).toBe('Completed'); expect(out.runtime.items.find((i) => i.id === 'S1')?.state).toBe('Completed');
}); });
/*
* **「数据本来就齐」那条路上,人工激活是命门**(2026-08-31 用户定:「就算数据是足的也进入
* 这个数据工单,就派发一个验收工单」)。
*
* 引擎在拍首先扫一遍 doneWhen(`sweepRipe`),为真就自己收步 —— 数据本来就齐的单,那一步的
* doneWhen 第一拍就为真,**配方一笔都还没跑**,步骤当场被收掉,卡永远发不出去。
* 标了人工激活,那道扫描多一个门槛:`worldTouched`(卡真发过)—— 于是次序变成
* 「配方发卡 → 人答 → 下一拍 doneWhen 为真才收」。doneWhen 照旧是这一步的真门,只是不抢在人前面。
*/
const MANUAL_MODEL: CaseModel = {
name: '一步的活动(人工激活)',
autoComplete: false,
items: [{ id: 'S1', name: '补数据', type: 'humanTask', discretionary: false, manualActivation: true, entryCriteria: [], exitCriteria: [] }],
sentries: [],
} as never;
const RIPE_STEP = {
item: 'S1',
guide: '',
writes: [],
reads: [],
tools: [],
doneWhen: 'ready = true',
auto: { do: [{ kind: 'card' as const, as: 'execution' as const, variant: 'accept' as const, title: '验收工单 · 核对', basis: [{ k: '料号', v: 'XL2596S-5.0' }] }] },
};
it('人工激活 + doneWhen 已为真 + 卡还没发过 → 拍首**不收步**,轮到配方把卡发出去', async () => {
const h = harness({ parsed: MANUAL_MODEL, runtime: AUTO_RUNTIME, sfeelCondition: () => true, steps: [RIPE_STEP] });
const out = await h.runner.runTurn(TASK);
expect(h.issued.map((i) => i.card.variant)).toEqual(['accept']);
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).not.toBe('Completed');
expect(h.decided).toHaveLength(0);
});
it('**同一份配方、同一个 doneWhen,没标人工激活就当场被收掉** —— 卡一张都发不出去(病根本身)', async () => {
const h = harness({ parsed: AUTO_MODEL, runtime: AUTO_RUNTIME, sfeelCondition: () => true, steps: [RIPE_STEP] });
const out = await h.runner.runTurn(TASK);
/* 只数执行工单 —— 这一步被收掉之后整单收口,尾巴上那张 acceptance 是放行验收单,与这条无关 */
expect(h.issued.filter((i) => i.workOrder === 'execution')).toHaveLength(0);
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).toBe('Completed');
});
/*
* **人答得比这一拍收尾还快,就别报「等人」**(2026-09-01 实测两次撞停):
* 决策卡的回传**不发事件、只调 `wake`**,而 `wake` 只翻停着的单子 —— 卡刚发出那一刻这一拍
* 还在 running,它空转了一次;随后这一拍把任务 park 回 awaiting,再没有第三样东西会来叫。
* 实测:卡 02:06:13.250 发出、219ms 后被答,这一拍 .636 才收尾,单子停到超时。
*/
it('刚发的卡已经被答了 → 这一拍照实说「还在跑」,推进器下一轮接着处理', async () => {
const h = harness({
parsed: AUTO_MODEL,
runtime: AUTO_RUNTIME,
sfeelCondition: () => false,
/* 发卡时没有待答的卡(所以发得出去);发完再查,那张卡已经不在名单里 = 人在这一拍里答了 */
unanswered: [],
unansweredAfterIssue: [],
steps: [
{
item: 'S1',
guide: '',
writes: [],
reads: [],
tools: [],
auto: { do: [{ kind: 'card', as: 'execution', title: '补数据', basis: [{ k: '料号', v: 'XL2596S-5.0' }] }] },
},
],
});
const out = await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(1);
expect(out.status).toBe('running');
});
it('卡还挂在待答名单里 → 照旧「等人」,不因此空转成热循环', async () => {
const h = harness({
parsed: AUTO_MODEL,
runtime: AUTO_RUNTIME,
sfeelCondition: () => false,
/* 不给 `unansweredAfterIssue`:假端口默认「发出去的卡就在等」—— 这一拍该报等人 */
steps: [
{
item: 'S1',
guide: '',
writes: [],
reads: [],
tools: [],
auto: { do: [{ kind: 'card', as: 'execution', title: '补数据', basis: [{ k: '料号', v: 'XL2596S-5.0' }] }] },
},
],
});
const out = await h.runner.runTurn(TASK);
expect(out.status).toBe('awaiting');
});
it('有卡在等人交回:配方照样写,但一张新卡都不发', async () => { it('有卡在等人交回:配方照样写,但一张新卡都不发', async () => {
const h = harness({ const h = harness({
parsed: AUTO_MODEL, parsed: AUTO_MODEL,
@@ -2440,7 +2575,13 @@ describe('auto 步:配方在拍首跑', () => {
}); });
/* /*
* **Important 2(2026-08-30 全分支终审,先坐实、不修)**:上面那条「卡答过了…→ 收步」 * **Important 2 已修**(2026-08-31 用户实测撞上它:「我重做后怎么直接推进了?不应该是
* 重做刚才那一步吗?」—— 那一单的轨迹逐条对得上这里描述的每一步:审核卡发出 → 人带意见
* 交回 →「这一步已经发过卡,不再发第二张」→「自动收步:S3_review —— 卡已交回,这一步到
* 此为止」→ 下一拍就发出了落账放行的验收卡)。下面这段诊断原样留着 —— 它把病灶说得很准,
* 连「半个修法会把单子卡死」都预判对了;修法见文末。
*
* 原记录:上面那条「卡答过了…→ 收步」
* 只证明了「答过就收」这个结构本身存在,没证明它对**裁决**视而不见 —— 这条把裁决 * 只证明了「答过就收」这个结构本身存在,没证明它对**裁决**视而不见 —— 这条把裁决
* 摆明白:审核卡只有一个选项(`approve`),人按的是「重做」,交的是 * 摆明白:审核卡只有一个选项(`approve`),人按的是「重做」,交的是
* `custom:<理由>`(`review-face.ts` 的 `redoReply`:写了理由且 `allowCustom` 就交 * `custom:<理由>`(`review-face.ts` 的 `redoReply`:写了理由且 `allowCustom` 就交
@@ -2463,42 +2604,73 @@ describe('auto 步:配方在拍首跑', () => {
* `chosenGuidance` 头注里 2026-08-27 W05 实测的原话)在一条新的(fully-auto)代码路径上 * `chosenGuidance` 头注里 2026-08-27 W05 实测的原话)在一条新的(fully-auto)代码路径上
* 重演,而那次的修法结构性地够不到这条路径。 * 重演,而那次的修法结构性地够不到这条路径。
* *
* **建议修法方向**(未实现,留给专门的一次改动):line 2179 那道门至少要能分清 * **修法**(2026-08-31)—— 正是上面预判的那个「重做即重开」闭环,两半缺一不可:
* 「答的是不是 approve 选项」——但这只是半个修法:即便挡住了误收,S3_review 的 * ①收步那道门认答复(`answerSettlesStep`:`custom:` 与约定的 `reject` 都不算办完);
* `card` 动作在 `context.carded === true` 时会**直接跳过、不再发新卡** * ②同一句判断也让 `carded` 让位 —— 幂等挡的是**同一问被重复发出去**,而人点重做正是说
* (`auto-runner.ts` 的 card 分支),重做之后没有任何机制会把 `worldTouched` 重置、 * 「这一轮不算,改了再问我一遍」,那不是重复,是下一轮:配方重跑(该重算的重算)再发一张。
* 也没有任何机制会再发一张新的审核卡 —— 单子会卡死在「答过但没收步」的死胡同, * 只做①就是那个预判过的半修法:挡住误收,单子卡死在「答过但没收步」的死胡同。
* 与 T_release 那条**已经做好**的重做闭环(`raiseAcceptance`,2026-08-22/23:意见落轨迹、
* 在途验收清掉、同一拍照常问模型)不是同一件事、也没有对应机制。真正修好需要给
* 这条 auto-card 路径补一个类似 `raiseAcceptance` 的「重做即重开」闭环,不是一行判断。
*/ */
it('Important 2 坐实:审核卡答的是"重做"(custom:<理由>),不是 approve —— 「卡答过了就收步」照样收步,意见对它不可见', async () => { const REVIEW_STEP = {
const h = harness({
parsed: AUTO_MODEL,
runtime: { items: [{ id: 'S1', state: 'Active', worldTouched: true }] } as never,
unanswered: [],
sfeelCondition: () => false,
// 人真答的是"重做",理由写在 note 里 —— 这条 trail 证明"裁决是拒答"这件事**在系统里查得到**
trail: [{ kind: 'card-reply', title: '结论审核', detail: { value: 'custom:结论算错了,请重算', by: 's-1' } }],
steps: [
{
item: 'S1', item: 'S1',
guide: '', guide: '',
writes: [], writes: [],
reads: [], reads: [],
tools: [], tools: [],
auto: { do: [{ kind: 'card', as: 'decide', variant: 'review', title: '结论审核', options: [{ value: 'approve', label: '确认通过' }], basis: [{ k: 'a', v: 'b' }] }] }, auto: { do: [{ kind: 'card' as const, as: 'decide' as const, variant: 'review' as const, title: '结论审核', options: [{ value: 'approve', label: '确认通过' }], basis: [{ k: 'a', v: 'b' }] }] },
}, };
], const ANSWERED: CaseRuntime = { items: [{ id: 'S1', state: 'Active', worldTouched: true }] } as never;
const replyOf = (value: string) => [{ kind: 'card-reply', title: '结论审核', detail: { value, by: 's-1' } }];
it('审核卡答的是「重做」(custom:<理由>):**这一步不收**,配方重开一轮把卡再发一张', async () => {
const h = harness({
parsed: AUTO_MODEL,
runtime: ANSWERED,
unanswered: [],
sfeelCondition: () => false,
// 人真答的是「重做」,理由带在值里 —— 裁决是拒答这件事**在系统里查得到**
trail: replyOf('custom:结论算错了,请重算'),
steps: [REVIEW_STEP],
});
const out = await h.runner.runTurn(TASK);
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).not.toBe('Completed');
/* 重开一轮 = 真的再发一张(只挡住误收就成了死胡同 —— 上面预判过) */
expect(h.issued.map((i) => i.workOrder)).toEqual(['decision']);
/* 打回这件事要在日志里说出来:人回头得看得见「它听见了」 */
expect(h.appended.some((a) => a.title.includes('打回重做'))).toBe(true);
});
/*
* **重开那一半发不出卡时,收步这道门也得认答复** —— 两处各管一段:配方能重发时(上一条)
* 这道门根本走不到;发不出来时(卡的 `when` 不成立 / 调人预算见底)才轮到它。
* 少了它就是:人说了重做,卡没发出来,这一步照样被判「办完」,单子往前推 —— 与病灶同款。
*/
it('人说重做、而配方这一拍发不出新卡:也不许收步(那时该交人看一眼,不是替他签字)', async () => {
const h = harness({
parsed: AUTO_MODEL,
runtime: ANSWERED,
unanswered: [],
sfeelCondition: () => false,
trail: replyOf('custom:结论算错了,请重算'),
/* 卡这一笔带 when,而 sfeelCondition 恒假 —— 这一拍发不出卡 */
steps: [{ ...REVIEW_STEP, auto: { do: [{ ...REVIEW_STEP.auto.do[0]!, when: 'never = 1' }] } }],
}); });
const out = await h.runner.runTurn(TASK); const out = await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(0); expect(h.issued).toHaveLength(0);
/* expect(out.runtime.items.find((i) => i.id === 'S1')?.state).not.toBe('Completed');
* **这就是 Important 2 的病灶**:trail 里明明白白记着人写的是"结论算错了,请重算" });
* (一次拒答),这一步却仍然被判 Completed —— 与「人按了确认通过」会得到的结果
* 完全相同。`out.runtime` 对这条 trail 里的裁决视而不见。 it('审核卡答的是「确认通过」:照旧收步、不再发第二张(这条近道本来就是为它设的)', async () => {
*/ const h = harness({
parsed: AUTO_MODEL,
runtime: ANSWERED,
unanswered: [],
sfeelCondition: () => false,
trail: replyOf('approve'),
steps: [REVIEW_STEP],
});
const out = await h.runner.runTurn(TASK);
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).toBe('Completed'); expect(out.runtime.items.find((i) => i.id === 'S1')?.state).toBe('Completed');
expect(h.issued).toHaveLength(0);
}); });
/* /*
@@ -43,6 +43,16 @@ export type AutoContext = {
readonly quiet: boolean; readonly quiet: boolean;
/** 这一步已经发过卡(worldTouched) */ /** 这一步已经发过卡(worldTouched) */
readonly carded: boolean; 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 = { export type AutoOutcome = {
@@ -98,7 +108,9 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
async run(recipe, ctx) { async run(recipe, ctx) {
const writable = await deps.data.writable(ctx.taskId); const writable = await deps.data.writable(ctx.taskId);
const results: Record<string, unknown[]> = {}; const results: Record<string, unknown[]> = {};
let scope: Record<string, unknown> = { ...(await deps.data.view(ctx.taskId)), ...results }; /* 人打回时写的那句话:没有就是空串(见 `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 dirty = false;
let wrote = 0; let wrote = 0;
let dropped = 0; let dropped = 0;
@@ -125,12 +137,24 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
* 而 `result` 不在 `task-log.ts` 的 `SKELETON` 里,这一笔永远进不了工作日志 * 而 `result` 不在 `task-log.ts` 的 `SKELETON` 里,这一笔永远进不了工作日志
* (spec §5.9 承诺「配方每一笔照常进 task_memory,工作日志里归系统灰」落空)。 * (spec §5.9 承诺「配方每一笔照常进 task_memory,工作日志里归系统灰」落空)。
* `observation` 在骨架里;不带 `detail.say` 时 `actorOf` 本就归系统。 * `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({ await deps.memory.append({
taskId: context.taskId, taskId: context.taskId,
kind: 'observation', kind: 'observation',
title: evalText(action.text, base), title: evalText(action.text, base),
detail: { auto: `${context.stepId}#${index + 1}` }, detail: context.quiet
? { auto: `${context.stepId}#${index + 1}`, internal: true }
: { auto: `${context.stepId}#${index + 1}` },
}); });
return { wrote: 0, dropped: 0 }; return { wrote: 0, dropped: 0 };
} }
@@ -196,6 +220,8 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
kept.add(keyText(key)); kept.add(keyText(key));
} }
let localDropped = 0; let localDropped = 0;
/** 已随「数据工单确认」落地、prune 不动它的行数 —— 跳过要说给人听(debt 那条的原话要求) */
let localLandedKept = 0;
/* /*
* **人正在件上编辑的时候不 prune**(2026-08-29 终审 F3)。 * **人正在件上编辑的时候不 prune**(2026-08-29 终审 F3)。
* `prune` 的语义是「这一笔投影出来的行就是这张表的全部,多出来的撤掉」—— * `prune` 的语义是「这一笔投影出来的行就是这张表的全部,多出来的撤掉」——
@@ -204,19 +230,30 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
* (2026-08-02 为人工撤行立过「撤要说出来、要点名哪几行」的规矩,这里连说都没说)。 * (2026-08-02 为人工撤行立过「撤要说出来、要点名哪几行」的规矩,这里连说都没说)。
* 有卡在等人(quiet)= 人正对着这张件填,这一拍只铺不撤;等他交回之后那一拍再对齐。 * 有卡在等人(quiet)= 人正对着这张件填,这一拍只铺不撤;等他交回之后那一拍再对齐。
*/ */
if (action.prune === true && !context.quiet) { /* true:非 quiet 拍才撤(F3,人正编辑不动他的行);'live':quiet 也撤 ——
只给人不会在件上加行的表用(契约注释与静态审都写着),跟手比守候更诚实 */
if (action.prune === 'live' || (action.prune === true && !context.quiet)) {
for (const stale of before) { for (const stale of before) {
const key: Record<string, string> = {}; const key: Record<string, string> = {};
for (const column of decl.keys) key[column] = String(stale[column] ?? ''); for (const column of decl.keys) key[column] = String(stale[column] ?? '');
if (kept.has(keyText(key))) continue; if (kept.has(keyText(key))) continue;
const { dropped: gone } = await deps.data.draftDrop({ taskId: context.taskId, domain: decl.domain, object: decl.object, key }); /*
* `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) { if (gone) {
localDropped += 1; localDropped += 1;
dropNames.push(rowName(key, decl.caseKey)); dropNames.push(rowName(key, decl.caseKey));
} else if (landed === true) {
localLandedKept += 1;
} }
} }
} }
if (localWrote > 0 || localDropped > 0 || localExisting > 0 || localUnkeyed > 0) { if (localWrote > 0 || localDropped > 0 || localExisting > 0 || localUnkeyed > 0 || localLandedKept > 0) {
/* /*
* **kind: 'observation' + `internal: true`**(2026-08-29 终审 I5,控制端裁定 F2)。 * **kind: 'observation' + `internal: true`**(2026-08-29 终审 I5,控制端裁定 F2)。
* `kind` 必须是 `observation`:`result` 不在 `task-log.ts` 的 `SKELETON` 里,进不了工作日志(R10)。 * `kind` 必须是 `observation`:`result` 不在 `task-log.ts` 的 `SKELETON` 里,进不了工作日志(R10)。
@@ -241,7 +278,9 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
/* 撤了要点名(M2):不点名的话,下一拍的读者(人或模型)对不上是哪几行没了 */ /* 撤了要点名(M2):不点名的话,下一拍的读者(人或模型)对不上是哪几行没了 */
(localDropped === 0 ? '' : `,撤掉 ${localDropped} 行(${nameList(dropNames)})`) + (localDropped === 0 ? '' : `,撤掉 ${localDropped} 行(${nameList(dropNames)})`) +
/* 少写了几行要出声(M1):静默 continue 的那几行,表里少了谁也查不出为什么 */ /* 少写了几行要出声(M1):静默 continue 的那几行,表里少了谁也查不出为什么 */
(localUnkeyed === 0 ? '' : `,${localUnkeyed} 行因主键算不出来没写(主键列:${decl.keys.join('、')})`), (localUnkeyed === 0 ? '' : `,${localUnkeyed} 行因主键算不出来没写(主键列:${decl.keys.join('、')})`) +
/* 已落地的陈旧行跳过也要出声 —— 「这几行已经入库、这一笔没法帮你清」的实情说给人听 */
(localLandedKept === 0 ? '' : `,${localLandedKept} 行已随数据工单入库、这一笔不清它`),
detail: { detail: {
auto: `${context.stepId}#${index + 1}`, auto: `${context.stepId}#${index + 1}`,
ledger: action.into, ledger: action.into,
@@ -340,10 +379,14 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
basis, basis,
/* 零输入:填报全在工作区的件上(2026-08-02 用户定) */ /* 零输入:填报全在工作区的件上(2026-08-02 用户定) */
fields: [], fields: [],
...(action.variant === 'data' ? { variant: 'data' } : {}), /* 执行工单认两副变体面孔:`data` 数据工单 / `accept` 验收工单;`review` 是决策卡那边的,按值窄化别串台 */
...(action.variant === 'data' || action.variant === 'accept' ? { variant: action.variant } : {}),
...(action.guide === undefined ? {} : { steps: [...action.guide] }), ...(action.guide === undefined ? {} : { steps: [...action.guide] }),
...(action.standard === undefined ? {} : { standard: evalText(action.standard, base) }), ...(action.standard === undefined ? {} : { standard: evalText(action.standard, base) }),
...(action.gateLedgers === undefined ? {} : { gateLedgers: [...action.gateLedgers] }), ...(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 }; return { wrote: 0, dropped: 0, cardId: id, stop: true };
@@ -360,7 +403,7 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
for (const [index, action] of recipe.do.entries()) { for (const [index, action] of recipe.do.entries()) {
try { try {
if (dirty) { if (dirty) {
scope = { ...(await deps.data.view(ctx.taskId)), ...results }; scope = { ...(await deps.data.view(ctx.taskId)), ...redoNote, ...results };
dirty = false; dirty = false;
} }
/* /*
@@ -384,7 +427,11 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
} }
const base: ExprCtx = { scope, lets, condition }; const base: ExprCtx = { scope, lets, condition };
if (action.when !== undefined && !condition(action.when, { ...scope, ...lets })) continue; if (action.when !== undefined && !condition(action.when, { ...scope, ...lets })) continue;
/* 有卡在等人交回:只算、只写 —— 不发新卡、不 submit、不 analyze */ /*
* 有卡在等人交回:只算、只写 —— 不发新卡、不 submit、不 analyze。
* `say` 照跑但**落成 internal**(见 runAction 里 say 那一段):话要留痕,
* 只是不摆到对话区上去 —— 人正在填,这一趟没有新一轮可言。
*/
if (ctx.quiet && action.kind !== 'project' && action.kind !== 'say') continue; if (ctx.quiet && action.kind !== 'project' && action.kind !== 'say') continue;
const done = await runAction(action, index, base, ctx); const done = await runAction(action, index, base, ctx);
@@ -324,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') {
@@ -335,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)}`;
} }
/** /**
@@ -2048,13 +2073,37 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
/* 配方**只认 `liveStep`** —— 见上面 `current` 兜底那条注释(I6) */ /* 配方**只认 `liveStep`** —— 见上面 `current` 兜底那条注释(I6) */
const current = liveStep; const current = liveStep;
const recipe = liveStep.auto; 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, { const auto = await autoRunner.run(recipe, {
taskId, taskId,
stepId: current.item, stepId: current.item,
ownerId: (await deps.tasks.of(taskId)).ownerId, ownerId: (await deps.tasks.of(taskId)).ownerId,
humanCallCap: (await deps.tasks.of(taskId)).humanCallCap, humanCallCap: (await deps.tasks.of(taskId)).humanCallCap,
quiet: false, quiet: false,
carded: stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true, 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) { if (auto.failedAt !== undefined) {
/* /*
@@ -2222,7 +2271,29 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
if (current.view !== undefined && current.view !== '') { if (current.view !== undefined && current.view !== '') {
await showView(taskId, current.view, touched, current.view); await showView(taskId, current.view, touched, current.view);
} }
return { runtime: touched, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' }; /*
* **人答得比这一拍收尾还快,就别报「等人」**(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) { if (auto.wrote > 0 || auto.dropped > 0) {
/* 写过东西:重新求一遍 doneWhen —— 这一步可能已经办完了 */ /* 写过东西:重新求一遍 doneWhen —— 这一步可能已经办完了 */
@@ -2248,7 +2319,14 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
*/ */
const noDoneWhen = current.doneWhen === undefined || current.doneWhen.trim() === ''; const noDoneWhen = current.doneWhen === undefined || current.doneWhen.trim() === '';
const carded = stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true; const carded = stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true;
if (noDoneWhen && carded && recipeIssuesCard(recipe) && current.guide.trim() === '') { /*
* **人签了字才叫办完 —— 他说重做就不是**(2026-08-31,见 `answerSettlesStep` 的头注:
* 打回与通过从前在这儿一模一样,于是「重做」当场变成了「推进」)。
* 走到这儿说明配方这一拍没能重新发出卡(条件不成立 / 预算见底)——
* 那就停在这一步不收,下面那条「配方跑完了,可这一步还没算办完」把话说给人听,
* 而不是替他把字签了。
*/
if (noDoneWhen && carded && settled && recipeIssuesCard(recipe) && current.guide.trim() === '') {
const active = readyToWork(parsed, stateRuntime, current.item); const active = readyToWork(parsed, stateRuntime, current.item);
const settled = transitionItem(active, current.item, 'Completed'); const settled = transitionItem(active, current.item, 'Completed');
await deps.memory.append({ await deps.memory.append({
+13 -1
View File
@@ -92,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 }>;
}; };
/** /**
+2
View File
@@ -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 已填好、可编辑」改的就是它们。
* *
+2
View File
@@ -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 对条件展开
+2 -1
View File
@@ -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([]);
});
});
});
@@ -0,0 +1,194 @@
/**
* 启动参数里**格与格之间**的那些事(2026-08-31 用户定)—— 纯函数,不碰 React。两件,别混:
* · **按分组联动**(`linkGroup`):按候选的分组键互相收窄 —— 引导,填了别的照收;
* · **互斥**(`distinctGroup`):同组的几格不能是同一个值 —— **墙**,别的格占了的候选这里不列,
* 自己写一个同样的值就按不下「启动活动」(真正拦住它的是服务端 `validateLaunchParams`)。
*
* 服务端下发的两样东西就是这里的全部依据:候选各自带 `group` / `groupLabel`(清单 `x-options.group`),
* 参数带 `linkGroup`(清单 `x-link`)。前端**不认识**「原内部料号」「芯片类型」这些名字 ——
* 换个活动声明另一组联动,这段代码一个字不用改。
*
* 三条口径(用户当场定):
* ①**组内第一个填上值的那格是锚**,锚自己永远摆全量候选,其余格按锚的分组键收窄;
* 锚**粘着**:把它清空只是解除收窄,**不把锚交给还填着值的另一格** ——
* 否则「清空锚、改个类型重填」当场被另一格锁住,人就改不了主意了(这一条是这次设计的要害)。
* 整组都空了才重新认锚。
* ②锚没有分组键(未标类型的老料)、或锚填的是库里没有的新值(新增料号)→ 不收窄。
* ③收窄之后,「未标类型」的候选**列在末尾单独一组**,不是不列。
*
* **联动**这一件服务端不立墙(`launch-params.ts` 头注):两格填不同类型照样收得下,类型一致归活动自己的收步判据管;
* **互斥**那一件服务端真拦(同一把尺 `launchDistinctKey`)—— 这里少列几条只是不让人走到那一步。
*/
import type { LaunchOption, LaunchParam } from '@contracts';
import { launchDistinctKey } from '@contracts';
/** 组名 → 当下坐着锚位的那格字段名 */
export type LinkAnchors = Readonly<Record<string, string>>;
/** 一组候选(`label` 为空 = 这批候选没有分组键,摆一列不分组) */
export type OptionGroup = { readonly label: string | undefined; readonly options: readonly LaunchOption[] };
/** 这一格该摆什么:分好组的候选 + 被谁按什么类型收窄(没收窄就没有)+ 被别的格占走、这里不列的那几个 */
export type ComboFace = {
readonly groups: readonly OptionGroup[];
readonly narrow: { readonly by: string; readonly label: string } | undefined;
readonly taken: readonly { readonly by: string; readonly value: string }[];
};
const UNGROUPED = '未标类型';
const valueOf = (values: Readonly<Record<string, string | readonly string[]>>, key: string): string => {
const v = values[key];
return typeof v === 'string' ? v.trim() : '';
};
/** 同一联动组的几格(按表单次序);只认真有候选的那些 —— 没有候选就没有可收窄的东西 */
function membersOf(params: readonly LaunchParam[], group: string): readonly LaunchParam[] {
return params.filter((p) => p.linkGroup === group && (p.options ?? []).length > 0);
}
/** 锚这一格填的值落在哪个分组键上;`undefined` = 收窄无从谈起(没填 / 填的是库里没有的新值) */
function anchorGroup(anchor: LaunchParam, value: string): { readonly key: string; readonly label: string } | undefined {
if (value === '') return undefined;
const hit = (anchor.options ?? []).find((o) => o.value === value);
// 值不在候选里 = 人自己新增的料号,它没有类型可言
if (hit === undefined || hit.group === undefined || hit.group === '') return undefined;
return { key: hit.group, label: hit.groupLabel ?? hit.group };
}
/**
* 值变过之后这一组的锚是谁。锚粘着:占了位就不让,直到组内所有格都空 —— 见头注①。
* 只动传进来的那份,原样返回也是合法结果(调用方按引用比较可省一次渲染)。
*/
export function nextAnchors(params: readonly LaunchParam[], values: Readonly<Record<string, string | readonly string[]>>, anchors: LinkAnchors): LinkAnchors {
const groups = new Set(params.map((p) => p.linkGroup).filter((g): g is string => g !== undefined));
let out = anchors;
const set = (group: string, key: string | undefined) => {
if (out === anchors) out = { ...anchors };
const next = { ...out };
if (key === undefined) delete next[group];
else next[group] = key;
out = next;
};
for (const group of groups) {
const members = membersOf(params, group);
const filled = members.filter((p) => valueOf(values, p.key) !== '');
const seated = anchors[group];
if (filled.length === 0) {
// 整组都空了 —— 锚位空出来,下一个填上值的那格重新认
if (seated !== undefined) set(group, undefined);
continue;
}
// 锚位还坐着人(哪怕它此刻是空的)就不动;锚被别的活动版本改没了才重新认
if (seated !== undefined && members.some((p) => p.key === seated)) continue;
set(group, filled[0]?.key);
}
return out;
}
/** 一批候选按分组键分组:有键的按首次出现的次序,**没有键的殿后**(用户定) */
function groupOptions(options: readonly LaunchOption[]): readonly OptionGroup[] {
if (!options.some((o) => o.group !== undefined)) return [{ label: undefined, options }];
const named = new Map<string, { label: string; options: LaunchOption[] }>();
const bare: LaunchOption[] = [];
for (const o of options) {
const key = o.group ?? '';
if (key === '') {
bare.push(o);
continue;
}
const hit = named.get(key);
if (hit === undefined) named.set(key, { label: o.groupLabel ?? key, options: [o] });
else hit.options.push(o);
}
const out: OptionGroup[] = [...named.values()].map((g) => ({ label: g.label, options: g.options }));
if (bare.length > 0) out.push({ label: UNGROUPED, options: bare });
return out;
}
/** 同一互斥组里**别的格**此刻占着的值(空的不算);自己那格不算自己占的 */
function takenByOthers(
param: LaunchParam,
params: readonly LaunchParam[],
values: Readonly<Record<string, string | readonly string[]>>,
): readonly { readonly by: string; readonly value: string }[] {
const group = param.distinctGroup;
if (group === undefined) return [];
const out: { by: string; value: string }[] = [];
for (const other of params) {
if (other.key === param.key || other.distinctGroup !== group) continue;
const value = valueOf(values, other.key);
if (value !== '') out.push({ by: other.label, value });
}
return out;
}
/** 这一格摆哪些候选、被谁收窄 —— 锚自己与不联动的格子都摆全量;别的格占走的一律不列 */
export function comboFace(
param: LaunchParam,
params: readonly LaunchParam[],
values: Readonly<Record<string, string | readonly string[]>>,
anchors: LinkAnchors,
): ComboFace {
const taken = takenByOthers(param, params, values);
const mine = launchDistinctKey(valueOf(values, param.key));
const takenKeys = new Set(taken.map((t) => launchDistinctKey(t.value)));
/*
* 互斥:别的格占了的候选这里不列 —— **唯独自己此刻填着的那个永远留着**。
* 抽掉正在显示的值,`<select>` 当场变空白,人看着像被谁清空了;真撞上了该说的是那句话
* (`distinctIssues` → 小红字 + 按不下启动),不是让画面自己变魔术。
*/
const all = (param.options ?? []).filter((o) => launchDistinctKey(o.value) === mine || !takenKeys.has(launchDistinctKey(o.value)));
const face = (options: readonly LaunchOption[], narrow: ComboFace['narrow']): ComboFace => ({ groups: groupOptions(options), narrow, taken });
const group = param.linkGroup;
const seated = group === undefined ? undefined : anchors[group];
if (group === undefined || seated === undefined || seated === param.key) return face(all, undefined);
const anchor = params.find((p) => p.key === seated);
const by = anchor === undefined ? undefined : anchorGroup(anchor, valueOf(values, seated));
if (anchor === undefined || by === undefined) return face(all, undefined);
// 收窄:同分组键的 + 未标类型的(殿后)—— 「新增」那条路由控件自己一直留着
return face(all.filter((o) => o.group === by.key || o.group === undefined || o.group === ''), { by: anchor.label, label: by.label });
}
/**
* 撞了同一个值的那几格 —— 点名**后填的那格**(表单次序在后的),`by` 是它撞上了谁。
* 与服务端 `distinctIssuesOf` 同一条规矩、同一把尺(`launchDistinctKey`):这里管「按不下启动」,
* 那里管「拦住」—— 两道都要,既不让人填完一屏才被打回来,也不指望前端自觉。
*
* 下拉里已经不列别人占的值了,还会撞上的只剩两条路:自己「新增」写了一个同样的、或者这格压根是自由输入。
*/
export function distinctIssues(
params: readonly LaunchParam[],
values: Readonly<Record<string, string | readonly string[]>>,
): readonly { readonly key: string; readonly by: string }[] {
const out: { key: string; by: string }[] = [];
const seenPerGroup = new Map<string, Map<string, LaunchParam>>();
for (const param of params) {
const group = param.distinctGroup;
if (group === undefined) continue;
const key = launchDistinctKey(valueOf(values, param.key));
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 out.push({ key: param.key, by: first.label });
}
return out;
}
/**
* 锚换了类型之后,组内哪几格的值**失配**:那个值在全量候选里、却不在收窄后的候选里。
* 人自己新增的值(压根不在候选里)不动 —— 它不是一次失配的旧选择,是一颗新料号。
*/
export function staleLinked(params: readonly LaunchParam[], values: Readonly<Record<string, string | readonly string[]>>, anchors: LinkAnchors): readonly string[] {
const out: string[] = [];
for (const param of params) {
const value = valueOf(values, param.key);
if (value === '' || param.linkGroup === undefined) continue;
const face = comboFace(param, params, values, anchors);
if (face.narrow === undefined) continue;
const known = (param.options ?? []).some((o) => o.value === value);
const kept = face.groups.some((g) => g.options.some((o) => o.value === value));
if (known && !kept) out.push(param.key);
}
return out;
}
@@ -21,6 +21,7 @@ import {
authTicketRoute, authTicketRoute,
controlTaskRoute, controlTaskRoute,
disableOpsActivityRoute, disableOpsActivityRoute,
launchDistinctMessage,
launchOpsInstanceRoute, launchOpsInstanceRoute,
meRoute, meRoute,
reassignTaskRoute, reassignTaskRoute,
@@ -52,6 +53,8 @@ import {
useOpsLaunchParams, useOpsLaunchParams,
} from '@web-shared/gov/gov-api.ts'; } from '@web-shared/gov/gov-api.ts';
import type { OpsInstance, OpsInstanceQuery } from '@web-shared/gov/gov-api.ts'; import type { OpsInstance, OpsInstanceQuery } from '@web-shared/gov/gov-api.ts';
import { comboFace, distinctIssues, nextAnchors, staleLinked } from './launch-link.ts';
import type { ComboFace, LinkAnchors } from './launch-link.ts';
export type HomeProps = { tab: string; nav: Nav }; export type HomeProps = { tab: string; nav: Nav };
@@ -305,8 +308,11 @@ const COMBO_CREATE = '__create__';
* 选择+新增(2026-08-27 用户定):先摆选择框,「新增 …」是候选**第一项**(2026-08-28 用户定,从末项提上来); * 选择+新增(2026-08-27 用户定):先摆选择框,「新增 …」是候选**第一项**(2026-08-28 用户定,从末项提上来);
* 选了它就换成输入框自己填(库里没有的值也收 —— 不拦启动,缺口由服务端派数据工单), * 选了它就换成输入框自己填(库里没有的值也收 —— 不拦启动,缺口由服务端派数据工单),
* 旁边留「返回选择」换回去。 * 旁边留「返回选择」换回去。
*
* 2026-08-31 加联动:候选摆什么由 `face` 说了算(`internal/launch-link.ts` 按服务端下发的分组键算好),
* 这里只管渲染 —— 分了组就出 `optgroup`,「新增」那条路**收窄与否都留着**(用户原话)。
*/ */
function ComboInput({ p, value, onChange }: { p: LaunchParam; value: string; onChange: (v: string) => void }) { function ComboInput({ p, value, face, onChange }: { p: LaunchParam; value: string; face: ComboFace; onChange: (v: string) => void }) {
const [custom, setCustom] = useState(false); const [custom, setCustom] = useState(false);
if (custom) { if (custom) {
return ( return (
@@ -340,11 +346,23 @@ function ComboInput({ p, value, onChange }: { p: LaunchParam; value: string; onC
> >
<option value="">请选择…</option> <option value="">请选择…</option>
<option value={COMBO_CREATE}>+ 新增{p.label}…</option> <option value={COMBO_CREATE}>+ 新增{p.label}…</option>
{(p.options ?? []).map((o) => ( {face.groups.map((g, i) =>
g.label === undefined ? (
g.options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))
) : (
<optgroup key={`${g.label}-${i}`} label={g.label}>
{g.options.map((o) => (
<option key={o.value} value={o.value}> <option key={o.value} value={o.value}>
{o.label} {o.label}
</option> </option>
))} ))}
</optgroup>
),
)}
</select> </select>
); );
} }
@@ -364,9 +382,18 @@ function LaunchDrawer({ activityId, name, onCancel, onLaunch }: { activityId: st
const specQ = useOpsLaunchParams(activityId, true); const specQ = useOpsLaunchParams(activityId, true);
const spec = specQ.data?.params ?? []; const spec = specQ.data?.params ?? [];
const [values, setValues] = useState<LaunchValues>({}); const [values, setValues] = useState<LaunchValues>({});
/* 联动(2026-08-31 用户定):组名 → 坐着锚位的那格;锚自己摆全量,其余格按它的分组键收窄。
`linkNote` 是「原来选的那个与新类型不同类,已清空」这句话 —— 人一动那一格就摘掉 */
const [anchors, setAnchors] = useState<LinkAnchors>({});
const [linkNote, setLinkNote] = useState<Record<string, string>>({});
/* 参数表回来了才铺初始值(含服务端 default 预填)—— 抽屉先开、表后到 */ /* 参数表回来了才铺初始值(含服务端 default 预填)—— 抽屉先开、表后到 */
useEffect(() => { useEffect(() => {
if (specQ.data !== undefined) setValues(initLaunchValues(specQ.data.params)); if (specQ.data === undefined) return;
const init = initLaunchValues(specQ.data.params);
setValues(init);
/* 服务端 default 预填的值也算「填上了」—— 它就是这一组的锚 */
setAnchors(nextAnchors(specQ.data.params, init, {}));
setLinkNote({});
}, [specQ.data]); }, [specQ.data]);
/* 幂等键:每开一次抽屉(每次挂载)一枚 */ /* 幂等键:每开一次抽屉(每次挂载)一枚 */
const [idem] = useState(() => crypto.randomUUID()); const [idem] = useState(() => crypto.randomUUID());
@@ -396,7 +423,25 @@ function LaunchDrawer({ activityId, name, onCancel, onLaunch }: { activityId: st
}, [onCancel]); }, [onCancel]);
const setVal = (key: string, v: string | string[]) => { const setVal = (key: string, v: string | string[]) => {
setValues((cur) => ({ ...cur, [key]: v })); /* 联动三步(2026-08-31):重认锚 → 按锚收窄 → 组内失配的值清空并说一句。
失配 = 那个值在全量候选里、却不在收窄后的候选里;人自己新增的值不动(它压根不在候选里) */
const merged: LaunchValues = { ...values, [key]: v };
const nextA = nextAnchors(spec, merged, anchors);
const notes: Record<string, string> = {};
for (const stale of staleLinked(spec, merged, nextA)) {
const was = merged[stale];
merged[stale] = '';
const kind = comboFace(spec.find((x) => x.key === stale) ?? { key: stale, label: stale, type: 'combo' }, spec, merged, nextA).narrow?.label;
notes[stale] = kind === undefined ? `原选的 ${String(was)} 与新的选择不同类,已清空` : `原选的 ${String(was)} 与「${kind}」不同类,已清空`;
}
setValues(merged);
if (nextA !== anchors) setAnchors(nextA);
setLinkNote((cur) => {
const next = { ...cur, ...notes };
/* 人一动这一格,它自己那句「已清空」就摘掉 —— 说的是上一次的事 */
delete next[key];
return next;
});
/* 改过的格子把它的小红字摘掉 —— 错留在屏上而人已经改了,是另一种撒谎 */ /* 改过的格子把它的小红字摘掉 —— 错留在屏上而人已经改了,是另一种撒谎 */
setFieldErr((cur) => { setFieldErr((cur) => {
if (cur[key] === undefined) return cur; if (cur[key] === undefined) return cur;
@@ -405,7 +450,14 @@ function LaunchDrawer({ activityId, name, onCancel, onLaunch }: { activityId: st
return next; return next;
}); });
}; };
const invalid = specQ.data === undefined || ownerId === '' || spec.some((p) => p.required === true && !paramFilled(p, values[p.key] ?? (p.type === 'list' ? [] : ''))); /* 互斥(2026-08-31 用户定:「不应该能选到同样的两个芯片」):同组两格填了同一个值 —— 点名后填的那格,启动按不下去。
下拉里已经不列别人占的值,还能撞上的只剩「新增」自己写的那条路;真正拦住它的是服务端(脚本 / API 一样过不去) */
const dupeOf: Readonly<Record<string, string>> = Object.fromEntries(distinctIssues(spec, values).map((d) => [d.key, d.by]));
const invalid =
specQ.data === undefined ||
ownerId === '' ||
Object.keys(dupeOf).length > 0 ||
spec.some((p) => p.required === true && !paramFilled(p, values[p.key] ?? (p.type === 'list' ? [] : '')));
const launch = useMutation({ const launch = useMutation({
mutationFn: () => { mutationFn: () => {
@@ -485,7 +537,10 @@ function LaunchDrawer({ activityId, name, onCancel, onLaunch }: { activityId: st
</button> </button>
</Note> </Note>
)} )}
{spec.map((p) => ( {spec.map((p) => {
/* combo 的面孔(候选摆什么 · 被谁按类型收窄 · 别的格占走了哪几个)一格算一次 —— 下面几处都读它 */
const face = p.type === 'combo' ? comboFace(p, spec, values, anchors) : undefined;
return (
<label key={p.key} className="g-fld"> <label key={p.key} className="g-fld">
<span> <span>
{p.label} {p.label}
@@ -509,7 +564,7 @@ function LaunchDrawer({ activityId, name, onCancel, onLaunch }: { activityId: st
))} ))}
</select> </select>
)} )}
{p.type === 'combo' && <ComboInput p={p} value={(values[p.key] as string) ?? ''} onChange={(v) => setVal(p.key, v)} />} {p.type === 'combo' && face !== undefined && <ComboInput p={p} value={(values[p.key] as string) ?? ''} face={face} onChange={(v) => setVal(p.key, v)} />}
{p.type === 'list' && ( {p.type === 'list' && (
<span className="g-lplist"> <span className="g-lplist">
{((values[p.key] as string[]) ?? ['']).map((v, i, arr) => ( {((values[p.key] as string[]) ?? ['']).map((v, i, arr) => (
@@ -537,9 +592,22 @@ function LaunchDrawer({ activityId, name, onCancel, onLaunch }: { activityId: st
</span> </span>
)} )}
{p.desc !== undefined && <small>{p.desc}</small>} {p.desc !== undefined && <small>{p.desc}</small>}
{/* 联动(2026-08-31):被收窄的那格要说清是按谁的什么收的,以及怎么换 —— 悄悄少几条候选比不收窄更糟 */}
{face?.narrow !== undefined && (
<small>
已按「{face.narrow.by}」收窄:只列同为「{face.narrow.label}」的候选(库里没标的在末尾一组),要别的就回去改「{face.narrow.by}」,或在这里新增{p.label}。
</small>
)}
{/* 互斥(2026-08-31 用户定):别的格占了的候选这里不列 —— 少了几条同样要说出来,同一条道理 */}
{face !== undefined && face.taken.length > 0 && (
<small>{face.taken.map((t) => `「${t.by}」已经选了 ${t.value}`).join('、')} —— 这一格不再列它,两格不能是同一个。</small>
)}
{dupeOf[p.key] !== undefined && <small className="warn">{launchDistinctMessage(dupeOf[p.key] ?? '')}</small>}
{linkNote[p.key] !== undefined && <small className="warn">{linkNote[p.key]}</small>}
{fieldErr[p.key] !== undefined && <small className="warn">{fieldErr[p.key]}</small>} {fieldErr[p.key] !== undefined && <small className="warn">{fieldErr[p.key]}</small>}
</label> </label>
))} );
})}
{err !== undefined && <Note tone="warn">{err}</Note>} {err !== undefined && <Note tone="warn">{err}</Note>}
</section> </section>
</div> </div>
+19 -3
View File
@@ -385,7 +385,9 @@ window.addEventListener('message', (e) => {
"required": true, "required": true,
"desc": "差异评估基准;库里没有该 PN 也可启动,启动后派数据工单补参数。", "desc": "差异评估基准;库里没有该 PN 也可启动,启动后派数据工单补参数。",
"placeholder": "LM2596S-5.0", "placeholder": "LM2596S-5.0",
"options": [{ "value": "LM2596S-5.0", "label": "LM2596S-5.0" }] "linkGroup": "chip-type",
"distinctGroup": "part",
"options": [{ "value": "LM2596S-5.0", "label": "LM2596S-5.0", "group": "1", "groupLabel": "DC-DC 降压稳压器" }]
}, },
{ {
"key": "candidateInternalPns", "key": "candidateInternalPns",
@@ -394,7 +396,13 @@ window.addEventListener('message', (e) => {
"required": true, "required": true,
"desc": "被评估物料;库里有就直接选,没有就自己填料号,启动后经数据工单创建。", "desc": "被评估物料;库里有就直接选,没有就自己填料号,启动后经数据工单创建。",
"placeholder": "XL2596S-5.0", "placeholder": "XL2596S-5.0",
"options": [{ "value": "LM2596S-5.0", "label": "LM2596S-5.0" }, { "value": "LM2596S-ADJ", "label": "LM2596S-ADJ" }] "linkGroup": "chip-type",
"distinctGroup": "part",
"options": [
{ "value": "LM2596S-5.0", "label": "LM2596S-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": "" }
]
} }
] ]
} }
@@ -406,7 +414,15 @@ W05 的候选内部料号(2026-08-27 用户定)与原内部料号**同款下拉
候选量大到不宜内联时(如全量物料台账选 PN),后续加 `optionsSource` 引用检索接口;**本期一律内联 `options`**(封顶 500 行)。 候选量大到不宜内联时(如全量物料台账选 PN),后续加 `optionsSource` 引用检索接口;**本期一律内联 `options`**(封顶 500 行)。
> 活动清单侧怎么声明(前端不用管,给写活动包的人看):`properties.<key>` 上 `title`→label、`description`→desc、`examples[0]`→placeholder、`default`、`required[]`;`enum`+`enumNames`→select;`'x-control'` 定标量控件(`text|textarea|select|combo`);`'x-options': { ledger:'域.对象', value:'列', label:'列' }` 让候选从正式台账现读;`type:'array'` 且元素是字符串→list,**数组字段标 `x-control: combo|select` 就摆成单选**(提交单个字符串,后端包成一元数组);**`'x-order': <数字>` 定表单次序**(清单存在 jsonb 里,书写顺序到不了这里);`format:'file'` 的字段本期不上表单;select 没候选降成 text。 **按分组联动(2026-08-31 用户定)**:候选可带 `group`(分组键,如这颗料的芯片类型编号;`""` = 那一列没值,即「未标类型」)与 `groupLabel`(它的人读名,字典读不到就没有这个键);参数可带 `linkGroup`(联动组名)。**同一 `linkGroup` 的几格按 `group` 互相收窄**:组内**第一个填上值的那格是锚**,锚自己永远摆全量候选,其余格只列与锚同 `group` 的候选 + `group` 为空的那些(殿后单独一组)+「新增」这条路;锚清空则整组解除收窄(锚**不让位**给还填着值的另一格 —— 否则「清空锚、改个类型重填」当场被另一格锁住),整组都空了才重新认锚。锚没有 `group`、或锚填的是候选之外的新值 → 不收窄。锚换了类型之后,另一格里**在全量候选里、却不在收窄后候选里**的值由前端清空并说明(人自己新增的值不动 —— 它压根不在候选里)。
> **这是表单的引导,不是墙**:服务端不据 `linkGroup` 校验 —— 两格填不同 `group` 的值照样收得下(combo 本就允许候选之外的值)。类型一致性由活动自己的收步判据管(W05 是 S1 的 `sub_eval_part_distinct_chip_type_id = 1`)。
**互斥(2026-08-31 用户定:「不应该能选到同样的两个芯片」)**:参数可带 `distinctGroup`(互斥组名)。**同一 `distinctGroup` 的几格不能填同一个值** —— 前端把**别的格此刻占着的候选**从这一格的下拉里抽掉(唯独这一格自己填着的那个永远留着,否则 `<select>` 会当场变空白),并在控件下说清是谁占了哪个;人若走「新增」自己写了一个同样的值,小红字出来、「启动活动」按不下去。**「算不算同一个」= 去空白 + 不分大小写**(契约的 `launchDistinctKey`,前后端同一把尺;那句话是 `launchDistinctMessage`)。只管标量格(text / select / combo),数组格不参与。
> **这一条是墙,与 `linkGroup` 正相反**:服务端 `POST …/instances` 独立校验,撞上就是 400 `invalid`、`detail=[{key,message}]` 点名后填的那格 —— 脚本 / API 绕过表单一样过不去。为什么非拦不可:W05 两格填同一颗料会把单子**做死** —— `sub_eval.part` 的主键是 `eval_ref + internal_pn`,配方铸的两行撞成同一个主键、后一笔覆盖前一笔,两行收成一行,S1 的收步判据 `sub_eval_part_exists_status_found = 2` 永不成立,而件上那两行人加不出来,唯一出路是终止重开。**墙立在起单这一刻,是因为路只在这里**(那时换一格就好了)。
> 活动清单侧怎么声明(前端不用管,给写活动包的人看):`properties.<key>` 上 `title`→label、`description`→desc、`examples[0]`→placeholder、`default`、`required[]`;`enum`+`enumNames`→select;`'x-control'` 定标量控件(`text|textarea|select|combo`);`'x-options': { ledger:'域.对象', value:'列', label:'列' }` 让候选从正式台账现读;`'x-options'.group` 让候选带上分组键 —— 只写列名(`'chip_type_id'`)= 有键没名,整份写 `{ column, ledger, value, label }` 再从字典表换人读名;`'x-link': '<组名>'` 把几格结成联动组(同组按分组键互相收窄,见上);`'x-distinct': '<组名>'` 把几格结成互斥组(同组不能填同一个值 —— **这条是墙**,见上);`type:'array'` 且元素是字符串→list,**数组字段标 `x-control: combo|select` 就摆成单选**(提交单个字符串,后端包成一元数组);**`'x-order': <数字>` 定表单次序**(清单存在 jsonb 里,书写顺序到不了这里);`format:'file'` 的字段本期不上表单;select 没候选降成 text。
## 5. 启动活动(创建实例)—— `POST /api/gov/activities/:activityId/instances`(`launchOpsInstanceRoute`) ## 5. 启动活动(创建实例)—— `POST /api/gov/activities/:activityId/instances`(`launchOpsInstanceRoute`)
+100 -3
View File
@@ -1397,8 +1397,9 @@ ACT-WORK-05 恰好撞上这个「恰好」:`engines.ts` 的 `taskData.view()`
- **行级来源标记只有 P-08 有**:P-08 借 `sub_eval.pn_check.exists_status`(found = 在库直读 / created = 本单建档)说了 - **行级来源标记只有 P-08 有**:P-08 借 `sub_eval.pn_check.exists_status`(found = 在库直读 / created = 本单建档)说了
每颗料是不是这一单建的;P-10 / P-06 只按「本单挂的 / 同料号历史」分两个 `<tbody>`,P-10 分不出哪份资料是本单 每颗料是不是这一单建的;P-10 / P-06 只按「本单挂的 / 同料号历史」分两个 `<tbody>`,P-10 分不出哪份资料是本单
数据工单建的。要分,得在放行留痕里记主键名单(`ledger-write` 的 detail 现在只有表与行数)。 数据工单建的。要分,得在放行留痕里记主键名单(`ledger-write` 的 detail 现在只有表与行数)。
- **`vtok` / `badgeCss` 三处各一份**(`report.ts` / `views/badge.ts` / `activity.test.ts`)——都注明「必须与 `classToken` - ~~**`vtok` / `badgeCss` 三处各一份**(`report.ts` / `views/badge.ts` / `activity.test.ts`)~~(2026-08-31 视图层
一字不差」;该抽进 `@sdk` 一处,包内测试改 import。report.ts 当时另一会话在改,没动它。 重设计时收掉一份:report.ts 改 import `views/badge.ts`,包内只剩 badge.ts 与测试镜像两份 —— 测试那份是
「与 classToken 一字不差」的回归网,按测试镜像惯例(view-render-addrow-thead 同款)留着;抽进 `@sdk` 仍可做,不急)。
- **`viewTemplates[].data` 没有装包期静态审**:`from` 指到没声明的表 / 后面的集合、`by.column` 不是那张表的列, - **`viewTemplates[].data` 没有装包期静态审**:`from` 指到没声明的表 / 后面的集合、`by.column` 不是那张表的列,
现在只有 W05 包内测试钉着 + 运行时「那一块空着 + stderr 点名」。该并进 `static-audit`(与 `refersTo` 目标核对同款)。 现在只有 W05 包内测试钉着 + 运行时「那一块空着 + stderr 点名」。该并进 `static-audit`(与 `refersTo` 目标核对同款)。
- **`docs/数据域字段说明-W05.md` §二 / §十二.4 的参数集口径过时**:仍写 `normalized_parameter_set` 是 text 列「不建子表」, - **`docs/数据域字段说明-W05.md` §二 / §十二.4 的参数集口径过时**:仍写 `normalized_parameter_set` 是 text 列「不建子表」,
@@ -1668,7 +1669,14 @@ B 表却**不在**同一份 `only` 名单里——B 表对应的那一行仍留
有),先看确认那张卡的 `gateLedgers`/绑定 `blocks` 是不是只列了「主表」、漏了它 有),先看确认那张卡的 `gateLedgers`/绑定 `blocks` 是不是只列了「主表」、漏了它
`refersTo` 指向的「字典表」。 `refersTo` 指向的「字典表」。
## `prune` 与「数据工单确认即入库」的架构性冲突(2026-08-30,ACT-WORK-05 案例 2 真跑撞见) ## ~~`prune` 与「数据工单确认即入库」的架构性冲突~~(2026-08-30 撞见;2026-08-31 已治,销账)
**治法即下文「要治」的第二条**:`draftDrop` 加配方专用旗 `skipLanded`(loop-engine/port.ts),
`prune` 遇已落地行**跳过并出声**(观察痕「N 行已随数据工单入库、这一笔不清它」),不再抛
`AutoRecipeError`;W05 ②③ 两笔自投影随之恢复 `prune:true` —— 没落地的陈旧选型照撤
(2026-08-31 用户实测点名的「改回同一类型,下面还是两个类型」就此清掉),已落地的原地留下
(它已是世界的一部分,字典行本就该积累)。模型那一路的硬拒一个字不变(拒回是它的行动指南)。
测:auto-runner「prune:已落地的陈旧行跳过不炸」(变异验证过)。原始记录留档如下。
**现象**:数据工单**确认落地之后**再改动会影响行集合的字段(如 W05 的芯片类型), **现象**:数据工单**确认落地之后**再改动会影响行集合的字段(如 W05 的芯片类型),
`project` 动作的 `prune: true` 要把「新计算出的集合里没有」的陈旧行撤掉——而那些行如果 `project` 动作的 `prune: true` 要把「新计算出的集合里没有」的陈旧行撤掉——而那些行如果
@@ -1710,3 +1718,92 @@ B 表却**不在**同一份 `only` 名单里——B 表对应的那一行仍留
**排查法留一条**:「配方办不下去」类的 `AutoRecipeError`,若原文提到「已入库/已落地…撤不掉」, **排查法留一条**:「配方办不下去」类的 `AutoRecipeError`,若原文提到「已入库/已落地…撤不掉」,
先看是不是这条——尤其当那一步的 `project` 动作带 `prune: true`、且它写向的表**也**在 先看是不是这条——尤其当那一步的 `project` 动作带 `prune: true`、且它写向的表**也**在
某张数据工单的 `gateLedgers` 上时,概率最高。 某张数据工单的 `gateLedgers` 上时,概率最高。
---
## ~~人答得太快,那条回传被判 `stale`,单子就此没人叫醒~~(2026-08-31 夜发现 → **2026-09-01 已修**)
**已修**:`task-service.deliver` 判了 stale 之后**照旧不跑这一拍**(stale 的本意不变),
但把停着的单子拨回 `running` 交给推进器 —— 三样凑齐时(卡答完了、没有新卡在等、单子停着)
再没有第三样东西会叫醒它,补的就是最后这一下。真没事可干,它自己再停回去(代价一拍空转)。
`deliver-stale.test.ts` 两条钉死(停着的拨回 running / 本来就在跑的一个字不动),变异验证过。
下面是当时的诊断,原样留着 —— 指纹与病理都对。
**实测指纹**(e2e w05 案例 0,ACT-WORK-05):
```
23:53:39.778 card-sent 逐条差异结论已备好…(S3 审核卡)
23:53:40.048 card-reply 人答了(重做,带意见) ← 卡发出去 270ms 后
23:53:40.274 last_progress_at 被更新 ← **发卡那一拍自己**跑完了
此后 4 分钟一拍没跑,
runtime 停在 awaiting-event
```
后来手工把 `runtime` 拨回 `running`,同一份代码立刻把该走的三拍走完(重做那一轮的新卡也发了)
—— 也就是说**逻辑是对的,只是没有任何东西再叫醒它**。
**病根**:2026-08-27 那条 stale 守卫的前提是
「`lastProgressAt > occurredAt` ⇒ 这条回传一定已被那一拍消费(留痕先于事件落)」。
人答得比发卡那一拍收尾还快时,这个前提就不成立:**把 `lastProgressAt` 往前挪的那一拍,
正是发出这张卡的那一拍** —— 它不可能消费一条当时还不存在的回传。
于是两件事叠在一起把单子锁死:
- 回传路由的 `wake`(2026-08-25)先把任务拨成 `running`,可**发卡那一拍随后收尾时又把它
park 回 `awaiting-event`**(晚到的写覆盖了早到的 wake);
- 人事件随后到达,`deliver` 按 `lastProgressAt > occurredAt` 判 stale,「只记到达痕不推」。
再没有第三样东西会叫醒它。
**为什么少见**:真人点一张卡至少要几秒,发卡那一拍早收尾了;e2e 是 3 秒轮询 + 秒答,
才把这条窗口撞得出来。但 CLAUDE.md 里几次「怎么推不动了」的报告有可能是同一条。
**要治**(方向,未验证):park 那一下要认「这一拍开跑之后有没有人叫醒过我」——
比如 `wake` 记一个时刻/世代号,tick 收尾时的 `update` 带上 `where last_wake_at <= <本拍开跑时刻>`;
或者 tick 收尾前再看一眼「有没有新的未消费回传」,有就留在 `running`。
**别去放宽 stale 守卫本身** —— 它治的是另一个真病(晚到十几秒的事件把没人答的卡收掉,
2026-08-27 实测),两件事不要混。
---
## 审核卡上的「本轮结论」在重判之后会过期(2026-09-01,未修)
**实测**(柴浩那一单 f14d94de):两张审核卡的 `本轮结论` 都写着
「共 7 项核心参数:0 项一致 / 5 项硬约束不符 / 2 项软约束不符」,而第二张发出去的那一刻
草稿里已经是 **1 项一致 / 5 硬 / 1 软**(重判把一条从 soft_mismatch 翻成了 match)。
卡上那一行说的是上一轮的数。
**根因不是 bug,是两条正确规矩撞在一起**:
- 卡的 `本轮结论` 取自 `substitution.relation.difference_summary`;
- 而 S3 用的是 `RELATION_RECOMPUTE`(只重算 `recommendation_level`),**故意不碰**收口四格
(推荐理由 / 差异摘要 / 风险 / 验证要求)—— 2026-08-31 用户定「这四格人可以就地改」,
每跑一次就按规则重写会把人写的话冲掉。
于是:等级会跟着重判变(判据核心那条链没断),**摘要这句话不会**。
**要治**(方向,未验证):
- 卡面那一行改成**按基座读数当场拼**(不读 `difference_summary`)—— 但拼法与
`RELATION_PROJECTION.difference_summary` 会成为两处真源,得先把拼法抽成一处;
- 或者给 `difference_summary` 一个「人动过没有」的记号:人没改过就随重判重写,改过就留着。
记号本身要是基座数得出来的(别又变成模型自报)。
**先别做的理由**:它只影响卡上那一句话,件上逐条明细与等级都是真的;而两种治法都要动
「人可改的格子」这条已经踩过坑的边界(2026-08-31:「人改完一保存就被冲掉」),值得单独一轮。
---
## 人自己打回引出的那张卡,不该算作「打扰」(2026-09-01,未修 —— 先用预算兜着)
**实测**(柴浩那一单 `INS-80`):S3 结论审核连打回四次,每打回一次配方就重发一张审核卡、
每张卡 +1 `humanCalls`,第八次触顶 → 任务落 `exhausted`。屏上看着像**「最后落账那一步直接没了」**
—— 其实是单子先被烧死了,放行那一步再也没机会出现。
**为什么这笔账记错了**:调人预算的计量对象是**打扰**(「每发一张卡就是调了一次人」)。
而打回之后重发的那一张,**是他自己叫的** —— 他按下「重做」,要的就是「改完再问我一遍」。
把它算成一次打扰,等于对「你要求再看一遍」这件事收费,而这恰恰是审核循环存在的意义。
**先做的**:W05 的 `humanCalls` 8 → 24(配置,`manifest.ts` 那一行有注释)。够二十来轮打回。
**要治**(方向,未验证):发卡那条路上分「主动叫人」与「应他要求再问一遍」——
`CardGateway.issue` 多一个 `reask` 之类的意思,`assertBudget` 与 `addConsumption` 对它都不计;
判据由引擎给(`AutoContext.redoNote !== undefined` 那一轮发的卡就是再问一遍),不由活动自报。
**别把预算这道门整个拆了** —— 它拦的是「AI 一遍遍叫人」,那件事照旧要拦。
+17 -1
View File
@@ -326,13 +326,24 @@ W05 的两张数据工单(人填的值已入库、必填不缺、逐颗对账)
|---|---| |---|---|
| `project` | 从某个来源的每一行投影出台账行,落草稿层。带 `prune` 时,这一笔投影出来的行**就是这张表在本单草稿里的全部** | | `project` | 从某个来源的每一行投影出台账行,落草稿层。带 `prune` 时,这一笔投影出来的行**就是这张表在本单草稿里的全部** |
| `analyze` | **一次结构化 AI 调用**:系统组装入参、声明出参 schema、系统把结果落表。不给工具、不给记忆、不多轮 | | `analyze` | **一次结构化 AI 调用**:系统组装入参、声明出参 schema、系统把结果落表。不给工具、不给记忆、不多轮 |
| `card` | 发一张卡(执行工单 / 数据工单 / 决策卡)。**至多一张**,发过就不再发 | | `card` | 发一张卡(执行工单 / 数据工单 / **验收工单** / 决策卡,`variant` 定面孔)。**至多一张**,发过就不再发 |
| `submit` | 提交放行(只许出现在声明了人工验收清单的那一步) | | `submit` | 提交放行(只许出现在声明了人工验收清单的那一步) |
| `say` | 一句给人看的交代 | | `say` | 一句给人看的交代 |
地址只有四种:`域.对象`(任务视图 = 正式 + 本单草稿叠加,**默认该用的那一种**)· `mine:域.对象` · 地址只有四种:`域.对象`(任务视图 = 正式 + 本单草稿叠加,**默认该用的那一种**)· `mine:域.对象` ·
`in:入参数组` · `result:xxx`(本拍 analyze 的出参)。 `in:入参数组` · `result:xxx`(本拍 analyze 的出参)。
**「至多一张」是把两条路写成两笔的前提**(2026-08-31,W05「数据齐了也叫人一次」):同一步里
摆两笔条件互斥的 `card`(缺东西 → 数据工单;都齐了 → 只读的验收工单),引擎的幂等保证一步
只发一张 —— 前一张发过之后,后一笔永远轮不到,人不会被叫第二次。
**这一步的活可能本来就干完了,而你仍想叫人过目一次 —— 那就把这一步标 `manualActivation`。**
引擎在拍首先扫一遍 `doneWhen`,为真就自己收步:输入本来就齐的单,那一步的 `doneWhen`
第一拍就为真,**配方一笔都还没跑**,步骤当场被收掉,卡永远发不出去。标了人工激活,那道扫描
多一个门槛(`worldTouched` —— 卡真发过),次序就变成「配方发卡 → 人答 → 下一拍 `doneWhen`
为真才收」。`doneWhen` 照旧保留,它仍是这一步的真门,只是不再抢在人前面。
(引擎测钉着这一对:`turn-runner-acceptance.test.ts`「auto 步:配方在拍首跑」那一组。)
**guide 可以留空,但只有声明了 auto 才行**:`planStepSchema` 原来要求 `guide` 非空 **guide 可以留空,但只有声明了 auto 才行**:`planStepSchema` 原来要求 `guide` 非空
(`.min(1)`)—— 那是防「作者忘了写做法」的老规矩,可它顺手把「这一步一次都不问模型」堵死了。 (`.min(1)`)—— 那是防「作者忘了写做法」的老规矩,可它顺手把「这一步一次都不问模型」堵死了。
现在改成 `superRefine`:没声明 `auto` 的步照旧非空;声明了 `auto` 的步允许 `guide` 写空串 现在改成 `superRefine`:没声明 `auto` 的步照旧非空;声明了 `auto` 的步允许 `guide` 写空串
@@ -363,6 +374,11 @@ W05 的两张数据工单(人填的值已入库、必填不缺、逐颗对账)
的规矩,这条路连说都没说)。基座给了两道护栏:有卡在等人(人正对着件填)那几拍**不 prune**; 的规矩,这条路连说都没说)。基座给了两道护栏:有卡在等人(人正对着件填)那几拍**不 prune**;
同一张表既被 `prune` 又列在这一步 `humanWrites` 里,装包时报 warn。护栏不是许可 —— 同一张表既被 `prune` 又列在这一步 `humanWrites` 里,装包时报 warn。护栏不是许可 ——
要人加行的表就别 `prune` 它。 要人加行的表就别 `prune` 它。
**`prune: 'live'`(2026-08-31)是这条纪律的反面特例**:行集**全由配方铺、人在件上加不了行**
的表(如 W05 的参数骨架 —— 行按「料 × 类型字段」派生,件上没有加行钮),quiet 拍也照撤 ——
F3 防的风险在这种表上不存在,而「改了类型,旧类型的参数行还摆在屏上」是另一种撒谎
(实测:用户改选类型,行「只会跟着变多,不会跟着变少」)。有加行钮的表**永远不许用 `'live'`**。
两档都跳过已落地的行并出声(`draftDrop` 的 `skipLanded`)。
6. **`card` 之后的那几笔,要等人交回之后的那一拍才跑。** 卡真发出去那一拍到此为止; 6. **`card` 之后的那几笔,要等人交回之后的那一拍才跑。** 卡真发出去那一拍到此为止;
人交回之后那一拍,`card` 这一笔被跳过、后面的照跑。所以「铺格 → 发卡 → 收口整理」读起来像一拍连做, 人交回之后那一拍,`card` 这一笔被跳过、后面的照跑。所以「铺格 → 发卡 → 收口整理」读起来像一拍连做,
其实是两拍 —— 收口那一笔看得见人填的值,这正是它该在的位置。装包时会为此报一条 warn 提醒。 其实是两拍 —— 收口那一笔看得见人填的值,这正是它该在的位置。装包时会为此报一条 warn 提醒。
@@ -28,6 +28,20 @@ describe('auto 配方契约', () => {
expect(parsed.success).toBe(false); expect(parsed.success).toBe(false);
}); });
it("prune 收 'live' 档 —— quiet 拍也撤,只给人不会在件上加行的表用(其余字符串拒)", () => {
const live = autoRecipeSchema.safeParse({ do: [{ kind: 'project', into: 'a.b', prune: 'live', from: { once: true }, fields: { k: 'v' } }] });
expect(live.success).toBe(true);
const junk = autoRecipeSchema.safeParse({ do: [{ kind: 'project', into: 'a.b', prune: 'always', from: { once: true }, fields: { k: 'v' } }] });
expect(junk.success).toBe(false);
});
it('card 收确认闸条件档 gateWhen/gateWhenSay(2026-08-31,类型不一致不许确认)', () => {
const ok = autoRecipeSchema.safeParse({
do: [{ kind: 'card', as: 'execution', title: 't', gateWhen: 'a_b_distinct_c <= 1', gateWhenSay: '先改对再确认' }],
});
expect(ok.success).toBe(true);
});
it('prune 只许挂在 project 上', () => { it('prune 只许挂在 project 上', () => {
const parsed = autoRecipeSchema.safeParse({ do: [{ kind: 'say', text: 'x', prune: true }] }); const parsed = autoRecipeSchema.safeParse({ do: [{ kind: 'say', text: 'x', prune: true }] });
expect(parsed.success).toBe(false); expect(parsed.success).toBe(false);
+40 -1
View File
@@ -174,7 +174,12 @@ export const LAUNCH_PARAM_TYPES = ['text', 'textarea', 'select', 'combo', 'list'
export const launchParamTypeSchema = z.enum(LAUNCH_PARAM_TYPES); export const launchParamTypeSchema = z.enum(LAUNCH_PARAM_TYPES);
export type LaunchParamType = z.infer<typeof launchParamTypeSchema>; export type LaunchParamType = z.infer<typeof launchParamTypeSchema>;
export const launchOptionSchema = z.object({ value: z.string(), label: z.string() }); /**
* 一条候选。`group` 是**联动分组键**(2026-08-31 用户定):清单 `x-options.group` 指了哪一列,
* 这颗候选就带上它在那一列的值(如料号的 `chip_type_id`);空串 = 那一列没值(「未标类型」)。
* `groupLabel` 是这枚键的人读名(从字典表现读,如「DC-DC 降压稳压器」),读不到就没有 —— 界面退回摆键本身。
*/
export const launchOptionSchema = z.object({ value: z.string(), label: z.string(), group: z.string().optional(), groupLabel: z.string().optional() });
export type LaunchOption = z.infer<typeof launchOptionSchema>; export type LaunchOption = z.infer<typeof launchOptionSchema>;
export const launchParamSchema = z.object({ export const launchParamSchema = z.object({
@@ -188,9 +193,43 @@ export const launchParamSchema = z.object({
/** select / combo 的候选;combo 提交候选之外的值也合法 */ /** select / combo 的候选;combo 提交候选之外的值也合法 */
options: z.array(launchOptionSchema).optional(), options: z.array(launchOptionSchema).optional(),
default: z.union([z.string(), z.array(z.string())]).optional(), default: z.union([z.string(), z.array(z.string())]).optional(),
/**
* 联动组名(清单 `x-link`)—— 同一组的几格按候选的 `group` 互相收窄:
* 组内**第一个填上值的那格是锚**,锚自己永远摆全量候选,其余格只摆与锚同 `group` 的候选
* (外加没有 `group` 的那些,单独一组殿后)+「新增」这条路;锚清空或改成新增即解除。
* 服务端**不据此立墙** —— combo 本就收候选之外的值,类型一致性归活动自己的收步判据管。
*/
linkGroup: z.string().optional(),
/**
* **互斥组名**(清单 `x-distinct`,2026-08-31 用户定)—— 同一组的几格**不能填同一个值**:
* 别的格已经占了的候选**不列**在这一格里(前端),自己写进来也拒(服务端 `validateLaunchParams`)。
*
* 与 `linkGroup` 是两件事,别混:那个是引导(收窄候选,填了别的照收),**这个是墙**。
* W05 两格填同一颗料会把单子做死 —— `sub_eval.part` 的主键是 `eval_ref + internal_pn`,
* 配方铸的两行撞成同一个主键,后一笔覆盖前一笔,两行收成一行:`sub_eval_part_exists_status_found = 2`
* 永不成立、S1 收不了步,而件上那两行人加不出来(行源是 `mine:sub_eval.part`,没有「+加一行」)——
* 唯一出路是终止重开。**墙立在起单这一刻,是因为路只在这里**:那时改一格就好了。
*
* 只管**标量格**(text / select / combo);数组格声明了也不算 —— 本期没有这种形状,真要了再补。
*/
distinctGroup: z.string().optional(),
}); });
export type LaunchParam = z.infer<typeof launchParamSchema>; export type LaunchParam = z.infer<typeof launchParamSchema>;
/**
* 「算不算同一个」的归一:去空白 + 不分大小写 —— **前后端同一把尺**。
* 大小写要归:料号这类身份不因字母大小写而不同,只挡精确相等的话,`lm2596s-5.0` 会照样进去,
* 混一颗垃圾主数据出来,人还得到一份自己跟自己比的评估。
*/
export function launchDistinctKey(value: string): string {
return value.trim().toLowerCase();
}
/** 撞上了那一格的话 —— 前端(填的当口)与服务端(400 的 detail)同一句,不许分叉 */
export function launchDistinctMessage(otherLabel: string): string {
return `与「${otherLabel}」填的是同一个 —— 这两格不能一样,换一个再启动`;
}
export const launchParamsSchema = z.object({ export const launchParamsSchema = z.object({
activityId: z.string(), activityId: z.string(),
version: z.string(), version: z.string(),
+13
View File
@@ -1060,6 +1060,19 @@ export const testActivityStateRoute = {
z.object({ z.object({
id: z.string(), id: z.string(),
mode: z.enum(['execution', 'acceptance']), mode: z.enum(['execution', 'acceptance']),
/**
* 执行工单的**面孔**:`data` 数据工单 / `accept` 验收工单(2026-08-31 补)。
* 观测面得看得见这张卡是以什么身份发出去的 —— 面孔在装配层那一处决定,
* 而它此前恰恰因为「配方那头与前端那头都有测、中间没有」漏过一次
* (配方递 `accept`,装配层写死只认 `data`,人收到的是「执行工单」)。
*/
variant: z.enum(['data', 'accept']).optional(),
/**
* 验收卡**打回得了吗**(2026-09-01 补)。这一路值被静默丢过一次:装配层算对了、
* release 转对了、契约也声明了,到容器那个**手写字段清单**的显式对象里被抹平
* (`fields` / `blocks` 之后的第三次)—— 断言必须钉在**最末端**,才拦得住这类丢值。
*/
redoable: z.boolean().optional(),
title: z.string(), title: z.string(),
done: z.boolean(), done: z.boolean(),
result: z.string().optional(), result: z.string().optional(),
+22 -5
View File
@@ -97,8 +97,16 @@ export type AutoAction =
readonly when?: string | undefined; readonly when?: string | undefined;
/** 台账地址「域.对象」—— 必须在 `declarations.ledger` 里(静态审) */ /** 台账地址「域.对象」—— 必须在 `declarations.ledger` 里(静态审) */
readonly into: string; readonly into: string;
/** 这一笔投影出来的行**就是这张表在本单草稿里的全部**,多出来的撤掉。只动草稿层 */ /**
readonly prune?: boolean | undefined; * 这一笔投影出来的行**就是这张表在本单草稿里的全部**,多出来的撤掉。只动草稿层。
* `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 from: AutoSource;
readonly fields: Readonly<Record<string, AutoExpr>>; readonly fields: Readonly<Record<string, AutoExpr>>;
} }
@@ -120,7 +128,11 @@ export type AutoAction =
readonly kind: 'card'; readonly kind: 'card';
readonly when?: string | undefined; readonly when?: string | undefined;
readonly as: 'execution' | 'decide'; readonly as: 'execution' | 'decide';
readonly variant?: ('data' | 'review') | undefined; /**
* 卡的面孔:`data` 数据工单 / `accept` 验收工单(都是执行工单的变体)· `review` 审核卡(决策卡的)。
* 不写就是普通执行工单 / 普通决策卡。
*/
readonly variant?: ('data' | 'review' | 'accept') | undefined;
readonly title: AutoExpr; readonly title: AutoExpr;
/** 决策卡问的那句话 */ /** 决策卡问的那句话 */
readonly ask?: AutoExpr | undefined; readonly ask?: AutoExpr | undefined;
@@ -131,6 +143,9 @@ export type AutoAction =
readonly basis?: readonly { readonly k: string; readonly v: AutoExpr }[] | undefined; readonly basis?: readonly { readonly k: string; readonly v: AutoExpr }[] | undefined;
/** 这张卡的提交闸盯哪几张表 */ /** 这张卡的提交闸盯哪几张表 */
readonly gateLedgers?: readonly string[] | undefined; readonly gateLedgers?: readonly string[] | undefined;
/** 确认闸的条件档(S-FEEL,只读基座读数;为假不受理确认)+ 拒回那句话 —— 见 card.ts 同名字段 */
readonly gateWhen?: string | undefined;
readonly gateWhenSay?: string | undefined;
/** 决策卡的选项;`variant:'review'` 时允许只有一个 */ /** 决策卡的选项;`variant:'review'` 时允许只有一个 */
readonly options?: readonly options?:
| readonly { readonly value: string; readonly label: string; readonly detail?: string | undefined }[] | readonly { readonly value: string; readonly label: string; readonly detail?: string | undefined }[]
@@ -145,7 +160,7 @@ export const autoActionSchema: z.ZodType<AutoAction> = z.discriminatedUnion('kin
kind: z.literal('project'), kind: z.literal('project'),
when: z.string().min(1).optional(), when: z.string().min(1).optional(),
into: z.string().regex(/^[a-z_]+\.[a-z_]+$/, '台账地址写成「域.对象」'), into: z.string().regex(/^[a-z_]+\.[a-z_]+$/, '台账地址写成「域.对象」'),
prune: z.boolean().optional(), prune: z.union([z.boolean(), z.literal('live')]).optional(),
from: autoSourceSchema, from: autoSourceSchema,
fields: z.record(z.string(), autoExprSchema), fields: z.record(z.string(), autoExprSchema),
}) })
@@ -166,13 +181,15 @@ export const autoActionSchema: z.ZodType<AutoAction> = z.discriminatedUnion('kin
kind: z.literal('card'), kind: z.literal('card'),
when: z.string().min(1).optional(), when: z.string().min(1).optional(),
as: z.enum(['execution', 'decide']), as: z.enum(['execution', 'decide']),
variant: z.enum(['data', 'review']).optional(), variant: z.enum(['data', 'review', 'accept']).optional(),
title: autoExprSchema, title: autoExprSchema,
ask: autoExprSchema.optional(), ask: autoExprSchema.optional(),
guide: z.array(z.string().min(1)).optional(), guide: z.array(z.string().min(1)).optional(),
standard: autoExprSchema.optional(), standard: autoExprSchema.optional(),
basis: z.array(z.object({ k: z.string().min(1), v: autoExprSchema }).strict()).optional(), basis: z.array(z.object({ k: z.string().min(1), v: autoExprSchema }).strict()).optional(),
gateLedgers: z.array(z.string().min(1)).optional(), gateLedgers: z.array(z.string().min(1)).optional(),
gateWhen: z.string().min(1).optional(),
gateWhenSay: z.string().min(1).optional(),
options: z options: z
.array(z.object({ value: z.string().min(1), label: z.string().min(1), detail: z.string().optional() }).strict()) .array(z.object({ value: z.string().min(1), label: z.string().min(1), detail: z.string().optional() }).strict())
.optional(), .optional(),
+27 -1
View File
@@ -344,8 +344,14 @@ export const executionWorkOrderSchema = z.object({
* 区别只在**它要人办的是「把一张主数据表建齐」**(件上的表就是要建的记录,确认即入库), * 区别只在**它要人办的是「把一张主数据表建齐」**(件上的表就是要建的记录,确认即入库),
* 所以对话区要长成自己的样子(色/图标/「数据工单」四字/「确认写入」),不能再顶着「执行工单」的牌子。 * 所以对话区要长成自己的样子(色/图标/「数据工单」四字/「确认写入」),不能再顶着「执行工单」的牌子。
* 活动在卡骨架上写 `"variant":"data"`,归一层原样带下来;不写就是普通执行工单。 * 活动在卡骨架上写 `"variant":"data"`,归一层原样带下来;不写就是普通执行工单。
*
* **`accept` = 验收工单**(2026-08-31 用户定:「就算数据是足的也进入这个数据工单,就派发一个
* 验收工单…把芯片卡片上的编辑按钮去掉,只展示不能编辑」)—— 同样是执行工单的变体:件上没有
* 要填的格子,人只核对、确认无误。**与 `mode: 'acceptance'` 不是一回事**:那个是 `submit`
* 抬起来的**收口验收**(签下去就 releaseWrite、发口、落终态,带打回重做那一路);
* 这个只是「这一步的活干完了,请你过目」,回传即办结,一步都不放行。
*/ */
variant: z.enum(['data']).optional(), variant: z.enum(['data', 'accept']).optional(),
ask: z.string(), ask: z.string(),
standard: z.string().optional(), standard: z.string().optional(),
steps: z.array(z.string()).optional(), steps: z.array(z.string()).optional(),
@@ -378,6 +384,14 @@ export const executionWorkOrderSchema = z.object({
* 优先级:卡上绑了表(`blocks`)> 这一项 > 必交付物声明。 * 优先级:卡上绑了表(`blocks`)> 这一项 > 必交付物声明。
*/ */
gateLedgers: z.array(z.string()).optional(), gateLedgers: z.array(z.string()).optional(),
/**
* **确认闸的条件档**(2026-08-31,W05「类型不一致还能确认」逼出):除了「必填缺几格」,
* 发卡的人还可以声明一条 S-FEEL 条件(只读基座读数),为假就不受理确认 ——
* 服务端在受理回传前现算(与缺格闸同一段 submitGate),display 与拒回同一句话
* (`gateWhenSay`)。机检只读基座数的纪律在这儿同样成立:条件里只许出现读数名。
*/
gateWhen: z.string().optional(),
gateWhenSay: z.string().optional(),
gate: z gate: z
.object({ .object({
/** 能交了吗 */ /** 能交了吗 */
@@ -502,6 +516,18 @@ export const acceptanceWorkOrderSchema = z.object({
}) })
.optional(), .optional(),
passLabel: z.string().optional(), passLabel: z.string().optional(),
/**
* **这张卡打回得了吗**(2026-09-01 用户定:「评审放行与落账确认这一步的验收卡不应该有重做的选项,
* 因为这里都没有接入 ai」)。不写 = 打得回(老行为);写 `false` = **打回之后没有任何东西能接住它**,
* 那颗钮就不该摆在那儿。
*
* 什么时候是 `false`:这一步整个由配方跑完、一个字的 `guide` 都没有(不问模型),而件又是只读的 ——
* 打回之后既没有模型会去读那句意见,人也没有格子可改,配方只会原样再提交一次、撞在
* 「打回之后台账没动过就不许再提交」那道闸上,最后落成一张「连续撞在同一条判据上」的兜底卡。
* **一颗按下去只会把单子推进死胡同的钮,不如不摆**(墙要配路,没有路就别立那扇门)。
* 有异议的人还有真路可走:别签,去把这一单终止掉重开。
*/
redoable: z.boolean().optional(),
}); });
export type AcceptanceWorkOrder = z.infer<typeof acceptanceWorkOrderSchema>; export type AcceptanceWorkOrder = z.infer<typeof acceptanceWorkOrderSchema>;
@@ -0,0 +1,129 @@
/**
* **多宿主收值 + 嵌套异地址排除**(2026-08-31,W05 芯片卡翻面逼出的平台扩面)。
*
* 版面要求「参数值印在每张芯片卡的**背面**、就地可改」:参数表的行天然分居两张卡,
* 一个宿主装不下 —— `hostsOf` 把同一地址的全部宿主都数进来,每个宿主各自归行。
*
* 更要紧的是**嵌套异地址排除**:卡背的参数宿主(`data-edit="parts.parameter"`)住在
* 芯片卡宿主(`data-edit="parts.profile"`)的行里 —— 外层收值若把里层宿主的格子也
* 收走,参数值就会被当成主数据的列并进主数据的行(`view-container-collect-nested.test.ts`
* 钉过的静默丢数据,换一副面孔回来)。规则:**格子只归它最近的 `data-edit` 祖先**。
* 嵌套的 `data-rows` 不带 `data-edit` 时行为原样(照旧并行 —— 那两条钉测不动)。
*/
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { collect, mirrorTwins, scopedTargets, setOpenIn } from '../src/ViewContainer.tsx';
/** 两张芯片卡:正面是主数据格(profile),背面各住一个参数宿主(parameter) */
const FLIP_CARDS =
'<article data-edit="parts.profile">' +
'<section class="dossier">' +
'<button class="pen-a" data-edit-open="parts.profile"></button>' +
'<div class="front"><input data-col="internal_pn" value="LM2596S-5.0"/><input data-col="brand_name" value="TI"/></div>' +
'<div class="back"><section class="pback" data-edit="parts.parameter">' +
'<div class="ptile"><input data-col="internal_pn" value="LM2596S-5.0"/><input data-col="param_name" value="输出电压"/><input data-col="param_value" value="5.0"/></div>' +
'<div class="ptile"><input data-col="internal_pn" value="LM2596S-5.0"/><input data-col="param_name" value="输出电流"/><input data-col="param_value" value="3"/></div>' +
'</section></div>' +
'</section>' +
'<section class="dossier">' +
'<div class="front"><input data-col="internal_pn" value="XL2596S-5.0"/><input data-col="brand_name" value="XLSEMI"/></div>' +
'<div class="back"><section class="pback" data-edit="parts.parameter">' +
'<div class="ptile"><input data-col="internal_pn" value="XL2596S-5.0"/><input data-col="param_name" value="输出电压"/><input data-col="param_value" value="5.0"/></div>' +
'<div class="ptile"><input data-col="internal_pn" value="XL2596S-5.0"/><input data-col="param_name" value="输出电流"/><input data-col="param_value" value="2.5"/></div>' +
'</section></div>' +
'</section>' +
'</article>' +
'<div class="jc-editgate" data-edit-for="parts.parameter"></div>';
function docOf(html: string): Document {
const doc = document.implementation.createHTMLDocument('');
doc.body.innerHTML = html;
return doc;
}
describe('多宿主 collect():同一地址的宿主都数进来,格子只归最近的 data-edit 祖先', () => {
it('parts.parameter 分居两张卡背的两个宿主 —— 4 个瓷贴收到 4 行,主键各自带对', () => {
const got = collect(docOf(FLIP_CARDS), 'parts.parameter');
expect(got).toHaveLength(4);
const xl = got.filter((r) => r['internal_pn'] === 'XL2596S-5.0');
expect(xl).toHaveLength(2);
expect(new Map(xl.map((r) => [r['param_name'], r['param_value']])).get('输出电流')).toBe('2.5');
});
it('parts.profile 收值**不含**卡背参数宿主的格子 —— 两行主数据,一列参数都不许混进来', () => {
const got = collect(docOf(FLIP_CARDS), 'parts.profile');
expect(got).toHaveLength(2);
for (const row of got) {
expect(row['brand_name']).toBeDefined();
/* 静默丢数据的那副面孔:param_value 若出现在主数据行里,就是里层宿主被外层吞了 */
expect(row['param_value']).toBeUndefined();
expect(row['param_name']).toBeUndefined();
}
});
it('setOpenIn:一把闸开合同一地址的**全部**宿主(非表格容器,.on 落在宿主自身)', () => {
const doc = docOf(FLIP_CARDS);
setOpenIn(doc, 'parts.parameter', true);
const hosts = Array.from(doc.querySelectorAll('.pback'));
expect(hosts).toHaveLength(2);
expect(hosts.every((h) => h.classList.contains('on'))).toBe(true);
expect(doc.querySelector('[data-edit-for="parts.parameter"]')?.classList.contains('on')).toBe(true);
/* 外层 profile 宿主不许被顺手点亮 —— 两把闸各管各的 */
expect(doc.querySelector('[data-edit="parts.profile"]')?.classList.contains('on')).toBe(false);
setOpenIn(doc, 'parts.parameter', false);
expect(hosts.every((h) => !h.classList.contains('on'))).toBe(true);
});
});
describe('mirrorTwins:下拉镜像的是选项文案,不是 value(2026-08-31「保存一瞬间变数字」)', () => {
const SEL =
'<div data-edit="parts.profile"><div class="row">' +
'<span class="jc-vcell tsel">旧名</span>' +
'<select class="jc-cell" data-col="chip_type_id">' +
'<option value=""></option><option value="1">DC-DC 降压稳压器</option><option value="2">LDO 线性稳压器</option>' +
'</select>' +
'</div></div>';
it('镜进孪生件的是「DC-DC 降压稳压器」,不是「1」;selected 属性拨到人选的那一项(守卫 CSS 认它)', () => {
const doc = document.implementation.createHTMLDocument('');
doc.body.innerHTML = SEL;
const sel = doc.querySelector('select') as HTMLSelectElement;
sel.value = '1';
mirrorTwins(doc, 'parts.profile');
expect(doc.querySelector('.tsel')?.textContent).toBe('DC-DC 降压稳压器');
expect(doc.querySelector('option[value="1"]')?.hasAttribute('selected')).toBe(true);
expect(doc.querySelector('option[value="2"]')?.hasAttribute('selected')).toBe(false);
});
it('没选(空占位)= 没答:孪生件一个字不动', () => {
const doc = document.implementation.createHTMLDocument('');
doc.body.innerHTML = SEL;
mirrorTwins(doc, 'parts.profile');
expect(doc.querySelector('.tsel')?.textContent).toBe('旧名');
});
});
describe('scopedTargets:开合作用域跟着触发钮走(2026-08-31「不要做联动,各自编辑各自的部分」)', () => {
it('钮在本地址宿主的行里 → 只这一行(A 卡的笔只开 A 卡);钮对别的地址 → 只这张卡里那个宿主', () => {
const doc = docOf(FLIP_CARDS);
const pen = doc.querySelector('.pen-a') as HTMLElement;
// ① profile:钮在 profile 宿主里 → 落到它所在的那一行(第一张卡的 section)
const t1 = scopedTargets(doc, 'parts.profile', pen);
expect(t1).toHaveLength(1);
expect(t1[0]?.className).toBe('dossier');
expect(t1[0]?.contains(pen)).toBe(true);
// ② parameter:钮不在任何 parameter 宿主里,但在 profile 的行里 → 行内那一个 pback,不是两个
const t2 = scopedTargets(doc, 'parts.parameter', pen);
expect(t2).toHaveLength(1);
expect(t2[0]?.classList.contains('pback')).toBe(true);
expect(t1[0]?.contains(t2[0] ?? null)).toBe(true);
});
it('外部闸(不在任何宿主/行里)→ 全部宿主,老行为', () => {
const doc = docOf(FLIP_CARDS);
const gate = doc.querySelector('[data-edit-for="parts.parameter"]') as HTMLElement;
const t = scopedTargets(doc, 'parts.parameter', gate);
expect(t).toHaveLength(2);
expect(t.every((h) => h.classList.contains('pback'))).toBe(true);
});
});
+114 -25
View File
@@ -161,9 +161,33 @@ function replaceInPlace(doc: Document, html: string): void {
} }
} }
/** 一张可改的表的容器(tbody)—— 按 `data-edit` 地址找 */ /** 一张可改的表的容器(tbody)—— 按 `data-edit` 地址找(多宿主时取文档序第一个,老调用方语义不变) */
export function bodyOf(doc: Document, address: string): HTMLElement | null { export function bodyOf(doc: Document, address: string): HTMLElement | null {
return doc.querySelector<HTMLElement>(`[data-edit="${CSS.escape(address)}"]`); return hostsOf(doc, address)[0] ?? null;
}
/**
* **同一个地址的全部宿主**(2026-08-31,W05 芯片卡翻面逼出的多宿主收值)。
*
* 从前一个地址恰一个宿主(`bodyOf` 单 querySelector)——「参数值印在每张芯片卡的背面、
* 就地可改」这种版面下,同一张表(`parts.parameter`)的行天然分居两张卡,一个宿主装不下。
* 现在收值 / 开合 / 镜像都按**宿主清单**走:每个宿主各自归行(行仍是宿主的直接子元素),
* 清单合起来就是这张表。加行(`addRow`)仍只认第一个宿主 —— 会加行的表(类型字典 /
* 字段清单)从来都是单宿主,多宿主的表行由配方铺、人不加行。
*/
export function hostsOf(doc: Document, address: string): HTMLElement[] {
return Array.from(doc.querySelectorAll<HTMLElement>(`[data-edit="${CSS.escape(address)}"]`));
}
/**
* 这个格子归这个宿主管吗 —— **认最近的 `data-edit` 祖先**。
* 宿主可以嵌套(芯片卡是 `parts.profile` 的行,卡背里又住着 `parts.parameter` 的宿主):
* 外层收值时不认里层宿主的格子,不然参数值会被当成主数据的列并进主数据的行
* (`view-container-collect-nested.test.ts` 钉过的那种静默丢数据,换一副面孔回来)。
* 嵌套的 `data-rows` **不带** `data-edit` 时行为原样(照旧并行 —— 那两条钉测不动)。
*/
function ownsCell(host: HTMLElement, cell: Element): boolean {
return cell.closest('[data-edit]') === host;
} }
/** /**
@@ -186,11 +210,11 @@ type EditCell = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
/** 一张表的输入格子;`gone` 的行(人删掉的)不算在内。下拉与多行文本(textarea)与文本输入同源同待 */ /** 一张表的输入格子;`gone` 的行(人删掉的)不算在内。下拉与多行文本(textarea)与文本输入同源同待 */
function cellsOf(doc: Document, address: string): EditCell[] { function cellsOf(doc: Document, address: string): EditCell[] {
const host = bodyOf(doc, address); return hostsOf(doc, address).flatMap((host) =>
if (host === null) return []; Array.from(host.querySelectorAll<EditCell>('input[data-col], select[data-col], textarea[data-col]')).filter(
return Array.from( (i) => ownsCell(host, i) && i.closest('.jc-row-gone') === null,
host.querySelectorAll<EditCell>('input[data-col], select[data-col], textarea[data-col]'), ),
).filter((i) => i.closest('.jc-row-gone') === null); );
} }
/** /**
@@ -212,10 +236,12 @@ export function unanswered(cell: EditCell): boolean {
* 认行元素则怎么增删都对得上:同一个 `<tr>` 里的格子就是同一行。 * 认行元素则怎么增删都对得上:同一个 `<tr>` 里的格子就是同一行。
*/ */
export function collect(doc: Document, address: string): Array<Record<string, string>> { export function collect(doc: Document, address: string): Array<Record<string, string>> {
const host = bodyOf(doc, address);
if (host === null) return [];
const byRow = new Map<Element, Record<string, string>>(); const byRow = new Map<Element, Record<string, string>>();
for (const cell of cellsOf(doc, address)) { for (const host of hostsOf(doc, address)) {
for (const cell of Array.from(
host.querySelectorAll<EditCell>('input[data-col], select[data-col], textarea[data-col]'),
)) {
if (!ownsCell(host, cell) || cell.closest('.jc-row-gone') !== null) continue;
const col = cell.dataset['col']; const col = cell.dataset['col'];
const row = rowOf(host, cell); const row = rowOf(host, cell);
if (col === undefined || row == null) continue; if (col === undefined || row == null) continue;
@@ -230,6 +256,7 @@ export function collect(doc: Document, address: string): Array<Record<string, st
one[col] = cell.value; one[col] = cell.value;
byRow.set(row, one); byRow.set(row, one);
} }
}
return [...byRow.values()]; return [...byRow.values()];
} }
@@ -253,28 +280,43 @@ export function collect(doc: Document, address: string): Array<Record<string, st
* 而 `twinInputs` 是把 input **紧接着**只读那份写出来的,前一个兄弟就是它的孪生件。 * 而 `twinInputs` 是把 input **紧接着**只读那份写出来的,前一个兄弟就是它的孪生件。
* 这条是在真单的 DOM 上查出来的:合成 fixture 里我两份都写了 data-slot,于是照样绿。 * 这条是在真单的 DOM 上查出来的:合成 fixture 里我两份都写了 data-slot,于是照样绿。
*/ */
function mirrorTwins(doc: Document, address: string): void { export function mirrorTwins(doc: Document, address: string): void {
for (const cell of cellsOf(doc, address)) { for (const cell of cellsOf(doc, address)) {
// 与 collect 同一条规矩:没答的不算话(「没点/没选」= 不改) // 与 collect 同一条规矩:没答的不算话(「没点/没选」= 不改)
if (unanswered(cell)) continue; if (unanswered(cell)) continue;
const twin = cell.previousElementSibling; const twin = cell.previousElementSibling;
if (twin === null || twin.tagName === 'INPUT' || twin.tagName === 'SELECT' || twin.tagName === 'TEXTAREA') continue; if (twin === null || twin.tagName === 'INPUT' || twin.tagName === 'SELECT' || twin.tagName === 'TEXTAREA') continue;
/*
* **下拉镜像的是选项文案,不是 value**(2026-08-31 用户:「保存的一瞬间芯片类型
* 下拉框的值会变成数字」)—— 下拉的 value 是机器码(type_id、tally 值),
* 摆给人看的向来是人读名;顺手把 selected 属性拨到人选的那一项:守卫类 CSS
* (:has(option[selected]))与「取消后再编辑」的回显都认它,不拨的话保存后到
* 重推回来那一段,屏上仍按旧选择说话。
*/
if (cell.tagName === 'SELECT') {
const sel = cell as HTMLSelectElement;
for (const o of Array.from(sel.options)) o.toggleAttribute('selected', o.value === sel.value && sel.value !== '');
twin.textContent = sel.selectedOptions[0]?.textContent ?? sel.value;
continue;
}
twin.textContent = cell.value; twin.textContent = cell.value;
} }
} }
/** 人删掉的那几行 —— 只交主键那几格够认行的值(整行都交也无妨,服务端只看主键) */ /** 人删掉的那几行 —— 只交主键那几格够认行的值(整行都交也无妨,服务端只看主键) */
function collectGone(doc: Document, address: string): Array<Record<string, string>> { function collectGone(doc: Document, address: string): Array<Record<string, string>> {
const host = bodyOf(doc, address); return hostsOf(doc, address).flatMap((host) =>
if (host === null) return []; Array.from(host.querySelectorAll<HTMLElement>('.jc-row-gone'))
return Array.from(host.querySelectorAll<HTMLElement>('.jc-row-gone')).map((row) => { .filter((row) => row.closest('[data-edit]') === host)
.map((row) => {
const one: Record<string, string> = {}; const one: Record<string, string> = {};
for (const cell of Array.from(row.querySelectorAll<HTMLInputElement>('input[data-col]'))) { for (const cell of Array.from(row.querySelectorAll<HTMLInputElement>('input[data-col]'))) {
const col = cell.dataset['col']; const col = cell.dataset['col'];
if (col !== undefined) one[col] = cell.value; if (col !== undefined) one[col] = cell.value;
} }
return one; return one;
}); }),
);
} }
/** /**
@@ -322,6 +364,46 @@ export function addRow(doc: Document, address: string): boolean {
return true; return true;
} }
/**
* 那块内容与它的闸一起开合。`.on` 落在哪:行容器是表格的落在 `<table>`(老件,样式按
* `table.on` 写);不是表格的落在 `data-edit` 元素自身(2026-08-02 A2 对决卡立的规矩)。
* 这一版是**全宿主**开合(外部闸行用它);按了卡上钮的走 `scopedTargets`(见下)。
*/
export function setOpenIn(doc: Document, address: string, on: boolean): void {
doc.querySelector<HTMLElement>(`[data-edit-for="${CSS.escape(address)}"]`)?.classList.toggle('on', on);
for (const host of hostsOf(doc, address)) (host.closest('table') ?? host).classList.toggle('on', on);
}
/**
* **开合的作用域跟着触发钮走**(2026-08-31 用户:「芯片档案编辑就不能各自编辑各自的部分吗,
* 不要做联动」)。三档,从近到远:
* ① 钮长在这张表**自己的宿主**里 —— 落到钮所在的**那一行**(宿主的直接子元素;
* W05 芯片卡的笔落在这张卡的行上,只这张卡进编辑);行认不出就整个宿主;
* ② 钮长在**别的**宿主的某一行里 —— 落到**这一行内部**属于本地址的宿主们
* (W05 卡背的参数宿主住在主数据宿主的行里:点这张卡的笔,只这张卡的卡背开);
* ③ 都不是(外部闸行,如差异审核件的编辑闸)—— 全部宿主一起,老行为。
* 收值与保存**不随作用域收窄**(collect 仍按地址全量;「只写真改了的行」在服务端把关,
* 没动的行是 no-op)—— 作用域只管「屏上哪一块进编辑态」这一件事。
*/
export function scopedTargets(doc: Document, address: string, trigger: Element): HTMLElement[] {
const hosts = hostsOf(doc, address);
for (const host of hosts) {
if (host.contains(trigger)) {
const row = rowOf(host, trigger);
return [(row as HTMLElement | null) ?? host];
}
}
/* 钮在别的宿主的行里:找出包住钮的那一行(任意 data-edit 宿主的直接子元素),取行内的本地址宿主 */
for (let n: Element | null = trigger; n !== null; n = n.parentElement) {
const parent = n.parentElement;
if (parent !== null && parent.hasAttribute('data-edit')) {
const inner = hosts.filter((h) => n !== null && n.contains(h));
if (inner.length > 0) return inner;
}
}
return hosts.map((h) => (h.closest('table') ?? h) as HTMLElement);
}
/** /**
* `data-file="域.对象/主键值"` → 取文件要的那两样。 * `data-file="域.对象/主键值"` → 取文件要的那两样。
* *
@@ -394,8 +476,15 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
* 填回表的尾巴(实测 A2 工作单:量得 2402、实际 2524)。 * 填回表的尾巴(实测 A2 工作单:量得 2402、实际 2524)。
*/ */
bindRef.current(); bindRef.current();
// 取 body 与 documentElement 的较大者:两者谁撑开高度随文档结构而异 /*
const next = Math.max(doc.documentElement.scrollHeight, doc.body?.scrollHeight ?? 0); * **量 body,不量 documentElement**(2026-08-31 用户:「页面底部都没东西了,
* 怎么还可以向下滑动一截」):documentElement.scrollHeight 以 iframe 自身高度为下限 ——
* 内容一旦把 iframe 撑高过一次(比如参数编辑态输入框把卡背拉长),收起后它也缩不回去,
* 量高从此**只涨不缩**,底部留一截空白滚动。body(margin 0、height auto)的 scrollHeight
* 才是内容自己的高;body 读不出(异常文档)再退回 documentElement。
*/
const body = doc.body?.scrollHeight ?? 0;
const next = body > 0 ? body : doc.documentElement.scrollHeight;
if (next > 0) setHeight(next); if (next > 0) setHeight(next);
}, []); }, []);
@@ -409,7 +498,7 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
const fileSink = useRef(onOpenFile); const fileSink = useRef(onOpenFile);
fileSink.current = onOpenFile; fileSink.current = onOpenFile;
/** 进编辑态那一刻的样子 —— 「取消」要还原成它(值 + 那时有几行) */ /** 进编辑态那一刻的样子 —— 「取消」要还原成它(值 + 那时有几行) */
const snapshot = useRef(new Map<string, { values: string[]; rows: number }>()); const snapshot = useRef(new Map<string, { values: string[]; rows: number[] }>());
/* /*
* 挂事件 + 开关闸 —— 都在**父文档这一侧**做,iframe 里没有脚本。 * 挂事件 + 开关闸 —— 都在**父文档这一侧**做,iframe 里没有脚本。
@@ -464,8 +553,7 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
*/ */
const setOpen = (address: string, on: boolean): void => { const setOpen = (address: string, on: boolean): void => {
doc.querySelector<HTMLElement>(`[data-edit-for="${CSS.escape(address)}"]`)?.classList.toggle('on', on); doc.querySelector<HTMLElement>(`[data-edit-for="${CSS.escape(address)}"]`)?.classList.toggle('on', on);
const host = doc.querySelector<HTMLElement>(`[data-edit="${CSS.escape(address)}"]`); for (const target of scopedTargets(doc, address, hit)) (target.closest('table') ?? target).classList.toggle('on', on);
(host?.closest('table') ?? host)?.classList.toggle('on', on);
}; };
const open = hit.dataset['editOpen']; const open = hit.dataset['editOpen'];
@@ -479,7 +567,8 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
// (radio 的 value 是恒定的选项值,快照与还原认的是 checked) // (radio 的 value 是恒定的选项值,快照与还原认的是 checked)
snapshot.current.set(open, { snapshot.current.set(open, {
values: cellsOf(doc, open).map((c) => (c.type === 'radio' ? ((c as HTMLInputElement).checked ? '1' : '') : c.value)), values: cellsOf(doc, open).map((c) => (c.type === 'radio' ? ((c as HTMLInputElement).checked ? '1' : '') : c.value)),
rows: Array.from(bodyOf(doc, open)?.children ?? []).length, /* 行数逐宿主记 —— 多宿主时「取消」要把每个宿主各自加出来的行裁回去 */
rows: hostsOf(doc, open).map((h) => h.children.length),
}); });
setOpen(open, true); setOpen(open, true);
return; return;
@@ -488,11 +577,11 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
const was = snapshot.current.get(cancel); const was = snapshot.current.get(cancel);
if (was !== undefined) { if (was !== undefined) {
// 加出来的那几行整个撤掉,删掉的标记摘掉,再把值放回去 —— 三样一起才叫还原 // 加出来的那几行整个撤掉,删掉的标记摘掉,再把值放回去 —— 三样一起才叫还原
const host = bodyOf(doc, cancel); hostsOf(doc, cancel).forEach((host, i) => {
if (host !== null) { const keep = was.rows[i];
while (host.children.length > was.rows) host.lastElementChild?.remove(); if (keep !== undefined) while (host.children.length > keep) host.lastElementChild?.remove();
for (const row of Array.from(host.querySelectorAll('.jc-row-gone'))) row.classList.remove('jc-row-gone'); for (const row of Array.from(host.querySelectorAll('.jc-row-gone'))) row.classList.remove('jc-row-gone');
} });
cellsOf(doc, cancel).forEach((c, i) => { cellsOf(doc, cancel).forEach((c, i) => {
const saved = was.values[i]; const saved = was.values[i];
if (saved === undefined) return; if (saved === undefined) return;
+190 -60
View File
@@ -593,33 +593,41 @@ const SUITES = {
address: 'parts.profile', address: 'parts.profile',
blankCol: 'brand_name', blankCol: 'brand_name',
stages: [ stages: [
{
/*
* **验收工单**(2026-08-31 用户定:「就算数据是足的也进入这个数据工单,就派发一个验收工单」)——
* 输入本来就齐的单走这一张:同一份件、同一批数据,只是件只读、卡角编辑钮整组收起。
* 没有 `edits`(件上没有可填的格子),`buildEdits` 算出来是空 → 一次保存都不发,直接确认。
*/
kind: 'accept',
match: /验收工单 · 差异评估的输入核对/,
address: 'parts.profile',
rowKey: 'internal_pn',
blankCol: 'brand_name',
edits: [],
},
{ {
// 唯一的数据工单:一张卡摆三块内容,写向四张表(主表 parts.profile, // 唯一的数据工单:一张卡摆三块内容,写向四张表(主表 parts.profile,
// extraTables 里另外三张)——四张表随同一次「保存」一并发出,不分次交。 // extraTables 里另外三张)——四张表随同一次「保存」一并发出,不分次交。
kind: 'data',
match: /差异评估的输入还差几格/, match: /差异评估的输入还差几格/,
address: 'parts.profile', address: 'parts.profile',
rowKey: 'internal_pn', rowKey: 'internal_pn',
blankCol: 'brand_name', blankCol: 'brand_name',
expectGateBlock: true, expectGateBlock: true,
/*
* **确认闸的条件档必须跟到工单载荷上**(2026-09-01 用户实测:「两个芯片类型不一样…
* 卡片还可以点击下一步」)。配方声明了 `gateWhen`,发卡装配的手写清单没点它的名 ——
* 落库载荷里是 undefined,按钮禁用与服务端拒收一起无声失效(手写字段清单第四次丢值,
* 与 variant 上次被丢的是同一段)。这类丢值只有末端断言拦得住:卡一到手就查载荷。
*/
expectCardGateWhen: true,
edits: [], edits: [],
extraTables: [ extraTables: [
/* /*
* `parts.chip_type` **没有任何 `data-edit` 绑定**(manifest.ts / case-model.ts * 2026-08-31「只选不建」:parts.chip_type / parts.type_param 两条随功能退役 ——
* 头注明写:新建类型时人在 `parts.type_param` 那张表加行,`parts.chip_type` 那 * S1 的写授权面(writes)已收窄,再喂这两张表会被 400 拒。字典只在治理侧维护。
* 张字典表本身没有编辑面 —— `ViewContainer.addRow` 靠克隆末行实现「加一行」,
* 而它在任何一单里都是从零行起,没有可克隆的样子)。写的授权面是**台账声明**
* 不是「当下卡上有没有绑定」(`writesOf` 只认 `declarations.ledger`),所以这条
* 写入走得通 —— 只是脚本代脚本走 API,不是真人代替点了一个并不存在的按钮。
* 只有样例 0(候选缺库·新建类型)会喂 `edits`,其余案例这里留空、这张表不写。
*/ */
{ address: 'parts.chip_type', rowKey: 'type_id', blankCol: 'type_name', edits: [] },
{
// 复合主键 (type_id, param_name) —— 同一类型两个字段不能靠单列区分。
address: 'parts.type_param',
rowKey: ['type_id', 'param_name'],
blankCol: 'param_desc',
edits: [],
},
{ {
// 复合主键 (internal_pn, param_name) —— 两颗料同名参数(如都有「工作温度」) // 复合主键 (internal_pn, param_name) —— 两颗料同名参数(如都有「工作温度」)
// 不这样区分,rows.find 会永远命中第一条同名行、把不同料的值互相顶掉。 // 不这样区分,rows.find 会永远命中第一条同名行、把不同料的值互相顶掉。
@@ -634,13 +642,36 @@ const SUITES = {
cases: [ cases: [
{ {
sample: 0, sample: 0,
name: '候选缺库 · XL2596S-5.0 新建芯片类型(自增 type_id,字段清单沿用原型口径)入库,' name: '候选缺库 · XL2596S-5.0 建档并选已有类型(type_1,2026-08-31 起本活动只选不建)'
+ '原 PN 一并转挂新类型才够得上 distinct=1 · 审核先点名意见驳回再通过', + '· 审核先点名意见驳回再通过',
decideSeq: ['custom:使能阈值那条 UNKNOWN 的去向请写明验证任务号与测试条件', 'approve'], /*
* **点名要一个结论**(2026-09-01 用户第二次实测:「重做还是没有修改掉内容」——
* 他写「静态电流结论改成不匹配」,AI 搬出「硬约束不因措辞改判」把他顶了回去)。
* 审核人是这一步的责任人:他点名要什么结论就该落什么。这条意见既点了名(使能阈值)
* 又点了结论(conditional),`expectRedoSetsStatus` 收工时逐字核。
*/
decideSeq: ['custom:使能阈值那条不是判不了,改成 conditional(有条件)—— 补一次实测就能用', 'approve'],
expectRedoSetsStatus: { address: 'substitution.difference_item', nameCol: 'parameter_name', param: '使能阈值', column: 'difference_status', equals: 'conditional' },
/*
* 复核完得在对话区说清「按你的意见改了哪一条、改成什么」(2026-09-01 用户:
* 「需要把 ai 的修改意见通过右边 ai 聊天窗口展示出来,不然都不知道发生了什么」)。
* 那句话必须**排在卡之前**才赶得上人看卡 —— 排在卡后面的 say 要等下一拍才跑。
*/
expectRedoNarration: '按你的意见复核了',
/*
* **放行那张验收卡不许还摆着重做**(2026-09-01 用户:「怎么还有重做??你没改上吗?」)——
* 那一步整个由配方跑完,打回之后没有模型会读那句意见。这个值在链上被静默丢过一次
* (容器那个手写字段清单),所以断言钉在**最末端**:卡真发出去之后它还在不在。
*/
expectReleaseNoRedo: true,
expectDecision: true, expectDecision: true,
expectPort: 'assessment-released', expectPort: 'assessment-released',
// 全套只该有一次模型调用(S2 唯一一次 analyze)—— S3 审核循环走的是纯投影重算,不重问模型 /*
expectAnalyzeCalls: 1, * **两次**:S2 第一轮判 + S3 被打回那一轮的复核重判(2026-09-01 用户定:
* 「重做后要把原因带回去,重新让 AI 重跑这一步」)。这条案例正是「先点名驳回、再通过」——
* 它同时钉住:意见真交回给了 AI(不是只把派生列重算一遍),而没被打回的案例仍然只调一次。
*/
expectAnalyzeCalls: 2,
/* /*
* **真走「新建芯片类型」这条路**——但**显式给 `type_id`,不靠 `autoKeys()` 续号** * **真走「新建芯片类型」这条路**——但**显式给 `type_id`,不靠 `autoKeys()` 续号**
* (2026-08-30,run8 撞见的平台侧发现,已报未改,见任务报告「发现的问题 · 平台侧」): * (2026-08-30,run8 撞见的平台侧发现,已报未改,见任务报告「发现的问题 · 平台侧」):
@@ -660,39 +691,13 @@ const SUITES = {
* `type_id`,只是绕开这一条平台坑去验证后续链路,不代表浏览器里的真人也这样能走通。** * `type_id`,只是绕开这一条平台坑去验证后续链路,不代表浏览器里的真人也这样能走通。**
*/ */
customFill: async (taskId) => { customFill: async (taskId) => {
const typeId = '90'; /* 2026-08-31「只选不建」:两颗料都挂到既有的 type_1(DC-DC)—— 新建类型那条路
// ① 新建芯片类型:显式给 type_id(见上方大注释,不走 autoKeys 续号) (显式 type_id=90 绕全表一致坑的那整套)随功能移除,史见 git 2.9.0 及更早。 */
const typeId = '1';
await api(`/api/desk/tasks/${taskId}/table`, { await api(`/api/desk/tasks/${taskId}/table`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
tables: { tables: {
'parts.chip_type': [
{
type_id: typeId,
type_name: 'DC-DC 降压稳压器(评估自定义)',
description: '本次评估自定义的降压转换器类型,核心参数字段清单沿用原类型口径,供原 PN 与候选料共用比对。',
},
],
},
}),
});
console.log(` 新建类型已保存,显式指定 type_id=${typeId}(绕开全表一致平台坑,已报未改)`);
// ③ 核心参数字段清单(逐字照抄 type_id=1 的字典行,见 seed-mvp-data.ts)+
// 两颗料都转挂新类型(distinct 要求同一个值)+ 候选料的参数值。
// LM2596S-5.0 的既有参数值按同名 lookup 自动带过来,不必在这里重填。
await api(`/api/desk/tasks/${taskId}/table`, {
method: 'POST',
body: JSON.stringify({
tables: {
'parts.type_param': [
{ type_id: typeId, param_name: '输入电压范围', param_desc: '芯片允许的输入直流电压区间。候选料的上限低于应用输入上限就是硬伤,烧的是芯片。', constraint_type: 'hard', param_unit: 'V' },
{ type_id: typeId, param_name: '输出电压', param_desc: '固定版给定值;可调版给可调区间,需外围反馈电阻网络设定。固定换可调要动外围。', constraint_type: 'hard', param_unit: 'V' },
{ type_id: typeId, param_name: '输出电流', param_desc: '满载持续输出能力。低于原料即降额,负载重时会掉压或过热保护。', constraint_type: 'hard', param_unit: 'A' },
{ type_id: typeId, param_name: '开关频率', param_desc: '决定外围电感与输出电容的取值。频率变了外围多半要跟着换,不是换颗料就完。', constraint_type: 'soft', param_unit: 'kHz' },
{ type_id: typeId, param_name: '静态电流', param_desc: '空载待机时自身消耗。电池供电场景敏感,市电场景通常不敏感。', constraint_type: 'soft', param_unit: 'mA' },
{ type_id: typeId, param_name: '工作温度', param_desc: '保证参数指标的环境温度区间。工业温域窄于应用要求就是硬伤。', constraint_type: 'hard', param_unit: '℃' },
{ type_id: typeId, param_name: '使能阈值', param_desc: 'ON/OFF 引脚的开关门限。上游用 MCU 电平直驱时,阈值对不上会关不掉或开不起来。', constraint_type: 'hard', param_unit: 'V' },
],
'parts.profile': [ 'parts.profile': [
{ internal_pn: 'LM2596S-5.0', chip_type_id: typeId }, { internal_pn: 'LM2596S-5.0', chip_type_id: typeId },
{ {
@@ -730,6 +735,16 @@ const SUITES = {
* 这一趟往返没被揉坏——是内容正确性检查,不是归属检查。 * 这一趟往返没被揉坏——是内容正确性检查,不是归属检查。
*/ */
expectFinalValue: { address: 'substitution.relation', where: 'candidate_internal_pn', equals: 'XL2596S-5.0', column: 'recommendation_level', contains: 'conditional' }, expectFinalValue: { address: 'substitution.relation', where: 'candidate_internal_pn', equals: 'XL2596S-5.0', column: 'recommendation_level', contains: 'conditional' },
/*
* **打回只许动被点名的那几条**(2026-09-01 用户实测:「我打回让修改工作温度的结论,
* 给我改了静态电流的结论」)。这条意见点名的是「使能阈值」——
* 交回之前先把明细存一份,收工时逐行比:名字没出现在意见里的行,四态与三段叙述一个字都不许变。
*/
expectRedoTouchesOnlyNamed: {
address: 'substitution.difference_item',
nameCol: 'parameter_name',
cols: ['difference_status', 'impact_statement', 'evidence_note', 'unknown_disposition'],
},
/* /*
* **跨案例隔离**(2026-08-30,协调者裁定①)—— 这一例为了让 `distinct=1` 成立, * **跨案例隔离**(2026-08-30,协调者裁定①)—— 这一例为了让 `distinct=1` 成立,
* 把**原 PN(LM2596S-5.0,案例 1/2 也在用它)**转挂到新建的 type_id=90;这一改 * 把**原 PN(LM2596S-5.0,案例 1/2 也在用它)**转挂到新建的 type_id=90;这一改
@@ -768,17 +783,21 @@ const SUITES = {
await db.query("delete from ledger.validation__record where validation_internal_pn = 'XL2596S-5.0'"); await db.query("delete from ledger.validation__record where validation_internal_pn = 'XL2596S-5.0'");
await db.query("delete from ledger.parts__parameter where internal_pn = 'XL2596S-5.0'"); await db.query("delete from ledger.parts__parameter where internal_pn = 'XL2596S-5.0'");
await db.query("delete from ledger.parts__profile where internal_pn = 'XL2596S-5.0'"); await db.query("delete from ledger.parts__profile where internal_pn = 'XL2596S-5.0'");
await db.query("delete from ledger.parts__type_param where type_id = '90'");
await db.query("delete from ledger.parts__chip_type where type_id = '90'");
}); });
console.log(' 跨案例隔离:LM2596S-5.0 已转回 type_1;这一例全新建的 XL2596S-5.0(候选料本身、评估结果、type_id=90 字典行)已整体清空'); console.log(' 跨案例隔离:XL2596S-5.0(这一例建档的候选料与它的评估结果)已整体清空;LM2596S-5.0 类型兜底归位 type_1');
}, },
}, },
{ {
sample: 1, sample: 1,
name: '都在库 · 两颗同型且齐备 · 零数据卡直接进 S2 分析 · 审核一次通过', name: '都在库 · 两颗同型且齐备 · 派只读的验收工单交人核对(不是数据工单)· 审核一次通过',
// S1 的 doneWhen 起手就成立(两颗都在库、同型、参数不缺)—— 断言一张数据工单都不发 /*
* 2026-08-31 用户定「数据齐了也叫人一次」之前,这一例是「零卡直分析」——
* S1 的 doneWhen 起手就成立,引擎在拍首把这一步收掉,人一眼没看过就进了 S2。
* 现在 S1 标了人工激活:doneWhen 不再抢在人前面,配方改派一张**只读的验收工单**。
* 两条断言一起钉:数据工单一张都不许有(没东西要填)· 验收工单必须出现过(不许再跳过人)。
*/
expectNoDataCard: true, expectNoDataCard: true,
expectAcceptCard: true,
decide: 'approve', decide: 'approve',
expectPort: 'assessment-released', expectPort: 'assessment-released',
expectAnalyzeCalls: 1, expectAnalyzeCalls: 1,
@@ -788,9 +807,9 @@ const SUITES = {
name: '两颗都在库但分属不同芯片类型 · 硬拦(填别的格子绕不过去,只有改类型才行)· 审核通过', name: '两颗都在库但分属不同芯片类型 · 硬拦(填别的格子绕不过去,只有改类型才行)· 审核通过',
// gate_reason 要点名「不是同一个芯片类型」——数据卡一出现就查这句话,latch 住 // gate_reason 要点名「不是同一个芯片类型」——数据卡一出现就查这句话,latch 住
expectGateReason: /芯片类型/, expectGateReason: /芯片类型/,
// 唯一的修复是把其中一颗转挂到另一颗的类型上;缺的那一个新必填参数一并补上, // 唯一的修复是把其中一颗转挂到另一颗的类型上;缺的那一个新必填参数一并补上。
// 不改类型光填别的格子过不去(S1 的 doneWhen/distinct 与提交闸的必填检查不是同一件事—— // 2026-08-31 起确认闸多了条件档(gateWhen 钉 parts_profile_distinct_chip_type_id):
// 提交闸不查 distinct,这条路真正的硬拦在 S2 的入场哨兵,详见任务报告)。 // 类型不一致时确认直接 400 —— 上面的空手交卷探针吃到的就是它;修好类型再确认才收。
stageEdits: { stageEdits: {
'parts.profile': [{ internal_pn: 'LM2596S-5.0', chip_type_id: '2' }], 'parts.profile': [{ internal_pn: 'LM2596S-5.0', chip_type_id: '2' }],
// 转去 LDO 类型后,LM2596S-5.0 多出一条它从没记过的必填参数「压差」 // 转去 LDO 类型后,LM2596S-5.0 多出一条它从没记过的必填参数「压差」
@@ -1328,6 +1347,9 @@ async function runCase(suite, kase) {
let fileCheck; let fileCheck;
let gateLeaked = false; let gateLeaked = false;
const gateChecked = new Set(); const gateChecked = new Set();
/** 确认闸的条件档(gateWhen)没跟到工单载荷上 —— 类型不一致将拦不住(末端断言,见 w05 数据卡阶段) */
let gateWhenMissing = false;
let gateWhenChecked = false;
let decisions = 0; let decisions = 0;
missedRows.clear(); missedRows.clear();
asked.length = 0; asked.length = 0;
@@ -1352,8 +1374,16 @@ async function runCase(suite, kase) {
let bailed = false; let bailed = false;
let receiptChecked = false; let receiptChecked = false;
let receiptMismatch = false; let receiptMismatch = false;
/** w05「都在库·零数据卡直分析」:任意一张匹配 stages 的执行卡出现过(不论此刻答没答)就算见过 */ /** w05:匹配**数据工单**那一段的执行卡出现过(不论此刻答没答)就算见过 */
let dataCardSeen = false; let dataCardSeen = false;
/** w05「都在库」:匹配**验收工单**那一段的执行卡出现过 —— 数据齐了也要叫人一次 */
let acceptCardSeen = false;
/** 打回之前的明细快照(`expectRedoTouchesOnlyNamed`)—— 只存第一份 */
let redoBefore;
/** 放行前的最后一份明细快照 —— 放行会清空草稿层,收工时取不到 */
let redoAfter;
/** 那张卡的**面孔**不对(标题是验收工单,发出去却是执行工单)—— 2026-08-31 真撞过一次 */
let acceptFaceWrong = false;
/** w05「类型不一致·硬拦」:`sub_eval.case.gate_reason` 命中过 `kase.expectGateReason` 那个词 */ /** w05「类型不一致·硬拦」:`sub_eval.case.gate_reason` 命中过 `kase.expectGateReason` 那个词 */
let gateReasonMatched = false; let gateReasonMatched = false;
/** `kase.customFill` 只跑一次(w05「候选缺库·新建类型」:两阶段写 —— 先铸 type_id,读回来再引用它) */ /** `kase.customFill` 只跑一次(w05「候选缺库·新建类型」:两阶段写 —— 先铸 type_id,读回来再引用它) */
@@ -1435,7 +1465,23 @@ async function runCase(suite, kase) {
} }
// w05「都在库·零数据卡直分析」:S1 的 doneWhen 起手就成立,断言一张数据工单都没出现过 // w05「都在库·零数据卡直分析」:S1 的 doneWhen 起手就成立,断言一张数据工单都没出现过
if (kase.expectNoDataCard && !dataCardSeen) { if (kase.expectNoDataCard && !dataCardSeen) {
dataCardSeen = (body.cards ?? []).some((c) => (suite.stages ?? []).some((s) => s.match.test(c.title ?? ''))); dataCardSeen = (body.cards ?? []).some((c) => (suite.stages ?? []).some((s) => s.kind !== 'accept' && s.match.test(c.title ?? '')));
}
if (kase.expectAcceptCard && !acceptCardSeen) {
/*
* **认面孔,不认标题**(2026-08-31 用户实测:「你怎么给我发成了执行工单」)——
* 标题是活动自己写的字符串,卡穿的是哪副面孔由装配层决定,两者可以对不上:
* 那一次配方递了 `accept`、标题也叫「验收工单 · …」,而装配层把 variant 丢了,
* 人收到的是一张执行工单 —— 按标题驱动的这套脚本当时照样全绿。
*/
const seen = (body.cards ?? []).find((c) => (suite.stages ?? []).some((s) => s.kind === 'accept' && s.match.test(c.title ?? '')));
if (seen !== undefined) {
acceptCardSeen = true;
if (seen.variant !== 'accept') {
console.log(` ⚠️ 这张卡的标题是验收工单,发出去穿的却是「${seen.variant ?? '执行工单'}」的面孔 —— 判失败`);
acceptFaceWrong = true;
}
}
} }
// w05「类型不一致·硬拦」:gate_reason 那句话要点了名(latch 住,答完卡草稿就清了,过了这一拍再查就晚了) // w05「类型不一致·硬拦」:gate_reason 那句话要点了名(latch 住,答完卡草稿就清了,过了这一拍再查就晚了)
if (kase.expectGateReason && !gateReasonMatched) { if (kase.expectGateReason && !gateReasonMatched) {
@@ -1443,6 +1489,19 @@ async function runCase(suite, kase) {
gateReasonMatched = caseRow !== undefined && kase.expectGateReason.test(String(caseRow['gate_reason'] ?? '')); gateReasonMatched = caseRow !== undefined && kase.expectGateReason.test(String(caseRow['gate_reason'] ?? ''));
} }
/*
* 打回之前把明细存一份(2026-09-01)—— 收工时拿它逐行比「没点名的行有没有被动过」。
* 存在**答卡之前**:答完那一拍配方就重判了,再存就晚了。
*/
if (kase.expectRedoTouchesOnlyNamed !== undefined) {
const rows = draftRows(body.drafts ?? [], kase.expectRedoTouchesOnlyNamed.address);
/* 第一份 = 打回之前(S2 刚判完那一版);此后每轮覆盖,**留住放行前的最后一份** ——
放行会把草稿并进正式表并清空,收工时再取就什么都没有了(实测踩到) */
if (rows.length > 0) {
if (redoBefore === undefined) redoBefore = rows.map((r) => ({ ...r }));
redoAfter = rows.map((r) => ({ ...r }));
}
}
// 决策卡挂在对话区,不在这一摞里 —— 每轮顺手看一眼有没有等着人点的 // 决策卡挂在对话区,不在这一摞里 —— 每轮顺手看一眼有没有等着人点的
decisions += await answerDecisions(taskId, kase, decided); decisions += await answerDecisions(taskId, kase, decided);
await answerEntries(taskId, kase, entered); await answerEntries(taskId, kase, entered);
@@ -1491,6 +1550,15 @@ async function runCase(suite, kase) {
: ` ⚠️ 缺着必填还放过去了:提交闸失效`, : ` ⚠️ 缺着必填还放过去了:提交闸失效`,
); );
} }
if (stage.expectCardGateWhen === true && !gateWhenChecked) {
gateWhenChecked = true;
const { body: det } = await api(`/api/desk/tasks/${taskId}`);
const wo = (det?.workOrders ?? []).find((o) => o.id === card.id);
if (wo?.gateWhen === undefined) {
gateWhenMissing = true;
console.log(' ⚠️ 确认闸的条件档(gateWhen)没跟到工单载荷上 —— 类型不一致将拦不住,判失败');
} else console.log(' 确认闸条件已随卡落库(gateWhen 在载荷上)');
}
/* /*
* **两阶段自定义填表**(w05「候选缺库·新建类型」专用)—— 在空手交卷的闸测过**之后**、 * **两阶段自定义填表**(w05「候选缺库·新建类型」专用)—— 在空手交卷的闸测过**之后**、
* 常规 `buildEdits` 之前跑一次:先铸 `parts.chip_type` 的自增 `type_id`,读回来才知道 * 常规 `buildEdits` 之前跑一次:先铸 `parts.chip_type` 的自增 `type_id`,读回来才知道
@@ -1626,6 +1694,10 @@ async function runCase(suite, kase) {
console.log(' ⚠️ 数据工单确认即入库那道门没兑现 —— 判失败'); console.log(' ⚠️ 数据工单确认即入库那道门没兑现 —— 判失败');
ok = false; ok = false;
} }
if (gateWhenMissing) {
console.log(' ⚠️ 确认闸条件(gateWhen)被链上丢掉 —— 判失败');
ok = false;
}
// 「超限才叫人」是 A4 的要害:该叫的那一单没叫,等于底价门禁是摆设 // 「超限才叫人」是 A4 的要害:该叫的那一单没叫,等于底价门禁是摆设
if (missedRows.size > 0) { if (missedRows.size > 0) {
console.log(` ⚠️ 案例点名要填的行,整例跑完也没在件上出现过(${missedRows.size} 处:${[...missedRows].slice(0, 3).join('、')}…)—— 判失败`); console.log(` ⚠️ 案例点名要填的行,整例跑完也没在件上出现过(${missedRows.size} 处:${[...missedRows].slice(0, 3).join('、')}…)—— 判失败`);
@@ -1672,9 +1744,67 @@ async function runCase(suite, kase) {
ok = false; ok = false;
} }
if (kase.expectNoDataCard && dataCardSeen) { if (kase.expectNoDataCard && dataCardSeen) {
console.log(' ⚠️ 这一例本该是零数据卡直分析,却见到了一张数据工单 —— 判失败'); console.log(' ⚠️ 这一例没有要补的数据,却派出了一张数据工单 —— 判失败');
ok = false; ok = false;
} }
if (kase.expectReleaseNoRedo === true) {
const acc = (last?.cards ?? []).filter((c) => c.mode === 'acceptance');
const bad = acc.filter((c) => c.redoable !== false);
if (acc.length === 0) {
console.log(' ⚠️ 一张放行验收卡都没见到,「不摆重做」这条没验成 —— 判失败');
ok = false;
} else if (bad.length > 0) {
console.log(` ⚠️ 放行验收卡上还摆着重做(redoable=${String(bad[0].redoable)})—— 判失败(这个值又在链上被丢了?)`);
ok = false;
} else console.log(' 放行验收卡不摆重做(redoable=false 一路到了卡上)');
}
if (kase.expectRedoNarration !== undefined) {
const said = (last?.trail ?? []).some((e) => String(e.title ?? '').includes(kase.expectRedoNarration));
if (said) console.log(` 复核那一轮在对话区说了话(「${kase.expectRedoNarration}…」)`);
else {
console.log(` ⚠️ 复核完对话区一个字没说(找不到「${kase.expectRedoNarration}」)—— 人不知道发生了什么,判失败`);
ok = false;
}
}
if (kase.expectRedoSetsStatus !== undefined) {
const { address, nameCol, param, column, equals } = kase.expectRedoSetsStatus;
const row = (redoAfter ?? draftRows(last?.drafts ?? [], address)).find((r) => String(r[nameCol] ?? '') === param);
const got = String(row?.[column] ?? '');
if (got === equals) console.log(` 打回点名要的结论真落下去了(${param}.${column} = ${equals})`);
else {
console.log(` ⚠️ 打回点名要「${equals}」,${param}.${column} 却是「${got || '(没取到)'}」—— 判失败(AI 又把审核人顶回去了?)`);
ok = false;
}
}
if (kase.expectRedoTouchesOnlyNamed !== undefined) {
/* `address` 在轮询那一头取快照时用过,这儿只比对已经存下来的两份 */
const { nameCol, cols } = kase.expectRedoTouchesOnlyNamed;
const note = (kase.decideSeq ?? []).find((d) => String(d).startsWith('custom:')) ?? '';
const after = redoAfter ?? [];
if (redoBefore === undefined || redoBefore.length === 0 || after.length === 0) {
console.log(' ⚠️ 打回前后的明细没取到,「只动被点名的行」这条没验成 —— 判失败');
ok = false;
} else {
const moved = [];
for (const row of after) {
const name = String(row[nameCol] ?? '');
// 被点名的那几行本来就该变
if (name === '' || note.includes(name)) continue;
const was = redoBefore.find((r) => String(r[nameCol] ?? '') === name);
if (was === undefined) continue;
for (const col of cols) if (String(was[col] ?? '') !== String(row[col] ?? '')) moved.push(`${name}.${col}`);
}
if (moved.length > 0) {
console.log(` ⚠️ 打回只点了名的那几条,没点名的行却被改了:${moved.join('、')} —— 判失败`);
ok = false;
} else console.log(' 打回只动了被点名的那几条,其余行一个字没变');
}
}
if (kase.expectAcceptCard && !acceptCardSeen) {
console.log(' ⚠️ 数据齐了也该叫人一次(只读的验收工单),一张都没见到 —— 判失败(多半是 S1 又被拍首的 doneWhen 扫描收掉了)');
ok = false;
}
if (acceptFaceWrong) ok = false;
if (kase.expectGateReason && !gateReasonMatched) { if (kase.expectGateReason && !gateReasonMatched) {
console.log(` ⚠️ gate_reason 没点名(期望命中 ${kase.expectGateReason})—— 判失败`); console.log(` ⚠️ gate_reason 没点名(期望命中 ${kase.expectGateReason})—— 判失败`);
ok = false; ok = false;