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,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 寻源询价 · 在办'),
});