Compare commits

..
10 Commits
Author SHA1 Message Date
xcsandClaude Fable 5 f3397a60ef E04 修到零被拒线,harness 补七刀:跨进程租约与广播桥、原子写、引用墙、对话区按序号排
活动侧(E04 2.1.0 → 2.1.9,I01 1.2.2):
- 覆盖闭合成为机检:基座新增 distinct 读数,「计划盖住的客户数 = 候选行数」钉进
  S3 收步 / S4 哨兵 / 放行 L2;人加的候选客户不再被整单无视
- 「关键人待识别」占位句式全链退役:查无联系人先发数据录入卡收人;契约新增 refersTo,
  计划/回执的 contact_code 只许引用真实存在的联系人(安装期核引用目标)
- 台账更新确认摆全落库面:客户档案/联系人档案两张落库预览(touched: 数据源,
  正式现值 + 草稿叠加),可补改、可加行(自增主键由基座续号);姓名升必填
- 更新明细:update_key 位置键 + 「同一处只留一条」L2;依据写人话不带机器键;
  标题旁不摆单号;判据与文案不夹字段名;行标识去掉单标识那一段
- 回执骨架 = 计划行数钉进 S3 收步与 S4 哨兵;重做分支不许删计划凑数
- 阶段刻度对齐文档 7 步;起单样例预填满

基座侧:
- 两个后端连同一个库:task-claim 跨进程租约(迁移 0030)+ Postgres LISTEN/NOTIFY
  广播桥;对话区按任务内记忆序号排,不按墙上时钟;并卡「刚说过话」按记忆序列判紧邻
- 多行 write 整批先验后写(dryRun),被拒 = 一行都没落
- 决策卡意见与本单草稿字面比对,归属以观察进轨迹(note-anchors)
- 括号栈补「补闭括号」两形(少一个 } / 行对象没关直接跟下一行)
- 件上加行:空着的自增主键直进续号,不从别的行借号;未落下的行点名回给前端
- 人答复落库那一刻就把 runtime 翻 running(task.wake),两块同帧变脸
- 试跑单可落真库(land_master,迁移 0031,默认开);数据域按活动分组;
  JSON 列不再是 [object Object];按钮键感 + 在办进度条 + 防抖
- 报价链 A1–A7 从系统卸载(只卸不删源码),种子只装 E04/I01

契约变更(api-report 已刷新):FieldDeclaration.distinct / refersTo,
objectDeclaration.file,landMaster,unkeyed,assetFiles,ledgerByActivityRoute。
1700 测 / typecheck / lint / depcruise / api:check 绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
2026-08-25 01:23:30 +08:00
XiaChangSong 92dd729c36 Merge pull request '资料文件真产出来了;E04/I01 补到验收线,顺手填了几个「墙拦住却不给路」的坑' (#1) from feat/e04-i01-asset-files into mvp-foundation
Reviewed-on: ChaiHao/BPOS-MVP#1
2026-08-24 10:34:07 +08:00
xcsandClaude Opus 5 0aa2fc11b0 资料文件真产出来了;E04/I01 补到验收线,顺手填了几个「墙拦住却不给路」的坑
**资料文件全链**(用户:审核资料这一步要真产出 word/excel/md/html,前端能渲、点资料名能开)
接手时这一步一个文件都产不出来 —— S3 剧本白纸黑字写着「file_ref 本 MVP 阶段可留空」。
文件是台账那一行的投影,不另存一份:
· 声明 `file:{titleColumn,bodyColumn,formatColumn}`,它同时是授权面(没声明就点不开)
· 现造真字节 —— docx/xlsx 是自己打的 OOXML 包(zip-write:store+CRC32,零依赖)
· 读回来渲 —— 解的就是造出来那份字节,渲得出来才证明文件是真的
· 件上 `data-file-of` → 工作区二级界面(Word 纸面/Excel 网格/渲好的 md/沙箱 html)+ 下载原件
放行落地前把每一份冻进 `sys.task_asset_file`(迁移 0029):归档件点开的必须是签字那一刻
那一份,而资料落在主数据上、试跑单不落正式表 —— 不存这一份,收工后一个都点不开。
工作成果里「产出的资料」自成一摞。Markdown 富文本渲染顺带销账(仍零依赖)。

**Harness**
· 画面按「这一拍办完之后」在办的那一步挑 —— 从前拿拍首的局面决定拍尾的事:
  收掉 S4、发出 S5 的卡,而屏上还摆着 S4 的只读件,人无处可填(墙要配路第四次现形)
· 验收「重做」真能退回重做,且打回后台账没动就不许再提交(软硬两段,不锁死单子)
· 立监听的授权面是清单声明;发卡的授权面是调人预算,不是计划项
· 读工具同参重调有兜底;空转写身后还有行动就不废串;finish 的 doneWhen 按写完后的草稿算

**界面**
· 翻件与翻页各归各位(从前二选一,多卡多页时页翻不动);没得翻写「—」不写 0/0
· 工作成果详情能翻份;表宽超出能横滑(E04/I01 抄版式时把 overflow-x 抄丢了)
· 查看面灰底滚过一屏变白 —— 两条独立的路都治了(沙箱内不同色 / 弹性子项不长个)
· 测试台「取不到」不再当成「还活着」

**活动**
E04 关系覆盖、I01 品牌内容各自补到验收线:判据全读基座数、判据指向的东西摆上卡面、
样例满载且名字 ≤14 字;提交前要裁的判据搬进说明书(模型会压拍,压过之后只有说明书还在)。

验收:e2e i01 4/4、e04 3/3 全部 released,资料在办中渲得出、放行后存了档点得开;
门禁 typecheck/lint/depcruise/1628 测全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
2026-08-24 10:26:45 +08:00
ChaiHaoandClaude Opus 5 70e83d0b09 工作日志:一行摆首句,展开就把话说完
从前一条只有一行标题,而人真想回看的东西全躺在留痕里没露过面 ——
凭什么算这一步办完了、那一笔落下哪几行、被拒到底拒在哪儿、发的是哪种卡送到哪儿。
现在每条可展开,细节由 detailOf 摘成**一句句人话**(不摆 execution 这类内部名);
摘不出就是空数组,前端据此不给展开钮 —— 点开是空抽屉比不给还糟。

不摆二级条目(用户定):细节不是「键:值」两栏 —— 那是把留痕的字段名搬到人面前,
人还得自己在脑子里连成句子。每句自带主语与语境,单拎出来也读得通。

连带修一处文档与实现不符:「一轮一句」的口径说埋头办事那几拍的交代「留进工作日志」,
而实现把 internal 整类滤掉了,那几句哪儿都看不到。现在带交代的放行,
章写「交代进展」、记在 AI 名下;排障用的内部痕照旧不摆。
两种交代统一「首句上行、全文进抽屉」,一句话说完的不重复摆;截了才加省略号。

契约:任务详情的日志行新增可选 detail(字符串数组),api-report 同批更新。
门禁:typecheck / lint / depcruise / test(1378)全绿;浏览器实测折叠行高 34-36px 齐平、
展开无键值两栏残留。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 13:10:46 +08:00
ChaiHaoandClaude Opus 5 ade0fa5a94 A2-A7 全部补齐到验收线;顺手把几处「门拦住却不给路」的坑填了
活动:A2 选型 / A3 寻源 / A4 定价 / A6 议价 / A7 归档五件各补成真活动包 ——
契约宽进(input.required 空,归一在活动内)、台账必填声明(含行级 requiredWhen)、
入场哨兵与 L1/L2 判据一律读基座数、两份件(在办可编辑 + 办结静态快照)、
三案例常备、包内自测。e2e 各三案例 released、行动被拒 0、发口正确、提交闸每单真拦。

基座(共享面,改一处影响所有活动,故每处都带回归):
· 重发判断走在站上场之前 —— 人答完那步常已收掉,readyToWork 先抛「不能再往它上面挂活」,
  于是「重发已答过的卡 = 无事发生」这条唯一活路在最需要时够不着,A1 基线当场熔断;
· 决策卡补幂等兜底(与执行工单同一把尺):头两次无事发生,第三次起才算被拒;
· 输出没成形先就地重问一次,原话进工作日志、不再摆上卡面;
· 工具调用读数 tool_<名>_calls —— 「机检只读基座数」这条纪律第一次覆盖到工具;
· 执行卡 steps/standard 补进出参 schema(从前能到卡面全靠厂商 JSON 模式宽松);
· 件的就地更新改逐节点 morph,保存先把新值镜像进只读那份 —— 不闪、不等回声。

A1(基线,全程守住):措辞三档对齐(必填/建议/选填)、禁止拿历史价当客户目标价、
卡骨架从步骤剧本搬进说明书(压成一拍连做时剧本没摊开,会发出没有指导书的裸卡)。

契约两处**新增可选字段**(非破坏):FieldDeclaration.requiredWhen、工单 finalHtml;
api-report 同批更新。docs/adr 与 docs/archive 的 13 份文件在工作树里不见了,
本次予以恢复而非提交删除。

门禁:typecheck / lint / depcruise / test(1366)全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:28:31 +08:00
ChaiHaoandClaude Opus 5 85466e815f 活动开发规范第三版(按四轴重组)+ HTML报告生成规范单独成册
给「任务活动开发 agent」沉淀的两份操作手册,基准是用户验收过的 A1(quote-intake@42,
三条形状不同的案例 released、被拒 0)。

活动开发规范.md 第三版,按四条轴重组:
· 解耦机制 —— 活动与系统之间只有安装通道;三份声明=三个授权面;
  边界纪律「别替下游产出」(随令牌流向下游的只有验收过的成果);
· Harness 契约 —— 台账三写一撤与字段四标记(required/expect/tally/auto)、
  基座读数全表与机检纪律(三次违反史逐条记账)、一路的门与闸
  (起单宽进/提交闸两端/提交门/放行门哨兵/熔断)、拒回话术三分、五类卡、
  phases/三色日志/一轮一句、放行落地;
· Loop —— 局面组成、行动十四型全表、串纪律、人工激活与 start
  (「话说的范围要盖住门拦的范围」)、doneWhen 自动收步;
· 活动怎么写 —— 清单六区(ledgers/editableAtRelease/判据措辞/内部一致性不上卡)、
  建模十纪律(哨兵 ifPart、阶段全覆盖补进)、剧本 steps 六字段与 doneWhen 三坑、
  guide 写作十条(一单一卡、卡骨架照抄、纯文本)、三常备案例与验收口径。
排障速查表并入这一轮全部新账(14 行新增)。

HTML报告生成规范.md 初版(用户点名单独成册,并强调报告与表单的关系):
· 一份 HTML 三个身份:件(看)/表单(改)/归档件(存)—— 同一份,分开必分叉;
· 报告与卡的关系立成硬规矩:逐行数据归件、整单级归卡;编辑在件、提交在卡;
  同一个数管三处(件上红带/卡上闸/放行门 L1 同源);保存链路与三条实测教训;
· 绑定语言全表、不算不判(按值上色/按条数分宽/按数开关)、编辑态四件套
  (闸/孪生件/增删行/缺项红带)、PRD 平移法、浏览器走查清单、陷阱表。

CLAUDE.md 文档权威地图指向两份新版。五道门禁全绿(975 passed)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsTDjoHovERXyy6Wmw1ALp
2026-08-02 15:30:40 +08:00
ChaiHaoandClaude Opus 5 ec47fd46a0 现状快照记上这一轮最后两条,日期改到 2026-08-02
「话说的范围要盖住门拦的范围」那一条(模型漏 start 的根因)与 A1 三案例常备,
是这一轮唯二还没进快照的。快照抬头还写着 07-30,而里面已经记到 08-02。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsTDjoHovERXyy6Wmw1ALp
2026-08-02 15:15:04 +08:00
ChaiHaoandClaude Opus 5 a9e66ceb39 话说的范围要盖住门拦的范围;报告上把缺哪几格红字直说
三件,都是「说的和做的不是同一件事」这一类的又一次现形。

**为什么模型会漏掉 start** —— 提示词那条规矩写窄了。
原文:「标了「须你启动」的项要先 start **再调工具**」——只说了调工具;
而拦截那一侧(`readyToWork`)管的是 tool / ask / human / decide / entry / finish 一整片。
模型那一拍要发的是 `human`(派工单),照字面读这条规矩管不着它,于是漏了 start,
撞上「S3_confirm 标了人工激活,要先 start 再干活」白烧一拍。
**话窄一格,模型就正好漏在那一格上。** 现在写全:动它之前先 start,发卡与 finish 都算;
只有 write 例外(它的 itemId 只是记账,挂不上照写)。

**目标价左对齐** —— 右对齐是给要竖着相加的那种数用的;这张表一行一个料、彼此不相加,
右对齐只让它跟自己的表头分了家:标题贴左、数贴右,中间空一条,眼睛来回找。
等宽字体留着(位数对齐仍有用),对齐跟表头走。

**报告上把缺哪几格红字直说** —— 从前只在空格子里摆一枚小小的「必填」胶囊,
九行料摊开就淹在表里;而人打开这一屏就是来填的。现在表上方一条红带,
点名还差几格、差在哪一行的哪一列。两个数由基座现算(`_missing` / `_missing_keys`),
与提交闸、放行门 L1 同一个数;**模版不算也不判** —— 「一格不缺就别出这条带」
交给样式表(`data-class-of` → `v-0` 那档 display:none)。
浏览器验过两态:缺 1 格时 v-1 显示,从件上补齐后重渲变 v-0 自动收起。

五道门禁全绿(975 passed)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsTDjoHovERXyy6Wmw1ALp
2026-08-02 15:04:33 +08:00
ChaiHaoandClaude Opus 5 3cf9f10b86 进度条补回漏掉的那一格;删掉一条已经不存在的路
用户送来一条实测日志:中间被拒四次,卡片进度条也走得不对。两处都不是模型跑偏。

**进度条漏了一格** —— `INTAKE_PHASES` 里没有 `S3_confirm`(它是 08-01 才补回成一项的):
计划项六个、名单里五个。于是 S2 一收,前两格全过,条当场跳到「判定去向」;
而人这会儿正对着受理需求审定那张卡 —— **他手上办的那件事在条上没有格子**,
条却已经走过去了。那不是走快了一格,是它在说一件没发生的事。
补成四格(定位客户 / 归一受理 / 交你审定 / 判定去向)+ 基座统一加的放行收口;
包内自测钉死「除放行外每一项恰好属于一个阶段」「一项不进两个阶段」。

**那四次拒回是一条已经不存在的路** —— 说明书里还留着「人交回来还缺必填,就再发一张卡」。
提交闸上线后那条路没有了:缺必填根本按不下提交。模型照它办,每次都撞在
「这件事刚问过,人也答过了」上,白烧四拍。改成「这张卡一单只发一次」。
同批把提交闸补到**服务端**(收下回传之前把住):说明书向模型承诺
「卡回来时必填一定是齐的」,那句承诺只有服务端拦得住才算数。

A1 三案例复跑:9 拍 / 10 拍 / 14 拍,前两个**被拒 0**(修前是 12 拍被拒 1),
进度条五格逐格走对。五道门禁全绿(975 passed)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsTDjoHovERXyy6Wmw1ALp
2026-08-02 14:13:24 +08:00
ChaiHaoandClaude Opus 5 259192f736 人手改的那一手真正落得下去;界面不撒谎那几处补齐
手改是**兜 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
2026-08-02 13:41:44 +08:00
347 changed files with 62275 additions and 3877 deletions
+76
View File
@@ -0,0 +1,76 @@
---
name: html-one-pager
description: 为 JCAI 活动设计并产出可直接在浏览器打开的 standalone HTML one-pager 目标件(活动执行页/验收页/汇报页)。照仓里 PRD 方法论做「带脚本演示页」:JCAI 家族版式(PRD_CSS 三层壳)、写死示例数据、可带演示脚本让编辑态活起来;零外链,尾部带平移指引,可按《HTML报告生成规范》平移进活动包。只要用户提到:给某个活动(A1-A7 或新活动)做执行页、验收页、工单页、one-pager、目标件、报告设计稿、HTML 样稿,或要求「把活动报告做得精美/有设计感/突出结构」——即使没说 one-pager 三个字,也要用本技能。
---
# html-one-pager —— 活动执行/验收页的目标件
## 定位:设计答案,不是提前交付的模版
产出 = 一份 standalone .html,照仓里 PRD 目标件的方法论做成**带脚本演示页**:
浏览器直接打开、示例数据写死、演示脚本让编辑态与重算活起来(仓里两份 A1 原型
`~/coding/JCAI_PRD/docs/活动执行页/` 就是这么做的)。设计定稿后由活动开发者按
`docs/HTML报告生成规范.md` §7 平移进活动包 —— 剥脚本、换绑定,不归本技能管。
实测教训(2026-08-03 一轮评测得来的):模型 + 仓库本身就能出用户认可的好件,
**版面与信息设计不需要本技能立规矩** —— 立多了反而把件做小(列缩到现有台账、
数字照存储口径摆、按钮是死的)。本技能只补三样:**省力**(装壳脚本)、
**可平移**(平移指引)、**底线**(自检)。其余放开发挥。
## 四步
### 1. 看范本定基调
打开整件读版面节奏(抄节奏不抄字):
- 文件件基调:A1 `activities/quote-intake/src/views/report.ts`(执行/验收/归档三张)
- slide 基调:A2 `activities/quote-alternative/src/views/report.ts`(一页一屏、深空 hero)
- 原型出处:`~/coding/JCAI_PRD/docs/活动执行页/`(带脚本演示页的原始形态)
- 组件名录:`references/block-catalog.md`(点菜用,以仓内源为准)
- 业务素材:`docs/MVP示例工作流.html`(示例数据要像真的)
可选:跑 ui-ux-pro-max 拿设计方向(`references/uipm-recipes.md`,注意五不取:
字体外链/JS 库/换色/图标库/CDN —— 查询结果只进脑子不落盘)。
### 2. 装壳
```bash
python3 <本技能目录>/scripts/assemble.py --out draft.html \
--activity <目标活动id> --title <件名> --kicker "统一报价 · <活动> · <身份>"
```
PRD_CSS 约 30KB,**手搬必静默截断**,所以走脚本抽。目标活动自带 `views/prd-style.ts`
时优先抽它自己的,缺了落回 A1。
### 3. 设计上行,尽情画
- **业务需要什么就摆什么**:来源标签、档位列、命中留痕、数轴、走廊图 —— 不受现有
台账声明限制。现有台账没有的列,**不缩版面**,记进平移指引的「建模欠账」清单
(目标件本该驱动建模:A1 就是先画件、后立表的)。
- **数字按人读形态摆**:9.2% 不是 0.092,让价 −0.17 元不是裸差值;台账口径与呈现
口径不一致的,也记欠账。判定表带「本单命中留痕」(哪条规则命中了哪几颗料)。
- **演示脚本可以有**:集中在 `<script>` 标签里(不写内联 `on*=`,平移一剥就净);
初始画面不靠脚本也完整(脚本只做演示交互:开编辑、改数重算、切状态)。
- **能映射现有台账的格子,顺手预埋真钩子**(`data-slot`/`data-col`/`data-edit`,
语义见规范 §4/§6)—— 省平移的事,但**不为它牺牲版面**。
- 家族底线:令牌不换色(--brand #3370ff 等)、三层壳一层不少、图标一律精灵 SVG
(缺的照 lucide 线稿新增 symbol)、无外链、报告上不摆英文内部名、无 emoji。
### 4. 自检交付
```bash
python3 <本技能目录>/scripts/check.py draft.html
```
13 条底线全绿(它不判设计,版面好不好交给人眼);浏览器打开截图走查一遍。
交付单文件,尾部**必带**「平移指引」注释段(骨架自带固定格式):取哪段、剥什么
(脚本/示例值)、建模欠账清单、钩子键名对着目标活动 `manifest.ts` 核一遍。
## 陷阱速查
| 症状 | 根因 → 出路 |
|---|---|
| 整张样式一条不生效 | 三层壳少了一层 → `.proto-root>.app-shell>.jc-stage>article.jc-report` 一层不能省 |
| 版面「像又不像」家族 | PRD_CSS 手搬截断(样式半死极难定位)→ 用 assemble.py;或没读范本自创版面 → 整件抄节奏 |
| 平移后行为丢了一半 | 演示行为写成了内联 on*=,散在标记里剥不净 → 全部集中进 script 标签 |
| 平移时不知道哪些列是编的 | 设计上行摆了台账没有的列却没记账 → 平移指引里「建模欠账」逐列点名 |
@@ -0,0 +1,69 @@
<!doctype html>
<!--
JCAI one-pager · 目标件骨架(html-one-pager 技能产出起点)
· 浏览器直接打开;设计定稿后按 docs/HTML报告生成规范.md §7 平移进活动包
· 照 PRD 方法论这是「带脚本演示页」:演示脚本集中写在文尾一个 script 标签里
(不写内联事件),平移时整段剥掉;初始画面不靠脚本也要完整
-->
<html lang="zh-CN" class="editable">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@TITLE@ · JCAI one-pager 目标件</title>
<style>
/* ⇩ standalone 包装层:真件跑在工作区 iframe 里,这几行由宿主环境承担。平移时丢弃 */
html, body { margin: 0; padding: 0; }
/* ⇧ 丢弃到此 */
/*@PRD_CSS@*/
/*@BIND_CSS@*/
/* ⇩ ONE_CSS · 本件自有补丁:平移时随模版走(补在 BIND_CSS 之后,只补不改) */
/* ⇧ ONE_CSS 到此 */
</style>
</head>
<body>
<!-- ⇩⇩ 模版片段从此开始:平移进 views/report.ts 的就是这一段(样式与精灵由 doc() 工厂另拼) -->
<!--@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"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-@BADGE@"/></svg></div>
<div class="jc-rp-tit">
<em>@KICKER@</em>
<h3>@TITLE@ <span>@REF@</span></h3>
</div>
<span class="jc-rp-wait">@STATE@</span>
</header>
<div class="jc-rp-bd">
<!-- 正文区块从这里画。基调与组件抄仓内范本(A1/A2 report.ts、JCAI_PRD 原型),
名录见技能 references/block-catalog.md;设计上行 —— 业务要什么摆什么,
现有台账没有的列记进文尾平移指引的「建模欠账」。 -->
</div>
<footer class="jc-rp-foot">
<div class="jc-rp-sign">
<span class="jc-rp-ava"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-user"/></svg></span>
<div><b>冯兵</b><span>AI 拟稿,你审定 —— 落款说明按件改写</span></div>
</div>
<span class="jc-done-tag"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-git-branch"/></svg>@KICKER@</span>
</footer>
</article>
</div></div></div>
<!-- ⇧⇧ 模版片段到此为止 -->
<!-- 演示脚本写在这里(可选):开编辑、改数重算、切状态。平移时整段剥掉。 -->
<!-- ═══ 平移指引(交付必带;照 docs/HTML报告生成规范.md §7 平移法)═══
① 取「模版片段」两枚边界注释之间的标记进 views/report.ts;样式与精灵改由 doc() 工厂拼
(PRD_CSS/BIND_CSS 用活动自己的 views/ 那份,ONE_CSS 段随本件带走,补在最后)
② 剥掉所有 script 标签与写死的示例数据;逐行数据换 data-rows 行模版,取值换 data-slot,
可改格换 data-col + data-edit(语义见规范 §4/§6;本件已预埋的钩子逐个核对)
③ 建模欠账(按件填写):本件摆了、现有台账还没有的列/呈现口径,逐条点名 ——
该加哪张表哪一列、哪些列该标 required、存储口径要不要改成人读形态
④ 绑定键名逐字核对目标活动 manifest.ts 的 declarations.ledger(列名照抄,不照记忆写)
═══ -->
</body>
</html>
@@ -0,0 +1,49 @@
{
"skill_name": "html-one-pager",
"evals": [
{
"id": 1,
"eval_name": "a4-pricing-exec",
"prompt": "A4 成本定价那几张报告还是老外壳,太素了。给它设计一张执行页的 one-pager 目标件:核价单——人要逐行核 AI 算出来的价,单价和毛利那几格要能就地改;P-01 利润模型、P-02 底价门禁是怎么判的要摆清楚,但那部分是说明,不许人碰。参考 A1 现在那套报告的做法,做成 standalone HTML,示例数据写死,浏览器打开就能看。仓库在 /Users/chaihao/coding/JCAI-WOS。",
"expected_output": "一份 standalone HTML:JCAI 家族版式(三层壳+PRD_CSS),编辑区是可就地改的核价表(编辑闸/缺项红带/胶囊/加一行),说明区讲 P-01/P-02 的判定规则;零脚本零外链,自带真绑定属性与 demo 行标记,尾部有平移指引。",
"files": [],
"assertions": [
"无外链(样式/字体/图标/脚本全内联)",
"无内联事件处理器(脚本集中在 script 标签里,平移一剥就净)",
"standalone 可直接打开(doctype + charset)",
"样式全内联(<style> 在)",
"样式未被手搬截断(<style> 总量 ≥ 25KB)",
"三层壳依序(proto-root → app-shell → jc-stage → jc-report)",
"家族令牌未换色(--brand: #3370ff)",
"图标是内联精灵(<symbol id=\"i-…\"> + <use href=\"#i-…\">)",
"表头不摆 snake_case 内部名(列名用人读名)",
"正文不带 markdown 星号(**)",
"有 @keyframes 就有 prefers-reduced-motion 守卫",
"无 emoji(图标一律精灵 SVG)",
"交付带平移指引(文内含「平移指引」注释段)"
]
},
{
"id": 2,
"eval_name": "a6-negotiate-release",
"prompt": "给 A6 报价议价做一张验收工单的 one-pager:放行前最后一屏——正式报价单草稿(币种、有效期、交期、付款条件这些条款,验收人还能就地改)+ 历轮报价往来(只读,给人当尺子)+「放行即发出」的后果说明。要有验收归档件的仪式感,参考 A1 归档那张。standalone HTML、写死示例数据,浏览器打开就能看。仓库在 /Users/chaihao/coding/JCAI-WOS。",
"expected_output": "一份 standalone HTML:验收/归档基调(仪式感题头+落款+落地动作),编辑区是报价条款表(四件套齐),说明区含历轮往来(只读)与放行后果;零脚本零外链,自带真绑定属性,尾部有平移指引。",
"files": [],
"assertions": [
"无外链(样式/字体/图标/脚本全内联)",
"无内联事件处理器(脚本集中在 script 标签里,平移一剥就净)",
"standalone 可直接打开(doctype + charset)",
"样式全内联(<style> 在)",
"样式未被手搬截断(<style> 总量 ≥ 25KB)",
"三层壳依序(proto-root → app-shell → jc-stage → jc-report)",
"家族令牌未换色(--brand: #3370ff)",
"图标是内联精灵(<symbol id=\"i-…\"> + <use href=\"#i-…\">)",
"表头不摆 snake_case 内部名(列名用人读名)",
"正文不带 markdown 星号(**)",
"有 @keyframes 就有 prefers-reduced-motion 守卫",
"无 emoji(图标一律精灵 SVG)",
"交付带平移指引(文内含「平移指引」注释段)"
]
}
]
}
@@ -0,0 +1,66 @@
# 组件名录 —— 点菜用
**用法:打开出处文件抄结构,别凭记忆写。** 行号会漂移,漂了就按名字 grep;
名录与仓内源冲突时,以源为准。两种基调:文件件(A1)与 slide 件(A2),按件的性格选。
原型的原始形态(带脚本演示页)在 `~/coding/JCAI_PRD/docs/活动执行页/`——
版面节奏与演示脚本怎么配合,整件读它最直观。
## 基调一 · 文件件(A1,`activities/quote-intake/src/views/report.ts`)
纸面、章节头、逐段读下去 —— 执行页/验收页/归档页的默认基调。
| 组件 | 出处(report.ts) | 是什么 · 何时用 |
|---|---|---|
| `doc()` 外壳三层 | `:265-289` | `<style>`+精灵+`.proto-root>.app-shell>.jc-stage>article.jc-report`+题头/正文/落款。骨架已内置同构,读它是为了懂结构 |
| `part()` 章节头 | `:292` | `<b>标签</b><i class="ln">细线</i><em>右端小签</em>`——每个区块一条;编辑区的右端小签挂「可就地修订」 |
| `sign()` 落款 | `:296` | 头像+署名(`case:owner`)+右端 `jc-done-tag` 流位签。签写「这份件在流里的位置」,不写状态 |
| `ico()` 图标 | `:258` | `<use href="#i-名">` 引精灵 symbol |
| `LETTER` 来源信件 | `:309` | `jc-letter`:收件签+归属 chip+正文 pre-wrap 原样照录+附件行(`data-rows="attachments"`) |
| `four()` 要素四卡 | `:330` | `jc-four > jc-fcard ×4`;含 `jc-logo/jc-face` 方圆标(方=组织、圆=人,里面放图标)、`jc-count` 大数、`jc-segmeter` 一格一条料的刻度条 |
| `listTable()` 可编辑表 | `:385` | `jc-tbl-wrap > table.jc-tbl-in`;`cell()` 工厂出 `req/opt/dimempty` 三态格;序号格 `jc-rn` 一列两用(读=行号,编辑=撤行 ×) |
| `gapBar` 缺项红带 | `:438` | 「还差 N 格必填没补——差在哪」;挂 `data-class-of`,`v-0` 那档样式表隐藏 |
| `editGate()` 编辑闸 | `:457` | 锁定说明+编辑/取消/保存三钮+`jc-editbar` 怎么改提示带 |
| `addRowBar()` 加一行 | `:475` | 表尾虚线条;文案讲清「主键填了才存得下」 |
| `DIST` 分布条 | `:501` | `jc-dist`:三段按条数分宽(`data-class-of` 投影 v-N 走宽度梯子)+图例 |
| `dmnRow()/G2` 判定表 | `:598-628` | `jc-dmn` 按序求值三行+`jc-verdict` 去向横幅;命中行由壳上 `v-r1/r2/r3` 上色,「命中/未命中」两句都写在模版里、哪句现身由 class 定 |
| `jc-acts` 落地动作 | `:679-693` | 「这一签下去会发生什么」三条;验收页收尾用 |
| 说明带 | `:526-541` | `jc-derive`(这些数从哪来)/`jc-drop`(填报口径与边界);还有 `jc-callout/jc-hintline/jc-lockhint` 见 prd-style.ts |
| 三张成品 | `:516 / :548 / :630` | INTAKE_OVERVIEW 执行页 / INTAKE_CLASSIFIED 中间态 / INTAKE_RELEASE 验收归档一张顶两身份——整件抄版面节奏最快的路 |
**区块顺序的家族习惯**(INTAKE_RELEASE 是最全样本):来源 → 要素卡 → 分布/图形 →
清单表(编辑区)→ 判定与去向 → 落地动作 → 落款。执行页砍掉判定/落地,多说明带。
## 基调二 · slide 件(A2,`activities/quote-alternative/src/views/report.ts`)
一页一屏、深空 hero、强视觉 —— 汇报页/对比页用。规范 §8.5 是它的成文版。
| 组件 | 出处 | 是什么 |
|---|---|---|
| `ALT_WORKSHEET` | `:450` | 替代选型工作底稿(slide 语言成品) |
| `ALT_ROSTER` | `:516` | 名册页成品 |
| `data-page` 翻页 | `:335` | 多页件的页标记(`article.jc-duelrow` 一页一段),宿主按可见段分页 |
hero 规矩:深底允许局部自备色组,但须与家族蓝紫呼应;`danger/ok/warn` 三色
不得挪作气氛渲染(它们承载判定语义)。
## 修饰类速查(prd-style.ts 全量定义,这里只列常用)
- 排版:`.mono`(等宽)`.num`(数值,**左对齐**,与表头同侧)`.dim` `.sp` `.ln`
- 语义色:`.ok .warn .bad` · 卡面色调 `t-teal / t-ok / t-warn / t-mute`
- 格三态:`req`(必填,空了红胶囊)`opt`(选填,空了灰胶囊)`dimempty`(归档,空=「—」)
- 投影后缀:`v-a/v-b/v-c`(类三色)`v-0..v-20`(计数:开关与宽度梯子)`v-r1/r2/r3`(判定命中)
## 精灵 19 枚(`prd-style.ts` PRD_SPRITE)
`alert arrow-right boxes building check copy file-check file-text git-branch history
info lock mail-open pen plus sheet shield-check user x`
缺图标怎么办:照 lucide 线稿风格(24×24 viewBox、stroke currentColor、round 线帽)
**新增 symbol 进本件的精灵**,命名 `i-<名>`;平移时提醒活动开发 agent 给该活动的
prd-style 补同名 symbol。不引图标库、不用 emoji。
## 概念图形
内联线稿 SVG(stroke currentColor、1.5-2 px 线宽)是报告的第一语言:流程走位、
门前门后、数据流向,能画就画。图形也遵守不算不判——形状写死在模版里,
只有上色/显隐可走 `data-class-of` 投影。
@@ -0,0 +1,50 @@
# 目标件约定 —— 设计答案怎么交,平移才接得住
目标件是**带脚本演示页**(照仓里 PRD 方法论):第一身份是给人看、给人认可的设计答案,
第二身份是活动开发者平移的底稿。权威契约在 `docs/HTML报告生成规范.md`,本文只讲
目标件这一侧的交付约定。
## 1. 平移边界(骨架已标好)
| 段 | 平移时 |
|---|---|
| doctype / head / 包装层 CSS(`⇩ standalone 包装层` 标记段) | 丢弃 |
| PRD_CSS / BIND_CSS(assemble.py 抽来的) | 丢弃 —— 真模版从活动自己的 `views/` import |
| ONE_CSS(本件自有补丁段) | **随模版走**,补在 BIND_CSS 之后;纪律与 BIND_CSS 同:只补不改 |
| `⇩⇩ 模版片段` 到 `⇧⇧` 之间的标记 | 就是要平移的正文 |
| 所有 `<script>` 标签 | **整段剥掉**(所以演示行为别写成内联 `on*=`,散在标记里剥不净) |
| 尾部「平移指引」注释 | 给活动开发者读,不平移 |
`<html class="editable">` 是给编辑态样式(闸/红带)显形用的;生产里由宿主挂,不进模版。
## 2. 演示脚本的三条规矩
1. **集中**:全部写在文尾的 `<script>` 标签里,标记面零内联事件(check.py 会拦);
2. **初始画面自足**:脚本删掉后页面内容仍然完整正确 —— 脚本只做演示交互
(开编辑、改数重算、切状态),不负责造内容;
3. **不引外部**:无 CDN、无外链(check.py 会拦)。
## 3. 设计上行与建模欠账
版面服务业务,不服务现有台账:来源标签、档位、命中留痕、数轴 —— 业务需要就摆,
数字按人读形态(9.2% 不是 0.092)。代价是一笔账要记清:**平移指引的「建模欠账」清单**,
逐条点名本件摆了、现有台账还没有的列与口径(该加哪张表哪一列、哪些该 required、
存储口径要不要改人读形态)。欠账不记,平移的人分不清哪些是真数据哪些是设计愿望。
能映射现有台账的格子,顺手预埋真钩子(`data-slot`/`data-col`/`data-edit`,键名逐字抄
目标活动 `manifest.ts` 的 `declarations.ledger`)—— 省平移的事,但不为它牺牲版面。
## 4. 示例数据真实感
- PN 像 PN(`SS8050`、`TL072CDR`),价格量级对(阻容几分几毛、IC 几块几十),
年用量千/万级;业务素材见 `docs/MVP示例工作流.html`;
- 人名/公司名用中文;报告上不摆系统内英文名(状态/表/列/口),表头一律人读名;
- 演示的数自己要对得上账(偏离度 = (拟报−基准)÷基准 这类,逐行可复算);
- 不编造没有来源的数据画面:真系统里没有写入方的数据,如实空着并写明为什么。
## 5. 自检与走查
- `python3 scripts/check.py 目标件.html` 13 条底线全绿(它不判设计);
- 浏览器走查:整页读一遍(版面像不像家族、信息层次立不立得住)→ 点一遍演示交互
(编辑开不开、改数重算对不对)→ 拉窄窗口看响应式 → 尾部平移指引读一遍
(欠账清单是否把编的列全点到了)。
@@ -0,0 +1,50 @@
# ui-ux-pro-max 查询菜谱
ui-ux-pro-max 是本机的插件技能:一个 BM25 检索的本地设计库(84 风格 / 192 配色 /
74 字体对 / 98 UX 守则 / 104 图标 / 25 图表)。**我们只用它的查询脚本拿设计方向,
不装载它的整套工作流**——它的持久化步骤会往仓库写文件,字体/动效建议也与 JCAI
契约相抵,见下面的过滤表。
## 定位与调用
```bash
UIPM=$(ls -d ~/.claude/plugins/cache/ui-ux-pro-max-skill/ui-ux-pro-max/*/.claude/skills/ui-ux-pro-max 2>/dev/null | sort -V | tail -1)
python3 "$UIPM/scripts/search.py" "<query>" --design-system -p "<件名>" --density 7
```
**缺席兜底**:`$UIPM` 为空(插件没装/被移除)时不装死——按「A1 版面语言 + 中高密度」
的默认继续走,并在交付说明里写一句「设计库缺席,走了默认」。
## 查询配方
| 场景 | query 建议 | 旋钮 |
|---|---|---|
| 执行页(密表核对) | `enterprise b2b workflow document review data table form` | `--density 7`(仪表密度,信息多) |
| 验收/归档页(仪式感) | `invoice quotation official document archive certificate` | `--density 5 --variance 4` |
| 汇报/对比 slide | `executive summary comparison dashboard presentation` | `--density 6 --variance 6` |
| 单域深挖 UX | `--domain ux` + `form table dense data review` | `-n 5` |
| 单域深挖版面风格 | `--domain style` + `editorial minimal document swiss` | `-n 5` |
0 结果:换一次关键词;仍空就走默认并明说,不编造「数据库推荐」。
## 三不用(命令行层面)
- `--persist` / `--output-dir`:会往仓库写 `design-system/<slug>/MASTER.md` —— 污染仓库
- `--motion`:产出 GSAP JS 片段,而目标件零脚本
- `--stack`:我们不是在选框架,产物是纯 HTML/CSS
## 五不取 · 四要取(结果层面)
| 它给的 | 取舍 | 为什么 |
|---|---|---|
| 字体对 + Google Fonts 外链 | **不取** | 目标件零外链;字体族固定 PRD_CSS 系统栈(Inter…PingFang SC) |
| 配色替换 | **不取** | JCAI 家族令牌零改动(--brand #3370ff 等);`v-*` 语义色承载判定语义 |
| GSAP/JS 动效 | **不取** | 零脚本。可译成纯 CSS 的意图(时长/缓动)可参考,且已有 reduced-motion 守卫 |
| 图标库引用 | **不取** | 一律 PRD_SPRITE 内联 symbol(见 block-catalog) |
| 任何 CDN/外部资源 | **不取** | 安装门与自检都拦 |
| 版面构图(分栏/bento/层级) | **取** | 这正是要它出的「精美组件布局突出结构」 |
| 组件形制(卡/带/表头/徽章的形) | **取** | 译成 JCAI 令牌下的 CSS |
| 密度/留白档 | **取** | 执行页密、归档页缓 |
| UX 守则(对比度/触达面积/反馈位置) | **取** | 出稿前对照检查 |
**取的东西只进脑子,不落盘**——查询结果不存文件,消化成设计决定写进件里。
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""拼一张 JCAI one-pager 骨架(html-one-pager 技能 · 第 3 步「装壳」)。
从仓库源文件机械抽取三段静态模板字符串 —— PRD_CSS / PRD_SPRITE(views/prd-style.ts)
与 BIND_CSS(views/report.ts)—— 填进 assets/skeleton.html 的占位符,产出一份
浏览器直接可开的 standalone 骨架。
为什么必须脚本抽:PRD_CSS 约 30KB,手搬必静默截断,截断后样式半死、极难肉眼定位。
抽取优先级:--activity 指定的活动自带 views/prd-style.ts 就抽它自己的
(A1/A2 各持一份且已各自演化,错族的版面「像又不像」);缺了才落回 A1(quote-intake)。
用法:
python3 assemble.py --out draft.html
python3 assemble.py --out draft.html --activity quote-pricing \
--title 核价单 --kicker "统一报价 · A4 成本定价 · 过程成果" --badge file-check
"""
import argparse
import re
import sys
from pathlib import Path
SKILL_DIR = Path(__file__).resolve().parent.parent # .../.claude/skills/html-one-pager
REPO = SKILL_DIR.parents[2] # 仓库根
FALLBACK = 'quote-intake'
def extract(path, name):
if not path.is_file():
return None
m = re.search(r'(?:export\s+)?const\s+' + name + r'\s*=\s*`([\s\S]*?)`;',
path.read_text(encoding='utf-8'))
return m.group(1) if m else None
def pick(activity, filename, name):
tried = []
order = ([activity] if activity and activity != FALLBACK else []) + [FALLBACK]
for act in order:
p = REPO / 'activities' / act / 'src' / 'views' / filename
tried.append(str(p))
got = extract(p, name)
if got is not None:
return got, act
sys.exit('抽不到 %s(试过:%s)。仓库根按脚本位置推得 %s;不对就检查技能目录层级,'
'或 grep -rn "const %s" activities/ 找真实位置。' % (name, ' | '.join(tried), REPO, name))
def main():
ap = argparse.ArgumentParser(description='拼 JCAI one-pager standalone 骨架')
ap.add_argument('--out', required=True, help='输出 HTML 路径')
ap.add_argument('--activity', default='',
help='目标活动 id(activities/<id>);它自带 prd-style.ts 就优先抽它的')
ap.add_argument('--title', default='工作单标题')
ap.add_argument('--kicker', default='统一报价 · 活动名 · 过程成果')
ap.add_argument('--ref', default='REF-2026-000')
ap.add_argument('--state', default='在办 · 示例状态灯')
ap.add_argument('--badge', default='file-text',
help='题头图标名(精灵 symbol 名,不带 i- 前缀;19 枚清单见 block-catalog.md)')
a = ap.parse_args()
css, css_src = pick(a.activity, 'prd-style.ts', 'PRD_CSS')
sprite, sprite_src = pick(a.activity, 'prd-style.ts', 'PRD_SPRITE')
bind, bind_src = pick(a.activity, 'report.ts', 'BIND_CSS')
if len(css) < 15000:
print('⚠ PRD_CSS 只有 %d 字节(常态约 30KB)—— 抽取源可能不完整,继续但请人工核一眼' % len(css))
skeleton = (SKILL_DIR / 'assets' / 'skeleton.html').read_text(encoding='utf-8')
out = (skeleton
.replace('/*@PRD_CSS@*/', css)
.replace('/*@BIND_CSS@*/', bind)
.replace('<!--@SPRITE@-->', sprite)
.replace('@TITLE@', a.title)
.replace('@KICKER@', a.kicker)
.replace('@REF@', a.ref)
.replace('@STATE@', a.state)
.replace('@BADGE@', a.badge))
dest = Path(a.out).expanduser()
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(out, encoding='utf-8')
print('已产出 %s(%.1f KB)' % (dest, len(out.encode('utf-8')) / 1024))
print(' PRD_CSS %d 字节 ← %s · BIND_CSS %d 字节 ← %s · 精灵 %d 枚 symbol ← %s'
% (len(css), css_src, len(bind), bind_src, sprite.count('<symbol'), sprite_src))
if __name__ == '__main__':
main()
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""JCAI one-pager 目标件的底线自检 —— 只守底线,不管设计。
目标件是「带脚本演示页」(照仓里 PRD 方法论):允许 <script> 做演示交互,
平移进活动包时整段剥掉。这把尺只拦真出事的东西:外链、内联事件(剥不净)、
壳错族、样式截断、英文内部名、没带平移指引。版面好不好交给人眼,不归它判。
用法:
python3 check.py 目标件.html # 人读表格,全 PASS 退出码 0,否则 1
python3 check.py 目标件.html --json # [{text,passed,evidence}],评测 grading 直接吃
"""
import argparse
import json
import re
import sys
from pathlib import Path
EMOJI = re.compile('[\U0001F000-\U0001FAFF☀-➿️]')
def strip_scripts(html):
return re.sub(r'<script[\s\S]*?</script>', ' ', html, flags=re.I)
def visible_text(html):
"""人看得见的正文:去脚本、去注释、去 <style>,再剥标签。"""
h = strip_scripts(html)
h = re.sub(r'<!--[\s\S]*?-->', ' ', h)
h = re.sub(r'<style[\s\S]*?</style>', ' ', h, flags=re.I)
return re.sub(r'<[^>]*>', ' ', h)
def run(path):
html = Path(path).read_text(encoding='utf-8')
markup = strip_scripts(html) # 剥掉脚本后的标记面(内联事件/外链在这上面查)
text = visible_text(html)
styles = ''.join(re.findall(r'<style[^>]*>([\s\S]*?)</style>', html, re.I))
checks = []
def c(name, passed, evidence=''):
checks.append({'text': name, 'passed': bool(passed), 'evidence': str(evidence)})
m = re.search(r'https?://', html, re.I)
c('无外链(样式/字体/图标/脚本全内联)', not m, m.group(0) if m else '0 命中')
m = re.search(r'\son[a-z]+=', markup, re.I)
c('无内联事件处理器(脚本集中在 script 标签里,平移一剥就净)', not m,
m.group(0) if m else '0 命中')
c('standalone 可直接打开(doctype + charset)',
html.lstrip().lower().startswith('<!doctype html') and '<meta charset' in html.lower(),
html.lstrip()[:24])
c('样式全内联(<style> 在)', '<style' in html.lower())
c('样式未被手搬截断(<style> 总量 ≥ 25KB)',
len(styles.encode('utf-8')) >= 25000, '%d 字节' % len(styles.encode('utf-8')))
c('三层壳依序(proto-root → app-shell → jc-stage → jc-report)',
bool(re.search(r'proto-root[\s\S]*app-shell[\s\S]*jc-stage[\s\S]*jc-report', html)))
c('家族令牌未换色(--brand: #3370ff)', bool(re.search(r'--brand:\s*#3370ff', html)))
c('图标是内联精灵(<symbol id="i-…"> + <use href="#i-…">)',
'<symbol id="i-' in html and '<use href="#i-' in html)
m = re.search(r'<th[^>]*>\s*[a-z0-9_]{2,}\s*<', markup)
c('表头不摆 snake_case 内部名(列名用人读名)', not m, m.group(0) if m else '无')
c('正文不带 markdown 星号(**)', '**' not in text)
c('有 @keyframes 就有 prefers-reduced-motion 守卫',
('@keyframes' not in styles) or ('prefers-reduced-motion' in styles))
c('无 emoji(图标一律精灵 SVG)', not EMOJI.search(text))
c('交付带平移指引(文内含「平移指引」注释段)', '平移指引' in html)
return checks
def main():
ap = argparse.ArgumentParser()
ap.add_argument('file')
ap.add_argument('--json', action='store_true')
a = ap.parse_args()
checks = run(a.file)
if a.json:
print(json.dumps(checks, ensure_ascii=False, indent=2))
else:
for k in checks:
mark = 'PASS' if k['passed'] else 'FAIL'
tail = (' —— ' + k['evidence']) if (k['evidence'] and not k['passed']) else ''
print('%s %s%s' % (mark, k['text'], tail))
n = sum(1 for k in checks if k['passed'])
print('%d/%d 通过' % (n, len(checks)))
sys.exit(0 if all(k['passed'] for k in checks) else 1)
if __name__ == '__main__':
main()
+10
View File
@@ -8,3 +8,13 @@ dist/
.env.local
coverage/
.vite/
# 本地开发日志(./start.sh 写的)
.dev-logs/
# html-one-pager 技能的评测工作区(skill-creator 产物,不入库)
.claude/skills/html-one-pager-workspace/
# 浏览器走查的截图与日志(本地取证用,不入库)
.playwright-mcp/
/*.png
+1284 -9
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,267 @@
/**
* I01 品牌形象与销售工具包管理的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。
* 案例活动没有 `run`,自测不跑回合;测的是清单本身与四处对齐
* (判据谓词 / 台账声明的三级占位 / 决策卡循环的骨架 / 报告模版的读写面),
* 方法照抄 E04(`crm-relationship-coverage`)已验证过的测试结构。
*/
import { describe, expect, it } from 'vitest';
import { activityManifestSchema } from '@contracts';
import { createMockHost } from '@sdk';
import { manifest } from '../src/manifest.ts';
import activity from '../src/index.ts';
describe('清单本身合规', () => {
it('过 @contracts 的 schema', () => {
expect(() => activityManifestSchema.parse(manifest)).not.toThrow();
});
it('没有悬空监听(I01 本轮没有静态监听声明)', () => {
expect(manifest.declarations.listeners).toEqual([]);
});
it('L1/L2 判据都给了可机检的谓词 —— 验收等级不得谎报', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef, c.key).toBeDefined();
}
});
it('L4 判据不给谓词 —— 是裁量项,不是机检项', () => {
const l4 = manifest.contract.acceptanceCriteria.filter((c) => c.level === 'L4');
expect(l4.length).toBe(3);
for (const c of l4) expect(c.predicate, c.key).toBeUndefined();
});
it('禁区短而硬,授权边界给了调人预算', () => {
expect(manifest.contract.prohibitions.length).toBeGreaterThan(0);
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(0);
});
it('每件必交付物都有台账地址', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(d.ledger, d.key).toBeDefined();
}
expect(manifest.contract.requiredDeliverables).toHaveLength(3);
});
/*
* **人环预算必须严格大于「最后一次拍板」时的实际调人次数**(照抄 E04 2026-08-20 实测栽过
* 那条纪律):基线三次(S2 / S4 通过 / T_release)+ 两轮决策卡循环(每轮 +2)= 7,留够余量。
*/
it('人环预算留够余量:基线三次 + 两轮决策卡循环仍在 cap 之内', () => {
const baseline = 3;
const twoRejectRounds = baseline + 4;
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(twoRejectRounds);
expect(manifest.contract.resources.budget.humanCalls).toBeGreaterThan(twoRejectRounds);
});
it('起单只必填 trigger,reason/requester/targetCustomer/note 都可选 —— 两级下拉的形状', () => {
expect(manifest.contract.input.data['required']).toEqual(['trigger']);
const properties = manifest.contract.input.data['properties'] as Record<
string,
{ enum?: unknown[]; showWhen?: { field?: string; equals?: string } }
>;
for (const key of ['trigger', 'reason', 'requester', 'targetCustomer', 'note']) {
expect(properties[key], `入参少了 ${key}`).toBeDefined();
}
expect(properties['reason']?.showWhen).toEqual({ field: 'trigger', equals: 'proactive' });
expect(properties['requester']?.showWhen).toEqual({ field: 'trigger', equals: 'reactive' });
});
it('四条样例形状真不同(触发路径各不相同),各自的入参都能通过对应 showWhen 校验', () => {
// 第四条是「资料文件」那一环的常备案例(一封需求要三种形态,格式分得开)
expect(manifest.samples).toHaveLength(4);
const shapes = manifest.samples.map((s) => JSON.stringify(s.input));
expect(new Set(shapes).size).toBe(4);
const proactive = manifest.samples.find((s) => (s.input as { trigger?: string }).trigger === 'proactive');
expect(proactive).toBeDefined();
const reactive = manifest.samples.find((s) => (s.input as { trigger?: string }).trigger === 'reactive');
expect(reactive).toBeDefined();
expect((reactive?.input as { requester?: string }).requester).toBe('E04');
expect((reactive?.input as { targetCustomer?: string }).targetCustomer).toBe('CU-0142');
});
it('三张视图模版都声明了,案例模型每一步引用的 view 都在其中', () => {
const names = new Set(manifest.assets.viewTemplates.map((v) => v.name));
expect(names).toEqual(new Set(['requirement-list', 'draft-review', 'publish-confirm']));
for (const step of manifest.assets.businessModel.steps as ReadonlyArray<{ view?: string }>) {
if (step.view !== undefined) expect(names.has(step.view), step.view).toBe(true);
}
});
});
describe('台账声明:三级占位与自有台账', () => {
const byAddress = (domain: string, object: string) =>
manifest.declarations.ledger.find((l) => l.domain === domain && l.object === object);
it('L-01 市场信号只读:本活动不写它,字段不带 required/expect', () => {
const signal = byAddress('market', 'signal');
expect(signal).toBeDefined();
for (const f of signal?.fields ?? []) {
expect(f.required, f.name).not.toBe(true);
expect(f.expect, f.name).not.toBe(true);
}
});
it('L-05 内容资产:I01 唯一读写者,必填列齐全(content_type/title/language/version/content_status)', () => {
const asset = byAddress('content', 'asset');
expect(asset).toBeDefined();
const required = new Set(
(asset?.fields ?? []).filter((f) => f.required === true).map((f) => f.name),
);
for (const name of ['content_type', 'title', 'language', 'version', 'content_status']) {
expect(required.has(name), name).toBe(true);
}
const status = asset?.fields.find((f) => f.name === 'content_status');
expect(status?.tally).toEqual(['draft', 'reviewed', 'published', 'deprecated']);
});
it('内容需求清单主键是 round_ref + req_seq(没有天然外部键,照抄 E04 ledger_update.update_seq 的处置)', () => {
const requirement = byAddress('content_brand', 'requirement');
const keys = (requirement?.fields ?? []).filter((f) => f.primaryKey).map((f) => f.name);
expect(keys).toEqual(['round_ref', 'req_seq']);
});
it('优先级是 ASCII tally(high/medium/low),报告靠 data-class-of 投影,不靠模型写中文', () => {
const requirement = byAddress('content_brand', 'requirement');
const priority = requirement?.fields.find((f) => f.name === 'priority');
expect(priority?.tally).toEqual(['high', 'medium', 'low']);
});
});
describe('案例模型与清单的四处对齐', () => {
it('步骤剧本的 item 名与 CMMN humanTask id 一一对应', () => {
const stepItems = (manifest.assets.businessModel.steps as ReadonlyArray<{ item: string }>).map((s) => s.item);
const cmmnIds = [...manifest.assets.businessModel.cmmnXml.matchAll(/<humanTask id="([^"]+)"/g)].map((m) => m[1] ?? '');
expect(stepItems).toEqual(cmmnIds);
});
it('六个计划项:1 执行 + 2 验收(S2 执行工单,S4/T_release 验收工单)', () => {
const cmmnIds = [...manifest.assets.businessModel.cmmnXml.matchAll(/<humanTask id="([^"]+)"/g)].map((m) => m[1] ?? '');
expect(cmmnIds).toEqual([
'S1_identify_requirement',
'S2_requirement_confirm',
'S3_gather_and_draft',
'S4_draft_review',
'S5_publish_prep',
'T_release',
]);
});
it('三处不给 doneWhen(交人核对/要人拍板/签下去触达世界),其余三处都给了', () => {
const steps = manifest.assets.businessModel.steps as ReadonlyArray<{ item: string; doneWhen?: string }>;
const noDoneWhen = new Set(['S2_requirement_confirm', 'S4_draft_review', 'T_release']);
for (const s of steps) {
if (noDoneWhen.has(s.item)) expect(s.doneWhen, s.item).toBeUndefined();
else expect(s.doneWhen, s.item).toBeDefined();
}
});
it('S2/S4/T_release 三项在 CMMN 里都标了人工激活(manualActivationRule)', () => {
for (const id of ['S2_requirement_confirm', 'S4_draft_review', 'T_release']) {
const block = new RegExp(`<humanTask id="${id}"[\\s\\S]*?</humanTask>`).exec(manifest.assets.businessModel.cmmnXml)?.[0] ?? '';
expect(block, id).toContain('manualActivationRule');
}
});
it('除放行外每一项恰好属于一个阶段(进度条刻度不重不漏)', () => {
const stepItems = (manifest.assets.businessModel.steps as ReadonlyArray<{ item: string }>)
.map((s) => s.item)
.filter((item) => item !== 'T_release');
const phaseItems = (manifest.assets.businessModel.phases ?? []).flatMap((p) => p.items);
expect([...phaseItems].sort()).toEqual([...stepItems].sort());
expect(new Set(phaseItems).size).toBe(phaseItems.length);
});
it('说明书点名的计划项都在案例模型里真实存在(没有臆造的 S-id)', () => {
const stepItems = new Set((manifest.assets.businessModel.steps as ReadonlyArray<{ item: string }>).map((s) => s.item));
// 只认「S数字_字母」这种完整计划项形状 —— 裸的「S1」「S1/S2/S3」是自然语言里的口语简写
// (如「见下方 S1 的 guide」),不是在点名一个具体的计划项 id,不该被当成引用校验。
const mentioned = [...manifest.assets.businessModel.narrative.matchAll(/\b(S\d_[a-zA-Z_]+|T_release)\b/g)].map((m) => m[1] ?? '');
for (const name of mentioned) {
expect(stepItems.has(name), `说明书提到但不存在的计划项: ${name}`).toBe(true);
}
});
it('决策卡骨架(S4_draft_review)三种 value 与说明书一致:approve/reject/custom:', () => {
expect(manifest.assets.businessModel.narrative).toContain('"kind":"decide","itemId":"S4_draft_review"');
expect(manifest.assets.businessModel.narrative).toContain('"approve"');
expect(manifest.assets.businessModel.narrative).toContain('"reject"');
expect(manifest.assets.businessModel.narrative).toContain('"custom:"');
});
/*
* **T_release 的 judge criterion 必须逐字抄 acceptanceCriteria 的 label**(2026-08-21
* 实测逼出):剧本写了缩写版"版本号与状态是否正确",清单的 label 是
* "版本号与状态是否正确:版本号已更新、状态流转无误"——两边对不上,模型照剧本裁的那句
* 匹配不到清单上的判据,提交放行永远差一条"[L4] 还没裁过",连拒五拍熔断。
* 这条测试钉死:T_release 的 guide 里每一句 `judge criterion="..."` 都要能在
* acceptanceCriteria 的 label 集合里原样找到。
*/
it('T_release 剧本里 judge criterion 的原文,逐字对得上 acceptanceCriteria 的 label', () => {
const tRelease = (manifest.assets.businessModel.steps as ReadonlyArray<{ item: string; guide?: string }>).find(
(s) => s.item === 'T_release',
);
const quoted = [...(tRelease?.guide ?? '').matchAll(/judge criterion="([^"]+)"/g)].map((m) => m[1] ?? '');
expect(quoted.length).toBeGreaterThan(0);
const labels = new Set(manifest.contract.acceptanceCriteria.map((c) => c.label));
for (const q of quoted) expect(labels.has(q), q).toBe(true);
});
});
describe('报告模版:编辑面与只读面各就各位', () => {
it('内容需求清单可编辑:data-edit 与 data-col 都在', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'requirement-list')?.html ?? '';
expect(html).toContain('data-edit="content_brand.requirement"');
expect(html).toContain('data-col="content_type"');
});
it('内容初稿审核只读:没有任何 data-edit / data-col —— 人只审不改', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'draft-review')?.html ?? '';
expect(html).not.toContain('data-edit=');
expect(html).not.toContain('data-col=');
});
it('发布确认只读:没有任何 data-edit / data-col —— 归档件不给改', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'publish-confirm')?.html ?? '';
expect(html).not.toContain('data-edit=');
expect(html).not.toContain('data-col=');
});
it('每个可编辑格子的 data-col 都在对应台账对象的声明里 —— 编辑面不能超出声明的字段', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'requirement-list')?.html ?? '';
const cols = new Set([...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1] ?? ''));
const declared = new Set(
manifest.declarations.ledger.find((l) => l.domain === 'content_brand' && l.object === 'requirement')?.fields.map((f) => f.name),
);
for (const c of cols) expect(declared.has(c), c).toBe(true);
});
it('状态徽(优先级/内容状态)只用 data-class-of 投影,不写 data-slot 重复暴露原值', () => {
const req = manifest.assets.viewTemplates.find((t) => t.name === 'requirement-list')?.html ?? '';
expect(req).toContain('<span class="jc-tag" data-class-of="priority"></span>');
const draft = manifest.assets.viewTemplates.find((t) => t.name === 'draft-review')?.html ?? '';
expect(draft).toContain('<span class="jc-tag" data-class-of="content_status"></span>');
});
it('宿主拒推含脚本的模版', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
});
});
describe('活动入口', () => {
it('没有 run —— 行为全在清单里', () => {
expect(activity.run).toBeUndefined();
});
});
const CONTRACT = {
activityId: 'brand-content-toolkit',
version: '1.0.0',
data: {},
message: undefined,
outputPorts: { mandatory: ['content-published'], selective: [] },
prohibitions: [],
discretion: [],
} as const;
@@ -0,0 +1,287 @@
/**
* 步骤剧本与呈现面 —— **这段错了不报错,只是活动开始转圈 / 界面开始撒谎**
* (与 quote-* / E04 各包同一套惯例)。
*
* 这一份是 2026-08-23 按 E04 的标准审 I01 时补的:每一条都对应审出来的一处真问题。
*/
import { describe, expect, it } from 'vitest';
import { evaluateCondition } from '@sfeel';
import { CMMN_XML, CONTENT_PHASES, CONTENT_STEPS, NARRATIVE } from '../src/case-model.ts';
import { DRAFT_REVIEW, PUBLISH_CONFIRM, REQUIREMENT_LIST } from '../src/views/report.ts';
import { PRD_CSS } from '../src/views/prd-style.ts';
import { manifest } from '../src/manifest.ts';
const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map(
(m) => m[1] ?? '',
);
describe('剧本与案例模型对得上', () => {
it('每一条剧本都指向一个真实存在的计划项,图上每一步也都有剧本', () => {
for (const step of CONTENT_STEPS) expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
for (const id of ITEM_IDS) expect(CONTENT_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
});
it('清单里挂的就是这份剧本与这份阶段(不是另抄一份)', () => {
expect(manifest.assets.businessModel.steps).toBe(CONTENT_STEPS);
expect(manifest.assets.businessModel.phases).toBe(CONTENT_PHASES);
});
});
describe('剧本声明的读写面 ⊆ 清单声明 —— 越出去的地址运行时才炸', () => {
const declaredAddresses = new Set(manifest.declarations.ledger.map((d) => `${d.domain}.${d.object}`));
const declaredTools = new Set(manifest.declarations.tools.map((t) => t.name));
it('steps[].writes / steps[].reads(台账那几个)全落在声明过的对象上', () => {
for (const step of CONTENT_STEPS) {
for (const address of step.writes) {
expect(declaredAddresses.has(address), `${step.item} 要写 ${address},清单没声明`).toBe(true);
}
for (const address of step.reads.filter((r) => r.includes('.'))) {
expect(declaredAddresses.has(address), `${step.item} 要读 ${address},清单没声明`).toBe(true);
}
}
});
it('steps[].tools 全部是声明过的工具(本活动一个工具都不用,那就都得是空)', () => {
for (const step of CONTENT_STEPS) {
for (const tool of step.tools) expect(declaredTools.has(tool), `${step.item} 要调 ${tool}`).toBe(true);
}
});
});
describe('doneWhen —— 开局不许真,干完必须真', () => {
const fresh = (input: Record<string, unknown>): Record<string, unknown> => ({
...input,
content_brand_requirement_rows: 0,
content_asset_rows: 0,
content_asset_missing: 0,
content_asset_content_status_published: 0,
});
it('**开局一步都不该被自动收掉** —— 三条样例的入参形状逐一试', () => {
for (const sample of manifest.samples) {
for (const step of CONTENT_STEPS) {
if (step.doneWhen === undefined) continue;
const ripe = safeEval(step.doneWhen, fresh(sample.input as Record<string, unknown>));
expect(ripe, `样例「${sample.name}」下 ${step.item} 的 doneWhen 开局就为真`).toBe(false);
}
}
});
it('需求写下了 → S1 收得掉;初稿有行且必填齐 → S3 收得掉,缺一头都收不掉', () => {
expect(safeEval(doneOf('S1_identify_requirement'), { content_brand_requirement_rows: 2 })).toBe(true);
expect(safeEval(doneOf('S3_gather_and_draft'), { content_asset_rows: 1, content_asset_missing: 0 })).toBe(true);
expect(safeEval(doneOf('S3_gather_and_draft'), { content_asset_rows: 1, content_asset_missing: 1 })).toBe(false);
});
it('拟发布:记账旗 + 已发布行数 + 必填齐,三样缺一不可', () => {
const done = doneOf('S5_publish_prep');
expect(
safeEval(done, { publish_prepared_total: 1, content_asset_content_status_published: 1, content_asset_missing: 0 }),
).toBe(true);
expect(
safeEval(done, { publish_prepared_total: 1, content_asset_content_status_published: 0, content_asset_missing: 0 }),
).toBe(false);
// 场景/BT 标签是「发布态必填」—— 缺了会进 _missing,这一步就收不掉
expect(
safeEval(done, { publish_prepared_total: 1, content_asset_content_status_published: 1, content_asset_missing: 1 }),
).toBe(false);
});
});
describe('这一轮审出来的口径 —— 改口要连测试一起改', () => {
/*
* **两条 L1 各钉一半基座数**:旗是模型写的,单独成谓词就是「机检只读基座数」的伪装形
* (E04 那单抢跑写旗、活单熔断,正是踩着这半边过的门)。
*/
it('两条 L1 都不只读记账旗,各自还钉着基座数', () => {
for (const key of ['requirement-confirmed', 'draft-approved']) {
const found = manifest.contract.acceptanceCriteria.find((c) => c.key === key);
expect(found?.predicate, `${key} 没有谓词`).toBeDefined();
expect(found?.predicate).toMatch(/_rows > 0/);
expect(found?.predicate).toMatch(/_missing = 0/);
}
});
/*
* **判据指向的东西必须摆在卡面上**(E04 那一课):L4「场景与 BT 标签是否登记完整」
* 从前让人核一个 `content.asset` 里压根不存在的列。
*/
it('BT 标签有列、发布态必填、两份验收件上都摆得出来', () => {
const asset = manifest.declarations.ledger.find((d) => d.domain === 'content' && d.object === 'asset');
const bt = asset?.fields.find((f) => f.name === 'bt_tag');
expect(bt, 'content.asset 缺 bt_tag').toBeDefined();
expect(bt?.requiredWhen).toEqual({ column: 'content_status', value: 'published' });
// 不带 tally —— 件上是普通格子、没有翻译层,存 ASCII 人看到的就是 ASCII(scene 踩过)
expect(bt?.tally).toBeUndefined();
expect(DRAFT_REVIEW).toContain('data-slot="bt_tag"');
expect(PUBLISH_CONFIRM).toContain('data-slot="bt_tag"');
});
it('场景也是发布态必填 —— S5 剧本说「发布前必须登记」,声明就得钉得住', () => {
const asset = manifest.declarations.ledger.find((d) => d.domain === 'content' && d.object === 'asset');
expect(asset?.fields.find((f) => f.name === 'scene')?.requiredWhen).toEqual({
column: 'content_status',
value: 'published',
});
});
/*
* **主键列必挂 `data-col`** —— 它不是编辑面,是**行的钥匙**(仓里立过的纪律,A1 从头就带)。
* 这一条是浏览器实测逼出来的:一度把序号列的 `data-col` 拿掉「让它只读」,
* 结果人改的老行也没了钥匙 —— 服务端只好去猜,把它当成新增行、又拿「全表一致」
* 猜出个 1,**当场覆盖掉原来那一行**。钥匙必须随行回传;
* 「人加的新行序号空着」那一半由基座续号(`resolveRowKey`)接住。
*/
it('需求清单的序号列挂着 data-col(行的钥匙),内容类型是可改的必填格', () => {
expect(REQUIREMENT_LIST).toContain('data-slot="req_seq"');
expect(REQUIREMENT_LIST).toContain('data-col="req_seq"');
expect(REQUIREMENT_LIST).toContain('data-col="content_type"');
});
it('放行剧本带「被打回重做」那一支,且写面覆盖要改的那几张表', () => {
const release = CONTENT_STEPS.find((s) => s.item === 'T_release');
expect(release?.guide).toContain('验收被打回重做');
for (const address of ['content.asset', 'content_brand.verdict']) {
expect(release?.writes, `T_release 改不了 ${address}`).toContain(address);
}
expect(release?.guide).toContain('覆盖写');
});
});
describe('三份工单模版照说明书 §6.2 的共用动态位', () => {
const ALL = [REQUIREMENT_LIST, DRAFT_REVIEW, PUBLISH_CONFIRM];
it('卡头:收件人 + 关联任务;side:步骤行;页脚:单号 —— 三份都有', () => {
for (const view of ALL) {
expect(view).toContain('data-slot="case:owner"');
expect(view).toContain('data-slot="case:taskId"');
expect(view).toContain('jc-rp-step');
expect(view).toContain('data-slot="round_ref"');
}
});
it('执行工单的编辑闸照目标件措辞:「AI 已识别 N 个内容需求」,N 是基座读数', () => {
expect(REQUIREMENT_LIST).toContain('AI 已识别');
expect(REQUIREMENT_LIST).toContain('data-slot="content_brand_requirement_rows"');
// 触发口径并进【做什么】,不再单开一段(照目标件的 note 结构)
expect(REQUIREMENT_LIST).not.toContain('【本轮触发】');
});
it('样例 0 是满载的那条,且每条样例名 ≤14 字(超了测试台只显示「样例 N」)', () => {
const first = manifest.samples[0]?.input as Record<string, unknown>;
for (const key of ['trigger', 'requester', 'targetCustomer', 'note']) {
expect(first[key], `样例 0 缺 ${key}`).toBeDefined();
}
for (const s of manifest.samples) {
expect(s.name.length, `样例名「${s.name}」超过 14 字`).toBeLessThanOrEqual(14);
expect((s.input as { note?: string }).note, `样例「${s.name}」没有 note`).toBeTruthy();
}
});
});
function doneOf(item: string): string {
const found = CONTENT_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;
}
}
/**
* **审核资料这一步要真产出资料文件**(2026-08-23 用户定:「格式可以是 word/excel/md/html,
* 文件需要能在前端渲染,在表格中点击资料名可以跳转到一个新的页面来渲染这些文件」)。
*
* 接手时这一步只写下一行标题 —— S3 的剧本白纸黑字写着「file_ref 选填,本 MVP 阶段可留空」:
* 人要审的那份初稿根本不在系统里,点不开也下不来。下面每一条都钉着这一轮补上的一环。
*/
describe('资料文件:声明 → 正文 → 格式 → 点得开', () => {
const asset = manifest.declarations.ledger.find((d) => d.domain === 'content' && d.object === 'asset');
it('台账声明了 file 段(它就是授权面 —— 不声明,件上点不开)', () => {
expect(asset?.file).toEqual({ titleColumn: 'title', bodyColumn: 'body_md', formatColumn: 'file_format' });
});
it('file 段点名的三列都真在 fields 里', () => {
const names = (asset?.fields ?? []).map((f) => f.name);
for (const col of Object.values(asset?.file ?? {})) expect(names, `声明的 ${col} 不在 fields 里`).toContain(col);
});
it('正文与格式都是**必填** —— 少一样这一份就不是文件,只是一行标题', () => {
for (const col of ['body_md', 'file_format']) {
expect(asset?.fields.find((f) => f.name === col)?.required, `${col} 不是必填`).toBe(true);
}
});
it('格式列带 tally 四档(基座据此现数,判据两边才都是基座数)', () => {
expect(asset?.fields.find((f) => f.name === 'file_format')?.tally).toEqual(['md', 'html', 'docx', 'xlsx']);
});
it('L2 判据「资料文件已产出」两边都是基座数:四档之和 = 行数', () => {
const found = manifest.contract.acceptanceCriteria.find((c) => c.key === 'asset-file-ready');
expect(found?.level).toBe('L2');
for (const fmt of ['md', 'html', 'docx', 'xlsx']) {
expect(found?.predicate, `谓词漏了 ${fmt}`).toContain(`content_asset_file_format_${fmt}`);
}
expect(found?.predicate).toContain('= content_asset_rows');
// 自报列一个都不许出现在机检谓词里
expect(found?.predicate).not.toMatch(/file_ref|reviewer/);
});
it('S3 的剧本教它写正文与挑格式,且不再说「可留空」', () => {
const s3 = CONTENT_STEPS.find((s) => s.item === 'S3_gather_and_draft');
expect(s3?.writes).toContain('content.asset');
expect(s3?.guide).toContain('body_md');
expect(s3?.guide).toContain('file_format');
for (const fmt of ['xlsx', 'docx', 'html', 'md']) expect(s3?.guide, `剧本没讲 ${fmt} 什么时候用`).toContain(fmt);
// 那句话正是「这一步不产出文件」的出处 —— 它在,行为就回去了
expect(s3?.guide).not.toContain('本 MVP 阶段可留空');
expect(s3?.guide).not.toContain('待补充');
});
it('S3 的 doneWhen 覆盖得住新的必填(必填齐才收步)', () => {
const s3 = CONTENT_STEPS.find((s) => s.item === 'S3_gather_and_draft');
expect(s3?.doneWhen).toContain('content_asset_missing = 0');
// 正文/格式没写 → missing 不为 0 → 收不掉
expect(evaluateCondition(s3?.doneWhen ?? 'false', { content_asset_rows: 2, content_asset_missing: 1 })).toBe(false);
expect(evaluateCondition(s3?.doneWhen ?? 'false', { content_asset_rows: 2, content_asset_missing: 0 })).toBe(true);
});
it('审核那一屏:资料名挂 data-file-of,旁边摆格式徽', () => {
expect(DRAFT_REVIEW).toContain('data-file-of="content_id"');
expect(DRAFT_REVIEW).toContain('data-class-of="file_format"');
// 归档那一屏同样点得开 —— 办结之后回看资料是常事
expect(PUBLISH_CONFIRM).toContain('data-file-of="content_id"');
});
it('资料名那一格看得出能点(样式表里有它,不是一格普通文字)', () => {
expect(DRAFT_REVIEW).toContain('jc-fileln');
expect(PRD_CSS).toContain('.jc-fileln');
expect(PRD_CSS).toContain('cursor: pointer');
});
});
/**
* **提交前要裁的那三条,写在说明书里**(2026-08-23 实测:S5 与 submit 被压成一拍,
* 那一拍摊开的是 S5 的剧本,T_release 的「先 judge 再 submit」看不见 → 每单白吃一次被拒)。
* 与 A1 的卡骨架、A6 的决策卡同一课:**模型会压拍,压过之后只有说明书还在**。
*/
describe('裁量判据的规矩住在说明书里,不只住在 T_release 的剧本里', () => {
it('三条 L4 的整句都在 NARRATIVE 里(criterion 要照抄,抄不到就对不上名)', () => {
for (const c of manifest.contract.acceptanceCriteria.filter((x) => x.level === 'L4')) {
expect(NARRATIVE, `说明书里没有「${c.label}」`).toContain(c.label);
}
});
it('说明书说清了顺序:同一串先 judge 再 submit', () => {
expect(NARRATIVE).toContain('judge');
expect(NARRATIVE).toContain('submit');
});
});
@@ -0,0 +1 @@
{ "name": "@activities/brand-content-toolkit", "version": "0.1.0", "private": true, "type": "module", "exports": { ".": "./src/index.ts" } }
@@ -0,0 +1,464 @@
/**
* I01 品牌形象与销售工具包管理的案例模型 —— **双表示同版本**:CMMN XML(机器面)与
* 案例说明书(叙事面)。方法论与文件结构照抄 E04(`crm-relationship-coverage`)已验证过的样子。
*
* 业务出处:`docs/SS01-客户与市场/i01-html/I01-活动流程与工单说明.md`(7 步业务链条,
* 工单只针对生产流程①→⑤,复盘⑥⑦独立拎出不并入)与
* `docs/SS01-客户与市场/Demo-E04-I01-Activity台账关联与设计.html`(L-01/L-05 台账字段表)。
*
* 7 个业务步收成 6 个案例模型项,非逐一对应:
* ① 需求识别与受理 → S1_identify_requirement(AI 起草)+ S2_requirement_confirm(人核对);
* ②③ 市场依据采集 + 内容创作 → S3_gather_and_draft(一项,AI,纯加工态);
* ④ 内部审核与调性把关 → S4_draft_review(决策卡循环,通过/驳回+AI重做);
* ⑤ 发布入库 → S5_publish_prep(AI 拟发布)+ T_release(终局验收放行)。
* ⑥⑦ 分发追踪与复盘迭代**不在这份案例模型里**(业务说明明文:工单只针对生产流程,
* 复盘是独立触发的另一条时间轴,不并入这一次生产任务)。
*
* 与 E04 的一处刻意不同:I01 没有「AI 做不到、必须人去线下执行」的环节
* (内容初稿由 AI 草拟,人只审不做)——所以只有 1 张执行工单(S2),
* 少 E04 那张「执行经营动作」;验收工单反倒多一张(S4 审初稿 + T_release 发布确认),
* 与 E04(S4 审计划 + 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="brand-content-toolkit">
<!--
案卷项的 id 就是任务视图里的键名。入参顶层是 trigger/reason/requester/targetCustomer/note
五个字段(不进这份 caseFileModel——没有谓词引用它们,S1 的步骤 reads 里单独声明);
content_brand_* 与 content_asset_* 那几个是基座读数(行数 / 缺必填 / 按值计数,
由基座现算,模型碰不着)。
-->
<caseFileModel>
<caseFileItem id="content_brand_requirement_rows" name="内容需求行数(基座现数)"/>
<caseFileItem id="requirement_confirmed_total" name="内容需求已核对确认(人环走完的凭据)"/>
<caseFileItem id="content_asset_rows" name="内容资产行数(基座现数)"/>
<caseFileItem id="content_asset_missing" name="内容资产缺必填的行数(基座现数)"/>
<caseFileItem id="draft_approved_total" name="内容初稿已审核通过(仅通过时置一)"/>
<caseFileItem id="content_asset_content_status_published" name="已置为已发布状态的行数(基座现数)"/>
<caseFileItem id="publish_prepared_total" name="发布准备已收口(S5 写完时置一)"/>
</caseFileModel>
<casePlanModel id="plan" name="品牌形象与销售工具包管理" autoComplete="true">
<!-- ① 内容需求已起草 —— 人核对那一步的入场前提 -->
<sentry id="s_requirement_drafted">
<onPart id="op_rd" caseFileItemRef="content_brand_requirement_rows"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_rd"><condition>content_brand_requirement_rows &gt; 0</condition></ifPart>
</sentry>
<!-- ① 内容需求已核对 —— 采集与创作那一步的入场前提 -->
<sentry id="s_requirement_confirmed">
<onPart id="op_rc" caseFileItemRef="requirement_confirmed_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_rc"><condition>requirement_confirmed_total &gt; 0</condition></ifPart>
</sentry>
<!-- ②③ 内容初稿已草拟且齐 —— 审核那一步的入场前提 -->
<sentry id="s_draft_ready">
<onPart id="op_dr" caseFileItemRef="content_asset_rows"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_dr"><condition>content_asset_rows &gt; 0 and content_asset_missing = 0</condition></ifPart>
</sentry>
<!-- ④ 内容初稿已审核通过 —— 拟发布那一步的入场前提 -->
<sentry id="s_draft_approved">
<onPart id="op_da" caseFileItemRef="draft_approved_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_da"><condition>draft_approved_total &gt; 0</condition></ifPart>
</sentry>
<!--
⑤ 发布已准备好 —— 放行门的入场前提。
认「办完了没有」:发布状态已置且不缺必填、且 S5 自己显式记了账
(publish_prepared_total 是写完之后才产生的数,不是「写过一笔就算数」)。
-->
<sentry id="s_publish_ready">
<onPart id="op_pr" caseFileItemRef="publish_prepared_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_pr"><condition>publish_prepared_total &gt; 0 and content_asset_content_status_published &gt; 0 and content_asset_missing = 0</condition></ifPart>
</sentry>
<!-- ① 识别内容需求,铸造 round_ref(能算的不问人) -->
<humanTask id="S1_identify_requirement" name="识别本轮内容需求"/>
<!-- ① 交人核对需求清单(增删改)—— 触达世界前的人环,不给 doneWhen -->
<humanTask id="S2_requirement_confirm" name="需求受理:核对需求清单,增删改">
<entryCriterion id="ec_s2" sentryRef="s_requirement_drafted"/>
<itemControl><manualActivationRule id="m_s2"/></itemControl>
</humanTask>
<!-- ②③ 采集市场依据 → 草拟多语言内容初稿(一项,不触达世界) -->
<humanTask id="S3_gather_and_draft" name="采集市场依据、按 BT 层级草拟内容初稿">
<entryCriterion id="ec_s3" sentryRef="s_requirement_confirmed"/>
</humanTask>
<!-- ④ 内容初稿审核 —— 决策卡循环,通过或驳回+AI重做,不给 doneWhen -->
<humanTask id="S4_draft_review" name="内容初稿审核:通过或驳回(驳回则AI修订重提交)">
<entryCriterion id="ec_s4" sentryRef="s_draft_ready"/>
<itemControl><manualActivationRule id="m_s4"/></itemControl>
</humanTask>
<!-- ⑤(前半)拟发布:版本号递增、状态流转、登记场景与 BT 标签(不触达世界,给 doneWhen) -->
<humanTask id="S5_publish_prep" name="拟发布:版本递增、状态流转、登记场景与标签">
<entryCriterion id="ec_s5" sentryRef="s_draft_approved"/>
</humanTask>
<!-- ⑤(后半)发布确认:终局唯放行,必须人工激活 -->
<humanTask id="T_release" name="发布确认放行">
<entryCriterion id="ec_rel" sentryRef="s_publish_ready"/>
<itemControl><manualActivationRule id="m_rl"/></itemControl>
</humanTask>
</casePlanModel>
</case>
</definitions>`;
/**
* 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本,两处不重复写同一件事)。
*/
export const NARRATIVE = `§0 开案:**这一轮要生产哪份对外内容资产,产完把它审核发布**。
入参五个顶层字段:
· \`trigger\`——**必有**,\`"proactive"\`(主动触发)或 \`"reactive"\`(被动触发·使用方请求),
翻成人读标签写进 \`content_brand.verdict.trigger\`(报告上不摆系统内英文码)。
· \`reason\`——仅 \`trigger:"proactive"\` 时可能有,四种之一:\`"strategy_shift"\`(战略调整)/
\`"new_market"\`(进入新市场)/ \`"tool_outdated"\`(工具过期)/ \`"monthly_update"\`
(月度定期内容更新)。
· \`requester\`——仅 \`trigger:"reactive"\` 时可能有,四种之一:\`"E04"\`(关系覆盖需要价值
传递素材)/ \`"T03"\`(触达需要材料)/ \`"T04"\`(攻坚需要定制内容)/ \`"E03"\`(方案需要支撑)——
T03/E03 在本仓库尚未实现,只是描述来源,不必真去调它们。
\`reason\`/\`requester\` 二选一有值,翻成人读标签一起写进
\`content_brand.verdict.trigger_detail\`(对照表见下方 S1 的 guide)。
· \`targetCustomer\`——选填,定向客户的编码(如为特定客户定制素材);通用内容留空。
· \`note\`——本轮背景说明(选填),帮你判断内容侧重与紧迫程度。
**成品是三样东西,缺一不可**:①一份**核对过的内容需求清单**;②一份**经审核通过的
内容初稿**(多语言,按 BT 层级定制结构);③**写入 L-05 品牌内容库的正式发布记录**
(版本号、状态流转、场景与 BT 标签登记齐全)。
**AI 承担信息汇总、记录与初稿生成,人负责判断与决策**:识别需求、采集市场依据、
草拟初稿、拟发布,都是你的活;核对需求清单、审核初稿、签字发布,三道都要人过。
**没有物理执行环节**:与 E04 不同,I01 没有「AI 做不到、必须人去线下办」的步骤——
内容初稿由你草拟,人只审核通过或驳回,不亲自动笔、不用到场。
**五步走**:识别需求 → 人核对 → 采集依据+草拟初稿(一步) → 人审核(可能来回改一轮)→
拟发布 → 人签字放行。
【这一单唯一的岔路口 —— 内容初稿审核,长这样,无论哪一拍都照这个发,别自己另编一张】
审核不是一次性的:人可能驳回、要求你按他的意见改。**这一步只在 S4_draft_review
这一项里发生**,发决策卡之前先在轨迹里找有没有已经答过、还没处理的 \`[card-reply]\`:
· **\`value\` 是 \`"approve"\`**——写 \`draft_approved_total=1\`,把这一轮草拟的每一行
\`content.asset\` 的 \`content_status\` 改成 \`reviewed\`(同 PK 覆盖写),finish 这一项,
别再发卡;
· **\`value\` 是 \`"reject"\` 或以 \`"custom:"\` 开头**——都算驳回:\`"reject"\` 是点了固定
按钮、没写具体意见,**不许再发卡追问理由**(那两个按钮点出来的回复永远没有 note,
再问一遍是把人指向一扇打不开的门);以 \`"custom:"\` 开头的,\`note\` 就是他写的具体
意见,按这条意见改。两种都要对 \`content.asset\` 做出至少一处实质性修订(同 PK
覆盖写,不开新计划项、不重新进入 sentry),然后**换一种问法**再发一张 decide 卡
(措辞不换会被系统当成同一张卡静默吞掉);
· **没有**——才发第一张 decide 卡。
{"kind":"decide","itemId":"S4_draft_review","allowCustom":true,
"ask":"内容初稿已草拟,共 <N> 份,请审核。",
"options":[{"value":"approve","label":"通过","recommended":true},
{"value":"reject","label":"驳回(不写具体意见)"}]}
三个 \`value\` 只有这三种形态:\`"approve"\`、\`"reject"\`、\`"custom:"\` 开头(人自己写的话)——
**别自己造第四种**。
【提交放行之前,先把三条裁量判据裁掉 —— 同一串 judge×3 + submit】
这三条机器判不了,**没裁过就提交必被拒**(拒回会点名是哪三条):
· "版本号与状态是否正确:版本号已更新、状态流转无误"
· "场景与 BT 标签是否登记完整"
· "多语言版本是否齐全"
criterion 照上面整句原样抄;裁成 satisfied=false 照样提交得了 —— 理由连着卡交给签字的人。
**这条写在这儿而不是只写在 T_release 的剧本里**:拟发布与提交常被压成同一拍,
那一拍摊开的是拟发布那一步的做法,T_release 的剧本这时看不见 —— 说明书每拍都在。
【一路上要说话】负责人正看着这一单。**每一拍带一句 say** —— 说业务的话:
识别到几条需求、初稿写了几份、发布准备好了什么;不说系统词。
到了要找人的那一拍,先说清这一轮办完了什么、为什么现在需要他。
【怎么把一项干完 · 一拍连做】write 和 tool 都不会自动收步 —— 做完用 finish 宣告。
动作排进同一拍的 actions 串一口气给;只有发卡 / 提交(挂起)必须停拍。
**这一单常路 7-9 拍就该收口**(比常路多两道人环:需求核对 + 初稿审核 + 放行,共三次;
审核若被驳回会多出一到两拍)。
§Z 结案:AM Lead 在验收单上签字,系统把草稿合并进 L-05 正式版 ——
这一轮生产的内容资产正式上线,可供 E04/T03/T04/E03 调用(分发追踪与复盘迭代是
独立触发的另一条时间轴,不在这一单里)。`;
/**
* 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。
*
* 三处不给 `doneWhen`:`S2_requirement_confirm`(交人核对触达世界)、`S4_draft_review`
* (要人拍板,且这一项内部可能循环)、`T_release`(签下去触达世界)——
* 给了等于给引擎一把替人签字的钥匙。
*/
export const CONTENT_STEPS = [
{
item: 'S1_identify_requirement',
intent: '识别本轮内容需求来源与类型,铸造本轮案号',
writes: ['content_brand.requirement', 'content_brand.verdict'],
/* 入参顶层字段不声明就会被整表段截断刀砍掉 —— 被动触发那几条路全靠这五个字段 */
reads: ['trigger', 'reason', 'requester', 'targetCustomer', 'note', 'content.asset'],
view: 'requirement-list',
tools: [],
doneWhen: 'content_brand_requirement_rows > 0',
guide: `第一件事:把这一轮**内容需求**列出来。
【先铸造本轮案号 round_ref】格式固定:\`'CTB-' + case:openedAt 按 YYYYMMDDHHmmss 格式化\`
(引擎注入的开案时刻,你不必也不许自己编)。**整单只有一个 round_ref**,写
\`content_brand.requirement\` / \`.verdict\` 时都用这同一个串 —— 造出两个号就是把一单裂成两单。
【识别需求】读 \`content.asset\`(已有资产,判断是否已有类似内容、避免重复):
· \`trigger:"proactive"\`——按 \`reason\` 映射:\`strategy_shift\`(战略调整)/
\`new_market\`(进入新市场)通常对应"公司介绍"/"能力声明"这类通用内容;
\`tool_outdated\`(工具过期)对应更新既有工具包;\`monthly_update\`(月度定期更新)
对应例行内容刷新,可参考已有资产里哪些版本号最旧、最久未更新。
· \`trigger:"reactive"\`——按 \`requester\` 映射到内容类型:\`E04\` 请求价值传递素材
→系统方案白皮书/案例集;\`T03\` 触达需要材料→销售工具包/选型指南;\`T04\` 攻坚需要
定制内容→系统方案白皮书(定向 \`targetCustomer\`);\`E03\` 方案需要支撑→技术规格书/
替代对照表。\`targetCustomer\` 有值就把这条需求的 \`target_customer\` 填成它。
一轮可以识别出不止一条需求(如月度更新可能同时刷新两三份内容)。
【写 \`content_brand.requirement\`,一条需求一行】(rows 收数组)
round_ref、req_seq(**必填**,本轮内从 1 递增)、content_type(**必填**,如
"系统方案白皮书"/"公司介绍"/"能力声明"/"销售工具包"/"案例集"/"品牌调性标准"/
"方案模板")、source(选填,一句需求来源,如"使用方请求(E04 价值传递)"或
"战略调整(进入俄罗斯市场)")、target_customer(选填,定向客户;通用内容写"—(通用)")、
scene(选填,**写人读标签**"首触"/"方案"/"攻坚"/"展会"/"报价"之一,按用途判断——这一列
在编辑面上是普通格子、不是徽标,不带翻译层,写 ASCII 码人看到的就是 ASCII 码)、
priority(选填,ASCII \`high\`/\`medium\`/\`low\`,按紧迫程度判断)、origin(固定写 \`ai\`)。
【同一拍写 \`content_brand.verdict\`(一行)】round_ref、trigger(**翻成人读标签写,
不写入参那个英文码**——\`"proactive"\`→"主动触发"、\`"reactive"\`→"被动触发")、
trigger_detail(把 \`reason\` 或 \`requester\` 也翻成人读标签写进来——
\`strategy_shift\`→"战略调整"、\`new_market\`→"进入新市场"、\`tool_outdated\`→"工具过期"、
\`monthly_update\`→"月度定期内容更新"、\`E04\`→"E04 关系覆盖需要价值传递素材"、
\`T03\`→"T03 触达需要材料"、\`T04\`→"T04 攻坚需要定制内容"、\`E03\`→"E03 方案需要支撑")、
target_customer(入参原样抄,没有留空)、note(入参原样抄,没有留空)、summary 一句人话
(本轮识别到几条需求、依据是什么)。写完 finish 这一项。`,
},
{
item: 'S2_requirement_confirm',
intent: '交人核对需求清单:增删改,确认本轮真正要生产什么',
writes: ['content_brand.verdict'],
reads: ['content_brand.requirement'],
view: 'requirement-list',
tools: [],
/* 不给 doneWhen —— 交人核对触达世界(manualActivation),引擎不替人收 */
guide: `需求清单起草好了,**该内容运营核对了** —— 这是本活动第一张执行单,零输入。
工作区摆的是需求清单的报告(编辑闸开着就能增删改),他在那份件上核对:AI 规则之外的
目标客户、适用场景、优先级、定制需求(如使用方要一份方案,他线下知道要针对某客户
做定向版)。
这一步标了人工激活:发卡就算启动,不必单发 start;人交回后你自己 finish。
【照这个骨架发,别少字段】(卡面是纯文本,steps/standard 里别写星号记号,别绑 \`blocks\`
或 \`fields\` —— 整份清单在工作区那份件上,执行卡零输入)
{"kind":"human","itemId":"S2_requirement_confirm","workOrder":"execution","card":{
"title":"需求受理:本轮共 <N> 条内容需求",
"ask":"AI 已按规则识别出 <N> 条内容需求,请核对确认。",
"steps":["工作区的件上核对需求清单:内容类型是必填,缺一条这一单走不到内容创作那一步",
"加一行 = 补充新需求(AI 规则之外的目标客户/场景/优先级/定制)",
"删一行 = 排除(AI 识别但实际不需要生产的需求)",
"改单元格 = 修正 AI 识别的内容 —— 尤其是目标客户、适用场景、优先级",
"改完保存,回这张卡提交"],
"standard":"需求清单里每一行内容类型都齐;该补充的都补充、该排除的都排除了。",
"basis":[{"k":"触发口径","v":"<照 content_brand.verdict 刚写的 trigger/trigger_detail 说,
如「主动触发:月度定期内容更新」或「被动触发:E04 关系覆盖需要价值传递素材」>"},
{"k":"AI 识别","v":"<N> 条内容需求"}]}}
发完这一拍就结束了(发卡挂起)。
【人交回之后】这一步你只做两件事,然后 finish:
① 写 \`content_brand.verdict.requirement_confirmed_total = 1\`;他在件上撤掉了行或加了行
的话,下一步的市场依据采集与内容创作按当下 \`content_brand.requirement\` 的实际行数走,
不必你重写。
② finish 这一项。**别在这一拍采集市场依据或草拟初稿** —— 那是下一步的活,这里做了也白做。
【这张卡一单只发一次】人交回来之后不许再发同一张卡 —— 缺必填有提交闸兜着,
卡回来时内容类型一定是齐的(或那几行已被他撤掉)。`,
},
{
item: 'S3_gather_and_draft',
intent: '采集市场依据、按 BT 层级草拟多语言内容初稿',
writes: ['content.asset', 'content_brand.verdict'],
reads: ['content_brand.requirement', 'market.signal', 'content.asset'],
view: 'draft-review',
tools: [],
doneWhen: 'content_asset_rows > 0 and content_asset_missing = 0',
guide: `需求确认了,**现在读市场依据、草拟内容初稿** —— 一拍连做,这一步纯加工态,
不必找人。
【采集市场依据】读 \`market.signal\`(L-01 宏观市场台账):按需求里的 \`target_customer\`/
业务背景筛选相关的 \`market_region\`/\`industry\`/\`chip_category\` 信号,归纳产业趋势与
差异化定位。**读不到相关信号是正常结果**(不是每条内容需求都有现成的市场信号支持)——
读不到就如实基于已有资产与客户信息拟定,别在报告里编一条不存在的市场依据。
【按 BT 层级草拟内容初稿】逐条 \`content_brand.requirement\` 草拟一份初稿:
· BT-01(采购/供应链层)侧重:供货能力 / 资质文件 / 渠道政策;
· BT-02(技术层)侧重:技术规格书 / 替代对照表 / 认证报告;
· BT-03(高层)侧重:系统方案白皮书 / 参考设计 / 成功案例。
需求没指明 BT 层级的,按内容类型判断("系统方案白皮书"多半是 BT-03,"技术规格书"多半
是 BT-02)。**维护多语言版本**:中文必备,俄罗斯市场相关(俄语区客户/进入俄罗斯市场
的需求)加俄语,其余按需求背景判断要不要加英语。同一份内容的多语言版本写进**同一行**
的 \`language\` 列(用" · "连接,如"中文 · Русский"),不必为每种语言各开一行。
读 \`content.asset\` 里已有资产与品牌调性标准,保持措辞一致、避免重复生产同一份内容。
【写 \`content.asset\`,一份内容初稿一行】(rows 收数组)
content_id(**必填**,格式固定 \`'CT-' + 本年年份 + '-' + 本轮内四位序号\`,如
"CT-2026-0001"、"CT-2026-0002",本轮内递增、不与已有资产的编号相撞)、content_type
(**必填**,照抄需求的 content_type)、title(**必填**,具体标题,不写"内容初稿"这类
占位词)、language(**必填**,如"中文"或"中文 · Русский")、version(**必填**,本步一律
写 \`v0.1\`)、scene(选填,照抄需求的 scene 人读标签)、content_status(固定写 \`draft\`)、
brand_tone(选填,按品牌调性标准挑 1-2 个最贴合的,如"专业 · 可信赖")、bt_tag(选填,
**写人读值** "BT-01"/"BT-02"/"BT-03" 之一,按上面那三档判 —— 这一列在件上是普通格子、
不带翻译层,写 ASCII 码人看到的就是 ASCII 码;验收工单要人核「BT 标签登记完整」,
核的就是它)、target_customer(选填,照抄需求的 target_customer)、file_ref(选填,
留空即可)、reviewer(选填,本步留空,S4 审核通过后再填)。
【**正文与格式:这一步真正要产出的资料**】(\`body_md\` 与 \`file_format\`,两列都**必填**)
人在下一步要审的是**资料本身**,不是一行标题 —— 件上点资料名就打开这份文件,
所以正文得真写出来。
· \`body_md\` = 这份资料的**全文,Markdown 原文**。照 BT 层级该有的结构写实内容:
一级标题一句话说清这是什么,再按"##"分节。**每一节都要有实打实的内容** ——
一句"回头再补"或者把人指去别处的话,等于这一份资料没产出。认得的写法就这几样,够用:
"# 标题" / "## 小节" / 段落 / "- 要点" / "1. 步骤" / "| 列 | 列 |" 表格 / "---" 分隔线 /
"**重点**"。多语言版本写进同一份正文的不同小节
(如 "## Русская версия"),不为每种语言另开一行。
· \`file_format\` = 这份资料**该以哪种格式交出去**,四选一,按内容形态挑,别一律写 md:
- \`xlsx\`:主体是表 —— 替代对照表、参数对比表、价格/交期清单;
- \`docx\`:正式文档 —— 系统方案白皮书、技术规格书、认证报告、渠道政策;
- \`html\`:要在网页上直接看的 —— 落地页文案、案例展示页;
- \`md\`:短说明、要点清单、内部速记。
挑 \`xlsx\` 的那一份,正文里**必须真有一张 Markdown 表格**(否则导出来是一张只有
标题的空表)。同一轮里几份内容各按自己的形态挑,不必统一。
【同一拍写 \`content_brand.verdict\`】summary 补一句人话(本轮草拟了几份内容、依据是
什么市场信号或已有资产)。
写完这一拍就结束(不必你 finish)——\`content_asset_rows > 0 and content_asset_missing = 0\`
一旦为真,引擎自己收这一步。`,
},
{
item: 'S4_draft_review',
intent: '内容初稿审核:通过就往下走,驳回则按意见修订后换一种问法再提交',
writes: ['content.asset', 'content_brand.verdict'],
reads: ['content.asset'],
view: 'draft-review',
tools: [],
/* 决策卡循环 + 要人拍板 —— 不给 doneWhen,引擎不许替人收 */
guide: `**动手之前先在轨迹里找一行 \`[card-reply]\` 带这一项的答复:**
· **没找到** —— 这是第一次,跳到下面【发一张决策卡】那一段;
· **\`value\` 是 \`"approve"\`** —— 把这一轮 \`content.asset\` 里状态还是 \`draft\` 的每一行
改成 \`content_status = "reviewed"\`(同 PK 覆盖写,\`reviewer\` 补一句"AM Lead 审核通过"),
写 \`content_brand.verdict.draft_approved_total = 1\`,finish 这一项,**不许再发卡**;
· **\`value\` 是 \`"reject"\` 或以 \`"custom:"\` 开头** —— 都算驳回,跳到下面【驳回之后】
那一段,修订初稿再重新发卡。
【这一步办完了没有,看一个数】\`draft_approved_total\`:它写成 1 就是这一步办完了,
引擎不会再把这一项交给你(它归零 doneWhen 帮不上忙,只有你自己 finish)。
【发一张决策卡,照这个骨架】(选项少于两个基座拒发;\`allowCustom\` 别漏 —— 少了它,人只有
两个固定按钮可点,连意见都没地方写;\`ask\` 每一轮都要按下面的规则换措辞)
{"kind":"decide","itemId":"S4_draft_review",
"allowCustom":true,
"ask":"内容初稿已草拟,共 <N> 份,请审核。",
"options":[
{"value":"approve","label":"通过","recommended":true,"consequence":"内容进入发布准备阶段"},
{"value":"reject","label":"驳回(不写具体意见)","consequence":"AI按调性标准复核一遍初稿,重新提交审核"}],
"basis":[{"k":"本轮初稿","v":"<N> 份,覆盖 <涉及的内容类型>"},
{"k":"调性依据","v":"专业 / 可信赖 / 高效 / 本土化"},
{"k":"想说明理由","v":"不点上面两个按钮,直接在下方输入框写你的意见再提交"}]}
发完这一拍就结束了(发卡挂起)。
【驳回之后 —— 不管有没有具体意见,都只处理这一轮,不许再追问一遍】
① \`value\` 以 \`"custom:"\` 开头 —— \`note\` 就是他写的具体意见,**按这条意见**修订
\`content.asset\`(同 PK 覆盖写对应的行,不开新计划项、不重新进入 sentry)。
② \`value\` 是 \`"reject"\`(点了固定按钮,没写具体意见)—— **不许再发卡追问理由**
(那两个按钮点出来的回复永远没有 note,再问一遍是把人指向一扇打不开的门,
会被同一句话拒回,连问出真话的机会都没有)。改成你自己复核:对照调性标准
(专业/可信赖/高效/本土化)重新审视措辞,技术数据有没有可疑处;复核后**至少要有
一处实质性变化**(改措辞、补细节、调标签,不能原样重发)。
③ **重新发卡的 \`ask\` 必须换一种说法**,带上修订轮次与改了什么,例如
"已复核并修订(第2稿:调整了措辞、补充了认证细节)请再次审核"——
措辞不换,系统会把它当成同一张卡:前两次静默吞掉(人看不到新卡),第三次直接拒。
**每一轮的 \`ask\` 都要与上一轮明显不同**,不止改个数字。
④ 再走一遍【发一张决策卡】那一段(用新措辞,\`allowCustom\` 仍然要给),然后这一拍结束
(发卡挂起)。
**这一步只在这一项里循环,不许把驳回意见带去改别的项**(S1/S2/S3 写下的东西不动)。`,
},
{
item: 'S5_publish_prep',
intent: '拟发布:版本号递增、状态流转、登记场景与 BT 标签',
writes: ['content.asset', 'content_brand.verdict'],
reads: ['content.asset', 'content_brand.requirement'],
view: 'publish-confirm',
tools: [],
doneWhen: 'publish_prepared_total > 0 and content_asset_content_status_published > 0 and content_asset_missing = 0',
guide: `审核通过了,**现在拟发布** —— 一拍连做。
【写 \`content.asset\`,把审核通过的每一行(\`content_status = "reviewed"\`)改成拟发布态】
(同 PK 覆盖写)version(**必填**,由 \`v0.1\` 改成 \`v1.0\`;若这份内容是已发布内容的
修订版本(\`content.asset\` 里已有同标题的 \`published\` 行),小版本递增,如 \`v1.1\`)、
content_status(**必填**,固定写 \`published\`)、scene(**必填**,若上一步没填齐,这一步
补上 —— 发布前场景必须登记完整)、bt_tag(**必填**,同上,人读值 "BT-01"/"BT-02"/"BT-03";
场景与 BT 标签这两列**声明里钉的就是「发布态才必填」**,状态一写成 published,
它们空着就会被红带、提交闸、放行门 L2 同时拦下)、brand_tone(选填,若上一步没填,
这一步按调性标准补)。
**这一步只是把状态写进本单草稿层,不是真的对外发布** —— 铁律②:草稿层的变更要等
T_release 人签字放行才合并进 L-05 正式版、真正对外可见。
【同一拍写 \`content_brand.verdict\`】publish_prepared_total = 1、
content_produced_total = 这一轮拟发布的内容份数、summary 补一句人话(这一轮生产了
哪几类内容、准备发布几份)。
写完 finish 这一项 —— 或者不必你 finish,\`doneWhen\` 会在必填列都齐的那一刻自动收步。`,
},
{
item: 'T_release',
intent: '发布已准备好,提交放行交 AM Lead 验收;被打回就按意见改完再交',
/* 被打回重做时要改的就是这两张 —— 不声明就写不进去(写的授权面是台账声明) */
writes: ['content.asset', 'content_brand.verdict'],
reads: ['content.asset', 'content_brand.requirement'],
view: 'publish-confirm',
tools: [],
/* 放行触达世界,必须人按一下 —— 引擎不自动收它 */
guide: `拟发布内容齐了、必填也补齐了,**可以走放行了**
(T_release 人工激活:动它就算启动,不必单发 start)。
【**先看一眼轨迹里有没有「验收被打回重做」**】有的话,这一拍**不是**再提交一遍 ——
人已经看过这份成果并把它退回来了,原样递上去他看见的还是刚被他打回的那一份
(基座那道闸也会当场拒:「打回之后台账一个字没改」)。照他的意见改**台账本身**,
改完这一拍再 judge + submit:
· 他说**版本号/状态不对** → 改 \`content.asset\` 的 \`version\`/\`content_status\`(同 PK 覆盖写);
· 他说**场景或 BT 标签没登记全** → 补 \`scene\`/\`bt_tag\`;
· 他说**语言版本不齐** → 补 \`language\`(同一行用" · "连接,不新开行);
· 他说**标题/调性不合** → 改 \`title\`/\`brand_tone\`;
· 他**没写具体意见**(只点了重做)→ 自己把这几份内容复核一遍,挑最站不住的那处改实,
**至少改对一处**;实在判断不出他要什么,发一张零输入执行工单问清楚,别原样重提。
**改的是值,不是行**:同一行覆盖写,别新开行 —— 那会把收步判据与发布份数弄假。
【没被打回(或已按意见改过了)】先把裁量判据裁掉,然后 submit,同一拍连做:
· judge criterion="版本号与状态是否正确:版本号已更新、状态流转无误" —— 逐条看
\`content.asset\` 的 \`version\`/\`content_status\`:版本号已从 \`v0.1\` 更新、状态流转
(草稿 → 已审核 → 已发布)每一步都走过没有跳步,就 satisfied=true;有一条对不上,
satisfied=**false**,rationale 逐条写清,交签字的人定。
· judge criterion="场景与 BT 标签是否登记完整" —— 核对每条拟发布内容的 \`scene\`/
\`bt_tag\` 是否都有值(这两列在声明里是「发布态必填」,基座也数得出来;
你要判的是**登记得对不对**,不只是有没有值)。
· judge criterion="多语言版本是否齐全" —— 核对 \`language\` 是否覆盖了这条内容该有的
语言版本(如面向俄罗斯市场的内容至少要有俄语)。
**裁成不满足照样能提交** —— 理由连着卡一起交给签字的人。criterion 照判据名整句原样抄,
写别的名字对不上。
这一单没有分支去向 —— 全程线性,只发一口 \`content-published\`,不必你选出口。
终局唯放行:你不能自己宣布完成。`,
},
];
/**
* 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段;
* 放行那一格由基座统一加,人看到的是 x/3。
*/
export const CONTENT_PHASES = [
{ name: '需求识别与受理', items: ['S1_identify_requirement', 'S2_requirement_confirm'] },
{ name: '采集与创作', items: ['S3_gather_and_draft'] },
{ name: '审核与发布', items: ['S4_draft_review', 'S5_publish_prep'] },
];
@@ -0,0 +1,11 @@
/**
* I01 · 品牌形象与销售工具包管理 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
*
* 这个活动没有 `run`。案例活动的推进由 Loop 驱动,行为全部写在清单里:
* 案例模型 + 说明书 + 步骤剧本 + 三条技能 + 台账声明 + 三张 HTML 报告模版。
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 ——
* 它是**被静态审扫描的产物文本**(查 import 与全局引用,证明活动包零环境能力)。
*/
import { defineActivity } from '@sdk';
export default defineActivity({ name: 'brand-content-toolkit' });
@@ -0,0 +1,526 @@
/**
* 活动包清单 —— I01 品牌形象与销售工具包管理(案例活动)。
*
* 业务出处:`docs/SS01-客户与市场/i01-html/I01-活动流程与工单说明.md`(7 步业务链条,
* 工单只针对生产流程①→⑤,收成 1 张执行工单 + 2 张验收工单)、
* `docs/SS01-客户与市场/Demo-E04-I01-Activity台账关联与设计.html`(L-01 宏观市场台账 /
* L-05 品牌内容库的字段表与读写关系)。
*
* 这是本仓库第二个不属于 `quote-*` A1-A7 报价管线的活动,方法论照抄第一个
* (`crm-relationship-coverage` / E04)已验证过的样子:双级下拉入参、活动自有工作台账
* + 共享主数据、decide 决策卡循环做中间验收、六步案例模型。
*
* 两处与 E04 的刻意不同:
* ① E04 核心是**读写**共享主数据(`customer.profile`/`customer.contact`),
* I01 核心是**只读** L-01(`market.signal`)、**读写**它独有的 L-05
* (`content.asset`)——本 Demo 范围内 I01 是 `content.asset` 唯一的读写者,
* 不追加列合并,是这份活动包**第一次**完整声明这两张台账的物理结构。
* ② I01 没有 E04 那种「AI 做不到、必须人去线下执行」的环节(内容初稿由 AI 草拟,
* 人只审不做)——所以只有 1 张执行工单(S2_requirement_confirm),
* 验收工单反倒有 2 张(S4_draft_review 审初稿 + T_release 发布确认),
* 与 E04(1 张验收决策 + 1 张终局放行)结构同型,只是执行工单少一张。
*/
import type { ActivityManifest, DmnModel } from '@contracts';
import { activityIdSchema } from '@contracts';
import { CONTENT_SKILLS } from './skills.ts';
import { CMMN_XML, NARRATIVE, CONTENT_PHASES, CONTENT_STEPS } from './case-model.ts';
import { REQUIREMENT_LIST, DRAFT_REVIEW, PUBLISH_CONFIRM } from './views/report.ts';
const D = 'content_brand';
const ACTIVITY_ID = activityIdSchema.parse('brand-content-toolkit');
/** 没有判定表 —— 触发事件→内容类型、BT 层级→内容结构都是文字规则,不是离散分支命中表。 */
const EMPTY_DMN: DmnModel = { drd: [], tables: [] };
export const manifest: ActivityManifest = {
identity: {
activityId: ACTIVITY_ID,
name: 'I01 · 品牌形象与销售工具包管理',
packageVersion: '1.2.2',
category: '内容生产',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-brand-content-toolkit',
},
/* ── 契约七件 ────────────────────────────────────────────────── */
contract: {
activityId: ACTIVITY_ID,
version: '1.0.0',
input: {
/*
* 不是来件解析(没有唯一「这封信」),是触发参数 —— 与 E04 同一处置
* (两级下拉 + 选填客户/说明):
* ① `trigger` 必填 —— 主动 / 被动,显式下拉,不接受省略;
* ② `reason`/`requester` 选填,只在对应 `trigger` 值时现身(`showWhen`);
* ③ `targetCustomer` 选填字符串 —— 定向客户编码,通用内容留空。
*/
data: {
type: 'object',
required: ['trigger'],
properties: {
trigger: {
type: 'string',
enum: ['proactive', 'reactive'],
enumNames: ['主动触发(战略调整/新市场/工具过期/月度更新)', '被动触发(使用方请求)'],
title: '触发方式',
},
reason: {
type: 'string',
enum: ['strategy_shift', 'new_market', 'tool_outdated', 'monthly_update'],
enumNames: ['战略调整', '进入新市场', '工具过期', '月度定期内容更新'],
title: '主动触发的原因(仅主动触发时选)',
showWhen: { field: 'trigger', equals: 'proactive' },
},
requester: {
type: 'string',
enum: ['E04', 'T03', 'T04', 'E03'],
enumNames: [
'E04 关系覆盖需要价值传递素材',
'T03 触达需要材料',
'T04 攻坚需要定制内容',
'E03 方案需要支撑',
],
title: '被动触发的使用方(仅被动触发时选)',
showWhen: { field: 'trigger', equals: 'reactive' },
},
targetCustomer: {
type: 'string',
title: '定向客户编码(选填,为特定客户定制素材时填;通用内容留空)',
examples: ['CU-0142'],
},
note: {
type: 'string',
title: '本轮说明(选填,背景信息 —— 帮AI判断内容侧重与紧迫程度)',
examples: ['进入俄罗斯市场,需要一份中俄双语的公司介绍与能力声明,月底展会要用'],
},
},
},
messageAllowed: true,
messageHint: '内容运营可在此说明本轮生产的特殊要求、目标受众、或需要重点突出的内容侧重',
},
output: {
/* 单一必发口,无分支:I01 生产流程线性(①→②③→④→⑤),没有岔路口。 */
mandatory: [
{
name: 'content-published',
description:
'本轮已发布的内容资产 —— 供 E04/T03/T04/E03 分发调用;当前代码库尚未实现这些下游活动的读取消费者,此口暂无消费者,仅按"载荷=已落地事实"的契约纪律预先声明',
payload: {
type: 'object',
required: ['roundRef', 'publishedContentTotal'],
properties: {
roundRef: { type: 'string', title: '本轮案号' },
publishedContentTotal: { type: 'integer', title: '本轮已发布内容份数' },
},
},
},
],
selective: [],
},
/*
* 三份必交付物,逐一对应三张工单产出的成果。全部 `editableAtRelease: []`——
* 业务说明明文"验收工单只读展示,不可修改"(内容初稿与拟发布明细都是 AI 的结论,
* 人只做通过/驳回或签字,不在验收页上就地改)。
*/
requiredDeliverables: [
{
key: 'requirement-confirm',
label: '内容需求清单',
ledger: `${D}.requirement`,
ledgers: [`${D}.requirement`, `${D}.verdict`],
editableAtRelease: [],
},
{ key: 'draft-review', label: '内容初稿', ledger: 'content.asset', editableAtRelease: [] },
{ key: 'publish-confirm', label: '拟发布内容', ledger: 'content.asset', editableAtRelease: [] },
],
/*
* 验收判据 —— **机检(L1/L2)只读基座数得出来的数**:`_rows`/`_missing`/按值计数基座现数,
* 与执行卡的提交闸同一个数同一套算法。L4 三条是纯裁量(版本状态对不对、标签齐不齐、
* 多语言够不够)——机器数得出「有没有值」,数不出「对不对、够不够」,那是人对结果负责的地方。
*/
acceptanceCriteria: [
/*
* 两条 L1 的「人过了没有」一半靠记账旗(只有人回卡之后模型才记 —— 与 A1 的
* confirmed_total 同款,规范 §5.3-4 背书的人环凭据);但**每条都必须再钉一半
* 基座数**(2026-08-23 照 E04 的账整改):旗是模型写的,单独成谓词就是
* 「机检只读基座数」第 N 次违反的伪装形 —— E04 那单抢跑写旗、活单熔断,
* 正是踩着这半边过的门。
*/
{
key: 'requirement-confirmed',
label: '内容需求已经内容运营核对确认(执行工单已交回,清单有行且内容类型齐)',
level: 'L1',
predicate: `requirement_confirmed_total > 0 and ${D}_requirement_rows > 0 and ${D}_requirement_missing = 0`,
},
{
key: 'draft-approved',
label: '内容初稿已经 AM Lead 审核通过(验收工单1已过,不处在驳回待改状态)',
level: 'L1',
predicate: 'draft_approved_total > 0 and content_asset_rows > 0 and content_asset_missing = 0',
},
{
key: 'draft-complete',
label: '内容初稿逐份完整:标题、内容类型、语言版本、状态都有',
level: 'L2',
predicate: 'content_asset_rows > 0 and content_asset_missing = 0',
},
/*
* **资料文件真产出了没有** —— 两边都是基座现数:每一行都报了一种格式
* (四档计数之和 = 行数),而正文空着的行会被 `_missing` 数出来(body_md 是必填)。
* 从前这一步只产出一行标题,人点不开也下不来 —— 判据得能指向一个看得见的东西:
* 这一条为真,件上每个资料名都点得开。
*/
{
key: 'asset-file-ready',
label: '每份内容都产出了资料文件(格式已定、正文已写,件上点资料名即可打开)',
level: 'L2',
predicate:
'content_asset_rows > 0 and content_asset_missing = 0 and ' +
'content_asset_file_format_md + content_asset_file_format_html + ' +
'content_asset_file_format_docx + content_asset_file_format_xlsx = content_asset_rows',
},
{
key: 'publish-registered',
label: '拟发布内容已登记为已发布状态且逐项完整',
level: 'L2',
predicate: 'content_asset_content_status_published > 0 and content_asset_missing = 0',
},
{ key: 'version-status-correct', label: '版本号与状态是否正确:版本号已更新、状态流转无误', level: 'L4' },
{ key: 'scene-tag-complete', label: '场景与 BT 标签是否登记完整', level: 'L4' },
{ key: 'language-complete', label: '多语言版本是否齐全', level: 'L4' },
/* L5 是责任关:那张卡本身就是这道关,卡面上不摆它(基座统一滤) */
{ key: 'owner-release', label: '发布入库放行:签下去即正式生效并写入 L-05', level: 'L5' },
],
revisableAtRelease: [],
prohibitions: [
{ key: 'no-fabricate-signal', statement: '市场信号读不到相关内容时不得编造 —— 如实基于已有资产与客户信息拟定' },
{ key: 'no-skip-review', statement: '内容状态流转不得跳步 —— draft → reviewed → published 每一步都要真的写过' },
{ key: 'no-external-write', statement: '放行前不得触达外部系统 —— 触达世界的动作集中在放行那一刻' },
],
authorization: {
discretion: [],
preauthorizedActions: [],
/* 基线三次(S2/S4通过/T_release)+ 决策卡循环预留两轮(每轮驳回+重提交各算一次,+4),留够余量 */
humanCallBudget: 10,
},
resources: {
tools: [],
skills: CONTENT_SKILLS.map((s) => s.id),
models: ['deepseek-v4-flash'],
roles: ['AM Lead'],
budget: { steps: 60, tokens: 400_000, activeMinutes: 60, humanCalls: 10 },
},
},
/* ── 声明 ────────────────────────────────────────────────────── */
declarations: {
models: ['deepseek-v4-flash'],
tools: [],
ledger: [
/*
* ── L-01 宏观市场台账(kind:'master',I01 只读)──
* 首次完整声明:本仓库尚无其它活动读写它,不是追加列。
*/
{
domain: 'market',
object: 'signal',
kind: 'master',
label: '宏观市场台账',
fields: [
{ name: 'signal_id', type: 'text', primaryKey: true, masked: false, label: '信号ID' },
{
name: 'signal_type',
type: 'text',
primaryKey: false,
masked: false,
tally: ['macro_economy', 'industry_trend', 'competition', 'policy', 'customer_market'],
label: '信号类型',
},
{ name: 'title', type: 'text', primaryKey: false, masked: false, label: '标题' },
{ name: 'content', type: 'text', primaryKey: false, masked: false, label: '详情' },
{ name: 'source', type: 'text', primaryKey: false, masked: false, label: '信号来源' },
{ name: 'signal_date', type: 'date', primaryKey: false, masked: false, label: '信号日期' },
{ name: 'market_region', type: 'text', primaryKey: false, masked: false, label: '所属市场' },
{ name: 'industry', type: 'text', primaryKey: false, masked: false, label: '细分行业' },
{ name: 'chip_category', type: 'text', primaryKey: false, masked: false, label: '关联芯片品类' },
{ name: 'importance', type: 'integer', primaryKey: false, masked: false, label: '重要性评分(1-5)' },
{
name: 'impact_dir',
type: 'text',
primaryKey: false,
masked: false,
tally: ['opportunity', 'threat', 'neutral'],
label: '影响方向',
},
{
name: 'signal_status',
type: 'text',
primaryKey: false,
masked: false,
tally: ['pending', 'evaluated', 'archived', 'expired'],
label: '信号状态',
},
{ name: 'version', type: 'text', primaryKey: false, masked: false, label: '版本号' },
{ name: 'review_note', type: 'text', primaryKey: false, masked: false, label: '复盘备注' },
],
},
/*
* ── L-05 品牌内容库(kind:'master',I01 唯一读写者)──
* 首次完整声明并持有全部基本列(不是追加,`declarations.ledger` 里明写 required/expect,
* 因为没有别的活动会与 I01 在这张表上"谁先立谁的字段"打架)。
*/
{
domain: 'content',
object: 'asset',
kind: 'master',
label: '品牌内容库',
fields: [
{ name: 'content_id', type: 'text', primaryKey: true, masked: false, label: '内容ID' },
{ name: 'content_type', type: 'text', primaryKey: false, masked: false, required: true, label: '内容类型' },
{ name: 'title', type: 'text', primaryKey: false, masked: false, required: true, label: '内容标题' },
{
name: 'language',
type: 'text',
primaryKey: false,
masked: false,
required: true,
label: '语言版本(多语言用" · "连接)',
},
{ name: 'version', type: 'text', primaryKey: false, masked: false, required: true, label: '版本号' },
/*
* **发布那一刻才必填**(2026-08-23):S5 的剧本明写「发布前场景必须登记」,
* 而声明只标 expect —— 机器数不出来,那句话就只能靠 L4 让人肉眼核。
* `requiredWhen` 把必填钉在**已发布**的那几行:草稿态不拦(那时还没定场景),
* 一旦状态流到 published,红带/提交闸/放行门 L2 三处同源同数地拦。
*/
{
name: 'scene',
type: 'text',
primaryKey: false,
masked: false,
expect: true,
requiredWhen: { column: 'content_status', value: 'published' },
label: '适用场景',
},
/*
* BT 标签 —— 目标件的验收工单1 就有这一列,而从前 `content.asset` 压根没有它:
* L4 判据「场景与 BT 标签是否登记完整」让人核一个卡面上不存在的东西
* (判据指向的东西必须摆得出来,E04 那一课)。与 `scene` 同款:发布那一刻才必填。
*
* **不带 tally** —— 与 `requirement.scene` 同一条教训(2026-08-21):
* tally 的取值域得是 ASCII(`data-class-of` 只认 ASCII),而这一列在件上是
* 普通格子、走 `data-slot` 直显,没有翻译层 —— 存 `bt_03` 人看到的就是 `bt_03`。
* 存人读值("BT-01"/"BT-02"/"BT-03"),屏上与目标件一字不差。
*/
{
name: 'bt_tag',
type: 'text',
primaryKey: false,
masked: false,
expect: true,
requiredWhen: { column: 'content_status', value: 'published' },
label: 'BT 层级标签',
},
{
name: 'content_status',
type: 'text',
primaryKey: false,
masked: false,
required: true,
tally: ['draft', 'reviewed', 'published', 'deprecated'],
label: '内容状态',
},
{ name: 'brand_tone', type: 'text', primaryKey: false, masked: false, expect: true, label: '品牌调性标签' },
{ name: 'target_customer', type: 'text', primaryKey: false, masked: false, label: '定向客户版本' },
/*
* ── 一份内容 = 一份**资料文件**(2026-08-23 用户:「审核资料这一步要真实
* 产出资料文件,格式可以是 word/excel/md/html,点资料名能打开渲染」)──
*
* 从前只有一句 `file_ref: '文件位置'`,而 S3 的剧本明写「本 MVP 阶段可留空」:
* **这一步压根没产出过任何文件** —— 屏上一行标题,点不开、下不来,
* 人要审的那份初稿根本不在系统里。
*
* 现在正文落 `body_md`(Markdown 原文,AI 写的就是它),格式落 `file_format`,
* 基座按 `file` 段现造成真字节(见 `apps/api/src/app/file-compose.ts`):
* .docx 用 Word 打得开、.xlsx 用 Excel 打得开,屏上渲的就是那份字节读回来的样子。
*
* 为什么正文只写一种(Markdown)而不是四种各写一遍:四份就是四份事实,
* 改一处就分叉。**写一份,按格式投影** —— 与「文件是台账的投影」同一条。
*/
{
name: 'body_md',
type: 'text',
primaryKey: false,
masked: false,
required: true,
label: '内容正文(Markdown 原文)',
},
{
name: 'file_format',
type: 'text',
primaryKey: false,
masked: false,
required: true,
tally: ['md', 'html', 'docx', 'xlsx'],
label: '资料文件格式',
},
{ name: 'file_ref', type: 'text', primaryKey: false, masked: false, label: '文件位置' },
{ name: 'reviewer', type: 'text', primaryKey: false, masked: false, label: '审核人' },
],
/**
* 这张表的每一行还是**一份资料文件**:标题、正文、格式各取哪一列。
* 这一段同时是授权面 —— 不声明它,件上的资料名就点不开(基座 403)。
*/
file: { titleColumn: 'title', bodyColumn: 'body_md', formatColumn: 'file_format' },
},
/* ── I01 自有工作台账(kind:'record',caseKey:'round_ref') ── */
{
domain: D,
object: 'requirement',
kind: 'record',
caseKey: 'round_ref',
label: '内容需求清单',
fields: [
{ name: 'round_ref', type: 'text', primaryKey: true, masked: false, label: '案号' },
{ name: 'req_seq', type: 'integer', primaryKey: true, masked: false, label: '本轮需求序号' },
{ name: 'content_type', type: 'text', primaryKey: false, masked: false, required: true, label: '内容类型' },
{ name: 'source', type: 'text', primaryKey: false, masked: false, expect: true, label: '需求来源' },
{ name: 'target_customer', type: 'text', primaryKey: false, masked: false, expect: true, label: '目标客户' },
/*
* **不带 tally**(2026-08-21 实测逼出的修死):这一列是编辑面上的普通格子
* (`REQUIREMENT_TABLE` 里就是一个 `<span data-col>`,不是 `data-class-of` 徽标),
* tally 过的 ASCII 码没有翻译层就直接摆上屏——实测人看见的是裸的 "solution",
* 不是"方案"。`content.asset.scene`(S3 照抄这一列)本来就是自由文本,两处
* 现在口径一致:AI 写人读标签(首触/方案/攻坚/展会/报价),不写 ASCII 码。
*/
{ name: 'scene', type: 'text', primaryKey: false, masked: false, expect: true, label: '适用场景' },
{
name: 'priority',
type: 'text',
primaryKey: false,
masked: false,
tally: ['high', 'medium', 'low'],
label: '优先级',
},
{ name: 'origin', type: 'text', primaryKey: false, masked: false, label: '来源(ai/human-added)' },
],
},
{
domain: D,
object: 'verdict',
kind: 'record',
caseKey: 'round_ref',
label: '本轮内容生产结论',
fields: [
{ name: 'round_ref', type: 'text', primaryKey: true, masked: false, label: '案号' },
{ name: 'trigger', type: 'text', primaryKey: false, masked: false, label: '触发方式(人读标签,非入参英文码)' },
{ name: 'trigger_detail', type: 'text', primaryKey: false, masked: false, label: '触发细节(人读标签)' },
{ name: 'target_customer', type: 'text', primaryKey: false, masked: false, label: '定向客户' },
{ name: 'note', type: 'text', primaryKey: false, masked: false, label: '本轮说明' },
{ name: 'requirement_confirmed_total', type: 'integer', primaryKey: false, masked: false, label: '需求已确认' },
{ name: 'draft_approved_total', type: 'integer', primaryKey: false, masked: false, label: '初稿已审核通过' },
{ name: 'publish_prepared_total', type: 'integer', primaryKey: false, masked: false, label: '发布准备已收口' },
{ name: 'content_produced_total', type: 'integer', primaryKey: false, masked: false, label: '本轮产出内容份数' },
{ name: 'summary', type: 'text', primaryKey: false, masked: false, label: '本轮生产结论' },
],
},
],
listeners: [],
harness: {
memoryWindow: 24,
/* 内容生产不是紧迫单,给够时间让人审核 */
stallAfter: 'PT4H',
nudge: [{ after: 'PT2H', action: 'nudge' }],
retry: { times: 2, backoff: 'PT30S' },
},
},
/* ── 资产初始版 ──────────────────────────────────────────────── */
assets: {
businessModel: {
cmmnXml: CMMN_XML,
narrative: NARRATIVE,
dmn: EMPTY_DMN,
steps: CONTENT_STEPS,
phases: CONTENT_PHASES,
},
skills: CONTENT_SKILLS,
/*
* 三个局面各一张:
* · `requirement-list` —— S1/S2 摆,内容需求清单,可编辑;
* · `draft-review` —— S3/S4 摆,内容初稿,只读(业务说明明文"人只审不改");
* · `publish-confirm` —— S5/T_release 摆,拟发布内容,只读,放行后即归档件。
*/
viewTemplates: [
{ name: 'requirement-list', html: REQUIREMENT_LIST },
{ name: 'draft-review', html: DRAFT_REVIEW },
{ name: 'publish-confirm', html: PUBLISH_CONFIRM },
],
cardSchemas: {},
distillHooks: [
{
on: 'terminal',
prompt: '归纳本轮内容生产:需求识别依据、审核是否被驳回过(为什么)、市场依据来自哪些信号、发布的内容覆盖哪些场景与语言。',
},
],
},
/*
* ── 样例集 —— 三条形状真不同 ────────────────────────────────
*
* **第一条必须是满载的那条**(2026-08-23,与 E04 同一课):测试台落到一个活动上
* 就自动铺 `samples[0]`(activity-run.tsx 的 useEffect),第一条就是人打开这一屏
* 看见的那份入参。从前第一条是月度更新、只有两个键,表单大半是空的,看着像没预填。
* **样例名一律 ≤14 字**:超了测试台的样例钮只显示「样例 N」,那等于没名字。
*/
samples: [
{
name: 'E04 请求定向素材',
input: {
trigger: 'reactive',
requester: 'E04',
targetCustomer: 'CU-0142',
note: 'E04 关系覆盖与关键人经营需要一份面向 Itelma 的价值传递素材,用于下轮拜访',
},
expect: { ports: ['content-published'] },
},
{
name: '月度定期内容更新',
input: {
trigger: 'proactive',
reason: 'monthly_update',
note: '八月例行内容刷新:优先看版本最旧、最久未更新的那几份工具包',
},
expect: { ports: ['content-published'] },
},
{
name: '进入新市场 · 俄罗斯',
input: {
trigger: 'proactive',
reason: 'new_market',
note: '公司战略进入俄罗斯市场,需要一份中俄双语的公司介绍与能力声明,月底展会要用',
},
expect: { ports: ['content-published'] },
},
/*
* **一轮里三种形态各要一份** —— 这一条样例是为「资料文件」那一环常备的
* (2026-08-23):前三条要的都是成篇的文档,模型一律挑 docx 并不算错,
* 于是表格型与网页型那两条路平时走不到。这一条把三种形态摆在同一封需求里:
* 对照表是表(xlsx)、落地页是网页(html)、销售速记是短要点(md)。
*/
{
name: '对照表与落地页',
input: {
trigger: 'reactive',
requester: 'FAE 华东',
note:
'客户要一份 TI 车规 MCU 的替代料参数对照表(逐型号列封装/温度/认证/交期,做成表格发过去);' +
'同时官网要上一版车规 MCU 能力的落地页文案(网页直接看);' +
'再给销售一页随身速记,三五条要点即可。',
},
expect: { ports: ['content-published'] },
},
],
entry: { file: 'index.js', checksum: 'dev-entry-brand-content-toolkit' },
};
@@ -0,0 +1,80 @@
/**
* I01 技能 —— 品牌内容生产的方法经验。载方法,不载权力。
*
* 与剧本的分工:剧本写「这一步做什么、写哪几列」,技能写「为什么这么判、
* 拿不准时按哪条走」。剧本改了口径,技能里同一句要跟着改(规范 §5.9)。
*/
import { skillIdSchema } from '@contracts';
import type { Skill } from '@contracts';
export const CONTENT_SKILLS: Skill[] = [
{
id: skillIdSchema.parse('brand-content.requirement-mapping'),
label: '触发事件映射到内容需求',
applicability: '识别本轮内容需求之前,要把触发事件(主动/被动)翻成具体该产什么。',
method: [
'主动触发按 `reason` 映射:`strategy_shift`/`new_market` 多半对应通用类内容',
'(公司介绍、能力声明——面向新市场或新战略方向,受众是还不认识我们的人);',
'`tool_outdated` 对应刷新既有工具包(读 `content.asset` 找版本号最旧的那份);',
'`monthly_update` 对应例行内容盘点,不是逢月必产新内容——先看有没有真正过期的。',
'',
'被动触发按 `requester` 映射:E04(关系覆盖)要的是「价值传递素材」,多半是系统方案',
'白皮书或案例集,常常带 `targetCustomer`(定向版);T03/T04/E03 目前本仓库未实现,',
'只是描述来源,产出物按它们的业务含义(触达材料/攻坚定制内容/方案支撑)对应到',
'内容类型即可,不必真的调用它们。',
'',
'同一轮识别出的多条需求不必是同一类型 —— 一次月度更新可能同时该刷新公司介绍',
'和某个过期的选型指南,分开列成两条需求,各自的优先级、目标客户可能完全不同。',
].join('\n'),
crystallized: [],
knowledgeRefs: ['触发事件 × 内容类型映射表'],
version: '1.0.0',
},
{
id: skillIdSchema.parse('brand-content.bt-structure'),
label: '按 BT 层级套内容结构、维护多语言',
applicability: '市场依据采集完、要草拟内容初稿时。',
method: [
'内容结构按决策链层级走,不是一刀切:BT-01(采购/供应链)要的是「能不能稳定供货」',
'——供货能力、资质文件、渠道政策;BT-02(技术层)要的是「参数对不对得上」——',
'技术规格书、替代对照表、认证报告;BT-03(高层)要的是「值不值得投入」——',
'系统方案白皮书、参考设计、成功案例。给错层级的内容(拿技术规格书糊弄高层)',
'传递不出该传递的价值,人审核这一步会驳回。',
'',
'多语言不是全都要三语俱全:中文必备;涉及俄罗斯市场或俄语区客户的内容配俄语;',
'其余按需求背景判断——面向国际展会或英语区客户才配英语,通用内部资料未必需要。',
'语言版本写进同一行的 `language` 列(如"中文 · Русский"),不是同一份内容开多行,',
'否则发布确认那一步的验收判据③(多语言版本是否齐全)没法在一行里核对齐不齐。',
'',
'市场依据读不到相关信号是正常结果,不是异常——不是每条需求都有现成的产业趋势',
'支持,读不到就基于已有资产与客户信息拟定,报告里如实说依据来自哪里,不编一条',
'不存在的市场信号撑场面。',
].join('\n'),
crystallized: [],
knowledgeRefs: ['BT 层级内容结构', '多语言版本策略'],
version: '1.0.0',
},
{
id: skillIdSchema.parse('brand-content.publish-flow'),
label: '版本受控、状态流转不跳步',
applicability: '审核通过之后,要把内容拟发布、准备交人签字放行时。',
method: [
'状态流转是台阶,不是开关:`draft`(草稿)→`reviewed`(已审核)→`published`(已发布),',
'每一步都要真的走过——审核通过那一刻先落 `reviewed`,拟发布那一步才落 `published`,',
'不要图省事在审核通过的同一拍就直接写成 `published`(那样发布确认那一步的判据①',
'核不出「状态流转无误」,因为 `reviewed` 这一站压根没被记录过)。',
'',
'版本号只在真正定稿时才递增到 v1.0——v0.x 是草稿阶段的内部编号,不对外。',
'同一份内容之后若再修订(如客户反馈需要调整),版本号在已发布版本基础上加小版本',
'(v1.0 → v1.1),不要退回 v0.x 或另起 content_id——同一份内容资产的版本历史',
'要能连起来看,另起编号等于假装这是一份全新的内容。',
'',
'场景与 BT 标签是发布前必须补齐的两列,不是可选的元数据——它们是 E04/T03/T04',
'将来按场景/BT 层级匹配调用这份内容时唯一的检索依据,缺了这两列,发布出去的内容',
'实际上没人找得到、等于白产。',
].join('\n'),
crystallized: [],
knowledgeRefs: ['内容状态机', '版本号递增规则'],
version: '1.0.0',
},
];
@@ -0,0 +1,163 @@
/**
* I01 品牌形象与销售工具包管理 · 视觉底(**I01 自己的 PRD 目标件**)。
*
* `docs/SS01-客户与市场/i01-html/` 下的三份 HTML(执行工单 1、验收工单 1/2)逐字量出。
* 颜色变量、卡头渐变、判据框、表格排版与 E04(`crm-relationship-coverage`)的目标件
* **逐字节相同**——两份目标件本就是同一套设计语言产出的(SS01 客户与市场同一批交付),
* 不是巧合也不是抄近路;`activities/*` 只准 import `@sdk`/`@contracts`,两个活动包之间
* 没有代码通道,只能各自持有一份(与 E04 那份原是 A1 家族版面拷贝、后改为自有目标件同理)。
*
* 版面照目标件分两层:这份文件是**底**(配色变量、卡壳、头/尾、判据框、说明框、表格基础排版),
* `report.ts` 里的 `BIND_CSS` 是**面**(哪个值决定哪种颜色/文案——三档占位、缺项红带、
* 状态徽、编辑闸开合、行增删这些"数据驱动的开关",判断在样式表,取值在模版,两处不许混)。
*/
export const PRD_CSS = `
.proto-root {
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
--brand:#3370ff; --brand-deep:#245bdb; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
--human:#a9711e; --human-soft:#f6ecd8; --human-line:#e6d3a8;
--ledger:#b23a2e; --ledger-soft:#f7e4e0; --ledger-line:#ecc5bd;
--ok:#278717; --ok-soft:#e8f8e4;
--danger:#d83931; --danger-soft:#feeae9;
--warn-accent:#a9711e; --warn-soft:#f6ecd8;
--mono: "SF Mono", Menlo, Consolas, monospace;
--t-fast: .16s;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
color: var(--ink);
font-size: 14px;
line-height: 1.7;
}
.proto-root, .proto-root * { box-sizing: border-box; }
/* ==================== 卡壳(嵌进工作区,不自带浮起——外层已经是一张卡)==================== */
.proto-root .jc-report { background: #fff; overflow: hidden; }
.proto-root .jc-rp-head { display: flex; align-items: center; gap: 14px; padding: 18px 22px; border-bottom: 2px solid var(--ink); }
.proto-root .jc-report.tone-human .jc-rp-head { background: linear-gradient(120deg, #fffaf1 0%, #fff 55%, #fbf6ea 100%); }
.proto-root .jc-report.tone-ledger .jc-rp-head { background: linear-gradient(120deg, #fdf3f1 0%, #fff 55%, #fbf2f0 100%); }
.proto-root .jc-rp-badge {
flex: none; width: 44px; height: 44px; border-radius: 10px;
display: grid; place-items: center; color: #fff; font-weight: 800; font-size: 13px;
}
.proto-root .jc-report.tone-human .jc-rp-badge { background: var(--human); box-shadow: 0 6px 16px rgba(169,113,30,.25); }
.proto-root .jc-report.tone-ledger .jc-rp-badge { background: var(--ledger); box-shadow: 0 6px 16px rgba(178,58,46,.25); }
.proto-root .jc-rp-tit { flex: 1; min-width: 0; }
.proto-root .jc-rp-tit em { font-style: normal; font-size: 11px; letter-spacing: .08em; color: var(--ink-4); font-weight: 700; }
.proto-root .jc-rp-tit h3 { margin: 2px 0 0; font-size: 19px; font-weight: 800; line-height: 1.3; }
.proto-root .jc-rp-sub { font-size: 12px; color: var(--ink-3); margin-top: 3px; }
.proto-root .jc-rp-side { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; flex: none; }
/* 目标件 side 第二行:步骤(静态文案),同目标件 .step 的字号字色 */
.proto-root .jc-rp-step { font-size: 12px; color: var(--ink-3); }
/* 关联任务号:全长 UUID 如实给,排版上截尾 —— 截断是排版,不是数据 */
.proto-root .jc-rp-taskid {
display: inline-block; max-width: 150px; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; vertical-align: bottom; font-family: var(--mono); font-size: 11px;
}
.proto-root .jc-rp-tag { font-size: 11px; font-weight: 800; padding: 2px 10px; border-radius: 999px; border: 1px solid; }
.proto-root .jc-report.tone-human .jc-rp-tag { color: var(--human); background: var(--human-soft); border-color: var(--human-line); }
.proto-root .jc-report.tone-ledger .jc-rp-tag { color: var(--ledger); background: var(--ledger-soft); border-color: var(--ledger-line); }
.proto-root .jc-rp-wait { font-size: 12px; color: var(--ink-3); }
.proto-root .jc-rp-wait.warn { color: var(--danger); font-weight: 700; }
.proto-root .jc-rp-bd { padding: 20px 22px; }
/* 【做什么】/【交回什么】这类分区小标——括号label + 破折号说明,同一行 */
.proto-root .jc-rp-part { font-size: 11px; font-weight: 800; letter-spacing: .12em; color: var(--ink-4); margin: 20px 0 8px; }
.proto-root .jc-rp-part:first-child { margin-top: 0; }
.proto-root .jc-rp-part b { color: var(--ink-3); }
/* AI 已完成 / 需要你 —— 说明框,左侧色条按 tone 走 */
.proto-root .jc-derive { border: 1px solid var(--line); border-left: 3px solid; border-radius: 8px; background: var(--soft); padding: 14px 16px; margin-bottom: 16px; }
.proto-root .jc-report.tone-human .jc-derive { border-left-color: var(--human); }
.proto-root .jc-report.tone-ledger .jc-derive { border-left-color: var(--ledger); }
.proto-root .jc-derive p { margin: 0; font-size: 13px; color: var(--ink-2); }
.proto-root .jc-derive p + p { margin-top: 6px; }
.proto-root .jc-derive b { color: var(--ink); }
.proto-root .jc-derive .done { color: var(--ok); font-weight: 700; }
.proto-root .jc-report.tone-human .jc-derive .need { color: var(--human); font-weight: 700; }
.proto-root .jc-report.tone-ledger .jc-derive .need { color: var(--ledger); font-weight: 700; }
/* 验收判据框——目标件里两份验收工单同一色(赭红),不随 tone 变 */
.proto-root .jc-criteria { border: 1px solid var(--ledger-line); border-radius: 10px; background: var(--ledger-soft); padding: 13px 16px; margin-bottom: 18px; }
.proto-root .jc-criteria-t { font-size: 11px; font-weight: 800; letter-spacing: .08em; color: var(--ledger); margin-bottom: 7px; }
.proto-root .jc-criteria ul { margin: 0; padding-left: 18px; font-size: 12.5px; color: var(--ink-2); }
.proto-root .jc-criteria li { margin: 4px 0; }
.proto-root .jc-criteria b { color: var(--ink); }
/* 表格基础排版(交互态的显隐开关在 BIND_CSS) */
/*
* **表宽超出就得能横着滑**(2026-08-23 用户:「表格已经显示不全了,不能左右滑动」)。
* 从前这里是 overflow: hidden —— 那是为了让圆角裁住表头,可它同时把超出的列
* **直接裁掉且滑不动**:列一多(或某列为了放下下拉框加宽)就看不全,人还没有任何办法。
* quote-* 各包一直是 overflow-x: auto,这两个新包抄版式时把这一条抄丢了。
* (注释里不写反引号 —— 整段 CSS 是模版字符串,一个反引号就把它截断,本轮踩第三次。)
* 竖向仍 hidden:件是整篇铺开跟着外层滚的,表格自己不该再套一层竖滚动条。
*/
.proto-root .jc-tbl-wrap { border: 1px solid var(--line-soft); border-radius: 10px; overflow-x: auto; overflow-y: hidden; margin-bottom: 4px; }
/*
* 资料名 = 可点开的那一格(2026-08-23)。**看得出能点**才会有人点:
* 品牌蓝 + 下划虚线 + 手型;悬停实线。点了去哪由外层接(件里一行脚本也没有)。
*/
.proto-root .jc-fileln { color: var(--brand); border-bottom: 1px dashed var(--brand-soft); cursor: pointer; transition: all var(--t-fast); }
.proto-root .jc-fileln:hover { border-bottom-style: solid; background: var(--brand-wash); }
/* 空标题不该长得像能点开 —— 那一格根本没有资料 */
.proto-root .jc-fileln:empty { border-bottom: 0; cursor: default; background: none; }
/* 格式徽:摆的是这份资料交出去时的样子(md / html / docx / xlsx) */
.proto-root .jc-fmt { display: inline-flex; align-items: center; justify-content: center; min-width: 44px; min-height: 19px;
padding: 1px 7px; border-radius: 5px; font-size: 10px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase;
background: var(--soft); color: var(--ink-3); border: 1px solid var(--line); }
.proto-root .jc-fmt.v-docx { background: #eaf1ff; color: #2b5fd9; border-color: #c3d6ff; }
.proto-root .jc-fmt.v-xlsx { background: #e9f7ee; color: #1f7a45; border-color: #bfe6cd; }
.proto-root .jc-fmt.v-html { background: #fdf0e6; color: #b25e15; border-color: #f3d3b4; }
.proto-root .jc-fmt.v-md { background: #f2f3f5; color: #5a6068; border-color: #dfe1e5; }
.proto-root .jc-tbl-in { width: 100%; border-collapse: collapse; font-size: 13px; }
.proto-root .jc-tbl-in th { text-align: left; background: var(--soft); font-size: 11px; letter-spacing: .04em; font-weight: 800; color: var(--ink-3); padding: 9px 11px; border-bottom: 1px solid var(--line); }
.proto-root .jc-tbl-in th.req::after { content: " *"; color: var(--danger); font-size: 11px; }
.proto-root .jc-tbl-in th.sug::after { content: " 建议"; color: var(--warn-accent); font-size: 10px; font-weight: 600; }
.proto-root .jc-tbl-in th.opt::after { content: " 选填"; color: var(--ink-4); font-size: 10px; font-weight: 600; }
.proto-root .jc-tbl-in td { padding: 9px 11px; border-bottom: 1px solid var(--line-soft); vertical-align: top; }
.proto-root .jc-tbl-in tr:last-child td { border-bottom: 0; }
.proto-root .jc-tbl-in td.jc-rn { width: 38px; white-space: nowrap; font-family: var(--mono); font-size: 11px; color: var(--ink-4); text-align: center; position: relative; }
.proto-root .jc-vcell { display: block; font-size: 12.5px; color: var(--ink); line-height: 1.5; overflow-wrap: anywhere; }
.proto-root .jc-vcell.mono { font-family: var(--mono); font-size: 11.5px; color: var(--ink-2); }
.proto-root .jc-vcell.cust { font-weight: 700; }
.proto-root .jc-vcell.dim { color: var(--ink-4); text-decoration: line-through; }
.proto-root .jc-diffnew { font-weight: 800; color: var(--ok); }
.proto-root .jc-cell, .proto-root select.jc-cell, .proto-root textarea.jc-cell {
width: 100%; min-width: 60px; border: 1px solid var(--line); border-radius: 6px;
padding: 6px 8px; font: inherit; font-size: 12.5px; color: var(--ink); background: #fff;
}
.proto-root .jc-cell.mono { font-family: var(--mono); font-size: 11.5px; }
.proto-root .jc-cell:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-wash); outline: none; }
.proto-root .jc-rowdel {
width: 22px; height: 22px; margin: 0 auto; cursor: pointer;
border: 1px solid var(--line); border-radius: 6px; padding: 0;
font: inherit; font-size: 14px; 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-rowdel:hover { border-color: var(--danger); color: var(--danger); background: var(--danger-soft); }
.proto-root .jc-addrow em { font-style: normal; font-size: 11.5px; color: var(--ink-4); }
.proto-root .jc-addrow button {
cursor: pointer; border: 1px solid var(--brand-soft); border-radius: 7px; padding: 5px 12px;
font: inherit; font-size: 12px; font-weight: 700; color: var(--brand-deep); background: var(--brand-wash);
}
.proto-root .jc-addrow button:hover { background: var(--brand-soft); }
/* 本工单只承载内容,提交/审批请在对话区——目标件三份文件都有这一条 */
.proto-root .jc-flow { margin-top: 16px; padding: 9px 12px; border-radius: 8px; background: var(--soft); border: 1px dashed var(--line); font-size: 12px; color: var(--ink-3); }
.proto-root .jc-flow b { color: var(--ink); }
.proto-root .jc-rp-foot { padding: 13px 22px; border-top: 1px solid var(--line-soft); font-size: 11.5px; color: var(--ink-4); display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; }
.proto-root .jc-rp-foot span:last-child { font-family: var(--mono); }
`;
@@ -0,0 +1,387 @@
/**
* I01 品牌形象与销售工具包管理的 HTML 报告模版 —— **活动自己持有的资产**,随包版本冻结。
*
* 三个局面各一张:
* · `requirement-list` —— S1/S2 摆,内容需求清单,可编辑(增删改);
* · `draft-review` —— S3/S4 摆,内容初稿,**只读**(业务说明明文"人只审不改" ——
* 通过/驳回的按钮在对话区的决策卡上,不在这份件里);
* · `publish-confirm` —— S5/T_release 摆,拟发布内容,**只读**,放行后即归档件。
*
* 版面语言逐字量自 I01 自己的目标件(`docs/SS01-客户与市场/i01-html/` 三份 HTML,
* `prd-style.ts` 头注写了它与 E04 目标件"逐字节相同"的原因)。
*
* 取值三纪律:顶层只取汇总列与单值列;逐条走 `mine:content_brand.<object>` 或
* `mine:content.asset`;列名逐字照 `declarations.ledger` 抄。**模版不算不判** ——
* 缺必填红带、状态徽、排序位次全部由基座算好的数经 `data-class-of` 投影,判断留在样式表。
*/
import { PRD_CSS } from './prd-style.ts';
const D = 'content_brand';
/* ==================== 绑定期补的样式(数据驱动的开关,判断都在这一层)==================== */
const BIND_CSS = `
/* 空占位三级(全局口径):必填红 / 建议填橘 / 选填灰 —— 值真的空才轮到 */
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after,
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after,
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after {
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
}
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after { content: "必填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after { content: "建议填"; color: var(--warn-accent); background: var(--warn-soft); border-color: #f0cf9a; }
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after { content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line); }
/* 归档件 / 只读件里空格子就是「没有」,不再摆「你还能填」的胶囊 */
.proto-root .jc-tbl-in td.dimempty > .jc-vcell:empty::after { content: "—"; color: var(--ink-4); border: 0; background: none; padding: 0; }
/* 缺必填的整行浅红 + 行首红条 —— 由 :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; }
.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); }
/* 就地改:可改格子渲了只读与输入两份,默认锁着,表上加 on 才换孪生件 */
.proto-root .jc-cell { display: none; }
.proto-root .jc-tbl-in.on td > .jc-vcell { display: none; }
.proto-root .jc-tbl-in.on td > .jc-cell { display: block; }
/* 编辑态下必填空格子:红框 + 整行浅红(由 :placeholder-shown 认) */
.proto-root .jc-tbl-in.on td.req > .jc-cell:placeholder-shown { border-color: #f3b6b3; background: #fffafa; }
.proto-root .jc-tbl-in.on tbody tr:has(td.req > .jc-cell:placeholder-shown) td { background: #fffbfb; }
/* 铺满工作区,卡壳不再自带外边框(外层容器已经是一张卡) */
.jc-stage { max-width: none; padding: 0 0 4px; }
/* 那道闸 —— 默认「已锁定 + 编辑」,开了变成「取消 / 保存」;只在可改屏现身 */
.proto-root .jc-editgate { display: none; }
html.editable .proto-root .jc-editgate {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin: 14px 0 10px; padding: 9px 12px; border-radius: 9px;
background: var(--soft); border: 1px solid var(--line-soft);
font-size: 12px; color: var(--ink-3);
}
.proto-root .jc-editgate-t { 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: 5px; cursor: pointer;
border: 1px solid var(--line); border-radius: 7px; padding: 6px 13px;
font: inherit; font-size: 12px; 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(--human); border-color: var(--human); }
.proto-root .jc-egbtn.save:hover { filter: brightness(1.06); color: #fff; }
.proto-root .jc-editgate .jc-egbtn.ghost, .proto-root .jc-editgate .jc-egbtn.save { display: none; }
.proto-root .jc-editgate.on { background: var(--human-soft); border-color: var(--human-line); color: #7c4a12; }
.proto-root .jc-editgate.on .jc-egbtn.open { display: none; }
.proto-root .jc-editgate.on .jc-egbtn.ghost, .proto-root .jc-editgate.on .jc-egbtn.save { display: inline-flex; }
.proto-root .jc-editgate.on .jc-editgate-t { visibility: hidden; }
/* 缺必填红带 —— 只在可改屏出,一格不缺自动收(v-0 那一档 display:none) */
.proto-root .jc-gapbar { display: none; }
html.editable .proto-root .jc-gapbar {
display: flex; align-items: flex-start; gap: 8px;
margin: 0 0 10px; padding: 9px 12px; border-radius: 9px;
background: #fff5f5; border: 1px solid #f3b6b3; color: var(--danger);
font-size: 12px; line-height: 1.65;
}
.proto-root .jc-gapbar b { font-weight: 800; }
.proto-root .jc-gapbar span { color: #9a3b36; }
html.editable .proto-root .jc-gapbar.v-0 { display: none; }
/* 那条「怎么改」的提示只在开了闸之后说 */
.proto-root .jc-editbar { display: none; }
.proto-root .jc-editgate.on + .jc-editbar {
display: flex; align-items: flex-start; gap: 8px;
margin: 0 0 10px; padding: 9px 12px; border-radius: 9px;
background: var(--brand-wash); border: 1px dashed var(--brand-soft); color: var(--ink-3);
font-size: 12px; line-height: 1.6;
}
.proto-root .jc-editbar b { color: var(--ink-2); }
/* 增删行 */
.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; }
.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);
}
/*
* 统一的枚举标签(优先级 / 内容状态 共用同一套机制):值是机器码,人读名与颜色一律由
* data-class-of 投影出的 .v-xxx 决定,不写死在取值那一侧。
*/
.proto-root .jc-tag { font-weight: 800; }
.proto-root .jc-tag::after { content: "待定"; color: var(--ink-4); font-weight: 600; }
.proto-root .jc-tag.v-high::after { content: "高"; }
.proto-root .jc-tag.v-high { color: var(--danger); font-family: var(--mono); }
.proto-root .jc-tag.v-medium::after { content: "中"; }
.proto-root .jc-tag.v-medium { color: var(--ink-2); font-family: var(--mono); }
.proto-root .jc-tag.v-low::after { content: "低"; }
.proto-root .jc-tag.v-low { color: var(--ink-4); font-family: var(--mono); }
.proto-root .jc-tag.v-draft::after { content: "草稿"; }
.proto-root .jc-tag.v-draft { color: var(--ink-3); }
.proto-root .jc-tag.v-reviewed::after { content: "已审核"; }
.proto-root .jc-tag.v-reviewed { color: var(--warn-accent); }
.proto-root .jc-tag.v-published::after { content: "已发布"; }
.proto-root .jc-tag.v-published { color: var(--ok); }
.proto-root .jc-tag.v-deprecated::after { content: "已废弃"; }
.proto-root .jc-tag.v-deprecated { color: var(--ink-4); text-decoration: line-through; }
`;
/** 三层壳照目标件:少一层样式表全失效 */
/**
* 三层壳照目标件。卡头/页脚的共用动态位照说明书 §6.2 补齐(2026-08-23):
* 收件人(case:owner)、**关联任务**(case:taskId,基座注入)、**步骤**(step,
* 每份工单盖哪几步是写清单时定死的静态文案)、页脚**单号**(round_ref)。
* 目标件还有一格「最迟交回」—— 本仓没有工单期限这个事实,**不摆假数据**,那一格不做
* (与 E04 同一口径)。
*/
const doc = (input: {
tone: 'human' | 'ledger';
badge: string;
tag: string;
kicker: string;
title: string;
titleRef: string;
/** 目标件 side 第二行:静态步骤文案 */
step: string;
state: string;
body: string;
foot: string;
}): string =>
`<style>${PRD_CSS}${BIND_CSS}</style>
<div class="proto-root"><div class="app-shell"><div class="jc-stage">
<article class="jc-report tone-${input.tone}">
<header class="jc-rp-head">
<div class="jc-rp-badge">${input.badge}</div>
<div class="jc-rp-tit">
<em>${input.kicker}</em>
<h3>${input.title} <span data-slot="${input.titleRef}"></span></h3>
<div class="jc-rp-sub">收件人:<span data-slot="case:owner"></span> · 关联任务:<span class="jc-rp-taskid" data-slot="case:taskId" data-blank></span></div>
</div>
<div class="jc-rp-side">
<span class="jc-rp-tag">${input.tag}</span>
<span class="jc-rp-step">${input.step}</span>
${input.state}
</div>
</header>
<div class="jc-rp-bd">${input.body}</div>
<footer class="jc-rp-foot">${input.foot}</footer>
</article>
</div></div></div>`;
const part = (label: string, aside = ''): string =>
`<p class="jc-rp-part"><b>【${label}】</b>${aside === '' ? '' : ` —— ${aside}`}</p>`;
const criteria = (title: string, items: readonly string[]): string =>
`<div class="jc-criteria"><div class="jc-criteria-t">${title}</div><ul>${items.map((i) => `<li>${i}</li>`).join('')}</ul></div>`;
const flow = (text: string): string => `<div class="jc-flow">${text}</div>`;
/** 页脚右侧照目标件长「工单类型:执行 · <单号>」—— 目标件的 card.id 没有真数据源,摆本轮案号 */
const sign = (note: string, tag: string): string =>
`<span>${note}</span><span>${tag} · <span data-slot="round_ref" data-blank></span></span>`;
/** 编辑闸 —— `label` 是闸上那句话(照目标件:「AI 已识别 N 个内容需求,点「编辑」增删改」) */
const editGate = (address: string, label: string): string => `<div class="jc-editgate" data-edit-for="${address}">
<span class="jc-editgate-t">${label}</span>
<button type="button" class="jc-egbtn open" data-edit-open="${address}">编辑</button>
<button type="button" class="jc-egbtn ghost" data-edit-cancel="${address}">取消</button>
<button type="button" class="jc-egbtn save" data-edit-save="${address}">保存</button>
</div>`;
/* ==================== 局面一 · 内容需求清单(S1/S2)==================== */
const REQUIREMENT_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
<thead><tr>
<th style="width:36px;min-width:36px"></th>
<th style="width:56px;min-width:56px">序</th>
<th class="req" style="min-width:150px">内容类型</th>
<th class="sug" style="min-width:170px">需求来源</th>
<th class="sug" style="min-width:120px">目标客户</th>
<th class="sug" style="min-width:100px">适用场景</th>
<th style="width:80px;min-width:80px">优先级</th>
</tr></thead>
<tbody data-rows="mine:${D}.requirement" data-edit="${D}.requirement"><tr>
<td class="jc-rn"><button type="button" class="jc-rowdel" data-edit-del="${D}.requirement" title="撤掉这一行">×</button></td>
<td data-h="序"><span class="jc-vcell mono" data-slot="req_seq" data-col="req_seq" data-blank></span></td>
<td class="req" data-h="内容类型"><span class="jc-vcell" data-slot="content_type" data-col="content_type" data-blank></span></td>
<td class="sug" data-h="需求来源"><span class="jc-vcell" data-slot="source" data-col="source" data-blank></span></td>
<td class="sug" data-h="目标客户"><span class="jc-vcell" data-slot="target_customer" data-col="target_customer" data-blank></span></td>
<td class="sug" data-h="适用场景"><span class="jc-vcell" data-slot="scene" data-col="scene" data-blank></span></td>
<td data-h="优先级"><span class="jc-tag" data-class-of="priority"></span></td>
</tr></tbody>
</table></div>`;
export const REQUIREMENT_LIST = doc({
tone: 'human',
badge: '执行',
tag: '执行工单',
kicker: 'I01 品牌形象与销售工具包管理 · 执行工单 1/3',
title: '需求受理与信息补充',
titleRef: 'round_ref',
step: '步骤 ① 需求识别与受理',
state: `<span class="jc-rp-wait">共 <b data-slot="${D}_requirement_rows"></b> 条需求</span>`,
body:
part('做什么', '确认本轮要生产的内容') +
`<div class="jc-derive">
<p><span class="done">AI 已完成</span>:按规则识别本轮内容需求来源(战略调整 / 新市场 / 工具过期 / 使用方请求)与
需求类型(公司介绍 / 能力声明 / 销售工具包 / 案例集 / 白皮书等),产出下方需求清单(候选基线)。</p>
<p class="jc-note-line">本轮触发:<b data-slot="trigger" data-blank></b> <b data-slot="trigger_detail" data-blank></b></p>
<p class="jc-note-line">本轮说明:<span data-slot="note" data-blank></span></p>
<p><span class="need">需要你查漏补缺</span>:点「编辑」在清单上增删改 —— <b>加一行 = 补充新需求</b>、
<b>删一行 = 排除</b>、<b>改单元格 = 修正</b>;尤其要补「目标客户、适用场景、优先级」这些 AI 规则之外的信息。</p>
</div>` +
part('交回什么', '核对后的内容需求清单(*为必填)') +
`<div class="jc-gapbar" data-class-of="${D}_requirement_missing">
<span><b>还差 <span data-slot="${D}_requirement_missing"></span> 行内容类型没填</b>
—— <span data-slot="${D}_requirement_missing_keys"></span>。
按右上角「编辑」开表,红框那几格就是要填的。补齐了执行单才提交得了。</span>
</div>` +
editGate(
`${D}.requirement`,
`<b>AI 已识别 <span data-slot="${D}_requirement_rows"></span> 个内容需求</b>,点「编辑」增删改`,
) +
`<div class="jc-editbar">
<span>加一行 = 补充新需求,删一行 = 排除,改单元格 = 修正 AI 识别的内容。
<b>内容类型</b>是必填,缺一条这一单走不到内容创作那一步。
改动只落任务草稿层,<b>保存不等于放行</b>。</span>
</div>` +
REQUIREMENT_TABLE +
`<div class="jc-addrow">
<button type="button" class="jc-egbtn ghost" data-edit-add="${D}.requirement">+ 加一行(补充新需求)</button>
<em>新加行的「内容类型」为必填;「目标客户」填定向客户,通用内容可留空;<b>序号留空即可</b> —— 系统按本轮现有最大值往下续。</em>
</div>` +
flow('<b>审批 / 提交:</b>本工单只承载内容;「提交回执」请在对话区的【执行卡】中进行。'),
foot: sign('I01 品牌形象与销售工具包管理 · 执行工单 1/3 · 需求受理', '工单类型:执行'),
});
/* ==================== 局面二 · 内容初稿(S3/S4,只读)==================== */
/*
* **资料名点得开**(2026-08-23 用户定):标题那一格挂 `data-file-of="content_id"`,
* 渲染时换成 `data-file="content.asset/<内容ID>"` —— 点它,工作区就跳到那一份资料的
* 查看面(基座按台账那一行现造成 docx / xlsx / md / html 再读回来渲)。
* 这份件仍然只做取值:「点了去哪」不在 HTML 里,它只说清「这一格是哪一份资料」。
*
* 格式一列摆在标题右边 —— 人点之前就知道点开的是张表还是一份文档。
*/
const DRAFT_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
<thead><tr>
<th style="width:40px;min-width:40px"></th>
<th style="min-width:200px">内容标题</th>
<th style="width:70px;min-width:70px">资料</th>
<th style="min-width:130px">内容类型</th>
<th style="width:120px;min-width:120px">语言版本</th>
<th style="width:80px;min-width:80px">BT 标签</th>
<th style="width:110px;min-width:110px">品牌调性</th>
<th style="width:90px;min-width:90px">状态</th>
</tr></thead>
<tbody data-rows="mine:content.asset">
<tr>
<td class="jc-rn"></td>
<td class="dimempty" data-h="内容标题"><span class="jc-vcell cust jc-fileln" data-file-of="content_id" data-slot="title" data-blank></span></td>
<td data-h="资料"><span class="jc-fmt" data-class-of="file_format" data-slot="file_format" data-blank></span></td>
<td class="dimempty" data-h="内容类型"><span class="jc-vcell" data-slot="content_type" data-blank></span></td>
<td class="dimempty" data-h="语言版本"><span class="jc-vcell mono" data-slot="language" data-blank></span></td>
<td class="dimempty" data-h="BT 标签"><span class="jc-vcell mono" data-slot="bt_tag" data-blank></span></td>
<td class="dimempty" data-h="品牌调性"><span class="jc-vcell mono" data-slot="brand_tone" data-blank></span></td>
<td data-h="状态"><span class="jc-tag" data-class-of="content_status"></span></td>
</tr>
</tbody>
</table></div>`;
export const DRAFT_REVIEW = doc({
tone: 'ledger',
badge: '验收',
tag: '验收工单',
kicker: 'I01 品牌形象与销售工具包管理 · 验收工单 2/3',
title: '内容初稿审核',
titleRef: 'round_ref',
step: '步骤 ②③④ 产出',
state: `<span class="jc-rp-wait">共 <b data-slot="content_asset_rows"></b> 份初稿</span>`,
body:
part('AI 结论说明', '结论是怎么算出来的') +
`<div class="jc-derive">
<p><b>AI 已完成的计算</b>:读取 L-01 宏观市场信号(产业趋势、市场依据),按 BT 层级草拟内容初稿——
BT-01 侧重供货能力 / 资质文件、BT-02 侧重技术规格书 / 替代对照表 / 认证报告、
BT-03 侧重系统方案白皮书 / 参考设计 / 成功案例;维护多语言版本(中 / 英 / 俄)。</p>
</div>` +
part('验收判据', '你据此判断「通过 / 驳回」') +
criteria('判断这份内容初稿是否合格,请逐项核对', [
'<b>品牌调性是否一致</b>:是否符合品牌"气质"标准(专业 / 可信赖 / 高效 / 本土化);',
'<b>技术内容是否准确</b>:规格、数据、认证信息有无错误;',
'<b>合规与多语言校对</b>:合规检查、多语言校对是否通过。',
]) +
part('AI 产出的内容初稿', '只读展示') +
DRAFT_TABLE +
flow('<b>审批:</b>本工单只承载内容;「通过 / 驳回」请在对话区的【验收卡】中进行。通过后进入发布,驳回由 AI 重做。'),
foot: sign('I01 品牌形象与销售工具包管理 · 验收工单 2/3 · 内容初稿审核', '工单类型:验收'),
});
/* ==================== 局面三 · 拟发布内容(S5/T_release,只读)==================== */
const PUBLISH_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
<thead><tr>
<th style="width:40px;min-width:40px"></th>
<th style="min-width:110px">内容 ID</th>
<th style="min-width:200px">标题</th>
<th style="width:120px;min-width:120px">语言版本</th>
<th style="width:80px;min-width:80px">版本号</th>
<th style="min-width:100px">适用场景</th>
<th style="width:80px;min-width:80px">BT 标签</th>
<th style="min-width:150px">状态流转</th>
</tr></thead>
<tbody data-rows="mine:content.asset">
<tr>
<td class="jc-rn"></td>
<td class="dimempty" data-h="内容 ID"><span class="jc-vcell mono" data-slot="content_id" data-blank></span></td>
<td class="dimempty" data-h="标题"><span class="jc-vcell cust jc-fileln" data-file-of="content_id" data-slot="title" data-blank></span></td>
<td class="dimempty" data-h="语言版本"><span class="jc-vcell mono" data-slot="language" data-blank></span></td>
<td class="dimempty" data-h="版本号"><span class="jc-vcell mono" data-slot="version" data-blank></span></td>
<td class="dimempty" data-h="适用场景"><span class="jc-vcell" data-slot="scene" data-blank></span></td>
<td class="dimempty" data-h="BT 标签"><span class="jc-vcell mono" data-slot="bt_tag" data-blank></span></td>
<td data-h="状态流转"><span class="jc-vcell dim">已审核</span> → <span class="jc-tag" data-class-of="content_status"></span></td>
</tr>
</tbody>
</table></div>`;
export const PUBLISH_CONFIRM = doc({
tone: 'ledger',
badge: '验收',
tag: '验收工单',
kicker: 'I01 品牌形象与销售工具包管理 · 验收工单 3/3',
title: '发布确认',
titleRef: 'round_ref',
step: '步骤 ⑤ 发布入库',
state: `<span class="jc-rp-wait">共 <b data-slot="content_asset_content_status_published"></b> 份拟发布</span>`,
body:
part('AI 结论说明', 'AI 做了什么') +
`<div class="jc-derive">
<p><b>AI 已完成</b>:内容初稿通过审核后,拟正式发布 —— 更新版本号、状态流转(待审核 → 已审核 → 已发布)、
登记适用场景与品牌调性标签,写入 L-05 品牌内容库。本轮产出:<span data-slot="summary" data-blank></span></p>
</div>` +
part('验收判据', '你据此判断「通过 / 驳回」') +
criteria('判断这次发布是否无误,请逐项核对', [
'<b>版本号与状态是否正确</b>:版本号已更新、状态流转(草稿 → 已审核 → 已发布)无误;',
'<b>场景与 BT 标签是否登记完整</b>;',
'<b>多语言版本是否齐全</b>。',
]) +
part('拟发布的内容', '只读展示') +
PUBLISH_TABLE +
flow('<b>审批:</b>本工单只承载内容;「通过入库 / 驳回」请在对话区的【验收卡】中进行。通过即落账写入 L-05,本次内容生产结束。'),
foot: sign('I01 品牌形象与销售工具包管理 · 验收工单 3/3 · 发布确认', '工单类型:验收 → 完结'),
});
@@ -0,0 +1,564 @@
/**
* E04 关系覆盖与关键人经营的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。
* 案例活动没有 `run`,自测不跑回合;测的是清单本身与四处对齐
* (判据谓词 / 台账声明的三级占位 / 决策卡循环的骨架 / 报告模版的读写面)。
*/
import { describe, expect, it } from 'vitest';
import { activityManifestSchema } from '@contracts';
import { evaluateCondition } from '@sfeel';
import { createMockHost } from '@sdk';
import { manifest } from '../src/manifest.ts';
import activity from '../src/index.ts';
describe('清单本身合规', () => {
it('过 @contracts 的 schema', () => {
expect(() => activityManifestSchema.parse(manifest)).not.toThrow();
});
it('没有悬空监听(E04 本轮没有静态监听声明)', () => {
expect(manifest.declarations.listeners).toEqual([]);
});
it('L1/L2 判据都给了可机检的谓词 —— 验收等级不得谎报', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef, c.key).toBeDefined();
}
});
it('禁区短而硬,授权边界给了调人预算', () => {
expect(manifest.contract.prohibitions.length).toBeGreaterThan(0);
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(0);
});
it('每件必交付物都有台账地址', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(d.ledger, d.key).toBeDefined();
}
});
/*
* **人环预算必须严格大于「最后一次拍板」时的实际调人次数**(2026-08-20 实测栽过):
* 基座按 `used >= cap` 判耗尽,若 cap 恰等于完成放行所需的调人数,「裁决:pass」
* 那一次回复本身就把 used 顶到 cap,harnessCheck 在 land() 之前就判 exhausted ——
* 验收卡显示全部达标、人也点了通过,任务却终态 exhausted,草稿从没合并进正式版。
* 基线四次(S2/S4通过/S5/T_release)+ 两轮决策卡循环(每轮 +2)= 8,留够余量。
*/
it('人环预算留够余量:基线四次 + 两轮决策卡循环仍在 cap 之内', () => {
const baseline = 4;
const twoRejectRounds = baseline + 4;
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(twoRejectRounds);
expect(manifest.contract.resources.budget.humanCalls).toBeGreaterThan(twoRejectRounds);
});
it('起单只必填 trigger,eventType/customers/note 都可选 —— 两级下拉+客户数组的形状', () => {
expect(manifest.contract.input.data['required']).toEqual(['trigger']);
const properties = manifest.contract.input.data['properties'] as Record<
string,
{ enum?: unknown[]; showWhen?: { field?: string; equals?: string } }
>;
for (const key of ['trigger', 'eventType', 'customers', 'note']) {
expect(properties[key], `入参少了 ${key}`).toBeDefined();
}
expect(properties['trigger']?.enum).toEqual(['monthly', 'event']);
expect(properties['eventType']?.enum).toEqual(['new_customer', 'key_person_change', 'major_deal', 't04_project']);
// eventType 只在 trigger:'event' 时该露面 —— 测试台靠这个字段做级联下拉
expect(properties['eventType']?.showWhen).toEqual({ field: 'trigger', equals: 'event' });
});
it('五条常备样例形状真不同:月度例行 1 条 + 事件驱动四种事件类型各 1 条', () => {
expect(manifest.samples.length).toBe(5);
const [majorDeal, monthly, keyPerson, newCustomer, t04] = manifest.samples;
type Input = { trigger?: string; eventType?: string; customers?: unknown[]; note?: string };
expect((monthly?.input as Input).trigger).toBe('monthly');
expect((monthly?.input as Input).customers).toBeUndefined();
expect((majorDeal?.input as Input).eventType).toBe('major_deal');
expect(typeof (majorDeal?.input as Input).customers?.[0]).toBe('string');
expect((keyPerson?.input as Input).eventType).toBe('key_person_change');
expect((keyPerson?.input as Input).customers).not.toEqual((majorDeal?.input as Input).customers);
// 新客户样例:customers 里是对象(新建 L-02 档案的字段),不是客户编码字符串
expect((newCustomer?.input as Input).eventType).toBe('new_customer');
const newCustomerItem = (newCustomer?.input as Input).customers?.[0] as { name?: string } | undefined;
expect(typeof newCustomerItem).toBe('object');
expect(newCustomerItem?.name).toBeDefined();
// 攻坚项目(T04)立项:四种事件类型里最后一种,点名已有客户(字符串形状)
expect((t04?.input as Input).eventType).toBe('t04_project');
expect((t04?.input as Input).customers).toEqual(['CU-0142']);
// 四种事件类型两两不同,凑齐 enum 的全部取值
const eventTypes = new Set([majorDeal, keyPerson, newCustomer, t04].map((s) => (s?.input as Input).eventType));
expect(eventTypes).toEqual(new Set(['major_deal', 'key_person_change', 'new_customer', 't04_project']));
for (const s of manifest.samples) expect(s.expect.ports).toEqual(['coverage-map']);
});
/*
* **落到这个活动上就该看见一份填好的入参**(2026-08-23 用户:「询价受理起单时左边
* 都有预填好的活动输入,E04 也要」)。测试台自动铺的是 `samples[0]`,所以第一条
* 必须四个字段全有值 —— 从前第一条是月度例行、只有 trigger 一个键,表单看着像没预填。
* 每一条也都至少要有背景说明:整屏只有一个下拉有值的样例,人看不出这活动吃什么。
*/
it('样例 0 是满载的那条(四个字段全有值)—— 测试台落地即预填', () => {
const first = manifest.samples[0]?.input as Record<string, unknown>;
for (const key of ['trigger', 'eventType', 'customers', 'note']) {
expect(first[key], `样例 0 缺 ${key}`).toBeDefined();
}
expect((first['customers'] as unknown[]).length).toBeGreaterThan(0);
});
it('每条样例都带本轮说明,且样例名 ≤14 字(超了测试台只显示「样例 N」)', () => {
for (const s of manifest.samples) {
expect((s.input as { note?: string }).note, `样例「${s.name}」没有 note`).toBeTruthy();
expect(s.name.length, `样例名「${s.name}」超过 14 字`).toBeLessThanOrEqual(14);
}
});
it('主数据追加列全部显式标 kind:master —— 与平台声明一致,不然静态审会拒', () => {
for (const object of ['profile', 'contact']) {
const decl = manifest.declarations.ledger.find((d) => d.domain === 'customer' && d.object === object);
expect(decl?.kind, `customer.${object}`).toBe('master');
}
});
it('influence / priority / completion_status 三个 tally 列的取值域全是 ASCII', () => {
for (const d of manifest.declarations.ledger) {
for (const f of d.fields) {
for (const value of f.tally ?? []) {
expect(/^[a-z0-9_]+$/.test(value), `${d.domain}.${d.object}.${f.name} 的取值 ${value} 不是纯 ASCII —— data-class-of 会把它清空成空 class`).toBe(true);
}
}
}
});
it('customer.profile 新增 cust_type:L-02 文档明列的字段,expect 而非 required,不带 tally', () => {
const profile = manifest.declarations.ledger.find((d) => d.domain === 'customer' && d.object === 'profile');
const custType = profile?.fields.find((f) => f.name === 'cust_type');
expect(custType, 'customer.profile 缺 cust_type').toBeDefined();
expect(custType?.expect).toBe(true);
expect(custType?.required).toBeUndefined();
expect(custType?.tally).toBeUndefined();
});
it('crm_coverage.verdict 新增 event_type 列(人读标签,月度例行留空)', () => {
const verdict = manifest.declarations.ledger.find((d) => d.object === 'verdict');
expect(verdict?.fields.find((f) => f.name === 'event_type')).toBeDefined();
});
it('我方追加的批注列(change_log)不标 expect —— 标了会让 _gaps 永远大于零', () => {
const contact = manifest.declarations.ledger.find((d) => d.domain === 'customer' && d.object === 'contact');
expect(contact?.fields.find((f) => f.name === 'change_log')?.expect).toBeUndefined();
});
it('候选客户清单的客户编码/客户名称都标 required —— 业务说明明文两列必填', () => {
const candidate = manifest.declarations.ledger.find((d) => d.object === 'candidate');
expect(candidate?.fields.find((f) => f.name === 'customer_code')?.required).toBe(true);
expect(candidate?.fields.find((f) => f.name === 'customer_name')?.required).toBe(true);
});
it('receipt 的完成状态/结果摘要/关键人变动三列必填,客户反馈选填', () => {
const receipt = manifest.declarations.ledger.find((d) => d.object === 'receipt');
for (const col of ['completion_status', 'result_summary', 'key_person_change']) {
expect(receipt?.fields.find((f) => f.name === col)?.required, col).toBe(true);
}
expect(receipt?.fields.find((f) => f.name === 'customer_feedback')?.required).toBeUndefined();
});
it('两个工具都是纯工具(pure),批量入参 customers/contacts 优先于单条', () => {
const declared = new Set(manifest.declarations.tools.map((t) => t.name));
expect(declared.has('crm-relationship-coverage.recompute-score')).toBe(true);
expect(declared.has('crm-relationship-coverage.rank-key-persons')).toBe(true);
});
});
describe('案例模型 —— 双表示同版本', () => {
const model = manifest.assets.businessModel;
const cmmn = parseCmmnLite(model.cmmnXml);
it('CMMN 与说明书同在,不允许分叉', () => {
expect(model.cmmnXml).toContain('<casePlanModel');
expect(model.narrative.length).toBeGreaterThan(400);
});
/*
* 收口拆两项(2.0.4 结构治):写回主数据(S6_writeback)与留更新明细(S6_ledger_update)
* 各一项 —— 从前一项三拍,模型照样把四张表挤进一串,串尾最费字数的明细表被截成
* 空数组挨拒(两轮实测同一处);剧本按项摊开,拆开之后串想长也长不起来。
*/
it('八个计划项:识别候选 / 人核对 / 制定计划 / 审批 / 执行回执 / 写回主数据 / 留更新明细 / 放行', () => {
expect(items(model.cmmnXml)).toEqual([
'S1_identify_candidates',
'S2_scope_confirm',
'S3_plan',
'S4_plan_approval',
'S5_execute_receipt',
'S6_writeback',
'S6_ledger_update',
'T_release',
]);
});
it('四处人的门都标了人工激活:核对范围 / 审批 / 执行回执 / 放行(识别/制定计划/写台账是查表或AI起草,不标)', () => {
expect(cmmn.manualActivation).toEqual(['S2_scope_confirm', 'S4_plan_approval', 'S5_execute_receipt', 'T_release']);
});
it('不用阶段 / 里程碑 / 裁量项', () => {
for (const tag of ['<stage', '<milestone', '<planningTable', '<discretionaryItem']) {
expect(model.cmmnXml, `不该出现 ${tag}`).not.toContain(tag);
}
});
it('人工激活的四项在剧本里都没给 doneWhen —— 给了就是给引擎替人签字的钥匙', () => {
const manual = new Set(cmmn.manualActivation);
for (const step of model.steps ?? []) {
if (manual.has(step.item)) expect(step.doneWhen, `${step.item} 不该有 doneWhen`).toBeUndefined();
}
});
it('AI 起草的三项都给了 doneWhen,且不是「未绑定名字恒真」那种坑', () => {
const manual = new Set(cmmn.manualActivation);
for (const step of model.steps ?? []) {
if (manual.has(step.item)) continue;
expect(step.doneWhen, `${step.item} 缺 doneWhen`).toBeDefined();
expect(step.doneWhen, `${step.item} 的 doneWhen 不许用 != null`).not.toContain('!= null');
}
});
/*
* **放行门的入场哨兵认「办完了没有」,四处全读基座数**(2026-08-02 那类「只有 onPart
* 没有 ifPart 等于没有门」的坑,与「写台账要给行 rows 为空」的哨兵松绑同一类)。
*/
it('放行门哨兵带 ifPart,四个条件全是基座读数或本步显式记账的旗', () => {
const condition = sentryIf(model.cmmnXml, 's_ledger_updated');
expect(condition).toContain('ledger_update_confirmed_total');
expect(condition).toContain('crm_coverage_ledger_update_rows');
expect(condition).toContain('crm_coverage_ledger_update_missing');
expect(condition).toContain('customer_profile_missing');
expect(condition).toContain('customer_contact_missing');
const done = {
ledger_update_confirmed_total: 1,
crm_coverage_ledger_update_rows: 4,
crm_coverage_ledger_update_missing: 0,
customer_profile_missing: 0,
customer_contact_missing: 0,
};
expect(safeEval(condition, done)).toBe(true);
// 写完了但主数据还缺必填 → 门不开
expect(safeEval(condition, { ...done, customer_profile_missing: 1 })).toBe(false);
// 写过一笔但还没显式记账(旗还没置一)→ 门不开(踩过「只有 onPart 没有 ifPart」的坑)
expect(safeEval(condition, { ...done, ledger_update_confirmed_total: 0 })).toBe(false);
});
it('S3 制定计划的入场哨兵认「人核对过了」,不认「AI 起草过」', () => {
const condition = sentryIf(model.cmmnXml, 's_scope_confirmed');
expect(condition).toContain('scope_confirmed_total');
expect(safeEval(condition, { scope_confirmed_total: 1 })).toBe(true);
expect(safeEval(condition, {})).toBe(false);
});
it('每个计划项都有剧本,guide 有实质内容', () => {
const ids = items(model.cmmnXml);
expect((model.steps ?? []).map((s) => s.item)).toEqual(ids);
for (const step of model.steps ?? []) {
expect(step.guide.length, `${step.item} 的做法`).toBeGreaterThan(80);
expect(step.intent.length, `${step.item} 的意图`).toBeGreaterThan(6);
}
});
it('说明书不点名不存在的计划项', () => {
const named = [...model.narrative.matchAll(/\b(?:S\d+_[A-Za-z_]+|T_[a-z]+)\b/g)].map((m) => m[0]);
const ids = new Set(items(model.cmmnXml));
for (const name of named) {
expect(ids.has(name), `说明书提到的 ${name} 不在案例模型里`).toBe(true);
}
});
/*
* **决策卡循环三处写死的东西也进说明书**(照 A6 T_haggle 立的规矩):
* itemId / 三个 value / allowCustom 断点那几拍模型手上只剩这份说明书。
*/
it('说明书写死了决策卡的 itemId / 三个 value / allowCustom', () => {
expect(model.narrative).toContain('S4_plan_approval');
for (const value of ['approve', 'reject', 'custom:']) {
expect(model.narrative, `说明书少了 value ${value}`).toContain(value);
}
expect(model.narrative).toContain('allowCustom');
});
/*
* **重发判断走在「问理由」之前**(2026-08-20 实测逼出来的修复):固定按钮点出来的
* 驳回永远没有 note,再问一遍是把人指向一扇打不开的门。S4 的 guide 必须写死
* 「不许再发卡追问理由」,而不是 A6 T_haggle 那种「找不到答复才发卡」的单发模式
* (E04 的决策卡允许多轮循环,与 A6 恰好相反,两处不能弄混)。
*/
it('S4 的 guide 写死「reject 不许追问理由」,不是无脑重问', () => {
const s4 = (model.steps ?? []).find((s) => s.item === 'S4_plan_approval');
expect(s4?.guide).toContain('不许再发卡追问理由');
});
/*
* **新客户建档的数据录入卡骨架也写死进说明书**(与 S4 决策卡同一条纪律):
* "断点"那几拍模型手上只保证摊开 NARRATIVE,S1 自己的 guide 不一定摊开。
* 照抄 A1 quote-intake 的 S1_customer 机制——`customer_code` 从不给 AI 编,
* 必须由人在卡上现场给。
*/
it('说明书写死了新客户录入卡的骨架:entry / customer.profile / customer_code 现场给', () => {
expect(model.narrative).toContain('"kind":"entry"');
expect(model.narrative).toContain('"ledger":"customer.profile"');
expect(model.narrative).toContain('customer_code');
expect(model.narrative, '不许自己编客户编码').toContain('不能自己编一个');
});
it('S1 的 guide 写死「人确认后不许再 write 一遍 customer.profile/.contact」', () => {
const s1 = (model.steps ?? []).find((s) => s.item === 'S1_identify_candidates');
expect(s1?.guide).toContain('不许你再');
expect(s1?.guide).toContain('customer.profile');
expect(s1?.guide).toContain('customer.contact');
});
});
describe('声明与契约对得上', () => {
const columns = new Set(
manifest.declarations.ledger.flatMap((d) => [`${d.domain}.${d.object}`, ...d.fields.map((f) => f.name)]),
);
it('必交付物的台账地址都声明过', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(columns.has(d.ledger ?? ''), `${d.key} → ${d.ledger}`).toBe(true);
}
});
it('四件必交付物 editableAtRelease 全是空数组 —— 验收工单只读展示,不可修改', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(d.editableAtRelease, d.key).toEqual([]);
}
});
const rowReadings = new Set([
...manifest.declarations.ledger.map((d) => `${d.domain}_${d.object}_rows`),
...manifest.declarations.ledger
.filter((d) => d.fields.some((f) => f.required === true || f.requiredWhen !== undefined))
.map((d) => `${d.domain}_${d.object}_missing`),
...manifest.declarations.ledger.flatMap((d) =>
d.fields.flatMap((f) =>
(f.tally ?? []).map((v) => `${d.domain}_${d.object}_${f.name}_${v.toLowerCase().replace(/[^a-z0-9_]/g, '')}`),
),
),
// distinct 声明出的不同值计数 —— 覆盖闭合判据的落点(2026-08-24)
...manifest.declarations.ledger.flatMap((d) =>
d.fields.filter((f) => f.distinct === true).map((f) => `${d.domain}_${d.object}_distinct_${f.name}`),
),
]);
it('L1/L2 谓词读的名字都有承载(台账列或基座读数)', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level !== 'L1' && c.level !== 'L2') continue;
for (const identifier of (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? []) {
if (['and', 'or', 'not', 'true', 'false', 'null', 'in', 'between'].includes(identifier)) continue;
if (rowReadings.has(identifier)) continue;
if (identifier === 'plan_approved_total' || identifier === 'scope_confirmed_total') continue; // 一次性记账旗,直接是 caseFileItem
expect(columns.has(identifier), `${c.key} 的谓词引用了 ${identifier}`).toBe(true);
}
}
});
it('每条 L1/L2 都至少读一个基座读数或本步记账旗 —— 不许两边都由活动自报凑数', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level !== 'L1' && c.level !== 'L2') continue;
const names = (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? [];
const grounded = names.some((n) => rowReadings.has(n) || n.endsWith('_total'));
expect(grounded, `${c.key} 一个基座读数/记账旗都没读`).toBe(true);
}
});
it('单一必发口 coverage-map,无选择组(E04 全程线性,无分支)', () => {
expect(manifest.contract.output.mandatory.map((p) => p.name)).toEqual(['coverage-map']);
expect(manifest.contract.output.selective).toEqual([]);
});
it('coverage-map 出口标量字段都有台账列承载', () => {
const snake = (name: string) => name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
const port = manifest.contract.output.mandatory[0];
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('L4 判据裁量语句整句出现在放行剧本里 —— judge 才对得上名', () => {
const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level !== 'L4') continue;
expect(release?.guide, `T_release 的 guide 要整句抄 ${c.key}`).toContain(c.label);
}
});
});
describe('报告模版 —— 活动持有,容器无脚本执行权', () => {
it('四个局面各一张:候选清单 / 经营计划 / 执行回执 / 台账更新明细', () => {
expect(manifest.assets.viewTemplates.map((t) => t.name)).toEqual([
'coverage-scope',
'coverage-plan',
'coverage-receipt',
'coverage-ledger-update',
]);
});
it('剧本每一步的 view 都在模版名单里', () => {
const names = new Set(manifest.assets.viewTemplates.map((t) => t.name));
for (const step of manifest.assets.businessModel.steps ?? []) {
expect(names.has(step.view ?? ''), `${step.item} 的 view=${step.view}`).toBe(true);
}
});
it('放行那一步摆的是台账更新明细 —— 最后摆上去的那张就是归档件', () => {
const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
expect(release?.view).toBe('coverage-ledger-update');
});
it('模版里没有脚本、没有事件处理器、没有外链 —— 样式自带', () => {
for (const template of manifest.assets.viewTemplates) {
expect(template.html, template.name).not.toMatch(/<script|\son[a-z]+=|https?:\/\//i);
expect(template.html, template.name).toContain('<style>');
}
});
it('候选清单可编辑:data-edit 指向 crm_coverage.candidate,主键客户编码挂了 data-col', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-scope')?.html ?? '';
expect(html).toContain('data-edit="crm_coverage.candidate"');
expect(html).toContain('data-col="customer_code"');
});
it('经营计划只读:没有任何 data-edit / data-col —— 人只审不改', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-plan')?.html ?? '';
expect(html).not.toContain('data-edit=');
expect(html).not.toContain('data-col=');
});
it('台账更新确认摆全落库面:更新明细只读,客户档案与联系人档案两张落库预览可编辑、联系人可加行', () => {
// 2026-08-24 用户定:「没有把要更新的台账展示全;AI 分析填的字段也要能在这一步补」
const html = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-ledger-update')?.html ?? '';
// 更新明细那张表仍是摘要、只读 —— 它的 tbody 上不许有 data-edit
expect(html).toContain('data-rows="mine:crm_coverage.ledger_update"');
expect(html).not.toContain('data-rows="mine:crm_coverage.ledger_update" data-edit');
// 两张落库预览:将写入正式表的行本身,可就地补改
expect(html).toContain('data-edit="customer.profile"');
expect(html).toContain('data-edit="customer.contact"');
/*
* 数据源必须是 touched:(正式现值 + 草稿叠加、只取本轮碰过的行)——
* 绑 mine: 的话草稿里没有的列(Itelma 既有的客户类型/行业)整片空白,
* 编辑框也空(2026-08-24 实测);文本格走自动孪生(data-slot + data-col 空 span),
* 手写 <input> 渲染器不给填值,一开编辑就是白框。
*/
expect(html).toContain('data-rows="touched:customer.profile"');
expect(html).toContain('data-rows="touched:customer.contact"');
expect(html).toContain('data-slot="name" data-col="name"');
expect(html).not.toMatch(/data-edit="customer\.(?:profile|contact)"[\s\S]*?<input class="jc-cell"/);
// 主键列必挂 data-col(行的钥匙);联系人可加行,编码由基座续号(自增列不问人)
expect(html).toContain('data-slot="customer_code" data-col="customer_code"');
expect(html).toContain('data-slot="contact_code" data-col="contact_code"');
expect(html).toContain('data-edit-add="customer.contact"');
// 红带与放行哨兵同源 —— 摆的是基座的 _missing
expect(html).toContain('data-slot="customer_profile_missing"');
expect(html).toContain('data-slot="customer_contact_missing"');
});
it('执行回执混排:主键联系人编码挂 data-col,四个可填列各自挂了 data-col', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-receipt')?.html ?? '';
expect(html).toContain('data-edit="crm_coverage.receipt"');
const cols = new Set([...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]));
expect(cols).toEqual(
new Set(['contact_code', 'completion_status', 'result_summary', 'customer_feedback', 'key_person_change']),
);
});
it('每个可编辑格子的 data-col 都在对应台账对象的声明里 —— 编辑面不能超出声明的字段', () => {
const editableViews: Array<[string, string]> = [
['coverage-scope', 'candidate'],
['coverage-receipt', 'receipt'],
];
for (const [view, object] of editableViews) {
const html = manifest.assets.viewTemplates.find((t) => t.name === view)?.html ?? '';
const declared = new Set(manifest.declarations.ledger.find((d) => d.object === object)?.fields.map((f) => f.name));
for (const col of [...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1])) {
expect(declared.has(col ?? ''), `${view} 的 data-col="${col}" 不在 crm_coverage.${object} 的声明里`).toBe(true);
}
}
});
it('缺必填红带与提交闸同源 —— 可编辑件上摆的是基座的 `_missing` 与主键名单', () => {
for (const [name, object] of [
['coverage-scope', 'candidate'],
['coverage-receipt', 'receipt'],
] as const) {
const html = manifest.assets.viewTemplates.find((t) => t.name === name)?.html ?? '';
expect(html, name).toContain(`crm_coverage_${object}_missing`);
expect(html, name).toContain(`crm_coverage_${object}_missing_keys`);
}
});
it('三个状态徽(候选状态/优先级/完成状态)只用 data-class-of 投影,不写 data-slot 重复暴露原值', () => {
const scope = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-scope')?.html ?? '';
expect(scope).toContain('<span class="jc-tag" data-class-of="status"></span>');
const plan = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-plan')?.html ?? '';
expect(plan).toContain('<span class="jc-tag" data-class-of="priority"></span>');
const receipt = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-receipt')?.html ?? '';
expect(receipt).toContain('<span class="jc-tag" data-class-of="completion_status"></span>');
});
it('宿主拒推含脚本的模版', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
});
});
const CONTRACT = {
activityId: 'crm-relationship-coverage',
version: '1.0.0',
data: {},
message: undefined,
outputPorts: { mandatory: ['coverage-map'], selective: [] },
prohibitions: [],
discretion: [],
} as const;
describe('代码入口 —— 案例活动没有 run,不是残包', () => {
it('默认导出是一个有名字的活动定义', () => {
expect(activity.name).toBe('crm-relationship-coverage');
});
it('mock 宿主如实报「没有 run」,不静默返回一个假的 outcome', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.run(activity)).rejects.toThrow(/没有 run/);
});
});
function items(xml: string): string[] {
return [...xml.matchAll(/<(?:humanTask|decisionTask|processTask|caseTask) id="([^"]+)"/g)].map((m) => m[1] ?? '');
}
/**
* 有的 humanTask 是自闭合标签(`<humanTask .../>`,如没有人工激活的 S1)——
* `[\s\S]*?<\/humanTask>` 这种非贪婪匹配会跳过自闭合标签、错吞下一个 humanTask
* 的内容(把 S2 的 manualActivationRule 记到 S1 头上)。两种收尾分别匹配。
*/
function parseCmmnLite(xml: string): { manualActivation: string[]; repetition: string[] } {
const manualActivation: string[] = [];
const repetition: string[] = [];
for (const m of xml.matchAll(/<humanTask id="([^"]+)"[^>]*?(?:\/>|>[\s\S]*?<\/humanTask>)/g)) {
if (m[0].includes('manualActivationRule')) manualActivation.push(m[1] ?? '');
if (m[0].includes('repetitionRule')) repetition.push(m[1] ?? '');
}
return { manualActivation, repetition };
}
function sentryIf(xml: string, sentryId: string): string {
const block = xml.slice(xml.indexOf(`<sentry id="${sentryId}"`));
const raw = /<condition>([\s\S]*?)<\/condition>/.exec(block.slice(0, block.indexOf('</sentry>')))?.[1] ?? '';
return raw.replaceAll('&gt;', '>').replaceAll('&lt;', '<').replaceAll('&amp;', '&');
}
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
@@ -0,0 +1,461 @@
/**
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(与 quote-* 各包同一套惯例)。
*
* E04 的实测账本(2026-08-21/22)每一条都对应这里的一类断言:
* · S1 只写一行就 finish → doneWhen 的「该有几行」半句 + count(customers) 语义;
* · S5 抢跑写旗 → 人工激活项不给 doneWhen(activity.test.ts 钉)+ 收口列写全;
* · 收口漏 influence → 新客户路放行门永远打不开(「碰过的行三列都带上」);
* · 剧本 writes/tools 指向没声明的地址 → 运行时被「台账地址未声明」拒到熔断。
*/
import { describe, expect, it } from 'vitest';
import { evaluateCondition } from '@sfeel';
import { CMMN_XML, COVERAGE_PHASES, COVERAGE_STEPS } from '../src/case-model.ts';
import { COVERAGE_LEDGER_UPDATE, COVERAGE_PLAN, COVERAGE_RECEIPT, COVERAGE_SCOPE } from '../src/views/report.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 COVERAGE_STEPS) {
expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
}
});
it('图上每一步都有剧本 —— 漏掉的那一步会退回整本说明书', () => {
for (const id of ITEM_IDS) {
expect(COVERAGE_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
}
});
it('清单里挂的就是这份剧本与这份阶段(不是另抄一份)', () => {
expect(manifest.assets.businessModel.steps).toBe(COVERAGE_STEPS);
expect(manifest.assets.businessModel.phases).toBe(COVERAGE_PHASES);
});
it('除放行外每个计划项恰属一个阶段 —— 进度条不许漏格,也不许一项占两格', () => {
const inPhases = COVERAGE_PHASES.flatMap((p) => p.items);
const expected = ITEM_IDS.filter((id) => id !== 'T_release');
expect([...inPhases].sort()).toEqual([...expected].sort());
expect(new Set(inPhases).size).toBe(inPhases.length);
});
});
describe('剧本声明的读写面 ⊆ 清单声明 —— 越出去的地址运行时才炸', () => {
const declaredAddresses = new Set(manifest.declarations.ledger.map((d) => `${d.domain}.${d.object}`));
const declaredTools = new Set(manifest.declarations.tools.map((t) => t.name));
it('steps[].writes 全部落在声明过的台账对象上', () => {
for (const step of COVERAGE_STEPS) {
for (const address of step.writes) {
expect(declaredAddresses.has(address), `${step.item} 要写 ${address},清单没声明`).toBe(true);
}
}
});
it('steps[].tools 全部是声明过的工具', () => {
for (const step of COVERAGE_STEPS) {
for (const tool of step.tools) {
expect(declaredTools.has(tool), `${step.item} 要调 ${tool},清单没声明`).toBe(true);
}
}
});
});
describe('doneWhen 是「成果已存在」的判据 —— 开局不许真,干完必须真', () => {
/** 四条常备样例各自的开案局面:台账一行没有,基座读数按声明铺 0 */
const fresh = (input: Record<string, unknown>): Record<string, unknown> => ({
...input,
crm_coverage_candidate_rows: 0,
crm_coverage_candidate_missing: 0,
crm_coverage_plan_rows: 0,
crm_coverage_plan_missing: 0,
crm_coverage_receipt_rows: 0,
crm_coverage_receipt_missing: 0,
crm_coverage_ledger_update_rows: 0,
crm_coverage_ledger_update_missing: 0,
customer_profile_missing: 0,
customer_contact_missing: 0,
});
it('**开局一步都不该被自动收掉** —— 五条样例的入参形状逐一试', () => {
for (const sample of manifest.samples) {
for (const step of COVERAGE_STEPS) {
if (step.doneWhen === undefined) continue;
const ripe = safeEval(step.doneWhen, fresh(sample.input as Record<string, unknown>));
expect(ripe, `样例「${sample.name}」下 ${step.item} 的 doneWhen 开局就为真`).toBe(false);
}
}
});
/*
* **count(customers) 的三种形状钉死**(2026-08-22 注释与实现相悖那一课):
* sfeel 的 count() 对未绑定名 flatten 出 [undefined],长度 1 —— 不是 0。
* 月度例行(键不存在)靠 rows >= 1 与 rows > 0 同义而行为恰好正确;
* 显式空数组才走 count = 0 那半句。这里用真 @sfeel 求值,注释再想撒谎测试拦着。
*/
it('月度例行(customers 键不存在):写下 1 行且必填齐 → S1 收得掉', () => {
expect(
safeEval(doneOf('S1_identify_candidates'), {
trigger: 'monthly',
crm_coverage_candidate_rows: 1,
crm_coverage_candidate_missing: 0,
}),
).toBe(true);
});
it('点名两家客户只写了一行 → S1 收不掉(漏写的那家不会自己回来)', () => {
expect(
safeEval(doneOf('S1_identify_candidates'), {
trigger: 'event',
customers: ['CU-0142', 'CU-0087'],
crm_coverage_candidate_rows: 1,
crm_coverage_candidate_missing: 0,
}),
).toBe(false);
});
it('点名两家客户写齐两行 → S1 收得掉', () => {
expect(
safeEval(doneOf('S1_identify_candidates'), {
trigger: 'event',
customers: ['CU-0142', 'CU-0087'],
crm_coverage_candidate_rows: 2,
crm_coverage_candidate_missing: 0,
}),
).toBe(true);
});
it('显式传空数组(customers: []):count = 0 那半句真的兜住', () => {
expect(
safeEval(doneOf('S1_identify_candidates'), {
trigger: 'monthly',
customers: [],
crm_coverage_candidate_rows: 1,
crm_coverage_candidate_missing: 0,
}),
).toBe(true);
});
it('行齐了但缺必填 → S1 收不掉', () => {
expect(
safeEval(doneOf('S1_identify_candidates'), {
trigger: 'monthly',
crm_coverage_candidate_rows: 2,
crm_coverage_candidate_missing: 1,
}),
).toBe(false);
});
it('计划有行、必填齐、且盖住每家候选 → S3 收得掉;缺一头都收不掉', () => {
const full = {
crm_coverage_plan_rows: 3,
crm_coverage_plan_missing: 0,
crm_coverage_plan_distinct_customer_code: 2,
crm_coverage_candidate_rows: 2,
crm_coverage_receipt_rows: 3,
};
expect(safeEval(doneOf('S3_plan'), full)).toBe(true);
expect(safeEval(doneOf('S3_plan'), { ...full, crm_coverage_plan_rows: 0 })).toBe(false);
expect(safeEval(doneOf('S3_plan'), { ...full, crm_coverage_plan_missing: 2 })).toBe(false);
});
it('**回执骨架比计划少一行 → S3 收不掉、S4 哨兵不开**(2026-08-24 柴浩实测:计划 7 回执 6,人填完就能交卷)', () => {
expect(
safeEval(doneOf('S3_plan'), {
crm_coverage_plan_rows: 7,
crm_coverage_plan_missing: 0,
crm_coverage_plan_distinct_customer_code: 6,
crm_coverage_candidate_rows: 6,
crm_coverage_receipt_rows: 6,
}),
).toBe(false);
const sentry = /<ifPart id="if_pa"><condition>([^<]+)<\/condition>/.exec(CMMN_XML)?.[1] ?? '';
expect(sentry).toContain('crm_coverage_receipt_rows = crm_coverage_plan_rows');
const s4 = COVERAGE_STEPS.find((x) => x.item === 'S4_plan_approval')?.guide ?? '';
expect(s4).toContain('加了一条计划就同拍给它加一行');
const rel = COVERAGE_STEPS.find((x) => x.item === 'T_release')?.guide ?? '';
expect(rel).toContain('不许删计划来凑数');
});
it('**计划漏了一家候选客户 → S3 收不掉**(2026-08-24 柴浩实测:人加的那家被从头无视到尾)', () => {
// 人在覆盖范围确认卡上加了一家(candidate 2 行),计划只盖住 AI 识别的那一家
expect(
safeEval(doneOf('S3_plan'), {
crm_coverage_plan_rows: 3,
crm_coverage_plan_missing: 0,
crm_coverage_plan_distinct_customer_code: 1,
crm_coverage_candidate_rows: 2,
}),
).toBe(false);
// S4 的入场哨兵是同一句闭合 —— 计划没盖全,审批卡就不该发出来
const sentry = /<ifPart id="if_pd"><condition>([^<]+)<\/condition>/.exec(CMMN_XML)?.[1] ?? '';
expect(sentry).toContain('crm_coverage_plan_distinct_customer_code = crm_coverage_candidate_rows');
});
it('覆盖闭合的两边都是基座数:distinct 声明在 plan.customer_code 上,放行 L2 也钉同一句', () => {
const plan = manifest.declarations.ledger.find((l) => l.object === 'plan');
expect(plan?.fields.find((f) => f.name === 'customer_code')?.distinct).toBe(true);
const criterion = manifest.contract.acceptanceCriteria.find((c) => c.key === 'plan-covers-scope');
expect(criterion?.level).toBe('L2');
expect(criterion?.predicate).toBe('crm_coverage_plan_distinct_customer_code = crm_coverage_candidate_rows');
});
it('台账更新明细齐 + 主数据必填齐 + 无重复位置 → S6 收得掉', () => {
expect(
safeEval(doneOf('S6_ledger_update'), {
crm_coverage_ledger_update_rows: 4,
crm_coverage_ledger_update_missing: 0,
crm_coverage_ledger_update_distinct_update_key: 4,
customer_profile_missing: 0,
customer_contact_missing: 0,
}),
).toBe(true);
});
it('**重做后旧明细没撤(同一处位置两条)→ S6 收不掉**(2026-08-24 用户:「重做后老的变更记录应该删除」)', () => {
expect(
safeEval(doneOf('S6_ledger_update'), {
crm_coverage_ledger_update_rows: 5,
crm_coverage_ledger_update_missing: 0,
crm_coverage_ledger_update_distinct_update_key: 4,
customer_profile_missing: 0,
customer_contact_missing: 0,
}),
).toBe(false);
const decl = manifest.declarations.ledger.find((l) => l.object === 'ledger_update');
const col = decl?.fields.find((f) => f.name === 'update_key');
expect(col?.distinct).toBe(true);
expect(col?.required).toBe(true);
expect(manifest.contract.acceptanceCriteria.some((c) => c.key === 'no-duplicate-update' && c.level === 'L2')).toBe(true);
// 依据一列写人话:不带联系人编码这类机器键;重做分支写明过期行要撤
const s6 = COVERAGE_STEPS.find((x) => x.item === 'S6_ledger_update')?.guide ?? '';
expect(s6).toContain('不写联系人编码');
expect(s6).toContain('update_key');
const rel = COVERAGE_STEPS.find((x) => x.item === 'T_release')?.guide ?? '';
expect(rel).toContain('过期的旧记录要撤掉');
});
it('四份件标题旁不再摆单号(2026-08-24 用户定);验收判据与「AI 已完成」文案不夹字段名', () => {
for (const t of manifest.assets.viewTemplates) {
expect(t.html).not.toMatch(/<h3>[^<]*<span data-slot="round_ref">/);
}
const html = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-ledger-update')?.html ?? '';
const prose = html.replace(/<style>[\s\S]*?<\/style>/, '').replace(/data-[a-z-]+="[^"]*"/g, '');
expect(prose).not.toMatch(/coverage_score|decision_chain/);
});
it('**收口写了明细却漏了联系人必填(如 influence)→ S6 收不掉** —— 新客户路的死锁口', () => {
expect(
safeEval(doneOf('S6_ledger_update'), {
crm_coverage_ledger_update_rows: 4,
crm_coverage_ledger_update_missing: 0,
customer_profile_missing: 0,
customer_contact_missing: 1,
}),
).toBe(false);
});
});
describe('实测账本对应的剧本口径 —— 改口要连测试一起改', () => {
const writeback = COVERAGE_STEPS.find((s) => s.item === 'S6_writeback');
const ledgerUpdate = COVERAGE_STEPS.find((s) => s.item === 'S6_ledger_update');
it('写回主数据那一项含 candidate(状态置 covered)与联系人三列的硬要求', () => {
expect(writeback?.writes).toContain('crm_coverage.candidate');
expect(writeback?.guide).toContain('covered');
for (const column of ['influence', 'relationship', 'key_person']) {
expect(writeback?.guide, `S6_writeback guide 没点名 ${column}`).toContain(column);
}
expect(writeback?.guide).toContain('新建档的联系人');
});
/*
* **放行那一步要接得住「被打回」**(2026-08-23,柴浩实测:点重做写了意见,模型照
* T_release 的剧本「裁判据 → submit」原样又递回来 —— 剧本里压根没有「被打回了怎么办」
* 这一支)。基座那道闸只拦得住原样重提,拦不出「该改哪一张表」——那是活动自己的知识。
*/
it('驳回意见先认归属:S4 剧本写死「照基座比对的观察认、不许改到没点名的行、认不出唯一就重发卡问」', () => {
// 2026-08-24 柴浩实测:意见点名「测试111」,模型把改动落到了 Itelma 那一行
const guide = COVERAGE_STEPS.find((x) => x.item === 'S4_plan_approval')?.guide ?? '';
expect(guide).toContain('先认归属');
expect(guide).toContain('意见按字面点名对上了');
expect(guide).toContain('不是把改动挪到别家');
expect(guide).toContain('不许猜');
});
it('姓名是必填:客户档案与联系人档案都不许无名落库(2026-08-24 两条空联系人 + 无名新客户档案)', () => {
const profile = manifest.declarations.ledger.find((l) => l.domain === 'customer' && l.object === 'profile');
const contact = manifest.declarations.ledger.find((l) => l.domain === 'customer' && l.object === 'contact');
expect(profile?.fields.find((f) => f.name === 'name')?.required).toBe(true);
expect(contact?.fields.find((f) => f.name === 'name')?.required).toBe(true);
// 行业/区域是建议档:AI 推得出就填,推不出橘胶囊留给人(确认屏上现在改得了)
expect(profile?.fields.find((f) => f.name === 'industry')?.expect).toBe(true);
expect(profile?.fields.find((f) => f.name === 'region')?.expect).toBe(true);
// 剧本写死:没有真实姓名的人不建联系人行;碰过的行 name 照现值带上
const guide = COVERAGE_STEPS.find((x) => x.item === 'S6_writeback')?.guide ?? '';
expect(guide).toContain('没有真实姓名的人不许建联系人行');
expect(guide).toContain('name');
expect(guide).toContain('照候选行抄');
expect(guide).toContain('不许编造一个查无出处的行业或地区');
});
it('计划只指向真人:占位句式已退役,查无联系人走数据录入卡(2026-08-24 用户裁定)', () => {
// 实测:模型把占位句式泛化到缺口节点,给 Itelma 编了两个顺延引用码 + 占位人名
const guide = COVERAGE_STEPS.find((x) => x.item === 'S3_plan')?.guide ?? '';
expect(guide).toContain('真实存在的编码');
expect(guide).toContain('数据录入卡');
expect(guide).toContain('"kind":"entry"');
// 占位引用码的句式一个不留 —— 给了可照抄的样板,模型就会把它用到不该用的地方
expect(guide).not.toContain('-00');
expect(guide).not.toContain('待识别');
const s6 = COVERAGE_STEPS.find((x) => x.item === 'S6_writeback')?.guide ?? '';
expect(s6).not.toContain('-00');
expect(manifest.contract.prohibitions.some((x) => x.key === 'no-phantom-contact')).toBe(true);
});
it('放行剧本带「被打回重做」那一支,且写面覆盖要改的那几张表', () => {
const release = COVERAGE_STEPS.find((s) => s.item === 'T_release');
expect(release?.guide).toContain('验收被打回重做');
for (const address of ['crm_coverage.plan', 'crm_coverage.ledger_update', 'crm_coverage.verdict']) {
expect(release?.writes, `T_release 改不了 ${address}`).toContain(address);
}
// 改的是值不是数 —— 新开行会把收步判据弄假
expect(release?.guide).toContain('覆盖写');
});
/*
* 收口拆两项的口径钉死(2.0.4 结构治):留明细那一项**只**写明细与结论 ——
* 写面一旦混进别的表,「一串挤四张、串尾截成空数组」那口锅迟早再背一次。
*/
it('留更新明细那一项只写 ledger_update + verdict 两张表', () => {
expect(ledgerUpdate?.writes).toEqual(['crm_coverage.ledger_update', 'crm_coverage.verdict']);
expect(ledgerUpdate?.tools).toEqual([]);
});
it('verdict 的触发列叫 trigger_label —— 与入参 trigger 同名会让首帧摆英文码', () => {
const verdict = manifest.declarations.ledger.find((d) => d.object === 'verdict');
expect(verdict?.fields.some((f) => f.name === 'trigger_label')).toBe(true);
expect(verdict?.fields.some((f) => f.name === 'trigger')).toBe(false);
});
it('plan 没有 status 列 —— 「过没过审」由 plan_approved_total 一处说了算', () => {
const plan = manifest.declarations.ledger.find((d) => d.domain === 'crm_coverage' && d.object === 'plan');
expect(plan?.fields.some((f) => f.name === 'status')).toBe(false);
});
it('建档卡要写的六列都在声明里 —— 授权面是台账声明,说明书不许开空头支票', () => {
const profile = manifest.declarations.ledger.find((d) => d.domain === 'customer' && d.object === 'profile');
const contact = manifest.declarations.ledger.find((d) => d.domain === 'customer' && d.object === 'contact');
for (const column of ['name', 'industry', 'region']) {
expect(profile?.fields.some((f) => f.name === column), `customer.profile 缺 ${column}`).toBe(true);
}
for (const column of ['name', 'title', 'email']) {
expect(contact?.fields.some((f) => f.name === column), `customer.contact 缺 ${column}`).toBe(true);
}
// 邮箱照平台口径标脱敏 —— 不标的话治理面那一层裁剪不到它
expect(contact?.fields.find((f) => f.name === 'email')?.masked).toBe(true);
});
});
describe('四份工单模版照目标件 §6.2 补齐的动态位', () => {
const ALL = [COVERAGE_SCOPE, COVERAGE_PLAN, COVERAGE_RECEIPT, COVERAGE_LEDGER_UPDATE];
it('卡头:收件人 + 关联任务;side:步骤行;页脚:单号 —— 四份都有', () => {
for (const view of ALL) {
expect(view).toContain('data-slot="case:owner"');
expect(view).toContain('data-slot="case:taskId"');
expect(view).toContain('jc-rp-step');
// 页脚右侧「工单类型:… · <单号>」—— round_ref 是唯一真数据源
expect(view).toContain('data-slot="round_ref"');
}
});
/*
* **枚举列上下两态说同一个字**(2026-08-23 用户:「BT 层级下拉框显示不全,
* 都看不到具体选了哪个」):存的是 ASCII(台账与 recompute-score 的 btLevel 入参认它),
* 那么**只读那一份必须走 data-class-of 徽标**,由样式表出人读名 ——
* data-slot 直显会把 bt-02 摆到人脸上,与编辑态下拉里的「BT-02 · 技术」对不上。
* 覆盖深度当初修过这个坑,BT 层级漏了;列宽也得放得下选项文字,否则下拉被挤扁。
*/
it('BT 层级与覆盖深度:只读态走徽标(不 data-slot 直显 ASCII),且人读名在样式表里', () => {
for (const column of ['bt_level', 'depth']) {
expect(COVERAGE_SCOPE).toContain(`data-class-of="${column}"`);
expect(COVERAGE_SCOPE, `${column} 不该 data-slot 直显机器码`).not.toContain(`data-slot="${column}"`);
}
for (const label of ['BT-01 · 采购/供应链', 'BT-02 · 技术', 'BT-03 · 高层']) {
expect(COVERAGE_SCOPE, `徽标字典缺「${label}」`).toContain(label);
}
});
it('BT 层级列放得下选项文字 —— 80px 那会儿下拉被挤扁,选了什么看不见', () => {
const head = /<th[^>]*>BT 层级<\/th>/.exec(COVERAGE_SCOPE)?.[0] ?? '';
const width = Number(/min-width:(\d+)px/.exec(head)?.[1] ?? 0);
expect(width, `BT 层级列宽 ${width}px,装不下「BT-01 · 采购/供应链」`).toBeGreaterThanOrEqual(140);
});
it('执行工单1 的编辑闸照目标件措辞:「AI 已识别 N 个客户」,N 是基座读数', () => {
expect(COVERAGE_SCOPE).toContain('AI 已识别');
expect(COVERAGE_SCOPE).toContain('data-slot="crm_coverage_candidate_rows"');
// 触发口径并进【做什么】,不再单开一段(照目标件的 note 结构)
expect(COVERAGE_SCOPE).not.toContain('【本轮触发】');
expect(COVERAGE_SCOPE).toContain('data-slot="trigger_label"');
});
it('经营计划表摆得出时间点 —— 验收判据③指向的东西必须在卡面上', () => {
expect(COVERAGE_PLAN).toContain('data-slot="target_date"');
});
it('执行回执:对象列长「客户 · 关键人」;结果摘要/客户反馈照目标件是 textarea', () => {
expect(COVERAGE_RECEIPT).toContain('data-slot="customer_name"');
expect(COVERAGE_RECEIPT).toContain('<textarea class="jc-cell" data-col="result_summary"');
expect(COVERAGE_RECEIPT).toContain('<textarea class="jc-cell" data-col="customer_feedback"');
});
it('台账更新确认摆得出依据与编码 —— 三条 L4 判据不再让人判他看不见的东西', () => {
expect(COVERAGE_LEDGER_UPDATE).toContain('data-slot="evidence_ref"');
expect(COVERAGE_LEDGER_UPDATE).toContain('data-slot="customer_code"');
});
});
function doneOf(item: string): string {
const found = COVERAGE_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;
}
}
/**
* **进度条的刻度要与业务流程的步数对得上**(2026-08-24 用户:「E04 应该是有 7 步、
* 给人确认是 4 步,这里要是 x/7 才对」)。从前粗分三段 → 屏上 x/4,
* 而文档开篇写的是「整体流程(7 步)」:同一件事两个数,人只能挑一个信。
*/
describe('阶段刻度 = 文档那 7 步', () => {
it('声明 6 段 —— 加上基座那一格「放行收口」,人看到的是 x/7', () => {
expect(COVERAGE_PHASES).toHaveLength(6);
});
it('一段一步:除「更新关系台账」外每段恰好一个计划项', () => {
const multi = COVERAGE_PHASES.filter((p) => p.items.length > 1).map((p) => p.name);
expect(multi).toEqual(['更新关系台账']); // 写回主数据 + 明细留痕,业务上是同一步⑥
});
it('四张卡对应的那四步各占一格 —— 「给人确认是 4 步」说的就是它们', () => {
const names = COVERAGE_PHASES.map((p) => p.name);
// 人确认的三格在声明里,第四格(放行收口)由基座加
expect(names).toContain('覆盖范围确认');
expect(names).toContain('经营计划审批');
expect(names).toContain('执行经营动作与回执');
});
});
@@ -0,0 +1 @@
{ "name": "@activities/crm-relationship-coverage", "version": "0.1.0", "private": true, "type": "module", "exports": { ".": "./src/index.ts" } }
@@ -0,0 +1,847 @@
/**
* E04 关系覆盖与关键人经营的案例模型 —— **双表示同版本**:CMMN XML(机器面)与
* 案例说明书(叙事面)。
*
* 业务出处:`docs/SS01-客户与市场/e04-html/E04-活动流程与工单说明.md`(7 步业务链条,
* 收成 2 张执行工单 + 2 张验收工单)与 `docs/SS01-客户与市场/Demo-E04-I01-Activity台账
* 关联与设计.html`(L-02/L-08 台账读写关系)。
*
* 7 个业务步收成 8 个案例模型项,非逐一对应:
* ① 启动与覆盖范围确认 → S1_identify_candidates(AI 起草)+ S2_scope_confirm(人核对);
* ②③④ 读现状→缺口分析→制定计划 → S3_plan(一项,AI)+ S4_plan_approval(决策卡循环);
* ⑤⑥ 执行经营动作→更新关系台账 → S5_execute_receipt(人执行填回执)+
* S6_writeback(AI 写回主数据)+ S6_ledger_update(AI 留更新明细与结论 ——
* 与写回拆成两项是 2.0.4 的结构治:一串挤四张表,串尾最费字数的明细表
* 被截成空数组挨拒,实测两轮都栽在同一处;拆开后每项的剧本只摊自己那几张表);
* ⑦ 复盘与完结 → T_release(终局验收放行)。
*
* 这是本仓库第一个用 `decide` 实现「非终局通过/驳回+AI重做」循环的活动
* (S4_plan_approval)——`issueAcceptance` 硬绑定终局放行,一个任务只能发一次,
* 不能拿来做②③④这个中间验收点;`decide` 才是唯一能循环的机制,细则见 S4 的 guide。
*/
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
<case id="crm-coverage">
<!--
案卷项的 id 就是任务视图里的键名。入参顶层是 trigger/eventType/customers/note 四个
字段(不进这份 caseFileModel——没有谓词引用它们,S1 的步骤 reads 里单独声明);
crm_coverage_* 与 customer_profile_* / customer_contact_* 那几个是基座读数
(行数 / 缺必填,由基座现算,模型碰不着)。
-->
<caseFileModel>
<caseFileItem id="crm_coverage_candidate_rows" name="候选客户行数(基座现数)"/>
<caseFileItem id="crm_coverage_candidate_missing" name="候选清单缺必填的行数(基座现数)"/>
<caseFileItem id="scope_confirmed_total" name="覆盖范围已核对确认(人环走完的凭据)"/>
<caseFileItem id="crm_coverage_plan_rows" name="经营计划行数(基座现数)"/>
<caseFileItem id="crm_coverage_plan_missing" name="经营计划缺必填的行数(基座现数)"/>
<caseFileItem id="crm_coverage_plan_distinct_customer_code" name="经营计划盖住了几家客户(基座现数不同值)"/>
<caseFileItem id="plan_approved_total" name="经营计划已审批通过(仅通过时置一)"/>
<caseFileItem id="crm_coverage_receipt_rows" name="执行回执行数(基座现数)"/>
<caseFileItem id="crm_coverage_receipt_missing" name="执行回执缺必填的行数(基座现数)"/>
<caseFileItem id="crm_coverage_ledger_update_rows" name="台账更新明细行数(基座现数)"/>
<caseFileItem id="crm_coverage_ledger_update_missing" name="台账更新明细缺必填的行数(基座现数)"/>
<caseFileItem id="crm_coverage_ledger_update_distinct_update_key" name="台账更新明细覆盖了几处不同位置(基座现数不同值)"/>
<caseFileItem id="customer_profile_rows" name="本单写下的客户档案行数(基座现数)"/>
<caseFileItem id="customer_profile_missing" name="客户档案缺必填的行数(基座现数)"/>
<caseFileItem id="customer_contact_rows" name="本单写下的客户联系人行数(基座现数)"/>
<caseFileItem id="customer_contact_missing" name="客户联系人缺必填的行数(基座现数)"/>
<caseFileItem id="ledger_update_confirmed_total" name="台账更新已收口(S6 写完时置一)"/>
</caseFileModel>
<casePlanModel id="plan" name="关系覆盖与关键人经营" autoComplete="true">
<!-- ① 候选客户已起草 —— 人核对那一步的入场前提 -->
<sentry id="s_candidates_drafted">
<onPart id="op_cd" caseFileItemRef="crm_coverage_candidate_rows"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_cd"><condition>crm_coverage_candidate_rows &gt; 0</condition></ifPart>
</sentry>
<!-- ② 覆盖范围已核对 —— 制定经营计划那一步的入场前提 -->
<sentry id="s_scope_confirmed">
<onPart id="op_sc" caseFileItemRef="scope_confirmed_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_sc"><condition>scope_confirmed_total &gt; 0</condition></ifPart>
</sentry>
<!-- ③ 经营计划已起草且齐 —— 审批那一步的入场前提 -->
<sentry id="s_plan_drafted">
<onPart id="op_pd" caseFileItemRef="crm_coverage_plan_rows"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_pd"><condition>crm_coverage_plan_rows &gt; 0 and crm_coverage_plan_missing = 0 and crm_coverage_plan_distinct_customer_code = crm_coverage_candidate_rows</condition></ifPart>
</sentry>
<!-- ④ 经营计划已审批通过 —— 交人执行那一步的入场前提 -->
<sentry id="s_plan_approved">
<onPart id="op_pa" caseFileItemRef="plan_approved_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_pa"><condition>plan_approved_total &gt; 0 and crm_coverage_receipt_rows = crm_coverage_plan_rows</condition></ifPart>
</sentry>
<!--
⑤ 执行回执已交回且齐 —— 收口写台账那一步的入场前提。
**不再有 receipt_confirmed_total 记账旗**(2.0.2 退役,活单熔断两轮实测逼的):
那面旗是模型写的,S4 通过后它顺手把旗写上就坚信 S5 已办完,连续五拍
[空转写旗 + finish 被 worldTouched 拒] 熔断 —— 「该不该往下走」不能由
被叫的那一方自己记账。回执四列全是**人在件上填出来的**,_rows/_missing
是基座数出来的 —— 人填齐那一刻门自己开,模型在 S5 只剩发卡与收步两件事,
没有可抢跑的旗。
-->
<sentry id="s_receipt_confirmed">
<onPart id="op_rc" caseFileItemRef="crm_coverage_receipt_missing"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_rc"><condition>crm_coverage_receipt_rows &gt; 0 and crm_coverage_receipt_missing = 0</condition></ifPart>
</sentry>
<!--
⑥(中缝)主数据已写回 —— 留更新明细那一步的入场前提。
收口从一项拆成两项(2.0.4,治「一串挤四张表、串尾被截成空数组」的实测账):
写回主数据(S6_writeback)与留明细(S6_ledger_update)各一项,剧本各只摊自己
要写的表,一串自然变小。四个数全是基座现数:重算分/档案都写齐了,门才开。
-->
<sentry id="s_masters_written">
<onPart id="op_mw" caseFileItemRef="customer_profile_rows"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_mw"><condition>customer_profile_rows &gt; 0 and customer_profile_missing = 0 and customer_contact_rows &gt; 0 and customer_contact_missing = 0</condition></ifPart>
</sentry>
<!--
⑥ 台账已收口 —— 放行门的入场前提。
认「办完了没有」,且四处都是基座现数:台账更新明细齐(有行、不缺必填)、
主数据(客户档案/联系人)写下的那几行不缺必填、且 S6 自己显式记了账
(ledger_update_confirmed_total 是写完之后才产生的数,不是「写过一笔就算数」)。
-->
<sentry id="s_ledger_updated">
<onPart id="op_lu" caseFileItemRef="ledger_update_confirmed_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_lu"><condition>ledger_update_confirmed_total &gt; 0 and crm_coverage_ledger_update_rows &gt; 0 and crm_coverage_ledger_update_missing = 0 and customer_profile_missing = 0 and customer_contact_missing = 0</condition></ifPart>
</sentry>
<!-- ① 识别候选客户,铸造 round_ref(能算的不问人) -->
<humanTask id="S1_identify_candidates" name="识别候选覆盖客户"/>
<!-- ① 交人核对候选清单(增删改)—— 触达世界前的人环,不给 doneWhen -->
<humanTask id="S2_scope_confirm" name="覆盖范围确认:核对候选清单,增删改">
<entryCriterion id="ec_s2" sentryRef="s_candidates_drafted"/>
<itemControl><manualActivationRule id="m_s2"/></itemControl>
</humanTask>
<!-- ②③④ 读现状 → 缺口分析 → 制定经营计划(一项,不触达世界) -->
<humanTask id="S3_plan" name="读现状、缺口分析、制定关系经营计划">
<entryCriterion id="ec_s3" sentryRef="s_scope_confirmed"/>
</humanTask>
<!-- ②③④ 经营计划审批 —— 决策卡循环,通过或驳回+AI重做,不给 doneWhen -->
<humanTask id="S4_plan_approval" name="经营计划审批:通过或驳回(驳回则AI修订重提交)">
<entryCriterion id="ec_s4" sentryRef="s_plan_drafted"/>
<itemControl><manualActivationRule id="m_s4"/></itemControl>
</humanTask>
<!-- ⑤⑥(前半)交人执行经营动作并填回执 —— 触达世界,不给 doneWhen -->
<humanTask id="S5_execute_receipt" name="执行经营动作并填回执">
<entryCriterion id="ec_s5" sentryRef="s_plan_approved"/>
<itemControl><manualActivationRule id="m_s5"/></itemControl>
</humanTask>
<!-- ⑥(后半A)写回主数据:重算覆盖度、更新档案与候选状态(不触达世界,给 doneWhen) -->
<humanTask id="S6_writeback" name="写回主数据:重算覆盖度并更新档案">
<entryCriterion id="ec_s6a" sentryRef="s_receipt_confirmed"/>
</humanTask>
<!-- ⑥(后半B)留痕:逐条台账更新明细 + 本轮结论(不触达世界,给 doneWhen) -->
<humanTask id="S6_ledger_update" name="更新关系台账:留更新明细与结论">
<entryCriterion id="ec_s6" sentryRef="s_masters_written"/>
</humanTask>
<!-- ⑦ 复盘与完结:终局唯放行,必须人工激活 -->
<humanTask id="T_release" name="台账更新确认放行">
<entryCriterion id="ec_rel" sentryRef="s_ledger_updated"/>
<itemControl><manualActivationRule id="m_rl"/></itemControl>
</humanTask>
</casePlanModel>
</case>
</definitions>`;
/**
* 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本,两处不重复写同一件事)。
*/
export const NARRATIVE = `§0 开案:**这一轮要经营哪些客户的关系,经营完把结果写回台账**。
入参四个顶层字段:
· \`trigger\`——**必有**,\`"monthly"\`(月度例行)或 \`"event"\`(事件驱动),翻成人读标签
写进 \`crm_coverage.verdict.trigger_label\`(报告上不摆系统内英文码),不用你自己判该归哪一类。
· \`eventType\`——仅 \`trigger:"event"\` 时可能有,四种之一:\`"new_customer"\`(新客户建立
合作)/ \`"key_person_change"\`(关键人离职/调岗)/ \`"major_deal"\`(重大商机启动)/
\`"t04_project"\`(攻坚项目 T04 立项)。同样翻成人读标签写进
\`crm_coverage.verdict.event_type\`(对照表见下方 S1 的 guide),\`trigger:"monthly"\`
时这一列留空。
· \`customers\`——本轮涉及的客户,**元素形状分两种**:①字符串,已有客户的 \`customer_code\`
(就是 L-02 客户主台账的 \`customer_id\`,同一个东西);②对象,**仅
\`eventType:"new_customer"\` 时出现**——这一刻这家客户还没有编码,对象里装的是
"新建一条 L-02 客户档案要用的字段"(公司名等),编码要等人在数据录入卡上现场给
(细节见 S1 的 guide,这是本轮唯一会用到的第二种卡)。**留空 = 月度例行,你自己
按台账规则从整个客户库识别本轮候选**。
· \`note\`——本轮背景说明(选填),帮你判断覆盖深度与经营侧重。
**成品是三样东西,缺一不可**:①一份**核对过的候选覆盖清单**;②一份**经审批通过的
关系经营计划**(逐个关键人的建议动作与优先级);③**写回 L-02/L-08 主数据的台账更新**
(覆盖度重算、决策链更新、联系人档案更新),每条更新都能在执行回执里找到依据。
**AI 承担信息汇总、记录与初稿生成,人负责判断与决策**:识别候选、缺口分析、排序计划、
收口写台账,都是你的活;核对候选清单、审批计划、到场执行、签字放行,四道都要人过。
**能算的不问人**:关键人是否覆盖(决策链节点比对)、覆盖度、排序,都调工具算,
你不临场拍;调 \`crm-relationship-coverage.recompute-score\` 和
\`crm-relationship-coverage.rank-key-persons\`,一批一次,别一条条调。
**六步走**:识别候选 → 人核对范围 → 制定计划 → 人审批(可能来回改一轮)→
人执行填回执 → 收口写台账 → 人签字放行。
**人核对过的候选清单是后面每一步的输入契约**:覆盖范围确认那一步人可能增删行
(轨迹里「就地修订成果」那条痕点名了增删的是谁),**他加的行与你识别的同等作数、
他删的行别再写回去** —— 缺口分析、经营计划、执行回执、收口写台账,每一步都以
台账现读的 \`crm_coverage.candidate\` 全表为准,不以你 S1 识别时的那份记忆为准。
经营计划必须一家不落(收步判据数这个闭合:计划盖住的客户数 = 候选清单行数)。
**计划与回执只指向真实存在的关键人**:\`crm_coverage.plan\` / \`receipt\` 的
contact_code 必须是 \`customer.contact\` 里已有的编码,contact_name 必须是真人姓名 ——
任何一步都不许自造联系人编码、不许拿占位称呼顶在人名格里。查无联系人的候选客户,
先在 S3 发数据录入卡把第一位关键人收进来(骨架见 S3 剧本),再排它的计划。
【本单可能用到的另一种卡 —— 新客户建档,长这样,只在 S1_identify_candidates 这一项里
发生,\`customers\` 里出现对象形状的元素才发】新客户这一刻没有编码,**不能自己编一个**
(客户编码是主数据的主键,编错一次后面每一单都跟着错——这条纪律与 A1 quote-intake
建新客户档案那一步完全一样,照抄它已经验证过的机制):
{"kind":"entry","itemId":"S1_identify_candidates","ask":"这是位新客户,请核对建档信息并给客户编码",
"ledger":"customer.profile","key":{"customer_code":""},
"fields":[
{"key":"customer_code","label":"客户编码","required":true},
{"key":"name","label":"公司名","value":"青岛新星电子有限公司","required":true},
{"key":"cust_type","label":"客户类型","value":"模组厂"},
{"key":"industry","label":"行业","value":"车规电子"},
{"key":"region","label":"区域","value":"山东"}
],
"also":[{"title":"联系人档案","ledger":"customer.contact","key":{"customer_code":""},
"autoNote":"联系人编码由系统按顺序生成,不用你填",
"fields":[
{"key":"name","label":"联系人姓名","value":"王芳","required":true},
{"key":"title","label":"职位","value":"采购经理"},
{"key":"email","label":"邮箱","value":"wang.fang@example.com"}
]}]}
**只有 \`customers\` 里那个对象已经给出的字段才带 \`value\`**,没给的字段整格别写(界面自己
留白待填,别拿假值凑数);\`customer_code\` 从不给 \`value\`,标 \`required:true\`,人不填就
确认不了;对象里没给 \`contact\` 就不带 \`also\` 段(不是每次都已经知道具体联系人)。
**联系人编码不用你写**——自增列,基座在确认那一刻生成,写进 \`fields\` 也会被摘掉。
**人按下确认那一刻基座已经把这两张表直接落进草稿层**——你不经手,同一拍或下一拍都不许
再 \`write\` 一遍 \`customer.profile\`/\`customer.contact\`(写了会被"写了等于没写"拒回)。
一次只发一张,这张的回复到了、这家客户的编码定下来了,才轮到 \`customers\` 里下一个
对象形状的元素(如果还有的话)。
【这一单唯一的岔路口 —— 经营计划审批,长这样,无论哪一拍都照这个发,别自己另编一张】
计划审批不是一次性的:人可能驳回、要求你按他的意见改。**这一步只在 S4_plan_approval
这一项里发生**,发决策卡之前先在轨迹里找有没有已经答过、还没处理的 \`[card-reply]\`:
· **\`value\` 是 \`"approve"\`**——写 \`plan_approved_total=1\`,finish 这一项,别再发卡;
· **\`value\` 是 \`"reject"\` 或以 \`"custom:"\` 开头**——都算驳回:\`"reject"\` 是点了固定
按钮、没写具体意见,**不许再发卡追问理由**(那两个按钮点出来的回复永远没有 note,
再问一遍是把人指向一扇打不开的门);以 \`"custom:"\` 开头的,\`note\` 就是他写的具体
意见,按这条意见改。两种都要对 \`crm_coverage.plan\` 做出至少一处实质性修订(同 PK
覆盖写,不开新计划项、不重新进入 sentry),然后**换一种问法**再发一张 decide 卡
(措辞不换会被系统当成同一张卡静默吞掉);
· **没有**——才发第一张 decide 卡。
{"kind":"decide","itemId":"S4_plan_approval","allowCustom":true,
"ask":"经营计划已算出,共 <N> 条,覆盖 <M> 家客户的关键人,请审批。",
"options":[{"value":"approve","label":"通过","recommended":true},
{"value":"reject","label":"驳回(不写具体意见)"}]}
三个 \`value\` 只有这三种形态:\`"approve"\`、\`"reject"\`、\`"custom:"\` 开头(人自己写的话)——
**别自己造第四种**。
【一路上要说话】负责人正看着这一单。**每一拍带一句 say** —— 说业务的话:
识别到几家候选、计划排了几个关键人、执行回执收了几条、覆盖度改了多少;不说系统词。
到了要找人的那一拍,先说清这一轮办完了什么、为什么现在需要他。
【怎么把一项干完 · 一拍连做】write 和 tool 都不会自动收步 —— 做完用 finish 宣告。
动作排进同一拍的 actions 串一口气给;只有发卡 / 提交(挂起)必须停拍。
**这一单常路 8-11 拍就该收口**(比 A1 多两道人环:候选核对 + 计划审批 + 执行回执 + 放行,
共四次;审批若被驳回会多出一到两拍)。
§Z 结案:AM Lead 在验收单上签字,系统把草稿合并进 L-02/L-08 正式版并发射
\`coverage-map\` 口 —— 这一轮的覆盖地图供下游读取(E05/E06 尚未实现,暂无消费者)。`;
/**
* 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。
*
* 四处不给 `doneWhen`:`S2_scope_confirm`(交人核对触达世界)、`S4_plan_approval`
* (要人拍板,且这一项内部可能循环)、`S5_execute_receipt`(拜访/交流等 AI 做不到的线下动作)、
* `T_release`(签下去触达世界)—— 给了等于给引擎一把替人签字的钥匙。
*/
export const COVERAGE_STEPS = [
{
item: 'S1_identify_candidates',
intent: '按台账规则识别本轮候选覆盖客户,铸造本轮案号',
writes: ['crm_coverage.candidate', 'crm_coverage.verdict', 'customer.profile', 'customer.contact'],
/* 入参顶层字段不声明就会被整表段截断刀砍掉 —— 事件驱动那几条路全靠这四个字段;
case:openedAt 是铸 round_ref 的料(引擎注入的开案时刻,2026-08-22 起真的在作用域里) */
reads: ['trigger', 'eventType', 'customers', 'note', 'case:openedAt', 'customer.profile', 'customer.contact'],
view: 'coverage-scope',
tools: [],
/*
* **`_rows > 0` 独木难支,`_missing = 0` 也只顾得上半条命**(2026-08-22 两轮实测
* 逼出来的:事件驱动、customers 数组两个客户点名的入参)。
* 第一轮:模型写成的那一行 customer_name 是空的——`_missing = 0` 补上就治好了。
* 第二轮:模型干脆**没写第二颗客户那一行**(不是写了个空值,是那一行压根不存在)——
* `_missing` 只查「已经存在的行缺不缺字段」,查不出「该有的行还没生出来」;
* `_rows > 0` 更是从一开始就跟"两颗"这个数没关系,一行也算数。
* 两轮都在 S1/S2/S3/S4 四道全部放行,一路悄悄漏到 S5 提交闸才第一次被拦下。
* 治法只能是**把「该有几行」也摆进判据**:customers 数组点了几个名,
* 候选行就该有几条。
*
* **`count(customers)` 在入参没给这个键时 = 1,不是 0**(sfeel 的 count 对未绑定名
* flatten 出 `[undefined]`,长度 1 —— 包内 steps 测试用真 @sfeel 钉死了这三种形状):
* 月度例行(键不存在)走的是 `rows >= 1` 这一半,与前面 `rows > 0` 恰好同义,
* 所以行为对;但别把 `count(...) = 0` 那半句当成「留空时不约束」的开关 ——
* 它只在显式传了 `customers: []` 时才为真。
*/
doneWhen:
'crm_coverage_candidate_rows > 0 and crm_coverage_candidate_missing = 0 ' +
'and (count(customers) = 0 or crm_coverage_candidate_rows >= count(customers))',
guide: `第一件事:把这一轮**候选覆盖客户**列出来。**分两段**——先把每一位客户的编码
定下来,全部定下来了才写候选清单(候选清单不许分批写,写一半会被引擎当成这一步已经办完)。
**customers 里点了几个名,候选清单就该有几行**——事件驱动且点名的那几条路,
遗漏一颗客户等于让它从这一单里凭空消失,后面所有步骤都不会再补回来;
写完候选清单,自己数一遍行数是不是等于 customers 里点名的客户数,对不上就是漏写了。
【先铸造本轮案号 round_ref】格式固定:\`'COV-' + case:openedAt 按 YYYYMMDDHHmmss 格式化\`
(\`case:openedAt\` 就在你的作用域里,形如 "2026-08-22 08:47:58" —— 去掉分隔符即得;
你不必也不许自己编)。**不许从 \`crm_coverage.candidate\` 等正式表里已有的别单案号里挑**
—— 那是别的轮次的号,借了就把两轮的数据搅进同一个主键(A2 借号那一课的同款)。
**整单只有一个 round_ref**,写
\`crm_coverage.candidate\` / \`.plan\` / \`.receipt\` / \`.ledger_update\` / \`.verdict\`
时都用这同一个串 —— 造出两个号就是把一单裂成两单。
【第一段:把 customers 里每一项都定成一个确切的客户编码】
· \`customers\` 留空 —— **月度例行**,不必定编码,直接跳到"没点名"那条候选识别路径。
· 元素是字符串 —— 已经是编码,直接用,不必确认。
· 元素是对象(只在 \`eventType:"new_customer"\` 时出现)——**这是新客户,还没有编码,
发一张数据录入卡请人现场建档**,骨架照 NARRATIVE 那段"新客户建档"原样抄
(\`"kind":"entry"\`,\`ledger:"customer.profile"\`,\`key:{"customer_code":""}\`)。
一次只发一张,发完这一拍就结束(挂起);**人按下确认那一刻基座已经把公司档
(和给了的话,联系人档)落进草稿层**——不许你再 \`write\` 一遍这两张表。回复到了,
从轨迹里取人刚给的 \`customer_code\`,继续处理 \`customers\` 里下一项(还有的话)。
【第二段:全部编码定下来了,才做候选识别与写盘,一拍连做】
· 有确切编码的(月度例行没有这一条)—— **这一轮是事件驱动**,以这些编码为骨架,
逐个从 \`customer.profile\` / \`customer.contact\` 里取画像与联系人现状;
**候选清单只收这几个编码,一个不多、一个不少**——事件驱动点了名的,是这一轮
唯一该覆盖的客户;别顺手把台账里其他覆盖度低、决策链有空白的客户也一并纳入
(那是月度例行的活,这一轮不归它管)。已实测炸过:入参只点了 1 家,模型却在
交代里说"识别到 2 家候选客户",多出来的那一家台账里从没写过——人在核对卡上
看见的候选数量,得和候选清单里躺着的行数完全对得上,不能一个在嘴上一个在表里。
· \`customers\` 从一开始就留空的 —— **这一轮是月度例行**,按台账规则识别:优先选
覆盖度低(\`coverage_score\` 低或缺)、决策链有空白节点、或近期有商机/关键人变动
信号的客户。识别不出候选也要如实写 \`crm_coverage.verdict.summary\`,别硬凑几行充数。
【\`decision_chain\` 是什么形状,没有时怎么办】它是一个 JSON 结构:
\`{nodes:[{node, btLevel:"bt-01"|"bt-02"|"bt-03", required:boolean, contactCode, covered}]}\`。
这家客户若从没跑过 E04(\`customer.profile.decision_chain\` 空的),这一步不必初始化它 ——
留给 S3 那一步在做缺口分析时按标准三节点(bt-01 采购/供应链、bt-02 技术、bt-03 高层)
起草,这一步只管把候选客户与联系人现状读出来、把清单列好。
【写 \`crm_coverage.candidate\`,一颗客户一行】(rows 收数组)
round_ref、customer_code(**必填**,上一段定下来的那个)、customer_name(**必填**)、
cust_type(选填,\`customer.profile.cust_type\` 有值就直接抄它——新客户刚在录入卡上
给过;没有才按 \`grade\`/\`profile_tags\` 猜,如"Tier1 OEM"/"模组厂"/"方案商"/"贸易商"/
"终端",猜不出留空)、bt_level(选填,ASCII \`bt-01\`/\`bt-02\`/\`bt-03\` 之一,按这家
客户目前哪个层级的联系人最缺覆盖来定)、status(选填,ASCII \`pending\`,本步一律先写
这个值)、basis(选填,一句纳入依据,如"覆盖度已 40 天未更新"或"客户点名,商机联动"或
"新客户建档,首轮覆盖")、depth(选填,ASCII \`light\`/\`standard\`/\`deep\` 之一,按 basis
的紧迫程度定)、origin(固定写 \`ai\`)。
【同一拍写 \`crm_coverage.verdict\`(一行)】round_ref、trigger_label(**翻成人读标签写,
不写入参那个英文码**——\`"monthly"\`→"月度例行"、\`"event"\`→"事件驱动";报告上不摆系统内
英文名)、event_type(\`trigger:"event"\` 时把入参 \`eventType\` 也翻成人读标签写进来——
\`new_customer\`→"新客户建立合作"、\`key_person_change\`→"关键人离职/调岗"、
\`major_deal\`→"重大商机启动"、\`t04_project\`→"攻坚项目(T04)立项";\`trigger:"monthly"\`
时这一列留空)、note(入参 \`note\` 原样抄,没有留空)、summary 一句人话(本轮识别到几家
候选、依据是什么)。写完 finish 这一项。`,
},
{
item: 'S2_scope_confirm',
intent: '交人核对候选清单:增删改,确认本轮真正要覆盖谁',
writes: ['crm_coverage.verdict'],
reads: ['crm_coverage.candidate'],
view: 'coverage-scope',
tools: [],
/* 不给 doneWhen —— 交人核对触达世界(manualActivation),引擎不替人收 */
guide: `候选清单起草好了,**该 AM Lead 核对了** —— 这是本活动第一张执行单,零输入。
工作区摆的是候选清单的报告(编辑闸开着就能增删改),他在那份件上核对:AI 没识别到但
他了解有价值的客户,他会加一行;AI 纳入了但实际不该覆盖的,他会删掉;识别错的信息,
他会就地改。
这一步标了人工激活:发卡就算启动,不必单发 start;人交回后你自己 finish。
【照这个骨架发,别少字段】(卡面是纯文本,steps/standard 里别写星号记号,别绑 \`blocks\`
或 \`fields\` —— 整份清单在工作区那份件上,执行卡零输入)
{"kind":"human","itemId":"S2_scope_confirm","workOrder":"execution","card":{
"title":"覆盖范围确认:本轮共 <N> 家候选客户",
"ask":"AI 已按台账规则识别出 <N> 家候选客户,请核对确认。",
"steps":["工作区的件上核对候选清单:客户编码与客户名称是必填,缺一条这一单走不到经营计划那一步",
"加一行 = 额外纳入(AI 没识别但你了解有价值的客户)",
"删一行 = 排除(AI 纳入了但实际应排除的客户)",
"改单元格 = 修正 AI 识别的内容 —— 客户类型、BT 层级、纳入依据都能改",
"改完保存,回这张卡提交"],
"standard":"候选清单里每一行客户编码、客户名称都齐;该纳入的都纳入、该排除的都排除了。",
"basis":[{"k":"触发口径","v":"<照 crm_coverage.verdict 刚写的 trigger_label/event_type 说,如
「月度例行」或「事件驱动:重大商机启动」>"},
{"k":"AI 识别","v":"<N> 家候选客户"}]}}
发完这一拍就结束了(发卡挂起)。
【人交回之后】这一步你只做两件事,然后 finish:
① 写 \`crm_coverage.verdict.scope_confirmed_total = 1\`;他在件上撤掉了行或加了行的话,
下一步的缺口分析、计划制定按当下 \`crm_coverage.candidate\` 的实际行数走,不必你重写。
② finish 这一项。**别在这一拍做缺口分析** —— 那是下一步的活,这里做了也白做。
【这张卡一单只发一次】人交回来之后不许再发同一张卡 —— 缺必填有提交闸兜着,
卡回来时客户编码/客户名称一定是齐的(或那几行已被他撤掉)。`,
},
{
item: 'S3_plan',
intent: '读覆盖现状、缺口分析、制定关系经营计划',
writes: ['crm_coverage.plan', 'crm_coverage.receipt', 'crm_coverage.candidate'],
reads: ['crm_coverage.candidate', 'customer.profile', 'customer.contact'],
view: 'coverage-plan',
tools: ['crm-relationship-coverage.recompute-score', 'crm-relationship-coverage.rank-key-persons'],
/*
* 覆盖闭合(2026-08-24 柴浩实测):只数「计划有没有行」的话,人在覆盖范围确认卡上
* 加的那家客户被漏掉后这一步照样自动收步 —— 计划盖住的客户数(distinct,基座数)
* 必须等于候选清单行数(人增删后的现数),一家不落这一步才算办完。
*/
doneWhen:
'crm_coverage_plan_rows > 0 and crm_coverage_plan_missing = 0 and ' +
'crm_coverage_plan_distinct_customer_code = crm_coverage_candidate_rows and ' +
/*
* 回执骨架与计划一一对应(2026-08-24 柴浩实测:计划 7 行回执 6 行,人填完 6 条就能交卷,
* 到放行门才撞墙)—— 少一行骨架人就少一格可填,而提交闸只数已有行缺不缺必填,
* 数不出「少了一行」。这里与 S4 入场哨兵同一句,派回执单之前就得齐。
*/
'crm_coverage_receipt_rows = crm_coverage_plan_rows',
guide: `候选范围定了,**现在读现状、找缺口、排计划** —— 固定三拍,一拍一件事,别在同一拍里
把「调工具」和「写台账」串在一起(**工具调用后同拍其余动作作废,结果要下一拍才看得见**;
串了也是白串,只会多烧一拍)。这一步只需要调工具**各一次**,拿到结果后不要再调第二次。
**这一步只管 \`crm_coverage.candidate\` 里躺着的那几家,一家都不能多**——\`reads\` 里
还带着 \`customer.profile\`/\`customer.contact\` 整表,那是给你查这几家候选客户各自的
决策链与联系人现状用的**参考资料**,不是"顺手也把看见的其他客户一起排上计划"的许可。
已实测炸过:候选清单里明明只有 1 家,写出来的计划却多了一家台账里压根不存在于候选清单
的客户(那家客户是**从 customer.profile/customer.contact 整表里"看见"的**,和这一轮
候选范围毫无关系)——动手前先把候选清单的 \`customer_code\` 抄一遍,写计划时逐条核对
是不是都在这张抄出来的清单里,不在的一律不许写。
**也一家都不能少** —— 抄名单抄的是台账现读的 \`crm_coverage.candidate\` **全表**,
不是你 S1 识别时记住的那几家:人核对时可能加了行(轨迹里那条「就地修订成果:新增」
点名了加的是谁),**人加的行与你识别的同等作数**。收步判据现在数这个闭合
(计划盖住的客户数 = 候选清单行数),漏一家这一步就收不了。
【第一拍:缺口分析,只调工具,别的什么都不写】把本轮全部候选客户(读
\`crm_coverage.candidate\`)一次性打包,调一次
\`crm-relationship-coverage.recompute-score\`,入参:
\`\`\`
{"customers":[
{"customerCode":"CU-0142",
"decisionNodes":[
{"node":"bt-01","btLevel":"bt-01","required":true},
{"node":"bt-02","btLevel":"bt-02","required":true},
{"node":"bt-03","btLevel":"bt-03","required":true}],
"contacts":[
{"contactCode":"CU-0142-01","decisionNode":"bt-01","keyPerson":false,"influence":"","relationship":0}]}
]}
\`\`\`
· \`decisionNodes\`:这家客户若已有 \`customer.profile.decision_chain\`,原样照抄它的节点;
没有(新客户/首次跑 E04)就照抄上面这三行(**node 与 btLevel 写同一个值**,
\`bt-01\`/\`bt-02\`/\`bt-03\`,一个字符不许错、不许留空 —— node 留空会让出参里那个节点
变成 \`null\`,缺口分析就废了一条)。
· \`contacts\`:这家客户名下**所有**已知联系人(读 \`customer.contact\`),逐人填
\`contactCode\`/\`influence\`/\`relationship\`/\`keyPerson\`;\`decisionNode\` 按人现有的
\`customer.contact.decision_node\` 填,没有就按职位/角色估一个最贴近的层级(如「采购部」
估 bt-01),都判不出就填 \`"bt-01"\` 兜底 —— **这一格不许留空**,留空这个人就永远配不上
任何节点、白算了这一趟。空缺字段(如 \`influence\` 还没定过)照现有值填,是空字符串
或 0 都行,工具认得。
多家候选客户就都塞进同一个 \`customers\` 数组里,**一次调完,不要一家调一次**。
某家候选客户在 \`customer.contact\` 里一个联系人都没有(人核对时额外纳入的客户常是
这种)—— \`contacts\` 传空数组 \`[]\`,工具会如实给出「三个节点全是缺口」,
这正是要的答案,不是异常。
调完这一拍就结束(工具出参要下一拍才看得见)。
【第二拍:排序,只调工具,别的什么都不写】把上一拍 \`results\` 里出现过的**所有联系人**
(不止缺口节点上的那几位)一次性打包,调一次 \`crm-relationship-coverage.rank-key-persons\`,
入参 \`{"contacts":[{"contactCode":"...","influence":"...","relationship":0}, ...]}\`——
**批量调,不要一人调一次**。调完这一拍就结束。
【第三拍:写计划,一拍连做】拿着前两拍的工具出参,**一次都不要再调工具**,直接写:
① 逐家候选客户看第一拍的 \`gapNodes\`:**gapNodes 里的每一个缺口节点,都要在计划里
至少配一个关键人经营动作**——就算这个节点上目前一个联系人都没有覆盖到(\`coveredNodes\`
是空的、\`coverageScore\` 是 0),那正是这一步该管的事:挑这家客户名下**已知的、
离这个节点最近的联系人**(按 \`decisionNode\` 匹配,匹配不上就挑影响力最高的那位)
写一条「去覆盖这个节点」的动作。**\`gapNodes\` 全员缺口是正常结果,不是异常,
不需要回去重调工具确认。**
**计划表里的 contact_code 只许写 \`customer.contact\` 里真实存在的编码,
contact_name 只许写真人姓名** —— 不许为缺口节点自造顺延的引用码、不许拿占位称呼
顶在关键人那一格(实测走岔过:模型给两个缺口节点各编了一个顺延号、关键人一栏
填了占位称呼 —— 那不是计划,是把「还没认识人」伪装成「有人」;审批的人一眼就退)。
缺口节点的动作挂在**已知联系人**名下,「识别/引荐」写进动作内容,不写进人名。
计划表**一位联系人一行**(主键就是联系人编码):同一位联系人罩几个缺口节点,
几个动作并进**他那一行**的 action(分号连写),不为节点另开行。
引擎会核 contact_code 真的在 \`customer.contact\` 里 —— 编出来的号写不进去。
② **写 \`crm_coverage.plan\`,按第二拍的 rank_score 降序、一个关键人一行**(rows 收数组;
模版不排序,你必须已经按序写)。round_ref、contact_code(**必填**)、customer_code
(**必填**)、customer_name(选填)、contact_name(选填)、action(**必填**,具体到能
执行的建议动作,如"下月中拜访技术总监,带 XX 认证报告"而不是笼统的"拜访")、priority
(**必填**,ASCII \`high\`/\`medium\`/\`low\`,照第二拍算出的档位抄)、rationale(**必填**,
说清为什么是这个人这个动作,引影响力与亲密度差值,或"该节点目前空白,此人是最接近的
候选")、influence(**必填**,写计划这一刻的快照,照抄联系人当前的 influence,没有就写
\`"low"\`)、relationship_gap(**必填**,第二拍算出的数)、rank_score(**必填**,第二拍
算出的数)、target_date(**必填**,具体日期,没有时间点的建议不算数)。
**查无任何联系人的候选客户,先把人收进来,再排它的计划**(人核对时额外纳入的客户
常是这种 —— 该经营谁,加行的人自己知道;你不许替他编一个人):在调工具**之前**
给这样的客户各发一张数据录入卡,照这个骨架(一家一张、一单只发一次;
人确认那一下基座直接落草稿,你不经手):
{"kind":"entry","itemId":"S3_plan",
"ask":"新纳入的客户 <客户名> 还没有联系人档案,请录入这一轮要经营的第一位关键人",
"ledger":"customer.contact","key":{"customer_code":"<候选行的客户编码>"},
"fields":[
{"key":"name","label":"姓名","required":true},
{"key":"title","label":"职务"},
{"key":"decision_node","label":"决策链节点(bt-01 采购 / bt-02 技术 / bt-03 高层)"}
]}
联系人编码由基座续号,不进卡面。发完这一拍就结束(发卡挂起);人答回之后这家客户
就有真实联系人了(在 \`customer.contact\` 里看得到),照上面三拍走,计划行引用
**这位真人**的编码与姓名,动作写首访与关系建立(influence/relationship 还没首评,
排序入参按默认:influence 按职务估、没法估写 \`"low"\`,relationship 写 1)。
**不许因为查无联系人就把这家客户跳过** —— 跳过它,收步判据
(计划客户数 = 候选行数)就差一家,这一步永远收不了。
③ **同一拍,给每条计划配一行 \`crm_coverage.receipt\` 骨架**(这一步只写骨架,完成状态那几列
留空,S5 那一步才由人填):round_ref、contact_code(与 plan 同一个,**必填**)、
customer_code(选填,照抄)、customer_name(选填,照抄 —— 执行回执工单的「对象」列长
"客户名 · 关键人",两个名字都从这里来)、contact_name(选填,照抄)、action(选填,
照抄 plan 的 action,供执行回执工单只读展示)、target_date(选填,照抄)。
completion_status / result_summary / customer_feedback / key_person_change
**这一步一格都不写**。
④ 若某候选客户的 \`cust_type\`/\`bt_level\` **还空着**,顺手补写
\`crm_coverage.candidate\` 的这两列(同 PK 覆盖写)。**两列都已有值的行一个字都不写**
—— 原值重写会被当空转拒(实测:这一句从前没写「还空着」,模型把有值的行
照抄了一遍,白吃一次拒)。
写完这一拍就结束(不必你 finish)——\`crm_coverage_plan_rows > 0 and
crm_coverage_plan_missing = 0\` 一旦为真,引擎自己收这一步。`,
},
{
item: 'S4_plan_approval',
intent: '经营计划审批:通过就往下走,驳回则按意见修订后换一种问法再提交',
writes: ['crm_coverage.plan', 'crm_coverage.receipt', 'crm_coverage.verdict'],
reads: ['crm_coverage.plan'],
view: 'coverage-plan',
tools: [],
/* 决策卡循环 + 要人拍板 —— 不给 doneWhen,引擎不许替人收 */
guide: `**动手之前先在轨迹里找一行 \`[card-reply]\` 带这一项的答复:**
· **没找到** —— 这是第一次,跳到下面【发一张决策卡】那一段;
· **\`value\` 是 \`"approve"\`** —— 写 \`crm_coverage.verdict.plan_approved_total = 1\`,
finish 这一项,**不许再发卡**;
· **\`value\` 是 \`"reject"\` 或以 \`"custom:"\` 开头** —— 都算驳回,跳到下面【驳回之后】
那一段,修订计划再重新发卡。
【这一步办完了没有,看一个数】\`plan_approved_total\`:它写成 1 就是这一步办完了,
引擎不会再把这一项交给你(它归零 doneWhen 帮不上忙,只有你自己 finish)。
【发一张决策卡,照这个骨架】(选项少于两个基座拒发;\`allowCustom\` 别漏 —— 少了它,人只有
两个固定按钮可点,连意见都没地方写;\`ask\` 每一轮都要按下面的规则换措辞)
{"kind":"decide","itemId":"S4_plan_approval",
"allowCustom":true,
"ask":"经营计划已算出,共 <N> 条,覆盖 <M> 家客户的关键人,请审批。",
"options":[
{"value":"approve","label":"通过","recommended":true,"consequence":"计划进入执行阶段,交人去落实"},
{"value":"reject","label":"驳回(不写具体意见)","consequence":"AI按自己的判断复核一遍计划,重新提交审批"}],
"basis":[{"k":"覆盖客户","v":"<M> / <本轮候选总数> 家已各有至少一条计划"},
{"k":"排序依据","v":"影响力 × 亲密度缺口"},
{"k":"想说明理由","v":"不点上面两个按钮,直接在下方输入框写你的意见再提交"}]}
发完这一拍就结束了(发卡挂起)。
【驳回之后 —— 不管有没有具体意见,都只处理这一轮,不许再追问一遍】
① \`value\` 以 \`"custom:"\` 开头 —— \`note\` 就是他写的具体意见。**先认归属,再动笔**:
意见里点了名(客户名 / 关键人名 / 编码)就拿那个名字与计划表逐行比对
(customer_name / contact_name / customer_code / contact_code 四列;轨迹里若有一条
「意见按字面点名对上了…」的观察,那是基座比对好的答案,**照它认,不自己另猜**)。
**改的必须是被点名的那一行** —— 点名的客户没有意见里说的那种联系人
(比如让去见某个职位的人,而这家的关键人还待识别),改的是这一行自己的动作内容
(把首访对象换成那个职位),**不是把改动挪到别家碰巧有这种联系人的行头上**;
名字对不上任何一行、或对上不止一行认不出唯一,**不许猜**:重新发卡,把
「你理解要改哪一行」写进 ask 请人确认(这算修订重提交,不算追问理由)。
认准之后**按这条意见**修订 \`crm_coverage.plan\`
(同 PK 覆盖写对应的行,不开新计划项、不重新进入 sentry)。
② \`value\` 是 \`"reject"\`(点了固定按钮,没写具体意见)—— **不许再发卡追问理由**
(那两个按钮点出来的回复永远没有 note,再问一遍是把人指向一扇打不开的门,
会被同一句话拒回,连问出真话的机会都没有)。改成你自己复核:排序有没有明显不合理、
有没有遗漏更紧要的缺口节点、理由那一句站不站得住,尽量改得更有说服力;复核后**至少要有
一处实质性变化**(改排序、换动作、补理由,不能原样重发)。
③ **计划与回执骨架一一对应,改哪边都得同步**:加了一条计划就同拍给它加一行
\`crm_coverage.receipt\` 骨架(round_ref + contact_code,其余照抄计划,完成状态那几列留空);
撤了一条计划就 \`drop\` 它的回执骨架。引擎数「回执行数 = 计划行数」,差一行,执行单派不出去。
**改了 \`action\` 这一列的话,\`crm_coverage.receipt\` 同一行的 \`action\` 也要跟着改**
(同 PK——\`round_ref\`+\`contact_code\`——覆盖写)。receipt.action 是 S3 那一拍从
plan.action **抄**过来的展示副本(执行回执工单直接读它、只读展示),这里只改了
plan 不改 receipt,S5 那一步人看到的还是驳回前的旧动作——已经实测过:审批来回改了
三稿,receipt 里摆的还是第一稿,推进到执行回执时对不上人刚通过的那一版。
④ **重新发卡的 \`ask\` 必须换一种说法**,带上修订轮次与**改了哪一家哪位、改成了什么**
(用人读名:客户名 · 关键人名,别只给编码 —— 这句话是给人核对归属的,
他要一眼看出你改的是不是他点名的那一行),例如
"已复核并修订(第2稿:把 客户甲·采购经理 的动作改为下月带认证报告拜访)请再次审批"——
措辞不换,系统会把它当成同一张卡:前两次静默吞掉(人看不到新卡),第三次直接拒。
**每一轮的 \`ask\` 都要与上一轮明显不同**,不止改个数字。
⑤ 再走一遍【发一张决策卡】那一段(用新措辞,\`allowCustom\` 仍然要给),然后这一拍结束
(发卡挂起)。
**这一步只在这一项里循环,不许把驳回意见带去改别的项**(S1/S2/S3 写下的东西不动)。`,
},
{
item: 'S5_execute_receipt',
intent: '交人执行经营动作,回填执行回执',
writes: [],
reads: ['crm_coverage.receipt'],
view: 'coverage-receipt',
tools: [],
/*
* 拜访 / 技术交流等线下动作 AI 做不到 —— 不给 doneWhen。
* **这一步没有任何记账旗**(2.0.2 退役 receipt_confirmed_total,两轮活单熔断实测):
* 旗是模型写的,S4 一过它顺手写旗就坚信 S5 已办完,连续五拍
* [写旗 + finish 被 worldTouched 拒] 熔断。回执四列全是人在件上填的,
* 收口那道门读的是基座数(`_rows`/`_missing`)—— 模型无账可记,也就无处抢跑。
*/
guide: `计划审批通过了,**该 AM 出场了** —— 这是本活动第二张执行单,零输入。
触达客户的那一下由人做:拜访、技术交流、价值传递这些线下动作 AI 做不到,
工作区摆的是执行回执工单(逐个关键人一行,计划动作只读展示,回执四列可填)。
这一步标了人工激活:发卡就算启动,不必单发 start。**这一步不写任何台账** ——
回执四列由人在件上填、由基座直接落草稿层,你只有两个动作:发卡,和人交回后 finish。
**这一步「人被问过没有」不用你判断 —— 【当前这一步】标题下面那行引擎事实写得明明白白**
(「这一单里已经真的给它发过卡…人已经被问过了」或「这一单里从没给它发过卡」),照它分两条路:
· 那行说**「从没给它发过卡」** —— 你唯一合法的动作就是照下面骨架发卡,发完这一拍结束(挂起)。
**别去轨迹里数 \`[card-reply]\` 自己判断**:S2 的执行单、S4 的决策卡都会在轨迹里留
\`[card-reply]\`,它们不是这一步的 —— 实测两轮连拒都是把 S4 审批的答复当成了 S5 的回执,
凭空宣布「执行回执已交回」。引擎那行事实比你对轨迹的读法准,以它为准。
· 那行说**「人已经被问过了」** —— 人交回后直接 finish 这一项,一个字都不用写
(回执数据人已经在件上填好、基座已落草稿层;**别在这一拍收口写台账** ——
重算覆盖度、写回主数据是下一步的活)。
【照这个骨架发,别少字段】(执行卡零输入,别绑 \`blocks\` 或 \`fields\`)
{"kind":"human","itemId":"S5_execute_receipt","workOrder":"execution","card":{
"title":"经营计划执行回执:共 <N> 项动作",
"ask":"经营计划已审批通过,请执行并回填回执。",
"steps":["每项动作执行后,在工作区的件上逐项回填:完成状态、结果摘要为必填,客户反馈选填",
"关键人变动如实回答「是/否」;若填「是」,请在结果摘要里说清具体变动是什么",
"确实没有变动的,「关键人变动」这一格要写「无变动」,不能留空",
"改完保存,回这张卡提交"],
"standard":"每一项动作的完成状态与结果摘要都填了;关键人变动逐项如实标注,不遗漏。",
"basis":[{"k":"本轮动作数","v":"<N> 项"}]}}
发完这一拍就结束了(发卡挂起)。
【这张卡一单只发一次】人交回来之后不许再发同一张卡 —— 缺必填有提交闸兜着。`,
},
{
item: 'S6_writeback',
intent: '写回主数据:按回执重算覆盖度,更新联系人/客户档案与候选状态',
writes: ['customer.profile', 'customer.contact', 'crm_coverage.candidate'],
reads: ['crm_coverage.receipt', 'crm_coverage.plan', 'customer.profile', 'customer.contact'],
view: 'coverage-ledger-update',
tools: ['crm-relationship-coverage.recompute-score'],
/*
* 四个数全是基座现数:重算分与档案都写齐了才算办完。
* 收口从一项拆成两项(2.0.4 结构治):从前「三拍纪律」写得再明白,模型照样把
* 四张表挤进一串,串尾最费字数的 ledger_update 被截成空数组挨拒(两轮实测同一处)。
* 拆开之后每项的剧本只摊自己那几张表 —— 串想长也长不起来。
*/
doneWhen:
'customer_profile_rows > 0 and customer_profile_missing = 0 and customer_contact_rows > 0 and customer_contact_missing = 0',
guide: `回执都收齐了,**现在写回主数据** —— 固定两拍,别把「调工具」和「写台账」串进
同一拍(工具调用后同拍其余动作作废,结果要下一拍才看得见)。
**这一步不碰 \`crm_coverage.ledger_update\`** —— 留更新明细是下一步(S6_ledger_update)
的活,这一步只管把主数据写对。
【第一拍:重算,只调工具,别的什么都不写】逐个联系人按回执推导新的 relationship/influence
(草稿,先想清楚,还不落库):\`completion_status\` 为 \`done\` 且关系明显推进的,
\`relationship\` 上调(不超过 5);\`partial\`/\`skipped\` 的原样不动或视回执具体内容微调。
**调不出结论就别改** —— 没有回执支持的调整不许写。把本轮涉及的候选客户**一次性打包**,
调一次 \`crm-relationship-coverage.recompute-score\`:
\`decisionNodes\` 照抄 \`customer.profile.decision_chain\` 现有的节点(\`node\`/\`btLevel\`/
\`required\` 三样都要,一个字符不许错);\`contacts\` 是这家客户名下**所有**已知联系人,
**逐人五个字段都要给全**——\`contactCode\`/\`decisionNode\`(按 \`customer.contact
.decision_node\` 填)/\`keyPerson\`(这轮判断的关键人标记)/\`relationship\`(推导后的新值)/
\`influence\`(推导后的新值)。
\`\`\`
{"customers":[{"customerCode":"CU-0142",
"decisionNodes":[{"node":"bt-01","btLevel":"bt-01","required":true}, ...],
"contacts":[{"contactCode":"CU-0142-01","decisionNode":"bt-01","keyPerson":true,
"relationship":4,"influence":"high"}]}]}
\`\`\`
**\`keyPerson\` 这一格漏了,工具会把这个人当成"不存在",这个节点当场判空**——已经实测炸过:
同一批入参只差这一个字段,coverageScore 从 17/33 变成 0,跟台账里明明白白写着的
"bt-01 已覆盖"对不上;模型因此怀疑结果、来回改了又调、调了又改,一单纯为这一步烧了 26 次
工具调用、烧穿了 500k token 预算,\`crm_coverage.ledger_update\` 一行没写成就 exhausted 了。
拿到 \`results\`(逐家 coverageScore/coveredNodes)——**一次调完,不要一家调一次,更不要见
结果与预期不符就反复重调**:它是纯工具,同样的输入永远同样的结论,算出来的覆盖度比预想低,
先检查自己传的 \`contacts\` 是不是漏了字段,而不是重调一遍赌下次会不一样(不会)。
调完这一拍就结束。
【第二拍:只写这两张,别的表这一拍不碰】
· **写 \`customer.contact\`**(对应联系人行,同 PK 覆盖写):**碰过的每一行,
\`name\` / \`influence\` / \`relationship\` / \`key_person\` 四列一列都不许缺**
(基座数缺必填只看本单写下的行 —— 你写了这一行却漏了其中一列,
\`customer_contact_missing\` 就大于零,放行门永远打不开;name 照现值抄一遍即可)。
**没有真实姓名的人不许建联系人行** —— 「计划要经营的岗位」不是人,节点缺口
留在 decision_chain 里如实标 covered:false,不落 customer.contact
(实测炸过:两条人名职务全空的"联系人"进了正式表,还标着关键人、亲密度3 ——
那是把计划冒充成了档案,后来只能人工删掉):\`influence\` 写推导后的评级(没变化就照现值抄一遍)、
\`relationship\` 写推导后的新值(没变化照现值抄)、\`key_person\` 照这轮判断写;
**本单新建档的联系人**(录入卡只落了姓名/职务/邮箱)这三列由你首评:
\`influence\` 按职务估(总监/VP 级 high、经理级 medium、其余 low)、
\`relationship\` 按实际接触定(还没接触过写 1)、\`key_person\` 如实判断;
\`role\`/\`decision_node\`/\`comm_pref\` 若这轮有新信息一并补;
\`change_log\` 追加一句本轮变动(离职/调岗从回执的 \`key_person_change\` 抓,
没有变动不必写这一列)。
· **写 \`customer.profile\`**(对应客户行,同 PK 覆盖写):**碰过的每一行把
\`name\` 照现值带上(必填,漏了放行门不开)**;\`coverage_score\` = 工具
算出的新分数(**必填**);\`decision_chain\` = 更新后的节点覆盖状态(**必填**,
整个数组重写,不是增量合并);\`interaction_timeline\` 追加本轮的互动条目(选填,
没有新增互动的客户不必写这一列)。
**本轮新建档的客户(候选清单里人加的那家)要把档案填实,不许只落两个数**:
\`name\` / \`cust_type\` 照候选行抄(candidate 里人核对过的就是权威);
\`industry\` / \`region\` 从**已知信息**里分析 —— 来件正文、背景说明(note)、
客户类型、回执里人写的内容,推得出就填、推不出留空(空格会以「建议填」摆在
台账更新确认屏上,人补得了)—— **不许编造一个查无出处的行业或地区**。
· 回执若带回了**新认识的关键人**(姓名/职务在 \`result_summary\`/\`key_person_change\` 里),
给这家客户**新建**联系人行 —— \`contact_code\` 照声明的续号规则铸:
\`<customer_code>-两位序号\`,序号接在该客户名下现有联系人(查 \`customer.contact\`
全表)最大号之后,一位都没有就从 \`-01\` 起;姓名/职务照回执写,首评三列
(influence/relationship/key_person)按上面「本单新建档」那条规则给。
回执没带回人,就什么都不建,如实留缺口。
**人工新增的候选客户在 \`customer.profile\` 里还没有档案的,这一拍补一行**
(customer_code/name 照候选行抄,coverage_score 与 decision_chain 照工具出参写)——
没有档案行,这家客户的覆盖度就没有落点。
· **把办完的候选行状态置 \`covered\`**(写 \`crm_coverage.candidate\`,同 PK 覆盖写,
只带 status 一列):回执里有 \`done\` 动作的客户置 ASCII \`covered\`;
全部 \`skipped\` 的留 \`pending\` 如实 —— 清单上那枚状态徽是给人看的,
一轮跑完还全写着「待覆盖」就是界面在撒谎。
写完这三张,这一步就办完了 —— 不必你 finish,\`doneWhen\` 会在主数据写齐那一刻自动收步,
留更新明细归下一步(S6_ledger_update),这一拍到此为止。`,
},
{
item: 'S6_ledger_update',
intent: '留痕收口:逐条台账更新明细 + 本轮覆盖结论',
writes: ['crm_coverage.ledger_update', 'crm_coverage.verdict'],
reads: ['crm_coverage.receipt', 'customer.profile', 'customer.contact'],
view: 'coverage-ledger-update',
tools: [],
doneWhen:
'crm_coverage_ledger_update_rows > 0 and crm_coverage_ledger_update_missing = 0 and ' +
'crm_coverage_ledger_update_distinct_update_key = crm_coverage_ledger_update_rows and ' +
'customer_profile_missing = 0 and customer_contact_missing = 0',
guide: `主数据已在上一步写回,**这一步只写两张表、一拍连做**:先写更新明细
(它最费篇幅,放在串首),再写结论。**上一步那三张表(contact/profile/candidate)
这一拍不许再写一遍** —— 再写一遍原值只会被当空转拒掉。
· **写 \`crm_coverage.ledger_update\`,每条主数据更新对应一行**(rows 收数组,
**数组不能是空的** —— 第二拍写的每一处真实变化,这里都要有一行对应):
round_ref、update_seq(**必填**,本轮内从 1 递增)、ledger_label(**必填**,人读名,
如"客户主台账·关系覆盖度")、ledger_address(**必填**,机器地址,如
"customer.profile.coverage_score")、customer_code(**必填**)、contact_code
(选填,客户级字段留空)、field_label(**必填**,人读列名)、before_value(**必填**,
无值写字面"(无)")、after_value(**必填**)、evidence_ref(**必填**,**用人话**引用具体
回执事实,如"Itelma·Ekaterina 的回执:已完成拜访,亲密度由3提升至4" —— 写客户名与
人名,**不写联系人编码、不带任何机器键前缀**:这一列是摆给签字的人看的)、
update_key(**必填**,更新位置键,按「台账地址|客户编码|联系人编码|字段」四段用竖线
拼,客户级字段第三段留空,如"customer.profile.coverage_score|CU-0142||关系覆盖度"
—— 同一处位置本轮只能有一行,引擎数这个)。
**没有真实变化的字段不必开一行** —— 明细表只记「真改了什么」。
每一处真实变化的依据都在回执与上一步写下的新值里,对照着写,别凭记忆。
· **写 \`crm_coverage.verdict\`**(同一拍接着写):ledger_update_confirmed_total = 1、
covered_customer_total = 这一轮覆盖度提升或已达标的客户数、
key_person_covered_total = 这一轮标记为 key_person 的联系人数、summary
补一句人话(这一轮覆盖了几家客户、覆盖度平均涨了多少)。
写完 finish 这一项 —— 或者不必你 finish,\`doneWhen\` 会在必填列都齐的那一刻自动收步。`,
},
{
item: 'T_release',
intent: '台账已收口,提交放行交 AM Lead 验收;被打回就按意见改完再交',
/* 被打回重做时要改的就是这三张 —— 不声明就写不进去(写的授权面是台账声明) */
writes: ['crm_coverage.plan', 'crm_coverage.ledger_update', 'crm_coverage.verdict'],
reads: ['crm_coverage.plan', 'crm_coverage.ledger_update', 'crm_coverage.receipt'],
view: 'coverage-ledger-update',
tools: [],
/* 放行触达世界,必须人按一下 —— 引擎不自动收它 */
guide: `台账更新确认那一屏摆着三样:更新明细(你的摘要)+ **两张落库预览**
(将写入的客户档案 / 联系人档案,人在上面补字段、加联系人都是合法的兜底 ——
他补的值、他加的行已在草稿层,**你不许再 write 覆盖**;判齐与哨兵读的是现值,
人补完门自己开)。台账更新明细齐了、主数据缺项也补齐了,**可以走放行了**
(T_release 人工激活:动它就算启动,不必单发 start)。
【**先看一眼轨迹里有没有「验收被打回重做」**】有的话,这一拍**不是**再提交一遍 ——
人已经看过这份成果并把它退回来了,原样递上去他看见的还是刚被他打回的那一份
(基座那道闸也会当场拒:「打回之后台账一个字没改」)。照他的意见改**台账本身**,
改完这一拍再 judge + submit:
· 他说**计划的理由空/不实** → 改 \`crm_coverage.plan\` 的 \`rationale\`(同 PK 覆盖写),
把影响力评级、亲密度差值、决策链缺的是哪个节点写进去,一条一条落实;
动作不具体的连 \`action\`/\`target_date\` 一起改。
· 他说**台账更新没依据 / 数不对** → 改 \`crm_coverage.ledger_update\` 的
\`evidence_ref\`(点名是哪条回执支持它)或 \`before_value\`/\`after_value\`。
· 他说**结论没说清** → 改 \`crm_coverage.verdict.summary\`。
· 放行门说**「每条计划都收到执行回执」不成立**(回执比计划少行)→ **不许删计划来凑数**
(删了「每家候选都进了计划」立刻不成立,两堵墙对着撞);补上缺的那行回执骨架
(round_ref + contact_code,照计划抄动作),再发一张执行工单让人把那条回执填回来 ——
回执是人办事的凭据,只有人能填。
· 他**没写具体意见**(只点了重做)→ 自己把成果复核一遍,挑最站不住的那处改实
(理由最空的那条计划、依据最含糊的那条更新),**至少改对一处**;
实在判断不出他要什么,发一张零输入执行工单问清楚,别原样重提。
**改的是值,不是数**:同一行覆盖写,别新开行、别把行数改了 —— 那会把收步判据弄假。
**明细里过期的旧记录要撤掉,不留旧账**(2026-08-24 用户当场指出):重做后某条更新
不再成立(数变了、结论变了),就用 \`drop\` 撤掉那一行再写新的,或直接同 seq 覆盖写;
同一处位置(update_key 相同)出现两条,判据「同一处字段只留一条更新」当场拦。
【没被打回(或已按意见改过了)】先把裁量判据裁掉,然后 submit,同一拍连做:
· judge criterion="每条台账更新都能在执行回执里找到对应事实依据,不是凭空推算" ——
逐条看 \`crm_coverage.ledger_update\` 的 \`evidence_ref\`:说得出具体哪条回执支持这条
更新就 satisfied=true;有一条说不清来路的,satisfied=**false**,rationale 逐条写清,
交签字的人定。
· judge criterion="更新后的数值(覆盖度、亲密度等)变化合理,没有异常跳变" ——
看每条数值型更新的 before/after 幅度是否与回执的完成程度相称。
· judge criterion="更新内容准确对应回执事实,没有张冠李戴或误更别的客户/联系人" ——
核对 customer_code/contact_code 与回执主体是否一致。
**裁成不满足照样能提交** —— 理由连着卡一起交给签字的人。criterion 照判据名整句原样抄,
写别的名字对不上。
这一单没有分支去向 —— 全程线性,只发一口 \`coverage-map\`,不必你选出口。
终局唯放行:你不能自己宣布完成。`,
},
];
/**
* 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段;
* 放行那一格由基座统一加(`CLOSING_PHASE`),所以声明 6 段、人看到的是 **x/7**。
*
* **刻度要与业务流程的步数对得上**(2026-08-24 用户:「E04 应该是有 7 步、
* 给人确认是 4 步,这里要是 x/7 才对」)。从前这里按「归口」粗分成三段,
* 于是屏上是 x/4 —— 而 `E04-活动流程与工单说明.md` 开篇写的是「整体流程(7 步)」:
* 人对着文档数是 7,对着进度条数是 4,**同一件事两个数**。
* 现在一段对一步,与文档那张表逐行对齐:
*
* ① 启动与覆盖范围确认 → 识别候选客户(AI 汇总)+ 覆盖范围确认(**人**,执行工单1)
* ②③④ 读现状 / 缺口分析 / 制定计划 → 制定关系经营计划(AI 算)
* ④ 计划审批 → 经营计划审批(**人**,验收工单1)
* ⑤ 执行经营动作 → 执行与回执(**人**,执行工单2)
* ⑥ 更新关系台账 → 写回主数据 + 台账明细留痕
* ⑦ 复盘与完结(门禁) → 放行收口(**人**,验收工单2,基座那一格)
*
* 四张卡就是那 4 个「给人确认」的刻度 —— 其余三格是 AI 在办事。
*/
export const COVERAGE_PHASES = [
{ name: '识别候选客户', items: ['S1_identify_candidates'] },
{ name: '覆盖范围确认', items: ['S2_scope_confirm'] },
{ name: '缺口分析与制定计划', items: ['S3_plan'] },
{ name: '经营计划审批', items: ['S4_plan_approval'] },
{ name: '执行经营动作与回执', items: ['S5_execute_receipt'] },
{ name: '更新关系台账', items: ['S6_writeback', 'S6_ledger_update'] },
];
@@ -0,0 +1,11 @@
/**
* E04 · 关系覆盖与关键人经营 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
*
* 这个活动没有 `run`。案例活动的推进由 Loop 驱动,行为全部写在清单里:
* 案例模型 + 说明书 + 步骤剧本 + 两个纯工具 + 三条技能 + 台账声明 + 四张 HTML 报告模版。
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 ——
* 它是**被静态审扫描的产物文本**(查 import 与全局引用,证明活动包零环境能力)。
*/
import { defineActivity } from '@sdk';
export default defineActivity({ name: 'crm-relationship-coverage' });
@@ -0,0 +1,639 @@
/**
* 活动包清单 —— E04 关系覆盖与关键人经营(案例活动)。
*
* 业务出处:`docs/SS01-客户与市场/e04-html/E04-活动流程与工单说明.md`(7 步业务链条,
* AI 承担信息汇总/记录/初稿生成,人负责判断与决策,收成 2 张执行工单 + 2 张验收/决策工单)、
* `docs/SS01-客户与市场/Demo-E04-I01-Activity台账关联与设计.html`(L-02 客户主台账 /
* L-08 客户联系人台账的读写关系)。
*
* 这是本仓库第一个不属于 `quote-*` A1-A7 报价管线的活动:触发方式是月度例行 / 事件驱动
* 而非来件邮件,核心读写对象是共享主数据 `customer.profile`/`customer.contact`。
*
* 两处与 A 系列的刻意不同,写在这里免得下一个读者当成疏漏:
* ① `customer.profile`/`customer.contact` 已由 A1(quote-intake)声明 `kind:'master'`
* 并持有 `customer_code`/`name` 等基本列 —— 这里只**追加**本活动要用的新列
* (`schema-merge.ts` 的声明合并是纯 add-column,不冲突,不需要重复声明已有列)。
* ② 「验收工单1」(经营计划审批)是**非终局**的通过/驳回+AI重做循环 ——
* `issueAcceptance` 硬绑定终局放行、一个任务只能发一次,不能拿来做中间验收点,
* 所以这里用 `decide` 决策卡实现(S4_plan_approval),真正的终局验收放行是
* 「验收工单2」(T_release,台账更新确认)。
*/
import type { ActivityManifest, DmnModel } from '@contracts';
import { activityIdSchema } from '@contracts';
import { COVERAGE_SKILLS } from './skills.ts';
import { CMMN_XML, NARRATIVE, COVERAGE_PHASES, COVERAGE_STEPS } from './case-model.ts';
import { COVERAGE_SCOPE, COVERAGE_PLAN, COVERAGE_RECEIPT, COVERAGE_LEDGER_UPDATE } from './views/report.ts';
const D = 'crm_coverage';
const ACTIVITY_ID = activityIdSchema.parse('crm-relationship-coverage');
/**
* 没有判定表 —— 覆盖度与排序是**连续加权算术**(BT 层级权重 × 覆盖率、
* 影响力权重 × 亲密度缺口),不是离散分支,不适合 DMN 命中表,交给两个纯工具算
* (见 `apps/api/src/app/tools/first-party.ts` 的
* `crm-relationship-coverage.recompute-score` / `.rank-key-persons`)。
* `dmn` 在清单里是必填字段,空表即可 —— 静态审不对 DMN 表数量做任何要求。
*/
const EMPTY_DMN: DmnModel = { drd: [], tables: [] };
export const manifest: ActivityManifest = {
identity: {
activityId: ACTIVITY_ID,
name: 'E04 · 关系覆盖与关键人经营',
packageVersion: '2.1.9',
category: '客户关系经营',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-crm-relationship-coverage',
},
/* ── 契约七件 ────────────────────────────────────────────────── */
contract: {
activityId: ACTIVITY_ID,
version: '1.0.0',
input: {
/*
* 不是来件解析(没有唯一「这封信」),是触发参数。§六 E04 业务说明与
* `Demo-E04-I01-Activity台账关联与设计.html`(L-02/L-08 完整字段表)两份文档核对
* 后按用户明确要求做成**两级下拉 + 一个数组**:
* ① `trigger` 必填 —— 月度例行 / 事件驱动,用户明确要求这一级必须是显式的
* 下拉框(不接受"从数组是否点名反推"这种省略,那样选项与数组状态一旦不一致
* 就是一处会撒谎的地方);
* ② `eventType` 选填,只在 `trigger:'event'` 时在测试台表单里现身(靠
* `showWhen` 这个测试台通用能力),值就是业务说明里那四种事件;
* ③ `customers` 数组,元素两种形状二选一:字符串 = 已有客户的 `customer_code`
* (业务说明里这就是 L-02 的 `customer_id`,同一个概念,本仓库沿用既有列名
* 不改叫法);对象 = **仅 `eventType:'new_customer'` 时使用**,这一刻这家
* 客户还没有编码(新客户建档要等人在录入卡上现场给),数组元素就相当于
* "新建一条 L-02 客户主台账记录所包含的字段"。录入卡会把这些字段落进
* `customer.profile`/`customer.contact` —— 那几列(name/industry/region/
* title/email)本版起**都在下方 declarations.ledger 里显式声明**
* (授权面是台账声明;从前只在说明书里出现、声明里没有,是一处会撒谎的注释)。
* `items` 不声明单一形状 —— 字符串与对象二选一,声明成 object 会让测试台的
* 类型提示(object[])与首选用法(字符串数组)相悖。
*/
data: {
type: 'object',
required: ['trigger'],
properties: {
trigger: {
type: 'string',
enum: ['monthly', 'event'],
enumNames: ['月度例行(常规覆盖轮次)', '事件驱动'],
title: '触发方式',
},
eventType: {
type: 'string',
enum: ['new_customer', 'key_person_change', 'major_deal', 't04_project'],
enumNames: ['新客户建立合作', '关键人离职/调岗', '重大商机启动', '攻坚项目(T04)立项'],
title: '事件类型(仅事件驱动时选)',
showWhen: { field: 'trigger', equals: 'event' },
},
customers: {
type: 'array',
title:
'本轮涉及的客户 —— 已有客户填客户编码字符串;若事件类型是"新客户建立合作",' +
'改填新客户档案对象(公司名等 L-02 字段,不含客户编码——那一格由人在录入卡上现场给)。留空 = 月度例行,AI按台账规则从整个客户库筛选候选',
examples: [
['CU-0142'],
[{ name: '青岛新星电子有限公司', custType: '模组厂', industry: '车规电子', region: '山东', contact: { name: '王芳', title: '采购经理', email: 'wang.fang@example.com' } }],
],
},
note: {
type: 'string',
title: '本轮说明(选填,背景信息 —— 帮AI判断覆盖深度与经营侧重)',
examples: ['客户 CU-0142 启动新项目,商机金额较大,AM Lead 要求本轮优先覆盖其决策链'],
},
},
},
messageAllowed: true,
messageHint: 'AM Lead 可在此说明本轮覆盖的特殊要求、优先客户、或需要重点关注的关键人变动',
},
output: {
/*
* 单一必发口,无分支:E04 全程线性(①→②③④→⑤⑥→⑦),没有像 A1 那样
* 「有没有 C 类」这种要按序求值的岔路。E05/E06 在本仓库尚未实现,暂无消费者 ——
* 按「契约先立、实现后补」的纪律先声明,不因为没有下游就不发。
*/
mandatory: [
{
name: 'coverage-map',
description:
'本轮更新后的关系覆盖地图 —— 供未来 E05互动与拜访 / E06账户计划读取;当前代码库尚未实现下游活动,此口暂无消费者,仅按"载荷=已落地事实"的契约纪律预先声明',
payload: {
type: 'object',
required: ['roundRef', 'coveredCustomerTotal', 'keyPersonCoveredTotal'],
properties: {
roundRef: { type: 'string', title: '覆盖轮次编号' },
coveredCustomerTotal: { type: 'integer', title: '本轮覆盖客户数' },
keyPersonCoveredTotal: { type: 'integer', title: '本轮覆盖关键人数' },
},
},
},
],
selective: [],
},
/*
* 四份必交付物,逐一对应四张工单产出的成果。全部 `editableAtRelease: []`——
* 业务说明明文"验收工单只读展示,不可修改"(与 A1 放行时允许改 type_tag 是刻意对比,
* 不是遗漏:E04 的验收工单从设计上就没有"就地修订"这一环)。
*/
requiredDeliverables: [
{
key: 'coverage-scope',
label: '覆盖范围客户清单',
ledger: `${D}.candidate`,
ledgers: [`${D}.candidate`, `${D}.verdict`],
editableAtRelease: [],
},
{ key: 'coverage-plan', label: '关键人经营计划', ledger: `${D}.plan`, editableAtRelease: [] },
{ key: 'coverage-receipt', label: '执行回执', ledger: `${D}.receipt`, editableAtRelease: [] },
{
key: 'ledger-update',
label: '台账更新明细',
ledger: `${D}.ledger_update`,
ledgers: [`${D}.ledger_update`, `${D}.verdict`],
editableAtRelease: [],
},
],
/*
* 验收判据 —— **机检(L1/L2)只读基座数得出来的数**:`_rows`/`_missing` 基座现数,
* 与执行卡的提交闸同一个数同一套算法。L4 三条是纯裁量(有没有依据、数值合不合理、
* 有没有张冠李戴)——机器数不出「这条更新站不站得住」,那是人对结果负责的地方。
*/
acceptanceCriteria: [
/*
* 两条 L1 的「人过了没有」一半仍靠记账旗(scope_confirmed_total / plan_approved_total,
* 只有人回卡之后模型才记 —— 与 A1 的 confirmed_total 同款,规范 §5.3-4 背书的人环凭据);
* 但**每条都必须再钉一半基座数**:旗是模型写的,单独成谓词就是「机检只读基座数」
* 第 5 类违反的伪装形 —— 抢跑写旗那单(2026-08-22 活单熔断)正是踩着这半边过的门。
*/
{
key: 'scope-confirmed',
label: '覆盖范围已经 AM Lead 核对确认(执行工单1已交回,清单有行且必填齐)',
level: 'L1',
predicate: `scope_confirmed_total > 0 and ${D}_candidate_rows > 0 and ${D}_candidate_missing = 0`,
},
{
key: 'plan-approved',
label: '关系经营计划已经 AM Lead 审批通过(验收工单1已过,不处在驳回待改状态)',
level: 'L1',
predicate: `plan_approved_total > 0 and ${D}_plan_rows > 0 and ${D}_plan_missing = 0`,
},
{
key: 'plan-complete',
label: '经营计划逐条完整:关键人、建议动作、优先级、理由、时间点都有',
level: 'L2',
predicate: `${D}_plan_rows > 0 and ${D}_plan_missing = 0`,
},
{
/*
* **覆盖闭合**(2026-08-24 柴浩实测补):人核对后的候选清单是后续每一步的输入契约 ——
* 他加的那家客户,计划里必须有它。两边都是基座数:计划里有几家不同客户
* (`distinct`,声明在 plan.customer_code 上)= 候选清单几行(增删后现数)。
* S3 的收步判据与 S4 的入场哨兵同一句 —— 这里是第二道墙,防的是绕过收步的路。
*/
key: 'plan-covers-scope',
label: '每家确认纳入的候选客户都进了经营计划(含人核对时新增的,一家不落)',
level: 'L2',
predicate: `${D}_plan_distinct_customer_code = ${D}_candidate_rows`,
},
{
key: 'receipt-matches-plan',
label: '每条计划都收到执行回执,且完成状态、结果摘要、关键人变动均已填写',
level: 'L2',
predicate: `${D}_receipt_rows > 0 and ${D}_receipt_rows = ${D}_plan_rows and ${D}_receipt_missing = 0`,
},
{
key: 'ledger-update-complete',
label: '台账更新明细逐条完整:每条都写明台账、字段、更新前、更新后、依据',
level: 'L2',
predicate: `${D}_ledger_update_rows > 0 and ${D}_ledger_update_missing = 0`,
},
{
/* 2026-08-24:重做后过期的旧明细不许留 —— 同一处字段只能有一条更新 */
key: 'no-duplicate-update',
label: '同一处字段只留一条更新(重做后过期的旧记录已撤掉)',
level: 'L2',
predicate: `${D}_ledger_update_distinct_update_key = ${D}_ledger_update_rows`,
},
{
key: 'no-missed-update',
label: "每条「已完成」的执行回执至少有一条对应的台账更新(没有漏更)",
level: 'L2',
predicate: `${D}_ledger_update_rows >= ${D}_receipt_completion_status_done`,
},
{
key: 'update-has-evidence',
label: '每条台账更新都能在执行回执里找到对应事实依据,不是凭空推算',
level: 'L4',
},
{ key: 'values-reasonable', label: '更新后的数值(覆盖度、亲密度等)变化合理,没有异常跳变', level: 'L4' },
{ key: 'no-wrong-update', label: '更新内容准确对应回执事实,没有张冠李戴或误更别的客户/联系人', level: 'L4' },
/* L5 是责任关:那张卡本身就是这道关,卡面上不摆它(基座统一滤) */
{ key: 'owner-release', label: '台账更新入库放行:签下去即正式生效并回写主数据', level: 'L5' },
],
revisableAtRelease: [],
prohibitions: [
{ key: 'no-guess-contact', statement: '决策链节点对应哪位联系人不确定时不得臆断 —— 匹配不上就留空,那就是缺口' },
{ key: 'no-skip-evidence', statement: '台账更新明细每条都要有回执依据,不得凭空推算写入主数据' },
{ key: 'no-external-write', statement: '放行前不得触达外部系统 —— 触达世界的动作集中在放行那一刻' },
/* 2026-08-24:模型给缺口节点自造过顺延引用码 + 占位人名 —— 计划里「有人」必须真的有人 */
{ key: 'no-phantom-contact', statement: '计划与回执只许引用 customer.contact 已有的联系人编码与真人姓名;不得自造编码或以占位称呼顶格 —— 查无联系人先以数据录入卡收人' },
],
authorization: {
discretion: [],
preauthorizedActions: [],
/*
* 基线四次(S2/S4通过/S5/T_release)+ 决策卡循环预留两轮(每轮驳回+重提交各算一次
* 人回复,两轮就是 +4)——**基座按 `used >= cap` 判耗尽**(实测栽过:6 次刚好等于
* 6 的上限,「裁决:pass」那次回复本身就是第 6 次,harnessCheck 在 land() 之前
* 就判耗尽,放行卡显示 9/9 达标、验收也点了通过,任务却终态 exhausted、草稿从没
* 合并进正式版)。留够余量,cap 必须严格大于「最后一次拍板」时的实际次数。
*/
humanCallBudget: 12,
},
resources: {
tools: [{ name: 'crm-relationship-coverage.recompute-score' }, { name: 'crm-relationship-coverage.rank-key-persons' }],
skills: COVERAGE_SKILLS.map((s) => s.id),
models: ['deepseek-v4-flash'],
roles: ['AM Lead'],
/* 月度关系维护不是 live-inbox 那种时间敏感的单;人环预算同上调宽到 12(见上注) */
budget: { steps: 80, tokens: 500_000, activeMinutes: 90, humanCalls: 12 },
},
},
/* ── 声明 ────────────────────────────────────────────────────── */
declarations: {
models: ['deepseek-v4-flash'],
tools: [{ name: 'crm-relationship-coverage.recompute-score' }, { name: 'crm-relationship-coverage.rank-key-persons' }],
ledger: [
/*
* ── 主数据追加列(kind:'master' 显式声明,纯 add-column,与 A1 现有声明并存)──
* 只声明本活动要用的新列;`customer_code` 只是回显/关联用途的重复声明,
* 平台已由 `customer.profile`/`customer.contact` 的全量结构持有主键。
*/
{
domain: 'customer',
object: 'profile',
kind: 'master',
label: '客户档案',
fields: [
{ name: 'customer_code', type: 'text', primaryKey: true, masked: false, label: '客户编码' },
/*
* 建档卡会写的那几列(name/industry/region)也要声明 —— **授权面是台账声明**:
* 说明书让录入卡落什么,声明里就得有什么(物理列平台基线本就有,声明是权责面)。
* 与 A1 的 name 声明并存(add-column 合并,同名同型不冲突)。
*/
/*
* name 必填(2026-08-24 柴浩实测:新客户档案落库连名字都是空的 —— 候选行里
* 明明写着,模型没抄)。`customer_profile_missing` 本就在 S6 收步与放行哨兵里,
* 标上它,「无名档案」从此进不了正式表;代价是碰过的客户行要把 name 照现值带上
* (剧本写死)。industry/region 标建议填:AI 从来件与背景说明推得出就填,
* 推不出留橘胶囊给人 —— 台账更新确认屏上现在改得了。
*/
{ name: 'name', type: 'text', primaryKey: false, masked: false, required: true, label: '客户名称' },
{ name: 'industry', type: 'text', primaryKey: false, masked: false, expect: true, label: '所属行业' },
{ name: 'region', type: 'text', primaryKey: false, masked: false, expect: true, label: '区域' },
/*
* L-02 文档明列的字段,权威维护者是尚未建成的 T02(客户建档评估) —— 平时
* E04 不写它;唯一的例外是"新客户建立合作"这一刻,E04 代替不存在的 T02
* 履行建档职责(与 A1 quote-intake 的 S1_customer 完全同构),此时由数据
* 录入卡收人现场给的值。不带 tally——存文档给的中文枚举字面值,不再造一套
* ASCII 编码来回译(这一列从不参与 data-class-of 徽标染色,没有 ASCII 约束)。
*/
{ name: 'cust_type', type: 'text', primaryKey: false, masked: false, expect: true, label: '客户类型' },
{ name: 'coverage_score', type: 'integer', primaryKey: false, masked: false, required: true, label: '关系覆盖度' },
/* 结构:{nodes:[{node, btLevel, required, contactCode, covered}]} —— 见 case-model.ts 的 guide 注 */
{ name: 'decision_chain', type: 'json', primaryKey: false, masked: false, required: true, label: '决策链地图' },
/* 结构:{entries:[{date, type, contactCode, note}]},append-only(每次整个数组重写) */
{ name: 'interaction_timeline', type: 'json', primaryKey: false, masked: false, expect: true, label: '互动时间线' },
],
},
{
domain: 'customer',
object: 'contact',
kind: 'master',
label: '客户联系人',
fields: [
{ name: 'customer_code', type: 'text', primaryKey: true, masked: false, label: '客户编码' },
/*
* `auto:true` **必须跟 A1 一致**(2026-08-20 修死):`autoKeys()`
* (`apps/api/src/app/routes/desk.ts`)按**当前任务自己所属活动**的声明判断
* 哪几列自增,不是按全仓合并后的物理表结构 —— E04 若只抄字段名不抄这个标记,
* E04 名下的任务发数据录入卡建新联系人时,`contact_code` 就悄悄不再自动生成
* (静默退化成留空,不报错)。
*/
{ name: 'contact_code', type: 'text', primaryKey: true, masked: false, auto: true, label: '联系人编码' },
/*
* 建档卡的联系人段会写的三列 —— 同上,声明即授权面;email 照平台口径标脱敏。
* name 必填(2026-08-24 柴浩实测:早前一轮模型替「计划要经营的岗位」凭空建了
* 两条无名联系人档,人名职务全空、却标着关键人/亲密度3 —— 那不是联系人,是把
* 计划里的节点冒充成了人)。`customer_contact_missing` 本就在收步与放行哨兵里,
* 标上它,无名建档从此进不了正式表;代价是碰过的联系人行把 name 照现值带上。
*/
{ name: 'name', type: 'text', primaryKey: false, masked: false, required: true, label: '姓名' },
{ name: 'title', type: 'text', primaryKey: false, masked: false, label: '职务' },
{ name: 'email', type: 'text', primaryKey: false, masked: true, label: '邮箱' },
{ name: 'role', type: 'text', primaryKey: false, masked: false, expect: true, label: '角色' },
{ name: 'decision_node', type: 'text', primaryKey: false, masked: false, expect: true, label: '决策链节点' },
{
name: 'influence',
type: 'text',
primaryKey: false,
masked: false,
required: true,
tally: ['high', 'medium', 'low'],
label: '影响力评级',
},
{ name: 'relationship', type: 'integer', primaryKey: false, masked: false, required: true, label: '关系亲密度(1-5)' },
{ name: 'comm_pref', type: 'text', primaryKey: false, masked: false, expect: true, label: '沟通偏好' },
{ name: 'key_person', type: 'boolean', primaryKey: false, masked: false, required: true, label: '关键人标记' },
/* 我方追加的批注(入职/离职/调岗留痕),不标 expect —— 标了会让 _gaps 永远大于零 */
{ name: 'change_log', type: 'text', primaryKey: false, masked: false, label: '变动记录' },
],
},
/* ── E04 自有工作台账(kind:'record',caseKey:'round_ref') ── */
{
domain: D,
object: 'candidate',
kind: 'record',
caseKey: 'round_ref',
label: '候选覆盖客户清单',
fields: [
{ name: 'round_ref', type: 'text', primaryKey: true, masked: false, label: '覆盖轮次编号' },
{ name: 'customer_code', type: 'text', primaryKey: true, masked: false, required: true, label: '客户编码' },
{ name: 'customer_name', type: 'text', primaryKey: false, masked: false, required: true, label: '客户名称' },
/* 本轮工作快照 —— 有 customer.profile.cust_type 就抄它,没有才按台账信号猜(见 guide) */
{ name: 'cust_type', type: 'text', primaryKey: false, masked: false, expect: true, label: '客户类型' },
{ name: 'bt_level', type: 'text', primaryKey: false, masked: false, expect: true, label: 'BT 层级' },
{ name: 'status', type: 'text', primaryKey: false, masked: false, tally: ['pending', 'covered', 'excluded'], label: '状态' },
{ name: 'basis', type: 'text', primaryKey: false, masked: false, expect: true, label: '纳入依据' },
{ name: 'depth', type: 'text', primaryKey: false, masked: false, expect: true, label: '覆盖深度' },
{ name: 'origin', type: 'text', primaryKey: false, masked: false, label: '来源(ai/human-added)' },
],
},
{
domain: D,
object: 'plan',
kind: 'record',
caseKey: 'round_ref',
label: '关键人经营计划',
fields: [
{ name: 'round_ref', type: 'text', primaryKey: true, masked: false, label: '覆盖轮次编号' },
/*
* `refersTo` —— 计划只许指向真实存在的联系人(2026-08-24,措辞治不了的结构治):
* 模型三次给缺口节点自造顺延引用码 + 占位人名,反例描述/说明书纪律/禁令
* 全拦不住;现在基座在写入那一刻查 customer.contact(正式 + 本单草稿),
* 查无此码当场拒并指路(先发数据录入卡收人)。receipt 同款。
*/
{ name: 'contact_code', type: 'text', primaryKey: true, masked: false, refersTo: 'customer.contact.contact_code', label: '联系人编码' },
/*
* `distinct: true` —— 基座数「计划里有几家不同客户」(`crm_coverage_plan_distinct_customer_code`)。
* 它是覆盖闭合判据的落点(2026-08-24 柴浩实测:人在覆盖范围确认卡上加了一家客户,
* 行落进了台账,而计划/回执/收口把它从头无视到尾 —— 收步与放行只数「计划有没有行」,
* 「盖没盖住每一家候选」没有任何一个基座数说得出来)。计划行数 ≠ 客户数
* (一家客户几位关键人就几行),所以必须另数这一个。
*/
{ name: 'customer_code', type: 'text', primaryKey: false, masked: false, required: true, distinct: true, label: '客户编码' },
{ name: 'customer_name', type: 'text', primaryKey: false, masked: false, expect: true, label: '客户名称' },
{ name: 'contact_name', type: 'text', primaryKey: false, masked: false, expect: true, label: '联系人姓名' },
{ name: 'action', type: 'text', primaryKey: false, masked: false, required: true, label: '建议动作' },
{
name: 'priority',
type: 'text',
primaryKey: false,
masked: false,
required: true,
tally: ['high', 'medium', 'low'],
label: '优先级',
},
{ name: 'rationale', type: 'text', primaryKey: false, masked: false, required: true, label: '理由' },
{ name: 'influence', type: 'text', primaryKey: false, masked: false, required: true, label: '影响力(写计划那一刻的快照)' },
{ name: 'relationship_gap', type: 'integer', primaryKey: false, masked: false, required: true, label: '亲密度缺口' },
{ name: 'rank_score', type: 'number', primaryKey: false, masked: false, required: true, label: '排序分(模版不排序,须已按此列降序写入)' },
{ name: 'target_date', type: 'date', primaryKey: false, masked: false, required: true, label: '建议动作时间点' },
/*
* 没有 status 列(2.0.0 撤):tally 里的 approved 从没人写过 —— 「计划过没过审」
* 由 plan_approved_total 一个数说了算,同一件事实两处各存一份就会分叉。
*/
],
},
{
/*
* 与 `plan` 分表:回执列若挤进 `plan`,`required` 是对象级全局声明 ——
* S3/S4 阶段的 `_missing` 会把还没执行的回执列也算成「缺」,doneWhen/L2 判据
* 永远打不开。S3 那一步顺手把 action/target_date 回填进来,供 S5 工单只读展示
* (单张表内混排只读+可编辑列,复用工作区渲染器 mine:域.对象 单地址绑定的既有能力)。
*/
domain: D,
object: 'receipt',
kind: 'record',
caseKey: 'round_ref',
label: '经营计划执行回执',
fields: [
{ name: 'round_ref', type: 'text', primaryKey: true, masked: false, label: '覆盖轮次编号' },
{ name: 'contact_code', type: 'text', primaryKey: true, masked: false, refersTo: 'customer.contact.contact_code', label: '联系人编码(与计划一一对应)' },
{ name: 'customer_code', type: 'text', primaryKey: false, masked: false, expect: true, label: '客户编码' },
/* 目标件的「对象」列长「AvtoKom · 产品 VP」—— 客户名与人名都要在,S3 从计划抄 */
{ name: 'customer_name', type: 'text', primaryKey: false, masked: false, label: '客户名称(承继自计划,只读展示)' },
{ name: 'contact_name', type: 'text', primaryKey: false, masked: false, expect: true, label: '联系人姓名' },
{ name: 'action', type: 'text', primaryKey: false, masked: false, label: '计划动作(承继自计划,只读展示)' },
{ name: 'target_date', type: 'date', primaryKey: false, masked: false, label: '建议时间点(承继自计划,只读展示)' },
{
name: 'completion_status',
type: 'text',
primaryKey: false,
masked: false,
required: true,
tally: ['done', 'partial', 'skipped'],
label: '完成状态',
},
{ name: 'result_summary', type: 'text', primaryKey: false, masked: false, required: true, label: '结果摘要' },
{ name: 'customer_feedback', type: 'text', primaryKey: false, masked: false, expect: true, label: '客户反馈' },
{
name: 'key_person_change',
type: 'text',
primaryKey: false,
masked: false,
required: true,
tally: ['changed', 'unchanged'],
label: '关键人变动',
},
],
},
{
domain: D,
object: 'ledger_update',
kind: 'record',
caseKey: 'round_ref',
label: '台账更新明细',
fields: [
{ name: 'round_ref', type: 'text', primaryKey: true, masked: false, label: '覆盖轮次编号' },
{ name: 'update_seq', type: 'integer', primaryKey: true, masked: false, label: '本轮更新序号' },
{ name: 'ledger_label', type: 'text', primaryKey: false, masked: false, required: true, label: '台账(人读名)' },
{ name: 'ledger_address', type: 'text', primaryKey: false, masked: false, required: true, label: '台账地址(机器地址)' },
{ name: 'customer_code', type: 'text', primaryKey: false, masked: false, required: true, label: '客户编码' },
{ name: 'contact_code', type: 'text', primaryKey: false, masked: false, expect: true, label: '联系人编码(空=客户级字段)' },
{ name: 'field_label', type: 'text', primaryKey: false, masked: false, required: true, label: '字段(人读列名)' },
{ name: 'before_value', type: 'text', primaryKey: false, masked: false, required: true, label: '更新前(无值写"(无)")' },
{ name: 'after_value', type: 'text', primaryKey: false, masked: false, required: true, label: '更新后' },
{ name: 'evidence_ref', type: 'text', primaryKey: false, masked: false, required: true, label: '依据(用人话引用回执事实)' },
/*
* 更新位置键(2026-08-24 用户:「重做后老的变更记录应该删除」):主键是序号,
* 同一处字段(同一张表·同一客户·同一联系人·同一字段)重做时若新开一行,旧行就留在
* 明细里骗人 —— SQ-0031 的覆盖度一度同时挂着 50→50 与 50→100 两条。
* 这一列由模型按「台账地址|客户编码|联系人编码|字段」拼,`distinct` 让基座数
* 「有几处不同位置」;判据钉 distinct = rows —— 同一处只留一条。
*/
{ name: 'update_key', type: 'text', primaryKey: false, masked: false, required: true, distinct: true, label: '更新位置键(台账地址|客户|联系人|字段)' },
],
},
{
domain: D,
object: 'verdict',
kind: 'record',
caseKey: 'round_ref',
label: '本轮覆盖结论',
fields: [
{ name: 'round_ref', type: 'text', primaryKey: true, masked: false, label: '覆盖轮次编号' },
/*
* 列名 `trigger_label` 不叫 `trigger`(2.0.0 改):任务视图把草稿列摊平到顶层、
* 与起单入参**同名同位** —— 从前这一列就叫 trigger,S1 写下中文标签之前,
* 视图上 `data-slot="trigger"` 摆的是入参英文码(monthly/event),
* 「报告上不摆系统内英文名」在首帧上破了功。改名后绑定读 trigger_label,
* 写下之前那一格真空着(data-blank),空着是诚实的。
*/
{ name: 'trigger_label', type: 'text', primaryKey: false, masked: false, label: '触发方式(人读标签,非入参英文码)' },
{ name: 'event_type', type: 'text', primaryKey: false, masked: false, label: '事件类型(入参原样抄的人读标签,月度例行留空)' },
{ name: 'note', type: 'text', primaryKey: false, masked: false, label: '本轮说明' },
{ name: 'scope_confirmed_total', type: 'integer', primaryKey: false, masked: false, label: '覆盖范围已确认' },
{ name: 'plan_approved_total', type: 'integer', primaryKey: false, masked: false, label: '经营计划已审批通过' },
/*
* 没有 receipt_confirmed_total(2.0.2 退役,两轮活单熔断实测):模型自写的
* 「人环走完」旗让它抢跑 —— S5 的门改读基座数(receipt _rows/_missing,
* 回执四列全是人在件上填出来的),被叫的那一方无账可记。
*/
{ name: 'ledger_update_confirmed_total', type: 'integer', primaryKey: false, masked: false, label: '台账更新已收口' },
{ name: 'covered_customer_total', type: 'integer', primaryKey: false, masked: false, label: '本轮覆盖客户数' },
{ name: 'key_person_covered_total', type: 'integer', primaryKey: false, masked: false, label: '本轮覆盖关键人数' },
{ name: 'summary', type: 'text', primaryKey: false, masked: false, label: '本轮覆盖结论' },
],
},
],
listeners: [],
harness: {
memoryWindow: 24,
/* 月度关系维护不是 live-inbox 那种紧迫的单,给够时间让人执行拜访这类线下动作 */
stallAfter: 'PT4H',
nudge: [{ after: 'PT2H', action: 'nudge' }],
retry: { times: 2, backoff: 'PT30S' },
},
},
/* ── 资产初始版 ──────────────────────────────────────────────── */
assets: {
businessModel: {
cmmnXml: CMMN_XML,
narrative: NARRATIVE,
dmn: EMPTY_DMN,
steps: COVERAGE_STEPS,
phases: COVERAGE_PHASES,
},
skills: COVERAGE_SKILLS,
/*
* 四个局面各一张:
* · `coverage-scope` —— S1/S2 摆,候选客户清单,可编辑;
* · `coverage-plan` —— S3/S4 摆,经营计划,只读(业务说明明文"人只做审批,不能修改结论");
* · `coverage-receipt` —— S5 摆,执行回执,混排只读(计划动作/时间点)+ 可编辑(回执四列);
* · `coverage-ledger-update` —— S6/T_release 摆,台账更新明细,只读,收口后即归档件。
*/
viewTemplates: [
{ name: 'coverage-scope', html: COVERAGE_SCOPE },
{ name: 'coverage-plan', html: COVERAGE_PLAN },
{ name: 'coverage-receipt', html: COVERAGE_RECEIPT },
{ name: 'coverage-ledger-update', html: COVERAGE_LEDGER_UPDATE },
],
cardSchemas: {},
distillHooks: [
{
on: 'terminal',
prompt: '归纳本轮关系覆盖:候选客户识别依据、计划审批是否被驳回过(为什么)、执行回执里客户反馈最集中的诉求、覆盖度变化幅度。',
},
],
},
/*
* ── 样例集 —— 五条形状真不同(常备案例即验收案例)─────────────
*
* **第一条必须是满载的那条**(2026-08-23 用户:「询价受理起单时左边都有预填好的入参,
* E04 也要」):测试台落到一个活动上就自动铺 `samples[0]`(activity-run.tsx 的
* useEffect),所以第一条就是人打开这一屏看见的那份入参。从前第一条是月度例行、
* 只有 `{trigger:'monthly'}` 一个键 —— 表单上除了那个下拉全是空的,看着像没预填。
* 现在第一条是**点名客户的事件驱动**:四个字段全有值,起单钮按下去就能跑。
*
* **样例名一律 ≤14 字**:测试台的样例钮超过 14 字就退成「样例 N」(activity-run.tsx),
* 那等于没名字 —— 人得逐个点开才知道哪条是哪条。
*/
samples: [
{
name: '重大商机 · 点名客户',
input: {
trigger: 'event',
eventType: 'major_deal',
customers: ['CU-0142'],
note: '客户 CU-0142(Itelma)启动新项目 MD14.2,商机金额较大,AM Lead 要求本轮优先覆盖其决策链',
},
expect: { ports: ['coverage-map'] },
},
{
/* 月度例行的本分就是不点名(客户格留空 = 由 AI 按台账规则筛)—— 但背景说明要给,不然表单半空 */
name: '月度例行 · 全库筛',
input: {
trigger: 'monthly',
note: '八月例行覆盖轮次:优先看覆盖度陈旧、决策链有空白节点的重点客户',
},
expect: { ports: ['coverage-map'] },
},
{
name: '关键人离职调岗',
input: {
trigger: 'event',
eventType: 'key_person_change',
customers: ['CU-0087'],
note: '客户 CU-0087 采购负责人 Chen Gang 已离职,需要重新识别关键人并更新决策链',
},
expect: { ports: ['coverage-map'] },
},
{
name: '新客户建立合作',
input: {
trigger: 'event',
eventType: 'new_customer',
customers: [
{
name: '青岛新星电子有限公司',
custType: '模组厂',
industry: '车规电子',
region: '山东',
profileTags: '价格敏感 · 首次合作',
contact: { name: '王芳', title: '采购经理', email: 'wang.fang@example.com' },
},
],
note: 'AM Lead 上周展会新结识的客户,想尽快建档并启动关系覆盖',
},
expect: { ports: ['coverage-map'] },
},
{
name: '攻坚项目 T04 立项',
input: {
trigger: 'event',
eventType: 't04_project',
customers: ['CU-0142'],
note: '客户 CU-0142(Itelma)的 MD14.2 项目已升级为 T04 攻坚项目,需要加强决策链覆盖、确保关键节点无缺口',
},
expect: { ports: ['coverage-map'] },
},
],
entry: { file: 'index.js', checksum: 'dev-entry-crm-relationship-coverage' },
};
@@ -0,0 +1,80 @@
/**
* E04 技能 —— 关系覆盖经营的方法经验。载方法,不载权力。
*
* 与剧本的分工:剧本写「这一步做什么、写哪几列」,技能写「为什么这么判、
* 拿不准时按哪条走」。剧本改了口径,技能里同一句要跟着改(规范 §5.9)。
*/
import { skillIdSchema } from '@contracts';
import type { Skill } from '@contracts';
export const COVERAGE_SKILLS: Skill[] = [
{
id: skillIdSchema.parse('crm-coverage.gap-analysis'),
label: '读覆盖现状、找决策链空白节点',
applicability: '识别候选客户之后、拟经营计划之前,要判断这一轮该覆盖谁。',
method: [
'决策链地图(`customer.profile.decision_chain`)按 BT 层级列节点:',
'· BT-01 采购/供应链层、BT-02 技术层(研发/质量)、BT-03 高层(产品总监/VP)。',
'一个节点算「已覆盖」,要同时满足三条:该节点上有联系人对应、那个人标了',
'`key_person=true`、亲密度 `relationship >= 3`。三条少一条都算空白 ——',
'认得这个人不等于覆盖了这个人,亲密度不够的关键人一样是缺口。',
'',
'调 `crm-relationship-coverage.recompute-score` 拿 `gapNodes`,别自己数:',
'节点覆盖是逐条核对的活,人算容易漏,数得出来的事一律让工具数。',
'**批量调**(入参 `customers` 数组,一次多家客户):工具调用后同拍其余动作作废,',
'一家客户调一次就要多截断一拍,而结果是同一份数据算出来的,批量传入不影响精度。',
'',
'`gapNodes` 全员缺口(一个节点都没覆盖到)是正常结果,不是异常 —— 不要回头重调工具',
'确认;缺口本身就是下一步经营计划要处理的东西。',
'',
'缺口分析只认台账现状,不猜将来:客户明说要覆盖某个人,先看 `customer.contact`',
'里有没有这个人的记录,没有的话缺口分析给不出他,只能在计划里写「需先建档」。',
].join('\n'),
crystallized: [],
knowledgeRefs: ['决策链地图', 'BT 层级'],
version: '1.0.0',
},
{
id: skillIdSchema.parse('crm-coverage.plan-drafting'),
label: '按影响力 × 亲密度缺口排经营计划',
applicability: '缺口找齐之后,要给每个关键人排优先级、定动作时。',
method: [
'排序不是拍脑袋,是尺:调 `crm-relationship-coverage.rank-key-persons` 批量算',
'(一次多条,不要一条关键人调一次 —— 拿回来的排序结果本就是同一份数据算出来的)。',
'`influence` 权重高的、`relationship` 缺口大的排前面 —— 一个天天见面的低影响力的人,',
'排序上必然让位给一个见不到面的高影响力的人,这是尺给的结论,不是你临场判断的。',
'',
'建议动作要**具体到能执行**:「拜访」不够,「下月中拜访技术总监,带 XX 认证报告」',
'才算数;每条动作配一个 `target_date`,没有时间点的建议是空话,验收判据③会核这个。',
'',
'理由(`rationale`)要说清「为什么是这个人、这个动作」——',
'引影响力评级与亲密度差值,不要只写「重要客户,建议拜访」这类空话,',
'人审计划时判的就是这句话站不站得住。',
].join('\n'),
crystallized: [],
knowledgeRefs: ['影响力 × 亲密度缺口排序'],
version: '1.0.0',
},
{
id: skillIdSchema.parse('crm-coverage.ledger-update'),
label: '收口写台账、每条更新配依据',
applicability: '执行回执收齐之后,要把结果写回 L-02/L-08 主数据时。',
method: [
'每一条更新都要能在回执里找到对应的事实 —— `evidence_ref` 不是可选的装饰,',
'是验收判据①的唯一抓手:写「Itelma·Ekaterina 的回执:已完成拜访,亲密度由3提升至4」',
'这种能溯源的**人话**(客户名·人名,不写编码、不带机器键前缀),',
'不写「综合判断」这类查无实据的话。',
'',
'覆盖度 `coverage_score` 收口时按执行后的最新数据重算一次(调',
'`crm-relationship-coverage.recompute-score`),不要凭回执的完成条数估一个大概值 ——',
'分数要能重算出同一个数,重算不出来的分数没人信得过。',
'',
'「无变动」也要写清楚:回执里 `key_person_change` 是 `unchanged`(人在卡上选的',
'「无变动」)的联系人,台账更新明细里不必为它单开一行,但不许因为没变动就漏掉',
'这条覆盖度的重算依据。',
].join('\n'),
crystallized: [],
knowledgeRefs: ['台账更新依据'],
version: '1.0.0',
},
];
@@ -0,0 +1,162 @@
/**
* E04 关系覆盖与关键人经营 · 视觉底(**E04 自己的 PRD 目标件,不是借来的家族版面**)。
*
* 上一版这里写着「E04 没有自己的 PRD 目标件」——那是假的:
* `docs/SS01-客户与市场/e04-html/` 下的四份 HTML(执行工单 1/2、验收工单 1/2)正是
* E04 自己的目标件,颜色变量、卡头渐变、判据框、原生 select/textarea 全部逐字量自那四份文件,
* 不是 A1/A2 那一支「浮起卡片 + 渐变徽章 + 头像签名」的家族版面。
*
* 两处**有意**偏离目标件,原因写在这里(别处再改记得回头看):
* 1. 目标件的必填只在表头打一个「*」;本仓有个更早、已验证过的平台级三档占位系统
* (必填红 / 建议填橘 / 选填灰,2026-08-02 定,`docs/HTML报告生成规范.md` 收录),
* 比「只在表头提一句」更能在空格子上直接指路。两者都留着:表头照抄目标件的「*」,
* 空格子再叠一层三档胶囊——不冲突,目标件的例子本来就没有空格子可比对。
* 2. 候选清单「状态」列:目标件那一列是「正式/试用」(客户主数据的状态),
* 本活动的 `candidate.status` 是另一个真实字段(`pending/covered/excluded`,
* 候选行本轮的覆盖生命周期,`declarations.ledger` 里 tally 着),两者恰好同名不同义。
* 留的是本活动这个——它是真在用的数据,目标件那份是另一件事。
*
* 版面照目标件分两层:这份文件是**底**(配色变量、卡壳、头/尾、判据框、说明框、表格基础排版),
* `report.ts` 里的 `BIND_CSS` 是**面**(哪个值决定哪种颜色/文案——三档占位、缺项红带、
* 状态徽、编辑闸开合、行增删这些「数据驱动的开关」,判断在样式表,取值在模版,两处不许混)。
*/
export const PRD_CSS = `
.proto-root {
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
--brand:#3370ff; --brand-deep:#245bdb; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
--human:#a9711e; --human-soft:#f6ecd8; --human-line:#e6d3a8;
--ledger:#b23a2e; --ledger-soft:#f7e4e0; --ledger-line:#ecc5bd;
--ok:#278717; --ok-soft:#e8f8e4;
--danger:#d83931; --danger-soft:#feeae9;
--warn-accent:#a9711e; --warn-soft:#f6ecd8;
--mono: "SF Mono", Menlo, Consolas, monospace;
--t-fast: .16s;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
color: var(--ink);
font-size: 14px;
line-height: 1.7;
}
.proto-root, .proto-root * { box-sizing: border-box; }
/* ==================== 卡壳(嵌进工作区,不自带浮起——外层已经是一张卡)==================== */
.proto-root .jc-report { background: #fff; overflow: hidden; }
.proto-root .jc-rp-head { display: flex; align-items: center; gap: 14px; padding: 18px 22px; border-bottom: 2px solid var(--ink); }
.proto-root .jc-report.tone-human .jc-rp-head { background: linear-gradient(120deg, #fffaf1 0%, #fff 55%, #fbf6ea 100%); }
.proto-root .jc-report.tone-ledger .jc-rp-head { background: linear-gradient(120deg, #fdf3f1 0%, #fff 55%, #fbf2f0 100%); }
.proto-root .jc-rp-badge {
flex: none; width: 44px; height: 44px; border-radius: 10px;
display: grid; place-items: center; color: #fff; font-weight: 800; font-size: 13px;
}
.proto-root .jc-report.tone-human .jc-rp-badge { background: var(--human); box-shadow: 0 6px 16px rgba(169,113,30,.25); }
.proto-root .jc-report.tone-ledger .jc-rp-badge { background: var(--ledger); box-shadow: 0 6px 16px rgba(178,58,46,.25); }
.proto-root .jc-rp-tit { flex: 1; min-width: 0; }
.proto-root .jc-rp-tit em { font-style: normal; font-size: 11px; letter-spacing: .08em; color: var(--ink-4); font-weight: 700; }
.proto-root .jc-rp-tit h3 { margin: 2px 0 0; font-size: 19px; font-weight: 800; line-height: 1.3; }
.proto-root .jc-rp-sub { font-size: 12px; color: var(--ink-3); margin-top: 3px; }
.proto-root .jc-rp-side { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; flex: none; }
/* 目标件 side 第二行:步骤(静态文案),同目标件 .step 的字号字色 */
.proto-root .jc-rp-step { font-size: 12px; color: var(--ink-3); }
/* 关联任务号:全长 UUID 如实给,排版上截尾 —— 截断是排版,不是数据 */
.proto-root .jc-rp-taskid {
display: inline-block; max-width: 150px; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; vertical-align: bottom; font-family: var(--mono); font-size: 11px;
}
.proto-root .jc-rp-tag { font-size: 11px; font-weight: 800; padding: 2px 10px; border-radius: 999px; border: 1px solid; }
.proto-root .jc-report.tone-human .jc-rp-tag { color: var(--human); background: var(--human-soft); border-color: var(--human-line); }
.proto-root .jc-report.tone-ledger .jc-rp-tag { color: var(--ledger); background: var(--ledger-soft); border-color: var(--ledger-line); }
.proto-root .jc-rp-wait { font-size: 12px; color: var(--ink-3); }
.proto-root .jc-rp-wait.warn { color: var(--danger); font-weight: 700; }
.proto-root .jc-rp-bd { padding: 20px 22px; }
/* 【做什么】/【交回什么】这类分区小标——括号label + 破折号说明,同一行 */
.proto-root .jc-rp-part { font-size: 11px; font-weight: 800; letter-spacing: .12em; color: var(--ink-4); margin: 20px 0 8px; }
.proto-root .jc-rp-part:first-child { margin-top: 0; }
.proto-root .jc-rp-part b { color: var(--ink-3); }
/* AI 已完成 / 需要你 —— 说明框,左侧色条按 tone 走 */
.proto-root .jc-derive { border: 1px solid var(--line); border-left: 3px solid; border-radius: 8px; background: var(--soft); padding: 14px 16px; margin-bottom: 16px; }
.proto-root .jc-report.tone-human .jc-derive { border-left-color: var(--human); }
.proto-root .jc-report.tone-ledger .jc-derive { border-left-color: var(--ledger); }
.proto-root .jc-derive p { margin: 0; font-size: 13px; color: var(--ink-2); }
.proto-root .jc-derive p + p { margin-top: 6px; }
.proto-root .jc-derive b { color: var(--ink); }
.proto-root .jc-derive .done { color: var(--ok); font-weight: 700; }
.proto-root .jc-report.tone-human .jc-derive .need { color: var(--human); font-weight: 700; }
.proto-root .jc-report.tone-ledger .jc-derive .need { color: var(--ledger); font-weight: 700; }
/* 验收判据框——目标件里两份验收工单同一色(赭红),不随 tone 变 */
.proto-root .jc-criteria { border: 1px solid var(--ledger-line); border-radius: 10px; background: var(--ledger-soft); padding: 13px 16px; margin-bottom: 18px; }
.proto-root .jc-criteria-t { font-size: 11px; font-weight: 800; letter-spacing: .08em; color: var(--ledger); margin-bottom: 7px; }
.proto-root .jc-criteria ul { margin: 0; padding-left: 18px; font-size: 12.5px; color: var(--ink-2); }
.proto-root .jc-criteria li { margin: 4px 0; }
.proto-root .jc-criteria b { color: var(--ink); }
/* 表格基础排版(交互态的显隐开关在 BIND_CSS) */
/*
* **表宽超出就得能横着滑**(2026-08-23 用户:「表格已经显示不全了,不能左右滑动」)。
* 从前这里是 overflow: hidden —— 那是为了让圆角裁住表头,可它同时把超出的列
* **直接裁掉且滑不动**:列一多(或某列为了放下下拉框加宽)就看不全,人还没有任何办法。
* quote-* 各包一直是 overflow-x: auto,这两个新包抄版式时把这一条抄丢了。
* (注释里不写反引号 —— 整段 CSS 是模版字符串,一个反引号就把它截断,本轮踩第三次。)
* 竖向仍 hidden:件是整篇铺开跟着外层滚的,表格自己不该再套一层竖滚动条。
*/
.proto-root .jc-tbl-wrap { border: 1px solid var(--line-soft); border-radius: 10px; overflow-x: auto; overflow-y: hidden; margin-bottom: 4px; }
.proto-root .jc-tbl-in { width: 100%; border-collapse: collapse; font-size: 13px; }
.proto-root .jc-tbl-in th { text-align: left; background: var(--soft); font-size: 11px; letter-spacing: .04em; font-weight: 800; color: var(--ink-3); padding: 9px 11px; border-bottom: 1px solid var(--line); }
.proto-root .jc-tbl-in th.req::after { content: " *"; color: var(--danger); font-size: 11px; }
.proto-root .jc-tbl-in th.sug::after { content: " 建议"; color: var(--warn-accent); font-size: 10px; font-weight: 600; }
.proto-root .jc-tbl-in th.opt::after { content: " 选填"; color: var(--ink-4); font-size: 10px; font-weight: 600; }
.proto-root .jc-tbl-in td { padding: 9px 11px; border-bottom: 1px solid var(--line-soft); vertical-align: top; }
.proto-root .jc-tbl-in tr:last-child td { border-bottom: 0; }
.proto-root .jc-tbl-in td.jc-rn { width: 38px; white-space: nowrap; font-family: var(--mono); font-size: 11px; color: var(--ink-4); text-align: center; position: relative; }
.proto-root .jc-vcell { display: block; font-size: 12.5px; color: var(--ink); line-height: 1.5; overflow-wrap: anywhere; }
.proto-root .jc-vcell.mono { font-family: var(--mono); font-size: 11.5px; color: var(--ink-2); }
.proto-root .jc-vcell.cust { font-weight: 700; }
.proto-root .jc-vcell.dim { color: var(--ink-4); text-decoration: line-through; }
.proto-root .jc-diffnew { font-weight: 800; color: var(--ok); }
.proto-root .jc-cell, .proto-root select.jc-cell, .proto-root textarea.jc-cell {
width: 100%; min-width: 60px; border: 1px solid var(--line); border-radius: 6px;
padding: 6px 8px; font: inherit; font-size: 12.5px; color: var(--ink); background: #fff;
}
/* 多行文本照目标件:最小两行高,竖向可拉 */
.proto-root textarea.jc-cell { min-height: 40px; resize: vertical; }
.proto-root .jc-cell.mono { font-family: var(--mono); font-size: 11.5px; }
.proto-root .jc-cell:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-wash); outline: none; }
/* 格内次行小字(依据 / 编码)—— 主行之下,不占独立列 */
.proto-root .jc-sub { display: block; font-size: 11px; color: var(--ink-4); margin-top: 2px; line-height: 1.55; }
.proto-root .jc-sub i { font-style: normal; }
/* 取值位(i)全空的次行整行藏掉;直填文本的次行(如对象格的编码)不归这条管 */
.proto-root .jc-sub:has(i):not(:has(i:not(:empty))) { display: none; }
.proto-root .jc-rowdel {
width: 22px; height: 22px; margin: 0 auto; cursor: pointer;
border: 1px solid var(--line); border-radius: 6px; padding: 0;
font: inherit; font-size: 14px; 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-rowdel:hover { border-color: var(--danger); color: var(--danger); background: var(--danger-soft); }
.proto-root .jc-addrow em { font-style: normal; font-size: 11.5px; color: var(--ink-4); }
.proto-root .jc-addrow button {
cursor: pointer; border: 1px solid var(--brand-soft); border-radius: 7px; padding: 5px 12px;
font: inherit; font-size: 12px; font-weight: 700; color: var(--brand-deep); background: var(--brand-wash);
}
.proto-root .jc-addrow button:hover { background: var(--brand-soft); }
/* 本工单只承载内容,提交/审批请在对话区——目标件四份文件都有这一条 */
.proto-root .jc-flow { margin-top: 16px; padding: 9px 12px; border-radius: 8px; background: var(--soft); border: 1px dashed var(--line); font-size: 12px; color: var(--ink-3); }
.proto-root .jc-flow b { color: var(--ink); }
.proto-root .jc-rp-foot { padding: 13px 22px; border-top: 1px solid var(--line-soft); font-size: 11.5px; color: var(--ink-4); display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; }
.proto-root .jc-rp-foot span:last-child { font-family: var(--mono); }
`;
@@ -0,0 +1,665 @@
/**
* E04 关系覆盖与关键人经营的 HTML 报告模版 —— **活动自己持有的资产**,随包版本冻结。
*
* 四个局面各一张:
* · `coverage-scope` —— S1/S2 摆,候选覆盖客户清单,可编辑(增删改);
* · `coverage-plan` —— S3/S4 摆,关键人经营计划,**只读**(业务说明明文
* "人只做审批,不能修改结论"——通过/驳回的按钮在对话区的决策卡上,不在这份件里);
* · `coverage-receipt` —— S5 摆,执行回执,混排只读(计划动作/对象承继自计划)+
* 可编辑(完成状态/结果摘要/客户反馈/关键人变动四列);
* · `coverage-ledger-update` —— S6/T_release 摆,台账更新明细,**只读**,放行后即归档件。
*
* 版面语言逐字量自 E04 自己的目标件(`docs/SS01-客户与市场/e04-html/` 四份 HTML,
* `prd-style.ts` 头注写了两处有意偏离的地方)。
*
* 取值三纪律:顶层只取汇总列与单值列;逐条走 `mine:crm_coverage.<object>`;
* 列名逐字照 `declarations.ledger` 抄。**模版不算不判** —— 缺必填红带、状态徽、
* 排序位次全部由基座算好的数经 `data-class-of` 投影,判断留在样式表。
*/
import { PRD_CSS } from './prd-style.ts';
const D = 'crm_coverage';
/* ==================== 绑定期补的样式(数据驱动的开关,判断都在这一层)==================== */
const BIND_CSS = `
/* 空占位三级(全局口径):必填红 / 建议填橘 / 选填灰 —— 值真的空才轮到 */
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after,
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after,
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after {
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
}
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after { content: "必填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after { content: "建议填"; color: var(--warn-accent); background: var(--warn-soft); border-color: #f0cf9a; }
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after { content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line); }
/* 归档件 / 只读件里空格子就是「没有」,不再摆「你还能填」的胶囊 */
.proto-root .jc-tbl-in td.dimempty > .jc-vcell:empty::after { content: "—"; color: var(--ink-4); border: 0; background: none; padding: 0; }
/* 缺必填的整行浅红 + 行首红条 —— 由 :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; }
.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); }
/*
* 就地改:可改格子渲了只读与输入两份,默认锁着,表上加 on 才换孪生件。
*
* **只换真有孪生件的那一份**(2026-08-21 用户送来实测截图查出来的坑):从前认
* 「表上有 on 就把这张表里所有 .jc-vcell 全藏起来」,而 coverage-receipt 混排
* 只读列(计划动作/对象里的联系人姓名——承继自计划,声明明写"改不了")与可改列
* (完成状态等),只读列压根没有 .jc-cell 孪生件可换 —— 一开编辑,那格字直接消失,
* 要退出编辑才重新看得见。**只藏「后面紧跟着一个孪生件」的那一份**:
* 没有孪生件的 .jc-vcell 不属于这道开关,编辑态里也照旧显示。
*/
.proto-root .jc-cell { display: none; }
.proto-root .jc-tbl-in.on td > .jc-vcell:has(+ .jc-cell) { display: none; }
.proto-root .jc-tbl-in.on td > .jc-cell { display: block; }
/* 编辑态下必填空格子:红框 + 整行浅红(由 :placeholder-shown / :invalid 认) */
.proto-root .jc-tbl-in.on td.req > select.jc-cell:invalid,
.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),
.proto-root .jc-tbl-in.on tbody tr:has(td.req > select.jc-cell:invalid) { background: #fffbfb; }
/* 铺满工作区,卡壳不再自带外边框(外层容器已经是一张卡) */
.jc-stage { max-width: none; padding: 0 0 4px; }
/* 那道闸 —— 默认「已锁定 + 编辑」,开了变成「取消 / 保存」;只在可改屏现身 */
.proto-root .jc-editgate { display: none; }
html.editable .proto-root .jc-editgate {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin: 14px 0 10px; padding: 9px 12px; border-radius: 9px;
background: var(--soft); border: 1px solid var(--line-soft);
font-size: 12px; color: var(--ink-3);
}
.proto-root .jc-editgate-t { 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: 5px; cursor: pointer;
border: 1px solid var(--line); border-radius: 7px; padding: 6px 13px;
font: inherit; font-size: 12px; 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(--human); border-color: var(--human); }
.proto-root .jc-egbtn.save:hover { filter: brightness(1.06); color: #fff; }
.proto-root .jc-editgate .jc-egbtn.ghost, .proto-root .jc-editgate .jc-egbtn.save { display: none; }
.proto-root .jc-editgate.on { background: var(--human-soft); border-color: var(--human-line); color: #7c4a12; }
.proto-root .jc-editgate.on .jc-egbtn.open { display: none; }
.proto-root .jc-editgate.on .jc-egbtn.ghost, .proto-root .jc-editgate.on .jc-egbtn.save { display: inline-flex; }
.proto-root .jc-editgate.on .jc-editgate-t { visibility: hidden; }
/* 缺必填红带 —— 只在可改屏出,一格不缺自动收(v-0 那一档 display:none) */
.proto-root .jc-gapbar { display: none; }
html.editable .proto-root .jc-gapbar {
display: flex; align-items: flex-start; gap: 8px;
margin: 0 0 10px; padding: 9px 12px; border-radius: 9px;
background: #fff5f5; border: 1px solid #f3b6b3; color: var(--danger);
font-size: 12px; line-height: 1.65;
}
.proto-root .jc-gapbar b { font-weight: 800; }
.proto-root .jc-gapbar span { color: #9a3b36; }
html.editable .proto-root .jc-gapbar.v-0 { display: none; }
/* 那条「怎么改」的提示只在开了闸之后说 */
.proto-root .jc-editbar { display: none; }
.proto-root .jc-editgate.on + .jc-editbar {
display: flex; align-items: flex-start; gap: 8px;
margin: 0 0 10px; padding: 9px 12px; border-radius: 9px;
background: var(--brand-wash); border: 1px dashed var(--brand-soft); color: var(--ink-3);
font-size: 12px; line-height: 1.6;
}
.proto-root .jc-editbar b { color: var(--ink-2); }
/* 增删行 */
.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; }
.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);
}
/*
* 统一的枚举标签(候选状态 / 优先级 / 完成状态 / 关键人变动 共用同一套机制):
* 值是机器码,人读名与颜色一律由 data-class-of 投影出的 .v-xxx 决定,不写死在取值那一侧。
* 三组值域(pending/covered/excluded、high/medium/low、done/partial/skipped、是/无变动)各自的
* .v-* 类名不会互相碰撞,同一个 .jc-tag 基座够用,不必分三套徽章系统。
*/
.proto-root .jc-tag { font-weight: 800; }
.proto-root .jc-tag::after { content: "待定"; color: var(--ink-4); font-weight: 600; }
.proto-root .jc-tag.v-pending::after { content: "待覆盖"; }
.proto-root .jc-tag.v-pending { color: var(--warn-accent); }
.proto-root .jc-tag.v-covered::after { content: "已覆盖"; }
.proto-root .jc-tag.v-covered { color: var(--ok); }
.proto-root .jc-tag.v-excluded::after { content: "已排除"; }
.proto-root .jc-tag.v-excluded { color: var(--ink-3); }
.proto-root .jc-tag.v-high::after { content: "高"; }
.proto-root .jc-tag.v-high { color: var(--danger); font-family: var(--mono); }
.proto-root .jc-tag.v-medium::after { content: "中"; }
.proto-root .jc-tag.v-medium { color: var(--ink-2); font-family: var(--mono); }
.proto-root .jc-tag.v-low::after { content: "低"; }
.proto-root .jc-tag.v-low { color: var(--ink-4); font-family: var(--mono); }
.proto-root .jc-tag.v-done::after { content: "完成"; }
.proto-root .jc-tag.v-done { color: var(--ok); }
.proto-root .jc-tag.v-partial::after { content: "部分完成"; }
.proto-root .jc-tag.v-partial { color: var(--warn-accent); }
.proto-root .jc-tag.v-skipped::after { content: "未执行"; }
.proto-root .jc-tag.v-skipped { color: var(--ink-3); }
/*
* **值域不能用中文**(2026-08-21 用户送来实测截图查出来的坑):classToken()
* 只放行 ASCII 字母数字与横杠(见 view-render.ts 的注释——属性位插值唯一开放
* 的白名单,宁可没有颜色也不给一个错的钩子),中文字符整段被消毒成空字符串,
* data-class-of="key_person_change" 因此永远长不出 v-是/v-无变动 这两个
* class —— 徽章只会落到默认的「待定」,人怎么选、选了多少次都一样,像是保存没生效。
* 机器值改成 ASCII(changed/unchanged),中文只留在这里的 ::after 与
* option 文字里(人看到的字一个没少)。
*/
.proto-root .jc-tag.v-changed::after { content: "是"; }
.proto-root .jc-tag.v-changed { color: var(--danger); }
.proto-root .jc-tag.v-unchanged::after { content: "无变动"; }
.proto-root .jc-tag.v-unchanged { color: var(--ink-4); }
/* 关键人标记(boolean 列):值是 true/false,人读字由这里出 —— 与其余枚举徽同一机制 */
.proto-root .jc-tag.v-true::after { content: "是"; }
.proto-root .jc-tag.v-true { color: var(--ok); }
.proto-root .jc-tag.v-false::after { content: "否"; }
.proto-root .jc-tag.v-false { color: var(--ink-4); }
/*
* **覆盖深度同一坑的另一半**(2026-08-21 用户:「编辑的下拉框是中文,显示是英文」)——
* depth 本身不含中文(说明书写死 ASCII deep/standard/light,不撞 classToken 那道墙),
* 但只读那一份从前是 .jc-vcell 直接回显原值,人看到的是英文单词,跟下拉选项(深度/标准/
* 轻度)对不上。换成同一套 data-class-of + .jc-tag 机制,人读的字上下一致。
*/
/*
* **BT 层级同一坑的第三半**(2026-08-23 用户:「BT 层级下拉框显示不全,
* 都看不到具体选了哪个」)。两处一起错:
* · 列宽只有 80px,而选项文字是「BT-01 · 采购/供应链」—— 下拉被挤扁,选了什么看不见
* (覆盖深度那列因为选项只有两个字,一直没暴露);
* · 只读那一份是 data-slot 直显,存的是 ASCII bt-02,人看到的就是 bt-02 ——
* 与编辑态下拉里写的「BT-02 · 技术」对不上。覆盖深度当初专门修过这个坑,
* BT 层级漏了。现在两列同一套:值仍是 ASCII(台账与 recompute-score 的
* btLevel 入参认它),人读的字由这里出。
*/
.proto-root .jc-tag.v-bt-01::after { content: "BT-01 · 采购/供应链"; }
.proto-root .jc-tag.v-bt-02::after { content: "BT-02 · 技术"; }
.proto-root .jc-tag.v-bt-03::after { content: "BT-03 · 高层"; }
.proto-root .jc-tag.v-bt-01, .proto-root .jc-tag.v-bt-02, .proto-root .jc-tag.v-bt-03 { color: var(--ink-2); font-family: var(--mono); font-size: 11.5px; }
.proto-root .jc-tag.v-deep::after { content: "深度"; }
.proto-root .jc-tag.v-standard::after { content: "标准"; }
.proto-root .jc-tag.v-light::after { content: "轻度"; }
/* 状态徽承继自引擎产物,不进编辑态的显隐开关——on 时也照旧摆着,当「现在是什么」的参照 */
.proto-root .jc-tbl-in.on td > .jc-tag { display: inline-flex; align-items: center; margin-top: 4px; }
/*
* 「本轮触发 / 本轮说明」这类**值还没写下就整行不摆**的说明行:
* 行里所有取值位(b/span/i)都空着 → 整行藏掉 —— 「本轮说明:」后面跟着一片空白
* 比不摆更糟(像是丢了数据)。值一落(S1 写 verdict),行自己现身。
*/
.proto-root .jc-note-line:not(:has(b:not(:empty), span:not(:empty), i:not(:empty))) { display: none; }
`;
/**
* 三层壳照目标件:少一层样式表全失效。
*
* 卡头/页脚的动态位照目标件 §6.2 补齐(2.0.0):收件人(case:owner)、
* **关联任务**(case:taskId,基座注入的任务号)、**步骤**(step,目标件里每份工单
* 写死的静态文案,如「步骤 ① 启动与覆盖范围确认」)、页脚**单号**(round_ref)。
* 目标件还有一格「最迟交回」—— 本仓没有工单期限这个事实,**不摆假数据**,那一格不做。
*/
const doc = (input: {
tone: 'human' | 'ledger';
badge: string;
tag: string;
kicker: string;
title: string;
/** 标题旁的单号 —— 2026-08-24 用户定:不摆(页脚已有),留作可选 */
titleRef?: string;
/** 目标件 side 第二行:静态步骤文案(哪份工单盖哪几步是写清单时定死的) */
step: string;
state: string;
body: string;
foot: string;
}): string =>
`<style>${PRD_CSS}${BIND_CSS}</style>
<div class="proto-root"><div class="app-shell"><div class="jc-stage">
<article class="jc-report tone-${input.tone}">
<header class="jc-rp-head">
<div class="jc-rp-badge">${input.badge}</div>
<div class="jc-rp-tit">
<em>${input.kicker}</em>
<h3>${input.title}${input.titleRef === undefined ? '' : ` <span data-slot="${input.titleRef}"></span>`}</h3>
<div class="jc-rp-sub">收件人:<span data-slot="case:owner"></span> · 关联任务:<span class="jc-rp-taskid" data-slot="case:taskId" data-blank></span></div>
</div>
<div class="jc-rp-side">
<span class="jc-rp-tag">${input.tag}</span>
<span class="jc-rp-step">${input.step}</span>
${input.state}
</div>
</header>
<div class="jc-rp-bd">${input.body}</div>
<footer class="jc-rp-foot">${input.foot}</footer>
</article>
</div></div></div>`;
const part = (label: string, aside = ''): string =>
`<p class="jc-rp-part"><b>【${label}】</b>${aside === '' ? '' : ` —— ${aside}`}</p>`;
const criteria = (title: string, items: readonly string[]): string =>
`<div class="jc-criteria"><div class="jc-criteria-t">${title}</div><ul>${items.map((i) => `<li>${i}</li>`).join('')}</ul></div>`;
const flow = (text: string): string => `<div class="jc-flow">${text}</div>`;
/** 页脚右侧照目标件长「工单类型:执行 · <单号>」—— 目标件的 card.id 没有真数据源,摆本轮案号 */
const sign = (note: string, tag: string): string =>
`<span>${note}</span><span>${tag} · <span data-slot="round_ref" data-blank></span></span>`;
/** 编辑闸 —— `label` 是闸上那句话(目标件的措辞各工单不同,如「AI 已识别 N 个客户」) */
const editGate = (address: string, label: string): string => `<div class="jc-editgate" data-edit-for="${address}">
<span class="jc-editgate-t">${label}</span>
<button type="button" class="jc-egbtn open" data-edit-open="${address}">编辑</button>
<button type="button" class="jc-egbtn ghost" data-edit-cancel="${address}">取消</button>
<button type="button" class="jc-egbtn save" data-edit-save="${address}">保存</button>
</div>`;
/* ==================== 局面一 · 候选覆盖客户清单(S1/S2)==================== */
const CANDIDATE_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
<thead><tr>
<th style="width:36px;min-width:36px"></th>
<th class="req" style="min-width:100px">客户编号</th>
<th class="req" style="min-width:170px">客户名称</th>
<th class="sug" style="min-width:110px">客户类型</th>
<th class="sug" style="width:150px;min-width:150px">BT 层级</th>
<th style="width:80px;min-width:80px">状态</th>
<th class="sug" style="min-width:200px;width:100%">识别依据 / 纳入原因</th>
<th class="sug" style="min-width:104px">覆盖深度</th>
</tr></thead>
<tbody data-rows="mine:${D}.candidate" data-edit="${D}.candidate"><tr>
<td class="jc-rn"><button type="button" class="jc-rowdel" data-edit-del="${D}.candidate" title="撤掉这一行">×</button></td>
<td class="req" data-h="客户编号"><span class="jc-vcell mono" data-slot="customer_code" data-col="customer_code" data-blank></span></td>
<td class="req" data-h="客户名称"><span class="jc-vcell" data-slot="customer_name" data-col="customer_name" data-blank></span></td>
<td class="sug" data-h="客户类型"><span class="jc-vcell" data-slot="cust_type" data-col="cust_type" data-blank></span></td>
<td class="sug" data-h="BT 层级">
<select class="jc-cell" data-col="bt_level">
<option value=""></option>
<option value="bt-01">BT-01 · 采购/供应链</option>
<option value="bt-02">BT-02 · 技术</option>
<option value="bt-03">BT-03 · 高层</option>
</select>
<span class="jc-tag" data-class-of="bt_level"></span>
</td>
<td data-h="状态"><span class="jc-tag" data-class-of="status"></span></td>
<td class="sug" data-h="识别依据 / 纳入原因"><span class="jc-vcell" data-slot="basis" data-col="basis" data-blank></span></td>
<td class="sug" data-h="覆盖深度">
<select class="jc-cell" data-col="depth">
<option value=""></option>
<option value="deep">深度</option>
<option value="standard">标准</option>
<option value="light">轻度</option>
</select>
<span class="jc-tag" data-class-of="depth"></span>
</td>
</tr></tbody>
</table></div>`;
export const COVERAGE_SCOPE = doc({
tone: 'human',
badge: '执行',
tag: '执行工单',
kicker: 'E04 关系覆盖与关键人经营 · 执行工单 1/2',
title: '覆盖范围确认',
step: '步骤 ① 启动与覆盖范围确认',
state: `<span class="jc-rp-wait">共 <b data-slot="${D}_candidate_rows"></b> 家候选</span>`,
body:
part('做什么', '确认本轮覆盖范围') +
`<div class="jc-derive">
<p><span class="done">AI 已完成</span>:按台账规则(覆盖度陈旧 / 决策链空白节点 / 事件触发)从 <b>L-02 客户主台账</b>与
<b>L-08 联系人台账</b>识别本轮触发口径与覆盖范围,产出下方客户清单(候选基线)。</p>
<p class="jc-note-line">本轮触发:<b data-slot="trigger_label" data-blank></b> <b data-slot="event_type" data-blank></b></p>
<p class="jc-note-line">本轮说明:<span data-slot="note" data-blank></span></p>
<p><span class="need">需要你查漏补缺</span>:点「编辑」直接在清单上增删改 —— <b>加一行 = 额外纳入</b>(AI 未识别但你有价值判断的客户)、
<b>删一行 = 排除</b>(AI 纳入但实际应排除)、<b>改单元格 = 修正</b> AI 识别的内容。没有要补的直接在对话区卡里提交。</p>
</div>` +
part('交回什么', '核对后的覆盖范围客户清单(*为必填)') +
`<div class="jc-gapbar" data-class-of="${D}_candidate_missing">
<span><b>还差 <span data-slot="${D}_candidate_missing"></span> 行客户编号或客户名称没填</b>
—— <span data-slot="${D}_candidate_missing_keys"></span>。
按右上角「编辑」开表,红框那几格就是要填的。补齐了执行单才提交得了。</span>
</div>` +
editGate(
`${D}.candidate`,
`<b>AI 已识别 <span data-slot="${D}_candidate_rows"></span> 家候选客户</b>,点「编辑」在清单上增删改`,
) +
`<div class="jc-editbar">
<span>加一行 = 额外纳入,删一行 = 排除,改单元格 = 修正 AI 识别的内容。
<b>客户编号</b>与<b>客户名称</b>是必填,缺一条这一单走不到经营计划那一步。
改动只落任务草稿层,<b>保存不等于放行</b>。</span>
</div>` +
CANDIDATE_TABLE +
`<div class="jc-addrow">
<button type="button" class="jc-egbtn ghost" data-edit-add="${D}.candidate">+ 加一行(额外纳入客户)</button>
<em>新加行的「客户编号」「客户名称」为必填;「识别依据」栏填你纳入它的原因。</em>
</div>` +
flow('<b>审批 / 提交:</b>本工单只承载内容;「提交回执」请在对话区的【执行卡】中进行。'),
foot: sign('E04 关系覆盖与关键人经营 · 执行工单 1/2 · 覆盖范围确认', '工单类型:执行'),
});
/* ==================== 局面二 · 关键人经营计划(S3/S4,只读)==================== */
const PLAN_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
<thead><tr>
<th style="width:40px;min-width:40px"></th>
<th style="min-width:140px">客户</th>
<th style="min-width:110px">关键人</th>
<th style="min-width:150px">建议动作</th>
<th style="width:92px;min-width:92px">时间点</th>
<th style="width:70px;min-width:70px">优先级</th>
<th style="min-width:200px;width:100%">AI 给出的理由</th>
</tr></thead>
<tbody data-rows="mine:${D}.plan">
<tr>
<td class="jc-rn"></td>
<td class="dimempty" data-h="客户"><span class="jc-vcell cust" data-slot="customer_name" data-blank></span></td>
<td class="dimempty" data-h="关键人"><span class="jc-vcell" data-slot="contact_name" data-blank></span></td>
<td class="dimempty" data-h="建议动作"><span class="jc-vcell" data-slot="action" data-blank></span></td>
<td class="dimempty" data-h="时间点"><span class="jc-vcell mono" data-slot="target_date" data-blank></span></td>
<td data-h="优先级"><span class="jc-tag" data-class-of="priority"></span></td>
<td class="dimempty" data-h="AI 给出的理由"><span class="jc-vcell" data-slot="rationale" data-blank></span></td>
</tr>
</tbody>
</table></div>`;
/*
* 比目标件多一列「时间点」(有意偏离):验收判据③让人核「动作有没有时间点」,
* 而目标件的表上没有这一列 —— 判据指向的东西必须摆在卡面上,人才核得了
* (target_date 在台账声明里是必填,数据一直都在,只是从前没摆出来)。
*/
export const COVERAGE_PLAN = doc({
tone: 'ledger',
badge: '验收',
tag: '验收工单',
kicker: 'E04 关系覆盖与关键人经营 · 验收工单 1/2',
title: '关系经营计划审批',
step: '步骤 ②③④ 产出',
state: `<span class="jc-rp-wait">共 <b data-slot="${D}_plan_rows"></b> 位关键人 · 按影响力 × 亲密度缺口排序</span>`,
body:
part('AI 结论说明', '结论是怎么算出来的') +
`<div class="jc-derive">
<p><b>AI 已完成的计算</b>:读取 L-02 客户画像、决策链地图、关系覆盖度,读取 L-08 联系人角色 / 影响力 / 亲密度;
对照决策链识别空白节点,按「影响力 × 亲密度缺口」排序,得出本轮<b>关系经营计划</b>。</p>
</div>` +
part('验收判据', '你据此判断「通过 / 驳回」') +
criteria('判断这份计划是否合理,请逐项核对', [
'<b>关键人覆盖是否完整</b>:该经营的决策链关键人(BT-01 / BT-02 / BT-03)有没有漏掉;',
'<b>优先级排序是否合理</b>:是否符合「影响力 × 亲密度缺口」的排序逻辑,有没有高优的客户被排低了;',
'<b>动作是否可执行</b>:建议动作是否明确、有没有时间点、可不可落地。',
]) +
part('AI 产出的经营计划', '只读展示') +
PLAN_TABLE +
flow('<b>审批:</b>本工单只承载内容;「通过 / 驳回」请在对话区的【验收卡】中进行。'),
foot: sign('E04 关系覆盖与关键人经营 · 验收工单 1/2 · 关系经营计划审批', '工单类型:验收'),
});
/* ==================== 局面三 · 执行回执(S5)==================== */
const RECEIPT_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:140px">计划动作</th>
<th style="min-width:150px">对象</th>
<th class="req" style="width:110px;min-width:110px">完成状态</th>
<th class="req" style="min-width:200px">结果摘要</th>
<th class="sug" style="min-width:180px">客户反馈</th>
<th class="req" style="width:100px;min-width:100px">关键人变动</th>
</tr></thead>
<tbody data-rows="mine:${D}.receipt" data-edit="${D}.receipt"><tr>
<td class="jc-rn"></td>
<td class="dimempty" data-h="计划动作"><span class="jc-vcell" data-slot="action" data-blank></span></td>
<td data-h="对象">
<span class="jc-vcell"><b class="cust" data-slot="customer_name" data-blank></b> · <i data-slot="contact_name" data-blank></i></span>
<span class="jc-vcell mono jc-sub" data-slot="contact_code" data-col="contact_code" data-blank></span>
</td>
<td class="req" data-h="完成状态">
<select class="jc-cell" data-col="completion_status" required>
<option value=""></option>
<option value="done">完成</option>
<option value="partial">部分完成</option>
<option value="skipped">未执行</option>
</select>
<span class="jc-tag" data-class-of="completion_status"></span>
</td>
<td class="req" data-h="结果摘要">
<span class="jc-vcell" data-slot="result_summary" data-blank></span>
<textarea class="jc-cell" data-col="result_summary" placeholder="见到的关键人、谈了什么、达成什么"></textarea>
</td>
<td class="sug" data-h="客户反馈">
<span class="jc-vcell" data-slot="customer_feedback" data-blank></span>
<textarea class="jc-cell" data-col="customer_feedback" placeholder="客户态度 / 诉求 / 下一步信号"></textarea>
</td>
<td class="req" data-h="关键人变动">
<select class="jc-cell" data-col="key_person_change" required>
<option value=""></option>
<option value="changed">是</option>
<option value="unchanged">无变动</option>
</select>
<span class="jc-tag" data-class-of="key_person_change"></span>
</td>
</tr></tbody>
</table></div>`;
export const COVERAGE_RECEIPT = doc({
tone: 'human',
badge: '执行',
tag: '执行工单',
kicker: 'E04 关系覆盖与关键人经营 · 执行工单 2/2',
title: '经营计划执行回执',
step: '步骤 ⑤⑥ 执行经营动作',
state: `<span class="jc-rp-wait warn">待回执 <b data-slot="${D}_receipt_missing"></b> / <b data-slot="${D}_receipt_rows"></b> 项</span>`,
body:
part('做什么', '执行计划动作') +
`<div class="jc-derive">
<p><b>经营计划已验收通过</b>。以下是需要你<span class="need">到场执行</span>的动作(物理拜访、技术交流、递送实物 ——
都是 AI 做不到、需要人去干的)。每项动作执行后,在下方回执。</p>
</div>` +
part('交回什么', '逐项回执(*为必填)') +
`<div class="jc-gapbar" data-class-of="${D}_receipt_missing">
<span><b>还差 <span data-slot="${D}_receipt_missing"></span> 项回执没填齐</b>
—— <span data-slot="${D}_receipt_missing_keys"></span>。
按右上角「编辑」开表,红框那几格就是要填的。补齐了执行单才提交得了。</span>
</div>` +
editGate(
`${D}.receipt`,
`<b>共 <span data-slot="${D}_receipt_rows"></span> 项计划动作待回执</b>,点「编辑」逐项回填`,
) +
`<div class="jc-editbar">
<span>可填的是<b>完成状态</b>、<b>结果摘要</b>、<b>客户反馈</b>、<b>关键人变动</b>四列;
计划动作与对象是承继自计划的只读展示,改不了。确实没有变动的「关键人变动」请选「无变动」,不能留空。
改动只落任务草稿层,<b>保存不等于放行</b>。</span>
</div>` +
RECEIPT_TABLE +
flow('<b>审批 / 提交:</b>本工单只承载内容;「提交回执」请在对话区的【执行卡】中进行。若「关键人变动」标「是」,将触发台账写变更记录。'),
foot: sign('E04 关系覆盖与关键人经营 · 执行工单 2/2 · 经营计划执行回执', '工单类型:执行'),
});
/* ==================== 局面四 · 台账更新明细(S6/T_release,只读)==================== */
const LEDGER_UPDATE_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:150px">台账</th>
<th style="min-width:200px">字段</th>
<th style="min-width:140px">更新前</th>
<th style="min-width:140px;width:100%">更新后</th>
</tr></thead>
<tbody data-rows="mine:${D}.ledger_update">
<tr>
<td class="jc-rn"></td>
<td class="dimempty" data-h="台账">
<span class="jc-vcell mono" data-slot="ledger_label" data-blank></span>
<span class="jc-sub mono"><i data-slot="customer_code" data-blank></i> <i data-slot="contact_code" data-blank></i></span>
</td>
<td class="dimempty" data-h="字段">
<span class="jc-vcell mono" data-slot="field_label" data-blank></span>
<span class="jc-sub">依据:<i data-slot="evidence_ref" data-blank></i></span>
</td>
<td class="dimempty" data-h="更新前"><span class="jc-vcell dim" data-slot="before_value" data-blank></span></td>
<td data-h="更新后"><span class="jc-vcell jc-diffnew" data-slot="after_value" data-blank></span></td>
</tr>
</tbody>
</table></div>`;
/*
* ==================== 落库预览两张表(2026-08-24 用户定)====================
* 「没有把要更新的台账展示全」:更新明细是模型写的**摘要**,而真正落库的是
* customer.profile / customer.contact 的整行 —— 签字的人要看的是**将要写进正式表的
* 东西本身**,不是转述。两张表直接摆本单将落的行,且**可编辑**:AI 分析填不出的
* 字段(行业/区域)人在这儿补,AI 没建上的联系人人在这儿加一行(联系人编码留空,
* 由系统按「客户编码-两位序号」续号 —— 自增列不问人,件上同规)。
*/
const PROFILE_LANDING_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:100px">客户编号</th>
<th class="req" style="min-width:150px">客户名称</th>
<th class="sug" style="min-width:110px">客户类型</th>
<th class="sug" style="min-width:120px">所属行业</th>
<th class="sug" style="min-width:110px">区域</th>
<th style="width:90px;min-width:90px">覆盖度</th>
</tr></thead>
<tbody data-rows="touched:customer.profile" data-edit="customer.profile"><tr>
<td class="jc-rn"></td>
<td data-h="客户编号"><span class="jc-vcell mono" data-slot="customer_code" data-col="customer_code" data-blank></span></td>
<td class="req" data-h="客户名称"><span class="jc-vcell cust" data-slot="name" data-col="name" data-blank></span></td>
<td class="sug" data-h="客户类型"><span class="jc-vcell" data-slot="cust_type" data-col="cust_type" data-blank></span></td>
<td class="sug" data-h="所属行业"><span class="jc-vcell" data-slot="industry" data-col="industry" data-blank></span></td>
<td class="sug" data-h="区域"><span class="jc-vcell" data-slot="region" data-col="region" data-blank></span></td>
<td class="dimempty" data-h="覆盖度"><span class="jc-vcell mono" data-slot="coverage_score" data-blank></span></td>
</tr></tbody>
</table></div>`;
const CONTACT_LANDING_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:96px">客户编号</th>
<th style="min-width:104px">联系人编码</th>
<th class="req" style="min-width:120px">姓名</th>
<th class="sug" style="min-width:100px">职务</th>
<th class="sug" style="width:150px;min-width:150px">决策链节点</th>
<th class="req" style="width:96px;min-width:96px">影响力</th>
<th class="req" style="width:96px;min-width:96px">亲密度</th>
<th class="req" style="width:96px;min-width:96px">关键人</th>
</tr></thead>
<tbody data-rows="touched:customer.contact" data-edit="customer.contact"><tr>
<td class="jc-rn"><button type="button" class="jc-rowdel" data-edit-del="customer.contact" title="撤掉这一行">×</button></td>
<td data-h="客户编号"><span class="jc-vcell mono" data-slot="customer_code" data-col="customer_code" data-blank></span></td>
<td class="dimempty" data-h="联系人编码"><span class="jc-vcell mono" data-slot="contact_code" data-col="contact_code" data-blank></span></td>
<td class="req" data-h="姓名"><span class="jc-vcell cust" data-slot="name" data-col="name" data-blank></span></td>
<td class="sug" data-h="职务"><span class="jc-vcell" data-slot="title" data-col="title" data-blank></span></td>
<td class="sug" data-h="决策链节点">
<select class="jc-cell" data-col="decision_node">
<option value=""></option>
<option value="bt-01">BT-01 · 采购/供应链</option>
<option value="bt-02">BT-02 · 技术</option>
<option value="bt-03">BT-03 · 高层</option>
</select>
<span class="jc-tag" data-class-of="decision_node"></span>
</td>
<td class="req" data-h="影响力">
<select class="jc-cell" data-col="influence" required>
<option value=""></option>
<option value="high">高</option>
<option value="medium">中</option>
<option value="low">低</option>
</select>
<span class="jc-tag" data-class-of="influence"></span>
</td>
<td class="req" data-h="亲密度"><span class="jc-vcell mono" data-slot="relationship" data-col="relationship" data-blank></span></td>
<td class="req" data-h="关键人">
<select class="jc-cell" data-col="key_person" required>
<option value=""></option>
<option value="true">是</option>
<option value="false">否</option>
</select>
<span class="jc-tag" data-class-of="key_person"></span>
</td>
</tr></tbody>
</table></div>`;
/*
* 「字段」格里多一行「依据」、「台账」格里多一行编码(有意偏离目标件):三条 L4 判据
* 让签字的人核「有没有依据 / 有没有张冠李戴」,而 evidence_ref / customer_code 从前
* 压根不在卡面上 —— 人被要求判他看不见的东西。次行小字摆进原格,四列结构不动。
*/
export const COVERAGE_LEDGER_UPDATE = doc({
tone: 'ledger',
badge: '验收',
tag: '验收工单',
kicker: 'E04 关系覆盖与关键人经营 · 验收工单 2/2',
title: '关系台账更新确认',
step: '步骤 ⑥⑦ 更新台账',
state: `<span class="jc-rp-wait">共 <b data-slot="${D}_ledger_update_rows"></b> 项更新</span>`,
body:
part('AI 结论说明', 'AI 收到回执后做了哪些更新') +
`<div class="jc-derive">
<p><b>AI 已完成</b>:收到执行回执后,重算关系覆盖度、更新决策链地图、
更新 L-08 联系人档案(角色 / 影响力 / 亲密度 / 关键人标记)、追加互动时间线。
本轮覆盖结论:<span data-slot="summary" data-blank></span></p>
</div>` +
part('验收判据', '你据此判断「通过 / 驳回」') +
criteria('判断这次台账更新是否无误,请逐项核对', [
'<b>更新是否有依据</b>:每条更新是否都能在「执行回执」里找到对应事实(如关系覆盖度上升对应哪几次拜访);',
'<b>数值是否合理</b>:关系覆盖度、亲密度等数值变化幅度与回执是否吻合;',
'<b>有无漏更或误更</b>:对照下方「将写入的客户档案 / 联系人档案」两张落库预览 —— 该更新的都在、不该动的没动;缺的字段、漏建的联系人可直接在表上补。',
]) +
part('更新明细', 'AI 的更新摘要,逐条带依据') +
LEDGER_UPDATE_TABLE +
part('将写入的客户档案', '落库预览 · 字段可就地补改') +
`<div class="jc-gapbar" data-class-of="customer_profile_missing">
<span><b>客户档案还差 <span data-slot="customer_profile_missing"></span> 行必填没齐</b>
—— <span data-slot="customer_profile_missing_keys"></span>。补齐了这一单才放行得了。</span>
</div>` +
editGate(
'customer.profile',
`<b>本轮将写入 <span data-slot="customer_profile_rows"></span> 行客户档案</b>,点「编辑」补改字段`,
) +
`<div class="jc-editbar">
<span>这就是签字后要进正式表的行。<b>客户名称</b>必填;<b>客户类型 / 行业 / 区域</b>是建议档
—— AI 从来件里推得出的已经填上,推不出的橘胶囊留给你。覆盖度是算出来的数,改不了。
改动只落任务草稿层,<b>保存不等于放行</b>。</span>
</div>` +
PROFILE_LANDING_TABLE +
part('将写入的联系人档案', '落库预览 · 可补改,也可加行') +
`<div class="jc-gapbar" data-class-of="customer_contact_missing">
<span><b>联系人档案还差 <span data-slot="customer_contact_missing"></span> 行必填没齐</b>
—— <span data-slot="customer_contact_missing_keys"></span>。补齐了这一单才放行得了。</span>
</div>` +
editGate(
'customer.contact',
`<b>本轮将写入 <span data-slot="customer_contact_rows"></span> 行联系人档案</b>,点「编辑」补改或加行`,
) +
`<div class="jc-editbar">
<span><b>姓名 / 影响力 / 亲密度 / 关键人</b>是必填;执行中结识了新的关键人而 AI 没建上档的,
在这儿<b>加一行</b>——联系人编码留空,由系统按「客户编码-两位序号」续号,不用你编。
改动只落任务草稿层,<b>保存不等于放行</b>。</span>
</div>` +
CONTACT_LANDING_TABLE +
`<div class="jc-addrow">
<button type="button" class="jc-egbtn ghost" data-edit-add="customer.contact">+ 加一行(补录联系人)</button>
<em>新加行填客户编号与姓名(必填);联系人编码留空由系统续号。</em>
</div>` +
flow('<b>审批:</b>本工单只承载内容;「通过入库 / 驳回」请在对话区的【验收卡】中进行。上面三张表就是签字后落库的全部内容。'),
foot: sign('E04 关系覆盖与关键人经营 · 验收工单 2/2 · 关系台账更新确认', '工单类型:验收 → 完结'),
});
@@ -0,0 +1,396 @@
/**
* A2 替代选型的包内自测 —— **只准 import @sdk / @contracts / @sfeel**
* (编码规范 §5.1 / ADR-004)。安装两审那一侧的验收在
* `apps/api/.../activity-registry/__tests__` —— 二者之间只有安装通道,没有代码通道。
*
* 案例活动没有 `run`,所以自测不跑回合。这里测的是**清单本身**:
* ① 清单合规 + 判据等级不谎报;
* ② 案例模型双表示同在、触达世界的项标了人工激活;
* ③ **声明与契约对得上** —— 必交付物的台账地址、验收谓词读的列、
* 出口载荷要的标量、出口 when 读的读数,处处必须有承载;
* ④ 报告模版无脚本、去向判定链齐全。
*/
import { describe, expect, it } from 'vitest';
import { activityManifestSchema } from '@contracts';
import { evaluateCondition } from '@sfeel';
import { createMockHost } from '@sdk';
import { manifest } from '../src/manifest.ts';
import activity from '../src/index.ts';
describe('清单本身合规', () => {
it('过 @contracts 的 schema', () => {
expect(() => activityManifestSchema.parse(manifest)).not.toThrow();
});
it('监听时限已配 —— 无悬空监听(AM 4.4)', () => {
expect(manifest.declarations.listeners.every((l) => /^P/.test(l.deadline))).toBe(true);
});
it('L1/L2 判据都给了可机检的谓词 —— 验收等级不得谎报', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef, c.key).toBeDefined();
}
});
it('禁区短而硬,授权边界给了调人预算', () => {
expect(manifest.contract.prohibitions.length).toBeGreaterThan(0);
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(0);
});
it('每件必交付物都有台账地址 —— 没地址,「缺一不得提交放行」只能靠活动自报', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(d.ledger, d.key).toBeDefined();
}
});
/*
* MVP 口径:A2 **没有判定表**。上一版的 GRADE_DMN 声明着三个入参而台账没有承载、
* 剧本零调用 —— 一张没有求值器的判定表不是资产,是错觉。判稳规则写死在剧本里。
*/
it('没有声明无求值器的判定表 —— 判稳规则写死在剧本(带 90 天那把尺)', () => {
expect(manifest.assets.businessModel.dmn.tables).toEqual([]);
const reclass = manifest.assets.businessModel.steps?.find((s) => s.item === 'S3_reclass');
expect(reclass?.guide).toContain('90');
expect(reclass?.guide).toContain('marketStable');
});
});
describe('案例模型 —— 双表示同版本', () => {
const model = manifest.assets.businessModel;
const cmmn = parseCmmnLite(model.cmmnXml);
it('CMMN 与说明书同在,不允许分叉', () => {
expect(model.cmmnXml).toContain('<casePlanModel');
expect(model.narrative.length).toBeGreaterThan(400);
});
/*
* **MVP 简化口径的四项**:归一(AI)→ 交 FAE 填回(一张执行单)→ 判稳定标(AI)→ 放行。
* SOP 里初选核险 / 圈选替代 / 对比评估的实质工作在 FAE 系统外,不建成项。
*/
it('计划项照状态机一步一项:归一 + 填回 + 判稳 + 放行,不多建一件', () => {
expect(items(model.cmmnXml)).toEqual(['S1_normalize', 'S2_fill', 'S3_reclass', 'T_release']);
});
it('两处人的门都标了人工激活:FAE 填回 · 清单放行', () => {
for (const id of ['S2_fill', 'T_release']) {
const block = model.cmmnXml.slice(model.cmmnXml.indexOf(`id="${id}"`));
expect(block.slice(0, block.indexOf('</humanTask>')), `${id} 少了 manualActivationRule`).toContain(
'<manualActivationRule',
);
}
});
it('不用阶段 / 里程碑 / 裁量项 —— 它们换不来业务上说得出的好处', () => {
for (const tag of ['<stage', '<milestone', '<planningTable', '<discretionaryItem']) {
expect(model.cmmnXml, `不该出现 ${tag}`).not.toContain(tag);
}
});
it('常来回的项标了可重复 —— 归一会随剔行改数重入', () => {
expect(cmmn.repetition).toContain('S1_normalize');
});
it('末尾一张验收单 —— 完成只出自放行通过', () => {
expect(model.cmmnXml).toContain('id="T_release"');
});
/*
* **放行门的哨兵必须有 ifPart,且认「办完了没有」、读基座数**(A1 s_routed 的账:
* 只有 onPart 的哨兵等于「有人写过一笔就开门」,模型写个 false 就推开了)。
*/
it('放行门哨兵带 ifPart:C 类清零 + 定标守恒 + 去向定过,全读基座数', () => {
const condition = sentryIf(model.cmmnXml, 's_routed');
expect(condition).toContain('quote_alternative_list_type_tag_c = 0');
expect(condition).toContain('quote_alternative_list_type_tag_a + quote_alternative_list_type_tag_b');
expect(condition).toContain('route_decided_total');
// FAE 漏填一行替代料(那一行仍是 C)→ 门不开;全转完 → 门开
const done = { route_decided_total: 1, quote_alternative_list_rows: 2, quote_alternative_list_type_tag_a: 1, quote_alternative_list_type_tag_b: 1, quote_alternative_list_type_tag_c: 0 };
expect(safeEval(condition, done)).toBe(true);
expect(safeEval(condition, { ...done, quote_alternative_list_type_tag_c: 1, quote_alternative_list_type_tag_b: 0 })).toBe(false);
});
it('每个计划项都有剧本 —— 轮到哪一步才摊开哪一步', () => {
const ids = items(model.cmmnXml);
expect((model.steps ?? []).map((s) => s.item)).toEqual(ids);
for (const step of model.steps ?? []) {
expect(step.guide.length, `${step.item} 的做法`).toBeGreaterThan(80);
expect(step.intent.length, `${step.item} 的意图`).toBeGreaterThan(6);
}
});
it('说明书不点名计划项 —— 点了就会与案例模型分叉(A1 踩过)', () => {
const named = [...model.narrative.matchAll(/\b(?:S\d+_[A-Za-z]+|T_[a-z]+)\b/g)].map((m) => m[0]);
const ids = new Set(items(model.cmmnXml));
for (const name of named) {
expect(ids.has(name), `说明书提到的 ${name} 不在案例模型里`).toBe(true);
}
});
it('说明书只讲整单的事:成品、怎么说话、怎么收口', () => {
for (const topic of ['开案', '要说话', '一拍连做', '结案']) {
expect(model.narrative, topic).toContain(topic);
}
});
});
/**
* 声明与契约的对齐 —— 三处引用同一批台账列,错位了不会报错,只会在放行那一刻
* 「判据取不到值 / 出口取不到字段」,而两者都长得像业务问题。
*/
describe('声明与契约对得上', () => {
const columns = new Set(
manifest.declarations.ledger.flatMap((d) => [`${d.domain}.${d.object}`, ...d.fields.map((f) => f.name)]),
);
it('必交付物的台账地址都声明过', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(columns.has(d.ledger ?? ''), `${d.key} → ${d.ledger}`).toBe(true);
}
});
it('editableAtRelease 的列都在成果表的声明里 —— 不在名单里的列压根不进写回载荷', () => {
const list = manifest.declarations.ledger.find((d) => d.object === 'list');
const names = new Set((list?.fields ?? []).map((f) => f.name));
for (const d of manifest.contract.requiredDeliverables) {
for (const col of d.editableAtRelease ?? []) {
expect(names.has(col), `editableAtRelease 里的 ${col}`).toBe(true);
}
}
});
const rowReadings = new Set([
...manifest.declarations.ledger.map((d) => `${d.domain}_${d.object}_rows`),
...manifest.declarations.ledger
.filter((d) => d.fields.some((f) => f.required === true))
.map((d) => `${d.domain}_${d.object}_missing`),
...manifest.declarations.ledger.flatMap((d) =>
d.fields.flatMap((f) =>
(f.tally ?? []).map((v) => `${d.domain}_${d.object}_${f.name}_${v.toLowerCase().replace(/[^a-z0-9_]/g, '')}`),
),
),
/*
* **工具调用读数**(2026-08-03 起):基座按留痕数「这一单调了几次某工具」,
* 声明过的工具一律先铺 0。有了它,「转 A 有据」这类判据才两边都不是活动自报的。
*/
...manifest.declarations.tools.map((t) => `tool_${t.name.replace(/[^0-9a-zA-Z]+/g, '_')}_calls`),
]);
it('L1/L2 谓词读的名字都有承载(台账列或基座读数)—— 否则机检永远取不到值', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level !== 'L1' && c.level !== 'L2') continue;
for (const identifier of (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? []) {
if (['and', 'or', 'not', 'true', 'false', 'null', 'in', 'between'].includes(identifier)) continue;
if (rowReadings.has(identifier)) continue;
expect(columns.has(identifier), `${c.key} 的谓词引用了 ${identifier}`).toBe(true);
}
}
});
it('出口选择组每一口的 when 读的名字也都有承载 —— 静态审不查它,这里自己查', () => {
for (const group of manifest.contract.output.selective) {
for (const port of group.ports) {
for (const identifier of (port.when ?? '').match(/[a-z_][a-z0-9_]*/g) ?? []) {
if (['and', 'or', 'not', 'true', 'false', 'null', 'in', 'between'].includes(identifier)) continue;
if (rowReadings.has(identifier)) continue;
expect(columns.has(identifier), `${port.name} 的 when 引用了 ${identifier}`).toBe(true);
}
}
}
});
it('出口载荷的标量字段都有台账列承载(踩过:去向旗没列 → E5 从来不成立)', () => {
const snake = (name: string) => name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
for (const port of manifest.contract.output.mandatory) {
const properties = (port.payload as { properties?: Record<string, { type?: string }> }).properties ?? {};
for (const [field, spec] of Object.entries(properties)) {
if (spec.type === 'array') continue;
expect(columns.has(snake(field)), `${port.name}.${field} → ${snake(field)}`).toBe(true);
}
}
});
it('技能引用的工具都在白名单声明内 —— 载方法不载权力', () => {
const declared = new Set(manifest.declarations.tools.map((t) => t.name));
for (const skill of manifest.assets.skills) {
for (const fragment of skill.crystallized) {
expect(declared.has(fragment.tool), `${skill.id} 用了 ${fragment.tool}`).toBe(true);
}
}
});
/*
* 裁量判据的 label 与剧本里 judge 的 criterion 必须**逐字一致** ——
* judge 按整句对名,差一个字那一条永远算没裁过,提交被拒。
*/
it('L3 判据的整句出现在放行剧本里 —— judge 才对得上名', () => {
const l3 = manifest.contract.acceptanceCriteria.find((c) => c.level === 'L3');
const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
expect(l3).toBeDefined();
expect(release?.guide, 'T_release 的 guide 要整句抄 L3 判据名').toContain(l3?.label ?? '');
});
});
describe('报告模版 —— 活动持有,容器无脚本执行权', () => {
/*
* **选型作业与收口验收各有各的件**(2026-08-03 用户:「验收工单怎么又变成
* 选型了?」)—— One Pager 归选型执行单(S1/S2),整合清单归收口(S3/放行/归档,
* 「清单是所有选型完成后收口才整合的东西」)。「1:1」落在每份件与它的办结快照
* 之间(finalHtml),不是全程只许一张。
*/
it('两件各归各位:选型 One Pager 归作业,整合清单归收口', () => {
expect(manifest.assets.viewTemplates.map((t) => t.name)).toEqual(['alt-worksheet', 'alt-roster']);
const viewOf = (item: string) => manifest.assets.businessModel.steps?.find((s) => s.item === item)?.view;
for (const item of ['S1_normalize', 'S2_fill']) expect(viewOf(item), item).toBe('alt-worksheet');
for (const item of ['S3_reclass', 'T_release']) expect(viewOf(item), item).toBe('alt-roster');
});
it('验收报告重列表与落边:汇总带 + 受理记录大表 + 落边图(命中的边亮)', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'alt-roster')?.html ?? '';
expect(roster).toContain('statrow');
expect(roster).toContain('data-class-of="case:routeKey"');
// 落边图:两条边(A3 寻源 / A4 定价)+ 命中流光
for (const mark of ['class="redge e1"', 'class="redge e2"', 'class="rpulse p1"', 'class="rpulse p2"']) {
expect(roster).toContain(mark);
}
for (const t of ['A3 · 渠道寻源', 'A4 · 成本定价', '还有 B 类?']) expect(roster).toContain(t);
for (const col of ['orig_type', 'type_tag', 'alt_grade', 'basis']) expect(roster).toContain(`data-slot="${col}"`);
// 收口件只读:就地改走验收卡的 editableAtRelease 表,这里一个输入格都不给
expect(roster).not.toContain('data-edit=');
expect(roster).not.toContain('data-col=');
});
/*
* 对决区(2026-08-02 用户定):左侧被替换目标的静态 SVG 卡,右侧替换卡可编辑,
* 替代等级三枚选择钮直接选(正确性全由人控);渲染态就是结构,
* 编辑只点亮输入;02 选型指引铺开(不写类型,口径贴在选择钮上)。
*/
it('执行工单重单颗选型:档案头 + 蓝图带 + 题面/定案双栏 + 等级三选 + 方法脚注,每颗一页', () => {
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'alt-worksheet')?.html ?? '';
expect(sheet).toContain('class="blueprint"');
expect(sheet).toContain('data-page');
expect(sheet).toContain('class="alt-page"');
expect(sheet).toContain('class="given"');
expect(sheet).toContain('class="decide"');
// 等级 = 三枚 radio 直接选,值域即台账取值域
for (const v of ['value="P2P"', 'value="FUNC"', 'value="OTHER"']) expect(sheet).toContain(v);
expect((sheet.match(/name="alt_grade_pick"/g) ?? []).length).toBe(3);
expect(sheet).toContain('class="m-five"');
expect(sheet).toContain('class="m-funnel"');
expect(sheet).toContain('data-slot="remark"');
expect(sheet).toContain('同封装不等于同引脚');
});
/*
* 空占位三级(2026-08-02 用户定,全局口径):必填红 / 建议填橘 / 选填灰,
* 与声明 required / expect / 其余一一对应。
*/
it('空占位三种:必填(红)· 选填(灰)· 建议(橘);待选择归必填', () => {
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'alt-worksheet')?.html ?? '';
for (const word of ['"必填"', '"建议填"', '"选填"']) expect(sheet).toContain(word);
// 等级空位归「必填」—— 不再有单独的「待选定」文案
expect(sheet).not.toContain('待选定');
// 声明侧对应:定案三格 requiredWhen(NOW 行必填),三格 expect(建议填)
const cols = new Map(manifest.declarations.ledger.find((l) => l.object === 'list')?.fields.map((f) => [f.name, f]) ?? []);
for (const c of ['alt_pn', 'alt_brand', 'alt_grade']) expect(cols.get(c)?.requiredWhen?.value, c).toBe('C');
for (const c of ['alt_diff', 'alt_risk', 'alt_verify']) expect(cols.get(c)?.expect, c).toBe(true);
expect(cols.get('alt_note')?.expect).toBeUndefined();
});
it('模版里没有脚本、没有事件处理器、没有外链 —— 样式自带', () => {
for (const template of manifest.assets.viewTemplates) {
expect(template.html, template.name).not.toMatch(/<script|\son[a-z]+=|https?:\/\//i);
expect(template.html, template.name).toContain('<style>');
}
});
it('One Pager 不摆去向与清单 —— 那是收口整合的东西(2026-08-02 用户定),去向在验收卡上', () => {
// 断正文 —— PRD 共享样式表里带着 jc-dmn 的规则,不算摆了判定表
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'alt-worksheet')?.html ?? '';
const markup = sheet.replace(/<style>[\s\S]*?<\/style>/, '');
expect(markup).not.toContain('case:routeKey');
expect(markup).not.toContain('jc-dmn');
});
it('编辑面 = FAE 的定案列 + 行的钥匙 pn —— 保存按格里的主键认行,少了它两行会对到一行上(走查逮过)', () => {
const fae = ['alt_pn', 'alt_brand', 'alt_grade', 'alt_diff', 'alt_risk', 'alt_verify', 'alt_note'];
const html = manifest.assets.viewTemplates.find((t) => t.name === 'alt-worksheet')?.html ?? '';
const cols = new Set([...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]));
expect(cols.has('pn'), '少了行的钥匙 pn').toBe(true);
for (const col of fae) expect(cols.has(col), `少了定案列 ${col}`).toBe(true);
// 现标不在工作单编辑面(推翻判定是放行时的职权,走验收卡的 editableAtRelease 表)
expect(cols.has('type_tag')).toBe(false);
});
it('选择组里每一口都写了判据,两口互斥覆盖 —— 去向才算得出来', () => {
const groups = manifest.contract.output.selective;
expect(groups.length).toBeGreaterThan(0);
for (const group of groups) {
expect(group.ports.length).toBeGreaterThanOrEqual(2);
for (const port of group.ports) expect(port.when, `${group.group}/${port.name}`).toBeTruthy();
}
// 还有 B 类 → 去寻源;全 A → 直通定价 —— 同一个数,两口恰好互斥
const [toSourcing, toPricing] = groups[0]?.ports ?? [];
expect(safeEval(toSourcing?.when ?? '', { quote_alternative_list_type_tag_b: 2 })).toBe(true);
expect(safeEval(toPricing?.when ?? '', { quote_alternative_list_type_tag_b: 2 })).toBe(false);
expect(safeEval(toSourcing?.when ?? '', { quote_alternative_list_type_tag_b: 0 })).toBe(false);
expect(safeEval(toPricing?.when ?? '', { quote_alternative_list_type_tag_b: 0 })).toBe(true);
});
it('宿主拒推含脚本的模版 —— 这道门在基座,不靠模版作者自觉', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
});
});
const CONTRACT = {
activityId: 'quote-alternative',
version: '1.0.0',
data: { rfqRef: 'RFQ MD14.1', records: [] },
message: undefined,
outputPorts: { mandatory: ['roster'], selective: [] },
prohibitions: [],
discretion: [],
} as const;
describe('代码入口 —— 案例活动没有 run,不是残包', () => {
it('默认导出是一个有名字的活动定义', () => {
expect(activity.name).toBe('quote-alternative');
});
it('mock 宿主如实报「没有 run」,不静默返回一个假的 outcome', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.run(activity)).rejects.toThrow(/没有 run/);
});
});
/** 计划项 id 按出现次序 —— 断言「图上有几步就是几项」 */
function items(xml: string): string[] {
return [...xml.matchAll(/<(?:humanTask|decisionTask|processTask|caseTask) id="([^"]+)"/g)].map((m) => m[1] ?? '');
}
function parseCmmnLite(xml: string): { manualActivation: string[]; repetition: string[] } {
const manualActivation: string[] = [];
const repetition: string[] = [];
for (const m of xml.matchAll(/<humanTask id="([^"]+)"[\s\S]*?<\/humanTask>/g)) {
if (m[0].includes('manualActivationRule')) manualActivation.push(m[1] ?? '');
if (m[0].includes('repetitionRule')) repetition.push(m[1] ?? '');
}
return { manualActivation, repetition };
}
/** 抠出某枚哨兵的 ifPart 条件(XML 实体还原)—— 断言放行门认的是「办完了没有」 */
function sentryIf(xml: string, sentryId: string): string {
const block = xml.slice(xml.indexOf(`<sentry id="${sentryId}"`));
const raw = /<condition>([\s\S]*?)<\/condition>/.exec(block.slice(0, block.indexOf('</sentry>')))?.[1] ?? '';
return raw.replaceAll('&gt;', '>').replaceAll('&lt;', '<').replaceAll('&amp;', '&');
}
/** 谓词写坏了引擎当作「没算出来」——测里也照这个口径 */
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
@@ -0,0 +1,212 @@
/**
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(A1 的账)。
*
* 这几条断言守的是剧本本身的可用性:项 id 对不对得上、判据会不会开局即真、
* 声明的台账与工具是不是活动真有的、每一项有没有进度条的格子。
*/
import { describe, expect, it } from 'vitest';
import { evaluateCondition } from '@sfeel';
import { ALTERNATIVE_PHASES, ALTERNATIVE_STEPS, CMMN_XML } from '../src/case-model.ts';
import { manifest } from '../src/manifest.ts';
/** 从 CMMN XML 里把计划项 id 抠出来 —— 剧本认的是它们 */
const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map(
(m) => m[1] ?? '',
);
describe('剧本与案例模型对得上 —— 对不上的条目等于没写', () => {
it('每一条剧本都指向一个真实存在的计划项', () => {
for (const step of ALTERNATIVE_STEPS) {
expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
}
});
it('图上每一步都有剧本 —— 漏掉的那一步会退回整本说明书', () => {
for (const id of ITEM_IDS) {
expect(ALTERNATIVE_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
}
});
it('清单里挂的就是这份剧本(不是另抄一份)', () => {
expect(manifest.assets.businessModel.steps).toBe(ALTERNATIVE_STEPS);
});
});
describe('doneWhen 是「成果已存在」的判据,不是「该不该做」的判据', () => {
/** 开案那一刻的两种局面:流内(上游载荷)与单飞(一段需求)—— 台账一行没有 */
const FRESH_FLOW: Record<string, unknown> = {
rfqRef: 'RFQ MD14.1',
records: [{ pn: 'BBPY00201209601Y00', type_tag: 'C' }],
customer_name: 'Itelma',
};
const FRESH_SOLO: Record<string, unknown> = {
subject: '车规运放选型需求',
body: '需要一颗车规级双通道运放……',
};
it('**开局一步都不该被自动收掉** —— 流内与单飞两种开局都要 false', () => {
for (const fresh of [FRESH_FLOW, FRESH_SOLO]) {
for (const step of ALTERNATIVE_STEPS) {
if (step.doneWhen === undefined) continue;
expect(
safeEval(step.doneWhen, fresh),
`${step.item} 的 doneWhen「${step.doneWhen}」开局就为真,那一步会在还没干之前被收掉`,
).toBe(false);
}
}
});
/* ── ① 归一清单 ─────────────────────────────────────────────── */
it('整份落表且两边对上 → 归一收得掉', () => {
expect(
safeEval(doneOf('S1_normalize'), { alt_line_total: 2, quote_alternative_list_rows: 2 }),
).toBe(true);
});
it('**少拆多写都收不掉** —— 自报条数与基座行数对不上,错在第 2 拍就现形', () => {
expect(safeEval(doneOf('S1_normalize'), { alt_line_total: 2, quote_alternative_list_rows: 3 })).toBe(false);
expect(safeEval(doneOf('S1_normalize'), { alt_line_total: 0, quote_alternative_list_rows: 0 })).toBe(false);
});
/* ── ② 交 FAE 填回:人环,引擎不许替人按 ─────────────────────── */
it('**FAE 那一步不给 doneWhen** —— 引擎不许替人交卷', () => {
expect(ALTERNATIVE_STEPS.find((s) => s.item === 'S2_fill')?.doneWhen).toBeUndefined();
});
it('填回这一步是一张执行单:工作单一颗一页、卡上零输入、答复处理记 filled_total', () => {
const fill = ALTERNATIVE_STEPS.find((s) => s.item === 'S2_fill');
expect(fill, 'FAE 填回这一步不见了').toBeDefined();
expect(fill?.view).toBe('alt-worksheet');
/*
* **一张执行单,多页待执行报告**(2026-08-02 用户定,替代此前的逐颗按序派单):
* 一颗待选型芯片一页,右上角翻页;定案三格是每页必填(requiredWhen orig_type=C),
* 提交闸看着。卡上零输入;人交回后只清点 filled_total、finish。
*/
expect(fill?.guide).toContain('一颗待选型芯片一页');
expect(fill?.guide).toContain('只发一次');
expect(fill?.guide).not.toContain('"fields"');
expect(fill?.guide).not.toContain('sel_stage');
expect(fill?.guide).toContain('filled_total');
for (const code of ['P2P', 'FUNC', 'OTHER']) expect(fill?.guide).toContain(code);
});
it('归一那一步不再排队列 —— 单卡多页后 sel_stage 已退役', () => {
expect(ALTERNATIVE_STEPS.find((s) => s.item === 'S1_normalize')?.guide).not.toContain('sel_stage');
});
/* ── ③ 判稳定标:收步判据与验收判据同源 ─────────────────────── */
it('C 类清零且去向定了 → 判稳收得掉', () => {
expect(
safeEval(doneOf('S3_reclass'), { quote_alternative_list_type_tag_c: 0, route_decided_total: 1 }),
).toBe(true);
});
it('**还有一颗 C 没处置 → 判稳收不掉** —— 收步比验收松,松出来的那一截就是返工', () => {
expect(
safeEval(doneOf('S3_reclass'), { quote_alternative_list_type_tag_c: 1, route_decided_total: 1 }),
).toBe(false);
});
it('**没记过去向数 → 收不掉** —— 未绑定的名字比大小为假,不会开局即收', () => {
expect(safeEval(doneOf('S3_reclass'), { quote_alternative_list_type_tag_c: 0 })).toBe(false);
});
it('全转 A(need_sourcing = false)照样收得掉 —— 判据看的是「定过没有」,不是旗本身', () => {
expect(
safeEval(doneOf('S3_reclass'), {
quote_alternative_list_type_tag_c: 0,
route_decided_total: 1,
need_sourcing: false,
}),
).toBe(true);
});
it('**放行那一项不给 doneWhen** —— 终局唯放行,引擎不许自动收它', () => {
expect(ALTERNATIVE_STEPS.find((s) => s.item === 'T_release')?.doneWhen).toBeUndefined();
});
/*
* `records` 是上游整包落地行 —— 不声明 reads 会被整表段的截断刀盲砍
* (按字符截,砍掉的恰是 C 类行;A1 认客户那一步栽过同款)。
*/
it('归一那一步声明了 reads: records —— 上游载荷置前不截断', () => {
expect(ALTERNATIVE_STEPS.find((s) => s.item === 'S1_normalize')?.reads).toContain('records');
});
});
describe('剧本声明的东西活动真有 —— 声明了没有的,那一步照着做必然失败', () => {
const declaredLedger = manifest.declarations.ledger.map((l) => `${l.domain}.${l.object}`);
const declaredTools = manifest.declarations.tools.map((t) => t.name);
it('每一步要写的台账都在清单的台账声明里', () => {
for (const step of ALTERNATIVE_STEPS) {
for (const address of step.writes) {
expect(declaredLedger, `${step.item} 要写 ${address},而活动没声明这个台账对象`).toContain(address);
}
}
});
it('每一步要调的工具都在清单的工具声明里', () => {
for (const step of ALTERNATIVE_STEPS) {
for (const tool of step.tools) {
expect(declaredTools, `${step.item} 要调 ${tool},而活动没声明这件工具`).toContain(tool);
}
}
});
it('每一步都定了画面,且模版真在包里', () => {
const names = new Set(manifest.assets.viewTemplates.map((t) => t.name));
for (const step of ALTERNATIVE_STEPS) {
expect(step.view, `${step.item} 没定画面`).toBeDefined();
expect(names.has(step.view ?? ''), `${step.item} 的 view「${step.view}」不在模版名单里`).toBe(true);
}
});
it('每一步都说得出它交出什么(intent 与 guide 都不为空)', () => {
for (const step of ALTERNATIVE_STEPS) {
expect(step.intent.trim(), `${step.item} 没写 intent`).not.toBe('');
expect(step.guide.trim().length, `${step.item} 的 guide 太短,等于没写`).toBeGreaterThan(20);
}
});
});
function doneOf(item: string): string {
const found = ALTERNATIVE_STEPS.find((s) => s.item === item)?.doneWhen;
if (found === undefined) throw new Error(`${item} 没有 doneWhen`);
return found;
}
/** 谓词写坏了引擎当作「没算出来」——测里也照这个口径,断言才对得上运行时 */
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
describe('阶段刻度:每一项都有格可待(A1 漏 S3_confirm 的账)', () => {
const inPhase = new Set(ALTERNATIVE_PHASES.flatMap((p) => p.items));
it('**除放行外每一项恰好属于一个阶段** —— 漏一项,进度条就会在它办着的时候跳过去', () => {
const planned = ALTERNATIVE_STEPS.map((s) => s.item).filter((id) => id !== 'T_release');
expect(planned.filter((id) => !inPhase.has(id))).toEqual([]);
});
it('一项不进两个阶段 —— 进了两个,「这一格过没过」就有两种答案', () => {
const all = ALTERNATIVE_PHASES.flatMap((p) => p.items);
expect(all.length).toBe(new Set(all).size);
});
it('**放行不在名单里** —— 收口那一格由基座统一加,活动自己起名会各叫各的', () => {
expect(inPhase.has('T_release')).toBe(false);
});
it('阶段里不许出现案例模型没有的项 —— 那一格永远过不去', () => {
const ids = new Set(ALTERNATIVE_STEPS.map((s) => s.item));
expect([...inPhase].filter((id) => !ids.has(id))).toEqual([]);
});
});
+314 -86
View File
@@ -1,111 +1,339 @@
/**
* A2 替代选型的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
*
* 业务出处:`docs/MVP示例工作流.html` §1.5,但按 **MVP 简化口径**建模
* (2026-08-02 用户定:A2 是复杂活动,MVP 期间不展开大规模业务建模):
*
* A1 带着询价受理记录转进来 → 挑出 C 类 → **一张执行单**让 FAE 在系统外完成选型、
* 把替代料填回来 → 判替代料在库里行情稳不稳:稳转 A、不稳转 B →
* 询价受理记录就此转换成**询价受理清单**,传给下游(有 B 类去寻源,全 A 直通定价)。
*
* SOP 里初选核险 / 圈选替代 / 对比评估那三段的**实质工作在 FAE 脑子里与系统外**
* (撒网、读规格书、参数对比 —— 本系统没有那些数据源);系统的本分是归一、组卡、
* 判稳、留痕。所以图上只有四项:归一 → 交人填回 → 判稳定标 → 放行。
*
* ── 建模纪律(照 A1 的账)──────────────────────────────────────────────
* 图上有几步就是几项,不多建一件;触达世界的项 manualActivation 且不给 doneWhen;
* 哨兵必须有 ifPart 且读基座数、认「办完了没有」;案卷项 id 就是任务视图键名。
*
* 上一版(@2.0.0)只有「派单 + 放行」两项,归一与判稳整个不存在 ——
* C 类怎么来的、去向旗从哪儿出,全靠模型临场发挥;E5 那条边(needSourcing)
* 因为台账里没有承载列,**从来没有成立过**。这一版把这两步补成真的。
*/
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
<case id="alternative">
<!--
案卷项的 id **就是任务视图里的键名** —— 前提的 onPart 认的是「这个键有值了」。
流内起单时上游载荷(records 等)与本活动台账列都会摊平到任务视图顶层,照它们命名。
开案即有的只有上游载荷(或单飞的自由入参)。rfq_ref 是归一那一步认出来
(或造出来)的:本活动所有台账对象都以它为主键。
-->
<caseFileModel>
<caseFileItem id="request" name="替代评估请求"/>
<caseFileItem id="shortlist" name="初筛清单"/>
<caseFileItem id="benchmark" name="替代对标表"/>
<caseFileItem id="datasheet" name="芯片规格书"/>
<caseFileItem id="records" name="上游受理记录(流内开案即有)"/>
<caseFileItem id="alt_line_total" name="清单条目数(自报)"/>
<caseFileItem id="quote_alternative_list_rows" name="清单行数(基座现数)"/>
<caseFileItem id="c_total" name="待选型条数"/>
<caseFileItem id="filled_total" name="已填回替代料条数"/>
<caseFileItem id="quote_alternative_list_type_tag_c" name="仍为 C 类的行数(基座现数)"/>
<caseFileItem id="route_decided_total" name="已定去向"/>
<caseFileItem id="need_sourcing" name="去向旗"/>
</caseFileModel>
<casePlanModel id="plan" name="替代选型" autoComplete="true">
<sentry id="s_request">
<onPart id="op_req" caseFileItemRef="request"><standardEvent>create</standardEvent></onPart>
<!--
① 归一完了 —— 交 FAE 那一步的入场前提。
两边对上才算:alt_line_total 是活动自报的条数,quote_alternative_list_rows
是基座数出来的本单行数 —— 少拆一行、多写一行都进不了下一步
(同一个错,在第 2 拍发现比在第 10 拍便宜得多,A1 的账)。
-->
<sentry id="s_normalized">
<onPart id="op_nm" caseFileItemRef="alt_line_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_nm"><condition>alt_line_total &gt; 0 and quote_alternative_list_rows = alt_line_total</condition></ifPart>
</sentry>
<sentry id="s_need_alt">
<onPart id="op_alt" caseFileItemRef="request"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_alt"><condition>needAlternative = true</condition></ifPart>
<!--
② FAE 的答复处理过了 —— 判稳那一步的入场前提。
filled_total 是「人交回执行单之后」由 AI 清点写下的数(填回替代料的条数)——
它只在人环走完才存在,所以它就是「经过人了」的凭据(照 A1 confirmed_total 的路子)。
全部替代未果时它是 0:「大于等于 0」对「写过的 0」为真、对「没写过」为假
(S-FEEL 未绑定的名字参与比较为假)。
-->
<sentry id="s_filled">
<onPart id="op_fl" caseFileItemRef="filled_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_fl"><condition>filled_total &gt;= 0</condition></ifPart>
</sentry>
<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>
<!--
── 放行门的入场条件:**认「办完了没有」,且读基座数**(A1 s_routed 的账)────
· 清单上不再有 C 类(quote_alternative_list_type_tag_c 由基座按值现数)——
FAE 漏填一行替代料,那一行转不了 A/B,这道门就不开;
· 定标守恒:A + B = 行数(两边都是基座出的数);
· 去向定过了(route_decided_total 是判稳那一步显式写下的记账数 ——
旗是布尔,分不清「没写」与「写了 false」)。
-->
<sentry id="s_routed">
<onPart id="op_rt" caseFileItemRef="route_decided_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_rt"><condition>route_decided_total &gt; 0 and quote_alternative_list_rows &gt; 0 and quote_alternative_list_type_tag_c = 0 and quote_alternative_list_type_tag_a + quote_alternative_list_type_tag_b = quote_alternative_list_rows</condition></ifPart>
</sentry>
<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>
<!--
① 归一询价受理清单 —— 上游受理记录(或单飞来件)整份落成本活动的清单,
C 类行就是本单要办的对象。**整份都落**:A/B 行原样承继 ——
这份清单最终要整份交给下游,不是只有 C 类那几行。
-->
<humanTask id="S1_normalize" name="归一询价受理清单:整份落表,C 类标待选型">
<itemControl><repetitionRule id="r_s1"/></itemControl>
</humanTask>
<timerEventListener id="TL_half" name="时限过半">
<timerExpression>P2D</timerExpression>
</timerEventListener>
<userEventListener id="UL_change" name="客户改需求"/>
<!--
② 替代选型交 FAE —— **一张执行单**(MVP 简化口径:选型的实质工作在系统外)。
manualActivation + 不给 doneWhen:发卡触达真人,引擎不替你按;
「人看过、选完、填回来了」也不是台账里任何一个数能自己变出来的。
-->
<humanTask id="S2_fill" name="替代选型交FAE:执行单上把 C 类行的替代料填回来">
<entryCriterion id="ec_s2" sentryRef="s_normalized"/>
<itemControl><manualActivationRule id="m_s2"/></itemControl>
</humanTask>
<!--
③ 判稳定标 —— 替代料查历史行情,稳转 A、不稳转 B(规则写死,零裁量);
清单就此从「受理记录」转成「受理清单」,去向旗随之立起。
-->
<humanTask id="S3_reclass" name="判稳定标:替代料查行情,稳转A不稳转B,立去向旗">
<entryCriterion id="ec_s3" sentryRef="s_filled"/>
</humanTask>
<!-- 清单放行:终局唯放行 —— 触达世界,必须人工激活 -->
<humanTask id="T_release" name="询价受理清单验收放行">
<entryCriterion id="ec_rel" sentryRef="s_routed"/>
<itemControl><manualActivationRule id="m_rl"/></itemControl>
</humanTask>
</casePlanModel>
</case>
</definitions>`;
export const NARRATIVE = `§0 开案:替代选型一事一议 —— 收到替代评估请求即开案。客户/PM 给出初选物料,或芯片类型与大致参数。
先核初选(EOL 等风险提示 + 是否我司主推品牌),无风险且主推直接采信初选办结;
需要替代的,先撒网(AI 检索/聚合平台拉初筛清单)后逐条人工核验,常要两三轮,
存活候选按替代等级分级(P2P/功能兼容/其他)圈 2~3 颗,做参数对比与风险评估成报告交回。
**任何替代必须把差异与风险写清 —— 差异没核清的不放行。**
/**
* 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本,两处不重复写同一件事)。
*/
export const NARRATIVE = `§0 开案:**一份带 C 类料的询价受理记录要过替代选型**。来路有两种,形状都收(宽进):
· **流内**:上游受理活动放行后带着它的落地事实进来 —— 顶层有单号 / 客户名 / 三类条数等
摊平字段,\`records\` 数组里是上游全部落地行**混在一起**(带年用量与制造商的是受理记录行、
带分级依据与历史天数的是分类表行、带各种总数的是结论行 —— 按 PN 归并自辨,别当成三张表);
· **单飞**:没有上游,只有一段需求(型号或芯片类型 + 参数 + 场景)—— 照来件归一,
没给的照实留空。
S1 初选核险:一手资料以原厂官网为准(产品页/规格书/PCN·EOL 公告);
查芯片规格书确认初选料有无 EOL/停产风险,生命周期对照 PN物料主库;
对品牌原厂字典判是否我司主推 —— 无风险且主推,直接采信初选、免替代办结。
顺手查替代料库与技术问题案例库(裁量项),有沉淀方案或已知坑先亮出来。
**这个活动是复杂活动的 MVP 简化形**:替代选型的实质工作(查规格、撒网、参数对比)
由 FAE 在**系统外**完成,系统只负责三件事 —— 把清单归一好、把执行单组好交给他、
把他填回的替代料**判稳定标**(在库行情稳转 A,不稳转 B)。
S2 圈选替代:先撒网后核验,常要两三轮。逐条人工核验做全面参数对比 ——
先明确每项数据的来源(一律以原厂官网规格书为准),再逐项对标(Pin-to-Pin);
存活者按替代等级分级;候选过客户技术画像禁用清单;不足 2~3 颗就放宽参数或换角度重新撒网。
**成品只有一样**:一份**询价受理清单** —— 整份行都在、C 类已全部转成 A 或 B、
去向旗立了(还有 B 类就去寻源补价,全 A 直通定价)。判稳的尺与三分类 P-03 的 A/B
两行同一把:**历史价 ≤ 90 天且行情稳 → A,否则 → B**。
FAE 的定案不只是一个型号:**替代等级(P2P 直换 / FUNC 功能兼容 / OTHER 须验证)、
参数差异、风险与验证建议一并落表** —— 那是他的专业产出,逐颗可溯。
S3 对比评估交付:收齐候选规格书,关键参数逐项对比写进替代对标表,标出差异项与替代等级,
写明差异带来的风险;按等级定评估深度 —— P2P 核确免改动,功能兼容/其他逐项评软件开发、
外围电路与 layout 改动。FAE 签字后交回发起方。
**人环两次,不多不少**:FAE 交回**一张选型执行单**(工作单里一颗待选型芯片一页,
右上角翻页逐颗定案,必填缺着按不下提交)、放行时 FAE 签字。
全程随时可做:知识入库 —— 规格书入库、替代关系回填替代料库与 PN物料主库、踩到的坑记案例库。
【一路上要说话】负责人正看着这一单。**每一拍都带一句 say** —— 说业务的话:
哪几颗料要替代、替代料查出来什么行情、这单接下来往哪走;
不说「归一化」「写草稿 N 行」这类系统词。到了要找人的那一拍,先说清这一轮办完了什么、
为什么现在需要他。别每拍一个腔调复读。
§Z 结案:必做的事都办完、没有进行中的事,自动算结案(CMMN AutoComplete)。
无需替代的,确认初选可用即结案;需要替代的,推荐报告(已签字)交回发起方即结案 ——
每办一案,知识库厚一层。`;
【怎么把一项干完 · 一拍连做】一个计划项常要两三个动作才交得出成果。write 和 tool
都**不会**自动把那一项收掉 —— 都做完了,用 finish 宣告完成。动作排进同一拍的 actions
串里一口气给,别拆拍。只有两处必须停拍:调工具要看出参(tool 放串尾),
发卡 / 提交会挂起(只能是串尾)。**常路 6-9 拍就该收口** —— 选型只有一张卡,颗数多不多不加拍。
§Z 结案:FAE 在验收单上签字,系统把草稿合并成正式清单并发出 roster 口的载荷 ——
清单整份交给下游,下游按自己的口径归一需要的数据。`;
/**
* 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。
*/
export const ALTERNATIVE_STEPS = [
{
item: 'S1_normalize',
intent: '把上游受理记录(或单飞来件)整份归一成选型底表,C 类行标待选型',
writes: ['quote_alternative.list', 'quote_alternative.verdict'],
/*
* `records` 是上游整包落地行 —— 不声明的话它会被整表段的截断刀砍掉
* (那一段按字符数截,上游一单的落地行远超刀口,砍掉的恰是 C 类行)。
* 声明了的键**置前且不参与截断**。单飞起单没有这个键,声明也无副作用。
*/
reads: ['records'],
/* 归一与 FAE 选型期间摆**选型工作单**;判稳转正后才换收口那张清单 */
view: 'alt-worksheet',
tools: [],
/*
* 归一完才算办完:两边对上 —— alt_line_total 自报、rows 基座现数。
* **不能写 `records != null`**:S-FEEL 未绑定名字 `!= null` 为 true,开局即收(A1 踩过)。
*/
doneWhen: 'alt_line_total > 0 and quote_alternative_list_rows = alt_line_total',
guide: `第一件事:把这一单的**选型底表**落出来 —— 整份承继,不是只有 C 类。
工作区这一步摆的是**替代选型工作单**(逐颗料的选型档案与填回区),你落的行就是它的血肉。
【料从哪儿来】
· 流内(任务数据里有 \`records\` 数组):那是上游全部落地行**混在一起**的一份 ——
同一颗料会出现两三次:带 eau/brand/target_price 的是**受理记录行**(以它为准),
带 basis/history_days/market_stable 的是分类表行(它的 type_tag 与受理记录一致),
带 line_total/type_a_total 这类总数的是结论行(不是料,别落进清单)。
**按 pn 归并**:一颗料归一成清单里的一行,列取受理记录行的值;
type_tag 照抄成本清单的 **orig_type(原标)与 type_tag(现标)两列同一个值**。
· 单飞(没有 \`records\`):照入参与正文归一 —— 有型号落型号;只给芯片类型与参数、
没有具体 PN 的,\`pn\` 写「类型-场景」需求代号(如「运放-车规-01」),
这类需求本来就是来选型的,orig_type 与 type_tag 都写 C。
【单号 rfq_ref】入参有 \`rfqRef\` 就**原样照抄**;没有就从主题/正文里认(RFQ-xxxx);
认不出才造一个稳定案号「ALT-<型号或类型>」—— 它是本活动所有台账行的主键,
造完这一单从头用到尾,**不许中途换写法**(换一个写法就是新开一行)。
造号**只从本单来件取材,不许从台账里已有的别单单号里挑** —— 正式表里躺着的
单号是别人的单,借它的号等于把两单的行混进同一个案卷。
【逐列怎么落】写 \`quote_alternative.list\`,一颗料一行,一拍写完(rows 收数组):
pn(**一个字符不许改**)、customer_pn、category、brand、eau、target_price、lead_time、
**remark(客户备注:预授权替代、认证要求、禁用约束 —— 选型的第一手依据,一个字别丢)**
照上游值抄,没有的留空字符串(**绝不写 0 顶空**);orig_type / type_tag 照抄原标
(**只写 A、B、C 三个大写字母之一**,写别的基座数不出来);
C 类行把「为什么是 C」写进 basis(从分类表行的 basis 抄,或客户点名替代的原话);
alt_pn / alt_brand / alt_grade / alt_diff / alt_risk / alt_verify / alt_note
**全部留空** —— 那是 FAE 的定案,你不许编。
【同一拍记账】写 \`quote_alternative.verdict\`(一单一行):
rfq_ref、customer_code / customer_name(顶层摊平字段里有就抄,没有留空)、
**alt_line_total** = 清单行数、**c_total** = C 类条数。
写完 finish 这一项 —— 引擎按「自报条数 = 基座行数」收步,少拆多写都过不去。`,
},
{
item: 'S2_fill',
intent: '一张选型执行单交 FAE:工作单一颗一页,定案(型号/品牌/等级)逐页填回',
writes: ['quote_alternative.verdict'],
reads: ['quote_alternative.list'],
view: 'alt-worksheet',
tools: [],
/*
* 不给 doneWhen —— 发卡触达真人(manualActivation),引擎不替人按;
* 「FAE 选完填回来了」也判不出来:它是人环,不是台账里能自己变出来的数。
*/
guide: `底表归一好了,**该 FAE 出场了**。规矩(2026-08-02 用户定):
**一张执行单,工作单里一颗待选型芯片一页** —— 他在右上角翻页逐颗定案,
定案三格(型号/品牌/等级)是每页的必填,缺着按不下提交(系统的闸看着)。
【怎么发】照这个骨架发一张卡(它标了人工激活:发卡就算启动,不必单发 start;
引擎也不会替你收 —— 人交回后要你自己 finish)。卡面是纯文本,
steps/standard 里**别写星号记号**:
{"kind":"human","itemId":"S2_fill","workOrder":"execution","card":{
"title":"替代选型:<单号> 待选型 <N> 颗",
"ask":"这几颗料要替代:请照工作单逐页的选型档案在系统外完成选型,把定案填进各页右栏的「替代定案」(型号/品牌/等级是必填,缺着按不下提交),填完保存,回这张卡提交;确实找不到替代的,在那一页的选型备注写明缘由后交回。",
"steps":["工作单一颗一页,右上角翻页;每页「替代定案」栏里给出替代料型号与品牌,替代等级点三档之一:P2P(同封装同引脚可直换)/ FUNC(功能兼容,需外围电路或 layout 调整)/ OTHER(部分兼容,须打样验证)",
"P2P 必须逐项核对过五样:封装尺寸、引脚定义、电气参数、温度等级、认证 —— 以原厂官网规格书为准,同封装不等于同引脚;核对过哪几项写进参数差异项",
"FUNC 或 OTHER 的,参数差异项与差异风险写清差在哪、要动什么;验证建议写明要不要打样、验什么",
"客户备注里已预授权的替代优先采信;点名禁用的品牌不得作候选;确实找不到替代:在那一页的选型备注写明缘由(那也是一个答案);填完保存,回这张卡提交"],
"standard":"每一颗:型号、品牌、等级都有且来源可溯(三格必填,闸看着);或备注写明替代未果的缘由。交回后判稳定标由系统接着办。",
"basis":[{"k":"<待选型 PN 之一>","v":"<为什么是 C + 客户约束(预授权/认证/禁用)>"}]}}
· \`basis\` 逐颗列待选型的料(几颗写几条);标题带单号与颗数。
· **卡上不摆格子**(填写全在工作区):定案填在工作单各页的「替代定案」栏里,
不往卡里塞 \`fields\` 或 \`blocks\`。
发完卡挂起,这一拍到此为止。**这张卡一单只发一次** —— 必填有提交闸兜着,
卡回来时每颗要么三格齐、要么备注写明未果。
【人交回之后】**他的定案已经在台账里了** —— 他在工作单上按「保存」那一刻,
基座就把那几格落进了草稿层(该由人亲手落下的,不经你的手)。
**不许再把那几格 write 一遍** —— 值相同的写入被「写了等于没写」拒(实测)。
这一拍只做两件事,然后 finish:
① 清点 alt_pn 有值的颗数写 **filled_total** 进 verdict(全部未果就是 0,照实写);
② finish 这一项,**这一拍到此为止** —— 判稳、转标、提交都不是这一拍的活:
转标还没做,submit 排进这一串必被「判据没过」拒回(实测白挨一次)。
**这一拍也不许调 crm.history-price-read** —— 查行情是判稳那一步(下一步)开头的活;
实测提前查过一次:查完没处落,连着四拍把同一行原样重写、四次被
「写了等于没写」拒回,白烧四拍才想起该 finish。`,
},
{
item: 'S3_reclass',
intent: '替代料批量查历史行情:稳转 A、不稳转 B;清单转正,立去向旗',
writes: ['quote_alternative.list', 'quote_alternative.verdict'],
reads: ['quote_alternative.list'],
view: 'alt-roster',
tools: ['crm.history-price-read'],
/*
* **收步判据与验收判据同源**:C 类清零(基座按值现数)才算这一步办完 ——
* 收步比验收松,松出来的那一截就是返工(A1 的账)。
* route_decided_total 是本步显式写下的记账数(布尔旗分不清「没写」与「写了 false」)。
*/
doneWhen: 'quote_alternative_list_type_tag_c = 0 and route_decided_total > 0',
guide: `FAE 填回来了,现在把清单**转正**:每颗替代料判稳、定标,清单从「受理记录」
变成「询价受理清单」。
【第一拍:查行情】把所有原标 C 行的 **\`alt_pn\` 那一列的值**(替代料的料号)收进
一个数组,调 \`crm.history-price-read\` 一次传完(参数 pns 收数组)。
**判稳的行情只有这一条来路 —— 这一拍亲自查 alt_pn。**两条近路都不许走:
· **不许传原 PN**(pn 列是被替代的旧料,查它查到的是旧料的行情);
· **不许拿上游带进来的数顶替**:入参里若躺着 history_days / market_stable /
lifecycle,那是 A1 当时查**原 PN** 得的账 —— 跟这颗**替代料**稳不稳没有关系。
实测抄过一次近路:上游写着 9999,模型没调工具就把替代料判成 B,
而那颗替代料库里明明有 43 天的稳定历史,整单去向就此走岔。
调完这一拍就结束(工具出参要下一拍才看得见)。
【第二拍:定标 + 记账 + 收步,一拍连做】
① **规则写死,不留裁量**(与三分类 P-03 的 A/B 两行同一把尺):
historyDays ≤ 90 且 marketStable = true → **type_tag 转 A**;否则 → **转 B**。
工具回的 lifecycle 若是 EOL/缺货/要求替代 —— 那说明替代料选错了,**不转标**,
当成替代未果处置(见③)。
② 逐行写回 \`quote_alternative.list\`(按 rfq_ref + pn 对行,主键一个字符不许改):
type_tag(A 或 B,**照规则写,不许臆断**)、history_days(工具出参照抄)、
basis(一句依据:「替代料 <alt_pn> 历史价 <N> 天·行情稳 → 转 A」这样)。
**先对号再用**:工具出参每行带着查询的 pn —— 那个 pn 必须等于这一行的 alt_pn,
对不上就是第一拍传错了列,回去重查,别把别人的行情安到这颗替代料头上。
orig_type **不动** —— 原标是留痕,转的是现标。
③ **替代料留空(FAE 判未果)的 C 行**:
· 选型备注写了缘由 → 用 \`drop\` 把那一行撤出本单草稿(主键照 \`_keys\` 名单**原样粘**),
把撤了哪几颗、为什么写进 verdict.skipped_note,并把 alt_line_total 改成撤后的行数
(清单条数跟着改对,别让「条数 5 而表里 4 行」把归一那步的判据顶翻);
· 备注也空着 → 发一张**决策卡(decide)**问负责人:「这颗料替代未果,剔出本轮
还是退回整单」两个选项摆出来,人点哪个照哪个办。**只问这一次**
(补全卡已退役 —— 二选一是处置,本就该用决策卡)。
④ 写 verdict:converted_a_total / converted_b_total(转成 A/B 各几条,照②的实数)、
**need_sourcing**(清单里还有 B 类 → true,全 A → false;照基座读数
quote_alternative_list_type_tag_b 的正负写,别凭记忆)、**route_decided_total 记 1**、
summary 一句人话(几颗替代、转成什么、往哪走)。
⑤ finish 这一项,**这一拍到此为止**。引擎按「C 类清零 + 去向定过」收步 ——
有一颗 C 没处置,这一步收不掉,出路只有三条:转标(填了替代料)、
剔除(未果且有说明)、问人(未果且没说明)。
**别把 submit 排进这一串**:放行那一步要先把 L3 裁量裁过才轮得到 submit,
跳过它会被当场拒回(实测白挨过一次)。
**去向不用你选,也不用人选**:契约给两个出口各写了判据(还有没有 B 类),
放行那一刻基座按序求值自己定;need_sourcing 那枚旗是给流程包的边读的,
两处读的都是同一个事实 —— 你只负责把标转对。`,
},
{
item: 'T_release',
view: 'alt-roster',
intent: '清单已转正,提交放行交 FAE 验收',
writes: [],
reads: [],
tools: [],
/* 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 doneWhen) */
guide: `清单整份转正、去向已定 —— 可以走放行了(T_release 人工激活:
动它就算启动,不必单发 start)。先把裁量判据裁掉,然后 submit,同一拍连做:
· judge criterion="替代料来源可溯且选型说明已留:以原厂官网规格书或客户预授权为准,每颗替代写明凭什么" ——
看清单里每颗替代料的 basis 与选型备注:都写了依据就 satisfied=true;
有替代料说不出来源的,satisfied=**false**,rationale 逐颗写清哪几颗没依据,
交签字的人定。**裁成不满足照样能提交** —— 理由连着卡一起交给他,那正是这条判据的用处。
criterion 照判据名**整句原样抄**,写别的名字对不上。
**去向不用你裁**:两个出口各写了判据,放行那一刻基座按序求值、首个命中即出。
终局唯放行:你不能自己宣布完成。`,
},
];
/**
* 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段;
* 放行那一格由基座统一加(CLOSING_PHASE),人看到的是 x/4。
*/
export const ALTERNATIVE_PHASES = [
{ name: '归一清单', items: ['S1_normalize'] },
{ name: '选型填回', items: ['S2_fill'] },
{ name: '判稳定标', items: ['S3_reclass'] },
];
+12 -59
View File
@@ -1,63 +1,16 @@
/**
* 统一报价 A2 · 替代选型 —— 活动包代码入口(ADR-004)。
* loop 活动:调度权归 AI;本文件只做视图流控与「差异没核清不放行」的把关呈现。
* 统一报价 A2 · 替代选型 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
*
* **这个活动没有 `run`,不是缺件。**
*
* 案例活动的推进由 Loop 驱动,行为全部写在清单里:案例模型(CMMN 子集)+ 案例说明书 +
* 步骤剧本 + 两条技能 + 台账声明 + 一张 HTML 报告模版。「清单机器可读,
* 系统不执行代码即知包为何物」。上一版这里有 62 行 `run()` —— loop 活动根本不会调它,
* 还在读一个契约里早已退场的 `originalPn`:摆一个空转的 run 去假装有实现,更坏。
*
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 —— 它是**被静态审扫描的产物文本**
* (查 import 与全局引用,证明活动包零环境能力)。
*/
import { defineActivity } from '@sdk';
import type { ActivityBase, ActivityOutcome } from '@sdk';
const SHORTLIST = 'quote_alternative.shortlist';
const BENCHMARK = 'quote_alternative.benchmark';
export default defineActivity({
name: 'quote-alternative',
paradigm: 'loop',
async run(base: ActivityBase): Promise<ActivityOutcome> {
const shortlist = await base.ledger.read(SHORTLIST);
const benchmark = await base.ledger.read(BENCHMARK);
// 差异没核清的不放行 —— 禁区的机检形态
const unclear = benchmark.filter((r) => String(r['risk'] ?? '').trim() === '');
await base.interaction.pushView([
{
t: 'kv',
title: '选型进展',
items: [
{ k: '原型号', v: String(base.contract.data['originalPn'] ?? '') },
{ k: '初筛候选', v: `${shortlist.length} 颗` },
{ k: '已对标', v: `${benchmark.length} 颗` },
{ k: '差异未核清', v: `${unclear.length} 颗` },
],
},
{
t: 'cards',
title: '候选替代',
items: benchmark.map((r) => ({
tag: String(r['grade'] ?? '待定级'),
title: String(r['candidate_pn'] ?? ''),
swap: { from: String(r['original_pn'] ?? ''), to: String(r['candidate_pn'] ?? '') },
big: String(r['grade'] ?? '—'),
bigLabel: '替代等级',
note: String(r['risk'] ?? '差异风险未写'),
tone: String(r['risk'] ?? '').trim() === '' ? 'danger' : 'ok',
})),
},
]);
if (benchmark.length < 2) {
await base.interaction.say(
shortlist.length === 0 ? '尚未撒网,先拉初筛清单。' : `已初筛 ${shortlist.length} 颗,继续逐条核验分级。`,
'conclusion',
);
return { kind: 'await', reason: '候选不足 2 颗' };
}
if (unclear.length > 0) {
await base.interaction.say(`${unclear.length} 颗候选的差异风险未写清 —— 差异没核清的不放行。`, 'criterion');
return { kind: 'await', reason: '差异风险未核清' };
}
await base.interaction.say('候选已分级、差异与风险已写清,可签字交回发起方。', 'criterion');
return { kind: 'submit', deliverable: 'benchmark-table', rationale: '2 颗以上分级候选,差异与风险齐备' };
},
});
export default defineActivity({ name: 'quote-alternative' });
+426 -78
View File
@@ -1,92 +1,222 @@
/**
* 活动包清单 —— 统一报价 A2 · 替代选型(**案例活动 · loop**)。
* 活动包清单 —— 统一报价 A2 · 替代选型(案例活动 · MVP 简化形)。
*
* 业务素材:docs/MVP示例工作流.html §1.6。
* `▶需拍板`(签字交出)→ manualActivation + 放行;`✋客户改需求` → userEventListener;
* `⏱时限过半` → 时间监听(**时限必配**)。
* 业务素材:`docs/MVP示例工作流.html` §1.5,按 2026-08-02 用户改盘的口径归一:
* **A2 是复杂活动(需要大规模业务建模),MVP 期间不展开** —— 只做一张精美的
* HTML 验收报告 + 一张执行单:AI 挑出 C 类、组好清单交 FAE 在系统外选型填回,
* 替代料查库内行情判稳(稳转 A / 不稳转 B),询价受理记录就此转换成
* **询价受理清单**传给下游。提交就是输出。
*
* ── 这里**去掉了 `GRADE_DMN`(替代等级判定表)**(2026-08-02)────────────────
* 它声明着三个入参(pinCompatible / packageSame / paramDelta),而台账上没有任何列
* 承载它们、剧本里也没有一步调它 —— **一张没有求值器的判定表不是资产,是错觉**:
* 看清单的人以为等级是算出来的,实际全程没人算过。MVP 口径下替代等级的判断
* 整个在 FAE 脑子里(系统外),系统不假装接得住;判稳转标那一步的规则
* (≤90 天且稳 → A,否则 → B)写死在剧本里、依据逐行落表,可回核。
*/
import type { ActivityManifest, DmnModel } from '@contracts';
import { ALTERNATIVE_SKILLS } from './skills.ts';
import { ALTERNATIVE_CASE } from './views/report.ts';
import { activityIdSchema } from '@contracts';
import { CMMN_XML, NARRATIVE } from './case-model.ts';
import { ALTERNATIVE_SKILLS } from './skills.ts';
import { ALT_ROSTER, ALT_WORKSHEET } from './views/report.ts';
import { ALTERNATIVE_PHASES, ALTERNATIVE_STEPS, CMMN_XML, NARRATIVE } from './case-model.ts';
const DOMAIN = 'quote_alternative';
const D = 'quote_alternative';
const ACTIVITY_ID = activityIdSchema.parse('quote-alternative');
/** 替代等级 —— 把「装得下的判断」从 LLM 手里拿走 */
export const GRADE_DMN: DmnModel = {
drd: [{ decision: 'alt-grade', requires: [], inputs: ['pinCompatible', 'packageSame', 'paramDelta'] }],
tables: [
{
id: 'alt-grade',
label: '替代等级判定',
hitPolicy: 'FIRST',
inputs: [
{ name: 'pinCompatible', expression: 'pinCompatible' },
{ name: 'packageSame', expression: 'packageSame' },
{ name: 'paramDelta', expression: 'paramDelta' },
],
outputs: [{ name: 'grade' }, { name: 'effort' }],
rules: [
{ id: 'g-p2p', when: ['true', 'true', '<= 0.05'], then: ['P2P', '核确免改动'] },
{ id: 'g-func', when: ['-', '-', '<= 0.20'], then: ['功能兼容', '评外围电路与 layout'] },
{ id: 'g-other', when: ['-', '-', '-'], then: ['其他', '须验证'] },
],
},
],
};
/** MVP 口径:A2 没有判定表(见文件头注释)—— 判稳规则写死在剧本,依据逐行落表 */
export const ALT_DMN: DmnModel = { drd: [], tables: [] };
export const manifest: ActivityManifest = {
identity: {
activityId: ACTIVITY_ID,
name: 'A2 · 替代选型',
packageVersion: '0.1.3',
packageVersion: '4.16.0',
category: '统一报价',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-quote-alternative',
},
/* ── 契约七件 ────────────────────────────────────────────────── */
contract: {
activityId: ACTIVITY_ID,
version: '0.1.0',
version: '1.0.0',
input: {
/*
* **宽进到底:一个必填都不设**(2026-08-02 用户当场定:「活动是宽进严出,
* 输入接受任何类型」「活动的输入契约是在活动内部归一+补全的」)。
*
* 来路本就有两种形状:流内(上游放行载荷 —— rfqRef + records + 一堆摊平字段)
* 与单飞(一段需求文字,可能连具体型号都没有)。门口拦谁都不对 ——
* 归一是第一个计划项的活,缺什么在活动内问。
*
* 上一版必填 `rfqRef + altRequests`:而 `alt_request` 表 2026-08-02 已在 A1 退休,
* 那个键只是载荷数组通配规则下的空壳(内容与 records 完全相同)——
* 必填一个不存在的东西,单飞起单永远停在补全卡上。
*/
data: {
type: 'object',
required: ['originalPn'],
required: [],
properties: {
originalPn: { type: 'string' },
chipType: { type: 'string' },
params: { type: 'object' },
scenario: { type: 'string' },
rfqRef: { type: 'string', title: '询价单号' },
records: { type: 'array', title: '上游受理记录(已打标)' },
subject: { type: 'string', title: '需求主题' },
body: { type: 'string', title: '需求正文' },
attachments: {
type: 'array',
format: 'file',
title: '附件',
items: {
type: 'object',
properties: {
name: { type: 'string', title: '文件名' },
contentType: { type: 'string', title: '类型' },
size: { type: 'integer', title: '字节数' },
ref: { type: 'string', title: '存档号' },
text: { type: 'string', title: '读出来的文本' },
note: { type: 'string', title: '读取说明' },
},
},
},
},
},
messageAllowed: true,
messageHint: '客户点名的替代授权、禁用品牌、场景约束等写这里',
},
output: {
/*
* 出口载荷**从落地事实推导**:标量字段必须有台账列承载。
* `needSourcing` ← verdict.need_sourcing —— 流程包 E5(仍需寻源 → A3)读的
* 就是它;上一版声明了这个键而台账没有承载列,**E5 从来没有成立过**:
* 选完型仍要寻价的单一律被默认边送去定价(2026-08-02 探查实证)。
*/
mandatory: [
{
name: 'benchmark',
payload: { type: 'object', required: ['rows'], properties: { rows: { type: 'array' } } },
description: '替代对标表(已签字)',
name: 'roster',
payload: {
type: 'object',
required: ['rfqRef', 'rows', 'needSourcing'],
properties: {
rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' },
rows: { type: 'array', title: '询价受理清单(C 类已转 A/B)' },
needSourcing: { type: 'boolean', title: '仍需寻源(清单里还有 B 类)' },
},
},
description: '询价受理清单(C 类已转 A/B)+ 寻源旗',
},
],
/*
* **两条出边 = 一个 XOR 选择组**(对齐流程包 E5/E6)。
* 每一口都写了 `when`(读基座按值现数的 tally 读数),这一单往哪走是**算出来的**:
* 放行时基座按序求值首个命中即出,卡上只如实告诉人走哪一口、凭哪一条。
*/
selective: [
{
group: 'alt-route',
ports: [
{
name: 'to-sourcing',
payload: {
type: 'object',
required: ['rfqRef', 'rows'],
properties: {
rfqRef: { type: 'string', title: '询价单号' },
rows: { type: 'array', title: '询价受理清单(含待补价的 B 类)' },
},
},
description: '清单里仍有 B 类 —— 去寻源补货源价格',
when: 'quote_alternative_list_type_tag_b > 0',
},
{
name: 'to-pricing',
payload: {
type: 'object',
required: ['rfqRef', 'rows'],
properties: {
rfqRef: { type: 'string', title: '询价单号' },
rows: { type: 'array', title: '询价受理清单(全 A 类)' },
},
},
description: '全 A 类:行情完备 —— 直通成本定价',
when: 'quote_alternative_list_type_tag_b = 0',
},
],
},
],
selective: [],
},
requiredDeliverables: [{ key: 'benchmark-table', label: '替代对标表(FAE 签字)' }],
acceptanceCriteria: [
{ key: 'candidates', label: '2~3 颗分级候选经确认', level: 'L2', predicate: 'candidatesConfirmed >= 2' },
{ key: 'diff-clear', label: '差异项与风险逐项写清', level: 'L2', predicate: 'undocumentedDiff = 0' },
{ key: 'source-traceable', label: '每项数据来源可溯(以原厂官网规格书为准)', level: 'L3' },
{ key: 'fae-sign', label: 'FAE 签字放行', level: 'L5' },
/*
* 必交付物只有一件:**询价受理清单**。选型结论(verdict)是它的结论栏,
* 不是第二份成果 —— `ledgers` 说清这份成果由哪几张表承载(A1 的账:
* 各占一条,人在成果面上会看到两样东西)。
*/
requiredDeliverables: [
{
key: 'alt-roster',
label: '询价受理清单(C 类已转 A/B)',
ledger: `${D}.list`,
/*
* 验收页上让 FAE 就地改的那几列 —— 恰是**他的职权面**:
* 替代料三列是他填的,现标 `type_tag` 是判稳规则出的 ——
* 但算得对不对该由人说了算,他改一颗料的标就是在推翻一次判定(A1 的账:
* 不在名单里的列压根不进写回载荷,人改了等于没改)。
* 去向不在其列:它是现标的函数,改标去向就跟着变。
*/
editableAtRelease: ['alt_pn', 'alt_brand', 'alt_grade', 'alt_diff', 'alt_risk', 'alt_verify', 'alt_note', 'type_tag'],
ledgers: [`${D}.list`, `${D}.verdict`],
},
],
/*
* 验收判据 —— **机检(L1/L2)只读基座数得出来的数**(A1 被违反三次的那条纪律):
* `quote_alternative_list_rows` 基座数行数、`_type_tag_a/b/c` 基座按取值域现数。
* 上一版两条 L2 读的是活动自报的 verdict 列(candidate_total / undocumented_total)——
* 那不是门,是「改哪个数能过」的提示。
*/
acceptanceCriteria: [
{
key: 'roster-complete',
label: '清单齐:上游受理记录逐条承继,说了几条清单里就躺着几行',
level: 'L1',
predicate: 'alt_line_total > 0 and quote_alternative_list_rows = alt_line_total',
},
{
key: 'no-c-left',
label: '选型办完:每颗待选型的 C 类料都已填回替代并转成 A 或 B,或经确认剔除',
level: 'L1',
predicate: 'quote_alternative_list_rows > 0 and quote_alternative_list_type_tag_c = 0',
},
{
key: 'tag-conserved',
label: '定标守恒:清单每一行都有现标,A 类加 B 类恰等于总行数',
level: 'L2',
predicate:
'quote_alternative_list_type_tag_a + quote_alternative_list_type_tag_b = quote_alternative_list_rows',
},
/*
* 来源可溯是**裁量**(L3):替代凭什么、说明留没留,机器数不出来 ——
* 别假装机器判得了。裁成不满足照样能提交,理由连着卡交给签字的人。
* 这句 label 与剧本里 judge 的 criterion **必须逐字一致**(judge 按整句对名)。
*/
{
key: 'alt-traceable',
label: '替代料来源可溯且选型说明已留:以原厂官网规格书或客户预授权为准,每颗替代写明凭什么',
level: 'L3',
},
/* L5 是责任关:那张卡本身就是这道关,卡面上不摆它(基座统一滤) */
{ key: 'fae-release', label: 'FAE 放行:签下去这份清单即转正并流向下游', level: 'L5' },
],
/* 去向不给人改(现标的函数);人在验收页改的是成果本身(editableAtRelease) */
revisableAtRelease: [],
prohibitions: [
{ key: 'no-unclear-diff', statement: '差异没核清的不放行' },
{ key: 'no-banned', statement: '客户技术画像禁用清单里的型号不得作候选' },
{ key: 'no-fake-alt', statement: '没有把握的替代不填:宁可在选型备注写明替代未果,也不编一个候选顶上' },
{ key: 'no-banned', statement: '客户点名禁用的品牌与型号不得作替代' },
{ key: 'no-tag-fudge', statement: '不许为过判据改标:现标只能来自判稳规则,或人在件上的亲手修改' },
],
authorization: {
discretion: [{ key: 'widen-search', statement: '候选不足 2~3 颗时可放宽参数或换角度重新撒网' }],
discretion: [
{ key: 'widen-search', statement: 'FAE 可放宽参数或换角度重新选型;是否接受非原厂品牌、参数降级必须问客户,不得替客户决定' },
],
/* 空着不是省事:这个字段全仓没有一个消费者(债 ⑨),写了也不会被执行 */
preauthorizedActions: [],
/* 一颗芯片一张选型单(按顺序派)—— 打扰上限按料数给:MVP 案例 ≤3 颗 + 放行 + 余量 */
humanCallBudget: 6,
},
resources: {
@@ -94,43 +224,121 @@ export const manifest: ActivityManifest = {
skills: ALTERNATIVE_SKILLS.map((s) => s.id),
models: ['deepseek-v4-flash'],
roles: ['FAE'],
budget: { steps: 100, tokens: 260_000, activeMinutes: 360, humanCalls: 6 },
/*
* FAE 在系统外选型,回来才继续 —— activeMinutes 给足一个工作日的等待;
* token 按实测估:每颗料一张卡各占两三拍,常路(1-2 颗)8-12 拍,留一倍余量。
*/
budget: { steps: 60, tokens: 300_000, activeMinutes: 480, humanCalls: 6 },
},
},
/* ── 声明 ────────────────────────────────────────────────────── */
declarations: {
models: ['deepseek-v4-flash'],
tools: [{ name: 'crm.history-price-read' }],
ledger: [
/*
* ── 这里**去掉了 `shortlist` / `benchmark` / `request` 三张表**(2026-08-02)──
*
* MVP 口径下初筛清单与对标表都不在系统内产出 —— 选型的实质工作在 FAE
* 系统外,系统收的是**结论**(替代料是哪颗、凭什么),落在清单行的三列上。
* 撤掉声明不 drop 物理表(物化只增不破坏),历史行还在库里。
*/
{
domain: DOMAIN,
object: 'shortlist',
label: '初筛清单',
domain: D,
object: 'list',
kind: 'record',
caseKey: 'rfq_ref',
label: '询价受理清单',
fields: [
{ name: 'original_pn', type: 'text', primaryKey: true, masked: false, label: '原型号' },
{ name: 'candidate_pn', type: 'text', primaryKey: true, masked: false, label: '候选型号' },
{ name: 'brand', type: 'text', primaryKey: false, masked: false, label: '品牌' },
{ name: 'lifecycle', type: 'text', primaryKey: false, masked: false, label: '生命周期' },
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' },
{ name: 'customer_pn', type: 'text', primaryKey: false, masked: false, label: '客户料号' },
{ name: 'category', type: 'text', primaryKey: false, masked: false, label: '品类' },
{ name: 'brand', type: 'text', primaryKey: false, masked: false, label: '制造商' },
{ name: 'eau', type: 'integer', primaryKey: false, masked: false, label: '年用量 EAU' },
{ name: 'target_price', type: 'number', primaryKey: false, masked: false, label: '目标价' },
{ name: 'lead_time', type: 'text', primaryKey: false, masked: false, label: '交期' },
/* 客户备注承继自上游 —— 预授权替代、认证要求、禁用约束都在这一格,是选型的第一手依据 */
{ name: 'remark', type: 'text', primaryKey: false, masked: false, label: '客户备注(约束/预授权)' },
/*
* **原标与现标两列同名不同物**:orig_type 是上游受理时的三分类(留痕,本活动不改);
* type_tag 是本活动的产物(C 类判稳后转 A/B)。两列都声明取值域,
* 条数由基座按值现数 —— 判据、分布条、出口谓词读的全是基座读数。
*/
{ name: 'orig_type', type: 'text', primaryKey: false, masked: false, label: '原标(上游三分类)', tally: ['A', 'B', 'C'] },
/*
* **定案三格是待选型行的必填**(2026-08-02 用户:「工作区的 HTML 必填项
* 必须填写了才能提交啊,不填写能算任务完成了吗?」)。
* `requiredWhen` 把必填钉在 orig_type=C 的行(一张执行单、件里一颗一页,
* 每页的定案三格都得齐);A/B 行原样承继,不拦。
* 「替代未果」不走「留空交回」—— 那在闸上就是空手交卷:
* 未果 = 在选型备注写明缘由后交回,判稳那一步按剔除处置(剧本 §S3)。
*/
{ name: 'alt_pn', type: 'text', primaryKey: false, masked: false, label: '替代料 PN', requiredWhen: { column: 'orig_type', value: 'C' } },
{ name: 'alt_brand', type: 'text', primaryKey: false, masked: false, label: '替代料品牌', requiredWhen: { column: 'orig_type', value: 'C' } },
/*
* **替代等级与对标三列**(2026-08-02 用户指出「执行单完全是验收单了」后补齐):
* 选型的定案不只是一个型号 —— FAE 的专业产出是**等级 + 差异 + 风险 + 验证建议**
* (照 MVP 素材对标表的口径)。等级用三个机器码(取值域声明,基座按值现数):
* P2P = 同封装同引脚可直换(核对五项:封装尺寸/引脚定义/电气/温度等级/认证);
* FUNC = 功能兼容(需外围电路或 layout 调整);
* OTHER = 其他(部分兼容,须打样验证)。
* 人读名由模版样式表翻译(徽上色 + 图例),FAE 在卡与件上照码填。
*/
{ name: 'alt_grade', type: 'text', primaryKey: false, masked: false, label: '替代等级(P2P/FUNC/OTHER)', tally: ['P2P', 'FUNC', 'OTHER'], requiredWhen: { column: 'orig_type', value: 'C' } },
{ name: 'alt_diff', type: 'text', primaryKey: false, masked: false, label: '参数差异项', expect: true },
{ name: 'alt_risk', type: 'text', primaryKey: false, masked: false, label: '差异风险与改动评估', expect: true },
{ name: 'alt_verify', type: 'text', primaryKey: false, masked: false, label: '验证建议', expect: true },
/*
* 选型备注是**我方写的注**,不标 expect —— 标了 `_gaps` 恒大于 0,
* 那条门就废了(A1 的 remark 同款教训)。
*/
{ name: 'alt_note', type: 'text', primaryKey: false, masked: false, label: '选型备注(来源/预授权)' },
{ name: 'type_tag', type: 'text', primaryKey: false, masked: false, label: '现标', tally: ['A', 'B', 'C'] },
{ name: 'basis', type: 'text', primaryKey: false, masked: false, label: '转标依据' },
{ name: 'history_days', type: 'integer', primaryKey: false, masked: false, label: '替代料距上次报价天数' },
/*
* **选型排程**(2026-08-02 用户定:「多个芯片按顺序派选型单」)——
* 一颗芯片一张选型单,按序派:WAIT 排队 / NOW 当前在办 / DONE 已定案。
* 只有待选型的料写这一列(A/B 行留空,读数自然为 0);工作单照它组画面
* (队列丸、当前颗的档案、填回表只显当前行),判断全在样式表。
* 它是排程不是编辑面 —— 不进 data-col,人改不到。
*/
],
},
{
domain: DOMAIN,
object: 'benchmark',
label: '替代对标表',
/*
* 选型结论 —— **一单一行**:汇总数(报告要素卡与哨兵读)、
* 去向旗(流程包 E5 唯一读的东西)、一句人话结论。
*/
domain: D,
object: 'verdict',
kind: 'record',
caseKey: 'rfq_ref',
label: '选型结论',
fields: [
{ name: 'original_pn', type: 'text', primaryKey: true, masked: false },
{ name: 'candidate_pn', type: 'text', primaryKey: true, masked: false },
{ name: 'grade', type: 'text', primaryKey: false, masked: false, label: '替代等级' },
{ name: 'param_diff', type: 'text', primaryKey: false, masked: false, label: '参数差异项' },
{ name: 'risk', type: 'text', primaryKey: false, masked: false, label: '差异风险' },
{ name: 'verify_advice', type: 'text', primaryKey: false, masked: false, label: '验证建议' },
{ name: 'signed_by', type: 'text', primaryKey: false, masked: false, label: 'FAE 签字' },
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'customer_code', type: 'text', primaryKey: false, masked: false, label: '客户码' },
{ name: 'customer_name', type: 'text', primaryKey: false, masked: false, label: '客户名称' },
{ name: 'alt_line_total', type: 'integer', primaryKey: false, masked: false, label: '清单条目数' },
{ name: 'c_total', type: 'integer', primaryKey: false, masked: false, label: '待选型条数' },
/* 「FAE 的答复处理过了」的凭据 —— 只在人环走完才产生(哨兵按它放行下一步) */
{ name: 'filled_total', type: 'integer', primaryKey: false, masked: false, label: '已填回替代料条数' },
{ name: 'converted_a_total', type: 'integer', primaryKey: false, masked: false, label: '判稳转 A 条数' },
{ name: 'converted_b_total', type: 'integer', primaryKey: false, masked: false, label: '转 B(需寻价)条数' },
{ name: 'skipped_note', type: 'text', primaryKey: false, masked: false, label: '剔除了哪几颗·为什么' },
/* 流程包 E5(仍需寻源 → A3)读的就是它 —— 没有这一列,E5 永远判假(实证过) */
{ name: 'need_sourcing', type: 'boolean', primaryKey: false, masked: false, label: '仍需寻源' },
/* 去向定过了没有 —— 旗是布尔,分不清「没写」与「写了 false」,另记一个计数 */
{ name: 'route_decided_total', type: 'integer', primaryKey: false, masked: false, label: '已定去向' },
{ name: 'summary', type: 'text', primaryKey: false, masked: false, label: '选型结论' },
],
},
],
listeners: [
{
// ⏱ 时限过半:向 BD 与 PM 同步进度与风险
/** 时限过半:给 FAE 与负责人提个醒 —— 静态监听,时限必配 */
origin: 'static',
eventType: 'time',
keyFilter: { kind: 'half-deadline' },
@@ -139,7 +347,7 @@ export const manifest: ActivityManifest = {
escalation: ['nudge'],
},
{
// ✋ 客户改需求:应用条件/参数变更 → 回初选核险与圈选复核
/** 客户改需求(应用条件/参数变更):记一笔观察,回炉与否由 Agent 裁量 */
origin: 'static',
eventType: 'human',
keyFilter: { kind: 'requirement-change' },
@@ -149,33 +357,173 @@ export const manifest: ActivityManifest = {
},
],
harness: {
memoryWindow: 30,
memoryWindow: 24,
// FAE 在系统外干活,等他不算停滞 —— 停滞检测放宽到一天
stallAfter: 'P1D',
nudge: [{ after: 'PT12H', action: 'nudge' }],
retry: { times: 2, backoff: 'PT5M' },
},
},
/* ── 资产初始版 ──────────────────────────────────────────────── */
assets: {
businessModel: {
cmmnXml: CMMN_XML,
narrative: NARRATIVE,
dmn: GRADE_DMN,
dmn: ALT_DMN,
steps: ALTERNATIVE_STEPS,
phases: ALTERNATIVE_PHASES,
},
skills: ALTERNATIVE_SKILLS,
viewTemplates: [{ name: 'case-overview', html: ALTERNATIVE_CASE }],
/*
* 两个局面各一张(2026-08-02 用户指出「执行单完全是验收单了」):
* · `alt-worksheet` 替代选型工作单 —— 归一与 FAE 选型期间摆它:逐颗料的选型档案、
* 替代等级判据、pin-to-pin 核对要点、三层筛指引 + 填回工作表。**这是干活的件**;
* 同一局面在办与归档同一张;局面变了换张(A1 三张同款口径)。
*/
viewTemplates: [
/*
* **每份件两态 1:1**(2026-08-02/03 用户两次校准):在办可编辑 ↔ 办结快照
* 静态归档(finalHtml),这是「1:1」的落点;而**选型作业与收口验收各有各的件**。
*/
{ name: 'alt-worksheet', html: ALT_WORKSHEET },
/*
* 收口件(2026-08-03 用户:「验收工单怎么又变成选型了?」)—— 选型作业与
* 收口验收是两件工作,各有各的件:One Pager 归选型执行单,**整合清单归验收**
* (「清单是所有选型完成后收口才整合的东西」)。「1:1」落在每份件与它的
* 办结快照之间(finalHtml),不是全程只许一张。
*/
{ name: 'alt-roster', html: ALT_ROSTER },
],
cardSchemas: {},
distillHooks: [
{ on: 'milestone', milestone: 'M_candidates', prompt: '归纳本轮撒网的有效角度与无效角度。' },
{ on: 'terminal', prompt: '归纳替代经验:哪些参数差异实际造成了问题、哪些品牌的替代关系可复用。' },
{
on: 'terminal',
prompt: '归纳本单替代选型的经验:哪类料找得到替代、FAE 的选型依据形态、判稳转标的分布。',
},
],
},
/* ── 样例集 —— 三条形状真不同(常备案例即验收案例)───────────── */
samples: [
{
name: 'EOL 料替代',
input: { originalPn: 'TL072CDR', chipType: '运放', scenario: '车规' },
expect: { ports: ['benchmark'] },
/*
* ① 流内常路:上游放行载荷的**真实形状** —— records 里同一颗料出现两次
* (受理记录行 + 分类表行)、还混着结论行;顶层带摊平字段。
* 替代料(客户预授权的 PBY)库内无历史 → 判稳转 B → to-sourcing(E5 复活的那条路)。
*/
name: '流内常路 · Itelma 两颗 C 类(一颗一张选型单按序派 · 替代料无历史 → 转 B 去寻源)',
input: {
rfqRef: 'RFQ MD14.1',
customer_code: 'CU-0142',
customer_name: 'Itelma',
type_c_total: 2,
records: [
{
rfq_ref: 'RFQ MD14.1',
pn: 'BBPY00201209601Y00',
customer_pn: '4-14-01502',
category: '滤波器',
brand: 'YAGEO',
eau: 30000,
remark: '客户预授权替代:PBY201209T-601Y-N(BOM Analog 列)',
type_tag: 'C',
},
{
rfq_ref: 'RFQ MD14.1',
pn: 'BBPY00201209601Y00',
history_days: 47,
market_stable: true,
lifecycle: '要求替代',
type_tag: 'C',
basis: 'EOL/缺货/要求替代 → C',
},
{
rfq_ref: 'RFQ MD14.1',
pn: 'DAG2400L321504XZB01-R0',
customer_pn: '9-00-00251',
category: '天线',
brand: '浙江嘉康',
eau: 30000,
remark: '',
type_tag: 'C',
},
{
rfq_ref: 'RFQ MD14.1',
pn: 'DAG2400L321504XZB01-R0',
history_days: 9999,
market_stable: false,
lifecycle: 'EOL',
type_tag: 'C',
basis: 'EOL/缺货/要求替代 → C',
},
{
rfq_ref: 'RFQ MD14.1',
pn: 'CJTB0101R6N',
customer_pn: '4-00-04102',
category: '集成电路',
brand: 'Jiangsu Changjing',
eau: 60000,
type_tag: 'A',
},
{
rfq_ref: 'RFQ MD14.1',
line_total: 3,
type_a_total: 1,
type_b_total: 0,
type_c_total: 2,
need_alternative: true,
need_sourcing: false,
customer_name: 'Itelma',
},
],
},
expect: { ports: ['roster'] },
},
{
/*
* ② 客户点名替代、替代料**库内行情稳** → 判稳转 A → 全 A 直通定价(to-pricing)。
* FAE 若填回 CJTB0101R6N(库内 43 天·行情稳),就走这条路。
*/
name: '点名替代 · 替代料库内行情稳(转 A 直通定价)',
input: {
rfqRef: 'RFQ-2026-0777',
customer_name: '青岛海洋电子',
records: [
{
rfq_ref: 'RFQ-2026-0777',
pn: 'TL072CDR',
category: '运放',
brand: 'TI',
eau: 3000,
target_price: 22,
remark: '客户点名替代:国产运放优先',
type_tag: 'C',
},
{
rfq_ref: 'RFQ-2026-0777',
pn: 'TL072CDR',
history_days: 9999,
market_stable: false,
lifecycle: '要求替代',
type_tag: 'C',
basis: '客户要求替代 → C',
},
],
},
expect: { ports: ['roster'] },
},
{
/*
* ③ 单飞宽进:没有单号、没有上游清单,只有一段需求(类型 + 参数 + 场景)——
* 归一造案号、需求代号,全标 C,照常走完(输入契约在活动内归一+补全)。
*/
name: '单飞需求 · 只有类型与参数(无单号无清单,宽进归一)',
input: {
subject: '车规运放选型需求',
body: '需要一颗车规级双通道运放:供电 3-36V,AEC-Q100,替代 TL072 系列,年用量约 5K。请选型给替代建议;定案后要给采购去问价。',
},
expect: { ports: ['roster'] },
},
],
+64 -25
View File
@@ -1,45 +1,84 @@
/**
* A2 技能 —— 替代选型的方法经验(AM 08)。
* A2 技能 —— 替代选型 MVP 简化形的方法经验(AM 08)。
*
* **载方法,不载权力**:这里提到的工具都在本活动白名单内;技能不扩权、不改约。
* 渐进披露:常驻上下文只有 `id label:applicability`,正文 Agent 说要才加载。
*
* 剧本改了口径,技能里同一句也要跟着改(A1 的账)—— 2026-08-02 改盘后:
* 选型的实质工作在 FAE 系统外,系统管归一 / 组卡 / 判稳;
* 旧的「圈替代候选(三层筛)」讲的是 FAE 脑子里的方法,系统不替他办,删。
*/
import { skillIdSchema } from '@contracts';
import type { Skill } from '@contracts';
export const ALTERNATIVE_SKILLS: Skill[] = [
{
id: skillIdSchema.parse('alternative.screen-candidates'),
label: '圈替代候选',
applicability: '原型号停产或缺货,要找替代时。',
/*
* 这条讲的是**组卡与核验时的选型口径**(FAE 的实质工作在系统外,但卡面指导书、
* basis 的取舍、收回后的核验都要按同一把尺)—— 2026-08-02 用户指出执行件
* 不是选型工作台后,连同工作单一起补回。
*/
id: skillIdSchema.parse('alternative.grade-discipline'),
label: '替代等级与三层筛',
applicability: '组选型执行单、核验 FAE 填回的定案、或放行前裁量来源可溯时。',
method: [
'三层筛,一层比一层贵,所以要按顺序:',
'① **参数硬指标**:封装、电压范围、精度、温度等级 —— 不满足直接淘汰,不必往下看。',
'② **认证与合规**:车规 / 医疗 / 工业等级不可降级替代,客户没明说也不行。',
'③ **供应与价格**:到这一步才比价,前两层没过的便宜也没用。',
'三层筛,一层比一层贵,按顺序(组卡的 steps 与核验都照它):',
'① 参数硬指标:封装、引脚、电气、温度等级 —— 不满足直接淘汰,不必往下看;',
'② 认证与合规:车规 AEC-Q100/Q200、医疗、工业等级不可降级替代,客户没明说也不行;',
'③ 供应与价格:生命周期在产、货态、品牌接受度 —— 到这一步才比价。',
'',
'「Pin-to-pin 兼容」要看数据手册核实,不要信分销商的一句话 —— ',
'同封装不等于同引脚定义,这个错误要到打样才暴露。',
'等级三码(照 MVP 素材对标表的口径,写别的码基座数不出来):',
'· P2P = 同封装同引脚可直换 —— 必须核对过五项:封装尺寸/引脚定义/电气/温度等级/认证;',
' 同封装不等于同引脚定义,要对数据手册,不要信分销商一句话(这个错要到打样才暴露);',
'· FUNC = 功能兼容,需外围电路或 layout 调整 —— 差异项与改动评估必须写清;',
'· OTHER = 部分兼容须打样验证 —— 关键差异逐项列出、给验证方案。',
'',
'客户备注里已预授权的替代优先采信;点名禁用的品牌不得作候选;',
'是否接受非原厂品牌、参数降级必须问客户,不得替客户决定。',
].join('\n'),
crystallized: [],
knowledgeRefs: ['替代选型判据表'],
knowledgeRefs: ['docs/MVP示例工作流.html §1.5(替代等级/初选核险/圈选替代)'],
version: '1.0.0',
},
{
id: skillIdSchema.parse('alternative.ask-customer'),
label: '什么时候必须问客户',
applicability: '候选已圈定,但存在客户口径不明的项时。',
id: skillIdSchema.parse('alternative.normalize-roster'),
label: '归一询价受理清单',
applicability: '开案第一步:把上游受理记录(或单飞需求)整份落成本活动的清单时。',
method: [
'这几件事**必须问**,不能替客户决定:',
'· 是否接受非原厂品牌;',
'· 是否接受参数降级(哪怕只降一档温度等级);',
'· 是否需要重新打样验证、由谁承担费用。',
'上游载荷里 `records` 是**全部落地行混在一起**的一份 —— 同一颗料出现两三次:',
'带 eau/brand/target_price 的是受理记录行(以它为准),带 basis/history_days 的是',
'分类表行,带 line_total 这类总数的是结论行(不是料,别落进清单)。',
'**按 pn 归并**,一颗料一行,一拍写完(rows 收数组)。',
'',
'问的方式是**跨任务征答**:卡以系统消息送到 BD 的消息要素,就地作答。',
'它不占对方的任务清单——那是一次性的解答请求,不是把责任转过去。',
'',
'问之前先把候选与判据整理好一并发过去:让对方**在数据上作判断**,',
'而不是回一句「你看着办」。',
'三条纪律:',
'① **PN 一个字符不许改**;上游没给的列留空字符串,绝不写 0 顶空。',
'② orig_type 与 type_tag 两列**同一个值起步**(照上游的标):原标是留痕,现标才是本活动的产物。',
'③ 单号 rfq_ref 原样承继;单飞认不出才造「ALT-<型号或类型>」,造完从头用到尾。',
].join('\n'),
crystallized: [],
knowledgeRefs: [],
version: '1.0.0',
knowledgeRefs: ['docs/MVP示例工作流.html §1.5'],
version: '2.0.0',
},
{
id: skillIdSchema.parse('alternative.reclass-stable'),
label: '替代料判稳转标',
applicability: 'FAE 填回替代料之后,要把 C 类行转成 A/B 时。',
method: [
'判稳的尺与三分类 P-03 的 A/B 两行**同一把**:',
'历史价 ≤ 90 天且行情稳 → 转 A(行情完备,可直接取历史锚点);否则 → 转 B(要去寻源补价)。',
'',
'判据从 `crm.history-price-read` 来:**参数 pns 收数组,所有替代料一次传完**。',
'没历史回 historyDays=9999、hit=false —— **那本身就是判据**(新料无历史 → 不稳 → B),',
'不是缺数据,不必替它补。工具回的 lifecycle 若是 EOL/缺货/要求替代,',
'说明替代料选错了 —— 不转标,当替代未果处置。',
'',
'转标写回清单行时:type_tag 只写 A 或 B(照规则,不许臆断);',
'依据一句话落 basis 列(「替代料 X 历史价 N 天·行情稳 → 转 A」),逐条可回核;',
'orig_type 不动。**不许为过判据改标** —— 判据读的是基座按值现数的条数,你改不动。',
].join('\n'),
// 结晶片段:查替代料行情这一步每单都一样 —— 确定性子序列,不必每次让模型重想
crystallized: [{ tool: 'crm.history-price-read', input: { pns: 'pns' } }],
knowledgeRefs: ['三分类规则 P-03(A/B 两行)'],
version: '2.0.0',
},
];
@@ -0,0 +1,409 @@
/**
* A1 两份执行页的样式表 —— **从 PRD 目标件原样搬过来**,不是我照着复述的。
*
* 出处:`~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理需求.html` 的整段 `<style>`,
* 外加 `A1-询价受理记录.html` 里归档件独有的那几件(分类分布条 / 类徽 / 判定表 /
* 结论横幅 / 落地动作 / 完成标)。**一个数值都没改**。
*
* 为什么是搬而不是抄:前三轮我用自己那套外壳「照着做」,块搬齐了、版面是两套 ——
* 章节头成了彩色方块编号、多出摘要与元信息带、字号用 clamp。人一眼就看出不是那份件。
* 目标件是**像素级的答案**,搬过来才不会在复述里走样。
*
* 选择器保留 `.proto-root .app-shell` 前缀,所以模版正文也要照原型那样包两层壳 ——
* 少一层壳,整张样式表一条都不生效(踩过)。
*/
export const PRD_CSS = `
/* ============================================================================
JCAI 员工面工作台 · A1 受理归一的过程成果「询价受理需求」——报告本体
立场:**AI 已从 RFQ 邮件抽取归一**,人在这里审核并就地修订。
两态:读态(AI 稿 + 红/灰占位提示缺项) ⇄ 编辑态(全字段可改,保存 / 取消)
核心是「有效 PN 清单」;清单之上四件受理要素:客户公司 / 联系人 / 客户属性 / 有效 PN。
令牌与既有组件取自 workbench/src/app/(proto)/app.css,数值未改。
⚠️ 修正一处原型 bug:app.css:2062-2064 的 '.jc-rp-sign span'(权重 0,1,1)
盖过了 '.jc-rp-ava'(0,1,0),把头像的 display:grid 覆盖成 block、字色改灰,
导致落款头像里的字掉到圆外。本文件把该规则收窄为 '.jc-rp-sign > div > span'。
新增件:四要素卡 .jc-four/.jc-fcard · 占位胶囊 .jc-ph · 分段控件 .jc-seg ·
来源条 .jc-src · 录入表 .jc-tbl-in
========================================================================== */
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { background: #e6eaf1; -webkit-font-smoothing: antialiased; }
.proto-root .app-shell {
--brand: #3370ff; --brand-deep: #245bdb;
--brand-soft: #e1eaff; --brand-wash: #f0f4ff;
--ai-grad: linear-gradient(135deg, #3370ff, #7f3bf5);
--ink: #1f2329; --ink-2: #41464e; --ink-3: #646a73; --ink-4: #8f959e;
--line: #dee0e3; --line-soft: #e8eaed; --soft: #f8f9fb;
--jcai-bg: #e6eaf1; --cardline: #e1e6ec; --card-shadow: 0 1px 2px rgba(31, 35, 41, .05);
--ok: #278717; --ok-soft: #e8f8e4;
--warn: #de7802; --warn-accent: #ff8800; --warn-soft: #fff4e5;
--danger: #d83931; --danger-soft: #feeae9;
--mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace;
--shadow-brand: 0 6px 16px rgba(51, 112, 255, .22);
--t-fast: .16s cubic-bezier(.3, 0, .2, 1);
--t-med: .26s cubic-bezier(.3, 0, .2, 1);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--jcai-bg); min-height: 100vh; color: var(--ink);
}
.proto-root :where(button, input, select, a, [tabindex]):focus-visible {
outline: 2px solid var(--brand); outline-offset: 2px; border-radius: 8px;
}
.proto-root ::selection { background: var(--brand-soft); }
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
@keyframes jcBlink { 0%,100% { opacity: 1 } 50% { opacity: .35 } }
@keyframes jcStamp { from { opacity: 0; transform: rotate(-6deg) scale(1.7); } to { opacity: .9; transform: rotate(-6deg) scale(1); } }
/* 放开宽度:1160px 是「9 列全展开、一个字都不裁」的最小容器宽度(见下方列 min-width 之和) */
.jc-stage { max-width: 1160px; margin: 0 auto; padding: 22px 16px 52px; }
.proto-root .jc-mono { font-family: var(--mono); font-size: 11px; color: var(--ink-3); }
.proto-root .jc-pulse-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--brand); display: inline-block; animation: jcBlink 1.1s ease-in-out infinite; }
/* ── 报告外壳 ───────────────────────────────────────────────────────── */
.proto-root .jc-report { background: #fff; border: 1px solid var(--cardline); border-radius: 14px; box-shadow: var(--card-shadow); overflow: hidden; animation: rise var(--t-med) both; }
.proto-root .jc-rp-head { position: relative; overflow: hidden; display: flex; align-items: flex-start; gap: 12px; padding: 16px 20px 13px; border-bottom: 2px solid var(--ink); }
/* 题头下缘粗细双线 = 正式件版式 */
.proto-root .jc-rp-head::after { content: ""; position: absolute; left: 0; right: 0; bottom: -4px; height: 1px; background: var(--line); }
/* 题头右上的品牌光晕:与工作台标题区同一签名件 */
.proto-root .jc-rp-head::before { content: ""; position: absolute; right: -70px; top: -96px; width: 240px; height: 240px; border-radius: 999px; background: radial-gradient(circle, rgba(51,112,255,.10), transparent 70%); pointer-events: none; }
.proto-root .jc-rp-head > * { position: relative; z-index: 1; }
.proto-root .jc-rp-badge { flex: none; width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand); }
.proto-root .jc-rp-tit { flex: 1; min-width: 0; }
.proto-root .jc-rp-tit em { display: block; font-style: normal; font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); font-family: var(--mono); }
.proto-root .jc-rp-tit h3 { margin: 4px 0 0; font-size: 18px; font-weight: 800; color: var(--ink); letter-spacing: -.01em; line-height: 1.3; }
.proto-root .jc-rp-wait { display: inline-flex; align-items: center; gap: 6px; flex: none; margin-top: 4px; font-size: 11px; font-weight: 700; border-radius: 999px; padding: 4px 12px; color: var(--brand-deep); background: var(--brand-wash); border: 1px solid var(--brand-soft); }
.proto-root .jc-rp-wait.warn { color: var(--warn); background: var(--warn-soft); border-color: #ffd9a8; }
.proto-root .jc-rp-wait.edit { color: #fff; background: var(--brand); border-color: var(--brand); }
.proto-root .jc-rp-stamp { flex: none; margin-top: 2px; transform: rotate(-6deg); border: 2px solid currentColor; border-radius: 9px; padding: 4px 8px 3px 12px; font-size: 13px; font-weight: 900; letter-spacing: .28em; color: var(--ok); opacity: .9; animation: jcStamp .32s cubic-bezier(.2, 1.4, .4, 1) both; }
.proto-root .jc-rp-bd { padding: 18px 20px 12px; }
.proto-root .jc-rp-part { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 9px; margin: 20px 0 11px; }
.proto-root .jc-rp-part:first-child { margin-top: 0; }
.proto-root .jc-rp-part > b { flex: none; font-size: 11px; font-weight: 800; letter-spacing: .12em; color: var(--ink-3); }
.proto-root .jc-rp-part > .ln { flex: 1; height: 1px; background: var(--line-soft); }
.proto-root .jc-rp-part > em { flex: none; font-style: normal; font-size: 10px; font-weight: 700; color: var(--ink-4); font-family: var(--mono); background: var(--soft); border-radius: 999px; padding: 2px 9px; white-space: nowrap; }
.proto-root .jc-rp-part > em.warn { color: var(--warn); background: var(--warn-soft); }
/* 落款 —— ⚠️ 选择器收窄到 > div > *,别再命中头像那个 span */
.proto-root .jc-rp-foot { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 13px 20px 15px; border-top: 2px solid var(--line); background: #fbfcfd; }
.proto-root .jc-rp-sign { display: flex; align-items: center; gap: 10px; flex: 1 1 200px; min-width: 0; }
.proto-root .jc-rp-ava { flex: 0 0 auto; width: 30px; height: 30px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; line-height: 1; color: #fff; background: var(--brand); }
.proto-root .jc-rp-sign > div { min-width: 0; }
.proto-root .jc-rp-sign > div > b { display: block; font-size: 12px; font-weight: 700; color: var(--ink-2); }
.proto-root .jc-rp-sign > div > span { display: block; margin-top: 1px; font-size: 10.5px; color: var(--ink-4); }
.proto-root .jc-rp-acts { display: flex; align-items: center; gap: 8px; margin-left: auto; flex: none; }
.proto-root .jc-hintline { font-size: 11px; font-weight: 700; color: var(--warn); line-height: 1.5; }
.proto-root .jc-btn { display: inline-flex; align-items: center; gap: 5px; border: 0; border-radius: 7px; padding: 8px 14px; font-size: 12px; font-weight: 600; font-family: inherit; background: var(--brand); color: #fff; cursor: pointer; transition: all var(--t-fast); }
.proto-root .jc-btn:hover:not(:disabled) { background: var(--brand-deep); }
.proto-root .jc-btn:disabled { opacity: .45; cursor: not-allowed; }
.proto-root .jc-btn-ghost { background: #fff; color: var(--ink-2); border: 1px solid var(--line); }
.proto-root .jc-btn-ghost:hover:not(:disabled) { background: var(--brand-wash); color: var(--brand); border-color: var(--brand-soft); }
.proto-root .jc-btn-primary { background: var(--ai-grad); }
.proto-root .jc-btn-primary:hover:not(:disabled) { filter: brightness(1.07); box-shadow: var(--shadow-brand); transform: translateY(-1px); }
/* ── 来源信件:客户 RFQ 邮件原文(复用原型 jc-letter 件,青色 = 收件) ── */
.proto-root .jc-letter { position: relative; border: 1px solid var(--cardline); border-left: 3px solid #0aa294; border-radius: 12px; padding: 14px 16px 15px; background: #fff; box-shadow: var(--card-shadow); }
.proto-root .jc-letter-h { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 11px; }
.proto-root .jc-letter-dir { display: inline-flex; align-items: center; gap: 5px; font-size: 10.5px; font-weight: 800; padding: 3px 9px; border-radius: 999px; background: #e4f8f5; color: #0a7f74; }
.proto-root .jc-letter-h .jc-chip { font-size: 10.5px; padding: 3px 9px; border-radius: 999px; background: var(--brand-wash); color: var(--brand-deep); border: 1px solid var(--brand-soft); }
.proto-root .jc-letter-h > code { margin-left: auto; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
.proto-root .jc-letter-meta { display: flex; flex-direction: column; gap: 4px; margin-bottom: 11px; padding-bottom: 11px; border-bottom: 1px solid var(--line-soft); font-size: 11.5px; color: var(--ink-2); }
.proto-root .jc-letter-meta p { margin: 0; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.proto-root .jc-letter-meta i { flex: none; font-style: normal; width: 44px; font-size: 10px; font-weight: 800; letter-spacing: .06em; color: var(--ink-4); }
.proto-root .jc-letter-meta code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); overflow-wrap: anywhere; }
.proto-root .jc-letter-sub { margin: 0 0 9px; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.45; overflow-wrap: anywhere; }
/* 正文:原文照录,pre-wrap 保原始换行;不截断、不折叠 */
.proto-root .jc-letter-body { margin: 0; font-size: 12.5px; line-height: 1.72; color: var(--ink-2); white-space: pre-wrap; overflow-wrap: anywhere; }
.proto-root .jc-letter-body b { color: var(--ink); font-weight: 700; }
.proto-root .jc-atts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; padding-top: 11px; border-top: 1px dashed var(--line-soft); }
.proto-root .jc-att { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px 5px 8px; border: 1px solid var(--brand-soft); border-radius: 8px; background: var(--brand-wash); font-size: 11.5px; color: var(--brand-deep); }
.proto-root .jc-att svg { flex: none; color: var(--brand); }
.proto-root .jc-att code { font-family: var(--mono); font-size: 10.5px; }
.proto-root .jc-att span { color: var(--ink-4); }
/* 抽取指向:从原文到下方要素的一句衔接 */
.proto-root .jc-derive { display: flex; align-items: flex-start; gap: 8px; margin-top: 11px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); font-size: 11.5px; line-height: 1.7; color: var(--brand-deep); }
.proto-root .jc-derive svg { flex: none; margin-top: 2px; }
/* ── 四要素卡 ───────────────────────────────────────────────────────────
色调字典:每张卡带一套 --tone / --tone-soft / --tone-line,组件只读变量。
卡 3、卡 4 的色调随数据变(老客户绿 / 新客户橙;齐了蓝 / 有缺橙),
所以这条要素带本身就是一行可读的状态,不只是四个格子。 */
.proto-root .jc-four { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 11px; }
.proto-root .jc-fcard {
position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-width: 0;
padding: 14px 14px 14px; border: 1px solid var(--cardline); border-radius: 13px; background: #fff;
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 8px 20px -14px rgba(31,35,41,.35);
transition: transform var(--t-fast), box-shadow var(--t-fast), border-color var(--t-fast);
--tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft);
}
/* 顶缘色棱 + 右上角光晕:卡的身份色,一眼分得清四件是四件 */
.proto-root .jc-fcard::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 3px; background: var(--tone); }
.proto-root .jc-fcard::after { content: ""; position: absolute; right: -46px; top: -52px; width: 132px; height: 132px; border-radius: 999px; background: radial-gradient(circle, var(--tone-soft), transparent 70%); pointer-events: none; }
.proto-root .jc-fcard > * { position: relative; z-index: 1; }
.proto-root .jc-fcard:hover { transform: translateY(-2px); border-color: var(--tone-line); box-shadow: 0 2px 4px rgba(31,35,41,.05), 0 14px 26px -16px rgba(31,35,41,.45); }
.proto-root .jc-fcard.t-teal { --tone: #0aa294; --tone-soft: rgba(10,162,148,.15); --tone-line: #9fe0d9; }
.proto-root .jc-fcard.t-ok { --tone: var(--ok); --tone-soft: rgba(39,135,23,.14); --tone-line: #bfedb4; }
.proto-root .jc-fcard.t-warn { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.12); --tone-line: #ffd9a8; }
.proto-root .jc-fcard-k { display: flex; align-items: center; gap: 6px; font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
.proto-root .jc-fcard-k > i { flex: none; width: 19px; height: 19px; border-radius: 6px; display: inline-grid; place-items: center; background: var(--tone-soft); color: var(--tone); }
.proto-root .jc-fcard-b { display: flex; align-items: center; gap: 10px; min-width: 0; }
.proto-root .jc-fcard-t { min-width: 0; flex: 1; }
/* 一律换行,不省略号——宁可多占一行,也不丢字 */
.proto-root .jc-fcard-t > b { display: block; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.35; overflow-wrap: anywhere; }
.proto-root .jc-fcard-t > span { display: block; margin-top: 2px; font-size: 10.5px; color: var(--ink-4); line-height: 1.55; overflow-wrap: anywhere; }
/* 公司标:圆角方 + AI 渐变 + 外圈光环(方=组织) */
.proto-root .jc-logo { flex: none; width: 38px; height: 38px; border-radius: 12px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; letter-spacing: .04em; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand), 0 0 0 3px rgba(51,112,255,.10); }
/* 人像:圆 + 青色系(圆=人,与方形的组织标一眼分开) */
.proto-root .jc-face { flex: none; width: 38px; height: 38px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; color: #0a7f74; background: #e4f8f5; border: 1px solid #9fe0d9; box-shadow: 0 0 0 3px rgba(10,162,148,.10); }
/* 邮箱做成可点的胶囊,复制按钮嵌在里面——比一行裸文字有抓手 */
/* 邮箱:软底胶囊、无描边——有描边会读成输入框,而读态这里不是可填的 */
.proto-root .jc-mailrow { display: flex; align-items: center; gap: 4px; margin-top: 5px; min-width: 0; max-width: 100%; padding: 3px 4px 3px 9px; border: 0; border-radius: 8px; background: var(--tone-soft); transition: background var(--t-fast); }
.proto-root .jc-mailrow:hover { background: rgba(10,162,148,.2); }
/* 编辑态里它包着输入框,软底会变成「框中框」,去掉 */
.proto-root .jc-report.editing .jc-mailrow { background: transparent; padding: 0; margin-top: 6px; }
.proto-root .jc-mailrow > code { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; line-height: 1.5; font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
.proto-root .jc-copy { flex: none; width: 22px; height: 22px; display: inline-grid; place-items: center; border: 0; border-radius: 6px; background: #fff; color: var(--ink-4); cursor: pointer; box-shadow: 0 1px 2px rgba(31,35,41,.08); transition: all var(--t-fast); }
.proto-root .jc-copy:hover { color: #0aa294; }
.proto-root .jc-copy.ok { color: var(--ok); background: var(--ok-soft); box-shadow: none; }
/* 客户属性徽章 */
.proto-root .jc-tagbig { display: inline-flex; align-items: center; gap: 6px; align-self: flex-start; padding: 7px 14px; border-radius: 999px; font-size: 13.5px; font-weight: 800; line-height: 1; color: var(--tone); background: var(--tone-soft); border: 1px solid var(--tone-line); }
.proto-root .jc-fcard-note { font-size: 10.5px; color: var(--ink-4); line-height: 1.55; }
.proto-root .jc-fcard-note b { color: var(--ink-2); font-weight: 700; }
.proto-root .jc-fcard-note b.bad { color: var(--tone); font-weight: 800; }
/* 成交迷你柱:把「台账 6 笔成交」从一句话变成看得见的节奏 */
.proto-root .jc-spark { display: flex; align-items: flex-end; gap: 4px; height: 26px; }
.proto-root .jc-spark > i { flex: 1 1 0; min-width: 4px; max-width: 15px; border-radius: 3px 3px 1px 1px; background: var(--tone); opacity: .3; transition: opacity var(--t-fast), height var(--t-med); }
.proto-root .jc-spark > i:last-child { opacity: 1; }
.proto-root .jc-spark:hover > i { opacity: .55; }
.proto-root .jc-spark:hover > i:last-child { opacity: 1; }
.proto-root .jc-spark.empty { align-items: stretch; }
.proto-root .jc-spark.empty > i { height: 100%; opacity: 1; background: repeating-linear-gradient(45deg, var(--tone-soft) 0 5px, transparent 5px 10px); border: 1px dashed var(--tone-line); border-radius: 5px; }
/* 有效 PN 计数 */
.proto-root .jc-count { display: flex; align-items: baseline; gap: 6px; }
.proto-root .jc-count > b { font-family: var(--mono); font-size: 32px; font-weight: 800; line-height: .95; color: var(--ink); letter-spacing: -.04em; }
.proto-root .jc-count > span { font-size: 11px; color: var(--ink-4); }
/* 分段刻度条:一格 = 一条 PN,缺必填的那格直接标红——比一根实心进度条信息量大 */
.proto-root .jc-segmeter { display: flex; gap: 3px; }
.proto-root .jc-segmeter > i { flex: 1; height: 7px; border-radius: 2px; background: var(--ok); transition: background var(--t-fast); }
.proto-root .jc-segmeter > i.bad { background: var(--danger); }
.proto-root .jc-lockhint { display: inline-flex; align-items: center; gap: 4px; font-size: 10px; color: var(--ink-4); }
/* ── 占位胶囊:必填=红虚线,选填=灰虚线;**都带文字**,不靠颜色单打 ── */
.proto-root .jc-ph { display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700; line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed; }
.proto-root .jc-ph.req { color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
.proto-root .jc-ph.opt { color: var(--ink-4); background: var(--soft); border-color: var(--line); }
/* ── 分段控件(新客户 / 老客户)── */
.proto-root .jc-seg { display: inline-flex; padding: 2px; gap: 2px; border: 1px solid var(--cardline); border-radius: 9px; background: var(--soft); align-self: flex-start; }
.proto-root .jc-seg button { border: 0; border-radius: 7px; padding: 6px 13px; font: inherit; font-size: 11.5px; font-weight: 700; color: var(--ink-3); background: transparent; cursor: pointer; transition: all var(--t-fast); }
.proto-root .jc-seg button:hover:not(.on) { color: var(--brand); }
.proto-root .jc-seg button.on { background: #fff; color: var(--ink); box-shadow: 0 1px 3px rgba(31,35,41,.12); }
/* ── 表:读态与编辑态共用骨架 ─────────────────────────────────────── */
.proto-root .jc-tbl-wrap { overflow-x: auto; border: 1px solid var(--line-soft); border-radius: 10px; }
.proto-root .jc-tbl-in { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; }
.proto-root .jc-tbl-in th { background: var(--soft); text-align: left; padding: 8px 9px; font-size: 10.5px; font-weight: 800; letter-spacing: .03em; color: var(--ink-3); white-space: nowrap; border-bottom: 1px solid var(--line-soft); }
.proto-root .jc-tbl-in th.req::after { content: "*"; margin-left: 1px; color: var(--danger); font-size: 10px; }
.proto-root .jc-tbl-in td { padding: 4px 5px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
.proto-root .jc-tbl-in tr:last-child td { border-bottom: 0; }
.proto-root .jc-tbl-in tbody tr:hover td { background: #f7f9fd; }
/* 缺必填的整行浅红打底 + 行首红标——不用找红胶囊,一眼扫到是哪几行 */
.proto-root .jc-tbl-in tr.miss td { background: #fffbfb; }
.proto-root .jc-tbl-in tbody tr.miss:hover td { background: #fff5f5; }
.proto-root .jc-tbl-in td.jc-rn { position: relative; width: 38px; padding: 4px 6px 4px 13px; white-space: nowrap; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); font-weight: 800; }
/* 行首红标只在表态用 ::after —— 卡片态的 ::after 被「第 N 条」占着,不能抢 */
@media (min-width: 961px) {
.proto-root .jc-tbl-in tr.miss td.jc-rn::after { content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger); }
}
.proto-root .jc-rndot { display: inline-block; width: 5px; height: 5px; margin-left: 4px; border-radius: 999px; background: var(--danger); vertical-align: 2px; }
/* 读态单元格 */
.proto-root .jc-vcell { display: block; padding: 6px 6px; font-size: 12px; color: var(--ink); line-height: 1.5; overflow-wrap: anywhere; }
.proto-root .jc-vcell.mono { font-family: var(--mono); font-size: 11px; color: var(--ink-2); }
.proto-root .jc-vcell.num { font-family: var(--mono); font-size: 11.5px; text-align: right; }
/* 编辑态单元格:安静的电子表格 */
.proto-root .jc-cell { width: 100%; min-width: 64px; border: 1px solid var(--line); border-radius: 6px; padding: 6px 7px; font: inherit; font-size: 12px; color: var(--ink); background: #fff; transition: border-color var(--t-fast), box-shadow var(--t-fast); }
.proto-root .jc-cell.mono { font-family: var(--mono); font-size: 11px; }
.proto-root .jc-cell.num { text-align: right; font-family: var(--mono); font-size: 11.5px; }
.proto-root .jc-cell::placeholder { color: var(--ink-4); }
.proto-root .jc-cell:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-wash); outline: none; }
.proto-root .jc-cell.req-empty { border-color: #f3b6b3; background: #fffafa; }
.proto-root .jc-cell.req-empty::placeholder { color: var(--danger); }
/* 备注这类长文本用文本域:能换行、随内容长高,不裁字 */
.proto-root textarea.jc-cell { display: block; resize: vertical; overflow: hidden; min-height: 31px; line-height: 1.55; }
/* 编辑态的整表提示带 */
.proto-root .jc-editbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); border: 1px solid var(--brand-soft); font-size: 11.5px; color: var(--brand-deep); }
.proto-root .jc-editbar svg { flex: none; }
.proto-root .jc-editbar .sp { flex: 1 1 auto; }
/* 剔除行说明 */
.proto-root .jc-drop { display: flex; align-items: flex-start; gap: 8px; margin-top: 10px; padding: 9px 12px; border-radius: 9px; border: 1px dashed var(--line); background: var(--soft); font-size: 11.5px; color: var(--ink-3); line-height: 1.6; }
.proto-root .jc-drop svg { flex: none; margin-top: 2px; color: var(--ink-4); }
.proto-root .jc-drop code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
.proto-root .jc-callout { display: flex; gap: 8px; align-items: flex-start; border-radius: 9px; padding: 11px 13px; font-size: 12.5px; line-height: 1.75; }
.proto-root .jc-callout p { margin: 0; }
.proto-root .jc-callout svg { flex: none; margin-top: 2px; }
.proto-root .jc-callout.t-mute { background: var(--soft); color: var(--ink-2) }
@media (prefers-reduced-motion: reduce) {
.proto-root .jc-pulse-dot { animation: none; }
.proto-root .jc-report, .proto-root .jc-rp-stamp { animation: none; }
}
/* ══════════════════════════════════════════════════════════════════════════
响应式:宁可另起一行,也不损失完整度
≥961px 六列全展开的表(列 min-width 之和 878px,容器有余量,绝不裁字、绝不横滚)
≤960px 表 → 卡片:一条 PN 一张卡、一行一字段(标签 / 值),无横滚、无省略
≤560px 字段行再拆成上下两行(标签在上、值在下)
══════════════════════════════════════════════════════════════════════════ */
@media (max-width: 1040px) { .proto-root .jc-four { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 560px) { .proto-root .jc-four { grid-template-columns: 1fr; } }
@media (max-width: 960px) {
.proto-root .jc-tbl-wrap { overflow: visible; border: 0; border-radius: 0; background: transparent; }
.proto-root .jc-tbl-in,
.proto-root .jc-tbl-in tbody,
.proto-root .jc-tbl-in tr,
.proto-root .jc-tbl-in td { display: block; width: 100%; }
.proto-root .jc-tbl-in thead { display: none; }
/* 一条 PN = 一张卡 */
.proto-root .jc-tbl-in tr { margin-bottom: 10px; padding: 4px 13px 9px; border: 1px solid var(--line-soft); border-radius: 11px; background: #fff; box-shadow: var(--card-shadow); }
.proto-root .jc-tbl-in tr:last-child { margin-bottom: 0; }
.proto-root .jc-tbl-in tbody tr:hover td { background: transparent; }
.proto-root .jc-tbl-in tr.miss { border-color: #f3b6b3; background: #fffbfb; }
/* 卡头:第 N 条 */
.proto-root .jc-tbl-in td.jc-rn { padding: 8px 0 8px; margin-bottom: 4px; border-bottom: 1px solid var(--line-soft); font-family: inherit; font-size: 12px; font-weight: 800; color: var(--ink-2); }
.proto-root .jc-tbl-in td.jc-rn::before { content: "第 "; }
.proto-root .jc-tbl-in td.jc-rn::after { content: " 条"; }
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); }
/* 字段行:左标签 + 右值,值超长自动折行 */
.proto-root .jc-tbl-in td:not(.jc-rn) { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 10px; align-items: center; padding: 6px 0; border-bottom: 1px dashed var(--line-soft); }
.proto-root .jc-tbl-in tr td:last-child { border-bottom: 0; padding-bottom: 2px; }
.proto-root .jc-tbl-in td:not(.jc-rn)::before {
content: attr(data-h); font-size: 10.5px; font-weight: 700; letter-spacing: .02em; color: var(--ink-4); line-height: 1.5;
}
.proto-root .jc-tbl-in td[data-req="1"]:not(.jc-rn)::before { content: attr(data-h) " *"; }
.proto-root .jc-tbl-in .jc-vcell { padding: 0; overflow-wrap: anywhere; }
.proto-root .jc-tbl-in .jc-vcell.num { text-align: left; }
.proto-root .jc-tbl-in .jc-cell.num { text-align: left; }
.proto-root .jc-tbl-in .jc-cell { min-width: 0; }
}
/* 窄屏:落款三件各占一行,动作按钮撑满一行 */
@media (max-width: 560px) {
.proto-root .jc-rp-acts { margin-left: 0; width: 100%; flex-wrap: wrap; }
.proto-root .jc-rp-acts .jc-btn { flex: 1 1 auto; justify-content: center; }
.proto-root .jc-hintline { flex: 1 1 100%; }
.proto-root .jc-tbl-in td:not(.jc-rn) { grid-template-columns: 1fr; gap: 3px; align-items: start; }
}
/* ══ 以下取自 A1-询价受理记录.html(归档件独有的件)══ */
/* ── 料号分类:分布条 + 图例 ───────────────────────────────────────── */
.proto-root .jc-dist { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 13px 15px; border: 1px solid var(--line-soft); border-radius: 12px; background: linear-gradient(180deg, #fbfcfe, #fff); }
.proto-root .jc-distbar { flex: 1 1 320px; min-width: 260px; display: flex; gap: 3px; }
.proto-root .jc-distbar > i { height: 30px; border-radius: 4px; display: grid; place-items: center; font-size: 10.5px; font-weight: 800; color: #fff; font-family: var(--mono); }
.proto-root .jc-distbar > i.a { background: var(--ok); }
.proto-root .jc-distbar > i.b { background: var(--brand); }
.proto-root .jc-distbar > i.c { background: var(--warn-accent); }
.proto-root .jc-distlg { display: flex; flex-wrap: wrap; gap: 8px 18px; }
.proto-root .jc-distlg > span { display: inline-flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--ink-3); }
.proto-root .jc-distlg em { width: 10px; height: 10px; border-radius: 3px; }
.proto-root .jc-distlg em.a { background: var(--ok); } .proto-root .jc-distlg em.b { background: var(--brand); } .proto-root .jc-distlg em.c { background: var(--warn-accent); }
.proto-root .jc-distlg b { font-family: var(--mono); font-weight: 800; color: var(--ink); }
/* 类别徽 A / B / C */
.proto-root .jc-tbadge { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 6px; font-family: var(--mono); font-size: 11px; font-weight: 800; line-height: 1; color: #fff; }
.proto-root .jc-tbadge.a { background: var(--ok); } .proto-root .jc-tbadge.b { background: var(--brand); } .proto-root .jc-tbadge.c { background: var(--warn-accent); }
/* ── 结论与去向 ─────────────────────────────────────────────────────────
G2 是**分支网关**:三条互斥条件按序求值,只命中一条、只落一个活动。
所以这里不是三张并列的去向卡,而是一张**判定表 + 一条结论**。 */
/* 判定表:一行一条规则,命中行上色留痕(DMN 口径) */
.proto-root .jc-dmn { border: 1px solid var(--line-soft); border-radius: 12px; overflow: hidden; }
.proto-root .jc-dmn-h, .proto-root .jc-dmn-r { display: grid; grid-template-columns: 40px minmax(0, 1fr) 170px 200px; align-items: center; gap: 12px; padding: 9px 14px; }
.proto-root .jc-dmn-h { background: var(--soft); border-bottom: 1px solid var(--line-soft); font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); }
.proto-root .jc-dmn-r { font-size: 12px; color: var(--ink-4); background: #fff; }
.proto-root .jc-dmn-r + .jc-dmn-r { border-top: 1px solid var(--line-soft); }
.proto-root .jc-dmn-i { font-family: var(--mono); font-size: 11px; font-weight: 800; }
.proto-root .jc-dmn-c { line-height: 1.6; overflow-wrap: anywhere; }
.proto-root .jc-dmn-t { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; }
.proto-root .jc-dmn-t code { font-family: var(--mono); font-size: 10.5px; font-weight: 800; color: var(--ink-4); background: var(--soft); border: 1px solid var(--line); border-radius: 5px; padding: 2px 6px; }
.proto-root .jc-dmn-v { font-size: 11px; line-height: 1.55; }
/* 命中行:上色 + 左缘棱 + 加粗 */
.proto-root .jc-dmn-r.hit { position: relative; background: var(--tone-soft); color: var(--ink-2); }
.proto-root .jc-dmn-r.hit::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--tone); }
.proto-root .jc-dmn-r.hit .jc-dmn-i { color: var(--tone); }
.proto-root .jc-dmn-r.hit .jc-dmn-c { color: var(--ink); }
.proto-root .jc-dmn-r.hit .jc-dmn-c b { color: var(--tone); font-weight: 800; }
.proto-root .jc-dmn-r.hit .jc-dmn-t { color: var(--ink); }
.proto-root .jc-dmn-r.hit .jc-dmn-t code { color: #fff; background: var(--tone); border-color: var(--tone); }
.proto-root .jc-dmn-v.y { display: inline-flex; align-items: center; gap: 5px; font-weight: 800; color: var(--tone); }
.proto-root .jc-dmn-v.n { color: var(--ink-4); }
/* 色调随命中类别 */
.proto-root .jc-dmn.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.10); }
.proto-root .jc-dmn.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.09); }
.proto-root .jc-dmn.k-c { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.11); }
/* 结论横幅:命中条件 → 唯一去向 */
.proto-root .jc-verdict {
position: relative; overflow: hidden; display: flex; align-items: stretch; flex-wrap: wrap; gap: 16px;
margin-top: 11px; padding: 16px 18px; border: 1px solid var(--tone-line); border-radius: 13px;
background: linear-gradient(105deg, var(--tone-soft), #fff 62%);
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 10px 24px -16px rgba(31,35,41,.4);
--tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.14); --tone-line: #ffd9a8;
}
.proto-root .jc-verdict.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.13); --tone-line: #bfedb4; }
.proto-root .jc-verdict.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft); }
.proto-root .jc-verdict::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 5px; background: var(--tone); }
.proto-root .jc-verdict > * { position: relative; z-index: 1; }
.proto-root .jc-vd-l { flex: 1 1 300px; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
.proto-root .jc-vd-k { font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
.proto-root .jc-vd-cond { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 15px; font-weight: 800; color: var(--ink); }
.proto-root .jc-vd-why { margin: 0; font-size: 11.5px; line-height: 1.7; color: var(--ink-3); overflow-wrap: anywhere; }
.proto-root .jc-vd-why code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
.proto-root .jc-vd-arrow { flex: none; align-self: center; color: var(--tone); }
.proto-root .jc-vd-r { flex: 1 1 260px; min-width: 0; display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-radius: 11px; background: #fff; border: 1px solid var(--tone-line); }
.proto-root .jc-vd-node { display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 800; color: var(--ink); }
.proto-root .jc-vd-node code { font-family: var(--mono); font-size: 12px; font-weight: 800; color: #fff; background: var(--tone); border-radius: 6px; padding: 3px 8px; }
.proto-root .jc-vd-who { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--ink-2); }
.proto-root .jc-vd-who > i { width: 20px; height: 20px; border-radius: 999px; display: inline-grid; place-items: center; font-style: normal; font-size: 10px; font-weight: 700; color: #fff; background: var(--tone); }
.proto-root .jc-vd-next { margin: 0; padding-top: 8px; border-top: 1px dashed var(--line-soft); font-size: 10.5px; line-height: 1.65; color: var(--ink-4); }
.proto-root .jc-vd-next b { color: var(--ink-2); font-weight: 700; }
/* 命中分支携带的条目 */
.proto-root .jc-pnlist { display: flex; flex-wrap: wrap; gap: 5px; margin: 0; padding: 0; list-style: none; }
.proto-root .jc-pnlist > li { display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 999px; background: #fff; border: 1px solid var(--tone-line); font-size: 11px; color: var(--ink-2); overflow-wrap: anywhere; }
.proto-root .jc-pnlist > li > i { flex: none; width: 5px; height: 5px; border-radius: 999px; background: var(--tone); }
.proto-root .jc-pnlist code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
/* ── 落地动作 ───────────────────────────────────────────────────────── */
.proto-root .jc-acts { display: flex; flex-direction: column; gap: 0; border: 1px solid var(--line-soft); border-radius: 11px; overflow: hidden; }
.proto-root .jc-act { display: flex; align-items: flex-start; gap: 9px; padding: 10px 13px; font-size: 12px; color: var(--ink-2); line-height: 1.6; background: #fff; }
.proto-root .jc-act + .jc-act { border-top: 1px solid var(--line-soft); }
.proto-root .jc-act > i { flex: none; width: 18px; height: 18px; margin-top: 1px; border-radius: 999px; display: inline-grid; place-items: center; color: #fff; background: var(--ok); }
.proto-root .jc-act b { font-weight: 700; color: var(--ink); }
.proto-root .jc-act span { color: var(--ink-4); }
.proto-root .jc-act code { font-family: var(--mono); font-size: 10.5px; color: var(--brand-deep); background: var(--brand-wash); border-radius: 4px; padding: 1px 5px; }
.proto-root .jc-done-tag { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; flex: none; padding: 6px 13px; border-radius: 999px; font-size: 11.5px; font-weight: 800; color: var(--ok); background: #fff; border: 1px solid #bfedb4; }
.proto-root .jc-vcell.dim { color: var(--ink-4); }
.proto-root .jc-tbl-in td.jc-ty { width: 44px; text-align: center; }
.proto-root .jc-tbl-in td.jc-ty { width: auto; text-align: left; }
`;
/**
* 图标精灵 —— 与样式表同一出处,两份目标件的 `<symbol>` 取并集。
* 沙箱里取不到图标字体,所以图标一律内联;`<use href="#i-xxx">` 引的就是这一坨。
*/
export const PRD_SPRITE = `<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs><symbol id="i-alert" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></symbol><symbol id="i-arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol><symbol id="i-boxes" viewBox="0 0 24 24"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></symbol><symbol id="i-building" viewBox="0 0 24 24"><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/><path d="M10 18h4"/></symbol><symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol><symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol><symbol id="i-file-check" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="m9 15 2 2 4-4"/></symbol><symbol id="i-file-text" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></symbol><symbol id="i-git-branch" viewBox="0 0 24 24"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></symbol><symbol id="i-history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></symbol><symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></symbol><symbol id="i-lock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol><symbol id="i-mail-open" viewBox="0 0 24 24"><path d="M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0z"/><path d="m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10"/></symbol><symbol id="i-pen" viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z"/></symbol><symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol><symbol id="i-sheet" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M8 13h2"/><path d="M14 13h2"/><path d="M8 17h2"/><path d="M14 17h2"/></symbol><symbol id="i-shield-check" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></symbol><symbol id="i-user" viewBox="0 0 24 24"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/></symbol><symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></symbol></defs></svg>`;
+629 -21
View File
@@ -1,26 +1,634 @@
/**
* A2 视图模版 —— 案例全景(AM 07:loop 按案例模型的阶段与里程碑组织汇报)。
* A2 替代选型的两张报告 —— 2026-08-03 第四轮重设计(隔离设计代理从零起版面;
* standalone 目标件先行,全绿后按《HTML报告生成规范》§7 机械平移成本文件)。
*
* · `ALT_WORKSHEET` 选型执行工单(S1/S2 在办;**一颗待选型料 = 一页**,tr 挂 data-page):
* 档案头(大字原厂 PN + 为什么要替代)→ 蓝图带(原厂芯片 → 五道核对 → 替代插座:
* 空位虚线红槽、定了实线绿槽 —— 概念图形是第一语言)→ 题面/定案双栏
* (左边客户给的,右边 FAE 的产出;等级三枚 radio 直接选,口径贴在钮上)→
* 方法脚注(五项核对 · 三层筛 · 未果通道)。
* 页容器是**真表格块化**(table.alt-pages > tbody > tr):宿主的加行/删行写死找 tr,
* 「补一颗漏掉的待选型料」与「这颗不该选型(剔除)」才真能用。
* · `ALT_ROSTER` 选型验收报告(S3/放行/归档;**通篇只读**):收口总览四格账 + 选型账一行
* → 询价受理清单大表(原料 → 原标 → 替代定案 → 现标 → 依据)→ 落边图
* (清单 → 还有 B 类? → A3 渠道寻源 / A4 成本定价,命中边实线亮 + 流光,
* 由 case:routeKey 投影,一条没命中两边全灰虚 —— 不冒充判过)→ 结论带 → 落地动作。
* 就地改走验收卡的 editableAtRelease 表,这里一个 data-col / data-edit 都没有。
*
* 设计目标件(standalone,浏览器直接可开,与本文件 1:1):
* ~/.claude/jobs/0aebbba5/tmp/target-alt-worksheet.html / target-alt-roster.html
*
* ── 工程纪律(都踩过,一条不破)──────────────────────────────────────
* ① 主键格必挂 `data-col`(行的钥匙;在 .decide 的 keyrow 里,开闸才现身,
* 也是「补一页」落键的地方 —— 不填不落库);
* ② data-rows 行容器的标签名不得在行模版内部再现(tbody 的行内无 tbody);
* ③ 收值按「input 的祖父」归行 —— 一页里所有输入格(含 radio)的祖父都是同一个 .decide;
* ④ radio(name=alt_grade_pick)不写 checked:当前档由 data-class-of 投影描虚线,
* 「没点」=「不改」;渲染期基座按行号缀名分组。已知边角:人「补一页」克隆末页时
* 克隆页 radio 与末页同组,新页选档会把末页的勾在 DOM 上顶掉 —— 但未选中列
* 不进写回载荷,台账无恙;
* ⑤ 只有待选型(orig_type=C)的行成页:`.alt-page:not(.v-c){display:none}`,判断在样式表;
* ⑥ 模版不算不判;列名逐字照 manifest 声明抄;逐条数据走 `mine:quote_alternative.list`;
* ⑦ 空占位三级全局口径:**必填红 / 建议填橘 / 选填灰**(required / expect / 其余),
* 蓝图插座的空位也归「必填」。
*/
import { kvCard, noteCard, stagesCard, viewShell } from '@sdk';
import { PRD_CSS, PRD_SPRITE } from './prd-style.ts';
export const ALTERNATIVE_CASE = viewShell({
title: '替代选型 · 案例全景',
kicker: 'A2 · 案例活动(loop)',
tone: 'case',
/** 一枚图标 —— `use` 引精灵里的 symbol */
const ico = (name: string, size = 11): string =>
`<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-${name}"/></svg>`;
/** 本件补充精灵:五项核对与等级示意的 6 枚 symbol(lucide 线稿风,随模版走,不动 prd-style) */
const EXTRA_SPRITE = `<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<symbol id="i-ruler" viewBox="0 0 24 24"><path d="M3 17 17 3l4 4L7 21z"/><path d="m8.5 12.5 1.8 1.8M11.5 9.5l1.8 1.8M14.5 6.5l1.8 1.8"/></symbol>
<symbol id="i-pins" viewBox="0 0 24 24"><path d="M4 7h5M4 12h5M4 17h5M15 7h5M15 12h5M15 17h5"/><rect x="9" y="4" width="6" height="16" rx="1.5"/></symbol>
<symbol id="i-bolt" viewBox="0 0 24 24"><path d="M13 2 4 14h6l-1 8 9-12h-6z"/></symbol>
<symbol id="i-thermo" viewBox="0 0 24 24"><path d="M12 3a2 2 0 0 0-2 2v8.5a4 4 0 1 0 4 0V5a2 2 0 0 0-2-2z"/><path d="M12 9v6"/></symbol>
<symbol id="i-flask" viewBox="0 0 24 24"><path d="M10 2v6L4.5 18a2 2 0 0 0 1.8 3h11.4a2 2 0 0 0 1.8-3L14 8V2"/><path d="M8.5 2h7"/><path d="M7.5 15h9"/></symbol>
<symbol id="i-gear" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3.2"/><path d="M12 2.5v3M12 18.5v3M2.5 12h3M18.5 12h3M5.2 5.2l2.1 2.1M16.7 16.7l2.1 2.1M18.8 5.2l-2.1 2.1M7.3 16.7l-2.1 2.1"/></symbol>
</svg>`;
/* ==================== 编辑机构 + 家族补丁(两张共用)====================
闸/红带/加行/撤行/孪生件开合 —— 拷自 A1 报告的 BIND_CSS 并按本件容器适配:
A1 的开合钉在 .jc-tbl-in 上,本件工单的容器是 table.alt-pages(.on 由宿主落在
table 上,data-edit 在 tbody);类徽三色与铺满工作区的两行也一并收在这里。 */
const HARNESS_CSS = `
/* 铺满工作区:件就是这一屏的正文,不是嵌在里面的另一个页面 */
.jc-stage { max-width: none; padding: 0 0 4px; }
.proto-root .jc-report { border: 0; border-radius: 0; box-shadow: none; }
.proto-root .jc-logo svg, .proto-root .jc-face svg, .proto-root .jc-rp-ava svg { width: 17px; height: 17px; }
/* 孪生输入件默认收着 —— 件首先是给人看的 */
.proto-root .jc-cell { display: none; }
/* 类徽三色(A/B/C) */
.proto-root .jc-tbadge { background: var(--ink-4); }
.proto-root .jc-tbadge.v-a { background: var(--ok); }
.proto-root .jc-tbadge.v-b { background: var(--brand); }
.proto-root .jc-tbadge.v-c { background: var(--warn-accent); }
/* 那道闸 —— 默认「已锁定 + 编辑」,开了变成「取消 / 保存」;只在可改屏现身 */
.proto-root .jc-editgate { display: none; }
html.editable .proto-root .jc-editgate {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
margin-bottom: 10px; padding: 8px 12px; border-radius: 9px;
background: var(--soft); border: 1px solid var(--line-soft);
font-size: 11.5px; color: var(--ink-3);
}
.proto-root .jc-editgate-t { display: inline-flex; align-items: center; gap: 6px; flex: 1 1 auto; min-width: 0; }
.proto-root .jc-editgate-t b { color: var(--ink-2); font-weight: 700; }
.proto-root .jc-egbtn {
flex: none; display: inline-flex; align-items: center; gap: 4px; cursor: pointer;
border: 1px solid var(--line); border-radius: 7px; padding: 5px 11px;
font: inherit; font-size: 11.5px; font-weight: 700; color: var(--ink-2); background: #fff;
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
}
.proto-root .jc-egbtn:hover { border-color: var(--brand); color: var(--brand); }
.proto-root .jc-egbtn.save { color: #fff; background: var(--brand); border-color: var(--brand); }
.proto-root .jc-egbtn.save:hover { background: var(--brand-deep); border-color: var(--brand-deep); }
.proto-root .jc-editgate .jc-egbtn.ghost, .proto-root .jc-editgate .jc-egbtn.save { display: none; }
.proto-root .jc-editgate.on { background: var(--brand-wash); border-color: var(--brand-soft); color: var(--brand-deep); }
.proto-root .jc-editgate.on .jc-egbtn.open { display: none; }
.proto-root .jc-editgate.on .jc-egbtn.ghost, .proto-root .jc-editgate.on .jc-egbtn.save { display: inline-flex; }
.proto-root .jc-editgate.on .jc-editgate-t { visibility: hidden; }
/* 缺项红带 —— 只在可改屏出,一格不缺自动收(v-0 那档隐藏,判断在样式表) */
.proto-root .jc-gapbar { display: none; }
html.editable .proto-root .jc-gapbar {
display: flex; align-items: flex-start; gap: 8px;
margin: 0 0 10px; padding: 9px 12px; border-radius: 9px;
background: #fff5f5; border: 1px solid #f3b6b3; color: var(--danger);
font-size: 12px; line-height: 1.65;
}
.proto-root .jc-gapbar svg { flex: 0 0 auto; margin-top: 2px; }
.proto-root .jc-gapbar b { font-weight: 800; }
.proto-root .jc-gapbar span { color: #9a3b36; }
html.editable .proto-root .jc-gapbar.v-0 { display: none; }
/* 「怎么改」的提示带只在开了闸之后说 —— 锁着的时候它是句废话 */
.proto-root .jc-editbar { display: none; }
.proto-root .jc-editgate.on + .jc-editbar {
display: flex; align-items: flex-start; gap: 8px;
margin: 0 0 10px; padding: 9px 12px; border-radius: 9px;
background: var(--brand-wash); border: 1px solid var(--brand-soft);
font-size: 12px; line-height: 1.65; color: var(--ink-2);
}
.proto-root .jc-editbar svg { flex: 0 0 auto; margin-top: 2px; color: var(--brand); }
/* 表尾「补一颗」—— 只在这一屏可改且开了闸时现身(容器是 table.alt-pages) */
.proto-root .jc-addrow { display: none; }
html.editable .proto-root table.alt-pages.on + .jc-addrow {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin: -2px 0 12px; padding: 7px 12px; border-radius: 9px;
background: var(--soft); border: 1px dashed var(--line);
}
.proto-root .jc-addrow em { font-style: normal; font-size: 11px; color: var(--ink-3); }
`;
/**
* 一份件的外壳 —— 照家族三层壳:.proto-root > .app-shell > .jc-stage > article.jc-report;
* 题头(徽 + kicker + 题 + 状态灯)与落款(署名 + 流位签)与 A1 同一套版面语言。
*/
const doc = (input: {
badge: string;
kicker: string;
title: string;
titleRef: string;
state: string;
css: string;
body: string;
foot: string;
tag: string;
}): string => `<style>${PRD_CSS}${HARNESS_CSS}${input.css}</style>
${PRD_SPRITE}${EXTRA_SPRITE}
<div class="proto-root"><div class="app-shell"><div class="jc-stage">
<article class="jc-report">
<header class="jc-rp-head">
<div class="jc-rp-badge">${ico(input.badge, 17)}</div>
<div class="jc-rp-tit">
<em>${input.kicker}</em>
<h3>${input.title} <span data-slot="${input.titleRef}"></span></h3>
</div>
<span class="jc-rp-wait">${input.state}</span>
</header>
<div class="jc-rp-bd">${input.body}</div>
<footer class="jc-rp-foot">
<div class="jc-rp-sign">
<span class="jc-rp-ava">${ico('user', 15)}</span>
<div><b data-slot="case:owner"></b><span>${input.foot}</span></div>
</div>
<span class="jc-done-tag">${ico('git-branch', 13)}${input.tag}</span>
</footer>
</article>
</div></div></div>`;
/** 章节头 —— 家族的 jc-rp-part:小标签 + 细线 + 右端小签 */
const part = (label: string, aside = '', tone = ''): string =>
`<div class="jc-rp-part"><b>${label}</b><i class="ln"></i>${aside === '' ? '' : `<em class="${tone}">${aside}</em>`}</div>`;
/* ==================== 选型执行工单(一颗一页)==================== */
const SHEET_CSS = `
/* 编辑区容器:左缘品牌竖线把「这一段可就地修订」从说明区里立出来 */
.proto-root .jc-zone.z-edit { border-left: 3px solid var(--brand); padding-left: 14px; margin: 2px 0 14px; }
/* ── 页容器:真表格块化(tbody 挂行来源与行地址,tr 即一页,宿主翻页认 [data-page])── */
.proto-root table.alt-pages { display: block; width: 100%; border-collapse: separate; border-spacing: 0; }
.proto-root .alt-pages tbody { display: block; }
.proto-root .alt-pages tr.alt-page { display: block; margin: 0 0 16px; }
.proto-root .alt-pages td.alt-cell { display: block; padding: 0; }
/* 只有待选型(原标 C)的行成页;A/B 行原样承继,不进这份工单(判断在样式表) */
.proto-root .alt-pages tr.alt-page:not(.v-c) { display: none; }
/* ── 档案页外框 ── */
.proto-root .alt-cell { border: 1px solid var(--line); border-radius: 12px; overflow: hidden; background: #fff; }
/* ── 档案头:这颗料的出身 ── */
.proto-root .archrow { display: flex; align-items: flex-start; gap: 16px; padding: 13px 16px 11px; border-bottom: 1px solid var(--line-soft); }
.proto-root .archrow .chip-id { flex: 1 1 auto; min-width: 0; }
.proto-root .archrow .chip-id em { display: block; font-style: normal; font-size: 10px; font-weight: 800; letter-spacing: .14em; color: var(--ink-4); margin-bottom: 3px; }
.proto-root .archrow .chip-id .pn { display: block; font-size: 23px; font-weight: 800; letter-spacing: -.3px; color: var(--ink); word-break: break-all; line-height: 1.2; }
.proto-root .archrow .why { flex: 0 1 300px; min-width: 210px; border-left: 1px solid var(--line-soft); padding-left: 14px; }
.proto-root .archrow .why i { display: inline-flex; align-items: center; gap: 5px; font-style: normal; font-size: 10px; font-weight: 800; letter-spacing: .1em; color: var(--ink-4); }
.proto-root .archrow .why p { margin: 4px 0 0; font-size: 11.5px; line-height: 1.55; color: var(--ink-2); }
.proto-root .archrow .why p span:empty::after { content: "上游未留约束 —— 以原厂官网规格书为准"; color: var(--ink-4); }
/* 剔除这一页:只在开了闸时现身,宿主认 data-edit-del(找 tr 挂删记号) */
.proto-root .alt-pages .jc-rowdel { display: none; }
.proto-root .alt-pages.on .jc-rowdel { display: inline-flex; align-items: center; gap: 4px; width: auto; padding: 3px 9px; font-size: 11px; }
.proto-root .alt-pages.on tr.jc-row-gone .alt-cell { opacity: .5; background: #fff6f6; }
.proto-root .alt-pages.on tr.jc-row-gone .chip-id .pn { text-decoration: line-through; color: var(--ink-3); }
.proto-root .alt-pages.on tr.jc-row-new .alt-cell { background: #f2fbf7; }
/* ── 蓝图带:替代路径要穿过五道核对(概念图形是第一语言)── */
.proto-root .blueprint { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid var(--line-soft);
background-image:
repeating-linear-gradient(0deg, rgba(51,112,255,.05) 0 1px, transparent 1px 22px),
repeating-linear-gradient(90deg, rgba(51,112,255,.05) 0 1px, transparent 1px 22px),
linear-gradient(180deg, var(--brand-wash), #fff 88%);
}
.proto-root .bp-box { flex: 0 0 auto; text-align: center; }
.proto-root .bp-box em { display: block; font-style: normal; font-size: 9.5px; font-weight: 800; letter-spacing: .1em; color: var(--ink-4); margin-bottom: 3px; }
.proto-root .bp-box svg { display: block; margin: 0 auto; }
.proto-root .bp-to .socket-pn { display: block; margin-top: 4px; font-size: 11px; font-weight: 800; color: var(--ink-2); max-width: 150px; word-break: break-all; line-height: 1.3; }
.proto-root .bp-mid { flex: 1 1 auto; min-width: 120px; text-align: center; }
.proto-root .bp-mid svg { display: block; width: 100%; height: 46px; }
.proto-root .bp-mid .cap { font-size: 10px; color: var(--ink-4); }
.proto-root .bp-mid .lane { stroke: var(--brand-soft); stroke-width: 2; stroke-dasharray: 6 5; }
.proto-root .bp-mid .gate { fill: #fff; stroke: var(--brand); stroke-width: 1.6; }
.proto-root .bp-mid .tipp { fill: var(--brand); }
/* 插座:定案空着是虚线红槽;填了替代 PN 就换成实线绿槽(判断在样式表,:has + :empty) */
.proto-root .bp-to .sk-body { stroke: var(--ok); fill: rgba(39,135,23,.06); }
.proto-root .bp-to .sk-slot { stroke: var(--ok); }
.proto-root .bp-to:has(.socket-pn:empty) .sk-body { stroke: var(--danger); fill: #fff; stroke-dasharray: 7 5; }
.proto-root .bp-to:has(.socket-pn:empty) .sk-slot { stroke: #f3b6b3; }
.proto-root .bp-to .socket-pn:empty::after { content: "必填"; display: inline-block; font-size: 10px; font-weight: 800; color: var(--danger); background: var(--danger-soft); border: 1px dashed #f3b6b3; border-radius: 5px; padding: 3px 8px; }
.proto-root .bp-from .chipfig .cf-body { fill: var(--ink-2); stroke: var(--ink); }
.proto-root .bp-from .chipfig .cf-pin { stroke: var(--ink-3); stroke-width: 2; }
.proto-root .bp-from .chipfig .cf-dot { fill: #fff; opacity: .8; }
/* ── 题面 / 定案双栏 ── */
.proto-root .duo { display: grid; grid-template-columns: 1fr 1.2fr; gap: 0; }
.proto-root .duo .given { padding: 12px 16px 14px; border-right: 1px solid var(--line-soft); }
.proto-root .duo .decide { padding: 12px 16px 14px; background: linear-gradient(180deg, rgba(51,112,255,.035), transparent 60px); }
.proto-root .duo h5 { display: flex; align-items: center; gap: 6px; margin: 0 0 8px; font-size: 11px; font-weight: 800; letter-spacing: .06em; color: var(--ink-3); }
.proto-root .duo .decide h5 { color: var(--brand-deep); }
.proto-root .fld { display: flex; align-items: baseline; gap: 10px; padding: 5px 0; border-bottom: 1px dashed var(--line-soft); }
.proto-root .fld:last-child { border-bottom: 0; }
.proto-root .fld > i { flex: 0 0 96px; font-style: normal; font-size: 11px; color: var(--ink-4); }
.proto-root .fld > .jc-vcell, .proto-root .fld > .gradechip { flex: 1 1 auto; min-width: 0; font-size: 12px; color: var(--ink); line-height: 1.5; word-break: break-all; }
/* 行键那一格:平时收起,开了闸才现出来(它不是编辑面,是行的钥匙;新加的页在这里落键) */
.proto-root .fld.keyrow { display: none; }
.proto-root .alt-pages.on .fld.keyrow { display: flex; background: var(--soft); border-radius: 6px; padding: 5px 8px; }
.proto-root .fld.keyrow > i { color: var(--ink-3); font-weight: 700; }
/* 占位三级(全局口径):必填红 · 建议填橘 · 选填灰;题面空格是「客户没给」,只出一杠 */
.proto-root .ph-req:empty::after { content: "必填"; display: inline-block; font-size: 10px; font-weight: 700; line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed #f3b6b3; color: var(--danger); background: var(--danger-soft); }
.proto-root .ph-sug:empty::after { content: "建议填"; display: inline-block; font-size: 10px; font-weight: 700; line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed #f0c184; color: var(--warn-accent); background: rgba(255,136,0,.08); }
.proto-root .ph-opt:empty::after { content: "选填"; display: inline-block; font-size: 10px; font-weight: 700; line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed var(--line); color: var(--ink-4); background: var(--soft); }
.proto-root .dimv:empty::after { content: "—"; color: var(--ink-4); }
.proto-root .decide .fld:has(.ph-req:empty) { background: #fffbfb; }
.proto-root .decide .fld:has(.ph-req:empty) > i { color: var(--danger); font-weight: 800; }
/* 定案格的就地改:锁着看只读那份,开了闸换输入孪生件(渲染期基座生成 input.jc-cell) */
.proto-root .alt-pages.on .decide .fld > .jc-vcell,
.proto-root .alt-pages.on .decide .fld > .gradechip { display: none; }
.proto-root .alt-pages.on .decide .fld > input.jc-cell { display: block; flex: 1 1 auto; font: inherit; font-size: 12px; color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: 4px 8px; background: #fff; }
.proto-root .alt-pages.on .decide .fld > input.jc-cell:placeholder-shown { border-color: #f3b6b3; background: #fffafa; }
/* 等级三选:口径贴在选择钮上;当前档由 data-class-of 投影描虚线;radio 只在开了闸时可点 */
.proto-root .gopts { margin: 6px 0 4px; display: grid; gap: 6px; }
.proto-root .gopt { position: relative; display: flex; align-items: center; gap: 9px; border: 1px solid var(--line-soft); border-radius: 8px; padding: 7px 10px; background: #fff; }
.proto-root .gopt > input.jc-cell { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; }
.proto-root .alt-pages.on .gopt > input.jc-cell { display: block; }
.proto-root .gopt b { flex: 0 0 52px; font-size: 12px; font-weight: 800; color: var(--ink-2); }
.proto-root .gopt em { flex: 1 1 auto; font-style: normal; font-size: 10.5px; color: var(--ink-3); line-height: 1.45; }
.proto-root .gopt svg { flex: 0 0 auto; color: var(--ink-4); }
/* 当前档:虚线描边(值是台账里的,不写 checked —— 「没点」就是「不改」) */
.proto-root .gopts.v-p2p .gopt.gp, .proto-root .gopts.v-func .gopt.gf, .proto-root .gopts.v-other .gopt.go { border: 1px dashed var(--brand); background: var(--brand-wash); }
.proto-root .gopts.v-p2p .gopt.gp b, .proto-root .gopts.v-func .gopt.gf b, .proto-root .gopts.v-other .gopt.go b { color: var(--brand-deep); }
.proto-root .gopts.v-p2p .gopt.gp svg, .proto-root .gopts.v-func .gopt.gf svg, .proto-root .gopts.v-other .gopt.go svg { color: var(--brand); }
/* 开了闸:radio 现出真钮的手感(选中那档实线亮) */
.proto-root .alt-pages.on .gopt:has(input:checked) { border: 1.6px solid var(--brand); background: var(--brand-wash); }
/* 等级徽:三档三色(P2P 绿 · FUNC 蓝 · OTHER 橙) */
/* gradechip 必须同时是 jc-vcell(2026-08-03 用户:「替代等级后面那个必填没对齐」)——
* 上两行的必填胶囊住在 jc-vcell 的 6px 内距里,裸 gradechip 没这层盒,胶囊贴左又偏高。 */
.proto-root .gradechip.v-p2p { color: var(--ok); font-weight: 800; }
.proto-root .gradechip.v-func { color: var(--brand); font-weight: 800; }
.proto-root .gradechip.v-other { color: var(--warn-accent); font-weight: 800; }
/* ── 方法区(固定说明,不进编辑容器)── */
.proto-root .m-funnel { display: flex; gap: 8px; margin: 8px 0 10px; flex-wrap: wrap; }
.proto-root .m-funnel .fs { flex: 1 1 180px; border: 1px solid var(--line-soft); border-left: 3px solid var(--brand-soft); border-radius: 8px; padding: 8px 10px; font-size: 11px; color: var(--ink-3); line-height: 1.5; background: var(--soft); }
.proto-root .m-funnel .fs b { display: block; color: var(--ink-2); font-size: 11.5px; margin-bottom: 2px; }
.proto-root .m-five { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0 10px; }
.proto-root .m-five .c5 { flex: 1 1 130px; display: flex; align-items: center; gap: 8px; border: 1px solid var(--line-soft); border-radius: 8px; padding: 7px 9px; background: #fff; }
.proto-root .m-five .c5 svg { flex: 0 0 auto; color: var(--brand); }
.proto-root .m-five .c5 b { display: block; font-size: 11px; color: var(--ink-2); }
.proto-root .m-five .c5 span { display: block; font-size: 9.5px; color: var(--ink-4); }
/* 窄屏:双栏叠置、蓝图竖排(媒体块排在基础规则之后,同权重后定义胜) */
@media (max-width: 900px) {
.proto-root .duo { grid-template-columns: 1fr; }
.proto-root .duo .given { border-right: 0; border-bottom: 1px solid var(--line-soft); }
.proto-root .archrow { flex-direction: column; gap: 8px; }
.proto-root .archrow .why { border-left: 0; padding-left: 0; border-top: 1px dashed var(--line-soft); padding-top: 8px; }
.proto-root .blueprint { flex-direction: column; }
.proto-root .bp-mid svg { transform: rotate(90deg); width: 46px; height: 46px; margin: 0 auto; }
}
/* 「必填」空槽轻呼吸 —— 连 @keyframes 一起包在动效许可里,减弱设置下全静 */
@media (prefers-reduced-motion: no-preference) {
.proto-root .bp-to .socket-pn:empty::after { animation: jc-breath 2.2s ease-in-out infinite; }
@keyframes jc-breath { 0%,100% { opacity: 1; } 50% { opacity: .55; } }
}
`;
/** 原厂芯片小像(方体 + 双侧引脚 + 一脚圆点) */
const CHIP_FIG = `<svg width="96" height="64" viewBox="0 0 96 64" fill="none" stroke-linecap="round" class="chipfig">
<line class="cf-pin" x1="8" y1="18" x2="22" y2="18"/><line class="cf-pin" x1="8" y1="28" x2="22" y2="28"/><line class="cf-pin" x1="8" y1="38" x2="22" y2="38"/><line class="cf-pin" x1="8" y1="48" x2="22" y2="48"/>
<line class="cf-pin" x1="74" y1="18" x2="88" y2="18"/><line class="cf-pin" x1="74" y1="28" x2="88" y2="28"/><line class="cf-pin" x1="74" y1="38" x2="88" y2="38"/><line class="cf-pin" x1="74" y1="48" x2="88" y2="48"/>
<rect class="cf-body" x="22" y="10" width="52" height="44" rx="6" stroke-width="1.6"/>
<circle class="cf-dot" cx="31" cy="19" r="2.5"/>
</svg>`;
/** 蓝图带:原厂芯片 → 五道核对 → 替代插座(空=虚线红槽,定了=实线绿槽) */
const BLUEPRINT = `<div class="blueprint">
<div class="bp-box bp-from"><em>原厂芯片</em>${CHIP_FIG}</div>
<div class="bp-mid">
<svg viewBox="0 0 240 46" fill="none">
<path class="lane" d="M6 23h206"/>
<circle class="gate" cx="38" cy="23" r="5"/><circle class="gate" cx="76" cy="23" r="5"/><circle class="gate" cx="114" cy="23" r="5"/><circle class="gate" cx="152" cy="23" r="5"/><circle class="gate" cx="190" cy="23" r="5"/>
<path class="tipp" d="M216 15l16 8-16 8z"/>
</svg>
<div class="cap">替代路径要穿过五道核对(封装 · 引脚 · 电气 · 温度 · 认证)</div>
</div>
<div class="bp-box bp-to"><em>替代定案</em>
<svg width="96" height="64" viewBox="0 0 96 64" fill="none" stroke-linecap="round">
<rect class="sk-body" x="10" y="8" width="76" height="48" rx="8" stroke-width="1.8"/>
<path class="sk-slot" d="M28 22h40M28 32h40M28 42h24" stroke-width="2"/>
</svg>
<span class="socket-pn jc-mono" data-slot="alt_pn" data-blank></span>
</div>
</div>`;
/** 等级三选(radio 不写 checked;当前档由 gopts 上的投影描虚线) */
const GRADE_OPTS = `<div class="gopts" data-class-of="alt_grade">
<div class="gopt gp"><input type="radio" name="alt_grade_pick" value="P2P" class="jc-cell" data-col="alt_grade"><b>P2P</b><em>同封装同引脚 · 可直换 —— 五道核对逐项过了再选,同封装不等于同引脚</em>${ico('pins', 15)}</div>
<div class="gopt gf"><input type="radio" name="alt_grade_pick" value="FUNC" class="jc-cell" data-col="alt_grade"><b>FUNC</b><em>功能兼容 —— 需外围电路或 layout 调整;差多少、怎么改,写进下面两格</em>${ico('gear', 15)}</div>
<div class="gopt go"><input type="radio" name="alt_grade_pick" value="OTHER" class="jc-cell" data-col="alt_grade"><b>OTHER</b><em>部分兼容 —— 须打样验证;怎么验写进验证建议</em>${ico('flask', 15)}</div>
</div>`;
/** 一页 = 一颗待选型料的选型档案(行模版;只有 orig_type=C 的行成页) */
const SHEET_PAGE = `<table class="alt-pages"><tbody data-rows="mine:quote_alternative.list" data-edit="quote_alternative.list">
<tr class="alt-page" data-page data-class-of="orig_type"><td class="alt-cell">
<div class="archrow">
<div class="chip-id"><em>原厂 · 被替换</em><span class="pn jc-mono" data-slot="pn"></span></div>
<div class="why"><i>${ico('history')}为什么要替代&nbsp;<span class="jc-tbadge" data-class-of="orig_type" data-slot="orig_type"></span></i>
<p><span data-slot="remark" data-blank></span></p>
</div>
<button type="button" class="jc-egbtn jc-rowdel" data-edit-del="quote_alternative.list" title="这颗不在本轮选型(剔除)">×&nbsp;剔除</button>
</div>
${BLUEPRINT}
<div class="duo">
<div class="given">
<h5>${ico('file-text', 12)}题面 · 客户给的</h5>
<div class="fld"><i>客户料号</i><span class="jc-vcell jc-mono dimv" data-slot="customer_pn" data-blank></span></div>
<div class="fld"><i>品类</i><span class="jc-vcell dimv" data-slot="category" data-blank></span></div>
<div class="fld"><i>制造商</i><span class="jc-vcell dimv" data-slot="brand" data-blank></span></div>
<div class="fld"><i>年用量</i><span class="jc-vcell jc-mono dimv" data-slot="eau" data-blank></span></div>
<div class="fld"><i>目标价</i><span class="jc-vcell jc-mono dimv" data-slot="target_price" data-blank></span></div>
<div class="fld"><i>交期要求</i><span class="jc-vcell dimv" data-slot="lead_time" data-blank></span></div>
</div>
<div class="decide">
<h5>${ico('pen', 12)}替代定案 · 你的产出</h5>
<div class="fld keyrow"><i>原厂 PN(行键)</i><span class="jc-vcell jc-mono" data-slot="pn" data-col="pn"></span></div>
<div class="fld"><i>替代料 PN</i><span class="jc-vcell jc-mono ph-req" data-slot="alt_pn" data-col="alt_pn" data-blank></span></div>
<div class="fld"><i>替代料品牌</i><span class="jc-vcell ph-req" data-slot="alt_brand" data-col="alt_brand" data-blank></span></div>
<div class="fld"><i>替代等级</i><span class="jc-vcell gradechip ph-req" data-class-of="alt_grade" data-slot="alt_grade" data-blank></span></div>
${GRADE_OPTS}
<div class="fld"><i>参数差异项</i><span class="jc-vcell ph-sug" data-slot="alt_diff" data-col="alt_diff" data-blank></span></div>
<div class="fld"><i>风险与改动</i><span class="jc-vcell ph-sug" data-slot="alt_risk" data-col="alt_risk" data-blank></span></div>
<div class="fld"><i>验证建议</i><span class="jc-vcell ph-sug" data-slot="alt_verify" data-col="alt_verify" data-blank></span></div>
<div class="fld"><i>选型备注</i><span class="jc-vcell ph-opt" data-slot="alt_note" data-col="alt_note" data-blank></span></div>
</div>
</div>
</td></tr>
</tbody></table>`;
export const ALT_WORKSHEET = doc({
badge: 'boxes',
kicker: '统一报价 · A2 替代选型 · 选型作业',
title: '替代选型工单',
titleRef: 'rfq_ref',
state: '在办 · 逐颗定案',
css: SHEET_CSS,
body:
kvCard('本单标的', [
['原型号', 'originalPn', 'mono'],
['芯片类型', 'chipType'],
['当前阶段', 'activeStage', 'tag'],
['当下可为', 'enabledCount'],
['目标缺口', 'gapCount'],
]) +
stagesCard('阶段(CMMN)', 'case:stages', '阶段推进到 <b>Enabled</b> 为止;触达世界的计划项须人工启动。') +
stagesCard('当下可为', 'case:enabled') +
stagesCard('目标缺口', 'case:gaps', '缺口决定下一步是调人还是立监听。') +
noteCard(
'跨人卡',
'选型常要向 BD 补客户口径 —— 那是<b>跨任务征答</b>:卡以系统消息送达对方的消息要素、<b>就地作答</b>,' +
'不占对方的任务清单,也不开放本任务的对话流。它是一次性的解答请求,不是责任转移。',
),
`<div class="jc-derive">${ico('info', 14)}
<span>这份工单只办一件事:给每颗<b>待选型(原标 C)</b>的料定一颗替代 —— 客户
<b data-slot="customer_name"></b> 本单待选型 <b data-slot="c_total"></b> 颗,
一颗一页(右上角翻页)。每页上半是<b>题面</b>(客户给的,改不了),下半右栏是<b>你的定案</b>;
定案保存后,查库判稳与转标(≤90 天且行情稳转 A,否则转 B 去寻源)由系统接办。
保存只落任务草稿层;交卷在对话区的执行卡上。</span>
</div>` +
part('替代定案 · 一颗一页', '可就地修订') +
`<div class="jc-zone z-edit">
<div class="jc-gapbar" data-class-of="quote_alternative_list_missing">${ico('alert', 14)}
<span><b>定案还差 <span data-slot="quote_alternative_list_missing"></span> 格必填</b>
—— <span data-slot="quote_alternative_list_missing_keys"></span>。
翻到那一颗,按「编辑」开闸就地填;补齐了执行卡上才交得了卷。</span>
</div>
<div class="jc-editgate" data-edit-for="quote_alternative.list">
<span class="jc-editgate-t">${ico('lock', 12)}替代定案<b>已锁定</b> —— 看的时候不误碰,要改按右边</span>
<button type="button" class="jc-egbtn open" data-edit-open="quote_alternative.list">${ico('pen', 12)}编辑</button>
<button type="button" class="jc-egbtn ghost" data-edit-cancel="quote_alternative.list">取消</button>
<button type="button" class="jc-egbtn save" data-edit-save="quote_alternative.list">${ico('check', 12)}保存</button>
</div>
<div class="jc-editbar">${ico('pen', 14)}
<span>定案三格是<b>必填</b>(替代料 PN / 品牌 / 等级),等级直接点三档之一;
这颗料整个不该选型,按档案头右上角的 × 剔除(划着摆在那儿,可后悔);
漏了一颗,用底下的「补一颗待选型料」。改动只落任务草稿层,<b>保存不等于放行</b>。</span>
</div>
${SHEET_PAGE}
<div class="jc-addrow">
<button type="button" class="jc-egbtn ghost" data-edit-add="quote_alternative.list">${ico('plus', 12)}补一颗待选型料</button>
<em>照末页添一页空档案;行键(原厂 PN)填了才存得下,不填这一页不落库。</em>
</div>
</div>` +
part('选型方法 · 凭什么这么选', '替代等级口径') +
`<div class="m-five">
<div class="c5">${ico('ruler', 16)}<div><b>封装尺寸</b><span>本体 / 脚距 / 高度</span></div></div>
<div class="c5">${ico('pins', 16)}<div><b>引脚定义</b><span>逐脚对数据手册</span></div></div>
<div class="c5">${ico('bolt', 16)}<div><b>电气参数</b><span>供电 / 精度 / 速度</span></div></div>
<div class="c5">${ico('thermo', 16)}<div><b>温度等级</b><span>工作温区不降级</span></div></div>
<div class="c5">${ico('shield-check', 16)}<div><b>认证</b><span>车规 / 医疗不降级</span></div></div>
</div>
<div class="m-funnel">
<div class="fs"><b>① 参数硬指标</b>封装 / 引脚 / 电气 / 温度 —— 任一不满足直接淘汰,不进下一层。</div>
<div class="fs"><b>② 认证与合规</b>车规 AEC-Q100/Q200、医疗、工业等级不可降级;客户点名禁用的品牌与型号不选。</div>
<div class="fs"><b>③ 供应与价格</b>在产 / 货态 / 品牌接受度;客户预授权的候选优先。</div>
</div>
<div class="jc-drop">${ico('info', 13)}
<span>一律以<b>原厂官网规格书</b>为准,不以商城参数表为准。没有把握的替代不填:
宁可在选型备注写明「替代未果」的缘由后保存交回,由判稳一步按剔除处置 ——
编一个候选顶上,是把风险埋进下游的报价单。是否接受非原厂品牌、参数降级,
必须问客户,不得替客户决定。</span>
</div>`,
foot: 'FAE 逐颗定案(右上角翻页)—— 判稳与转标由系统查库接办',
tag: '统一报价 · A2 替代选型 · 选型作业',
});
/* ==================== 选型验收报告(收口清单 + 落边)==================== */
const ROSTER_CSS = `
/* ── 收口总览:四格账 ── */
.proto-root .statrow { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin: 2px 0 10px; }
.proto-root .stat { border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px 9px; background: #fff; position: relative; overflow: hidden; }
.proto-root .stat::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--line); }
.proto-root .stat.sa::before { background: var(--ok); }
.proto-root .stat.sb::before { background: var(--brand); }
.proto-root .stat.sc::before { background: var(--warn-accent); }
.proto-root .stat b { display: block; font-size: 24px; font-weight: 800; letter-spacing: -.4px; color: var(--ink); line-height: 1.15; }
.proto-root .stat span { display: block; font-size: 10.5px; color: var(--ink-3); margin-top: 2px; }
/* 「待选型未清」那格:0 才是收口该有的样子 —— 0 绿、非 0 红(判断在样式表) */
.proto-root .stat.sc b.v-0 { color: var(--ok); }
.proto-root .stat.sc b.v-1, .proto-root .stat.sc b.v-2, .proto-root .stat.sc b.v-3, .proto-root .stat.sc b.v-4, .proto-root .stat.sc b.v-5 { color: var(--danger); }
/* 选型账一行:C 类怎么清零的 */
.proto-root .workline { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; margin: 0 0 12px; padding: 8px 12px; border: 1px dashed var(--line); border-radius: 9px; background: var(--soft); font-size: 11.5px; color: var(--ink-3); }
.proto-root .workline b { color: var(--ink); font-weight: 800; }
.proto-root .workline .sep { color: var(--ink-4); }
/* 剔除说明空着就整句收起(判断在样式表;有剔除才有这句话) */
.proto-root .workline span:has(> b:empty) { display: none; }
.proto-root .workline:has(span > b:empty) .sep:last-of-type { display: none; }
/* ── 清单表:序号计数器 + 归档空格「—」+ 底行小字(承继的第二行不出杠)── */
.proto-root .jc-tbl-in tbody { counter-reset: rn; }
.proto-root .jc-tbl-in td.jc-rn::before { counter-increment: rn; content: counter(rn); }
.proto-root .jc-tbl-in td.dimempty > .jc-vcell:empty::after { content: "—"; color: var(--ink-4); }
.proto-root .jc-tbl-in .sub2 { display: block; font-size: 10px; color: var(--ink-4); margin-top: 1px; }
.proto-root .gchip { font-weight: 800; font-size: 11.5px; }
.proto-root .gchip.v-p2p { color: var(--ok); }
.proto-root .gchip.v-func { color: var(--brand); }
.proto-root .gchip.v-other { color: var(--warn-accent); }
.proto-root .gchip:empty::after { content: "—"; color: var(--ink-4); font-weight: 400; }
/* ── 落边图:清单 → 还有 B 类? → A3 / A4(命中边由壳上 v-r1/v-r2 上色,件不算不判)── */
.proto-root .route { border: 1px solid var(--line); border-radius: 12px; padding: 12px 12px 8px; background: linear-gradient(180deg, #fff, var(--soft)); margin: 0 0 10px; }
.proto-root .route svg { display: block; width: 100%; height: auto; }
.proto-root .route .rnode { fill: #fff; stroke: var(--line); stroke-width: 1.4; }
.proto-root .route .rdia { fill: #fff; stroke: var(--ink-3); stroke-width: 1.4; }
.proto-root .route .redge { stroke: var(--line); stroke-width: 2; stroke-dasharray: 6 5; fill: none; }
/* 已走过的共段(清单 → 判定):中性实线,不随命中变色 */
.proto-root .route .rspine { stroke: var(--line); stroke-width: 2; fill: none; }
.proto-root .route .rpulse { stroke: transparent; stroke-width: 2.5; fill: none; }
.proto-root .route .rtxt { font-size: 12px; font-weight: 800; fill: var(--ink-2); }
.proto-root .route .rsub { font-size: 9.5px; font-weight: 400; fill: var(--ink-4); }
.proto-root .route .rcond { font-size: 10px; font-weight: 700; fill: var(--ink-4); }
.proto-root .route .rtip { fill: var(--line); }
/* 命中 r1:仍有 B 类 → A3 渠道寻源(蓝);未命中那条留灰虚线,不冒充判过 */
.proto-root .route.v-r1 .e1 { stroke: var(--brand); stroke-dasharray: none; }
.proto-root .route.v-r1 .n1 { stroke: var(--brand); stroke-width: 2; }
.proto-root .route.v-r1 .t1 { fill: var(--brand-deep); }
.proto-root .route.v-r1 .c1 { fill: var(--brand-deep); }
.proto-root .route.v-r1 .k1 { fill: var(--brand); }
/* 命中 r2:全 A → A4 成本定价(绿) */
.proto-root .route.v-r2 .e2 { stroke: var(--ok); stroke-dasharray: none; }
.proto-root .route.v-r2 .n2 { stroke: var(--ok); stroke-width: 2; }
.proto-root .route.v-r2 .t2 { fill: var(--ok); }
.proto-root .route.v-r2 .c2 { fill: var(--ok); }
.proto-root .route.v-r2 .k2 { fill: var(--ok); }
.proto-root .route .legend { display: flex; gap: 16px; flex-wrap: wrap; justify-content: center; margin: 6px 0 2px; font-size: 10.5px; color: var(--ink-3); }
.proto-root .route .legend b { font-weight: 800; color: var(--ink-2); }
/* 结论带:一句人话结论 + 去向凭据 */
.proto-root .concl { display: flex; gap: 12px; align-items: flex-start; border: 1px solid var(--line); border-left: 4px solid var(--brand); border-radius: 10px; padding: 10px 14px; background: #fff; margin: 0 0 12px; }
.proto-root .concl svg { flex: 0 0 auto; margin-top: 2px; color: var(--brand); }
.proto-root .concl .cl-main { flex: 1 1 auto; min-width: 0; font-size: 12px; line-height: 1.6; color: var(--ink-2); }
.proto-root .concl .cl-main b { color: var(--ink); }
.proto-root .concl .cl-route { flex: 0 0 auto; text-align: right; }
.proto-root .concl .cl-route em { display: block; font-style: normal; font-size: 9.5px; font-weight: 800; letter-spacing: .1em; color: var(--ink-4); }
.proto-root .concl .cl-route b { display: block; font-size: 14px; font-weight: 800; color: var(--brand-deep); margin: 2px 0; }
.proto-root .concl .cl-route code { font-size: 9.5px; color: var(--ink-4); background: var(--soft); border: 1px solid var(--line-soft); border-radius: 5px; padding: 2px 6px; }
/* 窄屏(媒体块排在基础规则之后) */
@media (max-width: 860px) {
.proto-root .statrow { grid-template-columns: repeat(2, 1fr); }
.proto-root .concl { flex-direction: column; }
.proto-root .concl .cl-route { text-align: left; }
}
/* 命中边的流光 —— 连 @keyframes 一起包在动效许可里,减弱设置下全静 */
@media (prefers-reduced-motion: no-preference) {
.proto-root .route.v-r1 .p1, .proto-root .route.v-r2 .p2 { stroke-dasharray: 10 130; animation: jc-flow 2.6s linear infinite; }
.proto-root .route.v-r1 .p1 { stroke: rgba(51,112,255,.65); }
.proto-root .route.v-r2 .p2 { stroke: rgba(39,135,23,.6); }
@keyframes jc-flow { to { stroke-dashoffset: -140; } }
}
`;
/** 清单表(只读;原料 → 原标 → 定案 → 现标 → 依据,一行一颗可回核) */
const ROSTER_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
<thead><tr>
<th style="width:36px;min-width:36px"></th>
<th style="min-width:190px">原厂料</th>
<th style="width:44px;min-width:44px">原标</th>
<th style="min-width:190px">替代定案</th>
<th style="width:64px;min-width:64px">等级</th>
<th style="width:44px;min-width:44px">现标</th>
<th style="min-width:220px;width:100%">转标依据</th>
</tr></thead>
<tbody data-rows="mine:quote_alternative.list"><tr>
<td class="jc-rn"></td>
<td class="dimempty" data-h="原厂料"><span class="jc-vcell jc-mono" data-slot="pn" data-blank></span><span class="sub2" data-slot="brand" data-blank></span></td>
<td class="jc-ty" data-h="原标"><span class="jc-tbadge" data-class-of="orig_type" data-slot="orig_type"></span></td>
<td class="dimempty" data-h="替代定案"><span class="jc-vcell jc-mono" data-slot="alt_pn" data-blank></span><span class="sub2" data-slot="alt_brand" data-blank></span></td>
<td data-h="等级"><span class="gchip" data-class-of="alt_grade" data-slot="alt_grade" data-blank></span></td>
<td class="jc-ty" data-h="现标"><span class="jc-tbadge" data-class-of="type_tag" data-slot="type_tag"></span></td>
<td class="dimempty" data-h="转标依据"><span class="jc-vcell" data-slot="basis" data-blank></span></td>
</tr></tbody>
</table></div>`;
/** 落边图:两条整段 path(命中哪条哪条亮);共段是中性 spine */
const ROUTE_FIG = `<div class="route" data-class-of="case:routeKey">
<svg viewBox="0 0 660 210" fill="none">
<path class="rspine" d="M148 100 H195"/>
<path class="redge e1" d="M305 100 C355 100 380 50 460 50"/>
<path class="rpulse p1" d="M305 100 C355 100 380 50 460 50"/>
<path class="redge e2" d="M250 138 C250 172 340 168 460 168"/>
<path class="rpulse p2" d="M250 138 C250 172 340 168 460 168"/>
<path class="rtip k1" d="M448 44 l14 6 -14 6 z"/>
<path class="rtip k2" d="M448 162 l14 6 -14 6 z"/>
<rect class="rnode" x="8" y="70" width="140" height="60" rx="10"/>
<text class="rtxt" x="78" y="95" text-anchor="middle">询价受理清单</text>
<text class="rsub" x="78" y="113" text-anchor="middle">共 <tspan data-slot="quote_alternative_list_rows"></tspan> 条 · C 已清零</text>
<path class="rdia" d="M250 62 L305 100 250 138 195 100 Z"/>
<text class="rcond" x="250" y="104" text-anchor="middle">还有 B 类?</text>
<text class="rcond c1" x="330" y="38">是 · B 类 <tspan data-slot="quote_alternative_list_type_tag_b"></tspan> 条</text>
<text class="rcond c2" x="330" y="196">否 · 全 A 类</text>
<rect class="rnode n1" x="460" y="18" width="170" height="64" rx="10"/>
<text class="rtxt t1" x="545" y="45" text-anchor="middle">A3 · 渠道寻源</text>
<text class="rsub" x="545" y="63" text-anchor="middle">补上 B 类的货源价格</text>
<rect class="rnode n2" x="460" y="136" width="170" height="64" rx="10"/>
<text class="rtxt t2" x="545" y="163" text-anchor="middle">A4 · 成本定价</text>
<text class="rsub" x="545" y="181" text-anchor="middle">行情完备 · 直通核价</text>
</svg>
<div class="legend"><span>命中边实线亮 · 未命中留灰虚线</span><span>判定在你签字那一刻由系统现算 —— 要改去向,在验收卡的成果表上改现标</span></div>
</div>`;
export const ALT_ROSTER = doc({
badge: 'file-check',
kicker: '统一报价 · A2 替代选型 · 交付成果',
title: '询价受理清单',
titleRef: 'rfq_ref',
state: '收口 · 待放行',
css: ROSTER_CSS,
body:
`<div class="jc-derive">${ico('info', 14)}
<span>这份清单是 A2 的<b>收口成果</b>:上游受理记录逐条承继,待选型(原标 C)的料
已由 FAE 逐颗定案,系统按库内行情判稳转标(≤90 天且行情稳 → A,否则 → B 去寻源)——
询价受理记录就此转成<b>询价受理清单</b>,客户 <b data-slot="customer_name"></b>。
本页只读:要改哪一格,在验收卡的成果表上就地改,这里跟着重渲。</span>
</div>` +
part('收口总览', 'C 类清零 · 判稳定标', 'ok') +
`<div class="statrow">
<div class="stat"><b data-slot="quote_alternative_list_rows"></b><span>清单条目 · 逐条承继上游</span></div>
<div class="stat sa"><b data-slot="quote_alternative_list_type_tag_a"></b><span>A 类 · 行情完备</span></div>
<div class="stat sb"><b data-slot="quote_alternative_list_type_tag_b"></b><span>B 类 · 待补货源价</span></div>
<div class="stat sc"><b data-class-of="quote_alternative_list_type_tag_c" data-slot="quote_alternative_list_type_tag_c"></b><span>C 类 · 待选型未清(应为 0)</span></div>
</div>
<div class="workline">
<span>待选型 <b data-slot="c_total"></b> 颗</span><span class="sep">→</span>
<span>已填回替代 <b data-slot="filled_total"></b> 颗</span><span class="sep">→</span>
<span>判稳转 A <b data-slot="converted_a_total"></b> 颗</span><span class="sep">·</span>
<span>转 B 待寻源 <b data-slot="converted_b_total"></b> 颗</span><span class="sep">·</span>
<span>剔除说明:<b data-slot="skipped_note" data-blank></b></span>
</div>` +
part('询价受理清单', '<b data-slot="quote_alternative_list_rows"></b> 条 · 交下游的就是这一份') +
ROSTER_TABLE +
`<div class="jc-drop">${ico('info', 13)}
<span><b>原标</b>是上游受理时的三分类(留痕,本活动不改);<b>现标</b>是本活动的产物:
C 类经替代定案与判稳转成 A 或 B。现标定去向 —— 改一颗料的现标,下面的落边判定跟着变。
等级口径:<b>P2P</b> 同封装同引脚可直换 · <b>FUNC</b> 功能兼容需调整 · <b>OTHER</b> 部分兼容须打样验证。</span>
</div>` +
part('落边 · 这一签流向哪里', '出口按序求值 · 首个命中即出') +
ROUTE_FIG +
`<div class="concl">
${ico('git-branch', 15)}
<div class="cl-main"><b>选型结论</b> —— <span data-slot="summary"></span></div>
<div class="cl-route"><em>转入活动</em><b data-slot="case:routeTo"></b><code data-slot="case:routeWhy"></code></div>
</div>` +
part('放行后即执行的落地动作', '门前 → 门后') +
`<div class="jc-acts">
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>草稿层合并进正式版</b>&#12288;<span>本任务写下的询价受理清单与选型结论一并落账;
你在验收卡上就地改的那几格随之落定。</span></div>
</div>
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>命中的那一口此刻才发射</b>&#12288;<span>清单
<b data-slot="quote_alternative_list_rows"></b> 条随载荷交给上面亮线指向的下游;
另一口本次不起单 —— 放行之前,世界上什么都没有发生。</span></div>
</div>
</div>`,
foot: 'AI 拟稿 · FAE 审定放行 —— 签下去这份清单即转正并流向下游',
tag: '统一报价 · A2 替代选型 → 交下游',
});
@@ -0,0 +1,575 @@
/**
* A7 情报归档的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。
* 案例活动没有 `run`,自测不跑回合;测的是清单本身与四处对齐
* (判据谓词 / 出口载荷 / 剧本声明 / 两把尺的档位)。
*/
import { describe, expect, it } from 'vitest';
import { activityManifestSchema } from '@contracts';
import { evaluateCondition, evaluateUnaryTest } from '@sfeel';
import { createMockHost } from '@sdk';
import { A01_DMN, manifest } from '../src/manifest.ts';
import activity from '../src/index.ts';
describe('清单本身合规', () => {
it('过 @contracts 的 schema', () => {
expect(() => activityManifestSchema.parse(manifest)).not.toThrow();
});
it('监听时限已配 —— 无悬空监听', () => {
expect(manifest.declarations.listeners.length).toBeGreaterThan(0);
expect(manifest.declarations.listeners.every((l) => /^P/.test(l.deadline))).toBe(true);
});
it('L1/L2 判据都给了可机检的谓词 —— 验收等级不得谎报', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef, c.key).toBeDefined();
}
});
it('禁区短而硬,授权边界给了调人预算', () => {
expect(manifest.contract.prohibitions.length).toBeGreaterThan(0);
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(0);
});
it('每件必交付物都有台账地址', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(d.ledger, d.key).toBeDefined();
}
});
/*
* **宽进严出**:起单一个必填都不设 —— 上游(A6 的三口)发的是
* `{rfqRef, customerCode, quoteNo, outcome, rows}`,而上一版必填前三个,
* 「补一单历史」那条单飞路直接被门口拦掉。
*/
it('起单宽进:input.required 是空的,两种来路的键都声明着', () => {
expect(manifest.contract.input.data['required']).toEqual([]);
const properties = manifest.contract.input.data['properties'] as Record<string, unknown>;
for (const key of ['rfqRef', 'customerCode', 'quoteNo', 'outcome', 'rows', 'subject', 'body']) {
expect(properties[key], `入参少了 ${key}`).toBeDefined();
}
});
/*
* **输赢归因是必填** —— 这是 A7 的门面判据:件上的缺归因红带、执行卡的提交闸、
* 放行门 L1 三处同源(都数基座的 `_missing`)。声明丢了 required,三处一起失明。
*/
it('输赢归因标了 required —— 红带 / 提交闸 / 放行门三处同源的那个数才存在', () => {
const intel = manifest.declarations.ledger.find((d) => d.object === 'intel');
expect(intel?.fields.find((f) => f.name === 'cause_tag')?.required).toBe(true);
});
/*
* **竞对价是行级必填**:表级 required 会让交期 / 认证败的行永远缺一格,
* 把闸拦成死锁 —— 而那几行本来就没有竞对报价这回事。
*/
it('竞对价是行级必填:只有归因落「输在价格」的那一行才算缺', () => {
const intel = manifest.declarations.ledger.find((d) => d.object === 'intel');
const competitor = intel?.fields.find((f) => f.name === 'competitor_price');
expect(competitor?.required).toBeUndefined();
expect(competitor?.requiredWhen).toEqual({ column: 'cause_tag', value: 'PRICE' });
});
it('归因 / 竞对价 / 依据三列写死了「你一格都不写」', () => {
const collect = manifest.assets.businessModel.steps?.find((s) => s.item === 'S1_collect')?.guide ?? '';
expect(collect).toContain('cause_tag / competitor_price / evidence');
expect(collect).toMatch(/系统不替人归因/);
});
it('结果、归因、败价幅度、入库口径四列都给了取值域 —— 条数由基座现数,不由活动自报', () => {
const intel = manifest.declarations.ledger.find((d) => d.object === 'intel');
const tally = (name: string): readonly string[] =>
intel?.fields.find((f) => f.name === name)?.tally ?? [];
expect(tally('deal_result')).toEqual(['WON', 'LOST']);
expect(tally('cause_tag')).toEqual(['PRICE', 'LEADTIME', 'CERT', 'SPEC', 'RELATION', 'OTHER']);
expect(tally('price_gap_band')).toEqual(['SLIGHT', 'MODERATE', 'STRUCTURAL']);
expect(tally('share_scope')).toEqual(['PENDING', 'FULL', 'MASKED', 'INTERNAL']);
});
it('没有一列滥标 expect —— `_gaps` 不是拿来常亮的', () => {
const flagged = manifest.declarations.ledger.flatMap((d) => d.fields.filter((f) => f.expect === true));
expect(flagged.length).toBe(0);
});
/*
* **三张共享域表已退役**:落地率单案例算不出来(让 AI 编一个数是界面撒谎)、
* 客户画像 A1 已经持有那张主数据、历史价格情报与情报卡是同一份事实。
*/
it('共享域三张表已退役 —— 只剩情报卡与归档结论两张', () => {
const addresses = manifest.declarations.ledger.map((d) => `${d.domain}.${d.object}`);
expect(addresses).toEqual(['quote_archive.intel', 'quote_archive.verdict']);
});
/*
* 情报要**下次查得到**:料号 / 客户 / 时间 / 我方价 / 成交价 / 竞对价 / 输赢原因
* 七样缺一样,这条情报到下一单就哑了。
*/
it('情报卡是「下次查得到的形状」:料号 · 客户 · 时间 · 两个价 · 竞对价 · 归因', () => {
const intel = manifest.declarations.ledger.find((d) => d.object === 'intel');
const names = new Set((intel?.fields ?? []).map((f) => f.name));
for (const col of ['pn', 'customer_code', 'quoted_at', 'quoted_price', 'final_price', 'competitor_price', 'cause_tag']) {
expect(names.has(col), `情报卡少了 ${col}`).toBe(true);
}
expect(intel?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['rfq_ref', 'pn']);
});
it('三条常备案例形状真不同:流内失单 / 成交归档 / 单飞补历史', () => {
expect(manifest.samples.length).toBe(3);
const [lost, won, solo] = manifest.samples;
expect(lost?.input['outcome']).toBe('lost');
expect(won?.input['outcome']).toBe('won');
expect(solo?.input['rows']).toBeUndefined();
expect(String(solo?.input['body'] ?? '').length).toBeGreaterThan(40);
for (const sample of manifest.samples) expect(sample.expect?.ports).toEqual(['archived']);
});
});
describe('案例模型 —— 双表示同版本', () => {
const model = manifest.assets.businessModel;
const cmmn = parseCmmnLite(model.cmmnXml);
it('CMMN 与说明书同在,不允许分叉', () => {
expect(model.cmmnXml).toContain('<casePlanModel');
expect(model.narrative.length).toBeGreaterThan(400);
});
/* 照 MVP 示例工作流 §1.6 的 S1 结果登记 / S2 画像更新 / S3 落地率统计,一步一项 */
it('计划项照 SOP 一步一项:归一 + 逐条归因 + 入库口径拍板 + 放行', () => {
expect(items(model.cmmnXml)).toEqual(['S1_collect', 'S2_attribute', 'T_scope', 'T_release']);
});
it('三处人的门都标了人工激活:逐条归因 · 入库口径拍板 · 归档放行(归一是查表,不标)', () => {
expect(cmmn.manualActivation).toEqual(['S2_attribute', 'T_scope', 'T_release']);
});
it('不用阶段 / 里程碑 / 裁量项', () => {
for (const tag of ['<stage', '<milestone', '<planningTable', '<discretionaryItem']) {
expect(model.cmmnXml, `不该出现 ${tag}`).not.toContain(tag);
}
});
/*
* **归因齐了才叫人拍口径** —— 这个活动的要害:哨兵读的是人自己填的那一格
* (基座按 required 现数的缺项 + 只有人交回才产生的 attributed_total),
* 而不是模型判出来的结论列。A6 两版栽的就是这个:
* **「该不该叫人」不能由被叫的那一方说了算**。
*/
it('入库口径拍板的哨兵读**人自己填的那一格**,不读模型判出来的结论', () => {
const condition = sentryIf(model.cmmnXml, 's_attributed');
expect(condition).toContain('quote_archive_intel_missing = 0');
expect(condition).toContain('attributed_total');
expect(condition).toContain('quote_archive_intel_share_scope_pending > 0');
// 归因落齐、口径还待定 → 叫人
expect(
safeEval(condition, {
attributed_total: 2,
quote_archive_intel_rows: 2,
quote_archive_intel_missing: 0,
quote_archive_intel_share_scope_pending: 2,
}),
).toBe(true);
// 还有行没归因 → 先别叫(问他口径之前,先让他把归因填完)
expect(
safeEval(condition, {
attributed_total: 2,
quote_archive_intel_rows: 2,
quote_archive_intel_missing: 1,
quote_archive_intel_share_scope_pending: 2,
}),
).toBe(false);
// 人还没交回执行单 → 门不开
expect(
safeEval(condition, {
quote_archive_intel_rows: 2,
quote_archive_intel_missing: 0,
quote_archive_intel_share_scope_pending: 2,
}),
).toBe(false);
});
/*
* 入场哨兵也归「机检只读基座数」管(A1 第四次违反就藏在哨兵里)——
* 读活动自报的条数,模型不记那个账,门就永不开,而它一步也没做错。
*/
it('交人归因的入场哨兵读基座行数,不读自报的归档条数', () => {
const condition = sentryIf(model.cmmnXml, 's_collected');
expect(condition).toContain('quote_archive_intel_rows');
expect(condition).not.toContain('archived_total');
expect(safeEval(condition, { intel_total: 2, quote_archive_intel_rows: 2, collected_total: 2 })).toBe(true);
// 行落了但归一那一轮还没走完(collected_total 没写过)→ 门不开
expect(safeEval(condition, { intel_total: 2, quote_archive_intel_rows: 2 })).toBe(false);
});
it('放行门哨兵带 ifPart:每条有归因 + 口径守恒 + 待定清零 + 定盘记过,全读基座数', () => {
const condition = sentryIf(model.cmmnXml, 's_ready');
expect(condition).toContain('quote_archive_intel_missing = 0');
expect(condition).toContain('quote_archive_intel_share_scope_pending = 0');
const done = {
scope_decided_total: 1,
quote_archive_intel_rows: 3,
quote_archive_intel_missing: 0,
quote_archive_intel_share_scope_pending: 0,
quote_archive_intel_share_scope_full: 1,
quote_archive_intel_share_scope_masked: 2,
quote_archive_intel_share_scope_internal: 0,
};
expect(safeEval(condition, done)).toBe(true);
// 还有一行缺归因 → 门不开
expect(safeEval(condition, { ...done, quote_archive_intel_missing: 1 })).toBe(false);
// 还有条目压在待定上 → 门不开(人还没拍板)
expect(
safeEval(condition, { ...done, quote_archive_intel_share_scope_pending: 1, quote_archive_intel_share_scope_masked: 1 }),
).toBe(false);
// 口径没拍过(scope_decided_total 没写过)→ 门不开
expect(safeEval(condition, { ...done, scope_decided_total: undefined })).toBe(false);
});
it('每个计划项都有剧本', () => {
const ids = new Set((model.steps ?? []).map((s) => s.item));
for (const id of items(model.cmmnXml)) expect(ids.has(id), `${id} 没有剧本`).toBe(true);
});
/* 说明书只讲整单的事;逐步做法归剧本 —— 两处各写一份会分叉出不存在的项名(A1 的账) */
it('说明书只讲整单的事:成品、怎么说话、怎么收口', () => {
const narrative = model.narrative;
for (const mark of ['§0 开案', '§Z 结案', '一路上要说话']) {
expect(narrative, `说明书少了「${mark}」`).toContain(mark);
}
expect(narrative, '说明书不该逐步教做法(那是剧本的事)').not.toContain('doneWhen');
});
/*
* 断点那几拍(依赖断点 / 目标缺口)剧本的 step guide 不摊开,模型手上只剩说明书 ——
* 那张决策卡的三处写死必须在说明书里也有一份,否则它会自编一张(A6 实测)。
*/
it('说明书写死了那一张决策卡的 itemId / ask / 三个 value —— 断点那几拍只剩它', () => {
const narrative = model.narrative;
expect(narrative).toContain('"itemId":"T_scope"');
expect(narrative).toContain('按哪一档进库?');
for (const value of ['full', 'masked', 'internal']) {
expect(narrative, `说明书少了选项 ${value}`).toContain(value);
}
});
});
describe('声明与契约对得上', () => {
const columns = new Set(
manifest.declarations.ledger.flatMap((d) => [`${d.domain}.${d.object}`, ...d.fields.map((f) => f.name)]),
);
it('必交付物的台账地址都声明过', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(columns.has(d.ledger ?? ''), `${d.key} → ${d.ledger}`).toBe(true);
}
});
it('editableAtRelease 的列都在成果表的声明里', () => {
const intel = manifest.declarations.ledger.find((d) => d.object === 'intel');
const names = new Set((intel?.fields ?? []).map((f) => f.name));
for (const d of manifest.contract.requiredDeliverables) {
for (const col of d.editableAtRelease ?? []) {
expect(names.has(col), `editableAtRelease 里的 ${col}`).toBe(true);
}
}
});
const rowReadings = new Set([
...manifest.declarations.ledger.map((d) => `${d.domain}_${d.object}_rows`),
...manifest.declarations.ledger
.filter((d) => d.fields.some((f) => f.required === true || f.requiredWhen !== undefined))
.map((d) => `${d.domain}_${d.object}_missing`),
...manifest.declarations.ledger.flatMap((d) =>
d.fields.flatMap((f) =>
(f.tally ?? []).map((v) => `${d.domain}_${d.object}_${f.name}_${v.toLowerCase().replace(/[^a-z0-9_]/g, '')}`),
),
),
]);
it('L1/L2 谓词读的名字都有承载(台账列或基座读数)', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level !== 'L1' && c.level !== 'L2') continue;
for (const identifier of (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? []) {
if (['and', 'or', 'not', 'true', 'false', 'null', 'in', 'between'].includes(identifier)) continue;
if (rowReadings.has(identifier)) continue;
expect(columns.has(identifier), `${c.key} 的谓词引用了 ${identifier}`).toBe(true);
}
}
});
/*
* **机检必须至少有一边是基座数**(违反史已记七次,每次都是同一个死法:
* 那个数要模型自己重算,漏算/改小一次门就废了)。
*/
it('每条 L1/L2 都至少读一个基座读数 —— 不许两边都由活动自报', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level !== 'L1' && c.level !== 'L2') continue;
const names = (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? [];
expect(names.some((n) => rowReadings.has(n)), `${c.key} 一个基座读数都没读`).toBe(true);
}
});
/*
* **A7 是终点**:流程包 E14(`A7 → end_arch`)是无条件边 —— 没有去向要选,
* 所以一口必交付、选择组为空。摆一个空的选择组会让基座退回「人选一口」,
* 而根本没有第二口可选。
*/
it('只有一口 archived,且没有选择组 —— 终点不是岔路口', () => {
expect(manifest.contract.output.mandatory.map((p) => p.name)).toEqual(['archived']);
expect(manifest.contract.output.selective).toEqual([]);
});
it('出口载荷的标量字段都有台账列承载', () => {
const snake = (name: string) => name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
const ports = [
...manifest.contract.output.mandatory,
...manifest.contract.output.selective.flatMap((g) => g.ports),
];
for (const port of ports) {
const properties = (port.payload as { properties?: Record<string, { type?: string }> }).properties ?? {};
for (const [field, spec] of Object.entries(properties)) {
if (spec.type === 'array') continue;
expect(columns.has(snake(field)), `${port.name}.${field} → ${snake(field)}`).toBe(true);
}
}
});
it('技能引用的工具都在白名单声明内 —— 载方法不载权力', () => {
const declared = new Set(manifest.declarations.tools.map((t) => t.name));
for (const skill of manifest.assets.skills) {
for (const fragment of skill.crystallized) {
expect(declared.has(fragment.tool), `${skill.id} 用了 ${fragment.tool}`).toBe(true);
}
}
});
it('L3 判据的整句出现在放行剧本里 —— judge 才对得上名', () => {
const l3 = manifest.contract.acceptanceCriteria.find((c) => c.level === 'L3');
const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
expect(l3).toBeDefined();
expect(release?.guide, 'T_release 的 guide 要整句抄 L3 判据名').toContain(l3?.label ?? '');
});
});
/*
* 两把尺逐行求值 —— 能算的不问 LLM(规范 §5.6)。
* A-01 的出参就是台账 `price_gap_band` 的取值:模型算完照抄,不必再翻译一道。
*/
describe('败价幅度 A-01 —— 价差 → 下一单怎么用这条情报', () => {
const table = A01_DMN.tables.find((t) => t.id === 'a01-band');
it('三个点以内 → 擦肩而过', () => {
expect(decideBy(table, { gap: 0.02 })).toMatchObject({ outputs: ['SLIGHT'] });
});
it('三到十个点 → 够得着(FIRST 命中,第二行就是这一档)', () => {
expect(decideBy(table, { gap: 0.08 })).toMatchObject({ rule: 'a01-2', outputs: ['MODERATE'] });
});
it('超过一成 → 结构性价差,下一单别再报同一价位', () => {
expect(decideBy(table, { gap: 0.204 })).toMatchObject({ outputs: ['STRUCTURAL'] });
});
it('三个档位码与台账取值域逐字一致', () => {
const tally = manifest.declarations.ledger
.find((d) => d.object === 'intel')
?.fields.find((f) => f.name === 'price_gap_band')?.tally;
expect(new Set(table?.rules.map((r) => String(r.then[0])))).toEqual(new Set(tally));
});
});
describe('入库口径建议 A-02 —— 归因 → 建议哪一档(建议归尺,结论归人)', () => {
const table = A01_DMN.tables.find((t) => t.id === 'a02-scope');
it('输在价格 → 建议脱敏:竞对的价是他给客户的,不是给我们的', () => {
expect(decideBy(table, { cause_tag: 'PRICE' })).toMatchObject({ outputs: ['masked'] });
});
it('不涉第三方报价的败因 → 建议全量入库', () => {
for (const tag of ['LEADTIME', 'CERT', 'SPEC', 'RELATION', 'OTHER']) {
expect(decideBy(table, { cause_tag: tag }), tag).toMatchObject({ outputs: ['full'] });
}
});
it('出参就是决策卡上那三个 value —— 建议标得上,人才点得准', () => {
const guide = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_scope')?.guide ?? '';
for (const value of new Set(table?.rules.map((r) => String(r.then[0])))) {
expect(guide, `拍板剧本少了 ${value}`).toContain(`"${value}"`);
}
});
});
describe('两把尺的档位三处同一份 —— 判定表 / 剧本 / 工作单', () => {
const attribute = manifest.assets.businessModel.steps?.find((s) => s.item === 'S2_attribute')?.guide ?? '';
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'archive-worksheet')?.html ?? '';
it('六个归因码剧本与工作单都写着(大写,一个字母不许差)', () => {
for (const code of ['PRICE', 'LEADTIME', 'CERT', 'SPEC', 'RELATION', 'OTHER']) {
expect(attribute, `剧本少了 ${code}`).toContain(code);
expect(sheet, `工作单少了 ${code}`).toContain(code);
}
});
it('三个败价幅度码剧本里写死', () => {
for (const code of ['SLIGHT', 'MODERATE', 'STRUCTURAL']) {
expect(attribute, `剧本少了 ${code}`).toContain(code);
}
});
it('败价幅度的三档门槛剧本与工作单说的是同一组数', () => {
for (const threshold of ['0.03', '0.10']) expect(attribute, `剧本少了 ${threshold}`).toContain(threshold);
for (const band of ['≤ 3%', '3% ~ 10%', '10%']) expect(sheet, `工作单少了 ${band}`).toContain(band);
});
});
describe('报告模版 —— 活动持有,容器无脚本执行权', () => {
it('两个局面各一张:情报归档工作单(干活)与情报归档清单(收口)', () => {
expect(manifest.assets.viewTemplates.map((t) => t.name)).toEqual(['archive-worksheet', 'archive-roster']);
});
it('剧本每一步的 view 都在模版名单里 —— 换不到画面屏上就不动', () => {
const names = new Set(manifest.assets.viewTemplates.map((t) => t.name));
for (const step of manifest.assets.businessModel.steps ?? []) {
expect(names.has(step.view ?? ''), `${step.item} 的 view=${step.view}`).toBe(true);
}
});
it('放行那一步摆的是验收目标件 —— 最后摆上去的那张就是归档件', () => {
const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
expect(release?.view).toBe('archive-roster');
});
it('模版里没有脚本、没有事件处理器、没有外链 —— 样式自带', () => {
for (const template of manifest.assets.viewTemplates) {
expect(template.html, template.name).not.toMatch(/<script|\son[a-z]+=|https?:\/\//i);
expect(template.html, template.name).toContain('<style>');
}
});
it('工作单是干活的件:逐颗档案 + 两把尺 + 填回工作表', () => {
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'archive-worksheet')?.html ?? '';
expect(sheet).toContain('jc-selcard');
expect(sheet).toContain('输赢归因六档');
expect(sheet).toContain('败价幅度 A-01');
expect(sheet).toContain('data-edit="quote_archive.intel"');
});
it('缺归因红带与提交闸同源 —— 件上摆的是基座的 `_missing` 与主键名单', () => {
for (const name of ['archive-worksheet', 'archive-roster']) {
const html = manifest.assets.viewTemplates.find((t) => t.name === name)?.html ?? '';
expect(html, name).toContain('quote_archive_intel_missing');
expect(html, name).toContain('quote_archive_intel_missing_keys');
}
});
/*
* 编辑面精确等于人在那个局面的职权面 + **行的钥匙 pn**:
* 主键格不挂 `data-col`,服务端按「全表一致列」补键,两行会对到同一行上
* (A2 浏览器走查逮过,e2e 走 API 自带主键测不出来)。
*
* 两张件的编辑面**故意不同**:干活那张收归因四列,收口那张只让改归因与依据
* (= `editableAtRelease`)—— 不同工作各有各的件。
*/
it('工作单的编辑面 = 归因 + 竞对价 + 竞对名 + 依据 + 行的钥匙 pn', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'archive-worksheet')?.html ?? '';
const cols = [...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]);
expect(new Set(cols)).toEqual(new Set(['pn', 'cause_tag', 'competitor_price', 'competitor_name', 'evidence']));
});
it('收口件的编辑面 = editableAtRelease 那两列 + 行的钥匙 pn', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'archive-roster')?.html ?? '';
const cols = new Set([...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]));
const editable = manifest.contract.requiredDeliverables[0]?.editableAtRelease ?? [];
expect(cols).toEqual(new Set(['pn', ...editable]));
});
it('口径分布读的是基座按值现数的四段 —— 守恒等式的可视形', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'archive-roster')?.html ?? '';
for (const code of ['pending', 'full', 'masked', 'internal']) {
expect(roster).toContain(`quote_archive_intel_share_scope_${code}`);
}
});
/*
* 一口活动的落边图**不冒充判过**:A7 只有一口且无条件(E14),
* 所以它不投 `case:routeKey`(那是「选出来的」才有的东西),
* 摆的是「这一签之后谁用得上」—— 右边三个用处各挂本单条数。
*/
it('收口件摆落边图:一口无条件,不投 routeKey 冒充选择', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'archive-roster')?.html ?? '';
expect(roster).toContain('归档闭环');
expect(roster).toContain('quote_archive_intel_cause_tag_price');
expect(roster, '一口活动不该摆去向选择').not.toContain('case:routeKey');
});
it('模版只做取值 —— data-class-of 不以 data-slot 为前提(纯色块曾整条静默失色)', () => {
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'archive-worksheet')?.html ?? '';
expect(sheet).toContain('<i data-class-of="deal_result"></i>');
});
it('宿主拒推含脚本的模版', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
});
});
const CONTRACT = {
activityId: 'quote-archive',
version: '1.0.0',
data: { rfqRef: 'RFQ-2026-0777', rows: [] },
message: undefined,
outputPorts: { mandatory: ['archived'], selective: [] },
prohibitions: [],
discretion: [],
} as const;
describe('代码入口 —— 案例活动没有 run,不是残包', () => {
it('默认导出是一个有名字的活动定义', () => {
expect(activity.name).toBe('quote-archive');
});
it('mock 宿主如实报「没有 run」,不静默返回一个假的 outcome', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.run(activity)).rejects.toThrow(/没有 run/);
});
});
/** 按 DMN 语义逐行求值:FIRST 命中即定,单元格谓词复用 @sfeel */
function decideBy(
table: (typeof A01_DMN)['tables'][number] | undefined,
scope: Record<string, unknown>,
): { outputs: readonly unknown[]; rule: string } | undefined {
if (table === undefined) return undefined;
for (const rule of table.rules) {
const hit = table.inputs.every((input, i) => evaluateUnaryTest(rule.when[i] ?? '-', scope[input.expression], scope));
if (hit) return { outputs: rule.then, rule: rule.id };
}
return undefined;
}
function items(xml: string): string[] {
return [...xml.matchAll(/<(?:humanTask|decisionTask|processTask|caseTask) id="([^"]+)"/g)].map((m) => m[1] ?? '');
}
function parseCmmnLite(xml: string): { manualActivation: string[]; repetition: string[] } {
const manualActivation: string[] = [];
const repetition: string[] = [];
for (const m of xml.matchAll(/<humanTask id="([^"]+)"[\s\S]*?<\/humanTask>/g)) {
if (m[0].includes('manualActivationRule')) manualActivation.push(m[1] ?? '');
if (m[0].includes('repetitionRule')) repetition.push(m[1] ?? '');
}
return { manualActivation, repetition };
}
function sentryIf(xml: string, sentryId: string): string {
const block = xml.slice(xml.indexOf(`<sentry id="${sentryId}"`));
const raw = /<condition>([\s\S]*?)<\/condition>/.exec(block.slice(0, block.indexOf('</sentry>')))?.[1] ?? '';
return raw.replaceAll('&gt;', '>').replaceAll('&lt;', '<').replaceAll('&amp;', '&');
}
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
@@ -0,0 +1,240 @@
/**
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(A1 的账)。
*/
import { describe, expect, it } from 'vitest';
import { evaluateCondition } from '@sfeel';
import { ARCHIVE_PHASES, ARCHIVE_STEPS, CMMN_XML } from '../src/case-model.ts';
import { manifest } from '../src/manifest.ts';
const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map(
(m) => m[1] ?? '',
);
describe('剧本与案例模型对得上 —— 对不上的条目等于没写', () => {
it('每一条剧本都指向一个真实存在的计划项', () => {
for (const step of ARCHIVE_STEPS) {
expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
}
});
it('图上每一步都有剧本', () => {
const scripted = new Set(ARCHIVE_STEPS.map((s) => s.item));
for (const id of ITEM_IDS) expect(scripted.has(id), `${id} 没有剧本`).toBe(true);
});
it('清单里挂的就是这份剧本(不是另抄一份)', () => {
expect(manifest.assets.businessModel.steps).toBe(ARCHIVE_STEPS);
});
});
describe('doneWhen 是「成果已存在」的判据,不是「该不该做」的判据', () => {
const doneWhen = (item: string): string => ARCHIVE_STEPS.find((s) => s.item === item)?.doneWhen ?? '';
const evalAt = (item: string, scope: Record<string, unknown>): boolean => {
try {
return evaluateCondition(doneWhen(item), scope);
} catch {
return false;
}
};
/*
* **开局一步都不该被自动收掉**:S-FEEL 里未绑定的名字参与比较为假,
* 靠的就是这一条 —— 写 `!= null` 那一步会开局即收(A1 踩过)。
*/
it('**开局一步都不该被自动收掉** —— 两种开局都要 false', () => {
// 流内开局:上游载荷在,台账一行没写
expect(
evalAt('S1_collect', {
rows: [{ pn: 'PESD2ETH-XR' }],
quote_archive_intel_rows: 0,
}),
).toBe(false);
// 单飞开局:什么都没有
expect(evalAt('S1_collect', { subject: '补个归档' })).toBe(false);
});
it('整份落表、行数对上、归一那一轮走完 → 收得掉', () => {
expect(evalAt('S1_collect', { intel_total: 2, quote_archive_intel_rows: 2, collected_total: 2 })).toBe(true);
});
it('**第一拍写完行就收步是不许的** —— collected_total 没写过,未绑定的名字比大小为假', () => {
expect(evalAt('S1_collect', { intel_total: 2, quote_archive_intel_rows: 2 })).toBe(false);
});
it('行数两边对不上 → 收不掉(漏落一行当场现形,不等到第 10 拍)', () => {
expect(evalAt('S1_collect', { intel_total: 3, quote_archive_intel_rows: 2, collected_total: 2 })).toBe(false);
});
/*
* **三项不给 doneWhen**:交人归因(发卡触达真人)、入库口径拍板(要人拍)、
* 归档放行(签下去触达世界)—— 给了等于给引擎一把替人签字的钥匙。
*/
it('**逐条归因、入库口径拍板、放行三项都不给 doneWhen**', () => {
for (const item of ['S2_attribute', 'T_scope', 'T_release']) {
const step = ARCHIVE_STEPS.find((s) => s.item === item);
expect(step, `${item} 没有剧本`).toBeDefined();
expect(step?.doneWhen, `${item} 不该有 doneWhen`).toBeUndefined();
}
});
it('归一那一步声明了 reads: rows —— 上游载荷置前不截断', () => {
expect(ARCHIVE_STEPS.find((s) => s.item === 'S1_collect')?.reads ?? []).toContain('rows');
});
/*
* **一单同一张卡只发一次**,且缺信息不再走补全卡(2026-08-02 全局退役)——
* 剧本里不许残留「还缺就再发一张卡」这条已经不存在的路。
*/
it('剧本不教补全卡,也不留「还缺就再发一张」的死路', () => {
for (const step of ARCHIVE_STEPS) {
expect(step.guide, `${step.item} 的 guide 提到了补全卡`).not.toMatch(/补全卡|"kind":"ask"/);
expect(step.guide, `${step.item} 的 guide 教了再发一张卡`).not.toMatch(/再发一张卡|重新发一张卡/);
}
});
/*
* **一单只发一张卡**:人点完之后这一步会被重新念一遍,而 guide 的头一段说什么,
* 模型就先做什么 —— 头一段若是「发一张决策卡」,它就再发一张(A4 实测连发四张,
* 调人预算烧光 exhausted)。所以拍板那一步的 guide 必须**先分岔再发卡**。
*/
it('拍板那一步先问「人点过没有」,再谈发卡 —— 分岔要排在发卡骨架前面', () => {
const guide = ARCHIVE_STEPS.find((s) => s.item === 'T_scope')?.guide ?? '';
const branch = guide.indexOf('找到了');
const skeleton = guide.indexOf('【发一张决策卡');
expect(branch, 'T_scope 的 guide 没有「人点过没有」这一岔').toBeGreaterThanOrEqual(0);
expect(skeleton, 'T_scope 的 guide 没有发卡骨架').toBeGreaterThan(branch);
expect(guide).toMatch(/不许再发|别再发第二张卡/);
});
/*
* **拍板那三条路都得动 share_scope** —— A6 2026-08-03 逼出来的结构账:
* 有一条路写着「口径不动」,人点完了待定条数还是那个数,入场哨兵照旧成立,
* 模型读同一段做法就再发一张卡(实测同一张连发六次)。
* 「办完了」得在数上看得见,措辞治不了。
*/
it('拍板三条路各把 share_scope 改成一个结论码 —— 没有一条留在 PENDING', () => {
const guide = ARCHIVE_STEPS.find((s) => s.item === 'T_scope')?.guide ?? '';
for (const code of ['FULL', 'MASKED', 'INTERNAL']) {
expect(guide, `拍板剧本少了 ${code} 这条路`).toContain(`share_scope 改 **${code}**`);
}
expect(guide).toContain('quote_archive_intel_share_scope_pending');
});
/*
* **三个结论码只有拍板那一步写得出来**:归一那一步替他写了,那张卡就永远不会发,
* 这条对外边界成了摆设(A6 实测:一单从头到尾 released 而没有人拍过板)。
*/
it('归一那一步写死「口径一律 PENDING」,三个结论码一个都不许写', () => {
const collect = ARCHIVE_STEPS.find((s) => s.item === 'S1_collect')?.guide ?? '';
expect(collect).toContain('PENDING');
expect(collect, '归一剧本没说清三个结论码不许写').toMatch(/三个码这一步一个都不许写/);
const attribute = ARCHIVE_STEPS.find((s) => s.item === 'S2_attribute')?.guide ?? '';
expect(attribute, '归因那一步不该顺手写入库口径').toContain('别在这一拍写入库口径');
});
/*
* start 墙已拆(2026-08-02):动它就算启动。剧本里再教 start 只是白烧 token,
* 而且学不稳的礼仪就是死法。
*/
it('剧本不教 start 礼仪 —— 那道墙已经拆了(说「不必单发 start」可以,教它先按一下不行)', () => {
for (const step of ARCHIVE_STEPS) {
expect(step.guide, `${step.item} 的 guide 还在教 start`).not.toMatch(/先\s*start|务必\s*start|要先发\s*start/);
}
});
/*
* 【目标缺口】那一段摆的是计划项的**中文名**;当下可为为空(依赖断点)那几拍,
* 模型手上只剩中文名,于是把它当 itemId 写了进去 —— 「计划项不存在」连拒两拍。
* 发卡骨架旁边必须点名说清写英文 id。
*/
it('发卡骨架写死了 itemId 是英文项号,并点名中文名不是 id', () => {
const guide = ARCHIVE_STEPS.find((s) => s.item === 'T_scope')?.guide ?? '';
expect(guide).toContain('"itemId":"T_scope"');
expect(guide, '没告诉模型中文名不能当 itemId').toMatch(/别写中文名/);
for (const value of ['full', 'masked', 'internal']) {
expect(guide, `发卡骨架少了选项 ${value}`).toContain(`"${value}"`);
}
});
it('剧本不教 listen 等回音 —— 那是一条挂到天荒地老的路', () => {
for (const step of ARCHIVE_STEPS) {
expect(step.guide, `${step.item} 的 guide 教了 listen`).not.toMatch(/"kind":"listen"|立监听|发一个\s*listen/);
}
});
/* 执行卡零输入(2026-08-02 用户定):填写全在工作区的件上 */
it('执行卡零输入 —— 剧本明写不许往卡里塞 blocks / fields', () => {
const guide = ARCHIVE_STEPS.find((s) => s.item === 'S2_attribute')?.guide ?? '';
expect(guide).toMatch(/别往卡里塞/);
expect(guide).toContain('执行卡零输入');
});
});
describe('剧本声明的东西活动真有', () => {
const declaredLedger = manifest.declarations.ledger.map((l) => `${l.domain}.${l.object}`);
const declaredTools = manifest.declarations.tools.map((t) => t.name);
it('每一步要写的台账都在清单的台账声明里', () => {
for (const step of ARCHIVE_STEPS) {
for (const address of step.writes) {
expect(declaredLedger, `${step.item} 要写 ${address}`).toContain(address);
}
}
});
it('每一步要调的工具都在清单的工具声明里', () => {
for (const step of ARCHIVE_STEPS) {
for (const tool of step.tools) {
expect(declaredTools, `${step.item} 要调 ${tool}`).toContain(tool);
}
}
});
it('每一步都定死了摆哪张件,且模版真在包里', () => {
const names = new Set(manifest.assets.viewTemplates.map((t) => t.name));
for (const step of ARCHIVE_STEPS) {
expect(step.view, `${step.item} 没定画面`).toBeDefined();
expect(names.has(step.view ?? ''), `${step.item} 的 view「${step.view}」不在模版名单里`).toBe(true);
}
});
it('每一步都说得出它交出什么', () => {
for (const step of ARCHIVE_STEPS) {
expect(step.intent.trim(), `${step.item} 没写 intent`).not.toBe('');
expect(step.guide.trim().length, `${step.item} 的 guide 太短`).toBeGreaterThan(20);
}
});
/* 卡面是纯文本:星号记号不会变粗体,只会原样摆在人眼前(规范 §5.5-10) */
it('发卡骨架里不写星号记号 —— 卡面是纯文本', () => {
for (const step of ARCHIVE_STEPS) {
for (const line of step.guide.split('\n')) {
if (!/"steps"|"standard"|"ask"|"label"|"consequence"/.test(line)) continue;
expect(line.includes('**'), `${step.item} 的卡面文案带了星号:${line.slice(0, 60)}`).toBe(false);
}
}
});
});
describe('阶段刻度:每一项都有格可待', () => {
const inPhase = new Set(ARCHIVE_PHASES.flatMap((p) => p.items));
it('**除放行外每一项恰好属于一个阶段**', () => {
const planned = ARCHIVE_STEPS.map((s) => s.item).filter((id) => id !== 'T_release');
expect(planned.filter((id) => !inPhase.has(id))).toEqual([]);
});
it('一项不进两个阶段', () => {
const all = ARCHIVE_PHASES.flatMap((p) => p.items);
expect(all.length).toBe(new Set(all).size);
});
it('**放行不在名单里** —— 收口那一格由基座统一加', () => {
expect(inPhase.has('T_release')).toBe(false);
});
it('阶段里不许出现案例模型没有的项', () => {
const ids = new Set(ARCHIVE_STEPS.map((s) => s.item));
expect([...inPhase].filter((id) => !ids.has(id))).toEqual([]);
});
});
+349 -39
View File
@@ -1,61 +1,371 @@
/**
* A7 情报归档的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
*
* 2026-07-29 由步序声明(flow)改写而来。归档这件事没有分支:登记结果 → 更新画像 →
* 统计落地率 → 验收。三件事互不依赖,同阶段并列(CMMN 里同阶段的项本就无隐含次序)。
* 业务出处:`docs/MVP示例工作流.html` §1.6(S1 结果登记 → S2 画像更新 → S3 落地率统计),
* 按 A4/A6 同一条形状重建(2026-08-03 重写 @3.0.0):
*
* 上游(A6 三口之一的载荷 `{rfqRef, customerCode, quoteNo, outcome, rows}`)进来 →
* 归一成**情报卡**底表(一颗料一条:报价 / 成交价 / 结果 / 时间)→ **一张执行单**
* 交 BD:逐条落**输赢归因**与竞对价(归因那一列 AI 一格都不写)→ 按败价幅度 A-01
* 给复用建议 → **入库口径要人拍板**(决策卡:全量 / 脱敏 / 只留内部)→ 放行归档闭环。
*
* 上一版(@2.1.0)六处不合现行口径,这一版整个置换:
* ① 入参必填 `rfqRef + customerCode + quoteNo` —— 而宽进严出是全线口径,归一在活动内做;
* ② 两条判据读活动自报的 `reason_logged_total` / `profile_updated_total` ——
* 「机检只读基座数得出来的数」违反史第七类:那个数模型自己写,写 1 就过;
* ③ 台账一个 `required` 都没有 —— 执行卡的闸没牙,人不填也能交卷;
* ④ **没有人的判断**:归档全由 AI 自己写完自己放行,而输赢归因恰恰是只有跟单的人
* 才说得出的事(客户到底为什么不下单,系统里没有一个字段答得了);
* ⑤ 三张共享域表(`market.price_history` / `customer.profile` / `shared.landing_stats`)
* 同批退役,墓志铭在清单里;
* ⑥ 那段 `run()` 删掉 —— 案例活动不会调它。
*
* 【为什么归档也要叫人】它是全流程最后一站,**这一单之后再没有人会回头看它**。
* 归因写错没有下一道工序纠正,而它会一路流进下一单的判据里 ——
* 「价格高」这种由 AI 从「客户没回」推出来的结论,三个月后没人记得它是编的。
* 所以两处人:归因逐条落在执行单上,入库口径一张决策卡拍板。
*/
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
<case id="archive">
<!--
案卷项的 id **就是任务视图里的键名**。上游载荷(rows)与本活动台账列都摊平到
任务视图顶层,照它们命名;quote_archive_* 那几个是**基座读数**
(行数 / 缺必填 / 按值现数),由基座现算,模型碰不着。
-->
<caseFileModel>
<caseFileItem id="result" name="报价结果"/>
<caseFileItem id="history" name="历史价格情报"/>
<caseFileItem id="profile" name="客户画像"/>
<caseFileItem id="stats" name="报价落地率统计"/>
<caseFileItem id="rows" name="上游条目(报价函行,流内开案即有)"/>
<caseFileItem id="intel_total" name="情报卡条数(自报)"/>
<caseFileItem id="collected_total" name="已归一落表的条数(归一走完的凭据)"/>
<caseFileItem id="attributed_total" name="已落归因的条数(人环走完的凭据)"/>
<caseFileItem id="scope_decided_total" name="入库口径已拍板"/>
<caseFileItem id="quote_archive_intel_rows" name="情报卡行数(基座现数)"/>
<caseFileItem id="quote_archive_intel_missing" name="缺归因的行数(基座现数)"/>
<caseFileItem id="quote_archive_intel_share_scope_pending" name="入库口径还待定的条数(基座现数)"/>
</caseFileModel>
<casePlanModel id="plan" name="情报归档" autoComplete="true">
<sentry id="s_result">
<onPart id="op_rs" caseFileItemRef="result"><standardEvent>create</standardEvent></onPart>
<!--
① 归一完了 —— 交 BD 归因那一步的入场前提。
行数两边对上(一边是基座现数),且归一那一轮走完(collected_total 是归一之后
显式写下的数;「大于等于 0」对「写过的 0」为真、对「没写过」为假)。
-->
<sentry id="s_collected">
<onPart id="op_cl" caseFileItemRef="collected_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_cl"><condition>intel_total &gt; 0 and quote_archive_intel_rows = intel_total and collected_total &gt;= 0</condition></ifPart>
</sentry>
<sentry id="s_logged">
<onPart id="op_lg" caseFileItemRef="history"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_lg"><condition>reasonLogged = true</condition></ifPart>
<!--
② **归因齐了才轮到问入库口径** —— 这个活动的要害(与 A6「还价才叫人」同一形状)。
它读的是**人自己填的那一格**(缺归因的行数由基座按 required 现数,
attributed_total 只在人交回执行单之后才产生),不是模型判出来的结论列。
A6 两版都栽在同一个病根上 —— **「该不该叫人」不能由被叫的那一方说了算**。
入库口径这一格也照同一条办:AI 归一时一律写 PENDING(过渡态),
FULL / MASKED / INTERNAL 三个码**只有拍板那一步写得出来**;
待定条数归零,这一步就不会再找人,放行门也才开得了。
-->
<sentry id="s_attributed">
<onPart id="op_at" caseFileItemRef="attributed_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_at"><condition>attributed_total &gt; 0 and quote_archive_intel_rows &gt; 0 and quote_archive_intel_missing = 0 and quote_archive_intel_share_scope_pending &gt; 0</condition></ifPart>
</sentry>
<!--
── 放行门的入场条件:**认「办完了没有」,且读基座数**────────────────
· 每条都有归因(quote_archive_intel_missing 由基座按 required 现数,
与执行卡的提交闸、放行门 L1 同一个数同一套算法);
· **没有还压在待定口径上的条目**(拍板那一下把 PENDING 换成三个结论码之一);
· 口径拍过了(scope_decided_total 是拍板那一步显式写下的记账数);
· 口径四档相加 = 行数(守恒,两边都是基座按值现数)。
-->
<sentry id="s_ready">
<onPart id="op_rd" caseFileItemRef="scope_decided_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_rd"><condition>scope_decided_total &gt; 0 and quote_archive_intel_rows &gt; 0 and quote_archive_intel_missing = 0 and quote_archive_intel_share_scope_pending = 0 and quote_archive_intel_share_scope_full + quote_archive_intel_share_scope_masked + quote_archive_intel_share_scope_internal + quote_archive_intel_share_scope_pending = quote_archive_intel_rows</condition></ifPart>
</sentry>
<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="S1_collect" name="归一情报卡:逐条承继报价与结果,标上报价日期">
<itemControl><repetitionRule id="r_s1"/></itemControl>
</humanTask>
<timerEventListener id="TL_half" name="时限过半">
<timerExpression>PT12H</timerExpression>
</timerEventListener>
<!--
② 逐条归因 —— **一张执行单**(输赢的缘由只有跟单的人说得出)。
manualActivation + 不给 doneWhen(发卡触达真人,引擎不替人按)。
-->
<humanTask id="S2_attribute" name="逐条落输赢归因与竞对价:工作单上一条一格">
<entryCriterion id="ec_s2" sentryRef="s_collected"/>
<itemControl><manualActivationRule id="m_s2"/></itemControl>
</humanTask>
<!--
③ 入库口径拍板 —— 情报进不进共享库、要不要脱敏,这一下触达公司对外的边界:
竞对报价与客户身份写进共享库之后,下一单谁都读得到。必须人工激活。
-->
<humanTask id="T_scope" name="入库口径拍板:这一单的情报按哪一档进库">
<entryCriterion id="ec_sc" sentryRef="s_attributed"/>
<itemControl><manualActivationRule id="m_sc"/><repetitionRule id="r_sc"/></itemControl>
</humanTask>
<!-- 归档闭环放行:终局唯放行 —— 触达世界,必须人工激活 -->
<humanTask id="T_release" name="归档闭环验收放行">
<entryCriterion id="ec_rel" sentryRef="s_ready"/>
<itemControl><manualActivationRule id="m_rl"/></itemControl>
</humanTask>
</casePlanModel>
</case>
</definitions>`;
export const NARRATIVE = `§0 开案:一单报价有了结果(成交 / 拒绝 / 超时)即开案。
这件事不产生对外交付,它产生的是**下一单的本钱** —— 报价落地率、客户画像、竞对价位。
/**
* 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本,两处不重复写同一件事)。
*/
export const NARRATIVE = `§0 开案:**一单报价有了结果,把它沉淀成下一单用得上的情报**。来路两种,形状都收(宽进):
· **流内转来**:任务数据里有 \`rows\` 数组 —— A6 的报价函行(每条带报价、客户表态与定案),
外加 \`outcome\`(won / lost)说明这一单是成了还是丢了;这是最全的一种来路;
· **单飞**:只有一段需求文字,说了哪一单、给谁报的、什么价、结果如何。
S1 回写沉淀:先登记结果 —— 拒绝的要写清原因与竞对价位,**「客户没回」不是原因**,
问不出来就写「未获反馈」,不许编一个听着合理的理由。
再按结果更新客户画像标签与等级(利润模型 P-01 的客户等级因子取的就是它),
并把这一单计入当月落地率统计(TypeA 命中率、D+3 回复率一并算)。
**成品只有一样**:一份**情报卡清单** —— 一颗料一条,每条都有**输赢归因**,
每条都定了**入库口径**。人签的不是「归了个档」,是**这一单留下了什么、下一单谁用得上**。
§Z 结案:原因已回写、画像已更新、BD 签字放行,自动结案。
归档做得好不好,不在这一单看得出来 —— 在下一次报价能不能一眼看到「上次为什么丢」看得出来。`;
**这一单的价值全在下一单**:今天记下的败因,是下次定价时的判据;今天记下的成交价,
是下次的基准。所以归档不是收尾的仪式,是情报生产。
**「客户没回」不是原因** —— 问不出来就如实写「说不清」,**不许编一个听着合理的理由**:
编出来的原因会一路流进下一单的判据里,而那时已经没人记得它是编的。
**能算的不问人,该人说的不代说**:败价幅度 A-01(价差 → 复用建议)、入库口径建议 A-02
(归因 → 建议档位),两支判定表说了算,你不临场拍;而**输赢归因、竞对价、客户原话
三列是人落的,你一格都不写** —— 客户到底为什么不下单,系统里没有一个字段答得了。
**三步走**:归一情报卡 → 交 BD 逐条归因 → 入库口径拍板 → 放行闭环。
**这一单要经过人两次**:BD 在情报归档工作单上逐条落归因(那张单绑着整份情报卡,
归因缺一条按不下提交 —— 基座的闸看着,他填的格子直接落账);
**入库口径另叫他拍一次板**(一张决策卡)。竞对报价与客户身份写进共享情报库之后,
下一单谁都读得到 —— 这条边界不该由系统自己越过。最后 BD 放行签字。
**这是全流程最后一站**:放行之后没有下一个节点,只有一口 \`archived\`(归档闭环)。
去向不必你选,也没得选。
【这一单只有一个岔路口,它长这样 —— 无论哪一拍都照这个发,别自己另编一张】
入库口径要人拍板,那张决策卡**三处写死**:
· \`"itemId":"T_scope"\` —— 英文计划项号。**别写中文名「入库口径拍板」**:中文名不是 id,
写了当场被拒(【目标缺口】那一段摆的就是中文名,别照它抄);
· \`"ask":"<N> 条情报待定入库口径,按哪一档进库?"\` —— 只把 N 换成条数,**别的字一个都不改**。
换了问法,系统就认不出这是同一张卡:人已经点过的那张会被你再发一遍;
· 三个 \`value\`:\`full\`(全量入库)、\`masked\`(脱敏入库)、\`internal\`(只留内部)。
label 与 consequence 可以按本单的数改写,**value 不行**。
**FULL / MASKED / INTERNAL 三个码只有拍板那一步写得出来** —— 归一那一步遇到情报一律落
PENDING,替他写了口径,那张卡就永远不会发,这条边界成了摆设。
拍板之前**别急着发卡也别绕路** —— 归一那一步先把情报卡写完、finish;
下一拍引擎会把拍板那一步的做法摊开给你,那时再发。
【一路上要说话】负责人正看着这一单。**每一拍都带一句 say** —— 说业务的话:
这一单最后怎么样、归了几条情报、下次遇到同类该注意什么;不说系统词。
到了要找人的那一拍,先说清这一轮办完了什么、为什么现在需要他。
【怎么把一项干完 · 一拍连做】write 和 tool 都不会自动收步 —— 做完用 finish 宣告。
动作排进同一拍的 actions 串一口气给;只有发卡 / 提交(挂起)必须停拍。
**这一单常路 6-8 拍就该收口**。
§Z 结案:BD 在验收单上签字,系统把草稿合并成正式情报卡并发出 \`archived\` 那一口 ——
这条工作流就此走到终点。归档做得好不好,不在这一单看得出来,
在下一次报价能不能一眼看到「上次为什么丢」看得出来。`;
/**
* 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。
*
* 三处不给 `doneWhen`:`S2_attribute`(发卡触达真人)、`T_scope`(要人拍板)、
* `T_release`(签下去触达世界)—— 给了等于给引擎一把替人签字的钥匙。
*/
export const ARCHIVE_STEPS = [
{
item: 'S1_collect',
intent: '整份归一成情报卡:逐条承继报价与结果、标上报价日期、口径一律先落待定',
writes: ['quote_archive.intel', 'quote_archive.verdict'],
/* 上游载荷数组不声明就会被整表段的截断刀砍掉(砍掉的恰是要归一的行) */
reads: ['rows'],
view: 'archive-worksheet',
tools: [],
/*
* 归一走完才算办完:行数两边对上(一边是基座现数),
* 且 collected_total 写过(S-FEEL 未绑定的名字参与比较为假,所以「没写过」进不来)。
*/
doneWhen: 'intel_total > 0 and quote_archive_intel_rows = intel_total and collected_total >= 0',
guide: `第一件事:把这一单的**情报卡**落出来 —— 一拍连做,别拆成两拍。
【条目从哪儿来】
· 流内转来(\`rows\` 里的行带 pn / quoted_price / reply_type / deal_status):照抄 ——
报过的价、谈成的价都是既成事实,**一个数字都别改**;带 line_total 这类总数的是结论行,
不是料,别落进情报卡。
· 单飞:照正文归一,列了几颗落几行;正文里说的报价与结果就是要沉淀的那一份。
**按 pn 归并,一颗料一行**;pn 一个字符不许改(它是主键,改一个字就是新开一行)。
【写 \`quote_archive.intel\`,一颗料一行】(rows 收数组)
rfq_ref(入参有就原样抄;没有从主题认;认不出才自己造一个,**造出来的必须以 \`ARC-\` 开头**
(\`ARC-<主题或第一颗料的型号>\`)。两条硬规矩:
**① 整单只有一个单号,不是一颗料一个** —— 它是这一单的案号,情报卡所有行、verdict 那一行
用的都得是同一个串(实测栽过:两颗料造出两个号,一单裂成两单);
**② 只从本单来件取材** —— 台账里躺着的 RFQ-xxxx 是别人的单,借它的号等于把两单的行
混进同一个案卷(实测栽过一轮))、pn、customer_code、customer_name、category、brand、eau、
**quoted_price**(我方报过的价,原样抄)、**final_price**(成交了才有;没成交留空)。
另两列**照这个口径落**:
· **deal_result** —— 这一条的结果,只许写 \`WON\`(成交)或 \`LOST\`(不成交)两个大写码之一。
上游给了 outcome 就照它:won → WON,lost / revise → LOST;
逐行还带着 deal_status 的,以行上的为准(WON → WON,其余一律 LOST)。
认不出就按 **LOST** 落 —— 归档里不许把说不清的算成成交。
· **quoted_at** —— 报价日期,写成 \`YYYY-MM-DD\`。来件里有报价日就抄它,没有就写今天。
这一格是下一单查「距上次报价多久」的那把尺,别留空。
【**这三列你一格都不写**:cause_tag / competitor_price / evidence】
输赢归因、竞对报的那个价、客户原话 —— 这三样只有跟单的人知道,**系统不替人归因**。
它们是 BD 在工作单上落的;price_gap_band 那一列要等竞对价回来才算得了,这一步也别写。
这不是客气话:一条要你分辨「这行能不能替人填」的规矩会走岔;
**「这几列不写」没有分支可走岔。**
【**share_scope 这一列一律写 \`PENDING\`**】入库口径是人在决策卡上拍的,
\`FULL\` / \`MASKED\` / \`INTERNAL\` 三个码这一步一个都不许写 —— 替他写了,那张卡就永远不会发。
【同一拍写 \`quote_archive.verdict\`(一行)】
rfq_ref、**quote_no**(报价单号:来件有就抄,没有写 \`Q-\` 开头 + 本单案号的尾段,整单一个)、
customer_code / customer_name(顶层有就抄)、**outcome**(整单结果:有一条 WON 就写 \`won\`,
一条都没有就写 \`lost\`)、**intel_total** = 情报卡行数、**collected_total** = 这一拍归一落表的
条数(0 也要写)、summary 一句人话(几条料、给谁报的、最后成没成)。
写完 finish 这一项。`,
},
{
item: 'S2_attribute',
intent: '一张执行单交 BD:逐条落输赢归因、竞对价与客户原话',
writes: ['quote_archive.intel', 'quote_archive.verdict'],
reads: ['quote_archive.intel'],
view: 'archive-worksheet',
tools: [],
/* 不给 doneWhen —— 发卡触达真人(manualActivation),引擎不替人按 */
guide: `情报卡落好了,**该 BD 出场了** —— 这是本活动唯一的一张执行单。
输赢的缘由只有跟单的人说得出:客户到底为什么不下单、竞对报了多少、他原话怎么说的。
工作区摆的是**情报归档工作单**:逐颗料一张情报档案卡(报价、结果、客户约束),
归因六档与败价幅度的尺挂在上面,填回区长在同一屏 —— 他在「输赢归因」那一列逐行落。
这一步标了人工激活:发卡就算启动,不必单发 start;引擎也不会替你收 ——
人交回后要你自己 finish。
【照这个骨架发,别少字段】(卡面是纯文本,steps/standard 里别写星号记号)
{"kind":"human","itemId":"S2_attribute","workOrder":"execution","card":{
"title":"情报归因:<单号> 共 <N> 条待归因",
"ask":"这一单的情报卡已按报价与结果归好,报价、成交价与日期都在工作单上。请逐条落输赢归因:输在价格 / 交期 / 认证 / 规格 / 关系,还是说不清;输在价格的把竞对报的那个价也填上,并把客户原话记进依据一栏。",
"steps":["归因只有六档,选最主要的那一个:PRICE 价格 · LEADTIME 交期 · CERT 认证资质 · SPEC 规格不匹配 · RELATION 关系或既有供应商 · OTHER 说不清",
"「客户没回」不是原因 —— 问不出来就落 OTHER 并在依据里写明「未获反馈」,不要编一个听着合理的理由:它会一路流进下一单的判据里",
"归因落 PRICE 的行必须填竞对价:没有那个数,「输在价格」下一单读不出任何东西 —— 缺这一格这张单按不下提交",
"依据一栏尽量记客户原话:「你们比 XX 贵两毛」比「价格偏高」值钱得多 —— 下一单定价时看的就是这一句"],
"standard":"整份情报卡每一条都有归因,归因落 PRICE 的那几条填了竞对报的价 —— 缺一条这张单按不下提交(系统的闸看着)。",
"basis":[{"k":"<PN>","v":"<报价 · 成交价 · 结果 · 报价日期>"}]}}
· \`basis\` 逐条列本单的料:PN + 报价与结果。几颗写几条,别铺全表。
· **卡上不摆表、不摆格子**(执行卡零输入):整份情报卡就在工作单那份件上,
归因、竞对价、竞对名、依据四列都能就地改、行能撤 —— **别往卡里塞 \`blocks\` 或 \`fields\`**。
· **这张卡带提交闸**:归因(必填列)还空着,他按不下提交 —— 缺几格、缺在哪行,
闸上与件上的红带都写着(与放行门 L1 同一个数同一套算法)。
发完卡这一拍就结束了(发卡挂起)。
【人交回之后 —— 他落的归因基座已经替你写回台账了】这一步你只做三件事,然后 finish:
① **算 price_gap_band**(只给归因是 PRICE 且填了竞对价的那几行):按败价幅度 A-01,
价差 gap =(quoted_price − competitor_price)/ quoted_price,只许写这三个大写码之一:
· gap ≤ 0.03 → **SLIGHT**(擦肩而过:下一单同价位再试,输的多半不在价格);
· 0.03 < gap ≤ 0.10 → **MODERATE**(有差距但够得着:下一单让到这一档或换替代料降本);
· gap > 0.10 → **STRUCTURAL**(结构性价差:下一单别再报同一价位,先走替代选型)。
不是 PRICE 的行、或没有竞对价的行,这一格给空串 \`""\`,**不许写 0 也不许编一个码**。
② 写 verdict:**attributed_total** = 已落归因的条数(照情报卡行数记);
他在件上撤掉了行的话,**intel_total 跟着改成撤后的实数**。
③ finish 这一项。
**别在这一拍写入库口径** —— 那是下一步人拍板的事(它有自己的一段做法),这里写了也白写。
情报卡其余列你一列都不用重写:他落的值基座已经落进台账了,重写会被「写了等于没写」拒回。
【这张卡一单只发一次】人交回来之后不许再发同一张卡 —— 必填有提交闸兜着,
卡回来时归因一定是齐的(或那几行已被他撤掉)。`,
},
{
item: 'T_scope',
intent: '入库口径要人定 —— 发一张决策卡,照他选的那一档把待定口径改掉',
writes: ['quote_archive.intel', 'quote_archive.verdict'],
reads: ['quote_archive.intel'],
view: 'archive-roster',
tools: [],
/* 人工激活 + 要人拍板 —— 不给 doneWhen,引擎不许替人收 */
guide: `**这一单只发一张决策卡。动手之前先在轨迹里找一行 \`[card-reply]\` 带「人选了:」:**
· **找到了** —— 跳到下面【人点完之后】那一段,照他选的那一档把那几行的 share_scope
从 PENDING 改掉、写 scope_decided_total、finish。**一个 decide 都不许再发**
(实测栽过:人点完了,而这一步又从头念了一遍「发一张决策卡」,同一张卡连发六次,
前两次无事发生、后四次被拒 —— 他点过的那一下就白点了);
· **没找到** —— 才轮到下面【发一张决策卡】那一段。
【这一步办完了没有,看一个数】待定口径条数 quote_archive_intel_share_scope_pending:
**它归零就是这一步办完了**。PENDING 是过渡态(情报归好了、还没定进哪一档库),
拍板那一下必须把它换成 FULL / MASKED / INTERNAL 之一 —— 三档都是结论。
这个数不归零,放行门开不了;它归零,这一步也就不会再找你。
这一步只在**归因齐了**(基座现数 quote_archive_intel_missing = 0 且人交回过执行单)时入场 ——
读的是人自己填的那一格,不是你判的结论:该不该叫人,不由被叫的那一方说了算。
它触达公司对外的边界(竞对报价与客户身份进了共享库,下一单谁都读得到),标了人工激活:
发卡就算启动,不必单发 start。
工作区这时摆的是**情报归档清单**,整份情报卡与归因分布都在上面,他看得见全局再拍板。
【发一张决策卡,照这个骨架】(选项少于两个基座拒发)
**三处一个字都不许改**(与说明书里那一段是同一份):
· \`itemId\` 只写英文计划项号 \`T_scope\` —— **别写中文名「入库口径拍板」**,
中文名不是 id,写了当场被拒(实测栽过两拍:「计划项不存在:异议拍板」);
【目标缺口】那一段摆的是中文名,它不是 id,别照它抄。
· \`ask\` 照抄「<N> 条情报待定入库口径,按哪一档进库?」,**只把 N 换成条数** ——
换了问法系统就认不出这是同一张卡,人已经点过的那张会被你再发一遍(实测发出去两张)。
· 三个 \`value\` 照抄 \`full\` / \`masked\` / \`internal\` —— 它们是三档的代号,
换了名字下一段的对应关系就对不上,你自己也不知道人点的是哪一档。
label 与 consequence 可以按本单的数改写,value 不行。
{"kind":"decide","itemId":"T_scope","ask":"<N> 条情报待定入库口径,按哪一档进库?",
"options":[
{"value":"full","label":"全量入库(客户与竞对都留名)","consequence":"下一单查这颗料时,客户是谁、竞对是谁、报了多少一目了然 —— 情报最值钱,但客户身份与第三方报价也就此对内公开"},
{"value":"masked","label":"脱敏入库(只留价位与败因)","consequence":"竞对与客户的名字抹掉,价位、归因、日期照留 —— 下一单照样判得了价,追不到是谁说的"},
{"value":"internal","label":"只留内部(不进共享情报库)","consequence":"这一单的情报只在本案卷里躺着,下一单查不到 —— 涉密或客户明说不外传时才选这一档"}],
"basis":[{"k":"<PN>","v":"归因 <码> · 报价 <X> · 竞对价 <Y> · 败价幅度 <码>"}]}
· basis 逐条摆本单的料:归因、报价、竞对价、败价幅度 —— 裸卡基座拒发。
· **recommended 按入库口径建议 A-02 标**,只标一项:归因里有 PRICE 的(带着第三方报价)
标 \`masked\`;一条 PRICE 都没有的标 \`full\`。
· 发完这一拍就结束了(发卡挂起)。
【人点完之后】轨迹上那一行长这样:\`[card-reply] …人选了:xxx\`。**照那一档把口径改掉,
别再发第二张卡**,一拍连做 —— **整份情报卡逐行都要动 share_scope**,一条都不许留在 PENDING:
· 选 full:每行 share_scope 改 **FULL**;verdict 的 share_policy 写 \`full\`;
· 选 masked:每行 share_scope 改 **MASKED**;verdict 的 share_policy 写 \`masked\`;
· 选 internal:每行 share_scope 改 **INTERNAL**;verdict 的 share_policy 写 \`internal\`。
然后把 verdict 写全:**scope_decided_total 记 1**、**buyer_profile** 一句客户行为画像
(照这一单看得出来的写:价格敏感 / 决策快慢 / 认证门槛 / 常用条款,是 MVP §1.6 S2 那一步的产物)、
summary 补一句拍板结果,finish 这一项。
改完之后待定口径条数就是 0 了,这一步再也不会找你。`,
},
{
item: 'T_release',
intent: '情报已沉淀、口径已定,提交放行交 BD 验收',
writes: [],
reads: [],
view: 'archive-roster',
tools: [],
/* 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 doneWhen) */
guide: `每条都有归因、口径全定过、待定清零 —— 可以走放行了
(T_release 人工激活:动它就算启动,不必单发 start)。先把裁量判据裁掉,然后 submit,同一拍连做:
· judge criterion="情报站得住:每条归因说得出来路、竞对价与客户原话可溯,BD 对这份情报负责" ——
看情报卡里每一行的归因与依据:说得出来路就 satisfied=true;
有行说不出的(比如归因落了 PRICE 却没有客户原话),satisfied=**false**,rationale 逐行写清,
交签字的人定。**裁成不满足照样能提交** —— 理由连着卡一起交给他。
criterion 照判据名**整句原样抄**,写别的名字对不上。
**去向没得选**:这是全流程最后一站,只有一口 \`archived\`(归档闭环),放行那一刻自动发射。
你要做的只是把情报落准,别在提交里手选出口。
终局唯放行:你不能自己宣布完成。`,
},
];
/**
* 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段;
* 放行那一格由基座统一加,人看到的是 x/4。
*/
export const ARCHIVE_PHASES = [
{ name: '归一情报卡', items: ['S1_collect'] },
{ name: '逐条归因', items: ['S2_attribute'] },
{ name: '入库口径拍板', items: ['T_scope'] },
];
+14 -33
View File
@@ -1,37 +1,18 @@
/**
* 统一报价 A7 · 情报归档 —— 活动包代码入口(ADR-004)。
* 「不成交也留资产」:把归档结果摆给人看,确认无空档再提交放行。
* 统一报价 A7 · 情报归档 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
*
* **这个活动没有 `run`,不是缺件。**
*
* 案例活动的推进由 Loop 驱动,行为全部写在清单里:案例模型 + 说明书 + 步骤剧本 +
* 两把判定表 + 技能 + 台账声明 + 两张 HTML 报告模版。
* 上一版这里有一段 `run()`(读 `shared.history_price`、推一个 kv 块、按行数返回
* await/submit)—— 案例活动根本不会调它;更坏的是它读的那张表**从来没被声明过**
* (声明的是 `market.price_history`),而它推的那套呈现与 `viewTemplates` 里的模版
* 各说各话,没人看得出以哪份为准。
*
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 —— 它是**被静态审扫描的产物文本**
* (查 import 与全局引用,证明活动包零环境能力)。
*/
import { defineActivity } from '@sdk';
import type { ActivityBase, ActivityOutcome } from '@sdk';
export default defineActivity({
name: 'quote-archive',
paradigm: 'flow',
async run(base: ActivityBase): Promise<ActivityOutcome> {
const history = await base.ledger.read('shared.history_price');
const stats = await base.ledger.read('shared.landing_stats');
await base.interaction.pushView([
{
t: 'kv',
title: '归档闭环',
items: [
{ k: '情报回写', v: `${history.length} 条` },
{ k: '落地率', v: stats.length > 0 ? String(stats[0]?.['landing_rate'] ?? '—') : '—' },
{ k: '客户', v: String(base.contract.data['customerCode'] ?? '') },
],
},
]);
// 不得空归档 —— 每一单不成交的报价也是情报资产
if (history.length === 0) {
await base.interaction.say('情报尚未回写,继续登记结果。', 'conclusion');
return { kind: 'await', reason: '等待结果登记步完成' };
}
await base.interaction.say('拒绝原因与竞对价位已回写,画像与落地率已落账。', 'criterion');
return { kind: 'submit', deliverable: 'history-archive', rationale: '情报、画像、落地率三样齐备' };
},
});
export default defineActivity({ name: 'quote-archive' });
+493 -62
View File
@@ -1,141 +1,572 @@
/**
* 活动包清单 —— 统一报价 A7 · 情报归档。
* 活动包清单 —— 统一报价 A7 · 情报归档(案例活动)。
*
* 业务素材:docs/MVP示例工作流.html §1.7(S1 结果登记 → S2 画像更新 → S3 落地率统计)。
* 「不成交也留资产:情报、客户画像、落地率」—— 写的是**共享域**,跨活动的沉淀事实。
* 业务素材:`docs/MVP示例工作流.html` §1.6(S1 结果登记 → S2 画像更新 → S3 落地率统计):
* 「不成交也留资产:情报、客户画像、落地率」
* 「拒绝或无回复的单子 7 天内闭环归档。每一单不成交的报价也是情报资产 ——
* 沉淀下来,下一单更快更准」。
*
* 形状与 A4/A6 同一条(2026-08-03 重写 @3.0.0):归一情报卡 → 一张执行单交人
* (逐条落输赢归因与竞对价)→ 按败价幅度给复用建议 → **入库口径要人拍板**
* (决策卡)→ 放行归档闭环。
* **能算的不问人**(A-01 败价幅度、A-02 入库口径建议两把尺写死),
* **该人说的不代说**(归因、竞对价、客户原话那三列 AI 一格都不写)。
*
* 与 @2.1.0 的差别(六处都是踩过的账,逐条见 case-model 头注):
* ① `input.required` 从 `['rfqRef','customerCode','quoteNo']` 清空 —— 宽进严出;
* ② 两条 L1/L2 判据读 `reason_logged_total` / `profile_updated_total` ——
* **活动自报的数**,写个 1 就过(「机检只读基座数」违反史第七类);
* ③ 台账补 `required` / `requiredWhen` —— 从前一个必填都没有,执行卡的闸没牙;
* ④ 补两处人的判断(归因 + 入库口径)—— 从前 AI 自己写完自己放行;
* ⑤ 三张共享域表退役(墓志铭见 `declarations.ledger` 头注);
* ⑥ `index.ts` 里那段 `run()` 删掉 —— 案例活动不会调它。
*/
import type { ActivityManifest } from '@contracts';
import { ARCHIVE_SKILLS } from './skills.ts';
import { CMMN_XML, NARRATIVE } from './case-model.ts';
import type { ActivityManifest, DmnModel } from '@contracts';
import { activityIdSchema } from '@contracts';
import { ARCHIVE_LOGGED, ARCHIVE_RELEASE } from './views/report.ts';
import { ARCHIVE_SKILLS } from './skills.ts';
import { ARCHIVE_PHASES, ARCHIVE_STEPS, CMMN_XML, NARRATIVE } from './case-model.ts';
import { ARCHIVE_ROSTER, ARCHIVE_WORKSHEET } from './views/report.ts';
const D = 'quote_archive';
const ACTIVITY_ID = activityIdSchema.parse('quote-archive');
/**
* 两把尺 —— **规则本身不是数据**,随包冻结。
*
* · 败价幅度 A-01:我方报价与竞对价的价差 → 这条情报下一单该怎么用
* (出参就是台账 `price_gap_band` 的取值);
* · 入库口径建议 A-02:输赢归因 → 决策卡上标 recommended 的那一档
* (出参是三个 `value` 之一,不落台账 —— 建议不是结论,结论由人拍)。
*
* 出参键名与台账列同名,模型算完照抄即可(规范 §5.6)。
* A-01 的档位在三处出现:这张表、剧本 guide、工作单上的规则块 ——
* 包内自测把三处钉在一起(改一处不改另两处,测就红)。
*/
export const A01_DMN: DmnModel = {
drd: [
{ decision: 'a01-band', requires: [], inputs: ['gap'] },
{ decision: 'a02-scope', requires: [], inputs: ['cause_tag'] },
],
tables: [
{
/** 败价幅度 A-01 —— FIRST 命中即定,所以第二行等价于「三到十个点」 */
id: 'a01-band',
label: '败价幅度 A-01',
hitPolicy: 'FIRST',
inputs: [{ name: 'gap', expression: 'gap' }],
outputs: [{ name: 'price_gap_band' }],
rules: [
{ id: 'a01-1', when: ['<= 0.03'], then: ['SLIGHT'], annotation: '三个点以内 —— 擦肩而过,下一单同价位再试,输的多半不在价格' },
{ id: 'a01-2', when: ['<= 0.10'], then: ['MODERATE'], annotation: '三到十个点 —— 够得着,下一单让到这一档或换替代料降本' },
{ id: 'a01-3', when: ['> 0.10'], then: ['STRUCTURAL'], annotation: '超过一成 —— 结构性价差,下一单别再报同一价位,先走替代选型' },
],
},
{
/**
* 入库口径建议 A-02 —— 只出**建议**,决策卡上那一枚 recommended 就是它。
* 带着第三方报价的情报默认建议脱敏:竞对报价写进共享库,下一单谁都读得到。
*/
id: 'a02-scope',
label: '入库口径建议 A-02',
hitPolicy: 'FIRST',
inputs: [{ name: 'cause_tag', expression: 'cause_tag' }],
outputs: [{ name: 'scope_hint' }],
rules: [
{ id: 'a02-1', when: ['"PRICE"'], then: ['masked'], annotation: '归因带竞对报价 —— 建议脱敏入库:价位与败因照留,谁说的抹掉' },
{ id: 'a02-default', when: ['-'], then: ['full'], annotation: '不涉第三方报价的败因 —— 建议全量入库,下一单查得越全越好' },
],
},
],
};
export const manifest: ActivityManifest = {
identity: {
activityId: ACTIVITY_ID,
name: 'A7 · 情报归档',
packageVersion: '0.1.6',
packageVersion: '3.0.0',
category: '统一报价',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-quote-archive',
},
/* ── 契约七件 ────────────────────────────────────────────────── */
contract: {
activityId: ACTIVITY_ID,
version: '0.1.0',
version: '1.0.0',
input: {
/*
* **宽进到底:一个必填都不设**(与 A1/A2/A3/A4/A6 同口径 —— 宽进严出,
* 输入契约在活动内部归一 + 补全)。
*
* 来路两种形状:流内转来(A6 三口的载荷 `{rfqRef, customerCode, quoteNo,
* outcome, rows}` —— `lost` 走 E13 直接进来,`won` 那一口的单子结案时也归这一份档)、
* 单飞(一段文字)。上一版必填 `rfqRef + customerCode + quoteNo`,
* 门口拦的恰是单飞那条路,而归档最常见的来路就是有人补一单历史。
*/
data: {
type: 'object',
required: ['customerCode', 'quoteNo'],
required: [],
properties: {
/** 归档写 (pn, quoted_at) 这一行,两个主键都由调用方给定 */
pn: { type: 'string' },
quotedAt: { type: 'string' },
customerCode: { type: 'string' },
quoteNo: { type: 'string' },
reason: { type: 'string' },
competitorPrice: { type: 'number' },
rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' },
customerCode: { type: 'string', title: '客户码' },
quoteNo: { type: 'string', title: '报价单号' },
outcome: { type: 'string', title: '上游议价结果(won / lost / revise)' },
reason: { type: 'string', title: '不成交缘由(上游给的一句话,归因时的第一手材料)' },
rows: { type: 'array', title: '上游条目(报价函行,带报价、客户表态与定案)' },
items: { type: 'array', title: '别的来路也收(条目数组)' },
subject: { type: 'string', title: '需求主题' },
body: { type: 'string', title: '需求正文' },
attachments: {
type: 'array',
format: 'file',
title: '附件',
items: {
type: 'object',
properties: {
name: { type: 'string', title: '文件名' },
contentType: { type: 'string', title: '类型' },
size: { type: 'integer', title: '字节数' },
ref: { type: 'string', title: '存档号' },
text: { type: 'string', title: '读出来的文本' },
},
},
},
},
},
messageAllowed: true,
messageHint: '客户这一单的行为观察、竞对是谁、是否涉密不许外传,写这里',
},
output: {
/*
* **一口,且是必交付口**:A7 是流程包的最后一站(E14 `A7 → end_arch` 是无条件边),
* 没有去向要选 —— 归档闭环就是把情报落准、口径定死。
* 载荷全部从**已落地的事实**推导(verdict 那一行),不由模型另述一遍。
*/
mandatory: [
{
name: 'archived',
payload: { type: 'object', properties: { landingRate: { type: 'number' } } },
description: '归档闭环:情报回写 + 落地率落账',
payload: {
type: 'object',
required: ['rfqRef', 'intelTotal'],
properties: {
rfqRef: { type: 'string', title: '询价单号(单标识)' },
customerCode: { type: 'string', title: '客户码' },
quoteNo: { type: 'string', title: '报价单号' },
outcome: { type: 'string', title: '这一单最后成没成(won / lost)' },
intelTotal: { type: 'integer', title: '归档情报条数' },
sharePolicy: { type: 'string', title: '入库口径(full / masked / internal —— 人拍的)' },
rows: { type: 'array', title: '情报卡(逐条归因与败价幅度)' },
},
},
description: '归档闭环:情报卡逐条落定、入库口径已拍板 —— 这条工作流走到终点',
},
],
selective: [],
},
/*
* 必交付物只有一件:**情报卡清单**。归档结论(verdict)是它的结论栏,
* `ledgers` 说清这份成果由哪几张表承载(一份成果,不是两份)。
*/
requiredDeliverables: [
{ key: 'history-archive', label: '历史价格情报(归档)' },
{ key: 'landing-stats', label: '报价落地率统计' },
{
key: 'intel-archive',
label: '情报卡清单(逐条输赢归因 + 入库口径)',
ledger: `${D}.intel`,
/* 验收页上让 BD 就地改的两列 —— 恰是他的职权面(归因与依据) */
editableAtRelease: ['cause_tag', 'evidence'],
ledgers: [`${D}.intel`, `${D}.verdict`],
},
],
/*
* 验收判据 —— **机检(L1/L2)只读基座数得出来的数**:
* `_rows` 基座数行数、`_missing` 基座按 required / requiredWhen 现数
* (与执行卡提交闸同一个数同一套算法)、`_<列>_<值>` 基座按值现数。
* 上一版两条机检读活动自报的 `reason_logged_total` / `profile_updated_total` ——
* 那不是门,是「改哪个数能过」的提示(写个 1 就过去了)。
*/
acceptanceCriteria: [
{ key: 'reason-logged', label: '拒绝原因 / 竞对价位已回写', level: 'L1', predicate: 'reasonLogged = true' },
{ key: 'profile-updated', label: '客户画像标签已更新', level: 'L2', predicate: 'profileUpdated = true' },
{ key: 'bd-release', label: 'BD 放行', level: 'L5' },
{
key: 'intel-complete',
label: '情报卡齐:上游条目逐条承继,说了几条情报卡里就躺着几行',
level: 'L1',
predicate: `intel_total > 0 and ${D}_intel_rows = intel_total`,
},
{
key: 'attributed-all',
label: '每条都有输赢归因:归因落「输在价格」的那几条还落下了竞对报的那个价,缺一行放不了',
level: 'L1',
predicate: `${D}_intel_rows > 0 and ${D}_intel_missing = 0`,
},
{
key: 'result-typed',
label: '结果逐条落成两种之一:成交 / 不成交 —— 没有含糊其辞的格子',
level: 'L2',
predicate: `${D}_intel_deal_result_won + ${D}_intel_deal_result_lost = ${D}_intel_rows`,
},
{
key: 'scope-settled',
label: '入库口径逐条落定:全量 / 脱敏 / 只留内部 / 待定,四档相加等于情报条数',
level: 'L2',
modelRef: 'a02-scope',
predicate: `${D}_intel_rows > 0 and ${D}_intel_share_scope_full + ${D}_intel_share_scope_masked + ${D}_intel_share_scope_internal + ${D}_intel_share_scope_pending = ${D}_intel_rows`,
},
{
/* 与 A6 的「待拍板清零」同一形状:待定是过渡态,人拍过板它才归零 */
key: 'pending-cleared',
label: '入库口径都拍过板了:没有还压在「待定」上的情报条目',
level: 'L2',
predicate: `${D}_intel_share_scope_pending = 0`,
},
{
/*
* 有败价幅度的行**必是归因落了「输在价格」的行** —— 两边都是基座按值现数。
* 不是价格败的却算出一个价差档,是模型给自己找了一条不必问竞对价的近路。
*/
key: 'band-from-price',
label: '算了败价幅度的,都是归因落「输在价格」的那几条 —— 没有凭空算出来的价差档',
level: 'L2',
modelRef: 'a01-band',
predicate: `${D}_intel_price_gap_band_slight + ${D}_intel_price_gap_band_moderate + ${D}_intel_price_gap_band_structural <= ${D}_intel_cause_tag_price`,
},
/*
* 情报站不站得住是**裁量**(L3):归因说不说得出来路、客户原话记没记全,
* 机器数不出来。这句 label 与剧本里 judge 的 criterion **必须逐字一致**。
*/
{
key: 'intel-defensible',
label: '情报站得住:每条归因说得出来路、竞对价与客户原话可溯,BD 对这份情报负责',
level: 'L3',
},
/* L5 是责任关:那张卡本身就是这道关,卡面上不摆它(基座统一滤) */
{ key: 'bd-release', label: 'BD 放行:签下去这一单的情报即入库,下一单查得到', level: 'L5' },
],
prohibitions: [{ key: 'no-blank-archive', statement: '不得空归档 —— 每一单不成交的报价也是情报资产' }],
authorization: { discretion: [], preauthorizedActions: [], humanCallBudget: 2 },
revisableAtRelease: [],
prohibitions: [
{
key: 'no-blank-archive',
statement: '不得空归档 —— 每一单不成交的报价也是情报资产,「没什么好记的」不是理由',
},
{
key: 'no-invented-reason',
statement: '不许编败因:问不出来就落 OTHER 并写明未获反馈 —— 编出来的原因会流进下一单的判据',
},
{
key: 'no-self-scope',
statement: '入库口径不许系统自己定:竞对报价与客户身份进共享库是对外边界,只由人拍板',
},
],
authorization: {
discretion: [
{ key: 'band-advice', statement: '败价幅度 A-01 给出的复用建议由活动自行判定;进不进共享库一律交人' },
],
preauthorizedActions: [],
/* 一张执行单 + 一张决策卡 + 放行 = 三次;留一倍余量 */
humanCallBudget: 5,
},
resources: {
/*
* **不调工具**。归档这一段没有要问外面的事 —— 上游把结果交过来,
* 人把缘由补上,系统把它落成下一单查得到的形状。
*/
tools: [],
skills: ARCHIVE_SKILLS.map((s) => s.id),
models: ['deepseek-v4-flash'],
roles: ['BD'],
budget: { steps: 30, tokens: 60_000, activeMinutes: 60, humanCalls: 2 },
/* SLA:MVP §1.6「7 天闭环」;预算按 A6 实测(6-9 拍)留一倍余量 */
budget: { steps: 60, tokens: 400_000, activeMinutes: 120, humanCalls: 5 },
},
},
/* ── 声明 ────────────────────────────────────────────────────── */
declarations: {
models: ['deepseek-v4-flash'],
tools: [],
/*
* 【三张共享域表的墓志铭】上一版声明了 `market.price_history` /
* `customer.profile` / `shared.landing_stats`,这一版三张一并退役:
*
* · `shared.landing_stats`(月度落地率 / TypeA 命中 / D+3 回复率)——
* **单案例算不出一个月的统计**。上一版让 AI 写一个 0.27 进去,那是界面撒谎:
* 它既不是本单的事实,也没有第二个数据源可核。月度统计的正经落点是驾驶舱
* **按表现算**,这一单该做的只是把自己那一条如实落准。
* · `customer.profile`(客户画像)—— **A1 已经持有这张主数据表**,
* A7 再声明一遍就是同一批业务对象的两套地址(六个占位槽退役时清过一次的账)。
* 本单看得出来的客户行为写进 `verdict.buyer_profile` 一句话,
* 要更新主数据由人在客户档上改,不由归档活动代笔。
* · `market.price_history`(历史价格情报)—— 与下面这张 `intel` **是同一份事实**。
* 两处各存一份,改一处就分叉而没人看得出以哪份为准(A1 开请求单那次的账)。
* 情报卡本身就是「下次查得到的形状」:按 pn 查得到价、按 customer_code 查得到客户、
* 按 quoted_at 查得到「距上次报价多久」(P-03 的那把尺读的就是它)。
*/
ledger: [
{
// 共享域:跨活动的沉淀事实(AM 11.3 暗耦合禁令的两条合法路径之一)
domain: 'shared',
object: 'history_price',
label: '历史价格情报',
domain: D,
object: 'intel',
kind: 'record',
caseKey: 'rfq_ref',
label: '情报卡',
fields: [
{ name: 'pn', type: 'text', primaryKey: true, masked: false },
{ name: 'quoted_at', type: 'date', primaryKey: true, masked: false },
{ name: 'lost_reason', type: 'text', primaryKey: false, masked: false, label: '拒绝原因' },
{ name: 'competitor_price', type: 'number', primaryKey: false, masked: false, label: '竞对价位' },
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' },
{ name: 'customer_code', type: 'text', primaryKey: false, masked: false, label: '客户码' },
{ name: 'customer_name', type: 'text', primaryKey: false, masked: false, label: '客户名称' },
{ name: 'category', type: 'text', primaryKey: false, masked: false, label: '品类' },
{ name: 'brand', type: 'text', primaryKey: false, masked: false, label: '制造商' },
{ name: 'eau', type: 'integer', primaryKey: false, masked: false, label: '年用量 EAU' },
/* ── 下次查得到的那几格:时间 / 我方价 / 成交价 / 结果 ───────────── */
{ name: 'quoted_at', type: 'date', primaryKey: false, masked: false, label: '报价日期' },
{ name: 'quoted_price', type: 'number', primaryKey: false, masked: false, label: '我方报价 CNY' },
{ name: 'final_price', type: 'number', primaryKey: false, masked: false, label: '成交价(成交才有)' },
{
name: 'deal_result',
type: 'text',
primaryKey: false,
masked: false,
label: '结果',
tally: ['WON', 'LOST'],
},
/*
* ── 人那一侧的三列:**AI 一格都不写** ──────────────────────────
*
* 与 A6 的「客户表态三列」同一处结构治:一条要模型分辨「这行能不能替人填」
* 的规矩会走岔;「这几列不写」没有分支可走岔。
*
* `cause_tag` 标 `required` —— 件上的缺项红带、执行卡的提交闸、放行门 L1
* 三处同源(都数基座的 `_missing`)。
* `competitor_price` 用 `requiredWhen`:**只有归因落「输在价格」的那一行才算必填**
* (表级 required 会让交期/认证败的行永远缺一格,把闸拦成死锁)。
*/
{
name: 'cause_tag',
type: 'text',
primaryKey: false,
masked: false,
required: true,
label: '输赢归因',
tally: ['PRICE', 'LEADTIME', 'CERT', 'SPEC', 'RELATION', 'OTHER'],
},
{
name: 'competitor_price',
type: 'number',
primaryKey: false,
masked: false,
requiredWhen: { column: 'cause_tag', value: 'PRICE' },
label: '竞对价',
},
{ name: 'competitor_name', type: 'text', primaryKey: false, masked: false, label: '竞对是谁' },
{ name: 'evidence', type: 'text', primaryKey: false, masked: false, label: '依据 / 客户原话' },
/* ── 算出来的那一格:尺说了算,模型照抄 ─────────────────────────── */
{
name: 'price_gap_band',
type: 'text',
primaryKey: false,
masked: false,
label: '败价幅度(A-01)',
tally: ['SLIGHT', 'MODERATE', 'STRUCTURAL'],
},
/*
* 入库口径,**PENDING 是过渡态**(情报归好了、还没定进哪一档库):
* 拍板那一下把它换成 FULL / MASKED / INTERNAL 之一,于是「归因齐了叫人」
* 那道哨兵当场为假。A6 的账:让「拍完不动数」的路存在,同一张卡会连发六次;
* 让模型自己写得出结论码,那张卡就永远不会发。两头都堵死才算数。
*/
{
name: 'share_scope',
type: 'text',
primaryKey: false,
masked: false,
label: '入库口径',
tally: ['PENDING', 'FULL', 'MASKED', 'INTERNAL'],
},
],
},
{
domain: 'shared',
object: 'customer',
label: '客户资料库',
/*
* 归档结论 —— 一单一行的**汇总表**,哨兵与报告读它。
* 汇总列与逐行列**故意不同名**:任务视图把草稿行摊平到顶层是「后写的赢」,
* 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。
*/
domain: D,
object: 'verdict',
kind: 'record',
caseKey: 'rfq_ref',
label: '归档结论',
fields: [
{ name: 'customer_code', type: 'text', primaryKey: true, masked: false, label: '客户码' },
{ name: 'grade', type: 'text', primaryKey: false, masked: false, label: '客户等级' },
{ name: 'profile_tags', type: 'text', primaryKey: false, masked: false, label: '画像标签' },
],
},
{
domain: 'shared',
object: 'landing_stats',
label: '报价落地率统计',
fields: [
{ name: 'period', type: 'text', primaryKey: true, masked: false, label: '月份' },
{ name: 'landing_rate', type: 'number', primaryKey: false, masked: false, label: '落地率' },
{ name: 'type_a_hit', type: 'number', primaryKey: false, masked: false, label: 'TypeA命中' },
{ name: 'reply_rate_d3', type: 'number', primaryKey: false, masked: false, label: 'D+3回复率' },
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'quote_no', type: 'text', primaryKey: false, masked: false, label: '报价单号' },
{ name: 'customer_code', type: 'text', primaryKey: false, masked: false, label: '客户码' },
{ name: 'customer_name', type: 'text', primaryKey: false, masked: false, label: '客户名称' },
{ name: 'outcome', type: 'text', primaryKey: false, masked: false, label: '这一单最后成没成(won/lost)' },
{ name: 'intel_total', type: 'integer', primaryKey: false, masked: false, label: '情报卡条数' },
/* 归一走完的凭据(0 也要写)—— 哨兵按它放交人归因那一步入场 */
{ name: 'collected_total', type: 'integer', primaryKey: false, masked: false, label: '已归一条数' },
/* 「归因落齐了」的凭据 —— 只在人环走完才产生 */
{ name: 'attributed_total', type: 'integer', primaryKey: false, masked: false, label: '已落归因条数' },
{ name: 'scope_decided_total', type: 'integer', primaryKey: false, masked: false, label: '入库口径已拍板' },
{ name: 'share_policy', type: 'text', primaryKey: false, masked: false, label: '入库口径(人拍的)' },
/* MVP §1.6 S2「画像更新」的落点 —— 一句话,不代笔改主数据 */
{ name: 'buyer_profile', type: 'text', primaryKey: false, masked: false, label: '客户行为画像' },
{ name: 'summary', type: 'text', primaryKey: false, masked: false, label: '归档结论' },
],
},
],
listeners: [],
// 7 天闭环(MVP示例 §1.7)
harness: { memoryWindow: 15, stallAfter: 'P1D', nudge: [], retry: { times: 2, backoff: 'PT30S' } },
/*
* 两条静态监听 —— **时限必配**(AM 4.4)。MVP §1.6 写死「7 天闭环」:
* 归档没有外部世界的回音要等,这两条管的是「别把它挂到天荒地老」——
* D+3 提醒把归因补上,D+7 到期升级。
*/
listeners: [
{
origin: 'static',
eventType: 'time',
keyFilter: { kind: 'archive-d3' },
semantic: 'observe',
deadline: 'P3D',
escalation: ['nudge'],
},
{
origin: 'static',
eventType: 'time',
keyFilter: { kind: 'archive-deadline' },
semantic: 'observe',
deadline: 'P7D',
escalation: ['nudge', 'escalate'],
},
],
harness: {
memoryWindow: 22,
// 7 天闭环是对**这一单办完**的约束;活本身只需两个人环
stallAfter: 'PT2H',
nudge: [{ after: 'PT1H', action: 'nudge' }],
retry: { times: 2, backoff: 'PT30S' },
},
},
/* ── 资产初始版 ──────────────────────────────────────────────── */
assets: {
/* 资产初始版:案例模型(2026-07-29 起活动只有这一种业务模型) */
businessModel: {
cmmnXml: CMMN_XML,
narrative: NARRATIVE,
dmn: { drd: [], tables: [] },
dmn: A01_DMN,
steps: ARCHIVE_STEPS,
phases: ARCHIVE_PHASES,
},
skills: ARCHIVE_SKILLS,
/*
* 两个局面各一张(与 A2/A3/A4/A6 同款口径):
* · `archive-worksheet` 情报归档工作单 —— 归一与逐条归因期间摆它(逐颗情报档案、
* 归因六档与败价幅度两把尺、填回工作表);
* · `archive-roster` 情报归档清单 —— 拍板、放行、归档摆它。
*/
viewTemplates: [
{ name: 'archive-logged', html: ARCHIVE_LOGGED },
{ name: 'archive-release', html: ARCHIVE_RELEASE },
{ name: 'archive-worksheet', html: ARCHIVE_WORKSHEET },
{ name: 'archive-roster', html: ARCHIVE_ROSTER },
],
cardSchemas: {},
distillHooks: [
{ on: 'terminal', prompt: '归纳丢单的共性:价格、交期、竞对、客户类型 —— 供下一单更快更准。' },
{
on: 'terminal',
prompt: '归纳丢单的共性:价格、交期、竞对、客户类型 —— 供下一单更快更准。',
},
],
},
/* ── 样例集 —— 三条形状真不同(常备案例即验收案例)───────────── */
samples: [
{
name: '客户拒绝',
input: { customerCode: 'C001', quoteNo: 'Q202601', reason: '竞对报价更低' },
/*
* ① 流内失单:A6 `lost` 口的真实形状 —— rows 里报价函行(带客户表态与定案)
* 混着结论行。客户拿竞对价压,归因落 PRICE,竞对价一填败价幅度就算得出来。
*/
name: '流内转来 · 客户拒绝 · 竞对价压两成(失单归档常路)',
input: {
rfqRef: 'RFQ-2026-0777',
customerCode: 'CU-0311',
quoteNo: 'Q-0777',
outcome: 'lost',
reason: '客户明确回绝:竞对价更低',
customer_name: '青岛海洋电子',
rows: [
{
rfq_ref: 'RFQ-2026-0777',
pn: 'PESD2ETH-XR',
category: '二极管',
brand: 'Nexperia',
eau: 30000,
quoted_price: 0.098,
reply_type: 'REJECT',
deal_status: 'LOST',
reply_note: '客户回:这个价做不了,别家给到 0.078',
},
{
rfq_ref: 'RFQ-2026-0777',
pn: 'NE555P',
category: '定时器',
brand: 'TI',
eau: 5000,
quoted_price: 1.05,
reply_type: 'REJECT',
deal_status: 'LOST',
reply_note: '客户回:这颗一起给别家了',
},
{ rfq_ref: 'RFQ-2026-0777', line_total: 2, customer_name: '青岛海洋电子', summary: '两条都没谈成,交情报归档' },
],
},
expect: { ports: ['archived'] },
},
{
/*
* ② 成交归档:A6 `won` 那一口的单子结案后也归这一份档 —— 成交价与胜因同样是情报
* (下一单的基准价就在这儿)。归因不是 PRICE,竞对价那一格不该被闸拦。
*/
name: '成交归档 · 两条谈成 · 胜在交期与既有供应关系',
input: {
rfqRef: 'RFQ MD14.1',
customerCode: 'CU-0142',
quoteNo: 'Q-MD14.1',
outcome: 'won',
customer_name: 'Itelma',
rows: [
{
rfq_ref: 'RFQ MD14.1',
pn: 'BBPY00201209601Y00',
customer_pn: '4-14-01502',
category: '滤波器',
brand: 'YAGEO',
eau: 30000,
quoted_price: 0.085,
final_price: 0.085,
reply_type: 'ACCEPT',
deal_status: 'WON',
reply_note: '客户回:按这个价走,PO 下周发',
},
{
rfq_ref: 'RFQ MD14.1',
pn: 'CJTB0101R6N',
category: '集成电路',
brand: 'Jiangsu Changjing',
eau: 60000,
quoted_price: 0.42,
final_price: 0.42,
reply_type: 'ACCEPT',
deal_status: 'WON',
reply_note: '客户回:可以,一起下',
},
{ rfq_ref: 'RFQ MD14.1', line_total: 2, customer_name: 'Itelma' },
],
},
expect: { ports: ['archived'] },
},
{
/*
* ③ 单飞宽进:没有单号没有清单,正文说了哪一单、报了什么价、客户怎么回的 ——
* 归一造案号(ARC- 开头),两颗料一条都没成。
*/
name: '单飞需求 · 正文列两颗料 · 客户始终未表态(补一单历史)',
input: {
subject: 'MD14.2 这一单补个归档',
body: '上个月发出去的报价函一直没回音:XCJ-7788T 电源模块 8K,报价 14.80;LMR-3302QQ 稳压器 15K,报价 2.35。客户是 深圳市锐驰电子,联系人王工。报价日 2026-07-05,D+3 与 D+7 都催过,客户始终没表态,这一单按丢单归档。',
},
expect: { ports: ['archived'] },
},
],
+28 -6
View File
@@ -1,5 +1,5 @@
/**
* A7 技能 —— 归档的方法经验(AM 08)。
* A7 技能 —— 归档的方法经验(AM 08)。**载方法不载权力**:这里一条工具都不引。
*/
import { skillIdSchema } from '@contracts';
import type { Skill } from '@contracts';
@@ -7,16 +7,38 @@ import type { Skill } from '@contracts';
export const ARCHIVE_SKILLS: Skill[] = [
{
id: skillIdSchema.parse('archive.log-lost-reason'),
label: '把拒绝原因写成下一单的判据',
applicability: '客户拒绝或超时,要登记结果时。',
label: '把输赢的缘由写成下一单的判据',
applicability: '逐条落归因、准备发那张执行单时。',
method: [
'「价格高」不是原因,是结论。要写的是**可比对的事实**:',
'· 竞对报价多少、差几个百分点;',
'· 输在价格、交期、还是资质/认证;',
'· 客户是否明说了可接受区间。',
'· 竞对报价多少、差几个百分点(差 2 个点与差 30 个点是两件完全不同的事);',
'· 输在价格、交期、认证资质、规格,还是既有供应关系;',
'· 客户是否明说了可接受区间 —— 下一单的起点就藏在这一句里。',
'',
'判据要能被下一单**机器读取**:P-03 用「距上次报价 / 行情稳定」,P-01 用客户等级。',
'写成一段散文,它就只能被人读;写成字段,它才进得了决策表。',
'',
'**「客户没回」不是原因**:问不出来就落 OTHER 并在依据里写明「未获反馈」。',
'编一个听着合理的理由,三个月后没人记得它是编的 —— 而它已经在替下一单定价了。',
].join('\n'),
crystallized: [],
knowledgeRefs: [],
version: '1.0.0',
},
{
id: skillIdSchema.parse('archive.share-scope'),
label: '情报入库口径:哪些该留名,哪些该抹掉',
applicability: '发入库口径决策卡、给出建议档位时。',
method: [
'进了共享情报库的东西,**下一单谁都读得到** —— 这条边界过去了就收不回来。',
'三档的分界:',
'· **全量入库**:不涉第三方报价的败因(交期、认证、规格)—— 查得越全越好;',
'· **脱敏入库**:带着竞对报价的情报 —— 价位、归因、日期照留,谁说的抹掉。',
' 竞对的报价是他给客户的,不是给我们的;客户转述给我们,不等于我们可以对内广播;',
'· **只留内部**:客户明说不外传、或涉及在谈的项目 —— 这一单的情报只在本案卷里躺着。',
'',
'**建议归尺,结论归人**:A-02 只在决策卡上标一枚 recommended,',
'按不按它是签字的人的事 —— 这不是效率问题,是谁承担后果的问题。',
].join('\n'),
crystallized: [],
knowledgeRefs: [],
@@ -0,0 +1,409 @@
/**
* A1 两份执行页的样式表 —— **从 PRD 目标件原样搬过来**,不是我照着复述的。
*
* 出处:`~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理需求.html` 的整段 `<style>`,
* 外加 `A1-询价受理记录.html` 里归档件独有的那几件(分类分布条 / 类徽 / 判定表 /
* 结论横幅 / 落地动作 / 完成标)。**一个数值都没改**。
*
* 为什么是搬而不是抄:前三轮我用自己那套外壳「照着做」,块搬齐了、版面是两套 ——
* 章节头成了彩色方块编号、多出摘要与元信息带、字号用 clamp。人一眼就看出不是那份件。
* 目标件是**像素级的答案**,搬过来才不会在复述里走样。
*
* 选择器保留 `.proto-root .app-shell` 前缀,所以模版正文也要照原型那样包两层壳 ——
* 少一层壳,整张样式表一条都不生效(踩过)。
*/
export const PRD_CSS = `
/* ============================================================================
JCAI 员工面工作台 · A1 受理归一的过程成果「询价受理需求」——报告本体
立场:**AI 已从 RFQ 邮件抽取归一**,人在这里审核并就地修订。
两态:读态(AI 稿 + 红/灰占位提示缺项) ⇄ 编辑态(全字段可改,保存 / 取消)
核心是「有效 PN 清单」;清单之上四件受理要素:客户公司 / 联系人 / 客户属性 / 有效 PN。
令牌与既有组件取自 workbench/src/app/(proto)/app.css,数值未改。
⚠️ 修正一处原型 bug:app.css:2062-2064 的 '.jc-rp-sign span'(权重 0,1,1)
盖过了 '.jc-rp-ava'(0,1,0),把头像的 display:grid 覆盖成 block、字色改灰,
导致落款头像里的字掉到圆外。本文件把该规则收窄为 '.jc-rp-sign > div > span'。
新增件:四要素卡 .jc-four/.jc-fcard · 占位胶囊 .jc-ph · 分段控件 .jc-seg ·
来源条 .jc-src · 录入表 .jc-tbl-in
========================================================================== */
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { background: #e6eaf1; -webkit-font-smoothing: antialiased; }
.proto-root .app-shell {
--brand: #3370ff; --brand-deep: #245bdb;
--brand-soft: #e1eaff; --brand-wash: #f0f4ff;
--ai-grad: linear-gradient(135deg, #3370ff, #7f3bf5);
--ink: #1f2329; --ink-2: #41464e; --ink-3: #646a73; --ink-4: #8f959e;
--line: #dee0e3; --line-soft: #e8eaed; --soft: #f8f9fb;
--jcai-bg: #e6eaf1; --cardline: #e1e6ec; --card-shadow: 0 1px 2px rgba(31, 35, 41, .05);
--ok: #278717; --ok-soft: #e8f8e4;
--warn: #de7802; --warn-accent: #ff8800; --warn-soft: #fff4e5;
--danger: #d83931; --danger-soft: #feeae9;
--mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace;
--shadow-brand: 0 6px 16px rgba(51, 112, 255, .22);
--t-fast: .16s cubic-bezier(.3, 0, .2, 1);
--t-med: .26s cubic-bezier(.3, 0, .2, 1);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--jcai-bg); min-height: 100vh; color: var(--ink);
}
.proto-root :where(button, input, select, a, [tabindex]):focus-visible {
outline: 2px solid var(--brand); outline-offset: 2px; border-radius: 8px;
}
.proto-root ::selection { background: var(--brand-soft); }
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
@keyframes jcBlink { 0%,100% { opacity: 1 } 50% { opacity: .35 } }
@keyframes jcStamp { from { opacity: 0; transform: rotate(-6deg) scale(1.7); } to { opacity: .9; transform: rotate(-6deg) scale(1); } }
/* 放开宽度:1160px 是「9 列全展开、一个字都不裁」的最小容器宽度(见下方列 min-width 之和) */
.jc-stage { max-width: 1160px; margin: 0 auto; padding: 22px 16px 52px; }
.proto-root .jc-mono { font-family: var(--mono); font-size: 11px; color: var(--ink-3); }
.proto-root .jc-pulse-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--brand); display: inline-block; animation: jcBlink 1.1s ease-in-out infinite; }
/* ── 报告外壳 ───────────────────────────────────────────────────────── */
.proto-root .jc-report { background: #fff; border: 1px solid var(--cardline); border-radius: 14px; box-shadow: var(--card-shadow); overflow: hidden; animation: rise var(--t-med) both; }
.proto-root .jc-rp-head { position: relative; overflow: hidden; display: flex; align-items: flex-start; gap: 12px; padding: 16px 20px 13px; border-bottom: 2px solid var(--ink); }
/* 题头下缘粗细双线 = 正式件版式 */
.proto-root .jc-rp-head::after { content: ""; position: absolute; left: 0; right: 0; bottom: -4px; height: 1px; background: var(--line); }
/* 题头右上的品牌光晕:与工作台标题区同一签名件 */
.proto-root .jc-rp-head::before { content: ""; position: absolute; right: -70px; top: -96px; width: 240px; height: 240px; border-radius: 999px; background: radial-gradient(circle, rgba(51,112,255,.10), transparent 70%); pointer-events: none; }
.proto-root .jc-rp-head > * { position: relative; z-index: 1; }
.proto-root .jc-rp-badge { flex: none; width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand); }
.proto-root .jc-rp-tit { flex: 1; min-width: 0; }
.proto-root .jc-rp-tit em { display: block; font-style: normal; font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); font-family: var(--mono); }
.proto-root .jc-rp-tit h3 { margin: 4px 0 0; font-size: 18px; font-weight: 800; color: var(--ink); letter-spacing: -.01em; line-height: 1.3; }
.proto-root .jc-rp-wait { display: inline-flex; align-items: center; gap: 6px; flex: none; margin-top: 4px; font-size: 11px; font-weight: 700; border-radius: 999px; padding: 4px 12px; color: var(--brand-deep); background: var(--brand-wash); border: 1px solid var(--brand-soft); }
.proto-root .jc-rp-wait.warn { color: var(--warn); background: var(--warn-soft); border-color: #ffd9a8; }
.proto-root .jc-rp-wait.edit { color: #fff; background: var(--brand); border-color: var(--brand); }
.proto-root .jc-rp-stamp { flex: none; margin-top: 2px; transform: rotate(-6deg); border: 2px solid currentColor; border-radius: 9px; padding: 4px 8px 3px 12px; font-size: 13px; font-weight: 900; letter-spacing: .28em; color: var(--ok); opacity: .9; animation: jcStamp .32s cubic-bezier(.2, 1.4, .4, 1) both; }
.proto-root .jc-rp-bd { padding: 18px 20px 12px; }
.proto-root .jc-rp-part { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 9px; margin: 20px 0 11px; }
.proto-root .jc-rp-part:first-child { margin-top: 0; }
.proto-root .jc-rp-part > b { flex: none; font-size: 11px; font-weight: 800; letter-spacing: .12em; color: var(--ink-3); }
.proto-root .jc-rp-part > .ln { flex: 1; height: 1px; background: var(--line-soft); }
.proto-root .jc-rp-part > em { flex: none; font-style: normal; font-size: 10px; font-weight: 700; color: var(--ink-4); font-family: var(--mono); background: var(--soft); border-radius: 999px; padding: 2px 9px; white-space: nowrap; }
.proto-root .jc-rp-part > em.warn { color: var(--warn); background: var(--warn-soft); }
/* 落款 —— ⚠️ 选择器收窄到 > div > *,别再命中头像那个 span */
.proto-root .jc-rp-foot { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 13px 20px 15px; border-top: 2px solid var(--line); background: #fbfcfd; }
.proto-root .jc-rp-sign { display: flex; align-items: center; gap: 10px; flex: 1 1 200px; min-width: 0; }
.proto-root .jc-rp-ava { flex: 0 0 auto; width: 30px; height: 30px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; line-height: 1; color: #fff; background: var(--brand); }
.proto-root .jc-rp-sign > div { min-width: 0; }
.proto-root .jc-rp-sign > div > b { display: block; font-size: 12px; font-weight: 700; color: var(--ink-2); }
.proto-root .jc-rp-sign > div > span { display: block; margin-top: 1px; font-size: 10.5px; color: var(--ink-4); }
.proto-root .jc-rp-acts { display: flex; align-items: center; gap: 8px; margin-left: auto; flex: none; }
.proto-root .jc-hintline { font-size: 11px; font-weight: 700; color: var(--warn); line-height: 1.5; }
.proto-root .jc-btn { display: inline-flex; align-items: center; gap: 5px; border: 0; border-radius: 7px; padding: 8px 14px; font-size: 12px; font-weight: 600; font-family: inherit; background: var(--brand); color: #fff; cursor: pointer; transition: all var(--t-fast); }
.proto-root .jc-btn:hover:not(:disabled) { background: var(--brand-deep); }
.proto-root .jc-btn:disabled { opacity: .45; cursor: not-allowed; }
.proto-root .jc-btn-ghost { background: #fff; color: var(--ink-2); border: 1px solid var(--line); }
.proto-root .jc-btn-ghost:hover:not(:disabled) { background: var(--brand-wash); color: var(--brand); border-color: var(--brand-soft); }
.proto-root .jc-btn-primary { background: var(--ai-grad); }
.proto-root .jc-btn-primary:hover:not(:disabled) { filter: brightness(1.07); box-shadow: var(--shadow-brand); transform: translateY(-1px); }
/* ── 来源信件:客户 RFQ 邮件原文(复用原型 jc-letter 件,青色 = 收件) ── */
.proto-root .jc-letter { position: relative; border: 1px solid var(--cardline); border-left: 3px solid #0aa294; border-radius: 12px; padding: 14px 16px 15px; background: #fff; box-shadow: var(--card-shadow); }
.proto-root .jc-letter-h { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 11px; }
.proto-root .jc-letter-dir { display: inline-flex; align-items: center; gap: 5px; font-size: 10.5px; font-weight: 800; padding: 3px 9px; border-radius: 999px; background: #e4f8f5; color: #0a7f74; }
.proto-root .jc-letter-h .jc-chip { font-size: 10.5px; padding: 3px 9px; border-radius: 999px; background: var(--brand-wash); color: var(--brand-deep); border: 1px solid var(--brand-soft); }
.proto-root .jc-letter-h > code { margin-left: auto; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
.proto-root .jc-letter-meta { display: flex; flex-direction: column; gap: 4px; margin-bottom: 11px; padding-bottom: 11px; border-bottom: 1px solid var(--line-soft); font-size: 11.5px; color: var(--ink-2); }
.proto-root .jc-letter-meta p { margin: 0; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.proto-root .jc-letter-meta i { flex: none; font-style: normal; width: 44px; font-size: 10px; font-weight: 800; letter-spacing: .06em; color: var(--ink-4); }
.proto-root .jc-letter-meta code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); overflow-wrap: anywhere; }
.proto-root .jc-letter-sub { margin: 0 0 9px; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.45; overflow-wrap: anywhere; }
/* 正文:原文照录,pre-wrap 保原始换行;不截断、不折叠 */
.proto-root .jc-letter-body { margin: 0; font-size: 12.5px; line-height: 1.72; color: var(--ink-2); white-space: pre-wrap; overflow-wrap: anywhere; }
.proto-root .jc-letter-body b { color: var(--ink); font-weight: 700; }
.proto-root .jc-atts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; padding-top: 11px; border-top: 1px dashed var(--line-soft); }
.proto-root .jc-att { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px 5px 8px; border: 1px solid var(--brand-soft); border-radius: 8px; background: var(--brand-wash); font-size: 11.5px; color: var(--brand-deep); }
.proto-root .jc-att svg { flex: none; color: var(--brand); }
.proto-root .jc-att code { font-family: var(--mono); font-size: 10.5px; }
.proto-root .jc-att span { color: var(--ink-4); }
/* 抽取指向:从原文到下方要素的一句衔接 */
.proto-root .jc-derive { display: flex; align-items: flex-start; gap: 8px; margin-top: 11px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); font-size: 11.5px; line-height: 1.7; color: var(--brand-deep); }
.proto-root .jc-derive svg { flex: none; margin-top: 2px; }
/* ── 四要素卡 ───────────────────────────────────────────────────────────
色调字典:每张卡带一套 --tone / --tone-soft / --tone-line,组件只读变量。
卡 3、卡 4 的色调随数据变(老客户绿 / 新客户橙;齐了蓝 / 有缺橙),
所以这条要素带本身就是一行可读的状态,不只是四个格子。 */
.proto-root .jc-four { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 11px; }
.proto-root .jc-fcard {
position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-width: 0;
padding: 14px 14px 14px; border: 1px solid var(--cardline); border-radius: 13px; background: #fff;
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 8px 20px -14px rgba(31,35,41,.35);
transition: transform var(--t-fast), box-shadow var(--t-fast), border-color var(--t-fast);
--tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft);
}
/* 顶缘色棱 + 右上角光晕:卡的身份色,一眼分得清四件是四件 */
.proto-root .jc-fcard::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 3px; background: var(--tone); }
.proto-root .jc-fcard::after { content: ""; position: absolute; right: -46px; top: -52px; width: 132px; height: 132px; border-radius: 999px; background: radial-gradient(circle, var(--tone-soft), transparent 70%); pointer-events: none; }
.proto-root .jc-fcard > * { position: relative; z-index: 1; }
.proto-root .jc-fcard:hover { transform: translateY(-2px); border-color: var(--tone-line); box-shadow: 0 2px 4px rgba(31,35,41,.05), 0 14px 26px -16px rgba(31,35,41,.45); }
.proto-root .jc-fcard.t-teal { --tone: #0aa294; --tone-soft: rgba(10,162,148,.15); --tone-line: #9fe0d9; }
.proto-root .jc-fcard.t-ok { --tone: var(--ok); --tone-soft: rgba(39,135,23,.14); --tone-line: #bfedb4; }
.proto-root .jc-fcard.t-warn { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.12); --tone-line: #ffd9a8; }
.proto-root .jc-fcard-k { display: flex; align-items: center; gap: 6px; font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
.proto-root .jc-fcard-k > i { flex: none; width: 19px; height: 19px; border-radius: 6px; display: inline-grid; place-items: center; background: var(--tone-soft); color: var(--tone); }
.proto-root .jc-fcard-b { display: flex; align-items: center; gap: 10px; min-width: 0; }
.proto-root .jc-fcard-t { min-width: 0; flex: 1; }
/* 一律换行,不省略号——宁可多占一行,也不丢字 */
.proto-root .jc-fcard-t > b { display: block; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.35; overflow-wrap: anywhere; }
.proto-root .jc-fcard-t > span { display: block; margin-top: 2px; font-size: 10.5px; color: var(--ink-4); line-height: 1.55; overflow-wrap: anywhere; }
/* 公司标:圆角方 + AI 渐变 + 外圈光环(方=组织) */
.proto-root .jc-logo { flex: none; width: 38px; height: 38px; border-radius: 12px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; letter-spacing: .04em; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand), 0 0 0 3px rgba(51,112,255,.10); }
/* 人像:圆 + 青色系(圆=人,与方形的组织标一眼分开) */
.proto-root .jc-face { flex: none; width: 38px; height: 38px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; color: #0a7f74; background: #e4f8f5; border: 1px solid #9fe0d9; box-shadow: 0 0 0 3px rgba(10,162,148,.10); }
/* 邮箱做成可点的胶囊,复制按钮嵌在里面——比一行裸文字有抓手 */
/* 邮箱:软底胶囊、无描边——有描边会读成输入框,而读态这里不是可填的 */
.proto-root .jc-mailrow { display: flex; align-items: center; gap: 4px; margin-top: 5px; min-width: 0; max-width: 100%; padding: 3px 4px 3px 9px; border: 0; border-radius: 8px; background: var(--tone-soft); transition: background var(--t-fast); }
.proto-root .jc-mailrow:hover { background: rgba(10,162,148,.2); }
/* 编辑态里它包着输入框,软底会变成「框中框」,去掉 */
.proto-root .jc-report.editing .jc-mailrow { background: transparent; padding: 0; margin-top: 6px; }
.proto-root .jc-mailrow > code { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; line-height: 1.5; font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
.proto-root .jc-copy { flex: none; width: 22px; height: 22px; display: inline-grid; place-items: center; border: 0; border-radius: 6px; background: #fff; color: var(--ink-4); cursor: pointer; box-shadow: 0 1px 2px rgba(31,35,41,.08); transition: all var(--t-fast); }
.proto-root .jc-copy:hover { color: #0aa294; }
.proto-root .jc-copy.ok { color: var(--ok); background: var(--ok-soft); box-shadow: none; }
/* 客户属性徽章 */
.proto-root .jc-tagbig { display: inline-flex; align-items: center; gap: 6px; align-self: flex-start; padding: 7px 14px; border-radius: 999px; font-size: 13.5px; font-weight: 800; line-height: 1; color: var(--tone); background: var(--tone-soft); border: 1px solid var(--tone-line); }
.proto-root .jc-fcard-note { font-size: 10.5px; color: var(--ink-4); line-height: 1.55; }
.proto-root .jc-fcard-note b { color: var(--ink-2); font-weight: 700; }
.proto-root .jc-fcard-note b.bad { color: var(--tone); font-weight: 800; }
/* 成交迷你柱:把「台账 6 笔成交」从一句话变成看得见的节奏 */
.proto-root .jc-spark { display: flex; align-items: flex-end; gap: 4px; height: 26px; }
.proto-root .jc-spark > i { flex: 1 1 0; min-width: 4px; max-width: 15px; border-radius: 3px 3px 1px 1px; background: var(--tone); opacity: .3; transition: opacity var(--t-fast), height var(--t-med); }
.proto-root .jc-spark > i:last-child { opacity: 1; }
.proto-root .jc-spark:hover > i { opacity: .55; }
.proto-root .jc-spark:hover > i:last-child { opacity: 1; }
.proto-root .jc-spark.empty { align-items: stretch; }
.proto-root .jc-spark.empty > i { height: 100%; opacity: 1; background: repeating-linear-gradient(45deg, var(--tone-soft) 0 5px, transparent 5px 10px); border: 1px dashed var(--tone-line); border-radius: 5px; }
/* 有效 PN 计数 */
.proto-root .jc-count { display: flex; align-items: baseline; gap: 6px; }
.proto-root .jc-count > b { font-family: var(--mono); font-size: 32px; font-weight: 800; line-height: .95; color: var(--ink); letter-spacing: -.04em; }
.proto-root .jc-count > span { font-size: 11px; color: var(--ink-4); }
/* 分段刻度条:一格 = 一条 PN,缺必填的那格直接标红——比一根实心进度条信息量大 */
.proto-root .jc-segmeter { display: flex; gap: 3px; }
.proto-root .jc-segmeter > i { flex: 1; height: 7px; border-radius: 2px; background: var(--ok); transition: background var(--t-fast); }
.proto-root .jc-segmeter > i.bad { background: var(--danger); }
.proto-root .jc-lockhint { display: inline-flex; align-items: center; gap: 4px; font-size: 10px; color: var(--ink-4); }
/* ── 占位胶囊:必填=红虚线,选填=灰虚线;**都带文字**,不靠颜色单打 ── */
.proto-root .jc-ph { display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700; line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed; }
.proto-root .jc-ph.req { color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
.proto-root .jc-ph.opt { color: var(--ink-4); background: var(--soft); border-color: var(--line); }
/* ── 分段控件(新客户 / 老客户)── */
.proto-root .jc-seg { display: inline-flex; padding: 2px; gap: 2px; border: 1px solid var(--cardline); border-radius: 9px; background: var(--soft); align-self: flex-start; }
.proto-root .jc-seg button { border: 0; border-radius: 7px; padding: 6px 13px; font: inherit; font-size: 11.5px; font-weight: 700; color: var(--ink-3); background: transparent; cursor: pointer; transition: all var(--t-fast); }
.proto-root .jc-seg button:hover:not(.on) { color: var(--brand); }
.proto-root .jc-seg button.on { background: #fff; color: var(--ink); box-shadow: 0 1px 3px rgba(31,35,41,.12); }
/* ── 表:读态与编辑态共用骨架 ─────────────────────────────────────── */
.proto-root .jc-tbl-wrap { overflow-x: auto; border: 1px solid var(--line-soft); border-radius: 10px; }
.proto-root .jc-tbl-in { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; }
.proto-root .jc-tbl-in th { background: var(--soft); text-align: left; padding: 8px 9px; font-size: 10.5px; font-weight: 800; letter-spacing: .03em; color: var(--ink-3); white-space: nowrap; border-bottom: 1px solid var(--line-soft); }
.proto-root .jc-tbl-in th.req::after { content: "*"; margin-left: 1px; color: var(--danger); font-size: 10px; }
.proto-root .jc-tbl-in td { padding: 4px 5px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
.proto-root .jc-tbl-in tr:last-child td { border-bottom: 0; }
.proto-root .jc-tbl-in tbody tr:hover td { background: #f7f9fd; }
/* 缺必填的整行浅红打底 + 行首红标——不用找红胶囊,一眼扫到是哪几行 */
.proto-root .jc-tbl-in tr.miss td { background: #fffbfb; }
.proto-root .jc-tbl-in tbody tr.miss:hover td { background: #fff5f5; }
.proto-root .jc-tbl-in td.jc-rn { position: relative; width: 38px; padding: 4px 6px 4px 13px; white-space: nowrap; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); font-weight: 800; }
/* 行首红标只在表态用 ::after —— 卡片态的 ::after 被「第 N 条」占着,不能抢 */
@media (min-width: 961px) {
.proto-root .jc-tbl-in tr.miss td.jc-rn::after { content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger); }
}
.proto-root .jc-rndot { display: inline-block; width: 5px; height: 5px; margin-left: 4px; border-radius: 999px; background: var(--danger); vertical-align: 2px; }
/* 读态单元格 */
.proto-root .jc-vcell { display: block; padding: 6px 6px; font-size: 12px; color: var(--ink); line-height: 1.5; overflow-wrap: anywhere; }
.proto-root .jc-vcell.mono { font-family: var(--mono); font-size: 11px; color: var(--ink-2); }
.proto-root .jc-vcell.num { font-family: var(--mono); font-size: 11.5px; text-align: right; }
/* 编辑态单元格:安静的电子表格 */
.proto-root .jc-cell { width: 100%; min-width: 64px; border: 1px solid var(--line); border-radius: 6px; padding: 6px 7px; font: inherit; font-size: 12px; color: var(--ink); background: #fff; transition: border-color var(--t-fast), box-shadow var(--t-fast); }
.proto-root .jc-cell.mono { font-family: var(--mono); font-size: 11px; }
.proto-root .jc-cell.num { text-align: right; font-family: var(--mono); font-size: 11.5px; }
.proto-root .jc-cell::placeholder { color: var(--ink-4); }
.proto-root .jc-cell:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-wash); outline: none; }
.proto-root .jc-cell.req-empty { border-color: #f3b6b3; background: #fffafa; }
.proto-root .jc-cell.req-empty::placeholder { color: var(--danger); }
/* 备注这类长文本用文本域:能换行、随内容长高,不裁字 */
.proto-root textarea.jc-cell { display: block; resize: vertical; overflow: hidden; min-height: 31px; line-height: 1.55; }
/* 编辑态的整表提示带 */
.proto-root .jc-editbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); border: 1px solid var(--brand-soft); font-size: 11.5px; color: var(--brand-deep); }
.proto-root .jc-editbar svg { flex: none; }
.proto-root .jc-editbar .sp { flex: 1 1 auto; }
/* 剔除行说明 */
.proto-root .jc-drop { display: flex; align-items: flex-start; gap: 8px; margin-top: 10px; padding: 9px 12px; border-radius: 9px; border: 1px dashed var(--line); background: var(--soft); font-size: 11.5px; color: var(--ink-3); line-height: 1.6; }
.proto-root .jc-drop svg { flex: none; margin-top: 2px; color: var(--ink-4); }
.proto-root .jc-drop code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
.proto-root .jc-callout { display: flex; gap: 8px; align-items: flex-start; border-radius: 9px; padding: 11px 13px; font-size: 12.5px; line-height: 1.75; }
.proto-root .jc-callout p { margin: 0; }
.proto-root .jc-callout svg { flex: none; margin-top: 2px; }
.proto-root .jc-callout.t-mute { background: var(--soft); color: var(--ink-2) }
@media (prefers-reduced-motion: reduce) {
.proto-root .jc-pulse-dot { animation: none; }
.proto-root .jc-report, .proto-root .jc-rp-stamp { animation: none; }
}
/* ══════════════════════════════════════════════════════════════════════════
响应式:宁可另起一行,也不损失完整度
≥961px 六列全展开的表(列 min-width 之和 878px,容器有余量,绝不裁字、绝不横滚)
≤960px 表 → 卡片:一条 PN 一张卡、一行一字段(标签 / 值),无横滚、无省略
≤560px 字段行再拆成上下两行(标签在上、值在下)
══════════════════════════════════════════════════════════════════════════ */
@media (max-width: 1040px) { .proto-root .jc-four { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 560px) { .proto-root .jc-four { grid-template-columns: 1fr; } }
@media (max-width: 960px) {
.proto-root .jc-tbl-wrap { overflow: visible; border: 0; border-radius: 0; background: transparent; }
.proto-root .jc-tbl-in,
.proto-root .jc-tbl-in tbody,
.proto-root .jc-tbl-in tr,
.proto-root .jc-tbl-in td { display: block; width: 100%; }
.proto-root .jc-tbl-in thead { display: none; }
/* 一条 PN = 一张卡 */
.proto-root .jc-tbl-in tr { margin-bottom: 10px; padding: 4px 13px 9px; border: 1px solid var(--line-soft); border-radius: 11px; background: #fff; box-shadow: var(--card-shadow); }
.proto-root .jc-tbl-in tr:last-child { margin-bottom: 0; }
.proto-root .jc-tbl-in tbody tr:hover td { background: transparent; }
.proto-root .jc-tbl-in tr.miss { border-color: #f3b6b3; background: #fffbfb; }
/* 卡头:第 N 条 */
.proto-root .jc-tbl-in td.jc-rn { padding: 8px 0 8px; margin-bottom: 4px; border-bottom: 1px solid var(--line-soft); font-family: inherit; font-size: 12px; font-weight: 800; color: var(--ink-2); }
.proto-root .jc-tbl-in td.jc-rn::before { content: "第 "; }
.proto-root .jc-tbl-in td.jc-rn::after { content: " 条"; }
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); }
/* 字段行:左标签 + 右值,值超长自动折行 */
.proto-root .jc-tbl-in td:not(.jc-rn) { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 10px; align-items: center; padding: 6px 0; border-bottom: 1px dashed var(--line-soft); }
.proto-root .jc-tbl-in tr td:last-child { border-bottom: 0; padding-bottom: 2px; }
.proto-root .jc-tbl-in td:not(.jc-rn)::before {
content: attr(data-h); font-size: 10.5px; font-weight: 700; letter-spacing: .02em; color: var(--ink-4); line-height: 1.5;
}
.proto-root .jc-tbl-in td[data-req="1"]:not(.jc-rn)::before { content: attr(data-h) " *"; }
.proto-root .jc-tbl-in .jc-vcell { padding: 0; overflow-wrap: anywhere; }
.proto-root .jc-tbl-in .jc-vcell.num { text-align: left; }
.proto-root .jc-tbl-in .jc-cell.num { text-align: left; }
.proto-root .jc-tbl-in .jc-cell { min-width: 0; }
}
/* 窄屏:落款三件各占一行,动作按钮撑满一行 */
@media (max-width: 560px) {
.proto-root .jc-rp-acts { margin-left: 0; width: 100%; flex-wrap: wrap; }
.proto-root .jc-rp-acts .jc-btn { flex: 1 1 auto; justify-content: center; }
.proto-root .jc-hintline { flex: 1 1 100%; }
.proto-root .jc-tbl-in td:not(.jc-rn) { grid-template-columns: 1fr; gap: 3px; align-items: start; }
}
/* ══ 以下取自 A1-询价受理记录.html(归档件独有的件)══ */
/* ── 料号分类:分布条 + 图例 ───────────────────────────────────────── */
.proto-root .jc-dist { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 13px 15px; border: 1px solid var(--line-soft); border-radius: 12px; background: linear-gradient(180deg, #fbfcfe, #fff); }
.proto-root .jc-distbar { flex: 1 1 320px; min-width: 260px; display: flex; gap: 3px; }
.proto-root .jc-distbar > i { height: 30px; border-radius: 4px; display: grid; place-items: center; font-size: 10.5px; font-weight: 800; color: #fff; font-family: var(--mono); }
.proto-root .jc-distbar > i.a { background: var(--ok); }
.proto-root .jc-distbar > i.b { background: var(--brand); }
.proto-root .jc-distbar > i.c { background: var(--warn-accent); }
.proto-root .jc-distlg { display: flex; flex-wrap: wrap; gap: 8px 18px; }
.proto-root .jc-distlg > span { display: inline-flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--ink-3); }
.proto-root .jc-distlg em { width: 10px; height: 10px; border-radius: 3px; }
.proto-root .jc-distlg em.a { background: var(--ok); } .proto-root .jc-distlg em.b { background: var(--brand); } .proto-root .jc-distlg em.c { background: var(--warn-accent); }
.proto-root .jc-distlg b { font-family: var(--mono); font-weight: 800; color: var(--ink); }
/* 类别徽 A / B / C */
.proto-root .jc-tbadge { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 6px; font-family: var(--mono); font-size: 11px; font-weight: 800; line-height: 1; color: #fff; }
.proto-root .jc-tbadge.a { background: var(--ok); } .proto-root .jc-tbadge.b { background: var(--brand); } .proto-root .jc-tbadge.c { background: var(--warn-accent); }
/* ── 结论与去向 ─────────────────────────────────────────────────────────
G2 是**分支网关**:三条互斥条件按序求值,只命中一条、只落一个活动。
所以这里不是三张并列的去向卡,而是一张**判定表 + 一条结论**。 */
/* 判定表:一行一条规则,命中行上色留痕(DMN 口径) */
.proto-root .jc-dmn { border: 1px solid var(--line-soft); border-radius: 12px; overflow: hidden; }
.proto-root .jc-dmn-h, .proto-root .jc-dmn-r { display: grid; grid-template-columns: 40px minmax(0, 1fr) 170px 200px; align-items: center; gap: 12px; padding: 9px 14px; }
.proto-root .jc-dmn-h { background: var(--soft); border-bottom: 1px solid var(--line-soft); font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); }
.proto-root .jc-dmn-r { font-size: 12px; color: var(--ink-4); background: #fff; }
.proto-root .jc-dmn-r + .jc-dmn-r { border-top: 1px solid var(--line-soft); }
.proto-root .jc-dmn-i { font-family: var(--mono); font-size: 11px; font-weight: 800; }
.proto-root .jc-dmn-c { line-height: 1.6; overflow-wrap: anywhere; }
.proto-root .jc-dmn-t { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; }
.proto-root .jc-dmn-t code { font-family: var(--mono); font-size: 10.5px; font-weight: 800; color: var(--ink-4); background: var(--soft); border: 1px solid var(--line); border-radius: 5px; padding: 2px 6px; }
.proto-root .jc-dmn-v { font-size: 11px; line-height: 1.55; }
/* 命中行:上色 + 左缘棱 + 加粗 */
.proto-root .jc-dmn-r.hit { position: relative; background: var(--tone-soft); color: var(--ink-2); }
.proto-root .jc-dmn-r.hit::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--tone); }
.proto-root .jc-dmn-r.hit .jc-dmn-i { color: var(--tone); }
.proto-root .jc-dmn-r.hit .jc-dmn-c { color: var(--ink); }
.proto-root .jc-dmn-r.hit .jc-dmn-c b { color: var(--tone); font-weight: 800; }
.proto-root .jc-dmn-r.hit .jc-dmn-t { color: var(--ink); }
.proto-root .jc-dmn-r.hit .jc-dmn-t code { color: #fff; background: var(--tone); border-color: var(--tone); }
.proto-root .jc-dmn-v.y { display: inline-flex; align-items: center; gap: 5px; font-weight: 800; color: var(--tone); }
.proto-root .jc-dmn-v.n { color: var(--ink-4); }
/* 色调随命中类别 */
.proto-root .jc-dmn.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.10); }
.proto-root .jc-dmn.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.09); }
.proto-root .jc-dmn.k-c { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.11); }
/* 结论横幅:命中条件 → 唯一去向 */
.proto-root .jc-verdict {
position: relative; overflow: hidden; display: flex; align-items: stretch; flex-wrap: wrap; gap: 16px;
margin-top: 11px; padding: 16px 18px; border: 1px solid var(--tone-line); border-radius: 13px;
background: linear-gradient(105deg, var(--tone-soft), #fff 62%);
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 10px 24px -16px rgba(31,35,41,.4);
--tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.14); --tone-line: #ffd9a8;
}
.proto-root .jc-verdict.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.13); --tone-line: #bfedb4; }
.proto-root .jc-verdict.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft); }
.proto-root .jc-verdict::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 5px; background: var(--tone); }
.proto-root .jc-verdict > * { position: relative; z-index: 1; }
.proto-root .jc-vd-l { flex: 1 1 300px; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
.proto-root .jc-vd-k { font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
.proto-root .jc-vd-cond { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 15px; font-weight: 800; color: var(--ink); }
.proto-root .jc-vd-why { margin: 0; font-size: 11.5px; line-height: 1.7; color: var(--ink-3); overflow-wrap: anywhere; }
.proto-root .jc-vd-why code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
.proto-root .jc-vd-arrow { flex: none; align-self: center; color: var(--tone); }
.proto-root .jc-vd-r { flex: 1 1 260px; min-width: 0; display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-radius: 11px; background: #fff; border: 1px solid var(--tone-line); }
.proto-root .jc-vd-node { display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 800; color: var(--ink); }
.proto-root .jc-vd-node code { font-family: var(--mono); font-size: 12px; font-weight: 800; color: #fff; background: var(--tone); border-radius: 6px; padding: 3px 8px; }
.proto-root .jc-vd-who { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--ink-2); }
.proto-root .jc-vd-who > i { width: 20px; height: 20px; border-radius: 999px; display: inline-grid; place-items: center; font-style: normal; font-size: 10px; font-weight: 700; color: #fff; background: var(--tone); }
.proto-root .jc-vd-next { margin: 0; padding-top: 8px; border-top: 1px dashed var(--line-soft); font-size: 10.5px; line-height: 1.65; color: var(--ink-4); }
.proto-root .jc-vd-next b { color: var(--ink-2); font-weight: 700; }
/* 命中分支携带的条目 */
.proto-root .jc-pnlist { display: flex; flex-wrap: wrap; gap: 5px; margin: 0; padding: 0; list-style: none; }
.proto-root .jc-pnlist > li { display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 999px; background: #fff; border: 1px solid var(--tone-line); font-size: 11px; color: var(--ink-2); overflow-wrap: anywhere; }
.proto-root .jc-pnlist > li > i { flex: none; width: 5px; height: 5px; border-radius: 999px; background: var(--tone); }
.proto-root .jc-pnlist code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
/* ── 落地动作 ───────────────────────────────────────────────────────── */
.proto-root .jc-acts { display: flex; flex-direction: column; gap: 0; border: 1px solid var(--line-soft); border-radius: 11px; overflow: hidden; }
.proto-root .jc-act { display: flex; align-items: flex-start; gap: 9px; padding: 10px 13px; font-size: 12px; color: var(--ink-2); line-height: 1.6; background: #fff; }
.proto-root .jc-act + .jc-act { border-top: 1px solid var(--line-soft); }
.proto-root .jc-act > i { flex: none; width: 18px; height: 18px; margin-top: 1px; border-radius: 999px; display: inline-grid; place-items: center; color: #fff; background: var(--ok); }
.proto-root .jc-act b { font-weight: 700; color: var(--ink); }
.proto-root .jc-act span { color: var(--ink-4); }
.proto-root .jc-act code { font-family: var(--mono); font-size: 10.5px; color: var(--brand-deep); background: var(--brand-wash); border-radius: 4px; padding: 1px 5px; }
.proto-root .jc-done-tag { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; flex: none; padding: 6px 13px; border-radius: 999px; font-size: 11.5px; font-weight: 800; color: var(--ok); background: #fff; border: 1px solid #bfedb4; }
.proto-root .jc-vcell.dim { color: var(--ink-4); }
.proto-root .jc-tbl-in td.jc-ty { width: 44px; text-align: center; }
.proto-root .jc-tbl-in td.jc-ty { width: auto; text-align: left; }
`;
/**
* 图标精灵 —— 与样式表同一出处,两份目标件的 `<symbol>` 取并集。
* 沙箱里取不到图标字体,所以图标一律内联;`<use href="#i-xxx">` 引的就是这一坨。
*/
export const PRD_SPRITE = `<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs><symbol id="i-alert" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></symbol><symbol id="i-arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol><symbol id="i-boxes" viewBox="0 0 24 24"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></symbol><symbol id="i-building" viewBox="0 0 24 24"><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/><path d="M10 18h4"/></symbol><symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol><symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol><symbol id="i-file-check" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="m9 15 2 2 4-4"/></symbol><symbol id="i-file-text" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></symbol><symbol id="i-git-branch" viewBox="0 0 24 24"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></symbol><symbol id="i-history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></symbol><symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></symbol><symbol id="i-lock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol><symbol id="i-mail-open" viewBox="0 0 24 24"><path d="M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0z"/><path d="m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10"/></symbol><symbol id="i-pen" viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z"/></symbol><symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol><symbol id="i-sheet" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M8 13h2"/><path d="M14 13h2"/><path d="M8 17h2"/><path d="M14 17h2"/></symbol><symbol id="i-shield-check" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></symbol><symbol id="i-user" viewBox="0 0 24 24"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/></symbol><symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></symbol></defs></svg>`;
+761 -35
View File
@@ -1,43 +1,769 @@
/**
* A7 视图模版 —— 归档两屏:结果登记 / 闭环待放行(AM 07)。
* 归档是这条流的收尾:它写回的是**下一单的判据**,所以要说清「这次学到了什么」。
* A7 情报归档的 HTML 报告模版 —— **活动自己持有的资产**,随包版本冻结。
*
* 两个局面各一张(与 A2/A3/A4/A6 同款口径):
* · `archive-worksheet` **情报归档工作单** —— 归一与逐条归因期间摆它:
* 逐颗料一张情报档案卡(既成事实在左、归因在右)、归因六档与败价幅度 A-01 两把尺、
* 填回工作表就长在同一屏;
* · `archive-roster` **情报归档清单** —— 入库口径拍板、放行与归档摆它:整份情报卡,
* 口径分布、下一单谁用得上的落边图与落地动作在上面。
*
* 「1:1」的落点是**每份件与它的办结快照**(在办可编辑 ↔ 回执收下那一刻的 finalHtml),
* 不是全程只许一张:作业的件与收口的件是两件工作两份件(规范姊妹篇 §1)。
*
* 版面语言沿用 A1 从 PRD 目标件机械抽取的那套(`prd-style.ts` 原样拷入)——
* A7 没有自己的 PRD 目标件,与 A2/A3/A4/A6 同一处置:沿用家族版面自造。
*
* 取值三纪律:顶层只取汇总列与单值列;逐条走 `mine:quote_archive.intel`;
* 列名逐字照 `declarations.ledger` 抄。**模版不算不判** —— 缺归因红带、归因徽、口径徽、
* 分布条全部由基座算好的数经 `data-class-of` 投影,判断留在样式表。
*/
import { kvCard, noteCard, tableCard, viewShell } from '@sdk';
import { PRD_CSS, PRD_SPRITE } from './prd-style.ts';
export const ARCHIVE_LOGGED = viewShell({
title: '结果登记',
kicker: 'A7 · 情报归档',
tone: 'archive',
/* ==================== 绑定期补的样式(照 A1/A3/A4/A6 的 BIND_CSS,地址换成本活动)==================== */
const BIND_CSS = `
/* 空占位三级(2026-08-02 全局口径):必填红 / 建议填橘 / 选填灰 —— 值真的空才轮到 */
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after,
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after,
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after {
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
}
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after { content: "必填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after { content: "建议填"; color: var(--warn-accent); background: var(--warn-soft); border-color: #f0cf9a; }
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after { content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line); }
/* 归档件里空格子就是「没有」,不再摆「你还能填」的胶囊 */
.proto-root .jc-tbl-in td.dimempty > .jc-vcell:empty::after { content: "—"; color: var(--ink-4); border: 0; background: none; padding: 0; }
/* 缺归因的整行浅红 + 行首红条 —— 原型的 tr.miss,这里由 :has() 认 */
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td { background: #fffbfb; }
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty):hover td { background: #fff5f5; }
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn { color: var(--danger); font-weight: 800; position: relative; }
@media (min-width: 1001px) {
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn::after {
content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger);
}
}
/* 序号由 CSS 计数器排 */
.proto-root .jc-tbl-in tbody { counter-reset: rn; }
.proto-root .jc-tbl-in td.jc-rn::before { counter-increment: rn; content: counter(rn); }
@media (max-width: 1000px) {
.proto-root .jc-tbl-in td.jc-rn::before { content: "第 " counter(rn) " 条"; }
.proto-root .jc-tbl-in td.jc-rn::after { content: none; }
}
/* 就地改:可改格子渲了只读与输入两份,默认锁着,表上加 on 才换孪生件 */
.proto-root .jc-cell { display: none; }
.proto-root .jc-tbl-in.on td > .jc-vcell,
.proto-root .jc-tbl-in.on td > .jc-tbadge { display: none; }
.proto-root .jc-tbl-in.on td > .jc-cell { display: block; }
/* 结果徽 / 归因徽 / 口径徽不进编辑态(承继与引擎产物),on 时也照旧摆着 */
.proto-root .jc-tbl-in.on td > .jc-obadge,
.proto-root .jc-tbl-in.on td > .jc-dbadge { display: inline-flex; }
/* 数值列与表头同侧左对齐(一行一个料,彼此不相加) */
.proto-root .jc-vcell.num, .proto-root .jc-cell.num { text-align: left; }
/* 编辑态下必填空格子:红框 + 整行浅红(由 :placeholder-shown 认) */
.proto-root .jc-tbl-in.on td.req > .jc-cell:placeholder-shown { border-color: #f3b6b3; background: #fffafa; }
.proto-root .jc-tbl-in.on tbody tr:has(td.req > .jc-cell:placeholder-shown) td { background: #fffbfb; }
/* 铺满工作区 */
.jc-stage { max-width: none; padding: 0 0 4px; }
.proto-root .jc-report { border: 0; border-radius: 0; box-shadow: none; }
/* 那道闸 —— 默认「已锁定 + 编辑」,开了变成「取消 / 保存」;只在可改屏现身 */
.proto-root .jc-editgate { display: none; }
html.editable .proto-root .jc-editgate {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
margin-bottom: 10px; padding: 8px 12px; border-radius: 9px;
background: var(--soft); border: 1px solid var(--line-soft);
font-size: 11.5px; color: var(--ink-3);
}
.proto-root .jc-editgate-t { display: inline-flex; align-items: center; gap: 6px; flex: 1 1 auto; min-width: 0; }
.proto-root .jc-editgate-t b { color: var(--ink-2); font-weight: 700; }
.proto-root .jc-egbtn {
flex: none; display: inline-flex; align-items: center; gap: 4px; cursor: pointer;
border: 1px solid var(--line); border-radius: 7px; padding: 5px 11px;
font: inherit; font-size: 11.5px; font-weight: 700; color: var(--ink-2); background: #fff;
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
}
.proto-root .jc-egbtn:hover { border-color: var(--brand); color: var(--brand); }
.proto-root .jc-egbtn.save { color: #fff; background: var(--brand); border-color: var(--brand); }
.proto-root .jc-egbtn.save:hover { background: var(--brand-deep); border-color: var(--brand-deep); }
.proto-root .jc-editgate .jc-egbtn.ghost, .proto-root .jc-editgate .jc-egbtn.save { display: none; }
.proto-root .jc-editgate.on { background: var(--brand-wash); border-color: var(--brand-soft); color: var(--brand-deep); }
.proto-root .jc-editgate.on .jc-egbtn.open { display: none; }
.proto-root .jc-editgate.on .jc-egbtn.ghost, .proto-root .jc-editgate.on .jc-egbtn.save { display: inline-flex; }
.proto-root .jc-editgate.on .jc-editgate-t { visibility: hidden; }
/* 缺归因红带 —— 只在可改屏出,一格不缺自动收(v-0 那一档 display:none) */
.proto-root .jc-gapbar { display: none; }
html.editable .proto-root .jc-gapbar {
display: flex; align-items: flex-start; gap: 8px;
margin: 0 0 10px; padding: 9px 12px; border-radius: 9px;
background: #fff5f5; border: 1px solid #f3b6b3; color: var(--danger);
font-size: 12px; line-height: 1.65;
}
.proto-root .jc-gapbar svg { flex: 0 0 auto; margin-top: 2px; }
.proto-root .jc-gapbar b { font-weight: 800; }
.proto-root .jc-gapbar span { color: #9a3b36; }
html.editable .proto-root .jc-gapbar.v-0 { display: none; }
/* 那条「怎么改」的提示只在开了闸之后说 */
.proto-root .jc-editbar { display: none; }
.proto-root .jc-editgate.on + .jc-editbar { display: flex; }
/* 增删行 */
.proto-root .jc-rowdel { display: none; }
.proto-root .jc-tbl-in.on td.jc-rn::before { content: none; }
.proto-root .jc-tbl-in.on .jc-rowdel {
display: block; width: 20px; height: 20px; margin: 0 auto; cursor: pointer;
border: 1px solid var(--line); border-radius: 6px; padding: 0;
font: inherit; font-size: 13px; line-height: 1; color: var(--ink-3); background: #fff;
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
}
.proto-root .jc-tbl-in.on .jc-rowdel:hover { border-color: var(--danger); color: var(--danger); background: #fff6f6; }
.proto-root .jc-tbl-in.on tr.jc-row-gone td { background: #fff6f6; opacity: .55; }
.proto-root .jc-tbl-in.on tr.jc-row-gone .jc-cell { text-decoration: line-through; color: var(--ink-3); }
.proto-root .jc-tbl-in.on tr.jc-row-gone .jc-rowdel { border-color: var(--danger); color: var(--danger); background: #fff; }
.proto-root .jc-tbl-in.on tr.jc-row-new td { background: #f2fbf7; }
.proto-root .jc-addrow { display: none; }
html.editable .proto-root .jc-tbl-wrap:has(table.on) + .jc-addrow {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin: -2px 0 12px; padding: 7px 12px; border-radius: 9px;
background: var(--soft); border: 1px dashed var(--line);
}
.proto-root .jc-addrow em { font-style: normal; font-size: 11px; color: var(--ink-3); }
/* 结果徽(承继上游定案):成交绿 / 不成交灰 —— 一格一料的刻度也用这套色 */
.proto-root .jc-tbadge { background: var(--ink-4); }
.proto-root .jc-tbadge.v-won { background: var(--ok); }
.proto-root .jc-tbadge.v-lost { background: var(--ink-4); }
.proto-root .jc-segmeter > i.v-won { background: var(--ok); }
.proto-root .jc-segmeter > i.v-lost { background: var(--ink-4); }
/* 归因徽:价格红 / 交期橙 / 认证蓝 / 规格深蓝 / 关系灰 / 说不清灰虚 —— 值是机器码,人读名交给样式表 */
.proto-root .jc-obadge {
display: inline-flex; align-items: center; justify-content: center;
min-width: 62px; height: 20px; padding: 0 7px; border-radius: 6px;
font-size: 10px; font-weight: 800; letter-spacing: .02em;
color: var(--ink-4); border: 1.5px dashed var(--ink-4); background: #fff;
}
.proto-root .jc-obadge::after { content: "待归因"; }
.proto-root .jc-obadge.v-price { color: var(--danger); border-color: var(--danger); border-style: solid; }
.proto-root .jc-obadge.v-price::after { content: "输在价格"; }
.proto-root .jc-obadge.v-leadtime { color: var(--warn-accent); border-color: var(--warn-accent); border-style: solid; }
.proto-root .jc-obadge.v-leadtime::after { content: "输在交期"; }
.proto-root .jc-obadge.v-cert { color: var(--brand); border-color: var(--brand); border-style: solid; }
.proto-root .jc-obadge.v-cert::after { content: "认证资质"; }
.proto-root .jc-obadge.v-spec { color: var(--brand-deep); border-color: var(--brand-deep); border-style: solid; }
.proto-root .jc-obadge.v-spec::after { content: "规格不符"; }
.proto-root .jc-obadge.v-relation { color: var(--ink-3); border-color: var(--ink-4); border-style: solid; }
.proto-root .jc-obadge.v-relation::after { content: "既有供应"; }
.proto-root .jc-obadge.v-other { color: var(--ink-3); border-color: var(--ink-4); border-style: dashed; }
.proto-root .jc-obadge.v-other::after { content: "说不清"; }
/* 口径徽:待定橙虚 / 全量绿 / 脱敏蓝 / 只留内部灰 */
.proto-root .jc-dbadge {
display: inline-flex; align-items: center; justify-content: center;
min-width: 66px; height: 20px; padding: 0 7px; border-radius: 6px;
font-size: 10px; font-weight: 800; letter-spacing: .02em;
color: var(--ink-4); border: 1.5px solid var(--ink-4); background: #fff;
}
.proto-root .jc-dbadge::after { content: "未落"; }
.proto-root .jc-dbadge.v-pending { color: var(--warn-accent); border-color: var(--warn-accent); border-style: dashed; }
.proto-root .jc-dbadge.v-pending::after { content: "待你拍板"; }
.proto-root .jc-dbadge.v-full { color: var(--ok); border-color: var(--ok); }
.proto-root .jc-dbadge.v-full::after { content: "全量入库"; }
.proto-root .jc-dbadge.v-masked { color: var(--brand); border-color: var(--brand); }
.proto-root .jc-dbadge.v-masked::after { content: "脱敏入库"; }
.proto-root .jc-dbadge.v-internal { color: var(--ink-3); border-color: var(--ink-4); }
.proto-root .jc-dbadge.v-internal::after { content: "只留内部"; }
/* 败价幅度(A-01)—— 只在归因落「输在价格」的行上有值 */
.proto-root .jc-cbadge { font-size: 10.5px; font-weight: 700; color: var(--ink-4); }
.proto-root .jc-cbadge.v-slight { color: var(--ok); }
.proto-root .jc-cbadge.v-slight::after { content: "擦肩而过 ≤3%"; }
.proto-root .jc-cbadge.v-moderate { color: var(--warn-accent); }
.proto-root .jc-cbadge.v-moderate::after { content: "够得着 3~10%"; }
.proto-root .jc-cbadge.v-structural { color: var(--danger); }
.proto-root .jc-cbadge.v-structural::after { content: "结构性价差 >10%"; }
/* 分布条:四段按条数分宽 —— 条数投影成 v-<N>,梯子取宽;零条那段自己消失 */
.proto-root .jc-distbar > i { flex: 24; min-width: 34px; display: inline-flex; align-items: center; justify-content: center; }
.proto-root .jc-distbar > i.v-0, .proto-root .jc-distbar > i:not([class*=" v-"]) { display: none; }
.proto-root .jc-distbar > i.v-1 { flex: 1 } .proto-root .jc-distbar > i.v-2 { flex: 2 }
.proto-root .jc-distbar > i.v-3 { flex: 3 } .proto-root .jc-distbar > i.v-4 { flex: 4 }
.proto-root .jc-distbar > i.v-5 { flex: 5 } .proto-root .jc-distbar > i.v-6 { flex: 6 }
.proto-root .jc-distbar > i.v-7 { flex: 7 } .proto-root .jc-distbar > i.v-8 { flex: 8 }
.proto-root .jc-distbar > i.v-9 { flex: 9 } .proto-root .jc-distbar > i.v-10 { flex: 10 }
.proto-root .jc-distbar > i.v-11 { flex: 11 } .proto-root .jc-distbar > i.v-12 { flex: 12 }
.proto-root .jc-distbar > i.v-13 { flex: 13 } .proto-root .jc-distbar > i.v-14 { flex: 14 }
.proto-root .jc-distbar > i.v-15 { flex: 15 } .proto-root .jc-distbar > i.v-16 { flex: 16 }
.proto-root .jc-distbar > i.v-17 { flex: 17 } .proto-root .jc-distbar > i.v-18 { flex: 18 }
.proto-root .jc-distbar > i.v-19 { flex: 19 } .proto-root .jc-distbar > i.v-20 { flex: 20 }
.proto-root .jc-distbar > i.p { background: var(--ok); }
.proto-root .jc-distbar > i.n { background: var(--warn-accent); }
.proto-root .jc-distbar > i.r { background: var(--brand); }
.proto-root .jc-distbar > i.k { background: var(--ink-4); }
.proto-root .jc-distbar > i.p::before { content: "全量入库 · "; }
.proto-root .jc-distbar > i.n::before { content: "待拍板 · "; }
.proto-root .jc-distbar > i.r::before { content: "脱敏入库 · "; }
.proto-root .jc-distbar > i.k::before { content: "只留内部 · "; }
.proto-root .jc-logo svg, .proto-root .jc-face svg, .proto-root .jc-rp-ava svg { width: 17px; height: 17px; }
/* ── 情报档案卡(工作单独有,照 A6 报价档案卡的骨架)──────────────────────
一颗料一张卡,data-rows 逐行复制:左既成事实、右归因。
形态按结果分档(data-class-of):不成交的整卡描红、成交的描绿 —— 判断在样式表。 */
.proto-root .jc-selcards { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
.proto-root .jc-selcard { border: 1px solid var(--line); border-radius: 11px; background: #fff; overflow: hidden; }
.proto-root .jc-selcard.v-lost { border-color: #f3b6b3; box-shadow: 0 1px 0 rgba(191,60,52,.05); }
.proto-root .jc-selcard.v-won { border-color: #bfe3cd; }
.proto-root .jc-selcard-h {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 9px 14px; background: var(--soft); border-bottom: 1px solid var(--line-soft);
}
.proto-root .jc-selcard.v-lost .jc-selcard-h { background: #fff6f5; border-bottom-color: #f3d3d0; }
.proto-root .jc-selcard-h code { font-size: 13px; font-weight: 800; color: var(--ink); }
.proto-root .jc-selcard-h .why { flex: 1 1 auto; min-width: 0; font-size: 11px; color: var(--ink-3); }
.proto-root .jc-selcard-b { display: grid; grid-template-columns: 1fr 1fr; gap: 0; }
@media (max-width: 1000px) { .proto-root .jc-selcard-b { grid-template-columns: 1fr; } }
.proto-root .jc-selcol { padding: 11px 14px; }
.proto-root .jc-selcol + .jc-selcol { border-left: 1px solid var(--line-soft); }
@media (max-width: 1000px) { .proto-root .jc-selcol + .jc-selcol { border-left: 0; border-top: 1px solid var(--line-soft); } }
.proto-root .jc-selcol h5 { margin: 0 0 7px; font-size: 10.5px; font-weight: 800; letter-spacing: .08em; color: var(--ink-3); }
.proto-root .jc-kvrow { display: flex; gap: 8px; padding: 3px 0; font-size: 12px; line-height: 1.55; }
.proto-root .jc-kvrow i { flex: 0 0 84px; font-style: normal; color: var(--ink-4); font-size: 11px; padding-top: 1px; }
.proto-root .jc-kvrow span { flex: 1 1 auto; min-width: 0; color: var(--ink); overflow-wrap: anywhere; }
.proto-root .jc-kvrow span:empty::after { content: "—"; color: var(--ink-4); }
.proto-root .jc-selcard .sc-req:empty::after {
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
content: "必填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3;
}
.proto-root .jc-selcard .sc-sug:empty::after {
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
content: "建议填"; color: var(--warn-accent); background: var(--warn-soft); border-color: #f0cf9a;
}
.proto-root .jc-selcard .sc-opt:empty::after {
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line);
}
/* 两把尺(静态规则表 —— 尺挂在墙上,不是这一单的数据) */
.proto-root .jc-graderule { border: 1px solid var(--line); border-radius: 10px; overflow: hidden; margin-bottom: 12px; }
.proto-root .jc-graderule-r { display: flex; gap: 12px; align-items: flex-start; padding: 9px 13px; font-size: 12px; line-height: 1.6; }
.proto-root .jc-graderule-r + .jc-graderule-r { border-top: 1px solid var(--line-soft); }
.proto-root .jc-graderule-r p { margin: 0; color: var(--ink-2); }
.proto-root .jc-graderule-r p b { color: var(--ink); }
.proto-root .jc-band {
flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; margin-top: 1px;
min-width: 84px; height: 20px; padding: 0 8px; border-radius: 6px;
font-size: 10.5px; font-weight: 800; letter-spacing: .04em; border: 1.5px solid; background: #fff;
}
.proto-root .jc-band.ga { color: var(--ok); border-color: var(--ok); }
.proto-root .jc-band.gb { color: var(--brand); border-color: var(--brand); }
.proto-root .jc-band.gc { color: var(--warn-accent); border-color: var(--warn-accent); }
.proto-root .jc-band.gk { color: var(--danger); border-color: var(--danger); }
.proto-root .jc-band.gz { color: var(--ink-3); border-color: var(--ink-4); }
/* 一单情报是怎么走完的:报价 → 结果 → 归因 → 口径 → 下一单,内联线稿走位图 */
.proto-root .jc-priceline { display: flex; align-items: center; gap: 0; margin: 2px 0 14px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 11px; background: var(--soft); }
.proto-root .jc-plnode { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 3px; align-items: flex-start; }
.proto-root .jc-plnode b { font-size: 10.5px; font-weight: 800; letter-spacing: .06em; color: var(--ink-3); }
.proto-root .jc-plnode span { font-size: 12px; color: var(--ink-2); line-height: 1.5; }
.proto-root .jc-plarrow { flex: 0 0 auto; padding: 0 10px; color: var(--brand-soft); }
@media (max-width: 1000px) { .proto-root .jc-priceline { flex-direction: column; align-items: stretch; gap: 8px; } .proto-root .jc-plarrow { display: none; } }
/* ── 落边图:情报卡清单 → 归档闭环 → 下一单的三个用处(件不算不判)── */
.proto-root .route { margin: 2px 0 14px; padding: 10px 6px 6px; border: 1px solid var(--line); border-radius: 11px; background: #fff; }
.proto-root .route svg { width: 100%; height: auto; display: block; }
.proto-root .route .rspine { stroke: var(--brand); stroke-width: 2; }
.proto-root .route .redge { stroke: var(--line); stroke-width: 2; stroke-dasharray: 5 5; fill: none; }
.proto-root .route .rnode { fill: #fff; stroke: var(--line); stroke-width: 1.5; }
.proto-root .route .rnode.hit { stroke: var(--brand); stroke-width: 2; }
.proto-root .route .rtxt { font-size: 12px; font-weight: 800; fill: var(--ink-3); }
.proto-root .route .rtxt.hit { fill: var(--brand-deep); }
.proto-root .route .rsub { font-size: 10px; fill: var(--ink-4); }
.proto-root .route .rcond { font-size: 10.5px; font-weight: 700; fill: var(--ink-4); }
.proto-root .route .rtip { fill: var(--line); }
.proto-root .route .rtip.hit { fill: var(--brand); }
.proto-root .route .legend { display: flex; gap: 14px; flex-wrap: wrap; padding: 6px 10px 2px; font-size: 10.5px; color: var(--ink-4); }
/* 结论带 */
.proto-root .concl { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; align-items: start; margin: 2px 0 14px; padding: 11px 14px; border: 1px solid var(--line); border-radius: 11px; background: var(--soft); }
.proto-root .concl svg { grid-row: span 2; margin-top: 2px; color: var(--brand); }
.proto-root .concl .cl-main { font-size: 12.5px; color: var(--ink); line-height: 1.6; }
.proto-root .concl .cl-main b { font-weight: 800; }
.proto-root .concl .cl-route { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; font-size: 11.5px; color: var(--ink-3); }
.proto-root .concl .cl-route em { font-style: normal; color: var(--ink-4); }
.proto-root .concl .cl-route b { color: var(--brand-deep); font-weight: 800; }
.proto-root .concl .cl-route code { font-family: var(--mono, ui-monospace, monospace); font-size: 10.5px; color: var(--ink-4); }
`;
/** 一枚图标 —— 与原型同一写法 */
const ico = (name: string, size = 11): string =>
`<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-${name}"/></svg>`;
/** 三层壳照原型:少一层样式表全失效 */
const doc = (input: { badge: string; kicker: string; title: string; titleRef: string; state: string; body: string; foot: string }): string =>
`<style>${PRD_CSS}${BIND_CSS}</style>
${PRD_SPRITE}
<div class="proto-root"><div class="app-shell"><div class="jc-stage">
<article class="jc-report">
<header class="jc-rp-head">
<div class="jc-rp-badge">${ico(input.badge, 17)}</div>
<div class="jc-rp-tit">
<em>${input.kicker}</em>
<h3>${input.title} <span data-slot="${input.titleRef}"></span></h3>
</div>
${input.state}
</header>
<div class="jc-rp-bd">${input.body}</div>
<footer class="jc-rp-foot">${input.foot}</footer>
</article>
</div></div></div>`;
const part = (label: string, aside = '', tone = ''): string =>
`<div class="jc-rp-part"><b>${label}</b><i class="ln"></i>${aside === '' ? '' : `<em class="${tone}">${aside}</em>`}</div>`;
const sign = (note: string, tag = ''): string =>
`<div class="jc-rp-sign">
<span class="jc-rp-ava">${ico('user', 15)}</span>
<div><b data-slot="case:owner"></b><span>${note}</span></div>
</div>${tag === '' ? '' : `<span class="jc-done-tag">${ico('git-branch', 13)}${tag}</span>`}`;
/* ==================== 组成件 ==================== */
/** 要素四卡:客户 / 待归因 / 一格一料的结果刻度 / 情报时效 */
const FOUR = `<div class="jc-four">
<div class="jc-fcard">
<span class="jc-fcard-k"><i>${ico('building')}</i>客户公司</span>
<div class="jc-fcard-b">
<span class="jc-logo">${ico('building', 17)}</span>
<div class="jc-fcard-t"><b data-slot="customer_name" data-blank></b><span data-slot="customer_code" data-blank></span></div>
</div>
<span class="jc-fcard-note">承自上游;单飞补历史时此栏可空</span>
</div>
<div class="jc-fcard t-teal">
<span class="jc-fcard-k"><i>${ico('boxes')}</i>待归因</span>
<div class="jc-count"><b data-slot="quote_archive_intel_missing"></b><span>/ <b data-slot="quote_archive_intel_rows"></b> 条情报</span></div>
<span class="jc-fcard-note">输赢归因还空着的行 —— 这一列只有跟单的人填得了,系统不替人归因;补一格少一格,基座现数</span>
</div>
<div class="jc-fcard">
<span class="jc-fcard-k"><i>${ico('shield-check')}</i>逐条结果</span>
<div class="jc-segmeter" data-rows="mine:quote_archive.intel">
<i data-class-of="deal_result"></i>
</div>
<span class="jc-fcard-note">一格一条料 · 绿=成交(下一单的基准价),灰=不成交(下一单的判据在归因里)</span>
</div>
<div class="jc-fcard t-ok">
<span class="jc-fcard-k"><i>${ico('history')}</i>情报时效</span>
<span class="jc-tagbig">${ico('check', 13)}<span>报价日 <b data-slot="quoted_at" data-blank></b></span></span>
<span class="jc-fcard-note">下一单的三分类 P-03 看的就是「距上次报价多久」—— 这一格空着,这条情报到下一单就哑了</span>
</div>
</div>`;
/** 一单情报是怎么走完的 —— 走位图(形状写死,不算不判) */
const ARCHIVE_LINE = `<div class="jc-priceline">
<div class="jc-plnode"><b>既成事实</b><span>报过的价、谈成的价、什么时候报的 —— 一个数字都不改</span></div>
<div class="jc-plarrow">${ico('arrow-right', 18)}</div>
<div class="jc-plnode"><b>输赢归因</b><span>价格 / 交期 / 认证 / 规格 / 关系 / 说不清 —— 这一格由跟单的人落</span></div>
<div class="jc-plarrow">${ico('arrow-right', 18)}</div>
<div class="jc-plnode"><b>败价幅度 A-01</b><span>价差 ≤3% / 3~10% / >10%,决定下一单怎么用这条情报</span></div>
<div class="jc-plarrow">${ico('arrow-right', 18)}</div>
<div class="jc-plnode"><b>入库口径</b><span>全量 / 脱敏 / 只留内部 —— 进共享库的边界由人拍板</span></div>
<div class="jc-plarrow">${ico('arrow-right', 18)}</div>
<div class="jc-plnode"><b>下一单</b><span>三分类取时效 · 定价取基准 · 客户画像取行为</span></div>
</div>`;
/** 缺归因红带 —— 与提交闸、放行门 L1 同一个数同一套算法(基座现数);一格不缺自动收 */
const GAP_BAR = `<div class="jc-gapbar" data-class-of="quote_archive_intel_missing">${ico('alert', 14)}
<span><b>还差 <span data-slot="quote_archive_intel_missing"></span> 行归因没落</b>
—— <span data-slot="quote_archive_intel_missing_keys"></span>。
按右上角「编辑」开表,红框那几格就是要填的:输在价格填 PRICE 并把竞对报的那个价填进「竞对价」,
交期填 LEADTIME,认证资质填 CERT,规格不符填 SPEC,既有供应关系填 RELATION,
问不出来填 OTHER 并在依据里写明未获反馈。补齐了执行单才提交得了。</span>
</div>`;
const editGate = (address: string, what: string): string => `<div class="jc-editgate" data-edit-for="${address}">
<span class="jc-editgate-t">${ico('lock', 12)}${what}<b>已锁定</b> —— 看的时候不误碰,要改按右边</span>
<button type="button" class="jc-egbtn open" data-edit-open="${address}">${ico('pen', 12)}编辑</button>
<button type="button" class="jc-egbtn ghost" data-edit-cancel="${address}">取消</button>
<button type="button" class="jc-egbtn save" data-edit-save="${address}">${ico('check', 12)}保存</button>
</div>
<div class="jc-editbar">${ico('pen', 14)}
<span>可填的是<b>输赢归因</b>、<b>竞对价</b>、<b>竞对是谁</b>与<b>依据 / 客户原话</b>四列:
<b>这四列系统一格都不写</b> —— 客户为什么不下单只有你看得见,别让系统替他解释。
归因落 PRICE 的行务必把竞对报的那个数填上,败价幅度全看这一格。不该归档的料按行首 × 撤掉。
改动只落任务草稿层,<b>保存不等于放行</b>。</span>
</div>`;
const addRowBar = (address: string): string => `<div class="jc-addrow">
<button type="button" class="jc-egbtn ghost" data-edit-add="${address}">${ico('plus', 12)}加一行</button>
<em>照末行的样子添一行空的;主键(型号 PN)填了才存得下,不填这一行不落库。</em>
</div>`;
/**
* 情报卡主表 —— 一行一料:既成事实列 + 人那一侧四列 + 算出来的败价幅度。
*
* `data-col` 只挂人可改的四列(cause_tag / competitor_price / competitor_name / evidence)
* + 主键 pn —— 编辑面精确等于 BD 的职权面;**主键格必挂 `data-col`**(它不是编辑面,
* 是行的钥匙:不挂,服务端按「全表一致列」补键,两行会对到同一行上,后者盖前者 ——
* A2 走查逮过)。
* 归因挂 `req`:空着出「必填」胶囊、整行标底,与提交闸、放行门同源(都数 `_missing`)。
*/
const INTEL_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
<thead><tr>
<th style="width:36px;min-width:36px"></th>
<th style="width:44px;min-width:44px">果</th>
<th style="min-width:170px">PN</th>
<th style="min-width:84px">品类</th>
<th style="min-width:70px">年用量</th>
<th style="min-width:88px">报价日期</th>
<th style="min-width:88px">我方报价</th>
<th style="min-width:80px">成交价</th>
<th class="req" style="width:92px;min-width:92px">输赢归因(你落)</th>
<th style="min-width:80px">竞对价</th>
<th style="min-width:96px">竞对是谁</th>
<th style="width:104px;min-width:104px">败价幅度</th>
<th style="min-width:150px;width:100%">依据 / 客户原话</th>
</tr></thead>
<tbody data-rows="mine:quote_archive.intel" data-edit="quote_archive.intel"><tr>
<td class="jc-rn"><button type="button" class="jc-rowdel" data-edit-del="quote_archive.intel" title="撤掉这一行">×</button></td>
<td class="jc-ty" data-h="果"><span class="jc-tbadge" data-class-of="deal_result" data-slot="deal_result"></span></td>
<td data-h="PN"><span class="jc-vcell mono" data-slot="pn" data-col="pn" data-blank></span></td>
<td class="dimempty" data-h="品类"><span class="jc-vcell" data-slot="category" data-blank></span></td>
<td class="dimempty" data-h="年用量"><span class="jc-vcell mono" data-slot="eau" data-blank></span></td>
<td class="dimempty" data-h="报价日期"><span class="jc-vcell mono" data-slot="quoted_at" data-blank></span></td>
<td class="dimempty" data-h="我方报价"><span class="jc-vcell num" data-slot="quoted_price" data-blank></span></td>
<td class="dimempty" data-h="成交价"><span class="jc-vcell num" data-slot="final_price" data-blank></span></td>
<td class="req" data-h="输赢归因(你落)">
<span class="jc-obadge" data-class-of="cause_tag"></span>
<span class="jc-vcell" data-slot="cause_tag" data-col="cause_tag" data-blank></span>
</td>
<td class="sug" data-h="竞对价"><span class="jc-vcell num" data-slot="competitor_price" data-col="competitor_price" data-blank></span></td>
<td class="sug" data-h="竞对是谁"><span class="jc-vcell" data-slot="competitor_name" data-col="competitor_name" data-blank></span></td>
<td data-h="败价幅度"><span class="jc-cbadge" data-class-of="price_gap_band"></span></td>
<td class="opt" data-h="依据 / 客户原话"><span class="jc-vcell" data-slot="evidence" data-col="evidence" data-blank></span></td>
</tr></tbody>
</table></div>`;
/**
* 情报清单表(收口件用)—— 编辑面换成**放行时可改的两列**(归因与依据),
* 与 `requiredDeliverables[].editableAtRelease` 逐字对齐。
*/
const ARCHIVED_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
<thead><tr>
<th style="width:36px;min-width:36px"></th>
<th style="width:44px;min-width:44px">果</th>
<th style="min-width:170px">PN</th>
<th style="min-width:88px">报价日期</th>
<th style="min-width:88px">我方报价</th>
<th style="min-width:80px">成交价</th>
<th style="width:92px;min-width:92px">输赢归因</th>
<th style="min-width:80px">竞对价</th>
<th style="width:104px;min-width:104px">败价幅度</th>
<th style="width:84px;min-width:84px">入库口径</th>
<th style="min-width:150px;width:100%">依据 / 客户原话</th>
</tr></thead>
<tbody data-rows="mine:quote_archive.intel" data-edit="quote_archive.intel"><tr>
<td class="jc-rn"><button type="button" class="jc-rowdel" data-edit-del="quote_archive.intel" title="撤掉这一行">×</button></td>
<td class="jc-ty" data-h="果"><span class="jc-tbadge" data-class-of="deal_result" data-slot="deal_result"></span></td>
<td data-h="PN"><span class="jc-vcell mono" data-slot="pn" data-col="pn" data-blank></span></td>
<td class="dimempty" data-h="报价日期"><span class="jc-vcell mono" data-slot="quoted_at" data-blank></span></td>
<td class="dimempty" data-h="我方报价"><span class="jc-vcell num" data-slot="quoted_price" data-blank></span></td>
<td class="dimempty" data-h="成交价"><span class="jc-vcell num" data-slot="final_price" data-blank></span></td>
<td class="req" data-h="输赢归因">
<span class="jc-obadge" data-class-of="cause_tag"></span>
<span class="jc-vcell" data-slot="cause_tag" data-col="cause_tag" data-blank></span>
</td>
<td class="dimempty" data-h="竞对价"><span class="jc-vcell num" data-slot="competitor_price" data-blank></span></td>
<td class="dimempty" data-h="败价幅度"><span class="jc-cbadge" data-class-of="price_gap_band"></span></td>
<td data-h="入库口径"><span class="jc-dbadge" data-class-of="share_scope"></span></td>
<td class="opt" data-h="依据 / 客户原话"><span class="jc-vcell" data-slot="evidence" data-col="evidence" data-blank></span></td>
</tr></tbody>
</table></div>`;
/** 入库口径分布:四段按条数分宽 —— 守恒等式(四档相加 = 行数)的可视形 */
const DIST = `<div class="jc-dist">
<div class="jc-distbar">
<i class="p" data-class-of="quote_archive_intel_share_scope_full" data-slot="quote_archive_intel_share_scope_full"></i>
<i class="n" data-class-of="quote_archive_intel_share_scope_pending" data-slot="quote_archive_intel_share_scope_pending"></i>
<i class="r" data-class-of="quote_archive_intel_share_scope_masked" data-slot="quote_archive_intel_share_scope_masked"></i>
<i class="k" data-class-of="quote_archive_intel_share_scope_internal" data-slot="quote_archive_intel_share_scope_internal"></i>
</div>
<div class="jc-distlg">
<span><em class="a"></em>全量入库 · 客户与竞对都留名 <b data-slot="quote_archive_intel_share_scope_full"></b></span>
<span><em class="b"></em>待拍板 · 口径还没定 <b data-slot="quote_archive_intel_share_scope_pending"></b></span>
<span><em class="b"></em>脱敏入库 · 只留价位与败因 <b data-slot="quote_archive_intel_share_scope_masked"></b></span>
<span><em class="c"></em>只留内部 · 不进共享库 <b data-slot="quote_archive_intel_share_scope_internal"></b></span>
</div>
</div>`;
/* ==================== 局面一 · 情报归档工作单(归一 / 逐条归因)==================== */
/**
* 逐颗情报档案 —— 一颗料一张卡(data-rows 逐行复制):左既成事实、右归因。
*
* **容器用 `<section>` 不用 `<div>`**:渲染器的行展开按第一个同名闭合标签截行内容,
* 行模版里嵌着 `<div>` 时 `<div data-rows>` 会把行截成套娃(A2 逮过,记在陷阱表)。
*/
const INTEL_CARDS = `<section class="jc-selcards" data-rows="mine:quote_archive.intel">
<article class="jc-selcard" data-class-of="deal_result">
<div class="jc-selcard-h">
<span class="jc-tbadge" data-class-of="deal_result" data-slot="deal_result"></span>
<code data-slot="pn"></code>
<span class="why" data-slot="evidence" data-blank></span>
<span class="jc-obadge" data-class-of="cause_tag"></span>
<span class="jc-dbadge" data-class-of="share_scope"></span>
</div>
<div class="jc-selcard-b">
<div class="jc-selcol">
<h5>既成事实(报过的价,一个数字都不改)</h5>
<div class="jc-kvrow"><i>客户</i><span data-slot="customer_name" data-blank></span></div>
<div class="jc-kvrow"><i>品类 / 制造商</i><span data-slot="category" data-blank></span></div>
<div class="jc-kvrow"><i>年用量</i><span data-slot="eau" data-blank></span></div>
<div class="jc-kvrow"><i>我方报价</i><span class="num" data-slot="quoted_price" data-blank></span></div>
<div class="jc-kvrow"><i>成交价</i><span class="num" data-slot="final_price" data-blank></span></div>
<div class="jc-kvrow"><i>报价日期</i><span data-slot="quoted_at" data-blank></span></div>
<div class="jc-kvrow"><i>结果</i><span data-slot="deal_result" data-blank></span></div>
</div>
<div class="jc-selcol">
<h5>归因(填回下表,这里回显)</h5>
<div class="jc-kvrow"><i>输赢归因</i><span class="sc-req" data-slot="cause_tag" data-blank></span></div>
<div class="jc-kvrow"><i>竞对价</i><span class="sc-sug num" data-slot="competitor_price" data-blank></span></div>
<div class="jc-kvrow"><i>竞对是谁</i><span class="sc-sug" data-slot="competitor_name" data-blank></span></div>
<div class="jc-kvrow"><i>依据 / 原话</i><span class="sc-opt" data-slot="evidence" data-blank></span></div>
<div class="jc-kvrow"><i>败价幅度</i><span class="jc-cbadge" data-class-of="price_gap_band"></span></div>
<div class="jc-kvrow"><i>入库口径</i><span class="jc-dbadge" data-class-of="share_scope"></span></div>
</div>
</div>
</article>
</section>`;
/** 归因六档 —— 静态规则,尺挂在墙上(素材:MVP 示例工作流 §1.6 结果登记) */
const CAUSE_RULES = `<div class="jc-graderule">
<div class="jc-graderule-r">
<span class="jc-band gk">PRICE</span>
<p><b>输在价格。</b>客户明说了别家更便宜,或直接给出一个我们够不着的数 ——
<b>这一档必须填竞对价</b>:没有那个数,「输在价格」下一单读不出任何东西,
而有了它,败价幅度 A-01 当场算得出来。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gc">LEADTIME</span>
<p><b>输在交期。</b>客户等不起 —— 别家现货、我们八周。
价格再低也没用,下一单同一颗料先问现货商。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gb">CERT</span>
<p><b>认证资质。</b>车规、医疗、军工的门槛没过,或供应商资质不在客户合格名录里 ——
下一单这一类需求先核认证,别报完价才发现进不去。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gb">SPEC</span>
<p><b>规格不匹配。</b>参数、封装、温度范围对不上 —— 这是选型的账,
下一单同一需求直接从替代选型开始,别在原厂料上耗。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gz">RELATION</span>
<p><b>既有供应关系。</b>客户手上已经有稳定的供应商,这一轮只是来比个价 ——
情报照记:下一单知道要拿什么去撬,或者干脆不投这个标。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gz">OTHER</span>
<p><b>说不清。</b><b>「客户没回」不是原因</b> —— 问不出来就落这一档,
并在依据里写明「未获反馈」。<b>不许编一个听着合理的理由</b>:
编出来的原因会一路流进下一单的判据里,而那时已经没人记得它是编的。</p>
</div>
</div>`;
/** 败价幅度 A-01 的三档 —— 静态规则(素材:MVP 示例工作流 §1.6 情报复用) */
const BAND_RULES = `<div class="jc-graderule">
<div class="jc-graderule-r">
<span class="jc-band ga">≤ 3%</span>
<p><b>擦肩而过。</b>三个点以内的价差不足以定输赢 —— 下一单同价位再试,
真正输的多半在交期、认证或关系上,回头看看依据那一栏写了什么。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gc">3% ~ 10%</span>
<p><b>够得着。</b>下一单要么让到这一档,要么换替代料把成本降下来 ——
这是替代选型最值钱的入口:同样的功能,换一颗料就把十个点让出来了。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gk">&gt; 10%</span>
<p><b>结构性价差。</b>一成以上不是让价能填的坑,是成本结构不对 ——
下一单别再报同一价位,先走替代选型或重新寻源。<b>拿同一个价再报一次不叫坚持,叫没记账</b>。</p>
</div>
</div>`;
export const ARCHIVE_WORKSHEET = doc({
badge: 'file-text',
kicker: '统一报价 · A7 情报归档 · 归因作业',
title: '情报归档工作单',
titleRef: 'rfq_ref',
state:
'<span class="jc-rp-wait warn">待归因 <b data-slot="quote_archive_intel_missing"></b> / <b data-slot="quote_archive_intel_rows"></b> 条</span>',
body:
kvCard('本单结果', [
['型号 PN', 'pn', 'mono'],
['报价日期', 'quotedAt', 'mono'],
['拒绝原因', 'lostReason'],
['竞对价位', 'competitor_price'],
]) +
noteCard('为什么要登记', '拒绝原因与竞对价位是<b>下一单的判据</b>:它们回写历史价格情报,供 P-03 的「距上次报价 / 行情稳定」与 P-01 的客户等级因子取用。这一单的败仗,是下一单的地形图。'),
part('归档任务', '客户 <b data-slot="customer_name" data-blank></b> · 报价单 <b data-slot="quote_no" data-blank></b> · 共 <b data-slot="intel_total"></b> 条') +
`<div class="jc-derive">${ico('info', 14)}
<span>这一屏是一份情报的作业台:系统已按上游的结果归好情报卡,报价、成交价与日期都在下面 ——
<b>既成事实一个数字都不改</b>。你要做的是<b>逐条落输赢归因</b>:客户到底为什么下单、
为什么不下单。这一列系统一格都不写:那件事只有跟单的人知道。
<b>这一单之后再没有人会回头看它</b> —— 归因写错没有下一道工序纠正,
而它会一路流进下一单的判据里。</span>
</div>` +
FOUR +
part('一单情报是怎么走完的', '既成事实 → 归因 → 败价幅度 → 口径 → 下一单') +
ARCHIVE_LINE +
part('逐颗情报档案', '既成事实在左 · 归因在右') +
INTEL_CARDS +
part('输赢归因六档', '选最主要的那一个 —— 尺挂在墙上') +
CAUSE_RULES +
part('败价幅度 A-01', '价差决定下一单怎么用这条情报') +
BAND_RULES +
part('填回工作表', '归因落在这 —— 补齐了执行单才提交得了') +
GAP_BAR +
editGate('quote_archive.intel', '情报卡填回表') +
INTEL_TABLE +
addRowBar('quote_archive.intel') +
`<div class="jc-drop">${ico('info', 13)}
<span>带红标的<b>输赢归因</b>是必填,而且<b>只由你填</b> —— 缺一行执行单按不下提交,
与放行门是同一个数同一套算法。六个取值一个字母不许差:
<b>PRICE</b> 价格 · <b>LEADTIME</b> 交期 · <b>CERT</b> 认证资质 · <b>SPEC</b> 规格不符 ·
<b>RELATION</b> 既有供应 · <b>OTHER</b> 说不清。
归因落 PRICE 的行务必把竞对报的那个价填进<b>竞对价</b> —— 那一格空着这张单同样提交不了,
败价幅度全看它。<b>依据那一栏尽量记客户原话</b>:「你们比 XX 贵两毛」比「价格偏高」
值钱得多 —— 下一单定价时看的就是这一句。不该归档的料按行首 × 撤掉。</span>
</div>`,
foot: sign('BD 归因 · AI 归一情报卡并按尺算败价幅度,输赢的缘由由你落', '统一报价 · A7 情报归档 · 在办'),
});
export const ARCHIVE_RELEASE = viewShell({
title: '情报归档 · 闭环待放行',
kicker: 'A7 · 成果预览',
tone: 'ok',
/* ==================== 局面二 · 情报归档清单(拍板 / 放行 / 归档)==================== */
/**
* 落边图:情报卡清单 → 归档闭环 → 下一单的三个用处。
*
* A7 是流程包的最后一站,**只有一口且无条件**(E14)—— 这条边不是选出来的,
* 所以画成实线亮是如实,不是替谁宣布。右边三个用处是这份情报的去处,
* 各挂本单条数(基座现数),没有的那一档条数为 0、人一眼看得出。
*/
const ROUTE_FIG = `<div class="route">
<svg viewBox="0 0 660 250" fill="none">
<rect class="rnode hit" x="8" y="95" width="150" height="60" rx="10"/>
<text class="rtxt hit" x="83" y="120" text-anchor="middle">情报卡清单</text>
<text class="rsub" x="83" y="138" text-anchor="middle">共 <tspan data-slot="quote_archive_intel_rows"></tspan> 条</text>
<path class="rspine" d="M158 125 H228"/>
<path class="rtip hit" d="M216 119 l14 6 -14 6 z"/>
<rect class="rnode hit" x="232" y="95" width="128" height="60" rx="10"/>
<text class="rtxt hit" x="296" y="120" text-anchor="middle">归档闭环</text>
<text class="rsub" x="296" y="138" text-anchor="middle">这条流走到终点</text>
<path class="redge" d="M360 125 C420 125 430 40 470 40"/>
<path class="redge" d="M360 125 C420 125 430 125 470 125"/>
<path class="redge" d="M360 125 C420 125 430 210 470 210"/>
<path class="rtip" d="M458 34 l14 6 -14 6 z"/>
<path class="rtip" d="M458 119 l14 6 -14 6 z"/>
<path class="rtip" d="M458 204 l14 6 -14 6 z"/>
<text class="rcond" x="378" y="62">时效 · 报价日期</text>
<text class="rcond" x="378" y="115">价格 <tspan data-slot="quote_archive_intel_cause_tag_price"></tspan> 条</text>
<text class="rcond" x="378" y="232">行为画像</text>
<rect class="rnode" x="470" y="8" width="180" height="64" rx="10"/>
<text class="rtxt" x="560" y="35" text-anchor="middle">下一单 · 三分类 P-03</text>
<text class="rsub" x="560" y="53" text-anchor="middle">距上次报价多久 · 行情稳不稳</text>
<rect class="rnode" x="470" y="93" width="180" height="64" rx="10"/>
<text class="rtxt" x="560" y="120" text-anchor="middle">下一单 · 成本定价</text>
<text class="rsub" x="560" y="138" text-anchor="middle">基准价与竞对价位</text>
<rect class="rnode" x="470" y="178" width="180" height="64" rx="10"/>
<text class="rtxt" x="560" y="205" text-anchor="middle">客户画像 · 利润模型 P-01</text>
<text class="rsub" x="560" y="223" text-anchor="middle">价格敏感度 · 决策周期</text>
</svg>
<div class="legend">
<span>A7 只有一口且无条件 —— 它不是选出来的,是这条流的终点</span>
<span>右边三个用处是这份情报的去处:入库口径定的就是它们读不读得到</span>
<span>只留内部那一档不进共享库 —— 三条虚线到那一单就全断了</span>
</div>
</div>`;
/** 入库口径三档 —— 静态规则,决策卡上那一枚 recommended 由 A-02 标 */
const SCOPE_RULES = `<div class="jc-graderule">
<div class="jc-graderule-r">
<span class="jc-band ga">全量入库</span>
<p><b>客户与竞对都留名。</b>下一单查这颗料时,客户是谁、竞对是谁、报了多少一目了然 ——
情报最值钱的形态。代价是客户身份与第三方报价就此对内公开。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gb">脱敏入库</span>
<p><b>只留价位与败因。</b>竞对与客户的名字抹掉,价位、归因、日期照留 ——
下一单照样判得了价,只是追不到是谁说的。<b>带着第三方报价的情报默认走这一档</b>:
竞对的价是他给客户的,不是给我们的。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gz">只留内部</span>
<p><b>不进共享情报库。</b>这一单的情报只在本案卷里躺着,下一单查不到 ——
涉密、客户明说不外传、或项目还在谈时才选这一档。选它等于承认这份情报只值一次。</p>
</div>
</div>`;
export const ARCHIVE_ROSTER = doc({
badge: 'file-check',
kicker: '统一报价 · A7 情报归档 · 交付成果',
title: '情报归档清单',
titleRef: 'rfq_ref',
state:
'<span class="jc-rp-wait">情报 <b data-slot="quote_archive_intel_rows"></b> 条 · 待拍板 <b data-slot="quote_archive_intel_share_scope_pending"></b> 条(要归零)</span>',
body:
tableCard('历史价格情报回写', 'mine:shared.history_price', [
['型号', 'pn', 'mono'],
['报价日期', 'quoted_at', 'mono'],
['拒绝原因', 'lost_reason'],
]) +
kvCard('画像与落地率', [
['客户编码', 'customer_code', 'mono'],
['画像标签', 'profile_tags'],
['统计周期', 'period', 'mono'],
['落地率', 'landing_rate'],
]) +
noteCard(
'闭环的含义',
'放行后这条工作流走到<b>终点节点</b>,状态收敛为「已完成」。' +
'终态封卷触发<b>蒸馏钩子</b>:从任务记忆归纳出活动记忆的候选条目(带证据链),' +
'经回放与人放行才成为下一版业务模型的一部分 —— <b>记忆不载权力</b>。',
),
part('归档要素', '情报归档 · 能算的归判定表,要担责的归人') +
FOUR +
part('入库口径分布', '全量 + 待拍板 + 脱敏 + 只留内部 = 总条数') +
DIST +
`<div class="jc-derive">${ico('info', 14)}
<span>四段条数由基座逐行现数,不是谁报上来的。橙段(<b>待拍板</b>)是过渡态 ——
情报归好了而口径还没定,它<b>归零这一单才走得到放行门</b>。
进了共享情报库的东西下一单谁都读得到,<b>这条边界过去了就收不回来</b> ——
所以系统不替你定,只按 A-02 给一枚建议。</span>
</div>` +
part('情报卡清单', '<b data-slot="intel_total"></b> 条 · 每条都有归因') +
GAP_BAR +
editGate('quote_archive.intel', '情报清单表') +
ARCHIVED_TABLE +
`<div class="jc-drop">${ico('info', 13)}
<span>每一行摆的是这一条的<b>来路</b>:报了多少、成没成、输赢归在哪一档、
竞对报了什么价、败价幅度落在哪一级。签字前请重点看两处:
<b>归因列</b>里落了 OTHER 的条目(说不清的情报到下一单就是哑的),
与<b>依据列</b> —— 没有客户原话的归因,下一单只能当传说看。
这一屏可改的是<b>输赢归因</b>与<b>依据</b>两列(你的职权面)。</span>
</div>` +
part('入库口径', '三档 · 建议归尺,结论归你') +
SCOPE_RULES +
part('落边 · 这一签之后谁用得上', '一口且无条件 · 归档闭环') +
ROUTE_FIG +
`<div class="concl">
${ico('git-branch', 15)}
<div class="cl-main"><b>归档结论</b> —— <span data-slot="summary" data-blank></span></div>
<div class="cl-route"><em>客户行为画像</em><b data-slot="buyer_profile" data-blank></b><code data-slot="share_policy" data-blank></code></div>
</div>` +
part('放行后即执行的落地动作', '门前 → 门后') +
`<div class="jc-acts">
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>草稿层合并进正式版</b>&#12288;<span>本任务写下的情报卡与归档结论一并落账;
你就地改过的归因与依据随之落定。</span></div>
</div>
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>归档闭环那一口此刻才发射</b>&#12288;<span>情报卡
<b data-slot="quote_archive_intel_rows"></b> 条随载荷交出去,这条工作流走到终点 ——
放行之前,世界上什么都没有发生。</span></div>
</div>
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>下一单开始受用</b>&#12288;<span>按你定的口径,这几条价位、败因与日期
进入下一次询价的判据面 —— <b>归档做得好不好,不在这一单看得出来</b>,
在下一次报价能不能一眼看到「上次为什么丢」看得出来。</span></div>
</div>
</div>`,
foot: sign('BD 归档放行 · AI 归一与算尺留痕 —— 要改哪一格,在这张情报清单上就地改', '统一报价 · A7 情报归档 → 归档闭环'),
});
@@ -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,298 @@
/**
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**。
*
* A1 反复空转的根因是没有它:S1 一次把八字段写齐之后,S3「补齐八字段」已经无活可干,
* 而每一拍喂给模型的是整本说明书,它只能把同样三行再写一遍 ——
* 被「写了等于没写」拒回,下一拍再来一遍,连拒到熔断。
*
* 剧本把这件事挪进引擎:`doneWhen` 一真,引擎自己收掉那一步,**不问模型**。
* 所以这几条断言守的是剧本本身的可用性 —— 项 id 对不对得上、判据会不会开局即真、
* 声明的台账与工具是不是活动真有的。写错任何一条,后果都是运行时才发现的空转。
*/
import { describe, expect, it } from 'vitest';
import { evaluateCondition } from '@sfeel';
import { INTAKE_PHASES, INTAKE_STEPS, NARRATIVE } from '../src/case-model.ts';
import { CMMN_XML } from '../src/case-model.ts';
import { manifest } from '../src/manifest.ts';
/** 从 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');
/*
* **执行卡零输入**(2026-08-02 用户定:填写全去工作区)——
* 卡上不绑表、不摆格子;清单在工作区那份件上就地改(editGate + 孪生件),
* 提交闸回落到必交付物的台账声明照样把必填看住。
* 从前这儿断言卡里带 `"ledger":"quote_intake.record"` 的 blocks —— 那一版
* 同一张表摆两处(卡上一份、件上一份),人不知道改哪一份算数。
*/
/*
* 骨架已搬进说明书(2026-08-03:模型常在「拆表+收步+派卡」同一拍发这张卡,
* 那一拍 S3 的剧本没摊开 —— 骨架只放剧本里就会发出裸卡,实测指导书整个是空的)。
* 剧本里留的是指路;零输入纪律与骨架本体都在说明书里,每一拍都看得见。
*/
expect(confirm?.guide).toContain('骨架在说明书里');
expect(NARRATIVE).toContain('受理需求审定卡的骨架');
expect(NARRATIVE).toContain('卡上不摆表、不摆格子');
expect(NARRATIVE).toContain('"itemId":"S3_confirm"');
expect(confirm?.guide).not.toContain('"blocks"');
expect(confirm?.guide).toContain('confirmed_total');
});
/*
* **收步判据与验收判据同源**(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;
}
}
/**
* **每个计划项都得有格可待**(2026-08-02 用户:「卡片的进度条走的不对」)。
*
* 从前 `INTAKE_PHASES` 漏了 `S3_confirm`:计划项六个、名单里五个。
* 于是 S2 一收,前两格全过,进度条当场跳到「判定去向」—— 而人这会儿正对着
* 受理需求审定那张卡。**他手上办的那件事在条上没有格子**,条却已经走过去了:
* 那不是走快了一格,是它在说一件没发生的事。
*/
describe('阶段刻度:每一项都有格可待', () => {
const inPhase = new Set(INTAKE_PHASES.flatMap((p) => p.items));
it('**除放行外每一项恰好属于一个阶段** —— 漏一项,进度条就会在它办着的时候跳过去', () => {
const planned = INTAKE_STEPS.map((s) => s.item).filter((id) => id !== 'T_release');
expect(planned.filter((id) => !inPhase.has(id))).toEqual([]);
});
it('一项不进两个阶段 —— 进了两个,「这一格过没过」就有两种答案', () => {
const all = INTAKE_PHASES.flatMap((p) => p.items);
expect(all.length).toBe(new Set(all).size);
});
it('**放行不在名单里** —— 收口那一格由基座统一加,活动自己起名会各叫各的', () => {
expect(inPhase.has('T_release')).toBe(false);
});
it('阶段里不许出现案例模型没有的项 —— 那一格永远过不去', () => {
const ids = new Set(INTAKE_STEPS.map((s) => s.item));
expect([...inPhase].filter((id) => !ids.has(id))).toEqual([]);
});
});
+737 -58
View File
@@ -1,85 +1,764 @@
/**
* 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="quote_intake_record_rows" 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>
<!--
② 拆完了 —— 交人审定那一步的入场前提。只看「有没有拆出行来」:
缺不缺字段不影响该不该给人看,缺口是**卡上要说清的事**,不是拦住这张卡的理由。
读**基座数的行数**,不读自报的 line_total(2026-08-02 用户实测送来的单,
「机检只读基座数」第四次被违反现形):模型把 record 三行在 S1 拍就写了،
却没记 line_total(那本是 S2 的账),S2 空手收步 —— 这道门于是永远不开,
引擎报「依赖断点」,模型被推去发补全卡绕路,两张多余的卡把 4 次人环
预算烧光,整单 exhausted。行落了没有,基座数得出来;门不该等一个
要模型自己记的数。
-->
<sentry id="s_parsed">
<onPart id="op_ps" caseFileItemRef="quote_intake_record_rows"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_ps"><condition>quote_intake_record_rows &gt; 0</condition></ifPart>
</sentry>
<!--
③ **人审定过了** —— 后面查历史价/打标的入场前提。
必填(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。**说这单接下来往哪走、谁接手**。
**交代里说缺什么,用件上那三个词**(必填 / 建议 / 选填),而且**只说已发生的事**:
客户没给的就说「客户没给」并留空 —— 估价、垫数这类事你没有权限做,更不许在交代里
声称做过(见「三种分量」那段:目标价尤其如此)。历史价、三分类、去向是**审定之后**
那几步的事:这一拍它们连数都还没有,一个字都不要提。
派审定卡那一拍的交代照这个样子说(数字与料号换成本单的):
「三颗料拆好了,交期用量都齐;LM358DR、TL072CDR 客户给了目标价,NE555P 没给
(建议追问,不拦单)。整份受理需求请你审定,哪一格不对在工作区的件上就地改。」
别每拍一个腔调复读(「已完成X,现在进行Y」)。事情不同,话就该不同 ——
调工具那一拍说「我去查这三颗的历史价」,拿到结果那一拍说查出了什么,
两句话该是不一样的语气。有风险、要他拿主意、或者你打算叫人,**先说出来**,
别等他自己从表里看出来。不说话的后果是:他的对话区从头到尾空白,只看得见「多了一个任务」。
【怎么把一项干完 · 一拍连做】一个计划项常要**两三个动作**才交得出成果(先写记录、
再结算总数;先调工具拿判据、再把判据写进台账)。write 和 tool 都**不会**自动把那一项收掉 ——
都做完了,用 finish 宣告这一项完成。**这些动作排进同一拍的 actions 串里一口气给**,
别拆拍:「写 record、写 verdict、finish S1」是一拍,不是三拍;
「写 type_table 打标结果、写 verdict 统计、finish S5、写 alt/rfq 请求、写旗、finish S6」
能排一串就排一串。只有两处必须停拍:调工具要看出参(tool 放串尾,下一拍接着干);
发卡 / 提交会挂起(它们只能是串尾)。**这一单常路 5-7 拍就该收口** ——
每多拆一拍,就是多烧一遍完整上下文。
【受理需求审定卡的骨架 —— 哪一拍发都照这个,一个字段别少】
拆完受理记录就要派这张卡(S3_confirm)。**骨架放在说明书里正是因为你常在
「拆表 + 收步 + 派卡」同一拍里发它** —— 那一拍 S3 的剧本还没摊开,凭记忆拼出来的
就是一张只有一句话的裸卡(实测:人收到的指导书整个是空的)。照抄:
{"kind":"human","itemId":"S3_confirm","workOrder":"execution","card":{
"title":"受理需求审定:<单号> 共<N>条","ask":"<照当下情形写,见下>",
"steps":["型号 PN 与年用量 EAU 必须有 —— 缺一条这一单就走不到放行;客户没给的去问,问不到也要在备注里写清「客户未提供」",
"客户料号、品类、制造商、目标价这四格是建议追问 —— 不填也走得到放行,但那一问不能省;尤其目标价,有它才谈得上「报得进不进得去」,客户不肯给就问一句「贵司这颗现在的成交价区间」",
"客户没给的一律留空,由你判断要不要追问 —— 系统查到的历史价是分类依据,不是客户的目标价",
"改在工作区的件上:抽错的就地改,多出来的行按行首 × 撤掉,漏掉的用表尾「加一行」补上;改完保存,回这张卡提交"],
"standard":"必填(PN / 年用量)一条不缺;建议档(客户料号 / 品类 / 制造商 / 目标价)该问的都问过了 —— 问到的填上,问不到的留空并在备注里写明,放行时由你决定先受理还是退回。",
"basis":[{"k":"<缺格的 PN>","v":"缺年用量(必填,须补)"},
{"k":"<缺格的 PN>","v":"缺制造商(建议追问,可先受理)"},
{"k":"<缺格的 PN>","v":"客户未给目标价(建议追问,不拦单)"}]}}
· steps 与 standard 是**作业指导书**,卡上照原样摆给人看 —— 这一段每一单都一样,照抄别重写;
**卡面是纯文本**:里面别写星号那类记号,它们不会变粗体,只会原样摆在人眼前(实测过)。
· ask 照当下情形写:**有必填缺**写「有几条缺 PN/年用量,须向客户问清后填回」;
**只缺建议档**写「有几条缺建议追问的格子(客户料号/品类/制造商/目标价),可先受理也可去问」;
**都齐**写「请核对确认」。三档的词照件上那三个词说,别混用。
· basis 逐条写**谁缺什么、是必填还是建议还是选填**;一格不缺就写这一单的要点
(几条料、客户点名的口径要求)。
· **卡上不摆表、不摆格子**(填写全在工作区):整份清单就在工作区那份件上,
每一格能就地改、能增删行,缺哪几格件顶上的红带点着名。别往卡里塞 blocks 或 fields ——
塞了是把同一张表摆两处,人不知道改哪一份算数。
§Z 结案:BD 在验收单上签字,系统把草稿合并成正式记录并发出 intake 口的载荷。`;
/**
* **步骤剧本** —— 一个计划项一条,把「这一步是什么状态」写成机器读得懂的东西。
*
* 说明书说的是**整单一以贯之的事**(这一单是什么、怎么说话、怎么收口);
* 逐步的做法只写在这里,轮到哪一步才摊开哪一步 —— **两处不重复写同一件事**,
* 重复的代价是钱(每拍烧两遍)与准(两份会分叉,分叉过一次:说明书里的计划项名
* 是案例模型里没有的三个,模型照它报 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。
· **建议(值得追问,但不拦单)**:\`customer_pn\` 客户料号 · \`category\` 品类 ·
\`brand\` 制造商 · \`target_price\` 目标价 —— 声明里标了 \`expect\`,件上标橘「建议」。
缺了照样往下走,但**那一问不能省**:执行卡上要写清「这几格建议追问」。
· **选填(其余一切)**:交期、品质要求、备注…… 件上标灰「选填」,缺了只当提醒。
建议档与选填档一并记进 missing_minor_total。
**别把「建议」说成「选填」**(2026-08-03 用户当场指出):同一件事,工单上标着「建议追问」,
交代里若换成另一档的叫法,人会以为这两处说的不是一回事 —— 而它们就是一回事。
**目标价客户没给就留空,不许估**:它是**客户的意向价**,不是我们算得出来的数。
S4 查到的历史价是**判 A/B/C 的依据**(距上次报价几天、行情稳不稳),
**不是客户的目标价** —— 拿它填进 \`target_price\` 就是替客户表了态,
下游定价、议价全建在这个假数上。也不许只在**交代里声称**估过价、垫过数
(实测栽过一次:台账留空是对的,嘴上却说按历史价估了 —— 而那一步连历史价都还没查,
纯属编造,人读了就以为表里躺着一个估出来的数)。留空、在件上标橘「建议」、
在执行卡上写明建议追问,这三件事合起来才是对的处置。
**目标价常缺是常态,不是缺陷** —— 实测栽过一次:一颗料只缺目标价却被记成必填缺、
还派了执行卡去问客户,而按 F-01 它根本不该拦单。
拿不准某个字段算哪一档,就问自己一句:**它是不是 PN 或年用量?** 不是就不许拦单。
【怎么拆】逐条写进 \`quote_intake.record\`,一条需求一行(同一 PN 不同数量档算两行)。
列名照台账字典写,别自己起名:
· \`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** —— 这一步触达世界(把件交到人手上),给了就等于给引擎
* 一把替人签字的钥匙。而它的成果按定义也判不出来:「人看过、改过、认了」
* 不是台账里任何一个数能自己变出来的。人工激活也一并给上:
* 引擎不会替你按下这一步,也不会替你收 —— 发卡就算启动,收要你自己 finish。
*/
guide: `拆好的九条摆在那儿了,**这一单现在要经过人的手**。
【一单必发一张,不管缺不缺】
这不是「缺东西时的追问」,是**阶段2 那张执行页**:AI 拟了稿,人审定。
字段齐全也要发 —— 拆错一格(PN 少个后缀、品类译反、年用量看错列)只有人看得出来,
而那些错往后每一步都跟着跑。**别因为「没有缺口」就跳过这一步**:
实测栽过一次(2026-08-01),那一版只在缺东西时才发卡,于是一封齐整的询价进来,
人一眼没看过受理需求,下一次被叫到已经是终局验收 —— 用户当场说
「我都没确认需求 它就让我验收了」。
【怎么发】**卡的骨架在说明书里(「受理需求审定卡的骨架」那一段),逐字段照抄** ——
不管你在哪一拍发它,骨架都在说明书里等着,不要凭记忆拼一张裸卡。
这一步人工激活:发卡就算启动,不必单发 start;引擎也不会替你收 —— 人交回后要你自己 finish。
发完卡把 **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 再写一行。
【**这张卡一单只发一次**】人交回来之后,不论还缺什么,**都不许再发同一张卡**。
必填(PN / 年用量)有提交闸兜着:缺一条他根本按不下提交,所以卡交回来时必填一定是齐的;
建议档的缺口不拦单,留着交放行的人定。
实测栽过多次(2026-08-02 那一单连撞四拍):从前这儿写着「人交回来还缺必填,就再发一张卡」——
而提交闸上线后那条路已经不存在了,模型照它办,每次都撞在
「这件事刚问过,人也答过了」上,白烧四拍。
【人把格子留空交回来 = **客户给不了**,那也是一个答案】
这时候要做的**不是再问一遍**(基座会当场拒:同一件事不许问第二遍),而是:
· 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、两枚旗已定 ——
可以走放行了(T_release 人工激活:动它就算启动,不必单发 start)。
**submit 之前,把清单里**每一条**裁量判据(L3/L4)都裁掉**。
**一条都不能漏** —— 漏一条,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。
*/
/*
* 给人看的刻度 —— **每个计划项都得有格可待**(2026-08-02 用户:「卡片的进度条走的不对」)。
*
* 从前这份名单漏了 `S3_confirm`(它是 2026-08-01 才补回成一项的):
* 计划项有六个,名单里只有五个。于是 S2 一收,前两格全过,进度条当场跳到「判定去向」——
* 而人这会儿正对着受理需求审定那张卡。**他手上办的那件事在条上没有格子**,
* 条却已经走过去了:那不是走快了一格,是它在说一件没发生的事。
*
* `T_release` 不在名单里是**对的**:每个活动都以放行收口,那一格由基座统一加
* (`CLOSING_PHASE`)—— 活动自己给它起名反而会各叫各的。
* 包内自测钉死这条:除放行外每一项恰好属于一个阶段。
*/
export const INTAKE_PHASES = [
{ name: '定位客户', items: ['S1_customer'] },
{ name: '归一受理', items: ['S2_normalize'] },
{ name: '交你审定', items: ['S3_confirm'] },
{ 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' });
+725 -75
View File
@@ -1,182 +1,737 @@
/**
* 活动包清单 —— 统一报价 · 客户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: '49.5.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: [
/*
* ── 这里**去掉了 `altRequests` / `rfqRequests` 两个载荷字段**(@43,2026-08-02)──
*
* `alt_request` / `rfq_request` 两张台账表同日已退休(见 declarations 那段墓志铭),
* 这两个键从那天起就是**空壳**:载荷装配对数组属性一律塞「本单全部落地行」
* (不加区分),于是它们的内容与 `records` 完全相同 —— 挂着一个名字叫
* 「替代评估请求」而里面装的是受理记录的键,比没有更糟:下游照名字用,用到的
* 是另一样东西。下游(A2/A3)自己按 `records` 里的状况标归一,契约照实说。
*/
{
name: 'to-alternative',
payload: {
type: 'object',
required: ['rfqRef', 'records'],
properties: {
rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' },
records: { type: 'array', title: '询价受理记录(已打标)' },
},
},
description: '有 C 类要替代 —— 去选型',
/* ① 有 C 类就先去选型(替代定案后仍可能回头寻价) */
when: 'quote_intake_record_type_tag_c > 0',
},
{
name: 'to-sourcing',
payload: {
type: 'object',
required: ['rfqRef', 'records'],
properties: {
rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' },
records: { 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 +741,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>`;
+709 -66
View File
@@ -1,77 +1,720 @@
/**
* 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)。
* 占位三级(2026-08-03 补齐,用户:「目标价是建议」):必填红 / 建议橘 / 选填灰,
* 档位跟台账声明走(required → req,expect → sug,其余 opt),颜色与 A2 同一套。 */
/* 胶囊与同一行的数字坐同一条线(2026-08-03 用户:「没有和数字对齐」)——
* 裸 inline-flex 的胶囊按 baseline 挂:盒子(10px 字 + 上下 3px 内距 + 边框 = 18px)比
* 字的基线深好几像素,视觉上整枚往下坠。治法:空格子自己撑起文字那份盒高(12px×1.5),
* 胶囊在里面垂直居中 —— 中线对中线,行高也不再忽高忽低。
* :not(.on) 不能省(2026-08-03 用户:「编辑态太拥挤」):这条比「编辑态藏起只读份」
* 的那条更特异,不圈住的话编辑态里胶囊会盖过 display:none 冒出来,54 个输入框旁边
* 各站一枚 —— 编辑态里格子该不该填由闸与红带说,胶囊只属于看的那份。 */
.proto-root .jc-tbl-in:not(.on) td.req > .jc-vcell:empty,
.proto-root .jc-tbl-in:not(.on) td.sug > .jc-vcell:empty,
.proto-root .jc-tbl-in:not(.on) td.opt > .jc-vcell:empty { display: flex; align-items: center; min-height: 18px; }
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after,
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after,
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after {
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
}
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after { content: "必填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after { content: "建议"; color: #b45309; background: #fff7ed; border-color: #fcd9a8; }
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after { content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line); }
/* 归档件里空格子就是「客户没给」,不再摆「你还能填」的胶囊 */
.proto-root .jc-tbl-in td.dimempty > .jc-vcell:empty::after { content: "—"; color: var(--ink-4); }
/* ② 缺必填的整行 —— 原型是 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; }
/*
* **数值列也左对齐**(2026-08-02 用户:「目标价的价格也要跟标题左对齐,右对齐太奇怪了」)。
*
* 右对齐是给**要竖着相加的那种数**用的(金额合计、数量小计)——这张表里的目标价
* 一行一个料、彼此不相加,右对齐只是让它和自己的表头分家:标题贴左、数贴右,
* 中间空一条,眼睛得来回找。等宽字体留着(位数对齐仍有用),对齐方式跟表头走。
*/
.proto-root .jc-vcell.num, .proto-root .jc-cell.num { text-align: left; }
.proto-root .jc-tbl-in.on td.num > .jc-cell, .proto-root .jc-tbl-in.on .jc-cell.num { text-align: left; }
/* 编辑态下必填空格子:红框 + 红 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-gapbar { display: none; }
html.editable .proto-root .jc-gapbar {
display: flex; align-items: flex-start; gap: 8px;
margin: 0 0 10px; padding: 9px 12px; border-radius: 9px;
background: #fff5f5; border: 1px solid #f3b6b3; color: var(--danger);
font-size: 12px; line-height: 1.65;
}
.proto-root .jc-gapbar svg { flex: 0 0 auto; margin-top: 2px; }
.proto-root .jc-gapbar b { font-weight: 800; }
.proto-root .jc-gapbar span { color: #9a3b36; }
/* 一格不缺 —— 那条带没有话可说 */
html.editable .proto-root .jc-gapbar.v-0 { display: none; }
/* 那条「怎么改」的提示只在开了闸之后说 —— 锁着的时候它是句废话 */
.proto-root .jc-editbar { display: none; }
.proto-root .jc-editgate.on + .jc-editbar { display: flex; }
/* ⑦ 增删行 —— 手改是兜 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' | 'sug' | 'opt', style = ''): string => {
const cls = mode === 'final' ? 'dimempty' : kind;
return `<td class="${cls}" data-h="${head}"><span class="jc-vcell ${style}" data-slot="${key}" data-col="${key}" data-blank></span></td>`;
};
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', '品类', 'sug')}
${cell('eau', '年用量', 'req', 'mono')}
${cell('brand', '制造商', 'sug')}
${cell('target_price', '目标价', 'sug', 'num')}
${cell('remark', '备注', 'opt')}
</tr></tbody>
</table></div>`;
};
/** 编辑态那条提示带 —— 原型的 `jc-editbar`,只在待处理那一屏现身 */
/**
* **缺哪几格,红字直说**(2026-08-02 用户:「执行单现在要怎么填不明显,在报告上明显一点」)。
*
* 从前这一屏只在空格子里摆一枚小小的「必填」胶囊 —— 九行料摊开,那几枚胶囊淹在表里,
* 人得逐行扫过去才知道自己要动哪一格。而他打开这一屏就是来填的。
*
* 现在表上方直接一条红带:**还差 N 格,分别是哪一行的哪一列**。
* 两个数都由基座现算(`域_对象_missing` / `_missing_keys`),模版不算也不判 ——
* 「一格不缺就别出这条带」这个判断交给样式表:`data-class-of` 把数消毒成
* `v-0` / `v-1` / …,`v-0` 那一档 `display:none`。件本身仍然只做取值。
*/
const gapBar = `<div class="jc-gapbar" data-class-of="quote_intake_record_missing">${ico('alert', 14)}
<span><b>还差 <span data-slot="quote_intake_record_missing"></span> 格必填没补</b>
—— <span data-slot="quote_intake_record_missing_keys"></span>。
按右上角「编辑」开表,红框那几格就是要填的;补齐了才提交得了。</span>
</div>`;
/**
* 一张可改的表的**闸** —— 默认锁着,按「编辑」才开(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>') +
gapBar +
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>,先受理、后追问,但那一问不能省。
<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 受理归一 → 交下游'),
});
@@ -0,0 +1,616 @@
/**
* A6 报价议价的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。
* 案例活动没有 `run`,自测不跑回合;测的是清单本身与四处对齐
* (判据谓词 / 出口载荷与 `when` / 剧本声明 / 两把尺的档位)。
*/
import { describe, expect, it } from 'vitest';
import { activityManifestSchema } from '@contracts';
import { evaluateCondition, evaluateUnaryTest } from '@sfeel';
import { createMockHost } from '@sdk';
import { N01_DMN, manifest } from '../src/manifest.ts';
import activity from '../src/index.ts';
describe('清单本身合规', () => {
it('过 @contracts 的 schema', () => {
expect(() => activityManifestSchema.parse(manifest)).not.toThrow();
});
it('监听时限已配 —— 无悬空监听', () => {
expect(manifest.declarations.listeners.length).toBeGreaterThan(0);
expect(manifest.declarations.listeners.every((l) => /^P/.test(l.deadline))).toBe(true);
});
it('L1/L2 判据都给了可机检的谓词 —— 验收等级不得谎报', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef, c.key).toBeDefined();
}
});
it('禁区短而硬,授权边界给了调人预算', () => {
expect(manifest.contract.prohibitions.length).toBeGreaterThan(0);
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(0);
});
it('每件必交付物都有台账地址', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(d.ledger, d.key).toBeDefined();
}
});
/*
* **宽进严出**:起单一个必填都不设 —— 上游(A4 的 draft 口)发的是
* `{rfqRef, customerCode, items}`,而上一版必填这三个,单飞那条路直接被门口拦掉。
*/
it('起单宽进:input.required 是空的,两种来路的键都声明着', () => {
expect(manifest.contract.input.data['required']).toEqual([]);
const properties = manifest.contract.input.data['properties'] as Record<string, unknown>;
for (const key of ['rfqRef', 'customerCode', 'items', 'subject', 'body']) {
expect(properties[key], `入参少了 ${key}`).toBeDefined();
}
});
/*
* **客户表态是必填** —— 这是 A6 的门面判据:件上的缺表态红带、执行卡的提交闸、
* 放行门 L1 三处同源(都数基座的 `_missing`)。声明丢了 required,三处一起失明。
*/
it('客户表态标了 required —— 红带 / 提交闸 / 放行门三处同源的那个数才存在', () => {
const line = manifest.declarations.ledger.find((d) => d.object === 'line');
expect(line?.fields.find((f) => f.name === 'reply_type')?.required).toBe(true);
});
/*
* **客户还价用 `requiredWhen` 钉在还价那一行** —— 表级 required 会让接受 / 拒绝的行
* 永远缺一格,把执行卡的闸拦成死锁(A2 逐颗派单踩过同一坑)。
*/
it('客户还价是行级必填:只有还价那一行才算缺', () => {
const line = manifest.declarations.ledger.find((d) => d.object === 'line');
const counter = line?.fields.find((f) => f.name === 'counter_price');
expect(counter?.requiredWhen).toEqual({ column: 'reply_type', value: 'COUNTER' });
expect(counter?.required, '整表必填会把接受/拒绝的行也拦住').toBeUndefined();
});
/*
* **客户那一侧的三列 AI 一格都不写**(结构治,不是措辞治):
* 一条要模型分辨「这行能不能替客户填」的规矩会走岔;「这几列不写」没有分支可走岔。
*/
it('客户表态 / 还价 / 原话三列写死了「你一格都不写」', () => {
const guide = manifest.assets.businessModel.steps?.find((s) => s.item === 'S1_compose')?.guide ?? '';
expect(guide).toMatch(/reply_type[^\n]*counter_price[^\n]*reply_note|这三列你一格都不写/);
expect(guide).toContain('一格都不写');
});
it('表态、定案、让步处置三列都给了取值域 —— 条数由基座现数,不由活动自报', () => {
const line = manifest.declarations.ledger.find((d) => d.object === 'line');
expect(line?.fields.find((f) => f.name === 'reply_type')?.tally).toEqual(['ACCEPT', 'COUNTER', 'REJECT', 'NOREPLY']);
expect(line?.fields.find((f) => f.name === 'deal_status')?.tally).toEqual(['WON', 'HAGGLE', 'REVISE', 'LOST']);
expect(line?.fields.find((f) => f.name === 'concession')?.tally).toEqual(['ACCEPT_COUNTER', 'NEGOTIATE', 'ESCALATE']);
});
/*
* 我方写的注不标 `expect` —— 标了 `_gaps` 恒大于 0,读它的判据永远绿不了(A1 的账)。
* 客户还价那一格由 `requiredWhen` 管,不再重复标 expect。
*/
it('没有一列滥标 expect —— `_gaps` 不是拿来常亮的', () => {
const line = manifest.declarations.ledger.find((d) => d.object === 'line');
expect((line?.fields ?? []).filter((f) => f.expect === true)).toEqual([]);
});
it('三条常备案例形状真不同:流内一轮成交 / 客户压价 / 单飞超时', () => {
expect(manifest.samples.length).toBe(3);
const [flowIn, haggled, solo] = manifest.samples;
expect((flowIn?.input as { items?: unknown[] }).items?.length).toBeGreaterThan(0);
expect(flowIn?.expect?.ports).toEqual(['won']);
expect(haggled?.expect?.ports).toEqual(['revise']);
expect((solo?.input as { items?: unknown[] }).items).toBeUndefined();
expect(solo?.expect?.ports).toEqual(['lost']);
});
});
describe('案例模型 —— 双表示同版本', () => {
const model = manifest.assets.businessModel;
const cmmn = parseCmmnLite(model.cmmnXml);
it('CMMN 与说明书同在,不允许分叉', () => {
expect(model.cmmnXml).toContain('<casePlanModel');
expect(model.narrative.length).toBeGreaterThan(400);
});
/* 照 MVP 示例工作流 §1.4 的 S1 发报留档 / S2 跟进议价 / S3 定案交接,一步一项 */
it('计划项照 SOP 一步一项:排版 + 发出落回音 + 逐条定案 + 异议拍板 + 放行', () => {
expect(items(model.cmmnXml)).toEqual(['S1_compose', 'S2_send', 'S3_settle', 'T_haggle', 'T_release']);
});
it('三处人的门都标了人工激活:发出并等回音 · 异议拍板 · 结果放行(逐条定案是查表,不标)', () => {
expect(cmmn.manualActivation).toEqual(['S2_send', 'T_haggle', 'T_release']);
});
it('不用阶段 / 里程碑 / 裁量项', () => {
for (const tag of ['<stage', '<milestone', '<planningTable', '<discretionaryItem']) {
expect(model.cmmnXml, `不该出现 ${tag}`).not.toContain(tag);
}
});
/*
* **还价才叫人** —— 这个活动的要害:哨兵读基座按值现数的还价条数,
* 而不是活动自报的 `haggle_total`;且要等回音落齐(客户直接接受或拒绝的单
* 一个人都不必额外惊动)。
*/
it('异议拍板的哨兵读**客户自己填的那一格**,不读模型判出来的定案', () => {
const condition = sentryIf(model.cmmnXml, 's_haggle');
expect(condition).toContain('quote_negotiate_line_reply_type_counter > 0');
expect(condition).toContain('replied_total');
/*
* **「该不该叫人」不能由被叫的那一方说了算**(2026-08-03 两轮实测):
* 哨兵读 deal_status 时,模型在定案那一步直接写 REVISE、HAGGLE 一条不留,
* 这道门整个不开 —— 单子 released 而决策卡 0 张,让步梯度成了摆设。
*/
expect(condition, '哨兵不该读模型判出来的定案').not.toContain('deal_status');
// 客户还过价且回音落过 → 叫人
expect(safeEval(condition, { quote_negotiate_line_reply_type_counter: 1, replied_total: 2 })).toBe(true);
// 没人还价 → 不叫人(这一步整个不入场)
expect(safeEval(condition, { quote_negotiate_line_reply_type_counter: 0, replied_total: 2 })).toBe(false);
// 回音还没落 → 先别叫
expect(safeEval(condition, { quote_negotiate_line_reply_type_counter: 1 })).toBe(false);
// **模型自己把定案写成 REVISE 也绕不过去** —— 客户那一格还在那儿
expect(
safeEval(condition, {
quote_negotiate_line_reply_type_counter: 1,
quote_negotiate_line_deal_status_haggle: 0,
quote_negotiate_line_deal_status_revise: 1,
replied_total: 2,
}),
).toBe(true);
});
/*
* 入场哨兵也归「机检只读基座数」管(A1 第四次违反就藏在哨兵里)——
* 读活动自报的条数,模型不记那个账,门就永不开,而它一步也没做错。
*/
it('交人发出的入场哨兵读基座行数,不读自报的报价条数', () => {
const condition = sentryIf(model.cmmnXml, 's_composed');
expect(condition).toContain('quote_negotiate_line_rows');
expect(condition).not.toContain('quote_total');
expect(safeEval(condition, { line_total: 2, quote_negotiate_line_rows: 2, composed_total: 2 })).toBe(true);
// 行落了但排版那一轮还没走完(composed_total 没写过)→ 门不开
expect(safeEval(condition, { line_total: 2, quote_negotiate_line_rows: 2 })).toBe(false);
});
it('逐条定案的入场哨兵认「回音齐了」:人交回 + 基座数的缺项为 0', () => {
const condition = sentryIf(model.cmmnXml, 's_replied');
expect(condition).toContain('quote_negotiate_line_missing = 0');
expect(safeEval(condition, { replied_total: 2, quote_negotiate_line_rows: 2, quote_negotiate_line_missing: 0 })).toBe(true);
expect(safeEval(condition, { replied_total: 2, quote_negotiate_line_rows: 2, quote_negotiate_line_missing: 1 })).toBe(false);
});
it('放行门哨兵带 ifPart:每条有表态 + 定案守恒 + 待拍板清零 + 定盘记过,全读基座数', () => {
const condition = sentryIf(model.cmmnXml, 's_ready');
expect(condition).toContain('quote_negotiate_line_missing = 0');
expect(condition).toContain('quote_negotiate_line_deal_status_haggle = 0');
const done = {
route_decided_total: 1,
quote_negotiate_line_rows: 3,
quote_negotiate_line_missing: 0,
quote_negotiate_line_deal_status_won: 2,
quote_negotiate_line_deal_status_haggle: 0,
quote_negotiate_line_deal_status_revise: 0,
quote_negotiate_line_deal_status_lost: 1,
};
expect(safeEval(condition, done)).toBe(true);
// 还有一行缺客户表态 → 门不开
expect(safeEval(condition, { ...done, quote_negotiate_line_missing: 1 })).toBe(false);
// 定案没记齐(守恒不成立)→ 门不开
expect(safeEval(condition, { ...done, quote_negotiate_line_deal_status_lost: 0 })).toBe(false);
/*
* **还悬着的还价放不了行**(与 A4 的「拦截档必须清零」同一形状):
* HAGGLE 是过渡态,拍板那一下必须把它换成 WON / REVISE / LOST。
*/
expect(
safeEval(condition, {
...done,
quote_negotiate_line_deal_status_won: 1,
quote_negotiate_line_deal_status_haggle: 1,
quote_negotiate_line_deal_status_lost: 1,
}),
).toBe(false);
// 退回重批是结论,不是悬着 —— 照样放得了行
expect(
safeEval(condition, {
...done,
quote_negotiate_line_deal_status_won: 1,
quote_negotiate_line_deal_status_revise: 1,
quote_negotiate_line_deal_status_lost: 1,
}),
).toBe(true);
});
it('每个计划项都有剧本', () => {
const ids = items(model.cmmnXml);
expect((model.steps ?? []).map((s) => s.item)).toEqual(ids);
for (const step of model.steps ?? []) {
expect(step.guide.length, `${step.item} 的做法`).toBeGreaterThan(80);
expect(step.intent.length, `${step.item} 的意图`).toBeGreaterThan(6);
}
});
it('说明书不点名计划项', () => {
const named = [...model.narrative.matchAll(/\b(?:S\d+_[A-Za-z]+|T_[a-z]+)\b/g)].map((m) => m[0]);
const ids = new Set(items(model.cmmnXml));
for (const name of named) {
expect(ids.has(name), `说明书提到的 ${name} 不在案例模型里`).toBe(true);
}
});
it('说明书只讲整单的事:成品、怎么说话、怎么收口', () => {
for (const topic of ['开案', '要说话', '一拍连做', '结案']) {
expect(model.narrative, topic).toContain(topic);
}
});
/*
* **决策卡那三处写死的东西也进说明书**(2026-08-03 实测逼出来的):
* 当下可为为空那几拍(依赖断点)引擎只说「请调人」,而拍板那一步的 guide
* 根本没摊开 —— 模型手上只剩计划项的中文名与自己的想象:
* 一轮把中文名当 itemId(连拒两拍),一轮换了问法于是同一个岔路口发出去两张卡。
* **说明书每一拍都在提示词里**,写死在这儿才是每一拍都看得见。
*/
it('说明书写死了那一张决策卡的 itemId / ask / 三个 value —— 断点那几拍只剩它', () => {
expect(model.narrative).toContain('"itemId":"T_haggle"');
expect(model.narrative).toContain('按哪条路办');
for (const value of ['accept-counter', 'revise', 'hold']) {
expect(model.narrative, `说明书少了选项 ${value}`).toContain(value);
}
});
});
describe('声明与契约对得上', () => {
const columns = new Set(
manifest.declarations.ledger.flatMap((d) => [`${d.domain}.${d.object}`, ...d.fields.map((f) => f.name)]),
);
it('必交付物的台账地址都声明过', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(columns.has(d.ledger ?? ''), `${d.key} → ${d.ledger}`).toBe(true);
}
});
it('editableAtRelease 的列都在成果表的声明里', () => {
const line = manifest.declarations.ledger.find((d) => d.object === 'line');
const names = new Set((line?.fields ?? []).map((f) => f.name));
for (const d of manifest.contract.requiredDeliverables) {
for (const col of d.editableAtRelease ?? []) {
expect(names.has(col), `editableAtRelease 里的 ${col}`).toBe(true);
}
}
});
const rowReadings = new Set([
...manifest.declarations.ledger.map((d) => `${d.domain}_${d.object}_rows`),
...manifest.declarations.ledger
.filter((d) => d.fields.some((f) => f.required === true || f.requiredWhen !== undefined))
.map((d) => `${d.domain}_${d.object}_missing`),
...manifest.declarations.ledger.flatMap((d) =>
d.fields.flatMap((f) =>
(f.tally ?? []).map((v) => `${d.domain}_${d.object}_${f.name}_${v.toLowerCase().replace(/[^a-z0-9_]/g, '')}`),
),
),
]);
it('L1/L2 谓词读的名字都有承载(台账列或基座读数)', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level !== 'L1' && c.level !== 'L2') continue;
for (const identifier of (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? []) {
if (['and', 'or', 'not', 'true', 'false', 'null', 'in', 'between'].includes(identifier)) continue;
if (rowReadings.has(identifier)) continue;
expect(columns.has(identifier), `${c.key} 的谓词引用了 ${identifier}`).toBe(true);
}
}
});
/*
* **机检必须至少有一边是基座数**(违反史已记五次,每次都是同一个死法:
* 那个数要模型自己重算,漏算/改小一次门就废了)。
*/
it('每条 L1/L2 都至少读一个基座读数 —— 不许两边都由活动自报', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level !== 'L1' && c.level !== 'L2') continue;
const names = (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? [];
expect(names.some((n) => rowReadings.has(n)), `${c.key} 一个基座读数都没读`).toBe(true);
}
});
/*
* **三口的去向是算出来的**:每一口都得写 `when`,少一口基座就退回「人选一口」——
* 而选择组恰选一口是这个活动的全部业务分支(E11/E12/E13 读的就是它)。
*/
it('三个出口对齐流程包 E11/E12/E13,且每一口都带 when', () => {
const group = manifest.contract.output.selective[0];
expect(group?.group).toBe('negotiation');
expect(group?.ports.map((p) => p.name)).toEqual(['revise', 'won', 'lost']);
for (const port of group?.ports ?? []) {
expect(port.when, `${port.name} 少了 when —— 少一口整组就退回人选`).toBeDefined();
expect(port.when).toContain('quote_negotiate_line_deal_status_');
expect(port.when, `${port.name} 的 when 不该读过渡态 HAGGLE`).not.toContain('_haggle');
}
});
it('去向按序求值:还价优先退回重批,其次成交转履约,都没成转归档', () => {
const ports = manifest.contract.output.selective[0]?.ports ?? [];
const pick = (scope: Record<string, unknown>): string | undefined =>
ports.find((p) => safeEval(p.when ?? '', scope))?.name;
expect(pick({ quote_negotiate_line_deal_status_revise: 1, quote_negotiate_line_deal_status_won: 1, quote_negotiate_line_deal_status_lost: 0 })).toBe('revise');
expect(pick({ quote_negotiate_line_deal_status_revise: 0, quote_negotiate_line_deal_status_won: 2, quote_negotiate_line_deal_status_lost: 1 })).toBe('won');
expect(pick({ quote_negotiate_line_deal_status_revise: 0, quote_negotiate_line_deal_status_won: 0, quote_negotiate_line_deal_status_lost: 2 })).toBe('lost');
});
/* 下游 A7 的入参要 rfqRef / customerCode / quoteNo —— 不成交那一口必须带齐 */
it('不成交那一口带齐 A7 要的三个键', () => {
const lost = manifest.contract.output.selective[0]?.ports.find((p) => p.name === 'lost');
const required = (lost?.payload as { required?: string[] }).required ?? [];
for (const key of ['rfqRef', 'customerCode', 'quoteNo']) expect(required, `lost 口少了 ${key}`).toContain(key);
});
it('出口载荷的标量字段都有台账列承载', () => {
const snake = (name: string) => name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
const ports = [
...manifest.contract.output.mandatory,
...manifest.contract.output.selective.flatMap((g) => g.ports),
];
for (const port of ports) {
const properties = (port.payload as { properties?: Record<string, { type?: string }> }).properties ?? {};
for (const [field, spec] of Object.entries(properties)) {
if (spec.type === 'array') continue;
expect(columns.has(snake(field)), `${port.name}.${field} → ${snake(field)}`).toBe(true);
}
}
});
it('技能引用的工具都在白名单声明内 —— 载方法不载权力', () => {
const declared = new Set(manifest.declarations.tools.map((t) => t.name));
for (const skill of manifest.assets.skills) {
for (const fragment of skill.crystallized) {
expect(declared.has(fragment.tool), `${skill.id} 用了 ${fragment.tool}`).toBe(true);
}
}
});
it('L3 判据的整句出现在放行剧本里 —— judge 才对得上名', () => {
const l3 = manifest.contract.acceptanceCriteria.find((c) => c.level === 'L3');
const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
expect(l3).toBeDefined();
expect(release?.guide, 'T_release 的 guide 要整句抄 L3 判据名').toContain(l3?.label ?? '');
});
});
/*
* 两把尺逐行求值 —— 能算的不问 LLM(规范 §5.6)。
* 出参就是台账 `deal_status` / `concession` 的取值:模型算完照抄,不必再翻译一道。
*/
describe('议价结果 N-01 —— 客户表态 → 定案', () => {
const table = N01_DMN.tables.find((t) => t.id === 'n01-outcome');
it('客户接受 → WON 成交', () => {
expect(decideBy(table, { reply_type: 'ACCEPT' })).toMatchObject({ outputs: ['WON'] });
});
it('客户还价 → HAGGLE 压在还价上,要人拍板', () => {
expect(decideBy(table, { reply_type: 'COUNTER' })).toMatchObject({ rule: 'n01-2', outputs: ['HAGGLE'] });
});
it('客户拒绝与超时无回复都 → LOST(两条路一个结果,缘由记在备注)', () => {
expect(decideBy(table, { reply_type: 'REJECT' })).toMatchObject({ outputs: ['LOST'] });
expect(decideBy(table, { reply_type: 'NOREPLY' })).toMatchObject({ outputs: ['LOST'] });
});
it('**认不出的表态按不成交处置** —— 兜底不许猜成接受', () => {
expect(decideBy(table, { reply_type: '客户说再看看' })).toMatchObject({ rule: 'n01-default', outputs: ['LOST'] });
});
/*
* 这把尺只出三个码:**REVISE 不在其中** —— 「退回重批」是人在决策卡上拍的,
* 判定表算不出来。所以是子集关系,不是等号;而多出来的那一个恰好只有一个,
* 就是拍板那条路(测这一条,免得哪天有人往 tally 里塞一个没人写得出的码)。
*/
it('尺出的定案码都在台账取值域里,只有「退回重批」由人拍板补上', () => {
const tally = manifest.declarations.ledger
.find((d) => d.object === 'line')
?.fields.find((f) => f.name === 'deal_status')?.tally ?? [];
const fromTable = new Set(table?.rules.map((r) => String(r.then[0])));
for (const code of fromTable) expect(tally, `尺出了台账没有的码 ${code}`).toContain(code);
expect([...tally].filter((v) => !fromTable.has(v))).toEqual(['REVISE']);
});
});
describe('让步梯度 N-02 —— 还价幅度 → 让不让、谁来让', () => {
const table = N01_DMN.tables.find((t) => t.id === 'n02-concession');
it('三个点以内 → 接了拿单', () => {
expect(decideBy(table, { gap: 0.02 })).toMatchObject({ outputs: ['ACCEPT_COUNTER', true] });
});
it('三到十个点 → 授权内回旋(FIRST 命中,第二行就是这一档)', () => {
expect(decideBy(table, { gap: 0.08 })).toMatchObject({ rule: 'n02-2', outputs: ['NEGOTIATE', true] });
});
it('超过一成 → 超出授权,退回成本定价重批', () => {
expect(decideBy(table, { gap: 0.204 })).toMatchObject({ outputs: ['ESCALATE', false] });
});
it('三个处置码与台账取值域逐字一致', () => {
const tally = manifest.declarations.ledger
.find((d) => d.object === 'line')
?.fields.find((f) => f.name === 'concession')?.tally;
expect(new Set(table?.rules.map((r) => String(r.then[0])))).toEqual(new Set(tally));
});
});
describe('两把尺的档位三处同一份 —— 判定表 / 剧本 / 工作单', () => {
const settle = manifest.assets.businessModel.steps?.find((s) => s.item === 'S3_settle')?.guide ?? '';
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-worksheet')?.html ?? '';
it('四个表态码剧本与工作单都写着(大写,一个字母不许差)', () => {
for (const code of ['ACCEPT', 'COUNTER', 'REJECT', 'NOREPLY']) {
expect(settle, `剧本少了 ${code}`).toContain(code);
expect(sheet, `工作单少了 ${code}`).toContain(code);
}
});
it('三个定案码与三个让步处置码剧本里写死', () => {
for (const code of ['WON', 'HAGGLE', 'REVISE', 'LOST', 'ACCEPT_COUNTER', 'NEGOTIATE', 'ESCALATE']) {
expect(settle, `剧本少了 ${code}`).toContain(code);
}
});
it('让步梯度的三档门槛剧本与工作单说的是同一组数', () => {
for (const threshold of ['0.03', '0.10']) expect(settle, `剧本少了 ${threshold}`).toContain(threshold);
for (const band of ['≤ 3%', '3% ~ 10%', '10%']) expect(sheet, `工作单少了 ${band}`).toContain(band);
});
});
describe('报告模版 —— 活动持有,容器无脚本执行权', () => {
it('两个局面各一张:议价工作单(干活)与成交定案清单(收口)', () => {
expect(manifest.assets.viewTemplates.map((t) => t.name)).toEqual(['negotiate-worksheet', 'negotiate-roster']);
});
it('剧本每一步的 view 都在模版名单里 —— 换不到画面屏上就不动', () => {
const names = new Set(manifest.assets.viewTemplates.map((t) => t.name));
for (const step of manifest.assets.businessModel.steps ?? []) {
expect(names.has(step.view ?? ''), `${step.item} 的 view=${step.view}`).toBe(true);
}
});
it('放行那一步摆的是验收目标件 —— 最后摆上去的那张就是归档件', () => {
const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
expect(release?.view).toBe('negotiate-roster');
});
it('模版里没有脚本、没有事件处理器、没有外链 —— 样式自带', () => {
for (const template of manifest.assets.viewTemplates) {
expect(template.html, template.name).not.toMatch(/<script|\son[a-z]+=|https?:\/\//i);
expect(template.html, template.name).toContain('<style>');
}
});
it('议价工作单是干活的件:逐颗档案 + 两把尺 + 填回工作表', () => {
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-worksheet')?.html ?? '';
expect(sheet).toContain('jc-selcard');
expect(sheet).toContain('议价结果 N-01');
expect(sheet).toContain('让步梯度 N-02');
expect(sheet).toContain('data-edit="quote_negotiate.line"');
});
it('缺表态红带与提交闸同源 —— 件上摆的是基座的 `_missing` 与主键名单', () => {
for (const name of ['negotiate-worksheet', 'negotiate-roster']) {
const html = manifest.assets.viewTemplates.find((t) => t.name === name)?.html ?? '';
expect(html, name).toContain('quote_negotiate_line_missing');
expect(html, name).toContain('quote_negotiate_line_missing_keys');
}
});
/*
* 编辑面精确等于人在那个局面的职权面 + **行的钥匙 pn**:
* 主键格不挂 `data-col`,服务端按「全表一致列」补键,两行会对到同一行上
* (A2 浏览器走查逮过,e2e 走 API 自带主键测不出来)。
*
* 两张件的编辑面**故意不同**:干活那张收客户的话,收口那张只让改成交价与原话
* (= `editableAtRelease`)—— 不同工作各有各的件。
*/
it('工作单的编辑面 = 客户表态 + 客户还价 + 客户原话 + 行的钥匙 pn', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-worksheet')?.html ?? '';
const cols = [...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]);
expect(new Set(cols)).toEqual(new Set(['pn', 'reply_type', 'counter_price', 'reply_note']));
});
it('收口件的编辑面 = editableAtRelease 那两列 + 行的钥匙 pn', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-roster')?.html ?? '';
const cols = new Set([...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]));
const editable = manifest.contract.requiredDeliverables[0]?.editableAtRelease ?? [];
expect(cols).toEqual(new Set(['pn', ...editable]));
});
it('定案分布读的是基座按值现数的三段 —— 守恒等式的可视形', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-roster')?.html ?? '';
for (const code of ['won', 'haggle', 'revise', 'lost']) {
expect(roster).toContain(`quote_negotiate_line_deal_status_${code}`);
}
});
/* 三口活动:收口件摆去向判定,由基座在放行那一刻求值后投影(件不算不判) */
it('收口件摆落边图,去向由 case:route* 投影', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-roster')?.html ?? '';
expect(roster).toContain('data-class-of="case:routeKey"');
expect(roster).toContain('case:routeTo');
expect(roster).toContain('case:routeWhy');
});
it('模版只做取值 —— data-class-of 不以 data-slot 为前提(纯色块曾整条静默失色)', () => {
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-worksheet')?.html ?? '';
expect(sheet).toContain('<i data-class-of="deal_status"></i>');
});
it('宿主拒推含脚本的模版', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
});
});
const CONTRACT = {
activityId: 'quote-negotiate',
version: '1.0.0',
data: { rfqRef: 'RFQ MD14.1', items: [] },
message: undefined,
outputPorts: { mandatory: [], selective: ['revise', 'won', 'lost'] },
prohibitions: [],
discretion: [],
} as const;
describe('代码入口 —— 案例活动没有 run,不是残包', () => {
it('默认导出是一个有名字的活动定义', () => {
expect(activity.name).toBe('quote-negotiate');
});
it('mock 宿主如实报「没有 run」,不静默返回一个假的 outcome', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.run(activity)).rejects.toThrow(/没有 run/);
});
});
/** 按 DMN 语义逐行求值:FIRST 命中即定,单元格谓词复用 @sfeel */
function decideBy(
table: (typeof N01_DMN)['tables'][number] | undefined,
scope: Record<string, unknown>,
): { outputs: readonly unknown[]; rule: string } | undefined {
if (table === undefined) return undefined;
for (const rule of table.rules) {
const hit = table.inputs.every((input, i) => evaluateUnaryTest(rule.when[i] ?? '-', scope[input.expression], scope));
if (hit) return { outputs: rule.then, rule: rule.id };
}
return undefined;
}
function items(xml: string): string[] {
return [...xml.matchAll(/<(?:humanTask|decisionTask|processTask|caseTask) id="([^"]+)"/g)].map((m) => m[1] ?? '');
}
function parseCmmnLite(xml: string): { manualActivation: string[]; repetition: string[] } {
const manualActivation: string[] = [];
const repetition: string[] = [];
for (const m of xml.matchAll(/<humanTask id="([^"]+)"[\s\S]*?<\/humanTask>/g)) {
if (m[0].includes('manualActivationRule')) manualActivation.push(m[1] ?? '');
if (m[0].includes('repetitionRule')) repetition.push(m[1] ?? '');
}
return { manualActivation, repetition };
}
function sentryIf(xml: string, sentryId: string): string {
const block = xml.slice(xml.indexOf(`<sentry id="${sentryId}"`));
const raw = /<condition>([\s\S]*?)<\/condition>/.exec(block.slice(0, block.indexOf('</sentry>')))?.[1] ?? '';
return raw.replaceAll('&gt;', '>').replaceAll('&lt;', '<').replaceAll('&amp;', '&');
}
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
@@ -0,0 +1,315 @@
/**
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(A1 的账)。
*/
import { describe, expect, it } from 'vitest';
import { evaluateCondition } from '@sfeel';
import { CMMN_XML, NEGOTIATE_PHASES, NEGOTIATE_STEPS } from '../src/case-model.ts';
import { manifest } from '../src/manifest.ts';
const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map(
(m) => m[1] ?? '',
);
describe('剧本与案例模型对得上 —— 对不上的条目等于没写', () => {
it('每一条剧本都指向一个真实存在的计划项', () => {
for (const step of NEGOTIATE_STEPS) {
expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
}
});
it('图上每一步都有剧本', () => {
for (const id of ITEM_IDS) {
expect(NEGOTIATE_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
}
});
it('清单里挂的就是这份剧本(不是另抄一份)', () => {
expect(manifest.assets.businessModel.steps).toBe(NEGOTIATE_STEPS);
});
});
describe('doneWhen 是「成果已存在」的判据,不是「该不该做」的判据', () => {
/** 开案那一刻的两种局面:流内转来 / 单飞 —— 台账一行没有 */
const FRESH_FLOW: Record<string, unknown> = {
rfqRef: 'RFQ MD14.1',
customerCode: 'CU-0142',
items: [{ pn: 'BBPY00201209601Y00', quoted_price: 0.085 }],
};
const FRESH_SOLO: Record<string, unknown> = { subject: 'MD14.2 报价函发客户', body: 'XCJ-7788T 8K 批准价 14.80……' };
it('**开局一步都不该被自动收掉** —— 两种开局都要 false', () => {
for (const fresh of [FRESH_FLOW, FRESH_SOLO]) {
for (const step of NEGOTIATE_STEPS) {
if (step.doneWhen === undefined) continue;
expect(safeEval(step.doneWhen, fresh), `${step.item} 的 doneWhen「${step.doneWhen}」开局就为真`).toBe(false);
}
}
});
/* ── 排版那一步 ─────────────────────────────────────────────── */
const composed = (over: Record<string, unknown>): Record<string, unknown> => ({
line_total: 2,
quote_negotiate_line_rows: 2,
composed_total: 2,
...over,
});
it('整份落表、行数对上、排版那一轮走完 → 收得掉', () => {
expect(safeEval(doneOf('S1_compose'), composed({}))).toBe(true);
});
it('**第一拍写完行就收步是不许的** —— composed_total 没写过,未绑定的名字比大小为假', () => {
const scope = composed({});
delete scope['composed_total'];
expect(safeEval(doneOf('S1_compose'), scope)).toBe(false);
});
it('行数两边对不上 → 收不掉(漏落一行当场现形,不等到第 10 拍)', () => {
expect(safeEval(doneOf('S1_compose'), composed({ quote_negotiate_line_rows: 1 }))).toBe(false);
});
/* ── 三处不给 doneWhen ──────────────────────────────────────── */
it('**发出等回音、异议拍板、放行三项都不给 doneWhen** —— 给了等于给引擎一把替人签字的钥匙', () => {
for (const item of ['S2_send', 'T_haggle', 'T_release']) {
expect(NEGOTIATE_STEPS.find((s) => s.item === item)?.doneWhen, `${item} 不该有 doneWhen`).toBeUndefined();
}
});
/* ── 逐条定案:判完了才算办完(收步判据与验收 L2 同源)────────────── */
const settled = (over: Record<string, unknown>): Record<string, unknown> => ({
quote_negotiate_line_rows: 3,
quote_negotiate_line_deal_status_won: 2,
quote_negotiate_line_deal_status_haggle: 0,
quote_negotiate_line_deal_status_revise: 0,
quote_negotiate_line_deal_status_lost: 1,
quote_negotiate_line_reply_type_counter: 0,
...over,
});
it('四档定案的条数加起来等于行数 → 逐条定案收得掉', () => {
expect(safeEval(doneOf('S3_settle'), settled({}))).toBe(true);
});
it('**有待拍板条目也照样收得掉** —— 判出来了就是判完了,还价的由下一步叫人拍板', () => {
expect(
safeEval(
doneOf('S3_settle'),
settled({
quote_negotiate_line_deal_status_lost: 0,
quote_negotiate_line_deal_status_haggle: 1,
quote_negotiate_line_reply_type_counter: 1,
}),
),
).toBe(true);
});
/*
* **模型自己写 REVISE 绕过拍板 → 这一步收不掉**(2026-08-03 实测逼出来的):
* 一轮里模型在定案那一步直接把还价的行判成「退回重批」,HAGGLE 一条没有,
* 「还价才叫人」那道门整个不开,单子一路 released 而没有人拍过板 —— 决策卡 0 张。
* 收步判据补上「待拍板条数 = 客户还价条数」,两边都是基座现数,模型改不动。
*/
it('客户还了价却没落成待拍板(自己写了 REVISE)→ 收不掉,拍板绕不过去', () => {
expect(
safeEval(
doneOf('S3_settle'),
settled({
quote_negotiate_line_deal_status_won: 1,
quote_negotiate_line_deal_status_haggle: 0,
quote_negotiate_line_deal_status_revise: 1,
quote_negotiate_line_deal_status_lost: 1,
quote_negotiate_line_reply_type_counter: 1,
}),
),
).toBe(false);
});
it('还有行没判 → 收不掉(松出来的那一截就是放行门前的返工)', () => {
expect(safeEval(doneOf('S3_settle'), settled({ quote_negotiate_line_deal_status_lost: 0 }))).toBe(false);
});
it('**一行都还没写时 0+0+0+0 = 0 不算判完** —— 全零相等那一坑,靠 rows > 0 挡住', () => {
expect(
safeEval(doneOf('S3_settle'), {
quote_negotiate_line_reply_type_counter: 0,
quote_negotiate_line_rows: 0,
quote_negotiate_line_deal_status_won: 0,
quote_negotiate_line_deal_status_haggle: 0,
quote_negotiate_line_deal_status_revise: 0,
quote_negotiate_line_deal_status_lost: 0,
}),
).toBe(false);
});
it('归一那一步声明了 reads: items —— 上游载荷置前不截断', () => {
expect(NEGOTIATE_STEPS.find((s) => s.item === 'S1_compose')?.reads ?? []).toContain('items');
});
/*
* **一单同一张卡只发一次**,且缺信息不再走补全卡(2026-08-02 全局退役)——
* 剧本里不许残留「还缺就再发一张卡」这条已经不存在的路。
*/
it('剧本不教补全卡,也不留「还缺就再发一张」的死路', () => {
for (const step of NEGOTIATE_STEPS) {
expect(step.guide, `${step.item} 的 guide 提到了补全卡`).not.toMatch(/补全卡|"kind":"ask"/);
expect(step.guide, `${step.item} 的 guide 教了再发一张卡`).not.toMatch(/再发一张卡|重新发一张卡/);
}
});
/*
* **一单只发一张卡**:人点完之后这一步会被重新念一遍,而 guide 的头一段说什么,
* 模型就先做什么 —— 头一段若是「发一张决策卡」,它就再发一张(A4 实测连发四张,
* 调人预算烧光 exhausted)。所以拍板那一步的 guide 必须**先分岔再发卡**。
*/
it('拍板那一步先问「人点过没有」,再谈发卡 —— 分岔要排在发卡骨架前面', () => {
const guide = NEGOTIATE_STEPS.find((s) => s.item === 'T_haggle')?.guide ?? '';
const branch = guide.indexOf('找到了');
const skeleton = guide.indexOf('【发一张决策卡');
expect(branch, 'T_haggle 的 guide 没有「人点过没有」这一岔').toBeGreaterThanOrEqual(0);
expect(skeleton, 'T_haggle 的 guide 没有发卡骨架').toBeGreaterThan(branch);
expect(guide).toMatch(/不许再发|别再发第二张卡/);
});
/*
* **拍板那三条路都得动 deal_status** —— 这是 2026-08-03 逼出来的结构账:
* 上一版 revise 那条写着「定案不动」,人点完了 haggle 还是那个数,
* 入场哨兵照旧成立,模型读同一段做法就再发一张卡(实测同一张连发六次)。
* 「办完了」得在数上看得见,措辞治不了。
*/
it('拍板三条路各把 deal_status 改成一个结论码 —— 没有一条留在 HAGGLE', () => {
const guide = NEGOTIATE_STEPS.find((s) => s.item === 'T_haggle')?.guide ?? '';
for (const code of ['WON', 'REVISE', 'LOST']) {
expect(guide, `拍板剧本少了 ${code} 这条路`).toContain(`deal_status 改 **${code}**`);
}
expect(guide, '拍板剧本还留着「定案不动」这条自我循环的路').not.toContain('定案不动');
expect(guide).toContain('quote_negotiate_line_deal_status_haggle');
});
/*
* start 墙已拆(2026-08-02):动它就算启动。剧本里再教 start 只是白烧 token,
* 而且学不稳的礼仪就是死法。
*/
it('剧本不教 start 礼仪 —— 那道墙已经拆了(说「不必单发 start」可以,教它先按一下不行)', () => {
for (const step of NEGOTIATE_STEPS) {
expect(step.guide, `${step.item} 的 guide 还在教 start`).not.toMatch(/先\s*start|务必\s*start|要先发\s*start/);
}
});
/*
* **等回音不落 `listen`**(见 case-model 头注):`listen` 在引擎里恒挂起,
* 要外部事件才唤得醒,而这一单里回音的落单人本来就是 BD。
* 剧本里若冒出一句「listen 等回音」,模型照做就是把整单挂到时限到期。
*/
it('定案那一步写死「这一拍到此为止」—— 收掉的步上再挂行动一律被拒', () => {
const guide = NEGOTIATE_STEPS.find((s) => s.item === 'S3_settle')?.guide ?? '';
expect(guide, 'S3_settle 没写「这一拍到此为止」').toContain('这一拍到此为止');
expect(guide, 'S3_settle 没说清决策卡属于下一步').toContain('T_haggle');
expect(guide, 'S3_settle 没写死「REVISE 这一步不写」').toMatch(/REVISE[^\n]*这一步一格都不写/);
});
/*
* 【目标缺口】那一段摆的是计划项的**中文名**;当下可为为空(依赖断点)那几拍,
* 模型手上只剩中文名,于是把它当 itemId 写了进去 —— 「计划项不存在:异议拍板」
* 连拒两拍(2026-08-03 实测)。发卡骨架旁边必须点名说清写英文 id。
*/
it('发卡骨架写死了 itemId 是英文项号,并点名中文名不是 id', () => {
const guide = NEGOTIATE_STEPS.find((s) => s.item === 'T_haggle')?.guide ?? '';
expect(guide).toContain('"itemId":"T_haggle"');
expect(guide, '没告诉模型中文名不能当 itemId').toMatch(/别写中文名/);
for (const value of ['accept-counter', 'revise', 'hold']) {
expect(guide, `发卡骨架少了选项 ${value}`).toContain(value);
}
});
it('剧本不教 listen 等回音 —— 那是一条挂到天荒地老的路', () => {
for (const step of NEGOTIATE_STEPS) {
expect(step.guide, `${step.item} 的 guide 教了 listen`).not.toMatch(/"kind":"listen"|立监听|发一个\s*listen/);
}
});
});
describe('剧本声明的东西活动真有', () => {
const declaredLedger = manifest.declarations.ledger.map((l) => `${l.domain}.${l.object}`);
const declaredTools = manifest.declarations.tools.map((t) => t.name);
it('每一步要写的台账都在清单的台账声明里', () => {
for (const step of NEGOTIATE_STEPS) {
for (const address of step.writes) {
expect(declaredLedger, `${step.item} 要写 ${address}`).toContain(address);
}
}
});
it('每一步要调的工具都在清单的工具声明里', () => {
for (const step of NEGOTIATE_STEPS) {
for (const tool of step.tools) {
expect(declaredTools, `${step.item} 要调 ${tool}`).toContain(tool);
}
}
});
it('每一步都定死了摆哪张件,且模版真在包里', () => {
const names = new Set(manifest.assets.viewTemplates.map((t) => t.name));
for (const step of NEGOTIATE_STEPS) {
expect(step.view, `${step.item} 没定画面`).toBeDefined();
expect(names.has(step.view ?? ''), `${step.item} 的 view「${step.view}」不在模版名单里`).toBe(true);
}
});
it('每一步都说得出它交出什么', () => {
for (const step of NEGOTIATE_STEPS) {
expect(step.intent.trim(), `${step.item} 没写 intent`).not.toBe('');
expect(step.guide.trim().length, `${step.item} 的 guide 太短`).toBeGreaterThan(20);
}
});
/* 卡面是纯文本:星号记号不会变粗体,只会原样摆在人眼前(规范 §5.5-10) */
it('发卡骨架里不写星号记号 —— 卡面是纯文本', () => {
for (const step of NEGOTIATE_STEPS) {
for (const line of step.guide.split('\n')) {
if (!/"steps"|"standard"|"ask"|"label"|"consequence"/.test(line)) continue;
expect(line.includes('**'), `${step.item} 的卡面文案带了星号:${line.slice(0, 60)}`).toBe(false);
}
}
});
});
describe('阶段刻度:每一项都有格可待', () => {
const inPhase = new Set(NEGOTIATE_PHASES.flatMap((p) => p.items));
it('**除放行外每一项恰好属于一个阶段**', () => {
const planned = NEGOTIATE_STEPS.map((s) => s.item).filter((id) => id !== 'T_release');
expect(planned.filter((id) => !inPhase.has(id))).toEqual([]);
});
it('一项不进两个阶段', () => {
const all = NEGOTIATE_PHASES.flatMap((p) => p.items);
expect(all.length).toBe(new Set(all).size);
});
it('**放行不在名单里** —— 收口那一格由基座统一加', () => {
expect(inPhase.has('T_release')).toBe(false);
});
it('阶段里不许出现案例模型没有的项', () => {
const ids = new Set(NEGOTIATE_STEPS.map((s) => s.item));
expect([...inPhase].filter((id) => !ids.has(id))).toEqual([]);
});
});
function doneOf(item: string): string {
const found = NEGOTIATE_STEPS.find((s) => s.item === item)?.doneWhen;
if (found === undefined) throw new Error(`${item} 没有 doneWhen`);
return found;
}
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
+417 -53
View File
@@ -1,77 +1,441 @@
/**
* A6 报价议价的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
*
* 2026-07-29 由步序声明(flow)改写而来。发出报价是**触达外界**的一下,
* 恒需人工激活(AM 4.1 原理一);客户回音是外部事件,靠 userEventListener 等,
* 不靠轮询。三个去向(成交 / 有异议 / 拒绝)由验收放行时的选择组给出。
* 业务出处:`docs/MVP示例工作流.html` §1.4(S1 发报留档 → S2 跟进议价 → S3 定案交接),
* 按 A4 同一条形状建模(2026-08-03 重写 @3.0.0):
*
* 上游(A5 批准的报价草案,A4 `draft` 口的载荷 `{rfqRef, customerCode, items}`)进来 →
* 归一成**正式报价函**底表(逐条条款齐备 + 按 P-04 注明有效期)→ **一张执行单**
* 交 BD:把报价函发给客户,回音回来后逐条落单(客户表态那一列 AI 一格都不写)→
* 按议价结果 N-01 逐条定案(接受 / 还价 / 不成交)→ **只有还价那几条才叫人拍板**
* (决策卡:照客户价成交 / 退回定价重批 / 守住现价)→ 放行,去向由三口的谓词算出来。
*
* 上一版(@2.1.0)四处不合现行口径,这一版整个置换:
* ① 入参必填 `rfqRef + customerCode + items` —— 而宽进严出是全线口径,归一在活动内做;
* ② 三条 L1/L2 判据读的是活动自报的 `missing_field_total` / `validity_days` /
* `followup_total` —— 「机检只读基座数得出来的数」违反史第六类;
* ③ 台账一个 `required` 都没有 —— 执行卡的闸没牙,人不填也能交卷;
* ④ 三个出口没有 `when`:往哪走要模型自己选,而**去向该是算出来的**。
*
* 【为什么没有 `listen` 动作】等客户回音这一段落在**执行工单**上,不落 `listen`:
* `listen` 在引擎里恒挂起(status: 'awaiting'),要外部事件才唤得醒;而这一单里
* 「客户回了什么」的落单人本来就是 BD(他从自己的邮箱收信,再把结论录进系统)——
* MVP 示例工作流 §1.4 的 S1「生成发出」与 S2「记录反馈」写的也都是**人工任务**。
* 时限那一侧照旧硬约束:`declarations.listeners` 三条静态监听(D+3 催 / D+7 到期 /
* 客户回音)由基座在开案那一刻注册,时限必配 —— 「无来源又无时限的等待」一条都没有。
*/
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
<case id="negotiate">
<!--
案卷项的 id **就是任务视图里的键名**。上游载荷(items)与本活动台账列都摊平到
任务视图顶层,照它们命名;quote_negotiate_* 那几个是**基座读数**
(行数 / 缺必填 / 按值现数),由基座现算,模型碰不着。
-->
<caseFileModel>
<caseFileItem id="draft" name="报价草案"/>
<caseFileItem id="quote" name="正式报价单"/>
<caseFileItem id="reply" name="客户回音"/>
<caseFileItem id="items" name="上游报价草案条目(已批准,流内开案即有)"/>
<caseFileItem id="line_total" name="报价函条数(自报)"/>
<caseFileItem id="composed_total" name="已排版落表的条数(排版走完的凭据)"/>
<caseFileItem id="replied_total" name="已落客户回音的条数(人环走完的凭据)"/>
<caseFileItem id="route_decided_total" name="已定案"/>
<caseFileItem id="quote_negotiate_line_rows" name="报价函行数(基座现数)"/>
<caseFileItem id="quote_negotiate_line_missing" name="缺客户表态的行数(基座现数)"/>
<caseFileItem id="quote_negotiate_line_deal_status_haggle" name="还价了但还没拍板的条数(基座现数)"/>
<caseFileItem id="quote_negotiate_line_reply_type_counter" name="客户还了价的条数(基座现数 —— 客户自己那一格)"/>
</caseFileModel>
<casePlanModel id="plan" name="报价议价" autoComplete="true">
<sentry id="s_draft">
<onPart id="op_df" caseFileItemRef="draft"><standardEvent>create</standardEvent></onPart>
<!--
① 排版完了 —— 交 BD 发出那一步的入场前提。
行数两边对上(自报 = 基座现数),且排版那一轮走完(composed_total 是排完之后
显式写下的数;「大于等于 0」对「写过的 0」为真、对「没写过」为假)。
-->
<sentry id="s_composed">
<onPart id="op_cp" caseFileItemRef="composed_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_cp"><condition>line_total &gt; 0 and quote_negotiate_line_rows = line_total and composed_total &gt;= 0</condition></ifPart>
</sentry>
<sentry id="s_quote_ready">
<onPart id="op_qr" caseFileItemRef="quote"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_qr"><condition>missingQuoteFields = 0</condition></ifPart>
<!--
② 回音齐了 —— 逐条定案那一步的入场前提。
replied_total 只在人交回执行单之后才产生,它就是「经过人了」的凭据;
缺表态的行数由基座按 required 现数(与执行卡的提交闸、放行门 L1 同一个数)。
-->
<sentry id="s_replied">
<onPart id="op_rp" caseFileItemRef="replied_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_rp"><condition>replied_total &gt; 0 and quote_negotiate_line_rows &gt; 0 and quote_negotiate_line_missing = 0</condition></ifPart>
</sentry>
<sentry id="s_sent">
<onPart id="op_st" caseFileItemRef="quote"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_st"><condition>sent = true</condition></ifPart>
<!--
③ **还价才叫人** —— 这个活动的要害(与 A4「超限才叫人」同一形状)。
它读的是**客户自己那一格**(reply_type = COUNTER 的条数,基座按取值域现数),
不是模型判出来的 deal_status。两版都栽过,病根是同一个 ——
**「该不该叫人」不能由被叫的那一方说了算**:
· 一版读 deal_status = HAGGLE,而 revise 那条路让「定案不动」,人点完了那个数
还在,哨兵照旧成立,模型读同一段做法就再发一张卡(同一张连发六次);
· 修成「拍板必须清 HAGGLE」之后,模型改在定案那一步**直接写 REVISE** ——
HAGGLE 一条没有,这道门整个不开,单子一路 released 而没人拍过板
(实测:决策卡 0 张,让步梯度成了摆设)。
现在钉在客户填的那一格上:客户还了价,这一步就必须入场,模型绕不过去。
-->
<sentry id="s_haggle">
<onPart id="op_hg" caseFileItemRef="quote_negotiate_line_reply_type_counter"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_hg"><condition>quote_negotiate_line_reply_type_counter &gt; 0 and replied_total &gt; 0</condition></ifPart>
</sentry>
<!--
── 放行门的入场条件:**认「办完了没有」,且读基座数**────────────────
· 每条都有客户表态(quote_negotiate_line_missing 由基座按 required 现数);
· 定案逐条落定且守恒(四档相加 = 行数,两边都是基座按值现数);
· **没有还压在 HAGGLE 上的条目**(还价的要么接、要么退回重批、要么守价不让);
· 定案过了(route_decided_total 是定案那一步显式写下的记账数)。
-->
<sentry id="s_ready">
<onPart id="op_rd" caseFileItemRef="route_decided_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_rd"><condition>route_decided_total &gt; 0 and quote_negotiate_line_rows &gt; 0 and quote_negotiate_line_missing = 0 and quote_negotiate_line_deal_status_haggle = 0 and quote_negotiate_line_deal_status_won + quote_negotiate_line_deal_status_haggle + quote_negotiate_line_deal_status_revise + quote_negotiate_line_deal_status_lost = quote_negotiate_line_rows</condition></ifPart>
</sentry>
<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>
<!-- ① 归一报价函:逐条条款齐备 + 按 P-04 注明有效期(能算的不问人) -->
<humanTask id="S1_compose" name="落正式报价函:逐条条款齐备,按报价有效期注明到期日">
<itemControl><repetitionRule id="r_s1"/></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="客户回音"/>
<!--
② 发出去并把回音落回来 —— **一张执行单**(触达客户的那一下由人做)。
manualActivation + 不给 doneWhen(发卡触达真人,引擎不替人按)。
-->
<humanTask id="S2_send" name="发出报价函并落客户回音:工作单上逐条落表态">
<entryCriterion id="ec_s2" sentryRef="s_composed"/>
<itemControl><manualActivationRule id="m_s2"/></itemControl>
</humanTask>
<!--
③ 逐条定案 —— SOP 里的 S3,一件独立的事:按客户表态判成交 / 还价 / 不成交,
还价的再按让步梯度 N-02 给出处置建议。它不触达世界,所以给 doneWhen,
引擎自己收 —— 从前这一段藏在「等回音」的收尾里,模型写完 replied_total
就把那一项收了,定案一格没写,放行门当场判「定案没落定」而拒回只会说
「数不对/行不对」——两扇打不开的门(A4 实测 8 次拒回熔断)。
-->
<humanTask id="S3_settle" name="逐条定案:按客户表态判成交/待拍板/不成交,还价的给让步处置">
<entryCriterion id="ec_s3" sentryRef="s_replied"/>
<itemControl><repetitionRule id="r_s3"/></itemControl>
</humanTask>
<!-- ④ 异议拍板:让价触达公司利益的那一下,必须人工激活;只在还有还价条目时入场 -->
<humanTask id="T_haggle" name="异议拍板:客户还的价接不接,要人定">
<entryCriterion id="ec_hg" sentryRef="s_haggle"/>
<itemControl><manualActivationRule id="m_hg"/><repetitionRule id="r_hg"/></itemControl>
</humanTask>
<!-- 议价结果放行:终局唯放行 —— 触达世界,必须人工激活 -->
<humanTask id="T_release" name="议价结果验收放行">
<entryCriterion id="ec_rel" sentryRef="s_ready"/>
<itemControl><manualActivationRule id="m_rl"/></itemControl>
</humanTask>
</casePlanModel>
</case>
</definitions>`;
export const NARRATIVE = `§0 开案:拿到报价草案即开案。成品是**一份发出去的正式报价单**,外加一个明确的结果。
/**
* 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本,两处不重复写同一件事)。
*/
export const NARRATIVE = `§0 开案:**一份已批准的报价要发出去,并谈出一个结果**。来路两种,形状都收(宽进):
· **流内转来**:任务数据里有 \`items\` 数组 —— A4 的报价草案条目(每条带拟报单价与门禁处置),
A5 已经批过;这是最全的一种来路;
· **单飞**:只有一段需求文字,说了要给谁发报价、几颗料什么价。
S1 成单发出:把草案写成正式报价单(中英双语 PDF)—— 单价、交期、SPQ、MOQ、贸易条款、
有效期六样缺一不可,有效期按 P-04 注明。**发出这一下必须人工按**:它触达客户,
系统不替人做这个决定。写类动作先登记意向,验收放行的那一刻才真发。
**成品只有一样**:一份**正式报价函** —— 整份条目都在、**每一条都有客户表态**,
而且每一条都定了案:成交 / 退回重批 / 不成交。人签的不是「发出去了」,
是**这一单谈成了什么**。它决定整条流往哪走。
(还价那一档 HAGGLE 是过渡态:**客户还了价、还没拍板**。放行前它必须清零 ——
拍板那一下把它换成成交、退回重批或不成交,三条路都是结论,都算办完。)
S2 跟进议价:没有跟进的报价等于没报 —— D+3、D+7 两记提醒自动到点,人去问。
客户提异议就议价调整(裁量项:改价、改交期、换料都可能),改完是新一版报价单,重新走发出。
**能算的不问人,该客户说的不代说**:议价结果 N-01(表态 → 定案)、让步梯度 N-02
(还价幅度 → 处置建议),两支判定表说了算,你不临场拍;而**客户表态那一列是人落的,
你一格都不写** —— 客户回了什么只有收信的人知道,系统不替客户表态。
§Z 结案:验收时**恰选一口**给出去向 —— 成交 / 有异议 / 拒绝或超时。
选哪一口就是这一单的结论,下游网关读的就是它;不选口不许放行,含糊的结果比没结果更坏。`;
**四步走**:归一排版报价函 → 交 BD 发出并落回音 → 逐条定案 → 拍板定案放行。
**这一单要经过人一到两次**:BD 在议价工作单上逐条落客户表态(那张单绑着整份报价函,
表态缺一条按不下提交 —— 基座的闸看着,他填的格子直接落账);
**只有客户还了价的那几条才另外叫人拍板**(一张决策卡)。每条都叫人,人就成了橡皮图章;
一条都不叫,授权红线就是摆设。最后 BD 放行签字。
**去向是算出来的,不是你选的**:有条目判了退回重批 → 退回定价重批;有成交 → 转订单履约;
全都没成 → 转情报归档。三口各带谓词,放行那一刻基座按序求值,你只要把定案落准。
【这一单只有一个岔路口,它长这样 —— 无论哪一拍都照这个发,别自己另编一张】
客户还了价要人拍板,那张决策卡**三处写死**:
· \`"itemId":"T_haggle"\` —— 英文计划项号。**别写中文名「异议拍板」**:中文名不是 id,
写了当场被拒(【目标缺口】那一段摆的就是中文名,别照它抄);
· \`"ask":"<N> 条客户还了价,按哪条路办?"\` —— 只把 N 换成条数,**别的字一个都不改**。
换了问法,系统就认不出这是同一张卡:人已经点过的那张会被你再发一遍;
· 三个 \`value\`:\`accept-encounter\` 不对,是 \`accept-counter\`(照客户还的价成交)、
\`revise\`(退回成本定价重批)、\`hold\`(守住现价不让)。label 与 consequence 可以按
本单的数改写,**value 不行**。
**REVISE(退回重批)这个码只有拍板那一步写得出来** —— 定案那一步遇到客户还价一律落
HAGGLE,替他写了 REVISE,那张卡就永远不会发,让步梯度成了摆设。
拍板之前**别急着发卡也别绕路** —— 定案那一步先把三档写完、finish;
下一拍引擎会把拍板那一步的做法摊开给你,那时再发。
【一路上要说话】负责人正看着这一单。**每一拍都带一句 say** —— 说业务的话:
报价函排了几条、有效期到哪天、客户回了什么、哪几条要拍板;不说系统词。
到了要找人的那一拍,先说清这一轮办完了什么、为什么现在需要他。
【怎么把一项干完 · 一拍连做】write 和 tool 都不会自动收步 —— 做完用 finish 宣告。
动作排进同一拍的 actions 串一口气给;只有发卡 / 提交(挂起)必须停拍。
**这一单常路 6-8 拍就该收口**。
§Z 结案:BD 在验收单上签字,系统把草稿合并成正式报价函并按定案发出对应那一口的载荷 ——
成交转订单履约,有异议退回成本定价,不成交转情报归档。`;
/**
* 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。
*
* 三处不给 `doneWhen`:`S2_send`(发卡触达真人)、`T_haggle`(要人拍板)、
* `T_release`(签下去触达世界)—— 给了等于给引擎一把替人签字的钥匙。
*/
export const NEGOTIATE_STEPS = [
{
item: 'S1_compose',
intent: '整份归一成正式报价函:逐条抄齐条款、补 SPQ/MOQ/贸易条款、按 P-04 注明有效期',
writes: ['quote_negotiate.line', 'quote_negotiate.verdict'],
/* 上游载荷数组不声明就会被整表段的截断刀砍掉(砍掉的恰是要归一的行) */
reads: ['items'],
view: 'negotiate-worksheet',
tools: [],
/*
* 归一 + 排版走完才算办完:行数两边对上(一边是基座现数),
* 且 composed_total 写过(S-FEEL 未绑定的名字参与比较为假,所以「没写过」进不来)。
*/
doneWhen: 'line_total > 0 and quote_negotiate_line_rows = line_total and composed_total >= 0',
guide: `第一件事:把这一单的**正式报价函**落出来 —— 一拍连做,别拆成两拍。
【条目从哪儿来】
· 流内转来(\`items\` 里的行带 quoted_price / disposition):照抄 —— **报出去的价就是
上游批准的那个价**,一个数字都别改;带 quote_line_total 这类总数的是结论行,不是料,
别落进报价函。
· 单飞:照正文归一,列了几颗落几行;正文里说的批准价就是报出去的价。
**按 pn 归并,一颗料一行**;pn 一个字符不许改(它是主键,改一个字就是新开一行)。
【写 \`quote_negotiate.line\`,一颗料一行】(rows 收数组)
rfq_ref(入参有就原样抄;没有从主题认;认不出才自己造一个,**造出来的必须以 \`NEG-\` 开头**
(\`NEG-<主题或第一颗料的型号>\`)。两条硬规矩:
**① 整单只有一个单号,不是一颗料一个** —— 它是这一单的案号,报价函所有行、verdict 那一行
用的都得是同一个串(实测栽过:两颗料造出两个号,一单裂成两单);
**② 只从本单来件取材** —— 台账里躺着的 RFQ-xxxx 是别人的单,借它的号等于把两单的行
混进同一个案卷(实测栽过一轮))、pn、customer_pn、category、brand、eau、lead_time、
remark(客户约束/急料,一个字别丢)、type_tag(照上游现标;认不出按 B)、
**quoted_price**(上游批准的报价,原样抄)。
条款三列**照这个口径补齐**(它们是报价函的必备条款,不是可选项):
· spq —— 最小包装量:上游给了就抄;没给按 eau 的量级给一个常规值(千位以下 100,
千到万 1000,万以上 5000)并在 remark 里注明是按常规包装估的;
· moq —— 最小起订量:同上,没给就取 spq;
· trade_terms —— 贸易条款:统一写 \`EXW 深圳\`,客户来件另有约定的照客户的写。
· valid_until —— 有效期到期日:按**报价有效期 P-04:7 个日历天**从今天算,写成
\`YYYY-MM-DD\`。整单同一个日子,不是一行一个。
【**这三列你一格都不写**:reply_type / counter_price / reply_note】
客户表态、客户还的价、客户原话 —— 这三样只有收信的人知道,**系统不替客户说话**。
它们是 BD 在工作单上落的;deal_status / concession / final_price 三列要等表态回来
才判得了,这一步也别写。
这不是客气话:一条要你分辨「这行能不能替客户填」的规矩会走岔;
**「这几列不写」没有分支可走岔。**
【同一拍写 \`quote_negotiate.verdict\`(一行)】
rfq_ref、**quote_no**(报价单号:\`Q-\` 开头 + 本单案号的尾段,整单一个)、
customer_code / customer_name(顶层有就抄)、**line_total** = 报价函行数、
**valid_days** = 7、**composed_total** = 这一拍排版落表的条数(0 也要写)、
summary 一句人话(几条料、报给谁、有效期到哪天)。
写完 finish 这一项。`,
},
{
item: 'S2_send',
intent: '一张执行单交 BD:把报价函发给客户,回音回来后逐条落客户表态',
writes: ['quote_negotiate.verdict'],
reads: ['quote_negotiate.line'],
view: 'negotiate-worksheet',
tools: [],
/* 不给 doneWhen —— 发卡触达真人(manualActivation),引擎不替人按 */
guide: `报价函排好了,**该 BD 出场了** —— 这是本活动唯一的一张执行单。
触达客户的那一下由人做:他把报价函发出去、按 D+3/D+7 催问、拿回客户的话。
工作区摆的是**议价工作单**:逐颗料一张报价档案卡(条款、报价、客户约束),
让步梯度与议价纪律挂在上面,填回区长在同一屏 —— 他在「客户表态」那一列逐行落。
这一步标了人工激活:发卡就算启动,不必单发 start;引擎也不会替你收 ——
人交回后要你自己 finish。
【照这个骨架发,别少字段】(卡面是纯文本,steps/standard 里别写星号记号)
{"kind":"human","itemId":"S2_send","workOrder":"execution","card":{
"title":"报价函发出并跟进:<单号> 共 <N> 条待回音",
"ask":"报价函已按批准价排好版,条款与有效期都在工作单上。请把它发给客户,按 D+3/D+7 的节奏催问;客户回话之后,在工作单的「客户表态」一列逐行落:接受 / 还价 / 拒绝 / 无回复,还价的把客户报的那个价也填上。",
"steps":["发出去之前先核一遍条款:单价、交期、SPQ、MOQ、贸易条款、有效期 —— 报价函一旦发出,这几项就是对客户的承诺",
"客户表态每一行都要有 —— 缺一行这张单按不下提交;客户只回了一部分料的,没回的那几行按无回复落,别替客户猜",
"客户还价的行:把他报的价填进「客户还价」那一格,再把他的原话记进备注 —— 争的是价格、交期还是 MOQ,后面拍板全看这一句",
"D+3 第一轮催问确认收到探态度,D+7 最后一催;催到有效期过仍无回复的,按无回复定案,不要一直挂着"],
"standard":"整份报价函每一条都有客户表态,还价的那几条填了客户报的价并记下他的原话 —— 缺一条这张单按不下提交(系统的闸看着)。",
"basis":[{"k":"<PN>","v":"<报价 · 交期 · 有效期到 YYYY-MM-DD>"}]}}
· \`basis\` 逐条列本单的料:PN + 报价与有效期。几颗写几条,别铺全表。
· **卡上不摆表、不摆格子**(执行卡零输入):整份报价函就在工作单那份件上,
客户表态、客户还价、备注三列都能就地改、行能撤 —— **别往卡里塞 \`blocks\` 或 \`fields\`**。
· **这张卡带提交闸**:客户表态(必填列)还空着,他按不下提交 —— 缺几格、缺在哪行,
闸上与件上的红带都写着(与放行门 L1 同一个数同一套算法)。
发完卡这一拍就结束了(发卡挂起)。
【人交回之后 —— 他落的表态基座已经替你写回台账了】这一步你只做两件事,然后 finish:
① 写 verdict:**replied_total** = 已落表态的条数(照报价函行数记);
他在件上撤掉了行的话,**line_total 跟着改成撤后的实数**。
② finish 这一项。
**别在这一拍判定案** —— 逐条定案是下一步的活(它有自己的一段做法),这里判了也白判。
报价函行你一列都不用重写:他落的值基座已经落进台账了,重写会被「写了等于没写」拒回。
【这张卡一单只发一次】人交回来之后不许再发同一张卡 —— 必填有提交闸兜着,
卡回来时客户表态一定是齐的(或那几行已被他撤掉)。`,
},
{
item: 'S3_settle',
intent: '逐条定案:按客户表态判成交/待拍板/不成交,还价的按让步梯度给处置建议',
writes: ['quote_negotiate.line', 'quote_negotiate.verdict'],
reads: ['quote_negotiate.line'],
view: 'negotiate-worksheet',
tools: [],
/*
* **收步判据与验收判据同源**:四档定案的条数加起来等于行数(两边都是基座现数)——
* 判完了才算办完。`_rows > 0` 那一半不能省:开案时行数是 0,而 0+0+0+0 = 0
* 会让这一步开局即收(未绑定名字的坑之外,还有「全零相等」这一坑)。
*/
doneWhen:
'quote_negotiate_line_rows > 0 and quote_negotiate_line_deal_status_haggle = quote_negotiate_line_reply_type_counter and quote_negotiate_line_deal_status_won + quote_negotiate_line_deal_status_haggle + quote_negotiate_line_deal_status_revise + quote_negotiate_line_deal_status_lost = quote_negotiate_line_rows',
guide: `客户的话都落回来了,**现在才轮到定案** —— 这一步是纯查表,一拍连做:
① **逐行按客户表态 reply_type 判 deal_status**,按议价结果 N-01,
只许写这四个大写码之一,一个字母不许差:
· ACCEPT(客户接受)→ **WON**,同一行 final_price 记 quoted_price(成交价就是报价);
· COUNTER(客户还价)→ **HAGGLE**,final_price **留空**(还没谈定,别替客户成交);
· REJECT(客户拒绝)→ **LOST**,final_price 留空;
· NOREPLY(催到有效期过仍无回复)→ **LOST**,final_price 留空。
**第四个码 REVISE「退回重批」这一步一格都不写** —— 它是人在决策卡上拍出来的,
不是你判出来的。你替他写了,那张卡就永远不会发,让步梯度成了摆设(实测栽过一轮:
一单从头到尾 released 而没有人拍过板)。客户还了价 = 落 **HAGGLE**,句号。
**两个数要对上,都是基座现数,你改不动它们**:
· 待拍板条数 quote_negotiate_line_deal_status_haggle
**必须等于**客户还价条数 quote_negotiate_line_reply_type_counter ——
对不上这一步收不掉(把写错的那行改回 HAGGLE 就对上了);
· 四档定案的条数加起来必须等于 quote_negotiate_line_rows(基座数的行数),一行都不能漏。
现在四档都是 0,就是一行都还没判 —— 这一步没办完,放行门开不了。
② **还价的那几行(且只有那几行)再算一格 concession**:让步梯度 N-02 看还价幅度
gap =(quoted_price − counter_price)/ quoted_price,只许写这三个大写码之一:
· gap ≤ 0.03(三个点以内)→ **ACCEPT_COUNTER**(意思意思的压价,接了拿单);
· 0.03 < gap ≤ 0.10(三到十个点)→ **NEGOTIATE**(在授权内回旋一轮,让一半再报回去);
· gap > 0.10(超过一成)→ **ESCALATE**(超出授权,退回成本定价重批,不私自破线)。
不是还价的行 concession 给空串 \`""\`,**不许写 0 也不许编一个码** ——
没还价就没有让步幅度这回事。
③ 看这一拍写完之后的**待拍板条数**(deal_status = HAGGLE 的行):
· 一条都没有 → 同一拍把 verdict 写全:**outcome**(全按 N-01 的结果算:
有 WON 就写 \`won\`,一条 WON 都没有就写 \`lost\`)、**route_decided_total 记 1**、
**settled_total** = 定案条数、reason(不成交时写清缘由:客户拒绝还是超时无回复)、
summary 补一句人话,然后 finish 这一项;
· 还有 → **别写 outcome 也别写 route_decided_total**,只写 settled_total 再 finish ——
下一拍会有一步专门叫人拍板。
【finish 之后这一拍到此为止】**别在这一拍顺手发决策卡** —— 那张卡属于下一步
(itemId 要写 T_haggle),这一步收掉之后再往它上面挂任何行动都会被拒
(实测:同一串里 write / write / finish / decide,最后那个 decide 挂着 S3_settle,当场被拒)。
下一拍引擎会把「异议拍板」那一步的做法摊开给你,那时再发。`,
},
{
item: 'T_haggle',
intent: '客户还的价接不接要人定 —— 发一张决策卡,照他选的那条路把定案改掉',
writes: ['quote_negotiate.line', 'quote_negotiate.verdict'],
reads: ['quote_negotiate.line'],
view: 'negotiate-roster',
tools: [],
/* 人工激活 + 要人拍板 —— 不给 doneWhen,引擎不许替人收 */
guide: `**这一单只发一张决策卡。动手之前先在轨迹里找一行 \`[card-reply]\` 带「人选了:」:**
· **找到了** —— 跳到下面【人点完之后】那一段,照他选的那条路把那几行的 deal_status
从 HAGGLE 改掉、写 outcome 与 route_decided_total、finish。**一个 decide 都不许再发**
(实测栽过:人点完了,而这一步又从头念了一遍「发一张决策卡」,同一张卡连发六次,
前两次无事发生、后四次被拒 —— 他点过的那一下就白点了);
· **没找到** —— 才轮到下面【发一张决策卡】那一段。
【这一步办完了没有,看一个数】待拍板条数 quote_negotiate_line_deal_status_haggle:
**它归零就是这一步办完了**。HAGGLE 是过渡态(客户还了价、还没拍板),
拍板那一下必须把它换成 WON / REVISE / LOST 之一 —— 三条路都是结论。
这个数不归零,放行门开不了;它归零,这一步也就不会再找你。
这一步只在**客户还过价**(基座现数 quote_negotiate_line_reply_type_counter > 0)时入场 ——
读的是客户自己填的那一格,不是你判的定案:该不该叫人,不由被叫的那一方说了算。
它触达公司利益,标了人工激活:发卡就算启动,不必单发 start。
工作区这时摆的是**成交定案清单**,整份报价函与定案分布都在上面,他看得见全局再拍板。
【发一张决策卡,照这个骨架】(选项少于两个基座拒发)
**三处一个字都不许改**(与说明书里那一段是同一份):
· \`itemId\` 只写英文计划项号 \`T_haggle\` —— **别写中文名「异议拍板」**,
中文名不是 id,写了当场被拒(实测栽过两拍:「计划项不存在:异议拍板」);
【目标缺口】那一段摆的是中文名,它不是 id,别照它抄。
· \`ask\` 照抄「<N> 条客户还了价,按哪条路办?」,**只把 N 换成条数** ——
换了问法系统就认不出这是同一张卡,人已经点过的那张会被你再发一遍(实测发出去两张)。
· 三个 \`value\` 照抄 \`accept-counter\` / \`revise\` / \`hold\` —— 它们是这一单三条路的
代号,换了名字下一段的对应关系就对不上,你自己也不知道人点的是哪条。
label 与 consequence 可以按本单的数改写,value 不行。
{"kind":"decide","itemId":"T_haggle","ask":"<N> 条客户还了价,按哪条路办?",
"options":[
{"value":"accept-counter","label":"照客户还的价成交","consequence":"当场拿下这几条,毛利让到客户的价位上,让出去的部分不再回来"},
{"value":"revise","label":"退回成本定价重批","consequence":"这几条超出让价授权,回 A4 重新算价再谈 —— 这一单本轮不成交,客户要多等一轮"},
{"value":"hold","label":"守住现价不让","consequence":"报价不动,客户不接受即视为不成交,这几条转情报归档"}],
"basis":[{"k":"<PN>","v":"报价 <X> · 客户还价 <Y> · 让幅 <Z>% · 让步梯度判 <码>"}]}
· basis 逐条摆还价的那几颗料:报价、客户还价、让幅、N-02 的处置码 —— 裸卡基座拒发。
· **recommended 按让步梯度 N-02 标**,标在与处置码对应的那一项上,只标一项:
ACCEPT_COUNTER → accept-counter;NEGOTIATE → accept-counter;ESCALATE → revise。
几条料的码不一致时,按**最靠后那一档**标(有一条要 ESCALATE 就标 revise)。
· 发完这一拍就结束了(发卡挂起)。
【人点完之后】轨迹上那一行长这样:\`[card-reply] …人选了:xxx\`。**照那条路把定案改掉,
别再发第二张卡**,一拍连做 —— 三条路**都要动 deal_status**,一条都不许留在 HAGGLE:
· 选 accept-counter:那几行 deal_status 改 **WON**、**final_price 记该行的
counter_price**(那个数不是你定的,是客户报的、人点头认的,你只是替他落账)、
reply_note 补一句「按客户还价成交,由负责人拍板」;verdict 的 outcome 写 \`won\`;
· 选 revise:那几行 deal_status 改 **REVISE**、final_price 留空,
reply_note 补一句「让幅超出授权,退回成本定价重批」;verdict 的 outcome 写 \`revise\`、
reason 写清超了多少;
· 选 hold:那几行 deal_status 改 **LOST**、final_price 留空,
reply_note 补一句「守价不让,客户未接受」;verdict 的 outcome 写 \`lost\`、
reason 写「客户还价未达成,守价不让」。
然后把 verdict 写全:**route_decided_total 记 1**、settled_total = 定案条数、
summary 补一句拍板结果,finish 这一项。
**outcome 那一格照上面三条写死的对应关系填,别自己另造词** —— 下游三条边读的就是它。
改完之后待拍板条数就是 0 了,这一步再也不会找你。`,
},
{
item: 'T_release',
intent: '定案已落,提交放行交 BD 验收',
writes: [],
reads: [],
view: 'negotiate-roster',
tools: [],
/* 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 doneWhen) */
guide: `每条都有客户表态、定案守恒、待拍板清零、定案已记 —— 可以走放行了
(T_release 人工激活:动它就算启动,不必单发 start)。先把裁量判据裁掉,然后 submit,同一拍连做:
· judge criterion="议价过程说得清:报价函条款齐备、每一轮客户反馈与让价理由逐条可溯,BD 对这个结果负责" ——
看报价函里每一行的条款、客户原话与让价理由:说得出来路就 satisfied=true;
有行说不出的(比如还价成交却没记客户的话),satisfied=**false**,rationale 逐行写清,
交签字的人定。**裁成不满足照样能提交** —— 理由连着卡一起交给他。
criterion 照判据名**整句原样抄**,写别的名字对不上。
**去向不必你选**:三口各带谓词,放行那一刻基座按序求值 ——
有条目判了退回重批就走「退回调整」,有成交就走「转订单履约」,全都没成就走「转情报归档」。
你要做的只是把定案落准,别在提交里手选出口。
终局唯放行:你不能自己宣布完成。`,
},
];
/**
* 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段;
* 放行那一格由基座统一加,人看到的是 x/5。
*/
export const NEGOTIATE_PHASES = [
{ name: '排版报价函', items: ['S1_compose'] },
{ name: '发出并等回音', items: ['S2_send'] },
{ name: '逐条定案', items: ['S3_settle'] },
{ name: '异议拍板', items: ['T_haggle'] },
];
+13 -45
View File
@@ -1,50 +1,18 @@
/**
* 统一报价 A6 · 报价议价 —— 活动包代码入口(ADR-004)。
* 统一报价 A6 · 报价议价 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
*
* 「发出的同时必须建立 D+3/D+7 跟进 —— 没有跟进的报价等于没报。」
* 这里把跟进监听立起来:AI 对世界只有触达(动作,落在放行)与等待(监听)两种姿势。
* **这个活动没有 `run`,不是缺件。**
*
* 案例活动的推进由 Loop 驱动,行为全部写在清单里:案例模型 + 说明书 + 步骤剧本 +
* 两把判定表 + 两条技能 + 台账声明 + 两张 HTML 报告模版。
* 上一版这里有一段 `run()`(读报价单、拼 table 块、按条款齐不齐返回 await/submit,
* 末尾还立了一条 D+3 监听)—— 案例活动根本不会调它;摆一个空转的 run 去假装有实现,
* 更坏:它写的那套呈现与 `viewTemplates` 里的模版各说各话,而没人看得出以哪份为准;
* 那条 D+3 监听也与 `declarations.listeners` 里声明的三条重复,谁真谁假说不清。
*
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 —— 它是**被静态审扫描的产物文本**
* (查 import 与全局引用,证明活动包零环境能力)。
*/
import { defineActivity } from '@sdk';
import type { ActivityBase, ActivityOutcome } from '@sdk';
const QUOTE = 'quote_negotiate.formal_quote';
export default defineActivity({
name: 'quote-negotiate',
paradigm: 'flow',
async run(base: ActivityBase): Promise<ActivityOutcome> {
const lines = await base.ledger.read(QUOTE);
await base.interaction.pushView([
{
t: 'table',
title: '正式报价单',
cols: ['PN', '单价CNY', '交期', 'MOQ', '有效期'],
rows: lines.map((r) => [
String(r['pn'] ?? ''),
String(r['unit_price'] ?? '—'),
String(r['lead_time'] ?? '—'),
String(r['moq'] ?? '—'),
String(r['valid_until'] ?? '—'),
]),
},
]);
const incomplete = lines.filter((r) => r['unit_price'] === undefined || r['valid_until'] === undefined);
if (lines.length === 0 || incomplete.length > 0) {
await base.interaction.say('报价单条目尚未齐备,继续排版。', 'conclusion');
return { kind: 'await', reason: '等待报价单生成步完成' };
}
// 建 D+3 跟进:等客户回音 —— 时限必配,无死等
await base.tools.listen({
eventType: 'data',
keyFilter: { kind: 'customer-reply', customerCode: String(base.contract.data['customerCode'] ?? '') },
deadline: 'P3D',
});
await base.interaction.say('已建 D+3 跟进监听 —— 没有跟进的报价等于没报。', 'guidance');
return { kind: 'submit', deliverable: 'formal-quote', rationale: '报价单齐备且已建跟进,提交放行即发出' };
},
});
export default defineActivity({ name: 'quote-negotiate' });
+486 -61
View File
@@ -1,155 +1,580 @@
/**
* 活动包清单 —— 统一报价 A6 · 报价议价。
* 活动包清单 —— 统一报价 A6 · 报价议价(案例活动)。
*
* 业务素材:docs/MVP示例工作流.html §1.4(S1 生成发出 → S2 CRM 留档 → S3 建立跟进)。
* 关键机制:生成正式报价单是**写类动作** → 登记为动作意向 → 落地预检 →
* 放行通过即落地(台账回写 + 出站发送)→ 输出口发射;并建 D+3 跟进监听。
* 业务素材:`docs/MVP示例工作流.html` §1.4(S1 发报留档 → S2 跟进议价 → S3 定案交接):
* 「审批通过后 15 分钟内发出……没有跟进的报价等于没报」
* 「客户不回不是结论,催到表态或有效期过」
* 「让价在授权范围内回旋,要破底价门禁红线的交回发起流程重批,不私自破线」。
*
* 形状与 A4 同一条(2026-08-03 重写 @3.0.0):归一排版 → 一张执行单交人(发出去 +
* 把客户回音落回来)→ 逐条定案 → **还价才叫人**拍板 → 放行。
* **能算的不问人**(N-01 议价结果、N-02 让步梯度两把尺写死),
* **该客户说的不代说**(客户表态那三列 AI 一格都不写)。
*
* 与 @2.1.0 的差别(四处都是踩过的账):
* ① `input.required` 从 `['rfqRef','customerCode','items']` 清空 —— 宽进严出;
* ② 台账补 `required` / `requiredWhen` —— 从前一个必填都没有,执行卡的闸没牙;
* ③ 判据与哨兵全部换成**基座读数**(`_rows` / `_missing` / `_<列>_<值>`)——
* 从前三条 L1/L2 读 `missing_field_total` / `validity_days` / `followup_total`,
* 三个都是活动自报的数(「机检只读基座数」违反史第六类);
* ④ 三个出口补 `when` —— 去向是**算出来的**,不是模型手选的。
*/
import type { ActivityManifest } from '@contracts';
import { NEGOTIATE_SKILLS } from './skills.ts';
import { CMMN_XML, NARRATIVE } from './case-model.ts';
import type { ActivityManifest, DmnModel } from '@contracts';
import { activityIdSchema } from '@contracts';
import { QUOTE_COMPOSED, QUOTE_RELEASE } from './views/report.ts';
import { NEGOTIATE_SKILLS } from './skills.ts';
import { CMMN_XML, NARRATIVE, NEGOTIATE_PHASES, NEGOTIATE_STEPS } from './case-model.ts';
import { NEGOTIATE_ROSTER, NEGOTIATE_WORKSHEET } from './views/report.ts';
const DOMAIN = 'quote_negotiate';
const D = 'quote_negotiate';
const ACTIVITY_ID = activityIdSchema.parse('quote-negotiate');
/**
* 两把尺 —— **规则本身不是数据**,随包冻结。
*
* · 议价结果 N-01:客户表态 → 定案(出参就是台账 `deal_status` 的取值);
* · 让步梯度 N-02:还价幅度 → 让步处置(出参就是台账 `concession` 的取值)。
*
* 出参键名与台账列同名,模型算完照抄即可(规范 §5.6)。
* 两把尺的档位在三处出现:这张表、剧本 guide、工作单上的规则块 ——
* 包内自测把三处钉在一起(改一处不改另两处,测就红)。
*/
export const N01_DMN: DmnModel = {
drd: [
{ decision: 'n01-outcome', requires: [], inputs: ['reply_type'] },
{ decision: 'n02-concession', requires: ['n01-outcome'], inputs: ['gap'] },
],
tables: [
{
id: 'n01-outcome',
label: '议价结果 N-01',
hitPolicy: 'FIRST',
inputs: [{ name: 'reply_type', expression: 'reply_type' }],
outputs: [{ name: 'deal_status' }],
rules: [
{ id: 'n01-1', when: ['"ACCEPT"'], then: ['WON'], annotation: '客户明确接受这个价 → 成交,转订单履约' },
{ id: 'n01-2', when: ['"COUNTER"'], then: ['HAGGLE'], annotation: '客户还了一个价 → 压在还价上,要人拍板' },
{ id: 'n01-3', when: ['"REJECT"'], then: ['LOST'], annotation: '客户明确不做了 → 不成交,情报归档' },
{ id: 'n01-4', when: ['"NOREPLY"'], then: ['LOST'], annotation: '催到有效期过仍不表态 → 按超时定案' },
{ id: 'n01-default', when: ['-'], then: ['LOST'], annotation: '认不出的表态按不成交处置 —— 不许猜成接受' },
],
},
{
/** 让步梯度 N-02 —— FIRST 命中即定,所以第二行等价于「三到十个点」 */
id: 'n02-concession',
label: '让步梯度 N-02',
hitPolicy: 'FIRST',
inputs: [{ name: 'gap', expression: 'gap' }],
outputs: [{ name: 'concession' }, { name: 'in_authority' }],
rules: [
{ id: 'n02-1', when: ['<= 0.03'], then: ['ACCEPT_COUNTER', true], annotation: '三个点以内 —— 意思意思的压价,接了拿单' },
{ id: 'n02-2', when: ['<= 0.10'], then: ['NEGOTIATE', true], annotation: '三到十个点 —— 授权内回旋一轮,让一半再报回去' },
{ id: 'n02-3', when: ['> 0.10'], then: ['ESCALATE', false], annotation: '超过一成 —— 超出授权,退回成本定价重批,不私自破线' },
],
},
],
};
export const manifest: ActivityManifest = {
identity: {
activityId: ACTIVITY_ID,
name: 'A6 · 报价议价',
packageVersion: '0.1.5',
packageVersion: '3.5.0',
category: '统一报价',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-quote-negotiate',
},
/* ── 契约七件 ────────────────────────────────────────────────── */
contract: {
activityId: ACTIVITY_ID,
version: '0.1.0',
version: '1.0.0',
input: {
/*
* **宽进到底:一个必填都不设**(与 A1/A2/A3/A4 同口径 —— 宽进严出,
* 输入契约在活动内部归一 + 补全)。
*
* 来路两种形状:流内转来(A4 `draft` 口的 `{rfqRef, customerCode, items}`)、
* 单飞(一段文字)。上一版必填 `rfqRef + customerCode + items`,门口拦的恰是主路。
*/
data: {
type: 'object',
required: ['customerCode', 'items'],
properties: { customerCode: { type: 'string' }, items: { type: 'array' } },
required: [],
properties: {
rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' },
customerCode: { type: 'string', title: '客户码' },
items: { type: 'array', title: '报价草案条目(已批准,每条带拟报单价与门禁处置)' },
rows: { type: 'array', title: '上游清单行(别的来路也收)' },
subject: { type: 'string', title: '需求主题' },
body: { type: 'string', title: '需求正文' },
attachments: {
type: 'array',
format: 'file',
title: '附件',
items: {
type: 'object',
properties: {
name: { type: 'string', title: '文件名' },
contentType: { type: 'string', title: '类型' },
size: { type: 'integer', title: '字节数' },
ref: { type: 'string', title: '存档号' },
text: { type: 'string', title: '读出来的文本' },
},
},
},
},
},
messageAllowed: true,
messageHint: '客户的议价习惯、竞对价位、这一单的让价授权、催办节奏写这里',
},
output: {
/*
* **没有必交付口**:这个活动的成品就是「谈出一个结果」,而结果本身是分支 ——
* 三口恰发一口,发出去的那一口就是这一单的 outcome(基座在放行时把口名
* 写进事实,下游 E11/E12/E13 读的就是它)。
*/
mandatory: [],
// 议价结果是**业务分支**:必须建模为选择组的一口,随放行发射 —— 不得用异常终态编码
/*
* **三条出边 = 一个 XOR 选择组**(对齐流程包 E11/E12/E13)。
* 每一口都写了 `when`(读基座按值现数的 tally 读数),这一单往哪走是**算出来的**:
* 放行时基座按序求值首个命中即出,卡上只如实告诉人走哪一口、凭哪一条。
*
* 顺序即优先级:**判了退回重批 → 先退回重批**(谈不拢的那几条不能被
* 「另外几条成交了」盖过去);其次有成交 → 转履约;都没成 → 转归档。
*/
selective: [
{
group: 'negotiation',
ports: [
{ name: 'won', payload: { type: 'object' }, description: '成交 → 转订单履约(ORM)' },
{ name: 'revise', payload: { type: 'object' }, description: '有异议 → 返回调整 A4' },
{ name: 'lost', payload: { type: 'object' }, description: '拒绝/超时 → 转 A7 情报归档' },
{
name: 'revise',
payload: {
type: 'object',
required: ['rfqRef', 'outcome'],
properties: {
rfqRef: { type: 'string', title: '询价单号' },
customerCode: { type: 'string', title: '客户码' },
outcome: { type: 'string', title: '议价结果(固定 revise)' },
reason: { type: 'string', title: '退回缘由(让幅超授权多少)' },
rows: { type: 'array', title: '报价函(带客户还价与让步处置)' },
},
},
description: '有条目经人拍板判了退回重批 —— 让幅超出授权,回成本定价重算再谈',
when: `${D}_line_deal_status_revise > 0`,
},
{
name: 'won',
payload: {
type: 'object',
required: ['rfqRef', 'outcome'],
properties: {
rfqRef: { type: 'string', title: '询价单号' },
customerCode: { type: 'string', title: '客户码' },
quoteNo: { type: 'string', title: '报价单号' },
outcome: { type: 'string', title: '议价结果(固定 won)' },
rows: { type: 'array', title: '成交条目(带成交价)' },
},
},
description: '有条目谈成 —— 转订单履约(ORM)',
when: `${D}_line_deal_status_won > 0`,
},
{
/* 下游 A7 的入参要 `rfqRef / customerCode / quoteNo` —— 边读什么、下游要什么,这一口就得发什么 */
name: 'lost',
payload: {
type: 'object',
required: ['rfqRef', 'customerCode', 'quoteNo', 'outcome'],
properties: {
rfqRef: { type: 'string', title: '询价单号' },
customerCode: { type: 'string', title: '客户码' },
quoteNo: { type: 'string', title: '报价单号(A7 归档的主键之一)' },
outcome: { type: 'string', title: '议价结果(固定 lost)' },
reason: { type: 'string', title: '不成交缘由(拒绝 / 超时无回复)' },
rows: { type: 'array', title: '报价函(带客户表态与定案)' },
},
},
description: '一条都没谈成:客户拒绝或超时无回复 —— 转情报归档',
when: `${D}_line_deal_status_lost > 0`,
},
],
},
],
},
/*
* 必交付物只有一件:**正式报价函**。议价结论(verdict)是它的结论栏,
* `ledgers` 说清这份成果由哪几张表承载(一份成果,不是两份)。
*/
requiredDeliverables: [
{ key: 'formal-quote', label: '正式报价单(PDF · 中英双语)' },
{ key: 'followup', label: 'D+3/D+7 跟进提醒' },
{
key: 'formal-quote',
label: '正式报价函(逐条条款 + 客户表态与定案)',
ledger: `${D}.line`,
/* 验收页上让 BD 就地改的两列 —— 恰是他的职权面(成交价与客户原话) */
editableAtRelease: ['final_price', 'reply_note'],
ledgers: [`${D}.line`, `${D}.verdict`],
},
],
/*
* 验收判据 —— **机检(L1/L2)只读基座数得出来的数**:
* `_rows` 基座数行数、`_missing` 基座按 required / requiredWhen 现数
* (与执行卡提交闸同一个数同一套算法)、`_<列>_<值>` 基座按值现数。
* 上一版三条机检读活动自报的 `missing_field_total` / `validity_days` /
* `followup_total` —— 那不是门,是「改哪个数能过」的提示。
*/
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: 'bd-release', label: 'BD 放行', level: 'L5' },
{
key: 'roster-complete',
label: '报价函齐:上游条目逐条承继,说了几条报价函里就躺着几行',
level: 'L1',
predicate: `line_total > 0 and ${D}_line_rows = line_total`,
},
{
key: 'replied-all',
label: '每条都有客户表态:还价的那几条还落下了客户报的那个价,缺一行放不了',
level: 'L1',
predicate: `${D}_line_rows > 0 and ${D}_line_missing = 0`,
},
{
key: 'reply-typed',
label: '表态逐条落成四种之一:接受 / 还价 / 拒绝 / 无回复 —— 没有含糊其辞的格子',
level: 'L2',
predicate: `${D}_line_reply_type_accept + ${D}_line_reply_type_counter + ${D}_line_reply_type_reject + ${D}_line_reply_type_noreply = ${D}_line_rows`,
},
{
key: 'settled',
label: '定案逐条落定:成交 / 待拍板 / 退回重批 / 不成交,四档相加等于报价函条数',
level: 'L2',
modelRef: 'n01-outcome',
predicate: `${D}_line_rows > 0 and ${D}_line_deal_status_won + ${D}_line_deal_status_haggle + ${D}_line_deal_status_revise + ${D}_line_deal_status_lost = ${D}_line_rows`,
},
{
/* 与 A4 的「拦截档清零」同一形状:还价的条目要么接、要么退回重批、要么守价不让 */
key: 'haggle-cleared',
label: '客户还的价都拍过板了:接了、退回重批了、还是守价不让 —— 没有还悬着的条目',
level: 'L2',
predicate: `${D}_line_deal_status_haggle = 0`,
},
{
/*
* 退回重批的行**必是客户还过价的行** —— 两边都是基座按值现数。
* 没人还价却判了「退回重批」,是模型给自己找了一条不必叫人的近路(实测栽过一轮)。
*/
key: 'revise-from-counter',
label: '判了退回重批的,都是客户真还过价的那几条 —— 没有凭空退回的条目',
level: 'L2',
predicate: `${D}_line_deal_status_revise <= ${D}_line_reply_type_counter`,
},
/*
* 议价过程可核是**裁量**(L3):客户的话记没记全、让价的理由站不站得住,
* 机器数不出来。这句 label 与剧本里 judge 的 criterion **必须逐字一致**。
*/
{
key: 'negotiation-defensible',
label: '议价过程说得清:报价函条款齐备、每一轮客户反馈与让价理由逐条可溯,BD 对这个结果负责',
level: 'L3',
},
/* L5 是责任关:那张卡本身就是这道关,卡面上不摆它(基座统一滤) */
{ key: 'bd-release', label: 'BD 放行:签下去这一单的议价结果即定案并按去向交接', level: 'L5' },
],
revisableAtRelease: [],
prohibitions: [
{ key: 'no-break-line', statement: '客户议价超出授权范围的,回 A5 重批,不私自破线' },
{ key: 'no-send-before-release', statement: '报价单在放行前不得发出 —— 写类动作集中在放行时落地' },
{ key: 'no-speak-for-customer', statement: '不许替客户表态:客户没回的行按无回复落,不得写成接受 —— 那是在系统里伪造一份客户承诺' },
{ key: 'no-break-line', statement: '客户要的价超出让价授权的,退回成本定价重批,不私自破线' },
{ key: 'no-send-before-release', statement: '报价函在放行前不得对外发出 —— 触达世界的动作集中在放行那一刻' },
],
authorization: {
discretion: [{ key: 'haggle-band', statement: '在已批准的价格区间内回旋;出区间必须回审批' }],
// 预授权必须逐项列举:催办模板可即时发,报价单不行
preauthorizedActions: [{ tool: 'mail.send', operation: 'nudge', limit: '仅催办模板' }],
humanCallBudget: 3,
discretion: [{ key: 'haggle-band', statement: '让步梯度 N-02 给的授权区间内自决;超过一成的让幅一律回成本定价重批' }],
preauthorizedActions: [],
/* 一张执行单 + 可能一张决策卡 + 放行 = 三次;留一倍余量 */
humanCallBudget: 5,
},
resources: {
tools: [{ name: 'mail.send' }, { name: 'crm.history-price-read' }],
/*
* **不调工具**。上一版声明了 `mail.send`(写类:登记意向、放行时落地)——
* 而「把报价函发给客户」这件事在 MVP 示例工作流 §1.4 里本来就是**人工任务**:
* 收信的邮箱是 BD 的,客户回什么也只有他看得见。让活动登记一条发信意向,
* 既要过落地预检(收件人 / 主题 / 工具授权三道),又与执行单上那句
* 「请把它发给客户」重复,而没人说得清究竟哪一份才真发了出去。
*/
tools: [],
skills: NEGOTIATE_SKILLS.map((s) => s.id),
models: ['deepseek-v4-flash'],
roles: ['BD'],
budget: { steps: 50, tokens: 120_000, activeMinutes: 60, humanCalls: 3 },
/* SLA:15 分钟内发出、D+3/D+7 催、有效期 7 天;预算按 A4 实测(6-9 拍)留一倍余量 */
budget: { steps: 60, tokens: 400_000, activeMinutes: 120, humanCalls: 5 },
},
},
/* ── 声明 ────────────────────────────────────────────────────── */
declarations: {
models: ['deepseek-v4-flash'],
tools: [{ name: 'mail.send' }, { name: 'crm.history-price-read' }],
tools: [],
ledger: [
{
domain: DOMAIN,
object: 'formal_quote',
label: '正式报价单',
domain: D,
object: 'line',
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: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' },
{ name: 'unit_price', type: 'number', primaryKey: false, masked: false, label: '单价CNY' },
{ name: 'customer_pn', type: 'text', primaryKey: false, masked: false, label: '客户料号' },
{ name: 'category', type: 'text', primaryKey: false, masked: false, label: '品类' },
{ name: 'brand', type: 'text', primaryKey: false, masked: false, label: '制造商' },
{ name: 'eau', type: 'integer', primaryKey: false, masked: false, label: '年用量 EAU' },
{ name: 'lead_time', type: 'text', primaryKey: false, masked: false, label: '交期' },
{ name: 'spq', type: 'integer', primaryKey: false, masked: false, label: 'SPQ' },
{ name: 'moq', type: 'integer', primaryKey: false, masked: false, label: 'MOQ' },
/* 客户备注承继自上游 —— 认证要求、预授权、急料标注,议价时的第一手依据 */
{ name: 'remark', type: 'text', primaryKey: false, masked: false, label: '客户备注(约束/急料)' },
{ name: 'type_tag', type: 'text', primaryKey: false, masked: false, label: '类别(承继)', tally: ['A', 'B', 'C'] },
/* ── 报价函的条款面:发出去就是对客户的承诺 ─────────────────── */
{ name: 'quoted_price', type: 'number', primaryKey: false, masked: false, label: '报价 CNY(已批准)' },
{ name: 'spq', type: 'integer', primaryKey: false, masked: false, label: 'SPQ 最小包装' },
{ name: 'moq', type: 'integer', primaryKey: false, masked: false, label: 'MOQ 最小起订' },
{ name: 'trade_terms', type: 'text', primaryKey: false, masked: false, label: '贸易条款' },
{ name: 'valid_until', type: 'date', primaryKey: false, masked: false, label: '有效期' },
{ name: 'valid_until', type: 'date', primaryKey: false, masked: false, label: '有效期至' },
/*
* ── 客户那一侧的三列:**AI 一格都不写** ────────────────────────
*
* 与 A4 的 `quoted_price` 同一处结构治:一条要模型分辨「这行能不能替客户填」
* 的规矩会走岔;「这几列不写」没有分支可走岔。
*
* `reply_type` 标 `required` —— 件上的缺项红带、执行卡的提交闸、放行门 L1
* 三处同源(都数基座的 `_missing`)。
* `counter_price` 用 `requiredWhen`:**只有还价那一行才算必填**
* (表级 required 会让接受/拒绝的行永远缺一格,把闸拦成死锁)。
*/
{
name: 'reply_type',
type: 'text',
primaryKey: false,
masked: false,
required: true,
label: '客户表态',
tally: ['ACCEPT', 'COUNTER', 'REJECT', 'NOREPLY'],
},
{
name: 'counter_price',
type: 'number',
primaryKey: false,
masked: false,
requiredWhen: { column: 'reply_type', value: 'COUNTER' },
label: '客户还价',
},
{ name: 'reply_note', type: 'text', primaryKey: false, masked: false, label: '客户原话 / 让价理由' },
/* ── 定案面:两把尺算出来的,模型照抄 ─────────────────────────── */
/*
* 定案四档,**HAGGLE 是过渡态**(客户还了价、还没拍板):拍板那一下把它换成
* WON / REVISE / LOST 之一,于是「还价才叫人」那道哨兵当场为假。
* 上一版只有三档、退回重批那条路「定案不动」—— 人点完了 haggle 还是那个数,
* 哨兵照旧成立,模型读同一段做法就再发一张卡:**实测同一张连发六次**。
* 措辞治不了这种事,得让「办完了」在数上看得见(2026-08-03 逼出来的账)。
*/
{ name: 'deal_status', type: 'text', primaryKey: false, masked: false, label: '定案', tally: ['WON', 'HAGGLE', 'REVISE', 'LOST'] },
{
name: 'concession',
type: 'text',
primaryKey: false,
masked: false,
label: '让步处置(N-02)',
tally: ['ACCEPT_COUNTER', 'NEGOTIATE', 'ESCALATE'],
},
{ name: 'final_price', type: 'number', primaryKey: false, masked: false, label: '成交价' },
],
},
{
domain: 'shared',
object: 'history_price',
label: '历史价格情报(共享域)',
/*
* 议价结论 —— 一单一行的**汇总表**,哨兵与报告读它。
* 汇总列与逐行列**故意不同名**:任务视图把草稿行摊平到顶层是「后写的赢」,
* 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。
*/
domain: D,
object: 'verdict',
kind: 'record',
caseKey: 'rfq_ref',
label: '议价结论',
fields: [
{ name: 'pn', type: 'text', primaryKey: true, masked: false },
{ name: 'quoted_at', type: 'date', primaryKey: true, masked: false, label: '报价日期' },
{ name: 'unit_price', type: 'number', primaryKey: false, masked: false },
{ name: 'lead_time', type: 'text', primaryKey: false, masked: false },
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'quote_no', type: 'text', primaryKey: false, masked: false, label: '报价单号' },
{ name: 'customer_code', type: 'text', primaryKey: false, masked: false, label: '客户码' },
{ name: 'customer_name', type: 'text', primaryKey: false, masked: false, label: '客户名称' },
{ name: 'line_total', type: 'integer', primaryKey: false, masked: false, label: '报价函条数' },
{ name: 'valid_days', type: 'integer', primaryKey: false, masked: false, label: '报价有效期天数' },
/* 排版走完的凭据(0 也要写)—— 哨兵按它放交人发出那一步入场 */
{ name: 'composed_total', type: 'integer', primaryKey: false, masked: false, label: '已排版条数' },
/* 「客户回音落齐了」的凭据 —— 只在人环走完才产生 */
{ name: 'replied_total', type: 'integer', primaryKey: false, masked: false, label: '已落客户表态条数' },
{ name: 'settled_total', type: 'integer', primaryKey: false, masked: false, label: '已定案条数' },
{ name: 'route_decided_total', type: 'integer', primaryKey: false, masked: false, label: '已定案定盘' },
{ name: 'outcome', type: 'text', primaryKey: false, masked: false, label: '议价结果(won/revise/lost)' },
{ name: 'reason', type: 'text', primaryKey: false, masked: false, label: '不成交 / 退回缘由' },
{ name: 'summary', type: 'text', primaryKey: false, masked: false, label: '议价结论' },
],
},
],
/*
* 三条静态监听 —— **时限必配**(AM 4.4)。等客户回音这件事的落单人是 BD
* (见 case-model 头注),这三条管的是「别把它挂到天荒地老」:
* D+3 催一轮、D+7 最后一催并升级、客户真从通道回信时也认得出是这一单。
*/
listeners: [
{
origin: 'static',
eventType: 'time',
keyFilter: { kind: 'followup-d3' },
semantic: 'observe',
deadline: 'P3D',
escalation: ['nudge'],
},
{
origin: 'static',
eventType: 'time',
keyFilter: { kind: 'quote-expiry' },
semantic: 'observe',
deadline: 'P7D',
escalation: ['nudge', 'escalate'],
},
{
origin: 'static',
eventType: 'data',
keyFilter: { kind: 'customer-reply' },
semantic: 'complete-step',
// 建 D+3 跟进监听:等客户回音,时限必配 —— 无死等
deadline: 'P3D',
escalation: ['nudge', 'escalate'],
semantic: 'observe',
deadline: 'P7D',
escalation: ['nudge'],
},
],
harness: {
memoryWindow: 20,
stallAfter: 'PT15M',
nudge: [{ after: 'PT10M', action: 'nudge' }],
memoryWindow: 22,
// 报价有效期 P-04 是 7 天,但这一单的**活办完**只需两个人环
stallAfter: 'PT2H',
nudge: [{ after: 'PT1H', action: 'nudge' }],
retry: { times: 2, backoff: 'PT30S' },
},
},
/* ── 资产初始版 ──────────────────────────────────────────────── */
assets: {
/* 资产初始版:案例模型(2026-07-29 起活动只有这一种业务模型) */
businessModel: {
cmmnXml: CMMN_XML,
narrative: NARRATIVE,
dmn: { drd: [], tables: [] },
dmn: N01_DMN,
steps: NEGOTIATE_STEPS,
phases: NEGOTIATE_PHASES,
},
skills: NEGOTIATE_SKILLS,
/*
* 两个局面各一张(与 A2/A3/A4 同款口径):
* · `negotiate-worksheet` 议价工作单 —— 排版、发出与落回音期间摆它(逐颗报价档案、
* 两把尺、填回工作表);
* · `negotiate-roster` 成交定案清单 —— 拍板、放行、归档摆它。
*/
viewTemplates: [
{ name: 'quote-composed', html: QUOTE_COMPOSED },
{ name: 'quote-release', html: QUOTE_RELEASE },
{ name: 'negotiate-worksheet', html: NEGOTIATE_WORKSHEET },
{ name: 'negotiate-roster', html: NEGOTIATE_ROSTER },
],
cardSchemas: {},
distillHooks: [{ on: 'terminal', prompt: '归纳本单议价的经验:客户的还价幅度、成交与否的关键因素。' }],
distillHooks: [
{
on: 'terminal',
prompt: '归纳本单议价:客户的还价幅度与说法、让步梯度哪一档最常命中、成交与不成交的关键因素。',
},
],
},
/* ── 样例集 —— 三条形状真不同(常备案例即验收案例)───────────── */
samples: [
{
name: '车规 Analog 报价',
input: { customerCode: 'C001', items: [{ pn: 'LM358DR', quotedPrice: 1.95 }] },
/*
* ① 流内常路:A4 `draft` 口的真实形状 —— items 里草案行(带批准价与门禁处置)
* 混着结论行。客户一轮就全接受,一个人都不必额外惊动 → won。
*/
name: '流内转来 · 两条已批准 · 客户一轮全接受(常路)',
input: {
rfqRef: 'RFQ MD14.1',
customerCode: 'CU-0142',
customer_name: 'Itelma',
items: [
{
rfq_ref: 'RFQ MD14.1',
pn: 'BBPY00201209601Y00',
customer_pn: '4-14-01502',
category: '滤波器',
brand: 'YAGEO',
eau: 30000,
lead_time: '8周',
type_tag: 'B',
quoted_price: 0.085,
disposition: 'PASS',
price_note: '客户长单,按目标价接',
},
{
rfq_ref: 'RFQ MD14.1',
pn: 'CJTB0101R6N',
customer_pn: '4-00-04102',
category: '集成电路',
brand: 'Jiangsu Changjing',
eau: 60000,
lead_time: '6周',
type_tag: 'A',
quoted_price: 0.42,
disposition: 'PASS',
},
{ rfq_ref: 'RFQ MD14.1', quote_line_total: 2, customer_name: 'Itelma', summary: '两条已批准,交议价' },
],
},
expect: { ports: ['won'] },
},
{
/*
* ② 客户压价:一条还了价、压得超过一成 —— 让步梯度判 ESCALATE(超出授权),
* 必须叫人拍板(决策卡);拍板选「退回成本定价重批」→ revise。
*/
name: '客户压价一成以上 · 决策卡拍板 → 退回定价重批',
input: {
rfqRef: 'RFQ-2026-0777',
customerCode: 'CU-0311',
customer_name: '青岛海洋电子',
items: [
{
rfq_ref: 'RFQ-2026-0777',
pn: 'PESD2ETH-XR',
category: '二极管',
brand: 'Nexperia',
eau: 30000,
lead_time: '8周',
type_tag: 'B',
quoted_price: 0.098,
disposition: 'NOTE',
price_note: '竞对价压,已让到底价线内',
remark: '客户明说这个价是竞对给的,要跟',
},
{
rfq_ref: 'RFQ-2026-0777',
pn: 'NE555P',
category: '定时器',
brand: 'TI',
eau: 5000,
lead_time: '4周',
type_tag: 'A',
quoted_price: 1.05,
disposition: 'PASS',
},
{ rfq_ref: 'RFQ-2026-0777', quote_line_total: 2, customer_name: '青岛海洋电子' },
],
},
expect: { ports: ['revise'] },
},
{
/*
* ③ 单飞宽进:没有单号没有清单,正文说了发给谁、报什么价 —— 归一造案号;
* 客户两轮催问都不回,按超时定案 → lost(下游 A7 情报归档)。
*/
name: '单飞需求 · 正文列两颗料 · 客户 D+7 无回复(超时定案)',
input: {
subject: 'MD14.2 报价函发客户',
body: '定价审批已通过:XCJ-7788T 电源模块 8K,批准价 14.80;LMR-3302QQ 稳压器 15K,批准价 2.35。客户是新客户 深圳市锐驰电子,联系人王工 wang@ruichi.example.cn,交期都按 6 周报。请发正式报价函并按 D+3/D+7 跟进。',
},
expect: { ports: ['lost'] },
},
],
entry: { file: 'index.js', checksum: 'dev-entry-quote-negotiate' },
+37 -23
View File
@@ -1,42 +1,56 @@
/**
* A6 技能 —— 议价与出单的方法经验(AM 08)。
* A6 技能 —— 议价的方法经验。载方法,不载权力。
*
* 与剧本的分工:剧本写「这一步做什么、写哪几列」,技能写「为什么这么谈、
* 拿不准时按哪条走」。剧本改了口径,技能里同一句要跟着改(规范 §5.9)。
*/
import { skillIdSchema } from '@contracts';
import type { Skill } from '@contracts';
export const NEGOTIATE_SKILLS: Skill[] = [
{
id: skillIdSchema.parse('negotiate.compose-quote'),
label: '拟一份能发出去的报价单',
applicability: '定价已过审批,要生成正式报价单时。',
id: skillIdSchema.parse('negotiate.read-reply'),
label: '读懂客户的那一句回音',
applicability: '客户回话了,要把它落成一个表态时。',
method: [
'报价单是**要发给客户**的东西,所以每一项都得能兑现:',
'· 单价与交期照审批通过的草案抄,不做任何「顺手优化」——改价要重新过审批点。',
'· SPQ / MOQ / 贸易条款 / 有效期缺一不可:缺了客户会追问,追问就是又一轮往返。',
'· 有效期按行情波动定,不要一律写「长期有效」——那等于把汇率与料价风险自己扛了。',
'客户的话落到系统里只有四种表态,别的都是这四种的说法之一:',
'· ACCEPT —— 明确接受这个价:「按这个价走」「下单吧」「PO 稍后发」。',
'· COUNTER —— 还了一个价,或明确说「贵了多少」「对标某某家便宜多少」。',
' **还价必须落下客户报的那个数**:没有数字的「太贵了」先追问,追不到才按无回复落。',
'· REJECT —— 明确不做了:选了别家、项目取消、规格不匹配。',
'· NOREPLY —— 催到有效期过仍不表态。**客户不回不是结论**:',
' D+3 第一轮催问确认收到探态度,D+7 最后一催,催过了才轮到按超时定案。',
'',
'拿不准的项**宁可空着走补全**,不要填一个看起来合理的值:',
'报价单一旦发出就是承诺,发出前的空白是成本,发出后的错误是损失。',
'两条别做的事:',
'· 不要替客户表态。他只回了一部分料的,没回的那几行就是 NOREPLY ——',
' 把「大概是接受了吧」写成 ACCEPT,是在系统里伪造一份客户承诺。',
'· 不要把口头承诺留在脑子里。每轮反馈当天记回备注,',
' 下一轮谈判、事后复盘、情报归档读的都是这一格。',
].join('\n'),
crystallized: [],
knowledgeRefs: ['报价单模版规范'],
version: '1.0.0',
knowledgeRefs: ['议价结果 N-01'],
version: '2.0.0',
},
{
id: skillIdSchema.parse('negotiate.pick-outcome'),
label: '选对议价出口',
applicability: '客户回复后,要确认议价结果出口时。',
id: skillIdSchema.parse('negotiate.concede'),
label: '让价守线',
applicability: '客户还了价,要决定让不让、让多少时。',
method: [
'三个出口对应三种后续,**恰发一口**:',
'· 成交 → 转订单履约,这条流走到终点。',
'· 有异议 → 回 A4 重开新实例重算 —— 注意是**新实例**,旧的已终、不再入算。',
'· 拒绝 / 超时 → 转 A7 情报归档:把拒绝原因与竞对价位登记下来。',
'先弄清争的是什么:价格、交期、MOQ,还是走过场压一轮 ——',
'**对症再谈,不上来就让**。争交期的用交期换,争 MOQ 的用包装换;',
'一上来就降价,后面每一轮就都只剩降价这一招。',
'',
'「拒绝」不是失败终态。这一单丢了,但拒绝原因回写历史情报后,',
'下一单的三分类与利润模型会用上它 —— 这才是归档存在的理由。',
'让步梯度 N-02 是尺,不是台阶(不必逐档走下去):',
'· 三个点以内 —— 意思意思的压价,接了拿单,来回一轮的时间成本比这三个点贵;',
'· 三到十个点 —— 授权内回旋:让一半再报回去,把让出去的换成量或账期;',
'· 超过一成 —— 超出授权,**交回成本定价重批,不私自破线**。',
' 破线不是勇气是事故:底价线以下的单,每补一次货都在亏。',
'',
'让了就要说清为什么让:客户长单、带单品、竞对压价 ——',
'理由随定案一起交给签字的人。没有理由的让价,到复盘那一关同样过不去。',
].join('\n'),
crystallized: [],
knowledgeRefs: [],
version: '1.0.0',
knowledgeRefs: ['让步梯度 N-02', '底价门禁 P-02'],
version: '2.0.0',
},
];
@@ -0,0 +1,409 @@
/**
* A1 两份执行页的样式表 —— **从 PRD 目标件原样搬过来**,不是我照着复述的。
*
* 出处:`~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理需求.html` 的整段 `<style>`,
* 外加 `A1-询价受理记录.html` 里归档件独有的那几件(分类分布条 / 类徽 / 判定表 /
* 结论横幅 / 落地动作 / 完成标)。**一个数值都没改**。
*
* 为什么是搬而不是抄:前三轮我用自己那套外壳「照着做」,块搬齐了、版面是两套 ——
* 章节头成了彩色方块编号、多出摘要与元信息带、字号用 clamp。人一眼就看出不是那份件。
* 目标件是**像素级的答案**,搬过来才不会在复述里走样。
*
* 选择器保留 `.proto-root .app-shell` 前缀,所以模版正文也要照原型那样包两层壳 ——
* 少一层壳,整张样式表一条都不生效(踩过)。
*/
export const PRD_CSS = `
/* ============================================================================
JCAI 员工面工作台 · A1 受理归一的过程成果「询价受理需求」——报告本体
立场:**AI 已从 RFQ 邮件抽取归一**,人在这里审核并就地修订。
两态:读态(AI 稿 + 红/灰占位提示缺项) ⇄ 编辑态(全字段可改,保存 / 取消)
核心是「有效 PN 清单」;清单之上四件受理要素:客户公司 / 联系人 / 客户属性 / 有效 PN。
令牌与既有组件取自 workbench/src/app/(proto)/app.css,数值未改。
⚠️ 修正一处原型 bug:app.css:2062-2064 的 '.jc-rp-sign span'(权重 0,1,1)
盖过了 '.jc-rp-ava'(0,1,0),把头像的 display:grid 覆盖成 block、字色改灰,
导致落款头像里的字掉到圆外。本文件把该规则收窄为 '.jc-rp-sign > div > span'。
新增件:四要素卡 .jc-four/.jc-fcard · 占位胶囊 .jc-ph · 分段控件 .jc-seg ·
来源条 .jc-src · 录入表 .jc-tbl-in
========================================================================== */
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { background: #e6eaf1; -webkit-font-smoothing: antialiased; }
.proto-root .app-shell {
--brand: #3370ff; --brand-deep: #245bdb;
--brand-soft: #e1eaff; --brand-wash: #f0f4ff;
--ai-grad: linear-gradient(135deg, #3370ff, #7f3bf5);
--ink: #1f2329; --ink-2: #41464e; --ink-3: #646a73; --ink-4: #8f959e;
--line: #dee0e3; --line-soft: #e8eaed; --soft: #f8f9fb;
--jcai-bg: #e6eaf1; --cardline: #e1e6ec; --card-shadow: 0 1px 2px rgba(31, 35, 41, .05);
--ok: #278717; --ok-soft: #e8f8e4;
--warn: #de7802; --warn-accent: #ff8800; --warn-soft: #fff4e5;
--danger: #d83931; --danger-soft: #feeae9;
--mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace;
--shadow-brand: 0 6px 16px rgba(51, 112, 255, .22);
--t-fast: .16s cubic-bezier(.3, 0, .2, 1);
--t-med: .26s cubic-bezier(.3, 0, .2, 1);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--jcai-bg); min-height: 100vh; color: var(--ink);
}
.proto-root :where(button, input, select, a, [tabindex]):focus-visible {
outline: 2px solid var(--brand); outline-offset: 2px; border-radius: 8px;
}
.proto-root ::selection { background: var(--brand-soft); }
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
@keyframes jcBlink { 0%,100% { opacity: 1 } 50% { opacity: .35 } }
@keyframes jcStamp { from { opacity: 0; transform: rotate(-6deg) scale(1.7); } to { opacity: .9; transform: rotate(-6deg) scale(1); } }
/* 放开宽度:1160px 是「9 列全展开、一个字都不裁」的最小容器宽度(见下方列 min-width 之和) */
.jc-stage { max-width: 1160px; margin: 0 auto; padding: 22px 16px 52px; }
.proto-root .jc-mono { font-family: var(--mono); font-size: 11px; color: var(--ink-3); }
.proto-root .jc-pulse-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--brand); display: inline-block; animation: jcBlink 1.1s ease-in-out infinite; }
/* ── 报告外壳 ───────────────────────────────────────────────────────── */
.proto-root .jc-report { background: #fff; border: 1px solid var(--cardline); border-radius: 14px; box-shadow: var(--card-shadow); overflow: hidden; animation: rise var(--t-med) both; }
.proto-root .jc-rp-head { position: relative; overflow: hidden; display: flex; align-items: flex-start; gap: 12px; padding: 16px 20px 13px; border-bottom: 2px solid var(--ink); }
/* 题头下缘粗细双线 = 正式件版式 */
.proto-root .jc-rp-head::after { content: ""; position: absolute; left: 0; right: 0; bottom: -4px; height: 1px; background: var(--line); }
/* 题头右上的品牌光晕:与工作台标题区同一签名件 */
.proto-root .jc-rp-head::before { content: ""; position: absolute; right: -70px; top: -96px; width: 240px; height: 240px; border-radius: 999px; background: radial-gradient(circle, rgba(51,112,255,.10), transparent 70%); pointer-events: none; }
.proto-root .jc-rp-head > * { position: relative; z-index: 1; }
.proto-root .jc-rp-badge { flex: none; width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand); }
.proto-root .jc-rp-tit { flex: 1; min-width: 0; }
.proto-root .jc-rp-tit em { display: block; font-style: normal; font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); font-family: var(--mono); }
.proto-root .jc-rp-tit h3 { margin: 4px 0 0; font-size: 18px; font-weight: 800; color: var(--ink); letter-spacing: -.01em; line-height: 1.3; }
.proto-root .jc-rp-wait { display: inline-flex; align-items: center; gap: 6px; flex: none; margin-top: 4px; font-size: 11px; font-weight: 700; border-radius: 999px; padding: 4px 12px; color: var(--brand-deep); background: var(--brand-wash); border: 1px solid var(--brand-soft); }
.proto-root .jc-rp-wait.warn { color: var(--warn); background: var(--warn-soft); border-color: #ffd9a8; }
.proto-root .jc-rp-wait.edit { color: #fff; background: var(--brand); border-color: var(--brand); }
.proto-root .jc-rp-stamp { flex: none; margin-top: 2px; transform: rotate(-6deg); border: 2px solid currentColor; border-radius: 9px; padding: 4px 8px 3px 12px; font-size: 13px; font-weight: 900; letter-spacing: .28em; color: var(--ok); opacity: .9; animation: jcStamp .32s cubic-bezier(.2, 1.4, .4, 1) both; }
.proto-root .jc-rp-bd { padding: 18px 20px 12px; }
.proto-root .jc-rp-part { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 9px; margin: 20px 0 11px; }
.proto-root .jc-rp-part:first-child { margin-top: 0; }
.proto-root .jc-rp-part > b { flex: none; font-size: 11px; font-weight: 800; letter-spacing: .12em; color: var(--ink-3); }
.proto-root .jc-rp-part > .ln { flex: 1; height: 1px; background: var(--line-soft); }
.proto-root .jc-rp-part > em { flex: none; font-style: normal; font-size: 10px; font-weight: 700; color: var(--ink-4); font-family: var(--mono); background: var(--soft); border-radius: 999px; padding: 2px 9px; white-space: nowrap; }
.proto-root .jc-rp-part > em.warn { color: var(--warn); background: var(--warn-soft); }
/* 落款 —— ⚠️ 选择器收窄到 > div > *,别再命中头像那个 span */
.proto-root .jc-rp-foot { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 13px 20px 15px; border-top: 2px solid var(--line); background: #fbfcfd; }
.proto-root .jc-rp-sign { display: flex; align-items: center; gap: 10px; flex: 1 1 200px; min-width: 0; }
.proto-root .jc-rp-ava { flex: 0 0 auto; width: 30px; height: 30px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; line-height: 1; color: #fff; background: var(--brand); }
.proto-root .jc-rp-sign > div { min-width: 0; }
.proto-root .jc-rp-sign > div > b { display: block; font-size: 12px; font-weight: 700; color: var(--ink-2); }
.proto-root .jc-rp-sign > div > span { display: block; margin-top: 1px; font-size: 10.5px; color: var(--ink-4); }
.proto-root .jc-rp-acts { display: flex; align-items: center; gap: 8px; margin-left: auto; flex: none; }
.proto-root .jc-hintline { font-size: 11px; font-weight: 700; color: var(--warn); line-height: 1.5; }
.proto-root .jc-btn { display: inline-flex; align-items: center; gap: 5px; border: 0; border-radius: 7px; padding: 8px 14px; font-size: 12px; font-weight: 600; font-family: inherit; background: var(--brand); color: #fff; cursor: pointer; transition: all var(--t-fast); }
.proto-root .jc-btn:hover:not(:disabled) { background: var(--brand-deep); }
.proto-root .jc-btn:disabled { opacity: .45; cursor: not-allowed; }
.proto-root .jc-btn-ghost { background: #fff; color: var(--ink-2); border: 1px solid var(--line); }
.proto-root .jc-btn-ghost:hover:not(:disabled) { background: var(--brand-wash); color: var(--brand); border-color: var(--brand-soft); }
.proto-root .jc-btn-primary { background: var(--ai-grad); }
.proto-root .jc-btn-primary:hover:not(:disabled) { filter: brightness(1.07); box-shadow: var(--shadow-brand); transform: translateY(-1px); }
/* ── 来源信件:客户 RFQ 邮件原文(复用原型 jc-letter 件,青色 = 收件) ── */
.proto-root .jc-letter { position: relative; border: 1px solid var(--cardline); border-left: 3px solid #0aa294; border-radius: 12px; padding: 14px 16px 15px; background: #fff; box-shadow: var(--card-shadow); }
.proto-root .jc-letter-h { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 11px; }
.proto-root .jc-letter-dir { display: inline-flex; align-items: center; gap: 5px; font-size: 10.5px; font-weight: 800; padding: 3px 9px; border-radius: 999px; background: #e4f8f5; color: #0a7f74; }
.proto-root .jc-letter-h .jc-chip { font-size: 10.5px; padding: 3px 9px; border-radius: 999px; background: var(--brand-wash); color: var(--brand-deep); border: 1px solid var(--brand-soft); }
.proto-root .jc-letter-h > code { margin-left: auto; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
.proto-root .jc-letter-meta { display: flex; flex-direction: column; gap: 4px; margin-bottom: 11px; padding-bottom: 11px; border-bottom: 1px solid var(--line-soft); font-size: 11.5px; color: var(--ink-2); }
.proto-root .jc-letter-meta p { margin: 0; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.proto-root .jc-letter-meta i { flex: none; font-style: normal; width: 44px; font-size: 10px; font-weight: 800; letter-spacing: .06em; color: var(--ink-4); }
.proto-root .jc-letter-meta code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); overflow-wrap: anywhere; }
.proto-root .jc-letter-sub { margin: 0 0 9px; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.45; overflow-wrap: anywhere; }
/* 正文:原文照录,pre-wrap 保原始换行;不截断、不折叠 */
.proto-root .jc-letter-body { margin: 0; font-size: 12.5px; line-height: 1.72; color: var(--ink-2); white-space: pre-wrap; overflow-wrap: anywhere; }
.proto-root .jc-letter-body b { color: var(--ink); font-weight: 700; }
.proto-root .jc-atts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; padding-top: 11px; border-top: 1px dashed var(--line-soft); }
.proto-root .jc-att { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px 5px 8px; border: 1px solid var(--brand-soft); border-radius: 8px; background: var(--brand-wash); font-size: 11.5px; color: var(--brand-deep); }
.proto-root .jc-att svg { flex: none; color: var(--brand); }
.proto-root .jc-att code { font-family: var(--mono); font-size: 10.5px; }
.proto-root .jc-att span { color: var(--ink-4); }
/* 抽取指向:从原文到下方要素的一句衔接 */
.proto-root .jc-derive { display: flex; align-items: flex-start; gap: 8px; margin-top: 11px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); font-size: 11.5px; line-height: 1.7; color: var(--brand-deep); }
.proto-root .jc-derive svg { flex: none; margin-top: 2px; }
/* ── 四要素卡 ───────────────────────────────────────────────────────────
色调字典:每张卡带一套 --tone / --tone-soft / --tone-line,组件只读变量。
卡 3、卡 4 的色调随数据变(老客户绿 / 新客户橙;齐了蓝 / 有缺橙),
所以这条要素带本身就是一行可读的状态,不只是四个格子。 */
.proto-root .jc-four { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 11px; }
.proto-root .jc-fcard {
position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-width: 0;
padding: 14px 14px 14px; border: 1px solid var(--cardline); border-radius: 13px; background: #fff;
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 8px 20px -14px rgba(31,35,41,.35);
transition: transform var(--t-fast), box-shadow var(--t-fast), border-color var(--t-fast);
--tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft);
}
/* 顶缘色棱 + 右上角光晕:卡的身份色,一眼分得清四件是四件 */
.proto-root .jc-fcard::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 3px; background: var(--tone); }
.proto-root .jc-fcard::after { content: ""; position: absolute; right: -46px; top: -52px; width: 132px; height: 132px; border-radius: 999px; background: radial-gradient(circle, var(--tone-soft), transparent 70%); pointer-events: none; }
.proto-root .jc-fcard > * { position: relative; z-index: 1; }
.proto-root .jc-fcard:hover { transform: translateY(-2px); border-color: var(--tone-line); box-shadow: 0 2px 4px rgba(31,35,41,.05), 0 14px 26px -16px rgba(31,35,41,.45); }
.proto-root .jc-fcard.t-teal { --tone: #0aa294; --tone-soft: rgba(10,162,148,.15); --tone-line: #9fe0d9; }
.proto-root .jc-fcard.t-ok { --tone: var(--ok); --tone-soft: rgba(39,135,23,.14); --tone-line: #bfedb4; }
.proto-root .jc-fcard.t-warn { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.12); --tone-line: #ffd9a8; }
.proto-root .jc-fcard-k { display: flex; align-items: center; gap: 6px; font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
.proto-root .jc-fcard-k > i { flex: none; width: 19px; height: 19px; border-radius: 6px; display: inline-grid; place-items: center; background: var(--tone-soft); color: var(--tone); }
.proto-root .jc-fcard-b { display: flex; align-items: center; gap: 10px; min-width: 0; }
.proto-root .jc-fcard-t { min-width: 0; flex: 1; }
/* 一律换行,不省略号——宁可多占一行,也不丢字 */
.proto-root .jc-fcard-t > b { display: block; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.35; overflow-wrap: anywhere; }
.proto-root .jc-fcard-t > span { display: block; margin-top: 2px; font-size: 10.5px; color: var(--ink-4); line-height: 1.55; overflow-wrap: anywhere; }
/* 公司标:圆角方 + AI 渐变 + 外圈光环(方=组织) */
.proto-root .jc-logo { flex: none; width: 38px; height: 38px; border-radius: 12px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; letter-spacing: .04em; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand), 0 0 0 3px rgba(51,112,255,.10); }
/* 人像:圆 + 青色系(圆=人,与方形的组织标一眼分开) */
.proto-root .jc-face { flex: none; width: 38px; height: 38px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; color: #0a7f74; background: #e4f8f5; border: 1px solid #9fe0d9; box-shadow: 0 0 0 3px rgba(10,162,148,.10); }
/* 邮箱做成可点的胶囊,复制按钮嵌在里面——比一行裸文字有抓手 */
/* 邮箱:软底胶囊、无描边——有描边会读成输入框,而读态这里不是可填的 */
.proto-root .jc-mailrow { display: flex; align-items: center; gap: 4px; margin-top: 5px; min-width: 0; max-width: 100%; padding: 3px 4px 3px 9px; border: 0; border-radius: 8px; background: var(--tone-soft); transition: background var(--t-fast); }
.proto-root .jc-mailrow:hover { background: rgba(10,162,148,.2); }
/* 编辑态里它包着输入框,软底会变成「框中框」,去掉 */
.proto-root .jc-report.editing .jc-mailrow { background: transparent; padding: 0; margin-top: 6px; }
.proto-root .jc-mailrow > code { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; line-height: 1.5; font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
.proto-root .jc-copy { flex: none; width: 22px; height: 22px; display: inline-grid; place-items: center; border: 0; border-radius: 6px; background: #fff; color: var(--ink-4); cursor: pointer; box-shadow: 0 1px 2px rgba(31,35,41,.08); transition: all var(--t-fast); }
.proto-root .jc-copy:hover { color: #0aa294; }
.proto-root .jc-copy.ok { color: var(--ok); background: var(--ok-soft); box-shadow: none; }
/* 客户属性徽章 */
.proto-root .jc-tagbig { display: inline-flex; align-items: center; gap: 6px; align-self: flex-start; padding: 7px 14px; border-radius: 999px; font-size: 13.5px; font-weight: 800; line-height: 1; color: var(--tone); background: var(--tone-soft); border: 1px solid var(--tone-line); }
.proto-root .jc-fcard-note { font-size: 10.5px; color: var(--ink-4); line-height: 1.55; }
.proto-root .jc-fcard-note b { color: var(--ink-2); font-weight: 700; }
.proto-root .jc-fcard-note b.bad { color: var(--tone); font-weight: 800; }
/* 成交迷你柱:把「台账 6 笔成交」从一句话变成看得见的节奏 */
.proto-root .jc-spark { display: flex; align-items: flex-end; gap: 4px; height: 26px; }
.proto-root .jc-spark > i { flex: 1 1 0; min-width: 4px; max-width: 15px; border-radius: 3px 3px 1px 1px; background: var(--tone); opacity: .3; transition: opacity var(--t-fast), height var(--t-med); }
.proto-root .jc-spark > i:last-child { opacity: 1; }
.proto-root .jc-spark:hover > i { opacity: .55; }
.proto-root .jc-spark:hover > i:last-child { opacity: 1; }
.proto-root .jc-spark.empty { align-items: stretch; }
.proto-root .jc-spark.empty > i { height: 100%; opacity: 1; background: repeating-linear-gradient(45deg, var(--tone-soft) 0 5px, transparent 5px 10px); border: 1px dashed var(--tone-line); border-radius: 5px; }
/* 有效 PN 计数 */
.proto-root .jc-count { display: flex; align-items: baseline; gap: 6px; }
.proto-root .jc-count > b { font-family: var(--mono); font-size: 32px; font-weight: 800; line-height: .95; color: var(--ink); letter-spacing: -.04em; }
.proto-root .jc-count > span { font-size: 11px; color: var(--ink-4); }
/* 分段刻度条:一格 = 一条 PN,缺必填的那格直接标红——比一根实心进度条信息量大 */
.proto-root .jc-segmeter { display: flex; gap: 3px; }
.proto-root .jc-segmeter > i { flex: 1; height: 7px; border-radius: 2px; background: var(--ok); transition: background var(--t-fast); }
.proto-root .jc-segmeter > i.bad { background: var(--danger); }
.proto-root .jc-lockhint { display: inline-flex; align-items: center; gap: 4px; font-size: 10px; color: var(--ink-4); }
/* ── 占位胶囊:必填=红虚线,选填=灰虚线;**都带文字**,不靠颜色单打 ── */
.proto-root .jc-ph { display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700; line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed; }
.proto-root .jc-ph.req { color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
.proto-root .jc-ph.opt { color: var(--ink-4); background: var(--soft); border-color: var(--line); }
/* ── 分段控件(新客户 / 老客户)── */
.proto-root .jc-seg { display: inline-flex; padding: 2px; gap: 2px; border: 1px solid var(--cardline); border-radius: 9px; background: var(--soft); align-self: flex-start; }
.proto-root .jc-seg button { border: 0; border-radius: 7px; padding: 6px 13px; font: inherit; font-size: 11.5px; font-weight: 700; color: var(--ink-3); background: transparent; cursor: pointer; transition: all var(--t-fast); }
.proto-root .jc-seg button:hover:not(.on) { color: var(--brand); }
.proto-root .jc-seg button.on { background: #fff; color: var(--ink); box-shadow: 0 1px 3px rgba(31,35,41,.12); }
/* ── 表:读态与编辑态共用骨架 ─────────────────────────────────────── */
.proto-root .jc-tbl-wrap { overflow-x: auto; border: 1px solid var(--line-soft); border-radius: 10px; }
.proto-root .jc-tbl-in { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; }
.proto-root .jc-tbl-in th { background: var(--soft); text-align: left; padding: 8px 9px; font-size: 10.5px; font-weight: 800; letter-spacing: .03em; color: var(--ink-3); white-space: nowrap; border-bottom: 1px solid var(--line-soft); }
.proto-root .jc-tbl-in th.req::after { content: "*"; margin-left: 1px; color: var(--danger); font-size: 10px; }
.proto-root .jc-tbl-in td { padding: 4px 5px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
.proto-root .jc-tbl-in tr:last-child td { border-bottom: 0; }
.proto-root .jc-tbl-in tbody tr:hover td { background: #f7f9fd; }
/* 缺必填的整行浅红打底 + 行首红标——不用找红胶囊,一眼扫到是哪几行 */
.proto-root .jc-tbl-in tr.miss td { background: #fffbfb; }
.proto-root .jc-tbl-in tbody tr.miss:hover td { background: #fff5f5; }
.proto-root .jc-tbl-in td.jc-rn { position: relative; width: 38px; padding: 4px 6px 4px 13px; white-space: nowrap; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); font-weight: 800; }
/* 行首红标只在表态用 ::after —— 卡片态的 ::after 被「第 N 条」占着,不能抢 */
@media (min-width: 961px) {
.proto-root .jc-tbl-in tr.miss td.jc-rn::after { content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger); }
}
.proto-root .jc-rndot { display: inline-block; width: 5px; height: 5px; margin-left: 4px; border-radius: 999px; background: var(--danger); vertical-align: 2px; }
/* 读态单元格 */
.proto-root .jc-vcell { display: block; padding: 6px 6px; font-size: 12px; color: var(--ink); line-height: 1.5; overflow-wrap: anywhere; }
.proto-root .jc-vcell.mono { font-family: var(--mono); font-size: 11px; color: var(--ink-2); }
.proto-root .jc-vcell.num { font-family: var(--mono); font-size: 11.5px; text-align: right; }
/* 编辑态单元格:安静的电子表格 */
.proto-root .jc-cell { width: 100%; min-width: 64px; border: 1px solid var(--line); border-radius: 6px; padding: 6px 7px; font: inherit; font-size: 12px; color: var(--ink); background: #fff; transition: border-color var(--t-fast), box-shadow var(--t-fast); }
.proto-root .jc-cell.mono { font-family: var(--mono); font-size: 11px; }
.proto-root .jc-cell.num { text-align: right; font-family: var(--mono); font-size: 11.5px; }
.proto-root .jc-cell::placeholder { color: var(--ink-4); }
.proto-root .jc-cell:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-wash); outline: none; }
.proto-root .jc-cell.req-empty { border-color: #f3b6b3; background: #fffafa; }
.proto-root .jc-cell.req-empty::placeholder { color: var(--danger); }
/* 备注这类长文本用文本域:能换行、随内容长高,不裁字 */
.proto-root textarea.jc-cell { display: block; resize: vertical; overflow: hidden; min-height: 31px; line-height: 1.55; }
/* 编辑态的整表提示带 */
.proto-root .jc-editbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); border: 1px solid var(--brand-soft); font-size: 11.5px; color: var(--brand-deep); }
.proto-root .jc-editbar svg { flex: none; }
.proto-root .jc-editbar .sp { flex: 1 1 auto; }
/* 剔除行说明 */
.proto-root .jc-drop { display: flex; align-items: flex-start; gap: 8px; margin-top: 10px; padding: 9px 12px; border-radius: 9px; border: 1px dashed var(--line); background: var(--soft); font-size: 11.5px; color: var(--ink-3); line-height: 1.6; }
.proto-root .jc-drop svg { flex: none; margin-top: 2px; color: var(--ink-4); }
.proto-root .jc-drop code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
.proto-root .jc-callout { display: flex; gap: 8px; align-items: flex-start; border-radius: 9px; padding: 11px 13px; font-size: 12.5px; line-height: 1.75; }
.proto-root .jc-callout p { margin: 0; }
.proto-root .jc-callout svg { flex: none; margin-top: 2px; }
.proto-root .jc-callout.t-mute { background: var(--soft); color: var(--ink-2) }
@media (prefers-reduced-motion: reduce) {
.proto-root .jc-pulse-dot { animation: none; }
.proto-root .jc-report, .proto-root .jc-rp-stamp { animation: none; }
}
/* ══════════════════════════════════════════════════════════════════════════
响应式:宁可另起一行,也不损失完整度
≥961px 六列全展开的表(列 min-width 之和 878px,容器有余量,绝不裁字、绝不横滚)
≤960px 表 → 卡片:一条 PN 一张卡、一行一字段(标签 / 值),无横滚、无省略
≤560px 字段行再拆成上下两行(标签在上、值在下)
══════════════════════════════════════════════════════════════════════════ */
@media (max-width: 1040px) { .proto-root .jc-four { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 560px) { .proto-root .jc-four { grid-template-columns: 1fr; } }
@media (max-width: 960px) {
.proto-root .jc-tbl-wrap { overflow: visible; border: 0; border-radius: 0; background: transparent; }
.proto-root .jc-tbl-in,
.proto-root .jc-tbl-in tbody,
.proto-root .jc-tbl-in tr,
.proto-root .jc-tbl-in td { display: block; width: 100%; }
.proto-root .jc-tbl-in thead { display: none; }
/* 一条 PN = 一张卡 */
.proto-root .jc-tbl-in tr { margin-bottom: 10px; padding: 4px 13px 9px; border: 1px solid var(--line-soft); border-radius: 11px; background: #fff; box-shadow: var(--card-shadow); }
.proto-root .jc-tbl-in tr:last-child { margin-bottom: 0; }
.proto-root .jc-tbl-in tbody tr:hover td { background: transparent; }
.proto-root .jc-tbl-in tr.miss { border-color: #f3b6b3; background: #fffbfb; }
/* 卡头:第 N 条 */
.proto-root .jc-tbl-in td.jc-rn { padding: 8px 0 8px; margin-bottom: 4px; border-bottom: 1px solid var(--line-soft); font-family: inherit; font-size: 12px; font-weight: 800; color: var(--ink-2); }
.proto-root .jc-tbl-in td.jc-rn::before { content: "第 "; }
.proto-root .jc-tbl-in td.jc-rn::after { content: " 条"; }
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); }
/* 字段行:左标签 + 右值,值超长自动折行 */
.proto-root .jc-tbl-in td:not(.jc-rn) { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 10px; align-items: center; padding: 6px 0; border-bottom: 1px dashed var(--line-soft); }
.proto-root .jc-tbl-in tr td:last-child { border-bottom: 0; padding-bottom: 2px; }
.proto-root .jc-tbl-in td:not(.jc-rn)::before {
content: attr(data-h); font-size: 10.5px; font-weight: 700; letter-spacing: .02em; color: var(--ink-4); line-height: 1.5;
}
.proto-root .jc-tbl-in td[data-req="1"]:not(.jc-rn)::before { content: attr(data-h) " *"; }
.proto-root .jc-tbl-in .jc-vcell { padding: 0; overflow-wrap: anywhere; }
.proto-root .jc-tbl-in .jc-vcell.num { text-align: left; }
.proto-root .jc-tbl-in .jc-cell.num { text-align: left; }
.proto-root .jc-tbl-in .jc-cell { min-width: 0; }
}
/* 窄屏:落款三件各占一行,动作按钮撑满一行 */
@media (max-width: 560px) {
.proto-root .jc-rp-acts { margin-left: 0; width: 100%; flex-wrap: wrap; }
.proto-root .jc-rp-acts .jc-btn { flex: 1 1 auto; justify-content: center; }
.proto-root .jc-hintline { flex: 1 1 100%; }
.proto-root .jc-tbl-in td:not(.jc-rn) { grid-template-columns: 1fr; gap: 3px; align-items: start; }
}
/* ══ 以下取自 A1-询价受理记录.html(归档件独有的件)══ */
/* ── 料号分类:分布条 + 图例 ───────────────────────────────────────── */
.proto-root .jc-dist { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 13px 15px; border: 1px solid var(--line-soft); border-radius: 12px; background: linear-gradient(180deg, #fbfcfe, #fff); }
.proto-root .jc-distbar { flex: 1 1 320px; min-width: 260px; display: flex; gap: 3px; }
.proto-root .jc-distbar > i { height: 30px; border-radius: 4px; display: grid; place-items: center; font-size: 10.5px; font-weight: 800; color: #fff; font-family: var(--mono); }
.proto-root .jc-distbar > i.a { background: var(--ok); }
.proto-root .jc-distbar > i.b { background: var(--brand); }
.proto-root .jc-distbar > i.c { background: var(--warn-accent); }
.proto-root .jc-distlg { display: flex; flex-wrap: wrap; gap: 8px 18px; }
.proto-root .jc-distlg > span { display: inline-flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--ink-3); }
.proto-root .jc-distlg em { width: 10px; height: 10px; border-radius: 3px; }
.proto-root .jc-distlg em.a { background: var(--ok); } .proto-root .jc-distlg em.b { background: var(--brand); } .proto-root .jc-distlg em.c { background: var(--warn-accent); }
.proto-root .jc-distlg b { font-family: var(--mono); font-weight: 800; color: var(--ink); }
/* 类别徽 A / B / C */
.proto-root .jc-tbadge { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 6px; font-family: var(--mono); font-size: 11px; font-weight: 800; line-height: 1; color: #fff; }
.proto-root .jc-tbadge.a { background: var(--ok); } .proto-root .jc-tbadge.b { background: var(--brand); } .proto-root .jc-tbadge.c { background: var(--warn-accent); }
/* ── 结论与去向 ─────────────────────────────────────────────────────────
G2 是**分支网关**:三条互斥条件按序求值,只命中一条、只落一个活动。
所以这里不是三张并列的去向卡,而是一张**判定表 + 一条结论**。 */
/* 判定表:一行一条规则,命中行上色留痕(DMN 口径) */
.proto-root .jc-dmn { border: 1px solid var(--line-soft); border-radius: 12px; overflow: hidden; }
.proto-root .jc-dmn-h, .proto-root .jc-dmn-r { display: grid; grid-template-columns: 40px minmax(0, 1fr) 170px 200px; align-items: center; gap: 12px; padding: 9px 14px; }
.proto-root .jc-dmn-h { background: var(--soft); border-bottom: 1px solid var(--line-soft); font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); }
.proto-root .jc-dmn-r { font-size: 12px; color: var(--ink-4); background: #fff; }
.proto-root .jc-dmn-r + .jc-dmn-r { border-top: 1px solid var(--line-soft); }
.proto-root .jc-dmn-i { font-family: var(--mono); font-size: 11px; font-weight: 800; }
.proto-root .jc-dmn-c { line-height: 1.6; overflow-wrap: anywhere; }
.proto-root .jc-dmn-t { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; }
.proto-root .jc-dmn-t code { font-family: var(--mono); font-size: 10.5px; font-weight: 800; color: var(--ink-4); background: var(--soft); border: 1px solid var(--line); border-radius: 5px; padding: 2px 6px; }
.proto-root .jc-dmn-v { font-size: 11px; line-height: 1.55; }
/* 命中行:上色 + 左缘棱 + 加粗 */
.proto-root .jc-dmn-r.hit { position: relative; background: var(--tone-soft); color: var(--ink-2); }
.proto-root .jc-dmn-r.hit::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--tone); }
.proto-root .jc-dmn-r.hit .jc-dmn-i { color: var(--tone); }
.proto-root .jc-dmn-r.hit .jc-dmn-c { color: var(--ink); }
.proto-root .jc-dmn-r.hit .jc-dmn-c b { color: var(--tone); font-weight: 800; }
.proto-root .jc-dmn-r.hit .jc-dmn-t { color: var(--ink); }
.proto-root .jc-dmn-r.hit .jc-dmn-t code { color: #fff; background: var(--tone); border-color: var(--tone); }
.proto-root .jc-dmn-v.y { display: inline-flex; align-items: center; gap: 5px; font-weight: 800; color: var(--tone); }
.proto-root .jc-dmn-v.n { color: var(--ink-4); }
/* 色调随命中类别 */
.proto-root .jc-dmn.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.10); }
.proto-root .jc-dmn.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.09); }
.proto-root .jc-dmn.k-c { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.11); }
/* 结论横幅:命中条件 → 唯一去向 */
.proto-root .jc-verdict {
position: relative; overflow: hidden; display: flex; align-items: stretch; flex-wrap: wrap; gap: 16px;
margin-top: 11px; padding: 16px 18px; border: 1px solid var(--tone-line); border-radius: 13px;
background: linear-gradient(105deg, var(--tone-soft), #fff 62%);
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 10px 24px -16px rgba(31,35,41,.4);
--tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.14); --tone-line: #ffd9a8;
}
.proto-root .jc-verdict.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.13); --tone-line: #bfedb4; }
.proto-root .jc-verdict.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft); }
.proto-root .jc-verdict::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 5px; background: var(--tone); }
.proto-root .jc-verdict > * { position: relative; z-index: 1; }
.proto-root .jc-vd-l { flex: 1 1 300px; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
.proto-root .jc-vd-k { font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
.proto-root .jc-vd-cond { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 15px; font-weight: 800; color: var(--ink); }
.proto-root .jc-vd-why { margin: 0; font-size: 11.5px; line-height: 1.7; color: var(--ink-3); overflow-wrap: anywhere; }
.proto-root .jc-vd-why code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
.proto-root .jc-vd-arrow { flex: none; align-self: center; color: var(--tone); }
.proto-root .jc-vd-r { flex: 1 1 260px; min-width: 0; display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-radius: 11px; background: #fff; border: 1px solid var(--tone-line); }
.proto-root .jc-vd-node { display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 800; color: var(--ink); }
.proto-root .jc-vd-node code { font-family: var(--mono); font-size: 12px; font-weight: 800; color: #fff; background: var(--tone); border-radius: 6px; padding: 3px 8px; }
.proto-root .jc-vd-who { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--ink-2); }
.proto-root .jc-vd-who > i { width: 20px; height: 20px; border-radius: 999px; display: inline-grid; place-items: center; font-style: normal; font-size: 10px; font-weight: 700; color: #fff; background: var(--tone); }
.proto-root .jc-vd-next { margin: 0; padding-top: 8px; border-top: 1px dashed var(--line-soft); font-size: 10.5px; line-height: 1.65; color: var(--ink-4); }
.proto-root .jc-vd-next b { color: var(--ink-2); font-weight: 700; }
/* 命中分支携带的条目 */
.proto-root .jc-pnlist { display: flex; flex-wrap: wrap; gap: 5px; margin: 0; padding: 0; list-style: none; }
.proto-root .jc-pnlist > li { display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 999px; background: #fff; border: 1px solid var(--tone-line); font-size: 11px; color: var(--ink-2); overflow-wrap: anywhere; }
.proto-root .jc-pnlist > li > i { flex: none; width: 5px; height: 5px; border-radius: 999px; background: var(--tone); }
.proto-root .jc-pnlist code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
/* ── 落地动作 ───────────────────────────────────────────────────────── */
.proto-root .jc-acts { display: flex; flex-direction: column; gap: 0; border: 1px solid var(--line-soft); border-radius: 11px; overflow: hidden; }
.proto-root .jc-act { display: flex; align-items: flex-start; gap: 9px; padding: 10px 13px; font-size: 12px; color: var(--ink-2); line-height: 1.6; background: #fff; }
.proto-root .jc-act + .jc-act { border-top: 1px solid var(--line-soft); }
.proto-root .jc-act > i { flex: none; width: 18px; height: 18px; margin-top: 1px; border-radius: 999px; display: inline-grid; place-items: center; color: #fff; background: var(--ok); }
.proto-root .jc-act b { font-weight: 700; color: var(--ink); }
.proto-root .jc-act span { color: var(--ink-4); }
.proto-root .jc-act code { font-family: var(--mono); font-size: 10.5px; color: var(--brand-deep); background: var(--brand-wash); border-radius: 4px; padding: 1px 5px; }
.proto-root .jc-done-tag { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; flex: none; padding: 6px 13px; border-radius: 999px; font-size: 11.5px; font-weight: 800; color: var(--ok); background: #fff; border: 1px solid #bfedb4; }
.proto-root .jc-vcell.dim { color: var(--ink-4); }
.proto-root .jc-tbl-in td.jc-ty { width: 44px; text-align: center; }
.proto-root .jc-tbl-in td.jc-ty { width: auto; text-align: left; }
`;
/**
* 图标精灵 —— 与样式表同一出处,两份目标件的 `<symbol>` 取并集。
* 沙箱里取不到图标字体,所以图标一律内联;`<use href="#i-xxx">` 引的就是这一坨。
*/
export const PRD_SPRITE = `<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs><symbol id="i-alert" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></symbol><symbol id="i-arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol><symbol id="i-boxes" viewBox="0 0 24 24"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></symbol><symbol id="i-building" viewBox="0 0 24 24"><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/><path d="M10 18h4"/></symbol><symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol><symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol><symbol id="i-file-check" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="m9 15 2 2 4-4"/></symbol><symbol id="i-file-text" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></symbol><symbol id="i-git-branch" viewBox="0 0 24 24"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></symbol><symbol id="i-history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></symbol><symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></symbol><symbol id="i-lock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol><symbol id="i-mail-open" viewBox="0 0 24 24"><path d="M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0z"/><path d="m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10"/></symbol><symbol id="i-pen" viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z"/></symbol><symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol><symbol id="i-sheet" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M8 13h2"/><path d="M14 13h2"/><path d="M8 17h2"/><path d="M14 17h2"/></symbol><symbol id="i-shield-check" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></symbol><symbol id="i-user" viewBox="0 0 24 24"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/></symbol><symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></symbol></defs></svg>`;
+712 -42
View File
@@ -1,50 +1,720 @@
/**
* A6 视图模版 —— 议价两屏:正式报价单 / 待放行即发出(AM 07)。
* 这是**唯一会触达外界**的活动:放行一刻要发信、要回写台账,所以那一屏必须说清后果。
* A6 报价议价的 HTML 报告模版 —— **活动自己持有的资产**,随包版本冻结。
*
* 两个局面各一张(与 A2/A3/A4 同款口径):
* · `negotiate-worksheet` **议价工作单** —— 排版、发出与落客户回音期间摆它:
* 逐颗料一张报价档案卡(条款在左、议价在右)、议价结果 N-01 与让步梯度 N-02 两把尺、
* 填回工作表就长在同一屏;
* · `negotiate-roster` **成交定案清单** —— 异议拍板、放行与归档摆它:整份报价函,
* 定案分布、去向落边图与落地动作在上面。
*
* 「1:1」的落点是**每份件与它的办结快照**(在办可编辑 ↔ 回执收下那一刻的 finalHtml),
* 不是全程只许一张:作业的件与收口的件是两件工作两份件(规范姊妹篇 §1)。
*
* 版面语言沿用 A1 从 PRD 目标件机械抽取的那套(`prd-style.ts` 原样拷入)——
* A6 没有自己的 PRD 目标件,与 A2/A3/A4 同一处置:沿用家族版面自造。
*
* 取值三纪律:顶层只取汇总列与单值列;逐条走 `mine:quote_negotiate.line`;
* 列名逐字照 `declarations.ledger` 抄。**模版不算不判** —— 缺表态红带、定案徽、分布条、
* 落边图全部由基座算好的数经 `data-class-of` 投影,判断留在样式表。
*/
import { kvCard, noteCard, tableCard, viewShell } from '@sdk';
import { PRD_CSS, PRD_SPRITE } from './prd-style.ts';
export const QUOTE_COMPOSED = viewShell({
title: '正式报价单',
kicker: 'A6 · 报价议价',
tone: 'brand',
/* ==================== 绑定期补的样式(照 A1/A3/A4 的 BIND_CSS,地址换成本活动)==================== */
const BIND_CSS = `
/* 空占位三级(2026-08-02 全局口径):必填红 / 建议填橘 / 选填灰 —— 值真的空才轮到 */
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after,
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after,
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after {
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
}
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after { content: "必填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after { content: "建议填"; color: var(--warn-accent); background: var(--warn-soft); border-color: #f0cf9a; }
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after { content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line); }
/* 归档件里空格子就是「没有」,不再摆「你还能填」的胶囊 */
.proto-root .jc-tbl-in td.dimempty > .jc-vcell:empty::after { content: "—"; color: var(--ink-4); border: 0; background: none; padding: 0; }
/* 缺客户表态的整行浅红 + 行首红条 —— 原型的 tr.miss,这里由 :has() 认 */
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td { background: #fffbfb; }
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty):hover td { background: #fff5f5; }
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn { color: var(--danger); font-weight: 800; position: relative; }
@media (min-width: 1001px) {
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn::after {
content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger);
}
}
/* 序号由 CSS 计数器排 */
.proto-root .jc-tbl-in tbody { counter-reset: rn; }
.proto-root .jc-tbl-in td.jc-rn::before { counter-increment: rn; content: counter(rn); }
@media (max-width: 1000px) {
.proto-root .jc-tbl-in td.jc-rn::before { content: "第 " counter(rn) " 条"; }
.proto-root .jc-tbl-in td.jc-rn::after { content: none; }
}
/* 就地改:可改格子渲了只读与输入两份,默认锁着,表上加 on 才换孪生件 */
.proto-root .jc-cell { display: none; }
.proto-root .jc-tbl-in.on td > .jc-vcell,
.proto-root .jc-tbl-in.on td > .jc-tbadge { display: none; }
.proto-root .jc-tbl-in.on td > .jc-cell { display: block; }
/* 类徽 / 表态徽 / 定案徽不进编辑态(承继与引擎产物),on 时也照旧摆着 */
.proto-root .jc-tbl-in.on td > .jc-obadge,
.proto-root .jc-tbl-in.on td > .jc-dbadge { display: inline-flex; }
/* 数值列与表头同侧左对齐(一行一个料,彼此不相加) */
.proto-root .jc-vcell.num, .proto-root .jc-cell.num { text-align: left; }
/* 编辑态下必填空格子:红框 + 整行浅红(由 :placeholder-shown 认) */
.proto-root .jc-tbl-in.on td.req > .jc-cell:placeholder-shown { border-color: #f3b6b3; background: #fffafa; }
.proto-root .jc-tbl-in.on tbody tr:has(td.req > .jc-cell:placeholder-shown) td { background: #fffbfb; }
/* 铺满工作区 */
.jc-stage { max-width: none; padding: 0 0 4px; }
.proto-root .jc-report { border: 0; border-radius: 0; box-shadow: none; }
/* 那道闸 —— 默认「已锁定 + 编辑」,开了变成「取消 / 保存」;只在可改屏现身 */
.proto-root .jc-editgate { display: none; }
html.editable .proto-root .jc-editgate {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
margin-bottom: 10px; padding: 8px 12px; border-radius: 9px;
background: var(--soft); border: 1px solid var(--line-soft);
font-size: 11.5px; color: var(--ink-3);
}
.proto-root .jc-editgate-t { display: inline-flex; align-items: center; gap: 6px; flex: 1 1 auto; min-width: 0; }
.proto-root .jc-editgate-t b { color: var(--ink-2); font-weight: 700; }
.proto-root .jc-egbtn {
flex: none; display: inline-flex; align-items: center; gap: 4px; cursor: pointer;
border: 1px solid var(--line); border-radius: 7px; padding: 5px 11px;
font: inherit; font-size: 11.5px; font-weight: 700; color: var(--ink-2); background: #fff;
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
}
.proto-root .jc-egbtn:hover { border-color: var(--brand); color: var(--brand); }
.proto-root .jc-egbtn.save { color: #fff; background: var(--brand); border-color: var(--brand); }
.proto-root .jc-egbtn.save:hover { background: var(--brand-deep); border-color: var(--brand-deep); }
.proto-root .jc-editgate .jc-egbtn.ghost, .proto-root .jc-editgate .jc-egbtn.save { display: none; }
.proto-root .jc-editgate.on { background: var(--brand-wash); border-color: var(--brand-soft); color: var(--brand-deep); }
.proto-root .jc-editgate.on .jc-egbtn.open { display: none; }
.proto-root .jc-editgate.on .jc-egbtn.ghost, .proto-root .jc-editgate.on .jc-egbtn.save { display: inline-flex; }
.proto-root .jc-editgate.on .jc-editgate-t { visibility: hidden; }
/* 缺表态红带 —— 只在可改屏出,一格不缺自动收(v-0 那一档 display:none) */
.proto-root .jc-gapbar { display: none; }
html.editable .proto-root .jc-gapbar {
display: flex; align-items: flex-start; gap: 8px;
margin: 0 0 10px; padding: 9px 12px; border-radius: 9px;
background: #fff5f5; border: 1px solid #f3b6b3; color: var(--danger);
font-size: 12px; line-height: 1.65;
}
.proto-root .jc-gapbar svg { flex: 0 0 auto; margin-top: 2px; }
.proto-root .jc-gapbar b { font-weight: 800; }
.proto-root .jc-gapbar span { color: #9a3b36; }
html.editable .proto-root .jc-gapbar.v-0 { display: none; }
/* 那条「怎么改」的提示只在开了闸之后说 */
.proto-root .jc-editbar { display: none; }
.proto-root .jc-editgate.on + .jc-editbar { display: flex; }
/* 增删行 */
.proto-root .jc-rowdel { display: none; }
.proto-root .jc-tbl-in.on td.jc-rn::before { content: none; }
.proto-root .jc-tbl-in.on .jc-rowdel {
display: block; width: 20px; height: 20px; margin: 0 auto; cursor: pointer;
border: 1px solid var(--line); border-radius: 6px; padding: 0;
font: inherit; font-size: 13px; line-height: 1; color: var(--ink-3); background: #fff;
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
}
.proto-root .jc-tbl-in.on .jc-rowdel:hover { border-color: var(--danger); color: var(--danger); background: #fff6f6; }
.proto-root .jc-tbl-in.on tr.jc-row-gone td { background: #fff6f6; opacity: .55; }
.proto-root .jc-tbl-in.on tr.jc-row-gone .jc-cell { text-decoration: line-through; color: var(--ink-3); }
.proto-root .jc-tbl-in.on tr.jc-row-gone .jc-rowdel { border-color: var(--danger); color: var(--danger); background: #fff; }
.proto-root .jc-tbl-in.on tr.jc-row-new td { background: #f2fbf7; }
.proto-root .jc-addrow { display: none; }
html.editable .proto-root .jc-tbl-wrap:has(table.on) + .jc-addrow {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin: -2px 0 12px; padding: 7px 12px; border-radius: 9px;
background: var(--soft); border: 1px dashed var(--line);
}
.proto-root .jc-addrow em { font-style: normal; font-size: 11px; color: var(--ink-3); }
/* 类徽按值上色(承继上游的三分类) */
.proto-root .jc-tbadge { background: var(--ink-4); }
.proto-root .jc-tbadge.v-a { background: var(--ok); }
.proto-root .jc-tbadge.v-b { background: var(--brand); }
.proto-root .jc-tbadge.v-c { background: var(--warn-accent); }
.proto-root .jc-segmeter > i.v-won { background: var(--ok); }
.proto-root .jc-segmeter > i.v-haggle { background: var(--warn-accent); }
.proto-root .jc-segmeter > i.v-revise { background: var(--brand); }
.proto-root .jc-segmeter > i.v-lost { background: var(--ink-4); }
/* 客户表态徽:接受绿 / 还价橙 / 拒绝红 / 无回复灰 —— 值是机器码,人读名交给样式表 */
.proto-root .jc-obadge {
display: inline-flex; align-items: center; justify-content: center;
min-width: 52px; height: 20px; padding: 0 7px; border-radius: 6px;
font-size: 10px; font-weight: 800; letter-spacing: .02em;
color: var(--ink-4); border: 1.5px dashed var(--ink-4); background: #fff;
}
.proto-root .jc-obadge::after { content: "待回音"; }
.proto-root .jc-obadge.v-accept { color: var(--ok); border-color: var(--ok); border-style: solid; }
.proto-root .jc-obadge.v-accept::after { content: "接受"; }
.proto-root .jc-obadge.v-counter { color: var(--warn-accent); border-color: var(--warn-accent); border-style: solid; }
.proto-root .jc-obadge.v-counter::after { content: "还价"; }
.proto-root .jc-obadge.v-reject { color: var(--danger); border-color: var(--danger); border-style: solid; }
.proto-root .jc-obadge.v-reject::after { content: "拒绝"; }
.proto-root .jc-obadge.v-noreply { color: var(--ink-3); border-color: var(--ink-4); border-style: solid; }
.proto-root .jc-obadge.v-noreply::after { content: "无回复"; }
/* 定案徽:成交绿 / 还在还价橙 / 不成交灰 */
.proto-root .jc-dbadge {
display: inline-flex; align-items: center; justify-content: center;
min-width: 62px; height: 20px; padding: 0 7px; border-radius: 6px;
font-size: 10px; font-weight: 800; letter-spacing: .02em;
color: var(--ink-4); border: 1.5px solid var(--ink-4); background: #fff;
}
.proto-root .jc-dbadge::after { content: "待定案"; }
.proto-root .jc-dbadge.v-won { color: var(--ok); border-color: var(--ok); }
.proto-root .jc-dbadge.v-won::after { content: "成交"; }
.proto-root .jc-dbadge.v-haggle { color: var(--warn-accent); border-color: var(--warn-accent); border-style: dashed; }
.proto-root .jc-dbadge.v-haggle::after { content: "待你拍板"; }
.proto-root .jc-dbadge.v-revise { color: var(--brand); border-color: var(--brand); }
.proto-root .jc-dbadge.v-revise::after { content: "退回重批"; }
.proto-root .jc-dbadge.v-lost { color: var(--ink-3); border-color: var(--ink-4); }
.proto-root .jc-dbadge.v-lost::after { content: "不成交"; }
/* 让步处置(N-02)—— 只在还价的行上有值 */
.proto-root .jc-cbadge { font-size: 10.5px; font-weight: 700; color: var(--ink-4); }
.proto-root .jc-cbadge.v-accept_counter { color: var(--ok); }
.proto-root .jc-cbadge.v-accept_counter::after { content: "接了拿单"; }
.proto-root .jc-cbadge.v-negotiate { color: var(--brand); }
.proto-root .jc-cbadge.v-negotiate::after { content: "授权内回旋"; }
.proto-root .jc-cbadge.v-escalate { color: var(--danger); }
.proto-root .jc-cbadge.v-escalate::after { content: "超授权 · 退回重批"; }
/* 分布条:三段按条数分宽 —— 条数投影成 v-<N>,梯子取宽;零条那段自己消失 */
.proto-root .jc-distbar > i { flex: 24; min-width: 34px; display: inline-flex; align-items: center; justify-content: center; }
.proto-root .jc-distbar > i.v-0, .proto-root .jc-distbar > i:not([class*=" v-"]) { display: none; }
.proto-root .jc-distbar > i.v-1 { flex: 1 } .proto-root .jc-distbar > i.v-2 { flex: 2 }
.proto-root .jc-distbar > i.v-3 { flex: 3 } .proto-root .jc-distbar > i.v-4 { flex: 4 }
.proto-root .jc-distbar > i.v-5 { flex: 5 } .proto-root .jc-distbar > i.v-6 { flex: 6 }
.proto-root .jc-distbar > i.v-7 { flex: 7 } .proto-root .jc-distbar > i.v-8 { flex: 8 }
.proto-root .jc-distbar > i.v-9 { flex: 9 } .proto-root .jc-distbar > i.v-10 { flex: 10 }
.proto-root .jc-distbar > i.v-11 { flex: 11 } .proto-root .jc-distbar > i.v-12 { flex: 12 }
.proto-root .jc-distbar > i.v-13 { flex: 13 } .proto-root .jc-distbar > i.v-14 { flex: 14 }
.proto-root .jc-distbar > i.v-15 { flex: 15 } .proto-root .jc-distbar > i.v-16 { flex: 16 }
.proto-root .jc-distbar > i.v-17 { flex: 17 } .proto-root .jc-distbar > i.v-18 { flex: 18 }
.proto-root .jc-distbar > i.v-19 { flex: 19 } .proto-root .jc-distbar > i.v-20 { flex: 20 }
.proto-root .jc-distbar > i.p { background: var(--ok); }
.proto-root .jc-distbar > i.n { background: var(--warn-accent); }
.proto-root .jc-distbar > i.r { background: var(--brand); }
.proto-root .jc-distbar > i.k { background: var(--ink-4); }
.proto-root .jc-distbar > i.p::before { content: "成交 · "; }
.proto-root .jc-distbar > i.n::before { content: "待拍板 · "; }
.proto-root .jc-distbar > i.r::before { content: "退回重批 · "; }
.proto-root .jc-distbar > i.k::before { content: "不成交 · "; }
.proto-root .jc-logo svg, .proto-root .jc-face svg, .proto-root .jc-rp-ava svg { width: 17px; height: 17px; }
/* ── 报价档案卡(工作单独有,照 A4 定价档案卡的骨架)──────────────────────
一颗料一张卡,data-rows 逐行复制:左条款、右议价。
形态按定案分档(data-class-of):还在还价的整卡描橙,不成交的压灰 —— 判断在样式表。 */
.proto-root .jc-selcards { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
.proto-root .jc-selcard { border: 1px solid var(--line); border-radius: 11px; background: #fff; overflow: hidden; }
.proto-root .jc-selcard.v-haggle { border-color: #f0cf9a; box-shadow: 0 1px 0 rgba(191,132,32,.06); }
.proto-root .jc-selcard.v-lost { border-color: var(--line); opacity: .82; }
.proto-root .jc-selcard-h {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 9px 14px; background: var(--soft); border-bottom: 1px solid var(--line-soft);
}
.proto-root .jc-selcard.v-haggle .jc-selcard-h { background: var(--warn-soft); border-bottom-color: #f0cf9a; }
.proto-root .jc-selcard-h code { font-size: 13px; font-weight: 800; color: var(--ink); }
.proto-root .jc-selcard-h .why { flex: 1 1 auto; min-width: 0; font-size: 11px; color: var(--ink-3); }
.proto-root .jc-selcard-b { display: grid; grid-template-columns: 1fr 1fr; gap: 0; }
@media (max-width: 1000px) { .proto-root .jc-selcard-b { grid-template-columns: 1fr; } }
.proto-root .jc-selcol { padding: 11px 14px; }
.proto-root .jc-selcol + .jc-selcol { border-left: 1px solid var(--line-soft); }
@media (max-width: 1000px) { .proto-root .jc-selcol + .jc-selcol { border-left: 0; border-top: 1px solid var(--line-soft); } }
.proto-root .jc-selcol h5 { margin: 0 0 7px; font-size: 10.5px; font-weight: 800; letter-spacing: .08em; color: var(--ink-3); }
.proto-root .jc-kvrow { display: flex; gap: 8px; padding: 3px 0; font-size: 12px; line-height: 1.55; }
.proto-root .jc-kvrow i { flex: 0 0 84px; font-style: normal; color: var(--ink-4); font-size: 11px; padding-top: 1px; }
.proto-root .jc-kvrow span { flex: 1 1 auto; min-width: 0; color: var(--ink); overflow-wrap: anywhere; }
.proto-root .jc-kvrow span:empty::after { content: "—"; color: var(--ink-4); }
.proto-root .jc-selcard .sc-req:empty::after {
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
content: "必填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3;
}
.proto-root .jc-selcard .sc-sug:empty::after {
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
content: "建议填"; color: var(--warn-accent); background: var(--warn-soft); border-color: #f0cf9a;
}
.proto-root .jc-selcard .sc-opt:empty::after {
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line);
}
/* 两把尺(静态规则表 —— 尺挂在墙上,不是这一单的数据) */
.proto-root .jc-graderule { border: 1px solid var(--line); border-radius: 10px; overflow: hidden; margin-bottom: 12px; }
.proto-root .jc-graderule-r { display: flex; gap: 12px; align-items: flex-start; padding: 9px 13px; font-size: 12px; line-height: 1.6; }
.proto-root .jc-graderule-r + .jc-graderule-r { border-top: 1px solid var(--line-soft); }
.proto-root .jc-graderule-r p { margin: 0; color: var(--ink-2); }
.proto-root .jc-graderule-r p b { color: var(--ink); }
.proto-root .jc-band {
flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; margin-top: 1px;
min-width: 84px; height: 20px; padding: 0 8px; border-radius: 6px;
font-size: 10.5px; font-weight: 800; letter-spacing: .04em; border: 1.5px solid; background: #fff;
}
.proto-root .jc-band.ga { color: var(--ok); border-color: var(--ok); }
.proto-root .jc-band.gb { color: var(--brand); border-color: var(--brand); }
.proto-root .jc-band.gc { color: var(--warn-accent); border-color: var(--warn-accent); }
.proto-root .jc-band.gk { color: var(--danger); border-color: var(--danger); }
.proto-root .jc-band.gz { color: var(--ink-3); border-color: var(--ink-4); }
/* 一单议价怎么走完:报价 → 发出 → 客户表态 → 定案 → 去向,内联线稿走位图 */
.proto-root .jc-priceline { display: flex; align-items: center; gap: 0; margin: 2px 0 14px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 11px; background: var(--soft); }
.proto-root .jc-plnode { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 3px; align-items: flex-start; }
.proto-root .jc-plnode b { font-size: 10.5px; font-weight: 800; letter-spacing: .06em; color: var(--ink-3); }
.proto-root .jc-plnode span { font-size: 12px; color: var(--ink-2); line-height: 1.5; }
.proto-root .jc-plarrow { flex: 0 0 auto; padding: 0 10px; color: var(--brand-soft); }
@media (max-width: 1000px) { .proto-root .jc-priceline { flex-direction: column; align-items: stretch; gap: 8px; } .proto-root .jc-plarrow { display: none; } }
/* ── 落边图:定案清单 → 三个去向(命中那条实线亮,别的留灰虚线;件不算不判)── */
.proto-root .route { margin: 2px 0 14px; padding: 10px 6px 6px; border: 1px solid var(--line); border-radius: 11px; background: #fff; }
.proto-root .route svg { width: 100%; height: auto; display: block; }
.proto-root .route .rspine { stroke: var(--line); stroke-width: 2; }
.proto-root .route .redge { stroke: var(--line); stroke-width: 2; stroke-dasharray: 5 5; fill: none; }
.proto-root .route .rnode { fill: #fff; stroke: var(--line); stroke-width: 1.5; }
.proto-root .route .rtxt { font-size: 12px; font-weight: 800; fill: var(--ink-3); }
.proto-root .route .rsub { font-size: 10px; fill: var(--ink-4); }
.proto-root .route .rcond { font-size: 10.5px; font-weight: 700; fill: var(--ink-4); }
.proto-root .route .rtip { fill: var(--line); }
.proto-root .route.v-r1 .e1, .proto-root .route.v-r2 .e2, .proto-root .route.v-r3 .e3 { stroke: var(--brand); stroke-dasharray: none; }
.proto-root .route.v-r1 .n1, .proto-root .route.v-r2 .n2, .proto-root .route.v-r3 .n3 { stroke: var(--brand); stroke-width: 2; }
.proto-root .route.v-r1 .t1, .proto-root .route.v-r2 .t2, .proto-root .route.v-r3 .t3 { fill: var(--brand-deep); }
.proto-root .route.v-r1 .k1, .proto-root .route.v-r2 .k2, .proto-root .route.v-r3 .k3 { fill: var(--brand); }
.proto-root .route.v-r1 .c1, .proto-root .route.v-r2 .c2, .proto-root .route.v-r3 .c3 { fill: var(--brand-deep); }
.proto-root .route .legend { display: flex; gap: 14px; flex-wrap: wrap; padding: 6px 10px 2px; font-size: 10.5px; color: var(--ink-4); }
/* 结论带 */
.proto-root .concl { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; align-items: start; margin: 2px 0 14px; padding: 11px 14px; border: 1px solid var(--line); border-radius: 11px; background: var(--soft); }
.proto-root .concl svg { grid-row: span 2; margin-top: 2px; color: var(--brand); }
.proto-root .concl .cl-main { font-size: 12.5px; color: var(--ink); line-height: 1.6; }
.proto-root .concl .cl-main b { font-weight: 800; }
.proto-root .concl .cl-route { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; font-size: 11.5px; color: var(--ink-3); }
.proto-root .concl .cl-route em { font-style: normal; color: var(--ink-4); }
.proto-root .concl .cl-route b { color: var(--brand-deep); font-weight: 800; }
.proto-root .concl .cl-route code { font-family: var(--mono, ui-monospace, monospace); font-size: 10.5px; color: var(--ink-4); }
`;
/** 一枚图标 —— 与原型同一写法 */
const ico = (name: string, size = 11): string =>
`<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-${name}"/></svg>`;
/** 三层壳照原型:少一层样式表全失效 */
const doc = (input: { badge: string; kicker: string; title: string; titleRef: string; state: string; body: string; foot: string }): string =>
`<style>${PRD_CSS}${BIND_CSS}</style>
${PRD_SPRITE}
<div class="proto-root"><div class="app-shell"><div class="jc-stage">
<article class="jc-report">
<header class="jc-rp-head">
<div class="jc-rp-badge">${ico(input.badge, 17)}</div>
<div class="jc-rp-tit">
<em>${input.kicker}</em>
<h3>${input.title} <span data-slot="${input.titleRef}"></span></h3>
</div>
${input.state}
</header>
<div class="jc-rp-bd">${input.body}</div>
<footer class="jc-rp-foot">${input.foot}</footer>
</article>
</div></div></div>`;
const part = (label: string, aside = '', tone = ''): string =>
`<div class="jc-rp-part"><b>${label}</b><i class="ln"></i>${aside === '' ? '' : `<em class="${tone}">${aside}</em>`}</div>`;
const sign = (note: string, tag = ''): string =>
`<div class="jc-rp-sign">
<span class="jc-rp-ava">${ico('user', 15)}</span>
<div><b data-slot="case:owner"></b><span>${note}</span></div>
</div>${tag === '' ? '' : `<span class="jc-done-tag">${ico('git-branch', 13)}${tag}</span>`}`;
/* ==================== 组成件 ==================== */
/** 要素四卡:客户 / 待回音 / 一格一料的定案刻度 / 议价的尺 */
const FOUR = `<div class="jc-four">
<div class="jc-fcard">
<span class="jc-fcard-k"><i>${ico('building')}</i>客户公司</span>
<div class="jc-fcard-b">
<span class="jc-logo">${ico('building', 17)}</span>
<div class="jc-fcard-t"><b data-slot="customer_name" data-blank></b><span data-slot="customer_code" data-blank></span></div>
</div>
<span class="jc-fcard-note">承自上游;单飞起单时此栏可空</span>
</div>
<div class="jc-fcard t-teal">
<span class="jc-fcard-k"><i>${ico('boxes')}</i>待回音</span>
<div class="jc-count"><b data-slot="quote_negotiate_line_missing"></b><span>/ <b data-slot="quote_negotiate_line_rows"></b> 条报价</span></div>
<span class="jc-fcard-note">客户表态还空着的行 —— 这一列只有收信的人填得了,系统不替客户说话;补一格少一格,基座现数</span>
</div>
<div class="jc-fcard">
<span class="jc-fcard-k"><i>${ico('shield-check')}</i>逐条定案</span>
<div class="jc-segmeter" data-rows="mine:quote_negotiate.line">
<i data-class-of="deal_status"></i>
</div>
<span class="jc-fcard-note">一格一条料 · 绿=成交,橙=客户还了价待拍板,蓝=退回重批,灰=不成交(拒绝或超时)</span>
</div>
<div class="jc-fcard t-ok">
<span class="jc-fcard-k"><i>${ico('history')}</i>报价有效期</span>
<span class="jc-tagbig">${ico('check', 13)}<span><b data-slot="valid_days"></b> 天 · 至 <b data-slot="valid_until" data-blank></b></span></span>
<span class="jc-fcard-note">按报价有效期 P-04 注明;D+3 催一轮、D+7 最后一催,催过仍不表态才按超时定案</span>
</div>
</div>`;
/** 一单议价是怎么走完的 —— 走位图(形状写死,不算不判) */
const DEAL_LINE = `<div class="jc-priceline">
<div class="jc-plnode"><b>批准价</b><span>A5 批过的那个数 —— 报出去的价一个数字都不改</span></div>
<div class="jc-plarrow">${ico('arrow-right', 18)}</div>
<div class="jc-plnode"><b>报价函发出</b><span>条款齐备(SPQ/MOQ/贸易条款/有效期),BD 发给客户</span></div>
<div class="jc-plarrow">${ico('arrow-right', 18)}</div>
<div class="jc-plnode"><b>客户表态</b><span>接受 / 还价 / 拒绝 / 无回复 —— 这一格由收信的人落</span></div>
<div class="jc-plarrow">${ico('arrow-right', 18)}</div>
<div class="jc-plnode"><b>定案 N-01</b><span>成交 / 待拍板 / 退回重批 / 不成交,逐条落定</span></div>
<div class="jc-plarrow">${ico('arrow-right', 18)}</div>
<div class="jc-plnode"><b>去向</b><span>退回重批→回定价 · 有成交→转履约 · 都没成→转归档</span></div>
</div>`;
/** 缺表态红带 —— 与提交闸、放行门 L1 同一个数同一套算法(基座现数);一格不缺自动收 */
const GAP_BAR = `<div class="jc-gapbar" data-class-of="quote_negotiate_line_missing">${ico('alert', 14)}
<span><b>还差 <span data-slot="quote_negotiate_line_missing"></span> 行客户表态没落</b>
—— <span data-slot="quote_negotiate_line_missing_keys"></span>。
按右上角「编辑」开表,红框那几格就是要填的:接受填 ACCEPT,还价填 COUNTER 并把客户报的那个价
填进「客户还价」,拒绝填 REJECT,催到有效期过仍不表态填 NOREPLY。补齐了执行单才提交得了。</span>
</div>`;
const editGate = (address: string, what: string): string => `<div class="jc-editgate" data-edit-for="${address}">
<span class="jc-editgate-t">${ico('lock', 12)}${what}<b>已锁定</b> —— 看的时候不误碰,要改按右边</span>
<button type="button" class="jc-egbtn open" data-edit-open="${address}">${ico('pen', 12)}编辑</button>
<button type="button" class="jc-egbtn ghost" data-edit-cancel="${address}">取消</button>
<button type="button" class="jc-egbtn save" data-edit-save="${address}">${ico('check', 12)}保存</button>
</div>
<div class="jc-editbar">${ico('pen', 14)}
<span>可填的是<b>客户表态</b>、<b>客户还价</b>与<b>客户原话</b>三列:
<b>这三列系统一格都不写</b> —— 客户回了什么只有你看得见,别让系统替他表态。
还价的行务必把他报的那个数填上,后面拍板全看这一格。本轮不报的料按行首 × 撤掉。
改动只落任务草稿层,<b>保存不等于放行</b>。</span>
</div>`;
const addRowBar = (address: string): string => `<div class="jc-addrow">
<button type="button" class="jc-egbtn ghost" data-edit-add="${address}">${ico('plus', 12)}加一行</button>
<em>照末行的样子添一行空的;主键(型号 PN)填了才存得下,不填这一行不落库。</em>
</div>`;
/**
* 报价函主表 —— 一行一料:条款列 + 客户那一侧三列 + 定案列。
*
* `data-col` 只挂人可改的三列(reply_type / counter_price / reply_note)+ 主键 pn ——
* 编辑面精确等于 BD 的职权面;**主键格必挂 `data-col`**(它不是编辑面,是行的钥匙:
* 不挂,服务端按「全表一致列」补键,两行会对到同一行上,后者盖前者 —— A2 走查逮过)。
* 客户表态挂 `req`:空着出「必填」胶囊、整行标底,与提交闸、放行门同源(都数 `_missing`)。
*/
const LINE_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
<thead><tr>
<th style="width:36px;min-width:36px"></th>
<th style="width:44px;min-width:44px">类</th>
<th style="min-width:170px">PN</th>
<th style="min-width:84px">品类</th>
<th style="min-width:70px">年用量</th>
<th style="min-width:70px">交期</th>
<th style="min-width:64px">SPQ</th>
<th style="min-width:64px">MOQ</th>
<th style="min-width:86px">贸易条款</th>
<th style="min-width:88px">报价 CNY</th>
<th class="req" style="width:88px;min-width:88px">客户表态(你落)</th>
<th style="min-width:80px">客户还价</th>
<th style="width:80px;min-width:80px">定案</th>
<th style="min-width:150px;width:100%">客户原话 / 让价理由</th>
</tr></thead>
<tbody data-rows="mine:quote_negotiate.line" data-edit="quote_negotiate.line"><tr>
<td class="jc-rn"><button type="button" class="jc-rowdel" data-edit-del="quote_negotiate.line" title="撤掉这一行">×</button></td>
<td class="jc-ty" data-h="类"><span class="jc-tbadge" data-class-of="type_tag" data-slot="type_tag"></span></td>
<td data-h="PN"><span class="jc-vcell mono" data-slot="pn" data-col="pn" data-blank></span></td>
<td class="dimempty" data-h="品类"><span class="jc-vcell" data-slot="category" data-blank></span></td>
<td class="dimempty" data-h="年用量"><span class="jc-vcell mono" data-slot="eau" data-blank></span></td>
<td class="dimempty" data-h="交期"><span class="jc-vcell" data-slot="lead_time" data-blank></span></td>
<td class="dimempty" data-h="SPQ"><span class="jc-vcell num" data-slot="spq" data-blank></span></td>
<td class="dimempty" data-h="MOQ"><span class="jc-vcell num" data-slot="moq" data-blank></span></td>
<td class="dimempty" data-h="贸易条款"><span class="jc-vcell" data-slot="trade_terms" data-blank></span></td>
<td class="dimempty" data-h="报价 CNY"><span class="jc-vcell num" data-slot="quoted_price" data-blank></span></td>
<td class="req" data-h="客户表态(你落)">
<span class="jc-obadge" data-class-of="reply_type"></span>
<span class="jc-vcell" data-slot="reply_type" data-col="reply_type" data-blank></span>
</td>
<td class="sug" data-h="客户还价"><span class="jc-vcell num" data-slot="counter_price" data-col="counter_price" data-blank></span></td>
<td data-h="定案"><span class="jc-dbadge" data-class-of="deal_status"></span></td>
<td class="opt" data-h="客户原话 / 让价理由"><span class="jc-vcell" data-slot="reply_note" data-col="reply_note" data-blank></span></td>
</tr></tbody>
</table></div>`;
/**
* 成交定案表(收口件用)—— 编辑面换成**放行时可改的两列**(成交价与客户原话),
* 与 `requiredDeliverables[].editableAtRelease` 逐字对齐。
*/
const SETTLED_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
<thead><tr>
<th style="width:36px;min-width:36px"></th>
<th style="width:44px;min-width:44px">类</th>
<th style="min-width:170px">PN</th>
<th style="min-width:88px">报价 CNY</th>
<th style="width:88px;min-width:88px">客户表态</th>
<th style="min-width:80px">客户还价</th>
<th style="min-width:110px">让步处置</th>
<th style="width:80px;min-width:80px">定案</th>
<th style="min-width:88px">成交价</th>
<th style="min-width:150px;width:100%">客户原话 / 让价理由</th>
</tr></thead>
<tbody data-rows="mine:quote_negotiate.line" data-edit="quote_negotiate.line"><tr>
<td class="jc-rn"><button type="button" class="jc-rowdel" data-edit-del="quote_negotiate.line" title="撤掉这一行">×</button></td>
<td class="jc-ty" data-h="类"><span class="jc-tbadge" data-class-of="type_tag" data-slot="type_tag"></span></td>
<td data-h="PN"><span class="jc-vcell mono" data-slot="pn" data-col="pn" data-blank></span></td>
<td class="dimempty" data-h="报价 CNY"><span class="jc-vcell num" data-slot="quoted_price" data-blank></span></td>
<td data-h="客户表态"><span class="jc-obadge" data-class-of="reply_type"></span></td>
<td class="dimempty" data-h="客户还价"><span class="jc-vcell num" data-slot="counter_price" data-blank></span></td>
<td class="dimempty" data-h="让步处置"><span class="jc-cbadge" data-class-of="concession"></span></td>
<td data-h="定案"><span class="jc-dbadge" data-class-of="deal_status"></span></td>
<td class="opt" data-h="成交价"><span class="jc-vcell num" data-slot="final_price" data-col="final_price" data-blank></span></td>
<td class="opt" data-h="客户原话 / 让价理由"><span class="jc-vcell" data-slot="reply_note" data-col="reply_note" data-blank></span></td>
</tr></tbody>
</table></div>`;
/** 定案分布:四段按条数分宽 —— 守恒等式(四档相加 = 行数)的可视形 */
const DIST = `<div class="jc-dist">
<div class="jc-distbar">
<i class="p" data-class-of="quote_negotiate_line_deal_status_won" data-slot="quote_negotiate_line_deal_status_won"></i>
<i class="n" data-class-of="quote_negotiate_line_deal_status_haggle" data-slot="quote_negotiate_line_deal_status_haggle"></i>
<i class="r" data-class-of="quote_negotiate_line_deal_status_revise" data-slot="quote_negotiate_line_deal_status_revise"></i>
<i class="k" data-class-of="quote_negotiate_line_deal_status_lost" data-slot="quote_negotiate_line_deal_status_lost"></i>
</div>
<div class="jc-distlg">
<span><em class="a"></em>成交 · 客户接受 <b data-slot="quote_negotiate_line_deal_status_won"></b></span>
<span><em class="b"></em>待拍板 · 客户还了价 <b data-slot="quote_negotiate_line_deal_status_haggle"></b></span>
<span><em class="b"></em>退回重批 · 让幅超授权 <b data-slot="quote_negotiate_line_deal_status_revise"></b></span>
<span><em class="c"></em>不成交 · 拒绝或超时 <b data-slot="quote_negotiate_line_deal_status_lost"></b></span>
</div>
</div>`;
/* ==================== 局面一 · 议价工作单(排版 / 发出 / 落回音)==================== */
/**
* 逐颗报价档案 —— 一颗料一张卡(data-rows 逐行复制):左条款、右议价。
*
* **容器用 `<section>` 不用 `<div>`**:渲染器的行展开按第一个同名闭合标签截行内容,
* 行模版里嵌着 `<div>` 时 `<div data-rows>` 会把行截成套娃(A2 逮过,记在陷阱表)。
*/
const QUOTE_CARDS = `<section class="jc-selcards" data-rows="mine:quote_negotiate.line">
<article class="jc-selcard" data-class-of="deal_status">
<div class="jc-selcard-h">
<span class="jc-tbadge" data-class-of="type_tag" data-slot="type_tag"></span>
<code data-slot="pn"></code>
<span class="why" data-slot="remark" data-blank></span>
<span class="jc-obadge" data-class-of="reply_type"></span>
<span class="jc-dbadge" data-class-of="deal_status"></span>
</div>
<div class="jc-selcard-b">
<div class="jc-selcol">
<h5>报价函条款(发出去就是承诺)</h5>
<div class="jc-kvrow"><i>客户料号</i><span data-slot="customer_pn" data-blank></span></div>
<div class="jc-kvrow"><i>品类 / 制造商</i><span data-slot="category" data-blank></span></div>
<div class="jc-kvrow"><i>年用量</i><span data-slot="eau" data-blank></span></div>
<div class="jc-kvrow"><i>报价 CNY</i><span class="num" data-slot="quoted_price" data-blank></span></div>
<div class="jc-kvrow"><i>交期</i><span data-slot="lead_time" data-blank></span></div>
<div class="jc-kvrow"><i>SPQ / MOQ</i><span class="num" data-slot="spq" data-blank></span></div>
<div class="jc-kvrow"><i>贸易条款</i><span data-slot="trade_terms" data-blank></span></div>
<div class="jc-kvrow"><i>有效期至</i><span data-slot="valid_until" data-blank></span></div>
</div>
<div class="jc-selcol">
<h5>议价(填回下表,这里回显)</h5>
<div class="jc-kvrow"><i>客户表态</i><span class="sc-req" data-slot="reply_type" data-blank></span></div>
<div class="jc-kvrow"><i>客户还价</i><span class="sc-sug num" data-slot="counter_price" data-blank></span></div>
<div class="jc-kvrow"><i>客户原话</i><span class="sc-opt" data-slot="reply_note" data-blank></span></div>
<div class="jc-kvrow"><i>让步处置</i><span class="jc-cbadge" data-class-of="concession"></span></div>
<div class="jc-kvrow"><i>定案</i><span class="jc-dbadge" data-class-of="deal_status"></span></div>
<div class="jc-kvrow"><i>成交价</i><span class="num" data-slot="final_price" data-blank></span></div>
<div class="jc-kvrow"><i>客户约束</i><span data-slot="remark" data-blank></span></div>
</div>
</div>
</article>
</section>`;
/** 议价结果 N-01 的四档 —— 静态规则,尺挂在墙上(素材:MVP 示例工作流 §1.4) */
const OUTCOME_RULES = `<div class="jc-graderule">
<div class="jc-graderule-r">
<span class="jc-band ga">ACCEPT</span>
<p><b>客户明确接受这个价。</b>「按这个价走」「下单吧」「PO 稍后发」——
定案成交,成交价就是报价,转订单履约。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gc">COUNTER</span>
<p><b>客户还了一个价。</b>把他报的那个数落进「客户还价」那一格 ——
没有数字的「太贵了」先追问,追不到才按无回复落。还价的条目<b>先落成「待拍板」</b>,
等人按让步梯度拍一下才有结论:接了算成交、退回重批、或守价不让 —— 系统不替你让价。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gk">REJECT</span>
<p><b>客户明确不做了。</b>选了别家、项目取消、规格不匹配 ——
定案不成交,把缘由记进备注,转情报归档:这一单丢了,情报留下。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gz">NOREPLY</span>
<p><b>催到有效期过仍不表态。</b><b>客户不回不是结论</b> ——
D+3 第一轮催问确认收到探态度,D+7 最后一催;催过了才轮到按超时定案。</p>
</div>
</div>`;
/** 让步梯度 N-02 的三档 —— 静态规则(素材:MVP 示例工作流 §1.4 让价核线) */
const CONCESSION_RULES = `<div class="jc-graderule">
<div class="jc-graderule-r">
<span class="jc-band ga">≤ 3%</span>
<p><b>接了拿单。</b>意思意思的压价 —— 来回一轮的时间成本比这三个点贵,
当场接住,把单子锁下来。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gb">3% ~ 10%</span>
<p><b>授权内回旋。</b>让一半再报回去,把让出去的换成量或账期 ——
先弄清争的是价格、交期还是 MOQ:争交期的用交期换,一上来就降价,后面只剩降价这一招。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gk">&gt; 10%</span>
<p><b>超出授权,退回成本定价重批。</b>这一档系统不替任何人放行,也不许私自破线 ——
底价线以下的单,每补一次货都在亏。<b>破线不是勇气是事故</b>。</p>
</div>
</div>`;
export const NEGOTIATE_WORKSHEET = doc({
badge: 'sheet',
kicker: '统一报价 · A6 报价议价 · 议价作业',
title: '议价工作单',
titleRef: 'rfq_ref',
state:
'<span class="jc-rp-wait warn">待回音 <b data-slot="quote_negotiate_line_missing"></b> / <b data-slot="quote_negotiate_line_rows"></b> 条</span>',
body:
kvCard('报价抬头', [
['报价单号', 'quoteNo', 'mono'],
['客户编码', 'customerCode', 'mono'],
['型号 PN', 'pn', 'mono'],
]) +
kvCard('报价条款', [
['单价', 'unit_price'],
['交期', 'lead_time'],
['最小包装量 SPQ', 'spq'],
['最小起订量 MOQ', 'moq'],
['贸易条款', 'trade_terms'],
['有效期', 'valid_until'],
]) +
noteCard('这一步还没碰外界', '报价单此刻只是<b>草稿</b>。发出动作是<b>写类</b>的,过程中只登记为「动作意向」——入参冻结、等放行,放行前一封信都不会发出去。'),
part('议价任务', '客户 <b data-slot="customer_name" data-blank></b> · 报价函 <b data-slot="quote_no" data-blank></b> · 共 <b data-slot="line_total"></b> 条') +
`<div class="jc-derive">${ico('info', 14)}
<span>这一屏是一份报价函的作业台:系统已按 A5 批准的价排好版,条款与有效期都在下面 ——
<b>报出去的价一个数字都不改</b>(要改价得回审批)。你要做的是把它发给客户,
按 D+3 / D+7 的节奏催问,<b>客户回话之后在「客户表态」那一列逐行落</b>。
这一列系统一格都不写:客户回了什么,只有收信的人看得见。</span>
</div>` +
FOUR +
part('一单议价是怎么走完的', '批准价 → 发出 → 客户表态 → 定案 → 去向') +
DEAL_LINE +
part('逐颗报价档案', '条款在左 · 议价在右') +
QUOTE_CARDS +
part('议价结果 N-01', '客户的话只有四种表态 —— 尺挂在墙上') +
OUTCOME_RULES +
part('让步梯度 N-02', '还价幅度决定让不让、谁来让') +
CONCESSION_RULES +
part('填回工作表', '客户表态落在这 —— 补齐了执行单才提交得了') +
GAP_BAR +
editGate('quote_negotiate.line', '报价函填回表') +
LINE_TABLE +
addRowBar('quote_negotiate.line') +
`<div class="jc-drop">${ico('info', 13)}
<span>带红标的<b>客户表态</b>是必填,而且<b>只由你填</b> —— 缺一行执行单按不下提交,
与放行门是同一个数同一套算法。四个取值一个字母不许差:
<b>ACCEPT</b> 接受 · <b>COUNTER</b> 还价 · <b>REJECT</b> 拒绝 · <b>NOREPLY</b> 无回复。
还价的行务必把客户报的那个价填进<b>客户还价</b> —— 那一格空着这张单同样提交不了,
后面拍板全看它。客户只回了一部分料的,没回的那几行按 NOREPLY 落,
<b>别替客户猜</b>;本轮不报的料按行首 × 撤掉。</span>
</div>`,
foot: sign('BD 议价 · AI 排版报价函并按两把尺定案,客户那一侧的话由你落', '统一报价 · A6 报价议价 · 在办'),
});
export const QUOTE_RELEASE = viewShell({
title: '正式报价单 · 放行即发出',
kicker: 'A6 · 成果预览',
tone: 'danger',
/* ==================== 局面二 · 成交定案清单(拍板 / 放行 / 归档)==================== */
/** 落边图:定案清单 → 三个去向(命中边由壳上 v-r1/v-r2/v-r3 上色,件不算不判) */
const ROUTE_FIG = `<div class="route" data-class-of="case:routeKey">
<svg viewBox="0 0 660 250" fill="none">
<rect class="rnode" x="8" y="95" width="150" height="60" rx="10"/>
<text class="rtxt" x="83" y="120" text-anchor="middle">成交定案清单</text>
<text class="rsub" x="83" y="138" text-anchor="middle">共 <tspan data-slot="quote_negotiate_line_rows"></tspan> 条</text>
<path class="rspine" d="M158 125 H210"/>
<path class="redge e1" d="M210 125 C280 125 300 40 460 40"/>
<path class="redge e2" d="M210 125 C300 125 340 125 460 125"/>
<path class="redge e3" d="M210 125 C280 125 300 210 460 210"/>
<path class="rtip k1" d="M448 34 l14 6 -14 6 z"/>
<path class="rtip k2" d="M448 119 l14 6 -14 6 z"/>
<path class="rtip k3" d="M448 204 l14 6 -14 6 z"/>
<text class="rcond c1" x="300" y="60">退回重批 <tspan data-slot="quote_negotiate_line_deal_status_revise"></tspan> 条</text>
<text class="rcond c2" x="300" y="115">成交 <tspan data-slot="quote_negotiate_line_deal_status_won"></tspan> 条</text>
<text class="rcond c3" x="300" y="230">不成交 <tspan data-slot="quote_negotiate_line_deal_status_lost"></tspan> 条</text>
<rect class="rnode n1" x="460" y="8" width="180" height="64" rx="10"/>
<text class="rtxt t1" x="550" y="35" text-anchor="middle">A4 · 成本定价</text>
<text class="rsub" x="550" y="53" text-anchor="middle">让幅超授权 · 退回重批</text>
<rect class="rnode n2" x="460" y="93" width="180" height="64" rx="10"/>
<text class="rtxt t2" x="550" y="120" text-anchor="middle">转订单履约 ORM</text>
<text class="rsub" x="550" y="138" text-anchor="middle">成交 · 这条流走到终点</text>
<rect class="rnode n3" x="460" y="178" width="180" height="64" rx="10"/>
<text class="rtxt t3" x="550" y="205" text-anchor="middle">A7 · 情报归档</text>
<text class="rsub" x="550" y="223" text-anchor="middle">拒绝 / 超时 · 情报留下</text>
</svg>
<div class="legend">
<span>命中边实线亮 · 未命中留灰虚线</span>
<span>按序求值首个命中即出:判了退回重批 → 回定价;其次有成交 → 转履约;都没成 → 转归档</span>
<span>判定在你签字那一刻由系统现算 —— 要改去向,在这张表上改定案那几格</span>
</div>
</div>`;
export const NEGOTIATE_ROSTER = doc({
badge: 'file-check',
kicker: '统一报价 · A6 报价议价 · 交付成果',
title: '成交定案清单',
titleRef: 'rfq_ref',
state:
'<span class="jc-rp-wait">报价 <b data-slot="quote_negotiate_line_rows"></b> 条 · 待拍板 <b data-slot="quote_negotiate_line_deal_status_haggle"></b> 条(要归零)</span>',
body:
tableCard(
'本单报价',
'mine:quote_negotiate.formal_quote',
[
['报价单号', 'quote_no', 'mono'],
['型号', 'pn', 'mono'],
['单价', 'unit_price'],
['交期', 'lead_time'],
],
) +
noteCard(
'按下放行会发生什么',
'① <b>落地预检</b>先跑:交付物齐备、动作意向可执行、授权在契约边界内 —— 预检不过不发卡。' +
'② 放行通过<b>即刻落地</b>:台账回写 + 出站发送(报价单发给客户),动作附幂等键。' +
'③ <b>落地收敛</b>后输出口才发射 —— 下游只见落地完成的任务;已成动作留存<b>不回滚</b>。' +
'④ 出口三选一(成交 / 有异议 / 拒绝)<b>恰发一口</b>,随放行一并确认,G4 网关按它分流。',
),
part('议价要素', '报价议价 · 能查表的归判定表,要让价的归人') +
FOUR +
part('定案分布', '成交 + 待拍板 + 退回重批 + 不成交 = 总条数') +
DIST +
`<div class="jc-derive">${ico('info', 14)}
<span>四段条数由基座逐行现数,不是谁报上来的。橙段(<b>待拍板</b>)是过渡态 ——
客户还了价而还没人拍板,它<b>归零这一单才走得到放行门</b>:接了算成交、
退回重批算一条正经的路、守价不让算不成交。要让价就得在授权内让,
超过一成的让幅一律回去重批,<b>不私自破线</b>。</span>
</div>` +
part('正式报价函', '<b data-slot="line_total"></b> 条 · 每条都带客户的话') +
GAP_BAR +
editGate('quote_negotiate.line', '成交定案表') +
SETTLED_TABLE +
`<div class="jc-drop">${ico('info', 13)}
<span>每一行摆的是这一条的<b>来路</b>:报了多少、客户怎么表态、还了什么价、
让步梯度判在哪一档、最后定成什么。签字前请重点看两处:<b>定案列</b>里非绿的条目,
与<b>客户原话列</b> —— 没有客户原话的还价,下一轮谈判就是重新开始。
这一屏可改的是<b>成交价</b>与<b>客户原话</b>两列(你的职权面)。</span>
</div>` +
part('落边 · 这一签流向哪里', '出口按序求值 · 首个命中即出') +
ROUTE_FIG +
`<div class="concl">
${ico('git-branch', 15)}
<div class="cl-main"><b>议价结论</b> —— <span data-slot="summary" data-blank></span></div>
<div class="cl-route"><em>转入活动</em><b data-slot="case:routeTo"></b><code data-slot="case:routeWhy"></code></div>
</div>` +
part('放行后即执行的落地动作', '门前 → 门后') +
`<div class="jc-acts">
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>草稿层合并进正式版</b>&#12288;<span>本任务写下的报价函与议价结论一并落账;
你就地改过的成交价与客户原话随之落定。</span></div>
</div>
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>命中的那一口此刻才发射</b>&#12288;<span>报价函
<b data-slot="quote_negotiate_line_rows"></b> 条随载荷交给上面亮线指向的下游;
另两口本次不起单 —— 放行之前,世界上什么都没有发生。</span></div>
</div>
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>不成交也留资产</b>&#12288;<span>拒绝原因与竞对价位随载荷交给情报归档 ——
这一单丢了,下一单的三分类与利润模型会用上它。</span></div>
</div>
</div>`,
foot: sign('BD 议价放行 · AI 排版与定案留痕 —— 要改哪一格,在这张定案表上就地改', '统一报价 · A6 报价议价 → 定去向'),
});
@@ -0,0 +1,499 @@
/**
* A4 成本定价的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。
* 案例活动没有 `run`,自测不跑回合;测的是清单本身与四处对齐
* (判据谓词 / 出口载荷 / 剧本声明 / 两把尺的档位)。
*/
import { describe, expect, it } from 'vitest';
import { activityManifestSchema } from '@contracts';
import { evaluateCondition, evaluateUnaryTest } from '@sfeel';
import { createMockHost } from '@sdk';
import { P01_DMN, manifest } from '../src/manifest.ts';
import activity from '../src/index.ts';
describe('清单本身合规', () => {
it('过 @contracts 的 schema', () => {
expect(() => activityManifestSchema.parse(manifest)).not.toThrow();
});
it('监听时限已配 —— 无悬空监听', () => {
expect(manifest.declarations.listeners.every((l) => /^P/.test(l.deadline))).toBe(true);
});
it('L1/L2 判据都给了可机检的谓词 —— 验收等级不得谎报', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef, c.key).toBeDefined();
}
});
it('禁区短而硬,授权边界给了调人预算', () => {
expect(manifest.contract.prohibitions.length).toBeGreaterThan(0);
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(0);
});
it('每件必交付物都有台账地址', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(d.ledger, d.key).toBeDefined();
}
});
/*
* **宽进严出**:起单一个必填都不设 —— 上游(A3 的 quotation 口)发的是
* `{rfqRef, rows}`,而上一版必填 `rfqRef + customerCode + records`,
* 门口拦的恰是主路。归一在活动内部做。
*/
it('起单宽进:input.required 是空的,三种来路的键都声明着', () => {
expect(manifest.contract.input.data['required']).toEqual([]);
const properties = manifest.contract.input.data['properties'] as Record<string, unknown>;
for (const key of ['rfqRef', 'rows', 'records', 'subject', 'body']) {
expect(properties[key], `入参少了 ${key}`).toBeDefined();
}
});
/*
* **拟报单价是必填** —— 这是 A4 的门面判据:件上的缺价红带、执行卡的提交闸、
* 放行门 L1 三处同源(都数基座的 `_missing`)。声明丢了 required,三处一起失明。
*/
it('拟报单价标了 required —— 红带 / 提交闸 / 放行门三处同源的那个数才存在', () => {
const draft = manifest.declarations.ledger.find((d) => d.object === 'draft');
expect(draft?.fields.find((f) => f.name === 'quoted_price')?.required).toBe(true);
});
/*
* **建议价与拟报单价分两列是结构治,不是措辞治**(2026-08-03 单飞案例逼出来的):
* 只有一列价时,剧本写「算不出的留空」,模型照样给库里查不到的料编了价 ——
* 一条要它分辨「算得出/算不出」的规矩会走岔;「这一列你不写」没有分支可走岔。
*/
it('建议价与拟报单价是两列 —— AI 写建议价,人写拟报单价', () => {
const draft = manifest.declarations.ledger.find((d) => d.object === 'draft');
const suggested = draft?.fields.find((f) => f.name === 'suggested_price');
expect(suggested, '缺 suggested_price 这一列').toBeDefined();
expect(suggested?.required, '建议价不该是必填 —— 算不出就该空着').toBeUndefined();
for (const step of ['S1_price', 'S3_gate']) {
const guide = manifest.assets.businessModel.steps?.find((s) => s.item === step)?.guide ?? '';
expect(guide, `${step} 没写死「quoted_price 你一格都不写」`).toMatch(/quoted_price[^\n]*一格都不写|一格都不写[^\n]*quoted_price/);
}
});
it('门禁处置给了取值域 —— 三档条数由基座现数,不由活动自报', () => {
const draft = manifest.declarations.ledger.find((d) => d.object === 'draft');
expect(draft?.fields.find((f) => f.name === 'disposition')?.tally).toEqual(['PASS', 'NOTE', 'BLOCK']);
});
/*
* 我方写的注不标 `expect` —— 标了 `_gaps` 恒大于 0,读它的判据永远绿不了(A1 的账)。
* 客户目标价才是「客户该给而没给的」那一问。
*/
it('expect 只标客户该给的那一格,不标我方写的注', () => {
const draft = manifest.declarations.ledger.find((d) => d.object === 'draft');
expect(draft?.fields.find((f) => f.name === 'target_price')?.expect).toBe(true);
expect(draft?.fields.find((f) => f.name === 'price_note')?.expect).toBeUndefined();
});
it('三条常备案例形状真不同:寻源转来 / 客户压价超限 / 单飞无基准', () => {
expect(manifest.samples.length).toBe(3);
const [flowIn, overLimit, solo] = manifest.samples;
expect((flowIn?.input as { rows?: unknown[] }).rows?.length).toBeGreaterThan(0);
expect((overLimit?.input as { rows?: Array<Record<string, number>> }).rows?.[0]?.['target_price']).toBeLessThan(
(overLimit?.input as { rows?: Array<Record<string, number>> }).rows?.[0]?.['base_price'] ?? 0,
);
expect((solo?.input as { rows?: unknown[] }).rows).toBeUndefined();
});
});
describe('案例模型 —— 双表示同版本', () => {
const model = manifest.assets.businessModel;
const cmmn = parseCmmnLite(model.cmmnXml);
it('CMMN 与说明书同在,不允许分叉', () => {
expect(model.cmmnXml).toContain('<casePlanModel');
expect(model.narrative.length).toBeGreaterThan(400);
});
/* 照 MVP 示例工作流 §1.2 的 S1 取价基准 / S2 逐条定价 / S3 底价自检,一步一项 */
it('计划项照 SOP 一步一项:算建议价 + 交人定价 + 底价自检 + 超限拍板 + 放行', () => {
expect(items(model.cmmnXml)).toEqual(['S1_price', 'S2_review', 'S3_gate', 'T_approve', 'T_release']);
});
it('三处人的门都标了人工激活:BD 定价 · 超限拍板 · 草案放行(底价自检是算术,不标)', () => {
expect(cmmn.manualActivation).toEqual(['S2_review', 'T_approve', 'T_release']);
});
it('不用阶段 / 里程碑 / 裁量项', () => {
for (const tag of ['<stage', '<milestone', '<planningTable', '<discretionaryItem']) {
expect(model.cmmnXml, `不该出现 ${tag}`).not.toContain(tag);
}
});
/*
* **超限才叫人** —— 这个活动的要害:哨兵读基座按值现数的拦截档条数,
* 而不是活动自报的 `over_total`;且要等 BD 核过价(他自己回调掉的不必再惊动第二个人)。
*/
it('超限拍板的哨兵读基座现数,且等人核过价 —— 不是每条都叫人,也不是一条都不叫', () => {
const condition = sentryIf(model.cmmnXml, 's_over');
expect(condition).toContain('quote_pricing_draft_disposition_block > 0');
expect(condition).toContain('confirmed_total');
// 有拦截档且人核过价 → 叫人
expect(safeEval(condition, { quote_pricing_draft_disposition_block: 1, confirmed_total: 2 })).toBe(true);
// 拦截档清零 → 不叫人(这一步整个不入场)
expect(safeEval(condition, { quote_pricing_draft_disposition_block: 0, confirmed_total: 2 })).toBe(false);
// 人还没核价 → 先别叫(他自己回调掉的那几条不该再惊动一个人)
expect(safeEval(condition, { quote_pricing_draft_disposition_block: 1 })).toBe(false);
});
/*
* 入场哨兵也归「机检只读基座数」管(第四次违反就藏在哨兵里)——
* 上一版 `s_priced` 读活动自报的 `draft_total`:模型不记这个账,门就永不开。
*/
it('交人核价的入场哨兵读基座行数,不读自报的草案条数', () => {
const condition = sentryIf(model.cmmnXml, 's_priced');
expect(condition).toContain('quote_pricing_draft_rows');
expect(condition).not.toContain('draft_total');
expect(safeEval(condition, { line_total: 2, quote_pricing_draft_rows: 2, priced_total: 2 })).toBe(true);
// 行落了但算价那一轮还没走完(priced_total 没写过)→ 门不开
expect(safeEval(condition, { line_total: 2, quote_pricing_draft_rows: 2 })).toBe(false);
});
it('放行门哨兵带 ifPart:每行有价 + 处置守恒 + 拦截清零 + 定盘记过,全读基座数', () => {
const condition = sentryIf(model.cmmnXml, 's_ready');
expect(condition).toContain('quote_pricing_draft_missing = 0');
expect(condition).toContain('quote_pricing_draft_disposition_block = 0');
const done = {
route_decided_total: 1,
quote_pricing_draft_rows: 3,
quote_pricing_draft_missing: 0,
quote_pricing_draft_disposition_pass: 2,
quote_pricing_draft_disposition_note: 1,
quote_pricing_draft_disposition_block: 0,
};
expect(safeEval(condition, done)).toBe(true);
// 还有一行缺拟报价 → 门不开
expect(safeEval(condition, { ...done, quote_pricing_draft_missing: 1 })).toBe(false);
// 还有条目压在拦截档 → 门不开(哪怕守恒仍成立)
expect(
safeEval(condition, { ...done, quote_pricing_draft_note: 0, quote_pricing_draft_disposition_note: 0, quote_pricing_draft_disposition_block: 1 }),
).toBe(false);
// 处置没记齐(守恒不成立)→ 门不开
expect(safeEval(condition, { ...done, quote_pricing_draft_disposition_note: 0 })).toBe(false);
});
it('每个计划项都有剧本', () => {
const ids = items(model.cmmnXml);
expect((model.steps ?? []).map((s) => s.item)).toEqual(ids);
for (const step of model.steps ?? []) {
expect(step.guide.length, `${step.item} 的做法`).toBeGreaterThan(80);
expect(step.intent.length, `${step.item} 的意图`).toBeGreaterThan(6);
}
});
it('说明书不点名计划项', () => {
const named = [...model.narrative.matchAll(/\b(?:S\d+_[A-Za-z]+|T_[a-z]+)\b/g)].map((m) => m[0]);
const ids = new Set(items(model.cmmnXml));
for (const name of named) {
expect(ids.has(name), `说明书提到的 ${name} 不在案例模型里`).toBe(true);
}
});
it('说明书只讲整单的事:成品、怎么说话、怎么收口', () => {
for (const topic of ['开案', '要说话', '一拍连做', '结案']) {
expect(model.narrative, topic).toContain(topic);
}
});
});
describe('声明与契约对得上', () => {
const columns = new Set(
manifest.declarations.ledger.flatMap((d) => [`${d.domain}.${d.object}`, ...d.fields.map((f) => f.name)]),
);
it('必交付物的台账地址都声明过', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(columns.has(d.ledger ?? ''), `${d.key} → ${d.ledger}`).toBe(true);
}
});
it('editableAtRelease 的列都在成果表的声明里', () => {
const draft = manifest.declarations.ledger.find((d) => d.object === 'draft');
const names = new Set((draft?.fields ?? []).map((f) => f.name));
for (const d of manifest.contract.requiredDeliverables) {
for (const col of d.editableAtRelease ?? []) {
expect(names.has(col), `editableAtRelease 里的 ${col}`).toBe(true);
}
}
});
const rowReadings = new Set([
...manifest.declarations.ledger.map((d) => `${d.domain}_${d.object}_rows`),
...manifest.declarations.ledger
.filter((d) => d.fields.some((f) => f.required === true))
.map((d) => `${d.domain}_${d.object}_missing`),
...manifest.declarations.ledger.flatMap((d) =>
d.fields.flatMap((f) =>
(f.tally ?? []).map((v) => `${d.domain}_${d.object}_${f.name}_${v.toLowerCase().replace(/[^a-z0-9_]/g, '')}`),
),
),
]);
it('L1/L2 谓词读的名字都有承载(台账列或基座读数)', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level !== 'L1' && c.level !== 'L2') continue;
for (const identifier of (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? []) {
if (['and', 'or', 'not', 'true', 'false', 'null', 'in', 'between'].includes(identifier)) continue;
if (rowReadings.has(identifier)) continue;
expect(columns.has(identifier), `${c.key} 的谓词引用了 ${identifier}`).toBe(true);
}
}
});
/*
* **机检必须至少有一边是基座数**(违反史已记四次,每次都是同一个死法:
* 那个数要模型自己重算,漏算/改小一次门就废了)。
*/
it('每条 L1/L2 都至少读一个基座读数 —— 不许两边都由活动自报', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level !== 'L1' && c.level !== 'L2') continue;
const names = (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? [];
expect(names.some((n) => rowReadings.has(n)), `${c.key} 一个基座读数都没读`).toBe(true);
}
});
it('出口载荷的标量字段都有台账列承载', () => {
const snake = (name: string) => name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
for (const port of manifest.contract.output.mandatory) {
const properties = (port.payload as { properties?: Record<string, { type?: string }> }).properties ?? {};
for (const [field, spec] of Object.entries(properties)) {
if (spec.type === 'array') continue;
expect(columns.has(snake(field)), `${port.name}.${field} → ${snake(field)}`).toBe(true);
}
}
});
it('技能引用的工具都在白名单声明内 —— 载方法不载权力', () => {
const declared = new Set(manifest.declarations.tools.map((t) => t.name));
for (const skill of manifest.assets.skills) {
for (const fragment of skill.crystallized) {
expect(declared.has(fragment.tool), `${skill.id} 用了 ${fragment.tool}`).toBe(true);
}
}
});
it('L3 判据的整句出现在放行剧本里 —— judge 才对得上名', () => {
const l3 = manifest.contract.acceptanceCriteria.find((c) => c.level === 'L3');
const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
expect(l3).toBeDefined();
expect(release?.guide, 'T_release 的 guide 要整句抄 L3 判据名').toContain(l3?.label ?? '');
});
});
/*
* 两把尺逐行求值 —— 能算的不问 LLM(规范 §5.6)。
* 出参就是台账 `disposition` 的取值:模型算完照抄,不必再翻译一道。
*/
describe('利润模型 P-01 —— 客户等级 × 类别 → 毛利档', () => {
const table = P01_DMN.tables.find((t) => t.id === 'p01-margin');
it('战略客户 + A 类 → 8%~10%(量换价那一档)', () => {
expect(decideBy(table, { customer_grade: '战略客户', type_tag: 'A' })).toMatchObject({ outputs: [0.08, 0.1] });
});
it('普通客户 + A 类 → 12%~15%', () => {
expect(decideBy(table, { customer_grade: '普通客户', type_tag: 'A' })).toMatchObject({ rule: 'p01-2' });
});
it('B 类不看客户等级 → 15%~20%(战略客户的 B 类也走这一档)', () => {
expect(decideBy(table, { customer_grade: '战略客户', type_tag: 'B' })).toMatchObject({ outputs: [0.15, 0.2] });
});
it('C 类替代/停产件 → 20%~50% 一事一议', () => {
expect(decideBy(table, { customer_grade: '普通客户', type_tag: 'C' })).toMatchObject({ rule: 'p01-4' });
});
it('等级与类别都认不出 → 兜底按普通客户标准件档,不是拒绝求值', () => {
expect(decideBy(table, {})).toMatchObject({ rule: 'p01-default', outputs: [0.12, 0.15] });
});
});
describe('底价门禁 P-02 —— 偏离底价线两成即止', () => {
const table = P01_DMN.tables.find((t) => t.id === 'p02-floor');
it('在底价线之上 → PASS 直接放行', () => {
expect(decideBy(table, { deviation: 0.12 })).toMatchObject({ outputs: ['PASS', true] });
expect(decideBy(table, { deviation: 0 })).toMatchObject({ outputs: ['PASS', true] });
});
it('低于底价线两成以内 → NOTE 附理由放行', () => {
expect(decideBy(table, { deviation: -0.08 })).toMatchObject({ outputs: ['NOTE', true] });
});
it('低于两成以上 → BLOCK 拦截,人拍板', () => {
expect(decideBy(table, { deviation: -0.25 })).toMatchObject({ outputs: ['BLOCK', false] });
});
it('三个处置码与台账取值域逐字一致 —— 模型照抄即可写回', () => {
const tally = manifest.declarations.ledger
.find((d) => d.object === 'draft')
?.fields.find((f) => f.name === 'disposition')?.tally;
expect(new Set(table?.rules.map((r) => String(r.then[0])))).toEqual(new Set(tally));
});
});
describe('两把尺的档位三处同一份 —— 判定表 / 剧本 / 工作单', () => {
const guide = manifest.assets.businessModel.steps?.find((s) => s.item === 'S1_price')?.guide ?? '';
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'pricing-worksheet')?.html ?? '';
it('毛利四档三处都写着同样的数', () => {
for (const band of ['8%~10%', '12%~15%', '15%~20%', '20%~50%']) {
expect(guide, `剧本少了 ${band}`).toContain(band);
expect(sheet, `工作单少了 ${band}`).toContain(band);
}
});
/* 门禁在人定完价之后才算得出来,所以三个码写死在底价自检那一步的 guide 里 */
it('三个处置码剧本里写死(大写,一个字母不许差)', () => {
const gate = manifest.assets.businessModel.steps?.find((s) => s.item === 'S3_gate')?.guide ?? '';
for (const code of ['PASS', 'NOTE', 'BLOCK']) expect(gate).toContain(code);
});
});
describe('报告模版 —— 活动持有,容器无脚本执行权', () => {
it('两个局面各一张:定价工作单(干活)与报价草案清单(收口)', () => {
expect(manifest.assets.viewTemplates.map((t) => t.name)).toEqual(['pricing-worksheet', 'pricing-roster']);
});
it('剧本每一步的 view 都在模版名单里 —— 换不到画面屏上就不动', () => {
const names = new Set(manifest.assets.viewTemplates.map((t) => t.name));
for (const step of manifest.assets.businessModel.steps ?? []) {
expect(names.has(step.view ?? ''), `${step.item} 的 view=${step.view}`).toBe(true);
}
});
it('放行那一步摆的是验收目标件 —— 最后摆上去的那张就是归档件', () => {
const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
expect(release?.view).toBe('pricing-roster');
});
it('模版里没有脚本、没有事件处理器、没有外链 —— 样式自带', () => {
for (const template of manifest.assets.viewTemplates) {
expect(template.html, template.name).not.toMatch(/<script|\son[a-z]+=|https?:\/\//i);
expect(template.html, template.name).toContain('<style>');
}
});
it('定价工作单是干活的件:逐颗档案 + 两把尺 + 填回工作表', () => {
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'pricing-worksheet')?.html ?? '';
expect(sheet).toContain('jc-selcard');
expect(sheet).toContain('底价线');
expect(sheet).toContain('利润模型 P-01');
expect(sheet).toContain('底价门禁 P-02');
expect(sheet).toContain('data-edit="quote_pricing.draft"');
});
it('缺价红带与提交闸同源 —— 件上摆的是基座的 `_missing` 与主键名单', () => {
for (const name of ['pricing-worksheet', 'pricing-roster']) {
const html = manifest.assets.viewTemplates.find((t) => t.name === name)?.html ?? '';
expect(html, name).toContain('quote_pricing_draft_missing');
expect(html, name).toContain('quote_pricing_draft_missing_keys');
}
});
/*
* 编辑面精确等于 BD 的职权面(定价与让价理由)+ **行的钥匙 pn**:
* 主键格不挂 `data-col`,服务端按「全表一致列」补键,两行会对到同一行上
* (A2 浏览器走查逮过,e2e 走 API 自带主键测不出来)。
*/
it('编辑面 = 拟报单价 + 让价理由 + 行的钥匙 pn', () => {
for (const name of ['pricing-worksheet', 'pricing-roster']) {
const html = manifest.assets.viewTemplates.find((t) => t.name === name)?.html ?? '';
const cols = [...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]);
expect(new Set(cols), name).toEqual(new Set(['pn', 'quoted_price', 'price_note']));
}
});
it('门禁分布读的是基座按值现数的三段 —— 守恒等式的可视形', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'pricing-roster')?.html ?? '';
for (const code of ['pass', 'note', 'block']) {
expect(roster).toContain(`quote_pricing_draft_disposition_${code}`);
}
});
/*
* A4 只有一个出口(E8 无条件边),**没有去向要判** —— 件上不摆判定表,
* 也不许残留 `case:route*` 的空壳(没有选择组它永远是空的,一屏灰行像判定没跑)。
*/
it('单出口活动:没有选择组,件上也不摆去向判定', () => {
expect(manifest.contract.output.selective).toEqual([]);
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'pricing-roster')?.html ?? '';
expect(roster).not.toContain('case:routeKey');
});
it('模版只做取值 —— data-class-of 不以 data-slot 为前提(纯色块曾整条静默失色)', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'pricing-roster')?.html ?? '';
expect(roster).toContain('<i data-class-of="disposition"></i>');
});
it('宿主拒推含脚本的模版', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
});
});
const CONTRACT = {
activityId: 'quote-pricing',
version: '1.0.0',
data: { rfqRef: 'RFQ MD14.1', rows: [] },
message: undefined,
outputPorts: { mandatory: ['draft'], selective: [] },
prohibitions: [],
discretion: [],
} as const;
describe('代码入口 —— 案例活动没有 run,不是残包', () => {
it('默认导出是一个有名字的活动定义', () => {
expect(activity.name).toBe('quote-pricing');
});
it('mock 宿主如实报「没有 run」,不静默返回一个假的 outcome', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.run(activity)).rejects.toThrow(/没有 run/);
});
});
/** 按 DMN 语义逐行求值:FIRST 命中即定,单元格谓词复用 @sfeel */
function decideBy(
table: (typeof P01_DMN)['tables'][number] | undefined,
scope: Record<string, unknown>,
): { outputs: readonly unknown[]; rule: string } | undefined {
if (table === undefined) return undefined;
for (const rule of table.rules) {
const hit = table.inputs.every((input, i) => evaluateUnaryTest(rule.when[i] ?? '-', scope[input.expression], scope));
if (hit) return { outputs: rule.then, rule: rule.id };
}
return undefined;
}
function items(xml: string): string[] {
return [...xml.matchAll(/<(?:humanTask|decisionTask|processTask|caseTask) id="([^"]+)"/g)].map((m) => m[1] ?? '');
}
function parseCmmnLite(xml: string): { manualActivation: string[]; repetition: string[] } {
const manualActivation: string[] = [];
const repetition: string[] = [];
for (const m of xml.matchAll(/<humanTask id="([^"]+)"[\s\S]*?<\/humanTask>/g)) {
if (m[0].includes('manualActivationRule')) manualActivation.push(m[1] ?? '');
if (m[0].includes('repetitionRule')) repetition.push(m[1] ?? '');
}
return { manualActivation, repetition };
}
function sentryIf(xml: string, sentryId: string): string {
const block = xml.slice(xml.indexOf(`<sentry id="${sentryId}"`));
const raw = /<condition>([\s\S]*?)<\/condition>/.exec(block.slice(0, block.indexOf('</sentry>')))?.[1] ?? '';
return raw.replaceAll('&gt;', '>').replaceAll('&lt;', '<').replaceAll('&amp;', '&');
}
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
@@ -0,0 +1,248 @@
/**
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(A1 的账)。
*/
import { describe, expect, it } from 'vitest';
import { evaluateCondition } from '@sfeel';
import { CMMN_XML, PRICING_PHASES, PRICING_STEPS } from '../src/case-model.ts';
import { manifest } from '../src/manifest.ts';
const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map(
(m) => m[1] ?? '',
);
describe('剧本与案例模型对得上 —— 对不上的条目等于没写', () => {
it('每一条剧本都指向一个真实存在的计划项', () => {
for (const step of PRICING_STEPS) {
expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
}
});
it('图上每一步都有剧本', () => {
for (const id of ITEM_IDS) {
expect(PRICING_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
}
});
it('清单里挂的就是这份剧本(不是另抄一份)', () => {
expect(manifest.assets.businessModel.steps).toBe(PRICING_STEPS);
});
});
describe('doneWhen 是「成果已存在」的判据,不是「该不该做」的判据', () => {
/** 开案那一刻的三种局面:寻源转来 / 受理直达 / 单飞 —— 台账一行没有 */
const FRESH_SOURCED: Record<string, unknown> = {
rfqRef: 'RFQ MD14.1',
rows: [{ pn: 'BBPY00201209601Y00', type_tag: 'B', base_price: 0.058 }],
};
const FRESH_INTAKE: Record<string, unknown> = {
rfqRef: 'RFQ-2026-0555',
records: [{ pn: 'PESD2ETH-XR', type_tag: 'B' }],
};
const FRESH_SOLO: Record<string, unknown> = { subject: '两颗料报个价', body: 'XCJ-7788T 8K……' };
it('**开局一步都不该被自动收掉** —— 三种开局都要 false', () => {
for (const fresh of [FRESH_SOURCED, FRESH_INTAKE, FRESH_SOLO]) {
for (const step of PRICING_STEPS) {
if (step.doneWhen === undefined) continue;
expect(
safeEval(step.doneWhen, fresh),
`${step.item} 的 doneWhen「${step.doneWhen}」开局就为真`,
).toBe(false);
}
}
});
/* ── 算价那一步 ─────────────────────────────────────────────── */
const priced = (over: Record<string, unknown>): Record<string, unknown> => ({
line_total: 2,
quote_pricing_draft_rows: 2,
priced_total: 2,
...over,
});
it('整份落表、行数对上、算价那一轮走完 → 收得掉', () => {
expect(safeEval(doneOf('S1_price'), priced({}))).toBe(true);
});
it('**全是无基准的单子也收得掉** —— 一条都算不出时 priced_total 是写过的 0', () => {
expect(safeEval(doneOf('S1_price'), priced({ priced_total: 0 }))).toBe(true);
});
it('**第一拍写完行就收步是不许的** —— priced_total 没写过,未绑定的名字比大小为假', () => {
const scope = priced({});
delete scope['priced_total'];
expect(safeEval(doneOf('S1_price'), scope)).toBe(false);
});
it('行数两边对不上 → 收不掉(漏落一行当场现形,不等到第 10 拍)', () => {
expect(safeEval(doneOf('S1_price'), priced({ quote_pricing_draft_rows: 1 }))).toBe(false);
});
/* ── 三处不给 doneWhen ──────────────────────────────────────── */
it('**交人定价、超限拍板、放行三项都不给 doneWhen** —— 给了等于给引擎一把替人签字的钥匙', () => {
for (const item of ['S2_review', 'T_approve', 'T_release']) {
expect(PRICING_STEPS.find((s) => s.item === item)?.doneWhen, `${item} 不该有 doneWhen`).toBeUndefined();
}
});
/* ── 底价自检:判完了才算办完(收步判据与验收 L2 同源)────────────── */
const gated = (over: Record<string, unknown>): Record<string, unknown> => ({
quote_pricing_draft_rows: 3,
quote_pricing_draft_disposition_pass: 2,
quote_pricing_draft_disposition_note: 1,
quote_pricing_draft_disposition_block: 0,
...over,
});
it('三档处置的条数加起来等于行数 → 底价自检收得掉', () => {
expect(safeEval(doneOf('S3_gate'), gated({}))).toBe(true);
});
it('**有拦截档也照样收得掉** —— 判出来了就是判完了,拦截档由下一步叫人拍板', () => {
expect(
safeEval(doneOf('S3_gate'), gated({ quote_pricing_draft_disposition_note: 0, quote_pricing_draft_disposition_block: 1 })),
).toBe(true);
});
it('还有行没判 → 收不掉(松出来的那一截就是放行门前的返工)', () => {
expect(safeEval(doneOf('S3_gate'), gated({ quote_pricing_draft_disposition_note: 0 }))).toBe(false);
});
it('**一行都还没写时 0+0+0 = 0 不算判完** —— 全零相等那一坑,靠 rows > 0 挡住', () => {
expect(
safeEval(doneOf('S3_gate'), {
quote_pricing_draft_rows: 0,
quote_pricing_draft_disposition_pass: 0,
quote_pricing_draft_disposition_note: 0,
quote_pricing_draft_disposition_block: 0,
}),
).toBe(false);
});
it('归一那一步声明了 reads: rows 与 records —— 上游载荷置前不截断', () => {
const reads = PRICING_STEPS.find((s) => s.item === 'S1_price')?.reads ?? [];
expect(reads).toContain('rows');
expect(reads).toContain('records');
});
/*
* **一单同一张卡只发一次**,且缺信息不再走补全卡(2026-08-02 全局退役)——
* 剧本里不许残留「还缺就再发一张卡」这条已经不存在的路。
*/
it('剧本不教补全卡,也不留「还缺就再发一张」的死路', () => {
for (const step of PRICING_STEPS) {
expect(step.guide, `${step.item} 的 guide 提到了补全卡`).not.toMatch(/补全卡|"kind":"ask"/);
expect(step.guide, `${step.item} 的 guide 教了再发一张卡`).not.toMatch(/再发一张卡|重新发一张卡/);
}
});
/*
* **一单只发一张卡**:人点完之后这一步会被重新念一遍,而 guide 的头一段说什么,
* 模型就先做什么 —— 头一段若是「发一张决策卡」,它就再发一张(实测连发四张,
* 调人预算烧光 exhausted)。所以拍板那一步的 guide 必须**先分岔再发卡**。
*/
it('拍板那一步先问「人点过没有」,再谈发卡 —— 分岔要排在发卡骨架前面', () => {
const guide = PRICING_STEPS.find((s) => s.item === 'T_approve')?.guide ?? '';
const branch = guide.indexOf('已经有');
const skeleton = guide.indexOf('【发一张决策卡');
expect(branch, 'T_approve 的 guide 没有「人点过没有」这一岔').toBeGreaterThanOrEqual(0);
expect(skeleton, 'T_approve 的 guide 没有发卡骨架').toBeGreaterThan(branch);
expect(guide).toMatch(/不许再发|别再发第二张卡/);
});
/*
* start 墙已拆(2026-08-02):动它就算启动。剧本里再教 start 只是白烧 token,
* 而且学不稳的礼仪就是死法。
*/
it('剧本不教 start 礼仪 —— 那道墙已经拆了(说「不必单发 start」可以,教它先按一下不行)', () => {
for (const step of PRICING_STEPS) {
expect(step.guide, `${step.item} 的 guide 还在教 start`).not.toMatch(/先\s*start|务必\s*start|要先发\s*start/);
}
});
});
describe('剧本声明的东西活动真有', () => {
const declaredLedger = manifest.declarations.ledger.map((l) => `${l.domain}.${l.object}`);
const declaredTools = manifest.declarations.tools.map((t) => t.name);
it('每一步要写的台账都在清单的台账声明里', () => {
for (const step of PRICING_STEPS) {
for (const address of step.writes) {
expect(declaredLedger, `${step.item} 要写 ${address}`).toContain(address);
}
}
});
it('每一步要调的工具都在清单的工具声明里', () => {
for (const step of PRICING_STEPS) {
for (const tool of step.tools) {
expect(declaredTools, `${step.item} 要调 ${tool}`).toContain(tool);
}
}
});
it('每一步都定死了摆哪张件,且模版真在包里', () => {
const names = new Set(manifest.assets.viewTemplates.map((t) => t.name));
for (const step of PRICING_STEPS) {
expect(step.view, `${step.item} 没定画面`).toBeDefined();
expect(names.has(step.view ?? ''), `${step.item} 的 view「${step.view}」不在模版名单里`).toBe(true);
}
});
it('每一步都说得出它交出什么', () => {
for (const step of PRICING_STEPS) {
expect(step.intent.trim(), `${step.item} 没写 intent`).not.toBe('');
expect(step.guide.trim().length, `${step.item} 的 guide 太短`).toBeGreaterThan(20);
}
});
/* 卡面是纯文本:星号记号不会变粗体,只会原样摆在人眼前(规范 §5.5-10) */
it('发卡骨架里不写星号记号 —— 卡面是纯文本', () => {
for (const step of PRICING_STEPS) {
for (const line of step.guide.split('\n')) {
if (!/"steps"|"standard"|"ask"|"label"|"consequence"/.test(line)) continue;
expect(line.includes('**'), `${step.item} 的卡面文案带了星号:${line.slice(0, 60)}`).toBe(false);
}
}
});
});
describe('阶段刻度:每一项都有格可待', () => {
const inPhase = new Set(PRICING_PHASES.flatMap((p) => p.items));
it('**除放行外每一项恰好属于一个阶段**', () => {
const planned = PRICING_STEPS.map((s) => s.item).filter((id) => id !== 'T_release');
expect(planned.filter((id) => !inPhase.has(id))).toEqual([]);
});
it('一项不进两个阶段', () => {
const all = PRICING_PHASES.flatMap((p) => p.items);
expect(all.length).toBe(new Set(all).size);
});
it('**放行不在名单里** —— 收口那一格由基座统一加', () => {
expect(inPhase.has('T_release')).toBe(false);
});
it('阶段里不许出现案例模型没有的项', () => {
const ids = new Set(PRICING_STEPS.map((s) => s.item));
expect([...inPhase].filter((id) => !ids.has(id))).toEqual([]);
});
});
function doneOf(item: string): string {
const found = PRICING_STEPS.find((s) => s.item === item)?.doneWhen;
if (found === undefined) throw new Error(`${item} 没有 doneWhen`);
return found;
}
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
+363 -57
View File
@@ -1,81 +1,387 @@
/**
* A4 成本定价的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
*
* 2026-07-29 由步序声明(flow)改写而来。原六步各归其位:取价基准与逐条定价属「算价」,
* 偏离率与底价门禁属「自检」(两支 DMN:P-01 利润模型、P-02 底价门禁),
* 超限条目调人决策是**触达客户利益的一下**,必须人工激活;末尾一张验收单。
* 业务出处:`docs/MVP示例工作流.html` §1.2(S1 取价基准 → S2 逐条定价 → S3 底价自检),
* 按 A3 同一条形状建模(2026-08-03 重写,@3.0.0):
*
* 上游(A3 的目标报价清单 / A2 的询价受理清单 / A1 直达的受理记录)进来 → 归一成
* **报价草案**底表,逐条按 P-01 利润模型算出底价线与拟报单价、过 P-02 底价门禁 →
* **一张执行单**交 BD 核价(拟报单价可校可改,无基准的那几行由他定价)→
* **只有压在拦截档上的那几条才叫人拍板**(决策卡)→ 放行,整份报价草案交定价审批。
*
* 上一版(@2.0.0)三处不合现行口径,这一版整个置换:
* ① 入参必填 `rfqRef + customerCode + records` —— 而 A3 发的是 `{rfqRef, rows}`,
* 门口拦的恰是主路(现在宽进到底,归一在活动内做);
* ② 台账一个 `required` 都没有 —— 执行卡的闸没牙,人不填也能交卷;
* ③ 入场哨兵 `s_priced` 读活动自报的 `draft_total`(「机检只读基座数」第 5 类违反)——
* 模型不记这个账,门就永不开,而它一步也没做错。
*/
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
<case id="pricing">
<!--
案卷项的 id **就是任务视图里的键名**。上游载荷(rows / records)与本活动台账列
都摊平到任务视图顶层,照它们命名;quote_pricing_* 那几个是**基座读数**
(行数 / 缺必填 / 按值现数),由基座现算,模型碰不着。
-->
<caseFileModel>
<caseFileItem id="records" name="待定价条目"/>
<caseFileItem id="basis" name="取价基准"/>
<caseFileItem id="draft" name="报价草案"/>
<caseFileItem id="floors" name="底价表"/>
<caseFileItem id="rows" name="上游清单行(寻源/选型转来,流内开案即有)"/>
<caseFileItem id="records" name="上游受理记录(受理直达时有)"/>
<caseFileItem id="line_total" name="草案条目数(自报)"/>
<caseFileItem id="priced_total" name="已算出拟报价的条数"/>
<caseFileItem id="confirmed_total" name="BD 已核价条数(人环走完的凭据)"/>
<caseFileItem id="route_decided_total" name="已定盘"/>
<caseFileItem id="quote_pricing_draft_rows" name="草案行数(基座现数)"/>
<caseFileItem id="quote_pricing_draft_missing" name="缺拟报单价的行数(基座现数)"/>
<caseFileItem id="quote_pricing_draft_disposition_block" name="压在拦截档的条数(基座现数)"/>
</caseFileModel>
<casePlanModel id="plan" name="成本定价" autoComplete="true">
<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>
<!--
① 算完了 —— 交 BD 核价那一步的入场前提。
行数两边对上(自报 = 基座现数),且算价那一轮走完(priced_total 是算完之后
显式写下的数;「大于等于 0」对「写过的 0」为真、对「没写过」为假)。
-->
<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="priced_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_pr"><condition>line_total &gt; 0 and quote_pricing_draft_rows = line_total and priced_total &gt;= 0</condition></ifPart>
</sentry>
<sentry id="s_breach">
<onPart id="op_br" caseFileItemRef="draft"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_br"><condition>breaches &gt; 0</condition></ifPart>
<!--
② 人定完价了 —— 底价自检那一步的入场前提。
confirmed_total 只在人交回执行单之后才产生,它就是「经过人了」的凭据。
-->
<sentry id="s_confirmed">
<onPart id="op_cf" caseFileItemRef="confirmed_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_cf"><condition>confirmed_total &gt; 0</condition></ifPart>
</sentry>
<!--
③ **超限才叫人** —— 这个活动的要害。
「压在拦截档上的条数」由基座按 disposition 的取值域现数(不是活动自报),
且要等 BD 核过价(confirmed_total 只在人交回执行单之后才产生)——
他自己回调掉的那几条不必再惊动第二个人。
-->
<sentry id="s_over">
<onPart id="op_ov" caseFileItemRef="quote_pricing_draft_disposition_block"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_ov"><condition>quote_pricing_draft_disposition_block &gt; 0 and confirmed_total &gt; 0</condition></ifPart>
</sentry>
<!--
── 放行门的入场条件:**认「办完了没有」,且读基座数**────────────────
· 每行都有拟报单价(quote_pricing_draft_missing 由基座按 required 现数,
与执行卡的提交闸、验收 L1 同一个数同一套算法);
· 门禁处置逐条落定且守恒(三档相加 = 行数,两边都是基座按值现数);
· **没有还压在拦截档上的条目**(要么回调、要么经人拍板改记附理由放行);
· 定盘过了(route_decided_total 是定盘那一步显式写下的记账数)。
-->
<sentry id="s_ready">
<onPart id="op_rd" caseFileItemRef="route_decided_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_rd"><condition>route_decided_total &gt; 0 and quote_pricing_draft_rows &gt; 0 and quote_pricing_draft_missing = 0 and quote_pricing_draft_disposition_block = 0 and quote_pricing_draft_disposition_pass + quote_pricing_draft_disposition_note + quote_pricing_draft_disposition_block = quote_pricing_draft_rows</condition></ifPart>
</sentry>
<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="S1_price" name="归一报价草案:取成本基准、按利润模型算底价线与建议价">
<itemControl><repetitionRule id="r_s1"/></itemControl>
</humanTask>
<timerEventListener id="TL_half" name="时限过半">
<timerExpression>PT8H</timerExpression>
</timerEventListener>
<!--
② 交 BD 核价 —— **一张执行单**(拟报价可校可改,无基准的行由他定价)。
manualActivation + 不给 doneWhen(发卡触达真人,引擎不替人按)。
-->
<humanTask id="S2_review" name="交BD定价:工作单上逐条定价,收回后过底价门禁">
<entryCriterion id="ec_s2" sentryRef="s_priced"/>
<itemControl><manualActivationRule id="m_s2"/></itemControl>
</humanTask>
<!--
③ 底价自检 —— SOP 里的 S3,一件独立的事:按人定的价算毛利、偏离度、门禁处置。
它不触达世界,所以给 doneWhen(三档处置的条数加起来等于行数就算判完了),
引擎自己收 —— 从前这一段藏在「交人核价」的收尾里,模型写完 confirmed_total
就把那一项收了,三列处置一格没写,放行门当场判「处置没落定」,而拒回只会说
「数不对/行不对」—— 两扇打不开的门(实测 8 次拒回熔断)。
-->
<humanTask id="S3_gate" name="底价自检:按定下的价算毛利与偏离度,逐条过底价门禁">
<entryCriterion id="ec_s3" sentryRef="s_confirmed"/>
<itemControl><repetitionRule id="r_s3"/></itemControl>
</humanTask>
<!-- ④ 超限拍板:触达客户利益的那一下,必须人工激活;只在拦截档还有条目时入场 -->
<humanTask id="T_approve" name="超限拍板:压在拦截档的条目要人定怎么办">
<entryCriterion id="ec_ap" sentryRef="s_over"/>
<itemControl><manualActivationRule id="m_ap"/><repetitionRule id="r_ap"/></itemControl>
</humanTask>
<!-- 草案放行:终局唯放行 —— 触达世界,必须人工激活 -->
<humanTask id="T_release" name="报价草案验收放行">
<entryCriterion id="ec_rel" sentryRef="s_ready"/>
<itemControl><manualActivationRule id="m_rl"/></itemControl>
</humanTask>
</casePlanModel>
</case>
</definitions>`;
export const NARRATIVE = `§0 开案:拿到一批待定价条目即开案。成品是**一份全条目带偏离度的报价草案** ——
每条有拟报单价与毛利率,且都过了底价门禁。
/**
* 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本,两处不重复写同一件事)。
*/
export const NARRATIVE = `§0 开案:**一批料要定出各自的拟报单价**。来路三种,形状都收(宽进):
· **寻源转来**:任务数据里有 \`rows\` 数组 —— 目标报价清单的落地行,每行带底价(base_price)
与价源;这是最全的一种来路;
· **受理/选型直达**:有 \`records\` 或 \`rows\` 而行上没有底价 —— 只有料号与年用量;
· **单飞**:只有一段需求文字,列了几颗料要报价。
S1 算价:先取价基准(历史成交价 / 供应商回价 / 替代料对标价,按可得性择优),
再按利润模型 P-01 逐条定价 —— 客户等级、数量档、料号类型、行情共同决定加价率。
基准取不到的条目不许拍脑袋定价,回头找寻源或标注「无基准」。
**成品只有一样**:一份**报价草案** —— 整份行都在、**每一行都有拟报单价**,
而且每个价都说得出来路:基准从哪来、毛利档是哪一档、偏离底价线多少、门禁怎么判。
人签的不是一个数字,是**这个数字的来路**。它是定价审批的直接输入。
S2 自检:算出偏离度(基准价 ↔ 拟报价),按底价门禁 P-02 逐条判。
**偏离 ±20% 以内自动放过;超限的一律调人** —— 要么回调价格,要么写明为什么这样报。
底价超一周没更新的先刷底价再判(裁量项),拿旧底价判新行情等于没判。
**能算的不问人,该人签的不代签**:P-01(利润模型)给毛利档、P-02(底价门禁)给处置,
两支判定表说了算,你不临场拍价;而**拟报单价那一列是 BD 定的,你一格都不写** ——
你给的是**建议价**,他确认或改成自己的数。你的活是把判据取齐、把结论落进台账。
§Z 结案:每条都有拟报单价与毛利率、超限条目已处置、BD 签字放行,自动结案。
草案交出去就是下游议价的起点 —— 它不是最终报价,但每一条都要经得起问「为什么是这个价」。`;
**四步走**:归一取基准算建议价 → 交 BD 定价 → 底价自检(按他定的价算毛利、偏离度、
门禁处置)→ 定盘放行。
**这一单要经过人一到两次**:BD 在定价工作单上逐条定价(那张单绑着整份草案,
拟报单价缺一行按不下提交 —— 基座的闸看着,他填的格子直接落账);
**只有还压在拦截档上的条目才另外叫人拍板**(一张决策卡)。每条都叫人,人就成了橡皮图章;
一条都不叫,底价门禁就是摆设。最后 BD 放行签字。
【一路上要说话】负责人正看着这一单。**每一拍都带一句 say** —— 说业务的话:
这批料算下来什么价、哪几条压线、要不要找人拍板;不说系统词。
到了要找人的那一拍,先说清这一轮办完了什么、为什么现在需要他。
【怎么把一项干完 · 一拍连做】write 和 tool 都不会自动收步 —— 做完用 finish 宣告。
动作排进同一拍的 actions 串一口气给;只有调工具(出参下一拍才看得见)与
发卡 / 提交(挂起)必须停拍。**这一单常路 7-9 拍就该收口**。
§Z 结案:BD 在验收单上签字,系统把草稿合并成正式草案并发出 draft 口的载荷 ——
整份报价草案交给定价审批。`;
/**
* 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。
*
* 三处不给 `doneWhen`:`S2_review`(发卡触达真人)、`T_approve`(要人拍板)、
* `T_release`(签下去触达世界)—— 给了等于给引擎一把替人签字的钥匙。
*/
export const PRICING_STEPS = [
{
item: 'S1_price',
intent: '整份归一成报价草案:取成本基准 → 按利润模型算出底价线与建议价,逐条落表',
writes: ['quote_pricing.draft', 'quote_pricing.verdict'],
/* 上游载荷数组不声明就会被整表段的截断刀砍掉(砍掉的恰是要归一的行) */
reads: ['rows', 'records'],
view: 'pricing-worksheet',
tools: ['crm.history-price-read'],
/*
* 归一 + 算价两轮都走完才算办完:行数两边对上(一边是基座现数),
* 且 priced_total 写过(全是无基准的单子这个数就是 0;S-FEEL 未绑定的名字
* 参与比较为假,所以「没写过」进不来)。
*/
doneWhen: 'line_total > 0 and quote_pricing_draft_rows = line_total and priced_total >= 0',
guide: `第一件事:把这一单的**报价草案**落出来,并把能算的价都算出来。
【料从哪儿来】
· 寻源转来(\`rows\` 里的行带 base_price / price_basis):照抄 —— 底价就是成本基准,
一个数字都别改;带 quote_line_total 这类总数的是结论行,不是料,别落进草案。
· 受理/选型直达(\`records\` 或没有底价的 \`rows\`):料号、品类、制造商、年用量、
客户目标价、类别(type_tag)照抄,**底价一栏留空**。
· 单飞:照正文归一,列了几颗落几行;没说类别的按 B。
**按 pn 归并,一颗料一行**;pn 一个字符不许改(它是主键,改一个字就是新开一行)。
【第一拍:只做两件事 —— 落草案行 + 串尾发一次查价】
① 写 \`quote_pricing.draft\`,一颗料一行(rows 收数组):
rfq_ref(入参有就原样抄;没有从主题认;认不出才自己造一个,**造出来的必须以 \`PRC-\` 开头**
(\`PRC-<主题或第一颗料的型号>\`)。两条硬规矩:
**① 整单只有一个单号,不是一颗料一个** —— 它是这一单的案号,草案所有行、verdict 那一行
用的都得是同一个串(实测栽过:两颗料造出两个号,一单裂成两单);
**② 只从本单来件取材** —— 台账里躺着的 RFQ-xxxx 是别人的单,借它的号等于把两单的行
混进同一个案卷(实测栽过一轮))、pn、customer_pn、category、
brand、eau、target_price(客户目标价)、lead_time、remark(客户约束/急料,一个字别丢)、
type_tag(照上游现标;单飞按 B)、base_price(上游给了就抄,没有留空)。
suggested_price / margin_band / floor_price **这一拍全部留空** —— 基准还没取齐。
**quoted_price / margin / deviation / disposition 这四列从头到尾都不是你写的**(见下)。
② 串尾调 \`crm.history-price-read\`:pns 收数组,把**底价还空着的那几颗料**一次传完
(上游已经给了底价的不必查)。
**这一拍不写 \`quote_pricing.verdict\`,一列都不写** —— 尤其 priced_total:
它是「算价走完了」的凭据,提前写等于把闸提前打开,引擎会在下一拍开头当场收掉这一步,
**算价从此不会发生**(A3 实测栽过:嘴上说预填了,台账里没有)。
【**这一列你一格都不写**:quoted_price】
拟报单价是 **BD 在工作单上定的那个数** —— 人签的就是它。你给的是**建议价**
(suggested_price),他确认或改成自己的数;margin / deviation / disposition 三列
要等他定完价才算得出来,这一步也别写。
这不是客气话:从前只有一列价、剧本写着「算不出的留空」,实测跑单时两颗库里查不到的料
照样被编出了价(一颗抄客户目标价、一颗凭空写了个数),而交代里还说着「价空着等你定」。
**一条要你分辨「这行算得出/算不出」的规矩会走岔;「这一列不写」没有分支可走岔。**
【第二拍:算建议价 + 记账 + 收步,一拍连做】
① 补基准:**只补上一拍 base_price 空着的那几行** —— 工具出参里 hit=true 的写
base_price = lastPrice、price_basis 写 **HIST**;hit=false 的**别编价**,base_price 留空,
它就是「无基准」那一档。
**上游已经给了 base_price 的行:工具回什么都不采用,那一格一个数字都别改** ——
上游给的是本单问回来的货源底价,工具给的是库内历史价;这一单的成本是前者
(实测栽过:两行上游底价被历史价盖掉,毛利从 20% 变成 115%,数字全对而结论是假的)。
② 逐条算**建议价**,规矩写死,照做即可(**这些是算术,不是判断**):
· 毛利档 margin_band 按利润模型 P-01 取(客户等级 × 类别):
战略客户 + A 类(大批量长单)→ 8%~10%;普通客户 + A 类 → 12%~15%;
B 类 → 15%~20%;C 类 → 20%~50%;拿不准按 12%~15%。写成「12%~15%」这样的文本。
· **底价线** floor_price = base_price ×(1 + 档下限)。保留四位小数。
· **建议价** suggested_price:
- 客户没给目标价(target_price 空)→ 取 base_price ×(1 + 档上限);
- 客户目标价 ≥ 底价线 → 就取客户目标价(接得住,毛利还在档内);
- 客户目标价 < 底价线 → 仍取客户目标价(贴着客户报),这一条到门禁那儿会亮灯;
- **base_price 空(无基准)→ margin_band / floor_price / suggested_price 三列一起留空**。
不要拿一个不相干的价去凑一个数出来 —— 那比没有建议更危险,它看起来像有依据。
· **「没有」要给空串 \`""\`,不许写 0** —— 0 是一个数,底价线写 0 看起来像「成本为零」,
偏离度写 0 看起来像「正好压在底价线上」。空串基座会当成「这一列不写」。
③ 这时才写 \`quote_pricing.verdict\`(一次写全):rfq_ref、customer_code / customer_name
(顶层有就抄)、customer_grade(来件认得出就写,认不出写「普通客户」)、
**line_total** = 草案行数、**basis_total** = 有成本基准的条数、
**priced_total** = 这一拍算出建议价的条数(0 也要写)。
④ finish 这一项。`,
},
{
item: 'S2_review',
intent: '一张执行单交 BD:工作单上逐条定价(拟报单价归他);收回后过底价门禁并定盘',
writes: ['quote_pricing.draft', 'quote_pricing.verdict'],
reads: ['quote_pricing.draft'],
view: 'pricing-worksheet',
tools: [],
/* 不给 doneWhen —— 发卡触达真人(manualActivation),引擎不替人按 */
guide: `建议价算完了,**该 BD 出场了** —— 这是本活动唯一的一张执行单,
**拟报单价是他定的,不是你定的**。工作区摆的是**定价工作单**:逐颗料一张定价档案卡
(成本基准、毛利档、客户目标价、底价线、系统建议价),利润模型与底价门禁两把尺挂在上面,
填回区长在同一屏 —— 他在「拟报单价」那一列逐行填(照建议价确认,或改成自己的数)。
这一步标了人工激活:发卡就算启动,不必单发 start;引擎也不会替你收 —— 人交回后要你自己 finish。
【照这个骨架发,别少字段】(卡面是纯文本,steps/standard 里**别写星号记号**):
{"kind":"human","itemId":"S2_review","workOrder":"execution","card":{
"title":"报价定价:<单号> 共 <N> 条待你定价",
"ask":"系统已按成本基准与利润模型算出每一颗的建议价,请在工作单的「拟报单价」一列逐行定价:认可建议价就照抄,不认可就改成你的数;库内没有成本基准的那几行没有建议价,由你定。",
"steps":["拟报单价每一行都要有 —— 缺一行这张单按不下提交;它是这一单最后签下去的那个数,系统不替你填",
"建议价是从成本基准按利润模型的毛利档推出来的:照抄就是认可这个毛利,改高改低都可以,改了在备注里写一句缘由",
"低于底价线的价会被底价门禁标出来:两成以内附理由即可放行,超过两成的要另外找人拍板 —— 你在这里往上调一点,就不必再惊动一个人",
"库内查不到成本的料没有建议价:按你的经验定价,并在备注写明依据;实在定不了的按行首 × 把这一条撤出本轮"],
"standard":"每一行都有拟报单价,而且每个价都说得出来路(基准 / 毛利档 / 让价理由)—— 缺一行这张单按不下提交(系统的闸看着)。",
"basis":[{"k":"<PN>","v":"<类别 · 成本基准 · 毛利档 · 建议价>"}]}}
· \`basis\` 逐条列本单的料:PN + 基准与建议价。几颗写几条,别铺全表。
· **卡上不摆表、不摆格子**(执行卡零输入):整份草案就在工作单那份件上,
拟报单价与备注两列都能就地改、行能撤 —— **别往卡里塞 \`blocks\` 或 \`fields\`**。
· **这张卡带提交闸**:拟报单价(必填列)还空着,他按不下提交 —— 缺几格、缺在哪行,
闸上与件上的红带都写着(与放行门 L1 同一个数同一套算法)。
发完卡这一拍就结束了(发卡挂起)。
【人交回之后 —— 他填的价基座已经替你写回台账了】这一步你只做两件事,然后 finish:
① 写 verdict:**confirmed_total** = 他定过价的条数(照草案行数记);
他在件上撤掉了行的话,**line_total 跟着改成撤后的实数**。
② finish 这一项。
**别在这一拍算门禁** —— 底价自检是下一步的活(它有自己的一段做法),这里算了也白算。
草案行你一列都不用重写:他填的值基座已经落进台账了,重写会被「写了等于没写」拒回。
【这张卡一单只发一次】人交回来之后不许再发同一张卡 —— 必填有提交闸兜着,
卡回来时拟报单价一定是齐的(或那几行已被他撤掉)。`,
},
{
item: 'S3_gate',
intent: '底价自检:按人定下的价逐条算毛利、偏离度与门禁处置,判完即定盘',
writes: ['quote_pricing.draft', 'quote_pricing.verdict'],
reads: ['quote_pricing.draft'],
view: 'pricing-worksheet',
tools: [],
/*
* **收步判据与验收判据同源**:三档处置的条数加起来等于行数(两边都是基座现数)——
* 判完了才算办完。`_rows > 0` 那一半不能省:开案时行数是 0,而 0+0+0 = 0
* 会让这一步开局即收(未绑定名字的坑之外,还有「全零相等」这一坑)。
*/
doneWhen:
'quote_pricing_draft_rows > 0 and quote_pricing_draft_disposition_pass + quote_pricing_draft_disposition_note + quote_pricing_draft_disposition_block = quote_pricing_draft_rows',
guide: `人把价定完了,**现在才轮到算门禁** —— 这一步是纯算术,一拍连做:
① **逐行按他定的 quoted_price 算三列**(**quoted_price 你一格都不写,只写这三列**):
· margin =(quoted_price − base_price)/ base_price,四位小数;
· **偏离度** deviation =(quoted_price − floor_price)/ floor_price,四位小数;
· **门禁处置** disposition 按底价门禁 P-02 判,只许写这三个大写码之一,一个字母不许差:
- deviation ≥ 0 → **PASS**(在底价线之上,直接放行)
- −0.20 ≤ deviation < 0 → **NOTE**(低于底价线两成以内,附理由放行)
- deviation < −0.20 → **BLOCK**(低于两成以上,压在拦截档,要人拍板)
· **base_price 空(无基准)的行**:margin / deviation 给空串 \`""\`(**不许写 0** ——
0 看起来像「正好压在底价线上」,而这一行压根没有底价线)、disposition 记 **NOTE**,
price_note 里保留他写的定价依据(他没写就补一句「库内无成本基准,由 BD 定价」)。
**一行都不能漏**:三档处置的条数加起来必须等于 quote_pricing_draft_rows(基座数的行数)。
现在三档都是 0,就是一行都还没判 —— 这一步没办完,放行门开不了。
② 看这一拍写完之后的**拦截档条数**(disposition = BLOCK 的行):
· 一条都没有 → 同一拍把 verdict 的 **route_decided_total 记 1**(定盘)、
summary 写一句人话(几条直接放行、几条附理由),然后 finish 这一项;
· 还有 → **别写 route_decided_total**,只 finish 这一项 —— 下一拍会有一步专门叫人拍板。`,
},
{
item: 'T_approve',
intent: '压在拦截档的条目要人拍板 —— 发一张决策卡,照他选的那条路把草案改掉',
writes: ['quote_pricing.draft', 'quote_pricing.verdict'],
reads: ['quote_pricing.draft'],
view: 'pricing-roster',
tools: [],
/* 人工激活 + 要人拍板 —— 不给 doneWhen,引擎不许替人收 */
guide: `**这一单只发一张决策卡。动手之前先分清你在哪一半:**
· 轨迹里**已经有**「decision 卡回传」/「人选了:xxx」—— 那就跳到下面【人点完之后】那一段,
照他选的那条路改草案、写 route_decided_total、finish。**一个 decide 都不许再发**
(实测栽过:人点完了,而这一步又从头念了一遍「发一张决策卡」,同一张卡连发四次,
调人预算烧光,单子 exhausted —— 他点过的那一下就白点了);
· 轨迹里**还没有** —— 才轮到下面【发一张决策卡】那一段。
这一步只在还有条目压在拦截档(基座现数 quote_pricing_draft_disposition_block > 0)时入场;
它触达客户利益,标了人工激活:发卡就算启动,不必单发 start。
工作区这时摆的是**报价草案清单**,整份草案与门禁分布都在上面,他看得见全局再拍板。
【发一张决策卡,照这个骨架】(选项少于两个基座拒发):
{"kind":"decide","itemId":"T_approve","ask":"<N> 条低于底价线两成以上,按哪条路办?",
"options":[
{"value":"floor","label":"回调到底价线报","consequence":"守住毛利下限,客户可能不接受这个价","recommended":true},
{"value":"as-is","label":"照现价破线报,附理由","consequence":"接住客户目标价拿单,毛利跌破下限,理由随草案交审批"},
{"value":"drop","label":"这几条本轮不报","consequence":"撤出本单草案,客户拿到的是一份缺项的报价"}],
"basis":[{"k":"<PN>","v":"成本基准 <X> · 底价线 <Y> · 拟报 <Z> · 偏离 <W>"}]}
· basis 逐条摆压在拦截档的那几颗料:基准、底价线、拟报价、偏离度 —— 裸卡基座拒发。
· 发完这一拍就结束了(发卡挂起)。
【人点完之后】轨迹上会出现「decision 卡回传 —— 人选了:xxx」。**照那条路把草案改掉,
别再发第二张卡**,一拍连做:
· 选 floor:**这是全活动唯一一次你可以写 quoted_price** —— 那个数不是你定的,
是他在卡上点下的(就是该行的底价线),你只是替他落账:
那几行 quoted_price 改成本行的 floor_price,margin 记该行毛利档的下限、deviation 记 0、
disposition 改 **PASS**,price_note 写「按底价线回调,由负责人拍板」;
· 选 as-is:价不动,disposition 改 **NOTE**,price_note 写明破线的理由与拍板人;
· 选 drop:用 \`drop\` 把那几行撤出本单草稿(主键照 \`quote_pricing_draft_keys\` 名单**原样粘**),
并把 verdict 的 line_total 改成撤后的实数。
然后写 verdict:**route_decided_total 记 1**、summary 补一句拍板结果,finish 这一项。
拦截档清零之后,放行门才开得了。`,
},
{
item: 'T_release',
intent: '草案已定盘,提交放行交 BD 验收',
writes: [],
reads: [],
view: 'pricing-roster',
tools: [],
/* 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 doneWhen) */
guide: `每行有价、门禁处置守恒、拦截档清零、定盘已记 —— 可以走放行了
(T_release 人工激活:动它就算启动,不必单发 start)。先把裁量判据裁掉,然后 submit,同一拍连做:
· judge criterion="报价的来路说得清:基准价来源、毛利档与让价理由逐条可溯,BD 对报价负责" ——
看草案里每一行的基准、毛利档与备注:说得出来路就 satisfied=true;
有行说不出的(比如无基准由人定的价没写缘由),satisfied=**false**,rationale 逐行写清,
交签字的人定。**裁成不满足照样能提交** —— 理由连着卡一起交给他。
criterion 照判据名**整句原样抄**,写别的名字对不上。
终局唯放行:你不能自己宣布完成。`,
},
];
/**
* 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段;
* 放行那一格由基座统一加,人看到的是 x/4。
*/
export const PRICING_PHASES = [
{ name: '取基准算建议价', items: ['S1_price'] },
{ name: '交BD定价', items: ['S2_review'] },
{ name: '底价自检', items: ['S3_gate'] },
{ name: '超限拍板', items: ['T_approve'] },
];
+12 -75
View File
@@ -1,80 +1,17 @@
/**
* 统一报价 A4 · 成本定价 —— 活动包代码入口(ADR-004)。
* 统一报价 A4 · 成本定价 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
*
* flow 活动:下一步由步序声明给定。本文件只做声明装不下的两件事 ——
* 视图流控,与「结论不可静默覆写」的呈现:P-02 拦下的条目必须显式呈现给人。
* **这个活动没有 `run`,不是缺件。**
*
* 案例活动的推进由 Loop 驱动,行为全部写在清单里:案例模型 + 说明书 + 步骤剧本 +
* 两把判定表 + 两条技能 + 台账声明 + 两张 HTML 报告模版。
* 上一版这里有一段 `run()`(读草案、拼 kv/table 块、按拦下条数返回 await/submit)——
* 案例活动根本不会调它;摆一个空转的 run 去假装有实现,更坏:
* 它写的那套呈现与 `viewTemplates` 里的模版各说各话,而没人看得出以哪份为准。
*
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 —— 它是**被静态审扫描的产物文本**
* (查 import 与全局引用,证明活动包零环境能力)。
*/
import { defineActivity } from '@sdk';
import type { ActivityBase, ActivityOutcome } from '@sdk';
const DRAFT = 'quote_pricing.draft';
export default defineActivity({
name: 'quote-pricing',
paradigm: 'flow',
async run(base: ActivityBase): Promise<ActivityOutcome> {
const items = await base.ledger.read(DRAFT);
const priced = items.filter((r) => typeof r['quoted_price'] === 'number');
// 底价门禁 P-02 拦下的条目 —— DMN 结论是**约束性输入**,不得静默覆写
const blocked = items.filter((r) => String(r['disposition'] ?? '').includes('拦截'));
await base.interaction.pushView([
{
t: 'kv',
title: '定价概览',
items: [
{ k: '条目', v: `${items.length} 条` },
{ k: '已定价', v: `${priced.length} 条` },
{ k: '底价门禁拦下', v: `${blocked.length} 条` },
],
},
{
t: 'table',
title: '报价草案',
cols: ['PN', '拟报单价', '毛利率', '偏离度', '门禁处置'],
rows: items.map((r) => [
String(r['pn'] ?? ''),
fmt(r['quoted_price']),
pct(r['margin']),
pct(r['deviation']),
String(r['disposition'] ?? '待判'),
]),
linkDonut: {
col: 4,
title: '门禁分布',
map: [
{ match: '直接放行', label: '直接放行', tone: 'ok' },
{ match: 'PM', label: 'PM 附理由', tone: 'warn' },
{ match: '拦截', label: '拦截', tone: 'danger' },
],
},
},
]);
if (priced.length < items.length) {
await base.interaction.say(`还有 ${items.length - priced.length} 条未定价,继续按利润模型 P-01 求值。`, 'conclusion');
return { kind: 'await', reason: '等待定价步完成' };
}
if (blocked.length > 0) {
// 超出授权边界 → 走「人 · 决策」,不自行破线
await base.interaction.say(
`${blocked.length} 条低于底价 20% 以上,已被底价门禁 P-02 拦下 —— 需要人裁决回调还是注明理由。`,
'criterion',
);
return { kind: 'await', reason: '等待超限条目的人裁决' };
}
await base.interaction.say(`${items.length} 条已全部定价且过底价门禁,可提交审批。`, 'criterion');
return { kind: 'submit', deliverable: 'quote-draft', rationale: '全条目带偏离度,底价门禁全过' };
},
});
function fmt(value: unknown): string {
return typeof value === 'number' ? value.toFixed(2) : '—';
}
function pct(value: unknown): string {
return typeof value === 'number' ? `${(value * 100).toFixed(1)}%` : '—';
}
export default defineActivity({ name: 'quote-pricing' });
+343 -67
View File
@@ -1,27 +1,45 @@
/**
* 活动包清单 —— 统一报价 A4 · 成本定价。
* 活动包清单 —— 统一报价 A4 · 成本定价(案例活动)。
*
* 业务素材:docs/MVP示例工作流.html §1.2(S1 取价基准 → S2 逐条定价 → S3 底价自检)。
* README《已裁定的落地决策》③:**A4 = flow** —— 取价、查表定价、自检的判断
* **全部装进 DMN 决策表确定性求值**;一旦出现「由 AI 决定怎么办」,按 AM 2.1 上升。
* 业务素材:`docs/MVP示例工作流.html` §1.2(S1 取价基准 → S2 逐条定价 → S3 底价自检):
* 「BD 逐条定价:价格 = 底价 × 利润模型。提交审批前先自查底价门禁 ——
* 超限的自己先调,不把问题留给 PM。」
*
* 形状与 A3 同一条(2026-08-03 重写 @3.0.0):归一算价 → 一张执行单交人 → 超限才叫人
* 拍板 → 放行。**能算的不问人**(P-01 毛利档、P-02 底价门禁两把尺写死),
* **该人负责的不自算**(拟报单价是必填,库内没有基准的那几行系统一个价都不编)。
*
* 与 @2.0.0 的差别(三处都是踩过的账):
* ① `input.required` 从 `['rfqRef','customerCode','records']` 清空 —— 宽进严出,
* 上游(A3)发的是 `{rfqRef, rows}`,门口拦的恰是主路;
* ② 台账补 `required` —— 从前一个必填都没有,执行卡的闸没牙,人不填也能交卷;
* ③ 判据与哨兵全部换成**基座读数**(`_rows` / `_missing` / `_<列>_<值>`)——
* 从前 L1 读 `unpriced_total`、哨兵读 `draft_total`,两个都是活动自报的数。
*/
import type { ActivityManifest, DmnModel } from '@contracts';
import { PRICING_SKILLS } from './skills.ts';
import { CMMN_XML, NARRATIVE } from './case-model.ts';
import { activityIdSchema } from '@contracts';
import { PRICING_BASIS, PRICING_PRICED, PRICING_RELEASE } from './views/report.ts';
import { PRICING_SKILLS } from './skills.ts';
import { CMMN_XML, NARRATIVE, PRICING_PHASES, PRICING_STEPS } from './case-model.ts';
import { PRICING_ROSTER, PRICING_WORKSHEET } from './views/report.ts';
const DOMAIN = 'quote_pricing';
const D = 'quote_pricing';
const ACTIVITY_ID = activityIdSchema.parse('quote-pricing');
/**
* 利润模型 P-01 —— 品牌 × 类型 × 客户等级 × 数量档 → 目标毛利。
* 「经验」段落不入决策表(那是技能 / 活动记忆种子:载方法不载权力)。
* 两把尺 —— **规则本身不是数据**,随包冻结。
*
* · 利润模型 P-01:客户等级 × 类别 → 目标毛利区间(FIRST,行序即业务优先级);
* · 底价门禁 P-02:相对底价线的偏离度 → 处置码。
*
* 出参就是台账列的取值(`disposition` 的三个大写码),模型算完照抄即可 ——
* 「出参键名与台账列同名」是为了它拿回去直接 write(规范 §5.6)。
* 两把尺的档位在三处出现:这张表、剧本 guide、工作单上的规则块 ——
* 包内自测把三处钉在一起(改一处不改另两处,测就红)。
*/
export const P01_DMN: DmnModel = {
drd: [
{ decision: 'p01-margin', requires: [], inputs: ['customerGrade', 'materialType', 'volumeTier'] },
{ decision: 'p02-floor', requires: ['p01-margin'], inputs: ['quotedPrice', 'floorPrice'] },
{ decision: 'p01-margin', requires: [], inputs: ['customer_grade', 'type_tag'] },
{ decision: 'p02-floor', requires: ['p01-margin'], inputs: ['deviation'] },
],
tables: [
{
@@ -29,30 +47,29 @@ export const P01_DMN: DmnModel = {
label: '利润模型 P-01',
hitPolicy: 'FIRST',
inputs: [
{ name: 'customerGrade', expression: 'customerGrade' },
{ name: 'materialType', expression: 'materialType' },
{ name: 'volumeTier', expression: 'volumeTier' },
{ name: 'customer_grade', expression: 'customer_grade' },
{ name: 'type_tag', expression: 'type_tag' },
],
outputs: [{ name: 'marginLow' }, { name: 'marginHigh' }],
outputs: [{ name: 'margin_low' }, { name: 'margin_high' }],
rules: [
{ id: 'p01-1', when: ['"战略客户"', '"A"', '"大批量"'], then: [0.08, 0.1], annotation: '战略客户 A 类标准件大批量 8%–10%' },
{ id: 'p01-2', when: ['"普通客户"', '"A"', '-'], then: [0.12, 0.15], annotation: '普通客户 A 类标准件 12%–15%' },
{ id: 'p01-3', when: ['-', '"B"', '-'], then: [0.15, 0.2], annotation: 'B 类波动件 15%–20%' },
{ id: 'p01-4', when: ['-', '"C"', '-'], then: [0.2, 0.5], annotation: 'C 类替代/EOL ≥20% 或一事一议' },
{ id: 'p01-default', when: ['-', '-', '-'], then: [0.12, 0.15], annotation: '兜底按普通客户标准件' },
{ id: 'p01-1', when: ['"战略客户"', '"A"'], then: [0.08, 0.1], annotation: '战略客户 A 类标准件(大批量长单)8%~10%' },
{ id: 'p01-2', when: ['"普通客户"', '"A"'], then: [0.12, 0.15], annotation: '普通客户 A 类标准件 12%~15%' },
{ id: 'p01-3', when: ['-', '"B"'], then: [0.15, 0.2], annotation: 'B 类波动件 15%~20%' },
{ id: 'p01-4', when: ['-', '"C"'], then: [0.2, 0.5], annotation: 'C 类替代/停产件 20%~50%(一事一议)' },
{ id: 'p01-default', when: ['-', '-'], then: [0.12, 0.15], annotation: '拿不准按普通客户标准件档' },
],
},
{
/** 底价门禁 P-02 —— 偏离底价 ±20% 即止 */
/** 底价门禁 P-02 —— 偏离底价线 ±20% 即止;出参就是台账 `disposition` 的取值 */
id: 'p02-floor',
label: '底价门禁 P-02',
hitPolicy: 'FIRST',
inputs: [{ name: 'deviation', expression: 'deviation' }],
outputs: [{ name: 'disposition' }, { name: 'passable' }],
rules: [
{ id: 'p02-1', when: ['>= 0'], then: ['直接放行', true], annotation: '≥底价 → 直接放行' },
{ id: 'p02-2', when: ['[-0.20..0)'], then: ['PM 附理由放行', true], annotation: '低于底价 ≤20% → PM 附理由' },
{ id: 'p02-3', when: ['< -0.20'], then: ['拦截 · BOSS 拍板', false], annotation: '低于底价 >20% → 拦截' },
{ id: 'p02-1', when: ['>= 0'], then: ['PASS', true], annotation: '在底价线之上 → 直接放行' },
{ id: 'p02-2', when: ['[-0.20..0)'], then: ['NOTE', true], annotation: '低于底价线两成以内 → 附理由放行' },
{ id: 'p02-3', when: ['< -0.20'], then: ['BLOCK', false], annotation: '低于两成以上 → 拦截,人拍板' },
],
},
],
@@ -62,130 +79,389 @@ export const manifest: ActivityManifest = {
identity: {
activityId: ACTIVITY_ID,
name: 'A4 · 成本定价',
packageVersion: '0.1.6',
packageVersion: '3.5.0',
category: '统一报价',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-quote-pricing',
},
/* ── 契约七件 ────────────────────────────────────────────────── */
contract: {
activityId: ACTIVITY_ID,
version: '0.1.0',
version: '1.0.0',
input: {
/*
* **宽进到底:一个必填都不设**(与 A1/A2/A3 同口径 —— 宽进严出,
* 输入契约在活动内部归一 + 补全)。
*
* 来路三种形状:寻源转来(rows,行上带 base_price)、受理/选型直达(records/rows,
* 没有底价)、单飞(一段文字)。上一版必填 `rfqRef + customerCode + records`,
* 而 A3 的 `quotation` 口发的是 `{rfqRef, rows}` —— 门口拦的恰是主路。
*/
data: {
type: 'object',
required: ['customerCode', 'records'],
required: [],
properties: {
customerCode: { type: 'string' },
records: { type: 'array' },
// B/C 类由 A3/A2 交回的底价与替代方案;A 类直通时为空
floorPrices: { type: 'array' },
rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' },
customerCode: { type: 'string', title: '客户码' },
rows: { type: 'array', title: '目标报价清单(寻源/选型转来,行上带底价)' },
records: { type: 'array', title: '上游受理记录(受理直达)' },
subject: { type: 'string', title: '需求主题' },
body: { type: 'string', title: '需求正文' },
attachments: {
type: 'array',
format: 'file',
title: '附件',
items: {
type: 'object',
properties: {
name: { type: 'string', title: '文件名' },
contentType: { type: 'string', title: '类型' },
size: { type: 'integer', title: '字节数' },
ref: { type: 'string', title: '存档号' },
text: { type: 'string', title: '读出来的文本' },
},
},
},
},
},
messageAllowed: true,
messageHint: '客户等级、急单、竞对价、这一单的让价授权写这里',
},
output: {
/*
* 只有一个出口:报价草案交定价审批(流程包 E8 是无条件边)。没有选择组 ——
* 没有去向要选,定盘就是把账写齐。
*
* 载荷键名照下游(A6 议价)的入参写:`rfqRef / customerCode / items` ——
* 边读什么、下游要什么,上游就得发什么。标量都有台账列承载(自测钉着)。
*/
mandatory: [
{
name: 'draft',
payload: {
type: 'object',
required: ['items'],
properties: { items: { type: 'array' } },
required: ['rfqRef', 'items'],
properties: {
rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' },
customerCode: { type: 'string', title: '客户码' },
items: { type: 'array', title: '报价草案(每条带拟报单价、偏离度与门禁处置)' },
},
},
description: '报价草案(逐条带偏离度)',
description: '报价草案(全条目带拟报单价与门禁处置)',
},
],
selective: [],
},
requiredDeliverables: [{ key: 'quote-draft', label: '报价草案(全条目带偏离度)' }],
acceptanceCriteria: [
{ key: 'all-priced', label: '每条都有拟报单价与毛利率', level: 'L1', predicate: 'unpriced = 0' },
// 底价门禁沉为 L2 机检 —— 「约束力收缩」的终点
{ key: 'floor-gate', label: '偏离底价 ≤±20%,超限者已回调或注明理由', level: 'L2', modelRef: 'p02-floor' },
{ key: 'pm-release', label: 'BD 放行', level: 'L5' },
/*
* 必交付物只有一件:**报价草案**。定价结论(verdict)是它的结论栏,
* `ledgers` 说清这份成果由哪几张表承载(一份成果,不是两份)。
*/
requiredDeliverables: [
{
key: 'quote-draft',
label: '报价草案(每条带拟报单价与门禁处置)',
ledger: `${D}.draft`,
/* 验收页上让 BD 就地改的两列 —— 恰是他的职权面(定价与让价理由) */
editableAtRelease: ['quoted_price', 'price_note'],
ledgers: [`${D}.draft`, `${D}.verdict`],
},
],
/*
* 验收判据 —— **机检(L1/L2)只读基座数得出来的数**:
* `_rows` 基座数行数、`_missing` 基座按 required 现数(与执行卡提交闸同一个数
* 同一套算法)、`_disposition_*` 基座按值现数。
* 上一版两条机检读活动自报的 `unpriced_total` / `unresolved_over_total` ——
* 那不是门,是「改哪个数能过」的提示(违反史已记四次,这是第五类)。
*/
acceptanceCriteria: [
{
key: 'roster-complete',
label: '草案齐:上游条目逐条承继,说了几条草案里就躺着几行',
level: 'L1',
predicate: `line_total > 0 and ${D}_draft_rows = line_total`,
},
{
key: 'all-priced',
label: '每条都有拟报单价:库内无基准价的那几行也由人定过价,缺一行放不了',
level: 'L1',
predicate: `${D}_draft_rows > 0 and ${D}_draft_missing = 0`,
},
{
key: 'floor-gate',
label: '底价门禁已过:处置逐条落定,没有还压在拦截档上的条目 —— 超限的要么回调、要么经人拍板附理由',
level: 'L2',
modelRef: 'p02-floor',
predicate: `${D}_draft_disposition_block = 0 and ${D}_draft_disposition_pass + ${D}_draft_disposition_note + ${D}_draft_disposition_block = ${D}_draft_rows`,
},
/*
* 报价来路可核是**裁量**(L3):基准是不是那个基准、让价理由站不站得住,
* 机器数不出来。这句 label 与剧本里 judge 的 criterion **必须逐字一致**。
*/
{
key: 'price-defensible',
label: '报价的来路说得清:基准价来源、毛利档与让价理由逐条可溯,BD 对报价负责',
level: 'L3',
},
/* L5 是责任关:那张卡本身就是这道关,卡面上不摆它(基座统一滤) */
{ key: 'bd-release', label: 'BD 放行:签下去这份报价草案即定盘并交定价审批', level: 'L5' },
],
revisableAtRelease: [],
prohibitions: [
{ key: 'no-silent-override', statement: '不得静默绕开 P-02 的结论;要破线只能走「人 · 决策」' },
{ key: 'no-stale-floor', statement: '底价超一周未更新的,先刷底价再判' },
{ key: 'no-silent-override', statement: '不得静默绕开底价门禁的结论;要破线只能走「人 · 决策」并留下理由' },
{ key: 'no-made-up-basis', statement: '没有成本基准的料不许编一个偏离率出来:基准空着就空着,价由人定并注明依据' },
{ key: 'no-stale-floor', statement: '底价超一周未更新的,先刷底价再判 —— 拿过期的成本算出来的毛利是假的' },
],
authorization: {
discretion: [{ key: 'margin-band', statement: '在 P-01 给出的毛利区间内自决;出区间必须调人决策' }],
discretion: [{ key: 'margin-band', statement: '在 P-01 给出的毛利区间内自决;出区间要么回调,要么调人拍板' }],
preauthorizedActions: [],
humanCallBudget: 3,
/* 一张执行单 + 可能一张决策卡 + 放行 = 三次;留一倍余量 */
humanCallBudget: 5,
},
resources: {
tools: [{ name: 'quote-pricing.basis' }, { name: 'crm.history-price-read' }],
tools: [{ name: 'crm.history-price-read' }],
skills: PRICING_SKILLS.map((s) => s.id),
models: ['deepseek-v4-flash'],
roles: ['BD'],
budget: { steps: 60, tokens: 150_000, activeMinutes: 120, humanCalls: 3 },
/* SLA <2h(MVP示例 §1.2);预算按 A3 实测(6-8 拍 / 40-50k)留一倍余量 */
budget: { steps: 60, tokens: 400_000, activeMinutes: 120, humanCalls: 5 },
},
},
/* ── 声明 ────────────────────────────────────────────────────── */
declarations: {
models: ['deepseek-v4-flash'],
tools: [{ name: 'crm.history-price-read' }, { name: 'quote-pricing.basis' }],
tools: [{ name: 'crm.history-price-read' }],
ledger: [
{
domain: DOMAIN,
domain: D,
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: 'customer_pn', type: 'text', primaryKey: false, masked: false, label: '客户料号' },
{ name: 'category', type: 'text', primaryKey: false, masked: false, label: '品类' },
{ name: 'brand', type: 'text', primaryKey: false, masked: false, label: '制造商' },
{ name: 'eau', type: 'integer', primaryKey: false, masked: false, label: '年用量 EAU' },
{ name: 'lead_time', type: 'text', primaryKey: false, masked: false, label: '客户要求交期' },
/* 客户备注承继自上游 —— 认证要求、预授权、急料标注,让价时的第一手依据 */
{ name: 'remark', type: 'text', primaryKey: false, masked: false, label: '客户备注(约束/急料)' },
{ name: 'type_tag', type: 'text', primaryKey: false, masked: false, label: '类别(承继)', tally: ['A', 'B', 'C'] },
/*
* **客户目标价标 `expect`** —— 客户该给而没给的那一问不能省(件上出橘色
* 「建议填」胶囊)。它不拦单:`_gaps` 只喂 L4,而本活动没有 L4 判据。
* 注意边界:我方自己写的注(price_note)**不标 expect** —— 标了 `_gaps`
* 恒大于 0,那条判据永远绿不了(A1 的账)。
*/
{ name: 'target_price', type: 'number', primaryKey: false, masked: false, expect: true, label: '客户目标价' },
/* 成本基准:上游底价或库内历史价。**取不到就空着** —— 空着不是异常,是「无基准」那一档 */
{ name: 'base_price', type: 'number', primaryKey: false, masked: false, label: '成本基准' },
{ name: 'price_basis', type: 'text', primaryKey: false, masked: false, label: '基准来源', tally: ['HIST', 'SOURCED'] },
{ name: 'margin_band', type: 'text', primaryKey: false, masked: false, label: '毛利档(P-01)' },
{ name: 'floor_price', type: 'number', primaryKey: false, masked: false, label: '底价线' },
/*
* **系统建议价是 AI 的活,拟报单价是人的活** —— 两列分开是**结构治**,不是措辞治。
*
* 实测账(2026-08-03,单飞那条案例):从前只有 `quoted_price` 一列,剧本写着
* 「无基准的行留空,由 BD 定」—— 模型照样给两颗库里查不到的料编了价
* (一颗抄了客户目标价,另一颗凭空写了 0.085),交代里还说着「价空着等你定」。
* 一条要模型分辨「这行算得出/算不出」的规矩,它十次里会走岔;
* 而「这一列你一格都不写」没有分支可走岔。
*
* 于是:AI 只写 `suggested_price`(算得出就给,算不出就空着),
* **`quoted_price` 永远是人在件上定的那个数** —— 它标 `required`,
* 件上的缺价红带、执行卡的提交闸、放行门 L1 三处同源(都数基座的 `_missing`)。
* 不用 `requiredWhen`:每一行都要有价,没有「这一行还没轮到」的情形。
*/
{ name: 'suggested_price', type: 'number', primaryKey: false, masked: false, label: '系统建议价' },
{ name: 'quoted_price', type: 'number', primaryKey: false, masked: false, required: true, label: '拟报单价(BD 定)' },
{ name: 'margin', type: 'number', primaryKey: false, masked: false, label: '毛利率' },
{ name: 'last_price', type: 'number', primaryKey: false, masked: false, label: '基准价' },
{ name: 'deviation', type: 'number', primaryKey: false, masked: false, label: '偏离度' },
{ name: 'disposition', type: 'text', primaryKey: false, masked: false, label: '门禁处置' },
{ name: 'deviation', type: 'number', primaryKey: false, masked: false, label: '偏离底价线' },
/*
* **门禁处置:取值域给出来,条数由基座现数** —— PASS / NOTE / BLOCK。
* 守恒判据、分布条、门禁徽读的全是基座读数;值是机器码,
* 人读名(直接放行 / 附理由 / 拦截待拍板)由模版样式表翻译。
*/
{ name: 'disposition', type: 'text', primaryKey: false, masked: false, label: '门禁处置', tally: ['PASS', 'NOTE', 'BLOCK'] },
{ name: 'price_note', type: 'text', primaryKey: false, masked: false, label: '备注 / 让价理由' },
],
},
{
/*
* 定价结论 —— 一单一行的**汇总表**,哨兵与报告读它。
* 汇总列与逐行列**故意不同名**:任务视图把草稿行摊平到顶层是「后写的赢」,
* 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。
*/
domain: 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: '客户码' },
{ name: 'customer_name', type: 'text', primaryKey: false, masked: false, label: '客户名称' },
/* 客户等级是 P-01 的入参之一(战略客户 / 普通客户),认不出按普通客户 */
{ name: 'customer_grade', type: 'text', primaryKey: false, masked: false, label: '客户等级' },
{ name: 'line_total', type: 'integer', primaryKey: false, masked: false, label: '草案条目数' },
{ name: 'basis_total', type: 'integer', primaryKey: false, masked: false, label: '有成本基准的条数' },
/* 算价走完的凭据(0 也要写)—— 哨兵按它放交人核价那一步入场 */
{ name: 'priced_total', type: 'integer', primaryKey: false, masked: false, label: '已算出建议价条数' },
/* 「BD 核过价了」的凭据 —— 只在人环走完才产生 */
{ name: 'confirmed_total', type: 'integer', primaryKey: false, masked: false, label: 'BD 已核价条数' },
{ name: 'route_decided_total', type: 'integer', primaryKey: false, masked: false, label: '已定盘' },
{ name: 'summary', type: 'text', primaryKey: false, masked: false, label: '定价结论' },
],
},
],
listeners: [
{
/** 时限过半:SLA <2h,给 BD 与负责人提个醒 —— 静态监听,时限必配 */
origin: 'static',
eventType: 'human',
keyFilter: { kind: 'decision' },
semantic: 'complete-step',
deadline: 'PT4H',
escalation: ['nudge', 'escalate'],
eventType: 'time',
keyFilter: { kind: 'half-deadline' },
semantic: 'observe',
deadline: 'PT1H',
escalation: ['nudge'],
},
],
// SLA <2h(MVP示例 §1.2 留痕 · SLA)
harness: {
memoryWindow: 20,
memoryWindow: 22,
// SLA <2h(MVP示例 §1.2 留痕 · SLA)
stallAfter: 'PT2H',
nudge: [{ after: 'PT1H', action: 'nudge' }],
retry: { times: 2, backoff: 'PT30S' },
},
},
/* ── 资产初始版 ──────────────────────────────────────────────── */
assets: {
/* 资产初始版:案例模型(2026-07-29 起活动只有这一种业务模型) */
businessModel: {
cmmnXml: CMMN_XML,
narrative: NARRATIVE,
dmn: P01_DMN,
steps: PRICING_STEPS,
phases: PRICING_PHASES,
},
skills: PRICING_SKILLS,
/*
* 两个局面各一张(与 A2/A3 同款口径):
* · `pricing-worksheet` 定价工作单 —— 算价与 BD 核价期间摆它(逐颗定价档案、
* 两把尺、填回工作表);
* · `pricing-roster` 报价草案清单 —— 拍板、放行、归档摆它。
*/
viewTemplates: [
{ name: 'pricing-basis', html: PRICING_BASIS },
{ name: 'pricing-priced', html: PRICING_PRICED },
{ name: 'pricing-release', html: PRICING_RELEASE },
{ name: 'pricing-worksheet', html: PRICING_WORKSHEET },
{ name: 'pricing-roster', html: PRICING_ROSTER },
],
cardSchemas: {},
distillHooks: [
{
on: 'terminal',
prompt: '归纳本单定价的经验:哪些组合的毛利档被人回调过、底价门禁拦下的形态、破线的理由。',
prompt: '归纳本单定价:哪些组合的毛利档被人回调过、底价门禁拦下的形态、破线的理由与拍板结果。',
},
],
},
/* ── 样例集 —— 三条形状真不同(常备案例即验收案例)───────────── */
samples: [
{
name: '战略客户 A 类大批量',
input: { customerCode: 'C001', records: [{ pn: 'LM358DR', type: 'A' }] },
/*
* ① 寻源转来 · 常路:A3 `quotation` 口的真实形状 —— rows 里清单行(带底价与价源)
* 混着结论行。客户目标价都在底价线之上,全 PASS,不必叫人拍板。
*/
name: '寻源转来 · 两条料底价齐 · 全在底价线之上(常路)',
input: {
rfqRef: 'RFQ MD14.1',
customerCode: 'CU-0142',
customer_name: 'Itelma',
rows: [
{
rfq_ref: 'RFQ MD14.1',
pn: 'BBPY00201209601Y00',
customer_pn: '4-14-01502',
category: '滤波器',
brand: 'YAGEO',
eau: 30000,
target_price: 0.085,
lead_time: '8周',
type_tag: 'B',
base_price: 0.058,
price_basis: 'SOURCED',
supplier: '深圳华强渠道',
},
{
rfq_ref: 'RFQ MD14.1',
pn: 'CJTB0101R6N',
customer_pn: '4-00-04102',
category: '集成电路',
brand: 'Jiangsu Changjing',
eau: 60000,
target_price: 0.42,
lead_time: '6周',
type_tag: 'A',
base_price: 0.35,
price_basis: 'HIST',
},
{ rfq_ref: 'RFQ MD14.1', quote_line_total: 2, route_decided_total: 1, customer_name: 'Itelma', summary: '两条底价齐,交定价' },
],
},
expect: { ports: ['draft'] },
},
{
/*
* ② 有超限条目:客户把目标价压到成本之下(0.06 对成本 0.082)——
* 偏离底价线超过两成,门禁判 BLOCK,必须叫人拍板(决策卡)。
*/
name: '客户压价 · 一条低于底价线两成以上 → 决策卡叫人拍板',
input: {
rfqRef: 'RFQ-2026-0777',
customerCode: 'CU-0311',
customer_name: '青岛海洋电子',
rows: [
{
rfq_ref: 'RFQ-2026-0777',
pn: 'PESD2ETH-XR',
category: '二极管',
brand: 'Nexperia',
eau: 30000,
target_price: 0.06,
lead_time: '8周',
type_tag: 'B',
base_price: 0.082,
price_basis: 'SOURCED',
remark: '客户明说这个价是竞对给的,要跟',
},
{
rfq_ref: 'RFQ-2026-0777',
pn: 'NE555P',
category: '定时器',
brand: 'TI',
eau: 5000,
target_price: 1.05,
lead_time: '4周',
type_tag: 'A',
base_price: 0.85,
price_basis: 'HIST',
},
{ rfq_ref: 'RFQ-2026-0777', quote_line_total: 2, route_decided_total: 1, customer_name: '青岛海洋电子' },
],
},
expect: { ports: ['draft'] },
},
{
/*
* ③ 单飞宽进:没有单号没有清单,正文列料 —— 归一造案号;库内查不到历史价,
* 系统一个价都不编,两行拟报单价空着交 BD 定 → **提交闸真拦**空手交卷。
*/
name: '单飞需求 · 正文列两颗料 · 库内无基准 → 价由人定(闸真拦)',
input: {
subject: '两颗料报个价',
body: '客户要两颗料的报价:XCJ-7788T 电源模块 8K,目标价 12.5;LMR-3302QQ 稳压器 15K,没给目标价。库里应该没做过这两颗,你先出个草案。',
},
expect: { ports: ['draft'] },
},
],
+23 -13
View File
@@ -1,5 +1,8 @@
/**
* A4 技能 —— 定价的方法经验(AM 08)。载方法,不载权力。
* A4 技能 —— 定价的方法经验。载方法,不载权力。
*
* 与剧本的分工:剧本写「这一步做什么、写哪几列」,技能写「为什么这么定、
* 拿不准时按哪条走」。剧本改了口径,技能里同一句要跟着改(规范 §5.9)。
*/
import { skillIdSchema } from '@contracts';
import type { Skill } from '@contracts';
@@ -8,34 +11,41 @@ export const PRICING_SKILLS: Skill[] = [
{
id: skillIdSchema.parse('pricing.pick-basis'),
label: '选对价格基准',
applicability: '定价开始,需要确定「跟谁比」时。',
applicability: '定价开始,需要确定「这个价跟谁比」时。',
method: [
'基准的优先级:① 本客户近期成交价 → ② 同型号他客户成交价 → ③ 供应商底价表 → ④ 无基准。',
'基准的优先级:① 上游寻源问回来的货源底价 → ② 本客户近期成交价 → ③ 同型号他客户成交价 → ④ 无基准。',
'越靠前越贴近这一单的真实议价空间;越靠后越要留余量。',
'',
'**无基准不是异常**,是一种基准:新料号、久未报价的老料号都会落到这里。',
'此时偏离率记 0(没有可偏离的对象),由底价门禁按「无基准」放行 —— ',
'此时成本基准、底价线、偏离度**一律留空**,拟报单价交给人定 ——',
'不要拿一个不相干的价去凑一个偏离率出来,那比没有基准更危险:它看起来像有依据。',
'',
'基准超一周未更新的先刷再判:拿过期成本算出来的毛利,数字是真的,结论是假的。',
].join('\n'),
crystallized: [{ tool: 'crm.history-price-read', input: { pn: 'pn' } }],
knowledgeRefs: ['利润模型 P-01'],
version: '1.0.0',
version: '2.0.0',
},
{
id: skillIdSchema.parse('pricing.read-gate'),
label: '读懂底价门禁的处置',
applicability: '底价门禁(P-02)给出处置结论,需要决定下一步时。',
applicability: '底价门禁(P-02)给出处置码,需要决定下一步时。',
method: [
'门禁不是「拦下」而是**分流**:',
'· 直接放行 —— 偏离在容许带内,照常提审。',
'· 超限待裁 —— 偏离超 ±20%,交人决断:回调价格,还是注明理由硬报。',
' 两条路都合法,但都要留下**理由**:这条留痕将来会成为客户等级与利润模型的迭代素材。',
'门禁不是「拦下」而是**分流**,三档各有各的下一步:',
'· PASS(在底价线之上)—— 照常提审,不必解释。',
'· NOTE(低于底价线两成以内)—— 附理由放行:让价的缘由写进备注,',
' 理由随草案一起交审批。不写理由的让价,到审批那一关同样过不去。',
'· BLOCK(低于两成以上)—— 系统不替任何人放行,交人拍板:回调到底价线、',
' 破线报并留理由、或这一条本轮不报。三条路都合法,都要留痕。',
'',
'不要为了让门禁「过」而回头改毛利参数 —— 那是把判据改成结论,',
'决策表就失去了它唯一的价值:同输入必同结论。',
'两条别做的事:',
'· 不要为了让门禁「过」回头改毛利档或改基准 —— 那是把判据改成结论,',
' 决策表就失去了它唯一的价值:同输入必同结论;',
'· 不要每一条都叫人。BD 自己能回调掉的,回调掉就是处置 ——',
' 每条都叫人,人就成了橡皮图章;一条都不叫,门禁就是摆设。',
].join('\n'),
crystallized: [],
knowledgeRefs: ['底价门禁 P-02'],
version: '1.0.0',
version: '2.0.0',
},
];
@@ -0,0 +1,409 @@
/**
* A1 两份执行页的样式表 —— **从 PRD 目标件原样搬过来**,不是我照着复述的。
*
* 出处:`~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理需求.html` 的整段 `<style>`,
* 外加 `A1-询价受理记录.html` 里归档件独有的那几件(分类分布条 / 类徽 / 判定表 /
* 结论横幅 / 落地动作 / 完成标)。**一个数值都没改**。
*
* 为什么是搬而不是抄:前三轮我用自己那套外壳「照着做」,块搬齐了、版面是两套 ——
* 章节头成了彩色方块编号、多出摘要与元信息带、字号用 clamp。人一眼就看出不是那份件。
* 目标件是**像素级的答案**,搬过来才不会在复述里走样。
*
* 选择器保留 `.proto-root .app-shell` 前缀,所以模版正文也要照原型那样包两层壳 ——
* 少一层壳,整张样式表一条都不生效(踩过)。
*/
export const PRD_CSS = `
/* ============================================================================
JCAI 员工面工作台 · A1 受理归一的过程成果「询价受理需求」——报告本体
立场:**AI 已从 RFQ 邮件抽取归一**,人在这里审核并就地修订。
两态:读态(AI 稿 + 红/灰占位提示缺项) ⇄ 编辑态(全字段可改,保存 / 取消)
核心是「有效 PN 清单」;清单之上四件受理要素:客户公司 / 联系人 / 客户属性 / 有效 PN。
令牌与既有组件取自 workbench/src/app/(proto)/app.css,数值未改。
⚠️ 修正一处原型 bug:app.css:2062-2064 的 '.jc-rp-sign span'(权重 0,1,1)
盖过了 '.jc-rp-ava'(0,1,0),把头像的 display:grid 覆盖成 block、字色改灰,
导致落款头像里的字掉到圆外。本文件把该规则收窄为 '.jc-rp-sign > div > span'。
新增件:四要素卡 .jc-four/.jc-fcard · 占位胶囊 .jc-ph · 分段控件 .jc-seg ·
来源条 .jc-src · 录入表 .jc-tbl-in
========================================================================== */
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { background: #e6eaf1; -webkit-font-smoothing: antialiased; }
.proto-root .app-shell {
--brand: #3370ff; --brand-deep: #245bdb;
--brand-soft: #e1eaff; --brand-wash: #f0f4ff;
--ai-grad: linear-gradient(135deg, #3370ff, #7f3bf5);
--ink: #1f2329; --ink-2: #41464e; --ink-3: #646a73; --ink-4: #8f959e;
--line: #dee0e3; --line-soft: #e8eaed; --soft: #f8f9fb;
--jcai-bg: #e6eaf1; --cardline: #e1e6ec; --card-shadow: 0 1px 2px rgba(31, 35, 41, .05);
--ok: #278717; --ok-soft: #e8f8e4;
--warn: #de7802; --warn-accent: #ff8800; --warn-soft: #fff4e5;
--danger: #d83931; --danger-soft: #feeae9;
--mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace;
--shadow-brand: 0 6px 16px rgba(51, 112, 255, .22);
--t-fast: .16s cubic-bezier(.3, 0, .2, 1);
--t-med: .26s cubic-bezier(.3, 0, .2, 1);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--jcai-bg); min-height: 100vh; color: var(--ink);
}
.proto-root :where(button, input, select, a, [tabindex]):focus-visible {
outline: 2px solid var(--brand); outline-offset: 2px; border-radius: 8px;
}
.proto-root ::selection { background: var(--brand-soft); }
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
@keyframes jcBlink { 0%,100% { opacity: 1 } 50% { opacity: .35 } }
@keyframes jcStamp { from { opacity: 0; transform: rotate(-6deg) scale(1.7); } to { opacity: .9; transform: rotate(-6deg) scale(1); } }
/* 放开宽度:1160px 是「9 列全展开、一个字都不裁」的最小容器宽度(见下方列 min-width 之和) */
.jc-stage { max-width: 1160px; margin: 0 auto; padding: 22px 16px 52px; }
.proto-root .jc-mono { font-family: var(--mono); font-size: 11px; color: var(--ink-3); }
.proto-root .jc-pulse-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--brand); display: inline-block; animation: jcBlink 1.1s ease-in-out infinite; }
/* ── 报告外壳 ───────────────────────────────────────────────────────── */
.proto-root .jc-report { background: #fff; border: 1px solid var(--cardline); border-radius: 14px; box-shadow: var(--card-shadow); overflow: hidden; animation: rise var(--t-med) both; }
.proto-root .jc-rp-head { position: relative; overflow: hidden; display: flex; align-items: flex-start; gap: 12px; padding: 16px 20px 13px; border-bottom: 2px solid var(--ink); }
/* 题头下缘粗细双线 = 正式件版式 */
.proto-root .jc-rp-head::after { content: ""; position: absolute; left: 0; right: 0; bottom: -4px; height: 1px; background: var(--line); }
/* 题头右上的品牌光晕:与工作台标题区同一签名件 */
.proto-root .jc-rp-head::before { content: ""; position: absolute; right: -70px; top: -96px; width: 240px; height: 240px; border-radius: 999px; background: radial-gradient(circle, rgba(51,112,255,.10), transparent 70%); pointer-events: none; }
.proto-root .jc-rp-head > * { position: relative; z-index: 1; }
.proto-root .jc-rp-badge { flex: none; width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand); }
.proto-root .jc-rp-tit { flex: 1; min-width: 0; }
.proto-root .jc-rp-tit em { display: block; font-style: normal; font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); font-family: var(--mono); }
.proto-root .jc-rp-tit h3 { margin: 4px 0 0; font-size: 18px; font-weight: 800; color: var(--ink); letter-spacing: -.01em; line-height: 1.3; }
.proto-root .jc-rp-wait { display: inline-flex; align-items: center; gap: 6px; flex: none; margin-top: 4px; font-size: 11px; font-weight: 700; border-radius: 999px; padding: 4px 12px; color: var(--brand-deep); background: var(--brand-wash); border: 1px solid var(--brand-soft); }
.proto-root .jc-rp-wait.warn { color: var(--warn); background: var(--warn-soft); border-color: #ffd9a8; }
.proto-root .jc-rp-wait.edit { color: #fff; background: var(--brand); border-color: var(--brand); }
.proto-root .jc-rp-stamp { flex: none; margin-top: 2px; transform: rotate(-6deg); border: 2px solid currentColor; border-radius: 9px; padding: 4px 8px 3px 12px; font-size: 13px; font-weight: 900; letter-spacing: .28em; color: var(--ok); opacity: .9; animation: jcStamp .32s cubic-bezier(.2, 1.4, .4, 1) both; }
.proto-root .jc-rp-bd { padding: 18px 20px 12px; }
.proto-root .jc-rp-part { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 9px; margin: 20px 0 11px; }
.proto-root .jc-rp-part:first-child { margin-top: 0; }
.proto-root .jc-rp-part > b { flex: none; font-size: 11px; font-weight: 800; letter-spacing: .12em; color: var(--ink-3); }
.proto-root .jc-rp-part > .ln { flex: 1; height: 1px; background: var(--line-soft); }
.proto-root .jc-rp-part > em { flex: none; font-style: normal; font-size: 10px; font-weight: 700; color: var(--ink-4); font-family: var(--mono); background: var(--soft); border-radius: 999px; padding: 2px 9px; white-space: nowrap; }
.proto-root .jc-rp-part > em.warn { color: var(--warn); background: var(--warn-soft); }
/* 落款 —— ⚠️ 选择器收窄到 > div > *,别再命中头像那个 span */
.proto-root .jc-rp-foot { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 13px 20px 15px; border-top: 2px solid var(--line); background: #fbfcfd; }
.proto-root .jc-rp-sign { display: flex; align-items: center; gap: 10px; flex: 1 1 200px; min-width: 0; }
.proto-root .jc-rp-ava { flex: 0 0 auto; width: 30px; height: 30px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; line-height: 1; color: #fff; background: var(--brand); }
.proto-root .jc-rp-sign > div { min-width: 0; }
.proto-root .jc-rp-sign > div > b { display: block; font-size: 12px; font-weight: 700; color: var(--ink-2); }
.proto-root .jc-rp-sign > div > span { display: block; margin-top: 1px; font-size: 10.5px; color: var(--ink-4); }
.proto-root .jc-rp-acts { display: flex; align-items: center; gap: 8px; margin-left: auto; flex: none; }
.proto-root .jc-hintline { font-size: 11px; font-weight: 700; color: var(--warn); line-height: 1.5; }
.proto-root .jc-btn { display: inline-flex; align-items: center; gap: 5px; border: 0; border-radius: 7px; padding: 8px 14px; font-size: 12px; font-weight: 600; font-family: inherit; background: var(--brand); color: #fff; cursor: pointer; transition: all var(--t-fast); }
.proto-root .jc-btn:hover:not(:disabled) { background: var(--brand-deep); }
.proto-root .jc-btn:disabled { opacity: .45; cursor: not-allowed; }
.proto-root .jc-btn-ghost { background: #fff; color: var(--ink-2); border: 1px solid var(--line); }
.proto-root .jc-btn-ghost:hover:not(:disabled) { background: var(--brand-wash); color: var(--brand); border-color: var(--brand-soft); }
.proto-root .jc-btn-primary { background: var(--ai-grad); }
.proto-root .jc-btn-primary:hover:not(:disabled) { filter: brightness(1.07); box-shadow: var(--shadow-brand); transform: translateY(-1px); }
/* ── 来源信件:客户 RFQ 邮件原文(复用原型 jc-letter 件,青色 = 收件) ── */
.proto-root .jc-letter { position: relative; border: 1px solid var(--cardline); border-left: 3px solid #0aa294; border-radius: 12px; padding: 14px 16px 15px; background: #fff; box-shadow: var(--card-shadow); }
.proto-root .jc-letter-h { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 11px; }
.proto-root .jc-letter-dir { display: inline-flex; align-items: center; gap: 5px; font-size: 10.5px; font-weight: 800; padding: 3px 9px; border-radius: 999px; background: #e4f8f5; color: #0a7f74; }
.proto-root .jc-letter-h .jc-chip { font-size: 10.5px; padding: 3px 9px; border-radius: 999px; background: var(--brand-wash); color: var(--brand-deep); border: 1px solid var(--brand-soft); }
.proto-root .jc-letter-h > code { margin-left: auto; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
.proto-root .jc-letter-meta { display: flex; flex-direction: column; gap: 4px; margin-bottom: 11px; padding-bottom: 11px; border-bottom: 1px solid var(--line-soft); font-size: 11.5px; color: var(--ink-2); }
.proto-root .jc-letter-meta p { margin: 0; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.proto-root .jc-letter-meta i { flex: none; font-style: normal; width: 44px; font-size: 10px; font-weight: 800; letter-spacing: .06em; color: var(--ink-4); }
.proto-root .jc-letter-meta code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); overflow-wrap: anywhere; }
.proto-root .jc-letter-sub { margin: 0 0 9px; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.45; overflow-wrap: anywhere; }
/* 正文:原文照录,pre-wrap 保原始换行;不截断、不折叠 */
.proto-root .jc-letter-body { margin: 0; font-size: 12.5px; line-height: 1.72; color: var(--ink-2); white-space: pre-wrap; overflow-wrap: anywhere; }
.proto-root .jc-letter-body b { color: var(--ink); font-weight: 700; }
.proto-root .jc-atts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; padding-top: 11px; border-top: 1px dashed var(--line-soft); }
.proto-root .jc-att { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px 5px 8px; border: 1px solid var(--brand-soft); border-radius: 8px; background: var(--brand-wash); font-size: 11.5px; color: var(--brand-deep); }
.proto-root .jc-att svg { flex: none; color: var(--brand); }
.proto-root .jc-att code { font-family: var(--mono); font-size: 10.5px; }
.proto-root .jc-att span { color: var(--ink-4); }
/* 抽取指向:从原文到下方要素的一句衔接 */
.proto-root .jc-derive { display: flex; align-items: flex-start; gap: 8px; margin-top: 11px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); font-size: 11.5px; line-height: 1.7; color: var(--brand-deep); }
.proto-root .jc-derive svg { flex: none; margin-top: 2px; }
/* ── 四要素卡 ───────────────────────────────────────────────────────────
色调字典:每张卡带一套 --tone / --tone-soft / --tone-line,组件只读变量。
卡 3、卡 4 的色调随数据变(老客户绿 / 新客户橙;齐了蓝 / 有缺橙),
所以这条要素带本身就是一行可读的状态,不只是四个格子。 */
.proto-root .jc-four { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 11px; }
.proto-root .jc-fcard {
position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-width: 0;
padding: 14px 14px 14px; border: 1px solid var(--cardline); border-radius: 13px; background: #fff;
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 8px 20px -14px rgba(31,35,41,.35);
transition: transform var(--t-fast), box-shadow var(--t-fast), border-color var(--t-fast);
--tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft);
}
/* 顶缘色棱 + 右上角光晕:卡的身份色,一眼分得清四件是四件 */
.proto-root .jc-fcard::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 3px; background: var(--tone); }
.proto-root .jc-fcard::after { content: ""; position: absolute; right: -46px; top: -52px; width: 132px; height: 132px; border-radius: 999px; background: radial-gradient(circle, var(--tone-soft), transparent 70%); pointer-events: none; }
.proto-root .jc-fcard > * { position: relative; z-index: 1; }
.proto-root .jc-fcard:hover { transform: translateY(-2px); border-color: var(--tone-line); box-shadow: 0 2px 4px rgba(31,35,41,.05), 0 14px 26px -16px rgba(31,35,41,.45); }
.proto-root .jc-fcard.t-teal { --tone: #0aa294; --tone-soft: rgba(10,162,148,.15); --tone-line: #9fe0d9; }
.proto-root .jc-fcard.t-ok { --tone: var(--ok); --tone-soft: rgba(39,135,23,.14); --tone-line: #bfedb4; }
.proto-root .jc-fcard.t-warn { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.12); --tone-line: #ffd9a8; }
.proto-root .jc-fcard-k { display: flex; align-items: center; gap: 6px; font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
.proto-root .jc-fcard-k > i { flex: none; width: 19px; height: 19px; border-radius: 6px; display: inline-grid; place-items: center; background: var(--tone-soft); color: var(--tone); }
.proto-root .jc-fcard-b { display: flex; align-items: center; gap: 10px; min-width: 0; }
.proto-root .jc-fcard-t { min-width: 0; flex: 1; }
/* 一律换行,不省略号——宁可多占一行,也不丢字 */
.proto-root .jc-fcard-t > b { display: block; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.35; overflow-wrap: anywhere; }
.proto-root .jc-fcard-t > span { display: block; margin-top: 2px; font-size: 10.5px; color: var(--ink-4); line-height: 1.55; overflow-wrap: anywhere; }
/* 公司标:圆角方 + AI 渐变 + 外圈光环(方=组织) */
.proto-root .jc-logo { flex: none; width: 38px; height: 38px; border-radius: 12px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; letter-spacing: .04em; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand), 0 0 0 3px rgba(51,112,255,.10); }
/* 人像:圆 + 青色系(圆=人,与方形的组织标一眼分开) */
.proto-root .jc-face { flex: none; width: 38px; height: 38px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; color: #0a7f74; background: #e4f8f5; border: 1px solid #9fe0d9; box-shadow: 0 0 0 3px rgba(10,162,148,.10); }
/* 邮箱做成可点的胶囊,复制按钮嵌在里面——比一行裸文字有抓手 */
/* 邮箱:软底胶囊、无描边——有描边会读成输入框,而读态这里不是可填的 */
.proto-root .jc-mailrow { display: flex; align-items: center; gap: 4px; margin-top: 5px; min-width: 0; max-width: 100%; padding: 3px 4px 3px 9px; border: 0; border-radius: 8px; background: var(--tone-soft); transition: background var(--t-fast); }
.proto-root .jc-mailrow:hover { background: rgba(10,162,148,.2); }
/* 编辑态里它包着输入框,软底会变成「框中框」,去掉 */
.proto-root .jc-report.editing .jc-mailrow { background: transparent; padding: 0; margin-top: 6px; }
.proto-root .jc-mailrow > code { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; line-height: 1.5; font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
.proto-root .jc-copy { flex: none; width: 22px; height: 22px; display: inline-grid; place-items: center; border: 0; border-radius: 6px; background: #fff; color: var(--ink-4); cursor: pointer; box-shadow: 0 1px 2px rgba(31,35,41,.08); transition: all var(--t-fast); }
.proto-root .jc-copy:hover { color: #0aa294; }
.proto-root .jc-copy.ok { color: var(--ok); background: var(--ok-soft); box-shadow: none; }
/* 客户属性徽章 */
.proto-root .jc-tagbig { display: inline-flex; align-items: center; gap: 6px; align-self: flex-start; padding: 7px 14px; border-radius: 999px; font-size: 13.5px; font-weight: 800; line-height: 1; color: var(--tone); background: var(--tone-soft); border: 1px solid var(--tone-line); }
.proto-root .jc-fcard-note { font-size: 10.5px; color: var(--ink-4); line-height: 1.55; }
.proto-root .jc-fcard-note b { color: var(--ink-2); font-weight: 700; }
.proto-root .jc-fcard-note b.bad { color: var(--tone); font-weight: 800; }
/* 成交迷你柱:把「台账 6 笔成交」从一句话变成看得见的节奏 */
.proto-root .jc-spark { display: flex; align-items: flex-end; gap: 4px; height: 26px; }
.proto-root .jc-spark > i { flex: 1 1 0; min-width: 4px; max-width: 15px; border-radius: 3px 3px 1px 1px; background: var(--tone); opacity: .3; transition: opacity var(--t-fast), height var(--t-med); }
.proto-root .jc-spark > i:last-child { opacity: 1; }
.proto-root .jc-spark:hover > i { opacity: .55; }
.proto-root .jc-spark:hover > i:last-child { opacity: 1; }
.proto-root .jc-spark.empty { align-items: stretch; }
.proto-root .jc-spark.empty > i { height: 100%; opacity: 1; background: repeating-linear-gradient(45deg, var(--tone-soft) 0 5px, transparent 5px 10px); border: 1px dashed var(--tone-line); border-radius: 5px; }
/* 有效 PN 计数 */
.proto-root .jc-count { display: flex; align-items: baseline; gap: 6px; }
.proto-root .jc-count > b { font-family: var(--mono); font-size: 32px; font-weight: 800; line-height: .95; color: var(--ink); letter-spacing: -.04em; }
.proto-root .jc-count > span { font-size: 11px; color: var(--ink-4); }
/* 分段刻度条:一格 = 一条 PN,缺必填的那格直接标红——比一根实心进度条信息量大 */
.proto-root .jc-segmeter { display: flex; gap: 3px; }
.proto-root .jc-segmeter > i { flex: 1; height: 7px; border-radius: 2px; background: var(--ok); transition: background var(--t-fast); }
.proto-root .jc-segmeter > i.bad { background: var(--danger); }
.proto-root .jc-lockhint { display: inline-flex; align-items: center; gap: 4px; font-size: 10px; color: var(--ink-4); }
/* ── 占位胶囊:必填=红虚线,选填=灰虚线;**都带文字**,不靠颜色单打 ── */
.proto-root .jc-ph { display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700; line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed; }
.proto-root .jc-ph.req { color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
.proto-root .jc-ph.opt { color: var(--ink-4); background: var(--soft); border-color: var(--line); }
/* ── 分段控件(新客户 / 老客户)── */
.proto-root .jc-seg { display: inline-flex; padding: 2px; gap: 2px; border: 1px solid var(--cardline); border-radius: 9px; background: var(--soft); align-self: flex-start; }
.proto-root .jc-seg button { border: 0; border-radius: 7px; padding: 6px 13px; font: inherit; font-size: 11.5px; font-weight: 700; color: var(--ink-3); background: transparent; cursor: pointer; transition: all var(--t-fast); }
.proto-root .jc-seg button:hover:not(.on) { color: var(--brand); }
.proto-root .jc-seg button.on { background: #fff; color: var(--ink); box-shadow: 0 1px 3px rgba(31,35,41,.12); }
/* ── 表:读态与编辑态共用骨架 ─────────────────────────────────────── */
.proto-root .jc-tbl-wrap { overflow-x: auto; border: 1px solid var(--line-soft); border-radius: 10px; }
.proto-root .jc-tbl-in { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; }
.proto-root .jc-tbl-in th { background: var(--soft); text-align: left; padding: 8px 9px; font-size: 10.5px; font-weight: 800; letter-spacing: .03em; color: var(--ink-3); white-space: nowrap; border-bottom: 1px solid var(--line-soft); }
.proto-root .jc-tbl-in th.req::after { content: "*"; margin-left: 1px; color: var(--danger); font-size: 10px; }
.proto-root .jc-tbl-in td { padding: 4px 5px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
.proto-root .jc-tbl-in tr:last-child td { border-bottom: 0; }
.proto-root .jc-tbl-in tbody tr:hover td { background: #f7f9fd; }
/* 缺必填的整行浅红打底 + 行首红标——不用找红胶囊,一眼扫到是哪几行 */
.proto-root .jc-tbl-in tr.miss td { background: #fffbfb; }
.proto-root .jc-tbl-in tbody tr.miss:hover td { background: #fff5f5; }
.proto-root .jc-tbl-in td.jc-rn { position: relative; width: 38px; padding: 4px 6px 4px 13px; white-space: nowrap; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); font-weight: 800; }
/* 行首红标只在表态用 ::after —— 卡片态的 ::after 被「第 N 条」占着,不能抢 */
@media (min-width: 961px) {
.proto-root .jc-tbl-in tr.miss td.jc-rn::after { content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger); }
}
.proto-root .jc-rndot { display: inline-block; width: 5px; height: 5px; margin-left: 4px; border-radius: 999px; background: var(--danger); vertical-align: 2px; }
/* 读态单元格 */
.proto-root .jc-vcell { display: block; padding: 6px 6px; font-size: 12px; color: var(--ink); line-height: 1.5; overflow-wrap: anywhere; }
.proto-root .jc-vcell.mono { font-family: var(--mono); font-size: 11px; color: var(--ink-2); }
.proto-root .jc-vcell.num { font-family: var(--mono); font-size: 11.5px; text-align: right; }
/* 编辑态单元格:安静的电子表格 */
.proto-root .jc-cell { width: 100%; min-width: 64px; border: 1px solid var(--line); border-radius: 6px; padding: 6px 7px; font: inherit; font-size: 12px; color: var(--ink); background: #fff; transition: border-color var(--t-fast), box-shadow var(--t-fast); }
.proto-root .jc-cell.mono { font-family: var(--mono); font-size: 11px; }
.proto-root .jc-cell.num { text-align: right; font-family: var(--mono); font-size: 11.5px; }
.proto-root .jc-cell::placeholder { color: var(--ink-4); }
.proto-root .jc-cell:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-wash); outline: none; }
.proto-root .jc-cell.req-empty { border-color: #f3b6b3; background: #fffafa; }
.proto-root .jc-cell.req-empty::placeholder { color: var(--danger); }
/* 备注这类长文本用文本域:能换行、随内容长高,不裁字 */
.proto-root textarea.jc-cell { display: block; resize: vertical; overflow: hidden; min-height: 31px; line-height: 1.55; }
/* 编辑态的整表提示带 */
.proto-root .jc-editbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); border: 1px solid var(--brand-soft); font-size: 11.5px; color: var(--brand-deep); }
.proto-root .jc-editbar svg { flex: none; }
.proto-root .jc-editbar .sp { flex: 1 1 auto; }
/* 剔除行说明 */
.proto-root .jc-drop { display: flex; align-items: flex-start; gap: 8px; margin-top: 10px; padding: 9px 12px; border-radius: 9px; border: 1px dashed var(--line); background: var(--soft); font-size: 11.5px; color: var(--ink-3); line-height: 1.6; }
.proto-root .jc-drop svg { flex: none; margin-top: 2px; color: var(--ink-4); }
.proto-root .jc-drop code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
.proto-root .jc-callout { display: flex; gap: 8px; align-items: flex-start; border-radius: 9px; padding: 11px 13px; font-size: 12.5px; line-height: 1.75; }
.proto-root .jc-callout p { margin: 0; }
.proto-root .jc-callout svg { flex: none; margin-top: 2px; }
.proto-root .jc-callout.t-mute { background: var(--soft); color: var(--ink-2) }
@media (prefers-reduced-motion: reduce) {
.proto-root .jc-pulse-dot { animation: none; }
.proto-root .jc-report, .proto-root .jc-rp-stamp { animation: none; }
}
/* ══════════════════════════════════════════════════════════════════════════
响应式:宁可另起一行,也不损失完整度
≥961px 六列全展开的表(列 min-width 之和 878px,容器有余量,绝不裁字、绝不横滚)
≤960px 表 → 卡片:一条 PN 一张卡、一行一字段(标签 / 值),无横滚、无省略
≤560px 字段行再拆成上下两行(标签在上、值在下)
══════════════════════════════════════════════════════════════════════════ */
@media (max-width: 1040px) { .proto-root .jc-four { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 560px) { .proto-root .jc-four { grid-template-columns: 1fr; } }
@media (max-width: 960px) {
.proto-root .jc-tbl-wrap { overflow: visible; border: 0; border-radius: 0; background: transparent; }
.proto-root .jc-tbl-in,
.proto-root .jc-tbl-in tbody,
.proto-root .jc-tbl-in tr,
.proto-root .jc-tbl-in td { display: block; width: 100%; }
.proto-root .jc-tbl-in thead { display: none; }
/* 一条 PN = 一张卡 */
.proto-root .jc-tbl-in tr { margin-bottom: 10px; padding: 4px 13px 9px; border: 1px solid var(--line-soft); border-radius: 11px; background: #fff; box-shadow: var(--card-shadow); }
.proto-root .jc-tbl-in tr:last-child { margin-bottom: 0; }
.proto-root .jc-tbl-in tbody tr:hover td { background: transparent; }
.proto-root .jc-tbl-in tr.miss { border-color: #f3b6b3; background: #fffbfb; }
/* 卡头:第 N 条 */
.proto-root .jc-tbl-in td.jc-rn { padding: 8px 0 8px; margin-bottom: 4px; border-bottom: 1px solid var(--line-soft); font-family: inherit; font-size: 12px; font-weight: 800; color: var(--ink-2); }
.proto-root .jc-tbl-in td.jc-rn::before { content: "第 "; }
.proto-root .jc-tbl-in td.jc-rn::after { content: " 条"; }
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); }
/* 字段行:左标签 + 右值,值超长自动折行 */
.proto-root .jc-tbl-in td:not(.jc-rn) { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 10px; align-items: center; padding: 6px 0; border-bottom: 1px dashed var(--line-soft); }
.proto-root .jc-tbl-in tr td:last-child { border-bottom: 0; padding-bottom: 2px; }
.proto-root .jc-tbl-in td:not(.jc-rn)::before {
content: attr(data-h); font-size: 10.5px; font-weight: 700; letter-spacing: .02em; color: var(--ink-4); line-height: 1.5;
}
.proto-root .jc-tbl-in td[data-req="1"]:not(.jc-rn)::before { content: attr(data-h) " *"; }
.proto-root .jc-tbl-in .jc-vcell { padding: 0; overflow-wrap: anywhere; }
.proto-root .jc-tbl-in .jc-vcell.num { text-align: left; }
.proto-root .jc-tbl-in .jc-cell.num { text-align: left; }
.proto-root .jc-tbl-in .jc-cell { min-width: 0; }
}
/* 窄屏:落款三件各占一行,动作按钮撑满一行 */
@media (max-width: 560px) {
.proto-root .jc-rp-acts { margin-left: 0; width: 100%; flex-wrap: wrap; }
.proto-root .jc-rp-acts .jc-btn { flex: 1 1 auto; justify-content: center; }
.proto-root .jc-hintline { flex: 1 1 100%; }
.proto-root .jc-tbl-in td:not(.jc-rn) { grid-template-columns: 1fr; gap: 3px; align-items: start; }
}
/* ══ 以下取自 A1-询价受理记录.html(归档件独有的件)══ */
/* ── 料号分类:分布条 + 图例 ───────────────────────────────────────── */
.proto-root .jc-dist { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 13px 15px; border: 1px solid var(--line-soft); border-radius: 12px; background: linear-gradient(180deg, #fbfcfe, #fff); }
.proto-root .jc-distbar { flex: 1 1 320px; min-width: 260px; display: flex; gap: 3px; }
.proto-root .jc-distbar > i { height: 30px; border-radius: 4px; display: grid; place-items: center; font-size: 10.5px; font-weight: 800; color: #fff; font-family: var(--mono); }
.proto-root .jc-distbar > i.a { background: var(--ok); }
.proto-root .jc-distbar > i.b { background: var(--brand); }
.proto-root .jc-distbar > i.c { background: var(--warn-accent); }
.proto-root .jc-distlg { display: flex; flex-wrap: wrap; gap: 8px 18px; }
.proto-root .jc-distlg > span { display: inline-flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--ink-3); }
.proto-root .jc-distlg em { width: 10px; height: 10px; border-radius: 3px; }
.proto-root .jc-distlg em.a { background: var(--ok); } .proto-root .jc-distlg em.b { background: var(--brand); } .proto-root .jc-distlg em.c { background: var(--warn-accent); }
.proto-root .jc-distlg b { font-family: var(--mono); font-weight: 800; color: var(--ink); }
/* 类别徽 A / B / C */
.proto-root .jc-tbadge { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 6px; font-family: var(--mono); font-size: 11px; font-weight: 800; line-height: 1; color: #fff; }
.proto-root .jc-tbadge.a { background: var(--ok); } .proto-root .jc-tbadge.b { background: var(--brand); } .proto-root .jc-tbadge.c { background: var(--warn-accent); }
/* ── 结论与去向 ─────────────────────────────────────────────────────────
G2 是**分支网关**:三条互斥条件按序求值,只命中一条、只落一个活动。
所以这里不是三张并列的去向卡,而是一张**判定表 + 一条结论**。 */
/* 判定表:一行一条规则,命中行上色留痕(DMN 口径) */
.proto-root .jc-dmn { border: 1px solid var(--line-soft); border-radius: 12px; overflow: hidden; }
.proto-root .jc-dmn-h, .proto-root .jc-dmn-r { display: grid; grid-template-columns: 40px minmax(0, 1fr) 170px 200px; align-items: center; gap: 12px; padding: 9px 14px; }
.proto-root .jc-dmn-h { background: var(--soft); border-bottom: 1px solid var(--line-soft); font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); }
.proto-root .jc-dmn-r { font-size: 12px; color: var(--ink-4); background: #fff; }
.proto-root .jc-dmn-r + .jc-dmn-r { border-top: 1px solid var(--line-soft); }
.proto-root .jc-dmn-i { font-family: var(--mono); font-size: 11px; font-weight: 800; }
.proto-root .jc-dmn-c { line-height: 1.6; overflow-wrap: anywhere; }
.proto-root .jc-dmn-t { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; }
.proto-root .jc-dmn-t code { font-family: var(--mono); font-size: 10.5px; font-weight: 800; color: var(--ink-4); background: var(--soft); border: 1px solid var(--line); border-radius: 5px; padding: 2px 6px; }
.proto-root .jc-dmn-v { font-size: 11px; line-height: 1.55; }
/* 命中行:上色 + 左缘棱 + 加粗 */
.proto-root .jc-dmn-r.hit { position: relative; background: var(--tone-soft); color: var(--ink-2); }
.proto-root .jc-dmn-r.hit::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--tone); }
.proto-root .jc-dmn-r.hit .jc-dmn-i { color: var(--tone); }
.proto-root .jc-dmn-r.hit .jc-dmn-c { color: var(--ink); }
.proto-root .jc-dmn-r.hit .jc-dmn-c b { color: var(--tone); font-weight: 800; }
.proto-root .jc-dmn-r.hit .jc-dmn-t { color: var(--ink); }
.proto-root .jc-dmn-r.hit .jc-dmn-t code { color: #fff; background: var(--tone); border-color: var(--tone); }
.proto-root .jc-dmn-v.y { display: inline-flex; align-items: center; gap: 5px; font-weight: 800; color: var(--tone); }
.proto-root .jc-dmn-v.n { color: var(--ink-4); }
/* 色调随命中类别 */
.proto-root .jc-dmn.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.10); }
.proto-root .jc-dmn.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.09); }
.proto-root .jc-dmn.k-c { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.11); }
/* 结论横幅:命中条件 → 唯一去向 */
.proto-root .jc-verdict {
position: relative; overflow: hidden; display: flex; align-items: stretch; flex-wrap: wrap; gap: 16px;
margin-top: 11px; padding: 16px 18px; border: 1px solid var(--tone-line); border-radius: 13px;
background: linear-gradient(105deg, var(--tone-soft), #fff 62%);
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 10px 24px -16px rgba(31,35,41,.4);
--tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.14); --tone-line: #ffd9a8;
}
.proto-root .jc-verdict.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.13); --tone-line: #bfedb4; }
.proto-root .jc-verdict.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft); }
.proto-root .jc-verdict::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 5px; background: var(--tone); }
.proto-root .jc-verdict > * { position: relative; z-index: 1; }
.proto-root .jc-vd-l { flex: 1 1 300px; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
.proto-root .jc-vd-k { font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
.proto-root .jc-vd-cond { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 15px; font-weight: 800; color: var(--ink); }
.proto-root .jc-vd-why { margin: 0; font-size: 11.5px; line-height: 1.7; color: var(--ink-3); overflow-wrap: anywhere; }
.proto-root .jc-vd-why code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
.proto-root .jc-vd-arrow { flex: none; align-self: center; color: var(--tone); }
.proto-root .jc-vd-r { flex: 1 1 260px; min-width: 0; display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-radius: 11px; background: #fff; border: 1px solid var(--tone-line); }
.proto-root .jc-vd-node { display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 800; color: var(--ink); }
.proto-root .jc-vd-node code { font-family: var(--mono); font-size: 12px; font-weight: 800; color: #fff; background: var(--tone); border-radius: 6px; padding: 3px 8px; }
.proto-root .jc-vd-who { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--ink-2); }
.proto-root .jc-vd-who > i { width: 20px; height: 20px; border-radius: 999px; display: inline-grid; place-items: center; font-style: normal; font-size: 10px; font-weight: 700; color: #fff; background: var(--tone); }
.proto-root .jc-vd-next { margin: 0; padding-top: 8px; border-top: 1px dashed var(--line-soft); font-size: 10.5px; line-height: 1.65; color: var(--ink-4); }
.proto-root .jc-vd-next b { color: var(--ink-2); font-weight: 700; }
/* 命中分支携带的条目 */
.proto-root .jc-pnlist { display: flex; flex-wrap: wrap; gap: 5px; margin: 0; padding: 0; list-style: none; }
.proto-root .jc-pnlist > li { display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 999px; background: #fff; border: 1px solid var(--tone-line); font-size: 11px; color: var(--ink-2); overflow-wrap: anywhere; }
.proto-root .jc-pnlist > li > i { flex: none; width: 5px; height: 5px; border-radius: 999px; background: var(--tone); }
.proto-root .jc-pnlist code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
/* ── 落地动作 ───────────────────────────────────────────────────────── */
.proto-root .jc-acts { display: flex; flex-direction: column; gap: 0; border: 1px solid var(--line-soft); border-radius: 11px; overflow: hidden; }
.proto-root .jc-act { display: flex; align-items: flex-start; gap: 9px; padding: 10px 13px; font-size: 12px; color: var(--ink-2); line-height: 1.6; background: #fff; }
.proto-root .jc-act + .jc-act { border-top: 1px solid var(--line-soft); }
.proto-root .jc-act > i { flex: none; width: 18px; height: 18px; margin-top: 1px; border-radius: 999px; display: inline-grid; place-items: center; color: #fff; background: var(--ok); }
.proto-root .jc-act b { font-weight: 700; color: var(--ink); }
.proto-root .jc-act span { color: var(--ink-4); }
.proto-root .jc-act code { font-family: var(--mono); font-size: 10.5px; color: var(--brand-deep); background: var(--brand-wash); border-radius: 4px; padding: 1px 5px; }
.proto-root .jc-done-tag { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; flex: none; padding: 6px 13px; border-radius: 999px; font-size: 11.5px; font-weight: 800; color: var(--ok); background: #fff; border: 1px solid #bfedb4; }
.proto-root .jc-vcell.dim { color: var(--ink-4); }
.proto-root .jc-tbl-in td.jc-ty { width: 44px; text-align: center; }
.proto-root .jc-tbl-in td.jc-ty { width: auto; text-align: left; }
`;
/**
* 图标精灵 —— 与样式表同一出处,两份目标件的 `<symbol>` 取并集。
* 沙箱里取不到图标字体,所以图标一律内联;`<use href="#i-xxx">` 引的就是这一坨。
*/
export const PRD_SPRITE = `<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs><symbol id="i-alert" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></symbol><symbol id="i-arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol><symbol id="i-boxes" viewBox="0 0 24 24"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></symbol><symbol id="i-building" viewBox="0 0 24 24"><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/><path d="M10 18h4"/></symbol><symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol><symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol><symbol id="i-file-check" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="m9 15 2 2 4-4"/></symbol><symbol id="i-file-text" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></symbol><symbol id="i-git-branch" viewBox="0 0 24 24"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></symbol><symbol id="i-history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></symbol><symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></symbol><symbol id="i-lock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol><symbol id="i-mail-open" viewBox="0 0 24 24"><path d="M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0z"/><path d="m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10"/></symbol><symbol id="i-pen" viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z"/></symbol><symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol><symbol id="i-sheet" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M8 13h2"/><path d="M14 13h2"/><path d="M8 17h2"/><path d="M14 17h2"/></symbol><symbol id="i-shield-check" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></symbol><symbol id="i-user" viewBox="0 0 24 24"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/></symbol><symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></symbol></defs></svg>`;
+570 -58
View File
@@ -1,67 +1,579 @@
/**
* A4 视图模版 —— 定价的三屏:取价基准 / 定价与门禁 / 报价草案待放行(AM 07)。
* 定价这件事最需要「把判据摊开」:人签的不是一个数字,是这个数字的来路。
* A4 成本定价的 HTML 报告模版 —— **活动自己持有的资产**,随包版本冻结。
*
* 两个局面各一张(与 A2/A3 同款口径):
* · `pricing-worksheet` **定价工作单** —— 算价与 BD 核价期间摆它:逐颗料一张定价档案卡
* (题面在左、定价推演在右)、利润模型 P-01 的毛利档、底价门禁 P-02 的三档尺、
* 填回工作表就长在同一屏;
* · `pricing-roster` **报价草案清单** —— 超限拍板、放行与归档摆它:整份草案只读,
* 门禁分布与落地动作在上面。
*
* 「1:1」的落点是**每份件与它的办结快照**(在办可编辑 ↔ 回执收下那一刻的 finalHtml),
* 不是全程只许一张:作业的件与收口的件是两件工作两份件(规范姊妹篇 §1)。
*
* 版面语言沿用 A1 从 PRD 目标件机械抽取的那套(`prd-style.ts` 原样拷入)——
* A4 没有自己的 PRD 目标件,与 A2/A3 同一处置:沿用家族版面自造。
*
* 取值三纪律:顶层只取汇总列与单值列;逐条走 `mine:quote_pricing.draft`;
* 列名逐字照 `declarations.ledger` 抄。**模版不算不判** —— 缺价红带、门禁徽、分布条
* 全部由基座算好的数经 `data-class-of` 投影,判断留在样式表。
*/
import { kvCard, noteCard, tableCard, viewShell } from '@sdk';
import { PRD_CSS, PRD_SPRITE } from './prd-style.ts';
export const PRICING_BASIS = viewShell({
title: '取价基准',
kicker: 'A4 · 成本定价',
tone: 'brand',
/* ==================== 绑定期补的样式(照 A1/A3 的 BIND_CSS,地址换成本活动)==================== */
const BIND_CSS = `
/* 空占位三级(2026-08-02 全局口径):必填红 / 建议填橘 / 选填灰 —— 值真的空才轮到 */
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after,
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after,
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after {
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
}
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after { content: "必填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after { content: "建议填"; color: var(--warn-accent); background: var(--warn-soft); border-color: #f0cf9a; }
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after { content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line); }
/* 归档件里空格子就是「没有」,不再摆「你还能填」的胶囊 */
.proto-root .jc-tbl-in td.dimempty > .jc-vcell:empty::after { content: "—"; color: var(--ink-4); border: 0; background: none; padding: 0; }
/* 缺拟报价的整行浅红 + 行首红条 —— 原型的 tr.miss,这里由 :has() 认 */
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td { background: #fffbfb; }
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty):hover td { background: #fff5f5; }
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn { color: var(--danger); font-weight: 800; position: relative; }
@media (min-width: 1001px) {
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn::after {
content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger);
}
}
/* 序号由 CSS 计数器排 */
.proto-root .jc-tbl-in tbody { counter-reset: rn; }
.proto-root .jc-tbl-in td.jc-rn::before { counter-increment: rn; content: counter(rn); }
@media (max-width: 1000px) {
.proto-root .jc-tbl-in td.jc-rn::before { content: "第 " counter(rn) " 条"; }
.proto-root .jc-tbl-in td.jc-rn::after { content: none; }
}
/* 就地改:可改格子渲了只读与输入两份,默认锁着,表上加 on 才换孪生件 */
.proto-root .jc-cell { display: none; }
.proto-root .jc-tbl-in.on td > .jc-vcell,
.proto-root .jc-tbl-in.on td > .jc-tbadge { display: none; }
.proto-root .jc-tbl-in.on td > .jc-cell { display: block; }
/* 类徽与门禁徽不进编辑态(承继与引擎产物),on 时也照旧摆着 */
.proto-root .jc-tbl-in.on td > .jc-obadge, .proto-root .jc-tbl-in.on td > .jc-gbadge { display: inline-flex; }
/* 数值列与表头同侧左对齐(一行一个料,彼此不相加) */
.proto-root .jc-vcell.num, .proto-root .jc-cell.num { text-align: left; }
/* 编辑态下必填空格子:红框 + 整行浅红(由 :placeholder-shown 认) */
.proto-root .jc-tbl-in.on td.req > .jc-cell:placeholder-shown { border-color: #f3b6b3; background: #fffafa; }
.proto-root .jc-tbl-in.on tbody tr:has(td.req > .jc-cell:placeholder-shown) td { background: #fffbfb; }
/* 铺满工作区 */
.jc-stage { max-width: none; padding: 0 0 4px; }
.proto-root .jc-report { border: 0; border-radius: 0; box-shadow: none; }
/* 那道闸 —— 默认「已锁定 + 编辑」,开了变成「取消 / 保存」;只在可改屏现身 */
.proto-root .jc-editgate { display: none; }
html.editable .proto-root .jc-editgate {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
margin-bottom: 10px; padding: 8px 12px; border-radius: 9px;
background: var(--soft); border: 1px solid var(--line-soft);
font-size: 11.5px; color: var(--ink-3);
}
.proto-root .jc-editgate-t { display: inline-flex; align-items: center; gap: 6px; flex: 1 1 auto; min-width: 0; }
.proto-root .jc-editgate-t b { color: var(--ink-2); font-weight: 700; }
.proto-root .jc-egbtn {
flex: none; display: inline-flex; align-items: center; gap: 4px; cursor: pointer;
border: 1px solid var(--line); border-radius: 7px; padding: 5px 11px;
font: inherit; font-size: 11.5px; font-weight: 700; color: var(--ink-2); background: #fff;
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
}
.proto-root .jc-egbtn:hover { border-color: var(--brand); color: var(--brand); }
.proto-root .jc-egbtn.save { color: #fff; background: var(--brand); border-color: var(--brand); }
.proto-root .jc-egbtn.save:hover { background: var(--brand-deep); border-color: var(--brand-deep); }
.proto-root .jc-editgate .jc-egbtn.ghost, .proto-root .jc-editgate .jc-egbtn.save { display: none; }
.proto-root .jc-editgate.on { background: var(--brand-wash); border-color: var(--brand-soft); color: var(--brand-deep); }
.proto-root .jc-editgate.on .jc-egbtn.open { display: none; }
.proto-root .jc-editgate.on .jc-egbtn.ghost, .proto-root .jc-editgate.on .jc-egbtn.save { display: inline-flex; }
.proto-root .jc-editgate.on .jc-editgate-t { visibility: hidden; }
/* 缺价红带 —— 只在可改屏出,一格不缺自动收(v-0 那一档 display:none) */
.proto-root .jc-gapbar { display: none; }
html.editable .proto-root .jc-gapbar {
display: flex; align-items: flex-start; gap: 8px;
margin: 0 0 10px; padding: 9px 12px; border-radius: 9px;
background: #fff5f5; border: 1px solid #f3b6b3; color: var(--danger);
font-size: 12px; line-height: 1.65;
}
.proto-root .jc-gapbar svg { flex: 0 0 auto; margin-top: 2px; }
.proto-root .jc-gapbar b { font-weight: 800; }
.proto-root .jc-gapbar span { color: #9a3b36; }
html.editable .proto-root .jc-gapbar.v-0 { display: none; }
/* 那条「怎么改」的提示只在开了闸之后说 */
.proto-root .jc-editbar { display: none; }
.proto-root .jc-editgate.on + .jc-editbar { display: flex; }
/* 增删行 */
.proto-root .jc-rowdel { display: none; }
.proto-root .jc-tbl-in.on td.jc-rn::before { content: none; }
.proto-root .jc-tbl-in.on .jc-rowdel {
display: block; width: 20px; height: 20px; margin: 0 auto; cursor: pointer;
border: 1px solid var(--line); border-radius: 6px; padding: 0;
font: inherit; font-size: 13px; line-height: 1; color: var(--ink-3); background: #fff;
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
}
.proto-root .jc-tbl-in.on .jc-rowdel:hover { border-color: var(--danger); color: var(--danger); background: #fff6f6; }
.proto-root .jc-tbl-in.on tr.jc-row-gone td { background: #fff6f6; opacity: .55; }
.proto-root .jc-tbl-in.on tr.jc-row-gone .jc-cell { text-decoration: line-through; color: var(--ink-3); }
.proto-root .jc-tbl-in.on tr.jc-row-gone .jc-rowdel { border-color: var(--danger); color: var(--danger); background: #fff; }
.proto-root .jc-tbl-in.on tr.jc-row-new td { background: #f2fbf7; }
.proto-root .jc-addrow { display: none; }
html.editable .proto-root .jc-tbl-wrap:has(table.on) + .jc-addrow {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin: -2px 0 12px; padding: 7px 12px; border-radius: 9px;
background: var(--soft); border: 1px dashed var(--line);
}
.proto-root .jc-addrow em { font-style: normal; font-size: 11px; color: var(--ink-3); }
/* 类徽按值上色(承继上游的三分类) */
.proto-root .jc-tbadge { background: var(--ink-4); }
.proto-root .jc-tbadge.v-a { background: var(--ok); }
.proto-root .jc-tbadge.v-b { background: var(--brand); }
.proto-root .jc-tbadge.v-c { background: var(--warn-accent); }
.proto-root .jc-segmeter > i.v-pass { background: var(--ok); }
.proto-root .jc-segmeter > i.v-note { background: var(--warn-accent); }
.proto-root .jc-segmeter > i.v-block { background: var(--danger); }
/* 门禁徽:直接放行绿 / 附理由橙 / 拦截红 —— 值(PASS/NOTE/BLOCK)是机器码,人读名交给样式表 */
.proto-root .jc-gbadge {
display: inline-flex; align-items: center; justify-content: center;
min-width: 56px; height: 20px; padding: 0 7px; border-radius: 6px;
font-size: 10px; font-weight: 800; letter-spacing: .02em;
color: var(--ink-4); border: 1.5px solid var(--ink-4); background: #fff;
}
.proto-root .jc-gbadge::after { content: "待判"; }
.proto-root .jc-gbadge.v-pass { color: var(--ok); border-color: var(--ok); }
.proto-root .jc-gbadge.v-pass::after { content: "直接放行"; }
.proto-root .jc-gbadge.v-note { color: var(--warn-accent); border-color: var(--warn-accent); }
.proto-root .jc-gbadge.v-note::after { content: "附理由"; }
.proto-root .jc-gbadge.v-block { color: var(--danger); border-color: var(--danger); }
.proto-root .jc-gbadge.v-block::after { content: "拦截待拍板"; }
/* 分布条:三段按条数分宽 —— 条数投影成 v-<N>,梯子取宽;零条那段自己消失 */
.proto-root .jc-distbar > i { flex: 24; min-width: 34px; display: inline-flex; align-items: center; justify-content: center; }
.proto-root .jc-distbar > i.v-0, .proto-root .jc-distbar > i:not([class*=" v-"]) { display: none; }
.proto-root .jc-distbar > i.v-1 { flex: 1 } .proto-root .jc-distbar > i.v-2 { flex: 2 }
.proto-root .jc-distbar > i.v-3 { flex: 3 } .proto-root .jc-distbar > i.v-4 { flex: 4 }
.proto-root .jc-distbar > i.v-5 { flex: 5 } .proto-root .jc-distbar > i.v-6 { flex: 6 }
.proto-root .jc-distbar > i.v-7 { flex: 7 } .proto-root .jc-distbar > i.v-8 { flex: 8 }
.proto-root .jc-distbar > i.v-9 { flex: 9 } .proto-root .jc-distbar > i.v-10 { flex: 10 }
.proto-root .jc-distbar > i.v-11 { flex: 11 } .proto-root .jc-distbar > i.v-12 { flex: 12 }
.proto-root .jc-distbar > i.v-13 { flex: 13 } .proto-root .jc-distbar > i.v-14 { flex: 14 }
.proto-root .jc-distbar > i.v-15 { flex: 15 } .proto-root .jc-distbar > i.v-16 { flex: 16 }
.proto-root .jc-distbar > i.v-17 { flex: 17 } .proto-root .jc-distbar > i.v-18 { flex: 18 }
.proto-root .jc-distbar > i.v-19 { flex: 19 } .proto-root .jc-distbar > i.v-20 { flex: 20 }
.proto-root .jc-distbar > i.p { background: var(--ok); }
.proto-root .jc-distbar > i.n { background: var(--warn-accent); }
.proto-root .jc-distbar > i.k { background: var(--danger); }
.proto-root .jc-distbar > i.p::before { content: "直接放行 · "; }
.proto-root .jc-distbar > i.n::before { content: "附理由 · "; }
.proto-root .jc-distbar > i.k::before { content: "拦截 · "; }
.proto-root .jc-logo svg, .proto-root .jc-face svg, .proto-root .jc-rp-ava svg { width: 17px; height: 17px; }
/* ── 定价档案卡(工作单独有,照 A3 询价档案卡的骨架)──────────────────────
一颗料一张卡,data-rows 逐行复制:左题面、右推演。
形态按门禁处置分档(data-class-of):拦截档整卡描红,附理由档描橙 —— 判断在样式表。 */
.proto-root .jc-selcards { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
.proto-root .jc-selcard { border: 1px solid var(--line); border-radius: 11px; background: #fff; overflow: hidden; }
.proto-root .jc-selcard.v-note { border-color: #f0cf9a; box-shadow: 0 1px 0 rgba(191,132,32,.06); }
.proto-root .jc-selcard.v-block { border-color: #f3b6b3; box-shadow: 0 1px 0 rgba(207,64,58,.08); }
.proto-root .jc-selcard-h {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 9px 14px; background: var(--soft); border-bottom: 1px solid var(--line-soft);
}
.proto-root .jc-selcard.v-block .jc-selcard-h { background: var(--danger-soft); border-bottom-color: #f3b6b3; }
.proto-root .jc-selcard.v-note .jc-selcard-h { background: var(--warn-soft); border-bottom-color: #f0cf9a; }
.proto-root .jc-selcard-h code { font-size: 13px; font-weight: 800; color: var(--ink); }
.proto-root .jc-selcard-h .why { flex: 1 1 auto; min-width: 0; font-size: 11px; color: var(--ink-3); }
.proto-root .jc-selcard-b { display: grid; grid-template-columns: 1fr 1fr; gap: 0; }
@media (max-width: 1000px) { .proto-root .jc-selcard-b { grid-template-columns: 1fr; } }
.proto-root .jc-selcol { padding: 11px 14px; }
.proto-root .jc-selcol + .jc-selcol { border-left: 1px solid var(--line-soft); }
@media (max-width: 1000px) { .proto-root .jc-selcol + .jc-selcol { border-left: 0; border-top: 1px solid var(--line-soft); } }
.proto-root .jc-selcol h5 { margin: 0 0 7px; font-size: 10.5px; font-weight: 800; letter-spacing: .08em; color: var(--ink-3); }
.proto-root .jc-kvrow { display: flex; gap: 8px; padding: 3px 0; font-size: 12px; line-height: 1.55; }
.proto-root .jc-kvrow i { flex: 0 0 84px; font-style: normal; color: var(--ink-4); font-size: 11px; padding-top: 1px; }
.proto-root .jc-kvrow span { flex: 1 1 auto; min-width: 0; color: var(--ink); overflow-wrap: anywhere; }
.proto-root .jc-kvrow span:empty::after { content: "—"; color: var(--ink-4); }
.proto-root .jc-selcard .sc-req:empty::after {
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
content: "必填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3;
}
.proto-root .jc-selcard .sc-sug:empty::after {
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
content: "建议填"; color: var(--warn-accent); background: var(--warn-soft); border-color: #f0cf9a;
}
.proto-root .jc-selcard .sc-opt:empty::after {
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line);
}
/* 两把尺(静态规则表 —— 尺挂在墙上,不是这一单的数据) */
.proto-root .jc-graderule { border: 1px solid var(--line); border-radius: 10px; overflow: hidden; margin-bottom: 12px; }
.proto-root .jc-graderule-r { display: flex; gap: 12px; align-items: flex-start; padding: 9px 13px; font-size: 12px; line-height: 1.6; }
.proto-root .jc-graderule-r + .jc-graderule-r { border-top: 1px solid var(--line-soft); }
.proto-root .jc-graderule-r p { margin: 0; color: var(--ink-2); }
.proto-root .jc-graderule-r p b { color: var(--ink); }
.proto-root .jc-band {
flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; margin-top: 1px;
min-width: 74px; height: 20px; padding: 0 8px; border-radius: 6px;
font-size: 10.5px; font-weight: 800; letter-spacing: .04em; border: 1.5px solid; background: #fff;
}
.proto-root .jc-band.ga { color: var(--ok); border-color: var(--ok); }
.proto-root .jc-band.gb { color: var(--brand); border-color: var(--brand); }
.proto-root .jc-band.gc { color: var(--warn-accent); border-color: var(--warn-accent); }
.proto-root .jc-band.gp { color: var(--ok); border-color: var(--ok); }
.proto-root .jc-band.gn { color: var(--warn-accent); border-color: var(--warn-accent); }
.proto-root .jc-band.gk { color: var(--danger); border-color: var(--danger); }
/* 一个价怎么长出来的:成本 → 底价线 → 拟报价,内联线稿 SVG 走位图 */
.proto-root .jc-priceline { display: flex; align-items: center; gap: 0; margin: 2px 0 14px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 11px; background: var(--soft); }
.proto-root .jc-plnode { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 3px; align-items: flex-start; }
.proto-root .jc-plnode b { font-size: 10.5px; font-weight: 800; letter-spacing: .06em; color: var(--ink-3); }
.proto-root .jc-plnode span { font-size: 12px; color: var(--ink-2); line-height: 1.5; }
.proto-root .jc-plarrow { flex: 0 0 auto; padding: 0 10px; color: var(--brand-soft); }
@media (max-width: 1000px) { .proto-root .jc-priceline { flex-direction: column; align-items: stretch; gap: 8px; } .proto-root .jc-plarrow { display: none; } }
`;
/** 一枚图标 —— 与原型同一写法 */
const ico = (name: string, size = 11): string =>
`<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-${name}"/></svg>`;
/** 三层壳照原型:少一层样式表全失效 */
const doc = (input: { badge: string; kicker: string; title: string; titleRef: string; state: string; body: string; foot: string }): string =>
`<style>${PRD_CSS}${BIND_CSS}</style>
${PRD_SPRITE}
<div class="proto-root"><div class="app-shell"><div class="jc-stage">
<article class="jc-report">
<header class="jc-rp-head">
<div class="jc-rp-badge">${ico(input.badge, 17)}</div>
<div class="jc-rp-tit">
<em>${input.kicker}</em>
<h3>${input.title} <span data-slot="${input.titleRef}"></span></h3>
</div>
${input.state}
</header>
<div class="jc-rp-bd">${input.body}</div>
<footer class="jc-rp-foot">${input.foot}</footer>
</article>
</div></div></div>`;
const part = (label: string, aside = '', tone = ''): string =>
`<div class="jc-rp-part"><b>${label}</b><i class="ln"></i>${aside === '' ? '' : `<em class="${tone}">${aside}</em>`}</div>`;
const sign = (note: string, tag = ''): string =>
`<div class="jc-rp-sign">
<span class="jc-rp-ava">${ico('user', 15)}</span>
<div><b data-slot="case:owner"></b><span>${note}</span></div>
</div>${tag === '' ? '' : `<span class="jc-done-tag">${ico('git-branch', 13)}${tag}</span>`}`;
/* ==================== 组成件 ==================== */
/** 要素四卡:客户 / 待定价 / 一格一料的门禁刻度 / 定价的尺 */
const FOUR = `<div class="jc-four">
<div class="jc-fcard">
<span class="jc-fcard-k"><i>${ico('building')}</i>客户公司</span>
<div class="jc-fcard-b">
<span class="jc-logo">${ico('building', 17)}</span>
<div class="jc-fcard-t"><b data-slot="customer_name" data-blank></b><span data-slot="customer_code" data-blank></span></div>
</div>
<span class="jc-fcard-note">承自上游;单飞起单时此栏可空</span>
</div>
<div class="jc-fcard t-teal">
<span class="jc-fcard-k"><i>${ico('boxes')}</i>待定价</span>
<div class="jc-count"><b data-slot="quote_pricing_draft_missing"></b><span>/ <b data-slot="quote_pricing_draft_rows"></b> 条草案</span></div>
<span class="jc-fcard-note">拟报单价还空着的行 —— 这一列由你落笔,系统只给建议价;补一格少一格,基座现数</span>
</div>
<div class="jc-fcard">
<span class="jc-fcard-k"><i>${ico('shield-check')}</i>底价门禁逐条</span>
<div class="jc-segmeter" data-rows="mine:quote_pricing.draft">
<i data-class-of="disposition"></i>
</div>
<span class="jc-fcard-note">一格一条料 · 绿=在底价线之上,橙=低于两成内附理由,红=压在拦截档待拍板</span>
</div>
<div class="jc-fcard t-ok">
<span class="jc-fcard-k"><i>${ico('history')}</i>取价的尺</span>
<span class="jc-tagbig">${ico('check', 13)}<span>成本 × 利润模型 → 底价线</span></span>
<span class="jc-fcard-note">基准取上游底价或库内历史价;毛利档由 P-01 定;偏离底价线两成即止(P-02)</span>
</div>
</div>`;
/** 一个价是怎么长出来的 —— 走位图(形状写死,不算不判) */
const PRICE_LINE = `<div class="jc-priceline">
<div class="jc-plnode"><b>成本基准</b><span>上游底价 / 库内历史价 —— 取不到就空着,不编</span></div>
<div class="jc-plarrow">${ico('arrow-right', 18)}</div>
<div class="jc-plnode"><b>毛利档 P-01</b><span>客户等级 × 类别 × 量档 → 一段区间</span></div>
<div class="jc-plarrow">${ico('arrow-right', 18)}</div>
<div class="jc-plnode"><b>底价线</b><span>成本 ×(1 + 档下限)—— 比它低就要有人负责</span></div>
<div class="jc-plarrow">${ico('arrow-right', 18)}</div>
<div class="jc-plnode"><b>建议价 → 你定价</b><span>系统给建议,拟报单价那一格由你落笔</span></div>
<div class="jc-plarrow">${ico('arrow-right', 18)}</div>
<div class="jc-plnode"><b>门禁 P-02</b><span>偏离底价线:≥0 放行 / 两成内附理由 / 超两成拍板</span></div>
</div>`;
/** 缺价红带 —— 与提交闸、放行门 L1 同一个数同一套算法(基座现数);一格不缺自动收 */
const GAP_BAR = `<div class="jc-gapbar" data-class-of="quote_pricing_draft_missing">${ico('alert', 14)}
<span><b>还差 <span data-slot="quote_pricing_draft_missing"></span> 行拟报单价没定</b>
—— <span data-slot="quote_pricing_draft_missing_keys"></span>。
按右上角「编辑」开表,红框那几格就是要填的:认可系统建议价就照抄那个数,不认可就改成你的;
库内查不到成本的那几行没有建议价,由你按经验定并在备注写明依据。补齐了执行单才提交得了。</span>
</div>`;
const editGate = (address: string, what: string): string => `<div class="jc-editgate" data-edit-for="${address}">
<span class="jc-editgate-t">${ico('lock', 12)}${what}<b>已锁定</b> —— 看的时候不误碰,要改按右边</span>
<button type="button" class="jc-egbtn open" data-edit-open="${address}">${ico('pen', 12)}编辑</button>
<button type="button" class="jc-egbtn ghost" data-edit-cancel="${address}">取消</button>
<button type="button" class="jc-egbtn save" data-edit-save="${address}">${ico('check', 12)}保存</button>
</div>
<div class="jc-editbar">${ico('pen', 14)}
<span>可填的是<b>拟报单价</b>与<b>备注</b>两列:<b>拟报单价这一列系统不替你填</b> ——
认可建议价就照抄那个数,改高改低都可以,改了在备注里写一句缘由;
毛利、偏离度与门禁结论随后按你定的价重算。本轮不报的料按行首 × 撤掉。
改动只落任务草稿层,<b>保存不等于放行</b>。</span>
</div>`;
const addRowBar = (address: string): string => `<div class="jc-addrow">
<button type="button" class="jc-egbtn ghost" data-edit-add="${address}">${ico('plus', 12)}加一行</button>
<em>照末行的样子添一行空的;主键(型号 PN)填了才存得下,不填这一行不落库。</em>
</div>`;
/**
* 草案主表 —— 一行一料:承继列 + 定价五列 + 类徽与门禁徽。
* `data-col` 只挂人可改的两列(quoted_price / price_note)+ 主键 pn ——
* 编辑面精确等于 BD 的职权面;**主键格必挂 `data-col`**(它不是编辑面,是行的钥匙:
* 不挂,服务端按「全表一致列」补键,两行会对到同一行上,后者盖前者 —— A2 走查逮过)。
* 拟报单价挂 `req`:空着出「必填」胶囊、整行标底,与提交闸、放行门同源(都数 `_missing`)。
*/
const DRAFT_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
<thead><tr>
<th style="width:36px;min-width:36px"></th>
<th style="width:44px;min-width:44px">类</th>
<th style="min-width:170px">PN</th>
<th style="min-width:84px">品类</th>
<th style="min-width:70px">年用量</th>
<th style="min-width:78px">成本基准</th>
<th style="min-width:86px">毛利档</th>
<th style="min-width:78px">底价线</th>
<th style="min-width:78px">客户目标价</th>
<th style="min-width:82px">系统建议价</th>
<th class="req" style="min-width:96px">拟报单价(你定)</th>
<th style="min-width:70px">毛利率</th>
<th style="min-width:70px">偏离度</th>
<th style="width:78px;min-width:78px">门禁</th>
<th style="min-width:150px;width:100%">备注 / 让价理由</th>
</tr></thead>
<tbody data-rows="mine:quote_pricing.draft" data-edit="quote_pricing.draft"><tr>
<td class="jc-rn"><button type="button" class="jc-rowdel" data-edit-del="quote_pricing.draft" title="撤掉这一行">×</button></td>
<td class="jc-ty" data-h="类"><span class="jc-tbadge" data-class-of="type_tag" data-slot="type_tag"></span></td>
<td data-h="PN"><span class="jc-vcell mono" data-slot="pn" data-col="pn" data-blank></span></td>
<td class="dimempty" data-h="品类"><span class="jc-vcell" data-slot="category" data-blank></span></td>
<td class="dimempty" data-h="年用量"><span class="jc-vcell mono" data-slot="eau" data-blank></span></td>
<td class="dimempty" data-h="成本基准"><span class="jc-vcell num" data-slot="base_price" data-blank></span></td>
<td class="dimempty" data-h="毛利档"><span class="jc-vcell" data-slot="margin_band" data-blank></span></td>
<td class="dimempty" data-h="底价线"><span class="jc-vcell num" data-slot="floor_price" data-blank></span></td>
<td class="sug" data-h="客户目标价"><span class="jc-vcell num" data-slot="target_price" data-blank></span></td>
<td class="dimempty" data-h="系统建议价"><span class="jc-vcell num" data-slot="suggested_price" data-blank></span></td>
<td class="req" data-h="拟报单价(你定)"><span class="jc-vcell num" data-slot="quoted_price" data-col="quoted_price" data-blank></span></td>
<td class="dimempty" data-h="毛利率"><span class="jc-vcell num" data-slot="margin" data-blank></span></td>
<td class="dimempty" data-h="偏离度"><span class="jc-vcell num" data-slot="deviation" data-blank></span></td>
<td data-h="门禁"><span class="jc-gbadge" data-class-of="disposition"></span></td>
<td class="opt" data-h="备注 / 让价理由"><span class="jc-vcell" data-slot="price_note" data-col="price_note" data-blank></span></td>
</tr></tbody>
</table></div>`;
/** 门禁分布:三段按条数分宽 —— 守恒等式(三档相加 = 行数)的可视形 */
const DIST = `<div class="jc-dist">
<div class="jc-distbar">
<i class="p" data-class-of="quote_pricing_draft_disposition_pass" data-slot="quote_pricing_draft_disposition_pass"></i>
<i class="n" data-class-of="quote_pricing_draft_disposition_note" data-slot="quote_pricing_draft_disposition_note"></i>
<i class="k" data-class-of="quote_pricing_draft_disposition_block" data-slot="quote_pricing_draft_disposition_block"></i>
</div>
<div class="jc-distlg">
<span><em class="a"></em>直接放行 · 在底价线之上 <b data-slot="quote_pricing_draft_disposition_pass"></b></span>
<span><em class="b"></em>附理由放行 · 低于两成内 <b data-slot="quote_pricing_draft_disposition_note"></b></span>
<span><em class="c"></em>拦截待拍板 · 低于两成以上 <b data-slot="quote_pricing_draft_disposition_block"></b></span>
</div>
</div>`;
/* ==================== 局面一 · 定价工作单(算价与 BD 核价期间)==================== */
/**
* 逐颗定价档案 —— 一颗料一张卡(data-rows 逐行复制):左题面、右推演。
*
* **容器用 `<section>` 不用 `<div>`**:渲染器的行展开按第一个同名闭合标签截行内容,
* 行模版里嵌着 `<div>` 时 `<div data-rows>` 会把行截成套娃(A2 逮过,记在陷阱表)。
*/
const PRICE_CARDS = `<section class="jc-selcards" data-rows="mine:quote_pricing.draft">
<article class="jc-selcard" data-class-of="disposition">
<div class="jc-selcard-h">
<span class="jc-tbadge" data-class-of="type_tag" data-slot="type_tag"></span>
<code data-slot="pn"></code>
<span class="why" data-slot="remark" data-blank></span>
<span class="jc-gbadge" data-class-of="disposition"></span>
</div>
<div class="jc-selcard-b">
<div class="jc-selcol">
<h5>题面(客户要什么)</h5>
<div class="jc-kvrow"><i>客户料号</i><span data-slot="customer_pn" data-blank></span></div>
<div class="jc-kvrow"><i>品类</i><span data-slot="category" data-blank></span></div>
<div class="jc-kvrow"><i>制造商</i><span data-slot="brand" data-blank></span></div>
<div class="jc-kvrow"><i>年用量</i><span data-slot="eau" data-blank></span></div>
<div class="jc-kvrow"><i>客户目标价</i><span class="sc-sug num" data-slot="target_price" data-blank></span></div>
<div class="jc-kvrow"><i>交期要求</i><span data-slot="lead_time" data-blank></span></div>
<div class="jc-kvrow"><i>客户约束</i><span data-slot="remark" data-blank></span></div>
</div>
<div class="jc-selcol">
<h5>定价推演(填回下表,这里回显)</h5>
<div class="jc-kvrow"><i>成本基准</i><span class="num" data-slot="base_price" data-blank></span></div>
<div class="jc-kvrow"><i>毛利档</i><span data-slot="margin_band" data-blank></span></div>
<div class="jc-kvrow"><i>底价线</i><span class="num" data-slot="floor_price" data-blank></span></div>
<div class="jc-kvrow"><i>系统建议价</i><span class="num" data-slot="suggested_price" data-blank></span></div>
<div class="jc-kvrow"><i>拟报单价</i><span class="sc-req num" data-slot="quoted_price" data-blank></span></div>
<div class="jc-kvrow"><i>毛利率</i><span class="num" data-slot="margin" data-blank></span></div>
<div class="jc-kvrow"><i>偏离底价线</i><span class="num" data-slot="deviation" data-blank></span></div>
<div class="jc-kvrow"><i>让价理由</i><span class="sc-opt" data-slot="price_note" data-blank></span></div>
</div>
</div>
</article>
</section>`;
/** 利润模型 P-01 的毛利档 —— 静态规则,尺挂在墙上(素材:MVP 示例工作流 §1.2) */
const MARGIN_RULES = `<div class="jc-graderule">
<div class="jc-graderule-r">
<span class="jc-band ga">8%~10%</span>
<p><b>战略客户 · A 类标准件 · 大批量。</b>量换价,毛利让到下限也要守住 —— 这一档的价不是拿来谈的,
是拿来锁长单的。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gb">12%~15%</span>
<p><b>普通客户 · A 类标准件。</b>常规档,也是拿不准时的兜底档 —— 行情透明的料按这一档报不吃亏。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gc">15%~20%</span>
<p><b>B 类波动件。</b>货源与行情都会动,毛利要留出被动让价的余地。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gc">20%~50%</span>
<p><b>C 类替代 / 停产件。</b>一事一议:找料本身就是成本,替代还带验证风险 ——
报低了后面每一次补货都在亏。</p>
</div>
</div>`;
/** 底价门禁 P-02 的三档尺 —— 静态规则(素材:MVP 示例工作流 §1.2 底价自检) */
const GATE_RULES = `<div class="jc-graderule">
<div class="jc-graderule-r">
<span class="jc-band gp">≥ 底价线</span>
<p><b>直接放行。</b>报价在底价线之上,毛利还在档内 —— 不必解释,提审即可。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gn">低于两成内</span>
<p><b>附理由放行。</b>为拿单让到底价线以下两成以内:在备注里写清让价的缘由
(客户长单、带单品、竞对压价)—— 理由随草案一起交审批,不写等于没有依据。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-band gk">低于两成以上</span>
<p><b>拦截,要人拍板。</b>这一档系统不替任何人放行:要么回调到底价线,要么由负责人
拍板破线并留下理由,要么这一条本轮不报。<b>私自绕开门禁不算处置</b>。</p>
</div>
</div>`;
export const PRICING_WORKSHEET = doc({
badge: 'sheet',
kicker: '统一报价 · A4 成本定价 · 定价作业',
title: '定价工作单',
titleRef: 'rfq_ref',
state:
'<span class="jc-rp-wait warn">待定价 <b data-slot="quote_pricing_draft_missing"></b> / <b data-slot="quote_pricing_draft_rows"></b> 条</span>',
body:
kvCard('本单标的', [
['客户编码', 'customerCode', 'mono'],
['型号 PN', 'pn', 'mono'],
['物料类型', 'materialType', 'tag'],
]) +
kvCard('价格基准', [
['上次成交价', 'last_price'],
['客户等级', 'customerGrade'],
['数量档', 'volumeTier'],
]) +
noteCard('基准从哪来', '基准价取自历史价情报(CRM)。新料号无历史时基准记 0,由底价门禁按<b>无基准</b>处置 —— 不凭空编一个偏离率出来。'),
part('定价任务', '客户 <b data-slot="customer_name" data-blank></b> · 共 <b data-slot="line_total"></b> 条 · 有基准价 <b data-slot="basis_total"></b> 条') +
`<div class="jc-derive">${ico('info', 14)}
<span>这一屏是一份报价草案的作业台:系统按「成本基准 × 利润模型」给出底价线与
<b>建议价</b>,<b>拟报单价那一列由你落笔</b> —— 人签的就是这个数,系统不代签。
库内查不到成本的料连建议价都没有(不编),那几行按你的经验定并在备注写明依据。
你定完价,毛利、偏离度与底价门禁的结论跟着重算。</span>
</div>` +
FOUR +
part('一个价是怎么长出来的', '成本 → 毛利档 → 底价线 → 拟报价 → 门禁') +
PRICE_LINE +
part('逐颗定价档案', '题面在左 · 推演在右') +
PRICE_CARDS +
part('利润模型 P-01 的毛利档', '客户等级 × 类别 × 量档 —— 尺挂在墙上') +
MARGIN_RULES +
part('底价门禁 P-02', '偏离底价线两成即止') +
GATE_RULES +
part('填回工作表', '定价落在这 —— 补齐了执行单才提交得了') +
GAP_BAR +
editGate('quote_pricing.draft', '报价草案填回表') +
DRAFT_TABLE +
addRowBar('quote_pricing.draft') +
`<div class="jc-drop">${ico('info', 13)}
<span>带红标的<b>拟报单价</b>是必填,而且<b>只由你填</b> —— 缺一行执行单按不下提交,
与放行门是同一个数同一套算法。系统给的是建议价:照抄即认可,改了以你的为准。
查不到成本的料没有建议价(系统不编):按你的经验定一个价并在备注写明依据,
或者按行首 × 把这一条撤出本轮。压在拦截档(红)的条目在你这儿往上调一点,
就不必再惊动第二个人。</span>
</div>`,
foot: sign('BD 核价 · AI 按利润模型与底价门禁算价,你定最后那个数', '统一报价 · A4 成本定价 · 在办'),
});
export const PRICING_PRICED = viewShell({
title: '定价与底价门禁',
kicker: 'A4 · DMN 两表',
tone: 'ok',
body:
kvCard('定价结论', [
['拟报单价', 'quoted_price'],
['目标毛利', 'margin'],
['相对基准偏离', 'deviation'],
['门禁处置', 'disposition', 'tag'],
]) +
noteCard(
'两张决策表各管一段',
'<b>利润模型 P-01</b>:客户等级 × 物料类型 × 数量档 → 目标毛利区间。' +
'<b>底价门禁 P-02</b>:偏离底价 ±20% 即止 —— 超限的不是「拦下」,而是转人裁断(回调还是注明理由)。' +
'两表都是确定性求值,同输入必同结论;命中的规则行随出参留痕,事后可逐条复盘。',
),
});
/* ==================== 局面二 · 报价草案清单(拍板 / 放行 / 归档)==================== */
export const PRICING_RELEASE = viewShell({
title: '报价草案 · 待放行',
kicker: 'A4 · 成果预览',
tone: 'case',
export const PRICING_ROSTER = doc({
badge: 'file-check',
kicker: '统一报价 · A4 成本定价 · 交付成果',
title: '报价草案',
titleRef: 'rfq_ref',
state:
'<span class="jc-rp-wait">草案 <b data-slot="quote_pricing_draft_rows"></b> 条 · 待拍板 <b data-slot="quote_pricing_draft_disposition_block"></b></span>',
body:
tableCard(
'本单报价草案',
'mine:quote_pricing.draft',
[
['型号', 'pn', 'mono'],
['基准价', 'last_price'],
['拟报价', 'quoted_price'],
['毛利', 'margin'],
['偏离', 'deviation'],
['门禁', 'disposition'],
],
'逐条列出,便于放行前逐条核对。',
) +
noteCard(
'放行之后',
'放行通过 → 草稿合并入正式版 → 进 <b>AP 定价审批</b>(卡到 PM 工位)。' +
'审批驳回按声明走向<b>回 A4 重开新实例</b> —— 新任务 ID、留痕关联,不复活旧任务。',
),
part('定价要素', '成本定价 · 能算的归判定表,超限的归人') +
FOUR +
part('底价门禁分布', '直接放行 + 附理由 + 拦截 = 总条数') +
DIST +
`<div class="jc-derive">${ico('info', 14)}
<span>三段条数由基座逐行现数,不是谁报上来的:红段一条不剩,这份草案才走得到放行门。
压在拦截档的条目只有三条出路 —— 回调到底价线、由负责人拍板破线并留理由、
或者本轮不报。</span>
</div>` +
part('报价草案', '<b data-slot="line_total"></b> 条 · 每条都带来路') +
GAP_BAR +
editGate('quote_pricing.draft', '报价草案') +
DRAFT_TABLE +
`<div class="jc-drop">${ico('info', 13)}
<span>每一行摆的是这个价的<b>来路</b>:成本基准从哪来、毛利档是哪一档、底价线在哪、
偏离多少、门禁怎么判。签字前请重点看两处:<b>门禁列</b>里非绿的条目,
与<b>备注列</b>里的让价理由 —— 没有理由的让价,到了审批那一关同样过不去。</span>
</div>` +
part('放行后即执行的落地动作', '门前 → 门后') +
`<div class="jc-acts">
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>草稿层合并进正式版</b>&#12288;<span>本任务写下的报价草案与定价结论一并落账;
你就地改过的那几个价随之落定。</span></div>
</div>
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>草案此刻才对外发出</b>&#12288;<span>整份 <b data-slot="quote_pricing_draft_rows"></b> 条
(每条带拟报单价与门禁处置)交出 —— 放行之前,世界上什么都没有发生。</span></div>
</div>
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>转入定价审批</b>&#12288;<span>本活动只有一个出口 —— 报价草案是审批环节的直接输入,
已进对方的任务清单。</span></div>
</div>
</div>`,
foot: sign('BD 定价 · AI 按判定表算价并留痕 —— 要改哪一格,在这张单的草案表上就地改', '统一报价 · A4 成本定价 → 交审批'),
});
@@ -0,0 +1,313 @@
/**
* A3 寻源询价的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。
* 案例活动没有 `run`,自测不跑回合;测的是清单本身与三处对齐
* (判据谓词 / 出口载荷 / 剧本声明)。
*/
import { describe, expect, it } from 'vitest';
import { activityManifestSchema } from '@contracts';
import { evaluateCondition } from '@sfeel';
import { createMockHost } from '@sdk';
import { manifest } from '../src/manifest.ts';
import activity from '../src/index.ts';
describe('清单本身合规', () => {
it('过 @contracts 的 schema', () => {
expect(() => activityManifestSchema.parse(manifest)).not.toThrow();
});
it('监听时限已配 —— 无悬空监听(AM 4.4)', () => {
expect(manifest.declarations.listeners.every((l) => /^P/.test(l.deadline))).toBe(true);
});
it('L1/L2 判据都给了可机检的谓词 —— 验收等级不得谎报', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef, c.key).toBeDefined();
}
});
it('禁区短而硬,授权边界给了调人预算', () => {
expect(manifest.contract.prohibitions.length).toBeGreaterThan(0);
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(0);
});
it('每件必交付物都有台账地址', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(d.ledger, d.key).toBeDefined();
}
});
/*
* MVP 口径:A3 **没有判定表**。上一版的 STRATEGY_DMN 声明着三个入参而台账没有承载、
* 剧本零调用。取价规则(A 取历史价 / B 问货源价)写死在剧本。
*/
it('没有声明无求值器的判定表 —— 取价规则写死在剧本', () => {
expect(manifest.assets.businessModel.dmn.tables).toEqual([]);
const normalize = manifest.assets.businessModel.steps?.find((s) => s.item === 'S1_normalize');
expect(normalize?.guide).toContain('HIST');
});
/*
* **底价是必填** —— 这是 A3 的门面判据:件上的缺价红带、执行卡的提交闸、
* 放行门 L1 三处同源(都数基座的 `_missing`)。声明丢了 required,三处一起失明。
*/
it('底价标了 required —— 红带 / 提交闸 / 放行门三处同源的那个数才存在', () => {
const quoteList = manifest.declarations.ledger.find((d) => d.object === 'quote_list');
expect(quoteList?.fields.find((f) => f.name === 'base_price')?.required).toBe(true);
});
});
describe('案例模型 —— 双表示同版本', () => {
const model = manifest.assets.businessModel;
const cmmn = parseCmmnLite(model.cmmnXml);
it('CMMN 与说明书同在,不允许分叉', () => {
expect(model.cmmnXml).toContain('<casePlanModel');
expect(model.narrative.length).toBeGreaterThan(400);
});
it('计划项照状态机一步一项:归一预填 + 补价 + 汇稿 + 放行,不多建一件', () => {
expect(items(model.cmmnXml)).toEqual(['S1_normalize', 'S2_fill', 'S3_compile', 'T_release']);
});
it('两处人的门都标了人工激活:PRM 补价 · 清单放行', () => {
for (const id of ['S2_fill', 'T_release']) {
const block = model.cmmnXml.slice(model.cmmnXml.indexOf(`id="${id}"`));
expect(block.slice(0, block.indexOf('</humanTask>')), `${id} 少了 manualActivationRule`).toContain(
'<manualActivationRule',
);
}
});
it('不用阶段 / 里程碑 / 裁量项', () => {
for (const tag of ['<stage', '<milestone', '<planningTable', '<discretionaryItem']) {
expect(model.cmmnXml, `不该出现 ${tag}`).not.toContain(tag);
}
});
it('常来回的项标了可重复 —— 归一会随剔行改数重入', () => {
expect(cmmn.repetition).toContain('S1_normalize');
});
it('放行门哨兵带 ifPart:每行有价 + 价源守恒 + 定盘记过,全读基座数', () => {
const condition = sentryIf(model.cmmnXml, 's_ready');
expect(condition).toContain('quote_sourcing_quote_list_missing = 0');
expect(condition).toContain('quote_sourcing_quote_list_price_basis_hist + quote_sourcing_quote_list_price_basis_sourced');
const done = {
route_decided_total: 1,
quote_sourcing_quote_list_rows: 3,
quote_sourcing_quote_list_missing: 0,
quote_sourcing_quote_list_price_basis_hist: 1,
quote_sourcing_quote_list_price_basis_sourced: 2,
};
expect(safeEval(condition, done)).toBe(true);
// 还有一行缺底价 → 门不开
expect(safeEval(condition, { ...done, quote_sourcing_quote_list_missing: 1 })).toBe(false);
// 价源没记齐(守恒不成立)→ 门不开
expect(safeEval(condition, { ...done, quote_sourcing_quote_list_price_basis_sourced: 1 })).toBe(false);
});
it('每个计划项都有剧本', () => {
const ids = items(model.cmmnXml);
expect((model.steps ?? []).map((s) => s.item)).toEqual(ids);
for (const step of model.steps ?? []) {
expect(step.guide.length, `${step.item} 的做法`).toBeGreaterThan(80);
expect(step.intent.length, `${step.item} 的意图`).toBeGreaterThan(6);
}
});
it('说明书不点名计划项', () => {
const named = [...model.narrative.matchAll(/\b(?:S\d+_[A-Za-z]+|T_[a-z]+)\b/g)].map((m) => m[0]);
const ids = new Set(items(model.cmmnXml));
for (const name of named) {
expect(ids.has(name), `说明书提到的 ${name} 不在案例模型里`).toBe(true);
}
});
it('说明书只讲整单的事:成品、怎么说话、怎么收口', () => {
for (const topic of ['开案', '要说话', '一拍连做', '结案']) {
expect(model.narrative, topic).toContain(topic);
}
});
});
describe('声明与契约对得上', () => {
const columns = new Set(
manifest.declarations.ledger.flatMap((d) => [`${d.domain}.${d.object}`, ...d.fields.map((f) => f.name)]),
);
it('必交付物的台账地址都声明过', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(columns.has(d.ledger ?? ''), `${d.key} → ${d.ledger}`).toBe(true);
}
});
it('editableAtRelease 的列都在成果表的声明里', () => {
const quoteList = manifest.declarations.ledger.find((d) => d.object === 'quote_list');
const names = new Set((quoteList?.fields ?? []).map((f) => f.name));
for (const d of manifest.contract.requiredDeliverables) {
for (const col of d.editableAtRelease ?? []) {
expect(names.has(col), `editableAtRelease 里的 ${col}`).toBe(true);
}
}
});
const rowReadings = new Set([
...manifest.declarations.ledger.map((d) => `${d.domain}_${d.object}_rows`),
...manifest.declarations.ledger
.filter((d) => d.fields.some((f) => f.required === true))
.map((d) => `${d.domain}_${d.object}_missing`),
...manifest.declarations.ledger.flatMap((d) =>
d.fields.flatMap((f) =>
(f.tally ?? []).map((v) => `${d.domain}_${d.object}_${f.name}_${v.toLowerCase().replace(/[^a-z0-9_]/g, '')}`),
),
),
]);
it('L1/L2 谓词读的名字都有承载(台账列或基座读数)', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level !== 'L1' && c.level !== 'L2') continue;
for (const identifier of (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? []) {
if (['and', 'or', 'not', 'true', 'false', 'null', 'in', 'between'].includes(identifier)) continue;
if (rowReadings.has(identifier)) continue;
expect(columns.has(identifier), `${c.key} 的谓词引用了 ${identifier}`).toBe(true);
}
}
});
it('出口载荷的标量字段都有台账列承载', () => {
const snake = (name: string) => name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
for (const port of manifest.contract.output.mandatory) {
const properties = (port.payload as { properties?: Record<string, { type?: string }> }).properties ?? {};
for (const [field, spec] of Object.entries(properties)) {
if (spec.type === 'array') continue;
expect(columns.has(snake(field)), `${port.name}.${field} → ${snake(field)}`).toBe(true);
}
}
});
it('技能引用的工具都在白名单声明内 —— 载方法不载权力', () => {
const declared = new Set(manifest.declarations.tools.map((t) => t.name));
for (const skill of manifest.assets.skills) {
for (const fragment of skill.crystallized) {
expect(declared.has(fragment.tool), `${skill.id} 用了 ${fragment.tool}`).toBe(true);
}
}
});
it('L3 判据的整句出现在放行剧本里 —— judge 才对得上名', () => {
const l3 = manifest.contract.acceptanceCriteria.find((c) => c.level === 'L3');
const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
expect(l3).toBeDefined();
expect(release?.guide, 'T_release 的 guide 要整句抄 L3 判据名').toContain(l3?.label ?? '');
});
});
describe('报告模版 —— 活动持有,容器无脚本执行权', () => {
it('两个局面各一张:询价工作单(干活)与目标报价清单(收口),同一局面在办与归档同一张', () => {
expect(manifest.assets.viewTemplates.map((t) => t.name)).toEqual(['sourcing-worksheet', 'sourcing-roster']);
});
/*
* 执行阶段的件必须是**询价工作台**,不是验收单(A2 同款口径,2026-08-02)。
* 工作单四要件:逐颗询价档案(题面含取价对象与客户约束)、按场景圈渠道的规则、
* 价格验证的尺、填回工作表 —— 素材出处 MVP 示例工作流 §1.7,只作文字参考。
*/
it('询价工作单是干活的件:档案卡 + 场景圈渠道 + 验价的尺 + 时限', () => {
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'sourcing-worksheet')?.html ?? '';
expect(sheet).toContain('jc-selcard');
expect(sheet).toContain('data-slot="remark"');
expect(sheet).toContain('取价对象');
for (const scene of ['BOM 早期', 'BOM 后期', '现货', '样品']) expect(sheet).toContain(scene);
expect(sheet).toContain('无库存转单商不可选');
expect(sheet).toContain('历史最低价降两成');
});
it('模版里没有脚本、没有事件处理器、没有外链 —— 样式自带', () => {
for (const template of manifest.assets.viewTemplates) {
expect(template.html, template.name).not.toMatch(/<script|\son[a-z]+=|https?:\/\//i);
expect(template.html, template.name).toContain('<style>');
}
});
/*
* A3 只有一个出口(E7 无条件边),**没有去向要判** —— 件上不摆判定表,
* 也不许残留 `case:route*` 的空壳(没有选择组它永远是空的,一屏灰行像判定没跑)。
*/
it('单出口活动:没有选择组,件上也不摆去向判定', () => {
expect(manifest.contract.output.selective).toEqual([]);
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'sourcing-roster')?.html ?? '';
expect(roster).not.toContain('case:routeKey');
});
it('缺价红带与提交闸同源 —— 件上摆的是基座的 `_missing` 与主键名单', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'sourcing-roster')?.html ?? '';
expect(roster).toContain('quote_sourcing_quote_list_missing');
expect(roster).toContain('quote_sourcing_quote_list_missing_keys');
});
it('编辑面 = PRM 的价格五列 + 行的钥匙 pn —— 保存按格里的主键认行(A2 走查逮过同款)', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'sourcing-roster')?.html ?? '';
const cols = [...roster.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]);
expect(new Set(cols)).toEqual(new Set(['pn', 'supplier', 'base_price', 'moq', 'source_lead_time', 'note']));
});
it('价源分布读的是基座按值现数的两段 —— 守恒等式的可视形', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'sourcing-roster')?.html ?? '';
expect(roster).toContain('quote_sourcing_quote_list_price_basis_hist');
expect(roster).toContain('quote_sourcing_quote_list_price_basis_sourced');
});
it('宿主拒推含脚本的模版', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
});
});
const CONTRACT = {
activityId: 'quote-sourcing',
version: '1.0.0',
data: { rfqRef: 'RFQ MD14.1', rows: [] },
message: undefined,
outputPorts: { mandatory: ['quotation'], selective: [] },
prohibitions: [],
discretion: [],
} as const;
describe('代码入口 —— 案例活动没有 run,不是残包', () => {
it('默认导出是一个有名字的活动定义', () => {
expect(activity.name).toBe('quote-sourcing');
});
it('mock 宿主如实报「没有 run」,不静默返回一个假的 outcome', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.run(activity)).rejects.toThrow(/没有 run/);
});
});
function items(xml: string): string[] {
return [...xml.matchAll(/<(?:humanTask|decisionTask|processTask|caseTask) id="([^"]+)"/g)].map((m) => m[1] ?? '');
}
function parseCmmnLite(xml: string): { manualActivation: string[]; repetition: string[] } {
const manualActivation: string[] = [];
const repetition: string[] = [];
for (const m of xml.matchAll(/<humanTask id="([^"]+)"[\s\S]*?<\/humanTask>/g)) {
if (m[0].includes('manualActivationRule')) manualActivation.push(m[1] ?? '');
if (m[0].includes('repetitionRule')) repetition.push(m[1] ?? '');
}
return { manualActivation, repetition };
}
function sentryIf(xml: string, sentryId: string): string {
const block = xml.slice(xml.indexOf(`<sentry id="${sentryId}"`));
const raw = /<condition>([\s\S]*?)<\/condition>/.exec(block.slice(0, block.indexOf('</sentry>')))?.[1] ?? '';
return raw.replaceAll('&gt;', '>').replaceAll('&lt;', '<').replaceAll('&amp;', '&');
}
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
@@ -0,0 +1,223 @@
/**
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(A1 的账)。
*/
import { describe, expect, it } from 'vitest';
import { evaluateCondition } from '@sfeel';
import { CMMN_XML, SOURCING_PHASES, SOURCING_STEPS } from '../src/case-model.ts';
import { manifest } from '../src/manifest.ts';
const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map(
(m) => m[1] ?? '',
);
describe('剧本与案例模型对得上 —— 对不上的条目等于没写', () => {
it('每一条剧本都指向一个真实存在的计划项', () => {
for (const step of SOURCING_STEPS) {
expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
}
});
it('图上每一步都有剧本', () => {
for (const id of ITEM_IDS) {
expect(SOURCING_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
}
});
it('清单里挂的就是这份剧本(不是另抄一份)', () => {
expect(manifest.assets.businessModel.steps).toBe(SOURCING_STEPS);
});
});
describe('doneWhen 是「成果已存在」的判据,不是「该不该做」的判据', () => {
/** 开案那一刻的三种局面:选型转来 / 受理直达 / 单飞 —— 台账一行没有 */
const FRESH_ALT: Record<string, unknown> = {
rfqRef: 'RFQ MD14.1',
rows: [{ pn: 'BBPY00201209601Y00', type_tag: 'B', alt_pn: 'PBY201209T-601Y-N' }],
};
const FRESH_INTAKE: Record<string, unknown> = {
rfqRef: 'RFQ-2026-0555',
records: [{ pn: 'PESD2ETH-XR', type_tag: 'B' }],
};
const FRESH_SOLO: Record<string, unknown> = { subject: '两颗现货料问底价', body: 'SS8050 20K……' };
it('**开局一步都不该被自动收掉** —— 三种开局都要 false', () => {
for (const fresh of [FRESH_ALT, FRESH_INTAKE, FRESH_SOLO]) {
for (const step of SOURCING_STEPS) {
if (step.doneWhen === undefined) continue;
expect(
safeEval(step.doneWhen, fresh),
`${step.item} 的 doneWhen「${step.doneWhen}」开局就为真`,
).toBe(false);
}
}
});
/* ── ① 归一预填 ─────────────────────────────────────────────── */
it('整份落表、行数对上、预填走完 → 归一收得掉(没有 A 类时预填数是写过的 0)', () => {
expect(
safeEval(doneOf('S1_normalize'), {
quote_line_total: 3,
quote_sourcing_quote_list_rows: 3,
hist_priced_total: 1,
}),
).toBe(true);
expect(
safeEval(doneOf('S1_normalize'), {
quote_line_total: 2,
quote_sourcing_quote_list_rows: 2,
hist_priced_total: 0,
}),
).toBe(true);
});
it('**预填那一轮没走完 → 归一收不掉** —— hist_priced_total 没写过,未绑定比较为假', () => {
expect(
safeEval(doneOf('S1_normalize'), { quote_line_total: 3, quote_sourcing_quote_list_rows: 3 }),
).toBe(false);
});
it('少拆多写都收不掉 —— 自报条数与基座行数对不上', () => {
expect(
safeEval(doneOf('S1_normalize'), {
quote_line_total: 3,
quote_sourcing_quote_list_rows: 2,
hist_priced_total: 0,
}),
).toBe(false);
});
/* ── ② 交 PRM 补价:人环 ────────────────────────────────────── */
it('**PRM 那一步不给 doneWhen** —— 引擎不许替人交卷', () => {
expect(SOURCING_STEPS.find((s) => s.item === 'S2_fill')?.doneWhen).toBeUndefined();
});
it('补价这一步摆的是询价工作单(不是收口清单):卡上零输入、闸看必填、答复处理记 sourced_total', () => {
const fill = SOURCING_STEPS.find((s) => s.item === 'S2_fill');
expect(fill).toBeDefined();
expect(fill?.view).toBe('sourcing-worksheet');
/*
* **执行卡零输入**(2026-08-02 用户定:填写全去工作区)——
* 供应商与底价在工作单那份件上就地填,卡上不绑表;
* 必填(底价)由提交闸看住,闸回落到必交付物的台账声明。
* 从前这儿断言卡里带 `"ledger":"quote_sourcing.quote_list"` 的 blocks。
*/
expect(fill?.guide).toContain('卡上不摆表、不摆格子');
expect(fill?.guide).not.toContain('"blocks"');
expect(fill?.guide).toContain('提交闸');
expect(fill?.guide).toContain('sourced_total');
});
/* ── ③ 汇稿定盘:收步判据与验收判据同源 ─────────────────────── */
const compiled = (over: Record<string, unknown>) => ({
quote_sourcing_quote_list_rows: 3,
quote_sourcing_quote_list_missing: 0,
quote_sourcing_quote_list_price_basis_hist: 1,
quote_sourcing_quote_list_price_basis_sourced: 2,
route_decided_total: 1,
...over,
});
it('每行有价 + 价源守恒 + 定盘记过 → 汇稿收得掉', () => {
expect(safeEval(doneOf('S3_compile'), compiled({}))).toBe(true);
});
it('**还有一行缺底价 → 收不掉** —— 与提交闸、放行门读同一个数', () => {
expect(safeEval(doneOf('S3_compile'), compiled({ quote_sourcing_quote_list_missing: 1 }))).toBe(false);
});
it('**价源没记齐(守恒不成立)→ 收不掉** —— 松出来的那一截就是返工', () => {
expect(safeEval(doneOf('S3_compile'), compiled({ quote_sourcing_quote_list_price_basis_sourced: 1 }))).toBe(false);
});
it('没记过定盘数 → 收不掉 —— 未绑定的名字比大小为假', () => {
const scope: Record<string, unknown> = compiled({});
delete scope['route_decided_total'];
expect(safeEval(doneOf('S3_compile'), scope)).toBe(false);
});
it('**放行那一项不给 doneWhen** —— 终局唯放行', () => {
expect(SOURCING_STEPS.find((s) => s.item === 'T_release')?.doneWhen).toBeUndefined();
});
it('归一那一步声明了 reads: rows 与 records —— 上游载荷置前不截断', () => {
const reads = SOURCING_STEPS.find((s) => s.item === 'S1_normalize')?.reads ?? [];
expect(reads).toContain('rows');
expect(reads).toContain('records');
});
});
describe('剧本声明的东西活动真有', () => {
const declaredLedger = manifest.declarations.ledger.map((l) => `${l.domain}.${l.object}`);
const declaredTools = manifest.declarations.tools.map((t) => t.name);
it('每一步要写的台账都在清单的台账声明里', () => {
for (const step of SOURCING_STEPS) {
for (const address of step.writes) {
expect(declaredLedger, `${step.item} 要写 ${address}`).toContain(address);
}
}
});
it('每一步要调的工具都在清单的工具声明里', () => {
for (const step of SOURCING_STEPS) {
for (const tool of step.tools) {
expect(declaredTools, `${step.item} 要调 ${tool}`).toContain(tool);
}
}
});
it('每一步都摆同一份件(一单一模版贯穿始终),且模版真在包里', () => {
const names = new Set(manifest.assets.viewTemplates.map((t) => t.name));
for (const step of SOURCING_STEPS) {
expect(step.view, `${step.item} 没定画面`).toBeDefined();
expect(names.has(step.view ?? ''), `${step.item} 的 view「${step.view}」不在模版名单里`).toBe(true);
}
});
it('每一步都说得出它交出什么', () => {
for (const step of SOURCING_STEPS) {
expect(step.intent.trim(), `${step.item} 没写 intent`).not.toBe('');
expect(step.guide.trim().length, `${step.item} 的 guide 太短`).toBeGreaterThan(20);
}
});
});
function doneOf(item: string): string {
const found = SOURCING_STEPS.find((s) => s.item === item)?.doneWhen;
if (found === undefined) throw new Error(`${item} 没有 doneWhen`);
return found;
}
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
describe('阶段刻度:每一项都有格可待', () => {
const inPhase = new Set(SOURCING_PHASES.flatMap((p) => p.items));
it('**除放行外每一项恰好属于一个阶段**', () => {
const planned = SOURCING_STEPS.map((s) => s.item).filter((id) => id !== 'T_release');
expect(planned.filter((id) => !inPhase.has(id))).toEqual([]);
});
it('一项不进两个阶段', () => {
const all = SOURCING_PHASES.flatMap((p) => p.items);
expect(all.length).toBe(new Set(all).size);
});
it('**放行不在名单里** —— 收口那一格由基座统一加', () => {
expect(inPhase.has('T_release')).toBe(false);
});
it('阶段里不许出现案例模型没有的项', () => {
const ids = new Set(SOURCING_STEPS.map((s) => s.item));
expect([...inPhase].filter((id) => !ids.has(id))).toEqual([]);
});
});
+257 -95
View File
@@ -1,120 +1,282 @@
/**
* A3 渠道寻源的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
* 「说明书是 XML 的叙事投影,迭代时一起放行」(AM 3.1)。
* A3 寻源询价的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
*
* 业务出处:`docs/MVP示例工作流.html` §1.7,按 **MVP 简化口径**建模
* (2026-08-02 用户定:A3 与 A2 同为复杂活动,MVP 期间不展开大规模业务建模):
*
* 上游(A2 的询价受理清单,或 A1 直达的 B 类受理记录)进来 → 归一成
* **目标报价清单**底表,A 类行按库内历史价预填基准 → **一张执行单**让采购(PRM)
* 在系统外完成询价、把 B 类行的货源价格填回来 → 汇稿定盘 → 放行,
* 整份目标报价清单交给成本定价。
*
* SOP 里接需核史 / 选渠定策 / 发询比价那几段的实质工作(圈渠道、发函、催回价、
* 验价)在 PRM 系统外;四种场景(BOM早期/后期/现货/样品)的尺度之分是他的判断,
* 不是系统的步。系统的本分:归一、预填、组卡、齐检、留痕。
*
* 上一版(@2.0.0)只有「派单 + 放行」两项,入参必填 `pn + scenario`(单料形状,
* 与上游发的整份清单对不上),两条 L2 判据读活动自报数。这一版整个置换。
*/
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
<case id="sourcing">
<!--
案卷项的 id **就是任务视图里的键名**。流内起单时上游载荷(rows / records)
与本活动台账列都会摊平到任务视图顶层,照它们命名。
-->
<caseFileModel>
<caseFileItem id="inquiry" name="询价请求"/>
<caseFileItem id="supplier_quote" name="供应商回价单"/>
<caseFileItem id="compare_table" name="寻源比价表"/>
<caseFileItem id="rows" name="上游清单行(A2 载荷,流内开案即有)"/>
<caseFileItem id="records" name="上游受理记录(A1 直达时有)"/>
<caseFileItem id="quote_line_total" name="清单条目数(自报)"/>
<caseFileItem id="quote_sourcing_quote_list_rows" name="清单行数(基座现数)"/>
<caseFileItem id="pending_total" name="待补价条数"/>
<caseFileItem id="hist_priced_total" name="A 类已按历史价预填条数"/>
<caseFileItem id="sourced_total" name="PRM 已补价条数"/>
<caseFileItem id="quote_sourcing_quote_list_missing" name="缺基准价的行数(基座现数)"/>
<caseFileItem id="route_decided_total" name="已定盘"/>
</caseFileModel>
<casePlanModel id="plan" name="渠道寻源" autoComplete="true">
<sentry id="s_inquiry">
<onPart id="op_inquiry" caseFileItemRef="inquiry"><standardEvent>create</standardEvent></onPart>
<casePlanModel id="plan" name="寻源询价" autoComplete="true">
<!--
① 归一并预填完了 —— 交 PRM 那一步的入场前提。
行数两边对上(自报 = 基座现数),且 A 类预填那一轮走完
(hist_priced_total 是预填后显式写下的数;没有 A 类就是 0,
「大于等于 0」对「写过的 0」为真、对「没写过」为假)。
-->
<sentry id="s_normalized">
<onPart id="op_nm" caseFileItemRef="hist_priced_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_nm"><condition>quote_line_total &gt; 0 and quote_sourcing_quote_list_rows = quote_line_total and hist_priced_total &gt;= 0</condition></ifPart>
</sentry>
<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>
<!--
② PRM 的答复处理过了 —— 汇稿那一步的入场前提。
sourced_total 是「人交回执行单之后」由 AI 清点写下的数(B 类行补上价的条数)——
只在人环走完才存在,它就是「经过人了」的凭据。
-->
<sentry id="s_sourced">
<onPart id="op_sc" caseFileItemRef="sourced_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_sc"><condition>sourced_total &gt;= 0</condition></ifPart>
</sentry>
<!--
── 放行门的入场条件:**认「办完了没有」,且读基座数**────────────────
· 每行都有基准价(quote_sourcing_quote_list_missing 由基座按 required 现数,
与执行卡的提交闸、验收 L1 同一个数同一套算法);
· 价源守恒:历史价 + 货源价 = 行数(两边都是基座按值现数);
· 定盘过了(route_decided_total 是汇稿那一步显式写下的记账数)。
-->
<sentry id="s_ready">
<onPart id="op_r" caseFileItemRef="compare_table"><standardEvent>create</standardEvent></onPart>
<onPart id="op_rd" caseFileItemRef="route_decided_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_rd"><condition>route_decided_total &gt; 0 and quote_sourcing_quote_list_rows &gt; 0 and quote_sourcing_quote_list_missing = 0 and quote_sourcing_quote_list_price_basis_hist + quote_sourcing_quote_list_price_basis_sourced = quote_sourcing_quote_list_rows</condition></ifPart>
</sentry>
<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>
<!-- ① 归一目标报价清单 + A 类按历史价预填基准 -->
<humanTask id="S1_normalize" name="归一目标报价清单:整份落表,A 类按历史价预填基准">
<itemControl><repetitionRule id="r_s1"/></itemControl>
</humanTask>
<timerEventListener id="TL_deadline" name="时限预警">
<timerExpression>P4D</timerExpression>
</timerEventListener>
<timerEventListener id="TL_quote_expiry" name="回价到期">
<timerExpression>P7D</timerExpression>
</timerEventListener>
<!--
② 补货源价格交 PRM —— **一张执行单**(询价的实质工作在系统外)。
manualActivation + 不给 doneWhen(发卡触达真人,引擎不替人按)。
-->
<humanTask id="S2_fill" name="补货源价格交PRM:执行单上把 B 类行的供应商与底价填回来">
<entryCriterion id="ec_s2" sentryRef="s_normalized"/>
<itemControl><manualActivationRule id="m_s2"/></itemControl>
</humanTask>
<!-- ③ 汇稿定盘:价源归档、齐检、结论 -->
<humanTask id="S3_compile" name="汇稿定盘:价源归档,每行齐检,生成目标报价清单">
<entryCriterion id="ec_s3" sentryRef="s_sourced"/>
</humanTask>
<!-- 清单放行:终局唯放行 —— 触达世界,必须人工激活 -->
<humanTask id="T_release" name="目标报价清单验收放行">
<entryCriterion id="ec_rel" sentryRef="s_ready"/>
<itemControl><manualActivationRule id="m_rl"/></itemControl>
</humanTask>
</casePlanModel>
</case>
</definitions>`;
export const NARRATIVE = `§0 开案:本案例覆盖四种询价场景 —— BOM早期、BOM后期、现货询价、样品询价。
场景不同,供应商选择尺度与价格验证规则各异,但都遵循同一主线:
PM 提交询价请求 → 查历史报价判定可否直采 →(须询价时)按场景圈渠道 → 发询验证 → 填入寻源比价表反馈。
/**
* 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本,两处不重复写同一件事)。
*/
export const NARRATIVE = `§0 开案:**一份清单要补齐每一行的价格基准**。来路三种,形状都收(宽进):
· **选型转来**:任务数据里有 \`rows\` 数组 —— 替代选型放行的全部落地行混在一起
(带 alt_pn / 现标的是清单行,带各种总数的是结论行,按 PN 归并自辨);
· **受理直达**:有 \`records\` 数组 —— 受理放行的落地行(受理记录行 + 分类表行 + 结论行);
· **单飞**:只有一段需求文字,列了几颗料要问价。
S1 接需核史:系统带出在供供应商与历史报价。先核历史,按场景规则逐 PN 判定是否可直接采用 ——
可直采则跳过询价直接进入反馈交付;须新询价则进入选渠阶段。
· 查历史报价:未达 3 次询价但有效期已过的,标记须重询。
· 判定采用策略:BOM早期有效期内直采、过期浮动≤10%直采、10-20%取均价、>20%重询;
BOM后期历史最低(≥3次)直采,否则降 20% 谈;现货/样品一律重询。
**这个活动是复杂活动的 MVP 简化形**:询价的实质工作(圈渠道、发函、催回价、验价)
由采购(PRM)在**系统外**完成;四种场景(BOM早期/后期/现货/样品)的尺度是他的判断。
系统只负责:归一清单、**A 类行按库内历史价预填基准**、把执行单组好交给他、
收回后齐检定盘。
S2 选渠定策:BOM早期/样品渠道可放宽;BOM后期按主→备选→贸易商顺序圈 2~3 家,贸易商尽量不用;
现货仅选有库存且可提供仓库实拍的供应商。渠道资质核实是「看情况」的裁量项。
**成品只有一样**:一份**目标报价清单** —— 整份行都在、**每一行都有基准价**
(A 类取历史价,B 类取 PRM 问回来的货源价),价源逐行可溯。它是成本定价的直接输入。
S3 发询比价:同一料所有渠道一次发齐;PN/数量/目标价/回价时限写死在询价里。
回价逐单登记,一一对应不可缺项。价格验证因场景而异。回价异常或未应答则追价补询(裁量项)。
**这一单要经过人两次,不多不少**:PRM 在执行单上补货源价(那张单绑着整份清单,
必填空着按不下提交 —— 基座的闸看着,他改的格子也直接落账)、放行时 PRM 签字。
S4 反馈交付:确认报价/LT/SPQ/有效期逐项填入寻源比价表,供 PM 直接查阅。
现货/样品场景多一步下单与跟踪(裁量项)。
【一路上要说话】负责人正看着这一单。**每一拍都带一句 say** —— 说业务的话:
哪几颗料等着问价、历史价预填了几条、这单接下来到谁手上;不说系统词。
到了要找人的那一拍,先说清这一轮办完了什么、为什么现在需要他。
全程随时可做:知识入库 —— 回价与成交沉淀历史价格情报,渠道表现回写供应商渠道目录。
【怎么把一项干完 · 一拍连做】write 和 tool 都不会自动收步 —— 做完用 finish 宣告。
动作排进同一拍的 actions 串一口气给;只有调工具(出参下一拍才看得见)与
发卡 / 提交(挂起)必须停拍。**这一单常路 6-8 拍就该收口**。
§Z 结案:必做的事都办完、没有进行中的事,自动算结案(CMMN AutoComplete)。
寻源比价表交付 PM 即结案 —— 每询一轮,行情底账厚一层。`;
§Z 结案:PRM 在验收单上签字,系统把草稿合并成正式清单并发出 quotation 口的载荷 ——
整份目标报价清单交给成本定价。`;
/**
* 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。
*/
export const SOURCING_STEPS = [
{
item: 'S1_normalize',
intent: '上游清单整份归一成目标报价清单;A 类行按库内历史价预填基准',
writes: ['quote_sourcing.quote_list', 'quote_sourcing.verdict'],
/* 上游载荷数组不声明就会被整表段的截断刀砍掉(按字符截,砍掉的恰是要归一的行) */
reads: ['rows', 'records'],
/* 归一预填与 PRM 补价期间摆**询价工作单**;汇稿定盘后才换收口那张清单 */
view: 'sourcing-worksheet',
tools: ['crm.history-price-read'],
/*
* 归一 + 预填两轮都走完才算办完:行数两边对上,且 hist_priced_total 写过
* (没有 A 类就是 0;S-FEEL 未绑定的名字参与比较为假,所以「没写过」进不来)。
*/
doneWhen: 'quote_line_total > 0 and quote_sourcing_quote_list_rows = quote_line_total and hist_priced_total >= 0',
guide: `第一件事:把这一单的**目标报价清单**落出来,并把 A 类行的基准价预填好。
【料从哪儿来】
· 选型转来(有 \`rows\`):清单行带 pn / alt_pn / type_tag(现标)—— 照抄;
带 alt_line_total 这类总数的是结论行,不是料,别落进清单。
· 受理直达(有 \`records\`):受理记录行(带 eau/brand)为准,type_tag 照抄;
分类表行与结论行不落。**按 pn 归并**,一颗料一行。
· 单飞:照正文归一,列了几颗落几行;没说类别的按 B(要问价的料本来就是 B)。
【第一拍:只做两件事 —— 落清单行 + 串尾发一次查价】
① 写 \`quote_sourcing.quote_list\`,一颗料一行(rows 收数组):
pn(**一个字符不许改**)、customer_pn、category、brand、eau、target_price、lead_time、
**remark(客户备注:认证要求、预授权、急料标注 —— 问价口径的第一手依据,一个字别丢)**、
alt_pn(上游有就承继)、type_tag(照上游现标;单飞按 B)。
supplier / base_price / moq / source_lead_time / note **全部留空** —— 价还没来。
② 串尾调 \`crm.history-price-read\`:pns 收数组,把 **A 类行的取价对象**一次传完 ——
**取价对象 = alt_pn 有值取 alt_pn,否则取 pn**(选型转来的 A 行,行情稳的是替代料)。
**这一拍不写 \`quote_sourcing.verdict\`,一列都不写** —— 尤其 hist_priced_total:
它是「预填走完了」的凭据,提前写等于把闸提前打开,引擎会在下一拍开头当场收掉这一步,
**预填从此不会发生**(实测:A 类行底价空着、执行卡照发,而交代里说着「已预填」——
台账与嘴对不上)。没有 A 类行也一样:查价那一步省掉,直接进第二拍。
【第二拍:预填 A 行基准 + 记账 + 收步,一拍连做】
① 工具出参逐行照抄:hit=true 的 A 行写 base_price = lastPrice、price_basis 写 **HIST**
(大写,一个字母不许差 —— 基座按值现数,写别的数不出来)、note 记一句
「库内历史价 <N> 天」;hit=false 的 A 行**别编价** —— base_price 留空,
当 B 类待询处理(pending_total 把它数上;type_tag 不动,那是上游的标)。
② 这时才写 \`quote_sourcing.verdict\`(一次写全):rfq_ref(入参有就原样抄,
没有从主题认,认不出才造「SRC-<型号或类型>」,从头用到尾。
**只从本单来件取材,不许从台账里已有的别单单号里挑** —— 正式表里躺着的
MD14.1 之类是别人的单,借它的号等于把两单的行混进同一个案卷)、
customer_code / customer_name(顶层有就抄)、**quote_line_total** = 清单行数、
**pending_total** = 底价还空着的条数、**hist_priced_total** = 刚预填的条数(0 也要写)。
③ finish 这一项。`,
},
{
item: 'S2_fill',
intent: '一张执行单交 PRM:照询价工作单在系统外询价,供应商与底价就地填回',
writes: ['quote_sourcing.verdict'],
reads: ['quote_sourcing.quote_list'],
view: 'sourcing-worksheet',
tools: [],
/* 不给 doneWhen —— 发卡触达真人(manualActivation),引擎不替人按 */
guide: `清单归一好、A 类基准预填了,**该 PRM 出场了** —— 这是本活动唯一的一张执行单。
工作区摆的是**寻源询价工作单**:逐颗待询料的询价档案(要素/取价对象/客户约束)、
按场景圈渠道的规则、价格验证的尺、发询纪律与时限,填回区就长在上面。
这一步标了人工激活:发卡就算启动,不必单发 start;
引擎也不会替你收 —— 人交回后要你自己 finish。
【照这个骨架发,别少字段】(卡面是纯文本,steps/standard 里**别写星号记号**):
{"kind":"human","itemId":"S2_fill","workOrder":"execution","card":{
"title":"寻源补价:<单号> 待补价 <N> 颗",
"ask":"清单里底价空着的行要补货源价格:请照工作单上的场景规则在系统外完成询价,把供应商与底价填回表里对应行;MOQ、货源交期、备注能问到就一并填。",
"steps":["底价空着的行是本单要问价的料 —— 每行必须补上:供应商、底价(未税单价按内部口径归一);取价对象是替代料一栏有值的取替代料,否则取原型号",
"圈渠道按场景走(细则见工作单):BOM早期与样品可放宽;BOM后期按主供应商、备选、贸易商的顺序圈两三家,贸易商尽量不用;现货只选有库存实拍的供应商",
"回价登记一一对应不可缺项:价格、MOQ、交期能问到就填;验价按场景的尺核过再采,不实回价或远低行情的换渠道补询,别直接采用",
"确实问不到价的料,先与负责人商定:在件上把那一行撤掉(剔出本轮),或按内部估价填并在备注写明;A 类行已按库内历史价预填,可校可改"],
"standard":"每一行都有基准价与价源可溯,渠道与验价按场景的尺走过 —— 底价缺一行,这张单按不下提交(系统的闸看着)。",
"basis":[{"k":"<待补价 PN>","v":"<类别与年用量 + 客户约束(认证/急料),取价对象标明>"}]}}
· \`basis\` 逐条列待补价的料:PN + 类别/年用量。几颗写几条,别铺全表。
· **卡上不摆表、不摆格子**(2026-08-02 用户定:填写全在工作区):整份清单就在
工作单那份件上,供应商/底价/MOQ/货源交期/备注五列都能就地改、行能撤 ——
**别往卡里塞 \`blocks\` 或 \`fields\`**,塞了就是同一张表摆两处。
· **这张卡带提交闸**:底价(必填列)还空着,他按不下提交 —— 缺几格、缺在哪行,
闸上与件上的红带都写着(与放行门 L1 同一个数同一套算法)。
发完卡这一拍就结束了(发卡挂起)。
【人交回之后 —— 件上的值基座已经替你写回了】他在件上按「保存」那一刻改动就落进了
草稿层,按「提交」只是把卡交回来。你只做两件事,然后 finish:
① 清点原本底价空着、现在有价的条数,把 **sourced_total** 写进 verdict
(他在件上撤了行也算处置过,照实清点;0 也要写);
② 别再 write 那些行 —— 值没变的写入会被「写了等于没写」拒回。
【这张卡一单只发一次】人交回来之后不许再发同一张卡 —— 必填有提交闸兜着,
卡回来时底价一定是齐的(或那几行已被他撤掉)。`,
},
{
item: 'S3_compile',
intent: '价源逐行归档,齐检定盘:目标报价清单成稿',
writes: ['quote_sourcing.quote_list', 'quote_sourcing.verdict'],
reads: ['quote_sourcing.quote_list'],
view: 'sourcing-roster',
tools: [],
/*
* **收步判据与验收判据同源**:每行有价(基座按 required 现数)、价源守恒
* (基座按值现数)、定盘记账 —— 三样齐才收,松出来的那一截就是返工。
*/
doneWhen:
'quote_sourcing_quote_list_missing = 0 and quote_sourcing_quote_list_price_basis_hist + quote_sourcing_quote_list_price_basis_sourced = quote_sourcing_quote_list_rows and route_decided_total > 0',
guide: `PRM 补回来了,现在把清单**定盘**,一拍连做:
① **价源归档**:PRM 补了价的行(price_basis 还空着的),逐行写 price_basis = **SOURCED**
(大写,一个字母不许差);A 类预填的行已是 HIST,不动。
按 rfq_ref + pn 对行,主键一个字符不许改。
② 若还有行缺底价(他留空又没撤):照他备注的说明办 —— 写明剔除的用 \`drop\`
把那一行撤出本单草稿(主键照 \`_keys\` 名单**原样粘**),撤了哪几颗、为什么写进
verdict.summary,并把 quote_line_total 与 pending_total 改成撤后的实数;
什么说明都没有的,发一张**决策卡(decide)**问负责人「这几颗问不到价,剔出本轮还是退回」,
照答复办。**只问这一次**。
③ 写 verdict:**route_decided_total 记 1**、summary 一句人话
(几行历史价、几行货源价、剔了几颗、整份可交定价)。
④ finish 这一项。引擎按「每行有价 + 价源守恒 + 定盘记过」收步,三样有一样不齐都收不掉。
这一单只有一个出口(交成本定价),**没有去向要选** —— 定盘就是把账写齐。`,
},
{
item: 'T_release',
view: 'sourcing-roster',
intent: '清单已定盘,提交放行交 PRM 验收',
writes: [],
reads: [],
tools: [],
/* 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 doneWhen) */
guide: `每行有价、价源守恒、定盘已记 —— 可以走放行了(T_release 人工激活:
动它就算启动,不必单发 start)。先把裁量判据裁掉,然后 submit,同一拍连做:
· judge criterion="货源与价格来源可核:B 类行的供应商与底价来自真实询价或可靠渠道,PRM 对报价负责" ——
看清单里 SOURCED 行的供应商与备注:说得出来源就 satisfied=true;
有行说不出的,satisfied=**false**,rationale 逐行写清,交签字的人定。
**裁成不满足照样能提交** —— 理由连着卡一起交给他。
criterion 照判据名**整句原样抄**,写别的名字对不上。
终局唯放行:你不能自己宣布完成。`,
},
];
/**
* 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段;
* 放行那一格由基座统一加,人看到的是 x/4。
*/
export const SOURCING_PHASES = [
{ name: '归一预填', items: ['S1_normalize'] },
{ name: '补货源价', items: ['S2_fill'] },
{ name: '汇稿定盘', items: ['S3_compile'] },
];
+10 -54
View File
@@ -1,59 +1,15 @@
/**
* 统一报价 A3 · 渠道寻源 —— 活动包代码入口(ADR-004)。
* 统一报价 A3 · 寻源询价 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
*
* **loop 活动**:下一步由 Agent 在案例模型的结构上临场决断,引擎只推进到 Enabled 为止。
* 本文件只做视图流控 —— 把阶段、里程碑与比价进展摆给人看。
* **这个活动没有 `run`,不是缺件。**
*
* 案例活动的推进由 Loop 驱动,行为全部写在清单里:案例模型 + 说明书 + 步骤剧本 +
* 两条技能 + 台账声明 + 一张 HTML 报告模版。上一版这里有一段 `run()` ——
* loop 活动根本不会调它;摆一个空转的 run 去假装有实现,更坏。
*
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 —— 它是**被静态审扫描的产物文本**
* (查 import 与全局引用,证明活动包零环境能力)。
*/
import { defineActivity } from '@sdk';
import type { ActivityBase, ActivityOutcome } from '@sdk';
const QUOTES = 'quote_sourcing.supplier_quote';
const COMPARE = 'quote_sourcing.compare_table';
export default defineActivity({
name: 'quote-sourcing',
paradigm: 'loop',
async run(base: ActivityBase): Promise<ActivityOutcome> {
const quotes = await base.ledger.read(QUOTES);
const compare = await base.ledger.read(COMPARE);
const suppliers = new Set(quotes.map((q) => String(q['supplier'] ?? '')));
await base.interaction.pushView([
{
t: 'kv',
title: '寻源进展',
items: [
{ k: '场景', v: String(base.contract.data['scenario'] ?? '') },
{ k: '已圈渠道', v: `${suppliers.size} 家` },
{ k: '已收回价', v: `${quotes.length} 单` },
{ k: '比价表', v: compare.length > 0 ? '已填' : '未填' },
],
},
{
t: 'table',
title: '供应商回价',
cols: ['供应商', '回价', 'MOQ', '交期', '渠道等级'],
rows: quotes.map((q) => [
String(q['supplier'] ?? ''),
String(q['price'] ?? '—'),
String(q['moq'] ?? '—'),
String(q['lead_time'] ?? '—'),
String(q['grade'] ?? '—'),
]),
},
]);
if (compare.length === 0) {
// 交集为空即断点 —— 由引擎判定、Agent 定夺调人还是立监听,这里不代它决定
await base.interaction.say(
suppliers.size === 0 ? '尚未圈定渠道,先按场景定策。' : `已收 ${quotes.length} 单回价,待填寻源比价表。`,
'conclusion',
);
return { kind: 'await', reason: '寻源比价表未交付' };
}
await base.interaction.say('寻源比价表已填齐,可交回 PM。', 'criterion');
return { kind: 'submit', deliverable: 'compare-table', rationale: '每料有有效报价或明确无货' };
},
});
export default defineActivity({ name: 'quote-sourcing' });
+301 -85
View File
@@ -1,141 +1,265 @@
/**
* 活动包清单 —— 统一报价 A3 · 渠道寻源(**案例活动 · loop**)。
* 活动包清单 —— 统一报价 A3 · 寻源询价(案例活动 · MVP 简化形)。
*
* 业务素材:docs/MVP示例工作流.html §1.5。按 README《归一化对照表》映射:
* 案例图 → CMMN XML;案例说明书 → 同版本叙事面;
* `!必做` → entryCriterion 常项;`看情况` → discretionary(planningTable);
* `#可反复` → repetitionRule;`▶需拍板` → manualActivation + 放行;
* `⏱时限/回价到期` → 时间监听(**时限必配**)。
* 业务素材:`docs/MVP示例工作流.html` §1.7,按 2026-08-02 用户改盘的口径归一:
* **A3 与 A2 同为复杂活动(需要大规模业务建模),MVP 期间不展开** —— 只做一张精美的
* HTML 验收报告 + 一张执行单:AI 归一目标报价清单、A 类行按库内历史价预填基准,
* 采购(PRM)在系统外询价、把 B 类行的货源价格填回来,汇稿定盘后放行 ——
* **生成最终的目标报价清单**交给成本定价。提交就是输出。
*
* ── 这里**去掉了 `STRATEGY_DMN`(采用策略判定表)**(2026-08-02)────────────────
* 它声明着 scenario / priceDrift / quoteCount 三个入参,台账上没有任何列承载、
* 剧本里也没有一步调它 —— 一张没有求值器的判定表不是资产,是错觉。
* 四种场景(BOM早期/后期/现货/样品)的尺度之分是 PRM 系统外的判断,
* MVP 口径下系统不假装接得住。
*/
import type { ActivityManifest, DmnModel } from '@contracts';
import { SOURCING_SKILLS } from './skills.ts';
import { SOURCING_CASE } from './views/report.ts';
import { activityIdSchema } from '@contracts';
import { CMMN_XML, NARRATIVE } from './case-model.ts';
import { SOURCING_SKILLS } from './skills.ts';
import { SOURCING_ROSTER, SOURCING_WORKSHEET } from './views/report.ts';
import { CMMN_XML, NARRATIVE, SOURCING_PHASES, SOURCING_STEPS } from './case-model.ts';
const DOMAIN = 'quote_sourcing';
const D = 'quote_sourcing';
const ACTIVITY_ID = activityIdSchema.parse('quote-sourcing');
/** 采用策略 —— 「能算的不问 LLM」:场景 × 历史情形 → 直采 / 重询 */
export const STRATEGY_DMN: DmnModel = {
drd: [{ decision: 'sourcing-strategy', requires: [], inputs: ['scenario', 'priceDrift', 'quoteCount'] }],
tables: [
{
id: 'sourcing-strategy',
label: '采用策略判定',
hitPolicy: 'FIRST',
inputs: [
{ name: 'scenario', expression: 'scenario' },
{ name: 'priceDrift', expression: 'priceDrift' },
{ name: 'quoteCount', expression: 'quoteCount' },
],
outputs: [{ name: 'strategy' }, { name: 'needNewQuote' }],
rules: [
{ id: 'st-spot', when: ['"现货","样品"', '-', '-'], then: ['一律重询', true] },
{ id: 'st-early-direct', when: ['"BOM早期"', '<= 0.10', '-'], then: ['直采', false] },
{ id: 'st-early-avg', when: ['"BOM早期"', '[0.10..0.20]', '-'], then: ['取均价', false] },
{ id: 'st-late-direct', when: ['"BOM后期"', '-', '>= 3'], then: ['历史最低直采', false] },
{ id: 'st-default', when: ['-', '-', '-'], then: ['重询', true] },
],
},
],
};
/** MVP 口径:A3 没有判定表(见文件头注释)—— 取价规则写死在剧本,价源逐行落表 */
export const SOURCING_DMN: DmnModel = { drd: [], tables: [] };
export const manifest: ActivityManifest = {
identity: {
activityId: ACTIVITY_ID,
name: 'A3 · 渠道寻源',
packageVersion: '0.1.3',
name: 'A3 · 寻源询价',
packageVersion: '3.7.0',
category: '统一报价',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-quote-sourcing',
},
/* ── 契约七件 ────────────────────────────────────────────────── */
contract: {
activityId: ACTIVITY_ID,
version: '0.1.0',
version: '1.0.0',
input: {
/*
* **宽进到底:一个必填都不设**(2026-08-02 用户定:宽进严出,
* 输入契约在活动内部归一 + 补全)。
*
* 来路三种形状:A2 转来(rows)、A1 直达(records)、单飞(一段文字)。
* 上一版必填 `rfqRef + pn + scenario` 是**单料形状** —— 上游发的是整份清单,
* 门口拦的恰是主路。
*/
data: {
type: 'object',
required: ['pn', 'scenario'],
required: [],
properties: {
pn: { type: 'string' },
scenario: { type: 'string' },
quantity: { type: 'integer' },
targetPrice: { type: 'number' },
rfqRef: { type: 'string', title: '询价单号' },
rows: { type: 'array', title: '询价受理清单(选型转来)' },
records: { type: 'array', title: '上游受理记录(受理直达)' },
subject: { type: 'string', title: '需求主题' },
body: { type: 'string', title: '需求正文' },
attachments: {
type: 'array',
format: 'file',
title: '附件',
items: {
type: 'object',
properties: {
name: { type: 'string', title: '文件名' },
contentType: { type: 'string', title: '类型' },
size: { type: 'integer', title: '字节数' },
ref: { type: 'string', title: '存档号' },
text: { type: 'string', title: '读出来的文本' },
note: { type: 'string', title: '读取说明' },
},
},
},
},
},
messageAllowed: true,
messageHint: '急料、客户特殊要求写这里',
messageHint: '急料、场景(BOM早期/后期/现货/样品)、客户特殊要求写这里',
},
output: {
/*
* 只有一个出口:目标报价清单交成本定价(流程包 E7 是无条件边)。
* 没有选择组 —— 没有去向要选,定盘就是把账写齐。
*/
mandatory: [
{
name: 'compare',
payload: { type: 'object', required: ['rows'], properties: { rows: { type: 'array' } } },
description: '寻源比价表(多渠道横比定底)',
name: 'quotation',
payload: {
type: 'object',
required: ['rfqRef', 'rows'],
properties: {
rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' },
rows: { type: 'array', title: '目标报价清单(每行带基准价与价源)' },
},
},
description: '目标报价清单(每行带基准价与价源)',
},
],
selective: [],
},
requiredDeliverables: [{ key: 'compare-table', label: '寻源比价表' }],
acceptanceCriteria: [
{ key: 'each-has-quote', label: '每料有有效报价或明确无货', level: 'L2', predicate: 'unquoted = 0' },
{ key: 'channels-enough', label: 'BOM后期每料 2~3 家渠道', level: 'L2', predicate: 'channelsPicked >= 2' },
{ key: 'cross-check', label: '价格验证按场景规则做过', level: 'L3' },
{ key: 'prm-release', label: '采购放行', level: 'L5' },
/*
* 必交付物只有一件:**目标报价清单**。寻源结论(verdict)是它的结论栏,
* `ledgers` 说清这份成果由哪几张表承载。
*/
requiredDeliverables: [
{
key: 'quotation-roster',
label: '目标报价清单(每行带基准价)',
ledger: `${D}.quote_list`,
/* 验收页上让 PRM 就地改的那几列 —— 恰是他的职权面(价格五列) */
editableAtRelease: ['supplier', 'base_price', 'moq', 'source_lead_time', 'note'],
ledgers: [`${D}.quote_list`, `${D}.verdict`],
},
],
/*
* 验收判据 —— **机检(L1/L2)只读基座数得出来的数**:
* `_rows` 基座数行数、`_missing` 基座按 required 现数(与执行卡提交闸同一个数
* 同一套算法)、`_price_basis_hist/_sourced` 基座按值现数。
* 上一版两条 L2 读活动自报的 verdict 列(unquoted_total / supplier_total)——
* 那不是门,是「改哪个数能过」的提示。
*/
acceptanceCriteria: [
{
key: 'roster-complete',
label: '清单齐:上游清单逐条承继,说了几条清单里就躺着几行',
level: 'L1',
predicate: 'quote_line_total > 0 and quote_sourcing_quote_list_rows = quote_line_total',
},
{
key: 'price-complete',
label: '每行有基准价:A 类历史价、B 类货源价,缺一行放不了',
level: 'L1',
predicate: 'quote_sourcing_quote_list_rows > 0 and quote_sourcing_quote_list_missing = 0',
},
{
key: 'basis-conserved',
label: '价源守恒:历史价条数加货源价条数恰等于总行数,逐行留徽可溯',
level: 'L2',
predicate:
'quote_sourcing_quote_list_price_basis_hist + quote_sourcing_quote_list_price_basis_sourced = quote_sourcing_quote_list_rows',
},
/*
* 来源可核是**裁量**(L3):供应商靠不靠谱、价问没问真,机器数不出来。
* 这句 label 与剧本里 judge 的 criterion **必须逐字一致**。
*/
{
key: 'source-verifiable',
label: '货源与价格来源可核:B 类行的供应商与底价来自真实询价或可靠渠道,PRM 对报价负责',
level: 'L3',
},
/* L5 是责任关:那张卡本身就是这道关,卡面上不摆它(基座统一滤) */
{ key: 'prm-release', label: 'PRM 放行:签下去这份清单即定盘并交成本定价', level: 'L5' },
],
revisableAtRelease: [],
prohibitions: [
{ key: 'no-made-up-price', statement: '问不到的价不许编:要么剔出本轮,要么按内部估价填并在备注写明' },
{ key: 'no-stockless-trader', statement: '现货场景不得选无库存转单商' },
{ key: 'no-unverified', statement: '不实回价或远低行情的,必须换渠道补询,不得直接采用' },
],
authorization: {
discretion: [{ key: 'channel-band', statement: '按场景放宽或收紧渠道尺度;贸易商尽量不用' }],
preauthorizedActions: [{ tool: 'mail.send', operation: 'inquiry', limit: '询价模板' }],
humanCallBudget: 8,
discretion: [
{ key: 'channel-band', statement: 'PRM 按场景放宽或收紧渠道尺度;贸易商尽量不用' },
],
/*
* 空着不是省事:这个字段全仓没有一个消费者(债 ⑨)。
* 上一版这里写着 `mail.send` 的预授权 —— 写了也不会被执行,一并撤掉。
*/
preauthorizedActions: [],
humanCallBudget: 4,
},
resources: {
tools: [{ name: 'mail.send' }, { name: 'crm.history-price-read' }],
/*
* 只留历史价一件工具(A 类预填的取数口)。上一版还声明 `mail.send` ——
* MVP 口径下发询在 PRM 系统外,系统不代发,声明它只是白占白名单。
*/
tools: [{ name: 'crm.history-price-read' }],
skills: SOURCING_SKILLS.map((s) => s.id),
models: ['deepseek-v4-flash'],
roles: ['PRM'],
budget: { steps: 120, tokens: 300_000, activeMinutes: 480, humanCalls: 8 },
/* PRM 系统外询价,按场景时限 BOM后期 3~9 日 —— activeMinutes 给足等待 */
budget: { steps: 50, tokens: 250_000, activeMinutes: 480, humanCalls: 4 },
},
},
/* ── 声明 ────────────────────────────────────────────────────── */
declarations: {
models: ['deepseek-v4-flash'],
tools: [{ name: 'mail.send' }, { name: 'crm.history-price-read' }],
tools: [{ name: 'crm.history-price-read' }],
ledger: [
/*
* ── 这里**去掉了 `supplier_quote` / `compare_table` 两张表**(2026-08-02)──
*
* MVP 口径下逐家回价与横比都不在系统内产出 —— 询价的实质工作在 PRM 系统外,
* 系统收的是**结论**(定盘的供应商与底价),落在清单行的价格五列上。
* 撤掉声明不 drop 物理表(物化只增不破坏),历史行还在库里。
*/
{
domain: DOMAIN,
object: 'supplier_quote',
label: '供应商回价单',
domain: D,
object: 'quote_list',
kind: 'record',
caseKey: 'rfq_ref',
label: '目标报价清单',
fields: [
{ name: 'pn', type: 'text', primaryKey: true, masked: false },
{ name: 'supplier', type: 'text', primaryKey: true, masked: false, label: '供应商' },
{ name: 'price', type: 'number', primaryKey: false, masked: false, label: '回价' },
{ name: 'moq', type: 'integer', primaryKey: false, masked: false, label: 'MOQ' },
{ name: 'lead_time', type: 'text', primaryKey: false, masked: false, label: '交期' },
{ name: 'valid_until', type: 'date', primaryKey: false, masked: false, label: '有效期' },
{ name: 'grade', type: 'text', primaryKey: false, masked: false, label: '渠道等级' },
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' },
{ name: 'customer_pn', type: 'text', primaryKey: false, masked: false, label: '客户料号' },
{ name: 'category', type: 'text', primaryKey: false, masked: false, label: '品类' },
{ name: 'brand', type: 'text', primaryKey: false, masked: false, label: '制造商' },
{ name: 'eau', type: 'integer', primaryKey: false, masked: false, label: '年用量 EAU' },
{ name: 'target_price', type: 'number', primaryKey: false, masked: false, label: '目标价' },
{ name: 'lead_time', type: 'text', primaryKey: false, masked: false, label: '客户要求交期' },
/* 客户备注承继自上游 —— 认证要求、预授权、急料标注都在这一格,是问价口径的第一手依据 */
{ name: 'remark', type: 'text', primaryKey: false, masked: false, label: '客户备注(约束/急料)' },
/* 选型转来的行带替代料 —— 取价对象是它;受理直达与单飞的行此列空着 */
{ name: 'alt_pn', type: 'text', primaryKey: false, masked: false, label: '替代料 PN' },
{ name: 'type_tag', type: 'text', primaryKey: false, masked: false, label: '类别(承继)', tally: ['A', 'B', 'C'] },
{ name: 'supplier', type: 'text', primaryKey: false, masked: false, label: '供应商' },
/*
* **底价是必填** —— 标了 `required`,基座就数得出「缺基准价的有几行」
* (读数 `quote_sourcing_quote_list_missing` / `_missing_keys`)。
* 这个数同时喂三处:件上的缺价红带、执行卡的提交闸、放行门 L1 ——
* 同一个数、同一套算法,三处永远说同一句话(同源纪律)。
*/
{ name: 'base_price', type: 'number', primaryKey: false, masked: false, required: true, label: '底价' },
/* MOQ 常带单位与档位(1K / 2500+),按文本收,不硬转数值 */
{ name: 'moq', type: 'text', primaryKey: false, masked: false, label: 'MOQ' },
{ name: 'source_lead_time', type: 'text', primaryKey: false, masked: false, label: '货源交期' },
/*
* **价源:取值域给出来,条数由基座现数** —— HIST(库内历史价)/ SOURCED(本单问回)。
* 价源守恒判据、分布条、价源徽读的全是基座读数;值是机器码,
* 人读名(历史价/货源价)由模版样式表翻译。
*/
{ name: 'price_basis', type: 'text', primaryKey: false, masked: false, label: '价源', tally: ['HIST', 'SOURCED'] },
/* 备注是我方写的注,不标 expect(标了 `_gaps` 恒大于 0,门就废了 —— A1 的账) */
{ name: 'note', type: 'text', primaryKey: false, masked: false, label: '备注' },
],
},
{
domain: DOMAIN,
object: 'compare_table',
label: '寻源比价表',
/* 寻源结论 —— 一单一行:汇总数(哨兵与报告读)、一句人话结论 */
domain: D,
object: 'verdict',
kind: 'record',
caseKey: 'rfq_ref',
label: '寻源结论',
fields: [
{ 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: '结论' },
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'customer_code', type: 'text', primaryKey: false, masked: false, label: '客户码' },
{ name: 'customer_name', type: 'text', primaryKey: false, masked: false, label: '客户名称' },
{ name: 'quote_line_total', type: 'integer', primaryKey: false, masked: false, label: '清单条目数' },
{ name: 'pending_total', type: 'integer', primaryKey: false, masked: false, label: '待补价条数' },
/* A 类预填走完的凭据(0 也要写)—— 哨兵按它放 PRM 那一步入场 */
{ name: 'hist_priced_total', type: 'integer', primaryKey: false, masked: false, label: 'A 类已按历史价预填条数' },
/* 「PRM 的答复处理过了」的凭据 —— 只在人环走完才产生 */
{ name: 'sourced_total', type: 'integer', primaryKey: false, masked: false, label: 'PRM 已补价条数' },
{ name: 'route_decided_total', type: 'integer', primaryKey: false, masked: false, label: '已定盘' },
{ name: 'summary', type: 'text', primaryKey: false, masked: false, label: '寻源结论' },
],
},
],
listeners: [
{
// 回价到期:临过期提醒复核确认,过期触发重询 —— 时限必配
/** 供应商回价到达:记一笔观察(PRM 在系统外收回价,这里只接得到通道转进来的) */
origin: 'static',
eventType: 'data',
keyFilter: { kind: 'supplier-quote' },
@@ -144,6 +268,7 @@ export const manifest: ActivityManifest = {
escalation: ['nudge', 'escalate'],
},
{
/** 回价临过期:提醒复核确认 —— 时限必配 */
origin: 'static',
eventType: 'time',
keyFilter: { kind: 'quote-expiry' },
@@ -152,33 +277,124 @@ export const manifest: ActivityManifest = {
escalation: ['nudge', 'reroute'],
},
],
// 按场景时限:BOM早期/现货 1~4 日、样品 1~3 日、BOM后期 3~9 日 —— 取最宽做停滞阈值
harness: {
memoryWindow: 30,
memoryWindow: 24,
// PRM 在系统外询价,按场景时限最宽 BOM后期 3~9 日 —— 停滞阈值放到两天
stallAfter: 'P2D',
nudge: [{ after: 'P1D', action: 'nudge' }],
retry: { times: 2, backoff: 'PT5M' },
},
},
/* ── 资产初始版 ──────────────────────────────────────────────── */
assets: {
businessModel: {
cmmnXml: CMMN_XML,
// 双表示同版本:XML 是机器面,说明书是叙事面 —— 不允许分叉
narrative: NARRATIVE,
dmn: STRATEGY_DMN,
dmn: SOURCING_DMN,
steps: SOURCING_STEPS,
phases: SOURCING_PHASES,
},
skills: SOURCING_SKILLS,
viewTemplates: [{ name: 'case-overview', html: SOURCING_CASE }],
/*
* 两个局面各一张(与 A2 同款口径,2026-08-02):
* · `sourcing-worksheet` 寻源询价工作单 —— 归一预填与 PRM 补价期间摆它:
* 逐颗待询料的询价档案、按场景圈渠道的规则、价格验证的尺、发询纪律与时限,
* 填回区就长在上面(素材出处 docs/MVP示例工作流.html §1.7,只作文字参考);
* · `sourcing-roster` 目标报价清单 —— 汇稿定盘后与放行、归档摆它。
*/
viewTemplates: [
{ name: 'sourcing-worksheet', html: SOURCING_WORKSHEET },
{ name: 'sourcing-roster', html: SOURCING_ROSTER },
],
cardSchemas: {},
/*
* distillHooks 只留 terminal —— 上一版还有 `milestone: 'M_quotes'`,
* 而案例模型里从来没有这个里程碑(死链,钩子永不触发)。
*/
distillHooks: [
{ on: 'milestone', milestone: 'M_quotes', prompt: '归纳各渠道的回价节奏与配合度。' },
{ on: 'terminal', prompt: '归纳本单寻源:哪些渠道靠谱、哪些场景的价格验证规则该收紧。' },
{
on: 'terminal',
prompt: '归纳本单寻源:哪些料历史价可用、货源价与历史价的偏离形态、剔单的原因分布。',
},
],
},
/* ── 样例集 —— 三条形状真不同(常备案例即验收案例)───────────── */
samples: [
{ name: 'BOM后期询价', input: { pn: 'LM358DR', scenario: 'BOM后期', quantity: 5000 }, expect: { ports: ['compare'] } },
{
/*
* ① 选型转来:A2 放行载荷的真实形状 —— rows 里清单行(带 alt_pn 与现标)
* 混着结论行;A 类行的取价对象是替代料。
*/
name: '选型转来 · 一颗转 B 的替代料要问价(A 类行历史价预填)',
input: {
rfqRef: 'RFQ MD14.1',
customer_code: 'CU-0142',
customer_name: 'Itelma',
rows: [
{
rfq_ref: 'RFQ MD14.1',
pn: 'BBPY00201209601Y00',
customer_pn: '4-14-01502',
category: '滤波器',
brand: 'YAGEO',
eau: 30000,
alt_pn: 'PBY201209T-601Y-N',
orig_type: 'C',
type_tag: 'B',
basis: '替代料 PBY201209T-601Y-N 库内无历史 → 转 B',
},
{
rfq_ref: 'RFQ MD14.1',
pn: 'CJTB0101R6N',
customer_pn: '4-00-04102',
category: '集成电路',
brand: 'Jiangsu Changjing',
eau: 60000,
orig_type: 'A',
type_tag: 'A',
},
{
rfq_ref: 'RFQ MD14.1',
alt_line_total: 2,
c_total: 1,
need_sourcing: true,
route_decided_total: 1,
customer_name: 'Itelma',
},
],
},
expect: { ports: ['quotation'] },
},
{
/*
* ② 受理直达(E3 那条路):A1 载荷形状 —— records 混行,B 类两颗要问价,
* A 类一颗历史价预填。
*/
name: '受理直达 · 两颗 B 类要问价(records 混行形状)',
input: {
rfqRef: 'RFQ-2026-0555',
customer_name: '青岛海洋电子',
records: [
{ rfq_ref: 'RFQ-2026-0555', pn: 'PESD2ETH-XR', category: '二极管', brand: 'Nexperia', eau: 30000, type_tag: 'B' },
{ rfq_ref: 'RFQ-2026-0555', pn: 'PESD2ETH-XR', history_days: 9999, market_stable: false, lifecycle: '未知', type_tag: 'B', basis: '无历史/过期/波动 → B' },
{ rfq_ref: 'RFQ-2026-0555', pn: 'NE555P', category: '定时器', brand: 'TI', eau: 5000, type_tag: 'B' },
{ rfq_ref: 'RFQ-2026-0555', pn: 'UMW AO6604', category: '晶体管', brand: 'UMW', eau: 30000, type_tag: 'A' },
{ rfq_ref: 'RFQ-2026-0555', line_total: 3, type_a_total: 1, type_b_total: 2, type_c_total: 0, need_sourcing: true },
],
},
expect: { ports: ['quotation'] },
},
{
/* ③ 单飞宽进:没有单号没有清单,正文列料 —— 归一造案号,全按 B 待询 */
name: '单飞需求 · 正文列两颗料要问底价(宽进归一)',
input: {
subject: '两颗现货料问底价',
body: '帮忙问两颗料的现货底价:SS8050 三极管 20K,XC6206P332MR LDO 10K。现货场景,只要有库存实拍的供应商。',
},
expect: { ports: ['quotation'] },
},
],
entry: { file: 'index.js', checksum: 'dev-entry-quote-sourcing' },
+31 -28
View File
@@ -1,46 +1,49 @@
/**
* A3 技能 —— 寻源的方法经验(AM 08)。
* loop 里技能由 AI **按适用描述临场装配**:常驻上下文只有目录,按事加载正文。
* A3 技能 —— 寻源询价 MVP 简化形的方法经验(AM 08)。
*
* **载方法,不载权力**;渐进披露:常驻上下文只有目录,按事加载正文。
* 剧本改了口径,技能里同一句也要跟着改 —— 2026-08-02 改盘后:
* 询价的实质工作在 PRM 系统外,系统管归一 / 预填 / 组卡 / 齐检;
* 旧的「按场景圈渠道」「该等还是该问」讲的是 PRM 脑子里的方法,系统不替他办,删。
*/
import { skillIdSchema } from '@contracts';
import type { Skill } from '@contracts';
export const SOURCING_SKILLS: Skill[] = [
{
id: skillIdSchema.parse('sourcing.pick-channels'),
label: '按场景圈渠道',
applicability: '拿到料号与场景,要决定向谁发询时。',
id: skillIdSchema.parse('sourcing.normalize-roster'),
label: '归一目标报价清单',
applicability: '开案第一步:把上游清单(或单飞需求)整份落成目标报价清单时。',
method: [
'渠道不是越多越好:发得越广,回价越慢、口径越乱、对供应商的信用消耗越大。',
'上游载荷是**全部落地行混在一起**的一份:选型转来(`rows`)的清单行带 alt_pn 与现标,',
'受理直达(`records`)的以受理记录行(带 eau/brand)为准;带总数的结论行不是料,别落。',
'**按 pn 归并**,一颗料一行,一拍写完(rows 收数组)。',
'',
'按场景定档:',
'· 常规补货 → 原厂代理 2 家足够,比的是账期与交期。',
'· BOM 后期 / 急单 → 加 1–2 家现货商,比的是**能不能拿到货**,价格其次。',
'· 停产料 → 直接问现货商与拆解渠道,原厂问了也是白问。',
'',
'每多发一家,就多一份要等的回价 —— 发询前先想清楚:这一家的回价会改变你的决定吗?',
'不会,就别发。',
'三条纪律:',
'① PN 一个字符不许改;上游没给的列留空字符串,绝不写 0 顶空。',
'② type_tag 照上游现标承继,本活动不改类 —— 改类是上游的职权。',
'③ 单号 rfq_ref 原样承继;单飞认不出才造「SRC-<型号或类型>」,造完从头用到尾。',
].join('\n'),
crystallized: [],
knowledgeRefs: ['渠道分级台账'],
version: '1.0.0',
knowledgeRefs: ['docs/MVP示例工作流.html §1.7'],
version: '2.0.0',
},
{
id: skillIdSchema.parse('sourcing.wait-or-ask'),
label: '该等还是该问',
applicability: '当下可为与目标所需交集为空(依赖断点)时。',
id: skillIdSchema.parse('sourcing.prefill-hist'),
label: 'A 类按历史价预填基准',
applicability: '清单落好后,要给 A 类行预填基准价时。',
method: [
'断点只有两种解法,选错就是白等或白问:',
'· 缺口**在世界侧**(供应商还没回价、样品还在路上)→ 立监听,必配时限。',
' 时限到了要有升级动作:催办 → 升级 → 改道,不能无限等。',
'· 缺口**只有人知道**(客户能不能接受替代、预算上限多少)→ 调人。',
'A 类的意思就是**库内历史价可用**(≤90 天且行情稳)—— 基准价直接取历史价,',
'不必劳烦 PRM 再问一遍。调 `crm.history-price-read`:**pns 收数组一次传完**;',
'**取价对象 = alt_pn 有值取 alt_pn,否则取 pn**(选型转来的 A 行,行情稳的是替代料)。',
'',
'判断标准很简单:**这个信息在系统外会自己到来吗?**',
'会 → 等(监听);不会 → 问(调人)。',
'把「该问的」等成超时,是寻源里最常见也最贵的错误。',
'出参逐行照抄:base_price = lastPrice、price_basis 写 HIST(大写,基座按值现数)。',
'hit=false 的 A 行**别编价** —— 留空当 B 类待询处理:上游打标与库内现状偶有出入,',
'照实处理,不替谁圆场。',
].join('\n'),
crystallized: [],
knowledgeRefs: [],
version: '1.0.0',
// 结晶片段:查历史价这一步每单都一样 —— 确定性子序列,不必每次让模型重想
crystallized: [{ tool: 'crm.history-price-read', input: { pns: 'pns' } }],
knowledgeRefs: ['三分类规则 P-03(A 行的定义)'],
version: '2.0.0',
},
];
@@ -0,0 +1,409 @@
/**
* A1 两份执行页的样式表 —— **从 PRD 目标件原样搬过来**,不是我照着复述的。
*
* 出处:`~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理需求.html` 的整段 `<style>`,
* 外加 `A1-询价受理记录.html` 里归档件独有的那几件(分类分布条 / 类徽 / 判定表 /
* 结论横幅 / 落地动作 / 完成标)。**一个数值都没改**。
*
* 为什么是搬而不是抄:前三轮我用自己那套外壳「照着做」,块搬齐了、版面是两套 ——
* 章节头成了彩色方块编号、多出摘要与元信息带、字号用 clamp。人一眼就看出不是那份件。
* 目标件是**像素级的答案**,搬过来才不会在复述里走样。
*
* 选择器保留 `.proto-root .app-shell` 前缀,所以模版正文也要照原型那样包两层壳 ——
* 少一层壳,整张样式表一条都不生效(踩过)。
*/
export const PRD_CSS = `
/* ============================================================================
JCAI 员工面工作台 · A1 受理归一的过程成果「询价受理需求」——报告本体
立场:**AI 已从 RFQ 邮件抽取归一**,人在这里审核并就地修订。
两态:读态(AI 稿 + 红/灰占位提示缺项) ⇄ 编辑态(全字段可改,保存 / 取消)
核心是「有效 PN 清单」;清单之上四件受理要素:客户公司 / 联系人 / 客户属性 / 有效 PN。
令牌与既有组件取自 workbench/src/app/(proto)/app.css,数值未改。
⚠️ 修正一处原型 bug:app.css:2062-2064 的 '.jc-rp-sign span'(权重 0,1,1)
盖过了 '.jc-rp-ava'(0,1,0),把头像的 display:grid 覆盖成 block、字色改灰,
导致落款头像里的字掉到圆外。本文件把该规则收窄为 '.jc-rp-sign > div > span'。
新增件:四要素卡 .jc-four/.jc-fcard · 占位胶囊 .jc-ph · 分段控件 .jc-seg ·
来源条 .jc-src · 录入表 .jc-tbl-in
========================================================================== */
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { background: #e6eaf1; -webkit-font-smoothing: antialiased; }
.proto-root .app-shell {
--brand: #3370ff; --brand-deep: #245bdb;
--brand-soft: #e1eaff; --brand-wash: #f0f4ff;
--ai-grad: linear-gradient(135deg, #3370ff, #7f3bf5);
--ink: #1f2329; --ink-2: #41464e; --ink-3: #646a73; --ink-4: #8f959e;
--line: #dee0e3; --line-soft: #e8eaed; --soft: #f8f9fb;
--jcai-bg: #e6eaf1; --cardline: #e1e6ec; --card-shadow: 0 1px 2px rgba(31, 35, 41, .05);
--ok: #278717; --ok-soft: #e8f8e4;
--warn: #de7802; --warn-accent: #ff8800; --warn-soft: #fff4e5;
--danger: #d83931; --danger-soft: #feeae9;
--mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace;
--shadow-brand: 0 6px 16px rgba(51, 112, 255, .22);
--t-fast: .16s cubic-bezier(.3, 0, .2, 1);
--t-med: .26s cubic-bezier(.3, 0, .2, 1);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--jcai-bg); min-height: 100vh; color: var(--ink);
}
.proto-root :where(button, input, select, a, [tabindex]):focus-visible {
outline: 2px solid var(--brand); outline-offset: 2px; border-radius: 8px;
}
.proto-root ::selection { background: var(--brand-soft); }
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
@keyframes jcBlink { 0%,100% { opacity: 1 } 50% { opacity: .35 } }
@keyframes jcStamp { from { opacity: 0; transform: rotate(-6deg) scale(1.7); } to { opacity: .9; transform: rotate(-6deg) scale(1); } }
/* 放开宽度:1160px 是「9 列全展开、一个字都不裁」的最小容器宽度(见下方列 min-width 之和) */
.jc-stage { max-width: 1160px; margin: 0 auto; padding: 22px 16px 52px; }
.proto-root .jc-mono { font-family: var(--mono); font-size: 11px; color: var(--ink-3); }
.proto-root .jc-pulse-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--brand); display: inline-block; animation: jcBlink 1.1s ease-in-out infinite; }
/* ── 报告外壳 ───────────────────────────────────────────────────────── */
.proto-root .jc-report { background: #fff; border: 1px solid var(--cardline); border-radius: 14px; box-shadow: var(--card-shadow); overflow: hidden; animation: rise var(--t-med) both; }
.proto-root .jc-rp-head { position: relative; overflow: hidden; display: flex; align-items: flex-start; gap: 12px; padding: 16px 20px 13px; border-bottom: 2px solid var(--ink); }
/* 题头下缘粗细双线 = 正式件版式 */
.proto-root .jc-rp-head::after { content: ""; position: absolute; left: 0; right: 0; bottom: -4px; height: 1px; background: var(--line); }
/* 题头右上的品牌光晕:与工作台标题区同一签名件 */
.proto-root .jc-rp-head::before { content: ""; position: absolute; right: -70px; top: -96px; width: 240px; height: 240px; border-radius: 999px; background: radial-gradient(circle, rgba(51,112,255,.10), transparent 70%); pointer-events: none; }
.proto-root .jc-rp-head > * { position: relative; z-index: 1; }
.proto-root .jc-rp-badge { flex: none; width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand); }
.proto-root .jc-rp-tit { flex: 1; min-width: 0; }
.proto-root .jc-rp-tit em { display: block; font-style: normal; font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); font-family: var(--mono); }
.proto-root .jc-rp-tit h3 { margin: 4px 0 0; font-size: 18px; font-weight: 800; color: var(--ink); letter-spacing: -.01em; line-height: 1.3; }
.proto-root .jc-rp-wait { display: inline-flex; align-items: center; gap: 6px; flex: none; margin-top: 4px; font-size: 11px; font-weight: 700; border-radius: 999px; padding: 4px 12px; color: var(--brand-deep); background: var(--brand-wash); border: 1px solid var(--brand-soft); }
.proto-root .jc-rp-wait.warn { color: var(--warn); background: var(--warn-soft); border-color: #ffd9a8; }
.proto-root .jc-rp-wait.edit { color: #fff; background: var(--brand); border-color: var(--brand); }
.proto-root .jc-rp-stamp { flex: none; margin-top: 2px; transform: rotate(-6deg); border: 2px solid currentColor; border-radius: 9px; padding: 4px 8px 3px 12px; font-size: 13px; font-weight: 900; letter-spacing: .28em; color: var(--ok); opacity: .9; animation: jcStamp .32s cubic-bezier(.2, 1.4, .4, 1) both; }
.proto-root .jc-rp-bd { padding: 18px 20px 12px; }
.proto-root .jc-rp-part { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 9px; margin: 20px 0 11px; }
.proto-root .jc-rp-part:first-child { margin-top: 0; }
.proto-root .jc-rp-part > b { flex: none; font-size: 11px; font-weight: 800; letter-spacing: .12em; color: var(--ink-3); }
.proto-root .jc-rp-part > .ln { flex: 1; height: 1px; background: var(--line-soft); }
.proto-root .jc-rp-part > em { flex: none; font-style: normal; font-size: 10px; font-weight: 700; color: var(--ink-4); font-family: var(--mono); background: var(--soft); border-radius: 999px; padding: 2px 9px; white-space: nowrap; }
.proto-root .jc-rp-part > em.warn { color: var(--warn); background: var(--warn-soft); }
/* 落款 —— ⚠️ 选择器收窄到 > div > *,别再命中头像那个 span */
.proto-root .jc-rp-foot { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 13px 20px 15px; border-top: 2px solid var(--line); background: #fbfcfd; }
.proto-root .jc-rp-sign { display: flex; align-items: center; gap: 10px; flex: 1 1 200px; min-width: 0; }
.proto-root .jc-rp-ava { flex: 0 0 auto; width: 30px; height: 30px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; line-height: 1; color: #fff; background: var(--brand); }
.proto-root .jc-rp-sign > div { min-width: 0; }
.proto-root .jc-rp-sign > div > b { display: block; font-size: 12px; font-weight: 700; color: var(--ink-2); }
.proto-root .jc-rp-sign > div > span { display: block; margin-top: 1px; font-size: 10.5px; color: var(--ink-4); }
.proto-root .jc-rp-acts { display: flex; align-items: center; gap: 8px; margin-left: auto; flex: none; }
.proto-root .jc-hintline { font-size: 11px; font-weight: 700; color: var(--warn); line-height: 1.5; }
.proto-root .jc-btn { display: inline-flex; align-items: center; gap: 5px; border: 0; border-radius: 7px; padding: 8px 14px; font-size: 12px; font-weight: 600; font-family: inherit; background: var(--brand); color: #fff; cursor: pointer; transition: all var(--t-fast); }
.proto-root .jc-btn:hover:not(:disabled) { background: var(--brand-deep); }
.proto-root .jc-btn:disabled { opacity: .45; cursor: not-allowed; }
.proto-root .jc-btn-ghost { background: #fff; color: var(--ink-2); border: 1px solid var(--line); }
.proto-root .jc-btn-ghost:hover:not(:disabled) { background: var(--brand-wash); color: var(--brand); border-color: var(--brand-soft); }
.proto-root .jc-btn-primary { background: var(--ai-grad); }
.proto-root .jc-btn-primary:hover:not(:disabled) { filter: brightness(1.07); box-shadow: var(--shadow-brand); transform: translateY(-1px); }
/* ── 来源信件:客户 RFQ 邮件原文(复用原型 jc-letter 件,青色 = 收件) ── */
.proto-root .jc-letter { position: relative; border: 1px solid var(--cardline); border-left: 3px solid #0aa294; border-radius: 12px; padding: 14px 16px 15px; background: #fff; box-shadow: var(--card-shadow); }
.proto-root .jc-letter-h { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 11px; }
.proto-root .jc-letter-dir { display: inline-flex; align-items: center; gap: 5px; font-size: 10.5px; font-weight: 800; padding: 3px 9px; border-radius: 999px; background: #e4f8f5; color: #0a7f74; }
.proto-root .jc-letter-h .jc-chip { font-size: 10.5px; padding: 3px 9px; border-radius: 999px; background: var(--brand-wash); color: var(--brand-deep); border: 1px solid var(--brand-soft); }
.proto-root .jc-letter-h > code { margin-left: auto; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
.proto-root .jc-letter-meta { display: flex; flex-direction: column; gap: 4px; margin-bottom: 11px; padding-bottom: 11px; border-bottom: 1px solid var(--line-soft); font-size: 11.5px; color: var(--ink-2); }
.proto-root .jc-letter-meta p { margin: 0; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.proto-root .jc-letter-meta i { flex: none; font-style: normal; width: 44px; font-size: 10px; font-weight: 800; letter-spacing: .06em; color: var(--ink-4); }
.proto-root .jc-letter-meta code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); overflow-wrap: anywhere; }
.proto-root .jc-letter-sub { margin: 0 0 9px; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.45; overflow-wrap: anywhere; }
/* 正文:原文照录,pre-wrap 保原始换行;不截断、不折叠 */
.proto-root .jc-letter-body { margin: 0; font-size: 12.5px; line-height: 1.72; color: var(--ink-2); white-space: pre-wrap; overflow-wrap: anywhere; }
.proto-root .jc-letter-body b { color: var(--ink); font-weight: 700; }
.proto-root .jc-atts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; padding-top: 11px; border-top: 1px dashed var(--line-soft); }
.proto-root .jc-att { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px 5px 8px; border: 1px solid var(--brand-soft); border-radius: 8px; background: var(--brand-wash); font-size: 11.5px; color: var(--brand-deep); }
.proto-root .jc-att svg { flex: none; color: var(--brand); }
.proto-root .jc-att code { font-family: var(--mono); font-size: 10.5px; }
.proto-root .jc-att span { color: var(--ink-4); }
/* 抽取指向:从原文到下方要素的一句衔接 */
.proto-root .jc-derive { display: flex; align-items: flex-start; gap: 8px; margin-top: 11px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); font-size: 11.5px; line-height: 1.7; color: var(--brand-deep); }
.proto-root .jc-derive svg { flex: none; margin-top: 2px; }
/* ── 四要素卡 ───────────────────────────────────────────────────────────
色调字典:每张卡带一套 --tone / --tone-soft / --tone-line,组件只读变量。
卡 3、卡 4 的色调随数据变(老客户绿 / 新客户橙;齐了蓝 / 有缺橙),
所以这条要素带本身就是一行可读的状态,不只是四个格子。 */
.proto-root .jc-four { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 11px; }
.proto-root .jc-fcard {
position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-width: 0;
padding: 14px 14px 14px; border: 1px solid var(--cardline); border-radius: 13px; background: #fff;
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 8px 20px -14px rgba(31,35,41,.35);
transition: transform var(--t-fast), box-shadow var(--t-fast), border-color var(--t-fast);
--tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft);
}
/* 顶缘色棱 + 右上角光晕:卡的身份色,一眼分得清四件是四件 */
.proto-root .jc-fcard::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 3px; background: var(--tone); }
.proto-root .jc-fcard::after { content: ""; position: absolute; right: -46px; top: -52px; width: 132px; height: 132px; border-radius: 999px; background: radial-gradient(circle, var(--tone-soft), transparent 70%); pointer-events: none; }
.proto-root .jc-fcard > * { position: relative; z-index: 1; }
.proto-root .jc-fcard:hover { transform: translateY(-2px); border-color: var(--tone-line); box-shadow: 0 2px 4px rgba(31,35,41,.05), 0 14px 26px -16px rgba(31,35,41,.45); }
.proto-root .jc-fcard.t-teal { --tone: #0aa294; --tone-soft: rgba(10,162,148,.15); --tone-line: #9fe0d9; }
.proto-root .jc-fcard.t-ok { --tone: var(--ok); --tone-soft: rgba(39,135,23,.14); --tone-line: #bfedb4; }
.proto-root .jc-fcard.t-warn { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.12); --tone-line: #ffd9a8; }
.proto-root .jc-fcard-k { display: flex; align-items: center; gap: 6px; font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
.proto-root .jc-fcard-k > i { flex: none; width: 19px; height: 19px; border-radius: 6px; display: inline-grid; place-items: center; background: var(--tone-soft); color: var(--tone); }
.proto-root .jc-fcard-b { display: flex; align-items: center; gap: 10px; min-width: 0; }
.proto-root .jc-fcard-t { min-width: 0; flex: 1; }
/* 一律换行,不省略号——宁可多占一行,也不丢字 */
.proto-root .jc-fcard-t > b { display: block; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.35; overflow-wrap: anywhere; }
.proto-root .jc-fcard-t > span { display: block; margin-top: 2px; font-size: 10.5px; color: var(--ink-4); line-height: 1.55; overflow-wrap: anywhere; }
/* 公司标:圆角方 + AI 渐变 + 外圈光环(方=组织) */
.proto-root .jc-logo { flex: none; width: 38px; height: 38px; border-radius: 12px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; letter-spacing: .04em; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand), 0 0 0 3px rgba(51,112,255,.10); }
/* 人像:圆 + 青色系(圆=人,与方形的组织标一眼分开) */
.proto-root .jc-face { flex: none; width: 38px; height: 38px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; color: #0a7f74; background: #e4f8f5; border: 1px solid #9fe0d9; box-shadow: 0 0 0 3px rgba(10,162,148,.10); }
/* 邮箱做成可点的胶囊,复制按钮嵌在里面——比一行裸文字有抓手 */
/* 邮箱:软底胶囊、无描边——有描边会读成输入框,而读态这里不是可填的 */
.proto-root .jc-mailrow { display: flex; align-items: center; gap: 4px; margin-top: 5px; min-width: 0; max-width: 100%; padding: 3px 4px 3px 9px; border: 0; border-radius: 8px; background: var(--tone-soft); transition: background var(--t-fast); }
.proto-root .jc-mailrow:hover { background: rgba(10,162,148,.2); }
/* 编辑态里它包着输入框,软底会变成「框中框」,去掉 */
.proto-root .jc-report.editing .jc-mailrow { background: transparent; padding: 0; margin-top: 6px; }
.proto-root .jc-mailrow > code { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; line-height: 1.5; font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
.proto-root .jc-copy { flex: none; width: 22px; height: 22px; display: inline-grid; place-items: center; border: 0; border-radius: 6px; background: #fff; color: var(--ink-4); cursor: pointer; box-shadow: 0 1px 2px rgba(31,35,41,.08); transition: all var(--t-fast); }
.proto-root .jc-copy:hover { color: #0aa294; }
.proto-root .jc-copy.ok { color: var(--ok); background: var(--ok-soft); box-shadow: none; }
/* 客户属性徽章 */
.proto-root .jc-tagbig { display: inline-flex; align-items: center; gap: 6px; align-self: flex-start; padding: 7px 14px; border-radius: 999px; font-size: 13.5px; font-weight: 800; line-height: 1; color: var(--tone); background: var(--tone-soft); border: 1px solid var(--tone-line); }
.proto-root .jc-fcard-note { font-size: 10.5px; color: var(--ink-4); line-height: 1.55; }
.proto-root .jc-fcard-note b { color: var(--ink-2); font-weight: 700; }
.proto-root .jc-fcard-note b.bad { color: var(--tone); font-weight: 800; }
/* 成交迷你柱:把「台账 6 笔成交」从一句话变成看得见的节奏 */
.proto-root .jc-spark { display: flex; align-items: flex-end; gap: 4px; height: 26px; }
.proto-root .jc-spark > i { flex: 1 1 0; min-width: 4px; max-width: 15px; border-radius: 3px 3px 1px 1px; background: var(--tone); opacity: .3; transition: opacity var(--t-fast), height var(--t-med); }
.proto-root .jc-spark > i:last-child { opacity: 1; }
.proto-root .jc-spark:hover > i { opacity: .55; }
.proto-root .jc-spark:hover > i:last-child { opacity: 1; }
.proto-root .jc-spark.empty { align-items: stretch; }
.proto-root .jc-spark.empty > i { height: 100%; opacity: 1; background: repeating-linear-gradient(45deg, var(--tone-soft) 0 5px, transparent 5px 10px); border: 1px dashed var(--tone-line); border-radius: 5px; }
/* 有效 PN 计数 */
.proto-root .jc-count { display: flex; align-items: baseline; gap: 6px; }
.proto-root .jc-count > b { font-family: var(--mono); font-size: 32px; font-weight: 800; line-height: .95; color: var(--ink); letter-spacing: -.04em; }
.proto-root .jc-count > span { font-size: 11px; color: var(--ink-4); }
/* 分段刻度条:一格 = 一条 PN,缺必填的那格直接标红——比一根实心进度条信息量大 */
.proto-root .jc-segmeter { display: flex; gap: 3px; }
.proto-root .jc-segmeter > i { flex: 1; height: 7px; border-radius: 2px; background: var(--ok); transition: background var(--t-fast); }
.proto-root .jc-segmeter > i.bad { background: var(--danger); }
.proto-root .jc-lockhint { display: inline-flex; align-items: center; gap: 4px; font-size: 10px; color: var(--ink-4); }
/* ── 占位胶囊:必填=红虚线,选填=灰虚线;**都带文字**,不靠颜色单打 ── */
.proto-root .jc-ph { display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700; line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed; }
.proto-root .jc-ph.req { color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
.proto-root .jc-ph.opt { color: var(--ink-4); background: var(--soft); border-color: var(--line); }
/* ── 分段控件(新客户 / 老客户)── */
.proto-root .jc-seg { display: inline-flex; padding: 2px; gap: 2px; border: 1px solid var(--cardline); border-radius: 9px; background: var(--soft); align-self: flex-start; }
.proto-root .jc-seg button { border: 0; border-radius: 7px; padding: 6px 13px; font: inherit; font-size: 11.5px; font-weight: 700; color: var(--ink-3); background: transparent; cursor: pointer; transition: all var(--t-fast); }
.proto-root .jc-seg button:hover:not(.on) { color: var(--brand); }
.proto-root .jc-seg button.on { background: #fff; color: var(--ink); box-shadow: 0 1px 3px rgba(31,35,41,.12); }
/* ── 表:读态与编辑态共用骨架 ─────────────────────────────────────── */
.proto-root .jc-tbl-wrap { overflow-x: auto; border: 1px solid var(--line-soft); border-radius: 10px; }
.proto-root .jc-tbl-in { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; }
.proto-root .jc-tbl-in th { background: var(--soft); text-align: left; padding: 8px 9px; font-size: 10.5px; font-weight: 800; letter-spacing: .03em; color: var(--ink-3); white-space: nowrap; border-bottom: 1px solid var(--line-soft); }
.proto-root .jc-tbl-in th.req::after { content: "*"; margin-left: 1px; color: var(--danger); font-size: 10px; }
.proto-root .jc-tbl-in td { padding: 4px 5px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
.proto-root .jc-tbl-in tr:last-child td { border-bottom: 0; }
.proto-root .jc-tbl-in tbody tr:hover td { background: #f7f9fd; }
/* 缺必填的整行浅红打底 + 行首红标——不用找红胶囊,一眼扫到是哪几行 */
.proto-root .jc-tbl-in tr.miss td { background: #fffbfb; }
.proto-root .jc-tbl-in tbody tr.miss:hover td { background: #fff5f5; }
.proto-root .jc-tbl-in td.jc-rn { position: relative; width: 38px; padding: 4px 6px 4px 13px; white-space: nowrap; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); font-weight: 800; }
/* 行首红标只在表态用 ::after —— 卡片态的 ::after 被「第 N 条」占着,不能抢 */
@media (min-width: 961px) {
.proto-root .jc-tbl-in tr.miss td.jc-rn::after { content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger); }
}
.proto-root .jc-rndot { display: inline-block; width: 5px; height: 5px; margin-left: 4px; border-radius: 999px; background: var(--danger); vertical-align: 2px; }
/* 读态单元格 */
.proto-root .jc-vcell { display: block; padding: 6px 6px; font-size: 12px; color: var(--ink); line-height: 1.5; overflow-wrap: anywhere; }
.proto-root .jc-vcell.mono { font-family: var(--mono); font-size: 11px; color: var(--ink-2); }
.proto-root .jc-vcell.num { font-family: var(--mono); font-size: 11.5px; text-align: right; }
/* 编辑态单元格:安静的电子表格 */
.proto-root .jc-cell { width: 100%; min-width: 64px; border: 1px solid var(--line); border-radius: 6px; padding: 6px 7px; font: inherit; font-size: 12px; color: var(--ink); background: #fff; transition: border-color var(--t-fast), box-shadow var(--t-fast); }
.proto-root .jc-cell.mono { font-family: var(--mono); font-size: 11px; }
.proto-root .jc-cell.num { text-align: right; font-family: var(--mono); font-size: 11.5px; }
.proto-root .jc-cell::placeholder { color: var(--ink-4); }
.proto-root .jc-cell:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-wash); outline: none; }
.proto-root .jc-cell.req-empty { border-color: #f3b6b3; background: #fffafa; }
.proto-root .jc-cell.req-empty::placeholder { color: var(--danger); }
/* 备注这类长文本用文本域:能换行、随内容长高,不裁字 */
.proto-root textarea.jc-cell { display: block; resize: vertical; overflow: hidden; min-height: 31px; line-height: 1.55; }
/* 编辑态的整表提示带 */
.proto-root .jc-editbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); border: 1px solid var(--brand-soft); font-size: 11.5px; color: var(--brand-deep); }
.proto-root .jc-editbar svg { flex: none; }
.proto-root .jc-editbar .sp { flex: 1 1 auto; }
/* 剔除行说明 */
.proto-root .jc-drop { display: flex; align-items: flex-start; gap: 8px; margin-top: 10px; padding: 9px 12px; border-radius: 9px; border: 1px dashed var(--line); background: var(--soft); font-size: 11.5px; color: var(--ink-3); line-height: 1.6; }
.proto-root .jc-drop svg { flex: none; margin-top: 2px; color: var(--ink-4); }
.proto-root .jc-drop code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
.proto-root .jc-callout { display: flex; gap: 8px; align-items: flex-start; border-radius: 9px; padding: 11px 13px; font-size: 12.5px; line-height: 1.75; }
.proto-root .jc-callout p { margin: 0; }
.proto-root .jc-callout svg { flex: none; margin-top: 2px; }
.proto-root .jc-callout.t-mute { background: var(--soft); color: var(--ink-2) }
@media (prefers-reduced-motion: reduce) {
.proto-root .jc-pulse-dot { animation: none; }
.proto-root .jc-report, .proto-root .jc-rp-stamp { animation: none; }
}
/* ══════════════════════════════════════════════════════════════════════════
响应式:宁可另起一行,也不损失完整度
≥961px 六列全展开的表(列 min-width 之和 878px,容器有余量,绝不裁字、绝不横滚)
≤960px 表 → 卡片:一条 PN 一张卡、一行一字段(标签 / 值),无横滚、无省略
≤560px 字段行再拆成上下两行(标签在上、值在下)
══════════════════════════════════════════════════════════════════════════ */
@media (max-width: 1040px) { .proto-root .jc-four { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 560px) { .proto-root .jc-four { grid-template-columns: 1fr; } }
@media (max-width: 960px) {
.proto-root .jc-tbl-wrap { overflow: visible; border: 0; border-radius: 0; background: transparent; }
.proto-root .jc-tbl-in,
.proto-root .jc-tbl-in tbody,
.proto-root .jc-tbl-in tr,
.proto-root .jc-tbl-in td { display: block; width: 100%; }
.proto-root .jc-tbl-in thead { display: none; }
/* 一条 PN = 一张卡 */
.proto-root .jc-tbl-in tr { margin-bottom: 10px; padding: 4px 13px 9px; border: 1px solid var(--line-soft); border-radius: 11px; background: #fff; box-shadow: var(--card-shadow); }
.proto-root .jc-tbl-in tr:last-child { margin-bottom: 0; }
.proto-root .jc-tbl-in tbody tr:hover td { background: transparent; }
.proto-root .jc-tbl-in tr.miss { border-color: #f3b6b3; background: #fffbfb; }
/* 卡头:第 N 条 */
.proto-root .jc-tbl-in td.jc-rn { padding: 8px 0 8px; margin-bottom: 4px; border-bottom: 1px solid var(--line-soft); font-family: inherit; font-size: 12px; font-weight: 800; color: var(--ink-2); }
.proto-root .jc-tbl-in td.jc-rn::before { content: "第 "; }
.proto-root .jc-tbl-in td.jc-rn::after { content: " 条"; }
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); }
/* 字段行:左标签 + 右值,值超长自动折行 */
.proto-root .jc-tbl-in td:not(.jc-rn) { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 10px; align-items: center; padding: 6px 0; border-bottom: 1px dashed var(--line-soft); }
.proto-root .jc-tbl-in tr td:last-child { border-bottom: 0; padding-bottom: 2px; }
.proto-root .jc-tbl-in td:not(.jc-rn)::before {
content: attr(data-h); font-size: 10.5px; font-weight: 700; letter-spacing: .02em; color: var(--ink-4); line-height: 1.5;
}
.proto-root .jc-tbl-in td[data-req="1"]:not(.jc-rn)::before { content: attr(data-h) " *"; }
.proto-root .jc-tbl-in .jc-vcell { padding: 0; overflow-wrap: anywhere; }
.proto-root .jc-tbl-in .jc-vcell.num { text-align: left; }
.proto-root .jc-tbl-in .jc-cell.num { text-align: left; }
.proto-root .jc-tbl-in .jc-cell { min-width: 0; }
}
/* 窄屏:落款三件各占一行,动作按钮撑满一行 */
@media (max-width: 560px) {
.proto-root .jc-rp-acts { margin-left: 0; width: 100%; flex-wrap: wrap; }
.proto-root .jc-rp-acts .jc-btn { flex: 1 1 auto; justify-content: center; }
.proto-root .jc-hintline { flex: 1 1 100%; }
.proto-root .jc-tbl-in td:not(.jc-rn) { grid-template-columns: 1fr; gap: 3px; align-items: start; }
}
/* ══ 以下取自 A1-询价受理记录.html(归档件独有的件)══ */
/* ── 料号分类:分布条 + 图例 ───────────────────────────────────────── */
.proto-root .jc-dist { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 13px 15px; border: 1px solid var(--line-soft); border-radius: 12px; background: linear-gradient(180deg, #fbfcfe, #fff); }
.proto-root .jc-distbar { flex: 1 1 320px; min-width: 260px; display: flex; gap: 3px; }
.proto-root .jc-distbar > i { height: 30px; border-radius: 4px; display: grid; place-items: center; font-size: 10.5px; font-weight: 800; color: #fff; font-family: var(--mono); }
.proto-root .jc-distbar > i.a { background: var(--ok); }
.proto-root .jc-distbar > i.b { background: var(--brand); }
.proto-root .jc-distbar > i.c { background: var(--warn-accent); }
.proto-root .jc-distlg { display: flex; flex-wrap: wrap; gap: 8px 18px; }
.proto-root .jc-distlg > span { display: inline-flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--ink-3); }
.proto-root .jc-distlg em { width: 10px; height: 10px; border-radius: 3px; }
.proto-root .jc-distlg em.a { background: var(--ok); } .proto-root .jc-distlg em.b { background: var(--brand); } .proto-root .jc-distlg em.c { background: var(--warn-accent); }
.proto-root .jc-distlg b { font-family: var(--mono); font-weight: 800; color: var(--ink); }
/* 类别徽 A / B / C */
.proto-root .jc-tbadge { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 6px; font-family: var(--mono); font-size: 11px; font-weight: 800; line-height: 1; color: #fff; }
.proto-root .jc-tbadge.a { background: var(--ok); } .proto-root .jc-tbadge.b { background: var(--brand); } .proto-root .jc-tbadge.c { background: var(--warn-accent); }
/* ── 结论与去向 ─────────────────────────────────────────────────────────
G2 是**分支网关**:三条互斥条件按序求值,只命中一条、只落一个活动。
所以这里不是三张并列的去向卡,而是一张**判定表 + 一条结论**。 */
/* 判定表:一行一条规则,命中行上色留痕(DMN 口径) */
.proto-root .jc-dmn { border: 1px solid var(--line-soft); border-radius: 12px; overflow: hidden; }
.proto-root .jc-dmn-h, .proto-root .jc-dmn-r { display: grid; grid-template-columns: 40px minmax(0, 1fr) 170px 200px; align-items: center; gap: 12px; padding: 9px 14px; }
.proto-root .jc-dmn-h { background: var(--soft); border-bottom: 1px solid var(--line-soft); font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); }
.proto-root .jc-dmn-r { font-size: 12px; color: var(--ink-4); background: #fff; }
.proto-root .jc-dmn-r + .jc-dmn-r { border-top: 1px solid var(--line-soft); }
.proto-root .jc-dmn-i { font-family: var(--mono); font-size: 11px; font-weight: 800; }
.proto-root .jc-dmn-c { line-height: 1.6; overflow-wrap: anywhere; }
.proto-root .jc-dmn-t { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; }
.proto-root .jc-dmn-t code { font-family: var(--mono); font-size: 10.5px; font-weight: 800; color: var(--ink-4); background: var(--soft); border: 1px solid var(--line); border-radius: 5px; padding: 2px 6px; }
.proto-root .jc-dmn-v { font-size: 11px; line-height: 1.55; }
/* 命中行:上色 + 左缘棱 + 加粗 */
.proto-root .jc-dmn-r.hit { position: relative; background: var(--tone-soft); color: var(--ink-2); }
.proto-root .jc-dmn-r.hit::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--tone); }
.proto-root .jc-dmn-r.hit .jc-dmn-i { color: var(--tone); }
.proto-root .jc-dmn-r.hit .jc-dmn-c { color: var(--ink); }
.proto-root .jc-dmn-r.hit .jc-dmn-c b { color: var(--tone); font-weight: 800; }
.proto-root .jc-dmn-r.hit .jc-dmn-t { color: var(--ink); }
.proto-root .jc-dmn-r.hit .jc-dmn-t code { color: #fff; background: var(--tone); border-color: var(--tone); }
.proto-root .jc-dmn-v.y { display: inline-flex; align-items: center; gap: 5px; font-weight: 800; color: var(--tone); }
.proto-root .jc-dmn-v.n { color: var(--ink-4); }
/* 色调随命中类别 */
.proto-root .jc-dmn.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.10); }
.proto-root .jc-dmn.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.09); }
.proto-root .jc-dmn.k-c { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.11); }
/* 结论横幅:命中条件 → 唯一去向 */
.proto-root .jc-verdict {
position: relative; overflow: hidden; display: flex; align-items: stretch; flex-wrap: wrap; gap: 16px;
margin-top: 11px; padding: 16px 18px; border: 1px solid var(--tone-line); border-radius: 13px;
background: linear-gradient(105deg, var(--tone-soft), #fff 62%);
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 10px 24px -16px rgba(31,35,41,.4);
--tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.14); --tone-line: #ffd9a8;
}
.proto-root .jc-verdict.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.13); --tone-line: #bfedb4; }
.proto-root .jc-verdict.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft); }
.proto-root .jc-verdict::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 5px; background: var(--tone); }
.proto-root .jc-verdict > * { position: relative; z-index: 1; }
.proto-root .jc-vd-l { flex: 1 1 300px; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
.proto-root .jc-vd-k { font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
.proto-root .jc-vd-cond { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 15px; font-weight: 800; color: var(--ink); }
.proto-root .jc-vd-why { margin: 0; font-size: 11.5px; line-height: 1.7; color: var(--ink-3); overflow-wrap: anywhere; }
.proto-root .jc-vd-why code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
.proto-root .jc-vd-arrow { flex: none; align-self: center; color: var(--tone); }
.proto-root .jc-vd-r { flex: 1 1 260px; min-width: 0; display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-radius: 11px; background: #fff; border: 1px solid var(--tone-line); }
.proto-root .jc-vd-node { display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 800; color: var(--ink); }
.proto-root .jc-vd-node code { font-family: var(--mono); font-size: 12px; font-weight: 800; color: #fff; background: var(--tone); border-radius: 6px; padding: 3px 8px; }
.proto-root .jc-vd-who { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--ink-2); }
.proto-root .jc-vd-who > i { width: 20px; height: 20px; border-radius: 999px; display: inline-grid; place-items: center; font-style: normal; font-size: 10px; font-weight: 700; color: #fff; background: var(--tone); }
.proto-root .jc-vd-next { margin: 0; padding-top: 8px; border-top: 1px dashed var(--line-soft); font-size: 10.5px; line-height: 1.65; color: var(--ink-4); }
.proto-root .jc-vd-next b { color: var(--ink-2); font-weight: 700; }
/* 命中分支携带的条目 */
.proto-root .jc-pnlist { display: flex; flex-wrap: wrap; gap: 5px; margin: 0; padding: 0; list-style: none; }
.proto-root .jc-pnlist > li { display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 999px; background: #fff; border: 1px solid var(--tone-line); font-size: 11px; color: var(--ink-2); overflow-wrap: anywhere; }
.proto-root .jc-pnlist > li > i { flex: none; width: 5px; height: 5px; border-radius: 999px; background: var(--tone); }
.proto-root .jc-pnlist code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
/* ── 落地动作 ───────────────────────────────────────────────────────── */
.proto-root .jc-acts { display: flex; flex-direction: column; gap: 0; border: 1px solid var(--line-soft); border-radius: 11px; overflow: hidden; }
.proto-root .jc-act { display: flex; align-items: flex-start; gap: 9px; padding: 10px 13px; font-size: 12px; color: var(--ink-2); line-height: 1.6; background: #fff; }
.proto-root .jc-act + .jc-act { border-top: 1px solid var(--line-soft); }
.proto-root .jc-act > i { flex: none; width: 18px; height: 18px; margin-top: 1px; border-radius: 999px; display: inline-grid; place-items: center; color: #fff; background: var(--ok); }
.proto-root .jc-act b { font-weight: 700; color: var(--ink); }
.proto-root .jc-act span { color: var(--ink-4); }
.proto-root .jc-act code { font-family: var(--mono); font-size: 10.5px; color: var(--brand-deep); background: var(--brand-wash); border-radius: 4px; padding: 1px 5px; }
.proto-root .jc-done-tag { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; flex: none; padding: 6px 13px; border-radius: 999px; font-size: 11.5px; font-weight: 800; color: var(--ok); background: #fff; border: 1px solid #bfedb4; }
.proto-root .jc-vcell.dim { color: var(--ink-4); }
.proto-root .jc-tbl-in td.jc-ty { width: 44px; text-align: center; }
.proto-root .jc-tbl-in td.jc-ty { width: auto; text-align: left; }
`;
/**
* 图标精灵 —— 与样式表同一出处,两份目标件的 `<symbol>` 取并集。
* 沙箱里取不到图标字体,所以图标一律内联;`<use href="#i-xxx">` 引的就是这一坨。
*/
export const PRD_SPRITE = `<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs><symbol id="i-alert" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></symbol><symbol id="i-arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol><symbol id="i-boxes" viewBox="0 0 24 24"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></symbol><symbol id="i-building" viewBox="0 0 24 24"><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/><path d="M10 18h4"/></symbol><symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol><symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol><symbol id="i-file-check" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="m9 15 2 2 4-4"/></symbol><symbol id="i-file-text" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></symbol><symbol id="i-git-branch" viewBox="0 0 24 24"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></symbol><symbol id="i-history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></symbol><symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></symbol><symbol id="i-lock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol><symbol id="i-mail-open" viewBox="0 0 24 24"><path d="M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0z"/><path d="m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10"/></symbol><symbol id="i-pen" viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z"/></symbol><symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol><symbol id="i-sheet" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M8 13h2"/><path d="M14 13h2"/><path d="M8 17h2"/><path d="M14 17h2"/></symbol><symbol id="i-shield-check" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></symbol><symbol id="i-user" viewBox="0 0 24 24"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/></symbol><symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></symbol></defs></svg>`;
+524 -19
View File
@@ -1,25 +1,530 @@
/**
* A3 视图模版 —— 案例全景(AM 07:loop 按案例模型的**阶段与里程碑**组织汇报)。
* A3 寻源询价的 HTML 报告模版 —— **一张件贯穿始终**:目标报价清单。
*
* 画面素材全部来自语义引擎:阶段的生命周期态、当下可为(Enabled 集)、目标缺口。
* 这些是算出来的确定事实,零 LLM 即可成屏 —— 模型只决定「下一步做什么」,不负责画面。
* 在办(PRM 补货源价时)与归档(放行后进工作成果)是**同一份 HTML**,
* 差别只在外层给不给编辑态(`html.editable`)。版面语言沿用 A1 从 PRD 目标件
* 机械抽取的那套(`prd-style.ts` 原样拷入)—— A3 没有自己的 PRD 目标件
* (2026-08-02 用户定:沿用 A1 版面语言自造)。
*
* 取值路径三纪律照 A1:顶层只取汇总列与单值列;逐条走 `mine:quote_sourcing.quote_list`;
* 列名逐字照 `declarations.ledger` 抄。模版不算不判 —— 缺价红带、价源徽、分布条
* 全部由基座算好的数经 `data-class-of` 投影,判断留在样式表。
*/
import { kvCard, noteCard, stagesCard, viewShell } from '@sdk';
import { PRD_CSS, PRD_SPRITE } from './prd-style.ts';
export const SOURCING_CASE = viewShell({
title: '渠道寻源 · 案例全景',
kicker: 'A3 · 案例活动(loop)',
tone: 'case',
/* ==================== 绑定期补的样式(照 A1 的 BIND_CSS,地址换成本活动)==================== */
const BIND_CSS = `
/* 空格子自己说话 —— 值真的空才轮到(基座 data-blank) */
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after,
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after {
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
}
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after { content: "必填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after { content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line); }
/* 归档件里空格子就是「没有」,不再摆「你还能填」的胶囊 */
.proto-root .jc-tbl-in td.dimempty > .jc-vcell:empty::after { content: "—"; color: var(--ink-4); border: 0; background: none; padding: 0; }
/* 缺基准价的整行浅红 + 行首红条 —— 原型的 tr.miss,这里由 :has() 认 */
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td { background: #fffbfb; }
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty):hover td { background: #fff5f5; }
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn { color: var(--danger); font-weight: 800; position: relative; }
@media (min-width: 1001px) {
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn::after {
content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger);
}
}
/* 序号由 CSS 计数器排 */
.proto-root .jc-tbl-in tbody { counter-reset: rn; }
.proto-root .jc-tbl-in td.jc-rn::before { counter-increment: rn; content: counter(rn); }
@media (max-width: 1000px) {
.proto-root .jc-tbl-in td.jc-rn::before { content: "第 " counter(rn) " 条"; }
.proto-root .jc-tbl-in td.jc-rn::after { content: none; }
}
/* 就地改:可改格子渲了只读与输入两份,默认锁着,表上加 on 才换孪生件 */
.proto-root .jc-cell { display: none; }
.proto-root .jc-tbl-in.on td > .jc-vcell,
.proto-root .jc-tbl-in.on td > .jc-tbadge { display: none; }
.proto-root .jc-tbl-in.on td > .jc-cell { display: block; }
/* 类与价源两枚徽不进编辑态(它们是承继与引擎产物),on 时也照旧摆着 */
.proto-root .jc-tbl-in.on td > .jc-obadge, .proto-root .jc-tbl-in.on td > .jc-pbadge { display: inline-flex; }
/* 数值列与表头同侧左对齐 */
.proto-root .jc-vcell.num, .proto-root .jc-cell.num { text-align: left; }
/* 编辑态下必填空格子:红框 + 整行浅红(由 :placeholder-shown 认) */
.proto-root .jc-tbl-in.on td.req > .jc-cell:placeholder-shown { border-color: #f3b6b3; background: #fffafa; }
.proto-root .jc-tbl-in.on tbody tr:has(td.req > .jc-cell:placeholder-shown) td { background: #fffbfb; }
/* 铺满工作区 */
.jc-stage { max-width: none; padding: 0 0 4px; }
.proto-root .jc-report { border: 0; border-radius: 0; box-shadow: none; }
/* 那道闸 —— 默认「已锁定 + 编辑」,开了变成「取消 / 保存」;只在可改屏现身 */
.proto-root .jc-editgate { display: none; }
html.editable .proto-root .jc-editgate {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
margin-bottom: 10px; padding: 8px 12px; border-radius: 9px;
background: var(--soft); border: 1px solid var(--line-soft);
font-size: 11.5px; color: var(--ink-3);
}
.proto-root .jc-editgate-t { display: inline-flex; align-items: center; gap: 6px; flex: 1 1 auto; min-width: 0; }
.proto-root .jc-editgate-t b { color: var(--ink-2); font-weight: 700; }
.proto-root .jc-egbtn {
flex: none; display: inline-flex; align-items: center; gap: 4px; cursor: pointer;
border: 1px solid var(--line); border-radius: 7px; padding: 5px 11px;
font: inherit; font-size: 11.5px; font-weight: 700; color: var(--ink-2); background: #fff;
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
}
.proto-root .jc-egbtn:hover { border-color: var(--brand); color: var(--brand); }
.proto-root .jc-egbtn.save { color: #fff; background: var(--brand); border-color: var(--brand); }
.proto-root .jc-egbtn.save:hover { background: var(--brand-deep); border-color: var(--brand-deep); }
.proto-root .jc-editgate .jc-egbtn.ghost, .proto-root .jc-editgate .jc-egbtn.save { display: none; }
.proto-root .jc-editgate.on { background: var(--brand-wash); border-color: var(--brand-soft); color: var(--brand-deep); }
.proto-root .jc-editgate.on .jc-egbtn.open { display: none; }
.proto-root .jc-editgate.on .jc-egbtn.ghost, .proto-root .jc-editgate.on .jc-egbtn.save { display: inline-flex; }
.proto-root .jc-editgate.on .jc-editgate-t { visibility: hidden; }
/* 缺价红带 —— 只在可改屏出,一格不缺自动收(v-0 那一档 display:none) */
.proto-root .jc-gapbar { display: none; }
html.editable .proto-root .jc-gapbar {
display: flex; align-items: flex-start; gap: 8px;
margin: 0 0 10px; padding: 9px 12px; border-radius: 9px;
background: #fff5f5; border: 1px solid #f3b6b3; color: var(--danger);
font-size: 12px; line-height: 1.65;
}
.proto-root .jc-gapbar svg { flex: 0 0 auto; margin-top: 2px; }
.proto-root .jc-gapbar b { font-weight: 800; }
.proto-root .jc-gapbar span { color: #9a3b36; }
html.editable .proto-root .jc-gapbar.v-0 { display: none; }
/* 那条「怎么改」的提示只在开了闸之后说 */
.proto-root .jc-editbar { display: none; }
.proto-root .jc-editgate.on + .jc-editbar { display: flex; }
/* 增删行 */
.proto-root .jc-rowdel { display: none; }
.proto-root .jc-tbl-in.on td.jc-rn::before { content: none; }
.proto-root .jc-tbl-in.on .jc-rowdel {
display: block; width: 20px; height: 20px; margin: 0 auto; cursor: pointer;
border: 1px solid var(--line); border-radius: 6px; padding: 0;
font: inherit; font-size: 13px; line-height: 1; color: var(--ink-3); background: #fff;
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
}
.proto-root .jc-tbl-in.on .jc-rowdel:hover { border-color: var(--danger); color: var(--danger); background: #fff6f6; }
.proto-root .jc-tbl-in.on tr.jc-row-gone td { background: #fff6f6; opacity: .55; }
.proto-root .jc-tbl-in.on tr.jc-row-gone .jc-cell { text-decoration: line-through; color: var(--ink-3); }
.proto-root .jc-tbl-in.on tr.jc-row-gone .jc-rowdel { border-color: var(--danger); color: var(--danger); background: #fff; }
.proto-root .jc-tbl-in.on tr.jc-row-new td { background: #f2fbf7; }
.proto-root .jc-addrow { display: none; }
html.editable .proto-root .jc-tbl-wrap:has(table.on) + .jc-addrow {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin: -2px 0 12px; padding: 7px 12px; border-radius: 9px;
background: var(--soft); border: 1px dashed var(--line);
}
.proto-root .jc-addrow em { font-style: normal; font-size: 11px; color: var(--ink-3); }
/* 按值上色 */
.proto-root .jc-tbadge { background: var(--ink-4); }
.proto-root .jc-tbadge.v-a { background: var(--ok); }
.proto-root .jc-tbadge.v-b { background: var(--brand); }
.proto-root .jc-tbadge.v-c { background: var(--warn-accent); }
.proto-root .jc-segmeter > i.v-a { background: var(--ok); }
.proto-root .jc-segmeter > i.v-b { background: var(--brand); }
.proto-root .jc-segmeter > i.v-c { background: var(--warn-accent); }
/* 价源徽:历史价绿(库内既有事实)/ 货源价蓝(本单问回来的) */
.proto-root .jc-pbadge {
display: inline-flex; align-items: center; justify-content: center;
min-width: 44px; height: 20px; padding: 0 7px; border-radius: 6px;
font-size: 10px; font-weight: 800; letter-spacing: .02em;
color: var(--ink-4); border: 1.5px solid var(--ink-4); background: #fff;
}
.proto-root .jc-pbadge.v-hist { color: var(--ok); border-color: var(--ok); }
.proto-root .jc-pbadge.v-sourced { color: var(--brand); border-color: var(--brand); }
/* 价源徽的字也由 class 出 —— 值(HIST/SOURCED)是机器码,人读名交给样式表 */
.proto-root .jc-pbadge::after { content: "待定"; }
.proto-root .jc-pbadge.v-hist::after { content: "历史价"; }
.proto-root .jc-pbadge.v-sourced::after { content: "货源价"; }
/* 分布条:两段按条数分宽 —— 条数投影成 v-<N>,梯子取宽;零条那段自己消失 */
.proto-root .jc-distbar > i { flex: 24; min-width: 34px; display: inline-flex; align-items: center; justify-content: center; }
.proto-root .jc-distbar > i.v-0, .proto-root .jc-distbar > i:not([class*=" v-"]) { display: none; }
.proto-root .jc-distbar > i.v-1 { flex: 1 } .proto-root .jc-distbar > i.v-2 { flex: 2 }
.proto-root .jc-distbar > i.v-3 { flex: 3 } .proto-root .jc-distbar > i.v-4 { flex: 4 }
.proto-root .jc-distbar > i.v-5 { flex: 5 } .proto-root .jc-distbar > i.v-6 { flex: 6 }
.proto-root .jc-distbar > i.v-7 { flex: 7 } .proto-root .jc-distbar > i.v-8 { flex: 8 }
.proto-root .jc-distbar > i.v-9 { flex: 9 } .proto-root .jc-distbar > i.v-10 { flex: 10 }
.proto-root .jc-distbar > i.v-11 { flex: 11 } .proto-root .jc-distbar > i.v-12 { flex: 12 }
.proto-root .jc-distbar > i.v-13 { flex: 13 } .proto-root .jc-distbar > i.v-14 { flex: 14 }
.proto-root .jc-distbar > i.v-15 { flex: 15 } .proto-root .jc-distbar > i.v-16 { flex: 16 }
.proto-root .jc-distbar > i.v-17 { flex: 17 } .proto-root .jc-distbar > i.v-18 { flex: 18 }
.proto-root .jc-distbar > i.v-19 { flex: 19 } .proto-root .jc-distbar > i.v-20 { flex: 20 }
.proto-root .jc-distbar > i.h { background: var(--ok); }
.proto-root .jc-distbar > i.s { background: var(--brand); }
.proto-root .jc-distbar > i.h::before { content: "历史价 · "; }
.proto-root .jc-distbar > i.s::before { content: "货源价 · "; }
.proto-root .jc-logo svg, .proto-root .jc-face svg, .proto-root .jc-rp-ava svg { width: 17px; height: 17px; }
/* ── 询价档案卡(工作单独有,照 A2 选型档案卡的骨架)──────────────────────
一颗料一张卡,data-rows 逐行复制;形态按类别分档(data-class-of):
v-b / v-c = 待询价,整卡摊开;v-a = 已按历史价预填,收成一行 —— 判断在样式表。 */
.proto-root .jc-selcards { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
.proto-root .jc-selcard { border: 1px solid var(--line); border-radius: 11px; background: #fff; overflow: hidden; }
.proto-root .jc-selcard.v-b, .proto-root .jc-selcard.v-c { border-color: var(--brand-soft); box-shadow: 0 1px 0 rgba(51,112,255,.06); }
.proto-root .jc-selcard-h {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 9px 14px; background: var(--soft); border-bottom: 1px solid var(--line-soft);
}
.proto-root .jc-selcard.v-b .jc-selcard-h, .proto-root .jc-selcard.v-c .jc-selcard-h { background: var(--brand-wash); border-bottom-color: var(--brand-soft); }
.proto-root .jc-selcard-h code { font-size: 13px; font-weight: 800; color: var(--ink); }
.proto-root .jc-selcard-h .why { flex: 1 1 auto; min-width: 0; font-size: 11px; color: var(--ink-3); }
.proto-root .jc-selcard-b { display: none; }
.proto-root .jc-selcard.v-b .jc-selcard-b, .proto-root .jc-selcard.v-c .jc-selcard-b { display: grid; grid-template-columns: 1fr 1fr; gap: 0; }
@media (max-width: 1000px) { .proto-root .jc-selcard.v-b .jc-selcard-b, .proto-root .jc-selcard.v-c .jc-selcard-b { grid-template-columns: 1fr; } }
.proto-root .jc-selcol { padding: 11px 14px; }
.proto-root .jc-selcol + .jc-selcol { border-left: 1px solid var(--line-soft); }
@media (max-width: 1000px) { .proto-root .jc-selcol + .jc-selcol { border-left: 0; border-top: 1px solid var(--line-soft); } }
.proto-root .jc-selcol h5 { margin: 0 0 7px; font-size: 10.5px; font-weight: 800; letter-spacing: .08em; color: var(--ink-3); }
.proto-root .jc-kvrow { display: flex; gap: 8px; padding: 3px 0; font-size: 12px; line-height: 1.55; }
.proto-root .jc-kvrow i { flex: 0 0 84px; font-style: normal; color: var(--ink-4); font-size: 11px; padding-top: 1px; }
.proto-root .jc-kvrow span { flex: 1 1 auto; min-width: 0; color: var(--ink); overflow-wrap: anywhere; }
.proto-root .jc-kvrow span:empty::after { content: "—"; color: var(--ink-4); }
.proto-root .jc-selcard.v-b .sc-req:empty::after, .proto-root .jc-selcard.v-c .sc-req:empty::after {
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
content: "待填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3;
}
.proto-root .jc-selcard .sc-opt:empty::after {
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line);
}
.proto-root .jc-selcard-skip { display: none; }
.proto-root .jc-selcard.v-a .jc-selcard-skip { display: block; padding: 0 14px 9px; font-size: 11px; color: var(--ink-4); }
.proto-root .jc-selcard.v-a .jc-selcard-h { border-bottom: 0; padding-bottom: 4px; }
/* 场景规则表(静态 —— 尺挂在墙上;场景徽是模版文字不是数据) */
.proto-root .jc-graderule { border: 1px solid var(--line); border-radius: 10px; overflow: hidden; margin-bottom: 12px; }
.proto-root .jc-graderule-r { display: flex; gap: 12px; align-items: flex-start; padding: 9px 13px; font-size: 12px; line-height: 1.6; }
.proto-root .jc-graderule-r + .jc-graderule-r { border-top: 1px solid var(--line-soft); }
.proto-root .jc-graderule-r p { margin: 0; color: var(--ink-2); }
.proto-root .jc-graderule-r p b { color: var(--ink); }
.proto-root .jc-scene {
flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; margin-top: 1px;
min-width: 52px; height: 20px; padding: 0 8px; border-radius: 6px;
font-size: 10.5px; font-weight: 800; letter-spacing: .04em; border: 1.5px solid; background: #fff;
}
.proto-root .jc-scene.early { color: #0f766e; border-color: #0f766e; }
.proto-root .jc-scene.late { color: var(--brand); border-color: var(--brand); }
.proto-root .jc-scene.spot { color: var(--warn-accent); border-color: var(--warn-accent); }
.proto-root .jc-scene.sample { color: var(--ok); border-color: var(--ok); }
`;
/** 一枚图标 —— 与原型同一写法 */
const ico = (name: string, size = 11): string =>
`<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-${name}"/></svg>`;
/** 三层壳照原型:少一层样式表全失效 */
const doc = (input: { badge: string; kicker: string; title: string; titleRef: string; state: string; body: string; foot: string }): string =>
`<style>${PRD_CSS}${BIND_CSS}</style>
${PRD_SPRITE}
<div class="proto-root"><div class="app-shell"><div class="jc-stage">
<article class="jc-report">
<header class="jc-rp-head">
<div class="jc-rp-badge">${ico(input.badge, 17)}</div>
<div class="jc-rp-tit">
<em>${input.kicker}</em>
<h3>${input.title} <span data-slot="${input.titleRef}"></span></h3>
</div>
${input.state}
</header>
<div class="jc-rp-bd">${input.body}</div>
<footer class="jc-rp-foot">${input.foot}</footer>
</article>
</div></div></div>`;
const part = (label: string, aside = '', tone = ''): string =>
`<div class="jc-rp-part"><b>${label}</b><i class="ln"></i>${aside === '' ? '' : `<em class="${tone}">${aside}</em>`}</div>`;
const sign = (note: string, tag = ''): string =>
`<div class="jc-rp-sign">
<span class="jc-rp-ava">${ico('user', 15)}</span>
<div><b data-slot="case:owner"></b><span>${note}</span></div>
</div>${tag === '' ? '' : `<span class="jc-done-tag">${ico('git-branch', 13)}${tag}</span>`}`;
/* ==================== 组成件 ==================== */
/** 要素四卡:客户 / 待补价量 / 一格一料的清单刻度 / 取价的尺 */
const FOUR = `<div class="jc-four">
<div class="jc-fcard">
<span class="jc-fcard-k"><i>${ico('building')}</i>客户公司</span>
<div class="jc-fcard-b">
<span class="jc-logo">${ico('building', 17)}</span>
<div class="jc-fcard-t"><b data-slot="customer_name" data-blank></b><span data-slot="customer_code" data-blank></span></div>
</div>
<span class="jc-fcard-note">承自上游;单飞起单时此栏可空</span>
</div>
<div class="jc-fcard t-teal">
<span class="jc-fcard-k"><i>${ico('boxes')}</i>待补价</span>
<div class="jc-count"><b data-slot="quote_sourcing_quote_list_missing"></b><span>/ <b data-slot="quote_line_total"></b> 行清单</span></div>
<span class="jc-fcard-note">底价还空着的行 —— 本单要问价的对象;补一格少一格,基座现数</span>
</div>
<div class="jc-fcard">
<span class="jc-fcard-k"><i>${ico('history')}</i>清单分类(承继)</span>
<div class="jc-segmeter" data-rows="mine:quote_sourcing.quote_list">
<i data-class-of="type_tag"></i>
</div>
<span class="jc-fcard-note">一格一条料 · A 绿 B 蓝按上游现标着色;类别本单不改</span>
</div>
<div class="jc-fcard t-ok">
<span class="jc-fcard-k"><i>${ico('shield-check')}</i>取价的尺</span>
<span class="jc-tagbig">${ico('check', 13)}<span>A 取历史价 · B 问货源价</span></span>
<span class="jc-fcard-note">A 类按库内历史价预填;B 类由采购系统外询价补回 —— 价源逐行留徽,可溯</span>
</div>
</div>`;
/** 缺价红带 —— 与提交闸、放行门 L1 同一个数同一套算法(基座现数);一格不缺自动收 */
const GAP_BAR = `<div class="jc-gapbar" data-class-of="quote_sourcing_quote_list_missing">${ico('alert', 14)}
<span><b>还差 <span data-slot="quote_sourcing_quote_list_missing"></span> 行基准价没补</b>
—— <span data-slot="quote_sourcing_quote_list_missing_keys"></span>。
按右上角「编辑」开表,红框那几格就是要填的;问不到价的行按行首 × 撤掉并在备注写明。
补齐了执行单才提交得了。</span>
</div>`;
const editGate = (address: string, what: string): string => `<div class="jc-editgate" data-edit-for="${address}">
<span class="jc-editgate-t">${ico('lock', 12)}${what}<b>已锁定</b> —— 看的时候不误碰,要改按右边</span>
<button type="button" class="jc-egbtn open" data-edit-open="${address}">${ico('pen', 12)}编辑</button>
<button type="button" class="jc-egbtn ghost" data-edit-cancel="${address}">取消</button>
<button type="button" class="jc-egbtn save" data-edit-save="${address}">${ico('check', 12)}保存</button>
</div>
<div class="jc-editbar">${ico('pen', 14)}
<span>底价空着的行是本单要补的:<b>供应商 / 底价 / MOQ / 货源交期 / 备注</b>逐格可填;
A 类预填的历史价也能改 —— 改了以你的为准。问不到价的行按行首 × 撤掉(剔出本轮)。
改动只落任务草稿层,<b>保存不等于放行</b>。</span>
</div>`;
const addRowBar = (address: string): string => `<div class="jc-addrow">
<button type="button" class="jc-egbtn ghost" data-edit-add="${address}">${ico('plus', 12)}加一行</button>
<em>照末行的样子添一行空的;主键(型号 PN)填了才存得下,不填这一行不落库。</em>
</div>`;
/**
* 清单主表 —— 一行一料:承继列 + 价格五列 + 类与价源两枚徽。
* `data-col` 只挂人可改的五列(supplier / base_price / moq / source_lead_time / note)——
* 编辑面精确等于 PRM 的职权面。底价格挂 `req`:空着出「必填」胶囊、整行标底,
* 与提交闸、放行门同源(都数 `_missing`)。
*/
const ROSTER = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
<thead><tr>
<th style="width:36px;min-width:36px"></th>
<th style="width:44px;min-width:44px">类</th>
<th style="min-width:170px">PN</th>
<th style="min-width:130px">替代料 PN</th>
<th style="min-width:84px">品类</th>
<th style="min-width:72px">年用量</th>
<th style="min-width:72px">目标价</th>
<th style="min-width:120px">供应商</th>
<th class="req" style="min-width:80px">底价</th>
<th style="min-width:64px">MOQ</th>
<th style="min-width:84px">货源交期</th>
<th style="width:64px;min-width:64px">价源</th>
<th style="min-width:150px;width:100%">备注</th>
</tr></thead>
<tbody data-rows="mine:quote_sourcing.quote_list" data-edit="quote_sourcing.quote_list"><tr>
<td class="jc-rn"><button type="button" class="jc-rowdel" data-edit-del="quote_sourcing.quote_list" title="撤掉这一行">×</button></td>
<td class="jc-ty" data-h="类"><span class="jc-tbadge" data-class-of="type_tag" data-slot="type_tag"></span></td>
<td data-h="PN"><span class="jc-vcell mono" data-slot="pn" data-col="pn" data-blank></span></td>
<td class="dimempty" data-h="替代料 PN"><span class="jc-vcell mono" data-slot="alt_pn" data-blank></span></td>
<td class="dimempty" data-h="品类"><span class="jc-vcell" data-slot="category" data-blank></span></td>
<td class="dimempty" data-h="年用量"><span class="jc-vcell mono" data-slot="eau" data-blank></span></td>
<td class="dimempty" data-h="目标价"><span class="jc-vcell num" data-slot="target_price" data-blank></span></td>
<td class="opt" data-h="供应商"><span class="jc-vcell" data-slot="supplier" data-col="supplier" data-blank></span></td>
<td class="req" data-h="底价"><span class="jc-vcell num" data-slot="base_price" data-col="base_price" data-blank></span></td>
<td class="opt" data-h="MOQ"><span class="jc-vcell mono" data-slot="moq" data-col="moq" data-blank></span></td>
<td class="opt" data-h="货源交期"><span class="jc-vcell" data-slot="source_lead_time" data-col="source_lead_time" data-blank></span></td>
<td data-h="价源"><span class="jc-pbadge" data-class-of="price_basis"></span></td>
<td class="opt" data-h="备注"><span class="jc-vcell" data-slot="note" data-col="note" data-blank></span></td>
</tr></tbody>
</table></div>`;
/** 价源分布:两段按条数分宽 —— 历史价 / 货源价,守恒等式的可视形 */
const DIST = `<div class="jc-dist">
<div class="jc-distbar">
<i class="h" data-class-of="quote_sourcing_quote_list_price_basis_hist" data-slot="quote_sourcing_quote_list_price_basis_hist"></i>
<i class="s" data-class-of="quote_sourcing_quote_list_price_basis_sourced" data-slot="quote_sourcing_quote_list_price_basis_sourced"></i>
</div>
<div class="jc-distlg">
<span><em class="a"></em>历史价 · 库内既有 <b data-slot="quote_sourcing_quote_list_price_basis_hist"></b></span>
<span><em class="b"></em>货源价 · 本单问回 <b data-slot="quote_sourcing_quote_list_price_basis_sourced"></b></span>
</div>
</div>`;
/* ==================== 那份件:目标报价清单(在办与归档同一张)==================== */
export const SOURCING_ROSTER = doc({
badge: 'sheet',
kicker: '统一报价 · A3 寻源询价 · 交付成果',
title: '目标报价清单',
titleRef: 'rfq_ref',
state:
'<span class="jc-rp-wait">清单 <b data-slot="quote_sourcing_quote_list_rows"></b> 条 · 待补价 <b data-slot="quote_sourcing_quote_list_missing"></b></span>',
body:
kvCard('本单标的', [
['型号 PN', 'pn', 'mono'],
['场景', 'scenario'],
['当前阶段', 'activeStage', 'tag'],
['当下可为', '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,系统只保证边框 —— 预算、停滞熔断、放行归人。'),
part('寻源要素', '寻源询价 · MVP 口径:询价在系统外,价格落表内') +
FOUR +
part('目标报价清单', '<b data-slot="quote_line_total"></b> 条 · 每行必有基准价') +
GAP_BAR +
editGate('quote_sourcing.quote_list', '目标报价清单') +
ROSTER +
addRowBar('quote_sourcing.quote_list') +
`<div class="jc-drop">${ico('info', 13)}
<span>带 * 的<b>底价</b>是必填:每一行都要有基准价 —— <b>A 类</b>取库内历史价(系统预填,可校可改),
<b>B 类</b>由采购系统外询价补回。价源那一列逐行留徽:绿「历史价」是库内既有事实,
蓝「货源价」是本单问回来的 —— 两段合起来恰等于总行数,对不上就是账没记齐。
<b>问不到的价不许编</b>:剔出本轮(行首 ×)或按内部估价填并在备注写明。</span>
</div>` +
part('价源分布', '历史价 + 货源价 = 总行数') +
DIST +
part('放行后即执行的落地动作', '门前 → 门后') +
`<div class="jc-acts">
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>草稿层合并进正式版</b>&#12288;<span>本任务写下的目标报价清单与寻源结论一并落账;
PRM 补价与就地修订的那几处随之落定。</span></div>
</div>
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>清单此刻才对外发出</b>&#12288;<span>整份 <b data-slot="quote_sourcing_quote_list_rows"></b> 行
(每行带基准价与价源)交出 —— 放行之前,世界上什么都没有发生。</span></div>
</div>
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>转入成本定价</b>&#12288;<span>本活动只有一个出口 —— 目标报价清单是定价环节的直接输入,
已进对方的任务清单。</span></div>
</div>
</div>`,
foot: sign('PRM 询价 · AI 归一齐检 —— 要改哪一格,在这张单的清单表上就地改', '统一报价 · A3 寻源询价 → 交定价'),
});
/* ==================== 局面一 · 寻源询价工作单(归一预填与 PRM 补价期间)==================== */
/**
* 逐颗询价档案 —— 一颗料一张卡(data-rows 逐行复制)。
* 形态按类别分档:B/C 摊开成「题面 + 定案回显」两栏;A 收成一行「已按历史价预填」。
*
* **容器用 `<section>` 不用 `<div>`**:渲染器的行展开按第一个同名闭合标签截行内容,
* 行模版里嵌着 `<div>` 时 `<div data-rows>` 会把行截成套娃(A2 逮过,记在陷阱表)。
*/
const INQ_CARDS = `<section class="jc-selcards" data-rows="mine:quote_sourcing.quote_list">
<article class="jc-selcard" data-class-of="type_tag">
<div class="jc-selcard-h">
<span class="jc-tbadge" data-class-of="type_tag" data-slot="type_tag"></span>
<code data-slot="pn"></code>
<span class="why" data-slot="remark" data-blank></span>
</div>
<div class="jc-selcard-b">
<div class="jc-selcol">
<h5>询价题面(要素与口径)</h5>
<div class="jc-kvrow"><i>取价对象</i><span data-slot="alt_pn" data-blank></span></div>
<div class="jc-kvrow"><i>品类</i><span data-slot="category" data-blank></span></div>
<div class="jc-kvrow"><i>制造商</i><span data-slot="brand" data-blank></span></div>
<div class="jc-kvrow"><i>客户料号</i><span data-slot="customer_pn" data-blank></span></div>
<div class="jc-kvrow"><i>年用量</i><span data-slot="eau" data-blank></span></div>
<div class="jc-kvrow"><i>目标价</i><span data-slot="target_price" data-blank></span></div>
<div class="jc-kvrow"><i>交期要求</i><span data-slot="lead_time" data-blank></span></div>
<div class="jc-kvrow"><i>客户约束</i><span data-slot="remark" data-blank></span></div>
</div>
<div class="jc-selcol">
<h5>问价定案(填回下表,这里回显)</h5>
<div class="jc-kvrow"><i>供应商</i><span class="sc-req" data-slot="supplier" data-blank></span></div>
<div class="jc-kvrow"><i>底价</i><span class="sc-req num" data-slot="base_price" data-blank></span></div>
<div class="jc-kvrow"><i>MOQ</i><span class="sc-opt" data-slot="moq" data-blank></span></div>
<div class="jc-kvrow"><i>货源交期</i><span class="sc-opt" data-slot="source_lead_time" data-blank></span></div>
<div class="jc-kvrow"><i>备注</i><span class="sc-opt" data-slot="note" data-blank></span></div>
</div>
</div>
<div class="jc-selcard-skip">已按库内历史价预填基准 · 可校可改;取价对象为空即原型号本身。</div>
</article>
</section>`;
/** 按场景圈渠道 —— 素材:MVP 示例工作流 §1.7 选渠定策(只作文字参考,尺挂在墙上) */
const CHANNEL_RULES = `<div class="jc-graderule">
<div class="jc-graderule-r">
<span class="jc-scene early">早期</span>
<p><b>BOM 早期:渠道可放宽。</b>系统带出的在供供应商直接圈入即可 —— 这一段要的是快,
价格验证也宽(首个报价即用,品牌价差两成内算正常)。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-scene late">后期</span>
<p><b>BOM 后期:必须靠谱。</b>按主供应商 → 备选 → 贸易商的顺序圈两三家,<b>贸易商尽量不用</b>;
核实授权代理资质与授权期限,贸易商核查货源链可靠性。默认两轮:首轮回价内部核过再发次轮。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-scene spot">现货</span>
<p><b>现货:只认有库存、可提供仓库实拍的供应商。</b>无库存转单商不可选 ——
转一手就是一手风险,货期与成色都不由你控。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-scene sample">样品</span>
<p><b>样品:量小求快。</b>渠道可放宽,低于线上商城价即可用;库内不足线上补。</p>
</div>
</div>`;
/** 价格验证的尺 —— 素材:MVP 示例工作流 §1.7 发询比价(只作文字参考) */
const VERIFY_RULES = `<div class="jc-graderule">
<div class="jc-graderule-r">
<span class="jc-scene early">早期</span>
<p>首个报价即用;品牌之间价差两成以内算正常,不必追平。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-scene late">后期</span>
<p>按历史最低价降两成去谈;两轮核价,首轮回价内部核过再谈次轮。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-scene spot">现货</span>
<p>多重校验:低于线上商城价、比过区域市场价、比过订货价(高出两成以内可接受)。</p>
</div>
<div class="jc-graderule-r">
<span class="jc-scene sample">样品</span>
<p>低于线上商城价即用 —— 样品要的是拿得到,不是省到底。</p>
</div>
</div>`;
export const SOURCING_WORKSHEET = doc({
badge: 'mail-open',
kicker: '统一报价 · A3 寻源询价 · 询价作业',
title: '寻源询价工作单',
titleRef: 'rfq_ref',
state:
'<span class="jc-rp-wait warn">待补价 <b data-slot="quote_sourcing_quote_list_missing"></b> / <b data-slot="quote_line_total"></b> 行</span>',
body:
part('询价任务', '客户 <b data-slot="customer_name" data-blank></b> · 待补价 <b data-slot="pending_total"></b> 颗 · A 类已预填 <b data-slot="hist_priced_total"></b> 颗') +
`<div class="jc-derive">${ico('info', 14)}
<span>下面每颗料一张<b>询价档案卡</b>:左边是题面(要素、取价对象与客户约束),右边回显你的定案。
蓝标 B 的料是本单要问价的对象;绿标 A 已按库内历史价预填基准,可校可改。
询价在系统外做(发函、催回价、比价都在你手上),<b>定案落回本单</b> —— 供应商、底价、
MOQ、货源交期逐颗可溯。发询纪律:同一料所有渠道一次发齐,PN / 数量 / 目标价 / 回价时限
写死在询价里;回价登记一一对应不可缺项;不实回价或远低行情的换渠道补询,别直接采用。</span>
</div>` +
part('逐颗询价档案', '题面在左 · 定案在右') +
INQ_CARDS +
part('按场景圈渠道', 'BOM早期 / BOM后期 / 现货 / 样品 —— 场景定尺度') +
CHANNEL_RULES +
part('价格验证的尺', '按场景核过再采') +
VERIFY_RULES +
`<div class="jc-drop">${ico('info', 13)}
<span><b>时限参考</b>:BOM早期 / 现货 1~4 日,样品 1~3 日,BOM后期 3~9 日 —— 急料电话催办;
回价临过期先复核确认,过期触发重询。历史价未满三次询价但有效期已过的,标记须重询。</span>
</div>` +
part('填回工作表', '定案落在这 —— 补齐了执行单才提交得了') +
GAP_BAR +
editGate('quote_sourcing.quote_list', '寻源询价填回表') +
ROSTER +
addRowBar('quote_sourcing.quote_list') +
`<div class="jc-drop">${ico('info', 13)}
<span>带 * 的<b>底价</b>是必填(未税单价按内部口径归一)—— 缺一行执行单按不下提交,
与放行门是同一个数同一套算法。<b>问不到的价不许编</b>:剔出本轮(行首 ×)
或按内部估价填并在备注写明。</span>
</div>`,
foot: sign('PRM 询价作业 · 定案落表,齐检定盘由系统接着办', '统一报价 · A3 寻源询价 · 在办'),
});
@@ -6,7 +6,8 @@ export { ActivityNotInstalledError, InstallRejectedError, ToolNotDeclaredError }
export type { Clock, IdGenerator, LedgerDeclarationApplier, ModelBindingProbe, RegistryStore, TemplateGuard, ToolContractProbe, } from './port.ts';
export type { RegistryServiceDeps } from './internal/usecase/registry-service.ts';
export { createRegistryService } from './internal/usecase/registry-service.ts';
export { acceptanceLevelFindings, auditPassed, danglingListeners, loopFindings, scanArtifact, staticAudit, } from './internal/domain/static-audit.ts';
export type { AcceptanceAuditInput, DeliverableAuditInput } from './internal/domain/static-audit.ts';
export { acceptanceLevelFindings, deliverableAddressFindings, ledgerKindFindings, auditPassed, danglingListeners, loopFindings, scanArtifact, staticAudit, } from './internal/domain/static-audit.ts';
export { ASSET_KINDS, assertTransition, buildLoadEnvelope, canTransition, IllegalIncrementTransitionError, INCREMENT_STATES, judgeReplay, mayTakeEffect, shouldSeedAsset, } from './internal/domain/versioning.ts';
export { createRegistryStore } from './internal/adapter/registry-store.ts';
export { activityAssetTable, activityPackageTable, assetIncrementTable } from './internal/adapter/registry-schema.ts';
+33 -1
View File
@@ -54,6 +54,8 @@ export type IssuedChatCard = {
};
export type ReplyOutcome = {
readonly order: WorkOrder;
/** 这张卡挂在哪一单 —— 装配层要拿它把「人就地改的那几行」写回草稿层 */
readonly taskId: TaskId;
/** 完成只出自放行通过 —— 这里为真,任务才可收敛终态 */
readonly producesCompletion: boolean;
/** 驳回:环内回转,任务继续 */
@@ -78,9 +80,39 @@ export type HumanToolService = {
}): Promise<IssuedWorkOrder>;
issueChatCard(context: IssueContext, card: ChatCard): Promise<IssuedChatCard>;
replyWorkOrder(reply: WorkOrderReply, by: StaffId): Promise<ReplyOutcome>;
replyChatCard(reply: ChatCardReply, by: StaffId): Promise<ChatCard>;
/**
* 回传带上 `taskId` —— **数据录入卡的确认要当场写草稿**,而写库要知道写给哪一单。
* 由本模块自己写会让 human-tool 依赖 ledger(越层),所以把事实交出去,由组装层落笔。
*/
replyChatCard(reply: ChatCardReply, by: StaffId): Promise<{
card: ChatCard;
taskId: TaskId;
}>;
/** 驳回重答:新卡实例,原卡闭合 —— 只增不改 */
rejectAndReask(cardId: string, reason: string): Promise<IssuedChatCard>;
/**
* 给已发出的验收工单补上选择组(AM 1.2 恰选一口)。
*
* 为什么分两步:卡由 `release.submitForRelease` 发 —— 它守的是预检那道门,
* 不该也不必知道「这个活动有哪些出口」。出口是契约的事实,由 app 从 manifest 现读后贴上。
*/
attachPortChoice(workOrderId: string, choice: {
group: string;
ports: ReadonlyArray<{
name: string;
label?: string | undefined;
}>;
}): Promise<void>;
/**
* **算出来的去向**上卡 —— 不给人选,只告诉他走哪一口、凭哪一条。
* 去向是台账那几个数的函数;摆成选择题,等于把可回放的判定换成一次手感。
*/
attachRoutedPort(workOrderId: string, routed: {
group: string;
name: string;
label?: string | undefined;
because: string;
}): Promise<void>;
openWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
/** 全部工单(含已办结)—— 工作成果要回看定稿的报告,只给 open 就永远是空的 */
allWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
+2
View File
@@ -17,6 +17,8 @@ export type CardStore = {
} | undefined>;
listWorkOrders(taskId: TaskId, openOnly: boolean): Promise<WorkOrder[]>;
markWorkOrderDone(id: string, done: NonNullable<WorkOrder['done']>): Promise<void>;
/** 局部改卡面 —— 只增不改是对**已回传**的卡;未答的卡还可补齐契约事实(如选择组) */
patchWorkOrder(id: string, patch: Partial<WorkOrder>): Promise<void>;
saveChatCard(taskId: TaskId, card: ChatCard, recipientId: StaffId): Promise<void>;
getChatCard(id: string): Promise<{
taskId: TaskId;
+20 -1
View File
@@ -50,17 +50,36 @@ export type InteractionService = {
/** 视图流控:活动按局面推结构化块 */
pushView(taskId: TaskId, blocks: readonly ViewBlock[]): Promise<void>;
/** 视图流控:活动推 HTML 模版 —— 过净化门,不过即抛 */
pushViewHtml(taskId: TaskId, html: string): Promise<void>;
/**
* 推一帧 HTML。`template` **要一起存下来** —— 件是投影,而投影得能按当下数据**重渲**。
*
* 只存渲染好的 HTML,那一帧就成了快照:人在件上改完几格,由数据算出来的东西
* (类徽 A/B/C、分布条、结论横幅那条去向)全停在渲染那一刻 ——
* 屏上一半新一半旧,那不是没更新,是界面在撒谎。记住模版名,才重得出来。
*/
pushViewHtml(taskId: TaskId, html: string, template?: string): Promise<void>;
/** 视图区当前呈现什么 —— 只读投影,刷新即重建(前端零过程状态) */
viewOf(taskId: TaskId): Promise<{
blocks?: readonly ViewBlock[];
html?: string;
template?: string;
} | undefined>;
/** 控制权转交 —— 随引擎的激活 / 完成事件自动发生 */
setPhase(taskId: TaskId, phase: InteractionPhase): Promise<ControlHolder>;
phaseOf(taskId: TaskId): Promise<InteractionPhase>;
/** SSE:单连接、按信封目标失效缓存 */
subscribe(staffId: StaffId, onPush: (envelope: PushEnvelope) => void): Subscription;
/**
* **系统级转接**:装配层把所有推送接出去(转给前端的 SSE 广播),不按人筛。
*
* 为什么不复用 `subscribe`:那个要一个收件人,而转接是系统的事 ——
* 为它编一个假员工 id 是撒谎。不按人筛也不漏:推送**不带业务载荷**
* (只说「哪个任务动了」),谁该看见由前端自己那几把查询决定(§9.2 前端零过程状态)。
*
* 没有这个口子的后果实测过:AI 说的每一句、推的每一帧画面都广播给了空气 ——
* `subscribe` 一个订阅者都没有,前端只好靠人点刷新。
*/
onPush(listener: (envelope: PushEnvelope) => void): Subscription;
/** 供上层广播失效信号 */
invalidate(target: 'task' | 'workflow' | 'desk', id: string): void;
};
+2
View File
@@ -16,10 +16,12 @@ export type InteractionStore = {
putView(taskId: TaskId, view: {
blocks?: readonly ViewBlock[];
html?: string;
template?: string;
}): Promise<void>;
getView(taskId: TaskId): Promise<{
blocks?: readonly ViewBlock[];
html?: string;
template?: string;
} | undefined>;
setPhase(taskId: TaskId, phase: InteractionPhase): Promise<void>;
phaseOf(taskId: TaskId): Promise<InteractionPhase | undefined>;
+22
View File
@@ -51,6 +51,12 @@ export type DraftWrite = {
export type ReleaseWriteResult = {
readonly mergedRows: number;
readonly emittedEvents: number;
/**
* **试跑单被挡下的主数据行数** —— 合到出参里去,是因为界面不撒谎:
* 放行痕上写「合并 N 行」而其中几行根本没进表,那句话就是假的。
* 非试跑单恒为 0。
*/
readonly simulatedRows: number;
/**
* 合并进正式版的那些行(键 + 值)。
* 「落地收敛,输出口才发射」——发射的载荷就该是**这些已落地的事实**,
@@ -78,6 +84,22 @@ export type LedgerService = {
}>>;
systemWrite(write: SystemWrite): Promise<void>;
draftWrite(write: DraftWrite): Promise<void>;
/**
* **撤掉本单草稿层的一行** —— 写错了得有退路(2026-08-01 加)。
*
* 不是第四种写:它只作用在**本单草稿层**,正式版一个字都动不了,也不抛事件 ——
* 撤的是「我这一单还没过门的那一笔」。放行门的位置没变。
*
* 没有它的后果实测过:客户说本轮跳过的那颗料被写进了有效清单,条目数报 9 而表里躺着 10,
* 判据 `record_rows = line_total` 正确地拦住,而模型**改不动** ——
* 它唯一能改的是那个数(改了 skipped_total 又不对),13 次拒回、23 拍、234k token 才熔断。
* 门拦住一件办错的事却不给改正的手段,那扇门就只是个死胡同。
*/
draftDrop(drop: {
taskId: TaskId;
address: ObjectAddress;
key: Record<string, unknown>;
}): Promise<boolean>;
releaseWrite(taskId: TaskId): Promise<ReleaseWriteResult>;
/** 正式版查询 —— 跨任务只见过门的事实 */
query(query: LedgerQuery): Promise<LedgerPage>;
+32 -1
View File
@@ -1,7 +1,7 @@
/**
* module: ledger —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
*/
import type { EnvelopeDraft, LedgerKey, LedgerQuery, LedgerRow } from '@contracts';
import type { EnvelopeDraft, LedgerKey, LedgerQuery, LedgerRow, LedgerObjectKind } from '@contracts';
import type { MaterializedObject, SchemaChange } from './internal/domain/schema-merge.ts';
import type { DraftEntry } from './internal/domain/task-view.ts';
export type Clock = {
@@ -14,6 +14,20 @@ export type IdGenerator = {
export type EventPublisher = {
publish(envelope: EnvelopeDraft): Promise<void>;
};
/**
* 这一单是不是**试跑** —— app 注入(设计台的两条试跑路径都算,见 `container.ts`)。
*
* 台账在 L1,认不出「谁起的单」;而放行写要不要落主数据,取决的恰恰是这件事。
* 所以由 app 把这一个布尔判断喂进来 —— 与 `EventPublisher` 同一个套路:
* 模块只声明「我需要知道什么」,不声明「去哪儿问」。
*
* 为什么只挡主数据、不挡单据:单据按单标识分行,一次试跑写一行自己的,
* 清运行数据就没了;主数据是**按业务实体**分行的档案,试跑往里建一位客户,
* 「新客户」那条样例就此再也试不出来 —— 它成了老客户。
*/
export type SimulationProbe = {
isSimulated(taskId: string): Promise<boolean>;
};
export type ObjectRef = {
readonly domain: string;
readonly object: string;
@@ -26,9 +40,18 @@ export type LedgerStore = {
/** 已物化对象的列与主键;未物化返回 undefined */
describe(ref: ObjectRef): Promise<MaterializedObject | undefined>;
/** 出参带人读名(表注释);没登记就没有 label,呈现退回对象键 */
/** 记下对象性质(单据/主数据/派生)与单标识 —— 随声明走,不随建表走 */
setObjectMeta(ref: ObjectRef, kind: LedgerObjectKind, caseKey: string | undefined): Promise<void>;
listObjects(): Promise<Array<ObjectRef & {
label?: string | undefined;
}>>;
/**
* 哪些对象是**主数据** —— 放行写要据此把试跑单的主数据挡在正式表外。
*
* 单独一个方法而不是让调用方 `listObjects` 自己筛:性质记在 `__object_meta` 里,
* 一次 where 就够;`describe` 读的是 information_schema,那里没有性质这一维。
*/
masterRefs(): Promise<ReadonlyArray<ObjectRef>>;
/** 执行「只增不破坏」的变更计划 */
applyChanges(ref: ObjectRef, changes: readonly SchemaChange[]): Promise<void>;
select(query: LedgerQuery): Promise<{
@@ -46,5 +69,13 @@ export type DraftStore = {
list(taskId: string): Promise<Array<DraftEntry & ObjectRef>>;
listForObject(taskId: string, ref: ObjectRef): Promise<DraftEntry[]>;
put(taskId: string, ref: ObjectRef, entry: DraftEntry): Promise<void>;
/**
* 撤掉本单草稿层的**一行** —— 写错了得有退路(2026-08-01 加)。
*
* 只动 `taskId` 那一份,正式版碰不到。没有它的后果实测过:客户说本轮跳过的那颗料
* 被写进了有效清单,条目数报 9 而表里躺着 10 —— 判据正确地拦住,而模型改不动
* (唯一能改的是那个数,改了别的判据又不对),13 次拒回、23 拍、234k token 才熔断。
*/
drop(taskId: string, ref: ObjectRef, key: LedgerKey): Promise<void>;
clear(taskId: string): Promise<void>;
};
+6
View File
@@ -31,6 +31,12 @@ export type ModelCall = {
readonly prompt: string;
readonly temperature?: number;
readonly maxOutputTokens?: number;
/**
* 压推理:`off` 不许想、`low` 少想;缺省由着模型。
* 只对画像里声明了 `reasoningControl` 的厂家生效 —— 没姿势的厂家如实忽略。
* 规划型调用(loop 单拍)该关:隐藏思考按秒计,而它的判断本就交给了判定表与工具。
*/
readonly reasoning?: 'off' | 'low';
/** 出参 schema(JSON Schema);给定即要求结构化出参并校验 */
readonly outputSchema?: Record<string, unknown>;
/** 录制/回放的定位:任务 × 步序号(§7.3) */
+229 -2
View File
@@ -8,7 +8,8 @@ import type { LoopModel, TaskId } from '@contracts';
import type { CaseModel, PlanItem, PlanItemType } from './internal/domain/cmmn-parse.ts';
import type { CaseRuntime, CaseState, EnabledEntry, EnabledSet, ItemRuntime, ItemState, Navigation, TerminalAuthority } from './internal/domain/lifecycle.ts';
import type { DecisionResult, DecisionTrace, Gap } from './internal/domain/dmn.ts';
export type { CaseModel, CaseRuntime, CaseState, DecisionResult, DecisionTrace, EnabledEntry, EnabledSet, Gap, ItemRuntime, ItemState, LoopModel, Navigation, PlanItem, PlanItemType, TerminalAuthority, };
import type { PhaseProgress } from './internal/domain/phase.ts';
export type { CaseModel, CaseRuntime, CaseState, DecisionResult, DecisionTrace, EnabledEntry, EnabledSet, Gap, ItemRuntime, ItemState, LoopModel, Navigation, PhaseProgress, PlanItem, PlanItemType, TerminalAuthority, };
export { CmmnParseError } from './internal/domain/cmmn-parse.ts';
export { IllegalTransitionError } from './internal/domain/lifecycle.ts';
export declare class ForbiddenTerminalError extends Error {
@@ -28,6 +29,48 @@ export type AgentAction =
readonly tool: string;
readonly input: Record<string, unknown>;
}
/**
* 写台账草稿 —— **活动过程写的唯一形态**(铁律②:登记那一刻世界上什么都没变)。
*
* 一拍可写多行:逐条把一份 BOM 解析成几十行,是**一件事**,不该拆成几十拍
* 各烧一次模型。地址必须在活动清单声明过的台账对象之内(越域写与越权调工具同罪)。
*/
| {
readonly kind: 'write';
readonly itemId: string;
/**
* 台账地址「域.对象」—— **一个字符串,不是两个字段**。
*
* 系统上下都用这一种写法(可写台账字典、`requiredDeliverables[].ledger` 都是它),
* 拆成 domain + object 只会让模型把整个地址塞进其中一格 —— 实测第一拍就这么错。
*/
readonly address: string;
readonly rows: ReadonlyArray<{
readonly key: Record<string, string | number>;
readonly patch: Record<string, unknown>;
}>;
}
/**
* **撤掉自己写错的那一行** —— 写错了得有退路(2026-08-01 加)。
*
* 只作用在**本单草稿层**:正式版一个字都动不了,也不抛事件 ——
* 撤的是「我这一单还没过门的那一笔」,放行门的位置没变。
*
* 起因是实测的一次死胡同:客户明说本轮跳过的那颗料被写进了有效清单,
* 条目数报 9(对)而表里躺着 10,判据 `record_rows = line_total` 正确地拦住 ——
* 而模型**改不动**:它唯一能改的是那个数,改了 `skipped_total` 又不对。
* 13 次拒回、23 拍、234k token 才熔断。**门拦住一件办错的事却不给改正的手段,
* 那扇门就只是个死胡同。**
*/
| {
readonly kind: 'drop';
readonly itemId: string;
/** 台账地址「域.对象」—— 与 write 同一种写法 */
readonly address: string;
/** 要撤的那几行的主键 —— 认不出的行如实回「草稿层里没有这一行」 */
readonly keys: ReadonlyArray<Record<string, string | number>>;
readonly reason: string;
}
/** 调人:四卡之一 —— 调人 = 立监听听人 */
| {
readonly kind: 'human';
@@ -35,6 +78,119 @@ export type AgentAction =
readonly card: Record<string, unknown>;
readonly workOrder: 'execution' | 'acceptance';
}
/**
* **发补全卡** —— 对话卡族之一,与执行工单是两种东西。
*
* 「按缺口清单追问补全」(A1-S2)要的就是它:缺口清单摆在对话区、带一张表单,
* 人就地填齐。而执行工单是「派一件活给你办」—— 派一张工单去问几个字段,
* 收卡的人得进待处理、办完再回来,重得不成比例。
*
* 从前 Agent **发不出对话卡**:行动型里只有 `human` 且只能选执行 / 验收两种工单,
* 于是 SOP 上画着「补全卡」的那一步只能拿执行工单顶 —— 形似而神非。
*/
| {
readonly kind: 'ask';
readonly itemId: string;
/** 问什么 —— 一句话说清缺口 */
readonly ask: string;
/**
* 要对方填的格子。`hint` 是格子里的灰字提示;
* **`value` 是预填值** —— AI 从来件里读得出的先替人填上(公司名、联系人姓名),
* 人只需核对一眼、补上填不出的那个,而不是对着一排空格从头敲。
* 读不出来的就别给:填错了人还得先擦掉再写,比空着更费事。
*/
readonly fields: ReadonlyArray<{
readonly key: string;
readonly label: string;
readonly hint?: string;
readonly value?: string;
}>;
/** 依据快照:哪几条缺哪几个字段 —— 调用必附上下文,裸调用拒发 */
readonly basis?: ReadonlyArray<{
readonly k: string;
readonly v: string;
}>;
}
/**
* 发**数据录入卡** —— 人类工具的第五类:**人按下确认那一刻就是写入**。
*
* 与补全卡(`ask`)的分工:那张收的是「答案」,你拿到之后还要自己 `write` 一遍;
* 这张收的是「这一行数据」—— 卡上写明落哪张表、哪个主键,人确认,基座当场落草稿层,
* **你不经手**。建档这类事该用它:主数据建错一次,后面每一单都跟着错,
* 那一笔该由人亲手落下,不该经你转述一遍。
*
* 你的活是**把能填的先填好**(`value`),填不出的留空 —— 界面会标「待填写」。
*/
| {
readonly kind: 'entry';
readonly itemId: string;
readonly ask: string;
/** 落哪张表:`域.对象` */
readonly ledger: string;
/** 主键值 —— 确认时按它定位那一行 */
readonly key: Readonly<Record<string, string>>;
readonly fields: ReadonlyArray<{
readonly key: string;
readonly label: string;
readonly value?: string;
readonly required?: boolean;
}>;
/**
* **同一次确认还要落的另几张表**(2026-08-02)。
*
* 建一位新客户落的是**两条主数据**:公司档 + 联系人档。只把公司那一半摆上卡、
* 联系人由你自己写,等于让人确认了一半 —— 而联系人档的 `contact_code`
* 恰恰是你编出来的那一个。分成两张卡也能办,但那是把一件事拆成两次打断。
*/
readonly also?: ReadonlyArray<{
/** 这一段是什么 —— 卡上那行小标题(如「联系人档案」) */
readonly title: string;
readonly ledger: string;
readonly key: Readonly<Record<string, string>>;
readonly fields: ReadonlyArray<{
readonly key: string;
readonly label: string;
readonly value?: string;
readonly required?: boolean;
}>;
}>;
readonly basis?: ReadonlyArray<{
readonly k: string;
readonly v: string;
}>;
}
/**
* 发**决策卡** —— 对话卡族的第二种:把一个岔路口摆给人,人点一个选项。
*
* 与补全卡的分工:补全卡问的是**事实**(缺哪几个字段,你填);
* 决策卡问的是**处置**(这一步该往哪走,你选)。A1 的
* 「字段没齐 —— 要我系统外去问客户,还是替你草拟一封邮件」正是后者:
* 两条路都合法,选哪条是业务判断,不该由模型替人决定。
*
* 从前决策卡**只有 UI 没有 Agent 侧**:卡面在对话区画得出来,而行动型里发不出它 ——
* 于是所有岔路口都被写成补全卡,人拿到的是一张要自己敲字的表单,而不是两个可点的选项。
*/
| {
readonly kind: 'decide';
readonly itemId: string;
/** 问什么 —— 一句话说清这是个什么岔路 */
readonly ask: string;
/** 选项:一条路一项。`consequence` 写清选了它会发生什么 */
readonly options: ReadonlyArray<{
readonly value: string;
readonly label: string;
readonly detail?: string;
readonly consequence?: string;
readonly recommended?: boolean;
}>;
/** 依据快照:凭什么问这一问 —— 调用必附上下文,裸调用拒发 */
readonly basis?: ReadonlyArray<{
readonly k: string;
readonly v: string;
}>;
/** 允许人给出选项之外的处置(附理由) */
readonly allowCustom?: boolean;
}
/** 立监听:AI 不能调用世界,对世界只有两种合法姿势 —— 触达它(动作)与等它(监听) */
| {
readonly kind: 'listen';
@@ -66,10 +222,38 @@ export type AgentAction =
readonly satisfied: boolean;
readonly rationale: string;
}
/** 判定成果已齐,提交放行 */
/**
* 这一项干完了 —— **完成由 Agent 自己宣告,不是某个动作的副作用**。
*
* 从前 `tool` / `write` 一做完就把计划项收了。可一个计划项常常要好几个动作才交得出成果
* (先逐条写记录、再结算总数;先调工具取判据、再把判据写进台账)——
* 一收就再也动不了它,于是那一项该产出的东西永远缺着,而环在原地打转。
* 实测:20 拍烧掉 120k token,卡在「当下可为 ∩ 目标所需 = 空」。
*
* 宣告完成不等于成果被认可:终局仍唯放行,人验收才算数。
*/
| {
readonly kind: 'finish';
readonly itemId: string;
readonly rationale: string;
}
/**
* 判定成果已齐,提交放行。
*
* `fields` = 验收报告上**让人当场改**的那几个值(PRD:「AI 已填好、可编辑」)——
* `ai` 是活动拟的值,`ledger` 是改完落回哪个台账地址(`域.对象`)。
* 不给 `fields`,验收报告就只能看不能改;给了 `ai` 不给 `ledger`,人改了也只留在
* 工单留痕里,落地用的还是活动的原值 —— 两样都得有,修订才落得下去。
*/
| {
readonly kind: 'submit';
readonly rationale: string;
readonly fields?: ReadonlyArray<{
readonly key: string;
readonly label: string;
readonly ai?: string;
readonly ledger?: string;
}>;
}
/**
* 本回合**已在工具循环里调过人**:卡已发、监听已立(工具总线执行的)。
@@ -81,6 +265,41 @@ export type AgentAction =
readonly cardId: string;
readonly rationale: string;
};
/**
* **对人说的一句话** —— 交代进展,并可顺手换一张局面画面。
*
* 它**不是一型行动,是每一拍的附带** —— 这一点是改过的:一开始把它做成独立行动,
* 结果它占一整拍又不推进局面,预算下的 Agent 理所当然跳过它,对话区照旧空白。
* 让说话与干活抢拍,是把「必须说」设计成了「有空才说」。
*
* 现在:一拍 = 一个行动 + 一句交代。说话不花额外的拍,也就没有不说的理由。
* `view` 是可选的局面模版名 —— 局面变了,人既要听到一句为什么,也要看到新画面。
*/
export type Narration = {
readonly text: string;
/** 语气签:指导 / 判据 / 结论 —— 前端各有其样式 */
readonly tag?: 'guidance' | 'criterion' | 'conclusion';
readonly view?: string;
};
/**
* 一拍的产出:**一串行动**(按序执行)+ 对人交代一句。
*
* 为什么是一串不是一件:一拍只许一个行动时,每个 write、每个 finish、每个 start
* 都单烧一次完整模型调用(说明书 + 轨迹整段重读)。实测 A1 六步的活恰好花 21 拍 ——
* 其中 19 拍是这个约束下的**最短路径**,浪费不在模型跑偏,在架构按拍计费。
* 收成一串后,「写记录、写结算、finish、start 下一项」一口气给,一单 5-8 拍收得了口。
*
* 执行纪律(引擎强制,不靠模型自觉):
* · 按序执行,谁被拒就停在谁那儿 —— 已执行的前缀落笔留痕,理由喂回下一拍;
* · `tool` 之后截断 —— 出参没看见,后面的行动是闭着眼计划的;
* · `ask` / `decide` / `human` / `listen` / `submit` 让本拍挂起 —— 只能是最后一个;
* · 一串封顶 `TURN_ACTION_CAP` 件,超出的直接丢弃(留痕)。
*/
export declare const TURN_ACTION_CAP = 6;
export type AgentTurn = {
readonly actions: readonly AgentAction[];
readonly say?: Narration;
};
export type TurnOutcome = {
readonly runtime: CaseRuntime;
readonly enabled: EnabledSet;
@@ -96,6 +315,14 @@ export type LoopEngineService = {
/** 装载:XML 进语义引擎,说明书进 Agent 上下文;版本全部入信封 */
load(taskId: TaskId, model: LoopModel): Promise<CaseRuntime>;
runtime(taskId: TaskId): Promise<CaseRuntime | undefined>;
/**
* 进度刻度:这一单在第几阶段(见 `internal/domain/phase.ts`)。
*
* 由 loop 出而不是让调用方自己算:阶段声明与计划项状态**同在一行运行时里**,
* 调用方要自己算就得先把模型和运行时都捞出来 —— 那是把内部结构摊给外面。
* 活动没声明阶段就回 `undefined`:界面照此不画进度条,不编一个出来。
*/
phaseOf(taskId: TaskId, terminal: boolean): Promise<PhaseProgress | undefined>;
/** 发布 Enabled 集与红绿灯 —— 引擎推进的终点 */
publish(taskId: TaskId): Promise<EnabledSet>;
/** 跑一个回合:定位 → 择向 → 决断 → 行动 → 写数据 */
+3 -3
View File
@@ -1,13 +1,13 @@
/**
* module: loop-engine —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type { AgentAction, CaseModel, CaseRuntime, CaseState, DecisionResult, DecisionTrace, EnabledEntry, EnabledSet, Gap, ItemRuntime, ItemState, LoopEngineService, LoopModel, Navigation, PlanItem, PlanItemType, TerminalAuthority, TurnOutcome, } from './contract.ts';
export { AutomaticOnWorldTouchingError, CmmnParseError, ForbiddenTerminalError, IllegalTransitionError, } from './contract.ts';
export type { AgentAction, AgentTurn, Narration, CaseModel, CaseRuntime, CaseState, DecisionResult, DecisionTrace, EnabledEntry, EnabledSet, Gap, ItemRuntime, ItemState, LoopEngineService, LoopModel, Navigation, PlanItem, PlanItemType, TerminalAuthority, TurnOutcome, } from './contract.ts';
export { AutomaticOnWorldTouchingError, CmmnParseError, ForbiddenTerminalError, IllegalTransitionError, TURN_ACTION_CAP, } from './contract.ts';
export type { AgentPort, CardGateway, Clock, ListenerRegistrar, LoopStateStore, MemoryRecorder, Sfeel, TaskContext, TaskDataGateway, ToolGateway, } from './port.ts';
export type { TurnRunnerDeps } from './internal/usecase/turn-runner.ts';
export { createTurnRunner } from './internal/usecase/turn-runner.ts';
export { parseCmmn, parseXml, PLAN_ITEM_TYPES } from './internal/domain/cmmn-parse.ts';
export { assertItemTransition, autoCompleteReady, canTransitionCase, canTransitionItem, CASE_STATES, evaluateCriterion, initialRuntime, ITEM_STATES, mayCompleteCase, mayTerminateCase, navigate, occur, publishEnabled, reenter, transitionItem, } from './internal/domain/lifecycle.ts';
export { assertItemTransition, achieveMilestones, autoCompleteReady, canTransitionCase, canTransitionItem, criterionKey, readingsOf, CASE_STATES, evaluateCriterion, initialRuntime, ITEM_STATES, mayCompleteCase, mayTerminateCase, navigate, occur, publishEnabled, reenter, transitionItem, } from './internal/domain/lifecycle.ts';
export { deduceGaps, evaluateTable, evaluationOrder, isBindingConclusion } from './internal/domain/dmn.ts';
export { createLoopStateStore } from './internal/adapter/loop-state-store.ts';
export { loopStateTable } from './internal/adapter/loop-state-schema.ts';
+220 -8
View File
@@ -4,7 +4,7 @@
import type { LoopModel, StaffId, TaskId } from '@contracts';
import type { CaseModel } from './internal/domain/cmmn-parse.ts';
import type { CaseRuntime, EnabledSet } from './internal/domain/lifecycle.ts';
import type { AgentAction } from './contract.ts';
import type { AgentTurn } from './contract.ts';
export type Clock = {
now(): Date;
};
@@ -27,13 +27,53 @@ export type Sfeel = {
/** 任务数据 —— app 注入 `ledger`(L1):引擎对**任务视图**求值,不依赖事件 */
export type TaskDataGateway = {
view(taskId: TaskId): Promise<Record<string, unknown>>;
/**
* 本活动**声明过**的台账对象(域.对象 → 列名与中文标)。
*
* 两用:喂给 Agent(不知道能写哪儿就只会瞎猜列名),以及校验 `write` 行动 ——
* 没声明过的地址一律拒写。越域写与越权调工具同罪,都得在结构上拦住。
*/
writable(taskId: TaskId): Promise<Array<{
domain: string;
object: string;
label: string;
keys: string[];
fields: Array<{
name: string;
label: string;
type?: string;
}>;
}>>;
/**
* 写草稿,并回报**这一笔到底有没有改变什么**。
*
* 值和上次一模一样的写是空转。不回报,Agent 就看不出自己在原地打转 ——
* 实测:同一行 verdict 被反复重写十几拍,每拍烧一次模型,预算就这么淌完。
*/
draftWrite(input: {
taskId: TaskId;
domain: string;
object: string;
key: Record<string, string | number>;
patch: Record<string, unknown>;
}): Promise<void>;
}): Promise<{
changed: boolean;
}>;
/**
* **撤掉本单草稿层的一行** —— 写错了得有退路。
*
* 回 `false` = 草稿层里根本没有这一行(多半是主键写法对不上,
* `DAG…` 与 `DAG…-R0` 是两行)。如实说比静默成功有用:静默成功会让模型
* 以为撤掉了,下一拍判据照旧不过,它又去撤一次。
*/
draftDrop(input: {
taskId: TaskId;
domain: string;
object: string;
key: Record<string, string | number>;
}): Promise<{
dropped: boolean;
}>;
};
/**
* Agent 回路 —— app 注入(Mastra Agent 在 adapter,§6.1)。
@@ -46,7 +86,32 @@ export type AgentPort = {
narrative: string;
enabled: EnabledSet;
scope: Record<string, unknown>;
}): Promise<AgentAction>;
/**
* 这一单能调什么 —— **声明 × 目录**编译出来的工具清单(`compileToolBus`)。
*
* 不给它这份清单,它就只知道说明书里提过的工具名,连入参长什么样都得猜 ——
* 实测的后果是:该调工具的那一步改成了发一张卡问人(「请为以下三个PN查询历史价格」),
* 明明系统里就有这件工具。工具是声明式的,清单得摆在它眼前。
*/
tools: Array<{
id: string;
description: string;
input: Record<string, unknown>;
effect: 'read' | 'write';
}>;
/** 本活动可写的台账地址与列名 —— 不给它字典,它只会照想象编列名 */
writable: Array<{
domain: string;
object: string;
label: string;
keys: string[];
fields: Array<{
name: string;
label: string;
type?: string;
}>;
}>;
}): Promise<AgentTurn>;
};
/** 工具 / 人类工具 / 监听 —— 三型行动的落点 */
export type ToolGateway = {
@@ -61,6 +126,16 @@ export type ToolGateway = {
error?: {
message: string;
};
/**
* 已转人代办,等人回填(§6.2)——「人是工具的一种实现」。
*
* 系统里没有这件工具时,不是失败:基座已开了一张执行卡并立了监听。
* 这一拍该**挂起等人**,不该把计划项判 Failed —— 判 Failed 等于告诉 Agent
* 「这条路走不通,换一条」,可这条路明明正在走,只是走的人换成了人。
*/
awaitingHuman?: {
readonly workOrderId: string;
};
}>;
isWriteEffect(tool: string): Promise<boolean>;
registerIntent(input: {
@@ -69,17 +144,64 @@ export type ToolGateway = {
input: Record<string, unknown>;
label: string;
}): Promise<void>;
/** 这一单能调什么(声明 × 目录)—— 单拍路径与开环路径吃同一份清单 */
busOf(activityId: string): Promise<Array<{
id: string;
description: string;
input: Record<string, unknown>;
effect: 'read' | 'write';
}>>;
};
/**
* 在途验收(提交放行的持久痕):卡发出即记,裁决到达再记 verdict ——
* 判齐重入不再发第二张卡;恢复的那一拍据 verdict 定终局(released 与否)。
* 判齐重入不再发第二张卡。恢复的那一拍按 verdict 分岔:
* `pass` 才是终局(released);**非 pass = 打回重做,不是终局** ——
* 意见(comment)用人话进轨迹、在途验收清掉,同一拍照常问模型,
* 模型改完重新提交,新发的验收卡才是下一次裁决的对象(2026-08-22,
* 从前非 pass 只把任务重新挂成 awaiting-human,人说了「重做」却再没有
* 任何事件叫醒它,一单就此卡死 —— 柴浩实测)。
*/
export type PendingAcceptance = {
readonly acceptanceCardId: string;
readonly verdict?: 'pass' | 'reject';
/** 打回重做时人写的意见 —— 空着就是点了固定按钮没写话 */
readonly comment?: string;
readonly selectedPorts?: Record<string, string>;
};
export type CardGateway = {
/** 数据录入卡 —— 人确认那一下由基座直接写草稿层,不回到 Agent 手里 */
entry(input: {
taskId: TaskId;
ownerId: StaffId;
humanCallCap: number;
ask: string;
ledger: string;
key: Readonly<Record<string, string>>;
fields: ReadonlyArray<{
key: string;
label: string;
value?: string;
required?: boolean;
}>;
/** 同一次确认还要落的另几张表 —— 建一位新客户 = 公司档 + 联系人档 */
also?: ReadonlyArray<{
title: string;
ledger: string;
key: Readonly<Record<string, string>>;
fields: ReadonlyArray<{
key: string;
label: string;
value?: string;
required?: boolean;
}>;
}>;
basis?: ReadonlyArray<{
k: string;
v: string;
}>;
}): Promise<{
id: string;
}>;
issue(input: {
taskId: TaskId;
ownerId: StaffId;
@@ -89,6 +211,56 @@ export type CardGateway = {
}): Promise<{
id: string;
}>;
/**
* 发**补全卡**(对话卡族)—— 缺口清单摆在对话区、带一张表单,人就地填齐。
*
* 与执行工单分开是因为它们是两件事:工单是「派一件活给你办」,
* 派一张工单去问几个字段,收卡的人得进待处理、办完再回来,重得不成比例。
* SOP 上画着「补全卡」的那一步(A1-S2),要的就是这一件。
*/
ask(input: {
taskId: TaskId;
ownerId: StaffId;
humanCallCap: number;
ask: string;
fields: ReadonlyArray<{
key: string;
label: string;
hint?: string;
}>;
basis?: ReadonlyArray<{
k: string;
v: string;
}>;
}): Promise<{
id: string;
}>;
/**
* 发**决策卡**(对话卡族)—— 岔路口摆给人**点一个**,不是让人敲字。
*
* 与补全卡的分工:补全卡问事实(缺哪几个字段,你填);决策卡问处置(往哪走,你选)。
* 两条路都合法、选哪条是业务判断的时候,该发的是这一件。
*/
decide(input: {
taskId: TaskId;
ownerId: StaffId;
humanCallCap: number;
ask: string;
options: ReadonlyArray<{
value: string;
label: string;
detail?: string;
consequence?: string;
recommended?: boolean;
}>;
basis?: ReadonlyArray<{
k: string;
v: string;
}>;
allowCustom?: boolean;
}): Promise<{
id: string;
}>;
};
export type ListenerRegistrar = {
register(input: {
@@ -99,6 +271,18 @@ export type ListenerRegistrar = {
}): Promise<{
id: string;
}>;
/**
* **这个活动声明过哪几种监听** —— 立监听的授权面(2026-08-23 加)。
*
* 与「写的授权面是台账声明」「调工具的授权面是白名单」同一条:没声明过的监听立不得。
* 起因是实测的一次永久挂起(E04):模型在依赖断点上临场立了个 data 监听等回执,
* 而那个活动 `declarations.listeners` 是空的、这一单也不会有任何 data 事件进来 ——
* 单子就此挂在 `awaiting-event` 一动不动。**立了没人叫醒的监听不是等待,是失联。**
*/
declared(taskId: TaskId): Promise<ReadonlyArray<{
eventType: string;
keyFilter: Record<string, string>;
}>>;
};
/**
* 视图流控 —— app 注入 `interaction`(L3)。
@@ -107,12 +291,21 @@ export type ListenerRegistrar = {
* 零 LLM 即可成屏;模型只决定「下一步做什么」,不负责画面。
*/
export type ViewGateway = {
/**
* 推一帧画面。**回报推没推上** —— 从前是 `Promise<void>` 且模版查不到就静默 return:
* 引擎每拍推一个硬编码的 `case-overview`,而 A1 声明的四张模版一个都不叫这个名字,
* 于是**每一帧都被悄悄丢掉**,工作区从头到尾空白,日志里也没有一个字。
* 静默失败比报错坏得多:它让「没画面」看起来像「活动还没产出」。
*/
push(input: {
taskId: TaskId;
activityId: string;
template: string;
scope: Record<string, unknown>;
}): Promise<void>;
}): Promise<{
pushed: boolean;
templates: string[];
}>;
};
/**
* 技能 —— app 注入 `skill-engine`(L4)。
@@ -134,7 +327,7 @@ export type SkillGateway = {
export type MemoryRecorder = {
append(input: {
taskId: TaskId;
kind: 'engine-transition' | 'evaluation' | 'thought' | 'action' | 'result' | 'observation';
kind: 'engine-transition' | 'evaluation' | 'thought' | 'action' | 'result' | 'observation' | 'narration';
title: string;
detail?: Record<string, unknown>;
}): Promise<void>;
@@ -143,10 +336,19 @@ export type MemoryRecorder = {
layer: string;
statement: string;
}>>;
/** 本任务自己的最近轨迹(窗口大小由活动声明的 memoryWindow 定)—— 喂回叙事面,Agent 每拍不失忆 */
window(taskId: TaskId): Promise<Array<{
/**
* 本任务自己的最近轨迹 —— 喂回叙事面,Agent 每拍不失忆。
*
* 不给 `size` 就用活动声明的 `memoryWindow`(叙事面按它给;窗口越大越贵)。
* **熔断检测要另开一个更大的窗**:它数的是「同一堵墙撞了几次」,
* 而一次提交被拒会连带写下交代、评估、推画面好几条 —— 20 条的叙事窗里
* 至多装得下三四次撞墙,于是撞十次也数不到 5(实测:同一条判据拒回 10 次没熔断,
* 另一单拒回 33 次,40 万 token 烧到 exhausted 才停)。
*/
window(taskId: TaskId, size?: number): Promise<Array<{
kind: string;
title: string;
detail?: Record<string, unknown> | undefined;
}>>;
};
export type TaskContext = {
@@ -155,4 +357,14 @@ export type TaskContext = {
ownerId: StaffId;
humanCallCap: number;
}>;
/**
* 这一单的**机检判据名**(归一化后的 key 与 label 都在里头)。
*
* 给 `judge` 行动当门用:L1/L2 是拿 S-FEEL 对台账求值的,Agent 裁不动它 ——
* 判据投影压根不读 Agent 对机检项的裁定。可从前引擎照收不误,还往轨迹里
* 写一句「语义性前提评估:XXX → 满足」:模型于是以为这条已经过了,
* 转头就 submit,被门以同一条判据拒回;再裁、再拒,连拒五拍熔断(实测 A2 死于此)。
* 轨迹里那句「满足」是**假话** —— 界面不撒谎,对 Agent 也一样。
*/
machineCriteria(taskId: TaskId): Promise<ReadonlySet<string>>;
};
+5
View File
@@ -28,6 +28,11 @@ export type SubmitForReleaseInput = {
readonly criteria: readonly CriterionCheck[];
/** 报告可修订:AI 拟值已填的表单字段 */
readonly fields?: ReadonlyArray<Record<string, unknown>>;
/**
* 验收页的正文块 —— 与执行工单同一套件,可含**绑台账的可编辑表**。
* 验收是最重的一道门:人得能在成果本身上改,不只是改几个整单级字段。
*/
readonly blocks?: ReadonlyArray<Record<string, unknown>>;
};
export type SubmitOutcome = {
readonly report: PrecheckReport;
+17
View File
@@ -23,6 +23,8 @@ export type LedgerGateway = {
releaseWrite(taskId: TaskId): Promise<{
mergedRows: number;
emittedEvents: number;
/** 试跑单被挡在正式表外的主数据行数(非试跑恒为 0)—— 放行痕要如实说 */
simulatedRows: number;
/** 合并进正式版的行 —— 输出口发射的载荷取自这里 */
merged: ReadonlyArray<{
domain: string;
@@ -79,12 +81,27 @@ export type CardGateway = {
label: string;
level: 'L1' | 'L2' | 'L3' | 'L4' | 'L5';
pass: boolean;
settled?: boolean | undefined;
rationale?: string | undefined;
}>;
actions: ReadonlyArray<{
label: string;
effect: 'read' | 'write';
detail: string;
}>;
/**
* 可修订字段 —— 验收报告「AI 已填好、可编辑」改的就是它们(带 `ai` 拟值与 `ledger` 落回地址)。
* **端口不声明它,值就在装配层被丢掉**:上游一路传下来,而 TypeScript 对条件展开
* 不做多余属性检查,于是编译不报、运行时静默丢 —— 卡面 `fields` 恒空(踩过)。
*/
fields?: ReadonlyArray<Record<string, unknown>>;
/**
* 验收页的正文块 —— 绑台账的可编辑成果表走这里。
* **端口不声明它,值就在装配层被丢掉**(与 `fields` 同一个坑,又踩了一次:
* 契约声明了、submit 传了、release 转了,到装配层那个显式对象里被抹平,
* 编译不报、运行时静默丢 —— 卡面 blocks 恒空)。
*/
blocks?: ReadonlyArray<Record<string, unknown>>;
}): Promise<{
id: string;
}>;
+3
View File
@@ -32,6 +32,8 @@ export type Task = {
/** 起单来源:工作流节点;登记触发的直起单为 undefined */
readonly workflowId?: string;
readonly nodeId?: string;
/** `testbed` = 设计台试跑 —— 认得出才拔得掉(仿真不该在别人工作台上过夜) */
readonly origin?: string;
readonly ownerId: StaffId;
readonly title: string;
readonly runtime: TaskRuntimeState;
@@ -52,6 +54,7 @@ export type StartTaskInput = {
readonly title: string;
readonly workflowId?: string;
readonly nodeId?: string;
readonly origin?: string;
/** 输入:data(按 schema 校验,缺项不得启动)+ message(委托消息,可为空) */
readonly data: Record<string, unknown>;
readonly message?: string;
+29 -2
View File
@@ -26,10 +26,34 @@ export type ActivityLoader = {
envelope: Record<string, unknown>;
/** 输入 data 的 JSON Schema —— 缺项不得启动 */
inputSchema: Record<string, unknown>;
/**
* 这一单是被一封信叫起来的 —— 活动清单声明的**来件字段映射**。
* 声明了,起单那一刻就把来件归进沟通记录(工作台那一屏)。
*/
inboundComm?: {
channel: string;
partyField: string;
subjectField?: string | undefined;
bodyField?: string | undefined;
attachmentsField?: string | undefined;
};
businessModel: unknown;
stallAfter: string;
}>;
};
/** 沟通记录 —— 一单往来的原件存档(工作台「沟通记录」那一屏读它) */
export type CommRecorder = {
record(input: {
taskId: TaskId;
direction: 'in' | 'out';
channel: string;
party: string;
subject: string;
body: string;
attachments: readonly unknown[];
note: string;
}): Promise<void>;
};
export type SchemaValidator = {
validate(schema: Record<string, unknown>, value: unknown): {
ok: boolean;
@@ -76,8 +100,11 @@ export type LandingGateway = {
export type MemoryRecorder = {
append(input: {
taskId: TaskId;
/** control = 治理干预(叫停 / 恢复 / 改派):人对过程的裁断,归人裁决侧 */
kind: 'engine-transition' | 'terminal' | 'observation' | 'control';
/**
* control = 治理干预(叫停 / 恢复 / 改派):人对过程的裁断,归人裁决侧。
* trigger = 触发事件:这一单被什么叫起来的 —— 不是机器产出,也不是谁的动作。
*/
kind: 'engine-transition' | 'terminal' | 'observation' | 'control' | 'trigger';
title: string;
detail?: Record<string, unknown>;
}): Promise<void>;
+5 -3
View File
@@ -3,15 +3,17 @@
*/
export type { ActionIntent, AuditFinding, CallDisposition, EffectLevel, EvolutionViolation, InvocationTrace, InvokerMode, Preauthorization, RegisterToolInput, RegistrationVerdict, ToolCall, ToolContract, ToolInvoker, ToolResult, ToolboxService, } from './contract.ts';
export { INVOKER_MODES, RegistrationRejectedError, ToolDisabledError, ToolNotAllowedError, ToolNotFoundError, ToolReplayMissError, } from './contract.ts';
export type { Clock, IdGenerator, SchemaValidator, SecretBox, ToolRegistry, ToolReplayStore, ToolRuntime, } from './port.ts';
export type { Clock, IdGenerator, SchemaValidator, SecretBox, ToolHandoff, ToolHandoffStore, ToolRegistry, ToolReplayStore, ToolRuntime, } from './port.ts';
export type { ToolInvokerDeps } from './internal/usecase/tool-invoker.ts';
export { createToolInvoker, toolReplayKey } from './internal/usecase/tool-invoker.ts';
export type { ToolboxServiceDeps } from './internal/usecase/toolbox-service.ts';
export { createToolboxService } from './internal/usecase/toolbox-service.ts';
export { checkEvolution, dispositionOf, EFFECT_LEVEL_LABEL, EFFECT_LEVELS } from './internal/domain/tool-contract.ts';
export type { BusTool, CatalogEntry, ToolBusInput, ToolBusReport } from './internal/domain/tool-bus.ts';
export { compileToolBus, HUMAN_TOOL, LLM_TOOLS } from './internal/domain/tool-bus.ts';
export { compileToolBus, HUMAN_TOOL, LEDGER_TOOL, LLM_TOOLS } from './internal/domain/tool-bus.ts';
export { auditPassed, staticAudit } from './internal/domain/registration-audit.ts';
export type { HandoffField } from './internal/domain/handoff.ts';
export { callKeyOf, coerceBySchema, handoffFields } from './internal/domain/handoff.ts';
export type { ToolHandler } from './internal/adapter/toolbox-store.ts';
export { createSchemaValidator, createToolRegistry, createToolReplayStore, createToolRuntime, isAllowed, } from './internal/adapter/toolbox-store.ts';
export { createSchemaValidator, createToolRegistry, createToolReplayStore, createToolHandoffStore, createToolRuntime, isAllowed, } from './internal/adapter/toolbox-store.ts';
export { replayToolTable, toolAllowlistTable, toolTable } from './internal/adapter/toolbox-schema.ts';
+20
View File
@@ -43,6 +43,26 @@ export type ToolReplayStore = {
* 工具执行 —— 实现对系统:server 侧执行代码,运行时受限,
* **只可访问注册申报的连接目标,无其他环境能力**(OS 7.3)。
*/
/**
* 工具转人台(§6.2)—— 人是工具的一种实现:发出去的卡、答回来的出参都记在这。
* 内容地址 `callKey` 保证同一次调用只问人一次。
*/
export type ToolHandoffStore = {
find(taskId: string, callKey: string): Promise<ToolHandoff | undefined>;
open(handoff: Omit<ToolHandoff, 'state' | 'output'>): Promise<void>;
/** 人答完:值即出参 */
answer(workOrderId: string, output: Record<string, unknown>): Promise<ToolHandoff | undefined>;
};
export type ToolHandoff = {
readonly id: string;
readonly taskId: string;
readonly tool: string;
readonly callKey: string;
readonly input: Record<string, unknown>;
readonly workOrderId: string;
readonly state: 'pending' | 'answered';
readonly output?: Record<string, unknown> | undefined;
};
export type ToolRuntime = {
execute(input: {
readonly contract: ToolContract;
+2 -2
View File
@@ -10,6 +10,6 @@ export { defineActivity } from './define.ts';
export type { ActivityMemoryLayer, ChatCardDraft, FormFieldDraft, LedgerKey, LedgerRow, ViewBlockLike, WorkOrderDraft, } from './types.ts';
export type { HostOptions, HostRecording, MockHost } from './host.ts';
export { createMockHost } from './host.ts';
export type { Tone } from './view.ts';
export { kvCard, noteCard, stagesCard, tableCard, viewShell } from './view.ts';
export type { ColStyle, FactTone, Stamp, Tone } from './view.ts';
export { actionList, bigCount, factCards, letter, meter, noteOnly, ruleTable, section, stagesOnly, statsBand, tableOnly, verdict, viewShell, } from './view.ts';
export declare const SDK_VERSION = "0.1.0-m5";
+414 -4
View File
@@ -116,9 +116,9 @@ export declare const listGrantsRoute: {
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"governance">;
function: z.ZodEnum<{
trigger: "trigger";
data: "data";
task: "task";
trigger: "trigger";
system: "system";
tool: "tool";
activity: "activity";
@@ -155,9 +155,9 @@ export declare const setGrantsRoute: {
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"governance">;
function: z.ZodEnum<{
trigger: "trigger";
data: "data";
task: "task";
trigger: "trigger";
system: "system";
tool: "tool";
activity: "activity";
@@ -187,9 +187,9 @@ export declare const setGrantsRoute: {
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"governance">;
function: z.ZodEnum<{
trigger: "trigger";
data: "data";
task: "task";
trigger: "trigger";
system: "system";
tool: "tool";
activity: "activity";
@@ -508,6 +508,7 @@ export declare const govTaskLogRoute: {
id: z.ZodString;
at: z.ZodString;
sideLabel: z.ZodString;
action: z.ZodString;
title: z.ZodString;
}, z.core.$strip>>;
}, z.core.$strip>;
@@ -1835,12 +1836,20 @@ export declare const installActivityRoute: {
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
messageAllowed: z.ZodDefault<z.ZodBoolean>;
messageHint: z.ZodOptional<z.ZodString>;
inboundComm: z.ZodOptional<z.ZodObject<{
channel: z.ZodDefault<z.ZodString>;
partyField: z.ZodString;
subjectField: z.ZodOptional<z.ZodString>;
bodyField: z.ZodOptional<z.ZodString>;
attachmentsField: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
}, z.core.$strip>;
output: z.ZodObject<{
mandatory: z.ZodDefault<z.ZodArray<z.ZodObject<{
name: z.ZodString;
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
description: z.ZodOptional<z.ZodString>;
when: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>>;
selective: z.ZodDefault<z.ZodArray<z.ZodObject<{
group: z.ZodString;
@@ -1848,6 +1857,7 @@ export declare const installActivityRoute: {
name: z.ZodString;
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
description: z.ZodOptional<z.ZodString>;
when: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
}, z.core.$strip>>>;
}, z.core.$strip>;
@@ -1855,6 +1865,9 @@ export declare const installActivityRoute: {
key: z.ZodString;
label: z.ZodString;
schema: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
ledger: z.ZodOptional<z.ZodString>;
ledgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
editableAtRelease: z.ZodOptional<z.ZodArray<z.ZodString>>;
}, z.core.$strip>>>;
acceptanceCriteria: z.ZodDefault<z.ZodArray<z.ZodObject<{
key: z.ZodString;
@@ -1869,6 +1882,11 @@ export declare const installActivityRoute: {
predicate: z.ZodOptional<z.ZodString>;
modelRef: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>>;
revisableAtRelease: z.ZodDefault<z.ZodArray<z.ZodObject<{
key: z.ZodString;
label: z.ZodString;
ledger: z.ZodString;
}, z.core.$strip>>>;
prohibitions: z.ZodDefault<z.ZodArray<z.ZodObject<{
key: z.ZodString;
statement: z.ZodString;
@@ -1911,6 +1929,17 @@ export declare const installActivityRoute: {
domain: z.ZodString;
object: z.ZodString;
label: z.ZodOptional<z.ZodString>;
kind: z.ZodDefault<z.ZodEnum<{
record: "record";
master: "master";
derived: "derived";
}>>;
caseKey: z.ZodOptional<z.ZodString>;
file: z.ZodOptional<z.ZodObject<{
titleColumn: z.ZodString;
bodyColumn: z.ZodString;
formatColumn: z.ZodString;
}, z.core.$strip>>;
fields: z.ZodArray<z.ZodObject<{
name: z.ZodString;
type: z.ZodEnum<{
@@ -1925,6 +1954,16 @@ export declare const installActivityRoute: {
label: z.ZodOptional<z.ZodString>;
primaryKey: z.ZodDefault<z.ZodBoolean>;
masked: z.ZodDefault<z.ZodBoolean>;
required: z.ZodOptional<z.ZodBoolean>;
tally: z.ZodOptional<z.ZodArray<z.ZodString>>;
expect: z.ZodOptional<z.ZodBoolean>;
auto: z.ZodOptional<z.ZodBoolean>;
requiredWhen: z.ZodOptional<z.ZodObject<{
column: z.ZodString;
value: z.ZodString;
}, z.core.$strip>>;
distinct: z.ZodOptional<z.ZodBoolean>;
refersTo: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
}, z.core.$strip>>>;
listeners: z.ZodDefault<z.ZodArray<z.ZodObject<{
@@ -2001,6 +2040,20 @@ export declare const installActivityRoute: {
}, z.core.$strip>>;
}, z.core.$strip>>;
}, z.core.$strip>;
steps: z.ZodOptional<z.ZodArray<z.ZodObject<{
item: z.ZodString;
intent: z.ZodString;
guide: z.ZodString;
writes: z.ZodDefault<z.ZodArray<z.ZodString>>;
reads: z.ZodDefault<z.ZodArray<z.ZodString>>;
view: z.ZodOptional<z.ZodString>;
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
doneWhen: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>>;
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
name: z.ZodString;
items: z.ZodArray<z.ZodString>;
}, z.core.$strip>>>;
}, z.core.$strip>;
skills: z.ZodDefault<z.ZodArray<z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "SkillId", "out">;
@@ -2139,7 +2192,22 @@ export declare const listActivitiesRoute: {
name: z.ZodString;
type: z.ZodString;
required: z.ZodBoolean;
label: z.ZodOptional<z.ZodString>;
format: z.ZodOptional<z.ZodString>;
options: z.ZodOptional<z.ZodArray<z.ZodObject<{
value: z.ZodString;
label: z.ZodString;
}, z.core.$strip>>>;
example: z.ZodOptional<z.ZodString>;
showWhen: z.ZodOptional<z.ZodObject<{
field: z.ZodString;
equals: z.ZodString;
}, z.core.$strip>>;
}, z.core.$strip>>;
samples: z.ZodDefault<z.ZodArray<z.ZodObject<{
name: z.ZodString;
input: z.ZodRecord<z.ZodString, z.ZodUnknown>;
}, z.core.$strip>>>;
outputPorts: z.ZodArray<z.ZodObject<{
name: z.ZodString;
description: z.ZodOptional<z.ZodString>;
@@ -2184,6 +2252,61 @@ export declare const listLedgerObjectsRoute: {
domain: z.ZodString;
object: z.ZodString;
label: z.ZodOptional<z.ZodString>;
kind: z.ZodOptional<z.ZodEnum<{
record: "record";
master: "master";
derived: "derived";
}>>;
caseKey: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
}, z.core.$strip>;
};
/**
* **按活动看数据域**(2026-08-24 用户定:「按活动数据来分类,要让每个活动执行完成后
* 台账在这里看到变化」)。
*
* 从前这一屏按**性质**(主数据/单据/派生)分组 —— 那是给「治理这张表」的人用的分法,
* 回答的是「这个对象定义全不全」。而人跑完一单来这儿,问的是另一件事:
* **我这个活动写了哪几张表,现在各有几行,刚才那一单落下了什么**。
* 按活动分组、带上行数,那个问题才答得了。
*
* 三个数各有各的意思,不能混:
* · `rows` —— 正式表里现在有几行(过了放行门的事实);
* · `drafts` —— 还在某一单草稿层的几行(**在办中,放行才进正式表**);
* · `recent` —— 这个活动最近几单,让人认出「刚跑完的那一单」。
*/
export declare const ledgerByActivityRoute: {
readonly method: "GET";
readonly path: "/api/gov/data/by-activity";
readonly output: z.ZodObject<{
activities: z.ZodArray<z.ZodObject<{
activityId: z.ZodString;
name: z.ZodString;
version: z.ZodOptional<z.ZodString>;
tables: z.ZodArray<z.ZodObject<{
domain: z.ZodString;
object: z.ZodString;
label: z.ZodOptional<z.ZodString>;
kind: z.ZodOptional<z.ZodString>;
caseKey: z.ZodOptional<z.ZodString>;
primaryKey: z.ZodArray<z.ZodString>;
rows: z.ZodOptional<z.ZodNumber>;
drafts: z.ZodNumber;
draftAt: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
recent: z.ZodArray<z.ZodObject<{
id: z.ZodString;
title: z.ZodString;
runtime: z.ZodString;
terminal: z.ZodOptional<z.ZodString>;
at: z.ZodString;
}, z.core.$strip>>;
}, z.core.$strip>>;
unclaimed: z.ZodArray<z.ZodObject<{
domain: z.ZodString;
object: z.ZodString;
label: z.ZodOptional<z.ZodString>;
rows: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>;
};
@@ -2355,6 +2478,31 @@ export declare const testRunRoute: {
}>;
}, z.core.$strip>;
};
/**
* 仿真测试台 · 试跑**一个活动** —— 与试跑流程包并列的第二种试法。
*
* 为什么要单有一条:开发一个活动时,关心的是「这个活动自己跑不跑得动」——
* 案例模型推不推得开、判据取不取得齐、台账写不写得对、验收卡长什么样。
* 走流程包试,前面几个节点的噪音全得先趟一遍,而且每改一次活动就要重跑整条流。
*
* 起出来的是**真任务**(不挂工作流),照旧落到被指派人的工作台、照旧过 Harness 与放行门 ——
* 试跑不等于假跑:假跑试不出任何东西。区别只在它带 `origin='testbed'`,可以连根拔掉。
*/
export declare const testRunActivityRoute: {
readonly method: "POST";
readonly path: "/api/gov/testbed/run-activity";
readonly body: z.ZodObject<{
activityId: z.ZodString;
data: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
message: z.ZodDefault<z.ZodString>;
ownerId: z.ZodDefault<z.ZodString>;
landMaster: z.ZodDefault<z.ZodBoolean>;
}, z.core.$strip>;
readonly output: z.ZodObject<{
taskId: z.ZodString;
title: z.ZodString;
}, z.core.$strip>;
};
/**
* 停止仿真 —— **把这一单连根拔掉**:工作流、它的活动任务、工单、实例、trace 全部销毁。
* 仿真不是生产:试跑留下的东西不该在库里、在别人工作台上过夜。
@@ -2362,8 +2510,10 @@ export declare const testRunRoute: {
export declare const testStopRoute: {
readonly method: "POST";
readonly path: "/api/gov/testbed/stop";
/** 两种试法各有其拔法:流程包给 workflowId,单活动给 taskId;都不给 = 拔掉所有试跑残留 */
readonly body: z.ZodObject<{
workflowId: z.ZodString;
workflowId: z.ZodDefault<z.ZodString>;
taskId: z.ZodDefault<z.ZodString>;
}, z.core.$strip>;
readonly output: z.ZodObject<{
destroyed: z.ZodBoolean;
@@ -2371,12 +2521,271 @@ export declare const testStopRoute: {
}, z.core.$strip>;
};
/** 当前在跑的仿真(测试台一次只跑一条)—— 刷新页面也认得回来 */
/**
* 当前这一台仪器上的试跑单(测试台一次只跑一条)—— 刷新页面也认得回来。
*
* **返回还在跑的那一单,以及刚跑完的那一单** —— 两者要分得开:
* `live` 为真才锁旋钮(在跑的不许换台);为假表示它已收尾,观测面照旧摆着结果给人看,
* 但可以再起一单了。从前不分,已终结的单照样被当成「在跑」——
* 跑完之后旋钮永久锁住,人只能刷新整页才起得了下一单(实测)。
*/
export declare const testCurrentRoute: {
readonly method: "GET";
readonly path: "/api/gov/testbed/current";
readonly output: z.ZodObject<{
workflowId: z.ZodOptional<z.ZodString>;
subject: z.ZodOptional<z.ZodString>;
taskId: z.ZodOptional<z.ZodString>;
activityId: z.ZodOptional<z.ZodString>;
live: z.ZodDefault<z.ZodBoolean>;
terminal: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
};
/**
* 单活动试跑的**观测面** —— 一屏看穿这一单:
* 轨迹(每一拍干了什么)· 台账草稿(成果落到哪儿了)· 卡(问了谁什么)· 预算读数。
*
* 流程包那侧看的是 trace 树(节点 → 步骤);活动这侧要看的是**活动内部**,
* 拿 trace 树看不出「第三拍为什么被拒」。
*/
export declare const testActivityStateRoute: {
readonly method: "GET";
readonly path: "/api/gov/testbed/activity/:taskId";
readonly params: z.ZodObject<{
taskId: z.ZodString;
}, z.core.$strip>;
readonly output: z.ZodObject<{
taskId: z.ZodString;
activityId: z.ZodString;
title: z.ZodString;
runtime: z.ZodEnum<{
running: "running";
"awaiting-human": "awaiting-human";
"awaiting-event": "awaiting-event";
suspended: "suspended";
"landing-blocked": "landing-blocked";
terminal: "terminal";
}>;
terminal: z.ZodOptional<z.ZodEnum<{
released: "released";
blocked: "blocked";
stalled: "stalled";
exhausted: "exhausted";
terminated: "terminated";
}>>;
reason: z.ZodOptional<z.ZodString>;
used: z.ZodObject<{
steps: z.ZodNumber;
tokens: z.ZodNumber;
humanCalls: z.ZodNumber;
}, z.core.$strip>;
caps: z.ZodObject<{
steps: z.ZodNumber;
tokens: z.ZodNumber;
humanCalls: z.ZodNumber;
}, z.core.$strip>;
trail: z.ZodArray<z.ZodObject<{
at: z.ZodString;
kind: z.ZodString;
title: z.ZodString;
detail: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
}, z.core.$strip>>;
drafts: z.ZodArray<z.ZodObject<{
address: z.ZodString;
key: z.ZodRecord<z.ZodString, z.ZodUnknown>;
patch: z.ZodRecord<z.ZodString, z.ZodUnknown>;
}, z.core.$strip>>;
cards: z.ZodArray<z.ZodObject<{
id: z.ZodString;
mode: z.ZodEnum<{
execution: "execution";
acceptance: "acceptance";
}>;
title: z.ZodString;
done: z.ZodBoolean;
result: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
}, z.core.$strip>;
};
/**
* 上传一个文件 —— 走 base64 而不是 multipart。
*
* 这一层所有路由都由同一套 zod 契约与 typed-fetch 收着,multipart 得在它旁边另开一条
* 不受契约管的路;而这里要传的是 BOM、规格书这类几十 KB 的件,base64 那点膨胀
* 换来「照旧一条契约管到底」是划算的。真大的件不该走界面上传,该走通道。
*/
export declare const uploadFileRoute: {
readonly method: "POST";
readonly path: "/api/gov/upload";
readonly body: z.ZodObject<{
name: z.ZodString;
contentType: z.ZodDefault<z.ZodString>;
dataBase64: z.ZodString;
}, z.core.$strip>;
readonly output: z.ZodObject<{
ref: z.ZodString;
name: z.ZodString;
contentType: z.ZodString;
size: z.ZodNumber;
text: z.ZodOptional<z.ZodString>;
note: z.ZodString;
}, z.core.$strip>;
};
export type UploadedFile = z.infer<typeof uploadFileRoute.output>;
/** 取回原件 —— 存的是真字节,人点得开才算真留住了 */
export declare const downloadFileRoute: {
readonly method: "GET";
readonly path: "/api/gov/upload/:id";
readonly params: z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>;
readonly output: z.ZodObject<{
name: z.ZodString;
contentType: z.ZodString;
dataBase64: z.ZodString;
}, z.core.$strip>;
};
/**
* 一次试跑的存档 —— **销毁那一刻拍的快照**。
*
* 「停止仿真 = 连根拔掉」这条不变(试跑起的是真任务,不该在别人工作台上过夜);
* 拔掉之前把这一单的观测面存下来。开发活动是反复试的活,「上一版跑了几拍、
* 卡在哪一步、台账写成什么样」正是要拿来比对的东西 —— 拔干净等于每改一次
* 就把上一次的账一并烧了。
*/
export declare const testLogRowSchema: z.ZodObject<{
id: z.ZodString;
kind: z.ZodEnum<{
activity: "activity";
package: "package";
}>;
subject: z.ZodString;
target: z.ZodString;
taskId: z.ZodOptional<z.ZodString>;
workflowId: z.ZodOptional<z.ZodString>;
terminal: z.ZodOptional<z.ZodString>;
reason: z.ZodOptional<z.ZodString>;
used: z.ZodObject<{
steps: z.ZodOptional<z.ZodNumber>;
tokens: z.ZodOptional<z.ZodNumber>;
humanCalls: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>;
caps: z.ZodObject<{
steps: z.ZodOptional<z.ZodNumber>;
tokens: z.ZodOptional<z.ZodNumber>;
humanCalls: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>;
trailCount: z.ZodNumber;
draftCount: z.ZodNumber;
cardCount: z.ZodNumber;
startedAt: z.ZodOptional<z.ZodString>;
archivedAt: z.ZodString;
}, z.core.$strip>;
export type TestLogRow = z.infer<typeof testLogRowSchema>;
export declare const listTestLogsRoute: {
readonly method: "GET";
readonly path: "/api/gov/testbed/logs";
readonly output: z.ZodObject<{
runs: z.ZodArray<z.ZodObject<{
id: z.ZodString;
kind: z.ZodEnum<{
activity: "activity";
package: "package";
}>;
subject: z.ZodString;
target: z.ZodString;
taskId: z.ZodOptional<z.ZodString>;
workflowId: z.ZodOptional<z.ZodString>;
terminal: z.ZodOptional<z.ZodString>;
reason: z.ZodOptional<z.ZodString>;
used: z.ZodObject<{
steps: z.ZodOptional<z.ZodNumber>;
tokens: z.ZodOptional<z.ZodNumber>;
humanCalls: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>;
caps: z.ZodObject<{
steps: z.ZodOptional<z.ZodNumber>;
tokens: z.ZodOptional<z.ZodNumber>;
humanCalls: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>;
trailCount: z.ZodNumber;
draftCount: z.ZodNumber;
cardCount: z.ZodNumber;
startedAt: z.ZodOptional<z.ZodString>;
archivedAt: z.ZodString;
}, z.core.$strip>>;
}, z.core.$strip>;
};
/** 一次试跑的正文 —— 逐拍轨迹 / 台账草稿 / 发过的卡 */
export declare const testLogDetailRoute: {
readonly method: "GET";
readonly path: "/api/gov/testbed/logs/:id";
readonly params: z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>;
readonly output: z.ZodObject<{
id: z.ZodString;
kind: z.ZodEnum<{
activity: "activity";
package: "package";
}>;
subject: z.ZodString;
target: z.ZodString;
taskId: z.ZodOptional<z.ZodString>;
workflowId: z.ZodOptional<z.ZodString>;
terminal: z.ZodOptional<z.ZodString>;
reason: z.ZodOptional<z.ZodString>;
used: z.ZodObject<{
steps: z.ZodOptional<z.ZodNumber>;
tokens: z.ZodOptional<z.ZodNumber>;
humanCalls: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>;
caps: z.ZodObject<{
steps: z.ZodOptional<z.ZodNumber>;
tokens: z.ZodOptional<z.ZodNumber>;
humanCalls: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>;
trailCount: z.ZodNumber;
draftCount: z.ZodNumber;
cardCount: z.ZodNumber;
startedAt: z.ZodOptional<z.ZodString>;
archivedAt: z.ZodString;
trail: z.ZodArray<z.ZodObject<{
at: z.ZodString;
kind: z.ZodString;
title: z.ZodString;
detail: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
}, z.core.$strip>>;
drafts: z.ZodArray<z.ZodObject<{
address: z.ZodString;
key: z.ZodRecord<z.ZodString, z.ZodUnknown>;
patch: z.ZodRecord<z.ZodString, z.ZodUnknown>;
}, z.core.$strip>>;
cards: z.ZodArray<z.ZodObject<{
id: z.ZodString;
mode: z.ZodString;
title: z.ZodString;
done: z.ZodBoolean;
result: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
}, z.core.$strip>;
};
/** 删一条 / 清空 —— 日志是给人比对用的,留多久由人说了算 */
export declare const deleteTestLogRoute: {
readonly method: "DELETE";
readonly path: "/api/gov/testbed/logs/:id";
readonly params: z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>;
readonly output: z.ZodObject<{
deleted: z.ZodNumber;
}, z.core.$strip>;
};
export declare const clearTestLogsRoute: {
readonly method: "DELETE";
readonly path: "/api/gov/testbed/logs";
readonly output: z.ZodObject<{
deleted: z.ZodNumber;
}, z.core.$strip>;
};
/** 全链 Trace:一条工作流的 span 树(节点 → 步骤 / 工具 / 模型 / 人卡) */
@@ -2620,6 +3029,7 @@ export type GovActivitiesOutput = z.infer<typeof listActivitiesRoute.output>;
export type GovToolsOutput = z.infer<typeof listToolsRoute.output>;
export type GovObjectsOutput = z.infer<typeof listLedgerObjectsRoute.output>;
export type GovLedgerPageOutput = z.infer<typeof browseLedgerRoute.output>;
export type GovLedgerByActivityOutput = z.infer<typeof ledgerByActivityRoute.output>;
export type GovMemoryOutput = z.infer<typeof listActivityMemoryRoute.output>;
export type GovMetricsOutput = z.infer<typeof govMetricsRoute.output>;
export type GovChannelsOutput = z.infer<typeof listChannelsRoute.output>;
File diff suppressed because it is too large Load Diff
+634
View File
@@ -70,6 +70,7 @@ export declare const formFieldSchema: z.ZodObject<{
group: z.ZodOptional<z.ZodString>;
hint: z.ZodOptional<z.ZodString>;
ai: z.ZodOptional<z.ZodString>;
ledger: z.ZodOptional<z.ZodString>;
unit: z.ZodOptional<z.ZodString>;
required: z.ZodOptional<z.ZodBoolean>;
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
@@ -83,6 +84,7 @@ declare const fileDocSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"markdown">;
name: z.ZodString;
md: z.ZodString;
html: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"excel">;
name: z.ZodString;
@@ -144,6 +146,8 @@ export declare const viewBlockSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
editable: z.ZodOptional<z.ZodBoolean>;
editableCols: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
ledger: z.ZodOptional<z.ZodString>;
fields: z.ZodOptional<z.ZodArray<z.ZodString>>;
linkDonut: z.ZodOptional<z.ZodObject<{
col: z.ZodNumber;
title: z.ZodOptional<z.ZodString>;
@@ -218,6 +222,7 @@ export declare const viewBlockSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"markdown">;
name: z.ZodString;
md: z.ZodString;
html: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"excel">;
name: z.ZodString;
@@ -291,6 +296,7 @@ export declare const viewBlockSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"markdown">;
name: z.ZodString;
md: z.ZodString;
html: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"excel">;
name: z.ZodString;
@@ -379,6 +385,8 @@ export declare const deliverableSchema: z.ZodObject<{
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
editable: z.ZodOptional<z.ZodBoolean>;
editableCols: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
ledger: z.ZodOptional<z.ZodString>;
fields: z.ZodOptional<z.ZodArray<z.ZodString>>;
linkDonut: z.ZodOptional<z.ZodObject<{
col: z.ZodNumber;
title: z.ZodOptional<z.ZodString>;
@@ -453,6 +461,7 @@ export declare const deliverableSchema: z.ZodObject<{
kind: z.ZodLiteral<"markdown">;
name: z.ZodString;
md: z.ZodString;
html: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"excel">;
name: z.ZodString;
@@ -526,6 +535,7 @@ export declare const deliverableSchema: z.ZodObject<{
kind: z.ZodLiteral<"markdown">;
name: z.ZodString;
md: z.ZodString;
html: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"excel">;
name: z.ZodString;
@@ -641,6 +651,8 @@ export declare const executionWorkOrderSchema: z.ZodObject<{
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
editable: z.ZodOptional<z.ZodBoolean>;
editableCols: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
ledger: z.ZodOptional<z.ZodString>;
fields: z.ZodOptional<z.ZodArray<z.ZodString>>;
linkDonut: z.ZodOptional<z.ZodObject<{
col: z.ZodNumber;
title: z.ZodOptional<z.ZodString>;
@@ -715,6 +727,7 @@ export declare const executionWorkOrderSchema: z.ZodObject<{
kind: z.ZodLiteral<"markdown">;
name: z.ZodString;
md: z.ZodString;
html: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"excel">;
name: z.ZodString;
@@ -788,6 +801,7 @@ export declare const executionWorkOrderSchema: z.ZodObject<{
kind: z.ZodLiteral<"markdown">;
name: z.ZodString;
md: z.ZodString;
html: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"excel">;
name: z.ZodString;
@@ -837,11 +851,17 @@ export declare const executionWorkOrderSchema: z.ZodObject<{
group: z.ZodOptional<z.ZodString>;
hint: z.ZodOptional<z.ZodString>;
ai: z.ZodOptional<z.ZodString>;
ledger: z.ZodOptional<z.ZodString>;
unit: z.ZodOptional<z.ZodString>;
required: z.ZodOptional<z.ZodBoolean>;
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
}, z.core.$strip>>;
submitLabel: z.ZodOptional<z.ZodString>;
gate: z.ZodOptional<z.ZodObject<{
ready: z.ZodBoolean;
why: z.ZodString;
blanks: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>>;
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
at: z.ZodISODateTime;
title: z.ZodString;
@@ -861,6 +881,7 @@ export declare const executionWorkOrderSchema: z.ZodObject<{
}>;
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
}, z.core.$strip>>;
finalHtml: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
export type ExecutionWorkOrder = z.infer<typeof executionWorkOrderSchema>;
export declare const acceptanceCriterionCheckSchema: z.ZodObject<{
@@ -873,6 +894,8 @@ export declare const acceptanceCriterionCheckSchema: z.ZodObject<{
L5: "L5";
}>;
pass: z.ZodBoolean;
settled: z.ZodOptional<z.ZodBoolean>;
rationale: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
export type AcceptanceCriterionCheck = z.infer<typeof acceptanceCriterionCheckSchema>;
/** 待落地动作意向:预检全过才上卡,卡面呈现的即已验证的动作清单(OS 7.3) */
@@ -900,6 +923,8 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{
L5: "L5";
}>;
pass: z.ZodBoolean;
settled: z.ZodOptional<z.ZodBoolean>;
rationale: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
actions: z.ZodOptional<z.ZodArray<z.ZodObject<{
label: z.ZodString;
@@ -932,10 +957,223 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{
group: z.ZodOptional<z.ZodString>;
hint: z.ZodOptional<z.ZodString>;
ai: z.ZodOptional<z.ZodString>;
ledger: z.ZodOptional<z.ZodString>;
unit: z.ZodOptional<z.ZodString>;
required: z.ZodOptional<z.ZodBoolean>;
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
}, z.core.$strip>>>;
blocks: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
t: z.ZodLiteral<"callout">;
tone: z.ZodEnum<{
ok: "ok";
warn: "warn";
danger: "danger";
brand: "brand";
muted: "muted";
}>;
text: z.ZodString;
}, z.core.$strip>, z.ZodObject<{
t: z.ZodLiteral<"kv">;
title: z.ZodOptional<z.ZodString>;
items: z.ZodArray<z.ZodObject<{
k: z.ZodString;
v: z.ZodString;
fromItem: z.ZodOptional<z.ZodObject<{
item: z.ZodString;
field: z.ZodString;
}, z.core.$strip>>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
t: z.ZodLiteral<"badges">;
title: z.ZodOptional<z.ZodString>;
items: z.ZodArray<z.ZodObject<{
label: z.ZodString;
on: z.ZodBoolean;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
t: z.ZodLiteral<"table">;
title: z.ZodOptional<z.ZodString>;
cols: z.ZodArray<z.ZodString>;
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
editable: z.ZodOptional<z.ZodBoolean>;
editableCols: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
ledger: z.ZodOptional<z.ZodString>;
fields: z.ZodOptional<z.ZodArray<z.ZodString>>;
linkDonut: z.ZodOptional<z.ZodObject<{
col: z.ZodNumber;
title: z.ZodOptional<z.ZodString>;
unit: z.ZodOptional<z.ZodString>;
map: z.ZodArray<z.ZodObject<{
match: z.ZodString;
label: z.ZodString;
tone: z.ZodEnum<{
ok: "ok";
warn: "warn";
danger: "danger";
brand: "brand";
muted: "muted";
}>;
}, z.core.$strip>>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
t: z.ZodLiteral<"bars">;
title: z.ZodOptional<z.ZodString>;
max: z.ZodOptional<z.ZodNumber>;
redline: z.ZodOptional<z.ZodNumber>;
items: z.ZodArray<z.ZodObject<{
label: z.ZodString;
value: z.ZodNumber;
text: z.ZodOptional<z.ZodString>;
tone: z.ZodOptional<z.ZodEnum<{
ok: "ok";
warn: "warn";
danger: "danger";
brand: "brand";
muted: "muted";
}>>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
t: z.ZodLiteral<"donut">;
title: z.ZodOptional<z.ZodString>;
unit: z.ZodOptional<z.ZodString>;
items: z.ZodArray<z.ZodObject<{
label: z.ZodString;
value: z.ZodNumber;
tone: z.ZodEnum<{
ok: "ok";
warn: "warn";
danger: "danger";
brand: "brand";
muted: "muted";
}>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
t: z.ZodLiteral<"mail">;
dir: z.ZodEnum<{
out: "out";
in: "in";
}>;
statusLabel: z.ZodOptional<z.ZodString>;
editable: z.ZodOptional<z.ZodBoolean>;
from: z.ZodString;
fromAddr: z.ZodOptional<z.ZodString>;
to: z.ZodString;
cc: z.ZodOptional<z.ZodString>;
subject: z.ZodString;
time: z.ZodString;
body: z.ZodString;
attachments: z.ZodOptional<z.ZodArray<z.ZodObject<{
name: z.ZodString;
meta: z.ZodOptional<z.ZodString>;
file: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"html">;
name: z.ZodString;
html: z.ZodString;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"markdown">;
name: z.ZodString;
md: z.ZodString;
html: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"excel">;
name: z.ZodString;
sheet: z.ZodString;
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"word">;
name: z.ZodString;
blocks: z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
t: z.ZodEnum<{
h1: "h1";
h2: "h2";
p: "p";
}>;
text: z.ZodString;
}, z.core.$strip>, z.ZodObject<{
t: z.ZodLiteral<"ul">;
items: z.ZodArray<z.ZodString>;
}, z.core.$strip>]>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"pdf">;
name: z.ZodString;
src: z.ZodString;
}, z.core.$strip>], "kind">>;
}, z.core.$strip>>>;
}, z.core.$strip>, z.ZodObject<{
t: z.ZodLiteral<"cards">;
title: z.ZodOptional<z.ZodString>;
items: z.ZodArray<z.ZodObject<{
tag: z.ZodString;
icon: z.ZodOptional<z.ZodString>;
tone: z.ZodOptional<z.ZodEnum<{
ok: "ok";
warn: "warn";
danger: "danger";
brand: "brand";
muted: "muted";
}>>;
title: z.ZodString;
code: z.ZodOptional<z.ZodString>;
swap: z.ZodOptional<z.ZodObject<{
from: z.ZodString;
to: z.ZodString;
hint: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
big: z.ZodString;
bigLabel: z.ZodOptional<z.ZodString>;
meta: z.ZodOptional<z.ZodArray<z.ZodObject<{
k: z.ZodString;
v: z.ZodString;
}, z.core.$strip>>>;
note: z.ZodOptional<z.ZodString>;
from: z.ZodOptional<z.ZodString>;
time: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
t: z.ZodLiteral<"waiting">;
label: z.ZodString;
hint: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{
t: z.ZodLiteral<"files">;
title: z.ZodOptional<z.ZodString>;
items: z.ZodArray<z.ZodObject<{
label: z.ZodString;
desc: z.ZodString;
file: z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"html">;
name: z.ZodString;
html: z.ZodString;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"markdown">;
name: z.ZodString;
md: z.ZodString;
html: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"excel">;
name: z.ZodString;
sheet: z.ZodString;
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"word">;
name: z.ZodString;
blocks: z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
t: z.ZodEnum<{
h1: "h1";
h2: "h2";
p: "p";
}>;
text: z.ZodString;
}, z.core.$strip>, z.ZodObject<{
t: z.ZodLiteral<"ul">;
items: z.ZodArray<z.ZodString>;
}, z.core.$strip>]>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"pdf">;
name: z.ZodString;
src: z.ZodString;
}, z.core.$strip>], "kind">;
}, z.core.$strip>>;
}, z.core.$strip>], "t">>>;
portChoice: z.ZodOptional<z.ZodObject<{
group: z.ZodString;
ports: z.ZodArray<z.ZodObject<{
@@ -943,6 +1181,22 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{
label: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
}, z.core.$strip>>;
landing: z.ZodOptional<z.ZodObject<{
rows: z.ZodNumber;
simulated: z.ZodDefault<z.ZodNumber>;
tables: z.ZodDefault<z.ZodArray<z.ZodObject<{
label: z.ZodString;
rows: z.ZodNumber;
}, z.core.$strip>>>;
ports: z.ZodDefault<z.ZodArray<z.ZodString>>;
carried: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>>;
routedPort: z.ZodOptional<z.ZodObject<{
group: z.ZodString;
name: z.ZodString;
label: z.ZodOptional<z.ZodString>;
because: z.ZodString;
}, z.core.$strip>>;
passLabel: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
at: z.ZodISODateTime;
@@ -963,6 +1217,7 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{
}>;
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
}, z.core.$strip>>;
finalHtml: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
export type AcceptanceWorkOrder = z.infer<typeof acceptanceWorkOrderSchema>;
export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
@@ -1009,6 +1264,8 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
editable: z.ZodOptional<z.ZodBoolean>;
editableCols: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
ledger: z.ZodOptional<z.ZodString>;
fields: z.ZodOptional<z.ZodArray<z.ZodString>>;
linkDonut: z.ZodOptional<z.ZodObject<{
col: z.ZodNumber;
title: z.ZodOptional<z.ZodString>;
@@ -1083,6 +1340,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"markdown">;
name: z.ZodString;
md: z.ZodString;
html: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"excel">;
name: z.ZodString;
@@ -1156,6 +1414,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"markdown">;
name: z.ZodString;
md: z.ZodString;
html: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"excel">;
name: z.ZodString;
@@ -1205,11 +1464,17 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
group: z.ZodOptional<z.ZodString>;
hint: z.ZodOptional<z.ZodString>;
ai: z.ZodOptional<z.ZodString>;
ledger: z.ZodOptional<z.ZodString>;
unit: z.ZodOptional<z.ZodString>;
required: z.ZodOptional<z.ZodBoolean>;
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
}, z.core.$strip>>;
submitLabel: z.ZodOptional<z.ZodString>;
gate: z.ZodOptional<z.ZodObject<{
ready: z.ZodBoolean;
why: z.ZodString;
blanks: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>>;
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
at: z.ZodISODateTime;
title: z.ZodString;
@@ -1229,6 +1494,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
}>;
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
}, z.core.$strip>>;
finalHtml: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{
mode: z.ZodLiteral<"acceptance">;
deliverable: z.core.$ZodBranded<z.ZodString, "DeliverableId", "out">;
@@ -1242,6 +1508,8 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
L5: "L5";
}>;
pass: z.ZodBoolean;
settled: z.ZodOptional<z.ZodBoolean>;
rationale: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
actions: z.ZodOptional<z.ZodArray<z.ZodObject<{
label: z.ZodString;
@@ -1274,10 +1542,223 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
group: z.ZodOptional<z.ZodString>;
hint: z.ZodOptional<z.ZodString>;
ai: z.ZodOptional<z.ZodString>;
ledger: z.ZodOptional<z.ZodString>;
unit: z.ZodOptional<z.ZodString>;
required: z.ZodOptional<z.ZodBoolean>;
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
}, z.core.$strip>>>;
blocks: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
t: z.ZodLiteral<"callout">;
tone: z.ZodEnum<{
ok: "ok";
warn: "warn";
danger: "danger";
brand: "brand";
muted: "muted";
}>;
text: z.ZodString;
}, z.core.$strip>, z.ZodObject<{
t: z.ZodLiteral<"kv">;
title: z.ZodOptional<z.ZodString>;
items: z.ZodArray<z.ZodObject<{
k: z.ZodString;
v: z.ZodString;
fromItem: z.ZodOptional<z.ZodObject<{
item: z.ZodString;
field: z.ZodString;
}, z.core.$strip>>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
t: z.ZodLiteral<"badges">;
title: z.ZodOptional<z.ZodString>;
items: z.ZodArray<z.ZodObject<{
label: z.ZodString;
on: z.ZodBoolean;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
t: z.ZodLiteral<"table">;
title: z.ZodOptional<z.ZodString>;
cols: z.ZodArray<z.ZodString>;
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
editable: z.ZodOptional<z.ZodBoolean>;
editableCols: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
ledger: z.ZodOptional<z.ZodString>;
fields: z.ZodOptional<z.ZodArray<z.ZodString>>;
linkDonut: z.ZodOptional<z.ZodObject<{
col: z.ZodNumber;
title: z.ZodOptional<z.ZodString>;
unit: z.ZodOptional<z.ZodString>;
map: z.ZodArray<z.ZodObject<{
match: z.ZodString;
label: z.ZodString;
tone: z.ZodEnum<{
ok: "ok";
warn: "warn";
danger: "danger";
brand: "brand";
muted: "muted";
}>;
}, z.core.$strip>>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
t: z.ZodLiteral<"bars">;
title: z.ZodOptional<z.ZodString>;
max: z.ZodOptional<z.ZodNumber>;
redline: z.ZodOptional<z.ZodNumber>;
items: z.ZodArray<z.ZodObject<{
label: z.ZodString;
value: z.ZodNumber;
text: z.ZodOptional<z.ZodString>;
tone: z.ZodOptional<z.ZodEnum<{
ok: "ok";
warn: "warn";
danger: "danger";
brand: "brand";
muted: "muted";
}>>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
t: z.ZodLiteral<"donut">;
title: z.ZodOptional<z.ZodString>;
unit: z.ZodOptional<z.ZodString>;
items: z.ZodArray<z.ZodObject<{
label: z.ZodString;
value: z.ZodNumber;
tone: z.ZodEnum<{
ok: "ok";
warn: "warn";
danger: "danger";
brand: "brand";
muted: "muted";
}>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
t: z.ZodLiteral<"mail">;
dir: z.ZodEnum<{
out: "out";
in: "in";
}>;
statusLabel: z.ZodOptional<z.ZodString>;
editable: z.ZodOptional<z.ZodBoolean>;
from: z.ZodString;
fromAddr: z.ZodOptional<z.ZodString>;
to: z.ZodString;
cc: z.ZodOptional<z.ZodString>;
subject: z.ZodString;
time: z.ZodString;
body: z.ZodString;
attachments: z.ZodOptional<z.ZodArray<z.ZodObject<{
name: z.ZodString;
meta: z.ZodOptional<z.ZodString>;
file: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"html">;
name: z.ZodString;
html: z.ZodString;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"markdown">;
name: z.ZodString;
md: z.ZodString;
html: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"excel">;
name: z.ZodString;
sheet: z.ZodString;
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"word">;
name: z.ZodString;
blocks: z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
t: z.ZodEnum<{
h1: "h1";
h2: "h2";
p: "p";
}>;
text: z.ZodString;
}, z.core.$strip>, z.ZodObject<{
t: z.ZodLiteral<"ul">;
items: z.ZodArray<z.ZodString>;
}, z.core.$strip>]>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"pdf">;
name: z.ZodString;
src: z.ZodString;
}, z.core.$strip>], "kind">>;
}, z.core.$strip>>>;
}, z.core.$strip>, z.ZodObject<{
t: z.ZodLiteral<"cards">;
title: z.ZodOptional<z.ZodString>;
items: z.ZodArray<z.ZodObject<{
tag: z.ZodString;
icon: z.ZodOptional<z.ZodString>;
tone: z.ZodOptional<z.ZodEnum<{
ok: "ok";
warn: "warn";
danger: "danger";
brand: "brand";
muted: "muted";
}>>;
title: z.ZodString;
code: z.ZodOptional<z.ZodString>;
swap: z.ZodOptional<z.ZodObject<{
from: z.ZodString;
to: z.ZodString;
hint: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
big: z.ZodString;
bigLabel: z.ZodOptional<z.ZodString>;
meta: z.ZodOptional<z.ZodArray<z.ZodObject<{
k: z.ZodString;
v: z.ZodString;
}, z.core.$strip>>>;
note: z.ZodOptional<z.ZodString>;
from: z.ZodOptional<z.ZodString>;
time: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
t: z.ZodLiteral<"waiting">;
label: z.ZodString;
hint: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{
t: z.ZodLiteral<"files">;
title: z.ZodOptional<z.ZodString>;
items: z.ZodArray<z.ZodObject<{
label: z.ZodString;
desc: z.ZodString;
file: z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"html">;
name: z.ZodString;
html: z.ZodString;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"markdown">;
name: z.ZodString;
md: z.ZodString;
html: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"excel">;
name: z.ZodString;
sheet: z.ZodString;
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"word">;
name: z.ZodString;
blocks: z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
t: z.ZodEnum<{
h1: "h1";
h2: "h2";
p: "p";
}>;
text: z.ZodString;
}, z.core.$strip>, z.ZodObject<{
t: z.ZodLiteral<"ul">;
items: z.ZodArray<z.ZodString>;
}, z.core.$strip>]>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"pdf">;
name: z.ZodString;
src: z.ZodString;
}, z.core.$strip>], "kind">;
}, z.core.$strip>>;
}, z.core.$strip>], "t">>>;
portChoice: z.ZodOptional<z.ZodObject<{
group: z.ZodString;
ports: z.ZodArray<z.ZodObject<{
@@ -1285,6 +1766,22 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
label: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
}, z.core.$strip>>;
landing: z.ZodOptional<z.ZodObject<{
rows: z.ZodNumber;
simulated: z.ZodDefault<z.ZodNumber>;
tables: z.ZodDefault<z.ZodArray<z.ZodObject<{
label: z.ZodString;
rows: z.ZodNumber;
}, z.core.$strip>>>;
ports: z.ZodDefault<z.ZodArray<z.ZodString>>;
carried: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>>;
routedPort: z.ZodOptional<z.ZodObject<{
group: z.ZodString;
name: z.ZodString;
label: z.ZodOptional<z.ZodString>;
because: z.ZodString;
}, z.core.$strip>>;
passLabel: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
at: z.ZodISODateTime;
@@ -1305,6 +1802,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
}>;
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
}, z.core.$strip>>;
finalHtml: z.ZodOptional<z.ZodString>;
}, z.core.$strip>], "mode">;
export type WorkOrder = z.infer<typeof workOrderSchema>;
/** AI 发言的类型标记 —— 对话区可分辨:结论 / 指导 / 判据 */
@@ -1339,6 +1837,7 @@ export declare const decisionCardSchema: z.ZodObject<{
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
note: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
finalHtml: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
at: z.ZodISODateTime;
}, z.core.$strip>;
@@ -1355,6 +1854,7 @@ export declare const elicitationCardSchema: z.ZodObject<{
key: z.ZodString;
label: z.ZodString;
placeholder: z.ZodOptional<z.ZodString>;
value: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
done: z.ZodOptional<z.ZodObject<{
at: z.ZodISODateTime;
@@ -1366,6 +1866,49 @@ export declare const elicitationCardSchema: z.ZodObject<{
at: z.ZodISODateTime;
}, z.core.$strip>;
export type ElicitationCard = z.infer<typeof elicitationCardSchema>;
export declare const dataEntryCardSchema: z.ZodObject<{
k: z.ZodLiteral<"entry">;
ask: z.ZodString;
basis: z.ZodOptional<z.ZodArray<z.ZodObject<{
k: z.ZodString;
v: z.ZodString;
}, z.core.$strip>>>;
ledger: z.ZodString;
ledgerLabel: z.ZodOptional<z.ZodString>;
key: z.ZodRecord<z.ZodString, z.ZodString>;
keyLabels: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
fields: z.ZodArray<z.ZodObject<{
key: z.ZodString;
label: z.ZodString;
value: z.ZodOptional<z.ZodString>;
required: z.ZodDefault<z.ZodBoolean>;
placeholder: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
autoNote: z.ZodOptional<z.ZodString>;
also: z.ZodOptional<z.ZodArray<z.ZodObject<{
title: z.ZodString;
ledger: z.ZodString;
ledgerLabel: z.ZodOptional<z.ZodString>;
key: z.ZodRecord<z.ZodString, z.ZodString>;
keyLabels: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
fields: z.ZodArray<z.ZodObject<{
key: z.ZodString;
label: z.ZodString;
value: z.ZodOptional<z.ZodString>;
required: z.ZodDefault<z.ZodBoolean>;
placeholder: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
autoNote: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>>;
done: z.ZodOptional<z.ZodObject<{
at: z.ZodISODateTime;
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
values: z.ZodRecord<z.ZodString, z.ZodString>;
}, z.core.$strip>>;
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
at: z.ZodISODateTime;
}, z.core.$strip>;
export type DataEntryCard = z.infer<typeof dataEntryCardSchema>;
/** 评价卡:任务评价(评分 1–5 + 改进建议) */
export declare const ratingCardSchema: z.ZodObject<{
k: z.ZodLiteral<"rating">;
@@ -1406,6 +1949,48 @@ export declare const chatCardSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
note: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
finalHtml: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
at: z.ZodISODateTime;
}, z.core.$strip>, z.ZodObject<{
k: z.ZodLiteral<"entry">;
ask: z.ZodString;
basis: z.ZodOptional<z.ZodArray<z.ZodObject<{
k: z.ZodString;
v: z.ZodString;
}, z.core.$strip>>>;
ledger: z.ZodString;
ledgerLabel: z.ZodOptional<z.ZodString>;
key: z.ZodRecord<z.ZodString, z.ZodString>;
keyLabels: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
fields: z.ZodArray<z.ZodObject<{
key: z.ZodString;
label: z.ZodString;
value: z.ZodOptional<z.ZodString>;
required: z.ZodDefault<z.ZodBoolean>;
placeholder: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
autoNote: z.ZodOptional<z.ZodString>;
also: z.ZodOptional<z.ZodArray<z.ZodObject<{
title: z.ZodString;
ledger: z.ZodString;
ledgerLabel: z.ZodOptional<z.ZodString>;
key: z.ZodRecord<z.ZodString, z.ZodString>;
keyLabels: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
fields: z.ZodArray<z.ZodObject<{
key: z.ZodString;
label: z.ZodString;
value: z.ZodOptional<z.ZodString>;
required: z.ZodDefault<z.ZodBoolean>;
placeholder: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
autoNote: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>>;
done: z.ZodOptional<z.ZodObject<{
at: z.ZodISODateTime;
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
values: z.ZodRecord<z.ZodString, z.ZodString>;
}, z.core.$strip>>;
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
at: z.ZodISODateTime;
}, z.core.$strip>, z.ZodObject<{
@@ -1419,6 +2004,7 @@ export declare const chatCardSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
key: z.ZodString;
label: z.ZodString;
placeholder: z.ZodOptional<z.ZodString>;
value: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
done: z.ZodOptional<z.ZodObject<{
at: z.ZodISODateTime;
@@ -1471,6 +2057,7 @@ export declare const chatMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
control: "control";
}>;
text: z.ZodString;
openComms: z.ZodOptional<z.ZodBoolean>;
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
at: z.ZodISODateTime;
}, z.core.$strip>, z.ZodObject<{
@@ -1495,6 +2082,48 @@ export declare const chatMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
note: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
finalHtml: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
at: z.ZodISODateTime;
}, z.core.$strip>, z.ZodObject<{
k: z.ZodLiteral<"entry">;
ask: z.ZodString;
basis: z.ZodOptional<z.ZodArray<z.ZodObject<{
k: z.ZodString;
v: z.ZodString;
}, z.core.$strip>>>;
ledger: z.ZodString;
ledgerLabel: z.ZodOptional<z.ZodString>;
key: z.ZodRecord<z.ZodString, z.ZodString>;
keyLabels: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
fields: z.ZodArray<z.ZodObject<{
key: z.ZodString;
label: z.ZodString;
value: z.ZodOptional<z.ZodString>;
required: z.ZodDefault<z.ZodBoolean>;
placeholder: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
autoNote: z.ZodOptional<z.ZodString>;
also: z.ZodOptional<z.ZodArray<z.ZodObject<{
title: z.ZodString;
ledger: z.ZodString;
ledgerLabel: z.ZodOptional<z.ZodString>;
key: z.ZodRecord<z.ZodString, z.ZodString>;
keyLabels: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
fields: z.ZodArray<z.ZodObject<{
key: z.ZodString;
label: z.ZodString;
value: z.ZodOptional<z.ZodString>;
required: z.ZodDefault<z.ZodBoolean>;
placeholder: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
autoNote: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>>;
done: z.ZodOptional<z.ZodObject<{
at: z.ZodISODateTime;
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
values: z.ZodRecord<z.ZodString, z.ZodString>;
}, z.core.$strip>>;
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
at: z.ZodISODateTime;
}, z.core.$strip>, z.ZodObject<{
@@ -1508,6 +2137,7 @@ export declare const chatMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
key: z.ZodString;
label: z.ZodString;
placeholder: z.ZodOptional<z.ZodString>;
value: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
done: z.ZodOptional<z.ZodObject<{
at: z.ZodISODateTime;
@@ -1560,6 +2190,10 @@ export declare const chatCardReplySchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
cardId: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
values: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
reason: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{
k: z.ZodLiteral<"entry">;
cardId: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
values: z.ZodRecord<z.ZodString, z.ZodString>;
}, z.core.$strip>, z.ZodObject<{
k: z.ZodLiteral<"rating">;
cardId: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
+30
View File
@@ -16,12 +16,20 @@ export declare const contractInputSchema: z.ZodObject<{
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
messageAllowed: z.ZodDefault<z.ZodBoolean>;
messageHint: z.ZodOptional<z.ZodString>;
inboundComm: z.ZodOptional<z.ZodObject<{
channel: z.ZodDefault<z.ZodString>;
partyField: z.ZodString;
subjectField: z.ZodOptional<z.ZodString>;
bodyField: z.ZodOptional<z.ZodString>;
attachmentsField: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
}, z.core.$strip>;
export type ContractInput = z.infer<typeof contractInputSchema>;
export declare const outputPortSchema: z.ZodObject<{
name: z.ZodString;
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
description: z.ZodOptional<z.ZodString>;
when: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
export type OutputPort = z.infer<typeof outputPortSchema>;
export declare const outputPortsSchema: z.ZodObject<{
@@ -29,6 +37,7 @@ export declare const outputPortsSchema: z.ZodObject<{
name: z.ZodString;
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
description: z.ZodOptional<z.ZodString>;
when: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>>;
selective: z.ZodDefault<z.ZodArray<z.ZodObject<{
group: z.ZodString;
@@ -36,6 +45,7 @@ export declare const outputPortsSchema: z.ZodObject<{
name: z.ZodString;
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
description: z.ZodOptional<z.ZodString>;
when: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
}, z.core.$strip>>>;
}, z.core.$strip>;
@@ -44,6 +54,9 @@ export declare const requiredDeliverableSchema: z.ZodObject<{
key: z.ZodString;
label: z.ZodString;
schema: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
ledger: z.ZodOptional<z.ZodString>;
ledgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
editableAtRelease: z.ZodOptional<z.ZodArray<z.ZodString>>;
}, z.core.$strip>;
export type RequiredDeliverable = z.infer<typeof requiredDeliverableSchema>;
export declare const acceptanceCriterionSchema: z.ZodObject<{
@@ -109,12 +122,20 @@ export declare const sevenPartContractSchema: z.ZodObject<{
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
messageAllowed: z.ZodDefault<z.ZodBoolean>;
messageHint: z.ZodOptional<z.ZodString>;
inboundComm: z.ZodOptional<z.ZodObject<{
channel: z.ZodDefault<z.ZodString>;
partyField: z.ZodString;
subjectField: z.ZodOptional<z.ZodString>;
bodyField: z.ZodOptional<z.ZodString>;
attachmentsField: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
}, z.core.$strip>;
output: z.ZodObject<{
mandatory: z.ZodDefault<z.ZodArray<z.ZodObject<{
name: z.ZodString;
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
description: z.ZodOptional<z.ZodString>;
when: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>>;
selective: z.ZodDefault<z.ZodArray<z.ZodObject<{
group: z.ZodString;
@@ -122,6 +143,7 @@ export declare const sevenPartContractSchema: z.ZodObject<{
name: z.ZodString;
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
description: z.ZodOptional<z.ZodString>;
when: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
}, z.core.$strip>>>;
}, z.core.$strip>;
@@ -129,6 +151,9 @@ export declare const sevenPartContractSchema: z.ZodObject<{
key: z.ZodString;
label: z.ZodString;
schema: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
ledger: z.ZodOptional<z.ZodString>;
ledgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
editableAtRelease: z.ZodOptional<z.ZodArray<z.ZodString>>;
}, z.core.$strip>>>;
acceptanceCriteria: z.ZodDefault<z.ZodArray<z.ZodObject<{
key: z.ZodString;
@@ -143,6 +168,11 @@ export declare const sevenPartContractSchema: z.ZodObject<{
predicate: z.ZodOptional<z.ZodString>;
modelRef: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>>;
revisableAtRelease: z.ZodDefault<z.ZodArray<z.ZodObject<{
key: z.ZodString;
label: z.ZodString;
ledger: z.ZodString;
}, z.core.$strip>>>;
prohibitions: z.ZodDefault<z.ZodArray<z.ZodObject<{
key: z.ZodString;
statement: z.ZodString;
+60
View File
@@ -49,12 +49,62 @@ export declare const fieldDeclarationSchema: z.ZodObject<{
label: z.ZodOptional<z.ZodString>;
primaryKey: z.ZodDefault<z.ZodBoolean>;
masked: z.ZodDefault<z.ZodBoolean>;
required: z.ZodOptional<z.ZodBoolean>;
tally: z.ZodOptional<z.ZodArray<z.ZodString>>;
expect: z.ZodOptional<z.ZodBoolean>;
auto: z.ZodOptional<z.ZodBoolean>;
requiredWhen: z.ZodOptional<z.ZodObject<{
column: z.ZodString;
value: z.ZodString;
}, z.core.$strip>>;
distinct: z.ZodOptional<z.ZodBoolean>;
refersTo: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
export type FieldDeclaration = z.infer<typeof fieldDeclarationSchema>;
/**
* 台账对象的**性质** —— 这条数据归谁管、活多久、谁有权改它。
*
* 从前台账只有「地址 + 列」,没有这一维,于是**没人需要回答「这张表是什么」**——
* 后果不是不好看,是主键写歪了还没人发现:A4 的报价草案本该一单一份,
* 主键却只有 `pn`,第二单报同一个料号直接 upsert 覆盖上一单
* (实测:跑了四轮 A4,`quote_pricing.draft` 只剩最后一轮的 3 行;
* 而主键带了单号的 `quote_intake.record` 是 61 行 / 20 单,一单不少)。
*
* 分三类,判据是**机械的**,不靠感觉:
*
* · `record` 单据 —— 一单一份的过程与结论。主键**必含单标识**(`caseKey`),
* 放行即定稿、只增不改。它不需要「管理」,需要的是查得到、回放得出。
* · `master` 主数据 —— 跨单积累的经营资产(客户、供应商、料号规格、历史价)。
* 主键是**业务实体**,会被反复更新,下一单读它做判断。治理都在这一类上:
* 谁能改、依据是什么、怎么去重、多久算过期。
* · `derived` 派生 —— 能由前两类算出来的指标(落地率、命中率)。
* **本不该手写**:手写的派生值能和它的来源打架,而没人会发现。
* 标出来至少让它可被重算校验。
*/
export declare const LEDGER_OBJECT_KINDS: readonly ["record", "master", "derived"];
export declare const ledgerObjectKindSchema: z.ZodEnum<{
record: "record";
master: "master";
derived: "derived";
}>;
export type LedgerObjectKind = z.infer<typeof ledgerObjectKindSchema>;
export declare const LEDGER_KIND_LABEL: Readonly<Record<LedgerObjectKind, string>>;
export declare const LEDGER_KIND_DESC: Readonly<Record<LedgerObjectKind, string>>;
export declare const objectDeclarationSchema: z.ZodObject<{
domain: z.ZodString;
object: z.ZodString;
label: z.ZodOptional<z.ZodString>;
kind: z.ZodDefault<z.ZodEnum<{
record: "record";
master: "master";
derived: "derived";
}>>;
caseKey: z.ZodOptional<z.ZodString>;
file: z.ZodOptional<z.ZodObject<{
titleColumn: z.ZodString;
bodyColumn: z.ZodString;
formatColumn: z.ZodString;
}, z.core.$strip>>;
fields: z.ZodArray<z.ZodObject<{
name: z.ZodString;
type: z.ZodEnum<{
@@ -69,6 +119,16 @@ export declare const objectDeclarationSchema: z.ZodObject<{
label: z.ZodOptional<z.ZodString>;
primaryKey: z.ZodDefault<z.ZodBoolean>;
masked: z.ZodDefault<z.ZodBoolean>;
required: z.ZodOptional<z.ZodBoolean>;
tally: z.ZodOptional<z.ZodArray<z.ZodString>>;
expect: z.ZodOptional<z.ZodBoolean>;
auto: z.ZodOptional<z.ZodBoolean>;
requiredWhen: z.ZodOptional<z.ZodObject<{
column: z.ZodString;
value: z.ZodString;
}, z.core.$strip>>;
distinct: z.ZodOptional<z.ZodBoolean>;
refersTo: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
}, z.core.$strip>;
export type ObjectDeclaration = z.infer<typeof objectDeclarationSchema>;

Some files were not shown because too many files have changed in this diff Show More