/** * 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(A1 的账)。 */ import { describe, expect, it } from 'vitest'; import { evaluateCondition } from '@sfeel'; import { CMMN_XML, SOURCING_PHASES, SOURCING_STEPS } from '../src/case-model.ts'; import { manifest } from '../src/manifest.ts'; const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map( (m) => m[1] ?? '', ); describe('剧本与案例模型对得上 —— 对不上的条目等于没写', () => { it('每一条剧本都指向一个真实存在的计划项', () => { for (const step of SOURCING_STEPS) { expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item); } }); it('图上每一步都有剧本', () => { for (const id of ITEM_IDS) { expect(SOURCING_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id); } }); it('清单里挂的就是这份剧本(不是另抄一份)', () => { expect(manifest.assets.businessModel.steps).toBe(SOURCING_STEPS); }); }); describe('doneWhen 是「成果已存在」的判据,不是「该不该做」的判据', () => { /** 开案那一刻的三种局面:选型转来 / 受理直达 / 单飞 —— 台账一行没有 */ const FRESH_ALT: Record = { rfqRef: 'RFQ MD14.1', rows: [{ pn: 'BBPY00201209601Y00', type_tag: 'B', alt_pn: 'PBY201209T-601Y-N' }], }; const FRESH_INTAKE: Record = { rfqRef: 'RFQ-2026-0555', records: [{ pn: 'PESD2ETH-XR', type_tag: 'B' }], }; const FRESH_SOLO: Record = { subject: '两颗现货料问底价', body: 'SS8050 20K……' }; it('**开局一步都不该被自动收掉** —— 三种开局都要 false', () => { for (const fresh of [FRESH_ALT, FRESH_INTAKE, FRESH_SOLO]) { for (const step of SOURCING_STEPS) { if (step.doneWhen === undefined) continue; expect( safeEval(step.doneWhen, fresh), `${step.item} 的 doneWhen「${step.doneWhen}」开局就为真`, ).toBe(false); } } }); /* ── ① 归一预填 ─────────────────────────────────────────────── */ it('整份落表、行数对上、预填走完 → 归一收得掉(没有 A 类时预填数是写过的 0)', () => { expect( safeEval(doneOf('S1_normalize'), { quote_line_total: 3, quote_sourcing_quote_list_rows: 3, hist_priced_total: 1, }), ).toBe(true); expect( safeEval(doneOf('S1_normalize'), { quote_line_total: 2, quote_sourcing_quote_list_rows: 2, hist_priced_total: 0, }), ).toBe(true); }); it('**预填那一轮没走完 → 归一收不掉** —— hist_priced_total 没写过,未绑定比较为假', () => { expect( safeEval(doneOf('S1_normalize'), { quote_line_total: 3, quote_sourcing_quote_list_rows: 3 }), ).toBe(false); }); it('少拆多写都收不掉 —— 自报条数与基座行数对不上', () => { expect( safeEval(doneOf('S1_normalize'), { quote_line_total: 3, quote_sourcing_quote_list_rows: 2, hist_priced_total: 0, }), ).toBe(false); }); /* ── ② 交 PRM 补价:人环 ────────────────────────────────────── */ it('**PRM 那一步不给 doneWhen** —— 引擎不许替人交卷', () => { expect(SOURCING_STEPS.find((s) => s.item === 'S2_fill')?.doneWhen).toBeUndefined(); }); it('补价这一步摆的是询价工作单(不是收口清单):卡上零输入、闸看必填、答复处理记 sourced_total', () => { const fill = SOURCING_STEPS.find((s) => s.item === 'S2_fill'); expect(fill).toBeDefined(); expect(fill?.view).toBe('sourcing-worksheet'); /* * **执行卡零输入**(2026-08-02 用户定:填写全去工作区)—— * 供应商与底价在工作单那份件上就地填,卡上不绑表; * 必填(底价)由提交闸看住,闸回落到必交付物的台账声明。 * 从前这儿断言卡里带 `"ledger":"quote_sourcing.quote_list"` 的 blocks。 */ expect(fill?.guide).toContain('卡上不摆表、不摆格子'); expect(fill?.guide).not.toContain('"blocks"'); expect(fill?.guide).toContain('提交闸'); expect(fill?.guide).toContain('sourced_total'); }); /* ── ③ 汇稿定盘:收步判据与验收判据同源 ─────────────────────── */ const compiled = (over: Record) => ({ quote_sourcing_quote_list_rows: 3, quote_sourcing_quote_list_missing: 0, quote_sourcing_quote_list_price_basis_hist: 1, quote_sourcing_quote_list_price_basis_sourced: 2, route_decided_total: 1, ...over, }); it('每行有价 + 价源守恒 + 定盘记过 → 汇稿收得掉', () => { expect(safeEval(doneOf('S3_compile'), compiled({}))).toBe(true); }); it('**还有一行缺底价 → 收不掉** —— 与提交闸、放行门读同一个数', () => { expect(safeEval(doneOf('S3_compile'), compiled({ quote_sourcing_quote_list_missing: 1 }))).toBe(false); }); it('**价源没记齐(守恒不成立)→ 收不掉** —— 松出来的那一截就是返工', () => { expect(safeEval(doneOf('S3_compile'), compiled({ quote_sourcing_quote_list_price_basis_sourced: 1 }))).toBe(false); }); it('没记过定盘数 → 收不掉 —— 未绑定的名字比大小为假', () => { const scope: Record = compiled({}); delete scope['route_decided_total']; expect(safeEval(doneOf('S3_compile'), scope)).toBe(false); }); it('**放行那一项不给 doneWhen** —— 终局唯放行', () => { expect(SOURCING_STEPS.find((s) => s.item === 'T_release')?.doneWhen).toBeUndefined(); }); it('归一那一步声明了 reads: rows 与 records —— 上游载荷置前不截断', () => { const reads = SOURCING_STEPS.find((s) => s.item === 'S1_normalize')?.reads ?? []; expect(reads).toContain('rows'); expect(reads).toContain('records'); }); }); describe('剧本声明的东西活动真有', () => { const declaredLedger = manifest.declarations.ledger.map((l) => `${l.domain}.${l.object}`); const declaredTools = manifest.declarations.tools.map((t) => t.name); it('每一步要写的台账都在清单的台账声明里', () => { for (const step of SOURCING_STEPS) { for (const address of step.writes) { expect(declaredLedger, `${step.item} 要写 ${address}`).toContain(address); } } }); it('每一步要调的工具都在清单的工具声明里', () => { for (const step of SOURCING_STEPS) { for (const tool of step.tools) { expect(declaredTools, `${step.item} 要调 ${tool}`).toContain(tool); } } }); it('每一步都摆同一份件(一单一模版贯穿始终),且模版真在包里', () => { const names = new Set(manifest.assets.viewTemplates.map((t) => t.name)); for (const step of SOURCING_STEPS) { expect(step.view, `${step.item} 没定画面`).toBeDefined(); expect(names.has(step.view ?? ''), `${step.item} 的 view「${step.view}」不在模版名单里`).toBe(true); } }); it('每一步都说得出它交出什么', () => { for (const step of SOURCING_STEPS) { expect(step.intent.trim(), `${step.item} 没写 intent`).not.toBe(''); expect(step.guide.trim().length, `${step.item} 的 guide 太短`).toBeGreaterThan(20); } }); }); function doneOf(item: string): string { const found = SOURCING_STEPS.find((s) => s.item === item)?.doneWhen; if (found === undefined) throw new Error(`${item} 没有 doneWhen`); return found; } function safeEval(expression: string, scope: Record): boolean { try { return evaluateCondition(expression, scope); } catch { return false; } } describe('阶段刻度:每一项都有格可待', () => { const inPhase = new Set(SOURCING_PHASES.flatMap((p) => p.items)); it('**除放行外每一项恰好属于一个阶段**', () => { const planned = SOURCING_STEPS.map((s) => s.item).filter((id) => id !== 'T_release'); expect(planned.filter((id) => !inPhase.has(id))).toEqual([]); }); it('一项不进两个阶段', () => { const all = SOURCING_PHASES.flatMap((p) => p.items); expect(all.length).toBe(new Set(all).size); }); it('**放行不在名单里** —— 收口那一格由基座统一加', () => { expect(inPhase.has('T_release')).toBe(false); }); it('阶段里不许出现案例模型没有的项', () => { const ids = new Set(SOURCING_STEPS.map((s) => s.item)); expect([...inPhase].filter((id) => !ids.has(id))).toEqual([]); }); });