Files
JX-BPOS/activities/quote-sourcing/__tests__/steps.test.ts
T
ChaiHaoandClaude Opus 5 ade0fa5a94 A2-A7 全部补齐到验收线;顺手把几处「门拦住却不给路」的坑填了
活动:A2 选型 / A3 寻源 / A4 定价 / A6 议价 / A7 归档五件各补成真活动包 ——
契约宽进(input.required 空,归一在活动内)、台账必填声明(含行级 requiredWhen)、
入场哨兵与 L1/L2 判据一律读基座数、两份件(在办可编辑 + 办结静态快照)、
三案例常备、包内自测。e2e 各三案例 released、行动被拒 0、发口正确、提交闸每单真拦。

基座(共享面,改一处影响所有活动,故每处都带回归):
· 重发判断走在站上场之前 —— 人答完那步常已收掉,readyToWork 先抛「不能再往它上面挂活」,
  于是「重发已答过的卡 = 无事发生」这条唯一活路在最需要时够不着,A1 基线当场熔断;
· 决策卡补幂等兜底(与执行工单同一把尺):头两次无事发生,第三次起才算被拒;
· 输出没成形先就地重问一次,原话进工作日志、不再摆上卡面;
· 工具调用读数 tool_<名>_calls —— 「机检只读基座数」这条纪律第一次覆盖到工具;
· 执行卡 steps/standard 补进出参 schema(从前能到卡面全靠厂商 JSON 模式宽松);
· 件的就地更新改逐节点 morph,保存先把新值镜像进只读那份 —— 不闪、不等回声。

A1(基线,全程守住):措辞三档对齐(必填/建议/选填)、禁止拿历史价当客户目标价、
卡骨架从步骤剧本搬进说明书(压成一拍连做时剧本没摊开,会发出没有指导书的裸卡)。

契约两处**新增可选字段**(非破坏):FieldDeclaration.requiredWhen、工单 finalHtml;
api-report 同批更新。docs/adr 与 docs/archive 的 13 份文件在工作树里不见了,
本次予以恢复而非提交删除。

门禁:typecheck / lint / depcruise / test(1366)全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:28:31 +08:00

224 lines
8.7 KiB
TypeScript

/**
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(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<string, unknown> = {
rfqRef: 'RFQ MD14.1',
rows: [{ pn: 'BBPY00201209601Y00', type_tag: 'B', alt_pn: 'PBY201209T-601Y-N' }],
};
const FRESH_INTAKE: Record<string, unknown> = {
rfqRef: 'RFQ-2026-0555',
records: [{ pn: 'PESD2ETH-XR', type_tag: 'B' }],
};
const FRESH_SOLO: Record<string, unknown> = { 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<string, unknown>) => ({
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<string, unknown> = 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<string, unknown>): 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([]);
});
});