diff --git a/CLAUDE.md b/CLAUDE.md index a37ddf6..334113f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2026,6 +2026,22 @@ **副产**: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` 把终态 diff --git a/apps/api/src/app/__tests__/card-condition-gate.test.ts b/apps/api/src/app/__tests__/card-condition-gate.test.ts new file mode 100644 index 0000000..2cd4c69 --- /dev/null +++ b/apps/api/src/app/__tests__/card-condition-gate.test.ts @@ -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' }); + }); +}); diff --git a/apps/api/src/app/wiring/engines.ts b/apps/api/src/app/wiring/engines.ts index 3e929c9..7504c17 100644 --- a/apps/api/src/app/wiring/engines.ts +++ b/apps/api/src/app/wiring/engines.ts @@ -279,6 +279,24 @@ export function executionCardVariant(fromCard: unknown, fromStep: unknown): 'dat return undefined; } +/** + * **确认闸的条件档从卡骨架到工单载荷**(2026-09-01 用户实测:「两个芯片类型不一样… + * 右边派发的卡片还可以点击下一步」)。配方声明了 `gateWhen`/`gateWhenSay`,发卡装配的 + * 手写清单没点它们的名 —— 落库的工单载荷里两个字段是 undefined,按钮禁用与服务端拒收 + * 读的都是它,一起无声失效(**手写字段清单第四次丢值**,且丢在 `variant` 上次被丢的同一段)。 + * 这个缝取的是模型也会路过的不可信卡骨架,不能整包 spread —— 校验着转交: + * 条件必须是非空字符串;那句话只在有条件时才有意义(评估端只认 `when`)。 + */ +export function cardConditionGate(card: Readonly>): { gateWhen?: string; gateWhenSay?: string } { + const when = card['gateWhen']; + if (typeof when !== 'string' || when.trim() === '') return {}; + const say = card['gateWhenSay']; + return { + gateWhen: when, + ...(typeof say === 'string' && say.trim() !== '' ? { gateWhenSay: say } : {}), + }; +} + export function decisionCardFace( input: { readonly variant?: 'review' | undefined; readonly checklist?: ReadonlyArray | undefined }, step: { readonly cardVariant?: string | undefined; readonly acceptanceChecklist?: ReadonlyArray | undefined } | undefined, @@ -1974,6 +1992,8 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { ...(blocks === undefined ? {} : { blocks: blocks as ExecutionWorkOrderDraft['blocks'] }), ...(gateLedgers.length === 0 ? {} : { gateLedgers }), ...(variant === undefined ? {} : { variant }), + /* 条件档(gateWhen/gateWhenSay)—— 校验着转交,见 cardConditionGate 的账 */ + ...cardConditionGate(input.card), }); // 打扰率的计量对象:每发一张卡就是调了一次人 —— 不计就等于没有上限 await taskStore.addConsumption(input.taskId, { humanCalls: 1 }); diff --git a/tools/e2e-a2.mjs b/tools/e2e-a2.mjs index da3f4de..fa5d10a 100644 --- a/tools/e2e-a2.mjs +++ b/tools/e2e-a2.mjs @@ -615,6 +615,13 @@ const SUITES = { rowKey: 'internal_pn', blankCol: 'brand_name', expectGateBlock: true, + /* + * **确认闸的条件档必须跟到工单载荷上**(2026-09-01 用户实测:「两个芯片类型不一样… + * 卡片还可以点击下一步」)。配方声明了 `gateWhen`,发卡装配的手写清单没点它的名 —— + * 落库载荷里是 undefined,按钮禁用与服务端拒收一起无声失效(手写字段清单第四次丢值, + * 与 variant 上次被丢的是同一段)。这类丢值只有末端断言拦得住:卡一到手就查载荷。 + */ + expectCardGateWhen: true, edits: [], extraTables: [ /* @@ -1340,6 +1347,9 @@ async function runCase(suite, kase) { let fileCheck; let gateLeaked = false; const gateChecked = new Set(); + /** 确认闸的条件档(gateWhen)没跟到工单载荷上 —— 类型不一致将拦不住(末端断言,见 w05 数据卡阶段) */ + let gateWhenMissing = false; + let gateWhenChecked = false; let decisions = 0; missedRows.clear(); asked.length = 0; @@ -1540,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「候选缺库·新建类型」专用)—— 在空手交卷的闸测过**之后**、 * 常规 `buildEdits` 之前跑一次:先铸 `parts.chip_type` 的自增 `type_id`,读回来才知道 @@ -1675,6 +1694,10 @@ async function runCase(suite, kase) { console.log(' ⚠️ 数据工单确认即入库那道门没兑现 —— 判失败'); ok = false; } + if (gateWhenMissing) { + console.log(' ⚠️ 确认闸条件(gateWhen)被链上丢掉 —— 判失败'); + ok = false; + } // 「超限才叫人」是 A4 的要害:该叫的那一单没叫,等于底价门禁是摆设 if (missedRows.size > 0) { console.log(` ⚠️ 案例点名要填的行,整例跑完也没在件上出现过(${missedRows.size} 处:${[...missedRows].slice(0, 3).join('、')}…)—— 判失败`);