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
This commit is contained in:
xcs
2026-08-31 22:44:00 +08:00
co-authored by Claude Fable 5
parent e47ce92702
commit ce72da77eb
21 changed files with 492 additions and 66 deletions
+32
View File
@@ -1834,6 +1834,38 @@
.dossiers 三列网格(1fr·76px·1fr),中间列是实体动画道 —— ⇄ 徽呼吸 + 双点绕徽轨道 .dossiers 三列网格(1fr·76px·1fr),中间列是实体动画道 —— ⇄ 徽呼吸 + 双点绕徽轨道
对转(sw-orbit),总线虚线在道里连两卡;窄屏单列收道。浏览器两帧相位实证。 对转(sw-orbit),总线虚线在道里连两卡;窄屏单列收道。浏览器两帧相位实证。
- **起单不许选到同一颗料;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 起单
落的主数据),那一例实际走的也是验收工单那条路 —— 名字与实测不符,清库存那件事记在这儿。
- **W05 红条可灭:gate_state 开关列**(2026-08-31 用户:「没有必填了红色区域就不要显示」, - **W05 红条可灭:gate_state 开关列**(2026-08-31 用户:「没有必填了红色区域就不要显示」,
`part-substitution-assessment@2.13.0`):病根 —— gate_reason 清空串按列归型不落笔 `part-substitution-assessment@2.13.0`):病根 —— gate_reason 清空串按列归型不落笔
(jsonb 保旧值),红条亮过灭不了。治:案卷加 `gate_state`(tally open/clear,双向真写), (jsonb 保旧值),红条亮过灭不了。治:案卷加 `gate_state`(tally open/clear,双向真写),
@@ -843,6 +843,27 @@ describe('件① 评估输入补全', () => {
expect(colOf('sub_eval.case', 'gate_state')?.tally).toEqual(['open', 'clear']); 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']);
});
it('缺项点名全并进顶条「还差什么」,正文不再有逐块红带', () => { it('缺项点名全并进顶条「还差什么」,正文不再有逐块红带', () => {
expect(html).toContain('class="jc-gate-sub" data-class-of="parts_profile_missing"'); 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_profile_missing_keys"');
@@ -146,13 +146,47 @@ 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', '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.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:` 这两张表不再天然为零行,
@@ -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 四态,依规则算出推荐等级与风险。产出:替代关系一行 + 差异明细逐参数一行 +
@@ -341,7 +352,7 @@ const GATE_OPEN_WHEN =
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 /
@@ -501,6 +512,22 @@ export const ASSESSMENT_STEPS = [
* 这面开关两个值都非空、两个方向都真写 —— 件上按它收红条,与发卡条件同一句判定。 * 这面开关两个值都非空、两个方向都真写 —— 件上按它收红条,与发卡条件同一句判定。
*/ */
gate_state: { when: GATE_OPEN_WHEN, then: 'open', else: 'clear' }, 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: '原料号与候选料号填成了同一个 —— 没法比出差异,请终止本单,换两个不同的料号重开。',
@@ -564,6 +591,37 @@ export const ASSESSMENT_STEPS = [
gateWhen: 'parts_profile_distinct_chip_type_id <= 1', gateWhen: 'parts_profile_distinct_chip_type_id <= 1',
gateWhenSay: '两颗料不是同一个芯片类型 —— 逐项比对无从谈起:换一颗料,或把归错的那颗类型改对,再来确认。', 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: '已核对两颗料的在库状态与参数齐备情况。' },
], ],
}, },
@@ -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.14.0', packageVersion: '2.15.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',
@@ -459,6 +459,13 @@ export const manifest: ActivityManifest = {
* 两个方向都写得动;件上 data-class-of 投影 v-clear 收红条,判断留在样式表。 * 两个方向都写得动;件上 data-class-of 投影 v-clear 收红条,判断留在样式表。
*/ */
col({ name: 'gate_state', type: 'text', label: '入参闸门状态', tally: ['open', '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: '结论摘要' }),
], ],
}, },
@@ -109,9 +109,26 @@ const gateBar = (): string => `<div class="jc-gate-bar" data-class-of="gate_stat
* 收件人 / 关联任务两格由引擎注入;工单编号即本次评估案号 `eval_ref`(页脚)。 * 收件人 / 关联任务两格由引擎注入;工单编号即本次评估案号 `eval_ref`(页脚)。
* `stage` 是每张件自己的 hero 场景(SVG 舞台),`accent` 决定这张件的强调色。 * `stage` 是每张件自己的 hero 场景(SVG 舞台),`accent` 决定这张件的强调色。
*/ */
const doc = (input: { accent: string; alias: string; tag: string; kicker: string; title: string; step: string; stage: string; body: string; footTag: string; headTone?: 'light' }): string => const doc = (input: {
accent: string;
alias: string;
tag: string;
kicker: string;
title: string;
step: string;
stage: string;
body: string;
footTag: string;
headTone?: 'light';
/**
* **这份件按哪一列换脸**(2026-08-31):列名给进来,渲染时投成 `v-<值>` 挂在最外层 `.card` 上,
* 件里凡是要随之变的(题头四字 / 标题 / 卡角编辑钮)都由样式表按这个类挑 —— 判断仍留在样式表,
* 模版不算不判。S1 那张件用它区分「数据工单(有格子要填)」与「验收工单(只读)」。
*/
kindOf?: string;
}): string =>
`<style>${SHELL_CSS}${input.accent}${input.alias}</style> `<style>${SHELL_CSS}${input.accent}${input.alias}</style>
<div class="page"><div class="card${input.headTone === 'light' ? ' hd-light' : ''}"> <div class="page"><div class="card${input.headTone === 'light' ? ' hd-light' : ''}"${input.kindOf === undefined ? '' : ` data-class-of="${input.kindOf}"`}>
<header class="card-head"> <header class="card-head">
<div class="hero-top"> <div class="hero-top">
<span class="hero-sys">${input.kicker}</span> <span class="hero-sys">${input.kicker}</span>
@@ -175,6 +192,19 @@ ${badgeCss('.rl', [
['original', '原内部料号'], ['original', '原内部料号'],
['candidate', '候选内部料号'], ['candidate', '候选内部料号'],
])} ])}
/*
* **一张件两副面孔**(2026-08-31 用户定)—— 最外层 .card 上由 data-class-of=order_kind
* 投出 v-data / v-accept,这里只管挑:
* · 题头 / 标题 / 页脚三处的四字换名(.k-data ↔ .k-acc);
* · **验收工单把卡角那组编辑钮整组收起** —— 藏的是容器 .fabs,不是逐枚钮:
* 那几枚各自的 display 由 html.editable 开头那一串更特异的规则给着,逐枚去盖是打不完的;
* 父容器一藏,里面的按钮连渲染都不渲染,件就此只读(人点不开编辑,孪生件也就不现身)。
* 旗还没写过(老单 / 第一帧)时两个类都不在 → 落回默认的「数据工单」那一副,与从前一模一样。
*/
.k-acc { display: none; }
.v-accept .k-data { display: none; }
.v-accept .k-acc { display: inline; }
.v-accept .fabs { display: none; }
`; `;
@@ -293,28 +323,35 @@ const BASIS_CARD = `<article class="basis" data-rows="mine:${D}.case"><section c
export const INPUT_SUPPLEMENT = doc({ export const INPUT_SUPPLEMENT = doc({
accent: ACCENT_DATA, accent: ACCENT_DATA,
alias: ALIAS_INPUT, alias: ALIAS_INPUT,
tag: '数据工单', /*
* **一张件两副面孔**(2026-08-31 用户定:「就算数据是足的也进入这个数据工单,就派发一个验收工单,
* 界面和数据要和现在的第一个数据工单一致,只不过把芯片卡片上的编辑按钮去掉…标题旁边的数据工单
* 改成验收工单」)—— 数据与画面一个字不改,只有这三处名字与卡角那组编辑钮随 `order_kind` 换。
* 谁是哪一副由配方在派卡那一刻定死(粘着的旗),件只负责摆出来。
*/
kindOf: 'order_kind',
tag: '<span class="k-data">数据工单</span><span class="k-acc">验收工单</span>',
kicker: 'SS02 · ACT-WORK-05 差异评估', kicker: 'SS02 · ACT-WORK-05 差异评估',
title: '差异评估的输入还差几格', title: '<span class="k-data">差异评估的输入还差几格</span><span class="k-acc">差异评估的输入核对</span>',
step: '步骤 1/4 · 校验入参与补数据', step: '步骤 1/4 · 校验入参与补数据',
stage: '', stage: '',
headTone: 'light', headTone: 'light',
footTag: '工单类型:数据', footTag: '工单类型:<span class="k-data">数据</span><span class="k-acc">验收</span>',
body: body:
gateBar() + gateBar() +
`<details class="lab lab-brief"><summary class="lab-sm"><b>【做什么】</b>核对两颗料在不在库、归没归同一个芯片类型、参数值齐不齐 —— 展开看说明与纪律<span class="lab-caret"></span></summary>` + `<details class="lab lab-brief"><summary class="lab-sm"><b>【做什么】</b>核对两颗料在不在库、归没归同一个芯片类型、参数值齐不齐 —— 展开看说明与纪律<span class="lab-caret"></span></summary>` +
note([ note([
'<span class="need">模板定位</span>:本单是「数据工单」——目标台账 = 芯片主数据(P-08 主记录 + 标准化参数子表),核心参数字段清单挂在芯片类型字典上。', '<span class="need">模板定位</span>:目标台账 = 芯片主数据(P-08 主记录 + 标准化参数子表),核心参数字段清单挂在芯片类型字典上。',
'<span class="need">触发原因</span>:下面电路板上两张档案卡就是本次要比对的原 PN 与候选 PN —— 至少有一颗不在库、两颗归的类型不一致、或必填格还空着,顶部那条红带才会出现。', '<span class="need">两副面孔</span>:资料还差东西 → 这是**数据工单**,顶部红带点名缺什么,卡角的笔可就地补;三样都齐了 → 这是**验收工单**,只读,交你核对无误后确认。',
'<span class="done">已绑定</span>:两颗料的内部料号已按启动参数铺上档案卡,不必你重填料号本身,只需核对 / 补齐其余各格;在库的料现值已回填,只是查漏补缺。', '<span class="done">已绑定</span>:两颗料的内部料号已按启动参数铺上档案卡,不必你重填料号本身,只需核对 / 补齐其余各格;在库的料现值已回填,只是查漏补缺。',
'<span class="need">纪律</span>:两颗料必须归到同一个芯片类型下才谈得上逐项比对;类型只从字典里选,本活动不新增类型 —— 字典里没有合适的,先请数据管理员补进字典;查不到的参数值留空(会被红带点名),不要编一个值顶替。', '<span class="need">纪律</span>:两颗料必须归到同一个芯片类型下才谈得上逐项比对;类型只从字典里选,本活动不新增类型 —— 字典里没有合适的,先请数据管理员补进字典;查不到的参数值留空(会被红带点名),不要编一个值顶替。',
]) + ]) +
`</details>` + `</details>` +
zone('比对基准', '芯片类型 + 它的核心参数字段 —— 两颗料归到哪个类型,就按这些字段逐项比;字段说明悬浮即看', ZIC_TABLE) + zone('比对基准', '芯片类型 + 它的核心参数字段 —— 两颗料归到哪个类型,就按这些字段逐项比;字段说明悬浮即看', ZIC_TABLE) +
BASIS_CARD + BASIS_CARD +
zone('芯片档案', '一颗芯片一张卡:正面主数据、背面核心参数值 —— 点卡翻面看另一面;右上角的笔 = 只编辑这张卡当前这一面,软盘 = 保存', ZIC_TABLE) + zone('芯片档案', '一颗芯片一张卡:正面主数据、背面核心参数值 —— 点卡翻面看另一面;可编辑时右上角的笔 = 只编辑当前这一面,软盘 = 保存', ZIC_TABLE) +
DOSSIERS + DOSSIERS +
flow('<b>提交 / 确认:</b>本工单只承载内容;确认请在对话区的【执行卡】中进行。确认后活动自动从「等待数据」唤醒。'), flow('<b>提交 / 确认:</b>本工单只承载内容;确认请在对话区那张卡上进行。确认后活动接着往下推进。'),
}); });
/* ==================== 局面:差异评估结论审核(difference-review,S3 的验收工单) ==================== */ /* ==================== 局面:差异评估结论审核(difference-review,S3 的验收工单) ==================== */
+4
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>;
@@ -1253,6 +1254,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>;
@@ -2588,6 +2590,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>;
@@ -3181,6 +3184,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>;
+5 -1
View File
@@ -104,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;
+2
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>;
@@ -1237,6 +1238,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>;
@@ -39,5 +39,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` / 绑的表并进正式表 —— 本模块只管回传本身
@@ -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'` = 审核卡摆成验收工单的样子,机制仍是决策卡);
@@ -425,6 +425,35 @@ describe('card 与 submit', () => {
}); });
}); });
/*
* **验收工单 = 执行工单的第三副面孔**(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(
@@ -2253,6 +2253,48 @@ 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');
});
it('有卡在等人交回:配方照样写,但一张新卡都不发', async () => { it('有卡在等人交回:配方照样写,但一张新卡都不发', async () => {
const h = harness({ const h = harness({
parsed: AUTO_MODEL, parsed: AUTO_MODEL,
@@ -367,7 +367,8 @@ 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] }),
@@ -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: '执行' });
});
});
@@ -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 ?? '';
@@ -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('工单 · ', '') };
}
+12 -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` 写空串
+6 -2
View File
@@ -128,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;
@@ -177,7 +181,7 @@ 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(),
+7 -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(),
+34 -5
View File
@@ -593,9 +593,23 @@ 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',
@@ -735,9 +749,15 @@ const SUITES = {
}, },
{ {
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,
@@ -1311,8 +1331,10 @@ 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;
/** 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,读回来再引用它) */
@@ -1394,7 +1416,10 @@ 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) {
acceptCardSeen = (body.cards ?? []).some((c) => (suite.stages ?? []).some((s) => s.kind === 'accept' && s.match.test(c.title ?? '')));
} }
// w05「类型不一致·硬拦」:gate_reason 那句话要点了名(latch 住,答完卡草稿就清了,过了这一拍再查就晚了) // w05「类型不一致·硬拦」:gate_reason 那句话要点了名(latch 住,答完卡草稿就清了,过了这一拍再查就晚了)
if (kase.expectGateReason && !gateReasonMatched) { if (kase.expectGateReason && !gateReasonMatched) {
@@ -1631,7 +1656,11 @@ async function runCase(suite, kase) {
ok = false; ok = false;
} }
if (kase.expectNoDataCard && dataCardSeen) { if (kase.expectNoDataCard && dataCardSeen) {
console.log(' ⚠️ 这一例本该是零数据卡直分析,却见到了一张数据工单 —— 判失败'); console.log(' ⚠️ 这一例没有要补的数据,却派出了一张数据工单 —— 判失败');
ok = false;
}
if (kase.expectAcceptCard && !acceptCardSeen) {
console.log(' ⚠️ 数据齐了也该叫人一次(只读的验收工单),一张都没见到 —— 判失败(多半是 S1 又被拍首的 doneWhen 扫描收掉了)');
ok = false; ok = false;
} }
if (kase.expectGateReason && !gateReasonMatched) { if (kase.expectGateReason && !gateReasonMatched) {