diff --git a/CLAUDE.md b/CLAUDE.md index 0e1dddf..7568ee8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1749,6 +1749,28 @@ 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 为空)。 + ## 文档权威地图 | 层 | 文件 | 说明 | diff --git a/activities/part-substitution-assessment/__tests__/activity.test.ts b/activities/part-substitution-assessment/__tests__/activity.test.ts index de66897..7207473 100644 --- a/activities/part-substitution-assessment/__tests__/activity.test.ts +++ b/activities/part-substitution-assessment/__tests__/activity.test.ts @@ -548,13 +548,46 @@ describe('件① 评估输入补全', () => { expect(steps.find((s) => s.item === 'S1_inputs')?.view).toBe('input-supplement'); }); - it('四块都在,行都绑本单草稿(不是整表)', () => { + it('四块都在,行都绑本单草稿(不是整表);档案卡行源是 parts.profile 本身', () => { 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.chip_type"'); expect(html).toContain('data-rows="mine:parts.type_param"'); }); + /* + * 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>)[\s\S])*?)<\/span>/g)]; + expect(nested.length).toBeGreaterThanOrEqual(2); + 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('); + }); + + it('三级占位:必填红 / 建议橘 —— 空格子直说要不要填(档案卡与参数值表两处)', () => { + expect(html).toContain('.fcell.req .jc-vcell:empty::after{content:"必填"'); + expect(html).toContain('.fcell.sug .jc-vcell:empty::after{content:"建议"'); + expect(html).toContain('td.pv .jc-vcell:empty::after{content:"必填"'); + expect(html).toContain('td.sg .jc-vcell:empty::after{content:"建议"'); + }); + it('芯片类型是从台账现读的下拉 —— 写死等于「只能选出包那天就有的那几种」(块①的 chip_type_id 与块②b 的 type_id 都是)', () => { expect(html).toContain('data-options="parts.chip_type"'); expect(html).toContain('data-option-value="type_id"'); @@ -626,7 +659,7 @@ describe('件① 评估输入补全', () => { expect(html).toContain('data-col="param_name"'); }); - it('可改的四张表各有一道编辑闸', () => { + it('可改的三张表 + 档案卡各有一道编辑闸', () => { for (const address of ['parts.profile', 'parts.chip_type', 'parts.type_param', 'parts.parameter']) { expect(html).toContain(`data-edit="${address}"`); } @@ -711,14 +744,14 @@ describe('件① 评估输入补全', () => { * 只读态与编辑态下「#」列逐行都是空白。钉死 `.jc-tbl-wrap` 现在也接了这五条规则, * 且这张件确实只用 `.jc-tbl-wrap`(没有表在用没打这条补丁就会数不出来的 `.tbl-wrap`)。 */ - it('四张表的序号列真的会数:.jc-tbl-wrap 也接了 CSS 计数器(不再只认 .tbl-wrap)', () => { + it('三张表的序号列真的会数:.jc-tbl-wrap 也接了 CSS 计数器(不再只认 .tbl-wrap)', () => { expect(html).toMatch(/\.jc-tbl-wrap\s+table\s*,\s*\.tbl-wrap\s+table\s*\{\s*counter-reset:\s*rn;?\s*\}/); 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*\}/); 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*\}/); - // 这张件的四张表都走 .jc-tbl-wrap(没有 .tbl-wrap)—— 补丁真的接在会用到的类上(Task 7b 新增 parts.chip_type 这一张,3→4) + // 这张件的三张表都走 .jc-tbl-wrap(没有 .tbl-wrap)—— 2026-08-31 块① 换成档案卡(不是表),4→3 expect(html).not.toContain('class="tbl-wrap"'); const jcTblWrapCount = html.match(/class="jc-tbl-wrap"/g)?.length ?? 0; - expect(jcTblWrapCount).toBe(4); + expect(jcTblWrapCount).toBe(3); }); }); diff --git a/activities/part-substitution-assessment/__tests__/steps.test.ts b/activities/part-substitution-assessment/__tests__/steps.test.ts index 034019b..bcb37bb 100644 --- a/activities/part-substitution-assessment/__tests__/steps.test.ts +++ b/activities/part-substitution-assessment/__tests__/steps.test.ts @@ -158,23 +158,24 @@ describe('S1 配方', () => { * 原样投一遍(值不变,幂等)——目的不是产出新数据,是让 `mine:` 这两张表不再天然为零行, * 好让它们能安全回到下面 ⑦ 卡的 `gateLedgers` 上(闸不会再拿「零行」误判)。 * - * **不带 `prune`**(2026-08-30 第二处修正,e2e 案例 2 撞出来的):这两张表在 ⑦ 的 - * `gateLedgers` 上,一旦人交回数据工单就随「数据工单确认即入库」落进正式表——已落地的行 - * `draftDrop`(`prune` 的撤行机制)撤不掉,配方原样抛错。字典类的表允许历史选型留在 - * 草稿层(不算错),不像 `parts.profile`/`parts.parameter` 那几笔(它们仍然 `prune:true`, - * 见下面「主数据骨架」那笔)必须清掉不再相关的陈旧行。 + * **带 `prune`,已落地的行由基座 `skipLanded` 跳过**(2026-08-31 改回)。曾特意不带 + * `prune`(2026-08-30 e2e 案例 2:已落地的旧类型行撤不掉,配方整单炸)—— 代价是 + * **没落地的**陈旧行也不清:人把候选从 LDO 改回 DC-DC,LDO 的字典/字段行积在屏上 + * (2026-08-31 用户实测点名「下面还是显示两个类型」)。现在 `draftDrop` 对配方这一路 + * 跳过已落地的行(loop-engine/port.ts),prune 回来:没落地的陈旧选型照撤, + * 已落地的原地留下 —— 案例 2 不再炸,用户那条路清得干净。 */ - it('②③ 两笔自投影都读 sub_eval.part 的 chip_type_id 去查字典/字段清单,原样抄回自己(不带 prune)', () => { + it('②③ 两笔自投影都读 sub_eval.part 的 chip_type_id 去查字典/字段清单,原样抄回自己(带 prune,已落地由基座跳过)', () => { const chipType = acts[1]; expect(chipType?.kind === 'project' && chipType.into).toBe('parts.chip_type'); - expect(chipType?.kind === 'project' && chipType.prune).toBeUndefined(); + expect(chipType?.kind === 'project' && chipType.prune).toBe(true); expect(chipType?.kind === 'project' && chipType.from).toMatchObject({ each: 'mine:sub_eval.part', then: { each: 'parts.chip_type', where: { type_id: { outer: 'chip_type_id' } } }, }); 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.prune).toBe(true); expect(typeParam?.kind === 'project' && typeParam.from).toMatchObject({ each: 'mine:sub_eval.part', then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } }, diff --git a/activities/part-substitution-assessment/src/case-model.ts b/activities/part-substitution-assessment/src/case-model.ts index 6756913..d91e1b6 100644 --- a/activities/part-substitution-assessment/src/case-model.ts +++ b/activities/part-substitution-assessment/src/case-model.ts @@ -400,18 +400,17 @@ export const ASSESSMENT_STEPS = [ 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`(它们是这一单自己的骨架,换料/换类型确实要清旧行, - * 且它们不会像字典这样跨料共享、也不会被单独确认入库)。 + * **带 `prune`,已落地的行由基座跳过**(2026-08-31 改回)。这一笔曾特意不带 + * `prune`(2026-08-30 e2e 案例 2:类型行随「数据工单确认即入库」落地后,人改了 + * 类型选择,`prune` 想撤已落地的旧行,`draftDrop` 硬拒、配方整单炸)—— 当时的 + * 代价是**没落地的**陈旧行也跟着不清:人在件上把候选从 LDO 改回 DC-DC,LDO 的 + * 字典行与字段行积在屏上,「下面还是显示两个类型」(2026-08-31 用户实测点名)。 + * 病根在撤行机制一刀切,不在 prune 本身 —— 现在 `draftDrop` 对配方这一路带 + * `skipLanded`(见 loop-engine/port.ts):已落地的行原地留下(它已是世界的一部分, + * 字典行本就该积累),**没落地的陈旧选型照撤** —— 案例 2 那条路不再炸, + * 用户点名的那条路清得干净。撤行只在非 quiet 拍跑(人正编辑时不动他的行,F3)。 */ + prune: true, from: { each: 'mine:sub_eval.part', then: { each: 'parts.chip_type', where: { type_id: { outer: 'chip_type_id' } } }, @@ -435,16 +434,14 @@ export const ASSESSMENT_STEPS = [ * 人在件上新增的字段行本就已经在 `mine:parts.type_param` 里,这一笔重新投一遍 * 原样命中、照样幂等跳过,不会覆盖人刚填的内容。 * - * **同样不带 `prune`**(与 ② 同一处修正,同一个理由:字段清单也在 ⑥ 的 - * `gateLedgers` 上,人交回数据工单那一刻就会跟着落地;已落地的行 `prune` 撤不掉, - * 带上 `prune:true` 会在「先前定过某个类型的字段清单、后来改选了另一个类型」这条 - * 路上原样复现 H35 e2e 案例 2 那个死锁)。历史选型的陈旧字段行留在草稿层不算错, - * 与 ② 的字典行是同一条道理。 + * **同样带 `prune`(已落地的由基座 `skipLanded` 跳过)**—— 与 ② 同一次改回、 + * 同一个理由,见 ② 头上的大注释:改选类型后,旧类型**没落地**的字段行照撤, + * 已随数据工单确认落地的原地留下,H35 案例 2 的死锁不再触发。 */ { kind: 'project' as const, into: 'parts.type_param', - /* 同上一笔同一个理由,同一次修正(含"不带 prune") —— 见 ② 那笔头上的大注释,不重复贴 */ + prune: true, from: { each: 'mine:sub_eval.part', then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } }, diff --git a/activities/part-substitution-assessment/src/manifest.ts b/activities/part-substitution-assessment/src/manifest.ts index 6b45f28..9066020 100644 --- a/activities/part-substitution-assessment/src/manifest.ts +++ b/activities/part-substitution-assessment/src/manifest.ts @@ -54,7 +54,7 @@ export const manifest: ActivityManifest = { activityId: ACTIVITY_ID, name: 'ACT-WORK-05 · 差异评估', /* 重写不是改 —— 同版重装是 no-op(静默丢失,踩过) */ - packageVersion: '2.6.0', + packageVersion: '2.7.0', category: '需求分析与方案匹配', sdkVersion: '0.1.0-m5', checksum: 'dev-checksum-part-substitution-assessment', diff --git a/activities/part-substitution-assessment/src/views/report.ts b/activities/part-substitution-assessment/src/views/report.ts index dea7017..bf453ce 100644 --- a/activities/part-substitution-assessment/src/views/report.ts +++ b/activities/part-substitution-assessment/src/views/report.ts @@ -70,7 +70,7 @@ const editGate = (address: string, label: string): string => `
保存
`; const gapbar = (object: string, what: string): string => `
- 还差 行${what}没填 —— 。按「编辑」开表,红框那几格就是要填的。补齐了才确认得了。 + 还差 行${what}没填 —— 。按「编辑」打开,带 * 的必填格填上,补齐了才确认得了。
`; const addrow = (address: string, label: string, hint: string): string => `
@@ -157,65 +157,52 @@ ${badgeCss('.rl', [ ])} `; -/** - * hero 场景①「建档台」:两颗料的芯片铭牌直接由 `mine:sub_eval.part` 铺出来(一行一块), - * 铭牌上是角色 / 料号 / 在库状态 —— hero 不是装饰画,摆的就是这一单的两颗料。 - * 行容器用 `
`(行模版里只有 div/span/svg,撞不上宿主自己的闭合标签); - * 铭牌整块挂 `data-class-of="exists_status"`,不在库的那块虚线描边(判断留在样式表)。 - */ -const STAGE_LAB = `
- ${CHIP} - - - -
`; /** - * 块① 要评估什么 —— 两颗料(原 PN / 候选 PN)的存在性与主数据。 + * 块① 要评估什么 —— 电路板工作台:两颗料各一张**可就地编辑的档案卡**。 * - * **行由 `sub_eval.part` 铺、写向 `parts.profile`**(`data-rows="mine:sub_eval.part" - * data-edit="parts.profile"`,两个不同的台账地址同时出现在一个行容器上,收值的授权面 - * 只认 `data-edit`,与行从哪来无关 —— `apps/api/src/app/routes/desk.ts` 的 - * `writesOf`/`collect` 按 `data-edit` 地址找容器、按容器内 `data-col` 收值,`data-rows` - * 只管这一屏摆几行、摆谁的)。选它做行源是因为**角色(原/候选)与在库状态**只长在 - * `sub_eval.part` 上;`internal_pn` 挂 `data-col` 之后,保存时按它认作 `parts.profile` - * 的主键。**人不加行**(要建哪颗料是启动参数定死的事),没有「+加一行」/删行按钮。 + * **行源改成 `mine:parts.profile` 本身**(2026-08-31,用户实测「品牌…点保存根本不会保存」 + * 的病根):从前行源是 `mine:sub_eval.part`,而那张案卷表上只有角色/在库/类型三列 —— + * `renderTemplate` 只认行作用域,品牌/生命周期这些 `data-slot` 与孪生件的值**永远是空白**; + * 人填了、保存也真落了草稿,件上却读不出来,像没保存。S1 配方第④笔(prune:true)保证 + * `parts.profile` 草稿恒有两行(原/候选各一,值回填现值),行里天生有全部主数据列 —— + * 显示与孪生件从此有值可读,保存 → wake → 重推,当场看得见。 * - * 芯片类型选择器**长在每一行里**(不是页面级单选):`chip_type_id` 是逐颗料各自一份的 - * 字段,写入目标仍是 `parts.profile`。只读态用嵌套 `data-by` 从 `parts.chip_type` 整表 - * 按 `type_id` 查出人读的 `type_name`(取值,不是算);编辑态换成 `optionSelect`(选项现读)。 + * **角色 / 在库状态是嵌套只读查表**(`mine:sub_eval.part` 按 `internal_pn` 对行): + * 嵌套行里**一个 `data-col` 都不许有** —— 嵌套 data-rows 里的输入格会被 `collect()` + * 并成一条记录(平台测试 view-container-collect-div-rows 钉过的机制),只读徽标无此虞。 + * + * **类型名带守卫,不再撒谎**(2026-08-31 用户:「明明还没选类型,界面自己就给上了」): + * `data-by` 在行里缺键时会落回外层同名字段(debt I3)—— 候选还没选类型时,查名 pill + * 摆出的是**案卷锚(原 PN)的类型**。守卫认 `selectCurrent` 按**这一行自己的值**写下的 + * `option[selected]`(行作用域求值,不受外层回落污染):这一行真选上了才摆名, + * 没选摆「待选择」胶囊 —— 判断全在样式表(与被退回的换版面那版同一招,机制经过实测)。 + * + * 人不加行(要评估哪两颗是启动参数定死的),没有「+加一行」;整卡一个宿主一道闸 + * (`bodyOf` 是单 querySelector),`.on` 落在 `data-edit` 元素自身(非表格容器)。 */ -const PART_TABLE = `
- - - - - - - - - - - - - - - - - - - - - - - - - - -
#角色内部料号在库状态芯片类型品牌生命周期制造商规格描述Datasheet 引用Datasheet 版本
${cell('internal_pn', 'mono')}${badge('stx', 'exists_status')} - - ${optionSelect('chip_type_id', 'parts.chip_type', 'type_id', 'type_name', true)} - ${cell('brand_name')}${badge('stx', 'lifecycle_status', true)}${select('lifecycle_status', [['active', 'Active'], ['nrnd', 'NRND'], ['eol', 'EOL'], ['discontinued', '停产'], ['risk', '风险']], true)}${cell('manufacturer_name')}${cell('specification_description')}${cell('datasheet_ref', 'mono')}${cell('datasheet_version')}
`; +const DOSSIERS = `
+
+ ${CHIP} + + + ${cell('internal_pn', 'mono')} + +
+
+ 芯片类型 * + + ${optionSelect('chip_type_id', 'parts.chip_type', 'type_id', 'type_name', true)} +
+
+ + + + + + +
+
`; /** * 块②a 新建芯片类型 —— `parts.chip_type`。类型下拉(块①)里没有合适的,人在这里现造一个。 @@ -335,11 +322,11 @@ const PARAMETER_TABLE = `
- + - + - +
${cell('internal_pn', 'mono')}${cell('internal_pn', 'mono')} ${cell('param_name')}${cell('param_value', 'mono')}${cell('param_value', 'mono')} ${ro('param_unit')}${cell('param_note')}${cell('param_note')}
`; @@ -350,22 +337,22 @@ export const INPUT_SUPPLEMENT = doc({ kicker: 'SS02 · ACT-WORK-05 差异评估', title: '差异评估的输入还差几格', step: '步骤 1/4 · 校验入参与补数据', - stage: STAGE_LAB, + stage: '', footTag: '工单类型:数据', body: gateBar() + zone('做什么', '核对两颗料在不在库、归没归同一个芯片类型、参数值齐不齐;缺什么就在下面补齐', ZIC_TASK) + note([ '模板定位:本单是「数据工单」——目标台账 = 芯片主数据(P-08 主记录 + 标准化参数子表),核心参数字段清单挂在芯片类型字典上。', - '触发原因:上面铭牌里两块是本次评估要比对的原 PN 与候选 PN —— 至少有一颗不在库、两颗归的类型不一致、或必填格还空着,顶部那条红带才会出现。', - '已绑定:两颗料的内部料号已按启动参数铺进下表,不必你重填料号本身,只需核对 / 补齐其余各格;在库的料现值已回填,只是查漏补缺。', + '触发原因:下面电路板上两张档案卡就是本次要比对的原 PN 与候选 PN —— 至少有一颗不在库、两颗归的类型不一致、或必填格还空着,顶部那条红带才会出现。', + '已绑定:两颗料的内部料号已按启动参数铺上档案卡,不必你重填料号本身,只需核对 / 补齐其余各格;在库的料现值已回填,只是查漏补缺。', '纪律:两颗料必须归到同一个芯片类型下才谈得上逐项比对;类型下拉里没有合适的就在【芯片类型】区新建一个,并把它的核心参数字段一并定下来;查不到的参数值留空(会被红带点名),不要编一个值顶替。', ]) + - zone('要评估什么', '两颗料的存在性与主数据(每行一颗,人不加行,*为必填列)', ZIC_TABLE) + - editGate('parts.profile', '两颗料的主数据核对 / 补齐,点「编辑」开表') + + zone('要评估什么', '两颗料各一张档案卡,直接在卡上核对 / 补齐(人不加行,*为必填)', ZIC_TABLE) + + editGate('parts.profile', '两张档案卡上的主数据核对 / 补齐,点「编辑」开卡') + gapbar('parts_profile', '必填') + - PART_TABLE + - zone('芯片类型', '上面下拉选不到合适的类型,就在这里新建一个(选填,*为必填列)', ZIC_TABLE) + + DOSSIERS + + zone('芯片类型', '本单选用 / 新建的类型;卡上改选之后,旧选择在你确认工单那一刻自动清掉(已入库的留档)', ZIC_TABLE) + editGate('parts.chip_type', '新建一个类型:表尾那一行填类型名称与说明,保存后立刻出现在上面的下拉里') + gapbar('parts_chip_type', '必填') + CHIP_TYPE_TABLE + diff --git a/activities/part-substitution-assessment/src/views/style.ts b/activities/part-substitution-assessment/src/views/style.ts index bfbb27a..daf5ccb 100644 --- a/activities/part-substitution-assessment/src/views/style.ts +++ b/activities/part-substitution-assessment/src/views/style.ts @@ -246,9 +246,61 @@ table.on tr.jc-row-static td{background:var(--acc-wash);border-top:1px dashed va *{animation:none !important;transition:none !important} } +/* ================= 电路板工作台(件① 的两张档案卡)================= */ +.board{position:relative;margin:0 0 6px;padding:20px 16px 18px;border-radius:16px; + background: + repeating-linear-gradient(0deg, rgba(255,255,255,.03) 0 1px, transparent 1px 26px), + repeating-linear-gradient(90deg, rgba(255,255,255,.03) 0 1px, transparent 1px 26px), + radial-gradient(700px 260px at 16% -30%, var(--hero-tint), transparent 62%), + linear-gradient(158deg, #13212f 0%, var(--board) 58%, #0f1d2b 100%)} +.board::before{content:"";position:absolute;left:4%;right:4%;top:50%;border-top:2px dashed var(--glow-line)} +.board::after{content:"";position:absolute;top:50%;left:4%;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} +.dossiers{position:relative;z-index:1;display:grid;grid-template-columns:repeat(auto-fit,minmax(272px,1fr));gap:16px} +.dossier{display:flex;flex-direction:column;gap:11px;background:rgba(255,255,255,.97);border-radius:16px;padding:15px 16px 14px;box-shadow:0 14px 30px rgba(3,10,18,.5),inset 0 1px 0 rgba(255,255,255,.7);transition:box-shadow .18s,transform .18s} +.dossier:hover{transform:translateY(-2px);box-shadow:0 18px 36px rgba(3,10,18,.55),inset 0 1px 0 rgba(255,255,255,.7)} +.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} +/* 档案卡的编辑态(非表格容器,.on 落在 data-edit 元素自身) */ +.dossiers.on .jc-vcell{display:none} +.dossiers.on .jc-vcell.ro{display:block} +.dossiers.on .jc-cell{display:block} +.dossiers.on .dz-role{display:flex} +/* 参数值表:所属料号列的角色徽 + 值/说明列的三级占位 */ +.pkrole{margin-bottom:2px} +.jc-tbl-wrap td.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} +.jc-tbl-wrap td.sg .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} + /* ================= 窄屏 ================= */ @media(max-width:620px){ .card-head{padding:16px 14px 18px} + .dz-grid{grid-template-columns:1fr} .card-bd{padding:16px 14px 6px} .hero-title{font-size:19.5px} .stage{gap:14px} diff --git a/api-report/apps/api/src/modules/loop-engine/port.d.ts b/api-report/apps/api/src/modules/loop-engine/port.d.ts index f313a89..ce8269c 100644 --- a/api-report/apps/api/src/modules/loop-engine/port.d.ts +++ b/api-report/apps/api/src/modules/loop-engine/port.d.ts @@ -92,8 +92,21 @@ export type TaskDataGateway = { domain: string; object: string; key: Record; + /** + * **已落地的行:跳过,不拒**(2026-08-31,配方专用 —— 与 `draftWrite` 的两个旗同族)。 + * 「数据工单确认即入库」把行落进正式表后,草稿层那一笔只是「本单写过它」的记号; + * 撤记号正式表一个字不动,读面反而少一行 —— 所以对人/模型那一路它是硬拒(拒回是 + * 行动指南)。而配方 `prune` 的语义是「陈旧的投影行清掉」:已落地 = 已经是世界的 + * 一部分,不是陈旧草稿 —— 对配方来说跳过是常态,抛错就是整单停摆(2026-08-30 + * e2e 案例 2 的死锁:人确认入库后改了类型选择,prune 想撤已落地的旧类型行,当场炸; + * 当时以「②③ 不带 prune」绕开,代价是**没落地的**陈旧行也跟着不清了 —— + * 2026-08-31 用户实测点名的「改回同一类型,下面还是两个类型」正是这半截代价)。 + * 模型那一路不传这个旗:行为一个字不变。 + */ + skipLanded?: boolean | undefined; }): Promise<{ dropped: boolean; + landed?: boolean; }>; }; /** diff --git a/apps/api/src/app/wiring/engines.ts b/apps/api/src/app/wiring/engines.ts index 5d97c87..78b459a 100644 --- a/apps/api/src/app/wiring/engines.ts +++ b/apps/api/src/app/wiring/engines.ts @@ -1203,7 +1203,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { * 回 `dropped: false` = 草稿层里根本没有这一行 —— 多半是主键写法对不上 * (`DAG…` 与 `DAG…-R0` 是两行);如实说比静默成功有用。 */ - async draftDrop(input: { taskId: TaskId; domain: string; object: string; key: Record }) { + async draftDrop(input: { taskId: TaskId; domain: string; object: string; key: Record; skipLanded?: boolean | undefined }) { const allowed = await taskData.writable(input.taskId); const target = allowed.find((a) => a.domain === input.domain && a.object === input.object); if (target === undefined) { @@ -1226,6 +1226,12 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { Object.keys(input.key).every((k) => String(d.key[k] ?? '') === String(input.key[k] ?? '')), ); if (landedHit !== undefined) { + /* + * 配方 `prune` 传 `skipLanded`:已落地 = 已经是世界的一部分,不是陈旧草稿 —— + * 跳过是常态,不该整单炸掉(port.ts 上的大注释)。模型那一路不传,照旧硬拒: + * 那句拒回是它的行动指南。 + */ + if (input.skipLanded === true) return { dropped: false, landed: true }; throw new Error( `${target.label}里这一行(${Object.entries(input.key).map(([k, v]) => `${k}=${String(v)}`).join('、')})` + '是本单在数据工单里建好、人确认后已入库的,撤不掉;要改由人在件上改,你接着办下一件事。', diff --git a/apps/api/src/modules/loop-engine/CONTRACT.md b/apps/api/src/modules/loop-engine/CONTRACT.md index 55e7b8d..84949c0 100644 --- a/apps/api/src/modules/loop-engine/CONTRACT.md +++ b/apps/api/src/modules/loop-engine/CONTRACT.md @@ -177,6 +177,12 @@ 跳过的行照旧算「这一笔要的行」,`prune` 不许撤它。 - `writable` 多一个可选 `caseKey`(单标识列):摆给人看的行标识要**去掉单号那一段** (2026-08-25 那条口径,基座那一侧是 `rowLabel`)—— 人正在这一单里看,单号是脚下的地面。 +- `draftDrop` 多一个可选入参 `skipLanded` 与可选出参 `landed`(2026-08-31,同族第三个旗): + 已随「数据工单确认即入库」落地的行,配方 `prune` 传 `skipLanded` 时回 `{dropped:false, landed:true}` + 而不是抛 —— 已落地 = 已是世界的一部分,不是陈旧草稿,跳过是配方的常态;抛出去就是整单停摆 + (2026-08-30 e2e 案例 2 的死锁,当时以「W05 ②③ 两笔不带 prune」绕开,代价是**没落地的** + 陈旧行也不清 —— 2026-08-31 用户实测点名的「改回同一类型,下面还是两个类型」)。 + 模型那一路不传:那句「已入库撤不掉」的拒回是它的行动指南,一个字不变。 同批三条不变量: - **配方由 `liveStep` 驱动,不由 `current` 的兜底驱动**:兜底(在场的第一个有剧本的项)不过滤 diff --git a/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts b/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts index 2f6a73c..f14ee63 100644 --- a/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts +++ b/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts @@ -103,8 +103,14 @@ function harness(seed: Record = {}, override: { analyze?: Analy }); return Promise.resolve({ changed }); }, - draftDrop: (i: { domain: string; object: string; key: Record }) => { + draftDrop: (i: { domain: string; object: string; key: Record; skipLanded?: boolean }) => { 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) }); }, }, @@ -244,6 +250,28 @@ describe('配方执行器', () => { 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 () => { const { runner, notes, ctx } = harness({ n: 2 }); await runner.run({ do: [{ kind: 'say', text: { cat: ['已核对 ', { in: 'n' }, ' 颗料'] } }] }, ctx); diff --git a/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts b/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts index bee203c..8d654de 100644 --- a/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts +++ b/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts @@ -208,6 +208,8 @@ export function createAutoRunner(deps: AutoRunnerDeps): { kept.add(keyText(key)); } let localDropped = 0; + /** 已随「数据工单确认」落地、prune 不动它的行数 —— 跳过要说给人听(debt 那条的原话要求) */ + let localLandedKept = 0; /* * **人正在件上编辑的时候不 prune**(2026-08-29 终审 F3)。 * `prune` 的语义是「这一笔投影出来的行就是这张表的全部,多出来的撤掉」—— @@ -221,14 +223,23 @@ export function createAutoRunner(deps: AutoRunnerDeps): { const key: Record = {}; for (const column of decl.keys) key[column] = String(stale[column] ?? ''); if (kept.has(keyText(key))) continue; - const { dropped: gone } = await deps.data.draftDrop({ taskId: context.taskId, domain: decl.domain, object: decl.object, key }); + /* + * `skipLanded`:已随「数据工单确认」落进正式表的行不撤、也不炸(2026-08-31)。 + * 从前这里不传,已落地的陈旧行让整个配方抛错停摆(2026-08-30 e2e 案例 2), + * ②③ 那两笔被迫整个放弃 prune —— 连**没落地的**陈旧行(人改了类型选择, + * 旧类型的字典/字段行)也跟着积在屏上。现在已落地的原地留下(它是世界的 + * 一部分),没落地的照撤 —— 两种行各得其所。 + */ + const { dropped: gone, landed } = await deps.data.draftDrop({ taskId: context.taskId, domain: decl.domain, object: decl.object, key, skipLanded: true }); if (gone) { localDropped += 1; dropNames.push(rowName(key, decl.caseKey)); + } else if (landed === true) { + localLandedKept += 1; } } } - if (localWrote > 0 || localDropped > 0 || localExisting > 0 || localUnkeyed > 0) { + if (localWrote > 0 || localDropped > 0 || localExisting > 0 || localUnkeyed > 0 || localLandedKept > 0) { /* * **kind: 'observation' + `internal: true`**(2026-08-29 终审 I5,控制端裁定 F2)。 * `kind` 必须是 `observation`:`result` 不在 `task-log.ts` 的 `SKELETON` 里,进不了工作日志(R10)。 @@ -253,7 +264,9 @@ export function createAutoRunner(deps: AutoRunnerDeps): { /* 撤了要点名(M2):不点名的话,下一拍的读者(人或模型)对不上是哪几行没了 */ (localDropped === 0 ? '' : `,撤掉 ${localDropped} 行(${nameList(dropNames)})`) + /* 少写了几行要出声(M1):静默 continue 的那几行,表里少了谁也查不出为什么 */ - (localUnkeyed === 0 ? '' : `,${localUnkeyed} 行因主键算不出来没写(主键列:${decl.keys.join('、')})`), + (localUnkeyed === 0 ? '' : `,${localUnkeyed} 行因主键算不出来没写(主键列:${decl.keys.join('、')})`) + + /* 已落地的陈旧行跳过也要出声 —— 「这几行已经入库、这一笔没法帮你清」的实情说给人听 */ + (localLandedKept === 0 ? '' : `,${localLandedKept} 行已随数据工单入库、这一笔不清它`), detail: { auto: `${context.stepId}#${index + 1}`, ledger: action.into, diff --git a/apps/api/src/modules/loop-engine/port.ts b/apps/api/src/modules/loop-engine/port.ts index e96b0d8..b42f9ac 100644 --- a/apps/api/src/modules/loop-engine/port.ts +++ b/apps/api/src/modules/loop-engine/port.ts @@ -92,7 +92,19 @@ export type TaskDataGateway = { domain: string; object: string; key: Record; - }): 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 }>; }; /** diff --git a/docs/debt.md b/docs/debt.md index 2fa373f..91a89d2 100644 --- a/docs/debt.md +++ b/docs/debt.md @@ -1669,7 +1669,14 @@ B 表却**不在**同一份 `only` 名单里——B 表对应的那一行仍留 有),先看确认那张卡的 `gateLedgers`/绑定 `blocks` 是不是只列了「主表」、漏了它 `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 的芯片类型), `project` 动作的 `prune: true` 要把「新计算出的集合里没有」的陈旧行撤掉——而那些行如果