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

手改是**兜 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
+116 -89
View File
@@ -1,111 +1,138 @@
/**
* A2 替代选型的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
* A2 替代选型的案例模型 —— **一张执行单办完**(2026-07-31 用户定,大幅简化)。
*
* 上一版把 SOP 上的每一小步都建成了计划项(查规格、判品牌、查沉淀、撒网初筛、
* 逐条核验、确认主推、收规格书、参数对比、风险评估…… 九个 humanTask)。
* 那是把「FAE 脑子里怎么想」当成了「系统要管几步」——
* 而这一整段在业务上是**一件事**:拿着原型号去选一个替代料,选完给出对标结论。
* 系统该做的不是替他把这件事切成九块,是**把该填的表摆好、把结果收进台账**。
*
* 现在只有两项:
* T_select —— 派一张执行单给 FAE(卡面两张可编辑表),他填完提交;
* T_release —— 放行(触达世界,人工激活)。
*
* 与 A1 的分工:A1 是**受理归一**,活多、判据多、有确定性规则可依(两张判定表),
* 值得逐步建模;A2 是**一次专家判断**,建模建得再细也换不来业务上的好处,
* 只会让 Agent 全程跟 harness 较劲(A1 上一版 12 项的教训就在这儿)。
*/
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
<case id="alternative">
<!--
案卷项的 id **就是任务视图里的键名**。这里只认两样:
起单入参(altRequests —— A1 发过来的那批替代评估请求)与「对标表写了几行」(benchmark_total)——
后者既是放行前提,也是这一单唯一的机检判据。
-->
<caseFileModel>
<caseFileItem id="request" name="替代评估请求"/>
<caseFileItem id="shortlist" name="初筛清单"/>
<caseFileItem id="benchmark" name="替代对标表"/>
<caseFileItem id="datasheet" name="芯片规格书"/>
<caseFileItem id="altRequests" name="替代评估请求(开案即有,一条料一行)"/>
<caseFileItem id="benchmark_total" name="对标表条数"/>
</caseFileModel>
<casePlanModel id="plan" name="替代选型" autoComplete="true">
<sentry id="s_request">
<onPart id="op_req" caseFileItemRef="request"><standardEvent>create</standardEvent></onPart>
</sentry>
<sentry id="s_need_alt">
<onPart id="op_alt" caseFileItemRef="request"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_alt"><condition>needAlternative = true</condition></ifPart>
</sentry>
<sentry id="s_shortlist">
<onPart id="op_sl" caseFileItemRef="shortlist"><standardEvent>create</standardEvent></onPart>
</sentry>
<sentry id="s_confirmed">
<onPart id="op_cf" caseFileItemRef="shortlist"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_cf"><condition>candidatesConfirmed &gt;= 2</condition></ifPart>
<sentry id="s_benchmarked">
<onPart id="op_bm" caseFileItemRef="benchmark_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_bm"><condition>benchmark_total &gt; 0</condition></ifPart>
</sentry>
<stage id="S1" name="初选核险" autoComplete="true">
<entryCriterion id="ec_s1" sentryRef="s_request"/>
<humanTask id="T_spec_risk" name="人工查规格核险">
<itemControl><manualActivationRule id="m_sr"/></itemControl>
</humanTask>
<humanTask id="T_brand" name="人工判主推品牌">
<itemControl><manualActivationRule id="m_br"/></itemControl>
</humanTask>
<planningTable id="pt_s1">
<humanTask id="T_known_issues" name="人工查沉淀避坑"/>
</planningTable>
</stage>
<milestone id="M_initial" name="初选定性"/>
<stage id="S2" name="圈选替代" autoComplete="true">
<entryCriterion id="ec_s2" sentryRef="s_need_alt"/>
<humanTask id="T_shortlist" name="人工撒网拉初筛">
<itemControl><manualActivationRule id="m_sl"/><repetitionRule id="r_sl"/></itemControl>
</humanTask>
<humanTask id="T_grade" name="人工逐条核验分级">
<entryCriterion id="ec_grade" sentryRef="s_shortlist"/>
<itemControl><manualActivationRule id="m_gr"/><repetitionRule id="r_gr"/></itemControl>
</humanTask>
<humanTask id="T_confirm" name="人工确认主推型号">
<itemControl><manualActivationRule id="m_cf"/></itemControl>
</humanTask>
</stage>
<milestone id="M_candidates" name="候选圈定"/>
<stage id="S3" name="对比评估交付" autoComplete="true">
<entryCriterion id="ec_s3" sentryRef="s_confirmed"/>
<humanTask id="T_collect" name="人工收集规格书">
<itemControl><manualActivationRule id="m_co"/></itemControl>
</humanTask>
<humanTask id="T_compare" name="人工参数对比表">
<itemControl><manualActivationRule id="m_cp"/></itemControl>
</humanTask>
<humanTask id="T_risk" name="人工替代风险评估">
<itemControl><manualActivationRule id="m_rk"/></itemControl>
</humanTask>
<humanTask id="T_sign" name="人工签字交出">
<itemControl><manualActivationRule id="m_sg"/></itemControl>
</humanTask>
</stage>
<milestone id="M_signed" name="签字放行"/>
<humanTask id="T_knowledge" name="人工知识入库">
<itemControl><manualActivationRule id="m_kb"/></itemControl>
<!-- 唯一的活:派一张执行单给 FAE,他填完提交 -->
<humanTask id="T_select" name="替代选型:派执行单给 FAE,收回初筛与对标结论">
<itemControl><repetitionRule id="r_sel"/></itemControl>
</humanTask>
<timerEventListener id="TL_half" name="时限过半">
<timerExpression>P2D</timerExpression>
</timerEventListener>
<userEventListener id="UL_change" name="客户改需求"/>
<!-- 放行:触达世界,必须人工激活 -->
<humanTask id="T_release" name="替代选型结论验收放行">
<entryCriterion id="ec_rel" sentryRef="s_benchmarked"/>
<itemControl><manualActivationRule id="m_rl"/></itemControl>
</humanTask>
</casePlanModel>
</case>
</definitions>`;
export const NARRATIVE = `§0 开案:替代选型一事一议 —— 收到替代评估请求即开案。客户/PM 给出初选物料,或芯片类型与大致参数。
先核初选(EOL 等风险提示 + 是否我司主推品牌),无风险且主推直接采信初选办结;
需要替代的,先撒网(AI 检索/聚合平台拉初筛清单)后逐条人工核验,常要两三轮,
存活候选按替代等级分级(P2P/功能兼容/其他)圈 2~3 颗,做参数对比与风险评估成报告交回。
**任何替代必须把差异与风险写清 —— 差异没核清的不放行。**
export const NARRATIVE = `§0 开案:A1 判出一条料要替代(原型号 EOL / 缺货 / 客户点名),这一单就到你手上。
成品只有一样:**一份能让 BD 拿去跟客户讲的替代对标结论** ——
哪几个候选、推荐哪一个、参数差在哪、有什么风险、要不要打样验证。
S1 初选核险:一手资料以原厂官网为准(产品页/规格书/PCN·EOL 公告);
查芯片规格书确认初选料有无 EOL/停产风险,生命周期对照 PN物料主库;
对品牌原厂字典判是否我司主推 —— 无风险且主推,直接采信初选、免替代办结。
顺手查替代料库与技术问题案例库(裁量项),有沉淀方案或已知坑先亮出来。
**这一单只有一步**:把选型这件事派给 FAE(执行工单),他填完你收进台账,然后提交放行。
别把它拆成「先查规格、再判品牌、再撒网、再核验……」—— 那是他脑子里的过程,
不是系统要管的步。你替他把表摆好就够了。
S2 圈选替代:先撒网后核验,常要两三轮。逐条人工核验做全面参数对比 ——
先明确每项数据的来源(一律以原厂官网规格书为准),再逐项对标(Pin-to-Pin);
存活者按替代等级分级;候选过客户技术画像禁用清单;不足 2~3 颗就放宽参数或换角度重新撒网。
【T_select 派执行单】发 human 行动(workOrder="execution"),卡面 blocks 放**两张可编辑表**:
· 初筛清单:列 = 原型号 / 候选型号 / 品牌 / 生命周期;
· 替代对标表:列 = 候选型号 / 替代等级 / 参数差异项 / 差异风险 / 验证建议 / FAE签字。
两张都 editable:true;没有的值给空字符串 —— 空格子界面会自己标红「待填写」。
ask 一句话说清要他做什么;basis 摆上原型号、芯片类型、客户的特殊要求(裸卡基座拒发)。
S3 对比评估交付:收齐候选规格书,关键参数逐项对比写进替代对标表,标出差异项与替代等级,
写明差异带来的风险;按等级定评估深度 —— P2P 核确免改动,功能兼容/其他逐项评软件开发、
外围电路与 layout 改动。FAE 签字后交回发起方。
他提交后,回传值出现在你的轨迹里:表格那一份的键是 **table:0 / table:1**(块序号),
值是整张表的 JSON —— **第一行是列名**,其余每行一条,列序与你派出去时一致。
照它 write 进 \`quote_alternative.shortlist\` 与 \`quote_alternative.benchmark\`,
并把对标表条数记进 benchmark_total。**写完就 finish T_select,别再发第二张单** —— 他已经答过了。
全程随时可做:知识入库 —— 规格书入库、替代关系回填替代料库与 PN物料主库、踩到的坑记案例库。
【提交放行】对标表有行了 —— **先 start T_release**(人工激活:放行触达世界,必须按一下),
然后 submit,BD 验收。终局唯放行:你不能自己宣布完成。
§Z 结案:必做的事都办完、没有进行中的事,自动算结案(CMMN AutoComplete)。
无需替代的,确认初选可用即结案;需要替代的,推荐报告(已签字)交回发起方即结案 ——
每办一案,知识库厚一层。`;
【一路上要说话】负责人正看着这一单。每一拍带一句 say,说**业务的话**:
哪颗料要替代、你打算怎么办、FAE 回了什么结论、这一单往哪走。
一两句、口语,像同事扭头说一声。别报「写草稿 2 行」这种系统词 ——
他明天要拿这些话去跟客户讲。`;
/**
* 步骤剧本 —— 与 A1 同一套机制:`doneWhen` 为真,引擎自己收步、**不问模型**。
*
* 只有一条真活。放行那一项不给 `doneWhen` —— 它触达世界,必须人按一下(铁律③)。
*/
export const ALTERNATIVE_STEPS = [
{
item: 'T_select',
intent: '派一张执行单给 FAE,收回初筛清单与替代对标结论,并结算汇总数',
writes: ['quote_alternative.shortlist', 'quote_alternative.benchmark', 'quote_alternative.verdict'],
reads: [],
tools: [],
/*
* 对标表有行了才算办完。**不能写 `benchmark_total != null`** ——
* S-FEEL 里未绑定的名字 `!= null` 求值为 true,那一步开局即被收掉(A1 踩过,测里拦下的)。
*/
doneWhen: 'benchmark_total > 0',
guide: `**照这个骨架发,一个字段都别少**(prose 描述不管用,直接抄):
{"kind":"human","itemId":"T_select","workOrder":"execution","card":{
"title":"…","ask":"这颗料原型号停产,请挑替代并给对标结论",
"basis":[{"k":"…","v":"…"}],
"blocks":[
{"t":"table","title":"初筛清单","cols":["原型号","候选型号","品牌","生命周期"],"editable":true,
"rows":[["LM358DR","LM358BIDR","",""],["LM358DR","TLV9062IDR","",""]]},
{"t":"table","title":"替代对标表","cols":["候选型号","替代等级","参数差异项","差异风险","验证建议","FAE签字"],"editable":true,
"rows":[["","","","","",""]]}
]}}
**rows 必须有行**:一条料一行,不知道的值给空字符串(界面自己标红「待填写」)。
只给 cols 不给 rows,人看到的是一排表头加一片空白,一个格子都填不了(实测栽过)。
**blocks 少了整张卡就废了** —— 基座会用当下局面兜底成一句「需要你判断下一步」,
人拿到一张没有表的空卡,这一步等于没派(实测栽过)。
**候选是你提的,不是让 FAE 从零想** —— 初筛清单每个原型号先摆 **2~3 行候选**
(原型号 + 候选型号两列你填,你认得这些料;品牌与生命周期留空让他核)。
判据「2~3 颗分级候选经确认」机检的就是候选条数,只给一行**过不了门**。
替代对标表照候选逐行起,basis 摆上原型号、芯片类型、客户特殊要求(**裸卡基座拒发**)。
他提交后回传值进你的轨迹:表格的键是 **table:0 / table:1**,值是整张表的 JSON,
**第一行是列名**。照它 write 进 \`shortlist\` 与 \`benchmark\`,
**同一拍再写一行 \`quote_alternative.verdict\`**(验收机检读的就是它):
· candidate_total = 初筛清单条数(要 ≥2);
· benchmark_total = 对标表条数;
· undocumented_total = 对标表里「参数差异项」或「差异风险」还空着的条数(要 = 0);
· recommend_pn 主推候选、summary 一句人话结论。
这三个数写不对,submit 会被门挡回来 —— **它们不是台账装饰,是判据本身**。
**写完就 finish,别再发第二张单** —— 他已经答过了。
**每一行都要带 \`rfq_ref\`** —— 它是这一单的单标识(起单入参里就有,原样抄进去)。
台账主键第一列就是它:不写,这一行会和**别的单**同料号的那一行撞主键,把人家覆盖掉
(实测栽过:A4 的报价草案主键只有 pn,跑四轮只剩最后一轮的 3 行)。`,
},
{
item: 'T_release',
intent: '对标结论已齐,提交放行交 BD 验收',
writes: [],
reads: [],
tools: [],
// 放行触达世界 —— 引擎不自动收它(所以这里不给 doneWhen)
guide: `对标表有行了 —— **先 start T_release**(它标了人工激活),然后 submit,BD 验收。
终局唯放行:你不能自己宣布完成。`,
},
];
@@ -10,7 +10,6 @@ const BENCHMARK = 'quote_alternative.benchmark';
export default defineActivity({
name: 'quote-alternative',
paradigm: 'loop',
async run(base: ActivityBase): Promise<ActivityOutcome> {
const shortlist = await base.ledger.read(SHORTLIST);
+79 -9
View File
@@ -9,7 +9,7 @@ import type { ActivityManifest, DmnModel } from '@contracts';
import { ALTERNATIVE_SKILLS } from './skills.ts';
import { ALTERNATIVE_CASE } from './views/report.ts';
import { activityIdSchema } from '@contracts';
import { CMMN_XML, NARRATIVE } from './case-model.ts';
import { ALTERNATIVE_STEPS, CMMN_XML, NARRATIVE } from './case-model.ts';
const DOMAIN = 'quote_alternative';
const ACTIVITY_ID = activityIdSchema.parse('quote-alternative');
@@ -41,7 +41,7 @@ export const manifest: ActivityManifest = {
identity: {
activityId: ACTIVITY_ID,
name: 'A2 · 替代选型',
packageVersion: '0.1.3',
packageVersion: '2.0.0',
category: '统一报价',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-quote-alternative',
@@ -51,13 +51,24 @@ export const manifest: ActivityManifest = {
activityId: ACTIVITY_ID,
version: '0.1.0',
input: {
/*
* 入参对齐**上游真发的东西**(2026-07-31,MVP 第一轮跑出来的)。
*
* A1 的 `to-alternative` 口发的是 `altRequests`(一批替代评估请求,一条料一行),
* 不是单个 `originalPn` —— 而这里从前把 `originalPn` 写成必填,
* 于是流程走到 A2 就停在补全卡上:「缺 originalPn」。
* 上游发一批,下游要一个,这是**契约对不上**,不是数据缺失。
*
* 一单可能要替代好几颗料,`altRequests` 才是那件事的真形状。
*/
data: {
type: 'object',
required: ['originalPn'],
required: ['rfqRef', 'altRequests'],
properties: {
originalPn: { type: 'string' },
rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' },
altRequests: { type: 'array', title: '替代评估请求(原 PN + 替代要求)' },
originalPn: { type: 'string', title: '原型号(单条时的便捷入口)' },
chipType: { type: 'string' },
params: { type: 'object' },
scenario: { type: 'string' },
},
},
@@ -67,19 +78,43 @@ export const manifest: ActivityManifest = {
mandatory: [
{
name: 'benchmark',
payload: { type: 'object', required: ['rows'], properties: { rows: { type: 'array' } } },
payload: { type: 'object', required: ['rfqRef', 'rows'], properties: {
/*
* **下游那条边读的就是它**(流程包 E5:A2→A3 `needSourcing = true`)。
* 从前这个口只发对标表,而边上写着 needSourcing —— 那条边永远不成立,
* 选完型再要寻价的单子出了 A2 就没路可走(与 A1 当年的 G1 全流死路同一种错)。
*/
needSourcing: { type: 'boolean', title: '选定的替代料仍需寻价' }, rfqRef: { type: 'string' }, rows: { type: 'array' } } },
description: '替代对标表(已签字)',
},
],
selective: [],
},
requiredDeliverables: [{ key: 'benchmark-table', label: '替代对标表(FAE 签字)' }],
/*
* 必交付物**必须给 `ledger` 地址**(2026-07-31 修)。
* 不给的话预检退回 `scope[key]` 去找 —— key 是 `benchmark-table` 这样的短横线别名,
* 任务视图里永远没有这个键,于是「必交付物缺一不得提交放行」永远拒,
* 活办完了也放不了行,连拒五拍熔断(实测 A2 死于此)。
*/
requiredDeliverables: [
{ key: 'benchmark-table', label: '替代对标表(FAE 签字)', ledger: `${DOMAIN}.benchmark` },
],
/*
* 机检谓词读的是 `verdict` 那张**汇总表**的列(照 A1 的口径)。
*
* 上一版写的是 `candidatesConfirmed >= 2` 与 `undocumentedDiff = 0` ——
* 这两个名字**台账里根本没有**(既不是案卷项,也不是任何一张表的列)。
* S-FEEL 拿未绑定的名字比大小求值为假,于是这两条判据**永远达不了标**:
* Agent 把活办得再干净也过不了 submit 门,连拒五拍熔断 —— 实测 A2 就死在这儿。
* 判据的名字必须落在台账上,否则「机检」是一句空话(2026-07-31 销账)。
*/
acceptanceCriteria: [
{ key: 'candidates', label: '2~3 颗分级候选经确认', level: 'L2', predicate: 'candidatesConfirmed >= 2' },
{ key: 'diff-clear', label: '差异项与风险逐项写清', level: 'L2', predicate: 'undocumentedDiff = 0' },
{ key: 'candidates', label: '2~3 颗分级候选经确认', level: 'L2', predicate: 'candidate_total >= 2' },
{ key: 'diff-clear', label: '差异项与风险逐项写清', level: 'L2', predicate: 'undocumented_total = 0' },
{ key: 'source-traceable', label: '每项数据来源可溯(以原厂官网规格书为准)', level: 'L3' },
{ key: 'fae-sign', label: 'FAE 签字放行', level: 'L5' },
],
revisableAtRelease: [],
prohibitions: [
{ key: 'no-unclear-diff', statement: '差异没核清的不放行' },
{ key: 'no-banned', statement: '客户技术画像禁用清单里的型号不得作候选' },
@@ -105,8 +140,11 @@ export const manifest: ActivityManifest = {
{
domain: DOMAIN,
object: 'shortlist',
kind: 'record',
caseKey: 'rfq_ref',
label: '初筛清单',
fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'original_pn', type: 'text', primaryKey: true, masked: false, label: '原型号' },
{ name: 'candidate_pn', type: 'text', primaryKey: true, masked: false, label: '候选型号' },
{ name: 'brand', type: 'text', primaryKey: false, masked: false, label: '品牌' },
@@ -116,8 +154,11 @@ export const manifest: ActivityManifest = {
{
domain: DOMAIN,
object: 'benchmark',
kind: 'record',
caseKey: 'rfq_ref',
label: '替代对标表',
fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'original_pn', type: 'text', primaryKey: true, masked: false },
{ name: 'candidate_pn', type: 'text', primaryKey: true, masked: false },
{ name: 'grade', type: 'text', primaryKey: false, masked: false, label: '替代等级' },
@@ -127,6 +168,33 @@ export const manifest: ActivityManifest = {
{ name: 'signed_by', type: 'text', primaryKey: false, masked: false, label: 'FAE 签字' },
],
},
{
/*
* 选型结论 —— 一单一行的**汇总表**,验收机检谓词的入参就在这儿。
* 汇总列与逐行列**故意不同名**:任务视图把草稿行摊平到顶层是「后写的赢」,
* 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。
*/
domain: DOMAIN,
object: 'verdict',
kind: 'record',
caseKey: 'rfq_ref',
label: '选型结论',
fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'original_pn', type: 'text', primaryKey: false, masked: false, label: '原型号' },
{ name: 'candidate_total', type: 'integer', primaryKey: false, masked: false, label: '候选条数' },
{ name: 'benchmark_total', type: 'integer', primaryKey: false, masked: false, label: '对标条数' },
{
name: 'undocumented_total',
type: 'integer',
primaryKey: false,
masked: false,
label: '差异项或风险缺失条数',
},
{ name: 'recommend_pn', type: 'text', primaryKey: false, masked: false, label: '主推候选' },
{ name: 'summary', type: 'text', primaryKey: false, masked: false, label: '选型结论' },
],
},
],
listeners: [
{
@@ -161,6 +229,8 @@ export const manifest: ActivityManifest = {
cmmnXml: CMMN_XML,
narrative: NARRATIVE,
dmn: GRADE_DMN,
/* 步骤剧本 —— 与 A1 同一套:doneWhen 真了引擎自己收步,不问模型 */
steps: ALTERNATIVE_STEPS,
},
skills: ALTERNATIVE_SKILLS,
viewTemplates: [{ name: 'case-overview', html: ALTERNATIVE_CASE }],
@@ -1,26 +1,102 @@
/**
* A2 视图模版 —— 案例全景(AM 07:loop 按案例模型的阶段与里程碑组织汇报)。
* A2 替代选型的 HTML 汇报模版 —— **活动自己持有的资产**(§9.3),随包版本冻结。
*
* ── 这一屏给谁看、他此刻要判什么 ──────────────────────────────────
* 看的人是 FAE 与发起的 BD/PM。他此刻只判一件事:**这单还用不用我插手**。
* 于是版面按「先成果、后局面」排 —— 成果是他要签的东西,局面是他决定插不插手的依据:
* 一 初筛清单(撒了多少网)· 二 替代对标表(交付件本体)
* → 三 案例阶段 · 四 当下可为 · 五 目标缺口(AI 的办案局面)
* 指标带扫一眼就该有结论(候选够不够、差异核没核清),往下五节是「凭什么这么说」。
*
* ── 画面上的数从哪来(一个都不是编的)────────────────────────────
* · `mine:quote_alternative.*` 是**本单自己写下的草稿行** —— 别单放行过的记录不混进来;
* 列名逐字取自 `declarations.ledger`,编一个不存在的列名 = 屏上永远空白。
* · `case:*` / `enabledCount` / `gapCount` / `activeStage` 由语义引擎每拍算出,
* 是确定事实、零 LLM 即可成屏 —— 模型只决定「下一步做什么」,不负责画面。
* · 指标带上「初筛候选 / 已对标」两格指的是**数组本身**:填数时数组取长度
* (view-render 的 `displayValue` 约定),所以不必为「几颗」另立一个计数字段。
*
* ── 两条不撒谎的纪律 ──────────────────────────────────────────
* ① 右上角挂的是「在办」灯不是印章:这一屏是活的局面,它没盖过章,就别画成盖了章的样子;
* 紫(tone `case`)是**案例活动的标记色不是状态色** —— 红绿橙只留给「达标没达标」。
* ② 表空着就是「本单还没写下这一行」,不是渲染坏了 —— 每张表的说明里都写明空着意味着什么,
* 否则人会把空白读成故障。
*
* 容器是 `sandbox=""` iframe:无脚本、无外链,样式与图标由 `@sdk` 自带。
*/
import { kvCard, noteCard, stagesCard, viewShell } from '@sdk';
import { noteOnly, section, stagesOnly, statsBand, tableOnly, viewShell } from '@sdk';
export const ALTERNATIVE_CASE = viewShell({
title: '替代选型 · 案例全景',
kicker: 'A2 · 案例活动(loop)',
kicker: 'A2 · 替代选型 · 案例活动',
tone: 'case',
icon: 'search',
stamp: { kind: 'live', text: '案例在办 · 非定稿' },
sign: 'A2 替代选型 · 本页每拍按当下局面重画',
body:
kvCard('本单标的', [
['原型号', 'originalPn', 'mono'],
['芯片类型', 'chipType'],
['当前阶段', 'activeStage', 'tag'],
statsBand([
['初筛候选', 'mine:quote_alternative.shortlist'],
['已对标', 'mine:quote_alternative.benchmark'],
['当下可为', 'enabledCount'],
['目标缺口', 'gapCount'],
]) +
stagesCard('阶段(CMMN)', 'case:stages', '阶段推进到 <b>Enabled</b> 为止;触达世界的计划项须人工启动。') +
stagesCard('当下可为', 'case:enabled') +
stagesCard('目标缺口', 'case:gaps', '缺口决定下一步是调人还是立监听。') +
noteCard(
'跨人卡',
'选型常要向 BD 补客户口径 —— 那是<b>跨任务征答</b>:卡以系统消息送达对方的消息要素、<b>就地作答</b>,' +
'不占对方的任务清单,也不开放本任务的对话流。它是一次性的解答请求,不是责任转移。',
section('初筛清单(本单撒网所得)',
tableOnly('mine:quote_alternative.shortlist', [
['原型号', 'original_pn', 'mono'],
['候选型号', 'candidate_pn', 'mono'],
['品牌', 'brand'],
['生命周期', 'lifecycle'],
]) +
noteOnly(
'先撒网后核验,常要两三轮:检索与聚合平台拉来的候选先落这一张,<b>逐条人工核验之前都不算数</b>。' +
'生命周期一栏是初筛就要看的第一道险 —— EOL/停产的当场淘汰;' +
'不足 2~3 颗就放宽参数或换角度重新撒网。这张表空着,是本单还没撒网,不是没找着。',
),
{ text: '常要两三轮' },
) +
section('替代对标表(本单交付件)',
tableOnly('mine:quote_alternative.benchmark', [
['候选型号', 'candidate_pn', 'mono'],
['替代等级', 'grade'],
['参数差异项', 'param_diff'],
['差异风险', 'risk'],
['验证建议', 'verify_advice'],
['FAE 签字', 'signed_by'],
]) +
noteOnly(
'这是要签字交回发起方的东西:<b>差异没核清的不放行</b> —— 「差异风险」空着的行,' +
'就是此刻拦着放行的那几行。替代等级由判定表出(P2P / 功能兼容 / 其他),不是模型自己说了算;' +
'等级定评估深度:P2P 核确免改动,其余逐项评软件、外围电路与 layout。' +
'每项数据一律以<b>原厂官网规格书</b>为准。',
),
{ text: '差异没核清不放行' },
) +
section('案例阶段(CMMN 生命周期)',
stagesOnly('case:stages') +
noteOnly(
'阶段的生命周期态由语义引擎推进到 <b>Enabled</b> 为止;凡触达世界或产出关键交付的计划项' +
'必须人工启动 —— <b>Enabled ≠ Active</b>。选型是<b>一事一议</b>:撒几轮网、哪颗值得深评、' +
'要不要换角度重来,事前排不出谱,所以调度权交给 AI,系统只保证边框:预算、停滞熔断、放行归人。',
),
{ text: '推进到 Enabled 为止' },
) +
section('当下可为(AI 的合法选项)',
stagesOnly('case:enabled') +
noteOnly(
'AI 只能在这个集合里选下一步:<b>可以越谱,不可越权</b>。标「待启动」的是触达世界或' +
'产出关键交付的项 —— 要按一下再动。这一栏空着不是它偷懒,是当下确实无可为:' +
'要么在等人回话,要么在等外面的回音。',
),
{ text: '本拍的合法选项' },
) +
section('目标缺口(离交付还差什么)',
stagesOnly('case:gaps') +
noteOnly(
'缺口只有人知道 → 调人。选型常要向 BD 补客户口径,那是<b>跨任务征答</b>:卡以系统消息送达' +
'对方、<b>就地作答</b>,不占对方的任务清单,也不开放本任务的对话流 —— 一次性的解答请求,' +
'不是责任转移。缺口在世界侧 → 立监听(<b>时限必配</b>)。' +
'当下可为 ∩ 目标所需 = 空,就是<b>依赖断点</b>。这一栏空着,是必做的事都办完了。',
),
{ text: '空 = 必做的都办完了' },
),
});
+74 -40
View File
@@ -1,61 +1,95 @@
/**
* A7 情报归档的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
* A7 情报归档的案例模型 —— **一步落三张表**(2026-07-31 照 A1 的状态机模式重做)。
*
* 2026-07-29 由步序声明(flow)改写而来。归档这件事没有分支:登记结果 → 更新画像 →
* 统计落地率 → 验收。三件事互不依赖,同阶段并列(CMMN 里同阶段的项本就无隐含次序)。
* 归档这件事没有分支,也没有判断:一单报价有了结果,把它沉淀成下一单用得上的情报 ——
* 历史价、客户画像、落地率统计。上一版把这三样各建成一个计划项,
* 可它们**互不依赖、同一拍就能写完**,分开只换来两次多余的 finish。
*
* 两项:
* T_archive —— 一步落三张表(历史价 / 客户画像 / 落地率);
* T_release —— 封卷放行(人工激活)。
*
* 归档的价值全在**下一单**:这一单的拒绝原因,是下一单定价时的判据。
* 所以「不得空归档」是它唯一的硬判据 —— 三张表都写了才算办完。
*/
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
<case id="archive">
<caseFileModel>
<caseFileItem id="result" name="报价结果"/>
<caseFileItem id="history" name="历史价格情报"/>
<caseFileItem id="profile" name="客户画像"/>
<caseFileItem id="stats" name="报价落地率统计"/>
<caseFileItem id="quoteNo" name="报价单号(开案即有)"/>
<caseFileItem id="archived_total" name="已归档条数"/>
</caseFileModel>
<casePlanModel id="plan" name="情报归档" autoComplete="true">
<sentry id="s_result">
<onPart id="op_rs" caseFileItemRef="result"><standardEvent>create</standardEvent></onPart>
</sentry>
<sentry id="s_logged">
<onPart id="op_lg" caseFileItemRef="history"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_lg"><condition>reasonLogged = true</condition></ifPart>
<sentry id="s_archived">
<onPart id="op_ar" caseFileItemRef="archived_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_ar"><condition>archived_total &gt; 0</condition></ifPart>
</sentry>
<stage id="S1" name="回写沉淀" autoComplete="true">
<entryCriterion id="ec_s1" sentryRef="s_result"/>
<humanTask id="T_log" name="人工登记结果(拒绝原因 / 竞对价位)">
<itemControl><manualActivationRule id="m_lg"/></itemControl>
</humanTask>
<humanTask id="T_profile" name="人工更新客户画像">
<entryCriterion id="ec_pf" sentryRef="s_logged"/>
<itemControl><manualActivationRule id="m_pf"/></itemControl>
</humanTask>
<humanTask id="T_stats" name="人工统计落地率">
<itemControl><manualActivationRule id="m_st"/></itemControl>
</humanTask>
</stage>
<milestone id="M_archived" name="情报已回写"/>
<humanTask id="T_release" name="人工归档闭环验收放行">
<itemControl><manualActivationRule id="m_rl"/></itemControl>
<!-- 一步落三张表:它们互不依赖,同一拍写完 -->
<humanTask id="T_archive" name="情报归档:落历史价、客户画像、落地率统计">
<itemControl><repetitionRule id="r_ar"/></itemControl>
</humanTask>
<timerEventListener id="TL_half" name="时限过半">
<timerExpression>PT12H</timerExpression>
</timerEventListener>
<humanTask id="T_release" name="封卷放行">
<entryCriterion id="ec_rel" sentryRef="s_archived"/>
<itemControl><manualActivationRule id="m_rl"/></itemControl>
</humanTask>
</casePlanModel>
</case>
</definitions>`;
export const NARRATIVE = `§0 开案:一单报价有了结果(成交 / 拒绝 / 超时)即开案。
这件事不产生对外交付,它产生的是**下一单的本钱** —— 报价落地率、客户画像、竞对价位。
这一单的价值**全在下一单**:今天记下的拒绝原因,是下次定价时的判据;
今天记下的成交价,是下次的基准。所以归档不是收尾的仪式,是情报生产。
S1 回写沉淀:先登记结果 —— 拒绝的要写清原因与竞对价位,**「客户没回」不是原因**,
问不出来就写「未获反馈」,不许编一个听着合理的理由。
再按结果更新客户画像标签与等级(利润模型 P-01 的客户等级因子取的就是它),
并把这一单计入当月落地率统计(TypeA 命中率、D+3 回复率一并算)。
**一步就能办完** —— 三张表互不依赖,同一拍写完:
· \`market.price_history\`:这一单报了什么价、什么时候报的、结果如何、拒绝原因是什么;
· \`customer.profile\`:客户画像该更新什么(价格敏感度、决策周期、常用条款);
· \`shared.landing_stats\`:这个月的落地率、A 类命中、三日回复率。
§Z 结案:原因已回写、画像已更新、BD 签字放行,自动结案。
归档做得好不好,不在这一单看得出来 —— 在下一次报价能不能一眼看到「上次为什么丢」看得出来。`;
**不得空归档**:三张表都得有东西。「这一单没什么好记的」不是理由 ——
结果本身就是情报,拒绝的原因尤其是。写完把 archived_total 记上,再 finish。
【封卷放行】**先 start T_release**(人工激活),提交前用 judge 裁掉裁量判据
(criterion 照判据名原样写),然后 submit。终局唯放行:你不能自己宣布完成。
【一路上要说话】每一拍带一句 say,说**业务的话**:这一单最后怎么样、为什么、
下次遇到同类该注意什么。一两句、口语。别报「写草稿 N 行」这种系统词。`;
/**
* 步骤剧本 —— 与 A1 同一套。放行那一项不给 `doneWhen`(触达世界)。
*/
export const ARCHIVE_STEPS = [
{
item: 'T_archive',
intent: '一步落三张表:历史价、客户画像、落地率统计',
writes: ['market.price_history', 'customer.profile', 'shared.landing_stats', 'quote_archive.verdict'],
reads: [],
tools: [],
// 三张都写了才算归档过。不写 `!= null` —— 未绑定的名字那样求值为 true(A1 踩过)
doneWhen: 'archived_total > 0',
guide: `三张表互不依赖,**同一拍写完**:
· \`market.price_history\`:报了什么价、什么时候、结果如何、拒绝原因;
· \`customer.profile\`:客户画像该更新什么(价格敏感度、决策周期、常用条款);
· \`shared.landing_stats\`:本月落地率、A 类命中、三日回复率。
**不得空归档** —— 「没什么好记的」不是理由,结果本身就是情报,拒绝原因尤其是。
**同一拍再写一行 \`quote_archive.verdict\`**(验收机检读的就是它):
· archived_total = 已归档条数;
· reason_logged_total = 已回写拒绝原因/竞对价位的条数(要 > 0);
· profile_updated_total = 已更新客户画像标签的条数(要 > 0);
· summary 一句人话结论。写不对 submit 会被门挡回来。写完再 finish。
**每一行都要带 \`rfq_ref\`** —— 它是这一单的单标识(起单入参里就有,原样抄进去)。
台账主键第一列就是它:不写,这一行会和**别的单**同料号的那一行撞主键,把人家覆盖掉
(实测栽过:A4 的报价草案主键只有 pn,跑四轮只剩最后一轮的 3 行)。`,
},
{
item: 'T_release',
intent: '情报已沉淀,封卷放行',
writes: [],
reads: [],
tools: [],
guide: `**先 start T_release**(人工激活),提交前用 judge 裁掉裁量判据,然后 submit。
终局唯放行:你不能自己宣布完成。`,
},
];
-1
View File
@@ -7,7 +7,6 @@ import type { ActivityBase, ActivityOutcome } from '@sdk';
export default defineActivity({
name: 'quote-archive',
paradigm: 'flow',
async run(base: ActivityBase): Promise<ActivityOutcome> {
const history = await base.ledger.read('shared.history_price');
+52 -11
View File
@@ -6,17 +6,19 @@
*/
import type { ActivityManifest } from '@contracts';
import { ARCHIVE_SKILLS } from './skills.ts';
import { CMMN_XML, NARRATIVE } from './case-model.ts';
import { CMMN_XML, NARRATIVE, ARCHIVE_STEPS } from './case-model.ts';
import { activityIdSchema } from '@contracts';
import { ARCHIVE_LOGGED, ARCHIVE_RELEASE } from './views/report.ts';
const ACTIVITY_ID = activityIdSchema.parse('quote-archive');
/** 本活动自己的数据域 —— 三张沉淀表落 `shared`,只有归档结论落这儿 */
const DOMAIN = 'quote_archive';
export const manifest: ActivityManifest = {
identity: {
activityId: ACTIVITY_ID,
name: 'A7 · 情报归档',
packageVersion: '0.1.6',
packageVersion: '2.1.0',
category: '统一报价',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-quote-archive',
@@ -28,9 +30,10 @@ export const manifest: ActivityManifest = {
input: {
data: {
type: 'object',
required: ['customerCode', 'quoteNo'],
required: ['rfqRef', 'customerCode', 'quoteNo'],
properties: {
/** 归档写 (pn, quoted_at) 这一行,两个主键都由调用方给定 */
rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' },
pn: { type: 'string' },
quotedAt: { type: 'string' },
customerCode: { type: 'string' },
@@ -51,15 +54,28 @@ export const manifest: ActivityManifest = {
],
selective: [],
},
/*
* 必交付物**必须给 `ledger` 地址**(2026-07-31 修)。
* 不给的话预检退回 `scope[key]` 去找 —— key 是 `benchmark-table` 这样的短横线别名,
* 任务视图里永远没有这个键,于是「必交付物缺一不得提交放行」永远拒,
* 活办完了也放不了行,连拒五拍熔断(实测 A2 死于此)。
*/
requiredDeliverables: [
{ key: 'history-archive', label: '历史价格情报(归档)' },
{ key: 'landing-stats', label: '报价落地率统计' },
{ key: 'history-archive', label: '历史价格情报(归档)', ledger: 'market.price_history' },
{ key: 'landing-stats', label: '报价落地率统计', ledger: 'shared.landing_stats' },
],
/*
* 机检谓词读的是 `verdict` 那张**汇总表**的列(照 A1 的口径)。
* 上一版的名字(reasonLogged / profileUpdated)**台账里根本没有** —— S-FEEL 拿未绑定的名字
* 比大小求值为假,判据永远达不了标,submit 门永远拒,连拒五拍熔断。
* 判据的名字必须落在台账上,否则「机检」是一句空话(2026-07-31 销账)。
*/
acceptanceCriteria: [
{ key: 'reason-logged', label: '拒绝原因 / 竞对价位已回写', level: 'L1', predicate: 'reasonLogged = true' },
{ key: 'profile-updated', label: '客户画像标签已更新', level: 'L2', predicate: 'profileUpdated = true' },
{ key: 'reason-logged', label: '拒绝原因 / 竞对价位已回写', level: 'L1', predicate: 'reason_logged_total > 0' },
{ key: 'profile-updated', label: '客户画像标签已更新', level: 'L2', predicate: 'profile_updated_total > 0' },
{ key: 'bd-release', label: 'BD 放行', level: 'L5' },
],
revisableAtRelease: [],
prohibitions: [{ key: 'no-blank-archive', statement: '不得空归档 —— 每一单不成交的报价也是情报资产' }],
authorization: { discretion: [], preauthorizedActions: [], humanCallBudget: 2 },
resources: {
@@ -77,8 +93,9 @@ export const manifest: ActivityManifest = {
ledger: [
{
// 共享域:跨活动的沉淀事实(AM 11.3 暗耦合禁令的两条合法路径之一)
domain: 'shared',
object: 'history_price',
domain: 'market',
object: 'price_history',
kind: 'master',
label: '历史价格情报',
fields: [
{ name: 'pn', type: 'text', primaryKey: true, masked: false },
@@ -88,8 +105,9 @@ export const manifest: ActivityManifest = {
],
},
{
domain: 'shared',
object: 'customer',
domain: 'customer',
object: 'profile',
kind: 'master',
label: '客户资料库',
fields: [
{ name: 'customer_code', type: 'text', primaryKey: true, masked: false, label: '客户码' },
@@ -100,6 +118,7 @@ export const manifest: ActivityManifest = {
{
domain: 'shared',
object: 'landing_stats',
kind: 'derived',
label: '报价落地率统计',
fields: [
{ name: 'period', type: 'text', primaryKey: true, masked: false, label: '月份' },
@@ -108,6 +127,26 @@ export const manifest: ActivityManifest = {
{ name: 'reply_rate_d3', type: 'number', primaryKey: false, masked: false, label: 'D+3回复率' },
],
},
{
/*
* 归档结论 —— 一单一行的**汇总表**,验收机检谓词的入参就在这儿。
* 汇总列与逐行列**故意不同名**:任务视图把草稿行摊平到顶层是「后写的赢」,
* 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。
*/
domain: DOMAIN,
object: 'verdict',
kind: 'record',
caseKey: 'rfq_ref',
label: '归档结论',
fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'quote_no', type: 'text', primaryKey: false, masked: false, label: '报价单号' },
{ name: 'archived_total', type: 'integer', primaryKey: false, masked: false, label: '已归档条数' },
{ name: 'reason_logged_total', type: 'integer', primaryKey: false, masked: false, label: '已回写原因条数' },
{ name: 'profile_updated_total', type: 'integer', primaryKey: false, masked: false, label: '已更新画像条数' },
{ name: 'summary', type: 'text', primaryKey: false, masked: false, label: '归档结论' },
],
},
],
listeners: [],
// 7 天闭环(MVP示例 §1.7)
@@ -120,6 +159,8 @@ export const manifest: ActivityManifest = {
cmmnXml: CMMN_XML,
narrative: NARRATIVE,
dmn: { drd: [], tables: [] },
/* 步骤剧本 —— 与 A1 同一套:doneWhen 真了引擎自己收步,不问模型 */
steps: ARCHIVE_STEPS,
},
skills: ARCHIVE_SKILLS,
viewTemplates: [
+136 -25
View File
@@ -1,43 +1,154 @@
/**
* A7 视图模版 —— 归档两屏:结果登记 / 闭环待放行(AM 07)。
* 归档是这条流的收尾:它写回的是**下一单的判据**,所以要说清「这次学到了什么」。
* A7 情报归档的 HTML 报告模版 —— **活动自己持有的资产**(§10.3),随包版本冻结。
*
* 归档最容易被当成走过场:单子已经丢了,再填一遍原因像是给失败写检讨。
* 所以这两屏从头到尾只讲一件事 —— **这一单留下了什么,下一单谁会用它**。
* 三张共享域表各自对着下一单的一个判据:历史价格情报喂三分类 P-03 的「距上次报价」、
* 客户资料库喂利润模型 P-01 的客户等级因子、落地率统计喂驾驶舱。人在这一屏上要判断的
* 不是「填没填」,是**填的这几个字下一单的机器读不读得懂** —— 写成散文只能被人读,
* 写成字段才进得了决策表。
*
* 两个局面:
* ① 结果登记 —— 拒绝原因与竞对价位在不在、算不算数;
* ② 闭环待放行 —— 三样沉淀齐不齐;签完这一笔整条流走到终点。
*
* 这里**没有「案例进程」一节**:归档只有 S1 一个阶段,摆出来是一行凑数的进度条,
* 而版面上每多一节,真正要看的三张表就少一分注意力。
*
* 容器是 `sandbox=""` iframe:无脚本、无外链、样式自带;数据由基座推送时填
* (`data-slot` 取值、`data-rows` 逐行展开)。状态章一律用「在办」灯 ——
* 模版是随包冻结的静态字符串,盖不了一个还没发生的章;`verdict` 的橙档专指
* 「还没发生,等你按」,绿只留给已达标的机检结论。
*/
import { kvCard, noteCard, tableCard, viewShell } from '@sdk';
import { noteOnly, section, statsBand, tableOnly, verdict, viewShell } from '@sdk';
/**
* 静态阶梯 —— **判据的写法与沉淀的去向不是数据**,它们随包冻结,不走 `data-rows`。
* 沿用外壳已有的 `.steps` 版式,与引擎推的阶段列表同一副面孔。
*/
function ladder(rows: ReadonlyArray<readonly [string, string, string]>): string {
return `<ul class="steps">${rows
.map(
([no, term, gloss]) =>
`<li><span class="n">${no}</span><span><span class="t">${term}</span> <span class="d">${gloss}</span></span></li>`,
)
.join('')}</ul>`;
}
/** 局面一:S1 回写 —— 人此刻要判的是「这条原因,下一单读不读得懂」 */
export const ARCHIVE_LOGGED = viewShell({
title: '结果登记',
kicker: 'A7 · 情报归档',
kicker: 'A7 · 情报归档 · S1 回写',
tone: 'archive',
icon: 'database',
stamp: { kind: 'live', text: '回写中' },
sign: 'A7 情报归档 · 结果登记(共享域)',
body:
kvCard('本单结果', [
['型号 PN', 'pn', 'mono'],
['报价日期', 'quotedAt', 'mono'],
['拒绝原因', 'lostReason'],
statsBand([
['报价日期', 'quoted_at'],
['竞对价位', 'competitor_price'],
['客户等级', 'grade'],
]) +
noteCard('为什么要登记', '拒绝原因与竞对价位是<b>下一单的判据</b>:它们回写历史价格情报,供 P-03 的「距上次报价 / 行情稳定」与 P-01 的客户等级因子取用。这一单的败仗,是下一单的地形图。'),
section('本单登记的结果',
tableOnly('mine:shared.history_price', [
['型号 PN', 'pn', 'mono'],
['报价日期', 'quoted_at', 'mono'],
['拒绝原因', 'lost_reason'],
['竞对价位', 'competitor_price'],
]) +
noteOnly(
'<b>「客户没回」不是原因。</b>问不出来就写「未获反馈」—— 不许编一个听着合理的理由:' +
'编出来的原因会一路流进下一单的判据里,而那时已经没人记得它是编的。',
),
{ text: '只列本单写下的行' },
) +
section('什么样的原因才算数',
ladder([
['1', '竞对报价多少、差几个百分点', '要数,不要形容词'],
['2', '输在价格、交期,还是资质 / 认证', '把败因归到一个维度上'],
['3', '客户是否明说了可接受区间', '下一单的起点就藏在这句话里'],
]) +
noteOnly(
'<b>「价格高」不是原因,是结论。</b>判据要能被下一单<b>机器读取</b>:' +
'P-03 用「距上次报价 / 行情稳定」,P-01 用客户等级。' +
'写成一段散文,它就只能被人读;写成字段,它才进得了决策表。',
),
{ text: '写成字段' },
) +
section('这一单往哪儿沉 —— 三张共享域表',
ladder([
['1', '历史价格情报 · shared.history_price', '喂给下一单的三分类 P-03'],
['2', '客户资料库 · shared.customer', '喂给下一单的利润模型 P-01'],
['3', '报价落地率统计 · shared.landing_stats', '喂给驾驶舱的落地率与 D+3 回复率'],
]),
{ text: '跨活动的沉淀事实' },
) +
section('这一单的底线',
verdict('不得空归档 —— 每一单不成交的报价也是情报资产', 'wait', '原因未回写,预检当场退回') +
noteOnly(
'归档做得好不好,<b>不在这一单看得出来</b> —— 在下一次报价能不能一眼看到「上次为什么丢」看得出来。',
),
),
});
/** 局面二:闭环待放行 —— 签完这一笔,整条工作流走到终点 */
export const ARCHIVE_RELEASE = viewShell({
title: '情报归档 · 闭环待放行',
kicker: 'A7 · 成果预览',
tone: 'ok',
kicker: 'A7 · 情报归档 · 成果待签',
tone: 'case',
icon: 'archive',
stamp: { kind: 'live', text: '待签字封卷' },
sign: 'A7 情报归档 · 闭环成果(待放行)',
body:
tableCard('历史价格情报回写', 'mine:shared.history_price', [
['型号', 'pn', 'mono'],
['报价日期', 'quoted_at', 'mono'],
['拒绝原因', 'lost_reason'],
]) +
kvCard('画像与落地率', [
['客户编码', 'customer_code', 'mono'],
['画像标签', 'profile_tags'],
['统计周期', 'period', 'mono'],
statsBand([
['统计周期', 'period'],
['落地率', 'landing_rate'],
['TypeA 命中', 'type_a_hit'],
['D+3 回复率', 'reply_rate_d3'],
]) +
noteCard(
'闭环的含义',
'放行后这条工作流走到<b>终点节点</b>,状态收敛为「已完成」。' +
'终态封卷触发<b>蒸馏钩子</b>:从任务记忆归纳出活动记忆的候选条目(带证据链),' +
'经回放与人放行才成为下一版业务模型的一部分 —— <b>记忆不载权力</b>。',
section('历史价格情报回写',
tableOnly('mine:shared.history_price', [
['型号 PN', 'pn', 'mono'],
['报价日期', 'quoted_at', 'mono'],
['拒绝原因', 'lost_reason'],
['竞对价位', 'competitor_price'],
]),
{ text: '下一单 P-03 取它' },
) +
section('客户画像更新',
tableOnly('mine:shared.customer', [
['客户码', 'customer_code', 'mono'],
['客户等级', 'grade'],
['画像标签', 'profile_tags'],
]),
{ text: '下一单 P-01 取它' },
) +
section('报价落地率统计',
tableOnly('mine:shared.landing_stats', [
['月份', 'period', 'mono'],
['落地率', 'landing_rate'],
['TypeA 命中', 'type_a_hit'],
['D+3 回复率', 'reply_rate_d3'],
]) +
noteOnly(
'三张表都写在<b>共享域</b>:它们是跨活动的沉淀事实,不属于这一单 ——' +
'这也是活动之间除了流程边以外,唯一一条合法的信息通路。',
),
{ text: '驾驶舱取它' },
) +
section('放行前要过的三道判据',
ladder([
['1', '拒绝原因 / 竞对价位已回写', 'L1 机检 · reasonLogged = true'],
['2', '客户画像标签已更新', 'L2 机检 · profileUpdated = true'],
['3', 'BD 放行', 'L5 人签 · 签了才算数'],
]),
{ text: '两机检 · 一人签' },
) +
section('封卷之后会发生什么',
verdict('放行即闭环:这条工作流就此收敛为「已完成」', 'wait', '终态封卷触发蒸馏钩子') +
noteOnly(
'蒸馏从任务记忆归纳出<b>活动记忆</b>的候选条目(带证据链),经回放与人放行才成为' +
'下一版业务模型的一部分 —— <b>记忆不载权力</b>。这一单丢了,但它把下一单的地形描出来了。',
),
),
});
@@ -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 受理归一 → 交下游'),
});
+94 -56
View File
@@ -1,77 +1,115 @@
/**
* A6 报价议价的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
* A6 报价议价的案例模型 —— **发出去是触达世界的一下,等回音是立监听**
* (2026-07-31 照 A1 的状态机模式重做)。
*
* 2026-07-29 由步序声明(flow)改写而来。发出报价是**触达外界**的一下,
* 恒需人工激活(AM 4.1 原理一);客户回音是外部事件,靠 userEventListener 等,
* 不靠轮询。三个去向(成交 / 有异议 / 拒绝)由验收放行时的选择组给出。
* 这个活动的要害不是步数,是**两处不可省的边界**:
* · 发正式报价单 = 触达外界 → 必须人工激活(铁律③ / AM 4.1);
* · 客户什么时候回是**外界的事** → 立监听等它,不在这一单里轮询、也不假装等到了。
*
* 三项:
* T_compose —— 把草案落成正式报价单(条款齐全),写进台账;
* T_send —— 发出并等回音:人工激活 → 登记发信意向 + 立监听;
* T_release —— 放行(人工激活),三个去向由输出选择组给出。
*/
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
<case id="negotiate">
<caseFileModel>
<caseFileItem id="draft" name="报价草案"/>
<caseFileItem id="quote" name="正式报价单"/>
<caseFileItem id="reply" name="客户回音"/>
<caseFileItem id="quote_no" name="报价单号(开案即有)"/>
<caseFileItem id="quote_total" name="报价行条数"/>
</caseFileModel>
<casePlanModel id="plan" name="报价议价" autoComplete="true">
<sentry id="s_draft">
<onPart id="op_df" caseFileItemRef="draft"><standardEvent>create</standardEvent></onPart>
</sentry>
<sentry id="s_quote_ready">
<onPart id="op_qr" caseFileItemRef="quote"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_qr"><condition>missingQuoteFields = 0</condition></ifPart>
</sentry>
<sentry id="s_sent">
<onPart id="op_st" caseFileItemRef="quote"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_st"><condition>sent = true</condition></ifPart>
<sentry id="s_composed">
<onPart id="op_cp" caseFileItemRef="quote_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_cp"><condition>quote_total &gt; 0</condition></ifPart>
</sentry>
<stage id="S1" name="成单发出" autoComplete="true">
<entryCriterion id="ec_s1" sentryRef="s_draft"/>
<humanTask id="T_compose" name="人工生成正式报价单">
<itemControl><manualActivationRule id="m_cp"/><repetitionRule id="r_cp"/></itemControl>
</humanTask>
<humanTask id="T_send" name="人工发出报价单">
<entryCriterion id="ec_send" sentryRef="s_quote_ready"/>
<itemControl><manualActivationRule id="m_sd"/></itemControl>
</humanTask>
</stage>
<milestone id="M_sent" name="报价已发出"/>
<stage id="S2" name="跟进议价" autoComplete="true">
<entryCriterion id="ec_s2" sentryRef="s_sent"/>
<humanTask id="T_followup" name="人工按 D+3/D+7 跟进">
<itemControl><manualActivationRule id="m_fu"/><repetitionRule id="r_fu"/></itemControl>
</humanTask>
<planningTable id="pt_s2">
<humanTask id="T_revise" name="人工议价调整"/>
</planningTable>
</stage>
<humanTask id="T_release" name="人工议价结果验收放行">
<itemControl><manualActivationRule id="m_rl"/></itemControl>
<!-- 落正式报价单:条款齐全才叫一份能发出去的件 -->
<humanTask id="T_compose" name="落正式报价单:条款齐全,逐条落台账">
<itemControl><repetitionRule id="r_cp"/></itemControl>
</humanTask>
<timerEventListener id="TL_d3" name="D+3 跟进">
<timerExpression>P3D</timerExpression>
</timerEventListener>
<timerEventListener id="TL_d7" name="D+7 跟进">
<timerExpression>P7D</timerExpression>
</timerEventListener>
<userEventListener id="UL_reply" name="客户回音"/>
<!-- 发出并等回音:**触达外界**,必须人工激活 -->
<humanTask id="T_send" name="发出报价并等客户回音">
<entryCriterion id="ec_sd" sentryRef="s_composed"/>
<itemControl><manualActivationRule id="m_sd"/><repetitionRule id="r_sd"/></itemControl>
</humanTask>
<humanTask id="T_release" name="议价结果验收放行">
<entryCriterion id="ec_rel" sentryRef="s_composed"/>
<itemControl><manualActivationRule id="m_rl"/></itemControl>
</humanTask>
</casePlanModel>
</case>
</definitions>`;
export const NARRATIVE = `§0 开案:拿到报价草案即开案。成品是**一份发出去的正式报价单**,外加一个明确的结果。
export const NARRATIVE = `§0 开案:拿到报价草案即开案。成品是**一份发出去的正式报价单**,外加一个明确的结果
(成交 / 有异议 / 拒绝)—— 那三个去向由放行时的选择组定。
S1 成单发出:把草案写成正式报价单(中英双语 PDF)—— 单价、交期、SPQ、MOQ、贸易条款、
有效期六样缺一不可,有效期按 P-04 注明。**发出这一下必须人工按**:它触达客户,
系统不替人做这个决定。写类动作先登记意向,验收放行的那一刻才真发。
这一单有两处不能含糊:
· **发出去是触达世界的一下** —— 那一项标了人工激活,人不按你发不出去;
· **客户什么时候回是外界的事** —— 立监听等它,别在这一单里轮询,更别假装等到了。
S2 跟进议价:没有跟进的报价等于没报 —— D+3、D+7 两记提醒自动到点,人去问。
客户提异议就议价调整(裁量项:改价、改交期、换料都可能),改完是新一版报价单,重新走发出。
【T_compose 落正式报价单】把草案落成能发出去的件:单价 / 交期 / SPQ / MOQ / 贸易条款 /
有效期,逐条写进 \`quote_negotiate.formal_quote\`,并把 quote_total(条数)写上。
条款缺一样都不算一份能发的报价单 —— 缺了就问,别自己填一个"看起来合理"的。写完 finish。
§Z 结案:验收时**恰选一口**给出去向 —— 成交 / 有异议 / 拒绝或超时。
选哪一口就是这一单的结论,下游网关读的就是它;不选口不许放行,含糊的结果比没结果更坏。`;
【T_send 发出并等回音】**先 start T_send**(人工激活:发出去触达客户)。
然后用 \`mail.send\` 登记发信意向(写类工具:过程中只登记,放行那一刻才真发),
接着 listen 一个 data 事件等客户回音,**时限必配**(比如 P7D)——
不配时限就是个幽灵等待,这一单会永远挂着。
回音到了再看:成交 / 有异议 / 拒绝,分别对应放行时的三个出口。
【提交放行】**先 start T_release**(人工激活),提交前用 judge 裁掉裁量判据
(criterion 照判据名原样写),然后 submit。终局唯放行:你不能自己宣布完成。
【一路上要说话】每一拍带一句 say,说**业务的话**:报了什么价、什么时候发的、客户回了什么。
一两句、口语。别报「写草稿 N 行」这种系统词。`;
/**
* 步骤剧本 —— 与 A1 同一套。
* `T_send` 与 `T_release` 都不给 `doneWhen`:一个触达客户、一个触达世界,都得人按一下。
*/
export const NEGOTIATE_STEPS = [
{
item: 'T_compose',
intent: '把草案落成条款齐全的正式报价单,并结算条数',
writes: ['quote_negotiate.formal_quote', 'quote_negotiate.verdict'],
reads: [],
tools: [],
doneWhen: 'quote_total > 0',
guide: `逐条写进 \`quote_negotiate.formal_quote\`:单价 / 交期 / SPQ / MOQ / 贸易条款 / 有效期,
并把 quote_total 写上。条款缺一样都不算一份能发的报价单 ——
缺了就问,**别自己填一个「看起来合理」的值**。
**同一拍再写一行 \`quote_negotiate.verdict\`**(验收机检读的就是它):
· quote_total = 报价行条数;missing_field_total = 六项条款还缺的条数(要 = 0);
· validity_days = 报价有效期天数(要 > 0);
· followup_total = 已建跟进条数(**要 ≥2** —— D+3 与 D+7 两条,没有跟进的报价等于没报);
· summary 一句人话结论。写不对 submit 会被门挡回来。写完再 finish。
**每一行都要带 \`rfq_ref\`** —— 它是这一单的单标识(起单入参里就有,原样抄进去)。
台账主键第一列就是它:不写,这一行会和**别的单**同料号的那一行撞主键,把人家覆盖掉
(实测栽过:A4 的报价草案主键只有 pn,跑四轮只剩最后一轮的 3 行)。`,
},
{
item: 'T_send',
intent: '发出报价并立监听等客户回音',
writes: [],
reads: [],
tools: ['mail.send'],
// 触达客户 —— 人工激活,不给 doneWhen
guide: `**先 start T_send**(人工激活:发出去触达客户)。
用 \`mail.send\` 登记发信意向(写类工具:只登记,放行那一刻才真发),
再 listen 一个 data 事件等回音,**时限必配**(如 P7D)—— 不配就是幽灵等待,这一单会永远挂着。`,
},
{
item: 'T_release',
intent: '议价有结果,提交放行交 BD 验收',
writes: [],
reads: [],
tools: [],
guide: `**先 start T_release**(人工激活),提交前用 judge 裁掉裁量判据,然后 submit。
三个去向(成交 / 有异议 / 拒绝)由放行时的选择组定。
**台账里要写 outcome**(won / revise / lost)—— 下游三条边读的就是它,不写整条流走不下去。`,
},
];
-1
View File
@@ -11,7 +11,6 @@ const QUOTE = 'quote_negotiate.formal_quote';
export default defineActivity({
name: 'quote-negotiate',
paradigm: 'flow',
async run(base: ActivityBase): Promise<ActivityOutcome> {
const lines = await base.ledger.read(QUOTE);
+87 -15
View File
@@ -7,7 +7,7 @@
*/
import type { ActivityManifest } from '@contracts';
import { NEGOTIATE_SKILLS } from './skills.ts';
import { CMMN_XML, NARRATIVE } from './case-model.ts';
import { CMMN_XML, NARRATIVE, NEGOTIATE_STEPS } from './case-model.ts';
import { activityIdSchema } from '@contracts';
import { QUOTE_COMPOSED, QUOTE_RELEASE } from './views/report.ts';
@@ -18,7 +18,7 @@ export const manifest: ActivityManifest = {
identity: {
activityId: ACTIVITY_ID,
name: 'A6 · 报价议价',
packageVersion: '0.1.5',
packageVersion: '2.1.0',
category: '统一报价',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-quote-negotiate',
@@ -30,8 +30,8 @@ export const manifest: ActivityManifest = {
input: {
data: {
type: 'object',
required: ['customerCode', 'items'],
properties: { customerCode: { type: 'string' }, items: { type: 'array' } },
required: ['rfqRef', 'customerCode', 'items'],
properties: { rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' }, customerCode: { type: 'string' }, items: { type: 'array' } },
},
messageAllowed: true,
},
@@ -42,23 +42,67 @@ export const manifest: ActivityManifest = {
{
group: 'negotiation',
ports: [
{ name: 'won', payload: { type: 'object' }, description: '成交 → 转订单履约(ORM)' },
{ name: 'revise', payload: { type: 'object' }, description: '有异议 → 返回调整 A4' },
{ name: 'lost', payload: { type: 'object' }, description: '拒绝/超时 → 转 A7 情报归档' },
/*
* 载荷里**必须带 `outcome`** —— 流程包 E10/E11/E12 三条出边读的就是它
* (`outcome = "won"/"revise"/"lost"`)。从前三个口的载荷都是空对象 `{}`,
* 于是三条边一条也不成立:整条流走到 A6 就没路可走,而口是"发射"过的。
* 这与 A1 当年 G1 全流死路是同一种错 —— **边读什么,口就得发什么**。
*/
{
name: 'won',
payload: {
type: 'object',
required: ['outcome'],
properties: { outcome: { type: 'string', title: '议价结果(固定 won)' } },
},
description: '成交 → 转订单履约(ORM)',
},
{
name: 'revise',
payload: {
type: 'object',
required: ['outcome'],
properties: { outcome: { type: 'string', title: '议价结果(固定 revise)' } },
},
description: '有异议 → 返回调整 A4',
},
{
name: 'lost',
payload: {
type: 'object',
required: ['outcome'],
properties: { outcome: { type: 'string', title: '议价结果(固定 lost)' } },
},
description: '拒绝/超时 → 转 A7 情报归档',
},
],
},
],
},
/*
* 必交付物**必须给 `ledger` 地址**(2026-07-31 修)。
* 不给的话预检退回 `scope[key]` 去找 —— key 是 `benchmark-table` 这样的短横线别名,
* 任务视图里永远没有这个键,于是「必交付物缺一不得提交放行」永远拒,
* 活办完了也放不了行,连拒五拍熔断(实测 A2 死于此)。
*/
requiredDeliverables: [
{ key: 'formal-quote', label: '正式报价单(PDF · 中英双语)' },
{ key: 'followup', label: 'D+3/D+7 跟进提醒' },
{ key: 'formal-quote', label: '正式报价单(PDF · 中英双语)', ledger: `${DOMAIN}.formal_quote` },
// 跟进不单独立表 —— 建了几条记在议价结论的 followup_total 上
{ key: 'followup', label: 'D+3/D+7 跟进提醒', ledger: `${DOMAIN}.verdict` },
],
/*
* 机检谓词读的是 `verdict` 那张**汇总表**的列(照 A1 的口径)。
* 上一版的名字(missingQuoteFields / validityDays / followupSet)**台账里根本没有** —— S-FEEL 拿未绑定的名字
* 比大小求值为假,判据永远达不了标,submit 门永远拒,连拒五拍熔断。
* 判据的名字必须落在台账上,否则「机检」是一句空话(2026-07-31 销账)。
*/
acceptanceCriteria: [
{ key: 'quote-complete', label: '单价/交期/SPQ/MOQ/贸易条款/有效期齐备', level: 'L1', predicate: 'missingQuoteFields = 0' },
{ key: 'validity', label: '按报价有效期 P-04 注明有效期', level: 'L2', predicate: 'validityDays > 0' },
{ key: 'followup-set', label: '已建 D+3/D+7 跟进 —— 没有跟进的报价等于没报', level: 'L2', predicate: 'followupSet = true' },
{ key: 'quote-complete', label: '单价/交期/SPQ/MOQ/贸易条款/有效期齐备', level: 'L1', predicate: 'missing_field_total = 0' },
{ key: 'validity', label: '按报价有效期 P-04 注明有效期', level: 'L2', predicate: 'validity_days > 0' },
{ key: 'followup-set', label: '已建 D+3/D+7 跟进 —— 没有跟进的报价等于没报', level: 'L2', predicate: 'followup_total >= 2' },
{ key: 'bd-release', label: 'BD 放行', level: 'L5' },
],
revisableAtRelease: [],
prohibitions: [
{ key: 'no-break-line', statement: '客户议价超出授权范围的,回 A5 重批,不私自破线' },
{ key: 'no-send-before-release', statement: '报价单在放行前不得发出 —— 写类动作集中在放行时落地' },
@@ -85,9 +129,12 @@ export const manifest: ActivityManifest = {
{
domain: DOMAIN,
object: 'formal_quote',
kind: 'record',
caseKey: 'rfq_ref',
label: '正式报价单',
fields: [
{ name: 'quote_no', type: 'text', primaryKey: true, masked: false, label: '报价单号' },
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'quote_no', type: 'text', primaryKey: false, masked: false, label: '报价单号' },
{ name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' },
{ name: 'unit_price', type: 'number', primaryKey: false, masked: false, label: '单价CNY' },
{ name: 'lead_time', type: 'text', primaryKey: false, masked: false, label: '交期' },
@@ -98,8 +145,9 @@ export const manifest: ActivityManifest = {
],
},
{
domain: 'shared',
object: 'history_price',
domain: 'market',
object: 'price_history',
kind: 'master',
label: '历史价格情报(共享域)',
fields: [
{ name: 'pn', type: 'text', primaryKey: true, masked: false },
@@ -108,6 +156,28 @@ export const manifest: ActivityManifest = {
{ name: 'lead_time', type: 'text', primaryKey: false, masked: false },
],
},
{
/*
* 议价结论 —— 一单一行的**汇总表**,验收机检谓词的入参就在这儿。
* 汇总列与逐行列**故意不同名**:任务视图把草稿行摊平到顶层是「后写的赢」,
* 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。
*/
domain: DOMAIN,
object: 'verdict',
kind: 'record',
caseKey: 'rfq_ref',
label: '议价结论',
fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'quote_no', type: 'text', primaryKey: false, masked: false, label: '报价单号' },
{ name: 'quote_total', type: 'integer', primaryKey: false, masked: false, label: '报价行条数' },
{ name: 'missing_field_total', type: 'integer', primaryKey: false, masked: false, label: '条款缺失条数' },
{ name: 'validity_days', type: 'integer', primaryKey: false, masked: false, label: '报价有效期天数' },
{ name: 'followup_total', type: 'integer', primaryKey: false, masked: false, label: '已建跟进条数' },
{ name: 'outcome', type: 'text', primaryKey: false, masked: false, label: '议价结果(won/revise/lost)' },
{ name: 'summary', type: 'text', primaryKey: false, masked: false, label: '议价结论' },
],
},
],
listeners: [
{
@@ -134,6 +204,8 @@ export const manifest: ActivityManifest = {
cmmnXml: CMMN_XML,
narrative: NARRATIVE,
dmn: { drd: [], tables: [] },
/* 步骤剧本 —— 与 A1 同一套:doneWhen 真了引擎自己收步,不问模型 */
steps: NEGOTIATE_STEPS,
},
skills: NEGOTIATE_SKILLS,
viewTemplates: [
+174 -35
View File
@@ -1,50 +1,189 @@
/**
* A6 视图模版 —— 议价两屏:正式报价单 / 待放行即发出(AM 07)。
* 这是**唯一会触达外界**的活动:放行一刻要发信、要回写台账,所以那一屏必须说清后果。
* A6 报价议价的 HTML 报告模版 —— **活动自己持有的资产**(§10.3),随包版本冻结。
*
* 议价是**多轮往来**:成单 → 人工按下发出 → D+3 催 → D+7 催 → 客户还价 → 改一版再发。
* 所以这两屏都要让人看得出**先后**:这一版是第几轮、上一轮报的是多少、下一记提醒在哪天。
* 只摆一个「当前单价」的屏,人签字时没有参照系,也就无从判断这一让让得值不值 ——
* 于是「历次报价」那一节摆的是共享域整表(含既往单),它不是本单成果,是本单的尺子。
*
* 两个局面:
* ① 正式报价单(草稿)—— 六样条款齐不齐;这一步还没碰外界,一封信都没发;
* ② 放行即发出 —— 按下的那一刻信就到客户手上,签字前是最后一次能改的机会。
*
* 容器是 `sandbox=""` iframe:无脚本、无外链、样式自带;数据由基座推送时填
* (`data-slot` 取值、`data-rows` 逐行展开)。
*
* 两条贯穿全包的呈现纪律:
* · **状态章一律用「在办」灯,不用「已办」印** —— 模版是随包冻结的静态字符串,
* 它盖不了一个还没发生的章;这两张都是放行前的局面;
* · **`verdict` 的橙档专指「还没发生,等你按」** —— 发出的后果是预告不是既成事实。
* 整屏的结构色一律品牌蓝/案卷紫,红橙绿只落在「达标没达标」上,不拿来渲染气氛。
*/
import { kvCard, noteCard, tableCard, viewShell } from '@sdk';
import { noteOnly, section, stagesOnly, statsBand, tableOnly, verdict, viewShell } from '@sdk';
/**
* 静态阶梯 —— **规则与节拍不是数据**:六样条款的要求、D+3/D+7 的节拍、三个出口的去向
* 都随包冻结,不该走 `data-rows` 等基座来填(那会让「规矩」看起来像「这一单的结果」)。
* 沿用外壳已有的 `.steps` 版式,与引擎推的阶段列表同一副面孔。
*/
function ladder(rows: ReadonlyArray<readonly [string, string, string]>): string {
return `<ul class="steps">${rows
.map(
([no, term, gloss]) =>
`<li><span class="n">${no}</span><span><span class="t">${term}</span> <span class="d">${gloss}</span></span></li>`,
)
.join('')}</ul>`;
}
/**
* 键值格 —— 报价单的条款是**一份单据的正面**,六样并排比一张六列横向滚动的表好读。
* `@sdk` 的 `kvCard` 自带一个「·」号章节,与本页「一/二/三」的编号排不到一起,
* 所以这里只借外壳已有的 `.kv` 版式,章节仍交给 `section` 统一编号。
*/
function kvGrid(items: ReadonlyArray<readonly [string, string, ('mono' | 'tag')?]>): string {
return `<div class="kv">${items
.map(([label, path, style]) => {
const cls = style === undefined ? '' : ` class="${style === 'mono' ? 'mono' : 'tag'}"`;
return `<div><em>${label}</em><b${cls} data-slot="${path}"></b></div>`;
})
.join('')}</div>`;
}
/** 局面一:S1 成单 —— 人此刻要核的是「六样齐没齐、这一版能不能发」 */
export const QUOTE_COMPOSED = viewShell({
title: '正式报价单',
kicker: 'A6 · 报价议价',
title: '正式报价单(草稿)',
kicker: 'A6 · 报价议价 · S1 成单',
tone: 'brand',
icon: 'handshake',
stamp: { kind: 'live', text: '成单中' },
sign: 'A6 报价议价 · 正式报价单(本单草稿)',
body:
kvCard('报价抬头', [
['报价单号', 'quoteNo', 'mono'],
['客户编码', 'customerCode', 'mono'],
['型号 PN', 'pn', 'mono'],
]) +
kvCard('报价条款', [
['单价', 'unit_price'],
/* `items` 是起单入参的数组,基座渲染数组时给的是条数 —— 正好是「这一单要报几条」 */
statsBand([
['报价条目', 'items'],
['单价 CNY', 'unit_price'],
['交期', 'lead_time'],
['最小包装量 SPQ', 'spq'],
['最小起订量 MOQ', 'moq'],
['贸易条款', 'trade_terms'],
['有效期', 'valid_until'],
]) +
noteCard('这一步还没碰外界', '报价单此刻只是<b>草稿</b>。发出动作是<b>写类</b>的,过程中只登记为「动作意向」——入参冻结、等放行,放行前一封信都不会发出去。'),
});
export const QUOTE_RELEASE = viewShell({
title: '正式报价单 · 放行即发出',
kicker: 'A6 · 成果预览',
tone: 'danger',
body:
tableCard(
'本单报价',
'mine:quote_negotiate.formal_quote',
[
section('报价条款六样 —— 缺一不可',
kvGrid([
['单价 CNY', 'unit_price'],
['交期', 'lead_time'],
['最小包装量 SPQ', 'spq'],
['最小起订量 MOQ', 'moq'],
['贸易条款', 'trade_terms'],
['有效期', 'valid_until'],
]) +
noteOnly(
'单价与交期<b>照审批通过的草案抄</b>,不做任何「顺手优化」—— 改价要重新过审批点。' +
'有效期按行情波动定,不要一律写「长期有效」:那等于把汇率与料价风险自己扛了。' +
'拿不准的项<b>宁可空着走补全</b>,不要填一个看起来合理的值。',
),
{ text: '缺一样多一轮' },
) +
section('本单已成形的报价行',
tableOnly('mine:quote_negotiate.formal_quote', [
['报价单号', 'quote_no', 'mono'],
['型号', 'pn', 'mono'],
['型号 PN', 'pn', 'mono'],
['单价', 'unit_price'],
['交期', 'lead_time'],
],
['有效期', 'valid_until'],
]),
{ text: '只列本单写下的行' },
) +
noteCard(
'按下放行会发生什么',
'① <b>落地预检</b>先跑:交付物齐备、动作意向可执行、授权在契约边界内 —— 预检不过不发卡。' +
'② 放行通过<b>即刻落地</b>:台账回写 + 出站发送(报价单发给客户),动作附幂等键。' +
'③ <b>落地收敛</b>后输出口才发射 —— 下游只见落地完成的任务;已成动作留存<b>不回滚</b>。' +
'④ 出口三选一(成交 / 有异议 / 拒绝)<b>恰发一口</b>,随放行一并确认,G4 网关按它分流。',
section('议价的节拍 —— 一轮一版',
ladder([
['1', '写成正式报价单', '中英双语,六样齐备'],
['2', '人工按下发出', '触达客户的一下,系统不替人做这个决定'],
['3', 'D+3 提醒到点', '没有跟进的报价等于没报'],
['4', 'D+7 提醒到点', '第二记催办,仍无回音即等超时'],
['5', '客户回音 → 议价调整', '改价、改交期、换料都是新一版,重新走发出'],
]),
{ text: '往来看得出先后' },
) +
section('案例当下进程', stagesOnly('case:stages'), { text: 'Enabled ≠ Active' }) +
section('这一步还没碰外界',
verdict('报价单此刻只是草稿 —— 一封信都还没发出去', 'wait', '发出这一下必须人工按') +
noteOnly(
'发出是<b>写类动作</b>:过程中只登记为「动作意向」—— 入参冻结、等放行,放行前不触达世界。' +
'预授权是逐项列举的:催办模板可即时发,<b>报价单不行</b>。',
),
),
});
/** 局面二:待放行 —— 按下的那一刻信就发出去,这是最后一次能改的机会 */
export const QUOTE_RELEASE = viewShell({
title: '正式报价单 · 放行即发出',
kicker: 'A6 · 报价议价 · 成果待签',
tone: 'case',
icon: 'send',
stamp: { kind: 'live', text: '待签字放行' },
sign: 'A6 报价议价 · 正式报价单(待放行即发出)',
body:
statsBand([
['单价 CNY', 'unit_price'],
['交期', 'lead_time'],
['最小起订量 MOQ', 'moq'],
['有效期', 'valid_until'],
]) +
section('本单交付:正式报价单',
tableOnly('mine:quote_negotiate.formal_quote', [
['报价单号', 'quote_no', 'mono'],
['型号 PN', 'pn', 'mono'],
['单价', 'unit_price'],
['交期', 'lead_time'],
['SPQ', 'spq'],
['MOQ', 'moq'],
['贸易条款', 'trade_terms'],
['有效期', 'valid_until'],
]),
{ text: '发出去的就是这几行' },
) +
section('历次报价对照 —— 这一轮让了多少',
/*
* 这一张**故意不用 `mine:`**:它取共享域整表,含既往单的行。
* 本单成果只看 `mine:`(见上一节),而「让了多少」这件事天然要跟别的单比 ——
* 没有上一轮的价摆在旁边,人签字时判断不了这一让的分寸。
*/
tableOnly('shared.history_price', [
['型号 PN', 'pn', 'mono'],
['报价日期', 'quoted_at', 'mono'],
['单价', 'unit_price'],
['交期', 'lead_time'],
]) +
noteOnly(
'这一节<b>不是本单成果</b>,是共享域里同一料号的历次报价(含既往单)—— 摆在这里只做一件事:' +
'给这一轮的价一个参照系。报价日期在列上,先后按日期对;它由 A7 情报归档一单一单沉下来。',
),
{ text: '含既往单' },
) +
section('放行前要过的四道判据',
ladder([
['1', '单价 / 交期 / SPQ / MOQ / 贸易条款 / 有效期齐备', 'L1 机检 · missingQuoteFields = 0'],
['2', '按报价有效期 P-04 注明有效期', 'L2 机检 · validityDays 大于 0'],
['3', '已建 D+3 / D+7 跟进', 'L2 机检 · 没有跟进的报价等于没报'],
['4', 'BD 放行', 'L5 人签 · 签了才算数'],
]),
{ text: '三机检 · 一人签' },
) +
section('出口三选一 —— 恰发一口',
ladder([
['1', '成交', '转订单履约(ORM),这条流走到终点'],
['2', '有异议', '回 A4 重开新实例重算 —— 旧的已终,不再入算'],
['3', '拒绝 / 超时', '转 A7 情报归档,把拒绝原因与竞对价位登记下来'],
]) +
noteOnly(
'三个出口对应三种后续,随放行<b>恰选一口</b>,下游网关读的就是它;<b>不选口不许放行</b> ——' +
'含糊的结果比没结果更坏。「拒绝」不是失败终态:原因回写历史情报后,下一单的三分类与利润模型会用上它。',
),
{ text: '下游只读这一口' },
) +
section('按下放行会发生什么',
verdict('放行即落地:台账回写 + 报价单出站发送', 'wait', '已成动作留存,不回滚') +
noteOnly(
'① <b>落地预检</b>先跑:交付物齐备、动作意向可执行、授权在契约边界内 —— 预检不过不发卡;' +
'② 放行通过<b>即刻落地</b>,出站动作附幂等键;' +
'③ <b>落地收敛</b>之后输出口才发射 —— 下游只见落地完成的任务。',
),
),
});
+113 -57
View File
@@ -1,81 +1,137 @@
/**
* A4 成本定价的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
* A4 成本定价的案例模型 —— **算价归判定表,超限那一下归人**(2026-07-31 照 A1 的状态机模式重做)。
*
* 2026-07-29 由步序声明(flow)改写而来。原六步各归其位:取价基准与逐条定价属「算价」,
* 偏离率与底价门禁属「自检」(两支 DMN:P-01 利润模型、P-02 底价门禁),
* 超限条目调人决策是**触达客户利益的一下**,必须人工激活;末尾一张验收单。
* 与 A2/A3 的分工:那两个是「一次专家判断」,派一张执行单收回结论就够了;
* 定价不同 —— 它**有确定性规则可依**(P-01 利润模型、P-02 底价门禁两支 DMN),
* 算价这件事该算不该问。所以它照 A1 的样子分步建模,而不是甩一张单给人。
*
* 三项:
* T_price —— 逐条算价并落草案(拟报单价 / 毛利 / 偏离度 / 门禁处置);
* T_approve —— **只在门禁判超限时入场**:触达客户利益的一下,人工激活,派决策卡;
* T_release —— 放行(人工激活)。
*
* 「超限才叫人」是这个活动的要害:每条都叫人,人就成了橡皮图章;一条都不叫,
* 底价门禁就是摆设。所以那一项挂前提(`s_over`),由判定表的结论决定它入不入场。
*/
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
<case id="pricing">
<!--
案卷项的 id **就是任务视图里的键名**:
draft_total 是草案条数(算完没算完),over_total 是超限条数(要不要叫人)。
两个都由 T_price 写进台账,摊平到顶层后驱动前提。
-->
<caseFileModel>
<caseFileItem id="records" name="待定价条目"/>
<caseFileItem id="basis" name="取价基准"/>
<caseFileItem id="draft" name="报价草案"/>
<caseFileItem id="floors" name="底价表"/>
<caseFileItem id="pn" name="型号(开案即有)"/>
<caseFileItem id="draft_total" name="草案条数"/>
<caseFileItem id="over_total" name="超限条数"/>
</caseFileModel>
<casePlanModel id="plan" name="成本定价" autoComplete="true">
<sentry id="s_records">
<onPart id="op_rc" caseFileItemRef="records"><standardEvent>create</standardEvent></onPart>
</sentry>
<sentry id="s_basis">
<onPart id="op_bs" caseFileItemRef="basis"><standardEvent>create</standardEvent></onPart>
</sentry>
<sentry id="s_priced">
<onPart id="op_pd" caseFileItemRef="draft"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_pd"><condition>unpriced = 0</condition></ifPart>
<onPart id="op_pr" caseFileItemRef="draft_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_pr"><condition>draft_total &gt; 0</condition></ifPart>
</sentry>
<sentry id="s_breach">
<onPart id="op_br" caseFileItemRef="draft"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_br"><condition>breaches &gt; 0</condition></ifPart>
<!-- 超限才叫人:门禁判出有超限条目,这一项才入场 -->
<sentry id="s_over">
<onPart id="op_ov" caseFileItemRef="over_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_ov"><condition>over_total &gt; 0</condition></ifPart>
</sentry>
<stage id="S1" name="算价" autoComplete="true">
<entryCriterion id="ec_s1" sentryRef="s_records"/>
<humanTask id="T_basis" name="人工取价基准">
<itemControl><manualActivationRule id="m_bs"/></itemControl>
</humanTask>
<decisionTask id="D_price" name="逐条定价" decisionRef="p01-margin">
<entryCriterion id="ec_price" sentryRef="s_basis"/>
</decisionTask>
</stage>
<milestone id="M_priced" name="全条已定价"/>
<stage id="S2" name="自检" autoComplete="true">
<entryCriterion id="ec_s2" sentryRef="s_priced"/>
<decisionTask id="D_floor" name="底价门禁" decisionRef="p02-floor"/>
<humanTask id="T_breach" name="人工裁超限条目(回调还是注明理由)">
<entryCriterion id="ec_breach" sentryRef="s_breach"/>
<itemControl><manualActivationRule id="m_br"/><repetitionRule id="r_br"/></itemControl>
</humanTask>
<planningTable id="pt_s2">
<humanTask id="T_refresh_floor" name="人工刷新底价"/>
</planningTable>
</stage>
<milestone id="M_gated" name="门禁已过"/>
<humanTask id="T_release" name="人工报价草案验收放行">
<itemControl><manualActivationRule id="m_rl"/></itemControl>
<!-- 算价:判定表说了算,不是临场判 -->
<humanTask id="T_price" name="逐条算价:取基准、算毛利与偏离度、过底价门禁">
<itemControl><repetitionRule id="r_pr"/></itemControl>
</humanTask>
<timerEventListener id="TL_half" name="时限过半">
<timerExpression>PT8H</timerExpression>
</timerEventListener>
<!-- 超限审批:触达客户利益的一下,必须人工激活 -->
<humanTask id="T_approve" name="超限条目决策:低于底价的那几条要人拍板">
<entryCriterion id="ec_ap" sentryRef="s_over"/>
<itemControl><manualActivationRule id="m_ap"/><repetitionRule id="r_ap"/></itemControl>
</humanTask>
<!-- 放行:触达世界,必须人工激活 -->
<humanTask id="T_release" name="报价草案验收放行">
<entryCriterion id="ec_rel" sentryRef="s_priced"/>
<itemControl><manualActivationRule id="m_rl"/></itemControl>
</humanTask>
</casePlanModel>
</case>
</definitions>`;
export const NARRATIVE = `§0 开案:拿到一批待定价条目即开案。成品是**一份全条目带偏离度的报价草案** ——
每条有拟报单价与毛利率,且都过了底价门禁。
人签的不是一个数字,是**这个数字的来路**:基准从哪来、毛利怎么算、偏离多少、门禁怎么判。
S1 算价:先取价基准(历史成交价 / 供应商回价 / 替代料对标价,按可得性择优),
再按利润模型 P-01 逐条定价 —— 客户等级、数量档、料号类型、行情共同决定加价率。
基准取不到的条目不许拍脑袋定价,回头找寻源或标注「无基准」。
**能算的不问人**:P-01(利润模型)与 P-02(底价门禁)两支判定表说了算,你不临场拍价。
你的活是把判据取齐、把结论落进台账;**只有超限的那几条才叫人**。
S2 自检:算出偏离度(基准价 ↔ 拟报价),按底价门禁 P-02 逐条判。
**偏离 ±20% 以内自动放过;超限的一律调人** —— 要么回调价格,要么写明为什么这样报。
底价超一周没更新的先刷底价再判(裁量项),拿旧底价判新行情等于没判。
【T_price 逐条算价】基准价取自历史价情报(A3 的比价结论 / CRM 历史价)。新料号无历史时
基准记 0,由底价门禁按「无基准」处置 —— **不凭空编一个偏离率出来**。
逐条写进 \`quote_pricing.draft\`:拟报单价 / 目标毛利 / 相对基准偏离 / 门禁处置。
同一拍接着把 draft_total(条数)与 over_total(门禁判「超限」的条数)写上 ——
这两个数一个是完成判据,一个决定要不要叫人。写完 finish。
§Z 结案:每条都有拟报单价与毛利率、超限条目已处置、BD 签字放行,自动结案。
草案交出去就是下游议价的起点 —— 它不是最终报价,但每一条都要经得起问「为什么是这个价」。`;
【T_approve 超限决策】**只在 over_total > 0 时走这一步**。它触达客户利益,
所以标了人工激活:**先 start,再发决策卡**(decide,itemId 填 T_approve)。
选项按门禁的处置给(照原价报 / 按底价报 / 退回重算),consequence 写清各自的后果;
basis 摆上是哪几条、偏离多少、底价是多少(裸卡基座拒发)。
人点完,轨迹上会出现「decision 卡回传 —— 人选了:xxx」—— **看到它就照那条路把 draft 改掉**,
别再发第二张卡。改完 finish。
【提交放行】草案有行了 —— **先 start T_release**(人工激活),然后 submit,BD 验收。
提交前把两条裁量判据裁掉(judge,criterion 照判据名原样写);终局唯放行,你不能自己宣布完成。
【一路上要说话】每一拍带一句 say,说**业务的话**:这批料算下来什么价、哪几条压线、
要不要找人拍板。一两句、口语。别报「写草稿 N 行」这种系统词。`;
/**
* 步骤剧本 —— 与 A1 同一套:`doneWhen` 为真,引擎自己收步、**不问模型**。
*
* 两处不给 `doneWhen`:`T_approve`(人工激活,要人拍板)与 `T_release`(触达世界)。
* 给了就等于给引擎一把替人签字的钥匙。
*/
export const PRICING_STEPS = [
{
item: 'T_price',
intent: '逐条算价并落草案:拟报单价 / 毛利 / 偏离度 / 门禁处置,并结算条数与超限数',
writes: ['quote_pricing.draft', 'quote_pricing.verdict'],
reads: [],
tools: [],
// 草案有行了才算算完。不写 `!= null` —— 未绑定的名字那样求值为 true(A1 踩过)
doneWhen: 'draft_total > 0',
guide: `基准价取自历史价情报(A3 的比价结论 / CRM 历史价)。新料号无历史时基准记 0,
由底价门禁按「无基准」处置 —— **不凭空编一个偏离率**。
逐条写进 \`quote_pricing.draft\`:拟报单价 / 目标毛利 / 相对基准偏离 / 门禁处置。
**同一拍接着**把 draft_total 与 over_total(门禁判超限的条数)写上 ——
前者是完成判据,后者决定要不要叫人。
**同一拍再写一行 \`quote_pricing.verdict\`**(验收机检读的就是它):
· draft_total / unpriced_total = 草案条数 / 还没定出价的条数(后者要 = 0);
· over_total = 门禁判超限的条数;
· unresolved_over_total = 超限里**还没处置**的条数(要 = 0 —— 回调了或注明了理由都算已处置);
· summary 一句人话结论。写不对 submit 会被门挡回来。三笔写完再 finish。
**每一行都要带 \`rfq_ref\`** —— 它是这一单的单标识(起单入参里就有,原样抄进去)。
台账主键第一列就是它:不写,这一行会和**别的单**同料号的那一行撞主键,把人家覆盖掉
(实测栽过:A4 的报价草案主键只有 pn,跑四轮只剩最后一轮的 3 行)。`,
},
{
item: 'T_approve',
intent: '超限条目要人拍板 —— 发决策卡,照人选的那条路改草案',
writes: ['quote_pricing.draft'],
reads: [],
tools: [],
// 人工激活 + 要人拍板 —— 不给 doneWhen,引擎不许替人收
guide: `**只在 over_total > 0 时走这一步**。它触达客户利益,标了人工激活:
**先 start T_approve**(与发卡排同一拍即可),再发决策卡(decide,itemId 填 T_approve)。
选项按门禁处置给(照原价报 / 按底价报 / 退回重算),consequence 写清后果;
basis 摆上哪几条、偏离多少、底价多少(**裸卡基座拒发**)。
人点完轨迹上会出现「decision 卡回传 —— 人选了:xxx」,**照那条路改 draft,别再发第二张卡**。`,
},
{
item: 'T_release',
intent: '草案已齐,提交放行交 BD 验收',
writes: [],
reads: [],
tools: [],
guide: `草案有行了 —— **先 start T_release**(人工激活),提交前用 judge 裁掉裁量判据
(criterion 照判据名原样写),然后 submit。终局唯放行:你不能自己宣布完成。`,
},
];
-1
View File
@@ -11,7 +11,6 @@ const DRAFT = 'quote_pricing.draft';
export default defineActivity({
name: 'quote-pricing',
paradigm: 'flow',
async run(base: ActivityBase): Promise<ActivityOutcome> {
const items = await base.ledger.read(DRAFT);
+58 -10
View File
@@ -7,7 +7,7 @@
*/
import type { ActivityManifest, DmnModel } from '@contracts';
import { PRICING_SKILLS } from './skills.ts';
import { CMMN_XML, NARRATIVE } from './case-model.ts';
import { CMMN_XML, NARRATIVE, PRICING_STEPS } from './case-model.ts';
import { activityIdSchema } from '@contracts';
import { PRICING_BASIS, PRICING_PRICED, PRICING_RELEASE } from './views/report.ts';
@@ -62,7 +62,7 @@ export const manifest: ActivityManifest = {
identity: {
activityId: ACTIVITY_ID,
name: 'A4 · 成本定价',
packageVersion: '0.1.6',
packageVersion: '2.0.0',
category: '统一报价',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-quote-pricing',
@@ -74,8 +74,9 @@ export const manifest: ActivityManifest = {
input: {
data: {
type: 'object',
required: ['customerCode', 'records'],
required: ['rfqRef', 'customerCode', 'records'],
properties: {
rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' },
customerCode: { type: 'string' },
records: { type: 'array' },
// B/C 类由 A3/A2 交回的底价与替代方案;A 类直通时为空
@@ -90,21 +91,36 @@ export const manifest: ActivityManifest = {
name: 'draft',
payload: {
type: 'object',
required: ['items'],
properties: { items: { type: 'array' } },
required: ['rfqRef', 'items'],
properties: { rfqRef: { type: 'string' }, items: { type: 'array' } },
},
description: '报价草案(逐条带偏离度)',
},
],
selective: [],
},
requiredDeliverables: [{ key: 'quote-draft', label: '报价草案(全条目带偏离度)' }],
/*
* 必交付物**必须给 `ledger` 地址**(2026-07-31 修)。
* 不给的话预检退回 `scope[key]` 去找 —— key 是 `benchmark-table` 这样的短横线别名,
* 任务视图里永远没有这个键,于是「必交付物缺一不得提交放行」永远拒,
* 活办完了也放不了行,连拒五拍熔断(实测 A2 死于此)。
*/
requiredDeliverables: [
{ key: 'quote-draft', label: '报价草案(全条目带偏离度)', ledger: `${DOMAIN}.draft` },
],
/*
* 机检谓词读的是 `verdict` 那张**汇总表**的列(照 A1 的口径)。
* 上一版的名字(unpriced;floor-gate 更是连谓词都没有)**台账里根本没有** —— S-FEEL 拿未绑定的名字
* 比大小求值为假,判据永远达不了标,submit 门永远拒,连拒五拍熔断。
* 判据的名字必须落在台账上,否则「机检」是一句空话(2026-07-31 销账)。
*/
acceptanceCriteria: [
{ key: 'all-priced', label: '每条都有拟报单价与毛利率', level: 'L1', predicate: 'unpriced = 0' },
{ key: 'all-priced', label: '每条都有拟报单价与毛利率', level: 'L1', predicate: 'unpriced_total = 0' },
// 底价门禁沉为 L2 机检 —— 「约束力收缩」的终点
{ key: 'floor-gate', label: '偏离底价 ≤±20%,超限者已回调或注明理由', level: 'L2', modelRef: 'p02-floor' },
{ key: 'floor-gate', label: '偏离底价 ≤±20%,超限者已回调或注明理由', level: 'L2', modelRef: 'p02-floor', predicate: 'unresolved_over_total = 0' },
{ key: 'pm-release', label: 'BD 放行', level: 'L5' },
],
revisableAtRelease: [],
prohibitions: [
{ key: 'no-silent-override', statement: '不得静默绕开 P-02 的结论;要破线只能走「人 · 决策」' },
{ key: 'no-stale-floor', statement: '底价超一周未更新的,先刷底价再判' },
@@ -115,7 +131,13 @@ export const manifest: ActivityManifest = {
humanCallBudget: 3,
},
resources: {
tools: [{ name: 'quote-pricing.basis' }, { name: 'crm.history-price-read' }],
/*
* `quote-pricing.basis` 已从声明里去掉(2026-07-31):工具箱里从没注册过它,
* 装包时静态审一直报「运行时转人代办」—— 而重做后的案例模型压根不调它
* (算价归判定表 P-01/P-02,取基准读历史价)。声明一件不存在也不用的工具,
* 只会让每一单跑到 A4 就弹一张莫名其妙的转人卡。
*/
tools: [{ name: 'crm.history-price-read' }],
skills: PRICING_SKILLS.map((s) => s.id),
models: ['deepseek-v4-flash'],
roles: ['BD'],
@@ -125,13 +147,16 @@ export const manifest: ActivityManifest = {
declarations: {
models: ['deepseek-v4-flash'],
tools: [{ name: 'crm.history-price-read' }, { name: 'quote-pricing.basis' }],
tools: [{ name: 'crm.history-price-read' }],
ledger: [
{
domain: DOMAIN,
object: 'draft',
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: 'quoted_price', type: 'number', primaryKey: false, masked: false, label: '拟报单价' },
{ name: 'margin', type: 'number', primaryKey: false, masked: false, label: '毛利率' },
@@ -140,6 +165,27 @@ export const manifest: ActivityManifest = {
{ name: 'disposition', type: 'text', primaryKey: false, masked: false, label: '门禁处置' },
],
},
{
/*
* 定价结论 —— 一单一行的**汇总表**,验收机检谓词的入参就在这儿。
* 汇总列与逐行列**故意不同名**:任务视图把草稿行摊平到顶层是「后写的赢」,
* 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。
*/
domain: DOMAIN,
object: 'verdict',
kind: 'record',
caseKey: 'rfq_ref',
label: '定价结论',
fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'pn', type: 'text', primaryKey: false, masked: false, label: '型号' },
{ name: 'draft_total', type: 'integer', primaryKey: false, masked: false, label: '草案条数' },
{ name: 'unpriced_total', type: 'integer', primaryKey: false, masked: false, label: '未定价条数' },
{ name: 'over_total', type: 'integer', primaryKey: false, masked: false, label: '超限条数' },
{ name: 'unresolved_over_total', type: 'integer', primaryKey: false, masked: false, label: '超限未处置条数' },
{ name: 'summary', type: 'text', primaryKey: false, masked: false, label: '定价结论' },
],
},
],
listeners: [
{
@@ -166,6 +212,8 @@ export const manifest: ActivityManifest = {
cmmnXml: CMMN_XML,
narrative: NARRATIVE,
dmn: P01_DMN,
/* 步骤剧本 —— 与 A1 同一套:doneWhen 真了引擎自己收步,不问模型 */
steps: PRICING_STEPS,
},
skills: PRICING_SKILLS,
viewTemplates: [
+164 -39
View File
@@ -1,67 +1,192 @@
/**
* A4 视图模版 —— 定价的三屏:取价基准 / 定价与门禁 / 报价草案待放行(AM 07)。
* 定价这件事最需要「把判据摊开」:人签的不是一个数字,是这个数字的来路。
* A4 成本定价的 HTML 报告模版 —— **活动自己持有的资产**(§10.3),随包版本冻结。
*
* 定价这件事最需要「把判据摊开」:**人签的不是一个数字,是这个数字的来路**。
* 所以三张屏不按「概览 / 明细 / 结论」排,按**一个价是怎么长出来的**排:
* ① 取价基准 —— 跟谁比。基准取错,后面每一条偏离率都是假的,而假偏离率长得跟真的一样;
* ② 定价与底价门禁 —— 凭什么是这个价。两张决策表的规则原样摆出来,人能逐条倒着核;
* ③ 报价草案待放行 —— 签字前最后一次能改的机会,门后的都算数了。
*
* 一个局面一张,局面变了让 Agent 用 `view` 换,不在一张里堆条件分支。
* 容器是 `sandbox=""` iframe:无脚本、无外链、样式自带;数据由基座推送时填
* (`data-slot` 取值、`data-rows` 逐行展开)。
*
* 两条贯穿全包的呈现纪律:
* · **状态章一律用「在办」灯,不用「已办」印** —— 模版是随包冻结的静态字符串,
* 它盖不了一个还没发生的章。三张屏都是放行前的局面,盖「已通过」就是撒谎;
* · **`verdict` 的橙档专指「还没发生,等你按」** —— 绿只留给已达标的机检结论。
* 放行后果是预告不是既成事实,一律走橙档。
*/
import { kvCard, noteCard, tableCard, viewShell } from '@sdk';
import { noteOnly, section, stagesOnly, statsBand, tableOnly, verdict, viewShell } from '@sdk';
/**
* 静态阶梯 —— **规则本身不是数据**:P-01 的毛利档、P-02 的三档处置随包冻结,
* 它们不该走 `data-rows` 等基座来填(那会让「规则」看起来像「这一单的结果」)。
* 沿用外壳已有的 `.steps` 版式,与引擎推的阶段列表同一副面孔 —— 页面上只该有一套样式。
*/
function ladder(rows: ReadonlyArray<readonly [string, string, string]>): string {
return `<ul class="steps">${rows
.map(
([no, term, gloss]) =>
`<li><span class="n">${no}</span><span><span class="t">${term}</span> <span class="d">${gloss}</span></span></li>`,
)
.join('')}</ul>`;
}
/** 局面一:S1 算价刚开场 —— 人此刻要核的是「这一单跟谁比、落在哪一档」 */
export const PRICING_BASIS = viewShell({
title: '取价基准',
kicker: 'A4 · 成本定价',
kicker: 'A4 · 成本定价 · S1 算价',
tone: 'brand',
icon: 'coins',
stamp: { kind: 'live', text: '算价中' },
sign: 'A4 成本定价 · 取价基准(本单草稿)',
body:
kvCard('本单标的', [
['客户编码', 'customerCode', 'mono'],
['型号 PN', 'pn', 'mono'],
['物料类型', 'materialType', 'tag'],
]) +
kvCard('价格基准', [
['上次成交价', 'last_price'],
/*
* 指标带摆的是**利润模型 P-01 的三个入参 + 这一单的工作量**,不是所有字段。
* 三个档位键取的是清单里 P-01 声明的决策入参名(customerGrade / materialType /
* volumeTier)—— 决策表照这三个名字取值,模版就照这三个名字摆,不另起炉灶。
* `records` 是起单入参的数组,基座渲染数组时给的是条数,正好是「这一单要定几条价」。
*/
statsBand([
['待定价条目', 'records'],
['客户等级', 'customerGrade'],
['料号类型', 'materialType'],
['数量档', 'volumeTier'],
]) +
noteCard('基准从哪来', '基准价取自历史价情报(CRM)。新料号无历史时基准记 0,由底价门禁按<b>无基准</b>处置 —— 不凭空编一个偏离率出来。'),
section('基准从哪来 —— 四级优先级',
ladder([
['1', '本客户近期成交价', '最贴近这一单的真实议价空间'],
['2', '同型号他客户成交价', '价位可比,但议价空间要留余量'],
['3', '供应商底价表', '成本侧的锚,不含客户侧信息'],
['4', '无基准', '新料号、久未报价的老料号都会落到这里'],
]) +
noteOnly(
'<b>无基准不是异常,它本身就是一种基准。</b>此时偏离率记 0 —— 没有可偏离的对象,' +
'由底价门禁按「无基准」处置。不要拿一个不相干的价去凑一个偏离率出来:' +
'那比没有基准更危险,因为它<b>看起来像有依据</b>。',
),
{ text: '越靠前越贴近实情' },
) +
section('本单待定价条目',
tableOnly('mine:quote_pricing.draft', [
['型号 PN', 'pn', 'mono'],
['基准价', 'last_price'],
['拟报单价', 'quoted_price'],
['毛利率', 'margin'],
]),
{ text: '只列本单写下的行' },
) +
section('案例当下进程', stagesOnly('case:stages'), { text: 'Enabled ≠ Active' }),
});
/** 局面二:S2 自检 —— 人此刻要核的是「凭什么是这个价、门禁怎么判的」 */
export const PRICING_PRICED = viewShell({
title: '定价与底价门禁',
kicker: 'A4 · DMN 两表',
tone: 'ok',
kicker: 'A4 · 成本定价 · S2 自检',
tone: 'brand',
icon: 'gauge',
stamp: { kind: 'live', text: '自检中' },
sign: 'A4 成本定价 · 定价自检(判定表 P-01 / P-02)',
body:
kvCard('定价结论', [
/* 标签一律照台账声明的列名(基准价 / 拟报单价 / 毛利率 / 偏离度 / 门禁处置):
同一个数在指标带上叫一个名、在下面的表里叫另一个名,签字的人就得先花一秒确认它俩是不是一回事 */
statsBand([
['基准价', 'last_price'],
['拟报单价', 'quoted_price'],
['目标毛利', 'margin'],
['相对基准偏离', 'deviation'],
['门禁处置', 'disposition', 'tag'],
['毛利率', 'margin'],
['偏离度', 'deviation'],
]) +
noteCard(
'两张决策表各管一段',
'<b>利润模型 P-01</b>:客户等级 × 物料类型 × 数量档 → 目标毛利区间。' +
'<b>底价门禁 P-02</b>:偏离底价 ±20% 即止 —— 超限的不是「拦下」,而是转人裁断(回调还是注明理由)。' +
'两表都是确定性求值,同输入必同结论;命中的规则行随出参留痕,事后可逐条复盘。',
section('逐条定价结论',
tableOnly('mine:quote_pricing.draft', [
['型号 PN', 'pn', 'mono'],
['基准价', 'last_price'],
['拟报单价', 'quoted_price'],
['毛利率', 'margin'],
['偏离度', 'deviation'],
['门禁处置', 'disposition'],
]),
{ text: '判据在左 · 结论在右' },
) +
section('目标毛利怎么定的 —— 利润模型 P-01',
ladder([
['1', '战略客户 · A 类标准件 · 大批量', '目标毛利 8%–10%'],
['2', '普通客户 · A 类标准件', '12%–15%'],
['3', 'B 类波动件', '15%–20%'],
['4', 'C 类替代 / EOL', '≥20%,或一事一议'],
['5', '以上都不命中', '兜底按普通客户标准件 12%–15%'],
]) +
noteOnly(
'自上而下取<b>第一条命中</b>的规则,同输入必得同结论、零方差零 token,命中的规则行随出参留痕。' +
'不要为了让门禁「过」而回头改毛利参数 —— 那是把判据改成结论,决策表也就失去了它唯一的价值。',
),
{ text: '首条命中' },
) +
section('偏离怎么判的 —— 底价门禁 P-02',
ladder([
['1', '拟报价 ≥ 底价', '直接放行'],
['2', '低于底价 ≤ 20%', 'PM 附理由放行'],
['3', '低于底价 超 20%', '拦截 · BOSS 拍板'],
]) +
noteOnly(
'门禁不是「拦下」而是<b>分流</b>:超限的一律调人 —— 要么回调价格,要么写明为什么这样报,' +
'两条路都合法,但都要留下理由。这条留痕将来会成为客户等级与利润模型的迭代素材。',
),
{ text: '±20% 即止' },
) +
section('本单门禁结论',
/*
* 结论章里嵌 `data-slot`:处置与偏离都是**这一单算出来的事实**,
* 写死一句「已通过」就是替判定表下结论。橙档在此的意思是「还没定,等人处置」。
*/
verdict('底价门禁 P-02 处置:<b data-slot="disposition"></b>', 'wait', '偏离度 <b data-slot="deviation"></b>') +
noteOnly(
'禁区两条随本单同行:<b>不得静默绕开 P-02 的结论</b> —— 要破线只能走「人 · 决策」;' +
'<b>底价超一周未更新的,先刷底价再判</b> —— 拿旧底价判新行情等于没判。',
),
),
});
/** 局面三:待放行 —— 验收单上人要签的那份成果报告 */
export const PRICING_RELEASE = viewShell({
title: '报价草案 · 待放行',
kicker: 'A4 · 成果预览',
kicker: 'A4 · 成本定价 · 成果待签',
tone: 'case',
icon: 'shield-check',
stamp: { kind: 'live', text: '待签字放行' },
sign: 'A4 成本定价 · 报价草案(待放行)',
body:
tableCard(
'本单报价草案',
'mine:quote_pricing.draft',
[
['型号', 'pn', 'mono'],
statsBand([
['拟报单价', 'quoted_price'],
['毛利率', 'margin'],
['偏离度', 'deviation'],
['门禁处置', 'disposition'],
]) +
section('本单交付:报价草案',
tableOnly('mine:quote_pricing.draft', [
['型号 PN', 'pn', 'mono'],
['基准价', 'last_price'],
['拟报价', 'quoted_price'],
['毛利', 'margin'],
['偏离', 'deviation'],
['门禁', 'disposition'],
],
'逐条列出,便于放行前逐条核对。',
['拟报单价', 'quoted_price'],
['毛利率', 'margin'],
['偏离度', 'deviation'],
['门禁处置', 'disposition'],
]) +
noteOnly('只列<b>本单</b>写下的行 —— 别单放行过的记录不混进来。逐条列出,便于放行前逐条核对。'),
{ text: '本单草稿' },
) +
noteCard(
'放行之后',
'放行通过 → 草稿合并入正式版 → 进 <b>AP 定价审批</b>(卡到 PM 工位)。' +
'审批驳回按声明走向<b>回 A4 重开新实例</b> —— 新任务 ID、留痕关联,不复活旧任务。',
section('放行前要过的三道判据',
ladder([
['1', '每条都有拟报单价与毛利率', 'L1 机检 · unpriced = 0'],
['2', '偏离底价 ≤ ±20%,超限者已回调或注明理由', 'L2 机检 · 底价门禁 P-02'],
['3', 'BD 放行', 'L5 人签 · 签了才算数'],
]),
{ text: '两机检 · 一人签' },
) +
section('放行之后会发生什么',
verdict('放行即落地:草稿合并入正式版,草案提交 A5 定价审批', 'wait', '审批点卡在 PM 工位') +
noteOnly(
'审批驳回按<b>声明</b>走向,不由临场决定:<b>回 A4 重开新实例</b> —— 新任务 ID、留痕关联,' +
'不复活旧任务。所以这一屏上还改得动的每一格,过了这道门就要花一整个新实例才改得回来。',
),
),
});
+109 -97
View File
@@ -1,120 +1,132 @@
/**
* A3 渠道寻源的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
* 「说明书是 XML 的叙事投影,迭代时一起放行」(AM 3.1)。
* A3 渠道寻源的案例模型 —— **一张执行单办完**(2026-07-31 用户定,与 A2 同一口径)。
*
* 上一版按四种询价场景(BOM 早期 / BOM 后期 / 现货 / 样品)把流程铺开成一串计划项。
* 场景之分在业务上是真的,可它**不改变系统要管的事**:发函、收回价、比价、给结论。
* 而这四步在一次寻源里是连着做的 —— 采购不会办完「发函」就交回来等系统叫他。
* 把它切成四项,换来的只是 Agent 每一步都要跟 harness 对一次账。
*
* 现在只有两项:
* T_source —— 派一张执行单给采购(卡面两张可编辑表:供应商回价 / 寻源比价),他填完提交;
* T_release —— 放行(触达世界,人工激活)。
*
* 场景之分不进模型,进**说明书**:采购自己知道 BOM 早期该广撒网、现货该问库存。
* 那是他的判断,不是系统的步。
*/
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
<case id="sourcing">
<!--
案卷项的 id **就是任务视图里的键名**。只认两样:
起单入参(pn)与「比价表写了几行」(compare_total)——
后者既是放行前提,也是这一单唯一的机检判据。
-->
<caseFileModel>
<caseFileItem id="inquiry" name="询价请求"/>
<caseFileItem id="supplier_quote" name="供应商回价单"/>
<caseFileItem id="compare_table" name="寻源比价表"/>
<caseFileItem id="pn" name="型号(开案即有)"/>
<caseFileItem id="compare_total" name="比价表条数"/>
</caseFileModel>
<casePlanModel id="plan" name="渠道寻源" autoComplete="true">
<sentry id="s_inquiry">
<onPart id="op_inquiry" caseFileItemRef="inquiry"><standardEvent>create</standardEvent></onPart>
</sentry>
<sentry id="s_need_quote">
<onPart id="op_need" caseFileItemRef="inquiry"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_need"><condition>needNewQuote = true</condition></ifPart>
</sentry>
<sentry id="s_channels">
<onPart id="op_ch" caseFileItemRef="inquiry"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_ch"><condition>channelsPicked &gt;= 2</condition></ifPart>
</sentry>
<sentry id="s_quotes_in">
<onPart id="op_q" caseFileItemRef="supplier_quote"><standardEvent>create</standardEvent></onPart>
</sentry>
<sentry id="s_ready">
<onPart id="op_r" caseFileItemRef="compare_table"><standardEvent>create</standardEvent></onPart>
<sentry id="s_compared">
<onPart id="op_cp" caseFileItemRef="compare_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_cp"><condition>compare_total &gt; 0</condition></ifPart>
</sentry>
<stage id="S1" name="接需核史" autoComplete="true">
<entryCriterion id="ec_s1" sentryRef="s_inquiry"/>
<humanTask id="T_history" name="人工查历史报价">
<itemControl><manualActivationRule id="m_hist"/></itemControl>
</humanTask>
<decisionTask id="D_strategy" name="判定采用策略" decisionRef="sourcing-strategy">
<itemControl><manualActivationRule id="m_str"/></itemControl>
</decisionTask>
</stage>
<milestone id="M_strategy" name="策略已定"/>
<stage id="S2" name="选渠定策" autoComplete="true">
<entryCriterion id="ec_s2" sentryRef="s_need_quote"/>
<humanTask id="T_pick" name="人工按场景圈渠道">
<itemControl><manualActivationRule id="m_pick"/></itemControl>
</humanTask>
<planningTable id="pt_s2">
<humanTask id="T_verify_channel" name="人工渠道资质核实"/>
</planningTable>
</stage>
<milestone id="M_channels" name="渠道圈定"/>
<stage id="S3" name="发询比价" autoComplete="true">
<entryCriterion id="ec_s3" sentryRef="s_channels"/>
<humanTask id="T_send" name="人工批量发询">
<itemControl><manualActivationRule id="m_send"/><repetitionRule id="r_send"/></itemControl>
</humanTask>
<humanTask id="T_log_quote" name="人工登记回价">
<entryCriterion id="ec_log" sentryRef="s_quotes_in"/>
<itemControl><manualActivationRule id="m_log"/><repetitionRule id="r_log"/></itemControl>
</humanTask>
<humanTask id="T_verify_price" name="人工价格验证">
<itemControl><manualActivationRule id="m_vp"/><repetitionRule id="r_vp"/></itemControl>
</humanTask>
<planningTable id="pt_s3">
<humanTask id="T_chase" name="人工追价补询"/>
</planningTable>
</stage>
<milestone id="M_quotes" name="报价确认"/>
<stage id="S4" name="反馈交付" autoComplete="true">
<entryCriterion id="ec_s4" sentryRef="s_ready"/>
<humanTask id="T_fill_table" name="人工填寻源比价表">
<itemControl><manualActivationRule id="m_fill"/></itemControl>
</humanTask>
<planningTable id="pt_s4">
<humanTask id="T_order" name="人工下单与跟踪"/>
</planningTable>
</stage>
<milestone id="M_delivered" name="交付完成"/>
<humanTask id="T_knowledge" name="人工知识入库">
<itemControl><manualActivationRule id="m_kb"/></itemControl>
<!-- 唯一的活:派一张执行单给采购,他填完提交 -->
<humanTask id="T_source" name="渠道寻源:派执行单给采购,收回供应商回价与比价结论">
<itemControl><repetitionRule id="r_src"/></itemControl>
</humanTask>
<timerEventListener id="TL_deadline" name="时限预警">
<timerExpression>P4D</timerExpression>
</timerEventListener>
<timerEventListener id="TL_quote_expiry" name="回价到期">
<timerExpression>P7D</timerExpression>
</timerEventListener>
<!-- 放行:触达世界,必须人工激活 -->
<humanTask id="T_release" name="寻源结论验收放行">
<entryCriterion id="ec_rel" sentryRef="s_compared"/>
<itemControl><manualActivationRule id="m_rl"/></itemControl>
</humanTask>
</casePlanModel>
</case>
</definitions>`;
export const NARRATIVE = `§0 开案:本案例覆盖四种询价场景 —— BOM早期、BOM后期、现货询价、样品询价。
场景不同,供应商选择尺度与价格验证规则各异,但都遵循同一主线:
PM 提交询价请求 → 查历史报价判定可否直采 →(须询价时)按场景圈渠道 → 发询验证 → 填入寻源比价表反馈。
export const NARRATIVE = `§0 开案:A1 判出一条料要寻源(无近期历史价、或行情波动),这一单就到你手上。
成品只有一样:**一份能拿去定价的比价结论** —— 几家报了什么价、交期多久、推荐哪一家、为什么。
S1 接需核史:系统带出在供供应商与历史报价。先核历史,按场景规则逐 PN 判定是否可直接采用 ——
可直采则跳过询价直接进入反馈交付;须新询价则进入选渠阶段。
· 查历史报价:未达 3 次询价但有效期已过的,标记须重询。
· 判定采用策略:BOM早期有效期内直采、过期浮动≤10%直采、10-20%取均价、>20%重询;
BOM后期历史最低(≥3次)直采,否则降 20% 谈;现货/样品一律重询。
**这一单只有一步**:把寻源这件事派给采购(执行工单),他填完你收进台账,然后提交放行。
四种询价场景(BOM 早期 / BOM 后期 / 现货 / 样品)的打法差别是**采购的判断**,
不是系统要管的步 —— 你在 ask 里说清是哪种场景就够了,别替他把流程铺开。
S2 选渠定策:BOM早期/样品渠道可放宽;BOM后期按主→备选→贸易商顺序圈 2~3 家,贸易商尽量不用;
现货仅选有库存且可提供仓库实拍的供应商。渠道资质核实是「看情况」的裁量项。
【T_source 派执行单】发 human 行动(workOrder="execution"),卡面 blocks 放**两张可编辑表**:
· 供应商回价:列 = 型号 / 供应商 / 回价 / MOQ / 交期 / 有效期;
· 寻源比价:列 = 型号 / 供应商 / 回价 / 结论。
两张都 editable:true;没有的值给空字符串 —— 空格子界面会自己标红「待填写」。
ask 说清要他做什么(哪颗料、什么场景、要几家、什么时候要);
basis 摆上型号、数量、目标价、客户交期要求(裸卡基座拒发)。
S3 发询比价:同一料所有渠道一次发齐;PN/数量/目标价/回价时限写死在询价里。
回价逐单登记,一一对应不可缺项。价格验证因场景而异。回价异常或未应答则追价补询(裁量项)。
他提交后,回传值出现在你的轨迹里:表格那一份的键是 **table:0 / table:1**(块序号),
值是整张表的 JSON —— **第一行是列名**,其余每行一条,列序与你派出去时一致。
照它 write 进 \`quote_sourcing.supplier_quote\` 与 \`quote_sourcing.compare_table\`,
并把比价表条数记进 compare_total。**写完就 finish T_source,别再发第二张单** —— 他已经答过了。
S4 反馈交付:确认报价/LT/SPQ/有效期逐项填入寻源比价表,供 PM 直接查阅。
现货/样品场景多一步下单与跟踪(裁量项)。
【提交放行】比价表有行了 —— **先 start T_release**(人工激活:放行触达世界,必须按一下),
然后 submit,BD 验收。终局唯放行:你不能自己宣布完成。
全程随时可做:知识入库 —— 回价与成交沉淀历史价格情报,渠道表现回写供应商渠道目录。
【一路上要说话】负责人正看着这一单。每一拍带一句 say,说**业务的话**:
哪颗料在寻源、找了几家、回价怎么样、这一单往哪走。
一两句、口语。别报「写草稿 2 行」这种系统词。`;
§Z 结案:必做的事都办完、没有进行中的事,自动算结案(CMMN AutoComplete)。
寻源比价表交付 PM 即结案 —— 每询一轮,行情底账厚一层。`;
/**
* 步骤剧本 —— 与 A1/A2 同一套机制:`doneWhen` 为真,引擎自己收步、**不问模型**。
* 放行那一项不给 `doneWhen` —— 它触达世界,必须人按一下(铁律③)。
*/
export const SOURCING_STEPS = [
{
item: 'T_source',
intent: '派一张执行单给采购,收回供应商回价与比价结论',
writes: ['quote_sourcing.supplier_quote', 'quote_sourcing.compare_table', 'quote_sourcing.verdict'],
reads: [],
tools: [],
/*
* 比价表有行了才算办完。**不能写 `compare_total != null`** ——
* S-FEEL 里未绑定的名字 `!= null` 求值为 true,那一步开局即被收掉(A1 踩过)。
*/
doneWhen: 'compare_total > 0',
guide: `**照这个骨架发,一个字段都别少**(prose 描述不管用,直接抄):
{"kind":"human","itemId":"T_source","workOrder":"execution","card":{
"title":"…","ask":"这颗料要寻源,请找几家报价并给比价结论",
"basis":[{"k":"…","v":"…"}],
"blocks":[
{"t":"table","title":"供应商回价","cols":["型号","供应商","回价","MOQ"],"editable":true,
"rows":[["NE555P","","",""],["NE555P","","",""]]},
{"t":"table","title":"寻源比价","cols":["型号","供应商","回价","结论"],"editable":true,
"rows":[["NE555P","","",""],["NE555P","","",""]]}
]}}
**一条料至少摆 2 行** —— 判据「BOM后期每料 2~3 家渠道」机检的就是渠道数,
只问一家过不了门(型号那列你先填,供应商与价格留给采购)。
**rows 必须有行**:一条料一行,不知道的值给空字符串(界面自己标红「待填写」)。
只给 cols 不给 rows,人看到的是一排表头加一片空白,一个格子都填不了(实测栽过)。
**blocks 少了整张卡就废了** —— 基座会用当下局面兜底成一句「需要你判断下一步」,
人拿到一张没有表的空卡,这一步等于没派(实测栽过)。
**rows 一条料一行**,型号那一列先填上;ask 说清什么场景、要几家、什么时候要;
basis 摆上型号、数量、目标价、交期要求(**裸卡基座拒发**)。
他提交后回传值进你的轨迹:表格的键是 **table:0 / table:1**,值是整张表的 JSON,
**第一行是列名**。照它 write 进两张台账,并把比价表条数记进 compare_total。
**同一拍再写一行 \`quote_sourcing.verdict\`**(验收机检读的就是它):
· supplier_total = 询了几家渠道(判据要 ≥2,**只问一家过不了门**);
· quoted_total / unquoted_total = 已回价 / 未回价条数(未回价要 = 0);
· compare_total = 比价表条数;recommend_supplier 推荐哪家;summary 一句人话结论。
这几个数写不对,submit 会被门挡回来 —— **它们不是台账装饰,是判据本身**。
**写完就 finish,别再发第二张单** —— 他已经答过了。
**每一行都要带 \`rfq_ref\`** —— 它是这一单的单标识(起单入参里就有,原样抄进去)。
台账主键第一列就是它:不写,这一行会和**别的单**同料号的那一行撞主键,把人家覆盖掉
(实测栽过:A4 的报价草案主键只有 pn,跑四轮只剩最后一轮的 3 行)。`,
},
{
item: 'T_release',
intent: '比价结论已齐,提交放行交 BD 验收',
writes: [],
reads: [],
tools: [],
guide: `比价表有行了 —— **先 start T_release**(它标了人工激活),然后 submit,BD 验收。
终局唯放行:你不能自己宣布完成。`,
},
];
-1
View File
@@ -12,7 +12,6 @@ const COMPARE = 'quote_sourcing.compare_table';
export default defineActivity({
name: 'quote-sourcing',
paradigm: 'loop',
async run(base: ActivityBase): Promise<ActivityOutcome> {
const quotes = await base.ledger.read(QUOTES);
+52 -6
View File
@@ -11,7 +11,7 @@ import type { ActivityManifest, DmnModel } from '@contracts';
import { SOURCING_SKILLS } from './skills.ts';
import { SOURCING_CASE } from './views/report.ts';
import { activityIdSchema } from '@contracts';
import { CMMN_XML, NARRATIVE } from './case-model.ts';
import { CMMN_XML, NARRATIVE, SOURCING_STEPS } from './case-model.ts';
const DOMAIN = 'quote_sourcing';
const ACTIVITY_ID = activityIdSchema.parse('quote-sourcing');
@@ -45,7 +45,7 @@ export const manifest: ActivityManifest = {
identity: {
activityId: ACTIVITY_ID,
name: 'A3 · 渠道寻源',
packageVersion: '0.1.3',
packageVersion: '2.0.0',
category: '统一报价',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-quote-sourcing',
@@ -57,8 +57,9 @@ export const manifest: ActivityManifest = {
input: {
data: {
type: 'object',
required: ['pn', 'scenario'],
required: ['rfqRef', 'pn', 'scenario'],
properties: {
rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' },
pn: { type: 'string' },
scenario: { type: 'string' },
quantity: { type: 'integer' },
@@ -78,13 +79,28 @@ export const manifest: ActivityManifest = {
],
selective: [],
},
requiredDeliverables: [{ key: 'compare-table', label: '寻源比价表' }],
/*
* 必交付物**必须给 `ledger` 地址**(2026-07-31 修)。
* 不给的话预检退回 `scope[key]` 去找 —— key 是 `benchmark-table` 这样的短横线别名,
* 任务视图里永远没有这个键,于是「必交付物缺一不得提交放行」永远拒,
* 活办完了也放不了行,连拒五拍熔断(实测 A2 死于此)。
*/
requiredDeliverables: [
{ key: 'compare-table', label: '寻源比价表', ledger: `${DOMAIN}.compare_table` },
],
/*
* 机检谓词读的是 `verdict` 那张**汇总表**的列(照 A1 的口径)。
* 上一版的名字(unquoted / channelsPicked)**台账里根本没有** —— S-FEEL 拿未绑定的名字
* 比大小求值为假,判据永远达不了标,submit 门永远拒,连拒五拍熔断。
* 判据的名字必须落在台账上,否则「机检」是一句空话(2026-07-31 销账)。
*/
acceptanceCriteria: [
{ key: 'each-has-quote', label: '每料有有效报价或明确无货', level: 'L2', predicate: 'unquoted = 0' },
{ key: 'channels-enough', label: 'BOM后期每料 2~3 家渠道', level: 'L2', predicate: 'channelsPicked >= 2' },
{ key: 'each-has-quote', label: '每料有有效报价或明确无货', level: 'L2', predicate: 'unquoted_total = 0' },
{ key: 'channels-enough', label: 'BOM后期每料 2~3 家渠道', level: 'L2', predicate: 'supplier_total >= 2' },
{ key: 'cross-check', label: '价格验证按场景规则做过', level: 'L3' },
{ key: 'prm-release', label: '采购放行', level: 'L5' },
],
revisableAtRelease: [],
prohibitions: [
{ key: 'no-stockless-trader', statement: '现货场景不得选无库存转单商' },
{ key: 'no-unverified', statement: '不实回价或远低行情的,必须换渠道补询,不得直接采用' },
@@ -110,8 +126,11 @@ export const manifest: ActivityManifest = {
{
domain: DOMAIN,
object: 'supplier_quote',
kind: 'record',
caseKey: 'rfq_ref',
label: '供应商回价单',
fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'pn', type: 'text', primaryKey: true, masked: false },
{ name: 'supplier', type: 'text', primaryKey: true, masked: false, label: '供应商' },
{ name: 'price', type: 'number', primaryKey: false, masked: false, label: '回价' },
@@ -124,14 +143,39 @@ export const manifest: ActivityManifest = {
{
domain: DOMAIN,
object: 'compare_table',
kind: 'record',
caseKey: 'rfq_ref',
label: '寻源比价表',
fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'pn', type: 'text', primaryKey: true, masked: false },
{ name: 'supplier', type: 'text', primaryKey: false, masked: false },
{ name: 'price', type: 'number', primaryKey: false, masked: false },
{ name: 'conclusion', type: 'text', primaryKey: false, masked: false, label: '结论' },
],
},
{
/*
* 寻源结论 —— 一单一行的**汇总表**,验收机检谓词的入参就在这儿。
* 汇总列与逐行列**故意不同名**:任务视图把草稿行摊平到顶层是「后写的赢」,
* 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。
*/
domain: DOMAIN,
object: 'verdict',
kind: 'record',
caseKey: 'rfq_ref',
label: '寻源结论',
fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'pn', type: 'text', primaryKey: false, masked: false, label: '型号' },
{ name: 'supplier_total', type: 'integer', primaryKey: false, masked: false, label: '已询渠道数' },
{ name: 'quoted_total', type: 'integer', primaryKey: false, masked: false, label: '已回价条数' },
{ name: 'unquoted_total', type: 'integer', primaryKey: false, masked: false, label: '未回价条数' },
{ name: 'compare_total', type: 'integer', primaryKey: false, masked: false, label: '比价条数' },
{ name: 'recommend_supplier', type: 'text', primaryKey: false, masked: false, label: '推荐供应商' },
{ name: 'summary', type: 'text', primaryKey: false, masked: false, label: '寻源结论' },
],
},
],
listeners: [
{
@@ -167,6 +211,8 @@ export const manifest: ActivityManifest = {
// 双表示同版本:XML 是机器面,说明书是叙事面 —— 不允许分叉
narrative: NARRATIVE,
dmn: STRATEGY_DMN,
/* 步骤剧本 —— 与 A1 同一套:doneWhen 真了引擎自己收步,不问模型 */
steps: SOURCING_STEPS,
},
skills: SOURCING_SKILLS,
viewTemplates: [{ name: 'case-overview', html: SOURCING_CASE }],
+89 -13
View File
@@ -1,25 +1,101 @@
/**
* A3 视图模版 —— 案例全景(AM 07:loop 按案例模型的**阶段与里程碑**组织汇报)。
* A3 渠道寻源的 HTML 汇报模版 —— **活动自己持有的资产**(§9.3),随包版本冻结。
*
* 画面素材全部来自语义引擎:阶段的生命周期态、当下可为(Enabled 集)、目标缺口。
* 这些是算出来的确定事实,零 LLM 即可成屏 —— 模型只决定「下一步做什么」,不负责画面。
* ── 这一屏给谁看、他此刻要判什么 ──────────────────────────────────
* 看的人是采购(PRM)与等着底价的 PM。他此刻只判一件事:**这价能不能拿去往下走**。
* 于是版面按「先成果、后局面」排:
* 一 供应商回价(收回来多少、都是什么渠道)· 二 寻源比价表(交回 PM 的成果本体)
* → 三 案例阶段 · 四 当下可为 · 五 目标缺口(AI 的办案局面)
* 指标带扫一眼就该有结论(回价够不够、比价表填没填),往下五节是「凭什么这么说」。
*
* ── 画面上的数从哪来(一个都不是编的)────────────────────────────
* · `mine:quote_sourcing.*` 是**本单自己写下的草稿行** —— 别单放行过的回价不混进来;
* 列名逐字取自 `declarations.ledger`,编一个不存在的列名 = 屏上永远空白。
* · `case:*` / `enabledCount` / `gapCount` / `activeStage` 由语义引擎每拍算出,
* 是确定事实、零 LLM 即可成屏 —— 模型只决定「下一步做什么」,不负责画面。
* · 指标带上「已收回价 / 比价表行数」两格指的是**数组本身**:填数时数组取长度
* (view-render 的 `displayValue` 约定),不必为「几单」另立一个计数字段。
*
* ── 两条不撒谎的纪律 ──────────────────────────────────────────
* ① 右上角挂的是「在办」灯不是印章:这一屏是活的局面,它没盖过章,就别画成盖了章的样子;
* 紫(tone `case`)是**案例活动的标记色不是状态色** —— 红绿橙只留给「达标没达标」。
* ② 表空着就是「本单还没写下这一行」,不是渲染坏了 —— 每张表的说明里都写明空着意味着什么。
* 寻源尤其怕这个:空白被读成「这家没报价」,而它可能只是还没登记。
*
* 容器是 `sandbox=""` iframe:无脚本、无外链,样式与图标由 `@sdk` 自带。
*/
import { kvCard, noteCard, stagesCard, viewShell } from '@sdk';
import { noteOnly, section, stagesOnly, statsBand, tableOnly, viewShell } from '@sdk';
export const SOURCING_CASE = viewShell({
title: '渠道寻源 · 案例全景',
kicker: 'A3 · 案例活动(loop)',
kicker: 'A3 · 渠道寻源 · 案例活动',
tone: 'case',
icon: 'mail',
stamp: { kind: 'live', text: '案例在办 · 非定稿' },
sign: 'A3 渠道寻源 · 本页每拍按当下局面重画',
body:
kvCard('本单标的', [
['型号 PN', 'pn', 'mono'],
['场景', 'scenario'],
['当前阶段', 'activeStage', 'tag'],
statsBand([
['已收回价', 'mine:quote_sourcing.supplier_quote'],
['比价表行数', 'mine:quote_sourcing.compare_table'],
['当下可为', 'enabledCount'],
['目标缺口', 'gapCount'],
]) +
stagesCard('阶段(CMMN)', 'case:stages', '阶段的生命周期态由语义引擎推进到 <b>Enabled</b> 为止;凡触达世界或产出关键交付的计划项必须人工启动 —— <b>Enabled ≠ Active</b>。') +
stagesCard('当下可为', 'case:enabled', 'AI 只能在这个集合里选下一步:<b>可以越谱,不可越权</b>。') +
stagesCard('目标缺口', 'case:gaps', '缺口只有人知道 → 调人;缺口在世界侧 → 立监听(必配时限)。当下可为 ∩ 目标所需 = 空,就是<b>依赖断点</b>。') +
noteCard('这个活动为什么是 loop', '寻源是<b>一事一议</b>:几家供应商、谁先回、要不要追加一轮,事前排不出谱。调度权交给 AI,系统只保证边框 —— 预算、停滞熔断、放行归人。'),
section('供应商回价(本单登记)',
tableOnly('mine:quote_sourcing.supplier_quote', [
['型号 PN', 'pn', 'mono'],
['供应商', 'supplier'],
['渠道等级', 'grade'],
['回价', 'price', 'mono'],
['交期', 'lead_time'],
['有效期', 'valid_until', 'mono'],
]) +
noteOnly(
'回价<b>逐单登记、一一对应不可缺项</b>:发出去几家就该收回几条,没回音的也要留一条写明' +
'「无货 / 未应答」—— 少一条,下游读到的就是「没这回事」。渠道等级是场景尺度落到纸面的地方:' +
'BOM后期按主 → 备选 → 贸易商圈 2~3 家、<b>贸易商尽量不用</b>,现货只选有库存的。' +
'「有效期」是回价到期监听盯的那一列,过期即触发重询。表空着,是本单还没登记回价。',
),
{ text: '一料一渠一行' },
) +
section('寻源比价表(本单交付件)',
tableOnly('mine:quote_sourcing.compare_table', [
['型号 PN', 'pn', 'mono'],
['供应商', 'supplier'],
['回价', 'price', 'mono'],
['结论', 'conclusion'],
]) +
noteOnly(
'这是交回 PM 的成果本体:一料一结论,多渠道横比定底。<b>不实回价或远低行情的必须换渠道补询</b>,' +
'不得直接采用;现货场景不得选无库存转单商。采用策略由判定表出(直采 / 取均价 / 重询),' +
'不是模型自己说了算。表空着,是本单还没走到反馈交付。',
),
{ text: '交回 PM 的成果' },
) +
section('案例阶段(CMMN 生命周期)',
stagesOnly('case:stages') +
noteOnly(
'阶段的生命周期态由语义引擎推进到 <b>Enabled</b> 为止;凡触达世界或产出关键交付的计划项' +
'必须人工启动 —— <b>Enabled ≠ Active</b>。寻源是<b>一事一议</b>:几家供应商、谁先回、' +
'要不要追加一轮,事前排不出谱,所以调度权交给 AI,系统只保证边框:预算、停滞熔断、放行归人。',
),
{ text: '推进到 Enabled 为止' },
) +
section('当下可为(AI 的合法选项)',
stagesOnly('case:enabled') +
noteOnly(
'AI 只能在这个集合里选下一步:<b>可以越谱,不可越权</b>。发询价信是<b>触达世界</b>的动作 ——' +
'标「待启动」的项要人按一下再动,信发出去就收不回来。这一栏空着不是它偷懒,' +
'是当下确实无可为:要么在等人回话,要么在等供应商的回音。',
),
{ text: '本拍的合法选项' },
) +
section('目标缺口(离交付还差什么)',
stagesOnly('case:gaps') +
noteOnly(
'缺口只有人知道 → 调人;缺口在世界侧(等供应商回价)→ 立监听,<b>时限必配</b> ——' +
'没有时限的等待就是无限期挂起,而回价的时限本就写死在询价信里。' +
'当下可为 ∩ 目标所需 = 空,就是<b>依赖断点</b>。这一栏空着,是必做的事都办完了。',
),
{ text: '空 = 必做的都办完了' },
),
});