diff --git a/activities/part-substitution-assessment/__tests__/activity.test.ts b/activities/part-substitution-assessment/__tests__/activity.test.ts index ecb11ef..fcd487b 100644 --- a/activities/part-substitution-assessment/__tests__/activity.test.ts +++ b/activities/part-substitution-assessment/__tests__/activity.test.ts @@ -132,6 +132,12 @@ describe('台账声明:授权面与《统一方案》口径', () => { expect(byAddress(D, 'pn_check')?.fields.find((f) => f.name === 'abandon_reason')?.requiredWhen).toEqual({ column: 'exists_status', value: 'abandoned' }); }); + it('三张主数据表标 createOnly:数据工单只建库里没有的料,在库的写下来当场拒(2026-08-27 实测:模型在数据工单里多铺了一行不该建的料号,件上多出一行要人填)', () => { + for (const obj of ['profile', 'parameter', 'document'] as const) expect(byAddress('parts', obj)?.createOnly, obj).toBe(true); + // 凭空拼出来的料号(实测 XL2596S-ADJ)由这一道管:只许建核对表里核过的 + expect(byAddress('parts', 'profile')?.fields.find((f) => f.name === 'internal_pn')?.refersTo).toBe(`${D}.pn_check.internal_pn`); + }); + it('判「在库」必须填得出引用依据,基座拿它去主数据里查(2026-08-27 实测:原 PN 不在库而模型把两颗都写成 found,硬阻塞那条路整个被跳过)', () => { const ref = byAddress(D, 'pn_check')?.fields.find((f) => f.name === 'found_pn_ref'); expect(ref?.refersTo).toBe('parts.profile.internal_pn'); diff --git a/activities/part-substitution-assessment/src/case-model.ts b/activities/part-substitution-assessment/src/case-model.ts index 25e2dd9..a70babf 100644 --- a/activities/part-substitution-assessment/src/case-model.ts +++ b/activities/part-substitution-assessment/src/case-model.ts @@ -362,7 +362,9 @@ current_stage = "assessing"。application_scenario / bom_position **这一步不 【名单】= \`${D}.pn_check\` 里 **exists_status = "absent" 的每一行**,不管它是原 PN 还是候选 —— **一律建,不问人** (2026-08-27 用户定:只要有缺失就自动派数据工单;从前那张「派数据工单还是放弃这颗候选」的决策卡已退役,别再发)。 **exists_status = "found" 的一行都不建** —— 那几颗的主数据本来就在库里,给它们派数据工单等于让人把已有的东西再填一遍, -而真正缺的那颗还是没人建、单子照样卡死。名单为空(一行 absent 都没有)这一项根本不会出现在你的「当下可为」里。 +而真正缺的那颗还是没人建、单子照样卡死。**这三张表在本单只建不改、且只建核对表里核过的料号**:在库的料号写下来(哪怕只是一行空骨架)会被当场拒, +凭空拼出来的料号(2026-08-27 实测:模型写了一颗不存在的 XL2596S-ADJ,把两个候选的名字拼在了一起)也会被当场拒; +真写了就用 drop 撤掉那一行,别留在件上让人去填。**名单只有一个来源:pn_check 里 absent 的那几行,一个字都别改。**名单为空(一行 absent 都没有)这一项根本不会出现在你的「当下可为」里。 **名单里的料号全放进同一张 P-08 卡**(标题列全料号,件上一颗一行,参数各归各的料号)—— 别一颗一张。 【「建」的名单定了 → 写骨架行,发 P-08 数据工单(一张单,件上两张表)】 diff --git a/activities/part-substitution-assessment/src/manifest.ts b/activities/part-substitution-assessment/src/manifest.ts index afbc7b1..838006a 100644 --- a/activities/part-substitution-assessment/src/manifest.ts +++ b/activities/part-substitution-assessment/src/manifest.ts @@ -89,7 +89,7 @@ export const manifest: ActivityManifest = { identity: { activityId: ACTIVITY_ID, name: 'ACT-WORK-05 · 差异评估', - packageVersion: '1.4.0', + packageVersion: '1.4.1', category: '需求分析与方案匹配', sdkVersion: '0.1.0-m5', checksum: 'dev-checksum-part-substitution-assessment', @@ -285,10 +285,17 @@ export const manifest: ActivityManifest = { { domain: 'parts', object: 'profile', + /* 数据工单从零建主数据:本单只建库里没有的,在库的料号写下来当场拒(2026-08-27) */ + createOnly: true, kind: 'master', label: '芯片主数据(P-08)', fields: [ - col({ name: 'internal_pn', type: 'text', label: '内部料号', primaryKey: true }), + /* + * **只许建本单核过的料号**(2026-08-27 实测):模型给一颗**不存在的** XL2596S-ADJ 铺了空骨架 —— + * 库里没有、入参里也没有,是把两个候选的名字拼出来的;件上于是多出一行要人填的记录。 + * 引用核对表:料号必须是这一单(或别单)在 pn_check 里核过的,凭空拼出来的当场拒。 + */ +col({ name: 'internal_pn', type: 'text', label: '内部料号', primaryKey: true, refersTo: `${D}.pn_check.internal_pn` }), col({ name: 'brand_name', type: 'text', label: '品牌名称', required: true }), col({ name: 'manufacturer_name', type: 'text', label: '制造商名称', expect: true }), col({ name: 'specification_description', type: 'text', label: '规格描述', required: true }), @@ -305,6 +312,8 @@ export const manifest: ActivityManifest = { { domain: 'parts', object: 'parameter', + /* 数据工单从零建主数据:本单只建库里没有的,在库的料号写下来当场拒(2026-08-27) */ + createOnly: true, kind: 'master', label: '标准化参数集(P-08 子表)', fields: [ @@ -345,6 +354,8 @@ export const manifest: ActivityManifest = { { domain: 'parts', object: 'document', + /* 数据工单从零建主数据:本单只建库里没有的,在库的料号写下来当场拒(2026-08-27) */ + createOnly: true, kind: 'master', label: '原厂技术资料库(P-10)', fields: [ diff --git a/api-report/packages/contracts/src/ledger.d.ts b/api-report/packages/contracts/src/ledger.d.ts index 2bbccc1..bba62eb 100644 --- a/api-report/packages/contracts/src/ledger.d.ts +++ b/api-report/packages/contracts/src/ledger.d.ts @@ -100,6 +100,7 @@ export declare const objectDeclarationSchema: z.ZodObject<{ derived: "derived"; }>>; caseKey: z.ZodOptional; + createOnly: z.ZodOptional; file: z.ZodOptional { ).toEqual({ customer_code: 'CU-9527', round_ref: 'COV-1' }); }); }); + +/** + * 只建不改的表(契约 `createOnly`)—— 2026-08-27 W05:数据工单里模型多铺了一行不该建的料号, + * 件上多出一行要人填的记录、红带点名它缺品牌。这一项守「在库的料被重建」这一形。 + */ +describe('createOnly:键已在正式表 → 拒并指路', () => { + const target = { label: '芯片主数据(P-08)', keys: ['internal_pn'] }; + const formal = [{ internal_pn: 'LM2596S-ADJ', brand_name: 'TI' }, { internal_pn: 'LM2596S-5.0' }]; + + it('在库的料号写下来 → 一句带指路的拒回(点名 drop)', () => { + const why = createOnlyViolation(target, { internal_pn: 'LM2596S-ADJ' }, formal); + expect(why).toContain('已经有「internal_pn=LM2596S-ADJ」'); + expect(why).toContain('drop'); + }); + + it('库里没有的料号 → 放行;复合主键按全部键列比', () => { + expect(createOnlyViolation(target, { internal_pn: 'XL2596S-5.0' }, formal)).toBeUndefined(); + const sub = { label: '标准化参数集', keys: ['internal_pn', 'param_name'] }; + const rows = [{ internal_pn: 'LM2596S-ADJ', param_name: '输出电压' }]; + expect(createOnlyViolation(sub, { internal_pn: 'LM2596S-ADJ', param_name: '输出电压' }, rows)).toBeDefined(); + expect(createOnlyViolation(sub, { internal_pn: 'LM2596S-ADJ', param_name: '使能阈值' }, rows)).toBeUndefined(); + expect(createOnlyViolation(sub, { internal_pn: 'XL2596S-5.0', param_name: '输出电压' }, rows)).toBeUndefined(); + }); +}); diff --git a/apps/api/src/app/wiring/engines.ts b/apps/api/src/app/wiring/engines.ts index c637c9c..7d6f6ea 100644 --- a/apps/api/src/app/wiring/engines.ts +++ b/apps/api/src/app/wiring/engines.ts @@ -190,6 +190,24 @@ export function localStamp(iso: string): string { return `${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`; } +/** + * 「只建不改」的表:这一笔写的键是不是已经在正式表里了 —— 在就回一句拒回的话(带指路),不在回 undefined。 + * 纯函数:正式表的行由调用方查好递进来,这里只比键。 + */ +export function createOnlyViolation( + target: { readonly label: string; readonly keys: ReadonlyArray }, + key: Readonly>, + formalRows: ReadonlyArray>, +): string | undefined { + const same = (row: Record) => target.keys.every((k) => String(row[k] ?? '') === String(key[k] ?? '')); + if (!formalRows.some(same)) return undefined; + const shown = target.keys.map((k) => `${k}=${String(key[k] ?? '')}`).join('、'); + return ( + `${target.label}里已经有「${shown}」这一行 —— 这张表在本单只许**新建库里没有的记录**(数据工单是从零建主数据用的),` + + '在库的料号不用建、也不许改:别再写它;要是本单草稿里已经替它铺了骨架,用 drop 把那一行撤掉,免得件上多出一行要人填的东西。' + ); +} + export function rowReadings( mine: Readonly>, declared: ReadonlyArray<{ domain: string; object: string }> = [], @@ -937,6 +955,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { label: d.label ?? `${d.domain}.${d.object}`, keys: d.fields.filter((f) => f.primaryKey).map((f) => f.name), fields: d.fields.map((f) => ({ name: f.name, label: f.label ?? f.name, type: f.type, refersTo: f.refersTo })), + createOnly: d.createOnly === true, })); }, /** @@ -975,6 +994,23 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { throw new Error(`台账无此列:${strayed.join('、')}(${target.label} 可写列:${[...known].join('、')})`); } + /* + * **只建不改的表,键已在库就拒**(契约 `createOnly`,2026-08-27 W05 实测逼出): + * 数据工单让人从零建主数据,模型却可能给在库的料号也写一行空骨架 —— 件上多出一行 + * 要人填的记录,红带点名它缺品牌,而正式表里它什么都不缺。剧本写了拦不住,写入那一刻查。 + */ + if (target.createOnly && target.keys.length > 0) { + const formal = await deps.ledger.query({ + address: { domain: input.domain, object: input.object }, + where: {}, + orderBy: [], + limit: 500, + offset: 0, + }); + const why = createOnlyViolation(target, input.key, formal.rows as ReadonlyArray>); + if (why !== undefined) throw new Error(why); + } + /* * **引用列查引用**(2026-08-24,措辞治不了的结构治):声明了 `refersTo` 的列, * 值必须在被引用的表里真实存在(正式 + 本单草稿叠加)。起因:经营计划的 diff --git a/apps/api/src/modules/ledger/CONTRACT.md b/apps/api/src/modules/ledger/CONTRACT.md index 399d55e..ec8662a 100644 --- a/apps/api/src/modules/ledger/CONTRACT.md +++ b/apps/api/src/modules/ledger/CONTRACT.md @@ -52,3 +52,7 @@ 此前 label 在建表那一步就丢了,治理台只剩 `project__intake_record` 这种键给人看。 用注释而非另建元数据表:名字与表同生共死,不会对不上。 `comment on` 是工具语句不吃占位符,字面量在 adapter 内转义单引号。 + +近变(2026-08-27):**对象声明加 `createOnly`** —— 标了的表在本单**只建不改**:Agent 写入那一刻查正式表, +键已在库当场拒并指路(撤掉草稿里那一行)。数据工单「从零建主数据」的三张表用它。只管 Agent 的写路径 +(`engines.draftWrite` 那道门),人在件上的保存不受此限。 diff --git a/packages/contracts/src/ledger.ts b/packages/contracts/src/ledger.ts index 596e2ac..fef93b9 100644 --- a/packages/contracts/src/ledger.ts +++ b/packages/contracts/src/ledger.ts @@ -218,6 +218,17 @@ export const objectDeclarationSchema = z.object({ * 安装期就能核:它在不在 fields 里、是不是主键的一部分。 */ caseKey: identifier.optional(), + /** + * **本单只许新建正式表里没有的行**(2026-08-27,W05 数据工单逼出来的)。 + * + * 数据工单让人「从零建一张主数据」—— 这张表在本单里的语义就是**只建不改**。 + * 实测(W05):模型在数据工单里多铺了一行不该建的料号 —— 件上于是多出一行要人填的记录, + * 红带点名它缺品牌;剧本写了「found 的一行都不建」拦不住。这一项守的是其中一种形: + * **在库的料号被当成新料重建**。标了它的表,写入那一刻查正式表:键已经在库 → 当场拒并指路 + * (墙要配路:撤掉那一行)。凭空拼出来的料号由 `refersTo` 核对表那道门管。 + * 只管 Agent 的写(人在件上的保存走另一条路,不受此限)。 + */ + createOnly: z.boolean().optional(), /** * **这张表的每一行还是一份资料文件** —— 哪几列合起来是那份文件(2026-08-23 加)。 *