人手改的那一手真正落得下去;界面不撒谎那几处补齐

手改是**兜 AI 的错**的那一手 —— 这一轮把它从「摆在那儿但落不了地」做成真的:

件上就地改
· 保存**按地址收**,不看当下那张卡:载荷从前借道工单的 `blocks[i]` 取列名,
  于是熔断卡(没有 blocks)在屏上时,人补的那一格一个字都送不出去 ——
  而他要补的恰恰是把这一单卡住的那个必填格。授权面改由活动清单的台账声明把关。
· **能增删行**:错不止「这一格填错了」,还有「这一行不该在」与「漏了一行」。
  删挂记号不当场抹掉(看得见、可后悔),且**删要说出来**(`drops`)——
  「没回传就是删了」会把一次不完整的回传变成一次删表。
· **必填没填按不下提交**:与放行门 L1 同一个数同一套算法;闸上摆出差在哪一行哪一格。
· 只写真改了的那几行 —— 改一格却留「修订 4 行」的痕,那句话是假的。

判据与门
· 裁量判据带谓词时**谓词说了算**:「选填缺口已补齐」基座数得出来,
  人填好的目标价它照样判「客户未提供」的毛病由此消掉。
· 验收卡判据**读时现算** —— 人在成果件上补一格,判据跟着变。
· 放行门认「**办完了没有**」不认「有人写过」:从前写个 `false` 就把门推开,
  于是一单没打标就进了放行门。三个数全由基座现数。
· 拒回把「活没干完」说成「数或行对不上」的毛病:判据点名的表一行都没写时直说那一步没办。
  **指向两扇打不开的门,比不指还糟。**

界面不撒谎
· 日志只有三色:负责人绿 · 智能体蓝 · 系统灰(归侧五种是度量的分法,不是给人看的)。
· 验收收口答两件事:落了什么(按**交付物**点名,一份成果的几张表并成一行)、
  往哪儿去(转出哪一口、带过去哪些字段);一个口都没发就直说到此为止。
· 签完到收尾之间那一段有自己的话「字已签下,正在落地」—— 中间态本来就该说出来,只是要说对。
· 消息不再被内部事件刷屏:只有从通道进来的件才谈得上「无人认领」。
· 建客户公司与联系人**一次确认落两行**;联系人编码自增不问人;卡面摆表的人读名。

