` 会把行截成套娃(A2 逮过,记在陷阱表)。
+ */
+const QUOTE_CARDS = `
+
+
+
+
+
+
+
+
+
+
+
报价函条款(发出去就是承诺)
+
客户料号
+
品类 / 制造商
+
年用量
+
报价 CNY
+
交期
+
SPQ / MOQ
+
贸易条款
+
有效期至
+
+
+
议价(填回下表,这里回显)
+
客户表态
+
客户还价
+
客户原话
+
让步处置
+
定案
+
成交价
+
客户约束
+
+
+
+`;
+
+/** 议价结果 N-01 的四档 —— 静态规则,尺挂在墙上(素材:MVP 示例工作流 §1.4) */
+const OUTCOME_RULES = `
+
+
ACCEPT
+
客户明确接受这个价。「按这个价走」「下单吧」「PO 稍后发」——
+ 定案成交,成交价就是报价,转订单履约。
+
+
+
COUNTER
+
客户还了一个价。把他报的那个数落进「客户还价」那一格 ——
+ 没有数字的「太贵了」先追问,追不到才按无回复落。还价的条目先落成「待拍板」,
+ 等人按让步梯度拍一下才有结论:接了算成交、退回重批、或守价不让 —— 系统不替你让价。
+
+
+
REJECT
+
客户明确不做了。选了别家、项目取消、规格不匹配 ——
+ 定案不成交,把缘由记进备注,转情报归档:这一单丢了,情报留下。
+
+
+
NOREPLY
+
催到有效期过仍不表态。客户不回不是结论 ——
+ D+3 第一轮催问确认收到探态度,D+7 最后一催;催过了才轮到按超时定案。
+
+
`;
+
+/** 让步梯度 N-02 的三档 —— 静态规则(素材:MVP 示例工作流 §1.4 让价核线) */
+const CONCESSION_RULES = `
+
+
≤ 3%
+
接了拿单。意思意思的压价 —— 来回一轮的时间成本比这三个点贵,
+ 当场接住,把单子锁下来。
+
+
+
3% ~ 10%
+
授权内回旋。让一半再报回去,把让出去的换成量或账期 ——
+ 先弄清争的是价格、交期还是 MOQ:争交期的用交期换,一上来就降价,后面只剩降价这一招。
+
+
+
> 10%
+
超出授权,退回成本定价重批。这一档系统不替任何人放行,也不许私自破线 ——
+ 底价线以下的单,每补一次货都在亏。破线不是勇气是事故。
+
+
`;
+
+export const NEGOTIATE_WORKSHEET = doc({
+ badge: 'sheet',
+ kicker: '统一报价 · A6 报价议价 · 议价作业',
+ title: '议价工作单',
+ titleRef: 'rfq_ref',
+ state:
+ '
待回音 / 条',
body:
- /* `items` 是起单入参的数组,基座渲染数组时给的是条数 —— 正好是「这一单要报几条」 */
- statsBand([
- ['报价条目', 'items'],
- ['单价 CNY', 'unit_price'],
- ['交期', 'lead_time'],
- ['有效期', 'valid_until'],
- ]) +
- section('报价条款六样 —— 缺一不可',
- kvGrid([
- ['单价 CNY', 'unit_price'],
- ['交期', 'lead_time'],
- ['最小包装量 SPQ', 'spq'],
- ['最小起订量 MOQ', 'moq'],
- ['贸易条款', 'trade_terms'],
- ['有效期', 'valid_until'],
- ]) +
- noteOnly(
- '单价与交期
照审批通过的草案抄,不做任何「顺手优化」—— 改价要重新过审批点。' +
- '有效期按行情波动定,不要一律写「长期有效」:那等于把汇率与料价风险自己扛了。' +
- '拿不准的项
宁可空着走补全,不要填一个看起来合理的值。',
- ),
- { text: '缺一样多一轮' },
- ) +
- section('本单已成形的报价行',
- tableOnly('mine:quote_negotiate.formal_quote', [
- ['报价单号', 'quote_no', 'mono'],
- ['型号 PN', 'pn', 'mono'],
- ['单价', 'unit_price'],
- ['交期', 'lead_time'],
- ['有效期', 'valid_until'],
- ]),
- { text: '只列本单写下的行' },
- ) +
- section('议价的节拍 —— 一轮一版',
- ladder([
- ['1', '写成正式报价单', '中英双语,六样齐备'],
- ['2', '人工按下发出', '触达客户的一下,系统不替人做这个决定'],
- ['3', 'D+3 提醒到点', '没有跟进的报价等于没报'],
- ['4', 'D+7 提醒到点', '第二记催办,仍无回音即等超时'],
- ['5', '客户回音 → 议价调整', '改价、改交期、换料都是新一版,重新走发出'],
- ]),
- { text: '往来看得出先后' },
- ) +
- section('案例当下进程', stagesOnly('case:stages'), { text: 'Enabled ≠ Active' }) +
- section('这一步还没碰外界',
- verdict('报价单此刻只是草稿 —— 一封信都还没发出去', 'wait', '发出这一下必须人工按') +
- noteOnly(
- '发出是
写类动作:过程中只登记为「动作意向」—— 入参冻结、等放行,放行前不触达世界。' +
- '预授权是逐项列举的:催办模板可即时发,
报价单不行。',
- ),
- ),
+ part('议价任务', '客户
· 报价函
· 共
条') +
+ `
${ico('info', 14)}
+ 这一屏是一份报价函的作业台:系统已按 A5 批准的价排好版,条款与有效期都在下面 ——
+ 报出去的价一个数字都不改(要改价得回审批)。你要做的是把它发给客户,
+ 按 D+3 / D+7 的节奏催问,客户回话之后在「客户表态」那一列逐行落。
+ 这一列系统一格都不写:客户回了什么,只有收信的人看得见。
+
` +
+ FOUR +
+ part('一单议价是怎么走完的', '批准价 → 发出 → 客户表态 → 定案 → 去向') +
+ DEAL_LINE +
+ part('逐颗报价档案', '条款在左 · 议价在右') +
+ QUOTE_CARDS +
+ part('议价结果 N-01', '客户的话只有四种表态 —— 尺挂在墙上') +
+ OUTCOME_RULES +
+ part('让步梯度 N-02', '还价幅度决定让不让、谁来让') +
+ CONCESSION_RULES +
+ part('填回工作表', '客户表态落在这 —— 补齐了执行单才提交得了') +
+ GAP_BAR +
+ editGate('quote_negotiate.line', '报价函填回表') +
+ LINE_TABLE +
+ addRowBar('quote_negotiate.line') +
+ `
${ico('info', 13)}
+ 带红标的客户表态是必填,而且只由你填 —— 缺一行执行单按不下提交,
+ 与放行门是同一个数同一套算法。四个取值一个字母不许差:
+ ACCEPT 接受 · COUNTER 还价 · REJECT 拒绝 · NOREPLY 无回复。
+ 还价的行务必把客户报的那个价填进客户还价 —— 那一格空着这张单同样提交不了,
+ 后面拍板全看它。客户只回了一部分料的,没回的那几行按 NOREPLY 落,
+ 别替客户猜;本轮不报的料按行首 × 撤掉。
+
`,
+ foot: sign('BD 议价 · AI 排版报价函并按两把尺定案,客户那一侧的话由你落', '统一报价 · A6 报价议价 · 在办'),
});
-/** 局面二:待放行 —— 按下的那一刻信就发出去,这是最后一次能改的机会 */
-export const QUOTE_RELEASE = viewShell({
- title: '正式报价单 · 放行即发出',
- kicker: 'A6 · 报价议价 · 成果待签',
- tone: 'case',
- icon: 'send',
- stamp: { kind: 'live', text: '待签字放行' },
- sign: 'A6 报价议价 · 正式报价单(待放行即发出)',
+/* ==================== 局面二 · 成交定案清单(拍板 / 放行 / 归档)==================== */
+
+/** 落边图:定案清单 → 三个去向(命中边由壳上 v-r1/v-r2/v-r3 上色,件不算不判) */
+const ROUTE_FIG = `
+
+
+ 命中边实线亮 · 未命中留灰虚线
+ 按序求值首个命中即出:判了退回重批 → 回定价;其次有成交 → 转履约;都没成 → 转归档
+ 判定在你签字那一刻由系统现算 —— 要改去向,在这张表上改定案那几格
+
+
`;
+
+export const NEGOTIATE_ROSTER = doc({
+ badge: 'file-check',
+ kicker: '统一报价 · A6 报价议价 · 交付成果',
+ title: '成交定案清单',
+ titleRef: 'rfq_ref',
+ state:
+ '
报价 条 · 待拍板 条(要归零)',
body:
- statsBand([
- ['单价 CNY', 'unit_price'],
- ['交期', 'lead_time'],
- ['最小起订量 MOQ', 'moq'],
- ['有效期', 'valid_until'],
- ]) +
- section('本单交付:正式报价单',
- tableOnly('mine:quote_negotiate.formal_quote', [
- ['报价单号', 'quote_no', 'mono'],
- ['型号 PN', 'pn', 'mono'],
- ['单价', 'unit_price'],
- ['交期', 'lead_time'],
- ['SPQ', 'spq'],
- ['MOQ', 'moq'],
- ['贸易条款', 'trade_terms'],
- ['有效期', 'valid_until'],
- ]),
- { text: '发出去的就是这几行' },
- ) +
- section('历次报价对照 —— 这一轮让了多少',
- /*
- * 这一张**故意不用 `mine:`**:它取共享域整表,含既往单的行。
- * 本单成果只看 `mine:`(见上一节),而「让了多少」这件事天然要跟别的单比 ——
- * 没有上一轮的价摆在旁边,人签字时判断不了这一让的分寸。
- */
- tableOnly('shared.history_price', [
- ['型号 PN', 'pn', 'mono'],
- ['报价日期', 'quoted_at', 'mono'],
- ['单价', 'unit_price'],
- ['交期', 'lead_time'],
- ]) +
- noteOnly(
- '这一节
不是本单成果,是共享域里同一料号的历次报价(含既往单)—— 摆在这里只做一件事:' +
- '给这一轮的价一个参照系。报价日期在列上,先后按日期对;它由 A7 情报归档一单一单沉下来。',
- ),
- { text: '含既往单' },
- ) +
- section('放行前要过的四道判据',
- ladder([
- ['1', '单价 / 交期 / SPQ / MOQ / 贸易条款 / 有效期齐备', 'L1 机检 · missingQuoteFields = 0'],
- ['2', '按报价有效期 P-04 注明有效期', 'L2 机检 · validityDays 大于 0'],
- ['3', '已建 D+3 / D+7 跟进', 'L2 机检 · 没有跟进的报价等于没报'],
- ['4', 'BD 放行', 'L5 人签 · 签了才算数'],
- ]),
- { text: '三机检 · 一人签' },
- ) +
- section('出口三选一 —— 恰发一口',
- ladder([
- ['1', '成交', '转订单履约(ORM),这条流走到终点'],
- ['2', '有异议', '回 A4 重开新实例重算 —— 旧的已终,不再入算'],
- ['3', '拒绝 / 超时', '转 A7 情报归档,把拒绝原因与竞对价位登记下来'],
- ]) +
- noteOnly(
- '三个出口对应三种后续,随放行
恰选一口,下游网关读的就是它;
不选口不许放行 ——' +
- '含糊的结果比没结果更坏。「拒绝」不是失败终态:原因回写历史情报后,下一单的三分类与利润模型会用上它。',
- ),
- { text: '下游只读这一口' },
- ) +
- section('按下放行会发生什么',
- verdict('放行即落地:台账回写 + 报价单出站发送', 'wait', '已成动作留存,不回滚') +
- noteOnly(
- '①
落地预检先跑:交付物齐备、动作意向可执行、授权在契约边界内 —— 预检不过不发卡;' +
- '② 放行通过
即刻落地,出站动作附幂等键;' +
- '③
落地收敛之后输出口才发射 —— 下游只见落地完成的任务。',
- ),
- ),
+ part('议价要素', '报价议价 · 能查表的归判定表,要让价的归人') +
+ FOUR +
+ part('定案分布', '成交 + 待拍板 + 退回重批 + 不成交 = 总条数') +
+ DIST +
+ `
${ico('info', 14)}
+ 四段条数由基座逐行现数,不是谁报上来的。橙段(待拍板)是过渡态 ——
+ 客户还了价而还没人拍板,它归零这一单才走得到放行门:接了算成交、
+ 退回重批算一条正经的路、守价不让算不成交。要让价就得在授权内让,
+ 超过一成的让幅一律回去重批,不私自破线。
+
` +
+ part('正式报价函', '
条 · 每条都带客户的话') +
+ GAP_BAR +
+ editGate('quote_negotiate.line', '成交定案表') +
+ SETTLED_TABLE +
+ `
${ico('info', 13)}
+ 每一行摆的是这一条的来路:报了多少、客户怎么表态、还了什么价、
+ 让步梯度判在哪一档、最后定成什么。签字前请重点看两处:定案列里非绿的条目,
+ 与客户原话列 —— 没有客户原话的还价,下一轮谈判就是重新开始。
+ 这一屏可改的是成交价与客户原话两列(你的职权面)。
+
` +
+ part('落边 · 这一签流向哪里', '出口按序求值 · 首个命中即出') +
+ ROUTE_FIG +
+ `
+ ${ico('git-branch', 15)}
+
议价结论 ——
+
转入活动
+
` +
+ part('放行后即执行的落地动作', '门前 → 门后') +
+ `
+
${ico('check', 11)}
+
草稿层合并进正式版 本任务写下的报价函与议价结论一并落账;
+ 你就地改过的成交价与客户原话随之落定。
+
+
${ico('check', 11)}
+
命中的那一口此刻才发射 报价函
+ 条随载荷交给上面亮线指向的下游;
+ 另两口本次不起单 —— 放行之前,世界上什么都没有发生。
+
+
${ico('check', 11)}
+
不成交也留资产 拒绝原因与竞对价位随载荷交给情报归档 ——
+ 这一单丢了,下一单的三分类与利润模型会用上它。
+
+
`,
+ foot: sign('BD 议价放行 · AI 排版与定案留痕 —— 要改哪一格,在这张定案表上就地改', '统一报价 · A6 报价议价 → 定去向'),
});
diff --git a/activities/quote-pricing/__tests__/activity.test.ts b/activities/quote-pricing/__tests__/activity.test.ts
new file mode 100644
index 0000000..ed6f4d6
--- /dev/null
+++ b/activities/quote-pricing/__tests__/activity.test.ts
@@ -0,0 +1,499 @@
+/**
+ * A4 成本定价的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。
+ * 案例活动没有 `run`,自测不跑回合;测的是清单本身与四处对齐
+ * (判据谓词 / 出口载荷 / 剧本声明 / 两把尺的档位)。
+ */
+import { describe, expect, it } from 'vitest';
+import { activityManifestSchema } from '@contracts';
+import { evaluateCondition, evaluateUnaryTest } from '@sfeel';
+import { createMockHost } from '@sdk';
+import { P01_DMN, manifest } from '../src/manifest.ts';
+import activity from '../src/index.ts';
+
+describe('清单本身合规', () => {
+ it('过 @contracts 的 schema', () => {
+ expect(() => activityManifestSchema.parse(manifest)).not.toThrow();
+ });
+
+ it('监听时限已配 —— 无悬空监听', () => {
+ expect(manifest.declarations.listeners.every((l) => /^P/.test(l.deadline))).toBe(true);
+ });
+
+ it('L1/L2 判据都给了可机检的谓词 —— 验收等级不得谎报', () => {
+ for (const c of manifest.contract.acceptanceCriteria) {
+ if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef, c.key).toBeDefined();
+ }
+ });
+
+ it('禁区短而硬,授权边界给了调人预算', () => {
+ expect(manifest.contract.prohibitions.length).toBeGreaterThan(0);
+ expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(0);
+ });
+
+ it('每件必交付物都有台账地址', () => {
+ for (const d of manifest.contract.requiredDeliverables) {
+ expect(d.ledger, d.key).toBeDefined();
+ }
+ });
+
+ /*
+ * **宽进严出**:起单一个必填都不设 —— 上游(A3 的 quotation 口)发的是
+ * `{rfqRef, rows}`,而上一版必填 `rfqRef + customerCode + records`,
+ * 门口拦的恰是主路。归一在活动内部做。
+ */
+ it('起单宽进:input.required 是空的,三种来路的键都声明着', () => {
+ expect(manifest.contract.input.data['required']).toEqual([]);
+ const properties = manifest.contract.input.data['properties'] as Record
;
+ for (const key of ['rfqRef', 'rows', 'records', 'subject', 'body']) {
+ expect(properties[key], `入参少了 ${key}`).toBeDefined();
+ }
+ });
+
+ /*
+ * **拟报单价是必填** —— 这是 A4 的门面判据:件上的缺价红带、执行卡的提交闸、
+ * 放行门 L1 三处同源(都数基座的 `_missing`)。声明丢了 required,三处一起失明。
+ */
+ it('拟报单价标了 required —— 红带 / 提交闸 / 放行门三处同源的那个数才存在', () => {
+ const draft = manifest.declarations.ledger.find((d) => d.object === 'draft');
+ expect(draft?.fields.find((f) => f.name === 'quoted_price')?.required).toBe(true);
+ });
+
+ /*
+ * **建议价与拟报单价分两列是结构治,不是措辞治**(2026-08-03 单飞案例逼出来的):
+ * 只有一列价时,剧本写「算不出的留空」,模型照样给库里查不到的料编了价 ——
+ * 一条要它分辨「算得出/算不出」的规矩会走岔;「这一列你不写」没有分支可走岔。
+ */
+ it('建议价与拟报单价是两列 —— AI 写建议价,人写拟报单价', () => {
+ const draft = manifest.declarations.ledger.find((d) => d.object === 'draft');
+ const suggested = draft?.fields.find((f) => f.name === 'suggested_price');
+ expect(suggested, '缺 suggested_price 这一列').toBeDefined();
+ expect(suggested?.required, '建议价不该是必填 —— 算不出就该空着').toBeUndefined();
+ for (const step of ['S1_price', 'S3_gate']) {
+ const guide = manifest.assets.businessModel.steps?.find((s) => s.item === step)?.guide ?? '';
+ expect(guide, `${step} 没写死「quoted_price 你一格都不写」`).toMatch(/quoted_price[^\n]*一格都不写|一格都不写[^\n]*quoted_price/);
+ }
+ });
+
+ it('门禁处置给了取值域 —— 三档条数由基座现数,不由活动自报', () => {
+ const draft = manifest.declarations.ledger.find((d) => d.object === 'draft');
+ expect(draft?.fields.find((f) => f.name === 'disposition')?.tally).toEqual(['PASS', 'NOTE', 'BLOCK']);
+ });
+
+ /*
+ * 我方写的注不标 `expect` —— 标了 `_gaps` 恒大于 0,读它的判据永远绿不了(A1 的账)。
+ * 客户目标价才是「客户该给而没给的」那一问。
+ */
+ it('expect 只标客户该给的那一格,不标我方写的注', () => {
+ const draft = manifest.declarations.ledger.find((d) => d.object === 'draft');
+ expect(draft?.fields.find((f) => f.name === 'target_price')?.expect).toBe(true);
+ expect(draft?.fields.find((f) => f.name === 'price_note')?.expect).toBeUndefined();
+ });
+
+ it('三条常备案例形状真不同:寻源转来 / 客户压价超限 / 单飞无基准', () => {
+ expect(manifest.samples.length).toBe(3);
+ const [flowIn, overLimit, solo] = manifest.samples;
+ expect((flowIn?.input as { rows?: unknown[] }).rows?.length).toBeGreaterThan(0);
+ expect((overLimit?.input as { rows?: Array> }).rows?.[0]?.['target_price']).toBeLessThan(
+ (overLimit?.input as { rows?: Array> }).rows?.[0]?.['base_price'] ?? 0,
+ );
+ expect((solo?.input as { rows?: unknown[] }).rows).toBeUndefined();
+ });
+});
+
+describe('案例模型 —— 双表示同版本', () => {
+ const model = manifest.assets.businessModel;
+ const cmmn = parseCmmnLite(model.cmmnXml);
+
+ it('CMMN 与说明书同在,不允许分叉', () => {
+ expect(model.cmmnXml).toContain(' {
+ expect(items(model.cmmnXml)).toEqual(['S1_price', 'S2_review', 'S3_gate', 'T_approve', 'T_release']);
+ });
+
+ it('三处人的门都标了人工激活:BD 定价 · 超限拍板 · 草案放行(底价自检是算术,不标)', () => {
+ expect(cmmn.manualActivation).toEqual(['S2_review', 'T_approve', 'T_release']);
+ });
+
+ it('不用阶段 / 里程碑 / 裁量项', () => {
+ for (const tag of [' {
+ const condition = sentryIf(model.cmmnXml, 's_over');
+ expect(condition).toContain('quote_pricing_draft_disposition_block > 0');
+ expect(condition).toContain('confirmed_total');
+ // 有拦截档且人核过价 → 叫人
+ expect(safeEval(condition, { quote_pricing_draft_disposition_block: 1, confirmed_total: 2 })).toBe(true);
+ // 拦截档清零 → 不叫人(这一步整个不入场)
+ expect(safeEval(condition, { quote_pricing_draft_disposition_block: 0, confirmed_total: 2 })).toBe(false);
+ // 人还没核价 → 先别叫(他自己回调掉的那几条不该再惊动一个人)
+ expect(safeEval(condition, { quote_pricing_draft_disposition_block: 1 })).toBe(false);
+ });
+
+ /*
+ * 入场哨兵也归「机检只读基座数」管(第四次违反就藏在哨兵里)——
+ * 上一版 `s_priced` 读活动自报的 `draft_total`:模型不记这个账,门就永不开。
+ */
+ it('交人核价的入场哨兵读基座行数,不读自报的草案条数', () => {
+ const condition = sentryIf(model.cmmnXml, 's_priced');
+ expect(condition).toContain('quote_pricing_draft_rows');
+ expect(condition).not.toContain('draft_total');
+ expect(safeEval(condition, { line_total: 2, quote_pricing_draft_rows: 2, priced_total: 2 })).toBe(true);
+ // 行落了但算价那一轮还没走完(priced_total 没写过)→ 门不开
+ expect(safeEval(condition, { line_total: 2, quote_pricing_draft_rows: 2 })).toBe(false);
+ });
+
+ it('放行门哨兵带 ifPart:每行有价 + 处置守恒 + 拦截清零 + 定盘记过,全读基座数', () => {
+ const condition = sentryIf(model.cmmnXml, 's_ready');
+ expect(condition).toContain('quote_pricing_draft_missing = 0');
+ expect(condition).toContain('quote_pricing_draft_disposition_block = 0');
+ const done = {
+ route_decided_total: 1,
+ quote_pricing_draft_rows: 3,
+ quote_pricing_draft_missing: 0,
+ quote_pricing_draft_disposition_pass: 2,
+ quote_pricing_draft_disposition_note: 1,
+ quote_pricing_draft_disposition_block: 0,
+ };
+ expect(safeEval(condition, done)).toBe(true);
+ // 还有一行缺拟报价 → 门不开
+ expect(safeEval(condition, { ...done, quote_pricing_draft_missing: 1 })).toBe(false);
+ // 还有条目压在拦截档 → 门不开(哪怕守恒仍成立)
+ expect(
+ safeEval(condition, { ...done, quote_pricing_draft_note: 0, quote_pricing_draft_disposition_note: 0, quote_pricing_draft_disposition_block: 1 }),
+ ).toBe(false);
+ // 处置没记齐(守恒不成立)→ 门不开
+ expect(safeEval(condition, { ...done, quote_pricing_draft_disposition_note: 0 })).toBe(false);
+ });
+
+ it('每个计划项都有剧本', () => {
+ const ids = items(model.cmmnXml);
+ expect((model.steps ?? []).map((s) => s.item)).toEqual(ids);
+ for (const step of model.steps ?? []) {
+ expect(step.guide.length, `${step.item} 的做法`).toBeGreaterThan(80);
+ expect(step.intent.length, `${step.item} 的意图`).toBeGreaterThan(6);
+ }
+ });
+
+ it('说明书不点名计划项', () => {
+ const named = [...model.narrative.matchAll(/\b(?:S\d+_[A-Za-z]+|T_[a-z]+)\b/g)].map((m) => m[0]);
+ const ids = new Set(items(model.cmmnXml));
+ for (const name of named) {
+ expect(ids.has(name), `说明书提到的 ${name} 不在案例模型里`).toBe(true);
+ }
+ });
+
+ it('说明书只讲整单的事:成品、怎么说话、怎么收口', () => {
+ for (const topic of ['开案', '要说话', '一拍连做', '结案']) {
+ expect(model.narrative, topic).toContain(topic);
+ }
+ });
+});
+
+describe('声明与契约对得上', () => {
+ const columns = new Set(
+ manifest.declarations.ledger.flatMap((d) => [`${d.domain}.${d.object}`, ...d.fields.map((f) => f.name)]),
+ );
+
+ it('必交付物的台账地址都声明过', () => {
+ for (const d of manifest.contract.requiredDeliverables) {
+ expect(columns.has(d.ledger ?? ''), `${d.key} → ${d.ledger}`).toBe(true);
+ }
+ });
+
+ it('editableAtRelease 的列都在成果表的声明里', () => {
+ const draft = manifest.declarations.ledger.find((d) => d.object === 'draft');
+ const names = new Set((draft?.fields ?? []).map((f) => f.name));
+ for (const d of manifest.contract.requiredDeliverables) {
+ for (const col of d.editableAtRelease ?? []) {
+ expect(names.has(col), `editableAtRelease 里的 ${col}`).toBe(true);
+ }
+ }
+ });
+
+ const rowReadings = new Set([
+ ...manifest.declarations.ledger.map((d) => `${d.domain}_${d.object}_rows`),
+ ...manifest.declarations.ledger
+ .filter((d) => d.fields.some((f) => f.required === true))
+ .map((d) => `${d.domain}_${d.object}_missing`),
+ ...manifest.declarations.ledger.flatMap((d) =>
+ d.fields.flatMap((f) =>
+ (f.tally ?? []).map((v) => `${d.domain}_${d.object}_${f.name}_${v.toLowerCase().replace(/[^a-z0-9_]/g, '')}`),
+ ),
+ ),
+ ]);
+
+ it('L1/L2 谓词读的名字都有承载(台账列或基座读数)', () => {
+ for (const c of manifest.contract.acceptanceCriteria) {
+ if (c.level !== 'L1' && c.level !== 'L2') continue;
+ for (const identifier of (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? []) {
+ if (['and', 'or', 'not', 'true', 'false', 'null', 'in', 'between'].includes(identifier)) continue;
+ if (rowReadings.has(identifier)) continue;
+ expect(columns.has(identifier), `${c.key} 的谓词引用了 ${identifier}`).toBe(true);
+ }
+ }
+ });
+
+ /*
+ * **机检必须至少有一边是基座数**(违反史已记四次,每次都是同一个死法:
+ * 那个数要模型自己重算,漏算/改小一次门就废了)。
+ */
+ it('每条 L1/L2 都至少读一个基座读数 —— 不许两边都由活动自报', () => {
+ for (const c of manifest.contract.acceptanceCriteria) {
+ if (c.level !== 'L1' && c.level !== 'L2') continue;
+ const names = (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? [];
+ expect(names.some((n) => rowReadings.has(n)), `${c.key} 一个基座读数都没读`).toBe(true);
+ }
+ });
+
+ it('出口载荷的标量字段都有台账列承载', () => {
+ const snake = (name: string) => name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
+ for (const port of manifest.contract.output.mandatory) {
+ const properties = (port.payload as { properties?: Record }).properties ?? {};
+ for (const [field, spec] of Object.entries(properties)) {
+ if (spec.type === 'array') continue;
+ expect(columns.has(snake(field)), `${port.name}.${field} → ${snake(field)}`).toBe(true);
+ }
+ }
+ });
+
+ it('技能引用的工具都在白名单声明内 —— 载方法不载权力', () => {
+ const declared = new Set(manifest.declarations.tools.map((t) => t.name));
+ for (const skill of manifest.assets.skills) {
+ for (const fragment of skill.crystallized) {
+ expect(declared.has(fragment.tool), `${skill.id} 用了 ${fragment.tool}`).toBe(true);
+ }
+ }
+ });
+
+ it('L3 判据的整句出现在放行剧本里 —— judge 才对得上名', () => {
+ const l3 = manifest.contract.acceptanceCriteria.find((c) => c.level === 'L3');
+ const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
+ expect(l3).toBeDefined();
+ expect(release?.guide, 'T_release 的 guide 要整句抄 L3 判据名').toContain(l3?.label ?? '');
+ });
+});
+
+/*
+ * 两把尺逐行求值 —— 能算的不问 LLM(规范 §5.6)。
+ * 出参就是台账 `disposition` 的取值:模型算完照抄,不必再翻译一道。
+ */
+describe('利润模型 P-01 —— 客户等级 × 类别 → 毛利档', () => {
+ const table = P01_DMN.tables.find((t) => t.id === 'p01-margin');
+
+ it('战略客户 + A 类 → 8%~10%(量换价那一档)', () => {
+ expect(decideBy(table, { customer_grade: '战略客户', type_tag: 'A' })).toMatchObject({ outputs: [0.08, 0.1] });
+ });
+
+ it('普通客户 + A 类 → 12%~15%', () => {
+ expect(decideBy(table, { customer_grade: '普通客户', type_tag: 'A' })).toMatchObject({ rule: 'p01-2' });
+ });
+
+ it('B 类不看客户等级 → 15%~20%(战略客户的 B 类也走这一档)', () => {
+ expect(decideBy(table, { customer_grade: '战略客户', type_tag: 'B' })).toMatchObject({ outputs: [0.15, 0.2] });
+ });
+
+ it('C 类替代/停产件 → 20%~50% 一事一议', () => {
+ expect(decideBy(table, { customer_grade: '普通客户', type_tag: 'C' })).toMatchObject({ rule: 'p01-4' });
+ });
+
+ it('等级与类别都认不出 → 兜底按普通客户标准件档,不是拒绝求值', () => {
+ expect(decideBy(table, {})).toMatchObject({ rule: 'p01-default', outputs: [0.12, 0.15] });
+ });
+});
+
+describe('底价门禁 P-02 —— 偏离底价线两成即止', () => {
+ const table = P01_DMN.tables.find((t) => t.id === 'p02-floor');
+
+ it('在底价线之上 → PASS 直接放行', () => {
+ expect(decideBy(table, { deviation: 0.12 })).toMatchObject({ outputs: ['PASS', true] });
+ expect(decideBy(table, { deviation: 0 })).toMatchObject({ outputs: ['PASS', true] });
+ });
+
+ it('低于底价线两成以内 → NOTE 附理由放行', () => {
+ expect(decideBy(table, { deviation: -0.08 })).toMatchObject({ outputs: ['NOTE', true] });
+ });
+
+ it('低于两成以上 → BLOCK 拦截,人拍板', () => {
+ expect(decideBy(table, { deviation: -0.25 })).toMatchObject({ outputs: ['BLOCK', false] });
+ });
+
+ it('三个处置码与台账取值域逐字一致 —— 模型照抄即可写回', () => {
+ const tally = manifest.declarations.ledger
+ .find((d) => d.object === 'draft')
+ ?.fields.find((f) => f.name === 'disposition')?.tally;
+ expect(new Set(table?.rules.map((r) => String(r.then[0])))).toEqual(new Set(tally));
+ });
+});
+
+describe('两把尺的档位三处同一份 —— 判定表 / 剧本 / 工作单', () => {
+ const guide = manifest.assets.businessModel.steps?.find((s) => s.item === 'S1_price')?.guide ?? '';
+ const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'pricing-worksheet')?.html ?? '';
+
+ it('毛利四档三处都写着同样的数', () => {
+ for (const band of ['8%~10%', '12%~15%', '15%~20%', '20%~50%']) {
+ expect(guide, `剧本少了 ${band}`).toContain(band);
+ expect(sheet, `工作单少了 ${band}`).toContain(band);
+ }
+ });
+
+ /* 门禁在人定完价之后才算得出来,所以三个码写死在底价自检那一步的 guide 里 */
+ it('三个处置码剧本里写死(大写,一个字母不许差)', () => {
+ const gate = manifest.assets.businessModel.steps?.find((s) => s.item === 'S3_gate')?.guide ?? '';
+ for (const code of ['PASS', 'NOTE', 'BLOCK']) expect(gate).toContain(code);
+ });
+});
+
+describe('报告模版 —— 活动持有,容器无脚本执行权', () => {
+ it('两个局面各一张:定价工作单(干活)与报价草案清单(收口)', () => {
+ expect(manifest.assets.viewTemplates.map((t) => t.name)).toEqual(['pricing-worksheet', 'pricing-roster']);
+ });
+
+ it('剧本每一步的 view 都在模版名单里 —— 换不到画面屏上就不动', () => {
+ const names = new Set(manifest.assets.viewTemplates.map((t) => t.name));
+ for (const step of manifest.assets.businessModel.steps ?? []) {
+ expect(names.has(step.view ?? ''), `${step.item} 的 view=${step.view}`).toBe(true);
+ }
+ });
+
+ it('放行那一步摆的是验收目标件 —— 最后摆上去的那张就是归档件', () => {
+ const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
+ expect(release?.view).toBe('pricing-roster');
+ });
+
+ it('模版里没有脚本、没有事件处理器、没有外链 —— 样式自带', () => {
+ for (const template of manifest.assets.viewTemplates) {
+ expect(template.html, template.name).not.toMatch(/')).rejects.toThrow(/无脚本执行权/);
+ });
+});
+
+const CONTRACT = {
+ activityId: 'quote-pricing',
+ version: '1.0.0',
+ data: { rfqRef: 'RFQ MD14.1', rows: [] },
+ message: undefined,
+ outputPorts: { mandatory: ['draft'], selective: [] },
+ prohibitions: [],
+ discretion: [],
+} as const;
+
+describe('代码入口 —— 案例活动没有 run,不是残包', () => {
+ it('默认导出是一个有名字的活动定义', () => {
+ expect(activity.name).toBe('quote-pricing');
+ });
+
+ it('mock 宿主如实报「没有 run」,不静默返回一个假的 outcome', async () => {
+ const host = createMockHost({ contract: CONTRACT });
+ await expect(host.run(activity)).rejects.toThrow(/没有 run/);
+ });
+});
+
+/** 按 DMN 语义逐行求值:FIRST 命中即定,单元格谓词复用 @sfeel */
+function decideBy(
+ table: (typeof P01_DMN)['tables'][number] | undefined,
+ scope: Record