数据工单只建名单上的料:拼出来的料号与在库的料号写下来都当场拒(W05 1.4.1)

用户在新一单上看到件上多出一行要人补的料号。查实:模型在数据工单里给一颗**不存在的**
XL2596S-ADJ 铺了空骨架 —— 库里没有、入参里也没有,是把两个候选的名字拼出来的;
件上于是多出一行、红带点名它缺品牌。存在性核对本身判对了(ADJ found、XL absent),
错在「判对了却多写了一行」—— 事实在模型手里,能判对也能写错。

两道门,都在写入那一刻:
- `parts.profile.internal_pn refersTo sub_eval.pn_check.internal_pn` —— 只许建核对表里核过的料号,
  凭空拼出来的当场拒(参数/资料两张子表的 internal_pn 本就引用 profile,随之一并拦住)。
- 契约新增对象声明 `createOnly`(平台侧,~25 行):标了的表本单只建不改,键已在正式表当场拒并指路。
  它守的是另一形(在库的料被当成新料重建)。只管 Agent 的写路径,人在件上的保存不受此限。
纯函数 `createOnlyViolation` 可测(复合主键按全部键列比);活动侧三张 parts 表标上;剧本补一句。
当前在跑那一单的草稿里拼出来的那行已手工撤掉。2100 测 / typecheck / lint / depcruise 绿;api-report 已刷。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh
This commit is contained in:
xcs
2026-08-27 13:28:25 +08:00
co-authored by Claude Fable 5
parent 51c87af583
commit fa27cef60e
8 changed files with 99 additions and 4 deletions
@@ -132,6 +132,12 @@ describe('台账声明:授权面与《统一方案》口径', () => {
expect(byAddress(D, 'pn_check')?.fields.find((f) => f.name === 'abandon_reason')?.requiredWhen).toEqual({ column: 'exists_status', value: 'abandoned' }); 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,硬阻塞那条路整个被跳过)', () => { it('判「在库」必须填得出引用依据,基座拿它去主数据里查(2026-08-27 实测:原 PN 不在库而模型把两颗都写成 found,硬阻塞那条路整个被跳过)', () => {
const ref = byAddress(D, 'pn_check')?.fields.find((f) => f.name === 'found_pn_ref'); const ref = byAddress(D, 'pn_check')?.fields.find((f) => f.name === 'found_pn_ref');
expect(ref?.refersTo).toBe('parts.profile.internal_pn'); expect(ref?.refersTo).toBe('parts.profile.internal_pn');
@@ -362,7 +362,9 @@ current_stage = "assessing"。application_scenario / bom_position **这一步不
【名单】= \`${D}.pn_check\` 里 **exists_status = "absent" 的每一行**,不管它是原 PN 还是候选 —— **一律建,不问人** 【名单】= \`${D}.pn_check\` 里 **exists_status = "absent" 的每一行**,不管它是原 PN 还是候选 —— **一律建,不问人**
(2026-08-27 用户定:只要有缺失就自动派数据工单;从前那张「派数据工单还是放弃这颗候选」的决策卡已退役,别再发)。 (2026-08-27 用户定:只要有缺失就自动派数据工单;从前那张「派数据工单还是放弃这颗候选」的决策卡已退役,别再发)。
**exists_status = "found" 的一行都不建** —— 那几颗的主数据本来就在库里,给它们派数据工单等于让人把已有的东西再填一遍, **exists_status = "found" 的一行都不建** —— 那几颗的主数据本来就在库里,给它们派数据工单等于让人把已有的东西再填一遍,
而真正缺的那颗还是没人建、单子照样卡死。名单为空(一行 absent 都没有)这一项根本不会出现在你的「当下可为」里。 而真正缺的那颗还是没人建、单子照样卡死。**这三张表在本单只建不改、且只建核对表里核过的料号**:在库的料号写下来(哪怕只是一行空骨架)会被当场拒,
凭空拼出来的料号(2026-08-27 实测:模型写了一颗不存在的 XL2596S-ADJ,把两个候选的名字拼在了一起)也会被当场拒;
真写了就用 drop 撤掉那一行,别留在件上让人去填。**名单只有一个来源:pn_check 里 absent 的那几行,一个字都别改。**名单为空(一行 absent 都没有)这一项根本不会出现在你的「当下可为」里。
**名单里的料号全放进同一张 P-08 卡**(标题列全料号,件上一颗一行,参数各归各的料号)—— 别一颗一张。 **名单里的料号全放进同一张 P-08 卡**(标题列全料号,件上一颗一行,参数各归各的料号)—— 别一颗一张。
【「建」的名单定了 → 写骨架行,发 P-08 数据工单(一张单,件上两张表)】 【「建」的名单定了 → 写骨架行,发 P-08 数据工单(一张单,件上两张表)】
@@ -89,7 +89,7 @@ export const manifest: ActivityManifest = {
identity: { identity: {
activityId: ACTIVITY_ID, activityId: ACTIVITY_ID,
name: 'ACT-WORK-05 · 差异评估', name: 'ACT-WORK-05 · 差异评估',
packageVersion: '1.4.0', packageVersion: '1.4.1',
category: '需求分析与方案匹配', category: '需求分析与方案匹配',
sdkVersion: '0.1.0-m5', sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-part-substitution-assessment', checksum: 'dev-checksum-part-substitution-assessment',
@@ -285,10 +285,17 @@ export const manifest: ActivityManifest = {
{ {
domain: 'parts', domain: 'parts',
object: 'profile', object: 'profile',
/* 数据工单从零建主数据:本单只建库里没有的,在库的料号写下来当场拒(2026-08-27) */
createOnly: true,
kind: 'master', kind: 'master',
label: '芯片主数据(P-08)', label: '芯片主数据(P-08)',
fields: [ 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: 'brand_name', type: 'text', label: '品牌名称', required: true }),
col({ name: 'manufacturer_name', type: 'text', label: '制造商名称', expect: true }), col({ name: 'manufacturer_name', type: 'text', label: '制造商名称', expect: true }),
col({ name: 'specification_description', type: 'text', label: '规格描述', required: true }), col({ name: 'specification_description', type: 'text', label: '规格描述', required: true }),
@@ -305,6 +312,8 @@ export const manifest: ActivityManifest = {
{ {
domain: 'parts', domain: 'parts',
object: 'parameter', object: 'parameter',
/* 数据工单从零建主数据:本单只建库里没有的,在库的料号写下来当场拒(2026-08-27) */
createOnly: true,
kind: 'master', kind: 'master',
label: '标准化参数集(P-08 子表)', label: '标准化参数集(P-08 子表)',
fields: [ fields: [
@@ -345,6 +354,8 @@ export const manifest: ActivityManifest = {
{ {
domain: 'parts', domain: 'parts',
object: 'document', object: 'document',
/* 数据工单从零建主数据:本单只建库里没有的,在库的料号写下来当场拒(2026-08-27) */
createOnly: true,
kind: 'master', kind: 'master',
label: '原厂技术资料库(P-10)', label: '原厂技术资料库(P-10)',
fields: [ fields: [
+1
View File
@@ -100,6 +100,7 @@ export declare const objectDeclarationSchema: z.ZodObject<{
derived: "derived"; derived: "derived";
}>>; }>>;
caseKey: z.ZodOptional<z.ZodString>; caseKey: z.ZodOptional<z.ZodString>;
createOnly: z.ZodOptional<z.ZodBoolean>;
file: z.ZodOptional<z.ZodObject<{ file: z.ZodOptional<z.ZodObject<{
titleColumn: z.ZodString; titleColumn: z.ZodString;
bodyColumn: z.ZodString; bodyColumn: z.ZodString;
@@ -20,7 +20,7 @@
*/ */
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { emptyGate, findKnownRow, gateAddressesOf, gateOf, parseRowKey, resolveRowKey, rowChanged, writesOf } from '../routes/desk.ts'; import { emptyGate, findKnownRow, gateAddressesOf, gateOf, parseRowKey, resolveRowKey, rowChanged, writesOf } from '../routes/desk.ts';
import { missingReadings } from '../wiring/engines.ts'; import { createOnlyViolation, missingReadings } from '../wiring/engines.ts';
/** A1 受理记录的声明(裁到这一段用得着的几列):PN 与年用量必填 */ /** A1 受理记录的声明(裁到这一段用得着的几列):PN 与年用量必填 */
const RECORD = { const RECORD = {
@@ -550,3 +550,27 @@ describe('落不下的行要点名 —— 不许悄悄丢', () => {
).toEqual({ customer_code: 'CU-9527', round_ref: 'COV-1' }); ).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();
});
});
+36
View File
@@ -190,6 +190,24 @@ export function localStamp(iso: string): string {
return `${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`; 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<string> },
key: Readonly<Record<string, string | number>>,
formalRows: ReadonlyArray<Record<string, unknown>>,
): string | undefined {
const same = (row: Record<string, unknown>) => 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( export function rowReadings(
mine: Readonly<Record<string, readonly unknown[]>>, mine: Readonly<Record<string, readonly unknown[]>>,
declared: ReadonlyArray<{ domain: string; object: string }> = [], declared: ReadonlyArray<{ domain: string; object: string }> = [],
@@ -937,6 +955,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
label: d.label ?? `${d.domain}.${d.object}`, label: d.label ?? `${d.domain}.${d.object}`,
keys: d.fields.filter((f) => f.primaryKey).map((f) => f.name), 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 })), 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('、')})`); 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<Record<string, unknown>>);
if (why !== undefined) throw new Error(why);
}
/* /*
* **引用列查引用**(2026-08-24,措辞治不了的结构治):声明了 `refersTo` 的列, * **引用列查引用**(2026-08-24,措辞治不了的结构治):声明了 `refersTo` 的列,
* 值必须在被引用的表里真实存在(正式 + 本单草稿叠加)。起因:经营计划的 * 值必须在被引用的表里真实存在(正式 + 本单草稿叠加)。起因:经营计划的
+4
View File
@@ -52,3 +52,7 @@
此前 label 在建表那一步就丢了,治理台只剩 `project__intake_record` 这种键给人看。 此前 label 在建表那一步就丢了,治理台只剩 `project__intake_record` 这种键给人看。
用注释而非另建元数据表:名字与表同生共死,不会对不上。 用注释而非另建元数据表:名字与表同生共死,不会对不上。
`comment on` 是工具语句不吃占位符,字面量在 adapter 内转义单引号。 `comment on` 是工具语句不吃占位符,字面量在 adapter 内转义单引号。
近变(2026-08-27):**对象声明加 `createOnly`** —— 标了的表在本单**只建不改**:Agent 写入那一刻查正式表,
键已在库当场拒并指路(撤掉草稿里那一行)。数据工单「从零建主数据」的三张表用它。只管 Agent 的写路径
(`engines.draftWrite` 那道门),人在件上的保存不受此限。
+11
View File
@@ -218,6 +218,17 @@ export const objectDeclarationSchema = z.object({
* 安装期就能核:它在不在 fields 里、是不是主键的一部分。 * 安装期就能核:它在不在 fields 里、是不是主键的一部分。
*/ */
caseKey: identifier.optional(), caseKey: identifier.optional(),
/**
* **本单只许新建正式表里没有的行**(2026-08-27,W05 数据工单逼出来的)。
*
* 数据工单让人「从零建一张主数据」—— 这张表在本单里的语义就是**只建不改**。
* 实测(W05):模型在数据工单里多铺了一行不该建的料号 —— 件上于是多出一行要人填的记录,
* 红带点名它缺品牌;剧本写了「found 的一行都不建」拦不住。这一项守的是其中一种形:
* **在库的料号被当成新料重建**。标了它的表,写入那一刻查正式表:键已经在库 → 当场拒并指路
* (墙要配路:撤掉那一行)。凭空拼出来的料号由 `refersTo` 核对表那道门管。
* 只管 Agent 的写(人在件上的保存走另一条路,不受此限)。
*/
createOnly: z.boolean().optional(),
/** /**
* **这张表的每一行还是一份资料文件** —— 哪几列合起来是那份文件(2026-08-23 加)。 * **这张表的每一行还是一份资料文件** —— 哪几列合起来是那份文件(2026-08-23 加)。
* *