From ab0dfd8b49b04c56edd059501985d0164aa3f1d0 Mon Sep 17 00:00:00 2001 From: xcs Date: Sat, 29 Aug 2026 14:01:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(w05):=20S1=20=E9=85=8D=E6=96=B9=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E6=A0=A1=E9=AA=8C=E5=85=A5=E5=8F=82?= =?UTF-8?q?=E3=80=81=E9=93=BA=E6=A0=BC=E3=80=81=E7=BC=BA=E4=BB=80=E4=B9=88?= =?UTF-8?q?=E6=B4=BE=E4=B8=80=E5=BC=A0=E6=95=B0=E6=8D=AE=E5=B7=A5=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在库与否是一次 lookup 的结果,不是一句断言:先取 found_pn_ref(refersTo 查无此值当场拒)再按它判。 参数骨架两颗料都铺、值回填现值 —— 于是「在库但当年参数没录全」也自动被红带拦住。 发卡的条件只用基座读数:gate_reason 为空串时那一列不写、未绑定,而 S-FEEL 里未绑定的名字 `!= ""` 求值为 true —— 拿它当条件恰恰会在一切正常时把卡发出去。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet --- .../__tests__/activity.test.ts | 2 + .../__tests__/steps.test.ts | 68 ++++++++ .../src/case-model.ts | 145 +++++++++++++++++- 3 files changed, 214 insertions(+), 1 deletion(-) diff --git a/activities/part-substitution-assessment/__tests__/activity.test.ts b/activities/part-substitution-assessment/__tests__/activity.test.ts index 7454f79..8462a9a 100644 --- a/activities/part-substitution-assessment/__tests__/activity.test.ts +++ b/activities/part-substitution-assessment/__tests__/activity.test.ts @@ -156,6 +156,8 @@ describe('台账声明', () => { /* 「两颗料是不是同一个类型」于是成了基座数得出来的数:不同取值恰好 1 种 */ expect(colOf('sub_eval.part', 'chip_type_id')).toMatchObject({ required: true, distinct: true }); expect(colOf('sub_eval.part', 'exists_status')?.tally).toEqual(['found', 'absent']); + /* 「在库」是一次 lookup 的结果,不是一句断言:判 found 就必须拿得出一个基座查得到的料号 */ + expect(colOf('sub_eval.part', 'found_pn_ref')?.refersTo).toBe('parts.profile.internal_pn'); expect(ledgerOf('sub_eval.case')?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['eval_ref']); }); diff --git a/activities/part-substitution-assessment/__tests__/steps.test.ts b/activities/part-substitution-assessment/__tests__/steps.test.ts index 8cc6612..eeccad7 100644 --- a/activities/part-substitution-assessment/__tests__/steps.test.ts +++ b/activities/part-substitution-assessment/__tests__/steps.test.ts @@ -113,3 +113,71 @@ describe('剧本声明的读写面 ⊆ 清单声明', () => { } }); }); + +describe('S1 配方', () => { + const recipe = stepOf('S1_inputs')?.auto; + const acts = recipe?.do ?? []; + const kinds = acts.map((a) => a.kind); + + it('六笔,次序是:两颗料 → 案卷 → 主数据骨架 → 参数骨架 → 卡 → 交代', () => { + expect(kinds).toEqual(['project', 'project', 'project', 'project', 'card', 'say']); + expect(acts.map((a) => (a.kind === 'project' ? a.into : undefined)).filter(Boolean)).toEqual([ + 'sub_eval.part', 'sub_eval.case', 'parts.profile', 'parts.parameter', + ]); + }); + + it('案号由 let 铸,只从本单开案时刻取材', () => { + expect(recipe?.let?.['evalRef']).toEqual({ cat: ['W05-', { fmt: { in: 'case:openedAt' }, as: 'digits' }] }); + }); + + it('两颗料由字面两行立起来 —— 这张表是后面每一笔遍历的唯一来源', () => { + const part = acts[0]; + expect(part?.kind === 'project' && 'rows' in part.from && part.from.rows).toHaveLength(2); + /* 在库与否是一次 lookup 的结果,不是一句断言:先取依据,再按依据判 */ + const fields = part?.kind === 'project' ? part.fields : undefined; + expect(fields?.['found_pn_ref']).toMatchObject({ lookup: 'parts.profile', take: 'internal_pn' }); + expect(fields?.['exists_status']).toEqual({ when: 'found_pn_ref != ""', then: 'found', else: 'absent' }); + }); + + it('参数骨架:两颗料都铺、值回填现值 —— 「在库但当年参数没录全」也要被拦住', () => { + const params = acts[3]; + expect(params?.kind === 'project' && params.prune).toBe(true); + const from = params?.kind === 'project' ? params.from : undefined; + /* 外层是本单两颗料,里层是该类型的字段清单 —— 人没挑类型时 where 一行不命中,什么也不铺 */ + expect(from).toMatchObject({ each: 'mine:sub_eval.part', then: { each: 'parts.type_param' } }); + const fields = params?.kind === 'project' ? params.fields : undefined; + expect(fields?.['param_value']).toMatchObject({ lookup: 'parts.parameter', take: 'param_value' }); + }); + + it('**发卡的条件全是基座读数** —— 不许拿 `gate_reason != ""` 当条件', () => { + const card = acts[4]; + const when = card?.kind === 'card' ? (card.when ?? '') : ''; + /* + * S-FEEL 里**未绑定的名字 `!= ""` 求值为 true**,而 `gate_reason` 为空串时那一列压根不写、 + * 于是未绑定 —— 拿它当条件,恰恰在一切正常时把卡发出去。条件只用恒有值的基座读数。 + */ + expect(when).not.toContain('gate_reason'); + expect(when).toContain('sub_eval_part_exists_status_found < 2'); + expect(when).toContain('parts_parameter_missing > 0'); + }); + + it('卡:数据工单、四张表都在闸上、且有 basis(基座拒发 basis 为空的工单)', () => { + const card = acts[4]; + expect(card?.kind === 'card' && card.as).toBe('execution'); + expect(card?.kind === 'card' && card.variant).toBe('data'); + expect(card?.kind === 'card' && card.gateLedgers).toEqual(['parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter']); + expect((card?.kind === 'card' ? card.basis ?? [] : []).length).toBeGreaterThan(0); + expect((card?.kind === 'card' ? card.guide ?? [] : []).length).toBeGreaterThanOrEqual(3); + }); + + it('发卡条件与 doneWhen 是同一件事的正反面', () => { + const when = acts[4]?.kind === 'card' ? (acts[4].when ?? '') : ''; + for (const reading of [ + 'sub_eval_part_exists_status_found', 'sub_eval_part_missing', + 'sub_eval_part_distinct_chip_type_id', 'parts_profile_missing', 'parts_parameter_missing', + ]) { + expect(when).toContain(reading); + expect(stepOf('S1_inputs')?.doneWhen).toContain(reading); + } + }); +}); diff --git a/activities/part-substitution-assessment/src/case-model.ts b/activities/part-substitution-assessment/src/case-model.ts index d3751b7..afc4708 100644 --- a/activities/part-substitution-assessment/src/case-model.ts +++ b/activities/part-substitution-assessment/src/case-model.ts @@ -129,7 +129,150 @@ export const ASSESSMENT_STEPS = [ doneWhen: 'sub_eval_part_exists_status_found = 2 and sub_eval_part_missing = 0 and ' + 'sub_eval_part_distinct_chip_type_id = 1 and parts_profile_missing = 0 and parts_parameter_missing = 0', - auto: { do: [{ kind: 'say' as const, text: '正在核对两颗料的在库状态与参数齐备情况。' }] }, + auto: { + /** 整单只有这一个案号,从本单开案时刻取材 —— **不许从正式表里别单的号里挑**(踩过) */ + let: { evalRef: { cat: ['W05-', { fmt: { in: 'case:openedAt' }, as: 'digits' as const }] } }, + do: [ + /* + * ① 本单两颗料 —— **后面每一笔遍历的唯一来源**。 + * 挤进案卷一行里就没法 `where` 过滤,而不能过滤就会给已经在库的那颗也铺一遍空参数行。 + */ + { + kind: 'project' as const, + into: 'sub_eval.part', + from: { + rows: [ + { pn: { in: 'originalInternalPn' }, role: 'original' }, + { pn: { in: 'candidateInternalPn' }, role: 'candidate' }, + ], + }, + fields: { + eval_ref: { in: 'evalRef' }, + internal_pn: { col: 'pn' }, + role: { col: 'role' }, + /* 先取依据,再按依据判 —— 字段按声明顺序求值,算出来的进同一行的 S-FEEL 作用域 */ + found_pn_ref: { lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'internal_pn', else: '' }, + exists_status: { when: 'found_pn_ref != ""', then: 'found', else: 'absent' }, + chip_type_id: { lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'chip_type_id', else: '' }, + }, + }, + /* ② 案卷一行:`gate_reason` 是**摆在件上给人看**的那句话(条件判断另用基座读数,见 ⑤) */ + { + kind: 'project' as const, + into: 'sub_eval.case', + from: { once: true as const }, + fields: { + eval_ref: { in: 'evalRef' }, + chip_type_id: { lookup: 'mine:sub_eval.part', by: { role: 'original' }, take: 'chip_type_id', else: '' }, + gate_reason: { + when: 'sub_eval_part_rows < 2', + then: '原料号与候选料号还没填全 —— 在下面把两格补上。', + else: { + when: 'sub_eval_part_exists_status_absent > 0', + then: '有料号库里查不到 —— 在下面把它的主数据建起来。', + else: { + when: 'sub_eval_part_missing > 0', + then: '有料号还没归芯片类型 —— 在下面给它选一个;没有合适的就新建一个,并把它的核心参数字段一并定下来。', + else: { + when: 'sub_eval_part_distinct_chip_type_id > 1', + then: '两颗料不是同一个芯片类型,逐项比对无从谈起 —— 换一颗料,或者把归错的那颗类型改对。', + else: { + when: 'parts_profile_missing + parts_parameter_missing > 0', + then: '还有必填格空着 —— 红带点名的那几格填上就能提交。', + else: '', + }, + }, + }, + }, + }, + }, + }, + /* + * ③ 主数据骨架:**两颗料都铺、值回填现值**。 + * 在库那颗回填出它此刻的样子(人不必重填);要建那颗全空等人填。 + * **「在库的老料还没归类型」于是也被拦住** —— 那一格回填出来是空的,而 `chip_type_id` 必填。 + * 人不必在件上「加一行」:要建哪颗料是启动参数就定死的事,行由系统摆好,他只填格子。 + */ + { + kind: 'project' as const, + into: 'parts.profile', + prune: true, + from: { each: 'mine:sub_eval.part' }, + fields: { + internal_pn: { col: 'internal_pn' }, + chip_type_id: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'chip_type_id', else: '' }, + brand_name: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'brand_name', else: '' }, + lifecycle_status: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'lifecycle_status', else: '' }, + specification_description: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'specification_description', else: '' }, + manufacturer_name: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'manufacturer_name', else: '' }, + datasheet_ref: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'datasheet_ref', else: '' }, + datasheet_version: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'datasheet_version', else: '' }, + }, + }, + /* + * ④ 参数骨架 —— 三件事一笔办完: + * · 人还没挑类型 → `{outer:'chip_type_id'}` 是空串,`where` 一行不命中,**什么也不铺**; + * 他挑了、按了保存,行就出来 —— **「保存即铺格」不是特判,是这一笔的自然结果**; + * · 在库那颗料**也**铺,值回填现值 → 屏上是它此刻的样子,`_missing` 不会误报; + * · **「在库但当年参数没录全」于是自动被拦住** —— 那一格回填出来是空的,红带点名、闸按不下。 + */ + { + kind: 'project' as const, + into: 'parts.parameter', + prune: true, + from: { + each: 'mine:sub_eval.part', + then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } }, + }, + fields: { + internal_pn: { outer: 'internal_pn' }, + param_name: { col: 'param_name' }, + param_unit: { col: 'param_unit' }, + param_value: { + lookup: 'parts.parameter', + by: { internal_pn: { outer: 'internal_pn' }, param_name: { col: 'param_name' } }, + take: 'param_value', + else: '', + }, + param_note: { + lookup: 'parts.parameter', + by: { internal_pn: { outer: 'internal_pn' }, param_name: { col: 'param_name' } }, + take: 'param_note', + else: '', + }, + }, + }, + /* + * ⑤ 缺什么就派一张数据工单。 + * **条件只用基座读数**:`gate_reason` 为空串时那一列压根不写、于是未绑定, + * 而 S-FEEL 里未绑定的名字 `!= ""` 求值为 **true** —— 拿它当条件,恰恰在一切正常时把卡发出去。 + */ + { + kind: 'card' as const, + when: + 'sub_eval_part_exists_status_found < 2 or sub_eval_part_missing > 0 or ' + + 'sub_eval_part_distinct_chip_type_id > 1 or parts_profile_missing > 0 or parts_parameter_missing > 0', + as: 'execution' as const, + variant: 'data' as const, + title: '数据工单 · 差异评估的输入还差几格', + basis: [ + { k: '原料号', v: { in: 'originalInternalPn' } }, + { k: '候选料号', v: { in: 'candidateInternalPn' } }, + { k: '还差什么', v: { in: 'gate_reason' } }, + ], + guide: [ + '两颗料必须归到**同一个芯片类型**下 —— 要比的核心参数字段由类型定,类型不同就没有可比的字段。', + '类型下拉里没有合适的,就新建一个,并把它的**核心参数字段**一并定下来:每个字段要有名字、一句说明、以及硬约束还是软约束。', + '说明写「这个参数为什么要紧」——**它是后面判定差异时唯一的解释来源**,写成参数名的复述等于没写。', + '硬约束 = 不满足就不能用(烧芯片、超温域、装不上);软约束 = 影响表现但可权衡(功耗、噪声)。', + '参数值填**原厂规格书上的值**,查不到就留空 —— 留空会被红带点名,但**编一个值比留空坏得多**。', + ], + standard: '红带消失、提交钮变绿:两颗料都在库、归了同一个类型、每个核心参数字段都有值。', + gateLedgers: ['parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter'], + }, + { kind: 'say' as const, text: '已核对两颗料的在库状态与参数齐备情况。' }, + ], + }, }, { item: 'S2_assess',