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:
2026-08-03 12:28:31 +08:00
co-authored by Claude Opus 5
parent 85466e815f
commit ade0fa5a94
90 changed files with 17969 additions and 2316 deletions
@@ -0,0 +1,396 @@
/**
* A2 替代选型的包内自测 —— **只准 import @sdk / @contracts / @sfeel**
* (编码规范 §5.1 / ADR-004)。安装两审那一侧的验收在
* `apps/api/.../activity-registry/__tests__` —— 二者之间只有安装通道,没有代码通道。
*
* 案例活动没有 `run`,所以自测不跑回合。这里测的是**清单本身**:
* ① 清单合规 + 判据等级不谎报;
* ② 案例模型双表示同在、触达世界的项标了人工激活;
* ③ **声明与契约对得上** —— 必交付物的台账地址、验收谓词读的列、
* 出口载荷要的标量、出口 when 读的读数,处处必须有承载;
* ④ 报告模版无脚本、去向判定链齐全。
*/
import { describe, expect, it } from 'vitest';
import { activityManifestSchema } from '@contracts';
import { evaluateCondition } from '@sfeel';
import { createMockHost } from '@sdk';
import { manifest } from '../src/manifest.ts';
import activity from '../src/index.ts';
describe('清单本身合规', () => {
it('过 @contracts 的 schema', () => {
expect(() => activityManifestSchema.parse(manifest)).not.toThrow();
});
it('监听时限已配 —— 无悬空监听(AM 4.4)', () => {
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();
}
});
/*
* MVP 口径:A2 **没有判定表**。上一版的 GRADE_DMN 声明着三个入参而台账没有承载、
* 剧本零调用 —— 一张没有求值器的判定表不是资产,是错觉。判稳规则写死在剧本里。
*/
it('没有声明无求值器的判定表 —— 判稳规则写死在剧本(带 90 天那把尺)', () => {
expect(manifest.assets.businessModel.dmn.tables).toEqual([]);
const reclass = manifest.assets.businessModel.steps?.find((s) => s.item === 'S3_reclass');
expect(reclass?.guide).toContain('90');
expect(reclass?.guide).toContain('marketStable');
});
});
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 简化口径的四项**:归一(AI)→ 交 FAE 填回(一张执行单)→ 判稳定标(AI)→ 放行。
* SOP 里初选核险 / 圈选替代 / 对比评估的实质工作在 FAE 系统外,不建成项。
*/
it('计划项照状态机一步一项:归一 + 填回 + 判稳 + 放行,不多建一件', () => {
expect(items(model.cmmnXml)).toEqual(['S1_normalize', 'S2_fill', 'S3_reclass', 'T_release']);
});
it('两处人的门都标了人工激活:FAE 填回 · 清单放行', () => {
for (const id of ['S2_fill', 'T_release']) {
const block = model.cmmnXml.slice(model.cmmnXml.indexOf(`id="${id}"`));
expect(block.slice(0, block.indexOf('</humanTask>')), `${id} 少了 manualActivationRule`).toContain(
'<manualActivationRule',
);
}
});
it('不用阶段 / 里程碑 / 裁量项 —— 它们换不来业务上说得出的好处', () => {
for (const tag of ['<stage', '<milestone', '<planningTable', '<discretionaryItem']) {
expect(model.cmmnXml, `不该出现 ${tag}`).not.toContain(tag);
}
});
it('常来回的项标了可重复 —— 归一会随剔行改数重入', () => {
expect(cmmn.repetition).toContain('S1_normalize');
});
it('末尾一张验收单 —— 完成只出自放行通过', () => {
expect(model.cmmnXml).toContain('id="T_release"');
});
/*
* **放行门的哨兵必须有 ifPart,且认「办完了没有」、读基座数**(A1 s_routed 的账:
* 只有 onPart 的哨兵等于「有人写过一笔就开门」,模型写个 false 就推开了)。
*/
it('放行门哨兵带 ifPart:C 类清零 + 定标守恒 + 去向定过,全读基座数', () => {
const condition = sentryIf(model.cmmnXml, 's_routed');
expect(condition).toContain('quote_alternative_list_type_tag_c = 0');
expect(condition).toContain('quote_alternative_list_type_tag_a + quote_alternative_list_type_tag_b');
expect(condition).toContain('route_decided_total');
// FAE 漏填一行替代料(那一行仍是 C)→ 门不开;全转完 → 门开
const done = { route_decided_total: 1, quote_alternative_list_rows: 2, quote_alternative_list_type_tag_a: 1, quote_alternative_list_type_tag_b: 1, quote_alternative_list_type_tag_c: 0 };
expect(safeEval(condition, done)).toBe(true);
expect(safeEval(condition, { ...done, quote_alternative_list_type_tag_c: 1, quote_alternative_list_type_tag_b: 0 })).toBe(false);
});
it('每个计划项都有剧本 —— 轮到哪一步才摊开哪一步', () => {
const ids = items(model.cmmnXml);
expect((model.steps ?? []).map((s) => s.item)).toEqual(ids);
for (const step of model.steps ?? []) {
expect(step.guide.length, `${step.item} 的做法`).toBeGreaterThan(80);
expect(step.intent.length, `${step.item} 的意图`).toBeGreaterThan(6);
}
});
it('说明书不点名计划项 —— 点了就会与案例模型分叉(A1 踩过)', () => {
const named = [...model.narrative.matchAll(/\b(?:S\d+_[A-Za-z]+|T_[a-z]+)\b/g)].map((m) => m[0]);
const ids = new Set(items(model.cmmnXml));
for (const name of named) {
expect(ids.has(name), `说明书提到的 ${name} 不在案例模型里`).toBe(true);
}
});
it('说明书只讲整单的事:成品、怎么说话、怎么收口', () => {
for (const topic of ['开案', '要说话', '一拍连做', '结案']) {
expect(model.narrative, topic).toContain(topic);
}
});
});
/**
* 声明与契约的对齐 —— 三处引用同一批台账列,错位了不会报错,只会在放行那一刻
* 「判据取不到值 / 出口取不到字段」,而两者都长得像业务问题。
*/
describe('声明与契约对得上', () => {
const columns = new Set(
manifest.declarations.ledger.flatMap((d) => [`${d.domain}.${d.object}`, ...d.fields.map((f) => f.name)]),
);
it('必交付物的台账地址都声明过', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(columns.has(d.ledger ?? ''), `${d.key} → ${d.ledger}`).toBe(true);
}
});
it('editableAtRelease 的列都在成果表的声明里 —— 不在名单里的列压根不进写回载荷', () => {
const list = manifest.declarations.ledger.find((d) => d.object === 'list');
const names = new Set((list?.fields ?? []).map((f) => f.name));
for (const d of manifest.contract.requiredDeliverables) {
for (const col of d.editableAtRelease ?? []) {
expect(names.has(col), `editableAtRelease 里的 ${col}`).toBe(true);
}
}
});
const rowReadings = new Set([
...manifest.declarations.ledger.map((d) => `${d.domain}_${d.object}_rows`),
...manifest.declarations.ledger
.filter((d) => d.fields.some((f) => f.required === true))
.map((d) => `${d.domain}_${d.object}_missing`),
...manifest.declarations.ledger.flatMap((d) =>
d.fields.flatMap((f) =>
(f.tally ?? []).map((v) => `${d.domain}_${d.object}_${f.name}_${v.toLowerCase().replace(/[^a-z0-9_]/g, '')}`),
),
),
/*
* **工具调用读数**(2026-08-03 起):基座按留痕数「这一单调了几次某工具」,
* 声明过的工具一律先铺 0。有了它,「转 A 有据」这类判据才两边都不是活动自报的。
*/
...manifest.declarations.tools.map((t) => `tool_${t.name.replace(/[^0-9a-zA-Z]+/g, '_')}_calls`),
]);
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('出口选择组每一口的 when 读的名字也都有承载 —— 静态审不查它,这里自己查', () => {
for (const group of manifest.contract.output.selective) {
for (const port of group.ports) {
for (const identifier of (port.when ?? '').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), `${port.name} 的 when 引用了 ${identifier}`).toBe(true);
}
}
}
});
it('出口载荷的标量字段都有台账列承载(踩过:去向旗没列 → E5 从来不成立)', () => {
const snake = (name: string) => name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
for (const port of manifest.contract.output.mandatory) {
const properties = (port.payload as { properties?: Record<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);
}
}
});
/*
* 裁量判据的 label 与剧本里 judge 的 criterion 必须**逐字一致** ——
* judge 按整句对名,差一个字那一条永远算没裁过,提交被拒。
*/
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 ?? '');
});
});
describe('报告模版 —— 活动持有,容器无脚本执行权', () => {
/*
* **选型作业与收口验收各有各的件**(2026-08-03 用户:「验收工单怎么又变成
* 选型了?」)—— One Pager 归选型执行单(S1/S2),整合清单归收口(S3/放行/归档,
* 「清单是所有选型完成后收口才整合的东西」)。「1:1」落在每份件与它的办结快照
* 之间(finalHtml),不是全程只许一张。
*/
it('两件各归各位:选型 One Pager 归作业,整合清单归收口', () => {
expect(manifest.assets.viewTemplates.map((t) => t.name)).toEqual(['alt-worksheet', 'alt-roster']);
const viewOf = (item: string) => manifest.assets.businessModel.steps?.find((s) => s.item === item)?.view;
for (const item of ['S1_normalize', 'S2_fill']) expect(viewOf(item), item).toBe('alt-worksheet');
for (const item of ['S3_reclass', 'T_release']) expect(viewOf(item), item).toBe('alt-roster');
});
it('验收报告重列表与落边:汇总带 + 受理记录大表 + 落边图(命中的边亮)', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'alt-roster')?.html ?? '';
expect(roster).toContain('statrow');
expect(roster).toContain('data-class-of="case:routeKey"');
// 落边图:两条边(A3 寻源 / A4 定价)+ 命中流光
for (const mark of ['class="redge e1"', 'class="redge e2"', 'class="rpulse p1"', 'class="rpulse p2"']) {
expect(roster).toContain(mark);
}
for (const t of ['A3 · 渠道寻源', 'A4 · 成本定价', '还有 B 类?']) expect(roster).toContain(t);
for (const col of ['orig_type', 'type_tag', 'alt_grade', 'basis']) expect(roster).toContain(`data-slot="${col}"`);
// 收口件只读:就地改走验收卡的 editableAtRelease 表,这里一个输入格都不给
expect(roster).not.toContain('data-edit=');
expect(roster).not.toContain('data-col=');
});
/*
* 对决区(2026-08-02 用户定):左侧被替换目标的静态 SVG 卡,右侧替换卡可编辑,
* 替代等级三枚选择钮直接选(正确性全由人控);渲染态就是结构,
* 编辑只点亮输入;02 选型指引铺开(不写类型,口径贴在选择钮上)。
*/
it('执行工单重单颗选型:档案头 + 蓝图带 + 题面/定案双栏 + 等级三选 + 方法脚注,每颗一页', () => {
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'alt-worksheet')?.html ?? '';
expect(sheet).toContain('class="blueprint"');
expect(sheet).toContain('data-page');
expect(sheet).toContain('class="alt-page"');
expect(sheet).toContain('class="given"');
expect(sheet).toContain('class="decide"');
// 等级 = 三枚 radio 直接选,值域即台账取值域
for (const v of ['value="P2P"', 'value="FUNC"', 'value="OTHER"']) expect(sheet).toContain(v);
expect((sheet.match(/name="alt_grade_pick"/g) ?? []).length).toBe(3);
expect(sheet).toContain('class="m-five"');
expect(sheet).toContain('class="m-funnel"');
expect(sheet).toContain('data-slot="remark"');
expect(sheet).toContain('同封装不等于同引脚');
});
/*
* 空占位三级(2026-08-02 用户定,全局口径):必填红 / 建议填橘 / 选填灰,
* 与声明 required / expect / 其余一一对应。
*/
it('空占位三种:必填(红)· 选填(灰)· 建议(橘);待选择归必填', () => {
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'alt-worksheet')?.html ?? '';
for (const word of ['"必填"', '"建议填"', '"选填"']) expect(sheet).toContain(word);
// 等级空位归「必填」—— 不再有单独的「待选定」文案
expect(sheet).not.toContain('待选定');
// 声明侧对应:定案三格 requiredWhen(NOW 行必填),三格 expect(建议填)
const cols = new Map(manifest.declarations.ledger.find((l) => l.object === 'list')?.fields.map((f) => [f.name, f]) ?? []);
for (const c of ['alt_pn', 'alt_brand', 'alt_grade']) expect(cols.get(c)?.requiredWhen?.value, c).toBe('C');
for (const c of ['alt_diff', 'alt_risk', 'alt_verify']) expect(cols.get(c)?.expect, c).toBe(true);
expect(cols.get('alt_note')?.expect).toBeUndefined();
});
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('One Pager 不摆去向与清单 —— 那是收口整合的东西(2026-08-02 用户定),去向在验收卡上', () => {
// 断正文 —— PRD 共享样式表里带着 jc-dmn 的规则,不算摆了判定表
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'alt-worksheet')?.html ?? '';
const markup = sheet.replace(/<style>[\s\S]*?<\/style>/, '');
expect(markup).not.toContain('case:routeKey');
expect(markup).not.toContain('jc-dmn');
});
it('编辑面 = FAE 的定案列 + 行的钥匙 pn —— 保存按格里的主键认行,少了它两行会对到一行上(走查逮过)', () => {
const fae = ['alt_pn', 'alt_brand', 'alt_grade', 'alt_diff', 'alt_risk', 'alt_verify', 'alt_note'];
const html = manifest.assets.viewTemplates.find((t) => t.name === 'alt-worksheet')?.html ?? '';
const cols = new Set([...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]));
expect(cols.has('pn'), '少了行的钥匙 pn').toBe(true);
for (const col of fae) expect(cols.has(col), `少了定案列 ${col}`).toBe(true);
// 现标不在工作单编辑面(推翻判定是放行时的职权,走验收卡的 editableAtRelease 表)
expect(cols.has('type_tag')).toBe(false);
});
it('选择组里每一口都写了判据,两口互斥覆盖 —— 去向才算得出来', () => {
const groups = manifest.contract.output.selective;
expect(groups.length).toBeGreaterThan(0);
for (const group of groups) {
expect(group.ports.length).toBeGreaterThanOrEqual(2);
for (const port of group.ports) expect(port.when, `${group.group}/${port.name}`).toBeTruthy();
}
// 还有 B 类 → 去寻源;全 A → 直通定价 —— 同一个数,两口恰好互斥
const [toSourcing, toPricing] = groups[0]?.ports ?? [];
expect(safeEval(toSourcing?.when ?? '', { quote_alternative_list_type_tag_b: 2 })).toBe(true);
expect(safeEval(toPricing?.when ?? '', { quote_alternative_list_type_tag_b: 2 })).toBe(false);
expect(safeEval(toSourcing?.when ?? '', { quote_alternative_list_type_tag_b: 0 })).toBe(false);
expect(safeEval(toPricing?.when ?? '', { quote_alternative_list_type_tag_b: 0 })).toBe(true);
});
it('宿主拒推含脚本的模版 —— 这道门在基座,不靠模版作者自觉', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
});
});
const CONTRACT = {
activityId: 'quote-alternative',
version: '1.0.0',
data: { rfqRef: 'RFQ MD14.1', records: [] },
message: undefined,
outputPorts: { mandatory: ['roster'], selective: [] },
prohibitions: [],
discretion: [],
} as const;
describe('代码入口 —— 案例活动没有 run,不是残包', () => {
it('默认导出是一个有名字的活动定义', () => {
expect(activity.name).toBe('quote-alternative');
});
it('mock 宿主如实报「没有 run」,不静默返回一个假的 outcome', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.run(activity)).rejects.toThrow(/没有 run/);
});
});
/** 计划项 id 按出现次序 —— 断言「图上有几步就是几项」 */
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 };
}
/** 抠出某枚哨兵的 ifPart 条件(XML 实体还原)—— 断言放行门认的是「办完了没有」 */
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('&gt;', '>').replaceAll('&lt;', '<').replaceAll('&amp;', '&');
}
/** 谓词写坏了引擎当作「没算出来」——测里也照这个口径 */
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
@@ -0,0 +1,212 @@
/**
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(A1 的账)。
*
* 这几条断言守的是剧本本身的可用性:项 id 对不对得上、判据会不会开局即真、
* 声明的台账与工具是不是活动真有的、每一项有没有进度条的格子。
*/
import { describe, expect, it } from 'vitest';
import { evaluateCondition } from '@sfeel';
import { ALTERNATIVE_PHASES, ALTERNATIVE_STEPS, CMMN_XML } from '../src/case-model.ts';
import { manifest } from '../src/manifest.ts';
/** 从 CMMN XML 里把计划项 id 抠出来 —— 剧本认的是它们 */
const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map(
(m) => m[1] ?? '',
);
describe('剧本与案例模型对得上 —— 对不上的条目等于没写', () => {
it('每一条剧本都指向一个真实存在的计划项', () => {
for (const step of ALTERNATIVE_STEPS) {
expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
}
});
it('图上每一步都有剧本 —— 漏掉的那一步会退回整本说明书', () => {
for (const id of ITEM_IDS) {
expect(ALTERNATIVE_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
}
});
it('清单里挂的就是这份剧本(不是另抄一份)', () => {
expect(manifest.assets.businessModel.steps).toBe(ALTERNATIVE_STEPS);
});
});
describe('doneWhen 是「成果已存在」的判据,不是「该不该做」的判据', () => {
/** 开案那一刻的两种局面:流内(上游载荷)与单飞(一段需求)—— 台账一行没有 */
const FRESH_FLOW: Record<string, unknown> = {
rfqRef: 'RFQ MD14.1',
records: [{ pn: 'BBPY00201209601Y00', type_tag: 'C' }],
customer_name: 'Itelma',
};
const FRESH_SOLO: Record<string, unknown> = {
subject: '车规运放选型需求',
body: '需要一颗车规级双通道运放……',
};
it('**开局一步都不该被自动收掉** —— 流内与单飞两种开局都要 false', () => {
for (const fresh of [FRESH_FLOW, FRESH_SOLO]) {
for (const step of ALTERNATIVE_STEPS) {
if (step.doneWhen === undefined) continue;
expect(
safeEval(step.doneWhen, fresh),
`${step.item} 的 doneWhen「${step.doneWhen}」开局就为真,那一步会在还没干之前被收掉`,
).toBe(false);
}
}
});
/* ── ① 归一清单 ─────────────────────────────────────────────── */
it('整份落表且两边对上 → 归一收得掉', () => {
expect(
safeEval(doneOf('S1_normalize'), { alt_line_total: 2, quote_alternative_list_rows: 2 }),
).toBe(true);
});
it('**少拆多写都收不掉** —— 自报条数与基座行数对不上,错在第 2 拍就现形', () => {
expect(safeEval(doneOf('S1_normalize'), { alt_line_total: 2, quote_alternative_list_rows: 3 })).toBe(false);
expect(safeEval(doneOf('S1_normalize'), { alt_line_total: 0, quote_alternative_list_rows: 0 })).toBe(false);
});
/* ── ② 交 FAE 填回:人环,引擎不许替人按 ─────────────────────── */
it('**FAE 那一步不给 doneWhen** —— 引擎不许替人交卷', () => {
expect(ALTERNATIVE_STEPS.find((s) => s.item === 'S2_fill')?.doneWhen).toBeUndefined();
});
it('填回这一步是一张执行单:工作单一颗一页、卡上零输入、答复处理记 filled_total', () => {
const fill = ALTERNATIVE_STEPS.find((s) => s.item === 'S2_fill');
expect(fill, 'FAE 填回这一步不见了').toBeDefined();
expect(fill?.view).toBe('alt-worksheet');
/*
* **一张执行单,多页待执行报告**(2026-08-02 用户定,替代此前的逐颗按序派单):
* 一颗待选型芯片一页,右上角翻页;定案三格是每页必填(requiredWhen orig_type=C),
* 提交闸看着。卡上零输入;人交回后只清点 filled_total、finish。
*/
expect(fill?.guide).toContain('一颗待选型芯片一页');
expect(fill?.guide).toContain('只发一次');
expect(fill?.guide).not.toContain('"fields"');
expect(fill?.guide).not.toContain('sel_stage');
expect(fill?.guide).toContain('filled_total');
for (const code of ['P2P', 'FUNC', 'OTHER']) expect(fill?.guide).toContain(code);
});
it('归一那一步不再排队列 —— 单卡多页后 sel_stage 已退役', () => {
expect(ALTERNATIVE_STEPS.find((s) => s.item === 'S1_normalize')?.guide).not.toContain('sel_stage');
});
/* ── ③ 判稳定标:收步判据与验收判据同源 ─────────────────────── */
it('C 类清零且去向定了 → 判稳收得掉', () => {
expect(
safeEval(doneOf('S3_reclass'), { quote_alternative_list_type_tag_c: 0, route_decided_total: 1 }),
).toBe(true);
});
it('**还有一颗 C 没处置 → 判稳收不掉** —— 收步比验收松,松出来的那一截就是返工', () => {
expect(
safeEval(doneOf('S3_reclass'), { quote_alternative_list_type_tag_c: 1, route_decided_total: 1 }),
).toBe(false);
});
it('**没记过去向数 → 收不掉** —— 未绑定的名字比大小为假,不会开局即收', () => {
expect(safeEval(doneOf('S3_reclass'), { quote_alternative_list_type_tag_c: 0 })).toBe(false);
});
it('全转 A(need_sourcing = false)照样收得掉 —— 判据看的是「定过没有」,不是旗本身', () => {
expect(
safeEval(doneOf('S3_reclass'), {
quote_alternative_list_type_tag_c: 0,
route_decided_total: 1,
need_sourcing: false,
}),
).toBe(true);
});
it('**放行那一项不给 doneWhen** —— 终局唯放行,引擎不许自动收它', () => {
expect(ALTERNATIVE_STEPS.find((s) => s.item === 'T_release')?.doneWhen).toBeUndefined();
});
/*
* `records` 是上游整包落地行 —— 不声明 reads 会被整表段的截断刀盲砍
* (按字符截,砍掉的恰是 C 类行;A1 认客户那一步栽过同款)。
*/
it('归一那一步声明了 reads: records —— 上游载荷置前不截断', () => {
expect(ALTERNATIVE_STEPS.find((s) => s.item === 'S1_normalize')?.reads).toContain('records');
});
});
describe('剧本声明的东西活动真有 —— 声明了没有的,那一步照着做必然失败', () => {
const declaredLedger = manifest.declarations.ledger.map((l) => `${l.domain}.${l.object}`);
const declaredTools = manifest.declarations.tools.map((t) => t.name);
it('每一步要写的台账都在清单的台账声明里', () => {
for (const step of ALTERNATIVE_STEPS) {
for (const address of step.writes) {
expect(declaredLedger, `${step.item} 要写 ${address},而活动没声明这个台账对象`).toContain(address);
}
}
});
it('每一步要调的工具都在清单的工具声明里', () => {
for (const step of ALTERNATIVE_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 ALTERNATIVE_STEPS) {
expect(step.view, `${step.item} 没定画面`).toBeDefined();
expect(names.has(step.view ?? ''), `${step.item} 的 view「${step.view}」不在模版名单里`).toBe(true);
}
});
it('每一步都说得出它交出什么(intent 与 guide 都不为空)', () => {
for (const step of ALTERNATIVE_STEPS) {
expect(step.intent.trim(), `${step.item} 没写 intent`).not.toBe('');
expect(step.guide.trim().length, `${step.item} 的 guide 太短,等于没写`).toBeGreaterThan(20);
}
});
});
function doneOf(item: string): string {
const found = ALTERNATIVE_STEPS.find((s) => s.item === item)?.doneWhen;
if (found === undefined) throw new Error(`${item} 没有 doneWhen`);
return found;
}
/** 谓词写坏了引擎当作「没算出来」——测里也照这个口径,断言才对得上运行时 */
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
describe('阶段刻度:每一项都有格可待(A1 漏 S3_confirm 的账)', () => {
const inPhase = new Set(ALTERNATIVE_PHASES.flatMap((p) => p.items));
it('**除放行外每一项恰好属于一个阶段** —— 漏一项,进度条就会在它办着的时候跳过去', () => {
const planned = ALTERNATIVE_STEPS.map((s) => s.item).filter((id) => id !== 'T_release');
expect(planned.filter((id) => !inPhase.has(id))).toEqual([]);
});
it('一项不进两个阶段 —— 进了两个,「这一格过没过」就有两种答案', () => {
const all = ALTERNATIVE_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(ALTERNATIVE_STEPS.map((s) => s.item));
expect([...inPhase].filter((id) => !ids.has(id))).toEqual([]);
});
});
+291 -90
View File
@@ -1,138 +1,339 @@
/**
* A2 替代选型的案例模型 —— **一张执行单办完**(2026-07-31 用户定,大幅简化)。
* A2 替代选型的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
*
* 上一版把 SOP 上的每一小步都建成了计划项(查规格、判品牌、查沉淀、撒网初筛、
* 逐条核验、确认主推、收规格书、参数对比、风险评估…… 九个 humanTask)。
* 那是把「FAE 脑子里怎么想」当成了「系统要管几步」——
* 而这一整段在业务上是**一件事**:拿着原型号去选一个替代料,选完给出对标结论。
* 系统该做的不是替他把这件事切成九块,是**把该填的表摆好、把结果收进台账**。
* 业务出处:`docs/MVP示例工作流.html` §1.5,但按 **MVP 简化口径**建模
* (2026-08-02 用户定:A2 是复杂活动,MVP 期间不展开大规模业务建模):
*
* 现在只有两项:
* T_select —— 派一张执行单给 FAE(卡面两张可编辑表),他填完提交;
* T_release —— 放行(触达世界,人工激活)。
* A1 带着询价受理记录转进来 → 挑出 C 类 → **一张执行单**让 FAE 在系统外完成选型、
* 把替代料填回来 → 判替代料在库里行情稳不稳:稳转 A、不稳转 B →
* 询价受理记录就此转换成**询价受理清单**,传给下游(有 B 类去寻源,全 A 直通定价)。
*
* 与 A1 的分工:A1 是**受理归一**,活多、判据多、有确定性规则可依(两张判定表),
* 值得逐步建模;A2 是**一次专家判断**,建模建得再细也换不来业务上的好处,
* 只会让 Agent 全程跟 harness 较劲(A1 上一版 12 项的教训就在这儿)。
* SOP 里初选核险 / 圈选替代 / 对比评估那三段的**实质工作在 FAE 脑子里与系统外**
* (撒网、读规格书、参数对比 —— 本系统没有那些数据源);系统的本分是归一、组卡、
* 判稳、留痕。所以图上只有四项:归一 → 交人填回 → 判稳定标 → 放行。
*
* ── 建模纪律(照 A1 的账)──────────────────────────────────────────────
* 图上有几步就是几项,不多建一件;触达世界的项 manualActivation 且不给 doneWhen;
* 哨兵必须有 ifPart 且读基座数、认「办完了没有」;案卷项 id 就是任务视图键名。
*
* 上一版(@2.0.0)只有「派单 + 放行」两项,归一与判稳整个不存在 ——
* C 类怎么来的、去向旗从哪儿出,全靠模型临场发挥;E5 那条边(needSourcing)
* 因为台账里没有承载列,**从来没有成立过**。这一版把这两步补成真的。
*/
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
<case id="alternative">
<!--
案卷项的 id **就是任务视图里的键名**。这里只认两样:
起单入参(altRequests —— A1 发过来的那批替代评估请求)与「对标表写了几行」(benchmark_total)——
后者既是放行前提,也是这一单唯一的机检判据。
案卷项的 id **就是任务视图里的键名** —— 前提的 onPart 认的是「这个键有值了」。
流内起单时上游载荷(records 等)与本活动台账列都会摊平到任务视图顶层,照它们命名。
开案即有的只有上游载荷(或单飞的自由入参)。rfq_ref 是归一那一步认出来
(或造出来)的:本活动所有台账对象都以它为主键。
-->
<caseFileModel>
<caseFileItem id="altRequests" name="替代评估请求(开案即有,一条料一行)"/>
<caseFileItem id="benchmark_total" name="对标表条数"/>
<caseFileItem id="records" name="上游受理记录(流内开案即有)"/>
<caseFileItem id="alt_line_total" name="清单条目数(自报)"/>
<caseFileItem id="quote_alternative_list_rows" name="清单行数(基座现数)"/>
<caseFileItem id="c_total" name="待选型条数"/>
<caseFileItem id="filled_total" name="已填回替代料条数"/>
<caseFileItem id="quote_alternative_list_type_tag_c" name="仍为 C 类的行数(基座现数)"/>
<caseFileItem id="route_decided_total" name="已定去向"/>
<caseFileItem id="need_sourcing" name="去向旗"/>
</caseFileModel>
<casePlanModel id="plan" name="替代选型" autoComplete="true">
<sentry id="s_benchmarked">
<onPart id="op_bm" caseFileItemRef="benchmark_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_bm"><condition>benchmark_total &gt; 0</condition></ifPart>
<!--
① 归一完了 —— 交 FAE 那一步的入场前提。
两边对上才算:alt_line_total 是活动自报的条数,quote_alternative_list_rows
是基座数出来的本单行数 —— 少拆一行、多写一行都进不了下一步
(同一个错,在第 2 拍发现比在第 10 拍便宜得多,A1 的账)。
-->
<sentry id="s_normalized">
<onPart id="op_nm" caseFileItemRef="alt_line_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_nm"><condition>alt_line_total &gt; 0 and quote_alternative_list_rows = alt_line_total</condition></ifPart>
</sentry>
<!--
② FAE 的答复处理过了 —— 判稳那一步的入场前提。
filled_total 是「人交回执行单之后」由 AI 清点写下的数(填回替代料的条数)——
它只在人环走完才存在,所以它就是「经过人了」的凭据(照 A1 confirmed_total 的路子)。
全部替代未果时它是 0:「大于等于 0」对「写过的 0」为真、对「没写过」为假
(S-FEEL 未绑定的名字参与比较为假)。
-->
<sentry id="s_filled">
<onPart id="op_fl" caseFileItemRef="filled_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_fl"><condition>filled_total &gt;= 0</condition></ifPart>
</sentry>
<!--
── 放行门的入场条件:**认「办完了没有」,且读基座数**(A1 s_routed 的账)────
· 清单上不再有 C 类(quote_alternative_list_type_tag_c 由基座按值现数)——
FAE 漏填一行替代料,那一行转不了 A/B,这道门就不开;
· 定标守恒:A + B = 行数(两边都是基座出的数);
· 去向定过了(route_decided_total 是判稳那一步显式写下的记账数 ——
旗是布尔,分不清「没写」与「写了 false」)。
-->
<sentry id="s_routed">
<onPart id="op_rt" caseFileItemRef="route_decided_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_rt"><condition>route_decided_total &gt; 0 and quote_alternative_list_rows &gt; 0 and quote_alternative_list_type_tag_c = 0 and quote_alternative_list_type_tag_a + quote_alternative_list_type_tag_b = quote_alternative_list_rows</condition></ifPart>
</sentry>
<!-- 唯一的活:派一张执行单给 FAE,他填完提交 -->
<humanTask id="T_select" name="替代选型:派执行单给 FAE,收回初筛与对标结论">
<itemControl><repetitionRule id="r_sel"/></itemControl>
<!--
① 归一询价受理清单 —— 上游受理记录(或单飞来件)整份落成本活动的清单,
C 类行就是本单要办的对象。**整份都落**:A/B 行原样承继 ——
这份清单最终要整份交给下游,不是只有 C 类那几行。
-->
<humanTask id="S1_normalize" name="归一询价受理清单:整份落表,C 类标待选型">
<itemControl><repetitionRule id="r_s1"/></itemControl>
</humanTask>
<!-- 放行:触达世界,必须人工激活 -->
<humanTask id="T_release" name="替代选型结论验收放行">
<entryCriterion id="ec_rel" sentryRef="s_benchmarked"/>
<!--
② 替代选型交 FAE —— **一张执行单**(MVP 简化口径:选型的实质工作在系统外)。
manualActivation + 不给 doneWhen:发卡触达真人,引擎不替你按;
「人看过、选完、填回来了」也不是台账里任何一个数能自己变出来的。
-->
<humanTask id="S2_fill" name="替代选型交FAE:执行单上把 C 类行的替代料填回来">
<entryCriterion id="ec_s2" sentryRef="s_normalized"/>
<itemControl><manualActivationRule id="m_s2"/></itemControl>
</humanTask>
<!--
③ 判稳定标 —— 替代料查历史行情,稳转 A、不稳转 B(规则写死,零裁量);
清单就此从「受理记录」转成「受理清单」,去向旗随之立起。
-->
<humanTask id="S3_reclass" name="判稳定标:替代料查行情,稳转A不稳转B,立去向旗">
<entryCriterion id="ec_s3" sentryRef="s_filled"/>
</humanTask>
<!-- 清单放行:终局唯放行 —— 触达世界,必须人工激活 -->
<humanTask id="T_release" name="询价受理清单验收放行">
<entryCriterion id="ec_rel" sentryRef="s_routed"/>
<itemControl><manualActivationRule id="m_rl"/></itemControl>
</humanTask>
</casePlanModel>
</case>
</definitions>`;
export const NARRATIVE = `§0 开案:A1 判出一条料要替代(原型号 EOL / 缺货 / 客户点名),这一单就到你手上。
成品只有一样:**一份能让 BD 拿去跟客户讲的替代对标结论** ——
哪几个候选、推荐哪一个、参数差在哪、有什么风险、要不要打样验证。
/**
* 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本,两处不重复写同一件事)。
*/
export const NARRATIVE = `§0 开案:**一份带 C 类料的询价受理记录要过替代选型**。来路有两种,形状都收(宽进):
· **流内**:上游受理活动放行后带着它的落地事实进来 —— 顶层有单号 / 客户名 / 三类条数等
摊平字段,\`records\` 数组里是上游全部落地行**混在一起**(带年用量与制造商的是受理记录行、
带分级依据与历史天数的是分类表行、带各种总数的是结论行 —— 按 PN 归并自辨,别当成三张表);
· **单飞**:没有上游,只有一段需求(型号或芯片类型 + 参数 + 场景)—— 照来件归一,
没给的照实留空。
**这一单只有一步**:把选型这件事派给 FAE(执行工单),他填完你收进台账,然后提交放行。
别把它拆成「先查规格、再判品牌、再撒网、再核验……」—— 那是他脑子里的过程,
不是系统要管的步。你替他把表摆好就够了。
**这个活动是复杂活动的 MVP 简化形**:替代选型的实质工作(查规格、撒网、参数对比)
由 FAE 在**系统外**完成,系统只负责三件事 —— 把清单归一好、把执行单组好交给他、
把他填回的替代料**判稳定标**(在库行情稳转 A,不稳转 B)。
【T_select 派执行单】发 human 行动(workOrder="execution"),卡面 blocks 放**两张可编辑表**:
· 初筛清单:列 = 原型号 / 候选型号 / 品牌 / 生命周期;
· 替代对标表:列 = 候选型号 / 替代等级 / 参数差异项 / 差异风险 / 验证建议 / FAE签字。
两张都 editable:true;没有的值给空字符串 —— 空格子界面会自己标红「待填写」。
ask 一句话说清要他做什么;basis 摆上原型号、芯片类型、客户的特殊要求(裸卡基座拒发)。
**成品只有一样**:一份**询价受理清单** —— 整份行都在、C 类已全部转成 A 或 B、
去向旗立了(还有 B 类就去寻源补价,全 A 直通定价)。判稳的尺与三分类 P-03 的 A/B
两行同一把:**历史价 ≤ 90 天且行情稳 → A,否则 → B**。
FAE 的定案不只是一个型号:**替代等级(P2P 直换 / FUNC 功能兼容 / OTHER 须验证)、
参数差异、风险与验证建议一并落表** —— 那是他的专业产出,逐颗可溯。
他提交后,回传值出现在你的轨迹里:表格那一份的键是 **table:0 / table:1**(块序号),
值是整张表的 JSON —— **第一行是列名**,其余每行一条,列序与你派出去时一致。
照它 write 进 \`quote_alternative.shortlist\` 与 \`quote_alternative.benchmark\`,
并把对标表条数记进 benchmark_total。**写完就 finish T_select,别再发第二张单** —— 他已经答过了。
**人环两次,不多不少**:FAE 交回**一张选型执行单**(工作单里一颗待选型芯片一页,
右上角翻页逐颗定案,必填缺着按不下提交)、放行时 FAE 签字。
【提交放行】对标表有行了 —— **先 start T_release**(人工激活:放行触达世界,必须按一下),
然后 submit,BD 验收。终局唯放行:你不能自己宣布完成。
【一路上要说话】负责人正看着这一单。**每一拍都带一句 say** —— 说业务的话:
哪几颗料要替代、替代料查出来什么行情、这单接下来往哪走;
不说「归一化」「写草稿 N 行」这类系统词。到了要找人的那一拍,先说清这一轮办完了什么、
为什么现在需要他。别每拍一个腔调复读。
【一路上要说话】负责人正看着这一单。每一拍带一句 say,说**业务的话**:
哪颗料要替代、你打算怎么办、FAE 回了什么结论、这一单往哪走。
一两句、口语,像同事扭头说一声。别报「写草稿 2 行」这种系统词 ——
他明天要拿这些话去跟客户讲。`;
【怎么把一项干完 · 一拍连做】一个计划项常要两三个动作才交得出成果。write 和 tool
都**不会**自动把那一项收掉 —— 都做完了,用 finish 宣告完成。动作排进同一拍的 actions
串里一口气给,别拆拍。只有两处必须停拍:调工具要看出参(tool 放串尾),
发卡 / 提交会挂起(只能是串尾)。**常路 6-9 拍就该收口** —— 选型只有一张卡,颗数多不多不加拍。
§Z 结案:FAE 在验收单上签字,系统把草稿合并成正式清单并发出 roster 口的载荷 ——
清单整份交给下游,下游按自己的口径归一需要的数据。`;
/**
* 步骤剧本 —— 与 A1 同一套机制:`doneWhen` 为真,引擎自己收步、**不问模型**。
*
* 只有一条真活。放行那一项不给 `doneWhen` —— 它触达世界,必须人按一下(铁律③)。
* 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。
*/
export const ALTERNATIVE_STEPS = [
{
item: 'T_select',
intent: '派一张执行单给 FAE,收回初筛清单与替代对标结论,并结算汇总数',
writes: ['quote_alternative.shortlist', 'quote_alternative.benchmark', 'quote_alternative.verdict'],
reads: [],
item: 'S1_normalize',
intent: '把上游受理记录(或单飞来件)整份归一成选型底表,C 类行标待选型',
writes: ['quote_alternative.list', 'quote_alternative.verdict'],
/*
* `records` 是上游整包落地行 —— 不声明的话它会被整表段的截断刀砍掉
* (那一段按字符数截,上游一单的落地行远超刀口,砍掉的恰是 C 类行)。
* 声明了的键**置前且不参与截断**。单飞起单没有这个键,声明也无副作用。
*/
reads: ['records'],
/* 归一与 FAE 选型期间摆**选型工作单**;判稳转正后才换收口那张清单 */
view: 'alt-worksheet',
tools: [],
/*
* 对标表有行了才算办完。**不能写 `benchmark_total != null`** ——
* S-FEEL 里未绑定的名字 `!= null` 求值为 true,那一步开局即被收掉(A1 踩过,测里拦下的)。
* 归一完才算办完:两边对上 —— alt_line_total 自报、rows 基座现数。
* **不能写 `records != null`**:S-FEEL 未绑定名字 `!= null` 为 true,开局即收(A1 踩过)。
*/
doneWhen: 'benchmark_total > 0',
guide: `**照这个骨架发,一个字段都别少**(prose 描述不管用,直接抄):
{"kind":"human","itemId":"T_select","workOrder":"execution","card":{
"title":"…","ask":"这颗料原型号停产,请挑替代并给对标结论",
"basis":[{"k":"…","v":"…"}],
"blocks":[
{"t":"table","title":"初筛清单","cols":["原型号","候选型号","品牌","生命周期"],"editable":true,
"rows":[["LM358DR","LM358BIDR","",""],["LM358DR","TLV9062IDR","",""]]},
{"t":"table","title":"替代对标表","cols":["候选型号","替代等级","参数差异项","差异风险","验证建议","FAE签字"],"editable":true,
"rows":[["","","","","",""]]}
]}}
**rows 必须有行**:一条料一行,不知道的值给空字符串(界面自己标红「待填写」)。
只给 cols 不给 rows,人看到的是一排表头加一片空白,一个格子都填不了(实测栽过)。
**blocks 少了整张卡就废了** —— 基座会用当下局面兜底成一句「需要你判断下一步」,
人拿到一张没有表的空卡,这一步等于没派(实测栽过)。
doneWhen: 'alt_line_total > 0 and quote_alternative_list_rows = alt_line_total',
guide: `第一件事:把这一单的**选型底表**落出来 —— 整份承继,不是只有 C 类。
工作区这一步摆的是**替代选型工作单**(逐颗料的选型档案与填回区),你落的行就是它的血肉。
**候选是你提的,不是让 FAE 从零想** —— 初筛清单每个原型号先摆 **2~3 行候选**
(原型号 + 候选型号两列你填,你认得这些料;品牌与生命周期留空让他核)。
判据「2~3 颗分级候选经确认」机检的就是候选条数,只给一行**过不了门**。
替代对标表照候选逐行起,basis 摆上原型号、芯片类型、客户特殊要求(**裸卡基座拒发**)。
【料从哪儿来】
· 流内(任务数据里有 \`records\` 数组):那是上游全部落地行**混在一起**的一份 ——
同一颗料会出现两三次:带 eau/brand/target_price 的是**受理记录行**(以它为准),
带 basis/history_days/market_stable 的是分类表行(它的 type_tag 与受理记录一致),
带 line_total/type_a_total 这类总数的是结论行(不是料,别落进清单)。
**按 pn 归并**:一颗料归一成清单里的一行,列取受理记录行的值;
type_tag 照抄成本清单的 **orig_type(原标)与 type_tag(现标)两列同一个值**。
· 单飞(没有 \`records\`):照入参与正文归一 —— 有型号落型号;只给芯片类型与参数、
没有具体 PN 的,\`pn\` 写「类型-场景」需求代号(如「运放-车规-01」),
这类需求本来就是来选型的,orig_type 与 type_tag 都写 C。
他提交后回传值进你的轨迹:表格的键是 **table:0 / table:1**,值是整张表的 JSON,
**第一行是列名**。照它 write 进 \`shortlist\` 与 \`benchmark\`,
**同一拍再写一行 \`quote_alternative.verdict\`**(验收机检读的就是它):
· candidate_total = 初筛清单条数(要 ≥2);
· benchmark_total = 对标表条数;
· undocumented_total = 对标表里「参数差异项」或「差异风险」还空着的条数(要 = 0);
· recommend_pn 主推候选、summary 一句人话结论。
这三个数写不对,submit 会被门挡回来 —— **它们不是台账装饰,是判据本身**。
**写完就 finish,别再发第二张单** —— 他已经答过了。
**每一行都要带 \`rfq_ref\`** —— 它是这一单的单标识(起单入参里就有,原样抄进去)。
台账主键第一列就是它:不写,这一行会和**别的单**同料号的那一行撞主键,把人家覆盖掉
(实测栽过:A4 的报价草案主键只有 pn,跑四轮只剩最后一轮的 3 行)。`,
【单号 rfq_ref】入参有 \`rfqRef\` 就**原样照抄**;没有就从主题/正文里认(RFQ-xxxx);
认不出才造一个稳定案号「ALT-<型号或类型>」—— 它是本活动所有台账行的主键,
造完这一单从头用到尾,**不许中途换写法**(换一个写法就是新开一行)。
造号**只从本单来件取材,不许从台账里已有的别单单号里挑** —— 正式表里躺着的
单号是别人的单,借它的号等于把两单的行混进同一个案卷。
【逐列怎么落】写 \`quote_alternative.list\`,一颗料一行,一拍写完(rows 收数组):
pn(**一个字符不许改**)、customer_pn、category、brand、eau、target_price、lead_time、
**remark(客户备注:预授权替代、认证要求、禁用约束 —— 选型的第一手依据,一个字别丢)**
照上游值抄,没有的留空字符串(**绝不写 0 顶空**);orig_type / type_tag 照抄原标
(**只写 A、B、C 三个大写字母之一**,写别的基座数不出来);
C 类行把「为什么是 C」写进 basis(从分类表行的 basis 抄,或客户点名替代的原话);
alt_pn / alt_brand / alt_grade / alt_diff / alt_risk / alt_verify / alt_note
**全部留空** —— 那是 FAE 的定案,你不许编。
【同一拍记账】写 \`quote_alternative.verdict\`(一单一行):
rfq_ref、customer_code / customer_name(顶层摊平字段里有就抄,没有留空)、
**alt_line_total** = 清单行数、**c_total** = C 类条数。
写完 finish 这一项 —— 引擎按「自报条数 = 基座行数」收步,少拆多写都过不去。`,
},
{
item: 'S2_fill',
intent: '一张选型执行单交 FAE:工作单一颗一页,定案(型号/品牌/等级)逐页填回',
writes: ['quote_alternative.verdict'],
reads: ['quote_alternative.list'],
view: 'alt-worksheet',
tools: [],
/*
* 不给 doneWhen —— 发卡触达真人(manualActivation),引擎不替人按;
* 「FAE 选完填回来了」也判不出来:它是人环,不是台账里能自己变出来的数。
*/
guide: `底表归一好了,**该 FAE 出场了**。规矩(2026-08-02 用户定):
**一张执行单,工作单里一颗待选型芯片一页** —— 他在右上角翻页逐颗定案,
定案三格(型号/品牌/等级)是每页的必填,缺着按不下提交(系统的闸看着)。
【怎么发】照这个骨架发一张卡(它标了人工激活:发卡就算启动,不必单发 start;
引擎也不会替你收 —— 人交回后要你自己 finish)。卡面是纯文本,
steps/standard 里**别写星号记号**:
{"kind":"human","itemId":"S2_fill","workOrder":"execution","card":{
"title":"替代选型:<单号> 待选型 <N> 颗",
"ask":"这几颗料要替代:请照工作单逐页的选型档案在系统外完成选型,把定案填进各页右栏的「替代定案」(型号/品牌/等级是必填,缺着按不下提交),填完保存,回这张卡提交;确实找不到替代的,在那一页的选型备注写明缘由后交回。",
"steps":["工作单一颗一页,右上角翻页;每页「替代定案」栏里给出替代料型号与品牌,替代等级点三档之一:P2P(同封装同引脚可直换)/ FUNC(功能兼容,需外围电路或 layout 调整)/ OTHER(部分兼容,须打样验证)",
"P2P 必须逐项核对过五样:封装尺寸、引脚定义、电气参数、温度等级、认证 —— 以原厂官网规格书为准,同封装不等于同引脚;核对过哪几项写进参数差异项",
"FUNC 或 OTHER 的,参数差异项与差异风险写清差在哪、要动什么;验证建议写明要不要打样、验什么",
"客户备注里已预授权的替代优先采信;点名禁用的品牌不得作候选;确实找不到替代:在那一页的选型备注写明缘由(那也是一个答案);填完保存,回这张卡提交"],
"standard":"每一颗:型号、品牌、等级都有且来源可溯(三格必填,闸看着);或备注写明替代未果的缘由。交回后判稳定标由系统接着办。",
"basis":[{"k":"<待选型 PN 之一>","v":"<为什么是 C + 客户约束(预授权/认证/禁用)>"}]}}
· \`basis\` 逐颗列待选型的料(几颗写几条);标题带单号与颗数。
· **卡上不摆格子**(填写全在工作区):定案填在工作单各页的「替代定案」栏里,
不往卡里塞 \`fields\` 或 \`blocks\`。
发完卡挂起,这一拍到此为止。**这张卡一单只发一次** —— 必填有提交闸兜着,
卡回来时每颗要么三格齐、要么备注写明未果。
【人交回之后】**他的定案已经在台账里了** —— 他在工作单上按「保存」那一刻,
基座就把那几格落进了草稿层(该由人亲手落下的,不经你的手)。
**不许再把那几格 write 一遍** —— 值相同的写入被「写了等于没写」拒(实测)。
这一拍只做两件事,然后 finish:
① 清点 alt_pn 有值的颗数写 **filled_total** 进 verdict(全部未果就是 0,照实写);
② finish 这一项,**这一拍到此为止** —— 判稳、转标、提交都不是这一拍的活:
转标还没做,submit 排进这一串必被「判据没过」拒回(实测白挨一次)。
**这一拍也不许调 crm.history-price-read** —— 查行情是判稳那一步(下一步)开头的活;
实测提前查过一次:查完没处落,连着四拍把同一行原样重写、四次被
「写了等于没写」拒回,白烧四拍才想起该 finish。`,
},
{
item: 'S3_reclass',
intent: '替代料批量查历史行情:稳转 A、不稳转 B;清单转正,立去向旗',
writes: ['quote_alternative.list', 'quote_alternative.verdict'],
reads: ['quote_alternative.list'],
view: 'alt-roster',
tools: ['crm.history-price-read'],
/*
* **收步判据与验收判据同源**:C 类清零(基座按值现数)才算这一步办完 ——
* 收步比验收松,松出来的那一截就是返工(A1 的账)。
* route_decided_total 是本步显式写下的记账数(布尔旗分不清「没写」与「写了 false」)。
*/
doneWhen: 'quote_alternative_list_type_tag_c = 0 and route_decided_total > 0',
guide: `FAE 填回来了,现在把清单**转正**:每颗替代料判稳、定标,清单从「受理记录」
变成「询价受理清单」。
【第一拍:查行情】把所有原标 C 行的 **\`alt_pn\` 那一列的值**(替代料的料号)收进
一个数组,调 \`crm.history-price-read\` 一次传完(参数 pns 收数组)。
**判稳的行情只有这一条来路 —— 这一拍亲自查 alt_pn。**两条近路都不许走:
· **不许传原 PN**(pn 列是被替代的旧料,查它查到的是旧料的行情);
· **不许拿上游带进来的数顶替**:入参里若躺着 history_days / market_stable /
lifecycle,那是 A1 当时查**原 PN** 得的账 —— 跟这颗**替代料**稳不稳没有关系。
实测抄过一次近路:上游写着 9999,模型没调工具就把替代料判成 B,
而那颗替代料库里明明有 43 天的稳定历史,整单去向就此走岔。
调完这一拍就结束(工具出参要下一拍才看得见)。
【第二拍:定标 + 记账 + 收步,一拍连做】
① **规则写死,不留裁量**(与三分类 P-03 的 A/B 两行同一把尺):
historyDays ≤ 90 且 marketStable = true → **type_tag 转 A**;否则 → **转 B**。
工具回的 lifecycle 若是 EOL/缺货/要求替代 —— 那说明替代料选错了,**不转标**,
当成替代未果处置(见③)。
② 逐行写回 \`quote_alternative.list\`(按 rfq_ref + pn 对行,主键一个字符不许改):
type_tag(A 或 B,**照规则写,不许臆断**)、history_days(工具出参照抄)、
basis(一句依据:「替代料 <alt_pn> 历史价 <N> 天·行情稳 → 转 A」这样)。
**先对号再用**:工具出参每行带着查询的 pn —— 那个 pn 必须等于这一行的 alt_pn,
对不上就是第一拍传错了列,回去重查,别把别人的行情安到这颗替代料头上。
orig_type **不动** —— 原标是留痕,转的是现标。
③ **替代料留空(FAE 判未果)的 C 行**:
· 选型备注写了缘由 → 用 \`drop\` 把那一行撤出本单草稿(主键照 \`_keys\` 名单**原样粘**),
把撤了哪几颗、为什么写进 verdict.skipped_note,并把 alt_line_total 改成撤后的行数
(清单条数跟着改对,别让「条数 5 而表里 4 行」把归一那步的判据顶翻);
· 备注也空着 → 发一张**决策卡(decide)**问负责人:「这颗料替代未果,剔出本轮
还是退回整单」两个选项摆出来,人点哪个照哪个办。**只问这一次**
(补全卡已退役 —— 二选一是处置,本就该用决策卡)。
④ 写 verdict:converted_a_total / converted_b_total(转成 A/B 各几条,照②的实数)、
**need_sourcing**(清单里还有 B 类 → true,全 A → false;照基座读数
quote_alternative_list_type_tag_b 的正负写,别凭记忆)、**route_decided_total 记 1**、
summary 一句人话(几颗替代、转成什么、往哪走)。
⑤ finish 这一项,**这一拍到此为止**。引擎按「C 类清零 + 去向定过」收步 ——
有一颗 C 没处置,这一步收不掉,出路只有三条:转标(填了替代料)、
剔除(未果且有说明)、问人(未果且没说明)。
**别把 submit 排进这一串**:放行那一步要先把 L3 裁量裁过才轮得到 submit,
跳过它会被当场拒回(实测白挨过一次)。
**去向不用你选,也不用人选**:契约给两个出口各写了判据(还有没有 B 类),
放行那一刻基座按序求值自己定;need_sourcing 那枚旗是给流程包的边读的,
两处读的都是同一个事实 —— 你只负责把标转对。`,
},
{
item: 'T_release',
intent: '对标结论已齐,提交放行交 BD 验收',
view: 'alt-roster',
intent: '清单已转正,提交放行交 FAE 验收',
writes: [],
reads: [],
tools: [],
// 放行触达世界 —— 引擎不自动收它(所以这里不给 doneWhen)
guide: `对标表有行了 —— **先 start T_release**(它标了人工激活),然后 submit,BD 验收。
/* 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 doneWhen) */
guide: `清单整份转正、去向已定 —— 可以走放行了(T_release 人工激活:
动它就算启动,不必单发 start)。先把裁量判据裁掉,然后 submit,同一拍连做:
· judge criterion="替代料来源可溯且选型说明已留:以原厂官网规格书或客户预授权为准,每颗替代写明凭什么" ——
看清单里每颗替代料的 basis 与选型备注:都写了依据就 satisfied=true;
有替代料说不出来源的,satisfied=**false**,rationale 逐颗写清哪几颗没依据,
交签字的人定。**裁成不满足照样能提交** —— 理由连着卡一起交给他,那正是这条判据的用处。
criterion 照判据名**整句原样抄**,写别的名字对不上。
**去向不用你裁**:两个出口各写了判据,放行那一刻基座按序求值、首个命中即出。
终局唯放行:你不能自己宣布完成。`,
},
];
/**
* 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段;
* 放行那一格由基座统一加(CLOSING_PHASE),人看到的是 x/4。
*/
export const ALTERNATIVE_PHASES = [
{ name: '归一清单', items: ['S1_normalize'] },
{ name: '选型填回', items: ['S2_fill'] },
{ name: '判稳定标', items: ['S3_reclass'] },
];
+12 -58
View File
@@ -1,62 +1,16 @@
/**
* 统一报价 A2 · 替代选型 —— 活动包代码入口(ADR-004)。
* loop 活动:调度权归 AI;本文件只做视图流控与「差异没核清不放行」的把关呈现。
* 统一报价 A2 · 替代选型 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
*
* **这个活动没有 `run`,不是缺件。**
*
* 案例活动的推进由 Loop 驱动,行为全部写在清单里:案例模型(CMMN 子集)+ 案例说明书 +
* 步骤剧本 + 两条技能 + 台账声明 + 一张 HTML 报告模版。「清单机器可读,
* 系统不执行代码即知包为何物」。上一版这里有 62 行 `run()` —— loop 活动根本不会调它,
* 还在读一个契约里早已退场的 `originalPn`:摆一个空转的 run 去假装有实现,更坏。
*
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 —— 它是**被静态审扫描的产物文本**
* (查 import 与全局引用,证明活动包零环境能力)。
*/
import { defineActivity } from '@sdk';
import type { ActivityBase, ActivityOutcome } from '@sdk';
const SHORTLIST = 'quote_alternative.shortlist';
const BENCHMARK = 'quote_alternative.benchmark';
export default defineActivity({
name: 'quote-alternative',
async run(base: ActivityBase): Promise<ActivityOutcome> {
const shortlist = await base.ledger.read(SHORTLIST);
const benchmark = await base.ledger.read(BENCHMARK);
// 差异没核清的不放行 —— 禁区的机检形态
const unclear = benchmark.filter((r) => String(r['risk'] ?? '').trim() === '');
await base.interaction.pushView([
{
t: 'kv',
title: '选型进展',
items: [
{ k: '原型号', v: String(base.contract.data['originalPn'] ?? '') },
{ k: '初筛候选', v: `${shortlist.length} 颗` },
{ k: '已对标', v: `${benchmark.length} 颗` },
{ k: '差异未核清', v: `${unclear.length} 颗` },
],
},
{
t: 'cards',
title: '候选替代',
items: benchmark.map((r) => ({
tag: String(r['grade'] ?? '待定级'),
title: String(r['candidate_pn'] ?? ''),
swap: { from: String(r['original_pn'] ?? ''), to: String(r['candidate_pn'] ?? '') },
big: String(r['grade'] ?? '—'),
bigLabel: '替代等级',
note: String(r['risk'] ?? '差异风险未写'),
tone: String(r['risk'] ?? '').trim() === '' ? 'danger' : 'ok',
})),
},
]);
if (benchmark.length < 2) {
await base.interaction.say(
shortlist.length === 0 ? '尚未撒网,先拉初筛清单。' : `已初筛 ${shortlist.length} 颗,继续逐条核验分级。`,
'conclusion',
);
return { kind: 'await', reason: '候选不足 2 颗' };
}
if (unclear.length > 0) {
await base.interaction.say(`${unclear.length} 颗候选的差异风险未写清 —— 差异没核清的不放行。`, 'criterion');
return { kind: 'await', reason: '差异风险未核清' };
}
await base.interaction.say('候选已分级、差异与风险已写清,可签字交回发起方。', 'criterion');
return { kind: 'submit', deliverable: 'benchmark-table', rationale: '2 颗以上分级候选,差异与风险齐备' };
},
});
export default defineActivity({ name: 'quote-alternative' });
+402 -124
View File
@@ -1,127 +1,222 @@
/**
* 活动包清单 —— 统一报价 A2 · 替代选型(**案例活动 · loop**)。
* 活动包清单 —— 统一报价 A2 · 替代选型(案例活动 · MVP 简化形)。
*
* 业务素材:docs/MVP示例工作流.html §1.6。
* `▶需拍板`(签字交出)→ manualActivation + 放行;`✋客户改需求` → userEventListener;
* `⏱时限过半` → 时间监听(**时限必配**)。
* 业务素材:`docs/MVP示例工作流.html` §1.5,按 2026-08-02 用户改盘的口径归一:
* **A2 是复杂活动(需要大规模业务建模),MVP 期间不展开** —— 只做一张精美的
* HTML 验收报告 + 一张执行单:AI 挑出 C 类、组好清单交 FAE 在系统外选型填回,
* 替代料查库内行情判稳(稳转 A / 不稳转 B),询价受理记录就此转换成
* **询价受理清单**传给下游。提交就是输出。
*
* ── 这里**去掉了 `GRADE_DMN`(替代等级判定表)**(2026-08-02)────────────────
* 它声明着三个入参(pinCompatible / packageSame / paramDelta),而台账上没有任何列
* 承载它们、剧本里也没有一步调它 —— **一张没有求值器的判定表不是资产,是错觉**:
* 看清单的人以为等级是算出来的,实际全程没人算过。MVP 口径下替代等级的判断
* 整个在 FAE 脑子里(系统外),系统不假装接得住;判稳转标那一步的规则
* (≤90 天且稳 → A,否则 → B)写死在剧本里、依据逐行落表,可回核。
*/
import type { ActivityManifest, DmnModel } from '@contracts';
import { ALTERNATIVE_SKILLS } from './skills.ts';
import { ALTERNATIVE_CASE } from './views/report.ts';
import { activityIdSchema } from '@contracts';
import { ALTERNATIVE_STEPS, CMMN_XML, NARRATIVE } from './case-model.ts';
import { ALTERNATIVE_SKILLS } from './skills.ts';
import { ALT_ROSTER, ALT_WORKSHEET } from './views/report.ts';
import { ALTERNATIVE_PHASES, ALTERNATIVE_STEPS, CMMN_XML, NARRATIVE } from './case-model.ts';
const DOMAIN = 'quote_alternative';
const D = 'quote_alternative';
const ACTIVITY_ID = activityIdSchema.parse('quote-alternative');
/** 替代等级 —— 把「装得下的判断」从 LLM 手里拿走 */
export const GRADE_DMN: DmnModel = {
drd: [{ decision: 'alt-grade', requires: [], inputs: ['pinCompatible', 'packageSame', 'paramDelta'] }],
tables: [
{
id: 'alt-grade',
label: '替代等级判定',
hitPolicy: 'FIRST',
inputs: [
{ name: 'pinCompatible', expression: 'pinCompatible' },
{ name: 'packageSame', expression: 'packageSame' },
{ name: 'paramDelta', expression: 'paramDelta' },
],
outputs: [{ name: 'grade' }, { name: 'effort' }],
rules: [
{ id: 'g-p2p', when: ['true', 'true', '<= 0.05'], then: ['P2P', '核确免改动'] },
{ id: 'g-func', when: ['-', '-', '<= 0.20'], then: ['功能兼容', '评外围电路与 layout'] },
{ id: 'g-other', when: ['-', '-', '-'], then: ['其他', '须验证'] },
],
},
],
};
/** MVP 口径:A2 没有判定表(见文件头注释)—— 判稳规则写死在剧本,依据逐行落表 */
export const ALT_DMN: DmnModel = { drd: [], tables: [] };
export const manifest: ActivityManifest = {
identity: {
activityId: ACTIVITY_ID,
name: 'A2 · 替代选型',
packageVersion: '2.0.0',
packageVersion: '4.16.0',
category: '统一报价',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-quote-alternative',
},
/* ── 契约七件 ────────────────────────────────────────────────── */
contract: {
activityId: ACTIVITY_ID,
version: '0.1.0',
version: '1.0.0',
input: {
/*
* 入参对齐**上游真发的东西**(2026-07-31,MVP 第一轮跑出来的)。
* **宽进到底:一个必填都不设**(2026-08-02 用户当场定:「活动是宽进严出,
* 输入接受任何类型」「活动的输入契约是在活动内部归一+补全的」)。
*
* A1 的 `to-alternative` 口发的是 `altRequests`(一批替代评估请求,一条料一行),
* 不是单个 `originalPn` —— 而这里从前把 `originalPn` 写成必填,
* 于是流程走到 A2 就停在补全卡上:「缺 originalPn」。
* 上游发一批,下游要一个,这是**契约对不上**,不是数据缺失。
* 来路本就有两种形状:流内(上游放行载荷 —— rfqRef + records + 一堆摊平字段)
* 与单飞(一段需求文字,可能连具体型号都没有)。门口拦谁都不对 ——
* 归一是第一个计划项的活,缺什么在活动内问。
*
* 一单可能要替代好几颗料,`altRequests` 才是那件事的真形状。
* 上一版必填 `rfqRef + altRequests`:而 `alt_request` 表 2026-08-02 已在 A1 退休,
* 那个键只是载荷数组通配规则下的空壳(内容与 records 完全相同)——
* 必填一个不存在的东西,单飞起单永远停在补全卡上。
*/
data: {
type: 'object',
required: ['rfqRef', 'altRequests'],
required: [],
properties: {
rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' },
altRequests: { type: 'array', title: '替代评估请求(原 PN + 替代要求)' },
originalPn: { type: 'string', title: '原型号(单条时的便捷入口)' },
chipType: { type: 'string' },
scenario: { type: 'string' },
rfqRef: { type: 'string', title: '询价单号' },
records: { 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: '读出来的文本' },
note: { type: 'string', title: '读取说明' },
},
},
},
},
},
messageAllowed: true,
messageHint: '客户点名的替代授权、禁用品牌、场景约束等写这里',
},
output: {
/*
* 出口载荷**从落地事实推导**:标量字段必须有台账列承载。
* `needSourcing` ← verdict.need_sourcing —— 流程包 E5(仍需寻源 → A3)读的
* 就是它;上一版声明了这个键而台账没有承载列,**E5 从来没有成立过**:
* 选完型仍要寻价的单一律被默认边送去定价(2026-08-02 探查实证)。
*/
mandatory: [
{
name: 'benchmark',
payload: { type: 'object', required: ['rfqRef', 'rows'], properties: {
/*
* **下游那条边读的就是它**(流程包 E5:A2→A3 `needSourcing = true`)。
* 从前这个口只发对标表,而边上写着 needSourcing —— 那条边永远不成立,
* 选完型再要寻价的单子出了 A2 就没路可走(与 A1 当年的 G1 全流死路同一种错)。
*/
needSourcing: { type: 'boolean', title: '选定的替代料仍需寻价' }, rfqRef: { type: 'string' }, rows: { type: 'array' } } },
description: '替代对标表(已签字)',
name: 'roster',
payload: {
type: 'object',
required: ['rfqRef', 'rows', 'needSourcing'],
properties: {
rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' },
rows: { type: 'array', title: '询价受理清单(C 类已转 A/B)' },
needSourcing: { type: 'boolean', title: '仍需寻源(清单里还有 B 类)' },
},
},
description: '询价受理清单(C 类已转 A/B)+ 寻源旗',
},
],
/*
* **两条出边 = 一个 XOR 选择组**(对齐流程包 E5/E6)。
* 每一口都写了 `when`(读基座按值现数的 tally 读数),这一单往哪走是**算出来的**:
* 放行时基座按序求值首个命中即出,卡上只如实告诉人走哪一口、凭哪一条。
*/
selective: [
{
group: 'alt-route',
ports: [
{
name: 'to-sourcing',
payload: {
type: 'object',
required: ['rfqRef', 'rows'],
properties: {
rfqRef: { type: 'string', title: '询价单号' },
rows: { type: 'array', title: '询价受理清单(含待补价的 B 类)' },
},
},
description: '清单里仍有 B 类 —— 去寻源补货源价格',
when: 'quote_alternative_list_type_tag_b > 0',
},
{
name: 'to-pricing',
payload: {
type: 'object',
required: ['rfqRef', 'rows'],
properties: {
rfqRef: { type: 'string', title: '询价单号' },
rows: { type: 'array', title: '询价受理清单(全 A 类)' },
},
},
description: '全 A 类:行情完备 —— 直通成本定价',
when: 'quote_alternative_list_type_tag_b = 0',
},
],
},
],
selective: [],
},
/*
* 必交付物**必须给 `ledger` 地址**(2026-07-31 修)。
* 不给的话预检退回 `scope[key]` 去找 —— key 是 `benchmark-table` 这样的短横线别名,
* 任务视图里永远没有这个键,于是「必交付物缺一不得提交放行」永远拒,
* 活办完了也放不了行,连拒五拍熔断(实测 A2 死于此)。
* 必交付物只有一件:**询价受理清单**。选型结论(verdict)是它的结论栏,
* 不是第二份成果 —— `ledgers` 说清这份成果由哪几张表承载(A1 的账:
* 各占一条,人在成果面上会看到两样东西)。
*/
requiredDeliverables: [
{ key: 'benchmark-table', label: '替代对标表(FAE 签字)', ledger: `${DOMAIN}.benchmark` },
{
key: 'alt-roster',
label: '询价受理清单(C 类已转 A/B)',
ledger: `${D}.list`,
/*
* 验收页上让 FAE 就地改的那几列 —— 恰是**他的职权面**:
* 替代料三列是他填的,现标 `type_tag` 是判稳规则出的 ——
* 但算得对不对该由人说了算,他改一颗料的标就是在推翻一次判定(A1 的账:
* 不在名单里的列压根不进写回载荷,人改了等于没改)。
* 去向不在其列:它是现标的函数,改标去向就跟着变。
*/
editableAtRelease: ['alt_pn', 'alt_brand', 'alt_grade', 'alt_diff', 'alt_risk', 'alt_verify', 'alt_note', 'type_tag'],
ledgers: [`${D}.list`, `${D}.verdict`],
},
],
/*
* 机检谓词读的是 `verdict` 那张**汇总表**的列(照 A1 的口径)。
*
* 上一版写的是 `candidatesConfirmed >= 2` 与 `undocumentedDiff = 0` ——
* 这两个名字**台账里根本没有**(既不是案卷项,也不是任何一张表的列)。
* S-FEEL 拿未绑定的名字比大小求值为假,于是这两条判据**永远达不了标**:
* Agent 把活办得再干净也过不了 submit 门,连拒五拍熔断 —— 实测 A2 就死在这儿。
* 判据的名字必须落在台账上,否则「机检」是一句空话(2026-07-31 销账)。
* 验收判据 —— **机检(L1/L2)只读基座数得出来的数**(A1 被违反三次的那条纪律):
* `quote_alternative_list_rows` 基座数行数、`_type_tag_a/b/c` 基座按取值域现数。
* 上一版两条 L2 读的是活动自报的 verdict 列(candidate_total / undocumented_total)——
* 那不是门,是「改哪个数能过」的提示。
*/
acceptanceCriteria: [
{ key: 'candidates', label: '2~3 颗分级候选经确认', level: 'L2', predicate: 'candidate_total >= 2' },
{ key: 'diff-clear', label: '差异项与风险逐项写清', level: 'L2', predicate: 'undocumented_total = 0' },
{ key: 'source-traceable', label: '每项数据来源可溯(以原厂官网规格书为准)', level: 'L3' },
{ key: 'fae-sign', label: 'FAE 签字放行', level: 'L5' },
{
key: 'roster-complete',
label: '清单齐:上游受理记录逐条承继,说了几条清单里就躺着几行',
level: 'L1',
predicate: 'alt_line_total > 0 and quote_alternative_list_rows = alt_line_total',
},
{
key: 'no-c-left',
label: '选型办完:每颗待选型的 C 类料都已填回替代并转成 A 或 B,或经确认剔除',
level: 'L1',
predicate: 'quote_alternative_list_rows > 0 and quote_alternative_list_type_tag_c = 0',
},
{
key: 'tag-conserved',
label: '定标守恒:清单每一行都有现标,A 类加 B 类恰等于总行数',
level: 'L2',
predicate:
'quote_alternative_list_type_tag_a + quote_alternative_list_type_tag_b = quote_alternative_list_rows',
},
/*
* 来源可溯是**裁量**(L3):替代凭什么、说明留没留,机器数不出来 ——
* 别假装机器判得了。裁成不满足照样能提交,理由连着卡交给签字的人。
* 这句 label 与剧本里 judge 的 criterion **必须逐字一致**(judge 按整句对名)。
*/
{
key: 'alt-traceable',
label: '替代料来源可溯且选型说明已留:以原厂官网规格书或客户预授权为准,每颗替代写明凭什么',
level: 'L3',
},
/* L5 是责任关:那张卡本身就是这道关,卡面上不摆它(基座统一滤) */
{ key: 'fae-release', label: 'FAE 放行:签下去这份清单即转正并流向下游', level: 'L5' },
],
/* 去向不给人改(现标的函数);人在验收页改的是成果本身(editableAtRelease) */
revisableAtRelease: [],
prohibitions: [
{ key: 'no-unclear-diff', statement: '差异没核清的不放行' },
{ key: 'no-banned', statement: '客户技术画像禁用清单里的型号不得作候选' },
{ key: 'no-fake-alt', statement: '没有把握的替代不填:宁可在选型备注写明替代未果,也不编一个候选顶上' },
{ key: 'no-banned', statement: '客户点名禁用的品牌与型号不得作替代' },
{ key: 'no-tag-fudge', statement: '不许为过判据改标:现标只能来自判稳规则,或人在件上的亲手修改' },
],
authorization: {
discretion: [{ key: 'widen-search', statement: '候选不足 2~3 颗时可放宽参数或换角度重新撒网' }],
discretion: [
{ key: 'widen-search', statement: 'FAE 可放宽参数或换角度重新选型;是否接受非原厂品牌、参数降级必须问客户,不得替客户决定' },
],
/* 空着不是省事:这个字段全仓没有一个消费者(债 ⑨),写了也不会被执行 */
preauthorizedActions: [],
/* 一颗芯片一张选型单(按顺序派)—— 打扰上限按料数给:MVP 案例 ≤3 颗 + 放行 + 余量 */
humanCallBudget: 6,
},
resources: {
@@ -129,76 +224,121 @@ export const manifest: ActivityManifest = {
skills: ALTERNATIVE_SKILLS.map((s) => s.id),
models: ['deepseek-v4-flash'],
roles: ['FAE'],
budget: { steps: 100, tokens: 260_000, activeMinutes: 360, humanCalls: 6 },
/*
* FAE 在系统外选型,回来才继续 —— activeMinutes 给足一个工作日的等待;
* token 按实测估:每颗料一张卡各占两三拍,常路(1-2 颗)8-12 拍,留一倍余量。
*/
budget: { steps: 60, tokens: 300_000, activeMinutes: 480, humanCalls: 6 },
},
},
/* ── 声明 ────────────────────────────────────────────────────── */
declarations: {
models: ['deepseek-v4-flash'],
tools: [{ name: 'crm.history-price-read' }],
ledger: [
/*
* ── 这里**去掉了 `shortlist` / `benchmark` / `request` 三张表**(2026-08-02)──
*
* MVP 口径下初筛清单与对标表都不在系统内产出 —— 选型的实质工作在 FAE
* 系统外,系统收的是**结论**(替代料是哪颗、凭什么),落在清单行的三列上。
* 撤掉声明不 drop 物理表(物化只增不破坏),历史行还在库里。
*/
{
domain: DOMAIN,
object: 'shortlist',
domain: D,
object: 'list',
kind: 'record',
caseKey: 'rfq_ref',
label: '初筛清单',
label: '询价受理清单',
fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'original_pn', type: 'text', primaryKey: true, masked: false, label: '原型号' },
{ name: 'candidate_pn', type: 'text', primaryKey: true, masked: false, label: '候选型号' },
{ name: 'brand', type: 'text', primaryKey: false, masked: false, label: '品牌' },
{ name: 'lifecycle', type: 'text', primaryKey: false, masked: false, label: '生命周期' },
],
},
{
domain: DOMAIN,
object: 'benchmark',
kind: 'record',
caseKey: 'rfq_ref',
label: '替代对标表',
fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'original_pn', type: 'text', primaryKey: true, masked: false },
{ name: 'candidate_pn', type: 'text', primaryKey: true, masked: false },
{ name: 'grade', type: 'text', primaryKey: false, masked: false, label: '替代等级' },
{ name: 'param_diff', type: 'text', primaryKey: false, masked: false, label: '参数差异项' },
{ name: 'risk', type: 'text', primaryKey: false, masked: false, label: '差异风险' },
{ name: 'verify_advice', type: 'text', primaryKey: false, masked: false, label: '验证建议' },
{ name: 'signed_by', type: 'text', primaryKey: false, masked: false, label: 'FAE 签字' },
{ name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' },
{ 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: 'target_price', type: 'number', primaryKey: false, masked: false, label: '目标价' },
{ name: 'lead_time', type: 'text', primaryKey: false, masked: false, label: '交期' },
/* 客户备注承继自上游 —— 预授权替代、认证要求、禁用约束都在这一格,是选型的第一手依据 */
{ name: 'remark', type: 'text', primaryKey: false, masked: false, label: '客户备注(约束/预授权)' },
/*
* **原标与现标两列同名不同物**:orig_type 是上游受理时的三分类(留痕,本活动不改);
* type_tag 是本活动的产物(C 类判稳后转 A/B)。两列都声明取值域,
* 条数由基座按值现数 —— 判据、分布条、出口谓词读的全是基座读数。
*/
{ name: 'orig_type', type: 'text', primaryKey: false, masked: false, label: '原标(上游三分类)', tally: ['A', 'B', 'C'] },
/*
* **定案三格是待选型行的必填**(2026-08-02 用户:「工作区的 HTML 必填项
* 必须填写了才能提交啊,不填写能算任务完成了吗?」)。
* `requiredWhen` 把必填钉在 orig_type=C 的行(一张执行单、件里一颗一页,
* 每页的定案三格都得齐);A/B 行原样承继,不拦。
* 「替代未果」不走「留空交回」—— 那在闸上就是空手交卷:
* 未果 = 在选型备注写明缘由后交回,判稳那一步按剔除处置(剧本 §S3)。
*/
{ name: 'alt_pn', type: 'text', primaryKey: false, masked: false, label: '替代料 PN', requiredWhen: { column: 'orig_type', value: 'C' } },
{ name: 'alt_brand', type: 'text', primaryKey: false, masked: false, label: '替代料品牌', requiredWhen: { column: 'orig_type', value: 'C' } },
/*
* **替代等级与对标三列**(2026-08-02 用户指出「执行单完全是验收单了」后补齐):
* 选型的定案不只是一个型号 —— FAE 的专业产出是**等级 + 差异 + 风险 + 验证建议**
* (照 MVP 素材对标表的口径)。等级用三个机器码(取值域声明,基座按值现数):
* P2P = 同封装同引脚可直换(核对五项:封装尺寸/引脚定义/电气/温度等级/认证);
* FUNC = 功能兼容(需外围电路或 layout 调整);
* OTHER = 其他(部分兼容,须打样验证)。
* 人读名由模版样式表翻译(徽上色 + 图例),FAE 在卡与件上照码填。
*/
{ name: 'alt_grade', type: 'text', primaryKey: false, masked: false, label: '替代等级(P2P/FUNC/OTHER)', tally: ['P2P', 'FUNC', 'OTHER'], requiredWhen: { column: 'orig_type', value: 'C' } },
{ name: 'alt_diff', type: 'text', primaryKey: false, masked: false, label: '参数差异项', expect: true },
{ name: 'alt_risk', type: 'text', primaryKey: false, masked: false, label: '差异风险与改动评估', expect: true },
{ name: 'alt_verify', type: 'text', primaryKey: false, masked: false, label: '验证建议', expect: true },
/*
* 选型备注是**我方写的注**,不标 expect —— 标了 `_gaps` 恒大于 0,
* 那条门就废了(A1 的 remark 同款教训)。
*/
{ name: 'alt_note', type: 'text', primaryKey: false, masked: false, label: '选型备注(来源/预授权)' },
{ name: 'type_tag', type: 'text', primaryKey: false, masked: false, label: '现标', tally: ['A', 'B', 'C'] },
{ name: 'basis', type: 'text', primaryKey: false, masked: false, label: '转标依据' },
{ name: 'history_days', type: 'integer', primaryKey: false, masked: false, label: '替代料距上次报价天数' },
/*
* **选型排程**(2026-08-02 用户定:「多个芯片按顺序派选型单」)——
* 一颗芯片一张选型单,按序派:WAIT 排队 / NOW 当前在办 / DONE 已定案。
* 只有待选型的料写这一列(A/B 行留空,读数自然为 0);工作单照它组画面
* (队列丸、当前颗的档案、填回表只显当前行),判断全在样式表。
* 它是排程不是编辑面 —— 不进 data-col,人改不到。
*/
],
},
{
/*
* 选型结论 —— 一单一行的**汇总表**,验收机检谓词的入参就在这儿。
* 汇总列与逐行列**故意不同名**:任务视图把草稿行摊平到顶层是「后写的赢」,
* 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。
* 选型结论 —— **一单一行**:汇总数(报告要素卡与哨兵读)、
* 去向旗(流程包 E5 唯一读的东西)、一句人话结论。
*/
domain: DOMAIN,
domain: D,
object: 'verdict',
kind: 'record',
caseKey: 'rfq_ref',
label: '选型结论',
fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'original_pn', type: 'text', primaryKey: false, masked: false, label: '原型号' },
{ name: 'candidate_total', type: 'integer', primaryKey: false, masked: false, label: '候选条数' },
{ name: 'benchmark_total', type: 'integer', primaryKey: false, masked: false, label: '对标条数' },
{
name: 'undocumented_total',
type: 'integer',
primaryKey: false,
masked: false,
label: '差异项或风险缺失条数',
},
{ name: 'recommend_pn', type: 'text', 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: 'alt_line_total', type: 'integer', primaryKey: false, masked: false, label: '清单条目数' },
{ name: 'c_total', type: 'integer', primaryKey: false, masked: false, label: '待选型条数' },
/* 「FAE 的答复处理过了」的凭据 —— 只在人环走完才产生(哨兵按它放行下一步) */
{ name: 'filled_total', type: 'integer', primaryKey: false, masked: false, label: '已填回替代料条数' },
{ name: 'converted_a_total', type: 'integer', primaryKey: false, masked: false, label: '判稳转 A 条数' },
{ name: 'converted_b_total', type: 'integer', primaryKey: false, masked: false, label: '转 B(需寻价)条数' },
{ name: 'skipped_note', type: 'text', primaryKey: false, masked: false, label: '剔除了哪几颗·为什么' },
/* 流程包 E5(仍需寻源 → A3)读的就是它 —— 没有这一列,E5 永远判假(实证过) */
{ name: 'need_sourcing', type: 'boolean', primaryKey: false, masked: false, label: '仍需寻源' },
/* 去向定过了没有 —— 旗是布尔,分不清「没写」与「写了 false」,另记一个计数 */
{ name: 'route_decided_total', type: 'integer', primaryKey: false, masked: false, label: '已定去向' },
{ name: 'summary', type: 'text', primaryKey: false, masked: false, label: '选型结论' },
],
},
],
listeners: [
{
// ⏱ 时限过半:向 BD 与 PM 同步进度与风险
/** 时限过半:给 FAE 与负责人提个醒 —— 静态监听,时限必配 */
origin: 'static',
eventType: 'time',
keyFilter: { kind: 'half-deadline' },
@@ -207,7 +347,7 @@ export const manifest: ActivityManifest = {
escalation: ['nudge'],
},
{
// ✋ 客户改需求:应用条件/参数变更 → 回初选核险与圈选复核
/** 客户改需求(应用条件/参数变更):记一笔观察,回炉与否由 Agent 裁量 */
origin: 'static',
eventType: 'human',
keyFilter: { kind: 'requirement-change' },
@@ -217,35 +357,173 @@ export const manifest: ActivityManifest = {
},
],
harness: {
memoryWindow: 30,
memoryWindow: 24,
// FAE 在系统外干活,等他不算停滞 —— 停滞检测放宽到一天
stallAfter: 'P1D',
nudge: [{ after: 'PT12H', action: 'nudge' }],
retry: { times: 2, backoff: 'PT5M' },
},
},
/* ── 资产初始版 ──────────────────────────────────────────────── */
assets: {
businessModel: {
cmmnXml: CMMN_XML,
narrative: NARRATIVE,
dmn: GRADE_DMN,
/* 步骤剧本 —— 与 A1 同一套:doneWhen 真了引擎自己收步,不问模型 */
dmn: ALT_DMN,
steps: ALTERNATIVE_STEPS,
phases: ALTERNATIVE_PHASES,
},
skills: ALTERNATIVE_SKILLS,
viewTemplates: [{ name: 'case-overview', html: ALTERNATIVE_CASE }],
/*
* 两个局面各一张(2026-08-02 用户指出「执行单完全是验收单了」):
* · `alt-worksheet` 替代选型工作单 —— 归一与 FAE 选型期间摆它:逐颗料的选型档案、
* 替代等级判据、pin-to-pin 核对要点、三层筛指引 + 填回工作表。**这是干活的件**;
* 同一局面在办与归档同一张;局面变了换张(A1 三张同款口径)。
*/
viewTemplates: [
/*
* **每份件两态 1:1**(2026-08-02/03 用户两次校准):在办可编辑 ↔ 办结快照
* 静态归档(finalHtml),这是「1:1」的落点;而**选型作业与收口验收各有各的件**。
*/
{ name: 'alt-worksheet', html: ALT_WORKSHEET },
/*
* 收口件(2026-08-03 用户:「验收工单怎么又变成选型了?」)—— 选型作业与
* 收口验收是两件工作,各有各的件:One Pager 归选型执行单,**整合清单归验收**
* (「清单是所有选型完成后收口才整合的东西」)。「1:1」落在每份件与它的
* 办结快照之间(finalHtml),不是全程只许一张。
*/
{ name: 'alt-roster', html: ALT_ROSTER },
],
cardSchemas: {},
distillHooks: [
{ on: 'milestone', milestone: 'M_candidates', prompt: '归纳本轮撒网的有效角度与无效角度。' },
{ on: 'terminal', prompt: '归纳替代经验:哪些参数差异实际造成了问题、哪些品牌的替代关系可复用。' },
{
on: 'terminal',
prompt: '归纳本单替代选型的经验:哪类料找得到替代、FAE 的选型依据形态、判稳转标的分布。',
},
],
},
/* ── 样例集 —— 三条形状真不同(常备案例即验收案例)───────────── */
samples: [
{
name: 'EOL 料替代',
input: { originalPn: 'TL072CDR', chipType: '运放', scenario: '车规' },
expect: { ports: ['benchmark'] },
/*
* ① 流内常路:上游放行载荷的**真实形状** —— records 里同一颗料出现两次
* (受理记录行 + 分类表行)、还混着结论行;顶层带摊平字段。
* 替代料(客户预授权的 PBY)库内无历史 → 判稳转 B → to-sourcing(E5 复活的那条路)。
*/
name: '流内常路 · Itelma 两颗 C 类(一颗一张选型单按序派 · 替代料无历史 → 转 B 去寻源)',
input: {
rfqRef: 'RFQ MD14.1',
customer_code: 'CU-0142',
customer_name: 'Itelma',
type_c_total: 2,
records: [
{
rfq_ref: 'RFQ MD14.1',
pn: 'BBPY00201209601Y00',
customer_pn: '4-14-01502',
category: '滤波器',
brand: 'YAGEO',
eau: 30000,
remark: '客户预授权替代:PBY201209T-601Y-N(BOM Analog 列)',
type_tag: 'C',
},
{
rfq_ref: 'RFQ MD14.1',
pn: 'BBPY00201209601Y00',
history_days: 47,
market_stable: true,
lifecycle: '要求替代',
type_tag: 'C',
basis: 'EOL/缺货/要求替代 → C',
},
{
rfq_ref: 'RFQ MD14.1',
pn: 'DAG2400L321504XZB01-R0',
customer_pn: '9-00-00251',
category: '天线',
brand: '浙江嘉康',
eau: 30000,
remark: '',
type_tag: 'C',
},
{
rfq_ref: 'RFQ MD14.1',
pn: 'DAG2400L321504XZB01-R0',
history_days: 9999,
market_stable: false,
lifecycle: 'EOL',
type_tag: 'C',
basis: 'EOL/缺货/要求替代 → C',
},
{
rfq_ref: 'RFQ MD14.1',
pn: 'CJTB0101R6N',
customer_pn: '4-00-04102',
category: '集成电路',
brand: 'Jiangsu Changjing',
eau: 60000,
type_tag: 'A',
},
{
rfq_ref: 'RFQ MD14.1',
line_total: 3,
type_a_total: 1,
type_b_total: 0,
type_c_total: 2,
need_alternative: true,
need_sourcing: false,
customer_name: 'Itelma',
},
],
},
expect: { ports: ['roster'] },
},
{
/*
* ② 客户点名替代、替代料**库内行情稳** → 判稳转 A → 全 A 直通定价(to-pricing)。
* FAE 若填回 CJTB0101R6N(库内 43 天·行情稳),就走这条路。
*/
name: '点名替代 · 替代料库内行情稳(转 A 直通定价)',
input: {
rfqRef: 'RFQ-2026-0777',
customer_name: '青岛海洋电子',
records: [
{
rfq_ref: 'RFQ-2026-0777',
pn: 'TL072CDR',
category: '运放',
brand: 'TI',
eau: 3000,
target_price: 22,
remark: '客户点名替代:国产运放优先',
type_tag: 'C',
},
{
rfq_ref: 'RFQ-2026-0777',
pn: 'TL072CDR',
history_days: 9999,
market_stable: false,
lifecycle: '要求替代',
type_tag: 'C',
basis: '客户要求替代 → C',
},
],
},
expect: { ports: ['roster'] },
},
{
/*
* ③ 单飞宽进:没有单号、没有上游清单,只有一段需求(类型 + 参数 + 场景)——
* 归一造案号、需求代号,全标 C,照常走完(输入契约在活动内归一+补全)。
*/
name: '单飞需求 · 只有类型与参数(无单号无清单,宽进归一)',
input: {
subject: '车规运放选型需求',
body: '需要一颗车规级双通道运放:供电 3-36V,AEC-Q100,替代 TL072 系列,年用量约 5K。请选型给替代建议;定案后要给采购去问价。',
},
expect: { ports: ['roster'] },
},
],
+64 -25
View File
@@ -1,45 +1,84 @@
/**
* A2 技能 —— 替代选型的方法经验(AM 08)。
* A2 技能 —— 替代选型 MVP 简化形的方法经验(AM 08)。
*
* **载方法,不载权力**:这里提到的工具都在本活动白名单内;技能不扩权、不改约。
* 渐进披露:常驻上下文只有 `id label:applicability`,正文 Agent 说要才加载。
*
* 剧本改了口径,技能里同一句也要跟着改(A1 的账)—— 2026-08-02 改盘后:
* 选型的实质工作在 FAE 系统外,系统管归一 / 组卡 / 判稳;
* 旧的「圈替代候选(三层筛)」讲的是 FAE 脑子里的方法,系统不替他办,删。
*/
import { skillIdSchema } from '@contracts';
import type { Skill } from '@contracts';
export const ALTERNATIVE_SKILLS: Skill[] = [
{
id: skillIdSchema.parse('alternative.screen-candidates'),
label: '圈替代候选',
applicability: '原型号停产或缺货,要找替代时。',
/*
* 这条讲的是**组卡与核验时的选型口径**(FAE 的实质工作在系统外,但卡面指导书、
* basis 的取舍、收回后的核验都要按同一把尺)—— 2026-08-02 用户指出执行件
* 不是选型工作台后,连同工作单一起补回。
*/
id: skillIdSchema.parse('alternative.grade-discipline'),
label: '替代等级与三层筛',
applicability: '组选型执行单、核验 FAE 填回的定案、或放行前裁量来源可溯时。',
method: [
'三层筛,一层比一层贵,所以要按顺序:',
'① **参数硬指标**:封装、电压范围、精度、温度等级 —— 不满足直接淘汰,不必往下看。',
'② **认证与合规**:车规 / 医疗 / 工业等级不可降级替代,客户没明说也不行。',
'③ **供应与价格**:到这一步才比价,前两层没过的便宜也没用。',
'三层筛,一层比一层贵,按顺序(组卡的 steps 与核验都照它):',
'① 参数硬指标:封装、引脚、电气、温度等级 —— 不满足直接淘汰,不必往下看;',
'② 认证与合规:车规 AEC-Q100/Q200、医疗、工业等级不可降级替代,客户没明说也不行;',
'③ 供应与价格:生命周期在产、货态、品牌接受度 —— 到这一步才比价。',
'',
'「Pin-to-pin 兼容」要看数据手册核实,不要信分销商的一句话 —— ',
'同封装不等于同引脚定义,这个错误要到打样才暴露。',
'等级三码(照 MVP 素材对标表的口径,写别的码基座数不出来):',
'· P2P = 同封装同引脚可直换 —— 必须核对过五项:封装尺寸/引脚定义/电气/温度等级/认证;',
' 同封装不等于同引脚定义,要对数据手册,不要信分销商一句话(这个错要到打样才暴露);',
'· FUNC = 功能兼容,需外围电路或 layout 调整 —— 差异项与改动评估必须写清;',
'· OTHER = 部分兼容须打样验证 —— 关键差异逐项列出、给验证方案。',
'',
'客户备注里已预授权的替代优先采信;点名禁用的品牌不得作候选;',
'是否接受非原厂品牌、参数降级必须问客户,不得替客户决定。',
].join('\n'),
crystallized: [],
knowledgeRefs: ['替代选型判据表'],
knowledgeRefs: ['docs/MVP示例工作流.html §1.5(替代等级/初选核险/圈选替代)'],
version: '1.0.0',
},
{
id: skillIdSchema.parse('alternative.ask-customer'),
label: '什么时候必须问客户',
applicability: '候选已圈定,但存在客户口径不明的项时。',
id: skillIdSchema.parse('alternative.normalize-roster'),
label: '归一询价受理清单',
applicability: '开案第一步:把上游受理记录(或单飞需求)整份落成本活动的清单时。',
method: [
'这几件事**必须问**,不能替客户决定:',
'· 是否接受非原厂品牌;',
'· 是否接受参数降级(哪怕只降一档温度等级);',
'· 是否需要重新打样验证、由谁承担费用。',
'上游载荷里 `records` 是**全部落地行混在一起**的一份 —— 同一颗料出现两三次:',
'带 eau/brand/target_price 的是受理记录行(以它为准),带 basis/history_days 的是',
'分类表行,带 line_total 这类总数的是结论行(不是料,别落进清单)。',
'**按 pn 归并**,一颗料一行,一拍写完(rows 收数组)。',
'',
'问的方式是**跨任务征答**:卡以系统消息送到 BD 的消息要素,就地作答。',
'它不占对方的任务清单——那是一次性的解答请求,不是把责任转过去。',
'',
'问之前先把候选与判据整理好一并发过去:让对方**在数据上作判断**,',
'而不是回一句「你看着办」。',
'三条纪律:',
'① **PN 一个字符不许改**;上游没给的列留空字符串,绝不写 0 顶空。',
'② orig_type 与 type_tag 两列**同一个值起步**(照上游的标):原标是留痕,现标才是本活动的产物。',
'③ 单号 rfq_ref 原样承继;单飞认不出才造「ALT-<型号或类型>」,造完从头用到尾。',
].join('\n'),
crystallized: [],
knowledgeRefs: [],
version: '1.0.0',
knowledgeRefs: ['docs/MVP示例工作流.html §1.5'],
version: '2.0.0',
},
{
id: skillIdSchema.parse('alternative.reclass-stable'),
label: '替代料判稳转标',
applicability: 'FAE 填回替代料之后,要把 C 类行转成 A/B 时。',
method: [
'判稳的尺与三分类 P-03 的 A/B 两行**同一把**:',
'历史价 ≤ 90 天且行情稳 → 转 A(行情完备,可直接取历史锚点);否则 → 转 B(要去寻源补价)。',
'',
'判据从 `crm.history-price-read` 来:**参数 pns 收数组,所有替代料一次传完**。',
'没历史回 historyDays=9999、hit=false —— **那本身就是判据**(新料无历史 → 不稳 → B),',
'不是缺数据,不必替它补。工具回的 lifecycle 若是 EOL/缺货/要求替代,',
'说明替代料选错了 —— 不转标,当替代未果处置。',
'',
'转标写回清单行时:type_tag 只写 A 或 B(照规则,不许臆断);',
'依据一句话落 basis 列(「替代料 X 历史价 N 天·行情稳 → 转 A」),逐条可回核;',
'orig_type 不动。**不许为过判据改标** —— 判据读的是基座按值现数的条数,你改不动。',
].join('\n'),
// 结晶片段:查替代料行情这一步每单都一样 —— 确定性子序列,不必每次让模型重想
crystallized: [{ tool: 'crm.history-price-read', input: { pns: 'pns' } }],
knowledgeRefs: ['三分类规则 P-03(A/B 两行)'],
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>`;
+626 -94
View File
@@ -1,102 +1,634 @@
/**
* A2 替代选型的 HTML 汇报模版 —— **活动自己持有的资产**(§9.3),随包版本冻结。
* A2 替代选型的两张报告 —— 2026-08-03 第四轮重设计(隔离设计代理从零起版面;
* standalone 目标件先行,全绿后按《HTML报告生成规范》§7 机械平移成本文件)。
*
* ── 这一屏给谁看、他此刻要判什么 ──────────────────────────────────
* 看的人是 FAE 与发起的 BD/PM。他此刻只判一件事:**这单还用不用我插手**。
* 于是版面按「先成果、后局面」排 —— 成果是他要签的东西,局面是他决定插不插手的依据:
* 一 初筛清单(撒了多少网)· 二 替代对标表(交付件本体)
* → 三 案例阶段 · 四 当下可为 · 五 目标缺口(AI 的办案局面)
* 指标带扫一眼就该有结论(候选够不够、差异核没核清),往下五节是「凭什么这么说」。
* · `ALT_WORKSHEET` 选型执行工单(S1/S2 在办;**一颗待选型料 = 一页**,tr 挂 data-page):
* 档案头(大字原厂 PN + 为什么要替代)→ 蓝图带(原厂芯片 → 五道核对 → 替代插座:
* 空位虚线红槽、定了实线绿槽 —— 概念图形是第一语言)→ 题面/定案双栏
* (左边客户给的,右边 FAE 的产出;等级三枚 radio 直接选,口径贴在钮上)→
* 方法脚注(五项核对 · 三层筛 · 未果通道)。
* 页容器是**真表格块化**(table.alt-pages > tbody > tr):宿主的加行/删行写死找 tr,
* 「补一颗漏掉的待选型料」与「这颗不该选型(剔除)」才真能用。
* · `ALT_ROSTER` 选型验收报告(S3/放行/归档;**通篇只读**):收口总览四格账 + 选型账一行
* → 询价受理清单大表(原料 → 原标 → 替代定案 → 现标 → 依据)→ 落边图
* (清单 → 还有 B 类? → A3 渠道寻源 / A4 成本定价,命中边实线亮 + 流光,
* 由 case:routeKey 投影,一条没命中两边全灰虚 —— 不冒充判过)→ 结论带 → 落地动作。
* 就地改走验收卡的 editableAtRelease 表,这里一个 data-col / data-edit 都没有。
*
* ── 画面上的数从哪来(一个都不是编的)────────────────────────────
* · `mine:quote_alternative.*` 是**本单自己写下的草稿行** —— 别单放行过的记录不混进来;
* 列名逐字取自 `declarations.ledger`,编一个不存在的列名 = 屏上永远空白。
* · `case:*` / `enabledCount` / `gapCount` / `activeStage` 由语义引擎每拍算出,
* 是确定事实、零 LLM 即可成屏 —— 模型只决定「下一步做什么」,不负责画面。
* · 指标带上「初筛候选 / 已对标」两格指的是**数组本身**:填数时数组取长度
* (view-render 的 `displayValue` 约定),所以不必为「几颗」另立一个计数字段。
* 设计目标件(standalone,浏览器直接可开,与本文件 1:1):
* ~/.claude/jobs/0aebbba5/tmp/target-alt-worksheet.html / target-alt-roster.html
*
* ── 两条不撒谎的纪律 ──────────────────────────────────────────
* ① 右上角挂的是「在办」灯不是印章:这一屏是活的局面,它没盖过章,就别画成盖了章的样子;
* 紫(tone `case`)是**案例活动的标记色不是状态色** —— 红绿橙只留给「达标没达标」。
* ② 表空着就是「本单还没写下这一行」,不是渲染坏了 —— 每张表的说明里都写明空着意味着什么,
* 否则人会把空白读成故障。
*
* 容器是 `sandbox=""` iframe:无脚本、无外链,样式与图标由 `@sdk` 自带。
* ── 工程纪律(都踩过,一条不破)──────────────────────────────────────
* ① 主键格必挂 `data-col`(行的钥匙;在 .decide 的 keyrow 里,开闸才现身,
* 也是「补一页」落键的地方 —— 不填不落库);
* ② data-rows 行容器的标签名不得在行模版内部再现(tbody 的行内无 tbody);
* ③ 收值按「input 的祖父」归行 —— 一页里所有输入格(含 radio)的祖父都是同一个 .decide;
* ④ radio(name=alt_grade_pick)不写 checked:当前档由 data-class-of 投影描虚线,
* 「没点」=「不改」;渲染期基座按行号缀名分组。已知边角:人「补一页」克隆末页时
* 克隆页 radio 与末页同组,新页选档会把末页的勾在 DOM 上顶掉 —— 但未选中列
* 不进写回载荷,台账无恙;
* ⑤ 只有待选型(orig_type=C)的行成页:`.alt-page:not(.v-c){display:none}`,判断在样式表;
* ⑥ 模版不算不判;列名逐字照 manifest 声明抄;逐条数据走 `mine:quote_alternative.list`;
* ⑦ 空占位三级全局口径:**必填红 / 建议填橘 / 选填灰**(required / expect / 其余),
* 蓝图插座的空位也归「必填」。
*/
import { noteOnly, section, stagesOnly, statsBand, tableOnly, viewShell } from '@sdk';
import { PRD_CSS, PRD_SPRITE } from './prd-style.ts';
export const ALTERNATIVE_CASE = viewShell({
title: '替代选型 · 案例全景',
kicker: 'A2 · 替代选型 · 案例活动',
tone: 'case',
icon: 'search',
stamp: { kind: 'live', text: '案例在办 · 非定稿' },
sign: 'A2 替代选型 · 本页每拍按当下局面重画',
/** 一枚图标 —— `use` 引精灵里的 symbol */
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>`;
/** 本件补充精灵:五项核对与等级示意的 6 枚 symbol(lucide 线稿风,随模版走,不动 prd-style) */
const EXTRA_SPRITE = `<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<symbol id="i-ruler" viewBox="0 0 24 24"><path d="M3 17 17 3l4 4L7 21z"/><path d="m8.5 12.5 1.8 1.8M11.5 9.5l1.8 1.8M14.5 6.5l1.8 1.8"/></symbol>
<symbol id="i-pins" viewBox="0 0 24 24"><path d="M4 7h5M4 12h5M4 17h5M15 7h5M15 12h5M15 17h5"/><rect x="9" y="4" width="6" height="16" rx="1.5"/></symbol>
<symbol id="i-bolt" viewBox="0 0 24 24"><path d="M13 2 4 14h6l-1 8 9-12h-6z"/></symbol>
<symbol id="i-thermo" viewBox="0 0 24 24"><path d="M12 3a2 2 0 0 0-2 2v8.5a4 4 0 1 0 4 0V5a2 2 0 0 0-2-2z"/><path d="M12 9v6"/></symbol>
<symbol id="i-flask" viewBox="0 0 24 24"><path d="M10 2v6L4.5 18a2 2 0 0 0 1.8 3h11.4a2 2 0 0 0 1.8-3L14 8V2"/><path d="M8.5 2h7"/><path d="M7.5 15h9"/></symbol>
<symbol id="i-gear" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3.2"/><path d="M12 2.5v3M12 18.5v3M2.5 12h3M18.5 12h3M5.2 5.2l2.1 2.1M16.7 16.7l2.1 2.1M18.8 5.2l-2.1 2.1M7.3 16.7l-2.1 2.1"/></symbol>
</svg>`;
/* ==================== 编辑机构 + 家族补丁(两张共用)====================
闸/红带/加行/撤行/孪生件开合 —— 拷自 A1 报告的 BIND_CSS 并按本件容器适配:
A1 的开合钉在 .jc-tbl-in 上,本件工单的容器是 table.alt-pages(.on 由宿主落在
table 上,data-edit 在 tbody);类徽三色与铺满工作区的两行也一并收在这里。 */
const HARNESS_CSS = `
/* 铺满工作区:件就是这一屏的正文,不是嵌在里面的另一个页面 */
.jc-stage { max-width: none; padding: 0 0 4px; }
.proto-root .jc-report { border: 0; border-radius: 0; box-shadow: none; }
.proto-root .jc-logo svg, .proto-root .jc-face svg, .proto-root .jc-rp-ava svg { width: 17px; height: 17px; }
/* 孪生输入件默认收着 —— 件首先是给人看的 */
.proto-root .jc-cell { display: none; }
/* 类徽三色(A/B/C) */
.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-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 那档隐藏,判断在样式表) */
.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; align-items: flex-start; gap: 8px;
margin: 0 0 10px; padding: 9px 12px; border-radius: 9px;
background: var(--brand-wash); border: 1px solid var(--brand-soft);
font-size: 12px; line-height: 1.65; color: var(--ink-2);
}
.proto-root .jc-editbar svg { flex: 0 0 auto; margin-top: 2px; color: var(--brand); }
/* 表尾「补一颗」—— 只在这一屏可改且开了闸时现身(容器是 table.alt-pages) */
.proto-root .jc-addrow { display: none; }
html.editable .proto-root table.alt-pages.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 > .app-shell > .jc-stage > article.jc-report;
* 题头(徽 + kicker + 题 + 状态灯)与落款(署名 + 流位签)与 A1 同一套版面语言。
*/
const doc = (input: {
badge: string;
kicker: string;
title: string;
titleRef: string;
state: string;
css: string;
body: string;
foot: string;
tag: string;
}): string => `<style>${PRD_CSS}${HARNESS_CSS}${input.css}</style>
${PRD_SPRITE}${EXTRA_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>
<span class="jc-rp-wait">${input.state}</span>
</header>
<div class="jc-rp-bd">${input.body}</div>
<footer class="jc-rp-foot">
<div class="jc-rp-sign">
<span class="jc-rp-ava">${ico('user', 15)}</span>
<div><b data-slot="case:owner"></b><span>${input.foot}</span></div>
</div>
<span class="jc-done-tag">${ico('git-branch', 13)}${input.tag}</span>
</footer>
</article>
</div></div></div>`;
/** 章节头 —— 家族的 jc-rp-part:小标签 + 细线 + 右端小签 */
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 SHEET_CSS = `
/* 编辑区容器:左缘品牌竖线把「这一段可就地修订」从说明区里立出来 */
.proto-root .jc-zone.z-edit { border-left: 3px solid var(--brand); padding-left: 14px; margin: 2px 0 14px; }
/* ── 页容器:真表格块化(tbody 挂行来源与行地址,tr 即一页,宿主翻页认 [data-page])── */
.proto-root table.alt-pages { display: block; width: 100%; border-collapse: separate; border-spacing: 0; }
.proto-root .alt-pages tbody { display: block; }
.proto-root .alt-pages tr.alt-page { display: block; margin: 0 0 16px; }
.proto-root .alt-pages td.alt-cell { display: block; padding: 0; }
/* 只有待选型(原标 C)的行成页;A/B 行原样承继,不进这份工单(判断在样式表) */
.proto-root .alt-pages tr.alt-page:not(.v-c) { display: none; }
/* ── 档案页外框 ── */
.proto-root .alt-cell { border: 1px solid var(--line); border-radius: 12px; overflow: hidden; background: #fff; }
/* ── 档案头:这颗料的出身 ── */
.proto-root .archrow { display: flex; align-items: flex-start; gap: 16px; padding: 13px 16px 11px; border-bottom: 1px solid var(--line-soft); }
.proto-root .archrow .chip-id { flex: 1 1 auto; min-width: 0; }
.proto-root .archrow .chip-id em { display: block; font-style: normal; font-size: 10px; font-weight: 800; letter-spacing: .14em; color: var(--ink-4); margin-bottom: 3px; }
.proto-root .archrow .chip-id .pn { display: block; font-size: 23px; font-weight: 800; letter-spacing: -.3px; color: var(--ink); word-break: break-all; line-height: 1.2; }
.proto-root .archrow .why { flex: 0 1 300px; min-width: 210px; border-left: 1px solid var(--line-soft); padding-left: 14px; }
.proto-root .archrow .why i { display: inline-flex; align-items: center; gap: 5px; font-style: normal; font-size: 10px; font-weight: 800; letter-spacing: .1em; color: var(--ink-4); }
.proto-root .archrow .why p { margin: 4px 0 0; font-size: 11.5px; line-height: 1.55; color: var(--ink-2); }
.proto-root .archrow .why p span:empty::after { content: "上游未留约束 —— 以原厂官网规格书为准"; color: var(--ink-4); }
/* 剔除这一页:只在开了闸时现身,宿主认 data-edit-del(找 tr 挂删记号) */
.proto-root .alt-pages .jc-rowdel { display: none; }
.proto-root .alt-pages.on .jc-rowdel { display: inline-flex; align-items: center; gap: 4px; width: auto; padding: 3px 9px; font-size: 11px; }
.proto-root .alt-pages.on tr.jc-row-gone .alt-cell { opacity: .5; background: #fff6f6; }
.proto-root .alt-pages.on tr.jc-row-gone .chip-id .pn { text-decoration: line-through; color: var(--ink-3); }
.proto-root .alt-pages.on tr.jc-row-new .alt-cell { background: #f2fbf7; }
/* ── 蓝图带:替代路径要穿过五道核对(概念图形是第一语言)── */
.proto-root .blueprint { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid var(--line-soft);
background-image:
repeating-linear-gradient(0deg, rgba(51,112,255,.05) 0 1px, transparent 1px 22px),
repeating-linear-gradient(90deg, rgba(51,112,255,.05) 0 1px, transparent 1px 22px),
linear-gradient(180deg, var(--brand-wash), #fff 88%);
}
.proto-root .bp-box { flex: 0 0 auto; text-align: center; }
.proto-root .bp-box em { display: block; font-style: normal; font-size: 9.5px; font-weight: 800; letter-spacing: .1em; color: var(--ink-4); margin-bottom: 3px; }
.proto-root .bp-box svg { display: block; margin: 0 auto; }
.proto-root .bp-to .socket-pn { display: block; margin-top: 4px; font-size: 11px; font-weight: 800; color: var(--ink-2); max-width: 150px; word-break: break-all; line-height: 1.3; }
.proto-root .bp-mid { flex: 1 1 auto; min-width: 120px; text-align: center; }
.proto-root .bp-mid svg { display: block; width: 100%; height: 46px; }
.proto-root .bp-mid .cap { font-size: 10px; color: var(--ink-4); }
.proto-root .bp-mid .lane { stroke: var(--brand-soft); stroke-width: 2; stroke-dasharray: 6 5; }
.proto-root .bp-mid .gate { fill: #fff; stroke: var(--brand); stroke-width: 1.6; }
.proto-root .bp-mid .tipp { fill: var(--brand); }
/* 插座:定案空着是虚线红槽;填了替代 PN 就换成实线绿槽(判断在样式表,:has + :empty) */
.proto-root .bp-to .sk-body { stroke: var(--ok); fill: rgba(39,135,23,.06); }
.proto-root .bp-to .sk-slot { stroke: var(--ok); }
.proto-root .bp-to:has(.socket-pn:empty) .sk-body { stroke: var(--danger); fill: #fff; stroke-dasharray: 7 5; }
.proto-root .bp-to:has(.socket-pn:empty) .sk-slot { stroke: #f3b6b3; }
.proto-root .bp-to .socket-pn:empty::after { content: "必填"; display: inline-block; font-size: 10px; font-weight: 800; color: var(--danger); background: var(--danger-soft); border: 1px dashed #f3b6b3; border-radius: 5px; padding: 3px 8px; }
.proto-root .bp-from .chipfig .cf-body { fill: var(--ink-2); stroke: var(--ink); }
.proto-root .bp-from .chipfig .cf-pin { stroke: var(--ink-3); stroke-width: 2; }
.proto-root .bp-from .chipfig .cf-dot { fill: #fff; opacity: .8; }
/* ── 题面 / 定案双栏 ── */
.proto-root .duo { display: grid; grid-template-columns: 1fr 1.2fr; gap: 0; }
.proto-root .duo .given { padding: 12px 16px 14px; border-right: 1px solid var(--line-soft); }
.proto-root .duo .decide { padding: 12px 16px 14px; background: linear-gradient(180deg, rgba(51,112,255,.035), transparent 60px); }
.proto-root .duo h5 { display: flex; align-items: center; gap: 6px; margin: 0 0 8px; font-size: 11px; font-weight: 800; letter-spacing: .06em; color: var(--ink-3); }
.proto-root .duo .decide h5 { color: var(--brand-deep); }
.proto-root .fld { display: flex; align-items: baseline; gap: 10px; padding: 5px 0; border-bottom: 1px dashed var(--line-soft); }
.proto-root .fld:last-child { border-bottom: 0; }
.proto-root .fld > i { flex: 0 0 96px; font-style: normal; font-size: 11px; color: var(--ink-4); }
.proto-root .fld > .jc-vcell, .proto-root .fld > .gradechip { flex: 1 1 auto; min-width: 0; font-size: 12px; color: var(--ink); line-height: 1.5; word-break: break-all; }
/* 行键那一格:平时收起,开了闸才现出来(它不是编辑面,是行的钥匙;新加的页在这里落键) */
.proto-root .fld.keyrow { display: none; }
.proto-root .alt-pages.on .fld.keyrow { display: flex; background: var(--soft); border-radius: 6px; padding: 5px 8px; }
.proto-root .fld.keyrow > i { color: var(--ink-3); font-weight: 700; }
/* 占位三级(全局口径):必填红 · 建议填橘 · 选填灰;题面空格是「客户没给」,只出一杠 */
.proto-root .ph-req:empty::after { content: "必填"; display: inline-block; font-size: 10px; font-weight: 700; line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed #f3b6b3; color: var(--danger); background: var(--danger-soft); }
.proto-root .ph-sug:empty::after { content: "建议填"; display: inline-block; font-size: 10px; font-weight: 700; line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed #f0c184; color: var(--warn-accent); background: rgba(255,136,0,.08); }
.proto-root .ph-opt:empty::after { content: "选填"; display: inline-block; font-size: 10px; font-weight: 700; line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed var(--line); color: var(--ink-4); background: var(--soft); }
.proto-root .dimv:empty::after { content: "—"; color: var(--ink-4); }
.proto-root .decide .fld:has(.ph-req:empty) { background: #fffbfb; }
.proto-root .decide .fld:has(.ph-req:empty) > i { color: var(--danger); font-weight: 800; }
/* 定案格的就地改:锁着看只读那份,开了闸换输入孪生件(渲染期基座生成 input.jc-cell) */
.proto-root .alt-pages.on .decide .fld > .jc-vcell,
.proto-root .alt-pages.on .decide .fld > .gradechip { display: none; }
.proto-root .alt-pages.on .decide .fld > input.jc-cell { display: block; flex: 1 1 auto; font: inherit; font-size: 12px; color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: 4px 8px; background: #fff; }
.proto-root .alt-pages.on .decide .fld > input.jc-cell:placeholder-shown { border-color: #f3b6b3; background: #fffafa; }
/* 等级三选:口径贴在选择钮上;当前档由 data-class-of 投影描虚线;radio 只在开了闸时可点 */
.proto-root .gopts { margin: 6px 0 4px; display: grid; gap: 6px; }
.proto-root .gopt { position: relative; display: flex; align-items: center; gap: 9px; border: 1px solid var(--line-soft); border-radius: 8px; padding: 7px 10px; background: #fff; }
.proto-root .gopt > input.jc-cell { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; }
.proto-root .alt-pages.on .gopt > input.jc-cell { display: block; }
.proto-root .gopt b { flex: 0 0 52px; font-size: 12px; font-weight: 800; color: var(--ink-2); }
.proto-root .gopt em { flex: 1 1 auto; font-style: normal; font-size: 10.5px; color: var(--ink-3); line-height: 1.45; }
.proto-root .gopt svg { flex: 0 0 auto; color: var(--ink-4); }
/* 当前档:虚线描边(值是台账里的,不写 checked —— 「没点」就是「不改」) */
.proto-root .gopts.v-p2p .gopt.gp, .proto-root .gopts.v-func .gopt.gf, .proto-root .gopts.v-other .gopt.go { border: 1px dashed var(--brand); background: var(--brand-wash); }
.proto-root .gopts.v-p2p .gopt.gp b, .proto-root .gopts.v-func .gopt.gf b, .proto-root .gopts.v-other .gopt.go b { color: var(--brand-deep); }
.proto-root .gopts.v-p2p .gopt.gp svg, .proto-root .gopts.v-func .gopt.gf svg, .proto-root .gopts.v-other .gopt.go svg { color: var(--brand); }
/* 开了闸:radio 现出真钮的手感(选中那档实线亮) */
.proto-root .alt-pages.on .gopt:has(input:checked) { border: 1.6px solid var(--brand); background: var(--brand-wash); }
/* 等级徽:三档三色(P2P 绿 · FUNC 蓝 · OTHER 橙) */
/* gradechip 必须同时是 jc-vcell(2026-08-03 用户:「替代等级后面那个必填没对齐」)——
* 上两行的必填胶囊住在 jc-vcell 的 6px 内距里,裸 gradechip 没这层盒,胶囊贴左又偏高。 */
.proto-root .gradechip.v-p2p { color: var(--ok); font-weight: 800; }
.proto-root .gradechip.v-func { color: var(--brand); font-weight: 800; }
.proto-root .gradechip.v-other { color: var(--warn-accent); font-weight: 800; }
/* ── 方法区(固定说明,不进编辑容器)── */
.proto-root .m-funnel { display: flex; gap: 8px; margin: 8px 0 10px; flex-wrap: wrap; }
.proto-root .m-funnel .fs { flex: 1 1 180px; border: 1px solid var(--line-soft); border-left: 3px solid var(--brand-soft); border-radius: 8px; padding: 8px 10px; font-size: 11px; color: var(--ink-3); line-height: 1.5; background: var(--soft); }
.proto-root .m-funnel .fs b { display: block; color: var(--ink-2); font-size: 11.5px; margin-bottom: 2px; }
.proto-root .m-five { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0 10px; }
.proto-root .m-five .c5 { flex: 1 1 130px; display: flex; align-items: center; gap: 8px; border: 1px solid var(--line-soft); border-radius: 8px; padding: 7px 9px; background: #fff; }
.proto-root .m-five .c5 svg { flex: 0 0 auto; color: var(--brand); }
.proto-root .m-five .c5 b { display: block; font-size: 11px; color: var(--ink-2); }
.proto-root .m-five .c5 span { display: block; font-size: 9.5px; color: var(--ink-4); }
/* 窄屏:双栏叠置、蓝图竖排(媒体块排在基础规则之后,同权重后定义胜) */
@media (max-width: 900px) {
.proto-root .duo { grid-template-columns: 1fr; }
.proto-root .duo .given { border-right: 0; border-bottom: 1px solid var(--line-soft); }
.proto-root .archrow { flex-direction: column; gap: 8px; }
.proto-root .archrow .why { border-left: 0; padding-left: 0; border-top: 1px dashed var(--line-soft); padding-top: 8px; }
.proto-root .blueprint { flex-direction: column; }
.proto-root .bp-mid svg { transform: rotate(90deg); width: 46px; height: 46px; margin: 0 auto; }
}
/* 「必填」空槽轻呼吸 —— 连 @keyframes 一起包在动效许可里,减弱设置下全静 */
@media (prefers-reduced-motion: no-preference) {
.proto-root .bp-to .socket-pn:empty::after { animation: jc-breath 2.2s ease-in-out infinite; }
@keyframes jc-breath { 0%,100% { opacity: 1; } 50% { opacity: .55; } }
}
`;
/** 原厂芯片小像(方体 + 双侧引脚 + 一脚圆点) */
const CHIP_FIG = `<svg width="96" height="64" viewBox="0 0 96 64" fill="none" stroke-linecap="round" class="chipfig">
<line class="cf-pin" x1="8" y1="18" x2="22" y2="18"/><line class="cf-pin" x1="8" y1="28" x2="22" y2="28"/><line class="cf-pin" x1="8" y1="38" x2="22" y2="38"/><line class="cf-pin" x1="8" y1="48" x2="22" y2="48"/>
<line class="cf-pin" x1="74" y1="18" x2="88" y2="18"/><line class="cf-pin" x1="74" y1="28" x2="88" y2="28"/><line class="cf-pin" x1="74" y1="38" x2="88" y2="38"/><line class="cf-pin" x1="74" y1="48" x2="88" y2="48"/>
<rect class="cf-body" x="22" y="10" width="52" height="44" rx="6" stroke-width="1.6"/>
<circle class="cf-dot" cx="31" cy="19" r="2.5"/>
</svg>`;
/** 蓝图带:原厂芯片 → 五道核对 → 替代插座(空=虚线红槽,定了=实线绿槽) */
const BLUEPRINT = `<div class="blueprint">
<div class="bp-box bp-from"><em>原厂芯片</em>${CHIP_FIG}</div>
<div class="bp-mid">
<svg viewBox="0 0 240 46" fill="none">
<path class="lane" d="M6 23h206"/>
<circle class="gate" cx="38" cy="23" r="5"/><circle class="gate" cx="76" cy="23" r="5"/><circle class="gate" cx="114" cy="23" r="5"/><circle class="gate" cx="152" cy="23" r="5"/><circle class="gate" cx="190" cy="23" r="5"/>
<path class="tipp" d="M216 15l16 8-16 8z"/>
</svg>
<div class="cap">替代路径要穿过五道核对(封装 · 引脚 · 电气 · 温度 · 认证)</div>
</div>
<div class="bp-box bp-to"><em>替代定案</em>
<svg width="96" height="64" viewBox="0 0 96 64" fill="none" stroke-linecap="round">
<rect class="sk-body" x="10" y="8" width="76" height="48" rx="8" stroke-width="1.8"/>
<path class="sk-slot" d="M28 22h40M28 32h40M28 42h24" stroke-width="2"/>
</svg>
<span class="socket-pn jc-mono" data-slot="alt_pn" data-blank></span>
</div>
</div>`;
/** 等级三选(radio 不写 checked;当前档由 gopts 上的投影描虚线) */
const GRADE_OPTS = `<div class="gopts" data-class-of="alt_grade">
<div class="gopt gp"><input type="radio" name="alt_grade_pick" value="P2P" class="jc-cell" data-col="alt_grade"><b>P2P</b><em>同封装同引脚 · 可直换 —— 五道核对逐项过了再选,同封装不等于同引脚</em>${ico('pins', 15)}</div>
<div class="gopt gf"><input type="radio" name="alt_grade_pick" value="FUNC" class="jc-cell" data-col="alt_grade"><b>FUNC</b><em>功能兼容 —— 需外围电路或 layout 调整;差多少、怎么改,写进下面两格</em>${ico('gear', 15)}</div>
<div class="gopt go"><input type="radio" name="alt_grade_pick" value="OTHER" class="jc-cell" data-col="alt_grade"><b>OTHER</b><em>部分兼容 —— 须打样验证;怎么验写进验证建议</em>${ico('flask', 15)}</div>
</div>`;
/** 一页 = 一颗待选型料的选型档案(行模版;只有 orig_type=C 的行成页) */
const SHEET_PAGE = `<table class="alt-pages"><tbody data-rows="mine:quote_alternative.list" data-edit="quote_alternative.list">
<tr class="alt-page" data-page data-class-of="orig_type"><td class="alt-cell">
<div class="archrow">
<div class="chip-id"><em>原厂 · 被替换</em><span class="pn jc-mono" data-slot="pn"></span></div>
<div class="why"><i>${ico('history')}为什么要替代&nbsp;<span class="jc-tbadge" data-class-of="orig_type" data-slot="orig_type"></span></i>
<p><span data-slot="remark" data-blank></span></p>
</div>
<button type="button" class="jc-egbtn jc-rowdel" data-edit-del="quote_alternative.list" title="这颗不在本轮选型(剔除)">×&nbsp;剔除</button>
</div>
${BLUEPRINT}
<div class="duo">
<div class="given">
<h5>${ico('file-text', 12)}题面 · 客户给的</h5>
<div class="fld"><i>客户料号</i><span class="jc-vcell jc-mono dimv" data-slot="customer_pn" data-blank></span></div>
<div class="fld"><i>品类</i><span class="jc-vcell dimv" data-slot="category" data-blank></span></div>
<div class="fld"><i>制造商</i><span class="jc-vcell dimv" data-slot="brand" data-blank></span></div>
<div class="fld"><i>年用量</i><span class="jc-vcell jc-mono dimv" data-slot="eau" data-blank></span></div>
<div class="fld"><i>目标价</i><span class="jc-vcell jc-mono dimv" data-slot="target_price" data-blank></span></div>
<div class="fld"><i>交期要求</i><span class="jc-vcell dimv" data-slot="lead_time" data-blank></span></div>
</div>
<div class="decide">
<h5>${ico('pen', 12)}替代定案 · 你的产出</h5>
<div class="fld keyrow"><i>原厂 PN(行键)</i><span class="jc-vcell jc-mono" data-slot="pn" data-col="pn"></span></div>
<div class="fld"><i>替代料 PN</i><span class="jc-vcell jc-mono ph-req" data-slot="alt_pn" data-col="alt_pn" data-blank></span></div>
<div class="fld"><i>替代料品牌</i><span class="jc-vcell ph-req" data-slot="alt_brand" data-col="alt_brand" data-blank></span></div>
<div class="fld"><i>替代等级</i><span class="jc-vcell gradechip ph-req" data-class-of="alt_grade" data-slot="alt_grade" data-blank></span></div>
${GRADE_OPTS}
<div class="fld"><i>参数差异项</i><span class="jc-vcell ph-sug" data-slot="alt_diff" data-col="alt_diff" data-blank></span></div>
<div class="fld"><i>风险与改动</i><span class="jc-vcell ph-sug" data-slot="alt_risk" data-col="alt_risk" data-blank></span></div>
<div class="fld"><i>验证建议</i><span class="jc-vcell ph-sug" data-slot="alt_verify" data-col="alt_verify" data-blank></span></div>
<div class="fld"><i>选型备注</i><span class="jc-vcell ph-opt" data-slot="alt_note" data-col="alt_note" data-blank></span></div>
</div>
</div>
</td></tr>
</tbody></table>`;
export const ALT_WORKSHEET = doc({
badge: 'boxes',
kicker: '统一报价 · A2 替代选型 · 选型作业',
title: '替代选型工单',
titleRef: 'rfq_ref',
state: '在办 · 逐颗定案',
css: SHEET_CSS,
body:
statsBand([
['初筛候选', 'mine:quote_alternative.shortlist'],
['已对标', 'mine:quote_alternative.benchmark'],
['当下可为', 'enabledCount'],
['目标缺口', 'gapCount'],
]) +
section('初筛清单(本单撒网所得)',
tableOnly('mine:quote_alternative.shortlist', [
['原型号', 'original_pn', 'mono'],
['候选型号', 'candidate_pn', 'mono'],
['品牌', 'brand'],
['生命周期', 'lifecycle'],
]) +
noteOnly(
'先撒网后核验,常要两三轮:检索与聚合平台拉来的候选先落这一张,<b>逐条人工核验之前都不算数</b>。' +
'生命周期一栏是初筛就要看的第一道险 —— EOL/停产的当场淘汰;' +
'不足 2~3 颗就放宽参数或换角度重新撒网。这张表空着,是本单还没撒网,不是没找着。',
),
{ text: '常要两三轮' },
) +
section('替代对标表(本单交付件)',
tableOnly('mine:quote_alternative.benchmark', [
['候选型号', 'candidate_pn', 'mono'],
['替代等级', 'grade'],
['参数差异项', 'param_diff'],
['差异风险', 'risk'],
['验证建议', 'verify_advice'],
['FAE 签字', 'signed_by'],
]) +
noteOnly(
'这是要签字交回发起方的东西:<b>差异没核清的不放行</b> —— 「差异风险」空着的行,' +
'就是此刻拦着放行的那几行。替代等级由判定表出(P2P / 功能兼容 / 其他),不是模型自己说了算;' +
'等级定评估深度:P2P 核确免改动,其余逐项评软件、外围电路与 layout。' +
'每项数据一律以<b>原厂官网规格书</b>为准。',
),
{ text: '差异没核清不放行' },
) +
section('案例阶段(CMMN 生命周期)',
stagesOnly('case:stages') +
noteOnly(
'阶段的生命周期态由语义引擎推进到 <b>Enabled</b> 为止;凡触达世界或产出关键交付的计划项' +
'必须人工启动 —— <b>Enabled ≠ Active</b>。选型是<b>一事一议</b>:撒几轮网、哪颗值得深评、' +
'要不要换角度重来,事前排不出谱,所以调度权交给 AI,系统只保证边框:预算、停滞熔断、放行归人。',
),
{ text: '推进到 Enabled 为止' },
) +
section('当下可为(AI 的合法选项)',
stagesOnly('case:enabled') +
noteOnly(
'AI 只能在这个集合里选下一步:<b>可以越谱,不可越权</b>。标「待启动」的是触达世界或' +
'产出关键交付的项 —— 要按一下再动。这一栏空着不是它偷懒,是当下确实无可为:' +
'要么在等人回话,要么在等外面的回音。',
),
{ text: '本拍的合法选项' },
) +
section('目标缺口(离交付还差什么)',
stagesOnly('case:gaps') +
noteOnly(
'缺口只有人知道 → 调人。选型常要向 BD 补客户口径,那是<b>跨任务征答</b>:卡以系统消息送达' +
'对方、<b>就地作答</b>,不占对方的任务清单,也不开放本任务的对话流 —— 一次性的解答请求,' +
'不是责任转移。缺口在世界侧 → 立监听(<b>时限必配</b>)。' +
'当下可为 ∩ 目标所需 = 空,就是<b>依赖断点</b>。这一栏空着,是必做的事都办完了。',
),
{ text: '空 = 必做的都办完了' },
),
`<div class="jc-derive">${ico('info', 14)}
<span>这份工单只办一件事:给每颗<b>待选型(原标 C)</b>的料定一颗替代 —— 客户
<b data-slot="customer_name"></b> 本单待选型 <b data-slot="c_total"></b> 颗,
一颗一页(右上角翻页)。每页上半是<b>题面</b>(客户给的,改不了),下半右栏是<b>你的定案</b>;
定案保存后,查库判稳与转标(≤90 天且行情稳转 A,否则转 B 去寻源)由系统接办。
保存只落任务草稿层;交卷在对话区的执行卡上。</span>
</div>` +
part('替代定案 · 一颗一页', '可就地修订') +
`<div class="jc-zone z-edit">
<div class="jc-gapbar" data-class-of="quote_alternative_list_missing">${ico('alert', 14)}
<span><b>定案还差 <span data-slot="quote_alternative_list_missing"></span> 格必填</b>
—— <span data-slot="quote_alternative_list_missing_keys"></span>。
翻到那一颗,按「编辑」开闸就地填;补齐了执行卡上才交得了卷。</span>
</div>
<div class="jc-editgate" data-edit-for="quote_alternative.list">
<span class="jc-editgate-t">${ico('lock', 12)}替代定案<b>已锁定</b> —— 看的时候不误碰,要改按右边</span>
<button type="button" class="jc-egbtn open" data-edit-open="quote_alternative.list">${ico('pen', 12)}编辑</button>
<button type="button" class="jc-egbtn ghost" data-edit-cancel="quote_alternative.list">取消</button>
<button type="button" class="jc-egbtn save" data-edit-save="quote_alternative.list">${ico('check', 12)}保存</button>
</div>
<div class="jc-editbar">${ico('pen', 14)}
<span>定案三格是<b>必填</b>(替代料 PN / 品牌 / 等级),等级直接点三档之一;
这颗料整个不该选型,按档案头右上角的 × 剔除(划着摆在那儿,可后悔);
漏了一颗,用底下的「补一颗待选型料」。改动只落任务草稿层,<b>保存不等于放行</b>。</span>
</div>
${SHEET_PAGE}
<div class="jc-addrow">
<button type="button" class="jc-egbtn ghost" data-edit-add="quote_alternative.list">${ico('plus', 12)}补一颗待选型料</button>
<em>照末页添一页空档案;行键(原厂 PN)填了才存得下,不填这一页不落库。</em>
</div>
</div>` +
part('选型方法 · 凭什么这么选', '替代等级口径') +
`<div class="m-five">
<div class="c5">${ico('ruler', 16)}<div><b>封装尺寸</b><span>本体 / 脚距 / 高度</span></div></div>
<div class="c5">${ico('pins', 16)}<div><b>引脚定义</b><span>逐脚对数据手册</span></div></div>
<div class="c5">${ico('bolt', 16)}<div><b>电气参数</b><span>供电 / 精度 / 速度</span></div></div>
<div class="c5">${ico('thermo', 16)}<div><b>温度等级</b><span>工作温区不降级</span></div></div>
<div class="c5">${ico('shield-check', 16)}<div><b>认证</b><span>车规 / 医疗不降级</span></div></div>
</div>
<div class="m-funnel">
<div class="fs"><b>① 参数硬指标</b>封装 / 引脚 / 电气 / 温度 —— 任一不满足直接淘汰,不进下一层。</div>
<div class="fs"><b>② 认证与合规</b>车规 AEC-Q100/Q200、医疗、工业等级不可降级;客户点名禁用的品牌与型号不选。</div>
<div class="fs"><b>③ 供应与价格</b>在产 / 货态 / 品牌接受度;客户预授权的候选优先。</div>
</div>
<div class="jc-drop">${ico('info', 13)}
<span>一律以<b>原厂官网规格书</b>为准,不以商城参数表为准。没有把握的替代不填:
宁可在选型备注写明「替代未果」的缘由后保存交回,由判稳一步按剔除处置 ——
编一个候选顶上,是把风险埋进下游的报价单。是否接受非原厂品牌、参数降级,
必须问客户,不得替客户决定。</span>
</div>`,
foot: 'FAE 逐颗定案(右上角翻页)—— 判稳与转标由系统查库接办',
tag: '统一报价 · A2 替代选型 · 选型作业',
});
/* ==================== 选型验收报告(收口清单 + 落边)==================== */
const ROSTER_CSS = `
/* ── 收口总览:四格账 ── */
.proto-root .statrow { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin: 2px 0 10px; }
.proto-root .stat { border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px 9px; background: #fff; position: relative; overflow: hidden; }
.proto-root .stat::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--line); }
.proto-root .stat.sa::before { background: var(--ok); }
.proto-root .stat.sb::before { background: var(--brand); }
.proto-root .stat.sc::before { background: var(--warn-accent); }
.proto-root .stat b { display: block; font-size: 24px; font-weight: 800; letter-spacing: -.4px; color: var(--ink); line-height: 1.15; }
.proto-root .stat span { display: block; font-size: 10.5px; color: var(--ink-3); margin-top: 2px; }
/* 「待选型未清」那格:0 才是收口该有的样子 —— 0 绿、非 0 红(判断在样式表) */
.proto-root .stat.sc b.v-0 { color: var(--ok); }
.proto-root .stat.sc b.v-1, .proto-root .stat.sc b.v-2, .proto-root .stat.sc b.v-3, .proto-root .stat.sc b.v-4, .proto-root .stat.sc b.v-5 { color: var(--danger); }
/* 选型账一行:C 类怎么清零的 */
.proto-root .workline { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; margin: 0 0 12px; padding: 8px 12px; border: 1px dashed var(--line); border-radius: 9px; background: var(--soft); font-size: 11.5px; color: var(--ink-3); }
.proto-root .workline b { color: var(--ink); font-weight: 800; }
.proto-root .workline .sep { color: var(--ink-4); }
/* 剔除说明空着就整句收起(判断在样式表;有剔除才有这句话) */
.proto-root .workline span:has(> b:empty) { display: none; }
.proto-root .workline:has(span > b:empty) .sep:last-of-type { display: none; }
/* ── 清单表:序号计数器 + 归档空格「—」+ 底行小字(承继的第二行不出杠)── */
.proto-root .jc-tbl-in tbody { counter-reset: rn; }
.proto-root .jc-tbl-in td.jc-rn::before { counter-increment: rn; content: counter(rn); }
.proto-root .jc-tbl-in td.dimempty > .jc-vcell:empty::after { content: "—"; color: var(--ink-4); }
.proto-root .jc-tbl-in .sub2 { display: block; font-size: 10px; color: var(--ink-4); margin-top: 1px; }
.proto-root .gchip { font-weight: 800; font-size: 11.5px; }
.proto-root .gchip.v-p2p { color: var(--ok); }
.proto-root .gchip.v-func { color: var(--brand); }
.proto-root .gchip.v-other { color: var(--warn-accent); }
.proto-root .gchip:empty::after { content: "—"; color: var(--ink-4); font-weight: 400; }
/* ── 落边图:清单 → 还有 B 类? → A3 / A4(命中边由壳上 v-r1/v-r2 上色,件不算不判)── */
.proto-root .route { border: 1px solid var(--line); border-radius: 12px; padding: 12px 12px 8px; background: linear-gradient(180deg, #fff, var(--soft)); margin: 0 0 10px; }
.proto-root .route svg { display: block; width: 100%; height: auto; }
.proto-root .route .rnode { fill: #fff; stroke: var(--line); stroke-width: 1.4; }
.proto-root .route .rdia { fill: #fff; stroke: var(--ink-3); stroke-width: 1.4; }
.proto-root .route .redge { stroke: var(--line); stroke-width: 2; stroke-dasharray: 6 5; fill: none; }
/* 已走过的共段(清单 → 判定):中性实线,不随命中变色 */
.proto-root .route .rspine { stroke: var(--line); stroke-width: 2; fill: none; }
.proto-root .route .rpulse { stroke: transparent; stroke-width: 2.5; fill: none; }
.proto-root .route .rtxt { font-size: 12px; font-weight: 800; fill: var(--ink-2); }
.proto-root .route .rsub { font-size: 9.5px; font-weight: 400; fill: var(--ink-4); }
.proto-root .route .rcond { font-size: 10px; font-weight: 700; fill: var(--ink-4); }
.proto-root .route .rtip { fill: var(--line); }
/* 命中 r1:仍有 B 类 → A3 渠道寻源(蓝);未命中那条留灰虚线,不冒充判过 */
.proto-root .route.v-r1 .e1 { stroke: var(--brand); stroke-dasharray: none; }
.proto-root .route.v-r1 .n1 { stroke: var(--brand); stroke-width: 2; }
.proto-root .route.v-r1 .t1 { fill: var(--brand-deep); }
.proto-root .route.v-r1 .c1 { fill: var(--brand-deep); }
.proto-root .route.v-r1 .k1 { fill: var(--brand); }
/* 命中 r2:全 A → A4 成本定价(绿) */
.proto-root .route.v-r2 .e2 { stroke: var(--ok); stroke-dasharray: none; }
.proto-root .route.v-r2 .n2 { stroke: var(--ok); stroke-width: 2; }
.proto-root .route.v-r2 .t2 { fill: var(--ok); }
.proto-root .route.v-r2 .c2 { fill: var(--ok); }
.proto-root .route.v-r2 .k2 { fill: var(--ok); }
.proto-root .route .legend { display: flex; gap: 16px; flex-wrap: wrap; justify-content: center; margin: 6px 0 2px; font-size: 10.5px; color: var(--ink-3); }
.proto-root .route .legend b { font-weight: 800; color: var(--ink-2); }
/* 结论带:一句人话结论 + 去向凭据 */
.proto-root .concl { display: flex; gap: 12px; align-items: flex-start; border: 1px solid var(--line); border-left: 4px solid var(--brand); border-radius: 10px; padding: 10px 14px; background: #fff; margin: 0 0 12px; }
.proto-root .concl svg { flex: 0 0 auto; margin-top: 2px; color: var(--brand); }
.proto-root .concl .cl-main { flex: 1 1 auto; min-width: 0; font-size: 12px; line-height: 1.6; color: var(--ink-2); }
.proto-root .concl .cl-main b { color: var(--ink); }
.proto-root .concl .cl-route { flex: 0 0 auto; text-align: right; }
.proto-root .concl .cl-route em { display: block; font-style: normal; font-size: 9.5px; font-weight: 800; letter-spacing: .1em; color: var(--ink-4); }
.proto-root .concl .cl-route b { display: block; font-size: 14px; font-weight: 800; color: var(--brand-deep); margin: 2px 0; }
.proto-root .concl .cl-route code { font-size: 9.5px; color: var(--ink-4); background: var(--soft); border: 1px solid var(--line-soft); border-radius: 5px; padding: 2px 6px; }
/* 窄屏(媒体块排在基础规则之后) */
@media (max-width: 860px) {
.proto-root .statrow { grid-template-columns: repeat(2, 1fr); }
.proto-root .concl { flex-direction: column; }
.proto-root .concl .cl-route { text-align: left; }
}
/* 命中边的流光 —— 连 @keyframes 一起包在动效许可里,减弱设置下全静 */
@media (prefers-reduced-motion: no-preference) {
.proto-root .route.v-r1 .p1, .proto-root .route.v-r2 .p2 { stroke-dasharray: 10 130; animation: jc-flow 2.6s linear infinite; }
.proto-root .route.v-r1 .p1 { stroke: rgba(51,112,255,.65); }
.proto-root .route.v-r2 .p2 { stroke: rgba(39,135,23,.6); }
@keyframes jc-flow { to { stroke-dashoffset: -140; } }
}
`;
/** 清单表(只读;原料 → 原标 → 定案 → 现标 → 依据,一行一颗可回核) */
const ROSTER_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
<thead><tr>
<th style="width:36px;min-width:36px"></th>
<th style="min-width:190px">原厂料</th>
<th style="width:44px;min-width:44px">原标</th>
<th style="min-width:190px">替代定案</th>
<th style="width:64px;min-width:64px">等级</th>
<th style="width:44px;min-width:44px">现标</th>
<th style="min-width:220px;width:100%">转标依据</th>
</tr></thead>
<tbody data-rows="mine:quote_alternative.list"><tr>
<td class="jc-rn"></td>
<td class="dimempty" data-h="原厂料"><span class="jc-vcell jc-mono" data-slot="pn" data-blank></span><span class="sub2" data-slot="brand" data-blank></span></td>
<td class="jc-ty" data-h="原标"><span class="jc-tbadge" data-class-of="orig_type" data-slot="orig_type"></span></td>
<td class="dimempty" data-h="替代定案"><span class="jc-vcell jc-mono" data-slot="alt_pn" data-blank></span><span class="sub2" data-slot="alt_brand" data-blank></span></td>
<td data-h="等级"><span class="gchip" data-class-of="alt_grade" data-slot="alt_grade" data-blank></span></td>
<td class="jc-ty" data-h="现标"><span class="jc-tbadge" data-class-of="type_tag" data-slot="type_tag"></span></td>
<td class="dimempty" data-h="转标依据"><span class="jc-vcell" data-slot="basis" data-blank></span></td>
</tr></tbody>
</table></div>`;
/** 落边图:两条整段 path(命中哪条哪条亮);共段是中性 spine */
const ROUTE_FIG = `<div class="route" data-class-of="case:routeKey">
<svg viewBox="0 0 660 210" fill="none">
<path class="rspine" d="M148 100 H195"/>
<path class="redge e1" d="M305 100 C355 100 380 50 460 50"/>
<path class="rpulse p1" d="M305 100 C355 100 380 50 460 50"/>
<path class="redge e2" d="M250 138 C250 172 340 168 460 168"/>
<path class="rpulse p2" d="M250 138 C250 172 340 168 460 168"/>
<path class="rtip k1" d="M448 44 l14 6 -14 6 z"/>
<path class="rtip k2" d="M448 162 l14 6 -14 6 z"/>
<rect class="rnode" x="8" y="70" width="140" height="60" rx="10"/>
<text class="rtxt" x="78" y="95" text-anchor="middle">询价受理清单</text>
<text class="rsub" x="78" y="113" text-anchor="middle">共 <tspan data-slot="quote_alternative_list_rows"></tspan> 条 · C 已清零</text>
<path class="rdia" d="M250 62 L305 100 250 138 195 100 Z"/>
<text class="rcond" x="250" y="104" text-anchor="middle">还有 B 类?</text>
<text class="rcond c1" x="330" y="38">是 · B 类 <tspan data-slot="quote_alternative_list_type_tag_b"></tspan> 条</text>
<text class="rcond c2" x="330" y="196">否 · 全 A 类</text>
<rect class="rnode n1" x="460" y="18" width="170" height="64" rx="10"/>
<text class="rtxt t1" x="545" y="45" text-anchor="middle">A3 · 渠道寻源</text>
<text class="rsub" x="545" y="63" text-anchor="middle">补上 B 类的货源价格</text>
<rect class="rnode n2" x="460" y="136" width="170" height="64" rx="10"/>
<text class="rtxt t2" x="545" y="163" text-anchor="middle">A4 · 成本定价</text>
<text class="rsub" x="545" y="181" text-anchor="middle">行情完备 · 直通核价</text>
</svg>
<div class="legend"><span>命中边实线亮 · 未命中留灰虚线</span><span>判定在你签字那一刻由系统现算 —— 要改去向,在验收卡的成果表上改现标</span></div>
</div>`;
export const ALT_ROSTER = doc({
badge: 'file-check',
kicker: '统一报价 · A2 替代选型 · 交付成果',
title: '询价受理清单',
titleRef: 'rfq_ref',
state: '收口 · 待放行',
css: ROSTER_CSS,
body:
`<div class="jc-derive">${ico('info', 14)}
<span>这份清单是 A2 的<b>收口成果</b>:上游受理记录逐条承继,待选型(原标 C)的料
已由 FAE 逐颗定案,系统按库内行情判稳转标(≤90 天且行情稳 → A,否则 → B 去寻源)——
询价受理记录就此转成<b>询价受理清单</b>,客户 <b data-slot="customer_name"></b>。
本页只读:要改哪一格,在验收卡的成果表上就地改,这里跟着重渲。</span>
</div>` +
part('收口总览', 'C 类清零 · 判稳定标', 'ok') +
`<div class="statrow">
<div class="stat"><b data-slot="quote_alternative_list_rows"></b><span>清单条目 · 逐条承继上游</span></div>
<div class="stat sa"><b data-slot="quote_alternative_list_type_tag_a"></b><span>A 类 · 行情完备</span></div>
<div class="stat sb"><b data-slot="quote_alternative_list_type_tag_b"></b><span>B 类 · 待补货源价</span></div>
<div class="stat sc"><b data-class-of="quote_alternative_list_type_tag_c" data-slot="quote_alternative_list_type_tag_c"></b><span>C 类 · 待选型未清(应为 0)</span></div>
</div>
<div class="workline">
<span>待选型 <b data-slot="c_total"></b> 颗</span><span class="sep">→</span>
<span>已填回替代 <b data-slot="filled_total"></b> 颗</span><span class="sep">→</span>
<span>判稳转 A <b data-slot="converted_a_total"></b> 颗</span><span class="sep">·</span>
<span>转 B 待寻源 <b data-slot="converted_b_total"></b> 颗</span><span class="sep">·</span>
<span>剔除说明:<b data-slot="skipped_note" data-blank></b></span>
</div>` +
part('询价受理清单', '<b data-slot="quote_alternative_list_rows"></b> 条 · 交下游的就是这一份') +
ROSTER_TABLE +
`<div class="jc-drop">${ico('info', 13)}
<span><b>原标</b>是上游受理时的三分类(留痕,本活动不改);<b>现标</b>是本活动的产物:
C 类经替代定案与判稳转成 A 或 B。现标定去向 —— 改一颗料的现标,下面的落边判定跟着变。
等级口径:<b>P2P</b> 同封装同引脚可直换 · <b>FUNC</b> 功能兼容需调整 · <b>OTHER</b> 部分兼容须打样验证。</span>
</div>` +
part('落边 · 这一签流向哪里', '出口按序求值 · 首个命中即出') +
ROUTE_FIG +
`<div class="concl">
${ico('git-branch', 15)}
<div class="cl-main"><b>选型结论</b> —— <span data-slot="summary"></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>&#12288;<span>本任务写下的询价受理清单与选型结论一并落账;
你在验收卡上就地改的那几格随之落定。</span></div>
</div>
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>命中的那一口此刻才发射</b>&#12288;<span>清单
<b data-slot="quote_alternative_list_rows"></b> 条随载荷交给上面亮线指向的下游;
另一口本次不起单 —— 放行之前,世界上什么都没有发生。</span></div>
</div>
</div>`,
foot: 'AI 拟稿 · FAE 审定放行 —— 签下去这份清单即转正并流向下游',
tag: '统一报价 · A2 替代选型 → 交下游',
});
@@ -0,0 +1,575 @@
/**
* A7 情报归档的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。
* 案例活动没有 `run`,自测不跑回合;测的是清单本身与四处对齐
* (判据谓词 / 出口载荷 / 剧本声明 / 两把尺的档位)。
*/
import { describe, expect, it } from 'vitest';
import { activityManifestSchema } from '@contracts';
import { evaluateCondition, evaluateUnaryTest } from '@sfeel';
import { createMockHost } from '@sdk';
import { A01_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();
}
});
/*
* **宽进严出**:起单一个必填都不设 —— 上游(A6 的三口)发的是
* `{rfqRef, customerCode, quoteNo, outcome, rows}`,而上一版必填前三个,
* 「补一单历史」那条单飞路直接被门口拦掉。
*/
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', 'quoteNo', 'outcome', 'rows', 'subject', 'body']) {
expect(properties[key], `入参少了 ${key}`).toBeDefined();
}
});
/*
* **输赢归因是必填** —— 这是 A7 的门面判据:件上的缺归因红带、执行卡的提交闸、
* 放行门 L1 三处同源(都数基座的 `_missing`)。声明丢了 required,三处一起失明。
*/
it('输赢归因标了 required —— 红带 / 提交闸 / 放行门三处同源的那个数才存在', () => {
const intel = manifest.declarations.ledger.find((d) => d.object === 'intel');
expect(intel?.fields.find((f) => f.name === 'cause_tag')?.required).toBe(true);
});
/*
* **竞对价是行级必填**:表级 required 会让交期 / 认证败的行永远缺一格,
* 把闸拦成死锁 —— 而那几行本来就没有竞对报价这回事。
*/
it('竞对价是行级必填:只有归因落「输在价格」的那一行才算缺', () => {
const intel = manifest.declarations.ledger.find((d) => d.object === 'intel');
const competitor = intel?.fields.find((f) => f.name === 'competitor_price');
expect(competitor?.required).toBeUndefined();
expect(competitor?.requiredWhen).toEqual({ column: 'cause_tag', value: 'PRICE' });
});
it('归因 / 竞对价 / 依据三列写死了「你一格都不写」', () => {
const collect = manifest.assets.businessModel.steps?.find((s) => s.item === 'S1_collect')?.guide ?? '';
expect(collect).toContain('cause_tag / competitor_price / evidence');
expect(collect).toMatch(/系统不替人归因/);
});
it('结果、归因、败价幅度、入库口径四列都给了取值域 —— 条数由基座现数,不由活动自报', () => {
const intel = manifest.declarations.ledger.find((d) => d.object === 'intel');
const tally = (name: string): readonly string[] =>
intel?.fields.find((f) => f.name === name)?.tally ?? [];
expect(tally('deal_result')).toEqual(['WON', 'LOST']);
expect(tally('cause_tag')).toEqual(['PRICE', 'LEADTIME', 'CERT', 'SPEC', 'RELATION', 'OTHER']);
expect(tally('price_gap_band')).toEqual(['SLIGHT', 'MODERATE', 'STRUCTURAL']);
expect(tally('share_scope')).toEqual(['PENDING', 'FULL', 'MASKED', 'INTERNAL']);
});
it('没有一列滥标 expect —— `_gaps` 不是拿来常亮的', () => {
const flagged = manifest.declarations.ledger.flatMap((d) => d.fields.filter((f) => f.expect === true));
expect(flagged.length).toBe(0);
});
/*
* **三张共享域表已退役**:落地率单案例算不出来(让 AI 编一个数是界面撒谎)、
* 客户画像 A1 已经持有那张主数据、历史价格情报与情报卡是同一份事实。
*/
it('共享域三张表已退役 —— 只剩情报卡与归档结论两张', () => {
const addresses = manifest.declarations.ledger.map((d) => `${d.domain}.${d.object}`);
expect(addresses).toEqual(['quote_archive.intel', 'quote_archive.verdict']);
});
/*
* 情报要**下次查得到**:料号 / 客户 / 时间 / 我方价 / 成交价 / 竞对价 / 输赢原因
* 七样缺一样,这条情报到下一单就哑了。
*/
it('情报卡是「下次查得到的形状」:料号 · 客户 · 时间 · 两个价 · 竞对价 · 归因', () => {
const intel = manifest.declarations.ledger.find((d) => d.object === 'intel');
const names = new Set((intel?.fields ?? []).map((f) => f.name));
for (const col of ['pn', 'customer_code', 'quoted_at', 'quoted_price', 'final_price', 'competitor_price', 'cause_tag']) {
expect(names.has(col), `情报卡少了 ${col}`).toBe(true);
}
expect(intel?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['rfq_ref', 'pn']);
});
it('三条常备案例形状真不同:流内失单 / 成交归档 / 单飞补历史', () => {
expect(manifest.samples.length).toBe(3);
const [lost, won, solo] = manifest.samples;
expect(lost?.input['outcome']).toBe('lost');
expect(won?.input['outcome']).toBe('won');
expect(solo?.input['rows']).toBeUndefined();
expect(String(solo?.input['body'] ?? '').length).toBeGreaterThan(40);
for (const sample of manifest.samples) expect(sample.expect?.ports).toEqual(['archived']);
});
});
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.6 的 S1 结果登记 / S2 画像更新 / S3 落地率统计,一步一项 */
it('计划项照 SOP 一步一项:归一 + 逐条归因 + 入库口径拍板 + 放行', () => {
expect(items(model.cmmnXml)).toEqual(['S1_collect', 'S2_attribute', 'T_scope', 'T_release']);
});
it('三处人的门都标了人工激活:逐条归因 · 入库口径拍板 · 归档放行(归一是查表,不标)', () => {
expect(cmmn.manualActivation).toEqual(['S2_attribute', 'T_scope', 'T_release']);
});
it('不用阶段 / 里程碑 / 裁量项', () => {
for (const tag of ['<stage', '<milestone', '<planningTable', '<discretionaryItem']) {
expect(model.cmmnXml, `不该出现 ${tag}`).not.toContain(tag);
}
});
/*
* **归因齐了才叫人拍口径** —— 这个活动的要害:哨兵读的是人自己填的那一格
* (基座按 required 现数的缺项 + 只有人交回才产生的 attributed_total),
* 而不是模型判出来的结论列。A6 两版栽的就是这个:
* **「该不该叫人」不能由被叫的那一方说了算**。
*/
it('入库口径拍板的哨兵读**人自己填的那一格**,不读模型判出来的结论', () => {
const condition = sentryIf(model.cmmnXml, 's_attributed');
expect(condition).toContain('quote_archive_intel_missing = 0');
expect(condition).toContain('attributed_total');
expect(condition).toContain('quote_archive_intel_share_scope_pending > 0');
// 归因落齐、口径还待定 → 叫人
expect(
safeEval(condition, {
attributed_total: 2,
quote_archive_intel_rows: 2,
quote_archive_intel_missing: 0,
quote_archive_intel_share_scope_pending: 2,
}),
).toBe(true);
// 还有行没归因 → 先别叫(问他口径之前,先让他把归因填完)
expect(
safeEval(condition, {
attributed_total: 2,
quote_archive_intel_rows: 2,
quote_archive_intel_missing: 1,
quote_archive_intel_share_scope_pending: 2,
}),
).toBe(false);
// 人还没交回执行单 → 门不开
expect(
safeEval(condition, {
quote_archive_intel_rows: 2,
quote_archive_intel_missing: 0,
quote_archive_intel_share_scope_pending: 2,
}),
).toBe(false);
});
/*
* 入场哨兵也归「机检只读基座数」管(A1 第四次违反就藏在哨兵里)——
* 读活动自报的条数,模型不记那个账,门就永不开,而它一步也没做错。
*/
it('交人归因的入场哨兵读基座行数,不读自报的归档条数', () => {
const condition = sentryIf(model.cmmnXml, 's_collected');
expect(condition).toContain('quote_archive_intel_rows');
expect(condition).not.toContain('archived_total');
expect(safeEval(condition, { intel_total: 2, quote_archive_intel_rows: 2, collected_total: 2 })).toBe(true);
// 行落了但归一那一轮还没走完(collected_total 没写过)→ 门不开
expect(safeEval(condition, { intel_total: 2, quote_archive_intel_rows: 2 })).toBe(false);
});
it('放行门哨兵带 ifPart:每条有归因 + 口径守恒 + 待定清零 + 定盘记过,全读基座数', () => {
const condition = sentryIf(model.cmmnXml, 's_ready');
expect(condition).toContain('quote_archive_intel_missing = 0');
expect(condition).toContain('quote_archive_intel_share_scope_pending = 0');
const done = {
scope_decided_total: 1,
quote_archive_intel_rows: 3,
quote_archive_intel_missing: 0,
quote_archive_intel_share_scope_pending: 0,
quote_archive_intel_share_scope_full: 1,
quote_archive_intel_share_scope_masked: 2,
quote_archive_intel_share_scope_internal: 0,
};
expect(safeEval(condition, done)).toBe(true);
// 还有一行缺归因 → 门不开
expect(safeEval(condition, { ...done, quote_archive_intel_missing: 1 })).toBe(false);
// 还有条目压在待定上 → 门不开(人还没拍板)
expect(
safeEval(condition, { ...done, quote_archive_intel_share_scope_pending: 1, quote_archive_intel_share_scope_masked: 1 }),
).toBe(false);
// 口径没拍过(scope_decided_total 没写过)→ 门不开
expect(safeEval(condition, { ...done, scope_decided_total: undefined })).toBe(false);
});
it('每个计划项都有剧本', () => {
const ids = new Set((model.steps ?? []).map((s) => s.item));
for (const id of items(model.cmmnXml)) expect(ids.has(id), `${id} 没有剧本`).toBe(true);
});
/* 说明书只讲整单的事;逐步做法归剧本 —— 两处各写一份会分叉出不存在的项名(A1 的账) */
it('说明书只讲整单的事:成品、怎么说话、怎么收口', () => {
const narrative = model.narrative;
for (const mark of ['§0 开案', '§Z 结案', '一路上要说话']) {
expect(narrative, `说明书少了「${mark}」`).toContain(mark);
}
expect(narrative, '说明书不该逐步教做法(那是剧本的事)').not.toContain('doneWhen');
});
/*
* 断点那几拍(依赖断点 / 目标缺口)剧本的 step guide 不摊开,模型手上只剩说明书 ——
* 那张决策卡的三处写死必须在说明书里也有一份,否则它会自编一张(A6 实测)。
*/
it('说明书写死了那一张决策卡的 itemId / ask / 三个 value —— 断点那几拍只剩它', () => {
const narrative = model.narrative;
expect(narrative).toContain('"itemId":"T_scope"');
expect(narrative).toContain('按哪一档进库?');
for (const value of ['full', 'masked', 'internal']) {
expect(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 intel = manifest.declarations.ledger.find((d) => d.object === 'intel');
const names = new Set((intel?.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);
}
});
/*
* **A7 是终点**:流程包 E14(`A7 → end_arch`)是无条件边 —— 没有去向要选,
* 所以一口必交付、选择组为空。摆一个空的选择组会让基座退回「人选一口」,
* 而根本没有第二口可选。
*/
it('只有一口 archived,且没有选择组 —— 终点不是岔路口', () => {
expect(manifest.contract.output.mandatory.map((p) => p.name)).toEqual(['archived']);
expect(manifest.contract.output.selective).toEqual([]);
});
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)。
* A-01 的出参就是台账 `price_gap_band` 的取值:模型算完照抄,不必再翻译一道。
*/
describe('败价幅度 A-01 —— 价差 → 下一单怎么用这条情报', () => {
const table = A01_DMN.tables.find((t) => t.id === 'a01-band');
it('三个点以内 → 擦肩而过', () => {
expect(decideBy(table, { gap: 0.02 })).toMatchObject({ outputs: ['SLIGHT'] });
});
it('三到十个点 → 够得着(FIRST 命中,第二行就是这一档)', () => {
expect(decideBy(table, { gap: 0.08 })).toMatchObject({ rule: 'a01-2', outputs: ['MODERATE'] });
});
it('超过一成 → 结构性价差,下一单别再报同一价位', () => {
expect(decideBy(table, { gap: 0.204 })).toMatchObject({ outputs: ['STRUCTURAL'] });
});
it('三个档位码与台账取值域逐字一致', () => {
const tally = manifest.declarations.ledger
.find((d) => d.object === 'intel')
?.fields.find((f) => f.name === 'price_gap_band')?.tally;
expect(new Set(table?.rules.map((r) => String(r.then[0])))).toEqual(new Set(tally));
});
});
describe('入库口径建议 A-02 —— 归因 → 建议哪一档(建议归尺,结论归人)', () => {
const table = A01_DMN.tables.find((t) => t.id === 'a02-scope');
it('输在价格 → 建议脱敏:竞对的价是他给客户的,不是给我们的', () => {
expect(decideBy(table, { cause_tag: 'PRICE' })).toMatchObject({ outputs: ['masked'] });
});
it('不涉第三方报价的败因 → 建议全量入库', () => {
for (const tag of ['LEADTIME', 'CERT', 'SPEC', 'RELATION', 'OTHER']) {
expect(decideBy(table, { cause_tag: tag }), tag).toMatchObject({ outputs: ['full'] });
}
});
it('出参就是决策卡上那三个 value —— 建议标得上,人才点得准', () => {
const guide = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_scope')?.guide ?? '';
for (const value of new Set(table?.rules.map((r) => String(r.then[0])))) {
expect(guide, `拍板剧本少了 ${value}`).toContain(`"${value}"`);
}
});
});
describe('两把尺的档位三处同一份 —— 判定表 / 剧本 / 工作单', () => {
const attribute = manifest.assets.businessModel.steps?.find((s) => s.item === 'S2_attribute')?.guide ?? '';
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'archive-worksheet')?.html ?? '';
it('六个归因码剧本与工作单都写着(大写,一个字母不许差)', () => {
for (const code of ['PRICE', 'LEADTIME', 'CERT', 'SPEC', 'RELATION', 'OTHER']) {
expect(attribute, `剧本少了 ${code}`).toContain(code);
expect(sheet, `工作单少了 ${code}`).toContain(code);
}
});
it('三个败价幅度码剧本里写死', () => {
for (const code of ['SLIGHT', 'MODERATE', 'STRUCTURAL']) {
expect(attribute, `剧本少了 ${code}`).toContain(code);
}
});
it('败价幅度的三档门槛剧本与工作单说的是同一组数', () => {
for (const threshold of ['0.03', '0.10']) expect(attribute, `剧本少了 ${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(['archive-worksheet', 'archive-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('archive-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 === 'archive-worksheet')?.html ?? '';
expect(sheet).toContain('jc-selcard');
expect(sheet).toContain('输赢归因六档');
expect(sheet).toContain('败价幅度 A-01');
expect(sheet).toContain('data-edit="quote_archive.intel"');
});
it('缺归因红带与提交闸同源 —— 件上摆的是基座的 `_missing` 与主键名单', () => {
for (const name of ['archive-worksheet', 'archive-roster']) {
const html = manifest.assets.viewTemplates.find((t) => t.name === name)?.html ?? '';
expect(html, name).toContain('quote_archive_intel_missing');
expect(html, name).toContain('quote_archive_intel_missing_keys');
}
});
/*
* 编辑面精确等于人在那个局面的职权面 + **行的钥匙 pn**:
* 主键格不挂 `data-col`,服务端按「全表一致列」补键,两行会对到同一行上
* (A2 浏览器走查逮过,e2e 走 API 自带主键测不出来)。
*
* 两张件的编辑面**故意不同**:干活那张收归因四列,收口那张只让改归因与依据
* (= `editableAtRelease`)—— 不同工作各有各的件。
*/
it('工作单的编辑面 = 归因 + 竞对价 + 竞对名 + 依据 + 行的钥匙 pn', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'archive-worksheet')?.html ?? '';
const cols = [...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]);
expect(new Set(cols)).toEqual(new Set(['pn', 'cause_tag', 'competitor_price', 'competitor_name', 'evidence']));
});
it('收口件的编辑面 = editableAtRelease 那两列 + 行的钥匙 pn', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'archive-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 === 'archive-roster')?.html ?? '';
for (const code of ['pending', 'full', 'masked', 'internal']) {
expect(roster).toContain(`quote_archive_intel_share_scope_${code}`);
}
});
/*
* 一口活动的落边图**不冒充判过**:A7 只有一口且无条件(E14),
* 所以它不投 `case:routeKey`(那是「选出来的」才有的东西),
* 摆的是「这一签之后谁用得上」—— 右边三个用处各挂本单条数。
*/
it('收口件摆落边图:一口无条件,不投 routeKey 冒充选择', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'archive-roster')?.html ?? '';
expect(roster).toContain('归档闭环');
expect(roster).toContain('quote_archive_intel_cause_tag_price');
expect(roster, '一口活动不该摆去向选择').not.toContain('case:routeKey');
});
it('模版只做取值 —— data-class-of 不以 data-slot 为前提(纯色块曾整条静默失色)', () => {
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'archive-worksheet')?.html ?? '';
expect(sheet).toContain('<i data-class-of="deal_result"></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-archive',
version: '1.0.0',
data: { rfqRef: 'RFQ-2026-0777', rows: [] },
message: undefined,
outputPorts: { mandatory: ['archived'], selective: [] },
prohibitions: [],
discretion: [],
} as const;
describe('代码入口 —— 案例活动没有 run,不是残包', () => {
it('默认导出是一个有名字的活动定义', () => {
expect(activity.name).toBe('quote-archive');
});
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 A01_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('&gt;', '>').replaceAll('&lt;', '<').replaceAll('&amp;', '&');
}
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
@@ -0,0 +1,240 @@
/**
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(A1 的账)。
*/
import { describe, expect, it } from 'vitest';
import { evaluateCondition } from '@sfeel';
import { ARCHIVE_PHASES, ARCHIVE_STEPS, CMMN_XML } 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 ARCHIVE_STEPS) {
expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
}
});
it('图上每一步都有剧本', () => {
const scripted = new Set(ARCHIVE_STEPS.map((s) => s.item));
for (const id of ITEM_IDS) expect(scripted.has(id), `${id} 没有剧本`).toBe(true);
});
it('清单里挂的就是这份剧本(不是另抄一份)', () => {
expect(manifest.assets.businessModel.steps).toBe(ARCHIVE_STEPS);
});
});
describe('doneWhen 是「成果已存在」的判据,不是「该不该做」的判据', () => {
const doneWhen = (item: string): string => ARCHIVE_STEPS.find((s) => s.item === item)?.doneWhen ?? '';
const evalAt = (item: string, scope: Record<string, unknown>): boolean => {
try {
return evaluateCondition(doneWhen(item), scope);
} catch {
return false;
}
};
/*
* **开局一步都不该被自动收掉**:S-FEEL 里未绑定的名字参与比较为假,
* 靠的就是这一条 —— 写 `!= null` 那一步会开局即收(A1 踩过)。
*/
it('**开局一步都不该被自动收掉** —— 两种开局都要 false', () => {
// 流内开局:上游载荷在,台账一行没写
expect(
evalAt('S1_collect', {
rows: [{ pn: 'PESD2ETH-XR' }],
quote_archive_intel_rows: 0,
}),
).toBe(false);
// 单飞开局:什么都没有
expect(evalAt('S1_collect', { subject: '补个归档' })).toBe(false);
});
it('整份落表、行数对上、归一那一轮走完 → 收得掉', () => {
expect(evalAt('S1_collect', { intel_total: 2, quote_archive_intel_rows: 2, collected_total: 2 })).toBe(true);
});
it('**第一拍写完行就收步是不许的** —— collected_total 没写过,未绑定的名字比大小为假', () => {
expect(evalAt('S1_collect', { intel_total: 2, quote_archive_intel_rows: 2 })).toBe(false);
});
it('行数两边对不上 → 收不掉(漏落一行当场现形,不等到第 10 拍)', () => {
expect(evalAt('S1_collect', { intel_total: 3, quote_archive_intel_rows: 2, collected_total: 2 })).toBe(false);
});
/*
* **三项不给 doneWhen**:交人归因(发卡触达真人)、入库口径拍板(要人拍)、
* 归档放行(签下去触达世界)—— 给了等于给引擎一把替人签字的钥匙。
*/
it('**逐条归因、入库口径拍板、放行三项都不给 doneWhen**', () => {
for (const item of ['S2_attribute', 'T_scope', 'T_release']) {
const step = ARCHIVE_STEPS.find((s) => s.item === item);
expect(step, `${item} 没有剧本`).toBeDefined();
expect(step?.doneWhen, `${item} 不该有 doneWhen`).toBeUndefined();
}
});
it('归一那一步声明了 reads: rows —— 上游载荷置前不截断', () => {
expect(ARCHIVE_STEPS.find((s) => s.item === 'S1_collect')?.reads ?? []).toContain('rows');
});
/*
* **一单同一张卡只发一次**,且缺信息不再走补全卡(2026-08-02 全局退役)——
* 剧本里不许残留「还缺就再发一张卡」这条已经不存在的路。
*/
it('剧本不教补全卡,也不留「还缺就再发一张」的死路', () => {
for (const step of ARCHIVE_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 = ARCHIVE_STEPS.find((s) => s.item === 'T_scope')?.guide ?? '';
const branch = guide.indexOf('找到了');
const skeleton = guide.indexOf('【发一张决策卡');
expect(branch, 'T_scope 的 guide 没有「人点过没有」这一岔').toBeGreaterThanOrEqual(0);
expect(skeleton, 'T_scope 的 guide 没有发卡骨架').toBeGreaterThan(branch);
expect(guide).toMatch(/不许再发|别再发第二张卡/);
});
/*
* **拍板那三条路都得动 share_scope** —— A6 2026-08-03 逼出来的结构账:
* 有一条路写着「口径不动」,人点完了待定条数还是那个数,入场哨兵照旧成立,
* 模型读同一段做法就再发一张卡(实测同一张连发六次)。
* 「办完了」得在数上看得见,措辞治不了。
*/
it('拍板三条路各把 share_scope 改成一个结论码 —— 没有一条留在 PENDING', () => {
const guide = ARCHIVE_STEPS.find((s) => s.item === 'T_scope')?.guide ?? '';
for (const code of ['FULL', 'MASKED', 'INTERNAL']) {
expect(guide, `拍板剧本少了 ${code} 这条路`).toContain(`share_scope 改 **${code}**`);
}
expect(guide).toContain('quote_archive_intel_share_scope_pending');
});
/*
* **三个结论码只有拍板那一步写得出来**:归一那一步替他写了,那张卡就永远不会发,
* 这条对外边界成了摆设(A6 实测:一单从头到尾 released 而没有人拍过板)。
*/
it('归一那一步写死「口径一律 PENDING」,三个结论码一个都不许写', () => {
const collect = ARCHIVE_STEPS.find((s) => s.item === 'S1_collect')?.guide ?? '';
expect(collect).toContain('PENDING');
expect(collect, '归一剧本没说清三个结论码不许写').toMatch(/三个码这一步一个都不许写/);
const attribute = ARCHIVE_STEPS.find((s) => s.item === 'S2_attribute')?.guide ?? '';
expect(attribute, '归因那一步不该顺手写入库口径').toContain('别在这一拍写入库口径');
});
/*
* start 墙已拆(2026-08-02):动它就算启动。剧本里再教 start 只是白烧 token,
* 而且学不稳的礼仪就是死法。
*/
it('剧本不教 start 礼仪 —— 那道墙已经拆了(说「不必单发 start」可以,教它先按一下不行)', () => {
for (const step of ARCHIVE_STEPS) {
expect(step.guide, `${step.item} 的 guide 还在教 start`).not.toMatch(/先\s*start|务必\s*start|要先发\s*start/);
}
});
/*
* 【目标缺口】那一段摆的是计划项的**中文名**;当下可为为空(依赖断点)那几拍,
* 模型手上只剩中文名,于是把它当 itemId 写了进去 —— 「计划项不存在」连拒两拍。
* 发卡骨架旁边必须点名说清写英文 id。
*/
it('发卡骨架写死了 itemId 是英文项号,并点名中文名不是 id', () => {
const guide = ARCHIVE_STEPS.find((s) => s.item === 'T_scope')?.guide ?? '';
expect(guide).toContain('"itemId":"T_scope"');
expect(guide, '没告诉模型中文名不能当 itemId').toMatch(/别写中文名/);
for (const value of ['full', 'masked', 'internal']) {
expect(guide, `发卡骨架少了选项 ${value}`).toContain(`"${value}"`);
}
});
it('剧本不教 listen 等回音 —— 那是一条挂到天荒地老的路', () => {
for (const step of ARCHIVE_STEPS) {
expect(step.guide, `${step.item} 的 guide 教了 listen`).not.toMatch(/"kind":"listen"|立监听|发一个\s*listen/);
}
});
/* 执行卡零输入(2026-08-02 用户定):填写全在工作区的件上 */
it('执行卡零输入 —— 剧本明写不许往卡里塞 blocks / fields', () => {
const guide = ARCHIVE_STEPS.find((s) => s.item === 'S2_attribute')?.guide ?? '';
expect(guide).toMatch(/别往卡里塞/);
expect(guide).toContain('执行卡零输入');
});
});
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 ARCHIVE_STEPS) {
for (const address of step.writes) {
expect(declaredLedger, `${step.item} 要写 ${address}`).toContain(address);
}
}
});
it('每一步要调的工具都在清单的工具声明里', () => {
for (const step of ARCHIVE_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 ARCHIVE_STEPS) {
expect(step.view, `${step.item} 没定画面`).toBeDefined();
expect(names.has(step.view ?? ''), `${step.item} 的 view「${step.view}」不在模版名单里`).toBe(true);
}
});
it('每一步都说得出它交出什么', () => {
for (const step of ARCHIVE_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 ARCHIVE_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(ARCHIVE_PHASES.flatMap((p) => p.items));
it('**除放行外每一项恰好属于一个阶段**', () => {
const planned = ARCHIVE_STEPS.map((s) => s.item).filter((id) => id !== 'T_release');
expect(planned.filter((id) => !inPhase.has(id))).toEqual([]);
});
it('一项不进两个阶段', () => {
const all = ARCHIVE_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(ARCHIVE_STEPS.map((s) => s.item));
expect([...inPhase].filter((id) => !ids.has(id))).toEqual([]);
});
});
+330 -54
View File
@@ -1,95 +1,371 @@
/**
* A7 情报归档的案例模型 —— **一步落三张表**(2026-07-31 照 A1 的状态机模式重做)。
* A7 情报归档的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
*
* 归档这件事没有分支,也没有判断:一单报价有了结果,把它沉淀成下一单用得上的情报 ——
* 历史价、客户画像、落地率统计。上一版把这三样各建成一个计划项,
* 可它们**互不依赖、同一拍就能写完**,分开只换来两次多余的 finish。
* 业务出处:`docs/MVP示例工作流.html` §1.6(S1 结果登记 → S2 画像更新 → S3 落地率统计),
* 按 A4/A6 同一条形状重建(2026-08-03 重写 @3.0.0):
*
* 两项:
* T_archive —— 一步落三张表(历史价 / 客户画像 / 落地率);
* T_release —— 封卷放行(人工激活)。
* 上游(A6 三口之一的载荷 `{rfqRef, customerCode, quoteNo, outcome, rows}`)进来 →
* 归一成**情报卡**底表(一颗料一条:报价 / 成交价 / 结果 / 时间)→ **一张执行单**
* 交 BD:逐条落**输赢归因**与竞对价(归因那一列 AI 一格都不写)→ 按败价幅度 A-01
* 给复用建议 → **入库口径要人拍板**(决策卡:全量 / 脱敏 / 只留内部)→ 放行归档闭环。
*
* 归档的价值全在**下一单**:这一单的拒绝原因,是下一单定价时的判据。
* 所以「不得空归档」是它唯一的硬判据 —— 三张表都写了才算办完。
* 上一版(@2.1.0)六处不合现行口径,这一版整个置换:
* ① 入参必填 `rfqRef + customerCode + quoteNo` —— 而宽进严出是全线口径,归一在活动内做;
* ② 两条判据读活动自报的 `reason_logged_total` / `profile_updated_total` ——
* 「机检只读基座数得出来的数」违反史第七类:那个数模型自己写,写 1 就过;
* ③ 台账一个 `required` 都没有 —— 执行卡的闸没牙,人不填也能交卷;
* ④ **没有人的判断**:归档全由 AI 自己写完自己放行,而输赢归因恰恰是只有跟单的人
* 才说得出的事(客户到底为什么不下单,系统里没有一个字段答得了);
* ⑤ 三张共享域表(`market.price_history` / `customer.profile` / `shared.landing_stats`)
* 同批退役,墓志铭在清单里;
* ⑥ 那段 `run()` 删掉 —— 案例活动不会调它。
*
* 【为什么归档也要叫人】它是全流程最后一站,**这一单之后再没有人会回头看它**。
* 归因写错没有下一道工序纠正,而它会一路流进下一单的判据里 ——
* 「价格高」这种由 AI 从「客户没回」推出来的结论,三个月后没人记得它是编的。
* 所以两处人:归因逐条落在执行单上,入库口径一张决策卡拍板。
*/
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
<case id="archive">
<!--
案卷项的 id **就是任务视图里的键名**。上游载荷(rows)与本活动台账列都摊平到
任务视图顶层,照它们命名;quote_archive_* 那几个是**基座读数**
(行数 / 缺必填 / 按值现数),由基座现算,模型碰不着。
-->
<caseFileModel>
<caseFileItem id="quoteNo" name="报价单号(开案即有)"/>
<caseFileItem id="archived_total" name="已归档条数"/>
<caseFileItem id="rows" name="上游条目(报价函行,流内开案即有)"/>
<caseFileItem id="intel_total" name="情报卡条数(自报)"/>
<caseFileItem id="collected_total" name="已归一落表的条数(归一走完的凭据)"/>
<caseFileItem id="attributed_total" name="已落归因的条数(人环走完的凭据)"/>
<caseFileItem id="scope_decided_total" name="入库口径已拍板"/>
<caseFileItem id="quote_archive_intel_rows" name="情报卡行数(基座现数)"/>
<caseFileItem id="quote_archive_intel_missing" name="缺归因的行数(基座现数)"/>
<caseFileItem id="quote_archive_intel_share_scope_pending" name="入库口径还待定的条数(基座现数)"/>
</caseFileModel>
<casePlanModel id="plan" name="情报归档" autoComplete="true">
<sentry id="s_archived">
<onPart id="op_ar" caseFileItemRef="archived_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_ar"><condition>archived_total &gt; 0</condition></ifPart>
<!--
① 归一完了 —— 交 BD 归因那一步的入场前提。
行数两边对上(一边是基座现数),且归一那一轮走完(collected_total 是归一之后
显式写下的数;「大于等于 0」对「写过的 0」为真、对「没写过」为假)。
-->
<sentry id="s_collected">
<onPart id="op_cl" caseFileItemRef="collected_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_cl"><condition>intel_total &gt; 0 and quote_archive_intel_rows = intel_total and collected_total &gt;= 0</condition></ifPart>
</sentry>
<!--
② **归因齐了才轮到问入库口径** —— 这个活动的要害(与 A6「还价才叫人」同一形状)。
它读的是**人自己填的那一格**(缺归因的行数由基座按 required 现数,
attributed_total 只在人交回执行单之后才产生),不是模型判出来的结论列。
A6 两版都栽在同一个病根上 —— **「该不该叫人」不能由被叫的那一方说了算**。
入库口径这一格也照同一条办:AI 归一时一律写 PENDING(过渡态),
FULL / MASKED / INTERNAL 三个码**只有拍板那一步写得出来**;
待定条数归零,这一步就不会再找人,放行门也才开得了。
-->
<sentry id="s_attributed">
<onPart id="op_at" caseFileItemRef="attributed_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_at"><condition>attributed_total &gt; 0 and quote_archive_intel_rows &gt; 0 and quote_archive_intel_missing = 0 and quote_archive_intel_share_scope_pending &gt; 0</condition></ifPart>
</sentry>
<!--
── 放行门的入场条件:**认「办完了没有」,且读基座数**────────────────
· 每条都有归因(quote_archive_intel_missing 由基座按 required 现数,
与执行卡的提交闸、放行门 L1 同一个数同一套算法);
· **没有还压在待定口径上的条目**(拍板那一下把 PENDING 换成三个结论码之一);
· 口径拍过了(scope_decided_total 是拍板那一步显式写下的记账数);
· 口径四档相加 = 行数(守恒,两边都是基座按值现数)。
-->
<sentry id="s_ready">
<onPart id="op_rd" caseFileItemRef="scope_decided_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_rd"><condition>scope_decided_total &gt; 0 and quote_archive_intel_rows &gt; 0 and quote_archive_intel_missing = 0 and quote_archive_intel_share_scope_pending = 0 and quote_archive_intel_share_scope_full + quote_archive_intel_share_scope_masked + quote_archive_intel_share_scope_internal + quote_archive_intel_share_scope_pending = quote_archive_intel_rows</condition></ifPart>
</sentry>
<!-- 一步落三张表:它们互不依赖,同一拍写完 -->
<humanTask id="T_archive" name="情报归档:落历史价、客户画像、落地率统计">
<itemControl><repetitionRule id="r_ar"/></itemControl>
<!-- ① 归一情报卡:一颗料一条,承继报价与结果,口径一律先落待定 -->
<humanTask id="S1_collect" name="归一情报卡:逐条承继报价与结果,标上报价日期">
<itemControl><repetitionRule id="r_s1"/></itemControl>
</humanTask>
<humanTask id="T_release" name="封卷放行">
<entryCriterion id="ec_rel" sentryRef="s_archived"/>
<!--
② 逐条归因 —— **一张执行单**(输赢的缘由只有跟单的人说得出)。
manualActivation + 不给 doneWhen(发卡触达真人,引擎不替人按)。
-->
<humanTask id="S2_attribute" name="逐条落输赢归因与竞对价:工作单上一条一格">
<entryCriterion id="ec_s2" sentryRef="s_collected"/>
<itemControl><manualActivationRule id="m_s2"/></itemControl>
</humanTask>
<!--
③ 入库口径拍板 —— 情报进不进共享库、要不要脱敏,这一下触达公司对外的边界:
竞对报价与客户身份写进共享库之后,下一单谁都读得到。必须人工激活。
-->
<humanTask id="T_scope" name="入库口径拍板:这一单的情报按哪一档进库">
<entryCriterion id="ec_sc" sentryRef="s_attributed"/>
<itemControl><manualActivationRule id="m_sc"/><repetitionRule id="r_sc"/></itemControl>
</humanTask>
<!-- 归档闭环放行:终局唯放行 —— 触达世界,必须人工激活 -->
<humanTask id="T_release" name="归档闭环验收放行">
<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 开案:**一单报价有了结果,把它沉淀成下一单用得上的情报**。来路两种,形状都收(宽进):
· **流内转来**:任务数据里有 \`rows\` 数组 —— A6 的报价函行(每条带报价、客户表态与定案),
外加 \`outcome\`(won / lost)说明这一单是成了还是丢了;这是最全的一种来路;
· **单飞**:只有一段需求文字,说了哪一单、给谁报的、什么价、结果如何。
**一步就能办完** —— 三张表互不依赖,同一拍写完:
· \`market.price_history\`:这一单报了什么价、什么时候报的、结果如何、拒绝原因是什么;
· \`customer.profile\`:客户画像该更新什么(价格敏感度、决策周期、常用条款);
· \`shared.landing_stats\`:这个月的落地率、A 类命中、三日回复率。
**成品只有一样**:一份**情报卡清单** —— 一颗料一条,每条都有**输赢归因**,
每条都定了**入库口径**。人签的不是「归了个档」,是**这一单留下了什么、下一单谁用得上**。
**不得空归档**:三张表都得有东西。「这一单没什么好记的」不是理由 ——
结果本身就是情报,拒绝的原因尤其是。写完把 archived_total 记上,再 finish。
**这一单的价值全在下一单**:今天记下的败因,是下次定价时的判据;今天记下的成交价,
是下次的基准。所以归档不是收尾的仪式,是情报生产。
**「客户没回」不是原因** —— 问不出来就如实写「说不清」,**不许编一个听着合理的理由**:
编出来的原因会一路流进下一单的判据里,而那时已经没人记得它是编的。
【封卷放行】**先 start T_release**(人工激活),提交前用 judge 裁掉裁量判据
(criterion 照判据名原样写),然后 submit。终局唯放行:你不能自己宣布完成。
**能算的不问人,该人说的不代说**:败价幅度 A-01(价差 → 复用建议)、入库口径建议 A-02
(归因 → 建议档位),两支判定表说了算,你不临场拍;而**输赢归因、竞对价、客户原话
三列是人落的,你一格都不写** —— 客户到底为什么不下单,系统里没有一个字段答得了。
【一路上要说话】每一拍带一句 say,说**业务的话**:这一单最后怎么样、为什么、
下次遇到同类该注意什么。一两句、口语。别报「写草稿 N 行」这种系统词。`;
**三步走**:归一情报卡 → 交 BD 逐条归因 → 入库口径拍板 → 放行闭环。
**这一单要经过人两次**:BD 在情报归档工作单上逐条落归因(那张单绑着整份情报卡,
归因缺一条按不下提交 —— 基座的闸看着,他填的格子直接落账);
**入库口径另叫他拍一次板**(一张决策卡)。竞对报价与客户身份写进共享情报库之后,
下一单谁都读得到 —— 这条边界不该由系统自己越过。最后 BD 放行签字。
**这是全流程最后一站**:放行之后没有下一个节点,只有一口 \`archived\`(归档闭环)。
去向不必你选,也没得选。
【这一单只有一个岔路口,它长这样 —— 无论哪一拍都照这个发,别自己另编一张】
入库口径要人拍板,那张决策卡**三处写死**:
· \`"itemId":"T_scope"\` —— 英文计划项号。**别写中文名「入库口径拍板」**:中文名不是 id,
写了当场被拒(【目标缺口】那一段摆的就是中文名,别照它抄);
· \`"ask":"<N> 条情报待定入库口径,按哪一档进库?"\` —— 只把 N 换成条数,**别的字一个都不改**。
换了问法,系统就认不出这是同一张卡:人已经点过的那张会被你再发一遍;
· 三个 \`value\`:\`full\`(全量入库)、\`masked\`(脱敏入库)、\`internal\`(只留内部)。
label 与 consequence 可以按本单的数改写,**value 不行**。
**FULL / MASKED / INTERNAL 三个码只有拍板那一步写得出来** —— 归一那一步遇到情报一律落
PENDING,替他写了口径,那张卡就永远不会发,这条边界成了摆设。
拍板之前**别急着发卡也别绕路** —— 归一那一步先把情报卡写完、finish;
下一拍引擎会把拍板那一步的做法摊开给你,那时再发。
【一路上要说话】负责人正看着这一单。**每一拍都带一句 say** —— 说业务的话:
这一单最后怎么样、归了几条情报、下次遇到同类该注意什么;不说系统词。
到了要找人的那一拍,先说清这一轮办完了什么、为什么现在需要他。
【怎么把一项干完 · 一拍连做】write 和 tool 都不会自动收步 —— 做完用 finish 宣告。
动作排进同一拍的 actions 串一口气给;只有发卡 / 提交(挂起)必须停拍。
**这一单常路 6-8 拍就该收口**。
§Z 结案:BD 在验收单上签字,系统把草稿合并成正式情报卡并发出 \`archived\` 那一口 ——
这条工作流就此走到终点。归档做得好不好,不在这一单看得出来,
在下一次报价能不能一眼看到「上次为什么丢」看得出来。`;
/**
* 步骤剧本 —— 与 A1 同一套。放行那一项不给 `doneWhen`(触达世界)。
* 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。
*
* 三处不给 `doneWhen`:`S2_attribute`(发卡触达真人)、`T_scope`(要人拍板)、
* `T_release`(签下去触达世界)—— 给了等于给引擎一把替人签字的钥匙。
*/
export const ARCHIVE_STEPS = [
{
item: 'T_archive',
intent: '一步落三张表:历史价、客户画像、落地率统计',
writes: ['market.price_history', 'customer.profile', 'shared.landing_stats', 'quote_archive.verdict'],
reads: [],
item: 'S1_collect',
intent: '整份归一成情报卡:逐条承继报价与结果、标上报价日期、口径一律先落待定',
writes: ['quote_archive.intel', 'quote_archive.verdict'],
/* 上游载荷数组不声明就会被整表段的截断刀砍掉(砍掉的恰是要归一的行) */
reads: ['rows'],
view: 'archive-worksheet',
tools: [],
// 三张都写了才算归档过。不写 `!= null` —— 未绑定的名字那样求值为 true(A1 踩过)
doneWhen: 'archived_total > 0',
guide: `三张表互不依赖,**同一拍写完**:
· \`market.price_history\`:报了什么价、什么时候、结果如何、拒绝原因;
· \`customer.profile\`:客户画像该更新什么(价格敏感度、决策周期、常用条款);
· \`shared.landing_stats\`:本月落地率、A 类命中、三日回复率。
**不得空归档** —— 「没什么好记的」不是理由,结果本身就是情报,拒绝原因尤其是。
**同一拍再写一行 \`quote_archive.verdict\`**(验收机检读的就是它):
· archived_total = 已归档条数;
· reason_logged_total = 已回写拒绝原因/竞对价位的条数(要 > 0);
· profile_updated_total = 已更新客户画像标签的条数(要 > 0);
· summary 一句人话结论。写不对 submit 会被门挡回来。写完再 finish。
**每一行都要带 \`rfq_ref\`** —— 它是这一单的单标识(起单入参里就有,原样抄进去)。
台账主键第一列就是它:不写,这一行会和**别的单**同料号的那一行撞主键,把人家覆盖掉
(实测栽过:A4 的报价草案主键只有 pn,跑四轮只剩最后一轮的 3 行)。`,
/*
* 归一走完才算办完:行数两边对上(一边是基座现数),
* 且 collected_total 写过(S-FEEL 未绑定的名字参与比较为假,所以「没写过」进不来)。
*/
doneWhen: 'intel_total > 0 and quote_archive_intel_rows = intel_total and collected_total >= 0',
guide: `第一件事:把这一单的**情报卡**落出来 —— 一拍连做,别拆成两拍。
【条目从哪儿来】
· 流内转来(\`rows\` 里的行带 pn / quoted_price / reply_type / deal_status):照抄 ——
报过的价、谈成的价都是既成事实,**一个数字都别改**;带 line_total 这类总数的是结论行,
不是料,别落进情报卡。
· 单飞:照正文归一,列了几颗落几行;正文里说的报价与结果就是要沉淀的那一份。
**按 pn 归并,一颗料一行**;pn 一个字符不许改(它是主键,改一个字就是新开一行)。
【写 \`quote_archive.intel\`,一颗料一行】(rows 收数组)
rfq_ref(入参有就原样抄;没有从主题认;认不出才自己造一个,**造出来的必须以 \`ARC-\` 开头**
(\`ARC-<主题或第一颗料的型号>\`)。两条硬规矩:
**① 整单只有一个单号,不是一颗料一个** —— 它是这一单的案号,情报卡所有行、verdict 那一行
用的都得是同一个串(实测栽过:两颗料造出两个号,一单裂成两单);
**② 只从本单来件取材** —— 台账里躺着的 RFQ-xxxx 是别人的单,借它的号等于把两单的行
混进同一个案卷(实测栽过一轮))、pn、customer_code、customer_name、category、brand、eau、
**quoted_price**(我方报过的价,原样抄)、**final_price**(成交了才有;没成交留空)。
另两列**照这个口径落**:
· **deal_result** —— 这一条的结果,只许写 \`WON\`(成交)或 \`LOST\`(不成交)两个大写码之一。
上游给了 outcome 就照它:won → WON,lost / revise → LOST;
逐行还带着 deal_status 的,以行上的为准(WON → WON,其余一律 LOST)。
认不出就按 **LOST** 落 —— 归档里不许把说不清的算成成交。
· **quoted_at** —— 报价日期,写成 \`YYYY-MM-DD\`。来件里有报价日就抄它,没有就写今天。
这一格是下一单查「距上次报价多久」的那把尺,别留空。
【**这三列你一格都不写**:cause_tag / competitor_price / evidence】
输赢归因、竞对报的那个价、客户原话 —— 这三样只有跟单的人知道,**系统不替人归因**。
它们是 BD 在工作单上落的;price_gap_band 那一列要等竞对价回来才算得了,这一步也别写。
这不是客气话:一条要你分辨「这行能不能替人填」的规矩会走岔;
**「这几列不写」没有分支可走岔。**
【**share_scope 这一列一律写 \`PENDING\`**】入库口径是人在决策卡上拍的,
\`FULL\` / \`MASKED\` / \`INTERNAL\` 三个码这一步一个都不许写 —— 替他写了,那张卡就永远不会发。
【同一拍写 \`quote_archive.verdict\`(一行)】
rfq_ref、**quote_no**(报价单号:来件有就抄,没有写 \`Q-\` 开头 + 本单案号的尾段,整单一个)、
customer_code / customer_name(顶层有就抄)、**outcome**(整单结果:有一条 WON 就写 \`won\`,
一条都没有就写 \`lost\`)、**intel_total** = 情报卡行数、**collected_total** = 这一拍归一落表的
条数(0 也要写)、summary 一句人话(几条料、给谁报的、最后成没成)。
写完 finish 这一项。`,
},
{
item: 'S2_attribute',
intent: '一张执行单交 BD:逐条落输赢归因、竞对价与客户原话',
writes: ['quote_archive.intel', 'quote_archive.verdict'],
reads: ['quote_archive.intel'],
view: 'archive-worksheet',
tools: [],
/* 不给 doneWhen —— 发卡触达真人(manualActivation),引擎不替人按 */
guide: `情报卡落好了,**该 BD 出场了** —— 这是本活动唯一的一张执行单。
输赢的缘由只有跟单的人说得出:客户到底为什么不下单、竞对报了多少、他原话怎么说的。
工作区摆的是**情报归档工作单**:逐颗料一张情报档案卡(报价、结果、客户约束),
归因六档与败价幅度的尺挂在上面,填回区长在同一屏 —— 他在「输赢归因」那一列逐行落。
这一步标了人工激活:发卡就算启动,不必单发 start;引擎也不会替你收 ——
人交回后要你自己 finish。
【照这个骨架发,别少字段】(卡面是纯文本,steps/standard 里别写星号记号)
{"kind":"human","itemId":"S2_attribute","workOrder":"execution","card":{
"title":"情报归因:<单号> 共 <N> 条待归因",
"ask":"这一单的情报卡已按报价与结果归好,报价、成交价与日期都在工作单上。请逐条落输赢归因:输在价格 / 交期 / 认证 / 规格 / 关系,还是说不清;输在价格的把竞对报的那个价也填上,并把客户原话记进依据一栏。",
"steps":["归因只有六档,选最主要的那一个:PRICE 价格 · LEADTIME 交期 · CERT 认证资质 · SPEC 规格不匹配 · RELATION 关系或既有供应商 · OTHER 说不清",
"「客户没回」不是原因 —— 问不出来就落 OTHER 并在依据里写明「未获反馈」,不要编一个听着合理的理由:它会一路流进下一单的判据里",
"归因落 PRICE 的行必须填竞对价:没有那个数,「输在价格」下一单读不出任何东西 —— 缺这一格这张单按不下提交",
"依据一栏尽量记客户原话:「你们比 XX 贵两毛」比「价格偏高」值钱得多 —— 下一单定价时看的就是这一句"],
"standard":"整份情报卡每一条都有归因,归因落 PRICE 的那几条填了竞对报的价 —— 缺一条这张单按不下提交(系统的闸看着)。",
"basis":[{"k":"<PN>","v":"<报价 · 成交价 · 结果 · 报价日期>"}]}}
· \`basis\` 逐条列本单的料:PN + 报价与结果。几颗写几条,别铺全表。
· **卡上不摆表、不摆格子**(执行卡零输入):整份情报卡就在工作单那份件上,
归因、竞对价、竞对名、依据四列都能就地改、行能撤 —— **别往卡里塞 \`blocks\` 或 \`fields\`**。
· **这张卡带提交闸**:归因(必填列)还空着,他按不下提交 —— 缺几格、缺在哪行,
闸上与件上的红带都写着(与放行门 L1 同一个数同一套算法)。
发完卡这一拍就结束了(发卡挂起)。
【人交回之后 —— 他落的归因基座已经替你写回台账了】这一步你只做三件事,然后 finish:
① **算 price_gap_band**(只给归因是 PRICE 且填了竞对价的那几行):按败价幅度 A-01,
价差 gap =(quoted_price − competitor_price)/ quoted_price,只许写这三个大写码之一:
· gap ≤ 0.03 → **SLIGHT**(擦肩而过:下一单同价位再试,输的多半不在价格);
· 0.03 < gap ≤ 0.10 → **MODERATE**(有差距但够得着:下一单让到这一档或换替代料降本);
· gap > 0.10 → **STRUCTURAL**(结构性价差:下一单别再报同一价位,先走替代选型)。
不是 PRICE 的行、或没有竞对价的行,这一格给空串 \`""\`,**不许写 0 也不许编一个码**。
② 写 verdict:**attributed_total** = 已落归因的条数(照情报卡行数记);
他在件上撤掉了行的话,**intel_total 跟着改成撤后的实数**。
③ finish 这一项。
**别在这一拍写入库口径** —— 那是下一步人拍板的事(它有自己的一段做法),这里写了也白写。
情报卡其余列你一列都不用重写:他落的值基座已经落进台账了,重写会被「写了等于没写」拒回。
【这张卡一单只发一次】人交回来之后不许再发同一张卡 —— 必填有提交闸兜着,
卡回来时归因一定是齐的(或那几行已被他撤掉)。`,
},
{
item: 'T_scope',
intent: '入库口径要人定 —— 发一张决策卡,照他选的那一档把待定口径改掉',
writes: ['quote_archive.intel', 'quote_archive.verdict'],
reads: ['quote_archive.intel'],
view: 'archive-roster',
tools: [],
/* 人工激活 + 要人拍板 —— 不给 doneWhen,引擎不许替人收 */
guide: `**这一单只发一张决策卡。动手之前先在轨迹里找一行 \`[card-reply]\` 带「人选了:」:**
· **找到了** —— 跳到下面【人点完之后】那一段,照他选的那一档把那几行的 share_scope
从 PENDING 改掉、写 scope_decided_total、finish。**一个 decide 都不许再发**
(实测栽过:人点完了,而这一步又从头念了一遍「发一张决策卡」,同一张卡连发六次,
前两次无事发生、后四次被拒 —— 他点过的那一下就白点了);
· **没找到** —— 才轮到下面【发一张决策卡】那一段。
【这一步办完了没有,看一个数】待定口径条数 quote_archive_intel_share_scope_pending:
**它归零就是这一步办完了**。PENDING 是过渡态(情报归好了、还没定进哪一档库),
拍板那一下必须把它换成 FULL / MASKED / INTERNAL 之一 —— 三档都是结论。
这个数不归零,放行门开不了;它归零,这一步也就不会再找你。
这一步只在**归因齐了**(基座现数 quote_archive_intel_missing = 0 且人交回过执行单)时入场 ——
读的是人自己填的那一格,不是你判的结论:该不该叫人,不由被叫的那一方说了算。
它触达公司对外的边界(竞对报价与客户身份进了共享库,下一单谁都读得到),标了人工激活:
发卡就算启动,不必单发 start。
工作区这时摆的是**情报归档清单**,整份情报卡与归因分布都在上面,他看得见全局再拍板。
【发一张决策卡,照这个骨架】(选项少于两个基座拒发)
**三处一个字都不许改**(与说明书里那一段是同一份):
· \`itemId\` 只写英文计划项号 \`T_scope\` —— **别写中文名「入库口径拍板」**,
中文名不是 id,写了当场被拒(实测栽过两拍:「计划项不存在:异议拍板」);
【目标缺口】那一段摆的是中文名,它不是 id,别照它抄。
· \`ask\` 照抄「<N> 条情报待定入库口径,按哪一档进库?」,**只把 N 换成条数** ——
换了问法系统就认不出这是同一张卡,人已经点过的那张会被你再发一遍(实测发出去两张)。
· 三个 \`value\` 照抄 \`full\` / \`masked\` / \`internal\` —— 它们是三档的代号,
换了名字下一段的对应关系就对不上,你自己也不知道人点的是哪一档。
label 与 consequence 可以按本单的数改写,value 不行。
{"kind":"decide","itemId":"T_scope","ask":"<N> 条情报待定入库口径,按哪一档进库?",
"options":[
{"value":"full","label":"全量入库(客户与竞对都留名)","consequence":"下一单查这颗料时,客户是谁、竞对是谁、报了多少一目了然 —— 情报最值钱,但客户身份与第三方报价也就此对内公开"},
{"value":"masked","label":"脱敏入库(只留价位与败因)","consequence":"竞对与客户的名字抹掉,价位、归因、日期照留 —— 下一单照样判得了价,追不到是谁说的"},
{"value":"internal","label":"只留内部(不进共享情报库)","consequence":"这一单的情报只在本案卷里躺着,下一单查不到 —— 涉密或客户明说不外传时才选这一档"}],
"basis":[{"k":"<PN>","v":"归因 <码> · 报价 <X> · 竞对价 <Y> · 败价幅度 <码>"}]}
· basis 逐条摆本单的料:归因、报价、竞对价、败价幅度 —— 裸卡基座拒发。
· **recommended 按入库口径建议 A-02 标**,只标一项:归因里有 PRICE 的(带着第三方报价)
标 \`masked\`;一条 PRICE 都没有的标 \`full\`。
· 发完这一拍就结束了(发卡挂起)。
【人点完之后】轨迹上那一行长这样:\`[card-reply] …人选了:xxx\`。**照那一档把口径改掉,
别再发第二张卡**,一拍连做 —— **整份情报卡逐行都要动 share_scope**,一条都不许留在 PENDING:
· 选 full:每行 share_scope 改 **FULL**;verdict 的 share_policy 写 \`full\`;
· 选 masked:每行 share_scope 改 **MASKED**;verdict 的 share_policy 写 \`masked\`;
· 选 internal:每行 share_scope 改 **INTERNAL**;verdict 的 share_policy 写 \`internal\`。
然后把 verdict 写全:**scope_decided_total 记 1**、**buyer_profile** 一句客户行为画像
(照这一单看得出来的写:价格敏感 / 决策快慢 / 认证门槛 / 常用条款,是 MVP §1.6 S2 那一步的产物)、
summary 补一句拍板结果,finish 这一项。
改完之后待定口径条数就是 0 了,这一步再也不会找你。`,
},
{
item: 'T_release',
intent: '情报已沉淀,封卷放行',
intent: '情报已沉淀、口径已定,提交放行交 BD 验收',
writes: [],
reads: [],
view: 'archive-roster',
tools: [],
guide: `**先 start T_release**(人工激活),提交前用 judge 裁掉裁量判据,然后 submit。
/* 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 doneWhen) */
guide: `每条都有归因、口径全定过、待定清零 —— 可以走放行了
(T_release 人工激活:动它就算启动,不必单发 start)。先把裁量判据裁掉,然后 submit,同一拍连做:
· judge criterion="情报站得住:每条归因说得出来路、竞对价与客户原话可溯,BD 对这份情报负责" ——
看情报卡里每一行的归因与依据:说得出来路就 satisfied=true;
有行说不出的(比如归因落了 PRICE 却没有客户原话),satisfied=**false**,rationale 逐行写清,
交签字的人定。**裁成不满足照样能提交** —— 理由连着卡一起交给他。
criterion 照判据名**整句原样抄**,写别的名字对不上。
**去向没得选**:这是全流程最后一站,只有一口 \`archived\`(归档闭环),放行那一刻自动发射。
你要做的只是把情报落准,别在提交里手选出口。
终局唯放行:你不能自己宣布完成。`,
},
];
/**
* 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段;
* 放行那一格由基座统一加,人看到的是 x/4。
*/
export const ARCHIVE_PHASES = [
{ name: '归一情报卡', items: ['S1_collect'] },
{ name: '逐条归因', items: ['S2_attribute'] },
{ name: '入库口径拍板', items: ['T_scope'] },
];
+14 -32
View File
@@ -1,36 +1,18 @@
/**
* 统一报价 A7 · 情报归档 —— 活动包代码入口(ADR-004)。
* 「不成交也留资产」:把归档结果摆给人看,确认无空档再提交放行。
* 统一报价 A7 · 情报归档 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
*
* **这个活动没有 `run`,不是缺件。**
*
* 案例活动的推进由 Loop 驱动,行为全部写在清单里:案例模型 + 说明书 + 步骤剧本 +
* 两把判定表 + 技能 + 台账声明 + 两张 HTML 报告模版。
* 上一版这里有一段 `run()`(读 `shared.history_price`、推一个 kv 块、按行数返回
* await/submit)—— 案例活动根本不会调它;更坏的是它读的那张表**从来没被声明过**
* (声明的是 `market.price_history`),而它推的那套呈现与 `viewTemplates` 里的模版
* 各说各话,没人看得出以哪份为准。
*
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 —— 它是**被静态审扫描的产物文本**
* (查 import 与全局引用,证明活动包零环境能力)。
*/
import { defineActivity } from '@sdk';
import type { ActivityBase, ActivityOutcome } from '@sdk';
export default defineActivity({
name: 'quote-archive',
async run(base: ActivityBase): Promise<ActivityOutcome> {
const history = await base.ledger.read('shared.history_price');
const stats = await base.ledger.read('shared.landing_stats');
await base.interaction.pushView([
{
t: 'kv',
title: '归档闭环',
items: [
{ k: '情报回写', v: `${history.length} 条` },
{ k: '落地率', v: stats.length > 0 ? String(stats[0]?.['landing_rate'] ?? '—') : '—' },
{ k: '客户', v: String(base.contract.data['customerCode'] ?? '') },
],
},
]);
// 不得空归档 —— 每一单不成交的报价也是情报资产
if (history.length === 0) {
await base.interaction.say('情报尚未回写,继续登记结果。', 'conclusion');
return { kind: 'await', reason: '等待结果登记步完成' };
}
await base.interaction.say('拒绝原因与竞对价位已回写,画像与落地率已落账。', 'criterion');
return { kind: 'submit', deliverable: 'history-archive', rationale: '情报、画像、落地率三样齐备' };
},
});
export default defineActivity({ name: 'quote-archive' });
+475 -85
View File
@@ -1,139 +1,392 @@
/**
* 活动包清单 —— 统一报价 A7 · 情报归档。
* 活动包清单 —— 统一报价 A7 · 情报归档(案例活动)。
*
* 业务素材:docs/MVP示例工作流.html §1.7(S1 结果登记 → S2 画像更新 → S3 落地率统计)。
* 「不成交也留资产:情报、客户画像、落地率」—— 写的是**共享域**,跨活动的沉淀事实。
* 业务素材:`docs/MVP示例工作流.html` §1.6(S1 结果登记 → S2 画像更新 → S3 落地率统计):
* 「不成交也留资产:情报、客户画像、落地率」
* 「拒绝或无回复的单子 7 天内闭环归档。每一单不成交的报价也是情报资产 ——
* 沉淀下来,下一单更快更准」。
*
* 形状与 A4/A6 同一条(2026-08-03 重写 @3.0.0):归一情报卡 → 一张执行单交人
* (逐条落输赢归因与竞对价)→ 按败价幅度给复用建议 → **入库口径要人拍板**
* (决策卡)→ 放行归档闭环。
* **能算的不问人**(A-01 败价幅度、A-02 入库口径建议两把尺写死),
* **该人说的不代说**(归因、竞对价、客户原话那三列 AI 一格都不写)。
*
* 与 @2.1.0 的差别(六处都是踩过的账,逐条见 case-model 头注):
* ① `input.required` 从 `['rfqRef','customerCode','quoteNo']` 清空 —— 宽进严出;
* ② 两条 L1/L2 判据读 `reason_logged_total` / `profile_updated_total` ——
* **活动自报的数**,写个 1 就过(「机检只读基座数」违反史第七类);
* ③ 台账补 `required` / `requiredWhen` —— 从前一个必填都没有,执行卡的闸没牙;
* ④ 补两处人的判断(归因 + 入库口径)—— 从前 AI 自己写完自己放行;
* ⑤ 三张共享域表退役(墓志铭见 `declarations.ledger` 头注);
* ⑥ `index.ts` 里那段 `run()` 删掉 —— 案例活动不会调它。
*/
import type { ActivityManifest } from '@contracts';
import { ARCHIVE_SKILLS } from './skills.ts';
import { CMMN_XML, NARRATIVE, ARCHIVE_STEPS } from './case-model.ts';
import type { ActivityManifest, DmnModel } from '@contracts';
import { activityIdSchema } from '@contracts';
import { ARCHIVE_LOGGED, ARCHIVE_RELEASE } from './views/report.ts';
import { ARCHIVE_SKILLS } from './skills.ts';
import { ARCHIVE_PHASES, ARCHIVE_STEPS, CMMN_XML, NARRATIVE } from './case-model.ts';
import { ARCHIVE_ROSTER, ARCHIVE_WORKSHEET } from './views/report.ts';
const D = 'quote_archive';
const ACTIVITY_ID = activityIdSchema.parse('quote-archive');
/** 本活动自己的数据域 —— 三张沉淀表落 `shared`,只有归档结论落这儿 */
const DOMAIN = 'quote_archive';
/**
* 两把尺 —— **规则本身不是数据**,随包冻结。
*
* · 败价幅度 A-01:我方报价与竞对价的价差 → 这条情报下一单该怎么用
* (出参就是台账 `price_gap_band` 的取值);
* · 入库口径建议 A-02:输赢归因 → 决策卡上标 recommended 的那一档
* (出参是三个 `value` 之一,不落台账 —— 建议不是结论,结论由人拍)。
*
* 出参键名与台账列同名,模型算完照抄即可(规范 §5.6)。
* A-01 的档位在三处出现:这张表、剧本 guide、工作单上的规则块 ——
* 包内自测把三处钉在一起(改一处不改另两处,测就红)。
*/
export const A01_DMN: DmnModel = {
drd: [
{ decision: 'a01-band', requires: [], inputs: ['gap'] },
{ decision: 'a02-scope', requires: [], inputs: ['cause_tag'] },
],
tables: [
{
/** 败价幅度 A-01 —— FIRST 命中即定,所以第二行等价于「三到十个点」 */
id: 'a01-band',
label: '败价幅度 A-01',
hitPolicy: 'FIRST',
inputs: [{ name: 'gap', expression: 'gap' }],
outputs: [{ name: 'price_gap_band' }],
rules: [
{ id: 'a01-1', when: ['<= 0.03'], then: ['SLIGHT'], annotation: '三个点以内 —— 擦肩而过,下一单同价位再试,输的多半不在价格' },
{ id: 'a01-2', when: ['<= 0.10'], then: ['MODERATE'], annotation: '三到十个点 —— 够得着,下一单让到这一档或换替代料降本' },
{ id: 'a01-3', when: ['> 0.10'], then: ['STRUCTURAL'], annotation: '超过一成 —— 结构性价差,下一单别再报同一价位,先走替代选型' },
],
},
{
/**
* 入库口径建议 A-02 —— 只出**建议**,决策卡上那一枚 recommended 就是它。
* 带着第三方报价的情报默认建议脱敏:竞对报价写进共享库,下一单谁都读得到。
*/
id: 'a02-scope',
label: '入库口径建议 A-02',
hitPolicy: 'FIRST',
inputs: [{ name: 'cause_tag', expression: 'cause_tag' }],
outputs: [{ name: 'scope_hint' }],
rules: [
{ id: 'a02-1', when: ['"PRICE"'], then: ['masked'], annotation: '归因带竞对报价 —— 建议脱敏入库:价位与败因照留,谁说的抹掉' },
{ id: 'a02-default', when: ['-'], then: ['full'], annotation: '不涉第三方报价的败因 —— 建议全量入库,下一单查得越全越好' },
],
},
],
};
export const manifest: ActivityManifest = {
identity: {
activityId: ACTIVITY_ID,
name: 'A7 · 情报归档',
packageVersion: '2.1.0',
packageVersion: '3.0.0',
category: '统一报价',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-quote-archive',
},
/* ── 契约七件 ────────────────────────────────────────────────── */
contract: {
activityId: ACTIVITY_ID,
version: '0.1.0',
version: '1.0.0',
input: {
/*
* **宽进到底:一个必填都不设**(与 A1/A2/A3/A4/A6 同口径 —— 宽进严出,
* 输入契约在活动内部归一 + 补全)。
*
* 来路两种形状:流内转来(A6 三口的载荷 `{rfqRef, customerCode, quoteNo,
* outcome, rows}` —— `lost` 走 E13 直接进来,`won` 那一口的单子结案时也归这一份档)、
* 单飞(一段文字)。上一版必填 `rfqRef + customerCode + quoteNo`,
* 门口拦的恰是单飞那条路,而归档最常见的来路就是有人补一单历史。
*/
data: {
type: 'object',
required: ['rfqRef', 'customerCode', 'quoteNo'],
required: [],
properties: {
/** 归档写 (pn, quoted_at) 这一行,两个主键都由调用方给定 */
rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' },
pn: { type: 'string' },
quotedAt: { type: 'string' },
customerCode: { type: 'string' },
quoteNo: { type: 'string' },
reason: { type: 'string' },
competitorPrice: { type: 'number' },
customerCode: { type: 'string', title: '客户码' },
quoteNo: { type: 'string', title: '报价单号' },
outcome: { type: 'string', title: '上游议价结果(won / lost / revise)' },
reason: { type: 'string', title: '不成交缘由(上游给的一句话,归因时的第一手材料)' },
rows: { type: 'array', title: '上游条目(报价函行,带报价、客户表态与定案)' },
items: { 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: {
/*
* **一口,且是必交付口**:A7 是流程包的最后一站(E14 `A7 → end_arch` 是无条件边),
* 没有去向要选 —— 归档闭环就是把情报落准、口径定死。
* 载荷全部从**已落地的事实**推导(verdict 那一行),不由模型另述一遍。
*/
mandatory: [
{
name: 'archived',
payload: { type: 'object', properties: { landingRate: { type: 'number' } } },
description: '归档闭环:情报回写 + 落地率落账',
payload: {
type: 'object',
required: ['rfqRef', 'intelTotal'],
properties: {
rfqRef: { type: 'string', title: '询价单号(单标识)' },
customerCode: { type: 'string', title: '客户码' },
quoteNo: { type: 'string', title: '报价单号' },
outcome: { type: 'string', title: '这一单最后成没成(won / lost)' },
intelTotal: { type: 'integer', title: '归档情报条数' },
sharePolicy: { type: 'string', title: '入库口径(full / masked / internal —— 人拍的)' },
rows: { type: 'array', title: '情报卡(逐条归因与败价幅度)' },
},
},
description: '归档闭环:情报卡逐条落定、入库口径已拍板 —— 这条工作流走到终点',
},
],
selective: [],
},
/*
* 必交付物**必须给 `ledger` 地址**(2026-07-31 修)。
* 不给的话预检退回 `scope[key]` 去找 —— key 是 `benchmark-table` 这样的短横线别名,
* 任务视图里永远没有这个键,于是「必交付物缺一不得提交放行」永远拒,
* 活办完了也放不了行,连拒五拍熔断(实测 A2 死于此)。
* 必交付物只有一件:**情报卡清单**。归档结论(verdict)是它的结论栏,
* `ledgers` 说清这份成果由哪几张表承载(一份成果,不是两份)。
*/
requiredDeliverables: [
{ key: 'history-archive', label: '历史价格情报(归档)', ledger: 'market.price_history' },
{ key: 'landing-stats', label: '报价落地率统计', ledger: 'shared.landing_stats' },
{
key: 'intel-archive',
label: '情报卡清单(逐条输赢归因 + 入库口径)',
ledger: `${D}.intel`,
/* 验收页上让 BD 就地改的两列 —— 恰是他的职权面(归因与依据) */
editableAtRelease: ['cause_tag', 'evidence'],
ledgers: [`${D}.intel`, `${D}.verdict`],
},
],
/*
* 机检谓词读的是 `verdict` 那张**汇总表**的列(照 A1 的口径)。
* 上一版的名字(reasonLogged / profileUpdated)**台账里根本没有** —— S-FEEL 拿未绑定的名字
* 比大小求值为假,判据永远达不了标,submit 门永远拒,连拒五拍熔断。
* 判据的名字必须落在台账上,否则「机检」是一句空话(2026-07-31 销账)。
* 验收判据 —— **机检(L1/L2)只读基座数得出来的数**:
* `_rows` 基座数行数、`_missing` 基座按 required / requiredWhen 现数
* (与执行卡提交闸同一个数同一套算法)、`_<列>_<值>` 基座按值现数。
* 上一版两条机检读活动自报的 `reason_logged_total` / `profile_updated_total` ——
* 那不是门,是「改哪个数能过」的提示(写个 1 就过去了)。
*/
acceptanceCriteria: [
{ key: 'reason-logged', label: '拒绝原因 / 竞对价位已回写', level: 'L1', predicate: 'reason_logged_total > 0' },
{ key: 'profile-updated', label: '客户画像标签已更新', level: 'L2', predicate: 'profile_updated_total > 0' },
{ key: 'bd-release', label: 'BD 放行', level: 'L5' },
{
key: 'intel-complete',
label: '情报卡齐:上游条目逐条承继,说了几条情报卡里就躺着几行',
level: 'L1',
predicate: `intel_total > 0 and ${D}_intel_rows = intel_total`,
},
{
key: 'attributed-all',
label: '每条都有输赢归因:归因落「输在价格」的那几条还落下了竞对报的那个价,缺一行放不了',
level: 'L1',
predicate: `${D}_intel_rows > 0 and ${D}_intel_missing = 0`,
},
{
key: 'result-typed',
label: '结果逐条落成两种之一:成交 / 不成交 —— 没有含糊其辞的格子',
level: 'L2',
predicate: `${D}_intel_deal_result_won + ${D}_intel_deal_result_lost = ${D}_intel_rows`,
},
{
key: 'scope-settled',
label: '入库口径逐条落定:全量 / 脱敏 / 只留内部 / 待定,四档相加等于情报条数',
level: 'L2',
modelRef: 'a02-scope',
predicate: `${D}_intel_rows > 0 and ${D}_intel_share_scope_full + ${D}_intel_share_scope_masked + ${D}_intel_share_scope_internal + ${D}_intel_share_scope_pending = ${D}_intel_rows`,
},
{
/* 与 A6 的「待拍板清零」同一形状:待定是过渡态,人拍过板它才归零 */
key: 'pending-cleared',
label: '入库口径都拍过板了:没有还压在「待定」上的情报条目',
level: 'L2',
predicate: `${D}_intel_share_scope_pending = 0`,
},
{
/*
* 有败价幅度的行**必是归因落了「输在价格」的行** —— 两边都是基座按值现数。
* 不是价格败的却算出一个价差档,是模型给自己找了一条不必问竞对价的近路。
*/
key: 'band-from-price',
label: '算了败价幅度的,都是归因落「输在价格」的那几条 —— 没有凭空算出来的价差档',
level: 'L2',
modelRef: 'a01-band',
predicate: `${D}_intel_price_gap_band_slight + ${D}_intel_price_gap_band_moderate + ${D}_intel_price_gap_band_structural <= ${D}_intel_cause_tag_price`,
},
/*
* 情报站不站得住是**裁量**(L3):归因说不说得出来路、客户原话记没记全,
* 机器数不出来。这句 label 与剧本里 judge 的 criterion **必须逐字一致**。
*/
{
key: 'intel-defensible',
label: '情报站得住:每条归因说得出来路、竞对价与客户原话可溯,BD 对这份情报负责',
level: 'L3',
},
/* L5 是责任关:那张卡本身就是这道关,卡面上不摆它(基座统一滤) */
{ key: 'bd-release', label: 'BD 放行:签下去这一单的情报即入库,下一单查得到', level: 'L5' },
],
revisableAtRelease: [],
prohibitions: [{ key: 'no-blank-archive', statement: '不得空归档 —— 每一单不成交的报价也是情报资产' }],
authorization: { discretion: [], preauthorizedActions: [], humanCallBudget: 2 },
prohibitions: [
{
key: 'no-blank-archive',
statement: '不得空归档 —— 每一单不成交的报价也是情报资产,「没什么好记的」不是理由',
},
{
key: 'no-invented-reason',
statement: '不许编败因:问不出来就落 OTHER 并写明未获反馈 —— 编出来的原因会流进下一单的判据',
},
{
key: 'no-self-scope',
statement: '入库口径不许系统自己定:竞对报价与客户身份进共享库是对外边界,只由人拍板',
},
],
authorization: {
discretion: [
{ key: 'band-advice', statement: '败价幅度 A-01 给出的复用建议由活动自行判定;进不进共享库一律交人' },
],
preauthorizedActions: [],
/* 一张执行单 + 一张决策卡 + 放行 = 三次;留一倍余量 */
humanCallBudget: 5,
},
resources: {
/*
* **不调工具**。归档这一段没有要问外面的事 —— 上游把结果交过来,
* 人把缘由补上,系统把它落成下一单查得到的形状。
*/
tools: [],
skills: ARCHIVE_SKILLS.map((s) => s.id),
models: ['deepseek-v4-flash'],
roles: ['BD'],
budget: { steps: 30, tokens: 60_000, activeMinutes: 60, humanCalls: 2 },
/* SLA:MVP §1.6「7 天闭环」;预算按 A6 实测(6-9 拍)留一倍余量 */
budget: { steps: 60, tokens: 400_000, activeMinutes: 120, humanCalls: 5 },
},
},
/* ── 声明 ────────────────────────────────────────────────────── */
declarations: {
models: ['deepseek-v4-flash'],
tools: [],
/*
* 【三张共享域表的墓志铭】上一版声明了 `market.price_history` /
* `customer.profile` / `shared.landing_stats`,这一版三张一并退役:
*
* · `shared.landing_stats`(月度落地率 / TypeA 命中 / D+3 回复率)——
* **单案例算不出一个月的统计**。上一版让 AI 写一个 0.27 进去,那是界面撒谎:
* 它既不是本单的事实,也没有第二个数据源可核。月度统计的正经落点是驾驶舱
* **按表现算**,这一单该做的只是把自己那一条如实落准。
* · `customer.profile`(客户画像)—— **A1 已经持有这张主数据表**,
* A7 再声明一遍就是同一批业务对象的两套地址(六个占位槽退役时清过一次的账)。
* 本单看得出来的客户行为写进 `verdict.buyer_profile` 一句话,
* 要更新主数据由人在客户档上改,不由归档活动代笔。
* · `market.price_history`(历史价格情报)—— 与下面这张 `intel` **是同一份事实**。
* 两处各存一份,改一处就分叉而没人看得出以哪份为准(A1 开请求单那次的账)。
* 情报卡本身就是「下次查得到的形状」:按 pn 查得到价、按 customer_code 查得到客户、
* 按 quoted_at 查得到「距上次报价多久」(P-03 的那把尺读的就是它)。
*/
ledger: [
{
// 共享域:跨活动的沉淀事实(AM 11.3 暗耦合禁令的两条合法路径之一)
domain: 'market',
object: 'price_history',
kind: 'master',
label: '历史价格情报',
domain: D,
object: 'intel',
kind: 'record',
caseKey: 'rfq_ref',
label: '情报卡',
fields: [
{ name: 'pn', type: 'text', primaryKey: true, masked: false },
{ name: 'quoted_at', type: 'date', primaryKey: true, masked: false },
{ name: 'lost_reason', type: 'text', primaryKey: false, masked: false, label: '拒绝原因' },
{ name: 'competitor_price', type: 'number', primaryKey: false, masked: false, label: '竞对价位' },
],
},
{
domain: 'customer',
object: 'profile',
kind: 'master',
label: '客户资料库',
fields: [
{ name: 'customer_code', type: 'text', primaryKey: true, masked: false, label: '客户码' },
{ name: 'grade', type: 'text', primaryKey: false, masked: false, label: '客户等级' },
{ name: 'profile_tags', type: 'text', primaryKey: false, masked: false, label: '画像标签' },
],
},
{
domain: 'shared',
object: 'landing_stats',
kind: 'derived',
label: '报价落地率统计',
fields: [
{ name: 'period', type: 'text', primaryKey: true, masked: false, label: '月份' },
{ name: 'landing_rate', type: 'number', primaryKey: false, masked: false, label: '落地率' },
{ name: 'type_a_hit', type: 'number', primaryKey: false, masked: false, label: 'TypeA命中' },
{ name: 'reply_rate_d3', type: 'number', primaryKey: false, masked: false, label: 'D+3回复率' },
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' },
{ name: 'customer_code', type: 'text', primaryKey: false, masked: false, label: '客户码' },
{ name: 'customer_name', 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: 'quoted_at', type: 'date', primaryKey: false, masked: false, label: '报价日期' },
{ name: 'quoted_price', type: 'number', primaryKey: false, masked: false, label: '我方报价 CNY' },
{ name: 'final_price', type: 'number', primaryKey: false, masked: false, label: '成交价(成交才有)' },
{
name: 'deal_result',
type: 'text',
primaryKey: false,
masked: false,
label: '结果',
tally: ['WON', 'LOST'],
},
/*
* ── 人那一侧的三列:**AI 一格都不写** ──────────────────────────
*
* 与 A6 的「客户表态三列」同一处结构治:一条要模型分辨「这行能不能替人填」
* 的规矩会走岔;「这几列不写」没有分支可走岔。
*
* `cause_tag` 标 `required` —— 件上的缺项红带、执行卡的提交闸、放行门 L1
* 三处同源(都数基座的 `_missing`)。
* `competitor_price` 用 `requiredWhen`:**只有归因落「输在价格」的那一行才算必填**
* (表级 required 会让交期/认证败的行永远缺一格,把闸拦成死锁)。
*/
{
name: 'cause_tag',
type: 'text',
primaryKey: false,
masked: false,
required: true,
label: '输赢归因',
tally: ['PRICE', 'LEADTIME', 'CERT', 'SPEC', 'RELATION', 'OTHER'],
},
{
name: 'competitor_price',
type: 'number',
primaryKey: false,
masked: false,
requiredWhen: { column: 'cause_tag', value: 'PRICE' },
label: '竞对价',
},
{ name: 'competitor_name', type: 'text', primaryKey: false, masked: false, label: '竞对是谁' },
{ name: 'evidence', type: 'text', primaryKey: false, masked: false, label: '依据 / 客户原话' },
/* ── 算出来的那一格:尺说了算,模型照抄 ─────────────────────────── */
{
name: 'price_gap_band',
type: 'text',
primaryKey: false,
masked: false,
label: '败价幅度(A-01)',
tally: ['SLIGHT', 'MODERATE', 'STRUCTURAL'],
},
/*
* 入库口径,**PENDING 是过渡态**(情报归好了、还没定进哪一档库):
* 拍板那一下把它换成 FULL / MASKED / INTERNAL 之一,于是「归因齐了叫人」
* 那道哨兵当场为假。A6 的账:让「拍完不动数」的路存在,同一张卡会连发六次;
* 让模型自己写得出结论码,那张卡就永远不会发。两头都堵死才算数。
*/
{
name: 'share_scope',
type: 'text',
primaryKey: false,
masked: false,
label: '入库口径',
tally: ['PENDING', 'FULL', 'MASKED', 'INTERNAL'],
},
],
},
{
/*
* 归档结论 —— 一单一行的**汇总表**,验收机检谓词的入参就在这儿。
* 归档结论 —— 一单一行的**汇总表**,哨兵与报告读它。
* 汇总列与逐行列**故意不同名**:任务视图把草稿行摊平到顶层是「后写的赢」,
* 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。
*/
domain: DOMAIN,
domain: D,
object: 'verdict',
kind: 'record',
caseKey: 'rfq_ref',
@@ -141,42 +394,179 @@ 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: 'archived_total', type: 'integer', primaryKey: false, masked: false, label: '已归档条数' },
{ name: 'reason_logged_total', type: 'integer', primaryKey: false, masked: false, label: '已回写原因条数' },
{ name: 'profile_updated_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: 'outcome', type: 'text', primaryKey: false, masked: false, label: '这一单最后成没成(won/lost)' },
{ name: 'intel_total', type: 'integer', primaryKey: false, masked: false, label: '情报卡条数' },
/* 归一走完的凭据(0 也要写)—— 哨兵按它放交人归因那一步入场 */
{ name: 'collected_total', type: 'integer', primaryKey: false, masked: false, label: '已归一条数' },
/* 「归因落齐了」的凭据 —— 只在人环走完才产生 */
{ name: 'attributed_total', type: 'integer', primaryKey: false, masked: false, label: '已落归因条数' },
{ name: 'scope_decided_total', type: 'integer', primaryKey: false, masked: false, label: '入库口径已拍板' },
{ name: 'share_policy', type: 'text', primaryKey: false, masked: false, label: '入库口径(人拍的)' },
/* MVP §1.6 S2「画像更新」的落点 —— 一句话,不代笔改主数据 */
{ name: 'buyer_profile', type: 'text', primaryKey: false, masked: false, label: '客户行为画像' },
{ name: 'summary', type: 'text', primaryKey: false, masked: false, label: '归档结论' },
],
},
],
listeners: [],
// 7 天闭环(MVP示例 §1.7)
harness: { memoryWindow: 15, stallAfter: 'P1D', nudge: [], retry: { times: 2, backoff: 'PT30S' } },
/*
* 两条静态监听 —— **时限必配**(AM 4.4)。MVP §1.6 写死「7 天闭环」:
* 归档没有外部世界的回音要等,这两条管的是「别把它挂到天荒地老」——
* D+3 提醒把归因补上,D+7 到期升级。
*/
listeners: [
{
origin: 'static',
eventType: 'time',
keyFilter: { kind: 'archive-d3' },
semantic: 'observe',
deadline: 'P3D',
escalation: ['nudge'],
},
{
origin: 'static',
eventType: 'time',
keyFilter: { kind: 'archive-deadline' },
semantic: 'observe',
deadline: 'P7D',
escalation: ['nudge', 'escalate'],
},
],
harness: {
memoryWindow: 22,
// 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: A01_DMN,
steps: ARCHIVE_STEPS,
phases: ARCHIVE_PHASES,
},
skills: ARCHIVE_SKILLS,
/*
* 两个局面各一张(与 A2/A3/A4/A6 同款口径):
* · `archive-worksheet` 情报归档工作单 —— 归一与逐条归因期间摆它(逐颗情报档案、
* 归因六档与败价幅度两把尺、填回工作表);
* · `archive-roster` 情报归档清单 —— 拍板、放行、归档摆它。
*/
viewTemplates: [
{ name: 'archive-logged', html: ARCHIVE_LOGGED },
{ name: 'archive-release', html: ARCHIVE_RELEASE },
{ name: 'archive-worksheet', html: ARCHIVE_WORKSHEET },
{ name: 'archive-roster', html: ARCHIVE_ROSTER },
],
cardSchemas: {},
distillHooks: [
{ on: 'terminal', prompt: '归纳丢单的共性:价格、交期、竞对、客户类型 —— 供下一单更快更准。' },
{
on: 'terminal',
prompt: '归纳丢单的共性:价格、交期、竞对、客户类型 —— 供下一单更快更准。',
},
],
},
/* ── 样例集 —— 三条形状真不同(常备案例即验收案例)───────────── */
samples: [
{
name: '客户拒绝',
input: { customerCode: 'C001', quoteNo: 'Q202601', reason: '竞对报价更低' },
/*
* ① 流内失单:A6 `lost` 口的真实形状 —— rows 里报价函行(带客户表态与定案)
* 混着结论行。客户拿竞对价压,归因落 PRICE,竞对价一填败价幅度就算得出来。
*/
name: '流内转来 · 客户拒绝 · 竞对价压两成(失单归档常路)',
input: {
rfqRef: 'RFQ-2026-0777',
customerCode: 'CU-0311',
quoteNo: 'Q-0777',
outcome: 'lost',
reason: '客户明确回绝:竞对价更低',
customer_name: '青岛海洋电子',
rows: [
{
rfq_ref: 'RFQ-2026-0777',
pn: 'PESD2ETH-XR',
category: '二极管',
brand: 'Nexperia',
eau: 30000,
quoted_price: 0.098,
reply_type: 'REJECT',
deal_status: 'LOST',
reply_note: '客户回:这个价做不了,别家给到 0.078',
},
{
rfq_ref: 'RFQ-2026-0777',
pn: 'NE555P',
category: '定时器',
brand: 'TI',
eau: 5000,
quoted_price: 1.05,
reply_type: 'REJECT',
deal_status: 'LOST',
reply_note: '客户回:这颗一起给别家了',
},
{ rfq_ref: 'RFQ-2026-0777', line_total: 2, customer_name: '青岛海洋电子', summary: '两条都没谈成,交情报归档' },
],
},
expect: { ports: ['archived'] },
},
{
/*
* ② 成交归档:A6 `won` 那一口的单子结案后也归这一份档 —— 成交价与胜因同样是情报
* (下一单的基准价就在这儿)。归因不是 PRICE,竞对价那一格不该被闸拦。
*/
name: '成交归档 · 两条谈成 · 胜在交期与既有供应关系',
input: {
rfqRef: 'RFQ MD14.1',
customerCode: 'CU-0142',
quoteNo: 'Q-MD14.1',
outcome: 'won',
customer_name: 'Itelma',
rows: [
{
rfq_ref: 'RFQ MD14.1',
pn: 'BBPY00201209601Y00',
customer_pn: '4-14-01502',
category: '滤波器',
brand: 'YAGEO',
eau: 30000,
quoted_price: 0.085,
final_price: 0.085,
reply_type: 'ACCEPT',
deal_status: 'WON',
reply_note: '客户回:按这个价走,PO 下周发',
},
{
rfq_ref: 'RFQ MD14.1',
pn: 'CJTB0101R6N',
category: '集成电路',
brand: 'Jiangsu Changjing',
eau: 60000,
quoted_price: 0.42,
final_price: 0.42,
reply_type: 'ACCEPT',
deal_status: 'WON',
reply_note: '客户回:可以,一起下',
},
{ rfq_ref: 'RFQ MD14.1', line_total: 2, customer_name: 'Itelma' },
],
},
expect: { ports: ['archived'] },
},
{
/*
* ③ 单飞宽进:没有单号没有清单,正文说了哪一单、报了什么价、客户怎么回的 ——
* 归一造案号(ARC- 开头),两颗料一条都没成。
*/
name: '单飞需求 · 正文列两颗料 · 客户始终未表态(补一单历史)',
input: {
subject: 'MD14.2 这一单补个归档',
body: '上个月发出去的报价函一直没回音:XCJ-7788T 电源模块 8K,报价 14.80;LMR-3302QQ 稳压器 15K,报价 2.35。客户是 深圳市锐驰电子,联系人王工。报价日 2026-07-05,D+3 与 D+7 都催过,客户始终没表态,这一单按丢单归档。',
},
expect: { ports: ['archived'] },
},
],
+28 -6
View File
@@ -1,5 +1,5 @@
/**
* A7 技能 —— 归档的方法经验(AM 08)。
* A7 技能 —— 归档的方法经验(AM 08)。**载方法不载权力**:这里一条工具都不引。
*/
import { skillIdSchema } from '@contracts';
import type { Skill } from '@contracts';
@@ -7,16 +7,38 @@ import type { Skill } from '@contracts';
export const ARCHIVE_SKILLS: Skill[] = [
{
id: skillIdSchema.parse('archive.log-lost-reason'),
label: '把拒绝原因写成下一单的判据',
applicability: '客户拒绝或超时,要登记结果时。',
label: '把输赢的缘由写成下一单的判据',
applicability: '逐条落归因、准备发那张执行单时。',
method: [
'「价格高」不是原因,是结论。要写的是**可比对的事实**:',
'· 竞对报价多少、差几个百分点;',
'· 输在价格、交期、还是资质/认证;',
'· 客户是否明说了可接受区间。',
'· 竞对报价多少、差几个百分点(差 2 个点与差 30 个点是两件完全不同的事);',
'· 输在价格、交期、认证资质、规格,还是既有供应关系;',
'· 客户是否明说了可接受区间 —— 下一单的起点就藏在这一句里。',
'',
'判据要能被下一单**机器读取**:P-03 用「距上次报价 / 行情稳定」,P-01 用客户等级。',
'写成一段散文,它就只能被人读;写成字段,它才进得了决策表。',
'',
'**「客户没回」不是原因**:问不出来就落 OTHER 并在依据里写明「未获反馈」。',
'编一个听着合理的理由,三个月后没人记得它是编的 —— 而它已经在替下一单定价了。',
].join('\n'),
crystallized: [],
knowledgeRefs: [],
version: '1.0.0',
},
{
id: skillIdSchema.parse('archive.share-scope'),
label: '情报入库口径:哪些该留名,哪些该抹掉',
applicability: '发入库口径决策卡、给出建议档位时。',
method: [
'进了共享情报库的东西,**下一单谁都读得到** —— 这条边界过去了就收不回来。',
'三档的分界:',
'· **全量入库**:不涉第三方报价的败因(交期、认证、规格)—— 查得越全越好;',
'· **脱敏入库**:带着竞对报价的情报 —— 价位、归因、日期照留,谁说的抹掉。',
' 竞对的报价是他给客户的,不是给我们的;客户转述给我们,不等于我们可以对内广播;',
'· **只留内部**:客户明说不外传、或涉及在谈的项目 —— 这一单的情报只在本案卷里躺着。',
'',
'**建议归尺,结论归人**:A-02 只在决策卡上标一枚 recommended,',
'按不按它是签字的人的事 —— 这不是效率问题,是谁承担后果的问题。',
].join('\n'),
crystallized: [],
knowledgeRefs: [],
@@ -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>`;
+755 -140
View File
@@ -1,154 +1,769 @@
/**
* A7 情报归档的 HTML 报告模版 —— **活动自己持有的资产**(§10.3),随包版本冻结。
* A7 情报归档的 HTML 报告模版 —— **活动自己持有的资产**,随包版本冻结。
*
* 归档最容易被当成走过场:单子已经丢了,再填一遍原因像是给失败写检讨。
* 所以这两屏从头到尾只讲一件事 —— **这一单留下了什么,下一单谁会用它**。
* 三张共享域表各自对着下一单的一个判据:历史价格情报喂三分类 P-03 的「距上次报价」、
* 客户资料库喂利润模型 P-01 的客户等级因子、落地率统计喂驾驶舱。人在这一屏上要判断的
* 不是「填没填」,是**填的这几个字下一单的机器读不读得懂** —— 写成散文只能被人读,
* 写成字段才进得了决策表。
* 两个局面各一张(与 A2/A3/A4/A6 同款口径):
* · `archive-worksheet` **情报归档工作单** —— 归一与逐条归因期间摆它:
* 逐颗料一张情报档案卡(既成事实在左、归因在右)、归因六档与败价幅度 A-01 两把尺、
* 填回工作表就长在同一屏;
* · `archive-roster` **情报归档清单** —— 入库口径拍板、放行与归档摆它:整份情报卡,
* 口径分布、下一单谁用得上的落边图与落地动作在上面。
*
* 两个局面:
* ① 结果登记 —— 拒绝原因与竞对价位在不在、算不算数;
* ② 闭环待放行 —— 三样沉淀齐不齐;签完这一笔整条流走到终点。
* 「1:1」的落点是**每份件与它的办结快照**(在办可编辑 ↔ 回执收下那一刻的 finalHtml),
* 不是全程只许一张:作业的件与收口的件是两件工作两份件(规范姊妹篇 §1)。
*
* 这里**没有「案例进程」一节**:归档只有 S1 一个阶段,摆出来是一行凑数的进度条,
* 而版面上每多一节,真正要看的三张表就少一分注意力。
* 版面语言沿用 A1 从 PRD 目标件机械抽取的那套(`prd-style.ts` 原样拷入)——
* A7 没有自己的 PRD 目标件,与 A2/A3/A4/A6 同一处置:沿用家族版面自造。
*
* 容器是 `sandbox=""` iframe:无脚本、无外链、样式自带;数据由基座推送时填
* (`data-slot` 取值、`data-rows` 逐行展开)。状态章一律用「在办」灯 ——
* 模版是随包冻结的静态字符串,盖不了一个还没发生的章;`verdict` 的橙档专指
* 「还没发生,等你按」,绿只留给已达标的机检结论。
* 取值三纪律:顶层只取汇总列与单值列;逐条走 `mine:quote_archive.intel`;
* 列名逐字照 `declarations.ledger` 抄。**模版不算不判** —— 缺归因红带、归因徽、口径徽、
* 分布条全部由基座算好的数经 `data-class-of` 投影,判断留在样式表。
*/
import { noteOnly, section, statsBand, tableOnly, verdict, viewShell } from '@sdk';
import { PRD_CSS, PRD_SPRITE } from './prd-style.ts';
/**
* 静态阶梯 —— **判据的写法与沉淀的去向不是数据**,它们随包冻结,不走 `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/A6 的 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);
}
}
/** 局面一:S1 回写 —— 人此刻要判的是「这条原因,下一单读不读得懂」 */
export const ARCHIVE_LOGGED = viewShell({
title: '结果登记',
kicker: 'A7 · 情报归档 · S1 回写',
tone: 'archive',
icon: 'database',
stamp: { kind: 'live', text: '回写中' },
sign: 'A7 情报归档 · 结果登记(共享域)',
/* 序号由 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; }
}
/* 就地改:可改格子渲了只读与输入两份,默认锁着,表上加 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-won { background: var(--ok); }
.proto-root .jc-tbadge.v-lost { background: var(--ink-4); }
.proto-root .jc-segmeter > i.v-won { background: var(--ok); }
.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: 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 dashed var(--ink-4); background: #fff;
}
.proto-root .jc-obadge::after { content: "待归因"; }
.proto-root .jc-obadge.v-price { color: var(--danger); border-color: var(--danger); border-style: solid; }
.proto-root .jc-obadge.v-price::after { content: "输在价格"; }
.proto-root .jc-obadge.v-leadtime { color: var(--warn-accent); border-color: var(--warn-accent); border-style: solid; }
.proto-root .jc-obadge.v-leadtime::after { content: "输在交期"; }
.proto-root .jc-obadge.v-cert { color: var(--brand); border-color: var(--brand); border-style: solid; }
.proto-root .jc-obadge.v-cert::after { content: "认证资质"; }
.proto-root .jc-obadge.v-spec { color: var(--brand-deep); border-color: var(--brand-deep); border-style: solid; }
.proto-root .jc-obadge.v-spec::after { content: "规格不符"; }
.proto-root .jc-obadge.v-relation { color: var(--ink-3); border-color: var(--ink-4); border-style: solid; }
.proto-root .jc-obadge.v-relation::after { content: "既有供应"; }
.proto-root .jc-obadge.v-other { color: var(--ink-3); border-color: var(--ink-4); border-style: dashed; }
.proto-root .jc-obadge.v-other::after { content: "说不清"; }
/* 口径徽:待定橙虚 / 全量绿 / 脱敏蓝 / 只留内部灰 */
.proto-root .jc-dbadge {
display: inline-flex; align-items: center; justify-content: center;
min-width: 66px; 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-pending { color: var(--warn-accent); border-color: var(--warn-accent); border-style: dashed; }
.proto-root .jc-dbadge.v-pending::after { content: "待你拍板"; }
.proto-root .jc-dbadge.v-full { color: var(--ok); border-color: var(--ok); }
.proto-root .jc-dbadge.v-full::after { content: "全量入库"; }
.proto-root .jc-dbadge.v-masked { color: var(--brand); border-color: var(--brand); }
.proto-root .jc-dbadge.v-masked::after { content: "脱敏入库"; }
.proto-root .jc-dbadge.v-internal { color: var(--ink-3); border-color: var(--ink-4); }
.proto-root .jc-dbadge.v-internal::after { content: "只留内部"; }
/* 败价幅度(A-01)—— 只在归因落「输在价格」的行上有值 */
.proto-root .jc-cbadge { font-size: 10.5px; font-weight: 700; color: var(--ink-4); }
.proto-root .jc-cbadge.v-slight { color: var(--ok); }
.proto-root .jc-cbadge.v-slight::after { content: "擦肩而过 ≤3%"; }
.proto-root .jc-cbadge.v-moderate { color: var(--warn-accent); }
.proto-root .jc-cbadge.v-moderate::after { content: "够得着 3~10%"; }
.proto-root .jc-cbadge.v-structural { color: var(--danger); }
.proto-root .jc-cbadge.v-structural::after { content: "结构性价差 >10%"; }
/* 分布条:四段按条数分宽 —— 条数投影成 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; }
/* ── 情报档案卡(工作单独有,照 A6 报价档案卡的骨架)──────────────────────
一颗料一张卡,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-lost { border-color: #f3b6b3; box-shadow: 0 1px 0 rgba(191,60,52,.05); }
.proto-root .jc-selcard.v-won { border-color: #bfe3cd; }
.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-lost .jc-selcard-h { background: #fff6f5; border-bottom-color: #f3d3d0; }
.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(--brand); 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 .rnode.hit { stroke: var(--brand); stroke-width: 2; }
.proto-root .route .rtxt { font-size: 12px; font-weight: 800; fill: var(--ink-3); }
.proto-root .route .rtxt.hit { fill: var(--brand-deep); }
.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 .rtip.hit { fill: var(--brand); }
.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_archive_intel_missing"></b><span>/ <b data-slot="quote_archive_intel_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_archive.intel">
<i data-class-of="deal_result"></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="quoted_at" data-blank></b></span></span>
<span class="jc-fcard-note">下一单的三分类 P-03 看的就是「距上次报价多久」—— 这一格空着,这条情报到下一单就哑了</span>
</div>
</div>`;
/** 一单情报是怎么走完的 —— 走位图(形状写死,不算不判) */
const ARCHIVE_LINE = `<div class="jc-priceline">
<div class="jc-plnode"><b>既成事实</b><span>报过的价、谈成的价、什么时候报的 —— 一个数字都不改</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>败价幅度 A-01</b><span>价差 ≤3% / 3~10% / >10%,决定下一单怎么用这条情报</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>下一单</b><span>三分类取时效 · 定价取基准 · 客户画像取行为</span></div>
</div>`;
/** 缺归因红带 —— 与提交闸、放行门 L1 同一个数同一套算法(基座现数);一格不缺自动收 */
const GAP_BAR = `<div class="jc-gapbar" data-class-of="quote_archive_intel_missing">${ico('alert', 14)}
<span><b>还差 <span data-slot="quote_archive_intel_missing"></span> 行归因没落</b>
—— <span data-slot="quote_archive_intel_missing_keys"></span>。
按右上角「编辑」开表,红框那几格就是要填的:输在价格填 PRICE 并把竞对报的那个价填进「竞对价」,
交期填 LEADTIME,认证资质填 CERT,规格不符填 SPEC,既有供应关系填 RELATION,
问不出来填 OTHER 并在依据里写明未获反馈。补齐了执行单才提交得了。</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> —— 客户为什么不下单只有你看得见,别让系统替他解释。
归因落 PRICE 的行务必把竞对报的那个数填上,败价幅度全看这一格。不该归档的料按行首 × 撤掉。
改动只落任务草稿层,<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` 只挂人可改的四列(cause_tag / competitor_price / competitor_name / evidence)
* + 主键 pn —— 编辑面精确等于 BD 的职权面;**主键格必挂 `data-col`**(它不是编辑面,
* 是行的钥匙:不挂,服务端按「全表一致列」补键,两行会对到同一行上,后者盖前者 ——
* A2 走查逮过)。
* 归因挂 `req`:空着出「必填」胶囊、整行标底,与提交闸、放行门同源(都数 `_missing`)。
*/
const INTEL_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:88px">报价日期</th>
<th style="min-width:88px">我方报价</th>
<th style="min-width:80px">成交价</th>
<th class="req" style="width:92px;min-width:92px">输赢归因(你落)</th>
<th style="min-width:80px">竞对价</th>
<th style="min-width:96px">竞对是谁</th>
<th style="width:104px;min-width:104px">败价幅度</th>
<th style="min-width:150px;width:100%">依据 / 客户原话</th>
</tr></thead>
<tbody data-rows="mine:quote_archive.intel" data-edit="quote_archive.intel"><tr>
<td class="jc-rn"><button type="button" class="jc-rowdel" data-edit-del="quote_archive.intel" title="撤掉这一行">×</button></td>
<td class="jc-ty" data-h="果"><span class="jc-tbadge" data-class-of="deal_result" data-slot="deal_result"></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 mono" data-slot="quoted_at" data-blank></span></td>
<td class="dimempty" data-h="我方报价"><span class="jc-vcell num" data-slot="quoted_price" data-blank></span></td>
<td class="dimempty" data-h="成交价"><span class="jc-vcell num" data-slot="final_price" data-blank></span></td>
<td class="req" data-h="输赢归因(你落)">
<span class="jc-obadge" data-class-of="cause_tag"></span>
<span class="jc-vcell" data-slot="cause_tag" data-col="cause_tag" data-blank></span>
</td>
<td class="sug" data-h="竞对价"><span class="jc-vcell num" data-slot="competitor_price" data-col="competitor_price" data-blank></span></td>
<td class="sug" data-h="竞对是谁"><span class="jc-vcell" data-slot="competitor_name" data-col="competitor_name" data-blank></span></td>
<td data-h="败价幅度"><span class="jc-cbadge" data-class-of="price_gap_band"></span></td>
<td class="opt" data-h="依据 / 客户原话"><span class="jc-vcell" data-slot="evidence" data-col="evidence" data-blank></span></td>
</tr></tbody>
</table></div>`;
/**
* 情报清单表(收口件用)—— 编辑面换成**放行时可改的两列**(归因与依据),
* 与 `requiredDeliverables[].editableAtRelease` 逐字对齐。
*/
const ARCHIVED_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">报价日期</th>
<th style="min-width:88px">我方报价</th>
<th style="min-width:80px">成交价</th>
<th style="width:92px;min-width:92px">输赢归因</th>
<th style="min-width:80px">竞对价</th>
<th style="width:104px;min-width:104px">败价幅度</th>
<th style="width:84px;min-width:84px">入库口径</th>
<th style="min-width:150px;width:100%">依据 / 客户原话</th>
</tr></thead>
<tbody data-rows="mine:quote_archive.intel" data-edit="quote_archive.intel"><tr>
<td class="jc-rn"><button type="button" class="jc-rowdel" data-edit-del="quote_archive.intel" title="撤掉这一行">×</button></td>
<td class="jc-ty" data-h="果"><span class="jc-tbadge" data-class-of="deal_result" data-slot="deal_result"></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 mono" data-slot="quoted_at" data-blank></span></td>
<td class="dimempty" data-h="我方报价"><span class="jc-vcell num" data-slot="quoted_price" data-blank></span></td>
<td class="dimempty" data-h="成交价"><span class="jc-vcell num" data-slot="final_price" data-blank></span></td>
<td class="req" data-h="输赢归因">
<span class="jc-obadge" data-class-of="cause_tag"></span>
<span class="jc-vcell" data-slot="cause_tag" data-col="cause_tag" data-blank></span>
</td>
<td class="dimempty" data-h="竞对价"><span class="jc-vcell num" data-slot="competitor_price" data-blank></span></td>
<td class="dimempty" data-h="败价幅度"><span class="jc-cbadge" data-class-of="price_gap_band"></span></td>
<td data-h="入库口径"><span class="jc-dbadge" data-class-of="share_scope"></span></td>
<td class="opt" data-h="依据 / 客户原话"><span class="jc-vcell" data-slot="evidence" data-col="evidence" 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_archive_intel_share_scope_full" data-slot="quote_archive_intel_share_scope_full"></i>
<i class="n" data-class-of="quote_archive_intel_share_scope_pending" data-slot="quote_archive_intel_share_scope_pending"></i>
<i class="r" data-class-of="quote_archive_intel_share_scope_masked" data-slot="quote_archive_intel_share_scope_masked"></i>
<i class="k" data-class-of="quote_archive_intel_share_scope_internal" data-slot="quote_archive_intel_share_scope_internal"></i>
</div>
<div class="jc-distlg">
<span><em class="a"></em>全量入库 · 客户与竞对都留名 <b data-slot="quote_archive_intel_share_scope_full"></b></span>
<span><em class="b"></em>待拍板 · 口径还没定 <b data-slot="quote_archive_intel_share_scope_pending"></b></span>
<span><em class="b"></em>脱敏入库 · 只留价位与败因 <b data-slot="quote_archive_intel_share_scope_masked"></b></span>
<span><em class="c"></em>只留内部 · 不进共享库 <b data-slot="quote_archive_intel_share_scope_internal"></b></span>
</div>
</div>`;
/* ==================== 局面一 · 情报归档工作单(归一 / 逐条归因)==================== */
/**
* 逐颗情报档案 —— 一颗料一张卡(data-rows 逐行复制):左既成事实、右归因。
*
* **容器用 `<section>` 不用 `<div>`**:渲染器的行展开按第一个同名闭合标签截行内容,
* 行模版里嵌着 `<div>` 时 `<div data-rows>` 会把行截成套娃(A2 逮过,记在陷阱表)。
*/
const INTEL_CARDS = `<section class="jc-selcards" data-rows="mine:quote_archive.intel">
<article class="jc-selcard" data-class-of="deal_result">
<div class="jc-selcard-h">
<span class="jc-tbadge" data-class-of="deal_result" data-slot="deal_result"></span>
<code data-slot="pn"></code>
<span class="why" data-slot="evidence" data-blank></span>
<span class="jc-obadge" data-class-of="cause_tag"></span>
<span class="jc-dbadge" data-class-of="share_scope"></span>
</div>
<div class="jc-selcard-b">
<div class="jc-selcol">
<h5>既成事实(报过的价,一个数字都不改)</h5>
<div class="jc-kvrow"><i>客户</i><span data-slot="customer_name" 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>我方报价</i><span class="num" data-slot="quoted_price" data-blank></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="quoted_at" data-blank></span></div>
<div class="jc-kvrow"><i>结果</i><span data-slot="deal_result" data-blank></span></div>
</div>
<div class="jc-selcol">
<h5>归因(填回下表,这里回显)</h5>
<div class="jc-kvrow"><i>输赢归因</i><span class="sc-req" data-slot="cause_tag" data-blank></span></div>
<div class="jc-kvrow"><i>竞对价</i><span class="sc-sug num" data-slot="competitor_price" data-blank></span></div>
<div class="jc-kvrow"><i>竞对是谁</i><span class="sc-sug" data-slot="competitor_name" data-blank></span></div>
<div class="jc-kvrow"><i>依据 / 原话</i><span class="sc-opt" data-slot="evidence" data-blank></span></div>
<div class="jc-kvrow"><i>败价幅度</i><span class="jc-cbadge" data-class-of="price_gap_band"></span></div>
<div class="jc-kvrow"><i>入库口径</i><span class="jc-dbadge" data-class-of="share_scope"></span></div>
</div>
</div>
</article>
</section>`;
/** 归因六档 —— 静态规则,尺挂在墙上(素材:MVP 示例工作流 §1.6 结果登记) */
const CAUSE_RULES = `<div class="jc-graderule">
<div class="jc-graderule-r">
<span class="jc-band gk">PRICE</span>
<p><b>输在价格。</b>客户明说了别家更便宜,或直接给出一个我们够不着的数 ——
<b>这一档必须填竞对价</b>:没有那个数,「输在价格」下一单读不出任何东西,
而有了它,败价幅度 A-01 当场算得出来。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gc">LEADTIME</span>
<p><b>输在交期。</b>客户等不起 —— 别家现货、我们八周。
价格再低也没用,下一单同一颗料先问现货商。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gb">CERT</span>
<p><b>认证资质。</b>车规、医疗、军工的门槛没过,或供应商资质不在客户合格名录里 ——
下一单这一类需求先核认证,别报完价才发现进不去。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gb">SPEC</span>
<p><b>规格不匹配。</b>参数、封装、温度范围对不上 —— 这是选型的账,
下一单同一需求直接从替代选型开始,别在原厂料上耗。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gz">RELATION</span>
<p><b>既有供应关系。</b>客户手上已经有稳定的供应商,这一轮只是来比个价 ——
情报照记:下一单知道要拿什么去撬,或者干脆不投这个标。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gz">OTHER</span>
<p><b>说不清。</b><b>「客户没回」不是原因</b> —— 问不出来就落这一档,
并在依据里写明「未获反馈」。<b>不许编一个听着合理的理由</b>:
编出来的原因会一路流进下一单的判据里,而那时已经没人记得它是编的。</p>
</div>
</div>`;
/** 败价幅度 A-01 的三档 —— 静态规则(素材:MVP 示例工作流 §1.6 情报复用) */
const BAND_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 gc">3% ~ 10%</span>
<p><b>够得着。</b>下一单要么让到这一档,要么换替代料把成本降下来 ——
这是替代选型最值钱的入口:同样的功能,换一颗料就把十个点让出来了。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gk">&gt; 10%</span>
<p><b>结构性价差。</b>一成以上不是让价能填的坑,是成本结构不对 ——
下一单别再报同一价位,先走替代选型或重新寻源。<b>拿同一个价再报一次不叫坚持,叫没记账</b>。</p>
</div>
</div>`;
export const ARCHIVE_WORKSHEET = doc({
badge: 'file-text',
kicker: '统一报价 · A7 情报归档 · 归因作业',
title: '情报归档工作单',
titleRef: 'rfq_ref',
state:
'<span class="jc-rp-wait warn">待归因 <b data-slot="quote_archive_intel_missing"></b> / <b data-slot="quote_archive_intel_rows"></b> 条</span>',
body:
statsBand([
['报价日期', 'quoted_at'],
['竞对价位', 'competitor_price'],
['客户等级', 'grade'],
]) +
section('本单登记的结果',
tableOnly('mine:shared.history_price', [
['型号 PN', 'pn', 'mono'],
['报价日期', 'quoted_at', 'mono'],
['拒绝原因', 'lost_reason'],
['竞对价位', 'competitor_price'],
]) +
noteOnly(
'<b>「客户没回」不是原因。</b>问不出来就写「未获反馈」—— 不许编一个听着合理的理由:' +
'编出来的原因会一路流进下一单的判据里,而那时已经没人记得它是编的。',
),
{ text: '只列本单写下的行' },
) +
section('什么样的原因才算数',
ladder([
['1', '竞对报价多少、差几个百分点', '要数,不要形容词'],
['2', '输在价格、交期,还是资质 / 认证', '把败因归到一个维度上'],
['3', '客户是否明说了可接受区间', '下一单的起点就藏在这句话里'],
]) +
noteOnly(
'<b>「价格高」不是原因,是结论。</b>判据要能被下一单<b>机器读取</b>:' +
'P-03 用「距上次报价 / 行情稳定」,P-01 用客户等级。' +
'写成一段散文,它就只能被人读;写成字段,它才进得了决策表。',
),
{ text: '写成字段' },
) +
section('这一单往哪儿沉 —— 三张共享域表',
ladder([
['1', '历史价格情报 · shared.history_price', '喂给下一单的三分类 P-03'],
['2', '客户资料库 · shared.customer', '喂给下一单的利润模型 P-01'],
['3', '报价落地率统计 · shared.landing_stats', '喂给驾驶舱的落地率与 D+3 回复率'],
]),
{ text: '跨活动的沉淀事实' },
) +
section('这一单的底线',
verdict('不得空归档 —— 每一单不成交的报价也是情报资产', 'wait', '原因未回写,预检当场退回') +
noteOnly(
'归档做得好不好,<b>不在这一单看得出来</b> —— 在下一次报价能不能一眼看到「上次为什么丢」看得出来。',
),
),
part('归档任务', '客户 <b data-slot="customer_name" data-blank></b> · 报价单 <b data-slot="quote_no" data-blank></b> · 共 <b data-slot="intel_total"></b> 条') +
`<div class="jc-derive">${ico('info', 14)}
<span>这一屏是一份情报的作业台:系统已按上游的结果归好情报卡,报价、成交价与日期都在下面 ——
<b>既成事实一个数字都不改</b>。你要做的是<b>逐条落输赢归因</b>:客户到底为什么下单、
为什么不下单。这一列系统一格都不写:那件事只有跟单的人知道。
<b>这一单之后再没有人会回头看它</b> —— 归因写错没有下一道工序纠正,
而它会一路流进下一单的判据里。</span>
</div>` +
FOUR +
part('一单情报是怎么走完的', '既成事实 → 归因 → 败价幅度 → 口径 → 下一单') +
ARCHIVE_LINE +
part('逐颗情报档案', '既成事实在左 · 归因在右') +
INTEL_CARDS +
part('输赢归因六档', '选最主要的那一个 —— 尺挂在墙上') +
CAUSE_RULES +
part('败价幅度 A-01', '价差决定下一单怎么用这条情报') +
BAND_RULES +
part('填回工作表', '归因落在这 —— 补齐了执行单才提交得了') +
GAP_BAR +
editGate('quote_archive.intel', '情报卡填回表') +
INTEL_TABLE +
addRowBar('quote_archive.intel') +
`<div class="jc-drop">${ico('info', 13)}
<span>带红标的<b>输赢归因</b>是必填,而且<b>只由你填</b> —— 缺一行执行单按不下提交,
与放行门是同一个数同一套算法。六个取值一个字母不许差:
<b>PRICE</b> 价格 · <b>LEADTIME</b> 交期 · <b>CERT</b> 认证资质 · <b>SPEC</b> 规格不符 ·
<b>RELATION</b> 既有供应 · <b>OTHER</b> 说不清。
归因落 PRICE 的行务必把竞对报的那个价填进<b>竞对价</b> —— 那一格空着这张单同样提交不了,
败价幅度全看它。<b>依据那一栏尽量记客户原话</b>:「你们比 XX 贵两毛」比「价格偏高」
值钱得多 —— 下一单定价时看的就是这一句。不该归档的料按行首 × 撤掉。</span>
</div>`,
foot: sign('BD 归因 · AI 归一情报卡并按尺算败价幅度,输赢的缘由由你落', '统一报价 · A7 情报归档 · 在办'),
});
/** 局面二:闭环待放行 —— 签完这一笔,整条工作流走到终点 */
export const ARCHIVE_RELEASE = viewShell({
title: '情报归档 · 闭环待放行',
kicker: 'A7 · 情报归档 · 成果待签',
tone: 'case',
icon: 'archive',
stamp: { kind: 'live', text: '待签字封卷' },
sign: 'A7 情报归档 · 闭环成果(待放行)',
/* ==================== 局面二 · 情报归档清单(拍板 / 放行 / 归档)==================== */
/**
* 落边图:情报卡清单 → 归档闭环 → 下一单的三个用处。
*
* A7 是流程包的最后一站,**只有一口且无条件**(E14)—— 这条边不是选出来的,
* 所以画成实线亮是如实,不是替谁宣布。右边三个用处是这份情报的去处,
* 各挂本单条数(基座现数),没有的那一档条数为 0、人一眼看得出。
*/
const ROUTE_FIG = `<div class="route">
<svg viewBox="0 0 660 250" fill="none">
<rect class="rnode hit" x="8" y="95" width="150" height="60" rx="10"/>
<text class="rtxt hit" x="83" y="120" text-anchor="middle">情报卡清单</text>
<text class="rsub" x="83" y="138" text-anchor="middle">共 <tspan data-slot="quote_archive_intel_rows"></tspan> 条</text>
<path class="rspine" d="M158 125 H228"/>
<path class="rtip hit" d="M216 119 l14 6 -14 6 z"/>
<rect class="rnode hit" x="232" y="95" width="128" height="60" rx="10"/>
<text class="rtxt hit" x="296" y="120" text-anchor="middle">归档闭环</text>
<text class="rsub" x="296" y="138" text-anchor="middle">这条流走到终点</text>
<path class="redge" d="M360 125 C420 125 430 40 470 40"/>
<path class="redge" d="M360 125 C420 125 430 125 470 125"/>
<path class="redge" d="M360 125 C420 125 430 210 470 210"/>
<path class="rtip" d="M458 34 l14 6 -14 6 z"/>
<path class="rtip" d="M458 119 l14 6 -14 6 z"/>
<path class="rtip" d="M458 204 l14 6 -14 6 z"/>
<text class="rcond" x="378" y="62">时效 · 报价日期</text>
<text class="rcond" x="378" y="115">价格 <tspan data-slot="quote_archive_intel_cause_tag_price"></tspan> 条</text>
<text class="rcond" x="378" y="232">行为画像</text>
<rect class="rnode" x="470" y="8" width="180" height="64" rx="10"/>
<text class="rtxt" x="560" y="35" text-anchor="middle">下一单 · 三分类 P-03</text>
<text class="rsub" x="560" y="53" text-anchor="middle">距上次报价多久 · 行情稳不稳</text>
<rect class="rnode" x="470" y="93" width="180" height="64" rx="10"/>
<text class="rtxt" x="560" y="120" text-anchor="middle">下一单 · 成本定价</text>
<text class="rsub" x="560" y="138" text-anchor="middle">基准价与竞对价位</text>
<rect class="rnode" x="470" y="178" width="180" height="64" rx="10"/>
<text class="rtxt" x="560" y="205" text-anchor="middle">客户画像 · 利润模型 P-01</text>
<text class="rsub" x="560" y="223" text-anchor="middle">价格敏感度 · 决策周期</text>
</svg>
<div class="legend">
<span>A7 只有一口且无条件 —— 它不是选出来的,是这条流的终点</span>
<span>右边三个用处是这份情报的去处:入库口径定的就是它们读不读得到</span>
<span>只留内部那一档不进共享库 —— 三条虚线到那一单就全断了</span>
</div>
</div>`;
/** 入库口径三档 —— 静态规则,决策卡上那一枚 recommended 由 A-02 标 */
const SCOPE_RULES = `<div class="jc-graderule">
<div class="jc-graderule-r">
<span class="jc-band ga">全量入库</span>
<p><b>客户与竞对都留名。</b>下一单查这颗料时,客户是谁、竞对是谁、报了多少一目了然 ——
情报最值钱的形态。代价是客户身份与第三方报价就此对内公开。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gb">脱敏入库</span>
<p><b>只留价位与败因。</b>竞对与客户的名字抹掉,价位、归因、日期照留 ——
下一单照样判得了价,只是追不到是谁说的。<b>带着第三方报价的情报默认走这一档</b>:
竞对的价是他给客户的,不是给我们的。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gz">只留内部</span>
<p><b>不进共享情报库。</b>这一单的情报只在本案卷里躺着,下一单查不到 ——
涉密、客户明说不外传、或项目还在谈时才选这一档。选它等于承认这份情报只值一次。</p>
</div>
</div>`;
export const ARCHIVE_ROSTER = doc({
badge: 'file-check',
kicker: '统一报价 · A7 情报归档 · 交付成果',
title: '情报归档清单',
titleRef: 'rfq_ref',
state:
'<span class="jc-rp-wait">情报 <b data-slot="quote_archive_intel_rows"></b> 条 · 待拍板 <b data-slot="quote_archive_intel_share_scope_pending"></b> 条(要归零)</span>',
body:
statsBand([
['统计周期', 'period'],
['落地率', 'landing_rate'],
['TypeA 命中', 'type_a_hit'],
['D+3 回复率', 'reply_rate_d3'],
]) +
section('历史价格情报回写',
tableOnly('mine:shared.history_price', [
['型号 PN', 'pn', 'mono'],
['报价日期', 'quoted_at', 'mono'],
['拒绝原因', 'lost_reason'],
['竞对价位', 'competitor_price'],
]),
{ text: '下一单 P-03 取它' },
) +
section('客户画像更新',
tableOnly('mine:shared.customer', [
['客户码', 'customer_code', 'mono'],
['客户等级', 'grade'],
['画像标签', 'profile_tags'],
]),
{ text: '下一单 P-01 取它' },
) +
section('报价落地率统计',
tableOnly('mine:shared.landing_stats', [
['月份', 'period', 'mono'],
['落地率', 'landing_rate'],
['TypeA 命中', 'type_a_hit'],
['D+3 回复率', 'reply_rate_d3'],
]) +
noteOnly(
'三张表都写在<b>共享域</b>:它们是跨活动的沉淀事实,不属于这一单 ——' +
'这也是活动之间除了流程边以外,唯一一条合法的信息通路。',
),
{ text: '驾驶舱取它' },
) +
section('放行前要过的三道判据',
ladder([
['1', '拒绝原因 / 竞对价位已回写', 'L1 机检 · reasonLogged = true'],
['2', '客户画像标签已更新', 'L2 机检 · profileUpdated = true'],
['3', 'BD 放行', 'L5 人签 · 签了才算数'],
]),
{ text: '两机检 · 一人签' },
) +
section('封卷之后会发生什么',
verdict('放行即闭环:这条工作流就此收敛为「已完成」', 'wait', '终态封卷触发蒸馏钩子') +
noteOnly(
'蒸馏从任务记忆归纳出<b>活动记忆</b>的候选条目(带证据链),经回放与人放行才成为' +
'下一版业务模型的一部分 —— <b>记忆不载权力</b>。这一单丢了,但它把下一单的地形描出来了。',
),
),
part('归档要素', '情报归档 · 能算的归判定表,要担责的归人') +
FOUR +
part('入库口径分布', '全量 + 待拍板 + 脱敏 + 只留内部 = 总条数') +
DIST +
`<div class="jc-derive">${ico('info', 14)}
<span>四段条数由基座逐行现数,不是谁报上来的。橙段(<b>待拍板</b>)是过渡态 ——
情报归好了而口径还没定,它<b>归零这一单才走得到放行门</b>。
进了共享情报库的东西下一单谁都读得到,<b>这条边界过去了就收不回来</b> ——
所以系统不替你定,只按 A-02 给一枚建议。</span>
</div>` +
part('情报卡清单', '<b data-slot="intel_total"></b> 条 · 每条都有归因') +
GAP_BAR +
editGate('quote_archive.intel', '情报清单表') +
ARCHIVED_TABLE +
`<div class="jc-drop">${ico('info', 13)}
<span>每一行摆的是这一条的<b>来路</b>:报了多少、成没成、输赢归在哪一档、
竞对报了什么价、败价幅度落在哪一级。签字前请重点看两处:
<b>归因列</b>里落了 OTHER 的条目(说不清的情报到下一单就是哑的),
与<b>依据列</b> —— 没有客户原话的归因,下一单只能当传说看。
这一屏可改的是<b>输赢归因</b>与<b>依据</b>两列(你的职权面)。</span>
</div>` +
part('入库口径', '三档 · 建议归尺,结论归你') +
SCOPE_RULES +
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="buyer_profile" data-blank></b><code data-slot="share_policy" data-blank></code></div>
</div>` +
part('放行后即执行的落地动作', '门前 → 门后') +
`<div class="jc-acts">
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>草稿层合并进正式版</b>&#12288;<span>本任务写下的情报卡与归档结论一并落账;
你就地改过的归因与依据随之落定。</span></div>
</div>
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>归档闭环那一口此刻才发射</b>&#12288;<span>情报卡
<b data-slot="quote_archive_intel_rows"></b> 条随载荷交出去,这条工作流走到终点 ——
放行之前,世界上什么都没有发生。</span></div>
</div>
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>下一单开始受用</b>&#12288;<span>按你定的口径,这几条价位、败因与日期
进入下一次询价的判据面 —— <b>归档做得好不好,不在这一单看得出来</b>,
在下一次报价能不能一眼看到「上次为什么丢」看得出来。</span></div>
</div>
</div>`,
foot: sign('BD 归档放行 · AI 归一与算尺留痕 —— 要改哪一格,在这张情报清单上就地改', '统一报价 · A7 情报归档 → 归档闭环'),
});
@@ -11,7 +11,7 @@
*/
import { describe, expect, it } from 'vitest';
import { evaluateCondition } from '@sfeel';
import { INTAKE_PHASES, INTAKE_STEPS } from '../src/case-model.ts';
import { INTAKE_PHASES, INTAKE_STEPS, NARRATIVE } from '../src/case-model.ts';
import { CMMN_XML } from '../src/case-model.ts';
import { manifest } from '../src/manifest.ts';
@@ -152,8 +152,23 @@ describe('doneWhen 是「成果已存在」的判据,不是「该不该做」的
const confirm = INTAKE_STEPS.find((s) => s.item === 'S3_confirm');
expect(confirm, '受理需求交人审定这一步不见了').toBeDefined();
expect(confirm?.view).toBe('intake-overview');
// 卡上那张表绑台账整份铺开 —— 人改的是九行,不是缺的那一两行
expect(confirm?.guide).toContain('"ledger":"quote_intake.record"');
/*
* **执行卡零输入**(2026-08-02 用户定:填写全去工作区)——
* 卡上不绑表、不摆格子;清单在工作区那份件上就地改(editGate + 孪生件),
* 提交闸回落到必交付物的台账声明照样把必填看住。
* 从前这儿断言卡里带 `"ledger":"quote_intake.record"` 的 blocks —— 那一版
* 同一张表摆两处(卡上一份、件上一份),人不知道改哪一份算数。
*/
/*
* 骨架已搬进说明书(2026-08-03:模型常在「拆表+收步+派卡」同一拍发这张卡,
* 那一拍 S3 的剧本没摊开 —— 骨架只放剧本里就会发出裸卡,实测指导书整个是空的)。
* 剧本里留的是指路;零输入纪律与骨架本体都在说明书里,每一拍都看得见。
*/
expect(confirm?.guide).toContain('骨架在说明书里');
expect(NARRATIVE).toContain('受理需求审定卡的骨架');
expect(NARRATIVE).toContain('卡上不摆表、不摆格子');
expect(NARRATIVE).toContain('"itemId":"S3_confirm"');
expect(confirm?.guide).not.toContain('"blocks"');
expect(confirm?.guide).toContain('confirmed_total');
});
+86 -54
View File
@@ -50,6 +50,7 @@ export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
(连跑十轮里三轮死在这)。自报的两个数照旧写,它们是给报告和人看的。
-->
<caseFileItem id="quote_intake_record_missing" name="缺必填的行数(基座现数)"/>
<caseFileItem id="quote_intake_record_rows" name="受理记录行数(基座现数)"/>
<caseFileItem id="missing_key_total" name="缺必填字段条数(PN/年用量)"/>
<caseFileItem id="missing_minor_total" name="缺选填字段条数(品牌/备注)"/>
<caseFileItem id="asked_total" name="已就缺口问过人的次数"/>
@@ -74,10 +75,17 @@ export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<!--
② 拆完了 —— 交人审定那一步的入场前提。只看「有没有拆出行来」:
缺不缺字段不影响该不该给人看,缺口是**卡上要说清的事**,不是拦住这张卡的理由。
读**基座数的行数**,不读自报的 line_total(2026-08-02 用户实测送来的单,
「机检只读基座数」第四次被违反现形):模型把 record 三行在 S1 拍就写了،
却没记 line_total(那本是 S2 的账),S2 空手收步 —— 这道门于是永远不开,
引擎报「依赖断点」,模型被推去发补全卡绕路,两张多余的卡把 4 次人环
预算烧光,整单 exhausted。行落了没有,基座数得出来;门不该等一个
要模型自己记的数。
-->
<sentry id="s_parsed">
<onPart id="op_ps" caseFileItemRef="line_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_ps"><condition>line_total &gt; 0</condition></ifPart>
<onPart id="op_ps" caseFileItemRef="quote_intake_record_rows"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_ps"><condition>quote_intake_record_rows &gt; 0</condition></ifPart>
</sentry>
<!--
③ **人审定过了** —— 后面查历史价/打标的入场前提。
@@ -126,7 +134,7 @@ export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<!--
① 定位客户信息 —— 拿发件邮箱去客户主数据里认人。
认得出就取它的客户编码;认不出说明这是个**新客户**,发补全卡请人建档
认得出就取它的客户编码;认不出说明这是个**新客户**,发数据录入卡请人建档
(公司名、客户编码、联系人)—— 建档是人的事,活动不替他编一个客户出来。
-->
<humanTask id="S1_customer" name="定位客户信息:按发件邮箱认客户,认不出请人建档">
@@ -135,7 +143,7 @@ export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<!--
② 归一客户 RFQ 输入 —— 解析邮件正文,逐条落成受理记录。
PN / 年用量**必填**,其余**选填**;缺哪几格照实记进两个缺口数。
PN / 年用量**必填**;客户料号 / 品类 / 制造商 / 目标价是**建议追问**;其余**选填**。缺哪几格照实记进两个缺口数。
**这一步不叫人**:拆料是 AI 的活,拆完交给下一步给人看。
-->
<humanTask id="S2_normalize" name="归一客户RFQ输入:解析正文逐条落表">
@@ -226,6 +234,14 @@ S5 逐条定状况标 → S6 定去向与派单 → 提交放行。
「LM358DR 有近期成交价可以直接报;NE555P 半年多没动过要重新核;TL072CDR 停产了,得找替代。」
· S6 定完去向 → view = intake-release。**说这单接下来往哪走、谁接手**。
**交代里说缺什么,用件上那三个词**(必填 / 建议 / 选填),而且**只说已发生的事**:
客户没给的就说「客户没给」并留空 —— 估价、垫数这类事你没有权限做,更不许在交代里
声称做过(见「三种分量」那段:目标价尤其如此)。历史价、三分类、去向是**审定之后**
那几步的事:这一拍它们连数都还没有,一个字都不要提。
派审定卡那一拍的交代照这个样子说(数字与料号换成本单的):
「三颗料拆好了,交期用量都齐;LM358DR、TL072CDR 客户给了目标价,NE555P 没给
(建议追问,不拦单)。整份受理需求请你审定,哪一格不对在工作区的件上就地改。」
别每拍一个腔调复读(「已完成X,现在进行Y」)。事情不同,话就该不同 ——
调工具那一拍说「我去查这三颗的历史价」,拿到结果那一拍说查出了什么,
两句话该是不一样的语气。有风险、要他拿主意、或者你打算叫人,**先说出来**,
@@ -240,6 +256,31 @@ S5 逐条定状况标 → S6 定去向与派单 → 提交放行。
发卡 / 提交会挂起(它们只能是串尾)。**这一单常路 5-7 拍就该收口** ——
每多拆一拍,就是多烧一遍完整上下文。
【受理需求审定卡的骨架 —— 哪一拍发都照这个,一个字段别少】
拆完受理记录就要派这张卡(S3_confirm)。**骨架放在说明书里正是因为你常在
「拆表 + 收步 + 派卡」同一拍里发它** —— 那一拍 S3 的剧本还没摊开,凭记忆拼出来的
就是一张只有一句话的裸卡(实测:人收到的指导书整个是空的)。照抄:
{"kind":"human","itemId":"S3_confirm","workOrder":"execution","card":{
"title":"受理需求审定:<单号> 共<N>条","ask":"<照当下情形写,见下>",
"steps":["型号 PN 与年用量 EAU 必须有 —— 缺一条这一单就走不到放行;客户没给的去问,问不到也要在备注里写清「客户未提供」",
"客户料号、品类、制造商、目标价这四格是建议追问 —— 不填也走得到放行,但那一问不能省;尤其目标价,有它才谈得上「报得进不进得去」,客户不肯给就问一句「贵司这颗现在的成交价区间」",
"客户没给的一律留空,由你判断要不要追问 —— 系统查到的历史价是分类依据,不是客户的目标价",
"改在工作区的件上:抽错的就地改,多出来的行按行首 × 撤掉,漏掉的用表尾「加一行」补上;改完保存,回这张卡提交"],
"standard":"必填(PN / 年用量)一条不缺;建议档(客户料号 / 品类 / 制造商 / 目标价)该问的都问过了 —— 问到的填上,问不到的留空并在备注里写明,放行时由你决定先受理还是退回。",
"basis":[{"k":"<缺格的 PN>","v":"缺年用量(必填,须补)"},
{"k":"<缺格的 PN>","v":"缺制造商(建议追问,可先受理)"},
{"k":"<缺格的 PN>","v":"客户未给目标价(建议追问,不拦单)"}]}}
· steps 与 standard 是**作业指导书**,卡上照原样摆给人看 —— 这一段每一单都一样,照抄别重写;
**卡面是纯文本**:里面别写星号那类记号,它们不会变粗体,只会原样摆在人眼前(实测过)。
· ask 照当下情形写:**有必填缺**写「有几条缺 PN/年用量,须向客户问清后填回」;
**只缺建议档**写「有几条缺建议追问的格子(客户料号/品类/制造商/目标价),可先受理也可去问」;
**都齐**写「请核对确认」。三档的词照件上那三个词说,别混用。
· basis 逐条写**谁缺什么、是必填还是建议还是选填**;一格不缺就写这一单的要点
(几条料、客户点名的口径要求)。
· **卡上不摆表、不摆格子**(填写全在工作区):整份清单就在工作区那份件上,
每一格能就地改、能增删行,缺哪几格件顶上的红带点着名。别往卡里塞 blocks 或 fields ——
塞了是把同一张表摆两处,人不知道改哪一份算数。
§Z 结案:BD 在验收单上签字,系统把草稿合并成正式记录并发出 intake 口的载荷。`;
/**
@@ -261,7 +302,7 @@ S5 逐条定状况标 → S6 定去向与派单 → 提交放行。
export const INTAKE_STEPS = [
{
item: 'S1_customer',
intent: '按发件邮箱认客户;认不出就发补全卡请人建档,建完写进客户主数据',
intent: '按发件邮箱认客户;认不出就发数据录入卡请人建档,建完写进客户主数据',
writes: ['customer.profile', 'customer.contact', 'quote_intake.verdict'],
/* 认客户就靠这两张 —— 没声明的话它们会被整表段的截断刀砍掉(踩过) */
reads: ['customer.contact', 'customer.profile'],
@@ -320,7 +361,7 @@ export const INTAKE_STEPS = [
③ **记账两个数,引擎按它们收步**(别绕过去,绕不过去):
· customer_new_total —— 第①步认出来了记 0;认不出、要建档记 1;
· customer_asked_total —— 发过建档补全卡就记 1。
· customer_asked_total —— 发过建档卡就记 1。
**新客户没问过人,这一步收不了** —— 实测有过一次:模型从邮箱域名
编出 \`CUST-SZ-MACH\` 直接把档建了,人根本没被问过一句,而那个编码是错的。
客户编码是主数据的主键,编错一次后面每一单都跟着错。
@@ -406,14 +447,29 @@ export const INTAKE_STEPS = [
【一张卡别铺太长】缺口卡的 basis 与表格**只列真正缺的那几行**,不要把全部条目铺进去:
整份回复有长度上限,铺太长会在半途被截断 —— 截断的 JSON 解析不了,
这一拍作废、还会弹一张「需要你判断下一步」把人叫来(实测)。缺几条就写几条。
【两种分量 —— **必填只有两项,别的一个都不是**】
【三种分量 —— 与件上的空占位、执行卡的指导书**逐字同一套话**】
台账声明里怎么标,件上就是什么颜色,你说话就得用哪个词。三档只有这三个词:
· **必填(只有这两项)**:\`pn\`、\`eau\` 年用量 —— 缺了这一单没法报价,
**这是验收标准**,不齐不许放行,记进 missing_key_total。
· **选填(除上面两项之外的一切)**:客户料号、品类、制造商、**目标价**、交期、品质要求、备注……
缺了照样往下走,只当提醒,记进 missing_minor_total。
**目标价尤其常缺** —— 客户不给目标价是常态,不是缺陷:实测栽过一次,
一颗料只缺目标价却被记成必填缺、还派了执行卡去问客户,而按 F-01 它根本不该拦单。
拿不准某个字段算哪一档,就问自己一句:**它是不是 PN 或年用量?** 不是就是选填。
**这是验收标准**,不齐不许放行(件上标红「必填」),记进 missing_key_total。
· **建议(值得追问,但不拦单)**:\`customer_pn\` 客户料号 · \`category\` 品类 ·
\`brand\` 制造商 · \`target_price\` 目标价 —— 声明里标了 \`expect\`,件上标橘「建议」。
缺了照样往下走,但**那一问不能省**:执行卡上要写清「这几格建议追问」。
· **选填(其余一切)**:交期、品质要求、备注…… 件上标灰「选填」,缺了只当提醒。
建议档与选填档一并记进 missing_minor_total。
**别把「建议」说成「选填」**(2026-08-03 用户当场指出):同一件事,工单上标着「建议追问」,
交代里若换成另一档的叫法,人会以为这两处说的不是一回事 —— 而它们就是一回事。
**目标价客户没给就留空,不许估**:它是**客户的意向价**,不是我们算得出来的数。
S4 查到的历史价是**判 A/B/C 的依据**(距上次报价几天、行情稳不稳),
**不是客户的目标价** —— 拿它填进 \`target_price\` 就是替客户表了态,
下游定价、议价全建在这个假数上。也不许只在**交代里声称**估过价、垫过数
(实测栽过一次:台账留空是对的,嘴上却说按历史价估了 —— 而那一步连历史价都还没查,
纯属编造,人读了就以为表里躺着一个估出来的数)。留空、在件上标橘「建议」、
在执行卡上写明建议追问,这三件事合起来才是对的处置。
**目标价常缺是常态,不是缺陷** —— 实测栽过一次:一颗料只缺目标价却被记成必填缺、
还派了执行卡去问客户,而按 F-01 它根本不该拦单。
拿不准某个字段算哪一档,就问自己一句:**它是不是 PN 或年用量?** 不是就不许拦单。
【怎么拆】逐条写进 \`quote_intake.record\`,一条需求一行(同一 PN 不同数量档算两行)。
列名照台账字典写,别自己起名:
@@ -473,7 +529,7 @@ export const INTAKE_STEPS = [
* **不给 doneWhen** —— 这一步触达世界(把件交到人手上),给了就等于给引擎
* 一把替人签字的钥匙。而它的成果按定义也判不出来:「人看过、改过、认了」
* 不是台账里任何一个数能自己变出来的。人工激活也一并给上:
* 引擎不会替你按下这一步,你得先 start 再发卡。
* 引擎不会替你按下这一步,也不会替你收 —— 发卡就算启动,收要你自己 finish。
*/
guide: `拆好的九条摆在那儿了,**这一单现在要经过人的手**。
@@ -485,39 +541,15 @@ export const INTAKE_STEPS = [
人一眼没看过受理需求,下一次被叫到已经是终局验收 —— 用户当场说
「我都没确认需求 它就让我验收了」。
【怎么发】先 start 这一步(它是人工激活的),再照这个骨架发,别少字段:
{"kind":"human","itemId":"S3_confirm","workOrder":"execution","card":{
"title":"…","ask":"这是从来件与附件 BOM 归一出来的受理需求,请核对确认;哪一格不对就地改",
"steps":["型号 PN 与年用量 EAU 必须有 —— 缺一条这一单就走不到放行;客户没给的去问,问不到也要在备注里写清「客户未提供」",
"目标价尽量试探出来 —— 有它才谈得上「报得进不进得去」;客户不肯给,问一句「贵司这颗现在的成交价区间」也常有答案",
"抽错的就地改,多出来的行按行首 × 撤掉,漏掉的用表尾「加一行」补上;核完回这张卡提交"],
· **卡面是纯文本**:\`steps\` 与 \`standard\` 里**别写星号那类记号** —— 它们不会变成粗体,
只会原样摆在人眼前(实测过一次)。要强调就靠句子本身的次序与用词。
"standard":"必填(PN / 年用量)一条不缺;选填该问的都问过了 —— 问到的填上,问不到的留空并在备注里写明,放行时由你决定先受理还是退回。",
"basis":[{"k":"DAG2400L321504XZB01-R0","v":"缺年用量(必填,须补)"},
{"k":"UMW AO6604","v":"缺制造商(选填,可先受理)"}],
"blocks":[{"t":"table","title":"有效 PN 清单(可就地改)",
"ledger":"quote_intake.record","editable":true,
"cols":["PN","客户料号","品类","年用量","制造商","目标价","备注"],
"fields":["pn","customer_pn","category","eau","brand","target_price","remark"]}]}}
**\`steps\` 与 \`standard\` 是作业指导书** —— 卡上照原样摆给人看:要核什么、核到什么程度算完。
照上面抄即可(这一步每一单都一样,不必每次重写)。少了它,人拿到的只有
「请核对确认」四个字,不知道该核什么。
· \`ask\` 照当下情形写:**有必填缺**就写「有几条缺 PN/年用量,须向客户问清后填回」;
**只有选填缺**写「有几条缺选填项,可先受理也可去问」;**都齐**写「请核对确认」。
· **别自己抄行**:绑了 \`ledger\` + \`fields\`,基座发卡那一刻就把这一单**整份清单**
从草稿层铺进去 —— 人看到的是完整的九行,哪一格不对都能就地改,不只是缺的那一两行。
你抄一遍的下场实测过两次:回复有长度上限,铺长了半途截断、整拍作废;
抄的时候还把 BOM 原样的 30 填了进去(台账里已经是 30000)。
· \`fields\` 与 \`cols\` **一一对应**,列名照台账字典写。
· \`basis\` 逐条写清**谁缺什么、是必填还是选填** —— 分量不同,人心里要有数;
一格不缺就写这一单的要点(几条料、几条本轮不报、客户点名的口径要求)。
【怎么发】**卡的骨架在说明书里(「受理需求审定卡的骨架」那一段),逐字段照抄** ——
不管你在哪一拍发它,骨架都在说明书里等着,不要凭记忆拼一张裸卡。
这一步人工激活:发卡就算启动,不必单发 start;引擎也不会替你收 —— 人交回后要你自己 finish。
发完卡把 **asked_total 加 1**(记账用:它证明这一问没省)。
【人提交之后 —— **表里的值基座已经替你写回了**】
那张表绑着台账,人按下提交那一刻基座就把改动落进了草稿层(与数据录入卡同一条规矩:
该由人亲手落下的,不经你的手)。所以**别再 write 一遍那几行** ——
值没变的写入会被「写了等于没写」拒回(实测)。
【人提交之后 —— **件上的改动基座已经替你写回了**】
人在件上按「保存」那一刻,改动就落进了草稿层(与数据录入卡同一条规矩:
该由人亲手落下的,不经你的手);他按「提交」只是把这张卡交回来。
所以**别再 write 一遍那几行** —— 值没变的写入会被「写了等于没写」拒回(实测)。
你这一拍只做三件事,然后 finish:
① **把 missing_key_total / missing_minor_total 重算** —— 照台账里现在的值,
@@ -526,14 +558,14 @@ export const INTAKE_STEPS = [
③ answered_total 加 1(答案已经处理过了)。
漏掉①的下场实测过:值明明补上了,汇总列还写着「缺 1 条」,提交被判据一次次拦回来,
连拒五拍熔断 —— 而你只要把那个数改对就过去了。
回填时**按卡上那一列的 PN 对行**:卡上的 PN 就是台账里那一行的主键,
真要补写某一行时**按台账里那一行的 PN 对行**:PN 是主键,
换一个写法就是**新开一行**(实测:补写时把 PN 补全成 \`…-R0\`,于是同一颗料在表里成了两行,
条目数说 9 而表里躺着 10)。真发现第一次的 PN 抄错了,在 say 里说明并交给人,
别自己用新 PN 再写一行。
【**这张卡一单只发一次**】人交回来之后,不论还缺什么,**都不许再发同一张卡**。
必填(PN / 年用量)有提交闸兜着:缺一条他根本按不下提交,所以卡交回来时必填一定是齐的;
选填缺口不拦单,留着交放行的人定。
建议档的缺口不拦单,留着交放行的人定。
实测栽过多次(2026-08-02 那一单连撞四拍):从前这儿写着「人交回来还缺必填,就再发一张卡」——
而提交闸上线后那条路已经不存在了,模型照它办,每次都撞在
「这件事刚问过,人也答过了」上,白烧四拍。
@@ -542,7 +574,7 @@ export const INTAKE_STEPS = [
这时候要做的**不是再问一遍**(基座会当场拒:同一件事不许问第二遍),而是:
· confirmed_total 记 1、answered_total 记 1(人已经处理过了);
· 缺口数**照实留着**(缺就是缺,别改小);
· 这一步就算办完了 —— 选填缺不拦单,放行时由人定「先受理还是退回」。
· 这一步就算办完了 —— 建议档的缺口不拦单,放行时由人定「先受理还是退回」。
实测栽过一次:人留空交回,模型又发一张一模一样的卡,被拒;
转头把同一行原样再写一遍,又被「写了等于没写」拒;连拒五拍熔断,一单就这么停了。`,
},
@@ -664,19 +696,19 @@ A2 / A3 拿到之后会按自己的口径归一需要的数据。你替它们先
tools: [],
// 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 doneWhen)
guide: `line_total > 0、missing_key_total = 0、untyped_total = 0、两枚旗已定 ——
**先 start T_release**(它标了人工激活:放行会触达世界,必须按一下)。
可以走放行了(T_release 人工激活:动它就算启动,不必单发 start)。
**start 之后、submit 之前,把清单里**每一条**裁量判据(L3/L4)都裁掉**
(同一拍里排在 start 后面即可)。**一条都不能漏** —— 漏一条,submit 当场被拒,
**submit 之前,把清单里**每一条**裁量判据(L3/L4)都裁掉**。
**一条都不能漏** —— 漏一条,submit 当场被拒,
而拒回来的理由里就写着漏的是哪一条:照它原样抄 criterion 再裁一次即可,
**别原样重提同一个 submit**(实测连提七次,每次都被同一条拒回来)。
眼下**只剩一条**,照名字逐字抄(2026-08-02 起「吃不准降 B」那条已从清单里删掉,
别再裁它 —— 裁一个不存在的判据会被拒):
· judge criterion="选填缺口(客户料号/品类/制造商/目标价)已补齐,或由你放行时确认可以先受理" ——
**只看 \`quote_intake_record_gaps\` 这一个数**(基座现数的:还有几行缺选填;
· judge criterion="建议追问的四格(客户料号/品类/制造商/目标价)已补齐,或由你放行时确认可以先受理" ——
**只看 \`quote_intake_record_gaps\` 这一个数**(基座现数的:还有几行缺**建议追问**的格子;
\`..._gaps_keys\` 写着缺在哪几行的哪一格)。就两种写法,没有第三种:
· **= 0** → satisfied=true,rationale 写「选填已补齐」;
· **= 0** → satisfied=true,rationale 写「建议追问的那几格都补齐了」;
· **> 0** → satisfied=**false**,rationale 照 \`..._gaps_keys\` 逐条写清缺在哪儿
(例:「DAG2400L321504XZB01-R0 缺目标价」),交放行的人定「先受理还是退回」。
+13 -6
View File
@@ -73,7 +73,7 @@ export const manifest: ActivityManifest = {
identity: {
activityId: ACTIVITY_ID,
name: 'A1 · 询价受理',
packageVersion: '42.0.0',
packageVersion: '49.5.0',
category: '统一报价',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-quote-intake',
@@ -192,15 +192,23 @@ export const manifest: ActivityManifest = {
* 等于把一个可回放的判定换成一次不可回放的手感。
*/
ports: [
/*
* ── 这里**去掉了 `altRequests` / `rfqRequests` 两个载荷字段**(@43,2026-08-02)──
*
* `alt_request` / `rfq_request` 两张台账表同日已退休(见 declarations 那段墓志铭),
* 这两个键从那天起就是**空壳**:载荷装配对数组属性一律塞「本单全部落地行」
* (不加区分),于是它们的内容与 `records` 完全相同 —— 挂着一个名字叫
* 「替代评估请求」而里面装的是受理记录的键,比没有更糟:下游照名字用,用到的
* 是另一样东西。下游(A2/A3)自己按 `records` 里的状况标归一,契约照实说。
*/
{
name: 'to-alternative',
payload: {
type: 'object',
required: ['rfqRef', 'records', 'altRequests'],
required: ['rfqRef', 'records'],
properties: {
rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' },
records: { type: 'array', title: '询价受理记录(已打标)' },
altRequests: { type: 'array', title: '替代评估请求' },
},
},
description: '有 C 类要替代 —— 去选型',
@@ -211,11 +219,10 @@ export const manifest: ActivityManifest = {
name: 'to-sourcing',
payload: {
type: 'object',
required: ['rfqRef', 'records', 'rfqRequests'],
required: ['rfqRef', 'records'],
properties: {
rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' },
records: { type: 'array', title: '询价受理记录(已打标)' },
rfqRequests: { type: 'array', title: '询价请求' },
},
},
description: '无选型但有 B 类 —— 去寻源',
@@ -365,7 +372,7 @@ export const manifest: ActivityManifest = {
*/
{
key: 'optional-asked',
label: '选填缺口(客户料号/品类/制造商/目标价)已补齐,或由你放行时确认可以先受理',
label: '建议追问的四格(客户料号/品类/制造商/目标价)已补齐,或由你放行时确认可以先受理',
level: 'L4',
predicate: 'quote_intake_record_gaps = 0',
},
+21 -6
View File
@@ -55,13 +55,27 @@ import { PRD_CSS, PRD_SPRITE } from './prd-style.ts';
* 这里由基座把值消毒成 `v-*` 追加进 class(`data-class-of`)—— 判断在样式表里,不在填数里。
*/
const BIND_CSS = `
/* ① 空格子自己说话 —— 值真的空才轮到这两条(基座 data-blank) */
/* ① 空格子自己说话 —— 值真的空才轮到这三条(基座 data-blank)。
* 占位三级(2026-08-03 补齐,用户:「目标价是建议」):必填红 / 建议橘 / 选填灰,
* 档位跟台账声明走(required → req,expect → sug,其余 opt),颜色与 A2 同一套。 */
/* 胶囊与同一行的数字坐同一条线(2026-08-03 用户:「没有和数字对齐」)——
* 裸 inline-flex 的胶囊按 baseline 挂:盒子(10px 字 + 上下 3px 内距 + 边框 = 18px)比
* 字的基线深好几像素,视觉上整枚往下坠。治法:空格子自己撑起文字那份盒高(12px×1.5),
* 胶囊在里面垂直居中 —— 中线对中线,行高也不再忽高忽低。
* :not(.on) 不能省(2026-08-03 用户:「编辑态太拥挤」):这条比「编辑态藏起只读份」
* 的那条更特异,不圈住的话编辑态里胶囊会盖过 display:none 冒出来,54 个输入框旁边
* 各站一枚 —— 编辑态里格子该不该填由闸与红带说,胶囊只属于看的那份。 */
.proto-root .jc-tbl-in:not(.on) td.req > .jc-vcell:empty,
.proto-root .jc-tbl-in:not(.on) td.sug > .jc-vcell:empty,
.proto-root .jc-tbl-in:not(.on) td.opt > .jc-vcell:empty { display: flex; align-items: center; min-height: 18px; }
.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: #b45309; background: #fff7ed; border-color: #fcd9a8; }
.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); }
@@ -388,7 +402,7 @@ const listTable = (mode: 'draft' | 'final'): string => {
* 待处理那一屏可就地改;写的是**台账列名**,不是表头文字 ——
* 表头改个说法不该让写回错位。归档件不挂:那时它是件,不是表单。
*/
const cell = (key: string, head: string, kind: 'req' | 'opt', style = ''): string => {
const cell = (key: string, head: string, kind: 'req' | 'sug' | 'opt', style = ''): string => {
const cls = mode === 'final' ? 'dimempty' : kind;
return `<td class="${cls}" data-h="${head}"><span class="jc-vcell ${style}" data-slot="${key}" data-col="${key}" data-blank></span></td>`;
};
@@ -414,10 +428,10 @@ const listTable = (mode: 'draft' | 'final'): string => {
${del}
${mode === 'final' ? '<td class="jc-ty" data-h="分类"><span class="jc-tbadge" data-class-of="type_tag" data-slot="type_tag" data-col="type_tag"></span></td>' : ''}
${cell('pn', 'PN', 'req', 'mono')}
${cell('category', '品类', 'opt')}
${cell('category', '品类', 'sug')}
${cell('eau', '年用量', 'req', 'mono')}
${cell('brand', '制造商', 'opt')}
${cell('target_price', '目标价', 'opt', 'num')}
${cell('brand', '制造商', 'sug')}
${cell('target_price', '目标价', 'sug', 'num')}
${cell('remark', '备注', 'opt')}
</tr></tbody>
</table></div>`;
@@ -537,7 +551,8 @@ export const INTAKE_OVERVIEW = doc({
addRowBar('quote_intake.record') +
`<div class="jc-drop">${ico('info', 13)}
<span>带 * 的两列是<b>必填</b>(PN / 年用量):缺任一按 F-01 退回补全,不许放行;
其余列缺了标灰「选填」—— 先受理、后追问,<b>不为它们拦单</b>,但那一问不能省。
品类 / 制造商 / 目标价缺了标橘「<b>建议</b>」—— 值得追问、尽量补上,备注缺了标灰「选填」。
三档都<b>不为它们拦单</b>,先受理、后追问,但那一问不能省。
<b>原文没写的一律不补</b>:受理阶段编出来的数会一路流到定价和报价单上,越往后越难发现。</span>
</div>`,
foot: sign('AI 拟稿,你审定 —— 要改哪一格,在「待处理」那张单的需求表上就地改', '统一报价 · A1 受理归一'),
@@ -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('&gt;', '>').replaceAll('&lt;', '<').replaceAll('&amp;', '&');
}
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;
}
}
+395 -69
View File
@@ -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 &gt; 0</condition></ifPart>
<onPart id="op_cp" caseFileItemRef="composed_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_cp"><condition>line_total &gt; 0 and quote_negotiate_line_rows = line_total and composed_total &gt;= 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 &gt; 0 and quote_negotiate_line_rows &gt; 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 &gt; 0 and replied_total &gt; 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 &gt; 0 and quote_negotiate_line_rows &gt; 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'] },
];
+13 -44
View File
@@ -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' });
+453 -100
View File
@@ -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' },
+37 -23
View File
@@ -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>`;
+704 -173
View File
@@ -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">&gt; 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>&#12288;<span>本任务写下的报价函与议价结论一并落账;
你就地改过的成交价与客户原话随之落定。</span></div>
</div>
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>命中的那一口此刻才发射</b>&#12288;<span>报价函
<b data-slot="quote_negotiate_line_rows"></b> 条随载荷交给上面亮线指向的下游;
另两口本次不起单 —— 放行之前,世界上什么都没有发生。</span></div>
</div>
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>不成交也留资产</b>&#12288;<span>拒绝原因与竞对价位随载荷交给情报归档 ——
这一单丢了,下一单的三分类与利润模型会用上它。</span></div>
</div>
</div>`,
foot: sign('BD 议价放行 · AI 排版与定案留痕 —— 要改哪一格,在这张定案表上就地改', '统一报价 · A6 报价议价 → 定去向'),
});
@@ -0,0 +1,499 @@
/**
* A4 成本定价的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。
* 案例活动没有 `run`,自测不跑回合;测的是清单本身与四处对齐
* (判据谓词 / 出口载荷 / 剧本声明 / 两把尺的档位)。
*/
import { describe, expect, it } from 'vitest';
import { activityManifestSchema } from '@contracts';
import { evaluateCondition, evaluateUnaryTest } from '@sfeel';
import { createMockHost } from '@sdk';
import { P01_DMN, manifest } from '../src/manifest.ts';
import activity from '../src/index.ts';
describe('清单本身合规', () => {
it('过 @contracts 的 schema', () => {
expect(() => activityManifestSchema.parse(manifest)).not.toThrow();
});
it('监听时限已配 —— 无悬空监听', () => {
expect(manifest.declarations.listeners.every((l) => /^P/.test(l.deadline))).toBe(true);
});
it('L1/L2 判据都给了可机检的谓词 —— 验收等级不得谎报', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef, c.key).toBeDefined();
}
});
it('禁区短而硬,授权边界给了调人预算', () => {
expect(manifest.contract.prohibitions.length).toBeGreaterThan(0);
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(0);
});
it('每件必交付物都有台账地址', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(d.ledger, d.key).toBeDefined();
}
});
/*
* **宽进严出**:起单一个必填都不设 —— 上游(A3 的 quotation 口)发的是
* `{rfqRef, rows}`,而上一版必填 `rfqRef + customerCode + records`,
* 门口拦的恰是主路。归一在活动内部做。
*/
it('起单宽进:input.required 是空的,三种来路的键都声明着', () => {
expect(manifest.contract.input.data['required']).toEqual([]);
const properties = manifest.contract.input.data['properties'] as Record<string, unknown>;
for (const key of ['rfqRef', 'rows', 'records', 'subject', 'body']) {
expect(properties[key], `入参少了 ${key}`).toBeDefined();
}
});
/*
* **拟报单价是必填** —— 这是 A4 的门面判据:件上的缺价红带、执行卡的提交闸、
* 放行门 L1 三处同源(都数基座的 `_missing`)。声明丢了 required,三处一起失明。
*/
it('拟报单价标了 required —— 红带 / 提交闸 / 放行门三处同源的那个数才存在', () => {
const draft = manifest.declarations.ledger.find((d) => d.object === 'draft');
expect(draft?.fields.find((f) => f.name === 'quoted_price')?.required).toBe(true);
});
/*
* **建议价与拟报单价分两列是结构治,不是措辞治**(2026-08-03 单飞案例逼出来的):
* 只有一列价时,剧本写「算不出的留空」,模型照样给库里查不到的料编了价 ——
* 一条要它分辨「算得出/算不出」的规矩会走岔;「这一列你不写」没有分支可走岔。
*/
it('建议价与拟报单价是两列 —— AI 写建议价,人写拟报单价', () => {
const draft = manifest.declarations.ledger.find((d) => d.object === 'draft');
const suggested = draft?.fields.find((f) => f.name === 'suggested_price');
expect(suggested, '缺 suggested_price 这一列').toBeDefined();
expect(suggested?.required, '建议价不该是必填 —— 算不出就该空着').toBeUndefined();
for (const step of ['S1_price', 'S3_gate']) {
const guide = manifest.assets.businessModel.steps?.find((s) => s.item === step)?.guide ?? '';
expect(guide, `${step} 没写死「quoted_price 你一格都不写」`).toMatch(/quoted_price[^\n]*一格都不写|一格都不写[^\n]*quoted_price/);
}
});
it('门禁处置给了取值域 —— 三档条数由基座现数,不由活动自报', () => {
const draft = manifest.declarations.ledger.find((d) => d.object === 'draft');
expect(draft?.fields.find((f) => f.name === 'disposition')?.tally).toEqual(['PASS', 'NOTE', 'BLOCK']);
});
/*
* 我方写的注不标 `expect` —— 标了 `_gaps` 恒大于 0,读它的判据永远绿不了(A1 的账)。
* 客户目标价才是「客户该给而没给的」那一问。
*/
it('expect 只标客户该给的那一格,不标我方写的注', () => {
const draft = manifest.declarations.ledger.find((d) => d.object === 'draft');
expect(draft?.fields.find((f) => f.name === 'target_price')?.expect).toBe(true);
expect(draft?.fields.find((f) => f.name === 'price_note')?.expect).toBeUndefined();
});
it('三条常备案例形状真不同:寻源转来 / 客户压价超限 / 单飞无基准', () => {
expect(manifest.samples.length).toBe(3);
const [flowIn, overLimit, solo] = manifest.samples;
expect((flowIn?.input as { rows?: unknown[] }).rows?.length).toBeGreaterThan(0);
expect((overLimit?.input as { rows?: Array<Record<string, number>> }).rows?.[0]?.['target_price']).toBeLessThan(
(overLimit?.input as { rows?: Array<Record<string, number>> }).rows?.[0]?.['base_price'] ?? 0,
);
expect((solo?.input as { rows?: unknown[] }).rows).toBeUndefined();
});
});
describe('案例模型 —— 双表示同版本', () => {
const model = manifest.assets.businessModel;
const cmmn = parseCmmnLite(model.cmmnXml);
it('CMMN 与说明书同在,不允许分叉', () => {
expect(model.cmmnXml).toContain('<casePlanModel');
expect(model.narrative.length).toBeGreaterThan(400);
});
/* 照 MVP 示例工作流 §1.2 的 S1 取价基准 / S2 逐条定价 / S3 底价自检,一步一项 */
it('计划项照 SOP 一步一项:算建议价 + 交人定价 + 底价自检 + 超限拍板 + 放行', () => {
expect(items(model.cmmnXml)).toEqual(['S1_price', 'S2_review', 'S3_gate', 'T_approve', 'T_release']);
});
it('三处人的门都标了人工激活:BD 定价 · 超限拍板 · 草案放行(底价自检是算术,不标)', () => {
expect(cmmn.manualActivation).toEqual(['S2_review', 'T_approve', 'T_release']);
});
it('不用阶段 / 里程碑 / 裁量项', () => {
for (const tag of ['<stage', '<milestone', '<planningTable', '<discretionaryItem']) {
expect(model.cmmnXml, `不该出现 ${tag}`).not.toContain(tag);
}
});
/*
* **超限才叫人** —— 这个活动的要害:哨兵读基座按值现数的拦截档条数,
* 而不是活动自报的 `over_total`;且要等 BD 核过价(他自己回调掉的不必再惊动第二个人)。
*/
it('超限拍板的哨兵读基座现数,且等人核过价 —— 不是每条都叫人,也不是一条都不叫', () => {
const condition = sentryIf(model.cmmnXml, 's_over');
expect(condition).toContain('quote_pricing_draft_disposition_block > 0');
expect(condition).toContain('confirmed_total');
// 有拦截档且人核过价 → 叫人
expect(safeEval(condition, { quote_pricing_draft_disposition_block: 1, confirmed_total: 2 })).toBe(true);
// 拦截档清零 → 不叫人(这一步整个不入场)
expect(safeEval(condition, { quote_pricing_draft_disposition_block: 0, confirmed_total: 2 })).toBe(false);
// 人还没核价 → 先别叫(他自己回调掉的那几条不该再惊动一个人)
expect(safeEval(condition, { quote_pricing_draft_disposition_block: 1 })).toBe(false);
});
/*
* 入场哨兵也归「机检只读基座数」管(第四次违反就藏在哨兵里)——
* 上一版 `s_priced` 读活动自报的 `draft_total`:模型不记这个账,门就永不开。
*/
it('交人核价的入场哨兵读基座行数,不读自报的草案条数', () => {
const condition = sentryIf(model.cmmnXml, 's_priced');
expect(condition).toContain('quote_pricing_draft_rows');
expect(condition).not.toContain('draft_total');
expect(safeEval(condition, { line_total: 2, quote_pricing_draft_rows: 2, priced_total: 2 })).toBe(true);
// 行落了但算价那一轮还没走完(priced_total 没写过)→ 门不开
expect(safeEval(condition, { line_total: 2, quote_pricing_draft_rows: 2 })).toBe(false);
});
it('放行门哨兵带 ifPart:每行有价 + 处置守恒 + 拦截清零 + 定盘记过,全读基座数', () => {
const condition = sentryIf(model.cmmnXml, 's_ready');
expect(condition).toContain('quote_pricing_draft_missing = 0');
expect(condition).toContain('quote_pricing_draft_disposition_block = 0');
const done = {
route_decided_total: 1,
quote_pricing_draft_rows: 3,
quote_pricing_draft_missing: 0,
quote_pricing_draft_disposition_pass: 2,
quote_pricing_draft_disposition_note: 1,
quote_pricing_draft_disposition_block: 0,
};
expect(safeEval(condition, done)).toBe(true);
// 还有一行缺拟报价 → 门不开
expect(safeEval(condition, { ...done, quote_pricing_draft_missing: 1 })).toBe(false);
// 还有条目压在拦截档 → 门不开(哪怕守恒仍成立)
expect(
safeEval(condition, { ...done, quote_pricing_draft_note: 0, quote_pricing_draft_disposition_note: 0, quote_pricing_draft_disposition_block: 1 }),
).toBe(false);
// 处置没记齐(守恒不成立)→ 门不开
expect(safeEval(condition, { ...done, quote_pricing_draft_disposition_note: 0 })).toBe(false);
});
it('每个计划项都有剧本', () => {
const ids = items(model.cmmnXml);
expect((model.steps ?? []).map((s) => s.item)).toEqual(ids);
for (const step of model.steps ?? []) {
expect(step.guide.length, `${step.item} 的做法`).toBeGreaterThan(80);
expect(step.intent.length, `${step.item} 的意图`).toBeGreaterThan(6);
}
});
it('说明书不点名计划项', () => {
const named = [...model.narrative.matchAll(/\b(?:S\d+_[A-Za-z]+|T_[a-z]+)\b/g)].map((m) => m[0]);
const ids = new Set(items(model.cmmnXml));
for (const name of named) {
expect(ids.has(name), `说明书提到的 ${name} 不在案例模型里`).toBe(true);
}
});
it('说明书只讲整单的事:成品、怎么说话、怎么收口', () => {
for (const topic of ['开案', '要说话', '一拍连做', '结案']) {
expect(model.narrative, topic).toContain(topic);
}
});
});
describe('声明与契约对得上', () => {
const columns = new Set(
manifest.declarations.ledger.flatMap((d) => [`${d.domain}.${d.object}`, ...d.fields.map((f) => f.name)]),
);
it('必交付物的台账地址都声明过', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(columns.has(d.ledger ?? ''), `${d.key} → ${d.ledger}`).toBe(true);
}
});
it('editableAtRelease 的列都在成果表的声明里', () => {
const draft = manifest.declarations.ledger.find((d) => d.object === 'draft');
const names = new Set((draft?.fields ?? []).map((f) => f.name));
for (const d of manifest.contract.requiredDeliverables) {
for (const col of d.editableAtRelease ?? []) {
expect(names.has(col), `editableAtRelease 里的 ${col}`).toBe(true);
}
}
});
const rowReadings = new Set([
...manifest.declarations.ledger.map((d) => `${d.domain}_${d.object}_rows`),
...manifest.declarations.ledger
.filter((d) => d.fields.some((f) => f.required === true))
.map((d) => `${d.domain}_${d.object}_missing`),
...manifest.declarations.ledger.flatMap((d) =>
d.fields.flatMap((f) =>
(f.tally ?? []).map((v) => `${d.domain}_${d.object}_${f.name}_${v.toLowerCase().replace(/[^a-z0-9_]/g, '')}`),
),
),
]);
it('L1/L2 谓词读的名字都有承载(台账列或基座读数)', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level !== 'L1' && c.level !== 'L2') continue;
for (const identifier of (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? []) {
if (['and', 'or', 'not', 'true', 'false', 'null', 'in', 'between'].includes(identifier)) continue;
if (rowReadings.has(identifier)) continue;
expect(columns.has(identifier), `${c.key} 的谓词引用了 ${identifier}`).toBe(true);
}
}
});
/*
* **机检必须至少有一边是基座数**(违反史已记四次,每次都是同一个死法:
* 那个数要模型自己重算,漏算/改小一次门就废了)。
*/
it('每条 L1/L2 都至少读一个基座读数 —— 不许两边都由活动自报', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level !== 'L1' && c.level !== 'L2') continue;
const names = (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? [];
expect(names.some((n) => rowReadings.has(n)), `${c.key} 一个基座读数都没读`).toBe(true);
}
});
it('出口载荷的标量字段都有台账列承载', () => {
const snake = (name: string) => name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
for (const port of manifest.contract.output.mandatory) {
const properties = (port.payload as { properties?: Record<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)。
* 出参就是台账 `disposition` 的取值:模型算完照抄,不必再翻译一道。
*/
describe('利润模型 P-01 —— 客户等级 × 类别 → 毛利档', () => {
const table = P01_DMN.tables.find((t) => t.id === 'p01-margin');
it('战略客户 + A 类 → 8%~10%(量换价那一档)', () => {
expect(decideBy(table, { customer_grade: '战略客户', type_tag: 'A' })).toMatchObject({ outputs: [0.08, 0.1] });
});
it('普通客户 + A 类 → 12%~15%', () => {
expect(decideBy(table, { customer_grade: '普通客户', type_tag: 'A' })).toMatchObject({ rule: 'p01-2' });
});
it('B 类不看客户等级 → 15%~20%(战略客户的 B 类也走这一档)', () => {
expect(decideBy(table, { customer_grade: '战略客户', type_tag: 'B' })).toMatchObject({ outputs: [0.15, 0.2] });
});
it('C 类替代/停产件 → 20%~50% 一事一议', () => {
expect(decideBy(table, { customer_grade: '普通客户', type_tag: 'C' })).toMatchObject({ rule: 'p01-4' });
});
it('等级与类别都认不出 → 兜底按普通客户标准件档,不是拒绝求值', () => {
expect(decideBy(table, {})).toMatchObject({ rule: 'p01-default', outputs: [0.12, 0.15] });
});
});
describe('底价门禁 P-02 —— 偏离底价线两成即止', () => {
const table = P01_DMN.tables.find((t) => t.id === 'p02-floor');
it('在底价线之上 → PASS 直接放行', () => {
expect(decideBy(table, { deviation: 0.12 })).toMatchObject({ outputs: ['PASS', true] });
expect(decideBy(table, { deviation: 0 })).toMatchObject({ outputs: ['PASS', true] });
});
it('低于底价线两成以内 → NOTE 附理由放行', () => {
expect(decideBy(table, { deviation: -0.08 })).toMatchObject({ outputs: ['NOTE', true] });
});
it('低于两成以上 → BLOCK 拦截,人拍板', () => {
expect(decideBy(table, { deviation: -0.25 })).toMatchObject({ outputs: ['BLOCK', false] });
});
it('三个处置码与台账取值域逐字一致 —— 模型照抄即可写回', () => {
const tally = manifest.declarations.ledger
.find((d) => d.object === 'draft')
?.fields.find((f) => f.name === 'disposition')?.tally;
expect(new Set(table?.rules.map((r) => String(r.then[0])))).toEqual(new Set(tally));
});
});
describe('两把尺的档位三处同一份 —— 判定表 / 剧本 / 工作单', () => {
const guide = manifest.assets.businessModel.steps?.find((s) => s.item === 'S1_price')?.guide ?? '';
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'pricing-worksheet')?.html ?? '';
it('毛利四档三处都写着同样的数', () => {
for (const band of ['8%~10%', '12%~15%', '15%~20%', '20%~50%']) {
expect(guide, `剧本少了 ${band}`).toContain(band);
expect(sheet, `工作单少了 ${band}`).toContain(band);
}
});
/* 门禁在人定完价之后才算得出来,所以三个码写死在底价自检那一步的 guide 里 */
it('三个处置码剧本里写死(大写,一个字母不许差)', () => {
const gate = manifest.assets.businessModel.steps?.find((s) => s.item === 'S3_gate')?.guide ?? '';
for (const code of ['PASS', 'NOTE', 'BLOCK']) expect(gate).toContain(code);
});
});
describe('报告模版 —— 活动持有,容器无脚本执行权', () => {
it('两个局面各一张:定价工作单(干活)与报价草案清单(收口)', () => {
expect(manifest.assets.viewTemplates.map((t) => t.name)).toEqual(['pricing-worksheet', 'pricing-roster']);
});
it('剧本每一步的 view 都在模版名单里 —— 换不到画面屏上就不动', () => {
const names = new Set(manifest.assets.viewTemplates.map((t) => t.name));
for (const step of manifest.assets.businessModel.steps ?? []) {
expect(names.has(step.view ?? ''), `${step.item} 的 view=${step.view}`).toBe(true);
}
});
it('放行那一步摆的是验收目标件 —— 最后摆上去的那张就是归档件', () => {
const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
expect(release?.view).toBe('pricing-roster');
});
it('模版里没有脚本、没有事件处理器、没有外链 —— 样式自带', () => {
for (const template of manifest.assets.viewTemplates) {
expect(template.html, template.name).not.toMatch(/<script|\son[a-z]+=|https?:\/\//i);
expect(template.html, template.name).toContain('<style>');
}
});
it('定价工作单是干活的件:逐颗档案 + 两把尺 + 填回工作表', () => {
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'pricing-worksheet')?.html ?? '';
expect(sheet).toContain('jc-selcard');
expect(sheet).toContain('底价线');
expect(sheet).toContain('利润模型 P-01');
expect(sheet).toContain('底价门禁 P-02');
expect(sheet).toContain('data-edit="quote_pricing.draft"');
});
it('缺价红带与提交闸同源 —— 件上摆的是基座的 `_missing` 与主键名单', () => {
for (const name of ['pricing-worksheet', 'pricing-roster']) {
const html = manifest.assets.viewTemplates.find((t) => t.name === name)?.html ?? '';
expect(html, name).toContain('quote_pricing_draft_missing');
expect(html, name).toContain('quote_pricing_draft_missing_keys');
}
});
/*
* 编辑面精确等于 BD 的职权面(定价与让价理由)+ **行的钥匙 pn**:
* 主键格不挂 `data-col`,服务端按「全表一致列」补键,两行会对到同一行上
* (A2 浏览器走查逮过,e2e 走 API 自带主键测不出来)。
*/
it('编辑面 = 拟报单价 + 让价理由 + 行的钥匙 pn', () => {
for (const name of ['pricing-worksheet', 'pricing-roster']) {
const html = manifest.assets.viewTemplates.find((t) => t.name === name)?.html ?? '';
const cols = [...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]);
expect(new Set(cols), name).toEqual(new Set(['pn', 'quoted_price', 'price_note']));
}
});
it('门禁分布读的是基座按值现数的三段 —— 守恒等式的可视形', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'pricing-roster')?.html ?? '';
for (const code of ['pass', 'note', 'block']) {
expect(roster).toContain(`quote_pricing_draft_disposition_${code}`);
}
});
/*
* A4 只有一个出口(E8 无条件边),**没有去向要判** —— 件上不摆判定表,
* 也不许残留 `case:route*` 的空壳(没有选择组它永远是空的,一屏灰行像判定没跑)。
*/
it('单出口活动:没有选择组,件上也不摆去向判定', () => {
expect(manifest.contract.output.selective).toEqual([]);
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'pricing-roster')?.html ?? '';
expect(roster).not.toContain('case:routeKey');
});
it('模版只做取值 —— data-class-of 不以 data-slot 为前提(纯色块曾整条静默失色)', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'pricing-roster')?.html ?? '';
expect(roster).toContain('<i data-class-of="disposition"></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-pricing',
version: '1.0.0',
data: { rfqRef: 'RFQ MD14.1', rows: [] },
message: undefined,
outputPorts: { mandatory: ['draft'], selective: [] },
prohibitions: [],
discretion: [],
} as const;
describe('代码入口 —— 案例活动没有 run,不是残包', () => {
it('默认导出是一个有名字的活动定义', () => {
expect(activity.name).toBe('quote-pricing');
});
it('mock 宿主如实报「没有 run」,不静默返回一个假的 outcome', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.run(activity)).rejects.toThrow(/没有 run/);
});
});
/** 按 DMN 语义逐行求值:FIRST 命中即定,单元格谓词复用 @sfeel */
function decideBy(
table: (typeof P01_DMN)['tables'][number] | undefined,
scope: Record<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('&gt;', '>').replaceAll('&lt;', '<').replaceAll('&amp;', '&');
}
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
@@ -0,0 +1,248 @@
/**
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(A1 的账)。
*/
import { describe, expect, it } from 'vitest';
import { evaluateCondition } from '@sfeel';
import { CMMN_XML, PRICING_PHASES, PRICING_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 PRICING_STEPS) {
expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
}
});
it('图上每一步都有剧本', () => {
for (const id of ITEM_IDS) {
expect(PRICING_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
}
});
it('清单里挂的就是这份剧本(不是另抄一份)', () => {
expect(manifest.assets.businessModel.steps).toBe(PRICING_STEPS);
});
});
describe('doneWhen 是「成果已存在」的判据,不是「该不该做」的判据', () => {
/** 开案那一刻的三种局面:寻源转来 / 受理直达 / 单飞 —— 台账一行没有 */
const FRESH_SOURCED: Record<string, unknown> = {
rfqRef: 'RFQ MD14.1',
rows: [{ pn: 'BBPY00201209601Y00', type_tag: 'B', base_price: 0.058 }],
};
const FRESH_INTAKE: Record<string, unknown> = {
rfqRef: 'RFQ-2026-0555',
records: [{ pn: 'PESD2ETH-XR', type_tag: 'B' }],
};
const FRESH_SOLO: Record<string, unknown> = { subject: '两颗料报个价', body: 'XCJ-7788T 8K……' };
it('**开局一步都不该被自动收掉** —— 三种开局都要 false', () => {
for (const fresh of [FRESH_SOURCED, FRESH_INTAKE, FRESH_SOLO]) {
for (const step of PRICING_STEPS) {
if (step.doneWhen === undefined) continue;
expect(
safeEval(step.doneWhen, fresh),
`${step.item} 的 doneWhen「${step.doneWhen}」开局就为真`,
).toBe(false);
}
}
});
/* ── 算价那一步 ─────────────────────────────────────────────── */
const priced = (over: Record<string, unknown>): Record<string, unknown> => ({
line_total: 2,
quote_pricing_draft_rows: 2,
priced_total: 2,
...over,
});
it('整份落表、行数对上、算价那一轮走完 → 收得掉', () => {
expect(safeEval(doneOf('S1_price'), priced({}))).toBe(true);
});
it('**全是无基准的单子也收得掉** —— 一条都算不出时 priced_total 是写过的 0', () => {
expect(safeEval(doneOf('S1_price'), priced({ priced_total: 0 }))).toBe(true);
});
it('**第一拍写完行就收步是不许的** —— priced_total 没写过,未绑定的名字比大小为假', () => {
const scope = priced({});
delete scope['priced_total'];
expect(safeEval(doneOf('S1_price'), scope)).toBe(false);
});
it('行数两边对不上 → 收不掉(漏落一行当场现形,不等到第 10 拍)', () => {
expect(safeEval(doneOf('S1_price'), priced({ quote_pricing_draft_rows: 1 }))).toBe(false);
});
/* ── 三处不给 doneWhen ──────────────────────────────────────── */
it('**交人定价、超限拍板、放行三项都不给 doneWhen** —— 给了等于给引擎一把替人签字的钥匙', () => {
for (const item of ['S2_review', 'T_approve', 'T_release']) {
expect(PRICING_STEPS.find((s) => s.item === item)?.doneWhen, `${item} 不该有 doneWhen`).toBeUndefined();
}
});
/* ── 底价自检:判完了才算办完(收步判据与验收 L2 同源)────────────── */
const gated = (over: Record<string, unknown>): Record<string, unknown> => ({
quote_pricing_draft_rows: 3,
quote_pricing_draft_disposition_pass: 2,
quote_pricing_draft_disposition_note: 1,
quote_pricing_draft_disposition_block: 0,
...over,
});
it('三档处置的条数加起来等于行数 → 底价自检收得掉', () => {
expect(safeEval(doneOf('S3_gate'), gated({}))).toBe(true);
});
it('**有拦截档也照样收得掉** —— 判出来了就是判完了,拦截档由下一步叫人拍板', () => {
expect(
safeEval(doneOf('S3_gate'), gated({ quote_pricing_draft_disposition_note: 0, quote_pricing_draft_disposition_block: 1 })),
).toBe(true);
});
it('还有行没判 → 收不掉(松出来的那一截就是放行门前的返工)', () => {
expect(safeEval(doneOf('S3_gate'), gated({ quote_pricing_draft_disposition_note: 0 }))).toBe(false);
});
it('**一行都还没写时 0+0+0 = 0 不算判完** —— 全零相等那一坑,靠 rows > 0 挡住', () => {
expect(
safeEval(doneOf('S3_gate'), {
quote_pricing_draft_rows: 0,
quote_pricing_draft_disposition_pass: 0,
quote_pricing_draft_disposition_note: 0,
quote_pricing_draft_disposition_block: 0,
}),
).toBe(false);
});
it('归一那一步声明了 reads: rows 与 records —— 上游载荷置前不截断', () => {
const reads = PRICING_STEPS.find((s) => s.item === 'S1_price')?.reads ?? [];
expect(reads).toContain('rows');
expect(reads).toContain('records');
});
/*
* **一单同一张卡只发一次**,且缺信息不再走补全卡(2026-08-02 全局退役)——
* 剧本里不许残留「还缺就再发一张卡」这条已经不存在的路。
*/
it('剧本不教补全卡,也不留「还缺就再发一张」的死路', () => {
for (const step of PRICING_STEPS) {
expect(step.guide, `${step.item} 的 guide 提到了补全卡`).not.toMatch(/补全卡|"kind":"ask"/);
expect(step.guide, `${step.item} 的 guide 教了再发一张卡`).not.toMatch(/再发一张卡|重新发一张卡/);
}
});
/*
* **一单只发一张卡**:人点完之后这一步会被重新念一遍,而 guide 的头一段说什么,
* 模型就先做什么 —— 头一段若是「发一张决策卡」,它就再发一张(实测连发四张,
* 调人预算烧光 exhausted)。所以拍板那一步的 guide 必须**先分岔再发卡**。
*/
it('拍板那一步先问「人点过没有」,再谈发卡 —— 分岔要排在发卡骨架前面', () => {
const guide = PRICING_STEPS.find((s) => s.item === 'T_approve')?.guide ?? '';
const branch = guide.indexOf('已经有');
const skeleton = guide.indexOf('【发一张决策卡');
expect(branch, 'T_approve 的 guide 没有「人点过没有」这一岔').toBeGreaterThanOrEqual(0);
expect(skeleton, 'T_approve 的 guide 没有发卡骨架').toBeGreaterThan(branch);
expect(guide).toMatch(/不许再发|别再发第二张卡/);
});
/*
* start 墙已拆(2026-08-02):动它就算启动。剧本里再教 start 只是白烧 token,
* 而且学不稳的礼仪就是死法。
*/
it('剧本不教 start 礼仪 —— 那道墙已经拆了(说「不必单发 start」可以,教它先按一下不行)', () => {
for (const step of PRICING_STEPS) {
expect(step.guide, `${step.item} 的 guide 还在教 start`).not.toMatch(/先\s*start|务必\s*start|要先发\s*start/);
}
});
});
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 PRICING_STEPS) {
for (const address of step.writes) {
expect(declaredLedger, `${step.item} 要写 ${address}`).toContain(address);
}
}
});
it('每一步要调的工具都在清单的工具声明里', () => {
for (const step of PRICING_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 PRICING_STEPS) {
expect(step.view, `${step.item} 没定画面`).toBeDefined();
expect(names.has(step.view ?? ''), `${step.item} 的 view「${step.view}」不在模版名单里`).toBe(true);
}
});
it('每一步都说得出它交出什么', () => {
for (const step of PRICING_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 PRICING_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(PRICING_PHASES.flatMap((p) => p.items));
it('**除放行外每一项恰好属于一个阶段**', () => {
const planned = PRICING_STEPS.map((s) => s.item).filter((id) => id !== 'T_release');
expect(planned.filter((id) => !inPhase.has(id))).toEqual([]);
});
it('一项不进两个阶段', () => {
const all = PRICING_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(PRICING_STEPS.map((s) => s.item));
expect([...inPhase].filter((id) => !ids.has(id))).toEqual([]);
});
});
function doneOf(item: string): string {
const found = PRICING_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;
}
}
+330 -80
View File
@@ -1,137 +1,387 @@
/**
* A4 成本定价的案例模型 —— **算价归判定表,超限那一下归人**(2026-07-31 照 A1 的状态机模式重做)。
* A4 成本定价的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
*
* 与 A2/A3 的分工:那两个是「一次专家判断」,派一张执行单收回结论就够了;
* 定价不同 —— 它**有确定性规则可依**(P-01 利润模型、P-02 底价门禁两支 DMN),
* 算价这件事该算不该问。所以它照 A1 的样子分步建模,而不是甩一张单给人。
* 业务出处:`docs/MVP示例工作流.html` §1.2(S1 取价基准 → S2 逐条定价 → S3 底价自检),
* 按 A3 同一条形状建模(2026-08-03 重写,@3.0.0):
*
* 三项:
* T_price —— 逐条算价并落草案(拟报单价 / 毛利 / 偏离度 / 门禁处置);
* T_approve —— **只在门禁判超限时入场**:触达客户利益的一下,人工激活,派决策卡;
* T_release —— 放行(人工激活)。
* 上游(A3 的目标报价清单 / A2 的询价受理清单 / A1 直达的受理记录)进来 → 归一成
* **报价草案**底表,逐条按 P-01 利润模型算出底价线与拟报单价、过 P-02 底价门禁 →
* **一张执行单**交 BD 核价(拟报单价可校可改,无基准的那几行由他定价)→
* **只有压在拦截档上的那几条才叫人拍板**(决策卡)→ 放行,整份报价草案交定价审批。
*
* 「超限才叫人」是这个活动的要害:每条都叫人,人就成了橡皮图章;一条都不叫,
* 底价门禁就是摆设。所以那一项挂前提(`s_over`),由判定表的结论决定它入不入场。
* 上一版(@2.0.0)三处不合现行口径,这一版整个置换:
* ① 入参必填 `rfqRef + customerCode + records` —— 而 A3 发的是 `{rfqRef, rows}`,
* 门口拦的恰是主路(现在宽进到底,归一在活动内做);
* ② 台账一个 `required` 都没有 —— 执行卡的闸没牙,人不填也能交卷;
* ③ 入场哨兵 `s_priced` 读活动自报的 `draft_total`(「机检只读基座数」第 5 类违反)——
* 模型不记这个账,门就永不开,而它一步也没做错。
*/
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
<case id="pricing">
<!--
案卷项的 id **就是任务视图里的键名**:
draft_total 是草案条数(算完没算完),over_total 是超限条数(要不要叫人)。
两个都由 T_price 写进台账,摊平到顶层后驱动前提。
案卷项的 id **就是任务视图里的键名**。上游载荷(rows / records)与本活动台账列
都摊平到任务视图顶层,照它们命名;quote_pricing_* 那几个是**基座读数**
(行数 / 缺必填 / 按值现数),由基座现算,模型碰不着。
-->
<caseFileModel>
<caseFileItem id="pn" name="型号(开案即有)"/>
<caseFileItem id="draft_total" name="草案条数"/>
<caseFileItem id="over_total" name="超限条数"/>
<caseFileItem id="rows" name="上游清单行(寻源/选型转来,流内开案即有)"/>
<caseFileItem id="records" name="上游受理记录(受理直达时有)"/>
<caseFileItem id="line_total" name="草案条目数(自报)"/>
<caseFileItem id="priced_total" name="已算出拟报价的条数"/>
<caseFileItem id="confirmed_total" name="BD 已核价条数(人环走完的凭据)"/>
<caseFileItem id="route_decided_total" name="已定盘"/>
<caseFileItem id="quote_pricing_draft_rows" name="草案行数(基座现数)"/>
<caseFileItem id="quote_pricing_draft_missing" name="缺拟报单价的行数(基座现数)"/>
<caseFileItem id="quote_pricing_draft_disposition_block" name="压在拦截档的条数(基座现数)"/>
</caseFileModel>
<casePlanModel id="plan" name="成本定价" autoComplete="true">
<!--
① 算完了 —— 交 BD 核价那一步的入场前提。
行数两边对上(自报 = 基座现数),且算价那一轮走完(priced_total 是算完之后
显式写下的数;「大于等于 0」对「写过的 0」为真、对「没写过」为假)。
-->
<sentry id="s_priced">
<onPart id="op_pr" caseFileItemRef="draft_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_pr"><condition>draft_total &gt; 0</condition></ifPart>
<onPart id="op_pr" caseFileItemRef="priced_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_pr"><condition>line_total &gt; 0 and quote_pricing_draft_rows = line_total and priced_total &gt;= 0</condition></ifPart>
</sentry>
<!-- 超限才叫人:门禁判出有超限条目,这一项才入场 -->
<!--
② 人定完价了 —— 底价自检那一步的入场前提。
confirmed_total 只在人交回执行单之后才产生,它就是「经过人了」的凭据。
-->
<sentry id="s_confirmed">
<onPart id="op_cf" caseFileItemRef="confirmed_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_cf"><condition>confirmed_total &gt; 0</condition></ifPart>
</sentry>
<!--
③ **超限才叫人** —— 这个活动的要害。
「压在拦截档上的条数」由基座按 disposition 的取值域现数(不是活动自报),
且要等 BD 核过价(confirmed_total 只在人交回执行单之后才产生)——
他自己回调掉的那几条不必再惊动第二个人。
-->
<sentry id="s_over">
<onPart id="op_ov" caseFileItemRef="over_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_ov"><condition>over_total &gt; 0</condition></ifPart>
<onPart id="op_ov" caseFileItemRef="quote_pricing_draft_disposition_block"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_ov"><condition>quote_pricing_draft_disposition_block &gt; 0 and confirmed_total &gt; 0</condition></ifPart>
</sentry>
<!--
── 放行门的入场条件:**认「办完了没有」,且读基座数**────────────────
· 每行都有拟报单价(quote_pricing_draft_missing 由基座按 required 现数,
与执行卡的提交闸、验收 L1 同一个数同一套算法);
· 门禁处置逐条落定且守恒(三档相加 = 行数,两边都是基座按值现数);
· **没有还压在拦截档上的条目**(要么回调、要么经人拍板改记附理由放行);
· 定盘过了(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 &gt; 0 and quote_pricing_draft_rows &gt; 0 and quote_pricing_draft_missing = 0 and quote_pricing_draft_disposition_block = 0 and quote_pricing_draft_disposition_pass + quote_pricing_draft_disposition_note + quote_pricing_draft_disposition_block = quote_pricing_draft_rows</condition></ifPart>
</sentry>
<!-- 算价:判定表说了算,不是临场判 -->
<humanTask id="T_price" name="逐条算价:取基准、算毛利与偏离度、过底价门禁">
<itemControl><repetitionRule id="r_pr"/></itemControl>
<!-- ① 归一草案 + 取基准 + 逐条算价过门禁(能算的不问人) -->
<humanTask id="S1_price" name="归一报价草案:取成本基准、按利润模型算底价线与建议价">
<itemControl><repetitionRule id="r_s1"/></itemControl>
</humanTask>
<!-- 超限审批:触达客户利益的一下,必须人工激活 -->
<humanTask id="T_approve" name="超限条目决策:低于底价的那几条要人拍板">
<!--
② 交 BD 核价 —— **一张执行单**(拟报价可校可改,无基准的行由他定价)。
manualActivation + 不给 doneWhen(发卡触达真人,引擎不替人按)。
-->
<humanTask id="S2_review" name="交BD定价:工作单上逐条定价,收回后过底价门禁">
<entryCriterion id="ec_s2" sentryRef="s_priced"/>
<itemControl><manualActivationRule id="m_s2"/></itemControl>
</humanTask>
<!--
③ 底价自检 —— SOP 里的 S3,一件独立的事:按人定的价算毛利、偏离度、门禁处置。
它不触达世界,所以给 doneWhen(三档处置的条数加起来等于行数就算判完了),
引擎自己收 —— 从前这一段藏在「交人核价」的收尾里,模型写完 confirmed_total
就把那一项收了,三列处置一格没写,放行门当场判「处置没落定」,而拒回只会说
「数不对/行不对」—— 两扇打不开的门(实测 8 次拒回熔断)。
-->
<humanTask id="S3_gate" name="底价自检:按定下的价算毛利与偏离度,逐条过底价门禁">
<entryCriterion id="ec_s3" sentryRef="s_confirmed"/>
<itemControl><repetitionRule id="r_s3"/></itemControl>
</humanTask>
<!-- ④ 超限拍板:触达客户利益的那一下,必须人工激活;只在拦截档还有条目时入场 -->
<humanTask id="T_approve" name="超限拍板:压在拦截档的条目要人定怎么办">
<entryCriterion id="ec_ap" sentryRef="s_over"/>
<itemControl><manualActivationRule id="m_ap"/><repetitionRule id="r_ap"/></itemControl>
</humanTask>
<!-- 放行:触达世界,必须人工激活 -->
<!-- 草案放行:终局唯放行 —— 触达世界,必须人工激活 -->
<humanTask id="T_release" name="报价草案验收放行">
<entryCriterion id="ec_rel" sentryRef="s_priced"/>
<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 开案:**一批料要定出各自的拟报单价**。来路三种,形状都收(宽进):
· **寻源转来**:任务数据里有 \`rows\` 数组 —— 目标报价清单的落地行,每行带底价(base_price)
与价源;这是最全的一种来路;
· **受理/选型直达**:有 \`records\` 或 \`rows\` 而行上没有底价 —— 只有料号与年用量;
· **单飞**:只有一段需求文字,列了几颗料要报价。
**能算的不问人**:P-01(利润模型)与 P-02(底价门禁)两支判定表说了算,你不临场拍价。
你的活是把判据取齐、把结论落进台账;**只有超限的那几条才叫人**。
**成品只有一样**:一份**报价草案** —— 整份行都在、**每一行都有拟报单价**,
而且每个价都说得出来路:基准从哪来、毛利档是哪一档、偏离底价线多少、门禁怎么判。
人签的不是一个数字,是**这个数字的来路**。它是定价审批的直接输入。
【T_price 逐条算价】基准价取自历史价情报(A3 的比价结论 / CRM 历史价)。新料号无历史时
基准记 0,由底价门禁按「无基准」处置 —— **不凭空编一个偏离率出来**。
逐条写进 \`quote_pricing.draft\`:拟报单价 / 目标毛利 / 相对基准偏离 / 门禁处置。
同一拍接着把 draft_total(条数)与 over_total(门禁判「超限」的条数)写上 ——
这两个数一个是完成判据,一个决定要不要叫人。写完 finish。
**能算的不问人,该人签的不代签**:P-01(利润模型)给毛利档、P-02(底价门禁)给处置,
两支判定表说了算,你不临场拍价;而**拟报单价那一列是 BD 定的,你一格都不写** ——
你给的是**建议价**,他确认或改成自己的数。你的活是把判据取齐、把结论落进台账。
【T_approve 超限决策】**只在 over_total > 0 时走这一步**。它触达客户利益,
所以标了人工激活:**先 start,再发决策卡**(decide,itemId 填 T_approve)。
选项按门禁的处置给(照原价报 / 按底价报 / 退回重算),consequence 写清各自的后果;
basis 摆上是哪几条、偏离多少、底价是多少(裸卡基座拒发)。
人点完,轨迹上会出现「decision 卡回传 —— 人选了:xxx」—— **看到它就照那条路把 draft 改掉**,
别再发第二张卡。改完 finish。
**四步走**:归一取基准算建议价 → 交 BD 定价 → 底价自检(按他定的价算毛利、偏离度、
门禁处置)→ 定盘放行。
【提交放行】草案有行了 —— **先 start T_release**(人工激活),然后 submit,BD 验收。
提交前把两条裁量判据裁掉(judge,criterion 照判据名原样写);终局唯放行,你不能自己宣布完成。
**这一单要经过人一到两次**:BD 在定价工作单上逐条定价(那张单绑着整份草案,
拟报单价缺一行按不下提交 —— 基座的闸看着,他填的格子直接落账);
**只有还压在拦截档上的条目才另外叫人拍板**(一张决策卡)。每条都叫人,人就成了橡皮图章;
一条都不叫,底价门禁就是摆设。最后 BD 放行签字。
【一路上要说话】每一拍带一句 say,说**业务的话**:这批料算下来什么价、哪几条压线、
要不要找人拍板。一两句、口语。别报「写草稿 N 行」这种系统词。`;
【一路上要说话】负责人正看着这一单。**每一拍都带一句 say** —— 说业务的话:
这批料算下来什么价、哪几条压线、要不要找人拍板;不说系统词。
到了要找人的那一拍,先说清这一轮办完了什么、为什么现在需要他。
【怎么把一项干完 · 一拍连做】write 和 tool 都不会自动收步 —— 做完用 finish 宣告。
动作排进同一拍的 actions 串一口气给;只有调工具(出参下一拍才看得见)与
发卡 / 提交(挂起)必须停拍。**这一单常路 7-9 拍就该收口**。
§Z 结案:BD 在验收单上签字,系统把草稿合并成正式草案并发出 draft 口的载荷 ——
整份报价草案交给定价审批。`;
/**
* 步骤剧本 —— 与 A1 同一套:`doneWhen` 为真,引擎自己收步、**不问模型**。
* 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。
*
* 两处不给 `doneWhen`:`T_approve`(人工激活,要人拍板)与 `T_release`(触达世界)。
* 给了就等于给引擎一把替人签字的钥匙。
* 三处不给 `doneWhen`:`S2_review`(发卡触达真人)、`T_approve`(要人拍板)、
* `T_release`(签下去触达世界)—— 给了等于给引擎一把替人签字的钥匙。
*/
export const PRICING_STEPS = [
{
item: 'T_price',
intent: '逐条算价并落草案:拟报单价 / 毛利 / 偏离度 / 门禁处置,并结算条数与超限数',
item: 'S1_price',
intent: '整份归一成报价草案:取成本基准 → 按利润模型算出底价线与建议价,逐条落表',
writes: ['quote_pricing.draft', 'quote_pricing.verdict'],
reads: [],
/* 上游载荷数组不声明就会被整表段的截断刀砍掉(砍掉的恰是要归一的行) */
reads: ['rows', 'records'],
view: 'pricing-worksheet',
tools: ['crm.history-price-read'],
/*
* 归一 + 算价两轮都走完才算办完:行数两边对上(一边是基座现数),
* 且 priced_total 写过(全是无基准的单子这个数就是 0;S-FEEL 未绑定的名字
* 参与比较为假,所以「没写过」进不来)。
*/
doneWhen: 'line_total > 0 and quote_pricing_draft_rows = line_total and priced_total >= 0',
guide: `第一件事:把这一单的**报价草案**落出来,并把能算的价都算出来。
【料从哪儿来】
· 寻源转来(\`rows\` 里的行带 base_price / price_basis):照抄 —— 底价就是成本基准,
一个数字都别改;带 quote_line_total 这类总数的是结论行,不是料,别落进草案。
· 受理/选型直达(\`records\` 或没有底价的 \`rows\`):料号、品类、制造商、年用量、
客户目标价、类别(type_tag)照抄,**底价一栏留空**。
· 单飞:照正文归一,列了几颗落几行;没说类别的按 B。
**按 pn 归并,一颗料一行**;pn 一个字符不许改(它是主键,改一个字就是新开一行)。
【第一拍:只做两件事 —— 落草案行 + 串尾发一次查价】
① 写 \`quote_pricing.draft\`,一颗料一行(rows 收数组):
rfq_ref(入参有就原样抄;没有从主题认;认不出才自己造一个,**造出来的必须以 \`PRC-\` 开头**
(\`PRC-<主题或第一颗料的型号>\`)。两条硬规矩:
**① 整单只有一个单号,不是一颗料一个** —— 它是这一单的案号,草案所有行、verdict 那一行
用的都得是同一个串(实测栽过:两颗料造出两个号,一单裂成两单);
**② 只从本单来件取材** —— 台账里躺着的 RFQ-xxxx 是别人的单,借它的号等于把两单的行
混进同一个案卷(实测栽过一轮))、pn、customer_pn、category、
brand、eau、target_price(客户目标价)、lead_time、remark(客户约束/急料,一个字别丢)、
type_tag(照上游现标;单飞按 B)、base_price(上游给了就抄,没有留空)。
suggested_price / margin_band / floor_price **这一拍全部留空** —— 基准还没取齐。
**quoted_price / margin / deviation / disposition 这四列从头到尾都不是你写的**(见下)。
② 串尾调 \`crm.history-price-read\`:pns 收数组,把**底价还空着的那几颗料**一次传完
(上游已经给了底价的不必查)。
**这一拍不写 \`quote_pricing.verdict\`,一列都不写** —— 尤其 priced_total:
它是「算价走完了」的凭据,提前写等于把闸提前打开,引擎会在下一拍开头当场收掉这一步,
**算价从此不会发生**(A3 实测栽过:嘴上说预填了,台账里没有)。
【**这一列你一格都不写**:quoted_price】
拟报单价是 **BD 在工作单上定的那个数** —— 人签的就是它。你给的是**建议价**
(suggested_price),他确认或改成自己的数;margin / deviation / disposition 三列
要等他定完价才算得出来,这一步也别写。
这不是客气话:从前只有一列价、剧本写着「算不出的留空」,实测跑单时两颗库里查不到的料
照样被编出了价(一颗抄客户目标价、一颗凭空写了个数),而交代里还说着「价空着等你定」。
**一条要你分辨「这行算得出/算不出」的规矩会走岔;「这一列不写」没有分支可走岔。**
【第二拍:算建议价 + 记账 + 收步,一拍连做】
① 补基准:**只补上一拍 base_price 空着的那几行** —— 工具出参里 hit=true 的写
base_price = lastPrice、price_basis 写 **HIST**;hit=false 的**别编价**,base_price 留空,
它就是「无基准」那一档。
**上游已经给了 base_price 的行:工具回什么都不采用,那一格一个数字都别改** ——
上游给的是本单问回来的货源底价,工具给的是库内历史价;这一单的成本是前者
(实测栽过:两行上游底价被历史价盖掉,毛利从 20% 变成 115%,数字全对而结论是假的)。
② 逐条算**建议价**,规矩写死,照做即可(**这些是算术,不是判断**):
· 毛利档 margin_band 按利润模型 P-01 取(客户等级 × 类别):
战略客户 + A 类(大批量长单)→ 8%~10%;普通客户 + A 类 → 12%~15%;
B 类 → 15%~20%;C 类 → 20%~50%;拿不准按 12%~15%。写成「12%~15%」这样的文本。
· **底价线** floor_price = base_price ×(1 + 档下限)。保留四位小数。
· **建议价** suggested_price:
- 客户没给目标价(target_price 空)→ 取 base_price ×(1 + 档上限);
- 客户目标价 ≥ 底价线 → 就取客户目标价(接得住,毛利还在档内);
- 客户目标价 < 底价线 → 仍取客户目标价(贴着客户报),这一条到门禁那儿会亮灯;
- **base_price 空(无基准)→ margin_band / floor_price / suggested_price 三列一起留空**。
不要拿一个不相干的价去凑一个数出来 —— 那比没有建议更危险,它看起来像有依据。
· **「没有」要给空串 \`""\`,不许写 0** —— 0 是一个数,底价线写 0 看起来像「成本为零」,
偏离度写 0 看起来像「正好压在底价线上」。空串基座会当成「这一列不写」。
③ 这时才写 \`quote_pricing.verdict\`(一次写全):rfq_ref、customer_code / customer_name
(顶层有就抄)、customer_grade(来件认得出就写,认不出写「普通客户」)、
**line_total** = 草案行数、**basis_total** = 有成本基准的条数、
**priced_total** = 这一拍算出建议价的条数(0 也要写)。
④ finish 这一项。`,
},
{
item: 'S2_review',
intent: '一张执行单交 BD:工作单上逐条定价(拟报单价归他);收回后过底价门禁并定盘',
writes: ['quote_pricing.draft', 'quote_pricing.verdict'],
reads: ['quote_pricing.draft'],
view: 'pricing-worksheet',
tools: [],
// 草案有行了才算算完。不写 `!= null` —— 未绑定的名字那样求值为 true(A1 踩过)
doneWhen: 'draft_total > 0',
guide: `基准价取自历史价情报(A3 的比价结论 / CRM 历史价)。新料号无历史时基准记 0,
由底价门禁按「无基准」处置 —— **不凭空编一个偏离率**。
逐条写进 \`quote_pricing.draft\`:拟报单价 / 目标毛利 / 相对基准偏离 / 门禁处置。
**同一拍接着**把 draft_total 与 over_total(门禁判超限的条数)写上 ——
前者是完成判据,后者决定要不要叫人。
**同一拍再写一行 \`quote_pricing.verdict\`**(验收机检读的就是它):
· draft_total / unpriced_total = 草案条数 / 还没定出价的条数(后者要 = 0);
· over_total = 门禁判超限的条数;
· unresolved_over_total = 超限里**还没处置**的条数(要 = 0 —— 回调了或注明了理由都算已处置);
· summary 一句人话结论。写不对 submit 会被门挡回来。三笔写完再 finish。
**每一行都要带 \`rfq_ref\`** —— 它是这一单的单标识(起单入参里就有,原样抄进去)。
台账主键第一列就是它:不写,这一行会和**别的单**同料号的那一行撞主键,把人家覆盖掉
(实测栽过:A4 的报价草案主键只有 pn,跑四轮只剩最后一轮的 3 行)。`,
/* 不给 doneWhen —— 发卡触达真人(manualActivation),引擎不替人按 */
guide: `建议价算完了,**该 BD 出场了** —— 这是本活动唯一的一张执行单,
**拟报单价是他定的,不是你定的**。工作区摆的是**定价工作单**:逐颗料一张定价档案卡
(成本基准、毛利档、客户目标价、底价线、系统建议价),利润模型与底价门禁两把尺挂在上面,
填回区长在同一屏 —— 他在「拟报单价」那一列逐行填(照建议价确认,或改成自己的数)。
这一步标了人工激活:发卡就算启动,不必单发 start;引擎也不会替你收 —— 人交回后要你自己 finish。
【照这个骨架发,别少字段】(卡面是纯文本,steps/standard 里**别写星号记号**):
{"kind":"human","itemId":"S2_review","workOrder":"execution","card":{
"title":"报价定价:<单号> 共 <N> 条待你定价",
"ask":"系统已按成本基准与利润模型算出每一颗的建议价,请在工作单的「拟报单价」一列逐行定价:认可建议价就照抄,不认可就改成你的数;库内没有成本基准的那几行没有建议价,由你定。",
"steps":["拟报单价每一行都要有 —— 缺一行这张单按不下提交;它是这一单最后签下去的那个数,系统不替你填",
"建议价是从成本基准按利润模型的毛利档推出来的:照抄就是认可这个毛利,改高改低都可以,改了在备注里写一句缘由",
"低于底价线的价会被底价门禁标出来:两成以内附理由即可放行,超过两成的要另外找人拍板 —— 你在这里往上调一点,就不必再惊动一个人",
"库内查不到成本的料没有建议价:按你的经验定价,并在备注写明依据;实在定不了的按行首 × 把这一条撤出本轮"],
"standard":"每一行都有拟报单价,而且每个价都说得出来路(基准 / 毛利档 / 让价理由)—— 缺一行这张单按不下提交(系统的闸看着)。",
"basis":[{"k":"<PN>","v":"<类别 · 成本基准 · 毛利档 · 建议价>"}]}}
· \`basis\` 逐条列本单的料:PN + 基准与建议价。几颗写几条,别铺全表。
· **卡上不摆表、不摆格子**(执行卡零输入):整份草案就在工作单那份件上,
拟报单价与备注两列都能就地改、行能撤 —— **别往卡里塞 \`blocks\` 或 \`fields\`**。
· **这张卡带提交闸**:拟报单价(必填列)还空着,他按不下提交 —— 缺几格、缺在哪行,
闸上与件上的红带都写着(与放行门 L1 同一个数同一套算法)。
发完卡这一拍就结束了(发卡挂起)。
【人交回之后 —— 他填的价基座已经替你写回台账了】这一步你只做两件事,然后 finish:
① 写 verdict:**confirmed_total** = 他定过价的条数(照草案行数记);
他在件上撤掉了行的话,**line_total 跟着改成撤后的实数**。
② finish 这一项。
**别在这一拍算门禁** —— 底价自检是下一步的活(它有自己的一段做法),这里算了也白算。
草案行你一列都不用重写:他填的值基座已经落进台账了,重写会被「写了等于没写」拒回。
【这张卡一单只发一次】人交回来之后不许再发同一张卡 —— 必填有提交闸兜着,
卡回来时拟报单价一定是齐的(或那几行已被他撤掉)。`,
},
{
item: 'S3_gate',
intent: '底价自检:按人定下的价逐条算毛利、偏离度与门禁处置,判完即定盘',
writes: ['quote_pricing.draft', 'quote_pricing.verdict'],
reads: ['quote_pricing.draft'],
view: 'pricing-worksheet',
tools: [],
/*
* **收步判据与验收判据同源**:三档处置的条数加起来等于行数(两边都是基座现数)——
* 判完了才算办完。`_rows > 0` 那一半不能省:开案时行数是 0,而 0+0+0 = 0
* 会让这一步开局即收(未绑定名字的坑之外,还有「全零相等」这一坑)。
*/
doneWhen:
'quote_pricing_draft_rows > 0 and quote_pricing_draft_disposition_pass + quote_pricing_draft_disposition_note + quote_pricing_draft_disposition_block = quote_pricing_draft_rows',
guide: `人把价定完了,**现在才轮到算门禁** —— 这一步是纯算术,一拍连做:
① **逐行按他定的 quoted_price 算三列**(**quoted_price 你一格都不写,只写这三列**):
· margin =(quoted_price − base_price)/ base_price,四位小数;
· **偏离度** deviation =(quoted_price − floor_price)/ floor_price,四位小数;
· **门禁处置** disposition 按底价门禁 P-02 判,只许写这三个大写码之一,一个字母不许差:
- deviation ≥ 0 → **PASS**(在底价线之上,直接放行)
- −0.20 ≤ deviation < 0 → **NOTE**(低于底价线两成以内,附理由放行)
- deviation < −0.20 → **BLOCK**(低于两成以上,压在拦截档,要人拍板)
· **base_price 空(无基准)的行**:margin / deviation 给空串 \`""\`(**不许写 0** ——
0 看起来像「正好压在底价线上」,而这一行压根没有底价线)、disposition 记 **NOTE**,
price_note 里保留他写的定价依据(他没写就补一句「库内无成本基准,由 BD 定价」)。
**一行都不能漏**:三档处置的条数加起来必须等于 quote_pricing_draft_rows(基座数的行数)。
现在三档都是 0,就是一行都还没判 —— 这一步没办完,放行门开不了。
② 看这一拍写完之后的**拦截档条数**(disposition = BLOCK 的行):
· 一条都没有 → 同一拍把 verdict 的 **route_decided_total 记 1**(定盘)、
summary 写一句人话(几条直接放行、几条附理由),然后 finish 这一项;
· 还有 → **别写 route_decided_total**,只 finish 这一项 —— 下一拍会有一步专门叫人拍板。`,
},
{
item: 'T_approve',
intent: '超限条目要人拍板 —— 发决策卡,照人选的那条路改草案',
writes: ['quote_pricing.draft'],
reads: [],
intent: '压在拦截档的条目要人拍板 —— 发一张决策卡,照他选的那条路把草案改掉',
writes: ['quote_pricing.draft', 'quote_pricing.verdict'],
reads: ['quote_pricing.draft'],
view: 'pricing-roster',
tools: [],
// 人工激活 + 要人拍板 —— 不给 doneWhen,引擎不许替人收
guide: `**只在 over_total > 0 时走这一步**。它触达客户利益,标了人工激活:
**先 start T_approve**(与发卡排同一拍即可),再发决策卡(decide,itemId 填 T_approve)。
选项按门禁处置给(照原价报 / 按底价报 / 退回重算),consequence 写清后果;
basis 摆上哪几条、偏离多少、底价多少(**裸卡基座拒发**)。
人点完轨迹上会出现「decision 卡回传 —— 人选了:xxx」,**照那条路改 draft,别再发第二张卡**。`,
/* 人工激活 + 要人拍板 —— 不给 doneWhen,引擎不许替人收 */
guide: `**这一单只发一张决策卡。动手之前先分清你在哪一半:**
· 轨迹里**已经有**「decision 卡回传」/「人选了:xxx」—— 那就跳到下面【人点完之后】那一段,
照他选的那条路改草案、写 route_decided_total、finish。**一个 decide 都不许再发**
(实测栽过:人点完了,而这一步又从头念了一遍「发一张决策卡」,同一张卡连发四次,
调人预算烧光,单子 exhausted —— 他点过的那一下就白点了);
· 轨迹里**还没有** —— 才轮到下面【发一张决策卡】那一段。
这一步只在还有条目压在拦截档(基座现数 quote_pricing_draft_disposition_block > 0)时入场;
它触达客户利益,标了人工激活:发卡就算启动,不必单发 start。
工作区这时摆的是**报价草案清单**,整份草案与门禁分布都在上面,他看得见全局再拍板。
【发一张决策卡,照这个骨架】(选项少于两个基座拒发):
{"kind":"decide","itemId":"T_approve","ask":"<N> 条低于底价线两成以上,按哪条路办?",
"options":[
{"value":"floor","label":"回调到底价线报","consequence":"守住毛利下限,客户可能不接受这个价","recommended":true},
{"value":"as-is","label":"照现价破线报,附理由","consequence":"接住客户目标价拿单,毛利跌破下限,理由随草案交审批"},
{"value":"drop","label":"这几条本轮不报","consequence":"撤出本单草案,客户拿到的是一份缺项的报价"}],
"basis":[{"k":"<PN>","v":"成本基准 <X> · 底价线 <Y> · 拟报 <Z> · 偏离 <W>"}]}
· basis 逐条摆压在拦截档的那几颗料:基准、底价线、拟报价、偏离度 —— 裸卡基座拒发。
· 发完这一拍就结束了(发卡挂起)。
【人点完之后】轨迹上会出现「decision 卡回传 —— 人选了:xxx」。**照那条路把草案改掉,
别再发第二张卡**,一拍连做:
· 选 floor:**这是全活动唯一一次你可以写 quoted_price** —— 那个数不是你定的,
是他在卡上点下的(就是该行的底价线),你只是替他落账:
那几行 quoted_price 改成本行的 floor_price,margin 记该行毛利档的下限、deviation 记 0、
disposition 改 **PASS**,price_note 写「按底价线回调,由负责人拍板」;
· 选 as-is:价不动,disposition 改 **NOTE**,price_note 写明破线的理由与拍板人;
· 选 drop:用 \`drop\` 把那几行撤出本单草稿(主键照 \`quote_pricing_draft_keys\` 名单**原样粘**),
并把 verdict 的 line_total 改成撤后的实数。
然后写 verdict:**route_decided_total 记 1**、summary 补一句拍板结果,finish 这一项。
拦截档清零之后,放行门才开得了。`,
},
{
item: 'T_release',
intent: '草案已齐,提交放行交 BD 验收',
intent: '草案已定盘,提交放行交 BD 验收',
writes: [],
reads: [],
view: 'pricing-roster',
tools: [],
guide: `草案有行了 —— **先 start T_release**(人工激活),提交前用 judge 裁掉裁量判据
(criterion 照判据名原样写),然后 submit。终局唯放行:你不能自己宣布完成。`,
/* 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 doneWhen) */
guide: `每行有价、门禁处置守恒、拦截档清零、定盘已记 —— 可以走放行了
(T_release 人工激活:动它就算启动,不必单发 start)。先把裁量判据裁掉,然后 submit,同一拍连做:
· judge criterion="报价的来路说得清:基准价来源、毛利档与让价理由逐条可溯,BD 对报价负责" ——
看草案里每一行的基准、毛利档与备注:说得出来路就 satisfied=true;
有行说不出的(比如无基准由人定的价没写缘由),satisfied=**false**,rationale 逐行写清,
交签字的人定。**裁成不满足照样能提交** —— 理由连着卡一起交给他。
criterion 照判据名**整句原样抄**,写别的名字对不上。
终局唯放行:你不能自己宣布完成。`,
},
];
/**
* 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段;
* 放行那一格由基座统一加,人看到的是 x/4。
*/
export const PRICING_PHASES = [
{ name: '取基准算建议价', items: ['S1_price'] },
{ name: '交BD定价', items: ['S2_review'] },
{ name: '底价自检', items: ['S3_gate'] },
{ name: '超限拍板', items: ['T_approve'] },
];
+12 -74
View File
@@ -1,79 +1,17 @@
/**
* 统一报价 A4 · 成本定价 —— 活动包代码入口(ADR-004)。
* 统一报价 A4 · 成本定价 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
*
* flow 活动:下一步由步序声明给定。本文件只做声明装不下的两件事 ——
* 视图流控,与「结论不可静默覆写」的呈现:P-02 拦下的条目必须显式呈现给人。
* **这个活动没有 `run`,不是缺件。**
*
* 案例活动的推进由 Loop 驱动,行为全部写在清单里:案例模型 + 说明书 + 步骤剧本 +
* 两把判定表 + 两条技能 + 台账声明 + 两张 HTML 报告模版。
* 上一版这里有一段 `run()`(读草案、拼 kv/table 块、按拦下条数返回 await/submit)——
* 案例活动根本不会调它;摆一个空转的 run 去假装有实现,更坏:
* 它写的那套呈现与 `viewTemplates` 里的模版各说各话,而没人看得出以哪份为准。
*
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 —— 它是**被静态审扫描的产物文本**
* (查 import 与全局引用,证明活动包零环境能力)。
*/
import { defineActivity } from '@sdk';
import type { ActivityBase, ActivityOutcome } from '@sdk';
const DRAFT = 'quote_pricing.draft';
export default defineActivity({
name: 'quote-pricing',
async run(base: ActivityBase): Promise<ActivityOutcome> {
const items = await base.ledger.read(DRAFT);
const priced = items.filter((r) => typeof r['quoted_price'] === 'number');
// 底价门禁 P-02 拦下的条目 —— DMN 结论是**约束性输入**,不得静默覆写
const blocked = items.filter((r) => String(r['disposition'] ?? '').includes('拦截'));
await base.interaction.pushView([
{
t: 'kv',
title: '定价概览',
items: [
{ k: '条目', v: `${items.length} 条` },
{ k: '已定价', v: `${priced.length} 条` },
{ k: '底价门禁拦下', v: `${blocked.length} 条` },
],
},
{
t: 'table',
title: '报价草案',
cols: ['PN', '拟报单价', '毛利率', '偏离度', '门禁处置'],
rows: items.map((r) => [
String(r['pn'] ?? ''),
fmt(r['quoted_price']),
pct(r['margin']),
pct(r['deviation']),
String(r['disposition'] ?? '待判'),
]),
linkDonut: {
col: 4,
title: '门禁分布',
map: [
{ match: '直接放行', label: '直接放行', tone: 'ok' },
{ match: 'PM', label: 'PM 附理由', tone: 'warn' },
{ match: '拦截', label: '拦截', tone: 'danger' },
],
},
},
]);
if (priced.length < items.length) {
await base.interaction.say(`还有 ${items.length - priced.length} 条未定价,继续按利润模型 P-01 求值。`, 'conclusion');
return { kind: 'await', reason: '等待定价步完成' };
}
if (blocked.length > 0) {
// 超出授权边界 → 走「人 · 决策」,不自行破线
await base.interaction.say(
`${blocked.length} 条低于底价 20% 以上,已被底价门禁 P-02 拦下 —— 需要人裁决回调还是注明理由。`,
'criterion',
);
return { kind: 'await', reason: '等待超限条目的人裁决' };
}
await base.interaction.say(`${items.length} 条已全部定价且过底价门禁,可提交审批。`, 'criterion');
return { kind: 'submit', deliverable: 'quote-draft', rationale: '全条目带偏离度,底价门禁全过' };
},
});
function fmt(value: unknown): string {
return typeof value === 'number' ? value.toFixed(2) : '—';
}
function pct(value: unknown): string {
return typeof value === 'number' ? `${(value * 100).toFixed(1)}%` : '—';
}
export default defineActivity({ name: 'quote-pricing' });
+313 -85
View File
@@ -1,27 +1,45 @@
/**
* 活动包清单 —— 统一报价 A4 · 成本定价。
* 活动包清单 —— 统一报价 A4 · 成本定价(案例活动)。
*
* 业务素材:docs/MVP示例工作流.html §1.2(S1 取价基准 → S2 逐条定价 → S3 底价自检)。
* README《已裁定的落地决策》③:**A4 = flow** —— 取价、查表定价、自检的判断
* **全部装进 DMN 决策表确定性求值**;一旦出现「由 AI 决定怎么办」,按 AM 2.1 上升。
* 业务素材:`docs/MVP示例工作流.html` §1.2(S1 取价基准 → S2 逐条定价 → S3 底价自检):
* 「BD 逐条定价:价格 = 底价 × 利润模型。提交审批前先自查底价门禁 ——
* 超限的自己先调,不把问题留给 PM。」
*
* 形状与 A3 同一条(2026-08-03 重写 @3.0.0):归一算价 → 一张执行单交人 → 超限才叫人
* 拍板 → 放行。**能算的不问人**(P-01 毛利档、P-02 底价门禁两把尺写死),
* **该人负责的不自算**(拟报单价是必填,库内没有基准的那几行系统一个价都不编)。
*
* 与 @2.0.0 的差别(三处都是踩过的账):
* ① `input.required` 从 `['rfqRef','customerCode','records']` 清空 —— 宽进严出,
* 上游(A3)发的是 `{rfqRef, rows}`,门口拦的恰是主路;
* ② 台账补 `required` —— 从前一个必填都没有,执行卡的闸没牙,人不填也能交卷;
* ③ 判据与哨兵全部换成**基座读数**(`_rows` / `_missing` / `_<列>_<值>`)——
* 从前 L1 读 `unpriced_total`、哨兵读 `draft_total`,两个都是活动自报的数。
*/
import type { ActivityManifest, DmnModel } from '@contracts';
import { PRICING_SKILLS } from './skills.ts';
import { CMMN_XML, NARRATIVE, PRICING_STEPS } from './case-model.ts';
import { activityIdSchema } from '@contracts';
import { PRICING_BASIS, PRICING_PRICED, PRICING_RELEASE } from './views/report.ts';
import { PRICING_SKILLS } from './skills.ts';
import { CMMN_XML, NARRATIVE, PRICING_PHASES, PRICING_STEPS } from './case-model.ts';
import { PRICING_ROSTER, PRICING_WORKSHEET } from './views/report.ts';
const DOMAIN = 'quote_pricing';
const D = 'quote_pricing';
const ACTIVITY_ID = activityIdSchema.parse('quote-pricing');
/**
* 利润模型 P-01 —— 品牌 × 类型 × 客户等级 × 数量档 → 目标毛利。
* 「经验」段落不入决策表(那是技能 / 活动记忆种子:载方法不载权力)。
* 两把尺 —— **规则本身不是数据**,随包冻结。
*
* · 利润模型 P-01:客户等级 × 类别 → 目标毛利区间(FIRST,行序即业务优先级);
* · 底价门禁 P-02:相对底价线的偏离度 → 处置码。
*
* 出参就是台账列的取值(`disposition` 的三个大写码),模型算完照抄即可 ——
* 「出参键名与台账列同名」是为了它拿回去直接 write(规范 §5.6)。
* 两把尺的档位在三处出现:这张表、剧本 guide、工作单上的规则块 ——
* 包内自测把三处钉在一起(改一处不改另两处,测就红)。
*/
export const P01_DMN: DmnModel = {
drd: [
{ decision: 'p01-margin', requires: [], inputs: ['customerGrade', 'materialType', 'volumeTier'] },
{ decision: 'p02-floor', requires: ['p01-margin'], inputs: ['quotedPrice', 'floorPrice'] },
{ decision: 'p01-margin', requires: [], inputs: ['customer_grade', 'type_tag'] },
{ decision: 'p02-floor', requires: ['p01-margin'], inputs: ['deviation'] },
],
tables: [
{
@@ -29,30 +47,29 @@ export const P01_DMN: DmnModel = {
label: '利润模型 P-01',
hitPolicy: 'FIRST',
inputs: [
{ name: 'customerGrade', expression: 'customerGrade' },
{ name: 'materialType', expression: 'materialType' },
{ name: 'volumeTier', expression: 'volumeTier' },
{ name: 'customer_grade', expression: 'customer_grade' },
{ name: 'type_tag', expression: 'type_tag' },
],
outputs: [{ name: 'marginLow' }, { name: 'marginHigh' }],
outputs: [{ name: 'margin_low' }, { name: 'margin_high' }],
rules: [
{ id: 'p01-1', when: ['"战略客户"', '"A"', '"大批量"'], then: [0.08, 0.1], annotation: '战略客户 A 类标准件大批量 8%–10%' },
{ id: 'p01-2', when: ['"普通客户"', '"A"', '-'], then: [0.12, 0.15], annotation: '普通客户 A 类标准件 12%–15%' },
{ id: 'p01-3', when: ['-', '"B"', '-'], then: [0.15, 0.2], annotation: 'B 类波动件 15%–20%' },
{ id: 'p01-4', when: ['-', '"C"', '-'], then: [0.2, 0.5], annotation: 'C 类替代/EOL ≥20% 或一事一议' },
{ id: 'p01-default', when: ['-', '-', '-'], then: [0.12, 0.15], annotation: '兜底按普通客户标准件' },
{ id: 'p01-1', when: ['"战略客户"', '"A"'], then: [0.08, 0.1], annotation: '战略客户 A 类标准件(大批量长单)8%~10%' },
{ id: 'p01-2', when: ['"普通客户"', '"A"'], then: [0.12, 0.15], annotation: '普通客户 A 类标准件 12%~15%' },
{ id: 'p01-3', when: ['-', '"B"'], then: [0.15, 0.2], annotation: 'B 类波动件 15%~20%' },
{ id: 'p01-4', when: ['-', '"C"'], then: [0.2, 0.5], annotation: 'C 类替代/停产件 20%~50%(一事一议)' },
{ id: 'p01-default', when: ['-', '-'], then: [0.12, 0.15], annotation: '拿不准按普通客户标准件档' },
],
},
{
/** 底价门禁 P-02 —— 偏离底价 ±20% 即止 */
/** 底价门禁 P-02 —— 偏离底价线 ±20% 即止;出参就是台账 `disposition` 的取值 */
id: 'p02-floor',
label: '底价门禁 P-02',
hitPolicy: 'FIRST',
inputs: [{ name: 'deviation', expression: 'deviation' }],
outputs: [{ name: 'disposition' }, { name: 'passable' }],
rules: [
{ id: 'p02-1', when: ['>= 0'], then: ['直接放行', true], annotation: '≥底价 → 直接放行' },
{ id: 'p02-2', when: ['[-0.20..0)'], then: ['PM 附理由放行', true], annotation: '低于底价 ≤20% → PM 附理由' },
{ id: 'p02-3', when: ['< -0.20'], then: ['拦截 · BOSS 拍板', false], annotation: '低于底价 >20% → 拦截' },
{ id: 'p02-1', when: ['>= 0'], then: ['PASS', true], annotation: '在底价线之上 → 直接放行' },
{ id: 'p02-2', when: ['[-0.20..0)'], then: ['NOTE', true], annotation: '低于底价线两成以内 → 附理由放行' },
{ id: 'p02-3', when: ['< -0.20'], then: ['BLOCK', false], annotation: '低于两成以上 → 拦截,人拍板' },
],
},
],
@@ -62,95 +79,162 @@ export const manifest: ActivityManifest = {
identity: {
activityId: ACTIVITY_ID,
name: 'A4 · 成本定价',
packageVersion: '2.0.0',
packageVersion: '3.5.0',
category: '统一报价',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-quote-pricing',
},
/* ── 契约七件 ────────────────────────────────────────────────── */
contract: {
activityId: ACTIVITY_ID,
version: '0.1.0',
version: '1.0.0',
input: {
/*
* **宽进到底:一个必填都不设**(与 A1/A2/A3 同口径 —— 宽进严出,
* 输入契约在活动内部归一 + 补全)。
*
* 来路三种形状:寻源转来(rows,行上带 base_price)、受理/选型直达(records/rows,
* 没有底价)、单飞(一段文字)。上一版必填 `rfqRef + customerCode + records`,
* 而 A3 的 `quotation` 口发的是 `{rfqRef, rows}` —— 门口拦的恰是主路。
*/
data: {
type: 'object',
required: ['rfqRef', 'customerCode', 'records'],
required: [],
properties: {
rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' },
customerCode: { type: 'string' },
records: { type: 'array' },
// B/C 类由 A3/A2 交回的底价与替代方案;A 类直通时为空
floorPrices: { type: 'array' },
customerCode: { type: 'string', title: '客户码' },
rows: { type: 'array', title: '目标报价清单(寻源/选型转来,行上带底价)' },
records: { 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: {
/*
* 只有一个出口:报价草案交定价审批(流程包 E8 是无条件边)。没有选择组 ——
* 没有去向要选,定盘就是把账写齐。
*
* 载荷键名照下游(A6 议价)的入参写:`rfqRef / customerCode / items` ——
* 边读什么、下游要什么,上游就得发什么。标量都有台账列承载(自测钉着)。
*/
mandatory: [
{
name: 'draft',
payload: {
type: 'object',
required: ['rfqRef', 'items'],
properties: { rfqRef: { type: 'string' }, items: { type: 'array' } },
properties: {
rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' },
customerCode: { type: 'string', title: '客户码' },
items: { type: 'array', title: '报价草案(每条带拟报单价、偏离度与门禁处置)' },
},
},
description: '报价草案(逐条带偏离度)',
description: '报价草案(全条目带拟报单价与门禁处置)',
},
],
selective: [],
},
/*
* 必交付物**必须给 `ledger` 地址**(2026-07-31 修)。
* 不给的话预检退回 `scope[key]` 去找 —— key 是 `benchmark-table` 这样的短横线别名,
* 任务视图里永远没有这个键,于是「必交付物缺一不得提交放行」永远拒,
* 活办完了也放不了行,连拒五拍熔断(实测 A2 死于此)。
* 必交付物只有一件:**报价草案**。定价结论(verdict)是它的结论栏,
* `ledgers` 说清这份成果由哪几张表承载(一份成果,不是两份)。
*/
requiredDeliverables: [
{ key: 'quote-draft', label: '报价草案(全条目带偏离度)', ledger: `${DOMAIN}.draft` },
{
key: 'quote-draft',
label: '报价草案(每条带拟报单价与门禁处置)',
ledger: `${D}.draft`,
/* 验收页上让 BD 就地改的两列 —— 恰是他的职权面(定价与让价理由) */
editableAtRelease: ['quoted_price', 'price_note'],
ledgers: [`${D}.draft`, `${D}.verdict`],
},
],
/*
* 机检谓词读的是 `verdict` 那张**汇总表**的列(照 A1 的口径)。
* 上一版的名字(unpriced;floor-gate 更是连谓词都没有)**台账里根本没有** —— S-FEEL 拿未绑定的名字
* 比大小求值为假,判据永远达不了标,submit 门永远拒,连拒五拍熔断。
* 判据的名字必须落在台账上,否则「机检」是一句空话(2026-07-31 销账)。
* 验收判据 —— **机检(L1/L2)只读基座数得出来的数**:
* `_rows` 基座数行数、`_missing` 基座按 required 现数(与执行卡提交闸同一个数
* 同一套算法)、`_disposition_*` 基座按值现数。
* 上一版两条机检读活动自报的 `unpriced_total` / `unresolved_over_total` ——
* 那不是门,是「改哪个数能过」的提示(违反史已记四次,这是第五类)。
*/
acceptanceCriteria: [
{ key: 'all-priced', label: '每条都有拟报单价与毛利率', level: 'L1', predicate: 'unpriced_total = 0' },
// 底价门禁沉为 L2 机检 —— 「约束力收缩」的终点
{ key: 'floor-gate', label: '偏离底价 ≤±20%,超限者已回调或注明理由', level: 'L2', modelRef: 'p02-floor', predicate: 'unresolved_over_total = 0' },
{ key: 'pm-release', label: 'BD 放行', level: 'L5' },
{
key: 'roster-complete',
label: '草案齐:上游条目逐条承继,说了几条草案里就躺着几行',
level: 'L1',
predicate: `line_total > 0 and ${D}_draft_rows = line_total`,
},
{
key: 'all-priced',
label: '每条都有拟报单价:库内无基准价的那几行也由人定过价,缺一行放不了',
level: 'L1',
predicate: `${D}_draft_rows > 0 and ${D}_draft_missing = 0`,
},
{
key: 'floor-gate',
label: '底价门禁已过:处置逐条落定,没有还压在拦截档上的条目 —— 超限的要么回调、要么经人拍板附理由',
level: 'L2',
modelRef: 'p02-floor',
predicate: `${D}_draft_disposition_block = 0 and ${D}_draft_disposition_pass + ${D}_draft_disposition_note + ${D}_draft_disposition_block = ${D}_draft_rows`,
},
/*
* 报价来路可核是**裁量**(L3):基准是不是那个基准、让价理由站不站得住,
* 机器数不出来。这句 label 与剧本里 judge 的 criterion **必须逐字一致**。
*/
{
key: 'price-defensible',
label: '报价的来路说得清:基准价来源、毛利档与让价理由逐条可溯,BD 对报价负责',
level: 'L3',
},
/* L5 是责任关:那张卡本身就是这道关,卡面上不摆它(基座统一滤) */
{ key: 'bd-release', label: 'BD 放行:签下去这份报价草案即定盘并交定价审批', level: 'L5' },
],
revisableAtRelease: [],
prohibitions: [
{ key: 'no-silent-override', statement: '不得静默绕开 P-02 的结论;要破线只能走「人 · 决策」' },
{ key: 'no-stale-floor', statement: '底价超一周未更新的,先刷底价再判' },
{ key: 'no-silent-override', statement: '不得静默绕开底价门禁的结论;要破线只能走「人 · 决策」并留下理由' },
{ key: 'no-made-up-basis', statement: '没有成本基准的料不许编一个偏离率出来:基准空着就空着,价由人定并注明依据' },
{ key: 'no-stale-floor', statement: '底价超一周未更新的,先刷底价再判 —— 拿过期的成本算出来的毛利是假的' },
],
authorization: {
discretion: [{ key: 'margin-band', statement: '在 P-01 给出的毛利区间内自决;出区间必须调人决策' }],
discretion: [{ key: 'margin-band', statement: '在 P-01 给出的毛利区间内自决;出区间要么回调,要么调人拍板' }],
preauthorizedActions: [],
humanCallBudget: 3,
/* 一张执行单 + 可能一张决策卡 + 放行 = 三次;留一倍余量 */
humanCallBudget: 5,
},
resources: {
/*
* `quote-pricing.basis` 已从声明里去掉(2026-07-31):工具箱里从没注册过它,
* 装包时静态审一直报「运行时转人代办」—— 而重做后的案例模型压根不调它
* (算价归判定表 P-01/P-02,取基准读历史价)。声明一件不存在也不用的工具,
* 只会让每一单跑到 A4 就弹一张莫名其妙的转人卡。
*/
tools: [{ name: 'crm.history-price-read' }],
skills: PRICING_SKILLS.map((s) => s.id),
models: ['deepseek-v4-flash'],
roles: ['BD'],
budget: { steps: 60, tokens: 150_000, activeMinutes: 120, humanCalls: 3 },
/* SLA <2h(MVP示例 §1.2);预算按 A3 实测(6-8 拍 / 40-50k)留一倍余量 */
budget: { steps: 60, tokens: 400_000, activeMinutes: 120, humanCalls: 5 },
},
},
/* ── 声明 ────────────────────────────────────────────────────── */
declarations: {
models: ['deepseek-v4-flash'],
tools: [{ name: 'crm.history-price-read' }],
ledger: [
{
domain: DOMAIN,
domain: D,
object: 'draft',
kind: 'record',
caseKey: 'rfq_ref',
@@ -158,82 +242,226 @@ export const manifest: ActivityManifest = {
fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' },
{ name: 'quoted_price', type: 'number', primaryKey: false, masked: false, label: '拟报单价' },
{ 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: 'remark', type: 'text', primaryKey: false, masked: false, label: '客户备注(约束/急料)' },
{ name: 'type_tag', type: 'text', primaryKey: false, masked: false, label: '类别(承继)', tally: ['A', 'B', 'C'] },
/*
* **客户目标价标 `expect`** —— 客户该给而没给的那一问不能省(件上出橘色
* 「建议填」胶囊)。它不拦单:`_gaps` 只喂 L4,而本活动没有 L4 判据。
* 注意边界:我方自己写的注(price_note)**不标 expect** —— 标了 `_gaps`
* 恒大于 0,那条判据永远绿不了(A1 的账)。
*/
{ name: 'target_price', type: 'number', primaryKey: false, masked: false, expect: true, label: '客户目标价' },
/* 成本基准:上游底价或库内历史价。**取不到就空着** —— 空着不是异常,是「无基准」那一档 */
{ name: 'base_price', type: 'number', primaryKey: false, masked: false, label: '成本基准' },
{ name: 'price_basis', type: 'text', primaryKey: false, masked: false, label: '基准来源', tally: ['HIST', 'SOURCED'] },
{ name: 'margin_band', type: 'text', primaryKey: false, masked: false, label: '毛利档(P-01)' },
{ name: 'floor_price', type: 'number', primaryKey: false, masked: false, label: '底价线' },
/*
* **系统建议价是 AI 的活,拟报单价是人的活** —— 两列分开是**结构治**,不是措辞治。
*
* 实测账(2026-08-03,单飞那条案例):从前只有 `quoted_price` 一列,剧本写着
* 「无基准的行留空,由 BD 定」—— 模型照样给两颗库里查不到的料编了价
* (一颗抄了客户目标价,另一颗凭空写了 0.085),交代里还说着「价空着等你定」。
* 一条要模型分辨「这行算得出/算不出」的规矩,它十次里会走岔;
* 而「这一列你一格都不写」没有分支可走岔。
*
* 于是:AI 只写 `suggested_price`(算得出就给,算不出就空着),
* **`quoted_price` 永远是人在件上定的那个数** —— 它标 `required`,
* 件上的缺价红带、执行卡的提交闸、放行门 L1 三处同源(都数基座的 `_missing`)。
* 不用 `requiredWhen`:每一行都要有价,没有「这一行还没轮到」的情形。
*/
{ name: 'suggested_price', type: 'number', primaryKey: false, masked: false, label: '系统建议价' },
{ name: 'quoted_price', type: 'number', primaryKey: false, masked: false, required: true, label: '拟报单价(BD 定)' },
{ name: 'margin', type: 'number', primaryKey: false, masked: false, label: '毛利率' },
{ name: 'last_price', type: 'number', primaryKey: false, masked: false, label: '基准价' },
{ name: 'deviation', type: 'number', primaryKey: false, masked: false, label: '偏离度' },
{ name: 'disposition', type: 'text', primaryKey: false, masked: false, label: '门禁处置' },
{ name: 'deviation', type: 'number', primaryKey: false, masked: false, label: '偏离底价线' },
/*
* **门禁处置:取值域给出来,条数由基座现数** —— PASS / NOTE / BLOCK。
* 守恒判据、分布条、门禁徽读的全是基座读数;值是机器码,
* 人读名(直接放行 / 附理由 / 拦截待拍板)由模版样式表翻译。
*/
{ name: 'disposition', type: 'text', primaryKey: false, masked: false, label: '门禁处置', tally: ['PASS', 'NOTE', 'BLOCK'] },
{ name: 'price_note', type: 'text', primaryKey: false, masked: false, label: '备注 / 让价理由' },
],
},
{
/*
* 定价结论 —— 一单一行的**汇总表**,验收机检谓词的入参就在这儿。
* 定价结论 —— 一单一行的**汇总表**,哨兵与报告读它。
* 汇总列与逐行列**故意不同名**:任务视图把草稿行摊平到顶层是「后写的赢」,
* 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。
*/
domain: DOMAIN,
domain: D,
object: 'verdict',
kind: 'record',
caseKey: 'rfq_ref',
label: '定价结论',
fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'pn', type: 'text', primaryKey: false, masked: false, label: '型号' },
{ name: 'draft_total', type: 'integer', primaryKey: false, masked: false, label: '草案条数' },
{ name: 'unpriced_total', type: 'integer', primaryKey: false, masked: false, label: '未定价条数' },
{ name: 'over_total', type: 'integer', primaryKey: false, masked: false, label: '超限条数' },
{ name: 'unresolved_over_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: '客户名称' },
/* 客户等级是 P-01 的入参之一(战略客户 / 普通客户),认不出按普通客户 */
{ name: 'customer_grade', type: 'text', primaryKey: false, masked: false, label: '客户等级' },
{ name: 'line_total', type: 'integer', primaryKey: false, masked: false, label: '草案条目数' },
{ name: 'basis_total', type: 'integer', primaryKey: false, masked: false, label: '有成本基准的条数' },
/* 算价走完的凭据(0 也要写)—— 哨兵按它放交人核价那一步入场 */
{ name: 'priced_total', type: 'integer', primaryKey: false, masked: false, label: '已算出建议价条数' },
/* 「BD 核过价了」的凭据 —— 只在人环走完才产生 */
{ name: 'confirmed_total', type: 'integer', primaryKey: false, masked: false, label: 'BD 已核价条数' },
{ name: 'route_decided_total', type: 'integer', primaryKey: false, masked: false, label: '已定盘' },
{ name: 'summary', type: 'text', primaryKey: false, masked: false, label: '定价结论' },
],
},
],
listeners: [
{
/** 时限过半:SLA <2h,给 BD 与负责人提个醒 —— 静态监听,时限必配 */
origin: 'static',
eventType: 'human',
keyFilter: { kind: 'decision' },
semantic: 'complete-step',
deadline: 'PT4H',
escalation: ['nudge', 'escalate'],
eventType: 'time',
keyFilter: { kind: 'half-deadline' },
semantic: 'observe',
deadline: 'PT1H',
escalation: ['nudge'],
},
],
// SLA <2h(MVP示例 §1.2 留痕 · SLA)
harness: {
memoryWindow: 20,
memoryWindow: 22,
// SLA <2h(MVP示例 §1.2 留痕 · SLA)
stallAfter: 'PT2H',
nudge: [{ after: 'PT1H', action: 'nudge' }],
retry: { times: 2, backoff: 'PT30S' },
},
},
/* ── 资产初始版 ──────────────────────────────────────────────── */
assets: {
/* 资产初始版:案例模型(2026-07-29 起活动只有这一种业务模型) */
businessModel: {
cmmnXml: CMMN_XML,
narrative: NARRATIVE,
dmn: P01_DMN,
/* 步骤剧本 —— 与 A1 同一套:doneWhen 真了引擎自己收步,不问模型 */
steps: PRICING_STEPS,
phases: PRICING_PHASES,
},
skills: PRICING_SKILLS,
/*
* 两个局面各一张(与 A2/A3 同款口径):
* · `pricing-worksheet` 定价工作单 —— 算价与 BD 核价期间摆它(逐颗定价档案、
* 两把尺、填回工作表);
* · `pricing-roster` 报价草案清单 —— 拍板、放行、归档摆它。
*/
viewTemplates: [
{ name: 'pricing-basis', html: PRICING_BASIS },
{ name: 'pricing-priced', html: PRICING_PRICED },
{ name: 'pricing-release', html: PRICING_RELEASE },
{ name: 'pricing-worksheet', html: PRICING_WORKSHEET },
{ name: 'pricing-roster', html: PRICING_ROSTER },
],
cardSchemas: {},
distillHooks: [
{
on: 'terminal',
prompt: '归纳本单定价的经验:哪些组合的毛利档被人回调过、底价门禁拦下的形态、破线的理由。',
prompt: '归纳本单定价:哪些组合的毛利档被人回调过、底价门禁拦下的形态、破线的理由与拍板结果。',
},
],
},
/* ── 样例集 —— 三条形状真不同(常备案例即验收案例)───────────── */
samples: [
{
name: '战略客户 A 类大批量',
input: { customerCode: 'C001', records: [{ pn: 'LM358DR', type: 'A' }] },
/*
* ① 寻源转来 · 常路:A3 `quotation` 口的真实形状 —— rows 里清单行(带底价与价源)
* 混着结论行。客户目标价都在底价线之上,全 PASS,不必叫人拍板。
*/
name: '寻源转来 · 两条料底价齐 · 全在底价线之上(常路)',
input: {
rfqRef: 'RFQ MD14.1',
customerCode: 'CU-0142',
customer_name: 'Itelma',
rows: [
{
rfq_ref: 'RFQ MD14.1',
pn: 'BBPY00201209601Y00',
customer_pn: '4-14-01502',
category: '滤波器',
brand: 'YAGEO',
eau: 30000,
target_price: 0.085,
lead_time: '8周',
type_tag: 'B',
base_price: 0.058,
price_basis: 'SOURCED',
supplier: '深圳华强渠道',
},
{
rfq_ref: 'RFQ MD14.1',
pn: 'CJTB0101R6N',
customer_pn: '4-00-04102',
category: '集成电路',
brand: 'Jiangsu Changjing',
eau: 60000,
target_price: 0.42,
lead_time: '6周',
type_tag: 'A',
base_price: 0.35,
price_basis: 'HIST',
},
{ rfq_ref: 'RFQ MD14.1', quote_line_total: 2, route_decided_total: 1, customer_name: 'Itelma', summary: '两条底价齐,交定价' },
],
},
expect: { ports: ['draft'] },
},
{
/*
* ② 有超限条目:客户把目标价压到成本之下(0.06 对成本 0.082)——
* 偏离底价线超过两成,门禁判 BLOCK,必须叫人拍板(决策卡)。
*/
name: '客户压价 · 一条低于底价线两成以上 → 决策卡叫人拍板',
input: {
rfqRef: 'RFQ-2026-0777',
customerCode: 'CU-0311',
customer_name: '青岛海洋电子',
rows: [
{
rfq_ref: 'RFQ-2026-0777',
pn: 'PESD2ETH-XR',
category: '二极管',
brand: 'Nexperia',
eau: 30000,
target_price: 0.06,
lead_time: '8周',
type_tag: 'B',
base_price: 0.082,
price_basis: 'SOURCED',
remark: '客户明说这个价是竞对给的,要跟',
},
{
rfq_ref: 'RFQ-2026-0777',
pn: 'NE555P',
category: '定时器',
brand: 'TI',
eau: 5000,
target_price: 1.05,
lead_time: '4周',
type_tag: 'A',
base_price: 0.85,
price_basis: 'HIST',
},
{ rfq_ref: 'RFQ-2026-0777', quote_line_total: 2, route_decided_total: 1, customer_name: '青岛海洋电子' },
],
},
expect: { ports: ['draft'] },
},
{
/*
* ③ 单飞宽进:没有单号没有清单,正文列料 —— 归一造案号;库内查不到历史价,
* 系统一个价都不编,两行拟报单价空着交 BD 定 → **提交闸真拦**空手交卷。
*/
name: '单飞需求 · 正文列两颗料 · 库内无基准 → 价由人定(闸真拦)',
input: {
subject: '两颗料报个价',
body: '客户要两颗料的报价:XCJ-7788T 电源模块 8K,目标价 12.5;LMR-3302QQ 稳压器 15K,没给目标价。库里应该没做过这两颗,你先出个草案。',
},
expect: { ports: ['draft'] },
},
],
+23 -13
View File
@@ -1,5 +1,8 @@
/**
* A4 技能 —— 定价的方法经验(AM 08)。载方法,不载权力。
* A4 技能 —— 定价的方法经验。载方法,不载权力。
*
* 与剧本的分工:剧本写「这一步做什么、写哪几列」,技能写「为什么这么定、
* 拿不准时按哪条走」。剧本改了口径,技能里同一句要跟着改(规范 §5.9)。
*/
import { skillIdSchema } from '@contracts';
import type { Skill } from '@contracts';
@@ -8,34 +11,41 @@ export const PRICING_SKILLS: Skill[] = [
{
id: skillIdSchema.parse('pricing.pick-basis'),
label: '选对价格基准',
applicability: '定价开始,需要确定「跟谁比」时。',
applicability: '定价开始,需要确定「这个价跟谁比」时。',
method: [
'基准的优先级:① 本客户近期成交价 → ② 同型号他客户成交价 → ③ 供应商底价表 → ④ 无基准。',
'基准的优先级:① 上游寻源问回来的货源底价 → ② 本客户近期成交价 → ③ 同型号他客户成交价 → ④ 无基准。',
'越靠前越贴近这一单的真实议价空间;越靠后越要留余量。',
'',
'**无基准不是异常**,是一种基准:新料号、久未报价的老料号都会落到这里。',
'此时偏离率记 0(没有可偏离的对象),由底价门禁按「无基准」放行 —— ',
'此时成本基准、底价线、偏离度**一律留空**,拟报单价交给人定 ——',
'不要拿一个不相干的价去凑一个偏离率出来,那比没有基准更危险:它看起来像有依据。',
'',
'基准超一周未更新的先刷再判:拿过期成本算出来的毛利,数字是真的,结论是假的。',
].join('\n'),
crystallized: [{ tool: 'crm.history-price-read', input: { pn: 'pn' } }],
knowledgeRefs: ['利润模型 P-01'],
version: '1.0.0',
version: '2.0.0',
},
{
id: skillIdSchema.parse('pricing.read-gate'),
label: '读懂底价门禁的处置',
applicability: '底价门禁(P-02)给出处置结论,需要决定下一步时。',
applicability: '底价门禁(P-02)给出处置码,需要决定下一步时。',
method: [
'门禁不是「拦下」而是**分流**:',
'· 直接放行 —— 偏离在容许带内,照常提审。',
'· 超限待裁 —— 偏离超 ±20%,交人决断:回调价格,还是注明理由硬报。',
' 两条路都合法,但都要留下**理由**:这条留痕将来会成为客户等级与利润模型的迭代素材。',
'门禁不是「拦下」而是**分流**,三档各有各的下一步:',
'· PASS(在底价线之上)—— 照常提审,不必解释。',
'· NOTE(低于底价线两成以内)—— 附理由放行:让价的缘由写进备注,',
' 理由随草案一起交审批。不写理由的让价,到审批那一关同样过不去。',
'· BLOCK(低于两成以上)—— 系统不替任何人放行,交人拍板:回调到底价线、',
' 破线报并留理由、或这一条本轮不报。三条路都合法,都要留痕。',
'',
'不要为了让门禁「过」而回头改毛利参数 —— 那是把判据改成结论,',
'决策表就失去了它唯一的价值:同输入必同结论。',
'两条别做的事:',
'· 不要为了让门禁「过」回头改毛利档或改基准 —— 那是把判据改成结论,',
' 决策表就失去了它唯一的价值:同输入必同结论;',
'· 不要每一条都叫人。BD 自己能回调掉的,回调掉就是处置 ——',
' 每条都叫人,人就成了橡皮图章;一条都不叫,门禁就是摆设。',
].join('\n'),
crystallized: [],
knowledgeRefs: ['底价门禁 P-02'],
version: '1.0.0',
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>`;
+565 -178
View File
@@ -1,192 +1,579 @@
/**
* A4 成本定价的 HTML 报告模版 —— **活动自己持有的资产**(§10.3),随包版本冻结。
* A4 成本定价的 HTML 报告模版 —— **活动自己持有的资产**,随包版本冻结。
*
* 定价这件事最需要「把判据摊开」:**人签的不是一个数字,是这个数字的来路**。
* 所以三张屏不按「概览 / 明细 / 结论」排,按**一个价是怎么长出来的**排:
* ① 取价基准 —— 跟谁比。基准取错,后面每一条偏离率都是假的,而假偏离率长得跟真的一样;
* ② 定价与底价门禁 —— 凭什么是这个价。两张决策表的规则原样摆出来,人能逐条倒着核;
* ③ 报价草案待放行 —— 签字前最后一次能改的机会,门后的都算数了。
* 两个局面各一张(与 A2/A3 同款口径):
* · `pricing-worksheet` **定价工作单** —— 算价与 BD 核价期间摆它:逐颗料一张定价档案卡
* (题面在左、定价推演在右)、利润模型 P-01 的毛利档、底价门禁 P-02 的三档尺、
* 填回工作表就长在同一屏;
* · `pricing-roster` **报价草案清单** —— 超限拍板、放行与归档摆它:整份草案只读,
* 门禁分布与落地动作在上面。
*
* 一个局面一张,局面变了让 Agent 用 `view` 换,不在一张里堆条件分支。
* 容器是 `sandbox=""` iframe:无脚本、无外链、样式自带;数据由基座推送时填
* (`data-slot` 取值、`data-rows` 逐行展开)。
* 「1:1」的落点是**每份件与它的办结快照**(在办可编辑 ↔ 回执收下那一刻的 finalHtml),
* 不是全程只许一张:作业的件与收口的件是两件工作两份件(规范姊妹篇 §1)。
*
* 两条贯穿全包的呈现纪律:
* · **状态章一律用「在办」灯,不用「已办」印** —— 模版是随包冻结的静态字符串,
* 它盖不了一个还没发生的章。三张屏都是放行前的局面,盖「已通过」就是撒谎;
* · **`verdict` 的橙档专指「还没发生,等你按」** —— 绿只留给已达标的机检结论。
* 放行后果是预告不是既成事实,一律走橙档。
* 版面语言沿用 A1 从 PRD 目标件机械抽取的那套(`prd-style.ts` 原样拷入)——
* A4 没有自己的 PRD 目标件,与 A2/A3 同一处置:沿用家族版面自造。
*
* 取值三纪律:顶层只取汇总列与单值列;逐条走 `mine:quote_pricing.draft`;
* 列名逐字照 `declarations.ledger` 抄。**模版不算不判** —— 缺价红带、门禁徽、分布条
* 全部由基座算好的数经 `data-class-of` 投影,判断留在样式表。
*/
import { noteOnly, section, stagesOnly, statsBand, tableOnly, verdict, viewShell } from '@sdk';
import { PRD_CSS, PRD_SPRITE } from './prd-style.ts';
/**
* 静态阶梯 —— **规则本身不是数据**:P-01 的毛利档、P-02 的三档处置随包冻结,
* 它们不该走 `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 的 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);
}
}
/** 局面一:S1 算价刚开场 —— 人此刻要核的是「这一单跟谁比、落在哪一档」 */
export const PRICING_BASIS = viewShell({
title: '取价基准',
kicker: 'A4 · 成本定价 · S1 算价',
tone: 'brand',
icon: 'coins',
stamp: { kind: 'live', text: '算价中' },
sign: 'A4 成本定价 · 取价基准(本单草稿)',
/* 序号由 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; }
}
/* 就地改:可改格子渲了只读与输入两份,默认锁着,表上加 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-gbadge { 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-pass { background: var(--ok); }
.proto-root .jc-segmeter > i.v-note { background: var(--warn-accent); }
.proto-root .jc-segmeter > i.v-block { background: var(--danger); }
/* 门禁徽:直接放行绿 / 附理由橙 / 拦截红 —— 值(PASS/NOTE/BLOCK)是机器码,人读名交给样式表 */
.proto-root .jc-gbadge {
display: inline-flex; align-items: center; justify-content: center;
min-width: 56px; 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-gbadge::after { content: "待判"; }
.proto-root .jc-gbadge.v-pass { color: var(--ok); border-color: var(--ok); }
.proto-root .jc-gbadge.v-pass::after { content: "直接放行"; }
.proto-root .jc-gbadge.v-note { color: var(--warn-accent); border-color: var(--warn-accent); }
.proto-root .jc-gbadge.v-note::after { content: "附理由"; }
.proto-root .jc-gbadge.v-block { color: var(--danger); border-color: var(--danger); }
.proto-root .jc-gbadge.v-block::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.k { background: var(--danger); }
.proto-root .jc-distbar > i.p::before { content: "直接放行 · "; }
.proto-root .jc-distbar > i.n::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; }
/* ── 定价档案卡(工作单独有,照 A3 询价档案卡的骨架)──────────────────────
一颗料一张卡,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-note { border-color: #f0cf9a; box-shadow: 0 1px 0 rgba(191,132,32,.06); }
.proto-root .jc-selcard.v-block { border-color: #f3b6b3; box-shadow: 0 1px 0 rgba(207,64,58,.08); }
.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-block .jc-selcard-h { background: var(--danger-soft); border-bottom-color: #f3b6b3; }
.proto-root .jc-selcard.v-note .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: 74px; 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.gp { color: var(--ok); border-color: var(--ok); }
.proto-root .jc-band.gn { color: var(--warn-accent); border-color: var(--warn-accent); }
.proto-root .jc-band.gk { color: var(--danger); border-color: var(--danger); }
/* 一个价怎么长出来的:成本 → 底价线 → 拟报价,内联线稿 SVG 走位图 */
.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; } }
`;
/** 一枚图标 —— 与原型同一写法 */
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_pricing_draft_missing"></b><span>/ <b data-slot="quote_pricing_draft_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_pricing.draft">
<i data-class-of="disposition"></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>成本 × 利润模型 → 底价线</span></span>
<span class="jc-fcard-note">基准取上游底价或库内历史价;毛利档由 P-01 定;偏离底价线两成即止(P-02)</span>
</div>
</div>`;
/** 一个价是怎么长出来的 —— 走位图(形状写死,不算不判) */
const PRICE_LINE = `<div class="jc-priceline">
<div class="jc-plnode"><b>成本基准</b><span>上游底价 / 库内历史价 —— 取不到就空着,不编</span></div>
<div class="jc-plarrow">${ico('arrow-right', 18)}</div>
<div class="jc-plnode"><b>毛利档 P-01</b><span>客户等级 × 类别 × 量档 → 一段区间</span></div>
<div class="jc-plarrow">${ico('arrow-right', 18)}</div>
<div class="jc-plnode"><b>底价线</b><span>成本 ×(1 + 档下限)—— 比它低就要有人负责</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>门禁 P-02</b><span>偏离底价线:≥0 放行 / 两成内附理由 / 超两成拍板</span></div>
</div>`;
/** 缺价红带 —— 与提交闸、放行门 L1 同一个数同一套算法(基座现数);一格不缺自动收 */
const GAP_BAR = `<div class="jc-gapbar" data-class-of="quote_pricing_draft_missing">${ico('alert', 14)}
<span><b>还差 <span data-slot="quote_pricing_draft_missing"></span> 行拟报单价没定</b>
—— <span data-slot="quote_pricing_draft_missing_keys"></span>。
按右上角「编辑」开表,红框那几格就是要填的:认可系统建议价就照抄那个数,不认可就改成你的;
库内查不到成本的那几行没有建议价,由你按经验定并在备注写明依据。补齐了执行单才提交得了。</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>。</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` 只挂人可改的两列(quoted_price / price_note)+ 主键 pn ——
* 编辑面精确等于 BD 的职权面;**主键格必挂 `data-col`**(它不是编辑面,是行的钥匙:
* 不挂,服务端按「全表一致列」补键,两行会对到同一行上,后者盖前者 —— A2 走查逮过)。
* 拟报单价挂 `req`:空着出「必填」胶囊、整行标底,与提交闸、放行门同源(都数 `_missing`)。
*/
const DRAFT_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:78px">成本基准</th>
<th style="min-width:86px">毛利档</th>
<th style="min-width:78px">底价线</th>
<th style="min-width:78px">客户目标价</th>
<th style="min-width:82px">系统建议价</th>
<th class="req" style="min-width:96px">拟报单价(你定)</th>
<th style="min-width:70px">毛利率</th>
<th style="min-width:70px">偏离度</th>
<th style="width:78px;min-width:78px">门禁</th>
<th style="min-width:150px;width:100%">备注 / 让价理由</th>
</tr></thead>
<tbody data-rows="mine:quote_pricing.draft" data-edit="quote_pricing.draft"><tr>
<td class="jc-rn"><button type="button" class="jc-rowdel" data-edit-del="quote_pricing.draft" 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 num" data-slot="base_price" data-blank></span></td>
<td class="dimempty" data-h="毛利档"><span class="jc-vcell" data-slot="margin_band" data-blank></span></td>
<td class="dimempty" data-h="底价线"><span class="jc-vcell num" data-slot="floor_price" data-blank></span></td>
<td class="sug" data-h="客户目标价"><span class="jc-vcell num" data-slot="target_price" data-blank></span></td>
<td class="dimempty" data-h="系统建议价"><span class="jc-vcell num" data-slot="suggested_price" data-blank></span></td>
<td class="req" data-h="拟报单价(你定)"><span class="jc-vcell num" data-slot="quoted_price" data-col="quoted_price" data-blank></span></td>
<td class="dimempty" data-h="毛利率"><span class="jc-vcell num" data-slot="margin" data-blank></span></td>
<td class="dimempty" data-h="偏离度"><span class="jc-vcell num" data-slot="deviation" data-blank></span></td>
<td data-h="门禁"><span class="jc-gbadge" data-class-of="disposition"></span></td>
<td class="opt" data-h="备注 / 让价理由"><span class="jc-vcell" data-slot="price_note" data-col="price_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_pricing_draft_disposition_pass" data-slot="quote_pricing_draft_disposition_pass"></i>
<i class="n" data-class-of="quote_pricing_draft_disposition_note" data-slot="quote_pricing_draft_disposition_note"></i>
<i class="k" data-class-of="quote_pricing_draft_disposition_block" data-slot="quote_pricing_draft_disposition_block"></i>
</div>
<div class="jc-distlg">
<span><em class="a"></em>直接放行 · 在底价线之上 <b data-slot="quote_pricing_draft_disposition_pass"></b></span>
<span><em class="b"></em>附理由放行 · 低于两成内 <b data-slot="quote_pricing_draft_disposition_note"></b></span>
<span><em class="c"></em>拦截待拍板 · 低于两成以上 <b data-slot="quote_pricing_draft_disposition_block"></b></span>
</div>
</div>`;
/* ==================== 局面一 · 定价工作单(算价与 BD 核价期间)==================== */
/**
* 逐颗定价档案 —— 一颗料一张卡(data-rows 逐行复制):左题面、右推演。
*
* **容器用 `<section>` 不用 `<div>`**:渲染器的行展开按第一个同名闭合标签截行内容,
* 行模版里嵌着 `<div>` 时 `<div data-rows>` 会把行截成套娃(A2 逮过,记在陷阱表)。
*/
const PRICE_CARDS = `<section class="jc-selcards" data-rows="mine:quote_pricing.draft">
<article class="jc-selcard" data-class-of="disposition">
<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-gbadge" data-class-of="disposition"></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="brand" data-blank></span></div>
<div class="jc-kvrow"><i>年用量</i><span data-slot="eau" data-blank></span></div>
<div class="jc-kvrow"><i>客户目标价</i><span class="sc-sug num" data-slot="target_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>客户约束</i><span data-slot="remark" data-blank></span></div>
</div>
<div class="jc-selcol">
<h5>定价推演(填回下表,这里回显)</h5>
<div class="jc-kvrow"><i>成本基准</i><span class="num" data-slot="base_price" data-blank></span></div>
<div class="jc-kvrow"><i>毛利档</i><span data-slot="margin_band" data-blank></span></div>
<div class="jc-kvrow"><i>底价线</i><span class="num" data-slot="floor_price" data-blank></span></div>
<div class="jc-kvrow"><i>系统建议价</i><span class="num" data-slot="suggested_price" data-blank></span></div>
<div class="jc-kvrow"><i>拟报单价</i><span class="sc-req num" data-slot="quoted_price" data-blank></span></div>
<div class="jc-kvrow"><i>毛利率</i><span class="num" data-slot="margin" data-blank></span></div>
<div class="jc-kvrow"><i>偏离底价线</i><span class="num" data-slot="deviation" data-blank></span></div>
<div class="jc-kvrow"><i>让价理由</i><span class="sc-opt" data-slot="price_note" data-blank></span></div>
</div>
</div>
</article>
</section>`;
/** 利润模型 P-01 的毛利档 —— 静态规则,尺挂在墙上(素材:MVP 示例工作流 §1.2) */
const MARGIN_RULES = `<div class="jc-graderule">
<div class="jc-graderule-r">
<span class="jc-band ga">8%~10%</span>
<p><b>战略客户 · A 类标准件 · 大批量。</b>量换价,毛利让到下限也要守住 —— 这一档的价不是拿来谈的,
是拿来锁长单的。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gb">12%~15%</span>
<p><b>普通客户 · A 类标准件。</b>常规档,也是拿不准时的兜底档 —— 行情透明的料按这一档报不吃亏。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gc">15%~20%</span>
<p><b>B 类波动件。</b>货源与行情都会动,毛利要留出被动让价的余地。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gc">20%~50%</span>
<p><b>C 类替代 / 停产件。</b>一事一议:找料本身就是成本,替代还带验证风险 ——
报低了后面每一次补货都在亏。</p>
</div>
</div>`;
/** 底价门禁 P-02 的三档尺 —— 静态规则(素材:MVP 示例工作流 §1.2 底价自检) */
const GATE_RULES = `<div class="jc-graderule">
<div class="jc-graderule-r">
<span class="jc-band gp">≥ 底价线</span>
<p><b>直接放行。</b>报价在底价线之上,毛利还在档内 —— 不必解释,提审即可。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gn">低于两成内</span>
<p><b>附理由放行。</b>为拿单让到底价线以下两成以内:在备注里写清让价的缘由
(客户长单、带单品、竞对压价)—— 理由随草案一起交审批,不写等于没有依据。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gk">低于两成以上</span>
<p><b>拦截,要人拍板。</b>这一档系统不替任何人放行:要么回调到底价线,要么由负责人
拍板破线并留下理由,要么这一条本轮不报。<b>私自绕开门禁不算处置</b>。</p>
</div>
</div>`;
export const PRICING_WORKSHEET = doc({
badge: 'sheet',
kicker: '统一报价 · A4 成本定价 · 定价作业',
title: '定价工作单',
titleRef: 'rfq_ref',
state:
'<span class="jc-rp-wait warn">待定价 <b data-slot="quote_pricing_draft_missing"></b> / <b data-slot="quote_pricing_draft_rows"></b> 条</span>',
body:
/*
* 指标带摆的是**利润模型 P-01 的三个入参 + 这一单的工作量**,不是所有字段。
* 三个档位键取的是清单里 P-01 声明的决策入参名(customerGrade / materialType /
* volumeTier)—— 决策表照这三个名字取值,模版就照这三个名字摆,不另起炉灶。
* `records` 是起单入参的数组,基座渲染数组时给的是条数,正好是「这一单要定几条价」。
*/
statsBand([
['待定价条目', 'records'],
['客户等级', 'customerGrade'],
['料号类型', 'materialType'],
['数量档', 'volumeTier'],
]) +
section('基准从哪来 —— 四级优先级',
ladder([
['1', '本客户近期成交价', '最贴近这一单的真实议价空间'],
['2', '同型号他客户成交价', '价位可比,但议价空间要留余量'],
['3', '供应商底价表', '成本侧的锚,不含客户侧信息'],
['4', '无基准', '新料号、久未报价的老料号都会落到这里'],
]) +
noteOnly(
'<b>无基准不是异常,它本身就是一种基准。</b>此时偏离率记 0 —— 没有可偏离的对象,' +
'由底价门禁按「无基准」处置。不要拿一个不相干的价去凑一个偏离率出来:' +
'那比没有基准更危险,因为它<b>看起来像有依据</b>。',
),
{ text: '越靠前越贴近实情' },
) +
section('本单待定价条目',
tableOnly('mine:quote_pricing.draft', [
['型号 PN', 'pn', 'mono'],
['基准价', 'last_price'],
['拟报单价', 'quoted_price'],
['毛利率', 'margin'],
]),
{ text: '只列本单写下的行' },
) +
section('案例当下进程', stagesOnly('case:stages'), { text: 'Enabled ≠ Active' }),
part('定价任务', '客户 <b data-slot="customer_name" data-blank></b> · 共 <b data-slot="line_total"></b> 条 · 有基准价 <b data-slot="basis_total"></b> 条') +
`<div class="jc-derive">${ico('info', 14)}
<span>这一屏是一份报价草案的作业台:系统按「成本基准 × 利润模型」给出底价线与
<b>建议价</b>,<b>拟报单价那一列由你落笔</b> —— 人签的就是这个数,系统不代签。
库内查不到成本的料连建议价都没有(不编),那几行按你的经验定并在备注写明依据。
你定完价,毛利、偏离度与底价门禁的结论跟着重算。</span>
</div>` +
FOUR +
part('一个价是怎么长出来的', '成本 → 毛利档 → 底价线 → 拟报价 → 门禁') +
PRICE_LINE +
part('逐颗定价档案', '题面在左 · 推演在右') +
PRICE_CARDS +
part('利润模型 P-01 的毛利档', '客户等级 × 类别 × 量档 —— 尺挂在墙上') +
MARGIN_RULES +
part('底价门禁 P-02', '偏离底价线两成即止') +
GATE_RULES +
part('填回工作表', '定价落在这 —— 补齐了执行单才提交得了') +
GAP_BAR +
editGate('quote_pricing.draft', '报价草案填回表') +
DRAFT_TABLE +
addRowBar('quote_pricing.draft') +
`<div class="jc-drop">${ico('info', 13)}
<span>带红标的<b>拟报单价</b>是必填,而且<b>只由你填</b> —— 缺一行执行单按不下提交,
与放行门是同一个数同一套算法。系统给的是建议价:照抄即认可,改了以你的为准。
查不到成本的料没有建议价(系统不编):按你的经验定一个价并在备注写明依据,
或者按行首 × 把这一条撤出本轮。压在拦截档(红)的条目在你这儿往上调一点,
就不必再惊动第二个人。</span>
</div>`,
foot: sign('BD 核价 · AI 按利润模型与底价门禁算价,你定最后那个数', '统一报价 · A4 成本定价 · 在办'),
});
/** 局面二:S2 自检 —— 人此刻要核的是「凭什么是这个价、门禁怎么判的」 */
export const PRICING_PRICED = viewShell({
title: '定价与底价门禁',
kicker: 'A4 · 成本定价 · S2 自检',
tone: 'brand',
icon: 'gauge',
stamp: { kind: 'live', text: '自检中' },
sign: 'A4 成本定价 · 定价自检(判定表 P-01 / P-02)',
body:
/* 标签一律照台账声明的列名(基准价 / 拟报单价 / 毛利率 / 偏离度 / 门禁处置):
同一个数在指标带上叫一个名、在下面的表里叫另一个名,签字的人就得先花一秒确认它俩是不是一回事 */
statsBand([
['基准价', 'last_price'],
['拟报单价', 'quoted_price'],
['毛利率', 'margin'],
['偏离度', 'deviation'],
]) +
section('逐条定价结论',
tableOnly('mine:quote_pricing.draft', [
['型号 PN', 'pn', 'mono'],
['基准价', 'last_price'],
['拟报单价', 'quoted_price'],
['毛利率', 'margin'],
['偏离度', 'deviation'],
['门禁处置', 'disposition'],
]),
{ text: '判据在左 · 结论在右' },
) +
section('目标毛利怎么定的 —— 利润模型 P-01',
ladder([
['1', '战略客户 · A 类标准件 · 大批量', '目标毛利 8%–10%'],
['2', '普通客户 · A 类标准件', '12%–15%'],
['3', 'B 类波动件', '15%–20%'],
['4', 'C 类替代 / EOL', '≥20%,或一事一议'],
['5', '以上都不命中', '兜底按普通客户标准件 12%–15%'],
]) +
noteOnly(
'自上而下取<b>第一条命中</b>的规则,同输入必得同结论、零方差零 token,命中的规则行随出参留痕。' +
'不要为了让门禁「过」而回头改毛利参数 —— 那是把判据改成结论,决策表也就失去了它唯一的价值。',
),
{ text: '首条命中' },
) +
section('偏离怎么判的 —— 底价门禁 P-02',
ladder([
['1', '拟报价 ≥ 底价', '直接放行'],
['2', '低于底价 ≤ 20%', 'PM 附理由放行'],
['3', '低于底价 超 20%', '拦截 · BOSS 拍板'],
]) +
noteOnly(
'门禁不是「拦下」而是<b>分流</b>:超限的一律调人 —— 要么回调价格,要么写明为什么这样报,' +
'两条路都合法,但都要留下理由。这条留痕将来会成为客户等级与利润模型的迭代素材。',
),
{ text: '±20% 即止' },
) +
section('本单门禁结论',
/*
* 结论章里嵌 `data-slot`:处置与偏离都是**这一单算出来的事实**,
* 写死一句「已通过」就是替判定表下结论。橙档在此的意思是「还没定,等人处置」。
*/
verdict('底价门禁 P-02 处置:<b data-slot="disposition"></b>', 'wait', '偏离度 <b data-slot="deviation"></b>') +
noteOnly(
'禁区两条随本单同行:<b>不得静默绕开 P-02 的结论</b> —— 要破线只能走「人 · 决策」;' +
'<b>底价超一周未更新的,先刷底价再判</b> —— 拿旧底价判新行情等于没判。',
),
),
});
/* ==================== 局面二 · 报价草案清单(拍板 / 放行 / 归档)==================== */
/** 局面三:待放行 —— 验收单上人要签的那份成果报告 */
export const PRICING_RELEASE = viewShell({
title: '报价草案 · 待放行',
kicker: 'A4 · 成本定价 · 成果待签',
tone: 'case',
icon: 'shield-check',
stamp: { kind: 'live', text: '待签字放行' },
sign: 'A4 成本定价 · 报价草案(待放行)',
export const PRICING_ROSTER = doc({
badge: 'file-check',
kicker: '统一报价 · A4 成本定价 · 交付成果',
title: '报价草案',
titleRef: 'rfq_ref',
state:
'<span class="jc-rp-wait">草案 <b data-slot="quote_pricing_draft_rows"></b> 条 · 待拍板 <b data-slot="quote_pricing_draft_disposition_block"></b></span>',
body:
statsBand([
['拟报单价', 'quoted_price'],
['毛利率', 'margin'],
['偏离度', 'deviation'],
['门禁处置', 'disposition'],
]) +
section('本单交付:报价草案',
tableOnly('mine:quote_pricing.draft', [
['型号 PN', 'pn', 'mono'],
['基准价', 'last_price'],
['拟报单价', 'quoted_price'],
['毛利率', 'margin'],
['偏离度', 'deviation'],
['门禁处置', 'disposition'],
]) +
noteOnly('只列<b>本单</b>写下的行 —— 别单放行过的记录不混进来。逐条列出,便于放行前逐条核对。'),
{ text: '本单草稿' },
) +
section('放行前要过的三道判据',
ladder([
['1', '每条都有拟报单价与毛利率', 'L1 机检 · unpriced = 0'],
['2', '偏离底价 ≤ ±20%,超限者已回调或注明理由', 'L2 机检 · 底价门禁 P-02'],
['3', 'BD 放行', 'L5 人签 · 签了才算数'],
]),
{ text: '两机检 · 一人签' },
) +
section('放行之后会发生什么',
verdict('放行即落地:草稿合并入正式版,草案提交 A5 定价审批', 'wait', '审批点卡在 PM 工位') +
noteOnly(
'审批驳回按<b>声明</b>走向,不由临场决定:<b>回 A4 重开新实例</b> —— 新任务 ID、留痕关联,' +
'不复活旧任务。所以这一屏上还改得动的每一格,过了这道门就要花一整个新实例才改得回来。',
),
),
part('定价要素', '成本定价 · 能算的归判定表,超限的归人') +
FOUR +
part('底价门禁分布', '直接放行 + 附理由 + 拦截 = 总条数') +
DIST +
`<div class="jc-derive">${ico('info', 14)}
<span>三段条数由基座逐行现数,不是谁报上来的:红段一条不剩,这份草案才走得到放行门。
压在拦截档的条目只有三条出路 —— 回调到底价线、由负责人拍板破线并留理由、
或者本轮不报。</span>
</div>` +
part('报价草案', '<b data-slot="line_total"></b> 条 · 每条都带来路') +
GAP_BAR +
editGate('quote_pricing.draft', '报价草案') +
DRAFT_TABLE +
`<div class="jc-drop">${ico('info', 13)}
<span>每一行摆的是这个价的<b>来路</b>:成本基准从哪来、毛利档是哪一档、底价线在哪、
偏离多少、门禁怎么判。签字前请重点看两处:<b>门禁列</b>里非绿的条目,
与<b>备注列</b>里的让价理由 —— 没有理由的让价,到了审批那一关同样过不去。</span>
</div>` +
part('放行后即执行的落地动作', '门前 → 门后') +
`<div class="jc-acts">
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>草稿层合并进正式版</b>&#12288;<span>本任务写下的报价草案与定价结论一并落账;
你就地改过的那几个价随之落定。</span></div>
</div>
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>草案此刻才对外发出</b>&#12288;<span>整份 <b data-slot="quote_pricing_draft_rows"></b> 条
(每条带拟报单价与门禁处置)交出 —— 放行之前,世界上什么都没有发生。</span></div>
</div>
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>转入定价审批</b>&#12288;<span>本活动只有一个出口 —— 报价草案是审批环节的直接输入,
已进对方的任务清单。</span></div>
</div>
</div>`,
foot: sign('BD 定价 · AI 按判定表算价并留痕 —— 要改哪一格,在这张单的草案表上就地改', '统一报价 · A4 成本定价 → 交审批'),
});
@@ -0,0 +1,313 @@
/**
* A3 寻源询价的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。
* 案例活动没有 `run`,自测不跑回合;测的是清单本身与三处对齐
* (判据谓词 / 出口载荷 / 剧本声明)。
*/
import { describe, expect, it } from 'vitest';
import { activityManifestSchema } from '@contracts';
import { evaluateCondition } from '@sfeel';
import { createMockHost } from '@sdk';
import { manifest } from '../src/manifest.ts';
import activity from '../src/index.ts';
describe('清单本身合规', () => {
it('过 @contracts 的 schema', () => {
expect(() => activityManifestSchema.parse(manifest)).not.toThrow();
});
it('监听时限已配 —— 无悬空监听(AM 4.4)', () => {
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();
}
});
/*
* MVP 口径:A3 **没有判定表**。上一版的 STRATEGY_DMN 声明着三个入参而台账没有承载、
* 剧本零调用。取价规则(A 取历史价 / B 问货源价)写死在剧本。
*/
it('没有声明无求值器的判定表 —— 取价规则写死在剧本', () => {
expect(manifest.assets.businessModel.dmn.tables).toEqual([]);
const normalize = manifest.assets.businessModel.steps?.find((s) => s.item === 'S1_normalize');
expect(normalize?.guide).toContain('HIST');
});
/*
* **底价是必填** —— 这是 A3 的门面判据:件上的缺价红带、执行卡的提交闸、
* 放行门 L1 三处同源(都数基座的 `_missing`)。声明丢了 required,三处一起失明。
*/
it('底价标了 required —— 红带 / 提交闸 / 放行门三处同源的那个数才存在', () => {
const quoteList = manifest.declarations.ledger.find((d) => d.object === 'quote_list');
expect(quoteList?.fields.find((f) => f.name === 'base_price')?.required).toBe(true);
});
});
describe('案例模型 —— 双表示同版本', () => {
const model = manifest.assets.businessModel;
const cmmn = parseCmmnLite(model.cmmnXml);
it('CMMN 与说明书同在,不允许分叉', () => {
expect(model.cmmnXml).toContain('<casePlanModel');
expect(model.narrative.length).toBeGreaterThan(400);
});
it('计划项照状态机一步一项:归一预填 + 补价 + 汇稿 + 放行,不多建一件', () => {
expect(items(model.cmmnXml)).toEqual(['S1_normalize', 'S2_fill', 'S3_compile', 'T_release']);
});
it('两处人的门都标了人工激活:PRM 补价 · 清单放行', () => {
for (const id of ['S2_fill', 'T_release']) {
const block = model.cmmnXml.slice(model.cmmnXml.indexOf(`id="${id}"`));
expect(block.slice(0, block.indexOf('</humanTask>')), `${id} 少了 manualActivationRule`).toContain(
'<manualActivationRule',
);
}
});
it('不用阶段 / 里程碑 / 裁量项', () => {
for (const tag of ['<stage', '<milestone', '<planningTable', '<discretionaryItem']) {
expect(model.cmmnXml, `不该出现 ${tag}`).not.toContain(tag);
}
});
it('常来回的项标了可重复 —— 归一会随剔行改数重入', () => {
expect(cmmn.repetition).toContain('S1_normalize');
});
it('放行门哨兵带 ifPart:每行有价 + 价源守恒 + 定盘记过,全读基座数', () => {
const condition = sentryIf(model.cmmnXml, 's_ready');
expect(condition).toContain('quote_sourcing_quote_list_missing = 0');
expect(condition).toContain('quote_sourcing_quote_list_price_basis_hist + quote_sourcing_quote_list_price_basis_sourced');
const done = {
route_decided_total: 1,
quote_sourcing_quote_list_rows: 3,
quote_sourcing_quote_list_missing: 0,
quote_sourcing_quote_list_price_basis_hist: 1,
quote_sourcing_quote_list_price_basis_sourced: 2,
};
expect(safeEval(condition, done)).toBe(true);
// 还有一行缺底价 → 门不开
expect(safeEval(condition, { ...done, quote_sourcing_quote_list_missing: 1 })).toBe(false);
// 价源没记齐(守恒不成立)→ 门不开
expect(safeEval(condition, { ...done, quote_sourcing_quote_list_price_basis_sourced: 1 })).toBe(false);
});
it('每个计划项都有剧本', () => {
const ids = items(model.cmmnXml);
expect((model.steps ?? []).map((s) => s.item)).toEqual(ids);
for (const step of model.steps ?? []) {
expect(step.guide.length, `${step.item} 的做法`).toBeGreaterThan(80);
expect(step.intent.length, `${step.item} 的意图`).toBeGreaterThan(6);
}
});
it('说明书不点名计划项', () => {
const named = [...model.narrative.matchAll(/\b(?:S\d+_[A-Za-z]+|T_[a-z]+)\b/g)].map((m) => m[0]);
const ids = new Set(items(model.cmmnXml));
for (const name of named) {
expect(ids.has(name), `说明书提到的 ${name} 不在案例模型里`).toBe(true);
}
});
it('说明书只讲整单的事:成品、怎么说话、怎么收口', () => {
for (const topic of ['开案', '要说话', '一拍连做', '结案']) {
expect(model.narrative, topic).toContain(topic);
}
});
});
describe('声明与契约对得上', () => {
const columns = new Set(
manifest.declarations.ledger.flatMap((d) => [`${d.domain}.${d.object}`, ...d.fields.map((f) => f.name)]),
);
it('必交付物的台账地址都声明过', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(columns.has(d.ledger ?? ''), `${d.key} → ${d.ledger}`).toBe(true);
}
});
it('editableAtRelease 的列都在成果表的声明里', () => {
const quoteList = manifest.declarations.ledger.find((d) => d.object === 'quote_list');
const names = new Set((quoteList?.fields ?? []).map((f) => f.name));
for (const d of manifest.contract.requiredDeliverables) {
for (const col of d.editableAtRelease ?? []) {
expect(names.has(col), `editableAtRelease 里的 ${col}`).toBe(true);
}
}
});
const rowReadings = new Set([
...manifest.declarations.ledger.map((d) => `${d.domain}_${d.object}_rows`),
...manifest.declarations.ledger
.filter((d) => d.fields.some((f) => f.required === true))
.map((d) => `${d.domain}_${d.object}_missing`),
...manifest.declarations.ledger.flatMap((d) =>
d.fields.flatMap((f) =>
(f.tally ?? []).map((v) => `${d.domain}_${d.object}_${f.name}_${v.toLowerCase().replace(/[^a-z0-9_]/g, '')}`),
),
),
]);
it('L1/L2 谓词读的名字都有承载(台账列或基座读数)', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level !== 'L1' && c.level !== 'L2') continue;
for (const identifier of (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? []) {
if (['and', 'or', 'not', 'true', 'false', 'null', 'in', 'between'].includes(identifier)) continue;
if (rowReadings.has(identifier)) continue;
expect(columns.has(identifier), `${c.key} 的谓词引用了 ${identifier}`).toBe(true);
}
}
});
it('出口载荷的标量字段都有台账列承载', () => {
const snake = (name: string) => name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
for (const port of manifest.contract.output.mandatory) {
const properties = (port.payload as { properties?: Record<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 ?? '');
});
});
describe('报告模版 —— 活动持有,容器无脚本执行权', () => {
it('两个局面各一张:询价工作单(干活)与目标报价清单(收口),同一局面在办与归档同一张', () => {
expect(manifest.assets.viewTemplates.map((t) => t.name)).toEqual(['sourcing-worksheet', 'sourcing-roster']);
});
/*
* 执行阶段的件必须是**询价工作台**,不是验收单(A2 同款口径,2026-08-02)。
* 工作单四要件:逐颗询价档案(题面含取价对象与客户约束)、按场景圈渠道的规则、
* 价格验证的尺、填回工作表 —— 素材出处 MVP 示例工作流 §1.7,只作文字参考。
*/
it('询价工作单是干活的件:档案卡 + 场景圈渠道 + 验价的尺 + 时限', () => {
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'sourcing-worksheet')?.html ?? '';
expect(sheet).toContain('jc-selcard');
expect(sheet).toContain('data-slot="remark"');
expect(sheet).toContain('取价对象');
for (const scene of ['BOM 早期', 'BOM 后期', '现货', '样品']) expect(sheet).toContain(scene);
expect(sheet).toContain('无库存转单商不可选');
expect(sheet).toContain('历史最低价降两成');
});
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>');
}
});
/*
* A3 只有一个出口(E7 无条件边),**没有去向要判** —— 件上不摆判定表,
* 也不许残留 `case:route*` 的空壳(没有选择组它永远是空的,一屏灰行像判定没跑)。
*/
it('单出口活动:没有选择组,件上也不摆去向判定', () => {
expect(manifest.contract.output.selective).toEqual([]);
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'sourcing-roster')?.html ?? '';
expect(roster).not.toContain('case:routeKey');
});
it('缺价红带与提交闸同源 —— 件上摆的是基座的 `_missing` 与主键名单', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'sourcing-roster')?.html ?? '';
expect(roster).toContain('quote_sourcing_quote_list_missing');
expect(roster).toContain('quote_sourcing_quote_list_missing_keys');
});
it('编辑面 = PRM 的价格五列 + 行的钥匙 pn —— 保存按格里的主键认行(A2 走查逮过同款)', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'sourcing-roster')?.html ?? '';
const cols = [...roster.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]);
expect(new Set(cols)).toEqual(new Set(['pn', 'supplier', 'base_price', 'moq', 'source_lead_time', 'note']));
});
it('价源分布读的是基座按值现数的两段 —— 守恒等式的可视形', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'sourcing-roster')?.html ?? '';
expect(roster).toContain('quote_sourcing_quote_list_price_basis_hist');
expect(roster).toContain('quote_sourcing_quote_list_price_basis_sourced');
});
it('宿主拒推含脚本的模版', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
});
});
const CONTRACT = {
activityId: 'quote-sourcing',
version: '1.0.0',
data: { rfqRef: 'RFQ MD14.1', rows: [] },
message: undefined,
outputPorts: { mandatory: ['quotation'], selective: [] },
prohibitions: [],
discretion: [],
} as const;
describe('代码入口 —— 案例活动没有 run,不是残包', () => {
it('默认导出是一个有名字的活动定义', () => {
expect(activity.name).toBe('quote-sourcing');
});
it('mock 宿主如实报「没有 run」,不静默返回一个假的 outcome', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.run(activity)).rejects.toThrow(/没有 run/);
});
});
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('&gt;', '>').replaceAll('&lt;', '<').replaceAll('&amp;', '&');
}
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
@@ -0,0 +1,223 @@
/**
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(A1 的账)。
*/
import { describe, expect, it } from 'vitest';
import { evaluateCondition } from '@sfeel';
import { CMMN_XML, SOURCING_PHASES, SOURCING_STEPS } from '../src/case-model.ts';
import { manifest } from '../src/manifest.ts';
const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map(
(m) => m[1] ?? '',
);
describe('剧本与案例模型对得上 —— 对不上的条目等于没写', () => {
it('每一条剧本都指向一个真实存在的计划项', () => {
for (const step of SOURCING_STEPS) {
expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
}
});
it('图上每一步都有剧本', () => {
for (const id of ITEM_IDS) {
expect(SOURCING_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
}
});
it('清单里挂的就是这份剧本(不是另抄一份)', () => {
expect(manifest.assets.businessModel.steps).toBe(SOURCING_STEPS);
});
});
describe('doneWhen 是「成果已存在」的判据,不是「该不该做」的判据', () => {
/** 开案那一刻的三种局面:选型转来 / 受理直达 / 单飞 —— 台账一行没有 */
const FRESH_ALT: Record<string, unknown> = {
rfqRef: 'RFQ MD14.1',
rows: [{ pn: 'BBPY00201209601Y00', type_tag: 'B', alt_pn: 'PBY201209T-601Y-N' }],
};
const FRESH_INTAKE: Record<string, unknown> = {
rfqRef: 'RFQ-2026-0555',
records: [{ pn: 'PESD2ETH-XR', type_tag: 'B' }],
};
const FRESH_SOLO: Record<string, unknown> = { subject: '两颗现货料问底价', body: 'SS8050 20K……' };
it('**开局一步都不该被自动收掉** —— 三种开局都要 false', () => {
for (const fresh of [FRESH_ALT, FRESH_INTAKE, FRESH_SOLO]) {
for (const step of SOURCING_STEPS) {
if (step.doneWhen === undefined) continue;
expect(
safeEval(step.doneWhen, fresh),
`${step.item} 的 doneWhen「${step.doneWhen}」开局就为真`,
).toBe(false);
}
}
});
/* ── ① 归一预填 ─────────────────────────────────────────────── */
it('整份落表、行数对上、预填走完 → 归一收得掉(没有 A 类时预填数是写过的 0)', () => {
expect(
safeEval(doneOf('S1_normalize'), {
quote_line_total: 3,
quote_sourcing_quote_list_rows: 3,
hist_priced_total: 1,
}),
).toBe(true);
expect(
safeEval(doneOf('S1_normalize'), {
quote_line_total: 2,
quote_sourcing_quote_list_rows: 2,
hist_priced_total: 0,
}),
).toBe(true);
});
it('**预填那一轮没走完 → 归一收不掉** —— hist_priced_total 没写过,未绑定比较为假', () => {
expect(
safeEval(doneOf('S1_normalize'), { quote_line_total: 3, quote_sourcing_quote_list_rows: 3 }),
).toBe(false);
});
it('少拆多写都收不掉 —— 自报条数与基座行数对不上', () => {
expect(
safeEval(doneOf('S1_normalize'), {
quote_line_total: 3,
quote_sourcing_quote_list_rows: 2,
hist_priced_total: 0,
}),
).toBe(false);
});
/* ── ② 交 PRM 补价:人环 ────────────────────────────────────── */
it('**PRM 那一步不给 doneWhen** —— 引擎不许替人交卷', () => {
expect(SOURCING_STEPS.find((s) => s.item === 'S2_fill')?.doneWhen).toBeUndefined();
});
it('补价这一步摆的是询价工作单(不是收口清单):卡上零输入、闸看必填、答复处理记 sourced_total', () => {
const fill = SOURCING_STEPS.find((s) => s.item === 'S2_fill');
expect(fill).toBeDefined();
expect(fill?.view).toBe('sourcing-worksheet');
/*
* **执行卡零输入**(2026-08-02 用户定:填写全去工作区)——
* 供应商与底价在工作单那份件上就地填,卡上不绑表;
* 必填(底价)由提交闸看住,闸回落到必交付物的台账声明。
* 从前这儿断言卡里带 `"ledger":"quote_sourcing.quote_list"` 的 blocks。
*/
expect(fill?.guide).toContain('卡上不摆表、不摆格子');
expect(fill?.guide).not.toContain('"blocks"');
expect(fill?.guide).toContain('提交闸');
expect(fill?.guide).toContain('sourced_total');
});
/* ── ③ 汇稿定盘:收步判据与验收判据同源 ─────────────────────── */
const compiled = (over: Record<string, unknown>) => ({
quote_sourcing_quote_list_rows: 3,
quote_sourcing_quote_list_missing: 0,
quote_sourcing_quote_list_price_basis_hist: 1,
quote_sourcing_quote_list_price_basis_sourced: 2,
route_decided_total: 1,
...over,
});
it('每行有价 + 价源守恒 + 定盘记过 → 汇稿收得掉', () => {
expect(safeEval(doneOf('S3_compile'), compiled({}))).toBe(true);
});
it('**还有一行缺底价 → 收不掉** —— 与提交闸、放行门读同一个数', () => {
expect(safeEval(doneOf('S3_compile'), compiled({ quote_sourcing_quote_list_missing: 1 }))).toBe(false);
});
it('**价源没记齐(守恒不成立)→ 收不掉** —— 松出来的那一截就是返工', () => {
expect(safeEval(doneOf('S3_compile'), compiled({ quote_sourcing_quote_list_price_basis_sourced: 1 }))).toBe(false);
});
it('没记过定盘数 → 收不掉 —— 未绑定的名字比大小为假', () => {
const scope: Record<string, unknown> = compiled({});
delete scope['route_decided_total'];
expect(safeEval(doneOf('S3_compile'), scope)).toBe(false);
});
it('**放行那一项不给 doneWhen** —— 终局唯放行', () => {
expect(SOURCING_STEPS.find((s) => s.item === 'T_release')?.doneWhen).toBeUndefined();
});
it('归一那一步声明了 reads: rows 与 records —— 上游载荷置前不截断', () => {
const reads = SOURCING_STEPS.find((s) => s.item === 'S1_normalize')?.reads ?? [];
expect(reads).toContain('rows');
expect(reads).toContain('records');
});
});
describe('剧本声明的东西活动真有', () => {
const declaredLedger = manifest.declarations.ledger.map((l) => `${l.domain}.${l.object}`);
const declaredTools = manifest.declarations.tools.map((t) => t.name);
it('每一步要写的台账都在清单的台账声明里', () => {
for (const step of SOURCING_STEPS) {
for (const address of step.writes) {
expect(declaredLedger, `${step.item} 要写 ${address}`).toContain(address);
}
}
});
it('每一步要调的工具都在清单的工具声明里', () => {
for (const step of SOURCING_STEPS) {
for (const tool of step.tools) {
expect(declaredTools, `${step.item} 要调 ${tool}`).toContain(tool);
}
}
});
it('每一步都摆同一份件(一单一模版贯穿始终),且模版真在包里', () => {
const names = new Set(manifest.assets.viewTemplates.map((t) => t.name));
for (const step of SOURCING_STEPS) {
expect(step.view, `${step.item} 没定画面`).toBeDefined();
expect(names.has(step.view ?? ''), `${step.item} 的 view「${step.view}」不在模版名单里`).toBe(true);
}
});
it('每一步都说得出它交出什么', () => {
for (const step of SOURCING_STEPS) {
expect(step.intent.trim(), `${step.item} 没写 intent`).not.toBe('');
expect(step.guide.trim().length, `${step.item} 的 guide 太短`).toBeGreaterThan(20);
}
});
});
function doneOf(item: string): string {
const found = SOURCING_STEPS.find((s) => s.item === item)?.doneWhen;
if (found === undefined) throw new Error(`${item} 没有 doneWhen`);
return found;
}
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
describe('阶段刻度:每一项都有格可待', () => {
const inPhase = new Set(SOURCING_PHASES.flatMap((p) => p.items));
it('**除放行外每一项恰好属于一个阶段**', () => {
const planned = SOURCING_STEPS.map((s) => s.item).filter((id) => id !== 'T_release');
expect(planned.filter((id) => !inPhase.has(id))).toEqual([]);
});
it('一项不进两个阶段', () => {
const all = SOURCING_PHASES.flatMap((p) => p.items);
expect(all.length).toBe(new Set(all).size);
});
it('**放行不在名单里** —— 收口那一格由基座统一加', () => {
expect(inPhase.has('T_release')).toBe(false);
});
it('阶段里不许出现案例模型没有的项', () => {
const ids = new Set(SOURCING_STEPS.map((s) => s.item));
expect([...inPhase].filter((id) => !ids.has(id))).toEqual([]);
});
});
+235 -85
View File
@@ -1,132 +1,282 @@
/**
* A3 渠道寻源的案例模型 —— **一张执行单办完**(2026-07-31 用户定,与 A2 同一口径)。
* A3 寻源询价的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
*
* 上一版按四种询价场景(BOM 早期 / BOM 后期 / 现货 / 样品)把流程铺开成一串计划项。
* 场景之分在业务上是真的,可它**不改变系统要管的事**:发函、收回价、比价、给结论。
* 而这四步在一次寻源里是连着做的 —— 采购不会办完「发函」就交回来等系统叫他。
* 把它切成四项,换来的只是 Agent 每一步都要跟 harness 对一次账。
* 业务出处:`docs/MVP示例工作流.html` §1.7,按 **MVP 简化口径**建模
* (2026-08-02 用户定:A3 与 A2 同为复杂活动,MVP 期间不展开大规模业务建模):
*
* 现在只有两项:
* T_source —— 派一张执行单给采购(卡面两张可编辑表:供应商回价 / 寻源比价),他填完提交;
* T_release —— 放行(触达世界,人工激活)。
* 上游(A2 的询价受理清单,或 A1 直达的 B 类受理记录)进来 → 归一成
* **目标报价清单**底表,A 类行按库内历史价预填基准 → **一张执行单**让采购(PRM)
* 在系统外完成询价、把 B 类行的货源价格填回来 → 汇稿定盘 → 放行,
* 整份目标报价清单交给成本定价。
*
* 场景之分不进模型,进**说明书**:采购自己知道 BOM 早期该广撒网、现货该问库存。
* 那是他的判断,不是系统的步。
* SOP 里接需核史 / 选渠定策 / 发询比价那几段的实质工作(圈渠道、发函、催回价、
* 验价)在 PRM 系统外;四种场景(BOM早期/后期/现货/样品)的尺度之分是他的判断,
* 不是系统的步。系统的本分:归一、预填、组卡、齐检、留痕。
*
* 上一版(@2.0.0)只有「派单 + 放行」两项,入参必填 `pn + scenario`(单料形状,
* 与上游发的整份清单对不上),两条 L2 判据读活动自报数。这一版整个置换。
*/
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
<case id="sourcing">
<!--
案卷项的 id **就是任务视图里的键名**。只认两样:
起单入参(pn)与「比价表写了几行」(compare_total)——
后者既是放行前提,也是这一单唯一的机检判据。
案卷项的 id **就是任务视图里的键名**。流内起单时上游载荷(rows / records)
与本活动台账列都会摊平到任务视图顶层,照它们命名。
-->
<caseFileModel>
<caseFileItem id="pn" name="型号(开案即有)"/>
<caseFileItem id="compare_total" name="比价表条数"/>
<caseFileItem id="rows" name="上游清单行(A2 载荷,流内开案即有)"/>
<caseFileItem id="records" name="上游受理记录(A1 直达时有)"/>
<caseFileItem id="quote_line_total" name="清单条目数(自报)"/>
<caseFileItem id="quote_sourcing_quote_list_rows" name="清单行数(基座现数)"/>
<caseFileItem id="pending_total" name="待补价条数"/>
<caseFileItem id="hist_priced_total" name="A 类已按历史价预填条数"/>
<caseFileItem id="sourced_total" name="PRM 已补价条数"/>
<caseFileItem id="quote_sourcing_quote_list_missing" name="缺基准价的行数(基座现数)"/>
<caseFileItem id="route_decided_total" name="已定盘"/>
</caseFileModel>
<casePlanModel id="plan" name="渠道寻源" autoComplete="true">
<sentry id="s_compared">
<onPart id="op_cp" caseFileItemRef="compare_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_cp"><condition>compare_total &gt; 0</condition></ifPart>
<casePlanModel id="plan" name="寻源询价" autoComplete="true">
<!--
① 归一并预填完了 —— 交 PRM 那一步的入场前提。
行数两边对上(自报 = 基座现数),且 A 类预填那一轮走完
(hist_priced_total 是预填后显式写下的数;没有 A 类就是 0,
「大于等于 0」对「写过的 0」为真、对「没写过」为假)。
-->
<sentry id="s_normalized">
<onPart id="op_nm" caseFileItemRef="hist_priced_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_nm"><condition>quote_line_total &gt; 0 and quote_sourcing_quote_list_rows = quote_line_total and hist_priced_total &gt;= 0</condition></ifPart>
</sentry>
<!--
② PRM 的答复处理过了 —— 汇稿那一步的入场前提。
sourced_total 是「人交回执行单之后」由 AI 清点写下的数(B 类行补上价的条数)——
只在人环走完才存在,它就是「经过人了」的凭据。
-->
<sentry id="s_sourced">
<onPart id="op_sc" caseFileItemRef="sourced_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_sc"><condition>sourced_total &gt;= 0</condition></ifPart>
</sentry>
<!--
── 放行门的入场条件:**认「办完了没有」,且读基座数**────────────────
· 每行都有基准价(quote_sourcing_quote_list_missing 由基座按 required 现数,
与执行卡的提交闸、验收 L1 同一个数同一套算法);
· 价源守恒:历史价 + 货源价 = 行数(两边都是基座按值现数);
· 定盘过了(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 &gt; 0 and quote_sourcing_quote_list_rows &gt; 0 and quote_sourcing_quote_list_missing = 0 and quote_sourcing_quote_list_price_basis_hist + quote_sourcing_quote_list_price_basis_sourced = quote_sourcing_quote_list_rows</condition></ifPart>
</sentry>
<!-- 唯一的活:派一张执行单给采购,他填完提交 -->
<humanTask id="T_source" name="渠道寻源:派执行单给采购,收回供应商回价与比价结论">
<itemControl><repetitionRule id="r_src"/></itemControl>
<!-- ① 归一目标报价清单 + A 类按历史价预填基准 -->
<humanTask id="S1_normalize" name="归一目标报价清单:整份落表,A 类按历史价预填基准">
<itemControl><repetitionRule id="r_s1"/></itemControl>
</humanTask>
<!-- 放行:触达世界,必须人工激活 -->
<humanTask id="T_release" name="寻源结论验收放行">
<entryCriterion id="ec_rel" sentryRef="s_compared"/>
<!--
② 补货源价格交 PRM —— **一张执行单**(询价的实质工作在系统外)。
manualActivation + 不给 doneWhen(发卡触达真人,引擎不替人按)。
-->
<humanTask id="S2_fill" name="补货源价格交PRM:执行单上把 B 类行的供应商与底价填回来">
<entryCriterion id="ec_s2" sentryRef="s_normalized"/>
<itemControl><manualActivationRule id="m_s2"/></itemControl>
</humanTask>
<!-- ③ 汇稿定盘:价源归档、齐检、结论 -->
<humanTask id="S3_compile" name="汇稿定盘:价源归档,每行齐检,生成目标报价清单">
<entryCriterion id="ec_s3" sentryRef="s_sourced"/>
</humanTask>
<!-- 清单放行:终局唯放行 —— 触达世界,必须人工激活 -->
<humanTask id="T_release" name="目标报价清单验收放行">
<entryCriterion id="ec_rel" sentryRef="s_ready"/>
<itemControl><manualActivationRule id="m_rl"/></itemControl>
</humanTask>
</casePlanModel>
</case>
</definitions>`;
export const NARRATIVE = `§0 开案:A1 判出一条料要寻源(无近期历史价、或行情波动),这一单就到你手上。
成品只有一样:**一份能拿去定价的比价结论** —— 几家报了什么价、交期多久、推荐哪一家、为什么。
/**
* 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本,两处不重复写同一件事)。
*/
export const NARRATIVE = `§0 开案:**一份清单要补齐每一行的价格基准**。来路三种,形状都收(宽进):
· **选型转来**:任务数据里有 \`rows\` 数组 —— 替代选型放行的全部落地行混在一起
(带 alt_pn / 现标的是清单行,带各种总数的是结论行,按 PN 归并自辨);
· **受理直达**:有 \`records\` 数组 —— 受理放行的落地行(受理记录行 + 分类表行 + 结论行);
· **单飞**:只有一段需求文字,列了几颗料要问价。
**这一单只有一步**:把寻源这件事派给采购(执行工单),他填完你收进台账,然后提交放行。
四种询价场景(BOM 早期 / BOM 后期 / 现货 / 样品)的打法差别是**采购的判断**,
不是系统要管的步 —— 你在 ask 里说清是哪种场景就够了,别替他把流程铺开。
**这个活动是复杂活动的 MVP 简化形**:询价的实质工作(圈渠道、发函、催回价、验价)
由采购(PRM)在**系统外**完成;四种场景(BOM早期/后期/现货/样品)的尺度是他的判断。
系统只负责:归一清单、**A 类行按库内历史价预填基准**、把执行单组好交给他、
收回后齐检定盘。
【T_source 派执行单】发 human 行动(workOrder="execution"),卡面 blocks 放**两张可编辑表**:
· 供应商回价:列 = 型号 / 供应商 / 回价 / MOQ / 交期 / 有效期;
· 寻源比价:列 = 型号 / 供应商 / 回价 / 结论。
两张都 editable:true;没有的值给空字符串 —— 空格子界面会自己标红「待填写」。
ask 说清要他做什么(哪颗料、什么场景、要几家、什么时候要);
basis 摆上型号、数量、目标价、客户交期要求(裸卡基座拒发)。
**成品只有一样**:一份**目标报价清单** —— 整份行都在、**每一行都有基准价**
(A 类取历史价,B 类取 PRM 问回来的货源价),价源逐行可溯。它是成本定价的直接输入。
他提交后,回传值出现在你的轨迹里:表格那一份的键是 **table:0 / table:1**(块序号),
值是整张表的 JSON —— **第一行是列名**,其余每行一条,列序与你派出去时一致。
照它 write 进 \`quote_sourcing.supplier_quote\` 与 \`quote_sourcing.compare_table\`,
并把比价表条数记进 compare_total。**写完就 finish T_source,别再发第二张单** —— 他已经答过了。
**这一单要经过人两次,不多不少**:PRM 在执行单上补货源价(那张单绑着整份清单,
必填空着按不下提交 —— 基座的闸看着,他改的格子也直接落账)、放行时 PRM 签字。
【提交放行】比价表有行了 —— **先 start T_release**(人工激活:放行触达世界,必须按一下),
然后 submit,BD 验收。终局唯放行:你不能自己宣布完成。
【一路上要说话】负责人正看着这一单。**每一拍都带一句 say** —— 说业务的话:
哪几颗料等着问价、历史价预填了几条、这单接下来到谁手上;不说系统词。
到了要找人的那一拍,先说清这一轮办完了什么、为什么现在需要他。
【一路上要说话】负责人正看着这一单。每一拍带一句 say,说**业务的话**:
哪颗料在寻源、找了几家、回价怎么样、这一单往哪走。
一两句、口语。别报「写草稿 2 行」这种系统词。`;
【怎么把一项干完 · 一拍连做】write 和 tool 都不会自动收步 —— 做完用 finish 宣告。
动作排进同一拍的 actions 串一口气给;只有调工具(出参下一拍才看得见)与
发卡 / 提交(挂起)必须停拍。**这一单常路 6-8 拍就该收口**。
§Z 结案:PRM 在验收单上签字,系统把草稿合并成正式清单并发出 quotation 口的载荷 ——
整份目标报价清单交给成本定价。`;
/**
* 步骤剧本 —— 与 A1/A2 同一套机制:`doneWhen` 为真,引擎自己收步、**不问模型**。
* 放行那一项不给 `doneWhen` —— 它触达世界,必须人按一下(铁律③)。
* 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。
*/
export const SOURCING_STEPS = [
{
item: 'T_source',
intent: '派一张执行单给采购,收回供应商回价与比价结论',
writes: ['quote_sourcing.supplier_quote', 'quote_sourcing.compare_table', 'quote_sourcing.verdict'],
reads: [],
item: 'S1_normalize',
intent: '上游清单整份归一成目标报价清单;A 类行按库内历史价预填基准',
writes: ['quote_sourcing.quote_list', 'quote_sourcing.verdict'],
/* 上游载荷数组不声明就会被整表段的截断刀砍掉(按字符截,砍掉的恰是要归一的行) */
reads: ['rows', 'records'],
/* 归一预填与 PRM 补价期间摆**询价工作单**;汇稿定盘后才换收口那张清单 */
view: 'sourcing-worksheet',
tools: ['crm.history-price-read'],
/*
* 归一 + 预填两轮都走完才算办完:行数两边对上,且 hist_priced_total 写过
* (没有 A 类就是 0;S-FEEL 未绑定的名字参与比较为假,所以「没写过」进不来)。
*/
doneWhen: 'quote_line_total > 0 and quote_sourcing_quote_list_rows = quote_line_total and hist_priced_total >= 0',
guide: `第一件事:把这一单的**目标报价清单**落出来,并把 A 类行的基准价预填好。
【料从哪儿来】
· 选型转来(有 \`rows\`):清单行带 pn / alt_pn / type_tag(现标)—— 照抄;
带 alt_line_total 这类总数的是结论行,不是料,别落进清单。
· 受理直达(有 \`records\`):受理记录行(带 eau/brand)为准,type_tag 照抄;
分类表行与结论行不落。**按 pn 归并**,一颗料一行。
· 单飞:照正文归一,列了几颗落几行;没说类别的按 B(要问价的料本来就是 B)。
【第一拍:只做两件事 —— 落清单行 + 串尾发一次查价】
① 写 \`quote_sourcing.quote_list\`,一颗料一行(rows 收数组):
pn(**一个字符不许改**)、customer_pn、category、brand、eau、target_price、lead_time、
**remark(客户备注:认证要求、预授权、急料标注 —— 问价口径的第一手依据,一个字别丢)**、
alt_pn(上游有就承继)、type_tag(照上游现标;单飞按 B)。
supplier / base_price / moq / source_lead_time / note **全部留空** —— 价还没来。
② 串尾调 \`crm.history-price-read\`:pns 收数组,把 **A 类行的取价对象**一次传完 ——
**取价对象 = alt_pn 有值取 alt_pn,否则取 pn**(选型转来的 A 行,行情稳的是替代料)。
**这一拍不写 \`quote_sourcing.verdict\`,一列都不写** —— 尤其 hist_priced_total:
它是「预填走完了」的凭据,提前写等于把闸提前打开,引擎会在下一拍开头当场收掉这一步,
**预填从此不会发生**(实测:A 类行底价空着、执行卡照发,而交代里说着「已预填」——
台账与嘴对不上)。没有 A 类行也一样:查价那一步省掉,直接进第二拍。
【第二拍:预填 A 行基准 + 记账 + 收步,一拍连做】
① 工具出参逐行照抄:hit=true 的 A 行写 base_price = lastPrice、price_basis 写 **HIST**
(大写,一个字母不许差 —— 基座按值现数,写别的数不出来)、note 记一句
「库内历史价 <N> 天」;hit=false 的 A 行**别编价** —— base_price 留空,
当 B 类待询处理(pending_total 把它数上;type_tag 不动,那是上游的标)。
② 这时才写 \`quote_sourcing.verdict\`(一次写全):rfq_ref(入参有就原样抄,
没有从主题认,认不出才造「SRC-<型号或类型>」,从头用到尾。
**只从本单来件取材,不许从台账里已有的别单单号里挑** —— 正式表里躺着的
MD14.1 之类是别人的单,借它的号等于把两单的行混进同一个案卷)、
customer_code / customer_name(顶层有就抄)、**quote_line_total** = 清单行数、
**pending_total** = 底价还空着的条数、**hist_priced_total** = 刚预填的条数(0 也要写)。
③ finish 这一项。`,
},
{
item: 'S2_fill',
intent: '一张执行单交 PRM:照询价工作单在系统外询价,供应商与底价就地填回',
writes: ['quote_sourcing.verdict'],
reads: ['quote_sourcing.quote_list'],
view: 'sourcing-worksheet',
tools: [],
/* 不给 doneWhen —— 发卡触达真人(manualActivation),引擎不替人按 */
guide: `清单归一好、A 类基准预填了,**该 PRM 出场了** —— 这是本活动唯一的一张执行单。
工作区摆的是**寻源询价工作单**:逐颗待询料的询价档案(要素/取价对象/客户约束)、
按场景圈渠道的规则、价格验证的尺、发询纪律与时限,填回区就长在上面。
这一步标了人工激活:发卡就算启动,不必单发 start;
引擎也不会替你收 —— 人交回后要你自己 finish。
【照这个骨架发,别少字段】(卡面是纯文本,steps/standard 里**别写星号记号**):
{"kind":"human","itemId":"S2_fill","workOrder":"execution","card":{
"title":"寻源补价:<单号> 待补价 <N> 颗",
"ask":"清单里底价空着的行要补货源价格:请照工作单上的场景规则在系统外完成询价,把供应商与底价填回表里对应行;MOQ、货源交期、备注能问到就一并填。",
"steps":["底价空着的行是本单要问价的料 —— 每行必须补上:供应商、底价(未税单价按内部口径归一);取价对象是替代料一栏有值的取替代料,否则取原型号",
"圈渠道按场景走(细则见工作单):BOM早期与样品可放宽;BOM后期按主供应商、备选、贸易商的顺序圈两三家,贸易商尽量不用;现货只选有库存实拍的供应商",
"回价登记一一对应不可缺项:价格、MOQ、交期能问到就填;验价按场景的尺核过再采,不实回价或远低行情的换渠道补询,别直接采用",
"确实问不到价的料,先与负责人商定:在件上把那一行撤掉(剔出本轮),或按内部估价填并在备注写明;A 类行已按库内历史价预填,可校可改"],
"standard":"每一行都有基准价与价源可溯,渠道与验价按场景的尺走过 —— 底价缺一行,这张单按不下提交(系统的闸看着)。",
"basis":[{"k":"<待补价 PN>","v":"<类别与年用量 + 客户约束(认证/急料),取价对象标明>"}]}}
· \`basis\` 逐条列待补价的料:PN + 类别/年用量。几颗写几条,别铺全表。
· **卡上不摆表、不摆格子**(2026-08-02 用户定:填写全在工作区):整份清单就在
工作单那份件上,供应商/底价/MOQ/货源交期/备注五列都能就地改、行能撤 ——
**别往卡里塞 \`blocks\` 或 \`fields\`**,塞了就是同一张表摆两处。
· **这张卡带提交闸**:底价(必填列)还空着,他按不下提交 —— 缺几格、缺在哪行,
闸上与件上的红带都写着(与放行门 L1 同一个数同一套算法)。
发完卡这一拍就结束了(发卡挂起)。
【人交回之后 —— 件上的值基座已经替你写回了】他在件上按「保存」那一刻改动就落进了
草稿层,按「提交」只是把卡交回来。你只做两件事,然后 finish:
① 清点原本底价空着、现在有价的条数,把 **sourced_total** 写进 verdict
(他在件上撤了行也算处置过,照实清点;0 也要写);
② 别再 write 那些行 —— 值没变的写入会被「写了等于没写」拒回。
【这张卡一单只发一次】人交回来之后不许再发同一张卡 —— 必填有提交闸兜着,
卡回来时底价一定是齐的(或那几行已被他撤掉)。`,
},
{
item: 'S3_compile',
intent: '价源逐行归档,齐检定盘:目标报价清单成稿',
writes: ['quote_sourcing.quote_list', 'quote_sourcing.verdict'],
reads: ['quote_sourcing.quote_list'],
view: 'sourcing-roster',
tools: [],
/*
* 比价表有行了才算办完。**不能写 `compare_total != null`** ——
* S-FEEL 里未绑定的名字 `!= null` 求值为 true,那一步开局即被收掉(A1 踩过)。
* **收步判据与验收判据同源**:每行有价(基座按 required 现数)、价源守恒
* (基座按值现数)、定盘记账 —— 三样齐才收,松出来的那一截就是返工。
*/
doneWhen: 'compare_total > 0',
guide: `**照这个骨架发,一个字段都别少**(prose 描述不管用,直接抄):
{"kind":"human","itemId":"T_source","workOrder":"execution","card":{
"title":"…","ask":"这颗料要寻源,请找几家报价并给比价结论",
"basis":[{"k":"…","v":"…"}],
"blocks":[
{"t":"table","title":"供应商回价","cols":["型号","供应商","回价","MOQ"],"editable":true,
"rows":[["NE555P","","",""],["NE555P","","",""]]},
{"t":"table","title":"寻源比价","cols":["型号","供应商","回价","结论"],"editable":true,
"rows":[["NE555P","","",""],["NE555P","","",""]]}
]}}
**一条料至少摆 2 行** —— 判据「BOM后期每料 2~3 家渠道」机检的就是渠道数,
只问一家过不了门(型号那列你先填,供应商与价格留给采购)。
**rows 必须有行**:一条料一行,不知道的值给空字符串(界面自己标红「待填写」)。
只给 cols 不给 rows,人看到的是一排表头加一片空白,一个格子都填不了(实测栽过)。
**blocks 少了整张卡就废了** —— 基座会用当下局面兜底成一句「需要你判断下一步」,
人拿到一张没有表的空卡,这一步等于没派(实测栽过)。
doneWhen:
'quote_sourcing_quote_list_missing = 0 and quote_sourcing_quote_list_price_basis_hist + quote_sourcing_quote_list_price_basis_sourced = quote_sourcing_quote_list_rows and route_decided_total > 0',
guide: `PRM 补回来了,现在把清单**定盘**,一拍连做:
**rows 一条料一行**,型号那一列先填上;ask 说清什么场景、要几家、什么时候要;
basis 摆上型号、数量、目标价、交期要求(**裸卡基座拒发**)。
① **价源归档**:PRM 补了价的行(price_basis 还空着的),逐行写 price_basis = **SOURCED**
(大写,一个字母不许差);A 类预填的行已是 HIST,不动。
按 rfq_ref + pn 对行,主键一个字符不许改。
② 若还有行缺底价(他留空又没撤):照他备注的说明办 —— 写明剔除的用 \`drop\`
把那一行撤出本单草稿(主键照 \`_keys\` 名单**原样粘**),撤了哪几颗、为什么写进
verdict.summary,并把 quote_line_total 与 pending_total 改成撤后的实数;
什么说明都没有的,发一张**决策卡(decide)**问负责人「这几颗问不到价,剔出本轮还是退回」,
照答复办。**只问这一次**。
③ 写 verdict:**route_decided_total 记 1**、summary 一句人话
(几行历史价、几行货源价、剔了几颗、整份可交定价)。
④ finish 这一项。引擎按「每行有价 + 价源守恒 + 定盘记过」收步,三样有一样不齐都收不掉。
他提交后回传值进你的轨迹:表格的键是 **table:0 / table:1**,值是整张表的 JSON,
**第一行是列名**。照它 write 进两张台账,并把比价表条数记进 compare_total。
**同一拍再写一行 \`quote_sourcing.verdict\`**(验收机检读的就是它):
· supplier_total = 询了几家渠道(判据要 ≥2,**只问一家过不了门**);
· quoted_total / unquoted_total = 已回价 / 未回价条数(未回价要 = 0);
· compare_total = 比价表条数;recommend_supplier 推荐哪家;summary 一句人话结论。
这几个数写不对,submit 会被门挡回来 —— **它们不是台账装饰,是判据本身**。
**写完就 finish,别再发第二张单** —— 他已经答过了。
**每一行都要带 \`rfq_ref\`** —— 它是这一单的单标识(起单入参里就有,原样抄进去)。
台账主键第一列就是它:不写,这一行会和**别的单**同料号的那一行撞主键,把人家覆盖掉
(实测栽过:A4 的报价草案主键只有 pn,跑四轮只剩最后一轮的 3 行)。`,
这一单只有一个出口(交成本定价),**没有去向要选** —— 定盘就是把账写齐。`,
},
{
item: 'T_release',
intent: '比价结论已齐,提交放行交 BD 验收',
view: 'sourcing-roster',
intent: '清单已定盘,提交放行交 PRM 验收',
writes: [],
reads: [],
tools: [],
guide: `比价表有行了 —— **先 start T_release**(它标了人工激活),然后 submit,BD 验收。
/* 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 doneWhen) */
guide: `每行有价、价源守恒、定盘已记 —— 可以走放行了(T_release 人工激活:
动它就算启动,不必单发 start)。先把裁量判据裁掉,然后 submit,同一拍连做:
· judge criterion="货源与价格来源可核:B 类行的供应商与底价来自真实询价或可靠渠道,PRM 对报价负责" ——
看清单里 SOURCED 行的供应商与备注:说得出来源就 satisfied=true;
有行说不出的,satisfied=**false**,rationale 逐行写清,交签字的人定。
**裁成不满足照样能提交** —— 理由连着卡一起交给他。
criterion 照判据名**整句原样抄**,写别的名字对不上。
终局唯放行:你不能自己宣布完成。`,
},
];
/**
* 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段;
* 放行那一格由基座统一加,人看到的是 x/4。
*/
export const SOURCING_PHASES = [
{ name: '归一预填', items: ['S1_normalize'] },
{ name: '补货源价', items: ['S2_fill'] },
{ name: '汇稿定盘', items: ['S3_compile'] },
];
+10 -53
View File
@@ -1,58 +1,15 @@
/**
* 统一报价 A3 · 渠道寻源 —— 活动包代码入口(ADR-004)。
* 统一报价 A3 · 寻源询价 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
*
* **loop 活动**:下一步由 Agent 在案例模型的结构上临场决断,引擎只推进到 Enabled 为止。
* 本文件只做视图流控 —— 把阶段、里程碑与比价进展摆给人看。
* **这个活动没有 `run`,不是缺件。**
*
* 案例活动的推进由 Loop 驱动,行为全部写在清单里:案例模型 + 说明书 + 步骤剧本 +
* 两条技能 + 台账声明 + 一张 HTML 报告模版。上一版这里有一段 `run()` ——
* loop 活动根本不会调它;摆一个空转的 run 去假装有实现,更坏。
*
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 —— 它是**被静态审扫描的产物文本**
* (查 import 与全局引用,证明活动包零环境能力)。
*/
import { defineActivity } from '@sdk';
import type { ActivityBase, ActivityOutcome } from '@sdk';
const QUOTES = 'quote_sourcing.supplier_quote';
const COMPARE = 'quote_sourcing.compare_table';
export default defineActivity({
name: 'quote-sourcing',
async run(base: ActivityBase): Promise<ActivityOutcome> {
const quotes = await base.ledger.read(QUOTES);
const compare = await base.ledger.read(COMPARE);
const suppliers = new Set(quotes.map((q) => String(q['supplier'] ?? '')));
await base.interaction.pushView([
{
t: 'kv',
title: '寻源进展',
items: [
{ k: '场景', v: String(base.contract.data['scenario'] ?? '') },
{ k: '已圈渠道', v: `${suppliers.size} 家` },
{ k: '已收回价', v: `${quotes.length} 单` },
{ k: '比价表', v: compare.length > 0 ? '已填' : '未填' },
],
},
{
t: 'table',
title: '供应商回价',
cols: ['供应商', '回价', 'MOQ', '交期', '渠道等级'],
rows: quotes.map((q) => [
String(q['supplier'] ?? ''),
String(q['price'] ?? '—'),
String(q['moq'] ?? '—'),
String(q['lead_time'] ?? '—'),
String(q['grade'] ?? '—'),
]),
},
]);
if (compare.length === 0) {
// 交集为空即断点 —— 由引擎判定、Agent 定夺调人还是立监听,这里不代它决定
await base.interaction.say(
suppliers.size === 0 ? '尚未圈定渠道,先按场景定策。' : `已收 ${quotes.length} 单回价,待填寻源比价表。`,
'conclusion',
);
return { kind: 'await', reason: '寻源比价表未交付' };
}
await base.interaction.say('寻源比价表已填齐,可交回 PM。', 'criterion');
return { kind: 'submit', deliverable: 'compare-table', rationale: '每料有有效报价或明确无货' };
},
});
export default defineActivity({ name: 'quote-sourcing' });
+283 -113
View File
@@ -1,185 +1,265 @@
/**
* 活动包清单 —— 统一报价 A3 · 渠道寻源(**案例活动 · loop**)。
* 活动包清单 —— 统一报价 A3 · 寻源询价(案例活动 · MVP 简化形)。
*
* 业务素材:docs/MVP示例工作流.html §1.5。按 README《归一化对照表》映射:
* 案例图 → CMMN XML;案例说明书 → 同版本叙事面;
* `!必做` → entryCriterion 常项;`看情况` → discretionary(planningTable);
* `#可反复` → repetitionRule;`▶需拍板` → manualActivation + 放行;
* `⏱时限/回价到期` → 时间监听(**时限必配**)。
* 业务素材:`docs/MVP示例工作流.html` §1.7,按 2026-08-02 用户改盘的口径归一:
* **A3 与 A2 同为复杂活动(需要大规模业务建模),MVP 期间不展开** —— 只做一张精美的
* HTML 验收报告 + 一张执行单:AI 归一目标报价清单、A 类行按库内历史价预填基准,
* 采购(PRM)在系统外询价、把 B 类行的货源价格填回来,汇稿定盘后放行 ——
* **生成最终的目标报价清单**交给成本定价。提交就是输出。
*
* ── 这里**去掉了 `STRATEGY_DMN`(采用策略判定表)**(2026-08-02)────────────────
* 它声明着 scenario / priceDrift / quoteCount 三个入参,台账上没有任何列承载、
* 剧本里也没有一步调它 —— 一张没有求值器的判定表不是资产,是错觉。
* 四种场景(BOM早期/后期/现货/样品)的尺度之分是 PRM 系统外的判断,
* MVP 口径下系统不假装接得住。
*/
import type { ActivityManifest, DmnModel } from '@contracts';
import { SOURCING_SKILLS } from './skills.ts';
import { SOURCING_CASE } from './views/report.ts';
import { activityIdSchema } from '@contracts';
import { CMMN_XML, NARRATIVE, SOURCING_STEPS } from './case-model.ts';
import { SOURCING_SKILLS } from './skills.ts';
import { SOURCING_ROSTER, SOURCING_WORKSHEET } from './views/report.ts';
import { CMMN_XML, NARRATIVE, SOURCING_PHASES, SOURCING_STEPS } from './case-model.ts';
const DOMAIN = 'quote_sourcing';
const D = 'quote_sourcing';
const ACTIVITY_ID = activityIdSchema.parse('quote-sourcing');
/** 采用策略 —— 「能算的不问 LLM」:场景 × 历史情形 → 直采 / 重询 */
export const STRATEGY_DMN: DmnModel = {
drd: [{ decision: 'sourcing-strategy', requires: [], inputs: ['scenario', 'priceDrift', 'quoteCount'] }],
tables: [
{
id: 'sourcing-strategy',
label: '采用策略判定',
hitPolicy: 'FIRST',
inputs: [
{ name: 'scenario', expression: 'scenario' },
{ name: 'priceDrift', expression: 'priceDrift' },
{ name: 'quoteCount', expression: 'quoteCount' },
],
outputs: [{ name: 'strategy' }, { name: 'needNewQuote' }],
rules: [
{ id: 'st-spot', when: ['"现货","样品"', '-', '-'], then: ['一律重询', true] },
{ id: 'st-early-direct', when: ['"BOM早期"', '<= 0.10', '-'], then: ['直采', false] },
{ id: 'st-early-avg', when: ['"BOM早期"', '[0.10..0.20]', '-'], then: ['取均价', false] },
{ id: 'st-late-direct', when: ['"BOM后期"', '-', '>= 3'], then: ['历史最低直采', false] },
{ id: 'st-default', when: ['-', '-', '-'], then: ['重询', true] },
],
},
],
};
/** MVP 口径:A3 没有判定表(见文件头注释)—— 取价规则写死在剧本,价源逐行落表 */
export const SOURCING_DMN: DmnModel = { drd: [], tables: [] };
export const manifest: ActivityManifest = {
identity: {
activityId: ACTIVITY_ID,
name: 'A3 · 渠道寻源',
packageVersion: '2.0.0',
name: 'A3 · 寻源询价',
packageVersion: '3.7.0',
category: '统一报价',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-quote-sourcing',
},
/* ── 契约七件 ────────────────────────────────────────────────── */
contract: {
activityId: ACTIVITY_ID,
version: '0.1.0',
version: '1.0.0',
input: {
/*
* **宽进到底:一个必填都不设**(2026-08-02 用户定:宽进严出,
* 输入契约在活动内部归一 + 补全)。
*
* 来路三种形状:A2 转来(rows)、A1 直达(records)、单飞(一段文字)。
* 上一版必填 `rfqRef + pn + scenario` 是**单料形状** —— 上游发的是整份清单,
* 门口拦的恰是主路。
*/
data: {
type: 'object',
required: ['rfqRef', 'pn', 'scenario'],
required: [],
properties: {
rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' },
pn: { type: 'string' },
scenario: { type: 'string' },
quantity: { type: 'integer' },
targetPrice: { type: 'number' },
rfqRef: { type: 'string', title: '询价单号' },
rows: { type: 'array', title: '询价受理清单(选型转来)' },
records: { 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: '读出来的文本' },
note: { type: 'string', title: '读取说明' },
},
},
},
},
},
messageAllowed: true,
messageHint: '急料、客户特殊要求写这里',
messageHint: '急料、场景(BOM早期/后期/现货/样品)、客户特殊要求写这里',
},
output: {
/*
* 只有一个出口:目标报价清单交成本定价(流程包 E7 是无条件边)。
* 没有选择组 —— 没有去向要选,定盘就是把账写齐。
*/
mandatory: [
{
name: 'compare',
payload: { type: 'object', required: ['rows'], properties: { rows: { type: 'array' } } },
description: '寻源比价表(多渠道横比定底)',
name: 'quotation',
payload: {
type: 'object',
required: ['rfqRef', 'rows'],
properties: {
rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' },
rows: { type: 'array', title: '目标报价清单(每行带基准价与价源)' },
},
},
description: '目标报价清单(每行带基准价与价源)',
},
],
selective: [],
},
/*
* 必交付物**必须给 `ledger` 地址**(2026-07-31 修)。
* 不给的话预检退回 `scope[key]` 去找 —— key 是 `benchmark-table` 这样的短横线别名,
* 任务视图里永远没有这个键,于是「必交付物缺一不得提交放行」永远拒,
* 活办完了也放不了行,连拒五拍熔断(实测 A2 死于此)。
* 必交付物只有一件:**目标报价清单**。寻源结论(verdict)是它的结论栏,
* `ledgers` 说清这份成果由哪几张表承载。
*/
requiredDeliverables: [
{ key: 'compare-table', label: '寻源比价表', ledger: `${DOMAIN}.compare_table` },
{
key: 'quotation-roster',
label: '目标报价清单(每行带基准价)',
ledger: `${D}.quote_list`,
/* 验收页上让 PRM 就地改的那几列 —— 恰是他的职权面(价格五列) */
editableAtRelease: ['supplier', 'base_price', 'moq', 'source_lead_time', 'note'],
ledgers: [`${D}.quote_list`, `${D}.verdict`],
},
],
/*
* 机检谓词读的是 `verdict` 那张**汇总表**的列(照 A1 的口径)。
* 上一版的名字(unquoted / channelsPicked)**台账里根本没有** —— S-FEEL 拿未绑定的名字
* 比大小求值为假,判据永远达不了标,submit 门永远拒,连拒五拍熔断。
* 判据的名字必须落在台账上,否则「机检」是一句空话(2026-07-31 销账)。
* 验收判据 —— **机检(L1/L2)只读基座数得出来的数**:
* `_rows` 基座数行数、`_missing` 基座按 required 现数(与执行卡提交闸同一个数
* 同一套算法)、`_price_basis_hist/_sourced` 基座按值现数。
* 上一版两条 L2 读活动自报的 verdict 列(unquoted_total / supplier_total)——
* 那不是门,是「改哪个数能过」的提示。
*/
acceptanceCriteria: [
{ key: 'each-has-quote', label: '每料有有效报价或明确无货', level: 'L2', predicate: 'unquoted_total = 0' },
{ key: 'channels-enough', label: 'BOM后期每料 2~3 家渠道', level: 'L2', predicate: 'supplier_total >= 2' },
{ key: 'cross-check', label: '价格验证按场景规则做过', level: 'L3' },
{ key: 'prm-release', label: '采购放行', level: 'L5' },
{
key: 'roster-complete',
label: '清单齐:上游清单逐条承继,说了几条清单里就躺着几行',
level: 'L1',
predicate: 'quote_line_total > 0 and quote_sourcing_quote_list_rows = quote_line_total',
},
{
key: 'price-complete',
label: '每行有基准价:A 类历史价、B 类货源价,缺一行放不了',
level: 'L1',
predicate: 'quote_sourcing_quote_list_rows > 0 and quote_sourcing_quote_list_missing = 0',
},
{
key: 'basis-conserved',
label: '价源守恒:历史价条数加货源价条数恰等于总行数,逐行留徽可溯',
level: 'L2',
predicate:
'quote_sourcing_quote_list_price_basis_hist + quote_sourcing_quote_list_price_basis_sourced = quote_sourcing_quote_list_rows',
},
/*
* 来源可核是**裁量**(L3):供应商靠不靠谱、价问没问真,机器数不出来。
* 这句 label 与剧本里 judge 的 criterion **必须逐字一致**。
*/
{
key: 'source-verifiable',
label: '货源与价格来源可核:B 类行的供应商与底价来自真实询价或可靠渠道,PRM 对报价负责',
level: 'L3',
},
/* L5 是责任关:那张卡本身就是这道关,卡面上不摆它(基座统一滤) */
{ key: 'prm-release', label: 'PRM 放行:签下去这份清单即定盘并交成本定价', level: 'L5' },
],
revisableAtRelease: [],
prohibitions: [
{ key: 'no-made-up-price', statement: '问不到的价不许编:要么剔出本轮,要么按内部估价填并在备注写明' },
{ key: 'no-stockless-trader', statement: '现货场景不得选无库存转单商' },
{ key: 'no-unverified', statement: '不实回价或远低行情的,必须换渠道补询,不得直接采用' },
],
authorization: {
discretion: [{ key: 'channel-band', statement: '按场景放宽或收紧渠道尺度;贸易商尽量不用' }],
preauthorizedActions: [{ tool: 'mail.send', operation: 'inquiry', limit: '询价模板' }],
humanCallBudget: 8,
discretion: [
{ key: 'channel-band', statement: 'PRM 按场景放宽或收紧渠道尺度;贸易商尽量不用' },
],
/*
* 空着不是省事:这个字段全仓没有一个消费者(债 ⑨)。
* 上一版这里写着 `mail.send` 的预授权 —— 写了也不会被执行,一并撤掉。
*/
preauthorizedActions: [],
humanCallBudget: 4,
},
resources: {
tools: [{ name: 'mail.send' }, { name: 'crm.history-price-read' }],
/*
* 只留历史价一件工具(A 类预填的取数口)。上一版还声明 `mail.send` ——
* MVP 口径下发询在 PRM 系统外,系统不代发,声明它只是白占白名单。
*/
tools: [{ name: 'crm.history-price-read' }],
skills: SOURCING_SKILLS.map((s) => s.id),
models: ['deepseek-v4-flash'],
roles: ['PRM'],
budget: { steps: 120, tokens: 300_000, activeMinutes: 480, humanCalls: 8 },
/* PRM 系统外询价,按场景时限 BOM后期 3~9 日 —— activeMinutes 给足等待 */
budget: { steps: 50, tokens: 250_000, activeMinutes: 480, humanCalls: 4 },
},
},
/* ── 声明 ────────────────────────────────────────────────────── */
declarations: {
models: ['deepseek-v4-flash'],
tools: [{ name: 'mail.send' }, { name: 'crm.history-price-read' }],
tools: [{ name: 'crm.history-price-read' }],
ledger: [
/*
* ── 这里**去掉了 `supplier_quote` / `compare_table` 两张表**(2026-08-02)──
*
* MVP 口径下逐家回价与横比都不在系统内产出 —— 询价的实质工作在 PRM 系统外,
* 系统收的是**结论**(定盘的供应商与底价),落在清单行的价格五列上。
* 撤掉声明不 drop 物理表(物化只增不破坏),历史行还在库里。
*/
{
domain: DOMAIN,
object: 'supplier_quote',
domain: D,
object: 'quote_list',
kind: 'record',
caseKey: 'rfq_ref',
label: '供应商回价单',
label: '目标报价清单',
fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'pn', type: 'text', primaryKey: true, masked: false },
{ name: 'supplier', type: 'text', primaryKey: true, masked: false, label: '供应商' },
{ name: 'price', type: 'number', primaryKey: false, masked: false, label: '回价' },
{ name: 'moq', type: 'integer', primaryKey: false, masked: false, label: 'MOQ' },
{ name: 'lead_time', type: 'text', primaryKey: false, masked: false, label: '交期' },
{ name: 'valid_until', type: 'date', primaryKey: false, masked: false, label: '有效期' },
{ name: 'grade', type: 'text', primaryKey: false, masked: false, label: '渠道等级' },
{ name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' },
{ 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: 'target_price', type: 'number', primaryKey: false, masked: false, label: '目标价' },
{ name: 'lead_time', type: 'text', primaryKey: false, masked: false, label: '客户要求交期' },
/* 客户备注承继自上游 —— 认证要求、预授权、急料标注都在这一格,是问价口径的第一手依据 */
{ name: 'remark', type: 'text', primaryKey: false, masked: false, label: '客户备注(约束/急料)' },
/* 选型转来的行带替代料 —— 取价对象是它;受理直达与单飞的行此列空着 */
{ name: 'alt_pn', type: 'text', primaryKey: false, masked: false, label: '替代料 PN' },
{ name: 'type_tag', type: 'text', primaryKey: false, masked: false, label: '类别(承继)', tally: ['A', 'B', 'C'] },
{ name: 'supplier', type: 'text', primaryKey: false, masked: false, label: '供应商' },
/*
* **底价是必填** —— 标了 `required`,基座就数得出「缺基准价的有几行」
* (读数 `quote_sourcing_quote_list_missing` / `_missing_keys`)。
* 这个数同时喂三处:件上的缺价红带、执行卡的提交闸、放行门 L1 ——
* 同一个数、同一套算法,三处永远说同一句话(同源纪律)。
*/
{ name: 'base_price', type: 'number', primaryKey: false, masked: false, required: true, label: '底价' },
/* MOQ 常带单位与档位(1K / 2500+),按文本收,不硬转数值 */
{ name: 'moq', type: 'text', primaryKey: false, masked: false, label: 'MOQ' },
{ name: 'source_lead_time', type: 'text', primaryKey: false, masked: false, label: '货源交期' },
/*
* **价源:取值域给出来,条数由基座现数** —— HIST(库内历史价)/ SOURCED(本单问回)。
* 价源守恒判据、分布条、价源徽读的全是基座读数;值是机器码,
* 人读名(历史价/货源价)由模版样式表翻译。
*/
{ name: 'price_basis', type: 'text', primaryKey: false, masked: false, label: '价源', tally: ['HIST', 'SOURCED'] },
/* 备注是我方写的注,不标 expect(标了 `_gaps` 恒大于 0,门就废了 —— A1 的账) */
{ name: 'note', type: 'text', primaryKey: false, masked: false, label: '备注' },
],
},
{
domain: DOMAIN,
object: 'compare_table',
kind: 'record',
caseKey: 'rfq_ref',
label: '寻源比价表',
fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'pn', type: 'text', primaryKey: true, masked: false },
{ name: 'supplier', type: 'text', primaryKey: false, masked: false },
{ name: 'price', type: 'number', primaryKey: false, masked: false },
{ name: 'conclusion', type: 'text', primaryKey: false, masked: false, label: '结论' },
],
},
{
/*
* 寻源结论 —— 一单一行的**汇总表**,验收机检谓词的入参就在这儿。
* 汇总列与逐行列**故意不同名**:任务视图把草稿行摊平到顶层是「后写的赢」,
* 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。
*/
domain: DOMAIN,
/* 寻源结论 —— 一单一行:汇总数(哨兵与报告读)、一句人话结论 */
domain: D,
object: 'verdict',
kind: 'record',
caseKey: 'rfq_ref',
label: '寻源结论',
fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'pn', type: 'text', primaryKey: false, masked: false, label: '型号' },
{ name: 'supplier_total', type: 'integer', primaryKey: false, masked: false, label: '已询渠道数' },
{ name: 'quoted_total', type: 'integer', primaryKey: false, masked: false, label: '已回价条数' },
{ name: 'unquoted_total', type: 'integer', primaryKey: false, masked: false, label: '未回价条数' },
{ name: 'compare_total', type: 'integer', primaryKey: false, masked: false, label: '比价条数' },
{ name: 'recommend_supplier', type: 'text', 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: 'quote_line_total', type: 'integer', primaryKey: false, masked: false, label: '清单条目数' },
{ name: 'pending_total', type: 'integer', primaryKey: false, masked: false, label: '待补价条数' },
/* A 类预填走完的凭据(0 也要写)—— 哨兵按它放 PRM 那一步入场 */
{ name: 'hist_priced_total', type: 'integer', primaryKey: false, masked: false, label: 'A 类已按历史价预填条数' },
/* 「PRM 的答复处理过了」的凭据 —— 只在人环走完才产生 */
{ name: 'sourced_total', type: 'integer', primaryKey: false, masked: false, label: 'PRM 已补价条数' },
{ name: 'route_decided_total', type: 'integer', primaryKey: false, masked: false, label: '已定盘' },
{ name: 'summary', type: 'text', primaryKey: false, masked: false, label: '寻源结论' },
],
},
],
listeners: [
{
// 回价到期:临过期提醒复核确认,过期触发重询 —— 时限必配
/** 供应商回价到达:记一笔观察(PRM 在系统外收回价,这里只接得到通道转进来的) */
origin: 'static',
eventType: 'data',
keyFilter: { kind: 'supplier-quote' },
@@ -188,6 +268,7 @@ export const manifest: ActivityManifest = {
escalation: ['nudge', 'escalate'],
},
{
/** 回价临过期:提醒复核确认 —— 时限必配 */
origin: 'static',
eventType: 'time',
keyFilter: { kind: 'quote-expiry' },
@@ -196,35 +277,124 @@ export const manifest: ActivityManifest = {
escalation: ['nudge', 'reroute'],
},
],
// 按场景时限:BOM早期/现货 1~4 日、样品 1~3 日、BOM后期 3~9 日 —— 取最宽做停滞阈值
harness: {
memoryWindow: 30,
memoryWindow: 24,
// PRM 在系统外询价,按场景时限最宽 BOM后期 3~9 日 —— 停滞阈值放到两天
stallAfter: 'P2D',
nudge: [{ after: 'P1D', action: 'nudge' }],
retry: { times: 2, backoff: 'PT5M' },
},
},
/* ── 资产初始版 ──────────────────────────────────────────────── */
assets: {
businessModel: {
cmmnXml: CMMN_XML,
// 双表示同版本:XML 是机器面,说明书是叙事面 —— 不允许分叉
narrative: NARRATIVE,
dmn: STRATEGY_DMN,
/* 步骤剧本 —— 与 A1 同一套:doneWhen 真了引擎自己收步,不问模型 */
dmn: SOURCING_DMN,
steps: SOURCING_STEPS,
phases: SOURCING_PHASES,
},
skills: SOURCING_SKILLS,
viewTemplates: [{ name: 'case-overview', html: SOURCING_CASE }],
/*
* 两个局面各一张(与 A2 同款口径,2026-08-02):
* · `sourcing-worksheet` 寻源询价工作单 —— 归一预填与 PRM 补价期间摆它:
* 逐颗待询料的询价档案、按场景圈渠道的规则、价格验证的尺、发询纪律与时限,
* 填回区就长在上面(素材出处 docs/MVP示例工作流.html §1.7,只作文字参考);
* · `sourcing-roster` 目标报价清单 —— 汇稿定盘后与放行、归档摆它。
*/
viewTemplates: [
{ name: 'sourcing-worksheet', html: SOURCING_WORKSHEET },
{ name: 'sourcing-roster', html: SOURCING_ROSTER },
],
cardSchemas: {},
/*
* distillHooks 只留 terminal —— 上一版还有 `milestone: 'M_quotes'`,
* 而案例模型里从来没有这个里程碑(死链,钩子永不触发)。
*/
distillHooks: [
{ on: 'milestone', milestone: 'M_quotes', prompt: '归纳各渠道的回价节奏与配合度。' },
{ on: 'terminal', prompt: '归纳本单寻源:哪些渠道靠谱、哪些场景的价格验证规则该收紧。' },
{
on: 'terminal',
prompt: '归纳本单寻源:哪些料历史价可用、货源价与历史价的偏离形态、剔单的原因分布。',
},
],
},
/* ── 样例集 —— 三条形状真不同(常备案例即验收案例)───────────── */
samples: [
{ name: 'BOM后期询价', input: { pn: 'LM358DR', scenario: 'BOM后期', quantity: 5000 }, expect: { ports: ['compare'] } },
{
/*
* ① 选型转来:A2 放行载荷的真实形状 —— rows 里清单行(带 alt_pn 与现标)
* 混着结论行;A 类行的取价对象是替代料。
*/
name: '选型转来 · 一颗转 B 的替代料要问价(A 类行历史价预填)',
input: {
rfqRef: 'RFQ MD14.1',
customer_code: 'CU-0142',
customer_name: 'Itelma',
rows: [
{
rfq_ref: 'RFQ MD14.1',
pn: 'BBPY00201209601Y00',
customer_pn: '4-14-01502',
category: '滤波器',
brand: 'YAGEO',
eau: 30000,
alt_pn: 'PBY201209T-601Y-N',
orig_type: 'C',
type_tag: 'B',
basis: '替代料 PBY201209T-601Y-N 库内无历史 → 转 B',
},
{
rfq_ref: 'RFQ MD14.1',
pn: 'CJTB0101R6N',
customer_pn: '4-00-04102',
category: '集成电路',
brand: 'Jiangsu Changjing',
eau: 60000,
orig_type: 'A',
type_tag: 'A',
},
{
rfq_ref: 'RFQ MD14.1',
alt_line_total: 2,
c_total: 1,
need_sourcing: true,
route_decided_total: 1,
customer_name: 'Itelma',
},
],
},
expect: { ports: ['quotation'] },
},
{
/*
* ② 受理直达(E3 那条路):A1 载荷形状 —— records 混行,B 类两颗要问价,
* A 类一颗历史价预填。
*/
name: '受理直达 · 两颗 B 类要问价(records 混行形状)',
input: {
rfqRef: 'RFQ-2026-0555',
customer_name: '青岛海洋电子',
records: [
{ rfq_ref: 'RFQ-2026-0555', pn: 'PESD2ETH-XR', category: '二极管', brand: 'Nexperia', eau: 30000, type_tag: 'B' },
{ rfq_ref: 'RFQ-2026-0555', pn: 'PESD2ETH-XR', history_days: 9999, market_stable: false, lifecycle: '未知', type_tag: 'B', basis: '无历史/过期/波动 → B' },
{ rfq_ref: 'RFQ-2026-0555', pn: 'NE555P', category: '定时器', brand: 'TI', eau: 5000, type_tag: 'B' },
{ rfq_ref: 'RFQ-2026-0555', pn: 'UMW AO6604', category: '晶体管', brand: 'UMW', eau: 30000, type_tag: 'A' },
{ rfq_ref: 'RFQ-2026-0555', line_total: 3, type_a_total: 1, type_b_total: 2, type_c_total: 0, need_sourcing: true },
],
},
expect: { ports: ['quotation'] },
},
{
/* ③ 单飞宽进:没有单号没有清单,正文列料 —— 归一造案号,全按 B 待询 */
name: '单飞需求 · 正文列两颗料要问底价(宽进归一)',
input: {
subject: '两颗现货料问底价',
body: '帮忙问两颗料的现货底价:SS8050 三极管 20K,XC6206P332MR LDO 10K。现货场景,只要有库存实拍的供应商。',
},
expect: { ports: ['quotation'] },
},
],
entry: { file: 'index.js', checksum: 'dev-entry-quote-sourcing' },
+31 -28
View File
@@ -1,46 +1,49 @@
/**
* A3 技能 —— 寻源的方法经验(AM 08)。
* loop 里技能由 AI **按适用描述临场装配**:常驻上下文只有目录,按事加载正文。
* A3 技能 —— 寻源询价 MVP 简化形的方法经验(AM 08)。
*
* **载方法,不载权力**;渐进披露:常驻上下文只有目录,按事加载正文。
* 剧本改了口径,技能里同一句也要跟着改 —— 2026-08-02 改盘后:
* 询价的实质工作在 PRM 系统外,系统管归一 / 预填 / 组卡 / 齐检;
* 旧的「按场景圈渠道」「该等还是该问」讲的是 PRM 脑子里的方法,系统不替他办,删。
*/
import { skillIdSchema } from '@contracts';
import type { Skill } from '@contracts';
export const SOURCING_SKILLS: Skill[] = [
{
id: skillIdSchema.parse('sourcing.pick-channels'),
label: '按场景圈渠道',
applicability: '拿到料号与场景,要决定向谁发询时。',
id: skillIdSchema.parse('sourcing.normalize-roster'),
label: '归一目标报价清单',
applicability: '开案第一步:把上游清单(或单飞需求)整份落成目标报价清单时。',
method: [
'渠道不是越多越好:发得越广,回价越慢、口径越乱、对供应商的信用消耗越大。',
'上游载荷是**全部落地行混在一起**的一份:选型转来(`rows`)的清单行带 alt_pn 与现标,',
'受理直达(`records`)的以受理记录行(带 eau/brand)为准;带总数的结论行不是料,别落。',
'**按 pn 归并**,一颗料一行,一拍写完(rows 收数组)。',
'',
'按场景定档:',
'· 常规补货 → 原厂代理 2 家足够,比的是账期与交期。',
'· BOM 后期 / 急单 → 加 1–2 家现货商,比的是**能不能拿到货**,价格其次。',
'· 停产料 → 直接问现货商与拆解渠道,原厂问了也是白问。',
'',
'每多发一家,就多一份要等的回价 —— 发询前先想清楚:这一家的回价会改变你的决定吗?',
'不会,就别发。',
'三条纪律:',
'① PN 一个字符不许改;上游没给的列留空字符串,绝不写 0 顶空。',
'② type_tag 照上游现标承继,本活动不改类 —— 改类是上游的职权。',
'③ 单号 rfq_ref 原样承继;单飞认不出才造「SRC-<型号或类型>」,造完从头用到尾。',
].join('\n'),
crystallized: [],
knowledgeRefs: ['渠道分级台账'],
version: '1.0.0',
knowledgeRefs: ['docs/MVP示例工作流.html §1.7'],
version: '2.0.0',
},
{
id: skillIdSchema.parse('sourcing.wait-or-ask'),
label: '该等还是该问',
applicability: '当下可为与目标所需交集为空(依赖断点)时。',
id: skillIdSchema.parse('sourcing.prefill-hist'),
label: 'A 类按历史价预填基准',
applicability: '清单落好后,要给 A 类行预填基准价时。',
method: [
'断点只有两种解法,选错就是白等或白问:',
'· 缺口**在世界侧**(供应商还没回价、样品还在路上)→ 立监听,必配时限。',
' 时限到了要有升级动作:催办 → 升级 → 改道,不能无限等。',
'· 缺口**只有人知道**(客户能不能接受替代、预算上限多少)→ 调人。',
'A 类的意思就是**库内历史价可用**(≤90 天且行情稳)—— 基准价直接取历史价,',
'不必劳烦 PRM 再问一遍。调 `crm.history-price-read`:**pns 收数组一次传完**;',
'**取价对象 = alt_pn 有值取 alt_pn,否则取 pn**(选型转来的 A 行,行情稳的是替代料)。',
'',
'判断标准很简单:**这个信息在系统外会自己到来吗?**',
'会 → 等(监听);不会 → 问(调人)。',
'把「该问的」等成超时,是寻源里最常见也最贵的错误。',
'出参逐行照抄:base_price = lastPrice、price_basis 写 HIST(大写,基座按值现数)。',
'hit=false 的 A 行**别编价** —— 留空当 B 类待询处理:上游打标与库内现状偶有出入,',
'照实处理,不替谁圆场。',
].join('\n'),
crystallized: [],
knowledgeRefs: [],
version: '1.0.0',
// 结晶片段:查历史价这一步每单都一样 —— 确定性子序列,不必每次让模型重想
crystallized: [{ tool: 'crm.history-price-read', input: { pns: 'pns' } }],
knowledgeRefs: ['三分类规则 P-03(A 行的定义)'],
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>`;
+523 -94
View File
@@ -1,101 +1,530 @@
/**
* A3 渠道寻源的 HTML 汇报模版 —— **活动自己持有的资产**(§9.3),随包版本冻结。
* A3 寻源询价的 HTML 报告模版 —— **一张件贯穿始终**:目标报价清单。
*
* ── 这一屏给谁看、他此刻要判什么 ──────────────────────────────────
* 看的人是采购(PRM)与等着底价的 PM。他此刻只判一件事:**这价能不能拿去往下走**。
* 于是版面按「先成果、后局面」排:
* 一 供应商回价(收回来多少、都是什么渠道)· 二 寻源比价表(交回 PM 的成果本体)
* → 三 案例阶段 · 四 当下可为 · 五 目标缺口(AI 的办案局面)
* 指标带扫一眼就该有结论(回价够不够、比价表填没填),往下五节是「凭什么这么说」。
* 在办(PRM 补货源价时)与归档(放行后进工作成果)是**同一份 HTML**,
* 差别只在外层给不给编辑态(`html.editable`)。版面语言沿用 A1 从 PRD 目标件
* 机械抽取的那套(`prd-style.ts` 原样拷入)—— A3 没有自己的 PRD 目标件
* (2026-08-02 用户定:沿用 A1 版面语言自造)。
*
* ── 画面上的数从哪来(一个都不是编的)────────────────────────────
* · `mine:quote_sourcing.*` 是**本单自己写下的草稿行** —— 别单放行过的回价不混进来;
* 列名逐字取自 `declarations.ledger`,编一个不存在的列名 = 屏上永远空白。
* · `case:*` / `enabledCount` / `gapCount` / `activeStage` 由语义引擎每拍算出,
* 是确定事实、零 LLM 即可成屏 —— 模型只决定「下一步做什么」,不负责画面。
* · 指标带上「已收回价 / 比价表行数」两格指的是**数组本身**:填数时数组取长度
* (view-render 的 `displayValue` 约定),不必为「几单」另立一个计数字段。
*
* ── 两条不撒谎的纪律 ──────────────────────────────────────────
* ① 右上角挂的是「在办」灯不是印章:这一屏是活的局面,它没盖过章,就别画成盖了章的样子;
* 紫(tone `case`)是**案例活动的标记色不是状态色** —— 红绿橙只留给「达标没达标」。
* ② 表空着就是「本单还没写下这一行」,不是渲染坏了 —— 每张表的说明里都写明空着意味着什么。
* 寻源尤其怕这个:空白被读成「这家没报价」,而它可能只是还没登记。
*
* 容器是 `sandbox=""` iframe:无脚本、无外链,样式与图标由 `@sdk` 自带。
* 取值路径三纪律照 A1:顶层只取汇总列与单值列;逐条走 `mine:quote_sourcing.quote_list`;
* 列名逐字照 `declarations.ledger` 抄。模版不算不判 —— 缺价红带、价源徽、分布条
* 全部由基座算好的数经 `data-class-of` 投影,判断留在样式表。
*/
import { noteOnly, section, stagesOnly, statsBand, tableOnly, viewShell } from '@sdk';
import { PRD_CSS, PRD_SPRITE } from './prd-style.ts';
export const SOURCING_CASE = viewShell({
title: '渠道寻源 · 案例全景',
kicker: 'A3 · 渠道寻源 · 案例活动',
tone: 'case',
icon: 'mail',
stamp: { kind: 'live', text: '案例在办 · 非定稿' },
sign: 'A3 渠道寻源 · 本页每拍按当下局面重画',
/* ==================== 绑定期补的样式(照 A1 的 BIND_CSS,地址换成本活动)==================== */
const BIND_CSS = `
/* 空格子自己说话 —— 值真的空才轮到(基座 data-blank) */
.proto-root .jc-tbl-in td.req > .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.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);
}
}
/* 序号由 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; }
}
/* 就地改:可改格子渲了只读与输入两份,默认锁着,表上加 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-pbadge { 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-a { background: var(--ok); }
.proto-root .jc-segmeter > i.v-b { background: var(--brand); }
.proto-root .jc-segmeter > i.v-c { background: var(--warn-accent); }
/* 价源徽:历史价绿(库内既有事实)/ 货源价蓝(本单问回来的) */
.proto-root .jc-pbadge {
display: inline-flex; align-items: center; justify-content: center;
min-width: 44px; 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-pbadge.v-hist { color: var(--ok); border-color: var(--ok); }
.proto-root .jc-pbadge.v-sourced { color: var(--brand); border-color: var(--brand); }
/* 价源徽的字也由 class 出 —— 值(HIST/SOURCED)是机器码,人读名交给样式表 */
.proto-root .jc-pbadge::after { content: "待定"; }
.proto-root .jc-pbadge.v-hist::after { content: "历史价"; }
.proto-root .jc-pbadge.v-sourced::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.h { background: var(--ok); }
.proto-root .jc-distbar > i.s { background: var(--brand); }
.proto-root .jc-distbar > i.h::before { content: "历史价 · "; }
.proto-root .jc-distbar > i.s::before { content: "货源价 · "; }
.proto-root .jc-logo svg, .proto-root .jc-face svg, .proto-root .jc-rp-ava svg { width: 17px; height: 17px; }
/* ── 询价档案卡(工作单独有,照 A2 选型档案卡的骨架)──────────────────────
一颗料一张卡,data-rows 逐行复制;形态按类别分档(data-class-of):
v-b / v-c = 待询价,整卡摊开;v-a = 已按历史价预填,收成一行 —— 判断在样式表。 */
.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-b, .proto-root .jc-selcard.v-c { border-color: var(--brand-soft); box-shadow: 0 1px 0 rgba(51,112,255,.06); }
.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-b .jc-selcard-h, .proto-root .jc-selcard.v-c .jc-selcard-h { background: var(--brand-wash); border-bottom-color: var(--brand-soft); }
.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: none; }
.proto-root .jc-selcard.v-b .jc-selcard-b, .proto-root .jc-selcard.v-c .jc-selcard-b { display: grid; grid-template-columns: 1fr 1fr; gap: 0; }
@media (max-width: 1000px) { .proto-root .jc-selcard.v-b .jc-selcard-b, .proto-root .jc-selcard.v-c .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.v-b .sc-req:empty::after, .proto-root .jc-selcard.v-c .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-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-selcard-skip { display: none; }
.proto-root .jc-selcard.v-a .jc-selcard-skip { display: block; padding: 0 14px 9px; font-size: 11px; color: var(--ink-4); }
.proto-root .jc-selcard.v-a .jc-selcard-h { border-bottom: 0; padding-bottom: 4px; }
/* 场景规则表(静态 —— 尺挂在墙上;场景徽是模版文字不是数据) */
.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-scene {
flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; margin-top: 1px;
min-width: 52px; 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-scene.early { color: #0f766e; border-color: #0f766e; }
.proto-root .jc-scene.late { color: var(--brand); border-color: var(--brand); }
.proto-root .jc-scene.spot { color: var(--warn-accent); border-color: var(--warn-accent); }
.proto-root .jc-scene.sample { color: var(--ok); border-color: var(--ok); }
`;
/** 一枚图标 —— 与原型同一写法 */
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_sourcing_quote_list_missing"></b><span>/ <b data-slot="quote_line_total"></b> 行清单</span></div>
<span class="jc-fcard-note">底价还空着的行 —— 本单要问价的对象;补一格少一格,基座现数</span>
</div>
<div class="jc-fcard">
<span class="jc-fcard-k"><i>${ico('history')}</i>清单分类(承继)</span>
<div class="jc-segmeter" data-rows="mine:quote_sourcing.quote_list">
<i data-class-of="type_tag"></i>
</div>
<span class="jc-fcard-note">一格一条料 · A 绿 B 蓝按上游现标着色;类别本单不改</span>
</div>
<div class="jc-fcard t-ok">
<span class="jc-fcard-k"><i>${ico('shield-check')}</i>取价的尺</span>
<span class="jc-tagbig">${ico('check', 13)}<span>A 取历史价 · B 问货源价</span></span>
<span class="jc-fcard-note">A 类按库内历史价预填;B 类由采购系统外询价补回 —— 价源逐行留徽,可溯</span>
</div>
</div>`;
/** 缺价红带 —— 与提交闸、放行门 L1 同一个数同一套算法(基座现数);一格不缺自动收 */
const GAP_BAR = `<div class="jc-gapbar" data-class-of="quote_sourcing_quote_list_missing">${ico('alert', 14)}
<span><b>还差 <span data-slot="quote_sourcing_quote_list_missing"></span> 行基准价没补</b>
—— <span data-slot="quote_sourcing_quote_list_missing_keys"></span>。
按右上角「编辑」开表,红框那几格就是要填的;问不到价的行按行首 × 撤掉并在备注写明。
补齐了执行单才提交得了。</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>供应商 / 底价 / MOQ / 货源交期 / 备注</b>逐格可填;
A 类预填的历史价也能改 —— 改了以你的为准。问不到价的行按行首 × 撤掉(剔出本轮)。
改动只落任务草稿层,<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` 只挂人可改的五列(supplier / base_price / moq / source_lead_time / note)——
* 编辑面精确等于 PRM 的职权面。底价格挂 `req`:空着出「必填」胶囊、整行标底,
* 与提交闸、放行门同源(都数 `_missing`)。
*/
const ROSTER = `<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:130px">替代料 PN</th>
<th style="min-width:84px">品类</th>
<th style="min-width:72px">年用量</th>
<th style="min-width:72px">目标价</th>
<th style="min-width:120px">供应商</th>
<th class="req" style="min-width:80px">底价</th>
<th style="min-width:64px">MOQ</th>
<th style="min-width:84px">货源交期</th>
<th style="width:64px;min-width:64px">价源</th>
<th style="min-width:150px;width:100%">备注</th>
</tr></thead>
<tbody data-rows="mine:quote_sourcing.quote_list" data-edit="quote_sourcing.quote_list"><tr>
<td class="jc-rn"><button type="button" class="jc-rowdel" data-edit-del="quote_sourcing.quote_list" 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="替代料 PN"><span class="jc-vcell mono" data-slot="alt_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 num" data-slot="target_price" data-blank></span></td>
<td class="opt" data-h="供应商"><span class="jc-vcell" data-slot="supplier" data-col="supplier" data-blank></span></td>
<td class="req" data-h="底价"><span class="jc-vcell num" data-slot="base_price" data-col="base_price" data-blank></span></td>
<td class="opt" data-h="MOQ"><span class="jc-vcell mono" data-slot="moq" data-col="moq" data-blank></span></td>
<td class="opt" data-h="货源交期"><span class="jc-vcell" data-slot="source_lead_time" data-col="source_lead_time" data-blank></span></td>
<td data-h="价源"><span class="jc-pbadge" data-class-of="price_basis"></span></td>
<td class="opt" data-h="备注"><span class="jc-vcell" data-slot="note" data-col="note" data-blank></span></td>
</tr></tbody>
</table></div>`;
/** 价源分布:两段按条数分宽 —— 历史价 / 货源价,守恒等式的可视形 */
const DIST = `<div class="jc-dist">
<div class="jc-distbar">
<i class="h" data-class-of="quote_sourcing_quote_list_price_basis_hist" data-slot="quote_sourcing_quote_list_price_basis_hist"></i>
<i class="s" data-class-of="quote_sourcing_quote_list_price_basis_sourced" data-slot="quote_sourcing_quote_list_price_basis_sourced"></i>
</div>
<div class="jc-distlg">
<span><em class="a"></em>历史价 · 库内既有 <b data-slot="quote_sourcing_quote_list_price_basis_hist"></b></span>
<span><em class="b"></em>货源价 · 本单问回 <b data-slot="quote_sourcing_quote_list_price_basis_sourced"></b></span>
</div>
</div>`;
/* ==================== 那份件:目标报价清单(在办与归档同一张)==================== */
export const SOURCING_ROSTER = doc({
badge: 'sheet',
kicker: '统一报价 · A3 寻源询价 · 交付成果',
title: '目标报价清单',
titleRef: 'rfq_ref',
state:
'<span class="jc-rp-wait">清单 <b data-slot="quote_sourcing_quote_list_rows"></b> 条 · 待补价 <b data-slot="quote_sourcing_quote_list_missing"></b></span>',
body:
statsBand([
['已收回价', 'mine:quote_sourcing.supplier_quote'],
['比价表行数', 'mine:quote_sourcing.compare_table'],
['当下可为', 'enabledCount'],
['目标缺口', 'gapCount'],
]) +
section('供应商回价(本单登记)',
tableOnly('mine:quote_sourcing.supplier_quote', [
['型号 PN', 'pn', 'mono'],
['供应商', 'supplier'],
['渠道等级', 'grade'],
['回价', 'price', 'mono'],
['交期', 'lead_time'],
['有效期', 'valid_until', 'mono'],
]) +
noteOnly(
'回价<b>逐单登记、一一对应不可缺项</b>:发出去几家就该收回几条,没回音的也要留一条写明' +
'「无货 / 未应答」—— 少一条,下游读到的就是「没这回事」。渠道等级是场景尺度落到纸面的地方:' +
'BOM后期按主 → 备选 → 贸易商圈 2~3 家、<b>贸易商尽量不用</b>,现货只选有库存的。' +
'「有效期」是回价到期监听盯的那一列,过期即触发重询。表空着,是本单还没登记回价。',
),
{ text: '一料一渠一行' },
) +
section('寻源比价表(本单交付件)',
tableOnly('mine:quote_sourcing.compare_table', [
['型号 PN', 'pn', 'mono'],
['供应商', 'supplier'],
['回价', 'price', 'mono'],
['结论', 'conclusion'],
]) +
noteOnly(
'这是交回 PM 的成果本体:一料一结论,多渠道横比定底。<b>不实回价或远低行情的必须换渠道补询</b>,' +
'不得直接采用;现货场景不得选无库存转单商。采用策略由判定表出(直采 / 取均价 / 重询),' +
'不是模型自己说了算。表空着,是本单还没走到反馈交付。',
),
{ text: '交回 PM 的成果' },
) +
section('案例阶段(CMMN 生命周期)',
stagesOnly('case:stages') +
noteOnly(
'阶段的生命周期态由语义引擎推进到 <b>Enabled</b> 为止;凡触达世界或产出关键交付的计划项' +
'必须人工启动 —— <b>Enabled ≠ Active</b>。寻源是<b>一事一议</b>:几家供应商、谁先回、' +
'要不要追加一轮,事前排不出谱,所以调度权交给 AI,系统只保证边框:预算、停滞熔断、放行归人。',
),
{ text: '推进到 Enabled 为止' },
) +
section('当下可为(AI 的合法选项)',
stagesOnly('case:enabled') +
noteOnly(
'AI 只能在这个集合里选下一步:<b>可以越谱,不可越权</b>。发询价信是<b>触达世界</b>的动作 ——' +
'标「待启动」的项要人按一下再动,信发出去就收不回来。这一栏空着不是它偷懒,' +
'是当下确实无可为:要么在等人回话,要么在等供应商的回音。',
),
{ text: '本拍的合法选项' },
) +
section('目标缺口(离交付还差什么)',
stagesOnly('case:gaps') +
noteOnly(
'缺口只有人知道 → 调人;缺口在世界侧(等供应商回价)→ 立监听,<b>时限必配</b> ——' +
'没有时限的等待就是无限期挂起,而回价的时限本就写死在询价信里。' +
'当下可为 ∩ 目标所需 = 空,就是<b>依赖断点</b>。这一栏空着,是必做的事都办完了。',
),
{ text: '空 = 必做的都办完了' },
),
part('寻源要素', '寻源询价 · MVP 口径:询价在系统外,价格落表内') +
FOUR +
part('目标报价清单', '<b data-slot="quote_line_total"></b> 条 · 每行必有基准价') +
GAP_BAR +
editGate('quote_sourcing.quote_list', '目标报价清单') +
ROSTER +
addRowBar('quote_sourcing.quote_list') +
`<div class="jc-drop">${ico('info', 13)}
<span>带 * 的<b>底价</b>是必填:每一行都要有基准价 —— <b>A 类</b>取库内历史价(系统预填,可校可改),
<b>B 类</b>由采购系统外询价补回。价源那一列逐行留徽:绿「历史价」是库内既有事实,
蓝「货源价」是本单问回来的 —— 两段合起来恰等于总行数,对不上就是账没记齐。
<b>问不到的价不许编</b>:剔出本轮(行首 ×)或按内部估价填并在备注写明。</span>
</div>` +
part('价源分布', '历史价 + 货源价 = 总行数') +
DIST +
part('放行后即执行的落地动作', '门前 → 门后') +
`<div class="jc-acts">
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>草稿层合并进正式版</b>&#12288;<span>本任务写下的目标报价清单与寻源结论一并落账;
PRM 补价与就地修订的那几处随之落定。</span></div>
</div>
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>清单此刻才对外发出</b>&#12288;<span>整份 <b data-slot="quote_sourcing_quote_list_rows"></b> 行
(每行带基准价与价源)交出 —— 放行之前,世界上什么都没有发生。</span></div>
</div>
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>转入成本定价</b>&#12288;<span>本活动只有一个出口 —— 目标报价清单是定价环节的直接输入,
已进对方的任务清单。</span></div>
</div>
</div>`,
foot: sign('PRM 询价 · AI 归一齐检 —— 要改哪一格,在这张单的清单表上就地改', '统一报价 · A3 寻源询价 → 交定价'),
});
/* ==================== 局面一 · 寻源询价工作单(归一预填与 PRM 补价期间)==================== */
/**
* 逐颗询价档案 —— 一颗料一张卡(data-rows 逐行复制)。
* 形态按类别分档:B/C 摊开成「题面 + 定案回显」两栏;A 收成一行「已按历史价预填」。
*
* **容器用 `<section>` 不用 `<div>`**:渲染器的行展开按第一个同名闭合标签截行内容,
* 行模版里嵌着 `<div>` 时 `<div data-rows>` 会把行截成套娃(A2 逮过,记在陷阱表)。
*/
const INQ_CARDS = `<section class="jc-selcards" data-rows="mine:quote_sourcing.quote_list">
<article class="jc-selcard" data-class-of="type_tag">
<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>
</div>
<div class="jc-selcard-b">
<div class="jc-selcol">
<h5>询价题面(要素与口径)</h5>
<div class="jc-kvrow"><i>取价对象</i><span data-slot="alt_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="brand" data-blank></span></div>
<div class="jc-kvrow"><i>客户料号</i><span data-slot="customer_pn" data-blank></span></div>
<div class="jc-kvrow"><i>年用量</i><span data-slot="eau" data-blank></span></div>
<div class="jc-kvrow"><i>目标价</i><span data-slot="target_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>客户约束</i><span data-slot="remark" data-blank></span></div>
</div>
<div class="jc-selcol">
<h5>问价定案(填回下表,这里回显)</h5>
<div class="jc-kvrow"><i>供应商</i><span class="sc-req" data-slot="supplier" data-blank></span></div>
<div class="jc-kvrow"><i>底价</i><span class="sc-req num" data-slot="base_price" data-blank></span></div>
<div class="jc-kvrow"><i>MOQ</i><span class="sc-opt" data-slot="moq" data-blank></span></div>
<div class="jc-kvrow"><i>货源交期</i><span class="sc-opt" data-slot="source_lead_time" data-blank></span></div>
<div class="jc-kvrow"><i>备注</i><span class="sc-opt" data-slot="note" data-blank></span></div>
</div>
</div>
<div class="jc-selcard-skip">已按库内历史价预填基准 · 可校可改;取价对象为空即原型号本身。</div>
</article>
</section>`;
/** 按场景圈渠道 —— 素材:MVP 示例工作流 §1.7 选渠定策(只作文字参考,尺挂在墙上) */
const CHANNEL_RULES = `<div class="jc-graderule">
<div class="jc-graderule-r">
<span class="jc-scene early">早期</span>
<p><b>BOM 早期:渠道可放宽。</b>系统带出的在供供应商直接圈入即可 —— 这一段要的是快,
价格验证也宽(首个报价即用,品牌价差两成内算正常)。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-scene late">后期</span>
<p><b>BOM 后期:必须靠谱。</b>按主供应商 → 备选 → 贸易商的顺序圈两三家,<b>贸易商尽量不用</b>;
核实授权代理资质与授权期限,贸易商核查货源链可靠性。默认两轮:首轮回价内部核过再发次轮。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-scene spot">现货</span>
<p><b>现货:只认有库存、可提供仓库实拍的供应商。</b>无库存转单商不可选 ——
转一手就是一手风险,货期与成色都不由你控。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-scene sample">样品</span>
<p><b>样品:量小求快。</b>渠道可放宽,低于线上商城价即可用;库内不足线上补。</p>
</div>
</div>`;
/** 价格验证的尺 —— 素材:MVP 示例工作流 §1.7 发询比价(只作文字参考) */
const VERIFY_RULES = `<div class="jc-graderule">
<div class="jc-graderule-r">
<span class="jc-scene early">早期</span>
<p>首个报价即用;品牌之间价差两成以内算正常,不必追平。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-scene late">后期</span>
<p>按历史最低价降两成去谈;两轮核价,首轮回价内部核过再谈次轮。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-scene spot">现货</span>
<p>多重校验:低于线上商城价、比过区域市场价、比过订货价(高出两成以内可接受)。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-scene sample">样品</span>
<p>低于线上商城价即用 —— 样品要的是拿得到,不是省到底。</p>
</div>
</div>`;
export const SOURCING_WORKSHEET = doc({
badge: 'mail-open',
kicker: '统一报价 · A3 寻源询价 · 询价作业',
title: '寻源询价工作单',
titleRef: 'rfq_ref',
state:
'<span class="jc-rp-wait warn">待补价 <b data-slot="quote_sourcing_quote_list_missing"></b> / <b data-slot="quote_line_total"></b> 行</span>',
body:
part('询价任务', '客户 <b data-slot="customer_name" data-blank></b> · 待补价 <b data-slot="pending_total"></b> 颗 · A 类已预填 <b data-slot="hist_priced_total"></b> 颗') +
`<div class="jc-derive">${ico('info', 14)}
<span>下面每颗料一张<b>询价档案卡</b>:左边是题面(要素、取价对象与客户约束),右边回显你的定案。
蓝标 B 的料是本单要问价的对象;绿标 A 已按库内历史价预填基准,可校可改。
询价在系统外做(发函、催回价、比价都在你手上),<b>定案落回本单</b> —— 供应商、底价、
MOQ、货源交期逐颗可溯。发询纪律:同一料所有渠道一次发齐,PN / 数量 / 目标价 / 回价时限
写死在询价里;回价登记一一对应不可缺项;不实回价或远低行情的换渠道补询,别直接采用。</span>
</div>` +
part('逐颗询价档案', '题面在左 · 定案在右') +
INQ_CARDS +
part('按场景圈渠道', 'BOM早期 / BOM后期 / 现货 / 样品 —— 场景定尺度') +
CHANNEL_RULES +
part('价格验证的尺', '按场景核过再采') +
VERIFY_RULES +
`<div class="jc-drop">${ico('info', 13)}
<span><b>时限参考</b>:BOM早期 / 现货 1~4 日,样品 1~3 日,BOM后期 3~9 日 —— 急料电话催办;
回价临过期先复核确认,过期触发重询。历史价未满三次询价但有效期已过的,标记须重询。</span>
</div>` +
part('填回工作表', '定案落在这 —— 补齐了执行单才提交得了') +
GAP_BAR +
editGate('quote_sourcing.quote_list', '寻源询价填回表') +
ROSTER +
addRowBar('quote_sourcing.quote_list') +
`<div class="jc-drop">${ico('info', 13)}
<span>带 * 的<b>底价</b>是必填(未税单价按内部口径归一)—— 缺一行执行单按不下提交,
与放行门是同一个数同一套算法。<b>问不到的价不许编</b>:剔出本轮(行首 ×)
或按内部估价填并在备注写明。</span>
</div>`,
foot: sign('PRM 询价作业 · 定案落表,齐检定盘由系统接着办', '统一报价 · A3 寻源询价 · 在办'),
});