A2-A7 全部补齐到验收线;顺手把几处「门拦住却不给路」的坑填了

活动:A2 选型 / A3 寻源 / A4 定价 / A6 议价 / A7 归档五件各补成真活动包 ——
契约宽进(input.required 空,归一在活动内)、台账必填声明(含行级 requiredWhen)、
入场哨兵与 L1/L2 判据一律读基座数、两份件(在办可编辑 + 办结静态快照)、
三案例常备、包内自测。e2e 各三案例 released、行动被拒 0、发口正确、提交闸每单真拦。

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 12:28:31 +08:00
co-authored by Claude Opus 5
parent 85466e815f
commit ade0fa5a94
90 changed files with 17969 additions and 2316 deletions
+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()
+3
View File
@@ -11,3 +11,6 @@ coverage/
# 本地开发日志(./start.sh 写的) # 本地开发日志(./start.sh 写的)
.dev-logs/ .dev-logs/
# html-one-pager 技能的评测工作区(skill-creator 产物,不入库)
.claude/skills/html-one-pager-workspace/
+354 -2
View File
@@ -414,8 +414,360 @@
- **A1 三案例常备**(2026-08-02):全 A 类直通 / 客户点名跳一条 / 新客户中文正文列料缺必填 —— - **A1 三案例常备**(2026-08-02):全 A 类直通 / 客户点名跳一条 / 新客户中文正文列料缺必填 ——
三条来件形状真不同(俄文 BOM 附件、正文点名跳料、中文正文列料无附件), 三条来件形状真不同(俄文 BOM 附件、正文点名跳料、中文正文列料无附件),
末次复跑 **3/3 released、全部被拒 0**(11 / 10 / 11 拍)。 末次复跑 **3/3 released、全部被拒 0**(11 / 10 / 11 拍)。
- 后端 19 模块 + 6 活动包 + 前端 features;五道门禁全绿(2026-08-02)。 - **A2/A3 按复杂活动 MVP 简化形完整重写**(2026-08-02 用户改盘:「MVP 期间只做精美验收报告 +
- **别用 `db:seed` 当装活动的手段**:它会重写整套治理台配置。装活动用 `pnpm activities:install`。 一张执行单,提交就是输出」):`quote-alternative@3.0.0` / `quote-sourcing@3.0.0`,各四项扁平
(归一 → 一张执行单交人 → AI 判稳/齐检 → 放行)。**A2**:挑 C 类组单交 FAE 系统外选型填回,
替代料查库行情**判稳定标**(≤90 天且稳转 A,否则转 B;尺与 P-03 的 A/B 行同一把),
询价受理记录就此转成**询价受理清单**;出口 `alt-route` 两口带 when(还有 B → to-sourcing,
全 A → to-pricing),verdict 落 `need_sourcing` —— **E5 自此第一次真正成立**(从前 A2 台账
没有承载列,那条边恒假)。**A3**:归一**目标报价清单**、A 类行按库内历史价预填
(`price_basis` tally HIST/SOURCED)、PRM 执行单补货源价 —— `base_price` 标 `required`,
件上红带 / 执行卡提交闸 / 放行门 L1 三处同源,实测闸真拦(单飞两颗料只填一颗,
交卷被点名「缺底价」)。判据全读基座读数(旧版两活动各两条 L2 读自报数,全部置换);
GRADE_DMN / STRATEGY_DMN 两张零求值器判定表、shortlist/benchmark/supplier_quote/compare_table
四张不再产出的表、两个死 `run()` 全部退役留墓志铭。报告各一张件贯穿在办与归档
(`alt-roster` / `sourcing-roster`,PRD 版面语言拷自 A1;A2/A3 无 PRD 目标件,用户定沿用自造)。
**验收:A2 3/3、A3 3/3 全部 `released`、行动被拒 0**(6-7 拍 / 38-47k token),
三案例形状真不同(流内混行载荷 / 免寻源转 A / 单飞宽进自造案号)。
`contract.input.required` 一律空(用户定:宽进严出,输入契约在活动内归一+补全)。
同批:A1@43 出口撤空壳 `altRequests`/`rfqRequests`(表已退休,数组通配让它装的与 records
完全相同);e2e 脚本落成 `tools/e2e-a2.mjs`(登录 AI → testbed 起单 → 件上按地址保存 →
答卡 → 断言 released/被拒 0/发口)。
- **「算出来的口」改为服务端算数**(2026-08-02,A2 e2e 逼出来的 harness 修死):放行卡的
`routedPort` 从前只在**前端回传**时顶上 `selectedPorts`(chat-bubbles 里那段),而放行落地
读的是**人事件载荷**(原始 reply)—— 换个客户端(脚本/API)就死在「选择组未选出口」,
台账已合并、单落 `blocked`。现 `human-tool-service.replyWorkOrder` 在发事件前算好**有效口**
(routedPort 优先,算不出才认人选),事件与返回值同一份 —— 服务端说了算的事,
不靠每个客户端自觉。顺带:执行卡 `fields` 缺席时基座兜底加必填「结果」回执字段,
代答脚本交卷要带 `values.result`。
- **A2 浏览器真人路走查通过并逮住一个洞**(2026-08-02):锁定 → 编辑 → 填错取消(还原)→
重填 → 保存(痕「改了 1 行」不撒谎)→ 交执行卡 → AI 判稳(原标 C 现标 B,依据逐行留痕)→
验收签字 → **released、7 拍/51k、被拒 0**;归档件零输入框、闸与红带都收、去向命中留在定稿。
逮住的洞:**模版主键格没挂 `data-col`,人保存的行没有钥匙**,服务端按「全表一致列」补键,
两行对到同一行上、后者盖前者(BBPY 的 C 标被隔壁行的 A 盖掉)—— e2e 走 API 自带主键测不出,
只有件上真按保存才现形。修:主键列必挂 `data-col`(它不是编辑面,是行的钥匙;A1 从头就带),
两包自测钉死,`quote-alternative@3.1.0` / `quote-sourcing@3.2.0`。
A3 另修一处时序:第一拍禁写 verdict(凭据列提前落会让引擎提前收步,预填从此不发生 ——
实测嘴上说预填了、台账里没有)。
- **A2 执行件重做成替代选型工作单**(2026-08-02 用户:「执行工单的 HTML 怎么是按验收单做的?
替代选型要为选型高度定制一个模版」):`quote-alternative@3.4.0` 两个局面各一张 ——
`alt-worksheet` 选型工作单(S1/S2 摆):逐颗料一张**选型档案卡**(题面:要素/为什么要替代/
客户约束与预授权;定案回显带待填胶囊;A/B 行收成「原样承继」一行)+ **替代等级判据表**
(P2P 直换 / FUNC 功能兼容 / OTHER 须验证,附 pin-to-pin 五项核对与「同封装不等于同引脚」警示)+
三层筛指引(参数硬指标 → 认证合规 → 供应价格)+ 填回工作表;`alt-roster` 清单(S3/放行/归档)。
台账补齐**对标维度**:`alt_grade`(tally P2P/FUNC/OTHER)/`alt_diff`/`alt_risk`/`alt_verify` +
承继 `remark`(预授权/约束);执行卡指导书按等级写死核对深度。复跑 3/3 released、被拒 0。
连带逮住一个渲染器约束并记进陷阱表:**行展开按第一个同名闭合标签截行内容** ——
`<div data-rows>` 的行模版里嵌 `<div>` 会把行截成套娃(第二张卡长进第一张的表头里),
行容器要用行内不会再出现的标签(`<section>`/`<tbody>`)。
- **A2 选型工作单精专一颗 + 逐颗按序派单**(2026-08-02 用户两条:「选型 HTML 里为啥出现
ABC 类描述的表?这个报告就精专一个芯片的选型」「多个芯片按顺序派选型单」):
`quote-alternative@3.8.0`。台账加**排程列 `sel_stage`(tally NOW/WAIT/DONE)**:
S1 给每颗待选型料标 WAIT;S2 只 start 一次、一串卡都发在它名下 —— 每轮把一颗置 NOW、
发**这一颗**的选型单(标题带 PN 与第 k/n 颗,互不撞「一单一卡」闸;卡=七格表单,
选填显式 `required:false` 留「替代未果」通道),人交回 → 定案照抄落行、置 DONE → 下一颗;
全部 DONE 记 filled_total 才 finish。工作单随排程列演**当前这一颗**:顶部选型队列丸
(当前蓝/办结绿/排队灰,非选型行一枚不出)、替代关系图(芯片→芯片内联 SVG,未定案「待选定」红)、
题面/定案两栏档案、等级判据三档配示意图(P2P 引脚一一对应/FUNC 芯片+外围件/OTHER 打样烧瓶)、
pin-to-pin 五项核对图标带(封装尺寸/引脚/电气/温度/认证)、三层筛漏斗;填回表只显当前行;
已定案收一行摘要 —— **ABC 分类语言全部归收口清单**(自测钉死工作单正文不含原标/分布条/判稳)。
验收:3/3 released、被拒 0(双卡案例 8 拍/69k,第 1/2 → 第 2/2 依序)。
路上逼掉三类抢跑(guide 写死):S2/S3 收步后「这一拍到此为止」、第一轮 start 提成显式第一步、
被拒重试不重写已生效的值(空转写也是拒)。
- **A3 执行件同款重做成寻源询价工作单**(2026-08-02 用户定,素材取 MVP 示例工作流 §1.7
**只作文字参考、不作实现逻辑参照**):`quote-sourcing@3.4.0` 两个局面各一张 ——
`sourcing-worksheet`(S1/S2 摆):逐颗待询料一张**询价档案卡**(题面含**取价对象**
「替代料有值取替代料」/客户约束;A 类收成「已按历史价预填 · 可校可改」一行)+
**按场景圈渠道**规则(BOM早期可放宽 / 后期主→备选→贸易商圈两三家贸易商尽量不用 /
现货只认库存实拍无库存转单商不可选 / 样品量小求快)+ **价格验证的尺**(早期首报即用 /
后期按历史最低降两成两轮核 / 现货多重校验 / 样品低于线上即用)+ 发询纪律与时限带 +
填回工作表(缺价红带与提交闸同源);`sourcing-roster`(S3/放行/归档)。
`quote_list` 补承继列 `remark`(认证/预授权/急料)。复跑 3/3 released、被拒 0。
同批堵一条自造案号的口子(A2@3.5.0 同款):单飞造号**只从本单来件取材,
不许从台账里已有的别单单号里挑** —— 实测一轮借走了正式表里的 MD14.1。
- **执行卡零输入**(2026-08-02 用户定:「执行卡片不要有任何填写的了,填写全去工作区,
包括A1的补全信息,那个『结果』输入框没任何作用」):基座兜底的必填「结果」textarea 删掉
(`fields = declaredFields ?? []`);三活动卡骨架撤 `blocks`/`fields` —— 填报全在
工作区的件上(editGate + 孪生件,保存即落草稿层,模型**不许再 write 一遍**)。
**提交闸回落**:卡不绑表时改看必交付物声明的 `ledgers`/`ledger`(纯函数
`gateAddressesOf`,有测),与放行门 L1 仍同一个数;服务端收回传前同一段把关。
连带删「执行工单 = 指导书 + 空表单,表单不得为空」的拒发 —— 通道换到件上之后,
那道机检让活动照新规矩发卡、基座按旧规矩连拒到熔断(实测 A1 五拍)。
- **A2 选型件升格「一页汇报」**(2026-08-02 用户:「还是不够华丽,要设计成强交互的
汇报形式,直接拿去当一页 PPT 的程度」):深空电路底 hero(队列信标呼吸 /
原厂芯片 → 流光走线 → 替代定案 / 四格账)+ 编号 slide 小节推进(题面七格 ·
等级三卡悬停展开口径 · 五项核对徽章 · 三层筛 · 填回表 · 已定案摘要,空节 `:has` 自折)。
「强交互」全是纯 CSS(hover 浮起 / focus 展开 / reduced-motion 全静),沙箱仍零脚本、
模版仍不算不判。规范新增 §8.5 立此语言;踩了「媒体查询写在基础规则之前被盖掉」
的层叠坑(竖排下走线该藏未藏,排障入册)。
- **start 墙拆除 + JSON 括号栈 + 空转拒回带指南**(2026-08-02,零输入改造连跑逼出):
①**人工激活「先 start」不再是墙**(`readyToWork`):措辞写全(话与门同宽)之后 A1
仍连拒五拍熔断,五次全是「要先 start」—— 那墙保护不了任何东西(start 是模型自己的
动作,同一串连发即过),学不稳的礼仪就是死法;现在**动它就算启动**,人工激活真正
要保的两条(引擎不因哨兵替启、不给 doneWhen 替收)原样有测。三活动剧本的 start
礼仪同批删除。②关思考的模型偶发多吐一个 `}`,单数花括号的配平切出半截非法片段,
整拍六个动作全对却退化成「需要你判断下一步」兜底卡;`parseJsonLoose` 改**括号栈**、
不配对的闭括号当噪声丢(7 测)。③「写了等于没写」拒回补行动指南(值已在台账 →
该办下一件事),治 A2 收步时连拒四拍的重写循环;A2 判稳同批堵死「拿上游原 PN 的
9999 顶替、不亲查 alt_pn」的近路(实测走岔过一单去向)。
**e2e 三套七案例 7/7 released、全部被拒 0**(6-11 拍):a1 闸拦空手交卷→件补年用量;
a2 to-sourcing / to-pricing / to-sourcing;a3 quotation×3(案例 3 闸拦真拦)。
浏览器全链走查:hero 呈现、编辑→保存→落草稿→队列推进(办结绿/当前信标)→
零输入卡提交→released。A1@45.0.0 / A2@4.5.0 / A3@3.6.0。
- **一天里连挨三炸后的返工账**(2026-08-02 下午,全部用户实测逼出):
①**工作区滚轴拉不到填写区** —— ViewContainer 量高在 `editable` class 落下**之前**
(闸/红带现身把内容撑高 122px 没人再量),且 ResizeObserver 挂在 srcdoc 之前那份
空白文档的 body 上(盯着一具尸体);修:先 bind 再量 + observer 每次有机会重挂真 body。
②**「稳定的活动又开始出错」** —— 零输入改造时我往**全局提示词**塞了一句
「执行工单…别再 write 一遍」,把 A1 说明书「人交回确认卡后写 confirmed_total 记账」
顶翻:模型收到答复不记账不收步、把确认卡当没答成,重发同一张卡连拒五拍熔断。
改回通用真理(「人答回的东西在轨迹里,照说明书处理,别当没答过再发一张」);
**全局提示词只说通用真理,活动级细节归说明书** —— 此为铁训。
同批把「重发已答过的卡」从拒改为**头两次无事发生 + 观察指路**(第三次起才算被拒,
熔断兜底仍在),空答复落痕带「人已办完并交回」一句(空 values 在模型眼里像没答)。
③**「啥都没干就能提交」** —— A2 定案列当时全标选填(想保「未果留空」通道),
闸回落后成了没牙的闸。新增**行级必填 `requiredWhen`**(`{column:'sel_stage',
value:'NOW'}`:该轮到的那一行才算必填,排队的颗不拦当前的卡 —— 表级 required
在逐颗派单下会被第二颗的空格拦死成死锁);未果通道改「备注写缘由 + 行首 × 撤行」。
`_missing`/红带/提交闸/放行门同源生效,e2e 三案例空手交卷全部 400 真拦。
④**「输入区和汇报内容为啥分开?一屏都看不完算什么一页 PPT」** —— A2 工作单改
**看与改同一块**:01 区平时是汇报稿(题面七格+定案摘要+档案行),按「编辑」同一块
换成表单(`:has` 切换),保存/取消回稿;方法论(等级三卡/五项核对/三层筛)收进
原生 `<details>` 折叠条(零脚本);页高 2528 → 1285。A2@4.7.0。
⑤**遗留(记 debt):A2 单飞判稳连续两轮「不查工具直接编」**转 A 走岔去向 ——
说明书措辞已到头,结构性根治 = 判稳 decisionTask 化 / 工具出参带标(见 debt.md);
现状兜底:验收页 type_tag 可改、去向跟着变。
⑥契约变更:`FieldDeclaration.requiredWhen`(api-report 已刷新,随本轮一并提交后
api:check 门才绿 —— 它拦的就是「契约变了没入库」)。
- **A1「又出补全卡」的病根:入场哨兵读自报列**(2026-08-02 用户送来 exhausted 单):
`s_parsed` 读模型自报的 `line_total`,模型把 record 三行在 S1 拍写完、没记这个数,
S2 空手收步 —— 门永不开,引擎报「依赖断点」,提示词「断点请调人」把模型推去发
补全卡绕路(两张标题只差尾巴的卡把 humanCalls 4/4 烧光)。
**「机检只读基座数」第四次违反现形,这次是哨兵**。修:s_parsed 改读
`quote_intake_record_rows > 0`(行落了门就开,不等模型记账);`sameQuestion`
补前缀比对(≥6 字前缀 = 同一问)。复跑新客户空手确认路:**released、被拒 0、
断点 0、补全卡 0**(11 拍,回稳定基线)。A2/A3 哨兵同缝记 debt。
- **补全卡(ask)全局退役**(2026-08-02 用户定:「彻底全局退役补全卡,执行卡完全
就是包含补全语义的人类工具」):缺信息 = 发**零输入执行工单**(指导书写清缺什么,
人在工作区的件上补)。归一层把旧输出的 `ask` **原地转成执行工单**(字段名并进
指导书;拒回只会让模型换花样再发,归一才是铺路);引擎侧直发 `ask` 如实拒并指路;
提示词④段改写、handsOff 去 ask;A2/A3 剧本里「未果问人」改用决策卡(二选本就是
处置);规范 §3.4 五类卡 → 四类。历史卡回放兼容件(契约类型/前端渲染/起单老路)
留着,清理记 debt。A1@47.0.0 / A2@4.8.0 / A3@3.7.0。
- **A2 工作单第三版:对决卡 + 一张贯穿 + 占位三级**(2026-08-02 用户四条连发):
①「01 重构成渲染态就是结构,编辑就是写组件内的值;顶栏与 01 合并:左静态原厂
SVG 卡、右替换卡、等级直接选择(正确性全由人控)」→ **对决区**:深空 hero 里
左卡(原厂题面规格)→ 流光走线 → 右卡(替代定案就地编辑);**等级 = 三枚自绘
radio**(不写 checked,当前值 data-class-of 投影描虚线;基座 collect 对未选中
radio 整列不交 ——「没点」是「不改」;快照/取消按 checked 存取)。
收值铁律:**一张卡里所有输入格的祖父必须同一元素**(collect 按祖父归行)。
②02 指引铺开、不写类型(口径贴在三枚选择钮上);③**空占位三级全局统一**:
必填红 / 建议填橘 / 选填灰 = required / expect / 其余(A2 已上,A1/A3 记 debt);
④**一张模版贯穿到底**:「待处理的 HTML 报告完成后直接转变为静态的工作成果」——
alt-roster 退役,收口内容(清单总览三态丸、去向判定)长在同一张尾部有值才现身;
规范 §1 硬规矩收紧,A1/A3 旧制记 debt。基座陪三小刀:`setOpen` 对非表格容器把
`.on` 落在 `data-edit` 元素自身;collect/快照/取消的 radio 特判。
验收:e2e 3/3 released 被拒 0(闸拦三案例全 400);浏览器实证 radio 选 P2P →
保存落草稿 → 徽变绿 → 取消还原(含 radio uncheck)。A2@4.9.0。
- **A2 工作单定稿为 One Pager + 单卡多页 + 成果 1:1 快照**(2026-08-02/03 用户连定):
①「这份报告就是一颗芯片的替代选型」—— 队列丸/清单总览/去向段全撤(收口整合的
东西不在这份),版面 = 深空 hero 对决卡(左原厂静态 SVG 卡 · 流光 · 右替换卡)+
选型指引(不分段);「待选定」与必填同风格红胶囊左对齐;编辑钮做进替换卡右上角
(cardbtn 三件套仍是 data-edit-open/cancel/save);等级口径贴在三枚选择钮上。
②「一个执行单可以派多个 HTML 待执行报告,合理利用右上角的翻页」——
**逐颗派单退役**:一张执行单,件里一颗待选型芯片一页(行挂 `data-page`,
orig_type=C 成页),右上角箭头翻页;`sel_stage` 排程列整个退休;
requiredWhen 改钉 `orig_type=C`;双 C 案例 7 拍(比逐颗省)。
基座配套:渲染器**行内表单组名唯一化**(radio 组按行号缀名,页间不互斥);
ViewContainer 通用分页(`[data-page]` 可见段,`page`/`onPages`,不传 page 不分页)。
③「工作成果要 HTML/UI/数据/内容/布局 1:1 从待处理转成静态报告」(反复强调)——
病根:执行单定稿走 WorkOrderReport 卡壳、验收单渲「当下」视图,都不是办结时那份。
修:**回执收下那一刻把渲好的件存进工单**(`WorkOrder.finalHtml`),成果页
1:1 静态渲快照(零编辑钮、数据定格);旧单无快照退最近视图再退卡壳(历史兼容)。
e2e 3/3 released 被拒 0;浏览器实证:1/2↔2/2 翻页、两页一次保存全落库、
成果点开即对决卡静态版。A2@4.10.0。
- **A2 逻辑收口:两件各归各位**(2026-08-03 用户:「验收工单怎么又变成选型了?」):
「一张贯穿」被我执行过头 —— 验收阶段摆的还是选型 One Pager。校准后的口径
(规范 §1 已改):**「1:1」的落点是每份件与它的办结快照**(在办可编辑 ↔ finalHtml
静态归档),**不同工作各有各的件** —— 选型 One Pager 归执行单(S1/S2),
**收口整合清单归验收**(S3/放行/归档,`alt-roster` 恢复:整份清单
原标/替代/等级/现标/依据 + 按分类算出的去向判定,只读 —— 就地改走验收卡的
editableAtRelease 表)。浏览器全链实证:验收阶段工作区 = 收口清单(判稳这轮
也真查了工具:43 天·稳 → 转 A);成果两份定稿各是各的快照(执行单=One Pager
静态、验收单=清单静态)。e2e released 被拒 0。A2@4.11.0。
同批沉淀:活动开发规范新增 **§6.5 设计律八条**(墙要配路 / 学不稳的礼仪就是死法 /
拒回喂对抗观察喂事实 / 机检只读基座数管一整片 / 提示词分层 / 措辞治不了的结构治 /
改共享面全量回归 / 用户重复提的要求说明没打中病根);HTML 规范补「概念图形是
第一语言」与「收值三铁律」。
- **A2 两张报告第四版(用户 /brand 推倒重设计)**(2026-08-03):
①**选型执行工单**重单颗芯片:白纸文件基底 + 身份条(眉头式,客户在条内不占右上角),
深空只留「对决 Stage」(原厂芯片 →流光→ 替代芯片,PN 大字,**编辑钮组在 Stage 右上**);
定案面板白纸双栏(左题面灰底/右定案,输入全在右栏);指引脚注化(五徽一行+三层筛横条)。
②**选型验收报告**重列表与落边:汇总带(条数/A/B/C)→ 受理记录大表 →
**落边图**(SVG 流程:清单 → 菱形「还有 B 类?」→ A3 寻源/A4 定价,命中边实线亮+流光、
未命中灰虚,`case:routeKey` 投影 —— 不冒充判过)。
③占位三种定稿:**必填(红)/选填(灰)/建议(橘)**(`ph-req/ph-opt/ph-sug`);
**等级空位归「必填」**(不再写「待选定」),打开编辑即三枚选择。
e2e released 被拒 0;浏览器实证:Stage 空位红胶囊呼吸、落边图 v-r1 命中亮线带真实计数。
A2@4.12.0。
- **兜底卡不再摆模型原话:输出不成形先就地重问**(2026-08-03,用户送来一单:
「正常派工单为啥不是作业指导书,卡片里又是乱七八糟的东西」):关思考的模型偶发把
整拍输出写坏(一串行动一个都不认识 —— 多闭括号那形已由括号栈自修,这次是另一形),
从前一次没认出就发「需要你判断下一步」兜底卡,还把 JSON 原话整段摆卡面。
现在 `normalizeTurn` 标 `unrecognized` → decide **就地重问一次**(原文经 `note`
存工作日志内部痕,附「只输出 JSON」硬提示);第二次仍不成形才交人,卡面只说人话
(原话在日志,不上卡)。假网关三测钉死:重问只一次、原文进痕、好拍不白重问。
复测 e2e a1:released、被拒 0(11 拍/102k)。
- **提交钮靠右 + A1 占位补齐三级**(2026-08-03 用户两条):①执行工单的提交钮从卡脚
左侧移到右侧(`jc-fc-ft` 加 flex-end;验收卡「重做|通过」有 `jc-fc-sp` 撑着不受影响;
旧版报告页脚 space-between 同批)。②A1 报告里品类/制造商/目标价三列的空占位从灰
「选填」改橘「**建议**」(用户点的是目标价)—— 档位跟台账声明走:required→必填红、
expect→建议橘、其余→选填灰,颜色与 A2 同套;备注仍选填;「分级依据」那段话同步改口。
跟着一条(用户:「没有和数字对齐」):裸 inline-flex 胶囊按 baseline 挂,盒子比字的
基线深几像素,整枚往下坠、行高忽高忽低 —— 空格子(`:empty`)转 flex 居中 +
`min-height` 撑住文字那份盒高,胶囊中线对数字中线(陷阱表入册)。
再跟一条(用户:「编辑态太拥挤」):那条 flex 规则比「编辑态藏只读份」的 display:none
**更特异**,编辑态里胶囊盖过隐藏冒了出来,54 个输入框旁边各站一枚 —— 圈上
`:not(.on)`:胶囊只属于看的那份,编辑态里该不该填由闸与红带说。
quote-intake@48.2.0 已装,62 测绿。A3 的占位三级仍欠着(debt)。
- **保存不再闪:就地换内容的第二层是逐节点 morph**(2026-08-03 用户:「保存的时候
HTML 会闪一下,之前都修过怎么又来了」):08-02 那刀把 srcDoc 重载换成
`body.innerHTML`,文档不再重载 —— 可整树仍是**推倒重建**:47KB 样式表在 body
再解析一遍(head 那份还在,从此一文档两份同文样式,实测拿探针在真单上量出来的)、
二十来个 SVG 全部重建、CSS 动画回第 0 帧重起跳 —— 同一个症状,病根在下一层。
现 `ViewContainer.replaceInPlace`:顶层样式归位 head 去重(注意 `:scope > style`
在 DocumentFragment 上**实测不命中**,要用 children 过滤 —— 不然新旧顶层错一位
全树失配,浏览器独立三轮验证逮出来的)+ `morphNode` 新旧逐节点对齐,一样的不碰,
输入件活值只在拟值真变时跟上(人敲了一半的字不因无关重推被擦);任何一步栽了
退回整树重建(闪但不错)。验证:浏览器独立三轮(改值保identity/加行保敲字/
遗留双样式自愈)全过;1103 测、typecheck/lint 绿。真单上的探针账:save 后
probe alive、loads 0、无 remount —— 闪的确不是重载,是重建。
- **「保存后旧值还持续一会儿」才是人看见的那件事**(2026-08-03 用户第三次指出同一处):
上面两刀治的都是**重绘**,而人说的是**内容**:保存要跑个来回(POST 落草稿 → 失效缓存 →
重取详情 → 新件推回),闸却在按下那一刻就关了 —— 这几百毫秒里屏上摆的是只读那份的
**旧值**,人刚敲完的数当场消失,看着像这一下没生效(会再改一遍、再按一次)。
值本来就在手上,不必等服务端告诉我们:按下保存**先把新值写进只读孪生件**再关闸
(`mirrorTwins`),回声到达时 morph 一比已经一样,一次重绘都没有。
只镜像格子自己的值 —— 红带缺几格 / 分布条 / 去向是**基座算的数**,一律等回声(界面不撒谎)。
途中逮住一条会让这一段静默失效的事实:**渲染那一遍把 `data-slot` 连同槽位一起消费掉**,
活 DOM 里只读那份与 input 都没有这个属性;认孪生件要**按位置**(`previousElementSibling`)。
第一版按 `data-slot` 找,在我自己搭的合成 fixture 上全绿、在真单上一格都不动 ——
**验证要在真单的 DOM 上做**。实测:显示 1ms(点击同步内完成)、20 秒 663 帧零抖动、
值真落库(服务端详情里就是新值)。
- **A4 成本定价补齐到验收线**(2026-08-03,`quote-pricing@3.5.0`):契约宽进(input.required 清空,
原本 required `customerCode`+`records` 而上游 A3 发的是 `rows` —— 这条边真跑会断)、
`draft` 口载荷对齐 A6、哨兵与判据全改基座读数(`draft_total` 自报数是第 5 类同款违反)、
两份件(pricing-worksheet / pricing-roster)、78 条包内自测、e2e 加 `a4` 套件
(含**决策卡代答** answerDecisions / expectDecision)。**e2e a4 3/3 released、被拒 0**
(7-9 拍 / 54-74k),闸每单真拦,超限那单真发决策卡。三处踩坑各留一条账:
①**只有一列价时 AI 会编价**(库里查不到的料它照样填一个,交代还说「价空着等你定」)——
结构治:拆 `suggested_price`(AI 算)/ `quoted_price`(人填,`required`),
闸从此每单有牙,不靠模型分辨「这行算得出没有」;②门禁三列没人写、放行门只说
「数不对/行不对」→ 底价自检独立成项 `S3_gate`,`doneWhen` = 三档守恒(与验收 L2 同源);
③人点完决策卡后那一步被重念、guide 头一段写着「发一张决策卡」→ 连发四张烧光调人预算,
改成**先分岔(轨迹里人选了没有)再谈动作**。
- **决策卡也有了幂等兜底**(2026-08-03,A4 逼出):`resendVerdict` 抽成纯函数,
执行工单与决策卡**同一把尺** —— 人已答过的同一问,头两次「无事发生」(卡不真发、
humanCalls 不烧,只把事实摆回轨迹),第三次起才算被拒(熔断仍看得见)。
从前只有执行工单那一路有,决策卡一道都没有:同一个岔路口连发四张,四次调人预算
烧光落 `exhausted`。**按钮按两下不该变成墙,也不该变成四张卡**;6 测钉死三档。
- **重发判断要走在站上场之前**(2026-08-03,**A1 基线当场熔断**逼出来的顺序):
人答完之后那一步常常已经收掉,而 `readyToWork` 走在前面 —— 对 Completed 项直接抛
「这一步已经办完,不能再往它上面挂活」,于是「重发已答过的卡 = 无事发生」这条**唯一的活路
在最需要它的时候够不着**:同一条拒回重复五遍熔断(实测 13 拍 124k、发口一个都没有,
而数据本身合规)。重发一张已答过的卡本就不需要那一项在场 —— 它什么也不做。
两族(human / decide)同批换序,无事发生那一路**原样交回局面**(不顺手把项推上场)。
这是设计律第一条「墙要配路」的第三次现形。复跑 A1 四轮:三轮 released 被拒 0、
一轮 released 被拒 4,**熔断不再出现**。
同批修 e2e 脚本一处误判:**「已回传」不是「闸拦着」** —— 两种 400 长得一样而处置正相反,
当成闸拦着就原样再交一遍、三次判失败,把一单 released 的 A1 记成了失败。
- **A6 报价议价补齐到验收线**(2026-08-03,`quote-negotiate@3.5.0`):五项
(排版报价函 → 交人发出并落客户回音 → 逐条定案 → **还价才叫人拍板** → 放行),
四道哨兵全读基座读数,两张判定表(N-01 议价结果 / N-02 让步梯度),三口带 `when`
(won / revise / lost),两份件(negotiate-worksheet 可编辑 + negotiate-roster 收口含落边图),
90 条包内自测,e2e 加 `a6` 套件。**连跑两轮 3/3 released、被拒 0**(6-8 拍 / 44-65k),
三口各覆盖一条,闸每单真拦,还价那单真发决策卡。三个结构账各修死并有测:
①**同一张决策卡连发六次** —— `revise` 那条路原写「定案不动」,人点完哨兵照旧成立;
改:`deal_status` 加第四档 `REVISE`,拍板三条路都必须动它,「办完了」在数上看得见。
②**模型自己写 REVISE 绕过拍板**(一轮 released 而决策卡 0 张,让步梯度成摆设)——
病根:哨兵读模型判出来的 `deal_status`;改读**客户自己填的那一格**(`_reply_type_counter`,
基座现数)。**「该不该叫人」不能由被叫的那一方说了算**。
③断点那几拍模型自编决策卡(拿计划项中文名当 itemId / 换问法发出两张)——
itemId / ask / 选项值写进**说明书**(说明书每拍都在提示词里,而 step guide 在断点时没摊开)。
**等外部回音落在执行工单上**(人收到信把回音填进件里),不用 `listen` ——
引擎的 `listen` 恒挂起而 desk 侧没有投递 data 事件的入口(记 debt)。
- **五套十三案例一次全绿**(2026-08-03,第三阶段主体达成):A1 1 + A2 3 + A3 3 + A4 3 + A6 3
= **13/13 released、行动被拒 0、发口全对、提交闸每单真拦**(6-11 拍 / 45-108k token;
两张决策卡分别落在 A4 超限拍板与 A6 让幅超授权)。门禁 1278 测、typecheck / lint 全绿。
这一轮同时是**共享面改动的回归证据**(agent-runner 重问 / turn-runner 重发换序 +
决策卡幂等 / ViewContainer morph 与保存镜像)。A7 归档同法补齐中。
- **「机检只读基座数」第一次覆盖到工具**(2026-08-03,治 A2 判稳偶发走岔):
新读数 `tool_<工具名去符号>_calls`(`toolCallReadings` 按留痕数**成功返回**的次数,
声明过的工具一律先铺 0;调失败不算查过 —— 调不通等于没拿到行情)。
病根:判稳的行情只有 `crm.history-price-read` 一条来路,而模型偶发压根没调工具
就把料判成「稳」转 A,**自报的依据列写得漂漂亮亮**,去向从寻源走成定价(约十次一次)。
说明书已写到头,**措辞治不了的结构治**(设计律第六条)。A2@4.14.0 立 L2:
`quote_alternative_list_type_tag_a = 0 or tool_crm_history_price_read_calls > 0`
—— **写成析取**:一颗都没转 A 就无需查行情(全 B 那条路合法),有 A 才必须亲查过。
静态审(`static-audit.boundNames`)与包内自测的「谓词绑得上」名单同批扩到这一类
(`declarations.tools` 在审计入参里做可选 —— 老调用方只传 ledger,别掀翻整份审计)。
判据锚在 `orig_type_c`(原标)而非现标 `type_tag_a`:清单里的 A 多半是**上游原样承继**的,
拿现标当锚会把「全是承继的 A、一颗 C 都没有」这条合法路径也拦下。
- **「闸没拦」与「压根没东西可拦」是两件事**(2026-08-03,一次假失败逼出来的):
`expectGateBlock` 从前写成「空手交卷必须吃 400」,于是模型碰巧把该填的格子全填上的那些轮
(A1 补齐了缺的年用量、A2 客户点名的替代料型号本来就在来件里)一律被判成「提交闸失效」。
**而闸好端端的** —— 拿一单直接空手交卷实测吃的就是 400,还点名了缺哪一行哪一格。
现在断言分三档:blocked / nothing-missing(该填的全填上了,闸无需出手)/ leaked(缺着还放过去,真失效),
用**与 buildEdits 同一份草稿、同一个列名**去数。断言报的失败是假的,真失效那天反而没人信它。
- **A7 情报归档补齐到验收线**(2026-08-03,`quote-archive@3.0.0`):归一情报卡 →
一张零输入执行单(逐条落**输赢归因** + 竞对价)→ 按败价幅度算档 → **入库口径决策卡**
拍板(全量/脱敏/仅内部)→ 放行,一口 `archived`。哨兵读**人填的那一格**、
6 条 L1/L2 全读基座数、`competitor_price` 用 `requiredWhen{cause_tag:PRICE}`
(交期/认证败的行不被拦成死锁)、82 条包内自测。**e2e a7 3/3 released、被拒 0**(各 7 拍/54k)。
同批退役三张共享域表并留墓志铭(单案例算不出月度落地率、客户档案 A1 已持有、
行情历史与情报卡是同一份事实)。
- **A2 常路会跳过派给 FAE 那一步**(2026-08-03 发现,**未修**,已写 debt):
案例①连续三轮 6 拍收口、一张执行工单都没发 —— 模型自己把替代料填了,
而 S2 的 `doneWhen` 认的正是那几格填没填,于是收步条件当场成立、**人环整个消失**。
这是「触达世界的项不给 doneWhen」的反面教材:「把清单交给 FAE 选型」触达的是人,
完成与否不该由数据形状推出来。治法与 T_release 同款(人工激活 + 不给 doneWhen)。
**排除项**:撤掉当日新加的工具留痕判据(@4.15.0)后原样复现 —— 不是它引起的。
- **A1 的话与工单说的不是一回事**(2026-08-03 用户四连指出:「这个工单是让员工填必填,
建议追问目标价,其他选填,你这条日志的表述就完全变了」):件与卡早已是**三档**
(必填红 / 建议橘 / 选填灰),而 A1 的说明书还在教**两档** —— 目标价被明写成「选填」,
于是交代里说成「缺的选填项你看着补」;更糟的是同一句里「NE555P 我先照历史价估」:
**它把 S4 查来的历史价当成了客户的目标价填进去** —— 那是替客户表了态,
下游定价议价全建在这个假数上。三处同批改齐(`quote-intake@49.2.0`):
①说明书「两种分量」改「三种分量」,逐列点名哪几格是建议档,并写死
**目标价客户没给就留空、不许用历史价或经验价估**;②say 口径写明「用件上那三个词、
只说事实」;③S3 卡骨架的 steps/standard/basis 与验收判据 label 一并换成「建议追问」
(judge 按整句对名,说明书那一处同字改)。实测交代已改成
「DAG… 缺年用量(必填),UMW… 缺制造商、NE555P 客户没给目标价(这两条建议追问,不拦单)」。
- **反例引了原话,模型照抄反例**(2026-08-03 用户送来 10:40 的单:「还是老样子啊,
派工单的时候为什么会查历史价?」):上一轮治措辞时,我把那句错话**原文**引进说明书
当反例(「别说『我先照历史价估』」)—— 关思考的模型不读「别说」的否定框,
只认框里现成的句子,又原样说了一遍。台账数据其实全对(信里给了 8.0/22,NE555P 留空,
一个假数没写),错的只有这句被我喂进去的叙事 —— 且它谈的历史价那一拍**根本还没查**
(S4 未跑,轨迹里零工具调用):纯编造。治法(`quote-intake@49.3.0`):
①**反例一律不引原话**(描述失败,不给可照抄的句子),A1 三处清扫,其余五包 grep 无同类;
②**要引原话就引正例** —— S3 派卡拍给了一句完整交代模版(三档词 + 只说事实);
③叙事纪律:历史价/分类/去向是审定之后那几步的事,派卡拍一个字不提。
验证:同一封信两轮 —— 一轮平实无编造、一轮逐字命中正例模版,台账两轮全对。
**提示词里唯一该出现的原话,是你想听到的那句。**
连带一笔:复现时我起的试跑单把用户 10:40 那单顶掉了(试跑台一次只挂一单,已有 debt)——
**用户正在测试台上测的时候,别起自己的试跑单**。
- **执行卡的指导书空了:卡骨架住错了地方**(2026-08-03 用户:「作业指导书展示不对了」):
受理需求审定卡的骨架(steps/standard/basis)写在 S3 那一步的**剧本**里,而模型把
「建档→落表→收步→派卡」压成一拍连做(被鼓励的省拍打法)—— 那一拍摊开的是 S1 的剧本,
S3 的骨架看不见,模型凭记忆拼出一张裸卡,基座兜底 basis 又摆上「当下可为(空)/目标缺口」
这类内部家具。**卡内容不该取决于它在哪一拍被发出**。两处修:
①骨架搬进**说明书**(每拍都在;A6 决策卡先立此法 —— 断点拍自编决策卡同一个坑),
剧本留一句指路;第一版误插进 S1 剧本(录入卡骨架就住在那儿,被我当成了说明书),
测试逮住后挪进 NARRATIVE 本体。②`steps`/`standard` **补进出参 schema** ——
它们从前不在 schema 里,能到卡面纯靠厂商 JSON 模式宽松放行,而本仓早有纪律
「结构化出参只吐 schema 里声明过的键」(ai/ledger 当年就是这么丢的)。
`quote-intake@49.5.0` 已装;1366 测、typecheck/lint/depcruise 绿。
用户在途那单已见到全指导书;e2e 复跑等测试台空出来再补(他的单还开着,不顶)。
## 文档权威地图 ## 文档权威地图
@@ -0,0 +1,396 @@
/**
* A2 替代选型的包内自测 —— **只准 import @sdk / @contracts / @sfeel**
* (编码规范 §5.1 / ADR-004)。安装两审那一侧的验收在
* `apps/api/.../activity-registry/__tests__` —— 二者之间只有安装通道,没有代码通道。
*
* 案例活动没有 `run`,所以自测不跑回合。这里测的是**清单本身**:
* ① 清单合规 + 判据等级不谎报;
* ② 案例模型双表示同在、触达世界的项标了人工激活;
* ③ **声明与契约对得上** —— 必交付物的台账地址、验收谓词读的列、
* 出口载荷要的标量、出口 when 读的读数,处处必须有承载;
* ④ 报告模版无脚本、去向判定链齐全。
*/
import { describe, expect, it } from 'vitest';
import { activityManifestSchema } from '@contracts';
import { evaluateCondition } from '@sfeel';
import { createMockHost } from '@sdk';
import { manifest } from '../src/manifest.ts';
import activity from '../src/index.ts';
describe('清单本身合规', () => {
it('过 @contracts 的 schema', () => {
expect(() => activityManifestSchema.parse(manifest)).not.toThrow();
});
it('监听时限已配 —— 无悬空监听(AM 4.4)', () => {
expect(manifest.declarations.listeners.every((l) => /^P/.test(l.deadline))).toBe(true);
});
it('L1/L2 判据都给了可机检的谓词 —— 验收等级不得谎报', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef, c.key).toBeDefined();
}
});
it('禁区短而硬,授权边界给了调人预算', () => {
expect(manifest.contract.prohibitions.length).toBeGreaterThan(0);
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(0);
});
it('每件必交付物都有台账地址 —— 没地址,「缺一不得提交放行」只能靠活动自报', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(d.ledger, d.key).toBeDefined();
}
});
/*
* MVP 口径:A2 **没有判定表**。上一版的 GRADE_DMN 声明着三个入参而台账没有承载、
* 剧本零调用 —— 一张没有求值器的判定表不是资产,是错觉。判稳规则写死在剧本里。
*/
it('没有声明无求值器的判定表 —— 判稳规则写死在剧本(带 90 天那把尺)', () => {
expect(manifest.assets.businessModel.dmn.tables).toEqual([]);
const reclass = manifest.assets.businessModel.steps?.find((s) => s.item === 'S3_reclass');
expect(reclass?.guide).toContain('90');
expect(reclass?.guide).toContain('marketStable');
});
});
describe('案例模型 —— 双表示同版本', () => {
const model = manifest.assets.businessModel;
const cmmn = parseCmmnLite(model.cmmnXml);
it('CMMN 与说明书同在,不允许分叉', () => {
expect(model.cmmnXml).toContain('<casePlanModel');
expect(model.narrative.length).toBeGreaterThan(400);
});
/*
* **MVP 简化口径的四项**:归一(AI)→ 交 FAE 填回(一张执行单)→ 判稳定标(AI)→ 放行。
* SOP 里初选核险 / 圈选替代 / 对比评估的实质工作在 FAE 系统外,不建成项。
*/
it('计划项照状态机一步一项:归一 + 填回 + 判稳 + 放行,不多建一件', () => {
expect(items(model.cmmnXml)).toEqual(['S1_normalize', 'S2_fill', 'S3_reclass', 'T_release']);
});
it('两处人的门都标了人工激活:FAE 填回 · 清单放行', () => {
for (const id of ['S2_fill', 'T_release']) {
const block = model.cmmnXml.slice(model.cmmnXml.indexOf(`id="${id}"`));
expect(block.slice(0, block.indexOf('</humanTask>')), `${id} 少了 manualActivationRule`).toContain(
'<manualActivationRule',
);
}
});
it('不用阶段 / 里程碑 / 裁量项 —— 它们换不来业务上说得出的好处', () => {
for (const tag of ['<stage', '<milestone', '<planningTable', '<discretionaryItem']) {
expect(model.cmmnXml, `不该出现 ${tag}`).not.toContain(tag);
}
});
it('常来回的项标了可重复 —— 归一会随剔行改数重入', () => {
expect(cmmn.repetition).toContain('S1_normalize');
});
it('末尾一张验收单 —— 完成只出自放行通过', () => {
expect(model.cmmnXml).toContain('id="T_release"');
});
/*
* **放行门的哨兵必须有 ifPart,且认「办完了没有」、读基座数**(A1 s_routed 的账:
* 只有 onPart 的哨兵等于「有人写过一笔就开门」,模型写个 false 就推开了)。
*/
it('放行门哨兵带 ifPart:C 类清零 + 定标守恒 + 去向定过,全读基座数', () => {
const condition = sentryIf(model.cmmnXml, 's_routed');
expect(condition).toContain('quote_alternative_list_type_tag_c = 0');
expect(condition).toContain('quote_alternative_list_type_tag_a + quote_alternative_list_type_tag_b');
expect(condition).toContain('route_decided_total');
// FAE 漏填一行替代料(那一行仍是 C)→ 门不开;全转完 → 门开
const done = { route_decided_total: 1, quote_alternative_list_rows: 2, quote_alternative_list_type_tag_a: 1, quote_alternative_list_type_tag_b: 1, quote_alternative_list_type_tag_c: 0 };
expect(safeEval(condition, done)).toBe(true);
expect(safeEval(condition, { ...done, quote_alternative_list_type_tag_c: 1, quote_alternative_list_type_tag_b: 0 })).toBe(false);
});
it('每个计划项都有剧本 —— 轮到哪一步才摊开哪一步', () => {
const ids = items(model.cmmnXml);
expect((model.steps ?? []).map((s) => s.item)).toEqual(ids);
for (const step of model.steps ?? []) {
expect(step.guide.length, `${step.item} 的做法`).toBeGreaterThan(80);
expect(step.intent.length, `${step.item} 的意图`).toBeGreaterThan(6);
}
});
it('说明书不点名计划项 —— 点了就会与案例模型分叉(A1 踩过)', () => {
const named = [...model.narrative.matchAll(/\b(?:S\d+_[A-Za-z]+|T_[a-z]+)\b/g)].map((m) => m[0]);
const ids = new Set(items(model.cmmnXml));
for (const name of named) {
expect(ids.has(name), `说明书提到的 ${name} 不在案例模型里`).toBe(true);
}
});
it('说明书只讲整单的事:成品、怎么说话、怎么收口', () => {
for (const topic of ['开案', '要说话', '一拍连做', '结案']) {
expect(model.narrative, topic).toContain(topic);
}
});
});
/**
* 声明与契约的对齐 —— 三处引用同一批台账列,错位了不会报错,只会在放行那一刻
* 「判据取不到值 / 出口取不到字段」,而两者都长得像业务问题。
*/
describe('声明与契约对得上', () => {
const columns = new Set(
manifest.declarations.ledger.flatMap((d) => [`${d.domain}.${d.object}`, ...d.fields.map((f) => f.name)]),
);
it('必交付物的台账地址都声明过', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(columns.has(d.ledger ?? ''), `${d.key} → ${d.ledger}`).toBe(true);
}
});
it('editableAtRelease 的列都在成果表的声明里 —— 不在名单里的列压根不进写回载荷', () => {
const list = manifest.declarations.ledger.find((d) => d.object === 'list');
const names = new Set((list?.fields ?? []).map((f) => f.name));
for (const d of manifest.contract.requiredDeliverables) {
for (const col of d.editableAtRelease ?? []) {
expect(names.has(col), `editableAtRelease 里的 ${col}`).toBe(true);
}
}
});
const rowReadings = new Set([
...manifest.declarations.ledger.map((d) => `${d.domain}_${d.object}_rows`),
...manifest.declarations.ledger
.filter((d) => d.fields.some((f) => f.required === true))
.map((d) => `${d.domain}_${d.object}_missing`),
...manifest.declarations.ledger.flatMap((d) =>
d.fields.flatMap((f) =>
(f.tally ?? []).map((v) => `${d.domain}_${d.object}_${f.name}_${v.toLowerCase().replace(/[^a-z0-9_]/g, '')}`),
),
),
/*
* **工具调用读数**(2026-08-03 起):基座按留痕数「这一单调了几次某工具」,
* 声明过的工具一律先铺 0。有了它,「转 A 有据」这类判据才两边都不是活动自报的。
*/
...manifest.declarations.tools.map((t) => `tool_${t.name.replace(/[^0-9a-zA-Z]+/g, '_')}_calls`),
]);
it('L1/L2 谓词读的名字都有承载(台账列或基座读数)—— 否则机检永远取不到值', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level !== 'L1' && c.level !== 'L2') continue;
for (const identifier of (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? []) {
if (['and', 'or', 'not', 'true', 'false', 'null', 'in', 'between'].includes(identifier)) continue;
if (rowReadings.has(identifier)) continue;
expect(columns.has(identifier), `${c.key} 的谓词引用了 ${identifier}`).toBe(true);
}
}
});
it('出口选择组每一口的 when 读的名字也都有承载 —— 静态审不查它,这里自己查', () => {
for (const group of manifest.contract.output.selective) {
for (const port of group.ports) {
for (const identifier of (port.when ?? '').match(/[a-z_][a-z0-9_]*/g) ?? []) {
if (['and', 'or', 'not', 'true', 'false', 'null', 'in', 'between'].includes(identifier)) continue;
if (rowReadings.has(identifier)) continue;
expect(columns.has(identifier), `${port.name} 的 when 引用了 ${identifier}`).toBe(true);
}
}
}
});
it('出口载荷的标量字段都有台账列承载(踩过:去向旗没列 → E5 从来不成立)', () => {
const snake = (name: string) => name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
for (const port of manifest.contract.output.mandatory) {
const properties = (port.payload as { properties?: Record<string, { type?: string }> }).properties ?? {};
for (const [field, spec] of Object.entries(properties)) {
if (spec.type === 'array') continue;
expect(columns.has(snake(field)), `${port.name}.${field} → ${snake(field)}`).toBe(true);
}
}
});
it('技能引用的工具都在白名单声明内 —— 载方法不载权力', () => {
const declared = new Set(manifest.declarations.tools.map((t) => t.name));
for (const skill of manifest.assets.skills) {
for (const fragment of skill.crystallized) {
expect(declared.has(fragment.tool), `${skill.id} 用了 ${fragment.tool}`).toBe(true);
}
}
});
/*
* 裁量判据的 label 与剧本里 judge 的 criterion 必须**逐字一致** ——
* judge 按整句对名,差一个字那一条永远算没裁过,提交被拒。
*/
it('L3 判据的整句出现在放行剧本里 —— judge 才对得上名', () => {
const l3 = manifest.contract.acceptanceCriteria.find((c) => c.level === 'L3');
const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
expect(l3).toBeDefined();
expect(release?.guide, 'T_release 的 guide 要整句抄 L3 判据名').toContain(l3?.label ?? '');
});
});
describe('报告模版 —— 活动持有,容器无脚本执行权', () => {
/*
* **选型作业与收口验收各有各的件**(2026-08-03 用户:「验收工单怎么又变成
* 选型了?」)—— One Pager 归选型执行单(S1/S2),整合清单归收口(S3/放行/归档,
* 「清单是所有选型完成后收口才整合的东西」)。「1:1」落在每份件与它的办结快照
* 之间(finalHtml),不是全程只许一张。
*/
it('两件各归各位:选型 One Pager 归作业,整合清单归收口', () => {
expect(manifest.assets.viewTemplates.map((t) => t.name)).toEqual(['alt-worksheet', 'alt-roster']);
const viewOf = (item: string) => manifest.assets.businessModel.steps?.find((s) => s.item === item)?.view;
for (const item of ['S1_normalize', 'S2_fill']) expect(viewOf(item), item).toBe('alt-worksheet');
for (const item of ['S3_reclass', 'T_release']) expect(viewOf(item), item).toBe('alt-roster');
});
it('验收报告重列表与落边:汇总带 + 受理记录大表 + 落边图(命中的边亮)', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'alt-roster')?.html ?? '';
expect(roster).toContain('statrow');
expect(roster).toContain('data-class-of="case:routeKey"');
// 落边图:两条边(A3 寻源 / A4 定价)+ 命中流光
for (const mark of ['class="redge e1"', 'class="redge e2"', 'class="rpulse p1"', 'class="rpulse p2"']) {
expect(roster).toContain(mark);
}
for (const t of ['A3 · 渠道寻源', 'A4 · 成本定价', '还有 B 类?']) expect(roster).toContain(t);
for (const col of ['orig_type', 'type_tag', 'alt_grade', 'basis']) expect(roster).toContain(`data-slot="${col}"`);
// 收口件只读:就地改走验收卡的 editableAtRelease 表,这里一个输入格都不给
expect(roster).not.toContain('data-edit=');
expect(roster).not.toContain('data-col=');
});
/*
* 对决区(2026-08-02 用户定):左侧被替换目标的静态 SVG 卡,右侧替换卡可编辑,
* 替代等级三枚选择钮直接选(正确性全由人控);渲染态就是结构,
* 编辑只点亮输入;02 选型指引铺开(不写类型,口径贴在选择钮上)。
*/
it('执行工单重单颗选型:档案头 + 蓝图带 + 题面/定案双栏 + 等级三选 + 方法脚注,每颗一页', () => {
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'alt-worksheet')?.html ?? '';
expect(sheet).toContain('class="blueprint"');
expect(sheet).toContain('data-page');
expect(sheet).toContain('class="alt-page"');
expect(sheet).toContain('class="given"');
expect(sheet).toContain('class="decide"');
// 等级 = 三枚 radio 直接选,值域即台账取值域
for (const v of ['value="P2P"', 'value="FUNC"', 'value="OTHER"']) expect(sheet).toContain(v);
expect((sheet.match(/name="alt_grade_pick"/g) ?? []).length).toBe(3);
expect(sheet).toContain('class="m-five"');
expect(sheet).toContain('class="m-funnel"');
expect(sheet).toContain('data-slot="remark"');
expect(sheet).toContain('同封装不等于同引脚');
});
/*
* 空占位三级(2026-08-02 用户定,全局口径):必填红 / 建议填橘 / 选填灰,
* 与声明 required / expect / 其余一一对应。
*/
it('空占位三种:必填(红)· 选填(灰)· 建议(橘);待选择归必填', () => {
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'alt-worksheet')?.html ?? '';
for (const word of ['"必填"', '"建议填"', '"选填"']) expect(sheet).toContain(word);
// 等级空位归「必填」—— 不再有单独的「待选定」文案
expect(sheet).not.toContain('待选定');
// 声明侧对应:定案三格 requiredWhen(NOW 行必填),三格 expect(建议填)
const cols = new Map(manifest.declarations.ledger.find((l) => l.object === 'list')?.fields.map((f) => [f.name, f]) ?? []);
for (const c of ['alt_pn', 'alt_brand', 'alt_grade']) expect(cols.get(c)?.requiredWhen?.value, c).toBe('C');
for (const c of ['alt_diff', 'alt_risk', 'alt_verify']) expect(cols.get(c)?.expect, c).toBe(true);
expect(cols.get('alt_note')?.expect).toBeUndefined();
});
it('模版里没有脚本、没有事件处理器、没有外链 —— 样式自带', () => {
for (const template of manifest.assets.viewTemplates) {
expect(template.html, template.name).not.toMatch(/<script|\son[a-z]+=|https?:\/\//i);
expect(template.html, template.name).toContain('<style>');
}
});
it('One Pager 不摆去向与清单 —— 那是收口整合的东西(2026-08-02 用户定),去向在验收卡上', () => {
// 断正文 —— PRD 共享样式表里带着 jc-dmn 的规则,不算摆了判定表
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'alt-worksheet')?.html ?? '';
const markup = sheet.replace(/<style>[\s\S]*?<\/style>/, '');
expect(markup).not.toContain('case:routeKey');
expect(markup).not.toContain('jc-dmn');
});
it('编辑面 = FAE 的定案列 + 行的钥匙 pn —— 保存按格里的主键认行,少了它两行会对到一行上(走查逮过)', () => {
const fae = ['alt_pn', 'alt_brand', 'alt_grade', 'alt_diff', 'alt_risk', 'alt_verify', 'alt_note'];
const html = manifest.assets.viewTemplates.find((t) => t.name === 'alt-worksheet')?.html ?? '';
const cols = new Set([...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]));
expect(cols.has('pn'), '少了行的钥匙 pn').toBe(true);
for (const col of fae) expect(cols.has(col), `少了定案列 ${col}`).toBe(true);
// 现标不在工作单编辑面(推翻判定是放行时的职权,走验收卡的 editableAtRelease 表)
expect(cols.has('type_tag')).toBe(false);
});
it('选择组里每一口都写了判据,两口互斥覆盖 —— 去向才算得出来', () => {
const groups = manifest.contract.output.selective;
expect(groups.length).toBeGreaterThan(0);
for (const group of groups) {
expect(group.ports.length).toBeGreaterThanOrEqual(2);
for (const port of group.ports) expect(port.when, `${group.group}/${port.name}`).toBeTruthy();
}
// 还有 B 类 → 去寻源;全 A → 直通定价 —— 同一个数,两口恰好互斥
const [toSourcing, toPricing] = groups[0]?.ports ?? [];
expect(safeEval(toSourcing?.when ?? '', { quote_alternative_list_type_tag_b: 2 })).toBe(true);
expect(safeEval(toPricing?.when ?? '', { quote_alternative_list_type_tag_b: 2 })).toBe(false);
expect(safeEval(toSourcing?.when ?? '', { quote_alternative_list_type_tag_b: 0 })).toBe(false);
expect(safeEval(toPricing?.when ?? '', { quote_alternative_list_type_tag_b: 0 })).toBe(true);
});
it('宿主拒推含脚本的模版 —— 这道门在基座,不靠模版作者自觉', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
});
});
const CONTRACT = {
activityId: 'quote-alternative',
version: '1.0.0',
data: { rfqRef: 'RFQ MD14.1', records: [] },
message: undefined,
outputPorts: { mandatory: ['roster'], selective: [] },
prohibitions: [],
discretion: [],
} as const;
describe('代码入口 —— 案例活动没有 run,不是残包', () => {
it('默认导出是一个有名字的活动定义', () => {
expect(activity.name).toBe('quote-alternative');
});
it('mock 宿主如实报「没有 run」,不静默返回一个假的 outcome', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.run(activity)).rejects.toThrow(/没有 run/);
});
});
/** 计划项 id 按出现次序 —— 断言「图上有几步就是几项」 */
function items(xml: string): string[] {
return [...xml.matchAll(/<(?:humanTask|decisionTask|processTask|caseTask) id="([^"]+)"/g)].map((m) => m[1] ?? '');
}
function parseCmmnLite(xml: string): { manualActivation: string[]; repetition: string[] } {
const manualActivation: string[] = [];
const repetition: string[] = [];
for (const m of xml.matchAll(/<humanTask id="([^"]+)"[\s\S]*?<\/humanTask>/g)) {
if (m[0].includes('manualActivationRule')) manualActivation.push(m[1] ?? '');
if (m[0].includes('repetitionRule')) repetition.push(m[1] ?? '');
}
return { manualActivation, repetition };
}
/** 抠出某枚哨兵的 ifPart 条件(XML 实体还原)—— 断言放行门认的是「办完了没有」 */
function sentryIf(xml: string, sentryId: string): string {
const block = xml.slice(xml.indexOf(`<sentry id="${sentryId}"`));
const raw = /<condition>([\s\S]*?)<\/condition>/.exec(block.slice(0, block.indexOf('</sentry>')))?.[1] ?? '';
return raw.replaceAll('&gt;', '>').replaceAll('&lt;', '<').replaceAll('&amp;', '&');
}
/** 谓词写坏了引擎当作「没算出来」——测里也照这个口径 */
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
@@ -0,0 +1,212 @@
/**
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(A1 的账)。
*
* 这几条断言守的是剧本本身的可用性:项 id 对不对得上、判据会不会开局即真、
* 声明的台账与工具是不是活动真有的、每一项有没有进度条的格子。
*/
import { describe, expect, it } from 'vitest';
import { evaluateCondition } from '@sfeel';
import { ALTERNATIVE_PHASES, ALTERNATIVE_STEPS, CMMN_XML } from '../src/case-model.ts';
import { manifest } from '../src/manifest.ts';
/** 从 CMMN XML 里把计划项 id 抠出来 —— 剧本认的是它们 */
const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map(
(m) => m[1] ?? '',
);
describe('剧本与案例模型对得上 —— 对不上的条目等于没写', () => {
it('每一条剧本都指向一个真实存在的计划项', () => {
for (const step of ALTERNATIVE_STEPS) {
expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
}
});
it('图上每一步都有剧本 —— 漏掉的那一步会退回整本说明书', () => {
for (const id of ITEM_IDS) {
expect(ALTERNATIVE_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
}
});
it('清单里挂的就是这份剧本(不是另抄一份)', () => {
expect(manifest.assets.businessModel.steps).toBe(ALTERNATIVE_STEPS);
});
});
describe('doneWhen 是「成果已存在」的判据,不是「该不该做」的判据', () => {
/** 开案那一刻的两种局面:流内(上游载荷)与单飞(一段需求)—— 台账一行没有 */
const FRESH_FLOW: Record<string, unknown> = {
rfqRef: 'RFQ MD14.1',
records: [{ pn: 'BBPY00201209601Y00', type_tag: 'C' }],
customer_name: 'Itelma',
};
const FRESH_SOLO: Record<string, unknown> = {
subject: '车规运放选型需求',
body: '需要一颗车规级双通道运放……',
};
it('**开局一步都不该被自动收掉** —— 流内与单飞两种开局都要 false', () => {
for (const fresh of [FRESH_FLOW, FRESH_SOLO]) {
for (const step of ALTERNATIVE_STEPS) {
if (step.doneWhen === undefined) continue;
expect(
safeEval(step.doneWhen, fresh),
`${step.item} 的 doneWhen「${step.doneWhen}」开局就为真,那一步会在还没干之前被收掉`,
).toBe(false);
}
}
});
/* ── ① 归一清单 ─────────────────────────────────────────────── */
it('整份落表且两边对上 → 归一收得掉', () => {
expect(
safeEval(doneOf('S1_normalize'), { alt_line_total: 2, quote_alternative_list_rows: 2 }),
).toBe(true);
});
it('**少拆多写都收不掉** —— 自报条数与基座行数对不上,错在第 2 拍就现形', () => {
expect(safeEval(doneOf('S1_normalize'), { alt_line_total: 2, quote_alternative_list_rows: 3 })).toBe(false);
expect(safeEval(doneOf('S1_normalize'), { alt_line_total: 0, quote_alternative_list_rows: 0 })).toBe(false);
});
/* ── ② 交 FAE 填回:人环,引擎不许替人按 ─────────────────────── */
it('**FAE 那一步不给 doneWhen** —— 引擎不许替人交卷', () => {
expect(ALTERNATIVE_STEPS.find((s) => s.item === 'S2_fill')?.doneWhen).toBeUndefined();
});
it('填回这一步是一张执行单:工作单一颗一页、卡上零输入、答复处理记 filled_total', () => {
const fill = ALTERNATIVE_STEPS.find((s) => s.item === 'S2_fill');
expect(fill, 'FAE 填回这一步不见了').toBeDefined();
expect(fill?.view).toBe('alt-worksheet');
/*
* **一张执行单,多页待执行报告**(2026-08-02 用户定,替代此前的逐颗按序派单):
* 一颗待选型芯片一页,右上角翻页;定案三格是每页必填(requiredWhen orig_type=C),
* 提交闸看着。卡上零输入;人交回后只清点 filled_total、finish。
*/
expect(fill?.guide).toContain('一颗待选型芯片一页');
expect(fill?.guide).toContain('只发一次');
expect(fill?.guide).not.toContain('"fields"');
expect(fill?.guide).not.toContain('sel_stage');
expect(fill?.guide).toContain('filled_total');
for (const code of ['P2P', 'FUNC', 'OTHER']) expect(fill?.guide).toContain(code);
});
it('归一那一步不再排队列 —— 单卡多页后 sel_stage 已退役', () => {
expect(ALTERNATIVE_STEPS.find((s) => s.item === 'S1_normalize')?.guide).not.toContain('sel_stage');
});
/* ── ③ 判稳定标:收步判据与验收判据同源 ─────────────────────── */
it('C 类清零且去向定了 → 判稳收得掉', () => {
expect(
safeEval(doneOf('S3_reclass'), { quote_alternative_list_type_tag_c: 0, route_decided_total: 1 }),
).toBe(true);
});
it('**还有一颗 C 没处置 → 判稳收不掉** —— 收步比验收松,松出来的那一截就是返工', () => {
expect(
safeEval(doneOf('S3_reclass'), { quote_alternative_list_type_tag_c: 1, route_decided_total: 1 }),
).toBe(false);
});
it('**没记过去向数 → 收不掉** —— 未绑定的名字比大小为假,不会开局即收', () => {
expect(safeEval(doneOf('S3_reclass'), { quote_alternative_list_type_tag_c: 0 })).toBe(false);
});
it('全转 A(need_sourcing = false)照样收得掉 —— 判据看的是「定过没有」,不是旗本身', () => {
expect(
safeEval(doneOf('S3_reclass'), {
quote_alternative_list_type_tag_c: 0,
route_decided_total: 1,
need_sourcing: false,
}),
).toBe(true);
});
it('**放行那一项不给 doneWhen** —— 终局唯放行,引擎不许自动收它', () => {
expect(ALTERNATIVE_STEPS.find((s) => s.item === 'T_release')?.doneWhen).toBeUndefined();
});
/*
* `records` 是上游整包落地行 —— 不声明 reads 会被整表段的截断刀盲砍
* (按字符截,砍掉的恰是 C 类行;A1 认客户那一步栽过同款)。
*/
it('归一那一步声明了 reads: records —— 上游载荷置前不截断', () => {
expect(ALTERNATIVE_STEPS.find((s) => s.item === 'S1_normalize')?.reads).toContain('records');
});
});
describe('剧本声明的东西活动真有 —— 声明了没有的,那一步照着做必然失败', () => {
const declaredLedger = manifest.declarations.ledger.map((l) => `${l.domain}.${l.object}`);
const declaredTools = manifest.declarations.tools.map((t) => t.name);
it('每一步要写的台账都在清单的台账声明里', () => {
for (const step of ALTERNATIVE_STEPS) {
for (const address of step.writes) {
expect(declaredLedger, `${step.item} 要写 ${address},而活动没声明这个台账对象`).toContain(address);
}
}
});
it('每一步要调的工具都在清单的工具声明里', () => {
for (const step of ALTERNATIVE_STEPS) {
for (const tool of step.tools) {
expect(declaredTools, `${step.item} 要调 ${tool},而活动没声明这件工具`).toContain(tool);
}
}
});
it('每一步都定了画面,且模版真在包里', () => {
const names = new Set(manifest.assets.viewTemplates.map((t) => t.name));
for (const step of ALTERNATIVE_STEPS) {
expect(step.view, `${step.item} 没定画面`).toBeDefined();
expect(names.has(step.view ?? ''), `${step.item} 的 view「${step.view}」不在模版名单里`).toBe(true);
}
});
it('每一步都说得出它交出什么(intent 与 guide 都不为空)', () => {
for (const step of ALTERNATIVE_STEPS) {
expect(step.intent.trim(), `${step.item} 没写 intent`).not.toBe('');
expect(step.guide.trim().length, `${step.item} 的 guide 太短,等于没写`).toBeGreaterThan(20);
}
});
});
function doneOf(item: string): string {
const found = ALTERNATIVE_STEPS.find((s) => s.item === item)?.doneWhen;
if (found === undefined) throw new Error(`${item} 没有 doneWhen`);
return found;
}
/** 谓词写坏了引擎当作「没算出来」——测里也照这个口径,断言才对得上运行时 */
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
describe('阶段刻度:每一项都有格可待(A1 漏 S3_confirm 的账)', () => {
const inPhase = new Set(ALTERNATIVE_PHASES.flatMap((p) => p.items));
it('**除放行外每一项恰好属于一个阶段** —— 漏一项,进度条就会在它办着的时候跳过去', () => {
const planned = ALTERNATIVE_STEPS.map((s) => s.item).filter((id) => id !== 'T_release');
expect(planned.filter((id) => !inPhase.has(id))).toEqual([]);
});
it('一项不进两个阶段 —— 进了两个,「这一格过没过」就有两种答案', () => {
const all = ALTERNATIVE_PHASES.flatMap((p) => p.items);
expect(all.length).toBe(new Set(all).size);
});
it('**放行不在名单里** —— 收口那一格由基座统一加,活动自己起名会各叫各的', () => {
expect(inPhase.has('T_release')).toBe(false);
});
it('阶段里不许出现案例模型没有的项 —— 那一格永远过不去', () => {
const ids = new Set(ALTERNATIVE_STEPS.map((s) => s.item));
expect([...inPhase].filter((id) => !ids.has(id))).toEqual([]);
});
});
+291 -90
View File
@@ -1,138 +1,339 @@
/** /**
* A2 替代选型的案例模型 —— **一张执行单办完**(2026-07-31 用户定,大幅简化)。 * A2 替代选型的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
* *
* 上一版把 SOP 上的每一小步都建成了计划项(查规格、判品牌、查沉淀、撒网初筛、 * 业务出处:`docs/MVP示例工作流.html` §1.5,但按 **MVP 简化口径**建模
* 逐条核验、确认主推、收规格书、参数对比、风险评估…… 九个 humanTask)。 * (2026-08-02 用户定:A2 是复杂活动,MVP 期间不展开大规模业务建模):
* 那是把「FAE 脑子里怎么想」当成了「系统要管几步」——
* 而这一整段在业务上是**一件事**:拿着原型号去选一个替代料,选完给出对标结论。
* 系统该做的不是替他把这件事切成九块,是**把该填的表摆好、把结果收进台账**。
* *
* 现在只有两项: * A1 带着询价受理记录转进来 → 挑出 C 类 → **一张执行单**让 FAE 在系统外完成选型、
* T_select —— 派一张执行单给 FAE(卡面两张可编辑表),他填完提交; * 把替代料填回来 → 判替代料在库里行情稳不稳:稳转 A、不稳转 B →
* T_release —— 放行(触达世界,人工激活)。 * 询价受理记录就此转换成**询价受理清单**,传给下游(有 B 类去寻源,全 A 直通定价)。
* *
* 与 A1 的分工:A1 是**受理归一**,活多、判据多、有确定性规则可依(两张判定表), * SOP 里初选核险 / 圈选替代 / 对比评估那三段的**实质工作在 FAE 脑子里与系统外**
* 值得逐步建模;A2 是**一次专家判断**,建模建得再细也换不来业务上的好处, * (撒网、读规格书、参数对比 —— 本系统没有那些数据源);系统的本分是归一、组卡、
* 只会让 Agent 全程跟 harness 较劲(A1 上一版 12 项的教训就在这儿)。 * 判稳、留痕。所以图上只有四项:归一 → 交人填回 → 判稳定标 → 放行。
*
* ── 建模纪律(照 A1 的账)──────────────────────────────────────────────
* 图上有几步就是几项,不多建一件;触达世界的项 manualActivation 且不给 doneWhen;
* 哨兵必须有 ifPart 且读基座数、认「办完了没有」;案卷项 id 就是任务视图键名。
*
* 上一版(@2.0.0)只有「派单 + 放行」两项,归一与判稳整个不存在 ——
* C 类怎么来的、去向旗从哪儿出,全靠模型临场发挥;E5 那条边(needSourcing)
* 因为台账里没有承载列,**从来没有成立过**。这一版把这两步补成真的。
*/ */
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?> export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL"> <definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
<case id="alternative"> <case id="alternative">
<!-- <!--
案卷项的 id **就是任务视图里的键名**。这里只认两样: 案卷项的 id **就是任务视图里的键名** —— 前提的 onPart 认的是「这个键有值了」。
起单入参(altRequests —— A1 发过来的那批替代评估请求)与「对标表写了几行」(benchmark_total)—— 流内起单时上游载荷(records 等)与本活动台账列都会摊平到任务视图顶层,照它们命名。
后者既是放行前提,也是这一单唯一的机检判据。
开案即有的只有上游载荷(或单飞的自由入参)。rfq_ref 是归一那一步认出来
(或造出来)的:本活动所有台账对象都以它为主键。
--> -->
<caseFileModel> <caseFileModel>
<caseFileItem id="altRequests" name="替代评估请求(开案即有,一条料一行)"/> <caseFileItem id="records" name="上游受理记录(流内开案即有)"/>
<caseFileItem id="benchmark_total" 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> </caseFileModel>
<casePlanModel id="plan" name="替代选型" autoComplete="true"> <casePlanModel id="plan" name="替代选型" autoComplete="true">
<sentry id="s_benchmarked"> <!--
<onPart id="op_bm" caseFileItemRef="benchmark_total"><standardEvent>update</standardEvent></onPart> ① 归一完了 —— 交 FAE 那一步的入场前提。
<ifPart id="if_bm"><condition>benchmark_total &gt; 0</condition></ifPart> 两边对上才算:alt_line_total 是活动自报的条数,quote_alternative_list_rows
是基座数出来的本单行数 —— 少拆一行、多写一行都进不了下一步
(同一个错,在第 2 拍发现比在第 10 拍便宜得多,A1 的账)。
-->
<sentry id="s_normalized">
<onPart id="op_nm" caseFileItemRef="alt_line_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_nm"><condition>alt_line_total &gt; 0 and quote_alternative_list_rows = alt_line_total</condition></ifPart>
</sentry>
<!--
② FAE 的答复处理过了 —— 判稳那一步的入场前提。
filled_total 是「人交回执行单之后」由 AI 清点写下的数(填回替代料的条数)——
它只在人环走完才存在,所以它就是「经过人了」的凭据(照 A1 confirmed_total 的路子)。
全部替代未果时它是 0:「大于等于 0」对「写过的 0」为真、对「没写过」为假
(S-FEEL 未绑定的名字参与比较为假)。
-->
<sentry id="s_filled">
<onPart id="op_fl" caseFileItemRef="filled_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_fl"><condition>filled_total &gt;= 0</condition></ifPart>
</sentry>
<!--
── 放行门的入场条件:**认「办完了没有」,且读基座数**(A1 s_routed 的账)────
· 清单上不再有 C 类(quote_alternative_list_type_tag_c 由基座按值现数)——
FAE 漏填一行替代料,那一行转不了 A/B,这道门就不开;
· 定标守恒:A + B = 行数(两边都是基座出的数);
· 去向定过了(route_decided_total 是判稳那一步显式写下的记账数 ——
旗是布尔,分不清「没写」与「写了 false」)。
-->
<sentry id="s_routed">
<onPart id="op_rt" caseFileItemRef="route_decided_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_rt"><condition>route_decided_total &gt; 0 and quote_alternative_list_rows &gt; 0 and quote_alternative_list_type_tag_c = 0 and quote_alternative_list_type_tag_a + quote_alternative_list_type_tag_b = quote_alternative_list_rows</condition></ifPart>
</sentry> </sentry>
<!-- 唯一的活:派一张执行单给 FAE,他填完提交 --> <!--
<humanTask id="T_select" name="替代选型:派执行单给 FAE,收回初筛与对标结论"> ① 归一询价受理清单 —— 上游受理记录(或单飞来件)整份落成本活动的清单,
<itemControl><repetitionRule id="r_sel"/></itemControl> C 类行就是本单要办的对象。**整份都落**:A/B 行原样承继 ——
这份清单最终要整份交给下游,不是只有 C 类那几行。
-->
<humanTask id="S1_normalize" name="归一询价受理清单:整份落表,C 类标待选型">
<itemControl><repetitionRule id="r_s1"/></itemControl>
</humanTask> </humanTask>
<!-- 放行:触达世界,必须人工激活 --> <!--
<humanTask id="T_release" name="替代选型结论验收放行"> ② 替代选型交 FAE —— **一张执行单**(MVP 简化口径:选型的实质工作在系统外)。
<entryCriterion id="ec_rel" sentryRef="s_benchmarked"/> 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> <itemControl><manualActivationRule id="m_rl"/></itemControl>
</humanTask> </humanTask>
</casePlanModel> </casePlanModel>
</case> </case>
</definitions>`; </definitions>`;
export const NARRATIVE = `§0 开案:A1 判出一条料要替代(原型号 EOL / 缺货 / 客户点名),这一单就到你手上。 /**
成品只有一样:**一份能让 BD 拿去跟客户讲的替代对标结论** —— * 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本,两处不重复写同一件事)。
哪几个候选、推荐哪一个、参数差在哪、有什么风险、要不要打样验证。 */
export const NARRATIVE = `§0 开案:**一份带 C 类料的询价受理记录要过替代选型**。来路有两种,形状都收(宽进):
· **流内**:上游受理活动放行后带着它的落地事实进来 —— 顶层有单号 / 客户名 / 三类条数等
摊平字段,\`records\` 数组里是上游全部落地行**混在一起**(带年用量与制造商的是受理记录行、
带分级依据与历史天数的是分类表行、带各种总数的是结论行 —— 按 PN 归并自辨,别当成三张表);
· **单飞**:没有上游,只有一段需求(型号或芯片类型 + 参数 + 场景)—— 照来件归一,
没给的照实留空。
**这一单只有一步**:把选型这件事派给 FAE(执行工单),他填完你收进台账,然后提交放行。 **这个活动是复杂活动的 MVP 简化形**:替代选型的实质工作(查规格、撒网、参数对比)
别把它拆成「先查规格、再判品牌、再撒网、再核验……」—— 那是他脑子里的过程, 由 FAE 在**系统外**完成,系统只负责三件事 —— 把清单归一好、把执行单组好交给他、
不是系统要管的步。你替他把表摆好就够了。 把他填回的替代料**判稳定标**(在库行情稳转 A,不稳转 B)。
【T_select 派执行单】发 human 行动(workOrder="execution"),卡面 blocks 放**两张可编辑表**: **成品只有一样**:一份**询价受理清单** —— 整份行都在、C 类已全部转成 A 或 B、
· 初筛清单:列 = 原型号 / 候选型号 / 品牌 / 生命周期; 去向旗立了(还有 B 类就去寻源补价,全 A 直通定价)。判稳的尺与三分类 P-03 的 A/B
· 替代对标表:列 = 候选型号 / 替代等级 / 参数差异项 / 差异风险 / 验证建议 / FAE签字。 两行同一把:**历史价 ≤ 90 天且行情稳 → A,否则 → B**。
两张都 editable:true;没有的值给空字符串 —— 空格子界面会自己标红「待填写」。 FAE 的定案不只是一个型号:**替代等级(P2P 直换 / FUNC 功能兼容 / OTHER 须验证)、
ask 一句话说清要他做什么;basis 摆上原型号、芯片类型、客户的特殊要求(裸卡基座拒发)。 参数差异、风险与验证建议一并落表** —— 那是他的专业产出,逐颗可溯。
他提交后,回传值出现在你的轨迹里:表格那一份的键是 **table:0 / table:1**(块序号), **人环两次,不多不少**:FAE 交回**一张选型执行单**(工作单里一颗待选型芯片一页,
值是整张表的 JSON —— **第一行是列名**,其余每行一条,列序与你派出去时一致。 右上角翻页逐颗定案,必填缺着按不下提交)、放行时 FAE 签字。
照它 write 进 \`quote_alternative.shortlist\` 与 \`quote_alternative.benchmark\`,
并把对标表条数记进 benchmark_total。**写完就 finish T_select,别再发第二张单** —— 他已经答过了。
【提交放行】对标表有行了 —— **先 start T_release**(人工激活:放行触达世界,必须按一下), 【一路上要说话】负责人正看着这一单。**每一拍都带一句 say** —— 说业务的话:
然后 submit,BD 验收。终局唯放行:你不能自己宣布完成。 哪几颗料要替代、替代料查出来什么行情、这单接下来往哪走;
不说「归一化」「写草稿 N 行」这类系统词。到了要找人的那一拍,先说清这一轮办完了什么、
为什么现在需要他。别每拍一个腔调复读。
【一路上要说话】负责人正看着这一单。每一拍带一句 say,说**业务的话**: 【怎么把一项干完 · 一拍连做】一个计划项常要两三个动作才交得出成果。write 和 tool
哪颗料要替代、你打算怎么办、FAE 回了什么结论、这一单往哪走。 都**不会**自动把那一项收掉 —— 都做完了,用 finish 宣告完成。动作排进同一拍的 actions
一两句、口语,像同事扭头说一声。别报「写草稿 2 行」这种系统词 —— 串里一口气给,别拆拍。只有两处必须停拍:调工具要看出参(tool 放串尾),
他明天要拿这些话去跟客户讲。`; 发卡 / 提交会挂起(只能是串尾)。**常路 6-9 拍就该收口** —— 选型只有一张卡,颗数多不多不加拍。
§Z 结案:FAE 在验收单上签字,系统把草稿合并成正式清单并发出 roster 口的载荷 ——
清单整份交给下游,下游按自己的口径归一需要的数据。`;
/** /**
* 步骤剧本 —— 与 A1 同一套机制:`doneWhen` 为真,引擎自己收步、**不问模型**。 * 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。
*
* 只有一条真活。放行那一项不给 `doneWhen` —— 它触达世界,必须人按一下(铁律③)。
*/ */
export const ALTERNATIVE_STEPS = [ export const ALTERNATIVE_STEPS = [
{ {
item: 'T_select', item: 'S1_normalize',
intent: '派一张执行单给 FAE,收回初筛清单与替代对标结论,并结算汇总数', intent: '把上游受理记录(或单飞来件)整份归一成选型底表,C 类行标待选型',
writes: ['quote_alternative.shortlist', 'quote_alternative.benchmark', 'quote_alternative.verdict'], writes: ['quote_alternative.list', 'quote_alternative.verdict'],
reads: [], /*
* `records` 是上游整包落地行 —— 不声明的话它会被整表段的截断刀砍掉
* (那一段按字符数截,上游一单的落地行远超刀口,砍掉的恰是 C 类行)。
* 声明了的键**置前且不参与截断**。单飞起单没有这个键,声明也无副作用。
*/
reads: ['records'],
/* 归一与 FAE 选型期间摆**选型工作单**;判稳转正后才换收口那张清单 */
view: 'alt-worksheet',
tools: [], tools: [],
/* /*
* 对标表有行了才算办完。**不能写 `benchmark_total != null`** —— * 归一完才算办完:两边对上 —— alt_line_total 自报、rows 基座现数。
* S-FEEL 里未绑定的名字 `!= null` 求值为 true,那一步开局即被收掉(A1 踩过,测里拦下的)。 * **不能写 `records != null`**:S-FEEL 未绑定名字 `!= null` 为 true,开局即收(A1 踩过)。
*/ */
doneWhen: 'benchmark_total > 0', doneWhen: 'alt_line_total > 0 and quote_alternative_list_rows = alt_line_total',
guide: `**照这个骨架发,一个字段都别少**(prose 描述不管用,直接抄): guide: `第一件事:把这一单的**选型底表**落出来 —— 整份承继,不是只有 C 类。
{"kind":"human","itemId":"T_select","workOrder":"execution","card":{ 工作区这一步摆的是**替代选型工作单**(逐颗料的选型档案与填回区),你落的行就是它的血肉。
"title":"…","ask":"这颗料原型号停产,请挑替代并给对标结论",
"basis":[{"k":"…","v":"…"}],
"blocks":[
{"t":"table","title":"初筛清单","cols":["原型号","候选型号","品牌","生命周期"],"editable":true,
"rows":[["LM358DR","LM358BIDR","",""],["LM358DR","TLV9062IDR","",""]]},
{"t":"table","title":"替代对标表","cols":["候选型号","替代等级","参数差异项","差异风险","验证建议","FAE签字"],"editable":true,
"rows":[["","","","","",""]]}
]}}
**rows 必须有行**:一条料一行,不知道的值给空字符串(界面自己标红「待填写」)。
只给 cols 不给 rows,人看到的是一排表头加一片空白,一个格子都填不了(实测栽过)。
**blocks 少了整张卡就废了** —— 基座会用当下局面兜底成一句「需要你判断下一步」,
人拿到一张没有表的空卡,这一步等于没派(实测栽过)。
**候选是你提的,不是让 FAE 从零想** —— 初筛清单每个原型号先摆 **2~3 行候选** 【料从哪儿来】
(原型号 + 候选型号两列你填,你认得这些料;品牌与生命周期留空让他核)。 · 流内(任务数据里有 \`records\` 数组):那是上游全部落地行**混在一起**的一份 ——
判据「2~3 颗分级候选经确认」机检的就是候选条数,只给一行**过不了门**。 同一颗料会出现两三次:带 eau/brand/target_price 的是**受理记录行**(以它为准),
替代对标表照候选逐行起,basis 摆上原型号、芯片类型、客户特殊要求(**裸卡基座拒发**)。 带 basis/history_days/market_stable 的是分类表行(它的 type_tag 与受理记录一致),
带 line_total/type_a_total 这类总数的是结论行(不是料,别落进清单)。
**按 pn 归并**:一颗料归一成清单里的一行,列取受理记录行的值;
type_tag 照抄成本清单的 **orig_type(原标)与 type_tag(现标)两列同一个值**。
· 单飞(没有 \`records\`):照入参与正文归一 —— 有型号落型号;只给芯片类型与参数、
没有具体 PN 的,\`pn\` 写「类型-场景」需求代号(如「运放-车规-01」),
这类需求本来就是来选型的,orig_type 与 type_tag 都写 C。
他提交后回传值进你的轨迹:表格的键是 **table:0 / table:1**,值是整张表的 JSON, 【单号 rfq_ref】入参有 \`rfqRef\` 就**原样照抄**;没有就从主题/正文里认(RFQ-xxxx);
**第一行是列名**。照它 write 进 \`shortlist\` 与 \`benchmark\`, 认不出才造一个稳定案号「ALT-<型号或类型>」—— 它是本活动所有台账行的主键,
**同一拍再写一行 \`quote_alternative.verdict\`**(验收机检读的就是它): 造完这一单从头用到尾,**不许中途换写法**(换一个写法就是新开一行)。
· candidate_total = 初筛清单条数(要 ≥2); 造号**只从本单来件取材,不许从台账里已有的别单单号里挑** —— 正式表里躺着的
· benchmark_total = 对标表条数; 单号是别人的单,借它的号等于把两单的行混进同一个案卷。
· undocumented_total = 对标表里「参数差异项」或「差异风险」还空着的条数(要 = 0);
· recommend_pn 主推候选、summary 一句人话结论。 【逐列怎么落】写 \`quote_alternative.list\`,一颗料一行,一拍写完(rows 收数组):
这三个数写不对,submit 会被门挡回来 —— **它们不是台账装饰,是判据本身**。 pn(**一个字符不许改**)、customer_pn、category、brand、eau、target_price、lead_time、
**写完就 finish,别再发第二张单** —— 他已经答过了。 **remark(客户备注:预授权替代、认证要求、禁用约束 —— 选型的第一手依据,一个字别丢)**
**每一行都要带 \`rfq_ref\`** —— 它是这一单的单标识(起单入参里就有,原样抄进去)。 照上游值抄,没有的留空字符串(**绝不写 0 顶空**);orig_type / type_tag 照抄原标
台账主键第一列就是它:不写,这一行会和**别的单**同料号的那一行撞主键,把人家覆盖掉 (**只写 A、B、C 三个大写字母之一**,写别的基座数不出来);
(实测栽过:A4 的报价草案主键只有 pn,跑四轮只剩最后一轮的 3 行)。`, 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', item: 'T_release',
intent: '对标结论已齐,提交放行交 BD 验收', view: 'alt-roster',
intent: '清单已转正,提交放行交 FAE 验收',
writes: [], writes: [],
reads: [], reads: [],
tools: [], tools: [],
// 放行触达世界 —— 引擎不自动收它(所以这里不给 doneWhen) /* 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 doneWhen) */
guide: `对标表有行了 —— **先 start T_release**(它标了人工激活),然后 submit,BD 验收。 guide: `清单整份转正、去向已定 —— 可以走放行了(T_release 人工激活:
动它就算启动,不必单发 start)。先把裁量判据裁掉,然后 submit,同一拍连做:
· judge criterion="替代料来源可溯且选型说明已留:以原厂官网规格书或客户预授权为准,每颗替代写明凭什么" ——
看清单里每颗替代料的 basis 与选型备注:都写了依据就 satisfied=true;
有替代料说不出来源的,satisfied=**false**,rationale 逐颗写清哪几颗没依据,
交签字的人定。**裁成不满足照样能提交** —— 理由连着卡一起交给他,那正是这条判据的用处。
criterion 照判据名**整句原样抄**,写别的名字对不上。
**去向不用你裁**:两个出口各写了判据,放行那一刻基座按序求值、首个命中即出。
终局唯放行:你不能自己宣布完成。`, 终局唯放行:你不能自己宣布完成。`,
}, },
]; ];
/**
* 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段;
* 放行那一格由基座统一加(CLOSING_PHASE),人看到的是 x/4。
*/
export const ALTERNATIVE_PHASES = [
{ name: '归一清单', items: ['S1_normalize'] },
{ name: '选型填回', items: ['S2_fill'] },
{ name: '判稳定标', items: ['S3_reclass'] },
];
+12 -58
View File
@@ -1,62 +1,16 @@
/** /**
* 统一报价 A2 · 替代选型 —— 活动包代码入口(ADR-004)。 * 统一报价 A2 · 替代选型 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
* loop 活动:调度权归 AI;本文件只做视图流控与「差异没核清不放行」的把关呈现。 *
* **这个活动没有 `run`,不是缺件。**
*
* 案例活动的推进由 Loop 驱动,行为全部写在清单里:案例模型(CMMN 子集)+ 案例说明书 +
* 步骤剧本 + 两条技能 + 台账声明 + 一张 HTML 报告模版。「清单机器可读,
* 系统不执行代码即知包为何物」。上一版这里有 62 行 `run()` —— loop 活动根本不会调它,
* 还在读一个契约里早已退场的 `originalPn`:摆一个空转的 run 去假装有实现,更坏。
*
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 —— 它是**被静态审扫描的产物文本**
* (查 import 与全局引用,证明活动包零环境能力)。
*/ */
import { defineActivity } from '@sdk'; import { defineActivity } from '@sdk';
import type { ActivityBase, ActivityOutcome } from '@sdk';
const SHORTLIST = 'quote_alternative.shortlist'; export default defineActivity({ name: 'quote-alternative' });
const BENCHMARK = 'quote_alternative.benchmark';
export default defineActivity({
name: 'quote-alternative',
async run(base: ActivityBase): Promise<ActivityOutcome> {
const shortlist = await base.ledger.read(SHORTLIST);
const benchmark = await base.ledger.read(BENCHMARK);
// 差异没核清的不放行 —— 禁区的机检形态
const unclear = benchmark.filter((r) => String(r['risk'] ?? '').trim() === '');
await base.interaction.pushView([
{
t: 'kv',
title: '选型进展',
items: [
{ k: '原型号', v: String(base.contract.data['originalPn'] ?? '') },
{ k: '初筛候选', v: `${shortlist.length} 颗` },
{ k: '已对标', v: `${benchmark.length} 颗` },
{ k: '差异未核清', v: `${unclear.length} 颗` },
],
},
{
t: 'cards',
title: '候选替代',
items: benchmark.map((r) => ({
tag: String(r['grade'] ?? '待定级'),
title: String(r['candidate_pn'] ?? ''),
swap: { from: String(r['original_pn'] ?? ''), to: String(r['candidate_pn'] ?? '') },
big: String(r['grade'] ?? '—'),
bigLabel: '替代等级',
note: String(r['risk'] ?? '差异风险未写'),
tone: String(r['risk'] ?? '').trim() === '' ? 'danger' : 'ok',
})),
},
]);
if (benchmark.length < 2) {
await base.interaction.say(
shortlist.length === 0 ? '尚未撒网,先拉初筛清单。' : `已初筛 ${shortlist.length} 颗,继续逐条核验分级。`,
'conclusion',
);
return { kind: 'await', reason: '候选不足 2 颗' };
}
if (unclear.length > 0) {
await base.interaction.say(`${unclear.length} 颗候选的差异风险未写清 —— 差异没核清的不放行。`, 'criterion');
return { kind: 'await', reason: '差异风险未核清' };
}
await base.interaction.say('候选已分级、差异与风险已写清,可签字交回发起方。', 'criterion');
return { kind: 'submit', deliverable: 'benchmark-table', rationale: '2 颗以上分级候选,差异与风险齐备' };
},
});
+402 -124
View File
@@ -1,127 +1,222 @@
/** /**
* 活动包清单 —— 统一报价 A2 · 替代选型(**案例活动 · loop**)。 * 活动包清单 —— 统一报价 A2 · 替代选型(案例活动 · MVP 简化形)。
* *
* 业务素材:docs/MVP示例工作流.html §1.6。 * 业务素材:`docs/MVP示例工作流.html` §1.5,按 2026-08-02 用户改盘的口径归一:
* `▶需拍板`(签字交出)→ manualActivation + 放行;`✋客户改需求` → userEventListener; * **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 type { ActivityManifest, DmnModel } from '@contracts';
import { ALTERNATIVE_SKILLS } from './skills.ts';
import { ALTERNATIVE_CASE } from './views/report.ts';
import { activityIdSchema } from '@contracts'; import { activityIdSchema } from '@contracts';
import { ALTERNATIVE_STEPS, CMMN_XML, NARRATIVE } from './case-model.ts'; import { ALTERNATIVE_SKILLS } from './skills.ts';
import { ALT_ROSTER, ALT_WORKSHEET } from './views/report.ts';
import { ALTERNATIVE_PHASES, ALTERNATIVE_STEPS, CMMN_XML, NARRATIVE } from './case-model.ts';
const DOMAIN = 'quote_alternative'; const D = 'quote_alternative';
const ACTIVITY_ID = activityIdSchema.parse('quote-alternative'); const ACTIVITY_ID = activityIdSchema.parse('quote-alternative');
/** 替代等级 —— 把「装得下的判断」从 LLM 手里拿走 */ /** MVP 口径:A2 没有判定表(见文件头注释)—— 判稳规则写死在剧本,依据逐行落表 */
export const GRADE_DMN: DmnModel = { export const ALT_DMN: DmnModel = { drd: [], tables: [] };
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: ['其他', '须验证'] },
],
},
],
};
export const manifest: ActivityManifest = { export const manifest: ActivityManifest = {
identity: { identity: {
activityId: ACTIVITY_ID, activityId: ACTIVITY_ID,
name: 'A2 · 替代选型', name: 'A2 · 替代选型',
packageVersion: '2.0.0', packageVersion: '4.16.0',
category: '统一报价', category: '统一报价',
sdkVersion: '0.1.0-m5', sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-quote-alternative', checksum: 'dev-checksum-quote-alternative',
}, },
/* ── 契约七件 ────────────────────────────────────────────────── */
contract: { contract: {
activityId: ACTIVITY_ID, activityId: ACTIVITY_ID,
version: '0.1.0', version: '1.0.0',
input: { input: {
/* /*
* 入参对齐**上游真发的东西**(2026-07-31,MVP 第一轮跑出来的)。 * **宽进到底:一个必填都不设**(2026-08-02 用户当场定:「活动是宽进严出,
* 输入接受任何类型」「活动的输入契约是在活动内部归一+补全的」)。
* *
* A1 的 `to-alternative` 口发的是 `altRequests`(一批替代评估请求,一条料一行), * 来路本就有两种形状:流内(上游放行载荷 —— rfqRef + records + 一堆摊平字段)
* 不是单个 `originalPn` —— 而这里从前把 `originalPn` 写成必填, * 与单飞(一段需求文字,可能连具体型号都没有)。门口拦谁都不对 ——
* 于是流程走到 A2 就停在补全卡上:「缺 originalPn」。 * 归一是第一个计划项的活,缺什么在活动内问。
* 上游发一批,下游要一个,这是**契约对不上**,不是数据缺失。
* *
* 一单可能要替代好几颗料,`altRequests` 才是那件事的真形状。 * 上一版必填 `rfqRef + altRequests`:而 `alt_request` 表 2026-08-02 已在 A1 退休,
* 那个键只是载荷数组通配规则下的空壳(内容与 records 完全相同)——
* 必填一个不存在的东西,单飞起单永远停在补全卡上。
*/ */
data: { data: {
type: 'object', type: 'object',
required: ['rfqRef', 'altRequests'], required: [],
properties: { properties: {
rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' }, rfqRef: { type: 'string', title: '询价单号' },
altRequests: { type: 'array', title: '替代评估请求(原 PN + 替代要求)' }, records: { type: 'array', title: '上游受理记录(已打标)' },
originalPn: { type: 'string', title: '原型号(单条时的便捷入口)' }, subject: { type: 'string', title: '需求主题' },
chipType: { type: 'string' }, body: { type: 'string', title: '需求正文' },
scenario: { type: 'string' }, 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, messageAllowed: true,
messageHint: '客户点名的替代授权、禁用品牌、场景约束等写这里',
}, },
output: { output: {
/*
* 出口载荷**从落地事实推导**:标量字段必须有台账列承载。
* `needSourcing` ← verdict.need_sourcing —— 流程包 E5(仍需寻源 → A3)读的
* 就是它;上一版声明了这个键而台账没有承载列,**E5 从来没有成立过**:
* 选完型仍要寻价的单一律被默认边送去定价(2026-08-02 探查实证)。
*/
mandatory: [ mandatory: [
{ {
name: 'benchmark', name: 'roster',
payload: { type: 'object', required: ['rfqRef', 'rows'], properties: { payload: {
/* type: 'object',
* **下游那条边读的就是它**(流程包 E5:A2→A3 `needSourcing = true`)。 required: ['rfqRef', 'rows', 'needSourcing'],
* 从前这个口只发对标表,而边上写着 needSourcing —— 那条边永远不成立, properties: {
* 选完型再要寻价的单子出了 A2 就没路可走(与 A1 当年的 G1 全流死路同一种错)。 rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' },
*/ rows: { type: 'array', title: '询价受理清单(C 类已转 A/B)' },
needSourcing: { type: 'boolean', title: '选定的替代料仍需寻价' }, rfqRef: { type: 'string' }, rows: { type: 'array' } } }, needSourcing: { type: 'boolean', title: '仍需寻源(清单里还有 B 类)' },
description: '替代对标表(已签字)', },
},
description: '询价受理清单(C 类已转 A/B)+ 寻源旗',
},
],
/*
* **两条出边 = 一个 XOR 选择组**(对齐流程包 E5/E6)。
* 每一口都写了 `when`(读基座按值现数的 tally 读数),这一单往哪走是**算出来的**:
* 放行时基座按序求值首个命中即出,卡上只如实告诉人走哪一口、凭哪一条。
*/
selective: [
{
group: 'alt-route',
ports: [
{
name: 'to-sourcing',
payload: {
type: 'object',
required: ['rfqRef', 'rows'],
properties: {
rfqRef: { type: 'string', title: '询价单号' },
rows: { type: 'array', title: '询价受理清单(含待补价的 B 类)' },
},
},
description: '清单里仍有 B 类 —— 去寻源补货源价格',
when: 'quote_alternative_list_type_tag_b > 0',
},
{
name: 'to-pricing',
payload: {
type: 'object',
required: ['rfqRef', 'rows'],
properties: {
rfqRef: { type: 'string', title: '询价单号' },
rows: { type: 'array', title: '询价受理清单(全 A 类)' },
},
},
description: '全 A 类:行情完备 —— 直通成本定价',
when: 'quote_alternative_list_type_tag_b = 0',
},
],
}, },
], ],
selective: [],
}, },
/* /*
* 必交付物**必须给 `ledger` 地址**(2026-07-31 修)。 * 必交付物只有一件:**询价受理清单**。选型结论(verdict)是它的结论栏,
* 不给的话预检退回 `scope[key]` 去找 —— key 是 `benchmark-table` 这样的短横线别名, * 不是第二份成果 —— `ledgers` 说清这份成果由哪几张表承载(A1 的账:
* 任务视图里永远没有这个键,于是「必交付物缺一不得提交放行」永远拒, * 各占一条,人在成果面上会看到两样东西)。
* 活办完了也放不了行,连拒五拍熔断(实测 A2 死于此)。
*/ */
requiredDeliverables: [ requiredDeliverables: [
{ key: 'benchmark-table', label: '替代对标表(FAE 签字)', ledger: `${DOMAIN}.benchmark` }, {
key: 'alt-roster',
label: '询价受理清单(C 类已转 A/B)',
ledger: `${D}.list`,
/*
* 验收页上让 FAE 就地改的那几列 —— 恰是**他的职权面**:
* 替代料三列是他填的,现标 `type_tag` 是判稳规则出的 ——
* 但算得对不对该由人说了算,他改一颗料的标就是在推翻一次判定(A1 的账:
* 不在名单里的列压根不进写回载荷,人改了等于没改)。
* 去向不在其列:它是现标的函数,改标去向就跟着变。
*/
editableAtRelease: ['alt_pn', 'alt_brand', 'alt_grade', 'alt_diff', 'alt_risk', 'alt_verify', 'alt_note', 'type_tag'],
ledgers: [`${D}.list`, `${D}.verdict`],
},
], ],
/* /*
* 机检谓词读的是 `verdict` 那张**汇总表**的列(照 A1 的口径)。 * 验收判据 —— **机检(L1/L2)只读基座数得出来的数**(A1 被违反三次的那条纪律):
* * `quote_alternative_list_rows` 基座数行数、`_type_tag_a/b/c` 基座按取值域现数。
* 上一版写的是 `candidatesConfirmed >= 2` 与 `undocumentedDiff = 0` —— * 上一版两条 L2 读的是活动自报的 verdict 列(candidate_total / undocumented_total)——
* 这两个名字**台账里根本没有**(既不是案卷项,也不是任何一张表的列)。 * 那不是门,是「改哪个数能过」的提示。
* S-FEEL 拿未绑定的名字比大小求值为假,于是这两条判据**永远达不了标**:
* Agent 把活办得再干净也过不了 submit 门,连拒五拍熔断 —— 实测 A2 就死在这儿。
* 判据的名字必须落在台账上,否则「机检」是一句空话(2026-07-31 销账)。
*/ */
acceptanceCriteria: [ acceptanceCriteria: [
{ key: 'candidates', label: '2~3 颗分级候选经确认', level: 'L2', predicate: 'candidate_total >= 2' }, {
{ key: 'diff-clear', label: '差异项与风险逐项写清', level: 'L2', predicate: 'undocumented_total = 0' }, key: 'roster-complete',
{ key: 'source-traceable', label: '每项数据来源可溯(以原厂官网规格书为准)', level: 'L3' }, label: '清单齐:上游受理记录逐条承继,说了几条清单里就躺着几行',
{ key: 'fae-sign', label: 'FAE 签字放行', level: 'L5' }, 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: [], revisableAtRelease: [],
prohibitions: [ prohibitions: [
{ key: 'no-unclear-diff', statement: '差异没核清的不放行' }, { key: 'no-fake-alt', statement: '没有把握的替代不填:宁可在选型备注写明替代未果,也不编一个候选顶上' },
{ key: 'no-banned', statement: '客户技术画像禁用清单里的型号不得作候选' }, { key: 'no-banned', statement: '客户点名禁用的品牌与型号不得作替代' },
{ key: 'no-tag-fudge', statement: '不许为过判据改标:现标只能来自判稳规则,或人在件上的亲手修改' },
], ],
authorization: { authorization: {
discretion: [{ key: 'widen-search', statement: '候选不足 2~3 颗时可放宽参数或换角度重新撒网' }], discretion: [
{ key: 'widen-search', statement: 'FAE 可放宽参数或换角度重新选型;是否接受非原厂品牌、参数降级必须问客户,不得替客户决定' },
],
/* 空着不是省事:这个字段全仓没有一个消费者(债 ⑨),写了也不会被执行 */
preauthorizedActions: [], preauthorizedActions: [],
/* 一颗芯片一张选型单(按顺序派)—— 打扰上限按料数给:MVP 案例 ≤3 颗 + 放行 + 余量 */
humanCallBudget: 6, humanCallBudget: 6,
}, },
resources: { resources: {
@@ -129,76 +224,121 @@ export const manifest: ActivityManifest = {
skills: ALTERNATIVE_SKILLS.map((s) => s.id), skills: ALTERNATIVE_SKILLS.map((s) => s.id),
models: ['deepseek-v4-flash'], models: ['deepseek-v4-flash'],
roles: ['FAE'], 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: { declarations: {
models: ['deepseek-v4-flash'], models: ['deepseek-v4-flash'],
tools: [{ name: 'crm.history-price-read' }], tools: [{ name: 'crm.history-price-read' }],
ledger: [ ledger: [
/*
* ── 这里**去掉了 `shortlist` / `benchmark` / `request` 三张表**(2026-08-02)──
*
* MVP 口径下初筛清单与对标表都不在系统内产出 —— 选型的实质工作在 FAE
* 系统外,系统收的是**结论**(替代料是哪颗、凭什么),落在清单行的三列上。
* 撤掉声明不 drop 物理表(物化只增不破坏),历史行还在库里。
*/
{ {
domain: DOMAIN, domain: D,
object: 'shortlist', object: 'list',
kind: 'record', kind: 'record',
caseKey: 'rfq_ref', caseKey: 'rfq_ref',
label: '初筛清单', label: '询价受理清单',
fields: [ fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' }, { name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'original_pn', type: 'text', primaryKey: true, masked: false, label: '原型号' }, { name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' },
{ name: 'candidate_pn', type: 'text', primaryKey: true, masked: false, label: '候选型号' }, { name: 'customer_pn', type: 'text', primaryKey: false, masked: false, label: '客户料号' },
{ name: 'brand', type: 'text', primaryKey: false, masked: false, label: '品牌' }, { name: 'category', type: 'text', primaryKey: false, masked: false, label: '品类' },
{ name: 'lifecycle', 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: '交期' },
domain: DOMAIN, /* 客户备注承继自上游 —— 预授权替代、认证要求、禁用约束都在这一格,是选型的第一手依据 */
object: 'benchmark', { name: 'remark', type: 'text', primaryKey: false, masked: false, label: '客户备注(约束/预授权)' },
kind: 'record', /*
caseKey: 'rfq_ref', * **原标与现标两列同名不同物**:orig_type 是上游受理时的三分类(留痕,本活动不改);
label: '替代对标表', * type_tag 是本活动的产物(C 类判稳后转 A/B)。两列都声明取值域,
fields: [ * 条数由基座按值现数 —— 判据、分布条、出口谓词读的全是基座读数。
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' }, */
{ name: 'original_pn', type: 'text', primaryKey: true, masked: false }, { name: 'orig_type', type: 'text', primaryKey: false, masked: false, label: '原标(上游三分类)', tally: ['A', 'B', 'C'] },
{ name: 'candidate_pn', type: 'text', primaryKey: true, masked: false }, /*
{ name: 'grade', type: 'text', primaryKey: false, masked: false, label: '替代等级' }, * **定案三格是待选型行的必填**(2026-08-02 用户:「工作区的 HTML 必填项
{ name: 'param_diff', type: 'text', primaryKey: false, masked: false, label: '参数差异项' }, * 必须填写了才能提交啊,不填写能算任务完成了吗?」)。
{ name: 'risk', type: 'text', primaryKey: false, masked: false, label: '差异风险' }, * `requiredWhen` 把必填钉在 orig_type=C 的行(一张执行单、件里一颗一页,
{ name: 'verify_advice', type: 'text', primaryKey: false, masked: false, label: '验证建议' }, * 每页的定案三格都得齐);A/B 行原样承继,不拦。
{ name: 'signed_by', type: 'text', primaryKey: false, masked: false, label: 'FAE 签字' }, * 「替代未果」不走「留空交回」—— 那在闸上就是空手交卷:
* 未果 = 在选型备注写明缘由后交回,判稳那一步按剔除处置(剧本 §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,人改不到。
*/
], ],
}, },
{ {
/* /*
* 选型结论 —— 一单一行的**汇总表**,验收机检谓词的入参就在这儿。 * 选型结论 —— **一单一行**:汇总数(报告要素卡与哨兵读)、
* 汇总列与逐行列**故意不同名**:任务视图把草稿行摊平到顶层是「后写的赢」, * 去向旗(流程包 E5 唯一读的东西)、一句人话结论。
* 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。
*/ */
domain: DOMAIN, domain: D,
object: 'verdict', object: 'verdict',
kind: 'record', kind: 'record',
caseKey: 'rfq_ref', caseKey: 'rfq_ref',
label: '选型结论', label: '选型结论',
fields: [ fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' }, { name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'original_pn', type: 'text', primaryKey: false, masked: false, label: '原型号' }, { name: 'customer_code', type: 'text', primaryKey: false, masked: false, label: '客户码' },
{ name: 'candidate_total', type: 'integer', primaryKey: false, masked: false, label: '候选条数' }, { name: 'customer_name', type: 'text', primaryKey: false, masked: false, label: '客户名称' },
{ name: 'benchmark_total', type: 'integer', 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: '待选型条数' },
name: 'undocumented_total', /* 「FAE 的答复处理过了」的凭据 —— 只在人环走完才产生(哨兵按它放行下一步) */
type: 'integer', { name: 'filled_total', type: 'integer', primaryKey: false, masked: false, label: '已填回替代料条数' },
primaryKey: false, { name: 'converted_a_total', type: 'integer', primaryKey: false, masked: false, label: '判稳转 A 条数' },
masked: false, { name: 'converted_b_total', type: 'integer', primaryKey: false, masked: false, label: '转 B(需寻价)条数' },
label: '差异项或风险缺失条数', { name: 'skipped_note', type: 'text', primaryKey: false, masked: false, label: '剔除了哪几颗·为什么' },
}, /* 流程包 E5(仍需寻源 → A3)读的就是它 —— 没有这一列,E5 永远判假(实证过) */
{ name: 'recommend_pn', type: 'text', primaryKey: false, masked: false, label: '主推候选' }, { 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: '选型结论' }, { name: 'summary', type: 'text', primaryKey: false, masked: false, label: '选型结论' },
], ],
}, },
], ],
listeners: [ listeners: [
{ {
// ⏱ 时限过半:向 BD 与 PM 同步进度与风险 /** 时限过半:给 FAE 与负责人提个醒 —— 静态监听,时限必配 */
origin: 'static', origin: 'static',
eventType: 'time', eventType: 'time',
keyFilter: { kind: 'half-deadline' }, keyFilter: { kind: 'half-deadline' },
@@ -207,7 +347,7 @@ export const manifest: ActivityManifest = {
escalation: ['nudge'], escalation: ['nudge'],
}, },
{ {
// ✋ 客户改需求:应用条件/参数变更 → 回初选核险与圈选复核 /** 客户改需求(应用条件/参数变更):记一笔观察,回炉与否由 Agent 裁量 */
origin: 'static', origin: 'static',
eventType: 'human', eventType: 'human',
keyFilter: { kind: 'requirement-change' }, keyFilter: { kind: 'requirement-change' },
@@ -217,35 +357,173 @@ export const manifest: ActivityManifest = {
}, },
], ],
harness: { harness: {
memoryWindow: 30, memoryWindow: 24,
// FAE 在系统外干活,等他不算停滞 —— 停滞检测放宽到一天
stallAfter: 'P1D', stallAfter: 'P1D',
nudge: [{ after: 'PT12H', action: 'nudge' }], nudge: [{ after: 'PT12H', action: 'nudge' }],
retry: { times: 2, backoff: 'PT5M' }, retry: { times: 2, backoff: 'PT5M' },
}, },
}, },
/* ── 资产初始版 ──────────────────────────────────────────────── */
assets: { assets: {
businessModel: { businessModel: {
cmmnXml: CMMN_XML, cmmnXml: CMMN_XML,
narrative: NARRATIVE, narrative: NARRATIVE,
dmn: GRADE_DMN, dmn: ALT_DMN,
/* 步骤剧本 —— 与 A1 同一套:doneWhen 真了引擎自己收步,不问模型 */
steps: ALTERNATIVE_STEPS, steps: ALTERNATIVE_STEPS,
phases: ALTERNATIVE_PHASES,
}, },
skills: ALTERNATIVE_SKILLS, 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: {}, cardSchemas: {},
distillHooks: [ distillHooks: [
{ on: 'milestone', milestone: 'M_candidates', prompt: '归纳本轮撒网的有效角度与无效角度。' }, {
{ on: 'terminal', prompt: '归纳替代经验:哪些参数差异实际造成了问题、哪些品牌的替代关系可复用。' }, on: 'terminal',
prompt: '归纳本单替代选型的经验:哪类料找得到替代、FAE 的选型依据形态、判稳转标的分布。',
},
], ],
}, },
/* ── 样例集 —— 三条形状真不同(常备案例即验收案例)───────────── */
samples: [ samples: [
{ {
name: 'EOL 料替代', /*
input: { originalPn: 'TL072CDR', chipType: '运放', scenario: '车规' }, * ① 流内常路:上游放行载荷的**真实形状** —— records 里同一颗料出现两次
expect: { ports: ['benchmark'] }, * (受理记录行 + 分类表行)、还混着结论行;顶层带摊平字段。
* 替代料(客户预授权的 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 { skillIdSchema } from '@contracts';
import type { Skill } from '@contracts'; import type { Skill } from '@contracts';
export const ALTERNATIVE_SKILLS: Skill[] = [ export const ALTERNATIVE_SKILLS: Skill[] = [
{ {
id: skillIdSchema.parse('alternative.screen-candidates'), /*
label: '圈替代候选', * 这条讲的是**组卡与核验时的选型口径**(FAE 的实质工作在系统外,但卡面指导书、
applicability: '原型号停产或缺货,要找替代时。', * basis 的取舍、收回后的核验都要按同一把尺)—— 2026-08-02 用户指出执行件
* 不是选型工作台后,连同工作单一起补回。
*/
id: skillIdSchema.parse('alternative.grade-discipline'),
label: '替代等级与三层筛',
applicability: '组选型执行单、核验 FAE 填回的定案、或放行前裁量来源可溯时。',
method: [ method: [
'三层筛,一层比一层贵,所以要按顺序:', '三层筛,一层比一层贵,按顺序(组卡的 steps 与核验都照它):',
'① **参数硬指标**:封装、电压范围、精度、温度等级 —— 不满足直接淘汰,不必往下看。', '① 参数硬指标:封装、引脚、电气、温度等级 —— 不满足直接淘汰,不必往下看;',
'② **认证与合规**:车规 / 医疗 / 工业等级不可降级替代,客户没明说也不行。', '② 认证与合规:车规 AEC-Q100/Q200、医疗、工业等级不可降级替代,客户没明说也不行;',
'③ **供应与价格**:到这一步才比价,前两层没过的便宜也没用。', '③ 供应与价格:生命周期在产、货态、品牌接受度 —— 到这一步才比价。',
'', '',
'「Pin-to-pin 兼容」要看数据手册核实,不要信分销商的一句话 —— ', '等级三码(照 MVP 素材对标表的口径,写别的码基座数不出来):',
'同封装不等于同引脚定义,这个错误要到打样才暴露。', '· P2P = 同封装同引脚可直换 —— 必须核对过五项:封装尺寸/引脚定义/电气/温度等级/认证;',
' 同封装不等于同引脚定义,要对数据手册,不要信分销商一句话(这个错要到打样才暴露);',
'· FUNC = 功能兼容,需外围电路或 layout 调整 —— 差异项与改动评估必须写清;',
'· OTHER = 部分兼容须打样验证 —— 关键差异逐项列出、给验证方案。',
'',
'客户备注里已预授权的替代优先采信;点名禁用的品牌不得作候选;',
'是否接受非原厂品牌、参数降级必须问客户,不得替客户决定。',
].join('\n'), ].join('\n'),
crystallized: [], crystallized: [],
knowledgeRefs: ['替代选型判据表'], knowledgeRefs: ['docs/MVP示例工作流.html §1.5(替代等级/初选核险/圈选替代)'],
version: '1.0.0', version: '1.0.0',
}, },
{ {
id: skillIdSchema.parse('alternative.ask-customer'), id: skillIdSchema.parse('alternative.normalize-roster'),
label: '什么时候必须问客户', label: '归一询价受理清单',
applicability: '候选已圈定,但存在客户口径不明的项时。', applicability: '开案第一步:把上游受理记录(或单飞需求)整份落成本活动的清单时。',
method: [ 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'), ].join('\n'),
crystallized: [], crystallized: [],
knowledgeRefs: [], knowledgeRefs: ['docs/MVP示例工作流.html §1.5'],
version: '1.0.0', version: '2.0.0',
},
{
id: skillIdSchema.parse('alternative.reclass-stable'),
label: '替代料判稳转标',
applicability: 'FAE 填回替代料之后,要把 C 类行转成 A/B 时。',
method: [
'判稳的尺与三分类 P-03 的 A/B 两行**同一把**:',
'历史价 ≤ 90 天且行情稳 → 转 A(行情完备,可直接取历史锚点);否则 → 转 B(要去寻源补价)。',
'',
'判据从 `crm.history-price-read` 来:**参数 pns 收数组,所有替代料一次传完**。',
'没历史回 historyDays=9999、hit=false —— **那本身就是判据**(新料无历史 → 不稳 → B),',
'不是缺数据,不必替它补。工具回的 lifecycle 若是 EOL/缺货/要求替代,',
'说明替代料选错了 —— 不转标,当替代未果处置。',
'',
'转标写回清单行时:type_tag 只写 A 或 B(照规则,不许臆断);',
'依据一句话落 basis 列(「替代料 X 历史价 N 天·行情稳 → 转 A」),逐条可回核;',
'orig_type 不动。**不许为过判据改标** —— 判据读的是基座按值现数的条数,你改不动。',
].join('\n'),
// 结晶片段:查替代料行情这一步每单都一样 —— 确定性子序列,不必每次让模型重想
crystallized: [{ tool: 'crm.history-price-read', input: { pns: 'pns' } }],
knowledgeRefs: ['三分类规则 P-03(A/B 两行)'],
version: '2.0.0',
}, },
]; ];
@@ -0,0 +1,409 @@
/**
* A1 两份执行页的样式表 —— **从 PRD 目标件原样搬过来**,不是我照着复述的。
*
* 出处:`~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理需求.html` 的整段 `<style>`,
* 外加 `A1-询价受理记录.html` 里归档件独有的那几件(分类分布条 / 类徽 / 判定表 /
* 结论横幅 / 落地动作 / 完成标)。**一个数值都没改**。
*
* 为什么是搬而不是抄:前三轮我用自己那套外壳「照着做」,块搬齐了、版面是两套 ——
* 章节头成了彩色方块编号、多出摘要与元信息带、字号用 clamp。人一眼就看出不是那份件。
* 目标件是**像素级的答案**,搬过来才不会在复述里走样。
*
* 选择器保留 `.proto-root .app-shell` 前缀,所以模版正文也要照原型那样包两层壳 ——
* 少一层壳,整张样式表一条都不生效(踩过)。
*/
export const PRD_CSS = `
/* ============================================================================
JCAI 员工面工作台 · A1 受理归一的过程成果「询价受理需求」——报告本体
立场:**AI 已从 RFQ 邮件抽取归一**,人在这里审核并就地修订。
两态:读态(AI 稿 + 红/灰占位提示缺项) ⇄ 编辑态(全字段可改,保存 / 取消)
核心是「有效 PN 清单」;清单之上四件受理要素:客户公司 / 联系人 / 客户属性 / 有效 PN。
令牌与既有组件取自 workbench/src/app/(proto)/app.css,数值未改。
⚠️ 修正一处原型 bug:app.css:2062-2064 的 '.jc-rp-sign span'(权重 0,1,1)
盖过了 '.jc-rp-ava'(0,1,0),把头像的 display:grid 覆盖成 block、字色改灰,
导致落款头像里的字掉到圆外。本文件把该规则收窄为 '.jc-rp-sign > div > span'。
新增件:四要素卡 .jc-four/.jc-fcard · 占位胶囊 .jc-ph · 分段控件 .jc-seg ·
来源条 .jc-src · 录入表 .jc-tbl-in
========================================================================== */
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { background: #e6eaf1; -webkit-font-smoothing: antialiased; }
.proto-root .app-shell {
--brand: #3370ff; --brand-deep: #245bdb;
--brand-soft: #e1eaff; --brand-wash: #f0f4ff;
--ai-grad: linear-gradient(135deg, #3370ff, #7f3bf5);
--ink: #1f2329; --ink-2: #41464e; --ink-3: #646a73; --ink-4: #8f959e;
--line: #dee0e3; --line-soft: #e8eaed; --soft: #f8f9fb;
--jcai-bg: #e6eaf1; --cardline: #e1e6ec; --card-shadow: 0 1px 2px rgba(31, 35, 41, .05);
--ok: #278717; --ok-soft: #e8f8e4;
--warn: #de7802; --warn-accent: #ff8800; --warn-soft: #fff4e5;
--danger: #d83931; --danger-soft: #feeae9;
--mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace;
--shadow-brand: 0 6px 16px rgba(51, 112, 255, .22);
--t-fast: .16s cubic-bezier(.3, 0, .2, 1);
--t-med: .26s cubic-bezier(.3, 0, .2, 1);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--jcai-bg); min-height: 100vh; color: var(--ink);
}
.proto-root :where(button, input, select, a, [tabindex]):focus-visible {
outline: 2px solid var(--brand); outline-offset: 2px; border-radius: 8px;
}
.proto-root ::selection { background: var(--brand-soft); }
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
@keyframes jcBlink { 0%,100% { opacity: 1 } 50% { opacity: .35 } }
@keyframes jcStamp { from { opacity: 0; transform: rotate(-6deg) scale(1.7); } to { opacity: .9; transform: rotate(-6deg) scale(1); } }
/* 放开宽度:1160px 是「9 列全展开、一个字都不裁」的最小容器宽度(见下方列 min-width 之和) */
.jc-stage { max-width: 1160px; margin: 0 auto; padding: 22px 16px 52px; }
.proto-root .jc-mono { font-family: var(--mono); font-size: 11px; color: var(--ink-3); }
.proto-root .jc-pulse-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--brand); display: inline-block; animation: jcBlink 1.1s ease-in-out infinite; }
/* ── 报告外壳 ───────────────────────────────────────────────────────── */
.proto-root .jc-report { background: #fff; border: 1px solid var(--cardline); border-radius: 14px; box-shadow: var(--card-shadow); overflow: hidden; animation: rise var(--t-med) both; }
.proto-root .jc-rp-head { position: relative; overflow: hidden; display: flex; align-items: flex-start; gap: 12px; padding: 16px 20px 13px; border-bottom: 2px solid var(--ink); }
/* 题头下缘粗细双线 = 正式件版式 */
.proto-root .jc-rp-head::after { content: ""; position: absolute; left: 0; right: 0; bottom: -4px; height: 1px; background: var(--line); }
/* 题头右上的品牌光晕:与工作台标题区同一签名件 */
.proto-root .jc-rp-head::before { content: ""; position: absolute; right: -70px; top: -96px; width: 240px; height: 240px; border-radius: 999px; background: radial-gradient(circle, rgba(51,112,255,.10), transparent 70%); pointer-events: none; }
.proto-root .jc-rp-head > * { position: relative; z-index: 1; }
.proto-root .jc-rp-badge { flex: none; width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand); }
.proto-root .jc-rp-tit { flex: 1; min-width: 0; }
.proto-root .jc-rp-tit em { display: block; font-style: normal; font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); font-family: var(--mono); }
.proto-root .jc-rp-tit h3 { margin: 4px 0 0; font-size: 18px; font-weight: 800; color: var(--ink); letter-spacing: -.01em; line-height: 1.3; }
.proto-root .jc-rp-wait { display: inline-flex; align-items: center; gap: 6px; flex: none; margin-top: 4px; font-size: 11px; font-weight: 700; border-radius: 999px; padding: 4px 12px; color: var(--brand-deep); background: var(--brand-wash); border: 1px solid var(--brand-soft); }
.proto-root .jc-rp-wait.warn { color: var(--warn); background: var(--warn-soft); border-color: #ffd9a8; }
.proto-root .jc-rp-wait.edit { color: #fff; background: var(--brand); border-color: var(--brand); }
.proto-root .jc-rp-stamp { flex: none; margin-top: 2px; transform: rotate(-6deg); border: 2px solid currentColor; border-radius: 9px; padding: 4px 8px 3px 12px; font-size: 13px; font-weight: 900; letter-spacing: .28em; color: var(--ok); opacity: .9; animation: jcStamp .32s cubic-bezier(.2, 1.4, .4, 1) both; }
.proto-root .jc-rp-bd { padding: 18px 20px 12px; }
.proto-root .jc-rp-part { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 9px; margin: 20px 0 11px; }
.proto-root .jc-rp-part:first-child { margin-top: 0; }
.proto-root .jc-rp-part > b { flex: none; font-size: 11px; font-weight: 800; letter-spacing: .12em; color: var(--ink-3); }
.proto-root .jc-rp-part > .ln { flex: 1; height: 1px; background: var(--line-soft); }
.proto-root .jc-rp-part > em { flex: none; font-style: normal; font-size: 10px; font-weight: 700; color: var(--ink-4); font-family: var(--mono); background: var(--soft); border-radius: 999px; padding: 2px 9px; white-space: nowrap; }
.proto-root .jc-rp-part > em.warn { color: var(--warn); background: var(--warn-soft); }
/* 落款 —— ⚠️ 选择器收窄到 > div > *,别再命中头像那个 span */
.proto-root .jc-rp-foot { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 13px 20px 15px; border-top: 2px solid var(--line); background: #fbfcfd; }
.proto-root .jc-rp-sign { display: flex; align-items: center; gap: 10px; flex: 1 1 200px; min-width: 0; }
.proto-root .jc-rp-ava { flex: 0 0 auto; width: 30px; height: 30px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; line-height: 1; color: #fff; background: var(--brand); }
.proto-root .jc-rp-sign > div { min-width: 0; }
.proto-root .jc-rp-sign > div > b { display: block; font-size: 12px; font-weight: 700; color: var(--ink-2); }
.proto-root .jc-rp-sign > div > span { display: block; margin-top: 1px; font-size: 10.5px; color: var(--ink-4); }
.proto-root .jc-rp-acts { display: flex; align-items: center; gap: 8px; margin-left: auto; flex: none; }
.proto-root .jc-hintline { font-size: 11px; font-weight: 700; color: var(--warn); line-height: 1.5; }
.proto-root .jc-btn { display: inline-flex; align-items: center; gap: 5px; border: 0; border-radius: 7px; padding: 8px 14px; font-size: 12px; font-weight: 600; font-family: inherit; background: var(--brand); color: #fff; cursor: pointer; transition: all var(--t-fast); }
.proto-root .jc-btn:hover:not(:disabled) { background: var(--brand-deep); }
.proto-root .jc-btn:disabled { opacity: .45; cursor: not-allowed; }
.proto-root .jc-btn-ghost { background: #fff; color: var(--ink-2); border: 1px solid var(--line); }
.proto-root .jc-btn-ghost:hover:not(:disabled) { background: var(--brand-wash); color: var(--brand); border-color: var(--brand-soft); }
.proto-root .jc-btn-primary { background: var(--ai-grad); }
.proto-root .jc-btn-primary:hover:not(:disabled) { filter: brightness(1.07); box-shadow: var(--shadow-brand); transform: translateY(-1px); }
/* ── 来源信件:客户 RFQ 邮件原文(复用原型 jc-letter 件,青色 = 收件) ── */
.proto-root .jc-letter { position: relative; border: 1px solid var(--cardline); border-left: 3px solid #0aa294; border-radius: 12px; padding: 14px 16px 15px; background: #fff; box-shadow: var(--card-shadow); }
.proto-root .jc-letter-h { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 11px; }
.proto-root .jc-letter-dir { display: inline-flex; align-items: center; gap: 5px; font-size: 10.5px; font-weight: 800; padding: 3px 9px; border-radius: 999px; background: #e4f8f5; color: #0a7f74; }
.proto-root .jc-letter-h .jc-chip { font-size: 10.5px; padding: 3px 9px; border-radius: 999px; background: var(--brand-wash); color: var(--brand-deep); border: 1px solid var(--brand-soft); }
.proto-root .jc-letter-h > code { margin-left: auto; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
.proto-root .jc-letter-meta { display: flex; flex-direction: column; gap: 4px; margin-bottom: 11px; padding-bottom: 11px; border-bottom: 1px solid var(--line-soft); font-size: 11.5px; color: var(--ink-2); }
.proto-root .jc-letter-meta p { margin: 0; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.proto-root .jc-letter-meta i { flex: none; font-style: normal; width: 44px; font-size: 10px; font-weight: 800; letter-spacing: .06em; color: var(--ink-4); }
.proto-root .jc-letter-meta code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); overflow-wrap: anywhere; }
.proto-root .jc-letter-sub { margin: 0 0 9px; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.45; overflow-wrap: anywhere; }
/* 正文:原文照录,pre-wrap 保原始换行;不截断、不折叠 */
.proto-root .jc-letter-body { margin: 0; font-size: 12.5px; line-height: 1.72; color: var(--ink-2); white-space: pre-wrap; overflow-wrap: anywhere; }
.proto-root .jc-letter-body b { color: var(--ink); font-weight: 700; }
.proto-root .jc-atts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; padding-top: 11px; border-top: 1px dashed var(--line-soft); }
.proto-root .jc-att { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px 5px 8px; border: 1px solid var(--brand-soft); border-radius: 8px; background: var(--brand-wash); font-size: 11.5px; color: var(--brand-deep); }
.proto-root .jc-att svg { flex: none; color: var(--brand); }
.proto-root .jc-att code { font-family: var(--mono); font-size: 10.5px; }
.proto-root .jc-att span { color: var(--ink-4); }
/* 抽取指向:从原文到下方要素的一句衔接 */
.proto-root .jc-derive { display: flex; align-items: flex-start; gap: 8px; margin-top: 11px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); font-size: 11.5px; line-height: 1.7; color: var(--brand-deep); }
.proto-root .jc-derive svg { flex: none; margin-top: 2px; }
/* ── 四要素卡 ───────────────────────────────────────────────────────────
色调字典:每张卡带一套 --tone / --tone-soft / --tone-line,组件只读变量。
卡 3、卡 4 的色调随数据变(老客户绿 / 新客户橙;齐了蓝 / 有缺橙),
所以这条要素带本身就是一行可读的状态,不只是四个格子。 */
.proto-root .jc-four { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 11px; }
.proto-root .jc-fcard {
position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-width: 0;
padding: 14px 14px 14px; border: 1px solid var(--cardline); border-radius: 13px; background: #fff;
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 8px 20px -14px rgba(31,35,41,.35);
transition: transform var(--t-fast), box-shadow var(--t-fast), border-color var(--t-fast);
--tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft);
}
/* 顶缘色棱 + 右上角光晕:卡的身份色,一眼分得清四件是四件 */
.proto-root .jc-fcard::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 3px; background: var(--tone); }
.proto-root .jc-fcard::after { content: ""; position: absolute; right: -46px; top: -52px; width: 132px; height: 132px; border-radius: 999px; background: radial-gradient(circle, var(--tone-soft), transparent 70%); pointer-events: none; }
.proto-root .jc-fcard > * { position: relative; z-index: 1; }
.proto-root .jc-fcard:hover { transform: translateY(-2px); border-color: var(--tone-line); box-shadow: 0 2px 4px rgba(31,35,41,.05), 0 14px 26px -16px rgba(31,35,41,.45); }
.proto-root .jc-fcard.t-teal { --tone: #0aa294; --tone-soft: rgba(10,162,148,.15); --tone-line: #9fe0d9; }
.proto-root .jc-fcard.t-ok { --tone: var(--ok); --tone-soft: rgba(39,135,23,.14); --tone-line: #bfedb4; }
.proto-root .jc-fcard.t-warn { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.12); --tone-line: #ffd9a8; }
.proto-root .jc-fcard-k { display: flex; align-items: center; gap: 6px; font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
.proto-root .jc-fcard-k > i { flex: none; width: 19px; height: 19px; border-radius: 6px; display: inline-grid; place-items: center; background: var(--tone-soft); color: var(--tone); }
.proto-root .jc-fcard-b { display: flex; align-items: center; gap: 10px; min-width: 0; }
.proto-root .jc-fcard-t { min-width: 0; flex: 1; }
/* 一律换行,不省略号——宁可多占一行,也不丢字 */
.proto-root .jc-fcard-t > b { display: block; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.35; overflow-wrap: anywhere; }
.proto-root .jc-fcard-t > span { display: block; margin-top: 2px; font-size: 10.5px; color: var(--ink-4); line-height: 1.55; overflow-wrap: anywhere; }
/* 公司标:圆角方 + AI 渐变 + 外圈光环(方=组织) */
.proto-root .jc-logo { flex: none; width: 38px; height: 38px; border-radius: 12px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; letter-spacing: .04em; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand), 0 0 0 3px rgba(51,112,255,.10); }
/* 人像:圆 + 青色系(圆=人,与方形的组织标一眼分开) */
.proto-root .jc-face { flex: none; width: 38px; height: 38px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; color: #0a7f74; background: #e4f8f5; border: 1px solid #9fe0d9; box-shadow: 0 0 0 3px rgba(10,162,148,.10); }
/* 邮箱做成可点的胶囊,复制按钮嵌在里面——比一行裸文字有抓手 */
/* 邮箱:软底胶囊、无描边——有描边会读成输入框,而读态这里不是可填的 */
.proto-root .jc-mailrow { display: flex; align-items: center; gap: 4px; margin-top: 5px; min-width: 0; max-width: 100%; padding: 3px 4px 3px 9px; border: 0; border-radius: 8px; background: var(--tone-soft); transition: background var(--t-fast); }
.proto-root .jc-mailrow:hover { background: rgba(10,162,148,.2); }
/* 编辑态里它包着输入框,软底会变成「框中框」,去掉 */
.proto-root .jc-report.editing .jc-mailrow { background: transparent; padding: 0; margin-top: 6px; }
.proto-root .jc-mailrow > code { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; line-height: 1.5; font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
.proto-root .jc-copy { flex: none; width: 22px; height: 22px; display: inline-grid; place-items: center; border: 0; border-radius: 6px; background: #fff; color: var(--ink-4); cursor: pointer; box-shadow: 0 1px 2px rgba(31,35,41,.08); transition: all var(--t-fast); }
.proto-root .jc-copy:hover { color: #0aa294; }
.proto-root .jc-copy.ok { color: var(--ok); background: var(--ok-soft); box-shadow: none; }
/* 客户属性徽章 */
.proto-root .jc-tagbig { display: inline-flex; align-items: center; gap: 6px; align-self: flex-start; padding: 7px 14px; border-radius: 999px; font-size: 13.5px; font-weight: 800; line-height: 1; color: var(--tone); background: var(--tone-soft); border: 1px solid var(--tone-line); }
.proto-root .jc-fcard-note { font-size: 10.5px; color: var(--ink-4); line-height: 1.55; }
.proto-root .jc-fcard-note b { color: var(--ink-2); font-weight: 700; }
.proto-root .jc-fcard-note b.bad { color: var(--tone); font-weight: 800; }
/* 成交迷你柱:把「台账 6 笔成交」从一句话变成看得见的节奏 */
.proto-root .jc-spark { display: flex; align-items: flex-end; gap: 4px; height: 26px; }
.proto-root .jc-spark > i { flex: 1 1 0; min-width: 4px; max-width: 15px; border-radius: 3px 3px 1px 1px; background: var(--tone); opacity: .3; transition: opacity var(--t-fast), height var(--t-med); }
.proto-root .jc-spark > i:last-child { opacity: 1; }
.proto-root .jc-spark:hover > i { opacity: .55; }
.proto-root .jc-spark:hover > i:last-child { opacity: 1; }
.proto-root .jc-spark.empty { align-items: stretch; }
.proto-root .jc-spark.empty > i { height: 100%; opacity: 1; background: repeating-linear-gradient(45deg, var(--tone-soft) 0 5px, transparent 5px 10px); border: 1px dashed var(--tone-line); border-radius: 5px; }
/* 有效 PN 计数 */
.proto-root .jc-count { display: flex; align-items: baseline; gap: 6px; }
.proto-root .jc-count > b { font-family: var(--mono); font-size: 32px; font-weight: 800; line-height: .95; color: var(--ink); letter-spacing: -.04em; }
.proto-root .jc-count > span { font-size: 11px; color: var(--ink-4); }
/* 分段刻度条:一格 = 一条 PN,缺必填的那格直接标红——比一根实心进度条信息量大 */
.proto-root .jc-segmeter { display: flex; gap: 3px; }
.proto-root .jc-segmeter > i { flex: 1; height: 7px; border-radius: 2px; background: var(--ok); transition: background var(--t-fast); }
.proto-root .jc-segmeter > i.bad { background: var(--danger); }
.proto-root .jc-lockhint { display: inline-flex; align-items: center; gap: 4px; font-size: 10px; color: var(--ink-4); }
/* ── 占位胶囊:必填=红虚线,选填=灰虚线;**都带文字**,不靠颜色单打 ── */
.proto-root .jc-ph { display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700; line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed; }
.proto-root .jc-ph.req { color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
.proto-root .jc-ph.opt { color: var(--ink-4); background: var(--soft); border-color: var(--line); }
/* ── 分段控件(新客户 / 老客户)── */
.proto-root .jc-seg { display: inline-flex; padding: 2px; gap: 2px; border: 1px solid var(--cardline); border-radius: 9px; background: var(--soft); align-self: flex-start; }
.proto-root .jc-seg button { border: 0; border-radius: 7px; padding: 6px 13px; font: inherit; font-size: 11.5px; font-weight: 700; color: var(--ink-3); background: transparent; cursor: pointer; transition: all var(--t-fast); }
.proto-root .jc-seg button:hover:not(.on) { color: var(--brand); }
.proto-root .jc-seg button.on { background: #fff; color: var(--ink); box-shadow: 0 1px 3px rgba(31,35,41,.12); }
/* ── 表:读态与编辑态共用骨架 ─────────────────────────────────────── */
.proto-root .jc-tbl-wrap { overflow-x: auto; border: 1px solid var(--line-soft); border-radius: 10px; }
.proto-root .jc-tbl-in { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; }
.proto-root .jc-tbl-in th { background: var(--soft); text-align: left; padding: 8px 9px; font-size: 10.5px; font-weight: 800; letter-spacing: .03em; color: var(--ink-3); white-space: nowrap; border-bottom: 1px solid var(--line-soft); }
.proto-root .jc-tbl-in th.req::after { content: "*"; margin-left: 1px; color: var(--danger); font-size: 10px; }
.proto-root .jc-tbl-in td { padding: 4px 5px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
.proto-root .jc-tbl-in tr:last-child td { border-bottom: 0; }
.proto-root .jc-tbl-in tbody tr:hover td { background: #f7f9fd; }
/* 缺必填的整行浅红打底 + 行首红标——不用找红胶囊,一眼扫到是哪几行 */
.proto-root .jc-tbl-in tr.miss td { background: #fffbfb; }
.proto-root .jc-tbl-in tbody tr.miss:hover td { background: #fff5f5; }
.proto-root .jc-tbl-in td.jc-rn { position: relative; width: 38px; padding: 4px 6px 4px 13px; white-space: nowrap; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); font-weight: 800; }
/* 行首红标只在表态用 ::after —— 卡片态的 ::after 被「第 N 条」占着,不能抢 */
@media (min-width: 961px) {
.proto-root .jc-tbl-in tr.miss td.jc-rn::after { content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger); }
}
.proto-root .jc-rndot { display: inline-block; width: 5px; height: 5px; margin-left: 4px; border-radius: 999px; background: var(--danger); vertical-align: 2px; }
/* 读态单元格 */
.proto-root .jc-vcell { display: block; padding: 6px 6px; font-size: 12px; color: var(--ink); line-height: 1.5; overflow-wrap: anywhere; }
.proto-root .jc-vcell.mono { font-family: var(--mono); font-size: 11px; color: var(--ink-2); }
.proto-root .jc-vcell.num { font-family: var(--mono); font-size: 11.5px; text-align: right; }
/* 编辑态单元格:安静的电子表格 */
.proto-root .jc-cell { width: 100%; min-width: 64px; border: 1px solid var(--line); border-radius: 6px; padding: 6px 7px; font: inherit; font-size: 12px; color: var(--ink); background: #fff; transition: border-color var(--t-fast), box-shadow var(--t-fast); }
.proto-root .jc-cell.mono { font-family: var(--mono); font-size: 11px; }
.proto-root .jc-cell.num { text-align: right; font-family: var(--mono); font-size: 11.5px; }
.proto-root .jc-cell::placeholder { color: var(--ink-4); }
.proto-root .jc-cell:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-wash); outline: none; }
.proto-root .jc-cell.req-empty { border-color: #f3b6b3; background: #fffafa; }
.proto-root .jc-cell.req-empty::placeholder { color: var(--danger); }
/* 备注这类长文本用文本域:能换行、随内容长高,不裁字 */
.proto-root textarea.jc-cell { display: block; resize: vertical; overflow: hidden; min-height: 31px; line-height: 1.55; }
/* 编辑态的整表提示带 */
.proto-root .jc-editbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); border: 1px solid var(--brand-soft); font-size: 11.5px; color: var(--brand-deep); }
.proto-root .jc-editbar svg { flex: none; }
.proto-root .jc-editbar .sp { flex: 1 1 auto; }
/* 剔除行说明 */
.proto-root .jc-drop { display: flex; align-items: flex-start; gap: 8px; margin-top: 10px; padding: 9px 12px; border-radius: 9px; border: 1px dashed var(--line); background: var(--soft); font-size: 11.5px; color: var(--ink-3); line-height: 1.6; }
.proto-root .jc-drop svg { flex: none; margin-top: 2px; color: var(--ink-4); }
.proto-root .jc-drop code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
.proto-root .jc-callout { display: flex; gap: 8px; align-items: flex-start; border-radius: 9px; padding: 11px 13px; font-size: 12.5px; line-height: 1.75; }
.proto-root .jc-callout p { margin: 0; }
.proto-root .jc-callout svg { flex: none; margin-top: 2px; }
.proto-root .jc-callout.t-mute { background: var(--soft); color: var(--ink-2) }
@media (prefers-reduced-motion: reduce) {
.proto-root .jc-pulse-dot { animation: none; }
.proto-root .jc-report, .proto-root .jc-rp-stamp { animation: none; }
}
/* ══════════════════════════════════════════════════════════════════════════
响应式:宁可另起一行,也不损失完整度
≥961px 六列全展开的表(列 min-width 之和 878px,容器有余量,绝不裁字、绝不横滚)
≤960px 表 → 卡片:一条 PN 一张卡、一行一字段(标签 / 值),无横滚、无省略
≤560px 字段行再拆成上下两行(标签在上、值在下)
══════════════════════════════════════════════════════════════════════════ */
@media (max-width: 1040px) { .proto-root .jc-four { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 560px) { .proto-root .jc-four { grid-template-columns: 1fr; } }
@media (max-width: 960px) {
.proto-root .jc-tbl-wrap { overflow: visible; border: 0; border-radius: 0; background: transparent; }
.proto-root .jc-tbl-in,
.proto-root .jc-tbl-in tbody,
.proto-root .jc-tbl-in tr,
.proto-root .jc-tbl-in td { display: block; width: 100%; }
.proto-root .jc-tbl-in thead { display: none; }
/* 一条 PN = 一张卡 */
.proto-root .jc-tbl-in tr { margin-bottom: 10px; padding: 4px 13px 9px; border: 1px solid var(--line-soft); border-radius: 11px; background: #fff; box-shadow: var(--card-shadow); }
.proto-root .jc-tbl-in tr:last-child { margin-bottom: 0; }
.proto-root .jc-tbl-in tbody tr:hover td { background: transparent; }
.proto-root .jc-tbl-in tr.miss { border-color: #f3b6b3; background: #fffbfb; }
/* 卡头:第 N 条 */
.proto-root .jc-tbl-in td.jc-rn { padding: 8px 0 8px; margin-bottom: 4px; border-bottom: 1px solid var(--line-soft); font-family: inherit; font-size: 12px; font-weight: 800; color: var(--ink-2); }
.proto-root .jc-tbl-in td.jc-rn::before { content: "第 "; }
.proto-root .jc-tbl-in td.jc-rn::after { content: " 条"; }
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); }
/* 字段行:左标签 + 右值,值超长自动折行 */
.proto-root .jc-tbl-in td:not(.jc-rn) { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 10px; align-items: center; padding: 6px 0; border-bottom: 1px dashed var(--line-soft); }
.proto-root .jc-tbl-in tr td:last-child { border-bottom: 0; padding-bottom: 2px; }
.proto-root .jc-tbl-in td:not(.jc-rn)::before {
content: attr(data-h); font-size: 10.5px; font-weight: 700; letter-spacing: .02em; color: var(--ink-4); line-height: 1.5;
}
.proto-root .jc-tbl-in td[data-req="1"]:not(.jc-rn)::before { content: attr(data-h) " *"; }
.proto-root .jc-tbl-in .jc-vcell { padding: 0; overflow-wrap: anywhere; }
.proto-root .jc-tbl-in .jc-vcell.num { text-align: left; }
.proto-root .jc-tbl-in .jc-cell.num { text-align: left; }
.proto-root .jc-tbl-in .jc-cell { min-width: 0; }
}
/* 窄屏:落款三件各占一行,动作按钮撑满一行 */
@media (max-width: 560px) {
.proto-root .jc-rp-acts { margin-left: 0; width: 100%; flex-wrap: wrap; }
.proto-root .jc-rp-acts .jc-btn { flex: 1 1 auto; justify-content: center; }
.proto-root .jc-hintline { flex: 1 1 100%; }
.proto-root .jc-tbl-in td:not(.jc-rn) { grid-template-columns: 1fr; gap: 3px; align-items: start; }
}
/* ══ 以下取自 A1-询价受理记录.html(归档件独有的件)══ */
/* ── 料号分类:分布条 + 图例 ───────────────────────────────────────── */
.proto-root .jc-dist { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 13px 15px; border: 1px solid var(--line-soft); border-radius: 12px; background: linear-gradient(180deg, #fbfcfe, #fff); }
.proto-root .jc-distbar { flex: 1 1 320px; min-width: 260px; display: flex; gap: 3px; }
.proto-root .jc-distbar > i { height: 30px; border-radius: 4px; display: grid; place-items: center; font-size: 10.5px; font-weight: 800; color: #fff; font-family: var(--mono); }
.proto-root .jc-distbar > i.a { background: var(--ok); }
.proto-root .jc-distbar > i.b { background: var(--brand); }
.proto-root .jc-distbar > i.c { background: var(--warn-accent); }
.proto-root .jc-distlg { display: flex; flex-wrap: wrap; gap: 8px 18px; }
.proto-root .jc-distlg > span { display: inline-flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--ink-3); }
.proto-root .jc-distlg em { width: 10px; height: 10px; border-radius: 3px; }
.proto-root .jc-distlg em.a { background: var(--ok); } .proto-root .jc-distlg em.b { background: var(--brand); } .proto-root .jc-distlg em.c { background: var(--warn-accent); }
.proto-root .jc-distlg b { font-family: var(--mono); font-weight: 800; color: var(--ink); }
/* 类别徽 A / B / C */
.proto-root .jc-tbadge { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 6px; font-family: var(--mono); font-size: 11px; font-weight: 800; line-height: 1; color: #fff; }
.proto-root .jc-tbadge.a { background: var(--ok); } .proto-root .jc-tbadge.b { background: var(--brand); } .proto-root .jc-tbadge.c { background: var(--warn-accent); }
/* ── 结论与去向 ─────────────────────────────────────────────────────────
G2 是**分支网关**:三条互斥条件按序求值,只命中一条、只落一个活动。
所以这里不是三张并列的去向卡,而是一张**判定表 + 一条结论**。 */
/* 判定表:一行一条规则,命中行上色留痕(DMN 口径) */
.proto-root .jc-dmn { border: 1px solid var(--line-soft); border-radius: 12px; overflow: hidden; }
.proto-root .jc-dmn-h, .proto-root .jc-dmn-r { display: grid; grid-template-columns: 40px minmax(0, 1fr) 170px 200px; align-items: center; gap: 12px; padding: 9px 14px; }
.proto-root .jc-dmn-h { background: var(--soft); border-bottom: 1px solid var(--line-soft); font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); }
.proto-root .jc-dmn-r { font-size: 12px; color: var(--ink-4); background: #fff; }
.proto-root .jc-dmn-r + .jc-dmn-r { border-top: 1px solid var(--line-soft); }
.proto-root .jc-dmn-i { font-family: var(--mono); font-size: 11px; font-weight: 800; }
.proto-root .jc-dmn-c { line-height: 1.6; overflow-wrap: anywhere; }
.proto-root .jc-dmn-t { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; }
.proto-root .jc-dmn-t code { font-family: var(--mono); font-size: 10.5px; font-weight: 800; color: var(--ink-4); background: var(--soft); border: 1px solid var(--line); border-radius: 5px; padding: 2px 6px; }
.proto-root .jc-dmn-v { font-size: 11px; line-height: 1.55; }
/* 命中行:上色 + 左缘棱 + 加粗 */
.proto-root .jc-dmn-r.hit { position: relative; background: var(--tone-soft); color: var(--ink-2); }
.proto-root .jc-dmn-r.hit::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--tone); }
.proto-root .jc-dmn-r.hit .jc-dmn-i { color: var(--tone); }
.proto-root .jc-dmn-r.hit .jc-dmn-c { color: var(--ink); }
.proto-root .jc-dmn-r.hit .jc-dmn-c b { color: var(--tone); font-weight: 800; }
.proto-root .jc-dmn-r.hit .jc-dmn-t { color: var(--ink); }
.proto-root .jc-dmn-r.hit .jc-dmn-t code { color: #fff; background: var(--tone); border-color: var(--tone); }
.proto-root .jc-dmn-v.y { display: inline-flex; align-items: center; gap: 5px; font-weight: 800; color: var(--tone); }
.proto-root .jc-dmn-v.n { color: var(--ink-4); }
/* 色调随命中类别 */
.proto-root .jc-dmn.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.10); }
.proto-root .jc-dmn.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.09); }
.proto-root .jc-dmn.k-c { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.11); }
/* 结论横幅:命中条件 → 唯一去向 */
.proto-root .jc-verdict {
position: relative; overflow: hidden; display: flex; align-items: stretch; flex-wrap: wrap; gap: 16px;
margin-top: 11px; padding: 16px 18px; border: 1px solid var(--tone-line); border-radius: 13px;
background: linear-gradient(105deg, var(--tone-soft), #fff 62%);
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 10px 24px -16px rgba(31,35,41,.4);
--tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.14); --tone-line: #ffd9a8;
}
.proto-root .jc-verdict.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.13); --tone-line: #bfedb4; }
.proto-root .jc-verdict.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft); }
.proto-root .jc-verdict::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 5px; background: var(--tone); }
.proto-root .jc-verdict > * { position: relative; z-index: 1; }
.proto-root .jc-vd-l { flex: 1 1 300px; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
.proto-root .jc-vd-k { font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
.proto-root .jc-vd-cond { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 15px; font-weight: 800; color: var(--ink); }
.proto-root .jc-vd-why { margin: 0; font-size: 11.5px; line-height: 1.7; color: var(--ink-3); overflow-wrap: anywhere; }
.proto-root .jc-vd-why code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
.proto-root .jc-vd-arrow { flex: none; align-self: center; color: var(--tone); }
.proto-root .jc-vd-r { flex: 1 1 260px; min-width: 0; display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-radius: 11px; background: #fff; border: 1px solid var(--tone-line); }
.proto-root .jc-vd-node { display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 800; color: var(--ink); }
.proto-root .jc-vd-node code { font-family: var(--mono); font-size: 12px; font-weight: 800; color: #fff; background: var(--tone); border-radius: 6px; padding: 3px 8px; }
.proto-root .jc-vd-who { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--ink-2); }
.proto-root .jc-vd-who > i { width: 20px; height: 20px; border-radius: 999px; display: inline-grid; place-items: center; font-style: normal; font-size: 10px; font-weight: 700; color: #fff; background: var(--tone); }
.proto-root .jc-vd-next { margin: 0; padding-top: 8px; border-top: 1px dashed var(--line-soft); font-size: 10.5px; line-height: 1.65; color: var(--ink-4); }
.proto-root .jc-vd-next b { color: var(--ink-2); font-weight: 700; }
/* 命中分支携带的条目 */
.proto-root .jc-pnlist { display: flex; flex-wrap: wrap; gap: 5px; margin: 0; padding: 0; list-style: none; }
.proto-root .jc-pnlist > li { display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 999px; background: #fff; border: 1px solid var(--tone-line); font-size: 11px; color: var(--ink-2); overflow-wrap: anywhere; }
.proto-root .jc-pnlist > li > i { flex: none; width: 5px; height: 5px; border-radius: 999px; background: var(--tone); }
.proto-root .jc-pnlist code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
/* ── 落地动作 ───────────────────────────────────────────────────────── */
.proto-root .jc-acts { display: flex; flex-direction: column; gap: 0; border: 1px solid var(--line-soft); border-radius: 11px; overflow: hidden; }
.proto-root .jc-act { display: flex; align-items: flex-start; gap: 9px; padding: 10px 13px; font-size: 12px; color: var(--ink-2); line-height: 1.6; background: #fff; }
.proto-root .jc-act + .jc-act { border-top: 1px solid var(--line-soft); }
.proto-root .jc-act > i { flex: none; width: 18px; height: 18px; margin-top: 1px; border-radius: 999px; display: inline-grid; place-items: center; color: #fff; background: var(--ok); }
.proto-root .jc-act b { font-weight: 700; color: var(--ink); }
.proto-root .jc-act span { color: var(--ink-4); }
.proto-root .jc-act code { font-family: var(--mono); font-size: 10.5px; color: var(--brand-deep); background: var(--brand-wash); border-radius: 4px; padding: 1px 5px; }
.proto-root .jc-done-tag { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; flex: none; padding: 6px 13px; border-radius: 999px; font-size: 11.5px; font-weight: 800; color: var(--ok); background: #fff; border: 1px solid #bfedb4; }
.proto-root .jc-vcell.dim { color: var(--ink-4); }
.proto-root .jc-tbl-in td.jc-ty { width: 44px; text-align: center; }
.proto-root .jc-tbl-in td.jc-ty { width: auto; text-align: left; }
`;
/**
* 图标精灵 —— 与样式表同一出处,两份目标件的 `<symbol>` 取并集。
* 沙箱里取不到图标字体,所以图标一律内联;`<use href="#i-xxx">` 引的就是这一坨。
*/
export const PRD_SPRITE = `<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs><symbol id="i-alert" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></symbol><symbol id="i-arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol><symbol id="i-boxes" viewBox="0 0 24 24"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></symbol><symbol id="i-building" viewBox="0 0 24 24"><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/><path d="M10 18h4"/></symbol><symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol><symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol><symbol id="i-file-check" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="m9 15 2 2 4-4"/></symbol><symbol id="i-file-text" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></symbol><symbol id="i-git-branch" viewBox="0 0 24 24"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></symbol><symbol id="i-history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></symbol><symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></symbol><symbol id="i-lock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol><symbol id="i-mail-open" viewBox="0 0 24 24"><path d="M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0z"/><path d="m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10"/></symbol><symbol id="i-pen" viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z"/></symbol><symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol><symbol id="i-sheet" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M8 13h2"/><path d="M14 13h2"/><path d="M8 17h2"/><path d="M14 17h2"/></symbol><symbol id="i-shield-check" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></symbol><symbol id="i-user" viewBox="0 0 24 24"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/></symbol><symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></symbol></defs></svg>`;
+626 -94
View File
@@ -1,102 +1,634 @@
/** /**
* A2 替代选型的 HTML 汇报模版 —— **活动自己持有的资产**(§9.3),随包版本冻结。 * A2 替代选型的两张报告 —— 2026-08-03 第四轮重设计(隔离设计代理从零起版面;
* standalone 目标件先行,全绿后按《HTML报告生成规范》§7 机械平移成本文件)。
* *
* ── 这一屏给谁看、他此刻要判什么 ────────────────────────────────── * · `ALT_WORKSHEET` 选型执行工单(S1/S2 在办;**一颗待选型料 = 一页**,tr 挂 data-page):
* 看的人是 FAE 与发起的 BD/PM。他此刻只判一件事:**这单还用不用我插手**。 * 档案头(大字原厂 PN + 为什么要替代)→ 蓝图带(原厂芯片 → 五道核对 → 替代插座:
* 于是版面按「先成果、后局面」排 —— 成果是他要签的东西,局面是他决定插不插手的依据: * 空位虚线红槽、定了实线绿槽 —— 概念图形是第一语言)→ 题面/定案双栏
* 一 初筛清单(撒了多少网)· 二 替代对标表(交付件本体) * (左边客户给的,右边 FAE 的产出;等级三枚 radio 直接选,口径贴在钮上)→
* → 三 案例阶段 · 四 当下可为 · 五 目标缺口(AI 的办案局面) * 方法脚注(五项核对 · 三层筛 · 未果通道)。
* 指标带扫一眼就该有结论(候选够不够、差异核没核清),往下五节是「凭什么这么说」。 * 页容器是**真表格块化**(table.alt-pages > tbody > tr):宿主的加行/删行写死找 tr,
* 「补一颗漏掉的待选型料」与「这颗不该选型(剔除)」才真能用。
* · `ALT_ROSTER` 选型验收报告(S3/放行/归档;**通篇只读**):收口总览四格账 + 选型账一行
* → 询价受理清单大表(原料 → 原标 → 替代定案 → 现标 → 依据)→ 落边图
* (清单 → 还有 B 类? → A3 渠道寻源 / A4 成本定价,命中边实线亮 + 流光,
* 由 case:routeKey 投影,一条没命中两边全灰虚 —— 不冒充判过)→ 结论带 → 落地动作。
* 就地改走验收卡的 editableAtRelease 表,这里一个 data-col / data-edit 都没有。
* *
* ── 画面上的数从哪来(一个都不是编的)──────────────────────────── * 设计目标件(standalone,浏览器直接可开,与本文件 1:1):
* · `mine:quote_alternative.*` 是**本单自己写下的草稿行** —— 别单放行过的记录不混进来; * ~/.claude/jobs/0aebbba5/tmp/target-alt-worksheet.html / target-alt-roster.html
* 列名逐字取自 `declarations.ledger`,编一个不存在的列名 = 屏上永远空白。
* · `case:*` / `enabledCount` / `gapCount` / `activeStage` 由语义引擎每拍算出,
* 是确定事实、零 LLM 即可成屏 —— 模型只决定「下一步做什么」,不负责画面。
* · 指标带上「初筛候选 / 已对标」两格指的是**数组本身**:填数时数组取长度
* (view-render 的 `displayValue` 约定),所以不必为「几颗」另立一个计数字段。
* *
* ── 两条不撒谎的纪律 ────────────────────────────────────────── * ── 工程纪律(都踩过,一条不破)──────────────────────────────────────
* ① 右上角挂的是「在办」灯不是印章:这一屏是活的局面,它没盖过章,就别画成盖了章的样子; * ① 主键格必挂 `data-col`(行的钥匙;在 .decide 的 keyrow 里,开闸才现身,
* 紫(tone `case`)是**案例活动的标记色不是状态色** —— 红绿橙只留给「达标没达标」。 * 也是「补一页」落键的地方 —— 不填不落库);
* ② 表空着就是「本单还没写下这一行」,不是渲染坏了 —— 每张表的说明里都写明空着意味着什么, * ② data-rows 行容器的标签名不得在行模版内部再现(tbody 的行内无 tbody);
* 否则人会把空白读成故障。 * ③ 收值按「input 的祖父」归行 —— 一页里所有输入格(含 radio)的祖父都是同一个 .decide;
* * ④ radio(name=alt_grade_pick)不写 checked:当前档由 data-class-of 投影描虚线,
* 容器是 `sandbox=""` iframe:无脚本、无外链,样式与图标由 `@sdk` 自带。 * 「没点」=「不改」;渲染期基座按行号缀名分组。已知边角:人「补一页」克隆末页时
* 克隆页 radio 与末页同组,新页选档会把末页的勾在 DOM 上顶掉 —— 但未选中列
* 不进写回载荷,台账无恙;
* ⑤ 只有待选型(orig_type=C)的行成页:`.alt-page:not(.v-c){display:none}`,判断在样式表;
* ⑥ 模版不算不判;列名逐字照 manifest 声明抄;逐条数据走 `mine:quote_alternative.list`;
* ⑦ 空占位三级全局口径:**必填红 / 建议填橘 / 选填灰**(required / expect / 其余),
* 蓝图插座的空位也归「必填」。
*/ */
import { noteOnly, section, stagesOnly, statsBand, tableOnly, viewShell } from '@sdk'; import { PRD_CSS, PRD_SPRITE } from './prd-style.ts';
export const ALTERNATIVE_CASE = viewShell({ /** 一枚图标 —— `use` 引精灵里的 symbol */
title: '替代选型 · 案例全景', const ico = (name: string, size = 11): string =>
kicker: 'A2 · 替代选型 · 案例活动', `<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>`;
tone: 'case',
icon: 'search', /** 本件补充精灵:五项核对与等级示意的 6 枚 symbol(lucide 线稿风,随模版走,不动 prd-style) */
stamp: { kind: 'live', text: '案例在办 · 非定稿' }, const EXTRA_SPRITE = `<svg width="0" height="0" style="position:absolute" aria-hidden="true">
sign: 'A2 替代选型 · 本页每拍按当下局面重画', <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: body:
statsBand([ `<div class="jc-derive">${ico('info', 14)}
['初筛候选', 'mine:quote_alternative.shortlist'], <span>这份工单只办一件事:给每颗<b>待选型(原标 C)</b>的料定一颗替代 —— 客户
['已对标', 'mine:quote_alternative.benchmark'], <b data-slot="customer_name"></b> 本单待选型 <b data-slot="c_total"></b> 颗,
['当下可为', 'enabledCount'], 一颗一页(右上角翻页)。每页上半是<b>题面</b>(客户给的,改不了),下半右栏是<b>你的定案</b>;
['目标缺口', 'gapCount'], 定案保存后,查库判稳与转标(≤90 天且行情稳转 A,否则转 B 去寻源)由系统接办。
]) + 保存只落任务草稿层;交卷在对话区的执行卡上。</span>
section('初筛清单(本单撒网所得)', </div>` +
tableOnly('mine:quote_alternative.shortlist', [ part('替代定案 · 一颗一页', '可就地修订') +
['原型号', 'original_pn', 'mono'], `<div class="jc-zone z-edit">
['候选型号', 'candidate_pn', 'mono'], <div class="jc-gapbar" data-class-of="quote_alternative_list_missing">${ico('alert', 14)}
['品牌', 'brand'], <span><b>定案还差 <span data-slot="quote_alternative_list_missing"></span> 格必填</b>
['生命周期', 'lifecycle'], —— <span data-slot="quote_alternative_list_missing_keys"></span>。
]) + 翻到那一颗,按「编辑」开闸就地填;补齐了执行卡上才交得了卷。</span>
noteOnly( </div>
'先撒网后核验,常要两三轮:检索与聚合平台拉来的候选先落这一张,<b>逐条人工核验之前都不算数</b>。' + <div class="jc-editgate" data-edit-for="quote_alternative.list">
'生命周期一栏是初筛就要看的第一道险 —— EOL/停产的当场淘汰;' + <span class="jc-editgate-t">${ico('lock', 12)}替代定案<b>已锁定</b> —— 看的时候不误碰,要改按右边</span>
'不足 2~3 颗就放宽参数或换角度重新撒网。这张表空着,是本单还没撒网,不是没找着。', <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>
{ text: '常要两三轮' }, <button type="button" class="jc-egbtn save" data-edit-save="quote_alternative.list">${ico('check', 12)}保存</button>
) + </div>
section('替代对标表(本单交付件)', <div class="jc-editbar">${ico('pen', 14)}
tableOnly('mine:quote_alternative.benchmark', [ <span>定案三格是<b>必填</b>(替代料 PN / 品牌 / 等级),等级直接点三档之一;
['候选型号', 'candidate_pn', 'mono'], 这颗料整个不该选型,按档案头右上角的 × 剔除(划着摆在那儿,可后悔);
['替代等级', 'grade'], 漏了一颗,用底下的「补一颗待选型料」。改动只落任务草稿层,<b>保存不等于放行</b>。</span>
['参数差异项', 'param_diff'], </div>
['差异风险', 'risk'], ${SHEET_PAGE}
['验证建议', 'verify_advice'], <div class="jc-addrow">
['FAE 签字', 'signed_by'], <button type="button" class="jc-egbtn ghost" data-edit-add="quote_alternative.list">${ico('plus', 12)}补一颗待选型料</button>
]) + <em>照末页添一页空档案;行键(原厂 PN)填了才存得下,不填这一页不落库。</em>
noteOnly( </div>
'这是要签字交回发起方的东西:<b>差异没核清的不放行</b> —— 「差异风险」空着的行,' + </div>` +
'就是此刻拦着放行的那几行。替代等级由判定表出(P2P / 功能兼容 / 其他),不是模型自己说了算;' + part('选型方法 · 凭什么这么选', '替代等级口径') +
'等级定评估深度:P2P 核确免改动,其余逐项评软件、外围电路与 layout。' + `<div class="m-five">
'每项数据一律以<b>原厂官网规格书</b>为准。', <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>
{ text: '差异没核清不放行' }, <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>
section('案例阶段(CMMN 生命周期)', <div class="c5">${ico('shield-check', 16)}<div><b>认证</b><span>车规 / 医疗不降级</span></div></div>
stagesOnly('case:stages') + </div>
noteOnly( <div class="m-funnel">
'阶段的生命周期态由语义引擎推进到 <b>Enabled</b> 为止;凡触达世界或产出关键交付的计划项' + <div class="fs"><b>① 参数硬指标</b>封装 / 引脚 / 电气 / 温度 —— 任一不满足直接淘汰,不进下一层。</div>
'必须人工启动 —— <b>Enabled ≠ Active</b>。选型是<b>一事一议</b>:撒几轮网、哪颗值得深评、' + <div class="fs"><b>② 认证与合规</b>车规 AEC-Q100/Q200、医疗、工业等级不可降级;客户点名禁用的品牌与型号不选。</div>
'要不要换角度重来,事前排不出谱,所以调度权交给 AI,系统只保证边框:预算、停滞熔断、放行归人。', <div class="fs"><b>③ 供应与价格</b>在产 / 货态 / 品牌接受度;客户预授权的候选优先。</div>
), </div>
{ text: '推进到 Enabled 为止' }, <div class="jc-drop">${ico('info', 13)}
) + <span>一律以<b>原厂官网规格书</b>为准,不以商城参数表为准。没有把握的替代不填:
section('当下可为(AI 的合法选项)', 宁可在选型备注写明「替代未果」的缘由后保存交回,由判稳一步按剔除处置 ——
stagesOnly('case:enabled') + 编一个候选顶上,是把风险埋进下游的报价单。是否接受非原厂品牌、参数降级,
noteOnly( 必须问客户,不得替客户决定。</span>
'AI 只能在这个集合里选下一步:<b>可以越谱,不可越权</b>。标「待启动」的是触达世界或' + </div>`,
'产出关键交付的项 —— 要按一下再动。这一栏空着不是它偷懒,是当下确实无可为:' + foot: 'FAE 逐颗定案(右上角翻页)—— 判稳与转标由系统查库接办',
'要么在等人回话,要么在等外面的回音。', tag: '统一报价 · A2 替代选型 · 选型作业',
), });
{ text: '本拍的合法选项' },
) + /* ==================== 选型验收报告(收口清单 + 落边)==================== */
section('目标缺口(离交付还差什么)',
stagesOnly('case:gaps') + const ROSTER_CSS = `
noteOnly( /* ── 收口总览:四格账 ── */
'缺口只有人知道 → 调人。选型常要向 BD 补客户口径,那是<b>跨任务征答</b>:卡以系统消息送达' + .proto-root .statrow { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin: 2px 0 10px; }
'对方、<b>就地作答</b>,不占对方的任务清单,也不开放本任务的对话流 —— 一次性的解答请求,' + .proto-root .stat { border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px 9px; background: #fff; position: relative; overflow: hidden; }
'不是责任转移。缺口在世界侧 → 立监听(<b>时限必配</b>)。' + .proto-root .stat::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--line); }
'当下可为 ∩ 目标所需 = 空,就是<b>依赖断点</b>。这一栏空着,是必做的事都办完了。', .proto-root .stat.sa::before { background: var(--ok); }
), .proto-root .stat.sb::before { background: var(--brand); }
{ text: '空 = 必做的都办完了' }, .proto-root .stat.sc::before { background: var(--warn-accent); }
), .proto-root .stat b { display: block; font-size: 24px; font-weight: 800; letter-spacing: -.4px; color: var(--ink); line-height: 1.15; }
.proto-root .stat span { display: block; font-size: 10.5px; color: var(--ink-3); margin-top: 2px; }
/* 「待选型未清」那格:0 才是收口该有的样子 —— 0 绿、非 0 红(判断在样式表) */
.proto-root .stat.sc b.v-0 { color: var(--ok); }
.proto-root .stat.sc b.v-1, .proto-root .stat.sc b.v-2, .proto-root .stat.sc b.v-3, .proto-root .stat.sc b.v-4, .proto-root .stat.sc b.v-5 { color: var(--danger); }
/* 选型账一行:C 类怎么清零的 */
.proto-root .workline { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; margin: 0 0 12px; padding: 8px 12px; border: 1px dashed var(--line); border-radius: 9px; background: var(--soft); font-size: 11.5px; color: var(--ink-3); }
.proto-root .workline b { color: var(--ink); font-weight: 800; }
.proto-root .workline .sep { color: var(--ink-4); }
/* 剔除说明空着就整句收起(判断在样式表;有剔除才有这句话) */
.proto-root .workline span:has(> b:empty) { display: none; }
.proto-root .workline:has(span > b:empty) .sep:last-of-type { display: none; }
/* ── 清单表:序号计数器 + 归档空格「—」+ 底行小字(承继的第二行不出杠)── */
.proto-root .jc-tbl-in tbody { counter-reset: rn; }
.proto-root .jc-tbl-in td.jc-rn::before { counter-increment: rn; content: counter(rn); }
.proto-root .jc-tbl-in td.dimempty > .jc-vcell:empty::after { content: "—"; color: var(--ink-4); }
.proto-root .jc-tbl-in .sub2 { display: block; font-size: 10px; color: var(--ink-4); margin-top: 1px; }
.proto-root .gchip { font-weight: 800; font-size: 11.5px; }
.proto-root .gchip.v-p2p { color: var(--ok); }
.proto-root .gchip.v-func { color: var(--brand); }
.proto-root .gchip.v-other { color: var(--warn-accent); }
.proto-root .gchip:empty::after { content: "—"; color: var(--ink-4); font-weight: 400; }
/* ── 落边图:清单 → 还有 B 类? → A3 / A4(命中边由壳上 v-r1/v-r2 上色,件不算不判)── */
.proto-root .route { border: 1px solid var(--line); border-radius: 12px; padding: 12px 12px 8px; background: linear-gradient(180deg, #fff, var(--soft)); margin: 0 0 10px; }
.proto-root .route svg { display: block; width: 100%; height: auto; }
.proto-root .route .rnode { fill: #fff; stroke: var(--line); stroke-width: 1.4; }
.proto-root .route .rdia { fill: #fff; stroke: var(--ink-3); stroke-width: 1.4; }
.proto-root .route .redge { stroke: var(--line); stroke-width: 2; stroke-dasharray: 6 5; fill: none; }
/* 已走过的共段(清单 → 判定):中性实线,不随命中变色 */
.proto-root .route .rspine { stroke: var(--line); stroke-width: 2; fill: none; }
.proto-root .route .rpulse { stroke: transparent; stroke-width: 2.5; fill: none; }
.proto-root .route .rtxt { font-size: 12px; font-weight: 800; fill: var(--ink-2); }
.proto-root .route .rsub { font-size: 9.5px; font-weight: 400; fill: var(--ink-4); }
.proto-root .route .rcond { font-size: 10px; font-weight: 700; fill: var(--ink-4); }
.proto-root .route .rtip { fill: var(--line); }
/* 命中 r1:仍有 B 类 → A3 渠道寻源(蓝);未命中那条留灰虚线,不冒充判过 */
.proto-root .route.v-r1 .e1 { stroke: var(--brand); stroke-dasharray: none; }
.proto-root .route.v-r1 .n1 { stroke: var(--brand); stroke-width: 2; }
.proto-root .route.v-r1 .t1 { fill: var(--brand-deep); }
.proto-root .route.v-r1 .c1 { fill: var(--brand-deep); }
.proto-root .route.v-r1 .k1 { fill: var(--brand); }
/* 命中 r2:全 A → A4 成本定价(绿) */
.proto-root .route.v-r2 .e2 { stroke: var(--ok); stroke-dasharray: none; }
.proto-root .route.v-r2 .n2 { stroke: var(--ok); stroke-width: 2; }
.proto-root .route.v-r2 .t2 { fill: var(--ok); }
.proto-root .route.v-r2 .c2 { fill: var(--ok); }
.proto-root .route.v-r2 .k2 { fill: var(--ok); }
.proto-root .route .legend { display: flex; gap: 16px; flex-wrap: wrap; justify-content: center; margin: 6px 0 2px; font-size: 10.5px; color: var(--ink-3); }
.proto-root .route .legend b { font-weight: 800; color: var(--ink-2); }
/* 结论带:一句人话结论 + 去向凭据 */
.proto-root .concl { display: flex; gap: 12px; align-items: flex-start; border: 1px solid var(--line); border-left: 4px solid var(--brand); border-radius: 10px; padding: 10px 14px; background: #fff; margin: 0 0 12px; }
.proto-root .concl svg { flex: 0 0 auto; margin-top: 2px; color: var(--brand); }
.proto-root .concl .cl-main { flex: 1 1 auto; min-width: 0; font-size: 12px; line-height: 1.6; color: var(--ink-2); }
.proto-root .concl .cl-main b { color: var(--ink); }
.proto-root .concl .cl-route { flex: 0 0 auto; text-align: right; }
.proto-root .concl .cl-route em { display: block; font-style: normal; font-size: 9.5px; font-weight: 800; letter-spacing: .1em; color: var(--ink-4); }
.proto-root .concl .cl-route b { display: block; font-size: 14px; font-weight: 800; color: var(--brand-deep); margin: 2px 0; }
.proto-root .concl .cl-route code { font-size: 9.5px; color: var(--ink-4); background: var(--soft); border: 1px solid var(--line-soft); border-radius: 5px; padding: 2px 6px; }
/* 窄屏(媒体块排在基础规则之后) */
@media (max-width: 860px) {
.proto-root .statrow { grid-template-columns: repeat(2, 1fr); }
.proto-root .concl { flex-direction: column; }
.proto-root .concl .cl-route { text-align: left; }
}
/* 命中边的流光 —— 连 @keyframes 一起包在动效许可里,减弱设置下全静 */
@media (prefers-reduced-motion: no-preference) {
.proto-root .route.v-r1 .p1, .proto-root .route.v-r2 .p2 { stroke-dasharray: 10 130; animation: jc-flow 2.6s linear infinite; }
.proto-root .route.v-r1 .p1 { stroke: rgba(51,112,255,.65); }
.proto-root .route.v-r2 .p2 { stroke: rgba(39,135,23,.6); }
@keyframes jc-flow { to { stroke-dashoffset: -140; } }
}
`;
/** 清单表(只读;原料 → 原标 → 定案 → 现标 → 依据,一行一颗可回核) */
const ROSTER_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
<thead><tr>
<th style="width:36px;min-width:36px"></th>
<th style="min-width:190px">原厂料</th>
<th style="width:44px;min-width:44px">原标</th>
<th style="min-width:190px">替代定案</th>
<th style="width:64px;min-width:64px">等级</th>
<th style="width:44px;min-width:44px">现标</th>
<th style="min-width:220px;width:100%">转标依据</th>
</tr></thead>
<tbody data-rows="mine:quote_alternative.list"><tr>
<td class="jc-rn"></td>
<td class="dimempty" data-h="原厂料"><span class="jc-vcell jc-mono" data-slot="pn" data-blank></span><span class="sub2" data-slot="brand" data-blank></span></td>
<td class="jc-ty" data-h="原标"><span class="jc-tbadge" data-class-of="orig_type" data-slot="orig_type"></span></td>
<td class="dimempty" data-h="替代定案"><span class="jc-vcell jc-mono" data-slot="alt_pn" data-blank></span><span class="sub2" data-slot="alt_brand" data-blank></span></td>
<td data-h="等级"><span class="gchip" data-class-of="alt_grade" data-slot="alt_grade" data-blank></span></td>
<td class="jc-ty" data-h="现标"><span class="jc-tbadge" data-class-of="type_tag" data-slot="type_tag"></span></td>
<td class="dimempty" data-h="转标依据"><span class="jc-vcell" data-slot="basis" data-blank></span></td>
</tr></tbody>
</table></div>`;
/** 落边图:两条整段 path(命中哪条哪条亮);共段是中性 spine */
const ROUTE_FIG = `<div class="route" data-class-of="case:routeKey">
<svg viewBox="0 0 660 210" fill="none">
<path class="rspine" d="M148 100 H195"/>
<path class="redge e1" d="M305 100 C355 100 380 50 460 50"/>
<path class="rpulse p1" d="M305 100 C355 100 380 50 460 50"/>
<path class="redge e2" d="M250 138 C250 172 340 168 460 168"/>
<path class="rpulse p2" d="M250 138 C250 172 340 168 460 168"/>
<path class="rtip k1" d="M448 44 l14 6 -14 6 z"/>
<path class="rtip k2" d="M448 162 l14 6 -14 6 z"/>
<rect class="rnode" x="8" y="70" width="140" height="60" rx="10"/>
<text class="rtxt" x="78" y="95" text-anchor="middle">询价受理清单</text>
<text class="rsub" x="78" y="113" text-anchor="middle">共 <tspan data-slot="quote_alternative_list_rows"></tspan> 条 · C 已清零</text>
<path class="rdia" d="M250 62 L305 100 250 138 195 100 Z"/>
<text class="rcond" x="250" y="104" text-anchor="middle">还有 B 类?</text>
<text class="rcond c1" x="330" y="38">是 · B 类 <tspan data-slot="quote_alternative_list_type_tag_b"></tspan> 条</text>
<text class="rcond c2" x="330" y="196">否 · 全 A 类</text>
<rect class="rnode n1" x="460" y="18" width="170" height="64" rx="10"/>
<text class="rtxt t1" x="545" y="45" text-anchor="middle">A3 · 渠道寻源</text>
<text class="rsub" x="545" y="63" text-anchor="middle">补上 B 类的货源价格</text>
<rect class="rnode n2" x="460" y="136" width="170" height="64" rx="10"/>
<text class="rtxt t2" x="545" y="163" text-anchor="middle">A4 · 成本定价</text>
<text class="rsub" x="545" y="181" text-anchor="middle">行情完备 · 直通核价</text>
</svg>
<div class="legend"><span>命中边实线亮 · 未命中留灰虚线</span><span>判定在你签字那一刻由系统现算 —— 要改去向,在验收卡的成果表上改现标</span></div>
</div>`;
export const ALT_ROSTER = doc({
badge: 'file-check',
kicker: '统一报价 · A2 替代选型 · 交付成果',
title: '询价受理清单',
titleRef: 'rfq_ref',
state: '收口 · 待放行',
css: ROSTER_CSS,
body:
`<div class="jc-derive">${ico('info', 14)}
<span>这份清单是 A2 的<b>收口成果</b>:上游受理记录逐条承继,待选型(原标 C)的料
已由 FAE 逐颗定案,系统按库内行情判稳转标(≤90 天且行情稳 → A,否则 → B 去寻源)——
询价受理记录就此转成<b>询价受理清单</b>,客户 <b data-slot="customer_name"></b>。
本页只读:要改哪一格,在验收卡的成果表上就地改,这里跟着重渲。</span>
</div>` +
part('收口总览', 'C 类清零 · 判稳定标', 'ok') +
`<div class="statrow">
<div class="stat"><b data-slot="quote_alternative_list_rows"></b><span>清单条目 · 逐条承继上游</span></div>
<div class="stat sa"><b data-slot="quote_alternative_list_type_tag_a"></b><span>A 类 · 行情完备</span></div>
<div class="stat sb"><b data-slot="quote_alternative_list_type_tag_b"></b><span>B 类 · 待补货源价</span></div>
<div class="stat sc"><b data-class-of="quote_alternative_list_type_tag_c" data-slot="quote_alternative_list_type_tag_c"></b><span>C 类 · 待选型未清(应为 0)</span></div>
</div>
<div class="workline">
<span>待选型 <b data-slot="c_total"></b> 颗</span><span class="sep">→</span>
<span>已填回替代 <b data-slot="filled_total"></b> 颗</span><span class="sep">→</span>
<span>判稳转 A <b data-slot="converted_a_total"></b> 颗</span><span class="sep">·</span>
<span>转 B 待寻源 <b data-slot="converted_b_total"></b> 颗</span><span class="sep">·</span>
<span>剔除说明:<b data-slot="skipped_note" data-blank></b></span>
</div>` +
part('询价受理清单', '<b data-slot="quote_alternative_list_rows"></b> 条 · 交下游的就是这一份') +
ROSTER_TABLE +
`<div class="jc-drop">${ico('info', 13)}
<span><b>原标</b>是上游受理时的三分类(留痕,本活动不改);<b>现标</b>是本活动的产物:
C 类经替代定案与判稳转成 A 或 B。现标定去向 —— 改一颗料的现标,下面的落边判定跟着变。
等级口径:<b>P2P</b> 同封装同引脚可直换 · <b>FUNC</b> 功能兼容需调整 · <b>OTHER</b> 部分兼容须打样验证。</span>
</div>` +
part('落边 · 这一签流向哪里', '出口按序求值 · 首个命中即出') +
ROUTE_FIG +
`<div class="concl">
${ico('git-branch', 15)}
<div class="cl-main"><b>选型结论</b> —— <span data-slot="summary"></span></div>
<div class="cl-route"><em>转入活动</em><b data-slot="case:routeTo"></b><code data-slot="case:routeWhy"></code></div>
</div>` +
part('放行后即执行的落地动作', '门前 → 门后') +
`<div class="jc-acts">
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>草稿层合并进正式版</b>&#12288;<span>本任务写下的询价受理清单与选型结论一并落账;
你在验收卡上就地改的那几格随之落定。</span></div>
</div>
<div class="jc-act"><i>${ico('check', 11)}</i>
<div><b>命中的那一口此刻才发射</b>&#12288;<span>清单
<b data-slot="quote_alternative_list_rows"></b> 条随载荷交给上面亮线指向的下游;
另一口本次不起单 —— 放行之前,世界上什么都没有发生。</span></div>
</div>
</div>`,
foot: 'AI 拟稿 · FAE 审定放行 —— 签下去这份清单即转正并流向下游',
tag: '统一报价 · A2 替代选型 → 交下游',
}); });
@@ -0,0 +1,575 @@
/**
* A7 情报归档的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。
* 案例活动没有 `run`,自测不跑回合;测的是清单本身与四处对齐
* (判据谓词 / 出口载荷 / 剧本声明 / 两把尺的档位)。
*/
import { describe, expect, it } from 'vitest';
import { activityManifestSchema } from '@contracts';
import { evaluateCondition, evaluateUnaryTest } from '@sfeel';
import { createMockHost } from '@sdk';
import { A01_DMN, manifest } from '../src/manifest.ts';
import activity from '../src/index.ts';
describe('清单本身合规', () => {
it('过 @contracts 的 schema', () => {
expect(() => activityManifestSchema.parse(manifest)).not.toThrow();
});
it('监听时限已配 —— 无悬空监听', () => {
expect(manifest.declarations.listeners.length).toBeGreaterThan(0);
expect(manifest.declarations.listeners.every((l) => /^P/.test(l.deadline))).toBe(true);
});
it('L1/L2 判据都给了可机检的谓词 —— 验收等级不得谎报', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef, c.key).toBeDefined();
}
});
it('禁区短而硬,授权边界给了调人预算', () => {
expect(manifest.contract.prohibitions.length).toBeGreaterThan(0);
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(0);
});
it('每件必交付物都有台账地址', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(d.ledger, d.key).toBeDefined();
}
});
/*
* **宽进严出**:起单一个必填都不设 —— 上游(A6 的三口)发的是
* `{rfqRef, customerCode, quoteNo, outcome, rows}`,而上一版必填前三个,
* 「补一单历史」那条单飞路直接被门口拦掉。
*/
it('起单宽进:input.required 是空的,两种来路的键都声明着', () => {
expect(manifest.contract.input.data['required']).toEqual([]);
const properties = manifest.contract.input.data['properties'] as Record<string, unknown>;
for (const key of ['rfqRef', 'customerCode', 'quoteNo', 'outcome', 'rows', 'subject', 'body']) {
expect(properties[key], `入参少了 ${key}`).toBeDefined();
}
});
/*
* **输赢归因是必填** —— 这是 A7 的门面判据:件上的缺归因红带、执行卡的提交闸、
* 放行门 L1 三处同源(都数基座的 `_missing`)。声明丢了 required,三处一起失明。
*/
it('输赢归因标了 required —— 红带 / 提交闸 / 放行门三处同源的那个数才存在', () => {
const intel = manifest.declarations.ledger.find((d) => d.object === 'intel');
expect(intel?.fields.find((f) => f.name === 'cause_tag')?.required).toBe(true);
});
/*
* **竞对价是行级必填**:表级 required 会让交期 / 认证败的行永远缺一格,
* 把闸拦成死锁 —— 而那几行本来就没有竞对报价这回事。
*/
it('竞对价是行级必填:只有归因落「输在价格」的那一行才算缺', () => {
const intel = manifest.declarations.ledger.find((d) => d.object === 'intel');
const competitor = intel?.fields.find((f) => f.name === 'competitor_price');
expect(competitor?.required).toBeUndefined();
expect(competitor?.requiredWhen).toEqual({ column: 'cause_tag', value: 'PRICE' });
});
it('归因 / 竞对价 / 依据三列写死了「你一格都不写」', () => {
const collect = manifest.assets.businessModel.steps?.find((s) => s.item === 'S1_collect')?.guide ?? '';
expect(collect).toContain('cause_tag / competitor_price / evidence');
expect(collect).toMatch(/系统不替人归因/);
});
it('结果、归因、败价幅度、入库口径四列都给了取值域 —— 条数由基座现数,不由活动自报', () => {
const intel = manifest.declarations.ledger.find((d) => d.object === 'intel');
const tally = (name: string): readonly string[] =>
intel?.fields.find((f) => f.name === name)?.tally ?? [];
expect(tally('deal_result')).toEqual(['WON', 'LOST']);
expect(tally('cause_tag')).toEqual(['PRICE', 'LEADTIME', 'CERT', 'SPEC', 'RELATION', 'OTHER']);
expect(tally('price_gap_band')).toEqual(['SLIGHT', 'MODERATE', 'STRUCTURAL']);
expect(tally('share_scope')).toEqual(['PENDING', 'FULL', 'MASKED', 'INTERNAL']);
});
it('没有一列滥标 expect —— `_gaps` 不是拿来常亮的', () => {
const flagged = manifest.declarations.ledger.flatMap((d) => d.fields.filter((f) => f.expect === true));
expect(flagged.length).toBe(0);
});
/*
* **三张共享域表已退役**:落地率单案例算不出来(让 AI 编一个数是界面撒谎)、
* 客户画像 A1 已经持有那张主数据、历史价格情报与情报卡是同一份事实。
*/
it('共享域三张表已退役 —— 只剩情报卡与归档结论两张', () => {
const addresses = manifest.declarations.ledger.map((d) => `${d.domain}.${d.object}`);
expect(addresses).toEqual(['quote_archive.intel', 'quote_archive.verdict']);
});
/*
* 情报要**下次查得到**:料号 / 客户 / 时间 / 我方价 / 成交价 / 竞对价 / 输赢原因
* 七样缺一样,这条情报到下一单就哑了。
*/
it('情报卡是「下次查得到的形状」:料号 · 客户 · 时间 · 两个价 · 竞对价 · 归因', () => {
const intel = manifest.declarations.ledger.find((d) => d.object === 'intel');
const names = new Set((intel?.fields ?? []).map((f) => f.name));
for (const col of ['pn', 'customer_code', 'quoted_at', 'quoted_price', 'final_price', 'competitor_price', 'cause_tag']) {
expect(names.has(col), `情报卡少了 ${col}`).toBe(true);
}
expect(intel?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['rfq_ref', 'pn']);
});
it('三条常备案例形状真不同:流内失单 / 成交归档 / 单飞补历史', () => {
expect(manifest.samples.length).toBe(3);
const [lost, won, solo] = manifest.samples;
expect(lost?.input['outcome']).toBe('lost');
expect(won?.input['outcome']).toBe('won');
expect(solo?.input['rows']).toBeUndefined();
expect(String(solo?.input['body'] ?? '').length).toBeGreaterThan(40);
for (const sample of manifest.samples) expect(sample.expect?.ports).toEqual(['archived']);
});
});
describe('案例模型 —— 双表示同版本', () => {
const model = manifest.assets.businessModel;
const cmmn = parseCmmnLite(model.cmmnXml);
it('CMMN 与说明书同在,不允许分叉', () => {
expect(model.cmmnXml).toContain('<casePlanModel');
expect(model.narrative.length).toBeGreaterThan(400);
});
/* 照 MVP 示例工作流 §1.6 的 S1 结果登记 / S2 画像更新 / S3 落地率统计,一步一项 */
it('计划项照 SOP 一步一项:归一 + 逐条归因 + 入库口径拍板 + 放行', () => {
expect(items(model.cmmnXml)).toEqual(['S1_collect', 'S2_attribute', 'T_scope', 'T_release']);
});
it('三处人的门都标了人工激活:逐条归因 · 入库口径拍板 · 归档放行(归一是查表,不标)', () => {
expect(cmmn.manualActivation).toEqual(['S2_attribute', 'T_scope', 'T_release']);
});
it('不用阶段 / 里程碑 / 裁量项', () => {
for (const tag of ['<stage', '<milestone', '<planningTable', '<discretionaryItem']) {
expect(model.cmmnXml, `不该出现 ${tag}`).not.toContain(tag);
}
});
/*
* **归因齐了才叫人拍口径** —— 这个活动的要害:哨兵读的是人自己填的那一格
* (基座按 required 现数的缺项 + 只有人交回才产生的 attributed_total),
* 而不是模型判出来的结论列。A6 两版栽的就是这个:
* **「该不该叫人」不能由被叫的那一方说了算**。
*/
it('入库口径拍板的哨兵读**人自己填的那一格**,不读模型判出来的结论', () => {
const condition = sentryIf(model.cmmnXml, 's_attributed');
expect(condition).toContain('quote_archive_intel_missing = 0');
expect(condition).toContain('attributed_total');
expect(condition).toContain('quote_archive_intel_share_scope_pending > 0');
// 归因落齐、口径还待定 → 叫人
expect(
safeEval(condition, {
attributed_total: 2,
quote_archive_intel_rows: 2,
quote_archive_intel_missing: 0,
quote_archive_intel_share_scope_pending: 2,
}),
).toBe(true);
// 还有行没归因 → 先别叫(问他口径之前,先让他把归因填完)
expect(
safeEval(condition, {
attributed_total: 2,
quote_archive_intel_rows: 2,
quote_archive_intel_missing: 1,
quote_archive_intel_share_scope_pending: 2,
}),
).toBe(false);
// 人还没交回执行单 → 门不开
expect(
safeEval(condition, {
quote_archive_intel_rows: 2,
quote_archive_intel_missing: 0,
quote_archive_intel_share_scope_pending: 2,
}),
).toBe(false);
});
/*
* 入场哨兵也归「机检只读基座数」管(A1 第四次违反就藏在哨兵里)——
* 读活动自报的条数,模型不记那个账,门就永不开,而它一步也没做错。
*/
it('交人归因的入场哨兵读基座行数,不读自报的归档条数', () => {
const condition = sentryIf(model.cmmnXml, 's_collected');
expect(condition).toContain('quote_archive_intel_rows');
expect(condition).not.toContain('archived_total');
expect(safeEval(condition, { intel_total: 2, quote_archive_intel_rows: 2, collected_total: 2 })).toBe(true);
// 行落了但归一那一轮还没走完(collected_total 没写过)→ 门不开
expect(safeEval(condition, { intel_total: 2, quote_archive_intel_rows: 2 })).toBe(false);
});
it('放行门哨兵带 ifPart:每条有归因 + 口径守恒 + 待定清零 + 定盘记过,全读基座数', () => {
const condition = sentryIf(model.cmmnXml, 's_ready');
expect(condition).toContain('quote_archive_intel_missing = 0');
expect(condition).toContain('quote_archive_intel_share_scope_pending = 0');
const done = {
scope_decided_total: 1,
quote_archive_intel_rows: 3,
quote_archive_intel_missing: 0,
quote_archive_intel_share_scope_pending: 0,
quote_archive_intel_share_scope_full: 1,
quote_archive_intel_share_scope_masked: 2,
quote_archive_intel_share_scope_internal: 0,
};
expect(safeEval(condition, done)).toBe(true);
// 还有一行缺归因 → 门不开
expect(safeEval(condition, { ...done, quote_archive_intel_missing: 1 })).toBe(false);
// 还有条目压在待定上 → 门不开(人还没拍板)
expect(
safeEval(condition, { ...done, quote_archive_intel_share_scope_pending: 1, quote_archive_intel_share_scope_masked: 1 }),
).toBe(false);
// 口径没拍过(scope_decided_total 没写过)→ 门不开
expect(safeEval(condition, { ...done, scope_decided_total: undefined })).toBe(false);
});
it('每个计划项都有剧本', () => {
const ids = new Set((model.steps ?? []).map((s) => s.item));
for (const id of items(model.cmmnXml)) expect(ids.has(id), `${id} 没有剧本`).toBe(true);
});
/* 说明书只讲整单的事;逐步做法归剧本 —— 两处各写一份会分叉出不存在的项名(A1 的账) */
it('说明书只讲整单的事:成品、怎么说话、怎么收口', () => {
const narrative = model.narrative;
for (const mark of ['§0 开案', '§Z 结案', '一路上要说话']) {
expect(narrative, `说明书少了「${mark}」`).toContain(mark);
}
expect(narrative, '说明书不该逐步教做法(那是剧本的事)').not.toContain('doneWhen');
});
/*
* 断点那几拍(依赖断点 / 目标缺口)剧本的 step guide 不摊开,模型手上只剩说明书 ——
* 那张决策卡的三处写死必须在说明书里也有一份,否则它会自编一张(A6 实测)。
*/
it('说明书写死了那一张决策卡的 itemId / ask / 三个 value —— 断点那几拍只剩它', () => {
const narrative = model.narrative;
expect(narrative).toContain('"itemId":"T_scope"');
expect(narrative).toContain('按哪一档进库?');
for (const value of ['full', 'masked', 'internal']) {
expect(narrative, `说明书少了选项 ${value}`).toContain(value);
}
});
});
describe('声明与契约对得上', () => {
const columns = new Set(
manifest.declarations.ledger.flatMap((d) => [`${d.domain}.${d.object}`, ...d.fields.map((f) => f.name)]),
);
it('必交付物的台账地址都声明过', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(columns.has(d.ledger ?? ''), `${d.key} → ${d.ledger}`).toBe(true);
}
});
it('editableAtRelease 的列都在成果表的声明里', () => {
const intel = manifest.declarations.ledger.find((d) => d.object === 'intel');
const names = new Set((intel?.fields ?? []).map((f) => f.name));
for (const d of manifest.contract.requiredDeliverables) {
for (const col of d.editableAtRelease ?? []) {
expect(names.has(col), `editableAtRelease 里的 ${col}`).toBe(true);
}
}
});
const rowReadings = new Set([
...manifest.declarations.ledger.map((d) => `${d.domain}_${d.object}_rows`),
...manifest.declarations.ledger
.filter((d) => d.fields.some((f) => f.required === true || f.requiredWhen !== undefined))
.map((d) => `${d.domain}_${d.object}_missing`),
...manifest.declarations.ledger.flatMap((d) =>
d.fields.flatMap((f) =>
(f.tally ?? []).map((v) => `${d.domain}_${d.object}_${f.name}_${v.toLowerCase().replace(/[^a-z0-9_]/g, '')}`),
),
),
]);
it('L1/L2 谓词读的名字都有承载(台账列或基座读数)', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level !== 'L1' && c.level !== 'L2') continue;
for (const identifier of (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? []) {
if (['and', 'or', 'not', 'true', 'false', 'null', 'in', 'between'].includes(identifier)) continue;
if (rowReadings.has(identifier)) continue;
expect(columns.has(identifier), `${c.key} 的谓词引用了 ${identifier}`).toBe(true);
}
}
});
/*
* **机检必须至少有一边是基座数**(违反史已记七次,每次都是同一个死法:
* 那个数要模型自己重算,漏算/改小一次门就废了)。
*/
it('每条 L1/L2 都至少读一个基座读数 —— 不许两边都由活动自报', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level !== 'L1' && c.level !== 'L2') continue;
const names = (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? [];
expect(names.some((n) => rowReadings.has(n)), `${c.key} 一个基座读数都没读`).toBe(true);
}
});
/*
* **A7 是终点**:流程包 E14(`A7 → end_arch`)是无条件边 —— 没有去向要选,
* 所以一口必交付、选择组为空。摆一个空的选择组会让基座退回「人选一口」,
* 而根本没有第二口可选。
*/
it('只有一口 archived,且没有选择组 —— 终点不是岔路口', () => {
expect(manifest.contract.output.mandatory.map((p) => p.name)).toEqual(['archived']);
expect(manifest.contract.output.selective).toEqual([]);
});
it('出口载荷的标量字段都有台账列承载', () => {
const snake = (name: string) => name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
const ports = [
...manifest.contract.output.mandatory,
...manifest.contract.output.selective.flatMap((g) => g.ports),
];
for (const port of ports) {
const properties = (port.payload as { properties?: Record<string, { type?: string }> }).properties ?? {};
for (const [field, spec] of Object.entries(properties)) {
if (spec.type === 'array') continue;
expect(columns.has(snake(field)), `${port.name}.${field} → ${snake(field)}`).toBe(true);
}
}
});
it('技能引用的工具都在白名单声明内 —— 载方法不载权力', () => {
const declared = new Set(manifest.declarations.tools.map((t) => t.name));
for (const skill of manifest.assets.skills) {
for (const fragment of skill.crystallized) {
expect(declared.has(fragment.tool), `${skill.id} 用了 ${fragment.tool}`).toBe(true);
}
}
});
it('L3 判据的整句出现在放行剧本里 —— judge 才对得上名', () => {
const l3 = manifest.contract.acceptanceCriteria.find((c) => c.level === 'L3');
const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
expect(l3).toBeDefined();
expect(release?.guide, 'T_release 的 guide 要整句抄 L3 判据名').toContain(l3?.label ?? '');
});
});
/*
* 两把尺逐行求值 —— 能算的不问 LLM(规范 §5.6)。
* A-01 的出参就是台账 `price_gap_band` 的取值:模型算完照抄,不必再翻译一道。
*/
describe('败价幅度 A-01 —— 价差 → 下一单怎么用这条情报', () => {
const table = A01_DMN.tables.find((t) => t.id === 'a01-band');
it('三个点以内 → 擦肩而过', () => {
expect(decideBy(table, { gap: 0.02 })).toMatchObject({ outputs: ['SLIGHT'] });
});
it('三到十个点 → 够得着(FIRST 命中,第二行就是这一档)', () => {
expect(decideBy(table, { gap: 0.08 })).toMatchObject({ rule: 'a01-2', outputs: ['MODERATE'] });
});
it('超过一成 → 结构性价差,下一单别再报同一价位', () => {
expect(decideBy(table, { gap: 0.204 })).toMatchObject({ outputs: ['STRUCTURAL'] });
});
it('三个档位码与台账取值域逐字一致', () => {
const tally = manifest.declarations.ledger
.find((d) => d.object === 'intel')
?.fields.find((f) => f.name === 'price_gap_band')?.tally;
expect(new Set(table?.rules.map((r) => String(r.then[0])))).toEqual(new Set(tally));
});
});
describe('入库口径建议 A-02 —— 归因 → 建议哪一档(建议归尺,结论归人)', () => {
const table = A01_DMN.tables.find((t) => t.id === 'a02-scope');
it('输在价格 → 建议脱敏:竞对的价是他给客户的,不是给我们的', () => {
expect(decideBy(table, { cause_tag: 'PRICE' })).toMatchObject({ outputs: ['masked'] });
});
it('不涉第三方报价的败因 → 建议全量入库', () => {
for (const tag of ['LEADTIME', 'CERT', 'SPEC', 'RELATION', 'OTHER']) {
expect(decideBy(table, { cause_tag: tag }), tag).toMatchObject({ outputs: ['full'] });
}
});
it('出参就是决策卡上那三个 value —— 建议标得上,人才点得准', () => {
const guide = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_scope')?.guide ?? '';
for (const value of new Set(table?.rules.map((r) => String(r.then[0])))) {
expect(guide, `拍板剧本少了 ${value}`).toContain(`"${value}"`);
}
});
});
describe('两把尺的档位三处同一份 —— 判定表 / 剧本 / 工作单', () => {
const attribute = manifest.assets.businessModel.steps?.find((s) => s.item === 'S2_attribute')?.guide ?? '';
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'archive-worksheet')?.html ?? '';
it('六个归因码剧本与工作单都写着(大写,一个字母不许差)', () => {
for (const code of ['PRICE', 'LEADTIME', 'CERT', 'SPEC', 'RELATION', 'OTHER']) {
expect(attribute, `剧本少了 ${code}`).toContain(code);
expect(sheet, `工作单少了 ${code}`).toContain(code);
}
});
it('三个败价幅度码剧本里写死', () => {
for (const code of ['SLIGHT', 'MODERATE', 'STRUCTURAL']) {
expect(attribute, `剧本少了 ${code}`).toContain(code);
}
});
it('败价幅度的三档门槛剧本与工作单说的是同一组数', () => {
for (const threshold of ['0.03', '0.10']) expect(attribute, `剧本少了 ${threshold}`).toContain(threshold);
for (const band of ['≤ 3%', '3% ~ 10%', '10%']) expect(sheet, `工作单少了 ${band}`).toContain(band);
});
});
describe('报告模版 —— 活动持有,容器无脚本执行权', () => {
it('两个局面各一张:情报归档工作单(干活)与情报归档清单(收口)', () => {
expect(manifest.assets.viewTemplates.map((t) => t.name)).toEqual(['archive-worksheet', 'archive-roster']);
});
it('剧本每一步的 view 都在模版名单里 —— 换不到画面屏上就不动', () => {
const names = new Set(manifest.assets.viewTemplates.map((t) => t.name));
for (const step of manifest.assets.businessModel.steps ?? []) {
expect(names.has(step.view ?? ''), `${step.item} 的 view=${step.view}`).toBe(true);
}
});
it('放行那一步摆的是验收目标件 —— 最后摆上去的那张就是归档件', () => {
const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
expect(release?.view).toBe('archive-roster');
});
it('模版里没有脚本、没有事件处理器、没有外链 —— 样式自带', () => {
for (const template of manifest.assets.viewTemplates) {
expect(template.html, template.name).not.toMatch(/<script|\son[a-z]+=|https?:\/\//i);
expect(template.html, template.name).toContain('<style>');
}
});
it('工作单是干活的件:逐颗档案 + 两把尺 + 填回工作表', () => {
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'archive-worksheet')?.html ?? '';
expect(sheet).toContain('jc-selcard');
expect(sheet).toContain('输赢归因六档');
expect(sheet).toContain('败价幅度 A-01');
expect(sheet).toContain('data-edit="quote_archive.intel"');
});
it('缺归因红带与提交闸同源 —— 件上摆的是基座的 `_missing` 与主键名单', () => {
for (const name of ['archive-worksheet', 'archive-roster']) {
const html = manifest.assets.viewTemplates.find((t) => t.name === name)?.html ?? '';
expect(html, name).toContain('quote_archive_intel_missing');
expect(html, name).toContain('quote_archive_intel_missing_keys');
}
});
/*
* 编辑面精确等于人在那个局面的职权面 + **行的钥匙 pn**:
* 主键格不挂 `data-col`,服务端按「全表一致列」补键,两行会对到同一行上
* (A2 浏览器走查逮过,e2e 走 API 自带主键测不出来)。
*
* 两张件的编辑面**故意不同**:干活那张收归因四列,收口那张只让改归因与依据
* (= `editableAtRelease`)—— 不同工作各有各的件。
*/
it('工作单的编辑面 = 归因 + 竞对价 + 竞对名 + 依据 + 行的钥匙 pn', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'archive-worksheet')?.html ?? '';
const cols = [...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]);
expect(new Set(cols)).toEqual(new Set(['pn', 'cause_tag', 'competitor_price', 'competitor_name', 'evidence']));
});
it('收口件的编辑面 = editableAtRelease 那两列 + 行的钥匙 pn', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'archive-roster')?.html ?? '';
const cols = new Set([...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]));
const editable = manifest.contract.requiredDeliverables[0]?.editableAtRelease ?? [];
expect(cols).toEqual(new Set(['pn', ...editable]));
});
it('口径分布读的是基座按值现数的四段 —— 守恒等式的可视形', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'archive-roster')?.html ?? '';
for (const code of ['pending', 'full', 'masked', 'internal']) {
expect(roster).toContain(`quote_archive_intel_share_scope_${code}`);
}
});
/*
* 一口活动的落边图**不冒充判过**:A7 只有一口且无条件(E14),
* 所以它不投 `case:routeKey`(那是「选出来的」才有的东西),
* 摆的是「这一签之后谁用得上」—— 右边三个用处各挂本单条数。
*/
it('收口件摆落边图:一口无条件,不投 routeKey 冒充选择', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'archive-roster')?.html ?? '';
expect(roster).toContain('归档闭环');
expect(roster).toContain('quote_archive_intel_cause_tag_price');
expect(roster, '一口活动不该摆去向选择').not.toContain('case:routeKey');
});
it('模版只做取值 —— data-class-of 不以 data-slot 为前提(纯色块曾整条静默失色)', () => {
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'archive-worksheet')?.html ?? '';
expect(sheet).toContain('<i data-class-of="deal_result"></i>');
});
it('宿主拒推含脚本的模版', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
});
});
const CONTRACT = {
activityId: 'quote-archive',
version: '1.0.0',
data: { rfqRef: 'RFQ-2026-0777', rows: [] },
message: undefined,
outputPorts: { mandatory: ['archived'], selective: [] },
prohibitions: [],
discretion: [],
} as const;
describe('代码入口 —— 案例活动没有 run,不是残包', () => {
it('默认导出是一个有名字的活动定义', () => {
expect(activity.name).toBe('quote-archive');
});
it('mock 宿主如实报「没有 run」,不静默返回一个假的 outcome', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.run(activity)).rejects.toThrow(/没有 run/);
});
});
/** 按 DMN 语义逐行求值:FIRST 命中即定,单元格谓词复用 @sfeel */
function decideBy(
table: (typeof A01_DMN)['tables'][number] | undefined,
scope: Record<string, unknown>,
): { outputs: readonly unknown[]; rule: string } | undefined {
if (table === undefined) return undefined;
for (const rule of table.rules) {
const hit = table.inputs.every((input, i) => evaluateUnaryTest(rule.when[i] ?? '-', scope[input.expression], scope));
if (hit) return { outputs: rule.then, rule: rule.id };
}
return undefined;
}
function items(xml: string): string[] {
return [...xml.matchAll(/<(?:humanTask|decisionTask|processTask|caseTask) id="([^"]+)"/g)].map((m) => m[1] ?? '');
}
function parseCmmnLite(xml: string): { manualActivation: string[]; repetition: string[] } {
const manualActivation: string[] = [];
const repetition: string[] = [];
for (const m of xml.matchAll(/<humanTask id="([^"]+)"[\s\S]*?<\/humanTask>/g)) {
if (m[0].includes('manualActivationRule')) manualActivation.push(m[1] ?? '');
if (m[0].includes('repetitionRule')) repetition.push(m[1] ?? '');
}
return { manualActivation, repetition };
}
function sentryIf(xml: string, sentryId: string): string {
const block = xml.slice(xml.indexOf(`<sentry id="${sentryId}"`));
const raw = /<condition>([\s\S]*?)<\/condition>/.exec(block.slice(0, block.indexOf('</sentry>')))?.[1] ?? '';
return raw.replaceAll('&gt;', '>').replaceAll('&lt;', '<').replaceAll('&amp;', '&');
}
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
@@ -0,0 +1,240 @@
/**
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(A1 的账)。
*/
import { describe, expect, it } from 'vitest';
import { evaluateCondition } from '@sfeel';
import { ARCHIVE_PHASES, ARCHIVE_STEPS, CMMN_XML } from '../src/case-model.ts';
import { manifest } from '../src/manifest.ts';
const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map(
(m) => m[1] ?? '',
);
describe('剧本与案例模型对得上 —— 对不上的条目等于没写', () => {
it('每一条剧本都指向一个真实存在的计划项', () => {
for (const step of ARCHIVE_STEPS) {
expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
}
});
it('图上每一步都有剧本', () => {
const scripted = new Set(ARCHIVE_STEPS.map((s) => s.item));
for (const id of ITEM_IDS) expect(scripted.has(id), `${id} 没有剧本`).toBe(true);
});
it('清单里挂的就是这份剧本(不是另抄一份)', () => {
expect(manifest.assets.businessModel.steps).toBe(ARCHIVE_STEPS);
});
});
describe('doneWhen 是「成果已存在」的判据,不是「该不该做」的判据', () => {
const doneWhen = (item: string): string => ARCHIVE_STEPS.find((s) => s.item === item)?.doneWhen ?? '';
const evalAt = (item: string, scope: Record<string, unknown>): boolean => {
try {
return evaluateCondition(doneWhen(item), scope);
} catch {
return false;
}
};
/*
* **开局一步都不该被自动收掉**:S-FEEL 里未绑定的名字参与比较为假,
* 靠的就是这一条 —— 写 `!= null` 那一步会开局即收(A1 踩过)。
*/
it('**开局一步都不该被自动收掉** —— 两种开局都要 false', () => {
// 流内开局:上游载荷在,台账一行没写
expect(
evalAt('S1_collect', {
rows: [{ pn: 'PESD2ETH-XR' }],
quote_archive_intel_rows: 0,
}),
).toBe(false);
// 单飞开局:什么都没有
expect(evalAt('S1_collect', { subject: '补个归档' })).toBe(false);
});
it('整份落表、行数对上、归一那一轮走完 → 收得掉', () => {
expect(evalAt('S1_collect', { intel_total: 2, quote_archive_intel_rows: 2, collected_total: 2 })).toBe(true);
});
it('**第一拍写完行就收步是不许的** —— collected_total 没写过,未绑定的名字比大小为假', () => {
expect(evalAt('S1_collect', { intel_total: 2, quote_archive_intel_rows: 2 })).toBe(false);
});
it('行数两边对不上 → 收不掉(漏落一行当场现形,不等到第 10 拍)', () => {
expect(evalAt('S1_collect', { intel_total: 3, quote_archive_intel_rows: 2, collected_total: 2 })).toBe(false);
});
/*
* **三项不给 doneWhen**:交人归因(发卡触达真人)、入库口径拍板(要人拍)、
* 归档放行(签下去触达世界)—— 给了等于给引擎一把替人签字的钥匙。
*/
it('**逐条归因、入库口径拍板、放行三项都不给 doneWhen**', () => {
for (const item of ['S2_attribute', 'T_scope', 'T_release']) {
const step = ARCHIVE_STEPS.find((s) => s.item === item);
expect(step, `${item} 没有剧本`).toBeDefined();
expect(step?.doneWhen, `${item} 不该有 doneWhen`).toBeUndefined();
}
});
it('归一那一步声明了 reads: rows —— 上游载荷置前不截断', () => {
expect(ARCHIVE_STEPS.find((s) => s.item === 'S1_collect')?.reads ?? []).toContain('rows');
});
/*
* **一单同一张卡只发一次**,且缺信息不再走补全卡(2026-08-02 全局退役)——
* 剧本里不许残留「还缺就再发一张卡」这条已经不存在的路。
*/
it('剧本不教补全卡,也不留「还缺就再发一张」的死路', () => {
for (const step of ARCHIVE_STEPS) {
expect(step.guide, `${step.item} 的 guide 提到了补全卡`).not.toMatch(/补全卡|"kind":"ask"/);
expect(step.guide, `${step.item} 的 guide 教了再发一张卡`).not.toMatch(/再发一张卡|重新发一张卡/);
}
});
/*
* **一单只发一张卡**:人点完之后这一步会被重新念一遍,而 guide 的头一段说什么,
* 模型就先做什么 —— 头一段若是「发一张决策卡」,它就再发一张(A4 实测连发四张,
* 调人预算烧光 exhausted)。所以拍板那一步的 guide 必须**先分岔再发卡**。
*/
it('拍板那一步先问「人点过没有」,再谈发卡 —— 分岔要排在发卡骨架前面', () => {
const guide = ARCHIVE_STEPS.find((s) => s.item === 'T_scope')?.guide ?? '';
const branch = guide.indexOf('找到了');
const skeleton = guide.indexOf('【发一张决策卡');
expect(branch, 'T_scope 的 guide 没有「人点过没有」这一岔').toBeGreaterThanOrEqual(0);
expect(skeleton, 'T_scope 的 guide 没有发卡骨架').toBeGreaterThan(branch);
expect(guide).toMatch(/不许再发|别再发第二张卡/);
});
/*
* **拍板那三条路都得动 share_scope** —— A6 2026-08-03 逼出来的结构账:
* 有一条路写着「口径不动」,人点完了待定条数还是那个数,入场哨兵照旧成立,
* 模型读同一段做法就再发一张卡(实测同一张连发六次)。
* 「办完了」得在数上看得见,措辞治不了。
*/
it('拍板三条路各把 share_scope 改成一个结论码 —— 没有一条留在 PENDING', () => {
const guide = ARCHIVE_STEPS.find((s) => s.item === 'T_scope')?.guide ?? '';
for (const code of ['FULL', 'MASKED', 'INTERNAL']) {
expect(guide, `拍板剧本少了 ${code} 这条路`).toContain(`share_scope 改 **${code}**`);
}
expect(guide).toContain('quote_archive_intel_share_scope_pending');
});
/*
* **三个结论码只有拍板那一步写得出来**:归一那一步替他写了,那张卡就永远不会发,
* 这条对外边界成了摆设(A6 实测:一单从头到尾 released 而没有人拍过板)。
*/
it('归一那一步写死「口径一律 PENDING」,三个结论码一个都不许写', () => {
const collect = ARCHIVE_STEPS.find((s) => s.item === 'S1_collect')?.guide ?? '';
expect(collect).toContain('PENDING');
expect(collect, '归一剧本没说清三个结论码不许写').toMatch(/三个码这一步一个都不许写/);
const attribute = ARCHIVE_STEPS.find((s) => s.item === 'S2_attribute')?.guide ?? '';
expect(attribute, '归因那一步不该顺手写入库口径').toContain('别在这一拍写入库口径');
});
/*
* start 墙已拆(2026-08-02):动它就算启动。剧本里再教 start 只是白烧 token,
* 而且学不稳的礼仪就是死法。
*/
it('剧本不教 start 礼仪 —— 那道墙已经拆了(说「不必单发 start」可以,教它先按一下不行)', () => {
for (const step of ARCHIVE_STEPS) {
expect(step.guide, `${step.item} 的 guide 还在教 start`).not.toMatch(/先\s*start|务必\s*start|要先发\s*start/);
}
});
/*
* 【目标缺口】那一段摆的是计划项的**中文名**;当下可为为空(依赖断点)那几拍,
* 模型手上只剩中文名,于是把它当 itemId 写了进去 —— 「计划项不存在」连拒两拍。
* 发卡骨架旁边必须点名说清写英文 id。
*/
it('发卡骨架写死了 itemId 是英文项号,并点名中文名不是 id', () => {
const guide = ARCHIVE_STEPS.find((s) => s.item === 'T_scope')?.guide ?? '';
expect(guide).toContain('"itemId":"T_scope"');
expect(guide, '没告诉模型中文名不能当 itemId').toMatch(/别写中文名/);
for (const value of ['full', 'masked', 'internal']) {
expect(guide, `发卡骨架少了选项 ${value}`).toContain(`"${value}"`);
}
});
it('剧本不教 listen 等回音 —— 那是一条挂到天荒地老的路', () => {
for (const step of ARCHIVE_STEPS) {
expect(step.guide, `${step.item} 的 guide 教了 listen`).not.toMatch(/"kind":"listen"|立监听|发一个\s*listen/);
}
});
/* 执行卡零输入(2026-08-02 用户定):填写全在工作区的件上 */
it('执行卡零输入 —— 剧本明写不许往卡里塞 blocks / fields', () => {
const guide = ARCHIVE_STEPS.find((s) => s.item === 'S2_attribute')?.guide ?? '';
expect(guide).toMatch(/别往卡里塞/);
expect(guide).toContain('执行卡零输入');
});
});
describe('剧本声明的东西活动真有', () => {
const declaredLedger = manifest.declarations.ledger.map((l) => `${l.domain}.${l.object}`);
const declaredTools = manifest.declarations.tools.map((t) => t.name);
it('每一步要写的台账都在清单的台账声明里', () => {
for (const step of ARCHIVE_STEPS) {
for (const address of step.writes) {
expect(declaredLedger, `${step.item} 要写 ${address}`).toContain(address);
}
}
});
it('每一步要调的工具都在清单的工具声明里', () => {
for (const step of ARCHIVE_STEPS) {
for (const tool of step.tools) {
expect(declaredTools, `${step.item} 要调 ${tool}`).toContain(tool);
}
}
});
it('每一步都定死了摆哪张件,且模版真在包里', () => {
const names = new Set(manifest.assets.viewTemplates.map((t) => t.name));
for (const step of ARCHIVE_STEPS) {
expect(step.view, `${step.item} 没定画面`).toBeDefined();
expect(names.has(step.view ?? ''), `${step.item} 的 view「${step.view}」不在模版名单里`).toBe(true);
}
});
it('每一步都说得出它交出什么', () => {
for (const step of ARCHIVE_STEPS) {
expect(step.intent.trim(), `${step.item} 没写 intent`).not.toBe('');
expect(step.guide.trim().length, `${step.item} 的 guide 太短`).toBeGreaterThan(20);
}
});
/* 卡面是纯文本:星号记号不会变粗体,只会原样摆在人眼前(规范 §5.5-10) */
it('发卡骨架里不写星号记号 —— 卡面是纯文本', () => {
for (const step of ARCHIVE_STEPS) {
for (const line of step.guide.split('\n')) {
if (!/"steps"|"standard"|"ask"|"label"|"consequence"/.test(line)) continue;
expect(line.includes('**'), `${step.item} 的卡面文案带了星号:${line.slice(0, 60)}`).toBe(false);
}
}
});
});
describe('阶段刻度:每一项都有格可待', () => {
const inPhase = new Set(ARCHIVE_PHASES.flatMap((p) => p.items));
it('**除放行外每一项恰好属于一个阶段**', () => {
const planned = ARCHIVE_STEPS.map((s) => s.item).filter((id) => id !== 'T_release');
expect(planned.filter((id) => !inPhase.has(id))).toEqual([]);
});
it('一项不进两个阶段', () => {
const all = ARCHIVE_PHASES.flatMap((p) => p.items);
expect(all.length).toBe(new Set(all).size);
});
it('**放行不在名单里** —— 收口那一格由基座统一加', () => {
expect(inPhase.has('T_release')).toBe(false);
});
it('阶段里不许出现案例模型没有的项', () => {
const ids = new Set(ARCHIVE_STEPS.map((s) => s.item));
expect([...inPhase].filter((id) => !ids.has(id))).toEqual([]);
});
});
+330 -54
View File
@@ -1,95 +1,371 @@
/** /**
* A7 情报归档的案例模型 —— **一步落三张表**(2026-07-31 照 A1 的状态机模式重做)。 * A7 情报归档的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
* *
* 归档这件事没有分支,也没有判断:一单报价有了结果,把它沉淀成下一单用得上的情报 —— * 业务出处:`docs/MVP示例工作流.html` §1.6(S1 结果登记 → S2 画像更新 → S3 落地率统计),
* 历史价、客户画像、落地率统计。上一版把这三样各建成一个计划项, * 按 A4/A6 同一条形状重建(2026-08-03 重写 @3.0.0):
* 可它们**互不依赖、同一拍就能写完**,分开只换来两次多余的 finish。
* *
* 两项: * 上游(A6 三口之一的载荷 `{rfqRef, customerCode, quoteNo, outcome, rows}`)进来 →
* T_archive —— 一步落三张表(历史价 / 客户画像 / 落地率); * 归一成**情报卡**底表(一颗料一条:报价 / 成交价 / 结果 / 时间)→ **一张执行单**
* T_release —— 封卷放行(人工激活)。 * 交 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"?> export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL"> <definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
<case id="archive"> <case id="archive">
<!--
案卷项的 id **就是任务视图里的键名**。上游载荷(rows)与本活动台账列都摊平到
任务视图顶层,照它们命名;quote_archive_* 那几个是**基座读数**
(行数 / 缺必填 / 按值现数),由基座现算,模型碰不着。
-->
<caseFileModel> <caseFileModel>
<caseFileItem id="quoteNo" name="报价单号(开案即有)"/> <caseFileItem id="rows" name="上游条目(报价函行,流内开案即有)"/>
<caseFileItem id="archived_total" 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> </caseFileModel>
<casePlanModel id="plan" name="情报归档" autoComplete="true"> <casePlanModel id="plan" name="情报归档" autoComplete="true">
<sentry id="s_archived"> <!--
<onPart id="op_ar" caseFileItemRef="archived_total"><standardEvent>update</standardEvent></onPart> ① 归一完了 —— 交 BD 归因那一步的入场前提。
<ifPart id="if_ar"><condition>archived_total &gt; 0</condition></ifPart> 行数两边对上(一边是基座现数),且归一那一轮走完(collected_total 是归一之后
显式写下的数;「大于等于 0」对「写过的 0」为真、对「没写过」为假)。
-->
<sentry id="s_collected">
<onPart id="op_cl" caseFileItemRef="collected_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_cl"><condition>intel_total &gt; 0 and quote_archive_intel_rows = intel_total and collected_total &gt;= 0</condition></ifPart>
</sentry>
<!--
② **归因齐了才轮到问入库口径** —— 这个活动的要害(与 A6「还价才叫人」同一形状)。
它读的是**人自己填的那一格**(缺归因的行数由基座按 required 现数,
attributed_total 只在人交回执行单之后才产生),不是模型判出来的结论列。
A6 两版都栽在同一个病根上 —— **「该不该叫人」不能由被叫的那一方说了算**。
入库口径这一格也照同一条办:AI 归一时一律写 PENDING(过渡态),
FULL / MASKED / INTERNAL 三个码**只有拍板那一步写得出来**;
待定条数归零,这一步就不会再找人,放行门也才开得了。
-->
<sentry id="s_attributed">
<onPart id="op_at" caseFileItemRef="attributed_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_at"><condition>attributed_total &gt; 0 and quote_archive_intel_rows &gt; 0 and quote_archive_intel_missing = 0 and quote_archive_intel_share_scope_pending &gt; 0</condition></ifPart>
</sentry>
<!--
── 放行门的入场条件:**认「办完了没有」,且读基座数**────────────────
· 每条都有归因(quote_archive_intel_missing 由基座按 required 现数,
与执行卡的提交闸、放行门 L1 同一个数同一套算法);
· **没有还压在待定口径上的条目**(拍板那一下把 PENDING 换成三个结论码之一);
· 口径拍过了(scope_decided_total 是拍板那一步显式写下的记账数);
· 口径四档相加 = 行数(守恒,两边都是基座按值现数)。
-->
<sentry id="s_ready">
<onPart id="op_rd" caseFileItemRef="scope_decided_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_rd"><condition>scope_decided_total &gt; 0 and quote_archive_intel_rows &gt; 0 and quote_archive_intel_missing = 0 and quote_archive_intel_share_scope_pending = 0 and quote_archive_intel_share_scope_full + quote_archive_intel_share_scope_masked + quote_archive_intel_share_scope_internal + quote_archive_intel_share_scope_pending = quote_archive_intel_rows</condition></ifPart>
</sentry> </sentry>
<!-- 一步落三张表:它们互不依赖,同一拍写完 --> <!-- ① 归一情报卡:一颗料一条,承继报价与结果,口径一律先落待定 -->
<humanTask id="T_archive" name="情报归档:落历史价、客户画像、落地率统计"> <humanTask id="S1_collect" name="归一情报卡:逐条承继报价与结果,标上报价日期">
<itemControl><repetitionRule id="r_ar"/></itemControl> <itemControl><repetitionRule id="r_s1"/></itemControl>
</humanTask> </humanTask>
<humanTask id="T_release" name="封卷放行"> <!--
<entryCriterion id="ec_rel" sentryRef="s_archived"/> ② 逐条归因 —— **一张执行单**(输赢的缘由只有跟单的人说得出)。
manualActivation + 不给 doneWhen(发卡触达真人,引擎不替人按)。
-->
<humanTask id="S2_attribute" name="逐条落输赢归因与竞对价:工作单上一条一格">
<entryCriterion id="ec_s2" sentryRef="s_collected"/>
<itemControl><manualActivationRule id="m_s2"/></itemControl>
</humanTask>
<!--
③ 入库口径拍板 —— 情报进不进共享库、要不要脱敏,这一下触达公司对外的边界:
竞对报价与客户身份写进共享库之后,下一单谁都读得到。必须人工激活。
-->
<humanTask id="T_scope" name="入库口径拍板:这一单的情报按哪一档进库">
<entryCriterion id="ec_sc" sentryRef="s_attributed"/>
<itemControl><manualActivationRule id="m_sc"/><repetitionRule id="r_sc"/></itemControl>
</humanTask>
<!-- 归档闭环放行:终局唯放行 —— 触达世界,必须人工激活 -->
<humanTask id="T_release" name="归档闭环验收放行">
<entryCriterion id="ec_rel" sentryRef="s_ready"/>
<itemControl><manualActivationRule id="m_rl"/></itemControl> <itemControl><manualActivationRule id="m_rl"/></itemControl>
</humanTask> </humanTask>
</casePlanModel> </casePlanModel>
</case> </case>
</definitions>`; </definitions>`;
export const NARRATIVE = `§0 开案:一单报价有了结果(成交 / 拒绝 / 超时)即开案。 /**
这一单的价值**全在下一单**:今天记下的拒绝原因,是下次定价时的判据; * 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本,两处不重复写同一件事)。
今天记下的成交价,是下次的基准。所以归档不是收尾的仪式,是情报生产。 */
export const NARRATIVE = `§0 开案:**一单报价有了结果,把它沉淀成下一单用得上的情报**。来路两种,形状都收(宽进):
· **流内转来**:任务数据里有 \`rows\` 数组 —— A6 的报价函行(每条带报价、客户表态与定案),
外加 \`outcome\`(won / lost)说明这一单是成了还是丢了;这是最全的一种来路;
· **单飞**:只有一段需求文字,说了哪一单、给谁报的、什么价、结果如何。
**一步就能办完** —— 三张表互不依赖,同一拍写完: **成品只有一样**:一份**情报卡清单** —— 一颗料一条,每条都有**输赢归因**,
· \`market.price_history\`:这一单报了什么价、什么时候报的、结果如何、拒绝原因是什么; 每条都定了**入库口径**。人签的不是「归了个档」,是**这一单留下了什么、下一单谁用得上**。
· \`customer.profile\`:客户画像该更新什么(价格敏感度、决策周期、常用条款);
· \`shared.landing_stats\`:这个月的落地率、A 类命中、三日回复率。
**不得空归档**:三张表都得有东西。「这一单没什么好记的」不是理由 —— **这一单的价值全在下一单**:今天记下的败因,是下次定价时的判据;今天记下的成交价,
结果本身就是情报,拒绝的原因尤其是。写完把 archived_total 记上,再 finish。 是下次的基准。所以归档不是收尾的仪式,是情报生产。
**「客户没回」不是原因** —— 问不出来就如实写「说不清」,**不许编一个听着合理的理由**:
编出来的原因会一路流进下一单的判据里,而那时已经没人记得它是编的。
【封卷放行】**先 start T_release**(人工激活),提交前用 judge 裁掉裁量判据 **能算的不问人,该人说的不代说**:败价幅度 A-01(价差 → 复用建议)、入库口径建议 A-02
(criterion 照判据名原样写),然后 submit。终局唯放行:你不能自己宣布完成。 (归因 → 建议档位),两支判定表说了算,你不临场拍;而**输赢归因、竞对价、客户原话
三列是人落的,你一格都不写** —— 客户到底为什么不下单,系统里没有一个字段答得了。
【一路上要说话】每一拍带一句 say,说**业务的话**:这一单最后怎么样、为什么、 **三步走**:归一情报卡 → 交 BD 逐条归因 → 入库口径拍板 → 放行闭环。
下次遇到同类该注意什么。一两句、口语。别报「写草稿 N 行」这种系统词。`;
**这一单要经过人两次**:BD 在情报归档工作单上逐条落归因(那张单绑着整份情报卡,
归因缺一条按不下提交 —— 基座的闸看着,他填的格子直接落账);
**入库口径另叫他拍一次板**(一张决策卡)。竞对报价与客户身份写进共享情报库之后,
下一单谁都读得到 —— 这条边界不该由系统自己越过。最后 BD 放行签字。
**这是全流程最后一站**:放行之后没有下一个节点,只有一口 \`archived\`(归档闭环)。
去向不必你选,也没得选。
【这一单只有一个岔路口,它长这样 —— 无论哪一拍都照这个发,别自己另编一张】
入库口径要人拍板,那张决策卡**三处写死**:
· \`"itemId":"T_scope"\` —— 英文计划项号。**别写中文名「入库口径拍板」**:中文名不是 id,
写了当场被拒(【目标缺口】那一段摆的就是中文名,别照它抄);
· \`"ask":"<N> 条情报待定入库口径,按哪一档进库?"\` —— 只把 N 换成条数,**别的字一个都不改**。
换了问法,系统就认不出这是同一张卡:人已经点过的那张会被你再发一遍;
· 三个 \`value\`:\`full\`(全量入库)、\`masked\`(脱敏入库)、\`internal\`(只留内部)。
label 与 consequence 可以按本单的数改写,**value 不行**。
**FULL / MASKED / INTERNAL 三个码只有拍板那一步写得出来** —— 归一那一步遇到情报一律落
PENDING,替他写了口径,那张卡就永远不会发,这条边界成了摆设。
拍板之前**别急着发卡也别绕路** —— 归一那一步先把情报卡写完、finish;
下一拍引擎会把拍板那一步的做法摊开给你,那时再发。
【一路上要说话】负责人正看着这一单。**每一拍都带一句 say** —— 说业务的话:
这一单最后怎么样、归了几条情报、下次遇到同类该注意什么;不说系统词。
到了要找人的那一拍,先说清这一轮办完了什么、为什么现在需要他。
【怎么把一项干完 · 一拍连做】write 和 tool 都不会自动收步 —— 做完用 finish 宣告。
动作排进同一拍的 actions 串一口气给;只有发卡 / 提交(挂起)必须停拍。
**这一单常路 6-8 拍就该收口**。
§Z 结案:BD 在验收单上签字,系统把草稿合并成正式情报卡并发出 \`archived\` 那一口 ——
这条工作流就此走到终点。归档做得好不好,不在这一单看得出来,
在下一次报价能不能一眼看到「上次为什么丢」看得出来。`;
/** /**
* 步骤剧本 —— 与 A1 同一套。放行那一项不给 `doneWhen`(触达世界)。 * 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。
*
* 三处不给 `doneWhen`:`S2_attribute`(发卡触达真人)、`T_scope`(要人拍板)、
* `T_release`(签下去触达世界)—— 给了等于给引擎一把替人签字的钥匙。
*/ */
export const ARCHIVE_STEPS = [ export const ARCHIVE_STEPS = [
{ {
item: 'T_archive', item: 'S1_collect',
intent: '一步落三张表:历史价、客户画像、落地率统计', intent: '整份归一成情报卡:逐条承继报价与结果、标上报价日期、口径一律先落待定',
writes: ['market.price_history', 'customer.profile', 'shared.landing_stats', 'quote_archive.verdict'], writes: ['quote_archive.intel', 'quote_archive.verdict'],
reads: [], /* 上游载荷数组不声明就会被整表段的截断刀砍掉(砍掉的恰是要归一的行) */
reads: ['rows'],
view: 'archive-worksheet',
tools: [], tools: [],
// 三张都写了才算归档过。不写 `!= null` —— 未绑定的名字那样求值为 true(A1 踩过) /*
doneWhen: 'archived_total > 0', * 归一走完才算办完:行数两边对上(一边是基座现数),
guide: `三张表互不依赖,**同一拍写完**: * 且 collected_total 写过(S-FEEL 未绑定的名字参与比较为假,所以「没写过」进不来)。
· \`market.price_history\`:报了什么价、什么时候、结果如何、拒绝原因; */
· \`customer.profile\`:客户画像该更新什么(价格敏感度、决策周期、常用条款); doneWhen: 'intel_total > 0 and quote_archive_intel_rows = intel_total and collected_total >= 0',
· \`shared.landing_stats\`:本月落地率、A 类命中、三日回复率。 guide: `第一件事:把这一单的**情报卡**落出来 —— 一拍连做,别拆成两拍。
**不得空归档** —— 「没什么好记的」不是理由,结果本身就是情报,拒绝原因尤其是。
**同一拍再写一行 \`quote_archive.verdict\`**(验收机检读的就是它): 【条目从哪儿来】
· archived_total = 已归档条数; · 流内转来(\`rows\` 里的行带 pn / quoted_price / reply_type / deal_status):照抄 ——
· reason_logged_total = 已回写拒绝原因/竞对价位的条数(要 > 0); 报过的价、谈成的价都是既成事实,**一个数字都别改**;带 line_total 这类总数的是结论行,
· profile_updated_total = 已更新客户画像标签的条数(要 > 0); 不是料,别落进情报卡。
· summary 一句人话结论。写不对 submit 会被门挡回来。写完再 finish。 · 单飞:照正文归一,列了几颗落几行;正文里说的报价与结果就是要沉淀的那一份。
**每一行都要带 \`rfq_ref\`** —— 它是这一单的单标识(起单入参里就有,原样抄进去)。 **按 pn 归并,一颗料一行**;pn 一个字符不许改(它是主键,改一个字就是新开一行)。
台账主键第一列就是它:不写,这一行会和**别的单**同料号的那一行撞主键,把人家覆盖掉
(实测栽过:A4 的报价草案主键只有 pn,跑四轮只剩最后一轮的 3 行)。`, 【写 \`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', item: 'T_release',
intent: '情报已沉淀,封卷放行', intent: '情报已沉淀、口径已定,提交放行交 BD 验收',
writes: [], writes: [],
reads: [], reads: [],
view: 'archive-roster',
tools: [], tools: [],
guide: `**先 start T_release**(人工激活),提交前用 judge 裁掉裁量判据,然后 submit。 /* 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 doneWhen) */
guide: `每条都有归因、口径全定过、待定清零 —— 可以走放行了
(T_release 人工激活:动它就算启动,不必单发 start)。先把裁量判据裁掉,然后 submit,同一拍连做:
· judge criterion="情报站得住:每条归因说得出来路、竞对价与客户原话可溯,BD 对这份情报负责" ——
看情报卡里每一行的归因与依据:说得出来路就 satisfied=true;
有行说不出的(比如归因落了 PRICE 却没有客户原话),satisfied=**false**,rationale 逐行写清,
交签字的人定。**裁成不满足照样能提交** —— 理由连着卡一起交给他。
criterion 照判据名**整句原样抄**,写别的名字对不上。
**去向没得选**:这是全流程最后一站,只有一口 \`archived\`(归档闭环),放行那一刻自动发射。
你要做的只是把情报落准,别在提交里手选出口。
终局唯放行:你不能自己宣布完成。`, 终局唯放行:你不能自己宣布完成。`,
}, },
]; ];
/**
* 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段;
* 放行那一格由基座统一加,人看到的是 x/4。
*/
export const ARCHIVE_PHASES = [
{ name: '归一情报卡', items: ['S1_collect'] },
{ name: '逐条归因', items: ['S2_attribute'] },
{ name: '入库口径拍板', items: ['T_scope'] },
];
+14 -32
View File
@@ -1,36 +1,18 @@
/** /**
* 统一报价 A7 · 情报归档 —— 活动包代码入口(ADR-004)。 * 统一报价 A7 · 情报归档 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
* 「不成交也留资产」:把归档结果摆给人看,确认无空档再提交放行。 *
* **这个活动没有 `run`,不是缺件。**
*
* 案例活动的推进由 Loop 驱动,行为全部写在清单里:案例模型 + 说明书 + 步骤剧本 +
* 两把判定表 + 技能 + 台账声明 + 两张 HTML 报告模版。
* 上一版这里有一段 `run()`(读 `shared.history_price`、推一个 kv 块、按行数返回
* await/submit)—— 案例活动根本不会调它;更坏的是它读的那张表**从来没被声明过**
* (声明的是 `market.price_history`),而它推的那套呈现与 `viewTemplates` 里的模版
* 各说各话,没人看得出以哪份为准。
*
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 —— 它是**被静态审扫描的产物文本**
* (查 import 与全局引用,证明活动包零环境能力)。
*/ */
import { defineActivity } from '@sdk'; import { defineActivity } from '@sdk';
import type { ActivityBase, ActivityOutcome } from '@sdk';
export default defineActivity({ export default defineActivity({ name: 'quote-archive' });
name: 'quote-archive',
async run(base: ActivityBase): Promise<ActivityOutcome> {
const history = await base.ledger.read('shared.history_price');
const stats = await base.ledger.read('shared.landing_stats');
await base.interaction.pushView([
{
t: 'kv',
title: '归档闭环',
items: [
{ k: '情报回写', v: `${history.length} 条` },
{ k: '落地率', v: stats.length > 0 ? String(stats[0]?.['landing_rate'] ?? '—') : '—' },
{ k: '客户', v: String(base.contract.data['customerCode'] ?? '') },
],
},
]);
// 不得空归档 —— 每一单不成交的报价也是情报资产
if (history.length === 0) {
await base.interaction.say('情报尚未回写,继续登记结果。', 'conclusion');
return { kind: 'await', reason: '等待结果登记步完成' };
}
await base.interaction.say('拒绝原因与竞对价位已回写,画像与落地率已落账。', 'criterion');
return { kind: 'submit', deliverable: 'history-archive', rationale: '情报、画像、落地率三样齐备' };
},
});
+475 -85
View File
@@ -1,139 +1,392 @@
/** /**
* 活动包清单 —— 统一报价 A7 · 情报归档。 * 活动包清单 —— 统一报价 A7 · 情报归档(案例活动)。
* *
* 业务素材:docs/MVP示例工作流.html §1.7(S1 结果登记 → S2 画像更新 → S3 落地率统计)。 * 业务素材:`docs/MVP示例工作流.html` §1.6(S1 结果登记 → S2 画像更新 → S3 落地率统计):
* 「不成交也留资产:情报、客户画像、落地率」—— 写的是**共享域**,跨活动的沉淀事实。 * 「不成交也留资产:情报、客户画像、落地率」
* 「拒绝或无回复的单子 7 天内闭环归档。每一单不成交的报价也是情报资产 ——
* 沉淀下来,下一单更快更准」。
*
* 形状与 A4/A6 同一条(2026-08-03 重写 @3.0.0):归一情报卡 → 一张执行单交人
* (逐条落输赢归因与竞对价)→ 按败价幅度给复用建议 → **入库口径要人拍板**
* (决策卡)→ 放行归档闭环。
* **能算的不问人**(A-01 败价幅度、A-02 入库口径建议两把尺写死),
* **该人说的不代说**(归因、竞对价、客户原话那三列 AI 一格都不写)。
*
* 与 @2.1.0 的差别(六处都是踩过的账,逐条见 case-model 头注):
* ① `input.required` 从 `['rfqRef','customerCode','quoteNo']` 清空 —— 宽进严出;
* ② 两条 L1/L2 判据读 `reason_logged_total` / `profile_updated_total` ——
* **活动自报的数**,写个 1 就过(「机检只读基座数」违反史第七类);
* ③ 台账补 `required` / `requiredWhen` —— 从前一个必填都没有,执行卡的闸没牙;
* ④ 补两处人的判断(归因 + 入库口径)—— 从前 AI 自己写完自己放行;
* ⑤ 三张共享域表退役(墓志铭见 `declarations.ledger` 头注);
* ⑥ `index.ts` 里那段 `run()` 删掉 —— 案例活动不会调它。
*/ */
import type { ActivityManifest } from '@contracts'; import type { ActivityManifest, DmnModel } from '@contracts';
import { ARCHIVE_SKILLS } from './skills.ts';
import { CMMN_XML, NARRATIVE, ARCHIVE_STEPS } from './case-model.ts';
import { activityIdSchema } 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'); const ACTIVITY_ID = activityIdSchema.parse('quote-archive');
/** 本活动自己的数据域 —— 三张沉淀表落 `shared`,只有归档结论落这儿 */
const DOMAIN = 'quote_archive'; /**
* 两把尺 —— **规则本身不是数据**,随包冻结。
*
* · 败价幅度 A-01:我方报价与竞对价的价差 → 这条情报下一单该怎么用
* (出参就是台账 `price_gap_band` 的取值);
* · 入库口径建议 A-02:输赢归因 → 决策卡上标 recommended 的那一档
* (出参是三个 `value` 之一,不落台账 —— 建议不是结论,结论由人拍)。
*
* 出参键名与台账列同名,模型算完照抄即可(规范 §5.6)。
* A-01 的档位在三处出现:这张表、剧本 guide、工作单上的规则块 ——
* 包内自测把三处钉在一起(改一处不改另两处,测就红)。
*/
export const A01_DMN: DmnModel = {
drd: [
{ decision: 'a01-band', requires: [], inputs: ['gap'] },
{ decision: 'a02-scope', requires: [], inputs: ['cause_tag'] },
],
tables: [
{
/** 败价幅度 A-01 —— FIRST 命中即定,所以第二行等价于「三到十个点」 */
id: 'a01-band',
label: '败价幅度 A-01',
hitPolicy: 'FIRST',
inputs: [{ name: 'gap', expression: 'gap' }],
outputs: [{ name: 'price_gap_band' }],
rules: [
{ id: 'a01-1', when: ['<= 0.03'], then: ['SLIGHT'], annotation: '三个点以内 —— 擦肩而过,下一单同价位再试,输的多半不在价格' },
{ id: 'a01-2', when: ['<= 0.10'], then: ['MODERATE'], annotation: '三到十个点 —— 够得着,下一单让到这一档或换替代料降本' },
{ id: 'a01-3', when: ['> 0.10'], then: ['STRUCTURAL'], annotation: '超过一成 —— 结构性价差,下一单别再报同一价位,先走替代选型' },
],
},
{
/**
* 入库口径建议 A-02 —— 只出**建议**,决策卡上那一枚 recommended 就是它。
* 带着第三方报价的情报默认建议脱敏:竞对报价写进共享库,下一单谁都读得到。
*/
id: 'a02-scope',
label: '入库口径建议 A-02',
hitPolicy: 'FIRST',
inputs: [{ name: 'cause_tag', expression: 'cause_tag' }],
outputs: [{ name: 'scope_hint' }],
rules: [
{ id: 'a02-1', when: ['"PRICE"'], then: ['masked'], annotation: '归因带竞对报价 —— 建议脱敏入库:价位与败因照留,谁说的抹掉' },
{ id: 'a02-default', when: ['-'], then: ['full'], annotation: '不涉第三方报价的败因 —— 建议全量入库,下一单查得越全越好' },
],
},
],
};
export const manifest: ActivityManifest = { export const manifest: ActivityManifest = {
identity: { identity: {
activityId: ACTIVITY_ID, activityId: ACTIVITY_ID,
name: 'A7 · 情报归档', name: 'A7 · 情报归档',
packageVersion: '2.1.0', packageVersion: '3.0.0',
category: '统一报价', category: '统一报价',
sdkVersion: '0.1.0-m5', sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-quote-archive', checksum: 'dev-checksum-quote-archive',
}, },
/* ── 契约七件 ────────────────────────────────────────────────── */
contract: { contract: {
activityId: ACTIVITY_ID, activityId: ACTIVITY_ID,
version: '0.1.0', version: '1.0.0',
input: { input: {
/*
* **宽进到底:一个必填都不设**(与 A1/A2/A3/A4/A6 同口径 —— 宽进严出,
* 输入契约在活动内部归一 + 补全)。
*
* 来路两种形状:流内转来(A6 三口的载荷 `{rfqRef, customerCode, quoteNo,
* outcome, rows}` —— `lost` 走 E13 直接进来,`won` 那一口的单子结案时也归这一份档)、
* 单飞(一段文字)。上一版必填 `rfqRef + customerCode + quoteNo`,
* 门口拦的恰是单飞那条路,而归档最常见的来路就是有人补一单历史。
*/
data: { data: {
type: 'object', type: 'object',
required: ['rfqRef', 'customerCode', 'quoteNo'], required: [],
properties: { properties: {
/** 归档写 (pn, quoted_at) 这一行,两个主键都由调用方给定 */
rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' }, rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' },
pn: { type: 'string' }, customerCode: { type: 'string', title: '客户码' },
quotedAt: { type: 'string' }, quoteNo: { type: 'string', title: '报价单号' },
customerCode: { type: 'string' }, outcome: { type: 'string', title: '上游议价结果(won / lost / revise)' },
quoteNo: { type: 'string' }, reason: { type: 'string', title: '不成交缘由(上游给的一句话,归因时的第一手材料)' },
reason: { type: 'string' }, rows: { type: 'array', title: '上游条目(报价函行,带报价、客户表态与定案)' },
competitorPrice: { type: 'number' }, 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, messageAllowed: true,
messageHint: '客户这一单的行为观察、竞对是谁、是否涉密不许外传,写这里',
}, },
output: { output: {
/*
* **一口,且是必交付口**:A7 是流程包的最后一站(E14 `A7 → end_arch` 是无条件边),
* 没有去向要选 —— 归档闭环就是把情报落准、口径定死。
* 载荷全部从**已落地的事实**推导(verdict 那一行),不由模型另述一遍。
*/
mandatory: [ mandatory: [
{ {
name: 'archived', name: 'archived',
payload: { type: 'object', properties: { landingRate: { type: 'number' } } }, payload: {
description: '归档闭环:情报回写 + 落地率落账', 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: [], selective: [],
}, },
/* /*
* 必交付物**必须给 `ledger` 地址**(2026-07-31 修)。 * 必交付物只有一件:**情报卡清单**。归档结论(verdict)是它的结论栏,
* 不给的话预检退回 `scope[key]` 去找 —— key 是 `benchmark-table` 这样的短横线别名, * `ledgers` 说清这份成果由哪几张表承载(一份成果,不是两份)。
* 任务视图里永远没有这个键,于是「必交付物缺一不得提交放行」永远拒,
* 活办完了也放不了行,连拒五拍熔断(实测 A2 死于此)。
*/ */
requiredDeliverables: [ requiredDeliverables: [
{ key: 'history-archive', label: '历史价格情报(归档)', ledger: 'market.price_history' }, {
{ key: 'landing-stats', label: '报价落地率统计', ledger: 'shared.landing_stats' }, key: 'intel-archive',
label: '情报卡清单(逐条输赢归因 + 入库口径)',
ledger: `${D}.intel`,
/* 验收页上让 BD 就地改的两列 —— 恰是他的职权面(归因与依据) */
editableAtRelease: ['cause_tag', 'evidence'],
ledgers: [`${D}.intel`, `${D}.verdict`],
},
], ],
/* /*
* 机检谓词读的是 `verdict` 那张**汇总表**的列(照 A1 的口径)。 * 验收判据 —— **机检(L1/L2)只读基座数得出来的数**:
* 上一版的名字(reasonLogged / profileUpdated)**台账里根本没有** —— S-FEEL 拿未绑定的名字 * `_rows` 基座数行数、`_missing` 基座按 required / requiredWhen 现数
* 比大小求值为假,判据永远达不了标,submit 门永远拒,连拒五拍熔断。 * (与执行卡提交闸同一个数同一套算法)、`_<列>_<值>` 基座按值现数。
* 判据的名字必须落在台账上,否则「机检」是一句空话(2026-07-31 销账)。 * 上一版两条机检读活动自报的 `reason_logged_total` / `profile_updated_total` ——
* 那不是门,是「改哪个数能过」的提示(写个 1 就过去了)。
*/ */
acceptanceCriteria: [ acceptanceCriteria: [
{ key: 'reason-logged', label: '拒绝原因 / 竞对价位已回写', level: 'L1', predicate: 'reason_logged_total > 0' }, {
{ key: 'profile-updated', label: '客户画像标签已更新', level: 'L2', predicate: 'profile_updated_total > 0' }, key: 'intel-complete',
{ key: 'bd-release', label: 'BD 放行', level: 'L5' }, label: '情报卡齐:上游条目逐条承继,说了几条情报卡里就躺着几行',
level: 'L1',
predicate: `intel_total > 0 and ${D}_intel_rows = intel_total`,
},
{
key: 'attributed-all',
label: '每条都有输赢归因:归因落「输在价格」的那几条还落下了竞对报的那个价,缺一行放不了',
level: 'L1',
predicate: `${D}_intel_rows > 0 and ${D}_intel_missing = 0`,
},
{
key: 'result-typed',
label: '结果逐条落成两种之一:成交 / 不成交 —— 没有含糊其辞的格子',
level: 'L2',
predicate: `${D}_intel_deal_result_won + ${D}_intel_deal_result_lost = ${D}_intel_rows`,
},
{
key: 'scope-settled',
label: '入库口径逐条落定:全量 / 脱敏 / 只留内部 / 待定,四档相加等于情报条数',
level: 'L2',
modelRef: 'a02-scope',
predicate: `${D}_intel_rows > 0 and ${D}_intel_share_scope_full + ${D}_intel_share_scope_masked + ${D}_intel_share_scope_internal + ${D}_intel_share_scope_pending = ${D}_intel_rows`,
},
{
/* 与 A6 的「待拍板清零」同一形状:待定是过渡态,人拍过板它才归零 */
key: 'pending-cleared',
label: '入库口径都拍过板了:没有还压在「待定」上的情报条目',
level: 'L2',
predicate: `${D}_intel_share_scope_pending = 0`,
},
{
/*
* 有败价幅度的行**必是归因落了「输在价格」的行** —— 两边都是基座按值现数。
* 不是价格败的却算出一个价差档,是模型给自己找了一条不必问竞对价的近路。
*/
key: 'band-from-price',
label: '算了败价幅度的,都是归因落「输在价格」的那几条 —— 没有凭空算出来的价差档',
level: 'L2',
modelRef: 'a01-band',
predicate: `${D}_intel_price_gap_band_slight + ${D}_intel_price_gap_band_moderate + ${D}_intel_price_gap_band_structural <= ${D}_intel_cause_tag_price`,
},
/*
* 情报站不站得住是**裁量**(L3):归因说不说得出来路、客户原话记没记全,
* 机器数不出来。这句 label 与剧本里 judge 的 criterion **必须逐字一致**。
*/
{
key: 'intel-defensible',
label: '情报站得住:每条归因说得出来路、竞对价与客户原话可溯,BD 对这份情报负责',
level: 'L3',
},
/* L5 是责任关:那张卡本身就是这道关,卡面上不摆它(基座统一滤) */
{ key: 'bd-release', label: 'BD 放行:签下去这一单的情报即入库,下一单查得到', level: 'L5' },
], ],
revisableAtRelease: [], revisableAtRelease: [],
prohibitions: [{ key: 'no-blank-archive', statement: '不得空归档 —— 每一单不成交的报价也是情报资产' }], prohibitions: [
authorization: { discretion: [], preauthorizedActions: [], humanCallBudget: 2 }, {
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: { resources: {
/*
* **不调工具**。归档这一段没有要问外面的事 —— 上游把结果交过来,
* 人把缘由补上,系统把它落成下一单查得到的形状。
*/
tools: [], tools: [],
skills: ARCHIVE_SKILLS.map((s) => s.id), skills: ARCHIVE_SKILLS.map((s) => s.id),
models: ['deepseek-v4-flash'], models: ['deepseek-v4-flash'],
roles: ['BD'], 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: { declarations: {
models: ['deepseek-v4-flash'], models: ['deepseek-v4-flash'],
tools: [], 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: [ ledger: [
{ {
// 共享域:跨活动的沉淀事实(AM 11.3 暗耦合禁令的两条合法路径之一) domain: D,
domain: 'market', object: 'intel',
object: 'price_history', kind: 'record',
kind: 'master', caseKey: 'rfq_ref',
label: '历史价格情报', label: '情报卡',
fields: [ fields: [
{ name: 'pn', type: 'text', primaryKey: true, masked: false }, { name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'quoted_at', type: 'date', primaryKey: true, masked: false }, { name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' },
{ name: 'lost_reason', type: 'text', primaryKey: false, masked: false, label: '拒绝原因' }, { name: 'customer_code', type: 'text', primaryKey: false, masked: false, label: '客户码' },
{ name: 'competitor_price', type: 'number', 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' },
domain: 'customer', /* ── 下次查得到的那几格:时间 / 我方价 / 成交价 / 结果 ───────────── */
object: 'profile', { name: 'quoted_at', type: 'date', primaryKey: false, masked: false, label: '报价日期' },
kind: 'master', { name: 'quoted_price', type: 'number', primaryKey: false, masked: false, label: '我方报价 CNY' },
label: '客户资料库', { name: 'final_price', type: 'number', primaryKey: false, masked: false, label: '成交价(成交才有)' },
fields: [ {
{ name: 'customer_code', type: 'text', primaryKey: true, masked: false, label: '客户码' }, name: 'deal_result',
{ name: 'grade', type: 'text', primaryKey: false, masked: false, label: '客户等级' }, type: 'text',
{ name: 'profile_tags', type: 'text', primaryKey: false, masked: false, label: '画像标签' }, primaryKey: false,
], masked: false,
}, label: '结果',
{ tally: ['WON', 'LOST'],
domain: 'shared', },
object: 'landing_stats', /*
kind: 'derived', * ── 人那一侧的三列:**AI 一格都不写** ──────────────────────────
label: '报价落地率统计', *
fields: [ * 与 A6 的「客户表态三列」同一处结构治:一条要模型分辨「这行能不能替人填」
{ 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命中' }, * `cause_tag` 标 `required` —— 件上的缺项红带、执行卡的提交闸、放行门 L1
{ name: 'reply_rate_d3', type: 'number', primaryKey: false, masked: false, label: 'D+3回复率' }, * 三处同源(都数基座的 `_missing`)。
* `competitor_price` 用 `requiredWhen`:**只有归因落「输在价格」的那一行才算必填**
* (表级 required 会让交期/认证败的行永远缺一格,把闸拦成死锁)。
*/
{
name: 'cause_tag',
type: 'text',
primaryKey: false,
masked: false,
required: true,
label: '输赢归因',
tally: ['PRICE', 'LEADTIME', 'CERT', 'SPEC', 'RELATION', 'OTHER'],
},
{
name: 'competitor_price',
type: 'number',
primaryKey: false,
masked: false,
requiredWhen: { column: 'cause_tag', value: 'PRICE' },
label: '竞对价',
},
{ name: 'competitor_name', type: 'text', primaryKey: false, masked: false, label: '竞对是谁' },
{ name: 'evidence', type: 'text', primaryKey: false, masked: false, label: '依据 / 客户原话' },
/* ── 算出来的那一格:尺说了算,模型照抄 ─────────────────────────── */
{
name: 'price_gap_band',
type: 'text',
primaryKey: false,
masked: false,
label: '败价幅度(A-01)',
tally: ['SLIGHT', 'MODERATE', 'STRUCTURAL'],
},
/*
* 入库口径,**PENDING 是过渡态**(情报归好了、还没定进哪一档库):
* 拍板那一下把它换成 FULL / MASKED / INTERNAL 之一,于是「归因齐了叫人」
* 那道哨兵当场为假。A6 的账:让「拍完不动数」的路存在,同一张卡会连发六次;
* 让模型自己写得出结论码,那张卡就永远不会发。两头都堵死才算数。
*/
{
name: 'share_scope',
type: 'text',
primaryKey: false,
masked: false,
label: '入库口径',
tally: ['PENDING', 'FULL', 'MASKED', 'INTERNAL'],
},
], ],
}, },
{ {
/* /*
* 归档结论 —— 一单一行的**汇总表**,验收机检谓词的入参就在这儿。 * 归档结论 —— 一单一行的**汇总表**,哨兵与报告读它。
* 汇总列与逐行列**故意不同名**:任务视图把草稿行摊平到顶层是「后写的赢」, * 汇总列与逐行列**故意不同名**:任务视图把草稿行摊平到顶层是「后写的赢」,
* 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。 * 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。
*/ */
domain: DOMAIN, domain: D,
object: 'verdict', object: 'verdict',
kind: 'record', kind: 'record',
caseKey: 'rfq_ref', caseKey: 'rfq_ref',
@@ -141,42 +394,179 @@ export const manifest: ActivityManifest = {
fields: [ fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' }, { name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'quote_no', type: 'text', primaryKey: false, masked: false, label: '报价单号' }, { name: 'quote_no', type: 'text', primaryKey: false, masked: false, label: '报价单号' },
{ name: 'archived_total', type: 'integer', primaryKey: false, masked: false, label: '已归档条数' }, { name: 'customer_code', type: 'text', primaryKey: false, masked: false, label: '客户码' },
{ name: 'reason_logged_total', type: 'integer', primaryKey: false, masked: false, label: '已回写原因条数' }, { name: 'customer_name', type: 'text', primaryKey: false, masked: false, label: '客户名称' },
{ name: 'profile_updated_total', type: 'integer', 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: '归档结论' }, { name: 'summary', type: 'text', primaryKey: false, masked: false, label: '归档结论' },
], ],
}, },
], ],
listeners: [], /*
// 7 天闭环(MVP示例 §1.7) * 两条静态监听 —— **时限必配**(AM 4.4)。MVP §1.6 写死「7 天闭环」:
harness: { memoryWindow: 15, stallAfter: 'P1D', nudge: [], retry: { times: 2, backoff: 'PT30S' } }, * 归档没有外部世界的回音要等,这两条管的是「别把它挂到天荒地老」——
* 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: { assets: {
/* 资产初始版:案例模型(2026-07-29 起活动只有这一种业务模型) */
businessModel: { businessModel: {
cmmnXml: CMMN_XML, cmmnXml: CMMN_XML,
narrative: NARRATIVE, narrative: NARRATIVE,
dmn: { drd: [], tables: [] }, dmn: A01_DMN,
/* 步骤剧本 —— 与 A1 同一套:doneWhen 真了引擎自己收步,不问模型 */
steps: ARCHIVE_STEPS, steps: ARCHIVE_STEPS,
phases: ARCHIVE_PHASES,
}, },
skills: ARCHIVE_SKILLS, skills: ARCHIVE_SKILLS,
/*
* 两个局面各一张(与 A2/A3/A4/A6 同款口径):
* · `archive-worksheet` 情报归档工作单 —— 归一与逐条归因期间摆它(逐颗情报档案、
* 归因六档与败价幅度两把尺、填回工作表);
* · `archive-roster` 情报归档清单 —— 拍板、放行、归档摆它。
*/
viewTemplates: [ viewTemplates: [
{ name: 'archive-logged', html: ARCHIVE_LOGGED }, { name: 'archive-worksheet', html: ARCHIVE_WORKSHEET },
{ name: 'archive-release', html: ARCHIVE_RELEASE }, { name: 'archive-roster', html: ARCHIVE_ROSTER },
], ],
cardSchemas: {}, cardSchemas: {},
distillHooks: [ distillHooks: [
{ on: 'terminal', prompt: '归纳丢单的共性:价格、交期、竞对、客户类型 —— 供下一单更快更准。' }, {
on: 'terminal',
prompt: '归纳丢单的共性:价格、交期、竞对、客户类型 —— 供下一单更快更准。',
},
], ],
}, },
/* ── 样例集 —— 三条形状真不同(常备案例即验收案例)───────────── */
samples: [ 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'] }, expect: { ports: ['archived'] },
}, },
], ],
+28 -6
View File
@@ -1,5 +1,5 @@
/** /**
* A7 技能 —— 归档的方法经验(AM 08)。 * A7 技能 —— 归档的方法经验(AM 08)。**载方法不载权力**:这里一条工具都不引。
*/ */
import { skillIdSchema } from '@contracts'; import { skillIdSchema } from '@contracts';
import type { Skill } from '@contracts'; import type { Skill } from '@contracts';
@@ -7,16 +7,38 @@ import type { Skill } from '@contracts';
export const ARCHIVE_SKILLS: Skill[] = [ export const ARCHIVE_SKILLS: Skill[] = [
{ {
id: skillIdSchema.parse('archive.log-lost-reason'), id: skillIdSchema.parse('archive.log-lost-reason'),
label: '把拒绝原因写成下一单的判据', label: '把输赢的缘由写成下一单的判据',
applicability: '客户拒绝或超时,要登记结果时。', applicability: '逐条落归因、准备发那张执行单时。',
method: [ method: [
'「价格高」不是原因,是结论。要写的是**可比对的事实**:', '「价格高」不是原因,是结论。要写的是**可比对的事实**:',
'· 竞对报价多少、差几个百分点;', '· 竞对报价多少、差几个百分点(差 2 个点与差 30 个点是两件完全不同的事);',
'· 输在价格、交期、还是资质/认证;', '· 输在价格、交期、认证资质、规格,还是既有供应关系;',
'· 客户是否明说了可接受区间。', '· 客户是否明说了可接受区间 —— 下一单的起点就藏在这一句里。',
'', '',
'判据要能被下一单**机器读取**:P-03 用「距上次报价 / 行情稳定」,P-01 用客户等级。', '判据要能被下一单**机器读取**: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'), ].join('\n'),
crystallized: [], crystallized: [],
knowledgeRefs: [], knowledgeRefs: [],
@@ -0,0 +1,409 @@
/**
* A1 两份执行页的样式表 —— **从 PRD 目标件原样搬过来**,不是我照着复述的。
*
* 出处:`~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理需求.html` 的整段 `<style>`,
* 外加 `A1-询价受理记录.html` 里归档件独有的那几件(分类分布条 / 类徽 / 判定表 /
* 结论横幅 / 落地动作 / 完成标)。**一个数值都没改**。
*
* 为什么是搬而不是抄:前三轮我用自己那套外壳「照着做」,块搬齐了、版面是两套 ——
* 章节头成了彩色方块编号、多出摘要与元信息带、字号用 clamp。人一眼就看出不是那份件。
* 目标件是**像素级的答案**,搬过来才不会在复述里走样。
*
* 选择器保留 `.proto-root .app-shell` 前缀,所以模版正文也要照原型那样包两层壳 ——
* 少一层壳,整张样式表一条都不生效(踩过)。
*/
export const PRD_CSS = `
/* ============================================================================
JCAI 员工面工作台 · A1 受理归一的过程成果「询价受理需求」——报告本体
立场:**AI 已从 RFQ 邮件抽取归一**,人在这里审核并就地修订。
两态:读态(AI 稿 + 红/灰占位提示缺项) ⇄ 编辑态(全字段可改,保存 / 取消)
核心是「有效 PN 清单」;清单之上四件受理要素:客户公司 / 联系人 / 客户属性 / 有效 PN。
令牌与既有组件取自 workbench/src/app/(proto)/app.css,数值未改。
⚠️ 修正一处原型 bug:app.css:2062-2064 的 '.jc-rp-sign span'(权重 0,1,1)
盖过了 '.jc-rp-ava'(0,1,0),把头像的 display:grid 覆盖成 block、字色改灰,
导致落款头像里的字掉到圆外。本文件把该规则收窄为 '.jc-rp-sign > div > span'。
新增件:四要素卡 .jc-four/.jc-fcard · 占位胶囊 .jc-ph · 分段控件 .jc-seg ·
来源条 .jc-src · 录入表 .jc-tbl-in
========================================================================== */
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { background: #e6eaf1; -webkit-font-smoothing: antialiased; }
.proto-root .app-shell {
--brand: #3370ff; --brand-deep: #245bdb;
--brand-soft: #e1eaff; --brand-wash: #f0f4ff;
--ai-grad: linear-gradient(135deg, #3370ff, #7f3bf5);
--ink: #1f2329; --ink-2: #41464e; --ink-3: #646a73; --ink-4: #8f959e;
--line: #dee0e3; --line-soft: #e8eaed; --soft: #f8f9fb;
--jcai-bg: #e6eaf1; --cardline: #e1e6ec; --card-shadow: 0 1px 2px rgba(31, 35, 41, .05);
--ok: #278717; --ok-soft: #e8f8e4;
--warn: #de7802; --warn-accent: #ff8800; --warn-soft: #fff4e5;
--danger: #d83931; --danger-soft: #feeae9;
--mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace;
--shadow-brand: 0 6px 16px rgba(51, 112, 255, .22);
--t-fast: .16s cubic-bezier(.3, 0, .2, 1);
--t-med: .26s cubic-bezier(.3, 0, .2, 1);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--jcai-bg); min-height: 100vh; color: var(--ink);
}
.proto-root :where(button, input, select, a, [tabindex]):focus-visible {
outline: 2px solid var(--brand); outline-offset: 2px; border-radius: 8px;
}
.proto-root ::selection { background: var(--brand-soft); }
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
@keyframes jcBlink { 0%,100% { opacity: 1 } 50% { opacity: .35 } }
@keyframes jcStamp { from { opacity: 0; transform: rotate(-6deg) scale(1.7); } to { opacity: .9; transform: rotate(-6deg) scale(1); } }
/* 放开宽度:1160px 是「9 列全展开、一个字都不裁」的最小容器宽度(见下方列 min-width 之和) */
.jc-stage { max-width: 1160px; margin: 0 auto; padding: 22px 16px 52px; }
.proto-root .jc-mono { font-family: var(--mono); font-size: 11px; color: var(--ink-3); }
.proto-root .jc-pulse-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--brand); display: inline-block; animation: jcBlink 1.1s ease-in-out infinite; }
/* ── 报告外壳 ───────────────────────────────────────────────────────── */
.proto-root .jc-report { background: #fff; border: 1px solid var(--cardline); border-radius: 14px; box-shadow: var(--card-shadow); overflow: hidden; animation: rise var(--t-med) both; }
.proto-root .jc-rp-head { position: relative; overflow: hidden; display: flex; align-items: flex-start; gap: 12px; padding: 16px 20px 13px; border-bottom: 2px solid var(--ink); }
/* 题头下缘粗细双线 = 正式件版式 */
.proto-root .jc-rp-head::after { content: ""; position: absolute; left: 0; right: 0; bottom: -4px; height: 1px; background: var(--line); }
/* 题头右上的品牌光晕:与工作台标题区同一签名件 */
.proto-root .jc-rp-head::before { content: ""; position: absolute; right: -70px; top: -96px; width: 240px; height: 240px; border-radius: 999px; background: radial-gradient(circle, rgba(51,112,255,.10), transparent 70%); pointer-events: none; }
.proto-root .jc-rp-head > * { position: relative; z-index: 1; }
.proto-root .jc-rp-badge { flex: none; width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand); }
.proto-root .jc-rp-tit { flex: 1; min-width: 0; }
.proto-root .jc-rp-tit em { display: block; font-style: normal; font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); font-family: var(--mono); }
.proto-root .jc-rp-tit h3 { margin: 4px 0 0; font-size: 18px; font-weight: 800; color: var(--ink); letter-spacing: -.01em; line-height: 1.3; }
.proto-root .jc-rp-wait { display: inline-flex; align-items: center; gap: 6px; flex: none; margin-top: 4px; font-size: 11px; font-weight: 700; border-radius: 999px; padding: 4px 12px; color: var(--brand-deep); background: var(--brand-wash); border: 1px solid var(--brand-soft); }
.proto-root .jc-rp-wait.warn { color: var(--warn); background: var(--warn-soft); border-color: #ffd9a8; }
.proto-root .jc-rp-wait.edit { color: #fff; background: var(--brand); border-color: var(--brand); }
.proto-root .jc-rp-stamp { flex: none; margin-top: 2px; transform: rotate(-6deg); border: 2px solid currentColor; border-radius: 9px; padding: 4px 8px 3px 12px; font-size: 13px; font-weight: 900; letter-spacing: .28em; color: var(--ok); opacity: .9; animation: jcStamp .32s cubic-bezier(.2, 1.4, .4, 1) both; }
.proto-root .jc-rp-bd { padding: 18px 20px 12px; }
.proto-root .jc-rp-part { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 9px; margin: 20px 0 11px; }
.proto-root .jc-rp-part:first-child { margin-top: 0; }
.proto-root .jc-rp-part > b { flex: none; font-size: 11px; font-weight: 800; letter-spacing: .12em; color: var(--ink-3); }
.proto-root .jc-rp-part > .ln { flex: 1; height: 1px; background: var(--line-soft); }
.proto-root .jc-rp-part > em { flex: none; font-style: normal; font-size: 10px; font-weight: 700; color: var(--ink-4); font-family: var(--mono); background: var(--soft); border-radius: 999px; padding: 2px 9px; white-space: nowrap; }
.proto-root .jc-rp-part > em.warn { color: var(--warn); background: var(--warn-soft); }
/* 落款 —— ⚠️ 选择器收窄到 > div > *,别再命中头像那个 span */
.proto-root .jc-rp-foot { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 13px 20px 15px; border-top: 2px solid var(--line); background: #fbfcfd; }
.proto-root .jc-rp-sign { display: flex; align-items: center; gap: 10px; flex: 1 1 200px; min-width: 0; }
.proto-root .jc-rp-ava { flex: 0 0 auto; width: 30px; height: 30px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; line-height: 1; color: #fff; background: var(--brand); }
.proto-root .jc-rp-sign > div { min-width: 0; }
.proto-root .jc-rp-sign > div > b { display: block; font-size: 12px; font-weight: 700; color: var(--ink-2); }
.proto-root .jc-rp-sign > div > span { display: block; margin-top: 1px; font-size: 10.5px; color: var(--ink-4); }
.proto-root .jc-rp-acts { display: flex; align-items: center; gap: 8px; margin-left: auto; flex: none; }
.proto-root .jc-hintline { font-size: 11px; font-weight: 700; color: var(--warn); line-height: 1.5; }
.proto-root .jc-btn { display: inline-flex; align-items: center; gap: 5px; border: 0; border-radius: 7px; padding: 8px 14px; font-size: 12px; font-weight: 600; font-family: inherit; background: var(--brand); color: #fff; cursor: pointer; transition: all var(--t-fast); }
.proto-root .jc-btn:hover:not(:disabled) { background: var(--brand-deep); }
.proto-root .jc-btn:disabled { opacity: .45; cursor: not-allowed; }
.proto-root .jc-btn-ghost { background: #fff; color: var(--ink-2); border: 1px solid var(--line); }
.proto-root .jc-btn-ghost:hover:not(:disabled) { background: var(--brand-wash); color: var(--brand); border-color: var(--brand-soft); }
.proto-root .jc-btn-primary { background: var(--ai-grad); }
.proto-root .jc-btn-primary:hover:not(:disabled) { filter: brightness(1.07); box-shadow: var(--shadow-brand); transform: translateY(-1px); }
/* ── 来源信件:客户 RFQ 邮件原文(复用原型 jc-letter 件,青色 = 收件) ── */
.proto-root .jc-letter { position: relative; border: 1px solid var(--cardline); border-left: 3px solid #0aa294; border-radius: 12px; padding: 14px 16px 15px; background: #fff; box-shadow: var(--card-shadow); }
.proto-root .jc-letter-h { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 11px; }
.proto-root .jc-letter-dir { display: inline-flex; align-items: center; gap: 5px; font-size: 10.5px; font-weight: 800; padding: 3px 9px; border-radius: 999px; background: #e4f8f5; color: #0a7f74; }
.proto-root .jc-letter-h .jc-chip { font-size: 10.5px; padding: 3px 9px; border-radius: 999px; background: var(--brand-wash); color: var(--brand-deep); border: 1px solid var(--brand-soft); }
.proto-root .jc-letter-h > code { margin-left: auto; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
.proto-root .jc-letter-meta { display: flex; flex-direction: column; gap: 4px; margin-bottom: 11px; padding-bottom: 11px; border-bottom: 1px solid var(--line-soft); font-size: 11.5px; color: var(--ink-2); }
.proto-root .jc-letter-meta p { margin: 0; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.proto-root .jc-letter-meta i { flex: none; font-style: normal; width: 44px; font-size: 10px; font-weight: 800; letter-spacing: .06em; color: var(--ink-4); }
.proto-root .jc-letter-meta code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); overflow-wrap: anywhere; }
.proto-root .jc-letter-sub { margin: 0 0 9px; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.45; overflow-wrap: anywhere; }
/* 正文:原文照录,pre-wrap 保原始换行;不截断、不折叠 */
.proto-root .jc-letter-body { margin: 0; font-size: 12.5px; line-height: 1.72; color: var(--ink-2); white-space: pre-wrap; overflow-wrap: anywhere; }
.proto-root .jc-letter-body b { color: var(--ink); font-weight: 700; }
.proto-root .jc-atts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; padding-top: 11px; border-top: 1px dashed var(--line-soft); }
.proto-root .jc-att { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px 5px 8px; border: 1px solid var(--brand-soft); border-radius: 8px; background: var(--brand-wash); font-size: 11.5px; color: var(--brand-deep); }
.proto-root .jc-att svg { flex: none; color: var(--brand); }
.proto-root .jc-att code { font-family: var(--mono); font-size: 10.5px; }
.proto-root .jc-att span { color: var(--ink-4); }
/* 抽取指向:从原文到下方要素的一句衔接 */
.proto-root .jc-derive { display: flex; align-items: flex-start; gap: 8px; margin-top: 11px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); font-size: 11.5px; line-height: 1.7; color: var(--brand-deep); }
.proto-root .jc-derive svg { flex: none; margin-top: 2px; }
/* ── 四要素卡 ───────────────────────────────────────────────────────────
色调字典:每张卡带一套 --tone / --tone-soft / --tone-line,组件只读变量。
卡 3、卡 4 的色调随数据变(老客户绿 / 新客户橙;齐了蓝 / 有缺橙),
所以这条要素带本身就是一行可读的状态,不只是四个格子。 */
.proto-root .jc-four { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 11px; }
.proto-root .jc-fcard {
position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-width: 0;
padding: 14px 14px 14px; border: 1px solid var(--cardline); border-radius: 13px; background: #fff;
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 8px 20px -14px rgba(31,35,41,.35);
transition: transform var(--t-fast), box-shadow var(--t-fast), border-color var(--t-fast);
--tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft);
}
/* 顶缘色棱 + 右上角光晕:卡的身份色,一眼分得清四件是四件 */
.proto-root .jc-fcard::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 3px; background: var(--tone); }
.proto-root .jc-fcard::after { content: ""; position: absolute; right: -46px; top: -52px; width: 132px; height: 132px; border-radius: 999px; background: radial-gradient(circle, var(--tone-soft), transparent 70%); pointer-events: none; }
.proto-root .jc-fcard > * { position: relative; z-index: 1; }
.proto-root .jc-fcard:hover { transform: translateY(-2px); border-color: var(--tone-line); box-shadow: 0 2px 4px rgba(31,35,41,.05), 0 14px 26px -16px rgba(31,35,41,.45); }
.proto-root .jc-fcard.t-teal { --tone: #0aa294; --tone-soft: rgba(10,162,148,.15); --tone-line: #9fe0d9; }
.proto-root .jc-fcard.t-ok { --tone: var(--ok); --tone-soft: rgba(39,135,23,.14); --tone-line: #bfedb4; }
.proto-root .jc-fcard.t-warn { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.12); --tone-line: #ffd9a8; }
.proto-root .jc-fcard-k { display: flex; align-items: center; gap: 6px; font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
.proto-root .jc-fcard-k > i { flex: none; width: 19px; height: 19px; border-radius: 6px; display: inline-grid; place-items: center; background: var(--tone-soft); color: var(--tone); }
.proto-root .jc-fcard-b { display: flex; align-items: center; gap: 10px; min-width: 0; }
.proto-root .jc-fcard-t { min-width: 0; flex: 1; }
/* 一律换行,不省略号——宁可多占一行,也不丢字 */
.proto-root .jc-fcard-t > b { display: block; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.35; overflow-wrap: anywhere; }
.proto-root .jc-fcard-t > span { display: block; margin-top: 2px; font-size: 10.5px; color: var(--ink-4); line-height: 1.55; overflow-wrap: anywhere; }
/* 公司标:圆角方 + AI 渐变 + 外圈光环(方=组织) */
.proto-root .jc-logo { flex: none; width: 38px; height: 38px; border-radius: 12px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; letter-spacing: .04em; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand), 0 0 0 3px rgba(51,112,255,.10); }
/* 人像:圆 + 青色系(圆=人,与方形的组织标一眼分开) */
.proto-root .jc-face { flex: none; width: 38px; height: 38px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; color: #0a7f74; background: #e4f8f5; border: 1px solid #9fe0d9; box-shadow: 0 0 0 3px rgba(10,162,148,.10); }
/* 邮箱做成可点的胶囊,复制按钮嵌在里面——比一行裸文字有抓手 */
/* 邮箱:软底胶囊、无描边——有描边会读成输入框,而读态这里不是可填的 */
.proto-root .jc-mailrow { display: flex; align-items: center; gap: 4px; margin-top: 5px; min-width: 0; max-width: 100%; padding: 3px 4px 3px 9px; border: 0; border-radius: 8px; background: var(--tone-soft); transition: background var(--t-fast); }
.proto-root .jc-mailrow:hover { background: rgba(10,162,148,.2); }
/* 编辑态里它包着输入框,软底会变成「框中框」,去掉 */
.proto-root .jc-report.editing .jc-mailrow { background: transparent; padding: 0; margin-top: 6px; }
.proto-root .jc-mailrow > code { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; line-height: 1.5; font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
.proto-root .jc-copy { flex: none; width: 22px; height: 22px; display: inline-grid; place-items: center; border: 0; border-radius: 6px; background: #fff; color: var(--ink-4); cursor: pointer; box-shadow: 0 1px 2px rgba(31,35,41,.08); transition: all var(--t-fast); }
.proto-root .jc-copy:hover { color: #0aa294; }
.proto-root .jc-copy.ok { color: var(--ok); background: var(--ok-soft); box-shadow: none; }
/* 客户属性徽章 */
.proto-root .jc-tagbig { display: inline-flex; align-items: center; gap: 6px; align-self: flex-start; padding: 7px 14px; border-radius: 999px; font-size: 13.5px; font-weight: 800; line-height: 1; color: var(--tone); background: var(--tone-soft); border: 1px solid var(--tone-line); }
.proto-root .jc-fcard-note { font-size: 10.5px; color: var(--ink-4); line-height: 1.55; }
.proto-root .jc-fcard-note b { color: var(--ink-2); font-weight: 700; }
.proto-root .jc-fcard-note b.bad { color: var(--tone); font-weight: 800; }
/* 成交迷你柱:把「台账 6 笔成交」从一句话变成看得见的节奏 */
.proto-root .jc-spark { display: flex; align-items: flex-end; gap: 4px; height: 26px; }
.proto-root .jc-spark > i { flex: 1 1 0; min-width: 4px; max-width: 15px; border-radius: 3px 3px 1px 1px; background: var(--tone); opacity: .3; transition: opacity var(--t-fast), height var(--t-med); }
.proto-root .jc-spark > i:last-child { opacity: 1; }
.proto-root .jc-spark:hover > i { opacity: .55; }
.proto-root .jc-spark:hover > i:last-child { opacity: 1; }
.proto-root .jc-spark.empty { align-items: stretch; }
.proto-root .jc-spark.empty > i { height: 100%; opacity: 1; background: repeating-linear-gradient(45deg, var(--tone-soft) 0 5px, transparent 5px 10px); border: 1px dashed var(--tone-line); border-radius: 5px; }
/* 有效 PN 计数 */
.proto-root .jc-count { display: flex; align-items: baseline; gap: 6px; }
.proto-root .jc-count > b { font-family: var(--mono); font-size: 32px; font-weight: 800; line-height: .95; color: var(--ink); letter-spacing: -.04em; }
.proto-root .jc-count > span { font-size: 11px; color: var(--ink-4); }
/* 分段刻度条:一格 = 一条 PN,缺必填的那格直接标红——比一根实心进度条信息量大 */
.proto-root .jc-segmeter { display: flex; gap: 3px; }
.proto-root .jc-segmeter > i { flex: 1; height: 7px; border-radius: 2px; background: var(--ok); transition: background var(--t-fast); }
.proto-root .jc-segmeter > i.bad { background: var(--danger); }
.proto-root .jc-lockhint { display: inline-flex; align-items: center; gap: 4px; font-size: 10px; color: var(--ink-4); }
/* ── 占位胶囊:必填=红虚线,选填=灰虚线;**都带文字**,不靠颜色单打 ── */
.proto-root .jc-ph { display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700; line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed; }
.proto-root .jc-ph.req { color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
.proto-root .jc-ph.opt { color: var(--ink-4); background: var(--soft); border-color: var(--line); }
/* ── 分段控件(新客户 / 老客户)── */
.proto-root .jc-seg { display: inline-flex; padding: 2px; gap: 2px; border: 1px solid var(--cardline); border-radius: 9px; background: var(--soft); align-self: flex-start; }
.proto-root .jc-seg button { border: 0; border-radius: 7px; padding: 6px 13px; font: inherit; font-size: 11.5px; font-weight: 700; color: var(--ink-3); background: transparent; cursor: pointer; transition: all var(--t-fast); }
.proto-root .jc-seg button:hover:not(.on) { color: var(--brand); }
.proto-root .jc-seg button.on { background: #fff; color: var(--ink); box-shadow: 0 1px 3px rgba(31,35,41,.12); }
/* ── 表:读态与编辑态共用骨架 ─────────────────────────────────────── */
.proto-root .jc-tbl-wrap { overflow-x: auto; border: 1px solid var(--line-soft); border-radius: 10px; }
.proto-root .jc-tbl-in { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; }
.proto-root .jc-tbl-in th { background: var(--soft); text-align: left; padding: 8px 9px; font-size: 10.5px; font-weight: 800; letter-spacing: .03em; color: var(--ink-3); white-space: nowrap; border-bottom: 1px solid var(--line-soft); }
.proto-root .jc-tbl-in th.req::after { content: "*"; margin-left: 1px; color: var(--danger); font-size: 10px; }
.proto-root .jc-tbl-in td { padding: 4px 5px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
.proto-root .jc-tbl-in tr:last-child td { border-bottom: 0; }
.proto-root .jc-tbl-in tbody tr:hover td { background: #f7f9fd; }
/* 缺必填的整行浅红打底 + 行首红标——不用找红胶囊,一眼扫到是哪几行 */
.proto-root .jc-tbl-in tr.miss td { background: #fffbfb; }
.proto-root .jc-tbl-in tbody tr.miss:hover td { background: #fff5f5; }
.proto-root .jc-tbl-in td.jc-rn { position: relative; width: 38px; padding: 4px 6px 4px 13px; white-space: nowrap; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); font-weight: 800; }
/* 行首红标只在表态用 ::after —— 卡片态的 ::after 被「第 N 条」占着,不能抢 */
@media (min-width: 961px) {
.proto-root .jc-tbl-in tr.miss td.jc-rn::after { content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger); }
}
.proto-root .jc-rndot { display: inline-block; width: 5px; height: 5px; margin-left: 4px; border-radius: 999px; background: var(--danger); vertical-align: 2px; }
/* 读态单元格 */
.proto-root .jc-vcell { display: block; padding: 6px 6px; font-size: 12px; color: var(--ink); line-height: 1.5; overflow-wrap: anywhere; }
.proto-root .jc-vcell.mono { font-family: var(--mono); font-size: 11px; color: var(--ink-2); }
.proto-root .jc-vcell.num { font-family: var(--mono); font-size: 11.5px; text-align: right; }
/* 编辑态单元格:安静的电子表格 */
.proto-root .jc-cell { width: 100%; min-width: 64px; border: 1px solid var(--line); border-radius: 6px; padding: 6px 7px; font: inherit; font-size: 12px; color: var(--ink); background: #fff; transition: border-color var(--t-fast), box-shadow var(--t-fast); }
.proto-root .jc-cell.mono { font-family: var(--mono); font-size: 11px; }
.proto-root .jc-cell.num { text-align: right; font-family: var(--mono); font-size: 11.5px; }
.proto-root .jc-cell::placeholder { color: var(--ink-4); }
.proto-root .jc-cell:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-wash); outline: none; }
.proto-root .jc-cell.req-empty { border-color: #f3b6b3; background: #fffafa; }
.proto-root .jc-cell.req-empty::placeholder { color: var(--danger); }
/* 备注这类长文本用文本域:能换行、随内容长高,不裁字 */
.proto-root textarea.jc-cell { display: block; resize: vertical; overflow: hidden; min-height: 31px; line-height: 1.55; }
/* 编辑态的整表提示带 */
.proto-root .jc-editbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); border: 1px solid var(--brand-soft); font-size: 11.5px; color: var(--brand-deep); }
.proto-root .jc-editbar svg { flex: none; }
.proto-root .jc-editbar .sp { flex: 1 1 auto; }
/* 剔除行说明 */
.proto-root .jc-drop { display: flex; align-items: flex-start; gap: 8px; margin-top: 10px; padding: 9px 12px; border-radius: 9px; border: 1px dashed var(--line); background: var(--soft); font-size: 11.5px; color: var(--ink-3); line-height: 1.6; }
.proto-root .jc-drop svg { flex: none; margin-top: 2px; color: var(--ink-4); }
.proto-root .jc-drop code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
.proto-root .jc-callout { display: flex; gap: 8px; align-items: flex-start; border-radius: 9px; padding: 11px 13px; font-size: 12.5px; line-height: 1.75; }
.proto-root .jc-callout p { margin: 0; }
.proto-root .jc-callout svg { flex: none; margin-top: 2px; }
.proto-root .jc-callout.t-mute { background: var(--soft); color: var(--ink-2) }
@media (prefers-reduced-motion: reduce) {
.proto-root .jc-pulse-dot { animation: none; }
.proto-root .jc-report, .proto-root .jc-rp-stamp { animation: none; }
}
/* ══════════════════════════════════════════════════════════════════════════
响应式:宁可另起一行,也不损失完整度
≥961px 六列全展开的表(列 min-width 之和 878px,容器有余量,绝不裁字、绝不横滚)
≤960px 表 → 卡片:一条 PN 一张卡、一行一字段(标签 / 值),无横滚、无省略
≤560px 字段行再拆成上下两行(标签在上、值在下)
══════════════════════════════════════════════════════════════════════════ */
@media (max-width: 1040px) { .proto-root .jc-four { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 560px) { .proto-root .jc-four { grid-template-columns: 1fr; } }
@media (max-width: 960px) {
.proto-root .jc-tbl-wrap { overflow: visible; border: 0; border-radius: 0; background: transparent; }
.proto-root .jc-tbl-in,
.proto-root .jc-tbl-in tbody,
.proto-root .jc-tbl-in tr,
.proto-root .jc-tbl-in td { display: block; width: 100%; }
.proto-root .jc-tbl-in thead { display: none; }
/* 一条 PN = 一张卡 */
.proto-root .jc-tbl-in tr { margin-bottom: 10px; padding: 4px 13px 9px; border: 1px solid var(--line-soft); border-radius: 11px; background: #fff; box-shadow: var(--card-shadow); }
.proto-root .jc-tbl-in tr:last-child { margin-bottom: 0; }
.proto-root .jc-tbl-in tbody tr:hover td { background: transparent; }
.proto-root .jc-tbl-in tr.miss { border-color: #f3b6b3; background: #fffbfb; }
/* 卡头:第 N 条 */
.proto-root .jc-tbl-in td.jc-rn { padding: 8px 0 8px; margin-bottom: 4px; border-bottom: 1px solid var(--line-soft); font-family: inherit; font-size: 12px; font-weight: 800; color: var(--ink-2); }
.proto-root .jc-tbl-in td.jc-rn::before { content: "第 "; }
.proto-root .jc-tbl-in td.jc-rn::after { content: " 条"; }
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); }
/* 字段行:左标签 + 右值,值超长自动折行 */
.proto-root .jc-tbl-in td:not(.jc-rn) { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 10px; align-items: center; padding: 6px 0; border-bottom: 1px dashed var(--line-soft); }
.proto-root .jc-tbl-in tr td:last-child { border-bottom: 0; padding-bottom: 2px; }
.proto-root .jc-tbl-in td:not(.jc-rn)::before {
content: attr(data-h); font-size: 10.5px; font-weight: 700; letter-spacing: .02em; color: var(--ink-4); line-height: 1.5;
}
.proto-root .jc-tbl-in td[data-req="1"]:not(.jc-rn)::before { content: attr(data-h) " *"; }
.proto-root .jc-tbl-in .jc-vcell { padding: 0; overflow-wrap: anywhere; }
.proto-root .jc-tbl-in .jc-vcell.num { text-align: left; }
.proto-root .jc-tbl-in .jc-cell.num { text-align: left; }
.proto-root .jc-tbl-in .jc-cell { min-width: 0; }
}
/* 窄屏:落款三件各占一行,动作按钮撑满一行 */
@media (max-width: 560px) {
.proto-root .jc-rp-acts { margin-left: 0; width: 100%; flex-wrap: wrap; }
.proto-root .jc-rp-acts .jc-btn { flex: 1 1 auto; justify-content: center; }
.proto-root .jc-hintline { flex: 1 1 100%; }
.proto-root .jc-tbl-in td:not(.jc-rn) { grid-template-columns: 1fr; gap: 3px; align-items: start; }
}
/* ══ 以下取自 A1-询价受理记录.html(归档件独有的件)══ */
/* ── 料号分类:分布条 + 图例 ───────────────────────────────────────── */
.proto-root .jc-dist { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 13px 15px; border: 1px solid var(--line-soft); border-radius: 12px; background: linear-gradient(180deg, #fbfcfe, #fff); }
.proto-root .jc-distbar { flex: 1 1 320px; min-width: 260px; display: flex; gap: 3px; }
.proto-root .jc-distbar > i { height: 30px; border-radius: 4px; display: grid; place-items: center; font-size: 10.5px; font-weight: 800; color: #fff; font-family: var(--mono); }
.proto-root .jc-distbar > i.a { background: var(--ok); }
.proto-root .jc-distbar > i.b { background: var(--brand); }
.proto-root .jc-distbar > i.c { background: var(--warn-accent); }
.proto-root .jc-distlg { display: flex; flex-wrap: wrap; gap: 8px 18px; }
.proto-root .jc-distlg > span { display: inline-flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--ink-3); }
.proto-root .jc-distlg em { width: 10px; height: 10px; border-radius: 3px; }
.proto-root .jc-distlg em.a { background: var(--ok); } .proto-root .jc-distlg em.b { background: var(--brand); } .proto-root .jc-distlg em.c { background: var(--warn-accent); }
.proto-root .jc-distlg b { font-family: var(--mono); font-weight: 800; color: var(--ink); }
/* 类别徽 A / B / C */
.proto-root .jc-tbadge { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 6px; font-family: var(--mono); font-size: 11px; font-weight: 800; line-height: 1; color: #fff; }
.proto-root .jc-tbadge.a { background: var(--ok); } .proto-root .jc-tbadge.b { background: var(--brand); } .proto-root .jc-tbadge.c { background: var(--warn-accent); }
/* ── 结论与去向 ─────────────────────────────────────────────────────────
G2 是**分支网关**:三条互斥条件按序求值,只命中一条、只落一个活动。
所以这里不是三张并列的去向卡,而是一张**判定表 + 一条结论**。 */
/* 判定表:一行一条规则,命中行上色留痕(DMN 口径) */
.proto-root .jc-dmn { border: 1px solid var(--line-soft); border-radius: 12px; overflow: hidden; }
.proto-root .jc-dmn-h, .proto-root .jc-dmn-r { display: grid; grid-template-columns: 40px minmax(0, 1fr) 170px 200px; align-items: center; gap: 12px; padding: 9px 14px; }
.proto-root .jc-dmn-h { background: var(--soft); border-bottom: 1px solid var(--line-soft); font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); }
.proto-root .jc-dmn-r { font-size: 12px; color: var(--ink-4); background: #fff; }
.proto-root .jc-dmn-r + .jc-dmn-r { border-top: 1px solid var(--line-soft); }
.proto-root .jc-dmn-i { font-family: var(--mono); font-size: 11px; font-weight: 800; }
.proto-root .jc-dmn-c { line-height: 1.6; overflow-wrap: anywhere; }
.proto-root .jc-dmn-t { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; }
.proto-root .jc-dmn-t code { font-family: var(--mono); font-size: 10.5px; font-weight: 800; color: var(--ink-4); background: var(--soft); border: 1px solid var(--line); border-radius: 5px; padding: 2px 6px; }
.proto-root .jc-dmn-v { font-size: 11px; line-height: 1.55; }
/* 命中行:上色 + 左缘棱 + 加粗 */
.proto-root .jc-dmn-r.hit { position: relative; background: var(--tone-soft); color: var(--ink-2); }
.proto-root .jc-dmn-r.hit::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--tone); }
.proto-root .jc-dmn-r.hit .jc-dmn-i { color: var(--tone); }
.proto-root .jc-dmn-r.hit .jc-dmn-c { color: var(--ink); }
.proto-root .jc-dmn-r.hit .jc-dmn-c b { color: var(--tone); font-weight: 800; }
.proto-root .jc-dmn-r.hit .jc-dmn-t { color: var(--ink); }
.proto-root .jc-dmn-r.hit .jc-dmn-t code { color: #fff; background: var(--tone); border-color: var(--tone); }
.proto-root .jc-dmn-v.y { display: inline-flex; align-items: center; gap: 5px; font-weight: 800; color: var(--tone); }
.proto-root .jc-dmn-v.n { color: var(--ink-4); }
/* 色调随命中类别 */
.proto-root .jc-dmn.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.10); }
.proto-root .jc-dmn.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.09); }
.proto-root .jc-dmn.k-c { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.11); }
/* 结论横幅:命中条件 → 唯一去向 */
.proto-root .jc-verdict {
position: relative; overflow: hidden; display: flex; align-items: stretch; flex-wrap: wrap; gap: 16px;
margin-top: 11px; padding: 16px 18px; border: 1px solid var(--tone-line); border-radius: 13px;
background: linear-gradient(105deg, var(--tone-soft), #fff 62%);
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 10px 24px -16px rgba(31,35,41,.4);
--tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.14); --tone-line: #ffd9a8;
}
.proto-root .jc-verdict.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.13); --tone-line: #bfedb4; }
.proto-root .jc-verdict.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft); }
.proto-root .jc-verdict::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 5px; background: var(--tone); }
.proto-root .jc-verdict > * { position: relative; z-index: 1; }
.proto-root .jc-vd-l { flex: 1 1 300px; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
.proto-root .jc-vd-k { font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
.proto-root .jc-vd-cond { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 15px; font-weight: 800; color: var(--ink); }
.proto-root .jc-vd-why { margin: 0; font-size: 11.5px; line-height: 1.7; color: var(--ink-3); overflow-wrap: anywhere; }
.proto-root .jc-vd-why code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
.proto-root .jc-vd-arrow { flex: none; align-self: center; color: var(--tone); }
.proto-root .jc-vd-r { flex: 1 1 260px; min-width: 0; display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-radius: 11px; background: #fff; border: 1px solid var(--tone-line); }
.proto-root .jc-vd-node { display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 800; color: var(--ink); }
.proto-root .jc-vd-node code { font-family: var(--mono); font-size: 12px; font-weight: 800; color: #fff; background: var(--tone); border-radius: 6px; padding: 3px 8px; }
.proto-root .jc-vd-who { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--ink-2); }
.proto-root .jc-vd-who > i { width: 20px; height: 20px; border-radius: 999px; display: inline-grid; place-items: center; font-style: normal; font-size: 10px; font-weight: 700; color: #fff; background: var(--tone); }
.proto-root .jc-vd-next { margin: 0; padding-top: 8px; border-top: 1px dashed var(--line-soft); font-size: 10.5px; line-height: 1.65; color: var(--ink-4); }
.proto-root .jc-vd-next b { color: var(--ink-2); font-weight: 700; }
/* 命中分支携带的条目 */
.proto-root .jc-pnlist { display: flex; flex-wrap: wrap; gap: 5px; margin: 0; padding: 0; list-style: none; }
.proto-root .jc-pnlist > li { display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 999px; background: #fff; border: 1px solid var(--tone-line); font-size: 11px; color: var(--ink-2); overflow-wrap: anywhere; }
.proto-root .jc-pnlist > li > i { flex: none; width: 5px; height: 5px; border-radius: 999px; background: var(--tone); }
.proto-root .jc-pnlist code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
/* ── 落地动作 ───────────────────────────────────────────────────────── */
.proto-root .jc-acts { display: flex; flex-direction: column; gap: 0; border: 1px solid var(--line-soft); border-radius: 11px; overflow: hidden; }
.proto-root .jc-act { display: flex; align-items: flex-start; gap: 9px; padding: 10px 13px; font-size: 12px; color: var(--ink-2); line-height: 1.6; background: #fff; }
.proto-root .jc-act + .jc-act { border-top: 1px solid var(--line-soft); }
.proto-root .jc-act > i { flex: none; width: 18px; height: 18px; margin-top: 1px; border-radius: 999px; display: inline-grid; place-items: center; color: #fff; background: var(--ok); }
.proto-root .jc-act b { font-weight: 700; color: var(--ink); }
.proto-root .jc-act span { color: var(--ink-4); }
.proto-root .jc-act code { font-family: var(--mono); font-size: 10.5px; color: var(--brand-deep); background: var(--brand-wash); border-radius: 4px; padding: 1px 5px; }
.proto-root .jc-done-tag { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; flex: none; padding: 6px 13px; border-radius: 999px; font-size: 11.5px; font-weight: 800; color: var(--ok); background: #fff; border: 1px solid #bfedb4; }
.proto-root .jc-vcell.dim { color: var(--ink-4); }
.proto-root .jc-tbl-in td.jc-ty { width: 44px; text-align: center; }
.proto-root .jc-tbl-in td.jc-ty { width: auto; text-align: left; }
`;
/**
* 图标精灵 —— 与样式表同一出处,两份目标件的 `<symbol>` 取并集。
* 沙箱里取不到图标字体,所以图标一律内联;`<use href="#i-xxx">` 引的就是这一坨。
*/
export const PRD_SPRITE = `<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs><symbol id="i-alert" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></symbol><symbol id="i-arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol><symbol id="i-boxes" viewBox="0 0 24 24"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></symbol><symbol id="i-building" viewBox="0 0 24 24"><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/><path d="M10 18h4"/></symbol><symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol><symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol><symbol id="i-file-check" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="m9 15 2 2 4-4"/></symbol><symbol id="i-file-text" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></symbol><symbol id="i-git-branch" viewBox="0 0 24 24"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></symbol><symbol id="i-history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></symbol><symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></symbol><symbol id="i-lock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol><symbol id="i-mail-open" viewBox="0 0 24 24"><path d="M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0z"/><path d="m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10"/></symbol><symbol id="i-pen" viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z"/></symbol><symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol><symbol id="i-sheet" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M8 13h2"/><path d="M14 13h2"/><path d="M8 17h2"/><path d="M14 17h2"/></symbol><symbol id="i-shield-check" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></symbol><symbol id="i-user" viewBox="0 0 24 24"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/></symbol><symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></symbol></defs></svg>`;
+755 -140
View File
@@ -1,154 +1,769 @@
/** /**
* A7 情报归档的 HTML 报告模版 —— **活动自己持有的资产**(§10.3),随包版本冻结。 * A7 情报归档的 HTML 报告模版 —— **活动自己持有的资产**,随包版本冻结。
* *
* 归档最容易被当成走过场:单子已经丢了,再填一遍原因像是给失败写检讨。 * 两个局面各一张(与 A2/A3/A4/A6 同款口径):
* 所以这两屏从头到尾只讲一件事 —— **这一单留下了什么,下一单谁会用它**。 * · `archive-worksheet` **情报归档工作单** —— 归一与逐条归因期间摆它:
* 三张共享域表各自对着下一单的一个判据:历史价格情报喂三分类 P-03 的「距上次报价」、 * 逐颗料一张情报档案卡(既成事实在左、归因在右)、归因六档与败价幅度 A-01 两把尺、
* 客户资料库喂利润模型 P-01 的客户等级因子、落地率统计喂驾驶舱。人在这一屏上要判断的 * 填回工作表就长在同一屏;
* 不是「填没填」,是**填的这几个字下一单的机器读不读得懂** —— 写成散文只能被人读, * · `archive-roster` **情报归档清单** —— 入库口径拍板、放行与归档摆它:整份情报卡,
* 写成字段才进得了决策表。 * 口径分布、下一单谁用得上的落边图与落地动作在上面。
* *
* 两个局面: * 「1:1」的落点是**每份件与它的办结快照**(在办可编辑 ↔ 回执收下那一刻的 finalHtml),
* ① 结果登记 —— 拒绝原因与竞对价位在不在、算不算数; * 不是全程只许一张:作业的件与收口的件是两件工作两份件(规范姊妹篇 §1)。
* ② 闭环待放行 —— 三样沉淀齐不齐;签完这一笔整条流走到终点。
* *
* 这里**没有「案例进程」一节**:归档只有 S1 一个阶段,摆出来是一行凑数的进度条, * 版面语言沿用 A1 从 PRD 目标件机械抽取的那套(`prd-style.ts` 原样拷入)——
* 而版面上每多一节,真正要看的三张表就少一分注意力。 * A7 没有自己的 PRD 目标件,与 A2/A3/A4/A6 同一处置:沿用家族版面自造。
* *
* 容器是 `sandbox=""` iframe:无脚本、无外链、样式自带;数据由基座推送时填 * 取值三纪律:顶层只取汇总列与单值列;逐条走 `mine:quote_archive.intel`;
* (`data-slot` 取值、`data-rows` 逐行展开)。状态章一律用「在办」灯 —— * 列名逐字照 `declarations.ledger` 抄。**模版不算不判** —— 缺归因红带、归因徽、口径徽、
* 模版是随包冻结的静态字符串,盖不了一个还没发生的章;`verdict` 的橙档专指 * 分布条全部由基座算好的数经 `data-class-of` 投影,判断留在样式表。
* 「还没发生,等你按」,绿只留给已达标的机检结论。
*/ */
import { noteOnly, section, statsBand, tableOnly, verdict, viewShell } from '@sdk'; import { PRD_CSS, PRD_SPRITE } from './prd-style.ts';
/** /* ==================== 绑定期补的样式(照 A1/A3/A4/A6 的 BIND_CSS,地址换成本活动)==================== */
* 静态阶梯 —— **判据的写法与沉淀的去向不是数据**,它们随包冻结,不走 `data-rows`。
* 沿用外壳已有的 `.steps` 版式,与引擎推的阶段列表同一副面孔。 const BIND_CSS = `
*/ /* 空占位三级(2026-08-02 全局口径):必填红 / 建议填橘 / 选填灰 —— 值真的空才轮到 */
function ladder(rows: ReadonlyArray<readonly [string, string, string]>): string { .proto-root .jc-tbl-in td.req > .jc-vcell:empty::after,
return `<ul class="steps">${rows .proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after,
.map( .proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after {
([no, term, gloss]) => display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
`<li><span class="n">${no}</span><span><span class="t">${term}</span> <span class="d">${gloss}</span></span></li>`, line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
) }
.join('')}</ul>`; .proto-root .jc-tbl-in td.req > .jc-vcell:empty::after { content: "必填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after { content: "建议填"; color: var(--warn-accent); background: var(--warn-soft); border-color: #f0cf9a; }
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after { content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line); }
/* 归档件里空格子就是「没有」,不再摆「你还能填」的胶囊 */
.proto-root .jc-tbl-in td.dimempty > .jc-vcell:empty::after { content: "—"; color: var(--ink-4); border: 0; background: none; padding: 0; }
/* 缺归因的整行浅红 + 行首红条 —— 原型的 tr.miss,这里由 :has() 认 */
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td { background: #fffbfb; }
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty):hover td { background: #fff5f5; }
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn { color: var(--danger); font-weight: 800; position: relative; }
@media (min-width: 1001px) {
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn::after {
content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger);
}
} }
/** 局面一:S1 回写 —— 人此刻要判的是「这条原因,下一单读不读得懂」 */ /* 序号由 CSS 计数器排 */
export const ARCHIVE_LOGGED = viewShell({ .proto-root .jc-tbl-in tbody { counter-reset: rn; }
title: '结果登记', .proto-root .jc-tbl-in td.jc-rn::before { counter-increment: rn; content: counter(rn); }
kicker: 'A7 · 情报归档 · S1 回写', @media (max-width: 1000px) {
tone: 'archive', .proto-root .jc-tbl-in td.jc-rn::before { content: "第 " counter(rn) " 条"; }
icon: 'database', .proto-root .jc-tbl-in td.jc-rn::after { content: none; }
stamp: { kind: 'live', text: '回写中' }, }
sign: 'A7 情报归档 · 结果登记(共享域)',
/* 就地改:可改格子渲了只读与输入两份,默认锁着,表上加 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: body:
statsBand([ part('归档任务', '客户 <b data-slot="customer_name" data-blank></b> · 报价单 <b data-slot="quote_no" data-blank></b> · 共 <b data-slot="intel_total"></b> 条') +
['报价日期', 'quoted_at'], `<div class="jc-derive">${ico('info', 14)}
['竞对价位', 'competitor_price'], <span>这一屏是一份情报的作业台:系统已按上游的结果归好情报卡,报价、成交价与日期都在下面 ——
['客户等级', 'grade'], <b>既成事实一个数字都不改</b>。你要做的是<b>逐条落输赢归因</b>:客户到底为什么下单、
]) + 为什么不下单。这一列系统一格都不写:那件事只有跟单的人知道。
section('本单登记的结果', <b>这一单之后再没有人会回头看它</b> —— 归因写错没有下一道工序纠正,
tableOnly('mine:shared.history_price', [ 而它会一路流进下一单的判据里。</span>
['型号 PN', 'pn', 'mono'], </div>` +
['报价日期', 'quoted_at', 'mono'], FOUR +
['拒绝原因', 'lost_reason'], part('一单情报是怎么走完的', '既成事实 → 归因 → 败价幅度 → 口径 → 下一单') +
['竞对价位', 'competitor_price'], ARCHIVE_LINE +
]) + part('逐颗情报档案', '既成事实在左 · 归因在右') +
noteOnly( INTEL_CARDS +
'<b>「客户没回」不是原因。</b>问不出来就写「未获反馈」—— 不许编一个听着合理的理由:' + part('输赢归因六档', '选最主要的那一个 —— 尺挂在墙上') +
'编出来的原因会一路流进下一单的判据里,而那时已经没人记得它是编的。', CAUSE_RULES +
), part('败价幅度 A-01', '价差决定下一单怎么用这条情报') +
{ text: '只列本单写下的行' }, BAND_RULES +
) + part('填回工作表', '归因落在这 —— 补齐了执行单才提交得了') +
section('什么样的原因才算数', GAP_BAR +
ladder([ editGate('quote_archive.intel', '情报卡填回表') +
['1', '竞对报价多少、差几个百分点', '要数,不要形容词'], INTEL_TABLE +
['2', '输在价格、交期,还是资质 / 认证', '把败因归到一个维度上'], addRowBar('quote_archive.intel') +
['3', '客户是否明说了可接受区间', '下一单的起点就藏在这句话里'], `<div class="jc-drop">${ico('info', 13)}
]) + <span>带红标的<b>输赢归因</b>是必填,而且<b>只由你填</b> —— 缺一行执行单按不下提交,
noteOnly( 与放行门是同一个数同一套算法。六个取值一个字母不许差:
'<b>「价格高」不是原因,是结论。</b>判据要能被下一单<b>机器读取</b>:' + <b>PRICE</b> 价格 · <b>LEADTIME</b> 交期 · <b>CERT</b> 认证资质 · <b>SPEC</b> 规格不符 ·
'P-03 用「距上次报价 / 行情稳定」,P-01 用客户等级。' + <b>RELATION</b> 既有供应 · <b>OTHER</b> 说不清。
'写成一段散文,它就只能被人读;写成字段,它才进得了决策表。', 归因落 PRICE 的行务必把竞对报的那个价填进<b>竞对价</b> —— 那一格空着这张单同样提交不了,
), 败价幅度全看它。<b>依据那一栏尽量记客户原话</b>:「你们比 XX 贵两毛」比「价格偏高」
{ text: '写成字段' }, 值钱得多 —— 下一单定价时看的就是这一句。不该归档的料按行首 × 撤掉。</span>
) + </div>`,
section('这一单往哪儿沉 —— 三张共享域表', foot: sign('BD 归因 · AI 归一情报卡并按尺算败价幅度,输赢的缘由由你落', '统一报价 · A7 情报归档 · 在办'),
ladder([
['1', '历史价格情报 · shared.history_price', '喂给下一单的三分类 P-03'],
['2', '客户资料库 · shared.customer', '喂给下一单的利润模型 P-01'],
['3', '报价落地率统计 · shared.landing_stats', '喂给驾驶舱的落地率与 D+3 回复率'],
]),
{ text: '跨活动的沉淀事实' },
) +
section('这一单的底线',
verdict('不得空归档 —— 每一单不成交的报价也是情报资产', 'wait', '原因未回写,预检当场退回') +
noteOnly(
'归档做得好不好,<b>不在这一单看得出来</b> —— 在下一次报价能不能一眼看到「上次为什么丢」看得出来。',
),
),
}); });
/** 局面二:闭环待放行 —— 签完这一笔,整条工作流走到终点 */ /* ==================== 局面二 · 情报归档清单(拍板 / 放行 / 归档)==================== */
export const ARCHIVE_RELEASE = viewShell({
title: '情报归档 · 闭环待放行', /**
kicker: 'A7 · 情报归档 · 成果待签', * 落边图:情报卡清单 → 归档闭环 → 下一单的三个用处。
tone: 'case', *
icon: 'archive', * A7 是流程包的最后一站,**只有一口且无条件**(E14)—— 这条边不是选出来的,
stamp: { kind: 'live', text: '待签字封卷' }, * 所以画成实线亮是如实,不是替谁宣布。右边三个用处是这份情报的去处,
sign: 'A7 情报归档 · 闭环成果(待放行)', * 各挂本单条数(基座现数),没有的那一档条数为 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: body:
statsBand([ part('归档要素', '情报归档 · 能算的归判定表,要担责的归人') +
['统计周期', 'period'], FOUR +
['落地率', 'landing_rate'], part('入库口径分布', '全量 + 待拍板 + 脱敏 + 只留内部 = 总条数') +
['TypeA 命中', 'type_a_hit'], DIST +
['D+3 回复率', 'reply_rate_d3'], `<div class="jc-derive">${ico('info', 14)}
]) + <span>四段条数由基座逐行现数,不是谁报上来的。橙段(<b>待拍板</b>)是过渡态 ——
section('历史价格情报回写', 情报归好了而口径还没定,它<b>归零这一单才走得到放行门</b>。
tableOnly('mine:shared.history_price', [ 进了共享情报库的东西下一单谁都读得到,<b>这条边界过去了就收不回来</b> ——
['型号 PN', 'pn', 'mono'], 所以系统不替你定,只按 A-02 给一枚建议。</span>
['报价日期', 'quoted_at', 'mono'], </div>` +
['拒绝原因', 'lost_reason'], part('情报卡清单', '<b data-slot="intel_total"></b> 条 · 每条都有归因') +
['竞对价位', 'competitor_price'], GAP_BAR +
]), editGate('quote_archive.intel', '情报清单表') +
{ text: '下一单 P-03 取它' }, ARCHIVED_TABLE +
) + `<div class="jc-drop">${ico('info', 13)}
section('客户画像更新', <span>每一行摆的是这一条的<b>来路</b>:报了多少、成没成、输赢归在哪一档、
tableOnly('mine:shared.customer', [ 竞对报了什么价、败价幅度落在哪一级。签字前请重点看两处:
['客户码', 'customer_code', 'mono'], <b>归因列</b>里落了 OTHER 的条目(说不清的情报到下一单就是哑的),
['客户等级', 'grade'], 与<b>依据列</b> —— 没有客户原话的归因,下一单只能当传说看。
['画像标签', 'profile_tags'], 这一屏可改的是<b>输赢归因</b>与<b>依据</b>两列(你的职权面)。</span>
]), </div>` +
{ text: '下一单 P-01 取它' }, part('入库口径', '三档 · 建议归尺,结论归你') +
) + SCOPE_RULES +
section('报价落地率统计', part('落边 · 这一签之后谁用得上', '一口且无条件 · 归档闭环') +
tableOnly('mine:shared.landing_stats', [ ROUTE_FIG +
['月份', 'period', 'mono'], `<div class="concl">
['落地率', 'landing_rate'], ${ico('git-branch', 15)}
['TypeA 命中', 'type_a_hit'], <div class="cl-main"><b>归档结论</b> —— <span data-slot="summary" data-blank></span></div>
['D+3 回复率', 'reply_rate_d3'], <div class="cl-route"><em>客户行为画像</em><b data-slot="buyer_profile" data-blank></b><code data-slot="share_policy" data-blank></code></div>
]) + </div>` +
noteOnly( part('放行后即执行的落地动作', '门前 → 门后') +
'三张表都写在<b>共享域</b>:它们是跨活动的沉淀事实,不属于这一单 ——' + `<div class="jc-acts">
'这也是活动之间除了流程边以外,唯一一条合法的信息通路。', <div class="jc-act"><i>${ico('check', 11)}</i>
), <div><b>草稿层合并进正式版</b>&#12288;<span>本任务写下的情报卡与归档结论一并落账;
{ text: '驾驶舱取它' }, 你就地改过的归因与依据随之落定。</span></div>
) + </div>
section('放行前要过的三道判据', <div class="jc-act"><i>${ico('check', 11)}</i>
ladder([ <div><b>归档闭环那一口此刻才发射</b>&#12288;<span>情报卡
['1', '拒绝原因 / 竞对价位已回写', 'L1 机检 · reasonLogged = true'], <b data-slot="quote_archive_intel_rows"></b> 条随载荷交出去,这条工作流走到终点 ——
['2', '客户画像标签已更新', 'L2 机检 · profileUpdated = true'], 放行之前,世界上什么都没有发生。</span></div>
['3', 'BD 放行', 'L5 人签 · 签了才算数'], </div>
]), <div class="jc-act"><i>${ico('check', 11)}</i>
{ text: '两机检 · 一人签' }, <div><b>下一单开始受用</b>&#12288;<span>按你定的口径,这几条价位、败因与日期
) + 进入下一次询价的判据面 —— <b>归档做得好不好,不在这一单看得出来</b>,
section('封卷之后会发生什么', 在下一次报价能不能一眼看到「上次为什么丢」看得出来。</span></div>
verdict('放行即闭环:这条工作流就此收敛为「已完成」', 'wait', '终态封卷触发蒸馏钩子') + </div>
noteOnly( </div>`,
'蒸馏从任务记忆归纳出<b>活动记忆</b>的候选条目(带证据链),经回放与人放行才成为' + foot: sign('BD 归档放行 · AI 归一与算尺留痕 —— 要改哪一格,在这张情报清单上就地改', '统一报价 · A7 情报归档 → 归档闭环'),
'下一版业务模型的一部分 —— <b>记忆不载权力</b>。这一单丢了,但它把下一单的地形描出来了。',
),
),
}); });
@@ -11,7 +11,7 @@
*/ */
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { evaluateCondition } from '@sfeel'; import { evaluateCondition } from '@sfeel';
import { INTAKE_PHASES, INTAKE_STEPS } from '../src/case-model.ts'; import { INTAKE_PHASES, INTAKE_STEPS, NARRATIVE } from '../src/case-model.ts';
import { CMMN_XML } from '../src/case-model.ts'; import { CMMN_XML } from '../src/case-model.ts';
import { manifest } from '../src/manifest.ts'; import { manifest } from '../src/manifest.ts';
@@ -152,8 +152,23 @@ describe('doneWhen 是「成果已存在」的判据,不是「该不该做」的
const confirm = INTAKE_STEPS.find((s) => s.item === 'S3_confirm'); const confirm = INTAKE_STEPS.find((s) => s.item === 'S3_confirm');
expect(confirm, '受理需求交人审定这一步不见了').toBeDefined(); expect(confirm, '受理需求交人审定这一步不见了').toBeDefined();
expect(confirm?.view).toBe('intake-overview'); expect(confirm?.view).toBe('intake-overview');
// 卡上那张表绑台账整份铺开 —— 人改的是九行,不是缺的那一两行 /*
expect(confirm?.guide).toContain('"ledger":"quote_intake.record"'); * **执行卡零输入**(2026-08-02 用户定:填写全去工作区)——
* 卡上不绑表、不摆格子;清单在工作区那份件上就地改(editGate + 孪生件),
* 提交闸回落到必交付物的台账声明照样把必填看住。
* 从前这儿断言卡里带 `"ledger":"quote_intake.record"` 的 blocks —— 那一版
* 同一张表摆两处(卡上一份、件上一份),人不知道改哪一份算数。
*/
/*
* 骨架已搬进说明书(2026-08-03:模型常在「拆表+收步+派卡」同一拍发这张卡,
* 那一拍 S3 的剧本没摊开 —— 骨架只放剧本里就会发出裸卡,实测指导书整个是空的)。
* 剧本里留的是指路;零输入纪律与骨架本体都在说明书里,每一拍都看得见。
*/
expect(confirm?.guide).toContain('骨架在说明书里');
expect(NARRATIVE).toContain('受理需求审定卡的骨架');
expect(NARRATIVE).toContain('卡上不摆表、不摆格子');
expect(NARRATIVE).toContain('"itemId":"S3_confirm"');
expect(confirm?.guide).not.toContain('"blocks"');
expect(confirm?.guide).toContain('confirmed_total'); expect(confirm?.guide).toContain('confirmed_total');
}); });
+86 -54
View File
@@ -50,6 +50,7 @@ export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
(连跑十轮里三轮死在这)。自报的两个数照旧写,它们是给报告和人看的。 (连跑十轮里三轮死在这)。自报的两个数照旧写,它们是给报告和人看的。
--> -->
<caseFileItem id="quote_intake_record_missing" name="缺必填的行数(基座现数)"/> <caseFileItem id="quote_intake_record_missing" name="缺必填的行数(基座现数)"/>
<caseFileItem id="quote_intake_record_rows" name="受理记录行数(基座现数)"/>
<caseFileItem id="missing_key_total" name="缺必填字段条数(PN/年用量)"/> <caseFileItem id="missing_key_total" name="缺必填字段条数(PN/年用量)"/>
<caseFileItem id="missing_minor_total" name="缺选填字段条数(品牌/备注)"/> <caseFileItem id="missing_minor_total" name="缺选填字段条数(品牌/备注)"/>
<caseFileItem id="asked_total" name="已就缺口问过人的次数"/> <caseFileItem id="asked_total" name="已就缺口问过人的次数"/>
@@ -74,10 +75,17 @@ export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<!-- <!--
② 拆完了 —— 交人审定那一步的入场前提。只看「有没有拆出行来」: ② 拆完了 —— 交人审定那一步的入场前提。只看「有没有拆出行来」:
缺不缺字段不影响该不该给人看,缺口是**卡上要说清的事**,不是拦住这张卡的理由。 缺不缺字段不影响该不该给人看,缺口是**卡上要说清的事**,不是拦住这张卡的理由。
读**基座数的行数**,不读自报的 line_total(2026-08-02 用户实测送来的单,
「机检只读基座数」第四次被违反现形):模型把 record 三行在 S1 拍就写了،
却没记 line_total(那本是 S2 的账),S2 空手收步 —— 这道门于是永远不开,
引擎报「依赖断点」,模型被推去发补全卡绕路,两张多余的卡把 4 次人环
预算烧光,整单 exhausted。行落了没有,基座数得出来;门不该等一个
要模型自己记的数。
--> -->
<sentry id="s_parsed"> <sentry id="s_parsed">
<onPart id="op_ps" caseFileItemRef="line_total"><standardEvent>update</standardEvent></onPart> <onPart id="op_ps" caseFileItemRef="quote_intake_record_rows"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_ps"><condition>line_total &gt; 0</condition></ifPart> <ifPart id="if_ps"><condition>quote_intake_record_rows &gt; 0</condition></ifPart>
</sentry> </sentry>
<!-- <!--
③ **人审定过了** —— 后面查历史价/打标的入场前提。 ③ **人审定过了** —— 后面查历史价/打标的入场前提。
@@ -126,7 +134,7 @@ export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<!-- <!--
① 定位客户信息 —— 拿发件邮箱去客户主数据里认人。 ① 定位客户信息 —— 拿发件邮箱去客户主数据里认人。
认得出就取它的客户编码;认不出说明这是个**新客户**,发补全卡请人建档 认得出就取它的客户编码;认不出说明这是个**新客户**,发数据录入卡请人建档
(公司名、客户编码、联系人)—— 建档是人的事,活动不替他编一个客户出来。 (公司名、客户编码、联系人)—— 建档是人的事,活动不替他编一个客户出来。
--> -->
<humanTask id="S1_customer" name="定位客户信息:按发件邮箱认客户,认不出请人建档"> <humanTask id="S1_customer" name="定位客户信息:按发件邮箱认客户,认不出请人建档">
@@ -135,7 +143,7 @@ export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<!-- <!--
② 归一客户 RFQ 输入 —— 解析邮件正文,逐条落成受理记录。 ② 归一客户 RFQ 输入 —— 解析邮件正文,逐条落成受理记录。
PN / 年用量**必填**,其余**选填**;缺哪几格照实记进两个缺口数。 PN / 年用量**必填**;客户料号 / 品类 / 制造商 / 目标价是**建议追问**;其余**选填**。缺哪几格照实记进两个缺口数。
**这一步不叫人**:拆料是 AI 的活,拆完交给下一步给人看。 **这一步不叫人**:拆料是 AI 的活,拆完交给下一步给人看。
--> -->
<humanTask id="S2_normalize" name="归一客户RFQ输入:解析正文逐条落表"> <humanTask id="S2_normalize" name="归一客户RFQ输入:解析正文逐条落表">
@@ -226,6 +234,14 @@ S5 逐条定状况标 → S6 定去向与派单 → 提交放行。
「LM358DR 有近期成交价可以直接报;NE555P 半年多没动过要重新核;TL072CDR 停产了,得找替代。」 「LM358DR 有近期成交价可以直接报;NE555P 半年多没动过要重新核;TL072CDR 停产了,得找替代。」
· S6 定完去向 → view = intake-release。**说这单接下来往哪走、谁接手**。 · S6 定完去向 → view = intake-release。**说这单接下来往哪走、谁接手**。
**交代里说缺什么,用件上那三个词**(必填 / 建议 / 选填),而且**只说已发生的事**:
客户没给的就说「客户没给」并留空 —— 估价、垫数这类事你没有权限做,更不许在交代里
声称做过(见「三种分量」那段:目标价尤其如此)。历史价、三分类、去向是**审定之后**
那几步的事:这一拍它们连数都还没有,一个字都不要提。
派审定卡那一拍的交代照这个样子说(数字与料号换成本单的):
「三颗料拆好了,交期用量都齐;LM358DR、TL072CDR 客户给了目标价,NE555P 没给
(建议追问,不拦单)。整份受理需求请你审定,哪一格不对在工作区的件上就地改。」
别每拍一个腔调复读(「已完成X,现在进行Y」)。事情不同,话就该不同 —— 别每拍一个腔调复读(「已完成X,现在进行Y」)。事情不同,话就该不同 ——
调工具那一拍说「我去查这三颗的历史价」,拿到结果那一拍说查出了什么, 调工具那一拍说「我去查这三颗的历史价」,拿到结果那一拍说查出了什么,
两句话该是不一样的语气。有风险、要他拿主意、或者你打算叫人,**先说出来**, 两句话该是不一样的语气。有风险、要他拿主意、或者你打算叫人,**先说出来**,
@@ -240,6 +256,31 @@ S5 逐条定状况标 → S6 定去向与派单 → 提交放行。
发卡 / 提交会挂起(它们只能是串尾)。**这一单常路 5-7 拍就该收口** —— 发卡 / 提交会挂起(它们只能是串尾)。**这一单常路 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 口的载荷。`; §Z 结案:BD 在验收单上签字,系统把草稿合并成正式记录并发出 intake 口的载荷。`;
/** /**
@@ -261,7 +302,7 @@ S5 逐条定状况标 → S6 定去向与派单 → 提交放行。
export const INTAKE_STEPS = [ export const INTAKE_STEPS = [
{ {
item: 'S1_customer', item: 'S1_customer',
intent: '按发件邮箱认客户;认不出就发补全卡请人建档,建完写进客户主数据', intent: '按发件邮箱认客户;认不出就发数据录入卡请人建档,建完写进客户主数据',
writes: ['customer.profile', 'customer.contact', 'quote_intake.verdict'], writes: ['customer.profile', 'customer.contact', 'quote_intake.verdict'],
/* 认客户就靠这两张 —— 没声明的话它们会被整表段的截断刀砍掉(踩过) */ /* 认客户就靠这两张 —— 没声明的话它们会被整表段的截断刀砍掉(踩过) */
reads: ['customer.contact', 'customer.profile'], reads: ['customer.contact', 'customer.profile'],
@@ -320,7 +361,7 @@ export const INTAKE_STEPS = [
③ **记账两个数,引擎按它们收步**(别绕过去,绕不过去): ③ **记账两个数,引擎按它们收步**(别绕过去,绕不过去):
· customer_new_total —— 第①步认出来了记 0;认不出、要建档记 1; · customer_new_total —— 第①步认出来了记 0;认不出、要建档记 1;
· customer_asked_total —— 发过建档补全卡就记 1。 · customer_asked_total —— 发过建档卡就记 1。
**新客户没问过人,这一步收不了** —— 实测有过一次:模型从邮箱域名 **新客户没问过人,这一步收不了** —— 实测有过一次:模型从邮箱域名
编出 \`CUST-SZ-MACH\` 直接把档建了,人根本没被问过一句,而那个编码是错的。 编出 \`CUST-SZ-MACH\` 直接把档建了,人根本没被问过一句,而那个编码是错的。
客户编码是主数据的主键,编错一次后面每一单都跟着错。 客户编码是主数据的主键,编错一次后面每一单都跟着错。
@@ -406,14 +447,29 @@ export const INTAKE_STEPS = [
【一张卡别铺太长】缺口卡的 basis 与表格**只列真正缺的那几行**,不要把全部条目铺进去: 【一张卡别铺太长】缺口卡的 basis 与表格**只列真正缺的那几行**,不要把全部条目铺进去:
整份回复有长度上限,铺太长会在半途被截断 —— 截断的 JSON 解析不了, 整份回复有长度上限,铺太长会在半途被截断 —— 截断的 JSON 解析不了,
这一拍作废、还会弹一张「需要你判断下一步」把人叫来(实测)。缺几条就写几条。 这一拍作废、还会弹一张「需要你判断下一步」把人叫来(实测)。缺几条就写几条。
【两种分量 —— **必填只有两项,别的一个都不是**】 【三种分量 —— 与件上的空占位、执行卡的指导书**逐字同一套话**】
台账声明里怎么标,件上就是什么颜色,你说话就得用哪个词。三档只有这三个词:
· **必填(只有这两项)**:\`pn\`、\`eau\` 年用量 —— 缺了这一单没法报价, · **必填(只有这两项)**:\`pn\`、\`eau\` 年用量 —— 缺了这一单没法报价,
**这是验收标准**,不齐不许放行,记进 missing_key_total。 **这是验收标准**,不齐不许放行(件上标红「必填」),记进 missing_key_total。
· **选填(除上面两项之外的一切)**:客户料号、品类、制造商、**目标价**、交期、品质要求、备注…… · **建议(值得追问,但不拦单)**:\`customer_pn\` 客户料号 · \`category\` 品类 ·
缺了照样往下走,只当提醒,记进 missing_minor_total。 \`brand\` 制造商 · \`target_price\` 目标价 —— 声明里标了 \`expect\`,件上标橘「建议」。
**目标价尤其常缺** —— 客户不给目标价是常态,不是缺陷:实测栽过一次, 缺了照样往下走,但**那一问不能省**:执行卡上要写清「这几格建议追问」。
一颗料只缺目标价却被记成必填缺、还派了执行卡去问客户,而按 F-01 它根本不该拦单。 · **选填(其余一切)**:交期、品质要求、备注…… 件上标灰「选填」,缺了只当提醒。
拿不准某个字段算哪一档,就问自己一句:**它是不是 PN 或年用量?** 不是就是选填。 建议档与选填档一并记进 missing_minor_total。
**别把「建议」说成「选填」**(2026-08-03 用户当场指出):同一件事,工单上标着「建议追问」,
交代里若换成另一档的叫法,人会以为这两处说的不是一回事 —— 而它们就是一回事。
**目标价客户没给就留空,不许估**:它是**客户的意向价**,不是我们算得出来的数。
S4 查到的历史价是**判 A/B/C 的依据**(距上次报价几天、行情稳不稳),
**不是客户的目标价** —— 拿它填进 \`target_price\` 就是替客户表了态,
下游定价、议价全建在这个假数上。也不许只在**交代里声称**估过价、垫过数
(实测栽过一次:台账留空是对的,嘴上却说按历史价估了 —— 而那一步连历史价都还没查,
纯属编造,人读了就以为表里躺着一个估出来的数)。留空、在件上标橘「建议」、
在执行卡上写明建议追问,这三件事合起来才是对的处置。
**目标价常缺是常态,不是缺陷** —— 实测栽过一次:一颗料只缺目标价却被记成必填缺、
还派了执行卡去问客户,而按 F-01 它根本不该拦单。
拿不准某个字段算哪一档,就问自己一句:**它是不是 PN 或年用量?** 不是就不许拦单。
【怎么拆】逐条写进 \`quote_intake.record\`,一条需求一行(同一 PN 不同数量档算两行)。 【怎么拆】逐条写进 \`quote_intake.record\`,一条需求一行(同一 PN 不同数量档算两行)。
列名照台账字典写,别自己起名: 列名照台账字典写,别自己起名:
@@ -473,7 +529,7 @@ export const INTAKE_STEPS = [
* **不给 doneWhen** —— 这一步触达世界(把件交到人手上),给了就等于给引擎 * **不给 doneWhen** —— 这一步触达世界(把件交到人手上),给了就等于给引擎
* 一把替人签字的钥匙。而它的成果按定义也判不出来:「人看过、改过、认了」 * 一把替人签字的钥匙。而它的成果按定义也判不出来:「人看过、改过、认了」
* 不是台账里任何一个数能自己变出来的。人工激活也一并给上: * 不是台账里任何一个数能自己变出来的。人工激活也一并给上:
* 引擎不会替你按下这一步,你得先 start 再发卡。 * 引擎不会替你按下这一步,也不会替你收 —— 发卡就算启动,收要你自己 finish。
*/ */
guide: `拆好的九条摆在那儿了,**这一单现在要经过人的手**。 guide: `拆好的九条摆在那儿了,**这一单现在要经过人的手**。
@@ -485,39 +541,15 @@ export const INTAKE_STEPS = [
人一眼没看过受理需求,下一次被叫到已经是终局验收 —— 用户当场说 人一眼没看过受理需求,下一次被叫到已经是终局验收 —— 用户当场说
「我都没确认需求 它就让我验收了」。 「我都没确认需求 它就让我验收了」。
【怎么发】先 start 这一步(它是人工激活的),再照这个骨架发,别少字段: 【怎么发】**卡的骨架在说明书里(「受理需求审定卡的骨架」那一段),逐字段照抄** ——
{"kind":"human","itemId":"S3_confirm","workOrder":"execution","card":{ 不管你在哪一拍发它,骨架都在说明书里等着,不要凭记忆拼一张裸卡。
"title":"…","ask":"这是从来件与附件 BOM 归一出来的受理需求,请核对确认;哪一格不对就地改", 这一步人工激活:发卡就算启动,不必单发 start;引擎也不会替你收 —— 人交回后要你自己 finish。
"steps":["型号 PN 与年用量 EAU 必须有 —— 缺一条这一单就走不到放行;客户没给的去问,问不到也要在备注里写清「客户未提供」",
"目标价尽量试探出来 —— 有它才谈得上「报得进不进得去」;客户不肯给,问一句「贵司这颗现在的成交价区间」也常有答案",
"抽错的就地改,多出来的行按行首 × 撤掉,漏掉的用表尾「加一行」补上;核完回这张卡提交"],
· **卡面是纯文本**:\`steps\` 与 \`standard\` 里**别写星号那类记号** —— 它们不会变成粗体,
只会原样摆在人眼前(实测过一次)。要强调就靠句子本身的次序与用词。
"standard":"必填(PN / 年用量)一条不缺;选填该问的都问过了 —— 问到的填上,问不到的留空并在备注里写明,放行时由你决定先受理还是退回。",
"basis":[{"k":"DAG2400L321504XZB01-R0","v":"缺年用量(必填,须补)"},
{"k":"UMW AO6604","v":"缺制造商(选填,可先受理)"}],
"blocks":[{"t":"table","title":"有效 PN 清单(可就地改)",
"ledger":"quote_intake.record","editable":true,
"cols":["PN","客户料号","品类","年用量","制造商","目标价","备注"],
"fields":["pn","customer_pn","category","eau","brand","target_price","remark"]}]}}
**\`steps\` 与 \`standard\` 是作业指导书** —— 卡上照原样摆给人看:要核什么、核到什么程度算完。
照上面抄即可(这一步每一单都一样,不必每次重写)。少了它,人拿到的只有
「请核对确认」四个字,不知道该核什么。
· \`ask\` 照当下情形写:**有必填缺**就写「有几条缺 PN/年用量,须向客户问清后填回」;
**只有选填缺**写「有几条缺选填项,可先受理也可去问」;**都齐**写「请核对确认」。
· **别自己抄行**:绑了 \`ledger\` + \`fields\`,基座发卡那一刻就把这一单**整份清单**
从草稿层铺进去 —— 人看到的是完整的九行,哪一格不对都能就地改,不只是缺的那一两行。
你抄一遍的下场实测过两次:回复有长度上限,铺长了半途截断、整拍作废;
抄的时候还把 BOM 原样的 30 填了进去(台账里已经是 30000)。
· \`fields\` 与 \`cols\` **一一对应**,列名照台账字典写。
· \`basis\` 逐条写清**谁缺什么、是必填还是选填** —— 分量不同,人心里要有数;
一格不缺就写这一单的要点(几条料、几条本轮不报、客户点名的口径要求)。
发完卡把 **asked_total 加 1**(记账用:它证明这一问没省)。 发完卡把 **asked_total 加 1**(记账用:它证明这一问没省)。
【人提交之后 —— **表里的值基座已经替你写回了**】 【人提交之后 —— **件上的改动基座已经替你写回了**】
那张表绑着台账,人按下提交那一刻基座就把改动落进了草稿层(与数据录入卡同一条规矩: 人在件上按「保存」那一刻,改动就落进了草稿层(与数据录入卡同一条规矩:
该由人亲手落下的,不经你的手)。所以**别再 write 一遍那几行** —— 该由人亲手落下的,不经你的手);他按「提交」只是把这张卡交回来。
值没变的写入会被「写了等于没写」拒回(实测)。 所以**别再 write 一遍那几行** —— 值没变的写入会被「写了等于没写」拒回(实测)。
你这一拍只做三件事,然后 finish: 你这一拍只做三件事,然后 finish:
① **把 missing_key_total / missing_minor_total 重算** —— 照台账里现在的值, ① **把 missing_key_total / missing_minor_total 重算** —— 照台账里现在的值,
@@ -526,14 +558,14 @@ export const INTAKE_STEPS = [
③ answered_total 加 1(答案已经处理过了)。 ③ answered_total 加 1(答案已经处理过了)。
漏掉①的下场实测过:值明明补上了,汇总列还写着「缺 1 条」,提交被判据一次次拦回来, 漏掉①的下场实测过:值明明补上了,汇总列还写着「缺 1 条」,提交被判据一次次拦回来,
连拒五拍熔断 —— 而你只要把那个数改对就过去了。 连拒五拍熔断 —— 而你只要把那个数改对就过去了。
回填时**按卡上那一列的 PN 对行**:卡上的 PN 就是台账里那一行的主键, 真要补写某一行时**按台账里那一行的 PN 对行**:PN 是主键,
换一个写法就是**新开一行**(实测:补写时把 PN 补全成 \`…-R0\`,于是同一颗料在表里成了两行, 换一个写法就是**新开一行**(实测:补写时把 PN 补全成 \`…-R0\`,于是同一颗料在表里成了两行,
条目数说 9 而表里躺着 10)。真发现第一次的 PN 抄错了,在 say 里说明并交给人, 条目数说 9 而表里躺着 10)。真发现第一次的 PN 抄错了,在 say 里说明并交给人,
别自己用新 PN 再写一行。 别自己用新 PN 再写一行。
【**这张卡一单只发一次**】人交回来之后,不论还缺什么,**都不许再发同一张卡**。 【**这张卡一单只发一次**】人交回来之后,不论还缺什么,**都不许再发同一张卡**。
必填(PN / 年用量)有提交闸兜着:缺一条他根本按不下提交,所以卡交回来时必填一定是齐的; 必填(PN / 年用量)有提交闸兜着:缺一条他根本按不下提交,所以卡交回来时必填一定是齐的;
选填缺口不拦单,留着交放行的人定。 建议档的缺口不拦单,留着交放行的人定。
实测栽过多次(2026-08-02 那一单连撞四拍):从前这儿写着「人交回来还缺必填,就再发一张卡」—— 实测栽过多次(2026-08-02 那一单连撞四拍):从前这儿写着「人交回来还缺必填,就再发一张卡」——
而提交闸上线后那条路已经不存在了,模型照它办,每次都撞在 而提交闸上线后那条路已经不存在了,模型照它办,每次都撞在
「这件事刚问过,人也答过了」上,白烧四拍。 「这件事刚问过,人也答过了」上,白烧四拍。
@@ -542,7 +574,7 @@ export const INTAKE_STEPS = [
这时候要做的**不是再问一遍**(基座会当场拒:同一件事不许问第二遍),而是: 这时候要做的**不是再问一遍**(基座会当场拒:同一件事不许问第二遍),而是:
· confirmed_total 记 1、answered_total 记 1(人已经处理过了); · confirmed_total 记 1、answered_total 记 1(人已经处理过了);
· 缺口数**照实留着**(缺就是缺,别改小); · 缺口数**照实留着**(缺就是缺,别改小);
· 这一步就算办完了 —— 选填缺不拦单,放行时由人定「先受理还是退回」。 · 这一步就算办完了 —— 建议档的缺口不拦单,放行时由人定「先受理还是退回」。
实测栽过一次:人留空交回,模型又发一张一模一样的卡,被拒; 实测栽过一次:人留空交回,模型又发一张一模一样的卡,被拒;
转头把同一行原样再写一遍,又被「写了等于没写」拒;连拒五拍熔断,一单就这么停了。`, 转头把同一行原样再写一遍,又被「写了等于没写」拒;连拒五拍熔断,一单就这么停了。`,
}, },
@@ -664,19 +696,19 @@ A2 / A3 拿到之后会按自己的口径归一需要的数据。你替它们先
tools: [], tools: [],
// 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 doneWhen) // 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 doneWhen)
guide: `line_total > 0、missing_key_total = 0、untyped_total = 0、两枚旗已定 —— guide: `line_total > 0、missing_key_total = 0、untyped_total = 0、两枚旗已定 ——
**先 start T_release**(它标了人工激活:放行会触达世界,必须按一下)。 可以走放行了(T_release 人工激活:动它就算启动,不必单发 start)。
**start 之后、submit 之前,把清单里**每一条**裁量判据(L3/L4)都裁掉** **submit 之前,把清单里**每一条**裁量判据(L3/L4)都裁掉**。
(同一拍里排在 start 后面即可)。**一条都不能漏** —— 漏一条,submit 当场被拒, **一条都不能漏** —— 漏一条,submit 当场被拒,
而拒回来的理由里就写着漏的是哪一条:照它原样抄 criterion 再裁一次即可, 而拒回来的理由里就写着漏的是哪一条:照它原样抄 criterion 再裁一次即可,
**别原样重提同一个 submit**(实测连提七次,每次都被同一条拒回来)。 **别原样重提同一个 submit**(实测连提七次,每次都被同一条拒回来)。
眼下**只剩一条**,照名字逐字抄(2026-08-02 起「吃不准降 B」那条已从清单里删掉, 眼下**只剩一条**,照名字逐字抄(2026-08-02 起「吃不准降 B」那条已从清单里删掉,
别再裁它 —— 裁一个不存在的判据会被拒): 别再裁它 —— 裁一个不存在的判据会被拒):
· judge criterion="选填缺口(客户料号/品类/制造商/目标价)已补齐,或由你放行时确认可以先受理" —— · judge criterion="建议追问的四格(客户料号/品类/制造商/目标价)已补齐,或由你放行时确认可以先受理" ——
**只看 \`quote_intake_record_gaps\` 这一个数**(基座现数的:还有几行缺选填; **只看 \`quote_intake_record_gaps\` 这一个数**(基座现数的:还有几行缺**建议追问**的格子;
\`..._gaps_keys\` 写着缺在哪几行的哪一格)。就两种写法,没有第三种: \`..._gaps_keys\` 写着缺在哪几行的哪一格)。就两种写法,没有第三种:
· **= 0** → satisfied=true,rationale 写「选填已补齐」; · **= 0** → satisfied=true,rationale 写「建议追问的那几格都补齐了」;
· **> 0** → satisfied=**false**,rationale 照 \`..._gaps_keys\` 逐条写清缺在哪儿 · **> 0** → satisfied=**false**,rationale 照 \`..._gaps_keys\` 逐条写清缺在哪儿
(例:「DAG2400L321504XZB01-R0 缺目标价」),交放行的人定「先受理还是退回」。 (例:「DAG2400L321504XZB01-R0 缺目标价」),交放行的人定「先受理还是退回」。
+13 -6
View File
@@ -73,7 +73,7 @@ export const manifest: ActivityManifest = {
identity: { identity: {
activityId: ACTIVITY_ID, activityId: ACTIVITY_ID,
name: 'A1 · 询价受理', name: 'A1 · 询价受理',
packageVersion: '42.0.0', packageVersion: '49.5.0',
category: '统一报价', category: '统一报价',
sdkVersion: '0.1.0-m5', sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-quote-intake', checksum: 'dev-checksum-quote-intake',
@@ -192,15 +192,23 @@ export const manifest: ActivityManifest = {
* 等于把一个可回放的判定换成一次不可回放的手感。 * 等于把一个可回放的判定换成一次不可回放的手感。
*/ */
ports: [ ports: [
/*
* ── 这里**去掉了 `altRequests` / `rfqRequests` 两个载荷字段**(@43,2026-08-02)──
*
* `alt_request` / `rfq_request` 两张台账表同日已退休(见 declarations 那段墓志铭),
* 这两个键从那天起就是**空壳**:载荷装配对数组属性一律塞「本单全部落地行」
* (不加区分),于是它们的内容与 `records` 完全相同 —— 挂着一个名字叫
* 「替代评估请求」而里面装的是受理记录的键,比没有更糟:下游照名字用,用到的
* 是另一样东西。下游(A2/A3)自己按 `records` 里的状况标归一,契约照实说。
*/
{ {
name: 'to-alternative', name: 'to-alternative',
payload: { payload: {
type: 'object', type: 'object',
required: ['rfqRef', 'records', 'altRequests'], required: ['rfqRef', 'records'],
properties: { properties: {
rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' }, rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' },
records: { type: 'array', title: '询价受理记录(已打标)' }, records: { type: 'array', title: '询价受理记录(已打标)' },
altRequests: { type: 'array', title: '替代评估请求' },
}, },
}, },
description: '有 C 类要替代 —— 去选型', description: '有 C 类要替代 —— 去选型',
@@ -211,11 +219,10 @@ export const manifest: ActivityManifest = {
name: 'to-sourcing', name: 'to-sourcing',
payload: { payload: {
type: 'object', type: 'object',
required: ['rfqRef', 'records', 'rfqRequests'], required: ['rfqRef', 'records'],
properties: { properties: {
rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' }, rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' },
records: { type: 'array', title: '询价受理记录(已打标)' }, records: { type: 'array', title: '询价受理记录(已打标)' },
rfqRequests: { type: 'array', title: '询价请求' },
}, },
}, },
description: '无选型但有 B 类 —— 去寻源', description: '无选型但有 B 类 —— 去寻源',
@@ -365,7 +372,7 @@ export const manifest: ActivityManifest = {
*/ */
{ {
key: 'optional-asked', key: 'optional-asked',
label: '选填缺口(客户料号/品类/制造商/目标价)已补齐,或由你放行时确认可以先受理', label: '建议追问的四格(客户料号/品类/制造商/目标价)已补齐,或由你放行时确认可以先受理',
level: 'L4', level: 'L4',
predicate: 'quote_intake_record_gaps = 0', predicate: 'quote_intake_record_gaps = 0',
}, },
+21 -6
View File
@@ -55,13 +55,27 @@ import { PRD_CSS, PRD_SPRITE } from './prd-style.ts';
* 这里由基座把值消毒成 `v-*` 追加进 class(`data-class-of`)—— 判断在样式表里,不在填数里。 * 这里由基座把值消毒成 `v-*` 追加进 class(`data-class-of`)—— 判断在样式表里,不在填数里。
*/ */
const BIND_CSS = ` const BIND_CSS = `
/* ① 空格子自己说话 —— 值真的空才轮到这两条(基座 data-blank) */ /* ① 空格子自己说话 —— 值真的空才轮到这三条(基座 data-blank)。
* 占位三级(2026-08-03 补齐,用户:「目标价是建议」):必填红 / 建议橘 / 选填灰,
* 档位跟台账声明走(required → req,expect → sug,其余 opt),颜色与 A2 同一套。 */
/* 胶囊与同一行的数字坐同一条线(2026-08-03 用户:「没有和数字对齐」)——
* 裸 inline-flex 的胶囊按 baseline 挂:盒子(10px 字 + 上下 3px 内距 + 边框 = 18px)比
* 字的基线深好几像素,视觉上整枚往下坠。治法:空格子自己撑起文字那份盒高(12px×1.5),
* 胶囊在里面垂直居中 —— 中线对中线,行高也不再忽高忽低。
* :not(.on) 不能省(2026-08-03 用户:「编辑态太拥挤」):这条比「编辑态藏起只读份」
* 的那条更特异,不圈住的话编辑态里胶囊会盖过 display:none 冒出来,54 个输入框旁边
* 各站一枚 —— 编辑态里格子该不该填由闸与红带说,胶囊只属于看的那份。 */
.proto-root .jc-tbl-in:not(.on) td.req > .jc-vcell:empty,
.proto-root .jc-tbl-in:not(.on) td.sug > .jc-vcell:empty,
.proto-root .jc-tbl-in:not(.on) td.opt > .jc-vcell:empty { display: flex; align-items: center; min-height: 18px; }
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after, .proto-root .jc-tbl-in td.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 { .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; 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; 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.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.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); } .proto-root .jc-tbl-in td.dimempty > .jc-vcell:empty::after { content: "—"; color: var(--ink-4); }
@@ -388,7 +402,7 @@ const listTable = (mode: 'draft' | 'final'): string => {
* 待处理那一屏可就地改;写的是**台账列名**,不是表头文字 —— * 待处理那一屏可就地改;写的是**台账列名**,不是表头文字 ——
* 表头改个说法不该让写回错位。归档件不挂:那时它是件,不是表单。 * 表头改个说法不该让写回错位。归档件不挂:那时它是件,不是表单。
*/ */
const cell = (key: string, head: string, kind: 'req' | 'opt', style = ''): string => { const cell = (key: string, head: string, kind: 'req' | 'sug' | 'opt', style = ''): string => {
const cls = mode === 'final' ? 'dimempty' : kind; 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>`; return `<td class="${cls}" data-h="${head}"><span class="jc-vcell ${style}" data-slot="${key}" data-col="${key}" data-blank></span></td>`;
}; };
@@ -414,10 +428,10 @@ const listTable = (mode: 'draft' | 'final'): string => {
${del} ${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>' : ''} ${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('pn', 'PN', 'req', 'mono')}
${cell('category', '品类', 'opt')} ${cell('category', '品类', 'sug')}
${cell('eau', '年用量', 'req', 'mono')} ${cell('eau', '年用量', 'req', 'mono')}
${cell('brand', '制造商', 'opt')} ${cell('brand', '制造商', 'sug')}
${cell('target_price', '目标价', 'opt', 'num')} ${cell('target_price', '目标价', 'sug', 'num')}
${cell('remark', '备注', 'opt')} ${cell('remark', '备注', 'opt')}
</tr></tbody> </tr></tbody>
</table></div>`; </table></div>`;
@@ -537,7 +551,8 @@ export const INTAKE_OVERVIEW = doc({
addRowBar('quote_intake.record') + addRowBar('quote_intake.record') +
`<div class="jc-drop">${ico('info', 13)} `<div class="jc-drop">${ico('info', 13)}
<span>带 * 的两列是<b>必填</b>(PN / 年用量):缺任一按 F-01 退回补全,不许放行; <span>带 * 的两列是<b>必填</b>(PN / 年用量):缺任一按 F-01 退回补全,不许放行;
其余列缺了标灰「选填」—— 先受理、后追问,<b>不为它们拦单</b>,但那一问不能省。 品类 / 制造商 / 目标价缺了标橘「<b>建议</b>」—— 值得追问、尽量补上,备注缺了标灰「选填」。
三档都<b>不为它们拦单</b>,先受理、后追问,但那一问不能省。
<b>原文没写的一律不补</b>:受理阶段编出来的数会一路流到定价和报价单上,越往后越难发现。</span> <b>原文没写的一律不补</b>:受理阶段编出来的数会一路流到定价和报价单上,越往后越难发现。</span>
</div>`, </div>`,
foot: sign('AI 拟稿,你审定 —— 要改哪一格,在「待处理」那张单的需求表上就地改', '统一报价 · A1 受理归一'), foot: sign('AI 拟稿,你审定 —— 要改哪一格,在「待处理」那张单的需求表上就地改', '统一报价 · A1 受理归一'),
@@ -0,0 +1,616 @@
/**
* A6 报价议价的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。
* 案例活动没有 `run`,自测不跑回合;测的是清单本身与四处对齐
* (判据谓词 / 出口载荷与 `when` / 剧本声明 / 两把尺的档位)。
*/
import { describe, expect, it } from 'vitest';
import { activityManifestSchema } from '@contracts';
import { evaluateCondition, evaluateUnaryTest } from '@sfeel';
import { createMockHost } from '@sdk';
import { N01_DMN, manifest } from '../src/manifest.ts';
import activity from '../src/index.ts';
describe('清单本身合规', () => {
it('过 @contracts 的 schema', () => {
expect(() => activityManifestSchema.parse(manifest)).not.toThrow();
});
it('监听时限已配 —— 无悬空监听', () => {
expect(manifest.declarations.listeners.length).toBeGreaterThan(0);
expect(manifest.declarations.listeners.every((l) => /^P/.test(l.deadline))).toBe(true);
});
it('L1/L2 判据都给了可机检的谓词 —— 验收等级不得谎报', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef, c.key).toBeDefined();
}
});
it('禁区短而硬,授权边界给了调人预算', () => {
expect(manifest.contract.prohibitions.length).toBeGreaterThan(0);
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(0);
});
it('每件必交付物都有台账地址', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(d.ledger, d.key).toBeDefined();
}
});
/*
* **宽进严出**:起单一个必填都不设 —— 上游(A4 的 draft 口)发的是
* `{rfqRef, customerCode, items}`,而上一版必填这三个,单飞那条路直接被门口拦掉。
*/
it('起单宽进:input.required 是空的,两种来路的键都声明着', () => {
expect(manifest.contract.input.data['required']).toEqual([]);
const properties = manifest.contract.input.data['properties'] as Record<string, unknown>;
for (const key of ['rfqRef', 'customerCode', 'items', 'subject', 'body']) {
expect(properties[key], `入参少了 ${key}`).toBeDefined();
}
});
/*
* **客户表态是必填** —— 这是 A6 的门面判据:件上的缺表态红带、执行卡的提交闸、
* 放行门 L1 三处同源(都数基座的 `_missing`)。声明丢了 required,三处一起失明。
*/
it('客户表态标了 required —— 红带 / 提交闸 / 放行门三处同源的那个数才存在', () => {
const line = manifest.declarations.ledger.find((d) => d.object === 'line');
expect(line?.fields.find((f) => f.name === 'reply_type')?.required).toBe(true);
});
/*
* **客户还价用 `requiredWhen` 钉在还价那一行** —— 表级 required 会让接受 / 拒绝的行
* 永远缺一格,把执行卡的闸拦成死锁(A2 逐颗派单踩过同一坑)。
*/
it('客户还价是行级必填:只有还价那一行才算缺', () => {
const line = manifest.declarations.ledger.find((d) => d.object === 'line');
const counter = line?.fields.find((f) => f.name === 'counter_price');
expect(counter?.requiredWhen).toEqual({ column: 'reply_type', value: 'COUNTER' });
expect(counter?.required, '整表必填会把接受/拒绝的行也拦住').toBeUndefined();
});
/*
* **客户那一侧的三列 AI 一格都不写**(结构治,不是措辞治):
* 一条要模型分辨「这行能不能替客户填」的规矩会走岔;「这几列不写」没有分支可走岔。
*/
it('客户表态 / 还价 / 原话三列写死了「你一格都不写」', () => {
const guide = manifest.assets.businessModel.steps?.find((s) => s.item === 'S1_compose')?.guide ?? '';
expect(guide).toMatch(/reply_type[^\n]*counter_price[^\n]*reply_note|这三列你一格都不写/);
expect(guide).toContain('一格都不写');
});
it('表态、定案、让步处置三列都给了取值域 —— 条数由基座现数,不由活动自报', () => {
const line = manifest.declarations.ledger.find((d) => d.object === 'line');
expect(line?.fields.find((f) => f.name === 'reply_type')?.tally).toEqual(['ACCEPT', 'COUNTER', 'REJECT', 'NOREPLY']);
expect(line?.fields.find((f) => f.name === 'deal_status')?.tally).toEqual(['WON', 'HAGGLE', 'REVISE', 'LOST']);
expect(line?.fields.find((f) => f.name === 'concession')?.tally).toEqual(['ACCEPT_COUNTER', 'NEGOTIATE', 'ESCALATE']);
});
/*
* 我方写的注不标 `expect` —— 标了 `_gaps` 恒大于 0,读它的判据永远绿不了(A1 的账)。
* 客户还价那一格由 `requiredWhen` 管,不再重复标 expect。
*/
it('没有一列滥标 expect —— `_gaps` 不是拿来常亮的', () => {
const line = manifest.declarations.ledger.find((d) => d.object === 'line');
expect((line?.fields ?? []).filter((f) => f.expect === true)).toEqual([]);
});
it('三条常备案例形状真不同:流内一轮成交 / 客户压价 / 单飞超时', () => {
expect(manifest.samples.length).toBe(3);
const [flowIn, haggled, solo] = manifest.samples;
expect((flowIn?.input as { items?: unknown[] }).items?.length).toBeGreaterThan(0);
expect(flowIn?.expect?.ports).toEqual(['won']);
expect(haggled?.expect?.ports).toEqual(['revise']);
expect((solo?.input as { items?: unknown[] }).items).toBeUndefined();
expect(solo?.expect?.ports).toEqual(['lost']);
});
});
describe('案例模型 —— 双表示同版本', () => {
const model = manifest.assets.businessModel;
const cmmn = parseCmmnLite(model.cmmnXml);
it('CMMN 与说明书同在,不允许分叉', () => {
expect(model.cmmnXml).toContain('<casePlanModel');
expect(model.narrative.length).toBeGreaterThan(400);
});
/* 照 MVP 示例工作流 §1.4 的 S1 发报留档 / S2 跟进议价 / S3 定案交接,一步一项 */
it('计划项照 SOP 一步一项:排版 + 发出落回音 + 逐条定案 + 异议拍板 + 放行', () => {
expect(items(model.cmmnXml)).toEqual(['S1_compose', 'S2_send', 'S3_settle', 'T_haggle', 'T_release']);
});
it('三处人的门都标了人工激活:发出并等回音 · 异议拍板 · 结果放行(逐条定案是查表,不标)', () => {
expect(cmmn.manualActivation).toEqual(['S2_send', 'T_haggle', 'T_release']);
});
it('不用阶段 / 里程碑 / 裁量项', () => {
for (const tag of ['<stage', '<milestone', '<planningTable', '<discretionaryItem']) {
expect(model.cmmnXml, `不该出现 ${tag}`).not.toContain(tag);
}
});
/*
* **还价才叫人** —— 这个活动的要害:哨兵读基座按值现数的还价条数,
* 而不是活动自报的 `haggle_total`;且要等回音落齐(客户直接接受或拒绝的单
* 一个人都不必额外惊动)。
*/
it('异议拍板的哨兵读**客户自己填的那一格**,不读模型判出来的定案', () => {
const condition = sentryIf(model.cmmnXml, 's_haggle');
expect(condition).toContain('quote_negotiate_line_reply_type_counter > 0');
expect(condition).toContain('replied_total');
/*
* **「该不该叫人」不能由被叫的那一方说了算**(2026-08-03 两轮实测):
* 哨兵读 deal_status 时,模型在定案那一步直接写 REVISE、HAGGLE 一条不留,
* 这道门整个不开 —— 单子 released 而决策卡 0 张,让步梯度成了摆设。
*/
expect(condition, '哨兵不该读模型判出来的定案').not.toContain('deal_status');
// 客户还过价且回音落过 → 叫人
expect(safeEval(condition, { quote_negotiate_line_reply_type_counter: 1, replied_total: 2 })).toBe(true);
// 没人还价 → 不叫人(这一步整个不入场)
expect(safeEval(condition, { quote_negotiate_line_reply_type_counter: 0, replied_total: 2 })).toBe(false);
// 回音还没落 → 先别叫
expect(safeEval(condition, { quote_negotiate_line_reply_type_counter: 1 })).toBe(false);
// **模型自己把定案写成 REVISE 也绕不过去** —— 客户那一格还在那儿
expect(
safeEval(condition, {
quote_negotiate_line_reply_type_counter: 1,
quote_negotiate_line_deal_status_haggle: 0,
quote_negotiate_line_deal_status_revise: 1,
replied_total: 2,
}),
).toBe(true);
});
/*
* 入场哨兵也归「机检只读基座数」管(A1 第四次违反就藏在哨兵里)——
* 读活动自报的条数,模型不记那个账,门就永不开,而它一步也没做错。
*/
it('交人发出的入场哨兵读基座行数,不读自报的报价条数', () => {
const condition = sentryIf(model.cmmnXml, 's_composed');
expect(condition).toContain('quote_negotiate_line_rows');
expect(condition).not.toContain('quote_total');
expect(safeEval(condition, { line_total: 2, quote_negotiate_line_rows: 2, composed_total: 2 })).toBe(true);
// 行落了但排版那一轮还没走完(composed_total 没写过)→ 门不开
expect(safeEval(condition, { line_total: 2, quote_negotiate_line_rows: 2 })).toBe(false);
});
it('逐条定案的入场哨兵认「回音齐了」:人交回 + 基座数的缺项为 0', () => {
const condition = sentryIf(model.cmmnXml, 's_replied');
expect(condition).toContain('quote_negotiate_line_missing = 0');
expect(safeEval(condition, { replied_total: 2, quote_negotiate_line_rows: 2, quote_negotiate_line_missing: 0 })).toBe(true);
expect(safeEval(condition, { replied_total: 2, quote_negotiate_line_rows: 2, quote_negotiate_line_missing: 1 })).toBe(false);
});
it('放行门哨兵带 ifPart:每条有表态 + 定案守恒 + 待拍板清零 + 定盘记过,全读基座数', () => {
const condition = sentryIf(model.cmmnXml, 's_ready');
expect(condition).toContain('quote_negotiate_line_missing = 0');
expect(condition).toContain('quote_negotiate_line_deal_status_haggle = 0');
const done = {
route_decided_total: 1,
quote_negotiate_line_rows: 3,
quote_negotiate_line_missing: 0,
quote_negotiate_line_deal_status_won: 2,
quote_negotiate_line_deal_status_haggle: 0,
quote_negotiate_line_deal_status_revise: 0,
quote_negotiate_line_deal_status_lost: 1,
};
expect(safeEval(condition, done)).toBe(true);
// 还有一行缺客户表态 → 门不开
expect(safeEval(condition, { ...done, quote_negotiate_line_missing: 1 })).toBe(false);
// 定案没记齐(守恒不成立)→ 门不开
expect(safeEval(condition, { ...done, quote_negotiate_line_deal_status_lost: 0 })).toBe(false);
/*
* **还悬着的还价放不了行**(与 A4 的「拦截档必须清零」同一形状):
* HAGGLE 是过渡态,拍板那一下必须把它换成 WON / REVISE / LOST。
*/
expect(
safeEval(condition, {
...done,
quote_negotiate_line_deal_status_won: 1,
quote_negotiate_line_deal_status_haggle: 1,
quote_negotiate_line_deal_status_lost: 1,
}),
).toBe(false);
// 退回重批是结论,不是悬着 —— 照样放得了行
expect(
safeEval(condition, {
...done,
quote_negotiate_line_deal_status_won: 1,
quote_negotiate_line_deal_status_revise: 1,
quote_negotiate_line_deal_status_lost: 1,
}),
).toBe(true);
});
it('每个计划项都有剧本', () => {
const ids = items(model.cmmnXml);
expect((model.steps ?? []).map((s) => s.item)).toEqual(ids);
for (const step of model.steps ?? []) {
expect(step.guide.length, `${step.item} 的做法`).toBeGreaterThan(80);
expect(step.intent.length, `${step.item} 的意图`).toBeGreaterThan(6);
}
});
it('说明书不点名计划项', () => {
const named = [...model.narrative.matchAll(/\b(?:S\d+_[A-Za-z]+|T_[a-z]+)\b/g)].map((m) => m[0]);
const ids = new Set(items(model.cmmnXml));
for (const name of named) {
expect(ids.has(name), `说明书提到的 ${name} 不在案例模型里`).toBe(true);
}
});
it('说明书只讲整单的事:成品、怎么说话、怎么收口', () => {
for (const topic of ['开案', '要说话', '一拍连做', '结案']) {
expect(model.narrative, topic).toContain(topic);
}
});
/*
* **决策卡那三处写死的东西也进说明书**(2026-08-03 实测逼出来的):
* 当下可为为空那几拍(依赖断点)引擎只说「请调人」,而拍板那一步的 guide
* 根本没摊开 —— 模型手上只剩计划项的中文名与自己的想象:
* 一轮把中文名当 itemId(连拒两拍),一轮换了问法于是同一个岔路口发出去两张卡。
* **说明书每一拍都在提示词里**,写死在这儿才是每一拍都看得见。
*/
it('说明书写死了那一张决策卡的 itemId / ask / 三个 value —— 断点那几拍只剩它', () => {
expect(model.narrative).toContain('"itemId":"T_haggle"');
expect(model.narrative).toContain('按哪条路办');
for (const value of ['accept-counter', 'revise', 'hold']) {
expect(model.narrative, `说明书少了选项 ${value}`).toContain(value);
}
});
});
describe('声明与契约对得上', () => {
const columns = new Set(
manifest.declarations.ledger.flatMap((d) => [`${d.domain}.${d.object}`, ...d.fields.map((f) => f.name)]),
);
it('必交付物的台账地址都声明过', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(columns.has(d.ledger ?? ''), `${d.key} → ${d.ledger}`).toBe(true);
}
});
it('editableAtRelease 的列都在成果表的声明里', () => {
const line = manifest.declarations.ledger.find((d) => d.object === 'line');
const names = new Set((line?.fields ?? []).map((f) => f.name));
for (const d of manifest.contract.requiredDeliverables) {
for (const col of d.editableAtRelease ?? []) {
expect(names.has(col), `editableAtRelease 里的 ${col}`).toBe(true);
}
}
});
const rowReadings = new Set([
...manifest.declarations.ledger.map((d) => `${d.domain}_${d.object}_rows`),
...manifest.declarations.ledger
.filter((d) => d.fields.some((f) => f.required === true || f.requiredWhen !== undefined))
.map((d) => `${d.domain}_${d.object}_missing`),
...manifest.declarations.ledger.flatMap((d) =>
d.fields.flatMap((f) =>
(f.tally ?? []).map((v) => `${d.domain}_${d.object}_${f.name}_${v.toLowerCase().replace(/[^a-z0-9_]/g, '')}`),
),
),
]);
it('L1/L2 谓词读的名字都有承载(台账列或基座读数)', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level !== 'L1' && c.level !== 'L2') continue;
for (const identifier of (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? []) {
if (['and', 'or', 'not', 'true', 'false', 'null', 'in', 'between'].includes(identifier)) continue;
if (rowReadings.has(identifier)) continue;
expect(columns.has(identifier), `${c.key} 的谓词引用了 ${identifier}`).toBe(true);
}
}
});
/*
* **机检必须至少有一边是基座数**(违反史已记五次,每次都是同一个死法:
* 那个数要模型自己重算,漏算/改小一次门就废了)。
*/
it('每条 L1/L2 都至少读一个基座读数 —— 不许两边都由活动自报', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level !== 'L1' && c.level !== 'L2') continue;
const names = (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? [];
expect(names.some((n) => rowReadings.has(n)), `${c.key} 一个基座读数都没读`).toBe(true);
}
});
/*
* **三口的去向是算出来的**:每一口都得写 `when`,少一口基座就退回「人选一口」——
* 而选择组恰选一口是这个活动的全部业务分支(E11/E12/E13 读的就是它)。
*/
it('三个出口对齐流程包 E11/E12/E13,且每一口都带 when', () => {
const group = manifest.contract.output.selective[0];
expect(group?.group).toBe('negotiation');
expect(group?.ports.map((p) => p.name)).toEqual(['revise', 'won', 'lost']);
for (const port of group?.ports ?? []) {
expect(port.when, `${port.name} 少了 when —— 少一口整组就退回人选`).toBeDefined();
expect(port.when).toContain('quote_negotiate_line_deal_status_');
expect(port.when, `${port.name} 的 when 不该读过渡态 HAGGLE`).not.toContain('_haggle');
}
});
it('去向按序求值:还价优先退回重批,其次成交转履约,都没成转归档', () => {
const ports = manifest.contract.output.selective[0]?.ports ?? [];
const pick = (scope: Record<string, unknown>): string | undefined =>
ports.find((p) => safeEval(p.when ?? '', scope))?.name;
expect(pick({ quote_negotiate_line_deal_status_revise: 1, quote_negotiate_line_deal_status_won: 1, quote_negotiate_line_deal_status_lost: 0 })).toBe('revise');
expect(pick({ quote_negotiate_line_deal_status_revise: 0, quote_negotiate_line_deal_status_won: 2, quote_negotiate_line_deal_status_lost: 1 })).toBe('won');
expect(pick({ quote_negotiate_line_deal_status_revise: 0, quote_negotiate_line_deal_status_won: 0, quote_negotiate_line_deal_status_lost: 2 })).toBe('lost');
});
/* 下游 A7 的入参要 rfqRef / customerCode / quoteNo —— 不成交那一口必须带齐 */
it('不成交那一口带齐 A7 要的三个键', () => {
const lost = manifest.contract.output.selective[0]?.ports.find((p) => p.name === 'lost');
const required = (lost?.payload as { required?: string[] }).required ?? [];
for (const key of ['rfqRef', 'customerCode', 'quoteNo']) expect(required, `lost 口少了 ${key}`).toContain(key);
});
it('出口载荷的标量字段都有台账列承载', () => {
const snake = (name: string) => name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
const ports = [
...manifest.contract.output.mandatory,
...manifest.contract.output.selective.flatMap((g) => g.ports),
];
for (const port of ports) {
const properties = (port.payload as { properties?: Record<string, { type?: string }> }).properties ?? {};
for (const [field, spec] of Object.entries(properties)) {
if (spec.type === 'array') continue;
expect(columns.has(snake(field)), `${port.name}.${field} → ${snake(field)}`).toBe(true);
}
}
});
it('技能引用的工具都在白名单声明内 —— 载方法不载权力', () => {
const declared = new Set(manifest.declarations.tools.map((t) => t.name));
for (const skill of manifest.assets.skills) {
for (const fragment of skill.crystallized) {
expect(declared.has(fragment.tool), `${skill.id} 用了 ${fragment.tool}`).toBe(true);
}
}
});
it('L3 判据的整句出现在放行剧本里 —— judge 才对得上名', () => {
const l3 = manifest.contract.acceptanceCriteria.find((c) => c.level === 'L3');
const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
expect(l3).toBeDefined();
expect(release?.guide, 'T_release 的 guide 要整句抄 L3 判据名').toContain(l3?.label ?? '');
});
});
/*
* 两把尺逐行求值 —— 能算的不问 LLM(规范 §5.6)。
* 出参就是台账 `deal_status` / `concession` 的取值:模型算完照抄,不必再翻译一道。
*/
describe('议价结果 N-01 —— 客户表态 → 定案', () => {
const table = N01_DMN.tables.find((t) => t.id === 'n01-outcome');
it('客户接受 → WON 成交', () => {
expect(decideBy(table, { reply_type: 'ACCEPT' })).toMatchObject({ outputs: ['WON'] });
});
it('客户还价 → HAGGLE 压在还价上,要人拍板', () => {
expect(decideBy(table, { reply_type: 'COUNTER' })).toMatchObject({ rule: 'n01-2', outputs: ['HAGGLE'] });
});
it('客户拒绝与超时无回复都 → LOST(两条路一个结果,缘由记在备注)', () => {
expect(decideBy(table, { reply_type: 'REJECT' })).toMatchObject({ outputs: ['LOST'] });
expect(decideBy(table, { reply_type: 'NOREPLY' })).toMatchObject({ outputs: ['LOST'] });
});
it('**认不出的表态按不成交处置** —— 兜底不许猜成接受', () => {
expect(decideBy(table, { reply_type: '客户说再看看' })).toMatchObject({ rule: 'n01-default', outputs: ['LOST'] });
});
/*
* 这把尺只出三个码:**REVISE 不在其中** —— 「退回重批」是人在决策卡上拍的,
* 判定表算不出来。所以是子集关系,不是等号;而多出来的那一个恰好只有一个,
* 就是拍板那条路(测这一条,免得哪天有人往 tally 里塞一个没人写得出的码)。
*/
it('尺出的定案码都在台账取值域里,只有「退回重批」由人拍板补上', () => {
const tally = manifest.declarations.ledger
.find((d) => d.object === 'line')
?.fields.find((f) => f.name === 'deal_status')?.tally ?? [];
const fromTable = new Set(table?.rules.map((r) => String(r.then[0])));
for (const code of fromTable) expect(tally, `尺出了台账没有的码 ${code}`).toContain(code);
expect([...tally].filter((v) => !fromTable.has(v))).toEqual(['REVISE']);
});
});
describe('让步梯度 N-02 —— 还价幅度 → 让不让、谁来让', () => {
const table = N01_DMN.tables.find((t) => t.id === 'n02-concession');
it('三个点以内 → 接了拿单', () => {
expect(decideBy(table, { gap: 0.02 })).toMatchObject({ outputs: ['ACCEPT_COUNTER', true] });
});
it('三到十个点 → 授权内回旋(FIRST 命中,第二行就是这一档)', () => {
expect(decideBy(table, { gap: 0.08 })).toMatchObject({ rule: 'n02-2', outputs: ['NEGOTIATE', true] });
});
it('超过一成 → 超出授权,退回成本定价重批', () => {
expect(decideBy(table, { gap: 0.204 })).toMatchObject({ outputs: ['ESCALATE', false] });
});
it('三个处置码与台账取值域逐字一致', () => {
const tally = manifest.declarations.ledger
.find((d) => d.object === 'line')
?.fields.find((f) => f.name === 'concession')?.tally;
expect(new Set(table?.rules.map((r) => String(r.then[0])))).toEqual(new Set(tally));
});
});
describe('两把尺的档位三处同一份 —— 判定表 / 剧本 / 工作单', () => {
const settle = manifest.assets.businessModel.steps?.find((s) => s.item === 'S3_settle')?.guide ?? '';
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-worksheet')?.html ?? '';
it('四个表态码剧本与工作单都写着(大写,一个字母不许差)', () => {
for (const code of ['ACCEPT', 'COUNTER', 'REJECT', 'NOREPLY']) {
expect(settle, `剧本少了 ${code}`).toContain(code);
expect(sheet, `工作单少了 ${code}`).toContain(code);
}
});
it('三个定案码与三个让步处置码剧本里写死', () => {
for (const code of ['WON', 'HAGGLE', 'REVISE', 'LOST', 'ACCEPT_COUNTER', 'NEGOTIATE', 'ESCALATE']) {
expect(settle, `剧本少了 ${code}`).toContain(code);
}
});
it('让步梯度的三档门槛剧本与工作单说的是同一组数', () => {
for (const threshold of ['0.03', '0.10']) expect(settle, `剧本少了 ${threshold}`).toContain(threshold);
for (const band of ['≤ 3%', '3% ~ 10%', '10%']) expect(sheet, `工作单少了 ${band}`).toContain(band);
});
});
describe('报告模版 —— 活动持有,容器无脚本执行权', () => {
it('两个局面各一张:议价工作单(干活)与成交定案清单(收口)', () => {
expect(manifest.assets.viewTemplates.map((t) => t.name)).toEqual(['negotiate-worksheet', 'negotiate-roster']);
});
it('剧本每一步的 view 都在模版名单里 —— 换不到画面屏上就不动', () => {
const names = new Set(manifest.assets.viewTemplates.map((t) => t.name));
for (const step of manifest.assets.businessModel.steps ?? []) {
expect(names.has(step.view ?? ''), `${step.item} 的 view=${step.view}`).toBe(true);
}
});
it('放行那一步摆的是验收目标件 —— 最后摆上去的那张就是归档件', () => {
const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
expect(release?.view).toBe('negotiate-roster');
});
it('模版里没有脚本、没有事件处理器、没有外链 —— 样式自带', () => {
for (const template of manifest.assets.viewTemplates) {
expect(template.html, template.name).not.toMatch(/<script|\son[a-z]+=|https?:\/\//i);
expect(template.html, template.name).toContain('<style>');
}
});
it('议价工作单是干活的件:逐颗档案 + 两把尺 + 填回工作表', () => {
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-worksheet')?.html ?? '';
expect(sheet).toContain('jc-selcard');
expect(sheet).toContain('议价结果 N-01');
expect(sheet).toContain('让步梯度 N-02');
expect(sheet).toContain('data-edit="quote_negotiate.line"');
});
it('缺表态红带与提交闸同源 —— 件上摆的是基座的 `_missing` 与主键名单', () => {
for (const name of ['negotiate-worksheet', 'negotiate-roster']) {
const html = manifest.assets.viewTemplates.find((t) => t.name === name)?.html ?? '';
expect(html, name).toContain('quote_negotiate_line_missing');
expect(html, name).toContain('quote_negotiate_line_missing_keys');
}
});
/*
* 编辑面精确等于人在那个局面的职权面 + **行的钥匙 pn**:
* 主键格不挂 `data-col`,服务端按「全表一致列」补键,两行会对到同一行上
* (A2 浏览器走查逮过,e2e 走 API 自带主键测不出来)。
*
* 两张件的编辑面**故意不同**:干活那张收客户的话,收口那张只让改成交价与原话
* (= `editableAtRelease`)—— 不同工作各有各的件。
*/
it('工作单的编辑面 = 客户表态 + 客户还价 + 客户原话 + 行的钥匙 pn', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-worksheet')?.html ?? '';
const cols = [...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]);
expect(new Set(cols)).toEqual(new Set(['pn', 'reply_type', 'counter_price', 'reply_note']));
});
it('收口件的编辑面 = editableAtRelease 那两列 + 行的钥匙 pn', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-roster')?.html ?? '';
const cols = new Set([...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]));
const editable = manifest.contract.requiredDeliverables[0]?.editableAtRelease ?? [];
expect(cols).toEqual(new Set(['pn', ...editable]));
});
it('定案分布读的是基座按值现数的三段 —— 守恒等式的可视形', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-roster')?.html ?? '';
for (const code of ['won', 'haggle', 'revise', 'lost']) {
expect(roster).toContain(`quote_negotiate_line_deal_status_${code}`);
}
});
/* 三口活动:收口件摆去向判定,由基座在放行那一刻求值后投影(件不算不判) */
it('收口件摆落边图,去向由 case:route* 投影', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-roster')?.html ?? '';
expect(roster).toContain('data-class-of="case:routeKey"');
expect(roster).toContain('case:routeTo');
expect(roster).toContain('case:routeWhy');
});
it('模版只做取值 —— data-class-of 不以 data-slot 为前提(纯色块曾整条静默失色)', () => {
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-worksheet')?.html ?? '';
expect(sheet).toContain('<i data-class-of="deal_status"></i>');
});
it('宿主拒推含脚本的模版', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
});
});
const CONTRACT = {
activityId: 'quote-negotiate',
version: '1.0.0',
data: { rfqRef: 'RFQ MD14.1', items: [] },
message: undefined,
outputPorts: { mandatory: [], selective: ['revise', 'won', 'lost'] },
prohibitions: [],
discretion: [],
} as const;
describe('代码入口 —— 案例活动没有 run,不是残包', () => {
it('默认导出是一个有名字的活动定义', () => {
expect(activity.name).toBe('quote-negotiate');
});
it('mock 宿主如实报「没有 run」,不静默返回一个假的 outcome', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.run(activity)).rejects.toThrow(/没有 run/);
});
});
/** 按 DMN 语义逐行求值:FIRST 命中即定,单元格谓词复用 @sfeel */
function decideBy(
table: (typeof N01_DMN)['tables'][number] | undefined,
scope: Record<string, unknown>,
): { outputs: readonly unknown[]; rule: string } | undefined {
if (table === undefined) return undefined;
for (const rule of table.rules) {
const hit = table.inputs.every((input, i) => evaluateUnaryTest(rule.when[i] ?? '-', scope[input.expression], scope));
if (hit) return { outputs: rule.then, rule: rule.id };
}
return undefined;
}
function items(xml: string): string[] {
return [...xml.matchAll(/<(?:humanTask|decisionTask|processTask|caseTask) id="([^"]+)"/g)].map((m) => m[1] ?? '');
}
function parseCmmnLite(xml: string): { manualActivation: string[]; repetition: string[] } {
const manualActivation: string[] = [];
const repetition: string[] = [];
for (const m of xml.matchAll(/<humanTask id="([^"]+)"[\s\S]*?<\/humanTask>/g)) {
if (m[0].includes('manualActivationRule')) manualActivation.push(m[1] ?? '');
if (m[0].includes('repetitionRule')) repetition.push(m[1] ?? '');
}
return { manualActivation, repetition };
}
function sentryIf(xml: string, sentryId: string): string {
const block = xml.slice(xml.indexOf(`<sentry id="${sentryId}"`));
const raw = /<condition>([\s\S]*?)<\/condition>/.exec(block.slice(0, block.indexOf('</sentry>')))?.[1] ?? '';
return raw.replaceAll('&gt;', '>').replaceAll('&lt;', '<').replaceAll('&amp;', '&');
}
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
@@ -0,0 +1,315 @@
/**
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(A1 的账)。
*/
import { describe, expect, it } from 'vitest';
import { evaluateCondition } from '@sfeel';
import { CMMN_XML, NEGOTIATE_PHASES, NEGOTIATE_STEPS } from '../src/case-model.ts';
import { manifest } from '../src/manifest.ts';
const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map(
(m) => m[1] ?? '',
);
describe('剧本与案例模型对得上 —— 对不上的条目等于没写', () => {
it('每一条剧本都指向一个真实存在的计划项', () => {
for (const step of NEGOTIATE_STEPS) {
expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
}
});
it('图上每一步都有剧本', () => {
for (const id of ITEM_IDS) {
expect(NEGOTIATE_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
}
});
it('清单里挂的就是这份剧本(不是另抄一份)', () => {
expect(manifest.assets.businessModel.steps).toBe(NEGOTIATE_STEPS);
});
});
describe('doneWhen 是「成果已存在」的判据,不是「该不该做」的判据', () => {
/** 开案那一刻的两种局面:流内转来 / 单飞 —— 台账一行没有 */
const FRESH_FLOW: Record<string, unknown> = {
rfqRef: 'RFQ MD14.1',
customerCode: 'CU-0142',
items: [{ pn: 'BBPY00201209601Y00', quoted_price: 0.085 }],
};
const FRESH_SOLO: Record<string, unknown> = { subject: 'MD14.2 报价函发客户', body: 'XCJ-7788T 8K 批准价 14.80……' };
it('**开局一步都不该被自动收掉** —— 两种开局都要 false', () => {
for (const fresh of [FRESH_FLOW, FRESH_SOLO]) {
for (const step of NEGOTIATE_STEPS) {
if (step.doneWhen === undefined) continue;
expect(safeEval(step.doneWhen, fresh), `${step.item} 的 doneWhen「${step.doneWhen}」开局就为真`).toBe(false);
}
}
});
/* ── 排版那一步 ─────────────────────────────────────────────── */
const composed = (over: Record<string, unknown>): Record<string, unknown> => ({
line_total: 2,
quote_negotiate_line_rows: 2,
composed_total: 2,
...over,
});
it('整份落表、行数对上、排版那一轮走完 → 收得掉', () => {
expect(safeEval(doneOf('S1_compose'), composed({}))).toBe(true);
});
it('**第一拍写完行就收步是不许的** —— composed_total 没写过,未绑定的名字比大小为假', () => {
const scope = composed({});
delete scope['composed_total'];
expect(safeEval(doneOf('S1_compose'), scope)).toBe(false);
});
it('行数两边对不上 → 收不掉(漏落一行当场现形,不等到第 10 拍)', () => {
expect(safeEval(doneOf('S1_compose'), composed({ quote_negotiate_line_rows: 1 }))).toBe(false);
});
/* ── 三处不给 doneWhen ──────────────────────────────────────── */
it('**发出等回音、异议拍板、放行三项都不给 doneWhen** —— 给了等于给引擎一把替人签字的钥匙', () => {
for (const item of ['S2_send', 'T_haggle', 'T_release']) {
expect(NEGOTIATE_STEPS.find((s) => s.item === item)?.doneWhen, `${item} 不该有 doneWhen`).toBeUndefined();
}
});
/* ── 逐条定案:判完了才算办完(收步判据与验收 L2 同源)────────────── */
const settled = (over: Record<string, unknown>): Record<string, unknown> => ({
quote_negotiate_line_rows: 3,
quote_negotiate_line_deal_status_won: 2,
quote_negotiate_line_deal_status_haggle: 0,
quote_negotiate_line_deal_status_revise: 0,
quote_negotiate_line_deal_status_lost: 1,
quote_negotiate_line_reply_type_counter: 0,
...over,
});
it('四档定案的条数加起来等于行数 → 逐条定案收得掉', () => {
expect(safeEval(doneOf('S3_settle'), settled({}))).toBe(true);
});
it('**有待拍板条目也照样收得掉** —— 判出来了就是判完了,还价的由下一步叫人拍板', () => {
expect(
safeEval(
doneOf('S3_settle'),
settled({
quote_negotiate_line_deal_status_lost: 0,
quote_negotiate_line_deal_status_haggle: 1,
quote_negotiate_line_reply_type_counter: 1,
}),
),
).toBe(true);
});
/*
* **模型自己写 REVISE 绕过拍板 → 这一步收不掉**(2026-08-03 实测逼出来的):
* 一轮里模型在定案那一步直接把还价的行判成「退回重批」,HAGGLE 一条没有,
* 「还价才叫人」那道门整个不开,单子一路 released 而没有人拍过板 —— 决策卡 0 张。
* 收步判据补上「待拍板条数 = 客户还价条数」,两边都是基座现数,模型改不动。
*/
it('客户还了价却没落成待拍板(自己写了 REVISE)→ 收不掉,拍板绕不过去', () => {
expect(
safeEval(
doneOf('S3_settle'),
settled({
quote_negotiate_line_deal_status_won: 1,
quote_negotiate_line_deal_status_haggle: 0,
quote_negotiate_line_deal_status_revise: 1,
quote_negotiate_line_deal_status_lost: 1,
quote_negotiate_line_reply_type_counter: 1,
}),
),
).toBe(false);
});
it('还有行没判 → 收不掉(松出来的那一截就是放行门前的返工)', () => {
expect(safeEval(doneOf('S3_settle'), settled({ quote_negotiate_line_deal_status_lost: 0 }))).toBe(false);
});
it('**一行都还没写时 0+0+0+0 = 0 不算判完** —— 全零相等那一坑,靠 rows > 0 挡住', () => {
expect(
safeEval(doneOf('S3_settle'), {
quote_negotiate_line_reply_type_counter: 0,
quote_negotiate_line_rows: 0,
quote_negotiate_line_deal_status_won: 0,
quote_negotiate_line_deal_status_haggle: 0,
quote_negotiate_line_deal_status_revise: 0,
quote_negotiate_line_deal_status_lost: 0,
}),
).toBe(false);
});
it('归一那一步声明了 reads: items —— 上游载荷置前不截断', () => {
expect(NEGOTIATE_STEPS.find((s) => s.item === 'S1_compose')?.reads ?? []).toContain('items');
});
/*
* **一单同一张卡只发一次**,且缺信息不再走补全卡(2026-08-02 全局退役)——
* 剧本里不许残留「还缺就再发一张卡」这条已经不存在的路。
*/
it('剧本不教补全卡,也不留「还缺就再发一张」的死路', () => {
for (const step of NEGOTIATE_STEPS) {
expect(step.guide, `${step.item} 的 guide 提到了补全卡`).not.toMatch(/补全卡|"kind":"ask"/);
expect(step.guide, `${step.item} 的 guide 教了再发一张卡`).not.toMatch(/再发一张卡|重新发一张卡/);
}
});
/*
* **一单只发一张卡**:人点完之后这一步会被重新念一遍,而 guide 的头一段说什么,
* 模型就先做什么 —— 头一段若是「发一张决策卡」,它就再发一张(A4 实测连发四张,
* 调人预算烧光 exhausted)。所以拍板那一步的 guide 必须**先分岔再发卡**。
*/
it('拍板那一步先问「人点过没有」,再谈发卡 —— 分岔要排在发卡骨架前面', () => {
const guide = NEGOTIATE_STEPS.find((s) => s.item === 'T_haggle')?.guide ?? '';
const branch = guide.indexOf('找到了');
const skeleton = guide.indexOf('【发一张决策卡');
expect(branch, 'T_haggle 的 guide 没有「人点过没有」这一岔').toBeGreaterThanOrEqual(0);
expect(skeleton, 'T_haggle 的 guide 没有发卡骨架').toBeGreaterThan(branch);
expect(guide).toMatch(/不许再发|别再发第二张卡/);
});
/*
* **拍板那三条路都得动 deal_status** —— 这是 2026-08-03 逼出来的结构账:
* 上一版 revise 那条写着「定案不动」,人点完了 haggle 还是那个数,
* 入场哨兵照旧成立,模型读同一段做法就再发一张卡(实测同一张连发六次)。
* 「办完了」得在数上看得见,措辞治不了。
*/
it('拍板三条路各把 deal_status 改成一个结论码 —— 没有一条留在 HAGGLE', () => {
const guide = NEGOTIATE_STEPS.find((s) => s.item === 'T_haggle')?.guide ?? '';
for (const code of ['WON', 'REVISE', 'LOST']) {
expect(guide, `拍板剧本少了 ${code} 这条路`).toContain(`deal_status 改 **${code}**`);
}
expect(guide, '拍板剧本还留着「定案不动」这条自我循环的路').not.toContain('定案不动');
expect(guide).toContain('quote_negotiate_line_deal_status_haggle');
});
/*
* start 墙已拆(2026-08-02):动它就算启动。剧本里再教 start 只是白烧 token,
* 而且学不稳的礼仪就是死法。
*/
it('剧本不教 start 礼仪 —— 那道墙已经拆了(说「不必单发 start」可以,教它先按一下不行)', () => {
for (const step of NEGOTIATE_STEPS) {
expect(step.guide, `${step.item} 的 guide 还在教 start`).not.toMatch(/先\s*start|务必\s*start|要先发\s*start/);
}
});
/*
* **等回音不落 `listen`**(见 case-model 头注):`listen` 在引擎里恒挂起,
* 要外部事件才唤得醒,而这一单里回音的落单人本来就是 BD。
* 剧本里若冒出一句「listen 等回音」,模型照做就是把整单挂到时限到期。
*/
it('定案那一步写死「这一拍到此为止」—— 收掉的步上再挂行动一律被拒', () => {
const guide = NEGOTIATE_STEPS.find((s) => s.item === 'S3_settle')?.guide ?? '';
expect(guide, 'S3_settle 没写「这一拍到此为止」').toContain('这一拍到此为止');
expect(guide, 'S3_settle 没说清决策卡属于下一步').toContain('T_haggle');
expect(guide, 'S3_settle 没写死「REVISE 这一步不写」').toMatch(/REVISE[^\n]*这一步一格都不写/);
});
/*
* 【目标缺口】那一段摆的是计划项的**中文名**;当下可为为空(依赖断点)那几拍,
* 模型手上只剩中文名,于是把它当 itemId 写了进去 —— 「计划项不存在:异议拍板」
* 连拒两拍(2026-08-03 实测)。发卡骨架旁边必须点名说清写英文 id。
*/
it('发卡骨架写死了 itemId 是英文项号,并点名中文名不是 id', () => {
const guide = NEGOTIATE_STEPS.find((s) => s.item === 'T_haggle')?.guide ?? '';
expect(guide).toContain('"itemId":"T_haggle"');
expect(guide, '没告诉模型中文名不能当 itemId').toMatch(/别写中文名/);
for (const value of ['accept-counter', 'revise', 'hold']) {
expect(guide, `发卡骨架少了选项 ${value}`).toContain(value);
}
});
it('剧本不教 listen 等回音 —— 那是一条挂到天荒地老的路', () => {
for (const step of NEGOTIATE_STEPS) {
expect(step.guide, `${step.item} 的 guide 教了 listen`).not.toMatch(/"kind":"listen"|立监听|发一个\s*listen/);
}
});
});
describe('剧本声明的东西活动真有', () => {
const declaredLedger = manifest.declarations.ledger.map((l) => `${l.domain}.${l.object}`);
const declaredTools = manifest.declarations.tools.map((t) => t.name);
it('每一步要写的台账都在清单的台账声明里', () => {
for (const step of NEGOTIATE_STEPS) {
for (const address of step.writes) {
expect(declaredLedger, `${step.item} 要写 ${address}`).toContain(address);
}
}
});
it('每一步要调的工具都在清单的工具声明里', () => {
for (const step of NEGOTIATE_STEPS) {
for (const tool of step.tools) {
expect(declaredTools, `${step.item} 要调 ${tool}`).toContain(tool);
}
}
});
it('每一步都定死了摆哪张件,且模版真在包里', () => {
const names = new Set(manifest.assets.viewTemplates.map((t) => t.name));
for (const step of NEGOTIATE_STEPS) {
expect(step.view, `${step.item} 没定画面`).toBeDefined();
expect(names.has(step.view ?? ''), `${step.item} 的 view「${step.view}」不在模版名单里`).toBe(true);
}
});
it('每一步都说得出它交出什么', () => {
for (const step of NEGOTIATE_STEPS) {
expect(step.intent.trim(), `${step.item} 没写 intent`).not.toBe('');
expect(step.guide.trim().length, `${step.item} 的 guide 太短`).toBeGreaterThan(20);
}
});
/* 卡面是纯文本:星号记号不会变粗体,只会原样摆在人眼前(规范 §5.5-10) */
it('发卡骨架里不写星号记号 —— 卡面是纯文本', () => {
for (const step of NEGOTIATE_STEPS) {
for (const line of step.guide.split('\n')) {
if (!/"steps"|"standard"|"ask"|"label"|"consequence"/.test(line)) continue;
expect(line.includes('**'), `${step.item} 的卡面文案带了星号:${line.slice(0, 60)}`).toBe(false);
}
}
});
});
describe('阶段刻度:每一项都有格可待', () => {
const inPhase = new Set(NEGOTIATE_PHASES.flatMap((p) => p.items));
it('**除放行外每一项恰好属于一个阶段**', () => {
const planned = NEGOTIATE_STEPS.map((s) => s.item).filter((id) => id !== 'T_release');
expect(planned.filter((id) => !inPhase.has(id))).toEqual([]);
});
it('一项不进两个阶段', () => {
const all = NEGOTIATE_PHASES.flatMap((p) => p.items);
expect(all.length).toBe(new Set(all).size);
});
it('**放行不在名单里** —— 收口那一格由基座统一加', () => {
expect(inPhase.has('T_release')).toBe(false);
});
it('阶段里不许出现案例模型没有的项', () => {
const ids = new Set(NEGOTIATE_STEPS.map((s) => s.item));
expect([...inPhase].filter((id) => !ids.has(id))).toEqual([]);
});
});
function doneOf(item: string): string {
const found = NEGOTIATE_STEPS.find((s) => s.item === item)?.doneWhen;
if (found === undefined) throw new Error(`${item} 没有 doneWhen`);
return found;
}
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
+395 -69
View File
@@ -1,115 +1,441 @@
/** /**
* A6 报价议价的案例模型 —— **发出去是触达世界的一下,等回音是立监听** * A6 报价议价的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
* (2026-07-31 照 A1 的状态机模式重做)。
* *
* 这个活动的要害不是步数,是**两处不可省的边界**: * 业务出处:`docs/MVP示例工作流.html` §1.4(S1 发报留档 → S2 跟进议价 → S3 定案交接),
* · 发正式报价单 = 触达外界 → 必须人工激活(铁律③ / AM 4.1); * 按 A4 同一条形状建模(2026-08-03 重写 @3.0.0):
* · 客户什么时候回是**外界的事** → 立监听等它,不在这一单里轮询、也不假装等到了。
* *
* 三项: * 上游(A5 批准的报价草案,A4 `draft` 口的载荷 `{rfqRef, customerCode, items}`)进来 →
* T_compose —— 把草案落成正式报价单(条款齐全),写进台账; * 归一成**正式报价函**底表(逐条条款齐备 + 按 P-04 注明有效期)→ **一张执行单**
* T_send —— 发出并等回音:人工激活 → 登记发信意向 + 立监听; * 交 BD:把报价函发给客户,回音回来后逐条落单(客户表态那一列 AI 一格都不写)→
* T_release —— 放行(人工激活),三个去向由输出选择组给出。 * 按议价结果 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"?> export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL"> <definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
<case id="negotiate"> <case id="negotiate">
<!--
案卷项的 id **就是任务视图里的键名**。上游载荷(items)与本活动台账列都摊平到
任务视图顶层,照它们命名;quote_negotiate_* 那几个是**基座读数**
(行数 / 缺必填 / 按值现数),由基座现算,模型碰不着。
-->
<caseFileModel> <caseFileModel>
<caseFileItem id="quote_no" name="报价单号(开案即有)"/> <caseFileItem id="items" name="上游报价草案条目(已批准,流内开案即有)"/>
<caseFileItem id="quote_total" 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> </caseFileModel>
<casePlanModel id="plan" name="报价议价" autoComplete="true"> <casePlanModel id="plan" name="报价议价" autoComplete="true">
<!--
① 排版完了 —— 交 BD 发出那一步的入场前提。
行数两边对上(自报 = 基座现数),且排版那一轮走完(composed_total 是排完之后
显式写下的数;「大于等于 0」对「写过的 0」为真、对「没写过」为假)。
-->
<sentry id="s_composed"> <sentry id="s_composed">
<onPart id="op_cp" caseFileItemRef="quote_total"><standardEvent>update</standardEvent></onPart> <onPart id="op_cp" caseFileItemRef="composed_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_cp"><condition>quote_total &gt; 0</condition></ifPart> <ifPart id="if_cp"><condition>line_total &gt; 0 and quote_negotiate_line_rows = line_total and composed_total &gt;= 0</condition></ifPart>
</sentry>
<!--
② 回音齐了 —— 逐条定案那一步的入场前提。
replied_total 只在人交回执行单之后才产生,它就是「经过人了」的凭据;
缺表态的行数由基座按 required 现数(与执行卡的提交闸、放行门 L1 同一个数)。
-->
<sentry id="s_replied">
<onPart id="op_rp" caseFileItemRef="replied_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_rp"><condition>replied_total &gt; 0 and quote_negotiate_line_rows &gt; 0 and quote_negotiate_line_missing = 0</condition></ifPart>
</sentry>
<!--
③ **还价才叫人** —— 这个活动的要害(与 A4「超限才叫人」同一形状)。
它读的是**客户自己那一格**(reply_type = COUNTER 的条数,基座按取值域现数),
不是模型判出来的 deal_status。两版都栽过,病根是同一个 ——
**「该不该叫人」不能由被叫的那一方说了算**:
· 一版读 deal_status = HAGGLE,而 revise 那条路让「定案不动」,人点完了那个数
还在,哨兵照旧成立,模型读同一段做法就再发一张卡(同一张连发六次);
· 修成「拍板必须清 HAGGLE」之后,模型改在定案那一步**直接写 REVISE** ——
HAGGLE 一条没有,这道门整个不开,单子一路 released 而没人拍过板
(实测:决策卡 0 张,让步梯度成了摆设)。
现在钉在客户填的那一格上:客户还了价,这一步就必须入场,模型绕不过去。
-->
<sentry id="s_haggle">
<onPart id="op_hg" caseFileItemRef="quote_negotiate_line_reply_type_counter"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_hg"><condition>quote_negotiate_line_reply_type_counter &gt; 0 and replied_total &gt; 0</condition></ifPart>
</sentry>
<!--
── 放行门的入场条件:**认「办完了没有」,且读基座数**────────────────
· 每条都有客户表态(quote_negotiate_line_missing 由基座按 required 现数);
· 定案逐条落定且守恒(四档相加 = 行数,两边都是基座按值现数);
· **没有还压在 HAGGLE 上的条目**(还价的要么接、要么退回重批、要么守价不让);
· 定案过了(route_decided_total 是定案那一步显式写下的记账数)。
-->
<sentry id="s_ready">
<onPart id="op_rd" caseFileItemRef="route_decided_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_rd"><condition>route_decided_total &gt; 0 and quote_negotiate_line_rows &gt; 0 and quote_negotiate_line_missing = 0 and quote_negotiate_line_deal_status_haggle = 0 and quote_negotiate_line_deal_status_won + quote_negotiate_line_deal_status_haggle + quote_negotiate_line_deal_status_revise + quote_negotiate_line_deal_status_lost = quote_negotiate_line_rows</condition></ifPart>
</sentry> </sentry>
<!-- 落正式报价单:条款齐全才叫一份能发出去的件 --> <!-- ① 归一报价函:逐条条款齐备 + 按 P-04 注明有效期(能算的不问人) -->
<humanTask id="T_compose" name="落正式报价单:条款齐全,逐条落台账"> <humanTask id="S1_compose" name="落正式报价函:逐条条款齐备,按报价有效期注明到期日">
<itemControl><repetitionRule id="r_cp"/></itemControl> <itemControl><repetitionRule id="r_s1"/></itemControl>
</humanTask> </humanTask>
<!-- 发出并等回音:**触达外界**,必须人工激活 --> <!--
<humanTask id="T_send" name="发出报价并等客户回音"> ② 发出去并把回音落回来 —— **一张执行单**(触达客户的那一下由人做)。
<entryCriterion id="ec_sd" sentryRef="s_composed"/> manualActivation + 不给 doneWhen(发卡触达真人,引擎不替人按)。
<itemControl><manualActivationRule id="m_sd"/><repetitionRule id="r_sd"/></itemControl> -->
<humanTask id="S2_send" name="发出报价函并落客户回音:工作单上逐条落表态">
<entryCriterion id="ec_s2" sentryRef="s_composed"/>
<itemControl><manualActivationRule id="m_s2"/></itemControl>
</humanTask> </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="议价结果验收放行"> <humanTask id="T_release" name="议价结果验收放行">
<entryCriterion id="ec_rel" sentryRef="s_composed"/> <entryCriterion id="ec_rel" sentryRef="s_ready"/>
<itemControl><manualActivationRule id="m_rl"/></itemControl> <itemControl><manualActivationRule id="m_rl"/></itemControl>
</humanTask> </humanTask>
</casePlanModel> </casePlanModel>
</case> </case>
</definitions>`; </definitions>`;
export const NARRATIVE = `§0 开案:拿到报价草案即开案。成品是**一份发出去的正式报价单**,外加一个明确的结果 /**
(成交 / 有异议 / 拒绝)—— 那三个去向由放行时的选择组定。 * 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本,两处不重复写同一件事)。
*/
export const NARRATIVE = `§0 开案:**一份已批准的报价要发出去,并谈出一个结果**。来路两种,形状都收(宽进):
· **流内转来**:任务数据里有 \`items\` 数组 —— A4 的报价草案条目(每条带拟报单价与门禁处置),
A5 已经批过;这是最全的一种来路;
· **单飞**:只有一段需求文字,说了要给谁发报价、几颗料什么价。
这一单有两处不能含糊: **成品只有一样**:一份**正式报价函** —— 整份条目都在、**每一条都有客户表态**,
· **发出去是触达世界的一下** —— 那一项标了人工激活,人不按你发不出去; 而且每一条都定了案:成交 / 退回重批 / 不成交。人签的不是「发出去了」,
· **客户什么时候回是外界的事** —— 立监听等它,别在这一单里轮询,更别假装等到了。 是**这一单谈成了什么**。它决定整条流往哪走。
(还价那一档 HAGGLE 是过渡态:**客户还了价、还没拍板**。放行前它必须清零 ——
拍板那一下把它换成成交、退回重批或不成交,三条路都是结论,都算办完。)
【T_compose 落正式报价单】把草案落成能发出去的件:单价 / 交期 / SPQ / MOQ / 贸易条款 / **能算的不问人,该客户说的不代说**:议价结果 N-01(表态 → 定案)、让步梯度 N-02
有效期,逐条写进 \`quote_negotiate.formal_quote\`,并把 quote_total(条数)写上。 (还价幅度 → 处置建议),两支判定表说了算,你不临场拍;而**客户表态那一列是人落的,
条款缺一样都不算一份能发的报价单 —— 缺了就问,别自己填一个"看起来合理"的。写完 finish。 你一格都不写** —— 客户回了什么只有收信的人知道,系统不替客户表态。
【T_send 发出并等回音】**先 start T_send**(人工激活:发出去触达客户)。 **四步走**:归一排版报价函 → 交 BD 发出并落回音 → 逐条定案 → 拍板定案放行。
然后用 \`mail.send\` 登记发信意向(写类工具:过程中只登记,放行那一刻才真发),
接着 listen 一个 data 事件等客户回音,**时限必配**(比如 P7D)——
不配时限就是个幽灵等待,这一单会永远挂着。
回音到了再看:成交 / 有异议 / 拒绝,分别对应放行时的三个出口。
【提交放行】**先 start T_release**(人工激活),提交前用 judge 裁掉裁量判据 **这一单要经过人一到两次**:BD 在议价工作单上逐条落客户表态(那张单绑着整份报价函,
(criterion 照判据名原样写),然后 submit。终局唯放行:你不能自己宣布完成。 表态缺一条按不下提交 —— 基座的闸看着,他填的格子直接落账);
**只有客户还了价的那几条才另外叫人拍板**(一张决策卡)。每条都叫人,人就成了橡皮图章;
一条都不叫,授权红线就是摆设。最后 BD 放行签字。
【一路上要说话】每一拍带一句 say,说**业务的话**:报了什么价、什么时候发的、客户回了什么。 **去向是算出来的,不是你选的**:有条目判了退回重批 → 退回定价重批;有成交 → 转订单履约;
一两句、口语。别报「写草稿 N 行」这种系统词。`; 全都没成 → 转情报归档。三口各带谓词,放行那一刻基座按序求值,你只要把定案落准。
【这一单只有一个岔路口,它长这样 —— 无论哪一拍都照这个发,别自己另编一张】
客户还了价要人拍板,那张决策卡**三处写死**:
· \`"itemId":"T_haggle"\` —— 英文计划项号。**别写中文名「异议拍板」**:中文名不是 id,
写了当场被拒(【目标缺口】那一段摆的就是中文名,别照它抄);
· \`"ask":"<N> 条客户还了价,按哪条路办?"\` —— 只把 N 换成条数,**别的字一个都不改**。
换了问法,系统就认不出这是同一张卡:人已经点过的那张会被你再发一遍;
· 三个 \`value\`:\`accept-encounter\` 不对,是 \`accept-counter\`(照客户还的价成交)、
\`revise\`(退回成本定价重批)、\`hold\`(守住现价不让)。label 与 consequence 可以按
本单的数改写,**value 不行**。
**REVISE(退回重批)这个码只有拍板那一步写得出来** —— 定案那一步遇到客户还价一律落
HAGGLE,替他写了 REVISE,那张卡就永远不会发,让步梯度成了摆设。
拍板之前**别急着发卡也别绕路** —— 定案那一步先把三档写完、finish;
下一拍引擎会把拍板那一步的做法摊开给你,那时再发。
【一路上要说话】负责人正看着这一单。**每一拍都带一句 say** —— 说业务的话:
报价函排了几条、有效期到哪天、客户回了什么、哪几条要拍板;不说系统词。
到了要找人的那一拍,先说清这一轮办完了什么、为什么现在需要他。
【怎么把一项干完 · 一拍连做】write 和 tool 都不会自动收步 —— 做完用 finish 宣告。
动作排进同一拍的 actions 串一口气给;只有发卡 / 提交(挂起)必须停拍。
**这一单常路 6-8 拍就该收口**。
§Z 结案:BD 在验收单上签字,系统把草稿合并成正式报价函并按定案发出对应那一口的载荷 ——
成交转订单履约,有异议退回成本定价,不成交转情报归档。`;
/** /**
* 步骤剧本 —— 与 A1 同一套。 * 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。
* `T_send` 与 `T_release` 都不给 `doneWhen`:一个触达客户、一个触达世界,都得人按一下。 *
* 三处不给 `doneWhen`:`S2_send`(发卡触达真人)、`T_haggle`(要人拍板)、
* `T_release`(签下去触达世界)—— 给了等于给引擎一把替人签字的钥匙。
*/ */
export const NEGOTIATE_STEPS = [ export const NEGOTIATE_STEPS = [
{ {
item: 'T_compose', item: 'S1_compose',
intent: '把草案落成条款齐全的正式报价单,并结算条数', intent: '整份归一成正式报价函:逐条抄齐条款、补 SPQ/MOQ/贸易条款、按 P-04 注明有效期',
writes: ['quote_negotiate.formal_quote', 'quote_negotiate.verdict'], writes: ['quote_negotiate.line', 'quote_negotiate.verdict'],
reads: [], /* 上游载荷数组不声明就会被整表段的截断刀砍掉(砍掉的恰是要归一的行) */
reads: ['items'],
view: 'negotiate-worksheet',
tools: [], tools: [],
doneWhen: 'quote_total > 0', /*
guide: `逐条写进 \`quote_negotiate.formal_quote\`:单价 / 交期 / SPQ / MOQ / 贸易条款 / 有效期, * 归一 + 排版走完才算办完:行数两边对上(一边是基座现数),
并把 quote_total 写上。条款缺一样都不算一份能发的报价单 —— * 且 composed_total 写过(S-FEEL 未绑定的名字参与比较为假,所以「没写过」进不来)。
缺了就问,**别自己填一个「看起来合理」的值**。 */
**同一拍再写一行 \`quote_negotiate.verdict\`**(验收机检读的就是它): doneWhen: 'line_total > 0 and quote_negotiate_line_rows = line_total and composed_total >= 0',
· quote_total = 报价行条数;missing_field_total = 六项条款还缺的条数(要 = 0); guide: `第一件事:把这一单的**正式报价函**落出来 —— 一拍连做,别拆成两拍。
· validity_days = 报价有效期天数(要 > 0);
· followup_total = 已建跟进条数(**要 ≥2** —— D+3 与 D+7 两条,没有跟进的报价等于没报); 【条目从哪儿来】
· summary 一句人话结论。写不对 submit 会被门挡回来。写完再 finish。 · 流内转来(\`items\` 里的行带 quoted_price / disposition):照抄 —— **报出去的价就是
**每一行都要带 \`rfq_ref\`** —— 它是这一单的单标识(起单入参里就有,原样抄进去)。 上游批准的那个价**,一个数字都别改;带 quote_line_total 这类总数的是结论行,不是料,
台账主键第一列就是它:不写,这一行会和**别的单**同料号的那一行撞主键,把人家覆盖掉 别落进报价函。
(实测栽过:A4 的报价草案主键只有 pn,跑四轮只剩最后一轮的 3 行)。`, · 单飞:照正文归一,列了几颗落几行;正文里说的批准价就是报出去的价。
**按 pn 归并,一颗料一行**;pn 一个字符不许改(它是主键,改一个字就是新开一行)。
【写 \`quote_negotiate.line\`,一颗料一行】(rows 收数组)
rfq_ref(入参有就原样抄;没有从主题认;认不出才自己造一个,**造出来的必须以 \`NEG-\` 开头**
(\`NEG-<主题或第一颗料的型号>\`)。两条硬规矩:
**① 整单只有一个单号,不是一颗料一个** —— 它是这一单的案号,报价函所有行、verdict 那一行
用的都得是同一个串(实测栽过:两颗料造出两个号,一单裂成两单);
**② 只从本单来件取材** —— 台账里躺着的 RFQ-xxxx 是别人的单,借它的号等于把两单的行
混进同一个案卷(实测栽过一轮))、pn、customer_pn、category、brand、eau、lead_time、
remark(客户约束/急料,一个字别丢)、type_tag(照上游现标;认不出按 B)、
**quoted_price**(上游批准的报价,原样抄)。
条款三列**照这个口径补齐**(它们是报价函的必备条款,不是可选项):
· spq —— 最小包装量:上游给了就抄;没给按 eau 的量级给一个常规值(千位以下 100,
千到万 1000,万以上 5000)并在 remark 里注明是按常规包装估的;
· moq —— 最小起订量:同上,没给就取 spq;
· trade_terms —— 贸易条款:统一写 \`EXW 深圳\`,客户来件另有约定的照客户的写。
· valid_until —— 有效期到期日:按**报价有效期 P-04:7 个日历天**从今天算,写成
\`YYYY-MM-DD\`。整单同一个日子,不是一行一个。
【**这三列你一格都不写**:reply_type / counter_price / reply_note】
客户表态、客户还的价、客户原话 —— 这三样只有收信的人知道,**系统不替客户说话**。
它们是 BD 在工作单上落的;deal_status / concession / final_price 三列要等表态回来
才判得了,这一步也别写。
这不是客气话:一条要你分辨「这行能不能替客户填」的规矩会走岔;
**「这几列不写」没有分支可走岔。**
【同一拍写 \`quote_negotiate.verdict\`(一行)】
rfq_ref、**quote_no**(报价单号:\`Q-\` 开头 + 本单案号的尾段,整单一个)、
customer_code / customer_name(顶层有就抄)、**line_total** = 报价函行数、
**valid_days** = 7、**composed_total** = 这一拍排版落表的条数(0 也要写)、
summary 一句人话(几条料、报给谁、有效期到哪天)。
写完 finish 这一项。`,
}, },
{ {
item: 'T_send', item: 'S2_send',
intent: '发出报价并立监听等客户回音', intent: '一张执行单交 BD:把报价函发给客户,回音回来后逐条落客户表态',
writes: [], writes: ['quote_negotiate.verdict'],
reads: [], reads: ['quote_negotiate.line'],
tools: ['mail.send'], view: 'negotiate-worksheet',
// 触达客户 —— 人工激活,不给 doneWhen tools: [],
guide: `**先 start T_send**(人工激活:发出去触达客户)。 /* 不给 doneWhen —— 发卡触达真人(manualActivation),引擎不替人按 */
用 \`mail.send\` 登记发信意向(写类工具:只登记,放行那一刻才真发), guide: `报价函排好了,**该 BD 出场了** —— 这是本活动唯一的一张执行单。
再 listen 一个 data 事件等回音,**时限必配**(如 P7D)—— 不配就是幽灵等待,这一单会永远挂着。`, 触达客户的那一下由人做:他把报价函发出去、按 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', item: 'T_release',
intent: '议价有结果,提交放行交 BD 验收', intent: '定案已落,提交放行交 BD 验收',
writes: [], writes: [],
reads: [], reads: [],
view: 'negotiate-roster',
tools: [], tools: [],
guide: `**先 start T_release**(人工激活),提交前用 judge 裁掉裁量判据,然后 submit。 /* 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 doneWhen) */
三个去向(成交 / 有异议 / 拒绝)由放行时的选择组定。 guide: `每条都有客户表态、定案守恒、待拍板清零、定案已记 —— 可以走放行了
**台账里要写 outcome**(won / revise / lost)—— 下游三条边读的就是它,不写整条流走不下去。`, (T_release 人工激活:动它就算启动,不必单发 start)。先把裁量判据裁掉,然后 submit,同一拍连做:
· judge criterion="议价过程说得清:报价函条款齐备、每一轮客户反馈与让价理由逐条可溯,BD 对这个结果负责" ——
看报价函里每一行的条款、客户原话与让价理由:说得出来路就 satisfied=true;
有行说不出的(比如还价成交却没记客户的话),satisfied=**false**,rationale 逐行写清,
交签字的人定。**裁成不满足照样能提交** —— 理由连着卡一起交给他。
criterion 照判据名**整句原样抄**,写别的名字对不上。
**去向不必你选**:三口各带谓词,放行那一刻基座按序求值 ——
有条目判了退回重批就走「退回调整」,有成交就走「转订单履约」,全都没成就走「转情报归档」。
你要做的只是把定案落准,别在提交里手选出口。
终局唯放行:你不能自己宣布完成。`,
}, },
]; ];
/**
* 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段;
* 放行那一格由基座统一加,人看到的是 x/5。
*/
export const NEGOTIATE_PHASES = [
{ name: '排版报价函', items: ['S1_compose'] },
{ name: '发出并等回音', items: ['S2_send'] },
{ name: '逐条定案', items: ['S3_settle'] },
{ name: '异议拍板', items: ['T_haggle'] },
];
+13 -44
View File
@@ -1,49 +1,18 @@
/** /**
* 统一报价 A6 · 报价议价 —— 活动包代码入口(ADR-004)。 * 统一报价 A6 · 报价议价 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
* *
* 「发出的同时必须建立 D+3/D+7 跟进 —— 没有跟进的报价等于没报。」 * **这个活动没有 `run`,不是缺件。**
* 这里把跟进监听立起来:AI 对世界只有触达(动作,落在放行)与等待(监听)两种姿势。 *
* 案例活动的推进由 Loop 驱动,行为全部写在清单里:案例模型 + 说明书 + 步骤剧本 +
* 两把判定表 + 两条技能 + 台账声明 + 两张 HTML 报告模版。
* 上一版这里有一段 `run()`(读报价单、拼 table 块、按条款齐不齐返回 await/submit,
* 末尾还立了一条 D+3 监听)—— 案例活动根本不会调它;摆一个空转的 run 去假装有实现,
* 更坏:它写的那套呈现与 `viewTemplates` 里的模版各说各话,而没人看得出以哪份为准;
* 那条 D+3 监听也与 `declarations.listeners` 里声明的三条重复,谁真谁假说不清。
*
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 —— 它是**被静态审扫描的产物文本**
* (查 import 与全局引用,证明活动包零环境能力)。
*/ */
import { defineActivity } from '@sdk'; import { defineActivity } from '@sdk';
import type { ActivityBase, ActivityOutcome } from '@sdk';
const QUOTE = 'quote_negotiate.formal_quote'; export default defineActivity({ name: 'quote-negotiate' });
export default defineActivity({
name: 'quote-negotiate',
async run(base: ActivityBase): Promise<ActivityOutcome> {
const lines = await base.ledger.read(QUOTE);
await base.interaction.pushView([
{
t: 'table',
title: '正式报价单',
cols: ['PN', '单价CNY', '交期', 'MOQ', '有效期'],
rows: lines.map((r) => [
String(r['pn'] ?? ''),
String(r['unit_price'] ?? '—'),
String(r['lead_time'] ?? '—'),
String(r['moq'] ?? '—'),
String(r['valid_until'] ?? '—'),
]),
},
]);
const incomplete = lines.filter((r) => r['unit_price'] === undefined || r['valid_until'] === undefined);
if (lines.length === 0 || incomplete.length > 0) {
await base.interaction.say('报价单条目尚未齐备,继续排版。', 'conclusion');
return { kind: 'await', reason: '等待报价单生成步完成' };
}
// 建 D+3 跟进:等客户回音 —— 时限必配,无死等
await base.tools.listen({
eventType: 'data',
keyFilter: { kind: 'customer-reply', customerCode: String(base.contract.data['customerCode'] ?? '') },
deadline: 'P3D',
});
await base.interaction.say('已建 D+3 跟进监听 —— 没有跟进的报价等于没报。', 'guidance');
return { kind: 'submit', deliverable: 'formal-quote', rationale: '报价单齐备且已建跟进,提交放行即发出' };
},
});
+453 -100
View File
@@ -1,168 +1,393 @@
/** /**
* 活动包清单 —— 统一报价 A6 · 报价议价。 * 活动包清单 —— 统一报价 A6 · 报价议价(案例活动)。
* *
* 业务素材:docs/MVP示例工作流.html §1.4(S1 生成发出 → S2 CRM 留档 → S3 建立跟进)。 * 业务素材:`docs/MVP示例工作流.html` §1.4(S1 发报留档 → S2 跟进议价 → S3 定案交接):
* 关键机制:生成正式报价单是**写类动作** → 登记为动作意向 → 落地预检 → * 「审批通过后 15 分钟内发出……没有跟进的报价等于没报」
* 放行通过即落地(台账回写 + 出站发送)→ 输出口发射;并建 D+3 跟进监听。 * 「客户不回不是结论,催到表态或有效期过」
* 「让价在授权范围内回旋,要破底价门禁红线的交回发起流程重批,不私自破线」。
*
* 形状与 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 type { ActivityManifest, DmnModel } from '@contracts';
import { NEGOTIATE_SKILLS } from './skills.ts';
import { CMMN_XML, NARRATIVE, NEGOTIATE_STEPS } from './case-model.ts';
import { activityIdSchema } 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'); 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 = { export const manifest: ActivityManifest = {
identity: { identity: {
activityId: ACTIVITY_ID, activityId: ACTIVITY_ID,
name: 'A6 · 报价议价', name: 'A6 · 报价议价',
packageVersion: '2.1.0', packageVersion: '3.5.0',
category: '统一报价', category: '统一报价',
sdkVersion: '0.1.0-m5', sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-quote-negotiate', checksum: 'dev-checksum-quote-negotiate',
}, },
/* ── 契约七件 ────────────────────────────────────────────────── */
contract: { contract: {
activityId: ACTIVITY_ID, activityId: ACTIVITY_ID,
version: '0.1.0', version: '1.0.0',
input: { input: {
/*
* **宽进到底:一个必填都不设**(与 A1/A2/A3/A4 同口径 —— 宽进严出,
* 输入契约在活动内部归一 + 补全)。
*
* 来路两种形状:流内转来(A4 `draft` 口的 `{rfqRef, customerCode, items}`)、
* 单飞(一段文字)。上一版必填 `rfqRef + customerCode + items`,门口拦的恰是主路。
*/
data: { data: {
type: 'object', type: 'object',
required: ['rfqRef', 'customerCode', 'items'], required: [],
properties: { rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' }, customerCode: { type: 'string' }, items: { type: 'array' } }, 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, messageAllowed: true,
messageHint: '客户的议价习惯、竞对价位、这一单的让价授权、催办节奏写这里',
}, },
output: { output: {
/*
* **没有必交付口**:这个活动的成品就是「谈出一个结果」,而结果本身是分支 ——
* 三口恰发一口,发出去的那一口就是这一单的 outcome(基座在放行时把口名
* 写进事实,下游 E11/E12/E13 读的就是它)。
*/
mandatory: [], mandatory: [],
// 议价结果是**业务分支**:必须建模为选择组的一口,随放行发射 —— 不得用异常终态编码 /*
* **三条出边 = 一个 XOR 选择组**(对齐流程包 E11/E12/E13)。
* 每一口都写了 `when`(读基座按值现数的 tally 读数),这一单往哪走是**算出来的**:
* 放行时基座按序求值首个命中即出,卡上只如实告诉人走哪一口、凭哪一条。
*
* 顺序即优先级:**判了退回重批 → 先退回重批**(谈不拢的那几条不能被
* 「另外几条成交了」盖过去);其次有成交 → 转履约;都没成 → 转归档。
*/
selective: [ selective: [
{ {
group: 'negotiation', group: 'negotiation',
ports: [ ports: [
/*
* 载荷里**必须带 `outcome`** —— 流程包 E10/E11/E12 三条出边读的就是它
* (`outcome = "won"/"revise"/"lost"`)。从前三个口的载荷都是空对象 `{}`,
* 于是三条边一条也不成立:整条流走到 A6 就没路可走,而口是"发射"过的。
* 这与 A1 当年 G1 全流死路是同一种错 —— **边读什么,口就得发什么**。
*/
{
name: 'won',
payload: {
type: 'object',
required: ['outcome'],
properties: { outcome: { type: 'string', title: '议价结果(固定 won)' } },
},
description: '成交 → 转订单履约(ORM)',
},
{ {
name: 'revise', name: 'revise',
payload: { payload: {
type: 'object', type: 'object',
required: ['outcome'], required: ['rfqRef', 'outcome'],
properties: { outcome: { type: 'string', title: '议价结果(固定 revise)' } }, properties: {
rfqRef: { type: 'string', title: '询价单号' },
customerCode: { type: 'string', title: '客户码' },
outcome: { type: 'string', title: '议价结果(固定 revise)' },
reason: { type: 'string', title: '退回缘由(让幅超授权多少)' },
rows: { type: 'array', title: '报价函(带客户还价与让步处置)' },
},
}, },
description: '有异议 → 返回调整 A4', description: '有条目经人拍板判了退回重批 —— 让幅超出授权,回成本定价重算再谈',
when: `${D}_line_deal_status_revise > 0`,
}, },
{ {
name: 'won',
payload: {
type: 'object',
required: ['rfqRef', 'outcome'],
properties: {
rfqRef: { type: 'string', title: '询价单号' },
customerCode: { type: 'string', title: '客户码' },
quoteNo: { type: 'string', title: '报价单号' },
outcome: { type: 'string', title: '议价结果(固定 won)' },
rows: { type: 'array', title: '成交条目(带成交价)' },
},
},
description: '有条目谈成 —— 转订单履约(ORM)',
when: `${D}_line_deal_status_won > 0`,
},
{
/* 下游 A7 的入参要 `rfqRef / customerCode / quoteNo` —— 边读什么、下游要什么,这一口就得发什么 */
name: 'lost', name: 'lost',
payload: { payload: {
type: 'object', type: 'object',
required: ['outcome'], required: ['rfqRef', 'customerCode', 'quoteNo', 'outcome'],
properties: { outcome: { type: 'string', title: '议价结果(固定 lost)' } }, properties: {
rfqRef: { type: 'string', title: '询价单号' },
customerCode: { type: 'string', title: '客户码' },
quoteNo: { type: 'string', title: '报价单号(A7 归档的主键之一)' },
outcome: { type: 'string', title: '议价结果(固定 lost)' },
reason: { type: 'string', title: '不成交缘由(拒绝 / 超时无回复)' },
rows: { type: 'array', title: '报价函(带客户表态与定案)' },
},
}, },
description: '拒绝/超时 → 转 A7 情报归档', description: '一条都没谈成:客户拒绝或超时无回复 —— 转情报归档',
when: `${D}_line_deal_status_lost > 0`,
}, },
], ],
}, },
], ],
}, },
/* /*
* 必交付物**必须给 `ledger` 地址**(2026-07-31 修)。 * 必交付物只有一件:**正式报价函**。议价结论(verdict)是它的结论栏,
* 不给的话预检退回 `scope[key]` 去找 —— key 是 `benchmark-table` 这样的短横线别名, * `ledgers` 说清这份成果由哪几张表承载(一份成果,不是两份)。
* 任务视图里永远没有这个键,于是「必交付物缺一不得提交放行」永远拒,
* 活办完了也放不了行,连拒五拍熔断(实测 A2 死于此)。
*/ */
requiredDeliverables: [ requiredDeliverables: [
{ key: 'formal-quote', label: '正式报价单(PDF · 中英双语)', ledger: `${DOMAIN}.formal_quote` }, {
// 跟进不单独立表 —— 建了几条记在议价结论的 followup_total 上 key: 'formal-quote',
{ key: 'followup', label: 'D+3/D+7 跟进提醒', ledger: `${DOMAIN}.verdict` }, label: '正式报价函(逐条条款 + 客户表态与定案)',
ledger: `${D}.line`,
/* 验收页上让 BD 就地改的两列 —— 恰是他的职权面(成交价与客户原话) */
editableAtRelease: ['final_price', 'reply_note'],
ledgers: [`${D}.line`, `${D}.verdict`],
},
], ],
/* /*
* 机检谓词读的是 `verdict` 那张**汇总表**的列(照 A1 的口径)。 * 验收判据 —— **机检(L1/L2)只读基座数得出来的数**:
* 上一版的名字(missingQuoteFields / validityDays / followupSet)**台账里根本没有** —— S-FEEL 拿未绑定的名字 * `_rows` 基座数行数、`_missing` 基座按 required / requiredWhen 现数
* 比大小求值为假,判据永远达不了标,submit 门永远拒,连拒五拍熔断。 * (与执行卡提交闸同一个数同一套算法)、`_<列>_<值>` 基座按值现数。
* 判据的名字必须落在台账上,否则「机检」是一句空话(2026-07-31 销账)。 * 上一版三条机检读活动自报的 `missing_field_total` / `validity_days` /
* `followup_total` —— 那不是门,是「改哪个数能过」的提示。
*/ */
acceptanceCriteria: [ acceptanceCriteria: [
{ key: 'quote-complete', label: '单价/交期/SPQ/MOQ/贸易条款/有效期齐备', level: 'L1', predicate: 'missing_field_total = 0' }, {
{ key: 'validity', label: '按报价有效期 P-04 注明有效期', level: 'L2', predicate: 'validity_days > 0' }, key: 'roster-complete',
{ key: 'followup-set', label: '已建 D+3/D+7 跟进 —— 没有跟进的报价等于没报', level: 'L2', predicate: 'followup_total >= 2' }, label: '报价函齐:上游条目逐条承继,说了几条报价函里就躺着几行',
{ key: 'bd-release', label: 'BD 放行', level: 'L5' }, 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: [], revisableAtRelease: [],
prohibitions: [ prohibitions: [
{ key: 'no-break-line', statement: '客户议价超出授权范围的,回 A5 重批,不私自破线' }, { key: 'no-speak-for-customer', statement: '不许替客户表态:客户没回的行按无回复落,不得写成接受 —— 那是在系统里伪造一份客户承诺' },
{ key: 'no-send-before-release', statement: '报价单在放行前不得发出 —— 写类动作集中在放行时落地' }, { key: 'no-break-line', statement: '客户要的价超出让价授权的,退回成本定价重批,不私自破线' },
{ key: 'no-send-before-release', statement: '报价函在放行前不得对外发出 —— 触达世界的动作集中在放行那一刻' },
], ],
authorization: { authorization: {
discretion: [{ key: 'haggle-band', statement: '在已批准的价格区间内回旋;出区间必须回审批' }], discretion: [{ key: 'haggle-band', statement: '让步梯度 N-02 给的授权区间内自决;超过一成的让幅一律回成本定价重批' }],
// 预授权必须逐项列举:催办模板可即时发,报价单不行 preauthorizedActions: [],
preauthorizedActions: [{ tool: 'mail.send', operation: 'nudge', limit: '仅催办模板' }], /* 一张执行单 + 可能一张决策卡 + 放行 = 三次;留一倍余量 */
humanCallBudget: 3, humanCallBudget: 5,
}, },
resources: { resources: {
tools: [{ name: 'mail.send' }, { name: 'crm.history-price-read' }], /*
* **不调工具**。上一版声明了 `mail.send`(写类:登记意向、放行时落地)——
* 而「把报价函发给客户」这件事在 MVP 示例工作流 §1.4 里本来就是**人工任务**:
* 收信的邮箱是 BD 的,客户回什么也只有他看得见。让活动登记一条发信意向,
* 既要过落地预检(收件人 / 主题 / 工具授权三道),又与执行单上那句
* 「请把它发给客户」重复,而没人说得清究竟哪一份才真发了出去。
*/
tools: [],
skills: NEGOTIATE_SKILLS.map((s) => s.id), skills: NEGOTIATE_SKILLS.map((s) => s.id),
models: ['deepseek-v4-flash'], models: ['deepseek-v4-flash'],
roles: ['BD'], 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: { declarations: {
models: ['deepseek-v4-flash'], models: ['deepseek-v4-flash'],
tools: [{ name: 'mail.send' }, { name: 'crm.history-price-read' }], tools: [],
ledger: [ ledger: [
{ {
domain: DOMAIN, domain: D,
object: 'formal_quote', object: 'line',
kind: 'record', kind: 'record',
caseKey: 'rfq_ref', caseKey: 'rfq_ref',
label: '正式报价单', label: '正式报价函',
fields: [ fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' }, { name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'quote_no', type: 'text', primaryKey: false, masked: false, label: '报价单号' },
{ name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' }, { name: '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: '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: '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 一格都不写** ────────────────────────
{ *
domain: 'market', * 与 A4 的 `quoted_price` 同一处结构治:一条要模型分辨「这行能不能替客户填」
object: 'price_history', * 的规矩会走岔;「这几列不写」没有分支可走岔。
kind: 'master', *
label: '历史价格情报(共享域)', * `reply_type` 标 `required` —— 件上的缺项红带、执行卡的提交闸、放行门 L1
fields: [ * 三处同源(都数基座的 `_missing`)。
{ name: 'pn', type: 'text', primaryKey: true, masked: false }, * `counter_price` 用 `requiredWhen`:**只有还价那一行才算必填**
{ name: 'quoted_at', type: 'date', primaryKey: true, masked: false, label: '报价日期' }, * (表级 required 会让接受/拒绝的行永远缺一格,把闸拦成死锁)。
{ name: 'unit_price', type: 'number', primaryKey: false, masked: false }, */
{ name: 'lead_time', type: 'text', primaryKey: false, masked: false }, {
name: 'reply_type',
type: 'text',
primaryKey: false,
masked: false,
required: true,
label: '客户表态',
tally: ['ACCEPT', 'COUNTER', 'REJECT', 'NOREPLY'],
},
{
name: 'counter_price',
type: 'number',
primaryKey: false,
masked: false,
requiredWhen: { column: 'reply_type', value: 'COUNTER' },
label: '客户还价',
},
{ name: 'reply_note', type: 'text', primaryKey: false, masked: false, label: '客户原话 / 让价理由' },
/* ── 定案面:两把尺算出来的,模型照抄 ─────────────────────────── */
/*
* 定案四档,**HAGGLE 是过渡态**(客户还了价、还没拍板):拍板那一下把它换成
* WON / REVISE / LOST 之一,于是「还价才叫人」那道哨兵当场为假。
* 上一版只有三档、退回重批那条路「定案不动」—— 人点完了 haggle 还是那个数,
* 哨兵照旧成立,模型读同一段做法就再发一张卡:**实测同一张连发六次**。
* 措辞治不了这种事,得让「办完了」在数上看得见(2026-08-03 逼出来的账)。
*/
{ name: 'deal_status', type: 'text', primaryKey: false, masked: false, label: '定案', tally: ['WON', 'HAGGLE', 'REVISE', 'LOST'] },
{
name: 'concession',
type: 'text',
primaryKey: false,
masked: false,
label: '让步处置(N-02)',
tally: ['ACCEPT_COUNTER', 'NEGOTIATE', 'ESCALATE'],
},
{ name: 'final_price', type: 'number', primaryKey: false, masked: false, label: '成交价' },
], ],
}, },
{ {
/* /*
* 议价结论 —— 一单一行的**汇总表**,验收机检谓词的入参就在这儿。 * 议价结论 —— 一单一行的**汇总表**,哨兵与报告读它。
* 汇总列与逐行列**故意不同名**:任务视图把草稿行摊平到顶层是「后写的赢」, * 汇总列与逐行列**故意不同名**:任务视图把草稿行摊平到顶层是「后写的赢」,
* 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。 * 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。
*/ */
domain: DOMAIN, domain: D,
object: 'verdict', object: 'verdict',
kind: 'record', kind: 'record',
caseKey: 'rfq_ref', caseKey: 'rfq_ref',
@@ -170,58 +395,186 @@ export const manifest: ActivityManifest = {
fields: [ fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' }, { name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'quote_no', type: 'text', primaryKey: false, masked: false, label: '报价单号' }, { name: 'quote_no', type: 'text', primaryKey: false, masked: false, label: '报价单号' },
{ name: 'quote_total', type: 'integer', primaryKey: false, masked: false, label: '报价行条数' }, { name: 'customer_code', type: 'text', primaryKey: false, masked: false, label: '客户码' },
{ name: 'missing_field_total', type: 'integer', primaryKey: false, masked: false, label: '条款缺失条数' }, { name: 'customer_name', type: 'text', primaryKey: false, masked: false, label: '客户名称' },
{ name: 'validity_days', type: 'integer', primaryKey: false, masked: false, label: '报价有效期天数' }, { name: 'line_total', type: 'integer', primaryKey: false, masked: false, label: '报价函条数' },
{ name: 'followup_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: '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: '议价结论' }, { name: 'summary', type: 'text', primaryKey: false, masked: false, label: '议价结论' },
], ],
}, },
], ],
/*
* 三条静态监听 —— **时限必配**(AM 4.4)。等客户回音这件事的落单人是 BD
* (见 case-model 头注),这三条管的是「别把它挂到天荒地老」:
* D+3 催一轮、D+7 最后一催并升级、客户真从通道回信时也认得出是这一单。
*/
listeners: [ 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', origin: 'static',
eventType: 'data', eventType: 'data',
keyFilter: { kind: 'customer-reply' }, keyFilter: { kind: 'customer-reply' },
semantic: 'complete-step', semantic: 'observe',
// 建 D+3 跟进监听:等客户回音,时限必配 —— 无死等 deadline: 'P7D',
deadline: 'P3D', escalation: ['nudge'],
escalation: ['nudge', 'escalate'],
}, },
], ],
harness: { harness: {
memoryWindow: 20, memoryWindow: 22,
stallAfter: 'PT15M', // 报价有效期 P-04 是 7 天,但这一单的**活办完**只需两个人环
nudge: [{ after: 'PT10M', action: 'nudge' }], stallAfter: 'PT2H',
nudge: [{ after: 'PT1H', action: 'nudge' }],
retry: { times: 2, backoff: 'PT30S' }, retry: { times: 2, backoff: 'PT30S' },
}, },
}, },
/* ── 资产初始版 ──────────────────────────────────────────────── */
assets: { assets: {
/* 资产初始版:案例模型(2026-07-29 起活动只有这一种业务模型) */
businessModel: { businessModel: {
cmmnXml: CMMN_XML, cmmnXml: CMMN_XML,
narrative: NARRATIVE, narrative: NARRATIVE,
dmn: { drd: [], tables: [] }, dmn: N01_DMN,
/* 步骤剧本 —— 与 A1 同一套:doneWhen 真了引擎自己收步,不问模型 */
steps: NEGOTIATE_STEPS, steps: NEGOTIATE_STEPS,
phases: NEGOTIATE_PHASES,
}, },
skills: NEGOTIATE_SKILLS, skills: NEGOTIATE_SKILLS,
/*
* 两个局面各一张(与 A2/A3/A4 同款口径):
* · `negotiate-worksheet` 议价工作单 —— 排版、发出与落回音期间摆它(逐颗报价档案、
* 两把尺、填回工作表);
* · `negotiate-roster` 成交定案清单 —— 拍板、放行、归档摆它。
*/
viewTemplates: [ viewTemplates: [
{ name: 'quote-composed', html: QUOTE_COMPOSED }, { name: 'negotiate-worksheet', html: NEGOTIATE_WORKSHEET },
{ name: 'quote-release', html: QUOTE_RELEASE }, { name: 'negotiate-roster', html: NEGOTIATE_ROSTER },
], ],
cardSchemas: {}, cardSchemas: {},
distillHooks: [{ on: 'terminal', prompt: '归纳本单议价的经验:客户的还价幅度、成交与否的关键因素。' }], distillHooks: [
{
on: 'terminal',
prompt: '归纳本单议价:客户的还价幅度与说法、让步梯度哪一档最常命中、成交与不成交的关键因素。',
},
],
}, },
/* ── 样例集 —— 三条形状真不同(常备案例即验收案例)───────────── */
samples: [ 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'] }, 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' }, 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 { skillIdSchema } from '@contracts';
import type { Skill } from '@contracts'; import type { Skill } from '@contracts';
export const NEGOTIATE_SKILLS: Skill[] = [ export const NEGOTIATE_SKILLS: Skill[] = [
{ {
id: skillIdSchema.parse('negotiate.compose-quote'), id: skillIdSchema.parse('negotiate.read-reply'),
label: '拟一份能发出去的报价单', label: '读懂客户的那一句回音',
applicability: '定价已过审批,要生成正式报价单时。', applicability: '客户回话了,要把它落成一个表态时。',
method: [ method: [
'报价单是**要发给客户**的东西,所以每一项都得能兑现:', '客户的话落到系统里只有四种表态,别的都是这四种的说法之一:',
'· 单价与交期照审批通过的草案抄,不做任何「顺手优化」——改价要重新过审批点。', '· ACCEPT —— 明确接受这个价:「按这个价走」「下单吧」「PO 稍后发」。',
'· SPQ / MOQ / 贸易条款 / 有效期缺一不可:缺了客户会追问,追问就是又一轮往返。', '· COUNTER —— 还了一个价,或明确说「贵了多少」「对标某某家便宜多少」。',
'· 有效期按行情波动定,不要一律写「长期有效」——那等于把汇率与料价风险自己扛了。', ' **还价必须落下客户报的那个数**:没有数字的「太贵了」先追问,追不到才按无回复落。',
'· REJECT —— 明确不做了:选了别家、项目取消、规格不匹配。',
'· NOREPLY —— 催到有效期过仍不表态。**客户不回不是结论**:',
' D+3 第一轮催问确认收到探态度,D+7 最后一催,催过了才轮到按超时定案。',
'', '',
'拿不准的项**宁可空着走补全**,不要填一个看起来合理的值:', '两条别做的事:',
'报价单一旦发出就是承诺,发出前的空白是成本,发出后的错误是损失。', '· 不要替客户表态。他只回了一部分料的,没回的那几行就是 NOREPLY ——',
' 把「大概是接受了吧」写成 ACCEPT,是在系统里伪造一份客户承诺。',
'· 不要把口头承诺留在脑子里。每轮反馈当天记回备注,',
' 下一轮谈判、事后复盘、情报归档读的都是这一格。',
].join('\n'), ].join('\n'),
crystallized: [], crystallized: [],
knowledgeRefs: ['报价单模版规范'], knowledgeRefs: ['议价结果 N-01'],
version: '1.0.0', version: '2.0.0',
}, },
{ {
id: skillIdSchema.parse('negotiate.pick-outcome'), id: skillIdSchema.parse('negotiate.concede'),
label: '选对议价出口', label: '让价守线',
applicability: '客户回复后,要确认议价结果出口时。', applicability: '客户还了价,要决定让不让、让多少时。',
method: [ method: [
'三个出口对应三种后续,**恰发一口**:', '先弄清争的是什么:价格、交期、MOQ,还是走过场压一轮 ——',
'· 成交 → 转订单履约,这条流走到终点。', '**对症再谈,不上来就让**。争交期的用交期换,争 MOQ 的用包装换;',
'· 有异议 → 回 A4 重开新实例重算 —— 注意是**新实例**,旧的已终、不再入算。', '一上来就降价,后面每一轮就都只剩降价这一招。',
'· 拒绝 / 超时 → 转 A7 情报归档:把拒绝原因与竞对价位登记下来。',
'', '',
'「拒绝」不是失败终态。这一单丢了,但拒绝原因回写历史情报后,', '让步梯度 N-02 是尺,不是台阶(不必逐档走下去):',
'下一单的三分类与利润模型会用上它 —— 这才是归档存在的理由。', '· 三个点以内 —— 意思意思的压价,接了拿单,来回一轮的时间成本比这三个点贵;',
'· 三到十个点 —— 授权内回旋:让一半再报回去,把让出去的换成量或账期;',
'· 超过一成 —— 超出授权,**交回成本定价重批,不私自破线**。',
' 破线不是勇气是事故:底价线以下的单,每补一次货都在亏。',
'',
'让了就要说清为什么让:客户长单、带单品、竞对压价 ——',
'理由随定案一起交给签字的人。没有理由的让价,到复盘那一关同样过不去。',
].join('\n'), ].join('\n'),
crystallized: [], crystallized: [],
knowledgeRefs: [], knowledgeRefs: ['让步梯度 N-02', '底价门禁 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>`;
+704 -173
View File
@@ -1,189 +1,720 @@
/** /**
* A6 报价议价的 HTML 报告模版 —— **活动自己持有的资产**(§10.3),随包版本冻结。 * A6 报价议价的 HTML 报告模版 —— **活动自己持有的资产**,随包版本冻结。
* *
* 议价是**多轮往来**:成单 → 人工按下发出 → D+3 催 → D+7 催 → 客户还价 → 改一版再发。 * 两个局面各一张(与 A2/A3/A4 同款口径):
* 所以这两屏都要让人看得出**先后**:这一版是第几轮、上一轮报的是多少、下一记提醒在哪天。 * · `negotiate-worksheet` **议价工作单** —— 排版、发出与落客户回音期间摆它:
* 只摆一个「当前单价」的屏,人签字时没有参照系,也就无从判断这一让让得值不值 —— * 逐颗料一张报价档案卡(条款在左、议价在右)、议价结果 N-01 与让步梯度 N-02 两把尺、
* 于是「历次报价」那一节摆的是共享域整表(含既往单),它不是本单成果,是本单的尺子。 * 填回工作表就长在同一屏;
* · `negotiate-roster` **成交定案清单** —— 异议拍板、放行与归档摆它:整份报价函,
* 定案分布、去向落边图与落地动作在上面。
* *
* 两个局面: * 「1:1」的落点是**每份件与它的办结快照**(在办可编辑 ↔ 回执收下那一刻的 finalHtml),
* ① 正式报价单(草稿)—— 六样条款齐不齐;这一步还没碰外界,一封信都没发; * 不是全程只许一张:作业的件与收口的件是两件工作两份件(规范姊妹篇 §1)。
* ② 放行即发出 —— 按下的那一刻信就到客户手上,签字前是最后一次能改的机会。
* *
* 容器是 `sandbox=""` iframe:无脚本、无外链、样式自带;数据由基座推送时填 * 版面语言沿用 A1 从 PRD 目标件机械抽取的那套(`prd-style.ts` 原样拷入)——
* (`data-slot` 取值、`data-rows` 逐行展开)。 * A6 没有自己的 PRD 目标件,与 A2/A3/A4 同一处置:沿用家族版面自造。
* *
* 两条贯穿全包的呈现纪律: * 取值三纪律:顶层只取汇总列与单值列;逐条走 `mine:quote_negotiate.line`;
* · **状态章一律用「在办」灯,不用「已办」印** —— 模版是随包冻结的静态字符串, * 列名逐字照 `declarations.ledger` 抄。**模版不算不判** —— 缺表态红带、定案徽、分布条、
* 它盖不了一个还没发生的章;这两张都是放行前的局面; * 落边图全部由基座算好的数经 `data-class-of` 投影,判断留在样式表。
* · **`verdict` 的橙档专指「还没发生,等你按」** —— 发出的后果是预告不是既成事实。
* 整屏的结构色一律品牌蓝/案卷紫,红橙绿只落在「达标没达标」上,不拿来渲染气氛。
*/ */
import { noteOnly, section, stagesOnly, statsBand, tableOnly, verdict, viewShell } from '@sdk'; import { PRD_CSS, PRD_SPRITE } from './prd-style.ts';
/** /* ==================== 绑定期补的样式(照 A1/A3/A4 的 BIND_CSS,地址换成本活动)==================== */
* 静态阶梯 —— **规则与节拍不是数据**:六样条款的要求、D+3/D+7 的节拍、三个出口的去向
* 都随包冻结,不该走 `data-rows` 等基座来填(那会让「规矩」看起来像「这一单的结果」)。 const BIND_CSS = `
* 沿用外壳已有的 `.steps` 版式,与引擎推的阶段列表同一副面孔。 /* 空占位三级(2026-08-02 全局口径):必填红 / 建议填橘 / 选填灰 —— 值真的空才轮到 */
*/ .proto-root .jc-tbl-in td.req > .jc-vcell:empty::after,
function ladder(rows: ReadonlyArray<readonly [string, string, string]>): string { .proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after,
return `<ul class="steps">${rows .proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after {
.map( display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
([no, term, gloss]) => line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
`<li><span class="n">${no}</span><span><span class="t">${term}</span> <span class="d">${gloss}</span></span></li>`, }
) .proto-root .jc-tbl-in td.req > .jc-vcell:empty::after { content: "必填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
.join('')}</ul>`; .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; }
* `@sdk` 的 `kvCard` 自带一个「·」号章节,与本页「一/二/三」的编号排不到一起, .proto-root .jc-tbl-in td.jc-rn::before { counter-increment: rn; content: counter(rn); }
* 所以这里只借外壳已有的 `.kv` 版式,章节仍交给 `section` 统一编号。 @media (max-width: 1000px) {
*/ .proto-root .jc-tbl-in td.jc-rn::before { content: "第 " counter(rn) " 条"; }
function kvGrid(items: ReadonlyArray<readonly [string, string, ('mono' | 'tag')?]>): string { .proto-root .jc-tbl-in td.jc-rn::after { content: none; }
return `<div class="kv">${items
.map(([label, path, style]) => {
const cls = style === undefined ? '' : ` class="${style === 'mono' ? 'mono' : 'tag'}"`;
return `<div><em>${label}</em><b${cls} data-slot="${path}"></b></div>`;
})
.join('')}</div>`;
} }
/** 局面一:S1 成单 —— 人此刻要核的是「六样齐没齐、这一版能不能发」 */ /* 就地改:可改格子渲了只读与输入两份,默认锁着,表上加 on 才换孪生件 */
export const QUOTE_COMPOSED = viewShell({ .proto-root .jc-cell { display: none; }
title: '正式报价单(草稿)', .proto-root .jc-tbl-in.on td > .jc-vcell,
kicker: 'A6 · 报价议价 · S1 成单', .proto-root .jc-tbl-in.on td > .jc-tbadge { display: none; }
tone: 'brand', .proto-root .jc-tbl-in.on td > .jc-cell { display: block; }
icon: 'handshake', /* 类徽 / 表态徽 / 定案徽不进编辑态(承继与引擎产物),on 时也照旧摆着 */
stamp: { kind: 'live', text: '成单中' }, .proto-root .jc-tbl-in.on td > .jc-obadge,
sign: 'A6 报价议价 · 正式报价单(本单草稿)', .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: body:
/* `items` 是起单入参的数组,基座渲染数组时给的是条数 —— 正好是「这一单要报几条」 */ part('议价任务', '客户 <b data-slot="customer_name" data-blank></b> · 报价函 <b data-slot="quote_no" data-blank></b> · 共 <b data-slot="line_total"></b> 条') +
statsBand([ `<div class="jc-derive">${ico('info', 14)}
['报价条目', 'items'], <span>这一屏是一份报价函的作业台:系统已按 A5 批准的价排好版,条款与有效期都在下面 ——
['单价 CNY', 'unit_price'], <b>报出去的价一个数字都不改</b>(要改价得回审批)。你要做的是把它发给客户,
['交期', 'lead_time'], 按 D+3 / D+7 的节奏催问,<b>客户回话之后在「客户表态」那一列逐行落</b>。
['有效期', 'valid_until'], 这一列系统一格都不写:客户回了什么,只有收信的人看得见。</span>
]) + </div>` +
section('报价条款六样 —— 缺一不可', FOUR +
kvGrid([ part('一单议价是怎么走完的', '批准价 → 发出 → 客户表态 → 定案 → 去向') +
['单价 CNY', 'unit_price'], DEAL_LINE +
['交期', 'lead_time'], part('逐颗报价档案', '条款在左 · 议价在右') +
['最小包装量 SPQ', 'spq'], QUOTE_CARDS +
['最小起订量 MOQ', 'moq'], part('议价结果 N-01', '客户的话只有四种表态 —— 尺挂在墙上') +
['贸易条款', 'trade_terms'], OUTCOME_RULES +
['有效期', 'valid_until'], part('让步梯度 N-02', '还价幅度决定让不让、谁来让') +
]) + CONCESSION_RULES +
noteOnly( part('填回工作表', '客户表态落在这 —— 补齐了执行单才提交得了') +
'单价与交期<b>照审批通过的草案抄</b>,不做任何「顺手优化」—— 改价要重新过审批点。' + GAP_BAR +
'有效期按行情波动定,不要一律写「长期有效」:那等于把汇率与料价风险自己扛了。' + editGate('quote_negotiate.line', '报价函填回表') +
'拿不准的项<b>宁可空着走补全</b>,不要填一个看起来合理的值。', LINE_TABLE +
), addRowBar('quote_negotiate.line') +
{ text: '缺一样多一轮' }, `<div class="jc-drop">${ico('info', 13)}
) + <span>带红标的<b>客户表态</b>是必填,而且<b>只由你填</b> —— 缺一行执行单按不下提交,
section('本单已成形的报价行', 与放行门是同一个数同一套算法。四个取值一个字母不许差:
tableOnly('mine:quote_negotiate.formal_quote', [ <b>ACCEPT</b> 接受 · <b>COUNTER</b> 还价 · <b>REJECT</b> 拒绝 · <b>NOREPLY</b> 无回复。
['报价单号', 'quote_no', 'mono'], 还价的行务必把客户报的那个价填进<b>客户还价</b> —— 那一格空着这张单同样提交不了,
['型号 PN', 'pn', 'mono'], 后面拍板全看它。客户只回了一部分料的,没回的那几行按 NOREPLY 落,
['单价', 'unit_price'], <b>别替客户猜</b>;本轮不报的料按行首 × 撤掉。</span>
['交期', 'lead_time'], </div>`,
['有效期', 'valid_until'], foot: sign('BD 议价 · AI 排版报价函并按两把尺定案,客户那一侧的话由你落', '统一报价 · A6 报价议价 · 在办'),
]),
{ text: '只列本单写下的行' },
) +
section('议价的节拍 —— 一轮一版',
ladder([
['1', '写成正式报价单', '中英双语,六样齐备'],
['2', '人工按下发出', '触达客户的一下,系统不替人做这个决定'],
['3', 'D+3 提醒到点', '没有跟进的报价等于没报'],
['4', 'D+7 提醒到点', '第二记催办,仍无回音即等超时'],
['5', '客户回音 → 议价调整', '改价、改交期、换料都是新一版,重新走发出'],
]),
{ text: '往来看得出先后' },
) +
section('案例当下进程', stagesOnly('case:stages'), { text: 'Enabled ≠ Active' }) +
section('这一步还没碰外界',
verdict('报价单此刻只是草稿 —— 一封信都还没发出去', 'wait', '发出这一下必须人工按') +
noteOnly(
'发出是<b>写类动作</b>:过程中只登记为「动作意向」—— 入参冻结、等放行,放行前不触达世界。' +
'预授权是逐项列举的:催办模板可即时发,<b>报价单不行</b>。',
),
),
}); });
/** 局面二:待放行 —— 按下的那一刻信就发出去,这是最后一次能改的机会 */ /* ==================== 局面二 · 成交定案清单(拍板 / 放行 / 归档)==================== */
export const QUOTE_RELEASE = viewShell({
title: '正式报价单 · 放行即发出', /** 落边图:定案清单 → 三个去向(命中边由壳上 v-r1/v-r2/v-r3 上色,件不算不判) */
kicker: 'A6 · 报价议价 · 成果待签', const ROUTE_FIG = `<div class="route" data-class-of="case:routeKey">
tone: 'case', <svg viewBox="0 0 660 250" fill="none">
icon: 'send', <rect class="rnode" x="8" y="95" width="150" height="60" rx="10"/>
stamp: { kind: 'live', text: '待签字放行' }, <text class="rtxt" x="83" y="120" text-anchor="middle">成交定案清单</text>
sign: 'A6 报价议价 · 正式报价单(待放行即发出)', <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: body:
statsBand([ part('议价要素', '报价议价 · 能查表的归判定表,要让价的归人') +
['单价 CNY', 'unit_price'], FOUR +
['交期', 'lead_time'], part('定案分布', '成交 + 待拍板 + 退回重批 + 不成交 = 总条数') +
['最小起订量 MOQ', 'moq'], DIST +
['有效期', 'valid_until'], `<div class="jc-derive">${ico('info', 14)}
]) + <span>四段条数由基座逐行现数,不是谁报上来的。橙段(<b>待拍板</b>)是过渡态 ——
section('本单交付:正式报价单', 客户还了价而还没人拍板,它<b>归零这一单才走得到放行门</b>:接了算成交、
tableOnly('mine:quote_negotiate.formal_quote', [ 退回重批算一条正经的路、守价不让算不成交。要让价就得在授权内让,
['报价单号', 'quote_no', 'mono'], 超过一成的让幅一律回去重批,<b>不私自破线</b>。</span>
['型号 PN', 'pn', 'mono'], </div>` +
['单价', 'unit_price'], part('正式报价函', '<b data-slot="line_total"></b> 条 · 每条都带客户的话') +
['交期', 'lead_time'], GAP_BAR +
['SPQ', 'spq'], editGate('quote_negotiate.line', '成交定案表') +
['MOQ', 'moq'], SETTLED_TABLE +
['贸易条款', 'trade_terms'], `<div class="jc-drop">${ico('info', 13)}
['有效期', 'valid_until'], <span>每一行摆的是这一条的<b>来路</b>:报了多少、客户怎么表态、还了什么价、
]), 让步梯度判在哪一档、最后定成什么。签字前请重点看两处:<b>定案列</b>里非绿的条目,
{ text: '发出去的就是这几行' }, 与<b>客户原话列</b> —— 没有客户原话的还价,下一轮谈判就是重新开始。
) + 这一屏可改的是<b>成交价</b>与<b>客户原话</b>两列(你的职权面)。</span>
section('历次报价对照 —— 这一轮让了多少', </div>` +
/* part('落边 · 这一签流向哪里', '出口按序求值 · 首个命中即出') +
* 这一张**故意不用 `mine:`**:它取共享域整表,含既往单的行。 ROUTE_FIG +
* 本单成果只看 `mine:`(见上一节),而「让了多少」这件事天然要跟别的单比 —— `<div class="concl">
* 没有上一轮的价摆在旁边,人签字时判断不了这一让的分寸。 ${ico('git-branch', 15)}
*/ <div class="cl-main"><b>议价结论</b> —— <span data-slot="summary" data-blank></span></div>
tableOnly('shared.history_price', [ <div class="cl-route"><em>转入活动</em><b data-slot="case:routeTo"></b><code data-slot="case:routeWhy"></code></div>
['型号 PN', 'pn', 'mono'], </div>` +
['报价日期', 'quoted_at', 'mono'], part('放行后即执行的落地动作', '门前 → 门后') +
['单价', 'unit_price'], `<div class="jc-acts">
['交期', 'lead_time'], <div class="jc-act"><i>${ico('check', 11)}</i>
]) + <div><b>草稿层合并进正式版</b>&#12288;<span>本任务写下的报价函与议价结论一并落账;
noteOnly( 你就地改过的成交价与客户原话随之落定。</span></div>
'这一节<b>不是本单成果</b>,是共享域里同一料号的历次报价(含既往单)—— 摆在这里只做一件事:' + </div>
'给这一轮的价一个参照系。报价日期在列上,先后按日期对;它由 A7 情报归档一单一单沉下来。', <div class="jc-act"><i>${ico('check', 11)}</i>
), <div><b>命中的那一口此刻才发射</b>&#12288;<span>报价函
{ text: '含既往单' }, <b data-slot="quote_negotiate_line_rows"></b> 条随载荷交给上面亮线指向的下游;
) + 另两口本次不起单 —— 放行之前,世界上什么都没有发生。</span></div>
section('放行前要过的四道判据', </div>
ladder([ <div class="jc-act"><i>${ico('check', 11)}</i>
['1', '单价 / 交期 / SPQ / MOQ / 贸易条款 / 有效期齐备', 'L1 机检 · missingQuoteFields = 0'], <div><b>不成交也留资产</b>&#12288;<span>拒绝原因与竞对价位随载荷交给情报归档 ——
['2', '按报价有效期 P-04 注明有效期', 'L2 机检 · validityDays 大于 0'], 这一单丢了,下一单的三分类与利润模型会用上它。</span></div>
['3', '已建 D+3 / D+7 跟进', 'L2 机检 · 没有跟进的报价等于没报'], </div>
['4', 'BD 放行', 'L5 人签 · 签了才算数'], </div>`,
]), foot: sign('BD 议价放行 · AI 排版与定案留痕 —— 要改哪一格,在这张定案表上就地改', '统一报价 · A6 报价议价 → 定去向'),
{ text: '三机检 · 一人签' },
) +
section('出口三选一 —— 恰发一口',
ladder([
['1', '成交', '转订单履约(ORM),这条流走到终点'],
['2', '有异议', '回 A4 重开新实例重算 —— 旧的已终,不再入算'],
['3', '拒绝 / 超时', '转 A7 情报归档,把拒绝原因与竞对价位登记下来'],
]) +
noteOnly(
'三个出口对应三种后续,随放行<b>恰选一口</b>,下游网关读的就是它;<b>不选口不许放行</b> ——' +
'含糊的结果比没结果更坏。「拒绝」不是失败终态:原因回写历史情报后,下一单的三分类与利润模型会用上它。',
),
{ text: '下游只读这一口' },
) +
section('按下放行会发生什么',
verdict('放行即落地:台账回写 + 报价单出站发送', 'wait', '已成动作留存,不回滚') +
noteOnly(
'① <b>落地预检</b>先跑:交付物齐备、动作意向可执行、授权在契约边界内 —— 预检不过不发卡;' +
'② 放行通过<b>即刻落地</b>,出站动作附幂等键;' +
'③ <b>落地收敛</b>之后输出口才发射 —— 下游只见落地完成的任务。',
),
),
}); });
@@ -0,0 +1,499 @@
/**
* A4 成本定价的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。
* 案例活动没有 `run`,自测不跑回合;测的是清单本身与四处对齐
* (判据谓词 / 出口载荷 / 剧本声明 / 两把尺的档位)。
*/
import { describe, expect, it } from 'vitest';
import { activityManifestSchema } from '@contracts';
import { evaluateCondition, evaluateUnaryTest } from '@sfeel';
import { createMockHost } from '@sdk';
import { P01_DMN, manifest } from '../src/manifest.ts';
import activity from '../src/index.ts';
describe('清单本身合规', () => {
it('过 @contracts 的 schema', () => {
expect(() => activityManifestSchema.parse(manifest)).not.toThrow();
});
it('监听时限已配 —— 无悬空监听', () => {
expect(manifest.declarations.listeners.every((l) => /^P/.test(l.deadline))).toBe(true);
});
it('L1/L2 判据都给了可机检的谓词 —— 验收等级不得谎报', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef, c.key).toBeDefined();
}
});
it('禁区短而硬,授权边界给了调人预算', () => {
expect(manifest.contract.prohibitions.length).toBeGreaterThan(0);
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(0);
});
it('每件必交付物都有台账地址', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(d.ledger, d.key).toBeDefined();
}
});
/*
* **宽进严出**:起单一个必填都不设 —— 上游(A3 的 quotation 口)发的是
* `{rfqRef, rows}`,而上一版必填 `rfqRef + customerCode + records`,
* 门口拦的恰是主路。归一在活动内部做。
*/
it('起单宽进:input.required 是空的,三种来路的键都声明着', () => {
expect(manifest.contract.input.data['required']).toEqual([]);
const properties = manifest.contract.input.data['properties'] as Record<string, unknown>;
for (const key of ['rfqRef', 'rows', 'records', 'subject', 'body']) {
expect(properties[key], `入参少了 ${key}`).toBeDefined();
}
});
/*
* **拟报单价是必填** —— 这是 A4 的门面判据:件上的缺价红带、执行卡的提交闸、
* 放行门 L1 三处同源(都数基座的 `_missing`)。声明丢了 required,三处一起失明。
*/
it('拟报单价标了 required —— 红带 / 提交闸 / 放行门三处同源的那个数才存在', () => {
const draft = manifest.declarations.ledger.find((d) => d.object === 'draft');
expect(draft?.fields.find((f) => f.name === 'quoted_price')?.required).toBe(true);
});
/*
* **建议价与拟报单价分两列是结构治,不是措辞治**(2026-08-03 单飞案例逼出来的):
* 只有一列价时,剧本写「算不出的留空」,模型照样给库里查不到的料编了价 ——
* 一条要它分辨「算得出/算不出」的规矩会走岔;「这一列你不写」没有分支可走岔。
*/
it('建议价与拟报单价是两列 —— AI 写建议价,人写拟报单价', () => {
const draft = manifest.declarations.ledger.find((d) => d.object === 'draft');
const suggested = draft?.fields.find((f) => f.name === 'suggested_price');
expect(suggested, '缺 suggested_price 这一列').toBeDefined();
expect(suggested?.required, '建议价不该是必填 —— 算不出就该空着').toBeUndefined();
for (const step of ['S1_price', 'S3_gate']) {
const guide = manifest.assets.businessModel.steps?.find((s) => s.item === step)?.guide ?? '';
expect(guide, `${step} 没写死「quoted_price 你一格都不写」`).toMatch(/quoted_price[^\n]*一格都不写|一格都不写[^\n]*quoted_price/);
}
});
it('门禁处置给了取值域 —— 三档条数由基座现数,不由活动自报', () => {
const draft = manifest.declarations.ledger.find((d) => d.object === 'draft');
expect(draft?.fields.find((f) => f.name === 'disposition')?.tally).toEqual(['PASS', 'NOTE', 'BLOCK']);
});
/*
* 我方写的注不标 `expect` —— 标了 `_gaps` 恒大于 0,读它的判据永远绿不了(A1 的账)。
* 客户目标价才是「客户该给而没给的」那一问。
*/
it('expect 只标客户该给的那一格,不标我方写的注', () => {
const draft = manifest.declarations.ledger.find((d) => d.object === 'draft');
expect(draft?.fields.find((f) => f.name === 'target_price')?.expect).toBe(true);
expect(draft?.fields.find((f) => f.name === 'price_note')?.expect).toBeUndefined();
});
it('三条常备案例形状真不同:寻源转来 / 客户压价超限 / 单飞无基准', () => {
expect(manifest.samples.length).toBe(3);
const [flowIn, overLimit, solo] = manifest.samples;
expect((flowIn?.input as { rows?: unknown[] }).rows?.length).toBeGreaterThan(0);
expect((overLimit?.input as { rows?: Array<Record<string, number>> }).rows?.[0]?.['target_price']).toBeLessThan(
(overLimit?.input as { rows?: Array<Record<string, number>> }).rows?.[0]?.['base_price'] ?? 0,
);
expect((solo?.input as { rows?: unknown[] }).rows).toBeUndefined();
});
});
describe('案例模型 —— 双表示同版本', () => {
const model = manifest.assets.businessModel;
const cmmn = parseCmmnLite(model.cmmnXml);
it('CMMN 与说明书同在,不允许分叉', () => {
expect(model.cmmnXml).toContain('<casePlanModel');
expect(model.narrative.length).toBeGreaterThan(400);
});
/* 照 MVP 示例工作流 §1.2 的 S1 取价基准 / S2 逐条定价 / S3 底价自检,一步一项 */
it('计划项照 SOP 一步一项:算建议价 + 交人定价 + 底价自检 + 超限拍板 + 放行', () => {
expect(items(model.cmmnXml)).toEqual(['S1_price', 'S2_review', 'S3_gate', 'T_approve', 'T_release']);
});
it('三处人的门都标了人工激活:BD 定价 · 超限拍板 · 草案放行(底价自检是算术,不标)', () => {
expect(cmmn.manualActivation).toEqual(['S2_review', 'T_approve', 'T_release']);
});
it('不用阶段 / 里程碑 / 裁量项', () => {
for (const tag of ['<stage', '<milestone', '<planningTable', '<discretionaryItem']) {
expect(model.cmmnXml, `不该出现 ${tag}`).not.toContain(tag);
}
});
/*
* **超限才叫人** —— 这个活动的要害:哨兵读基座按值现数的拦截档条数,
* 而不是活动自报的 `over_total`;且要等 BD 核过价(他自己回调掉的不必再惊动第二个人)。
*/
it('超限拍板的哨兵读基座现数,且等人核过价 —— 不是每条都叫人,也不是一条都不叫', () => {
const condition = sentryIf(model.cmmnXml, 's_over');
expect(condition).toContain('quote_pricing_draft_disposition_block > 0');
expect(condition).toContain('confirmed_total');
// 有拦截档且人核过价 → 叫人
expect(safeEval(condition, { quote_pricing_draft_disposition_block: 1, confirmed_total: 2 })).toBe(true);
// 拦截档清零 → 不叫人(这一步整个不入场)
expect(safeEval(condition, { quote_pricing_draft_disposition_block: 0, confirmed_total: 2 })).toBe(false);
// 人还没核价 → 先别叫(他自己回调掉的那几条不该再惊动一个人)
expect(safeEval(condition, { quote_pricing_draft_disposition_block: 1 })).toBe(false);
});
/*
* 入场哨兵也归「机检只读基座数」管(第四次违反就藏在哨兵里)——
* 上一版 `s_priced` 读活动自报的 `draft_total`:模型不记这个账,门就永不开。
*/
it('交人核价的入场哨兵读基座行数,不读自报的草案条数', () => {
const condition = sentryIf(model.cmmnXml, 's_priced');
expect(condition).toContain('quote_pricing_draft_rows');
expect(condition).not.toContain('draft_total');
expect(safeEval(condition, { line_total: 2, quote_pricing_draft_rows: 2, priced_total: 2 })).toBe(true);
// 行落了但算价那一轮还没走完(priced_total 没写过)→ 门不开
expect(safeEval(condition, { line_total: 2, quote_pricing_draft_rows: 2 })).toBe(false);
});
it('放行门哨兵带 ifPart:每行有价 + 处置守恒 + 拦截清零 + 定盘记过,全读基座数', () => {
const condition = sentryIf(model.cmmnXml, 's_ready');
expect(condition).toContain('quote_pricing_draft_missing = 0');
expect(condition).toContain('quote_pricing_draft_disposition_block = 0');
const done = {
route_decided_total: 1,
quote_pricing_draft_rows: 3,
quote_pricing_draft_missing: 0,
quote_pricing_draft_disposition_pass: 2,
quote_pricing_draft_disposition_note: 1,
quote_pricing_draft_disposition_block: 0,
};
expect(safeEval(condition, done)).toBe(true);
// 还有一行缺拟报价 → 门不开
expect(safeEval(condition, { ...done, quote_pricing_draft_missing: 1 })).toBe(false);
// 还有条目压在拦截档 → 门不开(哪怕守恒仍成立)
expect(
safeEval(condition, { ...done, quote_pricing_draft_note: 0, quote_pricing_draft_disposition_note: 0, quote_pricing_draft_disposition_block: 1 }),
).toBe(false);
// 处置没记齐(守恒不成立)→ 门不开
expect(safeEval(condition, { ...done, quote_pricing_draft_disposition_note: 0 })).toBe(false);
});
it('每个计划项都有剧本', () => {
const ids = items(model.cmmnXml);
expect((model.steps ?? []).map((s) => s.item)).toEqual(ids);
for (const step of model.steps ?? []) {
expect(step.guide.length, `${step.item} 的做法`).toBeGreaterThan(80);
expect(step.intent.length, `${step.item} 的意图`).toBeGreaterThan(6);
}
});
it('说明书不点名计划项', () => {
const named = [...model.narrative.matchAll(/\b(?:S\d+_[A-Za-z]+|T_[a-z]+)\b/g)].map((m) => m[0]);
const ids = new Set(items(model.cmmnXml));
for (const name of named) {
expect(ids.has(name), `说明书提到的 ${name} 不在案例模型里`).toBe(true);
}
});
it('说明书只讲整单的事:成品、怎么说话、怎么收口', () => {
for (const topic of ['开案', '要说话', '一拍连做', '结案']) {
expect(model.narrative, topic).toContain(topic);
}
});
});
describe('声明与契约对得上', () => {
const columns = new Set(
manifest.declarations.ledger.flatMap((d) => [`${d.domain}.${d.object}`, ...d.fields.map((f) => f.name)]),
);
it('必交付物的台账地址都声明过', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(columns.has(d.ledger ?? ''), `${d.key} → ${d.ledger}`).toBe(true);
}
});
it('editableAtRelease 的列都在成果表的声明里', () => {
const draft = manifest.declarations.ledger.find((d) => d.object === 'draft');
const names = new Set((draft?.fields ?? []).map((f) => f.name));
for (const d of manifest.contract.requiredDeliverables) {
for (const col of d.editableAtRelease ?? []) {
expect(names.has(col), `editableAtRelease 里的 ${col}`).toBe(true);
}
}
});
const rowReadings = new Set([
...manifest.declarations.ledger.map((d) => `${d.domain}_${d.object}_rows`),
...manifest.declarations.ledger
.filter((d) => d.fields.some((f) => f.required === true))
.map((d) => `${d.domain}_${d.object}_missing`),
...manifest.declarations.ledger.flatMap((d) =>
d.fields.flatMap((f) =>
(f.tally ?? []).map((v) => `${d.domain}_${d.object}_${f.name}_${v.toLowerCase().replace(/[^a-z0-9_]/g, '')}`),
),
),
]);
it('L1/L2 谓词读的名字都有承载(台账列或基座读数)', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level !== 'L1' && c.level !== 'L2') continue;
for (const identifier of (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? []) {
if (['and', 'or', 'not', 'true', 'false', 'null', 'in', 'between'].includes(identifier)) continue;
if (rowReadings.has(identifier)) continue;
expect(columns.has(identifier), `${c.key} 的谓词引用了 ${identifier}`).toBe(true);
}
}
});
/*
* **机检必须至少有一边是基座数**(违反史已记四次,每次都是同一个死法:
* 那个数要模型自己重算,漏算/改小一次门就废了)。
*/
it('每条 L1/L2 都至少读一个基座读数 —— 不许两边都由活动自报', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level !== 'L1' && c.level !== 'L2') continue;
const names = (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? [];
expect(names.some((n) => rowReadings.has(n)), `${c.key} 一个基座读数都没读`).toBe(true);
}
});
it('出口载荷的标量字段都有台账列承载', () => {
const snake = (name: string) => name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
for (const port of manifest.contract.output.mandatory) {
const properties = (port.payload as { properties?: Record<string, { type?: string }> }).properties ?? {};
for (const [field, spec] of Object.entries(properties)) {
if (spec.type === 'array') continue;
expect(columns.has(snake(field)), `${port.name}.${field} → ${snake(field)}`).toBe(true);
}
}
});
it('技能引用的工具都在白名单声明内 —— 载方法不载权力', () => {
const declared = new Set(manifest.declarations.tools.map((t) => t.name));
for (const skill of manifest.assets.skills) {
for (const fragment of skill.crystallized) {
expect(declared.has(fragment.tool), `${skill.id} 用了 ${fragment.tool}`).toBe(true);
}
}
});
it('L3 判据的整句出现在放行剧本里 —— judge 才对得上名', () => {
const l3 = manifest.contract.acceptanceCriteria.find((c) => c.level === 'L3');
const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
expect(l3).toBeDefined();
expect(release?.guide, 'T_release 的 guide 要整句抄 L3 判据名').toContain(l3?.label ?? '');
});
});
/*
* 两把尺逐行求值 —— 能算的不问 LLM(规范 §5.6)。
* 出参就是台账 `disposition` 的取值:模型算完照抄,不必再翻译一道。
*/
describe('利润模型 P-01 —— 客户等级 × 类别 → 毛利档', () => {
const table = P01_DMN.tables.find((t) => t.id === 'p01-margin');
it('战略客户 + A 类 → 8%~10%(量换价那一档)', () => {
expect(decideBy(table, { customer_grade: '战略客户', type_tag: 'A' })).toMatchObject({ outputs: [0.08, 0.1] });
});
it('普通客户 + A 类 → 12%~15%', () => {
expect(decideBy(table, { customer_grade: '普通客户', type_tag: 'A' })).toMatchObject({ rule: 'p01-2' });
});
it('B 类不看客户等级 → 15%~20%(战略客户的 B 类也走这一档)', () => {
expect(decideBy(table, { customer_grade: '战略客户', type_tag: 'B' })).toMatchObject({ outputs: [0.15, 0.2] });
});
it('C 类替代/停产件 → 20%~50% 一事一议', () => {
expect(decideBy(table, { customer_grade: '普通客户', type_tag: 'C' })).toMatchObject({ rule: 'p01-4' });
});
it('等级与类别都认不出 → 兜底按普通客户标准件档,不是拒绝求值', () => {
expect(decideBy(table, {})).toMatchObject({ rule: 'p01-default', outputs: [0.12, 0.15] });
});
});
describe('底价门禁 P-02 —— 偏离底价线两成即止', () => {
const table = P01_DMN.tables.find((t) => t.id === 'p02-floor');
it('在底价线之上 → PASS 直接放行', () => {
expect(decideBy(table, { deviation: 0.12 })).toMatchObject({ outputs: ['PASS', true] });
expect(decideBy(table, { deviation: 0 })).toMatchObject({ outputs: ['PASS', true] });
});
it('低于底价线两成以内 → NOTE 附理由放行', () => {
expect(decideBy(table, { deviation: -0.08 })).toMatchObject({ outputs: ['NOTE', true] });
});
it('低于两成以上 → BLOCK 拦截,人拍板', () => {
expect(decideBy(table, { deviation: -0.25 })).toMatchObject({ outputs: ['BLOCK', false] });
});
it('三个处置码与台账取值域逐字一致 —— 模型照抄即可写回', () => {
const tally = manifest.declarations.ledger
.find((d) => d.object === 'draft')
?.fields.find((f) => f.name === 'disposition')?.tally;
expect(new Set(table?.rules.map((r) => String(r.then[0])))).toEqual(new Set(tally));
});
});
describe('两把尺的档位三处同一份 —— 判定表 / 剧本 / 工作单', () => {
const guide = manifest.assets.businessModel.steps?.find((s) => s.item === 'S1_price')?.guide ?? '';
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'pricing-worksheet')?.html ?? '';
it('毛利四档三处都写着同样的数', () => {
for (const band of ['8%~10%', '12%~15%', '15%~20%', '20%~50%']) {
expect(guide, `剧本少了 ${band}`).toContain(band);
expect(sheet, `工作单少了 ${band}`).toContain(band);
}
});
/* 门禁在人定完价之后才算得出来,所以三个码写死在底价自检那一步的 guide 里 */
it('三个处置码剧本里写死(大写,一个字母不许差)', () => {
const gate = manifest.assets.businessModel.steps?.find((s) => s.item === 'S3_gate')?.guide ?? '';
for (const code of ['PASS', 'NOTE', 'BLOCK']) expect(gate).toContain(code);
});
});
describe('报告模版 —— 活动持有,容器无脚本执行权', () => {
it('两个局面各一张:定价工作单(干活)与报价草案清单(收口)', () => {
expect(manifest.assets.viewTemplates.map((t) => t.name)).toEqual(['pricing-worksheet', 'pricing-roster']);
});
it('剧本每一步的 view 都在模版名单里 —— 换不到画面屏上就不动', () => {
const names = new Set(manifest.assets.viewTemplates.map((t) => t.name));
for (const step of manifest.assets.businessModel.steps ?? []) {
expect(names.has(step.view ?? ''), `${step.item} 的 view=${step.view}`).toBe(true);
}
});
it('放行那一步摆的是验收目标件 —— 最后摆上去的那张就是归档件', () => {
const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
expect(release?.view).toBe('pricing-roster');
});
it('模版里没有脚本、没有事件处理器、没有外链 —— 样式自带', () => {
for (const template of manifest.assets.viewTemplates) {
expect(template.html, template.name).not.toMatch(/<script|\son[a-z]+=|https?:\/\//i);
expect(template.html, template.name).toContain('<style>');
}
});
it('定价工作单是干活的件:逐颗档案 + 两把尺 + 填回工作表', () => {
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'pricing-worksheet')?.html ?? '';
expect(sheet).toContain('jc-selcard');
expect(sheet).toContain('底价线');
expect(sheet).toContain('利润模型 P-01');
expect(sheet).toContain('底价门禁 P-02');
expect(sheet).toContain('data-edit="quote_pricing.draft"');
});
it('缺价红带与提交闸同源 —— 件上摆的是基座的 `_missing` 与主键名单', () => {
for (const name of ['pricing-worksheet', 'pricing-roster']) {
const html = manifest.assets.viewTemplates.find((t) => t.name === name)?.html ?? '';
expect(html, name).toContain('quote_pricing_draft_missing');
expect(html, name).toContain('quote_pricing_draft_missing_keys');
}
});
/*
* 编辑面精确等于 BD 的职权面(定价与让价理由)+ **行的钥匙 pn**:
* 主键格不挂 `data-col`,服务端按「全表一致列」补键,两行会对到同一行上
* (A2 浏览器走查逮过,e2e 走 API 自带主键测不出来)。
*/
it('编辑面 = 拟报单价 + 让价理由 + 行的钥匙 pn', () => {
for (const name of ['pricing-worksheet', 'pricing-roster']) {
const html = manifest.assets.viewTemplates.find((t) => t.name === name)?.html ?? '';
const cols = [...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]);
expect(new Set(cols), name).toEqual(new Set(['pn', 'quoted_price', 'price_note']));
}
});
it('门禁分布读的是基座按值现数的三段 —— 守恒等式的可视形', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'pricing-roster')?.html ?? '';
for (const code of ['pass', 'note', 'block']) {
expect(roster).toContain(`quote_pricing_draft_disposition_${code}`);
}
});
/*
* A4 只有一个出口(E8 无条件边),**没有去向要判** —— 件上不摆判定表,
* 也不许残留 `case:route*` 的空壳(没有选择组它永远是空的,一屏灰行像判定没跑)。
*/
it('单出口活动:没有选择组,件上也不摆去向判定', () => {
expect(manifest.contract.output.selective).toEqual([]);
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'pricing-roster')?.html ?? '';
expect(roster).not.toContain('case:routeKey');
});
it('模版只做取值 —— data-class-of 不以 data-slot 为前提(纯色块曾整条静默失色)', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'pricing-roster')?.html ?? '';
expect(roster).toContain('<i data-class-of="disposition"></i>');
});
it('宿主拒推含脚本的模版', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
});
});
const CONTRACT = {
activityId: 'quote-pricing',
version: '1.0.0',
data: { rfqRef: 'RFQ MD14.1', rows: [] },
message: undefined,
outputPorts: { mandatory: ['draft'], selective: [] },
prohibitions: [],
discretion: [],
} as const;
describe('代码入口 —— 案例活动没有 run,不是残包', () => {
it('默认导出是一个有名字的活动定义', () => {
expect(activity.name).toBe('quote-pricing');
});
it('mock 宿主如实报「没有 run」,不静默返回一个假的 outcome', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.run(activity)).rejects.toThrow(/没有 run/);
});
});
/** 按 DMN 语义逐行求值:FIRST 命中即定,单元格谓词复用 @sfeel */
function decideBy(
table: (typeof P01_DMN)['tables'][number] | undefined,
scope: Record<string, unknown>,
): { outputs: readonly unknown[]; rule: string } | undefined {
if (table === undefined) return undefined;
for (const rule of table.rules) {
const hit = table.inputs.every((input, i) => evaluateUnaryTest(rule.when[i] ?? '-', scope[input.expression], scope));
if (hit) return { outputs: rule.then, rule: rule.id };
}
return undefined;
}
function items(xml: string): string[] {
return [...xml.matchAll(/<(?:humanTask|decisionTask|processTask|caseTask) id="([^"]+)"/g)].map((m) => m[1] ?? '');
}
function parseCmmnLite(xml: string): { manualActivation: string[]; repetition: string[] } {
const manualActivation: string[] = [];
const repetition: string[] = [];
for (const m of xml.matchAll(/<humanTask id="([^"]+)"[\s\S]*?<\/humanTask>/g)) {
if (m[0].includes('manualActivationRule')) manualActivation.push(m[1] ?? '');
if (m[0].includes('repetitionRule')) repetition.push(m[1] ?? '');
}
return { manualActivation, repetition };
}
function sentryIf(xml: string, sentryId: string): string {
const block = xml.slice(xml.indexOf(`<sentry id="${sentryId}"`));
const raw = /<condition>([\s\S]*?)<\/condition>/.exec(block.slice(0, block.indexOf('</sentry>')))?.[1] ?? '';
return raw.replaceAll('&gt;', '>').replaceAll('&lt;', '<').replaceAll('&amp;', '&');
}
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
@@ -0,0 +1,248 @@
/**
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(A1 的账)。
*/
import { describe, expect, it } from 'vitest';
import { evaluateCondition } from '@sfeel';
import { CMMN_XML, PRICING_PHASES, PRICING_STEPS } from '../src/case-model.ts';
import { manifest } from '../src/manifest.ts';
const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map(
(m) => m[1] ?? '',
);
describe('剧本与案例模型对得上 —— 对不上的条目等于没写', () => {
it('每一条剧本都指向一个真实存在的计划项', () => {
for (const step of PRICING_STEPS) {
expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
}
});
it('图上每一步都有剧本', () => {
for (const id of ITEM_IDS) {
expect(PRICING_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
}
});
it('清单里挂的就是这份剧本(不是另抄一份)', () => {
expect(manifest.assets.businessModel.steps).toBe(PRICING_STEPS);
});
});
describe('doneWhen 是「成果已存在」的判据,不是「该不该做」的判据', () => {
/** 开案那一刻的三种局面:寻源转来 / 受理直达 / 单飞 —— 台账一行没有 */
const FRESH_SOURCED: Record<string, unknown> = {
rfqRef: 'RFQ MD14.1',
rows: [{ pn: 'BBPY00201209601Y00', type_tag: 'B', base_price: 0.058 }],
};
const FRESH_INTAKE: Record<string, unknown> = {
rfqRef: 'RFQ-2026-0555',
records: [{ pn: 'PESD2ETH-XR', type_tag: 'B' }],
};
const FRESH_SOLO: Record<string, unknown> = { subject: '两颗料报个价', body: 'XCJ-7788T 8K……' };
it('**开局一步都不该被自动收掉** —— 三种开局都要 false', () => {
for (const fresh of [FRESH_SOURCED, FRESH_INTAKE, FRESH_SOLO]) {
for (const step of PRICING_STEPS) {
if (step.doneWhen === undefined) continue;
expect(
safeEval(step.doneWhen, fresh),
`${step.item} 的 doneWhen「${step.doneWhen}」开局就为真`,
).toBe(false);
}
}
});
/* ── 算价那一步 ─────────────────────────────────────────────── */
const priced = (over: Record<string, unknown>): Record<string, unknown> => ({
line_total: 2,
quote_pricing_draft_rows: 2,
priced_total: 2,
...over,
});
it('整份落表、行数对上、算价那一轮走完 → 收得掉', () => {
expect(safeEval(doneOf('S1_price'), priced({}))).toBe(true);
});
it('**全是无基准的单子也收得掉** —— 一条都算不出时 priced_total 是写过的 0', () => {
expect(safeEval(doneOf('S1_price'), priced({ priced_total: 0 }))).toBe(true);
});
it('**第一拍写完行就收步是不许的** —— priced_total 没写过,未绑定的名字比大小为假', () => {
const scope = priced({});
delete scope['priced_total'];
expect(safeEval(doneOf('S1_price'), scope)).toBe(false);
});
it('行数两边对不上 → 收不掉(漏落一行当场现形,不等到第 10 拍)', () => {
expect(safeEval(doneOf('S1_price'), priced({ quote_pricing_draft_rows: 1 }))).toBe(false);
});
/* ── 三处不给 doneWhen ──────────────────────────────────────── */
it('**交人定价、超限拍板、放行三项都不给 doneWhen** —— 给了等于给引擎一把替人签字的钥匙', () => {
for (const item of ['S2_review', 'T_approve', 'T_release']) {
expect(PRICING_STEPS.find((s) => s.item === item)?.doneWhen, `${item} 不该有 doneWhen`).toBeUndefined();
}
});
/* ── 底价自检:判完了才算办完(收步判据与验收 L2 同源)────────────── */
const gated = (over: Record<string, unknown>): Record<string, unknown> => ({
quote_pricing_draft_rows: 3,
quote_pricing_draft_disposition_pass: 2,
quote_pricing_draft_disposition_note: 1,
quote_pricing_draft_disposition_block: 0,
...over,
});
it('三档处置的条数加起来等于行数 → 底价自检收得掉', () => {
expect(safeEval(doneOf('S3_gate'), gated({}))).toBe(true);
});
it('**有拦截档也照样收得掉** —— 判出来了就是判完了,拦截档由下一步叫人拍板', () => {
expect(
safeEval(doneOf('S3_gate'), gated({ quote_pricing_draft_disposition_note: 0, quote_pricing_draft_disposition_block: 1 })),
).toBe(true);
});
it('还有行没判 → 收不掉(松出来的那一截就是放行门前的返工)', () => {
expect(safeEval(doneOf('S3_gate'), gated({ quote_pricing_draft_disposition_note: 0 }))).toBe(false);
});
it('**一行都还没写时 0+0+0 = 0 不算判完** —— 全零相等那一坑,靠 rows > 0 挡住', () => {
expect(
safeEval(doneOf('S3_gate'), {
quote_pricing_draft_rows: 0,
quote_pricing_draft_disposition_pass: 0,
quote_pricing_draft_disposition_note: 0,
quote_pricing_draft_disposition_block: 0,
}),
).toBe(false);
});
it('归一那一步声明了 reads: rows 与 records —— 上游载荷置前不截断', () => {
const reads = PRICING_STEPS.find((s) => s.item === 'S1_price')?.reads ?? [];
expect(reads).toContain('rows');
expect(reads).toContain('records');
});
/*
* **一单同一张卡只发一次**,且缺信息不再走补全卡(2026-08-02 全局退役)——
* 剧本里不许残留「还缺就再发一张卡」这条已经不存在的路。
*/
it('剧本不教补全卡,也不留「还缺就再发一张」的死路', () => {
for (const step of PRICING_STEPS) {
expect(step.guide, `${step.item} 的 guide 提到了补全卡`).not.toMatch(/补全卡|"kind":"ask"/);
expect(step.guide, `${step.item} 的 guide 教了再发一张卡`).not.toMatch(/再发一张卡|重新发一张卡/);
}
});
/*
* **一单只发一张卡**:人点完之后这一步会被重新念一遍,而 guide 的头一段说什么,
* 模型就先做什么 —— 头一段若是「发一张决策卡」,它就再发一张(实测连发四张,
* 调人预算烧光 exhausted)。所以拍板那一步的 guide 必须**先分岔再发卡**。
*/
it('拍板那一步先问「人点过没有」,再谈发卡 —— 分岔要排在发卡骨架前面', () => {
const guide = PRICING_STEPS.find((s) => s.item === 'T_approve')?.guide ?? '';
const branch = guide.indexOf('已经有');
const skeleton = guide.indexOf('【发一张决策卡');
expect(branch, 'T_approve 的 guide 没有「人点过没有」这一岔').toBeGreaterThanOrEqual(0);
expect(skeleton, 'T_approve 的 guide 没有发卡骨架').toBeGreaterThan(branch);
expect(guide).toMatch(/不许再发|别再发第二张卡/);
});
/*
* start 墙已拆(2026-08-02):动它就算启动。剧本里再教 start 只是白烧 token,
* 而且学不稳的礼仪就是死法。
*/
it('剧本不教 start 礼仪 —— 那道墙已经拆了(说「不必单发 start」可以,教它先按一下不行)', () => {
for (const step of PRICING_STEPS) {
expect(step.guide, `${step.item} 的 guide 还在教 start`).not.toMatch(/先\s*start|务必\s*start|要先发\s*start/);
}
});
});
describe('剧本声明的东西活动真有', () => {
const declaredLedger = manifest.declarations.ledger.map((l) => `${l.domain}.${l.object}`);
const declaredTools = manifest.declarations.tools.map((t) => t.name);
it('每一步要写的台账都在清单的台账声明里', () => {
for (const step of PRICING_STEPS) {
for (const address of step.writes) {
expect(declaredLedger, `${step.item} 要写 ${address}`).toContain(address);
}
}
});
it('每一步要调的工具都在清单的工具声明里', () => {
for (const step of PRICING_STEPS) {
for (const tool of step.tools) {
expect(declaredTools, `${step.item} 要调 ${tool}`).toContain(tool);
}
}
});
it('每一步都定死了摆哪张件,且模版真在包里', () => {
const names = new Set(manifest.assets.viewTemplates.map((t) => t.name));
for (const step of PRICING_STEPS) {
expect(step.view, `${step.item} 没定画面`).toBeDefined();
expect(names.has(step.view ?? ''), `${step.item} 的 view「${step.view}」不在模版名单里`).toBe(true);
}
});
it('每一步都说得出它交出什么', () => {
for (const step of PRICING_STEPS) {
expect(step.intent.trim(), `${step.item} 没写 intent`).not.toBe('');
expect(step.guide.trim().length, `${step.item} 的 guide 太短`).toBeGreaterThan(20);
}
});
/* 卡面是纯文本:星号记号不会变粗体,只会原样摆在人眼前(规范 §5.5-10) */
it('发卡骨架里不写星号记号 —— 卡面是纯文本', () => {
for (const step of PRICING_STEPS) {
for (const line of step.guide.split('\n')) {
if (!/"steps"|"standard"|"ask"|"label"|"consequence"/.test(line)) continue;
expect(line.includes('**'), `${step.item} 的卡面文案带了星号:${line.slice(0, 60)}`).toBe(false);
}
}
});
});
describe('阶段刻度:每一项都有格可待', () => {
const inPhase = new Set(PRICING_PHASES.flatMap((p) => p.items));
it('**除放行外每一项恰好属于一个阶段**', () => {
const planned = PRICING_STEPS.map((s) => s.item).filter((id) => id !== 'T_release');
expect(planned.filter((id) => !inPhase.has(id))).toEqual([]);
});
it('一项不进两个阶段', () => {
const all = PRICING_PHASES.flatMap((p) => p.items);
expect(all.length).toBe(new Set(all).size);
});
it('**放行不在名单里** —— 收口那一格由基座统一加', () => {
expect(inPhase.has('T_release')).toBe(false);
});
it('阶段里不许出现案例模型没有的项', () => {
const ids = new Set(PRICING_STEPS.map((s) => s.item));
expect([...inPhase].filter((id) => !ids.has(id))).toEqual([]);
});
});
function doneOf(item: string): string {
const found = PRICING_STEPS.find((s) => s.item === item)?.doneWhen;
if (found === undefined) throw new Error(`${item} 没有 doneWhen`);
return found;
}
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
+330 -80
View File
@@ -1,137 +1,387 @@
/** /**
* A4 成本定价的案例模型 —— **算价归判定表,超限那一下归人**(2026-07-31 照 A1 的状态机模式重做)。 * A4 成本定价的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
* *
* 与 A2/A3 的分工:那两个是「一次专家判断」,派一张执行单收回结论就够了; * 业务出处:`docs/MVP示例工作流.html` §1.2(S1 取价基准 → S2 逐条定价 → S3 底价自检),
* 定价不同 —— 它**有确定性规则可依**(P-01 利润模型、P-02 底价门禁两支 DMN), * 按 A3 同一条形状建模(2026-08-03 重写,@3.0.0):
* 算价这件事该算不该问。所以它照 A1 的样子分步建模,而不是甩一张单给人。
* *
* 三项: * 上游(A3 的目标报价清单 / A2 的询价受理清单 / A1 直达的受理记录)进来 → 归一成
* T_price —— 逐条算价并落草案(拟报单价 / 毛利 / 偏离度 / 门禁处置); * **报价草案**底表,逐条按 P-01 利润模型算出底价线与拟报单价、过 P-02 底价门禁 →
* T_approve —— **只在门禁判超限时入场**:触达客户利益的一下,人工激活,派决策卡; * **一张执行单**交 BD 核价(拟报单价可校可改,无基准的那几行由他定价)→
* T_release —— 放行(人工激活)。 * **只有压在拦截档上的那几条才叫人拍板**(决策卡)→ 放行,整份报价草案交定价审批。
* *
* 「超限才叫人」是这个活动的要害:每条都叫人,人就成了橡皮图章;一条都不叫, * 上一版(@2.0.0)三处不合现行口径,这一版整个置换:
* 底价门禁就是摆设。所以那一项挂前提(`s_over`),由判定表的结论决定它入不入场。 * ① 入参必填 `rfqRef + customerCode + records` —— 而 A3 发的是 `{rfqRef, rows}`,
* 门口拦的恰是主路(现在宽进到底,归一在活动内做);
* ② 台账一个 `required` 都没有 —— 执行卡的闸没牙,人不填也能交卷;
* ③ 入场哨兵 `s_priced` 读活动自报的 `draft_total`(「机检只读基座数」第 5 类违反)——
* 模型不记这个账,门就永不开,而它一步也没做错。
*/ */
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?> export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL"> <definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
<case id="pricing"> <case id="pricing">
<!-- <!--
案卷项的 id **就是任务视图里的键名**: 案卷项的 id **就是任务视图里的键名**。上游载荷(rows / records)与本活动台账列
draft_total 是草案条数(算完没算完),over_total 是超限条数(要不要叫人)。 都摊平到任务视图顶层,照它们命名;quote_pricing_* 那几个是**基座读数**
两个都由 T_price 写进台账,摊平到顶层后驱动前提。 (行数 / 缺必填 / 按值现数),由基座现算,模型碰不着。
--> -->
<caseFileModel> <caseFileModel>
<caseFileItem id="pn" name="型号(开案即有)"/> <caseFileItem id="rows" name="上游清单行(寻源/选型转来,流内开案即有)"/>
<caseFileItem id="draft_total" name="草案条数"/> <caseFileItem id="records" name="上游受理记录(受理直达时有)"/>
<caseFileItem id="over_total" 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> </caseFileModel>
<casePlanModel id="plan" name="成本定价" autoComplete="true"> <casePlanModel id="plan" name="成本定价" autoComplete="true">
<!--
① 算完了 —— 交 BD 核价那一步的入场前提。
行数两边对上(自报 = 基座现数),且算价那一轮走完(priced_total 是算完之后
显式写下的数;「大于等于 0」对「写过的 0」为真、对「没写过」为假)。
-->
<sentry id="s_priced"> <sentry id="s_priced">
<onPart id="op_pr" caseFileItemRef="draft_total"><standardEvent>update</standardEvent></onPart> <onPart id="op_pr" caseFileItemRef="priced_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_pr"><condition>draft_total &gt; 0</condition></ifPart> <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>
<!-- 超限才叫人:门禁判出有超限条目,这一项才入场 --> <!--
② 人定完价了 —— 底价自检那一步的入场前提。
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"> <sentry id="s_over">
<onPart id="op_ov" caseFileItemRef="over_total"><standardEvent>update</standardEvent></onPart> <onPart id="op_ov" caseFileItemRef="quote_pricing_draft_disposition_block"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_ov"><condition>over_total &gt; 0</condition></ifPart> <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> </sentry>
<!-- 算价:判定表说了算,不是临场判 --> <!-- ① 归一草案 + 取基准 + 逐条算价过门禁(能算的不问人) -->
<humanTask id="T_price" name="逐条算价:取基准、算毛利与偏离度、过底价门禁"> <humanTask id="S1_price" name="归一报价草案:取成本基准、按利润模型算底价线与建议价">
<itemControl><repetitionRule id="r_pr"/></itemControl> <itemControl><repetitionRule id="r_s1"/></itemControl>
</humanTask> </humanTask>
<!-- 超限审批:触达客户利益的一下,必须人工激活 --> <!--
<humanTask id="T_approve" name="超限条目决策:低于底价的那几条要人拍板"> ② 交 BD 核价 —— **一张执行单**(拟报价可校可改,无基准的行由他定价)。
manualActivation + 不给 doneWhen(发卡触达真人,引擎不替人按)。
-->
<humanTask id="S2_review" name="交BD定价:工作单上逐条定价,收回后过底价门禁">
<entryCriterion id="ec_s2" sentryRef="s_priced"/>
<itemControl><manualActivationRule id="m_s2"/></itemControl>
</humanTask>
<!--
③ 底价自检 —— SOP 里的 S3,一件独立的事:按人定的价算毛利、偏离度、门禁处置。
它不触达世界,所以给 doneWhen(三档处置的条数加起来等于行数就算判完了),
引擎自己收 —— 从前这一段藏在「交人核价」的收尾里,模型写完 confirmed_total
就把那一项收了,三列处置一格没写,放行门当场判「处置没落定」,而拒回只会说
「数不对/行不对」—— 两扇打不开的门(实测 8 次拒回熔断)。
-->
<humanTask id="S3_gate" name="底价自检:按定下的价算毛利与偏离度,逐条过底价门禁">
<entryCriterion id="ec_s3" sentryRef="s_confirmed"/>
<itemControl><repetitionRule id="r_s3"/></itemControl>
</humanTask>
<!-- ④ 超限拍板:触达客户利益的那一下,必须人工激活;只在拦截档还有条目时入场 -->
<humanTask id="T_approve" name="超限拍板:压在拦截档的条目要人定怎么办">
<entryCriterion id="ec_ap" sentryRef="s_over"/> <entryCriterion id="ec_ap" sentryRef="s_over"/>
<itemControl><manualActivationRule id="m_ap"/><repetitionRule id="r_ap"/></itemControl> <itemControl><manualActivationRule id="m_ap"/><repetitionRule id="r_ap"/></itemControl>
</humanTask> </humanTask>
<!-- 放行:触达世界,必须人工激活 --> <!-- 草案放行:终局唯放行 —— 触达世界,必须人工激活 -->
<humanTask id="T_release" name="报价草案验收放行"> <humanTask id="T_release" name="报价草案验收放行">
<entryCriterion id="ec_rel" sentryRef="s_priced"/> <entryCriterion id="ec_rel" sentryRef="s_ready"/>
<itemControl><manualActivationRule id="m_rl"/></itemControl> <itemControl><manualActivationRule id="m_rl"/></itemControl>
</humanTask> </humanTask>
</casePlanModel> </casePlanModel>
</case> </case>
</definitions>`; </definitions>`;
export const NARRATIVE = `§0 开案:拿到一批待定价条目即开案。成品是**一份全条目带偏离度的报价草案** —— /**
人签的不是一个数字,是**这个数字的来路**:基准从哪来、毛利怎么算、偏离多少、门禁怎么判。 * 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本,两处不重复写同一件事)。
*/
export const NARRATIVE = `§0 开案:**一批料要定出各自的拟报单价**。来路三种,形状都收(宽进):
· **寻源转来**:任务数据里有 \`rows\` 数组 —— 目标报价清单的落地行,每行带底价(base_price)
与价源;这是最全的一种来路;
· **受理/选型直达**:有 \`records\` 或 \`rows\` 而行上没有底价 —— 只有料号与年用量;
· **单飞**:只有一段需求文字,列了几颗料要报价。
**能算的不问人**:P-01(利润模型)与 P-02(底价门禁)两支判定表说了算,你不临场拍价。 **成品只有一样**:一份**报价草案** —— 整份行都在、**每一行都有拟报单价**,
你的活是把判据取齐、把结论落进台账;**只有超限的那几条才叫人**。 而且每个价都说得出来路:基准从哪来、毛利档是哪一档、偏离底价线多少、门禁怎么判。
人签的不是一个数字,是**这个数字的来路**。它是定价审批的直接输入。
【T_price 逐条算价】基准价取自历史价情报(A3 的比价结论 / CRM 历史价)。新料号无历史时 **能算的不问人,该人签的不代签**:P-01(利润模型)给毛利档、P-02(底价门禁)给处置,
基准记 0,由底价门禁按「无基准」处置 —— **不凭空编一个偏离率出来**。 两支判定表说了算,你不临场拍价;而**拟报单价那一列是 BD 定的,你一格都不写** ——
逐条写进 \`quote_pricing.draft\`:拟报单价 / 目标毛利 / 相对基准偏离 / 门禁处置。 你给的是**建议价**,他确认或改成自己的数。你的活是把判据取齐、把结论落进台账。
同一拍接着把 draft_total(条数)与 over_total(门禁判「超限」的条数)写上 ——
这两个数一个是完成判据,一个决定要不要叫人。写完 finish。
【T_approve 超限决策】**只在 over_total > 0 时走这一步**。它触达客户利益, **四步走**:归一取基准算建议价 → 交 BD 定价 → 底价自检(按他定的价算毛利、偏离度、
所以标了人工激活:**先 start,再发决策卡**(decide,itemId 填 T_approve)。 门禁处置)→ 定盘放行。
选项按门禁的处置给(照原价报 / 按底价报 / 退回重算),consequence 写清各自的后果;
basis 摆上是哪几条、偏离多少、底价是多少(裸卡基座拒发)。
人点完,轨迹上会出现「decision 卡回传 —— 人选了:xxx」—— **看到它就照那条路把 draft 改掉**,
别再发第二张卡。改完 finish。
【提交放行】草案有行了 —— **先 start T_release**(人工激活),然后 submit,BD 验收。 **这一单要经过人一到两次**:BD 在定价工作单上逐条定价(那张单绑着整份草案,
提交前把两条裁量判据裁掉(judge,criterion 照判据名原样写);终局唯放行,你不能自己宣布完成。 拟报单价缺一行按不下提交 —— 基座的闸看着,他填的格子直接落账);
**只有还压在拦截档上的条目才另外叫人拍板**(一张决策卡)。每条都叫人,人就成了橡皮图章;
一条都不叫,底价门禁就是摆设。最后 BD 放行签字。
【一路上要说话】每一拍带一句 say,说**业务的话**:这批料算下来什么价、哪几条压线、 【一路上要说话】负责人正看着这一单。**每一拍都带一句 say** —— 说业务的话:
要不要找人拍板。一两句、口语。别报「写草稿 N 行」这种系统词。`; 这批料算下来什么价、哪几条压线、要不要找人拍板;不说系统词。
到了要找人的那一拍,先说清这一轮办完了什么、为什么现在需要他。
【怎么把一项干完 · 一拍连做】write 和 tool 都不会自动收步 —— 做完用 finish 宣告。
动作排进同一拍的 actions 串一口气给;只有调工具(出参下一拍才看得见)与
发卡 / 提交(挂起)必须停拍。**这一单常路 7-9 拍就该收口**。
§Z 结案:BD 在验收单上签字,系统把草稿合并成正式草案并发出 draft 口的载荷 ——
整份报价草案交给定价审批。`;
/** /**
* 步骤剧本 —— 与 A1 同一套:`doneWhen` 为真,引擎自己收步、**不问模型**。 * 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。
* *
* 两处不给 `doneWhen`:`T_approve`(人工激活,要人拍板)与 `T_release`(触达世界)。 * 三处不给 `doneWhen`:`S2_review`(发卡触达真人)、`T_approve`(要人拍板)、
* 给了就等于给引擎一把替人签字的钥匙。 * `T_release`(签下去触达世界)—— 给了等于给引擎一把替人签字的钥匙。
*/ */
export const PRICING_STEPS = [ export const PRICING_STEPS = [
{ {
item: 'T_price', item: 'S1_price',
intent: '逐条算价并落草案:拟报单价 / 毛利 / 偏离度 / 门禁处置,并结算条数与超限数', intent: '整份归一成报价草案:取成本基准 → 按利润模型算出底价线与建议价,逐条落表',
writes: ['quote_pricing.draft', 'quote_pricing.verdict'], writes: ['quote_pricing.draft', 'quote_pricing.verdict'],
reads: [], /* 上游载荷数组不声明就会被整表段的截断刀砍掉(砍掉的恰是要归一的行) */
reads: ['rows', 'records'],
view: 'pricing-worksheet',
tools: ['crm.history-price-read'],
/*
* 归一 + 算价两轮都走完才算办完:行数两边对上(一边是基座现数),
* 且 priced_total 写过(全是无基准的单子这个数就是 0;S-FEEL 未绑定的名字
* 参与比较为假,所以「没写过」进不来)。
*/
doneWhen: 'line_total > 0 and quote_pricing_draft_rows = line_total and priced_total >= 0',
guide: `第一件事:把这一单的**报价草案**落出来,并把能算的价都算出来。
【料从哪儿来】
· 寻源转来(\`rows\` 里的行带 base_price / price_basis):照抄 —— 底价就是成本基准,
一个数字都别改;带 quote_line_total 这类总数的是结论行,不是料,别落进草案。
· 受理/选型直达(\`records\` 或没有底价的 \`rows\`):料号、品类、制造商、年用量、
客户目标价、类别(type_tag)照抄,**底价一栏留空**。
· 单飞:照正文归一,列了几颗落几行;没说类别的按 B。
**按 pn 归并,一颗料一行**;pn 一个字符不许改(它是主键,改一个字就是新开一行)。
【第一拍:只做两件事 —— 落草案行 + 串尾发一次查价】
① 写 \`quote_pricing.draft\`,一颗料一行(rows 收数组):
rfq_ref(入参有就原样抄;没有从主题认;认不出才自己造一个,**造出来的必须以 \`PRC-\` 开头**
(\`PRC-<主题或第一颗料的型号>\`)。两条硬规矩:
**① 整单只有一个单号,不是一颗料一个** —— 它是这一单的案号,草案所有行、verdict 那一行
用的都得是同一个串(实测栽过:两颗料造出两个号,一单裂成两单);
**② 只从本单来件取材** —— 台账里躺着的 RFQ-xxxx 是别人的单,借它的号等于把两单的行
混进同一个案卷(实测栽过一轮))、pn、customer_pn、category、
brand、eau、target_price(客户目标价)、lead_time、remark(客户约束/急料,一个字别丢)、
type_tag(照上游现标;单飞按 B)、base_price(上游给了就抄,没有留空)。
suggested_price / margin_band / floor_price **这一拍全部留空** —— 基准还没取齐。
**quoted_price / margin / deviation / disposition 这四列从头到尾都不是你写的**(见下)。
② 串尾调 \`crm.history-price-read\`:pns 收数组,把**底价还空着的那几颗料**一次传完
(上游已经给了底价的不必查)。
**这一拍不写 \`quote_pricing.verdict\`,一列都不写** —— 尤其 priced_total:
它是「算价走完了」的凭据,提前写等于把闸提前打开,引擎会在下一拍开头当场收掉这一步,
**算价从此不会发生**(A3 实测栽过:嘴上说预填了,台账里没有)。
【**这一列你一格都不写**:quoted_price】
拟报单价是 **BD 在工作单上定的那个数** —— 人签的就是它。你给的是**建议价**
(suggested_price),他确认或改成自己的数;margin / deviation / disposition 三列
要等他定完价才算得出来,这一步也别写。
这不是客气话:从前只有一列价、剧本写着「算不出的留空」,实测跑单时两颗库里查不到的料
照样被编出了价(一颗抄客户目标价、一颗凭空写了个数),而交代里还说着「价空着等你定」。
**一条要你分辨「这行算得出/算不出」的规矩会走岔;「这一列不写」没有分支可走岔。**
【第二拍:算建议价 + 记账 + 收步,一拍连做】
① 补基准:**只补上一拍 base_price 空着的那几行** —— 工具出参里 hit=true 的写
base_price = lastPrice、price_basis 写 **HIST**;hit=false 的**别编价**,base_price 留空,
它就是「无基准」那一档。
**上游已经给了 base_price 的行:工具回什么都不采用,那一格一个数字都别改** ——
上游给的是本单问回来的货源底价,工具给的是库内历史价;这一单的成本是前者
(实测栽过:两行上游底价被历史价盖掉,毛利从 20% 变成 115%,数字全对而结论是假的)。
② 逐条算**建议价**,规矩写死,照做即可(**这些是算术,不是判断**):
· 毛利档 margin_band 按利润模型 P-01 取(客户等级 × 类别):
战略客户 + A 类(大批量长单)→ 8%~10%;普通客户 + A 类 → 12%~15%;
B 类 → 15%~20%;C 类 → 20%~50%;拿不准按 12%~15%。写成「12%~15%」这样的文本。
· **底价线** floor_price = base_price ×(1 + 档下限)。保留四位小数。
· **建议价** suggested_price:
- 客户没给目标价(target_price 空)→ 取 base_price ×(1 + 档上限);
- 客户目标价 ≥ 底价线 → 就取客户目标价(接得住,毛利还在档内);
- 客户目标价 < 底价线 → 仍取客户目标价(贴着客户报),这一条到门禁那儿会亮灯;
- **base_price 空(无基准)→ margin_band / floor_price / suggested_price 三列一起留空**。
不要拿一个不相干的价去凑一个数出来 —— 那比没有建议更危险,它看起来像有依据。
· **「没有」要给空串 \`""\`,不许写 0** —— 0 是一个数,底价线写 0 看起来像「成本为零」,
偏离度写 0 看起来像「正好压在底价线上」。空串基座会当成「这一列不写」。
③ 这时才写 \`quote_pricing.verdict\`(一次写全):rfq_ref、customer_code / customer_name
(顶层有就抄)、customer_grade(来件认得出就写,认不出写「普通客户」)、
**line_total** = 草案行数、**basis_total** = 有成本基准的条数、
**priced_total** = 这一拍算出建议价的条数(0 也要写)。
④ finish 这一项。`,
},
{
item: 'S2_review',
intent: '一张执行单交 BD:工作单上逐条定价(拟报单价归他);收回后过底价门禁并定盘',
writes: ['quote_pricing.draft', 'quote_pricing.verdict'],
reads: ['quote_pricing.draft'],
view: 'pricing-worksheet',
tools: [], tools: [],
// 草案有行了才算算完。不写 `!= null` —— 未绑定的名字那样求值为 true(A1 踩过) /* 不给 doneWhen —— 发卡触达真人(manualActivation),引擎不替人按 */
doneWhen: 'draft_total > 0', guide: `建议价算完了,**该 BD 出场了** —— 这是本活动唯一的一张执行单,
guide: `基准价取自历史价情报(A3 的比价结论 / CRM 历史价)。新料号无历史时基准记 0, **拟报单价是他定的,不是你定的**。工作区摆的是**定价工作单**:逐颗料一张定价档案卡
由底价门禁按「无基准」处置 —— **不凭空编一个偏离率**。 (成本基准、毛利档、客户目标价、底价线、系统建议价),利润模型与底价门禁两把尺挂在上面,
逐条写进 \`quote_pricing.draft\`:拟报单价 / 目标毛利 / 相对基准偏离 / 门禁处置。 填回区长在同一屏 —— 他在「拟报单价」那一列逐行填(照建议价确认,或改成自己的数)。
**同一拍接着**把 draft_total 与 over_total(门禁判超限的条数)写上 ——
前者是完成判据,后者决定要不要叫人。 这一步标了人工激活:发卡就算启动,不必单发 start;引擎也不会替你收 —— 人交回后要你自己 finish。
**同一拍再写一行 \`quote_pricing.verdict\`**(验收机检读的就是它):
· draft_total / unpriced_total = 草案条数 / 还没定出价的条数(后者要 = 0); 【照这个骨架发,别少字段】(卡面是纯文本,steps/standard 里**别写星号记号**):
· over_total = 门禁判超限的条数; {"kind":"human","itemId":"S2_review","workOrder":"execution","card":{
· unresolved_over_total = 超限里**还没处置**的条数(要 = 0 —— 回调了或注明了理由都算已处置); "title":"报价定价:<单号> 共 <N> 条待你定价",
· summary 一句人话结论。写不对 submit 会被门挡回来。三笔写完再 finish。 "ask":"系统已按成本基准与利润模型算出每一颗的建议价,请在工作单的「拟报单价」一列逐行定价:认可建议价就照抄,不认可就改成你的数;库内没有成本基准的那几行没有建议价,由你定。",
**每一行都要带 \`rfq_ref\`** —— 它是这一单的单标识(起单入参里就有,原样抄进去)。 "steps":["拟报单价每一行都要有 —— 缺一行这张单按不下提交;它是这一单最后签下去的那个数,系统不替你填",
台账主键第一列就是它:不写,这一行会和**别的单**同料号的那一行撞主键,把人家覆盖掉 "建议价是从成本基准按利润模型的毛利档推出来的:照抄就是认可这个毛利,改高改低都可以,改了在备注里写一句缘由",
(实测栽过:A4 的报价草案主键只有 pn,跑四轮只剩最后一轮的 3 行)。`, "低于底价线的价会被底价门禁标出来:两成以内附理由即可放行,超过两成的要另外找人拍板 —— 你在这里往上调一点,就不必再惊动一个人",
"库内查不到成本的料没有建议价:按你的经验定价,并在备注写明依据;实在定不了的按行首 × 把这一条撤出本轮"],
"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', item: 'T_approve',
intent: '超限条目要人拍板 —— 发决策卡,照人选的那条路改草案', intent: '压在拦截档的条目要人拍板 —— 发一张决策卡,照他选的那条路把草案改掉',
writes: ['quote_pricing.draft'], writes: ['quote_pricing.draft', 'quote_pricing.verdict'],
reads: [], reads: ['quote_pricing.draft'],
view: 'pricing-roster',
tools: [], tools: [],
// 人工激活 + 要人拍板 —— 不给 doneWhen,引擎不许替人收 /* 人工激活 + 要人拍板 —— 不给 doneWhen,引擎不许替人收 */
guide: `**只在 over_total > 0 时走这一步**。它触达客户利益,标了人工激活: guide: `**这一单只发一张决策卡。动手之前先分清你在哪一半:**
**先 start T_approve**(与发卡排同一拍即可),再发决策卡(decide,itemId 填 T_approve)。
选项按门禁处置给(照原价报 / 按底价报 / 退回重算),consequence 写清后果; · 轨迹里**已经有**「decision 卡回传」/「人选了:xxx」—— 那就跳到下面【人点完之后】那一段,
basis 摆上哪几条、偏离多少、底价多少(**裸卡基座拒发**)。 照他选的那条路改草案、写 route_decided_total、finish。**一个 decide 都不许再发**
人点完轨迹上会出现「decision 卡回传 —— 人选了:xxx」,**照那条路改 draft,别再发第二张卡**。`, (实测栽过:人点完了,而这一步又从头念了一遍「发一张决策卡」,同一张卡连发四次,
调人预算烧光,单子 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', item: 'T_release',
intent: '草案已齐,提交放行交 BD 验收', intent: '草案已定盘,提交放行交 BD 验收',
writes: [], writes: [],
reads: [], reads: [],
view: 'pricing-roster',
tools: [], tools: [],
guide: `草案有行了 —— **先 start T_release**(人工激活),提交前用 judge 裁掉裁量判据 /* 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 doneWhen) */
(criterion 照判据名原样写),然后 submit。终局唯放行:你不能自己宣布完成。`, guide: `每行有价、门禁处置守恒、拦截档清零、定盘已记 —— 可以走放行了
(T_release 人工激活:动它就算启动,不必单发 start)。先把裁量判据裁掉,然后 submit,同一拍连做:
· judge criterion="报价的来路说得清:基准价来源、毛利档与让价理由逐条可溯,BD 对报价负责" ——
看草案里每一行的基准、毛利档与备注:说得出来路就 satisfied=true;
有行说不出的(比如无基准由人定的价没写缘由),satisfied=**false**,rationale 逐行写清,
交签字的人定。**裁成不满足照样能提交** —— 理由连着卡一起交给他。
criterion 照判据名**整句原样抄**,写别的名字对不上。
终局唯放行:你不能自己宣布完成。`,
}, },
]; ];
/**
* 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段;
* 放行那一格由基座统一加,人看到的是 x/4。
*/
export const PRICING_PHASES = [
{ name: '取基准算建议价', items: ['S1_price'] },
{ name: '交BD定价', items: ['S2_review'] },
{ name: '底价自检', items: ['S3_gate'] },
{ name: '超限拍板', items: ['T_approve'] },
];
+12 -74
View File
@@ -1,79 +1,17 @@
/** /**
* 统一报价 A4 · 成本定价 —— 活动包代码入口(ADR-004)。 * 统一报价 A4 · 成本定价 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
* *
* flow 活动:下一步由步序声明给定。本文件只做声明装不下的两件事 —— * **这个活动没有 `run`,不是缺件。**
* 视图流控,与「结论不可静默覆写」的呈现:P-02 拦下的条目必须显式呈现给人。 *
* 案例活动的推进由 Loop 驱动,行为全部写在清单里:案例模型 + 说明书 + 步骤剧本 +
* 两把判定表 + 两条技能 + 台账声明 + 两张 HTML 报告模版。
* 上一版这里有一段 `run()`(读草案、拼 kv/table 块、按拦下条数返回 await/submit)——
* 案例活动根本不会调它;摆一个空转的 run 去假装有实现,更坏:
* 它写的那套呈现与 `viewTemplates` 里的模版各说各话,而没人看得出以哪份为准。
*
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 —— 它是**被静态审扫描的产物文本**
* (查 import 与全局引用,证明活动包零环境能力)。
*/ */
import { defineActivity } from '@sdk'; import { defineActivity } from '@sdk';
import type { ActivityBase, ActivityOutcome } from '@sdk';
const DRAFT = 'quote_pricing.draft'; export default defineActivity({ name: 'quote-pricing' });
export default defineActivity({
name: 'quote-pricing',
async run(base: ActivityBase): Promise<ActivityOutcome> {
const items = await base.ledger.read(DRAFT);
const priced = items.filter((r) => typeof r['quoted_price'] === 'number');
// 底价门禁 P-02 拦下的条目 —— DMN 结论是**约束性输入**,不得静默覆写
const blocked = items.filter((r) => String(r['disposition'] ?? '').includes('拦截'));
await base.interaction.pushView([
{
t: 'kv',
title: '定价概览',
items: [
{ k: '条目', v: `${items.length} 条` },
{ k: '已定价', v: `${priced.length} 条` },
{ k: '底价门禁拦下', v: `${blocked.length} 条` },
],
},
{
t: 'table',
title: '报价草案',
cols: ['PN', '拟报单价', '毛利率', '偏离度', '门禁处置'],
rows: items.map((r) => [
String(r['pn'] ?? ''),
fmt(r['quoted_price']),
pct(r['margin']),
pct(r['deviation']),
String(r['disposition'] ?? '待判'),
]),
linkDonut: {
col: 4,
title: '门禁分布',
map: [
{ match: '直接放行', label: '直接放行', tone: 'ok' },
{ match: 'PM', label: 'PM 附理由', tone: 'warn' },
{ match: '拦截', label: '拦截', tone: 'danger' },
],
},
},
]);
if (priced.length < items.length) {
await base.interaction.say(`还有 ${items.length - priced.length} 条未定价,继续按利润模型 P-01 求值。`, 'conclusion');
return { kind: 'await', reason: '等待定价步完成' };
}
if (blocked.length > 0) {
// 超出授权边界 → 走「人 · 决策」,不自行破线
await base.interaction.say(
`${blocked.length} 条低于底价 20% 以上,已被底价门禁 P-02 拦下 —— 需要人裁决回调还是注明理由。`,
'criterion',
);
return { kind: 'await', reason: '等待超限条目的人裁决' };
}
await base.interaction.say(`${items.length} 条已全部定价且过底价门禁,可提交审批。`, 'criterion');
return { kind: 'submit', deliverable: 'quote-draft', rationale: '全条目带偏离度,底价门禁全过' };
},
});
function fmt(value: unknown): string {
return typeof value === 'number' ? value.toFixed(2) : '—';
}
function pct(value: unknown): string {
return typeof value === 'number' ? `${(value * 100).toFixed(1)}%` : '—';
}
+313 -85
View File
@@ -1,27 +1,45 @@
/** /**
* 活动包清单 —— 统一报价 A4 · 成本定价。 * 活动包清单 —— 统一报价 A4 · 成本定价(案例活动)。
* *
* 业务素材:docs/MVP示例工作流.html §1.2(S1 取价基准 → S2 逐条定价 → S3 底价自检)。 * 业务素材:`docs/MVP示例工作流.html` §1.2(S1 取价基准 → S2 逐条定价 → S3 底价自检):
* README《已裁定的落地决策》③:**A4 = flow** —— 取价、查表定价、自检的判断 * 「BD 逐条定价:价格 = 底价 × 利润模型。提交审批前先自查底价门禁 ——
* **全部装进 DMN 决策表确定性求值**;一旦出现「由 AI 决定怎么办」,按 AM 2.1 上升。 * 超限的自己先调,不把问题留给 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 type { ActivityManifest, DmnModel } from '@contracts';
import { PRICING_SKILLS } from './skills.ts';
import { CMMN_XML, NARRATIVE, PRICING_STEPS } from './case-model.ts';
import { activityIdSchema } from '@contracts'; import { 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'); const ACTIVITY_ID = activityIdSchema.parse('quote-pricing');
/** /**
* 利润模型 P-01 —— 品牌 × 类型 × 客户等级 × 数量档 → 目标毛利。 * 两把尺 —— **规则本身不是数据**,随包冻结。
* 「经验」段落不入决策表(那是技能 / 活动记忆种子:载方法不载权力)。 *
* · 利润模型 P-01:客户等级 × 类别 → 目标毛利区间(FIRST,行序即业务优先级);
* · 底价门禁 P-02:相对底价线的偏离度 → 处置码。
*
* 出参就是台账列的取值(`disposition` 的三个大写码),模型算完照抄即可 ——
* 「出参键名与台账列同名」是为了它拿回去直接 write(规范 §5.6)。
* 两把尺的档位在三处出现:这张表、剧本 guide、工作单上的规则块 ——
* 包内自测把三处钉在一起(改一处不改另两处,测就红)。
*/ */
export const P01_DMN: DmnModel = { export const P01_DMN: DmnModel = {
drd: [ drd: [
{ decision: 'p01-margin', requires: [], inputs: ['customerGrade', 'materialType', 'volumeTier'] }, { decision: 'p01-margin', requires: [], inputs: ['customer_grade', 'type_tag'] },
{ decision: 'p02-floor', requires: ['p01-margin'], inputs: ['quotedPrice', 'floorPrice'] }, { decision: 'p02-floor', requires: ['p01-margin'], inputs: ['deviation'] },
], ],
tables: [ tables: [
{ {
@@ -29,30 +47,29 @@ export const P01_DMN: DmnModel = {
label: '利润模型 P-01', label: '利润模型 P-01',
hitPolicy: 'FIRST', hitPolicy: 'FIRST',
inputs: [ inputs: [
{ name: 'customerGrade', expression: 'customerGrade' }, { name: 'customer_grade', expression: 'customer_grade' },
{ name: 'materialType', expression: 'materialType' }, { name: 'type_tag', expression: 'type_tag' },
{ name: 'volumeTier', expression: 'volumeTier' },
], ],
outputs: [{ name: 'marginLow' }, { name: 'marginHigh' }], outputs: [{ name: 'margin_low' }, { name: 'margin_high' }],
rules: [ rules: [
{ id: 'p01-1', when: ['"战略客户"', '"A"', '"大批量"'], then: [0.08, 0.1], annotation: '战略客户 A 类标准件大批量 8%–10%' }, { 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-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-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-4', when: ['-', '"C"'], then: [0.2, 0.5], annotation: 'C 类替代/停产件 20%~50%(一事一议)' },
{ id: 'p01-default', when: ['-', '-', '-'], then: [0.12, 0.15], annotation: '兜底按普通客户标准件' }, { id: 'p01-default', when: ['-', '-'], then: [0.12, 0.15], annotation: '拿不准按普通客户标准件档' },
], ],
}, },
{ {
/** 底价门禁 P-02 —— 偏离底价 ±20% 即止 */ /** 底价门禁 P-02 —— 偏离底价线 ±20% 即止;出参就是台账 `disposition` 的取值 */
id: 'p02-floor', id: 'p02-floor',
label: '底价门禁 P-02', label: '底价门禁 P-02',
hitPolicy: 'FIRST', hitPolicy: 'FIRST',
inputs: [{ name: 'deviation', expression: 'deviation' }], inputs: [{ name: 'deviation', expression: 'deviation' }],
outputs: [{ name: 'disposition' }, { name: 'passable' }], outputs: [{ name: 'disposition' }, { name: 'passable' }],
rules: [ rules: [
{ id: 'p02-1', when: ['>= 0'], then: ['直接放行', true], annotation: '≥底价 → 直接放行' }, { id: 'p02-1', when: ['>= 0'], then: ['PASS', true], annotation: '在底价线之上 → 直接放行' },
{ id: 'p02-2', when: ['[-0.20..0)'], then: ['PM 附理由放行', true], annotation: '低于底价 ≤20% → PM 附理由' }, { id: 'p02-2', when: ['[-0.20..0)'], then: ['NOTE', true], annotation: '低于底价线两成以内 → 附理由放行' },
{ id: 'p02-3', when: ['< -0.20'], then: ['拦截 · BOSS 拍板', false], annotation: '低于底价 >20% → 拦截' }, { id: 'p02-3', when: ['< -0.20'], then: ['BLOCK', false], annotation: '低于两成以上 → 拦截,人拍板' },
], ],
}, },
], ],
@@ -62,95 +79,162 @@ export const manifest: ActivityManifest = {
identity: { identity: {
activityId: ACTIVITY_ID, activityId: ACTIVITY_ID,
name: 'A4 · 成本定价', name: 'A4 · 成本定价',
packageVersion: '2.0.0', packageVersion: '3.5.0',
category: '统一报价', category: '统一报价',
sdkVersion: '0.1.0-m5', sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-quote-pricing', checksum: 'dev-checksum-quote-pricing',
}, },
/* ── 契约七件 ────────────────────────────────────────────────── */
contract: { contract: {
activityId: ACTIVITY_ID, activityId: ACTIVITY_ID,
version: '0.1.0', version: '1.0.0',
input: { input: {
/*
* **宽进到底:一个必填都不设**(与 A1/A2/A3 同口径 —— 宽进严出,
* 输入契约在活动内部归一 + 补全)。
*
* 来路三种形状:寻源转来(rows,行上带 base_price)、受理/选型直达(records/rows,
* 没有底价)、单飞(一段文字)。上一版必填 `rfqRef + customerCode + records`,
* 而 A3 的 `quotation` 口发的是 `{rfqRef, rows}` —— 门口拦的恰是主路。
*/
data: { data: {
type: 'object', type: 'object',
required: ['rfqRef', 'customerCode', 'records'], required: [],
properties: { properties: {
rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' }, rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' },
customerCode: { type: 'string' }, customerCode: { type: 'string', title: '客户码' },
records: { type: 'array' }, rows: { type: 'array', title: '目标报价清单(寻源/选型转来,行上带底价)' },
// B/C 类由 A3/A2 交回的底价与替代方案;A 类直通时为空 records: { type: 'array', title: '上游受理记录(受理直达)' },
floorPrices: { type: 'array' }, 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, messageAllowed: true,
messageHint: '客户等级、急单、竞对价、这一单的让价授权写这里',
}, },
output: { output: {
/*
* 只有一个出口:报价草案交定价审批(流程包 E8 是无条件边)。没有选择组 ——
* 没有去向要选,定盘就是把账写齐。
*
* 载荷键名照下游(A6 议价)的入参写:`rfqRef / customerCode / items` ——
* 边读什么、下游要什么,上游就得发什么。标量都有台账列承载(自测钉着)。
*/
mandatory: [ mandatory: [
{ {
name: 'draft', name: 'draft',
payload: { payload: {
type: 'object', type: 'object',
required: ['rfqRef', 'items'], required: ['rfqRef', 'items'],
properties: { rfqRef: { type: 'string' }, items: { type: 'array' } }, properties: {
rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' },
customerCode: { type: 'string', title: '客户码' },
items: { type: 'array', title: '报价草案(每条带拟报单价、偏离度与门禁处置)' },
},
}, },
description: '报价草案(逐条带偏离度)', description: '报价草案(全条目带拟报单价与门禁处置)',
}, },
], ],
selective: [], selective: [],
}, },
/* /*
* 必交付物**必须给 `ledger` 地址**(2026-07-31 修)。 * 必交付物只有一件:**报价草案**。定价结论(verdict)是它的结论栏,
* 不给的话预检退回 `scope[key]` 去找 —— key 是 `benchmark-table` 这样的短横线别名, * `ledgers` 说清这份成果由哪几张表承载(一份成果,不是两份)。
* 任务视图里永远没有这个键,于是「必交付物缺一不得提交放行」永远拒,
* 活办完了也放不了行,连拒五拍熔断(实测 A2 死于此)。
*/ */
requiredDeliverables: [ requiredDeliverables: [
{ key: 'quote-draft', label: '报价草案(全条目带偏离度)', ledger: `${DOMAIN}.draft` }, {
key: 'quote-draft',
label: '报价草案(每条带拟报单价与门禁处置)',
ledger: `${D}.draft`,
/* 验收页上让 BD 就地改的两列 —— 恰是他的职权面(定价与让价理由) */
editableAtRelease: ['quoted_price', 'price_note'],
ledgers: [`${D}.draft`, `${D}.verdict`],
},
], ],
/* /*
* 机检谓词读的是 `verdict` 那张**汇总表**的列(照 A1 的口径)。 * 验收判据 —— **机检(L1/L2)只读基座数得出来的数**:
* 上一版的名字(unpriced;floor-gate 更是连谓词都没有)**台账里根本没有** —— S-FEEL 拿未绑定的名字 * `_rows` 基座数行数、`_missing` 基座按 required 现数(与执行卡提交闸同一个数
* 比大小求值为假,判据永远达不了标,submit 门永远拒,连拒五拍熔断。 * 同一套算法)、`_disposition_*` 基座按值现数。
* 判据的名字必须落在台账上,否则「机检」是一句空话(2026-07-31 销账)。 * 上一版两条机检读活动自报的 `unpriced_total` / `unresolved_over_total` ——
* 那不是门,是「改哪个数能过」的提示(违反史已记四次,这是第五类)。
*/ */
acceptanceCriteria: [ acceptanceCriteria: [
{ key: 'all-priced', label: '每条都有拟报单价与毛利率', level: 'L1', predicate: 'unpriced_total = 0' }, {
// 底价门禁沉为 L2 机检 —— 「约束力收缩」的终点 key: 'roster-complete',
{ key: 'floor-gate', label: '偏离底价 ≤±20%,超限者已回调或注明理由', level: 'L2', modelRef: 'p02-floor', predicate: 'unresolved_over_total = 0' }, label: '草案齐:上游条目逐条承继,说了几条草案里就躺着几行',
{ key: 'pm-release', label: 'BD 放行', level: 'L5' }, 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: [], revisableAtRelease: [],
prohibitions: [ prohibitions: [
{ key: 'no-silent-override', statement: '不得静默绕开 P-02 的结论;要破线只能走「人 · 决策」' }, { key: 'no-silent-override', statement: '不得静默绕开底价门禁的结论;要破线只能走「人 · 决策」并留下理由' },
{ key: 'no-stale-floor', statement: '底价超一周未更新的,先刷底价再判' }, { key: 'no-made-up-basis', statement: '没有成本基准的料不许编一个偏离率出来:基准空着就空着,价由人定并注明依据' },
{ key: 'no-stale-floor', statement: '底价超一周未更新的,先刷底价再判 —— 拿过期的成本算出来的毛利是假的' },
], ],
authorization: { authorization: {
discretion: [{ key: 'margin-band', statement: '在 P-01 给出的毛利区间内自决;出区间必须调人决策' }], discretion: [{ key: 'margin-band', statement: '在 P-01 给出的毛利区间内自决;出区间要么回调,要么调人拍板' }],
preauthorizedActions: [], preauthorizedActions: [],
humanCallBudget: 3, /* 一张执行单 + 可能一张决策卡 + 放行 = 三次;留一倍余量 */
humanCallBudget: 5,
}, },
resources: { resources: {
/*
* `quote-pricing.basis` 已从声明里去掉(2026-07-31):工具箱里从没注册过它,
* 装包时静态审一直报「运行时转人代办」—— 而重做后的案例模型压根不调它
* (算价归判定表 P-01/P-02,取基准读历史价)。声明一件不存在也不用的工具,
* 只会让每一单跑到 A4 就弹一张莫名其妙的转人卡。
*/
tools: [{ name: 'crm.history-price-read' }], tools: [{ name: 'crm.history-price-read' }],
skills: PRICING_SKILLS.map((s) => s.id), skills: PRICING_SKILLS.map((s) => s.id),
models: ['deepseek-v4-flash'], models: ['deepseek-v4-flash'],
roles: ['BD'], 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: { declarations: {
models: ['deepseek-v4-flash'], models: ['deepseek-v4-flash'],
tools: [{ name: 'crm.history-price-read' }], tools: [{ name: 'crm.history-price-read' }],
ledger: [ ledger: [
{ {
domain: DOMAIN, domain: D,
object: 'draft', object: 'draft',
kind: 'record', kind: 'record',
caseKey: 'rfq_ref', caseKey: 'rfq_ref',
@@ -158,82 +242,226 @@ export const manifest: ActivityManifest = {
fields: [ fields: [
{ name: 'rfq_ref', 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: '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: '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: 'deviation', type: 'number', primaryKey: false, masked: false, label: '偏离度' }, /*
{ name: 'disposition', type: 'text', 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 的教训)。 * 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。
*/ */
domain: DOMAIN, domain: D,
object: 'verdict', object: 'verdict',
kind: 'record', kind: 'record',
caseKey: 'rfq_ref', caseKey: 'rfq_ref',
label: '定价结论', label: '定价结论',
fields: [ fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' }, { name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'pn', type: 'text', primaryKey: false, masked: false, label: '型号' }, { name: 'customer_code', type: 'text', primaryKey: false, masked: false, label: '客户码' },
{ name: 'draft_total', type: 'integer', primaryKey: false, masked: false, label: '草案条数' }, { name: 'customer_name', type: 'text', primaryKey: false, masked: false, label: '客户名称' },
{ name: 'unpriced_total', type: 'integer', primaryKey: false, masked: false, label: '未定价条数' }, /* 客户等级是 P-01 的入参之一(战略客户 / 普通客户),认不出按普通客户 */
{ name: 'over_total', type: 'integer', primaryKey: false, masked: false, label: '超限条数' }, { name: 'customer_grade', type: 'text', primaryKey: false, masked: false, label: '客户等级' },
{ name: 'unresolved_over_total', type: 'integer', 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: '定价结论' }, { name: 'summary', type: 'text', primaryKey: false, masked: false, label: '定价结论' },
], ],
}, },
], ],
listeners: [ listeners: [
{ {
/** 时限过半:SLA <2h,给 BD 与负责人提个醒 —— 静态监听,时限必配 */
origin: 'static', origin: 'static',
eventType: 'human', eventType: 'time',
keyFilter: { kind: 'decision' }, keyFilter: { kind: 'half-deadline' },
semantic: 'complete-step', semantic: 'observe',
deadline: 'PT4H', deadline: 'PT1H',
escalation: ['nudge', 'escalate'], escalation: ['nudge'],
}, },
], ],
// SLA <2h(MVP示例 §1.2 留痕 · SLA)
harness: { harness: {
memoryWindow: 20, memoryWindow: 22,
// SLA <2h(MVP示例 §1.2 留痕 · SLA)
stallAfter: 'PT2H', stallAfter: 'PT2H',
nudge: [{ after: 'PT1H', action: 'nudge' }], nudge: [{ after: 'PT1H', action: 'nudge' }],
retry: { times: 2, backoff: 'PT30S' }, retry: { times: 2, backoff: 'PT30S' },
}, },
}, },
/* ── 资产初始版 ──────────────────────────────────────────────── */
assets: { assets: {
/* 资产初始版:案例模型(2026-07-29 起活动只有这一种业务模型) */
businessModel: { businessModel: {
cmmnXml: CMMN_XML, cmmnXml: CMMN_XML,
narrative: NARRATIVE, narrative: NARRATIVE,
dmn: P01_DMN, dmn: P01_DMN,
/* 步骤剧本 —— 与 A1 同一套:doneWhen 真了引擎自己收步,不问模型 */
steps: PRICING_STEPS, steps: PRICING_STEPS,
phases: PRICING_PHASES,
}, },
skills: PRICING_SKILLS, skills: PRICING_SKILLS,
/*
* 两个局面各一张(与 A2/A3 同款口径):
* · `pricing-worksheet` 定价工作单 —— 算价与 BD 核价期间摆它(逐颗定价档案、
* 两把尺、填回工作表);
* · `pricing-roster` 报价草案清单 —— 拍板、放行、归档摆它。
*/
viewTemplates: [ viewTemplates: [
{ name: 'pricing-basis', html: PRICING_BASIS }, { name: 'pricing-worksheet', html: PRICING_WORKSHEET },
{ name: 'pricing-priced', html: PRICING_PRICED }, { name: 'pricing-roster', html: PRICING_ROSTER },
{ name: 'pricing-release', html: PRICING_RELEASE },
], ],
cardSchemas: {}, cardSchemas: {},
distillHooks: [ distillHooks: [
{ {
on: 'terminal', on: 'terminal',
prompt: '归纳本单定价的经验:哪些组合的毛利档被人回调过、底价门禁拦下的形态、破线的理由。', prompt: '归纳本单定价:哪些组合的毛利档被人回调过、底价门禁拦下的形态、破线的理由与拍板结果。',
}, },
], ],
}, },
/* ── 样例集 —— 三条形状真不同(常备案例即验收案例)───────────── */
samples: [ 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'] }, expect: { ports: ['draft'] },
}, },
], ],
+23 -13
View File
@@ -1,5 +1,8 @@
/** /**
* A4 技能 —— 定价的方法经验(AM 08)。载方法,不载权力。 * A4 技能 —— 定价的方法经验。载方法,不载权力。
*
* 与剧本的分工:剧本写「这一步做什么、写哪几列」,技能写「为什么这么定、
* 拿不准时按哪条走」。剧本改了口径,技能里同一句要跟着改(规范 §5.9)。
*/ */
import { skillIdSchema } from '@contracts'; import { skillIdSchema } from '@contracts';
import type { Skill } from '@contracts'; import type { Skill } from '@contracts';
@@ -8,34 +11,41 @@ export const PRICING_SKILLS: Skill[] = [
{ {
id: skillIdSchema.parse('pricing.pick-basis'), id: skillIdSchema.parse('pricing.pick-basis'),
label: '选对价格基准', label: '选对价格基准',
applicability: '定价开始,需要确定「跟谁比」时。', applicability: '定价开始,需要确定「这个价跟谁比」时。',
method: [ method: [
'基准的优先级:① 本客户近期成交价 → ② 同型号他客户成交价 → ③ 供应商底价表 → ④ 无基准。', '基准的优先级:① 上游寻源问回来的货源底价 → ② 本客户近期成交价 → ③ 同型号他客户成交价 → ④ 无基准。',
'越靠前越贴近这一单的真实议价空间;越靠后越要留余量。', '越靠前越贴近这一单的真实议价空间;越靠后越要留余量。',
'', '',
'**无基准不是异常**,是一种基准:新料号、久未报价的老料号都会落到这里。', '**无基准不是异常**,是一种基准:新料号、久未报价的老料号都会落到这里。',
'此时偏离率记 0(没有可偏离的对象),由底价门禁按「无基准」放行 —— ', '此时成本基准、底价线、偏离度**一律留空**,拟报单价交给人定 ——',
'不要拿一个不相干的价去凑一个偏离率出来,那比没有基准更危险:它看起来像有依据。', '不要拿一个不相干的价去凑一个偏离率出来,那比没有基准更危险:它看起来像有依据。',
'',
'基准超一周未更新的先刷再判:拿过期成本算出来的毛利,数字是真的,结论是假的。',
].join('\n'), ].join('\n'),
crystallized: [{ tool: 'crm.history-price-read', input: { pn: 'pn' } }], crystallized: [{ tool: 'crm.history-price-read', input: { pn: 'pn' } }],
knowledgeRefs: ['利润模型 P-01'], knowledgeRefs: ['利润模型 P-01'],
version: '1.0.0', version: '2.0.0',
}, },
{ {
id: skillIdSchema.parse('pricing.read-gate'), id: skillIdSchema.parse('pricing.read-gate'),
label: '读懂底价门禁的处置', label: '读懂底价门禁的处置',
applicability: '底价门禁(P-02)给出处置结论,需要决定下一步时。', applicability: '底价门禁(P-02)给出处置码,需要决定下一步时。',
method: [ method: [
'门禁不是「拦下」而是**分流**:', '门禁不是「拦下」而是**分流**,三档各有各的下一步:',
'· 直接放行 —— 偏离在容许带内,照常提审。', '· PASS(在底价线之上)—— 照常提审,不必解释。',
'· 超限待裁 —— 偏离超 ±20%,交人决断:回调价格,还是注明理由硬报。', '· NOTE(低于底价线两成以内)—— 附理由放行:让价的缘由写进备注,',
' 两条路都合法,但都要留下**理由**:这条留痕将来会成为客户等级与利润模型的迭代素材。', ' 理由随草案一起交审批。不写理由的让价,到审批那一关同样过不去。',
'· BLOCK(低于两成以上)—— 系统不替任何人放行,交人拍板:回调到底价线、',
' 破线报并留理由、或这一条本轮不报。三条路都合法,都要留痕。',
'', '',
'不要为了让门禁「过」而回头改毛利参数 —— 那是把判据改成结论,', '两条别做的事:',
'决策表就失去了它唯一的价值:同输入必同结论。', '· 不要为了让门禁「过」回头改毛利档或改基准 —— 那是把判据改成结论,',
' 决策表就失去了它唯一的价值:同输入必同结论;',
'· 不要每一条都叫人。BD 自己能回调掉的,回调掉就是处置 ——',
' 每条都叫人,人就成了橡皮图章;一条都不叫,门禁就是摆设。',
].join('\n'), ].join('\n'),
crystallized: [], crystallized: [],
knowledgeRefs: ['底价门禁 P-02'], knowledgeRefs: ['底价门禁 P-02'],
version: '1.0.0', version: '2.0.0',
}, },
]; ];
@@ -0,0 +1,409 @@
/**
* A1 两份执行页的样式表 —— **从 PRD 目标件原样搬过来**,不是我照着复述的。
*
* 出处:`~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理需求.html` 的整段 `<style>`,
* 外加 `A1-询价受理记录.html` 里归档件独有的那几件(分类分布条 / 类徽 / 判定表 /
* 结论横幅 / 落地动作 / 完成标)。**一个数值都没改**。
*
* 为什么是搬而不是抄:前三轮我用自己那套外壳「照着做」,块搬齐了、版面是两套 ——
* 章节头成了彩色方块编号、多出摘要与元信息带、字号用 clamp。人一眼就看出不是那份件。
* 目标件是**像素级的答案**,搬过来才不会在复述里走样。
*
* 选择器保留 `.proto-root .app-shell` 前缀,所以模版正文也要照原型那样包两层壳 ——
* 少一层壳,整张样式表一条都不生效(踩过)。
*/
export const PRD_CSS = `
/* ============================================================================
JCAI 员工面工作台 · A1 受理归一的过程成果「询价受理需求」——报告本体
立场:**AI 已从 RFQ 邮件抽取归一**,人在这里审核并就地修订。
两态:读态(AI 稿 + 红/灰占位提示缺项) ⇄ 编辑态(全字段可改,保存 / 取消)
核心是「有效 PN 清单」;清单之上四件受理要素:客户公司 / 联系人 / 客户属性 / 有效 PN。
令牌与既有组件取自 workbench/src/app/(proto)/app.css,数值未改。
⚠️ 修正一处原型 bug:app.css:2062-2064 的 '.jc-rp-sign span'(权重 0,1,1)
盖过了 '.jc-rp-ava'(0,1,0),把头像的 display:grid 覆盖成 block、字色改灰,
导致落款头像里的字掉到圆外。本文件把该规则收窄为 '.jc-rp-sign > div > span'。
新增件:四要素卡 .jc-four/.jc-fcard · 占位胶囊 .jc-ph · 分段控件 .jc-seg ·
来源条 .jc-src · 录入表 .jc-tbl-in
========================================================================== */
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { background: #e6eaf1; -webkit-font-smoothing: antialiased; }
.proto-root .app-shell {
--brand: #3370ff; --brand-deep: #245bdb;
--brand-soft: #e1eaff; --brand-wash: #f0f4ff;
--ai-grad: linear-gradient(135deg, #3370ff, #7f3bf5);
--ink: #1f2329; --ink-2: #41464e; --ink-3: #646a73; --ink-4: #8f959e;
--line: #dee0e3; --line-soft: #e8eaed; --soft: #f8f9fb;
--jcai-bg: #e6eaf1; --cardline: #e1e6ec; --card-shadow: 0 1px 2px rgba(31, 35, 41, .05);
--ok: #278717; --ok-soft: #e8f8e4;
--warn: #de7802; --warn-accent: #ff8800; --warn-soft: #fff4e5;
--danger: #d83931; --danger-soft: #feeae9;
--mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace;
--shadow-brand: 0 6px 16px rgba(51, 112, 255, .22);
--t-fast: .16s cubic-bezier(.3, 0, .2, 1);
--t-med: .26s cubic-bezier(.3, 0, .2, 1);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--jcai-bg); min-height: 100vh; color: var(--ink);
}
.proto-root :where(button, input, select, a, [tabindex]):focus-visible {
outline: 2px solid var(--brand); outline-offset: 2px; border-radius: 8px;
}
.proto-root ::selection { background: var(--brand-soft); }
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
@keyframes jcBlink { 0%,100% { opacity: 1 } 50% { opacity: .35 } }
@keyframes jcStamp { from { opacity: 0; transform: rotate(-6deg) scale(1.7); } to { opacity: .9; transform: rotate(-6deg) scale(1); } }
/* 放开宽度:1160px 是「9 列全展开、一个字都不裁」的最小容器宽度(见下方列 min-width 之和) */
.jc-stage { max-width: 1160px; margin: 0 auto; padding: 22px 16px 52px; }
.proto-root .jc-mono { font-family: var(--mono); font-size: 11px; color: var(--ink-3); }
.proto-root .jc-pulse-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--brand); display: inline-block; animation: jcBlink 1.1s ease-in-out infinite; }
/* ── 报告外壳 ───────────────────────────────────────────────────────── */
.proto-root .jc-report { background: #fff; border: 1px solid var(--cardline); border-radius: 14px; box-shadow: var(--card-shadow); overflow: hidden; animation: rise var(--t-med) both; }
.proto-root .jc-rp-head { position: relative; overflow: hidden; display: flex; align-items: flex-start; gap: 12px; padding: 16px 20px 13px; border-bottom: 2px solid var(--ink); }
/* 题头下缘粗细双线 = 正式件版式 */
.proto-root .jc-rp-head::after { content: ""; position: absolute; left: 0; right: 0; bottom: -4px; height: 1px; background: var(--line); }
/* 题头右上的品牌光晕:与工作台标题区同一签名件 */
.proto-root .jc-rp-head::before { content: ""; position: absolute; right: -70px; top: -96px; width: 240px; height: 240px; border-radius: 999px; background: radial-gradient(circle, rgba(51,112,255,.10), transparent 70%); pointer-events: none; }
.proto-root .jc-rp-head > * { position: relative; z-index: 1; }
.proto-root .jc-rp-badge { flex: none; width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand); }
.proto-root .jc-rp-tit { flex: 1; min-width: 0; }
.proto-root .jc-rp-tit em { display: block; font-style: normal; font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); font-family: var(--mono); }
.proto-root .jc-rp-tit h3 { margin: 4px 0 0; font-size: 18px; font-weight: 800; color: var(--ink); letter-spacing: -.01em; line-height: 1.3; }
.proto-root .jc-rp-wait { display: inline-flex; align-items: center; gap: 6px; flex: none; margin-top: 4px; font-size: 11px; font-weight: 700; border-radius: 999px; padding: 4px 12px; color: var(--brand-deep); background: var(--brand-wash); border: 1px solid var(--brand-soft); }
.proto-root .jc-rp-wait.warn { color: var(--warn); background: var(--warn-soft); border-color: #ffd9a8; }
.proto-root .jc-rp-wait.edit { color: #fff; background: var(--brand); border-color: var(--brand); }
.proto-root .jc-rp-stamp { flex: none; margin-top: 2px; transform: rotate(-6deg); border: 2px solid currentColor; border-radius: 9px; padding: 4px 8px 3px 12px; font-size: 13px; font-weight: 900; letter-spacing: .28em; color: var(--ok); opacity: .9; animation: jcStamp .32s cubic-bezier(.2, 1.4, .4, 1) both; }
.proto-root .jc-rp-bd { padding: 18px 20px 12px; }
.proto-root .jc-rp-part { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 9px; margin: 20px 0 11px; }
.proto-root .jc-rp-part:first-child { margin-top: 0; }
.proto-root .jc-rp-part > b { flex: none; font-size: 11px; font-weight: 800; letter-spacing: .12em; color: var(--ink-3); }
.proto-root .jc-rp-part > .ln { flex: 1; height: 1px; background: var(--line-soft); }
.proto-root .jc-rp-part > em { flex: none; font-style: normal; font-size: 10px; font-weight: 700; color: var(--ink-4); font-family: var(--mono); background: var(--soft); border-radius: 999px; padding: 2px 9px; white-space: nowrap; }
.proto-root .jc-rp-part > em.warn { color: var(--warn); background: var(--warn-soft); }
/* 落款 —— ⚠️ 选择器收窄到 > div > *,别再命中头像那个 span */
.proto-root .jc-rp-foot { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 13px 20px 15px; border-top: 2px solid var(--line); background: #fbfcfd; }
.proto-root .jc-rp-sign { display: flex; align-items: center; gap: 10px; flex: 1 1 200px; min-width: 0; }
.proto-root .jc-rp-ava { flex: 0 0 auto; width: 30px; height: 30px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; line-height: 1; color: #fff; background: var(--brand); }
.proto-root .jc-rp-sign > div { min-width: 0; }
.proto-root .jc-rp-sign > div > b { display: block; font-size: 12px; font-weight: 700; color: var(--ink-2); }
.proto-root .jc-rp-sign > div > span { display: block; margin-top: 1px; font-size: 10.5px; color: var(--ink-4); }
.proto-root .jc-rp-acts { display: flex; align-items: center; gap: 8px; margin-left: auto; flex: none; }
.proto-root .jc-hintline { font-size: 11px; font-weight: 700; color: var(--warn); line-height: 1.5; }
.proto-root .jc-btn { display: inline-flex; align-items: center; gap: 5px; border: 0; border-radius: 7px; padding: 8px 14px; font-size: 12px; font-weight: 600; font-family: inherit; background: var(--brand); color: #fff; cursor: pointer; transition: all var(--t-fast); }
.proto-root .jc-btn:hover:not(:disabled) { background: var(--brand-deep); }
.proto-root .jc-btn:disabled { opacity: .45; cursor: not-allowed; }
.proto-root .jc-btn-ghost { background: #fff; color: var(--ink-2); border: 1px solid var(--line); }
.proto-root .jc-btn-ghost:hover:not(:disabled) { background: var(--brand-wash); color: var(--brand); border-color: var(--brand-soft); }
.proto-root .jc-btn-primary { background: var(--ai-grad); }
.proto-root .jc-btn-primary:hover:not(:disabled) { filter: brightness(1.07); box-shadow: var(--shadow-brand); transform: translateY(-1px); }
/* ── 来源信件:客户 RFQ 邮件原文(复用原型 jc-letter 件,青色 = 收件) ── */
.proto-root .jc-letter { position: relative; border: 1px solid var(--cardline); border-left: 3px solid #0aa294; border-radius: 12px; padding: 14px 16px 15px; background: #fff; box-shadow: var(--card-shadow); }
.proto-root .jc-letter-h { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 11px; }
.proto-root .jc-letter-dir { display: inline-flex; align-items: center; gap: 5px; font-size: 10.5px; font-weight: 800; padding: 3px 9px; border-radius: 999px; background: #e4f8f5; color: #0a7f74; }
.proto-root .jc-letter-h .jc-chip { font-size: 10.5px; padding: 3px 9px; border-radius: 999px; background: var(--brand-wash); color: var(--brand-deep); border: 1px solid var(--brand-soft); }
.proto-root .jc-letter-h > code { margin-left: auto; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
.proto-root .jc-letter-meta { display: flex; flex-direction: column; gap: 4px; margin-bottom: 11px; padding-bottom: 11px; border-bottom: 1px solid var(--line-soft); font-size: 11.5px; color: var(--ink-2); }
.proto-root .jc-letter-meta p { margin: 0; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.proto-root .jc-letter-meta i { flex: none; font-style: normal; width: 44px; font-size: 10px; font-weight: 800; letter-spacing: .06em; color: var(--ink-4); }
.proto-root .jc-letter-meta code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); overflow-wrap: anywhere; }
.proto-root .jc-letter-sub { margin: 0 0 9px; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.45; overflow-wrap: anywhere; }
/* 正文:原文照录,pre-wrap 保原始换行;不截断、不折叠 */
.proto-root .jc-letter-body { margin: 0; font-size: 12.5px; line-height: 1.72; color: var(--ink-2); white-space: pre-wrap; overflow-wrap: anywhere; }
.proto-root .jc-letter-body b { color: var(--ink); font-weight: 700; }
.proto-root .jc-atts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; padding-top: 11px; border-top: 1px dashed var(--line-soft); }
.proto-root .jc-att { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px 5px 8px; border: 1px solid var(--brand-soft); border-radius: 8px; background: var(--brand-wash); font-size: 11.5px; color: var(--brand-deep); }
.proto-root .jc-att svg { flex: none; color: var(--brand); }
.proto-root .jc-att code { font-family: var(--mono); font-size: 10.5px; }
.proto-root .jc-att span { color: var(--ink-4); }
/* 抽取指向:从原文到下方要素的一句衔接 */
.proto-root .jc-derive { display: flex; align-items: flex-start; gap: 8px; margin-top: 11px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); font-size: 11.5px; line-height: 1.7; color: var(--brand-deep); }
.proto-root .jc-derive svg { flex: none; margin-top: 2px; }
/* ── 四要素卡 ───────────────────────────────────────────────────────────
色调字典:每张卡带一套 --tone / --tone-soft / --tone-line,组件只读变量。
卡 3、卡 4 的色调随数据变(老客户绿 / 新客户橙;齐了蓝 / 有缺橙),
所以这条要素带本身就是一行可读的状态,不只是四个格子。 */
.proto-root .jc-four { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 11px; }
.proto-root .jc-fcard {
position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-width: 0;
padding: 14px 14px 14px; border: 1px solid var(--cardline); border-radius: 13px; background: #fff;
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 8px 20px -14px rgba(31,35,41,.35);
transition: transform var(--t-fast), box-shadow var(--t-fast), border-color var(--t-fast);
--tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft);
}
/* 顶缘色棱 + 右上角光晕:卡的身份色,一眼分得清四件是四件 */
.proto-root .jc-fcard::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 3px; background: var(--tone); }
.proto-root .jc-fcard::after { content: ""; position: absolute; right: -46px; top: -52px; width: 132px; height: 132px; border-radius: 999px; background: radial-gradient(circle, var(--tone-soft), transparent 70%); pointer-events: none; }
.proto-root .jc-fcard > * { position: relative; z-index: 1; }
.proto-root .jc-fcard:hover { transform: translateY(-2px); border-color: var(--tone-line); box-shadow: 0 2px 4px rgba(31,35,41,.05), 0 14px 26px -16px rgba(31,35,41,.45); }
.proto-root .jc-fcard.t-teal { --tone: #0aa294; --tone-soft: rgba(10,162,148,.15); --tone-line: #9fe0d9; }
.proto-root .jc-fcard.t-ok { --tone: var(--ok); --tone-soft: rgba(39,135,23,.14); --tone-line: #bfedb4; }
.proto-root .jc-fcard.t-warn { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.12); --tone-line: #ffd9a8; }
.proto-root .jc-fcard-k { display: flex; align-items: center; gap: 6px; font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
.proto-root .jc-fcard-k > i { flex: none; width: 19px; height: 19px; border-radius: 6px; display: inline-grid; place-items: center; background: var(--tone-soft); color: var(--tone); }
.proto-root .jc-fcard-b { display: flex; align-items: center; gap: 10px; min-width: 0; }
.proto-root .jc-fcard-t { min-width: 0; flex: 1; }
/* 一律换行,不省略号——宁可多占一行,也不丢字 */
.proto-root .jc-fcard-t > b { display: block; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.35; overflow-wrap: anywhere; }
.proto-root .jc-fcard-t > span { display: block; margin-top: 2px; font-size: 10.5px; color: var(--ink-4); line-height: 1.55; overflow-wrap: anywhere; }
/* 公司标:圆角方 + AI 渐变 + 外圈光环(方=组织) */
.proto-root .jc-logo { flex: none; width: 38px; height: 38px; border-radius: 12px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; letter-spacing: .04em; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand), 0 0 0 3px rgba(51,112,255,.10); }
/* 人像:圆 + 青色系(圆=人,与方形的组织标一眼分开) */
.proto-root .jc-face { flex: none; width: 38px; height: 38px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; color: #0a7f74; background: #e4f8f5; border: 1px solid #9fe0d9; box-shadow: 0 0 0 3px rgba(10,162,148,.10); }
/* 邮箱做成可点的胶囊,复制按钮嵌在里面——比一行裸文字有抓手 */
/* 邮箱:软底胶囊、无描边——有描边会读成输入框,而读态这里不是可填的 */
.proto-root .jc-mailrow { display: flex; align-items: center; gap: 4px; margin-top: 5px; min-width: 0; max-width: 100%; padding: 3px 4px 3px 9px; border: 0; border-radius: 8px; background: var(--tone-soft); transition: background var(--t-fast); }
.proto-root .jc-mailrow:hover { background: rgba(10,162,148,.2); }
/* 编辑态里它包着输入框,软底会变成「框中框」,去掉 */
.proto-root .jc-report.editing .jc-mailrow { background: transparent; padding: 0; margin-top: 6px; }
.proto-root .jc-mailrow > code { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; line-height: 1.5; font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
.proto-root .jc-copy { flex: none; width: 22px; height: 22px; display: inline-grid; place-items: center; border: 0; border-radius: 6px; background: #fff; color: var(--ink-4); cursor: pointer; box-shadow: 0 1px 2px rgba(31,35,41,.08); transition: all var(--t-fast); }
.proto-root .jc-copy:hover { color: #0aa294; }
.proto-root .jc-copy.ok { color: var(--ok); background: var(--ok-soft); box-shadow: none; }
/* 客户属性徽章 */
.proto-root .jc-tagbig { display: inline-flex; align-items: center; gap: 6px; align-self: flex-start; padding: 7px 14px; border-radius: 999px; font-size: 13.5px; font-weight: 800; line-height: 1; color: var(--tone); background: var(--tone-soft); border: 1px solid var(--tone-line); }
.proto-root .jc-fcard-note { font-size: 10.5px; color: var(--ink-4); line-height: 1.55; }
.proto-root .jc-fcard-note b { color: var(--ink-2); font-weight: 700; }
.proto-root .jc-fcard-note b.bad { color: var(--tone); font-weight: 800; }
/* 成交迷你柱:把「台账 6 笔成交」从一句话变成看得见的节奏 */
.proto-root .jc-spark { display: flex; align-items: flex-end; gap: 4px; height: 26px; }
.proto-root .jc-spark > i { flex: 1 1 0; min-width: 4px; max-width: 15px; border-radius: 3px 3px 1px 1px; background: var(--tone); opacity: .3; transition: opacity var(--t-fast), height var(--t-med); }
.proto-root .jc-spark > i:last-child { opacity: 1; }
.proto-root .jc-spark:hover > i { opacity: .55; }
.proto-root .jc-spark:hover > i:last-child { opacity: 1; }
.proto-root .jc-spark.empty { align-items: stretch; }
.proto-root .jc-spark.empty > i { height: 100%; opacity: 1; background: repeating-linear-gradient(45deg, var(--tone-soft) 0 5px, transparent 5px 10px); border: 1px dashed var(--tone-line); border-radius: 5px; }
/* 有效 PN 计数 */
.proto-root .jc-count { display: flex; align-items: baseline; gap: 6px; }
.proto-root .jc-count > b { font-family: var(--mono); font-size: 32px; font-weight: 800; line-height: .95; color: var(--ink); letter-spacing: -.04em; }
.proto-root .jc-count > span { font-size: 11px; color: var(--ink-4); }
/* 分段刻度条:一格 = 一条 PN,缺必填的那格直接标红——比一根实心进度条信息量大 */
.proto-root .jc-segmeter { display: flex; gap: 3px; }
.proto-root .jc-segmeter > i { flex: 1; height: 7px; border-radius: 2px; background: var(--ok); transition: background var(--t-fast); }
.proto-root .jc-segmeter > i.bad { background: var(--danger); }
.proto-root .jc-lockhint { display: inline-flex; align-items: center; gap: 4px; font-size: 10px; color: var(--ink-4); }
/* ── 占位胶囊:必填=红虚线,选填=灰虚线;**都带文字**,不靠颜色单打 ── */
.proto-root .jc-ph { display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700; line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed; }
.proto-root .jc-ph.req { color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
.proto-root .jc-ph.opt { color: var(--ink-4); background: var(--soft); border-color: var(--line); }
/* ── 分段控件(新客户 / 老客户)── */
.proto-root .jc-seg { display: inline-flex; padding: 2px; gap: 2px; border: 1px solid var(--cardline); border-radius: 9px; background: var(--soft); align-self: flex-start; }
.proto-root .jc-seg button { border: 0; border-radius: 7px; padding: 6px 13px; font: inherit; font-size: 11.5px; font-weight: 700; color: var(--ink-3); background: transparent; cursor: pointer; transition: all var(--t-fast); }
.proto-root .jc-seg button:hover:not(.on) { color: var(--brand); }
.proto-root .jc-seg button.on { background: #fff; color: var(--ink); box-shadow: 0 1px 3px rgba(31,35,41,.12); }
/* ── 表:读态与编辑态共用骨架 ─────────────────────────────────────── */
.proto-root .jc-tbl-wrap { overflow-x: auto; border: 1px solid var(--line-soft); border-radius: 10px; }
.proto-root .jc-tbl-in { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; }
.proto-root .jc-tbl-in th { background: var(--soft); text-align: left; padding: 8px 9px; font-size: 10.5px; font-weight: 800; letter-spacing: .03em; color: var(--ink-3); white-space: nowrap; border-bottom: 1px solid var(--line-soft); }
.proto-root .jc-tbl-in th.req::after { content: "*"; margin-left: 1px; color: var(--danger); font-size: 10px; }
.proto-root .jc-tbl-in td { padding: 4px 5px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
.proto-root .jc-tbl-in tr:last-child td { border-bottom: 0; }
.proto-root .jc-tbl-in tbody tr:hover td { background: #f7f9fd; }
/* 缺必填的整行浅红打底 + 行首红标——不用找红胶囊,一眼扫到是哪几行 */
.proto-root .jc-tbl-in tr.miss td { background: #fffbfb; }
.proto-root .jc-tbl-in tbody tr.miss:hover td { background: #fff5f5; }
.proto-root .jc-tbl-in td.jc-rn { position: relative; width: 38px; padding: 4px 6px 4px 13px; white-space: nowrap; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); font-weight: 800; }
/* 行首红标只在表态用 ::after —— 卡片态的 ::after 被「第 N 条」占着,不能抢 */
@media (min-width: 961px) {
.proto-root .jc-tbl-in tr.miss td.jc-rn::after { content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger); }
}
.proto-root .jc-rndot { display: inline-block; width: 5px; height: 5px; margin-left: 4px; border-radius: 999px; background: var(--danger); vertical-align: 2px; }
/* 读态单元格 */
.proto-root .jc-vcell { display: block; padding: 6px 6px; font-size: 12px; color: var(--ink); line-height: 1.5; overflow-wrap: anywhere; }
.proto-root .jc-vcell.mono { font-family: var(--mono); font-size: 11px; color: var(--ink-2); }
.proto-root .jc-vcell.num { font-family: var(--mono); font-size: 11.5px; text-align: right; }
/* 编辑态单元格:安静的电子表格 */
.proto-root .jc-cell { width: 100%; min-width: 64px; border: 1px solid var(--line); border-radius: 6px; padding: 6px 7px; font: inherit; font-size: 12px; color: var(--ink); background: #fff; transition: border-color var(--t-fast), box-shadow var(--t-fast); }
.proto-root .jc-cell.mono { font-family: var(--mono); font-size: 11px; }
.proto-root .jc-cell.num { text-align: right; font-family: var(--mono); font-size: 11.5px; }
.proto-root .jc-cell::placeholder { color: var(--ink-4); }
.proto-root .jc-cell:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-wash); outline: none; }
.proto-root .jc-cell.req-empty { border-color: #f3b6b3; background: #fffafa; }
.proto-root .jc-cell.req-empty::placeholder { color: var(--danger); }
/* 备注这类长文本用文本域:能换行、随内容长高,不裁字 */
.proto-root textarea.jc-cell { display: block; resize: vertical; overflow: hidden; min-height: 31px; line-height: 1.55; }
/* 编辑态的整表提示带 */
.proto-root .jc-editbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); border: 1px solid var(--brand-soft); font-size: 11.5px; color: var(--brand-deep); }
.proto-root .jc-editbar svg { flex: none; }
.proto-root .jc-editbar .sp { flex: 1 1 auto; }
/* 剔除行说明 */
.proto-root .jc-drop { display: flex; align-items: flex-start; gap: 8px; margin-top: 10px; padding: 9px 12px; border-radius: 9px; border: 1px dashed var(--line); background: var(--soft); font-size: 11.5px; color: var(--ink-3); line-height: 1.6; }
.proto-root .jc-drop svg { flex: none; margin-top: 2px; color: var(--ink-4); }
.proto-root .jc-drop code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
.proto-root .jc-callout { display: flex; gap: 8px; align-items: flex-start; border-radius: 9px; padding: 11px 13px; font-size: 12.5px; line-height: 1.75; }
.proto-root .jc-callout p { margin: 0; }
.proto-root .jc-callout svg { flex: none; margin-top: 2px; }
.proto-root .jc-callout.t-mute { background: var(--soft); color: var(--ink-2) }
@media (prefers-reduced-motion: reduce) {
.proto-root .jc-pulse-dot { animation: none; }
.proto-root .jc-report, .proto-root .jc-rp-stamp { animation: none; }
}
/* ══════════════════════════════════════════════════════════════════════════
响应式:宁可另起一行,也不损失完整度
≥961px 六列全展开的表(列 min-width 之和 878px,容器有余量,绝不裁字、绝不横滚)
≤960px 表 → 卡片:一条 PN 一张卡、一行一字段(标签 / 值),无横滚、无省略
≤560px 字段行再拆成上下两行(标签在上、值在下)
══════════════════════════════════════════════════════════════════════════ */
@media (max-width: 1040px) { .proto-root .jc-four { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 560px) { .proto-root .jc-four { grid-template-columns: 1fr; } }
@media (max-width: 960px) {
.proto-root .jc-tbl-wrap { overflow: visible; border: 0; border-radius: 0; background: transparent; }
.proto-root .jc-tbl-in,
.proto-root .jc-tbl-in tbody,
.proto-root .jc-tbl-in tr,
.proto-root .jc-tbl-in td { display: block; width: 100%; }
.proto-root .jc-tbl-in thead { display: none; }
/* 一条 PN = 一张卡 */
.proto-root .jc-tbl-in tr { margin-bottom: 10px; padding: 4px 13px 9px; border: 1px solid var(--line-soft); border-radius: 11px; background: #fff; box-shadow: var(--card-shadow); }
.proto-root .jc-tbl-in tr:last-child { margin-bottom: 0; }
.proto-root .jc-tbl-in tbody tr:hover td { background: transparent; }
.proto-root .jc-tbl-in tr.miss { border-color: #f3b6b3; background: #fffbfb; }
/* 卡头:第 N 条 */
.proto-root .jc-tbl-in td.jc-rn { padding: 8px 0 8px; margin-bottom: 4px; border-bottom: 1px solid var(--line-soft); font-family: inherit; font-size: 12px; font-weight: 800; color: var(--ink-2); }
.proto-root .jc-tbl-in td.jc-rn::before { content: "第 "; }
.proto-root .jc-tbl-in td.jc-rn::after { content: " 条"; }
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); }
/* 字段行:左标签 + 右值,值超长自动折行 */
.proto-root .jc-tbl-in td:not(.jc-rn) { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 10px; align-items: center; padding: 6px 0; border-bottom: 1px dashed var(--line-soft); }
.proto-root .jc-tbl-in tr td:last-child { border-bottom: 0; padding-bottom: 2px; }
.proto-root .jc-tbl-in td:not(.jc-rn)::before {
content: attr(data-h); font-size: 10.5px; font-weight: 700; letter-spacing: .02em; color: var(--ink-4); line-height: 1.5;
}
.proto-root .jc-tbl-in td[data-req="1"]:not(.jc-rn)::before { content: attr(data-h) " *"; }
.proto-root .jc-tbl-in .jc-vcell { padding: 0; overflow-wrap: anywhere; }
.proto-root .jc-tbl-in .jc-vcell.num { text-align: left; }
.proto-root .jc-tbl-in .jc-cell.num { text-align: left; }
.proto-root .jc-tbl-in .jc-cell { min-width: 0; }
}
/* 窄屏:落款三件各占一行,动作按钮撑满一行 */
@media (max-width: 560px) {
.proto-root .jc-rp-acts { margin-left: 0; width: 100%; flex-wrap: wrap; }
.proto-root .jc-rp-acts .jc-btn { flex: 1 1 auto; justify-content: center; }
.proto-root .jc-hintline { flex: 1 1 100%; }
.proto-root .jc-tbl-in td:not(.jc-rn) { grid-template-columns: 1fr; gap: 3px; align-items: start; }
}
/* ══ 以下取自 A1-询价受理记录.html(归档件独有的件)══ */
/* ── 料号分类:分布条 + 图例 ───────────────────────────────────────── */
.proto-root .jc-dist { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 13px 15px; border: 1px solid var(--line-soft); border-radius: 12px; background: linear-gradient(180deg, #fbfcfe, #fff); }
.proto-root .jc-distbar { flex: 1 1 320px; min-width: 260px; display: flex; gap: 3px; }
.proto-root .jc-distbar > i { height: 30px; border-radius: 4px; display: grid; place-items: center; font-size: 10.5px; font-weight: 800; color: #fff; font-family: var(--mono); }
.proto-root .jc-distbar > i.a { background: var(--ok); }
.proto-root .jc-distbar > i.b { background: var(--brand); }
.proto-root .jc-distbar > i.c { background: var(--warn-accent); }
.proto-root .jc-distlg { display: flex; flex-wrap: wrap; gap: 8px 18px; }
.proto-root .jc-distlg > span { display: inline-flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--ink-3); }
.proto-root .jc-distlg em { width: 10px; height: 10px; border-radius: 3px; }
.proto-root .jc-distlg em.a { background: var(--ok); } .proto-root .jc-distlg em.b { background: var(--brand); } .proto-root .jc-distlg em.c { background: var(--warn-accent); }
.proto-root .jc-distlg b { font-family: var(--mono); font-weight: 800; color: var(--ink); }
/* 类别徽 A / B / C */
.proto-root .jc-tbadge { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 6px; font-family: var(--mono); font-size: 11px; font-weight: 800; line-height: 1; color: #fff; }
.proto-root .jc-tbadge.a { background: var(--ok); } .proto-root .jc-tbadge.b { background: var(--brand); } .proto-root .jc-tbadge.c { background: var(--warn-accent); }
/* ── 结论与去向 ─────────────────────────────────────────────────────────
G2 是**分支网关**:三条互斥条件按序求值,只命中一条、只落一个活动。
所以这里不是三张并列的去向卡,而是一张**判定表 + 一条结论**。 */
/* 判定表:一行一条规则,命中行上色留痕(DMN 口径) */
.proto-root .jc-dmn { border: 1px solid var(--line-soft); border-radius: 12px; overflow: hidden; }
.proto-root .jc-dmn-h, .proto-root .jc-dmn-r { display: grid; grid-template-columns: 40px minmax(0, 1fr) 170px 200px; align-items: center; gap: 12px; padding: 9px 14px; }
.proto-root .jc-dmn-h { background: var(--soft); border-bottom: 1px solid var(--line-soft); font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); }
.proto-root .jc-dmn-r { font-size: 12px; color: var(--ink-4); background: #fff; }
.proto-root .jc-dmn-r + .jc-dmn-r { border-top: 1px solid var(--line-soft); }
.proto-root .jc-dmn-i { font-family: var(--mono); font-size: 11px; font-weight: 800; }
.proto-root .jc-dmn-c { line-height: 1.6; overflow-wrap: anywhere; }
.proto-root .jc-dmn-t { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; }
.proto-root .jc-dmn-t code { font-family: var(--mono); font-size: 10.5px; font-weight: 800; color: var(--ink-4); background: var(--soft); border: 1px solid var(--line); border-radius: 5px; padding: 2px 6px; }
.proto-root .jc-dmn-v { font-size: 11px; line-height: 1.55; }
/* 命中行:上色 + 左缘棱 + 加粗 */
.proto-root .jc-dmn-r.hit { position: relative; background: var(--tone-soft); color: var(--ink-2); }
.proto-root .jc-dmn-r.hit::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--tone); }
.proto-root .jc-dmn-r.hit .jc-dmn-i { color: var(--tone); }
.proto-root .jc-dmn-r.hit .jc-dmn-c { color: var(--ink); }
.proto-root .jc-dmn-r.hit .jc-dmn-c b { color: var(--tone); font-weight: 800; }
.proto-root .jc-dmn-r.hit .jc-dmn-t { color: var(--ink); }
.proto-root .jc-dmn-r.hit .jc-dmn-t code { color: #fff; background: var(--tone); border-color: var(--tone); }
.proto-root .jc-dmn-v.y { display: inline-flex; align-items: center; gap: 5px; font-weight: 800; color: var(--tone); }
.proto-root .jc-dmn-v.n { color: var(--ink-4); }
/* 色调随命中类别 */
.proto-root .jc-dmn.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.10); }
.proto-root .jc-dmn.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.09); }
.proto-root .jc-dmn.k-c { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.11); }
/* 结论横幅:命中条件 → 唯一去向 */
.proto-root .jc-verdict {
position: relative; overflow: hidden; display: flex; align-items: stretch; flex-wrap: wrap; gap: 16px;
margin-top: 11px; padding: 16px 18px; border: 1px solid var(--tone-line); border-radius: 13px;
background: linear-gradient(105deg, var(--tone-soft), #fff 62%);
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 10px 24px -16px rgba(31,35,41,.4);
--tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.14); --tone-line: #ffd9a8;
}
.proto-root .jc-verdict.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.13); --tone-line: #bfedb4; }
.proto-root .jc-verdict.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft); }
.proto-root .jc-verdict::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 5px; background: var(--tone); }
.proto-root .jc-verdict > * { position: relative; z-index: 1; }
.proto-root .jc-vd-l { flex: 1 1 300px; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
.proto-root .jc-vd-k { font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
.proto-root .jc-vd-cond { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 15px; font-weight: 800; color: var(--ink); }
.proto-root .jc-vd-why { margin: 0; font-size: 11.5px; line-height: 1.7; color: var(--ink-3); overflow-wrap: anywhere; }
.proto-root .jc-vd-why code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
.proto-root .jc-vd-arrow { flex: none; align-self: center; color: var(--tone); }
.proto-root .jc-vd-r { flex: 1 1 260px; min-width: 0; display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-radius: 11px; background: #fff; border: 1px solid var(--tone-line); }
.proto-root .jc-vd-node { display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 800; color: var(--ink); }
.proto-root .jc-vd-node code { font-family: var(--mono); font-size: 12px; font-weight: 800; color: #fff; background: var(--tone); border-radius: 6px; padding: 3px 8px; }
.proto-root .jc-vd-who { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--ink-2); }
.proto-root .jc-vd-who > i { width: 20px; height: 20px; border-radius: 999px; display: inline-grid; place-items: center; font-style: normal; font-size: 10px; font-weight: 700; color: #fff; background: var(--tone); }
.proto-root .jc-vd-next { margin: 0; padding-top: 8px; border-top: 1px dashed var(--line-soft); font-size: 10.5px; line-height: 1.65; color: var(--ink-4); }
.proto-root .jc-vd-next b { color: var(--ink-2); font-weight: 700; }
/* 命中分支携带的条目 */
.proto-root .jc-pnlist { display: flex; flex-wrap: wrap; gap: 5px; margin: 0; padding: 0; list-style: none; }
.proto-root .jc-pnlist > li { display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 999px; background: #fff; border: 1px solid var(--tone-line); font-size: 11px; color: var(--ink-2); overflow-wrap: anywhere; }
.proto-root .jc-pnlist > li > i { flex: none; width: 5px; height: 5px; border-radius: 999px; background: var(--tone); }
.proto-root .jc-pnlist code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
/* ── 落地动作 ───────────────────────────────────────────────────────── */
.proto-root .jc-acts { display: flex; flex-direction: column; gap: 0; border: 1px solid var(--line-soft); border-radius: 11px; overflow: hidden; }
.proto-root .jc-act { display: flex; align-items: flex-start; gap: 9px; padding: 10px 13px; font-size: 12px; color: var(--ink-2); line-height: 1.6; background: #fff; }
.proto-root .jc-act + .jc-act { border-top: 1px solid var(--line-soft); }
.proto-root .jc-act > i { flex: none; width: 18px; height: 18px; margin-top: 1px; border-radius: 999px; display: inline-grid; place-items: center; color: #fff; background: var(--ok); }
.proto-root .jc-act b { font-weight: 700; color: var(--ink); }
.proto-root .jc-act span { color: var(--ink-4); }
.proto-root .jc-act code { font-family: var(--mono); font-size: 10.5px; color: var(--brand-deep); background: var(--brand-wash); border-radius: 4px; padding: 1px 5px; }
.proto-root .jc-done-tag { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; flex: none; padding: 6px 13px; border-radius: 999px; font-size: 11.5px; font-weight: 800; color: var(--ok); background: #fff; border: 1px solid #bfedb4; }
.proto-root .jc-vcell.dim { color: var(--ink-4); }
.proto-root .jc-tbl-in td.jc-ty { width: 44px; text-align: center; }
.proto-root .jc-tbl-in td.jc-ty { width: auto; text-align: left; }
`;
/**
* 图标精灵 —— 与样式表同一出处,两份目标件的 `<symbol>` 取并集。
* 沙箱里取不到图标字体,所以图标一律内联;`<use href="#i-xxx">` 引的就是这一坨。
*/
export const PRD_SPRITE = `<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs><symbol id="i-alert" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></symbol><symbol id="i-arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol><symbol id="i-boxes" viewBox="0 0 24 24"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></symbol><symbol id="i-building" viewBox="0 0 24 24"><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/><path d="M10 18h4"/></symbol><symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol><symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol><symbol id="i-file-check" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="m9 15 2 2 4-4"/></symbol><symbol id="i-file-text" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></symbol><symbol id="i-git-branch" viewBox="0 0 24 24"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></symbol><symbol id="i-history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></symbol><symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></symbol><symbol id="i-lock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol><symbol id="i-mail-open" viewBox="0 0 24 24"><path d="M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0z"/><path d="m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10"/></symbol><symbol id="i-pen" viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z"/></symbol><symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol><symbol id="i-sheet" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M8 13h2"/><path d="M14 13h2"/><path d="M8 17h2"/><path d="M14 17h2"/></symbol><symbol id="i-shield-check" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></symbol><symbol id="i-user" viewBox="0 0 24 24"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/></symbol><symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></symbol></defs></svg>`;
+565 -178
View File
@@ -1,192 +1,579 @@
/** /**
* A4 成本定价的 HTML 报告模版 —— **活动自己持有的资产**(§10.3),随包版本冻结。 * A4 成本定价的 HTML 报告模版 —— **活动自己持有的资产**,随包版本冻结。
* *
* 定价这件事最需要「把判据摊开」:**人签的不是一个数字,是这个数字的来路**。 * 两个局面各一张(与 A2/A3 同款口径):
* 所以三张屏不按「概览 / 明细 / 结论」排,按**一个价是怎么长出来的**排: * · `pricing-worksheet` **定价工作单** —— 算价与 BD 核价期间摆它:逐颗料一张定价档案卡
* ① 取价基准 —— 跟谁比。基准取错,后面每一条偏离率都是假的,而假偏离率长得跟真的一样; * (题面在左、定价推演在右)、利润模型 P-01 的毛利档、底价门禁 P-02 的三档尺、
* ② 定价与底价门禁 —— 凭什么是这个价。两张决策表的规则原样摆出来,人能逐条倒着核; * 填回工作表就长在同一屏;
* ③ 报价草案待放行 —— 签字前最后一次能改的机会,门后的都算数了。 * · `pricing-roster` **报价草案清单** —— 超限拍板、放行与归档摆它:整份草案只读,
* 门禁分布与落地动作在上面。
* *
* 一个局面一张,局面变了让 Agent 用 `view` 换,不在一张里堆条件分支。 * 「1:1」的落点是**每份件与它的办结快照**(在办可编辑 ↔ 回执收下那一刻的 finalHtml),
* 容器是 `sandbox=""` iframe:无脚本、无外链、样式自带;数据由基座推送时填 * 不是全程只许一张:作业的件与收口的件是两件工作两份件(规范姊妹篇 §1)。
* (`data-slot` 取值、`data-rows` 逐行展开)。
* *
* 两条贯穿全包的呈现纪律: * 版面语言沿用 A1 从 PRD 目标件机械抽取的那套(`prd-style.ts` 原样拷入)——
* · **状态章一律用「在办」灯,不用「已办」印** —— 模版是随包冻结的静态字符串, * A4 没有自己的 PRD 目标件,与 A2/A3 同一处置:沿用家族版面自造。
* 它盖不了一个还没发生的章。三张屏都是放行前的局面,盖「已通过」就是撒谎; *
* · **`verdict` 的橙档专指「还没发生,等你按」** —— 绿只留给已达标的机检结论。 * 取值三纪律:顶层只取汇总列与单值列;逐条走 `mine:quote_pricing.draft`;
* 放行后果是预告不是既成事实,一律走橙档。 * 列名逐字照 `declarations.ledger` 抄。**模版不算不判** —— 缺价红带、门禁徽、分布条
* 全部由基座算好的数经 `data-class-of` 投影,判断留在样式表。
*/ */
import { noteOnly, section, stagesOnly, statsBand, tableOnly, verdict, viewShell } from '@sdk'; import { PRD_CSS, PRD_SPRITE } from './prd-style.ts';
/** /* ==================== 绑定期补的样式(照 A1/A3 的 BIND_CSS,地址换成本活动)==================== */
* 静态阶梯 —— **规则本身不是数据**:P-01 的毛利档、P-02 的三档处置随包冻结,
* 它们不该走 `data-rows` 等基座来填(那会让「规则」看起来像「这一单的结果」)。 const BIND_CSS = `
* 沿用外壳已有的 `.steps` 版式,与引擎推的阶段列表同一副面孔 —— 页面上只该有一套样式。 /* 空占位三级(2026-08-02 全局口径):必填红 / 建议填橘 / 选填灰 —— 值真的空才轮到 */
*/ .proto-root .jc-tbl-in td.req > .jc-vcell:empty::after,
function ladder(rows: ReadonlyArray<readonly [string, string, string]>): string { .proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after,
return `<ul class="steps">${rows .proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after {
.map( display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
([no, term, gloss]) => line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
`<li><span class="n">${no}</span><span><span class="t">${term}</span> <span class="d">${gloss}</span></span></li>`, }
) .proto-root .jc-tbl-in td.req > .jc-vcell:empty::after { content: "必填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
.join('')}</ul>`; .proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after { content: "建议填"; color: var(--warn-accent); background: var(--warn-soft); border-color: #f0cf9a; }
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after { content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line); }
/* 归档件里空格子就是「没有」,不再摆「你还能填」的胶囊 */
.proto-root .jc-tbl-in td.dimempty > .jc-vcell:empty::after { content: "—"; color: var(--ink-4); border: 0; background: none; padding: 0; }
/* 缺拟报价的整行浅红 + 行首红条 —— 原型的 tr.miss,这里由 :has() 认 */
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td { background: #fffbfb; }
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty):hover td { background: #fff5f5; }
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn { color: var(--danger); font-weight: 800; position: relative; }
@media (min-width: 1001px) {
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn::after {
content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger);
}
} }
/** 局面一:S1 算价刚开场 —— 人此刻要核的是「这一单跟谁比、落在哪一档」 */ /* 序号由 CSS 计数器排 */
export const PRICING_BASIS = viewShell({ .proto-root .jc-tbl-in tbody { counter-reset: rn; }
title: '取价基准', .proto-root .jc-tbl-in td.jc-rn::before { counter-increment: rn; content: counter(rn); }
kicker: 'A4 · 成本定价 · S1 算价', @media (max-width: 1000px) {
tone: 'brand', .proto-root .jc-tbl-in td.jc-rn::before { content: "第 " counter(rn) " 条"; }
icon: 'coins', .proto-root .jc-tbl-in td.jc-rn::after { content: none; }
stamp: { kind: 'live', text: '算价中' }, }
sign: 'A4 成本定价 · 取价基准(本单草稿)',
/* 就地改:可改格子渲了只读与输入两份,默认锁着,表上加 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: body:
/* part('定价任务', '客户 <b data-slot="customer_name" data-blank></b> · 共 <b data-slot="line_total"></b> 条 · 有基准价 <b data-slot="basis_total"></b> 条') +
* 指标带摆的是**利润模型 P-01 的三个入参 + 这一单的工作量**,不是所有字段。 `<div class="jc-derive">${ico('info', 14)}
* 三个档位键取的是清单里 P-01 声明的决策入参名(customerGrade / materialType / <span>这一屏是一份报价草案的作业台:系统按「成本基准 × 利润模型」给出底价线与
* volumeTier)—— 决策表照这三个名字取值,模版就照这三个名字摆,不另起炉灶。 <b>建议价</b>,<b>拟报单价那一列由你落笔</b> —— 人签的就是这个数,系统不代签。
* `records` 是起单入参的数组,基座渲染数组时给的是条数,正好是「这一单要定几条价」。 库内查不到成本的料连建议价都没有(不编),那几行按你的经验定并在备注写明依据。
*/ 你定完价,毛利、偏离度与底价门禁的结论跟着重算。</span>
statsBand([ </div>` +
['待定价条目', 'records'], FOUR +
['客户等级', 'customerGrade'], part('一个价是怎么长出来的', '成本 → 毛利档 → 底价线 → 拟报价 → 门禁') +
['料号类型', 'materialType'], PRICE_LINE +
['数量档', 'volumeTier'], part('逐颗定价档案', '题面在左 · 推演在右') +
]) + PRICE_CARDS +
section('基准从哪来 —— 四级优先级', part('利润模型 P-01 的毛利档', '客户等级 × 类别 × 量档 —— 尺挂在墙上') +
ladder([ MARGIN_RULES +
['1', '本客户近期成交价', '最贴近这一单的真实议价空间'], part('底价门禁 P-02', '偏离底价线两成即止') +
['2', '同型号他客户成交价', '价位可比,但议价空间要留余量'], GATE_RULES +
['3', '供应商底价表', '成本侧的锚,不含客户侧信息'], part('填回工作表', '定价落在这 —— 补齐了执行单才提交得了') +
['4', '无基准', '新料号、久未报价的老料号都会落到这里'], GAP_BAR +
]) + editGate('quote_pricing.draft', '报价草案填回表') +
noteOnly( DRAFT_TABLE +
'<b>无基准不是异常,它本身就是一种基准。</b>此时偏离率记 0 —— 没有可偏离的对象,' + addRowBar('quote_pricing.draft') +
'由底价门禁按「无基准」处置。不要拿一个不相干的价去凑一个偏离率出来:' + `<div class="jc-drop">${ico('info', 13)}
'那比没有基准更危险,因为它<b>看起来像有依据</b>。', <span>带红标的<b>拟报单价</b>是必填,而且<b>只由你填</b> —— 缺一行执行单按不下提交,
), 与放行门是同一个数同一套算法。系统给的是建议价:照抄即认可,改了以你的为准。
{ text: '越靠前越贴近实情' }, 查不到成本的料没有建议价(系统不编):按你的经验定一个价并在备注写明依据,
) + 或者按行首 × 把这一条撤出本轮。压在拦截档(红)的条目在你这儿往上调一点,
section('本单待定价条目', 就不必再惊动第二个人。</span>
tableOnly('mine:quote_pricing.draft', [ </div>`,
['型号 PN', 'pn', 'mono'], foot: sign('BD 核价 · AI 按利润模型与底价门禁算价,你定最后那个数', '统一报价 · A4 成本定价 · 在办'),
['基准价', 'last_price'],
['拟报单价', 'quoted_price'],
['毛利率', 'margin'],
]),
{ text: '只列本单写下的行' },
) +
section('案例当下进程', stagesOnly('case:stages'), { text: 'Enabled ≠ Active' }),
}); });
/** 局面二:S2 自检 —— 人此刻要核的是「凭什么是这个价、门禁怎么判的」 */ /* ==================== 局面二 · 报价草案清单(拍板 / 放行 / 归档)==================== */
export const PRICING_PRICED = viewShell({
title: '定价与底价门禁',
kicker: 'A4 · 成本定价 · S2 自检',
tone: 'brand',
icon: 'gauge',
stamp: { kind: 'live', text: '自检中' },
sign: 'A4 成本定价 · 定价自检(判定表 P-01 / P-02)',
body:
/* 标签一律照台账声明的列名(基准价 / 拟报单价 / 毛利率 / 偏离度 / 门禁处置):
同一个数在指标带上叫一个名、在下面的表里叫另一个名,签字的人就得先花一秒确认它俩是不是一回事 */
statsBand([
['基准价', 'last_price'],
['拟报单价', 'quoted_price'],
['毛利率', 'margin'],
['偏离度', 'deviation'],
]) +
section('逐条定价结论',
tableOnly('mine:quote_pricing.draft', [
['型号 PN', 'pn', 'mono'],
['基准价', 'last_price'],
['拟报单价', 'quoted_price'],
['毛利率', 'margin'],
['偏离度', 'deviation'],
['门禁处置', 'disposition'],
]),
{ text: '判据在左 · 结论在右' },
) +
section('目标毛利怎么定的 —— 利润模型 P-01',
ladder([
['1', '战略客户 · A 类标准件 · 大批量', '目标毛利 8%–10%'],
['2', '普通客户 · A 类标准件', '12%–15%'],
['3', 'B 类波动件', '15%–20%'],
['4', 'C 类替代 / EOL', '≥20%,或一事一议'],
['5', '以上都不命中', '兜底按普通客户标准件 12%–15%'],
]) +
noteOnly(
'自上而下取<b>第一条命中</b>的规则,同输入必得同结论、零方差零 token,命中的规则行随出参留痕。' +
'不要为了让门禁「过」而回头改毛利参数 —— 那是把判据改成结论,决策表也就失去了它唯一的价值。',
),
{ text: '首条命中' },
) +
section('偏离怎么判的 —— 底价门禁 P-02',
ladder([
['1', '拟报价 ≥ 底价', '直接放行'],
['2', '低于底价 ≤ 20%', 'PM 附理由放行'],
['3', '低于底价 超 20%', '拦截 · BOSS 拍板'],
]) +
noteOnly(
'门禁不是「拦下」而是<b>分流</b>:超限的一律调人 —— 要么回调价格,要么写明为什么这样报,' +
'两条路都合法,但都要留下理由。这条留痕将来会成为客户等级与利润模型的迭代素材。',
),
{ text: '±20% 即止' },
) +
section('本单门禁结论',
/*
* 结论章里嵌 `data-slot`:处置与偏离都是**这一单算出来的事实**,
* 写死一句「已通过」就是替判定表下结论。橙档在此的意思是「还没定,等人处置」。
*/
verdict('底价门禁 P-02 处置:<b data-slot="disposition"></b>', 'wait', '偏离度 <b data-slot="deviation"></b>') +
noteOnly(
'禁区两条随本单同行:<b>不得静默绕开 P-02 的结论</b> —— 要破线只能走「人 · 决策」;' +
'<b>底价超一周未更新的,先刷底价再判</b> —— 拿旧底价判新行情等于没判。',
),
),
});
/** 局面三:待放行 —— 验收单上人要签的那份成果报告 */ export const PRICING_ROSTER = doc({
export const PRICING_RELEASE = viewShell({ badge: 'file-check',
title: '报价草案 · 待放行', kicker: '统一报价 · A4 成本定价 · 交付成果',
kicker: 'A4 · 成本定价 · 成果待签', title: '报价草案',
tone: 'case', titleRef: 'rfq_ref',
icon: 'shield-check', state:
stamp: { kind: 'live', text: '待签字放行' }, '<span class="jc-rp-wait">草案 <b data-slot="quote_pricing_draft_rows"></b> 条 · 待拍板 <b data-slot="quote_pricing_draft_disposition_block"></b></span>',
sign: 'A4 成本定价 · 报价草案(待放行)',
body: body:
statsBand([ part('定价要素', '成本定价 · 能算的归判定表,超限的归人') +
['拟报单价', 'quoted_price'], FOUR +
['毛利率', 'margin'], part('底价门禁分布', '直接放行 + 附理由 + 拦截 = 总条数') +
['偏离度', 'deviation'], DIST +
['门禁处置', 'disposition'], `<div class="jc-derive">${ico('info', 14)}
]) + <span>三段条数由基座逐行现数,不是谁报上来的:红段一条不剩,这份草案才走得到放行门。
section('本单交付:报价草案', 压在拦截档的条目只有三条出路 —— 回调到底价线、由负责人拍板破线并留理由、
tableOnly('mine:quote_pricing.draft', [ 或者本轮不报。</span>
['型号 PN', 'pn', 'mono'], </div>` +
['基准价', 'last_price'], part('报价草案', '<b data-slot="line_total"></b> 条 · 每条都带来路') +
['拟报单价', 'quoted_price'], GAP_BAR +
['毛利率', 'margin'], editGate('quote_pricing.draft', '报价草案') +
['偏离度', 'deviation'], DRAFT_TABLE +
['门禁处置', 'disposition'], `<div class="jc-drop">${ico('info', 13)}
]) + <span>每一行摆的是这个价的<b>来路</b>:成本基准从哪来、毛利档是哪一档、底价线在哪、
noteOnly('只列<b>本单</b>写下的行 —— 别单放行过的记录不混进来。逐条列出,便于放行前逐条核对。'), 偏离多少、门禁怎么判。签字前请重点看两处:<b>门禁列</b>里非绿的条目,
{ text: '本单草稿' }, 与<b>备注列</b>里的让价理由 —— 没有理由的让价,到了审批那一关同样过不去。</span>
) + </div>` +
section('放行前要过的三道判据', part('放行后即执行的落地动作', '门前 → 门后') +
ladder([ `<div class="jc-acts">
['1', '每条都有拟报单价与毛利率', 'L1 机检 · unpriced = 0'], <div class="jc-act"><i>${ico('check', 11)}</i>
['2', '偏离底价 ≤ ±20%,超限者已回调或注明理由', 'L2 机检 · 底价门禁 P-02'], <div><b>草稿层合并进正式版</b>&#12288;<span>本任务写下的报价草案与定价结论一并落账;
['3', 'BD 放行', 'L5 人签 · 签了才算数'], 你就地改过的那几个价随之落定。</span></div>
]), </div>
{ text: '两机检 · 一人签' }, <div class="jc-act"><i>${ico('check', 11)}</i>
) + <div><b>草案此刻才对外发出</b>&#12288;<span>整份 <b data-slot="quote_pricing_draft_rows"></b> 条
section('放行之后会发生什么', (每条带拟报单价与门禁处置)交出 —— 放行之前,世界上什么都没有发生。</span></div>
verdict('放行即落地:草稿合并入正式版,草案提交 A5 定价审批', 'wait', '审批点卡在 PM 工位') + </div>
noteOnly( <div class="jc-act"><i>${ico('check', 11)}</i>
'审批驳回按<b>声明</b>走向,不由临场决定:<b>回 A4 重开新实例</b> —— 新任务 ID、留痕关联,' + <div><b>转入定价审批</b>&#12288;<span>本活动只有一个出口 —— 报价草案是审批环节的直接输入,
'不复活旧任务。所以这一屏上还改得动的每一格,过了这道门就要花一整个新实例才改得回来。', 已进对方的任务清单。</span></div>
), </div>
), </div>`,
foot: sign('BD 定价 · AI 按判定表算价并留痕 —— 要改哪一格,在这张单的草案表上就地改', '统一报价 · A4 成本定价 → 交审批'),
}); });
@@ -0,0 +1,313 @@
/**
* A3 寻源询价的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。
* 案例活动没有 `run`,自测不跑回合;测的是清单本身与三处对齐
* (判据谓词 / 出口载荷 / 剧本声明)。
*/
import { describe, expect, it } from 'vitest';
import { activityManifestSchema } from '@contracts';
import { evaluateCondition } from '@sfeel';
import { createMockHost } from '@sdk';
import { manifest } from '../src/manifest.ts';
import activity from '../src/index.ts';
describe('清单本身合规', () => {
it('过 @contracts 的 schema', () => {
expect(() => activityManifestSchema.parse(manifest)).not.toThrow();
});
it('监听时限已配 —— 无悬空监听(AM 4.4)', () => {
expect(manifest.declarations.listeners.every((l) => /^P/.test(l.deadline))).toBe(true);
});
it('L1/L2 判据都给了可机检的谓词 —— 验收等级不得谎报', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef, c.key).toBeDefined();
}
});
it('禁区短而硬,授权边界给了调人预算', () => {
expect(manifest.contract.prohibitions.length).toBeGreaterThan(0);
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(0);
});
it('每件必交付物都有台账地址', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(d.ledger, d.key).toBeDefined();
}
});
/*
* MVP 口径:A3 **没有判定表**。上一版的 STRATEGY_DMN 声明着三个入参而台账没有承载、
* 剧本零调用。取价规则(A 取历史价 / B 问货源价)写死在剧本。
*/
it('没有声明无求值器的判定表 —— 取价规则写死在剧本', () => {
expect(manifest.assets.businessModel.dmn.tables).toEqual([]);
const normalize = manifest.assets.businessModel.steps?.find((s) => s.item === 'S1_normalize');
expect(normalize?.guide).toContain('HIST');
});
/*
* **底价是必填** —— 这是 A3 的门面判据:件上的缺价红带、执行卡的提交闸、
* 放行门 L1 三处同源(都数基座的 `_missing`)。声明丢了 required,三处一起失明。
*/
it('底价标了 required —— 红带 / 提交闸 / 放行门三处同源的那个数才存在', () => {
const quoteList = manifest.declarations.ledger.find((d) => d.object === 'quote_list');
expect(quoteList?.fields.find((f) => f.name === 'base_price')?.required).toBe(true);
});
});
describe('案例模型 —— 双表示同版本', () => {
const model = manifest.assets.businessModel;
const cmmn = parseCmmnLite(model.cmmnXml);
it('CMMN 与说明书同在,不允许分叉', () => {
expect(model.cmmnXml).toContain('<casePlanModel');
expect(model.narrative.length).toBeGreaterThan(400);
});
it('计划项照状态机一步一项:归一预填 + 补价 + 汇稿 + 放行,不多建一件', () => {
expect(items(model.cmmnXml)).toEqual(['S1_normalize', 'S2_fill', 'S3_compile', 'T_release']);
});
it('两处人的门都标了人工激活:PRM 补价 · 清单放行', () => {
for (const id of ['S2_fill', 'T_release']) {
const block = model.cmmnXml.slice(model.cmmnXml.indexOf(`id="${id}"`));
expect(block.slice(0, block.indexOf('</humanTask>')), `${id} 少了 manualActivationRule`).toContain(
'<manualActivationRule',
);
}
});
it('不用阶段 / 里程碑 / 裁量项', () => {
for (const tag of ['<stage', '<milestone', '<planningTable', '<discretionaryItem']) {
expect(model.cmmnXml, `不该出现 ${tag}`).not.toContain(tag);
}
});
it('常来回的项标了可重复 —— 归一会随剔行改数重入', () => {
expect(cmmn.repetition).toContain('S1_normalize');
});
it('放行门哨兵带 ifPart:每行有价 + 价源守恒 + 定盘记过,全读基座数', () => {
const condition = sentryIf(model.cmmnXml, 's_ready');
expect(condition).toContain('quote_sourcing_quote_list_missing = 0');
expect(condition).toContain('quote_sourcing_quote_list_price_basis_hist + quote_sourcing_quote_list_price_basis_sourced');
const done = {
route_decided_total: 1,
quote_sourcing_quote_list_rows: 3,
quote_sourcing_quote_list_missing: 0,
quote_sourcing_quote_list_price_basis_hist: 1,
quote_sourcing_quote_list_price_basis_sourced: 2,
};
expect(safeEval(condition, done)).toBe(true);
// 还有一行缺底价 → 门不开
expect(safeEval(condition, { ...done, quote_sourcing_quote_list_missing: 1 })).toBe(false);
// 价源没记齐(守恒不成立)→ 门不开
expect(safeEval(condition, { ...done, quote_sourcing_quote_list_price_basis_sourced: 1 })).toBe(false);
});
it('每个计划项都有剧本', () => {
const ids = items(model.cmmnXml);
expect((model.steps ?? []).map((s) => s.item)).toEqual(ids);
for (const step of model.steps ?? []) {
expect(step.guide.length, `${step.item} 的做法`).toBeGreaterThan(80);
expect(step.intent.length, `${step.item} 的意图`).toBeGreaterThan(6);
}
});
it('说明书不点名计划项', () => {
const named = [...model.narrative.matchAll(/\b(?:S\d+_[A-Za-z]+|T_[a-z]+)\b/g)].map((m) => m[0]);
const ids = new Set(items(model.cmmnXml));
for (const name of named) {
expect(ids.has(name), `说明书提到的 ${name} 不在案例模型里`).toBe(true);
}
});
it('说明书只讲整单的事:成品、怎么说话、怎么收口', () => {
for (const topic of ['开案', '要说话', '一拍连做', '结案']) {
expect(model.narrative, topic).toContain(topic);
}
});
});
describe('声明与契约对得上', () => {
const columns = new Set(
manifest.declarations.ledger.flatMap((d) => [`${d.domain}.${d.object}`, ...d.fields.map((f) => f.name)]),
);
it('必交付物的台账地址都声明过', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(columns.has(d.ledger ?? ''), `${d.key} → ${d.ledger}`).toBe(true);
}
});
it('editableAtRelease 的列都在成果表的声明里', () => {
const quoteList = manifest.declarations.ledger.find((d) => d.object === 'quote_list');
const names = new Set((quoteList?.fields ?? []).map((f) => f.name));
for (const d of manifest.contract.requiredDeliverables) {
for (const col of d.editableAtRelease ?? []) {
expect(names.has(col), `editableAtRelease 里的 ${col}`).toBe(true);
}
}
});
const rowReadings = new Set([
...manifest.declarations.ledger.map((d) => `${d.domain}_${d.object}_rows`),
...manifest.declarations.ledger
.filter((d) => d.fields.some((f) => f.required === true))
.map((d) => `${d.domain}_${d.object}_missing`),
...manifest.declarations.ledger.flatMap((d) =>
d.fields.flatMap((f) =>
(f.tally ?? []).map((v) => `${d.domain}_${d.object}_${f.name}_${v.toLowerCase().replace(/[^a-z0-9_]/g, '')}`),
),
),
]);
it('L1/L2 谓词读的名字都有承载(台账列或基座读数)', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level !== 'L1' && c.level !== 'L2') continue;
for (const identifier of (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? []) {
if (['and', 'or', 'not', 'true', 'false', 'null', 'in', 'between'].includes(identifier)) continue;
if (rowReadings.has(identifier)) continue;
expect(columns.has(identifier), `${c.key} 的谓词引用了 ${identifier}`).toBe(true);
}
}
});
it('出口载荷的标量字段都有台账列承载', () => {
const snake = (name: string) => name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
for (const port of manifest.contract.output.mandatory) {
const properties = (port.payload as { properties?: Record<string, { type?: string }> }).properties ?? {};
for (const [field, spec] of Object.entries(properties)) {
if (spec.type === 'array') continue;
expect(columns.has(snake(field)), `${port.name}.${field} → ${snake(field)}`).toBe(true);
}
}
});
it('技能引用的工具都在白名单声明内 —— 载方法不载权力', () => {
const declared = new Set(manifest.declarations.tools.map((t) => t.name));
for (const skill of manifest.assets.skills) {
for (const fragment of skill.crystallized) {
expect(declared.has(fragment.tool), `${skill.id} 用了 ${fragment.tool}`).toBe(true);
}
}
});
it('L3 判据的整句出现在放行剧本里 —— judge 才对得上名', () => {
const l3 = manifest.contract.acceptanceCriteria.find((c) => c.level === 'L3');
const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
expect(l3).toBeDefined();
expect(release?.guide, 'T_release 的 guide 要整句抄 L3 判据名').toContain(l3?.label ?? '');
});
});
describe('报告模版 —— 活动持有,容器无脚本执行权', () => {
it('两个局面各一张:询价工作单(干活)与目标报价清单(收口),同一局面在办与归档同一张', () => {
expect(manifest.assets.viewTemplates.map((t) => t.name)).toEqual(['sourcing-worksheet', 'sourcing-roster']);
});
/*
* 执行阶段的件必须是**询价工作台**,不是验收单(A2 同款口径,2026-08-02)。
* 工作单四要件:逐颗询价档案(题面含取价对象与客户约束)、按场景圈渠道的规则、
* 价格验证的尺、填回工作表 —— 素材出处 MVP 示例工作流 §1.7,只作文字参考。
*/
it('询价工作单是干活的件:档案卡 + 场景圈渠道 + 验价的尺 + 时限', () => {
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'sourcing-worksheet')?.html ?? '';
expect(sheet).toContain('jc-selcard');
expect(sheet).toContain('data-slot="remark"');
expect(sheet).toContain('取价对象');
for (const scene of ['BOM 早期', 'BOM 后期', '现货', '样品']) expect(sheet).toContain(scene);
expect(sheet).toContain('无库存转单商不可选');
expect(sheet).toContain('历史最低价降两成');
});
it('模版里没有脚本、没有事件处理器、没有外链 —— 样式自带', () => {
for (const template of manifest.assets.viewTemplates) {
expect(template.html, template.name).not.toMatch(/<script|\son[a-z]+=|https?:\/\//i);
expect(template.html, template.name).toContain('<style>');
}
});
/*
* A3 只有一个出口(E7 无条件边),**没有去向要判** —— 件上不摆判定表,
* 也不许残留 `case:route*` 的空壳(没有选择组它永远是空的,一屏灰行像判定没跑)。
*/
it('单出口活动:没有选择组,件上也不摆去向判定', () => {
expect(manifest.contract.output.selective).toEqual([]);
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'sourcing-roster')?.html ?? '';
expect(roster).not.toContain('case:routeKey');
});
it('缺价红带与提交闸同源 —— 件上摆的是基座的 `_missing` 与主键名单', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'sourcing-roster')?.html ?? '';
expect(roster).toContain('quote_sourcing_quote_list_missing');
expect(roster).toContain('quote_sourcing_quote_list_missing_keys');
});
it('编辑面 = PRM 的价格五列 + 行的钥匙 pn —— 保存按格里的主键认行(A2 走查逮过同款)', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'sourcing-roster')?.html ?? '';
const cols = [...roster.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]);
expect(new Set(cols)).toEqual(new Set(['pn', 'supplier', 'base_price', 'moq', 'source_lead_time', 'note']));
});
it('价源分布读的是基座按值现数的两段 —— 守恒等式的可视形', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'sourcing-roster')?.html ?? '';
expect(roster).toContain('quote_sourcing_quote_list_price_basis_hist');
expect(roster).toContain('quote_sourcing_quote_list_price_basis_sourced');
});
it('宿主拒推含脚本的模版', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
});
});
const CONTRACT = {
activityId: 'quote-sourcing',
version: '1.0.0',
data: { rfqRef: 'RFQ MD14.1', rows: [] },
message: undefined,
outputPorts: { mandatory: ['quotation'], selective: [] },
prohibitions: [],
discretion: [],
} as const;
describe('代码入口 —— 案例活动没有 run,不是残包', () => {
it('默认导出是一个有名字的活动定义', () => {
expect(activity.name).toBe('quote-sourcing');
});
it('mock 宿主如实报「没有 run」,不静默返回一个假的 outcome', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.run(activity)).rejects.toThrow(/没有 run/);
});
});
function items(xml: string): string[] {
return [...xml.matchAll(/<(?:humanTask|decisionTask|processTask|caseTask) id="([^"]+)"/g)].map((m) => m[1] ?? '');
}
function parseCmmnLite(xml: string): { manualActivation: string[]; repetition: string[] } {
const manualActivation: string[] = [];
const repetition: string[] = [];
for (const m of xml.matchAll(/<humanTask id="([^"]+)"[\s\S]*?<\/humanTask>/g)) {
if (m[0].includes('manualActivationRule')) manualActivation.push(m[1] ?? '');
if (m[0].includes('repetitionRule')) repetition.push(m[1] ?? '');
}
return { manualActivation, repetition };
}
function sentryIf(xml: string, sentryId: string): string {
const block = xml.slice(xml.indexOf(`<sentry id="${sentryId}"`));
const raw = /<condition>([\s\S]*?)<\/condition>/.exec(block.slice(0, block.indexOf('</sentry>')))?.[1] ?? '';
return raw.replaceAll('&gt;', '>').replaceAll('&lt;', '<').replaceAll('&amp;', '&');
}
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
@@ -0,0 +1,223 @@
/**
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(A1 的账)。
*/
import { describe, expect, it } from 'vitest';
import { evaluateCondition } from '@sfeel';
import { CMMN_XML, SOURCING_PHASES, SOURCING_STEPS } from '../src/case-model.ts';
import { manifest } from '../src/manifest.ts';
const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map(
(m) => m[1] ?? '',
);
describe('剧本与案例模型对得上 —— 对不上的条目等于没写', () => {
it('每一条剧本都指向一个真实存在的计划项', () => {
for (const step of SOURCING_STEPS) {
expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
}
});
it('图上每一步都有剧本', () => {
for (const id of ITEM_IDS) {
expect(SOURCING_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
}
});
it('清单里挂的就是这份剧本(不是另抄一份)', () => {
expect(manifest.assets.businessModel.steps).toBe(SOURCING_STEPS);
});
});
describe('doneWhen 是「成果已存在」的判据,不是「该不该做」的判据', () => {
/** 开案那一刻的三种局面:选型转来 / 受理直达 / 单飞 —— 台账一行没有 */
const FRESH_ALT: Record<string, unknown> = {
rfqRef: 'RFQ MD14.1',
rows: [{ pn: 'BBPY00201209601Y00', type_tag: 'B', alt_pn: 'PBY201209T-601Y-N' }],
};
const FRESH_INTAKE: Record<string, unknown> = {
rfqRef: 'RFQ-2026-0555',
records: [{ pn: 'PESD2ETH-XR', type_tag: 'B' }],
};
const FRESH_SOLO: Record<string, unknown> = { subject: '两颗现货料问底价', body: 'SS8050 20K……' };
it('**开局一步都不该被自动收掉** —— 三种开局都要 false', () => {
for (const fresh of [FRESH_ALT, FRESH_INTAKE, FRESH_SOLO]) {
for (const step of SOURCING_STEPS) {
if (step.doneWhen === undefined) continue;
expect(
safeEval(step.doneWhen, fresh),
`${step.item} 的 doneWhen「${step.doneWhen}」开局就为真`,
).toBe(false);
}
}
});
/* ── ① 归一预填 ─────────────────────────────────────────────── */
it('整份落表、行数对上、预填走完 → 归一收得掉(没有 A 类时预填数是写过的 0)', () => {
expect(
safeEval(doneOf('S1_normalize'), {
quote_line_total: 3,
quote_sourcing_quote_list_rows: 3,
hist_priced_total: 1,
}),
).toBe(true);
expect(
safeEval(doneOf('S1_normalize'), {
quote_line_total: 2,
quote_sourcing_quote_list_rows: 2,
hist_priced_total: 0,
}),
).toBe(true);
});
it('**预填那一轮没走完 → 归一收不掉** —— hist_priced_total 没写过,未绑定比较为假', () => {
expect(
safeEval(doneOf('S1_normalize'), { quote_line_total: 3, quote_sourcing_quote_list_rows: 3 }),
).toBe(false);
});
it('少拆多写都收不掉 —— 自报条数与基座行数对不上', () => {
expect(
safeEval(doneOf('S1_normalize'), {
quote_line_total: 3,
quote_sourcing_quote_list_rows: 2,
hist_priced_total: 0,
}),
).toBe(false);
});
/* ── ② 交 PRM 补价:人环 ────────────────────────────────────── */
it('**PRM 那一步不给 doneWhen** —— 引擎不许替人交卷', () => {
expect(SOURCING_STEPS.find((s) => s.item === 'S2_fill')?.doneWhen).toBeUndefined();
});
it('补价这一步摆的是询价工作单(不是收口清单):卡上零输入、闸看必填、答复处理记 sourced_total', () => {
const fill = SOURCING_STEPS.find((s) => s.item === 'S2_fill');
expect(fill).toBeDefined();
expect(fill?.view).toBe('sourcing-worksheet');
/*
* **执行卡零输入**(2026-08-02 用户定:填写全去工作区)——
* 供应商与底价在工作单那份件上就地填,卡上不绑表;
* 必填(底价)由提交闸看住,闸回落到必交付物的台账声明。
* 从前这儿断言卡里带 `"ledger":"quote_sourcing.quote_list"` 的 blocks。
*/
expect(fill?.guide).toContain('卡上不摆表、不摆格子');
expect(fill?.guide).not.toContain('"blocks"');
expect(fill?.guide).toContain('提交闸');
expect(fill?.guide).toContain('sourced_total');
});
/* ── ③ 汇稿定盘:收步判据与验收判据同源 ─────────────────────── */
const compiled = (over: Record<string, unknown>) => ({
quote_sourcing_quote_list_rows: 3,
quote_sourcing_quote_list_missing: 0,
quote_sourcing_quote_list_price_basis_hist: 1,
quote_sourcing_quote_list_price_basis_sourced: 2,
route_decided_total: 1,
...over,
});
it('每行有价 + 价源守恒 + 定盘记过 → 汇稿收得掉', () => {
expect(safeEval(doneOf('S3_compile'), compiled({}))).toBe(true);
});
it('**还有一行缺底价 → 收不掉** —— 与提交闸、放行门读同一个数', () => {
expect(safeEval(doneOf('S3_compile'), compiled({ quote_sourcing_quote_list_missing: 1 }))).toBe(false);
});
it('**价源没记齐(守恒不成立)→ 收不掉** —— 松出来的那一截就是返工', () => {
expect(safeEval(doneOf('S3_compile'), compiled({ quote_sourcing_quote_list_price_basis_sourced: 1 }))).toBe(false);
});
it('没记过定盘数 → 收不掉 —— 未绑定的名字比大小为假', () => {
const scope: Record<string, unknown> = compiled({});
delete scope['route_decided_total'];
expect(safeEval(doneOf('S3_compile'), scope)).toBe(false);
});
it('**放行那一项不给 doneWhen** —— 终局唯放行', () => {
expect(SOURCING_STEPS.find((s) => s.item === 'T_release')?.doneWhen).toBeUndefined();
});
it('归一那一步声明了 reads: rows 与 records —— 上游载荷置前不截断', () => {
const reads = SOURCING_STEPS.find((s) => s.item === 'S1_normalize')?.reads ?? [];
expect(reads).toContain('rows');
expect(reads).toContain('records');
});
});
describe('剧本声明的东西活动真有', () => {
const declaredLedger = manifest.declarations.ledger.map((l) => `${l.domain}.${l.object}`);
const declaredTools = manifest.declarations.tools.map((t) => t.name);
it('每一步要写的台账都在清单的台账声明里', () => {
for (const step of SOURCING_STEPS) {
for (const address of step.writes) {
expect(declaredLedger, `${step.item} 要写 ${address}`).toContain(address);
}
}
});
it('每一步要调的工具都在清单的工具声明里', () => {
for (const step of SOURCING_STEPS) {
for (const tool of step.tools) {
expect(declaredTools, `${step.item} 要调 ${tool}`).toContain(tool);
}
}
});
it('每一步都摆同一份件(一单一模版贯穿始终),且模版真在包里', () => {
const names = new Set(manifest.assets.viewTemplates.map((t) => t.name));
for (const step of SOURCING_STEPS) {
expect(step.view, `${step.item} 没定画面`).toBeDefined();
expect(names.has(step.view ?? ''), `${step.item} 的 view「${step.view}」不在模版名单里`).toBe(true);
}
});
it('每一步都说得出它交出什么', () => {
for (const step of SOURCING_STEPS) {
expect(step.intent.trim(), `${step.item} 没写 intent`).not.toBe('');
expect(step.guide.trim().length, `${step.item} 的 guide 太短`).toBeGreaterThan(20);
}
});
});
function doneOf(item: string): string {
const found = SOURCING_STEPS.find((s) => s.item === item)?.doneWhen;
if (found === undefined) throw new Error(`${item} 没有 doneWhen`);
return found;
}
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
describe('阶段刻度:每一项都有格可待', () => {
const inPhase = new Set(SOURCING_PHASES.flatMap((p) => p.items));
it('**除放行外每一项恰好属于一个阶段**', () => {
const planned = SOURCING_STEPS.map((s) => s.item).filter((id) => id !== 'T_release');
expect(planned.filter((id) => !inPhase.has(id))).toEqual([]);
});
it('一项不进两个阶段', () => {
const all = SOURCING_PHASES.flatMap((p) => p.items);
expect(all.length).toBe(new Set(all).size);
});
it('**放行不在名单里** —— 收口那一格由基座统一加', () => {
expect(inPhase.has('T_release')).toBe(false);
});
it('阶段里不许出现案例模型没有的项', () => {
const ids = new Set(SOURCING_STEPS.map((s) => s.item));
expect([...inPhase].filter((id) => !ids.has(id))).toEqual([]);
});
});
+235 -85
View File
@@ -1,132 +1,282 @@
/** /**
* A3 渠道寻源的案例模型 —— **一张执行单办完**(2026-07-31 用户定,与 A2 同一口径)。 * A3 寻源询价的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
* *
* 上一版按四种询价场景(BOM 早期 / BOM 后期 / 现货 / 样品)把流程铺开成一串计划项。 * 业务出处:`docs/MVP示例工作流.html` §1.7,按 **MVP 简化口径**建模
* 场景之分在业务上是真的,可它**不改变系统要管的事**:发函、收回价、比价、给结论。 * (2026-08-02 用户定:A3 与 A2 同为复杂活动,MVP 期间不展开大规模业务建模):
* 而这四步在一次寻源里是连着做的 —— 采购不会办完「发函」就交回来等系统叫他。
* 把它切成四项,换来的只是 Agent 每一步都要跟 harness 对一次账。
* *
* 现在只有两项: * 上游(A2 的询价受理清单,或 A1 直达的 B 类受理记录)进来 → 归一成
* T_source —— 派一张执行单给采购(卡面两张可编辑表:供应商回价 / 寻源比价),他填完提交; * **目标报价清单**底表,A 类行按库内历史价预填基准 → **一张执行单**让采购(PRM)
* T_release —— 放行(触达世界,人工激活)。 * 在系统外完成询价、把 B 类行的货源价格填回来 → 汇稿定盘 → 放行,
* 整份目标报价清单交给成本定价。
* *
* 场景之分不进模型,进**说明书**:采购自己知道 BOM 早期该广撒网、现货该问库存。 * SOP 里接需核史 / 选渠定策 / 发询比价那几段的实质工作(圈渠道、发函、催回价、
* 那是他的判断,不是系统的步。 * 验价)在 PRM 系统外;四种场景(BOM早期/后期/现货/样品)的尺度之分是他的判断,
* 不是系统的步。系统的本分:归一、预填、组卡、齐检、留痕。
*
* 上一版(@2.0.0)只有「派单 + 放行」两项,入参必填 `pn + scenario`(单料形状,
* 与上游发的整份清单对不上),两条 L2 判据读活动自报数。这一版整个置换。
*/ */
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?> export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL"> <definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
<case id="sourcing"> <case id="sourcing">
<!-- <!--
案卷项的 id **就是任务视图里的键名**。只认两样: 案卷项的 id **就是任务视图里的键名**。流内起单时上游载荷(rows / records)
起单入参(pn)与「比价表写了几行」(compare_total)—— 与本活动台账列都会摊平到任务视图顶层,照它们命名。
后者既是放行前提,也是这一单唯一的机检判据。
--> -->
<caseFileModel> <caseFileModel>
<caseFileItem id="pn" name="型号(开案即有)"/> <caseFileItem id="rows" name="上游清单行(A2 载荷,流内开案即有)"/>
<caseFileItem id="compare_total" name="比价表条数"/> <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> </caseFileModel>
<casePlanModel id="plan" name="渠道寻源" autoComplete="true"> <casePlanModel id="plan" name="寻源询价" autoComplete="true">
<sentry id="s_compared"> <!--
<onPart id="op_cp" caseFileItemRef="compare_total"><standardEvent>update</standardEvent></onPart> ① 归一并预填完了 —— 交 PRM 那一步的入场前提。
<ifPart id="if_cp"><condition>compare_total &gt; 0</condition></ifPart> 行数两边对上(自报 = 基座现数),且 A 类预填那一轮走完
(hist_priced_total 是预填后显式写下的数;没有 A 类就是 0,
「大于等于 0」对「写过的 0」为真、对「没写过」为假)。
-->
<sentry id="s_normalized">
<onPart id="op_nm" caseFileItemRef="hist_priced_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_nm"><condition>quote_line_total &gt; 0 and quote_sourcing_quote_list_rows = quote_line_total and hist_priced_total &gt;= 0</condition></ifPart>
</sentry>
<!--
② PRM 的答复处理过了 —— 汇稿那一步的入场前提。
sourced_total 是「人交回执行单之后」由 AI 清点写下的数(B 类行补上价的条数)——
只在人环走完才存在,它就是「经过人了」的凭据。
-->
<sentry id="s_sourced">
<onPart id="op_sc" caseFileItemRef="sourced_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_sc"><condition>sourced_total &gt;= 0</condition></ifPart>
</sentry>
<!--
── 放行门的入场条件:**认「办完了没有」,且读基座数**────────────────
· 每行都有基准价(quote_sourcing_quote_list_missing 由基座按 required 现数,
与执行卡的提交闸、验收 L1 同一个数同一套算法);
· 价源守恒:历史价 + 货源价 = 行数(两边都是基座按值现数);
· 定盘过了(route_decided_total 是汇稿那一步显式写下的记账数)。
-->
<sentry id="s_ready">
<onPart id="op_rd" caseFileItemRef="route_decided_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_rd"><condition>route_decided_total &gt; 0 and quote_sourcing_quote_list_rows &gt; 0 and quote_sourcing_quote_list_missing = 0 and quote_sourcing_quote_list_price_basis_hist + quote_sourcing_quote_list_price_basis_sourced = quote_sourcing_quote_list_rows</condition></ifPart>
</sentry> </sentry>
<!-- 唯一的活:派一张执行单给采购,他填完提交 --> <!-- ① 归一目标报价清单 + A 类按历史价预填基准 -->
<humanTask id="T_source" name="渠道寻源:派执行单给采购,收回供应商回价与比价结论"> <humanTask id="S1_normalize" name="归一目标报价清单:整份落表,A 类按历史价预填基准">
<itemControl><repetitionRule id="r_src"/></itemControl> <itemControl><repetitionRule id="r_s1"/></itemControl>
</humanTask> </humanTask>
<!-- 放行:触达世界,必须人工激活 --> <!--
<humanTask id="T_release" name="寻源结论验收放行"> ② 补货源价格交 PRM —— **一张执行单**(询价的实质工作在系统外)。
<entryCriterion id="ec_rel" sentryRef="s_compared"/> 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> <itemControl><manualActivationRule id="m_rl"/></itemControl>
</humanTask> </humanTask>
</casePlanModel> </casePlanModel>
</case> </case>
</definitions>`; </definitions>`;
export const NARRATIVE = `§0 开案:A1 判出一条料要寻源(无近期历史价、或行情波动),这一单就到你手上。 /**
成品只有一样:**一份能拿去定价的比价结论** —— 几家报了什么价、交期多久、推荐哪一家、为什么。 * 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本,两处不重复写同一件事)。
*/
export const NARRATIVE = `§0 开案:**一份清单要补齐每一行的价格基准**。来路三种,形状都收(宽进):
· **选型转来**:任务数据里有 \`rows\` 数组 —— 替代选型放行的全部落地行混在一起
(带 alt_pn / 现标的是清单行,带各种总数的是结论行,按 PN 归并自辨);
· **受理直达**:有 \`records\` 数组 —— 受理放行的落地行(受理记录行 + 分类表行 + 结论行);
· **单飞**:只有一段需求文字,列了几颗料要问价。
**这一单只有一步**:把寻源这件事派给采购(执行工单),他填完你收进台账,然后提交放行。 **这个活动是复杂活动的 MVP 简化形**:询价的实质工作(圈渠道、发函、催回价、验价)
四种询价场景(BOM 早期 / BOM 后期 / 现货 / 样品)的打法差别是**采购的判断**, 由采购(PRM)在**系统外**完成;四种场景(BOM早期/后期/现货/样品)的尺度是他的判断。
不是系统要管的步 —— 你在 ask 里说清是哪种场景就够了,别替他把流程铺开。 系统只负责:归一清单、**A 类行按库内历史价预填基准**、把执行单组好交给他、
收回后齐检定盘。
【T_source 派执行单】发 human 行动(workOrder="execution"),卡面 blocks 放**两张可编辑表**: **成品只有一样**:一份**目标报价清单** —— 整份行都在、**每一行都有基准价**
· 供应商回价:列 = 型号 / 供应商 / 回价 / MOQ / 交期 / 有效期; (A 类取历史价,B 类取 PRM 问回来的货源价),价源逐行可溯。它是成本定价的直接输入。
· 寻源比价:列 = 型号 / 供应商 / 回价 / 结论。
两张都 editable:true;没有的值给空字符串 —— 空格子界面会自己标红「待填写」。
ask 说清要他做什么(哪颗料、什么场景、要几家、什么时候要);
basis 摆上型号、数量、目标价、客户交期要求(裸卡基座拒发)。
他提交后,回传值出现在你的轨迹里:表格那一份的键是 **table:0 / table:1**(块序号), **这一单要经过人两次,不多不少**:PRM 在执行单上补货源价(那张单绑着整份清单,
值是整张表的 JSON —— **第一行是列名**,其余每行一条,列序与你派出去时一致。 必填空着按不下提交 —— 基座的闸看着,他改的格子也直接落账)、放行时 PRM 签字。
照它 write 进 \`quote_sourcing.supplier_quote\` 与 \`quote_sourcing.compare_table\`,
并把比价表条数记进 compare_total。**写完就 finish T_source,别再发第二张单** —— 他已经答过了。
【提交放行】比价表有行了 —— **先 start T_release**(人工激活:放行触达世界,必须按一下), 【一路上要说话】负责人正看着这一单。**每一拍都带一句 say** —— 说业务的话:
然后 submit,BD 验收。终局唯放行:你不能自己宣布完成。 哪几颗料等着问价、历史价预填了几条、这单接下来到谁手上;不说系统词。
到了要找人的那一拍,先说清这一轮办完了什么、为什么现在需要他。
【一路上要说话】负责人正看着这一单。每一拍带一句 say,说**业务的话**: 【怎么把一项干完 · 一拍连做】write 和 tool 都不会自动收步 —— 做完用 finish 宣告。
哪颗料在寻源、找了几家、回价怎么样、这一单往哪走。 动作排进同一拍的 actions 串一口气给;只有调工具(出参下一拍才看得见)与
一两句、口语。别报「写草稿 2 行」这种系统词。`; 发卡 / 提交(挂起)必须停拍。**这一单常路 6-8 拍就该收口**。
§Z 结案:PRM 在验收单上签字,系统把草稿合并成正式清单并发出 quotation 口的载荷 ——
整份目标报价清单交给成本定价。`;
/** /**
* 步骤剧本 —— 与 A1/A2 同一套机制:`doneWhen` 为真,引擎自己收步、**不问模型**。 * 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。
* 放行那一项不给 `doneWhen` —— 它触达世界,必须人按一下(铁律③)。
*/ */
export const SOURCING_STEPS = [ export const SOURCING_STEPS = [
{ {
item: 'T_source', item: 'S1_normalize',
intent: '派一张执行单给采购,收回供应商回价与比价结论', intent: '上游清单整份归一成目标报价清单;A 类行按库内历史价预填基准',
writes: ['quote_sourcing.supplier_quote', 'quote_sourcing.compare_table', 'quote_sourcing.verdict'], writes: ['quote_sourcing.quote_list', 'quote_sourcing.verdict'],
reads: [], /* 上游载荷数组不声明就会被整表段的截断刀砍掉(按字符截,砍掉的恰是要归一的行) */
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: [], tools: [],
/* /*
* 比价表有行了才算办完。**不能写 `compare_total != null`** —— * **收步判据与验收判据同源**:每行有价(基座按 required 现数)、价源守恒
* S-FEEL 里未绑定的名字 `!= null` 求值为 true,那一步开局即被收掉(A1 踩过)。 * (基座按值现数)、定盘记账 —— 三样齐才收,松出来的那一截就是返工。
*/ */
doneWhen: 'compare_total > 0', doneWhen:
guide: `**照这个骨架发,一个字段都别少**(prose 描述不管用,直接抄): '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',
{"kind":"human","itemId":"T_source","workOrder":"execution","card":{ guide: `PRM 补回来了,现在把清单**定盘**,一拍连做:
"title":"…","ask":"这颗料要寻源,请找几家报价并给比价结论",
"basis":[{"k":"…","v":"…"}],
"blocks":[
{"t":"table","title":"供应商回价","cols":["型号","供应商","回价","MOQ"],"editable":true,
"rows":[["NE555P","","",""],["NE555P","","",""]]},
{"t":"table","title":"寻源比价","cols":["型号","供应商","回价","结论"],"editable":true,
"rows":[["NE555P","","",""],["NE555P","","",""]]}
]}}
**一条料至少摆 2 行** —— 判据「BOM后期每料 2~3 家渠道」机检的就是渠道数,
只问一家过不了门(型号那列你先填,供应商与价格留给采购)。
**rows 必须有行**:一条料一行,不知道的值给空字符串(界面自己标红「待填写」)。
只给 cols 不给 rows,人看到的是一排表头加一片空白,一个格子都填不了(实测栽过)。
**blocks 少了整张卡就废了** —— 基座会用当下局面兜底成一句「需要你判断下一步」,
人拿到一张没有表的空卡,这一步等于没派(实测栽过)。
**rows 一条料一行**,型号那一列先填上;ask 说清什么场景、要几家、什么时候要; ① **价源归档**:PRM 补了价的行(price_basis 还空着的),逐行写 price_basis = **SOURCED**
basis 摆上型号、数量、目标价、交期要求(**裸卡基座拒发**)。 (大写,一个字母不许差);A 类预填的行已是 HIST,不动。
按 rfq_ref + pn 对行,主键一个字符不许改。
② 若还有行缺底价(他留空又没撤):照他备注的说明办 —— 写明剔除的用 \`drop\`
把那一行撤出本单草稿(主键照 \`_keys\` 名单**原样粘**),撤了哪几颗、为什么写进
verdict.summary,并把 quote_line_total 与 pending_total 改成撤后的实数;
什么说明都没有的,发一张**决策卡(decide)**问负责人「这几颗问不到价,剔出本轮还是退回」,
照答复办。**只问这一次**。
③ 写 verdict:**route_decided_total 记 1**、summary 一句人话
(几行历史价、几行货源价、剔了几颗、整份可交定价)。
④ finish 这一项。引擎按「每行有价 + 价源守恒 + 定盘记过」收步,三样有一样不齐都收不掉。
他提交后回传值进你的轨迹:表格的键是 **table:0 / table:1**,值是整张表的 JSON, 这一单只有一个出口(交成本定价),**没有去向要选** —— 定盘就是把账写齐。`,
**第一行是列名**。照它 write 进两张台账,并把比价表条数记进 compare_total。
**同一拍再写一行 \`quote_sourcing.verdict\`**(验收机检读的就是它):
· supplier_total = 询了几家渠道(判据要 ≥2,**只问一家过不了门**);
· quoted_total / unquoted_total = 已回价 / 未回价条数(未回价要 = 0);
· compare_total = 比价表条数;recommend_supplier 推荐哪家;summary 一句人话结论。
这几个数写不对,submit 会被门挡回来 —— **它们不是台账装饰,是判据本身**。
**写完就 finish,别再发第二张单** —— 他已经答过了。
**每一行都要带 \`rfq_ref\`** —— 它是这一单的单标识(起单入参里就有,原样抄进去)。
台账主键第一列就是它:不写,这一行会和**别的单**同料号的那一行撞主键,把人家覆盖掉
(实测栽过:A4 的报价草案主键只有 pn,跑四轮只剩最后一轮的 3 行)。`,
}, },
{ {
item: 'T_release', item: 'T_release',
intent: '比价结论已齐,提交放行交 BD 验收', view: 'sourcing-roster',
intent: '清单已定盘,提交放行交 PRM 验收',
writes: [], writes: [],
reads: [], reads: [],
tools: [], tools: [],
guide: `比价表有行了 —— **先 start T_release**(它标了人工激活),然后 submit,BD 验收。 /* 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 doneWhen) */
guide: `每行有价、价源守恒、定盘已记 —— 可以走放行了(T_release 人工激活:
动它就算启动,不必单发 start)。先把裁量判据裁掉,然后 submit,同一拍连做:
· judge criterion="货源与价格来源可核:B 类行的供应商与底价来自真实询价或可靠渠道,PRM 对报价负责" ——
看清单里 SOURCED 行的供应商与备注:说得出来源就 satisfied=true;
有行说不出的,satisfied=**false**,rationale 逐行写清,交签字的人定。
**裁成不满足照样能提交** —— 理由连着卡一起交给他。
criterion 照判据名**整句原样抄**,写别的名字对不上。
终局唯放行:你不能自己宣布完成。`, 终局唯放行:你不能自己宣布完成。`,
}, },
]; ];
/**
* 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段;
* 放行那一格由基座统一加,人看到的是 x/4。
*/
export const SOURCING_PHASES = [
{ name: '归一预填', items: ['S1_normalize'] },
{ name: '补货源价', items: ['S2_fill'] },
{ name: '汇稿定盘', items: ['S3_compile'] },
];
+10 -53
View File
@@ -1,58 +1,15 @@
/** /**
* 统一报价 A3 · 渠道寻源 —— 活动包代码入口(ADR-004)。 * 统一报价 A3 · 寻源询价 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
* *
* **loop 活动**:下一步由 Agent 在案例模型的结构上临场决断,引擎只推进到 Enabled 为止。 * **这个活动没有 `run`,不是缺件。**
* 本文件只做视图流控 —— 把阶段、里程碑与比价进展摆给人看。 *
* 案例活动的推进由 Loop 驱动,行为全部写在清单里:案例模型 + 说明书 + 步骤剧本 +
* 两条技能 + 台账声明 + 一张 HTML 报告模版。上一版这里有一段 `run()` ——
* loop 活动根本不会调它;摆一个空转的 run 去假装有实现,更坏。
*
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 —— 它是**被静态审扫描的产物文本**
* (查 import 与全局引用,证明活动包零环境能力)。
*/ */
import { defineActivity } from '@sdk'; import { defineActivity } from '@sdk';
import type { ActivityBase, ActivityOutcome } from '@sdk';
const QUOTES = 'quote_sourcing.supplier_quote'; export default defineActivity({ name: 'quote-sourcing' });
const COMPARE = 'quote_sourcing.compare_table';
export default defineActivity({
name: 'quote-sourcing',
async run(base: ActivityBase): Promise<ActivityOutcome> {
const quotes = await base.ledger.read(QUOTES);
const compare = await base.ledger.read(COMPARE);
const suppliers = new Set(quotes.map((q) => String(q['supplier'] ?? '')));
await base.interaction.pushView([
{
t: 'kv',
title: '寻源进展',
items: [
{ k: '场景', v: String(base.contract.data['scenario'] ?? '') },
{ k: '已圈渠道', v: `${suppliers.size} 家` },
{ k: '已收回价', v: `${quotes.length} 单` },
{ k: '比价表', v: compare.length > 0 ? '已填' : '未填' },
],
},
{
t: 'table',
title: '供应商回价',
cols: ['供应商', '回价', 'MOQ', '交期', '渠道等级'],
rows: quotes.map((q) => [
String(q['supplier'] ?? ''),
String(q['price'] ?? '—'),
String(q['moq'] ?? '—'),
String(q['lead_time'] ?? '—'),
String(q['grade'] ?? '—'),
]),
},
]);
if (compare.length === 0) {
// 交集为空即断点 —— 由引擎判定、Agent 定夺调人还是立监听,这里不代它决定
await base.interaction.say(
suppliers.size === 0 ? '尚未圈定渠道,先按场景定策。' : `已收 ${quotes.length} 单回价,待填寻源比价表。`,
'conclusion',
);
return { kind: 'await', reason: '寻源比价表未交付' };
}
await base.interaction.say('寻源比价表已填齐,可交回 PM。', 'criterion');
return { kind: 'submit', deliverable: 'compare-table', rationale: '每料有有效报价或明确无货' };
},
});
+283 -113
View File
@@ -1,185 +1,265 @@
/** /**
* 活动包清单 —— 统一报价 A3 · 渠道寻源(**案例活动 · loop**)。 * 活动包清单 —— 统一报价 A3 · 寻源询价(案例活动 · MVP 简化形)。
* *
* 业务素材:docs/MVP示例工作流.html §1.5。按 README《归一化对照表》映射: * 业务素材:`docs/MVP示例工作流.html` §1.7,按 2026-08-02 用户改盘的口径归一:
* 案例图 → CMMN XML;案例说明书 → 同版本叙事面; * **A3 与 A2 同为复杂活动(需要大规模业务建模),MVP 期间不展开** —— 只做一张精美的
* `!必做` → entryCriterion 常项;`看情况` → discretionary(planningTable); * HTML 验收报告 + 一张执行单:AI 归一目标报价清单、A 类行按库内历史价预填基准,
* `#可反复` → repetitionRule;`▶需拍板` → manualActivation + 放行; * 采购(PRM)在系统外询价、把 B 类行的货源价格填回来,汇稿定盘后放行 ——
* `⏱时限/回价到期` → 时间监听(**时限必配**)。 * **生成最终的目标报价清单**交给成本定价。提交就是输出。
*
* ── 这里**去掉了 `STRATEGY_DMN`(采用策略判定表)**(2026-08-02)────────────────
* 它声明着 scenario / priceDrift / quoteCount 三个入参,台账上没有任何列承载、
* 剧本里也没有一步调它 —— 一张没有求值器的判定表不是资产,是错觉。
* 四种场景(BOM早期/后期/现货/样品)的尺度之分是 PRM 系统外的判断,
* MVP 口径下系统不假装接得住。
*/ */
import type { ActivityManifest, DmnModel } from '@contracts'; import type { ActivityManifest, DmnModel } from '@contracts';
import { SOURCING_SKILLS } from './skills.ts';
import { SOURCING_CASE } from './views/report.ts';
import { activityIdSchema } from '@contracts'; import { activityIdSchema } from '@contracts';
import { CMMN_XML, NARRATIVE, SOURCING_STEPS } from './case-model.ts'; import { SOURCING_SKILLS } from './skills.ts';
import { SOURCING_ROSTER, SOURCING_WORKSHEET } from './views/report.ts';
import { CMMN_XML, NARRATIVE, SOURCING_PHASES, SOURCING_STEPS } from './case-model.ts';
const DOMAIN = 'quote_sourcing'; const D = 'quote_sourcing';
const ACTIVITY_ID = activityIdSchema.parse('quote-sourcing'); const ACTIVITY_ID = activityIdSchema.parse('quote-sourcing');
/** 采用策略 —— 「能算的不问 LLM」:场景 × 历史情形 → 直采 / 重询 */ /** MVP 口径:A3 没有判定表(见文件头注释)—— 取价规则写死在剧本,价源逐行落表 */
export const STRATEGY_DMN: DmnModel = { export const SOURCING_DMN: DmnModel = { drd: [], tables: [] };
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] },
],
},
],
};
export const manifest: ActivityManifest = { export const manifest: ActivityManifest = {
identity: { identity: {
activityId: ACTIVITY_ID, activityId: ACTIVITY_ID,
name: 'A3 · 渠道寻源', name: 'A3 · 寻源询价',
packageVersion: '2.0.0', packageVersion: '3.7.0',
category: '统一报价', category: '统一报价',
sdkVersion: '0.1.0-m5', sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-quote-sourcing', checksum: 'dev-checksum-quote-sourcing',
}, },
/* ── 契约七件 ────────────────────────────────────────────────── */
contract: { contract: {
activityId: ACTIVITY_ID, activityId: ACTIVITY_ID,
version: '0.1.0', version: '1.0.0',
input: { input: {
/*
* **宽进到底:一个必填都不设**(2026-08-02 用户定:宽进严出,
* 输入契约在活动内部归一 + 补全)。
*
* 来路三种形状:A2 转来(rows)、A1 直达(records)、单飞(一段文字)。
* 上一版必填 `rfqRef + pn + scenario` 是**单料形状** —— 上游发的是整份清单,
* 门口拦的恰是主路。
*/
data: { data: {
type: 'object', type: 'object',
required: ['rfqRef', 'pn', 'scenario'], required: [],
properties: { properties: {
rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' }, rfqRef: { type: 'string', title: '询价单号' },
pn: { type: 'string' }, rows: { type: 'array', title: '询价受理清单(选型转来)' },
scenario: { type: 'string' }, records: { type: 'array', title: '上游受理记录(受理直达)' },
quantity: { type: 'integer' }, subject: { type: 'string', title: '需求主题' },
targetPrice: { type: 'number' }, 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, messageAllowed: true,
messageHint: '急料、客户特殊要求写这里', messageHint: '急料、场景(BOM早期/后期/现货/样品)、客户特殊要求写这里',
}, },
output: { output: {
/*
* 只有一个出口:目标报价清单交成本定价(流程包 E7 是无条件边)。
* 没有选择组 —— 没有去向要选,定盘就是把账写齐。
*/
mandatory: [ mandatory: [
{ {
name: 'compare', name: 'quotation',
payload: { type: 'object', required: ['rows'], properties: { rows: { type: 'array' } } }, payload: {
description: '寻源比价表(多渠道横比定底)', type: 'object',
required: ['rfqRef', 'rows'],
properties: {
rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' },
rows: { type: 'array', title: '目标报价清单(每行带基准价与价源)' },
},
},
description: '目标报价清单(每行带基准价与价源)',
}, },
], ],
selective: [], selective: [],
}, },
/* /*
* 必交付物**必须给 `ledger` 地址**(2026-07-31 修)。 * 必交付物只有一件:**目标报价清单**。寻源结论(verdict)是它的结论栏,
* 不给的话预检退回 `scope[key]` 去找 —— key 是 `benchmark-table` 这样的短横线别名, * `ledgers` 说清这份成果由哪几张表承载。
* 任务视图里永远没有这个键,于是「必交付物缺一不得提交放行」永远拒,
* 活办完了也放不了行,连拒五拍熔断(实测 A2 死于此)。
*/ */
requiredDeliverables: [ requiredDeliverables: [
{ key: 'compare-table', label: '寻源比价表', ledger: `${DOMAIN}.compare_table` }, {
key: 'quotation-roster',
label: '目标报价清单(每行带基准价)',
ledger: `${D}.quote_list`,
/* 验收页上让 PRM 就地改的那几列 —— 恰是他的职权面(价格五列) */
editableAtRelease: ['supplier', 'base_price', 'moq', 'source_lead_time', 'note'],
ledgers: [`${D}.quote_list`, `${D}.verdict`],
},
], ],
/* /*
* 机检谓词读的是 `verdict` 那张**汇总表**的列(照 A1 的口径)。 * 验收判据 —— **机检(L1/L2)只读基座数得出来的数**:
* 上一版的名字(unquoted / channelsPicked)**台账里根本没有** —— S-FEEL 拿未绑定的名字 * `_rows` 基座数行数、`_missing` 基座按 required 现数(与执行卡提交闸同一个数
* 比大小求值为假,判据永远达不了标,submit 门永远拒,连拒五拍熔断。 * 同一套算法)、`_price_basis_hist/_sourced` 基座按值现数。
* 判据的名字必须落在台账上,否则「机检」是一句空话(2026-07-31 销账)。 * 上一版两条 L2 读活动自报的 verdict 列(unquoted_total / supplier_total)——
* 那不是门,是「改哪个数能过」的提示。
*/ */
acceptanceCriteria: [ acceptanceCriteria: [
{ key: 'each-has-quote', label: '每料有有效报价或明确无货', level: 'L2', predicate: 'unquoted_total = 0' }, {
{ key: 'channels-enough', label: 'BOM后期每料 2~3 家渠道', level: 'L2', predicate: 'supplier_total >= 2' }, key: 'roster-complete',
{ key: 'cross-check', label: '价格验证按场景规则做过', level: 'L3' }, label: '清单齐:上游清单逐条承继,说了几条清单里就躺着几行',
{ key: 'prm-release', label: '采购放行', level: 'L5' }, 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: [], revisableAtRelease: [],
prohibitions: [ prohibitions: [
{ key: 'no-made-up-price', statement: '问不到的价不许编:要么剔出本轮,要么按内部估价填并在备注写明' },
{ key: 'no-stockless-trader', statement: '现货场景不得选无库存转单商' }, { key: 'no-stockless-trader', statement: '现货场景不得选无库存转单商' },
{ key: 'no-unverified', statement: '不实回价或远低行情的,必须换渠道补询,不得直接采用' },
], ],
authorization: { authorization: {
discretion: [{ key: 'channel-band', statement: '按场景放宽或收紧渠道尺度;贸易商尽量不用' }], discretion: [
preauthorizedActions: [{ tool: 'mail.send', operation: 'inquiry', limit: '询价模板' }], { key: 'channel-band', statement: 'PRM 按场景放宽或收紧渠道尺度;贸易商尽量不用' },
humanCallBudget: 8, ],
/*
* 空着不是省事:这个字段全仓没有一个消费者(债 ⑨)。
* 上一版这里写着 `mail.send` 的预授权 —— 写了也不会被执行,一并撤掉。
*/
preauthorizedActions: [],
humanCallBudget: 4,
}, },
resources: { 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), skills: SOURCING_SKILLS.map((s) => s.id),
models: ['deepseek-v4-flash'], models: ['deepseek-v4-flash'],
roles: ['PRM'], 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: { declarations: {
models: ['deepseek-v4-flash'], models: ['deepseek-v4-flash'],
tools: [{ name: 'mail.send' }, { name: 'crm.history-price-read' }], tools: [{ name: 'crm.history-price-read' }],
ledger: [ ledger: [
/*
* ── 这里**去掉了 `supplier_quote` / `compare_table` 两张表**(2026-08-02)──
*
* MVP 口径下逐家回价与横比都不在系统内产出 —— 询价的实质工作在 PRM 系统外,
* 系统收的是**结论**(定盘的供应商与底价),落在清单行的价格五列上。
* 撤掉声明不 drop 物理表(物化只增不破坏),历史行还在库里。
*/
{ {
domain: DOMAIN, domain: D,
object: 'supplier_quote', object: 'quote_list',
kind: 'record', kind: 'record',
caseKey: 'rfq_ref', caseKey: 'rfq_ref',
label: '供应商回价单', label: '目标报价清单',
fields: [ fields: [
{ name: 'rfq_ref', 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 }, { name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' },
{ name: 'supplier', type: 'text', primaryKey: true, masked: false, label: '供应商' }, { name: 'customer_pn', type: 'text', primaryKey: false, masked: false, label: '客户料号' },
{ name: 'price', type: 'number', primaryKey: false, masked: false, label: '回价' }, { name: 'category', type: 'text', primaryKey: false, masked: false, label: '品类' },
{ name: 'moq', type: 'integer', primaryKey: false, masked: false, label: 'MOQ' }, { name: 'brand', type: 'text', primaryKey: false, masked: false, label: '制造商' },
{ name: 'lead_time', type: 'text', primaryKey: false, masked: false, label: '交期' }, { name: 'eau', type: 'integer', primaryKey: false, masked: false, label: '年用量 EAU' },
{ name: 'valid_until', type: 'date', primaryKey: false, masked: false, label: '有效期' }, { name: 'target_price', type: 'number', primaryKey: false, masked: false, label: '目标价' },
{ name: 'grade', type: 'text', 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', domain: D,
kind: 'record',
caseKey: 'rfq_ref',
label: '寻源比价表',
fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'pn', type: 'text', primaryKey: true, masked: false },
{ name: 'supplier', type: 'text', primaryKey: false, masked: false },
{ name: 'price', type: 'number', primaryKey: false, masked: false },
{ name: 'conclusion', type: 'text', primaryKey: false, masked: false, label: '结论' },
],
},
{
/*
* 寻源结论 —— 一单一行的**汇总表**,验收机检谓词的入参就在这儿。
* 汇总列与逐行列**故意不同名**:任务视图把草稿行摊平到顶层是「后写的赢」,
* 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。
*/
domain: DOMAIN,
object: 'verdict', object: 'verdict',
kind: 'record', kind: 'record',
caseKey: 'rfq_ref', caseKey: 'rfq_ref',
label: '寻源结论', label: '寻源结论',
fields: [ fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' }, { name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'pn', type: 'text', primaryKey: false, masked: false, label: '型号' }, { name: 'customer_code', type: 'text', primaryKey: false, masked: false, label: '客户码' },
{ name: 'supplier_total', type: 'integer', primaryKey: false, masked: false, label: '已询渠道数' }, { name: 'customer_name', type: 'text', primaryKey: false, masked: false, label: '客户名称' },
{ name: 'quoted_total', type: 'integer', primaryKey: false, masked: false, label: '已回价条数' }, { name: 'quote_line_total', type: 'integer', primaryKey: false, masked: false, label: '清单条目数' },
{ name: 'unquoted_total', type: 'integer', primaryKey: false, masked: false, label: '未回价条数' }, { name: 'pending_total', type: 'integer', primaryKey: false, masked: false, label: '待补价条数' },
{ name: 'compare_total', type: 'integer', primaryKey: false, masked: false, label: '比价条数' }, /* A 类预填走完的凭据(0 也要写)—— 哨兵按它放 PRM 那一步入场 */
{ name: 'recommend_supplier', type: 'text', primaryKey: false, masked: false, label: '推荐供应商' }, { 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: '寻源结论' }, { name: 'summary', type: 'text', primaryKey: false, masked: false, label: '寻源结论' },
], ],
}, },
], ],
listeners: [ listeners: [
{ {
// 回价到期:临过期提醒复核确认,过期触发重询 —— 时限必配 /** 供应商回价到达:记一笔观察(PRM 在系统外收回价,这里只接得到通道转进来的) */
origin: 'static', origin: 'static',
eventType: 'data', eventType: 'data',
keyFilter: { kind: 'supplier-quote' }, keyFilter: { kind: 'supplier-quote' },
@@ -188,6 +268,7 @@ export const manifest: ActivityManifest = {
escalation: ['nudge', 'escalate'], escalation: ['nudge', 'escalate'],
}, },
{ {
/** 回价临过期:提醒复核确认 —— 时限必配 */
origin: 'static', origin: 'static',
eventType: 'time', eventType: 'time',
keyFilter: { kind: 'quote-expiry' }, keyFilter: { kind: 'quote-expiry' },
@@ -196,35 +277,124 @@ export const manifest: ActivityManifest = {
escalation: ['nudge', 'reroute'], escalation: ['nudge', 'reroute'],
}, },
], ],
// 按场景时限:BOM早期/现货 1~4 日、样品 1~3 日、BOM后期 3~9 日 —— 取最宽做停滞阈值
harness: { harness: {
memoryWindow: 30, memoryWindow: 24,
// PRM 在系统外询价,按场景时限最宽 BOM后期 3~9 日 —— 停滞阈值放到两天
stallAfter: 'P2D', stallAfter: 'P2D',
nudge: [{ after: 'P1D', action: 'nudge' }], nudge: [{ after: 'P1D', action: 'nudge' }],
retry: { times: 2, backoff: 'PT5M' }, retry: { times: 2, backoff: 'PT5M' },
}, },
}, },
/* ── 资产初始版 ──────────────────────────────────────────────── */
assets: { assets: {
businessModel: { businessModel: {
cmmnXml: CMMN_XML, cmmnXml: CMMN_XML,
// 双表示同版本:XML 是机器面,说明书是叙事面 —— 不允许分叉
narrative: NARRATIVE, narrative: NARRATIVE,
dmn: STRATEGY_DMN, dmn: SOURCING_DMN,
/* 步骤剧本 —— 与 A1 同一套:doneWhen 真了引擎自己收步,不问模型 */
steps: SOURCING_STEPS, steps: SOURCING_STEPS,
phases: SOURCING_PHASES,
}, },
skills: SOURCING_SKILLS, 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: {}, cardSchemas: {},
/*
* distillHooks 只留 terminal —— 上一版还有 `milestone: 'M_quotes'`,
* 而案例模型里从来没有这个里程碑(死链,钩子永不触发)。
*/
distillHooks: [ distillHooks: [
{ on: 'milestone', milestone: 'M_quotes', prompt: '归纳各渠道的回价节奏与配合度。' }, {
{ on: 'terminal', prompt: '归纳本单寻源:哪些渠道靠谱、哪些场景的价格验证规则该收紧。' }, on: 'terminal',
prompt: '归纳本单寻源:哪些料历史价可用、货源价与历史价的偏离形态、剔单的原因分布。',
},
], ],
}, },
/* ── 样例集 —— 三条形状真不同(常备案例即验收案例)───────────── */
samples: [ 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' }, entry: { file: 'index.js', checksum: 'dev-entry-quote-sourcing' },
+31 -28
View File
@@ -1,46 +1,49 @@
/** /**
* A3 技能 —— 寻源的方法经验(AM 08)。 * A3 技能 —— 寻源询价 MVP 简化形的方法经验(AM 08)。
* loop 里技能由 AI **按适用描述临场装配**:常驻上下文只有目录,按事加载正文。 *
* **载方法,不载权力**;渐进披露:常驻上下文只有目录,按事加载正文。
* 剧本改了口径,技能里同一句也要跟着改 —— 2026-08-02 改盘后:
* 询价的实质工作在 PRM 系统外,系统管归一 / 预填 / 组卡 / 齐检;
* 旧的「按场景圈渠道」「该等还是该问」讲的是 PRM 脑子里的方法,系统不替他办,删。
*/ */
import { skillIdSchema } from '@contracts'; import { skillIdSchema } from '@contracts';
import type { Skill } from '@contracts'; import type { Skill } from '@contracts';
export const SOURCING_SKILLS: Skill[] = [ export const SOURCING_SKILLS: Skill[] = [
{ {
id: skillIdSchema.parse('sourcing.pick-channels'), id: skillIdSchema.parse('sourcing.normalize-roster'),
label: '按场景圈渠道', label: '归一目标报价清单',
applicability: '拿到料号与场景,要决定向谁发询时。', applicability: '开案第一步:把上游清单(或单飞需求)整份落成目标报价清单时。',
method: [ method: [
'渠道不是越多越好:发得越广,回价越慢、口径越乱、对供应商的信用消耗越大。', '上游载荷是**全部落地行混在一起**的一份:选型转来(`rows`)的清单行带 alt_pn 与现标,',
'受理直达(`records`)的以受理记录行(带 eau/brand)为准;带总数的结论行不是料,别落。',
'**按 pn 归并**,一颗料一行,一拍写完(rows 收数组)。',
'', '',
'按场景定档:', '三条纪律:',
'· 常规补货 → 原厂代理 2 家足够,比的是账期与交期。', '① PN 一个字符不许改;上游没给的列留空字符串,绝不写 0 顶空。',
'· BOM 后期 / 急单 → 加 1–2 家现货商,比的是**能不能拿到货**,价格其次。', '② type_tag 照上游现标承继,本活动不改类 —— 改类是上游的职权。',
'· 停产料 → 直接问现货商与拆解渠道,原厂问了也是白问。', '③ 单号 rfq_ref 原样承继;单飞认不出才造「SRC-<型号或类型>」,造完从头用到尾。',
'',
'每多发一家,就多一份要等的回价 —— 发询前先想清楚:这一家的回价会改变你的决定吗?',
'不会,就别发。',
].join('\n'), ].join('\n'),
crystallized: [], crystallized: [],
knowledgeRefs: ['渠道分级台账'], knowledgeRefs: ['docs/MVP示例工作流.html §1.7'],
version: '1.0.0', version: '2.0.0',
}, },
{ {
id: skillIdSchema.parse('sourcing.wait-or-ask'), id: skillIdSchema.parse('sourcing.prefill-hist'),
label: '该等还是该问', label: 'A 类按历史价预填基准',
applicability: '当下可为与目标所需交集为空(依赖断点)时。', applicability: '清单落好后,要给 A 类行预填基准价时。',
method: [ 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'), ].join('\n'),
crystallized: [], // 结晶片段:查历史价这一步每单都一样 —— 确定性子序列,不必每次让模型重想
knowledgeRefs: [], crystallized: [{ tool: 'crm.history-price-read', input: { pns: 'pns' } }],
version: '1.0.0', knowledgeRefs: ['三分类规则 P-03(A 行的定义)'],
version: '2.0.0',
}, },
]; ];
@@ -0,0 +1,409 @@
/**
* A1 两份执行页的样式表 —— **从 PRD 目标件原样搬过来**,不是我照着复述的。
*
* 出处:`~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理需求.html` 的整段 `<style>`,
* 外加 `A1-询价受理记录.html` 里归档件独有的那几件(分类分布条 / 类徽 / 判定表 /
* 结论横幅 / 落地动作 / 完成标)。**一个数值都没改**。
*
* 为什么是搬而不是抄:前三轮我用自己那套外壳「照着做」,块搬齐了、版面是两套 ——
* 章节头成了彩色方块编号、多出摘要与元信息带、字号用 clamp。人一眼就看出不是那份件。
* 目标件是**像素级的答案**,搬过来才不会在复述里走样。
*
* 选择器保留 `.proto-root .app-shell` 前缀,所以模版正文也要照原型那样包两层壳 ——
* 少一层壳,整张样式表一条都不生效(踩过)。
*/
export const PRD_CSS = `
/* ============================================================================
JCAI 员工面工作台 · A1 受理归一的过程成果「询价受理需求」——报告本体
立场:**AI 已从 RFQ 邮件抽取归一**,人在这里审核并就地修订。
两态:读态(AI 稿 + 红/灰占位提示缺项) ⇄ 编辑态(全字段可改,保存 / 取消)
核心是「有效 PN 清单」;清单之上四件受理要素:客户公司 / 联系人 / 客户属性 / 有效 PN。
令牌与既有组件取自 workbench/src/app/(proto)/app.css,数值未改。
⚠️ 修正一处原型 bug:app.css:2062-2064 的 '.jc-rp-sign span'(权重 0,1,1)
盖过了 '.jc-rp-ava'(0,1,0),把头像的 display:grid 覆盖成 block、字色改灰,
导致落款头像里的字掉到圆外。本文件把该规则收窄为 '.jc-rp-sign > div > span'。
新增件:四要素卡 .jc-four/.jc-fcard · 占位胶囊 .jc-ph · 分段控件 .jc-seg ·
来源条 .jc-src · 录入表 .jc-tbl-in
========================================================================== */
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { background: #e6eaf1; -webkit-font-smoothing: antialiased; }
.proto-root .app-shell {
--brand: #3370ff; --brand-deep: #245bdb;
--brand-soft: #e1eaff; --brand-wash: #f0f4ff;
--ai-grad: linear-gradient(135deg, #3370ff, #7f3bf5);
--ink: #1f2329; --ink-2: #41464e; --ink-3: #646a73; --ink-4: #8f959e;
--line: #dee0e3; --line-soft: #e8eaed; --soft: #f8f9fb;
--jcai-bg: #e6eaf1; --cardline: #e1e6ec; --card-shadow: 0 1px 2px rgba(31, 35, 41, .05);
--ok: #278717; --ok-soft: #e8f8e4;
--warn: #de7802; --warn-accent: #ff8800; --warn-soft: #fff4e5;
--danger: #d83931; --danger-soft: #feeae9;
--mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace;
--shadow-brand: 0 6px 16px rgba(51, 112, 255, .22);
--t-fast: .16s cubic-bezier(.3, 0, .2, 1);
--t-med: .26s cubic-bezier(.3, 0, .2, 1);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--jcai-bg); min-height: 100vh; color: var(--ink);
}
.proto-root :where(button, input, select, a, [tabindex]):focus-visible {
outline: 2px solid var(--brand); outline-offset: 2px; border-radius: 8px;
}
.proto-root ::selection { background: var(--brand-soft); }
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
@keyframes jcBlink { 0%,100% { opacity: 1 } 50% { opacity: .35 } }
@keyframes jcStamp { from { opacity: 0; transform: rotate(-6deg) scale(1.7); } to { opacity: .9; transform: rotate(-6deg) scale(1); } }
/* 放开宽度:1160px 是「9 列全展开、一个字都不裁」的最小容器宽度(见下方列 min-width 之和) */
.jc-stage { max-width: 1160px; margin: 0 auto; padding: 22px 16px 52px; }
.proto-root .jc-mono { font-family: var(--mono); font-size: 11px; color: var(--ink-3); }
.proto-root .jc-pulse-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--brand); display: inline-block; animation: jcBlink 1.1s ease-in-out infinite; }
/* ── 报告外壳 ───────────────────────────────────────────────────────── */
.proto-root .jc-report { background: #fff; border: 1px solid var(--cardline); border-radius: 14px; box-shadow: var(--card-shadow); overflow: hidden; animation: rise var(--t-med) both; }
.proto-root .jc-rp-head { position: relative; overflow: hidden; display: flex; align-items: flex-start; gap: 12px; padding: 16px 20px 13px; border-bottom: 2px solid var(--ink); }
/* 题头下缘粗细双线 = 正式件版式 */
.proto-root .jc-rp-head::after { content: ""; position: absolute; left: 0; right: 0; bottom: -4px; height: 1px; background: var(--line); }
/* 题头右上的品牌光晕:与工作台标题区同一签名件 */
.proto-root .jc-rp-head::before { content: ""; position: absolute; right: -70px; top: -96px; width: 240px; height: 240px; border-radius: 999px; background: radial-gradient(circle, rgba(51,112,255,.10), transparent 70%); pointer-events: none; }
.proto-root .jc-rp-head > * { position: relative; z-index: 1; }
.proto-root .jc-rp-badge { flex: none; width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand); }
.proto-root .jc-rp-tit { flex: 1; min-width: 0; }
.proto-root .jc-rp-tit em { display: block; font-style: normal; font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); font-family: var(--mono); }
.proto-root .jc-rp-tit h3 { margin: 4px 0 0; font-size: 18px; font-weight: 800; color: var(--ink); letter-spacing: -.01em; line-height: 1.3; }
.proto-root .jc-rp-wait { display: inline-flex; align-items: center; gap: 6px; flex: none; margin-top: 4px; font-size: 11px; font-weight: 700; border-radius: 999px; padding: 4px 12px; color: var(--brand-deep); background: var(--brand-wash); border: 1px solid var(--brand-soft); }
.proto-root .jc-rp-wait.warn { color: var(--warn); background: var(--warn-soft); border-color: #ffd9a8; }
.proto-root .jc-rp-wait.edit { color: #fff; background: var(--brand); border-color: var(--brand); }
.proto-root .jc-rp-stamp { flex: none; margin-top: 2px; transform: rotate(-6deg); border: 2px solid currentColor; border-radius: 9px; padding: 4px 8px 3px 12px; font-size: 13px; font-weight: 900; letter-spacing: .28em; color: var(--ok); opacity: .9; animation: jcStamp .32s cubic-bezier(.2, 1.4, .4, 1) both; }
.proto-root .jc-rp-bd { padding: 18px 20px 12px; }
.proto-root .jc-rp-part { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 9px; margin: 20px 0 11px; }
.proto-root .jc-rp-part:first-child { margin-top: 0; }
.proto-root .jc-rp-part > b { flex: none; font-size: 11px; font-weight: 800; letter-spacing: .12em; color: var(--ink-3); }
.proto-root .jc-rp-part > .ln { flex: 1; height: 1px; background: var(--line-soft); }
.proto-root .jc-rp-part > em { flex: none; font-style: normal; font-size: 10px; font-weight: 700; color: var(--ink-4); font-family: var(--mono); background: var(--soft); border-radius: 999px; padding: 2px 9px; white-space: nowrap; }
.proto-root .jc-rp-part > em.warn { color: var(--warn); background: var(--warn-soft); }
/* 落款 —— ⚠️ 选择器收窄到 > div > *,别再命中头像那个 span */
.proto-root .jc-rp-foot { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 13px 20px 15px; border-top: 2px solid var(--line); background: #fbfcfd; }
.proto-root .jc-rp-sign { display: flex; align-items: center; gap: 10px; flex: 1 1 200px; min-width: 0; }
.proto-root .jc-rp-ava { flex: 0 0 auto; width: 30px; height: 30px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; line-height: 1; color: #fff; background: var(--brand); }
.proto-root .jc-rp-sign > div { min-width: 0; }
.proto-root .jc-rp-sign > div > b { display: block; font-size: 12px; font-weight: 700; color: var(--ink-2); }
.proto-root .jc-rp-sign > div > span { display: block; margin-top: 1px; font-size: 10.5px; color: var(--ink-4); }
.proto-root .jc-rp-acts { display: flex; align-items: center; gap: 8px; margin-left: auto; flex: none; }
.proto-root .jc-hintline { font-size: 11px; font-weight: 700; color: var(--warn); line-height: 1.5; }
.proto-root .jc-btn { display: inline-flex; align-items: center; gap: 5px; border: 0; border-radius: 7px; padding: 8px 14px; font-size: 12px; font-weight: 600; font-family: inherit; background: var(--brand); color: #fff; cursor: pointer; transition: all var(--t-fast); }
.proto-root .jc-btn:hover:not(:disabled) { background: var(--brand-deep); }
.proto-root .jc-btn:disabled { opacity: .45; cursor: not-allowed; }
.proto-root .jc-btn-ghost { background: #fff; color: var(--ink-2); border: 1px solid var(--line); }
.proto-root .jc-btn-ghost:hover:not(:disabled) { background: var(--brand-wash); color: var(--brand); border-color: var(--brand-soft); }
.proto-root .jc-btn-primary { background: var(--ai-grad); }
.proto-root .jc-btn-primary:hover:not(:disabled) { filter: brightness(1.07); box-shadow: var(--shadow-brand); transform: translateY(-1px); }
/* ── 来源信件:客户 RFQ 邮件原文(复用原型 jc-letter 件,青色 = 收件) ── */
.proto-root .jc-letter { position: relative; border: 1px solid var(--cardline); border-left: 3px solid #0aa294; border-radius: 12px; padding: 14px 16px 15px; background: #fff; box-shadow: var(--card-shadow); }
.proto-root .jc-letter-h { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 11px; }
.proto-root .jc-letter-dir { display: inline-flex; align-items: center; gap: 5px; font-size: 10.5px; font-weight: 800; padding: 3px 9px; border-radius: 999px; background: #e4f8f5; color: #0a7f74; }
.proto-root .jc-letter-h .jc-chip { font-size: 10.5px; padding: 3px 9px; border-radius: 999px; background: var(--brand-wash); color: var(--brand-deep); border: 1px solid var(--brand-soft); }
.proto-root .jc-letter-h > code { margin-left: auto; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
.proto-root .jc-letter-meta { display: flex; flex-direction: column; gap: 4px; margin-bottom: 11px; padding-bottom: 11px; border-bottom: 1px solid var(--line-soft); font-size: 11.5px; color: var(--ink-2); }
.proto-root .jc-letter-meta p { margin: 0; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.proto-root .jc-letter-meta i { flex: none; font-style: normal; width: 44px; font-size: 10px; font-weight: 800; letter-spacing: .06em; color: var(--ink-4); }
.proto-root .jc-letter-meta code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); overflow-wrap: anywhere; }
.proto-root .jc-letter-sub { margin: 0 0 9px; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.45; overflow-wrap: anywhere; }
/* 正文:原文照录,pre-wrap 保原始换行;不截断、不折叠 */
.proto-root .jc-letter-body { margin: 0; font-size: 12.5px; line-height: 1.72; color: var(--ink-2); white-space: pre-wrap; overflow-wrap: anywhere; }
.proto-root .jc-letter-body b { color: var(--ink); font-weight: 700; }
.proto-root .jc-atts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; padding-top: 11px; border-top: 1px dashed var(--line-soft); }
.proto-root .jc-att { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px 5px 8px; border: 1px solid var(--brand-soft); border-radius: 8px; background: var(--brand-wash); font-size: 11.5px; color: var(--brand-deep); }
.proto-root .jc-att svg { flex: none; color: var(--brand); }
.proto-root .jc-att code { font-family: var(--mono); font-size: 10.5px; }
.proto-root .jc-att span { color: var(--ink-4); }
/* 抽取指向:从原文到下方要素的一句衔接 */
.proto-root .jc-derive { display: flex; align-items: flex-start; gap: 8px; margin-top: 11px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); font-size: 11.5px; line-height: 1.7; color: var(--brand-deep); }
.proto-root .jc-derive svg { flex: none; margin-top: 2px; }
/* ── 四要素卡 ───────────────────────────────────────────────────────────
色调字典:每张卡带一套 --tone / --tone-soft / --tone-line,组件只读变量。
卡 3、卡 4 的色调随数据变(老客户绿 / 新客户橙;齐了蓝 / 有缺橙),
所以这条要素带本身就是一行可读的状态,不只是四个格子。 */
.proto-root .jc-four { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 11px; }
.proto-root .jc-fcard {
position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-width: 0;
padding: 14px 14px 14px; border: 1px solid var(--cardline); border-radius: 13px; background: #fff;
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 8px 20px -14px rgba(31,35,41,.35);
transition: transform var(--t-fast), box-shadow var(--t-fast), border-color var(--t-fast);
--tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft);
}
/* 顶缘色棱 + 右上角光晕:卡的身份色,一眼分得清四件是四件 */
.proto-root .jc-fcard::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 3px; background: var(--tone); }
.proto-root .jc-fcard::after { content: ""; position: absolute; right: -46px; top: -52px; width: 132px; height: 132px; border-radius: 999px; background: radial-gradient(circle, var(--tone-soft), transparent 70%); pointer-events: none; }
.proto-root .jc-fcard > * { position: relative; z-index: 1; }
.proto-root .jc-fcard:hover { transform: translateY(-2px); border-color: var(--tone-line); box-shadow: 0 2px 4px rgba(31,35,41,.05), 0 14px 26px -16px rgba(31,35,41,.45); }
.proto-root .jc-fcard.t-teal { --tone: #0aa294; --tone-soft: rgba(10,162,148,.15); --tone-line: #9fe0d9; }
.proto-root .jc-fcard.t-ok { --tone: var(--ok); --tone-soft: rgba(39,135,23,.14); --tone-line: #bfedb4; }
.proto-root .jc-fcard.t-warn { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.12); --tone-line: #ffd9a8; }
.proto-root .jc-fcard-k { display: flex; align-items: center; gap: 6px; font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
.proto-root .jc-fcard-k > i { flex: none; width: 19px; height: 19px; border-radius: 6px; display: inline-grid; place-items: center; background: var(--tone-soft); color: var(--tone); }
.proto-root .jc-fcard-b { display: flex; align-items: center; gap: 10px; min-width: 0; }
.proto-root .jc-fcard-t { min-width: 0; flex: 1; }
/* 一律换行,不省略号——宁可多占一行,也不丢字 */
.proto-root .jc-fcard-t > b { display: block; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.35; overflow-wrap: anywhere; }
.proto-root .jc-fcard-t > span { display: block; margin-top: 2px; font-size: 10.5px; color: var(--ink-4); line-height: 1.55; overflow-wrap: anywhere; }
/* 公司标:圆角方 + AI 渐变 + 外圈光环(方=组织) */
.proto-root .jc-logo { flex: none; width: 38px; height: 38px; border-radius: 12px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; letter-spacing: .04em; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand), 0 0 0 3px rgba(51,112,255,.10); }
/* 人像:圆 + 青色系(圆=人,与方形的组织标一眼分开) */
.proto-root .jc-face { flex: none; width: 38px; height: 38px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; color: #0a7f74; background: #e4f8f5; border: 1px solid #9fe0d9; box-shadow: 0 0 0 3px rgba(10,162,148,.10); }
/* 邮箱做成可点的胶囊,复制按钮嵌在里面——比一行裸文字有抓手 */
/* 邮箱:软底胶囊、无描边——有描边会读成输入框,而读态这里不是可填的 */
.proto-root .jc-mailrow { display: flex; align-items: center; gap: 4px; margin-top: 5px; min-width: 0; max-width: 100%; padding: 3px 4px 3px 9px; border: 0; border-radius: 8px; background: var(--tone-soft); transition: background var(--t-fast); }
.proto-root .jc-mailrow:hover { background: rgba(10,162,148,.2); }
/* 编辑态里它包着输入框,软底会变成「框中框」,去掉 */
.proto-root .jc-report.editing .jc-mailrow { background: transparent; padding: 0; margin-top: 6px; }
.proto-root .jc-mailrow > code { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; line-height: 1.5; font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
.proto-root .jc-copy { flex: none; width: 22px; height: 22px; display: inline-grid; place-items: center; border: 0; border-radius: 6px; background: #fff; color: var(--ink-4); cursor: pointer; box-shadow: 0 1px 2px rgba(31,35,41,.08); transition: all var(--t-fast); }
.proto-root .jc-copy:hover { color: #0aa294; }
.proto-root .jc-copy.ok { color: var(--ok); background: var(--ok-soft); box-shadow: none; }
/* 客户属性徽章 */
.proto-root .jc-tagbig { display: inline-flex; align-items: center; gap: 6px; align-self: flex-start; padding: 7px 14px; border-radius: 999px; font-size: 13.5px; font-weight: 800; line-height: 1; color: var(--tone); background: var(--tone-soft); border: 1px solid var(--tone-line); }
.proto-root .jc-fcard-note { font-size: 10.5px; color: var(--ink-4); line-height: 1.55; }
.proto-root .jc-fcard-note b { color: var(--ink-2); font-weight: 700; }
.proto-root .jc-fcard-note b.bad { color: var(--tone); font-weight: 800; }
/* 成交迷你柱:把「台账 6 笔成交」从一句话变成看得见的节奏 */
.proto-root .jc-spark { display: flex; align-items: flex-end; gap: 4px; height: 26px; }
.proto-root .jc-spark > i { flex: 1 1 0; min-width: 4px; max-width: 15px; border-radius: 3px 3px 1px 1px; background: var(--tone); opacity: .3; transition: opacity var(--t-fast), height var(--t-med); }
.proto-root .jc-spark > i:last-child { opacity: 1; }
.proto-root .jc-spark:hover > i { opacity: .55; }
.proto-root .jc-spark:hover > i:last-child { opacity: 1; }
.proto-root .jc-spark.empty { align-items: stretch; }
.proto-root .jc-spark.empty > i { height: 100%; opacity: 1; background: repeating-linear-gradient(45deg, var(--tone-soft) 0 5px, transparent 5px 10px); border: 1px dashed var(--tone-line); border-radius: 5px; }
/* 有效 PN 计数 */
.proto-root .jc-count { display: flex; align-items: baseline; gap: 6px; }
.proto-root .jc-count > b { font-family: var(--mono); font-size: 32px; font-weight: 800; line-height: .95; color: var(--ink); letter-spacing: -.04em; }
.proto-root .jc-count > span { font-size: 11px; color: var(--ink-4); }
/* 分段刻度条:一格 = 一条 PN,缺必填的那格直接标红——比一根实心进度条信息量大 */
.proto-root .jc-segmeter { display: flex; gap: 3px; }
.proto-root .jc-segmeter > i { flex: 1; height: 7px; border-radius: 2px; background: var(--ok); transition: background var(--t-fast); }
.proto-root .jc-segmeter > i.bad { background: var(--danger); }
.proto-root .jc-lockhint { display: inline-flex; align-items: center; gap: 4px; font-size: 10px; color: var(--ink-4); }
/* ── 占位胶囊:必填=红虚线,选填=灰虚线;**都带文字**,不靠颜色单打 ── */
.proto-root .jc-ph { display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700; line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed; }
.proto-root .jc-ph.req { color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
.proto-root .jc-ph.opt { color: var(--ink-4); background: var(--soft); border-color: var(--line); }
/* ── 分段控件(新客户 / 老客户)── */
.proto-root .jc-seg { display: inline-flex; padding: 2px; gap: 2px; border: 1px solid var(--cardline); border-radius: 9px; background: var(--soft); align-self: flex-start; }
.proto-root .jc-seg button { border: 0; border-radius: 7px; padding: 6px 13px; font: inherit; font-size: 11.5px; font-weight: 700; color: var(--ink-3); background: transparent; cursor: pointer; transition: all var(--t-fast); }
.proto-root .jc-seg button:hover:not(.on) { color: var(--brand); }
.proto-root .jc-seg button.on { background: #fff; color: var(--ink); box-shadow: 0 1px 3px rgba(31,35,41,.12); }
/* ── 表:读态与编辑态共用骨架 ─────────────────────────────────────── */
.proto-root .jc-tbl-wrap { overflow-x: auto; border: 1px solid var(--line-soft); border-radius: 10px; }
.proto-root .jc-tbl-in { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; }
.proto-root .jc-tbl-in th { background: var(--soft); text-align: left; padding: 8px 9px; font-size: 10.5px; font-weight: 800; letter-spacing: .03em; color: var(--ink-3); white-space: nowrap; border-bottom: 1px solid var(--line-soft); }
.proto-root .jc-tbl-in th.req::after { content: "*"; margin-left: 1px; color: var(--danger); font-size: 10px; }
.proto-root .jc-tbl-in td { padding: 4px 5px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
.proto-root .jc-tbl-in tr:last-child td { border-bottom: 0; }
.proto-root .jc-tbl-in tbody tr:hover td { background: #f7f9fd; }
/* 缺必填的整行浅红打底 + 行首红标——不用找红胶囊,一眼扫到是哪几行 */
.proto-root .jc-tbl-in tr.miss td { background: #fffbfb; }
.proto-root .jc-tbl-in tbody tr.miss:hover td { background: #fff5f5; }
.proto-root .jc-tbl-in td.jc-rn { position: relative; width: 38px; padding: 4px 6px 4px 13px; white-space: nowrap; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); font-weight: 800; }
/* 行首红标只在表态用 ::after —— 卡片态的 ::after 被「第 N 条」占着,不能抢 */
@media (min-width: 961px) {
.proto-root .jc-tbl-in tr.miss td.jc-rn::after { content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger); }
}
.proto-root .jc-rndot { display: inline-block; width: 5px; height: 5px; margin-left: 4px; border-radius: 999px; background: var(--danger); vertical-align: 2px; }
/* 读态单元格 */
.proto-root .jc-vcell { display: block; padding: 6px 6px; font-size: 12px; color: var(--ink); line-height: 1.5; overflow-wrap: anywhere; }
.proto-root .jc-vcell.mono { font-family: var(--mono); font-size: 11px; color: var(--ink-2); }
.proto-root .jc-vcell.num { font-family: var(--mono); font-size: 11.5px; text-align: right; }
/* 编辑态单元格:安静的电子表格 */
.proto-root .jc-cell { width: 100%; min-width: 64px; border: 1px solid var(--line); border-radius: 6px; padding: 6px 7px; font: inherit; font-size: 12px; color: var(--ink); background: #fff; transition: border-color var(--t-fast), box-shadow var(--t-fast); }
.proto-root .jc-cell.mono { font-family: var(--mono); font-size: 11px; }
.proto-root .jc-cell.num { text-align: right; font-family: var(--mono); font-size: 11.5px; }
.proto-root .jc-cell::placeholder { color: var(--ink-4); }
.proto-root .jc-cell:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-wash); outline: none; }
.proto-root .jc-cell.req-empty { border-color: #f3b6b3; background: #fffafa; }
.proto-root .jc-cell.req-empty::placeholder { color: var(--danger); }
/* 备注这类长文本用文本域:能换行、随内容长高,不裁字 */
.proto-root textarea.jc-cell { display: block; resize: vertical; overflow: hidden; min-height: 31px; line-height: 1.55; }
/* 编辑态的整表提示带 */
.proto-root .jc-editbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); border: 1px solid var(--brand-soft); font-size: 11.5px; color: var(--brand-deep); }
.proto-root .jc-editbar svg { flex: none; }
.proto-root .jc-editbar .sp { flex: 1 1 auto; }
/* 剔除行说明 */
.proto-root .jc-drop { display: flex; align-items: flex-start; gap: 8px; margin-top: 10px; padding: 9px 12px; border-radius: 9px; border: 1px dashed var(--line); background: var(--soft); font-size: 11.5px; color: var(--ink-3); line-height: 1.6; }
.proto-root .jc-drop svg { flex: none; margin-top: 2px; color: var(--ink-4); }
.proto-root .jc-drop code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
.proto-root .jc-callout { display: flex; gap: 8px; align-items: flex-start; border-radius: 9px; padding: 11px 13px; font-size: 12.5px; line-height: 1.75; }
.proto-root .jc-callout p { margin: 0; }
.proto-root .jc-callout svg { flex: none; margin-top: 2px; }
.proto-root .jc-callout.t-mute { background: var(--soft); color: var(--ink-2) }
@media (prefers-reduced-motion: reduce) {
.proto-root .jc-pulse-dot { animation: none; }
.proto-root .jc-report, .proto-root .jc-rp-stamp { animation: none; }
}
/* ══════════════════════════════════════════════════════════════════════════
响应式:宁可另起一行,也不损失完整度
≥961px 六列全展开的表(列 min-width 之和 878px,容器有余量,绝不裁字、绝不横滚)
≤960px 表 → 卡片:一条 PN 一张卡、一行一字段(标签 / 值),无横滚、无省略
≤560px 字段行再拆成上下两行(标签在上、值在下)
══════════════════════════════════════════════════════════════════════════ */
@media (max-width: 1040px) { .proto-root .jc-four { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 560px) { .proto-root .jc-four { grid-template-columns: 1fr; } }
@media (max-width: 960px) {
.proto-root .jc-tbl-wrap { overflow: visible; border: 0; border-radius: 0; background: transparent; }
.proto-root .jc-tbl-in,
.proto-root .jc-tbl-in tbody,
.proto-root .jc-tbl-in tr,
.proto-root .jc-tbl-in td { display: block; width: 100%; }
.proto-root .jc-tbl-in thead { display: none; }
/* 一条 PN = 一张卡 */
.proto-root .jc-tbl-in tr { margin-bottom: 10px; padding: 4px 13px 9px; border: 1px solid var(--line-soft); border-radius: 11px; background: #fff; box-shadow: var(--card-shadow); }
.proto-root .jc-tbl-in tr:last-child { margin-bottom: 0; }
.proto-root .jc-tbl-in tbody tr:hover td { background: transparent; }
.proto-root .jc-tbl-in tr.miss { border-color: #f3b6b3; background: #fffbfb; }
/* 卡头:第 N 条 */
.proto-root .jc-tbl-in td.jc-rn { padding: 8px 0 8px; margin-bottom: 4px; border-bottom: 1px solid var(--line-soft); font-family: inherit; font-size: 12px; font-weight: 800; color: var(--ink-2); }
.proto-root .jc-tbl-in td.jc-rn::before { content: "第 "; }
.proto-root .jc-tbl-in td.jc-rn::after { content: " 条"; }
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); }
/* 字段行:左标签 + 右值,值超长自动折行 */
.proto-root .jc-tbl-in td:not(.jc-rn) { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 10px; align-items: center; padding: 6px 0; border-bottom: 1px dashed var(--line-soft); }
.proto-root .jc-tbl-in tr td:last-child { border-bottom: 0; padding-bottom: 2px; }
.proto-root .jc-tbl-in td:not(.jc-rn)::before {
content: attr(data-h); font-size: 10.5px; font-weight: 700; letter-spacing: .02em; color: var(--ink-4); line-height: 1.5;
}
.proto-root .jc-tbl-in td[data-req="1"]:not(.jc-rn)::before { content: attr(data-h) " *"; }
.proto-root .jc-tbl-in .jc-vcell { padding: 0; overflow-wrap: anywhere; }
.proto-root .jc-tbl-in .jc-vcell.num { text-align: left; }
.proto-root .jc-tbl-in .jc-cell.num { text-align: left; }
.proto-root .jc-tbl-in .jc-cell { min-width: 0; }
}
/* 窄屏:落款三件各占一行,动作按钮撑满一行 */
@media (max-width: 560px) {
.proto-root .jc-rp-acts { margin-left: 0; width: 100%; flex-wrap: wrap; }
.proto-root .jc-rp-acts .jc-btn { flex: 1 1 auto; justify-content: center; }
.proto-root .jc-hintline { flex: 1 1 100%; }
.proto-root .jc-tbl-in td:not(.jc-rn) { grid-template-columns: 1fr; gap: 3px; align-items: start; }
}
/* ══ 以下取自 A1-询价受理记录.html(归档件独有的件)══ */
/* ── 料号分类:分布条 + 图例 ───────────────────────────────────────── */
.proto-root .jc-dist { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 13px 15px; border: 1px solid var(--line-soft); border-radius: 12px; background: linear-gradient(180deg, #fbfcfe, #fff); }
.proto-root .jc-distbar { flex: 1 1 320px; min-width: 260px; display: flex; gap: 3px; }
.proto-root .jc-distbar > i { height: 30px; border-radius: 4px; display: grid; place-items: center; font-size: 10.5px; font-weight: 800; color: #fff; font-family: var(--mono); }
.proto-root .jc-distbar > i.a { background: var(--ok); }
.proto-root .jc-distbar > i.b { background: var(--brand); }
.proto-root .jc-distbar > i.c { background: var(--warn-accent); }
.proto-root .jc-distlg { display: flex; flex-wrap: wrap; gap: 8px 18px; }
.proto-root .jc-distlg > span { display: inline-flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--ink-3); }
.proto-root .jc-distlg em { width: 10px; height: 10px; border-radius: 3px; }
.proto-root .jc-distlg em.a { background: var(--ok); } .proto-root .jc-distlg em.b { background: var(--brand); } .proto-root .jc-distlg em.c { background: var(--warn-accent); }
.proto-root .jc-distlg b { font-family: var(--mono); font-weight: 800; color: var(--ink); }
/* 类别徽 A / B / C */
.proto-root .jc-tbadge { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 6px; font-family: var(--mono); font-size: 11px; font-weight: 800; line-height: 1; color: #fff; }
.proto-root .jc-tbadge.a { background: var(--ok); } .proto-root .jc-tbadge.b { background: var(--brand); } .proto-root .jc-tbadge.c { background: var(--warn-accent); }
/* ── 结论与去向 ─────────────────────────────────────────────────────────
G2 是**分支网关**:三条互斥条件按序求值,只命中一条、只落一个活动。
所以这里不是三张并列的去向卡,而是一张**判定表 + 一条结论**。 */
/* 判定表:一行一条规则,命中行上色留痕(DMN 口径) */
.proto-root .jc-dmn { border: 1px solid var(--line-soft); border-radius: 12px; overflow: hidden; }
.proto-root .jc-dmn-h, .proto-root .jc-dmn-r { display: grid; grid-template-columns: 40px minmax(0, 1fr) 170px 200px; align-items: center; gap: 12px; padding: 9px 14px; }
.proto-root .jc-dmn-h { background: var(--soft); border-bottom: 1px solid var(--line-soft); font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); }
.proto-root .jc-dmn-r { font-size: 12px; color: var(--ink-4); background: #fff; }
.proto-root .jc-dmn-r + .jc-dmn-r { border-top: 1px solid var(--line-soft); }
.proto-root .jc-dmn-i { font-family: var(--mono); font-size: 11px; font-weight: 800; }
.proto-root .jc-dmn-c { line-height: 1.6; overflow-wrap: anywhere; }
.proto-root .jc-dmn-t { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; }
.proto-root .jc-dmn-t code { font-family: var(--mono); font-size: 10.5px; font-weight: 800; color: var(--ink-4); background: var(--soft); border: 1px solid var(--line); border-radius: 5px; padding: 2px 6px; }
.proto-root .jc-dmn-v { font-size: 11px; line-height: 1.55; }
/* 命中行:上色 + 左缘棱 + 加粗 */
.proto-root .jc-dmn-r.hit { position: relative; background: var(--tone-soft); color: var(--ink-2); }
.proto-root .jc-dmn-r.hit::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--tone); }
.proto-root .jc-dmn-r.hit .jc-dmn-i { color: var(--tone); }
.proto-root .jc-dmn-r.hit .jc-dmn-c { color: var(--ink); }
.proto-root .jc-dmn-r.hit .jc-dmn-c b { color: var(--tone); font-weight: 800; }
.proto-root .jc-dmn-r.hit .jc-dmn-t { color: var(--ink); }
.proto-root .jc-dmn-r.hit .jc-dmn-t code { color: #fff; background: var(--tone); border-color: var(--tone); }
.proto-root .jc-dmn-v.y { display: inline-flex; align-items: center; gap: 5px; font-weight: 800; color: var(--tone); }
.proto-root .jc-dmn-v.n { color: var(--ink-4); }
/* 色调随命中类别 */
.proto-root .jc-dmn.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.10); }
.proto-root .jc-dmn.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.09); }
.proto-root .jc-dmn.k-c { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.11); }
/* 结论横幅:命中条件 → 唯一去向 */
.proto-root .jc-verdict {
position: relative; overflow: hidden; display: flex; align-items: stretch; flex-wrap: wrap; gap: 16px;
margin-top: 11px; padding: 16px 18px; border: 1px solid var(--tone-line); border-radius: 13px;
background: linear-gradient(105deg, var(--tone-soft), #fff 62%);
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 10px 24px -16px rgba(31,35,41,.4);
--tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.14); --tone-line: #ffd9a8;
}
.proto-root .jc-verdict.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.13); --tone-line: #bfedb4; }
.proto-root .jc-verdict.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft); }
.proto-root .jc-verdict::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 5px; background: var(--tone); }
.proto-root .jc-verdict > * { position: relative; z-index: 1; }
.proto-root .jc-vd-l { flex: 1 1 300px; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
.proto-root .jc-vd-k { font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
.proto-root .jc-vd-cond { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 15px; font-weight: 800; color: var(--ink); }
.proto-root .jc-vd-why { margin: 0; font-size: 11.5px; line-height: 1.7; color: var(--ink-3); overflow-wrap: anywhere; }
.proto-root .jc-vd-why code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
.proto-root .jc-vd-arrow { flex: none; align-self: center; color: var(--tone); }
.proto-root .jc-vd-r { flex: 1 1 260px; min-width: 0; display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-radius: 11px; background: #fff; border: 1px solid var(--tone-line); }
.proto-root .jc-vd-node { display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 800; color: var(--ink); }
.proto-root .jc-vd-node code { font-family: var(--mono); font-size: 12px; font-weight: 800; color: #fff; background: var(--tone); border-radius: 6px; padding: 3px 8px; }
.proto-root .jc-vd-who { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--ink-2); }
.proto-root .jc-vd-who > i { width: 20px; height: 20px; border-radius: 999px; display: inline-grid; place-items: center; font-style: normal; font-size: 10px; font-weight: 700; color: #fff; background: var(--tone); }
.proto-root .jc-vd-next { margin: 0; padding-top: 8px; border-top: 1px dashed var(--line-soft); font-size: 10.5px; line-height: 1.65; color: var(--ink-4); }
.proto-root .jc-vd-next b { color: var(--ink-2); font-weight: 700; }
/* 命中分支携带的条目 */
.proto-root .jc-pnlist { display: flex; flex-wrap: wrap; gap: 5px; margin: 0; padding: 0; list-style: none; }
.proto-root .jc-pnlist > li { display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 999px; background: #fff; border: 1px solid var(--tone-line); font-size: 11px; color: var(--ink-2); overflow-wrap: anywhere; }
.proto-root .jc-pnlist > li > i { flex: none; width: 5px; height: 5px; border-radius: 999px; background: var(--tone); }
.proto-root .jc-pnlist code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
/* ── 落地动作 ───────────────────────────────────────────────────────── */
.proto-root .jc-acts { display: flex; flex-direction: column; gap: 0; border: 1px solid var(--line-soft); border-radius: 11px; overflow: hidden; }
.proto-root .jc-act { display: flex; align-items: flex-start; gap: 9px; padding: 10px 13px; font-size: 12px; color: var(--ink-2); line-height: 1.6; background: #fff; }
.proto-root .jc-act + .jc-act { border-top: 1px solid var(--line-soft); }
.proto-root .jc-act > i { flex: none; width: 18px; height: 18px; margin-top: 1px; border-radius: 999px; display: inline-grid; place-items: center; color: #fff; background: var(--ok); }
.proto-root .jc-act b { font-weight: 700; color: var(--ink); }
.proto-root .jc-act span { color: var(--ink-4); }
.proto-root .jc-act code { font-family: var(--mono); font-size: 10.5px; color: var(--brand-deep); background: var(--brand-wash); border-radius: 4px; padding: 1px 5px; }
.proto-root .jc-done-tag { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; flex: none; padding: 6px 13px; border-radius: 999px; font-size: 11.5px; font-weight: 800; color: var(--ok); background: #fff; border: 1px solid #bfedb4; }
.proto-root .jc-vcell.dim { color: var(--ink-4); }
.proto-root .jc-tbl-in td.jc-ty { width: 44px; text-align: center; }
.proto-root .jc-tbl-in td.jc-ty { width: auto; text-align: left; }
`;
/**
* 图标精灵 —— 与样式表同一出处,两份目标件的 `<symbol>` 取并集。
* 沙箱里取不到图标字体,所以图标一律内联;`<use href="#i-xxx">` 引的就是这一坨。
*/
export const PRD_SPRITE = `<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs><symbol id="i-alert" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></symbol><symbol id="i-arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol><symbol id="i-boxes" viewBox="0 0 24 24"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></symbol><symbol id="i-building" viewBox="0 0 24 24"><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/><path d="M10 18h4"/></symbol><symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol><symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol><symbol id="i-file-check" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="m9 15 2 2 4-4"/></symbol><symbol id="i-file-text" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></symbol><symbol id="i-git-branch" viewBox="0 0 24 24"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></symbol><symbol id="i-history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></symbol><symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></symbol><symbol id="i-lock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol><symbol id="i-mail-open" viewBox="0 0 24 24"><path d="M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0z"/><path d="m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10"/></symbol><symbol id="i-pen" viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z"/></symbol><symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol><symbol id="i-sheet" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M8 13h2"/><path d="M14 13h2"/><path d="M8 17h2"/><path d="M14 17h2"/></symbol><symbol id="i-shield-check" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></symbol><symbol id="i-user" viewBox="0 0 24 24"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/></symbol><symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></symbol></defs></svg>`;
+523 -94
View File
@@ -1,101 +1,530 @@
/** /**
* A3 渠道寻源的 HTML 汇报模版 —— **活动自己持有的资产**(§9.3),随包版本冻结。 * A3 寻源询价的 HTML 报告模版 —— **一张件贯穿始终**:目标报价清单。
* *
* ── 这一屏给谁看、他此刻要判什么 ────────────────────────────────── * 在办(PRM 补货源价时)与归档(放行后进工作成果)是**同一份 HTML**,
* 看的人是采购(PRM)与等着底价的 PM。他此刻只判一件事:**这价能不能拿去往下走**。 * 差别只在外层给不给编辑态(`html.editable`)。版面语言沿用 A1 从 PRD 目标件
* 于是版面按「先成果、后局面」排: * 机械抽取的那套(`prd-style.ts` 原样拷入)—— A3 没有自己的 PRD 目标件
* 一 供应商回价(收回来多少、都是什么渠道)· 二 寻源比价表(交回 PM 的成果本体) * (2026-08-02 用户定:沿用 A1 版面语言自造)。
* → 三 案例阶段 · 四 当下可为 · 五 目标缺口(AI 的办案局面)
* 指标带扫一眼就该有结论(回价够不够、比价表填没填),往下五节是「凭什么这么说」。
* *
* ── 画面上的数从哪来(一个都不是编的)──────────────────────────── * 取值路径三纪律照 A1:顶层只取汇总列与单值列;逐条走 `mine:quote_sourcing.quote_list`;
* · `mine:quote_sourcing.*` 是**本单自己写下的草稿行** —— 别单放行过的回价不混进来; * 列名逐字照 `declarations.ledger` 抄。模版不算不判 —— 缺价红带、价源徽、分布条
* 列名逐字取自 `declarations.ledger`,编一个不存在的列名 = 屏上永远空白。 * 全部由基座算好的数经 `data-class-of` 投影,判断留在样式表。
* · `case:*` / `enabledCount` / `gapCount` / `activeStage` 由语义引擎每拍算出,
* 是确定事实、零 LLM 即可成屏 —— 模型只决定「下一步做什么」,不负责画面。
* · 指标带上「已收回价 / 比价表行数」两格指的是**数组本身**:填数时数组取长度
* (view-render 的 `displayValue` 约定),不必为「几单」另立一个计数字段。
*
* ── 两条不撒谎的纪律 ──────────────────────────────────────────
* ① 右上角挂的是「在办」灯不是印章:这一屏是活的局面,它没盖过章,就别画成盖了章的样子;
* 紫(tone `case`)是**案例活动的标记色不是状态色** —— 红绿橙只留给「达标没达标」。
* ② 表空着就是「本单还没写下这一行」,不是渲染坏了 —— 每张表的说明里都写明空着意味着什么。
* 寻源尤其怕这个:空白被读成「这家没报价」,而它可能只是还没登记。
*
* 容器是 `sandbox=""` iframe:无脚本、无外链,样式与图标由 `@sdk` 自带。
*/ */
import { noteOnly, section, stagesOnly, statsBand, tableOnly, viewShell } from '@sdk'; import { PRD_CSS, PRD_SPRITE } from './prd-style.ts';
export const SOURCING_CASE = viewShell({ /* ==================== 绑定期补的样式(照 A1 的 BIND_CSS,地址换成本活动)==================== */
title: '渠道寻源 · 案例全景',
kicker: 'A3 · 渠道寻源 · 案例活动', const BIND_CSS = `
tone: 'case', /* 空格子自己说话 —— 值真的空才轮到(基座 data-blank) */
icon: 'mail', .proto-root .jc-tbl-in td.req > .jc-vcell:empty::after,
stamp: { kind: 'live', text: '案例在办 · 非定稿' }, .proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after {
sign: 'A3 渠道寻源 · 本页每拍按当下局面重画', 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: body:
statsBand([ part('寻源要素', '寻源询价 · MVP 口径:询价在系统外,价格落表内') +
['已收回价', 'mine:quote_sourcing.supplier_quote'], FOUR +
['比价表行数', 'mine:quote_sourcing.compare_table'], part('目标报价清单', '<b data-slot="quote_line_total"></b> 条 · 每行必有基准价') +
['当下可为', 'enabledCount'], GAP_BAR +
['目标缺口', 'gapCount'], editGate('quote_sourcing.quote_list', '目标报价清单') +
]) + ROSTER +
section('供应商回价(本单登记)', addRowBar('quote_sourcing.quote_list') +
tableOnly('mine:quote_sourcing.supplier_quote', [ `<div class="jc-drop">${ico('info', 13)}
['型号 PN', 'pn', 'mono'], <span>带 * 的<b>底价</b>是必填:每一行都要有基准价 —— <b>A 类</b>取库内历史价(系统预填,可校可改),
['供应商', 'supplier'], <b>B 类</b>由采购系统外询价补回。价源那一列逐行留徽:绿「历史价」是库内既有事实,
['渠道等级', 'grade'], 蓝「货源价」是本单问回来的 —— 两段合起来恰等于总行数,对不上就是账没记齐。
['回价', 'price', 'mono'], <b>问不到的价不许编</b>:剔出本轮(行首 ×)或按内部估价填并在备注写明。</span>
['交期', 'lead_time'], </div>` +
['有效期', 'valid_until', 'mono'], part('价源分布', '历史价 + 货源价 = 总行数') +
]) + DIST +
noteOnly( part('放行后即执行的落地动作', '门前 → 门后') +
'回价<b>逐单登记、一一对应不可缺项</b>:发出去几家就该收回几条,没回音的也要留一条写明' + `<div class="jc-acts">
'「无货 / 未应答」—— 少一条,下游读到的就是「没这回事」。渠道等级是场景尺度落到纸面的地方:' + <div class="jc-act"><i>${ico('check', 11)}</i>
'BOM后期按主 → 备选 → 贸易商圈 2~3 家、<b>贸易商尽量不用</b>,现货只选有库存的。' + <div><b>草稿层合并进正式版</b>&#12288;<span>本任务写下的目标报价清单与寻源结论一并落账;
'「有效期」是回价到期监听盯的那一列,过期即触发重询。表空着,是本单还没登记回价。', PRM 补价与就地修订的那几处随之落定。</span></div>
), </div>
{ text: '一料一渠一行' }, <div class="jc-act"><i>${ico('check', 11)}</i>
) + <div><b>清单此刻才对外发出</b>&#12288;<span>整份 <b data-slot="quote_sourcing_quote_list_rows"></b> 行
section('寻源比价表(本单交付件)', (每行带基准价与价源)交出 —— 放行之前,世界上什么都没有发生。</span></div>
tableOnly('mine:quote_sourcing.compare_table', [ </div>
['型号 PN', 'pn', 'mono'], <div class="jc-act"><i>${ico('check', 11)}</i>
['供应商', 'supplier'], <div><b>转入成本定价</b>&#12288;<span>本活动只有一个出口 —— 目标报价清单是定价环节的直接输入,
['回价', 'price', 'mono'], 已进对方的任务清单。</span></div>
['结论', 'conclusion'], </div>
]) + </div>`,
noteOnly( foot: sign('PRM 询价 · AI 归一齐检 —— 要改哪一格,在这张单的清单表上就地改', '统一报价 · A3 寻源询价 → 交定价'),
'这是交回 PM 的成果本体:一料一结论,多渠道横比定底。<b>不实回价或远低行情的必须换渠道补询</b>,' + });
'不得直接采用;现货场景不得选无库存转单商。采用策略由判定表出(直采 / 取均价 / 重询),' +
'不是模型自己说了算。表空着,是本单还没走到反馈交付。', /* ==================== 局面一 · 寻源询价工作单(归一预填与 PRM 补价期间)==================== */
),
{ text: '交回 PM 的成果' }, /**
) + * 逐颗询价档案 —— 一颗料一张卡(data-rows 逐行复制)。
section('案例阶段(CMMN 生命周期)', * 形态按类别分档:B/C 摊开成「题面 + 定案回显」两栏;A 收成一行「已按历史价预填」。
stagesOnly('case:stages') + *
noteOnly( * **容器用 `<section>` 不用 `<div>`**:渲染器的行展开按第一个同名闭合标签截行内容,
'阶段的生命周期态由语义引擎推进到 <b>Enabled</b> 为止;凡触达世界或产出关键交付的计划项' + * 行模版里嵌着 `<div>` 时 `<div data-rows>` 会把行截成套娃(A2 逮过,记在陷阱表)。
'必须人工启动 —— <b>Enabled ≠ Active</b>。寻源是<b>一事一议</b>:几家供应商、谁先回、' + */
'要不要追加一轮,事前排不出谱,所以调度权交给 AI,系统只保证边框:预算、停滞熔断、放行归人。', const INQ_CARDS = `<section class="jc-selcards" data-rows="mine:quote_sourcing.quote_list">
), <article class="jc-selcard" data-class-of="type_tag">
{ text: '推进到 Enabled 为止' }, <div class="jc-selcard-h">
) + <span class="jc-tbadge" data-class-of="type_tag" data-slot="type_tag"></span>
section('当下可为(AI 的合法选项)', <code data-slot="pn"></code>
stagesOnly('case:enabled') + <span class="why" data-slot="remark" data-blank></span>
noteOnly( </div>
'AI 只能在这个集合里选下一步:<b>可以越谱,不可越权</b>。发询价信是<b>触达世界</b>的动作 ——' + <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>
{ text: '本拍的合法选项' }, <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>
section('目标缺口(离交付还差什么)', <div class="jc-kvrow"><i>客户料号</i><span data-slot="customer_pn" data-blank></span></div>
stagesOnly('case:gaps') + <div class="jc-kvrow"><i>年用量</i><span data-slot="eau" data-blank></span></div>
noteOnly( <div class="jc-kvrow"><i>目标价</i><span data-slot="target_price" data-blank></span></div>
'缺口只有人知道 → 调人;缺口在世界侧(等供应商回价)→ 立监听,<b>时限必配</b> ——' + <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>
'当下可为 ∩ 目标所需 = 空,就是<b>依赖断点</b>。这一栏空着,是必做的事都办完了。', </div>
), <div class="jc-selcol">
{ text: '空 = 必做的都办完了' }, <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 寻源询价 · 在办'),
}); });
+4
View File
@@ -1953,6 +1953,10 @@ export declare const installActivityRoute: {
tally: z.ZodOptional<z.ZodArray<z.ZodString>>; tally: z.ZodOptional<z.ZodArray<z.ZodString>>;
expect: z.ZodOptional<z.ZodBoolean>; expect: z.ZodOptional<z.ZodBoolean>;
auto: z.ZodOptional<z.ZodBoolean>; auto: z.ZodOptional<z.ZodBoolean>;
requiredWhen: z.ZodOptional<z.ZodObject<{
column: z.ZodString;
value: z.ZodString;
}, z.core.$strip>>;
}, z.core.$strip>>; }, z.core.$strip>>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
listeners: z.ZodDefault<z.ZodArray<z.ZodObject<{ listeners: z.ZodDefault<z.ZodArray<z.ZodObject<{
+4
View File
@@ -859,6 +859,7 @@ export declare const taskDetailSchema: z.ZodObject<{
}>; }>;
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>; values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
}, z.core.$strip>>; }, z.core.$strip>>;
finalHtml: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{ }, z.core.$strip>, z.ZodObject<{
mode: z.ZodLiteral<"acceptance">; mode: z.ZodLiteral<"acceptance">;
deliverable: z.core.$ZodBranded<z.ZodString, "DeliverableId", "out">; deliverable: z.core.$ZodBranded<z.ZodString, "DeliverableId", "out">;
@@ -1164,6 +1165,7 @@ export declare const taskDetailSchema: z.ZodObject<{
}>; }>;
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>; values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
}, z.core.$strip>>; }, z.core.$strip>>;
finalHtml: z.ZodOptional<z.ZodString>;
}, z.core.$strip>], "mode">>; }, z.core.$strip>], "mode">>;
chat: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{ chat: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
k: z.ZodLiteral<"ai">; k: z.ZodLiteral<"ai">;
@@ -2157,6 +2159,7 @@ export declare const taskDetailRoute: {
}>; }>;
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>; values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
}, z.core.$strip>>; }, z.core.$strip>>;
finalHtml: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{ }, z.core.$strip>, z.ZodObject<{
mode: z.ZodLiteral<"acceptance">; mode: z.ZodLiteral<"acceptance">;
deliverable: z.core.$ZodBranded<z.ZodString, "DeliverableId", "out">; deliverable: z.core.$ZodBranded<z.ZodString, "DeliverableId", "out">;
@@ -2462,6 +2465,7 @@ export declare const taskDetailRoute: {
}>; }>;
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>; values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
}, z.core.$strip>>; }, z.core.$strip>>;
finalHtml: z.ZodOptional<z.ZodString>;
}, z.core.$strip>], "mode">>; }, z.core.$strip>], "mode">>;
chat: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{ chat: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
k: z.ZodLiteral<"ai">; k: z.ZodLiteral<"ai">;
+4
View File
@@ -874,6 +874,7 @@ export declare const executionWorkOrderSchema: z.ZodObject<{
}>; }>;
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>; values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
}, z.core.$strip>>; }, z.core.$strip>>;
finalHtml: z.ZodOptional<z.ZodString>;
}, z.core.$strip>; }, z.core.$strip>;
export type ExecutionWorkOrder = z.infer<typeof executionWorkOrderSchema>; export type ExecutionWorkOrder = z.infer<typeof executionWorkOrderSchema>;
export declare const acceptanceCriterionCheckSchema: z.ZodObject<{ export declare const acceptanceCriterionCheckSchema: z.ZodObject<{
@@ -1207,6 +1208,7 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{
}>; }>;
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>; values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
}, z.core.$strip>>; }, z.core.$strip>>;
finalHtml: z.ZodOptional<z.ZodString>;
}, z.core.$strip>; }, z.core.$strip>;
export type AcceptanceWorkOrder = z.infer<typeof acceptanceWorkOrderSchema>; export type AcceptanceWorkOrder = z.infer<typeof acceptanceWorkOrderSchema>;
export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
@@ -1481,6 +1483,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
}>; }>;
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>; values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
}, z.core.$strip>>; }, z.core.$strip>>;
finalHtml: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{ }, z.core.$strip>, z.ZodObject<{
mode: z.ZodLiteral<"acceptance">; mode: z.ZodLiteral<"acceptance">;
deliverable: z.core.$ZodBranded<z.ZodString, "DeliverableId", "out">; deliverable: z.core.$ZodBranded<z.ZodString, "DeliverableId", "out">;
@@ -1786,6 +1789,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
}>; }>;
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>; values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
}, z.core.$strip>>; }, z.core.$strip>>;
finalHtml: z.ZodOptional<z.ZodString>;
}, z.core.$strip>], "mode">; }, z.core.$strip>], "mode">;
export type WorkOrder = z.infer<typeof workOrderSchema>; export type WorkOrder = z.infer<typeof workOrderSchema>;
/** AI 发言的类型标记 —— 对话区可分辨:结论 / 指导 / 判据 */ /** AI 发言的类型标记 —— 对话区可分辨:结论 / 指导 / 判据 */
+8
View File
@@ -53,6 +53,10 @@ export declare const fieldDeclarationSchema: z.ZodObject<{
tally: z.ZodOptional<z.ZodArray<z.ZodString>>; tally: z.ZodOptional<z.ZodArray<z.ZodString>>;
expect: z.ZodOptional<z.ZodBoolean>; expect: z.ZodOptional<z.ZodBoolean>;
auto: z.ZodOptional<z.ZodBoolean>; auto: z.ZodOptional<z.ZodBoolean>;
requiredWhen: z.ZodOptional<z.ZodObject<{
column: z.ZodString;
value: z.ZodString;
}, z.core.$strip>>;
}, z.core.$strip>; }, z.core.$strip>;
export type FieldDeclaration = z.infer<typeof fieldDeclarationSchema>; export type FieldDeclaration = z.infer<typeof fieldDeclarationSchema>;
/** /**
@@ -112,6 +116,10 @@ export declare const objectDeclarationSchema: z.ZodObject<{
tally: z.ZodOptional<z.ZodArray<z.ZodString>>; tally: z.ZodOptional<z.ZodArray<z.ZodString>>;
expect: z.ZodOptional<z.ZodBoolean>; expect: z.ZodOptional<z.ZodBoolean>;
auto: z.ZodOptional<z.ZodBoolean>; auto: z.ZodOptional<z.ZodBoolean>;
requiredWhen: z.ZodOptional<z.ZodObject<{
column: z.ZodString;
value: z.ZodString;
}, z.core.$strip>>;
}, z.core.$strip>>; }, z.core.$strip>>;
}, z.core.$strip>; }, z.core.$strip>;
export type ObjectDeclaration = z.infer<typeof objectDeclarationSchema>; export type ObjectDeclaration = z.infer<typeof objectDeclarationSchema>;
+8
View File
@@ -66,6 +66,10 @@ export declare const manifestDeclarationsSchema: z.ZodObject<{
tally: z.ZodOptional<z.ZodArray<z.ZodString>>; tally: z.ZodOptional<z.ZodArray<z.ZodString>>;
expect: z.ZodOptional<z.ZodBoolean>; expect: z.ZodOptional<z.ZodBoolean>;
auto: z.ZodOptional<z.ZodBoolean>; auto: z.ZodOptional<z.ZodBoolean>;
requiredWhen: z.ZodOptional<z.ZodObject<{
column: z.ZodString;
value: z.ZodString;
}, z.core.$strip>>;
}, z.core.$strip>>; }, z.core.$strip>>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
listeners: z.ZodDefault<z.ZodArray<z.ZodObject<{ listeners: z.ZodDefault<z.ZodArray<z.ZodObject<{
@@ -544,6 +548,10 @@ export declare const activityManifestSchema: z.ZodObject<{
tally: z.ZodOptional<z.ZodArray<z.ZodString>>; tally: z.ZodOptional<z.ZodArray<z.ZodString>>;
expect: z.ZodOptional<z.ZodBoolean>; expect: z.ZodOptional<z.ZodBoolean>;
auto: z.ZodOptional<z.ZodBoolean>; auto: z.ZodOptional<z.ZodBoolean>;
requiredWhen: z.ZodOptional<z.ZodObject<{
column: z.ZodString;
value: z.ZodString;
}, z.core.$strip>>;
}, z.core.$strip>>; }, z.core.$strip>>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
listeners: z.ZodDefault<z.ZodArray<z.ZodObject<{ listeners: z.ZodDefault<z.ZodArray<z.ZodObject<{
@@ -81,13 +81,13 @@ describe('写行的形状', () => {
}); });
describe('看不懂就转人,不猜着动手', () => { describe('看不懂就转人,不猜着动手', () => {
it('行动型不认识 → 发执行卡,并把**模型原话**摆上去', () => { it('行动型不认识 → 发执行卡,卡面只说人话 —— JSON 原话归工作日志,不上卡', () => {
const action = normalizeAction({ kind: '我自己想的行动' }, ENABLED, '{"kind":"我自己想的行动"}'); const action = normalizeAction({ kind: '我自己想的行动' }, ENABLED);
expect(action.kind).toBe('human'); expect(action.kind).toBe('human');
if (action.kind !== 'human') return; if (action.kind !== 'human') return;
expect(action.card['reason']).toBe('模型未给出合规的行动'); // 2026-08-03 实测:原话上卡,人看到的是一坨代码(「卡片里又是乱七八糟的东西」)
// 只写「不合规」的话,收卡的人分不清是模型跑偏、行动型少了一种、还是提示词没交代清楚 expect(action.card['modelReply']).toBeUndefined();
expect(String(action.card['modelReply'])).toContain('我自己想的行动'); expect(String(action.card['reason'])).toContain('工作日志');
expect(action.card['kindSeen']).toBe('我自己想的行动'); expect(action.card['kindSeen']).toBe('我自己想的行动');
}); });
@@ -98,44 +98,38 @@ describe('看不懂就转人,不猜着动手', () => {
}); });
/** /**
* 补全卡的格子在哪儿 —— **两处都认**。 * **补全卡已全局退役**(2026-08-02 用户定:「彻底全局退役补全卡,执行卡完全就是
* * 包含补全语义的人类工具」)—— 归一层把旧输出里的 `ask` 原地转成零输入执行工单:
* 提示词里执行工单的写法是 `card.fields`,模型很自然把补全卡也那么写(实测就是), * 问句上卡,字段名并进指导书(人补在工作区的件上),`fields` 不上卡。
* 于是每一拍都被「fields 为空」拒回来。空不空由引擎验,这里只负责不让一个位置差异吃掉一整拍。 * 拒回只会让模型换着花样再发,归一才是把路铺平。
*/ */
describe('补全卡的格子', () => { describe('补全卡退役:ask 原地转成零输入执行工单', () => {
const fieldsOf = (raw: Record<string, unknown>) => { const norm = (raw: Record<string, unknown>) => normalizeAction({ kind: 'ask', itemId: 'T1', ...raw }, ENABLED);
const a = normalizeAction({ kind: 'ask', itemId: 'T1', ...raw }, ENABLED);
return a.kind === 'ask' ? a.fields : [];
};
it('顶层 fields', () => { it('转成 human + execution,问句在 ask,字段名进指导书', () => {
expect(fieldsOf({ ask: '补一下', fields: [{ key: 'lead_time', label: '交期' }] })).toEqual([ const a = norm({ ask: '缺交期,补一下', fields: [{ key: 'lead_time', label: '交期' }] });
{ key: 'lead_time', label: '交期' }, expect(a.kind).toBe('human');
]); if (a.kind !== 'human') return;
expect(a.workOrder).toBe('execution');
const card = a.card as Record<string, unknown>;
expect(card['ask']).toBe('缺交期,补一下');
expect(JSON.stringify(card['steps'])).toContain('交期');
// 零输入:fields 不上卡
expect(card['fields']).toBeUndefined();
}); });
it('落在 card 里也认(实测模型这么写过)', () => { it('fields 落在 card 里也认(旧模型这么写过)—— 一样进指导书', () => {
expect(fieldsOf({ card: { ask: '补一下', fields: [{ key: 'eau', label: '年用量' }] } })).toEqual([ const a = norm({ card: { ask: '补一下', fields: [{ key: 'eau', label: '年用量' }] } });
{ key: 'eau', label: '年用量' }, expect(a.kind).toBe('human');
]); if (a.kind !== 'human') return;
expect(JSON.stringify((a.card as Record<string, unknown>)['steps'])).toContain('年用量');
}); });
it('写成一串字段名也认 —— label 用 key 顶上,宁可显示字段名不显示空白', () => { it('连问句都没有:兜一个标题,不吃掉整拍', () => {
expect(fieldsOf({ ask: 'x', fields: ['eau', 'lead_time'] })).toEqual([ const a = norm({});
{ key: 'eau', label: 'eau' }, expect(a.kind).toBe('human');
{ key: 'lead_time', label: 'lead_time' }, if (a.kind !== 'human') return;
]); expect(String((a.card as Record<string, unknown>)['title'])).not.toBe('');
});
it('没有 key 的格子丢掉 —— 没有 key 就没法回填', () => {
expect(fieldsOf({ ask: 'x', fields: [{ label: '只有标签' }, { key: 'ok', label: '行' }] })).toEqual([
{ key: 'ok', label: '行' },
]);
});
it('一个都收不到就是空数组,交引擎拒(不替它编格子)', () => {
expect(fieldsOf({ ask: 'x' })).toEqual([]);
}); });
}); });
@@ -209,10 +203,16 @@ describe('一拍一串行动的归一', () => {
expect(turn.actions.map((a) => a.kind)).toEqual(['finish']); expect(turn.actions.map((a) => a.kind)).toEqual(['finish']);
}); });
it('整串一个都不认识 → 单张转人卡,模型原话摆上去', () => { it('整串一个都不认识 → 标 unrecognized(decide 先就地重问)+ 单张转人卡兜底', () => {
const turn = normalizeTurn({ actions: [{ kind: '胡编的' }] }, ENABLED, '{"kind":"胡编的"}'); const turn = normalizeTurn({ actions: [{ kind: '胡编的' }] }, ENABLED);
expect(turn.actions).toHaveLength(1); expect(turn.actions).toHaveLength(1);
expect(turn.actions[0]?.kind).toBe('human'); expect(turn.actions[0]?.kind).toBe('human');
expect(turn.unrecognized).toBe(true);
});
it('认出了行动就不标 unrecognized —— 别把好拍拿去白重问一遍', () => {
const turn = normalizeTurn({ actions: [{ kind: 'finish', itemId: 'T1' }] }, ENABLED);
expect(turn.unrecognized).toBeUndefined();
}); });
it('超出封顶截到 6 个 —— 引擎侧还会再守一道', () => { it('超出封顶截到 6 个 —— 引擎侧还会再守一道', () => {
@@ -0,0 +1,108 @@
/**
* 输出不成形的就地重问(2026-08-03 用户实测送来的单逼出来的)。
*
* 病:关思考的模型偶发把一拍输出写坏(整串行动一个都不认识),从前一次没认出
* 就直接发「需要你判断下一步」的兜底卡,还把 JSON 原话整段摆在卡面上 ——
* 人打开工单看到的是一坨代码(用户原话:「卡片里又是乱七八糟的东西」)。
*
* 法:第一次没认出 → 原文经 `note` 存进工作日志(内部痕),附「只输出 JSON」
* 就地再问一次;第二次仍不成形才交人,卡面只说人话、不摆原话。
* 这里用假网关钉死三件事:重问只有一次、原文进痕、好输出不白重问。
* (ModelGateway 是外界 —— LLM 厂商 —— 的缝,录制回放替身合规。)
*/
import { describe, expect, it } from 'vitest';
import type { EnabledSet } from '@modules/loop-engine';
import type { ModelCall, ModelGateway, ModelResult } from '@modules/llm';
import type { TaskId } from '@contracts';
import { createAgentRunner } from '../agent-runner.ts';
const ENABLED: EnabledSet = {
entries: [{ id: 'T1', name: '干活', type: 'humanTask', requiresManualStart: false, discretionary: false }],
inProgress: [],
judgements: [],
gaps: ['缺记录'],
activeStages: [],
};
/** 按序回放给定出参的假网关 —— 超出名单就重复最后一个 */
function gatewayOf(outputs: Array<{ object: unknown; text: string }>): { gateway: ModelGateway; calls: ModelCall[] } {
const calls: ModelCall[] = [];
const gateway: ModelGateway = {
mode: 'replay',
resolve: () => Promise.reject(new Error('测试里用不到 resolve')),
generate: (call) => {
calls.push(call);
const next = outputs[Math.min(calls.length - 1, outputs.length - 1)];
if (next === undefined) return Promise.reject(new Error('名单空着'));
const result: ModelResult = {
text: next.text,
object: next.object,
usage: { inputTokens: 10, outputTokens: 5, latencyMs: 1 },
binding: { name: 'fake', provider: 'deepseek', model: 'fake' },
mode: 'replay',
};
return Promise.resolve(result);
},
withMode: () => gateway,
};
return { gateway, calls };
}
function runnerOf(outputs: Array<{ object: unknown; text: string }>) {
const { gateway, calls } = gatewayOf(outputs);
const notes: Array<{ title: string; raw: string }> = [];
const runner = createAgentRunner({
gateway,
modelOf: () => Promise.resolve('fake'),
onUsage: () => Promise.resolve(),
note: (_taskId, title, raw) => {
notes.push({ title, raw });
return Promise.resolve();
},
});
const decide = () =>
runner.decide({
taskId: 'task-1' as TaskId,
narrative: '(空)',
enabled: ENABLED,
scope: {},
writable: [],
tools: [],
});
return { decide, calls, notes };
}
const GOOD = { object: { say: '收工', actions: [{ kind: 'finish', itemId: 'T1' }] }, text: '{"say":"收工"}' };
const BAD = { object: { say: '', actions: [{ kind: '胡编的' }] }, text: '再连贯的散文也不是行动串' };
describe('输出不成形先就地重问,别一抖就叫人', () => {
it('第一拍坏、重问好 → 两次调用、原文进痕一条、返回的是好行动', async () => {
const { decide, calls, notes } = runnerOf([BAD, GOOD]);
const turn = await decide();
expect(calls).toHaveLength(2);
// 重问那一次要把「只输出 JSON」的硬提示带上 —— 不带等于原样再撞一次
expect(calls[1]?.prompt).toContain('不是合法的行动 JSON');
expect(notes).toHaveLength(1);
expect(notes[0]?.raw).toContain('散文');
expect(turn.actions[0]?.kind).toBe('finish');
});
it('两拍都坏 → 就收手交人,不无限重问;卡面不摆模型原话', async () => {
const { decide, calls } = runnerOf([BAD, BAD]);
const turn = await decide();
expect(calls).toHaveLength(2);
const action = turn.actions[0];
expect(action?.kind).toBe('human');
if (action?.kind !== 'human') return;
expect(action.card['modelReply']).toBeUndefined();
expect(String(action.card['reason'])).toContain('工作日志');
});
it('第一拍就是好的 → 只调一次,不白烧重问', async () => {
const { decide, calls, notes } = runnerOf([GOOD]);
const turn = await decide();
expect(calls).toHaveLength(1);
expect(notes).toHaveLength(0);
expect(turn.actions[0]?.kind).toBe('finish');
});
});
@@ -9,7 +9,7 @@
* 所以归型放在写进草稿那一刻:归得了就归,归不了当场拒(理由喂回下一拍)。 * 所以归型放在写进草稿那一刻:归得了就归,归不了当场拒(理由喂回下一拍)。
*/ */
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { coerceToColumn, localStamp, missingReadings, rowReadings, rowKeyList, tallyReadings } from '../wiring/engines.ts'; import { coerceToColumn, localStamp, missingReadings, rowReadings, rowKeyList, tallyReadings, toolCallReadings } from '../wiring/engines.ts';
describe('空值 = 这一列没有值(不写)', () => { describe('空值 = 这一列没有值(不写)', () => {
it('空串 / 全空白 / null / undefined 一律不写 —— 「原文没写的不要补」的数据面落点', () => { it('空串 / 全空白 / null / undefined 一律不写 —— 「原文没写的不要补」的数据面落点', () => {
@@ -342,3 +342,42 @@ describe('按值现数:域_对象_列_值', () => {
).toBe(1); ).toBe(1);
}); });
}); });
/**
* **工具调用读数** —— 「机检只读基座数」这条纪律第一次覆盖到工具(2026-08-03)。
*
* 起因:A2 判稳偶发压根不调行情工具就把料判成「稳」转 A,而自报的那一列写得漂亮,
* 去向就此走岔(约十次一次)。工具出参本来就入痕,「调没调」是基座数得出来的事实。
*/
describe('工具调用读数', () => {
const declared = [{ name: 'crm.history-price-read' }, { name: 'mail.send' }];
it('声明过的工具一律先铺 0 —— 没调过与调了 0 次在业务上是同一件事', () => {
expect(toolCallReadings([], declared)).toEqual({
tool_crm_history_price_read_calls: 0,
tool_mail_send_calls: 0,
});
});
it('数成功返回的那几次,名字里的点号横杠一律换成下划线(S-FEEL 不认)', () => {
const trail = [
{ kind: 'result', title: 'crm.history-price-read 返回' },
{ kind: 'result', title: 'crm.history-price-read 返回(3 行)' },
{ kind: 'thought', title: 'crm.history-price-read 返回' },
];
expect(toolCallReadings(trail, declared).tool_crm_history_price_read_calls).toBe(2);
});
it('**调失败不算查过** —— 调不通等于没拿到行情,不该拿来当依据', () => {
const trail = [{ kind: 'result', title: 'crm.history-price-read 失败:端点不可达' }];
expect(toolCallReadings(trail, declared).tool_crm_history_price_read_calls).toBe(0);
});
it('没声明的工具不数 —— 声明面就是授权面,不在名单里的本来也数不着', () => {
const trail = [{ kind: 'result', title: 'erp.write 返回' }];
expect(Object.keys(toolCallReadings(trail, declared))).toEqual([
'tool_crm_history_price_read_calls',
'tool_mail_send_calls',
]);
});
});
@@ -19,7 +19,7 @@
* 抄一份的下场踩过 —— 生产改了、测没跟着改,测全绿而线上照旧。 * 抄一份的下场踩过 —— 生产改了、测没跟着改,测全绿而线上照旧。
*/ */
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { gateOf, rowChanged, writesOf } from '../routes/desk.ts'; import { gateAddressesOf, gateOf, rowChanged, writesOf } from '../routes/desk.ts';
import { missingReadings } from '../wiring/engines.ts'; import { missingReadings } from '../wiring/engines.ts';
/** A1 受理记录的声明(裁到这一段用得着的几列):PN 与年用量必填 */ /** A1 受理记录的声明(裁到这一段用得着的几列):PN 与年用量必填 */
@@ -103,6 +103,100 @@ describe('提交闸:必填没填就交不了', () => {
}); });
}); });
/*
* **行级必填 `requiredWhen`:必填钉在该轮到的那一行**(2026-08-02 用户:
* 「工作区的 HTML 必填项必须填写了才能提交啊,不填写能算任务完成了吗?」)。
*
* 逐颗按序派单(A2 的 sel_stage 队列)撞上表级 required 的死结:定案三格整列必填,
* 第一颗的卡会被第二颗(还没轮到、件上不可见)的空格拦死;整列不标,闸又没牙,
* 空手就能交卷(用户实测点名)。requiredWhen 只在行上指定列等于指定值时才算必填。
*/
describe('行级必填:requiredWhen 只数该轮到的那一行', () => {
const LIST = {
domain: 'quote_alternative',
object: 'list',
fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true },
{ name: 'pn', type: 'text', primaryKey: true },
{ name: 'alt_pn', type: 'text', primaryKey: false, label: '替代料 PN', requiredWhen: { column: 'sel_stage', value: 'NOW' } },
{ name: 'alt_grade', type: 'text', primaryKey: false, label: '替代等级', requiredWhen: { column: 'sel_stage', value: 'NOW' } },
],
};
const read = (rows: Array<Record<string, unknown>>) =>
missingReadings({ 'mine:quote_alternative.list': rows }, [LIST]);
it('**排队(WAIT)的颗不拦当前的卡** —— 只有 NOW 行的空格算缺', () => {
const got = read([
{ rfq_ref: 'R1', pn: 'A', sel_stage: 'NOW', alt_pn: '', alt_grade: '' },
{ rfq_ref: 'R1', pn: 'B', sel_stage: 'WAIT', alt_pn: '', alt_grade: '' },
]);
expect(got['quote_alternative_list_missing']).toBe(1);
expect(got['quote_alternative_list_missing_keys']).toBe('R1/A 缺替代料 PN、替代等级');
});
it('NOW 行定案齐了 → 缺 0,闸开;办结(DONE)的行不再数', () => {
const got = read([
{ rfq_ref: 'R1', pn: 'A', sel_stage: 'DONE', alt_pn: 'X', alt_grade: 'P2P' },
{ rfq_ref: 'R1', pn: 'B', sel_stage: 'NOW', alt_pn: 'Y', alt_grade: 'FUNC' },
]);
expect(got['quote_alternative_list_missing']).toBe(0);
});
it('**未果撤行之后 NOW 行不存在 → 缺 0** —— 撤行也是一个答案', () => {
expect(read([{ rfq_ref: 'R1', pn: 'B', sel_stage: 'WAIT', alt_pn: '', alt_grade: '' }])['quote_alternative_list_missing']).toBe(0);
});
it('不带 requiredWhen 的 required 照旧整列必填 —— 老声明一个字不用改', () => {
const plain = {
domain: 'd',
object: 'o',
fields: [
{ name: 'k', type: 'text', primaryKey: true },
{ name: 'v', type: 'text', primaryKey: false, label: '值', required: true },
],
};
expect(missingReadings({ 'mine:d.o': [{ k: '1', v: '' }] }, [plain])['d_o_missing']).toBe(1);
});
});
/*
* **执行卡零输入之后,闸看哪几张表**(2026-08-02 用户定:填写全去工作区)。
*
* 卡面不再绑 blocks,闸若只认卡上的表就整个失效 —— 缺着必填照样交得回来,
* 一路走到放行门才被 L1 拦住,那正是当初立这道闸要治的病。
* 回落断了页面上看不出来(按钮照常绿着),所以钉在这儿。
*/
describe('闸看哪几张表:卡上绑的优先,没绑回落到必交付物声明', () => {
const table = (ledger: string) => ({ t: 'table' as const, ledger, cols: [], rows: [], fields: [] });
it('卡上绑了表 → 看绑的那几张,交付物声明不掺和', () => {
expect(
gateAddressesOf([table('quote_intake.record')], [{ ledgers: ['a.b', 'c.d'] }]),
).toEqual(['quote_intake.record']);
});
it('**卡上没绑表(零输入执行卡)→ 回落到必交付物的 ledgers 全表**', () => {
expect(
gateAddressesOf(undefined, [{ ledger: 'quote_intake.record', ledgers: ['quote_intake.record', 'quote_intake.verdict'] }]),
).toEqual(['quote_intake.record', 'quote_intake.verdict']);
});
it('老清单只给 ledger 单张 → 退它那一张;两边都空才真的没有依据', () => {
expect(gateAddressesOf([], [{ ledger: 'quote_sourcing.quote_list' }])).toEqual(['quote_sourcing.quote_list']);
expect(gateAddressesOf([], [{}])).toEqual([]);
expect(gateAddressesOf(undefined, [])).toEqual([]);
});
it('非表块(文本段)不算绑表 —— 熔断卡带 basis 不带表,照样回落', () => {
expect(
gateAddressesOf(
[{ t: 'p', text: '拒绝理由' } as never],
[{ ledgers: ['quote_alternative.list', 'quote_alternative.verdict'] }],
),
).toEqual(['quote_alternative.list', 'quote_alternative.verdict']);
});
});
describe('只写真改了的那几行', () => { describe('只写真改了的那几行', () => {
const row = { rfq_ref: 'R1', pn: 'NE555P', eau: 30000, brand: 'TI' }; const row = { rfq_ref: 'R1', pn: 'NE555P', eau: 30000, brand: 'TI' };
+124 -76
View File
@@ -54,6 +54,14 @@ const ACTION_ITEM_SCHEMA = {
reason: { type: 'string' }, reason: { type: 'string' },
basis: { type: 'array' }, basis: { type: 'array' },
fields: { type: 'array' }, fields: { type: 'array' },
/*
* 作业指导书两件(2026-08-03 补进 schema):`steps` 一行一条 + `standard` 收口标准。
* 它们从前不在这份 schema 里,能到卡面纯靠厂商 JSON 模式宽松放行 ——
* 而本仓早有实测纪律:「结构化出参只吐 schema 里声明过的键,光在提示词里交代不算」
* (ai/ledger 那两个键当年就是这么丢的)。指导书是执行卡的正文,不能靠运气。
*/
steps: { type: 'array', items: { type: 'string' }, description: '作业指导书:一行一条,纯文本(不写星号记号)' },
standard: { type: 'string', description: '收口标准:办到什么程度算完,一句话' },
/* /*
* 卡面正文块 —— **人要在数据上作判断,数据就得摆在表单旁边**。 * 卡面正文块 —— **人要在数据上作判断,数据就得摆在表单旁边**。
* *
@@ -216,6 +224,12 @@ export type AgentRunnerDeps = {
readonly modelOf: (taskId: TaskId) => Promise<string>; readonly modelOf: (taskId: TaskId) => Promise<string>;
/** token 回报:计入任务预算 —— 不计,预算熔断就永远不会响 */ /** token 回报:计入任务预算 —— 不计,预算熔断就永远不会响 */
readonly onUsage: (taskId: TaskId, tokens: number) => Promise<void>; readonly onUsage: (taskId: TaskId, tokens: number) => Promise<void>;
/**
* 留一条内部痕(进工作日志,不进对话区)。输出不成形就地重试时用它把
* **模型原文**存档 —— 卡面不再摆 JSON 原话(人看着像乱码),原文归日志,
* 想尸检的时候有处可查。可选:不给就只重试不留痕。
*/
readonly note?: (taskId: TaskId, title: string, raw: string) => Promise<void>;
}; };
/** 可写台账摊成字典:地址 · 中文标 · 主键 · 列名 —— 模型照这个填,不照想象编 */ /** 可写台账摊成字典:地址 · 中文标 · 主键 · 列名 —— 模型照这个填,不照想象编 */
@@ -395,7 +409,7 @@ export function createAgentRunner(deps: AgentRunnerDeps): {
const prompt = [ const prompt = [
'你是这一单任务的案例工作者。下面是语义引擎发布的**当下可为**与**目标缺口**。', '你是这一单任务的案例工作者。下面是语义引擎发布的**当下可为**与**目标缺口**。',
'这一拍你给**一串行动**(actions 数组,1-6 个,按序执行):write(写台账草稿)/', '这一拍你给**一串行动**(actions 数组,1-6 个,按序执行):write(写台账草稿)/',
'tool(调工具)/ ask(补全卡)/ decide(决策卡)/ human(调人)/ listen(立监听),外加 start(启动须手动启动的项)、', 'tool(调工具)/ decide(决策卡)/ human(调人)/ listen(立监听),外加 start(启动一项,可省)、',
'plan(规划裁量项)、disable(此单不走这条)、judge(评估语义性前提)、finish(宣告一项完成)、submit(提交放行)。', 'plan(规划裁量项)、disable(此单不走这条)、judge(评估语义性前提)、finish(宣告一项完成)、submit(提交放行)。',
/* /*
* **写错了有退路**(2026-08-01 加 drop)。没有它的时候实测过一次死胡同: * **写错了有退路**(2026-08-01 加 drop)。没有它的时候实测过一次死胡同:
@@ -443,42 +457,42 @@ export function createAgentRunner(deps: AgentRunnerDeps): {
' 挂不上(项已完成等)写也照落 —— 写的授权在台账声明,不在计划项。', ' 挂不上(项已完成等)写也照落 —— 写的授权在台账声明,不在计划项。',
' tool / ask 的 `itemId` 则要落在**当下可为**里;阶段(stage)只能 start。', ' tool / ask 的 `itemId` 则要落在**当下可为**里;阶段(stage)只能 start。',
/* /*
* **话说的范围要盖住门拦的范围**(2026-08-02 实测逼出来的)。 * **「须你启动」不再是一堵墙**(2026-08-02 拆掉)。
* *
* 从前这一句写的是「标了「须你启动」的项要先 start **再调工具**」—— * 从前标了人工激活的项要求先发显式 `start`,不发就拒;还为「话没盖住门」
* 只说了调工具。而拦截那一侧(`readyToWork`)管的是 tool / ask / human / * 修过一轮措辞(话窄一格,模型正好漏在那一格上)。措辞修全之后实测仍熔断:
* decide / entry / finish 一整片。模型那一拍要发的是 `human`(派工单), * A1 一单连拒五拍,五次全是「S3_confirm 要先 start」—— 提示词与拒回都写着,
* 照字面读这条规矩管不着它,于是漏了 start,撞上 * 它就是学不稳。那道墙也保护不了任何东西:start 是模型自己的动作,
* 「S3_confirm 标了人工激活,要先 start 再干活」白烧一拍。 * 同一串连发即过。现在**动它就算启动**(引擎就地过 Active),
* * 「须你启动」只剩它本来的意思:引擎不替你启动、也不给 doneWhen 替人收。
* **门拦住的事和话说的事必须是同一件** —— 话窄一格,模型就正好漏在那一格上。
* 例外只有 write:它的 itemId 只是记账,挂不上照写不拦(见 turn-runner 的注释)。
*/ */
' 标了「须你启动」的项,**动它之前先 start** —— 调工具、发卡(ask / human / decide / entry)、', ' 标了「须你启动」的项,你**直接动它就算启动**(不必单发 start);',
' 宣告完成(finish)都算,一拍里 start 排在那个动作前面即可。', ' 它的分量在另一头:引擎不会替你启动它,也不会自动收它 —— 收它要你自己 finish。',
' 只有 write 例外:它的 itemId 只是记账,挂不上也照写。',
'② **系统能干的活不要交给人**:调工具就在「可调工具」里挑,照它的入参键名填 ——', '② **系统能干的活不要交给人**:调工具就在「可调工具」里挑,照它的入参键名填 ——',
' 把一件工具做得到的事发成一张卡问人,是白占人的时间(实测踩过:该调历史价工具,', ' 把一件工具做得到的事发成一张卡问人,是白占人的时间(实测踩过:该调历史价工具,',
' 却发了一张「请查这三个 PN 的历史价」的卡)。触达世界的动作也只能 tool', ' 却发了一张「请查这三个 PN 的历史价」的卡)。触达世界的动作也只能 tool',
' (写类会被登记为动作意向,放行那一刻才真执行)。', ' (写类会被登记为动作意向,放行那一刻才真执行)。',
'③ 调人(human)必须把卡填实:title 一句话说清要什么、ask 写明具体问题、',
' basis 给出判断依据(键值对数组)、fields 列出要对方回填的字段名。',
' 只写「请处理」的卡收卡人无从下手,等于白发一张。**人答回的值在轨迹里,',
' 看到了要用 write 落进台账** —— 系统不会替你写。',
'④ **缺字段要人补 → ask(补全卡)**:缺口清单摆在对话区、带一张表单,人就地填齐。',
' **能填的先替他填上**:格子给 `value` 预填(公司名、联系人姓名这类来件里写着的),',
' 人核对一眼比从头敲快得多;你填不出来的(如内部客户编码)留空,别硬凑。',
/* /*
* 形状里**必须带 `value`** —— 这一条实测栽过: * ③ 的措辞是一次实测教训(2026-08-02):零输入改造时这里曾写成
* 活动剧本让模型"能填的先替人填上",还给了带 value 的 JSON 骨架; * 「执行工单不摆表单……**别再 write 一遍**」—— 一句全局禁令,把 A1 说明书里
* 模型把骨架里的 ask 一字不差抄了过来,`value` 却全是空的 —— * 「人交回确认卡后要写 confirmed_total 记账」顶翻了:模型收到人的答复后
* 因为**系统提示词这一行写死的形状里没有它**,活动剧本盖不过系统提示词。 * 不写不收,又觉得这卡没答成,重发同一张卡连拒五拍熔断(用户实测送来的单)。
* 形状是模型真正照抄的那一份,漏一个键就等于那个键不存在。 * **全局提示词只说通用真理**:值怎么处理(哪些基座已替写、哪些要记账)
* 是各活动说明书的事,别在这儿下一刀切的禁令。
*/ */
' 形状:{kind:"ask", itemId, ask:"一句话说清缺口", fields:[{key,label,hint?,value?}], basis:[{k,v}]}。', '③ 调人(human)必须把卡填实:title 一句话说清要什么、ask 写明具体问题、',
' `value` = 预填值:来件里读得出的先替人填上(公司名、联系人姓名),读不出的留空。', ' basis 给出判断依据(键值对数组);执行工单不摆表单字段(填报在工作区的件上)。',
' 只写「请处理」的卡收卡人无从下手,等于白发一张。**人答回的东西在轨迹里,',
' 看到了要照说明书处理(该记账的记账)** —— 别当没答过再发一张。',
/*
* **补全卡(ask)已全局退役**(2026-08-02 用户定:「彻底全局退役补全卡,
* 执行卡完全就是包含补全语义的人类工具」)—— 缺信息也是发执行工单:
* 指导书写清缺什么,人在工作区的件上补(保存即落草稿层)。
* 旧输出里的 ask 会被基座原地转成执行工单,但别再用它。
*/
'④ **缺信息要人补 → human(执行工单)**:指导书写清缺哪几样、去哪儿补(工作区的件上),',
' 人补完保存、回卡提交 —— 值由基座写回草稿层,你别再 write 一遍;该记账的记账。',
'④·2 **要往台账写一行、而那一行该由人过目 → entry(数据录入卡)**:', '④·2 **要往台账写一行、而那一行该由人过目 → entry(数据录入卡)**:',
' 与补全卡的分别在**谁写库**:补全卡收回来的值要你自己再 write 一遍;',
' 数据录入卡是**人按下确认那一刻基座直接写**,你不经手 —— 建档这类事该用它,', ' 数据录入卡是**人按下确认那一刻基座直接写**,你不经手 —— 建档这类事该用它,',
' 主数据建错一次后面每单都跟着错,那一笔该由人亲手落下。', ' 主数据建错一次后面每单都跟着错,那一笔该由人亲手落下。',
' 形状:{kind:"entry", itemId, ask, ledger:"域.对象", entryKey:{主键列:值},', ' 形状:{kind:"entry", itemId, ask, ledger:"域.对象", entryKey:{主键列:值},',
@@ -489,8 +503,8 @@ export function createAgentRunner(deps: AgentRunnerDeps): {
' 机检判不了,只能你当场裁:{kind:"judge", criterion:"照判据名原样写", satisfied:true/false, rationale:"凭什么"}。', ' 机检判不了,只能你当场裁:{kind:"judge", criterion:"照判据名原样写", satisfied:true/false, rationale:"凭什么"}。',
' **提交放行前必须裁完** —— 除「人放行」外还有判据没达标时,submit 会被当场拒。', ' **提交放行前必须裁完** —— 除「人放行」外还有判据没达标时,submit 会被当场拒。',
'④\' **岔路口要人定 → decide(决策卡)**:把几条路摆出来,人点一个 —— 不用敲字。', '④\' **岔路口要人定 → decide(决策卡)**:把几条路摆出来,人点一个 —— 不用敲字。',
' 补全卡问**事实**(缺哪几个字段,你填);决策卡问**处置**(往哪走,你选)。', ' 执行工单问**事实与作业**(办完、补齐);决策卡问**处置**(往哪走,你选)。',
' 两条路都合法、选哪条是业务判断时,发这一张;别拿补全卡去问「你想怎么办」。', ' 两条路都合法、选哪条是业务判断时,发这一张;别拿执行工单去问「你想怎么办」。',
' 形状:{kind:"decide", itemId, ask, options:[{value,label,consequence?,recommended?}], basis:[{k,v}]}。', ' 形状:{kind:"decide", itemId, ask, options:[{value,label,consequence?,recommended?}], basis:[{k,v}]}。',
' `fields` 一个都不给会被拒 —— 空表单等于没问;一个缺口一个格子,label 写人话。', ' `fields` 一个都不给会被拒 —— 空表单等于没问;一个缺口一个格子,label 写人话。',
' 不要拿执行工单(human)去问几个字段 —— 那是「派一件活」,收卡的人得进待处理、', ' 不要拿执行工单(human)去问几个字段 —— 那是「派一件活」,收卡的人得进待处理、',
@@ -564,27 +578,44 @@ export function createAgentRunner(deps: AgentRunnerDeps): {
const model = await deps.modelOf(taskId); const model = await deps.modelOf(taskId);
try { try {
const result = await deps.gateway.generate({ /*
model, * **输出不成形先就地重试,别一抖就叫人**(2026-08-03 用户实测送来的单)。
prompt, *
outputSchema: TURN_SCHEMA, * 关思考的模型偶发把这一拍的输出写坏(多个闭括号那种已由括号栈自修;
// 规划型出参要的是稳,不是花样 —— 低温少绕弯,解码就是墙上时钟 * 这次是另一形状)。从前一次没认出就直接发「需要你判断下一步」的兜底卡,
temperature: 0.2, * 还把模型的 JSON 原话整段摆在卡面上 —— 人打开工单看到的是一坨代码
/* * (用户原话:「卡片里又是乱七八糟的东西」)。
* 关思考 —— 三档都实测过(A1 同一单): * 现在:第一次没认出 → 原文存进工作日志(内部痕,留档好尸检),
* 由着想:拍 9-56s,最慢拍 9.8k 字隐藏思考,八成五的时间在解码没人读的字; * 附一句「只输出 JSON」就地再问一次;第二次仍不成形才交人,
* 'low':这家厂商上没用 —— 推理仍 5-9.7k 字、拍 24-45s,和不压一样; * 而卡面只说人话(原文在日志里,不上卡)。
* 'off':拍 2-4s(十倍差)。 */
* off 暴露过一次业务误判(把缺目标价当关键字段拦单)—— 治法是把规则写死进 let hint = '';
* 说明书(「关键字段只有 PN/EAU/交期 三个」),不是给模型留思考额度: for (let attempt = 1; ; attempt += 1) {
* 这个架构里判断本就归判定表与说明书,模型只做编排。 const result = await deps.gateway.generate({
*/ model,
reasoning: 'off', prompt: hint === '' ? prompt : `${prompt}\n\n${hint}`,
taskId, outputSchema: TURN_SCHEMA,
label: 'loop-turn', // 规划型出参要的是稳,不是花样 —— 低温少绕弯,解码就是墙上时钟
}); temperature: 0.2,
await deps.onUsage(taskId, result.usage.inputTokens + result.usage.outputTokens); /*
return normalizeTurn(result.object, enabled, result.text); * 关思考 —— 三档都实测过(A1 同一单):
* 由着想:拍 9-56s,最慢拍 9.8k 字隐藏思考,八成五的时间在解码没人读的字;
* 'low':这家厂商上没用 —— 推理仍 5-9.7k 字、拍 24-45s,和不压一样;
* 'off':拍 2-4s(十倍差)。
* off 暴露过一次业务误判(把缺目标价当关键字段拦单)—— 治法是把规则写死进
* 说明书(「关键字段只有 PN/EAU/交期 三个」),不是给模型留思考额度:
* 这个架构里判断本就归判定表与说明书,模型只做编排。
*/
reasoning: 'off',
taskId,
label: 'loop-turn',
});
await deps.onUsage(taskId, result.usage.inputTokens + result.usage.outputTokens);
const turn = normalizeTurn(result.object, enabled);
if (!turn.unrecognized || attempt >= 2) return turn;
await deps.note?.(taskId, '这一拍的输出没成形(不是合法的行动串),就地重问一次', (result.text ?? '').slice(0, 2000));
hint = '【重要】你上一次的输出不是合法的行动 JSON,整拍作废了。只输出一个 JSON 对象,形如 {"say":"…","actions":[{"kind":"…",…}]},不要输出任何 JSON 之外的文字。';
}
} catch (error) { } catch (error) {
/* /*
* 模型这一轮问不出结果(端点不可达、鉴权失败、超时…)。 * 模型这一轮问不出结果(端点不可达、鉴权失败、超时…)。
@@ -631,13 +662,14 @@ const KNOWN_KINDS = new Set([
* *
* 三种来路都认:`{actions:[…]}`(规范形)、裸行动对象(旧形 —— 包成单元素串)、 * 三种来路都认:`{actions:[…]}`(规范形)、裸行动对象(旧形 —— 包成单元素串)、
* 顶层就是数组。串里**不认识的项丢掉**(认识不认识由 KNOWN_KINDS 定); * 顶层就是数组。串里**不认识的项丢掉**(认识不认识由 KNOWN_KINDS 定);
* 整串一个都不认识才退化为单张「转人裁断」卡(把模型原话摆上去)—— * 整串一个都不认识才标 `unrecognized` 退化为单张「转人裁断」卡 ——
* 不能让串里一个写坏的项在批中途给人甩一张卡。 * 不能让串里一个写坏的项在批中途给人甩一张卡。
* 调用方(decide)看到 `unrecognized` 先就地重问一次,原文进工作日志;卡面不摆 JSON。
* *
* 交代**不是一型行动**:一开始把它做成 `kind: 'say'`,结果它占一整拍又不推进局面, * 交代**不是一型行动**:一开始把它做成 `kind: 'say'`,结果它占一整拍又不推进局面,
* 预算下的模型理所当然跳过它 —— 对话区照旧空白(实测)。所以它是拍级的附带字段。 * 预算下的模型理所当然跳过它 —— 对话区照旧空白(实测)。所以它是拍级的附带字段。
*/ */
export function normalizeTurn(object: unknown, enabled: EnabledSet, rawText?: string): AgentTurn { export function normalizeTurn(object: unknown, enabled: EnabledSet): AgentTurn & { unrecognized?: true } {
const raw = (Array.isArray(object) ? { actions: object } : (object ?? {})) as Record<string, unknown>; const raw = (Array.isArray(object) ? { actions: object } : (object ?? {})) as Record<string, unknown>;
const text = String(raw['say'] ?? raw['text'] ?? '').trim(); const text = String(raw['say'] ?? raw['text'] ?? '').trim();
const tag = raw['tag']; const tag = raw['tag'];
@@ -649,11 +681,12 @@ export function normalizeTurn(object: unknown, enabled: EnabledSet, rawText?: st
.filter((a) => KNOWN_KINDS.has(String(a['kind'] ?? a['action'] ?? ''))); .filter((a) => KNOWN_KINDS.has(String(a['kind'] ?? a['action'] ?? '')));
const actions = const actions =
recognized.length > 0 recognized.length > 0
? recognized.slice(0, TURN_ACTION_CAP).map((a) => normalizeAction(a, enabled, rawText)) ? recognized.slice(0, TURN_ACTION_CAP).map((a) => normalizeAction(a, enabled))
: [normalizeAction(source[0] ?? {}, enabled, rawText)]; : [normalizeAction(source[0] ?? {}, enabled)];
return { return {
actions, actions,
...(recognized.length === 0 ? { unrecognized: true as const } : {}),
...(text === '' ...(text === ''
? {} ? {}
: { : {
@@ -842,7 +875,7 @@ function normalizeRows(raw: unknown): Array<{ key: Record<string, string | numbe
* 模型出参归一为 AgentAction —— 导出是为了让「宽进到什么程度」有测试守着。 * 模型出参归一为 AgentAction —— 导出是为了让「宽进到什么程度」有测试守着。
* 宽进的每一条都对应一次实测踩坑;不合规的行动仍由引擎拒,这里不放行为。 * 宽进的每一条都对应一次实测踩坑;不合规的行动仍由引擎拒,这里不放行为。
*/ */
export function normalizeAction(object: unknown, enabled: EnabledSet, rawText?: string): AgentAction { export function normalizeAction(object: unknown, enabled: EnabledSet): AgentAction {
const raw = (object ?? {}) as Record<string, unknown>; const raw = (object ?? {}) as Record<string, unknown>;
/* /*
* `kind` 也认 `action` —— 实测里模型把这一格叫 `action`(其余字段一字不差全对), * `kind` 也认 `action` —— 实测里模型把这一格叫 `action`(其余字段一字不差全对),
@@ -892,26 +925,41 @@ export function normalizeAction(object: unknown, enabled: EnabledSet, rawText?:
}; };
case 'ask': { case 'ask': {
/* /*
* 格子与问题**两处都认**:`fields` / `ask` 在顶层,或落在 `card` 里。 * **补全卡已全局退役**(2026-08-02 用户定:「彻底全局退役补全卡,
* 提示词里执行工单的写法是 `card.fields`,模型很自然把补全卡也那么写 —— * 执行卡完全就是包含补全语义的人类工具」)—— 「问人补信息」不再是
* 实测它就是这么写的,于是每一拍都被「fields 为空」拒回来。 * 一种单独的卡:发一张零输入的执行工单,指导书写清缺什么、
* 宽进不是纵容:空不空引擎照验,这里只是不让一个位置差异吃掉一整拍。 * 人在工作区的件上补(保存即落草稿层)或办完确认。
*
* 这里做的是**归一而不是拒**:旧说明书、旧模型输出里的 `ask` 照收,
* 原地转成执行工单 —— 拒回只会让模型换着花样再发一遍。
* `fields` 不上卡(执行卡零输入),但字段名并进指导书,
* 人知道要补的是哪几样、补在件上。
*/ */
const nested = (typeof raw['card'] === 'object' && raw['card'] !== null ? raw['card'] : {}) as Record<string, unknown>; const nested = (typeof raw['card'] === 'object' && raw['card'] !== null ? raw['card'] : {}) as Record<string, unknown>;
const asked = String(raw['ask'] ?? nested['ask'] ?? '').trim();
const wanted = normalizeFields(raw['fields'] ?? nested['fields'])
.map((f) => ((f.label ?? '') === '' ? f.key : (f.label ?? '')))
.filter((x) => x !== '');
const basis = Array.isArray(raw['basis'])
? raw['basis']
.filter((b): b is { k: unknown; v: unknown } => typeof b === 'object' && b !== null && 'k' in b)
.map((b) => ({ k: String(b.k), v: String(b.v) }))
: [];
return { return {
kind: 'ask', kind: 'human',
itemId, itemId,
ask: String(raw['ask'] ?? nested['ask'] ?? ''), workOrder: 'execution',
fields: normalizeFields(raw['fields'] ?? nested['fields']), card: {
...(Array.isArray(raw['basis']) title: asked === '' ? '请补齐这一单缺的信息' : asked.slice(0, 60),
? { ask: asked === '' ? '缺的信息见下面的依据:补在工作区的件上,补完回这张卡提交。' : asked,
basis: raw['basis'] ...(wanted.length === 0
.filter((b): b is { k: unknown; v: unknown } => typeof b === 'object' && b !== null && 'k' in b) ? {}
.map((b) => ({ k: String(b.k), v: String(b.v) })), : { steps: [`要补的是:${wanted.join('、')} —— 在工作区的件上就地填,保存后回这张卡提交;确实拿不到的,备注里写明缘由`] }),
} basis,
: {}), },
}; };
} }
case 'entry': { case 'entry': {
/* /*
* 数据录入卡 —— 与补全卡同款的宽进(顶层或 `card` 里都认)。 * 数据录入卡 —— 与补全卡同款的宽进(顶层或 `card` 里都认)。
@@ -1021,19 +1069,19 @@ export function normalizeAction(object: unknown, enabled: EnabledSet, rawText?:
/* /*
* 看不懂就等人,不擅自触达世界。 * 看不懂就等人,不擅自触达世界。
* *
* 但**要把模型的原话摆在卡上**:只写「模型未给出合规的行动」,收卡的人无从判断 * 卡面**只说人话,不摆模型的 JSON 原话**(2026-08-03 用户实测:原话上卡,
* 是模型跑偏了、还是行动型少了一种、还是提示词没交代清楚 —— 而这三种的处置完全不同。 * 人打开工单看到的是一坨代码 ——「卡片里又是乱七八糟的东西」)。
* 界面不撒谎的另一半是:不确定的时候,把手上的原始证据交出去。 * 原始证据仍然要留:decide 那一侧在重问之前已把原文存进工作日志(内部痕),
* 想分辨是模型跑偏、行动型少了一种、还是提示词没交代清楚,去日志里看。
*/ */
return { return {
kind: 'human', kind: 'human',
itemId, itemId,
card: { card: {
title: '需要你判断下一步', title: '需要你判断下一步',
reason: '模型未给出合规的行动', reason: '模型这一拍没给出合规的行动,就地重问一次仍未成形;它的原话在这一单的工作日志里,不摆到卡上',
modelReply: rawText === undefined || rawText === '' ? '(空)' : rawText.slice(0, 1200),
kindSeen: kind === '' ? '(未给 kind)' : kind, kindSeen: kind === '' ? '(未给 kind)' : kind,
ask: '请判断下一步:接着走哪一项,或补齐缺口后让环继续。', ask: '请判断下一步:接着走哪一项,或直接办完当前这一步让环继续。',
fields: ['decision'], fields: ['decision'],
}, },
workOrder: 'execution', workOrder: 'execution',
+44 -8
View File
@@ -497,8 +497,11 @@ async function applyBoundTableEdits(
* (都走 `missingReadings`)。同源是要紧的:两处各算各的,就会出现 * (都走 `missingReadings`)。同源是要紧的:两处各算各的,就会出现
* 「卡上说能交、放行门说不行」——那种矛盾人是判不出对错的。 * 「卡上说能交、放行门说不行」——那种矛盾人是判不出对错的。
* *
* 只对**绑了台账表**的执行卡出闸:算得出才拦,算不出(卡上没绑表)就不给闸, * 看哪几张表:卡上绑了表就看绑的那几张;**卡上没绑表就回落到必交付物的台账声明**
* 按钮照常能按 —— **没有依据的时候不拦人**。 * (`requiredDeliverables` 的 `ledgers`/`ledger`)—— 执行卡零输入之后(2026-08-02
* 用户定:「填写全去工作区」)卡面不再绑 blocks,闸若只认卡上的表就整个失效,
* 缺着必填照样交得回来,一路走到放行门才被 L1 拦住(那正是当初立这道闸要治的病)。
* 两条路都算不出(连交付物都没声明台账)才不给闸 —— **没有依据的时候不拦人**。
*/ */
async function submitGate( async function submitGate(
container: Container, container: Container,
@@ -506,13 +509,13 @@ async function submitGate(
order: WorkOrder, order: WorkOrder,
): Promise<{ ready: boolean; why: string; blanks: string[] } | undefined> { ): Promise<{ ready: boolean; why: string; blanks: string[] } | undefined> {
if (order.mode !== 'execution' || order.done !== undefined) return undefined; if (order.mode !== 'execution' || order.done !== undefined) return undefined;
const addresses = (order.blocks ?? []) const task = await container.task.get(taskId).catch(() => undefined);
.filter((b): b is Extract<ViewBlock, { t: 'table' }> => b.t === 'table' && typeof b.ledger === 'string') if (task === undefined) return undefined;
.map((b) => b.ledger as string); const manifest = await container.registry.manifestOf(task.activityId).catch(() => undefined);
if (manifest === undefined) return undefined;
const addresses = gateAddressesOf(order.blocks, manifest.contract.requiredDeliverables);
if (addresses.length === 0) return undefined; if (addresses.length === 0) return undefined;
const declarations = await declarationsOf(container, taskId); const watched = manifest.declarations.ledger.filter((l) => addresses.includes(`${l.domain}.${l.object}`));
if (declarations === undefined) return undefined;
const watched = declarations.filter((l) => addresses.includes(`${l.domain}.${l.object}`));
if (watched.length === 0) return undefined; if (watched.length === 0) return undefined;
// 本单写下的行 —— 与任务视图同一约定(`mine:域.对象`) // 本单写下的行 —— 与任务视图同一约定(`mine:域.对象`)
@@ -523,6 +526,23 @@ async function submitGate(
return gateOf(missingReadings(mine, watched), watched); return gateOf(missingReadings(mine, watched), watched);
} }
/**
* 闸看哪几张表:卡上绑了表就看绑的那几张;没绑(执行卡零输入之后是常态)
* 回落到**必交付物的台账声明**(`ledgers` 全表,老清单退 `ledger` 单张)。
* 两边都空才真的没有依据。抽成纯函数是为了钉测 —— 回落断了闸就整个失效,
* 而失效的闸在页面上看不出来(按钮照常绿着)。
*/
export function gateAddressesOf(
blocks: ReadonlyArray<ViewBlock> | undefined,
deliverables: ReadonlyArray<{ ledger?: string | undefined; ledgers?: ReadonlyArray<string> | undefined }>,
): string[] {
const bound = (blocks ?? [])
.filter((b): b is Extract<ViewBlock, { t: 'table' }> => b.t === 'table' && typeof b.ledger === 'string')
.map((b) => b.ledger as string);
if (bound.length > 0) return bound;
return deliverables.flatMap((d) => [...(d.ledgers ?? (d.ledger === undefined ? [] : [d.ledger]))]);
}
/** /**
* 读数 → 闸。**数与名单都来自基座的读数**,这里只负责说人话。 * 读数 → 闸。**数与名单都来自基座的读数**,这里只负责说人话。
* *
@@ -913,6 +933,22 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
} }
} }
const outcome = await container.humanTool.replyWorkOrder(parsed.data, staff.id); const outcome = await container.humanTool.replyWorkOrder(parsed.data, staff.id);
/*
* **办结那一刻,把当时的件原样存进这张工单**(2026-08-02 用户:「HTML UI、
* 数据、内容、布局都要 1:1 地从待处理转换为静态报告放到工作成果」)。
* 成果页渲的是这份快照 —— 不再渲「当下」的任务视图(随后续推进会变),
* 更不退回卡壳。快照失败不拦办结(留痕即可,人已交卷)。
*/
try {
const shot = await container.interaction.viewOf(outcome.taskId);
if (shot?.html !== undefined) {
await container.db.execute(
sql`update sys.work_order set payload = jsonb_set(payload, '{finalHtml}', ${JSON.stringify(shot.html)}::jsonb) where id = ${parsed.data.workOrderId}`,
);
}
} catch {
/* 件还没推过就没有快照 —— 成果页会退回旧路,不拦人交卷 */
}
/* /*
* **绑台账的可编辑表:人改了什么,基座当场写回草稿层**(2026-08-01)。 * **绑台账的可编辑表:人改了什么,基座当场写回草稿层**(2026-08-01)。
* *
+81 -6
View File
@@ -246,6 +246,7 @@ export function missingReadings(
primaryKey: boolean; primaryKey: boolean;
required?: boolean | undefined; required?: boolean | undefined;
expect?: boolean | undefined; expect?: boolean | undefined;
requiredWhen?: { column: string; value: string } | undefined;
}>; }>;
}>, }>,
): Record<string, string | number> { ): Record<string, string | number> {
@@ -260,17 +261,23 @@ export function missingReadings(
* · `required` → `_missing` 缺必填几行(**不齐不许放行**,是验收标准); * · `required` → `_missing` 缺必填几行(**不齐不许放行**,是验收标准);
* · `expect` → `_gaps` 缺选填几行(不拦单,但**那一问不能省**)。 * · `expect` → `_gaps` 缺选填几行(不拦单,但**那一问不能省**)。
* 缺口摆的是**中文列名**:这两个数会进拒回与卡面,给人看的地方不摆列名。 * 缺口摆的是**中文列名**:这两个数会进拒回与卡面,给人看的地方不摆列名。
*
* `requiredWhen` 把必填钉在**该轮到的那一行**(2026-08-02,逐颗派单的闸):
* 行上那一列不等于指定值,这一格就还没轮到,不算缺 —— 排队的颗不拦当前的卡。
*/ */
for (const [mark, suffix] of [ for (const [mark, suffix] of [
['required', 'missing'], ['required', 'missing'],
['expect', 'gaps'], ['expect', 'gaps'],
] as const) { ] as const) {
const watched = fields.filter((f) => f[mark] === true); const watched = fields.filter((f) => f[mark] === true || (mark === 'required' && f.requiredWhen !== undefined));
if (watched.length === 0) continue; if (watched.length === 0) continue;
const bad: string[] = []; const bad: string[] = [];
for (const row of rows) { for (const row of rows) {
const one = (typeof row === 'object' && row !== null ? row : {}) as Record<string, unknown>; const one = (typeof row === 'object' && row !== null ? row : {}) as Record<string, unknown>;
const gaps = watched.filter((f) => blank(one, f.name)); const due = watched.filter(
(f) => f.requiredWhen === undefined || String(one[f.requiredWhen.column] ?? '') === f.requiredWhen.value,
);
const gaps = due.filter((f) => blank(one, f.name));
if (gaps.length === 0) continue; if (gaps.length === 0) continue;
const id: Record<string, string> = {}; const id: Record<string, string> = {};
for (const k of keys) if (!blank(one, k)) id[k] = String(one[k]); for (const k of keys) if (!blank(one, k)) id[k] = String(one[k]);
@@ -323,6 +330,40 @@ export function tallyReadings(
return out; return out;
} }
/**
* **这一单到底调没调那个工具** —— `{ 'tool_crm_history_price_read_calls': N }`。
*
* 「机检只读基座数」这条纪律此前只覆盖到台账(`_rows` / `_missing` / tally),
* **覆盖不到工具**:一条判据若要说「转 A 的依据来自本单亲查的行情」,
* 从前只能读活动自报的列 —— 而模型偶发压根没调工具就把料判成「行情稳」,
* 自报的那一列照样写得漂亮(A2 实测约十次一次,去向就此走岔)。
* 说明书已经写到头(「这一拍亲自查 alt_pn」「不许拿上游的数顶替」),
* **措辞治不了的结构治**(设计律第六条)。
*
* 工具出参本来就入痕(`turn-runner` 每次调完记一条 `kind:'result'`、
* 标题 `${tool} 返回`),所以「调没调」是**基座数得出来的事实**,不必让活动自报。
*
* 只数**成功返回**的那几次:调失败等于没查到行情,不该拿来当依据。
* 名字取 `tool_<工具名去符号>_calls`(S-FEEL 不认点号与横杠);
* 声明过的工具**一律先铺 0** —— 与 `_rows` 同一个理由:未绑定的名字参与比较求值为假,
* 「没调过」与「调了 0 次」在业务上是同一件事,读数上也得是。
*/
export function toolCallReadings(
trail: ReadonlyArray<{ readonly kind: string; readonly title: string }>,
declared: ReadonlyArray<{ readonly name: string }>,
): Record<string, number> {
const key = (tool: string): string => `tool_${tool.replace(/[^0-9a-zA-Z]+/g, '_')}_calls`;
const out: Record<string, number> = {};
for (const { name } of declared) out[key(name)] = 0;
for (const entry of trail) {
if (entry.kind !== 'result') continue;
for (const { name } of declared) {
if (entry.title.startsWith(`${name} 返回`)) out[key(name)] = (out[key(name)] ?? 0) + 1;
}
}
return out;
}
export function rowKeyList( export function rowKeyList(
mine: Readonly<Record<string, readonly unknown[]>>, mine: Readonly<Record<string, readonly unknown[]>>,
declared: ReadonlyArray<{ domain: string; object: string; fields: ReadonlyArray<{ name: string; primaryKey: boolean }> }>, declared: ReadonlyArray<{ domain: string; object: string; fields: ReadonlyArray<{ name: string; primaryKey: boolean }> }>,
@@ -752,8 +793,19 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
* 而判据要写成 `quote_intake_record_rows = line_total` 这样一句才有用。 * 而判据要写成 `quote_intake_record_rows = line_total` 这样一句才有用。
* 只数**本单自己写下的行**(草稿层),别单放行过的记录不算在内。 * 只数**本单自己写下的行**(草稿层),别单放行过的记录不算在内。
*/ */
const declared = (await deps.registry.manifestOf(task.activityId)).declarations.ledger; const manifest = await deps.registry.manifestOf(task.activityId);
return { ...scope, ...rowReadings(mine, declared), ...rowKeyList(mine, declared), ...missingReadings(mine, declared), ...tallyReadings(mine, declared), ...mine }; const declared = manifest.declarations.ledger;
// 工具调用是**留痕里的事实**:判据要说「亲查过行情」,得有一边不是活动自报的
const trail = await deps.memory.window(taskId, 400);
return {
...scope,
...rowReadings(mine, declared),
...rowKeyList(mine, declared),
...missingReadings(mine, declared),
...tallyReadings(mine, declared),
...toolCallReadings(trail, manifest.declarations.tools),
...mine,
};
}, },
/** /**
* 本活动声明过的台账对象 —— 清单 `declarations.ledger` 就是这份名单。 * 本活动声明过的台账对象 —— 清单 `declarations.ledger` 就是这份名单。
@@ -1447,7 +1499,13 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
await cardSpan?.end({ output: { cardId: submitted.workOrderId, precheck: submitted.report.verified.length } }); await cardSpan?.end({ output: { cardId: submitted.workOrderId, precheck: submitted.report.verified.length } });
return { id: submitted.workOrderId }; return { id: submitted.workOrderId };
} }
const fields = declaredFields ?? [{ key: 'result', label: '结果', kind: 'textarea' as const, required: true }]; /*
* **执行卡不带表单字段**(2026-08-02 用户定:「填写全去工作区」)——
* 填报的地方是工作区那份件(editGate + 孪生件 + 增删行),卡上只剩
* 指导书、依据与提交钮。从前这里兜底一格必填「结果」textarea:
* 活动没声明字段就替它造一个,人对着一张没处下笔的卡还得编一句话才按得下提交。
*/
const fields = declaredFields ?? [];
/* /*
* 卡面正文块 —— **人要在数据上作判断,数据就得摆在表单旁边**。 * 卡面正文块 —— **人要在数据上作判断,数据就得摆在表单旁边**。
* *
@@ -1859,7 +1917,24 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
}, },
}, },
}) })
: createAgentRunner({ gateway: tracedGateway, onUsage: onAgentUsage, modelOf }); : createAgentRunner({
gateway: tracedGateway,
onUsage: onAgentUsage,
modelOf,
/*
* 输出不成形就地重问时,把模型原文存进工作日志(内部痕)——
* 卡面从此不摆 JSON 原话,尸检去日志里看。stamp 与 memoryRecorder 同款兜底。
*/
note: async (taskId, title, raw) => {
await deps.memory.append({
taskId,
kind: 'result',
title,
detail: { internal: true, raw },
stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] },
});
},
});
/* ── loop-engine ──────────────────────────────────────────────── */ /* ── loop-engine ──────────────────────────────────────────────── */
const loop = createTurnRunner({ const loop = createTurnRunner({
@@ -269,3 +269,29 @@ describe('行数读数认得出,写错的照旧拒装', () => {
expect(codes(manifest({ predicate: 'd_recrod_rows = line_total' }))).toContain('acceptance-predicate-unbound'); expect(codes(manifest({ predicate: 'd_recrod_rows = line_total' }))).toContain('acceptance-predicate-unbound');
}); });
}); });
/**
* **工具调用读数也算绑得上**(2026-08-03)——
* 基座按留痕数「这一单成功调了几次某工具」(`tool_<工具名去符号>_calls`),
* 声明过的工具一律先铺 0。判据的两边不能都由活动自报:A2 实测约十次一次,
* 判稳那一步压根没调行情工具就把料判成「稳」转 A,自报的依据列却写得漂亮。
*/
describe('工具调用读数绑得上', () => {
const withTool = (predicate: string) =>
acceptanceLevelFindings({
contract: {
acceptanceCriteria: [{ key: 'k', label: 'l', level: 'L2', predicate }],
input: { data: { type: 'object', properties: {} } },
},
declarations: { ledger: [], tools: [{ name: 'crm.history-price-read' }] },
assets: { businessModel: { cmmnXml: '' } },
} as never);
it('声明过的工具 → `tool_..._calls` 绑得上', () => {
expect(withTool('tool_crm_history_price_read_calls > 0')).toEqual([]);
});
it('没声明的工具照旧拒装 —— 声明面就是授权面,写错一个字当场拦下', () => {
expect(withTool('tool_erp_write_calls > 0').length).toBeGreaterThan(0);
});
});
@@ -37,7 +37,9 @@ export type AcceptanceAuditInput = {
readonly contract: Pick<ActivityManifest['contract'], 'acceptanceCriteria'> & { readonly contract: Pick<ActivityManifest['contract'], 'acceptanceCriteria'> & {
readonly input: Pick<ActivityManifest['contract']['input'], 'data'>; readonly input: Pick<ActivityManifest['contract']['input'], 'data'>;
}; };
readonly declarations: Pick<ActivityManifest['declarations'], 'ledger'>; // tools 也要:判据能读「这一单调了几次某工具」(`tool_..._calls`,基座按留痕数)
readonly declarations: Pick<ActivityManifest['declarations'], 'ledger'> &
Partial<Pick<ActivityManifest['declarations'], 'tools'>>;
readonly assets: { readonly assets: {
readonly businessModel: Pick<ActivityManifest['assets']['businessModel'], 'cmmnXml'>; readonly businessModel: Pick<ActivityManifest['assets']['businessModel'], 'cmmnXml'>;
}; };
@@ -290,6 +292,19 @@ function boundNames(manifest: AcceptanceAuditInput): ReadonlySet<string> {
} }
} }
} }
/*
* **工具调用读数** `tool_<工具名去符号>_calls`(2026-08-03 起)——
* 基座按留痕数「这一单成功调了几次某工具」,声明过的工具一律先铺 0。
*
* 它存在的理由与 `_rows` 一模一样:**判据的两边不能都由活动自报**。
* A2 实测约十次一次 —— 判稳那一步压根没调行情工具就把料判成「稳」转 A,
* 而自报的依据列写得漂漂亮亮,去向就此从寻源走成定价。
* 有了这个名字,「转 A 的依据来自本单亲查的行情」才是一条真机检。
*/
// 旧调用方(与老测试)只传 ledger —— 缺 tools 就当没声明工具,别掀翻整份审计
for (const tool of manifest.declarations.tools ?? []) {
out.add(`tool_${tool.name.replace(/[^0-9a-zA-Z]+/g, '_')}_calls`);
}
for (const id of manifest.assets.businessModel.cmmnXml.match(/caseFileItem\s+id="([^"]+)"/g) ?? []) { for (const id of manifest.assets.businessModel.cmmnXml.match(/caseFileItem\s+id="([^"]+)"/g) ?? []) {
const m = /id="([^"]+)"/.exec(id); const m = /id="([^"]+)"/.exec(id);
if (m?.[1] !== undefined) out.add(m[1]); if (m?.[1] !== undefined) out.add(m[1]);
@@ -68,8 +68,9 @@ describe('工单形态机检', () => {
it('规范的执行工单通过', () => { it('规范的执行工单通过', () => {
expect(checkWorkOrder(execution())).toEqual([]); expect(checkWorkOrder(execution())).toEqual([]);
}); });
it('执行工单 = 指导书 + 空表单,表单不得为空', () => { it('**执行工单空表单是常态** —— 填写全在工作区的件上,零字段照发(2026-08-02 用户定)', () => {
expect(checkWorkOrder(execution({ fields: [] })).map((v) => v.code)).toContain('fields-missing'); // 从前这儿拒发空表单;通道换到件上之后,那道拒发让活动照新规矩发卡被基座连拒到熔断(实测)
expect(checkWorkOrder(execution({ fields: [] }))).toEqual([]);
}); });
it('radio 字段缺选项即拒', () => { it('radio 字段缺选项即拒', () => {
const v = checkWorkOrder(execution({ fields: [{ key: 'x', label: 'X', kind: 'radio' }] })); const v = checkWorkOrder(execution({ fields: [{ key: 'x', label: 'X', kind: 'radio' }] }));
@@ -43,9 +43,14 @@ export function checkWorkOrder(order: WorkOrder): CardViolation[] {
function checkExecution(order: ExecutionWorkOrder): CardViolation[] { function checkExecution(order: ExecutionWorkOrder): CardViolation[] {
const out: CardViolation[] = []; const out: CardViolation[] = [];
if (order.ask.trim() === '') out.push({ code: 'ask-missing', message: '执行工单必须给出工作要求' }); if (order.ask.trim() === '') out.push({ code: 'ask-missing', message: '执行工单必须给出工作要求' });
if (order.fields.length === 0) { /*
out.push({ code: 'fields-missing', message: '执行工单 = 指导书 + 空表单,表单不得为空' }); * **执行卡零输入**(2026-08-02 用户定:「执行卡片不要有任何填写的了,填写全去
} * 工作区」)—— 空表单是常态:填报在工作区的件上(editGate + 孪生件),
* 卡上只剩指导书、依据与提交钮。从前这里拒发空表单(「执行工单 = 指导书 +
* 空表单,表单不得为空」),那一版把表单当交付通道;通道换到件上之后,
* 这道拒发就成了活动照新规矩发卡、基座按旧规矩连拒到熔断(实测 A1 五拍)。
* 带字段的卡(如熔断卡的 decision)照旧合法 —— 只是不再强制。
*/
out.push(...checkFieldKeys(order.fields)); out.push(...checkFieldKeys(order.fields));
return out; return out;
} }
@@ -199,18 +199,48 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
const values = reply.mode === 'acceptance' ? reply.values : reply.values; const values = reply.mode === 'acceptance' ? reply.values : reply.values;
await deps.cards.markWorkOrderDone(reply.workOrderId, { at, by, result, values }); await deps.cards.markWorkOrderDone(reply.workOrderId, { at, by, result, values });
/*
* 出口:**算出来的优先,而且在服务端算数**(2026-08-02 修)。
*
* 契约给每个出口写了 `when`,放行那一刻这一口就已经由基座按序求值定下了
* (`routedPort`),人回传里带不带都不作数 —— 去向是台账那几个数的函数,
* 不是一道让人点的选择题。算不出来的组才退回 `selectedPorts`。
*
* 这段话从前只对**返回值**成立:抛回任务的人事件载荷是原始 reply,
* 而放行落地读的恰是事件载荷 —— 于是「routedPort 顶上」只发生在没人看的那一份里,
* 浏览器靠自己把算出的口塞进回传才活着(chat-bubbles 那段注释),
* 换个客户端(脚本、API 调用)就死在「选择组未选出口」(A2 e2e 实测)。
* **服务端说了算的事,不靠每个客户端自觉** —— 事件与返回值现在用同一份有效口。
*/
const routed = (found.order as AcceptanceWorkOrder).routedPort;
const effectivePorts: Readonly<Record<string, string>> =
reply.mode !== 'acceptance' ? {} : routed !== undefined ? { [routed.group]: routed.name } : reply.selectedPorts;
// 回传即留痕:谁、何时、答了什么、依据什么,随卡入任务记忆 // 回传即留痕:谁、何时、答了什么、依据什么,随卡入任务记忆
/*
* **空答复要说成人话**(2026-08-02 用户实测送来的熔断单):执行卡零输入之后,
* 人「看过、确认、提交」的回传 `values` 就是空的 —— 痕上一个值都没有,
* 在模型眼里像「没答成」,于是把同一张卡再发一遍(连发五拍熔断)。
* 空不是没答:**是人照原样确认了**(件上若改过,那几笔已由保存那一刻落进草稿层)。
*/
const blank = reply.mode === 'execution' && Object.keys(values).length === 0;
await deps.memory.append({ await deps.memory.append({
taskId: found.taskId, taskId: found.taskId,
kind: reply.mode === 'acceptance' ? 'release-verdict' : 'card-reply', kind: reply.mode === 'acceptance' ? 'release-verdict' : 'card-reply',
title: reply.mode === 'acceptance' ? `裁决:${reply.verdict}` : found.order.title, title: reply.mode === 'acceptance' ? `裁决:${reply.verdict}` : found.order.title,
detail: { by, values, mode: reply.mode, ...(reply.mode === 'acceptance' ? { comment: reply.comment } : {}) }, detail: {
by,
values,
mode: reply.mode,
...(blank ? { note: '人已办完并交回(卡上无表单,改动若有已在件上保存时落进草稿层)—— 接着办说明书里「人交回之后」的事' } : {}),
...(reply.mode === 'acceptance' ? { comment: reply.comment } : {}),
},
}); });
// 回传经统一入口以人事件抛回任务 // 回传经统一入口以人事件抛回任务 —— 载荷带的是**有效口**,不是人碰巧发来的那份
await deps.events.publishHumanEvent({ await deps.events.publishHumanEvent({
taskId: found.taskId, taskId: found.taskId,
cardId: reply.workOrderId, cardId: reply.workOrderId,
payload: { ...reply }, payload: { ...reply, ...(reply.mode === 'acceptance' ? { selectedPorts: effectivePorts } : {}) },
}); });
const updated = await deps.cards.getWorkOrder(reply.workOrderId); const updated = await deps.cards.getWorkOrder(reply.workOrderId);
@@ -223,19 +253,7 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
...(reply.mode === 'acceptance' && reply.verdict !== 'pass' ...(reply.mode === 'acceptance' && reply.verdict !== 'pass'
? { rejection: { kind: reply.verdict, comment: reply.comment ?? '' } } ? { rejection: { kind: reply.verdict, comment: reply.comment ?? '' } }
: {}), : {}),
/* selectedPorts: effectivePorts,
* 出口:**算出来的优先**。
*
* 契约给每个出口写了 `when`,放行那一刻这一口就已经由基座按序求值定下了
* (`routedPort`),人回传里带不带都不作数 —— 去向是台账那几个数的函数,
* 不是一道让人点的选择题。算不出来的组才退回 `selectedPorts`。
*/
selectedPorts:
reply.mode !== 'acceptance'
? {}
: (order as AcceptanceWorkOrder).routedPort !== undefined
? { [(order as AcceptanceWorkOrder).routedPort!.group]: (order as AcceptanceWorkOrder).routedPort!.name }
: reply.selectedPorts,
}; };
}, },
@@ -165,13 +165,21 @@ export function renderRows(html: string, scope: Readonly<Record<string, unknown>
const body = rows const body = rows
.map((row, index) => { .map((row, index) => {
const one = (typeof row === 'object' && row !== null ? row : { value: row }) as Record<string, unknown>; const one = (typeof row === 'object' && row !== null ? row : { value: row }) as Record<string, unknown>;
return renderTemplate(editable ? twinInputs(inner, index) : inner, one); return renderTemplate(uniqueNames(editable ? twinInputs(inner, index) : inner, index), one);
}) })
.join(''); .join('');
return `${open}${body}</${tag}>`; return `${open}${body}</${tag}>`;
}); });
} }
/**
* **行内表单组名唯一化** —— 行模版里手写的 radio 组(如 A2 替代等级三选)带着
* 固定 `name`;行复制 N 份后同名 radio 在整份文档里成了一组,第 2 页点一下
* 会把第 1 页已选的顶掉。每行的 name 缀上行号,组内互斥只在本行内发生。
*/
const uniqueNames = (inner: string, rowIndex: number): string =>
inner.replace(/\bname\s*=\s*"([^"]+)"/g, (_m, n: string) => `name="${n}__r${rowIndex}"`);
/** 带 `data-col` 的取值元素:`<span … data-slot="pn" data-col="PN">` */ /** 带 `data-col` 的取值元素:`<span … data-slot="pn" data-col="PN">` */
const EDITABLE_CELL = /<(span|b)\b([^>]*?\bdata-col\s*=\s*"([^"]+)"[^>]*)><\/\1>/g; const EDITABLE_CELL = /<(span|b)\b([^>]*?\bdata-col\s*=\s*"([^"]+)"[^>]*)><\/\1>/g;
@@ -0,0 +1,44 @@
/**
* 结构化出参的提取 —— 两次实测栽跟头,各钉一条:
*
* ① 模型一次吐两个对象(把下一拍也写了):按第一个配平的取,后面当噪声。
* ② **关思考的模型偶发多吐一个闭括号**(2026-08-02,A2 案例 0 实测):
* `…"type_tag":"A"}}]}},{"kind":…` —— 该是 `}}]},`。从前只数花括号,
* 多出的 `}` 把计数带到零,切出的半截连 `[` 都没关上,解析失败;
* 整拍六个动作全对,却退化成一张「需要你判断下一步」的兜底卡。
* 括号栈知道当下该关谁:对不上的闭括号当噪声丢(不发明内容,只丢一个多余字符)。
*/
import { describe, expect, it } from 'vitest';
import { parseJsonLoose } from '../internal/adapter/ai-sdk-provider.ts';
describe('parseJsonLoose:模型出参的宽容提取', () => {
it('干净 JSON 原样过', () => {
expect(parseJsonLoose('{"a":1}')).toEqual({ a: 1 });
});
it('```json 围栏剥掉', () => {
expect(parseJsonLoose('```json\n{"a":1}\n```')).toEqual({ a: 1 });
});
it('一次吐两个对象:取第一个配平的,后面当噪声', () => {
expect(parseJsonLoose('{"a":1}\n\n{"b":2}')).toEqual({ a: 1 });
});
it('**多吐一个闭括号:当噪声丢,整拍不作废**(实测形状)', () => {
const slip = '{"say":"归一好了","actions":[{"kind":"write","rows":[{"patch":{"type_tag":"A"}}]}},{"kind":"finish","itemId":"S1"}]}';
const got = parseJsonLoose(slip) as { actions: Array<{ kind: string }> };
expect(got.actions.map((a) => a.kind)).toEqual(['write', 'finish']);
});
it('值里带括号的字符串不把栈带偏', () => {
expect(parseJsonLoose('{"a":"x}y]z","b":2}')).toEqual({ a: 'x}y]z', b: 2 });
});
it('转义引号照认', () => {
expect(parseJsonLoose('{"a":"he said \\"}\\" ok"}')).toEqual({ a: 'he said "}" ok' });
});
it('实在不是 JSON:undefined,不抛', () => {
expect(parseJsonLoose('这不是 JSON')).toBeUndefined();
});
});
@@ -108,38 +108,55 @@ export function parseJsonLoose(text: string): unknown {
* 发一张兜底卡把人叫来。实测栽过:模型给的 9 颗料判据完全正确、 * 发一张兜底卡把人叫来。实测栽过:模型给的 9 颗料判据完全正确、
* 分类器都要调了,只因为它多写了一个对象,整拍白跑、人被无谓地打扰了一次。 * 分类器都要调了,只因为它多写了一个对象,整拍白跑、人被无谓地打扰了一次。
* *
* 现在按括号配平扫第一个完整对象,后面多出来的当噪声丢掉 —— * 扫法是**真正的括号栈**,不是单数花括号(2026-08-02 又栽一次才改的):
* 多写的那一份本就是下一拍的事,这一拍不该替它执行。 * 关思考的模型偶发多吐一个闭括号(`…}}]}},{…` 该是 `…}}]},{…`),
* 扫的时候要认字符串与转义,否则值里带 `}` 的会把括号计数带偏。 * 只数花括号的计数被它带到零,切出的半截连 `[` 都没关上 —— 解析失败,
* 整拍(六个动作全对)退化成一张「需要你判断下一步」的兜底卡。
* 栈知道当下该关的是哪个括号:**对不上的闭括号当噪声丢掉**(自修这类手滑;
* 丢的只是一个多余字符,不发明任何内容),栈清空即取到第一个完整值,
* 后面多出来的照旧当噪声 —— 多写的那一份本就是下一拍的事。
* 字符串与转义照认,否则值里带 `}` 的会把栈带偏。
*/ */
function firstBalanced(text: string): unknown { function firstBalanced(text: string): unknown {
const start = text.search(/[[{]/); const start = text.search(/[[{]/);
if (start < 0) return undefined; if (start < 0) return undefined;
const open = text[start]; const stack: string[] = [];
const close = open === '{' ? '}' : ']'; const kept: string[] = [];
let depth = 0;
let inString = false; let inString = false;
let escaped = false; let escaped = false;
for (let i = start; i < text.length; i += 1) { for (let i = start; i < text.length; i += 1) {
const ch = text[i]; const ch = text[i] as string;
if (inString) { if (inString) {
kept.push(ch);
if (escaped) escaped = false; if (escaped) escaped = false;
else if (ch === '\\') escaped = true; else if (ch === '\\') escaped = true;
else if (ch === '"') inString = false; else if (ch === '"') inString = false;
continue; continue;
} }
if (ch === '"') inString = true; if (ch === '"') {
else if (ch === open) depth += 1; inString = true;
else if (ch === close) { kept.push(ch);
depth -= 1; continue;
if (depth === 0) { }
if (ch === '{' || ch === '[') {
stack.push(ch === '{' ? '}' : ']');
kept.push(ch);
continue;
}
if (ch === '}' || ch === ']') {
if (stack[stack.length - 1] !== ch) continue; // 对不上的闭括号 = 噪声,丢
stack.pop();
kept.push(ch);
if (stack.length === 0) {
try { try {
return JSON.parse(text.slice(start, i + 1)); return JSON.parse(kept.join(''));
} catch { } catch {
return undefined; return undefined;
} }
} }
continue;
} }
kept.push(ch);
} }
return undefined; return undefined;
} }
+9 -3
View File
@@ -50,11 +50,17 @@
- 地址与列名的把关在装配层(它认识活动清单)—— 未声明的地址与列一律拒写, - 地址与列名的把关在装配层(它认识活动清单)—— 未声明的地址与列一律拒写,
越域写与越权调工具同罪;拒了抛错,由回合收成「行动被拒」喂回下一拍,引擎不崩; 越域写与越权调工具同罪;拒了抛错,由回合收成「行动被拒」喂回下一拍,引擎不崩;
- **写的授权面是台账声明,不是计划项**(2026-07-30):`itemId` 只是记账, - **写的授权面是台账声明,不是计划项**(2026-07-30):`itemId` 只是记账,
挂得上就挂(项活性化),挂不上(已完成 / 人工激活未 start / 阶段 / 不存在)照写不拦, 挂得上就挂(项活性化),挂不上(已完成 / 阶段 / 不存在)照写不拦,
留痕注明「未挂到计划项」。从前绑死在项生命周期上,死锁过一单:S5 收完才发现打标 留痕注明「未挂到计划项」。从前绑死在项生命周期上,死锁过一单:S5 收完才发现打标
没写全,所有能挂的项都 Completed —— 每拍补写都被拒,连拒五拍熔断,而数据本身合规; 没写全,所有能挂的项都 Completed —— 每拍补写都被拒,连拒五拍熔断,而数据本身合规;
- 写完把该项收了(未 Active 的就地过一下 Active)。**这不是绕开「Enabled ≠ Active」**: - 写完把该项收了(未 Active 的就地过一下 Active)。
那条纪律管的是触达世界的项,而草稿写触达不到任何东西 —— 为它单烧一次模型去按那一下是纯仪式。 - **人工激活的项:模型显式动它 = 启动它**(2026-08-02 拆墙,`readyToWork`)。
从前它要求先发显式 `start`,不发就拒;提示词与拒回都写全之后实测仍熔断
(A1 一单连拒五拍,五次全是「S3_confirm 要先 start」)—— 那道墙保护不了任何东西:
start 是**模型自己的**动作,同一串连发即过,拦下只是逼它多演一步学不稳的礼仪。
人工激活真正的分量原样保留:**引擎不因哨兵满足替它启动**(哨兵只推到
Available/Enabled),**也不给 doneWhen 替人收**(自动收步跳过人工激活项)——
收它的只能是模型自己的 finish。`start` 仍是合法行动,只是可省。
- **里程碑前提达成即达成**(2026-07-30,`achieveMilestones`):CMMN 的里程碑没有执行体, - **里程碑前提达成即达成**(2026-07-30,`achieveMilestones`):CMMN 的里程碑没有执行体,
也没有谁去按它。从前只有 `onEvent(ref = 里程碑 id)` 会 occur,而系统里没有任何东西 也没有谁去按它。从前只有 `onEvent(ref = 里程碑 id)` 会 occur,而系统里没有任何东西
发这种事件 —— 于是里程碑永远 `Available`:既是必做项(堵死 autoComplete), 发这种事件 —— 于是里程碑永远 `Available`:既是必做项(堵死 autoComplete),
@@ -6,7 +6,7 @@
*/ */
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { TaskId } from '@contracts'; import type { TaskId } from '@contracts';
import { createTurnRunner, handsOff, sameGist, stalledTurns } from '../internal/usecase/turn-runner.ts'; import { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns } from '../internal/usecase/turn-runner.ts';
import type { TurnRunnerDeps } from '../internal/usecase/turn-runner.ts'; import type { TurnRunnerDeps } from '../internal/usecase/turn-runner.ts';
import type { PendingAcceptance } from '../port.ts'; import type { PendingAcceptance } from '../port.ts';
import type { CaseModel } from '../internal/domain/cmmn-parse.ts'; import type { CaseModel } from '../internal/domain/cmmn-parse.ts';
@@ -521,12 +521,14 @@ describe('干活前先站上场', () => {
* 实测的死锁:模型收完 S5 才发现打标结果没写全,此时所有能挂的项都 Completed, * 实测的死锁:模型收完 S5 才发现打标结果没写全,此时所有能挂的项都 Completed,
* 唯一没完成的只剩标人工激活的 T_release —— 每拍补写都被「要先 start」拒回来, * 唯一没完成的只剩标人工激活的 T_release —— 每拍补写都被「要先 start」拒回来,
* 连拒五拍熔断交人,而那几行数据本身合法合规。 * 连拒五拍熔断交人,而那几行数据本身合法合规。
* 2026-08-02 拆墙后,人工激活的项也**挂得上**了(动它就算启动):
* 写照落不变,记账从「挂不上、留痕注明」变成「挂上、点亮进行中」—— 更诚实。
*/ */
it('标了人工激活的项:写照落、不挂项、项不动 —— 挂项是记账,不是授权', async () => { it('标了人工激活的项:写照落、挂上点亮 —— 授权面仍是台账声明', async () => {
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: writeTo('GATED') }); const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: writeTo('GATED') });
const outcome = await h.runner.runTurn(TASK); const outcome = await h.runner.runTurn(TASK);
expect(h.written).toHaveLength(1); expect(h.written).toHaveLength(1);
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Available'); expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Active');
expect(outcome.status).toBe('running'); expect(outcome.status).toBe('running');
}); });
@@ -629,11 +631,18 @@ describe('宣告完成', () => {
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Completed'); expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Completed');
}); });
it('标了人工激活的项仍要先 start —— 那条纪律管的是触达世界与关键交付', async () => { /*
* **人工激活的项:动它就算启动**(2026-08-02 拆墙)。
* 从前这儿钉的是「要先 start,不发就拒」;实测那道墙保护不了任何东西
* (start 是模型自己的动作,同一串连发即过),却多出一种死法:A1 一单
* 连拒五拍熔断,五次全是「S3_confirm 要先 start」,提示词与拒回都写着,
* 模型就是学不稳。人工激活真正的分量在引擎那一侧:不替它启动、不替人收 ——
* 那两条各有各的测钉着(哨兵不自动激活 / 自动收步跳过人工激活项)。
*/
it('标了人工激活的项:模型显式动它 = 启动它,就地过 Active 再收', async () => {
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: finish('GATED') }); const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: finish('GATED') });
const outcome = await h.runner.runTurn(TASK); const outcome = await h.runner.runTurn(TASK);
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Available'); expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Completed');
expect(outcome.status).toBe('running'); // 拒了不崩,理由喂回下一拍
}); });
it('编出来的项收不了', async () => { it('编出来的项收不了', async () => {
@@ -784,7 +793,7 @@ describe('说话按轮不按拍:只有交出控制权的那一拍才进对话区
}); });
it('轮到人了才出气泡:问一句 / 派工单 / 摆岔路 / 等外部 / 提交放行', () => { it('轮到人了才出气泡:问一句 / 派工单 / 摆岔路 / 等外部 / 提交放行', () => {
for (const kind of ['ask', 'human', 'decide', 'listen', 'submit']) { for (const kind of ['human', 'decide', 'listen', 'submit']) {
expect(handsOff([{ kind }]), kind).toBe(true); expect(handsOff([{ kind }]), kind).toBe(true);
} }
}); });
@@ -878,13 +887,11 @@ describe('画面不再静默丢帧', () => {
}); });
/** /**
* 补全卡 —— 对话卡族,与执行工单是两件事。 * **补全卡已全局退役**(2026-08-02 用户定)—— 引擎侧这个行动型不再发卡:
* * 归一层(agent-runner)会把 `ask` 原地转成零输入执行工单,所以正常到不了这里;
* SOP 上 A1-S2「按缺口清单追问补全」画的就是它。从前 Agent **发不出对话卡**: * 别的调用方直发就如实拒并指对路(human + execution)。
* 行动型里只有 `human` 且只能选执行 / 验收两种工单,于是那一步只能拿执行工单顶 ——
* 形似而神非:派一张工单去问几个字段,收卡的人得进待处理、办完再回来,重得不成比例。
*/ */
describe('补全卡', () => { describe('补全卡退役:引擎不再发 elicitation 卡', () => {
const ASK = { const ASK = {
kind: 'ask', kind: 'ask',
itemId: 'i1', itemId: 'i1',
@@ -892,18 +899,9 @@ describe('补全卡', () => {
fields: [{ key: 'NE555P_lead_time', label: 'NE555P 交期' }], fields: [{ key: 'NE555P_lead_time', label: 'NE555P 交期' }],
}; };
it('发出去即挂起等填 —— 调人 = 立监听听人', async () => { it('直发 ask 被拒:一张卡都不发,理由指向执行工单', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: ASK }); const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: ASK });
const outcome = await h.runner.runTurn(TASK); const outcome = await h.runner.runTurn(TASK);
expect(outcome.status).toBe('awaiting');
expect(h.issued).toEqual([
{ workOrder: 'elicitation', card: { ask: '这两条缺交期,麻烦补一下', fields: ['NE555P_lead_time'] } },
]);
});
it('一个格子都没有就拒发 —— 空表单等于没问', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: { ...ASK, fields: [] } });
const outcome = await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(0); expect(h.issued).toHaveLength(0);
expect(outcome.status).toBe('running'); // 拒了不崩,理由喂回下一拍 expect(outcome.status).toBe('running'); // 拒了不崩,理由喂回下一拍
}); });
@@ -1225,6 +1223,30 @@ describe('办完的项:话要说对,按一下不报错', () => {
expect(h.issued).toHaveLength(0); expect(h.issued).toHaveLength(0);
}); });
/*
* **A1 基线熔断实测(2026-08-03)**:人答完之后那一步已经收掉,模型又发了一遍同一张卡。
* 从前 `readyToWork` 走在重发判断之前 —— Completed 项当场抛「不能再往它上面挂活」,
* 于是「重发已答过的卡 = 无事发生」这条唯一活路在最需要它的时候够不着,
* 同一条拒回重复五遍熔断(13 拍 124k,发口一个都没有)。顺序换过来才叫墙要配路。
*/
it('重发**已答过**的卡,哪怕那一步已经收掉 → 无事发生,不撞「已办完」的墙', async () => {
const h = harness({
parsed: DONE_MODEL,
runtime: DONE_RUNTIME,
action: { kind: 'human', itemId: 'S3', workOrder: 'execution', card: { title: '请审定这份清单', ask: 'a' } },
trail: [
{ kind: 'card-sent', title: '请审定这份清单' },
{ kind: 'card-reply', title: '请审定这份清单' },
] as never,
});
const outcome = await h.runner.runTurn(TASK);
// 没发第二张卡(人不被打扰、humanCalls 不烧),也没被拒
expect(h.issued).toHaveLength(0);
expect(outcome.status).not.toBe('awaiting');
// 收掉的项原样收着 —— 无事发生不该顺手把它推回场上
expect(h.envelope.runtime.items.find((i) => i.id === 'S3')?.state).toBe('Completed');
});
it('**再 start 一次收掉的项是无事发生,不是非法迁移** —— 别让两堵墙对着撞', async () => { it('**再 start 一次收掉的项是无事发生,不是非法迁移** —— 别让两堵墙对着撞', async () => {
const h = harness({ parsed: DONE_MODEL, runtime: DONE_RUNTIME, action: { kind: 'start', itemId: 'S3' } }); const h = harness({ parsed: DONE_MODEL, runtime: DONE_RUNTIME, action: { kind: 'start', itemId: 'S3' } });
const outcome = await h.runner.runTurn(TASK); const outcome = await h.runner.runTurn(TASK);
@@ -1233,3 +1255,44 @@ describe('办完的项:话要说对,按一下不报错', () => {
expect(h.envelope.runtime.items.find((i) => i.id === 'S3')?.state).toBe('Completed'); expect(h.envelope.runtime.items.find((i) => i.id === 'S3')?.state).toBe('Completed');
}); });
}); });
/**
* **重发已答过的卡:执行工单与决策卡同一把尺**(2026-08-03,A4 实测逼出来的)。
*
* 从前只有执行工单那一路有兜底,决策卡一道都没有 —— 同一个岔路口连发四张,
* 每张扣一次调人预算,四次扣完整单 `exhausted`。卡族不同,病是同一个。
* 三档由 `resendVerdict` 定:照发 / 无事发生(头两次)/ 第三次起当被拒(熔断看得见)。
*/
describe('同一张卡再发一遍的三档', () => {
const sent = (title: string) => ({ kind: 'card-sent', title });
const replied = (title: string) => ({ kind: 'card-reply', title });
const echoed = (answered: string) => ({ kind: 'observation', title: echoTitleOf(answered) });
it('没发过这一问 → 照发', () => {
expect(resendVerdict([sent('别的事'), replied('别的事')], '这一问').kind).toBe('send');
});
it('发过但**人还没答** → 照发 —— 未答的重发由卡自己去重管,不归这里', () => {
expect(resendVerdict([sent('这一问')], '这一问').kind).toBe('send');
});
it('人答过了 → 头两次无事发生(卡不真发、预算不烧)', () => {
const win = [sent('这一问'), replied('这一问')];
expect(resendVerdict(win, '这一问')).toEqual({ kind: 'echo', answered: '这一问' });
expect(resendVerdict([...win, echoed('这一问')], '这一问').kind).toBe('echo');
});
it('第三次起当被拒 —— 那时是真卡死了,熔断兜底要看得见它', () => {
const win = [sent('这一问'), replied('这一问'), echoed('这一问'), echoed('这一问')];
expect(resendVerdict(win, '这一问')).toEqual({ kind: 'reject', answered: '这一问' });
});
it('问句只少个尾巴也算同一问 —— 变体重发同样烧预算(与 sameQuestion 同源)', () => {
const win = [sent('请审定这份受理需求清单,确认无误后我就往下走'), replied('请审定这份受理需求清单,确认无误后我就往下走')];
expect(resendVerdict(win, '请审定这份受理需求清单').kind).toBe('echo');
});
it('空问句不拦 —— 认不出身份的卡不该被这道兜底吃掉', () => {
expect(resendVerdict([sent(''), replied('')], ' ').kind).toBe('send');
});
});
@@ -101,9 +101,16 @@ function payloadOf(detail: Record<string, unknown> | undefined, limit: number =
* 人以为自己白点了一次。补全卡那一路早就有(`values`),决策卡是漏的那一半。 * 人以为自己白点了一次。补全卡那一路早就有(`values`),决策卡是漏的那一半。
*/ */
const chosen = detail['value']; const chosen = detail['value'];
const note = detail['note'];
if (typeof chosen === 'string' && chosen !== '') { if (typeof chosen === 'string' && chosen !== '') {
const note = detail['note'];
parts.push(`人选了:${chosen}${typeof note === 'string' && note !== '' ? `(${note})` : ''}`); parts.push(`人选了:${chosen}${typeof note === 'string' && note !== '' ? `(${note})` : ''}`);
} else if (typeof note === 'string' && note !== '') {
/*
* 没有选项也可能有话要带 —— 零输入执行卡的空答复痕靠这一句说清
* 「人已办完并交回」,不然痕上一个值都没有,模型会当「没答成」再发一张卡
* (2026-08-02 实测熔断单)。
*/
parts.push(note);
} }
const output = detail['output']; const output = detail['output'];
@@ -186,7 +193,47 @@ export function sameQuestion(a: string, b: string): boolean {
const norm = (s: string): string => s.replace(/[\p{P}\p{S}\s]/gu, '').toLowerCase(); const norm = (s: string): string => s.replace(/[\p{P}\p{S}\s]/gu, '').toLowerCase();
const x = norm(a); const x = norm(a);
const y = norm(b); const y = norm(b);
return x !== '' && x === y; if (x === '' || y === '') return false;
if (x === y) return true;
/*
* **前缀也算同一件事**(2026-08-02 用户实测送来的单):第一张
* 「请审定这份受理需求清单,确认无误后我就往下走。」人答了;第二张
* 「请审定这份受理需求清单」—— 只少了个尾巴,全等比对放它过去,
* 又烧掉一次人环预算(那单 humanCalls 4/4 耗尽,放行卡都没发出去)。
* 短的一方够长(≥6 字)且是长方前缀,就是同一问 —— 阈值保守:
* 拦错一问比多问一问更坏,真的新问题不该被吃掉。
*/
const [short, long] = x.length <= y.length ? [x, y] : [y, x];
return short.length >= 6 && long.startsWith(short);
}
/** 「已答过、没有再发」那条观察痕的标题 —— 记与数是同一句话,不能各写各的 */
export function echoTitleOf(answered: string): string {
return `这张卡人已答过,没有再发(「${answered}」)`;
}
/**
* **同一张卡该不该再发一遍** —— 执行工单与决策卡共用这一把尺(纯判断,好测)。
*
* 从前只有执行工单那一路有这道兜底,决策卡**一道都没有**:同一个岔路口连发四张,
* 每张扣一次调人预算,四次扣完整单 `exhausted`(2026-08-03 A4 实测:人点完之后
* 那一步被重念,剧本头一段写着「发一张决策卡」,它就照办再发一张)。
* 卡族不同,病是同一个 —— **按钮按两下不该变成墙**,也不该变成四张卡。
*
* 三档:`send` 照发;`echo` 卡不真发(人不被打扰、humanCalls 不烧),
* 只把「已答过」的事实摆回轨迹;第三次起 `reject` —— 那时是真卡死了,熔断要看得见它。
*/
export function resendVerdict(
window: ReadonlyArray<{ readonly kind: string; readonly title: string }>,
asked: string,
): { readonly kind: 'send' } | { readonly kind: 'echo' | 'reject'; readonly answered: string } {
const trimmed = asked.trim();
if (trimmed === '') return { kind: 'send' };
const replied = new Set(window.filter((t) => t.kind === 'card-reply').map((t) => t.title));
const same = window.find((t) => t.kind === 'card-sent' && replied.has(t.title) && sameQuestion(t.title, trimmed));
if (same === undefined) return { kind: 'send' };
const echoed = window.filter((t) => t.kind === 'observation' && t.title === echoTitleOf(same.title)).length;
return { kind: echoed >= 2 ? 'reject' : 'echo', answered: same.title };
} }
/** /**
@@ -220,7 +267,7 @@ export function handsOff(actions: ReadonlyArray<{ kind: string }>): boolean {
return actions.some((a) => HANDOFF_KINDS.has(a.kind)); return actions.some((a) => HANDOFF_KINDS.has(a.kind));
} }
const HANDOFF_KINDS = new Set(['ask', 'human', 'decide', 'listen', 'submit']); const HANDOFF_KINDS = new Set(['human', 'decide', 'listen', 'submit']);
export function sameGist(a: string, b: string): boolean { export function sameGist(a: string, b: string): boolean {
const norm = (s: string): string => s.replace(/[\p{P}\p{S}\s]/gu, ''); const norm = (s: string): string => s.replace(/[\p{P}\p{S}\s]/gu, '');
@@ -304,11 +351,15 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
/** /**
* 干活的那一下:把计划项推到 Active,再由调用方收成 Completed。 * 干活的那一下:把计划项推到 Active,再由调用方收成 Completed。
* *
* 拓扑不许 `Available → Completed`,所以中间那一步总得有人走。谁走,分情况: * 拓扑不许 `Available → Completed`,所以中间那一步总得有人走 —— **引擎就地过一下**,
* · 项标了 `manualActivationRule` → **只能 Agent 自己 start**。那条纪律管的是 * 人工激活的项也一样(2026-08-02 拆墙)。从前人工激活的项要求模型先发一个显式
* 触达世界与关键交付:按一下再动,为的是别让 AI 悄悄改了外面。 * `start`,不发就拒;可那道墙保护不了任何东西:start 是**模型自己的**动作,
* · 没标 → 引擎就地过一下。为一次读类调用或一次草稿写单烧一拍模型去按那一下, * 同一串里连发即过,拦下只是逼它多演一步礼仪 —— 而礼仪学不稳就是死法:
* 是纯仪式;实测里它还会卡死:模型反复挑同一个没 start 的项,每拍都被拓扑拒回来。 * 实测 A1 一单连拒五拍熔断,五次全是「S3_confirm 要先 start」,提示词与拒回
* 都写着这句话,它就是学不会。人工激活真正要保的两样一样不少:
* 引擎不因哨兵满足**替它启动**(那在 sentry 那一侧),也不给 doneWhen
* **替人收步**(那在自动收步那一侧)。「模型显式动它」本身就是启动的意思表示,
* 与 finish 就地过 Active、start 已收项无事发生同族 —— 状态机的礼仪不该长成墙。
* *
* 项不存在就如实报,并把合法的选项摆出来 —— 模型会编 id(实测编过), * 项不存在就如实报,并把合法的选项摆出来 —— 模型会编 id(实测编过),
* 只回一句「(不存在)」它下一拍还是照编。 * 只回一句「(不存在)」它下一拍还是照编。
@@ -357,9 +408,6 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
`要接着办就挂当下这一步(可选:${live.join('、') || '无'});这一单该收口了就直接 submit`, `要接着办就挂当下这一步(可选:${live.join('、') || '无'});这一单该收口了就直接 submit`,
); );
} }
if (item.manualActivation) {
throw new Error(`${itemId} 标了人工激活(触达世界或关键交付),要先 start 再干活 —— 当下是 ${state}`);
}
return transitionItem(runtime, itemId, 'Active'); return transitionItem(runtime, itemId, 'Active');
} }
@@ -667,8 +715,15 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* 拒了不掀翻回合,草稿也确实没被改脏(写进去的值与原值相同)。 * 拒了不掀翻回合,草稿也确实没被改脏(写进去的值与原值相同)。
*/ */
if (changed === 0) { if (changed === 0) {
/*
* 「换一件事做」四个字不够指方向(2026-08-02 实测:A2 一单连撞四拍,
* 每拍都把同一行原样再写一遍)—— 拒回的话是模型下一拍的行动指南,
* 得说清「这些值**已经在**台账里」意味着什么:多半是该办下一件事了。
*/
throw new Error( throw new Error(
`写了等于没写:${action.address} 这 ${action.rows.length} 行的值与现有草稿完全相同,局面没有推进 —— 换一件事做`, `写了等于没写:${action.address} 这 ${action.rows.length} 行的值与现有草稿完全相同 —— ` +
`这些值**已经在台账里了**,不必再写。该办的多半是下一件事:还没写的列/表、` +
`该 finish 的项,或按剧本走下一步;把同样的值再写一遍只会再被拒一次`,
); );
} }
await deps.memory.append({ await deps.memory.append({
@@ -755,11 +810,19 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* Active → Active 是非法迁移,当场拒;模型只会原样再试一遍,连拒五拍熔断。 * Active → Active 是非法迁移,当场拒;模型只会原样再试一遍,连拒五拍熔断。
* 而它想做的事完全合法 —— 一个计划项本来就可以先问一句、再派一张单。 * 而它想做的事完全合法 —— 一个计划项本来就可以先问一句、再派一张单。
* *
* `readyToWork` 是同一件事的宽容写法:已在场就原样返回,人工激活未 start 才拦。 * `readyToWork` 是同一件事的宽容写法:已在场就原样返回,没在场就地站上
* (2026-08-02 起人工激活的项同样如此 —— 动它就算启动)。
*/ */
const { parsed: cardModel } = await loaded(taskId);
const advanced = readyToWork(cardModel, runtime, action.itemId);
/* /*
* **重发的判断要走在站上场之前**(2026-08-03,A1 熔断实测逼出来的顺序)。
*
* 原先先 `readyToWork` 再判重发:而人答完之后那一步常常已经**收掉**了,
* `readyToWork` 对 Completed 项直接抛「这一步已经办完,不能再往它上面挂活」——
* 于是「重发已答过的卡 = 无事发生」这条唯一的活路**在最需要它的时候够不着**,
* 模型原样重发、连拒五拍熔断(A1 基线实测:13 拍 124k,同一条拒回重复 5 遍)。
* 重发一张已答过的卡本来就不需要那一项在场 —— 它什么也不做。
* 「墙要配路」(设计律第一条):先给路,给不了路再谈墙。
*
* **同一件事不许问第二遍**(2026-08-01,实测账)。 * **同一件事不许问第二遍**(2026-08-01,实测账)。
* *
* 熔断管的是「连续被拒」,管不了这种:每一拍的行动都合法,只是**问的是同一件事**。 * 熔断管的是「连续被拒」,管不了这种:每一拍的行动都合法,只是**问的是同一件事**。
@@ -775,19 +838,44 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
const asked = String(face['title'] ?? face['ask'] ?? '').trim(); const asked = String(face['title'] ?? face['ask'] ?? '').trim();
if (asked !== '') { if (asked !== '') {
const window = await deps.memory.window(taskId, STALL_WINDOW); const window = await deps.memory.window(taskId, STALL_WINDOW);
const sent = window.filter((t) => t.kind === 'card-sent'); const verdict = resendVerdict(window, asked);
const replied = new Set(window.filter((t) => t.kind === 'card-reply').map((t) => t.title)); if (verdict.kind !== 'send') {
const same = sent.find((t) => replied.has(t.title) && sameQuestion(t.title, asked)); const same = { title: verdict.answered };
if (same !== undefined) { /*
throw new Error( * **重发已答过的卡:头两次当无事发生,不当被拒**(2026-08-02 用户实测
`这件事刚问过,人也答过了(「${same.title}」)—— 别再发一张一样的卡。` + * 送来的熔断单)。从前这里直接拒 —— 拒回的话把「答案在轨迹里」说得
'答案就在你的轨迹里(card-reply 那一条的 values)。' + * 再清楚,模型偶发就是读不进:零输入执行卡的空答复在它眼里像「没答成」,
'**他把格子留空提交回来,就是对方给不了** —— 那也是一个答案:' + * 于是原样重发、被拒、再重发,五拍熔断,人被无谓叫来。
'如实记账(问过了 / 答过了),缺口照实留着,把这一步收掉往下走;' + * 拒回喂的是对抗,观察喂的是事实 —— 与 start 已收项无事发生、
'真有别的东西要问,换一件事、换一个问法。', * finish 已收项无事发生同族:**按钮按两下不该变成墙**。
); * 卡不真发(人不被打扰、humanCalls 不烧),只把「已答过」的事实
* 连着下一步指路摆回去;第三次起仍按被拒算 —— 那时是真卡死了,
* 熔断兜底要看得见它。
*/
const echoTitle = echoTitleOf(same.title);
if (verdict.kind === 'reject') {
throw new Error(
`这件事刚问过,人也答过了(「${same.title}」)—— 别再发一张一样的卡。` +
'答案就在你的轨迹里(card-reply 那一条)。**空答复 = 人照原样确认了**,' +
'不是没答:接着办说明书里「人交回之后」的那几件事,把这一步收掉往下走;' +
'真有别的东西要问,换一件事、换一个问法。',
);
}
await deps.memory.append({
taskId,
kind: 'observation',
title: echoTitle,
detail: {
internal: true,
note: '人已答过这张卡(空答复 = 照原样确认)。别再发了 —— 接着办说明书里「人交回之后」的那几件事,办完把这一步 finish 掉。',
},
});
// 什么也没做,局面原样交回 —— 这一拍不该顺手把那一项推上场
return { runtime, status: 'running' };
} }
} }
const { parsed: cardModel } = await loaded(taskId);
const advanced = readyToWork(cardModel, runtime, action.itemId);
const card = await deps.cards.issue({ const card = await deps.cards.issue({
taskId, taskId,
ownerId: context.ownerId, ownerId: context.ownerId,
@@ -806,32 +894,12 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
case 'ask': { case 'ask': {
/* /*
* 发补全卡 = 立监听听人:卡发到对话区,本拍就此挂起等填。 * **补全卡已全局退役**(2026-08-02 用户定:「彻底全局退役补全卡,
* * 执行卡完全就是包含补全语义的人类工具」)。归一层(agent-runner)已把
* **先验迁移,再发卡**:迁移非法时抛在前面,世界上不会留下一张已发出的卡 —— * `ask` 原地转成零输入执行工单,这个分支正常到不了 —— 真到了
* 卡收不回,负责人的对话区就堆起一摞谁也不知道该怎么答的表单。 * (别的调用方直发)就如实拒,并指对路。
*/ */
if (action.fields.length === 0) { throw new Error('补全卡已退役:缺信息发零输入执行工单(human + workOrder:"execution"),指导书写清缺什么,人在工作区的件上补');
throw new Error(
'补全卡要有格子:一个缺口一个格子,形状 fields:[{key,label,hint?}] —— 空表单等于没问',
);
}
const advanced = readyToWork((await loaded(taskId)).parsed, runtime, action.itemId);
const card = await deps.cards.ask({
taskId,
ownerId: context.ownerId,
humanCallCap: context.humanCallCap,
ask: action.ask,
fields: action.fields,
...(action.basis === undefined ? {} : { basis: action.basis }),
});
await deps.listeners.register({
taskId,
eventType: 'human',
keyFilter: { cardId: card.id },
deadline: 'P3D',
});
return { runtime: advanced, status: 'awaiting' };
} }
case 'entry': { case 'entry': {
@@ -875,7 +943,38 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* 以及先验迁移再发卡(与补全卡同理 —— 卡发出去收不回)。 * 以及先验迁移再发卡(与补全卡同理 —— 卡发出去收不回)。
*/ */
if (action.options.length < 2) { if (action.options.length < 2) {
throw new Error('决策卡至少两个选项:只有一条路就不是决策,直接干或者发补全卡'); throw new Error('决策卡至少两个选项:只有一条路就不是决策,直接干或者派一张执行工单');
}
/*
* 同款顺序:**先判重发,再站上场**(见 human 那一路的账 —— 人选完之后
* 那一步常已收掉,先站上场就会撞在「已办完」的墙上,活路够不着)。
*
* **人已经选过的岔路口不再发第二张**(2026-08-03,A4 实测:同一张连发四次,
* 四次调人预算烧光落 `exhausted`)。与执行工单同一把尺 —— 决策卡的身份是问句(`ask`),
* 它入痕时记的正是 `ask`(`issueChatCard` → `card-sent` 标题)。
*/
const window = await deps.memory.window(taskId, STALL_WINDOW);
const verdict = resendVerdict(window, action.ask);
if (verdict.kind !== 'send') {
const echoTitle = echoTitleOf(verdict.answered);
if (verdict.kind === 'reject') {
throw new Error(
`这个岔路口人已经选过了(「${verdict.answered}」)—— 别再发一张一样的决策卡。` +
'他选了什么就在你的轨迹里(card-reply 那一条,detail 带着选中的值):' +
'照那个选择往下办,把这一步收掉;真有**另一个**岔路口要人定,换一件事、换一个问法。',
);
}
await deps.memory.append({
taskId,
kind: 'observation',
title: echoTitle,
detail: {
internal: true,
note: '人已经在这个岔路口选过了(选中的值在 card-reply 那一条)。别再发 —— 照他的选择接着办,办完把这一步 finish 掉。',
},
});
// 什么也没做,局面原样交回
return { runtime, status: 'running' };
} }
const advanced = readyToWork((await loaded(taskId)).parsed, runtime, action.itemId); const advanced = readyToWork((await loaded(taskId)).parsed, runtime, action.itemId);
const card = await deps.cards.decide({ const card = await deps.cards.decide({
@@ -931,11 +1030,12 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
/* /*
* 宣告完成。完成不等于成果被认可:终局仍唯放行,人验收才算数。 * 宣告完成。完成不等于成果被认可:终局仍唯放行,人验收才算数。
* *
* 没站上场的普通项**就地过 Active 再收**(readyToWork 同一套):这一项的活 * 没站上场的项**就地过 Active 再收**(readyToWork 同一套):这一项的活
* 常常在同一串里已经干了(write 落了行),只是记账没挂上 —— 为「按一下」 * 常常在同一串里已经干了(write 落了行),只是记账没挂上 —— 为「按一下」
* 单烧一拍模型是纯仪式(实测:finish S6 被「没在进行中」拒,下一拍 * 单烧一拍模型是纯仪式(实测:finish S6 被「没在进行中」拒,下一拍
* start S6 + finish 原样重来,白花一拍)。标了人工激活的仍要先 start: * start S6 + finish 原样重来,白花一拍)。人工激活的项也一样(2026-08-02
* 那条纪律管的是触达世界与关键交付,readyToWork 会替它把门。 * 拆墙,见 readyToWork 注释)—— 但它仍旧**没有 doneWhen、引擎不自动收**,
* 收它的只能是模型自己的这一下 finish。
*/ */
const { parsed: finishModel } = await loaded(taskId); const { parsed: finishModel } = await loaded(taskId);
/* /*
+3 -2
View File
@@ -2108,7 +2108,7 @@
.proto-root .jc-rp-vals dd { margin: 0; padding: 8px 12px; font-size: 12px; color: var(--ink); line-height: 1.65; background: #fff; } .proto-root .jc-rp-vals dd { margin: 0; padding: 8px 12px; font-size: 12px; color: var(--ink); line-height: 1.65; background: #fff; }
/* 落款 */ /* 落款 */
.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-foot { display: flex; align-items: center; justify-content: space-between; 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: 9px; flex: 1 1 200px; min-width: 0; } .proto-root .jc-rp-sign { display: flex; align-items: center; gap: 9px; flex: 1 1 200px; min-width: 0; }
.proto-root .jc-rp-ava { flex: none; width: 30px; height: 30px; border-radius: 999px; display: grid; place-items: center; font-size: 12px; font-weight: 700; color: #fff; background: var(--brand); } .proto-root .jc-rp-ava { flex: none; width: 30px; height: 30px; border-radius: 999px; display: grid; place-items: center; font-size: 12px; font-weight: 700; color: #fff; background: var(--brand); }
.proto-root .jc-rp-sign b { display: block; font-size: 12px; color: var(--ink-2); } .proto-root .jc-rp-sign b { display: block; font-size: 12px; color: var(--ink-2); }
@@ -2301,7 +2301,8 @@
.proto-root .jc-fc-h em.bad { color: var(--danger); } .proto-root .jc-fc-h em.bad { color: var(--danger); }
/* 卡体与动作栏 */ /* 卡体与动作栏 */
.proto-root .jc-fc-b { padding: 11px 13px; display: flex; flex-direction: column; gap: 10px; } .proto-root .jc-fc-b { padding: 11px 13px; display: flex; flex-direction: column; gap: 10px; }
.proto-root .jc-fc-ft { display: flex; align-items: center; gap: 8px; padding: 9px 13px; border-top: 1px solid var(--line-soft); background: #fbfcfe; } /* 动作靠右(2026-08-03 用户:「提交按钮放左侧太奇怪了」)—— 验收卡的「重做|通过」有 jc-fc-sp 撑开,不受影响 */
.proto-root .jc-fc-ft { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 9px 13px; border-top: 1px solid var(--line-soft); background: #fbfcfe; }
/* /*
* **交不了的时候,说清差在哪** —— 只摆一个灰按钮等于让人猜自己哪儿做错了。 * **交不了的时候,说清差在哪** —— 只摆一个灰按钮等于让人猜自己哪儿做错了。
* 底色用警示色而不是错误红:这不是出错,是活还没干完。 * 底色用警示色而不是错误红:这不是出错,是活还没干完。
@@ -81,6 +81,13 @@ function collectEdits(edits: ViewEdits, order: WorkOrder, onField: (k: string, v
export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: ViewRegionProps) { export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: ViewRegionProps) {
const [view, setView] = useState<Tab>('open'); const [view, setView] = useState<Tab>('open');
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
/*
* **件内翻页**(2026-08-02 用户:「一个执行单可以派多个 HTML 待执行报告,
* 合理利用右上角的翻页和待处理的数字气泡」):件里带 `data-page` 的可见段
* 各算一页(A2 = 一颗待选型芯片一页)。单卡多页时,右上角那对箭头翻的是页。
*/
const [docPage, setDocPage] = useState(0);
const [docPages, setDocPages] = useState(1);
/** 工作成果:列表里点开的那份定稿报告 */ /** 工作成果:列表里点开的那份定稿报告 */
const [doneSel, setDoneSel] = useState<string | null>(null); const [doneSel, setDoneSel] = useState<string | null>(null);
const [openFile, setOpenFile] = useState<FileDoc | undefined>(undefined); const [openFile, setOpenFile] = useState<FileDoc | undefined>(undefined);
@@ -279,29 +286,56 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
<Ico name="chevron-left" s={14} /> 返回列表 <Ico name="chevron-left" s={14} /> 返回列表
</button> </button>
) : view === 'open' ? ( ) : view === 'open' ? (
<span className="jc-tnav-pg" aria-label="翻页"> /* 单卡多页时这对箭头翻**件里的页**(一颗芯片一页);多卡时照旧翻件 */
<button docPages > 1 && openList.length <= 1 ? (
type="button" <span className="jc-tnav-pg" aria-label="翻页">
className="jc-tnav-btn" <button
disabled={pos === 0} type="button"
onClick={() => setPage(pos - 1)} className="jc-tnav-btn"
aria-label="上一件" disabled={docPage === 0}
> onClick={() => setDocPage(docPage - 1)}
<Ico name="chevron-left" s={15} /> aria-label="上一页"
</button> >
<b className="jc-tnav-n jc-mono"> <Ico name="chevron-left" s={15} />
{openList.length === 0 ? 0 : pos + 1}/{openList.length} </button>
</b> <b className="jc-tnav-n jc-mono">
<button {docPage + 1}/{docPages}
type="button" </b>
className="jc-tnav-btn" <button
disabled={pos >= openList.length - 1} type="button"
onClick={() => setPage(pos + 1)} className="jc-tnav-btn"
aria-label="下一件" disabled={docPage >= docPages - 1}
> onClick={() => setDocPage(docPage + 1)}
<Ico name="chevron-right" s={15} /> aria-label="下一页"
</button> >
</span> <Ico name="chevron-right" s={15} />
</button>
</span>
) : (
<span className="jc-tnav-pg" aria-label="翻页">
<button
type="button"
className="jc-tnav-btn"
disabled={pos === 0}
onClick={() => setPage(pos - 1)}
aria-label="上一件"
>
<Ico name="chevron-left" s={15} />
</button>
<b className="jc-tnav-n jc-mono">
{openList.length === 0 ? 0 : pos + 1}/{openList.length}
</b>
<button
type="button"
className="jc-tnav-btn"
disabled={pos >= openList.length - 1}
onClick={() => setPage(pos + 1)}
aria-label="下一件"
>
<Ico name="chevron-right" s={15} />
</button>
</span>
)
) : null} ) : null}
</div> </div>
@@ -416,12 +450,21 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
) : view === 'done' ? ( ) : view === 'done' ? (
doneItem !== undefined ? ( doneItem !== undefined ? (
/* /*
* 定稿归档 = **同一份件,不加编辑态**(§9.3 第 2 条:在办与归档同一张模版)。 * **定稿归档 = 办结那一刻的件快照,1:1 静态重放**(2026-08-02 用户:
* 与待处理那一屏一样,这里也只放件本身 —— 判据留在对话区那张办结卡上, * 「HTML UI、数据、内容、布局都要 1:1 地从待处理状态转换为静态报告
* 归档之后「凭什么算过」比当时更常被问起,所以它跟着卡走,不跟着件走。 * 放到工作成果」—— 反复强调过)。
*
* 从前两条路都不对:验收单渲**当下**的任务视图(随后续推进还会变),
* 执行单干脆退回 WorkOrderReport 卡壳(题头/编号带/blocks)——
* 没有一样是人办结时看到的那份。现在办结那一刻基座把渲好的件
* 存进工单(`finalHtml`),这里原样静态渲它;老单没有快照的,
* 退最近一帧任务视图,再没有才退卡壳(历史兼容)。
*/ */
doneItem.mode === 'acceptance' && detail.viewHtml !== undefined ? ( doneItem.finalHtml !== undefined || detail.viewHtml !== undefined ? (
<ViewContainer html={detail.viewHtml} title={`${detail.card.activity} · 定稿报告`} /> <ViewContainer
html={doneItem.finalHtml ?? detail.viewHtml ?? ''}
title={`${detail.card.activity} · 定稿报告`}
/>
) : ( ) : (
<article className="jc-mst-doc"> <article className="jc-mst-doc">
<WorkOrderReport <WorkOrderReport
@@ -504,6 +547,8 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
<ViewContainer <ViewContainer
html={detail.viewHtml} html={detail.viewHtml}
title={`${detail.card.activity} · 汇报报告`} title={`${detail.card.activity} · 汇报报告`}
page={docPage}
onPages={setDocPages}
editable editable
onEdits={(save) => { onEdits={(save) => {
/* /*
@@ -0,0 +1,669 @@
<!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>替代选型工作单 · JCAI one-pager 目标件</title>
<style>
/* ⇩ standalone 包装层:真件跑在工作区 iframe 里,这几行由宿主环境承担。平移时丢弃 */
html, body { margin: 0; padding: 0; }
/* ⇧ 丢弃到此 */
/* ============================================================================
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; }
/* ① 空格子自己说话 —— 值真的空才轮到这三条(基座 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; }
/* ⇩ ONE_CSS · 本件自有补丁:平移时随模版走(补在 BIND_CSS 之后,只补不改) */
/* ⇧ ONE_CSS 到此 */
</style>
</head>
<body>
<!-- ⇩⇩ 模版片段从此开始:平移进 views/report.ts 的就是这一段(样式与精灵由 doc() 工厂另拼) -->
<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>
<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-git-branch"/></svg></div>
<div class="jc-rp-tit">
<em>统一报价 · A2 替代选型 · 执行工单</em>
<h3>替代选型工作单 <span>RFQ MD14.1</span></h3>
</div>
<span class="jc-rp-wait">在办 · 待选型 2 颗 · 已定案 1 颗</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>统一报价 · A2 替代选型 · 执行工单</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,669 @@
<!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>替代选型验收报告 · JCAI one-pager 目标件</title>
<style>
/* ⇩ standalone 包装层:真件跑在工作区 iframe 里,这几行由宿主环境承担。平移时丢弃 */
html, body { margin: 0; padding: 0; }
/* ⇧ 丢弃到此 */
/* ============================================================================
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; }
/* ① 空格子自己说话 —— 值真的空才轮到这三条(基座 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; }
/* ⇩ ONE_CSS · 本件自有补丁:平移时随模版走(补在 BIND_CSS 之后,只补不改) */
/* ⇧ ONE_CSS 到此 */
</style>
</head>
<body>
<!-- ⇩⇩ 模版片段从此开始:平移进 views/report.ts 的就是这一段(样式与精灵由 doc() 工厂另拼) -->
<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>
<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-shield-check"/></svg></div>
<div class="jc-rp-tit">
<em>统一报价 · A2 替代选型 · 验收报告</em>
<h3>替代选型验收报告 <span>RFQ MD14.1</span></h3>
</div>
<span class="jc-rp-wait">待放行 · 清单 5 条已转正</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>统一报价 · A2 替代选型 · 验收报告</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>
+65 -2
View File
@@ -22,8 +22,12 @@
三条硬规矩: 三条硬规矩:
1. **一单一模版贯穿始终**:同一个局面,在办与归档用同一张;局面变了换张 1. **每份件两态 1:1,不同工作各有各的件**(2026-08-02/03 用户两次校准):
(A1 三张:受理需求 → 料号分类 → 受理记录),不在一张里堆条件分支。 「1:1」的落点是**每份件与它的办结快照**(在办可编辑 ↔ 回执收下那一刻存
`finalHtml`,成果页静态重放,数据/内容/布局一个字不差)—— 不是全程只许一张。
作业的件(如 A2 选型 One Pager)与收口的件(整合清单)是两件工作两份件:
「清单是所有选型完成后收口才整合的东西」。别再把两态统一误执行成一张到底
(踩过:验收工单打开还是选型页,用户当场指出)。
2. **剧本每步的 `view` 定推哪张**,写清单时定死,不归模型临场挑;放行那一步的 2. **剧本每步的 `view` 定推哪张**,写清单时定死,不归模型临场挑;放行那一步的
`view` 必须指向验收目标件 —— 最后摆上去的那张就是归档件。 `view` 必须指向验收目标件 —— 最后摆上去的那张就是归档件。
3. **数据变了基座重渲**(repush):人在件上保存、在卡上回填,落草稿层后服务端拿 3. **数据变了基座重渲**(repush):人在件上保存、在卡上回填,落草稿层后服务端拿
@@ -180,6 +184,14 @@ A1 两份报告是 1:1 平移 PRD 目标件:样式表整段搬(`prd-style.ts`
## 8. 版式纪律 ## 8. 版式纪律
- **空占位三级统一**(2026-08-02 用户定,全局口径):**必填(红)· 建议填(橘)·
选填(灰)** —— 与声明的 `required` / `expect` / 其余一一对应,胶囊文案就写这三个词。
归档静态态它们如实呈现「没有」。
- **选择列直接选**(如 A2 替代等级):三枚自绘 radio(`input[type=radio].jc-cell[data-col]`,
radio 盖满选项格可点、`:has(:checked)` 高亮),**不写 checked** —— 当前值由
`data-class-of` 投影 v-* 描虚线;基座对未选中的 radio 整列不交(「没点」=「不改」)。
收值按「input 的祖父」归行:一张卡里所有输入格的祖父必须是同一个元素。
- **铺满工作区**:去掉外层宽度上限与横向留白、卸掉外框圆角边 —— 件就是这一屏的正文, - **铺满工作区**:去掉外层宽度上限与横向留白、卸掉外框圆角边 —— 件就是这一屏的正文,
不是嵌在里面的另一个页面(`jc-stage { max-width:none }`)。 不是嵌在里面的另一个页面(`jc-stage { max-width:none }`)。
- 必填/选填胶囊只在**在办态**摆;归档件一律 `dimempty`(那时空格的意思不再是 - 必填/选填胶囊只在**在办态**摆;归档件一律 `dimempty`(那时空格的意思不再是
@@ -187,6 +199,51 @@ A1 两份报告是 1:1 平移 PRD 目标件:样式表整段搬(`prd-style.ts`
- 别摆恒常说「没有」的横幅(「本轮不报 0 行:—」),别把同一个数摆两处 - 别摆恒常说「没有」的横幅(「本轮不报 0 行:—」),别把同一个数摆两处
(人得停下来对一遍两处一不一样);已在别处说过的结论不再复述一遍。 (人得停下来对一遍两处一不一样);已在别处说过的结论不再复述一遍。
- 标记里不写 `**` 星号这类记号 —— 卡面与件都是纯文本/纯 HTML,记号不会变粗体。 - 标记里不写 `**` 星号这类记号 —— 卡面与件都是纯文本/纯 HTML,记号不会变粗体。
- **概念图形是报告的第一语言**(A2 迭代周用户反复要「图形占比大一点」):
芯片小像、流光走线、引脚对照、漏斗 —— 内联线稿 SVG(stroke currentColor,
配色走样式表)把「这是件什么事」画出来;数据格是第二语言。一份让人愿意看的
报告先立视觉隐喻,再摆数。
- **收值三铁律**(件上就地改的结构约束,违者行互相覆盖或值丢失):
① 同一行所有输入格的**祖父必须是同一个元素**(collect 按祖父归行);
② 主键格必挂 `data-col`(行的钥匙,可以是隐藏格);
③ radio 不写 `checked`,「没点」=「不改」(当前值靠 `data-class-of` 投影描样式)。
## 8.5 一页汇报(slide 语言)—— 强交互全走纯 CSS
用户对 A2 选型件的要求(2026-08-02):「不够华丽,要强交互的汇报形式,
直接拿去当一页 PPT 的程度」。沙箱零脚本不变 —— **「强交互」全部由样式表承担**,
数据仍只经 `data-slot` / `data-class-of` 投影,动效是呈现,不是判断。
参考实现:`activities/quote-alternative/src/views/report.ts` 的 `ALT_WORKSHEET`。
**构图**:主视觉(hero)+ 编号小节(slide part)推进。
- **hero 深空底**:`radial-gradient` 两团光 + `linear-gradient` 深空 +
`::before` 电路点阵(radial-gradient 1px 点)+ `::after` 顶部一道光。
hero 内配色自成一组(亮字/暗丸/信标),别把 PRD 亮色变量直接用在深底上。
- **slide 小节头**(`jc-sp`):`01/02/…` 编号丸 + 题 + 一句注 + 延伸线 ——
编号是版面语言,不是数据。
- **逐段入场**(`jc-rise` + `.d1…d6` 延迟):`opacity/transform` 动画,
**全部包在 `@media (prefers-reduced-motion: no-preference)` 里**(连 `@keyframes`
一起包 —— 引用未定义的动画名等于没有动画,减弱设置下自然全静)。
- **流光走线**:两条同 path 的 SVG(`wire` 底线 + `pulse` 虚线),
`stroke-dasharray` + `stroke-dashoffset` 动画 + `drop-shadow` 发光。
- **悬停揭示**:卡片 hover 浮起(`transform + box-shadow` 过渡);
等级卡的口径段 `max-height:0 → hover/focus-within 展开`,加 `tabindex="0"`
让键盘也进得去;**触屏常开**(`@media (hover: none)` 里展开、藏掉「悬停展开」hint)。
- **空节自折**:整节只在有数据时出现 —— 包一层 wrapper,
`:not(:has(.行.v-xxx)) { display:none }`(如 nowwrap/donewrap)。
没有 NOW 行时「当前这一颗」整节(含小节头)不出,不留空壳。
**三条工程账**(都踩过):
1. **媒体查询要排在被它覆盖的基础规则之后** —— 同权重下后定义胜。
竖排断点里写了 `.jc-trace{display:none}`,而 `.jc-trace{display:flex}` 基础规则
定义在媒体块之后,于是竖排下走线照显:横着一根流光指向右边的空气。
2. **动效只动 `transform`/`opacity`/`box-shadow`**,别动布局属性;
`jc-rise` 会在每次 srcdoc 重渲后重播(保存一次播一次)—— 半秒内是 slide 感,
长了就是折磨,入场动画别超过 0.6s。
3. 队列丸/信标这类**状态灯**照旧走 `data-class-of`(`v-now` 信标呼吸、`v-done` 绿、
`v-wait` 暗)—— 「哪颗在办」是数据说的,动画只是把它说响一点。
## 9. 自测与浏览器走查 ## 9. 自测与浏览器走查
@@ -208,6 +265,9 @@ A1 两份报告是 1:1 平移 PRD 目标件:样式表整段搬(`prd-style.ts`
|---|---|---| |---|---|---|
| 点「编辑」毫无反应,一切选择器都对 | `instanceof Element` 跨 realm 恒 false;或监听挂在 about:blank 上 | 认能力不认血统;幂等重挂(§3) | | 点「编辑」毫无反应,一切选择器都对 | `instanceof Element` 跨 realm 恒 false;或监听挂在 about:blank 上 | 认能力不认血统;幂等重挂(§3) |
| 保存时件「闪黑」一下 | srcDoc 换文档整页重载 | 就地换 body.innerHTML(§3) | | 保存时件「闪黑」一下 | srcDoc 换文档整页重载 | 就地换 body.innerHTML(§3) |
| 就地换了还是「闪一下」(2026-08-03 再报) | innerHTML 仍是**整树重建**:47KB 样式在 body 再解析一遍(head 那份还在,一文档两份)、SVG 全重建、CSS 动画回第 0 帧重起 | 第二层:逐节点 morph(`ViewContainer.replaceInPlace`)—— 一样的节点不碰,样式归位 head 去重,涟漪只落在真改了的格子上;任何一步栽了退回整树重建(闪但不错) |
| **保存后旧值还摆着一会儿**(真正让人以为没生效的那个) | 保存要跑个来回(POST 落草稿 → 失效 → 重取详情 → 新件推回),而闸在按下那一刻就关了 —— 这段时间屏上是只读那份的**旧值** | 按下保存**先把新值写进只读孪生件**再关闸(`mirrorTwins`),回声到达时 morph 一比已经一样;只镜像格子自己的值,红带/分布条/去向等基座算的数一律等回声 |
| 镜像/取值找不到孪生件而无声失效 | 渲染那一遍把 `data-slot` **连同槽位一起消费掉**,活 DOM 里两份都没有这个属性 | 认**位置**不认属性:`twinInputs` 把 input 紧接着只读那份写出,`previousElementSibling` 就是孪生件;**验证要在真单的 DOM 上做** —— 合成 fixture 里两份都写了 `data-slot`,照样绿 |
| 人保存了,一格都没落库 | 保存借道当下那张卡的 blocks | 按 `data-edit` 地址收,授权=台账声明(§2) | | 人保存了,一格都没落库 | 保存借道当下那张卡的 blocks | 按 `data-edit` 地址收,授权=台账声明(§2) |
| 改了类标,分布条/去向不动 | 汇总数是自报的没人重算;或没 repush | 分类列标 `tally` 让基座数;保存后必 repush(§1) | | 改了类标,分布条/去向不动 | 汇总数是自报的没人重算;或没 repush | 分类列标 `tally` 让基座数;保存后必 repush(§1) |
| 纯色块整条灰着像没算出来 | `data-class-of` 曾以 `data-slot` 为前提 | 已修;自测钉住 | | 纯色块整条灰着像没算出来 | `data-class-of` 曾以 `data-slot` 为前提 | 已修;自测钉住 |
@@ -216,3 +276,6 @@ A1 两份报告是 1:1 平移 PRD 目标件:样式表整段搬(`prd-style.ts`
| 报告与卡说的判据不一致 | 两处各算各的 | 全部读基座读数,同源(§2) | | 报告与卡说的判据不一致 | 两处各算各的 | 全部读基座读数,同源(§2) |
| 归档件上还有编辑闸/胶囊 | 闸没锁在 `editable` 根类下;胶囊没分态 | §6-1、§8 | | 归档件上还有编辑闸/胶囊 | 闸没锁在 `editable` 根类下;胶囊没分态 | §6-1、§8 |
| 版面「像又不像」目标件 | 用拼装件照着做而不是搬 | §7;截图比对 | | 版面「像又不像」目标件 | 用拼装件照着做而不是搬 | §7;截图比对 |
| 人保存后一行的值被另一行盖掉 | **主键格没挂 `data-col`** —— 收上去的行没有钥匙,服务端按「全表一致列」补键,多行对到同一行 | 主键列必挂 `data-col`(它不是编辑面,是行的钥匙);e2e 走 API 自带主键测不出,只有件上真按保存才现形(A2 走查逮过) |
| 逐行卡片克隆成套娃(第二行长进第一行里) | 渲染器按**第一个同名闭合标签**截行内容 —— `<div data-rows>` 的行模版里嵌了别的 `<div>`,行在半截被断开 | **行容器的标签名不得在行模版内部再出现**:容器换 `<section>`/`<tbody>` 这类行内不会嵌的标签(A2 选型档案卡逮过) |
| 空格胶囊比同行的数字**往下坠几像素** | 裸 inline-flex 胶囊按 baseline 挂:盒(字 + 内距 + 边框)比字的基线深,行高也随之忽高忽低 | 空格子(`:empty`)转 flex + `align-items:center` + `min-height` 撑住文字那份盒高,胶囊中线对数字中线(A1 逮过,2026-08-03) |
+165
View File
@@ -787,3 +787,168 @@ JS 拼的 class 与 `style="flex:N"`→`data-class-of`)。规范 §10.3.1 已立
`customer.contact`(主数据)里,可老客户不重写主数据、本单草稿里根本没有那一行; `customer.contact`(主数据)里,可老客户不重写主数据、本单草稿里根本没有那一行;
顶层裸键又与 `customer.profile.name` 撞名(后写的赢)。 顶层裸键又与 `customer.profile.name` 撞名(后写的赢)。
**一张写着公司名的联系人卡比空着更坏**,所以跟 `customer_name` 同一笔写下。 **一张写着公司名的联系人卡比空着更坏**,所以跟 `customer_name` 同一笔写下。
## 算出来的口只在前端算数(2026-08-02,A2 e2e 逼出来的,已销)
A2 第一轮端到端:七拍零被拒、去向已算出 `to-sourcing`、验收 pass ——
死在最后一步:「放行未确认分支结论:选择组 alt-route 未选出口」,台账已合并、单落 `blocked`。
查下来是 2026-08-01 那次「routedPort 那一路 selectedPorts 恒空」的修法**只修了一半**:
补的是**前端**(chat-bubbles 回传时把算出的口塞进 `selectedPorts`)与
**服务端返回值**(human-tool-service 的 outcome)。而放行落地读的是第三样东西 ——
**人事件载荷**(`publishHumanEvent({ payload: { ...reply } })`,原始回传),
那一份谁都没动。于是浏览器活着(它自带口)、返回值好看(没人读它),
换个客户端(脚本、API 调用)就死在落地,而服务端注释还写着
「人回传里带不带都不作数」—— **注释描述的行为在引擎读的那条路上从来不成立**。
修法:`replyWorkOrder` 在发事件**之前**算好有效口(routedPort 优先、算不出才认人选),
事件载荷与返回值用同一份。**服务端说了算的事,不靠每个客户端自觉** ——
前端那段照旧发,发什么都会被有效口盖掉。
顺带两条实测账(代答脚本要知道的):
- 执行卡 `fields` 缺席时基座兜底加一枚**必填**「结果」回执字段(`fillCard`),
交卷载荷要带 `values.result`,否则「必填字段未回填:结果」;
- A3 的提交闸第一次真拦人:单飞归一出两颗料、脚本只填一颗,交卷被点名
「还有 1 条必填没填 —— SRC-…/XC6206P332MR 缺底价」—— 与件上红带、放行门 L1
同一个数同一套算法,拦的位置和话术都对。
## 2026-08-02 · A2 判稳该 decisionTask 化 —— 说明书拦不住「不查工具直接编」
单飞案例连续两轮走岔:S3 判稳一拍,模型**一次都没调** `crm.history-price-read`,
直接给替代料写「历史 43 天·行情稳 → 转 A」(数是编的),去向从 to-sourcing
岔成 to-pricing。说明书已写到头(「判稳的行情只有一条来路 —— 这一拍亲自查
alt_pn」「不许拿上游的数顶替」都在),流内两案例被这些话治住了,单飞治不住:
没有上游数可抄时它就自己编,措辞层面无解。
**该走的路**:判稳是死规则(≤90 天且稳 → A,否则 → B)+ 机器可得的入参
(工具出参)—— 「能算的不问 LLM」,这一步本该像 A1 的 S5 那样交给引擎:
① 判稳规则进 DMN 表(`ALT_DMN` 现在是空的);② S3 改 decisionTask 或
第一方工具出参直接带 `type_tag`/`basis`(模型只照抄,抄错有对号检查);
③ 收步判据加「凭据齐」(原 C 行 `history_days` 非空 —— 需要 `requiredWhen`
或「非空行数」读数承载)。
**现状兜底没断**:验收页 `editableAtRelease` 含 `type_tag`,人改标去向即变
(routedPort 读 tally 现算)—— AI 第一稿错,人签字前看得见改得动。
## 2026-08-02 · 入场哨兵读自报列 —— A2/A3 也有同一条缝
A1 踩实的病(用户实测单 584655b6,humanCalls 4/4 耗尽 exhausted):`s_parsed` 读
模型自报的 `line_total`,模型把 record 三行在 S1 拍写完、没记这个数,S2 空手收步 ——
门永不开,引擎报「依赖断点」,提示词「断点请调人」把模型推去发**补全卡**绕路,
两张多余的卡烧光人环预算。已修:s_parsed 改读基座数的 `quote_intake_record_rows`
(A1@46.0.0),该形态复跑 released、被拒 0、断点 0、补全卡 0。
**同一条缝还在 A2/A3 的入场哨兵里**(尚未咬人,记账待修):
- A2 `s_normalized` 读自报 `alt_line_total`、`s_filled` 读自报 `filled_total`;
- A3 对应哨兵同款。
S1 的 guide 都教了「第一件事写这个数」,所以节奏对时不踩;但 A1 证明了
节奏是模型抖出来的,不是承诺。照 A1 的方子换成 `_rows` 类基座读数即可
(s_filled 这种「人环收回」型要另想承载 —— filled_total 是「答复处理过」的记账,
基座数不出来,或可读 DONE 计数 `_sel_stage_done`)。
## 2026-08-02 · 补全卡退役的扫尾
行为已全局退役(归一层 ask→零输入执行工单;引擎直发拒;提示词/规范/三活动清理)。
留着的旧物,后续轮清:
- 契约类型 `ChatCard` 的 elicitation 分支与 `checkChatCard`/`checkChatCardReply`
的对应校验(历史卡回放还要认,删除走三步弃用);
- 前端补全卡渲染(历史对话里旧卡要能显示);
- `process.start` 起单缺 required 顶层键时发补全卡的老路 —— 三活动 required
全空后实际不触发,彻底移除时一并清。
## 2026-08-02 · A2 已落的两条全局口径,A1/A3 待跟进
- **一张模版贯穿到底**(用户:「待处理的 HTML 报告完成后直接转变为静态的工作成果」):
A2 已并成一张(alt-roster 退役);A1(四张按局面换)与 A3(worksheet/roster 两张)
仍是旧制,按 A2 的样子归一。
- **空占位三级统一**(必填红/建议填橘/选填灰):A2 已上;~~A1~~(2026-08-03 已上:
品类/制造商/目标价按声明的 expect 标橘「建议」,quote-intake@48.0.0,用户点的是目标价)/
A3 的占位词还是旧两级(待填红/选填灰),跟着声明的 required/expect 换新三级。
## 2026-08-03 · A4 补齐时发现、没动的两处共享面
- **放行门拒回仍会指向两扇打不开的门**:`_rows > 0` 但某个 `tally` 列一格没填时,
拒回只给「改数 / 改行」两条路(而两者都对),模型原样重提到熔断。
基座那条「`_rows = 0` → 直说那一步还没办」只覆盖了「一行都没写」,
没覆盖「行写了但分类/处置列没填」。**拒回的话是下一拍的行动指南**(已立纪律),
该按「哪一列还空着」点名。A4 靠独立成项 `S3_gate` 绕过去了,病根还在。
- **单飞造号会借用正式表里别单的单号**(第三次现形:A2 借走 MD14.1、A4 借走 RFQ-2026-0777)。
A2/A3/A4 三处都只靠说明书措辞防守(「只从本单来件取材」「必须 PRC- 开头」)。
**措辞治不了的结构治**(设计律第六条):造号该由基座给(同组主键 + 序号,
与 `FieldDeclaration.auto` 同一条路),不该让模型自己编。
~~**决策卡没有幂等兜底**~~(2026-08-03 已修:`resendVerdict` 抽成纯函数,
执行工单与决策卡同一把尺,头两次无事发生、第三次起算被拒,6 测钉死)。
## 2026-08-03 · A6 补齐时发现、没动的三处基座欠账
- **`listen` 动作在引擎里恒挂起**:`turn-runner` 对 `listen` 返回 `awaiting`,要外部
data/time 事件才唤得醒,而 desk 侧**没有任何路由能把 data 事件投给指定任务** ——
「等客户回音」这类等外部的活,活动只能落在执行工单上(人收到信、把回音填进件里),
时限那一侧靠 `declarations.listeners` 静态监听。A6 已按这条口径建模并有测钉死
(剧本不许教 `listen`)。要让 `listen` 真能用,得补一条「把外部事件投给某任务」的入口。
- **依赖断点那几拍,全局提示词只说「请调人或立监听」**,而当下可为为空时模型手上
只剩计划项的**中文名** —— 它会拿中文名当 `itemId`(实测连拒两拍)。活动侧只能靠
说明书兜(A6 把 itemId / ask / 选项值写死进说明书)。**治法在基座**:断点提示里
把可用项的英文 id 一并摆出来。
- **`mail.send` 至今没有活动敢用**:写类工具放行落地要过「入参 schema + `isInvocable`
+ 目标预校验」三道,而治理台白名单授权口仍挂在 §15⑤。A6 `tools: []`,
报价函靠执行单上那句话交给人发 —— 现状如实,但「谁真发了出去」系统并不知道。
## ~~2026-08-03 · A2 判稳偶发不查工具~~(当日已结构治)
**症状**:A2 单飞那条案例约十次一次 —— 替代料没查行情就判「稳」转 A,
去向从 `to-sourcing` 走成 `to-pricing`(实测 2026-08-03 全量回归一次)。
说明书已经写到头(「判稳的行情只有这一条来路 —— 这一拍亲自查 alt_pn」
「不许传原 PN」「不许拿上游带进来的数顶替」),**措辞治不了的结构治**(设计律第六条)。
**可行治法**(基座已具备一半):工具出参本来就入痕 ——
`turn-runner` 每次调完写一条 `kind: 'result'` / 标题 `${tool} 返回` / detail 带 output。
所以「这一单到底调没调 `crm.history-price-read`」是**基座数得出来的事实**,
不必让活动自报。补一类判据谓词(如 `tool_called_crm_history_price_read > 0`,
与 `域_对象_rows` 同族的读数),A2 就能立一条 L2:
**「转 A 的依据来自本单亲查的行情」** —— 机检只读基座数,这条纪律第一次真正覆盖到工具。
**当下兜底**(已在):验收页 `type_tag` 可改、去向跟着变;人在放行那一屏能纠。
**~~已落地~~**(2026-08-03 当日):基座新增读数 `tool_<工具名去符号>_calls`
(`toolCallReadings`,按留痕数成功返回的次数,声明过的工具一律先铺 0;
调失败不算查过 —— 调不通等于没拿到行情)。静态审与包内自测的「谓词绑得上」
名单同批扩到这一类。A2@4.14.0 立 L2:
`quote_alternative_list_type_tag_a = 0 or tool_crm_history_price_read_calls > 0`
——**写成析取**:一颗都没转 A 就无需查行情(全 B 那条路合法),
有 A 才必须有过这一次亲查。**「机检只读基座数」这条纪律第一次覆盖到工具**。
## 2026-08-03 · A2 常路案例会**跳过派给 FAE 的那一步**(未修,已定位)
**症状**:`a2` 案例①(两颗 C · 替代无历史)连续三轮 6 拍收口,**一张执行工单都没发**——
轨迹里模型自称「2 颗替代料都查过行情、都转成 B 类」:替代料是它自己填的,
FAE 从头到尾没被叫过,单子照样 released(去向还对)。案例②③ 正常派单(7 拍)。
**病根**:S2「派选型单」的 `doneWhen` 认的是**件上那几格填没填**
(`alt_pn/alt_brand/alt_grade` 由 requiredWhen 钉在 orig_type=C 行)——
而这几格模型自己也写得进去。它一写,收步条件当场成立,引擎就把这一步收了,
**人环整个消失**。这正是「凡触达世界的项一律不给 `doneWhen`」那条纪律的反面教材:
「把清单交给 FAE 选型」触达的是人,它的完成与否不该由数据形状推出来。
同时它也踩了 `no-fake-alt` 禁令(没把握的替代不该编一个顶上)。
**治法**(与 T_release 同款,未施工):S2 改 **人工激活 + 不给 doneWhen** ——
派单那一下由模型显式做,收步由人交回触发;并在剧本里写死「替代料三列一格都不许自己填」。
**验证口径**:`a2` 三案例都必须出现「件上保存 / 交卷执行卡」两行,6 拍收口即判失败。
**排除项**:不是 2026-08-03 那条工具留痕判据引起的 —— 撤掉判据(@4.15.0)后原样复现。
## 2026-08-03 · 件上保存偶发不落那一格(间歇,两处独立撞见)
**症状**:`a1` 案例把 `DAG2400L321504XZB01-R0` 的年用量存进件上(`POST /tasks/:id/table`),
返回 200,而紧接着的提交闸仍报「缺年用量 EAU」,连拦三次判失败;
同一条案例前后几轮都正常。A6 那一轮独立撞见**同一行同一列**的同款症状。
**已排除**:不是提交闸失效(拿单直接空手交卷实测吃 400,且点名缺哪一行哪一格);
不是那一轮的说明书改动(A1@49.x 只改措辞,保存走的是基座那条链)。
**下一步怎么查**:保存那一下服务端按「收件自己说的话」(`data-edit` 地址 + `data-col` 列名)
落草稿层,主键靠行内 `data-col` 的主键格给出 —— 怀疑点在**主键补齐**那一段
(A2 走查逮过一次「主键格没挂 data-col → 服务端按全表一致列补键 → 两行对到同一行」)。
复现要碰运气,建议下次带着「保存请求体 + 落库后草稿行」两份留痕对着看。
@@ -0,0 +1,780 @@
<!doctype html>
<!-- JCAI-WOS 产品汇报 · 讲解用 slide 件(浏览器全屏即放映;滚轮/PageDown 翻页;#s0-#s14 锚点直达) -->
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>JCAI-WOS · 产品设计与系统机制</title>
<style>
:root {
--brand: #3370ff; --deep: #245bdb; --violet: #7f3bf5;
--grad: linear-gradient(135deg, #3370ff, #7f3bf5);
--ink: #1f2329; --ink2: #41464e; --ink3: #646a73; --ink4: #8f959e;
--line: #dee0e3; --soft: #f8f9fb; --wash: #f0f4ff; --brandsoft: #e1eaff;
--ok: #278717; --oksoft: #e8f8e4; --warn: #ff8800; --warnsoft: #fff4e5;
--danger: #d83931; --dangersoft: #feeae9;
--mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
--night: #0b1020; --night2: #121a33;
}
* { box-sizing: border-box; }
html { scroll-snap-type: y proximity; }
html, body { margin: 0; padding: 0; }
body {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
color: var(--ink); background: #e9edf3; -webkit-font-smoothing: antialiased;
line-height: 1.6;
}
.slide {
min-height: 100vh; scroll-snap-align: start; position: relative;
display: flex; flex-direction: column; justify-content: center;
padding: 64px clamp(28px, 6vw, 96px) 72px; background: #fff; overflow: hidden;
}
.slide + .slide { border-top: 1px solid var(--line); }
.pg {
position: absolute; right: 26px; bottom: 20px; font-size: 12px; color: var(--ink4);
font-family: var(--mono); letter-spacing: .08em;
}
.kicker {
display: inline-flex; align-items: center; gap: 8px;
font-size: 13px; font-weight: 700; color: var(--brand); letter-spacing: .18em; margin-bottom: 10px;
}
.kicker i { width: 22px; height: 2px; background: var(--grad); border-radius: 2px; }
h1 { font-size: clamp(28px, 3.2vw, 42px); line-height: 1.25; margin: 0 0 10px; letter-spacing: .01em; }
h2.sub { font-size: clamp(14.5px, 1.4vw, 18px); font-weight: 500; color: var(--ink3); margin: 0 0 24px; max-width: 66em; }
h2.sub b { color: var(--ink); }
.dia { width: 100%; max-width: 1120px; margin: 4px auto 0; display: block; }
.note { max-width: 1120px; margin: 16px auto 0; font-size: 13.5px; color: var(--ink3); }
.note b { color: var(--ink2); }
.center { text-align: center; }
/* 深空封面 / 收尾 */
.hero { background: radial-gradient(1200px 700px at 70% -10%, #1d2a55 0%, var(--night2) 45%, var(--night) 100%); color: #eef2ff; }
.hero .kicker { color: #9db4ff; }
.hero h1 { font-size: clamp(38px, 5vw, 64px); }
.hero .sub { color: #b9c4e6; }
.hero .pg { color: #5a6690; }
.threeword { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 34px; }
.threeword span {
padding: 12px 22px; border-radius: 999px; font-size: clamp(15px, 1.6vw, 19px); font-weight: 700;
border: 1px solid rgba(157, 180, 255, .35); background: rgba(51, 112, 255, .12);
}
.threeword span b { color: #9db4ff; font-weight: 800; }
.heroline { position: absolute; inset: 0; pointer-events: none; opacity: .5; }
.beacon { animation: pulse 2.8s ease-in-out infinite; transform-origin: center; }
@keyframes pulse { 0%,100% { opacity: .9 } 50% { opacity: .35 } }
@media (prefers-reduced-motion: reduce) { .beacon { animation: none } }
/* 解释卡 */
.cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; max-width: 1120px; margin: 18px auto 0; }
.cards.c2 { grid-template-columns: repeat(2, 1fr); }
.cards.c4 { grid-template-columns: repeat(4, 1fr); }
@media (max-width: 980px) { .cards, .cards.c4 { grid-template-columns: 1fr 1fr; } }
.card { border: 1px solid var(--line); border-radius: 12px; padding: 14px 18px; background: #fff; }
.card.tint { background: var(--soft); }
.card h4 { margin: 0 0 7px; font-size: 14.5px; display: flex; align-items: center; gap: 8px; }
.card h4 em { font-style: normal; font-size: 11px; font-weight: 800; padding: 2px 8px; border-radius: 6px; background: var(--wash); color: var(--deep); letter-spacing: .04em; flex: none; }
.card h4 em.g { background: var(--oksoft); color: var(--ok); }
.card h4 em.o { background: var(--warnsoft); color: #b25f00; }
.card h4 em.r { background: var(--dangersoft); color: var(--danger); }
.card p { margin: 0; font-size: 13px; color: var(--ink3); line-height: 1.75; }
.card p b { color: var(--ink2); }
.card p code { font-family: var(--mono); font-size: 11.5px; background: var(--soft); border: 1px solid var(--line); border-radius: 5px; padding: 1px 5px; color: var(--ink2); }
/* 芯片 */
.chips { display: flex; gap: 10px; flex-wrap: wrap; margin: 14px 0 6px; }
.chip {
display: inline-flex; align-items: center; gap: 7px; padding: 7px 14px; border-radius: 9px;
font-size: 13.5px; font-weight: 700; border: 1px solid var(--line); background: var(--soft); color: var(--ink2);
}
.chip.b { border-color: var(--brandsoft); background: var(--wash); color: var(--deep); }
.chip.g { border-color: #bfedb4; background: var(--oksoft); color: var(--ok); }
.chip.o { border-color: #ffd9a8; background: var(--warnsoft); color: #b25f00; }
.duo { display: grid; grid-template-columns: 1fr 1fr; gap: 26px; max-width: 1120px; margin: 0 auto; }
@media (max-width: 900px) { .duo { grid-template-columns: 1fr; } }
/* 判据梯(S8) */
.gates { max-width: 1120px; margin: 10px auto 0; display: flex; flex-direction: column; gap: 8px; }
.gate { display: grid; grid-template-columns: 92px 1fr 300px; gap: 14px; align-items: center;
border: 1px solid var(--line); border-radius: 11px; padding: 10px 16px; background: #fff; }
.gate .lv { font-family: var(--mono); font-weight: 800; font-size: 13px; }
.gate .lv small { display: block; font-family: inherit; font-weight: 700; font-size: 11px; color: var(--ink4); }
.gate .what { font-size: 13.5px; color: var(--ink2); }
.gate .what b { color: var(--ink); }
.gate .how { font-size: 12px; color: var(--ink4); text-align: right; }
.gate.m { border-left: 4px solid var(--brand); }
.gate.d { border-left: 4px solid var(--warn); }
.gate.h { border-left: 4px solid var(--ok); }
/* 三态样卡 */
.tri { display: flex; gap: 10px; flex-wrap: wrap; max-width: 1120px; margin: 14px auto 0; }
.tri span { display: inline-flex; align-items: center; gap: 8px; padding: 8px 14px; border-radius: 9px; font-size: 12.5px; font-weight: 700; border: 1px solid; }
.tri .ok { color: var(--ok); background: var(--oksoft); border-color: #bfedb4; }
.tri .amb { color: #8a5a00; background: var(--warnsoft); border-color: #ffd9a8; border-left: 4px solid var(--warn); }
.tri .no { color: var(--danger); background: var(--dangersoft); border-color: #f3b6b3; }
/* 前后对比(S13) */
.delta { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; max-width: 1120px; margin: 14px auto 0; }
@media (max-width: 980px) { .delta { grid-template-columns: 1fr; } }
.dcard { border: 1px solid var(--line); border-radius: 14px; padding: 18px 20px; background: var(--soft); }
.dcard .row { display: flex; align-items: baseline; gap: 10px; row-gap: 4px; flex-wrap: wrap; font-family: var(--mono); }
.dcard .row s { color: var(--ink4); font-size: 14.5px; text-decoration-thickness: 2px; white-space: nowrap; }
.dcard .row b { font-size: 22px; color: var(--deep); white-space: nowrap; }
.dcard .row i { font-style: normal; color: var(--ink4); font-size: 14px; }
.dcard h4 { margin: 0 0 8px; font-size: 14.5px; }
.dcard p { margin: 8px 0 0; font-size: 12.5px; color: var(--ink3); line-height: 1.7; }
/* 证据面板 */
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; max-width: 1120px; margin: 8px auto 0; }
@media (max-width: 900px) { .stats { grid-template-columns: 1fr 1fr; } }
.stat { border: 1px solid rgba(157,180,255,.25); background: rgba(51,112,255,.08); border-radius: 14px; padding: 18px 20px; }
.stat b { display: block; font-size: clamp(26px, 2.8vw, 38px); line-height: 1.2; color: #fff; font-family: var(--mono); }
.stat span { font-size: 13px; color: #b9c4e6; }
.stat em { font-style: normal; color: #9db4ff; }
/* SVG 通用 */
svg text { font-family: inherit; }
.t { font-size: 14px; fill: var(--ink); font-weight: 700; }
.t2 { font-size: 12.5px; fill: var(--ink3); }
.t3 { font-size: 11.5px; fill: var(--ink4); }
.tb { font-size: 13px; fill: var(--deep); font-weight: 700; }
.tm { font-family: var(--mono); font-size: 11.5px; fill: var(--ink3); }
.tw { font-size: 13px; fill: #fff; font-weight: 700; }
.tr { font-size: 12px; fill: var(--danger); font-weight: 700; }
</style>
</head>
<body>
<!-- ═══ S0 封面 ═══ -->
<section id="s0" class="slide hero">
<svg class="heroline" viewBox="0 0 1440 800" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<g fill="none" stroke="#33427a" stroke-width="1">
<path d="M-20 620 H 320 L 380 560 H 700"/><path d="M-20 690 H 240 L 300 750 H 620"/>
<path d="M760 80 H 1050 L 1110 140 H 1460"/><path d="M900 30 H 1180 L 1240 -30"/>
<path d="M1460 640 H 1160 L 1100 700 H 840"/>
</g>
<g fill="#3370ff">
<circle class="beacon" cx="320" cy="620" r="4"/><circle class="beacon" cx="700" cy="560" r="3" style="animation-delay:.9s"/>
<circle class="beacon" cx="1050" cy="80" r="4" style="animation-delay:.5s"/><circle class="beacon" cx="840" cy="700" r="3" style="animation-delay:1.4s"/>
</g>
</svg>
<div style="position:relative">
<span class="kicker"><i></i>JCAI-WOS · 产品汇报 · 2026-08</span>
<h1>企业级 AI Native<br>任务操作系统</h1>
<h2 class="sub" style="max-width:50em">把组织的工作定义为可编排的工作流;工作流由「活动」编排而成 ——
活动是完成一项任务的 Agent 机制,<b style="color:#fff">系统外开发、安装即用</b>。<br>
本篇讲三件事:产品怎么设计的 · 系统靠什么机制立住 · 已经跑出了什么。</h2>
<div class="threeword">
<span><b>确定</b>&#8201;交系统</span>
<span><b>临场</b>&#8201;交 AI</span>
<span><b>落地</b>&#8201;交人</span>
</div>
</div>
<span class="pg">JCAI-WOS</span>
</section>
<!-- ═══ S1 为什么做 ═══ -->
<section id="s1" class="slide">
<span class="kicker"><i></i>01 · 为什么做</span>
<h1>企业落 AI,现有三条路各断在一处</h1>
<h2 class="sub">不是没人试过让「系统 + 人 + AI」干活 —— 而是每条路都把三种成分<b>混在一个执行者身上</b>,断点因此结构性地无解。</h2>
<div class="cards">
<div class="card tint">
<h4><em>路线一</em>传统流程系统 / BPM</h4>
<p>把每一步写死在代码里。<b>断点:变更成本</b> —— 业务改一处,流程改一版,排期以月计;
长尾业务永远排不上,例外全靠线下走,系统里的流程和真实的干法渐行渐远。
它把「临场」也当成了「确定」来编。</p>
</div>
<div class="card tint">
<h4><em>路线二</em>人肉 + IM + 表格</h4>
<p>靠资深员工的手感把活串起来。<b>断点:不可见、不可度量</b> —— 单子在谁手里、卡在哪一步,
管理者看不见;判断标准在人脑子里,人一走经验清零。
它把「确定」也交给了「临场」。</p>
</div>
<div class="card tint">
<h4><em>路线三</em>裸大模型 / 通用 Agent</h4>
<p>能说会写,演示惊艳。<b>断点:不可控、无责任</b> —— 没有授权边界(什么都能碰),
没有责任交割(错了没人签过字),关键数字可能是编的。
企业敢让它聊天,不敢让它办事。它把「落地」也托付给了「临场」。</p>
</div>
</div>
<svg class="dia" viewBox="0 0 1120 170" style="margin-top:22px">
<defs><marker id="m0" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="#3370ff"/></marker></defs>
<g fill="none" stroke="#c9d6ff" stroke-width="1.8">
<path d="M186 30 C 186 80, 400 80, 520 96" marker-end="url(#m0)" stroke="#3370ff"/>
<path d="M560 30 V 88" marker-end="url(#m0)" stroke="#3370ff"/>
<path d="M934 30 C 934 80, 720 80, 600 96" marker-end="url(#m0)" stroke="#3370ff"/>
</g>
<g class="t2" text-anchor="middle">
<text x="186" y="20">确定性 · 从路线一继承</text>
<text x="560" y="20">临场力 · 从路线三继承</text>
<text x="934" y="20">责任制 · 从路线二继承</text>
</g>
<rect x="380" y="96" width="360" height="54" rx="27" fill="#f0f4ff" stroke="#3370ff" stroke-width="1.6"/>
<text x="560" y="122" text-anchor="middle" class="t" style="font-size:16px">JCAI-WOS:按成分拆开,各归其位</text>
<text x="560" y="141" text-anchor="middle" class="t3">确定交系统 · 临场交 AI · 落地交人 —— 边界由机制立死,不靠自觉</text>
</svg>
<span class="pg">01 / 14</span>
</section>
<!-- ═══ S2 核心命题:三分法 ═══ -->
<section id="s2" class="slide" style="background:var(--soft)">
<span class="kicker"><i></i>02 · 核心命题</span>
<h1>把一件工作拆开,答案就清楚了</h1>
<h2 class="sub">以一封客户询价邮件为例:同一单工作在三条泳道间流转,每一段都落在最擅长它的执行者手里 —— <b>谁该做什么,机制说了算</b>。</h2>
<svg class="dia" viewBox="0 0 1120 400">
<defs><marker id="m1" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="#3370ff"/></marker></defs>
<rect x="120" y="16" width="990" height="112" rx="12" fill="#f0f4ff"/>
<rect x="120" y="140" width="990" height="112" rx="12" fill="#f5f1ff"/>
<rect x="120" y="264" width="990" height="112" rx="12" fill="#eef8ea"/>
<g>
<text x="16" y="58" class="t">确定的部分</text><text x="16" y="80" class="t2">交系统</text>
<text x="16" y="182" class="t">临场的部分</text><text x="16" y="204" class="t2">交 AI</text>
<text x="16" y="306" class="t">落地的部分</text><text x="16" y="328" class="t2">交人</text>
</g>
<g class="t3">
<text x="1096" y="46" text-anchor="end">规则写成判定表 · 门与闸把关口</text>
<text x="1096" y="66" text-anchor="end">同样输入,必得同样结论</text>
<text x="1096" y="170" text-anchor="end">读懂来件 · 归一数据 · 编排步骤</text>
<text x="1096" y="190" text-anchor="end">每一步留痕,可回核</text>
<text x="1096" y="294" text-anchor="end">责任点与放行点</text>
<text x="1096" y="314" text-anchor="end">字签下去,世界才改变</text>
</g>
<g fill="none" stroke="#3370ff" stroke-width="2.2">
<path d="M198 196 H 300" marker-end="url(#m1)"/>
<path d="M400 176 C 430 120, 450 96, 486 76" marker-end="url(#m1)"/>
<path d="M600 76 C 640 120, 650 260, 690 300" marker-end="url(#m1)"/>
<path d="M806 320 H 890" marker-end="url(#m1)"/>
<path d="M994 300 C 1020 260, 1024 236, 1028 210" marker-end="url(#m1)"/>
</g>
<g>
<g><rect x="140" y="172" width="58" height="48" rx="9" fill="#fff" stroke="#c9d6ff"/><path d="M152 186 h34 v22 h-34 z M152 186 l17 12 17-12" fill="none" stroke="#3370ff" stroke-width="1.8"/><text x="169" y="238" text-anchor="middle" class="t3">来件</text></g>
<g><rect x="300" y="172" width="104" height="48" rx="9" fill="#fff" stroke="#c9d6ff"/><text x="352" y="192" text-anchor="middle" class="tb">AI 归一</text><text x="352" y="210" text-anchor="middle" class="t3">拆成受理清单</text></g>
<g><rect x="486" y="52" width="116" height="48" rx="9" fill="#fff" stroke="#c9d6ff"/><text x="544" y="72" text-anchor="middle" class="tb">判定表分级</text><text x="544" y="90" text-anchor="middle" class="t3">A / B / C 三分类</text></g>
<g><rect x="690" y="288" width="116" height="48" rx="9" fill="#fff" stroke="#bfe3b2"/><text x="748" y="308" text-anchor="middle" style="font-size:13px;font-weight:700" fill="#278717">人审定放行</text><text x="748" y="326" text-anchor="middle" class="t3">就地改 · 签字</text></g>
<g><rect x="890" y="288" width="104" height="48" rx="9" fill="#fff" stroke="#bfe3b2"/><text x="942" y="308" text-anchor="middle" style="font-size:13px;font-weight:700" fill="#278717">落账发出</text><text x="942" y="326" text-anchor="middle" class="t3">此刻才算数</text></g>
<g><rect x="1006" y="164" width="88" height="46" rx="9" fill="#fff" stroke="#c9d6ff"/><text x="1050" y="184" text-anchor="middle" class="tb">下游活动</text><text x="1050" y="201" text-anchor="middle" class="t3">按分类分流</text></g>
</g>
</svg>
<div class="cards">
<div class="card"><h4><em>确定</em>沉淀为资产</h4><p>三分类规则、完整度门槛、利润档位 —— 全部写成<b>判定表</b>与<b>判据</b>装进活动包。
从此这类判断不再依赖任何人的手感:换人、换模型,结论不变;要改口径,改表,一处生效。</p></div>
<div class="card"><h4><em>临场</em>收敛成编排</h4><p>俄文附件、中文正文、表格贴在邮件里 —— 来件形状千奇百怪,这正是大模型擅长的。
但 AI 在这里<b>只做归一与编排,不做裁断</b>:它算出的每个数都要过系统的门,过不了就得回去改。</p></div>
<div class="card"><h4><em>落地</em>绑定到责任</h4><p>建客户档案、审定受理需求、放行发出 —— 触达世界的动作全部落在<b>具名的人</b>身上。
谁签的字、签之前看到了什么、签下去发生了什么,台账留痕,事后可完整回放。</p></div>
</div>
<span class="pg">02 / 14</span>
</section>
<!-- ═══ S3 三面一体 ═══ -->
<section id="s3" class="slide">
<span class="kicker"><i></i>03 · 产品形态</span>
<h1>一套系统,三个面</h1>
<h2 class="sub">三种角色各有各的门面,底下是同一套台账 —— <b>定义工作的、看住工作的、干工作的,各看各的屏</b>。</h2>
<svg class="dia" viewBox="0 0 1120 380">
<defs><marker id="m2" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="#8f959e"/></marker></defs>
<g>
<rect x="30" y="50" width="320" height="240" rx="16" fill="#fff" stroke="#dee0e3"/>
<rect x="30" y="50" width="320" height="54" rx="16" fill="#f0f4ff"/>
<text x="54" y="84" class="t" style="font-size:16px">设计中心</text><text x="290" y="84" class="tm">/design</text>
<g class="t2"><text x="54" y="136">蓝图 · 触发器 · 流程包</text><text x="54" y="164">活动库 · 工具箱 · 数据域</text><text x="54" y="192">知识库 · 大模型 · 测试台</text><text x="54" y="220">(九区 —— 定义工作怎么做)</text></g>
<rect x="54" y="242" width="120" height="30" rx="15" fill="#e1eaff"/><text x="114" y="262" text-anchor="middle" class="tb">设计人员</text>
</g>
<g>
<rect x="400" y="50" width="320" height="240" rx="16" fill="#fff" stroke="#dee0e3"/>
<rect x="400" y="50" width="320" height="54" rx="16" fill="#eef8ea"/>
<text x="424" y="84" class="t" style="font-size:16px">运营中心</text><text x="668" y="84" class="tm">/ops</text>
<g class="t2"><text x="424" y="136">驾驶舱 · 工作流 · 任务</text><text x="424" y="164">员工 · 台账 · 系统</text><text x="424" y="192">(六区 —— 看住正在发生的)</text><text x="424" y="220">在途全景,卡点一眼可见</text></g>
<rect x="424" y="242" width="120" height="30" rx="15" fill="#e8f8e4"/><text x="484" y="262" text-anchor="middle" style="font-size:13px;font-weight:700" fill="#278717">管理者</text>
</g>
<g>
<rect x="770" y="50" width="320" height="240" rx="16" fill="#fff" stroke="#dee0e3"/>
<rect x="770" y="50" width="320" height="54" rx="16" fill="#f5f1ff"/>
<text x="794" y="84" class="t" style="font-size:16px">工作台</text><text x="1042" y="84" class="tm">/</text>
<g class="t2"><text x="794" y="136">对话区:AI 交代 + 工作卡</text><text x="794" y="164">工作区:件(报告即表单)</text><text x="794" y="192">待办 · 成果 · 沟通记录 · 消息</text><text x="794" y="220">(员工每天呆的地方)</text></g>
<rect x="794" y="242" width="120" height="30" rx="15" fill="#f0e9ff"/><text x="854" y="262" text-anchor="middle" style="font-size:13px;font-weight:700" fill="#7f3bf5">员工</text>
</g>
<g fill="none" stroke="#8f959e" stroke-width="1.8">
<path d="M350 170 H 396" marker-end="url(#m2)"/>
<path d="M720 170 H 766" marker-end="url(#m2)"/>
<path d="M930 296 C 930 352, 560 352, 560 296" marker-end="url(#m2)" stroke-dasharray="5 4"/>
</g>
<g class="t3">
<text x="373" y="156" text-anchor="middle">上线</text>
<text x="743" y="156" text-anchor="middle">派单</text>
<text x="745" y="344" text-anchor="middle">台账与指标实时回流</text>
</g>
</svg>
<div class="cards">
<div class="card"><h4><em>设计</em>装配式,不是开发式</h4><p>设计人员在治理台<b>装配</b>流程与活动:挑活动、连节点、配触发器、绑模型与工具 ——
不写代码。测试台里投一封信就能整流试跑,历次试跑存档可回放,改完当场验。</p></div>
<div class="card"><h4><em>运营</em>全程可观测</h4><p>每一单在哪个节点、AI 跑到第几步、等谁签字,驾驶舱一屏尽收;
配置变更、任务流转、台账写入全部实时推送 —— <b>不用刷新,不看快照</b>。</p></div>
<div class="card"><h4><em>员工</em>像带助理,不像用系统</h4><p>AI 把活干到只剩「该人拍板的那一下」再来找你:一轮一句交代进展,
该办的事变成一张卡,该看的件铺满工作区。<b>你面对的是一个汇报有条理的同事</b>,不是一堆待填表单。</p></div>
</div>
<span class="pg">03 / 14</span>
</section>
<!-- ═══ S4 三层抽象 + 版本治理 ═══ -->
<section id="s4" class="slide" style="background:var(--soft)">
<span class="kicker"><i></i>04 · 组织方式</span>
<h1>工作流 → 活动 → 单,三层各管各的</h1>
<h2 class="sub">工作流定<b>顺序</b>,活动定<b>做法</b>,单是<b>一次真实发生</b> —— 三层解耦,才谈得上各自演化、互不牵连。</h2>
<svg class="dia" viewBox="0 0 1120 420">
<defs><marker id="m3" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6.5" markerHeight="6.5" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="#3370ff"/></marker></defs>
<rect x="40" y="24" width="1040" height="86" rx="14" fill="#f0f4ff"/>
<text x="64" y="56" class="t">工作流(流程包)</text>
<text x="64" y="78" class="t3">节点 + 边;边只表达顺序与条件,数据按字段名流动 —— 编排层不掺业务逻辑</text>
<g>
<g fill="#fff" stroke="#3370ff" stroke-width="1.6">
<circle cx="600" cy="66" r="15"/><circle cx="672" cy="66" r="15"/><circle cx="744" cy="66" r="15"/>
<rect x="792" y="52" width="28" height="28" rx="5" transform="rotate(45 806 66)"/>
<circle cx="884" cy="66" r="15"/><circle cx="956" cy="66" r="15"/><circle cx="1028" cy="66" r="15"/>
</g>
<g fill="none" stroke="#3370ff" stroke-width="1.6">
<path d="M615 66 H 655"/><path d="M687 66 H 727"/><path d="M759 66 H 783"/><path d="M829 66 H 867"/><path d="M899 66 H 939"/><path d="M971 66 H 1011"/>
</g>
<g class="t3" text-anchor="middle"><text x="600" y="41">A1</text><text x="672" y="41">A2</text><text x="744" y="41">A3</text><text x="806" y="36">分流</text><text x="884" y="41">A4</text><text x="956" y="41">A6</text><text x="1028" y="41">A7</text></g>
</g>
<g stroke="#c9d6ff" stroke-dasharray="4 4" fill="none"><path d="M657 112 L 400 158"/><path d="M687 112 L 760 158"/></g>
<rect x="330" y="158" width="500" height="120" rx="14" fill="#fff" stroke="#3370ff" stroke-width="1.6"/>
<text x="354" y="190" class="t">活动 —— 完成一项任务的 Agent 机制</text>
<g class="t2"><text x="354" y="216">案例模型(每步剧本) + 说明书 + 判定表 + 台账声明</text><text x="354" y="240">系统外开发,像装 App 一样装进系统,升版本迭代</text></g>
<rect x="354" y="252" width="132" height="20" rx="10" fill="#e1eaff"/><text x="420" y="266" text-anchor="middle" class="t3" fill="#245bdb">安装即用 · 可替换</text>
<g stroke="#c9d6ff" stroke-dasharray="4 4" fill="none"><path d="M520 278 L 430 322"/><path d="M640 278 L 700 322"/></g>
<g>
<rect x="392" y="338" width="230" height="58" rx="10" fill="#fff" stroke="#dee0e3"/>
<rect x="380" y="330" width="230" height="58" rx="10" fill="#fff" stroke="#dee0e3"/>
<rect x="368" y="322" width="230" height="58" rx="10" fill="#fff" stroke="#3370ff" stroke-width="1.4"/>
<text x="388" y="346" class="tm">TY-2026-018 · 询价受理</text>
<text x="388" y="366" class="t3">一次触发起一单 · 起单宽进:门口不拒缺项</text>
<rect x="700" y="326" width="96" height="26" rx="13" fill="#e8f8e4"/><text x="748" y="344" text-anchor="middle" style="font-size:12px;font-weight:700" fill="#278717">推进中</text>
<rect x="700" y="360" width="96" height="26" rx="13" fill="#fff4e5"/><text x="748" y="378" text-anchor="middle" style="font-size:12px;font-weight:700" fill="#b25f00">等你签字</text>
</g>
<text x="64" y="360" class="t">单(任务)</text>
</svg>
<div class="cards">
<div class="card"><h4><em>治理</em>版本有纪律</h4><p>一条流<b>恰有一个生效版本</b>;新版本上线,在途的单按启动时的版本跑完,不被中途换规则。
活动改了内容必须升版本号 —— 同版重装无效,升级永远是显式动作,可追溯、可回退。</p></div>
<div class="card"><h4><em>宽进</em>门口不拒,过程补齐</h4><p>触发一到就起单 —— 缺字段不挡在门外(挡了就是把例外赶回线下)。
缺什么由活动在过程里向人开口补,<b>严出</b>由放行门把住:该有的没有,签不了字。</p></div>
<div class="card"><h4><em>价值</em>长尾业务也养得起</h4><p>新业务 = 装一个新活动 + 在流程包里连上节点,不动基座一行代码。
流程系统最养不起的长尾场景,在这里变成「多装一个包」的边际成本。</p></div>
</div>
<span class="pg">04 / 14</span>
</section>
<!-- ═══ S5 活动解剖 + 解耦 ═══ -->
<section id="s5" class="slide">
<span class="kicker"><i></i>05 · 解耦机制</span>
<h1>活动是安装进来的,声明就是授权</h1>
<h2 class="sub">系统(基座)与活动之间只有一条通道:<b>安装</b>。装进来的是一份「声明 + 剧本」,基座照声明给它一个能跑的世界 —— <b>没声明的,一律办不成</b>。</h2>
<svg class="dia" viewBox="0 0 1120 400">
<defs><marker id="m4" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="#3370ff"/></marker></defs>
<rect x="40" y="50" width="330" height="290" rx="16" fill="#fff" stroke="#dee0e3"/>
<text x="64" y="86" class="t" style="font-size:16px">系统基座(不动的部分)</text>
<g class="t2">
<text x="64" y="122">编排引擎 —— 逐拍推进 · 纪律把关</text>
<text x="64" y="150">台账基座 —— 草稿层 / 正式版两层账</text>
<text x="64" y="178">人环 —— 四类工作卡 · 工作区渲件</text>
<text x="64" y="206">门与闸 —— 判据 · 提交闸 · 放行门</text>
<text x="64" y="234">治理台 —— 装载 · 版本 · 授权</text>
</g>
<text x="64" y="286" class="t3">对每个活动,基座只做一件事:</text>
<text x="64" y="308" class="tb">照它的声明,给它一个能跑的世界</text>
<path d="M370 168 h18 v54 h-18" fill="none" stroke="#3370ff" stroke-width="2"/>
<g><path d="M640 195 H 396" fill="none" stroke="#3370ff" stroke-width="3" marker-end="url(#m4)"/>
<rect x="428" y="146" width="186" height="30" rx="15" fill="#e1eaff"/><text x="521" y="166" text-anchor="middle" class="tb">安装通道(唯一入口)</text>
<text x="521" y="232" text-anchor="middle" class="t3">读产物文件装入 · 改内容必升版本</text></g>
<g>
<rect x="640" y="50" width="440" height="290" rx="16" fill="#fff" stroke="#3370ff" stroke-width="1.6"/>
<text x="664" y="86" class="t" style="font-size:16px">活动包(系统外开发)</text>
<text x="1056" y="86" text-anchor="end" class="tm">quote-intake@49</text>
<g>
<rect x="664" y="104" width="196" height="62" rx="10" fill="#f0f4ff"/><text x="678" y="130" class="tb">案例模型</text><text x="678" y="150" class="t3">每个计划项一条剧本</text>
<rect x="872" y="104" width="188" height="62" rx="10" fill="#f0f4ff"/><text x="886" y="130" class="tb">活动说明书</text><text x="886" y="150" class="t3">AI 的作业规程</text>
<rect x="664" y="176" width="196" height="62" rx="10" fill="#f0f4ff"/><text x="678" y="202" class="tb">判定表 DMN</text><text x="678" y="222" class="t3">确定的判断查表出</text>
<rect x="872" y="176" width="188" height="62" rx="10" fill="#f0f4ff"/><text x="886" y="202" class="tb">台账声明</text><text x="886" y="222" class="t3">写哪几张表 = 授权面</text>
<rect x="664" y="248" width="196" height="62" rx="10" fill="#f0f4ff"/><text x="678" y="274" class="tb">HTML 报告模版</text><text x="678" y="294" class="t3">件 · 表单 · 归档件一体</text>
<rect x="872" y="248" width="188" height="62" rx="10" fill="#f0f4ff"/><text x="886" y="274" class="tb">出口与谓词</text><text x="886" y="294" class="t3">去向按数据算,不靠手感</text>
</g>
</g>
</svg>
<div class="cards">
<div class="card"><h4><em>写权</em>台账声明</h4><p>活动声明自己要写哪几张表、哪些列、哪些必填 —— 声明之外的表<b>一个字都写不进</b>。
列声明还顺带驱动界面:必填红、建议橘、自增列不问人,全从同一份声明长出来。</p></div>
<div class="card"><h4><em>调权 / 发权</em>工具与出口声明</h4><p>能调哪些工具、能向哪几个出口发数据、载荷什么形状 —— 全部声明制。
AI 在运行期<b>结构上不可能越权</b>:它想调一个没声明的工具,引擎连这个选项都不给它。</p></div>
<div class="card"><h4><em>产业含义</em>活动是可交易的能力单元</h4><p>活动在系统外开发、测试、打包 —— 意味着能力可以由不同团队并行生产、按版本分发。
基座越用越稳,业务能力靠「装活动」长出来,这是「操作系统」三个字的分量。</p></div>
</div>
<span class="pg">05 / 14</span>
</section>
<!-- ═══ S6 一单的一生 ═══ -->
<section id="s6" class="slide" style="background:var(--soft)">
<span class="kicker"><i></i>06 · 运行机制</span>
<h1>一单的一生:门前的都改得动,门后的都算数</h1>
<h2 class="sub">所有改动先落<b>草稿层</b>;人签字放行那一刻才合并进<b>正式版</b>、才对外发出 —— <b>放行之前,世界上什么都没有发生</b>。</h2>
<svg class="dia" viewBox="0 0 1120 430">
<defs><marker id="m5" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="#3370ff"/></marker>
<marker id="m5g" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="#278717"/></marker></defs>
<g fill="none" stroke="#3370ff" stroke-width="2">
<path d="M120 90 H 218"/><path d="M300 90 H 358"/><path d="M470 90 H 528"/><path d="M640 90 H 690"/><path d="M780 90 H 826" marker-end="url(#m5)"/>
</g>
<g>
<g><rect x="36" y="66" width="84" height="48" rx="9" fill="#fff" stroke="#dee0e3"/><text x="78" y="86" text-anchor="middle" class="t2">来件入站</text><text x="78" y="104" text-anchor="middle" class="t3">沟通记录归档</text></g>
<g><rect x="218" y="66" width="82" height="48" rx="9" fill="#fff" stroke="#c9d6ff"/><text x="259" y="86" text-anchor="middle" class="tb">起单</text><text x="259" y="104" text-anchor="middle" class="t3">宽进不拒缺</text></g>
<g><rect x="358" y="66" width="112" height="48" rx="9" fill="#fff" stroke="#c9d6ff"/><text x="414" y="86" text-anchor="middle" class="tb">AI 逐拍办事</text><text x="414" y="104" text-anchor="middle" class="t3">写草稿 · 全留痕</text></g>
<g><rect x="528" y="66" width="112" height="48" rx="9" fill="#fff" stroke="#e2d6ff"/><text x="584" y="86" text-anchor="middle" style="font-size:13px;font-weight:700" fill="#7f3bf5">人环 · 工作卡</text><text x="584" y="104" text-anchor="middle" class="t3">该问人的问人</text></g>
<g><rect x="690" y="48" width="90" height="84" rx="10" fill="#fff4e5" stroke="#ffd9a8"/>
<g stroke="#b25f00" stroke-width="2.4"><path d="M712 66 v48"/><path d="M735 66 v48"/><path d="M758 66 v48"/></g>
<text x="735" y="152" text-anchor="middle" class="t2" style="font-weight:700" fill="#b25f00">提交门</text>
<text x="735" y="170" text-anchor="middle" class="t3">判据逐条过</text></g>
<g><rect x="826" y="54" width="104" height="72" rx="10" fill="#eef8ea" stroke="#bfe3b2"/>
<circle cx="856" cy="82" r="13" fill="none" stroke="#278717" stroke-width="2"/><path d="M850 82 l5 5 9-10" fill="none" stroke="#278717" stroke-width="2"/>
<text x="892" y="80" class="t2" style="font-weight:700" fill="#278717">放行</text><text x="892" y="98" class="t3">人签字</text>
<text x="878" y="146" text-anchor="middle" class="t3">责任在此,清清楚楚</text></g>
<g><path d="M930 90 H 976" fill="none" stroke="#278717" stroke-width="2" marker-end="url(#m5g)"/>
<rect x="976" y="66" width="112" height="48" rx="9" fill="#fff" stroke="#bfe3b2"/><text x="1032" y="86" text-anchor="middle" style="font-size:13px;font-weight:700" fill="#278717">发射出口</text><text x="1032" y="104" text-anchor="middle" class="t3">载荷 = 验收过的成果</text></g>
</g>
<path d="M806 36 V 404" stroke="#8f959e" stroke-dasharray="6 5" fill="none"/>
<text x="796" y="30" text-anchor="end" class="t3">门前 · 都改得动</text><text x="818" y="30" class="t3">门后 · 都算数</text>
<g>
<rect x="120" y="230" width="640" height="66" rx="12" fill="#f5f1ff"/>
<text x="140" y="258" class="t">草稿层</text><text x="140" y="278" class="t3">AI 写的、人在件上改的、撤掉的行 —— 全在这层,随时可改可撤</text>
<g><rect x="470" y="242" width="86" height="24" rx="12" fill="#fff"/><text x="513" y="259" text-anchor="middle" class="t3">AI 写入</text>
<rect x="566" y="242" width="86" height="24" rx="12" fill="#fff"/><text x="609" y="259" text-anchor="middle" class="t3">人改两格</text>
<rect x="662" y="242" width="86" height="24" rx="12" fill="#fff"/><text x="705" y="259" text-anchor="middle" class="t3">撤掉一行</text></g>
<rect x="120" y="320" width="968" height="66" rx="12" fill="#eef8ea"/>
<text x="140" y="348" class="t">正式版</text><text x="140" y="368" class="t3">组织的事实。只有放行那一刻的合并能写进来;下游读到的永远是这层</text>
<path d="M780 296 C 812 306, 826 310, 848 318" fill="none" stroke="#278717" stroke-width="2.4" marker-end="url(#m5g)"/>
<text x="836" y="300" class="t3" fill="#278717">合并(放行那一刻)</text>
</g>
</svg>
<div class="cards">
<div class="card"><h4><em>类比</em>Git 的工作方式,搬进业务</h4><p>草稿层之于正式版,如同分支之于主干:AI 和人都在「分支」上放开干、随时改、随时撤;
<b>合入主干只有一条路 —— 人签字</b>。写错一行有退路(撤的只是草稿,正式版一个字不动)。</p></div>
<div class="card"><h4><em>放行那一刻</em>四件事原子发生</h4><p>草稿合并进正式版 → 出口按谓词求值、命中哪口发哪口 → 载荷带走验收过的成果 →
结构化留痕落档。签完那一屏直接回答「这一签下去发生了什么」:进了哪几张表各几行、转去了哪儿。</p></div>
<div class="card"><h4><em>止损面</em>错误出不了门</h4><p>AI 抽错一格、多写一行、编了一个数 —— 最坏情况也只是草稿层的一笔脏数据,
被红带点名、被闸拦住、被人改掉。<b>对外发出、写进正式账的,必然是人过目签字之后的版本</b>。</p></div>
</div>
<span class="pg">06 / 14</span>
</section>
<!-- ═══ S7 AI 的缰绳 ═══ -->
<section id="s7" class="slide">
<span class="kicker"><i></i>07 · AI 的缰绳</span>
<h1>AI 怎么被管住:一拍一串行动,纪律引擎当场把关</h1>
<h2 class="sub">AI 不是放出去自由发挥 —— 每一拍只看该看的局面,发出的每个行动都过纪律引擎;<b>判断归判定表,AI 只做编排</b>。</h2>
<div class="duo">
<svg viewBox="0 0 520 380" style="width:100%">
<defs><marker id="m6" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="#3370ff"/></marker></defs>
<circle cx="260" cy="190" r="128" fill="none" stroke="#dbe4f8" stroke-width="2"/>
<text x="260" y="184" text-anchor="middle" class="t" style="font-size:17px">一拍</text>
<text x="260" y="206" text-anchor="middle" class="t3">1.5 - 4.9 秒</text>
<g fill="none" stroke="#3370ff" stroke-width="2">
<path d="M330 84 A128 128 0 0 1 388 178" marker-end="url(#m6)"/>
<path d="M382 244 A128 128 0 0 1 300 308" marker-end="url(#m6)"/>
<path d="M190 296 A128 128 0 0 1 134 212" marker-end="url(#m6)"/>
<path d="M140 124 A128 128 0 0 1 216 68" marker-end="url(#m6)"/>
</g>
<g>
<rect x="186" y="26" width="148" height="44" rx="10" fill="#fff" stroke="#c9d6ff"/><text x="260" y="45" text-anchor="middle" class="tb">局面</text><text x="260" y="62" text-anchor="middle" class="t3">只摊当前这一步该看的</text>
<rect x="384" y="168" width="122" height="44" rx="10" fill="#fff" stroke="#c9d6ff"/><text x="445" y="187" text-anchor="middle" class="tb">模型决策</text><text x="445" y="204" text-anchor="middle" class="t3">编排,不裁断</text>
<rect x="186" y="310" width="148" height="44" rx="10" fill="#fff" stroke="#c9d6ff"/><text x="260" y="329" text-anchor="middle" class="tb">行动串(至多 6 个)</text><text x="260" y="346" text-anchor="middle" class="t3">记账 · 查表 · 调工具 · 发卡</text>
<rect x="10" y="168" width="128" height="44" rx="10" fill="#fff" stroke="#ffd9a8"/><text x="74" y="187" text-anchor="middle" style="font-size:13px;font-weight:700" fill="#b25f00">纪律引擎</text><text x="74" y="204" text-anchor="middle" class="t3">非法行动当场拒回</text>
</g>
</svg>
<div>
<div class="card" style="margin-bottom:12px"><h4><em>局面</em>上下文按状态组织</h4>
<p>每拍只摊开<b>当前这一步</b>的做法与该用的东西,其余步骤各留一行;台账读数、剧本、轨迹、可用工具全在局面里。
AI 不靠记忆干活 —— 它看到的就是此刻的真实世界。</p></div>
<div class="card" style="margin-bottom:12px"><h4><em>行动</em>十四种,各有边界</h4>
<p>记账、查判定表、调工具、发四类卡、宣告完成、提交放行、撤草稿…… 每种行动引擎都有一套合法性检查;
一拍最多六个、<b>被拒即停在被拒处</b>(前缀生效),不会带病往下跑。</p></div>
<div class="card"><h4><em>自动收步</em>与熔断</h4>
<p>每步剧本带「办成了没有」的判据 —— 数据到位引擎<b>自己收步翻页</b>,不再问模型,省拍省钱;
触达世界的步骤例外(不给引擎替人签字的钥匙)。连续被拒五拍熔断交人,调人有预算 —— <b>不空转、不越权、不无限烧</b>。</p></div>
</div>
</div>
<div class="cards" style="margin-top:14px">
<div class="card tint"><h4><em>纪律一</em>机检只读系统数出来的数</h4><p>判据要么由基座从台账<b>现算</b>(缺几格、写了几行、打了几个标),要么交人裁量。
AI 自报的数不当判据 —— 否则它总有一天会学会「把数改对」。</p></div>
<div class="card tint"><h4><em>纪律二</em>拒回的话是行动指南</h4><p>拦下一个错误动作时,连着告诉它:差的是数还是行、差在哪一行哪一格、哪条路能走通。
<b>指错方向比不给指引更糟</b> —— 这是我们在实测里反复验证过的工程铁律。</p></div>
<div class="card tint"><h4><em>纪律三</em>全程留痕,三色日志</h4><p>每一拍谁干了什么 —— 负责人绿、智能体蓝、系统灰,一单一本工作日志;
对话区只留一轮一句的交代,过程细节沉在日志里,<b>随时回放,永不失忆</b>。</p></div>
</div>
<span class="pg">07 / 14</span>
</section>
<!-- ═══ S8 判据体系 ═══ -->
<section id="s8" class="slide" style="background:var(--soft)">
<span class="kicker"><i></i>08 · 门的构造</span>
<h1>放行门里有什么:五层判据,各归其位</h1>
<h2 class="sub">「能不能放行」不是一个感觉,是一摞<b>可逐条核对的判据</b> —— 机器判得了的机器判,判不了的交人,<b>责任那一层永远留给签字的人</b>。</h2>
<div class="gates">
<div class="gate m"><span class="lv">L1<small>机检</small></span><span class="what"><b>必填齐</b> —— 型号 / 年用量一格不缺</span><span class="how">基座现算:缺几格、缺在哪一行哪一列</span></div>
<div class="gate m"><span class="lv">L2<small>机检</small></span><span class="what"><b>账面守恒</b> —— 每条都打了标、行数对得上、该查的工具真查过</span><span class="how">基座读数两边对账,自报无效</span></div>
<div class="gate d"><span class="lv">L3<small>裁量</small></span><span class="what"><b>质的判断</b> —— 拿不准的分类是否已按规则降级处理</span><span class="how">AI 裁,裁断与理由随卡交给人</span></div>
<div class="gate d"><span class="lv">L4<small>裁量</small></span><span class="what"><b>缺口处置</b> —— 选填缺口已补齐,或由你确认可以先受理</span><span class="how">带谓词:数据已齐则自动达标,否则才轮到裁量</span></div>
<div class="gate h"><span class="lv">L5<small>人</small></span><span class="what"><b>放行</b> —— 这一签本身就是这道关</span><span class="how">不列进判据表:那张卡就是 L5</span></div>
</div>
<div class="tri">
<span class="ok">达标 —— 机器核过,绿</span>
<span class="amb">AI 判为不满足 · 交你定 —— 琥珀,理由附在旁边</span>
<span class="no">未达标 —— 红,差什么直说</span>
</div>
<div class="cards" style="margin-top:16px">
<div class="card"><h4><em>三态</em>「裁了但不满足」不等于「没裁」</h4><p>判据在卡面上分三态呈现。AI 判为不满足的那条不假装绿、也不冒充红 ——
<b>连同理由一起摆到签字人面前</b>,由人定夺。裁量项裁过即可提交,不逼 AI 把裁断翻供成「满足」。</p></div>
<div class="card"><h4><em>可信</em>判据必须指向一个可做的动作</h4><p>每条判据背后都有一条能走的路:缺数去补、多行去撤、没办的回去办。
我们删掉过一批「恒不可满足」和「给自己盖章」式的判据 —— <b>门是用来把关的,不是用来表演严格的</b>。</p></div>
<div class="card"><h4><em>对老板的含义</em>审计级的放行记录</h4><p>每一单放行都留着:当时哪几条判据什么状态、AI 的裁断与理由是什么、人最终怎么定的。
合规回查时,这就是一份<b>逐条可回放的决策档案</b>。</p></div>
</div>
<span class="pg">08 / 14</span>
</section>
<!-- ═══ S9 人的位置 ═══ -->
<section id="s9" class="slide">
<span class="kicker"><i></i>09 · 人的位置</span>
<h1>人只在该出现的地方出现,出现时体验是对的</h1>
<h2 class="sub">人的介入收敛成<b>四类工作卡</b>;要看要改的内容是<b>一份件</b> —— 看是报告,按「编辑」就地改,办结即归档,三个身份同一份 HTML。</h2>
<div class="chips" style="max-width:1120px;margin:0 auto 14px">
<span class="chip b">执行工单 —— 把活干了:指导书说清缺什么,填报全在件上,零输入框卡片</span>
<span class="chip g">验收工单 —— 把关签字:判据三态 + 就地可改 + 落地结果回执</span>
<span class="chip o">决策卡 —— 岔路口拍板:至少两个可点的选项,不让人对着空表单猜</span>
<span class="chip">录入卡 —— 建档确认:一卡两段(公司 + 联系人),自增编号不问人</span>
</div>
<svg class="dia" viewBox="0 0 1120 380">
<defs><marker id="m7" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="#3370ff"/></marker></defs>
<rect x="60" y="20" width="1000" height="330" rx="14" fill="#fff" stroke="#dee0e3"/>
<rect x="60" y="20" width="1000" height="34" rx="14" fill="#f8f9fb"/>
<circle cx="86" cy="37" r="5" fill="#dee0e3"/><circle cx="104" cy="37" r="5" fill="#dee0e3"/><circle cx="122" cy="37" r="5" fill="#dee0e3"/>
<text x="560" y="42" text-anchor="middle" class="t3">员工工作台</text>
<rect x="80" y="70" width="330" height="262" rx="10" fill="#f8f9fb"/>
<text x="98" y="96" class="t2" style="font-weight:700">对话区</text>
<rect x="98" y="110" width="250" height="40" rx="9" fill="#fff" stroke="#e8eaed"/>
<text x="112" y="135" class="t3">AI:三颗料归一完毕,两格必填缺着</text>
<rect x="98" y="160" width="294" height="74" rx="9" fill="#fff" stroke="#c9d6ff"/>
<text x="112" y="186" class="tb">执行工单</text><text x="112" y="206" class="t3">补齐必填 · 建议追问目标价</text>
<rect x="286" y="196" width="90" height="26" rx="7" fill="#3370ff"/><text x="331" y="214" text-anchor="middle" class="tw">查看工单</text>
<rect x="98" y="244" width="250" height="40" rx="9" fill="#fff" stroke="#e8eaed"/>
<text x="112" y="269" class="t3">卡上只有整单级:判据 · 指导书 · 提交钮</text>
<rect x="440" y="70" width="600" height="262" rx="10" fill="#fff" stroke="#e8eaed"/>
<text x="458" y="96" class="t2" style="font-weight:700">工作区 · 件(报告即表单)</text>
<rect x="458" y="108" width="564" height="30" rx="7" fill="#f0f4ff"/><text x="472" y="128" class="tb">询价受理需求 · TY-2026-018</text>
<rect x="458" y="146" width="564" height="26" rx="7" fill="#feeae9"/><text x="472" y="164" style="font-size:12px;font-weight:700" fill="#d83931">还差 2 格必填没补 —— 第 2 行·年用量 / 第 3 行·型号</text>
<g>
<rect x="458" y="180" width="564" height="26" fill="#f8f9fb"/><text x="472" y="198" class="t3">SS8050&#12288;&#12288;三极管&#12288;&#12288;20,000&#12288;&#12288;长电</text>
<rect x="458" y="206" width="564" height="26" fill="#fffbfb"/><text x="472" y="224" class="t3" fill="#d83931">TL072CDR&#12288;运放&#12288;&#12288;(必填)&#12288;&#12288;TI</text>
<rect x="458" y="232" width="564" height="26" fill="#f8f9fb"/><text x="472" y="250" class="t3">NE555P&#12288;&#12288;定时器&#12288;12,000&#12288;&#12288;TI</text>
</g>
<rect x="458" y="272" width="200" height="28" rx="8" fill="#f8f9fb" stroke="#e8eaed"/><text x="472" y="291" class="t3">已锁定 —— 要改按「编辑」</text>
<rect x="672" y="272" width="66" height="28" rx="8" fill="#fff" stroke="#c9d6ff"/><text x="705" y="291" text-anchor="middle" class="tb">编辑</text>
<g fill="none" stroke="#3370ff" stroke-width="1.8"><path d="M392 197 C 420 197, 420 122, 452 122" marker-end="url(#m7)"/></g>
<text x="404" y="150" class="t3" fill="#245bdb">点卡,跳到件</text>
</svg>
<div class="cards">
<div class="card"><h4><em>分工</em>逐行数据归件,整单级归卡</h4><p>编辑在件(表格就地改、能增删行、红带点名缺口),提交在卡(判据 + 指导书 + 那一枚提交钮)。
两个区各说各的话,<b>不再有第二套表单</b> —— 人填的每一格都落在它将来归档的位置上。</p></div>
<div class="card"><h4><em>兜错</em>手改是兜 AI 的错的那一手</h4><p>改格子、撤掉不该在的行、补漏掉的行 —— 三类错都有出口;删是挂记号不当场抹掉,看得见、可后悔。
保存只落草稿层,<b>保存不等于放行</b>;必填还空着,提交钮按不下去,闸上直说差在哪。</p></div>
<div class="card"><h4><em>克制</em>该问人的问人,答不上来的不摆</h4><p>卡面摆表的人读名不摆库表英文名;系统能生成的编号不让人填;
AI 已办完的不再请示。人被打扰的每一次,都是<b>真正需要人</b>的那一次。</p></div>
</div>
<span class="pg">09 / 14</span>
</section>
<!-- ═══ S10 界面不撒谎 ═══ -->
<section id="s10" class="slide" style="background:var(--soft)">
<span class="kicker"><i></i>10 · 信任工程</span>
<h1>界面不撒谎:一个数,三处呈现,同一个源</h1>
<h2 class="sub">「还缺几格必填」由基座从台账现算 —— 件上的红带、卡上的提交闸、放行门的机检,读的是<b>同一个数、同一套算法</b>。没有两套口径,就没有扯皮。</h2>
<svg class="dia" viewBox="0 0 1120 330">
<defs><marker id="m8" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="#3370ff"/></marker></defs>
<rect x="430" y="240" width="260" height="56" rx="12" fill="#1f2329"/>
<text x="560" y="264" text-anchor="middle" class="tw" style="font-family:var(--mono)">基座读数 · 缺必填 = 2</text>
<text x="560" y="284" text-anchor="middle" style="font-size:11.5px" fill="#9aa3ad">从台账现算,不由 AI 自报</text>
<g fill="none" stroke="#3370ff" stroke-width="2">
<path d="M470 240 C 380 200, 300 180, 240 156" marker-end="url(#m8)"/>
<path d="M560 240 V 166" marker-end="url(#m8)"/>
<path d="M650 240 C 740 200, 820 180, 880 156" marker-end="url(#m8)"/>
</g>
<g>
<rect x="60" y="60" width="300" height="96" rx="12" fill="#fff" stroke="#dee0e3"/>
<text x="82" y="90" class="t">件上 · 缺项红带</text>
<rect x="82" y="104" width="256" height="30" rx="7" fill="#feeae9"/>
<text x="94" y="124" style="font-size:12px;font-weight:700" fill="#d83931">还差 2 格必填 —— 差在哪行哪列</text>
<rect x="410" y="60" width="300" height="96" rx="12" fill="#fff" stroke="#dee0e3"/>
<text x="432" y="90" class="t">卡上 · 提交闸</text>
<rect x="432" y="104" width="120" height="32" rx="8" fill="#f0f1f3"/><text x="492" y="125" text-anchor="middle" class="t3">提交(按不下)</text>
<text x="568" y="125" class="t3">缺着就拦,补齐自动开</text>
<rect x="760" y="60" width="300" height="96" rx="12" fill="#fff" stroke="#dee0e3"/>
<text x="782" y="90" class="t">放行门 · 机检判据</text>
<text x="782" y="120" class="t2">必填齐(型号 / 年用量)</text>
<rect x="990" y="102" width="52" height="24" rx="12" fill="#feeae9"/><text x="1016" y="119" text-anchor="middle" style="font-size:12px;font-weight:700" fill="#d83931">未达标</text>
</g>
</svg>
<div class="cards">
<div class="card"><h4><em>如实</em>状态、错误、能力都不装</h4><p>模型没绑密钥就写「不可用」,不摆假数字;外部系统报错,把厂商原话带给人看;
附件读不出文本,就写「读不出」—— <b>宁可难看,不可失真</b>。</p></div>
<div class="card"><h4><em>实时</em>屏上的永远是现在</h4><p>任务推进、台账写入、配置变更,全部事件推送实时刷新;别处销毁的单不会在你屏上留鬼影;
连「签完字到落定之间」的中间态都有自己的话:「字已签下,正在落地」。</p></div>
<div class="card"><h4><em>不冒充</em>算不出就空着</h4><p>去向没算出来,判定表一行都不高亮 —— 不冒充判过;没有数据来源的图表不画 ——
画六根编出来的柱子是撒谎。<b>报告只呈现,不计算、不判断</b>,判断永远发生在有留痕的地方。</p></div>
</div>
<span class="pg">10 / 14</span>
</section>
<!-- ═══ S11 实例:统一报价 ═══ -->
<section id="s11" class="slide">
<span class="kicker"><i></i>11 · 跑起来的样子</span>
<h1>实例:统一报价工作流,七节点全链</h1>
<h2 class="sub">客户一封询价邮件进来,到报价单发出、情报归档 —— 六个活动包 + 一道人工审批;每个分岔的去向都由<b>出口谓词按数据现算</b>。</h2>
<svg class="dia" viewBox="0 0 1120 430">
<defs><marker id="m9" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6.5" markerHeight="6.5" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="#3370ff"/></marker>
<marker id="m9d" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6.5" markerHeight="6.5" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="#8f959e"/></marker></defs>
<g>
<g><rect x="20" y="176" width="64" height="52" rx="9" fill="#fff" stroke="#dee0e3"/><path d="M34 192 h36 v22 h-36 z M34 192 l18 13 18-13" fill="none" stroke="#3370ff" stroke-width="1.8"/><text x="52" y="246" text-anchor="middle" class="t3">客户 RFQ</text></g>
<g><rect x="128" y="170" width="130" height="64" rx="11" fill="#f0f4ff" stroke="#c9d6ff"/><text x="193" y="196" text-anchor="middle" class="tb">A1 询价受理</text><text x="193" y="216" text-anchor="middle" class="t3">归一 · 三分类</text></g>
<path d="M300 174 l30 28 -30 28 -30 -28 z" fill="#fff" stroke="#3370ff" stroke-width="1.6"/><text x="300" y="160" text-anchor="middle" class="t3">分流</text>
<g><rect x="400" y="46" width="130" height="64" rx="11" fill="#f0f4ff" stroke="#c9d6ff"/><text x="465" y="72" text-anchor="middle" class="tb">A2 替代选型</text><text x="465" y="92" text-anchor="middle" class="t3">FAE 选替代 · 判稳</text></g>
<g><rect x="400" y="170" width="130" height="64" rx="11" fill="#f0f4ff" stroke="#c9d6ff"/><text x="465" y="196" text-anchor="middle" class="tb">A3 渠道寻源</text><text x="465" y="216" text-anchor="middle" class="t3">圈渠道 · 拿货源价</text></g>
<g><rect x="400" y="300" width="130" height="64" rx="11" fill="#f0f4ff" stroke="#c9d6ff"/><text x="465" y="326" text-anchor="middle" class="tb">A4 成本定价</text><text x="465" y="346" text-anchor="middle" class="t3">利润模型 · 底价门禁</text></g>
<g><path d="M640 300 l32 32 -32 32 -32 -32 z" fill="#fff4e5" stroke="#ffd9a8" stroke-width="1.6"/>
<circle cx="640" cy="326" r="7" fill="none" stroke="#b25f00" stroke-width="1.8"/><path d="M630 342 a10 7 0 0 1 20 0" fill="none" stroke="#b25f00" stroke-width="1.8"/>
<text x="640" y="384" text-anchor="middle" class="t3">A5 定价审批 · 人</text></g>
<g><rect x="740" y="170" width="130" height="64" rx="11" fill="#f0f4ff" stroke="#c9d6ff"/><text x="805" y="196" text-anchor="middle" class="tb">A6 报价议价</text><text x="805" y="216" text-anchor="middle" class="t3">发报价函 · 让步梯度</text></g>
<g><rect x="960" y="170" width="130" height="64" rx="11" fill="#eef8ea" stroke="#bfe3b2"/><text x="1025" y="196" text-anchor="middle" style="font-size:13px;font-weight:700" fill="#278717">A7 情报归档</text><text x="1025" y="216" text-anchor="middle" class="t3">输赢归因 · 竞对价</text></g>
</g>
<g fill="none" stroke="#3370ff" stroke-width="1.8">
<path d="M84 202 H 122" marker-end="url(#m9)"/>
<path d="M258 202 H 264" marker-end="url(#m9)"/>
<path d="M316 186 C 350 140, 360 110, 394 88" marker-end="url(#m9)"/>
<path d="M330 202 H 394" marker-end="url(#m9)"/>
<path d="M316 218 C 350 264, 360 300, 394 322" marker-end="url(#m9)"/>
<path d="M530 96 C 570 120, 580 150, 560 168 C 550 178, 540 186, 536 190" marker-end="url(#m9)"/>
<path d="M530 78 C 640 78, 700 180, 560 300 C 545 312, 540 316, 536 318" marker-end="url(#m9)" stroke-dasharray="5 4"/>
<path d="M465 234 V 294" marker-end="url(#m9)"/>
<path d="M530 332 H 602" marker-end="url(#m9)"/>
<path d="M672 332 C 700 332, 710 260, 736 224" marker-end="url(#m9)"/>
<path d="M870 202 H 954" marker-end="url(#m9)"/>
</g>
<path d="M805 236 C 772 330, 662 400, 538 356" fill="none" stroke="#8f959e" stroke-width="1.6" stroke-dasharray="5 4" marker-end="url(#m9d)"/>
<g class="t3">
<text x="330" y="120">有 C 类 · 需替代</text>
<text x="336" y="194">无 C 有 B</text>
<text x="330" y="286">全 A · 直通</text>
<text x="560" y="146">判稳仍有 B</text>
<text x="600" y="66" fill="#8f959e">判稳全转 A · 免寻源</text>
<text x="700" y="266">批下来</text>
<text x="668" y="404" fill="#8f959e">再议 · 回核价重批</text>
<text x="884" y="192">成交 / 未成</text>
</g>
</svg>
<div class="cards">
<div class="card"><h4><em>真实形状</em>来件千奇百怪,照单全收</h4><p>常备案例真不相同:俄文 BOM 附件、中文正文列料无附件、客户点名「这颗本轮跳过」——
全部端到端跑通。<b>宽进的意义就在这</b>:业务的真实形状不需要先被系统「格式化」。</p></div>
<div class="card"><h4><em>交接</em>随令牌流向下游的只有验收过的成果</h4><p>上游发什么、下游要什么,契约写死;每一步交接的载荷都是<b>人签过字的正式数据</b>。
A1 不替 A2 摆输入,A2 进场自己归一 —— 同一份事实只存一份,不分叉。</p></div>
<div class="card"><h4><em>去向</em>算出来的,不是点出来的</h4><p>「转替代还是转寻源」由分类计数按谓词现算,在放行那一刻与发口同一次判定;
要改去向,改的是清单里料号的分类,去向跟着变 —— <b>决策有据,回头可核</b>。</p></div>
</div>
<span class="pg">11 / 14</span>
</section>
<!-- ═══ S12 质量工程 ═══ -->
<section id="s12" class="slide" style="background:var(--soft)">
<span class="kicker"><i></i>12 · 质量工程</span>
<h1>可靠性不是许愿,是一条流水线</h1>
<h2 class="sub">AI 系统最怕「演示能跑,生产翻车」。我们把可靠性拆成<b>三道防线</b>:门禁拦代码,测试台拦设计,验收口径拦侥幸。</h2>
<svg class="dia" viewBox="0 0 1120 150">
<defs><marker id="mg" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="#3370ff"/></marker></defs>
<g fill="none" stroke="#3370ff" stroke-width="2">
<path d="M196 76 H 222" marker-end="url(#mg)"/><path d="M400 76 H 426" marker-end="url(#mg)"/>
<path d="M604 76 H 630" marker-end="url(#mg)"/><path d="M808 76 H 834" marker-end="url(#mg)"/>
</g>
<g>
<rect x="30" y="46" width="166" height="60" rx="11" fill="#fff" stroke="#c9d6ff"/><text x="113" y="72" text-anchor="middle" class="tb">类型 + 规范</text><text x="113" y="92" text-anchor="middle" class="t3">静态检查零豁免</text>
<rect x="234" y="46" width="166" height="60" rx="11" fill="#fff" stroke="#c9d6ff"/><text x="317" y="72" text-anchor="middle" class="tb">依赖边界</text><text x="317" y="92" text-anchor="middle" class="t3">模块分层不许穿</text>
<rect x="438" y="46" width="166" height="60" rx="11" fill="#fff" stroke="#c9d6ff"/><text x="521" y="72" text-anchor="middle" class="tb">契约入库</text><text x="521" y="92" text-anchor="middle" class="t3">接口变更必显式登记</text>
<rect x="642" y="46" width="166" height="60" rx="11" fill="#fff" stroke="#c9d6ff"/><text x="725" y="72" text-anchor="middle" class="tb">千余条测试</text><text x="725" y="92" text-anchor="middle" class="t3">含活动包内自测</text>
<rect x="846" y="46" width="166" height="60" rx="11" fill="#eef8ea" stroke="#bfe3b2"/><text x="929" y="72" text-anchor="middle" style="font-size:13px;font-weight:700" fill="#278717">五道全绿才合入</text><text x="929" y="92" text-anchor="middle" class="t3">以退出码验,不许口头绿</text>
</g>
</svg>
<div class="cards">
<div class="card"><h4><em>测试台</em>设计中心里的风洞</h4><p>投一封信就能试跑整条流或单个活动;逐拍轨迹、台账草稿、发出的人卡全存档,
可回放可对比。<b>设计人员改完配置,当场吹一遍风洞再上线</b>。</p></div>
<div class="card"><h4><em>验收口径</em>released + 被拒 0</h4><p>每个活动配三条形状不同的常备案例,标准是双硬指标:全部跑到放行收尾,且 <b>AI 行动被拒 0 次</b> ——
每一次被拒都被当成设计欠账修掉,而不是靠重试蒙混。这个口径逼着系统把「话与门」对齐。</p></div>
<div class="card"><h4><em>基线</em>动共享面,全量回归</h4><p>A1 是不可破的基线(连跑 10/10 全放行):任何人改了引擎、提示词、渲染层这些共享面,
全部案例重跑一遍 —— <b>「稳定的活动突然出错」这类事故,在流程上被堵死</b>。</p></div>
</div>
<span class="pg">12 / 14</span>
</section>
<!-- ═══ S13 效率账 ═══ -->
<section id="s13" class="slide">
<span class="kicker"><i></i>13 · 成本工程</span>
<h1>效率不是口号,是一笔一笔抠出来的账</h1>
<h2 class="sub">三组真实优化数据 —— 每一刀都从逐拍 trace 里挖出来(输入 / 输出 / 推理字数逐拍实证),<b>这个体系的成本曲线是可持续下压的</b>。</h2>
<div class="delta">
<div class="dcard">
<h4>每拍时延</h4>
<div class="row"><s>20 - 56s</s><i>&#8594;</i><b>1.5 - 4.9s</b></div>
<p>病根:模型的隐藏推理在烧时间。处方:<b>判断归判定表,模型只做编排</b> —— 关掉思考额度,
规则写死在说明书与判定表里。快了一个数量级,判断反而更稳(查表不抖)。</p>
</div>
<div class="dcard">
<h4>一单开销</h4>
<div class="row"><s>21 拍 · 17.3万 token</s><i>&#8594;</i><b>7 拍 · 4.9万</b></div>
<p>病根:一拍只许一个行动,每个动作都重烧一遍完整上下文。处方:<b>一拍一串行动</b>(封顶六个,
纪律引擎保证串内被拒即停)+ 成果已在就自动收步。同一单活,开销降到三分之一以下。</p>
</div>
<div class="dcard">
<h4>对话密度</h4>
<div class="row"><s>逐拍刷屏 9 条</s><i>&#8594;</i><b>一轮一句 5 条</b></div>
<p>病根:AI 每拍都播报,员工被日志淹没。处方:<b>只有交出控制权那一拍才进对话区</b>,
过程细节沉进工作日志。员工看到的是同事式汇报,细节要查随时能查 —— 体验和成本一起赢。</p>
</div>
</div>
<div class="cards" style="margin-top:16px">
<div class="card tint"><h4><em>方法</em>先度量,后动刀</h4><p>每拍的输入 token、输出 token、推理字数、耗时全部落 trace;优化是对着数据找最慢的那一环,
不是拍脑袋换模型。<b>同样的方法可以持续用在新活动、新模型上</b>。</p></div>
<div class="card tint"><h4><em>结构</em>贵的能力只花在刀刃上</h4><p>归一、编排这类临场活交给模型;分级、算价、判稳这类确定活交给判定表(零 token、零延迟、零抖动)。
成分拆得越清,<b>单位业务的 AI 成本越低</b> —— 这是三分法在成本上的红利。</p></div>
<div class="card tint"><h4><em>底线</em>省钱不省责任</h4><p>所有优化都在「验收口径不降」的前提下做:全部案例放行、被拒 0、数据逐轮对得上。
快和便宜,从来不以「少一道门」为代价。</p></div>
</div>
<span class="pg">13 / 14</span>
</section>
<!-- ═══ S14 证据与下一步 ═══ -->
<section id="s14" class="slide hero">
<span class="kicker"><i></i>14 · 现状与下一步</span>
<h1>不是概念稿 —— 是跑出来的</h1>
<h2 class="sub" style="margin-bottom:26px">以下全部来自 2026-08 端到端验收记录(测试台真跑 + 浏览器真人走查):</h2>
<div class="stats">
<div class="stat"><b>6 <em>个</em></b><span>真活动包全部到验收线(受理 / 替代 / 寻源 / 定价 / 议价 / 归档),经安装通道装入</span></div>
<div class="stat"><b>16 <em>条</em></b><span>端到端案例全部放行收尾,来件形状各不相同(俄文附件 / 中文正文 / 点名跳料…)</span></div>
<div class="stat"><b>0 <em>次</em></b><span>末轮 AI 行动被拒 —— 每次被拒都当设计欠账修掉,不靠重试蒙混</span></div>
<div class="stat"><b>1.5-4.9<em>s</em></b><span>每拍时延(自 20-56s 优化而来);一单 6-11 拍、五万至十万 token</span></div>
<div class="stat"><b>10 / 10</b><span>A1 连跑十轮全部放行、零被拒 —— 稳定性有基线,动共享面必全量回归</span></div>
<div class="stat"><b>5 <em>道</em></b><span>工程门禁(类型 / 规范 / 依赖 / 契约 / 千余条测试)全绿才许合入</span></div>
</div>
<div class="threeword" style="margin-top:34px">
<span>下一步:七节点全流程连跑常态化</span>
<span>运营度量与评价体系</span>
<span>活动包生态扩展</span>
</div>
<p style="margin-top:30px;font-size:15px;color:#b9c4e6">一句话收尾:<b style="color:#fff">确定交系统,临场交 AI,落地交人</b> —— 效率给机器,责任给人。</p>
<span class="pg">14 / 14</span>
</section>
</body>
</html>
+61 -19
View File
@@ -152,8 +152,8 @@ activities/<id>/src/ ──(pnpm activities:build)──> dist/{manifest.json,in
| 门 | 在哪 | 规则 | | 门 | 在哪 | 规则 |
|---|---|---| |---|---|---|
| **起单宽进** | `process.start` | 不按 Schema 拒缺项;缺 required 顶层键 → 发补全卡(不占预算)。「没法填」给理由即带缺口开跑 | | **起单宽进** | `process.start` | 不按 Schema 拒缺项。三活动 `input.required` 一律空(宽进严出),起单补全卡那条老路实际不再触发(彻底移除记 debt) |
| **执行卡提交闸** | 卡面 `gate` + 服务端 | 卡上绑了台账表的执行单,必填缺着按不下提交(`{ready,why,blanks}`,与放行门 L1 **同一个数同一套算法**);**服务端在收下回传之前也把一遍**(前端灰按钮只是给人看的)。算不出来(卡没绑表)不给闸 —— 没有依据的时候不拦人 | | **执行卡提交闸** | 卡面 `gate` + 服务端 | 必填缺着按不下提交(`{ready,why,blanks}`,与放行门 L1 **同一个数同一套算法**);**服务端在收下回传之前也把一遍**(前端灰按钮只是给人看的)。看哪几张表:卡上绑了 blocks 看绑的;**没绑(执行卡零输入后是常态)回落到必交付物声明的 `ledgers`/`ledger`**(`gateAddressesOf`,有测钉着)。两边都空才不给闸 —— 没有依据的时候不拦人 |
| **提交门**(`submit` 预检) | engines | 机检(L1/L2)**必须为真**;裁量(L3/L4)**裁过即可**(裁成「不满足」照样能提交,裁断连理由随卡交给人 —— 一条只接受「是」的判据是一句教模型说谎的提示);**L4 带谓词时谓词说了算**(谓词真 = 已达标,模型那句相反的理由一并丢掉;谓词假才轮到裁量);必交付物按 ledger 地址数本单草稿行 | | **提交门**(`submit` 预检) | engines | 机检(L1/L2)**必须为真**;裁量(L3/L4)**裁过即可**(裁成「不满足」照样能提交,裁断连理由随卡交给人 —— 一条只接受「是」的判据是一句教模型说谎的提示);**L4 带谓词时谓词说了算**(谓词真 = 已达标,模型那句相反的理由一并丢掉;谓词假才轮到裁量);必交付物按 ledger 地址数本单草稿行 |
| **放行门入场哨兵** | CMMN sentry | **必须有 ifPart,且读基座数、认「办完了没有」** —— 只有 onPart 的哨兵等于「有人写过一笔就开门」,模型写个 `false` 就推开了(实测:跳过三步直达放行门) | | **放行门入场哨兵** | CMMN sentry | **必须有 ifPart,且读基座数、认「办完了没有」** —— 只有 onPart 的哨兵等于「有人写过一笔就开门」,模型写个 `false` 就推开了(实测:跳过三步直达放行门) |
| **熔断** | Harness | 连续 5 拍行动被拒 → 停问模型,发熔断卡把拒绝理由摆给人(比对削掉串位前缀、另开 60 条窗) | | **熔断** | Harness | 连续 5 拍行动被拒 → 停问模型,发熔断卡把拒绝理由摆给人(比对削掉串位前缀、另开 60 条窗) |
@@ -165,13 +165,13 @@ activities/<id>/src/ ──(pnpm activities:build)──> dist/{manifest.json,in
- 已收掉的项按状态分开说(「挂到当下这一步」+ 列出可为的项),别说「先 start」—— - 已收掉的项按状态分开说(「挂到当下这一步」+ 列出可为的项),别说「先 start」——
start 一个 Completed 项同样被拒,两堵墙对着撞。 start 一个 Completed 项同样被拒,两堵墙对着撞。
### 3.4 人类工具:五类卡 ### 3.4 人类工具:四类卡(补全卡已退役)
| 类 | 行动 | 形态与硬规矩 | | 类 | 行动 | 形态与硬规矩 |
|---|---|---| |---|---|---|
| **执行** | `human` + `workOrder:'execution'` | 卡 = 查看工单钮(四字粗体,与验收卡同形)+ ask + **作业指导书**(`steps`+`standard`,与判据同一套版面、不画对勾)+ 依据 + 表单 + 提交闸。指导书写「哪几格必须有、哪几格值得追」,不写「怎么核表」。**`steps`/`standard` 是纯文本** —— 别写 `**` 这类记号,它们不会变粗体,只会原样摆在人眼前 | | **执行** | `human` + `workOrder:'execution'` | 卡 = 查看工单钮(四字粗体,与验收卡同形)+ ask + **作业指导书**(`steps`+`standard`,与判据同一套版面、不画对勾)+ 依据 + 提交闸。**零输入**(2026-08-02 用户定):卡上没有任何填写 —— 不摆 `fields`、不绑 `blocks`,填报全在工作区的件上(人按「保存」基座直写草稿层,**别再 write 一遍**);空表单是常态,基座照发(从前的「表单不得为空」拒发已删)。指导书写「哪几格必须有、哪几格值得追」,不写「怎么核表」。**`steps`/`standard` 是纯文本** —— 别写 `**` 这类记号,它们不会变粗体,只会原样摆在人眼前 |
| **验收** | `submit` | 判据表(读时现算 —— 人在件上补一格判据跟着变;**L5 不上卡**:那张卡本身就是那道关,列进去等于摆一条恒红的项)+ 落地动作 + 可修订值 + 出口(算得出摆 `routedPort` 告知,算不出退 `portChoice` 让人选)。**办结之后换成落地结果**(landing):按交付物点名落了几行、转出哪一口、带过去哪些字段;一个口都没发直说「到此为止」 | | **验收** | `submit` | 判据表(读时现算 —— 人在件上补一格判据跟着变;**L5 不上卡**:那张卡本身就是那道关,列进去等于摆一条恒红的项)+ 落地动作 + 可修订值 + 出口(算得出摆 `routedPort` 告知,算不出退 `portChoice` 让人选)。**办结之后换成落地结果**(landing):按交付物点名落了几行、转出哪一口、带过去哪些字段;一个口都没发直说「到此为止」 |
| **补全** | `ask` | 对话卡就地填;`fields` 空即拒;能预填的带 `value` 预填 | | ~~补全~~ | ~~`ask`~~ | **已全局退役**(2026-08-02 用户定:「执行卡完全就是包含补全语义的人类工具」)—— 缺信息发**零输入执行工单**:指导书写清缺什么,人在工作区的件上补(保存即落草稿层)。旧输出里的 `ask` 由基座原地转成执行工单(归一不拒);引擎侧直发 `ask` 拒并指路 |
| **决策** | `decide` | 选择题(≥2 个选项,少了拒发);人点哪个进轨迹 | | **决策** | `decide` | 选择题(≥2 个选项,少了拒发);人点哪个进轨迹 |
| **录入** | `entry` | **人按下确认那一刻基座直接写库,AI 不经手** —— 建档这类主数据用它。可多段(`also`):建客户 = 公司档 + 联系人档,一张卡两段、确认一次落两行(值按 `段号.列名` 隔开)。卡面摆表的**人读名**(不摆 `customer.profile`);`auto` 列不进表单、另摆一句说明 | | **录入** | `entry` | **人按下确认那一刻基座直接写库,AI 不经手** —— 建档这类主数据用它。可多段(`also`):建客户 = 公司档 + 联系人档,一张卡两段、确认一次落两行(值按 `段号.列名` 隔开)。卡面摆表的**人读名**(不摆 `customer.profile`);`auto` 列不进表单、另摆一句说明 |
@@ -179,8 +179,9 @@ activities/<id>/src/ ──(pnpm activities:build)──> dist/{manifest.json,in
- **一单同一张卡只发一次**。人交回来之后不论还缺什么都不许再发 —— - **一单同一张卡只发一次**。人交回来之后不论还缺什么都不许再发 ——
必填有提交闸兜着(卡回来时必填一定齐);**人留空交回来 = 对方给不了,那也是一个答案**: 必填有提交闸兜着(卡回来时必填一定齐);**人留空交回来 = 对方给不了,那也是一个答案**:
如实记账、缺口照实留着、把这一步收掉往下走。 如实记账、缺口照实留着、把这一步收掉往下走。
- 发卡即挂起,回传即续跑;在途验收先于一切;人答的值出现在下一拍轨迹里, - 发卡即挂起,回传即续跑;在途验收先于一切。**值怎么落**:件上保存的、录入卡确认的,
**看到了要自己 write 落台账**(绑台账的表除外 —— 基座直写)。 基座直写;执行卡零输入(空答复 = 人照原样确认,痕上写明);决策卡选项进轨迹 ——
该记账的记账,**别当没答过再发一张**。
- 调人预算(`humanCallBudget`)环外扣账。 - 调人预算(`humanCallBudget`)环外扣账。
### 3.5 视图渲染(概要,详见《HTML报告生成规范》) ### 3.5 视图渲染(概要,详见《HTML报告生成规范》)
@@ -229,17 +230,17 @@ activities/<id>/src/ ──(pnpm activities:build)──> dist/{manifest.json,in
| 行动 | 形状 | 语义 | | 行动 | 形状 | 语义 |
|---|---|---| |---|---|---|
| `start` | `{itemId}` | 启动标了人工激活的项;**已 Active / 已收掉再按 = 无事发生** | | `start` | `{itemId}` | 显式启动一项(**可省**:2026-08-02 起动它就算启动);已 Active / 已收掉再按 = 无事发生 |
| `plan` | `{itemId}` | 裁量项入场 | | `plan` | `{itemId}` | 裁量项入场 |
| `disable` | `{itemId, reason}` | 此单不走这条 | | `disable` | `{itemId, reason}` | 此单不走这条 |
| `judge` | `{criterion, satisfied, rationale}` | 裁语义性前提 / L3-L4 判据;criterion 照判据名**整句原样抄** | | `judge` | `{criterion, satisfied, rationale}` | 裁语义性前提 / L3-L4 判据;criterion 照判据名**整句原样抄** |
| `write` | `{itemId, address, rows:[{key,patch}]}` | 写草稿。授权面是台账声明;`itemId` 只是记账,挂不上照写不拦;patch 只带有值的列;空转写(一行没变)即拒 | | `write` | `{itemId, address, rows:[{key,patch}]}` | 写草稿。授权面是台账声明;`itemId` 只是记账,挂不上照写不拦;patch 只带有值的列;空转写(一行没变)即拒 |
| `drop` | `{itemId, address, keys:[{主键}], reason}` | 撤本单草稿层的行;主键与写进去时一字不差 | | `drop` | `{itemId, address, keys:[{主键}], reason}` | 撤本单草稿层的行;主键与写进去时一字不差 |
| `tool` | `{itemId, tool, input}` | 读类即调;写类转意向;未命中转人挂起 | | `tool` | `{itemId, tool, input}` | 读类即调;写类转意向;未命中转人挂起 |
| `ask` | `{itemId, ask, fields:[{key,label,hint?,value?}], basis}` | 补全卡,挂起 | | `ask` | ~~已退役~~ | 基座原地转成零输入执行工单(字段名并进指导书);别再用 |
| `entry` | `{itemId, ask, ledger, entryKey, fields, also?}` | 数据录入卡,挂起;确认那一下基座直写 | | `entry` | `{itemId, ask, ledger, entryKey, fields, also?}` | 数据录入卡,挂起;确认那一下基座直写 |
| `decide` | `{itemId, ask, options(≥2), basis}` | 决策卡,挂起 | | `decide` | `{itemId, ask, options(≥2), basis}` | 决策卡,挂起 |
| `human` | `{itemId, workOrder, card{title,ask,steps?,standard?,basis,fields?,blocks?}}` | 执行/验收工单,挂起。`blocks` 里绑 `ledger`+`fields` 的表**由基座铺行、由基座写回**,别自己抄行(回复有长度上限,铺长了截断整拍作废) | | `human` | `{itemId, workOrder, card{title,ask,steps?,standard?,basis}}` | 执行/验收工单,挂起。**执行卡零输入**(2026-08-02 用户定):不摆 `fields` 不绑 `blocks`,填写全在工作区的件上(editGate + 孪生件),人按「保存」基座直写草稿层;必填由提交闸看住(闸回落到必交付物的台账声明) |
| `listen` | `{itemId, eventType, keyFilter, deadline}` | 立监听,挂起;时限必配 | | `listen` | `{itemId, eventType, keyFilter, deadline}` | 立监听,挂起;时限必配 |
| `finish` | `{itemId, rationale}` | 宣告一项完成;非人工激活的项就地过 Active 再收 | | `finish` | `{itemId, rationale}` | 宣告一项完成;非人工激活的项就地过 Active 再收 |
| `submit` | `{rationale, fields?}` | 提交放行 → 预检 → 发验收卡挂起 | | `submit` | `{rationale, fields?}` | 提交放行 → 预检 → 发验收卡挂起 |
@@ -248,16 +249,20 @@ activities/<id>/src/ ──(pnpm activities:build)──> dist/{manifest.json,in
`tool` 之后截断(出参没看见,后面是闭着眼计划的);挂起型收拍;超封顶丢弃留痕。 `tool` 之后截断(出参没看见,后面是闭着眼计划的);挂起型收拍;超封顶丢弃留痕。
拍级附带 `say{text, tag, view?}`,不占行动位。 拍级附带 `say{text, tag, view?}`,不占行动位。
### 4.3 人工激活与 start(话与门同宽) ### 4.3 人工激活:动它就算启动(start 墙已拆)
标了 `manualActivationRule` 的项,**动它之前必须先 `start`** —— 调工具、发卡 标了 `manualActivationRule` 的项,**模型显式动它(调工具/发卡/finish)就算启动**,
(ask/human/decide/entry)、宣告完成(finish)都算,一拍里 start 排在那个动作前面即可。 不必单发 `start`(2026-08-02 拆墙)。它的分量在引擎那一侧,原样保留:
只有 `write` 例外(itemId 只是记账)。 **引擎不因哨兵满足替它启动**(哨兵只推到 Available/Enabled),
**也不给 doneWhen 替人收**(自动收步跳过人工激活项)—— 收它只能是模型的 finish。
实测账(2026-08-02):这条规矩从前在提示词里写成「先 start **再调工具**」—— 实测账(两轮,同一堵墙):先是「话与门同宽」—— 提示词写「先 start 再调工具」只说了
只说了调工具,而门拦的是一整片;模型那一拍发的是 `human`,照字面读规矩管不着它, 调工具,而门拦 tool/ask/human/decide/entry/finish 一整片,模型发 `human` 照字面漏了
于是漏了 start 白烧一拍。**话说的范围必须盖住门拦的范围,话窄一格,模型就正好漏在那一格上。** start 白烧一拍;措辞写全之后**仍然熔断**(A1 一单连拒五拍,五次全是「要先 start」,
写全之后三案例复跑全部被拒 0。 提示词与拒回都写着,它就是学不稳)。这才看清:那道墙保护不了任何东西 ——
start 是**模型自己的**动作,同一串连发即过,拦下只是逼它多演一步礼仪,
而学不稳的礼仪就是死法。**状态机的礼仪不该长成墙**(与 finish 就地过 Active、
start 已收项无事发生同族)。剧本里也别再教 start —— 教它白烧 token。
### 4.4 自动收步(doneWhen) ### 4.4 自动收步(doneWhen)
@@ -431,6 +436,33 @@ A1 是 `customerEmail / subject / body / attachments`)。
--- ---
## 6.5 设计律(2026-08-02/03 一整轮实测沉淀 —— 每条都付过学费)
排障表治的是「这一处坏了」;这几条治的是「为什么总在坏」。改 Harness、写活动、
做报告之前先过一遍:
1. **墙要配路**:任何一道门拦住一件事时,必须存在一个模型(或人)做得出的动作
把事办对 —— 没有出路的门只是死胡同(drop 之于「多写一行」、撤行之于「替代未果」)。
2. **学不稳的礼仪就是死法**:纯礼仪的墙一律拆(start 墙、空表单拒发)。
模型十次里学会九次不算学会 —— 第十次熔断的是用户的单。幂等优于拒回:
按钮按两下不该报错(start / finish / 重发已答卡,同族三例)。
3. **拒回喂对抗,观察喂事实**:拒回的话是模型下一拍的行动指南,指错方向比不给更糟;
头几次犯浑先转成 observation 指路(卡已答过→接着办交回后的事),连犯才算撞墙进熔断。
4. **「机检只读基座数」管的是一整片**:验收判据、收步判据、放行哨兵、**入场哨兵**、
提交闸、红带 —— 任何一处读模型自报的数,那道门就等一个可能永远不来的记账
(第四次违反藏在 s_parsed 哨兵里:行落了门却不开,模型被「依赖断点」推去发卡绕路)。
5. **提示词分层**:全局提示词只说通用真理,活动级细节归说明书 —— 一句全局禁令
(「别再 write 一遍」)能把所有活动的既有约定顶翻,且症状出在别的活动身上,
查起来像闹鬼。改全局面 = 改所有活动,回归也要按「所有活动 × 全部常备案例」跑。
6. **措辞治不了的要结构治**:模型「不查工具直接编结论」这类顽疾,说明书写死到头
仍概率性犯 —— 根治靠机制(规则进判定表由引擎求值 / 工具出参直接带结论只让照抄),
措辞只能降频。一条纪律连续两轮被同一形状踩穿,就该停止改话、开始改结构。
7. **改共享面必须全量回归**:提示词、turn-runner、解析器、容器 —— 动一处,
三活动的全部常备案例都要重跑(A1 新客户路的熔断就是「只回归了老客户路」漏出来的)。
「稳定的活动出错」比新功能不 work 伤得多。
8. **用户重复提的要求,说明上一轮没打中病根**:往下再挖一层,别在同一层再修一遍
(「工作成果 1:1」挖了三层才到「办结那一刻存快照」这个真解)。
## 7. 排障速查表(症状 → 根因 → 处置;全部实测账) ## 7. 排障速查表(症状 → 根因 → 处置;全部实测账)
| 症状 | 根因 | 处置 | | 症状 | 根因 | 处置 |
@@ -445,7 +477,15 @@ A1 是 `customerEmail / subject / body / attachments`)。
| 同一条理由连拒到熔断而数与行都对 | 拒回把「活没干完」说成「数/行不对」 | 已修:`_rows`=0 直说那一步没办 | | 同一条理由连拒到熔断而数与行都对 | 拒回把「活没干完」说成「数/行不对」 | 已修:`_rows`=0 直说那一步没办 |
| 行数对不上,模型只会改数字 | 拒回没摆主键名单 | 已修:`_keys` 名单;照名单 drop | | 行数对不上,模型只会改数字 | 拒回没摆主键名单 | 已修:`_keys` 名单;照名单 drop |
| 「要先 start」而 start 也被拒 | 拒回没按状态分开说 | 已修;start 已收的项无事发生 | | 「要先 start」而 start 也被拒 | 拒回没按状态分开说 | 已修;start 已收的项无事发生 |
| 模型漏 start 白烧一拍 | 提示词的话比门窄(只说了调工具) | 已修:话与门同宽(§4.3) | | 「要先 start」连拒五拍熔断(措辞已写全) | start 墙本身:学不稳的礼仪就是死法 | 已拆墙(2026-08-02):动它就算启动(§4.3) |
| 零输入执行卡被「表单不得为空」拒发到熔断 | 基座旧机检把表单当交付通道 | 已删该拒发:空表单是常态(卡族表「执行」行) |
| 整拍六个动作全对却退化成「需要你判断下一步」 | 模型多吐一个闭括号,单数花括号的配平切出半截 | 已修:括号栈 + 丢不配对闭括号(`parseJsonLoose`,有测) |
| 「需要你判断下一步」卡面摆着一坨 JSON 原话 | 整拍输出没成形,旧兜底一次就发卡还把原话上卡 | 已修(2026-08-03):就地重问一次、原话归工作日志、卡面只说人话(agent-runner,有测) |
| 模型逐字复读一句被明令禁止的话 | **反例把原话引进了提示词**(「别说『…』」)—— 关思考的模型不读否定框,只认框里现成的句子 | 反例只描述、不引原话;要引原话就引**正例**(想听哪句就示范哪句)。A1「照历史价估」逮过(2026-08-03) |
| 执行卡上作业指导书整个是空的(basis 里是「当下可为(空)/目标缺口」这类家具) | **卡骨架写在那一步的剧本里**,而模型在「干活+收步+派卡」压成的一拍里发了它 —— 那一拍该步剧本没摊开,模型手上没有骨架,凭记忆拼出裸卡,基座兜底 basis 又摆上局面家具 | **凡要模型照抄的卡骨架,放说明书不放步骤剧本**(说明书每拍都在;A6 决策卡先立此法,A1 审定卡 2026-08-03 同坑)。另:`steps`/`standard` 已补进出参 schema —— 从前能到卡面纯靠厂商 JSON 模式宽松 |
| 人答完后同一条拒回重复五遍熔断(「这一步已办完,不能再往它上面挂活」) | **重发判断走在站上场之后** —— 人答完那一步常已收掉,`readyToWork` 先抛,于是「重发已答过的卡 = 无事发生」这条活路在最需要时够不着 | 已修(2026-08-03):**先判重发再站上场**;重发一张已答过的卡本就不需要那一项在场(墙要配路,有测) |
| 同一个岔路口连发四张决策卡,调人预算烧光落 `exhausted` | 决策卡没有幂等兜底(执行工单一直有) | 已修:`resendVerdict` 两族共用 —— 头两次无事发生、第三次起算被拒 |
| 收上一步时连拒「写了等于没写」 | 值已在台账(件上保存/上一拍写过),模型照抄重写;或提前干了下一步的活(如在 S2 查行情) | 拒回已带指南「该办下一件事」;剧本明写这一拍只有哪几件事 |
| 人答了卡,模型又发一张一样的 | 剧本残留「还缺就再发一张卡」死路 | §5.5-5:一单一卡;必填有闸兜着 | | 人答了卡,模型又发一张一样的 | 剧本残留「还缺就再发一张卡」死路 | §5.5-5:一单一卡;必填有闸兜着 |
| 人在卡上填了值,台账还是空的 | doneWhen 写成「问过就算完」 | 等「答案落了表」的计数(`answered_total`) | | 人在卡上填了值,台账还是空的 | doneWhen 写成「问过就算完」 | 等「答案落了表」的计数(`answered_total`) |
| 该经过人的一步整个没发生 | 「经过人」写成了收步条件里的半句 | §5.3-4:独立一项+人工激活 | | 该经过人的一步整个没发生 | 「经过人」写成了收步条件里的半句 | §5.3-4:独立一项+人工激活 |
@@ -466,6 +506,8 @@ A1 是 `customerEmail / subject / body / attachments`)。
| 下游网关取不到值 | 出口标量没有台账列承载 | §5.2-② | | 下游网关取不到值 | 出口标量没有台账列承载 | §5.2-② |
| 改了包重装没生效 | 同版重装 no-op | 升 packageVersion | | 改了包重装没生效 | 同版重装 no-op | 升 packageVersion |
| 收口前一两拍预算耗尽 | budget 给太紧 | §5.2-⑦ 留一倍余量 | | 收口前一两拍预算耗尽 | budget 给太紧 | §5.2-⑦ 留一倍余量 |
| 代答脚本交执行卡被拒「必填字段未回填:结果」 | 卡没声明 `fields` 时基座兜底加必填「结果」回执字段 | 交卷 `values.result` 带一句 |
| 验收 pass 后落地判「选择组未选出口」 | routedPort 只在前端回传里顶上(已修:服务端发事件前算有效口) | 升级基座;临时:回传自带 `selectedPorts` |
## 8. 待实现与已知不一致 ## 8. 待实现与已知不一致
+10
View File
@@ -310,6 +310,16 @@ const workOrderBase = {
basis: basisSchema, basis: basisSchema,
stage: stageIdSchema.optional(), stage: stageIdSchema.optional(),
done: workDoneSchema.optional(), done: workDoneSchema.optional(),
/**
* **办结那一刻的件快照**(2026-08-02 用户:「HTML UI、数据、内容、布局都要
* 1:1 地从待处理状态转换为静态报告放到工作成果」)。
*
* 从前工作成果点开渲的是**当下**的任务视图(随后续推进还会变),执行工单的
* 定稿甚至退回卡壳(题头/编号带/blocks)—— 三样没有一样是人办结时看到的那份。
* 现在收下回传那一刻,基座把当时渲好的件原样存这里:成果页 1:1 静态重放,
* 数据、内容、布局与人交卷时一个字不差。
*/
finalHtml: z.string().optional(),
}; };
/** 执行工单:作业指导书 + 空表单(补全并入此类);落地失败卡是它的系统预置形态 */ /** 执行工单:作业指导书 + 空表单(补全并入此类);落地失败卡是它的系统预置形态 */
+13
View File
@@ -126,6 +126,19 @@ export const fieldDeclarationSchema = z.object({
* 同一条规矩的另一半:**人答不上来的,也别摆到他面前假装他该答**。 * 同一条规矩的另一半:**人答不上来的,也别摆到他面前假装他该答**。
*/ */
auto: z.boolean().optional(), auto: z.boolean().optional(),
/**
* **行级必填:同行某列等于某值时,这一列才算必填**(2026-08-02 用户:
* 「工作区的 HTML 必填项必须填写了才能提交啊,不填写能算任务完成了吗?」)。
*
* 逐颗按序派单(A2 的 sel_stage 队列)撞上表级 `required` 的死结:
* 定案三格若整列必填,第一颗的卡会被**第二颗**(还没轮到、在件上根本不可见)
* 的空格拦死 —— 人无处可填,闸成了死路;整列不标,闸又没牙,
* 空手就能交卷(用户实测点名)。
* 这一维把「必填」钉在**该轮到的那一行**上:`{ column: 'sel_stage', value: 'NOW' }`
* 即「排到 NOW 的那一行,这一格必填」。`_missing` / `_missing_keys` /
* 提交闸 / 件上红带全部同源生效;`required` 不带它照旧整列必填。
*/
requiredWhen: z.object({ column: z.string().min(1), value: z.string().min(1) }).optional(),
}); });
export type FieldDeclaration = z.infer<typeof fieldDeclarationSchema>; export type FieldDeclaration = z.infer<typeof fieldDeclarationSchema>;
+225 -26
View File
@@ -38,11 +38,117 @@ export type ViewContainerProps = {
readonly editable?: boolean; readonly editable?: boolean;
/** 人按下「保存」时把那张表收上来;不传就是纯呈现 */ /** 人按下「保存」时把那张表收上来;不传就是纯呈现 */
readonly onEdits?: (save: ViewSave) => void; readonly onEdits?: (save: ViewSave) => void;
/**
* **多页件**(2026-08-02 用户:「一个执行单可以派多个 HTML 待执行报告,
* 合理利用右上角的翻页」):件里带 `data-page` 的可见段各算一页,
* 外层用这两个口翻 —— `page` 显示第几页(0 起),`onPages` 回报共几页。
* 不传 `page` 或只有一页时全部照常显示。
*/
readonly page?: number;
readonly onPages?: (total: number) => void;
}; };
/** 量不到高度时的兜底,避免塌成 0 高 */ /** 量不到高度时的兜底,避免塌成 0 高 */
const FALLBACK_HEIGHT = 420; const FALLBACK_HEIGHT = 420;
/**
* **就地换内容的第二层:只换真变了的节点**(2026-08-03 用户:「保存的时候 HTML 会闪一下」)。
*
* 2026-08-02 那一刀把 srcDoc 重载换成了 `body.innerHTML = html` —— 文档不再重载,
* 可整棵 DOM 仍是**推倒重建**:47KB 样式表在 body 里再解析一遍(head 那份还在,从此一文档
* 两份同样的表)、二十来个 SVG 全部重建、CSS 动画一律回到第 0 帧重新起跳 —— 人眼里就是
* 保存那一下整张件「闪一下」。同一个症状,病根在下一层。
*
* 治法:新旧两棵树**逐节点对齐**,一样的一根手指都不碰 —— 样式表不重解析、精灵不重建、
* 没变的段落连重绘都没有;保存的涟漪只落在真改了的那几个格子上。
*
* 细则:
* · 顶层 `<style>` 单独归位:srcDoc 首帧解析会把它提进 head,而 innerHTML 不会 ——
* 不归位的话新旧顶层节点错一位,逐个对齐全部失配。同文归 head 去重,变了才换文;
* · 输入件的**活值**只在拟值(value 属性)真变时跟上:人刚敲进去的字不因一次
* 无关的重推被擦掉;radio 的 checked 是编辑态的临时物,不由这里管;
* · 任何一步栽了就整体退回 innerHTML 重建 —— 宁可闪一下,不可摆错件。
*/
function morphNode(oldEl: Element, newEl: Element): void {
if (oldEl.outerHTML === newEl.outerHTML) return;
if (oldEl.tagName !== newEl.tagName) {
oldEl.replaceWith(newEl);
return;
}
for (const name of oldEl.getAttributeNames()) {
if (!newEl.hasAttribute(name)) oldEl.removeAttribute(name);
}
for (const name of newEl.getAttributeNames()) {
const value = newEl.getAttribute(name);
if (value !== null && oldEl.getAttribute(name) !== value) {
oldEl.setAttribute(name, value);
// 拟值真变了,活值跟上(平时人敲了一半的字不动)
if (name === 'value' && oldEl.tagName === 'INPUT') (oldEl as HTMLInputElement).value = value;
}
}
const oldKids = Array.from(oldEl.childNodes);
const newKids = Array.from(newEl.childNodes);
const count = Math.max(oldKids.length, newKids.length);
for (let i = 0; i < count; i += 1) {
const o = oldKids[i];
const n = newKids[i];
if (o === undefined) {
if (n !== undefined) oldEl.appendChild(n);
continue;
}
if (n === undefined) {
(o as ChildNode).remove();
continue;
}
const bothElements = o.nodeType === Node.ELEMENT_NODE && n.nodeType === Node.ELEMENT_NODE;
if (!bothElements) {
if (o.nodeType !== n.nodeType) (o as ChildNode).replaceWith(n);
else if (o.textContent !== n.textContent) o.textContent = n.textContent;
continue;
}
morphNode(o as Element, n as Element);
}
}
/** 内容变了:样式表归位 + 逐节点对齐;失败退回整体重建(闪但不错) */
function replaceInPlace(doc: Document, html: string): void {
try {
const tpl = doc.createElement('template');
tpl.innerHTML = html;
// 顶层样式挑出来用 children 过滤 —— :scope 选择器在 DocumentFragment 上实测不命中,
// 样式留在树里会让新旧顶层错一位,逐个对齐全部失配(整树重建,闪照旧)
for (const style of Array.from(tpl.content.children).filter((el) => el.tagName === 'STYLE')) {
const twin = Array.from(doc.head.querySelectorAll('style')).find((s) => s.textContent === style.textContent);
if (twin === undefined) {
const head = doc.head.querySelector('style');
if (head === null) doc.head.appendChild(style);
else head.textContent = style.textContent;
}
style.remove();
}
// 历史现场:上一版 innerHTML 换进 body 的那份样式表,见到就摘(head 已有同文)
for (const stray of Array.from(doc.body.children).filter((el) => el.tagName === 'STYLE')) {
if (Array.from(doc.head.querySelectorAll('style')).some((s) => s.textContent === stray.textContent)) stray.remove();
}
const oldKids = Array.from(doc.body.children);
const newKids = Array.from(tpl.content.children);
const count = Math.max(oldKids.length, newKids.length);
for (let i = 0; i < count; i += 1) {
const o = oldKids[i];
const n = newKids[i];
if (o === undefined) {
if (n !== undefined) doc.body.appendChild(n);
} else if (n === undefined) {
o.remove();
} else {
morphNode(o, n);
}
}
} catch {
doc.body.innerHTML = html;
}
}
/** 一张可改的表的容器(tbody)—— 按 `data-edit` 地址找 */ /** 一张可改的表的容器(tbody)—— 按 `data-edit` 地址找 */
function bodyOf(doc: Document, address: string): HTMLElement | null { function bodyOf(doc: Document, address: string): HTMLElement | null {
return doc.querySelector<HTMLElement>(`[data-edit="${CSS.escape(address)}"]`); return doc.querySelector<HTMLElement>(`[data-edit="${CSS.escape(address)}"]`);
@@ -70,6 +176,13 @@ function collect(doc: Document, address: string): Array<Record<string, string>>
const col = cell.dataset['col']; const col = cell.dataset['col'];
const row = cell.parentElement?.parentElement ?? cell.parentElement; const row = cell.parentElement?.parentElement ?? cell.parentElement;
if (col === undefined || row == null) continue; if (col === undefined || row == null) continue;
/*
* **radio 选项:没选中的不算话,一组都没选 = 这一列不动**(2026-08-02,
* A2 替代等级三选)。radio 的 `value` 是选项值,恒有 —— 照 text 那样读,
* 三个选项互相覆盖、没人点过也会交出一个值,把台账里的原值抹掉;
* 跳过整列则原值保留:「没点」的意思是「不改」,不是「清空」。
*/
if (cell.type === 'radio' && !cell.checked) continue;
const one = byRow.get(row) ?? {}; const one = byRow.get(row) ?? {};
one[col] = cell.value; one[col] = cell.value;
byRow.set(row, one); byRow.set(row, one);
@@ -77,6 +190,36 @@ function collect(doc: Document, address: string): Array<Record<string, string>>
return [...byRow.values()]; return [...byRow.values()];
} }
/**
* **保存那一下,新值就地写进只读孪生件**(2026-08-03 用户:「编辑→保存的时候
* 旧的状态还是会持续一会儿」)。
*
* 保存这条链是:交出去 → POST 落草稿层 → 失效缓存 → 重取任务详情 → 新件推回来。
* 一个来回几百毫秒到一秒多,而闸在按下那一刻就关了 —— 于是这一段时间里屏上摆的是
* **只读那一份的旧值**:人刚敲完 36000,一按保存,格子跳回原来的空白/旧数,
* 看着像这一下没生效(有人会再改一遍、再按一次)。
*
* 值本来就在手上(正是要交出去的那一份),不必等服务端告诉我们。所以按下保存先把
* 每个格子的值写进它自己的只读孪生件,回声到达时 morph 一比对已经一样 —— 一次重绘都没有。
*
* **只镜像格子自己的值**:红带缺几格、分布条、去向这些是**基座算出来的数**,
* 一律等回声(界面不撒谎:自己算一个摆上去,就成了没有依据的画面)。
*
* 认孪生件**按位置不按 `data-slot`**:渲染那一遍会把 `data-slot` 连同槽位一起**消费掉**,
* 活 DOM 里两份谁都没有这个属性(拿它去找,找到的是空,这一段就静默失效)——
* 而 `twinInputs` 是把 input **紧接着**只读那份写出来的,前一个兄弟就是它的孪生件。
* 这条是在真单的 DOM 上查出来的:合成 fixture 里我两份都写了 data-slot,于是照样绿。
*/
function mirrorTwins(doc: Document, address: string): void {
for (const cell of cellsOf(doc, address)) {
// 与 collect 同一条规矩:没选中的 radio 不算话(「没点」= 不改)
if (cell.type === 'radio' && !cell.checked) continue;
const twin = cell.previousElementSibling;
if (twin === null || twin.tagName === 'INPUT') continue;
twin.textContent = cell.value;
}
}
/** 人删掉的那几行 —— 只交主键那几格够认行的值(整行都交也无妨,服务端只看主键) */ /** 人删掉的那几行 —— 只交主键那几格够认行的值(整行都交也无妨,服务端只看主键) */
function collectGone(doc: Document, address: string): Array<Record<string, string>> { function collectGone(doc: Document, address: string): Array<Record<string, string>> {
const host = bodyOf(doc, address); const host = bodyOf(doc, address);
@@ -118,9 +261,32 @@ function addRow(doc: Document, address: string): boolean {
return true; return true;
} }
export function ViewContainer({ html, title = '活动视图', editable = false, onEdits }: ViewContainerProps) { export function ViewContainer({ html, title = '活动视图', editable = false, onEdits, page, onPages }: ViewContainerProps) {
const ref = useRef<HTMLIFrameElement>(null); const ref = useRef<HTMLIFrameElement>(null);
const [height, setHeight] = useState(FALLBACK_HEIGHT); const [height, setHeight] = useState(FALLBACK_HEIGHT);
/*
* 多页件的翻页:`[data-page]` 的**可见**段各算一页(模版里被 CSS 藏掉的行
* 不算 —— A/B 行同样带着标记,但这份报告只翻待选型那几页)。
* 页的显隐用内联 display 盖:非当前页 none,当前页清掉内联让 CSS 接管。
*/
const pageRef = useRef(page);
pageRef.current = page;
const onPagesRef = useRef(onPages);
onPagesRef.current = onPages;
const paginate = useCallback(() => {
const doc = ref.current?.contentDocument;
if (doc?.body == null) return;
const marked = Array.from(doc.body.querySelectorAll<HTMLElement>('[data-page]'));
for (const el of marked) el.style.removeProperty('display');
const visible = marked.filter((el) => doc.defaultView?.getComputedStyle(el).display !== 'none');
onPagesRef.current?.(visible.length);
// 不传 page(如工作成果的静态归档)= 不分页,全部照常纵排
if (pageRef.current === undefined || visible.length <= 1) return;
const current = Math.min(pageRef.current, visible.length - 1);
visible.forEach((el, i) => {
if (i !== current) el.style.display = 'none';
});
}, []);
/* /*
* **`srcDoc` 只用来开第一帧,之后就地换内容**(2026-08-02 用户报的「保存时闪黑」)。 * **`srcDoc` 只用来开第一帧,之后就地换内容**(2026-08-02 用户报的「保存时闪黑」)。
* *
@@ -137,10 +303,17 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
const measure = useCallback(() => { const measure = useCallback(() => {
const doc = ref.current?.contentDocument; const doc = ref.current?.contentDocument;
if (doc?.documentElement == null) return; if (doc?.documentElement == null) return;
/*
* **先挂监听、先落 `editable` class,再量高**(2026-08-02 用户:「工作区的
* 滚轴都拉不到底下的填写区」)。从前这里先量后 bind:量的是编辑闸与红带
* **还没现身**时的高度;bind 一加 class,闸/红带/编辑条冒出来把内容撑高
* 一百多像素,而那之后没有人再量 —— 底部正好裁掉一截,外层滚到底也看不见
* 填回表的尾巴(实测 A2 工作单:量得 2402、实际 2524)。
*/
bindRef.current();
// 取 body 与 documentElement 的较大者:两者谁撑开高度随文档结构而异 // 取 body 与 documentElement 的较大者:两者谁撑开高度随文档结构而异
const next = Math.max(doc.documentElement.scrollHeight, doc.body?.scrollHeight ?? 0); const next = Math.max(doc.documentElement.scrollHeight, doc.body?.scrollHeight ?? 0);
if (next > 0) setHeight(next); if (next > 0) setHeight(next);
bindRef.current();
}, []); }, []);
/* /*
@@ -184,13 +357,17 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
if (hit === null || hit === undefined) return; if (hit === null || hit === undefined) return;
event.preventDefault(); event.preventDefault();
/** 那张表与它的闸一起开合 */ /**
* 那块内容与它的闸一起开合。`.on` 落在哪:行容器是表格的落在 `<table>`
* (老件,样式按 `.jc-tbl-in.on` 写);**不是表格的落在 `data-edit` 元素自身**
* (2026-08-02 A2 对决卡:渲染态即结构,编辑只是点亮其中的输入件 ——
* 容器是 `<section>`,从前这里写死 `closest('table')`,非表格容器永远拿不到
* `.on`,编辑态整个打不开)。
*/
const setOpen = (address: string, on: boolean): void => { const setOpen = (address: string, on: boolean): void => {
doc.querySelector<HTMLElement>(`[data-edit-for="${CSS.escape(address)}"]`)?.classList.toggle('on', on); doc.querySelector<HTMLElement>(`[data-edit-for="${CSS.escape(address)}"]`)?.classList.toggle('on', on);
doc const host = doc.querySelector<HTMLElement>(`[data-edit="${CSS.escape(address)}"]`);
.querySelector<HTMLElement>(`[data-edit="${CSS.escape(address)}"]`) (host?.closest('table') ?? host)?.classList.toggle('on', on);
?.closest('table')
?.classList.toggle('on', on);
}; };
const open = hit.dataset['editOpen']; const open = hit.dataset['editOpen'];
@@ -201,8 +378,9 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
if (open !== undefined) { if (open !== undefined) {
// 快照按下「编辑」那一刻的样子:值 + 那时的行数 —— 「取消」两样都要还原 // 快照按下「编辑」那一刻的样子:值 + 那时的行数 —— 「取消」两样都要还原
// (radio 的 value 是恒定的选项值,快照与还原认的是 checked)
snapshot.current.set(open, { snapshot.current.set(open, {
values: cellsOf(doc, open).map((c) => c.value), values: cellsOf(doc, open).map((c) => (c.type === 'radio' ? (c.checked ? '1' : '') : c.value)),
rows: Array.from(bodyOf(doc, open)?.children ?? []).length, rows: Array.from(bodyOf(doc, open)?.children ?? []).length,
}); });
setOpen(open, true); setOpen(open, true);
@@ -217,7 +395,12 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
while (host.children.length > was.rows) host.lastElementChild?.remove(); while (host.children.length > was.rows) host.lastElementChild?.remove();
for (const row of Array.from(host.querySelectorAll('.jc-row-gone'))) row.classList.remove('jc-row-gone'); for (const row of Array.from(host.querySelectorAll('.jc-row-gone'))) row.classList.remove('jc-row-gone');
} }
cellsOf(doc, cancel).forEach((c, i) => (c.value = was.values[i] ?? c.value)); cellsOf(doc, cancel).forEach((c, i) => {
const saved = was.values[i];
if (saved === undefined) return;
if (c.type === 'radio') c.checked = saved === '1';
else c.value = saved;
});
} }
setOpen(cancel, false); setOpen(cancel, false);
return; return;
@@ -243,6 +426,8 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
*/ */
sink.current?.({ tables: { [save]: collect(doc, save) }, drops: { [save]: collectGone(doc, save) } }); sink.current?.({ tables: { [save]: collect(doc, save) }, drops: { [save]: collectGone(doc, save) } });
snapshot.current.delete(save); snapshot.current.delete(save);
// 关闸之前先把新值摆到只读那一份上 —— 不然人对着旧值等一个来回(见 mirrorTwins)
mirrorTwins(doc, save);
setOpen(save, false); setOpen(save, false);
} }
}); });
@@ -266,34 +451,48 @@ export function ViewContainer({ html, title = '活动视图', editable = false,
}, [editable]); }, [editable]);
bindRef.current = bind; bindRef.current = bind;
/* /*
* 内容变了 —— **就地换,不重载**(见 `seed` 那段注释)。 * 图片 late-load、字体回流、编辑闸开合都会改高度 —— 盯着 body 尺寸变化重量一次。
*
* **observer 与点击监听同一条纪律:每次有机会就重挂到当前真 body**。
* 从前它由 effect 挂一次:挂载那一刻 `contentDocument.body` 还是 srcdoc 之前
* 那份空白文档的 body,监听盯的是一具尸体 —— 之后内容再怎么长高都无人重量,
* 高度停在第一次量的值(实测 A2 工作单:2402 对 2524,底部 122px 在容器外,
* 外层滚轴拉到底也够不着填回区)。
*/
const observerRef = useRef<ResizeObserver | null>(null);
const watchBody = useCallback(() => {
const body = ref.current?.contentDocument?.body;
if (body == null) return;
observerRef.current ??= new ResizeObserver(() => measureRef.current());
observerRef.current.disconnect();
observerRef.current.observe(body);
}, []);
useEffect(() => () => observerRef.current?.disconnect(), []);
/*
* 内容变了 —— **就地换,不重载**(见 `seed` 那段注释);换的那一层也只换
* 真变了的节点(见 `morphNode` —— 保存不闪的关键在那儿)。
* 换完立刻重量一次高度:新件长短不同,不重量会留一截空白或被截断。 * 换完立刻重量一次高度:新件长短不同,不重量会留一截空白或被截断。
*/ */
useEffect(() => { useEffect(() => {
const doc = ref.current?.contentDocument; const doc = ref.current?.contentDocument;
if (doc?.body != null && shown.current !== html) { if (doc?.body != null && shown.current !== html) {
shown.current = html; shown.current = html;
doc.body.innerHTML = html; replaceInPlace(doc, html);
measure(); watchBody();
} }
bind(); // 每次渲染都补一次分页、监听与量高(幂等):翻页/editable 开合都会改高度
}, [bind, html, measure]); paginate();
measure();
useEffect(() => { }, [bind, html, measure, watchBody, paginate, page]);
const frame = ref.current;
const doc = frame?.contentDocument;
if (doc?.body == null) return undefined;
// 图片late-load、字体回流都会改高度 —— 盯着 body 尺寸变化重量一次
const observer = new ResizeObserver(measure);
observer.observe(doc.body);
return () => observer.disconnect();
}, [measure, html]);
const onLoad = useCallback(() => { const onLoad = useCallback(() => {
watchBody();
paginate();
measure(); measure();
bind(); }, [watchBody, paginate, measure]);
}, [measure, bind]);
return ( return (
<iframe <iframe
+618
View File
@@ -0,0 +1,618 @@
/**
* A1 / A2 / A3 / A4 端到端跑测 —— 验收口径:全部 released、行动被拒 0。
*
* node tools/e2e-a2.mjs a2 # 只跑 A2 三案例
* node tools/e2e-a2.mjs a3 # 只跑 A3 三案例
* node tools/e2e-a2.mjs a4 # 只跑 A4 三案例(含决策卡代答)
* node tools/e2e-a2.mjs a6 # 只跑 A6 三案例(客户回音代答 + 决策卡,三口各一条)
* node tools/e2e-a2.mjs a7 # 只跑 A7 三案例(归因代答 + 入库口径决策卡,三档各一条)
* node tools/e2e-a2.mjs a1 # 只跑 A1 回归(执行卡零输入 + 提交闸回落)
* node tools/e2e-a2.mjs # 全跑
* node tools/e2e-a2.mjs a2 1 # 只跑 A2 的第 1 条(0 起)
*
* 前置:./start.sh 起着(api:8787);模型实例已绑密钥。
* 跑完记得:pnpm --filter @app/api reset-runs(清跑出来的,留配置好的)。
*
* 试跑一律用 AI 账号(铁规矩) —— 起单不指派,testbed 默认派给操作者自己。
*
* **执行卡零输入**(2026-08-02 用户定):卡上没有任何表单字段,填写全在工作区的
* 件上(POST /tasks/:id/table,按地址与列名,与真人保存同一条链);交卷载荷
* `values: {}`。A2 逐颗派单:每张卡只把**当前 NOW 那一颗**的行填了再交卷。
* 必填(如 A3 底价、A1 年用量)由提交闸看住 —— 案例里先空手交一次、
* 吃到 400 才算闸真的在(expectGateBlock)。
*/
const BASE = process.env.JCAI_E2E_BASE ?? 'http://localhost:8787';
const PASSWORD = process.env.JCAI_SEED_PASSWORD ?? 'jcai-dev-2026';
/** 每套活动的案例:样例序号 + 人在件上要填的格子(按 pn 对行) */
const SUITES = {
a1: {
activityId: 'quote-intake',
address: 'quote_intake.record',
cases: [
{
sample: 1,
name: '老客户 Itelma · 缺年用量 → 提交闸拦 → 件上补 → 放行(执行卡零输入回归)',
// 缺年用量的行(哪几行缺随模型抽取略有出入)—— 空手交卷必须被服务端的闸拦下
expectGateBlock: true,
edits: [{ pn: 'DAG2400L321504XZB01-R0', eau: '30000' }],
// 兜底:凡年用量还空着的行都补上 —— 闸点名哪行缺,人就补哪行,这里替人扫一遍
fillMissing: { column: 'eau', value: '25000' },
expectPort: 'to-alternative',
},
],
},
a2: {
activityId: 'quote-alternative',
address: 'quote_alternative.list',
// 一张执行单,件里一颗一页:一次把全部待选型行的定案表存,卡交回 values:{}
blankCol: 'alt_pn',
cases: [
{
sample: 0,
name: '流内常路 · 两颗 C 逐颗派单 · 替代无历史 → 转 B 去寻源',
// 定案三格是 NOW 行的必填 —— 空手交卷必须被闸拦(400)
expectGateBlock: true,
edits: [
{
pn: 'BBPY00201209601Y00',
alt_pn: 'PBY201209T-601Y-N',
alt_brand: 'YAGEO',
alt_grade: 'P2P',
alt_diff: '五项全核(封装尺寸/引脚/电气/温度/认证),无差异',
alt_risk: '直换,免改动',
alt_verify: '免打样,首批抽检即可',
alt_note: '客户预授权替代(BOM Analog 列)',
},
{
pn: 'DAG2400L321504XZB01-R0',
alt_pn: 'ANT-2400-KLM3216',
alt_brand: 'Kinsun',
alt_grade: 'FUNC',
alt_diff: '同频段 2.4G,贴片尺寸 3216 同;馈点位置差 0.4mm',
alt_risk: '需微调 layout 馈线;整机方向图需复测',
alt_verify: '打样验证驻波比与方向图',
alt_note: '原厂 EOL,官网规格书 2026-03 版核对',
},
],
expectPort: 'to-sourcing',
},
{
sample: 1,
name: '点名替代 · 替代料库内行情稳 → 转 A 直通定价',
expectGateBlock: true,
edits: [
{
pn: 'TL072CDR',
alt_pn: 'CJTB0101R6N',
alt_brand: 'Jiangsu Changjing',
alt_grade: 'FUNC',
alt_diff: '封装同 SOIC-8,引脚兼容;压摆率低 0.5V/us',
alt_risk: '音频链路无影响;高速采样场景需评估',
alt_verify: '建议打样验证增益带宽与噪声',
alt_note: '国产替代,客户接受国产优先',
},
],
expectPort: 'to-pricing',
},
{
sample: 2,
name: '单飞宽进 · 类型需求归一 → 替代无历史转 B',
expectGateBlock: true,
// 单飞的行是 AI 归一出来的,pn 事先不知道 —— 置空表示「填当前 NOW 那一颗」
edits: [
{
pn: '',
alt_pn: 'GS8558-SR',
alt_brand: 'Gainsil',
alt_grade: 'FUNC',
alt_diff: '供电 2.7-36V 覆盖;AEC-Q100 一级;失调电压略大',
alt_risk: '外围无需改动;精密测量场景需复核失调',
alt_verify: '打样验证失调与温漂',
alt_note: '原厂官网规格书核对,版本 2025-11',
},
],
expectPort: 'to-sourcing',
},
],
},
/*
* A4 成本定价:归一算建议价 → 一张执行单交 BD **定价** → **超限才叫人拍板**(决策卡)
* → 放行。必填列是 `quoted_price`(拟报单价):**这一列 AI 一格都不写**,
* 系统只给 `suggested_price`(建议价)—— 所以三条案例都必然先吃一次 400,
* 闸真不真在这里每一单都验一遍(结构治:不靠模型分辨「这行算得出/算不出」)。
*/
a4: {
activityId: 'quote-pricing',
address: 'quote_pricing.draft',
blankCol: 'quoted_price',
cases: [
{
sample: 0,
name: '寻源转来 · 两条底价齐 · 定的价都在底价线之上(常路,不叫人拍板)',
expectGateBlock: true,
edits: [
{ pn: 'BBPY00201209601Y00', quoted_price: '0.085', price_note: '客户长单,按目标价接' },
{ pn: 'CJTB0101R6N', quoted_price: '0.42', price_note: '照建议价' },
],
expectPort: 'draft',
},
{
sample: 1,
name: '客户压价 · 定的价低于底价线两成以上 → 决策卡叫人拍板(回调到底价线)',
expectGateBlock: true,
edits: [
// 0.06 对底价线 0.0943(成本 0.082 × 1.15)—— 偏离 −36%,门禁必判拦截
{ pn: 'PESD2ETH-XR', quoted_price: '0.06', price_note: '客户拿竞对价压,先照客户价挂着' },
{ pn: 'NE555P', quoted_price: '1.05', price_note: '照建议价' },
],
// 决策卡上选哪一条:回调到底价线 —— 选项值对不上时退回「推荐项 / 第一项」
decide: 'floor',
expectDecision: true,
expectPort: 'draft',
},
{
sample: 2,
name: '单飞宽进 · 库内无基准连建议价都没有 → 价全由人定(空手交卷吃 400)',
expectGateBlock: true,
edits: [
{ pn: '', quoted_price: '14.8', price_note: '按同类电源模块经验价,毛利 18%' },
{ pn: '', quoted_price: '2.35', price_note: '按同类稳压器经验价,毛利 20%' },
],
expectPort: 'draft',
},
],
},
/*
* A6 报价议价:归一排版报价函 → 一张执行单交 BD(发出去 + 把**客户回音**落回来)
* → 逐条定案 → **还价才叫人拍板**(决策卡)→ 放行,三口的去向由谓词算出来。
*
* 必填列是 `reply_type`(客户表态):**这一列 AI 一格都不写** —— 客户回了什么
* 只有收信的人知道。所以三条案例都必然先吃一次 400,闸真不真每一单都验一遍。
* **客户回音就是靠这一手喂进去的**:脚本扮演 BD,把客户的话按地址存进件上
* (`POST /tasks/:id/table`),再零输入交卷 —— 与真人在工作区填格子同一条链。
* 三条案例覆盖三个出口:won / revise(决策卡拍板)/ lost。
*/
a6: {
activityId: 'quote-negotiate',
address: 'quote_negotiate.line',
blankCol: 'reply_type',
cases: [
{
sample: 0,
name: '流内常路 · 客户一轮全接受 → 成交转履约(不叫人拍板)',
expectGateBlock: true,
edits: [
{ pn: 'BBPY00201209601Y00', reply_type: 'ACCEPT', reply_note: '客户回:按这个价走,PO 下周发' },
{ pn: 'CJTB0101R6N', reply_type: 'ACCEPT', reply_note: '客户回:可以,一起下' },
],
expectPort: 'won',
},
{
sample: 1,
name: '客户压价两成 · 让幅超授权 → 决策卡拍板退回定价重批',
expectGateBlock: true,
edits: [
// 报价 0.098 → 客户还 0.078:让幅 20.4%,让步梯度必判 ESCALATE(超授权)
{
pn: 'PESD2ETH-XR',
reply_type: 'COUNTER',
counter_price: '0.078',
reply_note: '客户回:竞对给到 0.078,这个价才做,不然这颗给别家',
},
{ pn: 'NE555P', reply_type: 'ACCEPT', reply_note: '客户回:这颗没问题' },
],
// 决策卡上选哪一条:退回成本定价重批 —— 选项值对不上时退回「推荐项 / 第一项」
decide: 'revise',
expectDecision: true,
expectPort: 'revise',
},
{
sample: 2,
name: '单飞宽进 · D+3/D+7 两轮催问无回复 → 超时定案转情报归档',
expectGateBlock: true,
// 单飞的行是 AI 归一出来的,pn 事先不知道 —— 置空表示「填当前待填那一行」
edits: [
{ pn: '', reply_type: 'NOREPLY', reply_note: 'D+3 已催、D+7 最后一催,客户始终未表态' },
{ pn: '', reply_type: 'NOREPLY', reply_note: 'D+3 已催、D+7 最后一催,客户始终未表态' },
],
// 兜底:凡表态还空着的行都按无回复落 —— 闸点名哪行缺,人就补哪行
fillMissing: { column: 'reply_type', value: 'NOREPLY' },
expectPort: 'lost',
},
],
},
/*
* A7 情报归档:归一情报卡 → 一张执行单交 BD(逐条落**输赢归因**与竞对价)
* → 按败价幅度 A-01 算档 → **入库口径要人拍板**(决策卡)→ 放行,一口 archived。
*
* 必填列是 `cause_tag`(输赢归因):**这一列 AI 一格都不写** —— 客户为什么不下单
* 只有跟单的人知道。所以三条案例都必然先吃一次 400,闸真不真每一单都验一遍。
* 归因落 PRICE 的行还有一道**行级必填**(`competitor_price`),
* 案例一与案例三各验一遍:归因与竞对价一起存,闸才放行。
* 决策卡**每一单都发**(口径是一条对外边界,不由系统自己越过)——
* 三条案例的 `decide` 各选一档:全量 / 脱敏 / 只留内部。
*/
a7: {
activityId: 'quote-archive',
address: 'quote_archive.intel',
blankCol: 'cause_tag',
cases: [
{
sample: 0,
name: '流内失单 · 竞对压两成 → 归因 PRICE + 竞对价 → 脱敏入库',
expectGateBlock: true,
edits: [
// 报价 0.098 → 竞对 0.078:价差 20.4%,败价幅度必判 STRUCTURAL
{
pn: 'PESD2ETH-XR',
cause_tag: 'PRICE',
competitor_price: '0.078',
competitor_name: '深圳华强某代理',
evidence: '客户原话:别家给到 0.078,这个价才做',
},
{
pn: 'NE555P',
cause_tag: 'RELATION',
evidence: '客户原话:这颗一直在别家拿,一起给他了',
},
],
// 决策卡上选哪一档:脱敏入库 —— 选项值对不上时退回「推荐项 / 第一项」
decide: 'masked',
expectDecision: true,
expectPort: 'archived',
},
{
sample: 1,
name: '成交归档 · 胜在交期与既有关系 → 全量入库(竞对价那道行级闸不该拦)',
expectGateBlock: true,
edits: [
{ pn: 'BBPY00201209601Y00', cause_tag: 'LEADTIME', evidence: '客户原话:你们八周能到,别家要十四周' },
{ pn: 'CJTB0101R6N', cause_tag: 'RELATION', evidence: '客户原话:这颗一直用你们的,一起下' },
],
decide: 'full',
expectDecision: true,
expectPort: 'archived',
},
{
sample: 2,
name: '单飞宽进 · 客户始终未表态 → 归因 OTHER · 只留内部',
expectGateBlock: true,
// 单飞的行是 AI 归一出来的,pn 事先不知道 —— 置空表示「填当前待填那一行」
edits: [
{ pn: '', cause_tag: 'OTHER', evidence: 'D+3 已催、D+7 最后一催,客户始终未获反馈' },
{ pn: '', cause_tag: 'OTHER', evidence: 'D+3 已催、D+7 最后一催,客户始终未获反馈' },
],
// 兜底:凡归因还空着的行都按说不清落 —— 闸点名哪行缺,人就补哪行
fillMissing: { column: 'cause_tag', value: 'OTHER' },
decide: 'internal',
expectDecision: true,
expectPort: 'archived',
},
],
},
a3: {
activityId: 'quote-sourcing',
address: 'quote_sourcing.quote_list',
cases: [
{
sample: 0,
name: '选型转来 · 替代料问价(A 行历史价预填)',
edits: [{ pn: 'BBPY00201209601Y00', supplier: '深圳华强渠道', base_price: '0.058', moq: '3000', source_lead_time: '6周', note: '两家比价取低' }],
expectPort: 'quotation',
},
{
sample: 1,
name: '受理直达 · 两颗 B 类问价',
edits: [
{ pn: 'PESD2ETH-XR', supplier: 'Nexperia 代理商', base_price: '0.112', moq: '10000', source_lead_time: '8周', note: '' },
{ pn: 'NE555P', supplier: 'TI 现货商', base_price: '0.85', moq: '2500', source_lead_time: '4周', note: '现货实拍已核' },
],
expectPort: 'quotation',
},
{
sample: 2,
name: '单飞宽进 · 正文列料问底价(空手交卷先吃 400 —— 闸在)',
// 底价是必填列:没填之前交卷必须被拦;填齐两行再交
expectGateBlock: true,
edits: [
{ pn: '', supplier: '现货商甲', base_price: '0.05', moq: '3000', source_lead_time: '现货', note: '库存实拍已核' },
{ pn: '', supplier: '现货商乙', base_price: '0.21', moq: '1000', source_lead_time: '现货', note: '库存实拍已核' },
],
expectPort: 'quotation',
},
],
},
};
let cookie = '';
async function api(path, init = {}) {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: { 'content-type': 'application/json', cookie, ...(init.headers ?? {}) },
});
const text = await res.text();
let body;
try {
body = JSON.parse(text);
} catch {
body = text;
}
if (!res.ok) {
const err = new Error(`${init.method ?? 'GET'} ${path} → ${res.status}: ${text.slice(0, 300)}`);
err.status = res.status;
throw err;
}
return { res, body };
}
async function login() {
const res = await fetch(`${BASE}/api/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ account: 'AI', password: PASSWORD }),
});
if (!res.ok) throw new Error(`登录失败:${res.status} ${await res.text()}`);
const raw = res.headers.get('set-cookie') ?? '';
cookie = raw.split(';')[0] ?? '';
if (!cookie.includes('jcai_session')) throw new Error(`没拿到会话 cookie:${raw}`);
}
async function loadSample(activityId, index) {
const { readFile } = await import('node:fs/promises');
const manifest = JSON.parse(await readFile(new URL(`../activities/${activityId}/dist/manifest.json`, import.meta.url), 'utf8'));
const sample = manifest.samples[index];
if (!sample) throw new Error(`${activityId} 没有第 ${index} 条样例`);
return sample;
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** 被拒的行动 —— 轨迹里认「行动被拒」的痕(标题形状照 turn-runner 的拒回) */
function rejectedCount(trail) {
return trail.filter((t) => t.kind === 'action-rejected' || /行动被拒|被拒[::]|拒绝执行/.test(t.title ?? '')).length;
}
/** 草稿层的行(带主键),按台账地址取 */
function draftRows(drafts, address) {
return (drafts ?? []).filter((d) => d.address === address).map((d) => ({ ...d.key, ...d.patch }));
}
/** 交卷执行卡 —— 载荷零字段(执行卡上没有任何输入,填写全在件上) */
async function submitExecution(cardId) {
await api('/api/desk/work-orders/reply', {
method: 'POST',
body: JSON.stringify({ reply: { mode: 'execution', workOrderId: cardId, values: {} } }),
});
}
/** 空手交卷,期望被提交闸拦下(400)—— 闸不在才是要报的错 */
/**
* 空手交卷该不该被拦 —— 返回 `'blocked'` / `'nothing-missing'` / `'leaked'`。
*
* **闸只在真有缺项时才该出手**(2026-08-03 实测逼出来的区分):
* 这条断言从前写成「必须吃 400」,于是模型碰巧把该填的格子全填上的那些轮
* (A1 把缺的年用量补齐了、A2 客户点名的替代料型号本来就在来件里)一律被判成
* 「提交闸失效」—— 而闸好端端的,拿一单直接空手交卷实测吃的就是 400。
* **断言要分清「闸没拦」与「压根没东西可拦」**,否则它报的失败是假的,
* 真失效那天反而没人信它。
*/
async function expectGateBlocks(cardId, taskId, suite, kase) {
try {
await submitExecution(cardId);
} catch (error) {
if (error.status === 400) return 'blocked';
throw error;
}
/*
* 没被拦:去数**这一单该填的那一列**还空着几格(用与 buildEdits 同一份草稿、
* 同一个列名 —— 两处认的必须是同一件事,否则这条断言自己就会说谎)。
* 一格都不空 = 模型这一轮把该填的全填上了,闸无需出手;还空着却放过去 = 真失效。
*/
const column = kase.blankCol ?? suite.blankCol ?? kase.fillMissing?.column;
if (column === undefined) return 'nothing-missing';
const { body } = await api(`/api/gov/testbed/activity/${taskId}`);
const rows = draftRows(body.drafts ?? [], suite.address);
const blank = rows.filter((r) => String(r[column] ?? '').trim() === '').length;
return blank > 0 ? 'leaked' : 'nothing-missing';
}
/**
* 决策卡 —— 它是**对话卡**,不在试跑观测面的工单摞里(那一摞只有执行/验收),
* 要从工作台的任务详情取:`chat` 里 `k === 'decision'` 且还没 `chosen` 的那几张。
*
* 选项按案例声明的 value 认;认不上退「推荐项 → 第一项」——
* 代答脚本不该因为模型换了个选项名就判活动失败。
*/
async function answerDecisions(taskId, kase, answered) {
const { body } = await api(`/api/desk/tasks/${taskId}`);
let count = 0;
for (const card of body.chat ?? []) {
if (card.k !== 'decision' || card.chosen !== undefined || answered.has(card.id)) continue;
const options = card.options ?? [];
if (options.length === 0) continue;
const picked =
options.find((o) => o.value === kase.decide) ?? options.find((o) => o.recommended === true) ?? options[0];
await api('/api/desk/chat-cards/reply', {
method: 'POST',
body: JSON.stringify({ reply: { k: 'decision', cardId: card.id, value: picked.value, note: '试跑代答' } }),
});
answered.add(card.id);
count += 1;
console.log(` 决策卡拍板:${card.ask?.slice(0, 40) ?? ''} → ${picked.label ?? picked.value}`);
}
return count;
}
/** 把案例声明的格子对齐成保存载荷(pn 为空 = 按序发给待填行;fillMissing 兜底扫空) */
function buildEdits(suite, kase, drafts) {
const rows = draftRows(drafts, suite.address);
const pending = rows.filter((r) => String(r[suite.blankCol ?? 'base_price'] ?? '') === '');
const lines = [];
const taken = new Set();
for (const [i, edit] of kase.edits.entries()) {
const { pn, ...cells } = edit;
const target = pn !== '' ? rows.find((r) => String(r.pn) === pn) : pending[i];
if (!target) {
console.log(` ⚠️ 找不到要填的行(pn=${pn || `待填第${i}行`});现有行:${rows.map((r) => r.pn).join(', ')}`);
continue;
}
taken.add(String(target.pn));
lines.push({ pn: String(target.pn), ...cells });
}
if (kase.fillMissing) {
const { column, value } = kase.fillMissing;
for (const r of rows) {
if (String(r[column] ?? '') === '' && !taken.has(String(r.pn))) {
lines.push({ pn: String(r.pn), [column]: value });
}
}
}
return lines;
}
async function runCase(suite, kase) {
const sample = await loadSample(suite.activityId, kase.sample);
console.log(`\n▶ ${suite.activityId} · ${kase.name}`);
const { body: started } = await api('/api/gov/testbed/run-activity', {
method: 'POST',
body: JSON.stringify({ activityId: suite.activityId, data: sample.input, message: sample.message ?? '', ownerId: '' }),
});
const taskId = started.taskId;
console.log(` 起单 ${taskId}`);
const replied = new Set();
const retries = new Map();
const decided = new Set();
let gateBlocked = false;
let decisions = 0;
const deadline = Date.now() + 20 * 60 * 1000;
let last;
while (Date.now() < deadline) {
await sleep(3000);
const { body } = await api(`/api/gov/testbed/activity/${taskId}`);
last = body;
if (body.terminal) break;
// 熔断卡 = 这一条案例已经失败 —— 别去答它,留着轨迹好查
const stalled = (body.cards ?? []).find((c) => !c.done && /推不动了/.test(c.title ?? ''));
if (stalled) {
console.log(` ✗ 熔断交人:${stalled.title}`);
last = body;
break;
}
// 决策卡挂在对话区,不在这一摞里 —— 每轮顺手看一眼有没有等着人点的
decisions += await answerDecisions(taskId, kase, decided);
for (const card of body.cards ?? []) {
if (card.done || replied.has(card.id)) continue;
if (card.mode === 'execution') {
if (kase.expectGateBlock && !gateBlocked) {
const verdict = await expectGateBlocks(card.id, taskId, suite, kase);
gateBlocked = verdict !== 'leaked';
console.log(
verdict === 'blocked'
? ` 闸拦住空手交卷(400)—— 如设计`
: verdict === 'nothing-missing'
? ` 这一轮该填的格子模型全填上了,闸无需出手(不是失效)`
: ` ⚠️ 缺着必填还放过去了:提交闸失效`,
);
}
const lines = buildEdits(suite, kase, body.drafts ?? []);
if (lines.length > 0) {
await api(`/api/desk/tasks/${taskId}/table`, {
method: 'POST',
body: JSON.stringify({ tables: { [suite.address]: lines } }),
});
console.log(` 件上保存 ${lines.length} 行(${card.title})`);
}
try {
await submitExecution(card.id);
} catch (error) {
if (error.status !== 400) throw error;
/*
* **「已回传」不是「闸拦着」** —— 两种 400 长得一样,处置正相反。
* 这一单交过了(卡还挂在开着的名单里多半是投影慢了一拍),该记下交过、往下走;
* 当成闸拦着就会原样再交一遍,再吃一个 400,三次判失败 ——
* 实测把一单 released 的 A1 记成了失败(2026-08-03)。
*/
if (/already-answered|已回传/.test(String(error.message))) {
console.log(` 这张卡已经交过了,接着往下走(${card.title})`);
replied.add(card.id);
continue;
}
// 闸还拦着(保存后仍有缺)—— 下一轮按最新草稿再补再交,连拦三次判失败
retries.set(card.id, (retries.get(card.id) ?? 0) + 1);
console.log(` 闸仍拦着(第 ${retries.get(card.id)} 次):${String(error.message).slice(0, 160)}`);
if ((retries.get(card.id) ?? 0) >= 3) throw error;
continue;
}
console.log(` 交卷执行卡:${card.title}`);
replied.add(card.id);
} else if (card.mode === 'acceptance') {
await api('/api/desk/work-orders/reply', {
method: 'POST',
body: JSON.stringify({ reply: { mode: 'acceptance', workOrderId: card.id, verdict: 'pass', values: {} } }),
});
console.log(` 验收通过:${card.title}`);
replied.add(card.id);
}
}
}
const rejects = rejectedCount(last?.trail ?? []);
const fired = (last?.trail ?? []).find((t) => t.kind === 'release-verdict' && t.detail?.ports);
const ports = fired?.detail?.ports ?? [];
let ok = last?.terminal === 'released' && rejects === 0;
console.log(
` → ${last?.terminal ?? '超时'}${last?.reason ? `(${last.reason})` : ''} · 被拒 ${rejects} · ` +
`${last?.used?.steps ?? '?'} 拍 / ${Math.round((last?.used?.tokens ?? 0) / 1000)}k token · ` +
`决策卡 ${decisions} 张 · 发口 [${ports.join(', ')}]`,
);
if (kase.expectGateBlock && !gateBlocked) {
console.log(' ⚠️ 提交闸没拦住空手交卷 —— 判失败');
ok = false;
}
// 「超限才叫人」是 A4 的要害:该叫的那一单没叫,等于底价门禁是摆设
if (kase.expectDecision && decisions === 0) {
console.log(' ⚠️ 超限条目没走决策卡 —— 判失败');
ok = false;
}
if (kase.expectPort && !ports.includes(kase.expectPort)) {
console.log(` ⚠️ 期望口 ${kase.expectPort} 未在发射名单里`);
}
if (!ok) {
console.log(' —— 轨迹末 12 条 ——');
for (const t of (last?.trail ?? []).slice(-12)) console.log(` [${t.kind}] ${t.title}`);
}
return ok && (!kase.expectPort || ports.includes(kase.expectPort));
}
async function main() {
const which = process.argv[2] ?? 'all';
const only = process.argv[3] === undefined ? undefined : Number(process.argv[3]);
await login();
const suites = which === 'all' || which === 'both' ? ['a1', 'a2', 'a3', 'a4', 'a6', 'a7'] : [which];
let pass = 0;
let total = 0;
for (const key of suites) {
const suite = SUITES[key];
if (!suite) throw new Error(`没有这套:${key}`);
for (const [i, kase] of suite.cases.entries()) {
if (only !== undefined && i !== only) continue;
total += 1;
if (await runCase(suite, kase)) pass += 1;
}
}
console.log(`\n${pass}/${total} 通过;记得 pnpm --filter @app/api reset-runs 清运行数据`);
process.exit(pass === total ? 0 : 1);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});