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>
This commit is contained in:
@@ -0,0 +1,616 @@
|
||||
/**
|
||||
* A6 报价议价的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。
|
||||
* 案例活动没有 `run`,自测不跑回合;测的是清单本身与四处对齐
|
||||
* (判据谓词 / 出口载荷与 `when` / 剧本声明 / 两把尺的档位)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { activityManifestSchema } from '@contracts';
|
||||
import { evaluateCondition, evaluateUnaryTest } from '@sfeel';
|
||||
import { createMockHost } from '@sdk';
|
||||
import { N01_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.length).toBeGreaterThan(0);
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **宽进严出**:起单一个必填都不设 —— 上游(A4 的 draft 口)发的是
|
||||
* `{rfqRef, customerCode, items}`,而上一版必填这三个,单飞那条路直接被门口拦掉。
|
||||
*/
|
||||
it('起单宽进:input.required 是空的,两种来路的键都声明着', () => {
|
||||
expect(manifest.contract.input.data['required']).toEqual([]);
|
||||
const properties = manifest.contract.input.data['properties'] as Record<string, unknown>;
|
||||
for (const key of ['rfqRef', 'customerCode', 'items', 'subject', 'body']) {
|
||||
expect(properties[key], `入参少了 ${key}`).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **客户表态是必填** —— 这是 A6 的门面判据:件上的缺表态红带、执行卡的提交闸、
|
||||
* 放行门 L1 三处同源(都数基座的 `_missing`)。声明丢了 required,三处一起失明。
|
||||
*/
|
||||
it('客户表态标了 required —— 红带 / 提交闸 / 放行门三处同源的那个数才存在', () => {
|
||||
const line = manifest.declarations.ledger.find((d) => d.object === 'line');
|
||||
expect(line?.fields.find((f) => f.name === 'reply_type')?.required).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* **客户还价用 `requiredWhen` 钉在还价那一行** —— 表级 required 会让接受 / 拒绝的行
|
||||
* 永远缺一格,把执行卡的闸拦成死锁(A2 逐颗派单踩过同一坑)。
|
||||
*/
|
||||
it('客户还价是行级必填:只有还价那一行才算缺', () => {
|
||||
const line = manifest.declarations.ledger.find((d) => d.object === 'line');
|
||||
const counter = line?.fields.find((f) => f.name === 'counter_price');
|
||||
expect(counter?.requiredWhen).toEqual({ column: 'reply_type', value: 'COUNTER' });
|
||||
expect(counter?.required, '整表必填会把接受/拒绝的行也拦住').toBeUndefined();
|
||||
});
|
||||
|
||||
/*
|
||||
* **客户那一侧的三列 AI 一格都不写**(结构治,不是措辞治):
|
||||
* 一条要模型分辨「这行能不能替客户填」的规矩会走岔;「这几列不写」没有分支可走岔。
|
||||
*/
|
||||
it('客户表态 / 还价 / 原话三列写死了「你一格都不写」', () => {
|
||||
const guide = manifest.assets.businessModel.steps?.find((s) => s.item === 'S1_compose')?.guide ?? '';
|
||||
expect(guide).toMatch(/reply_type[^\n]*counter_price[^\n]*reply_note|这三列你一格都不写/);
|
||||
expect(guide).toContain('一格都不写');
|
||||
});
|
||||
|
||||
it('表态、定案、让步处置三列都给了取值域 —— 条数由基座现数,不由活动自报', () => {
|
||||
const line = manifest.declarations.ledger.find((d) => d.object === 'line');
|
||||
expect(line?.fields.find((f) => f.name === 'reply_type')?.tally).toEqual(['ACCEPT', 'COUNTER', 'REJECT', 'NOREPLY']);
|
||||
expect(line?.fields.find((f) => f.name === 'deal_status')?.tally).toEqual(['WON', 'HAGGLE', 'REVISE', 'LOST']);
|
||||
expect(line?.fields.find((f) => f.name === 'concession')?.tally).toEqual(['ACCEPT_COUNTER', 'NEGOTIATE', 'ESCALATE']);
|
||||
});
|
||||
|
||||
/*
|
||||
* 我方写的注不标 `expect` —— 标了 `_gaps` 恒大于 0,读它的判据永远绿不了(A1 的账)。
|
||||
* 客户还价那一格由 `requiredWhen` 管,不再重复标 expect。
|
||||
*/
|
||||
it('没有一列滥标 expect —— `_gaps` 不是拿来常亮的', () => {
|
||||
const line = manifest.declarations.ledger.find((d) => d.object === 'line');
|
||||
expect((line?.fields ?? []).filter((f) => f.expect === true)).toEqual([]);
|
||||
});
|
||||
|
||||
it('三条常备案例形状真不同:流内一轮成交 / 客户压价 / 单飞超时', () => {
|
||||
expect(manifest.samples.length).toBe(3);
|
||||
const [flowIn, haggled, solo] = manifest.samples;
|
||||
expect((flowIn?.input as { items?: unknown[] }).items?.length).toBeGreaterThan(0);
|
||||
expect(flowIn?.expect?.ports).toEqual(['won']);
|
||||
expect(haggled?.expect?.ports).toEqual(['revise']);
|
||||
expect((solo?.input as { items?: unknown[] }).items).toBeUndefined();
|
||||
expect(solo?.expect?.ports).toEqual(['lost']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('案例模型 —— 双表示同版本', () => {
|
||||
const model = manifest.assets.businessModel;
|
||||
const cmmn = parseCmmnLite(model.cmmnXml);
|
||||
|
||||
it('CMMN 与说明书同在,不允许分叉', () => {
|
||||
expect(model.cmmnXml).toContain('<casePlanModel');
|
||||
expect(model.narrative.length).toBeGreaterThan(400);
|
||||
});
|
||||
|
||||
/* 照 MVP 示例工作流 §1.4 的 S1 发报留档 / S2 跟进议价 / S3 定案交接,一步一项 */
|
||||
it('计划项照 SOP 一步一项:排版 + 发出落回音 + 逐条定案 + 异议拍板 + 放行', () => {
|
||||
expect(items(model.cmmnXml)).toEqual(['S1_compose', 'S2_send', 'S3_settle', 'T_haggle', 'T_release']);
|
||||
});
|
||||
|
||||
it('三处人的门都标了人工激活:发出并等回音 · 异议拍板 · 结果放行(逐条定案是查表,不标)', () => {
|
||||
expect(cmmn.manualActivation).toEqual(['S2_send', 'T_haggle', 'T_release']);
|
||||
});
|
||||
|
||||
it('不用阶段 / 里程碑 / 裁量项', () => {
|
||||
for (const tag of ['<stage', '<milestone', '<planningTable', '<discretionaryItem']) {
|
||||
expect(model.cmmnXml, `不该出现 ${tag}`).not.toContain(tag);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **还价才叫人** —— 这个活动的要害:哨兵读基座按值现数的还价条数,
|
||||
* 而不是活动自报的 `haggle_total`;且要等回音落齐(客户直接接受或拒绝的单
|
||||
* 一个人都不必额外惊动)。
|
||||
*/
|
||||
it('异议拍板的哨兵读**客户自己填的那一格**,不读模型判出来的定案', () => {
|
||||
const condition = sentryIf(model.cmmnXml, 's_haggle');
|
||||
expect(condition).toContain('quote_negotiate_line_reply_type_counter > 0');
|
||||
expect(condition).toContain('replied_total');
|
||||
/*
|
||||
* **「该不该叫人」不能由被叫的那一方说了算**(2026-08-03 两轮实测):
|
||||
* 哨兵读 deal_status 时,模型在定案那一步直接写 REVISE、HAGGLE 一条不留,
|
||||
* 这道门整个不开 —— 单子 released 而决策卡 0 张,让步梯度成了摆设。
|
||||
*/
|
||||
expect(condition, '哨兵不该读模型判出来的定案').not.toContain('deal_status');
|
||||
// 客户还过价且回音落过 → 叫人
|
||||
expect(safeEval(condition, { quote_negotiate_line_reply_type_counter: 1, replied_total: 2 })).toBe(true);
|
||||
// 没人还价 → 不叫人(这一步整个不入场)
|
||||
expect(safeEval(condition, { quote_negotiate_line_reply_type_counter: 0, replied_total: 2 })).toBe(false);
|
||||
// 回音还没落 → 先别叫
|
||||
expect(safeEval(condition, { quote_negotiate_line_reply_type_counter: 1 })).toBe(false);
|
||||
// **模型自己把定案写成 REVISE 也绕不过去** —— 客户那一格还在那儿
|
||||
expect(
|
||||
safeEval(condition, {
|
||||
quote_negotiate_line_reply_type_counter: 1,
|
||||
quote_negotiate_line_deal_status_haggle: 0,
|
||||
quote_negotiate_line_deal_status_revise: 1,
|
||||
replied_total: 2,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* 入场哨兵也归「机检只读基座数」管(A1 第四次违反就藏在哨兵里)——
|
||||
* 读活动自报的条数,模型不记那个账,门就永不开,而它一步也没做错。
|
||||
*/
|
||||
it('交人发出的入场哨兵读基座行数,不读自报的报价条数', () => {
|
||||
const condition = sentryIf(model.cmmnXml, 's_composed');
|
||||
expect(condition).toContain('quote_negotiate_line_rows');
|
||||
expect(condition).not.toContain('quote_total');
|
||||
expect(safeEval(condition, { line_total: 2, quote_negotiate_line_rows: 2, composed_total: 2 })).toBe(true);
|
||||
// 行落了但排版那一轮还没走完(composed_total 没写过)→ 门不开
|
||||
expect(safeEval(condition, { line_total: 2, quote_negotiate_line_rows: 2 })).toBe(false);
|
||||
});
|
||||
|
||||
it('逐条定案的入场哨兵认「回音齐了」:人交回 + 基座数的缺项为 0', () => {
|
||||
const condition = sentryIf(model.cmmnXml, 's_replied');
|
||||
expect(condition).toContain('quote_negotiate_line_missing = 0');
|
||||
expect(safeEval(condition, { replied_total: 2, quote_negotiate_line_rows: 2, quote_negotiate_line_missing: 0 })).toBe(true);
|
||||
expect(safeEval(condition, { replied_total: 2, quote_negotiate_line_rows: 2, quote_negotiate_line_missing: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it('放行门哨兵带 ifPart:每条有表态 + 定案守恒 + 待拍板清零 + 定盘记过,全读基座数', () => {
|
||||
const condition = sentryIf(model.cmmnXml, 's_ready');
|
||||
expect(condition).toContain('quote_negotiate_line_missing = 0');
|
||||
expect(condition).toContain('quote_negotiate_line_deal_status_haggle = 0');
|
||||
const done = {
|
||||
route_decided_total: 1,
|
||||
quote_negotiate_line_rows: 3,
|
||||
quote_negotiate_line_missing: 0,
|
||||
quote_negotiate_line_deal_status_won: 2,
|
||||
quote_negotiate_line_deal_status_haggle: 0,
|
||||
quote_negotiate_line_deal_status_revise: 0,
|
||||
quote_negotiate_line_deal_status_lost: 1,
|
||||
};
|
||||
expect(safeEval(condition, done)).toBe(true);
|
||||
// 还有一行缺客户表态 → 门不开
|
||||
expect(safeEval(condition, { ...done, quote_negotiate_line_missing: 1 })).toBe(false);
|
||||
// 定案没记齐(守恒不成立)→ 门不开
|
||||
expect(safeEval(condition, { ...done, quote_negotiate_line_deal_status_lost: 0 })).toBe(false);
|
||||
/*
|
||||
* **还悬着的还价放不了行**(与 A4 的「拦截档必须清零」同一形状):
|
||||
* HAGGLE 是过渡态,拍板那一下必须把它换成 WON / REVISE / LOST。
|
||||
*/
|
||||
expect(
|
||||
safeEval(condition, {
|
||||
...done,
|
||||
quote_negotiate_line_deal_status_won: 1,
|
||||
quote_negotiate_line_deal_status_haggle: 1,
|
||||
quote_negotiate_line_deal_status_lost: 1,
|
||||
}),
|
||||
).toBe(false);
|
||||
// 退回重批是结论,不是悬着 —— 照样放得了行
|
||||
expect(
|
||||
safeEval(condition, {
|
||||
...done,
|
||||
quote_negotiate_line_deal_status_won: 1,
|
||||
quote_negotiate_line_deal_status_revise: 1,
|
||||
quote_negotiate_line_deal_status_lost: 1,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **决策卡那三处写死的东西也进说明书**(2026-08-03 实测逼出来的):
|
||||
* 当下可为为空那几拍(依赖断点)引擎只说「请调人」,而拍板那一步的 guide
|
||||
* 根本没摊开 —— 模型手上只剩计划项的中文名与自己的想象:
|
||||
* 一轮把中文名当 itemId(连拒两拍),一轮换了问法于是同一个岔路口发出去两张卡。
|
||||
* **说明书每一拍都在提示词里**,写死在这儿才是每一拍都看得见。
|
||||
*/
|
||||
it('说明书写死了那一张决策卡的 itemId / ask / 三个 value —— 断点那几拍只剩它', () => {
|
||||
expect(model.narrative).toContain('"itemId":"T_haggle"');
|
||||
expect(model.narrative).toContain('按哪条路办');
|
||||
for (const value of ['accept-counter', 'revise', 'hold']) {
|
||||
expect(model.narrative, `说明书少了选项 ${value}`).toContain(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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 line = manifest.declarations.ledger.find((d) => d.object === 'line');
|
||||
const names = new Set((line?.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 || f.requiredWhen !== undefined))
|
||||
.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);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **三口的去向是算出来的**:每一口都得写 `when`,少一口基座就退回「人选一口」——
|
||||
* 而选择组恰选一口是这个活动的全部业务分支(E11/E12/E13 读的就是它)。
|
||||
*/
|
||||
it('三个出口对齐流程包 E11/E12/E13,且每一口都带 when', () => {
|
||||
const group = manifest.contract.output.selective[0];
|
||||
expect(group?.group).toBe('negotiation');
|
||||
expect(group?.ports.map((p) => p.name)).toEqual(['revise', 'won', 'lost']);
|
||||
for (const port of group?.ports ?? []) {
|
||||
expect(port.when, `${port.name} 少了 when —— 少一口整组就退回人选`).toBeDefined();
|
||||
expect(port.when).toContain('quote_negotiate_line_deal_status_');
|
||||
expect(port.when, `${port.name} 的 when 不该读过渡态 HAGGLE`).not.toContain('_haggle');
|
||||
}
|
||||
});
|
||||
|
||||
it('去向按序求值:还价优先退回重批,其次成交转履约,都没成转归档', () => {
|
||||
const ports = manifest.contract.output.selective[0]?.ports ?? [];
|
||||
const pick = (scope: Record<string, unknown>): string | undefined =>
|
||||
ports.find((p) => safeEval(p.when ?? '', scope))?.name;
|
||||
expect(pick({ quote_negotiate_line_deal_status_revise: 1, quote_negotiate_line_deal_status_won: 1, quote_negotiate_line_deal_status_lost: 0 })).toBe('revise');
|
||||
expect(pick({ quote_negotiate_line_deal_status_revise: 0, quote_negotiate_line_deal_status_won: 2, quote_negotiate_line_deal_status_lost: 1 })).toBe('won');
|
||||
expect(pick({ quote_negotiate_line_deal_status_revise: 0, quote_negotiate_line_deal_status_won: 0, quote_negotiate_line_deal_status_lost: 2 })).toBe('lost');
|
||||
});
|
||||
|
||||
/* 下游 A7 的入参要 rfqRef / customerCode / quoteNo —— 不成交那一口必须带齐 */
|
||||
it('不成交那一口带齐 A7 要的三个键', () => {
|
||||
const lost = manifest.contract.output.selective[0]?.ports.find((p) => p.name === 'lost');
|
||||
const required = (lost?.payload as { required?: string[] }).required ?? [];
|
||||
for (const key of ['rfqRef', 'customerCode', 'quoteNo']) expect(required, `lost 口少了 ${key}`).toContain(key);
|
||||
});
|
||||
|
||||
it('出口载荷的标量字段都有台账列承载', () => {
|
||||
const snake = (name: string) => name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
|
||||
const ports = [
|
||||
...manifest.contract.output.mandatory,
|
||||
...manifest.contract.output.selective.flatMap((g) => g.ports),
|
||||
];
|
||||
for (const port of ports) {
|
||||
const properties = (port.payload as { properties?: Record<string, { type?: string }> }).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)。
|
||||
* 出参就是台账 `deal_status` / `concession` 的取值:模型算完照抄,不必再翻译一道。
|
||||
*/
|
||||
describe('议价结果 N-01 —— 客户表态 → 定案', () => {
|
||||
const table = N01_DMN.tables.find((t) => t.id === 'n01-outcome');
|
||||
|
||||
it('客户接受 → WON 成交', () => {
|
||||
expect(decideBy(table, { reply_type: 'ACCEPT' })).toMatchObject({ outputs: ['WON'] });
|
||||
});
|
||||
|
||||
it('客户还价 → HAGGLE 压在还价上,要人拍板', () => {
|
||||
expect(decideBy(table, { reply_type: 'COUNTER' })).toMatchObject({ rule: 'n01-2', outputs: ['HAGGLE'] });
|
||||
});
|
||||
|
||||
it('客户拒绝与超时无回复都 → LOST(两条路一个结果,缘由记在备注)', () => {
|
||||
expect(decideBy(table, { reply_type: 'REJECT' })).toMatchObject({ outputs: ['LOST'] });
|
||||
expect(decideBy(table, { reply_type: 'NOREPLY' })).toMatchObject({ outputs: ['LOST'] });
|
||||
});
|
||||
|
||||
it('**认不出的表态按不成交处置** —— 兜底不许猜成接受', () => {
|
||||
expect(decideBy(table, { reply_type: '客户说再看看' })).toMatchObject({ rule: 'n01-default', outputs: ['LOST'] });
|
||||
});
|
||||
|
||||
/*
|
||||
* 这把尺只出三个码:**REVISE 不在其中** —— 「退回重批」是人在决策卡上拍的,
|
||||
* 判定表算不出来。所以是子集关系,不是等号;而多出来的那一个恰好只有一个,
|
||||
* 就是拍板那条路(测这一条,免得哪天有人往 tally 里塞一个没人写得出的码)。
|
||||
*/
|
||||
it('尺出的定案码都在台账取值域里,只有「退回重批」由人拍板补上', () => {
|
||||
const tally = manifest.declarations.ledger
|
||||
.find((d) => d.object === 'line')
|
||||
?.fields.find((f) => f.name === 'deal_status')?.tally ?? [];
|
||||
const fromTable = new Set(table?.rules.map((r) => String(r.then[0])));
|
||||
for (const code of fromTable) expect(tally, `尺出了台账没有的码 ${code}`).toContain(code);
|
||||
expect([...tally].filter((v) => !fromTable.has(v))).toEqual(['REVISE']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('让步梯度 N-02 —— 还价幅度 → 让不让、谁来让', () => {
|
||||
const table = N01_DMN.tables.find((t) => t.id === 'n02-concession');
|
||||
|
||||
it('三个点以内 → 接了拿单', () => {
|
||||
expect(decideBy(table, { gap: 0.02 })).toMatchObject({ outputs: ['ACCEPT_COUNTER', true] });
|
||||
});
|
||||
|
||||
it('三到十个点 → 授权内回旋(FIRST 命中,第二行就是这一档)', () => {
|
||||
expect(decideBy(table, { gap: 0.08 })).toMatchObject({ rule: 'n02-2', outputs: ['NEGOTIATE', true] });
|
||||
});
|
||||
|
||||
it('超过一成 → 超出授权,退回成本定价重批', () => {
|
||||
expect(decideBy(table, { gap: 0.204 })).toMatchObject({ outputs: ['ESCALATE', false] });
|
||||
});
|
||||
|
||||
it('三个处置码与台账取值域逐字一致', () => {
|
||||
const tally = manifest.declarations.ledger
|
||||
.find((d) => d.object === 'line')
|
||||
?.fields.find((f) => f.name === 'concession')?.tally;
|
||||
expect(new Set(table?.rules.map((r) => String(r.then[0])))).toEqual(new Set(tally));
|
||||
});
|
||||
});
|
||||
|
||||
describe('两把尺的档位三处同一份 —— 判定表 / 剧本 / 工作单', () => {
|
||||
const settle = manifest.assets.businessModel.steps?.find((s) => s.item === 'S3_settle')?.guide ?? '';
|
||||
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-worksheet')?.html ?? '';
|
||||
|
||||
it('四个表态码剧本与工作单都写着(大写,一个字母不许差)', () => {
|
||||
for (const code of ['ACCEPT', 'COUNTER', 'REJECT', 'NOREPLY']) {
|
||||
expect(settle, `剧本少了 ${code}`).toContain(code);
|
||||
expect(sheet, `工作单少了 ${code}`).toContain(code);
|
||||
}
|
||||
});
|
||||
|
||||
it('三个定案码与三个让步处置码剧本里写死', () => {
|
||||
for (const code of ['WON', 'HAGGLE', 'REVISE', 'LOST', 'ACCEPT_COUNTER', 'NEGOTIATE', 'ESCALATE']) {
|
||||
expect(settle, `剧本少了 ${code}`).toContain(code);
|
||||
}
|
||||
});
|
||||
|
||||
it('让步梯度的三档门槛剧本与工作单说的是同一组数', () => {
|
||||
for (const threshold of ['0.03', '0.10']) expect(settle, `剧本少了 ${threshold}`).toContain(threshold);
|
||||
for (const band of ['≤ 3%', '3% ~ 10%', '10%']) expect(sheet, `工作单少了 ${band}`).toContain(band);
|
||||
});
|
||||
});
|
||||
|
||||
describe('报告模版 —— 活动持有,容器无脚本执行权', () => {
|
||||
it('两个局面各一张:议价工作单(干活)与成交定案清单(收口)', () => {
|
||||
expect(manifest.assets.viewTemplates.map((t) => t.name)).toEqual(['negotiate-worksheet', 'negotiate-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('negotiate-roster');
|
||||
});
|
||||
|
||||
it('模版里没有脚本、没有事件处理器、没有外链 —— 样式自带', () => {
|
||||
for (const template of manifest.assets.viewTemplates) {
|
||||
expect(template.html, template.name).not.toMatch(/<script|\son[a-z]+=|https?:\/\//i);
|
||||
expect(template.html, template.name).toContain('<style>');
|
||||
}
|
||||
});
|
||||
|
||||
it('议价工作单是干活的件:逐颗档案 + 两把尺 + 填回工作表', () => {
|
||||
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-worksheet')?.html ?? '';
|
||||
expect(sheet).toContain('jc-selcard');
|
||||
expect(sheet).toContain('议价结果 N-01');
|
||||
expect(sheet).toContain('让步梯度 N-02');
|
||||
expect(sheet).toContain('data-edit="quote_negotiate.line"');
|
||||
});
|
||||
|
||||
it('缺表态红带与提交闸同源 —— 件上摆的是基座的 `_missing` 与主键名单', () => {
|
||||
for (const name of ['negotiate-worksheet', 'negotiate-roster']) {
|
||||
const html = manifest.assets.viewTemplates.find((t) => t.name === name)?.html ?? '';
|
||||
expect(html, name).toContain('quote_negotiate_line_missing');
|
||||
expect(html, name).toContain('quote_negotiate_line_missing_keys');
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* 编辑面精确等于人在那个局面的职权面 + **行的钥匙 pn**:
|
||||
* 主键格不挂 `data-col`,服务端按「全表一致列」补键,两行会对到同一行上
|
||||
* (A2 浏览器走查逮过,e2e 走 API 自带主键测不出来)。
|
||||
*
|
||||
* 两张件的编辑面**故意不同**:干活那张收客户的话,收口那张只让改成交价与原话
|
||||
* (= `editableAtRelease`)—— 不同工作各有各的件。
|
||||
*/
|
||||
it('工作单的编辑面 = 客户表态 + 客户还价 + 客户原话 + 行的钥匙 pn', () => {
|
||||
const html = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-worksheet')?.html ?? '';
|
||||
const cols = [...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]);
|
||||
expect(new Set(cols)).toEqual(new Set(['pn', 'reply_type', 'counter_price', 'reply_note']));
|
||||
});
|
||||
|
||||
it('收口件的编辑面 = editableAtRelease 那两列 + 行的钥匙 pn', () => {
|
||||
const html = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-roster')?.html ?? '';
|
||||
const cols = new Set([...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]));
|
||||
const editable = manifest.contract.requiredDeliverables[0]?.editableAtRelease ?? [];
|
||||
expect(cols).toEqual(new Set(['pn', ...editable]));
|
||||
});
|
||||
|
||||
it('定案分布读的是基座按值现数的三段 —— 守恒等式的可视形', () => {
|
||||
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-roster')?.html ?? '';
|
||||
for (const code of ['won', 'haggle', 'revise', 'lost']) {
|
||||
expect(roster).toContain(`quote_negotiate_line_deal_status_${code}`);
|
||||
}
|
||||
});
|
||||
|
||||
/* 三口活动:收口件摆去向判定,由基座在放行那一刻求值后投影(件不算不判) */
|
||||
it('收口件摆落边图,去向由 case:route* 投影', () => {
|
||||
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-roster')?.html ?? '';
|
||||
expect(roster).toContain('data-class-of="case:routeKey"');
|
||||
expect(roster).toContain('case:routeTo');
|
||||
expect(roster).toContain('case:routeWhy');
|
||||
});
|
||||
|
||||
it('模版只做取值 —— data-class-of 不以 data-slot 为前提(纯色块曾整条静默失色)', () => {
|
||||
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-worksheet')?.html ?? '';
|
||||
expect(sheet).toContain('<i data-class-of="deal_status"></i>');
|
||||
});
|
||||
|
||||
it('宿主拒推含脚本的模版', async () => {
|
||||
const host = createMockHost({ contract: CONTRACT });
|
||||
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
|
||||
});
|
||||
});
|
||||
|
||||
const CONTRACT = {
|
||||
activityId: 'quote-negotiate',
|
||||
version: '1.0.0',
|
||||
data: { rfqRef: 'RFQ MD14.1', items: [] },
|
||||
message: undefined,
|
||||
outputPorts: { mandatory: [], selective: ['revise', 'won', 'lost'] },
|
||||
prohibitions: [],
|
||||
discretion: [],
|
||||
} as const;
|
||||
|
||||
describe('代码入口 —— 案例活动没有 run,不是残包', () => {
|
||||
it('默认导出是一个有名字的活动定义', () => {
|
||||
expect(activity.name).toBe('quote-negotiate');
|
||||
});
|
||||
|
||||
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 N01_DMN)['tables'][number] | undefined,
|
||||
scope: Record<string, unknown>,
|
||||
): { outputs: readonly unknown[]; rule: string } | undefined {
|
||||
if (table === undefined) return undefined;
|
||||
for (const rule of table.rules) {
|
||||
const hit = table.inputs.every((input, i) => evaluateUnaryTest(rule.when[i] ?? '-', scope[input.expression], scope));
|
||||
if (hit) return { outputs: rule.then, rule: rule.id };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function items(xml: string): string[] {
|
||||
return [...xml.matchAll(/<(?:humanTask|decisionTask|processTask|caseTask) id="([^"]+)"/g)].map((m) => m[1] ?? '');
|
||||
}
|
||||
|
||||
function parseCmmnLite(xml: string): { manualActivation: string[]; repetition: string[] } {
|
||||
const manualActivation: string[] = [];
|
||||
const repetition: string[] = [];
|
||||
for (const m of xml.matchAll(/<humanTask id="([^"]+)"[\s\S]*?<\/humanTask>/g)) {
|
||||
if (m[0].includes('manualActivationRule')) manualActivation.push(m[1] ?? '');
|
||||
if (m[0].includes('repetitionRule')) repetition.push(m[1] ?? '');
|
||||
}
|
||||
return { manualActivation, repetition };
|
||||
}
|
||||
|
||||
function sentryIf(xml: string, sentryId: string): string {
|
||||
const block = xml.slice(xml.indexOf(`<sentry id="${sentryId}"`));
|
||||
const raw = /<condition>([\s\S]*?)<\/condition>/.exec(block.slice(0, block.indexOf('</sentry>')))?.[1] ?? '';
|
||||
return raw.replaceAll('>', '>').replaceAll('<', '<').replaceAll('&', '&');
|
||||
}
|
||||
|
||||
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
|
||||
try {
|
||||
return evaluateCondition(expression, scope);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(A1 的账)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { evaluateCondition } from '@sfeel';
|
||||
import { CMMN_XML, NEGOTIATE_PHASES, NEGOTIATE_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 NEGOTIATE_STEPS) {
|
||||
expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
|
||||
}
|
||||
});
|
||||
|
||||
it('图上每一步都有剧本', () => {
|
||||
for (const id of ITEM_IDS) {
|
||||
expect(NEGOTIATE_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
|
||||
}
|
||||
});
|
||||
|
||||
it('清单里挂的就是这份剧本(不是另抄一份)', () => {
|
||||
expect(manifest.assets.businessModel.steps).toBe(NEGOTIATE_STEPS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('doneWhen 是「成果已存在」的判据,不是「该不该做」的判据', () => {
|
||||
/** 开案那一刻的两种局面:流内转来 / 单飞 —— 台账一行没有 */
|
||||
const FRESH_FLOW: Record<string, unknown> = {
|
||||
rfqRef: 'RFQ MD14.1',
|
||||
customerCode: 'CU-0142',
|
||||
items: [{ pn: 'BBPY00201209601Y00', quoted_price: 0.085 }],
|
||||
};
|
||||
const FRESH_SOLO: Record<string, unknown> = { subject: 'MD14.2 报价函发客户', body: 'XCJ-7788T 8K 批准价 14.80……' };
|
||||
|
||||
it('**开局一步都不该被自动收掉** —— 两种开局都要 false', () => {
|
||||
for (const fresh of [FRESH_FLOW, FRESH_SOLO]) {
|
||||
for (const step of NEGOTIATE_STEPS) {
|
||||
if (step.doneWhen === undefined) continue;
|
||||
expect(safeEval(step.doneWhen, fresh), `${step.item} 的 doneWhen「${step.doneWhen}」开局就为真`).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* ── 排版那一步 ─────────────────────────────────────────────── */
|
||||
|
||||
const composed = (over: Record<string, unknown>): Record<string, unknown> => ({
|
||||
line_total: 2,
|
||||
quote_negotiate_line_rows: 2,
|
||||
composed_total: 2,
|
||||
...over,
|
||||
});
|
||||
|
||||
it('整份落表、行数对上、排版那一轮走完 → 收得掉', () => {
|
||||
expect(safeEval(doneOf('S1_compose'), composed({}))).toBe(true);
|
||||
});
|
||||
|
||||
it('**第一拍写完行就收步是不许的** —— composed_total 没写过,未绑定的名字比大小为假', () => {
|
||||
const scope = composed({});
|
||||
delete scope['composed_total'];
|
||||
expect(safeEval(doneOf('S1_compose'), scope)).toBe(false);
|
||||
});
|
||||
|
||||
it('行数两边对不上 → 收不掉(漏落一行当场现形,不等到第 10 拍)', () => {
|
||||
expect(safeEval(doneOf('S1_compose'), composed({ quote_negotiate_line_rows: 1 }))).toBe(false);
|
||||
});
|
||||
|
||||
/* ── 三处不给 doneWhen ──────────────────────────────────────── */
|
||||
|
||||
it('**发出等回音、异议拍板、放行三项都不给 doneWhen** —— 给了等于给引擎一把替人签字的钥匙', () => {
|
||||
for (const item of ['S2_send', 'T_haggle', 'T_release']) {
|
||||
expect(NEGOTIATE_STEPS.find((s) => s.item === item)?.doneWhen, `${item} 不该有 doneWhen`).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
/* ── 逐条定案:判完了才算办完(收步判据与验收 L2 同源)────────────── */
|
||||
|
||||
const settled = (over: Record<string, unknown>): Record<string, unknown> => ({
|
||||
quote_negotiate_line_rows: 3,
|
||||
quote_negotiate_line_deal_status_won: 2,
|
||||
quote_negotiate_line_deal_status_haggle: 0,
|
||||
quote_negotiate_line_deal_status_revise: 0,
|
||||
quote_negotiate_line_deal_status_lost: 1,
|
||||
quote_negotiate_line_reply_type_counter: 0,
|
||||
...over,
|
||||
});
|
||||
|
||||
it('四档定案的条数加起来等于行数 → 逐条定案收得掉', () => {
|
||||
expect(safeEval(doneOf('S3_settle'), settled({}))).toBe(true);
|
||||
});
|
||||
|
||||
it('**有待拍板条目也照样收得掉** —— 判出来了就是判完了,还价的由下一步叫人拍板', () => {
|
||||
expect(
|
||||
safeEval(
|
||||
doneOf('S3_settle'),
|
||||
settled({
|
||||
quote_negotiate_line_deal_status_lost: 0,
|
||||
quote_negotiate_line_deal_status_haggle: 1,
|
||||
quote_negotiate_line_reply_type_counter: 1,
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* **模型自己写 REVISE 绕过拍板 → 这一步收不掉**(2026-08-03 实测逼出来的):
|
||||
* 一轮里模型在定案那一步直接把还价的行判成「退回重批」,HAGGLE 一条没有,
|
||||
* 「还价才叫人」那道门整个不开,单子一路 released 而没有人拍过板 —— 决策卡 0 张。
|
||||
* 收步判据补上「待拍板条数 = 客户还价条数」,两边都是基座现数,模型改不动。
|
||||
*/
|
||||
it('客户还了价却没落成待拍板(自己写了 REVISE)→ 收不掉,拍板绕不过去', () => {
|
||||
expect(
|
||||
safeEval(
|
||||
doneOf('S3_settle'),
|
||||
settled({
|
||||
quote_negotiate_line_deal_status_won: 1,
|
||||
quote_negotiate_line_deal_status_haggle: 0,
|
||||
quote_negotiate_line_deal_status_revise: 1,
|
||||
quote_negotiate_line_deal_status_lost: 1,
|
||||
quote_negotiate_line_reply_type_counter: 1,
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('还有行没判 → 收不掉(松出来的那一截就是放行门前的返工)', () => {
|
||||
expect(safeEval(doneOf('S3_settle'), settled({ quote_negotiate_line_deal_status_lost: 0 }))).toBe(false);
|
||||
});
|
||||
|
||||
it('**一行都还没写时 0+0+0+0 = 0 不算判完** —— 全零相等那一坑,靠 rows > 0 挡住', () => {
|
||||
expect(
|
||||
safeEval(doneOf('S3_settle'), {
|
||||
quote_negotiate_line_reply_type_counter: 0,
|
||||
quote_negotiate_line_rows: 0,
|
||||
quote_negotiate_line_deal_status_won: 0,
|
||||
quote_negotiate_line_deal_status_haggle: 0,
|
||||
quote_negotiate_line_deal_status_revise: 0,
|
||||
quote_negotiate_line_deal_status_lost: 0,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('归一那一步声明了 reads: items —— 上游载荷置前不截断', () => {
|
||||
expect(NEGOTIATE_STEPS.find((s) => s.item === 'S1_compose')?.reads ?? []).toContain('items');
|
||||
});
|
||||
|
||||
/*
|
||||
* **一单同一张卡只发一次**,且缺信息不再走补全卡(2026-08-02 全局退役)——
|
||||
* 剧本里不许残留「还缺就再发一张卡」这条已经不存在的路。
|
||||
*/
|
||||
it('剧本不教补全卡,也不留「还缺就再发一张」的死路', () => {
|
||||
for (const step of NEGOTIATE_STEPS) {
|
||||
expect(step.guide, `${step.item} 的 guide 提到了补全卡`).not.toMatch(/补全卡|"kind":"ask"/);
|
||||
expect(step.guide, `${step.item} 的 guide 教了再发一张卡`).not.toMatch(/再发一张卡|重新发一张卡/);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **一单只发一张卡**:人点完之后这一步会被重新念一遍,而 guide 的头一段说什么,
|
||||
* 模型就先做什么 —— 头一段若是「发一张决策卡」,它就再发一张(A4 实测连发四张,
|
||||
* 调人预算烧光 exhausted)。所以拍板那一步的 guide 必须**先分岔再发卡**。
|
||||
*/
|
||||
it('拍板那一步先问「人点过没有」,再谈发卡 —— 分岔要排在发卡骨架前面', () => {
|
||||
const guide = NEGOTIATE_STEPS.find((s) => s.item === 'T_haggle')?.guide ?? '';
|
||||
const branch = guide.indexOf('找到了');
|
||||
const skeleton = guide.indexOf('【发一张决策卡');
|
||||
expect(branch, 'T_haggle 的 guide 没有「人点过没有」这一岔').toBeGreaterThanOrEqual(0);
|
||||
expect(skeleton, 'T_haggle 的 guide 没有发卡骨架').toBeGreaterThan(branch);
|
||||
expect(guide).toMatch(/不许再发|别再发第二张卡/);
|
||||
});
|
||||
|
||||
/*
|
||||
* **拍板那三条路都得动 deal_status** —— 这是 2026-08-03 逼出来的结构账:
|
||||
* 上一版 revise 那条写着「定案不动」,人点完了 haggle 还是那个数,
|
||||
* 入场哨兵照旧成立,模型读同一段做法就再发一张卡(实测同一张连发六次)。
|
||||
* 「办完了」得在数上看得见,措辞治不了。
|
||||
*/
|
||||
it('拍板三条路各把 deal_status 改成一个结论码 —— 没有一条留在 HAGGLE', () => {
|
||||
const guide = NEGOTIATE_STEPS.find((s) => s.item === 'T_haggle')?.guide ?? '';
|
||||
for (const code of ['WON', 'REVISE', 'LOST']) {
|
||||
expect(guide, `拍板剧本少了 ${code} 这条路`).toContain(`deal_status 改 **${code}**`);
|
||||
}
|
||||
expect(guide, '拍板剧本还留着「定案不动」这条自我循环的路').not.toContain('定案不动');
|
||||
expect(guide).toContain('quote_negotiate_line_deal_status_haggle');
|
||||
});
|
||||
|
||||
/*
|
||||
* start 墙已拆(2026-08-02):动它就算启动。剧本里再教 start 只是白烧 token,
|
||||
* 而且学不稳的礼仪就是死法。
|
||||
*/
|
||||
it('剧本不教 start 礼仪 —— 那道墙已经拆了(说「不必单发 start」可以,教它先按一下不行)', () => {
|
||||
for (const step of NEGOTIATE_STEPS) {
|
||||
expect(step.guide, `${step.item} 的 guide 还在教 start`).not.toMatch(/先\s*start|务必\s*start|要先发\s*start/);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **等回音不落 `listen`**(见 case-model 头注):`listen` 在引擎里恒挂起,
|
||||
* 要外部事件才唤得醒,而这一单里回音的落单人本来就是 BD。
|
||||
* 剧本里若冒出一句「listen 等回音」,模型照做就是把整单挂到时限到期。
|
||||
*/
|
||||
it('定案那一步写死「这一拍到此为止」—— 收掉的步上再挂行动一律被拒', () => {
|
||||
const guide = NEGOTIATE_STEPS.find((s) => s.item === 'S3_settle')?.guide ?? '';
|
||||
expect(guide, 'S3_settle 没写「这一拍到此为止」').toContain('这一拍到此为止');
|
||||
expect(guide, 'S3_settle 没说清决策卡属于下一步').toContain('T_haggle');
|
||||
expect(guide, 'S3_settle 没写死「REVISE 这一步不写」').toMatch(/REVISE[^\n]*这一步一格都不写/);
|
||||
});
|
||||
|
||||
/*
|
||||
* 【目标缺口】那一段摆的是计划项的**中文名**;当下可为为空(依赖断点)那几拍,
|
||||
* 模型手上只剩中文名,于是把它当 itemId 写了进去 —— 「计划项不存在:异议拍板」
|
||||
* 连拒两拍(2026-08-03 实测)。发卡骨架旁边必须点名说清写英文 id。
|
||||
*/
|
||||
it('发卡骨架写死了 itemId 是英文项号,并点名中文名不是 id', () => {
|
||||
const guide = NEGOTIATE_STEPS.find((s) => s.item === 'T_haggle')?.guide ?? '';
|
||||
expect(guide).toContain('"itemId":"T_haggle"');
|
||||
expect(guide, '没告诉模型中文名不能当 itemId').toMatch(/别写中文名/);
|
||||
for (const value of ['accept-counter', 'revise', 'hold']) {
|
||||
expect(guide, `发卡骨架少了选项 ${value}`).toContain(value);
|
||||
}
|
||||
});
|
||||
|
||||
it('剧本不教 listen 等回音 —— 那是一条挂到天荒地老的路', () => {
|
||||
for (const step of NEGOTIATE_STEPS) {
|
||||
expect(step.guide, `${step.item} 的 guide 教了 listen`).not.toMatch(/"kind":"listen"|立监听|发一个\s*listen/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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 NEGOTIATE_STEPS) {
|
||||
for (const address of step.writes) {
|
||||
expect(declaredLedger, `${step.item} 要写 ${address}`).toContain(address);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('每一步要调的工具都在清单的工具声明里', () => {
|
||||
for (const step of NEGOTIATE_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 NEGOTIATE_STEPS) {
|
||||
expect(step.view, `${step.item} 没定画面`).toBeDefined();
|
||||
expect(names.has(step.view ?? ''), `${step.item} 的 view「${step.view}」不在模版名单里`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('每一步都说得出它交出什么', () => {
|
||||
for (const step of NEGOTIATE_STEPS) {
|
||||
expect(step.intent.trim(), `${step.item} 没写 intent`).not.toBe('');
|
||||
expect(step.guide.trim().length, `${step.item} 的 guide 太短`).toBeGreaterThan(20);
|
||||
}
|
||||
});
|
||||
|
||||
/* 卡面是纯文本:星号记号不会变粗体,只会原样摆在人眼前(规范 §5.5-10) */
|
||||
it('发卡骨架里不写星号记号 —— 卡面是纯文本', () => {
|
||||
for (const step of NEGOTIATE_STEPS) {
|
||||
for (const line of step.guide.split('\n')) {
|
||||
if (!/"steps"|"standard"|"ask"|"label"|"consequence"/.test(line)) continue;
|
||||
expect(line.includes('**'), `${step.item} 的卡面文案带了星号:${line.slice(0, 60)}`).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('阶段刻度:每一项都有格可待', () => {
|
||||
const inPhase = new Set(NEGOTIATE_PHASES.flatMap((p) => p.items));
|
||||
|
||||
it('**除放行外每一项恰好属于一个阶段**', () => {
|
||||
const planned = NEGOTIATE_STEPS.map((s) => s.item).filter((id) => id !== 'T_release');
|
||||
expect(planned.filter((id) => !inPhase.has(id))).toEqual([]);
|
||||
});
|
||||
|
||||
it('一项不进两个阶段', () => {
|
||||
const all = NEGOTIATE_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(NEGOTIATE_STEPS.map((s) => s.item));
|
||||
expect([...inPhase].filter((id) => !ids.has(id))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function doneOf(item: string): string {
|
||||
const found = NEGOTIATE_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;
|
||||
}
|
||||
}
|
||||
@@ -1,115 +1,441 @@
|
||||
/**
|
||||
* A6 报价议价的案例模型 —— **发出去是触达世界的一下,等回音是立监听**
|
||||
* (2026-07-31 照 A1 的状态机模式重做)。
|
||||
* A6 报价议价的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
|
||||
*
|
||||
* 这个活动的要害不是步数,是**两处不可省的边界**:
|
||||
* · 发正式报价单 = 触达外界 → 必须人工激活(铁律③ / AM 4.1);
|
||||
* · 客户什么时候回是**外界的事** → 立监听等它,不在这一单里轮询、也不假装等到了。
|
||||
* 业务出处:`docs/MVP示例工作流.html` §1.4(S1 发报留档 → S2 跟进议价 → S3 定案交接),
|
||||
* 按 A4 同一条形状建模(2026-08-03 重写 @3.0.0):
|
||||
*
|
||||
* 三项:
|
||||
* T_compose —— 把草案落成正式报价单(条款齐全),写进台账;
|
||||
* T_send —— 发出并等回音:人工激活 → 登记发信意向 + 立监听;
|
||||
* T_release —— 放行(人工激活),三个去向由输出选择组给出。
|
||||
* 上游(A5 批准的报价草案,A4 `draft` 口的载荷 `{rfqRef, customerCode, items}`)进来 →
|
||||
* 归一成**正式报价函**底表(逐条条款齐备 + 按 P-04 注明有效期)→ **一张执行单**
|
||||
* 交 BD:把报价函发给客户,回音回来后逐条落单(客户表态那一列 AI 一格都不写)→
|
||||
* 按议价结果 N-01 逐条定案(接受 / 还价 / 不成交)→ **只有还价那几条才叫人拍板**
|
||||
* (决策卡:照客户价成交 / 退回定价重批 / 守住现价)→ 放行,去向由三口的谓词算出来。
|
||||
*
|
||||
* 上一版(@2.1.0)四处不合现行口径,这一版整个置换:
|
||||
* ① 入参必填 `rfqRef + customerCode + items` —— 而宽进严出是全线口径,归一在活动内做;
|
||||
* ② 三条 L1/L2 判据读的是活动自报的 `missing_field_total` / `validity_days` /
|
||||
* `followup_total` —— 「机检只读基座数得出来的数」违反史第六类;
|
||||
* ③ 台账一个 `required` 都没有 —— 执行卡的闸没牙,人不填也能交卷;
|
||||
* ④ 三个出口没有 `when`:往哪走要模型自己选,而**去向该是算出来的**。
|
||||
*
|
||||
* 【为什么没有 `listen` 动作】等客户回音这一段落在**执行工单**上,不落 `listen`:
|
||||
* `listen` 在引擎里恒挂起(status: 'awaiting'),要外部事件才唤得醒;而这一单里
|
||||
* 「客户回了什么」的落单人本来就是 BD(他从自己的邮箱收信,再把结论录进系统)——
|
||||
* MVP 示例工作流 §1.4 的 S1「生成发出」与 S2「记录反馈」写的也都是**人工任务**。
|
||||
* 时限那一侧照旧硬约束:`declarations.listeners` 三条静态监听(D+3 催 / D+7 到期 /
|
||||
* 客户回音)由基座在开案那一刻注册,时限必配 —— 「无来源又无时限的等待」一条都没有。
|
||||
*/
|
||||
|
||||
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
|
||||
<case id="negotiate">
|
||||
<!--
|
||||
案卷项的 id **就是任务视图里的键名**。上游载荷(items)与本活动台账列都摊平到
|
||||
任务视图顶层,照它们命名;quote_negotiate_* 那几个是**基座读数**
|
||||
(行数 / 缺必填 / 按值现数),由基座现算,模型碰不着。
|
||||
-->
|
||||
<caseFileModel>
|
||||
<caseFileItem id="quote_no" name="报价单号(开案即有)"/>
|
||||
<caseFileItem id="quote_total" name="报价行条数"/>
|
||||
<caseFileItem id="items" name="上游报价草案条目(已批准,流内开案即有)"/>
|
||||
<caseFileItem id="line_total" name="报价函条数(自报)"/>
|
||||
<caseFileItem id="composed_total" name="已排版落表的条数(排版走完的凭据)"/>
|
||||
<caseFileItem id="replied_total" name="已落客户回音的条数(人环走完的凭据)"/>
|
||||
<caseFileItem id="route_decided_total" name="已定案"/>
|
||||
<caseFileItem id="quote_negotiate_line_rows" name="报价函行数(基座现数)"/>
|
||||
<caseFileItem id="quote_negotiate_line_missing" name="缺客户表态的行数(基座现数)"/>
|
||||
<caseFileItem id="quote_negotiate_line_deal_status_haggle" name="还价了但还没拍板的条数(基座现数)"/>
|
||||
<caseFileItem id="quote_negotiate_line_reply_type_counter" name="客户还了价的条数(基座现数 —— 客户自己那一格)"/>
|
||||
</caseFileModel>
|
||||
<casePlanModel id="plan" name="报价议价" autoComplete="true">
|
||||
<!--
|
||||
① 排版完了 —— 交 BD 发出那一步的入场前提。
|
||||
行数两边对上(自报 = 基座现数),且排版那一轮走完(composed_total 是排完之后
|
||||
显式写下的数;「大于等于 0」对「写过的 0」为真、对「没写过」为假)。
|
||||
-->
|
||||
<sentry id="s_composed">
|
||||
<onPart id="op_cp" caseFileItemRef="quote_total"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_cp"><condition>quote_total > 0</condition></ifPart>
|
||||
<onPart id="op_cp" caseFileItemRef="composed_total"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_cp"><condition>line_total > 0 and quote_negotiate_line_rows = line_total and composed_total >= 0</condition></ifPart>
|
||||
</sentry>
|
||||
<!--
|
||||
② 回音齐了 —— 逐条定案那一步的入场前提。
|
||||
replied_total 只在人交回执行单之后才产生,它就是「经过人了」的凭据;
|
||||
缺表态的行数由基座按 required 现数(与执行卡的提交闸、放行门 L1 同一个数)。
|
||||
-->
|
||||
<sentry id="s_replied">
|
||||
<onPart id="op_rp" caseFileItemRef="replied_total"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_rp"><condition>replied_total > 0 and quote_negotiate_line_rows > 0 and quote_negotiate_line_missing = 0</condition></ifPart>
|
||||
</sentry>
|
||||
<!--
|
||||
③ **还价才叫人** —— 这个活动的要害(与 A4「超限才叫人」同一形状)。
|
||||
|
||||
它读的是**客户自己那一格**(reply_type = COUNTER 的条数,基座按取值域现数),
|
||||
不是模型判出来的 deal_status。两版都栽过,病根是同一个 ——
|
||||
**「该不该叫人」不能由被叫的那一方说了算**:
|
||||
· 一版读 deal_status = HAGGLE,而 revise 那条路让「定案不动」,人点完了那个数
|
||||
还在,哨兵照旧成立,模型读同一段做法就再发一张卡(同一张连发六次);
|
||||
· 修成「拍板必须清 HAGGLE」之后,模型改在定案那一步**直接写 REVISE** ——
|
||||
HAGGLE 一条没有,这道门整个不开,单子一路 released 而没人拍过板
|
||||
(实测:决策卡 0 张,让步梯度成了摆设)。
|
||||
现在钉在客户填的那一格上:客户还了价,这一步就必须入场,模型绕不过去。
|
||||
-->
|
||||
<sentry id="s_haggle">
|
||||
<onPart id="op_hg" caseFileItemRef="quote_negotiate_line_reply_type_counter"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_hg"><condition>quote_negotiate_line_reply_type_counter > 0 and replied_total > 0</condition></ifPart>
|
||||
</sentry>
|
||||
<!--
|
||||
── 放行门的入场条件:**认「办完了没有」,且读基座数**────────────────
|
||||
· 每条都有客户表态(quote_negotiate_line_missing 由基座按 required 现数);
|
||||
· 定案逐条落定且守恒(四档相加 = 行数,两边都是基座按值现数);
|
||||
· **没有还压在 HAGGLE 上的条目**(还价的要么接、要么退回重批、要么守价不让);
|
||||
· 定案过了(route_decided_total 是定案那一步显式写下的记账数)。
|
||||
-->
|
||||
<sentry id="s_ready">
|
||||
<onPart id="op_rd" caseFileItemRef="route_decided_total"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_rd"><condition>route_decided_total > 0 and quote_negotiate_line_rows > 0 and quote_negotiate_line_missing = 0 and quote_negotiate_line_deal_status_haggle = 0 and quote_negotiate_line_deal_status_won + quote_negotiate_line_deal_status_haggle + quote_negotiate_line_deal_status_revise + quote_negotiate_line_deal_status_lost = quote_negotiate_line_rows</condition></ifPart>
|
||||
</sentry>
|
||||
|
||||
<!-- 落正式报价单:条款齐全才叫一份能发出去的件 -->
|
||||
<humanTask id="T_compose" name="落正式报价单:条款齐全,逐条落台账">
|
||||
<itemControl><repetitionRule id="r_cp"/></itemControl>
|
||||
<!-- ① 归一报价函:逐条条款齐备 + 按 P-04 注明有效期(能算的不问人) -->
|
||||
<humanTask id="S1_compose" name="落正式报价函:逐条条款齐备,按报价有效期注明到期日">
|
||||
<itemControl><repetitionRule id="r_s1"/></itemControl>
|
||||
</humanTask>
|
||||
|
||||
<!-- 发出并等回音:**触达外界**,必须人工激活 -->
|
||||
<humanTask id="T_send" name="发出报价并等客户回音">
|
||||
<entryCriterion id="ec_sd" sentryRef="s_composed"/>
|
||||
<itemControl><manualActivationRule id="m_sd"/><repetitionRule id="r_sd"/></itemControl>
|
||||
<!--
|
||||
② 发出去并把回音落回来 —— **一张执行单**(触达客户的那一下由人做)。
|
||||
manualActivation + 不给 doneWhen(发卡触达真人,引擎不替人按)。
|
||||
-->
|
||||
<humanTask id="S2_send" name="发出报价函并落客户回音:工作单上逐条落表态">
|
||||
<entryCriterion id="ec_s2" sentryRef="s_composed"/>
|
||||
<itemControl><manualActivationRule id="m_s2"/></itemControl>
|
||||
</humanTask>
|
||||
|
||||
<!--
|
||||
③ 逐条定案 —— SOP 里的 S3,一件独立的事:按客户表态判成交 / 还价 / 不成交,
|
||||
还价的再按让步梯度 N-02 给出处置建议。它不触达世界,所以给 doneWhen,
|
||||
引擎自己收 —— 从前这一段藏在「等回音」的收尾里,模型写完 replied_total
|
||||
就把那一项收了,定案一格没写,放行门当场判「定案没落定」而拒回只会说
|
||||
「数不对/行不对」——两扇打不开的门(A4 实测 8 次拒回熔断)。
|
||||
-->
|
||||
<humanTask id="S3_settle" name="逐条定案:按客户表态判成交/待拍板/不成交,还价的给让步处置">
|
||||
<entryCriterion id="ec_s3" sentryRef="s_replied"/>
|
||||
<itemControl><repetitionRule id="r_s3"/></itemControl>
|
||||
</humanTask>
|
||||
|
||||
<!-- ④ 异议拍板:让价触达公司利益的那一下,必须人工激活;只在还有还价条目时入场 -->
|
||||
<humanTask id="T_haggle" name="异议拍板:客户还的价接不接,要人定">
|
||||
<entryCriterion id="ec_hg" sentryRef="s_haggle"/>
|
||||
<itemControl><manualActivationRule id="m_hg"/><repetitionRule id="r_hg"/></itemControl>
|
||||
</humanTask>
|
||||
|
||||
<!-- 议价结果放行:终局唯放行 —— 触达世界,必须人工激活 -->
|
||||
<humanTask id="T_release" name="议价结果验收放行">
|
||||
<entryCriterion id="ec_rel" sentryRef="s_composed"/>
|
||||
<entryCriterion id="ec_rel" sentryRef="s_ready"/>
|
||||
<itemControl><manualActivationRule id="m_rl"/></itemControl>
|
||||
</humanTask>
|
||||
</casePlanModel>
|
||||
</case>
|
||||
</definitions>`;
|
||||
|
||||
export const NARRATIVE = `§0 开案:拿到报价草案即开案。成品是**一份发出去的正式报价单**,外加一个明确的结果
|
||||
(成交 / 有异议 / 拒绝)—— 那三个去向由放行时的选择组定。
|
||||
/**
|
||||
* 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本,两处不重复写同一件事)。
|
||||
*/
|
||||
export const NARRATIVE = `§0 开案:**一份已批准的报价要发出去,并谈出一个结果**。来路两种,形状都收(宽进):
|
||||
· **流内转来**:任务数据里有 \`items\` 数组 —— A4 的报价草案条目(每条带拟报单价与门禁处置),
|
||||
A5 已经批过;这是最全的一种来路;
|
||||
· **单飞**:只有一段需求文字,说了要给谁发报价、几颗料什么价。
|
||||
|
||||
这一单有两处不能含糊:
|
||||
· **发出去是触达世界的一下** —— 那一项标了人工激活,人不按你发不出去;
|
||||
· **客户什么时候回是外界的事** —— 立监听等它,别在这一单里轮询,更别假装等到了。
|
||||
**成品只有一样**:一份**正式报价函** —— 整份条目都在、**每一条都有客户表态**,
|
||||
而且每一条都定了案:成交 / 退回重批 / 不成交。人签的不是「发出去了」,
|
||||
是**这一单谈成了什么**。它决定整条流往哪走。
|
||||
(还价那一档 HAGGLE 是过渡态:**客户还了价、还没拍板**。放行前它必须清零 ——
|
||||
拍板那一下把它换成成交、退回重批或不成交,三条路都是结论,都算办完。)
|
||||
|
||||
【T_compose 落正式报价单】把草案落成能发出去的件:单价 / 交期 / SPQ / MOQ / 贸易条款 /
|
||||
有效期,逐条写进 \`quote_negotiate.formal_quote\`,并把 quote_total(条数)写上。
|
||||
条款缺一样都不算一份能发的报价单 —— 缺了就问,别自己填一个"看起来合理"的。写完 finish。
|
||||
**能算的不问人,该客户说的不代说**:议价结果 N-01(表态 → 定案)、让步梯度 N-02
|
||||
(还价幅度 → 处置建议),两支判定表说了算,你不临场拍;而**客户表态那一列是人落的,
|
||||
你一格都不写** —— 客户回了什么只有收信的人知道,系统不替客户表态。
|
||||
|
||||
【T_send 发出并等回音】**先 start T_send**(人工激活:发出去触达客户)。
|
||||
然后用 \`mail.send\` 登记发信意向(写类工具:过程中只登记,放行那一刻才真发),
|
||||
接着 listen 一个 data 事件等客户回音,**时限必配**(比如 P7D)——
|
||||
不配时限就是个幽灵等待,这一单会永远挂着。
|
||||
回音到了再看:成交 / 有异议 / 拒绝,分别对应放行时的三个出口。
|
||||
**四步走**:归一排版报价函 → 交 BD 发出并落回音 → 逐条定案 → 拍板定案放行。
|
||||
|
||||
【提交放行】**先 start T_release**(人工激活),提交前用 judge 裁掉裁量判据
|
||||
(criterion 照判据名原样写),然后 submit。终局唯放行:你不能自己宣布完成。
|
||||
**这一单要经过人一到两次**:BD 在议价工作单上逐条落客户表态(那张单绑着整份报价函,
|
||||
表态缺一条按不下提交 —— 基座的闸看着,他填的格子直接落账);
|
||||
**只有客户还了价的那几条才另外叫人拍板**(一张决策卡)。每条都叫人,人就成了橡皮图章;
|
||||
一条都不叫,授权红线就是摆设。最后 BD 放行签字。
|
||||
|
||||
【一路上要说话】每一拍带一句 say,说**业务的话**:报了什么价、什么时候发的、客户回了什么。
|
||||
一两句、口语。别报「写草稿 N 行」这种系统词。`;
|
||||
**去向是算出来的,不是你选的**:有条目判了退回重批 → 退回定价重批;有成交 → 转订单履约;
|
||||
全都没成 → 转情报归档。三口各带谓词,放行那一刻基座按序求值,你只要把定案落准。
|
||||
|
||||
【这一单只有一个岔路口,它长这样 —— 无论哪一拍都照这个发,别自己另编一张】
|
||||
客户还了价要人拍板,那张决策卡**三处写死**:
|
||||
· \`"itemId":"T_haggle"\` —— 英文计划项号。**别写中文名「异议拍板」**:中文名不是 id,
|
||||
写了当场被拒(【目标缺口】那一段摆的就是中文名,别照它抄);
|
||||
· \`"ask":"<N> 条客户还了价,按哪条路办?"\` —— 只把 N 换成条数,**别的字一个都不改**。
|
||||
换了问法,系统就认不出这是同一张卡:人已经点过的那张会被你再发一遍;
|
||||
· 三个 \`value\`:\`accept-encounter\` 不对,是 \`accept-counter\`(照客户还的价成交)、
|
||||
\`revise\`(退回成本定价重批)、\`hold\`(守住现价不让)。label 与 consequence 可以按
|
||||
本单的数改写,**value 不行**。
|
||||
**REVISE(退回重批)这个码只有拍板那一步写得出来** —— 定案那一步遇到客户还价一律落
|
||||
HAGGLE,替他写了 REVISE,那张卡就永远不会发,让步梯度成了摆设。
|
||||
拍板之前**别急着发卡也别绕路** —— 定案那一步先把三档写完、finish;
|
||||
下一拍引擎会把拍板那一步的做法摊开给你,那时再发。
|
||||
|
||||
【一路上要说话】负责人正看着这一单。**每一拍都带一句 say** —— 说业务的话:
|
||||
报价函排了几条、有效期到哪天、客户回了什么、哪几条要拍板;不说系统词。
|
||||
到了要找人的那一拍,先说清这一轮办完了什么、为什么现在需要他。
|
||||
|
||||
【怎么把一项干完 · 一拍连做】write 和 tool 都不会自动收步 —— 做完用 finish 宣告。
|
||||
动作排进同一拍的 actions 串一口气给;只有发卡 / 提交(挂起)必须停拍。
|
||||
**这一单常路 6-8 拍就该收口**。
|
||||
|
||||
§Z 结案:BD 在验收单上签字,系统把草稿合并成正式报价函并按定案发出对应那一口的载荷 ——
|
||||
成交转订单履约,有异议退回成本定价,不成交转情报归档。`;
|
||||
|
||||
/**
|
||||
* 步骤剧本 —— 与 A1 同一套。
|
||||
* `T_send` 与 `T_release` 都不给 `doneWhen`:一个触达客户、一个触达世界,都得人按一下。
|
||||
* 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。
|
||||
*
|
||||
* 三处不给 `doneWhen`:`S2_send`(发卡触达真人)、`T_haggle`(要人拍板)、
|
||||
* `T_release`(签下去触达世界)—— 给了等于给引擎一把替人签字的钥匙。
|
||||
*/
|
||||
export const NEGOTIATE_STEPS = [
|
||||
{
|
||||
item: 'T_compose',
|
||||
intent: '把草案落成条款齐全的正式报价单,并结算条数',
|
||||
writes: ['quote_negotiate.formal_quote', 'quote_negotiate.verdict'],
|
||||
reads: [],
|
||||
item: 'S1_compose',
|
||||
intent: '整份归一成正式报价函:逐条抄齐条款、补 SPQ/MOQ/贸易条款、按 P-04 注明有效期',
|
||||
writes: ['quote_negotiate.line', 'quote_negotiate.verdict'],
|
||||
/* 上游载荷数组不声明就会被整表段的截断刀砍掉(砍掉的恰是要归一的行) */
|
||||
reads: ['items'],
|
||||
view: 'negotiate-worksheet',
|
||||
tools: [],
|
||||
doneWhen: 'quote_total > 0',
|
||||
guide: `逐条写进 \`quote_negotiate.formal_quote\`:单价 / 交期 / SPQ / MOQ / 贸易条款 / 有效期,
|
||||
并把 quote_total 写上。条款缺一样都不算一份能发的报价单 ——
|
||||
缺了就问,**别自己填一个「看起来合理」的值**。
|
||||
**同一拍再写一行 \`quote_negotiate.verdict\`**(验收机检读的就是它):
|
||||
· quote_total = 报价行条数;missing_field_total = 六项条款还缺的条数(要 = 0);
|
||||
· validity_days = 报价有效期天数(要 > 0);
|
||||
· followup_total = 已建跟进条数(**要 ≥2** —— D+3 与 D+7 两条,没有跟进的报价等于没报);
|
||||
· summary 一句人话结论。写不对 submit 会被门挡回来。写完再 finish。
|
||||
**每一行都要带 \`rfq_ref\`** —— 它是这一单的单标识(起单入参里就有,原样抄进去)。
|
||||
台账主键第一列就是它:不写,这一行会和**别的单**同料号的那一行撞主键,把人家覆盖掉
|
||||
(实测栽过:A4 的报价草案主键只有 pn,跑四轮只剩最后一轮的 3 行)。`,
|
||||
/*
|
||||
* 归一 + 排版走完才算办完:行数两边对上(一边是基座现数),
|
||||
* 且 composed_total 写过(S-FEEL 未绑定的名字参与比较为假,所以「没写过」进不来)。
|
||||
*/
|
||||
doneWhen: 'line_total > 0 and quote_negotiate_line_rows = line_total and composed_total >= 0',
|
||||
guide: `第一件事:把这一单的**正式报价函**落出来 —— 一拍连做,别拆成两拍。
|
||||
|
||||
【条目从哪儿来】
|
||||
· 流内转来(\`items\` 里的行带 quoted_price / disposition):照抄 —— **报出去的价就是
|
||||
上游批准的那个价**,一个数字都别改;带 quote_line_total 这类总数的是结论行,不是料,
|
||||
别落进报价函。
|
||||
· 单飞:照正文归一,列了几颗落几行;正文里说的批准价就是报出去的价。
|
||||
**按 pn 归并,一颗料一行**;pn 一个字符不许改(它是主键,改一个字就是新开一行)。
|
||||
|
||||
【写 \`quote_negotiate.line\`,一颗料一行】(rows 收数组)
|
||||
rfq_ref(入参有就原样抄;没有从主题认;认不出才自己造一个,**造出来的必须以 \`NEG-\` 开头**
|
||||
(\`NEG-<主题或第一颗料的型号>\`)。两条硬规矩:
|
||||
**① 整单只有一个单号,不是一颗料一个** —— 它是这一单的案号,报价函所有行、verdict 那一行
|
||||
用的都得是同一个串(实测栽过:两颗料造出两个号,一单裂成两单);
|
||||
**② 只从本单来件取材** —— 台账里躺着的 RFQ-xxxx 是别人的单,借它的号等于把两单的行
|
||||
混进同一个案卷(实测栽过一轮))、pn、customer_pn、category、brand、eau、lead_time、
|
||||
remark(客户约束/急料,一个字别丢)、type_tag(照上游现标;认不出按 B)、
|
||||
**quoted_price**(上游批准的报价,原样抄)。
|
||||
条款三列**照这个口径补齐**(它们是报价函的必备条款,不是可选项):
|
||||
· spq —— 最小包装量:上游给了就抄;没给按 eau 的量级给一个常规值(千位以下 100,
|
||||
千到万 1000,万以上 5000)并在 remark 里注明是按常规包装估的;
|
||||
· moq —— 最小起订量:同上,没给就取 spq;
|
||||
· trade_terms —— 贸易条款:统一写 \`EXW 深圳\`,客户来件另有约定的照客户的写。
|
||||
· valid_until —— 有效期到期日:按**报价有效期 P-04:7 个日历天**从今天算,写成
|
||||
\`YYYY-MM-DD\`。整单同一个日子,不是一行一个。
|
||||
|
||||
【**这三列你一格都不写**:reply_type / counter_price / reply_note】
|
||||
客户表态、客户还的价、客户原话 —— 这三样只有收信的人知道,**系统不替客户说话**。
|
||||
它们是 BD 在工作单上落的;deal_status / concession / final_price 三列要等表态回来
|
||||
才判得了,这一步也别写。
|
||||
这不是客气话:一条要你分辨「这行能不能替客户填」的规矩会走岔;
|
||||
**「这几列不写」没有分支可走岔。**
|
||||
|
||||
【同一拍写 \`quote_negotiate.verdict\`(一行)】
|
||||
rfq_ref、**quote_no**(报价单号:\`Q-\` 开头 + 本单案号的尾段,整单一个)、
|
||||
customer_code / customer_name(顶层有就抄)、**line_total** = 报价函行数、
|
||||
**valid_days** = 7、**composed_total** = 这一拍排版落表的条数(0 也要写)、
|
||||
summary 一句人话(几条料、报给谁、有效期到哪天)。
|
||||
写完 finish 这一项。`,
|
||||
},
|
||||
{
|
||||
item: 'T_send',
|
||||
intent: '发出报价并立监听等客户回音',
|
||||
writes: [],
|
||||
reads: [],
|
||||
tools: ['mail.send'],
|
||||
// 触达客户 —— 人工激活,不给 doneWhen
|
||||
guide: `**先 start T_send**(人工激活:发出去触达客户)。
|
||||
用 \`mail.send\` 登记发信意向(写类工具:只登记,放行那一刻才真发),
|
||||
再 listen 一个 data 事件等回音,**时限必配**(如 P7D)—— 不配就是幽灵等待,这一单会永远挂着。`,
|
||||
item: 'S2_send',
|
||||
intent: '一张执行单交 BD:把报价函发给客户,回音回来后逐条落客户表态',
|
||||
writes: ['quote_negotiate.verdict'],
|
||||
reads: ['quote_negotiate.line'],
|
||||
view: 'negotiate-worksheet',
|
||||
tools: [],
|
||||
/* 不给 doneWhen —— 发卡触达真人(manualActivation),引擎不替人按 */
|
||||
guide: `报价函排好了,**该 BD 出场了** —— 这是本活动唯一的一张执行单。
|
||||
触达客户的那一下由人做:他把报价函发出去、按 D+3/D+7 催问、拿回客户的话。
|
||||
工作区摆的是**议价工作单**:逐颗料一张报价档案卡(条款、报价、客户约束),
|
||||
让步梯度与议价纪律挂在上面,填回区长在同一屏 —— 他在「客户表态」那一列逐行落。
|
||||
|
||||
这一步标了人工激活:发卡就算启动,不必单发 start;引擎也不会替你收 ——
|
||||
人交回后要你自己 finish。
|
||||
|
||||
【照这个骨架发,别少字段】(卡面是纯文本,steps/standard 里别写星号记号)
|
||||
{"kind":"human","itemId":"S2_send","workOrder":"execution","card":{
|
||||
"title":"报价函发出并跟进:<单号> 共 <N> 条待回音",
|
||||
"ask":"报价函已按批准价排好版,条款与有效期都在工作单上。请把它发给客户,按 D+3/D+7 的节奏催问;客户回话之后,在工作单的「客户表态」一列逐行落:接受 / 还价 / 拒绝 / 无回复,还价的把客户报的那个价也填上。",
|
||||
"steps":["发出去之前先核一遍条款:单价、交期、SPQ、MOQ、贸易条款、有效期 —— 报价函一旦发出,这几项就是对客户的承诺",
|
||||
"客户表态每一行都要有 —— 缺一行这张单按不下提交;客户只回了一部分料的,没回的那几行按无回复落,别替客户猜",
|
||||
"客户还价的行:把他报的价填进「客户还价」那一格,再把他的原话记进备注 —— 争的是价格、交期还是 MOQ,后面拍板全看这一句",
|
||||
"D+3 第一轮催问确认收到探态度,D+7 最后一催;催到有效期过仍无回复的,按无回复定案,不要一直挂着"],
|
||||
"standard":"整份报价函每一条都有客户表态,还价的那几条填了客户报的价并记下他的原话 —— 缺一条这张单按不下提交(系统的闸看着)。",
|
||||
"basis":[{"k":"<PN>","v":"<报价 · 交期 · 有效期到 YYYY-MM-DD>"}]}}
|
||||
· \`basis\` 逐条列本单的料:PN + 报价与有效期。几颗写几条,别铺全表。
|
||||
· **卡上不摆表、不摆格子**(执行卡零输入):整份报价函就在工作单那份件上,
|
||||
客户表态、客户还价、备注三列都能就地改、行能撤 —— **别往卡里塞 \`blocks\` 或 \`fields\`**。
|
||||
· **这张卡带提交闸**:客户表态(必填列)还空着,他按不下提交 —— 缺几格、缺在哪行,
|
||||
闸上与件上的红带都写着(与放行门 L1 同一个数同一套算法)。
|
||||
发完卡这一拍就结束了(发卡挂起)。
|
||||
|
||||
【人交回之后 —— 他落的表态基座已经替你写回台账了】这一步你只做两件事,然后 finish:
|
||||
① 写 verdict:**replied_total** = 已落表态的条数(照报价函行数记);
|
||||
他在件上撤掉了行的话,**line_total 跟着改成撤后的实数**。
|
||||
② finish 这一项。
|
||||
**别在这一拍判定案** —— 逐条定案是下一步的活(它有自己的一段做法),这里判了也白判。
|
||||
报价函行你一列都不用重写:他落的值基座已经落进台账了,重写会被「写了等于没写」拒回。
|
||||
|
||||
【这张卡一单只发一次】人交回来之后不许再发同一张卡 —— 必填有提交闸兜着,
|
||||
卡回来时客户表态一定是齐的(或那几行已被他撤掉)。`,
|
||||
},
|
||||
{
|
||||
item: 'S3_settle',
|
||||
intent: '逐条定案:按客户表态判成交/待拍板/不成交,还价的按让步梯度给处置建议',
|
||||
writes: ['quote_negotiate.line', 'quote_negotiate.verdict'],
|
||||
reads: ['quote_negotiate.line'],
|
||||
view: 'negotiate-worksheet',
|
||||
tools: [],
|
||||
/*
|
||||
* **收步判据与验收判据同源**:四档定案的条数加起来等于行数(两边都是基座现数)——
|
||||
* 判完了才算办完。`_rows > 0` 那一半不能省:开案时行数是 0,而 0+0+0+0 = 0
|
||||
* 会让这一步开局即收(未绑定名字的坑之外,还有「全零相等」这一坑)。
|
||||
*/
|
||||
doneWhen:
|
||||
'quote_negotiate_line_rows > 0 and quote_negotiate_line_deal_status_haggle = quote_negotiate_line_reply_type_counter and quote_negotiate_line_deal_status_won + quote_negotiate_line_deal_status_haggle + quote_negotiate_line_deal_status_revise + quote_negotiate_line_deal_status_lost = quote_negotiate_line_rows',
|
||||
guide: `客户的话都落回来了,**现在才轮到定案** —— 这一步是纯查表,一拍连做:
|
||||
|
||||
① **逐行按客户表态 reply_type 判 deal_status**,按议价结果 N-01,
|
||||
只许写这四个大写码之一,一个字母不许差:
|
||||
· ACCEPT(客户接受)→ **WON**,同一行 final_price 记 quoted_price(成交价就是报价);
|
||||
· COUNTER(客户还价)→ **HAGGLE**,final_price **留空**(还没谈定,别替客户成交);
|
||||
· REJECT(客户拒绝)→ **LOST**,final_price 留空;
|
||||
· NOREPLY(催到有效期过仍无回复)→ **LOST**,final_price 留空。
|
||||
**第四个码 REVISE「退回重批」这一步一格都不写** —— 它是人在决策卡上拍出来的,
|
||||
不是你判出来的。你替他写了,那张卡就永远不会发,让步梯度成了摆设(实测栽过一轮:
|
||||
一单从头到尾 released 而没有人拍过板)。客户还了价 = 落 **HAGGLE**,句号。
|
||||
**两个数要对上,都是基座现数,你改不动它们**:
|
||||
· 待拍板条数 quote_negotiate_line_deal_status_haggle
|
||||
**必须等于**客户还价条数 quote_negotiate_line_reply_type_counter ——
|
||||
对不上这一步收不掉(把写错的那行改回 HAGGLE 就对上了);
|
||||
· 四档定案的条数加起来必须等于 quote_negotiate_line_rows(基座数的行数),一行都不能漏。
|
||||
现在四档都是 0,就是一行都还没判 —— 这一步没办完,放行门开不了。
|
||||
② **还价的那几行(且只有那几行)再算一格 concession**:让步梯度 N-02 看还价幅度
|
||||
gap =(quoted_price − counter_price)/ quoted_price,只许写这三个大写码之一:
|
||||
· gap ≤ 0.03(三个点以内)→ **ACCEPT_COUNTER**(意思意思的压价,接了拿单);
|
||||
· 0.03 < gap ≤ 0.10(三到十个点)→ **NEGOTIATE**(在授权内回旋一轮,让一半再报回去);
|
||||
· gap > 0.10(超过一成)→ **ESCALATE**(超出授权,退回成本定价重批,不私自破线)。
|
||||
不是还价的行 concession 给空串 \`""\`,**不许写 0 也不许编一个码** ——
|
||||
没还价就没有让步幅度这回事。
|
||||
③ 看这一拍写完之后的**待拍板条数**(deal_status = HAGGLE 的行):
|
||||
· 一条都没有 → 同一拍把 verdict 写全:**outcome**(全按 N-01 的结果算:
|
||||
有 WON 就写 \`won\`,一条 WON 都没有就写 \`lost\`)、**route_decided_total 记 1**、
|
||||
**settled_total** = 定案条数、reason(不成交时写清缘由:客户拒绝还是超时无回复)、
|
||||
summary 补一句人话,然后 finish 这一项;
|
||||
· 还有 → **别写 outcome 也别写 route_decided_total**,只写 settled_total 再 finish ——
|
||||
下一拍会有一步专门叫人拍板。
|
||||
|
||||
【finish 之后这一拍到此为止】**别在这一拍顺手发决策卡** —— 那张卡属于下一步
|
||||
(itemId 要写 T_haggle),这一步收掉之后再往它上面挂任何行动都会被拒
|
||||
(实测:同一串里 write / write / finish / decide,最后那个 decide 挂着 S3_settle,当场被拒)。
|
||||
下一拍引擎会把「异议拍板」那一步的做法摊开给你,那时再发。`,
|
||||
},
|
||||
{
|
||||
item: 'T_haggle',
|
||||
intent: '客户还的价接不接要人定 —— 发一张决策卡,照他选的那条路把定案改掉',
|
||||
writes: ['quote_negotiate.line', 'quote_negotiate.verdict'],
|
||||
reads: ['quote_negotiate.line'],
|
||||
view: 'negotiate-roster',
|
||||
tools: [],
|
||||
/* 人工激活 + 要人拍板 —— 不给 doneWhen,引擎不许替人收 */
|
||||
guide: `**这一单只发一张决策卡。动手之前先在轨迹里找一行 \`[card-reply]\` 带「人选了:」:**
|
||||
|
||||
· **找到了** —— 跳到下面【人点完之后】那一段,照他选的那条路把那几行的 deal_status
|
||||
从 HAGGLE 改掉、写 outcome 与 route_decided_total、finish。**一个 decide 都不许再发**
|
||||
(实测栽过:人点完了,而这一步又从头念了一遍「发一张决策卡」,同一张卡连发六次,
|
||||
前两次无事发生、后四次被拒 —— 他点过的那一下就白点了);
|
||||
· **没找到** —— 才轮到下面【发一张决策卡】那一段。
|
||||
|
||||
【这一步办完了没有,看一个数】待拍板条数 quote_negotiate_line_deal_status_haggle:
|
||||
**它归零就是这一步办完了**。HAGGLE 是过渡态(客户还了价、还没拍板),
|
||||
拍板那一下必须把它换成 WON / REVISE / LOST 之一 —— 三条路都是结论。
|
||||
这个数不归零,放行门开不了;它归零,这一步也就不会再找你。
|
||||
|
||||
这一步只在**客户还过价**(基座现数 quote_negotiate_line_reply_type_counter > 0)时入场 ——
|
||||
读的是客户自己填的那一格,不是你判的定案:该不该叫人,不由被叫的那一方说了算。
|
||||
它触达公司利益,标了人工激活:发卡就算启动,不必单发 start。
|
||||
工作区这时摆的是**成交定案清单**,整份报价函与定案分布都在上面,他看得见全局再拍板。
|
||||
|
||||
【发一张决策卡,照这个骨架】(选项少于两个基座拒发)
|
||||
**三处一个字都不许改**(与说明书里那一段是同一份):
|
||||
· \`itemId\` 只写英文计划项号 \`T_haggle\` —— **别写中文名「异议拍板」**,
|
||||
中文名不是 id,写了当场被拒(实测栽过两拍:「计划项不存在:异议拍板」);
|
||||
【目标缺口】那一段摆的是中文名,它不是 id,别照它抄。
|
||||
· \`ask\` 照抄「<N> 条客户还了价,按哪条路办?」,**只把 N 换成条数** ——
|
||||
换了问法系统就认不出这是同一张卡,人已经点过的那张会被你再发一遍(实测发出去两张)。
|
||||
· 三个 \`value\` 照抄 \`accept-counter\` / \`revise\` / \`hold\` —— 它们是这一单三条路的
|
||||
代号,换了名字下一段的对应关系就对不上,你自己也不知道人点的是哪条。
|
||||
label 与 consequence 可以按本单的数改写,value 不行。
|
||||
{"kind":"decide","itemId":"T_haggle","ask":"<N> 条客户还了价,按哪条路办?",
|
||||
"options":[
|
||||
{"value":"accept-counter","label":"照客户还的价成交","consequence":"当场拿下这几条,毛利让到客户的价位上,让出去的部分不再回来"},
|
||||
{"value":"revise","label":"退回成本定价重批","consequence":"这几条超出让价授权,回 A4 重新算价再谈 —— 这一单本轮不成交,客户要多等一轮"},
|
||||
{"value":"hold","label":"守住现价不让","consequence":"报价不动,客户不接受即视为不成交,这几条转情报归档"}],
|
||||
"basis":[{"k":"<PN>","v":"报价 <X> · 客户还价 <Y> · 让幅 <Z>% · 让步梯度判 <码>"}]}
|
||||
· basis 逐条摆还价的那几颗料:报价、客户还价、让幅、N-02 的处置码 —— 裸卡基座拒发。
|
||||
· **recommended 按让步梯度 N-02 标**,标在与处置码对应的那一项上,只标一项:
|
||||
ACCEPT_COUNTER → accept-counter;NEGOTIATE → accept-counter;ESCALATE → revise。
|
||||
几条料的码不一致时,按**最靠后那一档**标(有一条要 ESCALATE 就标 revise)。
|
||||
· 发完这一拍就结束了(发卡挂起)。
|
||||
|
||||
【人点完之后】轨迹上那一行长这样:\`[card-reply] …人选了:xxx\`。**照那条路把定案改掉,
|
||||
别再发第二张卡**,一拍连做 —— 三条路**都要动 deal_status**,一条都不许留在 HAGGLE:
|
||||
· 选 accept-counter:那几行 deal_status 改 **WON**、**final_price 记该行的
|
||||
counter_price**(那个数不是你定的,是客户报的、人点头认的,你只是替他落账)、
|
||||
reply_note 补一句「按客户还价成交,由负责人拍板」;verdict 的 outcome 写 \`won\`;
|
||||
· 选 revise:那几行 deal_status 改 **REVISE**、final_price 留空,
|
||||
reply_note 补一句「让幅超出授权,退回成本定价重批」;verdict 的 outcome 写 \`revise\`、
|
||||
reason 写清超了多少;
|
||||
· 选 hold:那几行 deal_status 改 **LOST**、final_price 留空,
|
||||
reply_note 补一句「守价不让,客户未接受」;verdict 的 outcome 写 \`lost\`、
|
||||
reason 写「客户还价未达成,守价不让」。
|
||||
然后把 verdict 写全:**route_decided_total 记 1**、settled_total = 定案条数、
|
||||
summary 补一句拍板结果,finish 这一项。
|
||||
**outcome 那一格照上面三条写死的对应关系填,别自己另造词** —— 下游三条边读的就是它。
|
||||
改完之后待拍板条数就是 0 了,这一步再也不会找你。`,
|
||||
},
|
||||
{
|
||||
item: 'T_release',
|
||||
intent: '议价有结果,提交放行交 BD 验收',
|
||||
intent: '定案已落,提交放行交 BD 验收',
|
||||
writes: [],
|
||||
reads: [],
|
||||
view: 'negotiate-roster',
|
||||
tools: [],
|
||||
guide: `**先 start T_release**(人工激活),提交前用 judge 裁掉裁量判据,然后 submit。
|
||||
三个去向(成交 / 有异议 / 拒绝)由放行时的选择组定。
|
||||
**台账里要写 outcome**(won / revise / lost)—— 下游三条边读的就是它,不写整条流走不下去。`,
|
||||
/* 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 doneWhen) */
|
||||
guide: `每条都有客户表态、定案守恒、待拍板清零、定案已记 —— 可以走放行了
|
||||
(T_release 人工激活:动它就算启动,不必单发 start)。先把裁量判据裁掉,然后 submit,同一拍连做:
|
||||
|
||||
· judge criterion="议价过程说得清:报价函条款齐备、每一轮客户反馈与让价理由逐条可溯,BD 对这个结果负责" ——
|
||||
看报价函里每一行的条款、客户原话与让价理由:说得出来路就 satisfied=true;
|
||||
有行说不出的(比如还价成交却没记客户的话),satisfied=**false**,rationale 逐行写清,
|
||||
交签字的人定。**裁成不满足照样能提交** —— 理由连着卡一起交给他。
|
||||
criterion 照判据名**整句原样抄**,写别的名字对不上。
|
||||
|
||||
**去向不必你选**:三口各带谓词,放行那一刻基座按序求值 ——
|
||||
有条目判了退回重批就走「退回调整」,有成交就走「转订单履约」,全都没成就走「转情报归档」。
|
||||
你要做的只是把定案落准,别在提交里手选出口。
|
||||
|
||||
终局唯放行:你不能自己宣布完成。`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段;
|
||||
* 放行那一格由基座统一加,人看到的是 x/5。
|
||||
*/
|
||||
export const NEGOTIATE_PHASES = [
|
||||
{ name: '排版报价函', items: ['S1_compose'] },
|
||||
{ name: '发出并等回音', items: ['S2_send'] },
|
||||
{ name: '逐条定案', items: ['S3_settle'] },
|
||||
{ name: '异议拍板', items: ['T_haggle'] },
|
||||
];
|
||||
|
||||
@@ -1,49 +1,18 @@
|
||||
/**
|
||||
* 统一报价 A6 · 报价议价 —— 活动包代码入口(ADR-004)。
|
||||
* 统一报价 A6 · 报价议价 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
|
||||
*
|
||||
* 「发出的同时必须建立 D+3/D+7 跟进 —— 没有跟进的报价等于没报。」
|
||||
* 这里把跟进监听立起来:AI 对世界只有触达(动作,落在放行)与等待(监听)两种姿势。
|
||||
* **这个活动没有 `run`,不是缺件。**
|
||||
*
|
||||
* 案例活动的推进由 Loop 驱动,行为全部写在清单里:案例模型 + 说明书 + 步骤剧本 +
|
||||
* 两把判定表 + 两条技能 + 台账声明 + 两张 HTML 报告模版。
|
||||
* 上一版这里有一段 `run()`(读报价单、拼 table 块、按条款齐不齐返回 await/submit,
|
||||
* 末尾还立了一条 D+3 监听)—— 案例活动根本不会调它;摆一个空转的 run 去假装有实现,
|
||||
* 更坏:它写的那套呈现与 `viewTemplates` 里的模版各说各话,而没人看得出以哪份为准;
|
||||
* 那条 D+3 监听也与 `declarations.listeners` 里声明的三条重复,谁真谁假说不清。
|
||||
*
|
||||
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 —— 它是**被静态审扫描的产物文本**
|
||||
* (查 import 与全局引用,证明活动包零环境能力)。
|
||||
*/
|
||||
import { defineActivity } from '@sdk';
|
||||
import type { ActivityBase, ActivityOutcome } from '@sdk';
|
||||
|
||||
const QUOTE = 'quote_negotiate.formal_quote';
|
||||
|
||||
export default defineActivity({
|
||||
name: 'quote-negotiate',
|
||||
|
||||
async run(base: ActivityBase): Promise<ActivityOutcome> {
|
||||
const lines = await base.ledger.read(QUOTE);
|
||||
|
||||
await base.interaction.pushView([
|
||||
{
|
||||
t: 'table',
|
||||
title: '正式报价单',
|
||||
cols: ['PN', '单价CNY', '交期', 'MOQ', '有效期'],
|
||||
rows: lines.map((r) => [
|
||||
String(r['pn'] ?? ''),
|
||||
String(r['unit_price'] ?? '—'),
|
||||
String(r['lead_time'] ?? '—'),
|
||||
String(r['moq'] ?? '—'),
|
||||
String(r['valid_until'] ?? '—'),
|
||||
]),
|
||||
},
|
||||
]);
|
||||
|
||||
const incomplete = lines.filter((r) => r['unit_price'] === undefined || r['valid_until'] === undefined);
|
||||
if (lines.length === 0 || incomplete.length > 0) {
|
||||
await base.interaction.say('报价单条目尚未齐备,继续排版。', 'conclusion');
|
||||
return { kind: 'await', reason: '等待报价单生成步完成' };
|
||||
}
|
||||
|
||||
// 建 D+3 跟进:等客户回音 —— 时限必配,无死等
|
||||
await base.tools.listen({
|
||||
eventType: 'data',
|
||||
keyFilter: { kind: 'customer-reply', customerCode: String(base.contract.data['customerCode'] ?? '') },
|
||||
deadline: 'P3D',
|
||||
});
|
||||
await base.interaction.say('已建 D+3 跟进监听 —— 没有跟进的报价等于没报。', 'guidance');
|
||||
|
||||
return { kind: 'submit', deliverable: 'formal-quote', rationale: '报价单齐备且已建跟进,提交放行即发出' };
|
||||
},
|
||||
});
|
||||
export default defineActivity({ name: 'quote-negotiate' });
|
||||
|
||||
@@ -1,168 +1,393 @@
|
||||
/**
|
||||
* 活动包清单 —— 统一报价 A6 · 报价议价。
|
||||
* 活动包清单 —— 统一报价 A6 · 报价议价(案例活动)。
|
||||
*
|
||||
* 业务素材:docs/MVP示例工作流.html §1.4(S1 生成发出 → S2 CRM 留档 → S3 建立跟进)。
|
||||
* 关键机制:生成正式报价单是**写类动作** → 登记为动作意向 → 落地预检 →
|
||||
* 放行通过即落地(台账回写 + 出站发送)→ 输出口发射;并建 D+3 跟进监听。
|
||||
* 业务素材:`docs/MVP示例工作流.html` §1.4(S1 发报留档 → S2 跟进议价 → S3 定案交接):
|
||||
* 「审批通过后 15 分钟内发出……没有跟进的报价等于没报」
|
||||
* 「客户不回不是结论,催到表态或有效期过」
|
||||
* 「让价在授权范围内回旋,要破底价门禁红线的交回发起流程重批,不私自破线」。
|
||||
*
|
||||
* 形状与 A4 同一条(2026-08-03 重写 @3.0.0):归一排版 → 一张执行单交人(发出去 +
|
||||
* 把客户回音落回来)→ 逐条定案 → **还价才叫人**拍板 → 放行。
|
||||
* **能算的不问人**(N-01 议价结果、N-02 让步梯度两把尺写死),
|
||||
* **该客户说的不代说**(客户表态那三列 AI 一格都不写)。
|
||||
*
|
||||
* 与 @2.1.0 的差别(四处都是踩过的账):
|
||||
* ① `input.required` 从 `['rfqRef','customerCode','items']` 清空 —— 宽进严出;
|
||||
* ② 台账补 `required` / `requiredWhen` —— 从前一个必填都没有,执行卡的闸没牙;
|
||||
* ③ 判据与哨兵全部换成**基座读数**(`_rows` / `_missing` / `_<列>_<值>`)——
|
||||
* 从前三条 L1/L2 读 `missing_field_total` / `validity_days` / `followup_total`,
|
||||
* 三个都是活动自报的数(「机检只读基座数」违反史第六类);
|
||||
* ④ 三个出口补 `when` —— 去向是**算出来的**,不是模型手选的。
|
||||
*/
|
||||
import type { ActivityManifest } from '@contracts';
|
||||
import { NEGOTIATE_SKILLS } from './skills.ts';
|
||||
import { CMMN_XML, NARRATIVE, NEGOTIATE_STEPS } from './case-model.ts';
|
||||
import type { ActivityManifest, DmnModel } from '@contracts';
|
||||
import { activityIdSchema } from '@contracts';
|
||||
import { QUOTE_COMPOSED, QUOTE_RELEASE } from './views/report.ts';
|
||||
import { NEGOTIATE_SKILLS } from './skills.ts';
|
||||
import { CMMN_XML, NARRATIVE, NEGOTIATE_PHASES, NEGOTIATE_STEPS } from './case-model.ts';
|
||||
import { NEGOTIATE_ROSTER, NEGOTIATE_WORKSHEET } from './views/report.ts';
|
||||
|
||||
const DOMAIN = 'quote_negotiate';
|
||||
const D = 'quote_negotiate';
|
||||
const ACTIVITY_ID = activityIdSchema.parse('quote-negotiate');
|
||||
|
||||
/**
|
||||
* 两把尺 —— **规则本身不是数据**,随包冻结。
|
||||
*
|
||||
* · 议价结果 N-01:客户表态 → 定案(出参就是台账 `deal_status` 的取值);
|
||||
* · 让步梯度 N-02:还价幅度 → 让步处置(出参就是台账 `concession` 的取值)。
|
||||
*
|
||||
* 出参键名与台账列同名,模型算完照抄即可(规范 §5.6)。
|
||||
* 两把尺的档位在三处出现:这张表、剧本 guide、工作单上的规则块 ——
|
||||
* 包内自测把三处钉在一起(改一处不改另两处,测就红)。
|
||||
*/
|
||||
export const N01_DMN: DmnModel = {
|
||||
drd: [
|
||||
{ decision: 'n01-outcome', requires: [], inputs: ['reply_type'] },
|
||||
{ decision: 'n02-concession', requires: ['n01-outcome'], inputs: ['gap'] },
|
||||
],
|
||||
tables: [
|
||||
{
|
||||
id: 'n01-outcome',
|
||||
label: '议价结果 N-01',
|
||||
hitPolicy: 'FIRST',
|
||||
inputs: [{ name: 'reply_type', expression: 'reply_type' }],
|
||||
outputs: [{ name: 'deal_status' }],
|
||||
rules: [
|
||||
{ id: 'n01-1', when: ['"ACCEPT"'], then: ['WON'], annotation: '客户明确接受这个价 → 成交,转订单履约' },
|
||||
{ id: 'n01-2', when: ['"COUNTER"'], then: ['HAGGLE'], annotation: '客户还了一个价 → 压在还价上,要人拍板' },
|
||||
{ id: 'n01-3', when: ['"REJECT"'], then: ['LOST'], annotation: '客户明确不做了 → 不成交,情报归档' },
|
||||
{ id: 'n01-4', when: ['"NOREPLY"'], then: ['LOST'], annotation: '催到有效期过仍不表态 → 按超时定案' },
|
||||
{ id: 'n01-default', when: ['-'], then: ['LOST'], annotation: '认不出的表态按不成交处置 —— 不许猜成接受' },
|
||||
],
|
||||
},
|
||||
{
|
||||
/** 让步梯度 N-02 —— FIRST 命中即定,所以第二行等价于「三到十个点」 */
|
||||
id: 'n02-concession',
|
||||
label: '让步梯度 N-02',
|
||||
hitPolicy: 'FIRST',
|
||||
inputs: [{ name: 'gap', expression: 'gap' }],
|
||||
outputs: [{ name: 'concession' }, { name: 'in_authority' }],
|
||||
rules: [
|
||||
{ id: 'n02-1', when: ['<= 0.03'], then: ['ACCEPT_COUNTER', true], annotation: '三个点以内 —— 意思意思的压价,接了拿单' },
|
||||
{ id: 'n02-2', when: ['<= 0.10'], then: ['NEGOTIATE', true], annotation: '三到十个点 —— 授权内回旋一轮,让一半再报回去' },
|
||||
{ id: 'n02-3', when: ['> 0.10'], then: ['ESCALATE', false], annotation: '超过一成 —— 超出授权,退回成本定价重批,不私自破线' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const manifest: ActivityManifest = {
|
||||
identity: {
|
||||
activityId: ACTIVITY_ID,
|
||||
name: 'A6 · 报价议价',
|
||||
packageVersion: '2.1.0',
|
||||
packageVersion: '3.5.0',
|
||||
category: '统一报价',
|
||||
sdkVersion: '0.1.0-m5',
|
||||
checksum: 'dev-checksum-quote-negotiate',
|
||||
},
|
||||
|
||||
/* ── 契约七件 ────────────────────────────────────────────────── */
|
||||
contract: {
|
||||
activityId: ACTIVITY_ID,
|
||||
version: '0.1.0',
|
||||
version: '1.0.0',
|
||||
input: {
|
||||
/*
|
||||
* **宽进到底:一个必填都不设**(与 A1/A2/A3/A4 同口径 —— 宽进严出,
|
||||
* 输入契约在活动内部归一 + 补全)。
|
||||
*
|
||||
* 来路两种形状:流内转来(A4 `draft` 口的 `{rfqRef, customerCode, items}`)、
|
||||
* 单飞(一段文字)。上一版必填 `rfqRef + customerCode + items`,门口拦的恰是主路。
|
||||
*/
|
||||
data: {
|
||||
type: 'object',
|
||||
required: ['rfqRef', 'customerCode', 'items'],
|
||||
properties: { rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' }, customerCode: { type: 'string' }, items: { type: 'array' } },
|
||||
required: [],
|
||||
properties: {
|
||||
rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' },
|
||||
customerCode: { type: 'string', title: '客户码' },
|
||||
items: { type: 'array', title: '报价草案条目(已批准,每条带拟报单价与门禁处置)' },
|
||||
rows: { type: 'array', title: '上游清单行(别的来路也收)' },
|
||||
subject: { type: 'string', title: '需求主题' },
|
||||
body: { type: 'string', title: '需求正文' },
|
||||
attachments: {
|
||||
type: 'array',
|
||||
format: 'file',
|
||||
title: '附件',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', title: '文件名' },
|
||||
contentType: { type: 'string', title: '类型' },
|
||||
size: { type: 'integer', title: '字节数' },
|
||||
ref: { type: 'string', title: '存档号' },
|
||||
text: { type: 'string', title: '读出来的文本' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
messageAllowed: true,
|
||||
messageHint: '客户的议价习惯、竞对价位、这一单的让价授权、催办节奏写这里',
|
||||
},
|
||||
output: {
|
||||
/*
|
||||
* **没有必交付口**:这个活动的成品就是「谈出一个结果」,而结果本身是分支 ——
|
||||
* 三口恰发一口,发出去的那一口就是这一单的 outcome(基座在放行时把口名
|
||||
* 写进事实,下游 E11/E12/E13 读的就是它)。
|
||||
*/
|
||||
mandatory: [],
|
||||
// 议价结果是**业务分支**:必须建模为选择组的一口,随放行发射 —— 不得用异常终态编码
|
||||
/*
|
||||
* **三条出边 = 一个 XOR 选择组**(对齐流程包 E11/E12/E13)。
|
||||
* 每一口都写了 `when`(读基座按值现数的 tally 读数),这一单往哪走是**算出来的**:
|
||||
* 放行时基座按序求值首个命中即出,卡上只如实告诉人走哪一口、凭哪一条。
|
||||
*
|
||||
* 顺序即优先级:**判了退回重批 → 先退回重批**(谈不拢的那几条不能被
|
||||
* 「另外几条成交了」盖过去);其次有成交 → 转履约;都没成 → 转归档。
|
||||
*/
|
||||
selective: [
|
||||
{
|
||||
group: 'negotiation',
|
||||
ports: [
|
||||
/*
|
||||
* 载荷里**必须带 `outcome`** —— 流程包 E10/E11/E12 三条出边读的就是它
|
||||
* (`outcome = "won"/"revise"/"lost"`)。从前三个口的载荷都是空对象 `{}`,
|
||||
* 于是三条边一条也不成立:整条流走到 A6 就没路可走,而口是"发射"过的。
|
||||
* 这与 A1 当年 G1 全流死路是同一种错 —— **边读什么,口就得发什么**。
|
||||
*/
|
||||
{
|
||||
name: 'won',
|
||||
payload: {
|
||||
type: 'object',
|
||||
required: ['outcome'],
|
||||
properties: { outcome: { type: 'string', title: '议价结果(固定 won)' } },
|
||||
},
|
||||
description: '成交 → 转订单履约(ORM)',
|
||||
},
|
||||
{
|
||||
name: 'revise',
|
||||
payload: {
|
||||
type: 'object',
|
||||
required: ['outcome'],
|
||||
properties: { outcome: { type: 'string', title: '议价结果(固定 revise)' } },
|
||||
required: ['rfqRef', 'outcome'],
|
||||
properties: {
|
||||
rfqRef: { type: 'string', title: '询价单号' },
|
||||
customerCode: { type: 'string', title: '客户码' },
|
||||
outcome: { type: 'string', title: '议价结果(固定 revise)' },
|
||||
reason: { type: 'string', title: '退回缘由(让幅超授权多少)' },
|
||||
rows: { type: 'array', title: '报价函(带客户还价与让步处置)' },
|
||||
},
|
||||
},
|
||||
description: '有异议 → 返回调整 A4',
|
||||
description: '有条目经人拍板判了退回重批 —— 让幅超出授权,回成本定价重算再谈',
|
||||
when: `${D}_line_deal_status_revise > 0`,
|
||||
},
|
||||
{
|
||||
name: 'won',
|
||||
payload: {
|
||||
type: 'object',
|
||||
required: ['rfqRef', 'outcome'],
|
||||
properties: {
|
||||
rfqRef: { type: 'string', title: '询价单号' },
|
||||
customerCode: { type: 'string', title: '客户码' },
|
||||
quoteNo: { type: 'string', title: '报价单号' },
|
||||
outcome: { type: 'string', title: '议价结果(固定 won)' },
|
||||
rows: { type: 'array', title: '成交条目(带成交价)' },
|
||||
},
|
||||
},
|
||||
description: '有条目谈成 —— 转订单履约(ORM)',
|
||||
when: `${D}_line_deal_status_won > 0`,
|
||||
},
|
||||
{
|
||||
/* 下游 A7 的入参要 `rfqRef / customerCode / quoteNo` —— 边读什么、下游要什么,这一口就得发什么 */
|
||||
name: 'lost',
|
||||
payload: {
|
||||
type: 'object',
|
||||
required: ['outcome'],
|
||||
properties: { outcome: { type: 'string', title: '议价结果(固定 lost)' } },
|
||||
required: ['rfqRef', 'customerCode', 'quoteNo', 'outcome'],
|
||||
properties: {
|
||||
rfqRef: { type: 'string', title: '询价单号' },
|
||||
customerCode: { type: 'string', title: '客户码' },
|
||||
quoteNo: { type: 'string', title: '报价单号(A7 归档的主键之一)' },
|
||||
outcome: { type: 'string', title: '议价结果(固定 lost)' },
|
||||
reason: { type: 'string', title: '不成交缘由(拒绝 / 超时无回复)' },
|
||||
rows: { type: 'array', title: '报价函(带客户表态与定案)' },
|
||||
},
|
||||
},
|
||||
description: '拒绝/超时 → 转 A7 情报归档',
|
||||
description: '一条都没谈成:客户拒绝或超时无回复 —— 转情报归档',
|
||||
when: `${D}_line_deal_status_lost > 0`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
/*
|
||||
* 必交付物**必须给 `ledger` 地址**(2026-07-31 修)。
|
||||
* 不给的话预检退回 `scope[key]` 去找 —— key 是 `benchmark-table` 这样的短横线别名,
|
||||
* 任务视图里永远没有这个键,于是「必交付物缺一不得提交放行」永远拒,
|
||||
* 活办完了也放不了行,连拒五拍熔断(实测 A2 死于此)。
|
||||
* 必交付物只有一件:**正式报价函**。议价结论(verdict)是它的结论栏,
|
||||
* `ledgers` 说清这份成果由哪几张表承载(一份成果,不是两份)。
|
||||
*/
|
||||
requiredDeliverables: [
|
||||
{ key: 'formal-quote', label: '正式报价单(PDF · 中英双语)', ledger: `${DOMAIN}.formal_quote` },
|
||||
// 跟进不单独立表 —— 建了几条记在议价结论的 followup_total 上
|
||||
{ key: 'followup', label: 'D+3/D+7 跟进提醒', ledger: `${DOMAIN}.verdict` },
|
||||
{
|
||||
key: 'formal-quote',
|
||||
label: '正式报价函(逐条条款 + 客户表态与定案)',
|
||||
ledger: `${D}.line`,
|
||||
/* 验收页上让 BD 就地改的两列 —— 恰是他的职权面(成交价与客户原话) */
|
||||
editableAtRelease: ['final_price', 'reply_note'],
|
||||
ledgers: [`${D}.line`, `${D}.verdict`],
|
||||
},
|
||||
],
|
||||
/*
|
||||
* 机检谓词读的是 `verdict` 那张**汇总表**的列(照 A1 的口径)。
|
||||
* 上一版的名字(missingQuoteFields / validityDays / followupSet)**台账里根本没有** —— S-FEEL 拿未绑定的名字
|
||||
* 比大小求值为假,判据永远达不了标,submit 门永远拒,连拒五拍熔断。
|
||||
* 判据的名字必须落在台账上,否则「机检」是一句空话(2026-07-31 销账)。
|
||||
* 验收判据 —— **机检(L1/L2)只读基座数得出来的数**:
|
||||
* `_rows` 基座数行数、`_missing` 基座按 required / requiredWhen 现数
|
||||
* (与执行卡提交闸同一个数同一套算法)、`_<列>_<值>` 基座按值现数。
|
||||
* 上一版三条机检读活动自报的 `missing_field_total` / `validity_days` /
|
||||
* `followup_total` —— 那不是门,是「改哪个数能过」的提示。
|
||||
*/
|
||||
acceptanceCriteria: [
|
||||
{ key: 'quote-complete', label: '单价/交期/SPQ/MOQ/贸易条款/有效期齐备', level: 'L1', predicate: 'missing_field_total = 0' },
|
||||
{ key: 'validity', label: '按报价有效期 P-04 注明有效期', level: 'L2', predicate: 'validity_days > 0' },
|
||||
{ key: 'followup-set', label: '已建 D+3/D+7 跟进 —— 没有跟进的报价等于没报', level: 'L2', predicate: 'followup_total >= 2' },
|
||||
{ key: 'bd-release', label: 'BD 放行', level: 'L5' },
|
||||
{
|
||||
key: 'roster-complete',
|
||||
label: '报价函齐:上游条目逐条承继,说了几条报价函里就躺着几行',
|
||||
level: 'L1',
|
||||
predicate: `line_total > 0 and ${D}_line_rows = line_total`,
|
||||
},
|
||||
{
|
||||
key: 'replied-all',
|
||||
label: '每条都有客户表态:还价的那几条还落下了客户报的那个价,缺一行放不了',
|
||||
level: 'L1',
|
||||
predicate: `${D}_line_rows > 0 and ${D}_line_missing = 0`,
|
||||
},
|
||||
{
|
||||
key: 'reply-typed',
|
||||
label: '表态逐条落成四种之一:接受 / 还价 / 拒绝 / 无回复 —— 没有含糊其辞的格子',
|
||||
level: 'L2',
|
||||
predicate: `${D}_line_reply_type_accept + ${D}_line_reply_type_counter + ${D}_line_reply_type_reject + ${D}_line_reply_type_noreply = ${D}_line_rows`,
|
||||
},
|
||||
{
|
||||
key: 'settled',
|
||||
label: '定案逐条落定:成交 / 待拍板 / 退回重批 / 不成交,四档相加等于报价函条数',
|
||||
level: 'L2',
|
||||
modelRef: 'n01-outcome',
|
||||
predicate: `${D}_line_rows > 0 and ${D}_line_deal_status_won + ${D}_line_deal_status_haggle + ${D}_line_deal_status_revise + ${D}_line_deal_status_lost = ${D}_line_rows`,
|
||||
},
|
||||
{
|
||||
/* 与 A4 的「拦截档清零」同一形状:还价的条目要么接、要么退回重批、要么守价不让 */
|
||||
key: 'haggle-cleared',
|
||||
label: '客户还的价都拍过板了:接了、退回重批了、还是守价不让 —— 没有还悬着的条目',
|
||||
level: 'L2',
|
||||
predicate: `${D}_line_deal_status_haggle = 0`,
|
||||
},
|
||||
{
|
||||
/*
|
||||
* 退回重批的行**必是客户还过价的行** —— 两边都是基座按值现数。
|
||||
* 没人还价却判了「退回重批」,是模型给自己找了一条不必叫人的近路(实测栽过一轮)。
|
||||
*/
|
||||
key: 'revise-from-counter',
|
||||
label: '判了退回重批的,都是客户真还过价的那几条 —— 没有凭空退回的条目',
|
||||
level: 'L2',
|
||||
predicate: `${D}_line_deal_status_revise <= ${D}_line_reply_type_counter`,
|
||||
},
|
||||
/*
|
||||
* 议价过程可核是**裁量**(L3):客户的话记没记全、让价的理由站不站得住,
|
||||
* 机器数不出来。这句 label 与剧本里 judge 的 criterion **必须逐字一致**。
|
||||
*/
|
||||
{
|
||||
key: 'negotiation-defensible',
|
||||
label: '议价过程说得清:报价函条款齐备、每一轮客户反馈与让价理由逐条可溯,BD 对这个结果负责',
|
||||
level: 'L3',
|
||||
},
|
||||
/* L5 是责任关:那张卡本身就是这道关,卡面上不摆它(基座统一滤) */
|
||||
{ key: 'bd-release', label: 'BD 放行:签下去这一单的议价结果即定案并按去向交接', level: 'L5' },
|
||||
],
|
||||
revisableAtRelease: [],
|
||||
prohibitions: [
|
||||
{ key: 'no-break-line', statement: '客户议价超出授权范围的,回 A5 重批,不私自破线' },
|
||||
{ key: 'no-send-before-release', statement: '报价单在放行前不得发出 —— 写类动作集中在放行时落地' },
|
||||
{ key: 'no-speak-for-customer', statement: '不许替客户表态:客户没回的行按无回复落,不得写成接受 —— 那是在系统里伪造一份客户承诺' },
|
||||
{ key: 'no-break-line', statement: '客户要的价超出让价授权的,退回成本定价重批,不私自破线' },
|
||||
{ key: 'no-send-before-release', statement: '报价函在放行前不得对外发出 —— 触达世界的动作集中在放行那一刻' },
|
||||
],
|
||||
authorization: {
|
||||
discretion: [{ key: 'haggle-band', statement: '在已批准的价格区间内回旋;出区间必须回审批' }],
|
||||
// 预授权必须逐项列举:催办模板可即时发,报价单不行
|
||||
preauthorizedActions: [{ tool: 'mail.send', operation: 'nudge', limit: '仅催办模板' }],
|
||||
humanCallBudget: 3,
|
||||
discretion: [{ key: 'haggle-band', statement: '让步梯度 N-02 给的授权区间内自决;超过一成的让幅一律回成本定价重批' }],
|
||||
preauthorizedActions: [],
|
||||
/* 一张执行单 + 可能一张决策卡 + 放行 = 三次;留一倍余量 */
|
||||
humanCallBudget: 5,
|
||||
},
|
||||
resources: {
|
||||
tools: [{ name: 'mail.send' }, { name: 'crm.history-price-read' }],
|
||||
/*
|
||||
* **不调工具**。上一版声明了 `mail.send`(写类:登记意向、放行时落地)——
|
||||
* 而「把报价函发给客户」这件事在 MVP 示例工作流 §1.4 里本来就是**人工任务**:
|
||||
* 收信的邮箱是 BD 的,客户回什么也只有他看得见。让活动登记一条发信意向,
|
||||
* 既要过落地预检(收件人 / 主题 / 工具授权三道),又与执行单上那句
|
||||
* 「请把它发给客户」重复,而没人说得清究竟哪一份才真发了出去。
|
||||
*/
|
||||
tools: [],
|
||||
skills: NEGOTIATE_SKILLS.map((s) => s.id),
|
||||
models: ['deepseek-v4-flash'],
|
||||
roles: ['BD'],
|
||||
budget: { steps: 50, tokens: 120_000, activeMinutes: 60, humanCalls: 3 },
|
||||
/* SLA:15 分钟内发出、D+3/D+7 催、有效期 7 天;预算按 A4 实测(6-9 拍)留一倍余量 */
|
||||
budget: { steps: 60, tokens: 400_000, activeMinutes: 120, humanCalls: 5 },
|
||||
},
|
||||
},
|
||||
|
||||
/* ── 声明 ────────────────────────────────────────────────────── */
|
||||
declarations: {
|
||||
models: ['deepseek-v4-flash'],
|
||||
tools: [{ name: 'mail.send' }, { name: 'crm.history-price-read' }],
|
||||
tools: [],
|
||||
ledger: [
|
||||
{
|
||||
domain: DOMAIN,
|
||||
object: 'formal_quote',
|
||||
domain: D,
|
||||
object: 'line',
|
||||
kind: 'record',
|
||||
caseKey: 'rfq_ref',
|
||||
label: '正式报价单',
|
||||
label: '正式报价函',
|
||||
fields: [
|
||||
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
|
||||
{ name: 'quote_no', type: 'text', primaryKey: false, masked: false, label: '报价单号' },
|
||||
{ name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' },
|
||||
{ name: 'unit_price', type: 'number', primaryKey: false, masked: false, label: '单价CNY' },
|
||||
{ name: 'customer_pn', type: 'text', primaryKey: false, masked: false, label: '客户料号' },
|
||||
{ name: 'category', type: 'text', primaryKey: false, masked: false, label: '品类' },
|
||||
{ name: 'brand', type: 'text', primaryKey: false, masked: false, label: '制造商' },
|
||||
{ name: 'eau', type: 'integer', primaryKey: false, masked: false, label: '年用量 EAU' },
|
||||
{ name: 'lead_time', type: 'text', primaryKey: false, masked: false, label: '交期' },
|
||||
{ name: 'spq', type: 'integer', primaryKey: false, masked: false, label: 'SPQ' },
|
||||
{ name: 'moq', type: 'integer', primaryKey: false, masked: false, label: 'MOQ' },
|
||||
/* 客户备注承继自上游 —— 认证要求、预授权、急料标注,议价时的第一手依据 */
|
||||
{ name: 'remark', type: 'text', primaryKey: false, masked: false, label: '客户备注(约束/急料)' },
|
||||
{ name: 'type_tag', type: 'text', primaryKey: false, masked: false, label: '类别(承继)', tally: ['A', 'B', 'C'] },
|
||||
/* ── 报价函的条款面:发出去就是对客户的承诺 ─────────────────── */
|
||||
{ name: 'quoted_price', type: 'number', primaryKey: false, masked: false, label: '报价 CNY(已批准)' },
|
||||
{ name: 'spq', type: 'integer', primaryKey: false, masked: false, label: 'SPQ 最小包装' },
|
||||
{ name: 'moq', type: 'integer', primaryKey: false, masked: false, label: 'MOQ 最小起订' },
|
||||
{ name: 'trade_terms', type: 'text', primaryKey: false, masked: false, label: '贸易条款' },
|
||||
{ name: 'valid_until', type: 'date', primaryKey: false, masked: false, label: '有效期' },
|
||||
],
|
||||
},
|
||||
{
|
||||
domain: 'market',
|
||||
object: 'price_history',
|
||||
kind: 'master',
|
||||
label: '历史价格情报(共享域)',
|
||||
fields: [
|
||||
{ name: 'pn', type: 'text', primaryKey: true, masked: false },
|
||||
{ name: 'quoted_at', type: 'date', primaryKey: true, masked: false, label: '报价日期' },
|
||||
{ name: 'unit_price', type: 'number', primaryKey: false, masked: false },
|
||||
{ name: 'lead_time', type: 'text', primaryKey: false, masked: false },
|
||||
{ name: 'valid_until', type: 'date', primaryKey: false, masked: false, label: '有效期至' },
|
||||
/*
|
||||
* ── 客户那一侧的三列:**AI 一格都不写** ────────────────────────
|
||||
*
|
||||
* 与 A4 的 `quoted_price` 同一处结构治:一条要模型分辨「这行能不能替客户填」
|
||||
* 的规矩会走岔;「这几列不写」没有分支可走岔。
|
||||
*
|
||||
* `reply_type` 标 `required` —— 件上的缺项红带、执行卡的提交闸、放行门 L1
|
||||
* 三处同源(都数基座的 `_missing`)。
|
||||
* `counter_price` 用 `requiredWhen`:**只有还价那一行才算必填**
|
||||
* (表级 required 会让接受/拒绝的行永远缺一格,把闸拦成死锁)。
|
||||
*/
|
||||
{
|
||||
name: 'reply_type',
|
||||
type: 'text',
|
||||
primaryKey: false,
|
||||
masked: false,
|
||||
required: true,
|
||||
label: '客户表态',
|
||||
tally: ['ACCEPT', 'COUNTER', 'REJECT', 'NOREPLY'],
|
||||
},
|
||||
{
|
||||
name: 'counter_price',
|
||||
type: 'number',
|
||||
primaryKey: false,
|
||||
masked: false,
|
||||
requiredWhen: { column: 'reply_type', value: 'COUNTER' },
|
||||
label: '客户还价',
|
||||
},
|
||||
{ name: 'reply_note', type: 'text', primaryKey: false, masked: false, label: '客户原话 / 让价理由' },
|
||||
/* ── 定案面:两把尺算出来的,模型照抄 ─────────────────────────── */
|
||||
/*
|
||||
* 定案四档,**HAGGLE 是过渡态**(客户还了价、还没拍板):拍板那一下把它换成
|
||||
* WON / REVISE / LOST 之一,于是「还价才叫人」那道哨兵当场为假。
|
||||
* 上一版只有三档、退回重批那条路「定案不动」—— 人点完了 haggle 还是那个数,
|
||||
* 哨兵照旧成立,模型读同一段做法就再发一张卡:**实测同一张连发六次**。
|
||||
* 措辞治不了这种事,得让「办完了」在数上看得见(2026-08-03 逼出来的账)。
|
||||
*/
|
||||
{ name: 'deal_status', type: 'text', primaryKey: false, masked: false, label: '定案', tally: ['WON', 'HAGGLE', 'REVISE', 'LOST'] },
|
||||
{
|
||||
name: 'concession',
|
||||
type: 'text',
|
||||
primaryKey: false,
|
||||
masked: false,
|
||||
label: '让步处置(N-02)',
|
||||
tally: ['ACCEPT_COUNTER', 'NEGOTIATE', 'ESCALATE'],
|
||||
},
|
||||
{ name: 'final_price', type: 'number', primaryKey: false, masked: false, label: '成交价' },
|
||||
],
|
||||
},
|
||||
{
|
||||
/*
|
||||
* 议价结论 —— 一单一行的**汇总表**,验收机检谓词的入参就在这儿。
|
||||
* 议价结论 —— 一单一行的**汇总表**,哨兵与报告读它。
|
||||
* 汇总列与逐行列**故意不同名**:任务视图把草稿行摊平到顶层是「后写的赢」,
|
||||
* 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。
|
||||
*/
|
||||
domain: DOMAIN,
|
||||
domain: D,
|
||||
object: 'verdict',
|
||||
kind: 'record',
|
||||
caseKey: 'rfq_ref',
|
||||
@@ -170,58 +395,186 @@ export const manifest: ActivityManifest = {
|
||||
fields: [
|
||||
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
|
||||
{ name: 'quote_no', type: 'text', primaryKey: false, masked: false, label: '报价单号' },
|
||||
{ name: 'quote_total', type: 'integer', primaryKey: false, masked: false, label: '报价行条数' },
|
||||
{ name: 'missing_field_total', type: 'integer', primaryKey: false, masked: false, label: '条款缺失条数' },
|
||||
{ name: 'validity_days', type: 'integer', primaryKey: false, masked: false, label: '报价有效期天数' },
|
||||
{ name: 'followup_total', type: 'integer', primaryKey: false, masked: false, label: '已建跟进条数' },
|
||||
{ name: 'customer_code', type: 'text', primaryKey: false, masked: false, label: '客户码' },
|
||||
{ name: 'customer_name', type: 'text', primaryKey: false, masked: false, label: '客户名称' },
|
||||
{ name: 'line_total', type: 'integer', primaryKey: false, masked: false, label: '报价函条数' },
|
||||
{ name: 'valid_days', type: 'integer', primaryKey: false, masked: false, label: '报价有效期天数' },
|
||||
/* 排版走完的凭据(0 也要写)—— 哨兵按它放交人发出那一步入场 */
|
||||
{ name: 'composed_total', type: 'integer', primaryKey: false, masked: false, label: '已排版条数' },
|
||||
/* 「客户回音落齐了」的凭据 —— 只在人环走完才产生 */
|
||||
{ name: 'replied_total', type: 'integer', primaryKey: false, masked: false, label: '已落客户表态条数' },
|
||||
{ name: 'settled_total', type: 'integer', primaryKey: false, masked: false, label: '已定案条数' },
|
||||
{ name: 'route_decided_total', type: 'integer', primaryKey: false, masked: false, label: '已定案定盘' },
|
||||
{ name: 'outcome', type: 'text', primaryKey: false, masked: false, label: '议价结果(won/revise/lost)' },
|
||||
{ name: 'reason', type: 'text', primaryKey: false, masked: false, label: '不成交 / 退回缘由' },
|
||||
{ name: 'summary', type: 'text', primaryKey: false, masked: false, label: '议价结论' },
|
||||
],
|
||||
},
|
||||
],
|
||||
/*
|
||||
* 三条静态监听 —— **时限必配**(AM 4.4)。等客户回音这件事的落单人是 BD
|
||||
* (见 case-model 头注),这三条管的是「别把它挂到天荒地老」:
|
||||
* D+3 催一轮、D+7 最后一催并升级、客户真从通道回信时也认得出是这一单。
|
||||
*/
|
||||
listeners: [
|
||||
{
|
||||
origin: 'static',
|
||||
eventType: 'time',
|
||||
keyFilter: { kind: 'followup-d3' },
|
||||
semantic: 'observe',
|
||||
deadline: 'P3D',
|
||||
escalation: ['nudge'],
|
||||
},
|
||||
{
|
||||
origin: 'static',
|
||||
eventType: 'time',
|
||||
keyFilter: { kind: 'quote-expiry' },
|
||||
semantic: 'observe',
|
||||
deadline: 'P7D',
|
||||
escalation: ['nudge', 'escalate'],
|
||||
},
|
||||
{
|
||||
origin: 'static',
|
||||
eventType: 'data',
|
||||
keyFilter: { kind: 'customer-reply' },
|
||||
semantic: 'complete-step',
|
||||
// 建 D+3 跟进监听:等客户回音,时限必配 —— 无死等
|
||||
deadline: 'P3D',
|
||||
escalation: ['nudge', 'escalate'],
|
||||
semantic: 'observe',
|
||||
deadline: 'P7D',
|
||||
escalation: ['nudge'],
|
||||
},
|
||||
],
|
||||
harness: {
|
||||
memoryWindow: 20,
|
||||
stallAfter: 'PT15M',
|
||||
nudge: [{ after: 'PT10M', action: 'nudge' }],
|
||||
memoryWindow: 22,
|
||||
// 报价有效期 P-04 是 7 天,但这一单的**活办完**只需两个人环
|
||||
stallAfter: 'PT2H',
|
||||
nudge: [{ after: 'PT1H', action: 'nudge' }],
|
||||
retry: { times: 2, backoff: 'PT30S' },
|
||||
},
|
||||
},
|
||||
|
||||
/* ── 资产初始版 ──────────────────────────────────────────────── */
|
||||
assets: {
|
||||
/* 资产初始版:案例模型(2026-07-29 起活动只有这一种业务模型) */
|
||||
businessModel: {
|
||||
cmmnXml: CMMN_XML,
|
||||
narrative: NARRATIVE,
|
||||
dmn: { drd: [], tables: [] },
|
||||
/* 步骤剧本 —— 与 A1 同一套:doneWhen 真了引擎自己收步,不问模型 */
|
||||
dmn: N01_DMN,
|
||||
steps: NEGOTIATE_STEPS,
|
||||
phases: NEGOTIATE_PHASES,
|
||||
},
|
||||
skills: NEGOTIATE_SKILLS,
|
||||
/*
|
||||
* 两个局面各一张(与 A2/A3/A4 同款口径):
|
||||
* · `negotiate-worksheet` 议价工作单 —— 排版、发出与落回音期间摆它(逐颗报价档案、
|
||||
* 两把尺、填回工作表);
|
||||
* · `negotiate-roster` 成交定案清单 —— 拍板、放行、归档摆它。
|
||||
*/
|
||||
viewTemplates: [
|
||||
{ name: 'quote-composed', html: QUOTE_COMPOSED },
|
||||
{ name: 'quote-release', html: QUOTE_RELEASE },
|
||||
{ name: 'negotiate-worksheet', html: NEGOTIATE_WORKSHEET },
|
||||
{ name: 'negotiate-roster', html: NEGOTIATE_ROSTER },
|
||||
],
|
||||
cardSchemas: {},
|
||||
distillHooks: [{ on: 'terminal', prompt: '归纳本单议价的经验:客户的还价幅度、成交与否的关键因素。' }],
|
||||
distillHooks: [
|
||||
{
|
||||
on: 'terminal',
|
||||
prompt: '归纳本单议价:客户的还价幅度与说法、让步梯度哪一档最常命中、成交与不成交的关键因素。',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
/* ── 样例集 —— 三条形状真不同(常备案例即验收案例)───────────── */
|
||||
samples: [
|
||||
{
|
||||
name: '车规 Analog 报价',
|
||||
input: { customerCode: 'C001', items: [{ pn: 'LM358DR', quotedPrice: 1.95 }] },
|
||||
/*
|
||||
* ① 流内常路:A4 `draft` 口的真实形状 —— items 里草案行(带批准价与门禁处置)
|
||||
* 混着结论行。客户一轮就全接受,一个人都不必额外惊动 → won。
|
||||
*/
|
||||
name: '流内转来 · 两条已批准 · 客户一轮全接受(常路)',
|
||||
input: {
|
||||
rfqRef: 'RFQ MD14.1',
|
||||
customerCode: 'CU-0142',
|
||||
customer_name: 'Itelma',
|
||||
items: [
|
||||
{
|
||||
rfq_ref: 'RFQ MD14.1',
|
||||
pn: 'BBPY00201209601Y00',
|
||||
customer_pn: '4-14-01502',
|
||||
category: '滤波器',
|
||||
brand: 'YAGEO',
|
||||
eau: 30000,
|
||||
lead_time: '8周',
|
||||
type_tag: 'B',
|
||||
quoted_price: 0.085,
|
||||
disposition: 'PASS',
|
||||
price_note: '客户长单,按目标价接',
|
||||
},
|
||||
{
|
||||
rfq_ref: 'RFQ MD14.1',
|
||||
pn: 'CJTB0101R6N',
|
||||
customer_pn: '4-00-04102',
|
||||
category: '集成电路',
|
||||
brand: 'Jiangsu Changjing',
|
||||
eau: 60000,
|
||||
lead_time: '6周',
|
||||
type_tag: 'A',
|
||||
quoted_price: 0.42,
|
||||
disposition: 'PASS',
|
||||
},
|
||||
{ rfq_ref: 'RFQ MD14.1', quote_line_total: 2, customer_name: 'Itelma', summary: '两条已批准,交议价' },
|
||||
],
|
||||
},
|
||||
expect: { ports: ['won'] },
|
||||
},
|
||||
{
|
||||
/*
|
||||
* ② 客户压价:一条还了价、压得超过一成 —— 让步梯度判 ESCALATE(超出授权),
|
||||
* 必须叫人拍板(决策卡);拍板选「退回成本定价重批」→ revise。
|
||||
*/
|
||||
name: '客户压价一成以上 · 决策卡拍板 → 退回定价重批',
|
||||
input: {
|
||||
rfqRef: 'RFQ-2026-0777',
|
||||
customerCode: 'CU-0311',
|
||||
customer_name: '青岛海洋电子',
|
||||
items: [
|
||||
{
|
||||
rfq_ref: 'RFQ-2026-0777',
|
||||
pn: 'PESD2ETH-XR',
|
||||
category: '二极管',
|
||||
brand: 'Nexperia',
|
||||
eau: 30000,
|
||||
lead_time: '8周',
|
||||
type_tag: 'B',
|
||||
quoted_price: 0.098,
|
||||
disposition: 'NOTE',
|
||||
price_note: '竞对价压,已让到底价线内',
|
||||
remark: '客户明说这个价是竞对给的,要跟',
|
||||
},
|
||||
{
|
||||
rfq_ref: 'RFQ-2026-0777',
|
||||
pn: 'NE555P',
|
||||
category: '定时器',
|
||||
brand: 'TI',
|
||||
eau: 5000,
|
||||
lead_time: '4周',
|
||||
type_tag: 'A',
|
||||
quoted_price: 1.05,
|
||||
disposition: 'PASS',
|
||||
},
|
||||
{ rfq_ref: 'RFQ-2026-0777', quote_line_total: 2, customer_name: '青岛海洋电子' },
|
||||
],
|
||||
},
|
||||
expect: { ports: ['revise'] },
|
||||
},
|
||||
{
|
||||
/*
|
||||
* ③ 单飞宽进:没有单号没有清单,正文说了发给谁、报什么价 —— 归一造案号;
|
||||
* 客户两轮催问都不回,按超时定案 → lost(下游 A7 情报归档)。
|
||||
*/
|
||||
name: '单飞需求 · 正文列两颗料 · 客户 D+7 无回复(超时定案)',
|
||||
input: {
|
||||
subject: 'MD14.2 报价函发客户',
|
||||
body: '定价审批已通过:XCJ-7788T 电源模块 8K,批准价 14.80;LMR-3302QQ 稳压器 15K,批准价 2.35。客户是新客户 深圳市锐驰电子,联系人王工 wang@ruichi.example.cn,交期都按 6 周报。请发正式报价函并按 D+3/D+7 跟进。',
|
||||
},
|
||||
expect: { ports: ['lost'] },
|
||||
},
|
||||
],
|
||||
|
||||
entry: { file: 'index.js', checksum: 'dev-entry-quote-negotiate' },
|
||||
|
||||
@@ -1,42 +1,56 @@
|
||||
/**
|
||||
* A6 技能 —— 议价与出单的方法经验(AM 08)。
|
||||
* A6 技能 —— 议价的方法经验。载方法,不载权力。
|
||||
*
|
||||
* 与剧本的分工:剧本写「这一步做什么、写哪几列」,技能写「为什么这么谈、
|
||||
* 拿不准时按哪条走」。剧本改了口径,技能里同一句要跟着改(规范 §5.9)。
|
||||
*/
|
||||
import { skillIdSchema } from '@contracts';
|
||||
import type { Skill } from '@contracts';
|
||||
|
||||
export const NEGOTIATE_SKILLS: Skill[] = [
|
||||
{
|
||||
id: skillIdSchema.parse('negotiate.compose-quote'),
|
||||
label: '拟一份能发出去的报价单',
|
||||
applicability: '定价已过审批,要生成正式报价单时。',
|
||||
id: skillIdSchema.parse('negotiate.read-reply'),
|
||||
label: '读懂客户的那一句回音',
|
||||
applicability: '客户回话了,要把它落成一个表态时。',
|
||||
method: [
|
||||
'报价单是**要发给客户**的东西,所以每一项都得能兑现:',
|
||||
'· 单价与交期照审批通过的草案抄,不做任何「顺手优化」——改价要重新过审批点。',
|
||||
'· SPQ / MOQ / 贸易条款 / 有效期缺一不可:缺了客户会追问,追问就是又一轮往返。',
|
||||
'· 有效期按行情波动定,不要一律写「长期有效」——那等于把汇率与料价风险自己扛了。',
|
||||
'客户的话落到系统里只有四种表态,别的都是这四种的说法之一:',
|
||||
'· ACCEPT —— 明确接受这个价:「按这个价走」「下单吧」「PO 稍后发」。',
|
||||
'· COUNTER —— 还了一个价,或明确说「贵了多少」「对标某某家便宜多少」。',
|
||||
' **还价必须落下客户报的那个数**:没有数字的「太贵了」先追问,追不到才按无回复落。',
|
||||
'· REJECT —— 明确不做了:选了别家、项目取消、规格不匹配。',
|
||||
'· NOREPLY —— 催到有效期过仍不表态。**客户不回不是结论**:',
|
||||
' D+3 第一轮催问确认收到探态度,D+7 最后一催,催过了才轮到按超时定案。',
|
||||
'',
|
||||
'拿不准的项**宁可空着走补全**,不要填一个看起来合理的值:',
|
||||
'报价单一旦发出就是承诺,发出前的空白是成本,发出后的错误是损失。',
|
||||
'两条别做的事:',
|
||||
'· 不要替客户表态。他只回了一部分料的,没回的那几行就是 NOREPLY ——',
|
||||
' 把「大概是接受了吧」写成 ACCEPT,是在系统里伪造一份客户承诺。',
|
||||
'· 不要把口头承诺留在脑子里。每轮反馈当天记回备注,',
|
||||
' 下一轮谈判、事后复盘、情报归档读的都是这一格。',
|
||||
].join('\n'),
|
||||
crystallized: [],
|
||||
knowledgeRefs: ['报价单模版规范'],
|
||||
version: '1.0.0',
|
||||
knowledgeRefs: ['议价结果 N-01'],
|
||||
version: '2.0.0',
|
||||
},
|
||||
{
|
||||
id: skillIdSchema.parse('negotiate.pick-outcome'),
|
||||
label: '选对议价出口',
|
||||
applicability: '客户回复后,要确认议价结果出口时。',
|
||||
id: skillIdSchema.parse('negotiate.concede'),
|
||||
label: '让价守线',
|
||||
applicability: '客户还了价,要决定让不让、让多少时。',
|
||||
method: [
|
||||
'三个出口对应三种后续,**恰发一口**:',
|
||||
'· 成交 → 转订单履约,这条流走到终点。',
|
||||
'· 有异议 → 回 A4 重开新实例重算 —— 注意是**新实例**,旧的已终、不再入算。',
|
||||
'· 拒绝 / 超时 → 转 A7 情报归档:把拒绝原因与竞对价位登记下来。',
|
||||
'先弄清争的是什么:价格、交期、MOQ,还是走过场压一轮 ——',
|
||||
'**对症再谈,不上来就让**。争交期的用交期换,争 MOQ 的用包装换;',
|
||||
'一上来就降价,后面每一轮就都只剩降价这一招。',
|
||||
'',
|
||||
'「拒绝」不是失败终态。这一单丢了,但拒绝原因回写历史情报后,',
|
||||
'下一单的三分类与利润模型会用上它 —— 这才是归档存在的理由。',
|
||||
'让步梯度 N-02 是尺,不是台阶(不必逐档走下去):',
|
||||
'· 三个点以内 —— 意思意思的压价,接了拿单,来回一轮的时间成本比这三个点贵;',
|
||||
'· 三到十个点 —— 授权内回旋:让一半再报回去,把让出去的换成量或账期;',
|
||||
'· 超过一成 —— 超出授权,**交回成本定价重批,不私自破线**。',
|
||||
' 破线不是勇气是事故:底价线以下的单,每补一次货都在亏。',
|
||||
'',
|
||||
'让了就要说清为什么让:客户长单、带单品、竞对压价 ——',
|
||||
'理由随定案一起交给签字的人。没有理由的让价,到复盘那一关同样过不去。',
|
||||
].join('\n'),
|
||||
crystallized: [],
|
||||
knowledgeRefs: [],
|
||||
version: '1.0.0',
|
||||
knowledgeRefs: ['让步梯度 N-02', '底价门禁 P-02'],
|
||||
version: '2.0.0',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* A1 两份执行页的样式表 —— **从 PRD 目标件原样搬过来**,不是我照着复述的。
|
||||
*
|
||||
* 出处:`~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理需求.html` 的整段 `<style>`,
|
||||
* 外加 `A1-询价受理记录.html` 里归档件独有的那几件(分类分布条 / 类徽 / 判定表 /
|
||||
* 结论横幅 / 落地动作 / 完成标)。**一个数值都没改**。
|
||||
*
|
||||
* 为什么是搬而不是抄:前三轮我用自己那套外壳「照着做」,块搬齐了、版面是两套 ——
|
||||
* 章节头成了彩色方块编号、多出摘要与元信息带、字号用 clamp。人一眼就看出不是那份件。
|
||||
* 目标件是**像素级的答案**,搬过来才不会在复述里走样。
|
||||
*
|
||||
* 选择器保留 `.proto-root .app-shell` 前缀,所以模版正文也要照原型那样包两层壳 ——
|
||||
* 少一层壳,整张样式表一条都不生效(踩过)。
|
||||
*/
|
||||
export const PRD_CSS = `
|
||||
/* ============================================================================
|
||||
JCAI 员工面工作台 · A1 受理归一的过程成果「询价受理需求」——报告本体
|
||||
立场:**AI 已从 RFQ 邮件抽取归一**,人在这里审核并就地修订。
|
||||
两态:读态(AI 稿 + 红/灰占位提示缺项) ⇄ 编辑态(全字段可改,保存 / 取消)
|
||||
核心是「有效 PN 清单」;清单之上四件受理要素:客户公司 / 联系人 / 客户属性 / 有效 PN。
|
||||
|
||||
令牌与既有组件取自 workbench/src/app/(proto)/app.css,数值未改。
|
||||
⚠️ 修正一处原型 bug:app.css:2062-2064 的 '.jc-rp-sign span'(权重 0,1,1)
|
||||
盖过了 '.jc-rp-ava'(0,1,0),把头像的 display:grid 覆盖成 block、字色改灰,
|
||||
导致落款头像里的字掉到圆外。本文件把该规则收窄为 '.jc-rp-sign > div > span'。
|
||||
新增件:四要素卡 .jc-four/.jc-fcard · 占位胶囊 .jc-ph · 分段控件 .jc-seg ·
|
||||
来源条 .jc-src · 录入表 .jc-tbl-in
|
||||
========================================================================== */
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body { background: #e6eaf1; -webkit-font-smoothing: antialiased; }
|
||||
|
||||
.proto-root .app-shell {
|
||||
--brand: #3370ff; --brand-deep: #245bdb;
|
||||
--brand-soft: #e1eaff; --brand-wash: #f0f4ff;
|
||||
--ai-grad: linear-gradient(135deg, #3370ff, #7f3bf5);
|
||||
--ink: #1f2329; --ink-2: #41464e; --ink-3: #646a73; --ink-4: #8f959e;
|
||||
--line: #dee0e3; --line-soft: #e8eaed; --soft: #f8f9fb;
|
||||
--jcai-bg: #e6eaf1; --cardline: #e1e6ec; --card-shadow: 0 1px 2px rgba(31, 35, 41, .05);
|
||||
--ok: #278717; --ok-soft: #e8f8e4;
|
||||
--warn: #de7802; --warn-accent: #ff8800; --warn-soft: #fff4e5;
|
||||
--danger: #d83931; --danger-soft: #feeae9;
|
||||
--mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace;
|
||||
--shadow-brand: 0 6px 16px rgba(51, 112, 255, .22);
|
||||
--t-fast: .16s cubic-bezier(.3, 0, .2, 1);
|
||||
--t-med: .26s cubic-bezier(.3, 0, .2, 1);
|
||||
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: var(--jcai-bg); min-height: 100vh; color: var(--ink);
|
||||
}
|
||||
.proto-root :where(button, input, select, a, [tabindex]):focus-visible {
|
||||
outline: 2px solid var(--brand); outline-offset: 2px; border-radius: 8px;
|
||||
}
|
||||
.proto-root ::selection { background: var(--brand-soft); }
|
||||
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes jcBlink { 0%,100% { opacity: 1 } 50% { opacity: .35 } }
|
||||
@keyframes jcStamp { from { opacity: 0; transform: rotate(-6deg) scale(1.7); } to { opacity: .9; transform: rotate(-6deg) scale(1); } }
|
||||
|
||||
/* 放开宽度:1160px 是「9 列全展开、一个字都不裁」的最小容器宽度(见下方列 min-width 之和) */
|
||||
.jc-stage { max-width: 1160px; margin: 0 auto; padding: 22px 16px 52px; }
|
||||
.proto-root .jc-mono { font-family: var(--mono); font-size: 11px; color: var(--ink-3); }
|
||||
.proto-root .jc-pulse-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--brand); display: inline-block; animation: jcBlink 1.1s ease-in-out infinite; }
|
||||
|
||||
/* ── 报告外壳 ───────────────────────────────────────────────────────── */
|
||||
.proto-root .jc-report { background: #fff; border: 1px solid var(--cardline); border-radius: 14px; box-shadow: var(--card-shadow); overflow: hidden; animation: rise var(--t-med) both; }
|
||||
.proto-root .jc-rp-head { position: relative; overflow: hidden; display: flex; align-items: flex-start; gap: 12px; padding: 16px 20px 13px; border-bottom: 2px solid var(--ink); }
|
||||
/* 题头下缘粗细双线 = 正式件版式 */
|
||||
.proto-root .jc-rp-head::after { content: ""; position: absolute; left: 0; right: 0; bottom: -4px; height: 1px; background: var(--line); }
|
||||
/* 题头右上的品牌光晕:与工作台标题区同一签名件 */
|
||||
.proto-root .jc-rp-head::before { content: ""; position: absolute; right: -70px; top: -96px; width: 240px; height: 240px; border-radius: 999px; background: radial-gradient(circle, rgba(51,112,255,.10), transparent 70%); pointer-events: none; }
|
||||
.proto-root .jc-rp-head > * { position: relative; z-index: 1; }
|
||||
.proto-root .jc-rp-badge { flex: none; width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand); }
|
||||
.proto-root .jc-rp-tit { flex: 1; min-width: 0; }
|
||||
.proto-root .jc-rp-tit em { display: block; font-style: normal; font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); font-family: var(--mono); }
|
||||
.proto-root .jc-rp-tit h3 { margin: 4px 0 0; font-size: 18px; font-weight: 800; color: var(--ink); letter-spacing: -.01em; line-height: 1.3; }
|
||||
.proto-root .jc-rp-wait { display: inline-flex; align-items: center; gap: 6px; flex: none; margin-top: 4px; font-size: 11px; font-weight: 700; border-radius: 999px; padding: 4px 12px; color: var(--brand-deep); background: var(--brand-wash); border: 1px solid var(--brand-soft); }
|
||||
.proto-root .jc-rp-wait.warn { color: var(--warn); background: var(--warn-soft); border-color: #ffd9a8; }
|
||||
.proto-root .jc-rp-wait.edit { color: #fff; background: var(--brand); border-color: var(--brand); }
|
||||
.proto-root .jc-rp-stamp { flex: none; margin-top: 2px; transform: rotate(-6deg); border: 2px solid currentColor; border-radius: 9px; padding: 4px 8px 3px 12px; font-size: 13px; font-weight: 900; letter-spacing: .28em; color: var(--ok); opacity: .9; animation: jcStamp .32s cubic-bezier(.2, 1.4, .4, 1) both; }
|
||||
.proto-root .jc-rp-bd { padding: 18px 20px 12px; }
|
||||
.proto-root .jc-rp-part { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 9px; margin: 20px 0 11px; }
|
||||
.proto-root .jc-rp-part:first-child { margin-top: 0; }
|
||||
.proto-root .jc-rp-part > b { flex: none; font-size: 11px; font-weight: 800; letter-spacing: .12em; color: var(--ink-3); }
|
||||
.proto-root .jc-rp-part > .ln { flex: 1; height: 1px; background: var(--line-soft); }
|
||||
.proto-root .jc-rp-part > em { flex: none; font-style: normal; font-size: 10px; font-weight: 700; color: var(--ink-4); font-family: var(--mono); background: var(--soft); border-radius: 999px; padding: 2px 9px; white-space: nowrap; }
|
||||
.proto-root .jc-rp-part > em.warn { color: var(--warn); background: var(--warn-soft); }
|
||||
|
||||
/* 落款 —— ⚠️ 选择器收窄到 > div > *,别再命中头像那个 span */
|
||||
.proto-root .jc-rp-foot { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 13px 20px 15px; border-top: 2px solid var(--line); background: #fbfcfd; }
|
||||
.proto-root .jc-rp-sign { display: flex; align-items: center; gap: 10px; flex: 1 1 200px; min-width: 0; }
|
||||
.proto-root .jc-rp-ava { flex: 0 0 auto; width: 30px; height: 30px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; line-height: 1; color: #fff; background: var(--brand); }
|
||||
.proto-root .jc-rp-sign > div { min-width: 0; }
|
||||
.proto-root .jc-rp-sign > div > b { display: block; font-size: 12px; font-weight: 700; color: var(--ink-2); }
|
||||
.proto-root .jc-rp-sign > div > span { display: block; margin-top: 1px; font-size: 10.5px; color: var(--ink-4); }
|
||||
.proto-root .jc-rp-acts { display: flex; align-items: center; gap: 8px; margin-left: auto; flex: none; }
|
||||
.proto-root .jc-hintline { font-size: 11px; font-weight: 700; color: var(--warn); line-height: 1.5; }
|
||||
|
||||
.proto-root .jc-btn { display: inline-flex; align-items: center; gap: 5px; border: 0; border-radius: 7px; padding: 8px 14px; font-size: 12px; font-weight: 600; font-family: inherit; background: var(--brand); color: #fff; cursor: pointer; transition: all var(--t-fast); }
|
||||
.proto-root .jc-btn:hover:not(:disabled) { background: var(--brand-deep); }
|
||||
.proto-root .jc-btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.proto-root .jc-btn-ghost { background: #fff; color: var(--ink-2); border: 1px solid var(--line); }
|
||||
.proto-root .jc-btn-ghost:hover:not(:disabled) { background: var(--brand-wash); color: var(--brand); border-color: var(--brand-soft); }
|
||||
.proto-root .jc-btn-primary { background: var(--ai-grad); }
|
||||
.proto-root .jc-btn-primary:hover:not(:disabled) { filter: brightness(1.07); box-shadow: var(--shadow-brand); transform: translateY(-1px); }
|
||||
|
||||
/* ── 来源信件:客户 RFQ 邮件原文(复用原型 jc-letter 件,青色 = 收件) ── */
|
||||
.proto-root .jc-letter { position: relative; border: 1px solid var(--cardline); border-left: 3px solid #0aa294; border-radius: 12px; padding: 14px 16px 15px; background: #fff; box-shadow: var(--card-shadow); }
|
||||
.proto-root .jc-letter-h { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 11px; }
|
||||
.proto-root .jc-letter-dir { display: inline-flex; align-items: center; gap: 5px; font-size: 10.5px; font-weight: 800; padding: 3px 9px; border-radius: 999px; background: #e4f8f5; color: #0a7f74; }
|
||||
.proto-root .jc-letter-h .jc-chip { font-size: 10.5px; padding: 3px 9px; border-radius: 999px; background: var(--brand-wash); color: var(--brand-deep); border: 1px solid var(--brand-soft); }
|
||||
.proto-root .jc-letter-h > code { margin-left: auto; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
|
||||
.proto-root .jc-letter-meta { display: flex; flex-direction: column; gap: 4px; margin-bottom: 11px; padding-bottom: 11px; border-bottom: 1px solid var(--line-soft); font-size: 11.5px; color: var(--ink-2); }
|
||||
.proto-root .jc-letter-meta p { margin: 0; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
|
||||
.proto-root .jc-letter-meta i { flex: none; font-style: normal; width: 44px; font-size: 10px; font-weight: 800; letter-spacing: .06em; color: var(--ink-4); }
|
||||
.proto-root .jc-letter-meta code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); overflow-wrap: anywhere; }
|
||||
.proto-root .jc-letter-sub { margin: 0 0 9px; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.45; overflow-wrap: anywhere; }
|
||||
/* 正文:原文照录,pre-wrap 保原始换行;不截断、不折叠 */
|
||||
.proto-root .jc-letter-body { margin: 0; font-size: 12.5px; line-height: 1.72; color: var(--ink-2); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-letter-body b { color: var(--ink); font-weight: 700; }
|
||||
.proto-root .jc-atts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; padding-top: 11px; border-top: 1px dashed var(--line-soft); }
|
||||
.proto-root .jc-att { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px 5px 8px; border: 1px solid var(--brand-soft); border-radius: 8px; background: var(--brand-wash); font-size: 11.5px; color: var(--brand-deep); }
|
||||
.proto-root .jc-att svg { flex: none; color: var(--brand); }
|
||||
.proto-root .jc-att code { font-family: var(--mono); font-size: 10.5px; }
|
||||
.proto-root .jc-att span { color: var(--ink-4); }
|
||||
/* 抽取指向:从原文到下方要素的一句衔接 */
|
||||
.proto-root .jc-derive { display: flex; align-items: flex-start; gap: 8px; margin-top: 11px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); font-size: 11.5px; line-height: 1.7; color: var(--brand-deep); }
|
||||
.proto-root .jc-derive svg { flex: none; margin-top: 2px; }
|
||||
|
||||
/* ── 四要素卡 ───────────────────────────────────────────────────────────
|
||||
色调字典:每张卡带一套 --tone / --tone-soft / --tone-line,组件只读变量。
|
||||
卡 3、卡 4 的色调随数据变(老客户绿 / 新客户橙;齐了蓝 / 有缺橙),
|
||||
所以这条要素带本身就是一行可读的状态,不只是四个格子。 */
|
||||
.proto-root .jc-four { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 11px; }
|
||||
.proto-root .jc-fcard {
|
||||
position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-width: 0;
|
||||
padding: 14px 14px 14px; border: 1px solid var(--cardline); border-radius: 13px; background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 8px 20px -14px rgba(31,35,41,.35);
|
||||
transition: transform var(--t-fast), box-shadow var(--t-fast), border-color var(--t-fast);
|
||||
--tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft);
|
||||
}
|
||||
/* 顶缘色棱 + 右上角光晕:卡的身份色,一眼分得清四件是四件 */
|
||||
.proto-root .jc-fcard::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 3px; background: var(--tone); }
|
||||
.proto-root .jc-fcard::after { content: ""; position: absolute; right: -46px; top: -52px; width: 132px; height: 132px; border-radius: 999px; background: radial-gradient(circle, var(--tone-soft), transparent 70%); pointer-events: none; }
|
||||
.proto-root .jc-fcard > * { position: relative; z-index: 1; }
|
||||
.proto-root .jc-fcard:hover { transform: translateY(-2px); border-color: var(--tone-line); box-shadow: 0 2px 4px rgba(31,35,41,.05), 0 14px 26px -16px rgba(31,35,41,.45); }
|
||||
.proto-root .jc-fcard.t-teal { --tone: #0aa294; --tone-soft: rgba(10,162,148,.15); --tone-line: #9fe0d9; }
|
||||
.proto-root .jc-fcard.t-ok { --tone: var(--ok); --tone-soft: rgba(39,135,23,.14); --tone-line: #bfedb4; }
|
||||
.proto-root .jc-fcard.t-warn { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.12); --tone-line: #ffd9a8; }
|
||||
|
||||
.proto-root .jc-fcard-k { display: flex; align-items: center; gap: 6px; font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
|
||||
.proto-root .jc-fcard-k > i { flex: none; width: 19px; height: 19px; border-radius: 6px; display: inline-grid; place-items: center; background: var(--tone-soft); color: var(--tone); }
|
||||
.proto-root .jc-fcard-b { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.proto-root .jc-fcard-t { min-width: 0; flex: 1; }
|
||||
/* 一律换行,不省略号——宁可多占一行,也不丢字 */
|
||||
.proto-root .jc-fcard-t > b { display: block; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.35; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-fcard-t > span { display: block; margin-top: 2px; font-size: 10.5px; color: var(--ink-4); line-height: 1.55; overflow-wrap: anywhere; }
|
||||
/* 公司标:圆角方 + AI 渐变 + 外圈光环(方=组织) */
|
||||
.proto-root .jc-logo { flex: none; width: 38px; height: 38px; border-radius: 12px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; letter-spacing: .04em; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand), 0 0 0 3px rgba(51,112,255,.10); }
|
||||
/* 人像:圆 + 青色系(圆=人,与方形的组织标一眼分开) */
|
||||
.proto-root .jc-face { flex: none; width: 38px; height: 38px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; color: #0a7f74; background: #e4f8f5; border: 1px solid #9fe0d9; box-shadow: 0 0 0 3px rgba(10,162,148,.10); }
|
||||
/* 邮箱做成可点的胶囊,复制按钮嵌在里面——比一行裸文字有抓手 */
|
||||
/* 邮箱:软底胶囊、无描边——有描边会读成输入框,而读态这里不是可填的 */
|
||||
.proto-root .jc-mailrow { display: flex; align-items: center; gap: 4px; margin-top: 5px; min-width: 0; max-width: 100%; padding: 3px 4px 3px 9px; border: 0; border-radius: 8px; background: var(--tone-soft); transition: background var(--t-fast); }
|
||||
.proto-root .jc-mailrow:hover { background: rgba(10,162,148,.2); }
|
||||
/* 编辑态里它包着输入框,软底会变成「框中框」,去掉 */
|
||||
.proto-root .jc-report.editing .jc-mailrow { background: transparent; padding: 0; margin-top: 6px; }
|
||||
.proto-root .jc-mailrow > code { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; line-height: 1.5; font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
|
||||
.proto-root .jc-copy { flex: none; width: 22px; height: 22px; display: inline-grid; place-items: center; border: 0; border-radius: 6px; background: #fff; color: var(--ink-4); cursor: pointer; box-shadow: 0 1px 2px rgba(31,35,41,.08); transition: all var(--t-fast); }
|
||||
.proto-root .jc-copy:hover { color: #0aa294; }
|
||||
.proto-root .jc-copy.ok { color: var(--ok); background: var(--ok-soft); box-shadow: none; }
|
||||
/* 客户属性徽章 */
|
||||
.proto-root .jc-tagbig { display: inline-flex; align-items: center; gap: 6px; align-self: flex-start; padding: 7px 14px; border-radius: 999px; font-size: 13.5px; font-weight: 800; line-height: 1; color: var(--tone); background: var(--tone-soft); border: 1px solid var(--tone-line); }
|
||||
.proto-root .jc-fcard-note { font-size: 10.5px; color: var(--ink-4); line-height: 1.55; }
|
||||
.proto-root .jc-fcard-note b { color: var(--ink-2); font-weight: 700; }
|
||||
.proto-root .jc-fcard-note b.bad { color: var(--tone); font-weight: 800; }
|
||||
/* 成交迷你柱:把「台账 6 笔成交」从一句话变成看得见的节奏 */
|
||||
.proto-root .jc-spark { display: flex; align-items: flex-end; gap: 4px; height: 26px; }
|
||||
.proto-root .jc-spark > i { flex: 1 1 0; min-width: 4px; max-width: 15px; border-radius: 3px 3px 1px 1px; background: var(--tone); opacity: .3; transition: opacity var(--t-fast), height var(--t-med); }
|
||||
.proto-root .jc-spark > i:last-child { opacity: 1; }
|
||||
.proto-root .jc-spark:hover > i { opacity: .55; }
|
||||
.proto-root .jc-spark:hover > i:last-child { opacity: 1; }
|
||||
.proto-root .jc-spark.empty { align-items: stretch; }
|
||||
.proto-root .jc-spark.empty > i { height: 100%; opacity: 1; background: repeating-linear-gradient(45deg, var(--tone-soft) 0 5px, transparent 5px 10px); border: 1px dashed var(--tone-line); border-radius: 5px; }
|
||||
/* 有效 PN 计数 */
|
||||
.proto-root .jc-count { display: flex; align-items: baseline; gap: 6px; }
|
||||
.proto-root .jc-count > b { font-family: var(--mono); font-size: 32px; font-weight: 800; line-height: .95; color: var(--ink); letter-spacing: -.04em; }
|
||||
.proto-root .jc-count > span { font-size: 11px; color: var(--ink-4); }
|
||||
/* 分段刻度条:一格 = 一条 PN,缺必填的那格直接标红——比一根实心进度条信息量大 */
|
||||
.proto-root .jc-segmeter { display: flex; gap: 3px; }
|
||||
.proto-root .jc-segmeter > i { flex: 1; height: 7px; border-radius: 2px; background: var(--ok); transition: background var(--t-fast); }
|
||||
.proto-root .jc-segmeter > i.bad { background: var(--danger); }
|
||||
.proto-root .jc-lockhint { display: inline-flex; align-items: center; gap: 4px; font-size: 10px; color: var(--ink-4); }
|
||||
|
||||
/* ── 占位胶囊:必填=红虚线,选填=灰虚线;**都带文字**,不靠颜色单打 ── */
|
||||
.proto-root .jc-ph { display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700; line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed; }
|
||||
.proto-root .jc-ph.req { color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
|
||||
.proto-root .jc-ph.opt { color: var(--ink-4); background: var(--soft); border-color: var(--line); }
|
||||
|
||||
/* ── 分段控件(新客户 / 老客户)── */
|
||||
.proto-root .jc-seg { display: inline-flex; padding: 2px; gap: 2px; border: 1px solid var(--cardline); border-radius: 9px; background: var(--soft); align-self: flex-start; }
|
||||
.proto-root .jc-seg button { border: 0; border-radius: 7px; padding: 6px 13px; font: inherit; font-size: 11.5px; font-weight: 700; color: var(--ink-3); background: transparent; cursor: pointer; transition: all var(--t-fast); }
|
||||
.proto-root .jc-seg button:hover:not(.on) { color: var(--brand); }
|
||||
.proto-root .jc-seg button.on { background: #fff; color: var(--ink); box-shadow: 0 1px 3px rgba(31,35,41,.12); }
|
||||
|
||||
/* ── 表:读态与编辑态共用骨架 ─────────────────────────────────────── */
|
||||
.proto-root .jc-tbl-wrap { overflow-x: auto; border: 1px solid var(--line-soft); border-radius: 10px; }
|
||||
.proto-root .jc-tbl-in { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; }
|
||||
.proto-root .jc-tbl-in th { background: var(--soft); text-align: left; padding: 8px 9px; font-size: 10.5px; font-weight: 800; letter-spacing: .03em; color: var(--ink-3); white-space: nowrap; border-bottom: 1px solid var(--line-soft); }
|
||||
.proto-root .jc-tbl-in th.req::after { content: "*"; margin-left: 1px; color: var(--danger); font-size: 10px; }
|
||||
.proto-root .jc-tbl-in td { padding: 4px 5px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
|
||||
.proto-root .jc-tbl-in tr:last-child td { border-bottom: 0; }
|
||||
.proto-root .jc-tbl-in tbody tr:hover td { background: #f7f9fd; }
|
||||
/* 缺必填的整行浅红打底 + 行首红标——不用找红胶囊,一眼扫到是哪几行 */
|
||||
.proto-root .jc-tbl-in tr.miss td { background: #fffbfb; }
|
||||
.proto-root .jc-tbl-in tbody tr.miss:hover td { background: #fff5f5; }
|
||||
.proto-root .jc-tbl-in td.jc-rn { position: relative; width: 38px; padding: 4px 6px 4px 13px; white-space: nowrap; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
|
||||
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); font-weight: 800; }
|
||||
/* 行首红标只在表态用 ::after —— 卡片态的 ::after 被「第 N 条」占着,不能抢 */
|
||||
@media (min-width: 961px) {
|
||||
.proto-root .jc-tbl-in tr.miss td.jc-rn::after { content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger); }
|
||||
}
|
||||
.proto-root .jc-rndot { display: inline-block; width: 5px; height: 5px; margin-left: 4px; border-radius: 999px; background: var(--danger); vertical-align: 2px; }
|
||||
/* 读态单元格 */
|
||||
.proto-root .jc-vcell { display: block; padding: 6px 6px; font-size: 12px; color: var(--ink); line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-vcell.mono { font-family: var(--mono); font-size: 11px; color: var(--ink-2); }
|
||||
.proto-root .jc-vcell.num { font-family: var(--mono); font-size: 11.5px; text-align: right; }
|
||||
/* 编辑态单元格:安静的电子表格 */
|
||||
.proto-root .jc-cell { width: 100%; min-width: 64px; border: 1px solid var(--line); border-radius: 6px; padding: 6px 7px; font: inherit; font-size: 12px; color: var(--ink); background: #fff; transition: border-color var(--t-fast), box-shadow var(--t-fast); }
|
||||
.proto-root .jc-cell.mono { font-family: var(--mono); font-size: 11px; }
|
||||
.proto-root .jc-cell.num { text-align: right; font-family: var(--mono); font-size: 11.5px; }
|
||||
.proto-root .jc-cell::placeholder { color: var(--ink-4); }
|
||||
.proto-root .jc-cell:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-wash); outline: none; }
|
||||
.proto-root .jc-cell.req-empty { border-color: #f3b6b3; background: #fffafa; }
|
||||
.proto-root .jc-cell.req-empty::placeholder { color: var(--danger); }
|
||||
/* 备注这类长文本用文本域:能换行、随内容长高,不裁字 */
|
||||
.proto-root textarea.jc-cell { display: block; resize: vertical; overflow: hidden; min-height: 31px; line-height: 1.55; }
|
||||
|
||||
/* 编辑态的整表提示带 */
|
||||
.proto-root .jc-editbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); border: 1px solid var(--brand-soft); font-size: 11.5px; color: var(--brand-deep); }
|
||||
.proto-root .jc-editbar svg { flex: none; }
|
||||
.proto-root .jc-editbar .sp { flex: 1 1 auto; }
|
||||
|
||||
/* 剔除行说明 */
|
||||
.proto-root .jc-drop { display: flex; align-items: flex-start; gap: 8px; margin-top: 10px; padding: 9px 12px; border-radius: 9px; border: 1px dashed var(--line); background: var(--soft); font-size: 11.5px; color: var(--ink-3); line-height: 1.6; }
|
||||
.proto-root .jc-drop svg { flex: none; margin-top: 2px; color: var(--ink-4); }
|
||||
.proto-root .jc-drop code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
|
||||
|
||||
.proto-root .jc-callout { display: flex; gap: 8px; align-items: flex-start; border-radius: 9px; padding: 11px 13px; font-size: 12.5px; line-height: 1.75; }
|
||||
.proto-root .jc-callout p { margin: 0; }
|
||||
.proto-root .jc-callout svg { flex: none; margin-top: 2px; }
|
||||
.proto-root .jc-callout.t-mute { background: var(--soft); color: var(--ink-2) }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.proto-root .jc-pulse-dot { animation: none; }
|
||||
.proto-root .jc-report, .proto-root .jc-rp-stamp { animation: none; }
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════
|
||||
响应式:宁可另起一行,也不损失完整度
|
||||
≥961px 六列全展开的表(列 min-width 之和 878px,容器有余量,绝不裁字、绝不横滚)
|
||||
≤960px 表 → 卡片:一条 PN 一张卡、一行一字段(标签 / 值),无横滚、无省略
|
||||
≤560px 字段行再拆成上下两行(标签在上、值在下)
|
||||
══════════════════════════════════════════════════════════════════════════ */
|
||||
@media (max-width: 1040px) { .proto-root .jc-four { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 560px) { .proto-root .jc-four { grid-template-columns: 1fr; } }
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.proto-root .jc-tbl-wrap { overflow: visible; border: 0; border-radius: 0; background: transparent; }
|
||||
.proto-root .jc-tbl-in,
|
||||
.proto-root .jc-tbl-in tbody,
|
||||
.proto-root .jc-tbl-in tr,
|
||||
.proto-root .jc-tbl-in td { display: block; width: 100%; }
|
||||
.proto-root .jc-tbl-in thead { display: none; }
|
||||
|
||||
/* 一条 PN = 一张卡 */
|
||||
.proto-root .jc-tbl-in tr { margin-bottom: 10px; padding: 4px 13px 9px; border: 1px solid var(--line-soft); border-radius: 11px; background: #fff; box-shadow: var(--card-shadow); }
|
||||
.proto-root .jc-tbl-in tr:last-child { margin-bottom: 0; }
|
||||
.proto-root .jc-tbl-in tbody tr:hover td { background: transparent; }
|
||||
.proto-root .jc-tbl-in tr.miss { border-color: #f3b6b3; background: #fffbfb; }
|
||||
|
||||
/* 卡头:第 N 条 */
|
||||
.proto-root .jc-tbl-in td.jc-rn { padding: 8px 0 8px; margin-bottom: 4px; border-bottom: 1px solid var(--line-soft); font-family: inherit; font-size: 12px; font-weight: 800; color: var(--ink-2); }
|
||||
.proto-root .jc-tbl-in td.jc-rn::before { content: "第 "; }
|
||||
.proto-root .jc-tbl-in td.jc-rn::after { content: " 条"; }
|
||||
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); }
|
||||
|
||||
/* 字段行:左标签 + 右值,值超长自动折行 */
|
||||
.proto-root .jc-tbl-in td:not(.jc-rn) { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 10px; align-items: center; padding: 6px 0; border-bottom: 1px dashed var(--line-soft); }
|
||||
.proto-root .jc-tbl-in tr td:last-child { border-bottom: 0; padding-bottom: 2px; }
|
||||
.proto-root .jc-tbl-in td:not(.jc-rn)::before {
|
||||
content: attr(data-h); font-size: 10.5px; font-weight: 700; letter-spacing: .02em; color: var(--ink-4); line-height: 1.5;
|
||||
}
|
||||
.proto-root .jc-tbl-in td[data-req="1"]:not(.jc-rn)::before { content: attr(data-h) " *"; }
|
||||
.proto-root .jc-tbl-in .jc-vcell { padding: 0; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-tbl-in .jc-vcell.num { text-align: left; }
|
||||
.proto-root .jc-tbl-in .jc-cell.num { text-align: left; }
|
||||
.proto-root .jc-tbl-in .jc-cell { min-width: 0; }
|
||||
}
|
||||
|
||||
/* 窄屏:落款三件各占一行,动作按钮撑满一行 */
|
||||
@media (max-width: 560px) {
|
||||
.proto-root .jc-rp-acts { margin-left: 0; width: 100%; flex-wrap: wrap; }
|
||||
.proto-root .jc-rp-acts .jc-btn { flex: 1 1 auto; justify-content: center; }
|
||||
.proto-root .jc-hintline { flex: 1 1 100%; }
|
||||
.proto-root .jc-tbl-in td:not(.jc-rn) { grid-template-columns: 1fr; gap: 3px; align-items: start; }
|
||||
}
|
||||
|
||||
|
||||
/* ══ 以下取自 A1-询价受理记录.html(归档件独有的件)══ */
|
||||
/* ── 料号分类:分布条 + 图例 ───────────────────────────────────────── */
|
||||
.proto-root .jc-dist { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 13px 15px; border: 1px solid var(--line-soft); border-radius: 12px; background: linear-gradient(180deg, #fbfcfe, #fff); }
|
||||
.proto-root .jc-distbar { flex: 1 1 320px; min-width: 260px; display: flex; gap: 3px; }
|
||||
.proto-root .jc-distbar > i { height: 30px; border-radius: 4px; display: grid; place-items: center; font-size: 10.5px; font-weight: 800; color: #fff; font-family: var(--mono); }
|
||||
.proto-root .jc-distbar > i.a { background: var(--ok); }
|
||||
.proto-root .jc-distbar > i.b { background: var(--brand); }
|
||||
.proto-root .jc-distbar > i.c { background: var(--warn-accent); }
|
||||
.proto-root .jc-distlg { display: flex; flex-wrap: wrap; gap: 8px 18px; }
|
||||
.proto-root .jc-distlg > span { display: inline-flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--ink-3); }
|
||||
.proto-root .jc-distlg em { width: 10px; height: 10px; border-radius: 3px; }
|
||||
.proto-root .jc-distlg em.a { background: var(--ok); } .proto-root .jc-distlg em.b { background: var(--brand); } .proto-root .jc-distlg em.c { background: var(--warn-accent); }
|
||||
.proto-root .jc-distlg b { font-family: var(--mono); font-weight: 800; color: var(--ink); }
|
||||
|
||||
/* 类别徽 A / B / C */
|
||||
.proto-root .jc-tbadge { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 6px; font-family: var(--mono); font-size: 11px; font-weight: 800; line-height: 1; color: #fff; }
|
||||
.proto-root .jc-tbadge.a { background: var(--ok); } .proto-root .jc-tbadge.b { background: var(--brand); } .proto-root .jc-tbadge.c { background: var(--warn-accent); }
|
||||
|
||||
/* ── 结论与去向 ─────────────────────────────────────────────────────────
|
||||
G2 是**分支网关**:三条互斥条件按序求值,只命中一条、只落一个活动。
|
||||
所以这里不是三张并列的去向卡,而是一张**判定表 + 一条结论**。 */
|
||||
/* 判定表:一行一条规则,命中行上色留痕(DMN 口径) */
|
||||
.proto-root .jc-dmn { border: 1px solid var(--line-soft); border-radius: 12px; overflow: hidden; }
|
||||
.proto-root .jc-dmn-h, .proto-root .jc-dmn-r { display: grid; grid-template-columns: 40px minmax(0, 1fr) 170px 200px; align-items: center; gap: 12px; padding: 9px 14px; }
|
||||
.proto-root .jc-dmn-h { background: var(--soft); border-bottom: 1px solid var(--line-soft); font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); }
|
||||
.proto-root .jc-dmn-r { font-size: 12px; color: var(--ink-4); background: #fff; }
|
||||
.proto-root .jc-dmn-r + .jc-dmn-r { border-top: 1px solid var(--line-soft); }
|
||||
.proto-root .jc-dmn-i { font-family: var(--mono); font-size: 11px; font-weight: 800; }
|
||||
.proto-root .jc-dmn-c { line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-dmn-t { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; }
|
||||
.proto-root .jc-dmn-t code { font-family: var(--mono); font-size: 10.5px; font-weight: 800; color: var(--ink-4); background: var(--soft); border: 1px solid var(--line); border-radius: 5px; padding: 2px 6px; }
|
||||
.proto-root .jc-dmn-v { font-size: 11px; line-height: 1.55; }
|
||||
/* 命中行:上色 + 左缘棱 + 加粗 */
|
||||
.proto-root .jc-dmn-r.hit { position: relative; background: var(--tone-soft); color: var(--ink-2); }
|
||||
.proto-root .jc-dmn-r.hit::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--tone); }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-i { color: var(--tone); }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-c { color: var(--ink); }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-c b { color: var(--tone); font-weight: 800; }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-t { color: var(--ink); }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-t code { color: #fff; background: var(--tone); border-color: var(--tone); }
|
||||
.proto-root .jc-dmn-v.y { display: inline-flex; align-items: center; gap: 5px; font-weight: 800; color: var(--tone); }
|
||||
.proto-root .jc-dmn-v.n { color: var(--ink-4); }
|
||||
/* 色调随命中类别 */
|
||||
.proto-root .jc-dmn.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.10); }
|
||||
.proto-root .jc-dmn.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.09); }
|
||||
.proto-root .jc-dmn.k-c { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.11); }
|
||||
|
||||
/* 结论横幅:命中条件 → 唯一去向 */
|
||||
.proto-root .jc-verdict {
|
||||
position: relative; overflow: hidden; display: flex; align-items: stretch; flex-wrap: wrap; gap: 16px;
|
||||
margin-top: 11px; padding: 16px 18px; border: 1px solid var(--tone-line); border-radius: 13px;
|
||||
background: linear-gradient(105deg, var(--tone-soft), #fff 62%);
|
||||
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 10px 24px -16px rgba(31,35,41,.4);
|
||||
--tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.14); --tone-line: #ffd9a8;
|
||||
}
|
||||
.proto-root .jc-verdict.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.13); --tone-line: #bfedb4; }
|
||||
.proto-root .jc-verdict.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft); }
|
||||
.proto-root .jc-verdict::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 5px; background: var(--tone); }
|
||||
.proto-root .jc-verdict > * { position: relative; z-index: 1; }
|
||||
.proto-root .jc-vd-l { flex: 1 1 300px; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
|
||||
.proto-root .jc-vd-k { font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
|
||||
.proto-root .jc-vd-cond { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 15px; font-weight: 800; color: var(--ink); }
|
||||
.proto-root .jc-vd-why { margin: 0; font-size: 11.5px; line-height: 1.7; color: var(--ink-3); overflow-wrap: anywhere; }
|
||||
.proto-root .jc-vd-why code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
|
||||
.proto-root .jc-vd-arrow { flex: none; align-self: center; color: var(--tone); }
|
||||
.proto-root .jc-vd-r { flex: 1 1 260px; min-width: 0; display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-radius: 11px; background: #fff; border: 1px solid var(--tone-line); }
|
||||
.proto-root .jc-vd-node { display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 800; color: var(--ink); }
|
||||
.proto-root .jc-vd-node code { font-family: var(--mono); font-size: 12px; font-weight: 800; color: #fff; background: var(--tone); border-radius: 6px; padding: 3px 8px; }
|
||||
.proto-root .jc-vd-who { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--ink-2); }
|
||||
.proto-root .jc-vd-who > i { width: 20px; height: 20px; border-radius: 999px; display: inline-grid; place-items: center; font-style: normal; font-size: 10px; font-weight: 700; color: #fff; background: var(--tone); }
|
||||
.proto-root .jc-vd-next { margin: 0; padding-top: 8px; border-top: 1px dashed var(--line-soft); font-size: 10.5px; line-height: 1.65; color: var(--ink-4); }
|
||||
.proto-root .jc-vd-next b { color: var(--ink-2); font-weight: 700; }
|
||||
/* 命中分支携带的条目 */
|
||||
.proto-root .jc-pnlist { display: flex; flex-wrap: wrap; gap: 5px; margin: 0; padding: 0; list-style: none; }
|
||||
.proto-root .jc-pnlist > li { display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 999px; background: #fff; border: 1px solid var(--tone-line); font-size: 11px; color: var(--ink-2); overflow-wrap: anywhere; }
|
||||
.proto-root .jc-pnlist > li > i { flex: none; width: 5px; height: 5px; border-radius: 999px; background: var(--tone); }
|
||||
.proto-root .jc-pnlist code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
|
||||
|
||||
/* ── 落地动作 ───────────────────────────────────────────────────────── */
|
||||
.proto-root .jc-acts { display: flex; flex-direction: column; gap: 0; border: 1px solid var(--line-soft); border-radius: 11px; overflow: hidden; }
|
||||
.proto-root .jc-act { display: flex; align-items: flex-start; gap: 9px; padding: 10px 13px; font-size: 12px; color: var(--ink-2); line-height: 1.6; background: #fff; }
|
||||
.proto-root .jc-act + .jc-act { border-top: 1px solid var(--line-soft); }
|
||||
.proto-root .jc-act > i { flex: none; width: 18px; height: 18px; margin-top: 1px; border-radius: 999px; display: inline-grid; place-items: center; color: #fff; background: var(--ok); }
|
||||
.proto-root .jc-act b { font-weight: 700; color: var(--ink); }
|
||||
.proto-root .jc-act span { color: var(--ink-4); }
|
||||
.proto-root .jc-act code { font-family: var(--mono); font-size: 10.5px; color: var(--brand-deep); background: var(--brand-wash); border-radius: 4px; padding: 1px 5px; }
|
||||
|
||||
|
||||
.proto-root .jc-done-tag { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; flex: none; padding: 6px 13px; border-radius: 999px; font-size: 11.5px; font-weight: 800; color: var(--ok); background: #fff; border: 1px solid #bfedb4; }
|
||||
.proto-root .jc-vcell.dim { color: var(--ink-4); }
|
||||
.proto-root .jc-tbl-in td.jc-ty { width: 44px; text-align: center; }
|
||||
.proto-root .jc-tbl-in td.jc-ty { width: auto; text-align: left; }
|
||||
`;
|
||||
|
||||
/**
|
||||
* 图标精灵 —— 与样式表同一出处,两份目标件的 `<symbol>` 取并集。
|
||||
* 沙箱里取不到图标字体,所以图标一律内联;`<use href="#i-xxx">` 引的就是这一坨。
|
||||
*/
|
||||
export const PRD_SPRITE = `<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs><symbol id="i-alert" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></symbol><symbol id="i-arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol><symbol id="i-boxes" viewBox="0 0 24 24"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></symbol><symbol id="i-building" viewBox="0 0 24 24"><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/><path d="M10 18h4"/></symbol><symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol><symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol><symbol id="i-file-check" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="m9 15 2 2 4-4"/></symbol><symbol id="i-file-text" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></symbol><symbol id="i-git-branch" viewBox="0 0 24 24"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></symbol><symbol id="i-history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></symbol><symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></symbol><symbol id="i-lock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol><symbol id="i-mail-open" viewBox="0 0 24 24"><path d="M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0z"/><path d="m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10"/></symbol><symbol id="i-pen" viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z"/></symbol><symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol><symbol id="i-sheet" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M8 13h2"/><path d="M14 13h2"/><path d="M8 17h2"/><path d="M14 17h2"/></symbol><symbol id="i-shield-check" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></symbol><symbol id="i-user" viewBox="0 0 24 24"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/></symbol><symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></symbol></defs></svg>`;
|
||||
@@ -1,189 +1,720 @@
|
||||
/**
|
||||
* A6 报价议价的 HTML 报告模版 —— **活动自己持有的资产**(§10.3),随包版本冻结。
|
||||
* A6 报价议价的 HTML 报告模版 —— **活动自己持有的资产**,随包版本冻结。
|
||||
*
|
||||
* 议价是**多轮往来**:成单 → 人工按下发出 → D+3 催 → D+7 催 → 客户还价 → 改一版再发。
|
||||
* 所以这两屏都要让人看得出**先后**:这一版是第几轮、上一轮报的是多少、下一记提醒在哪天。
|
||||
* 只摆一个「当前单价」的屏,人签字时没有参照系,也就无从判断这一让让得值不值 ——
|
||||
* 于是「历次报价」那一节摆的是共享域整表(含既往单),它不是本单成果,是本单的尺子。
|
||||
* 两个局面各一张(与 A2/A3/A4 同款口径):
|
||||
* · `negotiate-worksheet` **议价工作单** —— 排版、发出与落客户回音期间摆它:
|
||||
* 逐颗料一张报价档案卡(条款在左、议价在右)、议价结果 N-01 与让步梯度 N-02 两把尺、
|
||||
* 填回工作表就长在同一屏;
|
||||
* · `negotiate-roster` **成交定案清单** —— 异议拍板、放行与归档摆它:整份报价函,
|
||||
* 定案分布、去向落边图与落地动作在上面。
|
||||
*
|
||||
* 两个局面:
|
||||
* ① 正式报价单(草稿)—— 六样条款齐不齐;这一步还没碰外界,一封信都没发;
|
||||
* ② 放行即发出 —— 按下的那一刻信就到客户手上,签字前是最后一次能改的机会。
|
||||
* 「1:1」的落点是**每份件与它的办结快照**(在办可编辑 ↔ 回执收下那一刻的 finalHtml),
|
||||
* 不是全程只许一张:作业的件与收口的件是两件工作两份件(规范姊妹篇 §1)。
|
||||
*
|
||||
* 容器是 `sandbox=""` iframe:无脚本、无外链、样式自带;数据由基座推送时填
|
||||
* (`data-slot` 取值、`data-rows` 逐行展开)。
|
||||
* 版面语言沿用 A1 从 PRD 目标件机械抽取的那套(`prd-style.ts` 原样拷入)——
|
||||
* A6 没有自己的 PRD 目标件,与 A2/A3/A4 同一处置:沿用家族版面自造。
|
||||
*
|
||||
* 两条贯穿全包的呈现纪律:
|
||||
* · **状态章一律用「在办」灯,不用「已办」印** —— 模版是随包冻结的静态字符串,
|
||||
* 它盖不了一个还没发生的章;这两张都是放行前的局面;
|
||||
* · **`verdict` 的橙档专指「还没发生,等你按」** —— 发出的后果是预告不是既成事实。
|
||||
* 整屏的结构色一律品牌蓝/案卷紫,红橙绿只落在「达标没达标」上,不拿来渲染气氛。
|
||||
* 取值三纪律:顶层只取汇总列与单值列;逐条走 `mine:quote_negotiate.line`;
|
||||
* 列名逐字照 `declarations.ledger` 抄。**模版不算不判** —— 缺表态红带、定案徽、分布条、
|
||||
* 落边图全部由基座算好的数经 `data-class-of` 投影,判断留在样式表。
|
||||
*/
|
||||
import { noteOnly, section, stagesOnly, statsBand, tableOnly, verdict, viewShell } from '@sdk';
|
||||
import { PRD_CSS, PRD_SPRITE } from './prd-style.ts';
|
||||
|
||||
/**
|
||||
* 静态阶梯 —— **规则与节拍不是数据**:六样条款的要求、D+3/D+7 的节拍、三个出口的去向
|
||||
* 都随包冻结,不该走 `data-rows` 等基座来填(那会让「规矩」看起来像「这一单的结果」)。
|
||||
* 沿用外壳已有的 `.steps` 版式,与引擎推的阶段列表同一副面孔。
|
||||
*/
|
||||
function ladder(rows: ReadonlyArray<readonly [string, string, string]>): string {
|
||||
return `<ul class="steps">${rows
|
||||
.map(
|
||||
([no, term, gloss]) =>
|
||||
`<li><span class="n">${no}</span><span><span class="t">${term}</span> <span class="d">${gloss}</span></span></li>`,
|
||||
)
|
||||
.join('')}</ul>`;
|
||||
/* ==================== 绑定期补的样式(照 A1/A3/A4 的 BIND_CSS,地址换成本活动)==================== */
|
||||
|
||||
const BIND_CSS = `
|
||||
/* 空占位三级(2026-08-02 全局口径):必填红 / 建议填橘 / 选填灰 —— 值真的空才轮到 */
|
||||
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after,
|
||||
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after,
|
||||
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after {
|
||||
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
|
||||
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
|
||||
}
|
||||
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after { content: "必填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
|
||||
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after { content: "建议填"; color: var(--warn-accent); background: var(--warn-soft); border-color: #f0cf9a; }
|
||||
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after { content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line); }
|
||||
/* 归档件里空格子就是「没有」,不再摆「你还能填」的胶囊 */
|
||||
.proto-root .jc-tbl-in td.dimempty > .jc-vcell:empty::after { content: "—"; color: var(--ink-4); border: 0; background: none; padding: 0; }
|
||||
|
||||
/* 缺客户表态的整行浅红 + 行首红条 —— 原型的 tr.miss,这里由 :has() 认 */
|
||||
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td { background: #fffbfb; }
|
||||
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty):hover td { background: #fff5f5; }
|
||||
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn { color: var(--danger); font-weight: 800; position: relative; }
|
||||
@media (min-width: 1001px) {
|
||||
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn::after {
|
||||
content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 键值格 —— 报价单的条款是**一份单据的正面**,六样并排比一张六列横向滚动的表好读。
|
||||
* `@sdk` 的 `kvCard` 自带一个「·」号章节,与本页「一/二/三」的编号排不到一起,
|
||||
* 所以这里只借外壳已有的 `.kv` 版式,章节仍交给 `section` 统一编号。
|
||||
*/
|
||||
function kvGrid(items: ReadonlyArray<readonly [string, string, ('mono' | 'tag')?]>): string {
|
||||
return `<div class="kv">${items
|
||||
.map(([label, path, style]) => {
|
||||
const cls = style === undefined ? '' : ` class="${style === 'mono' ? 'mono' : 'tag'}"`;
|
||||
return `<div><em>${label}</em><b${cls} data-slot="${path}"></b></div>`;
|
||||
})
|
||||
.join('')}</div>`;
|
||||
/* 序号由 CSS 计数器排 */
|
||||
.proto-root .jc-tbl-in tbody { counter-reset: rn; }
|
||||
.proto-root .jc-tbl-in td.jc-rn::before { counter-increment: rn; content: counter(rn); }
|
||||
@media (max-width: 1000px) {
|
||||
.proto-root .jc-tbl-in td.jc-rn::before { content: "第 " counter(rn) " 条"; }
|
||||
.proto-root .jc-tbl-in td.jc-rn::after { content: none; }
|
||||
}
|
||||
|
||||
/** 局面一:S1 成单 —— 人此刻要核的是「六样齐没齐、这一版能不能发」 */
|
||||
export const QUOTE_COMPOSED = viewShell({
|
||||
title: '正式报价单(草稿)',
|
||||
kicker: 'A6 · 报价议价 · S1 成单',
|
||||
tone: 'brand',
|
||||
icon: 'handshake',
|
||||
stamp: { kind: 'live', text: '成单中' },
|
||||
sign: 'A6 报价议价 · 正式报价单(本单草稿)',
|
||||
/* 就地改:可改格子渲了只读与输入两份,默认锁着,表上加 on 才换孪生件 */
|
||||
.proto-root .jc-cell { display: none; }
|
||||
.proto-root .jc-tbl-in.on td > .jc-vcell,
|
||||
.proto-root .jc-tbl-in.on td > .jc-tbadge { display: none; }
|
||||
.proto-root .jc-tbl-in.on td > .jc-cell { display: block; }
|
||||
/* 类徽 / 表态徽 / 定案徽不进编辑态(承继与引擎产物),on 时也照旧摆着 */
|
||||
.proto-root .jc-tbl-in.on td > .jc-obadge,
|
||||
.proto-root .jc-tbl-in.on td > .jc-dbadge { display: inline-flex; }
|
||||
/* 数值列与表头同侧左对齐(一行一个料,彼此不相加) */
|
||||
.proto-root .jc-vcell.num, .proto-root .jc-cell.num { text-align: left; }
|
||||
/* 编辑态下必填空格子:红框 + 整行浅红(由 :placeholder-shown 认) */
|
||||
.proto-root .jc-tbl-in.on td.req > .jc-cell:placeholder-shown { border-color: #f3b6b3; background: #fffafa; }
|
||||
.proto-root .jc-tbl-in.on tbody tr:has(td.req > .jc-cell:placeholder-shown) td { background: #fffbfb; }
|
||||
|
||||
/* 铺满工作区 */
|
||||
.jc-stage { max-width: none; padding: 0 0 4px; }
|
||||
.proto-root .jc-report { border: 0; border-radius: 0; box-shadow: none; }
|
||||
|
||||
/* 那道闸 —— 默认「已锁定 + 编辑」,开了变成「取消 / 保存」;只在可改屏现身 */
|
||||
.proto-root .jc-editgate { display: none; }
|
||||
html.editable .proto-root .jc-editgate {
|
||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
margin-bottom: 10px; padding: 8px 12px; border-radius: 9px;
|
||||
background: var(--soft); border: 1px solid var(--line-soft);
|
||||
font-size: 11.5px; color: var(--ink-3);
|
||||
}
|
||||
.proto-root .jc-editgate-t { display: inline-flex; align-items: center; gap: 6px; flex: 1 1 auto; min-width: 0; }
|
||||
.proto-root .jc-editgate-t b { color: var(--ink-2); font-weight: 700; }
|
||||
.proto-root .jc-egbtn {
|
||||
flex: none; display: inline-flex; align-items: center; gap: 4px; cursor: pointer;
|
||||
border: 1px solid var(--line); border-radius: 7px; padding: 5px 11px;
|
||||
font: inherit; font-size: 11.5px; font-weight: 700; color: var(--ink-2); background: #fff;
|
||||
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
|
||||
}
|
||||
.proto-root .jc-egbtn:hover { border-color: var(--brand); color: var(--brand); }
|
||||
.proto-root .jc-egbtn.save { color: #fff; background: var(--brand); border-color: var(--brand); }
|
||||
.proto-root .jc-egbtn.save:hover { background: var(--brand-deep); border-color: var(--brand-deep); }
|
||||
.proto-root .jc-editgate .jc-egbtn.ghost, .proto-root .jc-editgate .jc-egbtn.save { display: none; }
|
||||
.proto-root .jc-editgate.on { background: var(--brand-wash); border-color: var(--brand-soft); color: var(--brand-deep); }
|
||||
.proto-root .jc-editgate.on .jc-egbtn.open { display: none; }
|
||||
.proto-root .jc-editgate.on .jc-egbtn.ghost, .proto-root .jc-editgate.on .jc-egbtn.save { display: inline-flex; }
|
||||
.proto-root .jc-editgate.on .jc-editgate-t { visibility: hidden; }
|
||||
|
||||
/* 缺表态红带 —— 只在可改屏出,一格不缺自动收(v-0 那一档 display:none) */
|
||||
.proto-root .jc-gapbar { display: none; }
|
||||
html.editable .proto-root .jc-gapbar {
|
||||
display: flex; align-items: flex-start; gap: 8px;
|
||||
margin: 0 0 10px; padding: 9px 12px; border-radius: 9px;
|
||||
background: #fff5f5; border: 1px solid #f3b6b3; color: var(--danger);
|
||||
font-size: 12px; line-height: 1.65;
|
||||
}
|
||||
.proto-root .jc-gapbar svg { flex: 0 0 auto; margin-top: 2px; }
|
||||
.proto-root .jc-gapbar b { font-weight: 800; }
|
||||
.proto-root .jc-gapbar span { color: #9a3b36; }
|
||||
html.editable .proto-root .jc-gapbar.v-0 { display: none; }
|
||||
|
||||
/* 那条「怎么改」的提示只在开了闸之后说 */
|
||||
.proto-root .jc-editbar { display: none; }
|
||||
.proto-root .jc-editgate.on + .jc-editbar { display: flex; }
|
||||
|
||||
/* 增删行 */
|
||||
.proto-root .jc-rowdel { display: none; }
|
||||
.proto-root .jc-tbl-in.on td.jc-rn::before { content: none; }
|
||||
.proto-root .jc-tbl-in.on .jc-rowdel {
|
||||
display: block; width: 20px; height: 20px; margin: 0 auto; cursor: pointer;
|
||||
border: 1px solid var(--line); border-radius: 6px; padding: 0;
|
||||
font: inherit; font-size: 13px; line-height: 1; color: var(--ink-3); background: #fff;
|
||||
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
|
||||
}
|
||||
.proto-root .jc-tbl-in.on .jc-rowdel:hover { border-color: var(--danger); color: var(--danger); background: #fff6f6; }
|
||||
.proto-root .jc-tbl-in.on tr.jc-row-gone td { background: #fff6f6; opacity: .55; }
|
||||
.proto-root .jc-tbl-in.on tr.jc-row-gone .jc-cell { text-decoration: line-through; color: var(--ink-3); }
|
||||
.proto-root .jc-tbl-in.on tr.jc-row-gone .jc-rowdel { border-color: var(--danger); color: var(--danger); background: #fff; }
|
||||
.proto-root .jc-tbl-in.on tr.jc-row-new td { background: #f2fbf7; }
|
||||
.proto-root .jc-addrow { display: none; }
|
||||
html.editable .proto-root .jc-tbl-wrap:has(table.on) + .jc-addrow {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
margin: -2px 0 12px; padding: 7px 12px; border-radius: 9px;
|
||||
background: var(--soft); border: 1px dashed var(--line);
|
||||
}
|
||||
.proto-root .jc-addrow em { font-style: normal; font-size: 11px; color: var(--ink-3); }
|
||||
|
||||
/* 类徽按值上色(承继上游的三分类) */
|
||||
.proto-root .jc-tbadge { background: var(--ink-4); }
|
||||
.proto-root .jc-tbadge.v-a { background: var(--ok); }
|
||||
.proto-root .jc-tbadge.v-b { background: var(--brand); }
|
||||
.proto-root .jc-tbadge.v-c { background: var(--warn-accent); }
|
||||
.proto-root .jc-segmeter > i.v-won { background: var(--ok); }
|
||||
.proto-root .jc-segmeter > i.v-haggle { background: var(--warn-accent); }
|
||||
.proto-root .jc-segmeter > i.v-revise { background: var(--brand); }
|
||||
.proto-root .jc-segmeter > i.v-lost { background: var(--ink-4); }
|
||||
|
||||
/* 客户表态徽:接受绿 / 还价橙 / 拒绝红 / 无回复灰 —— 值是机器码,人读名交给样式表 */
|
||||
.proto-root .jc-obadge {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 52px; height: 20px; padding: 0 7px; border-radius: 6px;
|
||||
font-size: 10px; font-weight: 800; letter-spacing: .02em;
|
||||
color: var(--ink-4); border: 1.5px dashed var(--ink-4); background: #fff;
|
||||
}
|
||||
.proto-root .jc-obadge::after { content: "待回音"; }
|
||||
.proto-root .jc-obadge.v-accept { color: var(--ok); border-color: var(--ok); border-style: solid; }
|
||||
.proto-root .jc-obadge.v-accept::after { content: "接受"; }
|
||||
.proto-root .jc-obadge.v-counter { color: var(--warn-accent); border-color: var(--warn-accent); border-style: solid; }
|
||||
.proto-root .jc-obadge.v-counter::after { content: "还价"; }
|
||||
.proto-root .jc-obadge.v-reject { color: var(--danger); border-color: var(--danger); border-style: solid; }
|
||||
.proto-root .jc-obadge.v-reject::after { content: "拒绝"; }
|
||||
.proto-root .jc-obadge.v-noreply { color: var(--ink-3); border-color: var(--ink-4); border-style: solid; }
|
||||
.proto-root .jc-obadge.v-noreply::after { content: "无回复"; }
|
||||
|
||||
/* 定案徽:成交绿 / 还在还价橙 / 不成交灰 */
|
||||
.proto-root .jc-dbadge {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 62px; height: 20px; padding: 0 7px; border-radius: 6px;
|
||||
font-size: 10px; font-weight: 800; letter-spacing: .02em;
|
||||
color: var(--ink-4); border: 1.5px solid var(--ink-4); background: #fff;
|
||||
}
|
||||
.proto-root .jc-dbadge::after { content: "待定案"; }
|
||||
.proto-root .jc-dbadge.v-won { color: var(--ok); border-color: var(--ok); }
|
||||
.proto-root .jc-dbadge.v-won::after { content: "成交"; }
|
||||
.proto-root .jc-dbadge.v-haggle { color: var(--warn-accent); border-color: var(--warn-accent); border-style: dashed; }
|
||||
.proto-root .jc-dbadge.v-haggle::after { content: "待你拍板"; }
|
||||
.proto-root .jc-dbadge.v-revise { color: var(--brand); border-color: var(--brand); }
|
||||
.proto-root .jc-dbadge.v-revise::after { content: "退回重批"; }
|
||||
.proto-root .jc-dbadge.v-lost { color: var(--ink-3); border-color: var(--ink-4); }
|
||||
.proto-root .jc-dbadge.v-lost::after { content: "不成交"; }
|
||||
|
||||
/* 让步处置(N-02)—— 只在还价的行上有值 */
|
||||
.proto-root .jc-cbadge { font-size: 10.5px; font-weight: 700; color: var(--ink-4); }
|
||||
.proto-root .jc-cbadge.v-accept_counter { color: var(--ok); }
|
||||
.proto-root .jc-cbadge.v-accept_counter::after { content: "接了拿单"; }
|
||||
.proto-root .jc-cbadge.v-negotiate { color: var(--brand); }
|
||||
.proto-root .jc-cbadge.v-negotiate::after { content: "授权内回旋"; }
|
||||
.proto-root .jc-cbadge.v-escalate { color: var(--danger); }
|
||||
.proto-root .jc-cbadge.v-escalate::after { content: "超授权 · 退回重批"; }
|
||||
|
||||
/* 分布条:三段按条数分宽 —— 条数投影成 v-<N>,梯子取宽;零条那段自己消失 */
|
||||
.proto-root .jc-distbar > i { flex: 24; min-width: 34px; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.proto-root .jc-distbar > i.v-0, .proto-root .jc-distbar > i:not([class*=" v-"]) { display: none; }
|
||||
.proto-root .jc-distbar > i.v-1 { flex: 1 } .proto-root .jc-distbar > i.v-2 { flex: 2 }
|
||||
.proto-root .jc-distbar > i.v-3 { flex: 3 } .proto-root .jc-distbar > i.v-4 { flex: 4 }
|
||||
.proto-root .jc-distbar > i.v-5 { flex: 5 } .proto-root .jc-distbar > i.v-6 { flex: 6 }
|
||||
.proto-root .jc-distbar > i.v-7 { flex: 7 } .proto-root .jc-distbar > i.v-8 { flex: 8 }
|
||||
.proto-root .jc-distbar > i.v-9 { flex: 9 } .proto-root .jc-distbar > i.v-10 { flex: 10 }
|
||||
.proto-root .jc-distbar > i.v-11 { flex: 11 } .proto-root .jc-distbar > i.v-12 { flex: 12 }
|
||||
.proto-root .jc-distbar > i.v-13 { flex: 13 } .proto-root .jc-distbar > i.v-14 { flex: 14 }
|
||||
.proto-root .jc-distbar > i.v-15 { flex: 15 } .proto-root .jc-distbar > i.v-16 { flex: 16 }
|
||||
.proto-root .jc-distbar > i.v-17 { flex: 17 } .proto-root .jc-distbar > i.v-18 { flex: 18 }
|
||||
.proto-root .jc-distbar > i.v-19 { flex: 19 } .proto-root .jc-distbar > i.v-20 { flex: 20 }
|
||||
.proto-root .jc-distbar > i.p { background: var(--ok); }
|
||||
.proto-root .jc-distbar > i.n { background: var(--warn-accent); }
|
||||
.proto-root .jc-distbar > i.r { background: var(--brand); }
|
||||
.proto-root .jc-distbar > i.k { background: var(--ink-4); }
|
||||
.proto-root .jc-distbar > i.p::before { content: "成交 · "; }
|
||||
.proto-root .jc-distbar > i.n::before { content: "待拍板 · "; }
|
||||
.proto-root .jc-distbar > i.r::before { content: "退回重批 · "; }
|
||||
.proto-root .jc-distbar > i.k::before { content: "不成交 · "; }
|
||||
.proto-root .jc-logo svg, .proto-root .jc-face svg, .proto-root .jc-rp-ava svg { width: 17px; height: 17px; }
|
||||
|
||||
/* ── 报价档案卡(工作单独有,照 A4 定价档案卡的骨架)──────────────────────
|
||||
一颗料一张卡,data-rows 逐行复制:左条款、右议价。
|
||||
形态按定案分档(data-class-of):还在还价的整卡描橙,不成交的压灰 —— 判断在样式表。 */
|
||||
.proto-root .jc-selcards { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
|
||||
.proto-root .jc-selcard { border: 1px solid var(--line); border-radius: 11px; background: #fff; overflow: hidden; }
|
||||
.proto-root .jc-selcard.v-haggle { border-color: #f0cf9a; box-shadow: 0 1px 0 rgba(191,132,32,.06); }
|
||||
.proto-root .jc-selcard.v-lost { border-color: var(--line); opacity: .82; }
|
||||
.proto-root .jc-selcard-h {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
padding: 9px 14px; background: var(--soft); border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
.proto-root .jc-selcard.v-haggle .jc-selcard-h { background: var(--warn-soft); border-bottom-color: #f0cf9a; }
|
||||
.proto-root .jc-selcard-h code { font-size: 13px; font-weight: 800; color: var(--ink); }
|
||||
.proto-root .jc-selcard-h .why { flex: 1 1 auto; min-width: 0; font-size: 11px; color: var(--ink-3); }
|
||||
.proto-root .jc-selcard-b { display: grid; grid-template-columns: 1fr 1fr; gap: 0; }
|
||||
@media (max-width: 1000px) { .proto-root .jc-selcard-b { grid-template-columns: 1fr; } }
|
||||
.proto-root .jc-selcol { padding: 11px 14px; }
|
||||
.proto-root .jc-selcol + .jc-selcol { border-left: 1px solid var(--line-soft); }
|
||||
@media (max-width: 1000px) { .proto-root .jc-selcol + .jc-selcol { border-left: 0; border-top: 1px solid var(--line-soft); } }
|
||||
.proto-root .jc-selcol h5 { margin: 0 0 7px; font-size: 10.5px; font-weight: 800; letter-spacing: .08em; color: var(--ink-3); }
|
||||
.proto-root .jc-kvrow { display: flex; gap: 8px; padding: 3px 0; font-size: 12px; line-height: 1.55; }
|
||||
.proto-root .jc-kvrow i { flex: 0 0 84px; font-style: normal; color: var(--ink-4); font-size: 11px; padding-top: 1px; }
|
||||
.proto-root .jc-kvrow span { flex: 1 1 auto; min-width: 0; color: var(--ink); overflow-wrap: anywhere; }
|
||||
.proto-root .jc-kvrow span:empty::after { content: "—"; color: var(--ink-4); }
|
||||
.proto-root .jc-selcard .sc-req:empty::after {
|
||||
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
|
||||
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
|
||||
content: "必填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3;
|
||||
}
|
||||
.proto-root .jc-selcard .sc-sug:empty::after {
|
||||
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
|
||||
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
|
||||
content: "建议填"; color: var(--warn-accent); background: var(--warn-soft); border-color: #f0cf9a;
|
||||
}
|
||||
.proto-root .jc-selcard .sc-opt:empty::after {
|
||||
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
|
||||
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
|
||||
content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line);
|
||||
}
|
||||
|
||||
/* 两把尺(静态规则表 —— 尺挂在墙上,不是这一单的数据) */
|
||||
.proto-root .jc-graderule { border: 1px solid var(--line); border-radius: 10px; overflow: hidden; margin-bottom: 12px; }
|
||||
.proto-root .jc-graderule-r { display: flex; gap: 12px; align-items: flex-start; padding: 9px 13px; font-size: 12px; line-height: 1.6; }
|
||||
.proto-root .jc-graderule-r + .jc-graderule-r { border-top: 1px solid var(--line-soft); }
|
||||
.proto-root .jc-graderule-r p { margin: 0; color: var(--ink-2); }
|
||||
.proto-root .jc-graderule-r p b { color: var(--ink); }
|
||||
.proto-root .jc-band {
|
||||
flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; margin-top: 1px;
|
||||
min-width: 84px; height: 20px; padding: 0 8px; border-radius: 6px;
|
||||
font-size: 10.5px; font-weight: 800; letter-spacing: .04em; border: 1.5px solid; background: #fff;
|
||||
}
|
||||
.proto-root .jc-band.ga { color: var(--ok); border-color: var(--ok); }
|
||||
.proto-root .jc-band.gb { color: var(--brand); border-color: var(--brand); }
|
||||
.proto-root .jc-band.gc { color: var(--warn-accent); border-color: var(--warn-accent); }
|
||||
.proto-root .jc-band.gk { color: var(--danger); border-color: var(--danger); }
|
||||
.proto-root .jc-band.gz { color: var(--ink-3); border-color: var(--ink-4); }
|
||||
|
||||
/* 一单议价怎么走完:报价 → 发出 → 客户表态 → 定案 → 去向,内联线稿走位图 */
|
||||
.proto-root .jc-priceline { display: flex; align-items: center; gap: 0; margin: 2px 0 14px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 11px; background: var(--soft); }
|
||||
.proto-root .jc-plnode { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 3px; align-items: flex-start; }
|
||||
.proto-root .jc-plnode b { font-size: 10.5px; font-weight: 800; letter-spacing: .06em; color: var(--ink-3); }
|
||||
.proto-root .jc-plnode span { font-size: 12px; color: var(--ink-2); line-height: 1.5; }
|
||||
.proto-root .jc-plarrow { flex: 0 0 auto; padding: 0 10px; color: var(--brand-soft); }
|
||||
@media (max-width: 1000px) { .proto-root .jc-priceline { flex-direction: column; align-items: stretch; gap: 8px; } .proto-root .jc-plarrow { display: none; } }
|
||||
|
||||
/* ── 落边图:定案清单 → 三个去向(命中那条实线亮,别的留灰虚线;件不算不判)── */
|
||||
.proto-root .route { margin: 2px 0 14px; padding: 10px 6px 6px; border: 1px solid var(--line); border-radius: 11px; background: #fff; }
|
||||
.proto-root .route svg { width: 100%; height: auto; display: block; }
|
||||
.proto-root .route .rspine { stroke: var(--line); stroke-width: 2; }
|
||||
.proto-root .route .redge { stroke: var(--line); stroke-width: 2; stroke-dasharray: 5 5; fill: none; }
|
||||
.proto-root .route .rnode { fill: #fff; stroke: var(--line); stroke-width: 1.5; }
|
||||
.proto-root .route .rtxt { font-size: 12px; font-weight: 800; fill: var(--ink-3); }
|
||||
.proto-root .route .rsub { font-size: 10px; fill: var(--ink-4); }
|
||||
.proto-root .route .rcond { font-size: 10.5px; font-weight: 700; fill: var(--ink-4); }
|
||||
.proto-root .route .rtip { fill: var(--line); }
|
||||
.proto-root .route.v-r1 .e1, .proto-root .route.v-r2 .e2, .proto-root .route.v-r3 .e3 { stroke: var(--brand); stroke-dasharray: none; }
|
||||
.proto-root .route.v-r1 .n1, .proto-root .route.v-r2 .n2, .proto-root .route.v-r3 .n3 { stroke: var(--brand); stroke-width: 2; }
|
||||
.proto-root .route.v-r1 .t1, .proto-root .route.v-r2 .t2, .proto-root .route.v-r3 .t3 { fill: var(--brand-deep); }
|
||||
.proto-root .route.v-r1 .k1, .proto-root .route.v-r2 .k2, .proto-root .route.v-r3 .k3 { fill: var(--brand); }
|
||||
.proto-root .route.v-r1 .c1, .proto-root .route.v-r2 .c2, .proto-root .route.v-r3 .c3 { fill: var(--brand-deep); }
|
||||
.proto-root .route .legend { display: flex; gap: 14px; flex-wrap: wrap; padding: 6px 10px 2px; font-size: 10.5px; color: var(--ink-4); }
|
||||
|
||||
/* 结论带 */
|
||||
.proto-root .concl { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; align-items: start; margin: 2px 0 14px; padding: 11px 14px; border: 1px solid var(--line); border-radius: 11px; background: var(--soft); }
|
||||
.proto-root .concl svg { grid-row: span 2; margin-top: 2px; color: var(--brand); }
|
||||
.proto-root .concl .cl-main { font-size: 12.5px; color: var(--ink); line-height: 1.6; }
|
||||
.proto-root .concl .cl-main b { font-weight: 800; }
|
||||
.proto-root .concl .cl-route { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; font-size: 11.5px; color: var(--ink-3); }
|
||||
.proto-root .concl .cl-route em { font-style: normal; color: var(--ink-4); }
|
||||
.proto-root .concl .cl-route b { color: var(--brand-deep); font-weight: 800; }
|
||||
.proto-root .concl .cl-route code { font-family: var(--mono, ui-monospace, monospace); font-size: 10.5px; color: var(--ink-4); }
|
||||
`;
|
||||
|
||||
/** 一枚图标 —— 与原型同一写法 */
|
||||
const ico = (name: string, size = 11): string =>
|
||||
`<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-${name}"/></svg>`;
|
||||
|
||||
/** 三层壳照原型:少一层样式表全失效 */
|
||||
const doc = (input: { badge: string; kicker: string; title: string; titleRef: string; state: string; body: string; foot: string }): string =>
|
||||
`<style>${PRD_CSS}${BIND_CSS}</style>
|
||||
${PRD_SPRITE}
|
||||
<div class="proto-root"><div class="app-shell"><div class="jc-stage">
|
||||
<article class="jc-report">
|
||||
<header class="jc-rp-head">
|
||||
<div class="jc-rp-badge">${ico(input.badge, 17)}</div>
|
||||
<div class="jc-rp-tit">
|
||||
<em>${input.kicker}</em>
|
||||
<h3>${input.title} <span data-slot="${input.titleRef}"></span></h3>
|
||||
</div>
|
||||
${input.state}
|
||||
</header>
|
||||
<div class="jc-rp-bd">${input.body}</div>
|
||||
<footer class="jc-rp-foot">${input.foot}</footer>
|
||||
</article>
|
||||
</div></div></div>`;
|
||||
|
||||
const part = (label: string, aside = '', tone = ''): string =>
|
||||
`<div class="jc-rp-part"><b>${label}</b><i class="ln"></i>${aside === '' ? '' : `<em class="${tone}">${aside}</em>`}</div>`;
|
||||
|
||||
const sign = (note: string, tag = ''): string =>
|
||||
`<div class="jc-rp-sign">
|
||||
<span class="jc-rp-ava">${ico('user', 15)}</span>
|
||||
<div><b data-slot="case:owner"></b><span>${note}</span></div>
|
||||
</div>${tag === '' ? '' : `<span class="jc-done-tag">${ico('git-branch', 13)}${tag}</span>`}`;
|
||||
|
||||
/* ==================== 组成件 ==================== */
|
||||
|
||||
/** 要素四卡:客户 / 待回音 / 一格一料的定案刻度 / 议价的尺 */
|
||||
const FOUR = `<div class="jc-four">
|
||||
<div class="jc-fcard">
|
||||
<span class="jc-fcard-k"><i>${ico('building')}</i>客户公司</span>
|
||||
<div class="jc-fcard-b">
|
||||
<span class="jc-logo">${ico('building', 17)}</span>
|
||||
<div class="jc-fcard-t"><b data-slot="customer_name" data-blank></b><span data-slot="customer_code" data-blank></span></div>
|
||||
</div>
|
||||
<span class="jc-fcard-note">承自上游;单飞起单时此栏可空</span>
|
||||
</div>
|
||||
|
||||
<div class="jc-fcard t-teal">
|
||||
<span class="jc-fcard-k"><i>${ico('boxes')}</i>待回音</span>
|
||||
<div class="jc-count"><b data-slot="quote_negotiate_line_missing"></b><span>/ <b data-slot="quote_negotiate_line_rows"></b> 条报价</span></div>
|
||||
<span class="jc-fcard-note">客户表态还空着的行 —— 这一列只有收信的人填得了,系统不替客户说话;补一格少一格,基座现数</span>
|
||||
</div>
|
||||
|
||||
<div class="jc-fcard">
|
||||
<span class="jc-fcard-k"><i>${ico('shield-check')}</i>逐条定案</span>
|
||||
<div class="jc-segmeter" data-rows="mine:quote_negotiate.line">
|
||||
<i data-class-of="deal_status"></i>
|
||||
</div>
|
||||
<span class="jc-fcard-note">一格一条料 · 绿=成交,橙=客户还了价待拍板,蓝=退回重批,灰=不成交(拒绝或超时)</span>
|
||||
</div>
|
||||
|
||||
<div class="jc-fcard t-ok">
|
||||
<span class="jc-fcard-k"><i>${ico('history')}</i>报价有效期</span>
|
||||
<span class="jc-tagbig">${ico('check', 13)}<span><b data-slot="valid_days"></b> 天 · 至 <b data-slot="valid_until" data-blank></b></span></span>
|
||||
<span class="jc-fcard-note">按报价有效期 P-04 注明;D+3 催一轮、D+7 最后一催,催过仍不表态才按超时定案</span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
/** 一单议价是怎么走完的 —— 走位图(形状写死,不算不判) */
|
||||
const DEAL_LINE = `<div class="jc-priceline">
|
||||
<div class="jc-plnode"><b>批准价</b><span>A5 批过的那个数 —— 报出去的价一个数字都不改</span></div>
|
||||
<div class="jc-plarrow">${ico('arrow-right', 18)}</div>
|
||||
<div class="jc-plnode"><b>报价函发出</b><span>条款齐备(SPQ/MOQ/贸易条款/有效期),BD 发给客户</span></div>
|
||||
<div class="jc-plarrow">${ico('arrow-right', 18)}</div>
|
||||
<div class="jc-plnode"><b>客户表态</b><span>接受 / 还价 / 拒绝 / 无回复 —— 这一格由收信的人落</span></div>
|
||||
<div class="jc-plarrow">${ico('arrow-right', 18)}</div>
|
||||
<div class="jc-plnode"><b>定案 N-01</b><span>成交 / 待拍板 / 退回重批 / 不成交,逐条落定</span></div>
|
||||
<div class="jc-plarrow">${ico('arrow-right', 18)}</div>
|
||||
<div class="jc-plnode"><b>去向</b><span>退回重批→回定价 · 有成交→转履约 · 都没成→转归档</span></div>
|
||||
</div>`;
|
||||
|
||||
/** 缺表态红带 —— 与提交闸、放行门 L1 同一个数同一套算法(基座现数);一格不缺自动收 */
|
||||
const GAP_BAR = `<div class="jc-gapbar" data-class-of="quote_negotiate_line_missing">${ico('alert', 14)}
|
||||
<span><b>还差 <span data-slot="quote_negotiate_line_missing"></span> 行客户表态没落</b>
|
||||
—— <span data-slot="quote_negotiate_line_missing_keys"></span>。
|
||||
按右上角「编辑」开表,红框那几格就是要填的:接受填 ACCEPT,还价填 COUNTER 并把客户报的那个价
|
||||
填进「客户还价」,拒绝填 REJECT,催到有效期过仍不表态填 NOREPLY。补齐了执行单才提交得了。</span>
|
||||
</div>`;
|
||||
|
||||
const editGate = (address: string, what: string): string => `<div class="jc-editgate" data-edit-for="${address}">
|
||||
<span class="jc-editgate-t">${ico('lock', 12)}${what}<b>已锁定</b> —— 看的时候不误碰,要改按右边</span>
|
||||
<button type="button" class="jc-egbtn open" data-edit-open="${address}">${ico('pen', 12)}编辑</button>
|
||||
<button type="button" class="jc-egbtn ghost" data-edit-cancel="${address}">取消</button>
|
||||
<button type="button" class="jc-egbtn save" data-edit-save="${address}">${ico('check', 12)}保存</button>
|
||||
</div>
|
||||
<div class="jc-editbar">${ico('pen', 14)}
|
||||
<span>可填的是<b>客户表态</b>、<b>客户还价</b>与<b>客户原话</b>三列:
|
||||
<b>这三列系统一格都不写</b> —— 客户回了什么只有你看得见,别让系统替他表态。
|
||||
还价的行务必把他报的那个数填上,后面拍板全看这一格。本轮不报的料按行首 × 撤掉。
|
||||
改动只落任务草稿层,<b>保存不等于放行</b>。</span>
|
||||
</div>`;
|
||||
|
||||
const addRowBar = (address: string): string => `<div class="jc-addrow">
|
||||
<button type="button" class="jc-egbtn ghost" data-edit-add="${address}">${ico('plus', 12)}加一行</button>
|
||||
<em>照末行的样子添一行空的;主键(型号 PN)填了才存得下,不填这一行不落库。</em>
|
||||
</div>`;
|
||||
|
||||
/**
|
||||
* 报价函主表 —— 一行一料:条款列 + 客户那一侧三列 + 定案列。
|
||||
*
|
||||
* `data-col` 只挂人可改的三列(reply_type / counter_price / reply_note)+ 主键 pn ——
|
||||
* 编辑面精确等于 BD 的职权面;**主键格必挂 `data-col`**(它不是编辑面,是行的钥匙:
|
||||
* 不挂,服务端按「全表一致列」补键,两行会对到同一行上,后者盖前者 —— A2 走查逮过)。
|
||||
* 客户表态挂 `req`:空着出「必填」胶囊、整行标底,与提交闸、放行门同源(都数 `_missing`)。
|
||||
*/
|
||||
const LINE_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
|
||||
<thead><tr>
|
||||
<th style="width:36px;min-width:36px"></th>
|
||||
<th style="width:44px;min-width:44px">类</th>
|
||||
<th style="min-width:170px">PN</th>
|
||||
<th style="min-width:84px">品类</th>
|
||||
<th style="min-width:70px">年用量</th>
|
||||
<th style="min-width:70px">交期</th>
|
||||
<th style="min-width:64px">SPQ</th>
|
||||
<th style="min-width:64px">MOQ</th>
|
||||
<th style="min-width:86px">贸易条款</th>
|
||||
<th style="min-width:88px">报价 CNY</th>
|
||||
<th class="req" style="width:88px;min-width:88px">客户表态(你落)</th>
|
||||
<th style="min-width:80px">客户还价</th>
|
||||
<th style="width:80px;min-width:80px">定案</th>
|
||||
<th style="min-width:150px;width:100%">客户原话 / 让价理由</th>
|
||||
</tr></thead>
|
||||
<tbody data-rows="mine:quote_negotiate.line" data-edit="quote_negotiate.line"><tr>
|
||||
<td class="jc-rn"><button type="button" class="jc-rowdel" data-edit-del="quote_negotiate.line" title="撤掉这一行">×</button></td>
|
||||
<td class="jc-ty" data-h="类"><span class="jc-tbadge" data-class-of="type_tag" data-slot="type_tag"></span></td>
|
||||
<td data-h="PN"><span class="jc-vcell mono" data-slot="pn" data-col="pn" data-blank></span></td>
|
||||
<td class="dimempty" data-h="品类"><span class="jc-vcell" data-slot="category" data-blank></span></td>
|
||||
<td class="dimempty" data-h="年用量"><span class="jc-vcell mono" data-slot="eau" data-blank></span></td>
|
||||
<td class="dimempty" data-h="交期"><span class="jc-vcell" data-slot="lead_time" data-blank></span></td>
|
||||
<td class="dimempty" data-h="SPQ"><span class="jc-vcell num" data-slot="spq" data-blank></span></td>
|
||||
<td class="dimempty" data-h="MOQ"><span class="jc-vcell num" data-slot="moq" data-blank></span></td>
|
||||
<td class="dimempty" data-h="贸易条款"><span class="jc-vcell" data-slot="trade_terms" data-blank></span></td>
|
||||
<td class="dimempty" data-h="报价 CNY"><span class="jc-vcell num" data-slot="quoted_price" data-blank></span></td>
|
||||
<td class="req" data-h="客户表态(你落)">
|
||||
<span class="jc-obadge" data-class-of="reply_type"></span>
|
||||
<span class="jc-vcell" data-slot="reply_type" data-col="reply_type" data-blank></span>
|
||||
</td>
|
||||
<td class="sug" data-h="客户还价"><span class="jc-vcell num" data-slot="counter_price" data-col="counter_price" data-blank></span></td>
|
||||
<td data-h="定案"><span class="jc-dbadge" data-class-of="deal_status"></span></td>
|
||||
<td class="opt" data-h="客户原话 / 让价理由"><span class="jc-vcell" data-slot="reply_note" data-col="reply_note" data-blank></span></td>
|
||||
</tr></tbody>
|
||||
</table></div>`;
|
||||
|
||||
/**
|
||||
* 成交定案表(收口件用)—— 编辑面换成**放行时可改的两列**(成交价与客户原话),
|
||||
* 与 `requiredDeliverables[].editableAtRelease` 逐字对齐。
|
||||
*/
|
||||
const SETTLED_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
|
||||
<thead><tr>
|
||||
<th style="width:36px;min-width:36px"></th>
|
||||
<th style="width:44px;min-width:44px">类</th>
|
||||
<th style="min-width:170px">PN</th>
|
||||
<th style="min-width:88px">报价 CNY</th>
|
||||
<th style="width:88px;min-width:88px">客户表态</th>
|
||||
<th style="min-width:80px">客户还价</th>
|
||||
<th style="min-width:110px">让步处置</th>
|
||||
<th style="width:80px;min-width:80px">定案</th>
|
||||
<th style="min-width:88px">成交价</th>
|
||||
<th style="min-width:150px;width:100%">客户原话 / 让价理由</th>
|
||||
</tr></thead>
|
||||
<tbody data-rows="mine:quote_negotiate.line" data-edit="quote_negotiate.line"><tr>
|
||||
<td class="jc-rn"><button type="button" class="jc-rowdel" data-edit-del="quote_negotiate.line" title="撤掉这一行">×</button></td>
|
||||
<td class="jc-ty" data-h="类"><span class="jc-tbadge" data-class-of="type_tag" data-slot="type_tag"></span></td>
|
||||
<td data-h="PN"><span class="jc-vcell mono" data-slot="pn" data-col="pn" data-blank></span></td>
|
||||
<td class="dimempty" data-h="报价 CNY"><span class="jc-vcell num" data-slot="quoted_price" data-blank></span></td>
|
||||
<td data-h="客户表态"><span class="jc-obadge" data-class-of="reply_type"></span></td>
|
||||
<td class="dimempty" data-h="客户还价"><span class="jc-vcell num" data-slot="counter_price" data-blank></span></td>
|
||||
<td class="dimempty" data-h="让步处置"><span class="jc-cbadge" data-class-of="concession"></span></td>
|
||||
<td data-h="定案"><span class="jc-dbadge" data-class-of="deal_status"></span></td>
|
||||
<td class="opt" data-h="成交价"><span class="jc-vcell num" data-slot="final_price" data-col="final_price" data-blank></span></td>
|
||||
<td class="opt" data-h="客户原话 / 让价理由"><span class="jc-vcell" data-slot="reply_note" data-col="reply_note" data-blank></span></td>
|
||||
</tr></tbody>
|
||||
</table></div>`;
|
||||
|
||||
/** 定案分布:四段按条数分宽 —— 守恒等式(四档相加 = 行数)的可视形 */
|
||||
const DIST = `<div class="jc-dist">
|
||||
<div class="jc-distbar">
|
||||
<i class="p" data-class-of="quote_negotiate_line_deal_status_won" data-slot="quote_negotiate_line_deal_status_won"></i>
|
||||
<i class="n" data-class-of="quote_negotiate_line_deal_status_haggle" data-slot="quote_negotiate_line_deal_status_haggle"></i>
|
||||
<i class="r" data-class-of="quote_negotiate_line_deal_status_revise" data-slot="quote_negotiate_line_deal_status_revise"></i>
|
||||
<i class="k" data-class-of="quote_negotiate_line_deal_status_lost" data-slot="quote_negotiate_line_deal_status_lost"></i>
|
||||
</div>
|
||||
<div class="jc-distlg">
|
||||
<span><em class="a"></em>成交 · 客户接受 <b data-slot="quote_negotiate_line_deal_status_won"></b></span>
|
||||
<span><em class="b"></em>待拍板 · 客户还了价 <b data-slot="quote_negotiate_line_deal_status_haggle"></b></span>
|
||||
<span><em class="b"></em>退回重批 · 让幅超授权 <b data-slot="quote_negotiate_line_deal_status_revise"></b></span>
|
||||
<span><em class="c"></em>不成交 · 拒绝或超时 <b data-slot="quote_negotiate_line_deal_status_lost"></b></span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
/* ==================== 局面一 · 议价工作单(排版 / 发出 / 落回音)==================== */
|
||||
|
||||
/**
|
||||
* 逐颗报价档案 —— 一颗料一张卡(data-rows 逐行复制):左条款、右议价。
|
||||
*
|
||||
* **容器用 `<section>` 不用 `<div>`**:渲染器的行展开按第一个同名闭合标签截行内容,
|
||||
* 行模版里嵌着 `<div>` 时 `<div data-rows>` 会把行截成套娃(A2 逮过,记在陷阱表)。
|
||||
*/
|
||||
const QUOTE_CARDS = `<section class="jc-selcards" data-rows="mine:quote_negotiate.line">
|
||||
<article class="jc-selcard" data-class-of="deal_status">
|
||||
<div class="jc-selcard-h">
|
||||
<span class="jc-tbadge" data-class-of="type_tag" data-slot="type_tag"></span>
|
||||
<code data-slot="pn"></code>
|
||||
<span class="why" data-slot="remark" data-blank></span>
|
||||
<span class="jc-obadge" data-class-of="reply_type"></span>
|
||||
<span class="jc-dbadge" data-class-of="deal_status"></span>
|
||||
</div>
|
||||
<div class="jc-selcard-b">
|
||||
<div class="jc-selcol">
|
||||
<h5>报价函条款(发出去就是承诺)</h5>
|
||||
<div class="jc-kvrow"><i>客户料号</i><span data-slot="customer_pn" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>品类 / 制造商</i><span data-slot="category" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>年用量</i><span data-slot="eau" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>报价 CNY</i><span class="num" data-slot="quoted_price" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>交期</i><span data-slot="lead_time" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>SPQ / MOQ</i><span class="num" data-slot="spq" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>贸易条款</i><span data-slot="trade_terms" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>有效期至</i><span data-slot="valid_until" data-blank></span></div>
|
||||
</div>
|
||||
<div class="jc-selcol">
|
||||
<h5>议价(填回下表,这里回显)</h5>
|
||||
<div class="jc-kvrow"><i>客户表态</i><span class="sc-req" data-slot="reply_type" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>客户还价</i><span class="sc-sug num" data-slot="counter_price" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>客户原话</i><span class="sc-opt" data-slot="reply_note" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>让步处置</i><span class="jc-cbadge" data-class-of="concession"></span></div>
|
||||
<div class="jc-kvrow"><i>定案</i><span class="jc-dbadge" data-class-of="deal_status"></span></div>
|
||||
<div class="jc-kvrow"><i>成交价</i><span class="num" data-slot="final_price" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>客户约束</i><span data-slot="remark" data-blank></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>`;
|
||||
|
||||
/** 议价结果 N-01 的四档 —— 静态规则,尺挂在墙上(素材:MVP 示例工作流 §1.4) */
|
||||
const OUTCOME_RULES = `<div class="jc-graderule">
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band ga">ACCEPT</span>
|
||||
<p><b>客户明确接受这个价。</b>「按这个价走」「下单吧」「PO 稍后发」——
|
||||
定案成交,成交价就是报价,转订单履约。</p>
|
||||
</div>
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band gc">COUNTER</span>
|
||||
<p><b>客户还了一个价。</b>把他报的那个数落进「客户还价」那一格 ——
|
||||
没有数字的「太贵了」先追问,追不到才按无回复落。还价的条目<b>先落成「待拍板」</b>,
|
||||
等人按让步梯度拍一下才有结论:接了算成交、退回重批、或守价不让 —— 系统不替你让价。</p>
|
||||
</div>
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band gk">REJECT</span>
|
||||
<p><b>客户明确不做了。</b>选了别家、项目取消、规格不匹配 ——
|
||||
定案不成交,把缘由记进备注,转情报归档:这一单丢了,情报留下。</p>
|
||||
</div>
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band gz">NOREPLY</span>
|
||||
<p><b>催到有效期过仍不表态。</b><b>客户不回不是结论</b> ——
|
||||
D+3 第一轮催问确认收到探态度,D+7 最后一催;催过了才轮到按超时定案。</p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
/** 让步梯度 N-02 的三档 —— 静态规则(素材:MVP 示例工作流 §1.4 让价核线) */
|
||||
const CONCESSION_RULES = `<div class="jc-graderule">
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band ga">≤ 3%</span>
|
||||
<p><b>接了拿单。</b>意思意思的压价 —— 来回一轮的时间成本比这三个点贵,
|
||||
当场接住,把单子锁下来。</p>
|
||||
</div>
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band gb">3% ~ 10%</span>
|
||||
<p><b>授权内回旋。</b>让一半再报回去,把让出去的换成量或账期 ——
|
||||
先弄清争的是价格、交期还是 MOQ:争交期的用交期换,一上来就降价,后面只剩降价这一招。</p>
|
||||
</div>
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band gk">> 10%</span>
|
||||
<p><b>超出授权,退回成本定价重批。</b>这一档系统不替任何人放行,也不许私自破线 ——
|
||||
底价线以下的单,每补一次货都在亏。<b>破线不是勇气是事故</b>。</p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export const NEGOTIATE_WORKSHEET = doc({
|
||||
badge: 'sheet',
|
||||
kicker: '统一报价 · A6 报价议价 · 议价作业',
|
||||
title: '议价工作单',
|
||||
titleRef: 'rfq_ref',
|
||||
state:
|
||||
'<span class="jc-rp-wait warn">待回音 <b data-slot="quote_negotiate_line_missing"></b> / <b data-slot="quote_negotiate_line_rows"></b> 条</span>',
|
||||
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(
|
||||
'单价与交期<b>照审批通过的草案抄</b>,不做任何「顺手优化」—— 改价要重新过审批点。' +
|
||||
'有效期按行情波动定,不要一律写「长期有效」:那等于把汇率与料价风险自己扛了。' +
|
||||
'拿不准的项<b>宁可空着走补全</b>,不要填一个看起来合理的值。',
|
||||
),
|
||||
{ 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(
|
||||
'发出是<b>写类动作</b>:过程中只登记为「动作意向」—— 入参冻结、等放行,放行前不触达世界。' +
|
||||
'预授权是逐项列举的:催办模板可即时发,<b>报价单不行</b>。',
|
||||
),
|
||||
),
|
||||
part('议价任务', '客户 <b data-slot="customer_name" data-blank></b> · 报价函 <b data-slot="quote_no" data-blank></b> · 共 <b data-slot="line_total"></b> 条') +
|
||||
`<div class="jc-derive">${ico('info', 14)}
|
||||
<span>这一屏是一份报价函的作业台:系统已按 A5 批准的价排好版,条款与有效期都在下面 ——
|
||||
<b>报出去的价一个数字都不改</b>(要改价得回审批)。你要做的是把它发给客户,
|
||||
按 D+3 / D+7 的节奏催问,<b>客户回话之后在「客户表态」那一列逐行落</b>。
|
||||
这一列系统一格都不写:客户回了什么,只有收信的人看得见。</span>
|
||||
</div>` +
|
||||
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') +
|
||||
`<div class="jc-drop">${ico('info', 13)}
|
||||
<span>带红标的<b>客户表态</b>是必填,而且<b>只由你填</b> —— 缺一行执行单按不下提交,
|
||||
与放行门是同一个数同一套算法。四个取值一个字母不许差:
|
||||
<b>ACCEPT</b> 接受 · <b>COUNTER</b> 还价 · <b>REJECT</b> 拒绝 · <b>NOREPLY</b> 无回复。
|
||||
还价的行务必把客户报的那个价填进<b>客户还价</b> —— 那一格空着这张单同样提交不了,
|
||||
后面拍板全看它。客户只回了一部分料的,没回的那几行按 NOREPLY 落,
|
||||
<b>别替客户猜</b>;本轮不报的料按行首 × 撤掉。</span>
|
||||
</div>`,
|
||||
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 = `<div class="route" data-class-of="case:routeKey">
|
||||
<svg viewBox="0 0 660 250" fill="none">
|
||||
<rect class="rnode" x="8" y="95" width="150" height="60" rx="10"/>
|
||||
<text class="rtxt" x="83" y="120" text-anchor="middle">成交定案清单</text>
|
||||
<text class="rsub" x="83" y="138" text-anchor="middle">共 <tspan data-slot="quote_negotiate_line_rows"></tspan> 条</text>
|
||||
<path class="rspine" d="M158 125 H210"/>
|
||||
<path class="redge e1" d="M210 125 C280 125 300 40 460 40"/>
|
||||
<path class="redge e2" d="M210 125 C300 125 340 125 460 125"/>
|
||||
<path class="redge e3" d="M210 125 C280 125 300 210 460 210"/>
|
||||
<path class="rtip k1" d="M448 34 l14 6 -14 6 z"/>
|
||||
<path class="rtip k2" d="M448 119 l14 6 -14 6 z"/>
|
||||
<path class="rtip k3" d="M448 204 l14 6 -14 6 z"/>
|
||||
<text class="rcond c1" x="300" y="60">退回重批 <tspan data-slot="quote_negotiate_line_deal_status_revise"></tspan> 条</text>
|
||||
<text class="rcond c2" x="300" y="115">成交 <tspan data-slot="quote_negotiate_line_deal_status_won"></tspan> 条</text>
|
||||
<text class="rcond c3" x="300" y="230">不成交 <tspan data-slot="quote_negotiate_line_deal_status_lost"></tspan> 条</text>
|
||||
<rect class="rnode n1" x="460" y="8" width="180" height="64" rx="10"/>
|
||||
<text class="rtxt t1" x="550" y="35" text-anchor="middle">A4 · 成本定价</text>
|
||||
<text class="rsub" x="550" y="53" text-anchor="middle">让幅超授权 · 退回重批</text>
|
||||
<rect class="rnode n2" x="460" y="93" width="180" height="64" rx="10"/>
|
||||
<text class="rtxt t2" x="550" y="120" text-anchor="middle">转订单履约 ORM</text>
|
||||
<text class="rsub" x="550" y="138" text-anchor="middle">成交 · 这条流走到终点</text>
|
||||
<rect class="rnode n3" x="460" y="178" width="180" height="64" rx="10"/>
|
||||
<text class="rtxt t3" x="550" y="205" text-anchor="middle">A7 · 情报归档</text>
|
||||
<text class="rsub" x="550" y="223" text-anchor="middle">拒绝 / 超时 · 情报留下</text>
|
||||
</svg>
|
||||
<div class="legend">
|
||||
<span>命中边实线亮 · 未命中留灰虚线</span>
|
||||
<span>按序求值首个命中即出:判了退回重批 → 回定价;其次有成交 → 转履约;都没成 → 转归档</span>
|
||||
<span>判定在你签字那一刻由系统现算 —— 要改去向,在这张表上改定案那几格</span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export const NEGOTIATE_ROSTER = doc({
|
||||
badge: 'file-check',
|
||||
kicker: '统一报价 · A6 报价议价 · 交付成果',
|
||||
title: '成交定案清单',
|
||||
titleRef: 'rfq_ref',
|
||||
state:
|
||||
'<span class="jc-rp-wait">报价 <b data-slot="quote_negotiate_line_rows"></b> 条 · 待拍板 <b data-slot="quote_negotiate_line_deal_status_haggle"></b> 条(要归零)</span>',
|
||||
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(
|
||||
'这一节<b>不是本单成果</b>,是共享域里同一料号的历次报价(含既往单)—— 摆在这里只做一件事:' +
|
||||
'给这一轮的价一个参照系。报价日期在列上,先后按日期对;它由 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(
|
||||
'三个出口对应三种后续,随放行<b>恰选一口</b>,下游网关读的就是它;<b>不选口不许放行</b> ——' +
|
||||
'含糊的结果比没结果更坏。「拒绝」不是失败终态:原因回写历史情报后,下一单的三分类与利润模型会用上它。',
|
||||
),
|
||||
{ text: '下游只读这一口' },
|
||||
) +
|
||||
section('按下放行会发生什么',
|
||||
verdict('放行即落地:台账回写 + 报价单出站发送', 'wait', '已成动作留存,不回滚') +
|
||||
noteOnly(
|
||||
'① <b>落地预检</b>先跑:交付物齐备、动作意向可执行、授权在契约边界内 —— 预检不过不发卡;' +
|
||||
'② 放行通过<b>即刻落地</b>,出站动作附幂等键;' +
|
||||
'③ <b>落地收敛</b>之后输出口才发射 —— 下游只见落地完成的任务。',
|
||||
),
|
||||
),
|
||||
part('议价要素', '报价议价 · 能查表的归判定表,要让价的归人') +
|
||||
FOUR +
|
||||
part('定案分布', '成交 + 待拍板 + 退回重批 + 不成交 = 总条数') +
|
||||
DIST +
|
||||
`<div class="jc-derive">${ico('info', 14)}
|
||||
<span>四段条数由基座逐行现数,不是谁报上来的。橙段(<b>待拍板</b>)是过渡态 ——
|
||||
客户还了价而还没人拍板,它<b>归零这一单才走得到放行门</b>:接了算成交、
|
||||
退回重批算一条正经的路、守价不让算不成交。要让价就得在授权内让,
|
||||
超过一成的让幅一律回去重批,<b>不私自破线</b>。</span>
|
||||
</div>` +
|
||||
part('正式报价函', '<b data-slot="line_total"></b> 条 · 每条都带客户的话') +
|
||||
GAP_BAR +
|
||||
editGate('quote_negotiate.line', '成交定案表') +
|
||||
SETTLED_TABLE +
|
||||
`<div class="jc-drop">${ico('info', 13)}
|
||||
<span>每一行摆的是这一条的<b>来路</b>:报了多少、客户怎么表态、还了什么价、
|
||||
让步梯度判在哪一档、最后定成什么。签字前请重点看两处:<b>定案列</b>里非绿的条目,
|
||||
与<b>客户原话列</b> —— 没有客户原话的还价,下一轮谈判就是重新开始。
|
||||
这一屏可改的是<b>成交价</b>与<b>客户原话</b>两列(你的职权面)。</span>
|
||||
</div>` +
|
||||
part('落边 · 这一签流向哪里', '出口按序求值 · 首个命中即出') +
|
||||
ROUTE_FIG +
|
||||
`<div class="concl">
|
||||
${ico('git-branch', 15)}
|
||||
<div class="cl-main"><b>议价结论</b> —— <span data-slot="summary" data-blank></span></div>
|
||||
<div class="cl-route"><em>转入活动</em><b data-slot="case:routeTo"></b><code data-slot="case:routeWhy"></code></div>
|
||||
</div>` +
|
||||
part('放行后即执行的落地动作', '门前 → 门后') +
|
||||
`<div class="jc-acts">
|
||||
<div class="jc-act"><i>${ico('check', 11)}</i>
|
||||
<div><b>草稿层合并进正式版</b> <span>本任务写下的报价函与议价结论一并落账;
|
||||
你就地改过的成交价与客户原话随之落定。</span></div>
|
||||
</div>
|
||||
<div class="jc-act"><i>${ico('check', 11)}</i>
|
||||
<div><b>命中的那一口此刻才发射</b> <span>报价函
|
||||
<b data-slot="quote_negotiate_line_rows"></b> 条随载荷交给上面亮线指向的下游;
|
||||
另两口本次不起单 —— 放行之前,世界上什么都没有发生。</span></div>
|
||||
</div>
|
||||
<div class="jc-act"><i>${ico('check', 11)}</i>
|
||||
<div><b>不成交也留资产</b> <span>拒绝原因与竞对价位随载荷交给情报归档 ——
|
||||
这一单丢了,下一单的三分类与利润模型会用上它。</span></div>
|
||||
</div>
|
||||
</div>`,
|
||||
foot: sign('BD 议价放行 · AI 排版与定案留痕 —— 要改哪一格,在这张定案表上就地改', '统一报价 · A6 报价议价 → 定去向'),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user