From e47ce92702697861c59b9ca77bc316e5f4ede682 Mon Sep 17 00:00:00 2001 From: xcs Date: Mon, 31 Aug 2026 22:03:56 +0800 Subject: [PATCH] =?UTF-8?q?fix(w05):=20=E4=B8=A4=E9=A2=97=E6=96=99?= =?UTF-8?q?=E4=B8=8D=E8=83=BD=E6=98=AF=E5=90=8C=E4=B8=80=E9=A2=97=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E8=B5=B7=E5=8D=95=E8=BF=99=E4=B8=80?= =?UTF-8?q?=E5=88=BB=E7=AB=8B=E5=A2=99,=E5=9B=A0=E4=B8=BA=E8=B7=AF?= =?UTF-8?q?=E5=8F=AA=E5=9C=A8=E8=BF=99=E9=87=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户:「创建活动的启动参数里面不应该能选到同样的两个芯片」。 查下来这不只是没意义,是**把单子做死**: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 Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet --- .../__tests__/activity.test.ts | 14 ++++ .../src/manifest.ts | 16 +++- .../packages/contracts/src/activity-ops.d.ts | 10 +++ .../contracts/src/api/activity-ops.d.ts | 1 + .../activity-ops-launch-params.test.ts | 58 ++++++++++++++ .../api/src/app/activity-ops/launch-params.ts | 36 ++++++++- .../gov-task/__tests__/launch-link.test.ts | 70 +++++++++++++++- .../features/gov-task/internal/launch-link.ts | 80 ++++++++++++++++--- .../src/features/gov-task/internal/panel.tsx | 41 ++++++---- docs/API文档-活动模块.md | 8 +- packages/contracts/src/activity-ops.ts | 27 +++++++ 11 files changed, 331 insertions(+), 30 deletions(-) diff --git a/activities/part-substitution-assessment/__tests__/activity.test.ts b/activities/part-substitution-assessment/__tests__/activity.test.ts index 092024c..871bd73 100644 --- a/activities/part-substitution-assessment/__tests__/activity.test.ts +++ b/activities/part-substitution-assessment/__tests__/activity.test.ts @@ -309,6 +309,20 @@ describe('契约', () => { } }); + /* + * 两格不能是同一颗料(2026-08-31 用户定:「不应该能选到同样的两个芯片」)—— 与联动不同, + * 这一条**是墙**,且理由与上面「留空会卡死」那条是同一条链:同一个料号 = 同一个主键 + * (`eval_ref + internal_pn`,`role` 不在主键里),两行撞成一行,`exists_status_found = 2` + * 永不成立,而件上那两行人加不出来 —— 唯一出路是终止重开。**墙立在起单这一刻,因为路只在这里。** + */ + it('两格互斥:同一颗料不能填两遍(x-distinct 同名 = 一组,服务端真拦)', () => { + const props = manifest.contract.input.data['properties'] as Record>; + for (const key of ['originalInternalPn', 'candidateInternalPn']) expect(props[key]?.['x-distinct']).toBe('part'); + /* 主键真是「案号 + 料号」——「同一个料号 = 同一个主键」这条链的第一环,它变了这道墙就该重新论证 */ + const part = manifest.declarations.ledger.find((l) => `${l.domain}.${l.object}` === 'sub_eval.part'); + expect(part?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['eval_ref', 'internal_pn']); + }); + it('零工具 —— 这是这次重写的立场,不是疏漏', () => { expect(manifest.contract.resources.tools).toEqual([]); expect(manifest.declarations.tools).toEqual([]); diff --git a/activities/part-substitution-assessment/src/manifest.ts b/activities/part-substitution-assessment/src/manifest.ts index 743f00c..0ebd276 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.13.0', + packageVersion: '2.14.0', category: '需求分析与方案匹配', sdkVersion: '0.1.0-m5', checksum: 'dev-checksum-part-substitution-assessment', @@ -96,7 +96,17 @@ export const manifest: ActivityManifest = { * 组内**先填的那格是锚**(摆全量),另一格只列同类型的料 + 库里未标类型的(殿后一组)+「新增」。 * 锚没有类型、或锚填的是库里没有的新料号 → 不收窄(两颗都还没建档时不该拦着人)。 * **这只是表单的引导,不是墙**:服务端照收两格不同类型的组合(`launch-params.ts` 头注), - * 类型一致由 S1 的收步判据 `sub_eval_part_distinct_chip_type_id = 1` 管 —— 那才是数得出来的门。requestDescription / candidateInternalPns(数组)/ + * 类型一致由 S1 的收步判据 `sub_eval_part_distinct_chip_type_id = 1` 管 —— 那才是数得出来的门。 + * + * **两格不能是同一颗料**(`x-distinct: 'part'`,2026-08-31 用户定:「不应该能选到同样的两个芯片」)—— + * 这一条**是墙**,不是引导,理由与上面「留空」那段是同一条链:两格同一个料号 → 上面那笔 `project` + * 铸的两行撞上同一个主键(`eval_ref + internal_pn`,`role` 不在主键里)→ 后一笔覆盖前一笔 → + * `sub_eval.part` 收成一行 → `sub_eval_part_exists_status_found = 2` 永不成立,S1 收不了步; + * 而件上那两行人加不出来(块① 行源 `mine:parts.profile`,没有「+加一行」),**唯一出路是终止重开**。 + * 墙立在起单这一刻,是因为**路只在这里** —— 那时换一格就好了。运营端表单据此不把另一格已经选走的 + * 料号列出来;自己写一个同样的、或脚本 / API 直接起单,由服务端 `validateLaunchParams` 拒。 + * + * requestDescription / candidateInternalPns(数组)/ * datasheets 三键已随重写撤下 —— 这一版没有「需求主记录」「N 候选并行」「预填 * datasheet」这几件事。 */ @@ -113,6 +123,7 @@ export const manifest: ActivityManifest = { 'x-options': { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn', group: { column: 'chip_type_id', ledger: 'parts.chip_type', value: 'type_id', label: 'type_name' } }, 'x-order': 1, 'x-link': 'chip-type', + 'x-distinct': 'part', }, candidateInternalPn: { type: 'string', @@ -123,6 +134,7 @@ export const manifest: ActivityManifest = { 'x-options': { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn', group: { column: 'chip_type_id', ledger: 'parts.chip_type', value: 'type_id', label: 'type_name' } }, 'x-order': 2, 'x-link': 'chip-type', + 'x-distinct': 'part', }, }, }, diff --git a/api-report/packages/contracts/src/activity-ops.d.ts b/api-report/packages/contracts/src/activity-ops.d.ts index dfdfcf7..92f8abe 100644 --- a/api-report/packages/contracts/src/activity-ops.d.ts +++ b/api-report/packages/contracts/src/activity-ops.d.ts @@ -329,8 +329,17 @@ export declare const launchParamSchema: z.ZodObject<{ }, z.core.$strip>>>; default: z.ZodOptional]>>; linkGroup: z.ZodOptional; + distinctGroup: z.ZodOptional; }, z.core.$strip>; export type LaunchParam = z.infer; +/** + * 「算不算同一个」的归一:去空白 + 不分大小写 —— **前后端同一把尺**。 + * 大小写要归:料号这类身份不因字母大小写而不同,只挡精确相等的话,`lm2596s-5.0` 会照样进去, + * 混一颗垃圾主数据出来,人还得到一份自己跟自己比的评估。 + */ +export declare function launchDistinctKey(value: string): string; +/** 撞上了那一格的话 —— 前端(填的当口)与服务端(400 的 detail)同一句,不许分叉 */ +export declare function launchDistinctMessage(otherLabel: string): string; export declare const launchParamsSchema: z.ZodObject<{ activityId: z.ZodString; version: z.ZodString; @@ -355,6 +364,7 @@ export declare const launchParamsSchema: z.ZodObject<{ }, z.core.$strip>>>; default: z.ZodOptional]>>; linkGroup: z.ZodOptional; + distinctGroup: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>; export type LaunchParams = z.infer; diff --git a/api-report/packages/contracts/src/api/activity-ops.d.ts b/api-report/packages/contracts/src/api/activity-ops.d.ts index 92d215e..5bef8dc 100644 --- a/api-report/packages/contracts/src/api/activity-ops.d.ts +++ b/api-report/packages/contracts/src/api/activity-ops.d.ts @@ -181,6 +181,7 @@ export declare const opsLaunchParamsRoute: { }, z.core.$strip>>>; default: z.ZodOptional]>>; linkGroup: z.ZodOptional; + distinctGroup: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>; }; diff --git a/apps/api/src/app/__tests__/activity-ops-launch-params.test.ts b/apps/api/src/app/__tests__/activity-ops-launch-params.test.ts index 4fd586d..e26acba 100644 --- a/apps/api/src/app/__tests__/activity-ops-launch-params.test.ts +++ b/apps/api/src/app/__tests__/activity-ops-launch-params.test.ts @@ -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'] } }); + }); +}); diff --git a/apps/api/src/app/activity-ops/launch-params.ts b/apps/api/src/app/activity-ops/launch-params.ts index c6c78f1..7a3b88c 100644 --- a/apps/api/src/app/activity-ops/launch-params.ts +++ b/apps/api/src/app/activity-ops/launch-params.ts @@ -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>): 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>): LaunchIssue[] { + const issues: LaunchIssue[] = []; + const seenPerGroup = new Map>(); + 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(); + 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 }; } diff --git a/apps/web/src/features/gov-task/__tests__/launch-link.test.ts b/apps/web/src/features/gov-task/__tests__/launch-link.test.ts index 0e6a4b1..c343c4b 100644 --- a/apps/web/src/features/gov-task/__tests__/launch-link.test.ts +++ b/apps/web/src/features/gov-task/__tests__/launch-link.test.ts @@ -1,7 +1,9 @@ /** - * 启动参数的按分组联动(2026-08-31 用户定:原内部料号 / 候选内部料号按芯片类型互相收窄)。 + * 启动参数里格与格之间的两件事(2026-08-31 用户定,W05 的原料号 / 候选料号两格两样都有): + * · **按分组联动**(按芯片类型互相收窄)—— 引导,三条口径见下; + * · **互斥**(不能选到同一颗料)—— 墙,末尾那一个 describe。 * - * 三条口径由用户当场定死: + * 联动的三条口径由用户当场定死: * ①**组内第一个填上值的那格是锚**,锚自己永远摆全量;锚粘着不让位 —— 把它清空,收窄整组解除 * (不把锚顺手交给另一格,否则「清空锚再改主意」当场被另一格锁住);整组都空了才重新认锚。 * ②未标类型的老料**列在末尾单独一组**,不是不列。 @@ -9,7 +11,7 @@ */ import { describe, expect, it } from 'vitest'; import type { LaunchParam } from '@contracts'; -import { comboFace, nextAnchors, staleLinked } from '../internal/launch-link.ts'; +import { comboFace, distinctIssues, nextAnchors, staleLinked } from '../internal/launch-link.ts'; const OPTIONS = [ { value: 'LM2596S-5.0', label: 'LM2596S-5.0', group: '1', groupLabel: 'DC-DC 降压稳压器' }, @@ -123,3 +125,65 @@ describe('staleLinked —— 锚换了类型,另一格里失配的值', () => { expect(staleLinked(PARAMS, { originalInternalPn: 'AMS1117-3.3', candidateInternalPn: '' }, { 'chip-type': 'originalInternalPn' })).toEqual([]); }); }); + +/** + * 互斥(2026-08-31 用户定:「不应该能选到同样的两个芯片」)—— 与联动是两件事: + * 那个是引导(收窄),**这个是墙**(别的格占了的候选这里不列;自己写一个同样的就按不下启动)。 + * W05 那两格**两样都声明了**,所以最后一条专测合起来是什么样。 + */ +describe('互斥:别的格占走的候选不列', () => { + const both = (key: string, label: string): LaunchParam => ({ ...combo(key, label), distinctGroup: 'part' }); + const W05_PARAMS: LaunchParam[] = [both('originalInternalPn', '原内部料号'), both('candidateInternalPn', '候选内部料号')]; + const w05Face = (key: string, values: Record, anchors: Record = {}) => + comboFace(W05_PARAMS.find((p) => p.key === key)!, [...W05_PARAMS, LOOSE], values, anchors); + + it('原料号选了 LM2596S-5.0 → 候选那格不再列它,并说清是谁占了', () => { + const f = w05Face('candidateInternalPn', { originalInternalPn: 'LM2596S-5.0' }); + expect(f.groups.flatMap((g) => g.options.map((o) => o.value))).toEqual(['XL2596S-5.0', 'AMS1117-3.3', 'OLD-001']); + expect(f.taken).toEqual([{ by: '原内部料号', value: 'LM2596S-5.0' }]); + }); + + it('**反过来也一样**(不分先后,谁占了谁的都不列)—— 候选先填,原料号那格就少它一条', () => { + const f = w05Face('originalInternalPn', { candidateInternalPn: 'XL2596S-5.0' }); + expect(f.groups.flatMap((g) => g.options.map((o) => o.value))).toEqual(['LM2596S-5.0', 'AMS1117-3.3', 'OLD-001']); + }); + + it('**自己此刻填着的那个永远留着** —— 抽掉正在显示的值,下拉当场变空白,人看着像被谁清空了', () => { + const f = w05Face('candidateInternalPn', { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'LM2596S-5.0' }); + expect(f.groups.flatMap((g) => g.options.map((o) => o.value))).toContain('LM2596S-5.0'); + }); + + it('不在互斥组里的格子(没有 distinctGroup)谁也不管', () => { + const f = comboFace(LOOSE, [...W05_PARAMS, LOOSE], { originalInternalPn: 'LM2596S-5.0', customerCode: 'CU-0142' }, {}); + expect(f.taken).toEqual([]); + expect(f.groups).toEqual([{ label: undefined, options: LOOSE.options }]); + }); + + it('**联动与互斥合起来**(W05 的真形状):按锚的类型收窄之后,锚占着的那颗照样不列', () => { + const f = w05Face('candidateInternalPn', { originalInternalPn: 'LM2596S-5.0' }, { 'chip-type': 'originalInternalPn' }); + expect(f.narrow).toEqual({ by: '原内部料号', label: 'DC-DC 降压稳压器' }); + // 收窄留下 LM / XL / OLD-001,互斥再抽掉锚占着的 LM + expect(f.groups.map((g) => [g.label, g.options.map((o) => o.value)])).toEqual([ + ['DC-DC 降压稳压器', ['XL2596S-5.0']], + ['未标类型', ['OLD-001']], + ]); + }); + + describe('distinctIssues —— 还能撞上的那条路(自己「新增」写了一个同样的)', () => { + it('点名**后填的那格**,说清撞上了谁', () => { + expect(distinctIssues(W05_PARAMS, { originalInternalPn: 'BRAND-NEW', candidateInternalPn: 'BRAND-NEW' })).toEqual([ + { key: 'candidateInternalPn', by: '原内部料号' }, + ]); + }); + + it('去空白、不分大小写 —— 与服务端同一把尺', () => { + expect(distinctIssues(W05_PARAMS, { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: ' lm2596s-5.0 ' })).toHaveLength(1); + }); + + it('两颗不同的、空的、不同组的 —— 一个都不算撞上', () => { + expect(distinctIssues(W05_PARAMS, { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'XL2596S-5.0' })).toEqual([]); + expect(distinctIssues(W05_PARAMS, { originalInternalPn: '', candidateInternalPn: '' })).toEqual([]); + expect(distinctIssues([...W05_PARAMS, LOOSE], { originalInternalPn: 'CU-0142', customerCode: 'CU-0142' })).toEqual([]); + }); + }); +}); diff --git a/apps/web/src/features/gov-task/internal/launch-link.ts b/apps/web/src/features/gov-task/internal/launch-link.ts index 70d78df..ac5338b 100644 --- a/apps/web/src/features/gov-task/internal/launch-link.ts +++ b/apps/web/src/features/gov-task/internal/launch-link.ts @@ -1,5 +1,8 @@ /** - * 启动参数的**按分组联动**(2026-08-31 用户定)—— 纯函数,不碰 React。 + * 启动参数里**格与格之间**的那些事(2026-08-31 用户定)—— 纯函数,不碰 React。两件,别混: + * · **按分组联动**(`linkGroup`):按候选的分组键互相收窄 —— 引导,填了别的照收; + * · **互斥**(`distinctGroup`):同组的几格不能是同一个值 —— **墙**,别的格占了的候选这里不列, + * 自己写一个同样的值就按不下「启动活动」(真正拦住它的是服务端 `validateLaunchParams`)。 * * 服务端下发的两样东西就是这里的全部依据:候选各自带 `group` / `groupLabel`(清单 `x-options.group`), * 参数带 `linkGroup`(清单 `x-link`)。前端**不认识**「原内部料号」「芯片类型」这些名字 —— @@ -13,16 +16,22 @@ * ②锚没有分组键(未标类型的老料)、或锚填的是库里没有的新值(新增料号)→ 不收窄。 * ③收窄之后,「未标类型」的候选**列在末尾单独一组**,不是不列。 * - * 服务端不据此立墙(`launch-params.ts` 头注):两格填不同类型照样收得下,类型一致归活动自己的收步判据管。 + * **联动**这一件服务端不立墙(`launch-params.ts` 头注):两格填不同类型照样收得下,类型一致归活动自己的收步判据管; + * **互斥**那一件服务端真拦(同一把尺 `launchDistinctKey`)—— 这里少列几条只是不让人走到那一步。 */ import type { LaunchOption, LaunchParam } from '@contracts'; +import { launchDistinctKey } from '@contracts'; /** 组名 → 当下坐着锚位的那格字段名 */ export type LinkAnchors = Readonly>; /** 一组候选(`label` 为空 = 这批候选没有分组键,摆一列不分组) */ export type OptionGroup = { readonly label: string | undefined; readonly options: readonly LaunchOption[] }; -/** 这一格该摆什么:分好组的候选 + 被谁按什么类型收窄(没收窄就没有) */ -export type ComboFace = { readonly groups: readonly OptionGroup[]; readonly narrow: { readonly by: string; readonly label: string } | undefined }; +/** 这一格该摆什么:分好组的候选 + 被谁按什么类型收窄(没收窄就没有)+ 被别的格占走、这里不列的那几个 */ +export type ComboFace = { + readonly groups: readonly OptionGroup[]; + readonly narrow: { readonly by: string; readonly label: string } | undefined; + readonly taken: readonly { readonly by: string; readonly value: string }[]; +}; const UNGROUPED = '未标类型'; @@ -95,22 +104,75 @@ function groupOptions(options: readonly LaunchOption[]): readonly OptionGroup[] return out; } -/** 这一格摆哪些候选、被谁收窄 —— 锚自己与不联动的格子都摆全量 */ +/** 同一互斥组里**别的格**此刻占着的值(空的不算);自己那格不算自己占的 */ +function takenByOthers( + param: LaunchParam, + params: readonly LaunchParam[], + values: Readonly>, +): readonly { readonly by: string; readonly value: string }[] { + const group = param.distinctGroup; + if (group === undefined) return []; + const out: { by: string; value: string }[] = []; + for (const other of params) { + if (other.key === param.key || other.distinctGroup !== group) continue; + const value = valueOf(values, other.key); + if (value !== '') out.push({ by: other.label, value }); + } + return out; +} + +/** 这一格摆哪些候选、被谁收窄 —— 锚自己与不联动的格子都摆全量;别的格占走的一律不列 */ export function comboFace( param: LaunchParam, params: readonly LaunchParam[], values: Readonly>, anchors: LinkAnchors, ): ComboFace { - const all = param.options ?? []; + const taken = takenByOthers(param, params, values); + const mine = launchDistinctKey(valueOf(values, param.key)); + const takenKeys = new Set(taken.map((t) => launchDistinctKey(t.value))); + /* + * 互斥:别的格占了的候选这里不列 —— **唯独自己此刻填着的那个永远留着**。 + * 抽掉正在显示的值,` )} - {p.type === 'combo' && setVal(p.key, v)} />} + {p.type === 'combo' && face !== undefined && setVal(p.key, v)} />} {p.type === 'list' && ( {((values[p.key] as string[]) ?? ['']).map((v, i, arr) => ( @@ -582,19 +593,21 @@ function LaunchDrawer({ activityId, name, onCancel, onLaunch }: { activityId: st )} {p.desc !== undefined && {p.desc}} {/* 联动(2026-08-31):被收窄的那格要说清是按谁的什么收的,以及怎么换 —— 悄悄少几条候选比不收窄更糟 */} - {p.type === 'combo' && - (() => { - const narrow = comboFace(p, spec, values, anchors).narrow; - return narrow === undefined ? null : ( - - 已按「{narrow.by}」收窄:只列同为「{narrow.label}」的候选(库里没标的在末尾一组),要别的就回去改「{narrow.by}」,或在这里新增{p.label}。 - - ); - })()} + {face?.narrow !== undefined && ( + + 已按「{face.narrow.by}」收窄:只列同为「{face.narrow.label}」的候选(库里没标的在末尾一组),要别的就回去改「{face.narrow.by}」,或在这里新增{p.label}。 + + )} + {/* 互斥(2026-08-31 用户定):别的格占了的候选这里不列 —— 少了几条同样要说出来,同一条道理 */} + {face !== undefined && face.taken.length > 0 && ( + {face.taken.map((t) => `「${t.by}」已经选了 ${t.value}`).join('、')} —— 这一格不再列它,两格不能是同一个。 + )} + {dupeOf[p.key] !== undefined && {launchDistinctMessage(dupeOf[p.key] ?? '')}} {linkNote[p.key] !== undefined && {linkNote[p.key]}} {fieldErr[p.key] !== undefined && {fieldErr[p.key]}} - ))} + ); + })} {err !== undefined && {err}} diff --git a/docs/API文档-活动模块.md b/docs/API文档-活动模块.md index 3b8c616..1646bfb 100644 --- a/docs/API文档-活动模块.md +++ b/docs/API文档-活动模块.md @@ -386,6 +386,7 @@ window.addEventListener('message', (e) => { "desc": "差异评估基准;库里没有该 PN 也可启动,启动后派数据工单补参数。", "placeholder": "LM2596S-5.0", "linkGroup": "chip-type", + "distinctGroup": "part", "options": [{ "value": "LM2596S-5.0", "label": "LM2596S-5.0", "group": "1", "groupLabel": "DC-DC 降压稳压器" }] }, { @@ -396,6 +397,7 @@ window.addEventListener('message', (e) => { "desc": "被评估物料;库里有就直接选,没有就自己填料号,启动后经数据工单创建。", "placeholder": "XL2596S-5.0", "linkGroup": "chip-type", + "distinctGroup": "part", "options": [ { "value": "LM2596S-5.0", "label": "LM2596S-5.0", "group": "1", "groupLabel": "DC-DC 降压稳压器" }, { "value": "AMS1117-3.3", "label": "AMS1117-3.3", "group": "2", "groupLabel": "LDO 线性稳压器" }, @@ -416,7 +418,11 @@ W05 的候选内部料号(2026-08-27 用户定)与原内部料号**同款下拉 > **这是表单的引导,不是墙**:服务端不据 `linkGroup` 校验 —— 两格填不同 `group` 的值照样收得下(combo 本就允许候选之外的值)。类型一致性由活动自己的收步判据管(W05 是 S1 的 `sub_eval_part_distinct_chip_type_id = 1`)。 -> 活动清单侧怎么声明(前端不用管,给写活动包的人看):`properties.` 上 `title`→label、`description`→desc、`examples[0]`→placeholder、`default`、`required[]`;`enum`+`enumNames`→select;`'x-control'` 定标量控件(`text|textarea|select|combo`);`'x-options': { ledger:'域.对象', value:'列', label:'列' }` 让候选从正式台账现读;`'x-options'.group` 让候选带上分组键 —— 只写列名(`'chip_type_id'`)= 有键没名,整份写 `{ column, ledger, value, label }` 再从字典表换人读名;`'x-link': '<组名>'` 把几格结成联动组(同组按分组键互相收窄,见上);`type:'array'` 且元素是字符串→list,**数组字段标 `x-control: combo|select` 就摆成单选**(提交单个字符串,后端包成一元数组);**`'x-order': <数字>` 定表单次序**(清单存在 jsonb 里,书写顺序到不了这里);`format:'file'` 的字段本期不上表单;select 没候选降成 text。 +**互斥(2026-08-31 用户定:「不应该能选到同样的两个芯片」)**:参数可带 `distinctGroup`(互斥组名)。**同一 `distinctGroup` 的几格不能填同一个值** —— 前端把**别的格此刻占着的候选**从这一格的下拉里抽掉(唯独这一格自己填着的那个永远留着,否则 `