A1 三案例端到端全 released:全 A 类直通 12 拍 / 客户点名跳一条 10 拍 / 新客户缺必填 14 拍。
五道门禁全绿(971 passed)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsTDjoHovERXyy6Wmw1ALp
This commit is contained in:
2026-08-02 13:41:44 +08:00
co-authored by Claude Opus 5
parent 94f1c7a4ef
commit 259192f736
228 changed files with 27850 additions and 2847 deletions
@@ -1,13 +1,21 @@
/**
* 活动包的自测 —— **只准 import @sdk / @contracts 与白名单纯库**(编码规范 §5.1 / ADR-004)。
* 安装两审那一侧的验收在 `apps/api/.../activity-registry/__tests__` —— 二者之间只有安装通道,
* 没有代码通道,depcruise 双向隔离在守着。
* A1 询价受理的包内自测 —— **只准 import @sdk / @contracts / @sfeel**
* (编码规范 §5.1 / ADR-004)。安装两审那一侧的验收在
* `apps/api/.../activity-registry/__tests__` —— 二者之间只有安装通道,没有代码通道,
* depcruise 双向隔离在守着。
*
* 案例活动没有 `run`,所以自测不跑回合(那是 Loop 的事)。这里测的是**清单本身**:
* ① 清单合规 + 判据等级不谎报;
* ② 案例模型双表示同在、触达世界的项标了人工激活;
* ③ 两张判定表逐行求值(能算的不问 LLM);
* ④ **声明与契约对得上** —— 这一段最容易在改动中悄悄错位:
* 必交付物的台账地址、验收谓词读的列、出口载荷要的标量,三处必须都有台账列承载。
*/
import { describe, expect, it } from 'vitest';
import { activityManifestSchema } from '@contracts';
import { evaluateUnaryTest } from '@sfeel';
import { createMockHost } from '@sdk';
import { manifest, P03_DMN } from '../src/manifest.ts';
import { F01_DMN, INTAKE_DMN, P03_DMN, manifest } from '../src/manifest.ts';
import activity from '../src/index.ts';
describe('清单本身合规', () => {
@@ -21,7 +29,7 @@ describe('清单本身合规', () => {
it('L1/L2 判据都给了可机检的谓词 —— 验收等级不得谎报', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef).toBeDefined();
if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef, c.key).toBeDefined();
}
});
@@ -29,6 +37,12 @@ describe('清单本身合规', () => {
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();
}
});
});
describe('案例模型(AM 03/12.2)—— 双表示同版本', () => {
@@ -37,119 +51,342 @@ describe('案例模型(AM 03/12.2)—— 双表示同版本', () => {
it('CMMN 与说明书同在,不允许分叉', () => {
expect(model.cmmnXml).toContain('<casePlanModel');
expect(model.narrative.length).toBeGreaterThan(80);
expect(model.narrative.length).toBeGreaterThan(400);
});
it('触达世界的项恒需人工激活(AM 4.1 原理一)', () => {
// 解析归一、追问补全、查历史价 —— 三件都碰外界,一件也不许自动跑
for (const id of ['T_normalize', 'T_ask_back', 'T_history']) {
it('触达世界的项标了人工激活(AM 4.1 原理一)', () => {
/*
* 只剩放行这一件。定位客户与归一 RFQ 两步**发卡但不落地**:
* 补全卡与执行卡都是「问一句」,回来的值仍旧只进草稿层,
* 真正触达世界的只有放行那一下(铁律③)。
*/
for (const id of ['T_release']) {
expect(cmmn.manualActivation, `${id} 应为人工激活`).toContain(id);
}
});
/*
* **图上有几步就是几项**。上一版加了两个阶段、两个里程碑、一个裁量项、
* 一个用户事件监听器 —— 12 个计划项装 6 步的活,业务一分没多,卡壳的花样多了一堆:
* 每一条「行动被拒」都是结构上的,没有一条是业务判断上的(实测)。
* 阶段 / 里程碑 / 裁量项是 CMMN 有的东西,但「有」不等于「该用」。
*/
/*
* 2026-07-31 用户重定前两态:
* ① `S1_customer` 定位客户信息 —— 按发件邮箱认人,认不出发补全卡请人建档;
* ② `S2_normalize` 归一客户 RFQ 输入 —— 解析正文逐条落表。
* 旧的 S1_register / S2_ask_back / S3_normalize 三项合并进这两态。
*
* 2026-08-01 补回 `S3_confirm`(**人的门,不是 AI 的步**):
* 追问从前是「归一里缺字段时的动作」,于是不缺字段就不叫人 —— 一封齐整的询价
* 从起单一路自动跑到终局验收,人一眼没看过受理需求(用户当场指出)。
* 阶段2 那张执行页(`A1-询价受理需求.html`,页脚是【修改】【确认受理记录】)
* 从来就是一张人签的页,所以它得是**一项**:人工激活、无 doneWhen、一单必经一次。
*/
it('计划项照状态机一步一项:两态 + 人审定 + 后续三步 + 放行,不多建一件', () => {
expect(items(model.cmmnXml)).toEqual([
'S1_customer',
'S2_normalize',
'S3_confirm',
'S4_history',
'S5_classify',
'S6_route',
'T_release',
]);
});
/*
* **触达世界的项必须人工激活** —— 引擎不许自己按下它们。
* 受理需求那张卡交到人手上、放行那一签发出去,都是外面能看见的动作。
*/
it('两处人的门都标了人工激活:受理需求审定 · 受理放行', () => {
for (const id of ['S3_confirm', '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('常来回的项标了可重复 —— 认客户与归一都会走好几轮', () => {
for (const id of ['S1_customer', 'S2_normalize']) {
expect(cmmn.repetition, `${id} 应可重复`).toContain(id);
}
});
it('末尾一张验收单 —— 完成只出自放行通过', () => {
expect(model.cmmnXml).toContain('id="T_release"');
});
it('能算的交 DMN,不交 Agent —— 打标是 decisionTask', () => {
expect(model.cmmnXml).toContain('decisionRef="p03-type"');
expect(P03_DMN.tables.some((t) => t.id === 'p03-type')).toBe(true);
expect(INTAKE_DMN.tables.some((t) => t.id === 'p03-type')).toBe(true);
});
/*
* 作业指导**一处一份**:逐步的做法在剧本(`steps[].guide`),整单的事在说明书。
* 从前两处各写一份六步,实测分叉过 —— 说明书里写着 S1_register / S2_ask_back /
* S3_normalize 三个案例模型里根本不存在的计划项,模型照它报 itemId,一报一个被拒。
* 所以这里改测三件事:每一项都有剧本、剧本说得出做法、说明书**不许再点名计划项**。
*/
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('说明书不点名计划项 —— 点了就会与案例模型分叉(踩过)', () => {
// 认的是**计划项 id 的形状**(S数字_字母 / T_字母),不是「S1 → S2」那种路线简写
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);
}
});
});
/** 极小取值:只为断言用,不重实现引擎的解析器(那一份在 loop-engine 里有自己的测试) */
function parseCmmnLite(xml: string): { manualActivation: string[] } {
const ids: string[] = [];
for (const m of xml.matchAll(/<humanTask id="([^"]+)"[\s\S]*?<\/humanTask>/g)) {
if (m[0].includes('manualActivationRule')) ids.push(m[1]!);
}
return { manualActivation: ids };
/** 计划项 id 按出现次序 —— 断言「图上有几步就是几项」 */
function items(xml: string): string[] {
return [...xml.matchAll(/<(?:humanTask|decisionTask|processTask|caseTask) id="([^"]+)"/g)].map((m) => m[1] ?? '');
}
describe('三分类规则 P-03 —— 能算的不问 LLM', () => {
const table = P03_DMN.tables[0]!;
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 };
}
/** 按 DMN 语义逐行求值:FIRST 命中即定,单元格谓词复用 @sfeel */
const decide = (scope: Record<string, unknown>) => {
for (const rule of table.rules) {
const hit = table.inputs.every((input, i) => evaluateUnaryTest(rule.when[i] ?? '-', scope[input.expression], scope));
if (hit) return { type: rule.then[0], play: rule.then[1], rule: rule.id };
}
return undefined;
};
/** 按 DMN 语义逐行求值:FIRST 命中即定,单元格谓词复用 @sfeel */
function decideBy(table: typeof P03_DMN, scope: Record<string, unknown>) {
for (const rule of table.rules) {
const hit = table.inputs.every((input, i) => evaluateUnaryTest(rule.when[i] ?? '-', scope[input.expression], scope));
if (hit) return { outputs: rule.then, rule: rule.id };
}
return undefined;
}
describe('受理字段完整度 F-01 —— 三要素齐才放行', () => {
it('缺 PN/EAU/交期任一 → 退回补全', () => {
expect(decideBy(F01_DMN, { missing_key_total: 2, missing_minor_total: 0 })).toMatchObject({
outputs: ['缺PN/数量/交期', '退回补全'],
rule: 'f01-2',
});
});
it('只缺次要字段 → 受理并追问(不卡住)', () => {
expect(decideBy(F01_DMN, { missing_key_total: 0, missing_minor_total: 3 })).toMatchObject({
outputs: ['缺次要字段', '受理并追问'],
rule: 'f01-3',
});
});
it('八字段齐 → 往下走', () => {
expect(decideBy(F01_DMN, { missing_key_total: 0, missing_minor_total: 0 })).toMatchObject({ rule: 'f01-1' });
});
});
describe('三分类规则 P-03 —— 能算的不问 LLM', () => {
const decide = (scope: Record<string, unknown>) => decideBy(P03_DMN, scope);
it('历史价 ≤90 天且行情稳 → A · 直接快答', () => {
expect(decide({ historyDays: 30, marketStable: true, lifecycle: '在产' })).toEqual({
type: 'A',
play: '直接快答',
expect(decide({ history_days: 30, market_stable: true, lifecycle: '在产' })).toMatchObject({
outputs: ['A', '直接快答'],
rule: 'p03-a',
});
});
it('无历史 / 过期 / 波动 → B · 重估后报', () => {
expect(decide({ historyDays: 200, marketStable: false, lifecycle: '在产' })?.type).toBe('B');
expect(decide({ history_days: 9999, market_stable: false, lifecycle: '未知' })?.outputs[0]).toBe('B');
});
it('EOL / 缺货 / 要求替代 → C · 转案处理(优先于 A)', () => {
expect(decide({ historyDays: 10, marketStable: true, lifecycle: 'EOL' })).toMatchObject({ type: 'C', rule: 'p03-c' });
it('EOL / 缺货 / 要求替代 → C · 转案处理(**优先于 A**:新历史价也不该让 EOL 直通)', () => {
expect(decide({ history_days: 10, market_stable: true, lifecycle: 'EOL' })).toMatchObject({ rule: 'p03-c' });
});
it('同输入必同结论 —— 零方差、零 token', () => {
const scope = { historyDays: 30, marketStable: true, lifecycle: '在产' };
const scope = { history_days: 30, market_stable: true, lifecycle: '在产' };
expect(decide(scope)).toEqual(decide(scope));
});
});
describe('代码入口 —— 零环境能力,一切经基座六件', () => {
const contract = {
activityId: 'quote-intake',
version: '0.1.0',
data: { customerCode: 'C001', rfqRef: 'mail:m-1001' },
message: undefined,
outputPorts: { mandatory: ['intake'], selective: [] },
prohibitions: [],
discretion: [],
} as const;
/**
* 声明与契约的对齐 —— 三处引用同一批台账列,错位了不会报错,只会在放行那一刻
* 「判据取不到值 / 出口取不到字段」,而两者都长得像业务问题。所以在这儿钉死。
*/
describe('声明与契约对得上', () => {
const columns = new Set(
manifest.declarations.ledger.flatMap((d) => [`${d.domain}.${d.object}`, ...d.fields.map((f) => f.name)]),
);
it('全部打标后提交放行', async () => {
const host = createMockHost({
contract,
ledger: {
'quote_intake.record': [
{ customer_code: 'C001', pn: 'LM358DR', brand: 'TI', eau: 5000, type_tag: 'A' },
{ customer_code: 'C001', pn: 'NE555', brand: 'TI', eau: 2000, type_tag: 'B' },
],
'quote_intake.type_table': [{ pn: 'LM358DR', type_tag: 'A' }],
},
});
const outcome = await host.run(activity);
expect(outcome).toMatchObject({ kind: 'submit', deliverable: 'intake-record' });
expect(host.recording.views).toHaveLength(1);
expect(host.recording.says.join()).toContain('A 1 条直通定价');
it('必交付物的台账地址都声明过', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(columns.has(d.ledger ?? ''), `${d.key} → ${d.ledger}`).toBe(true);
}
});
it('尚有未打标即等待,不提交放行', async () => {
const host = createMockHost({
contract,
ledger: { 'quote_intake.record': [{ customer_code: 'C001', pn: 'LM358DR' }], 'quote_intake.type_table': [] },
});
expect(await host.run(activity)).toMatchObject({ kind: 'await' });
/*
* 除了台账列,谓词还能读一类**基座数出来的读数**:`域_对象_rows` = 本单在那张表上
* 写下了几行。它不是列(活动写不了它),正因如此才拦得住「两边都由活动自报」的假对账。
* 这里照样要核到底:对象没声明过,`quote_intake_recrod_rows` 这种手滑仍要当场炸。
*/
const rowReadings = new Set([
...manifest.declarations.ledger.map((d) => `${d.domain}_${d.object}_rows`),
/*
* `域_对象_missing` = 本单那张表缺必填的有几行(按声明的 `fields[].required` 现数)。
* 与 `_rows` 同类:活动写不了它,所以它才拦得住「两边都由活动自报」的假对账 ——
* 「必填齐」那条判据从前读活动自报的 `missing_key_total`,模型漏算一次就永远绿不了。
*/
...manifest.declarations.ledger
.filter((d) => d.fields.some((f) => f.required === true))
.map((d) => `${d.domain}_${d.object}_missing`),
/*
* `域_对象_<列>_<值>` = 本单那张表按值现数的条数(取值域由 `fields[].tally` 声明)。
* 与 `_rows` / `_missing` 同类:活动写不了它 —— 所以人在放行页改了类标,
* 分布条、去向谓词、派单对账**全都跟着重算**。手工修改本来就是为了纠正 AI 的判断。
*/
...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) ?? []) {
// S-FEEL 的保留字不是台账名 —— 漏掉它,`a = 0 or b > 0` 会被当成引用了一列叫 `or`
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('视图模版含脚本时宿主拒推 —— 容器无脚本执行权', async () => {
const host = createMockHost({ contract });
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
it('出口载荷的标量字段都有台账列承载(§7.5;踩过:两枚去向旗没列 → G1 全流死路)', () => {
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('裸调用被基座拒发 —— 调用必附上下文', async () => {
const host = createMockHost({ contract });
await expect(
host.base.interaction.issueWorkOrder({ mode: 'execution', title: 'x', basis: [], ask: 'y', fields: [] }),
).rejects.toThrow(/裸调用/);
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('监听时限必配 —— 无死等', async () => {
const host = createMockHost({ contract });
await expect(host.base.tools.listen({ eventType: 'data', keyFilter: {}, deadline: '' })).rejects.toThrow(/时限必配/);
describe('报告模版 —— 活动持有,容器无脚本执行权(§9.3)', () => {
it('四个局面各一张,不在一张里堆条件分支', () => {
expect(manifest.assets.viewTemplates.map((t) => t.name)).toEqual([
'intake-overview',
'intake-classified',
'intake-release',
]);
});
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>');
}
});
/*
* 放行那张报告要摆出**去向是怎么算出来的** —— 人签字前得看见这一单要往哪走,
* 更要看见凭哪一条走。
*
* 从前这里断言的是两枚去向旗(`need_alternative` / `need_sourcing`)——
* 那是「活动自己写下一个布尔,再照它发口」的旧口径:旗立错了没人看得出来。
* 现在去向由契约里三个出口各自的 `when` 按序求值(首个命中即出),
* 基座把命中结果注入 `case:route*`;报告摆的是**同一次判定**,不是这屏又算一遍。
* 所以这里改断言那条链:判定表三行、命中注入、以及判据读的那三个数。
*/
it('放行那张报告摆出分支判定与算出来的去向 —— 不是人点出来的,也不是报告自己算的', () => {
const release = manifest.assets.viewTemplates.find((t) => t.name === 'intake-release')?.html ?? '';
// 判定表三行俱在,壳上按命中项上色
expect(release).toContain('data-class-of="case:routeKey"');
for (const row of ['jc-dmn-r r1', 'jc-dmn-r r2', 'jc-dmn-r r3']) expect(release).toContain(row);
/*
* 去向那句话由基座填,报告不复述。
* **只摆人话的那一份**(2026-08-02 用户定:报告里不该出现系统内的英文名)——
* 口名 `to-alternative` 与判据表达式 `…_type_tag_c > 0` 都是给排障看的,
* 摆在人的成果件上等于噪音;人要知道的是「转到哪个环节、为什么」。
*/
expect(release).toContain('data-slot="case:routeTo"');
expect(release).not.toContain('case:routePort');
expect(release).not.toContain('case:routeWhy');
// 判据读的是三类条数:摆出来的数与算出去向用的是同三列
// 三类条数读**基座按值现数**的那一份 —— 人改了类标,分布条与去向才跟着变
for (const col of ['quote_intake_record_type_tag_a', 'quote_intake_record_type_tag_b', 'quote_intake_record_type_tag_c'])
expect(release).toContain(col);
});
/*
* **契约里每个出口都得写 `when`** —— 少一口,`routePort` 就整组算不出来,
* 放行卡当场退回「人选一口」,而报告上那张判定表也就没有命中行:
* 一屏三条规则都不高亮,看着像判定没跑,实则是这组分支有一段没有判据。
*/
it('选择组里每一口都写了判据 —— 去向才算得出来', () => {
const groups = manifest.contract.output.selective;
expect(groups.length).toBeGreaterThan(0);
for (const group of groups) {
expect(group.ports.length).toBeGreaterThanOrEqual(2);
for (const port of group.ports) expect(port.when, `${group.group}/${port.name}`).toBeTruthy();
}
});
it('宿主拒推含脚本的模版 —— 这道门在基座,不靠模版作者自觉', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
});
});
const CONTRACT = {
activityId: 'quote-intake',
version: '1.0.0',
data: { customerEmail: 'purchasing@sz-machinery.com', subject: '【询价】RFQ-2026-0001', body: 'LM358DR 12K/年' },
message: undefined,
outputPorts: { mandatory: ['intake'], selective: [] },
prohibitions: [],
discretion: [],
} as const;
describe('代码入口 —— 案例活动没有 run,不是残包', () => {
it('默认导出是一个有名字的活动定义', () => {
expect(activity.name).toBe('quote-intake');
});
it('mock 宿主如实报「没有 run」,不静默返回一个假的 outcome', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.run(activity)).rejects.toThrow(/没有 run/);
});
});
@@ -0,0 +1,252 @@
/**
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**。
*
* A1 反复空转的根因是没有它:S1 一次把八字段写齐之后,S3「补齐八字段」已经无活可干,
* 而每一拍喂给模型的是整本说明书,它只能把同样三行再写一遍 ——
* 被「写了等于没写」拒回,下一拍再来一遍,连拒到熔断。
*
* 剧本把这件事挪进引擎:`doneWhen` 一真,引擎自己收掉那一步,**不问模型**。
* 所以这几条断言守的是剧本本身的可用性 —— 项 id 对不对得上、判据会不会开局即真、
* 声明的台账与工具是不是活动真有的。写错任何一条,后果都是运行时才发现的空转。
*/
import { describe, expect, it } from 'vitest';
import { evaluateCondition } from '@sfeel';
import { INTAKE_STEPS } from '../src/case-model.ts';
import { CMMN_XML } from '../src/case-model.ts';
import { manifest } from '../src/manifest.ts';
/** 从 CMMN XML 里把计划项 id 抠出来 —— 剧本认的是它们 */
const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map(
(m) => m[1] ?? '',
);
describe('剧本与案例模型对得上 —— 对不上的条目等于没写', () => {
it('每一条剧本都指向一个真实存在的计划项', () => {
for (const step of INTAKE_STEPS) {
expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
}
});
it('图上每一步都有剧本 —— 漏掉的那一步会退回整本说明书', () => {
for (const id of ITEM_IDS) {
expect(INTAKE_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
}
});
it('清单里挂的就是这份剧本(不是另抄一份)', () => {
expect(manifest.assets.businessModel.steps).toBe(INTAKE_STEPS);
});
});
describe('doneWhen 是「成果已存在」的判据,不是「该不该做」的判据', () => {
/** 开案那一刻的局面:台账一行没有,汇总列全无值 */
const FRESH: Record<string, unknown> = {
customerEmail: 'buyer@x.com',
subject: '【询价】RFQ-2026-0001',
body: 'LM358DR 12K/年',
};
it('**开局一步都不该被自动收掉** —— 这是最容易写错的地方', () => {
for (const step of INTAKE_STEPS) {
if (step.doneWhen === undefined) continue;
const ripe = safeEval(step.doneWhen, FRESH);
expect(ripe, `${step.item} 的 doneWhen「${step.doneWhen}」开局就为真,那一步会在还没干之前被收掉`).toBe(false);
}
});
/* ── ① 定位客户信息 ─────────────────────────────────────────────── */
it('**老客户**认出来了 → S1 收得掉(不必问人)', () => {
expect(safeEval(doneOf('S1_customer'), { ...FRESH, customer_total: 1, customer_new_total: 0 })).toBe(true);
});
/*
* 这一条是拿一次实测换来的:剧本白纸黑字写着「别拿邮箱域名凑一个客户编码,也别自己编」,
* 模型照样从 purchasing@sz-machinery.com 编出 `CUST-SZ-MACH` 直接把档建了 ——
* 人根本没被问过一句,而客户编码是主数据的主键,编错一次后面每一单都跟着错。
* **说明书管不住,收步条件管**:新客户没问过人,这一步就收不掉。
*/
it('**新客户没问过人 → S1 收不掉**(说明书管不住的,让收步条件管)', () => {
expect(safeEval(doneOf('S1_customer'), { ...FRESH, customer_total: 1, customer_new_total: 1 })).toBe(false);
});
it('新客户问过人了 → S1 收得掉', () => {
expect(
safeEval(doneOf('S1_customer'), { ...FRESH, customer_total: 1, customer_new_total: 1, customer_asked_total: 1 }),
).toBe(true);
});
/*
* 这一条守的是那个老坑:**不能写 `customer_code != null`** ——
* S-FEEL 里未绑定的名字 `!= null` 求值为 true,这一步会在还没认人之前就被收掉。
* 判据必须取同步写下的计数列。
*/
it('还没认到客户 → S1 收不掉,归一也进不了场', () => {
expect(safeEval(doneOf('S1_customer'), FRESH)).toBe(false);
expect(safeEval(doneOf('S2_normalize'), FRESH)).toBe(false);
});
/* ── ② 归一客户 RFQ 输入 ────────────────────────────────────────── */
const normalized = (over: Record<string, unknown>) => ({
...FRESH,
customer_total: 1,
line_total: 3,
// 拆出来几行就报几条 —— 收步与验收同源(见下面那条「多写一行」)
quote_intake_record_rows: 3,
missing_key_total: 0,
missing_minor_total: 0,
asked_total: 0,
...over,
});
it('拆出行来了 → S2 收得掉(拆料是 AI 的活,办完交给下一步请人)', () => {
expect(safeEval(doneOf('S2_normalize'), normalized({}))).toBe(true);
});
/*
* **缺不缺字段不再是 S2 的收步条件** —— 缺口是卡上要说清的事,不是拦住拆料的理由。
* 从前把「缺了就得问过人」写进这里,连带出下面那条真正的病:
* 不缺就等于不必经过人。现在分开:S2 只管拆,S3_confirm 管「经过人」。
*/
it('缺必填 / 缺选填都不拦 S2 —— 那是 S3 卡上的事', () => {
expect(safeEval(doneOf('S2_normalize'), normalized({ missing_key_total: 2 }))).toBe(true);
expect(safeEval(doneOf('S2_normalize'), normalized({ missing_minor_total: 2 }))).toBe(true);
});
it('一条都没归一出来 → S2 收不掉', () => {
expect(safeEval(doneOf('S2_normalize'), normalized({ line_total: 0 }))).toBe(false);
});
/*
* **多写一行 → S2 就收不掉**(2026-08-01 加)。
*
* 实测:客户明说本轮跳过的那颗料被写进了有效清单 —— 数报得全对(9 有效 / 1 跳过),
* 行却有 10 条。从前 S2 只看 `line_total > 0`,照收;错要到第 10 拍提交放行时
* 才被同一条等式拦住,那时模型只会去改数(改了 skipped_total 又不对),连拒五拍熔断。
* **同一个错,在第 2 拍发现比在第 10 拍便宜得多**:那时行刚写下,多的是哪一行一目了然。
*/
it('**多写一行(如把客户说跳过的那条也写了)→ S2 收不掉**,错在第 2 拍就现形', () => {
expect(safeEval(doneOf('S2_normalize'), normalized({ quote_intake_record_rows: 4 }))).toBe(false);
});
/* ── ③ 受理需求交人审定:阶段2 那张执行页,一单必经一次 ─────────── */
/*
* **这一条是用户 2026-08-01 当场逼出来的**:「我都没确认需求 它就让我验收了」。
*
* 病根在旧口径:「经过人」是靠 `missing_minor_total = 0 or answered_total > 0`
* 表达的 —— 只有缺东西的时候才叫人。字段齐全的一封询价进来,这个条件在拆完那一刻
* 就为真,引擎自己把归一那步收了,人一眼没看过受理需求,
* 下一次被叫到已经是终局的验收放行。**确认不是缺口的补丁,是一道恒在的门。**
*
* 所以这一步跟放行同一个做法:**人工激活 + 不给 doneWhen**。
* 给它写 doneWhen 就是给引擎一把替人签字的钥匙;
* 而「人看过、改过、认了」按定义也不是台账里任何一个数能自己变出来的。
*/
it('**受理需求那一步不给 doneWhen** —— 引擎不许替人认下这份稿', () => {
expect(INTAKE_STEPS.find((s) => s.item === 'S3_confirm')?.doneWhen).toBeUndefined();
});
it('审定这一步在剧本里,且摆的是阶段2 那份目标件', () => {
const confirm = INTAKE_STEPS.find((s) => s.item === 'S3_confirm');
expect(confirm, '受理需求交人审定这一步不见了').toBeDefined();
expect(confirm?.view).toBe('intake-overview');
// 卡上那张表绑台账整份铺开 —— 人改的是九行,不是缺的那一两行
expect(confirm?.guide).toContain('"ledger":"quote_intake.record"');
expect(confirm?.guide).toContain('confirmed_total');
});
/*
* **收步判据与验收判据同源**(2026-08-01 改)。
*
* 从前 S5 只看自报的 `untyped_total = 0`、S6 只看「写过 alt_request_total 这个数」——
* 数写对了就收步,而验收比的是**基座数出来的行**(`type_table_rows = record_rows`、
* `alt_request_rows = type_c_total`)。两处比的不是同一件事,松的那一截就是返工:
* 收了步却过不了验收,模型得回头补写一张已经收掉的表。
*/
it('打完标且两张表行数对上 → S5 收得掉', () => {
const typed = { line_total: 3, untyped_total: 0, quote_intake_type_table_rows: 3, quote_intake_record_rows: 3 };
expect(safeEval(doneOf('S5_classify'), typed)).toBe(true);
});
it('**数归零了但表没写全 → S5 收不掉** —— 那正是从前「收了步却过不了验收」的口子', () => {
const short = { line_total: 3, untyped_total: 0, quote_intake_type_table_rows: 1, quote_intake_record_rows: 3 };
expect(safeEval(doneOf('S5_classify'), short)).toBe(false);
});
/*
* **S6 不再开单**(2026-08-02 用户当场定:「替代评估请求 / 询价请求这两项数据
* 不在这里产出;随令牌流向下一个节点的只有工作成果验收内的内容」)。
* 收步判据随之改成「两枚旗立过了」——它仍取**计数**而非布尔:
* 一单全 A 类时两枚旗都是 false,「写了 false」与「压根没写」分不清。
*/
it('去向定了 → S6 收得掉', () => {
expect(safeEval(doneOf('S6_route'), { untyped_total: 0, route_decided_total: 1 })).toBe(true);
});
it('**没记过那个数 → S6 收不掉** —— 未绑定的名字比大小为假,不会开局即收', () => {
expect(safeEval(doneOf('S6_route'), { untyped_total: 0 })).toBe(false);
});
it('全 A 类(两枚旗都是 false)照样收得掉 —— 判据看的是「定过没有」,不是旗本身', () => {
const allA = { untyped_total: 0, route_decided_total: 1, need_alternative: false, need_sourcing: false };
expect(safeEval(doneOf('S6_route'), allA)).toBe(true);
});
it('**S6 不写请求单** —— 那两张表已经不归 A1 产出', () => {
const writes = INTAKE_STEPS.find((x) => x.item === 'S6_route')?.writes ?? [];
expect(writes).toEqual(['quote_intake.verdict']);
});
/*
* 放行触达世界 —— 它必须由人按一下(CMMN 里标了 manualActivationRule)。
* 给它写 doneWhen 就是给引擎一把自动放行的钥匙,那是铁律③ 的红线。
*/
it('**放行那一项不给 doneWhen** —— 终局唯放行,引擎不许自动收它', () => {
expect(INTAKE_STEPS.find((s) => s.item === 'T_release')?.doneWhen).toBeUndefined();
});
});
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 INTAKE_STEPS) {
for (const address of step.writes) {
expect(declaredLedger, `${step.item} 要写 ${address},而活动没声明这个台账对象`).toContain(address);
}
}
});
it('每一步要调的工具都在清单的工具声明里', () => {
for (const step of INTAKE_STEPS) {
for (const tool of step.tools) {
expect(declaredTools, `${step.item} 要调 ${tool},而活动没声明这件工具`).toContain(tool);
}
}
});
it('每一步都说得出它交出什么(intent 与 guide 都不为空)', () => {
for (const step of INTAKE_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 = INTAKE_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;
}
}
+689 -58
View File
@@ -1,85 +1,716 @@
/**
* A1 询价受理的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
*
* 2026-07-29 由步序声明(flow)改写而来:活动不再分范式,一律案例模型 + Agent 临场调度。
* 原来的六步在这里各归其位 —— 解析归一与追问补全同属「收料归一」阶段(常来回几轮,
* 所以带 repetitionRule);查历史价与三分类打标属「定性分级」;末尾一张验收单。
* 业务出处:`docs/MVP示例工作流.html` §1.1 的那张步骤流程图,原样照搬:
*
* ● 接流程起点 → S1 接收登记 → D1 判断完整度
* ├─ 不完整 → S2 追问补全信息 ─┐
* └─ 完整 ───────────────────┴→ S3 逐条解析归一
* → S4 查历史价 → S5 逐条定状况标 → S6 定去向与派单 → G1
*
* **最长路径就是这六步**(常路五步 —— 字段齐就不走 S2),末尾加一张受理放行(终局唯放行)。
*
* ── 建模纪律:图上有几步就是几项 ──────────────────────────────────────
*
* 这份模型**重写过一次**。上一版自作主张加了两个阶段(收料归一 / 定性分级)、两个里程碑、
* 一个裁量项、一个用户事件监听器,12 个计划项去装 6 步的活。业务上一分没多,
* 卡壳的花样却多了一堆:阶段被当成写的落点(收了阶段,里面的项就永远入不了场)、
* 里程碑要不要收、项该先 start 还是先干、编出不存在的项 id……
*
* 实测账很清楚:**每一条「行动被拒」都是结构上的,没有一条是业务判断上的** ——
* 模型在跟 harness 较劲,不是在办事;一单烧掉二三十万 token 才收口,还常常收不了口。
*
* 所以:**图上有几步就是几项,不多建一件**。阶段、里程碑、裁量项都是 CMMN 有的东西,
* 但「有」不等于「该用」—— 它们要能换来业务上说得出的好处,否则就只是给 Agent 添的坑。
*/
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
<case id="intake">
<!--
案卷项的 id **就是任务视图里的键名** —— 前提的 onPart 认的是「这个键有值了」。
起单入参(subject)与台账列(line_total / missing_key_total / history_days /
untyped_total / need_sourcing)都会摊平到任务视图顶层,所以照它们命名。
名字对不上就等于前提永不满足:整案第一步都动不了(踩过)。
开案即有的只有那封信(客户邮箱 / 主题 / 邮件内容 / 邮件附件)。**询价单号不在其列** ——
它是 S1 从信里认出来的,认出来才写得动台账(所有台账对象都以 rfq_ref 为主键)。
-->
<caseFileModel>
<caseFileItem id="rfq" name="询价来函"/>
<caseFileItem id="records" name="统一受理记录"/>
<caseFileItem id="history" name="历史价格情报"/>
<caseFileItem id="verdict" name="受理结论"/>
<caseFileItem id="subject" name="来件主题(开案即有)"/>
<!-- ① 定位客户:认出来(或人建出来)才算数 -->
<caseFileItem id="customer_total" name="客户已确认"/>
<caseFileItem id="customer_new_total" name="新客户(需建档)"/>
<caseFileItem id="customer_asked_total" name="已就建档问过人"/>
<caseFileItem id="line_total" name="登记出的条目数"/>
<!-- ② 归一 RFQ:必填缺 = PN / 年用量;选填缺 = 品牌 / 备注 -->
<!--
缺必填的行数由**基座**按台账声明的 required 现数(quote_intake_record_missing)——
不读活动自报的 missing_key_total:那个数要模型自己重算,漏算一次这道门就永远开不了
(连跑十轮里三轮死在这)。自报的两个数照旧写,它们是给报告和人看的。
-->
<caseFileItem id="quote_intake_record_missing" name="缺必填的行数(基座现数)"/>
<caseFileItem id="missing_key_total" name="缺必填字段条数(PN/年用量)"/>
<caseFileItem id="missing_minor_total" name="缺选填字段条数(品牌/备注)"/>
<caseFileItem id="asked_total" name="已就缺口问过人的次数"/>
<caseFileItem id="answered_total" name="人答完并已落表的次数"/>
<!-- ③ 受理需求交人审定:这一单**经过人的手**了没有 -->
<caseFileItem id="confirmed_total" name="受理需求已经人审定"/>
<caseFileItem id="history_days" name="历史价判据"/>
<caseFileItem id="untyped_total" name="未打标条数"/>
<caseFileItem id="need_sourcing" name="去向旗"/>
</caseFileModel>
<casePlanModel id="plan" name="询价受理" autoComplete="true">
<sentry id="s_rfq">
<onPart id="op_rfq" caseFileItemRef="rfq"><standardEvent>create</standardEvent></onPart>
<!-- D1 判断完整度在图上是一个菱形,在这里是两条前提:先认客户,再看归一齐没齐 -->
<!--
① 客户认出来了 —— 归一那一步的入场前提。
先认客户再解析正文,不是先后顺序的洁癖:归一出来的每一条都要落在
某个客户名下(record.customer_code),客户没定位就写不动那一列。
-->
<sentry id="s_customer">
<onPart id="op_cu" caseFileItemRef="customer_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_cu"><condition>customer_total &gt; 0 and (customer_new_total = 0 or customer_asked_total &gt; 0)</condition></ifPart>
</sentry>
<sentry id="s_incomplete">
<onPart id="op_ic" caseFileItemRef="records"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_ic"><condition>missingKeyFields &gt; 0</condition></ifPart>
<!--
② 拆完了 —— 交人审定那一步的入场前提。只看「有没有拆出行来」:
缺不缺字段不影响该不该给人看,缺口是**卡上要说清的事**,不是拦住这张卡的理由。
-->
<sentry id="s_parsed">
<onPart id="op_ps" caseFileItemRef="line_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_ps"><condition>line_total &gt; 0</condition></ifPart>
</sentry>
<!--
③ **人审定过了** —— 后面查历史价/打标的入场前提。
必填(PN/年用量)缺一条都不算完(它是验收标准);而**这一单必须经过人的手**:
confirmed_total 由「人把受理需求那张卡交回来」这件事产生 —— 详见 S3_confirm 的注释。
从前这里写的是「missing_minor_total = 0 or answered_total > 0」:
**只在缺东西的时候才叫人**。于是一封字段齐全的询价进来,这个条件当场为真,
引擎在下一拍开头把归一那一步自己收了 —— 人一眼没看过受理需求,
下一次被叫到已经是终局的验收放行(用户 2026-08-01 当场指出:
「我都没确认需求 它就让我验收了」)。**确认不是缺口的补丁,是这一步本身。**
-->
<sentry id="s_complete">
<onPart id="op_cp" caseFileItemRef="records"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_cp"><condition>missingKeyFields = 0</condition></ifPart>
<onPart id="op_cp" caseFileItemRef="confirmed_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_cp"><condition>line_total &gt; 0 and quote_intake_record_missing = 0 and confirmed_total &gt; 0</condition></ifPart>
</sentry>
<sentry id="s_basis">
<onPart id="op_bs" caseFileItemRef="history_days"><standardEvent>update</standardEvent></onPart>
</sentry>
<!--
打完标才进得了定去向 —— 条件读**基座按值现数**的三类条数,不读活动自报的
untyped_total(那是它自己写的数,写错一次这道门就形同虚设)。
-->
<sentry id="s_typed">
<onPart id="op_tp" caseFileItemRef="records"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_tp"><condition>untyped = 0</condition></ifPart>
<onPart id="op_tp" caseFileItemRef="untyped_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_tp"><condition>quote_intake_record_rows &gt; 0 and quote_intake_record_type_tag_a + quote_intake_record_type_tag_b + quote_intake_record_type_tag_c = quote_intake_record_rows</condition></ifPart>
</sentry>
<!--
── 放行门的入场条件(2026-08-02 用户实测逼出来的)────────────────────────
从前这道哨兵**只有 onPart 没有 ifPart**:意思是「只要有人往 need_sourcing
写过一笔就开门」—— 写个 false 也算。实测的下场:模型跳过 S4/S5/S6,
在 verdict 上顺手写了 need_sourcing: false,放行门当场 Available,
它 start 完就提交;而受理记录三行一个标都没打、分类表一行都没写。
提交门正确地拦住了,可**该拦的地方本不该放它进来** —— 一道只认「有人写过」
的门,等于没有门。
现在认的是**办完了没有**,且三个数全由基座现数(活动自报的数改一下就骗过去了):
逐条打了标、去向定过了。route_decided_total 是 S6 显式写下的记账数
(旗是布尔,分不清「没写」与「写了 false」)。
-->
<sentry id="s_routed">
<onPart id="op_rt" caseFileItemRef="need_sourcing"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_rt"><condition>quote_intake_record_rows &gt; 0 and quote_intake_record_type_tag_a + quote_intake_record_type_tag_b + quote_intake_record_type_tag_c = quote_intake_record_rows and route_decided_total &gt; 0</condition></ifPart>
</sentry>
<stage id="S1" name="收料归一" autoComplete="true">
<entryCriterion id="ec_s1" sentryRef="s_rfq"/>
<humanTask id="T_normalize" name="人工逐条解析归一">
<itemControl><manualActivationRule id="m_nm"/><repetitionRule id="r_nm"/></itemControl>
</humanTask>
<humanTask id="T_ask_back" name="人工追问补全信息">
<entryCriterion id="ec_ask" sentryRef="s_incomplete"/>
<itemControl><manualActivationRule id="m_ab"/><repetitionRule id="r_ab"/></itemControl>
</humanTask>
</stage>
<milestone id="M_complete" name="八字段齐"/>
<stage id="S2" name="定性分级" autoComplete="true">
<entryCriterion id="ec_s2" sentryRef="s_complete"/>
<humanTask id="T_history" name="人工查历史价">
<itemControl><manualActivationRule id="m_hi"/></itemControl>
</humanTask>
<decisionTask id="D_classify" name="逐条打标 A/B/C" decisionRef="p03-type"/>
<planningTable id="pt_s2">
<humanTask id="T_lifecycle" name="人工核生命周期"/>
</planningTable>
</stage>
<milestone id="M_typed" name="全条已打标"/>
<humanTask id="T_release" name="人工受理结论验收放行">
<entryCriterion id="ec_rel" sentryRef="s_typed"/>
<itemControl><manualActivationRule id="m_rl"/></itemControl>
<!--
① 定位客户信息 —— 拿发件邮箱去客户主数据里认人。
认得出就取它的客户编码;认不出说明这是个**新客户**,发补全卡请人建档
(公司名、客户编码、联系人)—— 建档是人的事,活动不替他编一个客户出来。
-->
<humanTask id="S1_customer" name="定位客户信息:按发件邮箱认客户,认不出请人建档">
<itemControl><repetitionRule id="r_s1"/></itemControl>
</humanTask>
<timerEventListener id="TL_half" name="时限过半">
<timerExpression>PT12H</timerExpression>
</timerEventListener>
<userEventListener id="UL_amend" name="客户改需求"/>
<!--
② 归一客户 RFQ 输入 —— 解析邮件正文,逐条落成受理记录。
PN / 年用量**必填**,其余**选填**;缺哪几格照实记进两个缺口数。
**这一步不叫人**:拆料是 AI 的活,拆完交给下一步给人看。
-->
<humanTask id="S2_normalize" name="归一客户RFQ输入:解析正文逐条落表">
<entryCriterion id="ec_s2" sentryRef="s_customer"/>
<itemControl><repetitionRule id="r_s2"/></itemControl>
</humanTask>
<!--
③ 受理需求交人审定 —— **阶段2 那张执行页,一单必经一次**。
目标件 「A1-询价受理需求.html」 的落款写着「AI 拟稿,你审定;可就地修改后再确认」,
页脚是【修改】与【确认受理记录】—— 它从来就是一张**人签的页**,不是缺东西时才弹的追问。
**manualActivation + 不给 doneWhen**:两样缺一不可。
触达世界的项一律不给 doneWhen —— 给了就等于给引擎一把替人签字的钥匙;
而这一步的成果(人看过、改过、确认了)按定义不可能由引擎自己判出来。
-->
<humanTask id="S3_confirm" name="受理需求交人审定:整份清单可就地改,确认后才往下走">
<entryCriterion id="ec_s3" sentryRef="s_parsed"/>
<itemControl><manualActivationRule id="m_s3"/></itemControl>
</humanTask>
<!-- S4 查历史价:取齐 P-03 的三项判据 -->
<humanTask id="S4_history" name="查历史价:取齐距上次报价天数/行情/生命周期">
<entryCriterion id="ec_s4" sentryRef="s_complete"/>
</humanTask>
<!-- S5 逐条定状况标:决策表求值,不是临场判断 -->
<decisionTask id="S5_classify" name="逐条定状况标:按三分类规则P-03打标" decisionRef="p03-type">
<entryCriterion id="ec_s5" sentryRef="s_basis"/>
</decisionTask>
<!-- S6 定去向与派单:两枚去向旗 + 开替代评估请求/询价请求 -->
<humanTask id="S6_route" name="定去向与派单:立两枚旗,开替代评估请求/询价请求">
<entryCriterion id="ec_s6" sentryRef="s_typed"/>
</humanTask>
<!-- 受理放行:终局唯放行 —— 触达世界,必须人工激活 -->
<humanTask id="T_release" name="受理结论验收放行">
<entryCriterion id="ec_rel" sentryRef="s_routed"/>
<itemControl><manualActivationRule id="m_rl"/></itemControl>
</humanTask>
</casePlanModel>
</case>
</definitions>`;
export const NARRATIVE = `§0 开案:一封询价来函即开案。信里是 RFQ 正文与 BOM 附件,条目从几条到上百条不等。
受理这件事的成品只有一样:**一份能往下走的统一受理记录** —— 八字段齐、每条都打了标。
/**
* 案例说明书 —— **只写整单一以贯之的事**(2026-07-31 砍掉逐步做法)。
*
* 引擎每一拍已经把「当前这一步怎么做」单独摊开喂进去了(`INTAKE_STEPS[].guide`)。
* 说明书里再写一遍全部六步,是**两笔账同时亏**:
* · 钱 —— 同一段做法一拍烧两遍,而它是每拍都在的常驻上下文;
* · 准 —— 两份会分叉。实测就分叉过:这里写着 `S1_register` / `S2_ask_back` /
* `S3_normalize` 三个**案例模型里根本不存在的计划项**(真名是 S1_customer /
* S2_normalize),模型照说明书报 itemId,一报一个被拒。
*
* 所以留下的只有逐步做法**说不了**的那几件:这一单是什么、成品是什么、
* 一路上怎么跟人说话、怎么把一项干完、结案是什么。逐步的活归剧本。
*/
export const NARRATIVE = `§0 开案:**一封客户询价来函即开案**。你手上只有这封信的四样东西 ——
客户邮箱(customerEmail)、主题(subject)、邮件内容(body)、邮件附件(attachments,真文件)。
没有客户编码,没有询价单号:那两样是**受理出来的**,不是随信送来的。
真实 RFQ 五花八门 —— 俄文 BOM、客户内部码、字段东缺西缺都常见。
**受理这件事的成品只有一样**:一份能往下走的询价受理记录 —— 逐条落表、每条打了状况标、
两枚去向旗定了。SLA:受理<15 分钟,判定<45 分钟。判错状况,快单变慢单。
S1 收料归一:逐条把 BOM 解析成统一格式(PN / 厂牌 / EAU / 目标价 / 交期 / 包装 / 品质要求 / 备注)。
解析不出的不许臆造 —— 缺关键字段(PN、EAU、交期为必要三要素)就追问客户,常要来回一两轮,
所以这两件事都可重复做。**吃不准的宁可空着去问,也不许填一个看起来合理的值。**
**整条路就这几步**,一步一个计划项,别绕(每一步的做法,轮到它时会单独摊给你,
这里不重复):S1 定位客户 → S2 归一受理 → **S3 受理需求交人审定** → S4 查历史价 →
S5 逐条定状况标 → S6 定去向与派单 → 提交放行。
S2 定性分级:先查历史价(同客户同料近一年有没有报过、隔了多久、行情稳不稳),
再按三分类规则 P-03 逐条打标 —— A 类历史价直用、B 类需重新核价、C 类需选型或寻源。
拿不准的一律降 B,不许臆断 A:错标 A 会让一条本该重新核价的料带着旧价一路走到报价单上。
生命周期核查是裁量项 —— 遇到疑似 EOL 的料随手拉一次。
**这一单要经过人两次,不多不少**:S3 人审定受理需求(阶段2 那张执行页,
字段齐不齐都要发)、放行时人签字。**别把 S3 当成缺东西时的追问** ——
不缺也发:拆错一格只有人看得出来,而那些错往后每一步都跟着跑。
§Z 结案:八字段齐、每条都已打标、BD 签字放行,自动结案(CMMN AutoComplete)。
结论只有两个去向要交出去:需不需要选型、需不需要寻源 —— 下游的两道网关读的就是这两面旗。`;
**附件是真文件,基座已经替你读过一遍**。每一件长这样
\`{ name, contentType, size, ref, text?, note }\`:
\`text\` 有值就是**读出来的内容**(xlsx 读成制表符分列的行,第一行多半是表头),照它拆条;
\`text\` 没有就是这类文件读不出文本(\`note\` 里写着为什么,比如 pdf、扫描件),
这时以邮件内容为准。**别猜附件里有什么**:文件名叫 BOM 不等于里面有 BOM。
【一路上要说话】负责人(BD)正看着这一单。**每一拍都带一句 say** —— 你是在跟他当面说话,
不是播报进度。他明天要拿这些话去跟客户和采购讲,所以说的得是**业务的话**:
料号、交期、用量、价、客户要求;不是「关键字段」「归一化阶段」「写草稿 3 行」这些系统词。
一两句就够,口语,像同事扭头说一声。局面变了顺手用 view 换画面:
· S2 逐条落表后 → view = intake-overview。**先说这单是什么**,再说有没有坑:
「深圳机械这单三颗料,车规 Analog;交期用量都写清楚了,只有 NE555P 没给目标价。」
· S5 打标完 → view = intake-classified。**说结论对这单的影响**:
「LM358DR 有近期成交价可以直接报;NE555P 半年多没动过要重新核;TL072CDR 停产了,得找替代。」
· S6 定完去向 → view = intake-release。**说这单接下来往哪走、谁接手**。
别每拍一个腔调复读(「已完成X,现在进行Y」)。事情不同,话就该不同 ——
调工具那一拍说「我去查这三颗的历史价」,拿到结果那一拍说查出了什么,
两句话该是不一样的语气。有风险、要他拿主意、或者你打算叫人,**先说出来**,
别等他自己从表里看出来。不说话的后果是:他的对话区从头到尾空白,只看得见「多了一个任务」。
【怎么把一项干完 · 一拍连做】一个计划项常要**两三个动作**才交得出成果(先写记录、
再结算总数;先调工具拿判据、再把判据写进台账)。write 和 tool 都**不会**自动把那一项收掉 ——
都做完了,用 finish 宣告这一项完成。**这些动作排进同一拍的 actions 串里一口气给**,
别拆拍:「写 record、写 verdict、finish S1」是一拍,不是三拍;
「写 type_table 打标结果、写 verdict 统计、finish S5、写 alt/rfq 请求、写旗、finish S6」
能排一串就排一串。只有两处必须停拍:调工具要看出参(tool 放串尾,下一拍接着干);
发卡 / 提交会挂起(它们只能是串尾)。**这一单常路 5-7 拍就该收口** ——
每多拆一拍,就是多烧一遍完整上下文。
§Z 结案:BD 在验收单上签字,系统把草稿合并成正式记录并发出 intake 口的载荷。`;
/**
* **步骤剧本** —— 一个计划项一条,把「这一步是什么状态」写成机器读得懂的东西。
*
* 说明书说的是**整单一以贯之的事**(这一单是什么、怎么说话、怎么收口);
* 逐步的做法只写在这里,轮到哪一步才摊开哪一步 —— **两处不重复写同一件事**,
* 重复的代价是钱(每拍烧两遍)与准(两份会分叉,分叉过一次:说明书里的计划项名
* 是案例模型里没有的三个,模型照它报 itemId,一报一个被拒)。
*
* `doneWhen` 是这里的关键:它让引擎自己算得出「这一步的成果已经在台账里了」,
* 算出来就直接收掉,**不问模型**。A1 反复空转正是栽在没有它 ——
* S1 把八字段一次写齐之后,S3「补齐八字段」已经无活可干,而模型只能把同样三行
* 再写一遍,被「写了等于没写」拒回,下一拍再来一遍(实测连拒到熔断)。
*
* 写 `doneWhen` 的纪律:**它是「成果已存在」的判据,不是「该不该做」的判据**。
* 写成开局即为真的式子(如 `missing_key_total = 0`),那一步会在还没干之前就被收掉。
*/
export const INTAKE_STEPS = [
{
item: 'S1_customer',
intent: '按发件邮箱认客户;认不出就发补全卡请人建档,建完写进客户主数据',
writes: ['customer.profile', 'customer.contact', 'quote_intake.verdict'],
/* 认客户就靠这两张 —— 没声明的话它们会被整表段的截断刀砍掉(踩过) */
reads: ['customer.contact', 'customer.profile'],
view: 'intake-overview',
tools: [],
/*
* 认到人才算办完。**不能写 `customer_code != null`** ——
* S-FEEL 里未绑定的名字 `!= null` 求值为 true,这一步会开局即被收掉(A1 踩过)。
* 一律取同步写下的计数列。
*/
doneWhen: 'customer_total > 0 and (customer_new_total = 0 or customer_asked_total > 0)',
guide: `这一单的第一件事是**认人**:这封信是谁发来的。
① 先查客户主数据:拿入参的 \`customerEmail\` 去 \`customer.contact\` 里找 ——
找得到就取它那一行的 \`customer_code\`,这一步就完了(照下面第③条记账,不必问人)。
**邮箱一个字符都不许改**:用 \`customerEmail\` 的**原文**去比,别照签名里的姓名
重新拼一个(实测踩过:入参是 \`e.volkova@itelma.ru\`,模型按签名拼成
\`ekaterina.volkova@itelma.com\` —— 名字对、域名和缩写全错,于是老客户被当成新客户,
白发一张建档卡,还差点建出一个重复的客户档案)。
比对时忽略大小写与首尾空格,除此之外**逐字符相等才算认出**;
域名相同但用户名不同**不算认出** —— 同一家公司不同的人,是两个联系人。
② **找不到就是新客户** —— 发**数据录入卡**(不是补全卡)请人建档。
两者的分别在**谁写库**:补全卡收回来的值要你自己再 write 一遍(中间隔一道传话,
你可能抄错、可能顺手改一笔);数据录入卡是**人按下确认那一刻基座直接写**,你不经手。
客户档案是主数据,建错一次后面每一单都跟着错 —— 那一笔该由人亲手落下。
**建档要建两张表:公司档 + 联系人档,两段都摆上这张卡**(2026-08-02 用户定:
「新建客户 联系人和公司都需要确认」)。只摆公司那一半、联系人由你自己写,
等于让人确认了一半 —— 而联系人档里的 \`contact_code\` 恰恰是**你编出来**的那一个。
**照这个骨架发,一个字段都别少**(散文描述不管用,直接抄):
{"kind":"entry","itemId":"S1_customer","ask":"这是位新客户,请核对建档信息并给客户编码",
"ledger":"customer.profile","entryKey":{"customer_code":""},
"basis":[{"k":"发件邮箱","v":"chen@sh-elec.com"},{"k":"签名","v":"陈刚 | 上海精密电子有限公司"}],
"fields":[
{"key":"customer_code","label":"客户编码","required":true},
{"key":"name","label":"公司名","value":"上海精密电子有限公司","required":true}
],
"also":[{"title":"联系人档案","ledger":"customer.contact",
"key":{"customer_code":""},
"fields":[
{"key":"name","label":"联系人姓名","value":"陈刚","required":true},
{"key":"email","label":"邮箱","value":"chen@sh-elec.com","required":true}
]}]}
**联系人编码不用你写** —— 它是内部流水号,基座在人按下确认那一刻按「客户编码-01」生成
(台账声明里标了自增)。你硬塞一个进 \`fields\`,基座也会把它摘掉:
人哪知道这家客户已经有几位联系人,而你编一个就可能覆盖别人那一行。
**公司名与联系人姓名、邮箱都必须带 \`value\`** —— 公司名几乎总写在开头那句
「我是XX公司的张三」或结尾签名档里;联系人姓名从落款认;**邮箱照 \`customerEmail\` 原样抄,
一个字符都别改**。读出来填上,人核对一眼就行。
**客户编码不给 \`value\`** —— 那是我们内部的编码,来件里不会有,硬凑一个是错的;
标 required:true,人不填就确认不了。
两段的 \`customer_code\` 都留空:人在第一段填了什么,第二段跟着是什么(同一位客户)。
③ **记账两个数,引擎按它们收步**(别绕过去,绕不过去):
· customer_new_total —— 第①步认出来了记 0;认不出、要建档记 1;
· customer_asked_total —— 发过建档补全卡就记 1。
**新客户没问过人,这一步收不了** —— 实测有过一次:模型从邮箱域名
编出 \`CUST-SZ-MACH\` 直接把档建了,人根本没被问过一句,而那个编码是错的。
客户编码是主数据的主键,编错一次后面每一单都跟着错。
④ 人答完(或第①步就认出来了),同一拍把该写的写上:
· \`customer.profile\` 与 \`customer.contact\` —— **新客户这两张不用你写**:
人在录入卡上按下确认那一刻基座已经落好了(两段一次落两行)。
再写一遍只会被「写了等于没写」拒回。认出来的老客户更不必重写主数据。
· \`quote_intake.verdict\`:customer_code、**customer_name**(公司名,认出来的取主数据那一行,
新建的取卡上填的)、**contact_name**(发件人姓名,从落款或邮箱认;认不出就留空,别拿公司名顶)、
**customer_kind**(照第③条的判断写「老客户」或「新客户」四个字,
别的写法都不算 —— 报告上那一格与 A4 的首单口径都读它),并把 **customer_total 记 1**。
顺带把**询价单号 rfq_ref** 认出来记进 verdict:从主题或正文里原样照抄
(RFQ-xxxx / 询价单号 / Ref / PO 号),认不出就在②那张卡上一并问,**不要自己编** ——
所有台账都以 rfq_ref 为主键,编错了这一单的行会和别的单撞在一起。`,
},
{
item: 'S2_normalize',
intent: '解析邮件正文与附件 BOM,逐条落成受理记录;缺哪几格照实记数',
writes: ['quote_intake.record', 'quote_intake.verdict'],
reads: ['customer.profile'],
/* 拆料这一拍工作区就该摆出受理需求那份件 —— 人看得见 AI 拆到哪儿了 */
view: 'intake-overview',
tools: [],
/*
* **这一步不叫人**:拆料是 AI 的活,拆完就算办完,由下一步(S3_confirm)交给人审定。
*
* 从前这里既拆料又发卡,收步条件是
* `line_total > 0 and missing_key_total = 0 and (missing_minor_total = 0 or answered_total > 0)`
* —— 括号里那半句的意思是「缺东西才需要经过人」。于是一封字段齐全的询价进来,
* 条件当场为真,引擎在下一拍开头把这一步自己收了,人一眼没看过受理需求;
* 下一次被叫到已经是终局的验收放行。用户 2026-08-01 当场指出这件事。
*
* 现在这里只留「拆出行来了没有」:缺不缺字段不是收步的事,是卡上要说清的事。
*/
/*
* **拆出来几行,就报几条** —— 收步判据与验收判据同源(2026-08-01 加后半句)。
*
* 从前只看 `line_total > 0`:多写一行也照收,错要到第 10 拍提交放行时
* 才被 `record_rows = line_total` 拦住 —— 那时上下文早翻篇了,
* 模型只会去改数(实测:客户说本轮跳过的那颗料被写进有效清单,
* 改数又让 skipped_total 不对,连拒五拍熔断)。
* **同一个错,在第 2 拍发现比在第 10 拍便宜得多**:那时行刚写下、多的是哪一行一目了然。
*/
doneWhen: 'line_total > 0 and quote_intake_record_rows = line_total',
guide: `客户认出来了,现在把询价内容逐条落成受理记录。
【先看料在哪】**正文与附件分工不同,别只读一处**:
· 附件的 \`rows\` 有值 → **基座已经把那张表拆好了**:一行一个对象、键就是表头列名。
**照它拆条,别自己去数制表符** —— 数格子是基座替你做的事,你只管把列对到我方字段上。
(\`text\` 仍留着原文:口径要求那些不在表里的东西还得从正文与原文看。)
· 只有 \`text\` 没有 \`rows\` → 那张表不规整(列数对不齐),基座不敢替你拆;
这时才自己看原文,并且格外小心连着的制表符是「中间那一格空着」。
这时正文通常只给**口径要求**(币种/贸易条款/交期单位/认证/替代授权/本轮跳哪条),
那些不是料,是**落到备注与品质要求里的约束**。
· 附件没有或读不出 → 以正文为准。**别猜附件里有什么**。
【客户说不报的,就不要报】正文里点名「本轮跳过 / not finalised / 暂不报」的那几条,
**不进有效清单** —— 但要在收尾时说清「本轮不报几条、为什么」。
有效条数 = 附件总行数 − 客户点名跳过的条数,两个数都要说得出。
**跳过的那条连一行都别写进 \`record\`**:实测栽过一次 —— 数报对了(9 有效 / 1 跳过),
行却写了 10 条,判据 \`record_rows = line_total\` 当场拦住,而那时改数字是改不对的
(改了 skipped_total 又不对)。真写进去了就用 \`drop\` 把那一行撤掉,别去动数。
【表头怎么对】BOM 的列名常是客户本国语言或缩写,**先把列对上再拆行**:
· 年用量那一列叫 EAU / Qty / 年用量 / Годовой объём;**带 (k) 就是千**(30 → 30000);
· 制造商叫 Manufacturer / Производитель;品类叫 Category / Категория;
· 客户自己的编码常在第一列(Itelma Code / Customer P/N)→ 落 customer_pn。
**对不上列就别猜**:宁可把那一列整列判为缺,也不要把「认证要求」当成「年用量」。
【制表符分列:空格子会连着出现,别把后面的值往前挪】
一行里连着两个制表符(\`\\t\\t\`)就是**中间那一格空着**,不是没有这一格。
拆每一行前先数:**这一行的格子数必须与表头的列数相同**,数不对就整行重数,
别把右边的值挪到左边的空位上 —— 实测栽过两次,同一行:
制造商那一格空着,于是「浙江嘉康」被读成上一行的厂牌、品类也跟着错位成「集成电路」,
而 BOM 上白纸黑字写着 Антенна(天线)与浙江嘉康。
行尾连着几个制表符也一样:那是**末尾几列都空着**(年用量、目标价客户没给),
不是这一行到此为止 —— 末尾空列照样要算进格子数里。
列对上了就**逐行照抄值**,别整列判缺 —— 实测栽过一次:9 条料的年用量明明都在表里,
却被整批报成「缺年用量」,一张卡问了客户 9 个他早就给过的数。
【一张卡别铺太长】缺口卡的 basis 与表格**只列真正缺的那几行**,不要把全部条目铺进去:
整份回复有长度上限,铺太长会在半途被截断 —— 截断的 JSON 解析不了,
这一拍作废、还会弹一张「需要你判断下一步」把人叫来(实测)。缺几条就写几条。
【两种分量 —— **必填只有两项,别的一个都不是**】
· **必填(只有这两项)**:\`pn\`、\`eau\` 年用量 —— 缺了这一单没法报价,
**这是验收标准**,不齐不许放行,记进 missing_key_total。
· **选填(除上面两项之外的一切)**:客户料号、品类、制造商、**目标价**、交期、品质要求、备注……
缺了照样往下走,只当提醒,记进 missing_minor_total。
**目标价尤其常缺** —— 客户不给目标价是常态,不是缺陷:实测栽过一次,
一颗料只缺目标价却被记成必填缺、还派了执行卡去问客户,而按 F-01 它根本不该拦单。
拿不准某个字段算哪一档,就问自己一句:**它是不是 PN 或年用量?** 不是就是选填。
【怎么拆】逐条写进 \`quote_intake.record\`,一条需求一行(同一 PN 不同数量档算两行)。
列名照台账字典写,别自己起名:
· \`pn\` 我方料号 · \`customer_pn\` 客户自己的编码(BOM 里那一列,回价单要按它对齐)
· \`category\` 品类 · \`brand\` 制造商 · \`eau\` 年用量 · \`target_price\` 目标价
· \`quality_req\` 认证/品质要求 · \`remark\` 备注 · \`customer_code\` 上一步认出的那个
四条纪律:
① **原文没写的不要补** —— 缺就是缺,记进缺口数,别替客户填一个「看起来合理」的值。
数值列(年用量 / 目标价)没有就给**空字符串 \`""\`**,基座认得出「这一列没有值」,
报告上会标成红的「必填」等人补。**绝不写 0** —— 0 是一个真实的数量,
写下去就等于告诉下游「客户要 0 个」;实测栽过:DAG 那条 BOM 里年用量本来是空的,
被写成 eau=0,而同一拍的缺口数还记着 1 —— 一份自己跟自己打架的记录。
② **PN 一个字符都不许改**:后缀、横杠、空格全照抄
(实测:\`DAG2400L321504XZB01-R0\` 被写成 \`DAG2400L321504XZB01\`,尾巴上的 -R0 丢了 ——
往后查历史价查不到、开出去的询价请求对不上料,而错处早已在这一步埋下)。
③ **缺哪一格算哪一格,别整行判缺**:同一行里有值的列照抄
(实测:那条缺年用量的料,连 BOM 里明明写着的制造商「浙江嘉康」与品类都一并没写 ——
人拿到卡以为客户什么都没给,白问一轮他早就给过的东西);
④ 单位归一但不换算口径(12K→12000 可以;月用量×12 是业务判断,该问客户);
③ **外文品类照译不照抄**:BOM 常是客户本国语言(Варистор = 压敏电阻,
Антенна = 天线,Дроссель = 电感,Фильтр = 滤波器,Микросхема = 集成电路),
译的是**品类那一列的词**,不是按料号猜这颗料是什么
(实测:一颗天线被写成「集成电路」—— BOM 上白纸黑字写着 Антенна);
译不准的原文照留,别硬编一个。
④ 正文里的口径逐条落到对应行:点名要证书的写进 \`quality_req\`;
客户**已预授权的替代件**写进 \`remark\`(那是后面选型的依据,丢了就得回头问客户)。
【同一拍记账】把 \`quote_intake.verdict\` 的这几个数写上:
· bom_total = 来件一共几条(附件行数,不含表头);
· skipped_total = 客户点名本轮不报的条数;skipped_note = 是哪几条、为什么(一句话);
· line_total = 落了几条(= bom_total − skipped_total,对不上就是拆漏了);
· **missing_key_total** = 缺 PN 或 年用量 的条数(必填缺);
· **missing_minor_total** = **除 PN / 年用量之外任何字段缺了**的条数(选填缺)——
客户料号、品类、制造商、目标价、交期、品质要求、备注,缺哪一个都算,一条只数一次。
**别只数品类和制造商**:实测栽过一次,NE555P 只缺目标价,两个缺口数报的都是 0,
于是那一问被整个吞掉 —— 人在成果表里看见一格空白,却没人问过他一句。
空不空由他决定要不要去补,不由你替他吞掉。
【最要紧的一条:**数就是数,不许为了过判据去改它**】
两个缺口数如实写。缺一条就是 1,别因为「写 0 才好往下走」就写 0 ——
实测栽过一次:一颗料只缺目标价,如实记了 1,提交被判据拦住,
于是那个数被改回了 0、单子放行了,而台账上从此写着「缺选填 0 条」,
那一格空白再没人提起。**判据拦住你,说明该去办那件事,不是该去改那个数**;
真办不了(客户就是不给),把它留在数里、在 say 里说清楚,放行时由人定。
**这一步不发卡**:拆完就 finish,交给下一步(受理需求交人审定)去请人。`,
},
{
item: 'S3_confirm',
intent: '把整份受理需求交人审定:可就地改,确认后才往下走',
writes: ['quote_intake.verdict'],
reads: [],
/* 阶段2 的执行目标件:询价受理需求(照 JCAI_PRD 的 A1-询价受理需求.html) */
view: 'intake-overview',
tools: [],
/*
* **不给 doneWhen** —— 这一步触达世界(把件交到人手上),给了就等于给引擎
* 一把替人签字的钥匙。而它的成果按定义也判不出来:「人看过、改过、认了」
* 不是台账里任何一个数能自己变出来的。人工激活也一并给上:
* 引擎不会替你按下这一步,你得先 start 再发卡。
*/
guide: `拆好的九条摆在那儿了,**这一单现在要经过人的手**。
【一单必发一张,不管缺不缺】
这不是「缺东西时的追问」,是**阶段2 那张执行页**:AI 拟了稿,人审定。
字段齐全也要发 —— 拆错一格(PN 少个后缀、品类译反、年用量看错列)只有人看得出来,
而那些错往后每一步都跟着跑。**别因为「没有缺口」就跳过这一步**:
实测栽过一次(2026-08-01),那一版只在缺东西时才发卡,于是一封齐整的询价进来,
人一眼没看过受理需求,下一次被叫到已经是终局验收 —— 用户当场说
「我都没确认需求 它就让我验收了」。
【怎么发】先 start 这一步(它是人工激活的),再照这个骨架发,别少字段:
{"kind":"human","itemId":"S3_confirm","workOrder":"execution","card":{
"title":"…","ask":"这是从来件与附件 BOM 归一出来的受理需求,请核对确认;哪一格不对就地改",
"steps":["型号 PN 与年用量 EAU 必须有 —— 缺一条这一单就走不到放行;客户没给的去问,问不到也要在备注里写清「客户未提供」",
"目标价尽量试探出来 —— 有它才谈得上「报得进不进得去」;客户不肯给,问一句「贵司这颗现在的成交价区间」也常有答案",
"抽错的就地改,多出来的行按行首 × 撤掉,漏掉的用表尾「加一行」补上;核完回这张卡提交"],
· **卡面是纯文本**:\`steps\` 与 \`standard\` 里**别写星号那类记号** —— 它们不会变成粗体,
只会原样摆在人眼前(实测过一次)。要强调就靠句子本身的次序与用词。
"standard":"必填(PN / 年用量)一条不缺;选填该问的都问过了 —— 问到的填上,问不到的留空并在备注里写明,放行时由你决定先受理还是退回。",
"basis":[{"k":"DAG2400L321504XZB01-R0","v":"缺年用量(必填,须补)"},
{"k":"UMW AO6604","v":"缺制造商(选填,可先受理)"}],
"blocks":[{"t":"table","title":"有效 PN 清单(可就地改)",
"ledger":"quote_intake.record","editable":true,
"cols":["PN","客户料号","品类","年用量","制造商","目标价","备注"],
"fields":["pn","customer_pn","category","eau","brand","target_price","remark"]}]}}
**\`steps\` 与 \`standard\` 是作业指导书** —— 卡上照原样摆给人看:要核什么、核到什么程度算完。
照上面抄即可(这一步每一单都一样,不必每次重写)。少了它,人拿到的只有
「请核对确认」四个字,不知道该核什么。
· \`ask\` 照当下情形写:**有必填缺**就写「有几条缺 PN/年用量,须向客户问清后填回」;
**只有选填缺**写「有几条缺选填项,可先受理也可去问」;**都齐**写「请核对确认」。
· **别自己抄行**:绑了 \`ledger\` + \`fields\`,基座发卡那一刻就把这一单**整份清单**
从草稿层铺进去 —— 人看到的是完整的九行,哪一格不对都能就地改,不只是缺的那一两行。
你抄一遍的下场实测过两次:回复有长度上限,铺长了半途截断、整拍作废;
抄的时候还把 BOM 原样的 30 填了进去(台账里已经是 30000)。
· \`fields\` 与 \`cols\` **一一对应**,列名照台账字典写。
· \`basis\` 逐条写清**谁缺什么、是必填还是选填** —— 分量不同,人心里要有数;
一格不缺就写这一单的要点(几条料、几条本轮不报、客户点名的口径要求)。
发完卡把 **asked_total 加 1**(记账用:它证明这一问没省)。
【人提交之后 —— **表里的值基座已经替你写回了**】
那张表绑着台账,人按下提交那一刻基座就把改动落进了草稿层(与数据录入卡同一条规矩:
该由人亲手落下的,不经你的手)。所以**别再 write 一遍那几行** ——
值没变的写入会被「写了等于没写」拒回(实测)。
你这一拍只做三件事,然后 finish:
① **把 missing_key_total / missing_minor_total 重算** —— 照台账里现在的值,
补上了就该变小(基座写的是行,汇总列是你的活);
② **confirmed_total 记 1** —— 这一单经过人了。**不写它这一步收不了,整单卡在这儿**;
③ answered_total 加 1(答案已经处理过了)。
漏掉①的下场实测过:值明明补上了,汇总列还写着「缺 1 条」,提交被判据一次次拦回来,
连拒五拍熔断 —— 而你只要把那个数改对就过去了。
回填时**按卡上那一列的 PN 对行**:卡上的 PN 就是台账里那一行的主键,
换一个写法就是**新开一行**(实测:补写时把 PN 补全成 \`…-R0\`,于是同一颗料在表里成了两行,
条目数说 9 而表里躺着 10)。真发现第一次的 PN 抄错了,在 say 里说明并交给人,
别自己用新 PN 再写一行。
【**必填还缺就不能收**】confirmed_total 记了也没用:必填(PN / 年用量)缺一条,
下一步就是进不去 —— 这是验收标准。人交回来还缺必填,就再发一张卡说清还差哪几条。
选填缺则不拦:问过就够了,别为它反复发第二张卡。
【人把格子留空交回来 = **客户给不了**,那也是一个答案】
这时候要做的**不是再问一遍**(基座会当场拒:同一件事不许问第二遍),而是:
· confirmed_total 记 1、answered_total 记 1(人已经处理过了);
· 缺口数**照实留着**(缺就是缺,别改小);
· 这一步就算办完了 —— 选填缺不拦单,放行时由人定「先受理还是退回」。
实测栽过一次:人留空交回,模型又发一张一模一样的卡,被拒;
转头把同一行原样再写一遍,又被「写了等于没写」拒;连拒五拍熔断,一单就这么停了。`,
},
{
item: 'S4_history',
view: 'intake-classified',
intent: '调历史价工具,把 P-03 的三项判据取齐并落进料号分类表',
writes: ['quote_intake.type_table'],
reads: [],
tools: ['crm.history-price-read'],
/*
* 判据落表了才算取齐。**不能写 `history_days != null`** ——
* S-FEEL 里未绑定的名字 `!= null` 求值为 true,那一步开局就被收掉(踩过,测里拦下的)。
* 取正向判断:没写过就没有这个键,`>= 0` 自然为假。
*/
doneWhen: 'history_days >= 0',
guide: `P-03 只认三个入参:距上次报价天数、行情是否稳定、生命周期状态。
三者齐了结论是**算出来**的,所以这一步唯一该做的事是把判据取齐:
调 \`crm.history-price-read\`,**一次把这一单所有 PN 传进去**(参数 pns 收数组,
别一条一条调 —— 每调一次就多烧一拍)。取不到不是错误:新料号无历史会回
historyDays=9999、hit=false,那本身就是判据,命中 B 类「重估后报」是对的。
拿回来的三项判据**原样逐条**写进 \`quote_intake.type_table\`。
**这一步一个数都不许自己填**(实测踩过,三条同时犯):
① 工具回什么就写什么 —— historyDays / marketStable / lifecycle 三个值**逐字照抄**。
你不知道某颗料的行情,工具知道;你估一个「15 天 · 在产」,判定表就照着你的估算出 A,
而那颗料实际是客户点名要替代的 C。**估出来的判据比没有判据更坏**:它看起来很像真的。
② **PN 必须原样**,一个字符都不许截、不许改写(实测:\`DAG2400L321504XZB01-R0\`
被写成 \`DAG2400L321504XZB01\`,尾巴上的 -R0 丢了 —— 工具当场查不到,
于是你又给它编了一组判据)。传进去的 PN 与写回台账的 PN 必须与受理记录逐字一致。
③ 工具回 hit=false(这颗料没有历史)时,**照它回的 9999 / false / 未知 写**,
那三个值本身就是判据,命中 B 类「重估后报」是对的结论 —— 不是缺数据,不必替它补。`,
},
{
item: 'S5_classify',
view: 'intake-classified',
intent: '调分类器按 P-03 给每条打 A/B/C,标落两张表,条数照出参抄',
writes: ['quote_intake.type_table', 'quote_intake.record', 'quote_intake.verdict'],
reads: [],
tools: ['quote-intake.classify'],
/*
* **收步判据与验收判据同源**(2026-08-01 改)。
*
* 从前只看 `untyped_total = 0` —— 那是活动自己写的数。它归零了这一步就收,
* 而验收那条比的是 `type_table_rows = record_rows`(基座数出来的行):
* 数归零了、表没写全,于是**收了步却过不了验收**,模型得回头补写一张已经收掉的表。
* 收步比验收松,松出来的那一截就是返工。这里补上基座那一边:两处比同一件事。
*/
doneWhen: 'untyped_total = 0 and line_total > 0 and quote_intake_type_table_rows = quote_intake_record_rows',
guide: `调 \`quote-intake.classify\`(纯工具,P-03 的求值器,同输入必同结论、零 token),
把 type_table 那几行判据一次传进去,拿回每条的 A/B/C 与打法。
【标要落**两张表**】
· \`quote_intake.type_table\`:type_tag + basis —— 这是「凭什么这么判」的底稿;
· \`quote_intake.record\`:**同一个 type_tag 也要写回受理记录**(按 PN 对行)——
受理记录才是流去下游的那份交付物,A4 就靠这一列决定哪几条能直接取历史锚点。
漏了这一步,报告上的状况标那一列整列是空的,而下游拿到一份没打标的清单(实测)。
【条数**照出参抄,不许自己数**】classify 的出参里带 \`counts\`(A/B/C 各几条)与 \`total\`,
照它写 verdict 的 type_a_total / type_b_total / type_c_total,并把 untyped_total 归零。
实测数错过一次:判定器给的是 5A/3B/1C,报上去的是 7A/2B/1C,派单也跟着按错的数开 ——
而验收那条等式两边都是自报的,于是「2 = 2」顺利过关,真实的第三条 B 类料一张单也没开。
**不要绕过决策表自己判** —— 规则表可回放、可追责,临场判断不是。
\`type_tag\` 一律取出参里的那个字母,**不许自己写一个**:实测有过一次,
它自己给出的判据是「200 天 · 行情不稳」(按 P-03 该判 B),写回台账的标却是 A ——
判据与结论对不上,而验收只看结论,这种错一路走到报价单上都没人拦。
拿不准的一律降 B,不许臆断 A:错标 A 会让一条本该重新核价的料带着旧价走到报价单上。`,
},
{
item: 'S6_route',
/* 阶段3 的验收目标件:询价受理记录(照 A1-询价受理记录.html) */
view: 'intake-release',
intent: '按三类结果立两枚去向旗',
writes: ['quote_intake.verdict'],
reads: [],
tools: [],
/*
* **这一步不再开单**(2026-08-02 用户当场定):
* 「替代评估请求 / 询价请求这两项数据不在这里产出;随令牌流向下一个节点的
* 只有工作成果验收内的内容;进入选型替代后,活动会自己归一需要的数据。」
*
* 从前 A1 逐条开 `alt_request` / `rfq_request` 两张单交给 A2 / A3 ——
* 那是替下游把它的输入先摆好,而下游本来就要照自己的口径归一一遍:
* 同一份事实在两个活动里各存一份,改一处就分叉。A1 交出去的是**受理记录**,
* 下游读它即可。
*
* 收步判据随之改成「两枚旗都立了」。旗是布尔,合法值含 false,
* 拿它判「写没写过」分不清「没写」与「写了 false」—— 所以仍取同步写下的**计数**
* (`route_decided_total`:定完去向记 1),并挂 `untyped_total = 0` 当前置:
* 两个数都得是模型显式写过的,否则「未绑定的名字相等」会让这一步开局即收
* (包内自测那条「doneWhen 开局就为真」拦过这个)。
*/
doneWhen: 'untyped_total = 0 and route_decided_total > 0',
guide: `状况标是判断的**输入**,不等于去向。照三类的结果立两枚旗:
· 有 C 类(EOL / 缺货 / 客户点名替代)→ need_alternative = true,这一单要走替代选型;
· 有 B 类(无近期历史价或行情波动)→ need_sourcing = true,这一单要走寻源询价;
· 两者可同时成立 —— 选型定案后仍要为替代料寻价。
写完把 **route_decided_total 记 1**(记账用:它证明去向定过了,引擎按它收步)。
**不要开什么请求单** —— 替代评估请求、询价请求都**不在这一步产出**:
随令牌流去下一个节点的只有**工作成果验收里的那份受理记录**,
A2 / A3 拿到之后会按自己的口径归一需要的数据。你替它们先摆一份,
等于同一份事实在两个活动里各存一份,改一处就分叉。
**这两枚旗是下游两道网关唯一读的东西**:不写,整条流出了受理口就没路可走。
**去向不用你选,也不用人选** —— 契约给三个出口各写了判据(有没有 C 类、有没有 B 类),
放行那一刻基座按序求值自己定,卡上只如实告诉人走哪一口、凭哪一条。
你要做的只有一件:**把三类条数与两枚旗写对**(它们正是那几条判据读的数)。
旗写错,算出来的去向就错 —— 但那是数据错,不是选择错。`,
},
{
item: 'T_release',
view: 'intake-release',
intent: '判定成果已齐,提交放行交 BD 验收',
writes: [],
reads: [],
tools: [],
// 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 doneWhen)
guide: `line_total > 0、missing_key_total = 0、untyped_total = 0、两枚旗已定 ——
**先 start T_release**(它标了人工激活:放行会触达世界,必须按一下)。
**start 之后、submit 之前,把清单里**每一条**裁量判据(L3/L4)都裁掉**
(同一拍里排在 start 后面即可)。**一条都不能漏** —— 漏一条,submit 当场被拒,
而拒回来的理由里就写着漏的是哪一条:照它原样抄 criterion 再裁一次即可,
**别原样重提同一个 submit**(实测连提七次,每次都被同一条拒回来)。
眼下**只剩一条**,照名字逐字抄(2026-08-02 起「吃不准降 B」那条已从清单里删掉,
别再裁它 —— 裁一个不存在的判据会被拒):
· judge criterion="选填缺口(客户料号/品类/制造商/目标价)已补齐,或由你放行时确认可以先受理" ——
**只看 \`quote_intake_record_gaps\` 这一个数**(基座现数的:还有几行缺选填;
\`..._gaps_keys\` 写着缺在哪几行的哪一格)。就两种写法,没有第三种:
· **= 0** → satisfied=true,rationale 写「选填已补齐」;
· **> 0** → satisfied=**false**,rationale 照 \`..._gaps_keys\` 逐条写清缺在哪儿
(例:「DAG2400L321504XZB01-R0 缺目标价」),交放行的人定「先受理还是退回」。
**「我发过卡问过了」不算满足** —— 发卡是过程,不是结论;而这一单本来就必发一张
审定卡,拿它当理由的话这条判据永远是绿的,等于没有这道门
(实测:客户没给目标价,它照样裁了满足,人签字时根本不知道还缺着)。
裁成不满足**照样能提交**,理由连着卡一起交给签字的人 —— 那正是这条判据的用处。
**不许为了让它绿去改哪个数** —— 那个数你也改不动,它由基座现数。
**criterion 照判据名原样写**(整句,含括号里那几个字),写别的名字对不上,那一条仍算未达标。
**去向不用你裁**:三个出口各写了判据,放行那一刻基座按序求值、首个命中即出 ——
那是算出来的,不是判出来的。从前这里还有一条「去向判断合乎业务常理」,
那是让你给一个自己没做的决定盖章:裁「满足」是废话,裁「不满足」也改不动什么。
C 类开没开替代评估请求、B 类开没开询价请求,由两条机检判据当场对账。
这一条是机器判不了的(L4 独立评审:缺口真还在时,「先受理还是退回」只有人能定),
只能由你当场裁并留下理由 —— **不裁就提交会被当场拒**:还有裁量判据没裁过时,
提交等于把没办完的活推给人。裁完再 submit,BD 验收。
(缺口为 0 时基座当它已达标、不必你裁;裁了也不碍事。)
终局唯放行:你不能自己宣布完成。
验收报告上让 BD 当场改的那两个值(两枚去向旗)由**契约声明**(revisableAtRelease),
基座放行前从台账现读拟值 —— 你不用在 submit 上重述一遍,写好台账就行。`,
},
];
/**
* 阶段 —— **进度条的刻度**,只给人看,引擎一眼都不看。
*
* 六个计划项是引擎的粒度(拆到能判「干完没有」);阶段是人的粒度。
* 「查历史价 / 逐条打标 / 定去向」在业务上是**一件事** —— 判这单该怎么走,
* 分成三项是因为引擎要一项一项收,而人只想知道「到判去向这一步了」。
*
* 只列**激活态**的三段。放行不在这儿:每个活动都以放行收口(铁律③),
* 那是系统的规矩不是 A1 的阶段;进度条自己在末尾加那一格,所以人看到的是 x/4。
*/
export const INTAKE_PHASES = [
{ name: '定位客户', items: ['S1_customer'] },
{ name: '归一受理', items: ['S2_normalize'] },
{ name: '判定去向', items: ['S4_history', 'S5_classify', 'S6_route'] },
];
+12 -58
View File
@@ -1,63 +1,17 @@
/**
* 统一报价 A1 · RFQ 需求分析 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
* 统一报价 A1 · 询价受理 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
*
* 这是 **flow** 活动:下一步由步序声明给定,引擎照谱推进;
* 本文件承担声明装不下的那部分 —— 视图流控与纯工具(打标)。
* **零环境能力**:一切能力经基座六件注入。
* **这个活动没有 `run`,不是缺件。**
*
* 2026-07-29 范式退役后,活动一律案例活动:推进由 Loop 驱动(定位 → 择向 → 决断 →
* 行动 → 写数据),行动全部落在基座六件上 —— 调工具、调人、立监听、写台账草稿。
* 活动要交代的东西因此**全在清单里**:案例模型(CMMN 子集)+ 案例说明书 + 两张判定表 +
* 三条技能 + 台账声明 + 四张 HTML 报告模版。「清单机器可读,系统不执行代码即知包为何物」。
*
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 —— 它是**被静态审扫描的产物文本**
* (查 import 与全局引用,证明活动包零环境能力)。将来有确定性子序列要结晶成代码,
* 落点也在这里。现在没有就不写:摆一个空转的 `run` 去假装有实现,更坏。
*/
import { defineActivity } from '@sdk';
import type { ActivityBase, ActivityOutcome } from '@sdk';
const INTAKE = 'quote_intake.record';
const TYPE_TABLE = 'quote_intake.type_table';
export default defineActivity({
name: 'quote-intake',
paradigm: 'flow',
async run(base: ActivityBase): Promise<ActivityOutcome> {
const records = await base.ledger.read(INTAKE, { customer_code: String(base.contract.data['customerCode'] ?? '') });
const typed = await base.ledger.read(TYPE_TABLE);
// 视图流控:按局面推结构化块 —— 内容归活动,容器归系统
await base.interaction.pushView([
{
t: 'kv',
title: '受理概览',
items: [
{ k: '客户', v: String(base.contract.data['customerCode'] ?? '') },
{ k: '已归一', v: `${records.length} 条` },
{ k: '已打标', v: `${typed.length} 条` },
],
},
{
t: 'table',
title: '统一受理记录',
cols: ['PN', '厂牌', 'EAU', 'Type'],
rows: records.map((r) => [
String(r['pn'] ?? ''),
String(r['brand'] ?? ''),
String(r['eau'] ?? ''),
String(r['type_tag'] ?? '未打标'),
]),
},
]);
const untyped = records.filter((r) => r['type_tag'] === undefined || r['type_tag'] === null);
if (untyped.length > 0) {
await base.interaction.say(`还有 ${untyped.length} 条未打标,继续按三分类规则 P-03 判定。`, 'conclusion');
return { kind: 'await', reason: '等待打标步完成' };
}
await base.interaction.say(
`${records.length} 条已全部打标:` +
`A ${count(records, 'A')} 条直通定价,B ${count(records, 'B')} 条转寻源,C ${count(records, 'C')} 条转选型。`,
'criterion',
);
return { kind: 'submit', deliverable: 'intake-record', rationale: '逐条已打标,必交付物齐备' };
},
});
function count(rows: ReadonlyArray<Record<string, unknown>>, tag: string): number {
return rows.filter((r) => r['type_tag'] === tag).length;
}
export default defineActivity({ name: 'quote-intake' });
+718 -75
View File
@@ -1,182 +1,730 @@
/**
* 活动包清单 —— 统一报价 · 客户RFQ受理(受理归一)。
* 活动包清单 —— 统一报价 A1 · 询价受理。
*
* 业务素材出处:docs/MVP示例工作流.html §1.1;按 README《归一化对照表》映射为:
* SOP 节点 → **标准活动(flow)**;作业指导书 S1…S6 → **步序声明**(四种步型);
* 步内判断 D1 完整度 → **条件谓词**;三分类规则 P-03 → **DMN 决策表**;
* SLA(受理<15min、打标<45min)→ Harness 时限与预算。
* 业务素材出处:`docs/MVP示例工作流.html` §1.1。归一化映射:
* SOP 节点 → **案例活动**(CMMN 子集 + 说明书 + DMN);
* 作业指导书 S1…S6 → 案例说明书的六段(见 `case-model.ts` 的建模口径注);
* D1 判完整度 → **DMN 判定表 F-01**(《询价受理规范》);
* 三分类规则 P-03 → **DMN 判定表**;
* SLA(受理<15min、判定<45min)→ Harness 时限与预算。
*
* 「清单机器可读,系统不执行代码即知包为何物」—— 这份清单就是这个活动的全部。
*/
import type { ActivityManifest, DmnModel } from '@contracts';
import { INTAKE_SKILLS } from './skills.ts';
import { CMMN_XML, NARRATIVE } from './case-model.ts';
import { activityIdSchema } from '@contracts';
import { INTAKE_SKILLS } from './skills.ts';
import { CMMN_XML, INTAKE_PHASES, INTAKE_STEPS, NARRATIVE } from './case-model.ts';
import { INTAKE_CLASSIFIED, INTAKE_OVERVIEW, INTAKE_RELEASE } from './views/report.ts';
const INTAKE_DOMAIN = 'quote_intake';
const D = 'quote_intake';
const ACTIVITY_ID = activityIdSchema.parse('quote-intake');
/**
* 三分类规则 P-03 —— 把「装得下的判断」从 LLM 手里拿走。
* 由纯工具步 `quote-intake.classify` 逐条求值,命中规则行留痕。
* 两张判定表 —— **能算的不问 LLM**:同输入必同结论、零 token、命中行留痕。
*
* F-01 受理字段完整度(《询价受理规范》):三要素齐才放行,缺关键字段退回补全。
* P-03 三分类:A 历史价≤90天且行情稳 / B 无历史·过期·波动 / C EOL·缺货·要替代。
*
* 两张都是 `FIRST`:规则从上往下,第一条命中即出结论 —— 顺序本身就是业务优先级,
* 所以 C 的那一行必须在最前面(EOL 的料哪怕历史价很新,也不该判成 A 直接快答)。
*/
export const P03_DMN: DmnModel = {
drd: [{ decision: 'p03-type', requires: [], inputs: ['historyDays', 'marketStable', 'lifecycle'] }],
tables: [{
export const F01_DMN: DmnModel['tables'][number] = {
id: 'f01-completeness',
label: '受理字段完整度 F-01',
hitPolicy: 'FIRST',
inputs: [
{ name: 'missing_key_total', expression: 'missing_key_total' },
{ name: 'missing_minor_total', expression: 'missing_minor_total' },
],
outputs: [{ name: 'completeness' }, { name: 'disposition' }],
rules: [
{ id: 'f01-2', when: ['> 0', '-'], then: ['缺PN/数量/交期', '退回补全'], annotation: '三要素缺任一 → 必须追问补全' },
{ id: 'f01-3', when: ['0', '> 0'], then: ['缺次要字段', '受理并追问'], annotation: '只缺厂牌/包装/备注 → 先受理' },
{ id: 'f01-1', when: ['0', '-'], then: ['八字段齐', '受理转寻源'], annotation: '齐 → 直接往下走' },
],
};
export const P03_DMN: DmnModel['tables'][number] = {
id: 'p03-type',
label: '三分类规则 P-03',
hitPolicy: 'FIRST' as const,
hitPolicy: 'FIRST',
inputs: [
{ name: 'historyDays', expression: 'historyDays' },
{ name: 'marketStable', expression: 'marketStable' },
{ name: 'history_days', expression: 'history_days' },
{ name: 'market_stable', expression: 'market_stable' },
{ name: 'lifecycle', expression: 'lifecycle' },
],
outputs: [{ name: 'type' }, { name: 'play' }],
outputs: [{ name: 'type_tag' }, { name: 'play' }],
rules: [
{ id: 'p03-c', when: ['-', '-', '"EOL","缺货","要求替代"'], then: ['C', '转案处理'], annotation: 'EOL/缺货/要替代 → C' },
{ id: 'p03-a', when: ['<= 90', 'true', '-'], then: ['A', '直接快答'], annotation: '历史价≤90天且行情稳 → A' },
{ id: 'p03-b', when: ['-', '-', '-'], then: ['B', '重估后报'], annotation: '无历史/过期/波动 → B' },
],
}],
};
export const INTAKE_DMN: DmnModel = {
drd: [
{ decision: 'f01-completeness', requires: [], inputs: ['missing_key_total', 'missing_minor_total'] },
{ decision: 'p03-type', requires: [], inputs: ['history_days', 'market_stable', 'lifecycle'] },
],
tables: [F01_DMN, P03_DMN],
};
export const manifest: ActivityManifest = {
identity: {
activityId: ACTIVITY_ID,
name: '客户RFQ受理',
packageVersion: '0.1.9',
name: 'A1 · 询价受理',
packageVersion: '40.0.0',
category: '统一报价',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-quote-intake',
},
/* 契约七件 */
/* ── 契约七件 ────────────────────────────────────────────────── */
contract: {
activityId: ACTIVITY_ID,
version: '0.1.0',
version: '1.0.0',
input: {
/*
* 入参就是**那封信** —— 客户邮箱 / 主题 / 邮件内容 / 邮件附件,前三样必填。
*
* 从前这里收的是「客户编码 + 询价单号」,那是**受理之后**才有的东西:
* 一封真的询价来函不会带着我们内部的客户码,单号也常常压根没有。
* 把它们摆成必填的后果实测过:流程包起单时通道抽不出这两个键,基座当场发补全卡,
* 整单 0 拍停在那儿等人 —— 而信本身是完整的。认客户、认单号是 S1 的活,不是门口的闸。
*
* 起单仍是宽进:门口不拒缺项(缺 required 由基座发补全卡,不是活动的事)。
*/
data: {
type: 'object',
required: ['customerCode', 'rfqRef'],
required: ['customerEmail', 'subject', 'body'],
properties: {
customerCode: { type: 'string' },
rfqRef: { type: 'string' },
subject: { type: 'string' },
/** 询价原文 —— 归一步拆解的对象 */
body: { type: 'string' },
customerEmail: { type: 'string', title: '客户邮箱' },
subject: { type: 'string', title: '主题' },
/** 询价原文 + BOM 转述 —— 归一这一步拆解的对象 */
body: { type: 'string', title: '邮件内容' },
/*
* **真文件**(`format: 'file'`):界面给文件选择器,基座在入口把它读成文本。
* 每一件是 `{ name, contentType, size, ref, text?, note }` ——
* `text` 有值就是读得出内容(xlsx 读成制表符分列的行);
* 读不出的类型 `text` 缺席、`note` 说明为什么。**说明书里写死了怎么用**。
*/
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: '客户口头急单、老客户先受理后补单等特殊性写这里',
/*
* **这一单是被一封信叫起来的** —— 声明入参里哪几个字段合起来是那封来件,
* 基座在起单那一刻就把它归进任务的沟通记录(工作台那一屏),原件与附件一并存下。
*
* 为什么是声明而不是跑起来再认一遍:「入参的 subject 就是来件主题」是写清单时
* 就知道的事实,没有临场判断的成分 —— 交给模型每单认一遍,是拿一整拍的 token 换一个常量。
* 而且人一点开这单,沟通记录里就已经躺着那封信,不必等模型跑几拍。
*/
inboundComm: {
channel: 'mail',
partyField: 'customerEmail',
subjectField: 'subject',
bodyField: 'body',
attachmentsField: 'attachments',
},
},
output: {
/*
* 出口载荷**从落地事实推导**(§7.5):标量字段必须有台账列承载。
* 两枚去向旗就是下游两道网关唯一读的东西 —— 没有 `need_alternative` /
* `need_sourcing` 这两列,整条流出了受理口就没路可走(踩过:G1 全流死路)。
*/
mandatory: [
{
name: 'intake',
payload: {
type: 'object',
required: ['records'],
properties: { records: { type: 'array' } },
required: ['records', 'needAlternative', 'needSourcing'],
properties: {
records: { type: 'array', title: '询价受理记录(已打标)' },
needAlternative: { type: 'boolean', title: '需要选型' },
needSourcing: { type: 'boolean', title: '需要寻源' },
},
},
description: '统一受理记录(已打标)',
description: '询价受理记录(打标)+ 两枚去向旗',
},
],
/*
* **三条出边 = 一个 XOR 选择组**(2026-07-31 用户定,对齐流程包 E2/E3/E4)。
*
* 受理这件事办完,这一单只会往三个地方去其中一个:
* · 只有 A 类(有历史价、行情稳)→ 直通定价;
* · 有 C 类要替代 → 去选型(选型定案后仍可能回头寻价);
* · 无选型但有 B 类 → 去寻源。
*
* 从前这里是空的:活动契约只说得出「我发一个 intake 口」,三条边的知识
* 全长在流程包的边条件上 —— 光看活动清单不知道它有三个出口,
* 换一条流来编排就得重新猜。现在**契约自己说得出**。
*
* 两枚旗照旧留在 `intake` 载荷里:下游两道网关读的是它们,
* 撤掉等于把 G1 全流死路那个坑再踩一遍(踩过)。选口与旗必须自洽 ——
* 说明书里写死了对应关系,放行确认时选口随之落定。
*/
selective: [
{
group: 'intake-route',
/*
* **三条按序求值,首个命中即出** —— 与报告上那张 G2 规则表逐条对应。
* 每一口都写了 `when`,所以这一单往哪走是**算出来的**:
* 放行时基座自己判,卡上只如实告诉人走哪一口、凭哪一条,不摆成选择题。
* 去向是分类表那几个数的函数;让人凭印象点一下,
* 等于把一个可回放的判定换成一次不可回放的手感。
*/
ports: [
{
name: 'to-alternative',
payload: {
type: 'object',
required: ['rfqRef', 'records', 'altRequests'],
properties: {
rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' },
records: { type: 'array', title: '询价受理记录(已打标)' },
altRequests: { type: 'array', title: '替代评估请求' },
},
},
description: '有 C 类要替代 —— 去选型',
/* ① 有 C 类就先去选型(替代定案后仍可能回头寻价) */
when: 'quote_intake_record_type_tag_c > 0',
},
{
name: 'to-sourcing',
payload: {
type: 'object',
required: ['rfqRef', 'records', 'rfqRequests'],
properties: {
rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' },
records: { type: 'array', title: '询价受理记录(已打标)' },
rfqRequests: { type: 'array', title: '询价请求' },
},
},
description: '无选型但有 B 类 —— 去寻源',
/* ② 无 C 类、有 B 类 → 寻源 */
when: 'quote_intake_record_type_tag_c = 0 and quote_intake_record_type_tag_b > 0',
},
{
name: 'to-pricing',
payload: {
type: 'object',
required: ['rfqRef', 'records'],
properties: {
rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' },
records: { type: 'array', title: '询价受理记录(全 A 类)' },
},
},
description: '只有 A 类:有近期历史价且行情稳 —— 直通定价',
/* ③ 前两条都不命中,才是「全 A 直通定价」 */
when: 'quote_intake_record_type_tag_c = 0 and quote_intake_record_type_tag_b = 0',
},
],
},
],
selective: [],
},
/*
* 必交付物**带可机检依据**:`ledger` 是台账地址,预检自己数行数。
* 不给地址,「缺一不得提交放行」就只能靠活动自报 —— 自报等于没有门。
*/
requiredDeliverables: [
{ key: 'intake-record', label: '统一受理记录(打标)' },
{ key: 'type-table', label: '料号分类表' },
{
key: 'intake-record',
label: '询价受理记录',
ledger: `${D}.record`,
/*
* 验收页上让 BD 就地改的那几列 —— **数据是他该管的**。
*
* **`type_tag` 在其列**(2026-08-01 用户实测逼出来的):三分类是 P-03 算的,
* 但算得对不对该由人说了算 —— 他改一颗料的类,就是在推翻一次判定,那是他的职权。
* 从前它不在这份名单里,于是人在件上把三条全改成 A、按了保存,
* **改动被整个丢掉**(写回按这份名单摆格子,不在名单里的列压根不进载荷)——
* 屏上类徽照旧、去向照旧写着「去选型」。他以为自己改了,其实什么也没发生。
*
* **去向仍不在其列**:那是 `type_tag` 的函数(见 output.selective 的 `when`)——
* 改类就够了,去向随之重算;再让人单独选一次去向,等于给同一件事开两个入口。
*/
editableAtRelease: ['pn', 'customer_pn', 'category', 'eau', 'brand', 'target_price', 'remark', 'type_tag'],
/** 这份成果由哪几张表承载 —— 分类表是判据来源、结论是结论栏,都是它的内部结构 */
ledgers: [`${D}.record`, `${D}.type_table`, `${D}.verdict`],
},
/*
* ── **料号分类表与受理结论不是两份成果**(2026-08-02 用户当场指出:
* 「不都是包含在询价受理记录的结构里的吗,为啥单独陈列?」)────────────────
*
* 从前它们各占一条 `requiredDeliverables`,于是人在成果面上看到三样东西 ——
* 而在业务上受理这件事**只交出一份东西**:一份能往下走的询价受理记录。
* 分类表是它的判据来源、受理结论是它的结论栏,两者都是**同一份成果的内部结构**。
*
* 现在合成一条,`ledgers` 说清它由哪几张表承载:落地那一屏按交付物点名,
* 三张表的行数并进「询价受理记录」一行,不再各报各的。
*/
],
/*
* 验收判据逐条标真实等级。L1/L2 必带 predicate —— 当场对任务视图 S-FEEL 求值,
* 不带就等于把「机检」写成了摆设(安装期 acceptance-level-misreported 挡这个)。
*
* 谓词读的是**汇总列**(line_total / missing_key_total / untyped_total),
* 不是逐行列:任务视图把草稿行摊平到顶层是「后写的赢」,拿逐行的
* missing_key_fields 求值只会验到最后写的那一行 —— 那不叫机检,叫抽样。
* 所以汇总列的名字与逐行列**故意不同名**,免得互相盖掉。
*/
acceptanceCriteria: [
{ key: 'fields-complete', label: '八字段齐(PN/EAU/交期为必要三要素)', level: 'L1', predicate: 'missingKeyFields = 0' },
{ key: 'typed', label: '每条都已打标 A/B/C', level: 'L2', predicate: 'untyped = 0' },
{ key: 'downgrade-safe', label: '吃不准降 B,不许臆断 A', level: 'L3' },
{ key: 'owner-release', label: 'BD 放行', level: 'L5' },
/* ① 定位客户 —— 认得出老客户或人建了新客户,这一单才落得到某个客户名下 */
{ key: 'customer-known', label: '客户已确认:发件邮箱认出为老客户,或已为新客户建好档', level: 'L1', predicate: 'customer_total > 0' },
/*
* 条目数**与真实行数对得上**。判据不读活动自报的那一个数就完了 ——
* `quote_intake_record_rows` 是基座数出来的本单行数(草稿层),两边同时成立才算。
* 实测拦的是这个:PN 抄漏了后缀,补写时又成了新的一行,表里 10 行而 line_total 记着 9;
* 从前两边都由活动自己报,自然对得上,人签完字,下游拿到一份多一行的交付物。
*/
{
key: 'lines-present',
label: '来件已逐条归一:说了几条,受理记录里就躺着几行',
level: 'L1',
predicate: 'line_total > 0 and quote_intake_record_rows = line_total',
},
/*
* ② 必填与选填**分量不同**(2026-07-31 用户定):
* · 必填(PN / 年用量)**是验收标准** —— 不齐不许放行;
* · 选填(品牌 / 备注)**只提醒** —— 缺了照样放行,但那一问不能省。
* 所以选填这条判据不看「补齐没有」,看**问过没有**:
* 客户不肯给品牌是客户的事,没去问过是我们的事。
*/
/*
* **读基座数出来的数,不读活动自报的**(2026-08-01 改)。
*
* 从前是 `missing_key_total = 0` —— 那个数由模型自己重算。人在卡上补了年用量、
* 基座已经写回草稿层,而模型漏算一次这条判据就永远绿不了:连跑十轮里三轮死在这,
* 每次都是同一条连拒五拍熔断。`quote_intake_record_missing` 由基座按
* 台账声明的 `required` 现数(缺在哪几行也一并摆进拒回),模型改不动也不必改 ——
* 它只要把那一格补上。**一条只接受「是」而模型又左右不了的判据,是死胡同。**
*/
{
key: 'required-complete',
label: '必填齐:每一条都有型号 PN 与年用量 EAU',
level: 'L1',
predicate: 'quote_intake_record_missing = 0',
},
/*
* ── 这一条为什么**不是机检**(2026-08-01,实测逼下来的)──────────────
*
* 它原本是 L2:`missing_minor_total = 0 or asked_total > 0`。两个数都由活动自己写,
* 于是这条判据对模型来说不是「去把那一问办了」,而是**「把这个数改小就能过」**。
* 实测就是这么走的:NE555P 只缺目标价,模型如实记了 missing_minor_total = 1,
* 提交被这条判据连拒四次,第五拍它把那个数**改回了 0** —— 判据当场变绿、单子放行,
* 而台账上从此写着「缺选填 0 条」,那一格空白再没人提起。
*
* **机检判据只许读基座数得出来的数**(如 `域_对象_rows`);读活动自报的数,
* 它就不是门,是一句「改哪个数能过」的提示。而「问过客户没有」是过程事实,
* 基座此刻数不出来(卡上不带计划项号,数不清哪张卡是为这件事发的)——
* 那就**别假装机器判得了**:降到 L4 交人,并把「缺选填几条」摆到放行页的指标带上,
* 签字的人一眼看得见。要它重回机检,得先让基座能按用途数卡(见 debt A1-3)。
*/
/*
* **「问过」不是结论,是过程**(2026-08-02 用户实测:「我这次没给目标价,
* 你 L4 咋又达标了?」)。
*
* 原文是「要么**问过客户**,要么放行时确认可以先受理」—— 而 A1 每一单必发一张
* 受理需求审定卡,「问过」于是恒真:这条判据永远能裁成绿,等于没有这道门。
* (上一版剧本还明写「发过卡问过就 satisfied=true」,是亲手把它焊死在绿上。)
*
* 结论只有两种:**缺口补齐了**,或者**还缺着、交给签字的人定**。
* 后者正是这条判据的价值 —— 把「还缺这几格」连同理由摆到他面前,
* 而不是替他宣布「问过了就算数」。裁成不满足照样能提交,门不因此拦人。
*/
/*
* **前半句由基座数,后半句才交人**(2026-08-02 用户实测逼出来的)。
*
* 「已补齐」是 `quote_intake_record_gaps = 0` —— 基座照台账声明的 `expect`
* 现数(草稿层实时),模型算不算得对都不影响它。从前整条只读裁断,
* 于是它凭记忆写理由:用户在件上把目标价填好了,它照样裁「未满足」,
* 理由写「NE555P 缺目标价,客户未提供」。用户原话:「这个价格是我填写的,
* 但是它判错了」。复现的那一单更离谱 —— 两行年用量都填着,它写
* 「共 4 处选填缺口」,而 EAU 压根是必填不是选填。
*
* 等级仍是 L4:缺口真还在的时候,「先受理还是退回」确实只有人能定。
* 谓词为真时引擎当它已达标、把那句理由一并丢掉(见 `criterionCheck`)。
*/
{
key: 'optional-asked',
label: '选填缺口(客户料号/品类/制造商/目标价)已补齐,或由你放行时确认可以先受理',
level: 'L4',
predicate: 'quote_intake_record_gaps = 0',
},
/*
* **这条判据读基座数出来的数**(2026-08-02 实测逼出来的,「机检只许读基座数得出来的数」
* 第三次被违反)。从前读活动自写的 `untyped_total` —— 那个数它改一下就骗过去了。
*
* 现在比的是:**打了标的条数(基座按 `type_tag` 的取值域现数)= 受理记录行数**。
* 两边都由基座出,模型碰不着。
*/
{
key: 'typed',
label: '每条都按三分类规则 P-03 打了标:A 直接报 / B 重估后报 / C 找替代',
level: 'L2',
predicate:
'quote_intake_record_rows > 0 and quote_intake_record_type_tag_a + quote_intake_record_type_tag_b + quote_intake_record_type_tag_c = quote_intake_record_rows',
},
/*
* ── 这里**去掉了四条**(2026-08-02 用户当场指出:「这些判据都删了吧」)──────
*
* `downgrade-safe`(L3 吃不准降 B)· `route-alt`(C 类都开了替代评估请求)·
* `route-rfq`(B 类都开了询价请求)· `typed-on-record`(每条记录都带状况标)。
*
* 四条都是**内部一致性**:打标依据对不对、几类料开了几张单、两张表行数齐不齐。
* 它们该由引擎自己看住(数对不上就是活没干完,拒回让模型改),
* 而**放行卡是给签字的人看的**——他要判的是「这一单受理得对不对」,
* 不是替引擎复核一遍它自己的账。一屏九条,真正要他过目的那几条反而淹了。
*
* 落地那一侧不因此变松:派单条数与打标覆盖仍在剧本的收步判据里
* (S5/S6 的 `doneWhen` 读同一批基座读数),错了照旧走不到放行门。
* 「几张单开出去了」改在**验收之后**如实呈现(落地结果),
* 那才是人真正关心的:这一签下去,世界上发生了什么。
*/
/*
* ── 这里**去掉了 `route-judgement`**(L4,2026-08-01 用户当场指出)──────
*
* 原文是「去向判断合乎业务常理(客户特殊要求、急单等)」。
* **去向已经不是判断了**:三个出口各写了 `when`,放行那一刻基座按序求值、
* 首个命中即出(`type_c_total > 0` …),人签的字确认的正是算出来的那一口。
* 再挂一条 L4 让模型裁一次「这个去向合不合理」,是让它给一个自己没做的决定盖章:
* 裁「满足」是废话,裁「不满足」也改不动任何东西 —— 判据读的那几个数才改得动。
* 要改去向,改的是三类条数;那已经由 route-alt / route-rfq 两条机检看着了。
*
* 一条判据得能**指向一个可做的动作**,否则它只是一道要人按一下的手续。
*/
/*
* L5 是**责任关**,不是质量关:它声明「这份交付物必须由人放行」,
* 由 `T_release` 的人工激活 + 终局唯放行落实。
* 卡面上不摆它 —— **那张卡本身就是这道关**(基座统一滤,见 `engines.ts`)。
*/
{ key: 'owner-release', label: 'BD 放行:签下去这一单即正式受理并转入下游', level: 'L5' },
],
/*
* 放行时人**可以改什么** —— 2026-08-01 改口径:**去向不在其列**。
*
* 从前这里摆的是两枚去向旗,让 BD 在放行卡上当场改。那是把「这一单往哪走」
* 交给了一次手感 —— 而它本是**分类表算得出来的事实**(有没有 C 类、有没有 B 类),
* 现在由契约的出口谓词按序求值定下(见 output.selective 的 `when`),
* 卡上只如实告诉人走哪一口、凭哪一条。可回放的判定不该退化成一次不可回放的点击。
*
* 那人在验收页上改什么?**改成果本身** —— 那份受理记录清单就绑在卡上,
* 九行逐格可改,改完由基座写回草稿层、随放行合并(AI 不经手)。
* 数据是他该管的;去向不是。
*/
revisableAtRelease: [],
prohibitions: [
{ key: 'no-guess-field', statement: '原文没写的字段不得凭常识补,缺就记缺' },
{ key: 'no-guess-a', statement: '吃不准的一律降 B,不得臆断为 A' },
{ key: 'no-unit-convert', statement: '不得自作主张换算用量口径(月用量×12 是业务判断,须问客户)' },
{ key: 'no-external-write', statement: '不得在放行前写任何外部系统' },
],
authorization: {
discretion: [{ key: 'ask-back', statement: '缺次要字段可受理并追问,缺 PN/数量/交期必须退回补全' }],
discretion: [
{ key: 'ask-back', statement: '缺次要字段可受理并追问;缺 PN/EAU/交期必须退回补全' },
{ key: 'lifecycle-check', statement: '疑似 EOL 的料可随手拉一次生命周期核查' },
],
/* 空着不是省事:这个字段全仓没有一个消费者(债 ⑨),写了也不会被执行 */
preauthorizedActions: [],
humanCallBudget: 4,
},
resources: {
tools: [{ name: 'crm.history-price-read' }],
tools: [{ name: 'crm.history-price-read' }, { name: 'quote-intake.classify' }, { name: 'mail.send' }],
skills: INTAKE_SKILLS.map((s) => s.id),
models: ['deepseek-v4-flash'],
roles: ['BD'],
budget: { steps: 40, tokens: 120_000, activeMinutes: 60, humanCalls: 4 },
/*
* SLA:受理<15min、判定<45min → activeMinutes 60 封顶;打扰上限 4。
*
* token 预算按**实测**给:一份三条的 BOM 走完全程约 20 拍、18 万 token
* (每拍的提示词都带着案例说明书 + 轨迹,越往后越长)。原来给 20 万,
* 正好卡在收口前一两拍被熔断 —— 预算给得太紧,等于让 Harness 去杀正在干的活。
* 条目多的单会更贵,这里留一倍余量。
*/
budget: { steps: 60, tokens: 400_000, activeMinutes: 60, humanCalls: 4 },
},
},
/* 声明 */
/* ── 声明 ────────────────────────────────────────────────────── */
declarations: {
models: ['deepseek-v4-flash'],
tools: [{ name: 'crm.history-price-read' }],
tools: [{ name: 'crm.history-price-read' }, { name: 'quote-intake.classify' }, { name: 'mail.send' }],
ledger: [
/*
* 客户主数据 —— **A1 只声明它要用的那几列**,全量结构由平台播种
* (`seed-mvp-data.ts` 的四域)。声明合并是「最大化合并、只增不破坏」,
* 两边并存不冲突;`kind: 'master'` 必须与平台一致,否则治理台会把它归错类。
*
* 为什么受理活动要写主数据:第一件事就是认人 —— 认得出取它的编码,
* 认不出请人建档。建出来的那一行是**给下一单用的**:下次同一个邮箱来信,
* 第一步就认得出,不必再问一遍。
*/
{
domain: INTAKE_DOMAIN,
object: 'record',
label: '统一受理记录',
domain: 'customer',
object: 'profile',
kind: 'master',
label: '客户档案',
fields: [
{ name: 'customer_code', type: 'text', primaryKey: true, masked: false, label: '客户码' },
{ name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' },
{ name: 'brand', type: 'text', primaryKey: false, masked: false, label: '厂牌' },
{ name: 'eau', type: 'integer', primaryKey: false, masked: false, label: 'EAU' },
{ name: 'type_tag', type: 'text', primaryKey: false, masked: false, label: 'Type 标' },
{ name: 'target_price', type: 'number', primaryKey: false, masked: false, label: '目标价' },
{ name: 'lead_time', type: 'text', primaryKey: false, masked: false, label: '交期' },
{ name: 'status', type: 'text', primaryKey: false, masked: false, label: '状态' },
{ name: 'missing_key_fields', type: 'integer', primaryKey: false, masked: false, label: '缺关键字段数' },
{ name: 'customer_code', type: 'text', primaryKey: true, masked: false, label: '客户编码' },
{ name: 'name', type: 'text', primaryKey: false, masked: false, label: '客户名称' },
],
},
{
domain: INTAKE_DOMAIN,
domain: 'customer',
object: 'contact',
kind: 'master',
label: '客户联系人',
fields: [
{ name: 'customer_code', type: 'text', primaryKey: true, masked: false, label: '客户编码' },
/*
* **自增,不问人**(2026-08-02 用户:「联系人ID自增啊,不用填」)——
* 它不承载业务含义,只是「这家客户下的第几位」。人哪知道已经有几位;
* 让模型编更糟,编重了就是覆盖别人那一行。基座在写入那一刻按
* `客户编码-NN` 生成(见 `FieldDeclaration.auto`)。
*/
{ name: 'contact_code', type: 'text', primaryKey: true, masked: false, auto: true, label: '联系人编码' },
{ name: 'name', type: 'text', primaryKey: false, masked: false, label: '姓名' },
/* 邮箱是**认人的钥匙**,也是个人信息 —— 与平台声明一致标脱敏 */
{ name: 'email', type: 'text', primaryKey: false, masked: true, label: '邮箱' },
],
},
{
domain: D,
object: 'record',
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, label: 'PN' },
{ name: 'customer_code', type: 'text', primaryKey: false, masked: false, label: '客户码' },
/* 客户自己的物料编码 —— 回价单必须按它对齐,否则客户那边对不上账 */
{ name: 'customer_pn', type: 'text', primaryKey: false, masked: false, expect: true, label: '客户料号' },
{ name: 'category', type: 'text', primaryKey: false, masked: false, expect: true, label: '品类' },
{ name: 'brand', type: 'text', primaryKey: false, masked: false, expect: true, label: '制造商' },
/*
* **年用量是必填** —— 标了 `required`,基座就数得出「缺必填的有几行」
* (读数 `quote_intake_record_missing` / `_missing_keys`)。
*
* 为什么非要基座来数(2026-08-01,连跑十轮里三轮死在这):
* 「必填齐」是 L1 机检,从前读的是活动自己写的 `missing_key_total` ——
* 人在卡上补了年用量、基座已经写回草稿层,而那个汇总列还要模型自己重算;
* 它算漏一次,这条判据就永远绿不了,连拒五拍熔断。
* PN 不必标:它是主键,没有 PN 那一行压根不存在。
*/
{ name: 'eau', type: 'integer', primaryKey: false, masked: false, required: true, label: '年用量 EAU' },
{ name: 'target_price', type: 'number', primaryKey: false, masked: false, expect: true, label: '目标价' },
{ name: 'lead_time', type: 'text', primaryKey: false, masked: false, label: '交期' },
{ name: 'packaging', type: 'text', primaryKey: false, masked: false, label: '包装' },
{ name: 'quality_req', type: 'text', primaryKey: false, masked: false, label: '品质要求' },
/*
* 备注**不算选填缺口**(2026-08-02):`expect` 的意思是「客户该给而没给的」——
* 备注是我方写上去的注,一行没有备注不是缺了什么。
* 标着它,`_gaps` 几乎恒大于 0,那条 L4 判据于是永远绿不了 —— 门就废了。
*/
{ name: 'remark', type: 'text', primaryKey: false, masked: false, label: '备注' },
/*
* **分类列:取值域给出来,条数由基座现数** —— `quote_intake_record_type_tag_a/b/c`。
*
* 三分类条数从前是活动自己写的 `type_a_total` 之流,而分布条、放行时的去向谓词、
* 验收判据读的全是它们。于是人在放行页把九条料全改成 A:类徽变了,
* 分布条还是 5/3/1、去向还是「去选型」—— 逐行变了,汇总数没人重算。
* **手工修改的全部意义就是纠正 AI 的判断**:改了类标而流向不跟着变,那个兜底就是假的。
*/
{ name: 'type_tag', type: 'text', primaryKey: false, masked: false, label: '状况标', tally: ['A', 'B', 'C'] },
{ name: 'status', type: 'text', primaryKey: false, masked: false, label: '状态' },
{ name: 'missing_key_fields', type: 'integer', primaryKey: false, masked: false, label: '本条缺关键字段数' },
],
},
{
domain: D,
object: 'type_table',
kind: 'record',
caseKey: 'rfq_ref',
label: '料号分类表',
fields: [
{ name: 'pn', type: 'text', primaryKey: true, masked: false },
{ name: 'type_tag', type: 'text', primaryKey: false, masked: false },
{ name: 'basis', type: 'text', primaryKey: false, masked: false, label: '分级依据' },
{ name: 'history_hit', type: 'boolean', primaryKey: false, masked: false, label: '历史价命中' },
// 打标的三个判据必须落表:S5 的 DMN 从这里读,不落就无米下锅
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' },
// 打标的三项判据必须落表:P-03 从这里读,不落就无米下锅
{ name: 'history_days', type: 'integer', primaryKey: false, masked: false, label: '距上次报价天数' },
{ name: 'market_stable', type: 'boolean', primaryKey: false, masked: false, label: '行情稳定' },
{ name: 'lifecycle', type: 'text', primaryKey: false, masked: false, label: '生命周期' },
{ name: 'history_hit', type: 'boolean', primaryKey: false, masked: false, label: '历史价命中' },
{ name: 'last_price', type: 'number', primaryKey: false, masked: false, label: '上次成交单价' },
{ name: 'type_tag', type: 'text', primaryKey: false, masked: false, label: '状况标' },
{ name: 'basis', type: 'text', primaryKey: false, masked: false, label: '分级依据' },
],
},
{
/*
* 受理结论 —— **一单一行**。它承担三件别处放不下的事:
* ① 两枚去向旗(下游两道网关唯一读的东西);
* ② 三个汇总数(验收 L1/L2 机检谓词的入参);
* ③ 一句人话结论(验收单上给人看的)。
*/
domain: D,
object: 'verdict',
kind: 'record',
caseKey: 'rfq_ref',
label: '受理结论',
fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'customer_code', type: 'text', primaryKey: false, masked: false, label: '客户码' },
/*
* 客户名与新老 —— **报告上那两格,和 A4 的一条硬约束**。
*
* 名字:认出来的老客户不必重写 `customer.profile`(那是主数据),
* 于是报告要显示「这一单是哪家公司」就无处可取 —— 落一列在结论上,
* 一份件自己说得出自己是谁,不必去连主数据表。
* 新老:新客户走首单口径,**A4 不得取历史价锚点**。这是下游的判据,不是装饰。
*/
{ name: 'customer_name', type: 'text', primaryKey: false, masked: false, label: '客户名称' },
/*
* 联系人姓名 —— 报告「联系人」那张卡上摆的那个人,**本单对外发件的收件人**。
*
* 它本来在 `customer.contact.name`(主数据)里,但报告取不到:老客户不重写主数据,
* 于是本单的草稿里根本没有那一行;而顶层同名字段又与 `customer.profile.name`
* 撞名(「后写的赢」),摆上去随时可能变成公司名 —— 一张写着公司名的联系人卡
* 比空着更坏。跟 customer_name 同一笔写下,一份件自己说得出这单是跟谁在谈。
*/
{ name: 'contact_name', type: 'text', primaryKey: false, masked: false, label: '联系人姓名' },
{ name: 'customer_kind', type: 'text', primaryKey: false, masked: false, label: '客户属性(老客户/新客户)' },
/* ① 定位客户那一步的收步判据 —— 认出老客户或人建了新客户,都记 1 */
{ name: 'customer_total', type: 'integer', primaryKey: false, masked: false, label: '客户已确认' },
/*
* 「是不是新客户」与「问过人没有」—— 这两个数是**建档必须经过人**的机检形态。
* 实测:剧本写了「别自己编客户编码」,模型照样从邮箱域名编出 CUST-SZ-MACH
* 直接建了档。说明书管不住,得让收步条件管:新客户没问过人就收不了步。
*/
{ name: 'customer_new_total', type: 'integer', primaryKey: false, masked: false, label: '新客户(需建档)' },
{ name: 'customer_asked_total', type: 'integer', primaryKey: false, masked: false, label: '已就建档问过人' },
{ name: 'line_total', type: 'integer', primaryKey: false, masked: false, label: '有效条目数' },
/*
* 来件一共几条、本轮不报几条 —— 「有效 PN 9 / 10 行 BOM」那句话的出处。
* 没有这两个数,「9 条」是个孤零零的数字:人看不出漏没漏,
* 而客户点名跳过的那一条到底有没有被看见,也就无从对账。
*/
{ name: 'bom_total', type: 'integer', primaryKey: false, masked: false, label: '来件条目数' },
{ name: 'skipped_total', type: 'integer', primaryKey: false, masked: false, label: '本轮不报条数' },
{ name: 'skipped_note', type: 'text', primaryKey: false, masked: false, label: '不报的是哪几条·为什么' },
/*
* ② 「问过没有」—— 选填缺口靠**问过**来解,不靠补齐。
* 用户定的口径:必填项是验收标准,选填项只提醒,但两种缺失都必须经过人。
*/
{ name: 'asked_total', type: 'integer', primaryKey: false, masked: false, label: '已就缺口问过人的次数' },
/*
* **问过 ≠ 答过**。收步判据从前只看 `asked_total`:卡一发出去它就为真,
* 于是人填完提交、引擎在下一拍开头就把这一步收了 —— 模型根本没轮到把答案写回台账,
* 那一格白填(实测:人补的制造商落了空,而台账里那一列仍旧是空的)。
* 收步要等的是**答案落了表**,所以另记一个数。
*/
{ name: 'answered_total', type: 'integer', primaryKey: false, masked: false, label: '人答完并已落表的次数' },
/*
* **这一单经过人的手了没有** —— 阶段2 那张受理需求执行页签回来才记 1。
*
* 它存在的理由是一次实测:从前「经过人」是靠 `missing_minor_total = 0 or
* answered_total > 0` 表达的 —— **只在缺东西的时候才叫人**。于是字段齐全的
* 一封询价进来,这个条件在拆完那一刻就为真,引擎自己把归一那步收了,
* 人一眼没看过受理需求,下一次被叫到已经是终局验收
* (用户 2026-08-01:「我都没确认需求 它就让我验收了」)。
* 确认不是缺口的补丁,是一道**恒在**的门,所以它得有自己的一个数。
*/
{ name: 'confirmed_total', type: 'integer', primaryKey: false, masked: false, label: '受理需求已经人审定' },
{ name: 'missing_key_total', type: 'integer', primaryKey: false, masked: false, label: '缺必填字段条数(PN/年用量)' },
{ name: 'missing_minor_total', type: 'integer', primaryKey: false, masked: false, label: '缺选填字段条数(品牌/备注)' },
{ name: 'untyped_total', type: 'integer', primaryKey: false, masked: false, label: '未打标条数' },
/*
* 分类与派单的条数 —— 「去向旗与状况标对得上」这件事**必须机检**。
* 它本是 L4 交人判的:实测里人(我)盲签了通过,而 C 类那条的替代评估请求
* 根本没开 —— 旗立着、单没开,下游 A2 收到一个空活。
* 算术不该靠人的注意力:把条数落成列,判据就能自己对账。
*/
{ name: 'type_a_total', type: 'integer', primaryKey: false, masked: false, label: 'A 类条数' },
{ name: 'type_b_total', type: 'integer', primaryKey: false, masked: false, label: 'B 类条数' },
{ name: 'type_c_total', type: 'integer', primaryKey: false, masked: false, label: 'C 类条数' },
/*
* **去向定过了没有**(2026-08-02):从前这儿是 `alt_request_total` /
* `rfq_request_total` 两个派单计数 —— 而 A1 已经不开单了(见下面那段注释),
* 那两个数没了对象。收步仍要一个「这一步办过了」的凭据:
* 旗是布尔(合法值含 false),分不清「没写」与「写了 false」,所以另记一个计数。
*/
{ name: 'route_decided_total', type: 'integer', primaryKey: false, masked: false, label: '已定去向' },
{ name: 'need_alternative', type: 'boolean', primaryKey: false, masked: false, label: '需要选型' },
{ name: 'need_sourcing', type: 'boolean', primaryKey: false, masked: false, label: '需要寻源' },
{ name: 'summary', type: 'text', primaryKey: false, masked: false, label: '受理结论' },
],
},
/*
* ── 这里**去掉了 `alt_request` / `rfq_request` 两张表**(2026-08-02 用户当场定)──
*
* 原话:「替代评估请求 / 询价请求这两项数据不在这里产出;随令牌流向下一个节点的
* 只有工作成果验收内的内容;进入选型替代后,活动会自己归一需要的数据。」
*
* 从前 A1 逐条开这两张单交给 A2 / A3 —— 那是**替下游把它的输入先摆好**,
* 而下游本来就要照自己的口径归一一遍:同一份事实在两个活动里各存一份,
* 改一处就分叉,而分叉之后没人看得出以哪一份为准。
* A1 交出去的是**受理记录**(带状况标与两枚去向旗),下游读它即可。
*
* 连带退休的:两条派单对账判据(同日删)、`alt_request_total` / `rfq_request_total`
* 两个计数列、报告里那两张明细表、S6 的开单动作。
*/
],
listeners: [
{
/** 客户改需求:整单要回炉 —— 静态监听,时限必配(悬空监听安装时拒绝) */
origin: 'static',
eventType: 'human',
keyFilter: { kind: 'elicitation' },
semantic: 'complete-step',
// 时限必配 —— 悬空监听安装时拒绝
eventType: 'data',
keyFilter: { kind: 'rfq-amend' },
/*
* 语义只有两种:`observe`(记一笔观察,交 Agent 裁量)与 `complete-step`(驱动步完成)。
* 客户改需求要的是「整单回炉」,那是 Agent 看到这条观察后自己决定重入哪一段 ——
* 监听没有「重入」这一档,也不该有:重入是业务判断,不是事件语义。
*/
semantic: 'observe',
deadline: 'P1D',
escalation: ['nudge', 'escalate'],
},
],
harness: {
memoryWindow: 20,
// SLA:受理<15min、打标<45min —— 停滞检测按此设
// SLA:判定<45min —— 停滞检测按此设;受理<15min 那一档做催办
stallAfter: 'PT45M',
nudge: [{ after: 'PT15M', action: 'nudge' }],
retry: { times: 2, backoff: 'PT30S' },
},
},
/* 资产初始版:flow = 步序声明 */
/* ── 资产初始版 ──────────────────────────────────────────────── */
assets: {
/* 资产初始版:案例模型(2026-07-29 起活动只有这一种业务模型) */
businessModel: {
cmmnXml: CMMN_XML,
narrative: NARRATIVE,
dmn: P03_DMN,
dmn: INTAKE_DMN,
/* 步骤剧本 —— 引擎按它组织每一拍的上下文,并自己收掉成果已在的步骤 */
steps: INTAKE_STEPS,
/* 阶段 —— 只给人看的进度刻度:三段激活态 + 放行收口 = 卡上的 x/4 */
phases: INTAKE_PHASES,
},
skills: INTAKE_SKILLS,
// 三张模版对应三种局面(事态全景 / 进展 / 成果预览),由步序声明按位置推送
// 四张模版对应四个局面;在办与归档同一张(§9.3 第 2 条)
viewTemplates: [
{ name: 'intake-overview', html: INTAKE_OVERVIEW },
{ name: 'intake-classified', html: INTAKE_CLASSIFIED },
@@ -186,16 +734,111 @@ export const manifest: ActivityManifest = {
distillHooks: [
{
on: 'terminal',
prompt: '归纳本单受理归一的经验:字段缺口形态、打标依据、被驳回的理由。',
prompt: '归纳本单受理归一的经验:字段缺口形态、打标依据、去向判断被驳回的理由。',
},
],
},
/* 样例集 —— 首装审以静态审 + 样例集替代回放审 */
/* ── 样例集 —— 首装审以静态审 + 样例集替代回放审 ───────────────── */
/*
* 样例就是**真客户会发的那种信**(2026-07-31 用户定)。
*
* 从前两个样例都写着「我司客户编码 C001」—— 那是我们内部的编码,
* 客户凭什么知道?真实来函里人只会说**自己是哪个公司的谁**。
* 拿带编码的样例去试,等于把「定位客户」这一步的难处提前替它做掉了:
* 认不出客户、要请人建档的那条路根本走不到,而那恰恰是这一状态的主路。
*/
samples: [
{
name: '车规 Analog 询价',
input: { customerCode: 'C001', rfqRef: 'mail:m-1001', subject: '询价 RFQ - 车规 Analog' },
/*
* **新客户** —— 这个邮箱**不在** `customer.contact` 里。
*
* 它跟样例 2 必须是两家公司、两个邮箱。曾经两条样例用的是同一个邮箱
* (`purchasing@sz-machinery.com`,正是随主数据播下的那位老客户),
* 于是「认不出 → 发数据录入卡请人建档」这条路**一次也没被走到过** ——
* 两条样例在第一个状态上是同一条路,而卡面上写着它们是两条。
* 认客户的钥匙只有邮箱:换名字不算换客户,换邮箱才算。
*/
name: '新客户询价(三条:在产/波动/EOL)—— 认不出客户,要请人建档',
input: {
customerEmail: 'sourcing@qd-marine.com',
subject: '【询价】RFQ-2026-0001 车规 Analog 三项',
body:
'你好,\n我是青岛海洋电子有限公司的李娜,采购部。\n' +
'我们是第一次和贵司询价,本次三项,请按标准交期报价:\n' +
'LM358DR 12K/年 目标价 8.0 交期8周;NE555P 5K/年 交期6周;TL072CDR 3K/年 目标价 22 交期10周\n' +
'付款方式请一并给个建议,谢谢。\n李娜 | 青岛海洋电子有限公司',
attachments: [],
},
expect: { ports: ['intake'] },
},
{
/*
* ── **老客户案例 · Itelma MD14.1** ────────────────────────────────
*
* 这一条**不是编的**:正文、附件 BOM、缺口位置、三分类结果,逐项照
* `~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理需求.html`(阶段2 执行目标)
* 与 `A1-询价受理记录.html`(阶段3 验收目标)两份目标件抄下来。
* 那两份是「这个活动办完该长什么样」的答案,这一条是「照它跑一遍」的题面 ——
* 题面与答案不同源,测出来的东西就不作数。
*
* 它一条压住 A1 全部要害:
* · 邮箱在 `customer.contact` 里(CU-0142)→ 第一状态认得出,**不发建档卡**
* (与样例 1 的新客户正好两条路);
* · 料在**附件**里不在正文里 —— 正文只给口径要求(CNY含税/EXW/交期按周/
* AEC-Q200/预授权 Analog/跳过 XTSD),逼着走「读附件」那条路;
* · 附件 10 行,其中 XTSD 那行客户明说本轮不报 → **有效 PN 应是 9 条**;
* · 两处缺口:UMW AO6604 缺制造商(俄文 BOM 那一列为空)、DAG… 缺年用量
* → 必填缺(年用量)要派执行卡,选填缺(制造商)只提醒;
* · 补齐后三分类判出 **5A / 3B / 1C**,有 C 类 → 去向是选型(to-alternative)。
*
* 附件给的是**基座读 xlsx 之后的形态**(制表符分列的文本),不是二进制:
* 契约里 `attachments[].text` 就是「读出来的文本」,活动看见的正是这个。
*/
name: '老客户询价 · Itelma MD14.1(认得出客户 · 料在附件 · 10 行报 9 条)',
input: {
customerEmail: 'e.volkova@itelma.ru',
subject: 'RFQ MD14.1 — 10 positions, BOM attached',
body:
'Dear Colleagues,\n\n' +
'We are preparing the sourcing package for our new project MD14.1 and would like to request ' +
'your quotation for the 10 positions listed in the attached BOM.\n\n' +
'Please note the following requirements:\n' +
'1. Prices in CNY including VAT, terms EXW.\n' +
'2. Lead time in weeks. Please state SPQ and MOQ separately.\n' +
'3. For position 4-02-01812 (TVM6C160K203R, varistor) AEC-Q200 certification is mandatory ' +
'— please attach the certificate together with your offer.\n' +
'4. Where the column "Analog" is filled in, the listed replacement is already pre-approved ' +
'by our engineering department. Any other analog requires our written confirmation.\n' +
'5. Please indicate the validity period of your prices.\n\n' +
'Position 4-00-04187 (XTSD series, SD controller) is not finalised on our side yet ' +
'— please skip it in this round.\n\n' +
'We would appreciate receiving your offer by 17 July.\n\n' +
'Best regards,\nEkaterina Volkova\nProcurement Department, Itelma',
attachments: [
{
name: 'BOM_Itelma_MD14.1.xlsx',
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
size: 18_432,
ref: 'sample-bom-md141',
/* 基座读 xlsx 的产物:首行列名,其后一行一条,制表符分列;空格 = 客户没填 */
text: [
'Itelma Code\tPN\tКатегория\tПроизводитель\tAnalog\tСертификация\tEAU (k)\tTarget price',
'4-02-01812\tTVM6C160K203R\tВаристор\tThinking Electronic\t\tAEC-Q200\t30\t',
'4-02-01733\tPESD2ETH-XR\tДиодная сборка\tNexperia\t\t\t30\t',
'4-14-01389\tBSCH00160808R12J00\tДроссель\tYAGEO\tCLH1608T-R12J-S\t\t60\t',
'4-14-01455\tBWVF005050201R0T00\tДроссель\tYAGEO\tLVF505020-1R0T-N\t\t90\t',
'4-00-04102\tCJTB0101R6N\tМикросхема\tJiangsu Changjing\t\t\t60\t',
'4-00-03987\tSGM2202-ADJYN6G/TR\tМикросхема\tSGMicro\t\t\t30\t',
'4-02-01698\tUMW AO6604\tТранзисторная сборка\t\t\t\t30\t',
'4-14-01502\tBBPY00201209601Y00\tФильтр\tYAGEO\tPBY201209T-601Y-N\t\t30\t',
'9-00-00251\tDAG2400L321504XZB01-R0\tАнтенна\t浙江嘉康\t\t\t\t',
'4-00-04187\tXTSD04GLGEAQG-I\tКонтроллер SD\t\t\t\t\t',
].join('\n'),
note: 'xlsx 已读出 11 行(含表头),10 条料',
},
],
},
expect: { ports: ['intake'] },
},
],
+50 -11
View File
@@ -6,6 +6,8 @@
* + 结晶片段脚本(可选:反复出现的确定性子序列)。
*
* **载方法,不载权力**:这里提到的工具都在本活动白名单内;技能不扩权、不改约。
* 渐进披露:常驻上下文只有 `id label:applicability`,正文 Agent 说要才加载 ——
* 一次把所有正文塞进去,上下文会被方法论淹掉,真正的局面反而看不见。
*/
import { skillIdSchema } from '@contracts';
import type { Skill } from '@contracts';
@@ -20,17 +22,21 @@ export const INTAKE_SKILLS: Skill[] = [
'目标价常带货币符号或「目标价/target」,交期常带「周/weeks/wks/交期/lead」。',
'',
'三条纪律:',
'① **原文没写的不要补**。宁可标 missingKeyFields,也不要按常识填一个「合理值」——',
'① **原文没写的不要补**。宁可把 missing_key_fields 记上,也不要按常识填一个「合理值」——',
' 受理阶段编出来的数,会一路流到定价和报价单上,越往后越难发现。',
'② **单位归一但不换算口径**:12K → 12000 可以;「月用量 1000」不要自作主张乘 12,',
' 那是业务判断,该走补全卡问客户。',
'③ **一条需求一行**。同一 PN 不同数量档是两行,不是一行的两个值。',
'',
'拆不动就交人:缺 PN / 数量 / 交期任一,把 missingKeyFields 加一,让 D1 判断把补全卡发出去。',
'写台账时一拍写完:`ledger.write` 的 rows 收数组,一份 BOM 几十条也是一次写完 ——',
'一行一拍等于把同一件事拆成几十次模型调用,每次都要重读一遍上下文。',
'',
'写完顺手把 `quote_intake.verdict` 的 line_total 与 missing_key_total 填上:',
'那两个数是完整度判定表 F-01 的入参,也是验收的机检判据 —— 不写,下一步无从判起。',
].join('\n'),
crystallized: [],
knowledgeRefs: ['《询价受理规范》'],
version: '1.0.0',
knowledgeRefs: ['《询价受理规范》F-01'],
version: '2.0.0',
},
{
id: skillIdSchema.parse('intake.classify-basis'),
@@ -39,15 +45,48 @@ export const INTAKE_SKILLS: Skill[] = [
method: [
'P-03 只认三个入参:距上次报价天数、行情是否稳定、生命周期状态。三者齐了,结论是**算出来**的,不是判出来的。',
'',
'所以打标前唯一该做的事是**把判据取齐**:调历史价情报工具,拿回这三项。',
'取不到(新料号无历史)不是错误——工具会回「无历史」,那本身就是判据:',
'距上次报价记为极大值 → 命中 B 类「重估后报」,这是对的,新料号本就该重估。',
'所以打标前唯一该做的事是**把判据取齐**:调 `crm.history-price-read`,',
'**参数 pns 收数组** —— 这一单所有 PN 一次传进去。一条一条调,每调一次就多烧一拍模型,',
'而拿回来的东西一模一样。',
'',
'不要绕过决策表自己判。规则表是可回放、可追责的;临场判断不是。',
'取不到不是错误:新料号无历史,工具回 historyDays=9999、hit=false。',
'**那本身就是判据** —— 距上次报价极大 → 命中 B 类「重估后报」,这是对的:新料号本就该重估。',
'',
'判据逐条写进 `quote_intake.type_table`(history_days / market_stable / lifecycle),',
'再调 `quote-intake.classify` 一次把这几行传进去拿回 A/B/C。',
'**不要绕过决策表自己判**:规则表可回放、可追责;临场判断不是。',
].join('\n'),
// 结晶片段:反复出现的确定性子序列 —— 取历史价这一步每单都一样
crystallized: [{ tool: 'crm.history-price-read', input: { pn: 'pn' } }],
// 结晶片段:取历史价这一步每单都一样 —— 确定性子序列,不必每次让模型重想
crystallized: [{ tool: 'crm.history-price-read', input: { pns: 'pns' } }],
knowledgeRefs: ['三分类规则 P-03'],
version: '1.0.0',
version: '2.0.0',
},
{
id: skillIdSchema.parse('intake.route-and-dispatch'),
label: '定去向:状况标不等于去向',
applicability: '打标完成、要决定这一单往哪走(要不要选型、要不要寻源)时。',
method: [
'**状况标是判断的输入,不是去向本身**。同一个 B 标,可能只需寻源,也可能连带选型 ——',
'把标直接当去向用,是这一步最常见的错。',
'',
'两问分开答,可以同时成立:',
'· **要不要选型**:原厂料能正常供货且客户没点名替代 → 不需要;',
' EOL/停产、长交期缺货,或客户点名要替代 → 需要,交 A2 替代选型。',
'· **要不要寻源**:无近期历史价或行情波动(B 类)→ 需要,交 A3 寻源询价。',
'· 两者可同时成立 —— 选型定案后仍要为替代料寻价。',
'',
'**不要在这一步开什么请求单**:随令牌流去下一个节点的只有**验收过的那份受理记录**,',
'A2 / A3 拿到之后按自己的口径归一需要的数据。替它们先摆一份,',
'等于同一份事实在两个活动里各存一份 —— 改一处就分叉,而分叉之后没人看得出以哪份为准。',
'',
'答完把 `quote_intake.verdict` 的 need_alternative / need_sourcing 两枚旗写死。',
'**这两枚旗是下游两道网关唯一读的东西**:不写,整条流出了受理口就没路可走',
'(踩过:两枚旗没有台账列承载,G1 全流死路)。',
'',
'吃不准就走判断,不许臆断直通:直通省下的十分钟,错了要用三天返工换。',
].join('\n'),
crystallized: [],
knowledgeRefs: ['三分类规则 P-03', 'docs/MVP示例工作流.html §1.1 S6/G1'],
version: '3.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>`;
+654 -66
View File
@@ -1,77 +1,665 @@
/**
* A1 视图模版 —— 三种局面:事态全景 / 打标判据 / 成果预览(AM 07)。
* 外壳与卡片件来自 `@sdk`:六个活动共用一套面貌,不各自发明。
* A1 询价受理的 HTML 报告模版 —— **从 PRD 目标件搬过来的那两份件**。
*
* 出处(逐块照搬,样式表见 `prd-style.ts`,一个数值都没改):
* · `~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理需求.html` → `INTAKE_OVERVIEW`
* (阶段2 · 待处理页里嵌的那份);
* · `~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理记录.html` → `INTAKE_RELEASE`
* (阶段3 · 工作成果里定稿归档的那份)。
*
* ── 为什么是搬,不是照着做 ─────────────────────────────────────────────
* 前三轮我拿自己那套 SDK 外壳「照着做」:块搬齐了,版面却是两套 —— 章节头成了
* 彩色方块编号、多出一段摘要与一条元信息带、字号用 clamp 响应式。三次报「25 块到位」,
* 而那是**结构断言**(某个 class 在不在),对版面一点约束都没有。人一眼就看出不是那份件。
* 目标件是像素级的答案:搬过来,才不会在复述里走样。
*
* ── 搬的时候,哪些东西必须换掉 ─────────────────────────────────────────
* 原型是**带脚本的演示页**:数据写死在 script 里,由 JS 造表、算计数、切编辑态。
* 而这里的容器是 sandbox iframe,**一行脚本也跑不了**;填数只有三件事 ——
* `data-slot` 取值、`data-rows` 逐行展开、`data-class-of` 按值上色。所以:
* · 写死的行 → `data-rows` 模版,一行一条从台账草稿展开;
* · JS 算的计数 → 台账里的汇总列,`data-slot` 取;
* · JS 造的「必填/选填」占位胶囊 → CSS `:empty::after`(基座对空值留空,见 `data-blank`);
* · JS 算的**命中行**与结论横幅 → 基座按契约的出口谓词求值后注入 `case:route*`
* (与放行时发口的是同一次判定,不是这屏自己又算了一遍);
* · 编辑态 → **就长在件里**(2026-08-01 改)。每个可改的格子渲两份:只读那份与
* `<input class="jc-cell">` 孪生件,由根节点上的 `editing` 二选一显示。
* 待处理那一屏摆的**只有这份件**,所以改数据也只能在件上改;
* 工作成果不加那个 class,同一份 HTML 就是归档件。
* 收值在外层(`allow-same-origin` 读得到 DOM),iframe 里仍然一行脚本都没有。
* · 「修改 / 确认」按钮 → 不做:提交那一下在**对话区的卡**上,
* 判据与作业指导书也在那儿 —— 工作区只放件本身。
*
* ── 搬不过来的两处,如实空着 ───────────────────────────────────────────
* · 圆标里的首字母(IT / EV / 冯)—— 得算,而模版不许算;换成同形状里的图标。
* · 「近 6 笔成交」迷你柱 —— `market.price_history` 有列没数据(种子不写、CRM 是桩),
* 没有写入方。画六根编出来的柱子是撒谎,画一条空轨像组件坏了,所以那一格不画,
* 把「为什么没有」写在卡脚。
*
* ── 取值路径三纪律(编错一个字,屏上就是一片「—」)────────────────────
* ① 顶层只取汇总列与单值列;逐行列摊到顶层是「后写的赢」,摆到题头上是抽样不是概览。
* ② 逐条数据一律走 `mine:域.对象` —— `域.对象` 是正式版全表,别单的行也在里面。
* ③ 列名逐字照 `manifest.ts` 的 `declarations.ledger` 抄,不照记忆写。
*/
import { kvCard, noteCard, tableCard, viewShell } from '@sdk';
import { PRD_CSS, PRD_SPRITE } from './prd-style.ts';
export const INTAKE_OVERVIEW = viewShell({
title: 'RFQ 受理全景',
kicker: 'A1 · 统一受理',
tone: 'brand',
/* ==================== 搬运期补的几条样式 ==================== */
/**
* 原型用 JS 干、这里只能用 CSS 干的那几件 —— **补在原样式表之后,不改原值**。
*
* ① 空格子的「必填 / 选填」胶囊:原型是 JS 判空后插一个 `jc-ph`;
* 这里改成 `:empty::after`,靠基座对空值留空(`data-blank`)触发,胶囊样式仍用原型的数值。
* ② 缺必填的整行浅红 + 行首红条:原型给 `tr.miss`,这里用 `:has()` 认。
* ③ 类徽 / 分布条 / 判定表命中行按值上色:原型由 JS 拼 class,
* 这里由基座把值消毒成 `v-*` 追加进 class(`data-class-of`)—— 判断在样式表里,不在填数里。
*/
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); }
/* ② 缺必填的整行 —— 原型是 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); }
/* 窄屏原型给的是 ::before「第 」+ ::after「 条」;这里 ::before 已被计数器占用,
把两句合进 ::before,并把原型那句 ::after 收掉 —— 否则窄屏上写着「第 9 条 条」 */
@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; }
/* 开闸是给那张表的容器加 「on」(外层按下「编辑」才加)—— 一屏两张表各管各的 */
.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; }
/* 类那一列平时只有一枚 24px 的徽,编辑态要放得下一个字 */
.proto-root .jc-tbl-in.on td.jc-ty, .proto-root .jc-tbl-in.on th:nth-child(2) { width: 56px; min-width: 56px; }
.proto-root .jc-tbl-in.on td.num > .jc-cell, .proto-root .jc-tbl-in.on .jc-cell.num { text-align: right; }
/* 编辑态下必填空格子:红框 + 红 placeholder(原型的 req-empty 同款,只是由 :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; }
/* ⑥ **铺满工作区**(2026-08-02 用户定:「报告不要做成单独的嵌入层」)。
原型那份是独立网页,jc-stage 给了 1160px 上限与 22/16/52px 的四周留白 ——
摆进工作区就成了「画布上浮着的一张小卡」:左右各空一条,底下还坠着 52px。
件在这里**就是这一屏的正文**,不是嵌在里面的另一个页面:去掉上限与横向留白,
外框那圈圆角边也一并卸掉(工作区自己已经是一张卡,两层框叠着像贴纸)。 */
.jc-stage { max-width: none; padding: 0 0 4px; }
.proto-root .jc-report { border: 0; border-radius: 0; box-shadow: none; }
/* ⑤ 那道闸 —— 默认「已锁定 + 编辑」,开了变成「取消 / 保存」。
整块只在这一屏可改时才出现(外层给根节点加 「editable」);
归档件里它一个字都不该有 —— 那时件是件,不是表单。 */
.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; }
/* 那条「怎么改」的提示只在开了闸之后说 —— 锁着的时候它是句废话 */
.proto-root .jc-editbar { display: none; }
.proto-root .jc-editgate.on + .jc-editbar { display: flex; }
/* ⑦ 增删行 —— 手改是兜 AI 的错那一手,而错不止「这一格填错了」:
「这一行根本不该在」与「漏了一行」是同一类错,只给改格子就没有出口。 */
/* 序号那一格一列两用:只读时是行号(计数器画的),编辑时是「撤掉这一行」的 × */
.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); }
/* ③ 按值上色:基座把值消毒成 v-* 追加进 class */
/* 刻度条按缺口:missing_key_fields 是条数,0 = 完整(绿,原型的默认色),非 0 = 缺必填(红) */
.proto-root .jc-segmeter > i.v-1, .proto-root .jc-segmeter > i.v-2, .proto-root .jc-segmeter > i.v-3 { background: var(--danger); }
/* 刻度条按状况标:归档屏那张卡上,一格即一条料的类别 */
.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-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); }
/* 分布条:三段按条数分宽。原型是 JS 写 inline style="flex:N",而模版不许算 ——
改由 data-class-of 把**条数本身**投影成 v-<N>,这里备一把梯子让 CSS 认。
条数 0 的那段整段不出:摆一条零宽的彩带不如不摆。 */
/* 原型那段是 grid + place-items:center;这里段里多了个 ::before 前缀,
grid 会把前缀和数字排成上下两行 —— 改成一行居中的 flex */
.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 }
/* 段里那句「A · 5」的前缀 —— 类别是这一段的固有属性,不必从数据里取 */
.proto-root .jc-distbar > i.a::before { content: "A · "; }
.proto-root .jc-distbar > i.b::before { content: "B · "; }
.proto-root .jc-distbar > i.c::before { content: "C · "; }
/* 判定表命中行:基座把命中的那一条注入成 v-r1/r2/r3,壳上带 class,行自己认 */
.proto-root .jc-dmn.v-r1 { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.11); }
.proto-root .jc-dmn.v-r2 { --tone: var(--brand); --tone-soft: rgba(51,112,255,.09); }
.proto-root .jc-dmn.v-r3 { --tone: var(--ok); --tone-soft: rgba(39,135,23,.10); }
.proto-root .jc-dmn.v-r1 .jc-dmn-r.r1, .proto-root .jc-dmn.v-r2 .jc-dmn-r.r2, .proto-root .jc-dmn.v-r3 .jc-dmn-r.r3 {
position: relative; background: var(--tone-soft); color: var(--ink-2);
}
.proto-root .jc-dmn.v-r1 .jc-dmn-r.r1::before, .proto-root .jc-dmn.v-r2 .jc-dmn-r.r2::before, .proto-root .jc-dmn.v-r3 .jc-dmn-r.r3::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--tone);
}
.proto-root .jc-dmn.v-r1 .jc-dmn-r.r1 .jc-dmn-c, .proto-root .jc-dmn.v-r2 .jc-dmn-r.r2 .jc-dmn-c, .proto-root .jc-dmn.v-r3 .jc-dmn-r.r3 .jc-dmn-c { color: var(--ink); }
.proto-root .jc-dmn.v-r1 .jc-dmn-r.r1 .jc-dmn-i, .proto-root .jc-dmn.v-r2 .jc-dmn-r.r2 .jc-dmn-i, .proto-root .jc-dmn.v-r3 .jc-dmn-r.r3 .jc-dmn-i { color: var(--tone); }
.proto-root .jc-dmn.v-r1 .jc-dmn-r.r1 .jc-dmn-c b, .proto-root .jc-dmn.v-r2 .jc-dmn-r.r2 .jc-dmn-c b, .proto-root .jc-dmn.v-r3 .jc-dmn-r.r3 .jc-dmn-c b { color: var(--tone); font-weight: 800; }
.proto-root .jc-dmn.v-r1 .jc-dmn-r.r1 .jc-dmn-t, .proto-root .jc-dmn.v-r2 .jc-dmn-r.r2 .jc-dmn-t, .proto-root .jc-dmn.v-r3 .jc-dmn-r.r3 .jc-dmn-t { color: var(--ink); }
.proto-root .jc-dmn.v-r1 .jc-dmn-r.r1 .jc-dmn-t code, .proto-root .jc-dmn.v-r2 .jc-dmn-r.r2 .jc-dmn-t code, .proto-root .jc-dmn.v-r3 .jc-dmn-r.r3 .jc-dmn-t code {
color: #fff; background: var(--tone); border-color: var(--tone);
}
/* 判定那一列:两句话都写在模版里,哪一句现身**由壳上那个 v-rN 决定** ——
模版仍然不许算,而「命中 / 未求值」这层判断留在样式表。
一条都没命中(算不出去向)时壳上没有 v-rN,两句都不出:不冒充判过。 */
.proto-root .jc-dmn .jc-dmn-v > .y, .proto-root .jc-dmn .jc-dmn-v > .n { display: none; }
.proto-root .jc-dmn.v-r1 .r1 .jc-dmn-v > .y, .proto-root .jc-dmn.v-r2 .r2 .jc-dmn-v > .y, .proto-root .jc-dmn.v-r3 .r3 .jc-dmn-v > .y {
display: inline-flex; align-items: center; gap: 5px; font-weight: 800; color: var(--tone);
}
.proto-root .jc-dmn.v-r1 .r2 .jc-dmn-v > .n, .proto-root .jc-dmn.v-r1 .r3 .jc-dmn-v > .n,
.proto-root .jc-dmn.v-r2 .r1 .jc-dmn-v > .n, .proto-root .jc-dmn.v-r2 .r3 .jc-dmn-v > .n,
.proto-root .jc-dmn.v-r3 .r1 .jc-dmn-v > .n, .proto-root .jc-dmn.v-r3 .r2 .jc-dmn-v > .n { display: inline; color: var(--ink-4); }
.proto-root .jc-verdict.v-r1 { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.14); --tone-line: #ffd9a8; }
.proto-root .jc-verdict.v-r2 { --tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft); }
.proto-root .jc-verdict.v-r3 { --tone: var(--ok); --tone-soft: rgba(39,135,23,.13); --tone-line: #bfedb4; }
/* 圆标里放图标不放首字母 —— 首字母得算,而画一个算错的缩写不如画一个说得清类别的图标 */
.proto-root .jc-logo svg, .proto-root .jc-face svg, .proto-root .jc-rp-ava svg { width: 17px; height: 17px; }
`;
/** 一枚图标 —— 与原型同一写法:`use` 引精灵里的 symbol */
const ico = (name: string, size = 11): string =>
`<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-${name}"/></svg>`;
/**
* 一份件的外壳 —— **照原型的三层壳**:`.proto-root > .app-shell > .jc-stage > article.jc-report`。
* 少一层,整张样式表(选择器带 `.proto-root .app-shell` 前缀)一条都不生效。
*/
const doc = (input: {
badge: string;
kicker: string;
title: string;
titleRef: string;
/** 题头右上:在办亮灯(jc-rp-wait);办结那一枚印章由归档态自己带 */
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>`;
/** 章节头 —— 原型的 `jc-rp-part`:小标签 + 细线 + 右端小签 */
const part = (label: string, aside = '', tone = ''): string =>
`<div class="jc-rp-part"><b>${label}</b><i class="ln"></i>${aside === '' ? '' : `<em class="${tone}">${aside}</em>`}</div>`;
/** 落款 —— 署名取任务负责人(基座填);右端那枚签写「这份件在流里的位置」,不写状态 */
const 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>`}`;
/* ==================== 共用件 ==================== */
/**
* 来源信件 —— 原型的 `jc-letter`。正文 `pre-wrap` 原样照录:摘要过的原文就不是原文了。
* 收件签旁那枚归属签(通道入站 → 归了谁)与右端入站时刻都由基座填 ——
* 让活动自报「归属解析 → 冯兵」,等于让被指派的人自己说自己是谁。
*/
const LETTER = `<article class="jc-letter">
<div class="jc-letter-h">
<span class="jc-letter-dir">${ico('mail-open', 12)}收件</span>
<span class="jc-chip"><b data-slot="case:origin"></b> · 归属解析 → <b data-slot="case:owner"></b></span>
<code data-slot="case:openedAt"></code>
</div>
<div class="jc-letter-meta">
<p><i>发件人</i><span data-slot="contact_name"></span><code data-slot="customerEmail"></code></p>
</div>
<h4 class="jc-letter-sub" data-slot="subject"></h4>
<p class="jc-letter-body" data-slot="body"></p>
<div class="jc-atts" data-rows="attachments">
<span class="jc-att">${ico('sheet', 13)}<code data-slot="name"></code><span data-slot="note"></span></span>
</div>
</article>`;
/**
* 受理要素四卡 —— 原型的 `jc-four`。
* `kind` 决定第四张卡下面那条刻度条按什么上色:过程屏按缺口(红格 = 必填未补),
* 归档屏按状况标(A 绿 / B 蓝 / C 橙)。**一格一条料**,每格都对得上清单里的一行。
*/
const four = (kind: 'gap' | 'type'): string => `<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"></b><span data-slot="customer_kind"></span></div>
</div>
<span class="jc-fcard-note">已命中客户资料库 <code class="jc-mono" data-slot="customer_code"></code></span>
</div>
<div class="jc-fcard t-teal">
<span class="jc-fcard-k"><i>${ico('user')}</i>联系人</span>
<div class="jc-fcard-b">
<span class="jc-face">${ico('user', 17)}</span>
<div class="jc-fcard-t">
<b data-slot="contact_name"></b>
<div class="jc-mailrow"><code data-slot="customerEmail"></code></div>
</div>
</div>
<span class="jc-fcard-note">采购口 · 本单对外发件收件人</span>
</div>
<div class="jc-fcard t-ok">
<span class="jc-fcard-k"><i>${ico('history')}</i>客户属性</span>
<span class="jc-tagbig">${ico('shield-check', 13)}<span data-slot="customer_kind"></span></span>
<span class="jc-fcard-note">
成交台账尚未接入 —— 历史价由下游定价环节现查;
新客户走首单口径,<b>不得取锚点</b>
</span>
</div>
<div class="jc-fcard${kind === 'type' ? ' t-ok' : ''}">
<span class="jc-fcard-k"><i>${ico('boxes')}</i>有效 PN</span>
<div class="jc-count"><b data-slot="line_total"></b><span>/ <b data-slot="bom_total"></b> 行 BOM</span></div>
<div class="jc-segmeter" data-rows="mine:quote_intake.record">
<i data-class-of="${kind === 'type' ? 'type_tag' : 'missing_key_fields'}"></i>
</div>
<span class="jc-fcard-note">
${
kind === 'type'
? '一格一条料 · 按状况标着色 · <b data-slot="line_total"></b> 条已交下游'
: '一格一条料 · 红格是必填未补 <b data-slot="missing_key_total"></b> 条'
}
</span>
</div>
</div>`;
/**
* 有效 PN 清单 —— 原型那六列,一列不多一列不少。
*
* 「客户料号」原型明写不收(『Itelma Code 不是本记录的字段』),这里也不摆:
* 报告是给报价这条线看的,客户自己的编码在 A1 出口的载荷里,不占版面。
* `req`/`opt` 只决定空格子出哪种胶囊;归档屏一律 `dimempty`(那时空就是「客户没给」,
* 再摆「你还能填」的红胶囊就是在催一件已经办完的事)。
*/
const listTable = (mode: 'draft' | 'final'): string => {
/*
* `data-col` 一挂,这一格就多渲一个输入孪生件(基座 `twinInputs`),
* 待处理那一屏可就地改;写的是**台账列名**,不是表头文字 ——
* 表头改个说法不该让写回错位。归档件不挂:那时它是件,不是表单。
*/
const cell = (key: string, head: string, kind: 'req' | 'opt', style = ''): string => {
const cls = mode === 'final' ? 'dimempty' : kind;
return `<td class="${cls}" data-h="${head}"><span class="jc-vcell ${style}" data-slot="${key}" data-col="${key}" data-blank></span></td>`;
};
const req = mode === 'final' ? '' : 'req';
/*
* 序号那一格在编辑态换成「撤掉这一行」的 × —— 一列两用,不为它单开一列。
* 只读时它是行号(CSS 计数器画的),编辑时人要的是「这一行不该在」的那个动作。
*/
const del = `<td class="jc-rn"><button type="button" class="jc-rowdel"
data-edit-del="quote_intake.record" title="撤掉这一行">×</button></td>`;
return `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
<thead><tr>
<th style="width:36px;min-width:36px"></th>
${mode === 'final' ? '<th style="width:44px;min-width:44px">类</th>' : ''}
<th class="${req}" style="min-width:178px">PN</th>
<th style="min-width:88px">品类</th>
<th class="${req}" style="min-width:78px">年用量</th>
<th style="min-width:140px">制造商</th>
<th style="min-width:84px">目标价</th>
<th style="min-width:200px;width:100%">备注</th>
</tr></thead>
<tbody data-rows="mine:quote_intake.record" data-edit="quote_intake.record"><tr>
${del}
${mode === 'final' ? '<td class="jc-ty" data-h="分类"><span class="jc-tbadge" data-class-of="type_tag" data-slot="type_tag" data-col="type_tag"></span></td>' : ''}
${cell('pn', 'PN', 'req', 'mono')}
${cell('category', '品类', 'opt')}
${cell('eau', '年用量', 'req', 'mono')}
${cell('brand', '制造商', 'opt')}
${cell('target_price', '目标价', 'opt', 'num')}
${cell('remark', '备注', 'opt')}
</tr></tbody>
</table></div>`;
};
/** 编辑态那条提示带 —— 原型的 `jc-editbar`,只在待处理那一屏现身 */
/**
* 一张可改的表的**闸** —— 默认锁着,按「编辑」才开(2026-08-01 用户定)。
*
* 从前进这一屏整份件就是编辑态:54 个输入框一起亮着,人还没决定要改什么,
* 屏上已经是一张待填的表 —— **看**与**改**是两件事,件首先是给人看的。
*
* 三个按钮由外层挂事件(容器 `allow-same-origin`,读得到也改得动 iframe 的 DOM);
* **iframe 里仍然一行脚本都没有**。`data-edit-*` 是外层认它们的钩子:
* · `open` → 给根节点加 `editing`,输入孪生件现身;
* · `cancel` → 把每个输入还原成进编辑态那一刻的值,再关掉;
* · `save` → 收值交给外层缓冲(提交那一下才真落库),再关掉。
* 一屏可能有两张表(需求表 / 分类表),所以闸带 `data-edit-for="域.对象"` —— 各管各的。
*/
const editGate = (address: string, what: string): string => `<div class="jc-editgate" data-edit-for="${address}">
<span class="jc-editgate-t">${ico('lock', 12)}${what}<b>已锁定</b> —— 看的时候不误碰,要改按右边</span>
<button type="button" class="jc-egbtn open" data-edit-open="${address}">${ico('pen', 12)}编辑</button>
<button type="button" class="jc-egbtn ghost" data-edit-cancel="${address}">取消</button>
<button type="button" class="jc-egbtn save" data-edit-save="${address}">${ico('check', 12)}保存</button>
</div>
<div class="jc-editbar">${ico('pen', 14)}
<span>改哪一格点哪一格:<b>整份清单每一格都能覆盖</b> —— AI 抽错的直接改,缺的补上;
多出来的行按右边的 × 撤掉,漏了的用底下的「加一行」补。
红框是必填未补。改动只落任务草稿层,<b>保存不等于放行</b>。</span>
</div>`;
/**
* 表尾那条「加一行」—— **只在编辑态现身**(样式表按根节点的 `editing` 决定)。
*
* 为什么人得能加行:手改本来就是**兜 AI 的错**的那一手,而错不止「这一格填错了」——
* 「BOM 里有而它没抽出来」是同一类错,只给改格子就没有出口。
*/
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>`;
/*
* ── 这里**去掉了「本轮不报」那条横幅**(2026-08-02 用户当场指出:「也删了」)────
*
* 原文:「本轮不报 N 行:… —— 客户补齐规格后可另起一单。
* 有效条目数 = 来件条目 − 本轮不报,两个数对不上就是拆漏了」。
*
* 绝大多数单子它是「0 行:—」——**一条永远摆在那儿、九成时候说「没有」的横幅**,
* 读的人每次都得扫过它才知道没事。而后半句是讲给拆料那一步听的对账口诀,
* 不是给验收的人看的:拆漏没拆漏由收步判据 `record_rows = line_total` 当场拦着,
* 拦不住的时候摆一句口诀也没用。
*
* 题头那一行仍如实写着「N 条 · 已剔除 M」——「剔除了几条」这个事实没有丢。
*/
/**
* 料号分类:分布条 + 图例 —— 原型的 `jc-dist`。
*
* 三段按条数分宽:段宽在原型里是 JS 写的 `style="flex:N"`,模版不许算,
* 于是把**条数本身**投影成 class(`data-class-of` → `v-5`),让样式表按梯子取宽。
* 段里印的数字与图例读的是同三个汇总列 —— 一处数据两处呈现,对不上就是台账自己错了。
*/
const DIST = `<div class="jc-dist">
<div class="jc-distbar">
<i class="a" data-class-of="quote_intake_record_type_tag_a" data-slot="quote_intake_record_type_tag_a"></i>
<i class="b" data-class-of="quote_intake_record_type_tag_b" data-slot="quote_intake_record_type_tag_b"></i>
<i class="c" data-class-of="quote_intake_record_type_tag_c" data-slot="quote_intake_record_type_tag_c"></i>
</div>
<div class="jc-distlg">
<span><em class="a"></em>A 类 · 行情完备 <b data-slot="quote_intake_record_type_tag_a"></b></span>
<span><em class="b"></em>B 类 · 需寻源询价 <b data-slot="quote_intake_record_type_tag_b"></b></span>
<span><em class="c"></em>C 类 · 需替代选型 <b data-slot="quote_intake_record_type_tag_c"></b></span>
</div>
</div>`;
/* ==================== 局面一 · 受理中(阶段2 待处理页嵌的那份)==================== */
export const INTAKE_OVERVIEW = doc({
badge: 'file-text',
kicker: '统一报价 · A1 受理归一 · 过程成果',
title: '询价受理需求',
titleRef: 'rfq_ref',
state:
'<span class="jc-rp-wait warn">缺必填 <b data-slot="missing_key_total"></b> · 缺选填 <b data-slot="missing_minor_total"></b></span>',
body:
kvCard('询价来源', [
['客户编码', 'customer_code', 'mono'],
['询价单号', 'rfqRef', 'mono'],
['主题', 'subject'],
]) +
kvCard('归一结果', [
['型号 PN', 'pn', 'mono'],
['厂牌', 'brand'],
['年用量 EAU', 'eau'],
['目标价', 'target_price'],
['交期', 'lead_time'],
['缺关键字段', 'missing_key_fields'],
]) +
noteCard('这一屏在说什么', '归一由模型从询价原文抽取,逐条落<b>任务级草稿层</b>:本任务可见、正式版不可见、不产生数据事件。放行前的一切都还改得动。'),
part('来源 · 客户 RFQ 邮件', '<span data-slot="case:openedAt"></span> 入站', 'jc-mono') +
LETTER +
`<div class="jc-derive">${ico('info', 14)}
<span>下方受理要素与 PN 清单,均由 AI 从这封来函与附件 BOM 抽取归一而来 —— 正文里的口径要求
(币种 / 贸易条款 / 交期单位 / 认证 / 预授权替代 / 本轮跳哪条)已逐条落到对应字段与备注;
附件那一行写的是<b>基座读取的结果</b>,读得出文本才作数。</span>
</div>` +
part('受理要素', 'F-01 完整度自检') +
four('gap') +
part('有效 PN 清单', '<b data-slot="line_total"></b> 条 · 已剔除 <b data-slot="skipped_total"></b>') +
editGate('quote_intake.record', '有效 PN 清单') +
listTable('draft') +
addRowBar('quote_intake.record') +
`<div class="jc-drop">${ico('info', 13)}
<span>带 * 的两列是<b>必填</b>(PN / 年用量):缺任一按 F-01 退回补全,不许放行;
其余列缺了标灰「选填」—— 先受理、后追问,<b>不为它们拦单</b>,但那一问不能省。
<b>原文没写的一律不补</b>:受理阶段编出来的数会一路流到定价和报价单上,越往后越难发现。</span>
</div>`,
foot: sign('AI 拟稿,你审定 —— 要改哪一格,在「待处理」那张单的需求表上就地改', '统一报价 · A1 受理归一'),
});
export const INTAKE_CLASSIFIED = viewShell({
title: '料号分类与判据',
kicker: 'A1 · 三分类 P-03',
tone: 'ok',
/* ==================== 局面三 · 已分类 ==================== */
export const INTAKE_CLASSIFIED = doc({
badge: 'shield-check',
kicker: '统一报价 · A1 受理归一 · 三分类 P-03',
title: '料号分类表',
titleRef: 'rfq_ref',
state: '<span class="jc-rp-wait">已分级 · 未打标 <b data-slot="untyped_total"></b></span>',
body:
kvCard('分类结论', [
['型号 PN', 'pn', 'mono'],
['Type 标', 'type_tag', 'tag'],
['分级依据', 'basis'],
]) +
kvCard('打标判据(DMN 入参)', [
['距上次报价', 'history_days'],
['行情稳定', 'market_stable'],
['生命周期', 'lifecycle'],
['历史价命中', 'history_hit'],
]) +
noteCard(
'为什么是确定性的',
'三分类规则 P-03 是<b>决策表</b>不是模型判断:同样的判据必得同样的结论,零方差零 token,命中的规则行随出参留痕。' +
'A = 直接快答;B = 重估后报(转寻源);C = 转案处理(转替代选型)。',
),
part('料号分类', '三分类规则 P-03 · 逐条判定') +
DIST +
part('逐条分级(判据 → 结论)', '按序求值 · 首个命中即出') +
editGate('quote_intake.type_table', '料号分类表') +
`<div class="jc-tbl-wrap"><table class="jc-tbl-in">
<thead><tr>
<th style="width:36px;min-width:36px"></th>
<th style="min-width:178px">型号 PN</th>
<th style="min-width:120px">距上次报价(天)</th>
<th style="min-width:88px">行情稳定</th>
<th style="min-width:96px">生命周期</th>
<th style="width:44px;min-width:44px">类</th>
<th style="min-width:200px;width:100%">分级依据</th>
</tr></thead>
<tbody data-rows="mine:quote_intake.type_table" data-edit="quote_intake.type_table"><tr>
<td class="jc-rn"></td>
<td data-h="型号 PN"><span class="jc-vcell mono" data-slot="pn" data-col="pn"></span></td>
<td data-h="距上次报价"><span class="jc-vcell num" data-slot="history_days" data-col="history_days"></span></td>
<td data-h="行情稳定"><span class="jc-vcell" data-slot="market_stable" data-col="market_stable"></span></td>
<td data-h="生命周期"><span class="jc-vcell" data-slot="lifecycle" data-col="lifecycle"></span></td>
<td class="jc-ty" data-h="分类"><span class="jc-tbadge" data-class-of="type_tag" data-slot="type_tag" data-col="type_tag"></span></td>
<td data-h="分级依据"><span class="jc-vcell" data-slot="basis" data-col="basis"></span></td>
</tr></tbody>
</table></div>` +
`<div class="jc-drop">${ico('info', 13)}
<span>三分类规则 P-03 是<b>决策表</b>,不是临场判断:同样的输入必得同样的结论,凭哪一条命中都留了痕。
<b>A</b> = 历史价 ≤ 90 天且行情稳;<b>B</b> = 无历史 / 过期 / 波动;<b>C</b> = EOL / 缺货 / 客户要求替代。
规则从上往下第一条命中即出 —— C 那一行排在最前,EOL 的料哪怕历史价很新也不判 A。
<b>拿不准的一律降 B</b>:错标一个 A,就是让一条本该重新核价的料带着旧价走到报价单上。</span>
</div>`,
foot: sign('状况标由判定表 P-03 求值 —— 判据与结论逐条可回核', '统一报价 · A1 受理归一'),
});
export const INTAKE_RELEASE = viewShell({
title: '统一受理记录 · 待放行',
kicker: 'A1 · 成果预览',
tone: 'case',
/* ==================== 局面四 · 待放行(阶段3 工作成果里的那份)==================== */
/**
* G2 分支网关 —— 三条互斥条件按序求值,只命中一条、只落一个活动。
*
* **命中哪一条不是这屏自己算的**:契约给每个出口写了判据,基座在放行那一刻按序求值,
* 把结果注入 `case:route*`(与发口用的是同一次判定)。这里只把它摆出来:
* 壳上带 `v-r1/r2/r3`,对应那一行自己上色。
* 摆成三张并列的去向卡就丢了「按序求值、只落一个」那层意思,所以照原型做成判定表。
*/
const dmnRow = (n: 1 | 2 | 3, cond: string, node: string, to: string, hit: string): string =>
`<div class="jc-dmn-r r${n}">
<span class="jc-dmn-i">${n}</span>
<span class="jc-dmn-c">${cond}</span>
<span class="jc-dmn-t"><code>${node}</code>${to}</span>
<span class="jc-dmn-v"><b class="y">${ico('check', 13)}命中 · ${hit}</b><b class="n">未命中 · 排他网关只落一口</b></span>
</div>`;
const G2 = `<div class="jc-dmn" data-class-of="case:routeKey">
<div class="jc-dmn-h"><span>序</span><span>条件(按序求值)</span><span>去向</span><span>判定</span></div>
${dmnRow(1, '存在 <b>C 类</b>(需替代选型)', 'A2', '替代选型', 'C 类 <b data-slot="quote_intake_record_type_tag_c"></b> 条')}
${dmnRow(2, '无 C 类、存在 <b>B 类</b>(需寻源询价)', 'A3', '渠道寻源', 'B 类 <b data-slot="quote_intake_record_type_tag_b"></b> 条')}
${dmnRow(3, '全部为 <b>A 类</b>(行情完备)', 'A4', '成本定价', 'A 类 <b data-slot="quote_intake_record_type_tag_a"></b> 条')}
</div>
<div class="jc-verdict" data-class-of="case:routeKey">
<div class="jc-vd-l">
<span class="jc-vd-k">本次判定 · 按序求值,首个命中即出</span>
<div class="jc-vd-cond"><span data-slot="case:routeTo"></span></div>
<p class="jc-vd-why">去向是<b>分类表算出来的</b>:三条规则按序求值、首个命中即出,
在你签字那一刻现算。所以它不是谁点出来的一次手感 ——
<b>要改去向,就在上面的清单里改料号的类,这一格会跟着变。</b></p>
</div>
<span class="jc-vd-arrow">${ico('arrow-right', 26)}</span>
<div class="jc-vd-r">
<span class="jc-vd-k">转入活动</span>
<div class="jc-vd-node"><span data-slot="case:routeTo"></span></div>
<span class="jc-vd-who"><i>${ico('user', 11)}</i>下游负责人按运营分派落定</span>
<p class="jc-vd-next">放行即落地:草稿层合并进正式版、intake 口的数据事件此刻才发射,
这一口的载荷随之交给下游。<b>门前的都还改得动,门后的都算数了。</b></p>
</div>
</div>`;
export const INTAKE_RELEASE = doc({
badge: 'file-check',
kicker: '统一报价 · A1 受理归一 · 交付成果',
title: '询价受理记录',
titleRef: 'rfq_ref',
/*
* 题头这枚灯**在办与归档同一句话才算数**:这份件放行前嵌在放行卡上、放行后原样进
* 「工作成果」。写「待放行」进了档案就成了谎,写「受理完成」在放行前就是替人签了字 ——
* 所以只写这份件本身是什么:归一了几条、三分类已定。
*/
state: '<span class="jc-rp-wait">受理记录 · <b data-slot="line_total"></b> 条 · 三分类已定</span>',
body:
tableCard(
'本单交付',
'mine:quote_intake.record',
[
['型号', 'pn', 'mono'],
['厂牌', 'brand'],
['年用量', 'eau'],
['目标价', 'target_price'],
['交期', 'lead_time'],
],
'只列<b>本单</b>写下的行 —— 别单放行过的记录不混进来。',
) +
kvCard('分流去向', [
['Type 标', 'type_tag', 'tag'],
['依据', 'basis'],
]) +
noteCard(
'放行意味着什么',
'放行通过<b>即刻落地</b>:草稿层合并入正式版、数据事件此刻才发射,G2 网关按 Type 分流。' +
'放行是草稿态与事实态之间的那道门 —— 门前的都还改得动,门后的都算数了。',
),
/*
* **来件原文留在归档件里**(2026-08-02 用户指出:「成果里为啥没有邮箱原文了」)。
*
* 待处理与工作成果本来就是**同一份件**,差别只在编辑态开不开。
* 归档件少一块来件,就等于把「凭什么这么受理」的出处从档案里抽走了 ——
* 三个月后回看这一单,九条料是怎么来的、客户那句「本轮跳过」在哪儿说的,
* 全都无从对起。件要自己说得清自己。
*/
part('来源 · 客户 RFQ 邮件', '<span data-slot="case:openedAt"></span> 入站', 'jc-mono') +
LETTER +
part('受理要素', 'F-01 完整度自检 · 通过', 'ok') +
four('type') +
part('料号分类', '三分类规则 P-03 · 逐条判定') +
DIST +
part('有效 PN 清单', '<b data-slot="line_total"></b> 条 · 已剔除 <b data-slot="skipped_total"></b>') +
editGate('quote_intake.record', '有效 PN 清单') +
listTable('final') +
addRowBar('quote_intake.record') +
part('结论与去向', '分支判定 · 起单 ×1', 'ok') +
G2 +
/*
* ── 这里**去掉了两张请求单表与那条对账说明**(2026-08-02 用户当场指出)──────
*
* 原来摆的是「开出的替代评估请求(交 A2)」「开出的询价请求(交 A3)」两张明细表,
* 外加一句「这两条等式在你签字前由系统当场对账,对不上直接拒收」。
*
* 两个理由:
* · 那句话**已经不成立** —— `route-alt` / `route-rfq` 两条对账判据同日删掉了
* (它们是内部一致性,该由引擎自己看住,不占放行卡)。件上还写着「系统当场对账」,
* 就是在说一件不再发生的事;
* · 这份件是**受理记录**:它答的是「这一单受理成了什么样」。派出去的两类单是
* **下游的输入**,不是这一单的成果 —— 要看它们,去 A2 / A3 那两条流水线上看。
* 摆在这儿只是把同一份数据在两处各画一遍。
*
* 「这一签下去开出了几张单」仍看得见:放行之后验收卡那一屏(落地结果)按表点名,
* 询价请求 / 替代评估请求各几行都在上面 —— 那才是它该出现的时刻与位置。
*/
part('放行后即执行的落地动作', '门前 → 门后') +
`<div class="jc-acts">
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>草稿层合并进正式版</b>&#12288;<span>本任务写下的受理记录、分类表、结论与两类请求单一并落账;
人工补全与就地修订的那几处随之落定。</span></div>
</div>
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>受理结果此刻才对外发出</b>&#12288;<span>连同
<b data-slot="line_total"></b> 条有效料号与它们的分类一并交出 —— 放行之前,世界上什么都没有发生。</span></div>
</div>
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>按分类算出的去向落下游一单</b>&#12288;<span>本次转入
<b data-slot="case:routeTo"></b>,已进对方的任务清单;另两条去向本次不起单。</span></div>
</div>
</div>`,
/*
* ── 这里**去掉了「受理结论」那张表**(2026-08-02 用户当场指出:「这个也多余」)──
*
* 摆的是 `bom_total / skipped_total / skipped_note / summary` 四格,而
* 前三格与同日删掉的那条「本轮不报」横幅说的是同一件事,
* 第四格是一句 AI 复述的总结 —— 底下的清单、分布条、去向横幅把该说的都说了,
* 再加一句转述只是让人多读一行。
*
* 一份件里同一个数出现两次,人得停下来对一遍两处一不一样 —— 那是白花的注意力。
*/
foot: sign('AI 拟稿 · 待审定放行 —— 要改哪一格,在这张放行单的成果表上就地改', '统一报价 · A1 受理归一 → 交下游'),
});