Archived
fix(w05): 两颗料不能是同一颗 —— 起单这一刻立墙,因为路只在这里
用户:「创建活动的启动参数里面不应该能选到同样的两个芯片」。 查下来这不只是没意义,是**把单子做死**:sub_eval.part 的主键是 eval_ref+internal_pn (role 不在主键里),S1 配方铸的两行撞成同一个主键、后一笔覆盖前一笔,两行收成一行 —— sub_eval_part_exists_status_found = 2 永不成立,S1 收不了步,而件上那两行人加不出来 (行源 mine:,没有「+加一行」),唯一出路是终止重开。与「两格留空」是同一条链。 平台加互斥声明 x-distinct(契约 distinctGroup)——与 x-link 正相反:那个是引导,这个是墙。 · 前端:别的格占着的候选这一格不列(自己填着的那个永远留着,否则 select 当场变空白), 并说清是谁占了哪个;走「新增」自己写一个同样的 → 小红字 + 启动按不下去; · 服务端 validateLaunchParams 独立拦(脚本 / API 绕不过),400 点名后填的那格; · 「算不算同一个」= 去空白 + 不分大小写(launchDistinctKey,前后端同一把尺)—— 只挡精确相等的话 lm2596s-5.0 照样进去,混一颗垃圾主数据出来。 W05 两格挂 x-distinct: 'part',2.14.0 已装库核过。 测:服务端 6 条 + 前端 8 条(含联动与互斥合起来的真形状)+ 包内 1 条,三处变异验证过; 四道门禁绿(2548 测)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
@@ -293,3 +293,61 @@ describe('x-options.group / x-link', () => {
|
||||
expect(out).toEqual({ ok: true, data: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'AMS1117-3.3' } });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 互斥组(2026-08-31 用户定:「不应该能选到同样的两个芯片」)。
|
||||
* 与 `x-link` 正相反 —— **这一条是墙**:W05 两格同一颗料会把单子做死(两行撞同一个主键收成一行,
|
||||
* S1 的 `exists_status_found = 2` 永不成立,而件上那两行人加不出来),而唯一能改的时机就是起单这一刻。
|
||||
*/
|
||||
describe('x-distinct', () => {
|
||||
const DISTINCT = {
|
||||
type: 'object',
|
||||
required: ['originalInternalPn', 'candidateInternalPn'],
|
||||
properties: {
|
||||
originalInternalPn: { type: 'string', title: '原内部料号', 'x-control': 'combo', 'x-options': { ledger: 'parts.profile', value: 'internal_pn' }, 'x-distinct': 'part', 'x-order': 1 },
|
||||
candidateInternalPn: { type: 'string', title: '候选内部料号', 'x-control': 'combo', 'x-options': { ledger: 'parts.profile', value: 'internal_pn' }, 'x-distinct': 'part', 'x-order': 2 },
|
||||
/* 同一张表上不在这一组的格子:填成一样也与它无关 */
|
||||
note: { type: 'string', title: '备注' },
|
||||
},
|
||||
};
|
||||
const paramsOf = async () => launchParamsOf(DISTINCT, async () => [{ value: 'LM2596S-5.0', label: 'LM2596S-5.0' }, { value: 'XL2596S-5.0', label: 'XL2596S-5.0' }]);
|
||||
|
||||
it('读得到组名;**不以有候选为前提** —— 自由输入的两格同样归它管(墙管的是值,不是候选)', async () => {
|
||||
expect(fieldSpecsOf(DISTINCT).map((s) => s.distinctGroup)).toEqual(['part', 'part', undefined]);
|
||||
const bare = await launchParamsOf({ properties: { a: { type: 'string', 'x-distinct': 'g' }, b: { type: 'string', 'x-distinct': '' } } }, noResolve);
|
||||
expect(bare.map((p) => [p.type, p.distinctGroup])).toEqual([
|
||||
['text', 'g'],
|
||||
['text', undefined],
|
||||
]);
|
||||
});
|
||||
|
||||
it('两格同一个值 → 拒,点名**后填的那格**,话里指向它撞上了谁', async () => {
|
||||
const out = validateLaunchParams(await paramsOf(), { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'LM2596S-5.0' }, fieldSpecsOf(DISTINCT));
|
||||
expect(out).toEqual({ ok: false, issues: [{ key: 'candidateInternalPn', message: '与「原内部料号」填的是同一个 —— 这两格不能一样,换一个再启动' }] });
|
||||
});
|
||||
|
||||
it('**去空白 + 不分大小写**:只挡精确相等的话,`lm2596s-5.0` 会照样进去,混一颗垃圾主数据出来', async () => {
|
||||
const out = validateLaunchParams(await paramsOf(), { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: ' lm2596s-5.0 ' }, fieldSpecsOf(DISTINCT));
|
||||
expect(out.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('两颗不同的料照收;不在这一组的格子填成一样也不管', async () => {
|
||||
const out = validateLaunchParams(
|
||||
await paramsOf(),
|
||||
{ originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'XL2596S-5.0', note: 'LM2596S-5.0' },
|
||||
fieldSpecsOf(DISTINCT),
|
||||
);
|
||||
expect(out).toEqual({ ok: true, data: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'XL2596S-5.0', note: 'LM2596S-5.0' } });
|
||||
});
|
||||
|
||||
it('空的不算撞上 —— 两格都没填该报的是必填,不是「填了同一个」', async () => {
|
||||
const out = validateLaunchParams(await paramsOf(), { originalInternalPn: '', candidateInternalPn: '' }, fieldSpecsOf(DISTINCT));
|
||||
expect(out).toEqual({ ok: false, issues: [{ key: 'originalInternalPn', message: '必填' }, { key: 'candidateInternalPn', message: '必填' }] });
|
||||
});
|
||||
|
||||
it('数组格不参与(契约写死的边界:本期没有这种形状)', async () => {
|
||||
const arrays = { properties: { a: { type: 'array', items: { type: 'string' }, title: 'A', 'x-distinct': 'g' }, b: { type: 'array', items: { type: 'string' }, title: 'B', 'x-distinct': 'g' } } };
|
||||
const out = validateLaunchParams(await launchParamsOf(arrays, noResolve), { a: ['X'], b: ['X'] }, fieldSpecsOf(arrays));
|
||||
expect(out).toEqual({ ok: true, data: { a: ['X'], b: ['X'] } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
* 整份写 `{ column, ledger, value, label }` 再从字典表换人读名(`parts.chip_type.type_id → type_name`)
|
||||
* · `x-link`:联动组名 —— 同名的几格按候选的分组键互相收窄(组内先填的那格是锚,其余按锚的键收窄)。
|
||||
* **服务端不据此立墙**:combo 本就收候选之外的值(新增料号),类型一致归活动自己的收步判据管
|
||||
* · `x-distinct`:互斥组名 —— 同名的几格**不能填同一个值**。这条**是墙**(与 `x-link` 正相反):
|
||||
* 前端不把别的格占了的候选列出来,自己写进来的由这里拒。理由见契约 `distinctGroup` 的注释:
|
||||
* W05 两格同一颗料会把单子做死,而唯一能改的时机就是起单这一刻
|
||||
* · `format: 'file'` 的字段**本期不上表单**(文档没有文件控件;W05 的 datasheet 在数据工单里传)
|
||||
* · `x-order`:表单里的次序(数字,小的在前)。**必须声明**才有稳定次序 —— 清单存在 jsonb 里,
|
||||
* PG 按键长再按字节序重排对象键,`properties` 的书写顺序到不了这里(实测:原料号跑到了客户需求前面)
|
||||
@@ -19,7 +22,7 @@
|
||||
* 纯函数、零 IO;候选的读取由调用方以 `resolve` 注入。
|
||||
*/
|
||||
import type { LaunchOption, LaunchParam, LaunchParamType } from '@contracts';
|
||||
import { LAUNCH_PARAM_TYPES } from '@contracts';
|
||||
import { LAUNCH_PARAM_TYPES, launchDistinctKey, launchDistinctMessage } from '@contracts';
|
||||
|
||||
/**
|
||||
* 候选的**分组键**(2026-08-31 用户定的联动):`column` 是候选行上取哪一列当键(如 `chip_type_id`),
|
||||
@@ -46,6 +49,8 @@ export type FieldSpec = {
|
||||
readonly optionsSource: OptionsSource | undefined;
|
||||
/** 联动组名(`x-link`);同名的几格按候选的分组键互相收窄 —— 摆法在前端,服务端不据此立墙 */
|
||||
readonly linkGroup: string | undefined;
|
||||
/** 互斥组名(`x-distinct`);同名的几格不能填同一个值 —— 这一条服务端真拦 */
|
||||
readonly distinctGroup: string | undefined;
|
||||
/** 表单次序(`x-order`);没声明的排在声明了的后面、彼此保持读到的顺序 */
|
||||
readonly order: number | undefined;
|
||||
};
|
||||
@@ -112,6 +117,7 @@ export function fieldSpecsOf(inputSchema: Readonly<Record<string, unknown>>): Fi
|
||||
defaultValue: spec['default'],
|
||||
optionsSource: optionsSourceOf(spec['x-options']),
|
||||
linkGroup: asString(spec['x-link']) === '' ? undefined : asString(spec['x-link']),
|
||||
distinctGroup: asString(spec['x-distinct']) === '' ? undefined : asString(spec['x-distinct']),
|
||||
order: typeof spec['x-order'] === 'number' && Number.isFinite(spec['x-order']) ? spec['x-order'] : undefined,
|
||||
};
|
||||
});
|
||||
@@ -192,6 +198,8 @@ export function launchParamOf(spec: FieldSpec, resolved: readonly LaunchOption[]
|
||||
...(fallback === undefined ? {} : { default: fallback }),
|
||||
/* 联动只在真有候选时才谈得上 —— 一个候选都没有的 combo 摆了组名,前端也没有可收窄的东西 */
|
||||
...(withOptions && spec.linkGroup !== undefined ? { linkGroup: spec.linkGroup } : {}),
|
||||
/* 互斥**不以有候选为前提**:两格都自己写、写成同一个,照样要拦 —— 墙管的是值,不是候选 */
|
||||
...(spec.distinctGroup === undefined ? {} : { distinctGroup: spec.distinctGroup }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -230,9 +238,34 @@ function coerceScalar(value: string, spec: FieldSpec | undefined): { value: unkn
|
||||
return { value };
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一互斥组(`x-distinct`)里撞了同一个值的那几格 —— 点名的是**后填的那格**(表单次序在后的),
|
||||
* 话里指向它撞上了谁。只认标量:数组格不参与(见契约 `distinctGroup` 的注释)。
|
||||
* 「算不算同一个」由 `launchDistinctKey` 说了算 —— 前端用的是同一把尺。
|
||||
*/
|
||||
function distinctIssuesOf(params: readonly LaunchParam[], data: Readonly<Record<string, unknown>>): LaunchIssue[] {
|
||||
const issues: LaunchIssue[] = [];
|
||||
const seenPerGroup = new Map<string, Map<string, LaunchParam>>();
|
||||
for (const param of params) {
|
||||
const group = param.distinctGroup;
|
||||
const value = data[param.key];
|
||||
if (group === undefined || value === undefined || value === null || typeof value === 'object') continue;
|
||||
const key = launchDistinctKey(String(value));
|
||||
if (key === '') continue;
|
||||
const seen = seenPerGroup.get(group) ?? new Map<string, LaunchParam>();
|
||||
seenPerGroup.set(group, seen);
|
||||
const first = seen.get(key);
|
||||
if (first === undefined) seen.set(key, param);
|
||||
else issues.push({ key: param.key, message: launchDistinctMessage(first.label) });
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端的那一道校验:未知键 / 必填空 / 类型不符 / select 值不在候选 → issues(`key` 点名是哪一格);
|
||||
* combo 候选之外的值合法(存在性检查在启动后由活动做);可选的空值不落入参。
|
||||
* **互斥组(`x-distinct`)是这里唯一的跨格校验**:同组两格填了同一个值就拒 —— 前端不列出别人占了的
|
||||
* 候选只是礼貌,自己写一个同样的值、或压根不经这个表单起单(脚本 / API),拦住的得是这里。
|
||||
* 通过时返回归一后的 `data`(按清单类型把数字 / 布尔转回真类型),直接交 `task.start`。
|
||||
*/
|
||||
export function validateLaunchParams(params: readonly LaunchParam[], input: LaunchWire, specs: readonly FieldSpec[] = []): LaunchValidation {
|
||||
@@ -287,5 +320,6 @@ export function validateLaunchParams(params: readonly LaunchParam[], input: Laun
|
||||
}
|
||||
data[param.key] = coerced.value;
|
||||
}
|
||||
issues.push(...distinctIssuesOf(params, data));
|
||||
return issues.length === 0 ? { ok: true, data } : { ok: false, issues };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user