diff --git a/apps/api/src/app/wiring/engines.ts b/apps/api/src/app/wiring/engines.ts index 93bffe2..11883cc 100644 --- a/apps/api/src/app/wiring/engines.ts +++ b/apps/api/src/app/wiring/engines.ts @@ -1022,6 +1022,8 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { object: d.object, label: d.label ?? `${d.domain}.${d.object}`, keys: d.fields.filter((f) => f.primaryKey).map((f) => f.name), + /* 单标识列 —— 配方留痕点名行时按它削掉单号那一段(与 `rowLabel` 同一条口径) */ + ...(d.caseKey === undefined ? {} : { caseKey: d.caseKey }), fields: d.fields.map((f) => ({ name: f.name, label: f.label ?? f.name, type: f.type, refersTo: f.refersTo })), createOnly: d.createOnly === true, })); 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 c71c0ff..167398e 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 @@ -62,7 +62,7 @@ function harness(seed: Record = {}, override: { analyze?: Analy view: () => Promise.resolve(view()), writable: () => Promise.resolve([ - { domain: 'sub_eval', object: 'part', label: '本单两颗料', keys: ['eval_ref', 'internal_pn'], fields: [] }, + { domain: 'sub_eval', object: 'part', label: '本单两颗料', keys: ['eval_ref', 'internal_pn'], caseKey: 'eval_ref', fields: [] }, { domain: 'parts', object: 'parameter', label: '参数', keys: ['internal_pn', 'param_name'], fields: [] }, ]), /** @@ -173,6 +173,31 @@ describe('配方执行器', () => { expect(drafts.size).toBe(0); }); + /* + * **少写了几行要出声**(2026-08-29 终审 M1):从前主键算不出来是静默 `continue` —— + * 既不计数也不留痕。表里少了行,人和判据都查不出为什么。 + */ + it('主键算不出来:留一句痕点名少了几行、主键列是哪几个', async () => { + const { runner, notes, ctx } = harness({}); + await runner.run( + { + do: [ + { + kind: 'project', + into: 'sub_eval.part', + from: { rows: [{ pn: 'A' }, { pn: '' }] }, + fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } }, + }, + ], + }, + ctx, + ); + const line = notes.find((n) => n.includes('主键算不出来')); + expect(line).toBeDefined(); + expect(line).toContain('1 行因主键算不出来没写'); + expect(line).toContain('eval_ref、internal_pn'); + }); + it('when 为假就跳过这一笔', async () => { const { runner, drafts, ctx } = harness({}); await runner.run({ do: [{ kind: 'project', when: 'never', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'a', internal_pn: 'b' } }] }, ctx); @@ -202,8 +227,8 @@ describe('配方执行器', () => { expect(second.wrote).toBe(0); }); - it('prune:这一笔没投影出来的行,从草稿层撤掉', async () => { - const { runner, drafts, ctx } = harness({}); + it('prune:这一笔没投影出来的行,从草稿层撤掉,**并点名撤了哪几行**(M2)', async () => { + const { runner, drafts, notes, ctx } = harness({}); await runner.run({ do: [{ kind: 'project', into: 'sub_eval.part', from: { rows: [{ pn: 'A' }, { pn: 'B' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }] }, ctx); expect(drafts.size).toBe(2); const out = await runner.run( @@ -212,6 +237,10 @@ describe('配方执行器', () => { ); expect(out.dropped).toBe(1); expect([...drafts.values()].map((d) => d.key['internal_pn'])).toEqual(['A']); + /* 点名,且**去掉单号那一段** —— 人正在这一单里看,`W05-1` 是脚下的地面(2026-08-25 那条口径) */ + const line = notes.find((n) => n.includes('撤掉 1 行')); + expect(line).toContain('撤掉 1 行(B)'); + expect(line).not.toContain('W05-1'); }); it('say:进工作日志,归系统', async () => { 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 96f22db..ce3a11c 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 @@ -54,6 +54,23 @@ export type AutoOutcome = { const keyText = (key: Readonly>): string => Object.values(key).join('|'); +/** + * 摆给人看的行标识 —— **去掉单号那一段**(2026-08-25 柴浩:「只保留斜杠右边」): + * 人正在这一单里看,单号是脚下的地面,不是区分行的信息。与基座的 `rowLabel` 同一条口径。 + * 去完一段都不剩(主键只有单号)就退回整段。 + */ +function rowName(key: Readonly>, caseKey: string | undefined): string { + const rest = Object.entries(key) + .filter(([column]) => column !== caseKey) + .map(([, value]) => value); + return (rest.length > 0 ? rest : Object.values(key)).join('/'); +} + +/** 点名封顶 8 个 —— 再多就不是「点名」而是刷屏(拒回主键名单封顶 12 是同一条理由) */ +function nameList(names: readonly string[]): string { + return names.length <= 8 ? names.join('、') : `${names.slice(0, 8).join('、')}…等 ${names.length} 行`; +} + /** 这份配方会不会发卡 —— 「卡答过了就收步」那道门要问它 */ export function recipeIssuesCard(recipe: AutoRecipe): boolean { return recipe.do.some((action) => action.kind === 'card'); @@ -112,6 +129,10 @@ export function createAutoRunner(deps: AutoRunnerDeps): { let localWrote = 0; /** 「只建不改」的表里已经在库、这一笔不用建的行数 */ let localExisting = 0; + /** 主键算不出来、这一笔没写下去的行数 —— 不出声的话表里少了行谁也查不出为什么(M1) */ + let localUnkeyed = 0; + /** 撤掉的是哪几行 —— 「撤要说出来、要点名」(2026-08-02 为人工撤行立的规矩,配方这一侧跟上,M2) */ + const dropNames: string[] = []; for (const { row, outer } of expandSource(action.from, base)) { const computed: Record = {}; for (const [name, expr] of Object.entries(action.fields)) { @@ -119,8 +140,11 @@ export function createAutoRunner(deps: AutoRunnerDeps): { } const key: Record = {}; for (const column of decl.keys) key[column] = String(computed[column] ?? ''); - /* 主键算不出来就先不写 —— 空主键写下去是一行永远对不上的垃圾 */ - if (decl.keys.some((column) => key[column] === '')) continue; + /* 主键算不出来就先不写 —— 空主键写下去是一行永远对不上的垃圾。**但要数一笔**(M1) */ + if (decl.keys.some((column) => key[column] === '')) { + localUnkeyed += 1; + continue; + } const patch: Record = {}; for (const [name, value] of Object.entries(computed)) if (!decl.keys.includes(name)) patch[name] = value; /* @@ -160,10 +184,13 @@ export function createAutoRunner(deps: AutoRunnerDeps): { 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 }); - if (gone) localDropped += 1; + if (gone) { + localDropped += 1; + dropNames.push(rowName(key, decl.caseKey)); + } } } - if (localWrote > 0 || localDropped > 0 || localExisting > 0) { + if (localWrote > 0 || localDropped > 0 || localExisting > 0 || localUnkeyed > 0) { /* * **kind: 'observation' + `internal: true`**(2026-08-29 终审 I5,控制端裁定 F2)。 * `kind` 必须是 `observation`:`result` 不在 `task-log.ts` 的 `SKELETON` 里,进不了工作日志(R10)。 @@ -178,7 +205,10 @@ export function createAutoRunner(deps: AutoRunnerDeps): { title: `${decl.label}:写下 ${localWrote} 行` + (localExisting === 0 ? '' : `,${localExisting} 行已在库、这一笔不用建`) + - (localDropped === 0 ? '' : `,撤掉 ${localDropped} 行`), + /* 撤了要点名(M2):不点名的话,下一拍的读者(人或模型)对不上是哪几行没了 */ + (localDropped === 0 ? '' : `,撤掉 ${localDropped} 行(${nameList(dropNames)})`) + + /* 少写了几行要出声(M1):静默 continue 的那几行,表里少了谁也查不出为什么 */ + (localUnkeyed === 0 ? '' : `,${localUnkeyed} 行因主键算不出来没写(主键列:${decl.keys.join('、')})`), detail: { auto: `${context.stepId}#${index + 1}`, ledger: action.into, internal: true }, }); } diff --git a/apps/api/src/modules/loop-engine/port.ts b/apps/api/src/modules/loop-engine/port.ts index 57eea9b..e96b0d8 100644 --- a/apps/api/src/modules/loop-engine/port.ts +++ b/apps/api/src/modules/loop-engine/port.ts @@ -44,6 +44,12 @@ export type TaskDataGateway = { object: string; label: string; keys: string[]; + /** + * 单标识列(`declarations.ledger[].caseKey`)—— **摆给人看的行标识要去掉这一段** + * (2026-08-25 柴浩:「COV-…/CU-0142-01 只保留斜杠右边」):人正在这一单里看, + * 单号是脚下的地面,不是区分行的信息。基座那一侧同款做法见 `rowLabel`。 + */ + caseKey?: string | undefined; fields: Array<{ name: string; label: string; type?: string }>; }> >;