Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70e83d0b09 | ||
|
|
ade0fa5a94 | ||
|
|
85466e815f | ||
|
|
ec47fd46a0 | ||
|
|
a9e66ceb39 | ||
|
|
3cf9f10b86 | ||
|
|
259192f736 | ||
|
|
94f1c7a4ef | ||
|
|
1d2d5ebb53 | ||
|
|
d1ddf5f481 |
@@ -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 守则(对比度/触达面积/反馈位置) | **取** | 出稿前对照检查 |
|
||||
|
||||
**取的东西只进脑子,不落盘**——查询结果不存文件,消化成设计决定写进件里。
|
||||
Binary file not shown.
@@ -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()
|
||||
@@ -8,3 +8,9 @@ dist/
|
||||
.env.local
|
||||
coverage/
|
||||
.vite/
|
||||
|
||||
# 本地开发日志(./start.sh 写的)
|
||||
.dev-logs/
|
||||
|
||||
# html-one-pager 技能的评测工作区(skill-creator 产物,不入库)
|
||||
.claude/skills/html-one-pager-workspace/
|
||||
|
||||
@@ -0,0 +1,837 @@
|
||||
# JCAI-WOS —— 企业级 AI Native 任务操作系统
|
||||
|
||||
> 一句话:组织的工作定义为可编排的工作流,工作流由**活动**编排而成;活动是完成一项任务的
|
||||
> Agent 机制,系统外开发、安装即用。人只在责任点与放行点出现 —— 确定交系统,临场交 AI,落地交人。
|
||||
|
||||
## 现状快照(2026-08-02)
|
||||
|
||||
- **三面一体**:设计中心 `/design`(九区:蓝图/触发器/流程包/活动库/工具箱/数据域/知识库/
|
||||
大模型/**测试台:三格 —— 流程(投一封信)/ 活动(按契约填入参)/ 日志(历次试跑存档)**)· 运营中心 `/ops`(六区:驾驶舱/工作流/任务/员工/台账/系统)· 工作台 `/`。
|
||||
`/gov` 302 → `/ops`。
|
||||
- **编排层与流程引擎已跑通并测死**:起单宽进(门口不拒缺项)、数据按字段名流动(无强制
|
||||
类型)、边只表达顺序与条件(对齐 Mastra)、闭合校验只查结构闭合 + 活动装载、一条流恰一个
|
||||
生效版本、在途按启动版本跑完、版本号与名归设计人员自己定。
|
||||
- **活动一律案例模型**(2026-07-29 范式退役):flow-engine 已删,业务模型只有
|
||||
CMMN + 说明书 + DMN 一形;`defineActivity` 已去 `paradigm`,`run` 改为可选 ——
|
||||
行为全在清单里。`turn-runner` 每拍要 `agent.decide` —— **环境无模型密钥时
|
||||
案例活动一步走不动**,三套端到端(unified-quote-flow / new-package-flow / testbed-trace)
|
||||
整套跳过,恢复条件写在各文件头。
|
||||
- **A1 客户RFQ受理已是真活动包**(2026-07-30):`activities/quote-intake@2.0.0` 经**安装通道
|
||||
读文件**装入(`seed-activities.ts` 读 `dist/{manifest.json,index.js}`,不经 import ——
|
||||
与 activities 之间只有安装通道)。五对象台账(record / type_table / verdict / alt_request /
|
||||
rfq_request)、两张判定表(F-01 完整度 · P-03 三分类)、三条技能、四张 HTML 报告模版;
|
||||
**案例模型照 SOP 图一步一项**(S1…S6 + 放行,7 项,扁平无阶段)—— v2.0.0 重写:
|
||||
上一版 12 项(擅自加阶段/里程碑/裁量项)让 Agent 全程跟 harness 较劲,实测无限死循环;
|
||||
流程包 A1 节点已指向它。其余**五**槽位照旧占位。
|
||||
- **模型实例**:只留 `deepseek-v4-flash` / `deepseek-v4-pro` 两条;种子读不到
|
||||
`JCAI_DEEPSEEK_API_KEY` 就**不绑密钥**(卡上如实显示不可用),去治理台大模型区补。
|
||||
- **A1 已真跑通**(2026-07-30):起单 → Loop 逐拍 → 写台账草稿 → 提交放行 → 人签 →
|
||||
releaseWrite 合并进正式版 → 发射 intake 口 → `released`。三分类与双去向旗业务正确
|
||||
(A 直通 / B 寻源 / C 选型)。为跑通它补了 Loop 的七个洞:`write` 行动 · `finish` 宣告完成 ·
|
||||
进行中的项进局面 · 工具清单进单拍提示词 · 里程碑自达成 · 在途验收不被兜底 save 擦掉 ·
|
||||
必交付物只数本单草稿。**A1 的槽位 `rfq-intake` 已退役卸载**(库里两个同名活动会骗人)。
|
||||
- **A1 两条路径都跑通并零卡壳**(2026-07-30):常路五步与最长路径(含 S2 补全卡来回)
|
||||
各跑一遍 `released`,**行动被拒 0 次**。补全卡走对话卡族(`ask` 行动);
|
||||
连续 5 拍被拒即熔断交人(`stalledTurns`)。
|
||||
- **一拍一串行动**(2026-07-30,`AgentTurn.actions`,封顶 6):从前一拍只许一个行动,
|
||||
每个 write/finish/start 都单烧一遍完整上下文 —— A1 六步恰花 21 拍(19 拍是该约束下的
|
||||
最短路径)。收成串后 **7 拍 released、56k token**(自 21 拍 173k);纪律引擎强制:
|
||||
被拒停在谁那儿(前缀生效)、tool 后截断、挂起型收拍。浏览器 UI 点「确认通过」→
|
||||
released 全链亦验证过。
|
||||
- **写的授权面是台账声明,不是计划项**(2026-07-30 修死锁):`write` 的 itemId 降为记账,
|
||||
挂不上(项已完成/人工激活未 start/阶段/不存在)照写不拦、留痕注明。此前绑死在项生命周期上,
|
||||
实测死锁:S5 收完才发现打标没写全,能挂的项全 Completed,每拍补写被「要先 start T_release」
|
||||
拒回,连拒五拍熔断 —— 而数据本身合规。台账声明与空转检查仍是硬门。
|
||||
同批修:`finish` 对非人工激活的项就地过 Active 再收(白按一下省一拍);
|
||||
事件到达痕**只记 task 层一条**(此前 task 与 loop 各记一条,同一次回传日志三行像结巴)。
|
||||
最长路径(含补全卡人环)复测:**8 拍 released、64k token、行动被拒 0 次**。
|
||||
- **每拍时延 20-56s → 1.5-4.9s**(2026-07-30):trace 拆出 tokIn/tokOut/reasonChars 实证 ——
|
||||
慢全在**隐藏推理解码**(最慢拍可见 1538 字、推理 9808 字)。`ModelCall.reasoning: 'off'`
|
||||
经 provider-profile 的 `reasoningControl` 译成 DeepSeek `thinking:{type:'disabled'}`
|
||||
(探针核过;`low` 在该厂无效)。配套:write patch 只带有值列、scope 摊平字段前置整表殿后
|
||||
(rest 3000→1800)、轨迹剔除装配技能戳、旧出参截 120 字、技能戳版本不变不重写、温度 0.2。
|
||||
off 档一次误判(缺目标价当关键字段)以**说明书写死规则**治(「关键字段只有 PN/EAU/交期」),
|
||||
不给思考额度 —— 判断归判定表与说明书,模型只做编排。复测:7 拍 49k token released、零被拒。
|
||||
- **工作台交互按 PRD 补全**(2026-07-30):①对话区点卡 → 视图区 locate 跳转(待办跳页位、
|
||||
办结跳成果详情);②工作成果页签真实现(一行一份 → 定稿报告[印章+verdict+嵌 HTML 汇报] →
|
||||
返回列表);③执行工单挂作业指导书卡组二;④工单办结回执以人事件进对话流,引擎内部观察
|
||||
(internal 标)与裸「事件到达(human)」不再污染对话区;⑤空待处理四态分明(推进中动画 /
|
||||
等外部回音 / 都办完了 / 已收尾)。
|
||||
- **AI 工作中动画**(2026-07-30):对话区**办事条**(均衡条,语义「在办事」不是「在打字」;
|
||||
三点气泡仍留给回话中)+ 头像呼吸环;空工作区**推进场景**(涟漪 + 微光工作线)。
|
||||
纯 CSS、只动 transform/opacity、prefers-reduced-motion 全静止;
|
||||
状态源自任务投影 `runtime === 'running'`,等人/等外部/已收尾不亮 —— 界面不撒谎。
|
||||
- **活动开发规范已重写为开发手册**(2026-07-30 第二版):销账史砍掉(在 git),
|
||||
精华收进建模八纪律与排障速查表;余账在 §15(④受理分支误吞验收裁决 ·
|
||||
⑤治理台白名单授权口 · ⑦dist 真编译 · ⑧单拍工具缝 · ⑨⑩死代码 · ⑪评价卡暂不做)。
|
||||
语义源两卷(操作系统/活动模型)已归档 `docs/archive/`。
|
||||
- **实时同步补齐五处**(2026-07-30 第二轮,测试台为引子):①订阅层不失效
|
||||
`['gov','testbed',*]` —— 测试台全靠 2s 轮询而轮询到终态即停,一单跑完那屏就此定格;
|
||||
②`testCurrentRoute` 把已终结的试跑单当成「在跑」,旋钮永久锁死(现返回 `live` 分开
|
||||
「摆出来」与「占着仪器」);③试跑起单/销毁不广播 —— 销毁掉的真任务还挂在别人工作台上;
|
||||
④**`kind:'system'` 声明了却全仓从未发布** —— 治理台所有配置面(活动/工具/模型/流程包/
|
||||
数据域/驾驶舱)永不实时刷新;现由 `/api/gov/*` 写路由中间件统一发(一处拦住,新路由自动生效),
|
||||
任务走到终态也补发一次(台账与指标不以任务为轴);⑤本地 id 活过服务端事实 ——
|
||||
别处销毁后面板还钉着旧号,显示「取数失败」的鬼卡片(渲染期对齐,服务端说没了就松手)。
|
||||
订阅映射已抽成纯函数 `invalidateFor` 并按「哪一屏靠什么刷新」逐屏断言(8 条)。
|
||||
- **MVP 工作流第一轮跨节点跑通 A1→A2**(2026-07-31),并挖出**七个编排缺陷**(全已修):
|
||||
①六节点绑的是退役槽位 id ②流程包入参还是 `customerCode`+`rfqRef`
|
||||
③**触发器装配映射是空的** —— 事件触发的 scope **不打底**(只有 registration 才铺 scope),
|
||||
「同名会自动带过去」是错觉,`subject`/`body` 同名照样缺
|
||||
④E5 读 `needSourcing` 而 A2 出口不发 ⑤E10/E11/E12 读 `outcome` 而 A6 三口载荷是空对象
|
||||
⑥A2 要 `originalPn` 而 A1 发的是 `altRequests`(上游发一批、下游要一个)
|
||||
⑦**自动收步与行动串并行** —— 引擎在拍首收了 T_select,而模型这一拍原本就打算 write 完再 finish,
|
||||
`Completed → Active` 当场抛非法迁移,连拒五拍熔断;现与 `start` 同款:收过了就当已完成。
|
||||
④⑤⑥ 同一类:**边读什么 / 下游要什么,上游就得发什么** —— 光测单活动测不出来,只有真跑跨节点才现形。
|
||||
- **六个活动全部转正为真活动包**(2026-07-31):`REAL_PACKAGES` 六件齐,经安装通道读
|
||||
`dist/` 装入 —— 治理台里不再有 `viewTemplates: []` 的占位槽。六份案例模型统一到 A1 的
|
||||
状态机模式(剧本 `steps`:intent / guide / writes / tools / doneWhen):
|
||||
**A2/A3 各只有「派一张执行单 + 放行」两项**(专家判断,建细了只是跟 harness 较劲);
|
||||
**A4 定价**三项(算价归判定表 · **超限才叫人**的决策卡 · 放行);
|
||||
**A6 议价**三项(落单 · **发出去人工激活 + 立监听等回音** · 放行);
|
||||
**A7 归档**两项(一步落三张表 —— 它们互不依赖,分开只多两次 finish)。
|
||||
凡触达世界的项一律不给 `doneWhen`(给了等于给引擎一把替人签字的钥匙)。
|
||||
- **A2/A3 的简化口径**(同日):案例模型从九个 humanTask 砍成**两项** ——
|
||||
`T_select`/`T_source`(派**一张执行单**,卡面两张可编辑表)+ `T_release`(人工激活)。
|
||||
上一版把「FAE/采购脑子里怎么想」当成了「系统要管几步」;而那一整段在业务上是**一件事**。
|
||||
剧本与 A1 同一套(`doneWhen` 真了引擎自己收步)。`REAL_PACKAGES` 已含这两个,
|
||||
不再是治理台里 `viewTemplates: []` 的占位槽。
|
||||
- **判据三态 + 表格默认锁着**(2026-08-01,`quote-intake@20.0.0`,用户两问):
|
||||
①「这条未达标为啥还在」—— `acceptanceCriterionCheckSchema` 只有 `{label,level,pass}`,
|
||||
**裁断与理由传不到卡面**,于是「裁了但裁成不满足」与「压根没裁」都是一个红叉。
|
||||
补 `settled` + `rationale`,卡面变三态:达标(绿)/ **AI 判为不满足 · 交你定**
|
||||
(琥珀 + 左侧色条 + 理由)/ 未达标(红)。更早的注释写着「理由随卡交给人」——
|
||||
**那句话描述的行为从来没实现过**,因为契约里没有那两个字段。
|
||||
②**表格默认锁着**:每张可改的表各带一道闸(`[data-edit-for]`),锁着只出「编辑」,
|
||||
开了换「取消 / 保存」;取消还原成按下那一刻的值,保存才交给外层缓冲。
|
||||
**看与改是两件事** —— 从前一进屏 54 个输入框一起亮着。
|
||||
途中踩了跨 realm 陷阱:`event.target instanceof Element` 在 iframe 元素上**恒为 false**
|
||||
(`Element` 是父文档的构造器),症状是点「编辑」毫无反应而 class/CSS/选择器逐个查都对。
|
||||
**认能力不认血统**;监听挂载也改成幂等的(srcdoc 换文档时机抓不准,每次有机会就挂一遍)。
|
||||
验收:6/6 released;浏览器里走完「编辑→改→取消还原→再改→保存→提交」,落草稿层。
|
||||
- **A1 连跑 10/10 全 released、行动被拒 0 次**(2026-08-01,`quote-intake@19.0.0`,
|
||||
10-11 拍 / 99-112k token)。基线是 5 轮熔断 1(其中一轮 13 次拒回、23 拍、234k token)——
|
||||
五处 harness 改动逐轮逼出来的(见下两条与 `docs/debt.md`)。
|
||||
- **最后一条自报的机检判据也交给基座数**(2026-08-01):台账字段声明加 `required`(可选,
|
||||
不建 NOT NULL —— 草稿层的本分就是过程中允许缺),基座据此出 `域_对象_missing` 与
|
||||
`_missing_keys`(缺在哪几行、缺哪一列)。A1 的「必填齐(PN/年用量)」原读活动自写的
|
||||
`missing_key_total` —— 人在卡上补了年用量、基座已写回草稿层,而那个数要模型自己重算,
|
||||
**漏算一次这道门就再也开不了**:十轮里三轮死在这。这是「机检只许读基座数得出来的数」
|
||||
这条纪律第二次被违反,第一次是 missing_minor_total 被改小那次。
|
||||
- **Harness 三条死胡同已通**(2026-08-01,连跑 A1 摸底逼出来的:5 轮熔断 1、再 6 轮熔断 2)。
|
||||
三条全是同一个病 —— **判据正确地拦住一件办错的事,而模型没有一个动作能把它办对**:
|
||||
①**写错一行没有退路** → 加 `drop` 行动(`ledger.draftDrop` → `DraftStore.drop`):
|
||||
只撤本单草稿层那一笔,正式版一个字都动不了、不抛事件,**不是第四种写**。
|
||||
起因:客户明说本轮跳过的那颗料被写进有效清单,数全对(9 有效/1 跳过)而表里 10 行,
|
||||
`record_rows = line_total` 拦住 —— 模型唯一能改的是那个数(改了 skipped_total 又不对),
|
||||
13 次拒回、23 拍、234k token;
|
||||
②**拒回只给数字** → 读数连**主键名单**一起摆(`域_对象_keys`,封顶 12 行):
|
||||
两份名单一比,多的那个 PN 就在里面对着,一句话能办的事;
|
||||
③**拒回把模型指上死路** —— 「要先 start,当下是 Completed」,而 start 一个 Completed
|
||||
项同样被拒,两堵墙对着撞;现按状态分开说,且 start 已收的项改为无事发生。
|
||||
**拒回的话是模型下一拍的行动指南,指错方向比不给指引更糟** —— 同一条也改了提交门的
|
||||
结尾建议:先分清差的是**数**还是**行**,行不对就 drop,别去改数。
|
||||
顺带:**收步判据与验收判据同源**(S2/S5/S6 都改读基座的 `_rows`)——
|
||||
松出来的那一截就是返工;S2 那条把「多写一行」的发现点从第 10 拍提前到**第 2 拍**。
|
||||
还删了孤儿模版 `intake-gaps`(7 处 `view:` 无一指向它)。
|
||||
- **对话区一轮一句(不是一拍一句)**(2026-08-01,用户:「太密集了」):形状是
|
||||
**设定目标 →(分析现状 → 规划 → 执行)× N 轮 → 一个气泡**,气泡可以是仅结论,
|
||||
也可以是**结论 + 功能卡两部分**。三处:①`turn-runner` 的 `handsOff` ——
|
||||
只有交出控制权那一拍(ask/human/decide/listen/submit)的交代进对话区,
|
||||
别的拍留进工作日志(`internal`);②提示词与 say 字段说明明写这条规矩,
|
||||
否则模型照旧每拍写播报、只是被默默丢掉(白烧 token 还写不好总结);
|
||||
③`chat-projection` 把交代与卡**并成一个气泡**,并把收尾三条引擎回声
|
||||
(裁决 / 发口 / 终态)收成一条。并卡的判断按**来路**认(只有 terminal 不认领卡)——
|
||||
一度写成 `tag !== 'conclusion'`,而交代本身常是结论口吻,于是第一轮并对、第二轮没并。
|
||||
账:同一条 11 拍的 A1,对话区 **9 条 → 5 条**;压下的 8 条交代一条不少留在工作日志。
|
||||
- **放行卡不摆 L5、去向不再有 L4 裁量**(2026-08-01,用户当场指出):
|
||||
L5「BD 放行」是**责任关**,而那张卡本身就是这道关 —— 列进判据表等于摆一条恒为
|
||||
「未达标」的项,「n/m 达标」永远差一条;声明留着、呈现去掉(滤在基座组卡处,六活动通用)。
|
||||
L4「去向判断合乎业务常理」删掉 —— 去向已由出口谓词算出,再让模型裁一次是**给自己
|
||||
没做的决定盖章**:裁「满足」是废话,裁「不满足」也改不动什么(能改的是三类条数,
|
||||
那已由 route-alt / route-rfq 两条机检看着)。**判据得能指向一个可做的动作。**
|
||||
- **三个区各归各位:件归工作区,判据与指导书归对话区**(2026-08-01,`quote-intake@14.0.0`,
|
||||
用户指出「你用你自己的报告格式嵌套了一层我要的报告格式」):工单报告组件自带一整套外壳
|
||||
(题头/编号带/依据带/判据表/章节头),又把活动推的那一屏包在里面 —— 两套版面语言叠着。
|
||||
现在 **待处理与工作成果只放那份 HTML 模版**;作业指导书 · 依据 · 判据 · 填报表单 ·
|
||||
**提交与裁决**全在对话区的卡上。**待处理是可编辑态**:可改的格子在模版里挂 `data-col`、
|
||||
行容器挂 `data-edit="域.对象"`,渲染时每格多渲一个 `<input class="jc-cell">` 孪生件,
|
||||
只读那份与输入那份**由样式表按根节点的 `editing` 二选一** —— 同一份 HTML 两处用,不存两份。
|
||||
收值在外层(`allow-same-origin` 读得到 DOM),**iframe 里仍然一行脚本都没有**;
|
||||
收上来按台账地址对上工单 `blocks[i]` 的 `ledger`/`fields`,仍走老的 `table:N` 回传。
|
||||
同批修两个只在真人点的那条路上现形的洞:①**监听挂在 about:blank 上** ——
|
||||
`srcDoc` 异步换文档,渲染那刻挂的监听随旧文档作废,人改了两格提交时载荷一个字都没有;
|
||||
②**算出来的去向没交回去** —— `routedPort` 那一路 `selectedPorts` 恒空,按「确认通过」
|
||||
当场判「选择组未选出口」,台账都合并进正式表了单子却死在最后一步(`blocked`)。
|
||||
验收:浏览器里走完两道人环 —— 件上改两格 → 提交 → 落草稿层 → 放行 → **released**,
|
||||
九行数据(含人补的 eau/brand)落进正式表;工作成果那一屏零输入框、零外壳。
|
||||
- **阶段2 补回成一项:受理需求交人审定**(2026-08-01,`quote-intake@13.0.0`,用户指出
|
||||
「我都没确认需求 它就让我验收了」):「经过人」从前不是一项,而是归一那步收步条件里的半句
|
||||
`missing_minor_total = 0 or answered_total > 0` —— **只在缺东西的时候才叫人**。
|
||||
一封字段齐全的询价进来,这半句在拆完那一刻就为真,引擎自己把那步收了,
|
||||
人一眼没看过受理需求,下一次被叫到已是终局验收。现在 `S3_confirm` 是独立一项:
|
||||
**人工激活 + 不给 doneWhen**(与放行同款),`view: intake-overview`,一单必发一张;
|
||||
S4 的入场前提改读 `confirmed_total`(只有人回卡才产生的数)。
|
||||
规范 §7.1 立纪律 3.5:**判据是「这件事办完了没有」,不是「要不要办」**。
|
||||
连带修掉一个零条死锁:**没写过 ≠ 写了 0 行** —— 全 A 类那条路上替代请求表一行没写,
|
||||
`quote_intake_alt_request_rows` 整个键不存在,而判据 `= type_c_total`(0=0)本该成立,
|
||||
未绑定的名字比较为假 → 永远过不去、连撞五拍熔断;现按 `declarations.ledger` 先铺 0。
|
||||
验收:常路 **11 拍 released、被拒 0**;全齐无 C 类那条 **10 拍 released**、去向 `to-sourcing`。
|
||||
- **A1 两份报告改为从 PRD 目标件整份搬过来**(2026-08-01,`quote-intake@12.0.0`):
|
||||
`views/prd-style.ts` 是机械抽取的整段样式表 + 图标精灵(`PRD_CSS`/`PRD_SPRITE`,数值未改),
|
||||
`views/report.ts` 逐块搬标记、只把写死的数据换成绑定 —— **不再用 `@sdk` 拼装件复述**
|
||||
(那套外壳有自己的版面语言,「照着做」出来的是第二种版面;前三轮报「25 块到位」都是
|
||||
结构断言,对版面零约束)。规范 §10.3.1 立此法:**视觉目标件只能看,不能用 DOM 断言验收**。
|
||||
连带三件:①**`data-class-of` 不再以 `data-slot` 为前提** —— 纯色块(格里不写字、只按值上色)
|
||||
从前整条静默失色,屏上一排灰格子看着像「还没算出来」(补 4 条用例);
|
||||
②**去向进视图**:`case:routeKey/routePort/routeTo/routeWhy` 由基座按出口谓词求值后注入,
|
||||
与放行发口是同一次判定,报告只摆不算(分布条按条数分宽也走同一招:条数投影成 class + 宽度梯子);
|
||||
③`contact_name` 落进 `verdict` —— 老客户不重写主数据,联系人卡从前会显示公司名。
|
||||
端到端复跑:**10 拍 released、96k token、行动被拒 0 次**,去向 `to-alternative(type_c_total > 0)`。
|
||||
- **A1 五轮端到端全部 released**(2026-07-31):行动被拒 **0** 次,判据 7/8(只剩 L5 人放行)。
|
||||
跑测逼出并修掉两个「人答了什么 Agent 看不见」:①可编辑表的修改只活在组件局部 state,
|
||||
回传载荷一个格子都没有(加 `onTableChange` 链,键 `table:N`、首行列名);
|
||||
②决策卡选了哪个不进轨迹(`payloadOf` 只认 values/output/error,补 `detail.value`)。
|
||||
两者都会让 Agent 重发同一张卡 —— 人以为自己白填/白点了一次。
|
||||
- **工作台空占位统一**(2026-07-31):任务那一栏的空态从裸 `execution-panel` 改挂
|
||||
`jc-panel-card`(其余四模块一直有,只它是画布上飘着的一段文字);
|
||||
`.jc-empty` 由固定内边距改 `margin:auto` + `justify-content:center`,在栏内真居中。
|
||||
- **活动状态机:每步一条剧本,成果已在就自己收**(2026-07-31,治 A1 空转)。
|
||||
`LoopModel.steps` 一个计划项一条:`intent` / `guide` / `writes` / `tools` / `doneWhen`。
|
||||
引擎每拍开头先对任务视图求 `doneWhen`,**真了就自己 finish 那一项,不问模型**
|
||||
(人工激活的项不自动收 —— T_release 触达世界,不给它 doneWhen);
|
||||
上下文按状态组织:只摊开当前这一步的做法与它该用的东西,其余步骤各留一行。
|
||||
起因是实测死循环:S1 一次写齐八字段后 S3「补齐八字段」已无活可干,而每拍喂整本说明书,
|
||||
模型只能把同样三行再写一遍 → 「写了等于没写」被拒 → 下一拍再来一遍,连拒到熔断。
|
||||
**`doneWhen` 的坑**:S-FEEL 里未绑定的名字 `!= null` 求值为 **true**,写它等于那一步开局即收;
|
||||
布尔列也不能当判据(分不清「没写」与「写了 false」)—— 一律取同步写下的计数列(测里拦下过)。
|
||||
实测三连:**A1 三次端到端全部 `released`,行动被拒 0 次,S3 每次自动收步**;
|
||||
8 拍 / 58k token(状态机之前是 11 拍 / 82-90k)。
|
||||
- **决策卡 Agent 侧接通**(2026-07-31):新增 `decide` 行动(contract / port / turn-runner /
|
||||
engines / 提示词 / 解析)。此前决策卡**只有 UI 没有 Agent 侧**,岔路口只能写成补全卡 ——
|
||||
人拿到一张要敲字的表单而不是两个可点的选项。少于两个选项拒发。
|
||||
- **真实文件进得了活动**(2026-07-31):`sys.upload`(迁移 0024)存原字节 + 读出来的文本;
|
||||
`POST /api/gov/upload`(base64,8MB 封顶)/ `GET /api/gov/upload/:id`;
|
||||
xlsx **不引依赖**自己读(ZIP 中央目录 + `inflateRawSync` + XML,13 条测试);
|
||||
契约字段标 `format:'file'` → 测试台出文件选择器。读不出文本的类型如实说读不出。
|
||||
- **沟通记录接通**(2026-07-31):`sys.task_comm`(迁移 0025);活动清单声明
|
||||
`contract.input.inboundComm`(哪几个入参字段合起来是那封来件),基座**起单那一刻**归档 ——
|
||||
人一点开这单,沟通记录里就已经躺着那封信,不必等模型跑几拍。附件那一行照抄基座的读取说明。
|
||||
- **测试台改三格 + 试跑日志**(2026-07-30):两档旋钮长在内容里(别处跳不进来、刷新记不住
|
||||
试的是哪一台,第三样东西也塞不进二档),提到页签 —— **流程 / 活动 / 日志**;单在另一台上
|
||||
不抢页签,只挂一句「它在那边」。日志是**拔之前先存一份**:停止仿真照旧连根拔掉,拔前
|
||||
`archiveRun` 存一条(逐拍轨迹 + 台账草稿 + 发出去的人卡 + 读数),**一次试跑一条**
|
||||
(流程包把名下节点任务合成一条,痕带节点名前缀、按时刻排);可单删可清空,`reset-runs` 一并清。
|
||||
库表 `sys.testbed_run`(迁移 0023),路由四条挂 `/api/gov/testbed/logs`(写路由中间件自动广播)。
|
||||
顺手修:单活动那一台的负责人下拉掉进 `.tb-asn-r` 的 40px 那一格(它比流程那台少一列),
|
||||
现给 `.solo` 四列版 + 插座列加宽到 184–238px。
|
||||
- **台账列归型前移到写入那一刻**(2026-07-30):模型用 `""` 表示「原文没给」,草稿层照收,
|
||||
到 `releaseWrite` 才被 PG 掀桌(`invalid input syntax for type double precision: ""`)——
|
||||
**而那是人签完字之后**,整单落 `blocked`。现在 `draftWrite` 按声明列型归:空值=不写这一列、
|
||||
数值/布尔字符串转真类型、归不了当场拒(理由喂回下一拍)。台账字典也带上列型给 Agent 看。
|
||||
- **前端实时刷新已通**(2026-07-30):前端那一侧一直是好的(单条 SSE + 按前缀失效缓存),
|
||||
三处**后端没广播**:①`task-advancer` 只在 runtime/terminal **换名字**时广播,而案例活动
|
||||
连跑十几拍全程 `running`(改为 `progressed` 即广播);②`interaction` 的推送一个订阅者都没有
|
||||
(加 `onPush` 系统级转接);③发消息不经事件投递,收件人的消息气泡不动(outbox 处补广播)。
|
||||
- **四类人类工具:前三类已通**(2026-07-30):执行(工单 · 报告页填报提交)、
|
||||
**补全**(对话卡 · `ask` 行动,两条路必走其一)、验收(工单 · 判据表 + AI 已填好可编辑 +
|
||||
裁决在对话区)。决策卡(第四类)UI 已在、Agent 侧未接。
|
||||
验收修订链原来断在五处「声明了没接线」——契约 `revisableAtRelease` 定死哪几个值可改、
|
||||
改完落哪儿;拟值放行前从台账现读,不让模型重述。
|
||||
- **A1 的两份执行页照 PRD 目标件重做并跑实**(2026-07-31,`quote-intake@9.5.0`):
|
||||
`~/coding/JCAI_PRD/docs/活动执行页/` 的两份 HTML 是「办完该长什么样」的答案 ——
|
||||
**询价受理需求**(来件原文 → 受理要素四卡 → 指标带 → PN 清单 → 本轮不报)与
|
||||
**统一受理记录**(要素 → 逐条记录带标 → 分级依据 → 分流去向 → 两类请求 → 结论 →
|
||||
这一签下去会发生什么)。`@sdk` 补 `letter` / `factCards` / `actionList` 与列样式
|
||||
`req`/`opt`/`num`/`pill`;`data-blank` 让空值真的留空,必填/选填胶囊与缺项标底
|
||||
由模版自己的 CSS 出 —— **模版仍不许算**(分布条宽度、命中高亮这类算出来的画面一律不做假)。
|
||||
顺手删掉 `@sdk` 里五个零调用方的拼装件(kvCard/tableCard/noteCard/stagesCard/critList)。
|
||||
- **A1 六轮端到端,末轮九行数据全对**(2026-07-31):每轮 `released`、**行动被拒 0 次**,
|
||||
9-10 拍 / 76-89k token。逐轮逼出并修掉五个洞:
|
||||
①**机检判据两边都由活动自报** —— 条数报 9 而表里 10 行、三类条数报 7A/2B 而判定器给
|
||||
5A/3B,派单数同源同错于是「2 = 2」顺利过关;现加基座读数 `域_对象_rows`(本单写下几行),
|
||||
判据必有一边不是自报,`classify` 出参也带 `counts` 供照抄;
|
||||
②**问过 ≠ 答过** —— 收步判据写 `asked_total`(卡一发就为真),人填完提交这一步当场被收,
|
||||
答案没轮到写回台账;改等 `answered_total`;
|
||||
③**归档件被冲掉** —— 收口后引擎再跑一拍,推固定的 `case-overview` 退到「声明的第一张」,
|
||||
把人刚签过的放行页冲成阶段二的执行件;现按剧本 `view` 推、活步收完不推;
|
||||
④**说明书与剧本各写一份六步**,分叉出三个案例模型里不存在的计划项名(S1_register 等),
|
||||
模型照它报 itemId 一报一个被拒;说明书现在只讲整单的事,逐步做法归剧本(有测试钉死);
|
||||
⑤三类数据失真(PN 抄漏后缀→同一料两行、数值缺失写成 0、制表符 BOM 拆错列)——
|
||||
逐条在说明书里写死实测账。
|
||||
- **Harness 可靠性:三处「门本身教模型撒谎」已修**(2026-08-01,两条样例路径连跑逼出来的):
|
||||
①**机检判据读活动自报的数** —— 一颗料只缺目标价,模型如实记 1、被连拒四次,
|
||||
第五拍把那个数**改回 0** 就绿了(台账从此写着「缺选填 0 条」);
|
||||
**机检只许读基座数得出来的数**,数不出来的别假装机器判得了 —— 该条已降 L4 交人;
|
||||
②**裁量判据也被要求「为真」** —— 它如实裁「未满足」并写了理由,提交照样被拒,
|
||||
判-提-拒十二轮 21 拍 187k token,最后把裁断翻成「满足」才过去;
|
||||
提交门已改:机检必须为真、**裁量裁过即可**,裁断连理由随卡交给签字的人;
|
||||
③**熔断数不出「同一堵墙」** —— 拒回标题带着串位(「第 2 个」),整条一比成了 N 堵墙,
|
||||
叙事窗 20 条也装不下几次撞墙;一单被同一条判据拒回 **33 次**、40 万 token 烧到
|
||||
`exhausted`。已修:削掉串位前缀比对 + 熔断另开 60 条窗。
|
||||
顺带:计划项号认唯一前缀简写(`S4` → `S4_history`)。
|
||||
**验收**:Itelma 与新客户两条路径各三轮全部 `released` ——
|
||||
10 拍 / 88-90k / 被拒 0 与 9-11 拍 / 71-88k / 被拒 0-1,数据逐轮对得上。
|
||||
- **执行页补到「能做的极限」**(2026-08-01):新增 `data-class-of`(把值消毒成 class 后缀,
|
||||
仍只是取值,判断留在样式表)—— 于是类标 A/B/C 三色、两枚旗的是/否、**一条料一格的刻度条**
|
||||
都真做得出来;要素卡补方/圆标(方=组织、圆=人,里面放图标不放算出来的首字母)与大号计数;
|
||||
G2 那段改成**三条规则连着本单计数**的规则表 —— 命中由编排层求值,这份件不画对勾替它宣布。
|
||||
仍不做的只剩两类:按条数分宽的分布条(宽度得算)、编辑态(沙箱无脚本,改数走执行卡)。
|
||||
- **试跑日志吞主键已修**(2026-08-01):存档那一屏只渲染 `patch`,`key` 整个不显示 ——
|
||||
三行受理记录屏上只剩「eau / lead_time」,看的人据此判「PN 没提取到」,而 PN 一条不少。
|
||||
两处观测面已合并到 `gov-testbed/internal/drafts.tsx` 一份实现。
|
||||
- **就地改的三处不老实已修**(2026-08-02,同一单实测逼出来的):
|
||||
①**保存按地址走,不看当下那张卡** —— 载荷从前是 `{workOrderId, values:{'table:0':…}}`,
|
||||
列名由那张工单的 `blocks[0]` 给出,于是「这份件能不能改」被绑在了「此刻开着的那张卡
|
||||
长什么样」上。一单在放行门连拒五拍熔断,引擎发的是**没有 blocks 的熔断卡**,而屏上摆着的
|
||||
正是受理记录那份件 —— 闸在、编辑进得去、保存按得下,前端找不到匹配的块,**一格都没往下送**;
|
||||
人要补的恰恰是把这一单卡在那儿的那个必填格。现在收件自己说的话(`data-edit` 地址 +
|
||||
`data-col` 列名),授权面改由**活动清单的台账声明**把关。
|
||||
②**必填还空着就按不下提交**(`ExecutionWorkOrder.gate`,基座按声明现算,
|
||||
与放行门 L1 `域_对象_missing = 0` **同一个数同一套算法**);闸上摆出差在哪一行哪一格,
|
||||
人在件上补齐、保存,闸自己开。算不出来(卡上没绑表)就不给闸 —— 没有依据的时候不拦人。
|
||||
③**只写真改了的那几行**:件上按一次保存整张表都回传,照单全写会留「就地修订成果:4 行」
|
||||
的痕而人只改了一格。
|
||||
端到端:12/12 判据、`released`、被拒 0(另一轮 3 次、亦 released)。
|
||||
- **日志只有三色**(2026-08-02 用户定):负责人绿 · 智能体蓝 · 系统灰(`LogLine.who`)。
|
||||
从前按归侧上五色 —— 那是**度量**的分法(自主率要把「等外面」与「机器干的」分开),
|
||||
对读日志的人不构成区别。连带两件:放行那一刻四条痕说的是同一件事(合并 / 发口 / 流转 / 终态),
|
||||
日志只留说得出实情的那一条(带「几行没进正式表」);台账写入认 `by` 定主语 ——
|
||||
放行合并归系统,人工修订归人。
|
||||
- **件上的表能增删行**(2026-08-02 用户:「编辑列表应该还能增删行」):手改本来就是
|
||||
**兜 AI 的错**那一手,而错不止「这一格填错了」——「这一行根本不该在」(客户点名跳过的料
|
||||
被写进了有效清单)与「漏了一行」是同一类错,只给改格子就没有出口。
|
||||
序号那一格编辑态换成 ×(一列两用),表尾一条「加一行」;删是**挂记号不当场抹掉**
|
||||
(看得见、可后悔),「取消」把加的撤掉、把记号摘掉、把值放回去,三样一起才叫还原。
|
||||
**删要说出来**(载荷 `drops`),不靠「没回传就是删了」—— 那种口径会把一次不完整的回传
|
||||
变成一次删表。撤的只是本单草稿层那一笔(`draftDrop`),正式版一个字不动。
|
||||
痕里点名撤了哪几行并写「别再写回去、条目数跟着改对」—— 那条痕是**下一拍模型的行动指南**,
|
||||
不说清它会照「条目数 9 而表里 8 行」判成少写了一行,把人刚删的写回去。
|
||||
实测:删一行 + 加一行 + 补必填 → **released、11 拍、被拒 0**。
|
||||
- **裁量判据带谓词时谓词说了算**(2026-08-02 用户:「这个价格是我填写的,但是它判错了」):
|
||||
「选填缺口:已补齐,或由你放行时确认可以先受理」是复合命题 —— 前半句基座数得出来
|
||||
(`域_对象_gaps`),后半句才需要人。从前整条只读裁断,于是模型凭记忆写理由,
|
||||
人填好的目标价它照样判「客户未提供」(复现的那单更离谱:两行年用量都填着,
|
||||
它写「共 4 处选填缺口」,而 EAU 压根是必填)。现在谓词为真即达标、**那句相反的话一并丢掉**;
|
||||
谓词为假才轮到裁量。同批:验收卡的判据改为**读时现算**(`container.checks.of`,
|
||||
与提交门同一段 `criterionCheck`)—— 人在成果件上补一格,判据跟着变;
|
||||
`remark` 不再算选填缺口(它是我方写的注,不是客户该给而没给的)。
|
||||
- **作业指导书与验收判据同一套版面**(2026-08-02 用户定):从前指导书是一串松散的圆点、
|
||||
判据是一摞带章的行,同一张卡上两套版面语言。差别只留一处:**指导书不画对勾** ——
|
||||
它是要办的事,系统并不知道人做没做。执行卡那一钮也改称「查看工单」。
|
||||
连带挖出:**引擎压根不往下传 `steps` / `standard`** —— 活动照骨架写了,发卡时一个字
|
||||
都没到卡面,人拿到的只有一句「请核对确认」。空着不是简洁,是把交代省掉了。
|
||||
- **建客户要公司与联系人都经人确认**(2026-08-02 用户定):录入卡加 `also` 段 ——
|
||||
一张卡两段(公司档 + 联系人档),确认一次落两行,值按 `段号.列名` 隔开
|
||||
(两张表都有 `name`,平铺会互相盖掉而没人看得出来)。从前联系人由 AI 自己写,
|
||||
而 `contact_code` 恰是它编出来的那一个。
|
||||
- **该问人的问人,人答不上来的别摆到他面前**(2026-08-02 用户两条):
|
||||
①卡面摆的是台账登记的**表人读名**(「客户档案」),不是 `customer.profile`;
|
||||
主键行只摆表单里没有的那几格、用中文列名 —— 从前一句「主键 customer_code=待定」
|
||||
既是英文列名,又在替一件他马上就要做的事做旁白。
|
||||
②**自增列不问人**:声明加 `FieldDeclaration.auto`,标了的列压根不进表单,
|
||||
写入那一刻由基座按「同组其余主键值-两位序号」生成(`CU-9001-01`,序号数正式版 + 本单草稿,
|
||||
同一单连加两位也不撞);卡上另摆一句「由系统按顺序生成,不用你填」——
|
||||
**不能一声不吭地拿掉**,那样人会以为这张档少了一格。
|
||||
- **放行卡砍掉四条判据,办结后换成落地结果**(2026-08-02 用户定):
|
||||
`downgrade-safe` · `route-alt` · `route-rfq` · `typed-on-record` 四条都是**内部一致性**
|
||||
(打标依据对不对、几类料开了几张单、两张表行数齐不齐)—— 该由引擎自己看住,
|
||||
而放行卡是给签字的人看的:一屏九条,真正要他过目的那几条反而淹了。
|
||||
落地那一侧不因此变松:同一批基座读数仍在 S5/S6 的收步判据里。
|
||||
**办结之后那一屏换成「这一签下去发生了什么」**:进了哪几张表各几行(摆表的人读名)、
|
||||
转去哪儿了(摆出口的 description)、试跑挡下几行 —— 判据说的是签之前要核什么,
|
||||
签完再回看,那一摞对勾答不了这个问题。事实取自放行那一刻的**结构化留痕**
|
||||
(`ledger-write` / `release-verdict` 的 detail),不解析日志句子。
|
||||
- **判据措辞写全,两张卡的按钮统一**(2026-08-02 用户定):
|
||||
「客户已确认(认出或已建档)」→「客户已确认:发件邮箱认出为老客户,或已为新客户建好档」,
|
||||
五条同批改写;执行卡与验收卡那一钮改成同一形状 —— 图标一格、**四字粗体**
|
||||
(执行工单 / 验收工单)、小字说明、右侧箭头,且四字在上小字在下(从前是反的)。
|
||||
- **执行工单说的是「哪几格必须有、哪几格值得追」**(2026-08-02 用户定):
|
||||
A1 的作业指导书从「怎么核表」改成逐项建议 —— PN/年用量必须有(缺一条走不到放行)、
|
||||
目标价尽量试探出来、客户料号对齐回价单、品类与制造商能填就填;
|
||||
收口标准写明「问不到的留空并在备注里写明,放行时由你决定先受理还是退回」。
|
||||
- **A1 不再开请求单**(2026-08-02 用户当场定):`alt_request` / `rfq_request` 两张表
|
||||
连同 S6 的开单动作、两个派单计数列、报告里那两张明细表一并退休。原话:
|
||||
「这两项数据不在这里产出;随令牌流向下一个节点的只有工作成果验收内的内容;
|
||||
进入选型替代后,活动会自己归一需要的数据」。从前 A1 逐条开单交给 A2/A3 ——
|
||||
那是**替下游把它的输入先摆好**,而下游本来就要照自己口径归一一遍:
|
||||
同一份事实两处各存一份,改一处就分叉而没人看得出以哪份为准。
|
||||
S6 的收步判据改读 `route_decided_total`(旗是布尔,分不清「没写」与「写了 false」)。
|
||||
报告同批瘦身三处:两张请求单表 + 那条对账说明、「受理结论」四格表、
|
||||
「本轮不报 0 行」横幅 —— 后两者要么与别处重复,要么九成时候在说「没有」。
|
||||
- **消息不再被内部事件刷屏**(2026-08-02 用户:「消息怎么总莫名其妙的出内容」):
|
||||
「无主入站待认领」从前只看「有没有主 + 有没有人接住」,于是**台账写一行发的
|
||||
`data` 事件**也进兜底 —— 装一次活动包就往三个人收件箱塞十几条
|
||||
「无主入站待认领:(无主题) … 原件:无」。判据加一刀认**来源**不认类型:
|
||||
同样是 `data`,从通道进来的是件、台账写出来的是事实;只有 `source.kind === 'channel'`
|
||||
才谈得上「无人认领」。顺带:顺利收尾那条 `终态:released` 不再进对话区
|
||||
(落地结果那一屏已经说全了,且它摆的是英文状态名);没顺利收尾的仍出声,措辞取
|
||||
`TERMINAL_STATE_LABEL`。
|
||||
- **一道只认「有人写过」的门等于没有门**(2026-08-02,用户送来一单熔断的实测):
|
||||
那一单跳过 S4/S5/S6 直接提交放行 —— 受理记录三行一个标没打、分类表一行没写,
|
||||
却进得了放行门。查出来三个洞:
|
||||
①**放行门的入场哨兵 `s_routed` 只有 onPart 没有 ifPart** —— 「往 need_sourcing 写过一笔
|
||||
就开门」,模型顺手写了个 `false` 就把门推开了。现在认**办完了没有**,
|
||||
且三个数全由基座现数(打了标的条数 = 记录行数、去向定过了)。
|
||||
②「每条都已打标」这条 L2 读的还是活动自报的 `untyped_total` ——
|
||||
「机检只许读基座数得出来的数」**第三次**被违反(前两次:missing_key_total /
|
||||
missing_minor_total)。改读 `_type_tag_a + _b + _c = _rows`,两边都是基座出的数。
|
||||
③**拒回把「活没干完」说成了「数或行对不上」** —— 那一单数是对的(诚实报了 3 条没打标)、
|
||||
行也是对的,而话术只给「改数 / 改行」两条路,还明写「别为了过判据去改那个数」,
|
||||
正好堵死它唯一想走的门,于是原样重提五拍熔断。现在判据点名的表若**这一单一行都没写**
|
||||
(基座 `_rows` = 0),就直说「那一步还没办,回去办了再来」。
|
||||
**指向两扇打不开的门,比不指还糟。**
|
||||
- **验收那一屏是活动的收口,答两件事**(2026-08-02 用户定):①落了什么 —— 按**交付物**点名
|
||||
(`RequiredDeliverable.ledgers` 说清一份成果由哪几张表承载:A1 的受理记录 / 分类表 / 结论
|
||||
是**同一份**询价受理记录的内部结构,从前各占一条,人在成果面上看到三样东西);
|
||||
②往哪儿去 —— 转出哪一口、**带过去哪些字段**(载荷即已落地的事实,摆列名不摆值),
|
||||
一个口都没发就直说「到此为止」。同批:`统一受理记录` 全仓改称 `询价受理记录`;
|
||||
成果列表执行蓝 / 验收绿两色;办结卡那一钮改称「打开报告」。
|
||||
- **签完到收尾之间有自己的话**(2026-08-02 用户:「点完验收按钮后会闪一下中间态」):
|
||||
那不是渲染毛病,是**真的中间态**(合并 / 发口 / 落终态还在跑)。从前它落在通用四态里 ——
|
||||
待办清零先闪一句「都办完了」再跳「已收尾」;那句话在那一刻是对的,可它说的是另一件事。
|
||||
现在这一段说「字已签下,正在落地」。**中间态本来就该说出来,只是要说对。**
|
||||
- **进度条漏了一格,和一条已经不存在的路**(2026-08-02 用户送来一条实测日志):
|
||||
①`INTAKE_PHASES` 漏了 `S3_confirm`(它是 08-01 才补回成一项的)—— 计划项六个、名单里五个,
|
||||
于是 S2 一收进度条当场跳到「判定去向」,而人这会儿正对着受理需求审定那张卡:
|
||||
**他手上办的那件事在条上没有格子**,条却已经走过去了。补成四格
|
||||
(定位客户 / 归一受理 / 交你审定 / 判定去向 + 基座统一加的放行收口),
|
||||
包内自测钉死「除放行外每一项恰好属于一个阶段」。
|
||||
②说明书里还留着「人交回来还缺必填,就再发一张卡」—— **提交闸上线后那条路已经不存在**
|
||||
(缺必填按不下提交),模型照它办,每次都撞在「这件事刚问过,人也答过了」上,连烧四拍。
|
||||
改成「这张卡一单只发一次」;同时把提交闸补到**服务端**(收下回传之前把住)——
|
||||
说明书向模型承诺「卡回来时必填一定是齐的」,那句承诺只有服务端拦得住才算数。
|
||||
- **话说的范围要盖住门拦的范围**(2026-08-02 用户问「为什么会少了 start」查出来的):
|
||||
提示词那条规矩写的是「标了『须你启动』的项要先 start **再调工具**」——只说了调工具;
|
||||
而拦截那一侧(`readyToWork`)管的是 tool / ask / human / decide / entry / finish 一整片。
|
||||
模型那一拍要发的是 `human`(派工单),照字面读这条规矩管不着它,于是漏了 start,
|
||||
撞上「S3_confirm 标了人工激活,要先 start 再干活」白烧一拍。**话窄一格,模型就正好漏在那一格上。**
|
||||
写全之后三案例复跑 **全部被拒 0**(案例三从 14 拍 126k / 被拒 2 → 11 拍 97k / 被拒 0)。
|
||||
同批两处呈现:目标价改左对齐(右对齐是给要竖着相加的数用的,这张表一行一个料、彼此不相加);
|
||||
待处理那一屏表上方加**缺项红带**,点名还差几格、差在哪一行哪一列 —— 两个数由基座现算
|
||||
(`_missing` / `_missing_keys`,与提交闸、放行门 L1 同源),「一格不缺就别出这条带」
|
||||
交给样式表(`data-class-of` → `v-0` 那档 display:none),模版仍不算也不判。
|
||||
- **A1 三案例常备**(2026-08-02):全 A 类直通 / 客户点名跳一条 / 新客户中文正文列料缺必填 ——
|
||||
三条来件形状真不同(俄文 BOM 附件、正文点名跳料、中文正文列料无附件),
|
||||
末次复跑 **3/3 released、全部被拒 0**(11 / 10 / 11 拍)。
|
||||
- **A2/A3 按复杂活动 MVP 简化形完整重写**(2026-08-02 用户改盘:「MVP 期间只做精美验收报告 +
|
||||
一张执行单,提交就是输出」):`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 复跑等测试台空出来再补(他的单还开着,不顶)。
|
||||
- **工作日志:一行摆首句,展开才看细节**(2026-08-03 用户:「细节展开不够好」):
|
||||
从前一条只有一行标题,而留痕里那些人真想回看的东西 —— 凭什么算这一步办完了
|
||||
(`rationale`)、那一笔写了哪几行(主键名单)、被拒到底拒在哪儿(`reason`+`hint`)、
|
||||
发的是哪种卡送到哪儿 —— **一个字都没露过面**。现在 `detailOf` 把它们摘成
|
||||
**人读键值对**(与 title 同一条纪律:不摆内部英文名、认不出的不摆),
|
||||
折叠不占地方、展开才现身;**摘不出就是空数组,前端据此不给展开钮** ——
|
||||
点开是空的抽屉比不给还糟。
|
||||
连带修一处**文档与实现不符**:「一轮一句」口径说埋头办事那几拍的交代
|
||||
「留进工作日志」,而实现把 `internal` 整类滤掉了 —— 那几句**哪儿都看不到**。
|
||||
现在带 `say` 的放行(它是人话),章写「交代进展」、记在 AI 名下;
|
||||
排障用的内部痕(重问原文)照旧不摆。
|
||||
两种交代统一「首句上行、全文进抽屉」(一句话说完的不重复摆,免得点开发现一模一样);
|
||||
截了才加省略号 —— 加了等于说「后面还有」。
|
||||
实测:一单 8 行、6 行有抽屉、行高 34-36px 齐平,展开那两行 141/103px。
|
||||
14 条新测(detailOf 五类 + 压下交代放行 + 首句去重)。
|
||||
|
||||
## 文档权威地图
|
||||
|
||||
| 层 | 文件 | 说明 |
|
||||
|---|---|---|
|
||||
| **活文档**(冲突时以代码现状为准) | `docs/编码规范.md` | 工程结构权威:模块层级 L1-L8、依赖门禁、AI 协作协议 §12 |
|
||||
| | `docs/产品设计.md` | 三面一体的产品形态与决策记录 |
|
||||
| | `docs/活动开发规范.md` | **开发活动照这份**(2026-08-02 第三版,按四轴重组:解耦机制/Harness 契约/Loop/活动怎么写):基座读数与机检纪律、门与闸、五类卡、剧本 steps、建模十纪律、三常备案例与验收口径(released+被拒0)、**排障速查表**;§8 = 待实现 |
|
||||
| | `docs/HTML报告生成规范.md` | **报告模版照这份**(2026-08-02 从开发规范拆出):一份 HTML 三个身份(件/表单/归档件)、**报告与卡的关系与同源纪律**、绑定语言全表、编辑态(闸/孪生件/增删行/红带)、PRD 平移法、陷阱表 |
|
||||
| | `docs/debt.md` | 欠账台账(只增,销账划线) |
|
||||
| | `docs/adr/` · 各模块 `CONTRACT.md` | 架构决策与模块级不变量 |
|
||||
| 业务样例(活) | `docs/MVP示例工作流.html` | 七节点 SOP + 判定表 + 台账域 —— 开发活动的业务素材 |
|
||||
| 前端语义权威 | `~/coding/JCAI_PRD/workbench` `~/coding/JCAI_PRD/governance` | 原型 1:1 平移(只平移不 import);工作台视图逻辑以 `workbench/.../\_data/model.ts` 统一建模为准 |
|
||||
| 归档 | `docs/archive/` | 已完成使命的文档(**操作系统/活动模型两卷语义源**、旧 README 规格包、PROGRESS、旧计划),只读;代码注释里的 OS x.x / AM x.x 编号指向归档卷章节 |
|
||||
|
||||
## 铁规矩(违者返工)
|
||||
|
||||
1. **五道门禁以退出码验**,不许以 echo 验:`pnpm -s typecheck && pnpm -s lint &&
|
||||
pnpm -s depcruise && pnpm -s api:check && pnpm -s test`(或 `pnpm gates`)。
|
||||
2. **契约变更四步**(§12.4):depcruise 反查下游 → 同步 CONTRACT.md →
|
||||
`pnpm api:check --update && git add api-report` → 破坏性走三步弃用。
|
||||
3. **§12.5 禁止清单**:no `any` / `as unknown as` / `@ts-ignore`;domain 零 IO;
|
||||
不删测试;不动门禁配置;不绕写三分/两缝。
|
||||
4. **mock 只许出现在外界**(邮件通道、外部系统的录制回放替身);系统内部机制不得 mock。
|
||||
5. **密钥只从环境读**:`JCAI_DEEPSEEK_API_KEY`(模型)、`JCAI_SEED_PASSWORD`(种子口令)、
|
||||
`JCAI_SECRET_KEY`(凭证密封);不入源码、不入包、不入留痕;E2E 永不回退开发库。
|
||||
6. **试跑一律用 AI 账号**(密码 `jcai-dev-2026`),绝不派到用户工作台;测完
|
||||
`pnpm --filter @app/api reset-runs` 清运行数据(清跑出来的,留配置好的)。
|
||||
7. **安装恒新增**:活动/工具改了内容必须升版本号,同版重装是 no-op(静默丢失,踩过)。
|
||||
8. **界面不撒谎**:状态如实(未绑就写未绑)、错误带厂商原话、不可用不摆假数字。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
pnpm gates # 五道门禁(gen:tiers → typecheck → lint → depcruise → api:check → test)
|
||||
pnpm db:up # docker compose 起 PG16
|
||||
pnpm db:migrate # 应用迁移(新迁移写 db/migrations/ 并登记 meta/_journal.json)
|
||||
pnpm db:seed # 播种(五员工 + AI 账号;登录者 = 冯兵)
|
||||
pnpm dev:api # :8787 pnpm dev:web # :5173(/api 反代)
|
||||
pnpm --filter @app/api reset-runs # 清运行数据,留配置
|
||||
pnpm activities:install # 只装活动包(不碰治理台配置)—— 开发活动时用这个,别用 db:seed
|
||||
```
|
||||
|
||||
## 结构速查
|
||||
|
||||
```
|
||||
apps/api/src/modules/<m>/{index,contract,port,CONTRACT.md,internal/{domain,usecase,adapter}}
|
||||
apps/api/src/app/ # 唯一组装层:container / wiring/engines / routes / workers / seed
|
||||
apps/web/src/features/ # desk-*(工作台)· gov-*(设计+运营)· auth
|
||||
packages/{contracts,activity-sdk,sfeel,ui-kit,testing}
|
||||
activities/<id>/src/{index,manifest,case-model,skills,views/} # 活动包(只准 import @sdk/@contracts)
|
||||
```
|
||||
@@ -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('>', '>').replaceAll('<', '<').replaceAll('&', '&');
|
||||
}
|
||||
|
||||
/** 谓词写坏了引擎当作「没算出来」——测里也照这个口径 */
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -1,111 +1,339 @@
|
||||
/**
|
||||
* A2 替代选型的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
|
||||
*
|
||||
* 业务出处:`docs/MVP示例工作流.html` §1.5,但按 **MVP 简化口径**建模
|
||||
* (2026-08-02 用户定:A2 是复杂活动,MVP 期间不展开大规模业务建模):
|
||||
*
|
||||
* A1 带着询价受理记录转进来 → 挑出 C 类 → **一张执行单**让 FAE 在系统外完成选型、
|
||||
* 把替代料填回来 → 判替代料在库里行情稳不稳:稳转 A、不稳转 B →
|
||||
* 询价受理记录就此转换成**询价受理清单**,传给下游(有 B 类去寻源,全 A 直通定价)。
|
||||
*
|
||||
* SOP 里初选核险 / 圈选替代 / 对比评估那三段的**实质工作在 FAE 脑子里与系统外**
|
||||
* (撒网、读规格书、参数对比 —— 本系统没有那些数据源);系统的本分是归一、组卡、
|
||||
* 判稳、留痕。所以图上只有四项:归一 → 交人填回 → 判稳定标 → 放行。
|
||||
*
|
||||
* ── 建模纪律(照 A1 的账)──────────────────────────────────────────────
|
||||
* 图上有几步就是几项,不多建一件;触达世界的项 manualActivation 且不给 doneWhen;
|
||||
* 哨兵必须有 ifPart 且读基座数、认「办完了没有」;案卷项 id 就是任务视图键名。
|
||||
*
|
||||
* 上一版(@2.0.0)只有「派单 + 放行」两项,归一与判稳整个不存在 ——
|
||||
* C 类怎么来的、去向旗从哪儿出,全靠模型临场发挥;E5 那条边(needSourcing)
|
||||
* 因为台账里没有承载列,**从来没有成立过**。这一版把这两步补成真的。
|
||||
*/
|
||||
|
||||
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
|
||||
<case id="alternative">
|
||||
<!--
|
||||
案卷项的 id **就是任务视图里的键名** —— 前提的 onPart 认的是「这个键有值了」。
|
||||
流内起单时上游载荷(records 等)与本活动台账列都会摊平到任务视图顶层,照它们命名。
|
||||
|
||||
开案即有的只有上游载荷(或单飞的自由入参)。rfq_ref 是归一那一步认出来
|
||||
(或造出来)的:本活动所有台账对象都以它为主键。
|
||||
-->
|
||||
<caseFileModel>
|
||||
<caseFileItem id="request" name="替代评估请求"/>
|
||||
<caseFileItem id="shortlist" name="初筛清单"/>
|
||||
<caseFileItem id="benchmark" name="替代对标表"/>
|
||||
<caseFileItem id="datasheet" name="芯片规格书"/>
|
||||
<caseFileItem id="records" name="上游受理记录(流内开案即有)"/>
|
||||
<caseFileItem id="alt_line_total" name="清单条目数(自报)"/>
|
||||
<caseFileItem id="quote_alternative_list_rows" name="清单行数(基座现数)"/>
|
||||
<caseFileItem id="c_total" name="待选型条数"/>
|
||||
<caseFileItem id="filled_total" name="已填回替代料条数"/>
|
||||
<caseFileItem id="quote_alternative_list_type_tag_c" name="仍为 C 类的行数(基座现数)"/>
|
||||
<caseFileItem id="route_decided_total" name="已定去向"/>
|
||||
<caseFileItem id="need_sourcing" name="去向旗"/>
|
||||
</caseFileModel>
|
||||
<casePlanModel id="plan" name="替代选型" autoComplete="true">
|
||||
<sentry id="s_request">
|
||||
<onPart id="op_req" caseFileItemRef="request"><standardEvent>create</standardEvent></onPart>
|
||||
<!--
|
||||
① 归一完了 —— 交 FAE 那一步的入场前提。
|
||||
两边对上才算:alt_line_total 是活动自报的条数,quote_alternative_list_rows
|
||||
是基座数出来的本单行数 —— 少拆一行、多写一行都进不了下一步
|
||||
(同一个错,在第 2 拍发现比在第 10 拍便宜得多,A1 的账)。
|
||||
-->
|
||||
<sentry id="s_normalized">
|
||||
<onPart id="op_nm" caseFileItemRef="alt_line_total"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_nm"><condition>alt_line_total > 0 and quote_alternative_list_rows = alt_line_total</condition></ifPart>
|
||||
</sentry>
|
||||
<sentry id="s_need_alt">
|
||||
<onPart id="op_alt" caseFileItemRef="request"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_alt"><condition>needAlternative = true</condition></ifPart>
|
||||
<!--
|
||||
② FAE 的答复处理过了 —— 判稳那一步的入场前提。
|
||||
filled_total 是「人交回执行单之后」由 AI 清点写下的数(填回替代料的条数)——
|
||||
它只在人环走完才存在,所以它就是「经过人了」的凭据(照 A1 confirmed_total 的路子)。
|
||||
全部替代未果时它是 0:「大于等于 0」对「写过的 0」为真、对「没写过」为假
|
||||
(S-FEEL 未绑定的名字参与比较为假)。
|
||||
-->
|
||||
<sentry id="s_filled">
|
||||
<onPart id="op_fl" caseFileItemRef="filled_total"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_fl"><condition>filled_total >= 0</condition></ifPart>
|
||||
</sentry>
|
||||
<sentry id="s_shortlist">
|
||||
<onPart id="op_sl" caseFileItemRef="shortlist"><standardEvent>create</standardEvent></onPart>
|
||||
</sentry>
|
||||
<sentry id="s_confirmed">
|
||||
<onPart id="op_cf" caseFileItemRef="shortlist"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_cf"><condition>candidatesConfirmed >= 2</condition></ifPart>
|
||||
<!--
|
||||
── 放行门的入场条件:**认「办完了没有」,且读基座数**(A1 s_routed 的账)────
|
||||
· 清单上不再有 C 类(quote_alternative_list_type_tag_c 由基座按值现数)——
|
||||
FAE 漏填一行替代料,那一行转不了 A/B,这道门就不开;
|
||||
· 定标守恒:A + B = 行数(两边都是基座出的数);
|
||||
· 去向定过了(route_decided_total 是判稳那一步显式写下的记账数 ——
|
||||
旗是布尔,分不清「没写」与「写了 false」)。
|
||||
-->
|
||||
<sentry id="s_routed">
|
||||
<onPart id="op_rt" caseFileItemRef="route_decided_total"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_rt"><condition>route_decided_total > 0 and quote_alternative_list_rows > 0 and quote_alternative_list_type_tag_c = 0 and quote_alternative_list_type_tag_a + quote_alternative_list_type_tag_b = quote_alternative_list_rows</condition></ifPart>
|
||||
</sentry>
|
||||
|
||||
<stage id="S1" name="初选核险" autoComplete="true">
|
||||
<entryCriterion id="ec_s1" sentryRef="s_request"/>
|
||||
<humanTask id="T_spec_risk" name="人工查规格核险">
|
||||
<itemControl><manualActivationRule id="m_sr"/></itemControl>
|
||||
</humanTask>
|
||||
<humanTask id="T_brand" name="人工判主推品牌">
|
||||
<itemControl><manualActivationRule id="m_br"/></itemControl>
|
||||
</humanTask>
|
||||
<planningTable id="pt_s1">
|
||||
<humanTask id="T_known_issues" name="人工查沉淀避坑"/>
|
||||
</planningTable>
|
||||
</stage>
|
||||
<milestone id="M_initial" name="初选定性"/>
|
||||
|
||||
<stage id="S2" name="圈选替代" autoComplete="true">
|
||||
<entryCriterion id="ec_s2" sentryRef="s_need_alt"/>
|
||||
<humanTask id="T_shortlist" name="人工撒网拉初筛">
|
||||
<itemControl><manualActivationRule id="m_sl"/><repetitionRule id="r_sl"/></itemControl>
|
||||
</humanTask>
|
||||
<humanTask id="T_grade" name="人工逐条核验分级">
|
||||
<entryCriterion id="ec_grade" sentryRef="s_shortlist"/>
|
||||
<itemControl><manualActivationRule id="m_gr"/><repetitionRule id="r_gr"/></itemControl>
|
||||
</humanTask>
|
||||
<humanTask id="T_confirm" name="人工确认主推型号">
|
||||
<itemControl><manualActivationRule id="m_cf"/></itemControl>
|
||||
</humanTask>
|
||||
</stage>
|
||||
<milestone id="M_candidates" name="候选圈定"/>
|
||||
|
||||
<stage id="S3" name="对比评估交付" autoComplete="true">
|
||||
<entryCriterion id="ec_s3" sentryRef="s_confirmed"/>
|
||||
<humanTask id="T_collect" name="人工收集规格书">
|
||||
<itemControl><manualActivationRule id="m_co"/></itemControl>
|
||||
</humanTask>
|
||||
<humanTask id="T_compare" name="人工参数对比表">
|
||||
<itemControl><manualActivationRule id="m_cp"/></itemControl>
|
||||
</humanTask>
|
||||
<humanTask id="T_risk" name="人工替代风险评估">
|
||||
<itemControl><manualActivationRule id="m_rk"/></itemControl>
|
||||
</humanTask>
|
||||
<humanTask id="T_sign" name="人工签字交出">
|
||||
<itemControl><manualActivationRule id="m_sg"/></itemControl>
|
||||
</humanTask>
|
||||
</stage>
|
||||
<milestone id="M_signed" name="签字放行"/>
|
||||
|
||||
<humanTask id="T_knowledge" name="人工知识入库">
|
||||
<itemControl><manualActivationRule id="m_kb"/></itemControl>
|
||||
<!--
|
||||
① 归一询价受理清单 —— 上游受理记录(或单飞来件)整份落成本活动的清单,
|
||||
C 类行就是本单要办的对象。**整份都落**:A/B 行原样承继 ——
|
||||
这份清单最终要整份交给下游,不是只有 C 类那几行。
|
||||
-->
|
||||
<humanTask id="S1_normalize" name="归一询价受理清单:整份落表,C 类标待选型">
|
||||
<itemControl><repetitionRule id="r_s1"/></itemControl>
|
||||
</humanTask>
|
||||
|
||||
<timerEventListener id="TL_half" name="时限过半">
|
||||
<timerExpression>P2D</timerExpression>
|
||||
</timerEventListener>
|
||||
<userEventListener id="UL_change" name="客户改需求"/>
|
||||
<!--
|
||||
② 替代选型交 FAE —— **一张执行单**(MVP 简化口径:选型的实质工作在系统外)。
|
||||
manualActivation + 不给 doneWhen:发卡触达真人,引擎不替你按;
|
||||
「人看过、选完、填回来了」也不是台账里任何一个数能自己变出来的。
|
||||
-->
|
||||
<humanTask id="S2_fill" name="替代选型交FAE:执行单上把 C 类行的替代料填回来">
|
||||
<entryCriterion id="ec_s2" sentryRef="s_normalized"/>
|
||||
<itemControl><manualActivationRule id="m_s2"/></itemControl>
|
||||
</humanTask>
|
||||
|
||||
<!--
|
||||
③ 判稳定标 —— 替代料查历史行情,稳转 A、不稳转 B(规则写死,零裁量);
|
||||
清单就此从「受理记录」转成「受理清单」,去向旗随之立起。
|
||||
-->
|
||||
<humanTask id="S3_reclass" name="判稳定标:替代料查行情,稳转A不稳转B,立去向旗">
|
||||
<entryCriterion id="ec_s3" sentryRef="s_filled"/>
|
||||
</humanTask>
|
||||
|
||||
<!-- 清单放行:终局唯放行 —— 触达世界,必须人工激活 -->
|
||||
<humanTask id="T_release" name="询价受理清单验收放行">
|
||||
<entryCriterion id="ec_rel" sentryRef="s_routed"/>
|
||||
<itemControl><manualActivationRule id="m_rl"/></itemControl>
|
||||
</humanTask>
|
||||
</casePlanModel>
|
||||
</case>
|
||||
</definitions>`;
|
||||
|
||||
export const NARRATIVE = `§0 开案:替代选型一事一议 —— 收到替代评估请求即开案。客户/PM 给出初选物料,或芯片类型与大致参数。
|
||||
先核初选(EOL 等风险提示 + 是否我司主推品牌),无风险且主推直接采信初选办结;
|
||||
需要替代的,先撒网(AI 检索/聚合平台拉初筛清单)后逐条人工核验,常要两三轮,
|
||||
存活候选按替代等级分级(P2P/功能兼容/其他)圈 2~3 颗,做参数对比与风险评估成报告交回。
|
||||
**任何替代必须把差异与风险写清 —— 差异没核清的不放行。**
|
||||
/**
|
||||
* 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本,两处不重复写同一件事)。
|
||||
*/
|
||||
export const NARRATIVE = `§0 开案:**一份带 C 类料的询价受理记录要过替代选型**。来路有两种,形状都收(宽进):
|
||||
· **流内**:上游受理活动放行后带着它的落地事实进来 —— 顶层有单号 / 客户名 / 三类条数等
|
||||
摊平字段,\`records\` 数组里是上游全部落地行**混在一起**(带年用量与制造商的是受理记录行、
|
||||
带分级依据与历史天数的是分类表行、带各种总数的是结论行 —— 按 PN 归并自辨,别当成三张表);
|
||||
· **单飞**:没有上游,只有一段需求(型号或芯片类型 + 参数 + 场景)—— 照来件归一,
|
||||
没给的照实留空。
|
||||
|
||||
S1 初选核险:一手资料以原厂官网为准(产品页/规格书/PCN·EOL 公告);
|
||||
查芯片规格书确认初选料有无 EOL/停产风险,生命周期对照 PN物料主库;
|
||||
对品牌原厂字典判是否我司主推 —— 无风险且主推,直接采信初选、免替代办结。
|
||||
顺手查替代料库与技术问题案例库(裁量项),有沉淀方案或已知坑先亮出来。
|
||||
**这个活动是复杂活动的 MVP 简化形**:替代选型的实质工作(查规格、撒网、参数对比)
|
||||
由 FAE 在**系统外**完成,系统只负责三件事 —— 把清单归一好、把执行单组好交给他、
|
||||
把他填回的替代料**判稳定标**(在库行情稳转 A,不稳转 B)。
|
||||
|
||||
S2 圈选替代:先撒网后核验,常要两三轮。逐条人工核验做全面参数对比 ——
|
||||
先明确每项数据的来源(一律以原厂官网规格书为准),再逐项对标(Pin-to-Pin);
|
||||
存活者按替代等级分级;候选过客户技术画像禁用清单;不足 2~3 颗就放宽参数或换角度重新撒网。
|
||||
**成品只有一样**:一份**询价受理清单** —— 整份行都在、C 类已全部转成 A 或 B、
|
||||
去向旗立了(还有 B 类就去寻源补价,全 A 直通定价)。判稳的尺与三分类 P-03 的 A/B
|
||||
两行同一把:**历史价 ≤ 90 天且行情稳 → A,否则 → B**。
|
||||
FAE 的定案不只是一个型号:**替代等级(P2P 直换 / FUNC 功能兼容 / OTHER 须验证)、
|
||||
参数差异、风险与验证建议一并落表** —— 那是他的专业产出,逐颗可溯。
|
||||
|
||||
S3 对比评估交付:收齐候选规格书,关键参数逐项对比写进替代对标表,标出差异项与替代等级,
|
||||
写明差异带来的风险;按等级定评估深度 —— P2P 核确免改动,功能兼容/其他逐项评软件开发、
|
||||
外围电路与 layout 改动。FAE 签字后交回发起方。
|
||||
**人环两次,不多不少**:FAE 交回**一张选型执行单**(工作单里一颗待选型芯片一页,
|
||||
右上角翻页逐颗定案,必填缺着按不下提交)、放行时 FAE 签字。
|
||||
|
||||
全程随时可做:知识入库 —— 规格书入库、替代关系回填替代料库与 PN物料主库、踩到的坑记案例库。
|
||||
【一路上要说话】负责人正看着这一单。**每一拍都带一句 say** —— 说业务的话:
|
||||
哪几颗料要替代、替代料查出来什么行情、这单接下来往哪走;
|
||||
不说「归一化」「写草稿 N 行」这类系统词。到了要找人的那一拍,先说清这一轮办完了什么、
|
||||
为什么现在需要他。别每拍一个腔调复读。
|
||||
|
||||
§Z 结案:必做的事都办完、没有进行中的事,自动算结案(CMMN AutoComplete)。
|
||||
无需替代的,确认初选可用即结案;需要替代的,推荐报告(已签字)交回发起方即结案 ——
|
||||
每办一案,知识库厚一层。`;
|
||||
【怎么把一项干完 · 一拍连做】一个计划项常要两三个动作才交得出成果。write 和 tool
|
||||
都**不会**自动把那一项收掉 —— 都做完了,用 finish 宣告完成。动作排进同一拍的 actions
|
||||
串里一口气给,别拆拍。只有两处必须停拍:调工具要看出参(tool 放串尾),
|
||||
发卡 / 提交会挂起(只能是串尾)。**常路 6-9 拍就该收口** —— 选型只有一张卡,颗数多不多不加拍。
|
||||
|
||||
§Z 结案:FAE 在验收单上签字,系统把草稿合并成正式清单并发出 roster 口的载荷 ——
|
||||
清单整份交给下游,下游按自己的口径归一需要的数据。`;
|
||||
|
||||
/**
|
||||
* 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。
|
||||
*/
|
||||
export const ALTERNATIVE_STEPS = [
|
||||
{
|
||||
item: 'S1_normalize',
|
||||
intent: '把上游受理记录(或单飞来件)整份归一成选型底表,C 类行标待选型',
|
||||
writes: ['quote_alternative.list', 'quote_alternative.verdict'],
|
||||
/*
|
||||
* `records` 是上游整包落地行 —— 不声明的话它会被整表段的截断刀砍掉
|
||||
* (那一段按字符数截,上游一单的落地行远超刀口,砍掉的恰是 C 类行)。
|
||||
* 声明了的键**置前且不参与截断**。单飞起单没有这个键,声明也无副作用。
|
||||
*/
|
||||
reads: ['records'],
|
||||
/* 归一与 FAE 选型期间摆**选型工作单**;判稳转正后才换收口那张清单 */
|
||||
view: 'alt-worksheet',
|
||||
tools: [],
|
||||
/*
|
||||
* 归一完才算办完:两边对上 —— alt_line_total 自报、rows 基座现数。
|
||||
* **不能写 `records != null`**:S-FEEL 未绑定名字 `!= null` 为 true,开局即收(A1 踩过)。
|
||||
*/
|
||||
doneWhen: 'alt_line_total > 0 and quote_alternative_list_rows = alt_line_total',
|
||||
guide: `第一件事:把这一单的**选型底表**落出来 —— 整份承继,不是只有 C 类。
|
||||
工作区这一步摆的是**替代选型工作单**(逐颗料的选型档案与填回区),你落的行就是它的血肉。
|
||||
|
||||
【料从哪儿来】
|
||||
· 流内(任务数据里有 \`records\` 数组):那是上游全部落地行**混在一起**的一份 ——
|
||||
同一颗料会出现两三次:带 eau/brand/target_price 的是**受理记录行**(以它为准),
|
||||
带 basis/history_days/market_stable 的是分类表行(它的 type_tag 与受理记录一致),
|
||||
带 line_total/type_a_total 这类总数的是结论行(不是料,别落进清单)。
|
||||
**按 pn 归并**:一颗料归一成清单里的一行,列取受理记录行的值;
|
||||
type_tag 照抄成本清单的 **orig_type(原标)与 type_tag(现标)两列同一个值**。
|
||||
· 单飞(没有 \`records\`):照入参与正文归一 —— 有型号落型号;只给芯片类型与参数、
|
||||
没有具体 PN 的,\`pn\` 写「类型-场景」需求代号(如「运放-车规-01」),
|
||||
这类需求本来就是来选型的,orig_type 与 type_tag 都写 C。
|
||||
|
||||
【单号 rfq_ref】入参有 \`rfqRef\` 就**原样照抄**;没有就从主题/正文里认(RFQ-xxxx);
|
||||
认不出才造一个稳定案号「ALT-<型号或类型>」—— 它是本活动所有台账行的主键,
|
||||
造完这一单从头用到尾,**不许中途换写法**(换一个写法就是新开一行)。
|
||||
造号**只从本单来件取材,不许从台账里已有的别单单号里挑** —— 正式表里躺着的
|
||||
单号是别人的单,借它的号等于把两单的行混进同一个案卷。
|
||||
|
||||
【逐列怎么落】写 \`quote_alternative.list\`,一颗料一行,一拍写完(rows 收数组):
|
||||
pn(**一个字符不许改**)、customer_pn、category、brand、eau、target_price、lead_time、
|
||||
**remark(客户备注:预授权替代、认证要求、禁用约束 —— 选型的第一手依据,一个字别丢)**
|
||||
照上游值抄,没有的留空字符串(**绝不写 0 顶空**);orig_type / type_tag 照抄原标
|
||||
(**只写 A、B、C 三个大写字母之一**,写别的基座数不出来);
|
||||
C 类行把「为什么是 C」写进 basis(从分类表行的 basis 抄,或客户点名替代的原话);
|
||||
alt_pn / alt_brand / alt_grade / alt_diff / alt_risk / alt_verify / alt_note
|
||||
**全部留空** —— 那是 FAE 的定案,你不许编。
|
||||
|
||||
【同一拍记账】写 \`quote_alternative.verdict\`(一单一行):
|
||||
rfq_ref、customer_code / customer_name(顶层摊平字段里有就抄,没有留空)、
|
||||
**alt_line_total** = 清单行数、**c_total** = C 类条数。
|
||||
写完 finish 这一项 —— 引擎按「自报条数 = 基座行数」收步,少拆多写都过不去。`,
|
||||
},
|
||||
{
|
||||
item: 'S2_fill',
|
||||
intent: '一张选型执行单交 FAE:工作单一颗一页,定案(型号/品牌/等级)逐页填回',
|
||||
writes: ['quote_alternative.verdict'],
|
||||
reads: ['quote_alternative.list'],
|
||||
view: 'alt-worksheet',
|
||||
tools: [],
|
||||
/*
|
||||
* 不给 doneWhen —— 发卡触达真人(manualActivation),引擎不替人按;
|
||||
* 「FAE 选完填回来了」也判不出来:它是人环,不是台账里能自己变出来的数。
|
||||
*/
|
||||
guide: `底表归一好了,**该 FAE 出场了**。规矩(2026-08-02 用户定):
|
||||
**一张执行单,工作单里一颗待选型芯片一页** —— 他在右上角翻页逐颗定案,
|
||||
定案三格(型号/品牌/等级)是每页的必填,缺着按不下提交(系统的闸看着)。
|
||||
|
||||
【怎么发】照这个骨架发一张卡(它标了人工激活:发卡就算启动,不必单发 start;
|
||||
引擎也不会替你收 —— 人交回后要你自己 finish)。卡面是纯文本,
|
||||
steps/standard 里**别写星号记号**:
|
||||
{"kind":"human","itemId":"S2_fill","workOrder":"execution","card":{
|
||||
"title":"替代选型:<单号> 待选型 <N> 颗",
|
||||
"ask":"这几颗料要替代:请照工作单逐页的选型档案在系统外完成选型,把定案填进各页右栏的「替代定案」(型号/品牌/等级是必填,缺着按不下提交),填完保存,回这张卡提交;确实找不到替代的,在那一页的选型备注写明缘由后交回。",
|
||||
"steps":["工作单一颗一页,右上角翻页;每页「替代定案」栏里给出替代料型号与品牌,替代等级点三档之一:P2P(同封装同引脚可直换)/ FUNC(功能兼容,需外围电路或 layout 调整)/ OTHER(部分兼容,须打样验证)",
|
||||
"P2P 必须逐项核对过五样:封装尺寸、引脚定义、电气参数、温度等级、认证 —— 以原厂官网规格书为准,同封装不等于同引脚;核对过哪几项写进参数差异项",
|
||||
"FUNC 或 OTHER 的,参数差异项与差异风险写清差在哪、要动什么;验证建议写明要不要打样、验什么",
|
||||
"客户备注里已预授权的替代优先采信;点名禁用的品牌不得作候选;确实找不到替代:在那一页的选型备注写明缘由(那也是一个答案);填完保存,回这张卡提交"],
|
||||
"standard":"每一颗:型号、品牌、等级都有且来源可溯(三格必填,闸看着);或备注写明替代未果的缘由。交回后判稳定标由系统接着办。",
|
||||
"basis":[{"k":"<待选型 PN 之一>","v":"<为什么是 C + 客户约束(预授权/认证/禁用)>"}]}}
|
||||
· \`basis\` 逐颗列待选型的料(几颗写几条);标题带单号与颗数。
|
||||
· **卡上不摆格子**(填写全在工作区):定案填在工作单各页的「替代定案」栏里,
|
||||
不往卡里塞 \`fields\` 或 \`blocks\`。
|
||||
发完卡挂起,这一拍到此为止。**这张卡一单只发一次** —— 必填有提交闸兜着,
|
||||
卡回来时每颗要么三格齐、要么备注写明未果。
|
||||
|
||||
【人交回之后】**他的定案已经在台账里了** —— 他在工作单上按「保存」那一刻,
|
||||
基座就把那几格落进了草稿层(该由人亲手落下的,不经你的手)。
|
||||
**不许再把那几格 write 一遍** —— 值相同的写入被「写了等于没写」拒(实测)。
|
||||
这一拍只做两件事,然后 finish:
|
||||
① 清点 alt_pn 有值的颗数写 **filled_total** 进 verdict(全部未果就是 0,照实写);
|
||||
② finish 这一项,**这一拍到此为止** —— 判稳、转标、提交都不是这一拍的活:
|
||||
转标还没做,submit 排进这一串必被「判据没过」拒回(实测白挨一次)。
|
||||
**这一拍也不许调 crm.history-price-read** —— 查行情是判稳那一步(下一步)开头的活;
|
||||
实测提前查过一次:查完没处落,连着四拍把同一行原样重写、四次被
|
||||
「写了等于没写」拒回,白烧四拍才想起该 finish。`,
|
||||
},
|
||||
{
|
||||
item: 'S3_reclass',
|
||||
intent: '替代料批量查历史行情:稳转 A、不稳转 B;清单转正,立去向旗',
|
||||
writes: ['quote_alternative.list', 'quote_alternative.verdict'],
|
||||
reads: ['quote_alternative.list'],
|
||||
view: 'alt-roster',
|
||||
tools: ['crm.history-price-read'],
|
||||
/*
|
||||
* **收步判据与验收判据同源**:C 类清零(基座按值现数)才算这一步办完 ——
|
||||
* 收步比验收松,松出来的那一截就是返工(A1 的账)。
|
||||
* route_decided_total 是本步显式写下的记账数(布尔旗分不清「没写」与「写了 false」)。
|
||||
*/
|
||||
doneWhen: 'quote_alternative_list_type_tag_c = 0 and route_decided_total > 0',
|
||||
guide: `FAE 填回来了,现在把清单**转正**:每颗替代料判稳、定标,清单从「受理记录」
|
||||
变成「询价受理清单」。
|
||||
|
||||
【第一拍:查行情】把所有原标 C 行的 **\`alt_pn\` 那一列的值**(替代料的料号)收进
|
||||
一个数组,调 \`crm.history-price-read\` 一次传完(参数 pns 收数组)。
|
||||
**判稳的行情只有这一条来路 —— 这一拍亲自查 alt_pn。**两条近路都不许走:
|
||||
· **不许传原 PN**(pn 列是被替代的旧料,查它查到的是旧料的行情);
|
||||
· **不许拿上游带进来的数顶替**:入参里若躺着 history_days / market_stable /
|
||||
lifecycle,那是 A1 当时查**原 PN** 得的账 —— 跟这颗**替代料**稳不稳没有关系。
|
||||
实测抄过一次近路:上游写着 9999,模型没调工具就把替代料判成 B,
|
||||
而那颗替代料库里明明有 43 天的稳定历史,整单去向就此走岔。
|
||||
调完这一拍就结束(工具出参要下一拍才看得见)。
|
||||
|
||||
【第二拍:定标 + 记账 + 收步,一拍连做】
|
||||
① **规则写死,不留裁量**(与三分类 P-03 的 A/B 两行同一把尺):
|
||||
historyDays ≤ 90 且 marketStable = true → **type_tag 转 A**;否则 → **转 B**。
|
||||
工具回的 lifecycle 若是 EOL/缺货/要求替代 —— 那说明替代料选错了,**不转标**,
|
||||
当成替代未果处置(见③)。
|
||||
② 逐行写回 \`quote_alternative.list\`(按 rfq_ref + pn 对行,主键一个字符不许改):
|
||||
type_tag(A 或 B,**照规则写,不许臆断**)、history_days(工具出参照抄)、
|
||||
basis(一句依据:「替代料 <alt_pn> 历史价 <N> 天·行情稳 → 转 A」这样)。
|
||||
**先对号再用**:工具出参每行带着查询的 pn —— 那个 pn 必须等于这一行的 alt_pn,
|
||||
对不上就是第一拍传错了列,回去重查,别把别人的行情安到这颗替代料头上。
|
||||
orig_type **不动** —— 原标是留痕,转的是现标。
|
||||
③ **替代料留空(FAE 判未果)的 C 行**:
|
||||
· 选型备注写了缘由 → 用 \`drop\` 把那一行撤出本单草稿(主键照 \`_keys\` 名单**原样粘**),
|
||||
把撤了哪几颗、为什么写进 verdict.skipped_note,并把 alt_line_total 改成撤后的行数
|
||||
(清单条数跟着改对,别让「条数 5 而表里 4 行」把归一那步的判据顶翻);
|
||||
· 备注也空着 → 发一张**决策卡(decide)**问负责人:「这颗料替代未果,剔出本轮
|
||||
还是退回整单」两个选项摆出来,人点哪个照哪个办。**只问这一次**
|
||||
(补全卡已退役 —— 二选一是处置,本就该用决策卡)。
|
||||
④ 写 verdict:converted_a_total / converted_b_total(转成 A/B 各几条,照②的实数)、
|
||||
**need_sourcing**(清单里还有 B 类 → true,全 A → false;照基座读数
|
||||
quote_alternative_list_type_tag_b 的正负写,别凭记忆)、**route_decided_total 记 1**、
|
||||
summary 一句人话(几颗替代、转成什么、往哪走)。
|
||||
⑤ finish 这一项,**这一拍到此为止**。引擎按「C 类清零 + 去向定过」收步 ——
|
||||
有一颗 C 没处置,这一步收不掉,出路只有三条:转标(填了替代料)、
|
||||
剔除(未果且有说明)、问人(未果且没说明)。
|
||||
**别把 submit 排进这一串**:放行那一步要先把 L3 裁量裁过才轮得到 submit,
|
||||
跳过它会被当场拒回(实测白挨过一次)。
|
||||
|
||||
**去向不用你选,也不用人选**:契约给两个出口各写了判据(还有没有 B 类),
|
||||
放行那一刻基座按序求值自己定;need_sourcing 那枚旗是给流程包的边读的,
|
||||
两处读的都是同一个事实 —— 你只负责把标转对。`,
|
||||
},
|
||||
{
|
||||
item: 'T_release',
|
||||
view: 'alt-roster',
|
||||
intent: '清单已转正,提交放行交 FAE 验收',
|
||||
writes: [],
|
||||
reads: [],
|
||||
tools: [],
|
||||
/* 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 doneWhen) */
|
||||
guide: `清单整份转正、去向已定 —— 可以走放行了(T_release 人工激活:
|
||||
动它就算启动,不必单发 start)。先把裁量判据裁掉,然后 submit,同一拍连做:
|
||||
|
||||
· judge criterion="替代料来源可溯且选型说明已留:以原厂官网规格书或客户预授权为准,每颗替代写明凭什么" ——
|
||||
看清单里每颗替代料的 basis 与选型备注:都写了依据就 satisfied=true;
|
||||
有替代料说不出来源的,satisfied=**false**,rationale 逐颗写清哪几颗没依据,
|
||||
交签字的人定。**裁成不满足照样能提交** —— 理由连着卡一起交给他,那正是这条判据的用处。
|
||||
criterion 照判据名**整句原样抄**,写别的名字对不上。
|
||||
|
||||
**去向不用你裁**:两个出口各写了判据,放行那一刻基座按序求值、首个命中即出。
|
||||
终局唯放行:你不能自己宣布完成。`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段;
|
||||
* 放行那一格由基座统一加(CLOSING_PHASE),人看到的是 x/4。
|
||||
*/
|
||||
export const ALTERNATIVE_PHASES = [
|
||||
{ name: '归一清单', items: ['S1_normalize'] },
|
||||
{ name: '选型填回', items: ['S2_fill'] },
|
||||
{ name: '判稳定标', items: ['S3_reclass'] },
|
||||
];
|
||||
|
||||
@@ -1,63 +1,16 @@
|
||||
/**
|
||||
* 统一报价 A2 · 替代选型 —— 活动包代码入口(ADR-004)。
|
||||
* loop 活动:调度权归 AI;本文件只做视图流控与「差异没核清不放行」的把关呈现。
|
||||
* 统一报价 A2 · 替代选型 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
|
||||
*
|
||||
* **这个活动没有 `run`,不是缺件。**
|
||||
*
|
||||
* 案例活动的推进由 Loop 驱动,行为全部写在清单里:案例模型(CMMN 子集)+ 案例说明书 +
|
||||
* 步骤剧本 + 两条技能 + 台账声明 + 一张 HTML 报告模版。「清单机器可读,
|
||||
* 系统不执行代码即知包为何物」。上一版这里有 62 行 `run()` —— loop 活动根本不会调它,
|
||||
* 还在读一个契约里早已退场的 `originalPn`:摆一个空转的 run 去假装有实现,更坏。
|
||||
*
|
||||
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 —— 它是**被静态审扫描的产物文本**
|
||||
* (查 import 与全局引用,证明活动包零环境能力)。
|
||||
*/
|
||||
import { defineActivity } from '@sdk';
|
||||
import type { ActivityBase, ActivityOutcome } from '@sdk';
|
||||
|
||||
const SHORTLIST = 'quote_alternative.shortlist';
|
||||
const BENCHMARK = 'quote_alternative.benchmark';
|
||||
|
||||
export default defineActivity({
|
||||
name: 'quote-alternative',
|
||||
paradigm: 'loop',
|
||||
|
||||
async run(base: ActivityBase): Promise<ActivityOutcome> {
|
||||
const shortlist = await base.ledger.read(SHORTLIST);
|
||||
const benchmark = await base.ledger.read(BENCHMARK);
|
||||
// 差异没核清的不放行 —— 禁区的机检形态
|
||||
const unclear = benchmark.filter((r) => String(r['risk'] ?? '').trim() === '');
|
||||
|
||||
await base.interaction.pushView([
|
||||
{
|
||||
t: 'kv',
|
||||
title: '选型进展',
|
||||
items: [
|
||||
{ k: '原型号', v: String(base.contract.data['originalPn'] ?? '') },
|
||||
{ k: '初筛候选', v: `${shortlist.length} 颗` },
|
||||
{ k: '已对标', v: `${benchmark.length} 颗` },
|
||||
{ k: '差异未核清', v: `${unclear.length} 颗` },
|
||||
],
|
||||
},
|
||||
{
|
||||
t: 'cards',
|
||||
title: '候选替代',
|
||||
items: benchmark.map((r) => ({
|
||||
tag: String(r['grade'] ?? '待定级'),
|
||||
title: String(r['candidate_pn'] ?? ''),
|
||||
swap: { from: String(r['original_pn'] ?? ''), to: String(r['candidate_pn'] ?? '') },
|
||||
big: String(r['grade'] ?? '—'),
|
||||
bigLabel: '替代等级',
|
||||
note: String(r['risk'] ?? '差异风险未写'),
|
||||
tone: String(r['risk'] ?? '').trim() === '' ? 'danger' : 'ok',
|
||||
})),
|
||||
},
|
||||
]);
|
||||
|
||||
if (benchmark.length < 2) {
|
||||
await base.interaction.say(
|
||||
shortlist.length === 0 ? '尚未撒网,先拉初筛清单。' : `已初筛 ${shortlist.length} 颗,继续逐条核验分级。`,
|
||||
'conclusion',
|
||||
);
|
||||
return { kind: 'await', reason: '候选不足 2 颗' };
|
||||
}
|
||||
|
||||
if (unclear.length > 0) {
|
||||
await base.interaction.say(`${unclear.length} 颗候选的差异风险未写清 —— 差异没核清的不放行。`, 'criterion');
|
||||
return { kind: 'await', reason: '差异风险未核清' };
|
||||
}
|
||||
|
||||
await base.interaction.say('候选已分级、差异与风险已写清,可签字交回发起方。', 'criterion');
|
||||
return { kind: 'submit', deliverable: 'benchmark-table', rationale: '2 颗以上分级候选,差异与风险齐备' };
|
||||
},
|
||||
});
|
||||
export default defineActivity({ name: 'quote-alternative' });
|
||||
|
||||
@@ -1,92 +1,222 @@
|
||||
/**
|
||||
* 活动包清单 —— 统一报价 A2 · 替代选型(**案例活动 · loop**)。
|
||||
* 活动包清单 —— 统一报价 A2 · 替代选型(案例活动 · MVP 简化形)。
|
||||
*
|
||||
* 业务素材:docs/MVP示例工作流.html §1.6。
|
||||
* `▶需拍板`(签字交出)→ manualActivation + 放行;`✋客户改需求` → userEventListener;
|
||||
* `⏱时限过半` → 时间监听(**时限必配**)。
|
||||
* 业务素材:`docs/MVP示例工作流.html` §1.5,按 2026-08-02 用户改盘的口径归一:
|
||||
* **A2 是复杂活动(需要大规模业务建模),MVP 期间不展开** —— 只做一张精美的
|
||||
* HTML 验收报告 + 一张执行单:AI 挑出 C 类、组好清单交 FAE 在系统外选型填回,
|
||||
* 替代料查库内行情判稳(稳转 A / 不稳转 B),询价受理记录就此转换成
|
||||
* **询价受理清单**传给下游。提交就是输出。
|
||||
*
|
||||
* ── 这里**去掉了 `GRADE_DMN`(替代等级判定表)**(2026-08-02)────────────────
|
||||
* 它声明着三个入参(pinCompatible / packageSame / paramDelta),而台账上没有任何列
|
||||
* 承载它们、剧本里也没有一步调它 —— **一张没有求值器的判定表不是资产,是错觉**:
|
||||
* 看清单的人以为等级是算出来的,实际全程没人算过。MVP 口径下替代等级的判断
|
||||
* 整个在 FAE 脑子里(系统外),系统不假装接得住;判稳转标那一步的规则
|
||||
* (≤90 天且稳 → A,否则 → B)写死在剧本里、依据逐行落表,可回核。
|
||||
*/
|
||||
import type { ActivityManifest, DmnModel } from '@contracts';
|
||||
import { ALTERNATIVE_SKILLS } from './skills.ts';
|
||||
import { ALTERNATIVE_CASE } from './views/report.ts';
|
||||
import { activityIdSchema } from '@contracts';
|
||||
import { CMMN_XML, NARRATIVE } from './case-model.ts';
|
||||
import { ALTERNATIVE_SKILLS } from './skills.ts';
|
||||
import { ALT_ROSTER, ALT_WORKSHEET } from './views/report.ts';
|
||||
import { ALTERNATIVE_PHASES, ALTERNATIVE_STEPS, CMMN_XML, NARRATIVE } from './case-model.ts';
|
||||
|
||||
const DOMAIN = 'quote_alternative';
|
||||
const D = 'quote_alternative';
|
||||
const ACTIVITY_ID = activityIdSchema.parse('quote-alternative');
|
||||
|
||||
/** 替代等级 —— 把「装得下的判断」从 LLM 手里拿走 */
|
||||
export const GRADE_DMN: DmnModel = {
|
||||
drd: [{ decision: 'alt-grade', requires: [], inputs: ['pinCompatible', 'packageSame', 'paramDelta'] }],
|
||||
tables: [
|
||||
{
|
||||
id: 'alt-grade',
|
||||
label: '替代等级判定',
|
||||
hitPolicy: 'FIRST',
|
||||
inputs: [
|
||||
{ name: 'pinCompatible', expression: 'pinCompatible' },
|
||||
{ name: 'packageSame', expression: 'packageSame' },
|
||||
{ name: 'paramDelta', expression: 'paramDelta' },
|
||||
],
|
||||
outputs: [{ name: 'grade' }, { name: 'effort' }],
|
||||
rules: [
|
||||
{ id: 'g-p2p', when: ['true', 'true', '<= 0.05'], then: ['P2P', '核确免改动'] },
|
||||
{ id: 'g-func', when: ['-', '-', '<= 0.20'], then: ['功能兼容', '评外围电路与 layout'] },
|
||||
{ id: 'g-other', when: ['-', '-', '-'], then: ['其他', '须验证'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
/** MVP 口径:A2 没有判定表(见文件头注释)—— 判稳规则写死在剧本,依据逐行落表 */
|
||||
export const ALT_DMN: DmnModel = { drd: [], tables: [] };
|
||||
|
||||
export const manifest: ActivityManifest = {
|
||||
identity: {
|
||||
activityId: ACTIVITY_ID,
|
||||
name: 'A2 · 替代选型',
|
||||
packageVersion: '0.1.3',
|
||||
packageVersion: '4.16.0',
|
||||
category: '统一报价',
|
||||
sdkVersion: '0.1.0-m5',
|
||||
checksum: 'dev-checksum-quote-alternative',
|
||||
},
|
||||
|
||||
/* ── 契约七件 ────────────────────────────────────────────────── */
|
||||
contract: {
|
||||
activityId: ACTIVITY_ID,
|
||||
version: '0.1.0',
|
||||
version: '1.0.0',
|
||||
input: {
|
||||
/*
|
||||
* **宽进到底:一个必填都不设**(2026-08-02 用户当场定:「活动是宽进严出,
|
||||
* 输入接受任何类型」「活动的输入契约是在活动内部归一+补全的」)。
|
||||
*
|
||||
* 来路本就有两种形状:流内(上游放行载荷 —— rfqRef + records + 一堆摊平字段)
|
||||
* 与单飞(一段需求文字,可能连具体型号都没有)。门口拦谁都不对 ——
|
||||
* 归一是第一个计划项的活,缺什么在活动内问。
|
||||
*
|
||||
* 上一版必填 `rfqRef + altRequests`:而 `alt_request` 表 2026-08-02 已在 A1 退休,
|
||||
* 那个键只是载荷数组通配规则下的空壳(内容与 records 完全相同)——
|
||||
* 必填一个不存在的东西,单飞起单永远停在补全卡上。
|
||||
*/
|
||||
data: {
|
||||
type: 'object',
|
||||
required: ['originalPn'],
|
||||
required: [],
|
||||
properties: {
|
||||
originalPn: { type: 'string' },
|
||||
chipType: { type: 'string' },
|
||||
params: { type: 'object' },
|
||||
scenario: { type: 'string' },
|
||||
rfqRef: { type: 'string', title: '询价单号' },
|
||||
records: { type: 'array', title: '上游受理记录(已打标)' },
|
||||
subject: { type: 'string', title: '需求主题' },
|
||||
body: { type: 'string', title: '需求正文' },
|
||||
attachments: {
|
||||
type: 'array',
|
||||
format: 'file',
|
||||
title: '附件',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', title: '文件名' },
|
||||
contentType: { type: 'string', title: '类型' },
|
||||
size: { type: 'integer', title: '字节数' },
|
||||
ref: { type: 'string', title: '存档号' },
|
||||
text: { type: 'string', title: '读出来的文本' },
|
||||
note: { type: 'string', title: '读取说明' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
messageAllowed: true,
|
||||
messageHint: '客户点名的替代授权、禁用品牌、场景约束等写这里',
|
||||
},
|
||||
output: {
|
||||
/*
|
||||
* 出口载荷**从落地事实推导**:标量字段必须有台账列承载。
|
||||
* `needSourcing` ← verdict.need_sourcing —— 流程包 E5(仍需寻源 → A3)读的
|
||||
* 就是它;上一版声明了这个键而台账没有承载列,**E5 从来没有成立过**:
|
||||
* 选完型仍要寻价的单一律被默认边送去定价(2026-08-02 探查实证)。
|
||||
*/
|
||||
mandatory: [
|
||||
{
|
||||
name: 'benchmark',
|
||||
payload: { type: 'object', required: ['rows'], properties: { rows: { type: 'array' } } },
|
||||
description: '替代对标表(已签字)',
|
||||
name: 'roster',
|
||||
payload: {
|
||||
type: 'object',
|
||||
required: ['rfqRef', 'rows', 'needSourcing'],
|
||||
properties: {
|
||||
rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' },
|
||||
rows: { type: 'array', title: '询价受理清单(C 类已转 A/B)' },
|
||||
needSourcing: { type: 'boolean', title: '仍需寻源(清单里还有 B 类)' },
|
||||
},
|
||||
},
|
||||
description: '询价受理清单(C 类已转 A/B)+ 寻源旗',
|
||||
},
|
||||
],
|
||||
selective: [],
|
||||
/*
|
||||
* **两条出边 = 一个 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 类)' },
|
||||
},
|
||||
requiredDeliverables: [{ key: 'benchmark-table', label: '替代对标表(FAE 签字)' }],
|
||||
},
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
/*
|
||||
* 必交付物只有一件:**询价受理清单**。选型结论(verdict)是它的结论栏,
|
||||
* 不是第二份成果 —— `ledgers` 说清这份成果由哪几张表承载(A1 的账:
|
||||
* 各占一条,人在成果面上会看到两样东西)。
|
||||
*/
|
||||
requiredDeliverables: [
|
||||
{
|
||||
key: 'alt-roster',
|
||||
label: '询价受理清单(C 类已转 A/B)',
|
||||
ledger: `${D}.list`,
|
||||
/*
|
||||
* 验收页上让 FAE 就地改的那几列 —— 恰是**他的职权面**:
|
||||
* 替代料三列是他填的,现标 `type_tag` 是判稳规则出的 ——
|
||||
* 但算得对不对该由人说了算,他改一颗料的标就是在推翻一次判定(A1 的账:
|
||||
* 不在名单里的列压根不进写回载荷,人改了等于没改)。
|
||||
* 去向不在其列:它是现标的函数,改标去向就跟着变。
|
||||
*/
|
||||
editableAtRelease: ['alt_pn', 'alt_brand', 'alt_grade', 'alt_diff', 'alt_risk', 'alt_verify', 'alt_note', 'type_tag'],
|
||||
ledgers: [`${D}.list`, `${D}.verdict`],
|
||||
},
|
||||
],
|
||||
/*
|
||||
* 验收判据 —— **机检(L1/L2)只读基座数得出来的数**(A1 被违反三次的那条纪律):
|
||||
* `quote_alternative_list_rows` 基座数行数、`_type_tag_a/b/c` 基座按取值域现数。
|
||||
* 上一版两条 L2 读的是活动自报的 verdict 列(candidate_total / undocumented_total)——
|
||||
* 那不是门,是「改哪个数能过」的提示。
|
||||
*/
|
||||
acceptanceCriteria: [
|
||||
{ key: 'candidates', label: '2~3 颗分级候选经确认', level: 'L2', predicate: 'candidatesConfirmed >= 2' },
|
||||
{ key: 'diff-clear', label: '差异项与风险逐项写清', level: 'L2', predicate: 'undocumentedDiff = 0' },
|
||||
{ key: 'source-traceable', label: '每项数据来源可溯(以原厂官网规格书为准)', level: 'L3' },
|
||||
{ key: 'fae-sign', label: 'FAE 签字放行', level: 'L5' },
|
||||
{
|
||||
key: 'roster-complete',
|
||||
label: '清单齐:上游受理记录逐条承继,说了几条清单里就躺着几行',
|
||||
level: 'L1',
|
||||
predicate: 'alt_line_total > 0 and quote_alternative_list_rows = alt_line_total',
|
||||
},
|
||||
{
|
||||
key: 'no-c-left',
|
||||
label: '选型办完:每颗待选型的 C 类料都已填回替代并转成 A 或 B,或经确认剔除',
|
||||
level: 'L1',
|
||||
predicate: 'quote_alternative_list_rows > 0 and quote_alternative_list_type_tag_c = 0',
|
||||
},
|
||||
{
|
||||
key: 'tag-conserved',
|
||||
label: '定标守恒:清单每一行都有现标,A 类加 B 类恰等于总行数',
|
||||
level: 'L2',
|
||||
predicate:
|
||||
'quote_alternative_list_type_tag_a + quote_alternative_list_type_tag_b = quote_alternative_list_rows',
|
||||
},
|
||||
/*
|
||||
* 来源可溯是**裁量**(L3):替代凭什么、说明留没留,机器数不出来 ——
|
||||
* 别假装机器判得了。裁成不满足照样能提交,理由连着卡交给签字的人。
|
||||
* 这句 label 与剧本里 judge 的 criterion **必须逐字一致**(judge 按整句对名)。
|
||||
*/
|
||||
{
|
||||
key: 'alt-traceable',
|
||||
label: '替代料来源可溯且选型说明已留:以原厂官网规格书或客户预授权为准,每颗替代写明凭什么',
|
||||
level: 'L3',
|
||||
},
|
||||
/* L5 是责任关:那张卡本身就是这道关,卡面上不摆它(基座统一滤) */
|
||||
{ key: 'fae-release', label: 'FAE 放行:签下去这份清单即转正并流向下游', level: 'L5' },
|
||||
],
|
||||
/* 去向不给人改(现标的函数);人在验收页改的是成果本身(editableAtRelease) */
|
||||
revisableAtRelease: [],
|
||||
prohibitions: [
|
||||
{ key: 'no-unclear-diff', statement: '差异没核清的不放行' },
|
||||
{ key: 'no-banned', statement: '客户技术画像禁用清单里的型号不得作候选' },
|
||||
{ key: 'no-fake-alt', statement: '没有把握的替代不填:宁可在选型备注写明替代未果,也不编一个候选顶上' },
|
||||
{ key: 'no-banned', statement: '客户点名禁用的品牌与型号不得作替代' },
|
||||
{ key: 'no-tag-fudge', statement: '不许为过判据改标:现标只能来自判稳规则,或人在件上的亲手修改' },
|
||||
],
|
||||
authorization: {
|
||||
discretion: [{ key: 'widen-search', statement: '候选不足 2~3 颗时可放宽参数或换角度重新撒网' }],
|
||||
discretion: [
|
||||
{ key: 'widen-search', statement: 'FAE 可放宽参数或换角度重新选型;是否接受非原厂品牌、参数降级必须问客户,不得替客户决定' },
|
||||
],
|
||||
/* 空着不是省事:这个字段全仓没有一个消费者(债 ⑨),写了也不会被执行 */
|
||||
preauthorizedActions: [],
|
||||
/* 一颗芯片一张选型单(按顺序派)—— 打扰上限按料数给:MVP 案例 ≤3 颗 + 放行 + 余量 */
|
||||
humanCallBudget: 6,
|
||||
},
|
||||
resources: {
|
||||
@@ -94,43 +224,121 @@ export const manifest: ActivityManifest = {
|
||||
skills: ALTERNATIVE_SKILLS.map((s) => s.id),
|
||||
models: ['deepseek-v4-flash'],
|
||||
roles: ['FAE'],
|
||||
budget: { steps: 100, tokens: 260_000, activeMinutes: 360, humanCalls: 6 },
|
||||
/*
|
||||
* FAE 在系统外选型,回来才继续 —— activeMinutes 给足一个工作日的等待;
|
||||
* token 按实测估:每颗料一张卡各占两三拍,常路(1-2 颗)8-12 拍,留一倍余量。
|
||||
*/
|
||||
budget: { steps: 60, tokens: 300_000, activeMinutes: 480, humanCalls: 6 },
|
||||
},
|
||||
},
|
||||
|
||||
/* ── 声明 ────────────────────────────────────────────────────── */
|
||||
declarations: {
|
||||
models: ['deepseek-v4-flash'],
|
||||
tools: [{ name: 'crm.history-price-read' }],
|
||||
ledger: [
|
||||
/*
|
||||
* ── 这里**去掉了 `shortlist` / `benchmark` / `request` 三张表**(2026-08-02)──
|
||||
*
|
||||
* MVP 口径下初筛清单与对标表都不在系统内产出 —— 选型的实质工作在 FAE
|
||||
* 系统外,系统收的是**结论**(替代料是哪颗、凭什么),落在清单行的三列上。
|
||||
* 撤掉声明不 drop 物理表(物化只增不破坏),历史行还在库里。
|
||||
*/
|
||||
{
|
||||
domain: DOMAIN,
|
||||
object: 'shortlist',
|
||||
label: '初筛清单',
|
||||
domain: D,
|
||||
object: 'list',
|
||||
kind: 'record',
|
||||
caseKey: 'rfq_ref',
|
||||
label: '询价受理清单',
|
||||
fields: [
|
||||
{ name: 'original_pn', type: 'text', primaryKey: true, masked: false, label: '原型号' },
|
||||
{ name: 'candidate_pn', type: 'text', primaryKey: true, masked: false, label: '候选型号' },
|
||||
{ name: 'brand', type: 'text', primaryKey: false, masked: false, label: '品牌' },
|
||||
{ name: 'lifecycle', type: 'text', primaryKey: false, masked: false, label: '生命周期' },
|
||||
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
|
||||
{ name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' },
|
||||
{ name: 'customer_pn', type: 'text', primaryKey: false, masked: false, label: '客户料号' },
|
||||
{ name: 'category', type: 'text', primaryKey: false, masked: false, label: '品类' },
|
||||
{ name: 'brand', type: 'text', primaryKey: false, masked: false, label: '制造商' },
|
||||
{ name: 'eau', type: 'integer', primaryKey: false, masked: false, label: '年用量 EAU' },
|
||||
{ name: 'target_price', type: 'number', primaryKey: false, masked: false, label: '目标价' },
|
||||
{ name: 'lead_time', type: 'text', primaryKey: false, masked: false, label: '交期' },
|
||||
/* 客户备注承继自上游 —— 预授权替代、认证要求、禁用约束都在这一格,是选型的第一手依据 */
|
||||
{ name: 'remark', type: 'text', primaryKey: false, masked: false, label: '客户备注(约束/预授权)' },
|
||||
/*
|
||||
* **原标与现标两列同名不同物**:orig_type 是上游受理时的三分类(留痕,本活动不改);
|
||||
* type_tag 是本活动的产物(C 类判稳后转 A/B)。两列都声明取值域,
|
||||
* 条数由基座按值现数 —— 判据、分布条、出口谓词读的全是基座读数。
|
||||
*/
|
||||
{ name: 'orig_type', type: 'text', primaryKey: false, masked: false, label: '原标(上游三分类)', tally: ['A', 'B', 'C'] },
|
||||
/*
|
||||
* **定案三格是待选型行的必填**(2026-08-02 用户:「工作区的 HTML 必填项
|
||||
* 必须填写了才能提交啊,不填写能算任务完成了吗?」)。
|
||||
* `requiredWhen` 把必填钉在 orig_type=C 的行(一张执行单、件里一颗一页,
|
||||
* 每页的定案三格都得齐);A/B 行原样承继,不拦。
|
||||
* 「替代未果」不走「留空交回」—— 那在闸上就是空手交卷:
|
||||
* 未果 = 在选型备注写明缘由后交回,判稳那一步按剔除处置(剧本 §S3)。
|
||||
*/
|
||||
{ name: 'alt_pn', type: 'text', primaryKey: false, masked: false, label: '替代料 PN', requiredWhen: { column: 'orig_type', value: 'C' } },
|
||||
{ name: 'alt_brand', type: 'text', primaryKey: false, masked: false, label: '替代料品牌', requiredWhen: { column: 'orig_type', value: 'C' } },
|
||||
/*
|
||||
* **替代等级与对标三列**(2026-08-02 用户指出「执行单完全是验收单了」后补齐):
|
||||
* 选型的定案不只是一个型号 —— FAE 的专业产出是**等级 + 差异 + 风险 + 验证建议**
|
||||
* (照 MVP 素材对标表的口径)。等级用三个机器码(取值域声明,基座按值现数):
|
||||
* P2P = 同封装同引脚可直换(核对五项:封装尺寸/引脚定义/电气/温度等级/认证);
|
||||
* FUNC = 功能兼容(需外围电路或 layout 调整);
|
||||
* OTHER = 其他(部分兼容,须打样验证)。
|
||||
* 人读名由模版样式表翻译(徽上色 + 图例),FAE 在卡与件上照码填。
|
||||
*/
|
||||
{ name: 'alt_grade', type: 'text', primaryKey: false, masked: false, label: '替代等级(P2P/FUNC/OTHER)', tally: ['P2P', 'FUNC', 'OTHER'], requiredWhen: { column: 'orig_type', value: 'C' } },
|
||||
{ name: 'alt_diff', type: 'text', primaryKey: false, masked: false, label: '参数差异项', expect: true },
|
||||
{ name: 'alt_risk', type: 'text', primaryKey: false, masked: false, label: '差异风险与改动评估', expect: true },
|
||||
{ name: 'alt_verify', type: 'text', primaryKey: false, masked: false, label: '验证建议', expect: true },
|
||||
/*
|
||||
* 选型备注是**我方写的注**,不标 expect —— 标了 `_gaps` 恒大于 0,
|
||||
* 那条门就废了(A1 的 remark 同款教训)。
|
||||
*/
|
||||
{ name: 'alt_note', type: 'text', primaryKey: false, masked: false, label: '选型备注(来源/预授权)' },
|
||||
{ name: 'type_tag', type: 'text', primaryKey: false, masked: false, label: '现标', tally: ['A', 'B', 'C'] },
|
||||
{ name: 'basis', type: 'text', primaryKey: false, masked: false, label: '转标依据' },
|
||||
{ name: 'history_days', type: 'integer', primaryKey: false, masked: false, label: '替代料距上次报价天数' },
|
||||
/*
|
||||
* **选型排程**(2026-08-02 用户定:「多个芯片按顺序派选型单」)——
|
||||
* 一颗芯片一张选型单,按序派:WAIT 排队 / NOW 当前在办 / DONE 已定案。
|
||||
* 只有待选型的料写这一列(A/B 行留空,读数自然为 0);工作单照它组画面
|
||||
* (队列丸、当前颗的档案、填回表只显当前行),判断全在样式表。
|
||||
* 它是排程不是编辑面 —— 不进 data-col,人改不到。
|
||||
*/
|
||||
],
|
||||
},
|
||||
{
|
||||
domain: DOMAIN,
|
||||
object: 'benchmark',
|
||||
label: '替代对标表',
|
||||
/*
|
||||
* 选型结论 —— **一单一行**:汇总数(报告要素卡与哨兵读)、
|
||||
* 去向旗(流程包 E5 唯一读的东西)、一句人话结论。
|
||||
*/
|
||||
domain: D,
|
||||
object: 'verdict',
|
||||
kind: 'record',
|
||||
caseKey: 'rfq_ref',
|
||||
label: '选型结论',
|
||||
fields: [
|
||||
{ name: 'original_pn', type: 'text', primaryKey: true, masked: false },
|
||||
{ name: 'candidate_pn', type: 'text', primaryKey: true, masked: false },
|
||||
{ name: 'grade', type: 'text', primaryKey: false, masked: false, label: '替代等级' },
|
||||
{ name: 'param_diff', type: 'text', primaryKey: false, masked: false, label: '参数差异项' },
|
||||
{ name: 'risk', type: 'text', primaryKey: false, masked: false, label: '差异风险' },
|
||||
{ name: 'verify_advice', type: 'text', primaryKey: false, masked: false, label: '验证建议' },
|
||||
{ name: 'signed_by', type: 'text', primaryKey: false, masked: false, label: 'FAE 签字' },
|
||||
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
|
||||
{ name: 'customer_code', type: 'text', primaryKey: false, masked: false, label: '客户码' },
|
||||
{ name: 'customer_name', type: 'text', primaryKey: false, masked: false, label: '客户名称' },
|
||||
{ name: 'alt_line_total', type: 'integer', primaryKey: false, masked: false, label: '清单条目数' },
|
||||
{ name: 'c_total', type: 'integer', primaryKey: false, masked: false, label: '待选型条数' },
|
||||
/* 「FAE 的答复处理过了」的凭据 —— 只在人环走完才产生(哨兵按它放行下一步) */
|
||||
{ name: 'filled_total', type: 'integer', primaryKey: false, masked: false, label: '已填回替代料条数' },
|
||||
{ name: 'converted_a_total', type: 'integer', primaryKey: false, masked: false, label: '判稳转 A 条数' },
|
||||
{ name: 'converted_b_total', type: 'integer', primaryKey: false, masked: false, label: '转 B(需寻价)条数' },
|
||||
{ name: 'skipped_note', type: 'text', primaryKey: false, masked: false, label: '剔除了哪几颗·为什么' },
|
||||
/* 流程包 E5(仍需寻源 → A3)读的就是它 —— 没有这一列,E5 永远判假(实证过) */
|
||||
{ name: 'need_sourcing', type: 'boolean', primaryKey: false, masked: false, label: '仍需寻源' },
|
||||
/* 去向定过了没有 —— 旗是布尔,分不清「没写」与「写了 false」,另记一个计数 */
|
||||
{ name: 'route_decided_total', type: 'integer', primaryKey: false, masked: false, label: '已定去向' },
|
||||
{ name: 'summary', type: 'text', primaryKey: false, masked: false, label: '选型结论' },
|
||||
],
|
||||
},
|
||||
],
|
||||
listeners: [
|
||||
{
|
||||
// ⏱ 时限过半:向 BD 与 PM 同步进度与风险
|
||||
/** 时限过半:给 FAE 与负责人提个醒 —— 静态监听,时限必配 */
|
||||
origin: 'static',
|
||||
eventType: 'time',
|
||||
keyFilter: { kind: 'half-deadline' },
|
||||
@@ -139,7 +347,7 @@ export const manifest: ActivityManifest = {
|
||||
escalation: ['nudge'],
|
||||
},
|
||||
{
|
||||
// ✋ 客户改需求:应用条件/参数变更 → 回初选核险与圈选复核
|
||||
/** 客户改需求(应用条件/参数变更):记一笔观察,回炉与否由 Agent 裁量 */
|
||||
origin: 'static',
|
||||
eventType: 'human',
|
||||
keyFilter: { kind: 'requirement-change' },
|
||||
@@ -149,33 +357,173 @@ export const manifest: ActivityManifest = {
|
||||
},
|
||||
],
|
||||
harness: {
|
||||
memoryWindow: 30,
|
||||
memoryWindow: 24,
|
||||
// FAE 在系统外干活,等他不算停滞 —— 停滞检测放宽到一天
|
||||
stallAfter: 'P1D',
|
||||
nudge: [{ after: 'PT12H', action: 'nudge' }],
|
||||
retry: { times: 2, backoff: 'PT5M' },
|
||||
},
|
||||
},
|
||||
|
||||
/* ── 资产初始版 ──────────────────────────────────────────────── */
|
||||
assets: {
|
||||
businessModel: {
|
||||
cmmnXml: CMMN_XML,
|
||||
narrative: NARRATIVE,
|
||||
dmn: GRADE_DMN,
|
||||
dmn: ALT_DMN,
|
||||
steps: ALTERNATIVE_STEPS,
|
||||
phases: ALTERNATIVE_PHASES,
|
||||
},
|
||||
skills: ALTERNATIVE_SKILLS,
|
||||
viewTemplates: [{ name: 'case-overview', html: ALTERNATIVE_CASE }],
|
||||
/*
|
||||
* 两个局面各一张(2026-08-02 用户指出「执行单完全是验收单了」):
|
||||
* · `alt-worksheet` 替代选型工作单 —— 归一与 FAE 选型期间摆它:逐颗料的选型档案、
|
||||
* 替代等级判据、pin-to-pin 核对要点、三层筛指引 + 填回工作表。**这是干活的件**;
|
||||
* 同一局面在办与归档同一张;局面变了换张(A1 三张同款口径)。
|
||||
*/
|
||||
viewTemplates: [
|
||||
/*
|
||||
* **每份件两态 1:1**(2026-08-02/03 用户两次校准):在办可编辑 ↔ 办结快照
|
||||
* 静态归档(finalHtml),这是「1:1」的落点;而**选型作业与收口验收各有各的件**。
|
||||
*/
|
||||
{ name: 'alt-worksheet', html: ALT_WORKSHEET },
|
||||
/*
|
||||
* 收口件(2026-08-03 用户:「验收工单怎么又变成选型了?」)—— 选型作业与
|
||||
* 收口验收是两件工作,各有各的件:One Pager 归选型执行单,**整合清单归验收**
|
||||
* (「清单是所有选型完成后收口才整合的东西」)。「1:1」落在每份件与它的
|
||||
* 办结快照之间(finalHtml),不是全程只许一张。
|
||||
*/
|
||||
{ name: 'alt-roster', html: ALT_ROSTER },
|
||||
],
|
||||
cardSchemas: {},
|
||||
distillHooks: [
|
||||
{ on: 'milestone', milestone: 'M_candidates', prompt: '归纳本轮撒网的有效角度与无效角度。' },
|
||||
{ on: 'terminal', prompt: '归纳替代经验:哪些参数差异实际造成了问题、哪些品牌的替代关系可复用。' },
|
||||
{
|
||||
on: 'terminal',
|
||||
prompt: '归纳本单替代选型的经验:哪类料找得到替代、FAE 的选型依据形态、判稳转标的分布。',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
/* ── 样例集 —— 三条形状真不同(常备案例即验收案例)───────────── */
|
||||
samples: [
|
||||
{
|
||||
name: 'EOL 料替代',
|
||||
input: { originalPn: 'TL072CDR', chipType: '运放', scenario: '车规' },
|
||||
expect: { ports: ['benchmark'] },
|
||||
/*
|
||||
* ① 流内常路:上游放行载荷的**真实形状** —— records 里同一颗料出现两次
|
||||
* (受理记录行 + 分类表行)、还混着结论行;顶层带摊平字段。
|
||||
* 替代料(客户预授权的 PBY)库内无历史 → 判稳转 B → to-sourcing(E5 复活的那条路)。
|
||||
*/
|
||||
name: '流内常路 · Itelma 两颗 C 类(一颗一张选型单按序派 · 替代料无历史 → 转 B 去寻源)',
|
||||
input: {
|
||||
rfqRef: 'RFQ MD14.1',
|
||||
customer_code: 'CU-0142',
|
||||
customer_name: 'Itelma',
|
||||
type_c_total: 2,
|
||||
records: [
|
||||
{
|
||||
rfq_ref: 'RFQ MD14.1',
|
||||
pn: 'BBPY00201209601Y00',
|
||||
customer_pn: '4-14-01502',
|
||||
category: '滤波器',
|
||||
brand: 'YAGEO',
|
||||
eau: 30000,
|
||||
remark: '客户预授权替代:PBY201209T-601Y-N(BOM Analog 列)',
|
||||
type_tag: 'C',
|
||||
},
|
||||
{
|
||||
rfq_ref: 'RFQ MD14.1',
|
||||
pn: 'BBPY00201209601Y00',
|
||||
history_days: 47,
|
||||
market_stable: true,
|
||||
lifecycle: '要求替代',
|
||||
type_tag: 'C',
|
||||
basis: 'EOL/缺货/要求替代 → C',
|
||||
},
|
||||
{
|
||||
rfq_ref: 'RFQ MD14.1',
|
||||
pn: 'DAG2400L321504XZB01-R0',
|
||||
customer_pn: '9-00-00251',
|
||||
category: '天线',
|
||||
brand: '浙江嘉康',
|
||||
eau: 30000,
|
||||
remark: '',
|
||||
type_tag: 'C',
|
||||
},
|
||||
{
|
||||
rfq_ref: 'RFQ MD14.1',
|
||||
pn: 'DAG2400L321504XZB01-R0',
|
||||
history_days: 9999,
|
||||
market_stable: false,
|
||||
lifecycle: 'EOL',
|
||||
type_tag: 'C',
|
||||
basis: 'EOL/缺货/要求替代 → C',
|
||||
},
|
||||
{
|
||||
rfq_ref: 'RFQ MD14.1',
|
||||
pn: 'CJTB0101R6N',
|
||||
customer_pn: '4-00-04102',
|
||||
category: '集成电路',
|
||||
brand: 'Jiangsu Changjing',
|
||||
eau: 60000,
|
||||
type_tag: 'A',
|
||||
},
|
||||
{
|
||||
rfq_ref: 'RFQ MD14.1',
|
||||
line_total: 3,
|
||||
type_a_total: 1,
|
||||
type_b_total: 0,
|
||||
type_c_total: 2,
|
||||
need_alternative: true,
|
||||
need_sourcing: false,
|
||||
customer_name: 'Itelma',
|
||||
},
|
||||
],
|
||||
},
|
||||
expect: { ports: ['roster'] },
|
||||
},
|
||||
{
|
||||
/*
|
||||
* ② 客户点名替代、替代料**库内行情稳** → 判稳转 A → 全 A 直通定价(to-pricing)。
|
||||
* FAE 若填回 CJTB0101R6N(库内 43 天·行情稳),就走这条路。
|
||||
*/
|
||||
name: '点名替代 · 替代料库内行情稳(转 A 直通定价)',
|
||||
input: {
|
||||
rfqRef: 'RFQ-2026-0777',
|
||||
customer_name: '青岛海洋电子',
|
||||
records: [
|
||||
{
|
||||
rfq_ref: 'RFQ-2026-0777',
|
||||
pn: 'TL072CDR',
|
||||
category: '运放',
|
||||
brand: 'TI',
|
||||
eau: 3000,
|
||||
target_price: 22,
|
||||
remark: '客户点名替代:国产运放优先',
|
||||
type_tag: 'C',
|
||||
},
|
||||
{
|
||||
rfq_ref: 'RFQ-2026-0777',
|
||||
pn: 'TL072CDR',
|
||||
history_days: 9999,
|
||||
market_stable: false,
|
||||
lifecycle: '要求替代',
|
||||
type_tag: 'C',
|
||||
basis: '客户要求替代 → C',
|
||||
},
|
||||
],
|
||||
},
|
||||
expect: { ports: ['roster'] },
|
||||
},
|
||||
{
|
||||
/*
|
||||
* ③ 单飞宽进:没有单号、没有上游清单,只有一段需求(类型 + 参数 + 场景)——
|
||||
* 归一造案号、需求代号,全标 C,照常走完(输入契约在活动内归一+补全)。
|
||||
*/
|
||||
name: '单飞需求 · 只有类型与参数(无单号无清单,宽进归一)',
|
||||
input: {
|
||||
subject: '车规运放选型需求',
|
||||
body: '需要一颗车规级双通道运放:供电 3-36V,AEC-Q100,替代 TL072 系列,年用量约 5K。请选型给替代建议;定案后要给采购去问价。',
|
||||
},
|
||||
expect: { ports: ['roster'] },
|
||||
},
|
||||
],
|
||||
|
||||
|
||||
@@ -1,45 +1,84 @@
|
||||
/**
|
||||
* A2 技能 —— 替代选型的方法经验(AM 08)。
|
||||
* A2 技能 —— 替代选型 MVP 简化形的方法经验(AM 08)。
|
||||
*
|
||||
* **载方法,不载权力**:这里提到的工具都在本活动白名单内;技能不扩权、不改约。
|
||||
* 渐进披露:常驻上下文只有 `id label:applicability`,正文 Agent 说要才加载。
|
||||
*
|
||||
* 剧本改了口径,技能里同一句也要跟着改(A1 的账)—— 2026-08-02 改盘后:
|
||||
* 选型的实质工作在 FAE 系统外,系统管归一 / 组卡 / 判稳;
|
||||
* 旧的「圈替代候选(三层筛)」讲的是 FAE 脑子里的方法,系统不替他办,删。
|
||||
*/
|
||||
import { skillIdSchema } from '@contracts';
|
||||
import type { Skill } from '@contracts';
|
||||
|
||||
export const ALTERNATIVE_SKILLS: Skill[] = [
|
||||
{
|
||||
id: skillIdSchema.parse('alternative.screen-candidates'),
|
||||
label: '圈替代候选',
|
||||
applicability: '原型号停产或缺货,要找替代时。',
|
||||
/*
|
||||
* 这条讲的是**组卡与核验时的选型口径**(FAE 的实质工作在系统外,但卡面指导书、
|
||||
* basis 的取舍、收回后的核验都要按同一把尺)—— 2026-08-02 用户指出执行件
|
||||
* 不是选型工作台后,连同工作单一起补回。
|
||||
*/
|
||||
id: skillIdSchema.parse('alternative.grade-discipline'),
|
||||
label: '替代等级与三层筛',
|
||||
applicability: '组选型执行单、核验 FAE 填回的定案、或放行前裁量来源可溯时。',
|
||||
method: [
|
||||
'三层筛,一层比一层贵,所以要按顺序:',
|
||||
'① **参数硬指标**:封装、电压范围、精度、温度等级 —— 不满足直接淘汰,不必往下看。',
|
||||
'② **认证与合规**:车规 / 医疗 / 工业等级不可降级替代,客户没明说也不行。',
|
||||
'③ **供应与价格**:到这一步才比价,前两层没过的便宜也没用。',
|
||||
'三层筛,一层比一层贵,按顺序(组卡的 steps 与核验都照它):',
|
||||
'① 参数硬指标:封装、引脚、电气、温度等级 —— 不满足直接淘汰,不必往下看;',
|
||||
'② 认证与合规:车规 AEC-Q100/Q200、医疗、工业等级不可降级替代,客户没明说也不行;',
|
||||
'③ 供应与价格:生命周期在产、货态、品牌接受度 —— 到这一步才比价。',
|
||||
'',
|
||||
'「Pin-to-pin 兼容」要看数据手册核实,不要信分销商的一句话 —— ',
|
||||
'同封装不等于同引脚定义,这个错误要到打样才暴露。',
|
||||
'等级三码(照 MVP 素材对标表的口径,写别的码基座数不出来):',
|
||||
'· P2P = 同封装同引脚可直换 —— 必须核对过五项:封装尺寸/引脚定义/电气/温度等级/认证;',
|
||||
' 同封装不等于同引脚定义,要对数据手册,不要信分销商一句话(这个错要到打样才暴露);',
|
||||
'· FUNC = 功能兼容,需外围电路或 layout 调整 —— 差异项与改动评估必须写清;',
|
||||
'· OTHER = 部分兼容须打样验证 —— 关键差异逐项列出、给验证方案。',
|
||||
'',
|
||||
'客户备注里已预授权的替代优先采信;点名禁用的品牌不得作候选;',
|
||||
'是否接受非原厂品牌、参数降级必须问客户,不得替客户决定。',
|
||||
].join('\n'),
|
||||
crystallized: [],
|
||||
knowledgeRefs: ['替代选型判据表'],
|
||||
knowledgeRefs: ['docs/MVP示例工作流.html §1.5(替代等级/初选核险/圈选替代)'],
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
id: skillIdSchema.parse('alternative.ask-customer'),
|
||||
label: '什么时候必须问客户',
|
||||
applicability: '候选已圈定,但存在客户口径不明的项时。',
|
||||
id: skillIdSchema.parse('alternative.normalize-roster'),
|
||||
label: '归一询价受理清单',
|
||||
applicability: '开案第一步:把上游受理记录(或单飞需求)整份落成本活动的清单时。',
|
||||
method: [
|
||||
'这几件事**必须问**,不能替客户决定:',
|
||||
'· 是否接受非原厂品牌;',
|
||||
'· 是否接受参数降级(哪怕只降一档温度等级);',
|
||||
'· 是否需要重新打样验证、由谁承担费用。',
|
||||
'上游载荷里 `records` 是**全部落地行混在一起**的一份 —— 同一颗料出现两三次:',
|
||||
'带 eau/brand/target_price 的是受理记录行(以它为准),带 basis/history_days 的是',
|
||||
'分类表行,带 line_total 这类总数的是结论行(不是料,别落进清单)。',
|
||||
'**按 pn 归并**,一颗料一行,一拍写完(rows 收数组)。',
|
||||
'',
|
||||
'问的方式是**跨任务征答**:卡以系统消息送到 BD 的消息要素,就地作答。',
|
||||
'它不占对方的任务清单——那是一次性的解答请求,不是把责任转过去。',
|
||||
'',
|
||||
'问之前先把候选与判据整理好一并发过去:让对方**在数据上作判断**,',
|
||||
'而不是回一句「你看着办」。',
|
||||
'三条纪律:',
|
||||
'① **PN 一个字符不许改**;上游没给的列留空字符串,绝不写 0 顶空。',
|
||||
'② orig_type 与 type_tag 两列**同一个值起步**(照上游的标):原标是留痕,现标才是本活动的产物。',
|
||||
'③ 单号 rfq_ref 原样承继;单飞认不出才造「ALT-<型号或类型>」,造完从头用到尾。',
|
||||
].join('\n'),
|
||||
crystallized: [],
|
||||
knowledgeRefs: [],
|
||||
version: '1.0.0',
|
||||
knowledgeRefs: ['docs/MVP示例工作流.html §1.5'],
|
||||
version: '2.0.0',
|
||||
},
|
||||
{
|
||||
id: skillIdSchema.parse('alternative.reclass-stable'),
|
||||
label: '替代料判稳转标',
|
||||
applicability: 'FAE 填回替代料之后,要把 C 类行转成 A/B 时。',
|
||||
method: [
|
||||
'判稳的尺与三分类 P-03 的 A/B 两行**同一把**:',
|
||||
'历史价 ≤ 90 天且行情稳 → 转 A(行情完备,可直接取历史锚点);否则 → 转 B(要去寻源补价)。',
|
||||
'',
|
||||
'判据从 `crm.history-price-read` 来:**参数 pns 收数组,所有替代料一次传完**。',
|
||||
'没历史回 historyDays=9999、hit=false —— **那本身就是判据**(新料无历史 → 不稳 → B),',
|
||||
'不是缺数据,不必替它补。工具回的 lifecycle 若是 EOL/缺货/要求替代,',
|
||||
'说明替代料选错了 —— 不转标,当替代未果处置。',
|
||||
'',
|
||||
'转标写回清单行时:type_tag 只写 A 或 B(照规则,不许臆断);',
|
||||
'依据一句话落 basis 列(「替代料 X 历史价 N 天·行情稳 → 转 A」),逐条可回核;',
|
||||
'orig_type 不动。**不许为过判据改标** —— 判据读的是基座按值现数的条数,你改不动。',
|
||||
].join('\n'),
|
||||
// 结晶片段:查替代料行情这一步每单都一样 —— 确定性子序列,不必每次让模型重想
|
||||
crystallized: [{ tool: 'crm.history-price-read', input: { pns: 'pns' } }],
|
||||
knowledgeRefs: ['三分类规则 P-03(A/B 两行)'],
|
||||
version: '2.0.0',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* A1 两份执行页的样式表 —— **从 PRD 目标件原样搬过来**,不是我照着复述的。
|
||||
*
|
||||
* 出处:`~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理需求.html` 的整段 `<style>`,
|
||||
* 外加 `A1-询价受理记录.html` 里归档件独有的那几件(分类分布条 / 类徽 / 判定表 /
|
||||
* 结论横幅 / 落地动作 / 完成标)。**一个数值都没改**。
|
||||
*
|
||||
* 为什么是搬而不是抄:前三轮我用自己那套外壳「照着做」,块搬齐了、版面是两套 ——
|
||||
* 章节头成了彩色方块编号、多出摘要与元信息带、字号用 clamp。人一眼就看出不是那份件。
|
||||
* 目标件是**像素级的答案**,搬过来才不会在复述里走样。
|
||||
*
|
||||
* 选择器保留 `.proto-root .app-shell` 前缀,所以模版正文也要照原型那样包两层壳 ——
|
||||
* 少一层壳,整张样式表一条都不生效(踩过)。
|
||||
*/
|
||||
export const PRD_CSS = `
|
||||
/* ============================================================================
|
||||
JCAI 员工面工作台 · A1 受理归一的过程成果「询价受理需求」——报告本体
|
||||
立场:**AI 已从 RFQ 邮件抽取归一**,人在这里审核并就地修订。
|
||||
两态:读态(AI 稿 + 红/灰占位提示缺项) ⇄ 编辑态(全字段可改,保存 / 取消)
|
||||
核心是「有效 PN 清单」;清单之上四件受理要素:客户公司 / 联系人 / 客户属性 / 有效 PN。
|
||||
|
||||
令牌与既有组件取自 workbench/src/app/(proto)/app.css,数值未改。
|
||||
⚠️ 修正一处原型 bug:app.css:2062-2064 的 '.jc-rp-sign span'(权重 0,1,1)
|
||||
盖过了 '.jc-rp-ava'(0,1,0),把头像的 display:grid 覆盖成 block、字色改灰,
|
||||
导致落款头像里的字掉到圆外。本文件把该规则收窄为 '.jc-rp-sign > div > span'。
|
||||
新增件:四要素卡 .jc-four/.jc-fcard · 占位胶囊 .jc-ph · 分段控件 .jc-seg ·
|
||||
来源条 .jc-src · 录入表 .jc-tbl-in
|
||||
========================================================================== */
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body { background: #e6eaf1; -webkit-font-smoothing: antialiased; }
|
||||
|
||||
.proto-root .app-shell {
|
||||
--brand: #3370ff; --brand-deep: #245bdb;
|
||||
--brand-soft: #e1eaff; --brand-wash: #f0f4ff;
|
||||
--ai-grad: linear-gradient(135deg, #3370ff, #7f3bf5);
|
||||
--ink: #1f2329; --ink-2: #41464e; --ink-3: #646a73; --ink-4: #8f959e;
|
||||
--line: #dee0e3; --line-soft: #e8eaed; --soft: #f8f9fb;
|
||||
--jcai-bg: #e6eaf1; --cardline: #e1e6ec; --card-shadow: 0 1px 2px rgba(31, 35, 41, .05);
|
||||
--ok: #278717; --ok-soft: #e8f8e4;
|
||||
--warn: #de7802; --warn-accent: #ff8800; --warn-soft: #fff4e5;
|
||||
--danger: #d83931; --danger-soft: #feeae9;
|
||||
--mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace;
|
||||
--shadow-brand: 0 6px 16px rgba(51, 112, 255, .22);
|
||||
--t-fast: .16s cubic-bezier(.3, 0, .2, 1);
|
||||
--t-med: .26s cubic-bezier(.3, 0, .2, 1);
|
||||
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: var(--jcai-bg); min-height: 100vh; color: var(--ink);
|
||||
}
|
||||
.proto-root :where(button, input, select, a, [tabindex]):focus-visible {
|
||||
outline: 2px solid var(--brand); outline-offset: 2px; border-radius: 8px;
|
||||
}
|
||||
.proto-root ::selection { background: var(--brand-soft); }
|
||||
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes jcBlink { 0%,100% { opacity: 1 } 50% { opacity: .35 } }
|
||||
@keyframes jcStamp { from { opacity: 0; transform: rotate(-6deg) scale(1.7); } to { opacity: .9; transform: rotate(-6deg) scale(1); } }
|
||||
|
||||
/* 放开宽度:1160px 是「9 列全展开、一个字都不裁」的最小容器宽度(见下方列 min-width 之和) */
|
||||
.jc-stage { max-width: 1160px; margin: 0 auto; padding: 22px 16px 52px; }
|
||||
.proto-root .jc-mono { font-family: var(--mono); font-size: 11px; color: var(--ink-3); }
|
||||
.proto-root .jc-pulse-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--brand); display: inline-block; animation: jcBlink 1.1s ease-in-out infinite; }
|
||||
|
||||
/* ── 报告外壳 ───────────────────────────────────────────────────────── */
|
||||
.proto-root .jc-report { background: #fff; border: 1px solid var(--cardline); border-radius: 14px; box-shadow: var(--card-shadow); overflow: hidden; animation: rise var(--t-med) both; }
|
||||
.proto-root .jc-rp-head { position: relative; overflow: hidden; display: flex; align-items: flex-start; gap: 12px; padding: 16px 20px 13px; border-bottom: 2px solid var(--ink); }
|
||||
/* 题头下缘粗细双线 = 正式件版式 */
|
||||
.proto-root .jc-rp-head::after { content: ""; position: absolute; left: 0; right: 0; bottom: -4px; height: 1px; background: var(--line); }
|
||||
/* 题头右上的品牌光晕:与工作台标题区同一签名件 */
|
||||
.proto-root .jc-rp-head::before { content: ""; position: absolute; right: -70px; top: -96px; width: 240px; height: 240px; border-radius: 999px; background: radial-gradient(circle, rgba(51,112,255,.10), transparent 70%); pointer-events: none; }
|
||||
.proto-root .jc-rp-head > * { position: relative; z-index: 1; }
|
||||
.proto-root .jc-rp-badge { flex: none; width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand); }
|
||||
.proto-root .jc-rp-tit { flex: 1; min-width: 0; }
|
||||
.proto-root .jc-rp-tit em { display: block; font-style: normal; font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); font-family: var(--mono); }
|
||||
.proto-root .jc-rp-tit h3 { margin: 4px 0 0; font-size: 18px; font-weight: 800; color: var(--ink); letter-spacing: -.01em; line-height: 1.3; }
|
||||
.proto-root .jc-rp-wait { display: inline-flex; align-items: center; gap: 6px; flex: none; margin-top: 4px; font-size: 11px; font-weight: 700; border-radius: 999px; padding: 4px 12px; color: var(--brand-deep); background: var(--brand-wash); border: 1px solid var(--brand-soft); }
|
||||
.proto-root .jc-rp-wait.warn { color: var(--warn); background: var(--warn-soft); border-color: #ffd9a8; }
|
||||
.proto-root .jc-rp-wait.edit { color: #fff; background: var(--brand); border-color: var(--brand); }
|
||||
.proto-root .jc-rp-stamp { flex: none; margin-top: 2px; transform: rotate(-6deg); border: 2px solid currentColor; border-radius: 9px; padding: 4px 8px 3px 12px; font-size: 13px; font-weight: 900; letter-spacing: .28em; color: var(--ok); opacity: .9; animation: jcStamp .32s cubic-bezier(.2, 1.4, .4, 1) both; }
|
||||
.proto-root .jc-rp-bd { padding: 18px 20px 12px; }
|
||||
.proto-root .jc-rp-part { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 9px; margin: 20px 0 11px; }
|
||||
.proto-root .jc-rp-part:first-child { margin-top: 0; }
|
||||
.proto-root .jc-rp-part > b { flex: none; font-size: 11px; font-weight: 800; letter-spacing: .12em; color: var(--ink-3); }
|
||||
.proto-root .jc-rp-part > .ln { flex: 1; height: 1px; background: var(--line-soft); }
|
||||
.proto-root .jc-rp-part > em { flex: none; font-style: normal; font-size: 10px; font-weight: 700; color: var(--ink-4); font-family: var(--mono); background: var(--soft); border-radius: 999px; padding: 2px 9px; white-space: nowrap; }
|
||||
.proto-root .jc-rp-part > em.warn { color: var(--warn); background: var(--warn-soft); }
|
||||
|
||||
/* 落款 —— ⚠️ 选择器收窄到 > div > *,别再命中头像那个 span */
|
||||
.proto-root .jc-rp-foot { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 13px 20px 15px; border-top: 2px solid var(--line); background: #fbfcfd; }
|
||||
.proto-root .jc-rp-sign { display: flex; align-items: center; gap: 10px; flex: 1 1 200px; min-width: 0; }
|
||||
.proto-root .jc-rp-ava { flex: 0 0 auto; width: 30px; height: 30px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; line-height: 1; color: #fff; background: var(--brand); }
|
||||
.proto-root .jc-rp-sign > div { min-width: 0; }
|
||||
.proto-root .jc-rp-sign > div > b { display: block; font-size: 12px; font-weight: 700; color: var(--ink-2); }
|
||||
.proto-root .jc-rp-sign > div > span { display: block; margin-top: 1px; font-size: 10.5px; color: var(--ink-4); }
|
||||
.proto-root .jc-rp-acts { display: flex; align-items: center; gap: 8px; margin-left: auto; flex: none; }
|
||||
.proto-root .jc-hintline { font-size: 11px; font-weight: 700; color: var(--warn); line-height: 1.5; }
|
||||
|
||||
.proto-root .jc-btn { display: inline-flex; align-items: center; gap: 5px; border: 0; border-radius: 7px; padding: 8px 14px; font-size: 12px; font-weight: 600; font-family: inherit; background: var(--brand); color: #fff; cursor: pointer; transition: all var(--t-fast); }
|
||||
.proto-root .jc-btn:hover:not(:disabled) { background: var(--brand-deep); }
|
||||
.proto-root .jc-btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.proto-root .jc-btn-ghost { background: #fff; color: var(--ink-2); border: 1px solid var(--line); }
|
||||
.proto-root .jc-btn-ghost:hover:not(:disabled) { background: var(--brand-wash); color: var(--brand); border-color: var(--brand-soft); }
|
||||
.proto-root .jc-btn-primary { background: var(--ai-grad); }
|
||||
.proto-root .jc-btn-primary:hover:not(:disabled) { filter: brightness(1.07); box-shadow: var(--shadow-brand); transform: translateY(-1px); }
|
||||
|
||||
/* ── 来源信件:客户 RFQ 邮件原文(复用原型 jc-letter 件,青色 = 收件) ── */
|
||||
.proto-root .jc-letter { position: relative; border: 1px solid var(--cardline); border-left: 3px solid #0aa294; border-radius: 12px; padding: 14px 16px 15px; background: #fff; box-shadow: var(--card-shadow); }
|
||||
.proto-root .jc-letter-h { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 11px; }
|
||||
.proto-root .jc-letter-dir { display: inline-flex; align-items: center; gap: 5px; font-size: 10.5px; font-weight: 800; padding: 3px 9px; border-radius: 999px; background: #e4f8f5; color: #0a7f74; }
|
||||
.proto-root .jc-letter-h .jc-chip { font-size: 10.5px; padding: 3px 9px; border-radius: 999px; background: var(--brand-wash); color: var(--brand-deep); border: 1px solid var(--brand-soft); }
|
||||
.proto-root .jc-letter-h > code { margin-left: auto; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
|
||||
.proto-root .jc-letter-meta { display: flex; flex-direction: column; gap: 4px; margin-bottom: 11px; padding-bottom: 11px; border-bottom: 1px solid var(--line-soft); font-size: 11.5px; color: var(--ink-2); }
|
||||
.proto-root .jc-letter-meta p { margin: 0; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
|
||||
.proto-root .jc-letter-meta i { flex: none; font-style: normal; width: 44px; font-size: 10px; font-weight: 800; letter-spacing: .06em; color: var(--ink-4); }
|
||||
.proto-root .jc-letter-meta code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); overflow-wrap: anywhere; }
|
||||
.proto-root .jc-letter-sub { margin: 0 0 9px; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.45; overflow-wrap: anywhere; }
|
||||
/* 正文:原文照录,pre-wrap 保原始换行;不截断、不折叠 */
|
||||
.proto-root .jc-letter-body { margin: 0; font-size: 12.5px; line-height: 1.72; color: var(--ink-2); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-letter-body b { color: var(--ink); font-weight: 700; }
|
||||
.proto-root .jc-atts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; padding-top: 11px; border-top: 1px dashed var(--line-soft); }
|
||||
.proto-root .jc-att { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px 5px 8px; border: 1px solid var(--brand-soft); border-radius: 8px; background: var(--brand-wash); font-size: 11.5px; color: var(--brand-deep); }
|
||||
.proto-root .jc-att svg { flex: none; color: var(--brand); }
|
||||
.proto-root .jc-att code { font-family: var(--mono); font-size: 10.5px; }
|
||||
.proto-root .jc-att span { color: var(--ink-4); }
|
||||
/* 抽取指向:从原文到下方要素的一句衔接 */
|
||||
.proto-root .jc-derive { display: flex; align-items: flex-start; gap: 8px; margin-top: 11px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); font-size: 11.5px; line-height: 1.7; color: var(--brand-deep); }
|
||||
.proto-root .jc-derive svg { flex: none; margin-top: 2px; }
|
||||
|
||||
/* ── 四要素卡 ───────────────────────────────────────────────────────────
|
||||
色调字典:每张卡带一套 --tone / --tone-soft / --tone-line,组件只读变量。
|
||||
卡 3、卡 4 的色调随数据变(老客户绿 / 新客户橙;齐了蓝 / 有缺橙),
|
||||
所以这条要素带本身就是一行可读的状态,不只是四个格子。 */
|
||||
.proto-root .jc-four { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 11px; }
|
||||
.proto-root .jc-fcard {
|
||||
position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-width: 0;
|
||||
padding: 14px 14px 14px; border: 1px solid var(--cardline); border-radius: 13px; background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 8px 20px -14px rgba(31,35,41,.35);
|
||||
transition: transform var(--t-fast), box-shadow var(--t-fast), border-color var(--t-fast);
|
||||
--tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft);
|
||||
}
|
||||
/* 顶缘色棱 + 右上角光晕:卡的身份色,一眼分得清四件是四件 */
|
||||
.proto-root .jc-fcard::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 3px; background: var(--tone); }
|
||||
.proto-root .jc-fcard::after { content: ""; position: absolute; right: -46px; top: -52px; width: 132px; height: 132px; border-radius: 999px; background: radial-gradient(circle, var(--tone-soft), transparent 70%); pointer-events: none; }
|
||||
.proto-root .jc-fcard > * { position: relative; z-index: 1; }
|
||||
.proto-root .jc-fcard:hover { transform: translateY(-2px); border-color: var(--tone-line); box-shadow: 0 2px 4px rgba(31,35,41,.05), 0 14px 26px -16px rgba(31,35,41,.45); }
|
||||
.proto-root .jc-fcard.t-teal { --tone: #0aa294; --tone-soft: rgba(10,162,148,.15); --tone-line: #9fe0d9; }
|
||||
.proto-root .jc-fcard.t-ok { --tone: var(--ok); --tone-soft: rgba(39,135,23,.14); --tone-line: #bfedb4; }
|
||||
.proto-root .jc-fcard.t-warn { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.12); --tone-line: #ffd9a8; }
|
||||
|
||||
.proto-root .jc-fcard-k { display: flex; align-items: center; gap: 6px; font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
|
||||
.proto-root .jc-fcard-k > i { flex: none; width: 19px; height: 19px; border-radius: 6px; display: inline-grid; place-items: center; background: var(--tone-soft); color: var(--tone); }
|
||||
.proto-root .jc-fcard-b { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.proto-root .jc-fcard-t { min-width: 0; flex: 1; }
|
||||
/* 一律换行,不省略号——宁可多占一行,也不丢字 */
|
||||
.proto-root .jc-fcard-t > b { display: block; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.35; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-fcard-t > span { display: block; margin-top: 2px; font-size: 10.5px; color: var(--ink-4); line-height: 1.55; overflow-wrap: anywhere; }
|
||||
/* 公司标:圆角方 + AI 渐变 + 外圈光环(方=组织) */
|
||||
.proto-root .jc-logo { flex: none; width: 38px; height: 38px; border-radius: 12px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; letter-spacing: .04em; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand), 0 0 0 3px rgba(51,112,255,.10); }
|
||||
/* 人像:圆 + 青色系(圆=人,与方形的组织标一眼分开) */
|
||||
.proto-root .jc-face { flex: none; width: 38px; height: 38px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; color: #0a7f74; background: #e4f8f5; border: 1px solid #9fe0d9; box-shadow: 0 0 0 3px rgba(10,162,148,.10); }
|
||||
/* 邮箱做成可点的胶囊,复制按钮嵌在里面——比一行裸文字有抓手 */
|
||||
/* 邮箱:软底胶囊、无描边——有描边会读成输入框,而读态这里不是可填的 */
|
||||
.proto-root .jc-mailrow { display: flex; align-items: center; gap: 4px; margin-top: 5px; min-width: 0; max-width: 100%; padding: 3px 4px 3px 9px; border: 0; border-radius: 8px; background: var(--tone-soft); transition: background var(--t-fast); }
|
||||
.proto-root .jc-mailrow:hover { background: rgba(10,162,148,.2); }
|
||||
/* 编辑态里它包着输入框,软底会变成「框中框」,去掉 */
|
||||
.proto-root .jc-report.editing .jc-mailrow { background: transparent; padding: 0; margin-top: 6px; }
|
||||
.proto-root .jc-mailrow > code { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; line-height: 1.5; font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
|
||||
.proto-root .jc-copy { flex: none; width: 22px; height: 22px; display: inline-grid; place-items: center; border: 0; border-radius: 6px; background: #fff; color: var(--ink-4); cursor: pointer; box-shadow: 0 1px 2px rgba(31,35,41,.08); transition: all var(--t-fast); }
|
||||
.proto-root .jc-copy:hover { color: #0aa294; }
|
||||
.proto-root .jc-copy.ok { color: var(--ok); background: var(--ok-soft); box-shadow: none; }
|
||||
/* 客户属性徽章 */
|
||||
.proto-root .jc-tagbig { display: inline-flex; align-items: center; gap: 6px; align-self: flex-start; padding: 7px 14px; border-radius: 999px; font-size: 13.5px; font-weight: 800; line-height: 1; color: var(--tone); background: var(--tone-soft); border: 1px solid var(--tone-line); }
|
||||
.proto-root .jc-fcard-note { font-size: 10.5px; color: var(--ink-4); line-height: 1.55; }
|
||||
.proto-root .jc-fcard-note b { color: var(--ink-2); font-weight: 700; }
|
||||
.proto-root .jc-fcard-note b.bad { color: var(--tone); font-weight: 800; }
|
||||
/* 成交迷你柱:把「台账 6 笔成交」从一句话变成看得见的节奏 */
|
||||
.proto-root .jc-spark { display: flex; align-items: flex-end; gap: 4px; height: 26px; }
|
||||
.proto-root .jc-spark > i { flex: 1 1 0; min-width: 4px; max-width: 15px; border-radius: 3px 3px 1px 1px; background: var(--tone); opacity: .3; transition: opacity var(--t-fast), height var(--t-med); }
|
||||
.proto-root .jc-spark > i:last-child { opacity: 1; }
|
||||
.proto-root .jc-spark:hover > i { opacity: .55; }
|
||||
.proto-root .jc-spark:hover > i:last-child { opacity: 1; }
|
||||
.proto-root .jc-spark.empty { align-items: stretch; }
|
||||
.proto-root .jc-spark.empty > i { height: 100%; opacity: 1; background: repeating-linear-gradient(45deg, var(--tone-soft) 0 5px, transparent 5px 10px); border: 1px dashed var(--tone-line); border-radius: 5px; }
|
||||
/* 有效 PN 计数 */
|
||||
.proto-root .jc-count { display: flex; align-items: baseline; gap: 6px; }
|
||||
.proto-root .jc-count > b { font-family: var(--mono); font-size: 32px; font-weight: 800; line-height: .95; color: var(--ink); letter-spacing: -.04em; }
|
||||
.proto-root .jc-count > span { font-size: 11px; color: var(--ink-4); }
|
||||
/* 分段刻度条:一格 = 一条 PN,缺必填的那格直接标红——比一根实心进度条信息量大 */
|
||||
.proto-root .jc-segmeter { display: flex; gap: 3px; }
|
||||
.proto-root .jc-segmeter > i { flex: 1; height: 7px; border-radius: 2px; background: var(--ok); transition: background var(--t-fast); }
|
||||
.proto-root .jc-segmeter > i.bad { background: var(--danger); }
|
||||
.proto-root .jc-lockhint { display: inline-flex; align-items: center; gap: 4px; font-size: 10px; color: var(--ink-4); }
|
||||
|
||||
/* ── 占位胶囊:必填=红虚线,选填=灰虚线;**都带文字**,不靠颜色单打 ── */
|
||||
.proto-root .jc-ph { display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700; line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed; }
|
||||
.proto-root .jc-ph.req { color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
|
||||
.proto-root .jc-ph.opt { color: var(--ink-4); background: var(--soft); border-color: var(--line); }
|
||||
|
||||
/* ── 分段控件(新客户 / 老客户)── */
|
||||
.proto-root .jc-seg { display: inline-flex; padding: 2px; gap: 2px; border: 1px solid var(--cardline); border-radius: 9px; background: var(--soft); align-self: flex-start; }
|
||||
.proto-root .jc-seg button { border: 0; border-radius: 7px; padding: 6px 13px; font: inherit; font-size: 11.5px; font-weight: 700; color: var(--ink-3); background: transparent; cursor: pointer; transition: all var(--t-fast); }
|
||||
.proto-root .jc-seg button:hover:not(.on) { color: var(--brand); }
|
||||
.proto-root .jc-seg button.on { background: #fff; color: var(--ink); box-shadow: 0 1px 3px rgba(31,35,41,.12); }
|
||||
|
||||
/* ── 表:读态与编辑态共用骨架 ─────────────────────────────────────── */
|
||||
.proto-root .jc-tbl-wrap { overflow-x: auto; border: 1px solid var(--line-soft); border-radius: 10px; }
|
||||
.proto-root .jc-tbl-in { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; }
|
||||
.proto-root .jc-tbl-in th { background: var(--soft); text-align: left; padding: 8px 9px; font-size: 10.5px; font-weight: 800; letter-spacing: .03em; color: var(--ink-3); white-space: nowrap; border-bottom: 1px solid var(--line-soft); }
|
||||
.proto-root .jc-tbl-in th.req::after { content: "*"; margin-left: 1px; color: var(--danger); font-size: 10px; }
|
||||
.proto-root .jc-tbl-in td { padding: 4px 5px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
|
||||
.proto-root .jc-tbl-in tr:last-child td { border-bottom: 0; }
|
||||
.proto-root .jc-tbl-in tbody tr:hover td { background: #f7f9fd; }
|
||||
/* 缺必填的整行浅红打底 + 行首红标——不用找红胶囊,一眼扫到是哪几行 */
|
||||
.proto-root .jc-tbl-in tr.miss td { background: #fffbfb; }
|
||||
.proto-root .jc-tbl-in tbody tr.miss:hover td { background: #fff5f5; }
|
||||
.proto-root .jc-tbl-in td.jc-rn { position: relative; width: 38px; padding: 4px 6px 4px 13px; white-space: nowrap; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
|
||||
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); font-weight: 800; }
|
||||
/* 行首红标只在表态用 ::after —— 卡片态的 ::after 被「第 N 条」占着,不能抢 */
|
||||
@media (min-width: 961px) {
|
||||
.proto-root .jc-tbl-in tr.miss td.jc-rn::after { content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger); }
|
||||
}
|
||||
.proto-root .jc-rndot { display: inline-block; width: 5px; height: 5px; margin-left: 4px; border-radius: 999px; background: var(--danger); vertical-align: 2px; }
|
||||
/* 读态单元格 */
|
||||
.proto-root .jc-vcell { display: block; padding: 6px 6px; font-size: 12px; color: var(--ink); line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-vcell.mono { font-family: var(--mono); font-size: 11px; color: var(--ink-2); }
|
||||
.proto-root .jc-vcell.num { font-family: var(--mono); font-size: 11.5px; text-align: right; }
|
||||
/* 编辑态单元格:安静的电子表格 */
|
||||
.proto-root .jc-cell { width: 100%; min-width: 64px; border: 1px solid var(--line); border-radius: 6px; padding: 6px 7px; font: inherit; font-size: 12px; color: var(--ink); background: #fff; transition: border-color var(--t-fast), box-shadow var(--t-fast); }
|
||||
.proto-root .jc-cell.mono { font-family: var(--mono); font-size: 11px; }
|
||||
.proto-root .jc-cell.num { text-align: right; font-family: var(--mono); font-size: 11.5px; }
|
||||
.proto-root .jc-cell::placeholder { color: var(--ink-4); }
|
||||
.proto-root .jc-cell:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-wash); outline: none; }
|
||||
.proto-root .jc-cell.req-empty { border-color: #f3b6b3; background: #fffafa; }
|
||||
.proto-root .jc-cell.req-empty::placeholder { color: var(--danger); }
|
||||
/* 备注这类长文本用文本域:能换行、随内容长高,不裁字 */
|
||||
.proto-root textarea.jc-cell { display: block; resize: vertical; overflow: hidden; min-height: 31px; line-height: 1.55; }
|
||||
|
||||
/* 编辑态的整表提示带 */
|
||||
.proto-root .jc-editbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); border: 1px solid var(--brand-soft); font-size: 11.5px; color: var(--brand-deep); }
|
||||
.proto-root .jc-editbar svg { flex: none; }
|
||||
.proto-root .jc-editbar .sp { flex: 1 1 auto; }
|
||||
|
||||
/* 剔除行说明 */
|
||||
.proto-root .jc-drop { display: flex; align-items: flex-start; gap: 8px; margin-top: 10px; padding: 9px 12px; border-radius: 9px; border: 1px dashed var(--line); background: var(--soft); font-size: 11.5px; color: var(--ink-3); line-height: 1.6; }
|
||||
.proto-root .jc-drop svg { flex: none; margin-top: 2px; color: var(--ink-4); }
|
||||
.proto-root .jc-drop code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
|
||||
|
||||
.proto-root .jc-callout { display: flex; gap: 8px; align-items: flex-start; border-radius: 9px; padding: 11px 13px; font-size: 12.5px; line-height: 1.75; }
|
||||
.proto-root .jc-callout p { margin: 0; }
|
||||
.proto-root .jc-callout svg { flex: none; margin-top: 2px; }
|
||||
.proto-root .jc-callout.t-mute { background: var(--soft); color: var(--ink-2) }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.proto-root .jc-pulse-dot { animation: none; }
|
||||
.proto-root .jc-report, .proto-root .jc-rp-stamp { animation: none; }
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════
|
||||
响应式:宁可另起一行,也不损失完整度
|
||||
≥961px 六列全展开的表(列 min-width 之和 878px,容器有余量,绝不裁字、绝不横滚)
|
||||
≤960px 表 → 卡片:一条 PN 一张卡、一行一字段(标签 / 值),无横滚、无省略
|
||||
≤560px 字段行再拆成上下两行(标签在上、值在下)
|
||||
══════════════════════════════════════════════════════════════════════════ */
|
||||
@media (max-width: 1040px) { .proto-root .jc-four { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 560px) { .proto-root .jc-four { grid-template-columns: 1fr; } }
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.proto-root .jc-tbl-wrap { overflow: visible; border: 0; border-radius: 0; background: transparent; }
|
||||
.proto-root .jc-tbl-in,
|
||||
.proto-root .jc-tbl-in tbody,
|
||||
.proto-root .jc-tbl-in tr,
|
||||
.proto-root .jc-tbl-in td { display: block; width: 100%; }
|
||||
.proto-root .jc-tbl-in thead { display: none; }
|
||||
|
||||
/* 一条 PN = 一张卡 */
|
||||
.proto-root .jc-tbl-in tr { margin-bottom: 10px; padding: 4px 13px 9px; border: 1px solid var(--line-soft); border-radius: 11px; background: #fff; box-shadow: var(--card-shadow); }
|
||||
.proto-root .jc-tbl-in tr:last-child { margin-bottom: 0; }
|
||||
.proto-root .jc-tbl-in tbody tr:hover td { background: transparent; }
|
||||
.proto-root .jc-tbl-in tr.miss { border-color: #f3b6b3; background: #fffbfb; }
|
||||
|
||||
/* 卡头:第 N 条 */
|
||||
.proto-root .jc-tbl-in td.jc-rn { padding: 8px 0 8px; margin-bottom: 4px; border-bottom: 1px solid var(--line-soft); font-family: inherit; font-size: 12px; font-weight: 800; color: var(--ink-2); }
|
||||
.proto-root .jc-tbl-in td.jc-rn::before { content: "第 "; }
|
||||
.proto-root .jc-tbl-in td.jc-rn::after { content: " 条"; }
|
||||
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); }
|
||||
|
||||
/* 字段行:左标签 + 右值,值超长自动折行 */
|
||||
.proto-root .jc-tbl-in td:not(.jc-rn) { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 10px; align-items: center; padding: 6px 0; border-bottom: 1px dashed var(--line-soft); }
|
||||
.proto-root .jc-tbl-in tr td:last-child { border-bottom: 0; padding-bottom: 2px; }
|
||||
.proto-root .jc-tbl-in td:not(.jc-rn)::before {
|
||||
content: attr(data-h); font-size: 10.5px; font-weight: 700; letter-spacing: .02em; color: var(--ink-4); line-height: 1.5;
|
||||
}
|
||||
.proto-root .jc-tbl-in td[data-req="1"]:not(.jc-rn)::before { content: attr(data-h) " *"; }
|
||||
.proto-root .jc-tbl-in .jc-vcell { padding: 0; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-tbl-in .jc-vcell.num { text-align: left; }
|
||||
.proto-root .jc-tbl-in .jc-cell.num { text-align: left; }
|
||||
.proto-root .jc-tbl-in .jc-cell { min-width: 0; }
|
||||
}
|
||||
|
||||
/* 窄屏:落款三件各占一行,动作按钮撑满一行 */
|
||||
@media (max-width: 560px) {
|
||||
.proto-root .jc-rp-acts { margin-left: 0; width: 100%; flex-wrap: wrap; }
|
||||
.proto-root .jc-rp-acts .jc-btn { flex: 1 1 auto; justify-content: center; }
|
||||
.proto-root .jc-hintline { flex: 1 1 100%; }
|
||||
.proto-root .jc-tbl-in td:not(.jc-rn) { grid-template-columns: 1fr; gap: 3px; align-items: start; }
|
||||
}
|
||||
|
||||
|
||||
/* ══ 以下取自 A1-询价受理记录.html(归档件独有的件)══ */
|
||||
/* ── 料号分类:分布条 + 图例 ───────────────────────────────────────── */
|
||||
.proto-root .jc-dist { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 13px 15px; border: 1px solid var(--line-soft); border-radius: 12px; background: linear-gradient(180deg, #fbfcfe, #fff); }
|
||||
.proto-root .jc-distbar { flex: 1 1 320px; min-width: 260px; display: flex; gap: 3px; }
|
||||
.proto-root .jc-distbar > i { height: 30px; border-radius: 4px; display: grid; place-items: center; font-size: 10.5px; font-weight: 800; color: #fff; font-family: var(--mono); }
|
||||
.proto-root .jc-distbar > i.a { background: var(--ok); }
|
||||
.proto-root .jc-distbar > i.b { background: var(--brand); }
|
||||
.proto-root .jc-distbar > i.c { background: var(--warn-accent); }
|
||||
.proto-root .jc-distlg { display: flex; flex-wrap: wrap; gap: 8px 18px; }
|
||||
.proto-root .jc-distlg > span { display: inline-flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--ink-3); }
|
||||
.proto-root .jc-distlg em { width: 10px; height: 10px; border-radius: 3px; }
|
||||
.proto-root .jc-distlg em.a { background: var(--ok); } .proto-root .jc-distlg em.b { background: var(--brand); } .proto-root .jc-distlg em.c { background: var(--warn-accent); }
|
||||
.proto-root .jc-distlg b { font-family: var(--mono); font-weight: 800; color: var(--ink); }
|
||||
|
||||
/* 类别徽 A / B / C */
|
||||
.proto-root .jc-tbadge { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 6px; font-family: var(--mono); font-size: 11px; font-weight: 800; line-height: 1; color: #fff; }
|
||||
.proto-root .jc-tbadge.a { background: var(--ok); } .proto-root .jc-tbadge.b { background: var(--brand); } .proto-root .jc-tbadge.c { background: var(--warn-accent); }
|
||||
|
||||
/* ── 结论与去向 ─────────────────────────────────────────────────────────
|
||||
G2 是**分支网关**:三条互斥条件按序求值,只命中一条、只落一个活动。
|
||||
所以这里不是三张并列的去向卡,而是一张**判定表 + 一条结论**。 */
|
||||
/* 判定表:一行一条规则,命中行上色留痕(DMN 口径) */
|
||||
.proto-root .jc-dmn { border: 1px solid var(--line-soft); border-radius: 12px; overflow: hidden; }
|
||||
.proto-root .jc-dmn-h, .proto-root .jc-dmn-r { display: grid; grid-template-columns: 40px minmax(0, 1fr) 170px 200px; align-items: center; gap: 12px; padding: 9px 14px; }
|
||||
.proto-root .jc-dmn-h { background: var(--soft); border-bottom: 1px solid var(--line-soft); font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); }
|
||||
.proto-root .jc-dmn-r { font-size: 12px; color: var(--ink-4); background: #fff; }
|
||||
.proto-root .jc-dmn-r + .jc-dmn-r { border-top: 1px solid var(--line-soft); }
|
||||
.proto-root .jc-dmn-i { font-family: var(--mono); font-size: 11px; font-weight: 800; }
|
||||
.proto-root .jc-dmn-c { line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-dmn-t { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; }
|
||||
.proto-root .jc-dmn-t code { font-family: var(--mono); font-size: 10.5px; font-weight: 800; color: var(--ink-4); background: var(--soft); border: 1px solid var(--line); border-radius: 5px; padding: 2px 6px; }
|
||||
.proto-root .jc-dmn-v { font-size: 11px; line-height: 1.55; }
|
||||
/* 命中行:上色 + 左缘棱 + 加粗 */
|
||||
.proto-root .jc-dmn-r.hit { position: relative; background: var(--tone-soft); color: var(--ink-2); }
|
||||
.proto-root .jc-dmn-r.hit::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--tone); }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-i { color: var(--tone); }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-c { color: var(--ink); }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-c b { color: var(--tone); font-weight: 800; }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-t { color: var(--ink); }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-t code { color: #fff; background: var(--tone); border-color: var(--tone); }
|
||||
.proto-root .jc-dmn-v.y { display: inline-flex; align-items: center; gap: 5px; font-weight: 800; color: var(--tone); }
|
||||
.proto-root .jc-dmn-v.n { color: var(--ink-4); }
|
||||
/* 色调随命中类别 */
|
||||
.proto-root .jc-dmn.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.10); }
|
||||
.proto-root .jc-dmn.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.09); }
|
||||
.proto-root .jc-dmn.k-c { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.11); }
|
||||
|
||||
/* 结论横幅:命中条件 → 唯一去向 */
|
||||
.proto-root .jc-verdict {
|
||||
position: relative; overflow: hidden; display: flex; align-items: stretch; flex-wrap: wrap; gap: 16px;
|
||||
margin-top: 11px; padding: 16px 18px; border: 1px solid var(--tone-line); border-radius: 13px;
|
||||
background: linear-gradient(105deg, var(--tone-soft), #fff 62%);
|
||||
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 10px 24px -16px rgba(31,35,41,.4);
|
||||
--tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.14); --tone-line: #ffd9a8;
|
||||
}
|
||||
.proto-root .jc-verdict.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.13); --tone-line: #bfedb4; }
|
||||
.proto-root .jc-verdict.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft); }
|
||||
.proto-root .jc-verdict::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 5px; background: var(--tone); }
|
||||
.proto-root .jc-verdict > * { position: relative; z-index: 1; }
|
||||
.proto-root .jc-vd-l { flex: 1 1 300px; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
|
||||
.proto-root .jc-vd-k { font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
|
||||
.proto-root .jc-vd-cond { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 15px; font-weight: 800; color: var(--ink); }
|
||||
.proto-root .jc-vd-why { margin: 0; font-size: 11.5px; line-height: 1.7; color: var(--ink-3); overflow-wrap: anywhere; }
|
||||
.proto-root .jc-vd-why code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
|
||||
.proto-root .jc-vd-arrow { flex: none; align-self: center; color: var(--tone); }
|
||||
.proto-root .jc-vd-r { flex: 1 1 260px; min-width: 0; display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-radius: 11px; background: #fff; border: 1px solid var(--tone-line); }
|
||||
.proto-root .jc-vd-node { display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 800; color: var(--ink); }
|
||||
.proto-root .jc-vd-node code { font-family: var(--mono); font-size: 12px; font-weight: 800; color: #fff; background: var(--tone); border-radius: 6px; padding: 3px 8px; }
|
||||
.proto-root .jc-vd-who { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--ink-2); }
|
||||
.proto-root .jc-vd-who > i { width: 20px; height: 20px; border-radius: 999px; display: inline-grid; place-items: center; font-style: normal; font-size: 10px; font-weight: 700; color: #fff; background: var(--tone); }
|
||||
.proto-root .jc-vd-next { margin: 0; padding-top: 8px; border-top: 1px dashed var(--line-soft); font-size: 10.5px; line-height: 1.65; color: var(--ink-4); }
|
||||
.proto-root .jc-vd-next b { color: var(--ink-2); font-weight: 700; }
|
||||
/* 命中分支携带的条目 */
|
||||
.proto-root .jc-pnlist { display: flex; flex-wrap: wrap; gap: 5px; margin: 0; padding: 0; list-style: none; }
|
||||
.proto-root .jc-pnlist > li { display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 999px; background: #fff; border: 1px solid var(--tone-line); font-size: 11px; color: var(--ink-2); overflow-wrap: anywhere; }
|
||||
.proto-root .jc-pnlist > li > i { flex: none; width: 5px; height: 5px; border-radius: 999px; background: var(--tone); }
|
||||
.proto-root .jc-pnlist code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
|
||||
|
||||
/* ── 落地动作 ───────────────────────────────────────────────────────── */
|
||||
.proto-root .jc-acts { display: flex; flex-direction: column; gap: 0; border: 1px solid var(--line-soft); border-radius: 11px; overflow: hidden; }
|
||||
.proto-root .jc-act { display: flex; align-items: flex-start; gap: 9px; padding: 10px 13px; font-size: 12px; color: var(--ink-2); line-height: 1.6; background: #fff; }
|
||||
.proto-root .jc-act + .jc-act { border-top: 1px solid var(--line-soft); }
|
||||
.proto-root .jc-act > i { flex: none; width: 18px; height: 18px; margin-top: 1px; border-radius: 999px; display: inline-grid; place-items: center; color: #fff; background: var(--ok); }
|
||||
.proto-root .jc-act b { font-weight: 700; color: var(--ink); }
|
||||
.proto-root .jc-act span { color: var(--ink-4); }
|
||||
.proto-root .jc-act code { font-family: var(--mono); font-size: 10.5px; color: var(--brand-deep); background: var(--brand-wash); border-radius: 4px; padding: 1px 5px; }
|
||||
|
||||
|
||||
.proto-root .jc-done-tag { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; flex: none; padding: 6px 13px; border-radius: 999px; font-size: 11.5px; font-weight: 800; color: var(--ok); background: #fff; border: 1px solid #bfedb4; }
|
||||
.proto-root .jc-vcell.dim { color: var(--ink-4); }
|
||||
.proto-root .jc-tbl-in td.jc-ty { width: 44px; text-align: center; }
|
||||
.proto-root .jc-tbl-in td.jc-ty { width: auto; text-align: left; }
|
||||
`;
|
||||
|
||||
/**
|
||||
* 图标精灵 —— 与样式表同一出处,两份目标件的 `<symbol>` 取并集。
|
||||
* 沙箱里取不到图标字体,所以图标一律内联;`<use href="#i-xxx">` 引的就是这一坨。
|
||||
*/
|
||||
export const PRD_SPRITE = `<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs><symbol id="i-alert" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></symbol><symbol id="i-arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol><symbol id="i-boxes" viewBox="0 0 24 24"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></symbol><symbol id="i-building" viewBox="0 0 24 24"><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/><path d="M10 18h4"/></symbol><symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol><symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol><symbol id="i-file-check" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="m9 15 2 2 4-4"/></symbol><symbol id="i-file-text" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></symbol><symbol id="i-git-branch" viewBox="0 0 24 24"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></symbol><symbol id="i-history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></symbol><symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></symbol><symbol id="i-lock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol><symbol id="i-mail-open" viewBox="0 0 24 24"><path d="M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0z"/><path d="m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10"/></symbol><symbol id="i-pen" viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z"/></symbol><symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol><symbol id="i-sheet" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M8 13h2"/><path d="M14 13h2"/><path d="M8 17h2"/><path d="M14 17h2"/></symbol><symbol id="i-shield-check" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></symbol><symbol id="i-user" viewBox="0 0 24 24"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/></symbol><symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></symbol></defs></svg>`;
|
||||
@@ -1,26 +1,634 @@
|
||||
/**
|
||||
* A2 视图模版 —— 案例全景(AM 07:loop 按案例模型的阶段与里程碑组织汇报)。
|
||||
* A2 替代选型的两张报告 —— 2026-08-03 第四轮重设计(隔离设计代理从零起版面;
|
||||
* standalone 目标件先行,全绿后按《HTML报告生成规范》§7 机械平移成本文件)。
|
||||
*
|
||||
* · `ALT_WORKSHEET` 选型执行工单(S1/S2 在办;**一颗待选型料 = 一页**,tr 挂 data-page):
|
||||
* 档案头(大字原厂 PN + 为什么要替代)→ 蓝图带(原厂芯片 → 五道核对 → 替代插座:
|
||||
* 空位虚线红槽、定了实线绿槽 —— 概念图形是第一语言)→ 题面/定案双栏
|
||||
* (左边客户给的,右边 FAE 的产出;等级三枚 radio 直接选,口径贴在钮上)→
|
||||
* 方法脚注(五项核对 · 三层筛 · 未果通道)。
|
||||
* 页容器是**真表格块化**(table.alt-pages > tbody > tr):宿主的加行/删行写死找 tr,
|
||||
* 「补一颗漏掉的待选型料」与「这颗不该选型(剔除)」才真能用。
|
||||
* · `ALT_ROSTER` 选型验收报告(S3/放行/归档;**通篇只读**):收口总览四格账 + 选型账一行
|
||||
* → 询价受理清单大表(原料 → 原标 → 替代定案 → 现标 → 依据)→ 落边图
|
||||
* (清单 → 还有 B 类? → A3 渠道寻源 / A4 成本定价,命中边实线亮 + 流光,
|
||||
* 由 case:routeKey 投影,一条没命中两边全灰虚 —— 不冒充判过)→ 结论带 → 落地动作。
|
||||
* 就地改走验收卡的 editableAtRelease 表,这里一个 data-col / data-edit 都没有。
|
||||
*
|
||||
* 设计目标件(standalone,浏览器直接可开,与本文件 1:1):
|
||||
* ~/.claude/jobs/0aebbba5/tmp/target-alt-worksheet.html / target-alt-roster.html
|
||||
*
|
||||
* ── 工程纪律(都踩过,一条不破)──────────────────────────────────────
|
||||
* ① 主键格必挂 `data-col`(行的钥匙;在 .decide 的 keyrow 里,开闸才现身,
|
||||
* 也是「补一页」落键的地方 —— 不填不落库);
|
||||
* ② data-rows 行容器的标签名不得在行模版内部再现(tbody 的行内无 tbody);
|
||||
* ③ 收值按「input 的祖父」归行 —— 一页里所有输入格(含 radio)的祖父都是同一个 .decide;
|
||||
* ④ radio(name=alt_grade_pick)不写 checked:当前档由 data-class-of 投影描虚线,
|
||||
* 「没点」=「不改」;渲染期基座按行号缀名分组。已知边角:人「补一页」克隆末页时
|
||||
* 克隆页 radio 与末页同组,新页选档会把末页的勾在 DOM 上顶掉 —— 但未选中列
|
||||
* 不进写回载荷,台账无恙;
|
||||
* ⑤ 只有待选型(orig_type=C)的行成页:`.alt-page:not(.v-c){display:none}`,判断在样式表;
|
||||
* ⑥ 模版不算不判;列名逐字照 manifest 声明抄;逐条数据走 `mine:quote_alternative.list`;
|
||||
* ⑦ 空占位三级全局口径:**必填红 / 建议填橘 / 选填灰**(required / expect / 其余),
|
||||
* 蓝图插座的空位也归「必填」。
|
||||
*/
|
||||
import { kvCard, noteCard, stagesCard, viewShell } from '@sdk';
|
||||
import { PRD_CSS, PRD_SPRITE } from './prd-style.ts';
|
||||
|
||||
export const ALTERNATIVE_CASE = viewShell({
|
||||
title: '替代选型 · 案例全景',
|
||||
kicker: 'A2 · 案例活动(loop)',
|
||||
tone: 'case',
|
||||
/** 一枚图标 —— `use` 引精灵里的 symbol */
|
||||
const ico = (name: string, size = 11): string =>
|
||||
`<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-${name}"/></svg>`;
|
||||
|
||||
/** 本件补充精灵:五项核对与等级示意的 6 枚 symbol(lucide 线稿风,随模版走,不动 prd-style) */
|
||||
const EXTRA_SPRITE = `<svg width="0" height="0" style="position:absolute" aria-hidden="true">
|
||||
<symbol id="i-ruler" viewBox="0 0 24 24"><path d="M3 17 17 3l4 4L7 21z"/><path d="m8.5 12.5 1.8 1.8M11.5 9.5l1.8 1.8M14.5 6.5l1.8 1.8"/></symbol>
|
||||
<symbol id="i-pins" viewBox="0 0 24 24"><path d="M4 7h5M4 12h5M4 17h5M15 7h5M15 12h5M15 17h5"/><rect x="9" y="4" width="6" height="16" rx="1.5"/></symbol>
|
||||
<symbol id="i-bolt" viewBox="0 0 24 24"><path d="M13 2 4 14h6l-1 8 9-12h-6z"/></symbol>
|
||||
<symbol id="i-thermo" viewBox="0 0 24 24"><path d="M12 3a2 2 0 0 0-2 2v8.5a4 4 0 1 0 4 0V5a2 2 0 0 0-2-2z"/><path d="M12 9v6"/></symbol>
|
||||
<symbol id="i-flask" viewBox="0 0 24 24"><path d="M10 2v6L4.5 18a2 2 0 0 0 1.8 3h11.4a2 2 0 0 0 1.8-3L14 8V2"/><path d="M8.5 2h7"/><path d="M7.5 15h9"/></symbol>
|
||||
<symbol id="i-gear" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3.2"/><path d="M12 2.5v3M12 18.5v3M2.5 12h3M18.5 12h3M5.2 5.2l2.1 2.1M16.7 16.7l2.1 2.1M18.8 5.2l-2.1 2.1M7.3 16.7l-2.1 2.1"/></symbol>
|
||||
</svg>`;
|
||||
|
||||
/* ==================== 编辑机构 + 家族补丁(两张共用)====================
|
||||
闸/红带/加行/撤行/孪生件开合 —— 拷自 A1 报告的 BIND_CSS 并按本件容器适配:
|
||||
A1 的开合钉在 .jc-tbl-in 上,本件工单的容器是 table.alt-pages(.on 由宿主落在
|
||||
table 上,data-edit 在 tbody);类徽三色与铺满工作区的两行也一并收在这里。 */
|
||||
|
||||
const HARNESS_CSS = `
|
||||
/* 铺满工作区:件就是这一屏的正文,不是嵌在里面的另一个页面 */
|
||||
.jc-stage { max-width: none; padding: 0 0 4px; }
|
||||
.proto-root .jc-report { border: 0; border-radius: 0; box-shadow: none; }
|
||||
.proto-root .jc-logo svg, .proto-root .jc-face svg, .proto-root .jc-rp-ava svg { width: 17px; height: 17px; }
|
||||
|
||||
/* 孪生输入件默认收着 —— 件首先是给人看的 */
|
||||
.proto-root .jc-cell { display: none; }
|
||||
|
||||
/* 类徽三色(A/B/C) */
|
||||
.proto-root .jc-tbadge { background: var(--ink-4); }
|
||||
.proto-root .jc-tbadge.v-a { background: var(--ok); }
|
||||
.proto-root .jc-tbadge.v-b { background: var(--brand); }
|
||||
.proto-root .jc-tbadge.v-c { background: var(--warn-accent); }
|
||||
|
||||
/* 那道闸 —— 默认「已锁定 + 编辑」,开了变成「取消 / 保存」;只在可改屏现身 */
|
||||
.proto-root .jc-editgate { display: none; }
|
||||
html.editable .proto-root .jc-editgate {
|
||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
margin-bottom: 10px; padding: 8px 12px; border-radius: 9px;
|
||||
background: var(--soft); border: 1px solid var(--line-soft);
|
||||
font-size: 11.5px; color: var(--ink-3);
|
||||
}
|
||||
.proto-root .jc-editgate-t { display: inline-flex; align-items: center; gap: 6px; flex: 1 1 auto; min-width: 0; }
|
||||
.proto-root .jc-editgate-t b { color: var(--ink-2); font-weight: 700; }
|
||||
.proto-root .jc-egbtn {
|
||||
flex: none; display: inline-flex; align-items: center; gap: 4px; cursor: pointer;
|
||||
border: 1px solid var(--line); border-radius: 7px; padding: 5px 11px;
|
||||
font: inherit; font-size: 11.5px; font-weight: 700; color: var(--ink-2); background: #fff;
|
||||
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
|
||||
}
|
||||
.proto-root .jc-egbtn:hover { border-color: var(--brand); color: var(--brand); }
|
||||
.proto-root .jc-egbtn.save { color: #fff; background: var(--brand); border-color: var(--brand); }
|
||||
.proto-root .jc-egbtn.save:hover { background: var(--brand-deep); border-color: var(--brand-deep); }
|
||||
.proto-root .jc-editgate .jc-egbtn.ghost, .proto-root .jc-editgate .jc-egbtn.save { display: none; }
|
||||
.proto-root .jc-editgate.on { background: var(--brand-wash); border-color: var(--brand-soft); color: var(--brand-deep); }
|
||||
.proto-root .jc-editgate.on .jc-egbtn.open { display: none; }
|
||||
.proto-root .jc-editgate.on .jc-egbtn.ghost, .proto-root .jc-editgate.on .jc-egbtn.save { display: inline-flex; }
|
||||
.proto-root .jc-editgate.on .jc-editgate-t { visibility: hidden; }
|
||||
|
||||
/* 缺项红带 —— 只在可改屏出,一格不缺自动收(v-0 那档隐藏,判断在样式表) */
|
||||
.proto-root .jc-gapbar { display: none; }
|
||||
html.editable .proto-root .jc-gapbar {
|
||||
display: flex; align-items: flex-start; gap: 8px;
|
||||
margin: 0 0 10px; padding: 9px 12px; border-radius: 9px;
|
||||
background: #fff5f5; border: 1px solid #f3b6b3; color: var(--danger);
|
||||
font-size: 12px; line-height: 1.65;
|
||||
}
|
||||
.proto-root .jc-gapbar svg { flex: 0 0 auto; margin-top: 2px; }
|
||||
.proto-root .jc-gapbar b { font-weight: 800; }
|
||||
.proto-root .jc-gapbar span { color: #9a3b36; }
|
||||
html.editable .proto-root .jc-gapbar.v-0 { display: none; }
|
||||
|
||||
/* 「怎么改」的提示带只在开了闸之后说 —— 锁着的时候它是句废话 */
|
||||
.proto-root .jc-editbar { display: none; }
|
||||
.proto-root .jc-editgate.on + .jc-editbar {
|
||||
display: flex; align-items: flex-start; gap: 8px;
|
||||
margin: 0 0 10px; padding: 9px 12px; border-radius: 9px;
|
||||
background: var(--brand-wash); border: 1px solid var(--brand-soft);
|
||||
font-size: 12px; line-height: 1.65; color: var(--ink-2);
|
||||
}
|
||||
.proto-root .jc-editbar svg { flex: 0 0 auto; margin-top: 2px; color: var(--brand); }
|
||||
|
||||
/* 表尾「补一颗」—— 只在这一屏可改且开了闸时现身(容器是 table.alt-pages) */
|
||||
.proto-root .jc-addrow { display: none; }
|
||||
html.editable .proto-root table.alt-pages.on + .jc-addrow {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
margin: -2px 0 12px; padding: 7px 12px; border-radius: 9px;
|
||||
background: var(--soft); border: 1px dashed var(--line);
|
||||
}
|
||||
.proto-root .jc-addrow em { font-style: normal; font-size: 11px; color: var(--ink-3); }
|
||||
`;
|
||||
|
||||
/**
|
||||
* 一份件的外壳 —— 照家族三层壳:.proto-root > .app-shell > .jc-stage > article.jc-report;
|
||||
* 题头(徽 + kicker + 题 + 状态灯)与落款(署名 + 流位签)与 A1 同一套版面语言。
|
||||
*/
|
||||
const doc = (input: {
|
||||
badge: string;
|
||||
kicker: string;
|
||||
title: string;
|
||||
titleRef: string;
|
||||
state: string;
|
||||
css: string;
|
||||
body: string;
|
||||
foot: string;
|
||||
tag: string;
|
||||
}): string => `<style>${PRD_CSS}${HARNESS_CSS}${input.css}</style>
|
||||
${PRD_SPRITE}${EXTRA_SPRITE}
|
||||
<div class="proto-root"><div class="app-shell"><div class="jc-stage">
|
||||
<article class="jc-report">
|
||||
<header class="jc-rp-head">
|
||||
<div class="jc-rp-badge">${ico(input.badge, 17)}</div>
|
||||
<div class="jc-rp-tit">
|
||||
<em>${input.kicker}</em>
|
||||
<h3>${input.title} <span data-slot="${input.titleRef}"></span></h3>
|
||||
</div>
|
||||
<span class="jc-rp-wait">${input.state}</span>
|
||||
</header>
|
||||
<div class="jc-rp-bd">${input.body}</div>
|
||||
<footer class="jc-rp-foot">
|
||||
<div class="jc-rp-sign">
|
||||
<span class="jc-rp-ava">${ico('user', 15)}</span>
|
||||
<div><b data-slot="case:owner"></b><span>${input.foot}</span></div>
|
||||
</div>
|
||||
<span class="jc-done-tag">${ico('git-branch', 13)}${input.tag}</span>
|
||||
</footer>
|
||||
</article>
|
||||
</div></div></div>`;
|
||||
|
||||
/** 章节头 —— 家族的 jc-rp-part:小标签 + 细线 + 右端小签 */
|
||||
const part = (label: string, aside = '', tone = ''): string =>
|
||||
`<div class="jc-rp-part"><b>${label}</b><i class="ln"></i>${aside === '' ? '' : `<em class="${tone}">${aside}</em>`}</div>`;
|
||||
|
||||
/* ==================== 选型执行工单(一颗一页)==================== */
|
||||
|
||||
const SHEET_CSS = `
|
||||
/* 编辑区容器:左缘品牌竖线把「这一段可就地修订」从说明区里立出来 */
|
||||
.proto-root .jc-zone.z-edit { border-left: 3px solid var(--brand); padding-left: 14px; margin: 2px 0 14px; }
|
||||
|
||||
/* ── 页容器:真表格块化(tbody 挂行来源与行地址,tr 即一页,宿主翻页认 [data-page])── */
|
||||
.proto-root table.alt-pages { display: block; width: 100%; border-collapse: separate; border-spacing: 0; }
|
||||
.proto-root .alt-pages tbody { display: block; }
|
||||
.proto-root .alt-pages tr.alt-page { display: block; margin: 0 0 16px; }
|
||||
.proto-root .alt-pages td.alt-cell { display: block; padding: 0; }
|
||||
/* 只有待选型(原标 C)的行成页;A/B 行原样承继,不进这份工单(判断在样式表) */
|
||||
.proto-root .alt-pages tr.alt-page:not(.v-c) { display: none; }
|
||||
|
||||
/* ── 档案页外框 ── */
|
||||
.proto-root .alt-cell { border: 1px solid var(--line); border-radius: 12px; overflow: hidden; background: #fff; }
|
||||
|
||||
/* ── 档案头:这颗料的出身 ── */
|
||||
.proto-root .archrow { display: flex; align-items: flex-start; gap: 16px; padding: 13px 16px 11px; border-bottom: 1px solid var(--line-soft); }
|
||||
.proto-root .archrow .chip-id { flex: 1 1 auto; min-width: 0; }
|
||||
.proto-root .archrow .chip-id em { display: block; font-style: normal; font-size: 10px; font-weight: 800; letter-spacing: .14em; color: var(--ink-4); margin-bottom: 3px; }
|
||||
.proto-root .archrow .chip-id .pn { display: block; font-size: 23px; font-weight: 800; letter-spacing: -.3px; color: var(--ink); word-break: break-all; line-height: 1.2; }
|
||||
.proto-root .archrow .why { flex: 0 1 300px; min-width: 210px; border-left: 1px solid var(--line-soft); padding-left: 14px; }
|
||||
.proto-root .archrow .why i { display: inline-flex; align-items: center; gap: 5px; font-style: normal; font-size: 10px; font-weight: 800; letter-spacing: .1em; color: var(--ink-4); }
|
||||
.proto-root .archrow .why p { margin: 4px 0 0; font-size: 11.5px; line-height: 1.55; color: var(--ink-2); }
|
||||
.proto-root .archrow .why p span:empty::after { content: "上游未留约束 —— 以原厂官网规格书为准"; color: var(--ink-4); }
|
||||
/* 剔除这一页:只在开了闸时现身,宿主认 data-edit-del(找 tr 挂删记号) */
|
||||
.proto-root .alt-pages .jc-rowdel { display: none; }
|
||||
.proto-root .alt-pages.on .jc-rowdel { display: inline-flex; align-items: center; gap: 4px; width: auto; padding: 3px 9px; font-size: 11px; }
|
||||
.proto-root .alt-pages.on tr.jc-row-gone .alt-cell { opacity: .5; background: #fff6f6; }
|
||||
.proto-root .alt-pages.on tr.jc-row-gone .chip-id .pn { text-decoration: line-through; color: var(--ink-3); }
|
||||
.proto-root .alt-pages.on tr.jc-row-new .alt-cell { background: #f2fbf7; }
|
||||
|
||||
/* ── 蓝图带:替代路径要穿过五道核对(概念图形是第一语言)── */
|
||||
.proto-root .blueprint { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid var(--line-soft);
|
||||
background-image:
|
||||
repeating-linear-gradient(0deg, rgba(51,112,255,.05) 0 1px, transparent 1px 22px),
|
||||
repeating-linear-gradient(90deg, rgba(51,112,255,.05) 0 1px, transparent 1px 22px),
|
||||
linear-gradient(180deg, var(--brand-wash), #fff 88%);
|
||||
}
|
||||
.proto-root .bp-box { flex: 0 0 auto; text-align: center; }
|
||||
.proto-root .bp-box em { display: block; font-style: normal; font-size: 9.5px; font-weight: 800; letter-spacing: .1em; color: var(--ink-4); margin-bottom: 3px; }
|
||||
.proto-root .bp-box svg { display: block; margin: 0 auto; }
|
||||
.proto-root .bp-to .socket-pn { display: block; margin-top: 4px; font-size: 11px; font-weight: 800; color: var(--ink-2); max-width: 150px; word-break: break-all; line-height: 1.3; }
|
||||
.proto-root .bp-mid { flex: 1 1 auto; min-width: 120px; text-align: center; }
|
||||
.proto-root .bp-mid svg { display: block; width: 100%; height: 46px; }
|
||||
.proto-root .bp-mid .cap { font-size: 10px; color: var(--ink-4); }
|
||||
.proto-root .bp-mid .lane { stroke: var(--brand-soft); stroke-width: 2; stroke-dasharray: 6 5; }
|
||||
.proto-root .bp-mid .gate { fill: #fff; stroke: var(--brand); stroke-width: 1.6; }
|
||||
.proto-root .bp-mid .tipp { fill: var(--brand); }
|
||||
/* 插座:定案空着是虚线红槽;填了替代 PN 就换成实线绿槽(判断在样式表,:has + :empty) */
|
||||
.proto-root .bp-to .sk-body { stroke: var(--ok); fill: rgba(39,135,23,.06); }
|
||||
.proto-root .bp-to .sk-slot { stroke: var(--ok); }
|
||||
.proto-root .bp-to:has(.socket-pn:empty) .sk-body { stroke: var(--danger); fill: #fff; stroke-dasharray: 7 5; }
|
||||
.proto-root .bp-to:has(.socket-pn:empty) .sk-slot { stroke: #f3b6b3; }
|
||||
.proto-root .bp-to .socket-pn:empty::after { content: "必填"; display: inline-block; font-size: 10px; font-weight: 800; color: var(--danger); background: var(--danger-soft); border: 1px dashed #f3b6b3; border-radius: 5px; padding: 3px 8px; }
|
||||
.proto-root .bp-from .chipfig .cf-body { fill: var(--ink-2); stroke: var(--ink); }
|
||||
.proto-root .bp-from .chipfig .cf-pin { stroke: var(--ink-3); stroke-width: 2; }
|
||||
.proto-root .bp-from .chipfig .cf-dot { fill: #fff; opacity: .8; }
|
||||
|
||||
/* ── 题面 / 定案双栏 ── */
|
||||
.proto-root .duo { display: grid; grid-template-columns: 1fr 1.2fr; gap: 0; }
|
||||
.proto-root .duo .given { padding: 12px 16px 14px; border-right: 1px solid var(--line-soft); }
|
||||
.proto-root .duo .decide { padding: 12px 16px 14px; background: linear-gradient(180deg, rgba(51,112,255,.035), transparent 60px); }
|
||||
.proto-root .duo h5 { display: flex; align-items: center; gap: 6px; margin: 0 0 8px; font-size: 11px; font-weight: 800; letter-spacing: .06em; color: var(--ink-3); }
|
||||
.proto-root .duo .decide h5 { color: var(--brand-deep); }
|
||||
.proto-root .fld { display: flex; align-items: baseline; gap: 10px; padding: 5px 0; border-bottom: 1px dashed var(--line-soft); }
|
||||
.proto-root .fld:last-child { border-bottom: 0; }
|
||||
.proto-root .fld > i { flex: 0 0 96px; font-style: normal; font-size: 11px; color: var(--ink-4); }
|
||||
.proto-root .fld > .jc-vcell, .proto-root .fld > .gradechip { flex: 1 1 auto; min-width: 0; font-size: 12px; color: var(--ink); line-height: 1.5; word-break: break-all; }
|
||||
/* 行键那一格:平时收起,开了闸才现出来(它不是编辑面,是行的钥匙;新加的页在这里落键) */
|
||||
.proto-root .fld.keyrow { display: none; }
|
||||
.proto-root .alt-pages.on .fld.keyrow { display: flex; background: var(--soft); border-radius: 6px; padding: 5px 8px; }
|
||||
.proto-root .fld.keyrow > i { color: var(--ink-3); font-weight: 700; }
|
||||
|
||||
/* 占位三级(全局口径):必填红 · 建议填橘 · 选填灰;题面空格是「客户没给」,只出一杠 */
|
||||
.proto-root .ph-req:empty::after { content: "必填"; display: inline-block; font-size: 10px; font-weight: 700; line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed #f3b6b3; color: var(--danger); background: var(--danger-soft); }
|
||||
.proto-root .ph-sug:empty::after { content: "建议填"; display: inline-block; font-size: 10px; font-weight: 700; line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed #f0c184; color: var(--warn-accent); background: rgba(255,136,0,.08); }
|
||||
.proto-root .ph-opt:empty::after { content: "选填"; display: inline-block; font-size: 10px; font-weight: 700; line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed var(--line); color: var(--ink-4); background: var(--soft); }
|
||||
.proto-root .dimv:empty::after { content: "—"; color: var(--ink-4); }
|
||||
.proto-root .decide .fld:has(.ph-req:empty) { background: #fffbfb; }
|
||||
.proto-root .decide .fld:has(.ph-req:empty) > i { color: var(--danger); font-weight: 800; }
|
||||
|
||||
/* 定案格的就地改:锁着看只读那份,开了闸换输入孪生件(渲染期基座生成 input.jc-cell) */
|
||||
.proto-root .alt-pages.on .decide .fld > .jc-vcell,
|
||||
.proto-root .alt-pages.on .decide .fld > .gradechip { display: none; }
|
||||
.proto-root .alt-pages.on .decide .fld > input.jc-cell { display: block; flex: 1 1 auto; font: inherit; font-size: 12px; color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: 4px 8px; background: #fff; }
|
||||
.proto-root .alt-pages.on .decide .fld > input.jc-cell:placeholder-shown { border-color: #f3b6b3; background: #fffafa; }
|
||||
|
||||
/* 等级三选:口径贴在选择钮上;当前档由 data-class-of 投影描虚线;radio 只在开了闸时可点 */
|
||||
.proto-root .gopts { margin: 6px 0 4px; display: grid; gap: 6px; }
|
||||
.proto-root .gopt { position: relative; display: flex; align-items: center; gap: 9px; border: 1px solid var(--line-soft); border-radius: 8px; padding: 7px 10px; background: #fff; }
|
||||
.proto-root .gopt > input.jc-cell { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; }
|
||||
.proto-root .alt-pages.on .gopt > input.jc-cell { display: block; }
|
||||
.proto-root .gopt b { flex: 0 0 52px; font-size: 12px; font-weight: 800; color: var(--ink-2); }
|
||||
.proto-root .gopt em { flex: 1 1 auto; font-style: normal; font-size: 10.5px; color: var(--ink-3); line-height: 1.45; }
|
||||
.proto-root .gopt svg { flex: 0 0 auto; color: var(--ink-4); }
|
||||
/* 当前档:虚线描边(值是台账里的,不写 checked —— 「没点」就是「不改」) */
|
||||
.proto-root .gopts.v-p2p .gopt.gp, .proto-root .gopts.v-func .gopt.gf, .proto-root .gopts.v-other .gopt.go { border: 1px dashed var(--brand); background: var(--brand-wash); }
|
||||
.proto-root .gopts.v-p2p .gopt.gp b, .proto-root .gopts.v-func .gopt.gf b, .proto-root .gopts.v-other .gopt.go b { color: var(--brand-deep); }
|
||||
.proto-root .gopts.v-p2p .gopt.gp svg, .proto-root .gopts.v-func .gopt.gf svg, .proto-root .gopts.v-other .gopt.go svg { color: var(--brand); }
|
||||
/* 开了闸:radio 现出真钮的手感(选中那档实线亮) */
|
||||
.proto-root .alt-pages.on .gopt:has(input:checked) { border: 1.6px solid var(--brand); background: var(--brand-wash); }
|
||||
/* 等级徽:三档三色(P2P 绿 · FUNC 蓝 · OTHER 橙) */
|
||||
/* gradechip 必须同时是 jc-vcell(2026-08-03 用户:「替代等级后面那个必填没对齐」)——
|
||||
* 上两行的必填胶囊住在 jc-vcell 的 6px 内距里,裸 gradechip 没这层盒,胶囊贴左又偏高。 */
|
||||
.proto-root .gradechip.v-p2p { color: var(--ok); font-weight: 800; }
|
||||
.proto-root .gradechip.v-func { color: var(--brand); font-weight: 800; }
|
||||
.proto-root .gradechip.v-other { color: var(--warn-accent); font-weight: 800; }
|
||||
|
||||
/* ── 方法区(固定说明,不进编辑容器)── */
|
||||
.proto-root .m-funnel { display: flex; gap: 8px; margin: 8px 0 10px; flex-wrap: wrap; }
|
||||
.proto-root .m-funnel .fs { flex: 1 1 180px; border: 1px solid var(--line-soft); border-left: 3px solid var(--brand-soft); border-radius: 8px; padding: 8px 10px; font-size: 11px; color: var(--ink-3); line-height: 1.5; background: var(--soft); }
|
||||
.proto-root .m-funnel .fs b { display: block; color: var(--ink-2); font-size: 11.5px; margin-bottom: 2px; }
|
||||
.proto-root .m-five { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0 10px; }
|
||||
.proto-root .m-five .c5 { flex: 1 1 130px; display: flex; align-items: center; gap: 8px; border: 1px solid var(--line-soft); border-radius: 8px; padding: 7px 9px; background: #fff; }
|
||||
.proto-root .m-five .c5 svg { flex: 0 0 auto; color: var(--brand); }
|
||||
.proto-root .m-five .c5 b { display: block; font-size: 11px; color: var(--ink-2); }
|
||||
.proto-root .m-five .c5 span { display: block; font-size: 9.5px; color: var(--ink-4); }
|
||||
|
||||
/* 窄屏:双栏叠置、蓝图竖排(媒体块排在基础规则之后,同权重后定义胜) */
|
||||
@media (max-width: 900px) {
|
||||
.proto-root .duo { grid-template-columns: 1fr; }
|
||||
.proto-root .duo .given { border-right: 0; border-bottom: 1px solid var(--line-soft); }
|
||||
.proto-root .archrow { flex-direction: column; gap: 8px; }
|
||||
.proto-root .archrow .why { border-left: 0; padding-left: 0; border-top: 1px dashed var(--line-soft); padding-top: 8px; }
|
||||
.proto-root .blueprint { flex-direction: column; }
|
||||
.proto-root .bp-mid svg { transform: rotate(90deg); width: 46px; height: 46px; margin: 0 auto; }
|
||||
}
|
||||
|
||||
/* 「必填」空槽轻呼吸 —— 连 @keyframes 一起包在动效许可里,减弱设置下全静 */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.proto-root .bp-to .socket-pn:empty::after { animation: jc-breath 2.2s ease-in-out infinite; }
|
||||
@keyframes jc-breath { 0%,100% { opacity: 1; } 50% { opacity: .55; } }
|
||||
}
|
||||
`;
|
||||
|
||||
/** 原厂芯片小像(方体 + 双侧引脚 + 一脚圆点) */
|
||||
const CHIP_FIG = `<svg width="96" height="64" viewBox="0 0 96 64" fill="none" stroke-linecap="round" class="chipfig">
|
||||
<line class="cf-pin" x1="8" y1="18" x2="22" y2="18"/><line class="cf-pin" x1="8" y1="28" x2="22" y2="28"/><line class="cf-pin" x1="8" y1="38" x2="22" y2="38"/><line class="cf-pin" x1="8" y1="48" x2="22" y2="48"/>
|
||||
<line class="cf-pin" x1="74" y1="18" x2="88" y2="18"/><line class="cf-pin" x1="74" y1="28" x2="88" y2="28"/><line class="cf-pin" x1="74" y1="38" x2="88" y2="38"/><line class="cf-pin" x1="74" y1="48" x2="88" y2="48"/>
|
||||
<rect class="cf-body" x="22" y="10" width="52" height="44" rx="6" stroke-width="1.6"/>
|
||||
<circle class="cf-dot" cx="31" cy="19" r="2.5"/>
|
||||
</svg>`;
|
||||
|
||||
/** 蓝图带:原厂芯片 → 五道核对 → 替代插座(空=虚线红槽,定了=实线绿槽) */
|
||||
const BLUEPRINT = `<div class="blueprint">
|
||||
<div class="bp-box bp-from"><em>原厂芯片</em>${CHIP_FIG}</div>
|
||||
<div class="bp-mid">
|
||||
<svg viewBox="0 0 240 46" fill="none">
|
||||
<path class="lane" d="M6 23h206"/>
|
||||
<circle class="gate" cx="38" cy="23" r="5"/><circle class="gate" cx="76" cy="23" r="5"/><circle class="gate" cx="114" cy="23" r="5"/><circle class="gate" cx="152" cy="23" r="5"/><circle class="gate" cx="190" cy="23" r="5"/>
|
||||
<path class="tipp" d="M216 15l16 8-16 8z"/>
|
||||
</svg>
|
||||
<div class="cap">替代路径要穿过五道核对(封装 · 引脚 · 电气 · 温度 · 认证)</div>
|
||||
</div>
|
||||
<div class="bp-box bp-to"><em>替代定案</em>
|
||||
<svg width="96" height="64" viewBox="0 0 96 64" fill="none" stroke-linecap="round">
|
||||
<rect class="sk-body" x="10" y="8" width="76" height="48" rx="8" stroke-width="1.8"/>
|
||||
<path class="sk-slot" d="M28 22h40M28 32h40M28 42h24" stroke-width="2"/>
|
||||
</svg>
|
||||
<span class="socket-pn jc-mono" data-slot="alt_pn" data-blank></span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
/** 等级三选(radio 不写 checked;当前档由 gopts 上的投影描虚线) */
|
||||
const GRADE_OPTS = `<div class="gopts" data-class-of="alt_grade">
|
||||
<div class="gopt gp"><input type="radio" name="alt_grade_pick" value="P2P" class="jc-cell" data-col="alt_grade"><b>P2P</b><em>同封装同引脚 · 可直换 —— 五道核对逐项过了再选,同封装不等于同引脚</em>${ico('pins', 15)}</div>
|
||||
<div class="gopt gf"><input type="radio" name="alt_grade_pick" value="FUNC" class="jc-cell" data-col="alt_grade"><b>FUNC</b><em>功能兼容 —— 需外围电路或 layout 调整;差多少、怎么改,写进下面两格</em>${ico('gear', 15)}</div>
|
||||
<div class="gopt go"><input type="radio" name="alt_grade_pick" value="OTHER" class="jc-cell" data-col="alt_grade"><b>OTHER</b><em>部分兼容 —— 须打样验证;怎么验写进验证建议</em>${ico('flask', 15)}</div>
|
||||
</div>`;
|
||||
|
||||
/** 一页 = 一颗待选型料的选型档案(行模版;只有 orig_type=C 的行成页) */
|
||||
const SHEET_PAGE = `<table class="alt-pages"><tbody data-rows="mine:quote_alternative.list" data-edit="quote_alternative.list">
|
||||
<tr class="alt-page" data-page data-class-of="orig_type"><td class="alt-cell">
|
||||
<div class="archrow">
|
||||
<div class="chip-id"><em>原厂 · 被替换</em><span class="pn jc-mono" data-slot="pn"></span></div>
|
||||
<div class="why"><i>${ico('history')}为什么要替代 <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="这颗不在本轮选型(剔除)">× 剔除</button>
|
||||
</div>
|
||||
${BLUEPRINT}
|
||||
<div class="duo">
|
||||
<div class="given">
|
||||
<h5>${ico('file-text', 12)}题面 · 客户给的</h5>
|
||||
<div class="fld"><i>客户料号</i><span class="jc-vcell jc-mono dimv" data-slot="customer_pn" data-blank></span></div>
|
||||
<div class="fld"><i>品类</i><span class="jc-vcell dimv" data-slot="category" data-blank></span></div>
|
||||
<div class="fld"><i>制造商</i><span class="jc-vcell dimv" data-slot="brand" data-blank></span></div>
|
||||
<div class="fld"><i>年用量</i><span class="jc-vcell jc-mono dimv" data-slot="eau" data-blank></span></div>
|
||||
<div class="fld"><i>目标价</i><span class="jc-vcell jc-mono dimv" data-slot="target_price" data-blank></span></div>
|
||||
<div class="fld"><i>交期要求</i><span class="jc-vcell dimv" data-slot="lead_time" data-blank></span></div>
|
||||
</div>
|
||||
<div class="decide">
|
||||
<h5>${ico('pen', 12)}替代定案 · 你的产出</h5>
|
||||
<div class="fld keyrow"><i>原厂 PN(行键)</i><span class="jc-vcell jc-mono" data-slot="pn" data-col="pn"></span></div>
|
||||
<div class="fld"><i>替代料 PN</i><span class="jc-vcell jc-mono ph-req" data-slot="alt_pn" data-col="alt_pn" data-blank></span></div>
|
||||
<div class="fld"><i>替代料品牌</i><span class="jc-vcell ph-req" data-slot="alt_brand" data-col="alt_brand" data-blank></span></div>
|
||||
<div class="fld"><i>替代等级</i><span class="jc-vcell gradechip ph-req" data-class-of="alt_grade" data-slot="alt_grade" data-blank></span></div>
|
||||
${GRADE_OPTS}
|
||||
<div class="fld"><i>参数差异项</i><span class="jc-vcell ph-sug" data-slot="alt_diff" data-col="alt_diff" data-blank></span></div>
|
||||
<div class="fld"><i>风险与改动</i><span class="jc-vcell ph-sug" data-slot="alt_risk" data-col="alt_risk" data-blank></span></div>
|
||||
<div class="fld"><i>验证建议</i><span class="jc-vcell ph-sug" data-slot="alt_verify" data-col="alt_verify" data-blank></span></div>
|
||||
<div class="fld"><i>选型备注</i><span class="jc-vcell ph-opt" data-slot="alt_note" data-col="alt_note" data-blank></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</td></tr>
|
||||
</tbody></table>`;
|
||||
|
||||
export const ALT_WORKSHEET = doc({
|
||||
badge: 'boxes',
|
||||
kicker: '统一报价 · A2 替代选型 · 选型作业',
|
||||
title: '替代选型工单',
|
||||
titleRef: 'rfq_ref',
|
||||
state: '在办 · 逐颗定案',
|
||||
css: SHEET_CSS,
|
||||
body:
|
||||
kvCard('本单标的', [
|
||||
['原型号', 'originalPn', 'mono'],
|
||||
['芯片类型', 'chipType'],
|
||||
['当前阶段', 'activeStage', 'tag'],
|
||||
['当下可为', 'enabledCount'],
|
||||
['目标缺口', 'gapCount'],
|
||||
]) +
|
||||
stagesCard('阶段(CMMN)', 'case:stages', '阶段推进到 <b>Enabled</b> 为止;触达世界的计划项须人工启动。') +
|
||||
stagesCard('当下可为', 'case:enabled') +
|
||||
stagesCard('目标缺口', 'case:gaps', '缺口决定下一步是调人还是立监听。') +
|
||||
noteCard(
|
||||
'跨人卡',
|
||||
'选型常要向 BD 补客户口径 —— 那是<b>跨任务征答</b>:卡以系统消息送达对方的消息要素、<b>就地作答</b>,' +
|
||||
'不占对方的任务清单,也不开放本任务的对话流。它是一次性的解答请求,不是责任转移。',
|
||||
),
|
||||
`<div class="jc-derive">${ico('info', 14)}
|
||||
<span>这份工单只办一件事:给每颗<b>待选型(原标 C)</b>的料定一颗替代 —— 客户
|
||||
<b data-slot="customer_name"></b> 本单待选型 <b data-slot="c_total"></b> 颗,
|
||||
一颗一页(右上角翻页)。每页上半是<b>题面</b>(客户给的,改不了),下半右栏是<b>你的定案</b>;
|
||||
定案保存后,查库判稳与转标(≤90 天且行情稳转 A,否则转 B 去寻源)由系统接办。
|
||||
保存只落任务草稿层;交卷在对话区的执行卡上。</span>
|
||||
</div>` +
|
||||
part('替代定案 · 一颗一页', '可就地修订') +
|
||||
`<div class="jc-zone z-edit">
|
||||
<div class="jc-gapbar" data-class-of="quote_alternative_list_missing">${ico('alert', 14)}
|
||||
<span><b>定案还差 <span data-slot="quote_alternative_list_missing"></span> 格必填</b>
|
||||
—— <span data-slot="quote_alternative_list_missing_keys"></span>。
|
||||
翻到那一颗,按「编辑」开闸就地填;补齐了执行卡上才交得了卷。</span>
|
||||
</div>
|
||||
<div class="jc-editgate" data-edit-for="quote_alternative.list">
|
||||
<span class="jc-editgate-t">${ico('lock', 12)}替代定案<b>已锁定</b> —— 看的时候不误碰,要改按右边</span>
|
||||
<button type="button" class="jc-egbtn open" data-edit-open="quote_alternative.list">${ico('pen', 12)}编辑</button>
|
||||
<button type="button" class="jc-egbtn ghost" data-edit-cancel="quote_alternative.list">取消</button>
|
||||
<button type="button" class="jc-egbtn save" data-edit-save="quote_alternative.list">${ico('check', 12)}保存</button>
|
||||
</div>
|
||||
<div class="jc-editbar">${ico('pen', 14)}
|
||||
<span>定案三格是<b>必填</b>(替代料 PN / 品牌 / 等级),等级直接点三档之一;
|
||||
这颗料整个不该选型,按档案头右上角的 × 剔除(划着摆在那儿,可后悔);
|
||||
漏了一颗,用底下的「补一颗待选型料」。改动只落任务草稿层,<b>保存不等于放行</b>。</span>
|
||||
</div>
|
||||
${SHEET_PAGE}
|
||||
<div class="jc-addrow">
|
||||
<button type="button" class="jc-egbtn ghost" data-edit-add="quote_alternative.list">${ico('plus', 12)}补一颗待选型料</button>
|
||||
<em>照末页添一页空档案;行键(原厂 PN)填了才存得下,不填这一页不落库。</em>
|
||||
</div>
|
||||
</div>` +
|
||||
part('选型方法 · 凭什么这么选', '替代等级口径') +
|
||||
`<div class="m-five">
|
||||
<div class="c5">${ico('ruler', 16)}<div><b>封装尺寸</b><span>本体 / 脚距 / 高度</span></div></div>
|
||||
<div class="c5">${ico('pins', 16)}<div><b>引脚定义</b><span>逐脚对数据手册</span></div></div>
|
||||
<div class="c5">${ico('bolt', 16)}<div><b>电气参数</b><span>供电 / 精度 / 速度</span></div></div>
|
||||
<div class="c5">${ico('thermo', 16)}<div><b>温度等级</b><span>工作温区不降级</span></div></div>
|
||||
<div class="c5">${ico('shield-check', 16)}<div><b>认证</b><span>车规 / 医疗不降级</span></div></div>
|
||||
</div>
|
||||
<div class="m-funnel">
|
||||
<div class="fs"><b>① 参数硬指标</b>封装 / 引脚 / 电气 / 温度 —— 任一不满足直接淘汰,不进下一层。</div>
|
||||
<div class="fs"><b>② 认证与合规</b>车规 AEC-Q100/Q200、医疗、工业等级不可降级;客户点名禁用的品牌与型号不选。</div>
|
||||
<div class="fs"><b>③ 供应与价格</b>在产 / 货态 / 品牌接受度;客户预授权的候选优先。</div>
|
||||
</div>
|
||||
<div class="jc-drop">${ico('info', 13)}
|
||||
<span>一律以<b>原厂官网规格书</b>为准,不以商城参数表为准。没有把握的替代不填:
|
||||
宁可在选型备注写明「替代未果」的缘由后保存交回,由判稳一步按剔除处置 ——
|
||||
编一个候选顶上,是把风险埋进下游的报价单。是否接受非原厂品牌、参数降级,
|
||||
必须问客户,不得替客户决定。</span>
|
||||
</div>`,
|
||||
foot: 'FAE 逐颗定案(右上角翻页)—— 判稳与转标由系统查库接办',
|
||||
tag: '统一报价 · A2 替代选型 · 选型作业',
|
||||
});
|
||||
|
||||
/* ==================== 选型验收报告(收口清单 + 落边)==================== */
|
||||
|
||||
const ROSTER_CSS = `
|
||||
/* ── 收口总览:四格账 ── */
|
||||
.proto-root .statrow { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin: 2px 0 10px; }
|
||||
.proto-root .stat { border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px 9px; background: #fff; position: relative; overflow: hidden; }
|
||||
.proto-root .stat::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--line); }
|
||||
.proto-root .stat.sa::before { background: var(--ok); }
|
||||
.proto-root .stat.sb::before { background: var(--brand); }
|
||||
.proto-root .stat.sc::before { background: var(--warn-accent); }
|
||||
.proto-root .stat b { display: block; font-size: 24px; font-weight: 800; letter-spacing: -.4px; color: var(--ink); line-height: 1.15; }
|
||||
.proto-root .stat span { display: block; font-size: 10.5px; color: var(--ink-3); margin-top: 2px; }
|
||||
/* 「待选型未清」那格:0 才是收口该有的样子 —— 0 绿、非 0 红(判断在样式表) */
|
||||
.proto-root .stat.sc b.v-0 { color: var(--ok); }
|
||||
.proto-root .stat.sc b.v-1, .proto-root .stat.sc b.v-2, .proto-root .stat.sc b.v-3, .proto-root .stat.sc b.v-4, .proto-root .stat.sc b.v-5 { color: var(--danger); }
|
||||
/* 选型账一行:C 类怎么清零的 */
|
||||
.proto-root .workline { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; margin: 0 0 12px; padding: 8px 12px; border: 1px dashed var(--line); border-radius: 9px; background: var(--soft); font-size: 11.5px; color: var(--ink-3); }
|
||||
.proto-root .workline b { color: var(--ink); font-weight: 800; }
|
||||
.proto-root .workline .sep { color: var(--ink-4); }
|
||||
/* 剔除说明空着就整句收起(判断在样式表;有剔除才有这句话) */
|
||||
.proto-root .workline span:has(> b:empty) { display: none; }
|
||||
.proto-root .workline:has(span > b:empty) .sep:last-of-type { display: none; }
|
||||
|
||||
/* ── 清单表:序号计数器 + 归档空格「—」+ 底行小字(承继的第二行不出杠)── */
|
||||
.proto-root .jc-tbl-in tbody { counter-reset: rn; }
|
||||
.proto-root .jc-tbl-in td.jc-rn::before { counter-increment: rn; content: counter(rn); }
|
||||
.proto-root .jc-tbl-in td.dimempty > .jc-vcell:empty::after { content: "—"; color: var(--ink-4); }
|
||||
.proto-root .jc-tbl-in .sub2 { display: block; font-size: 10px; color: var(--ink-4); margin-top: 1px; }
|
||||
.proto-root .gchip { font-weight: 800; font-size: 11.5px; }
|
||||
.proto-root .gchip.v-p2p { color: var(--ok); }
|
||||
.proto-root .gchip.v-func { color: var(--brand); }
|
||||
.proto-root .gchip.v-other { color: var(--warn-accent); }
|
||||
.proto-root .gchip:empty::after { content: "—"; color: var(--ink-4); font-weight: 400; }
|
||||
|
||||
/* ── 落边图:清单 → 还有 B 类? → A3 / A4(命中边由壳上 v-r1/v-r2 上色,件不算不判)── */
|
||||
.proto-root .route { border: 1px solid var(--line); border-radius: 12px; padding: 12px 12px 8px; background: linear-gradient(180deg, #fff, var(--soft)); margin: 0 0 10px; }
|
||||
.proto-root .route svg { display: block; width: 100%; height: auto; }
|
||||
.proto-root .route .rnode { fill: #fff; stroke: var(--line); stroke-width: 1.4; }
|
||||
.proto-root .route .rdia { fill: #fff; stroke: var(--ink-3); stroke-width: 1.4; }
|
||||
.proto-root .route .redge { stroke: var(--line); stroke-width: 2; stroke-dasharray: 6 5; fill: none; }
|
||||
/* 已走过的共段(清单 → 判定):中性实线,不随命中变色 */
|
||||
.proto-root .route .rspine { stroke: var(--line); stroke-width: 2; fill: none; }
|
||||
.proto-root .route .rpulse { stroke: transparent; stroke-width: 2.5; fill: none; }
|
||||
.proto-root .route .rtxt { font-size: 12px; font-weight: 800; fill: var(--ink-2); }
|
||||
.proto-root .route .rsub { font-size: 9.5px; font-weight: 400; fill: var(--ink-4); }
|
||||
.proto-root .route .rcond { font-size: 10px; font-weight: 700; fill: var(--ink-4); }
|
||||
.proto-root .route .rtip { fill: var(--line); }
|
||||
/* 命中 r1:仍有 B 类 → A3 渠道寻源(蓝);未命中那条留灰虚线,不冒充判过 */
|
||||
.proto-root .route.v-r1 .e1 { stroke: var(--brand); stroke-dasharray: none; }
|
||||
.proto-root .route.v-r1 .n1 { stroke: var(--brand); stroke-width: 2; }
|
||||
.proto-root .route.v-r1 .t1 { fill: var(--brand-deep); }
|
||||
.proto-root .route.v-r1 .c1 { fill: var(--brand-deep); }
|
||||
.proto-root .route.v-r1 .k1 { fill: var(--brand); }
|
||||
/* 命中 r2:全 A → A4 成本定价(绿) */
|
||||
.proto-root .route.v-r2 .e2 { stroke: var(--ok); stroke-dasharray: none; }
|
||||
.proto-root .route.v-r2 .n2 { stroke: var(--ok); stroke-width: 2; }
|
||||
.proto-root .route.v-r2 .t2 { fill: var(--ok); }
|
||||
.proto-root .route.v-r2 .c2 { fill: var(--ok); }
|
||||
.proto-root .route.v-r2 .k2 { fill: var(--ok); }
|
||||
.proto-root .route .legend { display: flex; gap: 16px; flex-wrap: wrap; justify-content: center; margin: 6px 0 2px; font-size: 10.5px; color: var(--ink-3); }
|
||||
.proto-root .route .legend b { font-weight: 800; color: var(--ink-2); }
|
||||
|
||||
/* 结论带:一句人话结论 + 去向凭据 */
|
||||
.proto-root .concl { display: flex; gap: 12px; align-items: flex-start; border: 1px solid var(--line); border-left: 4px solid var(--brand); border-radius: 10px; padding: 10px 14px; background: #fff; margin: 0 0 12px; }
|
||||
.proto-root .concl svg { flex: 0 0 auto; margin-top: 2px; color: var(--brand); }
|
||||
.proto-root .concl .cl-main { flex: 1 1 auto; min-width: 0; font-size: 12px; line-height: 1.6; color: var(--ink-2); }
|
||||
.proto-root .concl .cl-main b { color: var(--ink); }
|
||||
.proto-root .concl .cl-route { flex: 0 0 auto; text-align: right; }
|
||||
.proto-root .concl .cl-route em { display: block; font-style: normal; font-size: 9.5px; font-weight: 800; letter-spacing: .1em; color: var(--ink-4); }
|
||||
.proto-root .concl .cl-route b { display: block; font-size: 14px; font-weight: 800; color: var(--brand-deep); margin: 2px 0; }
|
||||
.proto-root .concl .cl-route code { font-size: 9.5px; color: var(--ink-4); background: var(--soft); border: 1px solid var(--line-soft); border-radius: 5px; padding: 2px 6px; }
|
||||
|
||||
/* 窄屏(媒体块排在基础规则之后) */
|
||||
@media (max-width: 860px) {
|
||||
.proto-root .statrow { grid-template-columns: repeat(2, 1fr); }
|
||||
.proto-root .concl { flex-direction: column; }
|
||||
.proto-root .concl .cl-route { text-align: left; }
|
||||
}
|
||||
|
||||
/* 命中边的流光 —— 连 @keyframes 一起包在动效许可里,减弱设置下全静 */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.proto-root .route.v-r1 .p1, .proto-root .route.v-r2 .p2 { stroke-dasharray: 10 130; animation: jc-flow 2.6s linear infinite; }
|
||||
.proto-root .route.v-r1 .p1 { stroke: rgba(51,112,255,.65); }
|
||||
.proto-root .route.v-r2 .p2 { stroke: rgba(39,135,23,.6); }
|
||||
@keyframes jc-flow { to { stroke-dashoffset: -140; } }
|
||||
}
|
||||
`;
|
||||
|
||||
/** 清单表(只读;原料 → 原标 → 定案 → 现标 → 依据,一行一颗可回核) */
|
||||
const ROSTER_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
|
||||
<thead><tr>
|
||||
<th style="width:36px;min-width:36px"></th>
|
||||
<th style="min-width:190px">原厂料</th>
|
||||
<th style="width:44px;min-width:44px">原标</th>
|
||||
<th style="min-width:190px">替代定案</th>
|
||||
<th style="width:64px;min-width:64px">等级</th>
|
||||
<th style="width:44px;min-width:44px">现标</th>
|
||||
<th style="min-width:220px;width:100%">转标依据</th>
|
||||
</tr></thead>
|
||||
<tbody data-rows="mine:quote_alternative.list"><tr>
|
||||
<td class="jc-rn"></td>
|
||||
<td class="dimempty" data-h="原厂料"><span class="jc-vcell jc-mono" data-slot="pn" data-blank></span><span class="sub2" data-slot="brand" data-blank></span></td>
|
||||
<td class="jc-ty" data-h="原标"><span class="jc-tbadge" data-class-of="orig_type" data-slot="orig_type"></span></td>
|
||||
<td class="dimempty" data-h="替代定案"><span class="jc-vcell jc-mono" data-slot="alt_pn" data-blank></span><span class="sub2" data-slot="alt_brand" data-blank></span></td>
|
||||
<td data-h="等级"><span class="gchip" data-class-of="alt_grade" data-slot="alt_grade" data-blank></span></td>
|
||||
<td class="jc-ty" data-h="现标"><span class="jc-tbadge" data-class-of="type_tag" data-slot="type_tag"></span></td>
|
||||
<td class="dimempty" data-h="转标依据"><span class="jc-vcell" data-slot="basis" data-blank></span></td>
|
||||
</tr></tbody>
|
||||
</table></div>`;
|
||||
|
||||
/** 落边图:两条整段 path(命中哪条哪条亮);共段是中性 spine */
|
||||
const ROUTE_FIG = `<div class="route" data-class-of="case:routeKey">
|
||||
<svg viewBox="0 0 660 210" fill="none">
|
||||
<path class="rspine" d="M148 100 H195"/>
|
||||
<path class="redge e1" d="M305 100 C355 100 380 50 460 50"/>
|
||||
<path class="rpulse p1" d="M305 100 C355 100 380 50 460 50"/>
|
||||
<path class="redge e2" d="M250 138 C250 172 340 168 460 168"/>
|
||||
<path class="rpulse p2" d="M250 138 C250 172 340 168 460 168"/>
|
||||
<path class="rtip k1" d="M448 44 l14 6 -14 6 z"/>
|
||||
<path class="rtip k2" d="M448 162 l14 6 -14 6 z"/>
|
||||
<rect class="rnode" x="8" y="70" width="140" height="60" rx="10"/>
|
||||
<text class="rtxt" x="78" y="95" text-anchor="middle">询价受理清单</text>
|
||||
<text class="rsub" x="78" y="113" text-anchor="middle">共 <tspan data-slot="quote_alternative_list_rows"></tspan> 条 · C 已清零</text>
|
||||
<path class="rdia" d="M250 62 L305 100 250 138 195 100 Z"/>
|
||||
<text class="rcond" x="250" y="104" text-anchor="middle">还有 B 类?</text>
|
||||
<text class="rcond c1" x="330" y="38">是 · B 类 <tspan data-slot="quote_alternative_list_type_tag_b"></tspan> 条</text>
|
||||
<text class="rcond c2" x="330" y="196">否 · 全 A 类</text>
|
||||
<rect class="rnode n1" x="460" y="18" width="170" height="64" rx="10"/>
|
||||
<text class="rtxt t1" x="545" y="45" text-anchor="middle">A3 · 渠道寻源</text>
|
||||
<text class="rsub" x="545" y="63" text-anchor="middle">补上 B 类的货源价格</text>
|
||||
<rect class="rnode n2" x="460" y="136" width="170" height="64" rx="10"/>
|
||||
<text class="rtxt t2" x="545" y="163" text-anchor="middle">A4 · 成本定价</text>
|
||||
<text class="rsub" x="545" y="181" text-anchor="middle">行情完备 · 直通核价</text>
|
||||
</svg>
|
||||
<div class="legend"><span>命中边实线亮 · 未命中留灰虚线</span><span>判定在你签字那一刻由系统现算 —— 要改去向,在验收卡的成果表上改现标</span></div>
|
||||
</div>`;
|
||||
|
||||
export const ALT_ROSTER = doc({
|
||||
badge: 'file-check',
|
||||
kicker: '统一报价 · A2 替代选型 · 交付成果',
|
||||
title: '询价受理清单',
|
||||
titleRef: 'rfq_ref',
|
||||
state: '收口 · 待放行',
|
||||
css: ROSTER_CSS,
|
||||
body:
|
||||
`<div class="jc-derive">${ico('info', 14)}
|
||||
<span>这份清单是 A2 的<b>收口成果</b>:上游受理记录逐条承继,待选型(原标 C)的料
|
||||
已由 FAE 逐颗定案,系统按库内行情判稳转标(≤90 天且行情稳 → A,否则 → B 去寻源)——
|
||||
询价受理记录就此转成<b>询价受理清单</b>,客户 <b data-slot="customer_name"></b>。
|
||||
本页只读:要改哪一格,在验收卡的成果表上就地改,这里跟着重渲。</span>
|
||||
</div>` +
|
||||
part('收口总览', 'C 类清零 · 判稳定标', 'ok') +
|
||||
`<div class="statrow">
|
||||
<div class="stat"><b data-slot="quote_alternative_list_rows"></b><span>清单条目 · 逐条承继上游</span></div>
|
||||
<div class="stat sa"><b data-slot="quote_alternative_list_type_tag_a"></b><span>A 类 · 行情完备</span></div>
|
||||
<div class="stat sb"><b data-slot="quote_alternative_list_type_tag_b"></b><span>B 类 · 待补货源价</span></div>
|
||||
<div class="stat sc"><b data-class-of="quote_alternative_list_type_tag_c" data-slot="quote_alternative_list_type_tag_c"></b><span>C 类 · 待选型未清(应为 0)</span></div>
|
||||
</div>
|
||||
<div class="workline">
|
||||
<span>待选型 <b data-slot="c_total"></b> 颗</span><span class="sep">→</span>
|
||||
<span>已填回替代 <b data-slot="filled_total"></b> 颗</span><span class="sep">→</span>
|
||||
<span>判稳转 A <b data-slot="converted_a_total"></b> 颗</span><span class="sep">·</span>
|
||||
<span>转 B 待寻源 <b data-slot="converted_b_total"></b> 颗</span><span class="sep">·</span>
|
||||
<span>剔除说明:<b data-slot="skipped_note" data-blank></b></span>
|
||||
</div>` +
|
||||
part('询价受理清单', '<b data-slot="quote_alternative_list_rows"></b> 条 · 交下游的就是这一份') +
|
||||
ROSTER_TABLE +
|
||||
`<div class="jc-drop">${ico('info', 13)}
|
||||
<span><b>原标</b>是上游受理时的三分类(留痕,本活动不改);<b>现标</b>是本活动的产物:
|
||||
C 类经替代定案与判稳转成 A 或 B。现标定去向 —— 改一颗料的现标,下面的落边判定跟着变。
|
||||
等级口径:<b>P2P</b> 同封装同引脚可直换 · <b>FUNC</b> 功能兼容需调整 · <b>OTHER</b> 部分兼容须打样验证。</span>
|
||||
</div>` +
|
||||
part('落边 · 这一签流向哪里', '出口按序求值 · 首个命中即出') +
|
||||
ROUTE_FIG +
|
||||
`<div class="concl">
|
||||
${ico('git-branch', 15)}
|
||||
<div class="cl-main"><b>选型结论</b> —— <span data-slot="summary"></span></div>
|
||||
<div class="cl-route"><em>转入活动</em><b data-slot="case:routeTo"></b><code data-slot="case:routeWhy"></code></div>
|
||||
</div>` +
|
||||
part('放行后即执行的落地动作', '门前 → 门后') +
|
||||
`<div class="jc-acts">
|
||||
<div class="jc-act"><i>${ico('check', 11)}</i>
|
||||
<div><b>草稿层合并进正式版</b> <span>本任务写下的询价受理清单与选型结论一并落账;
|
||||
你在验收卡上就地改的那几格随之落定。</span></div>
|
||||
</div>
|
||||
<div class="jc-act"><i>${ico('check', 11)}</i>
|
||||
<div><b>命中的那一口此刻才发射</b> <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('>', '>').replaceAll('<', '<').replaceAll('&', '&');
|
||||
}
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -1,61 +1,371 @@
|
||||
/**
|
||||
* A7 情报归档的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
|
||||
*
|
||||
* 2026-07-29 由步序声明(flow)改写而来。归档这件事没有分支:登记结果 → 更新画像 →
|
||||
* 统计落地率 → 验收。三件事互不依赖,同阶段并列(CMMN 里同阶段的项本就无隐含次序)。
|
||||
* 业务出处:`docs/MVP示例工作流.html` §1.6(S1 结果登记 → S2 画像更新 → S3 落地率统计),
|
||||
* 按 A4/A6 同一条形状重建(2026-08-03 重写 @3.0.0):
|
||||
*
|
||||
* 上游(A6 三口之一的载荷 `{rfqRef, customerCode, quoteNo, outcome, rows}`)进来 →
|
||||
* 归一成**情报卡**底表(一颗料一条:报价 / 成交价 / 结果 / 时间)→ **一张执行单**
|
||||
* 交 BD:逐条落**输赢归因**与竞对价(归因那一列 AI 一格都不写)→ 按败价幅度 A-01
|
||||
* 给复用建议 → **入库口径要人拍板**(决策卡:全量 / 脱敏 / 只留内部)→ 放行归档闭环。
|
||||
*
|
||||
* 上一版(@2.1.0)六处不合现行口径,这一版整个置换:
|
||||
* ① 入参必填 `rfqRef + customerCode + quoteNo` —— 而宽进严出是全线口径,归一在活动内做;
|
||||
* ② 两条判据读活动自报的 `reason_logged_total` / `profile_updated_total` ——
|
||||
* 「机检只读基座数得出来的数」违反史第七类:那个数模型自己写,写 1 就过;
|
||||
* ③ 台账一个 `required` 都没有 —— 执行卡的闸没牙,人不填也能交卷;
|
||||
* ④ **没有人的判断**:归档全由 AI 自己写完自己放行,而输赢归因恰恰是只有跟单的人
|
||||
* 才说得出的事(客户到底为什么不下单,系统里没有一个字段答得了);
|
||||
* ⑤ 三张共享域表(`market.price_history` / `customer.profile` / `shared.landing_stats`)
|
||||
* 同批退役,墓志铭在清单里;
|
||||
* ⑥ 那段 `run()` 删掉 —— 案例活动不会调它。
|
||||
*
|
||||
* 【为什么归档也要叫人】它是全流程最后一站,**这一单之后再没有人会回头看它**。
|
||||
* 归因写错没有下一道工序纠正,而它会一路流进下一单的判据里 ——
|
||||
* 「价格高」这种由 AI 从「客户没回」推出来的结论,三个月后没人记得它是编的。
|
||||
* 所以两处人:归因逐条落在执行单上,入库口径一张决策卡拍板。
|
||||
*/
|
||||
|
||||
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
|
||||
<case id="archive">
|
||||
<!--
|
||||
案卷项的 id **就是任务视图里的键名**。上游载荷(rows)与本活动台账列都摊平到
|
||||
任务视图顶层,照它们命名;quote_archive_* 那几个是**基座读数**
|
||||
(行数 / 缺必填 / 按值现数),由基座现算,模型碰不着。
|
||||
-->
|
||||
<caseFileModel>
|
||||
<caseFileItem id="result" name="报价结果"/>
|
||||
<caseFileItem id="history" name="历史价格情报"/>
|
||||
<caseFileItem id="profile" name="客户画像"/>
|
||||
<caseFileItem id="stats" name="报价落地率统计"/>
|
||||
<caseFileItem id="rows" name="上游条目(报价函行,流内开案即有)"/>
|
||||
<caseFileItem id="intel_total" name="情报卡条数(自报)"/>
|
||||
<caseFileItem id="collected_total" name="已归一落表的条数(归一走完的凭据)"/>
|
||||
<caseFileItem id="attributed_total" name="已落归因的条数(人环走完的凭据)"/>
|
||||
<caseFileItem id="scope_decided_total" name="入库口径已拍板"/>
|
||||
<caseFileItem id="quote_archive_intel_rows" name="情报卡行数(基座现数)"/>
|
||||
<caseFileItem id="quote_archive_intel_missing" name="缺归因的行数(基座现数)"/>
|
||||
<caseFileItem id="quote_archive_intel_share_scope_pending" name="入库口径还待定的条数(基座现数)"/>
|
||||
</caseFileModel>
|
||||
<casePlanModel id="plan" name="情报归档" autoComplete="true">
|
||||
<sentry id="s_result">
|
||||
<onPart id="op_rs" caseFileItemRef="result"><standardEvent>create</standardEvent></onPart>
|
||||
<!--
|
||||
① 归一完了 —— 交 BD 归因那一步的入场前提。
|
||||
行数两边对上(一边是基座现数),且归一那一轮走完(collected_total 是归一之后
|
||||
显式写下的数;「大于等于 0」对「写过的 0」为真、对「没写过」为假)。
|
||||
-->
|
||||
<sentry id="s_collected">
|
||||
<onPart id="op_cl" caseFileItemRef="collected_total"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_cl"><condition>intel_total > 0 and quote_archive_intel_rows = intel_total and collected_total >= 0</condition></ifPart>
|
||||
</sentry>
|
||||
<sentry id="s_logged">
|
||||
<onPart id="op_lg" caseFileItemRef="history"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_lg"><condition>reasonLogged = true</condition></ifPart>
|
||||
<!--
|
||||
② **归因齐了才轮到问入库口径** —— 这个活动的要害(与 A6「还价才叫人」同一形状)。
|
||||
|
||||
它读的是**人自己填的那一格**(缺归因的行数由基座按 required 现数,
|
||||
attributed_total 只在人交回执行单之后才产生),不是模型判出来的结论列。
|
||||
A6 两版都栽在同一个病根上 —— **「该不该叫人」不能由被叫的那一方说了算**。
|
||||
入库口径这一格也照同一条办:AI 归一时一律写 PENDING(过渡态),
|
||||
FULL / MASKED / INTERNAL 三个码**只有拍板那一步写得出来**;
|
||||
待定条数归零,这一步就不会再找人,放行门也才开得了。
|
||||
-->
|
||||
<sentry id="s_attributed">
|
||||
<onPart id="op_at" caseFileItemRef="attributed_total"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_at"><condition>attributed_total > 0 and quote_archive_intel_rows > 0 and quote_archive_intel_missing = 0 and quote_archive_intel_share_scope_pending > 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 > 0 and quote_archive_intel_rows > 0 and quote_archive_intel_missing = 0 and quote_archive_intel_share_scope_pending = 0 and quote_archive_intel_share_scope_full + quote_archive_intel_share_scope_masked + quote_archive_intel_share_scope_internal + quote_archive_intel_share_scope_pending = quote_archive_intel_rows</condition></ifPart>
|
||||
</sentry>
|
||||
|
||||
<stage id="S1" name="回写沉淀" autoComplete="true">
|
||||
<entryCriterion id="ec_s1" sentryRef="s_result"/>
|
||||
<humanTask id="T_log" name="人工登记结果(拒绝原因 / 竞对价位)">
|
||||
<itemControl><manualActivationRule id="m_lg"/></itemControl>
|
||||
<!-- ① 归一情报卡:一颗料一条,承继报价与结果,口径一律先落待定 -->
|
||||
<humanTask id="S1_collect" name="归一情报卡:逐条承继报价与结果,标上报价日期">
|
||||
<itemControl><repetitionRule id="r_s1"/></itemControl>
|
||||
</humanTask>
|
||||
<humanTask id="T_profile" name="人工更新客户画像">
|
||||
<entryCriterion id="ec_pf" sentryRef="s_logged"/>
|
||||
<itemControl><manualActivationRule id="m_pf"/></itemControl>
|
||||
</humanTask>
|
||||
<humanTask id="T_stats" name="人工统计落地率">
|
||||
<itemControl><manualActivationRule id="m_st"/></itemControl>
|
||||
</humanTask>
|
||||
</stage>
|
||||
<milestone id="M_archived" name="情报已回写"/>
|
||||
|
||||
<humanTask id="T_release" name="人工归档闭环验收放行">
|
||||
<!--
|
||||
② 逐条归因 —— **一张执行单**(输赢的缘由只有跟单的人说得出)。
|
||||
manualActivation + 不给 doneWhen(发卡触达真人,引擎不替人按)。
|
||||
-->
|
||||
<humanTask id="S2_attribute" name="逐条落输赢归因与竞对价:工作单上一条一格">
|
||||
<entryCriterion id="ec_s2" sentryRef="s_collected"/>
|
||||
<itemControl><manualActivationRule id="m_s2"/></itemControl>
|
||||
</humanTask>
|
||||
|
||||
<!--
|
||||
③ 入库口径拍板 —— 情报进不进共享库、要不要脱敏,这一下触达公司对外的边界:
|
||||
竞对报价与客户身份写进共享库之后,下一单谁都读得到。必须人工激活。
|
||||
-->
|
||||
<humanTask id="T_scope" name="入库口径拍板:这一单的情报按哪一档进库">
|
||||
<entryCriterion id="ec_sc" sentryRef="s_attributed"/>
|
||||
<itemControl><manualActivationRule id="m_sc"/><repetitionRule id="r_sc"/></itemControl>
|
||||
</humanTask>
|
||||
|
||||
<!-- 归档闭环放行:终局唯放行 —— 触达世界,必须人工激活 -->
|
||||
<humanTask id="T_release" name="归档闭环验收放行">
|
||||
<entryCriterion id="ec_rel" sentryRef="s_ready"/>
|
||||
<itemControl><manualActivationRule id="m_rl"/></itemControl>
|
||||
</humanTask>
|
||||
|
||||
<timerEventListener id="TL_half" name="时限过半">
|
||||
<timerExpression>PT12H</timerExpression>
|
||||
</timerEventListener>
|
||||
</casePlanModel>
|
||||
</case>
|
||||
</definitions>`;
|
||||
|
||||
export const NARRATIVE = `§0 开案:一单报价有了结果(成交 / 拒绝 / 超时)即开案。
|
||||
这件事不产生对外交付,它产生的是**下一单的本钱** —— 报价落地率、客户画像、竞对价位。
|
||||
/**
|
||||
* 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本,两处不重复写同一件事)。
|
||||
*/
|
||||
export const NARRATIVE = `§0 开案:**一单报价有了结果,把它沉淀成下一单用得上的情报**。来路两种,形状都收(宽进):
|
||||
· **流内转来**:任务数据里有 \`rows\` 数组 —— A6 的报价函行(每条带报价、客户表态与定案),
|
||||
外加 \`outcome\`(won / lost)说明这一单是成了还是丢了;这是最全的一种来路;
|
||||
· **单飞**:只有一段需求文字,说了哪一单、给谁报的、什么价、结果如何。
|
||||
|
||||
S1 回写沉淀:先登记结果 —— 拒绝的要写清原因与竞对价位,**「客户没回」不是原因**,
|
||||
问不出来就写「未获反馈」,不许编一个听着合理的理由。
|
||||
再按结果更新客户画像标签与等级(利润模型 P-01 的客户等级因子取的就是它),
|
||||
并把这一单计入当月落地率统计(TypeA 命中率、D+3 回复率一并算)。
|
||||
**成品只有一样**:一份**情报卡清单** —— 一颗料一条,每条都有**输赢归因**,
|
||||
每条都定了**入库口径**。人签的不是「归了个档」,是**这一单留下了什么、下一单谁用得上**。
|
||||
|
||||
§Z 结案:原因已回写、画像已更新、BD 签字放行,自动结案。
|
||||
归档做得好不好,不在这一单看得出来 —— 在下一次报价能不能一眼看到「上次为什么丢」看得出来。`;
|
||||
**这一单的价值全在下一单**:今天记下的败因,是下次定价时的判据;今天记下的成交价,
|
||||
是下次的基准。所以归档不是收尾的仪式,是情报生产。
|
||||
**「客户没回」不是原因** —— 问不出来就如实写「说不清」,**不许编一个听着合理的理由**:
|
||||
编出来的原因会一路流进下一单的判据里,而那时已经没人记得它是编的。
|
||||
|
||||
**能算的不问人,该人说的不代说**:败价幅度 A-01(价差 → 复用建议)、入库口径建议 A-02
|
||||
(归因 → 建议档位),两支判定表说了算,你不临场拍;而**输赢归因、竞对价、客户原话
|
||||
三列是人落的,你一格都不写** —— 客户到底为什么不下单,系统里没有一个字段答得了。
|
||||
|
||||
**三步走**:归一情报卡 → 交 BD 逐条归因 → 入库口径拍板 → 放行闭环。
|
||||
|
||||
**这一单要经过人两次**:BD 在情报归档工作单上逐条落归因(那张单绑着整份情报卡,
|
||||
归因缺一条按不下提交 —— 基座的闸看着,他填的格子直接落账);
|
||||
**入库口径另叫他拍一次板**(一张决策卡)。竞对报价与客户身份写进共享情报库之后,
|
||||
下一单谁都读得到 —— 这条边界不该由系统自己越过。最后 BD 放行签字。
|
||||
|
||||
**这是全流程最后一站**:放行之后没有下一个节点,只有一口 \`archived\`(归档闭环)。
|
||||
去向不必你选,也没得选。
|
||||
|
||||
【这一单只有一个岔路口,它长这样 —— 无论哪一拍都照这个发,别自己另编一张】
|
||||
入库口径要人拍板,那张决策卡**三处写死**:
|
||||
· \`"itemId":"T_scope"\` —— 英文计划项号。**别写中文名「入库口径拍板」**:中文名不是 id,
|
||||
写了当场被拒(【目标缺口】那一段摆的就是中文名,别照它抄);
|
||||
· \`"ask":"<N> 条情报待定入库口径,按哪一档进库?"\` —— 只把 N 换成条数,**别的字一个都不改**。
|
||||
换了问法,系统就认不出这是同一张卡:人已经点过的那张会被你再发一遍;
|
||||
· 三个 \`value\`:\`full\`(全量入库)、\`masked\`(脱敏入库)、\`internal\`(只留内部)。
|
||||
label 与 consequence 可以按本单的数改写,**value 不行**。
|
||||
**FULL / MASKED / INTERNAL 三个码只有拍板那一步写得出来** —— 归一那一步遇到情报一律落
|
||||
PENDING,替他写了口径,那张卡就永远不会发,这条边界成了摆设。
|
||||
拍板之前**别急着发卡也别绕路** —— 归一那一步先把情报卡写完、finish;
|
||||
下一拍引擎会把拍板那一步的做法摊开给你,那时再发。
|
||||
|
||||
【一路上要说话】负责人正看着这一单。**每一拍都带一句 say** —— 说业务的话:
|
||||
这一单最后怎么样、归了几条情报、下次遇到同类该注意什么;不说系统词。
|
||||
到了要找人的那一拍,先说清这一轮办完了什么、为什么现在需要他。
|
||||
|
||||
【怎么把一项干完 · 一拍连做】write 和 tool 都不会自动收步 —— 做完用 finish 宣告。
|
||||
动作排进同一拍的 actions 串一口气给;只有发卡 / 提交(挂起)必须停拍。
|
||||
**这一单常路 6-8 拍就该收口**。
|
||||
|
||||
§Z 结案:BD 在验收单上签字,系统把草稿合并成正式情报卡并发出 \`archived\` 那一口 ——
|
||||
这条工作流就此走到终点。归档做得好不好,不在这一单看得出来,
|
||||
在下一次报价能不能一眼看到「上次为什么丢」看得出来。`;
|
||||
|
||||
/**
|
||||
* 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。
|
||||
*
|
||||
* 三处不给 `doneWhen`:`S2_attribute`(发卡触达真人)、`T_scope`(要人拍板)、
|
||||
* `T_release`(签下去触达世界)—— 给了等于给引擎一把替人签字的钥匙。
|
||||
*/
|
||||
export const ARCHIVE_STEPS = [
|
||||
{
|
||||
item: 'S1_collect',
|
||||
intent: '整份归一成情报卡:逐条承继报价与结果、标上报价日期、口径一律先落待定',
|
||||
writes: ['quote_archive.intel', 'quote_archive.verdict'],
|
||||
/* 上游载荷数组不声明就会被整表段的截断刀砍掉(砍掉的恰是要归一的行) */
|
||||
reads: ['rows'],
|
||||
view: 'archive-worksheet',
|
||||
tools: [],
|
||||
/*
|
||||
* 归一走完才算办完:行数两边对上(一边是基座现数),
|
||||
* 且 collected_total 写过(S-FEEL 未绑定的名字参与比较为假,所以「没写过」进不来)。
|
||||
*/
|
||||
doneWhen: 'intel_total > 0 and quote_archive_intel_rows = intel_total and collected_total >= 0',
|
||||
guide: `第一件事:把这一单的**情报卡**落出来 —— 一拍连做,别拆成两拍。
|
||||
|
||||
【条目从哪儿来】
|
||||
· 流内转来(\`rows\` 里的行带 pn / quoted_price / reply_type / deal_status):照抄 ——
|
||||
报过的价、谈成的价都是既成事实,**一个数字都别改**;带 line_total 这类总数的是结论行,
|
||||
不是料,别落进情报卡。
|
||||
· 单飞:照正文归一,列了几颗落几行;正文里说的报价与结果就是要沉淀的那一份。
|
||||
**按 pn 归并,一颗料一行**;pn 一个字符不许改(它是主键,改一个字就是新开一行)。
|
||||
|
||||
【写 \`quote_archive.intel\`,一颗料一行】(rows 收数组)
|
||||
rfq_ref(入参有就原样抄;没有从主题认;认不出才自己造一个,**造出来的必须以 \`ARC-\` 开头**
|
||||
(\`ARC-<主题或第一颗料的型号>\`)。两条硬规矩:
|
||||
**① 整单只有一个单号,不是一颗料一个** —— 它是这一单的案号,情报卡所有行、verdict 那一行
|
||||
用的都得是同一个串(实测栽过:两颗料造出两个号,一单裂成两单);
|
||||
**② 只从本单来件取材** —— 台账里躺着的 RFQ-xxxx 是别人的单,借它的号等于把两单的行
|
||||
混进同一个案卷(实测栽过一轮))、pn、customer_code、customer_name、category、brand、eau、
|
||||
**quoted_price**(我方报过的价,原样抄)、**final_price**(成交了才有;没成交留空)。
|
||||
另两列**照这个口径落**:
|
||||
· **deal_result** —— 这一条的结果,只许写 \`WON\`(成交)或 \`LOST\`(不成交)两个大写码之一。
|
||||
上游给了 outcome 就照它:won → WON,lost / revise → LOST;
|
||||
逐行还带着 deal_status 的,以行上的为准(WON → WON,其余一律 LOST)。
|
||||
认不出就按 **LOST** 落 —— 归档里不许把说不清的算成成交。
|
||||
· **quoted_at** —— 报价日期,写成 \`YYYY-MM-DD\`。来件里有报价日就抄它,没有就写今天。
|
||||
这一格是下一单查「距上次报价多久」的那把尺,别留空。
|
||||
|
||||
【**这三列你一格都不写**:cause_tag / competitor_price / evidence】
|
||||
输赢归因、竞对报的那个价、客户原话 —— 这三样只有跟单的人知道,**系统不替人归因**。
|
||||
它们是 BD 在工作单上落的;price_gap_band 那一列要等竞对价回来才算得了,这一步也别写。
|
||||
这不是客气话:一条要你分辨「这行能不能替人填」的规矩会走岔;
|
||||
**「这几列不写」没有分支可走岔。**
|
||||
|
||||
【**share_scope 这一列一律写 \`PENDING\`**】入库口径是人在决策卡上拍的,
|
||||
\`FULL\` / \`MASKED\` / \`INTERNAL\` 三个码这一步一个都不许写 —— 替他写了,那张卡就永远不会发。
|
||||
|
||||
【同一拍写 \`quote_archive.verdict\`(一行)】
|
||||
rfq_ref、**quote_no**(报价单号:来件有就抄,没有写 \`Q-\` 开头 + 本单案号的尾段,整单一个)、
|
||||
customer_code / customer_name(顶层有就抄)、**outcome**(整单结果:有一条 WON 就写 \`won\`,
|
||||
一条都没有就写 \`lost\`)、**intel_total** = 情报卡行数、**collected_total** = 这一拍归一落表的
|
||||
条数(0 也要写)、summary 一句人话(几条料、给谁报的、最后成没成)。
|
||||
写完 finish 这一项。`,
|
||||
},
|
||||
{
|
||||
item: 'S2_attribute',
|
||||
intent: '一张执行单交 BD:逐条落输赢归因、竞对价与客户原话',
|
||||
writes: ['quote_archive.intel', 'quote_archive.verdict'],
|
||||
reads: ['quote_archive.intel'],
|
||||
view: 'archive-worksheet',
|
||||
tools: [],
|
||||
/* 不给 doneWhen —— 发卡触达真人(manualActivation),引擎不替人按 */
|
||||
guide: `情报卡落好了,**该 BD 出场了** —— 这是本活动唯一的一张执行单。
|
||||
输赢的缘由只有跟单的人说得出:客户到底为什么不下单、竞对报了多少、他原话怎么说的。
|
||||
工作区摆的是**情报归档工作单**:逐颗料一张情报档案卡(报价、结果、客户约束),
|
||||
归因六档与败价幅度的尺挂在上面,填回区长在同一屏 —— 他在「输赢归因」那一列逐行落。
|
||||
|
||||
这一步标了人工激活:发卡就算启动,不必单发 start;引擎也不会替你收 ——
|
||||
人交回后要你自己 finish。
|
||||
|
||||
【照这个骨架发,别少字段】(卡面是纯文本,steps/standard 里别写星号记号)
|
||||
{"kind":"human","itemId":"S2_attribute","workOrder":"execution","card":{
|
||||
"title":"情报归因:<单号> 共 <N> 条待归因",
|
||||
"ask":"这一单的情报卡已按报价与结果归好,报价、成交价与日期都在工作单上。请逐条落输赢归因:输在价格 / 交期 / 认证 / 规格 / 关系,还是说不清;输在价格的把竞对报的那个价也填上,并把客户原话记进依据一栏。",
|
||||
"steps":["归因只有六档,选最主要的那一个:PRICE 价格 · LEADTIME 交期 · CERT 认证资质 · SPEC 规格不匹配 · RELATION 关系或既有供应商 · OTHER 说不清",
|
||||
"「客户没回」不是原因 —— 问不出来就落 OTHER 并在依据里写明「未获反馈」,不要编一个听着合理的理由:它会一路流进下一单的判据里",
|
||||
"归因落 PRICE 的行必须填竞对价:没有那个数,「输在价格」下一单读不出任何东西 —— 缺这一格这张单按不下提交",
|
||||
"依据一栏尽量记客户原话:「你们比 XX 贵两毛」比「价格偏高」值钱得多 —— 下一单定价时看的就是这一句"],
|
||||
"standard":"整份情报卡每一条都有归因,归因落 PRICE 的那几条填了竞对报的价 —— 缺一条这张单按不下提交(系统的闸看着)。",
|
||||
"basis":[{"k":"<PN>","v":"<报价 · 成交价 · 结果 · 报价日期>"}]}}
|
||||
· \`basis\` 逐条列本单的料:PN + 报价与结果。几颗写几条,别铺全表。
|
||||
· **卡上不摆表、不摆格子**(执行卡零输入):整份情报卡就在工作单那份件上,
|
||||
归因、竞对价、竞对名、依据四列都能就地改、行能撤 —— **别往卡里塞 \`blocks\` 或 \`fields\`**。
|
||||
· **这张卡带提交闸**:归因(必填列)还空着,他按不下提交 —— 缺几格、缺在哪行,
|
||||
闸上与件上的红带都写着(与放行门 L1 同一个数同一套算法)。
|
||||
发完卡这一拍就结束了(发卡挂起)。
|
||||
|
||||
【人交回之后 —— 他落的归因基座已经替你写回台账了】这一步你只做三件事,然后 finish:
|
||||
① **算 price_gap_band**(只给归因是 PRICE 且填了竞对价的那几行):按败价幅度 A-01,
|
||||
价差 gap =(quoted_price − competitor_price)/ quoted_price,只许写这三个大写码之一:
|
||||
· gap ≤ 0.03 → **SLIGHT**(擦肩而过:下一单同价位再试,输的多半不在价格);
|
||||
· 0.03 < gap ≤ 0.10 → **MODERATE**(有差距但够得着:下一单让到这一档或换替代料降本);
|
||||
· gap > 0.10 → **STRUCTURAL**(结构性价差:下一单别再报同一价位,先走替代选型)。
|
||||
不是 PRICE 的行、或没有竞对价的行,这一格给空串 \`""\`,**不许写 0 也不许编一个码**。
|
||||
② 写 verdict:**attributed_total** = 已落归因的条数(照情报卡行数记);
|
||||
他在件上撤掉了行的话,**intel_total 跟着改成撤后的实数**。
|
||||
③ finish 这一项。
|
||||
**别在这一拍写入库口径** —— 那是下一步人拍板的事(它有自己的一段做法),这里写了也白写。
|
||||
情报卡其余列你一列都不用重写:他落的值基座已经落进台账了,重写会被「写了等于没写」拒回。
|
||||
|
||||
【这张卡一单只发一次】人交回来之后不许再发同一张卡 —— 必填有提交闸兜着,
|
||||
卡回来时归因一定是齐的(或那几行已被他撤掉)。`,
|
||||
},
|
||||
{
|
||||
item: 'T_scope',
|
||||
intent: '入库口径要人定 —— 发一张决策卡,照他选的那一档把待定口径改掉',
|
||||
writes: ['quote_archive.intel', 'quote_archive.verdict'],
|
||||
reads: ['quote_archive.intel'],
|
||||
view: 'archive-roster',
|
||||
tools: [],
|
||||
/* 人工激活 + 要人拍板 —— 不给 doneWhen,引擎不许替人收 */
|
||||
guide: `**这一单只发一张决策卡。动手之前先在轨迹里找一行 \`[card-reply]\` 带「人选了:」:**
|
||||
|
||||
· **找到了** —— 跳到下面【人点完之后】那一段,照他选的那一档把那几行的 share_scope
|
||||
从 PENDING 改掉、写 scope_decided_total、finish。**一个 decide 都不许再发**
|
||||
(实测栽过:人点完了,而这一步又从头念了一遍「发一张决策卡」,同一张卡连发六次,
|
||||
前两次无事发生、后四次被拒 —— 他点过的那一下就白点了);
|
||||
· **没找到** —— 才轮到下面【发一张决策卡】那一段。
|
||||
|
||||
【这一步办完了没有,看一个数】待定口径条数 quote_archive_intel_share_scope_pending:
|
||||
**它归零就是这一步办完了**。PENDING 是过渡态(情报归好了、还没定进哪一档库),
|
||||
拍板那一下必须把它换成 FULL / MASKED / INTERNAL 之一 —— 三档都是结论。
|
||||
这个数不归零,放行门开不了;它归零,这一步也就不会再找你。
|
||||
|
||||
这一步只在**归因齐了**(基座现数 quote_archive_intel_missing = 0 且人交回过执行单)时入场 ——
|
||||
读的是人自己填的那一格,不是你判的结论:该不该叫人,不由被叫的那一方说了算。
|
||||
它触达公司对外的边界(竞对报价与客户身份进了共享库,下一单谁都读得到),标了人工激活:
|
||||
发卡就算启动,不必单发 start。
|
||||
工作区这时摆的是**情报归档清单**,整份情报卡与归因分布都在上面,他看得见全局再拍板。
|
||||
|
||||
【发一张决策卡,照这个骨架】(选项少于两个基座拒发)
|
||||
**三处一个字都不许改**(与说明书里那一段是同一份):
|
||||
· \`itemId\` 只写英文计划项号 \`T_scope\` —— **别写中文名「入库口径拍板」**,
|
||||
中文名不是 id,写了当场被拒(实测栽过两拍:「计划项不存在:异议拍板」);
|
||||
【目标缺口】那一段摆的是中文名,它不是 id,别照它抄。
|
||||
· \`ask\` 照抄「<N> 条情报待定入库口径,按哪一档进库?」,**只把 N 换成条数** ——
|
||||
换了问法系统就认不出这是同一张卡,人已经点过的那张会被你再发一遍(实测发出去两张)。
|
||||
· 三个 \`value\` 照抄 \`full\` / \`masked\` / \`internal\` —— 它们是三档的代号,
|
||||
换了名字下一段的对应关系就对不上,你自己也不知道人点的是哪一档。
|
||||
label 与 consequence 可以按本单的数改写,value 不行。
|
||||
{"kind":"decide","itemId":"T_scope","ask":"<N> 条情报待定入库口径,按哪一档进库?",
|
||||
"options":[
|
||||
{"value":"full","label":"全量入库(客户与竞对都留名)","consequence":"下一单查这颗料时,客户是谁、竞对是谁、报了多少一目了然 —— 情报最值钱,但客户身份与第三方报价也就此对内公开"},
|
||||
{"value":"masked","label":"脱敏入库(只留价位与败因)","consequence":"竞对与客户的名字抹掉,价位、归因、日期照留 —— 下一单照样判得了价,追不到是谁说的"},
|
||||
{"value":"internal","label":"只留内部(不进共享情报库)","consequence":"这一单的情报只在本案卷里躺着,下一单查不到 —— 涉密或客户明说不外传时才选这一档"}],
|
||||
"basis":[{"k":"<PN>","v":"归因 <码> · 报价 <X> · 竞对价 <Y> · 败价幅度 <码>"}]}
|
||||
· basis 逐条摆本单的料:归因、报价、竞对价、败价幅度 —— 裸卡基座拒发。
|
||||
· **recommended 按入库口径建议 A-02 标**,只标一项:归因里有 PRICE 的(带着第三方报价)
|
||||
标 \`masked\`;一条 PRICE 都没有的标 \`full\`。
|
||||
· 发完这一拍就结束了(发卡挂起)。
|
||||
|
||||
【人点完之后】轨迹上那一行长这样:\`[card-reply] …人选了:xxx\`。**照那一档把口径改掉,
|
||||
别再发第二张卡**,一拍连做 —— **整份情报卡逐行都要动 share_scope**,一条都不许留在 PENDING:
|
||||
· 选 full:每行 share_scope 改 **FULL**;verdict 的 share_policy 写 \`full\`;
|
||||
· 选 masked:每行 share_scope 改 **MASKED**;verdict 的 share_policy 写 \`masked\`;
|
||||
· 选 internal:每行 share_scope 改 **INTERNAL**;verdict 的 share_policy 写 \`internal\`。
|
||||
然后把 verdict 写全:**scope_decided_total 记 1**、**buyer_profile** 一句客户行为画像
|
||||
(照这一单看得出来的写:价格敏感 / 决策快慢 / 认证门槛 / 常用条款,是 MVP §1.6 S2 那一步的产物)、
|
||||
summary 补一句拍板结果,finish 这一项。
|
||||
改完之后待定口径条数就是 0 了,这一步再也不会找你。`,
|
||||
},
|
||||
{
|
||||
item: 'T_release',
|
||||
intent: '情报已沉淀、口径已定,提交放行交 BD 验收',
|
||||
writes: [],
|
||||
reads: [],
|
||||
view: 'archive-roster',
|
||||
tools: [],
|
||||
/* 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 doneWhen) */
|
||||
guide: `每条都有归因、口径全定过、待定清零 —— 可以走放行了
|
||||
(T_release 人工激活:动它就算启动,不必单发 start)。先把裁量判据裁掉,然后 submit,同一拍连做:
|
||||
|
||||
· judge criterion="情报站得住:每条归因说得出来路、竞对价与客户原话可溯,BD 对这份情报负责" ——
|
||||
看情报卡里每一行的归因与依据:说得出来路就 satisfied=true;
|
||||
有行说不出的(比如归因落了 PRICE 却没有客户原话),satisfied=**false**,rationale 逐行写清,
|
||||
交签字的人定。**裁成不满足照样能提交** —— 理由连着卡一起交给他。
|
||||
criterion 照判据名**整句原样抄**,写别的名字对不上。
|
||||
|
||||
**去向没得选**:这是全流程最后一站,只有一口 \`archived\`(归档闭环),放行那一刻自动发射。
|
||||
你要做的只是把情报落准,别在提交里手选出口。
|
||||
|
||||
终局唯放行:你不能自己宣布完成。`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段;
|
||||
* 放行那一格由基座统一加,人看到的是 x/4。
|
||||
*/
|
||||
export const ARCHIVE_PHASES = [
|
||||
{ name: '归一情报卡', items: ['S1_collect'] },
|
||||
{ name: '逐条归因', items: ['S2_attribute'] },
|
||||
{ name: '入库口径拍板', items: ['T_scope'] },
|
||||
];
|
||||
|
||||
@@ -1,37 +1,18 @@
|
||||
/**
|
||||
* 统一报价 A7 · 情报归档 —— 活动包代码入口(ADR-004)。
|
||||
* 「不成交也留资产」:把归档结果摆给人看,确认无空档再提交放行。
|
||||
* 统一报价 A7 · 情报归档 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
|
||||
*
|
||||
* **这个活动没有 `run`,不是缺件。**
|
||||
*
|
||||
* 案例活动的推进由 Loop 驱动,行为全部写在清单里:案例模型 + 说明书 + 步骤剧本 +
|
||||
* 两把判定表 + 技能 + 台账声明 + 两张 HTML 报告模版。
|
||||
* 上一版这里有一段 `run()`(读 `shared.history_price`、推一个 kv 块、按行数返回
|
||||
* await/submit)—— 案例活动根本不会调它;更坏的是它读的那张表**从来没被声明过**
|
||||
* (声明的是 `market.price_history`),而它推的那套呈现与 `viewTemplates` 里的模版
|
||||
* 各说各话,没人看得出以哪份为准。
|
||||
*
|
||||
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 —— 它是**被静态审扫描的产物文本**
|
||||
* (查 import 与全局引用,证明活动包零环境能力)。
|
||||
*/
|
||||
import { defineActivity } from '@sdk';
|
||||
import type { ActivityBase, ActivityOutcome } from '@sdk';
|
||||
|
||||
export default defineActivity({
|
||||
name: 'quote-archive',
|
||||
paradigm: 'flow',
|
||||
|
||||
async run(base: ActivityBase): Promise<ActivityOutcome> {
|
||||
const history = await base.ledger.read('shared.history_price');
|
||||
const stats = await base.ledger.read('shared.landing_stats');
|
||||
|
||||
await base.interaction.pushView([
|
||||
{
|
||||
t: 'kv',
|
||||
title: '归档闭环',
|
||||
items: [
|
||||
{ k: '情报回写', v: `${history.length} 条` },
|
||||
{ k: '落地率', v: stats.length > 0 ? String(stats[0]?.['landing_rate'] ?? '—') : '—' },
|
||||
{ k: '客户', v: String(base.contract.data['customerCode'] ?? '') },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
// 不得空归档 —— 每一单不成交的报价也是情报资产
|
||||
if (history.length === 0) {
|
||||
await base.interaction.say('情报尚未回写,继续登记结果。', 'conclusion');
|
||||
return { kind: 'await', reason: '等待结果登记步完成' };
|
||||
}
|
||||
|
||||
await base.interaction.say('拒绝原因与竞对价位已回写,画像与落地率已落账。', 'criterion');
|
||||
return { kind: 'submit', deliverable: 'history-archive', rationale: '情报、画像、落地率三样齐备' };
|
||||
},
|
||||
});
|
||||
export default defineActivity({ name: 'quote-archive' });
|
||||
|
||||
@@ -1,141 +1,572 @@
|
||||
/**
|
||||
* 活动包清单 —— 统一报价 A7 · 情报归档。
|
||||
* 活动包清单 —— 统一报价 A7 · 情报归档(案例活动)。
|
||||
*
|
||||
* 业务素材:docs/MVP示例工作流.html §1.7(S1 结果登记 → S2 画像更新 → S3 落地率统计)。
|
||||
* 「不成交也留资产:情报、客户画像、落地率」—— 写的是**共享域**,跨活动的沉淀事实。
|
||||
* 业务素材:`docs/MVP示例工作流.html` §1.6(S1 结果登记 → S2 画像更新 → S3 落地率统计):
|
||||
* 「不成交也留资产:情报、客户画像、落地率」
|
||||
* 「拒绝或无回复的单子 7 天内闭环归档。每一单不成交的报价也是情报资产 ——
|
||||
* 沉淀下来,下一单更快更准」。
|
||||
*
|
||||
* 形状与 A4/A6 同一条(2026-08-03 重写 @3.0.0):归一情报卡 → 一张执行单交人
|
||||
* (逐条落输赢归因与竞对价)→ 按败价幅度给复用建议 → **入库口径要人拍板**
|
||||
* (决策卡)→ 放行归档闭环。
|
||||
* **能算的不问人**(A-01 败价幅度、A-02 入库口径建议两把尺写死),
|
||||
* **该人说的不代说**(归因、竞对价、客户原话那三列 AI 一格都不写)。
|
||||
*
|
||||
* 与 @2.1.0 的差别(六处都是踩过的账,逐条见 case-model 头注):
|
||||
* ① `input.required` 从 `['rfqRef','customerCode','quoteNo']` 清空 —— 宽进严出;
|
||||
* ② 两条 L1/L2 判据读 `reason_logged_total` / `profile_updated_total` ——
|
||||
* **活动自报的数**,写个 1 就过(「机检只读基座数」违反史第七类);
|
||||
* ③ 台账补 `required` / `requiredWhen` —— 从前一个必填都没有,执行卡的闸没牙;
|
||||
* ④ 补两处人的判断(归因 + 入库口径)—— 从前 AI 自己写完自己放行;
|
||||
* ⑤ 三张共享域表退役(墓志铭见 `declarations.ledger` 头注);
|
||||
* ⑥ `index.ts` 里那段 `run()` 删掉 —— 案例活动不会调它。
|
||||
*/
|
||||
import type { ActivityManifest } from '@contracts';
|
||||
import { ARCHIVE_SKILLS } from './skills.ts';
|
||||
import { CMMN_XML, NARRATIVE } from './case-model.ts';
|
||||
import type { ActivityManifest, DmnModel } from '@contracts';
|
||||
import { activityIdSchema } from '@contracts';
|
||||
import { ARCHIVE_LOGGED, ARCHIVE_RELEASE } from './views/report.ts';
|
||||
import { ARCHIVE_SKILLS } from './skills.ts';
|
||||
import { ARCHIVE_PHASES, ARCHIVE_STEPS, CMMN_XML, NARRATIVE } from './case-model.ts';
|
||||
import { ARCHIVE_ROSTER, ARCHIVE_WORKSHEET } from './views/report.ts';
|
||||
|
||||
const D = 'quote_archive';
|
||||
const ACTIVITY_ID = activityIdSchema.parse('quote-archive');
|
||||
|
||||
/**
|
||||
* 两把尺 —— **规则本身不是数据**,随包冻结。
|
||||
*
|
||||
* · 败价幅度 A-01:我方报价与竞对价的价差 → 这条情报下一单该怎么用
|
||||
* (出参就是台账 `price_gap_band` 的取值);
|
||||
* · 入库口径建议 A-02:输赢归因 → 决策卡上标 recommended 的那一档
|
||||
* (出参是三个 `value` 之一,不落台账 —— 建议不是结论,结论由人拍)。
|
||||
*
|
||||
* 出参键名与台账列同名,模型算完照抄即可(规范 §5.6)。
|
||||
* A-01 的档位在三处出现:这张表、剧本 guide、工作单上的规则块 ——
|
||||
* 包内自测把三处钉在一起(改一处不改另两处,测就红)。
|
||||
*/
|
||||
export const A01_DMN: DmnModel = {
|
||||
drd: [
|
||||
{ decision: 'a01-band', requires: [], inputs: ['gap'] },
|
||||
{ decision: 'a02-scope', requires: [], inputs: ['cause_tag'] },
|
||||
],
|
||||
tables: [
|
||||
{
|
||||
/** 败价幅度 A-01 —— FIRST 命中即定,所以第二行等价于「三到十个点」 */
|
||||
id: 'a01-band',
|
||||
label: '败价幅度 A-01',
|
||||
hitPolicy: 'FIRST',
|
||||
inputs: [{ name: 'gap', expression: 'gap' }],
|
||||
outputs: [{ name: 'price_gap_band' }],
|
||||
rules: [
|
||||
{ id: 'a01-1', when: ['<= 0.03'], then: ['SLIGHT'], annotation: '三个点以内 —— 擦肩而过,下一单同价位再试,输的多半不在价格' },
|
||||
{ id: 'a01-2', when: ['<= 0.10'], then: ['MODERATE'], annotation: '三到十个点 —— 够得着,下一单让到这一档或换替代料降本' },
|
||||
{ id: 'a01-3', when: ['> 0.10'], then: ['STRUCTURAL'], annotation: '超过一成 —— 结构性价差,下一单别再报同一价位,先走替代选型' },
|
||||
],
|
||||
},
|
||||
{
|
||||
/**
|
||||
* 入库口径建议 A-02 —— 只出**建议**,决策卡上那一枚 recommended 就是它。
|
||||
* 带着第三方报价的情报默认建议脱敏:竞对报价写进共享库,下一单谁都读得到。
|
||||
*/
|
||||
id: 'a02-scope',
|
||||
label: '入库口径建议 A-02',
|
||||
hitPolicy: 'FIRST',
|
||||
inputs: [{ name: 'cause_tag', expression: 'cause_tag' }],
|
||||
outputs: [{ name: 'scope_hint' }],
|
||||
rules: [
|
||||
{ id: 'a02-1', when: ['"PRICE"'], then: ['masked'], annotation: '归因带竞对报价 —— 建议脱敏入库:价位与败因照留,谁说的抹掉' },
|
||||
{ id: 'a02-default', when: ['-'], then: ['full'], annotation: '不涉第三方报价的败因 —— 建议全量入库,下一单查得越全越好' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const manifest: ActivityManifest = {
|
||||
identity: {
|
||||
activityId: ACTIVITY_ID,
|
||||
name: 'A7 · 情报归档',
|
||||
packageVersion: '0.1.6',
|
||||
packageVersion: '3.0.0',
|
||||
category: '统一报价',
|
||||
sdkVersion: '0.1.0-m5',
|
||||
checksum: 'dev-checksum-quote-archive',
|
||||
},
|
||||
|
||||
/* ── 契约七件 ────────────────────────────────────────────────── */
|
||||
contract: {
|
||||
activityId: ACTIVITY_ID,
|
||||
version: '0.1.0',
|
||||
version: '1.0.0',
|
||||
input: {
|
||||
/*
|
||||
* **宽进到底:一个必填都不设**(与 A1/A2/A3/A4/A6 同口径 —— 宽进严出,
|
||||
* 输入契约在活动内部归一 + 补全)。
|
||||
*
|
||||
* 来路两种形状:流内转来(A6 三口的载荷 `{rfqRef, customerCode, quoteNo,
|
||||
* outcome, rows}` —— `lost` 走 E13 直接进来,`won` 那一口的单子结案时也归这一份档)、
|
||||
* 单飞(一段文字)。上一版必填 `rfqRef + customerCode + quoteNo`,
|
||||
* 门口拦的恰是单飞那条路,而归档最常见的来路就是有人补一单历史。
|
||||
*/
|
||||
data: {
|
||||
type: 'object',
|
||||
required: ['customerCode', 'quoteNo'],
|
||||
required: [],
|
||||
properties: {
|
||||
/** 归档写 (pn, quoted_at) 这一行,两个主键都由调用方给定 */
|
||||
pn: { type: 'string' },
|
||||
quotedAt: { type: 'string' },
|
||||
customerCode: { type: 'string' },
|
||||
quoteNo: { type: 'string' },
|
||||
reason: { type: 'string' },
|
||||
competitorPrice: { type: 'number' },
|
||||
rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' },
|
||||
customerCode: { type: 'string', title: '客户码' },
|
||||
quoteNo: { type: 'string', title: '报价单号' },
|
||||
outcome: { type: 'string', title: '上游议价结果(won / lost / revise)' },
|
||||
reason: { type: 'string', title: '不成交缘由(上游给的一句话,归因时的第一手材料)' },
|
||||
rows: { type: 'array', title: '上游条目(报价函行,带报价、客户表态与定案)' },
|
||||
items: { type: 'array', title: '别的来路也收(条目数组)' },
|
||||
subject: { type: 'string', title: '需求主题' },
|
||||
body: { type: 'string', title: '需求正文' },
|
||||
attachments: {
|
||||
type: 'array',
|
||||
format: 'file',
|
||||
title: '附件',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', title: '文件名' },
|
||||
contentType: { type: 'string', title: '类型' },
|
||||
size: { type: 'integer', title: '字节数' },
|
||||
ref: { type: 'string', title: '存档号' },
|
||||
text: { type: 'string', title: '读出来的文本' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
messageAllowed: true,
|
||||
messageHint: '客户这一单的行为观察、竞对是谁、是否涉密不许外传,写这里',
|
||||
},
|
||||
output: {
|
||||
/*
|
||||
* **一口,且是必交付口**:A7 是流程包的最后一站(E14 `A7 → end_arch` 是无条件边),
|
||||
* 没有去向要选 —— 归档闭环就是把情报落准、口径定死。
|
||||
* 载荷全部从**已落地的事实**推导(verdict 那一行),不由模型另述一遍。
|
||||
*/
|
||||
mandatory: [
|
||||
{
|
||||
name: 'archived',
|
||||
payload: { type: 'object', properties: { landingRate: { type: 'number' } } },
|
||||
description: '归档闭环:情报回写 + 落地率落账',
|
||||
payload: {
|
||||
type: 'object',
|
||||
required: ['rfqRef', 'intelTotal'],
|
||||
properties: {
|
||||
rfqRef: { type: 'string', title: '询价单号(单标识)' },
|
||||
customerCode: { type: 'string', title: '客户码' },
|
||||
quoteNo: { type: 'string', title: '报价单号' },
|
||||
outcome: { type: 'string', title: '这一单最后成没成(won / lost)' },
|
||||
intelTotal: { type: 'integer', title: '归档情报条数' },
|
||||
sharePolicy: { type: 'string', title: '入库口径(full / masked / internal —— 人拍的)' },
|
||||
rows: { type: 'array', title: '情报卡(逐条归因与败价幅度)' },
|
||||
},
|
||||
},
|
||||
description: '归档闭环:情报卡逐条落定、入库口径已拍板 —— 这条工作流走到终点',
|
||||
},
|
||||
],
|
||||
selective: [],
|
||||
},
|
||||
/*
|
||||
* 必交付物只有一件:**情报卡清单**。归档结论(verdict)是它的结论栏,
|
||||
* `ledgers` 说清这份成果由哪几张表承载(一份成果,不是两份)。
|
||||
*/
|
||||
requiredDeliverables: [
|
||||
{ key: 'history-archive', label: '历史价格情报(归档)' },
|
||||
{ key: 'landing-stats', label: '报价落地率统计' },
|
||||
{
|
||||
key: 'intel-archive',
|
||||
label: '情报卡清单(逐条输赢归因 + 入库口径)',
|
||||
ledger: `${D}.intel`,
|
||||
/* 验收页上让 BD 就地改的两列 —— 恰是他的职权面(归因与依据) */
|
||||
editableAtRelease: ['cause_tag', 'evidence'],
|
||||
ledgers: [`${D}.intel`, `${D}.verdict`],
|
||||
},
|
||||
],
|
||||
/*
|
||||
* 验收判据 —— **机检(L1/L2)只读基座数得出来的数**:
|
||||
* `_rows` 基座数行数、`_missing` 基座按 required / requiredWhen 现数
|
||||
* (与执行卡提交闸同一个数同一套算法)、`_<列>_<值>` 基座按值现数。
|
||||
* 上一版两条机检读活动自报的 `reason_logged_total` / `profile_updated_total` ——
|
||||
* 那不是门,是「改哪个数能过」的提示(写个 1 就过去了)。
|
||||
*/
|
||||
acceptanceCriteria: [
|
||||
{ key: 'reason-logged', label: '拒绝原因 / 竞对价位已回写', level: 'L1', predicate: 'reasonLogged = true' },
|
||||
{ key: 'profile-updated', label: '客户画像标签已更新', level: 'L2', predicate: 'profileUpdated = true' },
|
||||
{ key: 'bd-release', label: 'BD 放行', level: 'L5' },
|
||||
{
|
||||
key: 'intel-complete',
|
||||
label: '情报卡齐:上游条目逐条承继,说了几条情报卡里就躺着几行',
|
||||
level: 'L1',
|
||||
predicate: `intel_total > 0 and ${D}_intel_rows = intel_total`,
|
||||
},
|
||||
{
|
||||
key: 'attributed-all',
|
||||
label: '每条都有输赢归因:归因落「输在价格」的那几条还落下了竞对报的那个价,缺一行放不了',
|
||||
level: 'L1',
|
||||
predicate: `${D}_intel_rows > 0 and ${D}_intel_missing = 0`,
|
||||
},
|
||||
{
|
||||
key: 'result-typed',
|
||||
label: '结果逐条落成两种之一:成交 / 不成交 —— 没有含糊其辞的格子',
|
||||
level: 'L2',
|
||||
predicate: `${D}_intel_deal_result_won + ${D}_intel_deal_result_lost = ${D}_intel_rows`,
|
||||
},
|
||||
{
|
||||
key: 'scope-settled',
|
||||
label: '入库口径逐条落定:全量 / 脱敏 / 只留内部 / 待定,四档相加等于情报条数',
|
||||
level: 'L2',
|
||||
modelRef: 'a02-scope',
|
||||
predicate: `${D}_intel_rows > 0 and ${D}_intel_share_scope_full + ${D}_intel_share_scope_masked + ${D}_intel_share_scope_internal + ${D}_intel_share_scope_pending = ${D}_intel_rows`,
|
||||
},
|
||||
{
|
||||
/* 与 A6 的「待拍板清零」同一形状:待定是过渡态,人拍过板它才归零 */
|
||||
key: 'pending-cleared',
|
||||
label: '入库口径都拍过板了:没有还压在「待定」上的情报条目',
|
||||
level: 'L2',
|
||||
predicate: `${D}_intel_share_scope_pending = 0`,
|
||||
},
|
||||
{
|
||||
/*
|
||||
* 有败价幅度的行**必是归因落了「输在价格」的行** —— 两边都是基座按值现数。
|
||||
* 不是价格败的却算出一个价差档,是模型给自己找了一条不必问竞对价的近路。
|
||||
*/
|
||||
key: 'band-from-price',
|
||||
label: '算了败价幅度的,都是归因落「输在价格」的那几条 —— 没有凭空算出来的价差档',
|
||||
level: 'L2',
|
||||
modelRef: 'a01-band',
|
||||
predicate: `${D}_intel_price_gap_band_slight + ${D}_intel_price_gap_band_moderate + ${D}_intel_price_gap_band_structural <= ${D}_intel_cause_tag_price`,
|
||||
},
|
||||
/*
|
||||
* 情报站不站得住是**裁量**(L3):归因说不说得出来路、客户原话记没记全,
|
||||
* 机器数不出来。这句 label 与剧本里 judge 的 criterion **必须逐字一致**。
|
||||
*/
|
||||
{
|
||||
key: 'intel-defensible',
|
||||
label: '情报站得住:每条归因说得出来路、竞对价与客户原话可溯,BD 对这份情报负责',
|
||||
level: 'L3',
|
||||
},
|
||||
/* L5 是责任关:那张卡本身就是这道关,卡面上不摆它(基座统一滤) */
|
||||
{ key: 'bd-release', label: 'BD 放行:签下去这一单的情报即入库,下一单查得到', level: 'L5' },
|
||||
],
|
||||
prohibitions: [{ key: 'no-blank-archive', statement: '不得空归档 —— 每一单不成交的报价也是情报资产' }],
|
||||
authorization: { discretion: [], preauthorizedActions: [], humanCallBudget: 2 },
|
||||
revisableAtRelease: [],
|
||||
prohibitions: [
|
||||
{
|
||||
key: 'no-blank-archive',
|
||||
statement: '不得空归档 —— 每一单不成交的报价也是情报资产,「没什么好记的」不是理由',
|
||||
},
|
||||
{
|
||||
key: 'no-invented-reason',
|
||||
statement: '不许编败因:问不出来就落 OTHER 并写明未获反馈 —— 编出来的原因会流进下一单的判据',
|
||||
},
|
||||
{
|
||||
key: 'no-self-scope',
|
||||
statement: '入库口径不许系统自己定:竞对报价与客户身份进共享库是对外边界,只由人拍板',
|
||||
},
|
||||
],
|
||||
authorization: {
|
||||
discretion: [
|
||||
{ key: 'band-advice', statement: '败价幅度 A-01 给出的复用建议由活动自行判定;进不进共享库一律交人' },
|
||||
],
|
||||
preauthorizedActions: [],
|
||||
/* 一张执行单 + 一张决策卡 + 放行 = 三次;留一倍余量 */
|
||||
humanCallBudget: 5,
|
||||
},
|
||||
resources: {
|
||||
/*
|
||||
* **不调工具**。归档这一段没有要问外面的事 —— 上游把结果交过来,
|
||||
* 人把缘由补上,系统把它落成下一单查得到的形状。
|
||||
*/
|
||||
tools: [],
|
||||
skills: ARCHIVE_SKILLS.map((s) => s.id),
|
||||
models: ['deepseek-v4-flash'],
|
||||
roles: ['BD'],
|
||||
budget: { steps: 30, tokens: 60_000, activeMinutes: 60, humanCalls: 2 },
|
||||
/* SLA:MVP §1.6「7 天闭环」;预算按 A6 实测(6-9 拍)留一倍余量 */
|
||||
budget: { steps: 60, tokens: 400_000, activeMinutes: 120, humanCalls: 5 },
|
||||
},
|
||||
},
|
||||
|
||||
/* ── 声明 ────────────────────────────────────────────────────── */
|
||||
declarations: {
|
||||
models: ['deepseek-v4-flash'],
|
||||
tools: [],
|
||||
/*
|
||||
* 【三张共享域表的墓志铭】上一版声明了 `market.price_history` /
|
||||
* `customer.profile` / `shared.landing_stats`,这一版三张一并退役:
|
||||
*
|
||||
* · `shared.landing_stats`(月度落地率 / TypeA 命中 / D+3 回复率)——
|
||||
* **单案例算不出一个月的统计**。上一版让 AI 写一个 0.27 进去,那是界面撒谎:
|
||||
* 它既不是本单的事实,也没有第二个数据源可核。月度统计的正经落点是驾驶舱
|
||||
* **按表现算**,这一单该做的只是把自己那一条如实落准。
|
||||
* · `customer.profile`(客户画像)—— **A1 已经持有这张主数据表**,
|
||||
* A7 再声明一遍就是同一批业务对象的两套地址(六个占位槽退役时清过一次的账)。
|
||||
* 本单看得出来的客户行为写进 `verdict.buyer_profile` 一句话,
|
||||
* 要更新主数据由人在客户档上改,不由归档活动代笔。
|
||||
* · `market.price_history`(历史价格情报)—— 与下面这张 `intel` **是同一份事实**。
|
||||
* 两处各存一份,改一处就分叉而没人看得出以哪份为准(A1 开请求单那次的账)。
|
||||
* 情报卡本身就是「下次查得到的形状」:按 pn 查得到价、按 customer_code 查得到客户、
|
||||
* 按 quoted_at 查得到「距上次报价多久」(P-03 的那把尺读的就是它)。
|
||||
*/
|
||||
ledger: [
|
||||
{
|
||||
// 共享域:跨活动的沉淀事实(AM 11.3 暗耦合禁令的两条合法路径之一)
|
||||
domain: 'shared',
|
||||
object: 'history_price',
|
||||
label: '历史价格情报',
|
||||
domain: D,
|
||||
object: 'intel',
|
||||
kind: 'record',
|
||||
caseKey: 'rfq_ref',
|
||||
label: '情报卡',
|
||||
fields: [
|
||||
{ name: 'pn', type: 'text', primaryKey: true, masked: false },
|
||||
{ name: 'quoted_at', type: 'date', primaryKey: true, masked: false },
|
||||
{ name: 'lost_reason', type: 'text', primaryKey: false, masked: false, label: '拒绝原因' },
|
||||
{ name: 'competitor_price', type: 'number', primaryKey: false, masked: false, label: '竞对价位' },
|
||||
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
|
||||
{ name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' },
|
||||
{ name: 'customer_code', type: 'text', primaryKey: false, masked: false, label: '客户码' },
|
||||
{ name: 'customer_name', type: 'text', primaryKey: false, masked: false, label: '客户名称' },
|
||||
{ name: 'category', type: 'text', primaryKey: false, masked: false, label: '品类' },
|
||||
{ name: 'brand', type: 'text', primaryKey: false, masked: false, label: '制造商' },
|
||||
{ name: 'eau', type: 'integer', primaryKey: false, masked: false, label: '年用量 EAU' },
|
||||
/* ── 下次查得到的那几格:时间 / 我方价 / 成交价 / 结果 ───────────── */
|
||||
{ name: 'quoted_at', type: 'date', primaryKey: false, masked: false, label: '报价日期' },
|
||||
{ name: 'quoted_price', type: 'number', primaryKey: false, masked: false, label: '我方报价 CNY' },
|
||||
{ name: 'final_price', type: 'number', primaryKey: false, masked: false, label: '成交价(成交才有)' },
|
||||
{
|
||||
name: 'deal_result',
|
||||
type: 'text',
|
||||
primaryKey: false,
|
||||
masked: false,
|
||||
label: '结果',
|
||||
tally: ['WON', 'LOST'],
|
||||
},
|
||||
/*
|
||||
* ── 人那一侧的三列:**AI 一格都不写** ──────────────────────────
|
||||
*
|
||||
* 与 A6 的「客户表态三列」同一处结构治:一条要模型分辨「这行能不能替人填」
|
||||
* 的规矩会走岔;「这几列不写」没有分支可走岔。
|
||||
*
|
||||
* `cause_tag` 标 `required` —— 件上的缺项红带、执行卡的提交闸、放行门 L1
|
||||
* 三处同源(都数基座的 `_missing`)。
|
||||
* `competitor_price` 用 `requiredWhen`:**只有归因落「输在价格」的那一行才算必填**
|
||||
* (表级 required 会让交期/认证败的行永远缺一格,把闸拦成死锁)。
|
||||
*/
|
||||
{
|
||||
name: 'cause_tag',
|
||||
type: 'text',
|
||||
primaryKey: false,
|
||||
masked: false,
|
||||
required: true,
|
||||
label: '输赢归因',
|
||||
tally: ['PRICE', 'LEADTIME', 'CERT', 'SPEC', 'RELATION', 'OTHER'],
|
||||
},
|
||||
{
|
||||
name: 'competitor_price',
|
||||
type: 'number',
|
||||
primaryKey: false,
|
||||
masked: false,
|
||||
requiredWhen: { column: 'cause_tag', value: 'PRICE' },
|
||||
label: '竞对价',
|
||||
},
|
||||
{ name: 'competitor_name', type: 'text', primaryKey: false, masked: false, label: '竞对是谁' },
|
||||
{ name: 'evidence', type: 'text', primaryKey: false, masked: false, label: '依据 / 客户原话' },
|
||||
/* ── 算出来的那一格:尺说了算,模型照抄 ─────────────────────────── */
|
||||
{
|
||||
name: 'price_gap_band',
|
||||
type: 'text',
|
||||
primaryKey: false,
|
||||
masked: false,
|
||||
label: '败价幅度(A-01)',
|
||||
tally: ['SLIGHT', 'MODERATE', 'STRUCTURAL'],
|
||||
},
|
||||
/*
|
||||
* 入库口径,**PENDING 是过渡态**(情报归好了、还没定进哪一档库):
|
||||
* 拍板那一下把它换成 FULL / MASKED / INTERNAL 之一,于是「归因齐了叫人」
|
||||
* 那道哨兵当场为假。A6 的账:让「拍完不动数」的路存在,同一张卡会连发六次;
|
||||
* 让模型自己写得出结论码,那张卡就永远不会发。两头都堵死才算数。
|
||||
*/
|
||||
{
|
||||
name: 'share_scope',
|
||||
type: 'text',
|
||||
primaryKey: false,
|
||||
masked: false,
|
||||
label: '入库口径',
|
||||
tally: ['PENDING', 'FULL', 'MASKED', 'INTERNAL'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
domain: 'shared',
|
||||
object: 'customer',
|
||||
label: '客户资料库',
|
||||
/*
|
||||
* 归档结论 —— 一单一行的**汇总表**,哨兵与报告读它。
|
||||
* 汇总列与逐行列**故意不同名**:任务视图把草稿行摊平到顶层是「后写的赢」,
|
||||
* 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。
|
||||
*/
|
||||
domain: D,
|
||||
object: 'verdict',
|
||||
kind: 'record',
|
||||
caseKey: 'rfq_ref',
|
||||
label: '归档结论',
|
||||
fields: [
|
||||
{ name: 'customer_code', type: 'text', primaryKey: true, masked: false, label: '客户码' },
|
||||
{ name: 'grade', type: 'text', primaryKey: false, masked: false, label: '客户等级' },
|
||||
{ name: 'profile_tags', type: 'text', primaryKey: false, masked: false, label: '画像标签' },
|
||||
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
|
||||
{ name: 'quote_no', type: 'text', primaryKey: false, masked: false, label: '报价单号' },
|
||||
{ name: 'customer_code', type: 'text', primaryKey: false, masked: false, label: '客户码' },
|
||||
{ name: 'customer_name', type: 'text', primaryKey: false, masked: false, label: '客户名称' },
|
||||
{ name: 'outcome', type: 'text', primaryKey: false, masked: false, label: '这一单最后成没成(won/lost)' },
|
||||
{ name: 'intel_total', type: 'integer', primaryKey: false, masked: false, label: '情报卡条数' },
|
||||
/* 归一走完的凭据(0 也要写)—— 哨兵按它放交人归因那一步入场 */
|
||||
{ name: 'collected_total', type: 'integer', primaryKey: false, masked: false, label: '已归一条数' },
|
||||
/* 「归因落齐了」的凭据 —— 只在人环走完才产生 */
|
||||
{ name: 'attributed_total', type: 'integer', primaryKey: false, masked: false, label: '已落归因条数' },
|
||||
{ name: 'scope_decided_total', type: 'integer', primaryKey: false, masked: false, label: '入库口径已拍板' },
|
||||
{ name: 'share_policy', type: 'text', primaryKey: false, masked: false, label: '入库口径(人拍的)' },
|
||||
/* MVP §1.6 S2「画像更新」的落点 —— 一句话,不代笔改主数据 */
|
||||
{ name: 'buyer_profile', type: 'text', primaryKey: false, masked: false, label: '客户行为画像' },
|
||||
{ name: 'summary', type: 'text', primaryKey: false, masked: false, label: '归档结论' },
|
||||
],
|
||||
},
|
||||
],
|
||||
/*
|
||||
* 两条静态监听 —— **时限必配**(AM 4.4)。MVP §1.6 写死「7 天闭环」:
|
||||
* 归档没有外部世界的回音要等,这两条管的是「别把它挂到天荒地老」——
|
||||
* D+3 提醒把归因补上,D+7 到期升级。
|
||||
*/
|
||||
listeners: [
|
||||
{
|
||||
origin: 'static',
|
||||
eventType: 'time',
|
||||
keyFilter: { kind: 'archive-d3' },
|
||||
semantic: 'observe',
|
||||
deadline: 'P3D',
|
||||
escalation: ['nudge'],
|
||||
},
|
||||
{
|
||||
domain: 'shared',
|
||||
object: 'landing_stats',
|
||||
label: '报价落地率统计',
|
||||
fields: [
|
||||
{ name: 'period', type: 'text', primaryKey: true, masked: false, label: '月份' },
|
||||
{ name: 'landing_rate', type: 'number', primaryKey: false, masked: false, label: '落地率' },
|
||||
{ name: 'type_a_hit', type: 'number', primaryKey: false, masked: false, label: 'TypeA命中' },
|
||||
{ name: 'reply_rate_d3', type: 'number', primaryKey: false, masked: false, label: 'D+3回复率' },
|
||||
],
|
||||
origin: 'static',
|
||||
eventType: 'time',
|
||||
keyFilter: { kind: 'archive-deadline' },
|
||||
semantic: 'observe',
|
||||
deadline: 'P7D',
|
||||
escalation: ['nudge', 'escalate'],
|
||||
},
|
||||
],
|
||||
listeners: [],
|
||||
// 7 天闭环(MVP示例 §1.7)
|
||||
harness: { memoryWindow: 15, stallAfter: 'P1D', nudge: [], retry: { times: 2, backoff: 'PT30S' } },
|
||||
harness: {
|
||||
memoryWindow: 22,
|
||||
// 7 天闭环是对**这一单办完**的约束;活本身只需两个人环
|
||||
stallAfter: 'PT2H',
|
||||
nudge: [{ after: 'PT1H', action: 'nudge' }],
|
||||
retry: { times: 2, backoff: 'PT30S' },
|
||||
},
|
||||
},
|
||||
|
||||
/* ── 资产初始版 ──────────────────────────────────────────────── */
|
||||
assets: {
|
||||
/* 资产初始版:案例模型(2026-07-29 起活动只有这一种业务模型) */
|
||||
businessModel: {
|
||||
cmmnXml: CMMN_XML,
|
||||
narrative: NARRATIVE,
|
||||
dmn: { drd: [], tables: [] },
|
||||
dmn: A01_DMN,
|
||||
steps: ARCHIVE_STEPS,
|
||||
phases: ARCHIVE_PHASES,
|
||||
},
|
||||
skills: ARCHIVE_SKILLS,
|
||||
/*
|
||||
* 两个局面各一张(与 A2/A3/A4/A6 同款口径):
|
||||
* · `archive-worksheet` 情报归档工作单 —— 归一与逐条归因期间摆它(逐颗情报档案、
|
||||
* 归因六档与败价幅度两把尺、填回工作表);
|
||||
* · `archive-roster` 情报归档清单 —— 拍板、放行、归档摆它。
|
||||
*/
|
||||
viewTemplates: [
|
||||
{ name: 'archive-logged', html: ARCHIVE_LOGGED },
|
||||
{ name: 'archive-release', html: ARCHIVE_RELEASE },
|
||||
{ name: 'archive-worksheet', html: ARCHIVE_WORKSHEET },
|
||||
{ name: 'archive-roster', html: ARCHIVE_ROSTER },
|
||||
],
|
||||
cardSchemas: {},
|
||||
distillHooks: [
|
||||
{ on: 'terminal', prompt: '归纳丢单的共性:价格、交期、竞对、客户类型 —— 供下一单更快更准。' },
|
||||
{
|
||||
on: 'terminal',
|
||||
prompt: '归纳丢单的共性:价格、交期、竞对、客户类型 —— 供下一单更快更准。',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
/* ── 样例集 —— 三条形状真不同(常备案例即验收案例)───────────── */
|
||||
samples: [
|
||||
{
|
||||
name: '客户拒绝',
|
||||
input: { customerCode: 'C001', quoteNo: 'Q202601', reason: '竞对报价更低' },
|
||||
/*
|
||||
* ① 流内失单:A6 `lost` 口的真实形状 —— rows 里报价函行(带客户表态与定案)
|
||||
* 混着结论行。客户拿竞对价压,归因落 PRICE,竞对价一填败价幅度就算得出来。
|
||||
*/
|
||||
name: '流内转来 · 客户拒绝 · 竞对价压两成(失单归档常路)',
|
||||
input: {
|
||||
rfqRef: 'RFQ-2026-0777',
|
||||
customerCode: 'CU-0311',
|
||||
quoteNo: 'Q-0777',
|
||||
outcome: 'lost',
|
||||
reason: '客户明确回绝:竞对价更低',
|
||||
customer_name: '青岛海洋电子',
|
||||
rows: [
|
||||
{
|
||||
rfq_ref: 'RFQ-2026-0777',
|
||||
pn: 'PESD2ETH-XR',
|
||||
category: '二极管',
|
||||
brand: 'Nexperia',
|
||||
eau: 30000,
|
||||
quoted_price: 0.098,
|
||||
reply_type: 'REJECT',
|
||||
deal_status: 'LOST',
|
||||
reply_note: '客户回:这个价做不了,别家给到 0.078',
|
||||
},
|
||||
{
|
||||
rfq_ref: 'RFQ-2026-0777',
|
||||
pn: 'NE555P',
|
||||
category: '定时器',
|
||||
brand: 'TI',
|
||||
eau: 5000,
|
||||
quoted_price: 1.05,
|
||||
reply_type: 'REJECT',
|
||||
deal_status: 'LOST',
|
||||
reply_note: '客户回:这颗一起给别家了',
|
||||
},
|
||||
{ rfq_ref: 'RFQ-2026-0777', line_total: 2, customer_name: '青岛海洋电子', summary: '两条都没谈成,交情报归档' },
|
||||
],
|
||||
},
|
||||
expect: { ports: ['archived'] },
|
||||
},
|
||||
{
|
||||
/*
|
||||
* ② 成交归档:A6 `won` 那一口的单子结案后也归这一份档 —— 成交价与胜因同样是情报
|
||||
* (下一单的基准价就在这儿)。归因不是 PRICE,竞对价那一格不该被闸拦。
|
||||
*/
|
||||
name: '成交归档 · 两条谈成 · 胜在交期与既有供应关系',
|
||||
input: {
|
||||
rfqRef: 'RFQ MD14.1',
|
||||
customerCode: 'CU-0142',
|
||||
quoteNo: 'Q-MD14.1',
|
||||
outcome: 'won',
|
||||
customer_name: 'Itelma',
|
||||
rows: [
|
||||
{
|
||||
rfq_ref: 'RFQ MD14.1',
|
||||
pn: 'BBPY00201209601Y00',
|
||||
customer_pn: '4-14-01502',
|
||||
category: '滤波器',
|
||||
brand: 'YAGEO',
|
||||
eau: 30000,
|
||||
quoted_price: 0.085,
|
||||
final_price: 0.085,
|
||||
reply_type: 'ACCEPT',
|
||||
deal_status: 'WON',
|
||||
reply_note: '客户回:按这个价走,PO 下周发',
|
||||
},
|
||||
{
|
||||
rfq_ref: 'RFQ MD14.1',
|
||||
pn: 'CJTB0101R6N',
|
||||
category: '集成电路',
|
||||
brand: 'Jiangsu Changjing',
|
||||
eau: 60000,
|
||||
quoted_price: 0.42,
|
||||
final_price: 0.42,
|
||||
reply_type: 'ACCEPT',
|
||||
deal_status: 'WON',
|
||||
reply_note: '客户回:可以,一起下',
|
||||
},
|
||||
{ rfq_ref: 'RFQ MD14.1', line_total: 2, customer_name: 'Itelma' },
|
||||
],
|
||||
},
|
||||
expect: { ports: ['archived'] },
|
||||
},
|
||||
{
|
||||
/*
|
||||
* ③ 单飞宽进:没有单号没有清单,正文说了哪一单、报了什么价、客户怎么回的 ——
|
||||
* 归一造案号(ARC- 开头),两颗料一条都没成。
|
||||
*/
|
||||
name: '单飞需求 · 正文列两颗料 · 客户始终未表态(补一单历史)',
|
||||
input: {
|
||||
subject: 'MD14.2 这一单补个归档',
|
||||
body: '上个月发出去的报价函一直没回音:XCJ-7788T 电源模块 8K,报价 14.80;LMR-3302QQ 稳压器 15K,报价 2.35。客户是 深圳市锐驰电子,联系人王工。报价日 2026-07-05,D+3 与 D+7 都催过,客户始终没表态,这一单按丢单归档。',
|
||||
},
|
||||
expect: { ports: ['archived'] },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* A7 技能 —— 归档的方法经验(AM 08)。
|
||||
* A7 技能 —— 归档的方法经验(AM 08)。**载方法不载权力**:这里一条工具都不引。
|
||||
*/
|
||||
import { skillIdSchema } from '@contracts';
|
||||
import type { Skill } from '@contracts';
|
||||
@@ -7,16 +7,38 @@ import type { Skill } from '@contracts';
|
||||
export const ARCHIVE_SKILLS: Skill[] = [
|
||||
{
|
||||
id: skillIdSchema.parse('archive.log-lost-reason'),
|
||||
label: '把拒绝原因写成下一单的判据',
|
||||
applicability: '客户拒绝或超时,要登记结果时。',
|
||||
label: '把输赢的缘由写成下一单的判据',
|
||||
applicability: '逐条落归因、准备发那张执行单时。',
|
||||
method: [
|
||||
'「价格高」不是原因,是结论。要写的是**可比对的事实**:',
|
||||
'· 竞对报价多少、差几个百分点;',
|
||||
'· 输在价格、交期、还是资质/认证;',
|
||||
'· 客户是否明说了可接受区间。',
|
||||
'· 竞对报价多少、差几个百分点(差 2 个点与差 30 个点是两件完全不同的事);',
|
||||
'· 输在价格、交期、认证资质、规格,还是既有供应关系;',
|
||||
'· 客户是否明说了可接受区间 —— 下一单的起点就藏在这一句里。',
|
||||
'',
|
||||
'判据要能被下一单**机器读取**:P-03 用「距上次报价 / 行情稳定」,P-01 用客户等级。',
|
||||
'写成一段散文,它就只能被人读;写成字段,它才进得了决策表。',
|
||||
'',
|
||||
'**「客户没回」不是原因**:问不出来就落 OTHER 并在依据里写明「未获反馈」。',
|
||||
'编一个听着合理的理由,三个月后没人记得它是编的 —— 而它已经在替下一单定价了。',
|
||||
].join('\n'),
|
||||
crystallized: [],
|
||||
knowledgeRefs: [],
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
id: skillIdSchema.parse('archive.share-scope'),
|
||||
label: '情报入库口径:哪些该留名,哪些该抹掉',
|
||||
applicability: '发入库口径决策卡、给出建议档位时。',
|
||||
method: [
|
||||
'进了共享情报库的东西,**下一单谁都读得到** —— 这条边界过去了就收不回来。',
|
||||
'三档的分界:',
|
||||
'· **全量入库**:不涉第三方报价的败因(交期、认证、规格)—— 查得越全越好;',
|
||||
'· **脱敏入库**:带着竞对报价的情报 —— 价位、归因、日期照留,谁说的抹掉。',
|
||||
' 竞对的报价是他给客户的,不是给我们的;客户转述给我们,不等于我们可以对内广播;',
|
||||
'· **只留内部**:客户明说不外传、或涉及在谈的项目 —— 这一单的情报只在本案卷里躺着。',
|
||||
'',
|
||||
'**建议归尺,结论归人**:A-02 只在决策卡上标一枚 recommended,',
|
||||
'按不按它是签字的人的事 —— 这不是效率问题,是谁承担后果的问题。',
|
||||
].join('\n'),
|
||||
crystallized: [],
|
||||
knowledgeRefs: [],
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* A1 两份执行页的样式表 —— **从 PRD 目标件原样搬过来**,不是我照着复述的。
|
||||
*
|
||||
* 出处:`~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理需求.html` 的整段 `<style>`,
|
||||
* 外加 `A1-询价受理记录.html` 里归档件独有的那几件(分类分布条 / 类徽 / 判定表 /
|
||||
* 结论横幅 / 落地动作 / 完成标)。**一个数值都没改**。
|
||||
*
|
||||
* 为什么是搬而不是抄:前三轮我用自己那套外壳「照着做」,块搬齐了、版面是两套 ——
|
||||
* 章节头成了彩色方块编号、多出摘要与元信息带、字号用 clamp。人一眼就看出不是那份件。
|
||||
* 目标件是**像素级的答案**,搬过来才不会在复述里走样。
|
||||
*
|
||||
* 选择器保留 `.proto-root .app-shell` 前缀,所以模版正文也要照原型那样包两层壳 ——
|
||||
* 少一层壳,整张样式表一条都不生效(踩过)。
|
||||
*/
|
||||
export const PRD_CSS = `
|
||||
/* ============================================================================
|
||||
JCAI 员工面工作台 · A1 受理归一的过程成果「询价受理需求」——报告本体
|
||||
立场:**AI 已从 RFQ 邮件抽取归一**,人在这里审核并就地修订。
|
||||
两态:读态(AI 稿 + 红/灰占位提示缺项) ⇄ 编辑态(全字段可改,保存 / 取消)
|
||||
核心是「有效 PN 清单」;清单之上四件受理要素:客户公司 / 联系人 / 客户属性 / 有效 PN。
|
||||
|
||||
令牌与既有组件取自 workbench/src/app/(proto)/app.css,数值未改。
|
||||
⚠️ 修正一处原型 bug:app.css:2062-2064 的 '.jc-rp-sign span'(权重 0,1,1)
|
||||
盖过了 '.jc-rp-ava'(0,1,0),把头像的 display:grid 覆盖成 block、字色改灰,
|
||||
导致落款头像里的字掉到圆外。本文件把该规则收窄为 '.jc-rp-sign > div > span'。
|
||||
新增件:四要素卡 .jc-four/.jc-fcard · 占位胶囊 .jc-ph · 分段控件 .jc-seg ·
|
||||
来源条 .jc-src · 录入表 .jc-tbl-in
|
||||
========================================================================== */
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body { background: #e6eaf1; -webkit-font-smoothing: antialiased; }
|
||||
|
||||
.proto-root .app-shell {
|
||||
--brand: #3370ff; --brand-deep: #245bdb;
|
||||
--brand-soft: #e1eaff; --brand-wash: #f0f4ff;
|
||||
--ai-grad: linear-gradient(135deg, #3370ff, #7f3bf5);
|
||||
--ink: #1f2329; --ink-2: #41464e; --ink-3: #646a73; --ink-4: #8f959e;
|
||||
--line: #dee0e3; --line-soft: #e8eaed; --soft: #f8f9fb;
|
||||
--jcai-bg: #e6eaf1; --cardline: #e1e6ec; --card-shadow: 0 1px 2px rgba(31, 35, 41, .05);
|
||||
--ok: #278717; --ok-soft: #e8f8e4;
|
||||
--warn: #de7802; --warn-accent: #ff8800; --warn-soft: #fff4e5;
|
||||
--danger: #d83931; --danger-soft: #feeae9;
|
||||
--mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace;
|
||||
--shadow-brand: 0 6px 16px rgba(51, 112, 255, .22);
|
||||
--t-fast: .16s cubic-bezier(.3, 0, .2, 1);
|
||||
--t-med: .26s cubic-bezier(.3, 0, .2, 1);
|
||||
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: var(--jcai-bg); min-height: 100vh; color: var(--ink);
|
||||
}
|
||||
.proto-root :where(button, input, select, a, [tabindex]):focus-visible {
|
||||
outline: 2px solid var(--brand); outline-offset: 2px; border-radius: 8px;
|
||||
}
|
||||
.proto-root ::selection { background: var(--brand-soft); }
|
||||
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes jcBlink { 0%,100% { opacity: 1 } 50% { opacity: .35 } }
|
||||
@keyframes jcStamp { from { opacity: 0; transform: rotate(-6deg) scale(1.7); } to { opacity: .9; transform: rotate(-6deg) scale(1); } }
|
||||
|
||||
/* 放开宽度:1160px 是「9 列全展开、一个字都不裁」的最小容器宽度(见下方列 min-width 之和) */
|
||||
.jc-stage { max-width: 1160px; margin: 0 auto; padding: 22px 16px 52px; }
|
||||
.proto-root .jc-mono { font-family: var(--mono); font-size: 11px; color: var(--ink-3); }
|
||||
.proto-root .jc-pulse-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--brand); display: inline-block; animation: jcBlink 1.1s ease-in-out infinite; }
|
||||
|
||||
/* ── 报告外壳 ───────────────────────────────────────────────────────── */
|
||||
.proto-root .jc-report { background: #fff; border: 1px solid var(--cardline); border-radius: 14px; box-shadow: var(--card-shadow); overflow: hidden; animation: rise var(--t-med) both; }
|
||||
.proto-root .jc-rp-head { position: relative; overflow: hidden; display: flex; align-items: flex-start; gap: 12px; padding: 16px 20px 13px; border-bottom: 2px solid var(--ink); }
|
||||
/* 题头下缘粗细双线 = 正式件版式 */
|
||||
.proto-root .jc-rp-head::after { content: ""; position: absolute; left: 0; right: 0; bottom: -4px; height: 1px; background: var(--line); }
|
||||
/* 题头右上的品牌光晕:与工作台标题区同一签名件 */
|
||||
.proto-root .jc-rp-head::before { content: ""; position: absolute; right: -70px; top: -96px; width: 240px; height: 240px; border-radius: 999px; background: radial-gradient(circle, rgba(51,112,255,.10), transparent 70%); pointer-events: none; }
|
||||
.proto-root .jc-rp-head > * { position: relative; z-index: 1; }
|
||||
.proto-root .jc-rp-badge { flex: none; width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand); }
|
||||
.proto-root .jc-rp-tit { flex: 1; min-width: 0; }
|
||||
.proto-root .jc-rp-tit em { display: block; font-style: normal; font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); font-family: var(--mono); }
|
||||
.proto-root .jc-rp-tit h3 { margin: 4px 0 0; font-size: 18px; font-weight: 800; color: var(--ink); letter-spacing: -.01em; line-height: 1.3; }
|
||||
.proto-root .jc-rp-wait { display: inline-flex; align-items: center; gap: 6px; flex: none; margin-top: 4px; font-size: 11px; font-weight: 700; border-radius: 999px; padding: 4px 12px; color: var(--brand-deep); background: var(--brand-wash); border: 1px solid var(--brand-soft); }
|
||||
.proto-root .jc-rp-wait.warn { color: var(--warn); background: var(--warn-soft); border-color: #ffd9a8; }
|
||||
.proto-root .jc-rp-wait.edit { color: #fff; background: var(--brand); border-color: var(--brand); }
|
||||
.proto-root .jc-rp-stamp { flex: none; margin-top: 2px; transform: rotate(-6deg); border: 2px solid currentColor; border-radius: 9px; padding: 4px 8px 3px 12px; font-size: 13px; font-weight: 900; letter-spacing: .28em; color: var(--ok); opacity: .9; animation: jcStamp .32s cubic-bezier(.2, 1.4, .4, 1) both; }
|
||||
.proto-root .jc-rp-bd { padding: 18px 20px 12px; }
|
||||
.proto-root .jc-rp-part { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 9px; margin: 20px 0 11px; }
|
||||
.proto-root .jc-rp-part:first-child { margin-top: 0; }
|
||||
.proto-root .jc-rp-part > b { flex: none; font-size: 11px; font-weight: 800; letter-spacing: .12em; color: var(--ink-3); }
|
||||
.proto-root .jc-rp-part > .ln { flex: 1; height: 1px; background: var(--line-soft); }
|
||||
.proto-root .jc-rp-part > em { flex: none; font-style: normal; font-size: 10px; font-weight: 700; color: var(--ink-4); font-family: var(--mono); background: var(--soft); border-radius: 999px; padding: 2px 9px; white-space: nowrap; }
|
||||
.proto-root .jc-rp-part > em.warn { color: var(--warn); background: var(--warn-soft); }
|
||||
|
||||
/* 落款 —— ⚠️ 选择器收窄到 > div > *,别再命中头像那个 span */
|
||||
.proto-root .jc-rp-foot { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 13px 20px 15px; border-top: 2px solid var(--line); background: #fbfcfd; }
|
||||
.proto-root .jc-rp-sign { display: flex; align-items: center; gap: 10px; flex: 1 1 200px; min-width: 0; }
|
||||
.proto-root .jc-rp-ava { flex: 0 0 auto; width: 30px; height: 30px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; line-height: 1; color: #fff; background: var(--brand); }
|
||||
.proto-root .jc-rp-sign > div { min-width: 0; }
|
||||
.proto-root .jc-rp-sign > div > b { display: block; font-size: 12px; font-weight: 700; color: var(--ink-2); }
|
||||
.proto-root .jc-rp-sign > div > span { display: block; margin-top: 1px; font-size: 10.5px; color: var(--ink-4); }
|
||||
.proto-root .jc-rp-acts { display: flex; align-items: center; gap: 8px; margin-left: auto; flex: none; }
|
||||
.proto-root .jc-hintline { font-size: 11px; font-weight: 700; color: var(--warn); line-height: 1.5; }
|
||||
|
||||
.proto-root .jc-btn { display: inline-flex; align-items: center; gap: 5px; border: 0; border-radius: 7px; padding: 8px 14px; font-size: 12px; font-weight: 600; font-family: inherit; background: var(--brand); color: #fff; cursor: pointer; transition: all var(--t-fast); }
|
||||
.proto-root .jc-btn:hover:not(:disabled) { background: var(--brand-deep); }
|
||||
.proto-root .jc-btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.proto-root .jc-btn-ghost { background: #fff; color: var(--ink-2); border: 1px solid var(--line); }
|
||||
.proto-root .jc-btn-ghost:hover:not(:disabled) { background: var(--brand-wash); color: var(--brand); border-color: var(--brand-soft); }
|
||||
.proto-root .jc-btn-primary { background: var(--ai-grad); }
|
||||
.proto-root .jc-btn-primary:hover:not(:disabled) { filter: brightness(1.07); box-shadow: var(--shadow-brand); transform: translateY(-1px); }
|
||||
|
||||
/* ── 来源信件:客户 RFQ 邮件原文(复用原型 jc-letter 件,青色 = 收件) ── */
|
||||
.proto-root .jc-letter { position: relative; border: 1px solid var(--cardline); border-left: 3px solid #0aa294; border-radius: 12px; padding: 14px 16px 15px; background: #fff; box-shadow: var(--card-shadow); }
|
||||
.proto-root .jc-letter-h { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 11px; }
|
||||
.proto-root .jc-letter-dir { display: inline-flex; align-items: center; gap: 5px; font-size: 10.5px; font-weight: 800; padding: 3px 9px; border-radius: 999px; background: #e4f8f5; color: #0a7f74; }
|
||||
.proto-root .jc-letter-h .jc-chip { font-size: 10.5px; padding: 3px 9px; border-radius: 999px; background: var(--brand-wash); color: var(--brand-deep); border: 1px solid var(--brand-soft); }
|
||||
.proto-root .jc-letter-h > code { margin-left: auto; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
|
||||
.proto-root .jc-letter-meta { display: flex; flex-direction: column; gap: 4px; margin-bottom: 11px; padding-bottom: 11px; border-bottom: 1px solid var(--line-soft); font-size: 11.5px; color: var(--ink-2); }
|
||||
.proto-root .jc-letter-meta p { margin: 0; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
|
||||
.proto-root .jc-letter-meta i { flex: none; font-style: normal; width: 44px; font-size: 10px; font-weight: 800; letter-spacing: .06em; color: var(--ink-4); }
|
||||
.proto-root .jc-letter-meta code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); overflow-wrap: anywhere; }
|
||||
.proto-root .jc-letter-sub { margin: 0 0 9px; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.45; overflow-wrap: anywhere; }
|
||||
/* 正文:原文照录,pre-wrap 保原始换行;不截断、不折叠 */
|
||||
.proto-root .jc-letter-body { margin: 0; font-size: 12.5px; line-height: 1.72; color: var(--ink-2); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-letter-body b { color: var(--ink); font-weight: 700; }
|
||||
.proto-root .jc-atts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; padding-top: 11px; border-top: 1px dashed var(--line-soft); }
|
||||
.proto-root .jc-att { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px 5px 8px; border: 1px solid var(--brand-soft); border-radius: 8px; background: var(--brand-wash); font-size: 11.5px; color: var(--brand-deep); }
|
||||
.proto-root .jc-att svg { flex: none; color: var(--brand); }
|
||||
.proto-root .jc-att code { font-family: var(--mono); font-size: 10.5px; }
|
||||
.proto-root .jc-att span { color: var(--ink-4); }
|
||||
/* 抽取指向:从原文到下方要素的一句衔接 */
|
||||
.proto-root .jc-derive { display: flex; align-items: flex-start; gap: 8px; margin-top: 11px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); font-size: 11.5px; line-height: 1.7; color: var(--brand-deep); }
|
||||
.proto-root .jc-derive svg { flex: none; margin-top: 2px; }
|
||||
|
||||
/* ── 四要素卡 ───────────────────────────────────────────────────────────
|
||||
色调字典:每张卡带一套 --tone / --tone-soft / --tone-line,组件只读变量。
|
||||
卡 3、卡 4 的色调随数据变(老客户绿 / 新客户橙;齐了蓝 / 有缺橙),
|
||||
所以这条要素带本身就是一行可读的状态,不只是四个格子。 */
|
||||
.proto-root .jc-four { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 11px; }
|
||||
.proto-root .jc-fcard {
|
||||
position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-width: 0;
|
||||
padding: 14px 14px 14px; border: 1px solid var(--cardline); border-radius: 13px; background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 8px 20px -14px rgba(31,35,41,.35);
|
||||
transition: transform var(--t-fast), box-shadow var(--t-fast), border-color var(--t-fast);
|
||||
--tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft);
|
||||
}
|
||||
/* 顶缘色棱 + 右上角光晕:卡的身份色,一眼分得清四件是四件 */
|
||||
.proto-root .jc-fcard::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 3px; background: var(--tone); }
|
||||
.proto-root .jc-fcard::after { content: ""; position: absolute; right: -46px; top: -52px; width: 132px; height: 132px; border-radius: 999px; background: radial-gradient(circle, var(--tone-soft), transparent 70%); pointer-events: none; }
|
||||
.proto-root .jc-fcard > * { position: relative; z-index: 1; }
|
||||
.proto-root .jc-fcard:hover { transform: translateY(-2px); border-color: var(--tone-line); box-shadow: 0 2px 4px rgba(31,35,41,.05), 0 14px 26px -16px rgba(31,35,41,.45); }
|
||||
.proto-root .jc-fcard.t-teal { --tone: #0aa294; --tone-soft: rgba(10,162,148,.15); --tone-line: #9fe0d9; }
|
||||
.proto-root .jc-fcard.t-ok { --tone: var(--ok); --tone-soft: rgba(39,135,23,.14); --tone-line: #bfedb4; }
|
||||
.proto-root .jc-fcard.t-warn { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.12); --tone-line: #ffd9a8; }
|
||||
|
||||
.proto-root .jc-fcard-k { display: flex; align-items: center; gap: 6px; font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
|
||||
.proto-root .jc-fcard-k > i { flex: none; width: 19px; height: 19px; border-radius: 6px; display: inline-grid; place-items: center; background: var(--tone-soft); color: var(--tone); }
|
||||
.proto-root .jc-fcard-b { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.proto-root .jc-fcard-t { min-width: 0; flex: 1; }
|
||||
/* 一律换行,不省略号——宁可多占一行,也不丢字 */
|
||||
.proto-root .jc-fcard-t > b { display: block; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.35; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-fcard-t > span { display: block; margin-top: 2px; font-size: 10.5px; color: var(--ink-4); line-height: 1.55; overflow-wrap: anywhere; }
|
||||
/* 公司标:圆角方 + AI 渐变 + 外圈光环(方=组织) */
|
||||
.proto-root .jc-logo { flex: none; width: 38px; height: 38px; border-radius: 12px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; letter-spacing: .04em; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand), 0 0 0 3px rgba(51,112,255,.10); }
|
||||
/* 人像:圆 + 青色系(圆=人,与方形的组织标一眼分开) */
|
||||
.proto-root .jc-face { flex: none; width: 38px; height: 38px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; color: #0a7f74; background: #e4f8f5; border: 1px solid #9fe0d9; box-shadow: 0 0 0 3px rgba(10,162,148,.10); }
|
||||
/* 邮箱做成可点的胶囊,复制按钮嵌在里面——比一行裸文字有抓手 */
|
||||
/* 邮箱:软底胶囊、无描边——有描边会读成输入框,而读态这里不是可填的 */
|
||||
.proto-root .jc-mailrow { display: flex; align-items: center; gap: 4px; margin-top: 5px; min-width: 0; max-width: 100%; padding: 3px 4px 3px 9px; border: 0; border-radius: 8px; background: var(--tone-soft); transition: background var(--t-fast); }
|
||||
.proto-root .jc-mailrow:hover { background: rgba(10,162,148,.2); }
|
||||
/* 编辑态里它包着输入框,软底会变成「框中框」,去掉 */
|
||||
.proto-root .jc-report.editing .jc-mailrow { background: transparent; padding: 0; margin-top: 6px; }
|
||||
.proto-root .jc-mailrow > code { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; line-height: 1.5; font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
|
||||
.proto-root .jc-copy { flex: none; width: 22px; height: 22px; display: inline-grid; place-items: center; border: 0; border-radius: 6px; background: #fff; color: var(--ink-4); cursor: pointer; box-shadow: 0 1px 2px rgba(31,35,41,.08); transition: all var(--t-fast); }
|
||||
.proto-root .jc-copy:hover { color: #0aa294; }
|
||||
.proto-root .jc-copy.ok { color: var(--ok); background: var(--ok-soft); box-shadow: none; }
|
||||
/* 客户属性徽章 */
|
||||
.proto-root .jc-tagbig { display: inline-flex; align-items: center; gap: 6px; align-self: flex-start; padding: 7px 14px; border-radius: 999px; font-size: 13.5px; font-weight: 800; line-height: 1; color: var(--tone); background: var(--tone-soft); border: 1px solid var(--tone-line); }
|
||||
.proto-root .jc-fcard-note { font-size: 10.5px; color: var(--ink-4); line-height: 1.55; }
|
||||
.proto-root .jc-fcard-note b { color: var(--ink-2); font-weight: 700; }
|
||||
.proto-root .jc-fcard-note b.bad { color: var(--tone); font-weight: 800; }
|
||||
/* 成交迷你柱:把「台账 6 笔成交」从一句话变成看得见的节奏 */
|
||||
.proto-root .jc-spark { display: flex; align-items: flex-end; gap: 4px; height: 26px; }
|
||||
.proto-root .jc-spark > i { flex: 1 1 0; min-width: 4px; max-width: 15px; border-radius: 3px 3px 1px 1px; background: var(--tone); opacity: .3; transition: opacity var(--t-fast), height var(--t-med); }
|
||||
.proto-root .jc-spark > i:last-child { opacity: 1; }
|
||||
.proto-root .jc-spark:hover > i { opacity: .55; }
|
||||
.proto-root .jc-spark:hover > i:last-child { opacity: 1; }
|
||||
.proto-root .jc-spark.empty { align-items: stretch; }
|
||||
.proto-root .jc-spark.empty > i { height: 100%; opacity: 1; background: repeating-linear-gradient(45deg, var(--tone-soft) 0 5px, transparent 5px 10px); border: 1px dashed var(--tone-line); border-radius: 5px; }
|
||||
/* 有效 PN 计数 */
|
||||
.proto-root .jc-count { display: flex; align-items: baseline; gap: 6px; }
|
||||
.proto-root .jc-count > b { font-family: var(--mono); font-size: 32px; font-weight: 800; line-height: .95; color: var(--ink); letter-spacing: -.04em; }
|
||||
.proto-root .jc-count > span { font-size: 11px; color: var(--ink-4); }
|
||||
/* 分段刻度条:一格 = 一条 PN,缺必填的那格直接标红——比一根实心进度条信息量大 */
|
||||
.proto-root .jc-segmeter { display: flex; gap: 3px; }
|
||||
.proto-root .jc-segmeter > i { flex: 1; height: 7px; border-radius: 2px; background: var(--ok); transition: background var(--t-fast); }
|
||||
.proto-root .jc-segmeter > i.bad { background: var(--danger); }
|
||||
.proto-root .jc-lockhint { display: inline-flex; align-items: center; gap: 4px; font-size: 10px; color: var(--ink-4); }
|
||||
|
||||
/* ── 占位胶囊:必填=红虚线,选填=灰虚线;**都带文字**,不靠颜色单打 ── */
|
||||
.proto-root .jc-ph { display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700; line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed; }
|
||||
.proto-root .jc-ph.req { color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
|
||||
.proto-root .jc-ph.opt { color: var(--ink-4); background: var(--soft); border-color: var(--line); }
|
||||
|
||||
/* ── 分段控件(新客户 / 老客户)── */
|
||||
.proto-root .jc-seg { display: inline-flex; padding: 2px; gap: 2px; border: 1px solid var(--cardline); border-radius: 9px; background: var(--soft); align-self: flex-start; }
|
||||
.proto-root .jc-seg button { border: 0; border-radius: 7px; padding: 6px 13px; font: inherit; font-size: 11.5px; font-weight: 700; color: var(--ink-3); background: transparent; cursor: pointer; transition: all var(--t-fast); }
|
||||
.proto-root .jc-seg button:hover:not(.on) { color: var(--brand); }
|
||||
.proto-root .jc-seg button.on { background: #fff; color: var(--ink); box-shadow: 0 1px 3px rgba(31,35,41,.12); }
|
||||
|
||||
/* ── 表:读态与编辑态共用骨架 ─────────────────────────────────────── */
|
||||
.proto-root .jc-tbl-wrap { overflow-x: auto; border: 1px solid var(--line-soft); border-radius: 10px; }
|
||||
.proto-root .jc-tbl-in { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; }
|
||||
.proto-root .jc-tbl-in th { background: var(--soft); text-align: left; padding: 8px 9px; font-size: 10.5px; font-weight: 800; letter-spacing: .03em; color: var(--ink-3); white-space: nowrap; border-bottom: 1px solid var(--line-soft); }
|
||||
.proto-root .jc-tbl-in th.req::after { content: "*"; margin-left: 1px; color: var(--danger); font-size: 10px; }
|
||||
.proto-root .jc-tbl-in td { padding: 4px 5px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
|
||||
.proto-root .jc-tbl-in tr:last-child td { border-bottom: 0; }
|
||||
.proto-root .jc-tbl-in tbody tr:hover td { background: #f7f9fd; }
|
||||
/* 缺必填的整行浅红打底 + 行首红标——不用找红胶囊,一眼扫到是哪几行 */
|
||||
.proto-root .jc-tbl-in tr.miss td { background: #fffbfb; }
|
||||
.proto-root .jc-tbl-in tbody tr.miss:hover td { background: #fff5f5; }
|
||||
.proto-root .jc-tbl-in td.jc-rn { position: relative; width: 38px; padding: 4px 6px 4px 13px; white-space: nowrap; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
|
||||
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); font-weight: 800; }
|
||||
/* 行首红标只在表态用 ::after —— 卡片态的 ::after 被「第 N 条」占着,不能抢 */
|
||||
@media (min-width: 961px) {
|
||||
.proto-root .jc-tbl-in tr.miss td.jc-rn::after { content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger); }
|
||||
}
|
||||
.proto-root .jc-rndot { display: inline-block; width: 5px; height: 5px; margin-left: 4px; border-radius: 999px; background: var(--danger); vertical-align: 2px; }
|
||||
/* 读态单元格 */
|
||||
.proto-root .jc-vcell { display: block; padding: 6px 6px; font-size: 12px; color: var(--ink); line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-vcell.mono { font-family: var(--mono); font-size: 11px; color: var(--ink-2); }
|
||||
.proto-root .jc-vcell.num { font-family: var(--mono); font-size: 11.5px; text-align: right; }
|
||||
/* 编辑态单元格:安静的电子表格 */
|
||||
.proto-root .jc-cell { width: 100%; min-width: 64px; border: 1px solid var(--line); border-radius: 6px; padding: 6px 7px; font: inherit; font-size: 12px; color: var(--ink); background: #fff; transition: border-color var(--t-fast), box-shadow var(--t-fast); }
|
||||
.proto-root .jc-cell.mono { font-family: var(--mono); font-size: 11px; }
|
||||
.proto-root .jc-cell.num { text-align: right; font-family: var(--mono); font-size: 11.5px; }
|
||||
.proto-root .jc-cell::placeholder { color: var(--ink-4); }
|
||||
.proto-root .jc-cell:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-wash); outline: none; }
|
||||
.proto-root .jc-cell.req-empty { border-color: #f3b6b3; background: #fffafa; }
|
||||
.proto-root .jc-cell.req-empty::placeholder { color: var(--danger); }
|
||||
/* 备注这类长文本用文本域:能换行、随内容长高,不裁字 */
|
||||
.proto-root textarea.jc-cell { display: block; resize: vertical; overflow: hidden; min-height: 31px; line-height: 1.55; }
|
||||
|
||||
/* 编辑态的整表提示带 */
|
||||
.proto-root .jc-editbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); border: 1px solid var(--brand-soft); font-size: 11.5px; color: var(--brand-deep); }
|
||||
.proto-root .jc-editbar svg { flex: none; }
|
||||
.proto-root .jc-editbar .sp { flex: 1 1 auto; }
|
||||
|
||||
/* 剔除行说明 */
|
||||
.proto-root .jc-drop { display: flex; align-items: flex-start; gap: 8px; margin-top: 10px; padding: 9px 12px; border-radius: 9px; border: 1px dashed var(--line); background: var(--soft); font-size: 11.5px; color: var(--ink-3); line-height: 1.6; }
|
||||
.proto-root .jc-drop svg { flex: none; margin-top: 2px; color: var(--ink-4); }
|
||||
.proto-root .jc-drop code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
|
||||
|
||||
.proto-root .jc-callout { display: flex; gap: 8px; align-items: flex-start; border-radius: 9px; padding: 11px 13px; font-size: 12.5px; line-height: 1.75; }
|
||||
.proto-root .jc-callout p { margin: 0; }
|
||||
.proto-root .jc-callout svg { flex: none; margin-top: 2px; }
|
||||
.proto-root .jc-callout.t-mute { background: var(--soft); color: var(--ink-2) }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.proto-root .jc-pulse-dot { animation: none; }
|
||||
.proto-root .jc-report, .proto-root .jc-rp-stamp { animation: none; }
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════
|
||||
响应式:宁可另起一行,也不损失完整度
|
||||
≥961px 六列全展开的表(列 min-width 之和 878px,容器有余量,绝不裁字、绝不横滚)
|
||||
≤960px 表 → 卡片:一条 PN 一张卡、一行一字段(标签 / 值),无横滚、无省略
|
||||
≤560px 字段行再拆成上下两行(标签在上、值在下)
|
||||
══════════════════════════════════════════════════════════════════════════ */
|
||||
@media (max-width: 1040px) { .proto-root .jc-four { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 560px) { .proto-root .jc-four { grid-template-columns: 1fr; } }
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.proto-root .jc-tbl-wrap { overflow: visible; border: 0; border-radius: 0; background: transparent; }
|
||||
.proto-root .jc-tbl-in,
|
||||
.proto-root .jc-tbl-in tbody,
|
||||
.proto-root .jc-tbl-in tr,
|
||||
.proto-root .jc-tbl-in td { display: block; width: 100%; }
|
||||
.proto-root .jc-tbl-in thead { display: none; }
|
||||
|
||||
/* 一条 PN = 一张卡 */
|
||||
.proto-root .jc-tbl-in tr { margin-bottom: 10px; padding: 4px 13px 9px; border: 1px solid var(--line-soft); border-radius: 11px; background: #fff; box-shadow: var(--card-shadow); }
|
||||
.proto-root .jc-tbl-in tr:last-child { margin-bottom: 0; }
|
||||
.proto-root .jc-tbl-in tbody tr:hover td { background: transparent; }
|
||||
.proto-root .jc-tbl-in tr.miss { border-color: #f3b6b3; background: #fffbfb; }
|
||||
|
||||
/* 卡头:第 N 条 */
|
||||
.proto-root .jc-tbl-in td.jc-rn { padding: 8px 0 8px; margin-bottom: 4px; border-bottom: 1px solid var(--line-soft); font-family: inherit; font-size: 12px; font-weight: 800; color: var(--ink-2); }
|
||||
.proto-root .jc-tbl-in td.jc-rn::before { content: "第 "; }
|
||||
.proto-root .jc-tbl-in td.jc-rn::after { content: " 条"; }
|
||||
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); }
|
||||
|
||||
/* 字段行:左标签 + 右值,值超长自动折行 */
|
||||
.proto-root .jc-tbl-in td:not(.jc-rn) { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 10px; align-items: center; padding: 6px 0; border-bottom: 1px dashed var(--line-soft); }
|
||||
.proto-root .jc-tbl-in tr td:last-child { border-bottom: 0; padding-bottom: 2px; }
|
||||
.proto-root .jc-tbl-in td:not(.jc-rn)::before {
|
||||
content: attr(data-h); font-size: 10.5px; font-weight: 700; letter-spacing: .02em; color: var(--ink-4); line-height: 1.5;
|
||||
}
|
||||
.proto-root .jc-tbl-in td[data-req="1"]:not(.jc-rn)::before { content: attr(data-h) " *"; }
|
||||
.proto-root .jc-tbl-in .jc-vcell { padding: 0; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-tbl-in .jc-vcell.num { text-align: left; }
|
||||
.proto-root .jc-tbl-in .jc-cell.num { text-align: left; }
|
||||
.proto-root .jc-tbl-in .jc-cell { min-width: 0; }
|
||||
}
|
||||
|
||||
/* 窄屏:落款三件各占一行,动作按钮撑满一行 */
|
||||
@media (max-width: 560px) {
|
||||
.proto-root .jc-rp-acts { margin-left: 0; width: 100%; flex-wrap: wrap; }
|
||||
.proto-root .jc-rp-acts .jc-btn { flex: 1 1 auto; justify-content: center; }
|
||||
.proto-root .jc-hintline { flex: 1 1 100%; }
|
||||
.proto-root .jc-tbl-in td:not(.jc-rn) { grid-template-columns: 1fr; gap: 3px; align-items: start; }
|
||||
}
|
||||
|
||||
|
||||
/* ══ 以下取自 A1-询价受理记录.html(归档件独有的件)══ */
|
||||
/* ── 料号分类:分布条 + 图例 ───────────────────────────────────────── */
|
||||
.proto-root .jc-dist { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 13px 15px; border: 1px solid var(--line-soft); border-radius: 12px; background: linear-gradient(180deg, #fbfcfe, #fff); }
|
||||
.proto-root .jc-distbar { flex: 1 1 320px; min-width: 260px; display: flex; gap: 3px; }
|
||||
.proto-root .jc-distbar > i { height: 30px; border-radius: 4px; display: grid; place-items: center; font-size: 10.5px; font-weight: 800; color: #fff; font-family: var(--mono); }
|
||||
.proto-root .jc-distbar > i.a { background: var(--ok); }
|
||||
.proto-root .jc-distbar > i.b { background: var(--brand); }
|
||||
.proto-root .jc-distbar > i.c { background: var(--warn-accent); }
|
||||
.proto-root .jc-distlg { display: flex; flex-wrap: wrap; gap: 8px 18px; }
|
||||
.proto-root .jc-distlg > span { display: inline-flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--ink-3); }
|
||||
.proto-root .jc-distlg em { width: 10px; height: 10px; border-radius: 3px; }
|
||||
.proto-root .jc-distlg em.a { background: var(--ok); } .proto-root .jc-distlg em.b { background: var(--brand); } .proto-root .jc-distlg em.c { background: var(--warn-accent); }
|
||||
.proto-root .jc-distlg b { font-family: var(--mono); font-weight: 800; color: var(--ink); }
|
||||
|
||||
/* 类别徽 A / B / C */
|
||||
.proto-root .jc-tbadge { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 6px; font-family: var(--mono); font-size: 11px; font-weight: 800; line-height: 1; color: #fff; }
|
||||
.proto-root .jc-tbadge.a { background: var(--ok); } .proto-root .jc-tbadge.b { background: var(--brand); } .proto-root .jc-tbadge.c { background: var(--warn-accent); }
|
||||
|
||||
/* ── 结论与去向 ─────────────────────────────────────────────────────────
|
||||
G2 是**分支网关**:三条互斥条件按序求值,只命中一条、只落一个活动。
|
||||
所以这里不是三张并列的去向卡,而是一张**判定表 + 一条结论**。 */
|
||||
/* 判定表:一行一条规则,命中行上色留痕(DMN 口径) */
|
||||
.proto-root .jc-dmn { border: 1px solid var(--line-soft); border-radius: 12px; overflow: hidden; }
|
||||
.proto-root .jc-dmn-h, .proto-root .jc-dmn-r { display: grid; grid-template-columns: 40px minmax(0, 1fr) 170px 200px; align-items: center; gap: 12px; padding: 9px 14px; }
|
||||
.proto-root .jc-dmn-h { background: var(--soft); border-bottom: 1px solid var(--line-soft); font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); }
|
||||
.proto-root .jc-dmn-r { font-size: 12px; color: var(--ink-4); background: #fff; }
|
||||
.proto-root .jc-dmn-r + .jc-dmn-r { border-top: 1px solid var(--line-soft); }
|
||||
.proto-root .jc-dmn-i { font-family: var(--mono); font-size: 11px; font-weight: 800; }
|
||||
.proto-root .jc-dmn-c { line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-dmn-t { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; }
|
||||
.proto-root .jc-dmn-t code { font-family: var(--mono); font-size: 10.5px; font-weight: 800; color: var(--ink-4); background: var(--soft); border: 1px solid var(--line); border-radius: 5px; padding: 2px 6px; }
|
||||
.proto-root .jc-dmn-v { font-size: 11px; line-height: 1.55; }
|
||||
/* 命中行:上色 + 左缘棱 + 加粗 */
|
||||
.proto-root .jc-dmn-r.hit { position: relative; background: var(--tone-soft); color: var(--ink-2); }
|
||||
.proto-root .jc-dmn-r.hit::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--tone); }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-i { color: var(--tone); }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-c { color: var(--ink); }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-c b { color: var(--tone); font-weight: 800; }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-t { color: var(--ink); }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-t code { color: #fff; background: var(--tone); border-color: var(--tone); }
|
||||
.proto-root .jc-dmn-v.y { display: inline-flex; align-items: center; gap: 5px; font-weight: 800; color: var(--tone); }
|
||||
.proto-root .jc-dmn-v.n { color: var(--ink-4); }
|
||||
/* 色调随命中类别 */
|
||||
.proto-root .jc-dmn.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.10); }
|
||||
.proto-root .jc-dmn.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.09); }
|
||||
.proto-root .jc-dmn.k-c { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.11); }
|
||||
|
||||
/* 结论横幅:命中条件 → 唯一去向 */
|
||||
.proto-root .jc-verdict {
|
||||
position: relative; overflow: hidden; display: flex; align-items: stretch; flex-wrap: wrap; gap: 16px;
|
||||
margin-top: 11px; padding: 16px 18px; border: 1px solid var(--tone-line); border-radius: 13px;
|
||||
background: linear-gradient(105deg, var(--tone-soft), #fff 62%);
|
||||
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 10px 24px -16px rgba(31,35,41,.4);
|
||||
--tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.14); --tone-line: #ffd9a8;
|
||||
}
|
||||
.proto-root .jc-verdict.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.13); --tone-line: #bfedb4; }
|
||||
.proto-root .jc-verdict.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft); }
|
||||
.proto-root .jc-verdict::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 5px; background: var(--tone); }
|
||||
.proto-root .jc-verdict > * { position: relative; z-index: 1; }
|
||||
.proto-root .jc-vd-l { flex: 1 1 300px; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
|
||||
.proto-root .jc-vd-k { font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
|
||||
.proto-root .jc-vd-cond { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 15px; font-weight: 800; color: var(--ink); }
|
||||
.proto-root .jc-vd-why { margin: 0; font-size: 11.5px; line-height: 1.7; color: var(--ink-3); overflow-wrap: anywhere; }
|
||||
.proto-root .jc-vd-why code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
|
||||
.proto-root .jc-vd-arrow { flex: none; align-self: center; color: var(--tone); }
|
||||
.proto-root .jc-vd-r { flex: 1 1 260px; min-width: 0; display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-radius: 11px; background: #fff; border: 1px solid var(--tone-line); }
|
||||
.proto-root .jc-vd-node { display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 800; color: var(--ink); }
|
||||
.proto-root .jc-vd-node code { font-family: var(--mono); font-size: 12px; font-weight: 800; color: #fff; background: var(--tone); border-radius: 6px; padding: 3px 8px; }
|
||||
.proto-root .jc-vd-who { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--ink-2); }
|
||||
.proto-root .jc-vd-who > i { width: 20px; height: 20px; border-radius: 999px; display: inline-grid; place-items: center; font-style: normal; font-size: 10px; font-weight: 700; color: #fff; background: var(--tone); }
|
||||
.proto-root .jc-vd-next { margin: 0; padding-top: 8px; border-top: 1px dashed var(--line-soft); font-size: 10.5px; line-height: 1.65; color: var(--ink-4); }
|
||||
.proto-root .jc-vd-next b { color: var(--ink-2); font-weight: 700; }
|
||||
/* 命中分支携带的条目 */
|
||||
.proto-root .jc-pnlist { display: flex; flex-wrap: wrap; gap: 5px; margin: 0; padding: 0; list-style: none; }
|
||||
.proto-root .jc-pnlist > li { display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 999px; background: #fff; border: 1px solid var(--tone-line); font-size: 11px; color: var(--ink-2); overflow-wrap: anywhere; }
|
||||
.proto-root .jc-pnlist > li > i { flex: none; width: 5px; height: 5px; border-radius: 999px; background: var(--tone); }
|
||||
.proto-root .jc-pnlist code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
|
||||
|
||||
/* ── 落地动作 ───────────────────────────────────────────────────────── */
|
||||
.proto-root .jc-acts { display: flex; flex-direction: column; gap: 0; border: 1px solid var(--line-soft); border-radius: 11px; overflow: hidden; }
|
||||
.proto-root .jc-act { display: flex; align-items: flex-start; gap: 9px; padding: 10px 13px; font-size: 12px; color: var(--ink-2); line-height: 1.6; background: #fff; }
|
||||
.proto-root .jc-act + .jc-act { border-top: 1px solid var(--line-soft); }
|
||||
.proto-root .jc-act > i { flex: none; width: 18px; height: 18px; margin-top: 1px; border-radius: 999px; display: inline-grid; place-items: center; color: #fff; background: var(--ok); }
|
||||
.proto-root .jc-act b { font-weight: 700; color: var(--ink); }
|
||||
.proto-root .jc-act span { color: var(--ink-4); }
|
||||
.proto-root .jc-act code { font-family: var(--mono); font-size: 10.5px; color: var(--brand-deep); background: var(--brand-wash); border-radius: 4px; padding: 1px 5px; }
|
||||
|
||||
|
||||
.proto-root .jc-done-tag { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; flex: none; padding: 6px 13px; border-radius: 999px; font-size: 11.5px; font-weight: 800; color: var(--ok); background: #fff; border: 1px solid #bfedb4; }
|
||||
.proto-root .jc-vcell.dim { color: var(--ink-4); }
|
||||
.proto-root .jc-tbl-in td.jc-ty { width: 44px; text-align: center; }
|
||||
.proto-root .jc-tbl-in td.jc-ty { width: auto; text-align: left; }
|
||||
`;
|
||||
|
||||
/**
|
||||
* 图标精灵 —— 与样式表同一出处,两份目标件的 `<symbol>` 取并集。
|
||||
* 沙箱里取不到图标字体,所以图标一律内联;`<use href="#i-xxx">` 引的就是这一坨。
|
||||
*/
|
||||
export const PRD_SPRITE = `<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs><symbol id="i-alert" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></symbol><symbol id="i-arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol><symbol id="i-boxes" viewBox="0 0 24 24"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></symbol><symbol id="i-building" viewBox="0 0 24 24"><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/><path d="M10 18h4"/></symbol><symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol><symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol><symbol id="i-file-check" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="m9 15 2 2 4-4"/></symbol><symbol id="i-file-text" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></symbol><symbol id="i-git-branch" viewBox="0 0 24 24"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></symbol><symbol id="i-history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></symbol><symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></symbol><symbol id="i-lock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol><symbol id="i-mail-open" viewBox="0 0 24 24"><path d="M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0z"/><path d="m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10"/></symbol><symbol id="i-pen" viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z"/></symbol><symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol><symbol id="i-sheet" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M8 13h2"/><path d="M14 13h2"/><path d="M8 17h2"/><path d="M14 17h2"/></symbol><symbol id="i-shield-check" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></symbol><symbol id="i-user" viewBox="0 0 24 24"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/></symbol><symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></symbol></defs></svg>`;
|
||||
@@ -1,43 +1,769 @@
|
||||
/**
|
||||
* A7 视图模版 —— 归档两屏:结果登记 / 闭环待放行(AM 07)。
|
||||
* 归档是这条流的收尾:它写回的是**下一单的判据**,所以要说清「这次学到了什么」。
|
||||
* A7 情报归档的 HTML 报告模版 —— **活动自己持有的资产**,随包版本冻结。
|
||||
*
|
||||
* 两个局面各一张(与 A2/A3/A4/A6 同款口径):
|
||||
* · `archive-worksheet` **情报归档工作单** —— 归一与逐条归因期间摆它:
|
||||
* 逐颗料一张情报档案卡(既成事实在左、归因在右)、归因六档与败价幅度 A-01 两把尺、
|
||||
* 填回工作表就长在同一屏;
|
||||
* · `archive-roster` **情报归档清单** —— 入库口径拍板、放行与归档摆它:整份情报卡,
|
||||
* 口径分布、下一单谁用得上的落边图与落地动作在上面。
|
||||
*
|
||||
* 「1:1」的落点是**每份件与它的办结快照**(在办可编辑 ↔ 回执收下那一刻的 finalHtml),
|
||||
* 不是全程只许一张:作业的件与收口的件是两件工作两份件(规范姊妹篇 §1)。
|
||||
*
|
||||
* 版面语言沿用 A1 从 PRD 目标件机械抽取的那套(`prd-style.ts` 原样拷入)——
|
||||
* A7 没有自己的 PRD 目标件,与 A2/A3/A4/A6 同一处置:沿用家族版面自造。
|
||||
*
|
||||
* 取值三纪律:顶层只取汇总列与单值列;逐条走 `mine:quote_archive.intel`;
|
||||
* 列名逐字照 `declarations.ledger` 抄。**模版不算不判** —— 缺归因红带、归因徽、口径徽、
|
||||
* 分布条全部由基座算好的数经 `data-class-of` 投影,判断留在样式表。
|
||||
*/
|
||||
import { kvCard, noteCard, tableCard, viewShell } from '@sdk';
|
||||
import { PRD_CSS, PRD_SPRITE } from './prd-style.ts';
|
||||
|
||||
export const ARCHIVE_LOGGED = viewShell({
|
||||
title: '结果登记',
|
||||
kicker: 'A7 · 情报归档',
|
||||
tone: 'archive',
|
||||
/* ==================== 绑定期补的样式(照 A1/A3/A4/A6 的 BIND_CSS,地址换成本活动)==================== */
|
||||
|
||||
const BIND_CSS = `
|
||||
/* 空占位三级(2026-08-02 全局口径):必填红 / 建议填橘 / 选填灰 —— 值真的空才轮到 */
|
||||
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after,
|
||||
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after,
|
||||
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after {
|
||||
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
|
||||
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
|
||||
}
|
||||
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after { content: "必填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
|
||||
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after { content: "建议填"; color: var(--warn-accent); background: var(--warn-soft); border-color: #f0cf9a; }
|
||||
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after { content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line); }
|
||||
/* 归档件里空格子就是「没有」,不再摆「你还能填」的胶囊 */
|
||||
.proto-root .jc-tbl-in td.dimempty > .jc-vcell:empty::after { content: "—"; color: var(--ink-4); border: 0; background: none; padding: 0; }
|
||||
|
||||
/* 缺归因的整行浅红 + 行首红条 —— 原型的 tr.miss,这里由 :has() 认 */
|
||||
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td { background: #fffbfb; }
|
||||
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty):hover td { background: #fff5f5; }
|
||||
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn { color: var(--danger); font-weight: 800; position: relative; }
|
||||
@media (min-width: 1001px) {
|
||||
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn::after {
|
||||
content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
/* 序号由 CSS 计数器排 */
|
||||
.proto-root .jc-tbl-in tbody { counter-reset: rn; }
|
||||
.proto-root .jc-tbl-in td.jc-rn::before { counter-increment: rn; content: counter(rn); }
|
||||
@media (max-width: 1000px) {
|
||||
.proto-root .jc-tbl-in td.jc-rn::before { content: "第 " counter(rn) " 条"; }
|
||||
.proto-root .jc-tbl-in td.jc-rn::after { content: none; }
|
||||
}
|
||||
|
||||
/* 就地改:可改格子渲了只读与输入两份,默认锁着,表上加 on 才换孪生件 */
|
||||
.proto-root .jc-cell { display: none; }
|
||||
.proto-root .jc-tbl-in.on td > .jc-vcell,
|
||||
.proto-root .jc-tbl-in.on td > .jc-tbadge { display: none; }
|
||||
.proto-root .jc-tbl-in.on td > .jc-cell { display: block; }
|
||||
/* 结果徽 / 归因徽 / 口径徽不进编辑态(承继与引擎产物),on 时也照旧摆着 */
|
||||
.proto-root .jc-tbl-in.on td > .jc-obadge,
|
||||
.proto-root .jc-tbl-in.on td > .jc-dbadge { display: inline-flex; }
|
||||
/* 数值列与表头同侧左对齐(一行一个料,彼此不相加) */
|
||||
.proto-root .jc-vcell.num, .proto-root .jc-cell.num { text-align: left; }
|
||||
/* 编辑态下必填空格子:红框 + 整行浅红(由 :placeholder-shown 认) */
|
||||
.proto-root .jc-tbl-in.on td.req > .jc-cell:placeholder-shown { border-color: #f3b6b3; background: #fffafa; }
|
||||
.proto-root .jc-tbl-in.on tbody tr:has(td.req > .jc-cell:placeholder-shown) td { background: #fffbfb; }
|
||||
|
||||
/* 铺满工作区 */
|
||||
.jc-stage { max-width: none; padding: 0 0 4px; }
|
||||
.proto-root .jc-report { border: 0; border-radius: 0; box-shadow: none; }
|
||||
|
||||
/* 那道闸 —— 默认「已锁定 + 编辑」,开了变成「取消 / 保存」;只在可改屏现身 */
|
||||
.proto-root .jc-editgate { display: none; }
|
||||
html.editable .proto-root .jc-editgate {
|
||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
margin-bottom: 10px; padding: 8px 12px; border-radius: 9px;
|
||||
background: var(--soft); border: 1px solid var(--line-soft);
|
||||
font-size: 11.5px; color: var(--ink-3);
|
||||
}
|
||||
.proto-root .jc-editgate-t { display: inline-flex; align-items: center; gap: 6px; flex: 1 1 auto; min-width: 0; }
|
||||
.proto-root .jc-editgate-t b { color: var(--ink-2); font-weight: 700; }
|
||||
.proto-root .jc-egbtn {
|
||||
flex: none; display: inline-flex; align-items: center; gap: 4px; cursor: pointer;
|
||||
border: 1px solid var(--line); border-radius: 7px; padding: 5px 11px;
|
||||
font: inherit; font-size: 11.5px; font-weight: 700; color: var(--ink-2); background: #fff;
|
||||
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
|
||||
}
|
||||
.proto-root .jc-egbtn:hover { border-color: var(--brand); color: var(--brand); }
|
||||
.proto-root .jc-egbtn.save { color: #fff; background: var(--brand); border-color: var(--brand); }
|
||||
.proto-root .jc-egbtn.save:hover { background: var(--brand-deep); border-color: var(--brand-deep); }
|
||||
.proto-root .jc-editgate .jc-egbtn.ghost, .proto-root .jc-editgate .jc-egbtn.save { display: none; }
|
||||
.proto-root .jc-editgate.on { background: var(--brand-wash); border-color: var(--brand-soft); color: var(--brand-deep); }
|
||||
.proto-root .jc-editgate.on .jc-egbtn.open { display: none; }
|
||||
.proto-root .jc-editgate.on .jc-egbtn.ghost, .proto-root .jc-editgate.on .jc-egbtn.save { display: inline-flex; }
|
||||
.proto-root .jc-editgate.on .jc-editgate-t { visibility: hidden; }
|
||||
|
||||
/* 缺归因红带 —— 只在可改屏出,一格不缺自动收(v-0 那一档 display:none) */
|
||||
.proto-root .jc-gapbar { display: none; }
|
||||
html.editable .proto-root .jc-gapbar {
|
||||
display: flex; align-items: flex-start; gap: 8px;
|
||||
margin: 0 0 10px; padding: 9px 12px; border-radius: 9px;
|
||||
background: #fff5f5; border: 1px solid #f3b6b3; color: var(--danger);
|
||||
font-size: 12px; line-height: 1.65;
|
||||
}
|
||||
.proto-root .jc-gapbar svg { flex: 0 0 auto; margin-top: 2px; }
|
||||
.proto-root .jc-gapbar b { font-weight: 800; }
|
||||
.proto-root .jc-gapbar span { color: #9a3b36; }
|
||||
html.editable .proto-root .jc-gapbar.v-0 { display: none; }
|
||||
|
||||
/* 那条「怎么改」的提示只在开了闸之后说 */
|
||||
.proto-root .jc-editbar { display: none; }
|
||||
.proto-root .jc-editgate.on + .jc-editbar { display: flex; }
|
||||
|
||||
/* 增删行 */
|
||||
.proto-root .jc-rowdel { display: none; }
|
||||
.proto-root .jc-tbl-in.on td.jc-rn::before { content: none; }
|
||||
.proto-root .jc-tbl-in.on .jc-rowdel {
|
||||
display: block; width: 20px; height: 20px; margin: 0 auto; cursor: pointer;
|
||||
border: 1px solid var(--line); border-radius: 6px; padding: 0;
|
||||
font: inherit; font-size: 13px; line-height: 1; color: var(--ink-3); background: #fff;
|
||||
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
|
||||
}
|
||||
.proto-root .jc-tbl-in.on .jc-rowdel:hover { border-color: var(--danger); color: var(--danger); background: #fff6f6; }
|
||||
.proto-root .jc-tbl-in.on tr.jc-row-gone td { background: #fff6f6; opacity: .55; }
|
||||
.proto-root .jc-tbl-in.on tr.jc-row-gone .jc-cell { text-decoration: line-through; color: var(--ink-3); }
|
||||
.proto-root .jc-tbl-in.on tr.jc-row-gone .jc-rowdel { border-color: var(--danger); color: var(--danger); background: #fff; }
|
||||
.proto-root .jc-tbl-in.on tr.jc-row-new td { background: #f2fbf7; }
|
||||
.proto-root .jc-addrow { display: none; }
|
||||
html.editable .proto-root .jc-tbl-wrap:has(table.on) + .jc-addrow {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
margin: -2px 0 12px; padding: 7px 12px; border-radius: 9px;
|
||||
background: var(--soft); border: 1px dashed var(--line);
|
||||
}
|
||||
.proto-root .jc-addrow em { font-style: normal; font-size: 11px; color: var(--ink-3); }
|
||||
|
||||
/* 结果徽(承继上游定案):成交绿 / 不成交灰 —— 一格一料的刻度也用这套色 */
|
||||
.proto-root .jc-tbadge { background: var(--ink-4); }
|
||||
.proto-root .jc-tbadge.v-won { background: var(--ok); }
|
||||
.proto-root .jc-tbadge.v-lost { background: var(--ink-4); }
|
||||
.proto-root .jc-segmeter > i.v-won { background: var(--ok); }
|
||||
.proto-root .jc-segmeter > i.v-lost { background: var(--ink-4); }
|
||||
|
||||
/* 归因徽:价格红 / 交期橙 / 认证蓝 / 规格深蓝 / 关系灰 / 说不清灰虚 —— 值是机器码,人读名交给样式表 */
|
||||
.proto-root .jc-obadge {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 62px; height: 20px; padding: 0 7px; border-radius: 6px;
|
||||
font-size: 10px; font-weight: 800; letter-spacing: .02em;
|
||||
color: var(--ink-4); border: 1.5px dashed var(--ink-4); background: #fff;
|
||||
}
|
||||
.proto-root .jc-obadge::after { content: "待归因"; }
|
||||
.proto-root .jc-obadge.v-price { color: var(--danger); border-color: var(--danger); border-style: solid; }
|
||||
.proto-root .jc-obadge.v-price::after { content: "输在价格"; }
|
||||
.proto-root .jc-obadge.v-leadtime { color: var(--warn-accent); border-color: var(--warn-accent); border-style: solid; }
|
||||
.proto-root .jc-obadge.v-leadtime::after { content: "输在交期"; }
|
||||
.proto-root .jc-obadge.v-cert { color: var(--brand); border-color: var(--brand); border-style: solid; }
|
||||
.proto-root .jc-obadge.v-cert::after { content: "认证资质"; }
|
||||
.proto-root .jc-obadge.v-spec { color: var(--brand-deep); border-color: var(--brand-deep); border-style: solid; }
|
||||
.proto-root .jc-obadge.v-spec::after { content: "规格不符"; }
|
||||
.proto-root .jc-obadge.v-relation { color: var(--ink-3); border-color: var(--ink-4); border-style: solid; }
|
||||
.proto-root .jc-obadge.v-relation::after { content: "既有供应"; }
|
||||
.proto-root .jc-obadge.v-other { color: var(--ink-3); border-color: var(--ink-4); border-style: dashed; }
|
||||
.proto-root .jc-obadge.v-other::after { content: "说不清"; }
|
||||
|
||||
/* 口径徽:待定橙虚 / 全量绿 / 脱敏蓝 / 只留内部灰 */
|
||||
.proto-root .jc-dbadge {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 66px; height: 20px; padding: 0 7px; border-radius: 6px;
|
||||
font-size: 10px; font-weight: 800; letter-spacing: .02em;
|
||||
color: var(--ink-4); border: 1.5px solid var(--ink-4); background: #fff;
|
||||
}
|
||||
.proto-root .jc-dbadge::after { content: "未落"; }
|
||||
.proto-root .jc-dbadge.v-pending { color: var(--warn-accent); border-color: var(--warn-accent); border-style: dashed; }
|
||||
.proto-root .jc-dbadge.v-pending::after { content: "待你拍板"; }
|
||||
.proto-root .jc-dbadge.v-full { color: var(--ok); border-color: var(--ok); }
|
||||
.proto-root .jc-dbadge.v-full::after { content: "全量入库"; }
|
||||
.proto-root .jc-dbadge.v-masked { color: var(--brand); border-color: var(--brand); }
|
||||
.proto-root .jc-dbadge.v-masked::after { content: "脱敏入库"; }
|
||||
.proto-root .jc-dbadge.v-internal { color: var(--ink-3); border-color: var(--ink-4); }
|
||||
.proto-root .jc-dbadge.v-internal::after { content: "只留内部"; }
|
||||
|
||||
/* 败价幅度(A-01)—— 只在归因落「输在价格」的行上有值 */
|
||||
.proto-root .jc-cbadge { font-size: 10.5px; font-weight: 700; color: var(--ink-4); }
|
||||
.proto-root .jc-cbadge.v-slight { color: var(--ok); }
|
||||
.proto-root .jc-cbadge.v-slight::after { content: "擦肩而过 ≤3%"; }
|
||||
.proto-root .jc-cbadge.v-moderate { color: var(--warn-accent); }
|
||||
.proto-root .jc-cbadge.v-moderate::after { content: "够得着 3~10%"; }
|
||||
.proto-root .jc-cbadge.v-structural { color: var(--danger); }
|
||||
.proto-root .jc-cbadge.v-structural::after { content: "结构性价差 >10%"; }
|
||||
|
||||
/* 分布条:四段按条数分宽 —— 条数投影成 v-<N>,梯子取宽;零条那段自己消失 */
|
||||
.proto-root .jc-distbar > i { flex: 24; min-width: 34px; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.proto-root .jc-distbar > i.v-0, .proto-root .jc-distbar > i:not([class*=" v-"]) { display: none; }
|
||||
.proto-root .jc-distbar > i.v-1 { flex: 1 } .proto-root .jc-distbar > i.v-2 { flex: 2 }
|
||||
.proto-root .jc-distbar > i.v-3 { flex: 3 } .proto-root .jc-distbar > i.v-4 { flex: 4 }
|
||||
.proto-root .jc-distbar > i.v-5 { flex: 5 } .proto-root .jc-distbar > i.v-6 { flex: 6 }
|
||||
.proto-root .jc-distbar > i.v-7 { flex: 7 } .proto-root .jc-distbar > i.v-8 { flex: 8 }
|
||||
.proto-root .jc-distbar > i.v-9 { flex: 9 } .proto-root .jc-distbar > i.v-10 { flex: 10 }
|
||||
.proto-root .jc-distbar > i.v-11 { flex: 11 } .proto-root .jc-distbar > i.v-12 { flex: 12 }
|
||||
.proto-root .jc-distbar > i.v-13 { flex: 13 } .proto-root .jc-distbar > i.v-14 { flex: 14 }
|
||||
.proto-root .jc-distbar > i.v-15 { flex: 15 } .proto-root .jc-distbar > i.v-16 { flex: 16 }
|
||||
.proto-root .jc-distbar > i.v-17 { flex: 17 } .proto-root .jc-distbar > i.v-18 { flex: 18 }
|
||||
.proto-root .jc-distbar > i.v-19 { flex: 19 } .proto-root .jc-distbar > i.v-20 { flex: 20 }
|
||||
.proto-root .jc-distbar > i.p { background: var(--ok); }
|
||||
.proto-root .jc-distbar > i.n { background: var(--warn-accent); }
|
||||
.proto-root .jc-distbar > i.r { background: var(--brand); }
|
||||
.proto-root .jc-distbar > i.k { background: var(--ink-4); }
|
||||
.proto-root .jc-distbar > i.p::before { content: "全量入库 · "; }
|
||||
.proto-root .jc-distbar > i.n::before { content: "待拍板 · "; }
|
||||
.proto-root .jc-distbar > i.r::before { content: "脱敏入库 · "; }
|
||||
.proto-root .jc-distbar > i.k::before { content: "只留内部 · "; }
|
||||
.proto-root .jc-logo svg, .proto-root .jc-face svg, .proto-root .jc-rp-ava svg { width: 17px; height: 17px; }
|
||||
|
||||
/* ── 情报档案卡(工作单独有,照 A6 报价档案卡的骨架)──────────────────────
|
||||
一颗料一张卡,data-rows 逐行复制:左既成事实、右归因。
|
||||
形态按结果分档(data-class-of):不成交的整卡描红、成交的描绿 —— 判断在样式表。 */
|
||||
.proto-root .jc-selcards { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
|
||||
.proto-root .jc-selcard { border: 1px solid var(--line); border-radius: 11px; background: #fff; overflow: hidden; }
|
||||
.proto-root .jc-selcard.v-lost { border-color: #f3b6b3; box-shadow: 0 1px 0 rgba(191,60,52,.05); }
|
||||
.proto-root .jc-selcard.v-won { border-color: #bfe3cd; }
|
||||
.proto-root .jc-selcard-h {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
padding: 9px 14px; background: var(--soft); border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
.proto-root .jc-selcard.v-lost .jc-selcard-h { background: #fff6f5; border-bottom-color: #f3d3d0; }
|
||||
.proto-root .jc-selcard-h code { font-size: 13px; font-weight: 800; color: var(--ink); }
|
||||
.proto-root .jc-selcard-h .why { flex: 1 1 auto; min-width: 0; font-size: 11px; color: var(--ink-3); }
|
||||
.proto-root .jc-selcard-b { display: grid; grid-template-columns: 1fr 1fr; gap: 0; }
|
||||
@media (max-width: 1000px) { .proto-root .jc-selcard-b { grid-template-columns: 1fr; } }
|
||||
.proto-root .jc-selcol { padding: 11px 14px; }
|
||||
.proto-root .jc-selcol + .jc-selcol { border-left: 1px solid var(--line-soft); }
|
||||
@media (max-width: 1000px) { .proto-root .jc-selcol + .jc-selcol { border-left: 0; border-top: 1px solid var(--line-soft); } }
|
||||
.proto-root .jc-selcol h5 { margin: 0 0 7px; font-size: 10.5px; font-weight: 800; letter-spacing: .08em; color: var(--ink-3); }
|
||||
.proto-root .jc-kvrow { display: flex; gap: 8px; padding: 3px 0; font-size: 12px; line-height: 1.55; }
|
||||
.proto-root .jc-kvrow i { flex: 0 0 84px; font-style: normal; color: var(--ink-4); font-size: 11px; padding-top: 1px; }
|
||||
.proto-root .jc-kvrow span { flex: 1 1 auto; min-width: 0; color: var(--ink); overflow-wrap: anywhere; }
|
||||
.proto-root .jc-kvrow span:empty::after { content: "—"; color: var(--ink-4); }
|
||||
.proto-root .jc-selcard .sc-req:empty::after {
|
||||
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
|
||||
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
|
||||
content: "必填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3;
|
||||
}
|
||||
.proto-root .jc-selcard .sc-sug:empty::after {
|
||||
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
|
||||
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
|
||||
content: "建议填"; color: var(--warn-accent); background: var(--warn-soft); border-color: #f0cf9a;
|
||||
}
|
||||
.proto-root .jc-selcard .sc-opt:empty::after {
|
||||
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
|
||||
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
|
||||
content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line);
|
||||
}
|
||||
|
||||
/* 两把尺(静态规则表 —— 尺挂在墙上,不是这一单的数据) */
|
||||
.proto-root .jc-graderule { border: 1px solid var(--line); border-radius: 10px; overflow: hidden; margin-bottom: 12px; }
|
||||
.proto-root .jc-graderule-r { display: flex; gap: 12px; align-items: flex-start; padding: 9px 13px; font-size: 12px; line-height: 1.6; }
|
||||
.proto-root .jc-graderule-r + .jc-graderule-r { border-top: 1px solid var(--line-soft); }
|
||||
.proto-root .jc-graderule-r p { margin: 0; color: var(--ink-2); }
|
||||
.proto-root .jc-graderule-r p b { color: var(--ink); }
|
||||
.proto-root .jc-band {
|
||||
flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; margin-top: 1px;
|
||||
min-width: 84px; height: 20px; padding: 0 8px; border-radius: 6px;
|
||||
font-size: 10.5px; font-weight: 800; letter-spacing: .04em; border: 1.5px solid; background: #fff;
|
||||
}
|
||||
.proto-root .jc-band.ga { color: var(--ok); border-color: var(--ok); }
|
||||
.proto-root .jc-band.gb { color: var(--brand); border-color: var(--brand); }
|
||||
.proto-root .jc-band.gc { color: var(--warn-accent); border-color: var(--warn-accent); }
|
||||
.proto-root .jc-band.gk { color: var(--danger); border-color: var(--danger); }
|
||||
.proto-root .jc-band.gz { color: var(--ink-3); border-color: var(--ink-4); }
|
||||
|
||||
/* 一单情报是怎么走完的:报价 → 结果 → 归因 → 口径 → 下一单,内联线稿走位图 */
|
||||
.proto-root .jc-priceline { display: flex; align-items: center; gap: 0; margin: 2px 0 14px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 11px; background: var(--soft); }
|
||||
.proto-root .jc-plnode { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 3px; align-items: flex-start; }
|
||||
.proto-root .jc-plnode b { font-size: 10.5px; font-weight: 800; letter-spacing: .06em; color: var(--ink-3); }
|
||||
.proto-root .jc-plnode span { font-size: 12px; color: var(--ink-2); line-height: 1.5; }
|
||||
.proto-root .jc-plarrow { flex: 0 0 auto; padding: 0 10px; color: var(--brand-soft); }
|
||||
@media (max-width: 1000px) { .proto-root .jc-priceline { flex-direction: column; align-items: stretch; gap: 8px; } .proto-root .jc-plarrow { display: none; } }
|
||||
|
||||
/* ── 落边图:情报卡清单 → 归档闭环 → 下一单的三个用处(件不算不判)── */
|
||||
.proto-root .route { margin: 2px 0 14px; padding: 10px 6px 6px; border: 1px solid var(--line); border-radius: 11px; background: #fff; }
|
||||
.proto-root .route svg { width: 100%; height: auto; display: block; }
|
||||
.proto-root .route .rspine { stroke: var(--brand); stroke-width: 2; }
|
||||
.proto-root .route .redge { stroke: var(--line); stroke-width: 2; stroke-dasharray: 5 5; fill: none; }
|
||||
.proto-root .route .rnode { fill: #fff; stroke: var(--line); stroke-width: 1.5; }
|
||||
.proto-root .route .rnode.hit { stroke: var(--brand); stroke-width: 2; }
|
||||
.proto-root .route .rtxt { font-size: 12px; font-weight: 800; fill: var(--ink-3); }
|
||||
.proto-root .route .rtxt.hit { fill: var(--brand-deep); }
|
||||
.proto-root .route .rsub { font-size: 10px; fill: var(--ink-4); }
|
||||
.proto-root .route .rcond { font-size: 10.5px; font-weight: 700; fill: var(--ink-4); }
|
||||
.proto-root .route .rtip { fill: var(--line); }
|
||||
.proto-root .route .rtip.hit { fill: var(--brand); }
|
||||
.proto-root .route .legend { display: flex; gap: 14px; flex-wrap: wrap; padding: 6px 10px 2px; font-size: 10.5px; color: var(--ink-4); }
|
||||
|
||||
/* 结论带 */
|
||||
.proto-root .concl { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; align-items: start; margin: 2px 0 14px; padding: 11px 14px; border: 1px solid var(--line); border-radius: 11px; background: var(--soft); }
|
||||
.proto-root .concl svg { grid-row: span 2; margin-top: 2px; color: var(--brand); }
|
||||
.proto-root .concl .cl-main { font-size: 12.5px; color: var(--ink); line-height: 1.6; }
|
||||
.proto-root .concl .cl-main b { font-weight: 800; }
|
||||
.proto-root .concl .cl-route { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; font-size: 11.5px; color: var(--ink-3); }
|
||||
.proto-root .concl .cl-route em { font-style: normal; color: var(--ink-4); }
|
||||
.proto-root .concl .cl-route b { color: var(--brand-deep); font-weight: 800; }
|
||||
.proto-root .concl .cl-route code { font-family: var(--mono, ui-monospace, monospace); font-size: 10.5px; color: var(--ink-4); }
|
||||
`;
|
||||
|
||||
/** 一枚图标 —— 与原型同一写法 */
|
||||
const ico = (name: string, size = 11): string =>
|
||||
`<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-${name}"/></svg>`;
|
||||
|
||||
/** 三层壳照原型:少一层样式表全失效 */
|
||||
const doc = (input: { badge: string; kicker: string; title: string; titleRef: string; state: string; body: string; foot: string }): string =>
|
||||
`<style>${PRD_CSS}${BIND_CSS}</style>
|
||||
${PRD_SPRITE}
|
||||
<div class="proto-root"><div class="app-shell"><div class="jc-stage">
|
||||
<article class="jc-report">
|
||||
<header class="jc-rp-head">
|
||||
<div class="jc-rp-badge">${ico(input.badge, 17)}</div>
|
||||
<div class="jc-rp-tit">
|
||||
<em>${input.kicker}</em>
|
||||
<h3>${input.title} <span data-slot="${input.titleRef}"></span></h3>
|
||||
</div>
|
||||
${input.state}
|
||||
</header>
|
||||
<div class="jc-rp-bd">${input.body}</div>
|
||||
<footer class="jc-rp-foot">${input.foot}</footer>
|
||||
</article>
|
||||
</div></div></div>`;
|
||||
|
||||
const part = (label: string, aside = '', tone = ''): string =>
|
||||
`<div class="jc-rp-part"><b>${label}</b><i class="ln"></i>${aside === '' ? '' : `<em class="${tone}">${aside}</em>`}</div>`;
|
||||
|
||||
const sign = (note: string, tag = ''): string =>
|
||||
`<div class="jc-rp-sign">
|
||||
<span class="jc-rp-ava">${ico('user', 15)}</span>
|
||||
<div><b data-slot="case:owner"></b><span>${note}</span></div>
|
||||
</div>${tag === '' ? '' : `<span class="jc-done-tag">${ico('git-branch', 13)}${tag}</span>`}`;
|
||||
|
||||
/* ==================== 组成件 ==================== */
|
||||
|
||||
/** 要素四卡:客户 / 待归因 / 一格一料的结果刻度 / 情报时效 */
|
||||
const FOUR = `<div class="jc-four">
|
||||
<div class="jc-fcard">
|
||||
<span class="jc-fcard-k"><i>${ico('building')}</i>客户公司</span>
|
||||
<div class="jc-fcard-b">
|
||||
<span class="jc-logo">${ico('building', 17)}</span>
|
||||
<div class="jc-fcard-t"><b data-slot="customer_name" data-blank></b><span data-slot="customer_code" data-blank></span></div>
|
||||
</div>
|
||||
<span class="jc-fcard-note">承自上游;单飞补历史时此栏可空</span>
|
||||
</div>
|
||||
|
||||
<div class="jc-fcard t-teal">
|
||||
<span class="jc-fcard-k"><i>${ico('boxes')}</i>待归因</span>
|
||||
<div class="jc-count"><b data-slot="quote_archive_intel_missing"></b><span>/ <b data-slot="quote_archive_intel_rows"></b> 条情报</span></div>
|
||||
<span class="jc-fcard-note">输赢归因还空着的行 —— 这一列只有跟单的人填得了,系统不替人归因;补一格少一格,基座现数</span>
|
||||
</div>
|
||||
|
||||
<div class="jc-fcard">
|
||||
<span class="jc-fcard-k"><i>${ico('shield-check')}</i>逐条结果</span>
|
||||
<div class="jc-segmeter" data-rows="mine:quote_archive.intel">
|
||||
<i data-class-of="deal_result"></i>
|
||||
</div>
|
||||
<span class="jc-fcard-note">一格一条料 · 绿=成交(下一单的基准价),灰=不成交(下一单的判据在归因里)</span>
|
||||
</div>
|
||||
|
||||
<div class="jc-fcard t-ok">
|
||||
<span class="jc-fcard-k"><i>${ico('history')}</i>情报时效</span>
|
||||
<span class="jc-tagbig">${ico('check', 13)}<span>报价日 <b data-slot="quoted_at" data-blank></b></span></span>
|
||||
<span class="jc-fcard-note">下一单的三分类 P-03 看的就是「距上次报价多久」—— 这一格空着,这条情报到下一单就哑了</span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
/** 一单情报是怎么走完的 —— 走位图(形状写死,不算不判) */
|
||||
const ARCHIVE_LINE = `<div class="jc-priceline">
|
||||
<div class="jc-plnode"><b>既成事实</b><span>报过的价、谈成的价、什么时候报的 —— 一个数字都不改</span></div>
|
||||
<div class="jc-plarrow">${ico('arrow-right', 18)}</div>
|
||||
<div class="jc-plnode"><b>输赢归因</b><span>价格 / 交期 / 认证 / 规格 / 关系 / 说不清 —— 这一格由跟单的人落</span></div>
|
||||
<div class="jc-plarrow">${ico('arrow-right', 18)}</div>
|
||||
<div class="jc-plnode"><b>败价幅度 A-01</b><span>价差 ≤3% / 3~10% / >10%,决定下一单怎么用这条情报</span></div>
|
||||
<div class="jc-plarrow">${ico('arrow-right', 18)}</div>
|
||||
<div class="jc-plnode"><b>入库口径</b><span>全量 / 脱敏 / 只留内部 —— 进共享库的边界由人拍板</span></div>
|
||||
<div class="jc-plarrow">${ico('arrow-right', 18)}</div>
|
||||
<div class="jc-plnode"><b>下一单</b><span>三分类取时效 · 定价取基准 · 客户画像取行为</span></div>
|
||||
</div>`;
|
||||
|
||||
/** 缺归因红带 —— 与提交闸、放行门 L1 同一个数同一套算法(基座现数);一格不缺自动收 */
|
||||
const GAP_BAR = `<div class="jc-gapbar" data-class-of="quote_archive_intel_missing">${ico('alert', 14)}
|
||||
<span><b>还差 <span data-slot="quote_archive_intel_missing"></span> 行归因没落</b>
|
||||
—— <span data-slot="quote_archive_intel_missing_keys"></span>。
|
||||
按右上角「编辑」开表,红框那几格就是要填的:输在价格填 PRICE 并把竞对报的那个价填进「竞对价」,
|
||||
交期填 LEADTIME,认证资质填 CERT,规格不符填 SPEC,既有供应关系填 RELATION,
|
||||
问不出来填 OTHER 并在依据里写明未获反馈。补齐了执行单才提交得了。</span>
|
||||
</div>`;
|
||||
|
||||
const editGate = (address: string, what: string): string => `<div class="jc-editgate" data-edit-for="${address}">
|
||||
<span class="jc-editgate-t">${ico('lock', 12)}${what}<b>已锁定</b> —— 看的时候不误碰,要改按右边</span>
|
||||
<button type="button" class="jc-egbtn open" data-edit-open="${address}">${ico('pen', 12)}编辑</button>
|
||||
<button type="button" class="jc-egbtn ghost" data-edit-cancel="${address}">取消</button>
|
||||
<button type="button" class="jc-egbtn save" data-edit-save="${address}">${ico('check', 12)}保存</button>
|
||||
</div>
|
||||
<div class="jc-editbar">${ico('pen', 14)}
|
||||
<span>可填的是<b>输赢归因</b>、<b>竞对价</b>、<b>竞对是谁</b>与<b>依据 / 客户原话</b>四列:
|
||||
<b>这四列系统一格都不写</b> —— 客户为什么不下单只有你看得见,别让系统替他解释。
|
||||
归因落 PRICE 的行务必把竞对报的那个数填上,败价幅度全看这一格。不该归档的料按行首 × 撤掉。
|
||||
改动只落任务草稿层,<b>保存不等于放行</b>。</span>
|
||||
</div>`;
|
||||
|
||||
const addRowBar = (address: string): string => `<div class="jc-addrow">
|
||||
<button type="button" class="jc-egbtn ghost" data-edit-add="${address}">${ico('plus', 12)}加一行</button>
|
||||
<em>照末行的样子添一行空的;主键(型号 PN)填了才存得下,不填这一行不落库。</em>
|
||||
</div>`;
|
||||
|
||||
/**
|
||||
* 情报卡主表 —— 一行一料:既成事实列 + 人那一侧四列 + 算出来的败价幅度。
|
||||
*
|
||||
* `data-col` 只挂人可改的四列(cause_tag / competitor_price / competitor_name / evidence)
|
||||
* + 主键 pn —— 编辑面精确等于 BD 的职权面;**主键格必挂 `data-col`**(它不是编辑面,
|
||||
* 是行的钥匙:不挂,服务端按「全表一致列」补键,两行会对到同一行上,后者盖前者 ——
|
||||
* A2 走查逮过)。
|
||||
* 归因挂 `req`:空着出「必填」胶囊、整行标底,与提交闸、放行门同源(都数 `_missing`)。
|
||||
*/
|
||||
const INTEL_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
|
||||
<thead><tr>
|
||||
<th style="width:36px;min-width:36px"></th>
|
||||
<th style="width:44px;min-width:44px">果</th>
|
||||
<th style="min-width:170px">PN</th>
|
||||
<th style="min-width:84px">品类</th>
|
||||
<th style="min-width:70px">年用量</th>
|
||||
<th style="min-width:88px">报价日期</th>
|
||||
<th style="min-width:88px">我方报价</th>
|
||||
<th style="min-width:80px">成交价</th>
|
||||
<th class="req" style="width:92px;min-width:92px">输赢归因(你落)</th>
|
||||
<th style="min-width:80px">竞对价</th>
|
||||
<th style="min-width:96px">竞对是谁</th>
|
||||
<th style="width:104px;min-width:104px">败价幅度</th>
|
||||
<th style="min-width:150px;width:100%">依据 / 客户原话</th>
|
||||
</tr></thead>
|
||||
<tbody data-rows="mine:quote_archive.intel" data-edit="quote_archive.intel"><tr>
|
||||
<td class="jc-rn"><button type="button" class="jc-rowdel" data-edit-del="quote_archive.intel" title="撤掉这一行">×</button></td>
|
||||
<td class="jc-ty" data-h="果"><span class="jc-tbadge" data-class-of="deal_result" data-slot="deal_result"></span></td>
|
||||
<td data-h="PN"><span class="jc-vcell mono" data-slot="pn" data-col="pn" data-blank></span></td>
|
||||
<td class="dimempty" data-h="品类"><span class="jc-vcell" data-slot="category" data-blank></span></td>
|
||||
<td class="dimempty" data-h="年用量"><span class="jc-vcell mono" data-slot="eau" data-blank></span></td>
|
||||
<td class="dimempty" data-h="报价日期"><span class="jc-vcell mono" data-slot="quoted_at" data-blank></span></td>
|
||||
<td class="dimempty" data-h="我方报价"><span class="jc-vcell num" data-slot="quoted_price" data-blank></span></td>
|
||||
<td class="dimempty" data-h="成交价"><span class="jc-vcell num" data-slot="final_price" data-blank></span></td>
|
||||
<td class="req" data-h="输赢归因(你落)">
|
||||
<span class="jc-obadge" data-class-of="cause_tag"></span>
|
||||
<span class="jc-vcell" data-slot="cause_tag" data-col="cause_tag" data-blank></span>
|
||||
</td>
|
||||
<td class="sug" data-h="竞对价"><span class="jc-vcell num" data-slot="competitor_price" data-col="competitor_price" data-blank></span></td>
|
||||
<td class="sug" data-h="竞对是谁"><span class="jc-vcell" data-slot="competitor_name" data-col="competitor_name" data-blank></span></td>
|
||||
<td data-h="败价幅度"><span class="jc-cbadge" data-class-of="price_gap_band"></span></td>
|
||||
<td class="opt" data-h="依据 / 客户原话"><span class="jc-vcell" data-slot="evidence" data-col="evidence" data-blank></span></td>
|
||||
</tr></tbody>
|
||||
</table></div>`;
|
||||
|
||||
/**
|
||||
* 情报清单表(收口件用)—— 编辑面换成**放行时可改的两列**(归因与依据),
|
||||
* 与 `requiredDeliverables[].editableAtRelease` 逐字对齐。
|
||||
*/
|
||||
const ARCHIVED_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
|
||||
<thead><tr>
|
||||
<th style="width:36px;min-width:36px"></th>
|
||||
<th style="width:44px;min-width:44px">果</th>
|
||||
<th style="min-width:170px">PN</th>
|
||||
<th style="min-width:88px">报价日期</th>
|
||||
<th style="min-width:88px">我方报价</th>
|
||||
<th style="min-width:80px">成交价</th>
|
||||
<th style="width:92px;min-width:92px">输赢归因</th>
|
||||
<th style="min-width:80px">竞对价</th>
|
||||
<th style="width:104px;min-width:104px">败价幅度</th>
|
||||
<th style="width:84px;min-width:84px">入库口径</th>
|
||||
<th style="min-width:150px;width:100%">依据 / 客户原话</th>
|
||||
</tr></thead>
|
||||
<tbody data-rows="mine:quote_archive.intel" data-edit="quote_archive.intel"><tr>
|
||||
<td class="jc-rn"><button type="button" class="jc-rowdel" data-edit-del="quote_archive.intel" title="撤掉这一行">×</button></td>
|
||||
<td class="jc-ty" data-h="果"><span class="jc-tbadge" data-class-of="deal_result" data-slot="deal_result"></span></td>
|
||||
<td data-h="PN"><span class="jc-vcell mono" data-slot="pn" data-col="pn" data-blank></span></td>
|
||||
<td class="dimempty" data-h="报价日期"><span class="jc-vcell mono" data-slot="quoted_at" data-blank></span></td>
|
||||
<td class="dimempty" data-h="我方报价"><span class="jc-vcell num" data-slot="quoted_price" data-blank></span></td>
|
||||
<td class="dimempty" data-h="成交价"><span class="jc-vcell num" data-slot="final_price" data-blank></span></td>
|
||||
<td class="req" data-h="输赢归因">
|
||||
<span class="jc-obadge" data-class-of="cause_tag"></span>
|
||||
<span class="jc-vcell" data-slot="cause_tag" data-col="cause_tag" data-blank></span>
|
||||
</td>
|
||||
<td class="dimempty" data-h="竞对价"><span class="jc-vcell num" data-slot="competitor_price" data-blank></span></td>
|
||||
<td class="dimempty" data-h="败价幅度"><span class="jc-cbadge" data-class-of="price_gap_band"></span></td>
|
||||
<td data-h="入库口径"><span class="jc-dbadge" data-class-of="share_scope"></span></td>
|
||||
<td class="opt" data-h="依据 / 客户原话"><span class="jc-vcell" data-slot="evidence" data-col="evidence" data-blank></span></td>
|
||||
</tr></tbody>
|
||||
</table></div>`;
|
||||
|
||||
/** 入库口径分布:四段按条数分宽 —— 守恒等式(四档相加 = 行数)的可视形 */
|
||||
const DIST = `<div class="jc-dist">
|
||||
<div class="jc-distbar">
|
||||
<i class="p" data-class-of="quote_archive_intel_share_scope_full" data-slot="quote_archive_intel_share_scope_full"></i>
|
||||
<i class="n" data-class-of="quote_archive_intel_share_scope_pending" data-slot="quote_archive_intel_share_scope_pending"></i>
|
||||
<i class="r" data-class-of="quote_archive_intel_share_scope_masked" data-slot="quote_archive_intel_share_scope_masked"></i>
|
||||
<i class="k" data-class-of="quote_archive_intel_share_scope_internal" data-slot="quote_archive_intel_share_scope_internal"></i>
|
||||
</div>
|
||||
<div class="jc-distlg">
|
||||
<span><em class="a"></em>全量入库 · 客户与竞对都留名 <b data-slot="quote_archive_intel_share_scope_full"></b></span>
|
||||
<span><em class="b"></em>待拍板 · 口径还没定 <b data-slot="quote_archive_intel_share_scope_pending"></b></span>
|
||||
<span><em class="b"></em>脱敏入库 · 只留价位与败因 <b data-slot="quote_archive_intel_share_scope_masked"></b></span>
|
||||
<span><em class="c"></em>只留内部 · 不进共享库 <b data-slot="quote_archive_intel_share_scope_internal"></b></span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
/* ==================== 局面一 · 情报归档工作单(归一 / 逐条归因)==================== */
|
||||
|
||||
/**
|
||||
* 逐颗情报档案 —— 一颗料一张卡(data-rows 逐行复制):左既成事实、右归因。
|
||||
*
|
||||
* **容器用 `<section>` 不用 `<div>`**:渲染器的行展开按第一个同名闭合标签截行内容,
|
||||
* 行模版里嵌着 `<div>` 时 `<div data-rows>` 会把行截成套娃(A2 逮过,记在陷阱表)。
|
||||
*/
|
||||
const INTEL_CARDS = `<section class="jc-selcards" data-rows="mine:quote_archive.intel">
|
||||
<article class="jc-selcard" data-class-of="deal_result">
|
||||
<div class="jc-selcard-h">
|
||||
<span class="jc-tbadge" data-class-of="deal_result" data-slot="deal_result"></span>
|
||||
<code data-slot="pn"></code>
|
||||
<span class="why" data-slot="evidence" data-blank></span>
|
||||
<span class="jc-obadge" data-class-of="cause_tag"></span>
|
||||
<span class="jc-dbadge" data-class-of="share_scope"></span>
|
||||
</div>
|
||||
<div class="jc-selcard-b">
|
||||
<div class="jc-selcol">
|
||||
<h5>既成事实(报过的价,一个数字都不改)</h5>
|
||||
<div class="jc-kvrow"><i>客户</i><span data-slot="customer_name" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>品类 / 制造商</i><span data-slot="category" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>年用量</i><span data-slot="eau" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>我方报价</i><span class="num" data-slot="quoted_price" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>成交价</i><span class="num" data-slot="final_price" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>报价日期</i><span data-slot="quoted_at" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>结果</i><span data-slot="deal_result" data-blank></span></div>
|
||||
</div>
|
||||
<div class="jc-selcol">
|
||||
<h5>归因(填回下表,这里回显)</h5>
|
||||
<div class="jc-kvrow"><i>输赢归因</i><span class="sc-req" data-slot="cause_tag" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>竞对价</i><span class="sc-sug num" data-slot="competitor_price" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>竞对是谁</i><span class="sc-sug" data-slot="competitor_name" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>依据 / 原话</i><span class="sc-opt" data-slot="evidence" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>败价幅度</i><span class="jc-cbadge" data-class-of="price_gap_band"></span></div>
|
||||
<div class="jc-kvrow"><i>入库口径</i><span class="jc-dbadge" data-class-of="share_scope"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>`;
|
||||
|
||||
/** 归因六档 —— 静态规则,尺挂在墙上(素材:MVP 示例工作流 §1.6 结果登记) */
|
||||
const CAUSE_RULES = `<div class="jc-graderule">
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band gk">PRICE</span>
|
||||
<p><b>输在价格。</b>客户明说了别家更便宜,或直接给出一个我们够不着的数 ——
|
||||
<b>这一档必须填竞对价</b>:没有那个数,「输在价格」下一单读不出任何东西,
|
||||
而有了它,败价幅度 A-01 当场算得出来。</p>
|
||||
</div>
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band gc">LEADTIME</span>
|
||||
<p><b>输在交期。</b>客户等不起 —— 别家现货、我们八周。
|
||||
价格再低也没用,下一单同一颗料先问现货商。</p>
|
||||
</div>
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band gb">CERT</span>
|
||||
<p><b>认证资质。</b>车规、医疗、军工的门槛没过,或供应商资质不在客户合格名录里 ——
|
||||
下一单这一类需求先核认证,别报完价才发现进不去。</p>
|
||||
</div>
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band gb">SPEC</span>
|
||||
<p><b>规格不匹配。</b>参数、封装、温度范围对不上 —— 这是选型的账,
|
||||
下一单同一需求直接从替代选型开始,别在原厂料上耗。</p>
|
||||
</div>
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band gz">RELATION</span>
|
||||
<p><b>既有供应关系。</b>客户手上已经有稳定的供应商,这一轮只是来比个价 ——
|
||||
情报照记:下一单知道要拿什么去撬,或者干脆不投这个标。</p>
|
||||
</div>
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band gz">OTHER</span>
|
||||
<p><b>说不清。</b><b>「客户没回」不是原因</b> —— 问不出来就落这一档,
|
||||
并在依据里写明「未获反馈」。<b>不许编一个听着合理的理由</b>:
|
||||
编出来的原因会一路流进下一单的判据里,而那时已经没人记得它是编的。</p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
/** 败价幅度 A-01 的三档 —— 静态规则(素材:MVP 示例工作流 §1.6 情报复用) */
|
||||
const BAND_RULES = `<div class="jc-graderule">
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band ga">≤ 3%</span>
|
||||
<p><b>擦肩而过。</b>三个点以内的价差不足以定输赢 —— 下一单同价位再试,
|
||||
真正输的多半在交期、认证或关系上,回头看看依据那一栏写了什么。</p>
|
||||
</div>
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band gc">3% ~ 10%</span>
|
||||
<p><b>够得着。</b>下一单要么让到这一档,要么换替代料把成本降下来 ——
|
||||
这是替代选型最值钱的入口:同样的功能,换一颗料就把十个点让出来了。</p>
|
||||
</div>
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band gk">> 10%</span>
|
||||
<p><b>结构性价差。</b>一成以上不是让价能填的坑,是成本结构不对 ——
|
||||
下一单别再报同一价位,先走替代选型或重新寻源。<b>拿同一个价再报一次不叫坚持,叫没记账</b>。</p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export const ARCHIVE_WORKSHEET = doc({
|
||||
badge: 'file-text',
|
||||
kicker: '统一报价 · A7 情报归档 · 归因作业',
|
||||
title: '情报归档工作单',
|
||||
titleRef: 'rfq_ref',
|
||||
state:
|
||||
'<span class="jc-rp-wait warn">待归因 <b data-slot="quote_archive_intel_missing"></b> / <b data-slot="quote_archive_intel_rows"></b> 条</span>',
|
||||
body:
|
||||
kvCard('本单结果', [
|
||||
['型号 PN', 'pn', 'mono'],
|
||||
['报价日期', 'quotedAt', 'mono'],
|
||||
['拒绝原因', 'lostReason'],
|
||||
['竞对价位', 'competitor_price'],
|
||||
]) +
|
||||
noteCard('为什么要登记', '拒绝原因与竞对价位是<b>下一单的判据</b>:它们回写历史价格情报,供 P-03 的「距上次报价 / 行情稳定」与 P-01 的客户等级因子取用。这一单的败仗,是下一单的地形图。'),
|
||||
part('归档任务', '客户 <b data-slot="customer_name" data-blank></b> · 报价单 <b data-slot="quote_no" data-blank></b> · 共 <b data-slot="intel_total"></b> 条') +
|
||||
`<div class="jc-derive">${ico('info', 14)}
|
||||
<span>这一屏是一份情报的作业台:系统已按上游的结果归好情报卡,报价、成交价与日期都在下面 ——
|
||||
<b>既成事实一个数字都不改</b>。你要做的是<b>逐条落输赢归因</b>:客户到底为什么下单、
|
||||
为什么不下单。这一列系统一格都不写:那件事只有跟单的人知道。
|
||||
<b>这一单之后再没有人会回头看它</b> —— 归因写错没有下一道工序纠正,
|
||||
而它会一路流进下一单的判据里。</span>
|
||||
</div>` +
|
||||
FOUR +
|
||||
part('一单情报是怎么走完的', '既成事实 → 归因 → 败价幅度 → 口径 → 下一单') +
|
||||
ARCHIVE_LINE +
|
||||
part('逐颗情报档案', '既成事实在左 · 归因在右') +
|
||||
INTEL_CARDS +
|
||||
part('输赢归因六档', '选最主要的那一个 —— 尺挂在墙上') +
|
||||
CAUSE_RULES +
|
||||
part('败价幅度 A-01', '价差决定下一单怎么用这条情报') +
|
||||
BAND_RULES +
|
||||
part('填回工作表', '归因落在这 —— 补齐了执行单才提交得了') +
|
||||
GAP_BAR +
|
||||
editGate('quote_archive.intel', '情报卡填回表') +
|
||||
INTEL_TABLE +
|
||||
addRowBar('quote_archive.intel') +
|
||||
`<div class="jc-drop">${ico('info', 13)}
|
||||
<span>带红标的<b>输赢归因</b>是必填,而且<b>只由你填</b> —— 缺一行执行单按不下提交,
|
||||
与放行门是同一个数同一套算法。六个取值一个字母不许差:
|
||||
<b>PRICE</b> 价格 · <b>LEADTIME</b> 交期 · <b>CERT</b> 认证资质 · <b>SPEC</b> 规格不符 ·
|
||||
<b>RELATION</b> 既有供应 · <b>OTHER</b> 说不清。
|
||||
归因落 PRICE 的行务必把竞对报的那个价填进<b>竞对价</b> —— 那一格空着这张单同样提交不了,
|
||||
败价幅度全看它。<b>依据那一栏尽量记客户原话</b>:「你们比 XX 贵两毛」比「价格偏高」
|
||||
值钱得多 —— 下一单定价时看的就是这一句。不该归档的料按行首 × 撤掉。</span>
|
||||
</div>`,
|
||||
foot: sign('BD 归因 · AI 归一情报卡并按尺算败价幅度,输赢的缘由由你落', '统一报价 · A7 情报归档 · 在办'),
|
||||
});
|
||||
|
||||
export const ARCHIVE_RELEASE = viewShell({
|
||||
title: '情报归档 · 闭环待放行',
|
||||
kicker: 'A7 · 成果预览',
|
||||
tone: 'ok',
|
||||
/* ==================== 局面二 · 情报归档清单(拍板 / 放行 / 归档)==================== */
|
||||
|
||||
/**
|
||||
* 落边图:情报卡清单 → 归档闭环 → 下一单的三个用处。
|
||||
*
|
||||
* A7 是流程包的最后一站,**只有一口且无条件**(E14)—— 这条边不是选出来的,
|
||||
* 所以画成实线亮是如实,不是替谁宣布。右边三个用处是这份情报的去处,
|
||||
* 各挂本单条数(基座现数),没有的那一档条数为 0、人一眼看得出。
|
||||
*/
|
||||
const ROUTE_FIG = `<div class="route">
|
||||
<svg viewBox="0 0 660 250" fill="none">
|
||||
<rect class="rnode hit" x="8" y="95" width="150" height="60" rx="10"/>
|
||||
<text class="rtxt hit" x="83" y="120" text-anchor="middle">情报卡清单</text>
|
||||
<text class="rsub" x="83" y="138" text-anchor="middle">共 <tspan data-slot="quote_archive_intel_rows"></tspan> 条</text>
|
||||
<path class="rspine" d="M158 125 H228"/>
|
||||
<path class="rtip hit" d="M216 119 l14 6 -14 6 z"/>
|
||||
<rect class="rnode hit" x="232" y="95" width="128" height="60" rx="10"/>
|
||||
<text class="rtxt hit" x="296" y="120" text-anchor="middle">归档闭环</text>
|
||||
<text class="rsub" x="296" y="138" text-anchor="middle">这条流走到终点</text>
|
||||
<path class="redge" d="M360 125 C420 125 430 40 470 40"/>
|
||||
<path class="redge" d="M360 125 C420 125 430 125 470 125"/>
|
||||
<path class="redge" d="M360 125 C420 125 430 210 470 210"/>
|
||||
<path class="rtip" d="M458 34 l14 6 -14 6 z"/>
|
||||
<path class="rtip" d="M458 119 l14 6 -14 6 z"/>
|
||||
<path class="rtip" d="M458 204 l14 6 -14 6 z"/>
|
||||
<text class="rcond" x="378" y="62">时效 · 报价日期</text>
|
||||
<text class="rcond" x="378" y="115">价格 <tspan data-slot="quote_archive_intel_cause_tag_price"></tspan> 条</text>
|
||||
<text class="rcond" x="378" y="232">行为画像</text>
|
||||
<rect class="rnode" x="470" y="8" width="180" height="64" rx="10"/>
|
||||
<text class="rtxt" x="560" y="35" text-anchor="middle">下一单 · 三分类 P-03</text>
|
||||
<text class="rsub" x="560" y="53" text-anchor="middle">距上次报价多久 · 行情稳不稳</text>
|
||||
<rect class="rnode" x="470" y="93" width="180" height="64" rx="10"/>
|
||||
<text class="rtxt" x="560" y="120" text-anchor="middle">下一单 · 成本定价</text>
|
||||
<text class="rsub" x="560" y="138" text-anchor="middle">基准价与竞对价位</text>
|
||||
<rect class="rnode" x="470" y="178" width="180" height="64" rx="10"/>
|
||||
<text class="rtxt" x="560" y="205" text-anchor="middle">客户画像 · 利润模型 P-01</text>
|
||||
<text class="rsub" x="560" y="223" text-anchor="middle">价格敏感度 · 决策周期</text>
|
||||
</svg>
|
||||
<div class="legend">
|
||||
<span>A7 只有一口且无条件 —— 它不是选出来的,是这条流的终点</span>
|
||||
<span>右边三个用处是这份情报的去处:入库口径定的就是它们读不读得到</span>
|
||||
<span>只留内部那一档不进共享库 —— 三条虚线到那一单就全断了</span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
/** 入库口径三档 —— 静态规则,决策卡上那一枚 recommended 由 A-02 标 */
|
||||
const SCOPE_RULES = `<div class="jc-graderule">
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band ga">全量入库</span>
|
||||
<p><b>客户与竞对都留名。</b>下一单查这颗料时,客户是谁、竞对是谁、报了多少一目了然 ——
|
||||
情报最值钱的形态。代价是客户身份与第三方报价就此对内公开。</p>
|
||||
</div>
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band gb">脱敏入库</span>
|
||||
<p><b>只留价位与败因。</b>竞对与客户的名字抹掉,价位、归因、日期照留 ——
|
||||
下一单照样判得了价,只是追不到是谁说的。<b>带着第三方报价的情报默认走这一档</b>:
|
||||
竞对的价是他给客户的,不是给我们的。</p>
|
||||
</div>
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band gz">只留内部</span>
|
||||
<p><b>不进共享情报库。</b>这一单的情报只在本案卷里躺着,下一单查不到 ——
|
||||
涉密、客户明说不外传、或项目还在谈时才选这一档。选它等于承认这份情报只值一次。</p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export const ARCHIVE_ROSTER = doc({
|
||||
badge: 'file-check',
|
||||
kicker: '统一报价 · A7 情报归档 · 交付成果',
|
||||
title: '情报归档清单',
|
||||
titleRef: 'rfq_ref',
|
||||
state:
|
||||
'<span class="jc-rp-wait">情报 <b data-slot="quote_archive_intel_rows"></b> 条 · 待拍板 <b data-slot="quote_archive_intel_share_scope_pending"></b> 条(要归零)</span>',
|
||||
body:
|
||||
tableCard('历史价格情报回写', 'mine:shared.history_price', [
|
||||
['型号', 'pn', 'mono'],
|
||||
['报价日期', 'quoted_at', 'mono'],
|
||||
['拒绝原因', 'lost_reason'],
|
||||
]) +
|
||||
kvCard('画像与落地率', [
|
||||
['客户编码', 'customer_code', 'mono'],
|
||||
['画像标签', 'profile_tags'],
|
||||
['统计周期', 'period', 'mono'],
|
||||
['落地率', 'landing_rate'],
|
||||
]) +
|
||||
noteCard(
|
||||
'闭环的含义',
|
||||
'放行后这条工作流走到<b>终点节点</b>,状态收敛为「已完成」。' +
|
||||
'终态封卷触发<b>蒸馏钩子</b>:从任务记忆归纳出活动记忆的候选条目(带证据链),' +
|
||||
'经回放与人放行才成为下一版业务模型的一部分 —— <b>记忆不载权力</b>。',
|
||||
),
|
||||
part('归档要素', '情报归档 · 能算的归判定表,要担责的归人') +
|
||||
FOUR +
|
||||
part('入库口径分布', '全量 + 待拍板 + 脱敏 + 只留内部 = 总条数') +
|
||||
DIST +
|
||||
`<div class="jc-derive">${ico('info', 14)}
|
||||
<span>四段条数由基座逐行现数,不是谁报上来的。橙段(<b>待拍板</b>)是过渡态 ——
|
||||
情报归好了而口径还没定,它<b>归零这一单才走得到放行门</b>。
|
||||
进了共享情报库的东西下一单谁都读得到,<b>这条边界过去了就收不回来</b> ——
|
||||
所以系统不替你定,只按 A-02 给一枚建议。</span>
|
||||
</div>` +
|
||||
part('情报卡清单', '<b data-slot="intel_total"></b> 条 · 每条都有归因') +
|
||||
GAP_BAR +
|
||||
editGate('quote_archive.intel', '情报清单表') +
|
||||
ARCHIVED_TABLE +
|
||||
`<div class="jc-drop">${ico('info', 13)}
|
||||
<span>每一行摆的是这一条的<b>来路</b>:报了多少、成没成、输赢归在哪一档、
|
||||
竞对报了什么价、败价幅度落在哪一级。签字前请重点看两处:
|
||||
<b>归因列</b>里落了 OTHER 的条目(说不清的情报到下一单就是哑的),
|
||||
与<b>依据列</b> —— 没有客户原话的归因,下一单只能当传说看。
|
||||
这一屏可改的是<b>输赢归因</b>与<b>依据</b>两列(你的职权面)。</span>
|
||||
</div>` +
|
||||
part('入库口径', '三档 · 建议归尺,结论归你') +
|
||||
SCOPE_RULES +
|
||||
part('落边 · 这一签之后谁用得上', '一口且无条件 · 归档闭环') +
|
||||
ROUTE_FIG +
|
||||
`<div class="concl">
|
||||
${ico('git-branch', 15)}
|
||||
<div class="cl-main"><b>归档结论</b> —— <span data-slot="summary" data-blank></span></div>
|
||||
<div class="cl-route"><em>客户行为画像</em><b data-slot="buyer_profile" data-blank></b><code data-slot="share_policy" data-blank></code></div>
|
||||
</div>` +
|
||||
part('放行后即执行的落地动作', '门前 → 门后') +
|
||||
`<div class="jc-acts">
|
||||
<div class="jc-act"><i>${ico('check', 11)}</i>
|
||||
<div><b>草稿层合并进正式版</b> <span>本任务写下的情报卡与归档结论一并落账;
|
||||
你就地改过的归因与依据随之落定。</span></div>
|
||||
</div>
|
||||
<div class="jc-act"><i>${ico('check', 11)}</i>
|
||||
<div><b>归档闭环那一口此刻才发射</b> <span>情报卡
|
||||
<b data-slot="quote_archive_intel_rows"></b> 条随载荷交出去,这条工作流走到终点 ——
|
||||
放行之前,世界上什么都没有发生。</span></div>
|
||||
</div>
|
||||
<div class="jc-act"><i>${ico('check', 11)}</i>
|
||||
<div><b>下一单开始受用</b> <span>按你定的口径,这几条价位、败因与日期
|
||||
进入下一次询价的判据面 —— <b>归档做得好不好,不在这一单看得出来</b>,
|
||||
在下一次报价能不能一眼看到「上次为什么丢」看得出来。</span></div>
|
||||
</div>
|
||||
</div>`,
|
||||
foot: sign('BD 归档放行 · AI 归一与算尺留痕 —— 要改哪一格,在这张情报清单上就地改', '统一报价 · A7 情报归档 → 归档闭环'),
|
||||
});
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
/**
|
||||
* 活动包的自测 —— **只准 import @sdk / @contracts 与白名单纯库**(编码规范 §5.1 / ADR-004)。
|
||||
* 安装两审那一侧的验收在 `apps/api/.../activity-registry/__tests__` —— 二者之间只有安装通道,
|
||||
* 没有代码通道,depcruise 双向隔离在守着。
|
||||
* A1 询价受理的包内自测 —— **只准 import @sdk / @contracts / @sfeel**
|
||||
* (编码规范 §5.1 / ADR-004)。安装两审那一侧的验收在
|
||||
* `apps/api/.../activity-registry/__tests__` —— 二者之间只有安装通道,没有代码通道,
|
||||
* depcruise 双向隔离在守着。
|
||||
*
|
||||
* 案例活动没有 `run`,所以自测不跑回合(那是 Loop 的事)。这里测的是**清单本身**:
|
||||
* ① 清单合规 + 判据等级不谎报;
|
||||
* ② 案例模型双表示同在、触达世界的项标了人工激活;
|
||||
* ③ 两张判定表逐行求值(能算的不问 LLM);
|
||||
* ④ **声明与契约对得上** —— 这一段最容易在改动中悄悄错位:
|
||||
* 必交付物的台账地址、验收谓词读的列、出口载荷要的标量,三处必须都有台账列承载。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { activityManifestSchema } from '@contracts';
|
||||
import { evaluateUnaryTest } from '@sfeel';
|
||||
import { createMockHost } from '@sdk';
|
||||
import { manifest, P03_DMN } from '../src/manifest.ts';
|
||||
import { F01_DMN, INTAKE_DMN, P03_DMN, manifest } from '../src/manifest.ts';
|
||||
import activity from '../src/index.ts';
|
||||
|
||||
describe('清单本身合规', () => {
|
||||
@@ -21,7 +29,7 @@ describe('清单本身合规', () => {
|
||||
|
||||
it('L1/L2 判据都给了可机检的谓词 —— 验收等级不得谎报', () => {
|
||||
for (const c of manifest.contract.acceptanceCriteria) {
|
||||
if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef).toBeDefined();
|
||||
if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef, c.key).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -29,6 +37,12 @@ describe('清单本身合规', () => {
|
||||
expect(manifest.contract.prohibitions.length).toBeGreaterThan(0);
|
||||
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('每件必交付物都有台账地址 —— 没地址,「缺一不得提交放行」只能靠活动自报', () => {
|
||||
for (const d of manifest.contract.requiredDeliverables) {
|
||||
expect(d.ledger, d.key).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('案例模型(AM 03/12.2)—— 双表示同版本', () => {
|
||||
@@ -37,119 +51,342 @@ describe('案例模型(AM 03/12.2)—— 双表示同版本', () => {
|
||||
|
||||
it('CMMN 与说明书同在,不允许分叉', () => {
|
||||
expect(model.cmmnXml).toContain('<casePlanModel');
|
||||
expect(model.narrative.length).toBeGreaterThan(80);
|
||||
expect(model.narrative.length).toBeGreaterThan(400);
|
||||
});
|
||||
|
||||
it('触达世界的项恒需人工激活(AM 4.1 原理一)', () => {
|
||||
// 解析归一、追问补全、查历史价 —— 三件都碰外界,一件也不许自动跑
|
||||
for (const id of ['T_normalize', 'T_ask_back', 'T_history']) {
|
||||
it('触达世界的项标了人工激活(AM 4.1 原理一)', () => {
|
||||
/*
|
||||
* 只剩放行这一件。定位客户与归一 RFQ 两步**发卡但不落地**:
|
||||
* 补全卡与执行卡都是「问一句」,回来的值仍旧只进草稿层,
|
||||
* 真正触达世界的只有放行那一下(铁律③)。
|
||||
*/
|
||||
for (const id of ['T_release']) {
|
||||
expect(cmmn.manualActivation, `${id} 应为人工激活`).toContain(id);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **图上有几步就是几项**。上一版加了两个阶段、两个里程碑、一个裁量项、
|
||||
* 一个用户事件监听器 —— 12 个计划项装 6 步的活,业务一分没多,卡壳的花样多了一堆:
|
||||
* 每一条「行动被拒」都是结构上的,没有一条是业务判断上的(实测)。
|
||||
* 阶段 / 里程碑 / 裁量项是 CMMN 有的东西,但「有」不等于「该用」。
|
||||
*/
|
||||
/*
|
||||
* 2026-07-31 用户重定前两态:
|
||||
* ① `S1_customer` 定位客户信息 —— 按发件邮箱认人,认不出发补全卡请人建档;
|
||||
* ② `S2_normalize` 归一客户 RFQ 输入 —— 解析正文逐条落表。
|
||||
* 旧的 S1_register / S2_ask_back / S3_normalize 三项合并进这两态。
|
||||
*
|
||||
* 2026-08-01 补回 `S3_confirm`(**人的门,不是 AI 的步**):
|
||||
* 追问从前是「归一里缺字段时的动作」,于是不缺字段就不叫人 —— 一封齐整的询价
|
||||
* 从起单一路自动跑到终局验收,人一眼没看过受理需求(用户当场指出)。
|
||||
* 阶段2 那张执行页(`A1-询价受理需求.html`,页脚是【修改】【确认受理记录】)
|
||||
* 从来就是一张人签的页,所以它得是**一项**:人工激活、无 doneWhen、一单必经一次。
|
||||
*/
|
||||
it('计划项照状态机一步一项:两态 + 人审定 + 后续三步 + 放行,不多建一件', () => {
|
||||
expect(items(model.cmmnXml)).toEqual([
|
||||
'S1_customer',
|
||||
'S2_normalize',
|
||||
'S3_confirm',
|
||||
'S4_history',
|
||||
'S5_classify',
|
||||
'S6_route',
|
||||
'T_release',
|
||||
]);
|
||||
});
|
||||
|
||||
/*
|
||||
* **触达世界的项必须人工激活** —— 引擎不许自己按下它们。
|
||||
* 受理需求那张卡交到人手上、放行那一签发出去,都是外面能看见的动作。
|
||||
*/
|
||||
it('两处人的门都标了人工激活:受理需求审定 · 受理放行', () => {
|
||||
for (const id of ['S3_confirm', 'T_release']) {
|
||||
const block = model.cmmnXml.slice(model.cmmnXml.indexOf(`id="${id}"`));
|
||||
expect(block.slice(0, block.indexOf('</humanTask>')), `${id} 少了 manualActivationRule`).toContain(
|
||||
'<manualActivationRule',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('不用阶段 / 里程碑 / 裁量项 —— 它们换不来业务上说得出的好处', () => {
|
||||
for (const tag of ['<stage', '<milestone', '<planningTable', '<discretionaryItem']) {
|
||||
expect(model.cmmnXml, `不该出现 ${tag}`).not.toContain(tag);
|
||||
}
|
||||
});
|
||||
|
||||
it('常来回的项标了可重复 —— 认客户与归一都会走好几轮', () => {
|
||||
for (const id of ['S1_customer', 'S2_normalize']) {
|
||||
expect(cmmn.repetition, `${id} 应可重复`).toContain(id);
|
||||
}
|
||||
});
|
||||
|
||||
it('末尾一张验收单 —— 完成只出自放行通过', () => {
|
||||
expect(model.cmmnXml).toContain('id="T_release"');
|
||||
});
|
||||
|
||||
it('能算的交 DMN,不交 Agent —— 打标是 decisionTask', () => {
|
||||
expect(model.cmmnXml).toContain('decisionRef="p03-type"');
|
||||
expect(P03_DMN.tables.some((t) => t.id === 'p03-type')).toBe(true);
|
||||
expect(INTAKE_DMN.tables.some((t) => t.id === 'p03-type')).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* 作业指导**一处一份**:逐步的做法在剧本(`steps[].guide`),整单的事在说明书。
|
||||
* 从前两处各写一份六步,实测分叉过 —— 说明书里写着 S1_register / S2_ask_back /
|
||||
* S3_normalize 三个案例模型里根本不存在的计划项,模型照它报 itemId,一报一个被拒。
|
||||
* 所以这里改测三件事:每一项都有剧本、剧本说得出做法、说明书**不许再点名计划项**。
|
||||
*/
|
||||
it('每个计划项都有剧本 —— 轮到哪一步才摊开哪一步', () => {
|
||||
const ids = items(model.cmmnXml);
|
||||
expect((model.steps ?? []).map((s) => s.item)).toEqual(ids);
|
||||
for (const step of model.steps ?? []) {
|
||||
expect(step.guide.length, `${step.item} 的做法`).toBeGreaterThan(80);
|
||||
expect(step.intent.length, `${step.item} 的意图`).toBeGreaterThan(6);
|
||||
}
|
||||
});
|
||||
|
||||
it('说明书不点名计划项 —— 点了就会与案例模型分叉(踩过)', () => {
|
||||
// 认的是**计划项 id 的形状**(S数字_字母 / T_字母),不是「S1 → S2」那种路线简写
|
||||
const named = [...model.narrative.matchAll(/\b(?:S\d+_[A-Za-z]+|T_[a-z]+)\b/g)].map((m) => m[0]);
|
||||
const ids = new Set(items(model.cmmnXml));
|
||||
for (const name of named) {
|
||||
expect(ids.has(name), `说明书提到的 ${name} 不在案例模型里`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('说明书只讲整单的事:成品、怎么说话、怎么收口', () => {
|
||||
for (const topic of ['开案', '要说话', '一拍连做', '结案']) {
|
||||
expect(model.narrative, topic).toContain(topic);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** 极小取值:只为断言用,不重实现引擎的解析器(那一份在 loop-engine 里有自己的测试) */
|
||||
function parseCmmnLite(xml: string): { manualActivation: string[] } {
|
||||
const ids: string[] = [];
|
||||
for (const m of xml.matchAll(/<humanTask id="([^"]+)"[\s\S]*?<\/humanTask>/g)) {
|
||||
if (m[0].includes('manualActivationRule')) ids.push(m[1]!);
|
||||
}
|
||||
return { manualActivation: ids };
|
||||
/** 计划项 id 按出现次序 —— 断言「图上有几步就是几项」 */
|
||||
function items(xml: string): string[] {
|
||||
return [...xml.matchAll(/<(?:humanTask|decisionTask|processTask|caseTask) id="([^"]+)"/g)].map((m) => m[1] ?? '');
|
||||
}
|
||||
|
||||
describe('三分类规则 P-03 —— 能算的不问 LLM', () => {
|
||||
const table = P03_DMN.tables[0]!;
|
||||
function parseCmmnLite(xml: string): { manualActivation: string[]; repetition: string[] } {
|
||||
const manualActivation: string[] = [];
|
||||
const repetition: string[] = [];
|
||||
for (const m of xml.matchAll(/<humanTask id="([^"]+)"[\s\S]*?<\/humanTask>/g)) {
|
||||
if (m[0].includes('manualActivationRule')) manualActivation.push(m[1] ?? '');
|
||||
if (m[0].includes('repetitionRule')) repetition.push(m[1] ?? '');
|
||||
}
|
||||
return { manualActivation, repetition };
|
||||
}
|
||||
|
||||
/** 按 DMN 语义逐行求值:FIRST 命中即定,单元格谓词复用 @sfeel */
|
||||
const decide = (scope: Record<string, unknown>) => {
|
||||
/** 按 DMN 语义逐行求值:FIRST 命中即定,单元格谓词复用 @sfeel */
|
||||
function decideBy(table: typeof P03_DMN, scope: Record<string, unknown>) {
|
||||
for (const rule of table.rules) {
|
||||
const hit = table.inputs.every((input, i) => evaluateUnaryTest(rule.when[i] ?? '-', scope[input.expression], scope));
|
||||
if (hit) return { type: rule.then[0], play: rule.then[1], rule: rule.id };
|
||||
if (hit) return { outputs: rule.then, rule: rule.id };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
|
||||
describe('受理字段完整度 F-01 —— 三要素齐才放行', () => {
|
||||
it('缺 PN/EAU/交期任一 → 退回补全', () => {
|
||||
expect(decideBy(F01_DMN, { missing_key_total: 2, missing_minor_total: 0 })).toMatchObject({
|
||||
outputs: ['缺PN/数量/交期', '退回补全'],
|
||||
rule: 'f01-2',
|
||||
});
|
||||
});
|
||||
|
||||
it('只缺次要字段 → 受理并追问(不卡住)', () => {
|
||||
expect(decideBy(F01_DMN, { missing_key_total: 0, missing_minor_total: 3 })).toMatchObject({
|
||||
outputs: ['缺次要字段', '受理并追问'],
|
||||
rule: 'f01-3',
|
||||
});
|
||||
});
|
||||
|
||||
it('八字段齐 → 往下走', () => {
|
||||
expect(decideBy(F01_DMN, { missing_key_total: 0, missing_minor_total: 0 })).toMatchObject({ rule: 'f01-1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('三分类规则 P-03 —— 能算的不问 LLM', () => {
|
||||
const decide = (scope: Record<string, unknown>) => decideBy(P03_DMN, scope);
|
||||
|
||||
it('历史价 ≤90 天且行情稳 → A · 直接快答', () => {
|
||||
expect(decide({ historyDays: 30, marketStable: true, lifecycle: '在产' })).toEqual({
|
||||
type: 'A',
|
||||
play: '直接快答',
|
||||
expect(decide({ history_days: 30, market_stable: true, lifecycle: '在产' })).toMatchObject({
|
||||
outputs: ['A', '直接快答'],
|
||||
rule: 'p03-a',
|
||||
});
|
||||
});
|
||||
|
||||
it('无历史 / 过期 / 波动 → B · 重估后报', () => {
|
||||
expect(decide({ historyDays: 200, marketStable: false, lifecycle: '在产' })?.type).toBe('B');
|
||||
expect(decide({ history_days: 9999, market_stable: false, lifecycle: '未知' })?.outputs[0]).toBe('B');
|
||||
});
|
||||
|
||||
it('EOL / 缺货 / 要求替代 → C · 转案处理(优先于 A)', () => {
|
||||
expect(decide({ historyDays: 10, marketStable: true, lifecycle: 'EOL' })).toMatchObject({ type: 'C', rule: 'p03-c' });
|
||||
it('EOL / 缺货 / 要求替代 → C · 转案处理(**优先于 A**:新历史价也不该让 EOL 直通)', () => {
|
||||
expect(decide({ history_days: 10, market_stable: true, lifecycle: 'EOL' })).toMatchObject({ rule: 'p03-c' });
|
||||
});
|
||||
|
||||
it('同输入必同结论 —— 零方差、零 token', () => {
|
||||
const scope = { historyDays: 30, marketStable: true, lifecycle: '在产' };
|
||||
const scope = { history_days: 30, market_stable: true, lifecycle: '在产' };
|
||||
expect(decide(scope)).toEqual(decide(scope));
|
||||
});
|
||||
});
|
||||
|
||||
describe('代码入口 —— 零环境能力,一切经基座六件', () => {
|
||||
const contract = {
|
||||
/**
|
||||
* 声明与契约的对齐 —— 三处引用同一批台账列,错位了不会报错,只会在放行那一刻
|
||||
* 「判据取不到值 / 出口取不到字段」,而两者都长得像业务问题。所以在这儿钉死。
|
||||
*/
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* 除了台账列,谓词还能读一类**基座数出来的读数**:`域_对象_rows` = 本单在那张表上
|
||||
* 写下了几行。它不是列(活动写不了它),正因如此才拦得住「两边都由活动自报」的假对账。
|
||||
* 这里照样要核到底:对象没声明过,`quote_intake_recrod_rows` 这种手滑仍要当场炸。
|
||||
*/
|
||||
const rowReadings = new Set([
|
||||
...manifest.declarations.ledger.map((d) => `${d.domain}_${d.object}_rows`),
|
||||
/*
|
||||
* `域_对象_missing` = 本单那张表缺必填的有几行(按声明的 `fields[].required` 现数)。
|
||||
* 与 `_rows` 同类:活动写不了它,所以它才拦得住「两边都由活动自报」的假对账 ——
|
||||
* 「必填齐」那条判据从前读活动自报的 `missing_key_total`,模型漏算一次就永远绿不了。
|
||||
*/
|
||||
...manifest.declarations.ledger
|
||||
.filter((d) => d.fields.some((f) => f.required === true))
|
||||
.map((d) => `${d.domain}_${d.object}_missing`),
|
||||
/*
|
||||
* `域_对象_<列>_<值>` = 本单那张表按值现数的条数(取值域由 `fields[].tally` 声明)。
|
||||
* 与 `_rows` / `_missing` 同类:活动写不了它 —— 所以人在放行页改了类标,
|
||||
* 分布条、去向谓词、派单对账**全都跟着重算**。手工修改本来就是为了纠正 AI 的判断。
|
||||
*/
|
||||
...manifest.declarations.ledger.flatMap((d) =>
|
||||
d.fields.flatMap((f) =>
|
||||
(f.tally ?? []).map((v) => `${d.domain}_${d.object}_${f.name}_${v.toLowerCase().replace(/[^a-z0-9_]/g, '')}`),
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
it('L1/L2 谓词读的列都有台账承载 —— 否则机检永远取不到值', () => {
|
||||
for (const c of manifest.contract.acceptanceCriteria) {
|
||||
if (c.level !== 'L1' && c.level !== 'L2') continue;
|
||||
for (const identifier of (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? []) {
|
||||
// S-FEEL 的保留字不是台账名 —— 漏掉它,`a = 0 or b > 0` 会被当成引用了一列叫 `or`
|
||||
if (['and', 'or', 'not', 'true', 'false', 'null', 'in', 'between'].includes(identifier)) continue;
|
||||
if (rowReadings.has(identifier)) continue;
|
||||
expect(columns.has(identifier), `${c.key} 的谓词引用了 ${identifier}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('出口载荷的标量字段都有台账列承载(§7.5;踩过:两枚去向旗没列 → G1 全流死路)', () => {
|
||||
const snake = (name: string) => name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
|
||||
for (const port of manifest.contract.output.mandatory) {
|
||||
const properties = (port.payload as { properties?: Record<string, { type?: string }> }).properties ?? {};
|
||||
for (const [field, spec] of Object.entries(properties)) {
|
||||
if (spec.type === 'array') continue; // 数组字段取整表,不需要单列
|
||||
expect(columns.has(snake(field)), `${port.name}.${field} → ${snake(field)}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('技能引用的工具都在白名单声明内 —— 载方法不载权力', () => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('报告模版 —— 活动持有,容器无脚本执行权(§9.3)', () => {
|
||||
it('四个局面各一张,不在一张里堆条件分支', () => {
|
||||
expect(manifest.assets.viewTemplates.map((t) => t.name)).toEqual([
|
||||
'intake-overview',
|
||||
'intake-classified',
|
||||
'intake-release',
|
||||
]);
|
||||
});
|
||||
|
||||
it('模版里没有脚本、没有事件处理器、没有外链 —— 样式自带', () => {
|
||||
for (const template of manifest.assets.viewTemplates) {
|
||||
expect(template.html, template.name).not.toMatch(/<script|\son[a-z]+=|https?:\/\//i);
|
||||
expect(template.html, template.name).toContain('<style>');
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* 放行那张报告要摆出**去向是怎么算出来的** —— 人签字前得看见这一单要往哪走,
|
||||
* 更要看见凭哪一条走。
|
||||
*
|
||||
* 从前这里断言的是两枚去向旗(`need_alternative` / `need_sourcing`)——
|
||||
* 那是「活动自己写下一个布尔,再照它发口」的旧口径:旗立错了没人看得出来。
|
||||
* 现在去向由契约里三个出口各自的 `when` 按序求值(首个命中即出),
|
||||
* 基座把命中结果注入 `case:route*`;报告摆的是**同一次判定**,不是这屏又算一遍。
|
||||
* 所以这里改断言那条链:判定表三行、命中注入、以及判据读的那三个数。
|
||||
*/
|
||||
it('放行那张报告摆出分支判定与算出来的去向 —— 不是人点出来的,也不是报告自己算的', () => {
|
||||
const release = manifest.assets.viewTemplates.find((t) => t.name === 'intake-release')?.html ?? '';
|
||||
// 判定表三行俱在,壳上按命中项上色
|
||||
expect(release).toContain('data-class-of="case:routeKey"');
|
||||
for (const row of ['jc-dmn-r r1', 'jc-dmn-r r2', 'jc-dmn-r r3']) expect(release).toContain(row);
|
||||
/*
|
||||
* 去向那句话由基座填,报告不复述。
|
||||
* **只摆人话的那一份**(2026-08-02 用户定:报告里不该出现系统内的英文名)——
|
||||
* 口名 `to-alternative` 与判据表达式 `…_type_tag_c > 0` 都是给排障看的,
|
||||
* 摆在人的成果件上等于噪音;人要知道的是「转到哪个环节、为什么」。
|
||||
*/
|
||||
expect(release).toContain('data-slot="case:routeTo"');
|
||||
expect(release).not.toContain('case:routePort');
|
||||
expect(release).not.toContain('case:routeWhy');
|
||||
// 判据读的是三类条数:摆出来的数与算出去向用的是同三列
|
||||
// 三类条数读**基座按值现数**的那一份 —— 人改了类标,分布条与去向才跟着变
|
||||
for (const col of ['quote_intake_record_type_tag_a', 'quote_intake_record_type_tag_b', 'quote_intake_record_type_tag_c'])
|
||||
expect(release).toContain(col);
|
||||
});
|
||||
|
||||
/*
|
||||
* **契约里每个出口都得写 `when`** —— 少一口,`routePort` 就整组算不出来,
|
||||
* 放行卡当场退回「人选一口」,而报告上那张判定表也就没有命中行:
|
||||
* 一屏三条规则都不高亮,看着像判定没跑,实则是这组分支有一段没有判据。
|
||||
*/
|
||||
it('选择组里每一口都写了判据 —— 去向才算得出来', () => {
|
||||
const groups = manifest.contract.output.selective;
|
||||
expect(groups.length).toBeGreaterThan(0);
|
||||
for (const group of groups) {
|
||||
expect(group.ports.length).toBeGreaterThanOrEqual(2);
|
||||
for (const port of group.ports) expect(port.when, `${group.group}/${port.name}`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('宿主拒推含脚本的模版 —— 这道门在基座,不靠模版作者自觉', async () => {
|
||||
const host = createMockHost({ contract: CONTRACT });
|
||||
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
|
||||
});
|
||||
});
|
||||
|
||||
const CONTRACT = {
|
||||
activityId: 'quote-intake',
|
||||
version: '0.1.0',
|
||||
data: { customerCode: 'C001', rfqRef: 'mail:m-1001' },
|
||||
version: '1.0.0',
|
||||
data: { customerEmail: 'purchasing@sz-machinery.com', subject: '【询价】RFQ-2026-0001', body: 'LM358DR 12K/年' },
|
||||
message: undefined,
|
||||
outputPorts: { mandatory: ['intake'], selective: [] },
|
||||
prohibitions: [],
|
||||
discretion: [],
|
||||
} as const;
|
||||
} as const;
|
||||
|
||||
it('全部打标后提交放行', async () => {
|
||||
const host = createMockHost({
|
||||
contract,
|
||||
ledger: {
|
||||
'quote_intake.record': [
|
||||
{ customer_code: 'C001', pn: 'LM358DR', brand: 'TI', eau: 5000, type_tag: 'A' },
|
||||
{ customer_code: 'C001', pn: 'NE555', brand: 'TI', eau: 2000, type_tag: 'B' },
|
||||
],
|
||||
'quote_intake.type_table': [{ pn: 'LM358DR', type_tag: 'A' }],
|
||||
},
|
||||
});
|
||||
const outcome = await host.run(activity);
|
||||
expect(outcome).toMatchObject({ kind: 'submit', deliverable: 'intake-record' });
|
||||
expect(host.recording.views).toHaveLength(1);
|
||||
expect(host.recording.says.join()).toContain('A 1 条直通定价');
|
||||
describe('代码入口 —— 案例活动没有 run,不是残包', () => {
|
||||
it('默认导出是一个有名字的活动定义', () => {
|
||||
expect(activity.name).toBe('quote-intake');
|
||||
});
|
||||
|
||||
it('尚有未打标即等待,不提交放行', async () => {
|
||||
const host = createMockHost({
|
||||
contract,
|
||||
ledger: { 'quote_intake.record': [{ customer_code: 'C001', pn: 'LM358DR' }], 'quote_intake.type_table': [] },
|
||||
});
|
||||
expect(await host.run(activity)).toMatchObject({ kind: 'await' });
|
||||
});
|
||||
|
||||
it('视图模版含脚本时宿主拒推 —— 容器无脚本执行权', async () => {
|
||||
const host = createMockHost({ contract });
|
||||
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
|
||||
});
|
||||
|
||||
it('裸调用被基座拒发 —— 调用必附上下文', async () => {
|
||||
const host = createMockHost({ contract });
|
||||
await expect(
|
||||
host.base.interaction.issueWorkOrder({ mode: 'execution', title: 'x', basis: [], ask: 'y', fields: [] }),
|
||||
).rejects.toThrow(/裸调用/);
|
||||
});
|
||||
|
||||
it('监听时限必配 —— 无死等', async () => {
|
||||
const host = createMockHost({ contract });
|
||||
await expect(host.base.tools.listen({ eventType: 'data', keyFilter: {}, deadline: '' })).rejects.toThrow(/时限必配/);
|
||||
it('mock 宿主如实报「没有 run」,不静默返回一个假的 outcome', async () => {
|
||||
const host = createMockHost({ contract: CONTRACT });
|
||||
await expect(host.run(activity)).rejects.toThrow(/没有 run/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**。
|
||||
*
|
||||
* A1 反复空转的根因是没有它:S1 一次把八字段写齐之后,S3「补齐八字段」已经无活可干,
|
||||
* 而每一拍喂给模型的是整本说明书,它只能把同样三行再写一遍 ——
|
||||
* 被「写了等于没写」拒回,下一拍再来一遍,连拒到熔断。
|
||||
*
|
||||
* 剧本把这件事挪进引擎:`doneWhen` 一真,引擎自己收掉那一步,**不问模型**。
|
||||
* 所以这几条断言守的是剧本本身的可用性 —— 项 id 对不对得上、判据会不会开局即真、
|
||||
* 声明的台账与工具是不是活动真有的。写错任何一条,后果都是运行时才发现的空转。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { evaluateCondition } from '@sfeel';
|
||||
import { INTAKE_PHASES, INTAKE_STEPS, NARRATIVE } from '../src/case-model.ts';
|
||||
import { CMMN_XML } from '../src/case-model.ts';
|
||||
import { manifest } from '../src/manifest.ts';
|
||||
|
||||
/** 从 CMMN XML 里把计划项 id 抠出来 —— 剧本认的是它们 */
|
||||
const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map(
|
||||
(m) => m[1] ?? '',
|
||||
);
|
||||
|
||||
describe('剧本与案例模型对得上 —— 对不上的条目等于没写', () => {
|
||||
it('每一条剧本都指向一个真实存在的计划项', () => {
|
||||
for (const step of INTAKE_STEPS) {
|
||||
expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
|
||||
}
|
||||
});
|
||||
|
||||
it('图上每一步都有剧本 —— 漏掉的那一步会退回整本说明书', () => {
|
||||
for (const id of ITEM_IDS) {
|
||||
expect(INTAKE_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
|
||||
}
|
||||
});
|
||||
|
||||
it('清单里挂的就是这份剧本(不是另抄一份)', () => {
|
||||
expect(manifest.assets.businessModel.steps).toBe(INTAKE_STEPS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('doneWhen 是「成果已存在」的判据,不是「该不该做」的判据', () => {
|
||||
/** 开案那一刻的局面:台账一行没有,汇总列全无值 */
|
||||
const FRESH: Record<string, unknown> = {
|
||||
customerEmail: 'buyer@x.com',
|
||||
subject: '【询价】RFQ-2026-0001',
|
||||
body: 'LM358DR 12K/年',
|
||||
};
|
||||
|
||||
it('**开局一步都不该被自动收掉** —— 这是最容易写错的地方', () => {
|
||||
for (const step of INTAKE_STEPS) {
|
||||
if (step.doneWhen === undefined) continue;
|
||||
const ripe = safeEval(step.doneWhen, FRESH);
|
||||
expect(ripe, `${step.item} 的 doneWhen「${step.doneWhen}」开局就为真,那一步会在还没干之前被收掉`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
/* ── ① 定位客户信息 ─────────────────────────────────────────────── */
|
||||
|
||||
it('**老客户**认出来了 → S1 收得掉(不必问人)', () => {
|
||||
expect(safeEval(doneOf('S1_customer'), { ...FRESH, customer_total: 1, customer_new_total: 0 })).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* 这一条是拿一次实测换来的:剧本白纸黑字写着「别拿邮箱域名凑一个客户编码,也别自己编」,
|
||||
* 模型照样从 purchasing@sz-machinery.com 编出 `CUST-SZ-MACH` 直接把档建了 ——
|
||||
* 人根本没被问过一句,而客户编码是主数据的主键,编错一次后面每一单都跟着错。
|
||||
* **说明书管不住,收步条件管**:新客户没问过人,这一步就收不掉。
|
||||
*/
|
||||
it('**新客户没问过人 → S1 收不掉**(说明书管不住的,让收步条件管)', () => {
|
||||
expect(safeEval(doneOf('S1_customer'), { ...FRESH, customer_total: 1, customer_new_total: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it('新客户问过人了 → S1 收得掉', () => {
|
||||
expect(
|
||||
safeEval(doneOf('S1_customer'), { ...FRESH, customer_total: 1, customer_new_total: 1, customer_asked_total: 1 }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* 这一条守的是那个老坑:**不能写 `customer_code != null`** ——
|
||||
* S-FEEL 里未绑定的名字 `!= null` 求值为 true,这一步会在还没认人之前就被收掉。
|
||||
* 判据必须取同步写下的计数列。
|
||||
*/
|
||||
it('还没认到客户 → S1 收不掉,归一也进不了场', () => {
|
||||
expect(safeEval(doneOf('S1_customer'), FRESH)).toBe(false);
|
||||
expect(safeEval(doneOf('S2_normalize'), FRESH)).toBe(false);
|
||||
});
|
||||
|
||||
/* ── ② 归一客户 RFQ 输入 ────────────────────────────────────────── */
|
||||
|
||||
const normalized = (over: Record<string, unknown>) => ({
|
||||
...FRESH,
|
||||
customer_total: 1,
|
||||
line_total: 3,
|
||||
// 拆出来几行就报几条 —— 收步与验收同源(见下面那条「多写一行」)
|
||||
quote_intake_record_rows: 3,
|
||||
missing_key_total: 0,
|
||||
missing_minor_total: 0,
|
||||
asked_total: 0,
|
||||
...over,
|
||||
});
|
||||
|
||||
it('拆出行来了 → S2 收得掉(拆料是 AI 的活,办完交给下一步请人)', () => {
|
||||
expect(safeEval(doneOf('S2_normalize'), normalized({}))).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* **缺不缺字段不再是 S2 的收步条件** —— 缺口是卡上要说清的事,不是拦住拆料的理由。
|
||||
* 从前把「缺了就得问过人」写进这里,连带出下面那条真正的病:
|
||||
* 不缺就等于不必经过人。现在分开:S2 只管拆,S3_confirm 管「经过人」。
|
||||
*/
|
||||
it('缺必填 / 缺选填都不拦 S2 —— 那是 S3 卡上的事', () => {
|
||||
expect(safeEval(doneOf('S2_normalize'), normalized({ missing_key_total: 2 }))).toBe(true);
|
||||
expect(safeEval(doneOf('S2_normalize'), normalized({ missing_minor_total: 2 }))).toBe(true);
|
||||
});
|
||||
|
||||
it('一条都没归一出来 → S2 收不掉', () => {
|
||||
expect(safeEval(doneOf('S2_normalize'), normalized({ line_total: 0 }))).toBe(false);
|
||||
});
|
||||
|
||||
/*
|
||||
* **多写一行 → S2 就收不掉**(2026-08-01 加)。
|
||||
*
|
||||
* 实测:客户明说本轮跳过的那颗料被写进了有效清单 —— 数报得全对(9 有效 / 1 跳过),
|
||||
* 行却有 10 条。从前 S2 只看 `line_total > 0`,照收;错要到第 10 拍提交放行时
|
||||
* 才被同一条等式拦住,那时模型只会去改数(改了 skipped_total 又不对),连拒五拍熔断。
|
||||
* **同一个错,在第 2 拍发现比在第 10 拍便宜得多**:那时行刚写下,多的是哪一行一目了然。
|
||||
*/
|
||||
it('**多写一行(如把客户说跳过的那条也写了)→ S2 收不掉**,错在第 2 拍就现形', () => {
|
||||
expect(safeEval(doneOf('S2_normalize'), normalized({ quote_intake_record_rows: 4 }))).toBe(false);
|
||||
});
|
||||
|
||||
/* ── ③ 受理需求交人审定:阶段2 那张执行页,一单必经一次 ─────────── */
|
||||
|
||||
/*
|
||||
* **这一条是用户 2026-08-01 当场逼出来的**:「我都没确认需求 它就让我验收了」。
|
||||
*
|
||||
* 病根在旧口径:「经过人」是靠 `missing_minor_total = 0 or answered_total > 0`
|
||||
* 表达的 —— 只有缺东西的时候才叫人。字段齐全的一封询价进来,这个条件在拆完那一刻
|
||||
* 就为真,引擎自己把归一那步收了,人一眼没看过受理需求,
|
||||
* 下一次被叫到已经是终局的验收放行。**确认不是缺口的补丁,是一道恒在的门。**
|
||||
*
|
||||
* 所以这一步跟放行同一个做法:**人工激活 + 不给 doneWhen**。
|
||||
* 给它写 doneWhen 就是给引擎一把替人签字的钥匙;
|
||||
* 而「人看过、改过、认了」按定义也不是台账里任何一个数能自己变出来的。
|
||||
*/
|
||||
it('**受理需求那一步不给 doneWhen** —— 引擎不许替人认下这份稿', () => {
|
||||
expect(INTAKE_STEPS.find((s) => s.item === 'S3_confirm')?.doneWhen).toBeUndefined();
|
||||
});
|
||||
|
||||
it('审定这一步在剧本里,且摆的是阶段2 那份目标件', () => {
|
||||
const confirm = INTAKE_STEPS.find((s) => s.item === 'S3_confirm');
|
||||
expect(confirm, '受理需求交人审定这一步不见了').toBeDefined();
|
||||
expect(confirm?.view).toBe('intake-overview');
|
||||
/*
|
||||
* **执行卡零输入**(2026-08-02 用户定:填写全去工作区)——
|
||||
* 卡上不绑表、不摆格子;清单在工作区那份件上就地改(editGate + 孪生件),
|
||||
* 提交闸回落到必交付物的台账声明照样把必填看住。
|
||||
* 从前这儿断言卡里带 `"ledger":"quote_intake.record"` 的 blocks —— 那一版
|
||||
* 同一张表摆两处(卡上一份、件上一份),人不知道改哪一份算数。
|
||||
*/
|
||||
/*
|
||||
* 骨架已搬进说明书(2026-08-03:模型常在「拆表+收步+派卡」同一拍发这张卡,
|
||||
* 那一拍 S3 的剧本没摊开 —— 骨架只放剧本里就会发出裸卡,实测指导书整个是空的)。
|
||||
* 剧本里留的是指路;零输入纪律与骨架本体都在说明书里,每一拍都看得见。
|
||||
*/
|
||||
expect(confirm?.guide).toContain('骨架在说明书里');
|
||||
expect(NARRATIVE).toContain('受理需求审定卡的骨架');
|
||||
expect(NARRATIVE).toContain('卡上不摆表、不摆格子');
|
||||
expect(NARRATIVE).toContain('"itemId":"S3_confirm"');
|
||||
expect(confirm?.guide).not.toContain('"blocks"');
|
||||
expect(confirm?.guide).toContain('confirmed_total');
|
||||
});
|
||||
|
||||
/*
|
||||
* **收步判据与验收判据同源**(2026-08-01 改)。
|
||||
*
|
||||
* 从前 S5 只看自报的 `untyped_total = 0`、S6 只看「写过 alt_request_total 这个数」——
|
||||
* 数写对了就收步,而验收比的是**基座数出来的行**(`type_table_rows = record_rows`、
|
||||
* `alt_request_rows = type_c_total`)。两处比的不是同一件事,松的那一截就是返工:
|
||||
* 收了步却过不了验收,模型得回头补写一张已经收掉的表。
|
||||
*/
|
||||
it('打完标且两张表行数对上 → S5 收得掉', () => {
|
||||
const typed = { line_total: 3, untyped_total: 0, quote_intake_type_table_rows: 3, quote_intake_record_rows: 3 };
|
||||
expect(safeEval(doneOf('S5_classify'), typed)).toBe(true);
|
||||
});
|
||||
|
||||
it('**数归零了但表没写全 → S5 收不掉** —— 那正是从前「收了步却过不了验收」的口子', () => {
|
||||
const short = { line_total: 3, untyped_total: 0, quote_intake_type_table_rows: 1, quote_intake_record_rows: 3 };
|
||||
expect(safeEval(doneOf('S5_classify'), short)).toBe(false);
|
||||
});
|
||||
|
||||
/*
|
||||
* **S6 不再开单**(2026-08-02 用户当场定:「替代评估请求 / 询价请求这两项数据
|
||||
* 不在这里产出;随令牌流向下一个节点的只有工作成果验收内的内容」)。
|
||||
* 收步判据随之改成「两枚旗立过了」——它仍取**计数**而非布尔:
|
||||
* 一单全 A 类时两枚旗都是 false,「写了 false」与「压根没写」分不清。
|
||||
*/
|
||||
it('去向定了 → S6 收得掉', () => {
|
||||
expect(safeEval(doneOf('S6_route'), { untyped_total: 0, route_decided_total: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it('**没记过那个数 → S6 收不掉** —— 未绑定的名字比大小为假,不会开局即收', () => {
|
||||
expect(safeEval(doneOf('S6_route'), { untyped_total: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
it('全 A 类(两枚旗都是 false)照样收得掉 —— 判据看的是「定过没有」,不是旗本身', () => {
|
||||
const allA = { untyped_total: 0, route_decided_total: 1, need_alternative: false, need_sourcing: false };
|
||||
expect(safeEval(doneOf('S6_route'), allA)).toBe(true);
|
||||
});
|
||||
|
||||
it('**S6 不写请求单** —— 那两张表已经不归 A1 产出', () => {
|
||||
const writes = INTAKE_STEPS.find((x) => x.item === 'S6_route')?.writes ?? [];
|
||||
expect(writes).toEqual(['quote_intake.verdict']);
|
||||
});
|
||||
|
||||
/*
|
||||
* 放行触达世界 —— 它必须由人按一下(CMMN 里标了 manualActivationRule)。
|
||||
* 给它写 doneWhen 就是给引擎一把自动放行的钥匙,那是铁律③ 的红线。
|
||||
*/
|
||||
it('**放行那一项不给 doneWhen** —— 终局唯放行,引擎不许自动收它', () => {
|
||||
expect(INTAKE_STEPS.find((s) => s.item === 'T_release')?.doneWhen).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('剧本声明的东西活动真有 —— 声明了没有的,那一步照着做必然失败', () => {
|
||||
const declaredLedger = manifest.declarations.ledger.map((l) => `${l.domain}.${l.object}`);
|
||||
const declaredTools = manifest.declarations.tools.map((t) => t.name);
|
||||
|
||||
it('每一步要写的台账都在清单的台账声明里', () => {
|
||||
for (const step of INTAKE_STEPS) {
|
||||
for (const address of step.writes) {
|
||||
expect(declaredLedger, `${step.item} 要写 ${address},而活动没声明这个台账对象`).toContain(address);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('每一步要调的工具都在清单的工具声明里', () => {
|
||||
for (const step of INTAKE_STEPS) {
|
||||
for (const tool of step.tools) {
|
||||
expect(declaredTools, `${step.item} 要调 ${tool},而活动没声明这件工具`).toContain(tool);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('每一步都说得出它交出什么(intent 与 guide 都不为空)', () => {
|
||||
for (const step of INTAKE_STEPS) {
|
||||
expect(step.intent.trim(), `${step.item} 没写 intent`).not.toBe('');
|
||||
expect(step.guide.trim().length, `${step.item} 的 guide 太短,等于没写`).toBeGreaterThan(20);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function doneOf(item: string): string {
|
||||
const found = INTAKE_STEPS.find((s) => s.item === item)?.doneWhen;
|
||||
if (found === undefined) throw new Error(`${item} 没有 doneWhen`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** 谓词写坏了引擎当作「没算出来」——测里也照这个口径,断言才对得上运行时 */
|
||||
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
|
||||
try {
|
||||
return evaluateCondition(expression, scope);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* **每个计划项都得有格可待**(2026-08-02 用户:「卡片的进度条走的不对」)。
|
||||
*
|
||||
* 从前 `INTAKE_PHASES` 漏了 `S3_confirm`:计划项六个、名单里五个。
|
||||
* 于是 S2 一收,前两格全过,进度条当场跳到「判定去向」—— 而人这会儿正对着
|
||||
* 受理需求审定那张卡。**他手上办的那件事在条上没有格子**,条却已经走过去了:
|
||||
* 那不是走快了一格,是它在说一件没发生的事。
|
||||
*/
|
||||
describe('阶段刻度:每一项都有格可待', () => {
|
||||
const inPhase = new Set(INTAKE_PHASES.flatMap((p) => p.items));
|
||||
|
||||
it('**除放行外每一项恰好属于一个阶段** —— 漏一项,进度条就会在它办着的时候跳过去', () => {
|
||||
const planned = INTAKE_STEPS.map((s) => s.item).filter((id) => id !== 'T_release');
|
||||
expect(planned.filter((id) => !inPhase.has(id))).toEqual([]);
|
||||
});
|
||||
|
||||
it('一项不进两个阶段 —— 进了两个,「这一格过没过」就有两种答案', () => {
|
||||
const all = INTAKE_PHASES.flatMap((p) => p.items);
|
||||
expect(all.length).toBe(new Set(all).size);
|
||||
});
|
||||
|
||||
it('**放行不在名单里** —— 收口那一格由基座统一加,活动自己起名会各叫各的', () => {
|
||||
expect(inPhase.has('T_release')).toBe(false);
|
||||
});
|
||||
|
||||
it('阶段里不许出现案例模型没有的项 —— 那一格永远过不去', () => {
|
||||
const ids = new Set(INTAKE_STEPS.map((s) => s.item));
|
||||
expect([...inPhase].filter((id) => !ids.has(id))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,85 +1,764 @@
|
||||
/**
|
||||
* A1 询价受理的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
|
||||
*
|
||||
* 2026-07-29 由步序声明(flow)改写而来:活动不再分范式,一律案例模型 + Agent 临场调度。
|
||||
* 原来的六步在这里各归其位 —— 解析归一与追问补全同属「收料归一」阶段(常来回几轮,
|
||||
* 所以带 repetitionRule);查历史价与三分类打标属「定性分级」;末尾一张验收单。
|
||||
* 业务出处:`docs/MVP示例工作流.html` §1.1 的那张步骤流程图,原样照搬:
|
||||
*
|
||||
* ● 接流程起点 → S1 接收登记 → D1 判断完整度
|
||||
* ├─ 不完整 → S2 追问补全信息 ─┐
|
||||
* └─ 完整 ───────────────────┴→ S3 逐条解析归一
|
||||
* → S4 查历史价 → S5 逐条定状况标 → S6 定去向与派单 → G1
|
||||
*
|
||||
* **最长路径就是这六步**(常路五步 —— 字段齐就不走 S2),末尾加一张受理放行(终局唯放行)。
|
||||
*
|
||||
* ── 建模纪律:图上有几步就是几项 ──────────────────────────────────────
|
||||
*
|
||||
* 这份模型**重写过一次**。上一版自作主张加了两个阶段(收料归一 / 定性分级)、两个里程碑、
|
||||
* 一个裁量项、一个用户事件监听器,12 个计划项去装 6 步的活。业务上一分没多,
|
||||
* 卡壳的花样却多了一堆:阶段被当成写的落点(收了阶段,里面的项就永远入不了场)、
|
||||
* 里程碑要不要收、项该先 start 还是先干、编出不存在的项 id……
|
||||
*
|
||||
* 实测账很清楚:**每一条「行动被拒」都是结构上的,没有一条是业务判断上的** ——
|
||||
* 模型在跟 harness 较劲,不是在办事;一单烧掉二三十万 token 才收口,还常常收不了口。
|
||||
*
|
||||
* 所以:**图上有几步就是几项,不多建一件**。阶段、里程碑、裁量项都是 CMMN 有的东西,
|
||||
* 但「有」不等于「该用」—— 它们要能换来业务上说得出的好处,否则就只是给 Agent 添的坑。
|
||||
*/
|
||||
|
||||
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
|
||||
<case id="intake">
|
||||
<!--
|
||||
案卷项的 id **就是任务视图里的键名** —— 前提的 onPart 认的是「这个键有值了」。
|
||||
起单入参(subject)与台账列(line_total / missing_key_total / history_days /
|
||||
untyped_total / need_sourcing)都会摊平到任务视图顶层,所以照它们命名。
|
||||
名字对不上就等于前提永不满足:整案第一步都动不了(踩过)。
|
||||
|
||||
开案即有的只有那封信(客户邮箱 / 主题 / 邮件内容 / 邮件附件)。**询价单号不在其列** ——
|
||||
它是 S1 从信里认出来的,认出来才写得动台账(所有台账对象都以 rfq_ref 为主键)。
|
||||
-->
|
||||
<caseFileModel>
|
||||
<caseFileItem id="rfq" name="询价来函"/>
|
||||
<caseFileItem id="records" name="统一受理记录"/>
|
||||
<caseFileItem id="history" name="历史价格情报"/>
|
||||
<caseFileItem id="verdict" name="受理结论"/>
|
||||
<caseFileItem id="subject" name="来件主题(开案即有)"/>
|
||||
<!-- ① 定位客户:认出来(或人建出来)才算数 -->
|
||||
<caseFileItem id="customer_total" name="客户已确认"/>
|
||||
<caseFileItem id="customer_new_total" name="新客户(需建档)"/>
|
||||
<caseFileItem id="customer_asked_total" name="已就建档问过人"/>
|
||||
<caseFileItem id="line_total" name="登记出的条目数"/>
|
||||
<!-- ② 归一 RFQ:必填缺 = PN / 年用量;选填缺 = 品牌 / 备注 -->
|
||||
<!--
|
||||
缺必填的行数由**基座**按台账声明的 required 现数(quote_intake_record_missing)——
|
||||
不读活动自报的 missing_key_total:那个数要模型自己重算,漏算一次这道门就永远开不了
|
||||
(连跑十轮里三轮死在这)。自报的两个数照旧写,它们是给报告和人看的。
|
||||
-->
|
||||
<caseFileItem id="quote_intake_record_missing" name="缺必填的行数(基座现数)"/>
|
||||
<caseFileItem id="quote_intake_record_rows" name="受理记录行数(基座现数)"/>
|
||||
<caseFileItem id="missing_key_total" name="缺必填字段条数(PN/年用量)"/>
|
||||
<caseFileItem id="missing_minor_total" name="缺选填字段条数(品牌/备注)"/>
|
||||
<caseFileItem id="asked_total" name="已就缺口问过人的次数"/>
|
||||
<caseFileItem id="answered_total" name="人答完并已落表的次数"/>
|
||||
<!-- ③ 受理需求交人审定:这一单**经过人的手**了没有 -->
|
||||
<caseFileItem id="confirmed_total" name="受理需求已经人审定"/>
|
||||
<caseFileItem id="history_days" name="历史价判据"/>
|
||||
<caseFileItem id="untyped_total" name="未打标条数"/>
|
||||
<caseFileItem id="need_sourcing" name="去向旗"/>
|
||||
</caseFileModel>
|
||||
<casePlanModel id="plan" name="询价受理" autoComplete="true">
|
||||
<sentry id="s_rfq">
|
||||
<onPart id="op_rfq" caseFileItemRef="rfq"><standardEvent>create</standardEvent></onPart>
|
||||
<!-- D1 判断完整度在图上是一个菱形,在这里是两条前提:先认客户,再看归一齐没齐 -->
|
||||
<!--
|
||||
① 客户认出来了 —— 归一那一步的入场前提。
|
||||
先认客户再解析正文,不是先后顺序的洁癖:归一出来的每一条都要落在
|
||||
某个客户名下(record.customer_code),客户没定位就写不动那一列。
|
||||
-->
|
||||
<sentry id="s_customer">
|
||||
<onPart id="op_cu" caseFileItemRef="customer_total"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_cu"><condition>customer_total > 0 and (customer_new_total = 0 or customer_asked_total > 0)</condition></ifPart>
|
||||
</sentry>
|
||||
<sentry id="s_incomplete">
|
||||
<onPart id="op_ic" caseFileItemRef="records"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_ic"><condition>missingKeyFields > 0</condition></ifPart>
|
||||
<!--
|
||||
② 拆完了 —— 交人审定那一步的入场前提。只看「有没有拆出行来」:
|
||||
缺不缺字段不影响该不该给人看,缺口是**卡上要说清的事**,不是拦住这张卡的理由。
|
||||
|
||||
读**基座数的行数**,不读自报的 line_total(2026-08-02 用户实测送来的单,
|
||||
「机检只读基座数」第四次被违反现形):模型把 record 三行在 S1 拍就写了،
|
||||
却没记 line_total(那本是 S2 的账),S2 空手收步 —— 这道门于是永远不开,
|
||||
引擎报「依赖断点」,模型被推去发补全卡绕路,两张多余的卡把 4 次人环
|
||||
预算烧光,整单 exhausted。行落了没有,基座数得出来;门不该等一个
|
||||
要模型自己记的数。
|
||||
-->
|
||||
<sentry id="s_parsed">
|
||||
<onPart id="op_ps" caseFileItemRef="quote_intake_record_rows"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_ps"><condition>quote_intake_record_rows > 0</condition></ifPart>
|
||||
</sentry>
|
||||
<!--
|
||||
③ **人审定过了** —— 后面查历史价/打标的入场前提。
|
||||
必填(PN/年用量)缺一条都不算完(它是验收标准);而**这一单必须经过人的手**:
|
||||
confirmed_total 由「人把受理需求那张卡交回来」这件事产生 —— 详见 S3_confirm 的注释。
|
||||
|
||||
从前这里写的是「missing_minor_total = 0 or answered_total > 0」:
|
||||
**只在缺东西的时候才叫人**。于是一封字段齐全的询价进来,这个条件当场为真,
|
||||
引擎在下一拍开头把归一那一步自己收了 —— 人一眼没看过受理需求,
|
||||
下一次被叫到已经是终局的验收放行(用户 2026-08-01 当场指出:
|
||||
「我都没确认需求 它就让我验收了」)。**确认不是缺口的补丁,是这一步本身。**
|
||||
-->
|
||||
<sentry id="s_complete">
|
||||
<onPart id="op_cp" caseFileItemRef="records"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_cp"><condition>missingKeyFields = 0</condition></ifPart>
|
||||
<onPart id="op_cp" caseFileItemRef="confirmed_total"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_cp"><condition>line_total > 0 and quote_intake_record_missing = 0 and confirmed_total > 0</condition></ifPart>
|
||||
</sentry>
|
||||
<sentry id="s_basis">
|
||||
<onPart id="op_bs" caseFileItemRef="history_days"><standardEvent>update</standardEvent></onPart>
|
||||
</sentry>
|
||||
<!--
|
||||
打完标才进得了定去向 —— 条件读**基座按值现数**的三类条数,不读活动自报的
|
||||
untyped_total(那是它自己写的数,写错一次这道门就形同虚设)。
|
||||
-->
|
||||
<sentry id="s_typed">
|
||||
<onPart id="op_tp" caseFileItemRef="records"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_tp"><condition>untyped = 0</condition></ifPart>
|
||||
<onPart id="op_tp" caseFileItemRef="untyped_total"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_tp"><condition>quote_intake_record_rows > 0 and quote_intake_record_type_tag_a + quote_intake_record_type_tag_b + quote_intake_record_type_tag_c = quote_intake_record_rows</condition></ifPart>
|
||||
</sentry>
|
||||
<!--
|
||||
── 放行门的入场条件(2026-08-02 用户实测逼出来的)────────────────────────
|
||||
|
||||
从前这道哨兵**只有 onPart 没有 ifPart**:意思是「只要有人往 need_sourcing
|
||||
写过一笔就开门」—— 写个 false 也算。实测的下场:模型跳过 S4/S5/S6,
|
||||
在 verdict 上顺手写了 need_sourcing: false,放行门当场 Available,
|
||||
它 start 完就提交;而受理记录三行一个标都没打、分类表一行都没写。
|
||||
提交门正确地拦住了,可**该拦的地方本不该放它进来** —— 一道只认「有人写过」
|
||||
的门,等于没有门。
|
||||
|
||||
现在认的是**办完了没有**,且三个数全由基座现数(活动自报的数改一下就骗过去了):
|
||||
逐条打了标、去向定过了。route_decided_total 是 S6 显式写下的记账数
|
||||
(旗是布尔,分不清「没写」与「写了 false」)。
|
||||
-->
|
||||
<sentry id="s_routed">
|
||||
<onPart id="op_rt" caseFileItemRef="need_sourcing"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_rt"><condition>quote_intake_record_rows > 0 and quote_intake_record_type_tag_a + quote_intake_record_type_tag_b + quote_intake_record_type_tag_c = quote_intake_record_rows and route_decided_total > 0</condition></ifPart>
|
||||
</sentry>
|
||||
|
||||
<stage id="S1" name="收料归一" autoComplete="true">
|
||||
<entryCriterion id="ec_s1" sentryRef="s_rfq"/>
|
||||
<humanTask id="T_normalize" name="人工逐条解析归一">
|
||||
<itemControl><manualActivationRule id="m_nm"/><repetitionRule id="r_nm"/></itemControl>
|
||||
<!--
|
||||
① 定位客户信息 —— 拿发件邮箱去客户主数据里认人。
|
||||
认得出就取它的客户编码;认不出说明这是个**新客户**,发数据录入卡请人建档
|
||||
(公司名、客户编码、联系人)—— 建档是人的事,活动不替他编一个客户出来。
|
||||
-->
|
||||
<humanTask id="S1_customer" name="定位客户信息:按发件邮箱认客户,认不出请人建档">
|
||||
<itemControl><repetitionRule id="r_s1"/></itemControl>
|
||||
</humanTask>
|
||||
<humanTask id="T_ask_back" name="人工追问补全信息">
|
||||
<entryCriterion id="ec_ask" sentryRef="s_incomplete"/>
|
||||
<itemControl><manualActivationRule id="m_ab"/><repetitionRule id="r_ab"/></itemControl>
|
||||
</humanTask>
|
||||
</stage>
|
||||
<milestone id="M_complete" name="八字段齐"/>
|
||||
|
||||
<stage id="S2" name="定性分级" autoComplete="true">
|
||||
<entryCriterion id="ec_s2" sentryRef="s_complete"/>
|
||||
<humanTask id="T_history" name="人工查历史价">
|
||||
<itemControl><manualActivationRule id="m_hi"/></itemControl>
|
||||
<!--
|
||||
② 归一客户 RFQ 输入 —— 解析邮件正文,逐条落成受理记录。
|
||||
PN / 年用量**必填**;客户料号 / 品类 / 制造商 / 目标价是**建议追问**;其余**选填**。缺哪几格照实记进两个缺口数。
|
||||
**这一步不叫人**:拆料是 AI 的活,拆完交给下一步给人看。
|
||||
-->
|
||||
<humanTask id="S2_normalize" name="归一客户RFQ输入:解析正文逐条落表">
|
||||
<entryCriterion id="ec_s2" sentryRef="s_customer"/>
|
||||
<itemControl><repetitionRule id="r_s2"/></itemControl>
|
||||
</humanTask>
|
||||
<decisionTask id="D_classify" name="逐条打标 A/B/C" decisionRef="p03-type"/>
|
||||
<planningTable id="pt_s2">
|
||||
<humanTask id="T_lifecycle" name="人工核生命周期"/>
|
||||
</planningTable>
|
||||
</stage>
|
||||
<milestone id="M_typed" name="全条已打标"/>
|
||||
|
||||
<humanTask id="T_release" name="人工受理结论验收放行">
|
||||
<entryCriterion id="ec_rel" sentryRef="s_typed"/>
|
||||
<!--
|
||||
③ 受理需求交人审定 —— **阶段2 那张执行页,一单必经一次**。
|
||||
目标件 「A1-询价受理需求.html」 的落款写着「AI 拟稿,你审定;可就地修改后再确认」,
|
||||
页脚是【修改】与【确认受理记录】—— 它从来就是一张**人签的页**,不是缺东西时才弹的追问。
|
||||
|
||||
**manualActivation + 不给 doneWhen**:两样缺一不可。
|
||||
触达世界的项一律不给 doneWhen —— 给了就等于给引擎一把替人签字的钥匙;
|
||||
而这一步的成果(人看过、改过、确认了)按定义不可能由引擎自己判出来。
|
||||
-->
|
||||
<humanTask id="S3_confirm" name="受理需求交人审定:整份清单可就地改,确认后才往下走">
|
||||
<entryCriterion id="ec_s3" sentryRef="s_parsed"/>
|
||||
<itemControl><manualActivationRule id="m_s3"/></itemControl>
|
||||
</humanTask>
|
||||
|
||||
<!-- S4 查历史价:取齐 P-03 的三项判据 -->
|
||||
<humanTask id="S4_history" name="查历史价:取齐距上次报价天数/行情/生命周期">
|
||||
<entryCriterion id="ec_s4" sentryRef="s_complete"/>
|
||||
</humanTask>
|
||||
|
||||
<!-- S5 逐条定状况标:决策表求值,不是临场判断 -->
|
||||
<decisionTask id="S5_classify" name="逐条定状况标:按三分类规则P-03打标" decisionRef="p03-type">
|
||||
<entryCriterion id="ec_s5" sentryRef="s_basis"/>
|
||||
</decisionTask>
|
||||
|
||||
<!-- S6 定去向与派单:两枚去向旗 + 开替代评估请求/询价请求 -->
|
||||
<humanTask id="S6_route" name="定去向与派单:立两枚旗,开替代评估请求/询价请求">
|
||||
<entryCriterion id="ec_s6" sentryRef="s_typed"/>
|
||||
</humanTask>
|
||||
|
||||
<!-- 受理放行:终局唯放行 —— 触达世界,必须人工激活 -->
|
||||
<humanTask id="T_release" name="受理结论验收放行">
|
||||
<entryCriterion id="ec_rel" sentryRef="s_routed"/>
|
||||
<itemControl><manualActivationRule id="m_rl"/></itemControl>
|
||||
</humanTask>
|
||||
|
||||
<timerEventListener id="TL_half" name="时限过半">
|
||||
<timerExpression>PT12H</timerExpression>
|
||||
</timerEventListener>
|
||||
<userEventListener id="UL_amend" name="客户改需求"/>
|
||||
</casePlanModel>
|
||||
</case>
|
||||
</definitions>`;
|
||||
|
||||
export const NARRATIVE = `§0 开案:一封询价来函即开案。信里是 RFQ 正文与 BOM 附件,条目从几条到上百条不等。
|
||||
受理这件事的成品只有一样:**一份能往下走的统一受理记录** —— 八字段齐、每条都打了标。
|
||||
/**
|
||||
* 案例说明书 —— **只写整单一以贯之的事**(2026-07-31 砍掉逐步做法)。
|
||||
*
|
||||
* 引擎每一拍已经把「当前这一步怎么做」单独摊开喂进去了(`INTAKE_STEPS[].guide`)。
|
||||
* 说明书里再写一遍全部六步,是**两笔账同时亏**:
|
||||
* · 钱 —— 同一段做法一拍烧两遍,而它是每拍都在的常驻上下文;
|
||||
* · 准 —— 两份会分叉。实测就分叉过:这里写着 `S1_register` / `S2_ask_back` /
|
||||
* `S3_normalize` 三个**案例模型里根本不存在的计划项**(真名是 S1_customer /
|
||||
* S2_normalize),模型照说明书报 itemId,一报一个被拒。
|
||||
*
|
||||
* 所以留下的只有逐步做法**说不了**的那几件:这一单是什么、成品是什么、
|
||||
* 一路上怎么跟人说话、怎么把一项干完、结案是什么。逐步的活归剧本。
|
||||
*/
|
||||
export const NARRATIVE = `§0 开案:**一封客户询价来函即开案**。你手上只有这封信的四样东西 ——
|
||||
客户邮箱(customerEmail)、主题(subject)、邮件内容(body)、邮件附件(attachments,真文件)。
|
||||
没有客户编码,没有询价单号:那两样是**受理出来的**,不是随信送来的。
|
||||
真实 RFQ 五花八门 —— 俄文 BOM、客户内部码、字段东缺西缺都常见。
|
||||
**受理这件事的成品只有一样**:一份能往下走的询价受理记录 —— 逐条落表、每条打了状况标、
|
||||
两枚去向旗定了。SLA:受理<15 分钟,判定<45 分钟。判错状况,快单变慢单。
|
||||
|
||||
S1 收料归一:逐条把 BOM 解析成统一格式(PN / 厂牌 / EAU / 目标价 / 交期 / 包装 / 品质要求 / 备注)。
|
||||
解析不出的不许臆造 —— 缺关键字段(PN、EAU、交期为必要三要素)就追问客户,常要来回一两轮,
|
||||
所以这两件事都可重复做。**吃不准的宁可空着去问,也不许填一个看起来合理的值。**
|
||||
**整条路就这几步**,一步一个计划项,别绕(每一步的做法,轮到它时会单独摊给你,
|
||||
这里不重复):S1 定位客户 → S2 归一受理 → **S3 受理需求交人审定** → S4 查历史价 →
|
||||
S5 逐条定状况标 → S6 定去向与派单 → 提交放行。
|
||||
|
||||
S2 定性分级:先查历史价(同客户同料近一年有没有报过、隔了多久、行情稳不稳),
|
||||
再按三分类规则 P-03 逐条打标 —— A 类历史价直用、B 类需重新核价、C 类需选型或寻源。
|
||||
拿不准的一律降 B,不许臆断 A:错标 A 会让一条本该重新核价的料带着旧价一路走到报价单上。
|
||||
生命周期核查是裁量项 —— 遇到疑似 EOL 的料随手拉一次。
|
||||
**这一单要经过人两次,不多不少**:S3 人审定受理需求(阶段2 那张执行页,
|
||||
字段齐不齐都要发)、放行时人签字。**别把 S3 当成缺东西时的追问** ——
|
||||
不缺也发:拆错一格只有人看得出来,而那些错往后每一步都跟着跑。
|
||||
|
||||
§Z 结案:八字段齐、每条都已打标、BD 签字放行,自动结案(CMMN AutoComplete)。
|
||||
结论只有两个去向要交出去:需不需要选型、需不需要寻源 —— 下游的两道网关读的就是这两面旗。`;
|
||||
**附件是真文件,基座已经替你读过一遍**。每一件长这样
|
||||
\`{ name, contentType, size, ref, text?, note }\`:
|
||||
\`text\` 有值就是**读出来的内容**(xlsx 读成制表符分列的行,第一行多半是表头),照它拆条;
|
||||
\`text\` 没有就是这类文件读不出文本(\`note\` 里写着为什么,比如 pdf、扫描件),
|
||||
这时以邮件内容为准。**别猜附件里有什么**:文件名叫 BOM 不等于里面有 BOM。
|
||||
|
||||
【一路上要说话】负责人(BD)正看着这一单。**每一拍都带一句 say** —— 你是在跟他当面说话,
|
||||
不是播报进度。他明天要拿这些话去跟客户和采购讲,所以说的得是**业务的话**:
|
||||
料号、交期、用量、价、客户要求;不是「关键字段」「归一化阶段」「写草稿 3 行」这些系统词。
|
||||
|
||||
一两句就够,口语,像同事扭头说一声。局面变了顺手用 view 换画面:
|
||||
· S2 逐条落表后 → view = intake-overview。**先说这单是什么**,再说有没有坑:
|
||||
「深圳机械这单三颗料,车规 Analog;交期用量都写清楚了,只有 NE555P 没给目标价。」
|
||||
· S5 打标完 → view = intake-classified。**说结论对这单的影响**:
|
||||
「LM358DR 有近期成交价可以直接报;NE555P 半年多没动过要重新核;TL072CDR 停产了,得找替代。」
|
||||
· S6 定完去向 → view = intake-release。**说这单接下来往哪走、谁接手**。
|
||||
|
||||
**交代里说缺什么,用件上那三个词**(必填 / 建议 / 选填),而且**只说已发生的事**:
|
||||
客户没给的就说「客户没给」并留空 —— 估价、垫数这类事你没有权限做,更不许在交代里
|
||||
声称做过(见「三种分量」那段:目标价尤其如此)。历史价、三分类、去向是**审定之后**
|
||||
那几步的事:这一拍它们连数都还没有,一个字都不要提。
|
||||
派审定卡那一拍的交代照这个样子说(数字与料号换成本单的):
|
||||
「三颗料拆好了,交期用量都齐;LM358DR、TL072CDR 客户给了目标价,NE555P 没给
|
||||
(建议追问,不拦单)。整份受理需求请你审定,哪一格不对在工作区的件上就地改。」
|
||||
|
||||
别每拍一个腔调复读(「已完成X,现在进行Y」)。事情不同,话就该不同 ——
|
||||
调工具那一拍说「我去查这三颗的历史价」,拿到结果那一拍说查出了什么,
|
||||
两句话该是不一样的语气。有风险、要他拿主意、或者你打算叫人,**先说出来**,
|
||||
别等他自己从表里看出来。不说话的后果是:他的对话区从头到尾空白,只看得见「多了一个任务」。
|
||||
|
||||
【怎么把一项干完 · 一拍连做】一个计划项常要**两三个动作**才交得出成果(先写记录、
|
||||
再结算总数;先调工具拿判据、再把判据写进台账)。write 和 tool 都**不会**自动把那一项收掉 ——
|
||||
都做完了,用 finish 宣告这一项完成。**这些动作排进同一拍的 actions 串里一口气给**,
|
||||
别拆拍:「写 record、写 verdict、finish S1」是一拍,不是三拍;
|
||||
「写 type_table 打标结果、写 verdict 统计、finish S5、写 alt/rfq 请求、写旗、finish S6」
|
||||
能排一串就排一串。只有两处必须停拍:调工具要看出参(tool 放串尾,下一拍接着干);
|
||||
发卡 / 提交会挂起(它们只能是串尾)。**这一单常路 5-7 拍就该收口** ——
|
||||
每多拆一拍,就是多烧一遍完整上下文。
|
||||
|
||||
【受理需求审定卡的骨架 —— 哪一拍发都照这个,一个字段别少】
|
||||
拆完受理记录就要派这张卡(S3_confirm)。**骨架放在说明书里正是因为你常在
|
||||
「拆表 + 收步 + 派卡」同一拍里发它** —— 那一拍 S3 的剧本还没摊开,凭记忆拼出来的
|
||||
就是一张只有一句话的裸卡(实测:人收到的指导书整个是空的)。照抄:
|
||||
{"kind":"human","itemId":"S3_confirm","workOrder":"execution","card":{
|
||||
"title":"受理需求审定:<单号> 共<N>条","ask":"<照当下情形写,见下>",
|
||||
"steps":["型号 PN 与年用量 EAU 必须有 —— 缺一条这一单就走不到放行;客户没给的去问,问不到也要在备注里写清「客户未提供」",
|
||||
"客户料号、品类、制造商、目标价这四格是建议追问 —— 不填也走得到放行,但那一问不能省;尤其目标价,有它才谈得上「报得进不进得去」,客户不肯给就问一句「贵司这颗现在的成交价区间」",
|
||||
"客户没给的一律留空,由你判断要不要追问 —— 系统查到的历史价是分类依据,不是客户的目标价",
|
||||
"改在工作区的件上:抽错的就地改,多出来的行按行首 × 撤掉,漏掉的用表尾「加一行」补上;改完保存,回这张卡提交"],
|
||||
"standard":"必填(PN / 年用量)一条不缺;建议档(客户料号 / 品类 / 制造商 / 目标价)该问的都问过了 —— 问到的填上,问不到的留空并在备注里写明,放行时由你决定先受理还是退回。",
|
||||
"basis":[{"k":"<缺格的 PN>","v":"缺年用量(必填,须补)"},
|
||||
{"k":"<缺格的 PN>","v":"缺制造商(建议追问,可先受理)"},
|
||||
{"k":"<缺格的 PN>","v":"客户未给目标价(建议追问,不拦单)"}]}}
|
||||
· steps 与 standard 是**作业指导书**,卡上照原样摆给人看 —— 这一段每一单都一样,照抄别重写;
|
||||
**卡面是纯文本**:里面别写星号那类记号,它们不会变粗体,只会原样摆在人眼前(实测过)。
|
||||
· ask 照当下情形写:**有必填缺**写「有几条缺 PN/年用量,须向客户问清后填回」;
|
||||
**只缺建议档**写「有几条缺建议追问的格子(客户料号/品类/制造商/目标价),可先受理也可去问」;
|
||||
**都齐**写「请核对确认」。三档的词照件上那三个词说,别混用。
|
||||
· basis 逐条写**谁缺什么、是必填还是建议还是选填**;一格不缺就写这一单的要点
|
||||
(几条料、客户点名的口径要求)。
|
||||
· **卡上不摆表、不摆格子**(填写全在工作区):整份清单就在工作区那份件上,
|
||||
每一格能就地改、能增删行,缺哪几格件顶上的红带点着名。别往卡里塞 blocks 或 fields ——
|
||||
塞了是把同一张表摆两处,人不知道改哪一份算数。
|
||||
|
||||
§Z 结案:BD 在验收单上签字,系统把草稿合并成正式记录并发出 intake 口的载荷。`;
|
||||
|
||||
/**
|
||||
* **步骤剧本** —— 一个计划项一条,把「这一步是什么状态」写成机器读得懂的东西。
|
||||
*
|
||||
* 说明书说的是**整单一以贯之的事**(这一单是什么、怎么说话、怎么收口);
|
||||
* 逐步的做法只写在这里,轮到哪一步才摊开哪一步 —— **两处不重复写同一件事**,
|
||||
* 重复的代价是钱(每拍烧两遍)与准(两份会分叉,分叉过一次:说明书里的计划项名
|
||||
* 是案例模型里没有的三个,模型照它报 itemId,一报一个被拒)。
|
||||
*
|
||||
* `doneWhen` 是这里的关键:它让引擎自己算得出「这一步的成果已经在台账里了」,
|
||||
* 算出来就直接收掉,**不问模型**。A1 反复空转正是栽在没有它 ——
|
||||
* S1 把八字段一次写齐之后,S3「补齐八字段」已经无活可干,而模型只能把同样三行
|
||||
* 再写一遍,被「写了等于没写」拒回,下一拍再来一遍(实测连拒到熔断)。
|
||||
*
|
||||
* 写 `doneWhen` 的纪律:**它是「成果已存在」的判据,不是「该不该做」的判据**。
|
||||
* 写成开局即为真的式子(如 `missing_key_total = 0`),那一步会在还没干之前就被收掉。
|
||||
*/
|
||||
export const INTAKE_STEPS = [
|
||||
{
|
||||
item: 'S1_customer',
|
||||
intent: '按发件邮箱认客户;认不出就发数据录入卡请人建档,建完写进客户主数据',
|
||||
writes: ['customer.profile', 'customer.contact', 'quote_intake.verdict'],
|
||||
/* 认客户就靠这两张 —— 没声明的话它们会被整表段的截断刀砍掉(踩过) */
|
||||
reads: ['customer.contact', 'customer.profile'],
|
||||
view: 'intake-overview',
|
||||
tools: [],
|
||||
/*
|
||||
* 认到人才算办完。**不能写 `customer_code != null`** ——
|
||||
* S-FEEL 里未绑定的名字 `!= null` 求值为 true,这一步会开局即被收掉(A1 踩过)。
|
||||
* 一律取同步写下的计数列。
|
||||
*/
|
||||
doneWhen: 'customer_total > 0 and (customer_new_total = 0 or customer_asked_total > 0)',
|
||||
guide: `这一单的第一件事是**认人**:这封信是谁发来的。
|
||||
|
||||
① 先查客户主数据:拿入参的 \`customerEmail\` 去 \`customer.contact\` 里找 ——
|
||||
找得到就取它那一行的 \`customer_code\`,这一步就完了(照下面第③条记账,不必问人)。
|
||||
|
||||
**邮箱一个字符都不许改**:用 \`customerEmail\` 的**原文**去比,别照签名里的姓名
|
||||
重新拼一个(实测踩过:入参是 \`e.volkova@itelma.ru\`,模型按签名拼成
|
||||
\`ekaterina.volkova@itelma.com\` —— 名字对、域名和缩写全错,于是老客户被当成新客户,
|
||||
白发一张建档卡,还差点建出一个重复的客户档案)。
|
||||
比对时忽略大小写与首尾空格,除此之外**逐字符相等才算认出**;
|
||||
域名相同但用户名不同**不算认出** —— 同一家公司不同的人,是两个联系人。
|
||||
|
||||
② **找不到就是新客户** —— 发**数据录入卡**(不是补全卡)请人建档。
|
||||
两者的分别在**谁写库**:补全卡收回来的值要你自己再 write 一遍(中间隔一道传话,
|
||||
你可能抄错、可能顺手改一笔);数据录入卡是**人按下确认那一刻基座直接写**,你不经手。
|
||||
客户档案是主数据,建错一次后面每一单都跟着错 —— 那一笔该由人亲手落下。
|
||||
|
||||
**建档要建两张表:公司档 + 联系人档,两段都摆上这张卡**(2026-08-02 用户定:
|
||||
「新建客户 联系人和公司都需要确认」)。只摆公司那一半、联系人由你自己写,
|
||||
等于让人确认了一半 —— 而联系人档里的 \`contact_code\` 恰恰是**你编出来**的那一个。
|
||||
|
||||
**照这个骨架发,一个字段都别少**(散文描述不管用,直接抄):
|
||||
{"kind":"entry","itemId":"S1_customer","ask":"这是位新客户,请核对建档信息并给客户编码",
|
||||
"ledger":"customer.profile","entryKey":{"customer_code":""},
|
||||
"basis":[{"k":"发件邮箱","v":"chen@sh-elec.com"},{"k":"签名","v":"陈刚 | 上海精密电子有限公司"}],
|
||||
"fields":[
|
||||
{"key":"customer_code","label":"客户编码","required":true},
|
||||
{"key":"name","label":"公司名","value":"上海精密电子有限公司","required":true}
|
||||
],
|
||||
"also":[{"title":"联系人档案","ledger":"customer.contact",
|
||||
"key":{"customer_code":""},
|
||||
"fields":[
|
||||
{"key":"name","label":"联系人姓名","value":"陈刚","required":true},
|
||||
{"key":"email","label":"邮箱","value":"chen@sh-elec.com","required":true}
|
||||
]}]}
|
||||
**联系人编码不用你写** —— 它是内部流水号,基座在人按下确认那一刻按「客户编码-01」生成
|
||||
(台账声明里标了自增)。你硬塞一个进 \`fields\`,基座也会把它摘掉:
|
||||
人哪知道这家客户已经有几位联系人,而你编一个就可能覆盖别人那一行。
|
||||
**公司名与联系人姓名、邮箱都必须带 \`value\`** —— 公司名几乎总写在开头那句
|
||||
「我是XX公司的张三」或结尾签名档里;联系人姓名从落款认;**邮箱照 \`customerEmail\` 原样抄,
|
||||
一个字符都别改**。读出来填上,人核对一眼就行。
|
||||
**客户编码不给 \`value\`** —— 那是我们内部的编码,来件里不会有,硬凑一个是错的;
|
||||
标 required:true,人不填就确认不了。
|
||||
两段的 \`customer_code\` 都留空:人在第一段填了什么,第二段跟着是什么(同一位客户)。
|
||||
|
||||
③ **记账两个数,引擎按它们收步**(别绕过去,绕不过去):
|
||||
· customer_new_total —— 第①步认出来了记 0;认不出、要建档记 1;
|
||||
· customer_asked_total —— 发过建档卡就记 1。
|
||||
**新客户没问过人,这一步收不了** —— 实测有过一次:模型从邮箱域名
|
||||
编出 \`CUST-SZ-MACH\` 直接把档建了,人根本没被问过一句,而那个编码是错的。
|
||||
客户编码是主数据的主键,编错一次后面每一单都跟着错。
|
||||
|
||||
④ 人答完(或第①步就认出来了),同一拍把该写的写上:
|
||||
· \`customer.profile\` 与 \`customer.contact\` —— **新客户这两张不用你写**:
|
||||
人在录入卡上按下确认那一刻基座已经落好了(两段一次落两行)。
|
||||
再写一遍只会被「写了等于没写」拒回。认出来的老客户更不必重写主数据。
|
||||
· \`quote_intake.verdict\`:customer_code、**customer_name**(公司名,认出来的取主数据那一行,
|
||||
新建的取卡上填的)、**contact_name**(发件人姓名,从落款或邮箱认;认不出就留空,别拿公司名顶)、
|
||||
**customer_kind**(照第③条的判断写「老客户」或「新客户」四个字,
|
||||
别的写法都不算 —— 报告上那一格与 A4 的首单口径都读它),并把 **customer_total 记 1**。
|
||||
|
||||
顺带把**询价单号 rfq_ref** 认出来记进 verdict:从主题或正文里原样照抄
|
||||
(RFQ-xxxx / 询价单号 / Ref / PO 号),认不出就在②那张卡上一并问,**不要自己编** ——
|
||||
所有台账都以 rfq_ref 为主键,编错了这一单的行会和别的单撞在一起。`,
|
||||
},
|
||||
{
|
||||
item: 'S2_normalize',
|
||||
intent: '解析邮件正文与附件 BOM,逐条落成受理记录;缺哪几格照实记数',
|
||||
writes: ['quote_intake.record', 'quote_intake.verdict'],
|
||||
reads: ['customer.profile'],
|
||||
/* 拆料这一拍工作区就该摆出受理需求那份件 —— 人看得见 AI 拆到哪儿了 */
|
||||
view: 'intake-overview',
|
||||
tools: [],
|
||||
/*
|
||||
* **这一步不叫人**:拆料是 AI 的活,拆完就算办完,由下一步(S3_confirm)交给人审定。
|
||||
*
|
||||
* 从前这里既拆料又发卡,收步条件是
|
||||
* `line_total > 0 and missing_key_total = 0 and (missing_minor_total = 0 or answered_total > 0)`
|
||||
* —— 括号里那半句的意思是「缺东西才需要经过人」。于是一封字段齐全的询价进来,
|
||||
* 条件当场为真,引擎在下一拍开头把这一步自己收了,人一眼没看过受理需求;
|
||||
* 下一次被叫到已经是终局的验收放行。用户 2026-08-01 当场指出这件事。
|
||||
*
|
||||
* 现在这里只留「拆出行来了没有」:缺不缺字段不是收步的事,是卡上要说清的事。
|
||||
*/
|
||||
/*
|
||||
* **拆出来几行,就报几条** —— 收步判据与验收判据同源(2026-08-01 加后半句)。
|
||||
*
|
||||
* 从前只看 `line_total > 0`:多写一行也照收,错要到第 10 拍提交放行时
|
||||
* 才被 `record_rows = line_total` 拦住 —— 那时上下文早翻篇了,
|
||||
* 模型只会去改数(实测:客户说本轮跳过的那颗料被写进有效清单,
|
||||
* 改数又让 skipped_total 不对,连拒五拍熔断)。
|
||||
* **同一个错,在第 2 拍发现比在第 10 拍便宜得多**:那时行刚写下、多的是哪一行一目了然。
|
||||
*/
|
||||
doneWhen: 'line_total > 0 and quote_intake_record_rows = line_total',
|
||||
guide: `客户认出来了,现在把询价内容逐条落成受理记录。
|
||||
|
||||
【先看料在哪】**正文与附件分工不同,别只读一处**:
|
||||
· 附件的 \`rows\` 有值 → **基座已经把那张表拆好了**:一行一个对象、键就是表头列名。
|
||||
**照它拆条,别自己去数制表符** —— 数格子是基座替你做的事,你只管把列对到我方字段上。
|
||||
(\`text\` 仍留着原文:口径要求那些不在表里的东西还得从正文与原文看。)
|
||||
· 只有 \`text\` 没有 \`rows\` → 那张表不规整(列数对不齐),基座不敢替你拆;
|
||||
这时才自己看原文,并且格外小心连着的制表符是「中间那一格空着」。
|
||||
这时正文通常只给**口径要求**(币种/贸易条款/交期单位/认证/替代授权/本轮跳哪条),
|
||||
那些不是料,是**落到备注与品质要求里的约束**。
|
||||
· 附件没有或读不出 → 以正文为准。**别猜附件里有什么**。
|
||||
|
||||
【客户说不报的,就不要报】正文里点名「本轮跳过 / not finalised / 暂不报」的那几条,
|
||||
**不进有效清单** —— 但要在收尾时说清「本轮不报几条、为什么」。
|
||||
有效条数 = 附件总行数 − 客户点名跳过的条数,两个数都要说得出。
|
||||
**跳过的那条连一行都别写进 \`record\`**:实测栽过一次 —— 数报对了(9 有效 / 1 跳过),
|
||||
行却写了 10 条,判据 \`record_rows = line_total\` 当场拦住,而那时改数字是改不对的
|
||||
(改了 skipped_total 又不对)。真写进去了就用 \`drop\` 把那一行撤掉,别去动数。
|
||||
|
||||
【表头怎么对】BOM 的列名常是客户本国语言或缩写,**先把列对上再拆行**:
|
||||
· 年用量那一列叫 EAU / Qty / 年用量 / Годовой объём;**带 (k) 就是千**(30 → 30000);
|
||||
· 制造商叫 Manufacturer / Производитель;品类叫 Category / Категория;
|
||||
· 客户自己的编码常在第一列(Itelma Code / Customer P/N)→ 落 customer_pn。
|
||||
**对不上列就别猜**:宁可把那一列整列判为缺,也不要把「认证要求」当成「年用量」。
|
||||
|
||||
【制表符分列:空格子会连着出现,别把后面的值往前挪】
|
||||
一行里连着两个制表符(\`\\t\\t\`)就是**中间那一格空着**,不是没有这一格。
|
||||
拆每一行前先数:**这一行的格子数必须与表头的列数相同**,数不对就整行重数,
|
||||
别把右边的值挪到左边的空位上 —— 实测栽过两次,同一行:
|
||||
制造商那一格空着,于是「浙江嘉康」被读成上一行的厂牌、品类也跟着错位成「集成电路」,
|
||||
而 BOM 上白纸黑字写着 Антенна(天线)与浙江嘉康。
|
||||
行尾连着几个制表符也一样:那是**末尾几列都空着**(年用量、目标价客户没给),
|
||||
不是这一行到此为止 —— 末尾空列照样要算进格子数里。
|
||||
列对上了就**逐行照抄值**,别整列判缺 —— 实测栽过一次:9 条料的年用量明明都在表里,
|
||||
却被整批报成「缺年用量」,一张卡问了客户 9 个他早就给过的数。
|
||||
|
||||
【一张卡别铺太长】缺口卡的 basis 与表格**只列真正缺的那几行**,不要把全部条目铺进去:
|
||||
整份回复有长度上限,铺太长会在半途被截断 —— 截断的 JSON 解析不了,
|
||||
这一拍作废、还会弹一张「需要你判断下一步」把人叫来(实测)。缺几条就写几条。
|
||||
【三种分量 —— 与件上的空占位、执行卡的指导书**逐字同一套话**】
|
||||
台账声明里怎么标,件上就是什么颜色,你说话就得用哪个词。三档只有这三个词:
|
||||
· **必填(只有这两项)**:\`pn\`、\`eau\` 年用量 —— 缺了这一单没法报价,
|
||||
**这是验收标准**,不齐不许放行(件上标红「必填」),记进 missing_key_total。
|
||||
· **建议(值得追问,但不拦单)**:\`customer_pn\` 客户料号 · \`category\` 品类 ·
|
||||
\`brand\` 制造商 · \`target_price\` 目标价 —— 声明里标了 \`expect\`,件上标橘「建议」。
|
||||
缺了照样往下走,但**那一问不能省**:执行卡上要写清「这几格建议追问」。
|
||||
· **选填(其余一切)**:交期、品质要求、备注…… 件上标灰「选填」,缺了只当提醒。
|
||||
建议档与选填档一并记进 missing_minor_total。
|
||||
|
||||
**别把「建议」说成「选填」**(2026-08-03 用户当场指出):同一件事,工单上标着「建议追问」,
|
||||
交代里若换成另一档的叫法,人会以为这两处说的不是一回事 —— 而它们就是一回事。
|
||||
|
||||
**目标价客户没给就留空,不许估**:它是**客户的意向价**,不是我们算得出来的数。
|
||||
S4 查到的历史价是**判 A/B/C 的依据**(距上次报价几天、行情稳不稳),
|
||||
**不是客户的目标价** —— 拿它填进 \`target_price\` 就是替客户表了态,
|
||||
下游定价、议价全建在这个假数上。也不许只在**交代里声称**估过价、垫过数
|
||||
(实测栽过一次:台账留空是对的,嘴上却说按历史价估了 —— 而那一步连历史价都还没查,
|
||||
纯属编造,人读了就以为表里躺着一个估出来的数)。留空、在件上标橘「建议」、
|
||||
在执行卡上写明建议追问,这三件事合起来才是对的处置。
|
||||
**目标价常缺是常态,不是缺陷** —— 实测栽过一次:一颗料只缺目标价却被记成必填缺、
|
||||
还派了执行卡去问客户,而按 F-01 它根本不该拦单。
|
||||
拿不准某个字段算哪一档,就问自己一句:**它是不是 PN 或年用量?** 不是就不许拦单。
|
||||
|
||||
【怎么拆】逐条写进 \`quote_intake.record\`,一条需求一行(同一 PN 不同数量档算两行)。
|
||||
列名照台账字典写,别自己起名:
|
||||
· \`pn\` 我方料号 · \`customer_pn\` 客户自己的编码(BOM 里那一列,回价单要按它对齐)
|
||||
· \`category\` 品类 · \`brand\` 制造商 · \`eau\` 年用量 · \`target_price\` 目标价
|
||||
· \`quality_req\` 认证/品质要求 · \`remark\` 备注 · \`customer_code\` 上一步认出的那个
|
||||
四条纪律:
|
||||
① **原文没写的不要补** —— 缺就是缺,记进缺口数,别替客户填一个「看起来合理」的值。
|
||||
数值列(年用量 / 目标价)没有就给**空字符串 \`""\`**,基座认得出「这一列没有值」,
|
||||
报告上会标成红的「必填」等人补。**绝不写 0** —— 0 是一个真实的数量,
|
||||
写下去就等于告诉下游「客户要 0 个」;实测栽过:DAG 那条 BOM 里年用量本来是空的,
|
||||
被写成 eau=0,而同一拍的缺口数还记着 1 —— 一份自己跟自己打架的记录。
|
||||
② **PN 一个字符都不许改**:后缀、横杠、空格全照抄
|
||||
(实测:\`DAG2400L321504XZB01-R0\` 被写成 \`DAG2400L321504XZB01\`,尾巴上的 -R0 丢了 ——
|
||||
往后查历史价查不到、开出去的询价请求对不上料,而错处早已在这一步埋下)。
|
||||
③ **缺哪一格算哪一格,别整行判缺**:同一行里有值的列照抄
|
||||
(实测:那条缺年用量的料,连 BOM 里明明写着的制造商「浙江嘉康」与品类都一并没写 ——
|
||||
人拿到卡以为客户什么都没给,白问一轮他早就给过的东西);
|
||||
④ 单位归一但不换算口径(12K→12000 可以;月用量×12 是业务判断,该问客户);
|
||||
③ **外文品类照译不照抄**:BOM 常是客户本国语言(Варистор = 压敏电阻,
|
||||
Антенна = 天线,Дроссель = 电感,Фильтр = 滤波器,Микросхема = 集成电路),
|
||||
译的是**品类那一列的词**,不是按料号猜这颗料是什么
|
||||
(实测:一颗天线被写成「集成电路」—— BOM 上白纸黑字写着 Антенна);
|
||||
译不准的原文照留,别硬编一个。
|
||||
④ 正文里的口径逐条落到对应行:点名要证书的写进 \`quality_req\`;
|
||||
客户**已预授权的替代件**写进 \`remark\`(那是后面选型的依据,丢了就得回头问客户)。
|
||||
|
||||
【同一拍记账】把 \`quote_intake.verdict\` 的这几个数写上:
|
||||
· bom_total = 来件一共几条(附件行数,不含表头);
|
||||
· skipped_total = 客户点名本轮不报的条数;skipped_note = 是哪几条、为什么(一句话);
|
||||
· line_total = 落了几条(= bom_total − skipped_total,对不上就是拆漏了);
|
||||
· **missing_key_total** = 缺 PN 或 年用量 的条数(必填缺);
|
||||
· **missing_minor_total** = **除 PN / 年用量之外任何字段缺了**的条数(选填缺)——
|
||||
客户料号、品类、制造商、目标价、交期、品质要求、备注,缺哪一个都算,一条只数一次。
|
||||
**别只数品类和制造商**:实测栽过一次,NE555P 只缺目标价,两个缺口数报的都是 0,
|
||||
于是那一问被整个吞掉 —— 人在成果表里看见一格空白,却没人问过他一句。
|
||||
空不空由他决定要不要去补,不由你替他吞掉。
|
||||
|
||||
【最要紧的一条:**数就是数,不许为了过判据去改它**】
|
||||
两个缺口数如实写。缺一条就是 1,别因为「写 0 才好往下走」就写 0 ——
|
||||
实测栽过一次:一颗料只缺目标价,如实记了 1,提交被判据拦住,
|
||||
于是那个数被改回了 0、单子放行了,而台账上从此写着「缺选填 0 条」,
|
||||
那一格空白再没人提起。**判据拦住你,说明该去办那件事,不是该去改那个数**;
|
||||
真办不了(客户就是不给),把它留在数里、在 say 里说清楚,放行时由人定。
|
||||
|
||||
**这一步不发卡**:拆完就 finish,交给下一步(受理需求交人审定)去请人。`,
|
||||
},
|
||||
{
|
||||
item: 'S3_confirm',
|
||||
intent: '把整份受理需求交人审定:可就地改,确认后才往下走',
|
||||
writes: ['quote_intake.verdict'],
|
||||
reads: [],
|
||||
/* 阶段2 的执行目标件:询价受理需求(照 JCAI_PRD 的 A1-询价受理需求.html) */
|
||||
view: 'intake-overview',
|
||||
tools: [],
|
||||
/*
|
||||
* **不给 doneWhen** —— 这一步触达世界(把件交到人手上),给了就等于给引擎
|
||||
* 一把替人签字的钥匙。而它的成果按定义也判不出来:「人看过、改过、认了」
|
||||
* 不是台账里任何一个数能自己变出来的。人工激活也一并给上:
|
||||
* 引擎不会替你按下这一步,也不会替你收 —— 发卡就算启动,收要你自己 finish。
|
||||
*/
|
||||
guide: `拆好的九条摆在那儿了,**这一单现在要经过人的手**。
|
||||
|
||||
【一单必发一张,不管缺不缺】
|
||||
这不是「缺东西时的追问」,是**阶段2 那张执行页**:AI 拟了稿,人审定。
|
||||
字段齐全也要发 —— 拆错一格(PN 少个后缀、品类译反、年用量看错列)只有人看得出来,
|
||||
而那些错往后每一步都跟着跑。**别因为「没有缺口」就跳过这一步**:
|
||||
实测栽过一次(2026-08-01),那一版只在缺东西时才发卡,于是一封齐整的询价进来,
|
||||
人一眼没看过受理需求,下一次被叫到已经是终局验收 —— 用户当场说
|
||||
「我都没确认需求 它就让我验收了」。
|
||||
|
||||
【怎么发】**卡的骨架在说明书里(「受理需求审定卡的骨架」那一段),逐字段照抄** ——
|
||||
不管你在哪一拍发它,骨架都在说明书里等着,不要凭记忆拼一张裸卡。
|
||||
这一步人工激活:发卡就算启动,不必单发 start;引擎也不会替你收 —— 人交回后要你自己 finish。
|
||||
发完卡把 **asked_total 加 1**(记账用:它证明这一问没省)。
|
||||
|
||||
【人提交之后 —— **件上的改动基座已经替你写回了**】
|
||||
人在件上按「保存」那一刻,改动就落进了草稿层(与数据录入卡同一条规矩:
|
||||
该由人亲手落下的,不经你的手);他按「提交」只是把这张卡交回来。
|
||||
所以**别再 write 一遍那几行** —— 值没变的写入会被「写了等于没写」拒回(实测)。
|
||||
|
||||
你这一拍只做三件事,然后 finish:
|
||||
① **把 missing_key_total / missing_minor_total 重算** —— 照台账里现在的值,
|
||||
补上了就该变小(基座写的是行,汇总列是你的活);
|
||||
② **confirmed_total 记 1** —— 这一单经过人了。**不写它这一步收不了,整单卡在这儿**;
|
||||
③ answered_total 加 1(答案已经处理过了)。
|
||||
漏掉①的下场实测过:值明明补上了,汇总列还写着「缺 1 条」,提交被判据一次次拦回来,
|
||||
连拒五拍熔断 —— 而你只要把那个数改对就过去了。
|
||||
真要补写某一行时**按台账里那一行的 PN 对行**:PN 是主键,
|
||||
换一个写法就是**新开一行**(实测:补写时把 PN 补全成 \`…-R0\`,于是同一颗料在表里成了两行,
|
||||
条目数说 9 而表里躺着 10)。真发现第一次的 PN 抄错了,在 say 里说明并交给人,
|
||||
别自己用新 PN 再写一行。
|
||||
|
||||
【**这张卡一单只发一次**】人交回来之后,不论还缺什么,**都不许再发同一张卡**。
|
||||
必填(PN / 年用量)有提交闸兜着:缺一条他根本按不下提交,所以卡交回来时必填一定是齐的;
|
||||
建议档的缺口不拦单,留着交放行的人定。
|
||||
实测栽过多次(2026-08-02 那一单连撞四拍):从前这儿写着「人交回来还缺必填,就再发一张卡」——
|
||||
而提交闸上线后那条路已经不存在了,模型照它办,每次都撞在
|
||||
「这件事刚问过,人也答过了」上,白烧四拍。
|
||||
|
||||
【人把格子留空交回来 = **客户给不了**,那也是一个答案】
|
||||
这时候要做的**不是再问一遍**(基座会当场拒:同一件事不许问第二遍),而是:
|
||||
· confirmed_total 记 1、answered_total 记 1(人已经处理过了);
|
||||
· 缺口数**照实留着**(缺就是缺,别改小);
|
||||
· 这一步就算办完了 —— 建议档的缺口不拦单,放行时由人定「先受理还是退回」。
|
||||
实测栽过一次:人留空交回,模型又发一张一模一样的卡,被拒;
|
||||
转头把同一行原样再写一遍,又被「写了等于没写」拒;连拒五拍熔断,一单就这么停了。`,
|
||||
},
|
||||
{
|
||||
item: 'S4_history',
|
||||
view: 'intake-classified',
|
||||
intent: '调历史价工具,把 P-03 的三项判据取齐并落进料号分类表',
|
||||
writes: ['quote_intake.type_table'],
|
||||
reads: [],
|
||||
tools: ['crm.history-price-read'],
|
||||
/*
|
||||
* 判据落表了才算取齐。**不能写 `history_days != null`** ——
|
||||
* S-FEEL 里未绑定的名字 `!= null` 求值为 true,那一步开局就被收掉(踩过,测里拦下的)。
|
||||
* 取正向判断:没写过就没有这个键,`>= 0` 自然为假。
|
||||
*/
|
||||
doneWhen: 'history_days >= 0',
|
||||
guide: `P-03 只认三个入参:距上次报价天数、行情是否稳定、生命周期状态。
|
||||
三者齐了结论是**算出来**的,所以这一步唯一该做的事是把判据取齐:
|
||||
调 \`crm.history-price-read\`,**一次把这一单所有 PN 传进去**(参数 pns 收数组,
|
||||
别一条一条调 —— 每调一次就多烧一拍)。取不到不是错误:新料号无历史会回
|
||||
historyDays=9999、hit=false,那本身就是判据,命中 B 类「重估后报」是对的。
|
||||
拿回来的三项判据**原样逐条**写进 \`quote_intake.type_table\`。
|
||||
|
||||
**这一步一个数都不许自己填**(实测踩过,三条同时犯):
|
||||
① 工具回什么就写什么 —— historyDays / marketStable / lifecycle 三个值**逐字照抄**。
|
||||
你不知道某颗料的行情,工具知道;你估一个「15 天 · 在产」,判定表就照着你的估算出 A,
|
||||
而那颗料实际是客户点名要替代的 C。**估出来的判据比没有判据更坏**:它看起来很像真的。
|
||||
② **PN 必须原样**,一个字符都不许截、不许改写(实测:\`DAG2400L321504XZB01-R0\`
|
||||
被写成 \`DAG2400L321504XZB01\`,尾巴上的 -R0 丢了 —— 工具当场查不到,
|
||||
于是你又给它编了一组判据)。传进去的 PN 与写回台账的 PN 必须与受理记录逐字一致。
|
||||
③ 工具回 hit=false(这颗料没有历史)时,**照它回的 9999 / false / 未知 写**,
|
||||
那三个值本身就是判据,命中 B 类「重估后报」是对的结论 —— 不是缺数据,不必替它补。`,
|
||||
},
|
||||
{
|
||||
item: 'S5_classify',
|
||||
view: 'intake-classified',
|
||||
intent: '调分类器按 P-03 给每条打 A/B/C,标落两张表,条数照出参抄',
|
||||
writes: ['quote_intake.type_table', 'quote_intake.record', 'quote_intake.verdict'],
|
||||
reads: [],
|
||||
tools: ['quote-intake.classify'],
|
||||
/*
|
||||
* **收步判据与验收判据同源**(2026-08-01 改)。
|
||||
*
|
||||
* 从前只看 `untyped_total = 0` —— 那是活动自己写的数。它归零了这一步就收,
|
||||
* 而验收那条比的是 `type_table_rows = record_rows`(基座数出来的行):
|
||||
* 数归零了、表没写全,于是**收了步却过不了验收**,模型得回头补写一张已经收掉的表。
|
||||
* 收步比验收松,松出来的那一截就是返工。这里补上基座那一边:两处比同一件事。
|
||||
*/
|
||||
doneWhen: 'untyped_total = 0 and line_total > 0 and quote_intake_type_table_rows = quote_intake_record_rows',
|
||||
guide: `调 \`quote-intake.classify\`(纯工具,P-03 的求值器,同输入必同结论、零 token),
|
||||
把 type_table 那几行判据一次传进去,拿回每条的 A/B/C 与打法。
|
||||
|
||||
【标要落**两张表**】
|
||||
· \`quote_intake.type_table\`:type_tag + basis —— 这是「凭什么这么判」的底稿;
|
||||
· \`quote_intake.record\`:**同一个 type_tag 也要写回受理记录**(按 PN 对行)——
|
||||
受理记录才是流去下游的那份交付物,A4 就靠这一列决定哪几条能直接取历史锚点。
|
||||
漏了这一步,报告上的状况标那一列整列是空的,而下游拿到一份没打标的清单(实测)。
|
||||
|
||||
【条数**照出参抄,不许自己数**】classify 的出参里带 \`counts\`(A/B/C 各几条)与 \`total\`,
|
||||
照它写 verdict 的 type_a_total / type_b_total / type_c_total,并把 untyped_total 归零。
|
||||
实测数错过一次:判定器给的是 5A/3B/1C,报上去的是 7A/2B/1C,派单也跟着按错的数开 ——
|
||||
而验收那条等式两边都是自报的,于是「2 = 2」顺利过关,真实的第三条 B 类料一张单也没开。
|
||||
|
||||
**不要绕过决策表自己判** —— 规则表可回放、可追责,临场判断不是。
|
||||
\`type_tag\` 一律取出参里的那个字母,**不许自己写一个**:实测有过一次,
|
||||
它自己给出的判据是「200 天 · 行情不稳」(按 P-03 该判 B),写回台账的标却是 A ——
|
||||
判据与结论对不上,而验收只看结论,这种错一路走到报价单上都没人拦。
|
||||
拿不准的一律降 B,不许臆断 A:错标 A 会让一条本该重新核价的料带着旧价走到报价单上。`,
|
||||
},
|
||||
{
|
||||
item: 'S6_route',
|
||||
/* 阶段3 的验收目标件:询价受理记录(照 A1-询价受理记录.html) */
|
||||
view: 'intake-release',
|
||||
intent: '按三类结果立两枚去向旗',
|
||||
writes: ['quote_intake.verdict'],
|
||||
reads: [],
|
||||
tools: [],
|
||||
/*
|
||||
* **这一步不再开单**(2026-08-02 用户当场定):
|
||||
* 「替代评估请求 / 询价请求这两项数据不在这里产出;随令牌流向下一个节点的
|
||||
* 只有工作成果验收内的内容;进入选型替代后,活动会自己归一需要的数据。」
|
||||
*
|
||||
* 从前 A1 逐条开 `alt_request` / `rfq_request` 两张单交给 A2 / A3 ——
|
||||
* 那是替下游把它的输入先摆好,而下游本来就要照自己的口径归一一遍:
|
||||
* 同一份事实在两个活动里各存一份,改一处就分叉。A1 交出去的是**受理记录**,
|
||||
* 下游读它即可。
|
||||
*
|
||||
* 收步判据随之改成「两枚旗都立了」。旗是布尔,合法值含 false,
|
||||
* 拿它判「写没写过」分不清「没写」与「写了 false」—— 所以仍取同步写下的**计数**
|
||||
* (`route_decided_total`:定完去向记 1),并挂 `untyped_total = 0` 当前置:
|
||||
* 两个数都得是模型显式写过的,否则「未绑定的名字相等」会让这一步开局即收
|
||||
* (包内自测那条「doneWhen 开局就为真」拦过这个)。
|
||||
*/
|
||||
doneWhen: 'untyped_total = 0 and route_decided_total > 0',
|
||||
guide: `状况标是判断的**输入**,不等于去向。照三类的结果立两枚旗:
|
||||
· 有 C 类(EOL / 缺货 / 客户点名替代)→ need_alternative = true,这一单要走替代选型;
|
||||
· 有 B 类(无近期历史价或行情波动)→ need_sourcing = true,这一单要走寻源询价;
|
||||
· 两者可同时成立 —— 选型定案后仍要为替代料寻价。
|
||||
写完把 **route_decided_total 记 1**(记账用:它证明去向定过了,引擎按它收步)。
|
||||
|
||||
**不要开什么请求单** —— 替代评估请求、询价请求都**不在这一步产出**:
|
||||
随令牌流去下一个节点的只有**工作成果验收里的那份受理记录**,
|
||||
A2 / A3 拿到之后会按自己的口径归一需要的数据。你替它们先摆一份,
|
||||
等于同一份事实在两个活动里各存一份,改一处就分叉。
|
||||
|
||||
**这两枚旗是下游两道网关唯一读的东西**:不写,整条流出了受理口就没路可走。
|
||||
|
||||
**去向不用你选,也不用人选** —— 契约给三个出口各写了判据(有没有 C 类、有没有 B 类),
|
||||
放行那一刻基座按序求值自己定,卡上只如实告诉人走哪一口、凭哪一条。
|
||||
你要做的只有一件:**把三类条数与两枚旗写对**(它们正是那几条判据读的数)。
|
||||
旗写错,算出来的去向就错 —— 但那是数据错,不是选择错。`,
|
||||
},
|
||||
{
|
||||
item: 'T_release',
|
||||
view: 'intake-release',
|
||||
intent: '判定成果已齐,提交放行交 BD 验收',
|
||||
writes: [],
|
||||
reads: [],
|
||||
tools: [],
|
||||
// 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 doneWhen)
|
||||
guide: `line_total > 0、missing_key_total = 0、untyped_total = 0、两枚旗已定 ——
|
||||
可以走放行了(T_release 人工激活:动它就算启动,不必单发 start)。
|
||||
|
||||
**submit 之前,把清单里**每一条**裁量判据(L3/L4)都裁掉**。
|
||||
**一条都不能漏** —— 漏一条,submit 当场被拒,
|
||||
而拒回来的理由里就写着漏的是哪一条:照它原样抄 criterion 再裁一次即可,
|
||||
**别原样重提同一个 submit**(实测连提七次,每次都被同一条拒回来)。
|
||||
|
||||
眼下**只剩一条**,照名字逐字抄(2026-08-02 起「吃不准降 B」那条已从清单里删掉,
|
||||
别再裁它 —— 裁一个不存在的判据会被拒):
|
||||
· judge criterion="建议追问的四格(客户料号/品类/制造商/目标价)已补齐,或由你放行时确认可以先受理" ——
|
||||
**只看 \`quote_intake_record_gaps\` 这一个数**(基座现数的:还有几行缺**建议追问**的格子;
|
||||
\`..._gaps_keys\` 写着缺在哪几行的哪一格)。就两种写法,没有第三种:
|
||||
· **= 0** → satisfied=true,rationale 写「建议追问的那几格都补齐了」;
|
||||
· **> 0** → satisfied=**false**,rationale 照 \`..._gaps_keys\` 逐条写清缺在哪儿
|
||||
(例:「DAG2400L321504XZB01-R0 缺目标价」),交放行的人定「先受理还是退回」。
|
||||
|
||||
**「我发过卡问过了」不算满足** —— 发卡是过程,不是结论;而这一单本来就必发一张
|
||||
审定卡,拿它当理由的话这条判据永远是绿的,等于没有这道门
|
||||
(实测:客户没给目标价,它照样裁了满足,人签字时根本不知道还缺着)。
|
||||
裁成不满足**照样能提交**,理由连着卡一起交给签字的人 —— 那正是这条判据的用处。
|
||||
**不许为了让它绿去改哪个数** —— 那个数你也改不动,它由基座现数。
|
||||
**criterion 照判据名原样写**(整句,含括号里那几个字),写别的名字对不上,那一条仍算未达标。
|
||||
|
||||
**去向不用你裁**:三个出口各写了判据,放行那一刻基座按序求值、首个命中即出 ——
|
||||
那是算出来的,不是判出来的。从前这里还有一条「去向判断合乎业务常理」,
|
||||
那是让你给一个自己没做的决定盖章:裁「满足」是废话,裁「不满足」也改不动什么。
|
||||
C 类开没开替代评估请求、B 类开没开询价请求,由两条机检判据当场对账。
|
||||
|
||||
这一条是机器判不了的(L4 独立评审:缺口真还在时,「先受理还是退回」只有人能定),
|
||||
只能由你当场裁并留下理由 —— **不裁就提交会被当场拒**:还有裁量判据没裁过时,
|
||||
提交等于把没办完的活推给人。裁完再 submit,BD 验收。
|
||||
(缺口为 0 时基座当它已达标、不必你裁;裁了也不碍事。)
|
||||
终局唯放行:你不能自己宣布完成。
|
||||
验收报告上让 BD 当场改的那两个值(两枚去向旗)由**契约声明**(revisableAtRelease),
|
||||
基座放行前从台账现读拟值 —— 你不用在 submit 上重述一遍,写好台账就行。`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 阶段 —— **进度条的刻度**,只给人看,引擎一眼都不看。
|
||||
*
|
||||
* 六个计划项是引擎的粒度(拆到能判「干完没有」);阶段是人的粒度。
|
||||
* 「查历史价 / 逐条打标 / 定去向」在业务上是**一件事** —— 判这单该怎么走,
|
||||
* 分成三项是因为引擎要一项一项收,而人只想知道「到判去向这一步了」。
|
||||
*
|
||||
* 只列**激活态**的三段。放行不在这儿:每个活动都以放行收口(铁律③),
|
||||
* 那是系统的规矩不是 A1 的阶段;进度条自己在末尾加那一格,所以人看到的是 x/4。
|
||||
*/
|
||||
/*
|
||||
* 给人看的刻度 —— **每个计划项都得有格可待**(2026-08-02 用户:「卡片的进度条走的不对」)。
|
||||
*
|
||||
* 从前这份名单漏了 `S3_confirm`(它是 2026-08-01 才补回成一项的):
|
||||
* 计划项有六个,名单里只有五个。于是 S2 一收,前两格全过,进度条当场跳到「判定去向」——
|
||||
* 而人这会儿正对着受理需求审定那张卡。**他手上办的那件事在条上没有格子**,
|
||||
* 条却已经走过去了:那不是走快了一格,是它在说一件没发生的事。
|
||||
*
|
||||
* `T_release` 不在名单里是**对的**:每个活动都以放行收口,那一格由基座统一加
|
||||
* (`CLOSING_PHASE`)—— 活动自己给它起名反而会各叫各的。
|
||||
* 包内自测钉死这条:除放行外每一项恰好属于一个阶段。
|
||||
*/
|
||||
export const INTAKE_PHASES = [
|
||||
{ name: '定位客户', items: ['S1_customer'] },
|
||||
{ name: '归一受理', items: ['S2_normalize'] },
|
||||
{ name: '交你审定', items: ['S3_confirm'] },
|
||||
{ name: '判定去向', items: ['S4_history', 'S5_classify', 'S6_route'] },
|
||||
];
|
||||
|
||||
@@ -1,63 +1,17 @@
|
||||
/**
|
||||
* 统一报价 A1 · RFQ 需求分析 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
|
||||
* 统一报价 A1 · 询价受理 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
|
||||
*
|
||||
* 这是 **flow** 活动:下一步由步序声明给定,引擎照谱推进;
|
||||
* 本文件承担声明装不下的那部分 —— 视图流控与纯工具(打标)。
|
||||
* **零环境能力**:一切能力经基座六件注入。
|
||||
* **这个活动没有 `run`,不是缺件。**
|
||||
*
|
||||
* 2026-07-29 范式退役后,活动一律案例活动:推进由 Loop 驱动(定位 → 择向 → 决断 →
|
||||
* 行动 → 写数据),行动全部落在基座六件上 —— 调工具、调人、立监听、写台账草稿。
|
||||
* 活动要交代的东西因此**全在清单里**:案例模型(CMMN 子集)+ 案例说明书 + 两张判定表 +
|
||||
* 三条技能 + 台账声明 + 四张 HTML 报告模版。「清单机器可读,系统不执行代码即知包为何物」。
|
||||
*
|
||||
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 —— 它是**被静态审扫描的产物文本**
|
||||
* (查 import 与全局引用,证明活动包零环境能力)。将来有确定性子序列要结晶成代码,
|
||||
* 落点也在这里。现在没有就不写:摆一个空转的 `run` 去假装有实现,更坏。
|
||||
*/
|
||||
import { defineActivity } from '@sdk';
|
||||
import type { ActivityBase, ActivityOutcome } from '@sdk';
|
||||
|
||||
const INTAKE = 'quote_intake.record';
|
||||
const TYPE_TABLE = 'quote_intake.type_table';
|
||||
|
||||
export default defineActivity({
|
||||
name: 'quote-intake',
|
||||
paradigm: 'flow',
|
||||
|
||||
async run(base: ActivityBase): Promise<ActivityOutcome> {
|
||||
const records = await base.ledger.read(INTAKE, { customer_code: String(base.contract.data['customerCode'] ?? '') });
|
||||
const typed = await base.ledger.read(TYPE_TABLE);
|
||||
|
||||
// 视图流控:按局面推结构化块 —— 内容归活动,容器归系统
|
||||
await base.interaction.pushView([
|
||||
{
|
||||
t: 'kv',
|
||||
title: '受理概览',
|
||||
items: [
|
||||
{ k: '客户', v: String(base.contract.data['customerCode'] ?? '') },
|
||||
{ k: '已归一', v: `${records.length} 条` },
|
||||
{ k: '已打标', v: `${typed.length} 条` },
|
||||
],
|
||||
},
|
||||
{
|
||||
t: 'table',
|
||||
title: '统一受理记录',
|
||||
cols: ['PN', '厂牌', 'EAU', 'Type'],
|
||||
rows: records.map((r) => [
|
||||
String(r['pn'] ?? ''),
|
||||
String(r['brand'] ?? ''),
|
||||
String(r['eau'] ?? ''),
|
||||
String(r['type_tag'] ?? '未打标'),
|
||||
]),
|
||||
},
|
||||
]);
|
||||
|
||||
const untyped = records.filter((r) => r['type_tag'] === undefined || r['type_tag'] === null);
|
||||
if (untyped.length > 0) {
|
||||
await base.interaction.say(`还有 ${untyped.length} 条未打标,继续按三分类规则 P-03 判定。`, 'conclusion');
|
||||
return { kind: 'await', reason: '等待打标步完成' };
|
||||
}
|
||||
|
||||
await base.interaction.say(
|
||||
`${records.length} 条已全部打标:` +
|
||||
`A ${count(records, 'A')} 条直通定价,B ${count(records, 'B')} 条转寻源,C ${count(records, 'C')} 条转选型。`,
|
||||
'criterion',
|
||||
);
|
||||
return { kind: 'submit', deliverable: 'intake-record', rationale: '逐条已打标,必交付物齐备' };
|
||||
},
|
||||
});
|
||||
|
||||
function count(rows: ReadonlyArray<Record<string, unknown>>, tag: string): number {
|
||||
return rows.filter((r) => r['type_tag'] === tag).length;
|
||||
}
|
||||
export default defineActivity({ name: 'quote-intake' });
|
||||
|
||||
@@ -1,182 +1,737 @@
|
||||
/**
|
||||
* 活动包清单 —— 统一报价 · 客户RFQ受理(受理归一)。
|
||||
* 活动包清单 —— 统一报价 A1 · 询价受理。
|
||||
*
|
||||
* 业务素材出处:docs/MVP示例工作流.html §1.1;按 README《归一化对照表》映射为:
|
||||
* SOP 节点 → **标准活动(flow)**;作业指导书 S1…S6 → **步序声明**(四种步型);
|
||||
* 步内判断 D1 完整度 → **条件谓词**;三分类规则 P-03 → **DMN 决策表**;
|
||||
* SLA(受理<15min、打标<45min)→ Harness 时限与预算。
|
||||
* 业务素材出处:`docs/MVP示例工作流.html` §1.1。归一化映射:
|
||||
* SOP 节点 → **案例活动**(CMMN 子集 + 说明书 + DMN);
|
||||
* 作业指导书 S1…S6 → 案例说明书的六段(见 `case-model.ts` 的建模口径注);
|
||||
* D1 判完整度 → **DMN 判定表 F-01**(《询价受理规范》);
|
||||
* 三分类规则 P-03 → **DMN 判定表**;
|
||||
* SLA(受理<15min、判定<45min)→ Harness 时限与预算。
|
||||
*
|
||||
* 「清单机器可读,系统不执行代码即知包为何物」—— 这份清单就是这个活动的全部。
|
||||
*/
|
||||
import type { ActivityManifest, DmnModel } from '@contracts';
|
||||
import { INTAKE_SKILLS } from './skills.ts';
|
||||
import { CMMN_XML, NARRATIVE } from './case-model.ts';
|
||||
import { activityIdSchema } from '@contracts';
|
||||
import { INTAKE_SKILLS } from './skills.ts';
|
||||
import { CMMN_XML, INTAKE_PHASES, INTAKE_STEPS, NARRATIVE } from './case-model.ts';
|
||||
import { INTAKE_CLASSIFIED, INTAKE_OVERVIEW, INTAKE_RELEASE } from './views/report.ts';
|
||||
|
||||
const INTAKE_DOMAIN = 'quote_intake';
|
||||
const D = 'quote_intake';
|
||||
const ACTIVITY_ID = activityIdSchema.parse('quote-intake');
|
||||
|
||||
/**
|
||||
* 三分类规则 P-03 —— 把「装得下的判断」从 LLM 手里拿走。
|
||||
* 由纯工具步 `quote-intake.classify` 逐条求值,命中规则行留痕。
|
||||
* 两张判定表 —— **能算的不问 LLM**:同输入必同结论、零 token、命中行留痕。
|
||||
*
|
||||
* F-01 受理字段完整度(《询价受理规范》):三要素齐才放行,缺关键字段退回补全。
|
||||
* P-03 三分类:A 历史价≤90天且行情稳 / B 无历史·过期·波动 / C EOL·缺货·要替代。
|
||||
*
|
||||
* 两张都是 `FIRST`:规则从上往下,第一条命中即出结论 —— 顺序本身就是业务优先级,
|
||||
* 所以 C 的那一行必须在最前面(EOL 的料哪怕历史价很新,也不该判成 A 直接快答)。
|
||||
*/
|
||||
export const P03_DMN: DmnModel = {
|
||||
drd: [{ decision: 'p03-type', requires: [], inputs: ['historyDays', 'marketStable', 'lifecycle'] }],
|
||||
tables: [{
|
||||
export const F01_DMN: DmnModel['tables'][number] = {
|
||||
id: 'f01-completeness',
|
||||
label: '受理字段完整度 F-01',
|
||||
hitPolicy: 'FIRST',
|
||||
inputs: [
|
||||
{ name: 'missing_key_total', expression: 'missing_key_total' },
|
||||
{ name: 'missing_minor_total', expression: 'missing_minor_total' },
|
||||
],
|
||||
outputs: [{ name: 'completeness' }, { name: 'disposition' }],
|
||||
rules: [
|
||||
{ id: 'f01-2', when: ['> 0', '-'], then: ['缺PN/数量/交期', '退回补全'], annotation: '三要素缺任一 → 必须追问补全' },
|
||||
{ id: 'f01-3', when: ['0', '> 0'], then: ['缺次要字段', '受理并追问'], annotation: '只缺厂牌/包装/备注 → 先受理' },
|
||||
{ id: 'f01-1', when: ['0', '-'], then: ['八字段齐', '受理转寻源'], annotation: '齐 → 直接往下走' },
|
||||
],
|
||||
};
|
||||
|
||||
export const P03_DMN: DmnModel['tables'][number] = {
|
||||
id: 'p03-type',
|
||||
label: '三分类规则 P-03',
|
||||
hitPolicy: 'FIRST' as const,
|
||||
hitPolicy: 'FIRST',
|
||||
inputs: [
|
||||
{ name: 'historyDays', expression: 'historyDays' },
|
||||
{ name: 'marketStable', expression: 'marketStable' },
|
||||
{ name: 'history_days', expression: 'history_days' },
|
||||
{ name: 'market_stable', expression: 'market_stable' },
|
||||
{ name: 'lifecycle', expression: 'lifecycle' },
|
||||
],
|
||||
outputs: [{ name: 'type' }, { name: 'play' }],
|
||||
outputs: [{ name: 'type_tag' }, { name: 'play' }],
|
||||
rules: [
|
||||
{ id: 'p03-c', when: ['-', '-', '"EOL","缺货","要求替代"'], then: ['C', '转案处理'], annotation: 'EOL/缺货/要替代 → C' },
|
||||
{ id: 'p03-a', when: ['<= 90', 'true', '-'], then: ['A', '直接快答'], annotation: '历史价≤90天且行情稳 → A' },
|
||||
{ id: 'p03-b', when: ['-', '-', '-'], then: ['B', '重估后报'], annotation: '无历史/过期/波动 → B' },
|
||||
],
|
||||
}],
|
||||
};
|
||||
|
||||
export const INTAKE_DMN: DmnModel = {
|
||||
drd: [
|
||||
{ decision: 'f01-completeness', requires: [], inputs: ['missing_key_total', 'missing_minor_total'] },
|
||||
{ decision: 'p03-type', requires: [], inputs: ['history_days', 'market_stable', 'lifecycle'] },
|
||||
],
|
||||
tables: [F01_DMN, P03_DMN],
|
||||
};
|
||||
|
||||
export const manifest: ActivityManifest = {
|
||||
identity: {
|
||||
activityId: ACTIVITY_ID,
|
||||
name: '客户RFQ受理',
|
||||
packageVersion: '0.1.9',
|
||||
name: 'A1 · 询价受理',
|
||||
packageVersion: '49.5.0',
|
||||
category: '统一报价',
|
||||
sdkVersion: '0.1.0-m5',
|
||||
checksum: 'dev-checksum-quote-intake',
|
||||
},
|
||||
|
||||
/* 契约七件 */
|
||||
/* ── 契约七件 ────────────────────────────────────────────────── */
|
||||
contract: {
|
||||
activityId: ACTIVITY_ID,
|
||||
version: '0.1.0',
|
||||
version: '1.0.0',
|
||||
input: {
|
||||
/*
|
||||
* 入参就是**那封信** —— 客户邮箱 / 主题 / 邮件内容 / 邮件附件,前三样必填。
|
||||
*
|
||||
* 从前这里收的是「客户编码 + 询价单号」,那是**受理之后**才有的东西:
|
||||
* 一封真的询价来函不会带着我们内部的客户码,单号也常常压根没有。
|
||||
* 把它们摆成必填的后果实测过:流程包起单时通道抽不出这两个键,基座当场发补全卡,
|
||||
* 整单 0 拍停在那儿等人 —— 而信本身是完整的。认客户、认单号是 S1 的活,不是门口的闸。
|
||||
*
|
||||
* 起单仍是宽进:门口不拒缺项(缺 required 由基座发补全卡,不是活动的事)。
|
||||
*/
|
||||
data: {
|
||||
type: 'object',
|
||||
required: ['customerCode', 'rfqRef'],
|
||||
required: ['customerEmail', 'subject', 'body'],
|
||||
properties: {
|
||||
customerCode: { type: 'string' },
|
||||
rfqRef: { type: 'string' },
|
||||
subject: { type: 'string' },
|
||||
/** 询价原文 —— 归一步拆解的对象 */
|
||||
body: { type: 'string' },
|
||||
customerEmail: { type: 'string', title: '客户邮箱' },
|
||||
subject: { type: 'string', title: '主题' },
|
||||
/** 询价原文 + BOM 转述 —— 归一这一步拆解的对象 */
|
||||
body: { type: 'string', title: '邮件内容' },
|
||||
/*
|
||||
* **真文件**(`format: 'file'`):界面给文件选择器,基座在入口把它读成文本。
|
||||
* 每一件是 `{ name, contentType, size, ref, text?, note }` ——
|
||||
* `text` 有值就是读得出内容(xlsx 读成制表符分列的行);
|
||||
* 读不出的类型 `text` 缺席、`note` 说明为什么。**说明书里写死了怎么用**。
|
||||
*/
|
||||
attachments: {
|
||||
type: 'array',
|
||||
format: 'file',
|
||||
title: '邮件附件',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', title: '文件名' },
|
||||
contentType: { type: 'string', title: '类型' },
|
||||
size: { type: 'integer', title: '字节数' },
|
||||
ref: { type: 'string', title: '存档号' },
|
||||
text: { type: 'string', title: '读出来的文本' },
|
||||
note: { type: 'string', title: '读取说明' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
messageAllowed: true,
|
||||
messageHint: '客户口头急单等特殊性写这里',
|
||||
messageHint: '客户口头急单、老客户先受理后补单等特殊性写这里',
|
||||
/*
|
||||
* **这一单是被一封信叫起来的** —— 声明入参里哪几个字段合起来是那封来件,
|
||||
* 基座在起单那一刻就把它归进任务的沟通记录(工作台那一屏),原件与附件一并存下。
|
||||
*
|
||||
* 为什么是声明而不是跑起来再认一遍:「入参的 subject 就是来件主题」是写清单时
|
||||
* 就知道的事实,没有临场判断的成分 —— 交给模型每单认一遍,是拿一整拍的 token 换一个常量。
|
||||
* 而且人一点开这单,沟通记录里就已经躺着那封信,不必等模型跑几拍。
|
||||
*/
|
||||
inboundComm: {
|
||||
channel: 'mail',
|
||||
partyField: 'customerEmail',
|
||||
subjectField: 'subject',
|
||||
bodyField: 'body',
|
||||
attachmentsField: 'attachments',
|
||||
},
|
||||
},
|
||||
output: {
|
||||
/*
|
||||
* 出口载荷**从落地事实推导**(§7.5):标量字段必须有台账列承载。
|
||||
* 两枚去向旗就是下游两道网关唯一读的东西 —— 没有 `need_alternative` /
|
||||
* `need_sourcing` 这两列,整条流出了受理口就没路可走(踩过:G1 全流死路)。
|
||||
*/
|
||||
mandatory: [
|
||||
{
|
||||
name: 'intake',
|
||||
payload: {
|
||||
type: 'object',
|
||||
required: ['records'],
|
||||
properties: { records: { type: 'array' } },
|
||||
required: ['records', 'needAlternative', 'needSourcing'],
|
||||
properties: {
|
||||
records: { type: 'array', title: '询价受理记录(已打标)' },
|
||||
needAlternative: { type: 'boolean', title: '需要选型' },
|
||||
needSourcing: { type: 'boolean', title: '需要寻源' },
|
||||
},
|
||||
description: '统一受理记录(已打标)',
|
||||
},
|
||||
description: '询价受理记录(打标)+ 两枚去向旗',
|
||||
},
|
||||
],
|
||||
selective: [],
|
||||
/*
|
||||
* **三条出边 = 一个 XOR 选择组**(2026-07-31 用户定,对齐流程包 E2/E3/E4)。
|
||||
*
|
||||
* 受理这件事办完,这一单只会往三个地方去其中一个:
|
||||
* · 只有 A 类(有历史价、行情稳)→ 直通定价;
|
||||
* · 有 C 类要替代 → 去选型(选型定案后仍可能回头寻价);
|
||||
* · 无选型但有 B 类 → 去寻源。
|
||||
*
|
||||
* 从前这里是空的:活动契约只说得出「我发一个 intake 口」,三条边的知识
|
||||
* 全长在流程包的边条件上 —— 光看活动清单不知道它有三个出口,
|
||||
* 换一条流来编排就得重新猜。现在**契约自己说得出**。
|
||||
*
|
||||
* 两枚旗照旧留在 `intake` 载荷里:下游两道网关读的是它们,
|
||||
* 撤掉等于把 G1 全流死路那个坑再踩一遍(踩过)。选口与旗必须自洽 ——
|
||||
* 说明书里写死了对应关系,放行确认时选口随之落定。
|
||||
*/
|
||||
selective: [
|
||||
{
|
||||
group: 'intake-route',
|
||||
/*
|
||||
* **三条按序求值,首个命中即出** —— 与报告上那张 G2 规则表逐条对应。
|
||||
* 每一口都写了 `when`,所以这一单往哪走是**算出来的**:
|
||||
* 放行时基座自己判,卡上只如实告诉人走哪一口、凭哪一条,不摆成选择题。
|
||||
* 去向是分类表那几个数的函数;让人凭印象点一下,
|
||||
* 等于把一个可回放的判定换成一次不可回放的手感。
|
||||
*/
|
||||
ports: [
|
||||
/*
|
||||
* ── 这里**去掉了 `altRequests` / `rfqRequests` 两个载荷字段**(@43,2026-08-02)──
|
||||
*
|
||||
* `alt_request` / `rfq_request` 两张台账表同日已退休(见 declarations 那段墓志铭),
|
||||
* 这两个键从那天起就是**空壳**:载荷装配对数组属性一律塞「本单全部落地行」
|
||||
* (不加区分),于是它们的内容与 `records` 完全相同 —— 挂着一个名字叫
|
||||
* 「替代评估请求」而里面装的是受理记录的键,比没有更糟:下游照名字用,用到的
|
||||
* 是另一样东西。下游(A2/A3)自己按 `records` 里的状况标归一,契约照实说。
|
||||
*/
|
||||
{
|
||||
name: 'to-alternative',
|
||||
payload: {
|
||||
type: 'object',
|
||||
required: ['rfqRef', 'records'],
|
||||
properties: {
|
||||
rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' },
|
||||
records: { type: 'array', title: '询价受理记录(已打标)' },
|
||||
},
|
||||
},
|
||||
description: '有 C 类要替代 —— 去选型',
|
||||
/* ① 有 C 类就先去选型(替代定案后仍可能回头寻价) */
|
||||
when: 'quote_intake_record_type_tag_c > 0',
|
||||
},
|
||||
{
|
||||
name: 'to-sourcing',
|
||||
payload: {
|
||||
type: 'object',
|
||||
required: ['rfqRef', 'records'],
|
||||
properties: {
|
||||
rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' },
|
||||
records: { type: 'array', title: '询价受理记录(已打标)' },
|
||||
},
|
||||
},
|
||||
description: '无选型但有 B 类 —— 去寻源',
|
||||
/* ② 无 C 类、有 B 类 → 寻源 */
|
||||
when: 'quote_intake_record_type_tag_c = 0 and quote_intake_record_type_tag_b > 0',
|
||||
},
|
||||
{
|
||||
name: 'to-pricing',
|
||||
payload: {
|
||||
type: 'object',
|
||||
required: ['rfqRef', 'records'],
|
||||
properties: {
|
||||
rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' },
|
||||
records: { type: 'array', title: '询价受理记录(全 A 类)' },
|
||||
},
|
||||
},
|
||||
description: '只有 A 类:有近期历史价且行情稳 —— 直通定价',
|
||||
/* ③ 前两条都不命中,才是「全 A 直通定价」 */
|
||||
when: 'quote_intake_record_type_tag_c = 0 and quote_intake_record_type_tag_b = 0',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
/*
|
||||
* 必交付物**带可机检依据**:`ledger` 是台账地址,预检自己数行数。
|
||||
* 不给地址,「缺一不得提交放行」就只能靠活动自报 —— 自报等于没有门。
|
||||
*/
|
||||
requiredDeliverables: [
|
||||
{ key: 'intake-record', label: '统一受理记录(打标)' },
|
||||
{ key: 'type-table', label: '料号分类表' },
|
||||
{
|
||||
key: 'intake-record',
|
||||
label: '询价受理记录',
|
||||
ledger: `${D}.record`,
|
||||
/*
|
||||
* 验收页上让 BD 就地改的那几列 —— **数据是他该管的**。
|
||||
*
|
||||
* **`type_tag` 在其列**(2026-08-01 用户实测逼出来的):三分类是 P-03 算的,
|
||||
* 但算得对不对该由人说了算 —— 他改一颗料的类,就是在推翻一次判定,那是他的职权。
|
||||
* 从前它不在这份名单里,于是人在件上把三条全改成 A、按了保存,
|
||||
* **改动被整个丢掉**(写回按这份名单摆格子,不在名单里的列压根不进载荷)——
|
||||
* 屏上类徽照旧、去向照旧写着「去选型」。他以为自己改了,其实什么也没发生。
|
||||
*
|
||||
* **去向仍不在其列**:那是 `type_tag` 的函数(见 output.selective 的 `when`)——
|
||||
* 改类就够了,去向随之重算;再让人单独选一次去向,等于给同一件事开两个入口。
|
||||
*/
|
||||
editableAtRelease: ['pn', 'customer_pn', 'category', 'eau', 'brand', 'target_price', 'remark', 'type_tag'],
|
||||
/** 这份成果由哪几张表承载 —— 分类表是判据来源、结论是结论栏,都是它的内部结构 */
|
||||
ledgers: [`${D}.record`, `${D}.type_table`, `${D}.verdict`],
|
||||
},
|
||||
/*
|
||||
* ── **料号分类表与受理结论不是两份成果**(2026-08-02 用户当场指出:
|
||||
* 「不都是包含在询价受理记录的结构里的吗,为啥单独陈列?」)────────────────
|
||||
*
|
||||
* 从前它们各占一条 `requiredDeliverables`,于是人在成果面上看到三样东西 ——
|
||||
* 而在业务上受理这件事**只交出一份东西**:一份能往下走的询价受理记录。
|
||||
* 分类表是它的判据来源、受理结论是它的结论栏,两者都是**同一份成果的内部结构**。
|
||||
*
|
||||
* 现在合成一条,`ledgers` 说清它由哪几张表承载:落地那一屏按交付物点名,
|
||||
* 三张表的行数并进「询价受理记录」一行,不再各报各的。
|
||||
*/
|
||||
],
|
||||
/*
|
||||
* 验收判据逐条标真实等级。L1/L2 必带 predicate —— 当场对任务视图 S-FEEL 求值,
|
||||
* 不带就等于把「机检」写成了摆设(安装期 acceptance-level-misreported 挡这个)。
|
||||
*
|
||||
* 谓词读的是**汇总列**(line_total / missing_key_total / untyped_total),
|
||||
* 不是逐行列:任务视图把草稿行摊平到顶层是「后写的赢」,拿逐行的
|
||||
* missing_key_fields 求值只会验到最后写的那一行 —— 那不叫机检,叫抽样。
|
||||
* 所以汇总列的名字与逐行列**故意不同名**,免得互相盖掉。
|
||||
*/
|
||||
acceptanceCriteria: [
|
||||
{ key: 'fields-complete', label: '八字段齐(PN/EAU/交期为必要三要素)', level: 'L1', predicate: 'missingKeyFields = 0' },
|
||||
{ key: 'typed', label: '每条都已打标 A/B/C', level: 'L2', predicate: 'untyped = 0' },
|
||||
{ key: 'downgrade-safe', label: '吃不准降 B,不许臆断 A', level: 'L3' },
|
||||
{ key: 'owner-release', label: 'BD 放行', level: 'L5' },
|
||||
/* ① 定位客户 —— 认得出老客户或人建了新客户,这一单才落得到某个客户名下 */
|
||||
{ key: 'customer-known', label: '客户已确认:发件邮箱认出为老客户,或已为新客户建好档', level: 'L1', predicate: 'customer_total > 0' },
|
||||
/*
|
||||
* 条目数**与真实行数对得上**。判据不读活动自报的那一个数就完了 ——
|
||||
* `quote_intake_record_rows` 是基座数出来的本单行数(草稿层),两边同时成立才算。
|
||||
* 实测拦的是这个:PN 抄漏了后缀,补写时又成了新的一行,表里 10 行而 line_total 记着 9;
|
||||
* 从前两边都由活动自己报,自然对得上,人签完字,下游拿到一份多一行的交付物。
|
||||
*/
|
||||
{
|
||||
key: 'lines-present',
|
||||
label: '来件已逐条归一:说了几条,受理记录里就躺着几行',
|
||||
level: 'L1',
|
||||
predicate: 'line_total > 0 and quote_intake_record_rows = line_total',
|
||||
},
|
||||
/*
|
||||
* ② 必填与选填**分量不同**(2026-07-31 用户定):
|
||||
* · 必填(PN / 年用量)**是验收标准** —— 不齐不许放行;
|
||||
* · 选填(品牌 / 备注)**只提醒** —— 缺了照样放行,但那一问不能省。
|
||||
* 所以选填这条判据不看「补齐没有」,看**问过没有**:
|
||||
* 客户不肯给品牌是客户的事,没去问过是我们的事。
|
||||
*/
|
||||
/*
|
||||
* **读基座数出来的数,不读活动自报的**(2026-08-01 改)。
|
||||
*
|
||||
* 从前是 `missing_key_total = 0` —— 那个数由模型自己重算。人在卡上补了年用量、
|
||||
* 基座已经写回草稿层,而模型漏算一次这条判据就永远绿不了:连跑十轮里三轮死在这,
|
||||
* 每次都是同一条连拒五拍熔断。`quote_intake_record_missing` 由基座按
|
||||
* 台账声明的 `required` 现数(缺在哪几行也一并摆进拒回),模型改不动也不必改 ——
|
||||
* 它只要把那一格补上。**一条只接受「是」而模型又左右不了的判据,是死胡同。**
|
||||
*/
|
||||
{
|
||||
key: 'required-complete',
|
||||
label: '必填齐:每一条都有型号 PN 与年用量 EAU',
|
||||
level: 'L1',
|
||||
predicate: 'quote_intake_record_missing = 0',
|
||||
},
|
||||
/*
|
||||
* ── 这一条为什么**不是机检**(2026-08-01,实测逼下来的)──────────────
|
||||
*
|
||||
* 它原本是 L2:`missing_minor_total = 0 or asked_total > 0`。两个数都由活动自己写,
|
||||
* 于是这条判据对模型来说不是「去把那一问办了」,而是**「把这个数改小就能过」**。
|
||||
* 实测就是这么走的:NE555P 只缺目标价,模型如实记了 missing_minor_total = 1,
|
||||
* 提交被这条判据连拒四次,第五拍它把那个数**改回了 0** —— 判据当场变绿、单子放行,
|
||||
* 而台账上从此写着「缺选填 0 条」,那一格空白再没人提起。
|
||||
*
|
||||
* **机检判据只许读基座数得出来的数**(如 `域_对象_rows`);读活动自报的数,
|
||||
* 它就不是门,是一句「改哪个数能过」的提示。而「问过客户没有」是过程事实,
|
||||
* 基座此刻数不出来(卡上不带计划项号,数不清哪张卡是为这件事发的)——
|
||||
* 那就**别假装机器判得了**:降到 L4 交人,并把「缺选填几条」摆到放行页的指标带上,
|
||||
* 签字的人一眼看得见。要它重回机检,得先让基座能按用途数卡(见 debt A1-3)。
|
||||
*/
|
||||
/*
|
||||
* **「问过」不是结论,是过程**(2026-08-02 用户实测:「我这次没给目标价,
|
||||
* 你 L4 咋又达标了?」)。
|
||||
*
|
||||
* 原文是「要么**问过客户**,要么放行时确认可以先受理」—— 而 A1 每一单必发一张
|
||||
* 受理需求审定卡,「问过」于是恒真:这条判据永远能裁成绿,等于没有这道门。
|
||||
* (上一版剧本还明写「发过卡问过就 satisfied=true」,是亲手把它焊死在绿上。)
|
||||
*
|
||||
* 结论只有两种:**缺口补齐了**,或者**还缺着、交给签字的人定**。
|
||||
* 后者正是这条判据的价值 —— 把「还缺这几格」连同理由摆到他面前,
|
||||
* 而不是替他宣布「问过了就算数」。裁成不满足照样能提交,门不因此拦人。
|
||||
*/
|
||||
/*
|
||||
* **前半句由基座数,后半句才交人**(2026-08-02 用户实测逼出来的)。
|
||||
*
|
||||
* 「已补齐」是 `quote_intake_record_gaps = 0` —— 基座照台账声明的 `expect`
|
||||
* 现数(草稿层实时),模型算不算得对都不影响它。从前整条只读裁断,
|
||||
* 于是它凭记忆写理由:用户在件上把目标价填好了,它照样裁「未满足」,
|
||||
* 理由写「NE555P 缺目标价,客户未提供」。用户原话:「这个价格是我填写的,
|
||||
* 但是它判错了」。复现的那一单更离谱 —— 两行年用量都填着,它写
|
||||
* 「共 4 处选填缺口」,而 EAU 压根是必填不是选填。
|
||||
*
|
||||
* 等级仍是 L4:缺口真还在的时候,「先受理还是退回」确实只有人能定。
|
||||
* 谓词为真时引擎当它已达标、把那句理由一并丢掉(见 `criterionCheck`)。
|
||||
*/
|
||||
{
|
||||
key: 'optional-asked',
|
||||
label: '建议追问的四格(客户料号/品类/制造商/目标价)已补齐,或由你放行时确认可以先受理',
|
||||
level: 'L4',
|
||||
predicate: 'quote_intake_record_gaps = 0',
|
||||
},
|
||||
/*
|
||||
* **这条判据读基座数出来的数**(2026-08-02 实测逼出来的,「机检只许读基座数得出来的数」
|
||||
* 第三次被违反)。从前读活动自写的 `untyped_total` —— 那个数它改一下就骗过去了。
|
||||
*
|
||||
* 现在比的是:**打了标的条数(基座按 `type_tag` 的取值域现数)= 受理记录行数**。
|
||||
* 两边都由基座出,模型碰不着。
|
||||
*/
|
||||
{
|
||||
key: 'typed',
|
||||
label: '每条都按三分类规则 P-03 打了标:A 直接报 / B 重估后报 / C 找替代',
|
||||
level: 'L2',
|
||||
predicate:
|
||||
'quote_intake_record_rows > 0 and quote_intake_record_type_tag_a + quote_intake_record_type_tag_b + quote_intake_record_type_tag_c = quote_intake_record_rows',
|
||||
},
|
||||
/*
|
||||
* ── 这里**去掉了四条**(2026-08-02 用户当场指出:「这些判据都删了吧」)──────
|
||||
*
|
||||
* `downgrade-safe`(L3 吃不准降 B)· `route-alt`(C 类都开了替代评估请求)·
|
||||
* `route-rfq`(B 类都开了询价请求)· `typed-on-record`(每条记录都带状况标)。
|
||||
*
|
||||
* 四条都是**内部一致性**:打标依据对不对、几类料开了几张单、两张表行数齐不齐。
|
||||
* 它们该由引擎自己看住(数对不上就是活没干完,拒回让模型改),
|
||||
* 而**放行卡是给签字的人看的**——他要判的是「这一单受理得对不对」,
|
||||
* 不是替引擎复核一遍它自己的账。一屏九条,真正要他过目的那几条反而淹了。
|
||||
*
|
||||
* 落地那一侧不因此变松:派单条数与打标覆盖仍在剧本的收步判据里
|
||||
* (S5/S6 的 `doneWhen` 读同一批基座读数),错了照旧走不到放行门。
|
||||
* 「几张单开出去了」改在**验收之后**如实呈现(落地结果),
|
||||
* 那才是人真正关心的:这一签下去,世界上发生了什么。
|
||||
*/
|
||||
/*
|
||||
* ── 这里**去掉了 `route-judgement`**(L4,2026-08-01 用户当场指出)──────
|
||||
*
|
||||
* 原文是「去向判断合乎业务常理(客户特殊要求、急单等)」。
|
||||
* **去向已经不是判断了**:三个出口各写了 `when`,放行那一刻基座按序求值、
|
||||
* 首个命中即出(`type_c_total > 0` …),人签的字确认的正是算出来的那一口。
|
||||
* 再挂一条 L4 让模型裁一次「这个去向合不合理」,是让它给一个自己没做的决定盖章:
|
||||
* 裁「满足」是废话,裁「不满足」也改不动任何东西 —— 判据读的那几个数才改得动。
|
||||
* 要改去向,改的是三类条数;那已经由 route-alt / route-rfq 两条机检看着了。
|
||||
*
|
||||
* 一条判据得能**指向一个可做的动作**,否则它只是一道要人按一下的手续。
|
||||
*/
|
||||
/*
|
||||
* L5 是**责任关**,不是质量关:它声明「这份交付物必须由人放行」,
|
||||
* 由 `T_release` 的人工激活 + 终局唯放行落实。
|
||||
* 卡面上不摆它 —— **那张卡本身就是这道关**(基座统一滤,见 `engines.ts`)。
|
||||
*/
|
||||
{ key: 'owner-release', label: 'BD 放行:签下去这一单即正式受理并转入下游', level: 'L5' },
|
||||
],
|
||||
/*
|
||||
* 放行时人**可以改什么** —— 2026-08-01 改口径:**去向不在其列**。
|
||||
*
|
||||
* 从前这里摆的是两枚去向旗,让 BD 在放行卡上当场改。那是把「这一单往哪走」
|
||||
* 交给了一次手感 —— 而它本是**分类表算得出来的事实**(有没有 C 类、有没有 B 类),
|
||||
* 现在由契约的出口谓词按序求值定下(见 output.selective 的 `when`),
|
||||
* 卡上只如实告诉人走哪一口、凭哪一条。可回放的判定不该退化成一次不可回放的点击。
|
||||
*
|
||||
* 那人在验收页上改什么?**改成果本身** —— 那份受理记录清单就绑在卡上,
|
||||
* 九行逐格可改,改完由基座写回草稿层、随放行合并(AI 不经手)。
|
||||
* 数据是他该管的;去向不是。
|
||||
*/
|
||||
revisableAtRelease: [],
|
||||
prohibitions: [
|
||||
{ key: 'no-guess-field', statement: '原文没写的字段不得凭常识补,缺就记缺' },
|
||||
{ key: 'no-guess-a', statement: '吃不准的一律降 B,不得臆断为 A' },
|
||||
{ key: 'no-unit-convert', statement: '不得自作主张换算用量口径(月用量×12 是业务判断,须问客户)' },
|
||||
{ key: 'no-external-write', statement: '不得在放行前写任何外部系统' },
|
||||
],
|
||||
authorization: {
|
||||
discretion: [{ key: 'ask-back', statement: '缺次要字段可受理并追问,缺 PN/数量/交期必须退回补全' }],
|
||||
discretion: [
|
||||
{ key: 'ask-back', statement: '缺次要字段可受理并追问;缺 PN/EAU/交期必须退回补全' },
|
||||
{ key: 'lifecycle-check', statement: '疑似 EOL 的料可随手拉一次生命周期核查' },
|
||||
],
|
||||
/* 空着不是省事:这个字段全仓没有一个消费者(债 ⑨),写了也不会被执行 */
|
||||
preauthorizedActions: [],
|
||||
humanCallBudget: 4,
|
||||
},
|
||||
resources: {
|
||||
tools: [{ name: 'crm.history-price-read' }],
|
||||
tools: [{ name: 'crm.history-price-read' }, { name: 'quote-intake.classify' }, { name: 'mail.send' }],
|
||||
skills: INTAKE_SKILLS.map((s) => s.id),
|
||||
models: ['deepseek-v4-flash'],
|
||||
roles: ['BD'],
|
||||
budget: { steps: 40, tokens: 120_000, activeMinutes: 60, humanCalls: 4 },
|
||||
/*
|
||||
* SLA:受理<15min、判定<45min → activeMinutes 60 封顶;打扰上限 4。
|
||||
*
|
||||
* token 预算按**实测**给:一份三条的 BOM 走完全程约 20 拍、18 万 token
|
||||
* (每拍的提示词都带着案例说明书 + 轨迹,越往后越长)。原来给 20 万,
|
||||
* 正好卡在收口前一两拍被熔断 —— 预算给得太紧,等于让 Harness 去杀正在干的活。
|
||||
* 条目多的单会更贵,这里留一倍余量。
|
||||
*/
|
||||
budget: { steps: 60, tokens: 400_000, activeMinutes: 60, humanCalls: 4 },
|
||||
},
|
||||
},
|
||||
|
||||
/* 声明 */
|
||||
/* ── 声明 ────────────────────────────────────────────────────── */
|
||||
declarations: {
|
||||
models: ['deepseek-v4-flash'],
|
||||
tools: [{ name: 'crm.history-price-read' }],
|
||||
tools: [{ name: 'crm.history-price-read' }, { name: 'quote-intake.classify' }, { name: 'mail.send' }],
|
||||
ledger: [
|
||||
/*
|
||||
* 客户主数据 —— **A1 只声明它要用的那几列**,全量结构由平台播种
|
||||
* (`seed-mvp-data.ts` 的四域)。声明合并是「最大化合并、只增不破坏」,
|
||||
* 两边并存不冲突;`kind: 'master'` 必须与平台一致,否则治理台会把它归错类。
|
||||
*
|
||||
* 为什么受理活动要写主数据:第一件事就是认人 —— 认得出取它的编码,
|
||||
* 认不出请人建档。建出来的那一行是**给下一单用的**:下次同一个邮箱来信,
|
||||
* 第一步就认得出,不必再问一遍。
|
||||
*/
|
||||
{
|
||||
domain: INTAKE_DOMAIN,
|
||||
object: 'record',
|
||||
label: '统一受理记录',
|
||||
domain: 'customer',
|
||||
object: 'profile',
|
||||
kind: 'master',
|
||||
label: '客户档案',
|
||||
fields: [
|
||||
{ name: 'customer_code', type: 'text', primaryKey: true, masked: false, label: '客户码' },
|
||||
{ name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' },
|
||||
{ name: 'brand', type: 'text', primaryKey: false, masked: false, label: '厂牌' },
|
||||
{ name: 'eau', type: 'integer', primaryKey: false, masked: false, label: 'EAU' },
|
||||
{ name: 'type_tag', type: 'text', primaryKey: false, masked: false, label: 'Type 标' },
|
||||
{ name: 'target_price', type: 'number', primaryKey: false, masked: false, label: '目标价' },
|
||||
{ name: 'lead_time', type: 'text', primaryKey: false, masked: false, label: '交期' },
|
||||
{ name: 'status', type: 'text', primaryKey: false, masked: false, label: '状态' },
|
||||
{ name: 'missing_key_fields', type: 'integer', primaryKey: false, masked: false, label: '缺关键字段数' },
|
||||
{ name: 'customer_code', type: 'text', primaryKey: true, masked: false, label: '客户编码' },
|
||||
{ name: 'name', type: 'text', primaryKey: false, masked: false, label: '客户名称' },
|
||||
],
|
||||
},
|
||||
{
|
||||
domain: INTAKE_DOMAIN,
|
||||
domain: 'customer',
|
||||
object: 'contact',
|
||||
kind: 'master',
|
||||
label: '客户联系人',
|
||||
fields: [
|
||||
{ name: 'customer_code', type: 'text', primaryKey: true, masked: false, label: '客户编码' },
|
||||
/*
|
||||
* **自增,不问人**(2026-08-02 用户:「联系人ID自增啊,不用填」)——
|
||||
* 它不承载业务含义,只是「这家客户下的第几位」。人哪知道已经有几位;
|
||||
* 让模型编更糟,编重了就是覆盖别人那一行。基座在写入那一刻按
|
||||
* `客户编码-NN` 生成(见 `FieldDeclaration.auto`)。
|
||||
*/
|
||||
{ name: 'contact_code', type: 'text', primaryKey: true, masked: false, auto: true, label: '联系人编码' },
|
||||
{ name: 'name', type: 'text', primaryKey: false, masked: false, label: '姓名' },
|
||||
/* 邮箱是**认人的钥匙**,也是个人信息 —— 与平台声明一致标脱敏 */
|
||||
{ name: 'email', type: 'text', primaryKey: false, masked: true, label: '邮箱' },
|
||||
],
|
||||
},
|
||||
{
|
||||
domain: D,
|
||||
object: 'record',
|
||||
kind: 'record',
|
||||
caseKey: 'rfq_ref',
|
||||
label: '询价受理记录',
|
||||
fields: [
|
||||
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
|
||||
{ name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' },
|
||||
{ name: 'customer_code', type: 'text', primaryKey: false, masked: false, label: '客户码' },
|
||||
/* 客户自己的物料编码 —— 回价单必须按它对齐,否则客户那边对不上账 */
|
||||
{ name: 'customer_pn', type: 'text', primaryKey: false, masked: false, expect: true, label: '客户料号' },
|
||||
{ name: 'category', type: 'text', primaryKey: false, masked: false, expect: true, label: '品类' },
|
||||
{ name: 'brand', type: 'text', primaryKey: false, masked: false, expect: true, label: '制造商' },
|
||||
/*
|
||||
* **年用量是必填** —— 标了 `required`,基座就数得出「缺必填的有几行」
|
||||
* (读数 `quote_intake_record_missing` / `_missing_keys`)。
|
||||
*
|
||||
* 为什么非要基座来数(2026-08-01,连跑十轮里三轮死在这):
|
||||
* 「必填齐」是 L1 机检,从前读的是活动自己写的 `missing_key_total` ——
|
||||
* 人在卡上补了年用量、基座已经写回草稿层,而那个汇总列还要模型自己重算;
|
||||
* 它算漏一次,这条判据就永远绿不了,连拒五拍熔断。
|
||||
* PN 不必标:它是主键,没有 PN 那一行压根不存在。
|
||||
*/
|
||||
{ name: 'eau', type: 'integer', primaryKey: false, masked: false, required: true, label: '年用量 EAU' },
|
||||
{ name: 'target_price', type: 'number', primaryKey: false, masked: false, expect: true, label: '目标价' },
|
||||
{ name: 'lead_time', type: 'text', primaryKey: false, masked: false, label: '交期' },
|
||||
{ name: 'packaging', type: 'text', primaryKey: false, masked: false, label: '包装' },
|
||||
{ name: 'quality_req', type: 'text', primaryKey: false, masked: false, label: '品质要求' },
|
||||
/*
|
||||
* 备注**不算选填缺口**(2026-08-02):`expect` 的意思是「客户该给而没给的」——
|
||||
* 备注是我方写上去的注,一行没有备注不是缺了什么。
|
||||
* 标着它,`_gaps` 几乎恒大于 0,那条 L4 判据于是永远绿不了 —— 门就废了。
|
||||
*/
|
||||
{ name: 'remark', type: 'text', primaryKey: false, masked: false, label: '备注' },
|
||||
/*
|
||||
* **分类列:取值域给出来,条数由基座现数** —— `quote_intake_record_type_tag_a/b/c`。
|
||||
*
|
||||
* 三分类条数从前是活动自己写的 `type_a_total` 之流,而分布条、放行时的去向谓词、
|
||||
* 验收判据读的全是它们。于是人在放行页把九条料全改成 A:类徽变了,
|
||||
* 分布条还是 5/3/1、去向还是「去选型」—— 逐行变了,汇总数没人重算。
|
||||
* **手工修改的全部意义就是纠正 AI 的判断**:改了类标而流向不跟着变,那个兜底就是假的。
|
||||
*/
|
||||
{ name: 'type_tag', type: 'text', primaryKey: false, masked: false, label: '状况标', tally: ['A', 'B', 'C'] },
|
||||
{ name: 'status', type: 'text', primaryKey: false, masked: false, label: '状态' },
|
||||
{ name: 'missing_key_fields', type: 'integer', primaryKey: false, masked: false, label: '本条缺关键字段数' },
|
||||
],
|
||||
},
|
||||
{
|
||||
domain: D,
|
||||
object: 'type_table',
|
||||
kind: 'record',
|
||||
caseKey: 'rfq_ref',
|
||||
label: '料号分类表',
|
||||
fields: [
|
||||
{ name: 'pn', type: 'text', primaryKey: true, masked: false },
|
||||
{ name: 'type_tag', type: 'text', primaryKey: false, masked: false },
|
||||
{ name: 'basis', type: 'text', primaryKey: false, masked: false, label: '分级依据' },
|
||||
{ name: 'history_hit', type: 'boolean', primaryKey: false, masked: false, label: '历史价命中' },
|
||||
// 打标的三个判据必须落表:S5 的 DMN 从这里读,不落就无米下锅
|
||||
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
|
||||
{ name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' },
|
||||
// 打标的三项判据必须落表:P-03 从这里读,不落就无米下锅
|
||||
{ name: 'history_days', type: 'integer', primaryKey: false, masked: false, label: '距上次报价天数' },
|
||||
{ name: 'market_stable', type: 'boolean', primaryKey: false, masked: false, label: '行情稳定' },
|
||||
{ name: 'lifecycle', type: 'text', primaryKey: false, masked: false, label: '生命周期' },
|
||||
{ name: 'history_hit', type: 'boolean', primaryKey: false, masked: false, label: '历史价命中' },
|
||||
{ name: 'last_price', type: 'number', primaryKey: false, masked: false, label: '上次成交单价' },
|
||||
{ name: 'type_tag', type: 'text', primaryKey: false, masked: false, label: '状况标' },
|
||||
{ name: 'basis', type: 'text', primaryKey: false, masked: false, label: '分级依据' },
|
||||
],
|
||||
},
|
||||
{
|
||||
/*
|
||||
* 受理结论 —— **一单一行**。它承担三件别处放不下的事:
|
||||
* ① 两枚去向旗(下游两道网关唯一读的东西);
|
||||
* ② 三个汇总数(验收 L1/L2 机检谓词的入参);
|
||||
* ③ 一句人话结论(验收单上给人看的)。
|
||||
*/
|
||||
domain: D,
|
||||
object: 'verdict',
|
||||
kind: 'record',
|
||||
caseKey: 'rfq_ref',
|
||||
label: '受理结论',
|
||||
fields: [
|
||||
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
|
||||
{ name: 'customer_code', type: 'text', primaryKey: false, masked: false, label: '客户码' },
|
||||
/*
|
||||
* 客户名与新老 —— **报告上那两格,和 A4 的一条硬约束**。
|
||||
*
|
||||
* 名字:认出来的老客户不必重写 `customer.profile`(那是主数据),
|
||||
* 于是报告要显示「这一单是哪家公司」就无处可取 —— 落一列在结论上,
|
||||
* 一份件自己说得出自己是谁,不必去连主数据表。
|
||||
* 新老:新客户走首单口径,**A4 不得取历史价锚点**。这是下游的判据,不是装饰。
|
||||
*/
|
||||
{ name: 'customer_name', type: 'text', primaryKey: false, masked: false, label: '客户名称' },
|
||||
/*
|
||||
* 联系人姓名 —— 报告「联系人」那张卡上摆的那个人,**本单对外发件的收件人**。
|
||||
*
|
||||
* 它本来在 `customer.contact.name`(主数据)里,但报告取不到:老客户不重写主数据,
|
||||
* 于是本单的草稿里根本没有那一行;而顶层同名字段又与 `customer.profile.name`
|
||||
* 撞名(「后写的赢」),摆上去随时可能变成公司名 —— 一张写着公司名的联系人卡
|
||||
* 比空着更坏。跟 customer_name 同一笔写下,一份件自己说得出这单是跟谁在谈。
|
||||
*/
|
||||
{ name: 'contact_name', type: 'text', primaryKey: false, masked: false, label: '联系人姓名' },
|
||||
{ name: 'customer_kind', type: 'text', primaryKey: false, masked: false, label: '客户属性(老客户/新客户)' },
|
||||
/* ① 定位客户那一步的收步判据 —— 认出老客户或人建了新客户,都记 1 */
|
||||
{ name: 'customer_total', type: 'integer', primaryKey: false, masked: false, label: '客户已确认' },
|
||||
/*
|
||||
* 「是不是新客户」与「问过人没有」—— 这两个数是**建档必须经过人**的机检形态。
|
||||
* 实测:剧本写了「别自己编客户编码」,模型照样从邮箱域名编出 CUST-SZ-MACH
|
||||
* 直接建了档。说明书管不住,得让收步条件管:新客户没问过人就收不了步。
|
||||
*/
|
||||
{ name: 'customer_new_total', type: 'integer', primaryKey: false, masked: false, label: '新客户(需建档)' },
|
||||
{ name: 'customer_asked_total', type: 'integer', primaryKey: false, masked: false, label: '已就建档问过人' },
|
||||
{ name: 'line_total', type: 'integer', primaryKey: false, masked: false, label: '有效条目数' },
|
||||
/*
|
||||
* 来件一共几条、本轮不报几条 —— 「有效 PN 9 / 10 行 BOM」那句话的出处。
|
||||
* 没有这两个数,「9 条」是个孤零零的数字:人看不出漏没漏,
|
||||
* 而客户点名跳过的那一条到底有没有被看见,也就无从对账。
|
||||
*/
|
||||
{ name: 'bom_total', type: 'integer', primaryKey: false, masked: false, label: '来件条目数' },
|
||||
{ name: 'skipped_total', type: 'integer', primaryKey: false, masked: false, label: '本轮不报条数' },
|
||||
{ name: 'skipped_note', type: 'text', primaryKey: false, masked: false, label: '不报的是哪几条·为什么' },
|
||||
/*
|
||||
* ② 「问过没有」—— 选填缺口靠**问过**来解,不靠补齐。
|
||||
* 用户定的口径:必填项是验收标准,选填项只提醒,但两种缺失都必须经过人。
|
||||
*/
|
||||
{ name: 'asked_total', type: 'integer', primaryKey: false, masked: false, label: '已就缺口问过人的次数' },
|
||||
/*
|
||||
* **问过 ≠ 答过**。收步判据从前只看 `asked_total`:卡一发出去它就为真,
|
||||
* 于是人填完提交、引擎在下一拍开头就把这一步收了 —— 模型根本没轮到把答案写回台账,
|
||||
* 那一格白填(实测:人补的制造商落了空,而台账里那一列仍旧是空的)。
|
||||
* 收步要等的是**答案落了表**,所以另记一个数。
|
||||
*/
|
||||
{ name: 'answered_total', type: 'integer', primaryKey: false, masked: false, label: '人答完并已落表的次数' },
|
||||
/*
|
||||
* **这一单经过人的手了没有** —— 阶段2 那张受理需求执行页签回来才记 1。
|
||||
*
|
||||
* 它存在的理由是一次实测:从前「经过人」是靠 `missing_minor_total = 0 or
|
||||
* answered_total > 0` 表达的 —— **只在缺东西的时候才叫人**。于是字段齐全的
|
||||
* 一封询价进来,这个条件在拆完那一刻就为真,引擎自己把归一那步收了,
|
||||
* 人一眼没看过受理需求,下一次被叫到已经是终局验收
|
||||
* (用户 2026-08-01:「我都没确认需求 它就让我验收了」)。
|
||||
* 确认不是缺口的补丁,是一道**恒在**的门,所以它得有自己的一个数。
|
||||
*/
|
||||
{ name: 'confirmed_total', type: 'integer', primaryKey: false, masked: false, label: '受理需求已经人审定' },
|
||||
{ name: 'missing_key_total', type: 'integer', primaryKey: false, masked: false, label: '缺必填字段条数(PN/年用量)' },
|
||||
{ name: 'missing_minor_total', type: 'integer', primaryKey: false, masked: false, label: '缺选填字段条数(品牌/备注)' },
|
||||
{ name: 'untyped_total', type: 'integer', primaryKey: false, masked: false, label: '未打标条数' },
|
||||
/*
|
||||
* 分类与派单的条数 —— 「去向旗与状况标对得上」这件事**必须机检**。
|
||||
* 它本是 L4 交人判的:实测里人(我)盲签了通过,而 C 类那条的替代评估请求
|
||||
* 根本没开 —— 旗立着、单没开,下游 A2 收到一个空活。
|
||||
* 算术不该靠人的注意力:把条数落成列,判据就能自己对账。
|
||||
*/
|
||||
{ name: 'type_a_total', type: 'integer', primaryKey: false, masked: false, label: 'A 类条数' },
|
||||
{ name: 'type_b_total', type: 'integer', primaryKey: false, masked: false, label: 'B 类条数' },
|
||||
{ name: 'type_c_total', type: 'integer', primaryKey: false, masked: false, label: 'C 类条数' },
|
||||
/*
|
||||
* **去向定过了没有**(2026-08-02):从前这儿是 `alt_request_total` /
|
||||
* `rfq_request_total` 两个派单计数 —— 而 A1 已经不开单了(见下面那段注释),
|
||||
* 那两个数没了对象。收步仍要一个「这一步办过了」的凭据:
|
||||
* 旗是布尔(合法值含 false),分不清「没写」与「写了 false」,所以另记一个计数。
|
||||
*/
|
||||
{ name: 'route_decided_total', type: 'integer', primaryKey: false, masked: false, label: '已定去向' },
|
||||
{ name: 'need_alternative', type: 'boolean', primaryKey: false, masked: false, label: '需要选型' },
|
||||
{ name: 'need_sourcing', type: 'boolean', primaryKey: false, masked: false, label: '需要寻源' },
|
||||
{ name: 'summary', type: 'text', primaryKey: false, masked: false, label: '受理结论' },
|
||||
],
|
||||
},
|
||||
/*
|
||||
* ── 这里**去掉了 `alt_request` / `rfq_request` 两张表**(2026-08-02 用户当场定)──
|
||||
*
|
||||
* 原话:「替代评估请求 / 询价请求这两项数据不在这里产出;随令牌流向下一个节点的
|
||||
* 只有工作成果验收内的内容;进入选型替代后,活动会自己归一需要的数据。」
|
||||
*
|
||||
* 从前 A1 逐条开这两张单交给 A2 / A3 —— 那是**替下游把它的输入先摆好**,
|
||||
* 而下游本来就要照自己的口径归一一遍:同一份事实在两个活动里各存一份,
|
||||
* 改一处就分叉,而分叉之后没人看得出以哪一份为准。
|
||||
* A1 交出去的是**受理记录**(带状况标与两枚去向旗),下游读它即可。
|
||||
*
|
||||
* 连带退休的:两条派单对账判据(同日删)、`alt_request_total` / `rfq_request_total`
|
||||
* 两个计数列、报告里那两张明细表、S6 的开单动作。
|
||||
*/
|
||||
],
|
||||
listeners: [
|
||||
{
|
||||
/** 客户改需求:整单要回炉 —— 静态监听,时限必配(悬空监听安装时拒绝) */
|
||||
origin: 'static',
|
||||
eventType: 'human',
|
||||
keyFilter: { kind: 'elicitation' },
|
||||
semantic: 'complete-step',
|
||||
// 时限必配 —— 悬空监听安装时拒绝
|
||||
eventType: 'data',
|
||||
keyFilter: { kind: 'rfq-amend' },
|
||||
/*
|
||||
* 语义只有两种:`observe`(记一笔观察,交 Agent 裁量)与 `complete-step`(驱动步完成)。
|
||||
* 客户改需求要的是「整单回炉」,那是 Agent 看到这条观察后自己决定重入哪一段 ——
|
||||
* 监听没有「重入」这一档,也不该有:重入是业务判断,不是事件语义。
|
||||
*/
|
||||
semantic: 'observe',
|
||||
deadline: 'P1D',
|
||||
escalation: ['nudge', 'escalate'],
|
||||
},
|
||||
],
|
||||
harness: {
|
||||
memoryWindow: 20,
|
||||
// SLA:受理<15min、打标<45min —— 停滞检测按此设
|
||||
// SLA:判定<45min —— 停滞检测按此设;受理<15min 那一档做催办
|
||||
stallAfter: 'PT45M',
|
||||
nudge: [{ after: 'PT15M', action: 'nudge' }],
|
||||
retry: { times: 2, backoff: 'PT30S' },
|
||||
},
|
||||
},
|
||||
|
||||
/* 资产初始版:flow = 步序声明 */
|
||||
/* ── 资产初始版 ──────────────────────────────────────────────── */
|
||||
assets: {
|
||||
/* 资产初始版:案例模型(2026-07-29 起活动只有这一种业务模型) */
|
||||
businessModel: {
|
||||
cmmnXml: CMMN_XML,
|
||||
narrative: NARRATIVE,
|
||||
dmn: P03_DMN,
|
||||
dmn: INTAKE_DMN,
|
||||
/* 步骤剧本 —— 引擎按它组织每一拍的上下文,并自己收掉成果已在的步骤 */
|
||||
steps: INTAKE_STEPS,
|
||||
/* 阶段 —— 只给人看的进度刻度:三段激活态 + 放行收口 = 卡上的 x/4 */
|
||||
phases: INTAKE_PHASES,
|
||||
},
|
||||
skills: INTAKE_SKILLS,
|
||||
// 三张模版对应三种局面(事态全景 / 进展 / 成果预览),由步序声明按位置推送
|
||||
// 四张模版对应四个局面;在办与归档同一张(§9.3 第 2 条)
|
||||
viewTemplates: [
|
||||
{ name: 'intake-overview', html: INTAKE_OVERVIEW },
|
||||
{ name: 'intake-classified', html: INTAKE_CLASSIFIED },
|
||||
@@ -186,16 +741,111 @@ export const manifest: ActivityManifest = {
|
||||
distillHooks: [
|
||||
{
|
||||
on: 'terminal',
|
||||
prompt: '归纳本单受理归一的经验:字段缺口形态、打标依据、被驳回的理由。',
|
||||
prompt: '归纳本单受理归一的经验:字段缺口形态、打标依据、去向判断被驳回的理由。',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
/* 样例集 —— 首装审以静态审 + 样例集替代回放审 */
|
||||
/* ── 样例集 —— 首装审以静态审 + 样例集替代回放审 ───────────────── */
|
||||
/*
|
||||
* 样例就是**真客户会发的那种信**(2026-07-31 用户定)。
|
||||
*
|
||||
* 从前两个样例都写着「我司客户编码 C001」—— 那是我们内部的编码,
|
||||
* 客户凭什么知道?真实来函里人只会说**自己是哪个公司的谁**。
|
||||
* 拿带编码的样例去试,等于把「定位客户」这一步的难处提前替它做掉了:
|
||||
* 认不出客户、要请人建档的那条路根本走不到,而那恰恰是这一状态的主路。
|
||||
*/
|
||||
samples: [
|
||||
{
|
||||
name: '车规 Analog 询价',
|
||||
input: { customerCode: 'C001', rfqRef: 'mail:m-1001', subject: '询价 RFQ - 车规 Analog' },
|
||||
/*
|
||||
* **新客户** —— 这个邮箱**不在** `customer.contact` 里。
|
||||
*
|
||||
* 它跟样例 2 必须是两家公司、两个邮箱。曾经两条样例用的是同一个邮箱
|
||||
* (`purchasing@sz-machinery.com`,正是随主数据播下的那位老客户),
|
||||
* 于是「认不出 → 发数据录入卡请人建档」这条路**一次也没被走到过** ——
|
||||
* 两条样例在第一个状态上是同一条路,而卡面上写着它们是两条。
|
||||
* 认客户的钥匙只有邮箱:换名字不算换客户,换邮箱才算。
|
||||
*/
|
||||
name: '新客户询价(三条:在产/波动/EOL)—— 认不出客户,要请人建档',
|
||||
input: {
|
||||
customerEmail: 'sourcing@qd-marine.com',
|
||||
subject: '【询价】RFQ-2026-0001 车规 Analog 三项',
|
||||
body:
|
||||
'你好,\n我是青岛海洋电子有限公司的李娜,采购部。\n' +
|
||||
'我们是第一次和贵司询价,本次三项,请按标准交期报价:\n' +
|
||||
'LM358DR 12K/年 目标价 8.0 交期8周;NE555P 5K/年 交期6周;TL072CDR 3K/年 目标价 22 交期10周\n' +
|
||||
'付款方式请一并给个建议,谢谢。\n李娜 | 青岛海洋电子有限公司',
|
||||
attachments: [],
|
||||
},
|
||||
expect: { ports: ['intake'] },
|
||||
},
|
||||
{
|
||||
/*
|
||||
* ── **老客户案例 · Itelma MD14.1** ────────────────────────────────
|
||||
*
|
||||
* 这一条**不是编的**:正文、附件 BOM、缺口位置、三分类结果,逐项照
|
||||
* `~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理需求.html`(阶段2 执行目标)
|
||||
* 与 `A1-询价受理记录.html`(阶段3 验收目标)两份目标件抄下来。
|
||||
* 那两份是「这个活动办完该长什么样」的答案,这一条是「照它跑一遍」的题面 ——
|
||||
* 题面与答案不同源,测出来的东西就不作数。
|
||||
*
|
||||
* 它一条压住 A1 全部要害:
|
||||
* · 邮箱在 `customer.contact` 里(CU-0142)→ 第一状态认得出,**不发建档卡**
|
||||
* (与样例 1 的新客户正好两条路);
|
||||
* · 料在**附件**里不在正文里 —— 正文只给口径要求(CNY含税/EXW/交期按周/
|
||||
* AEC-Q200/预授权 Analog/跳过 XTSD),逼着走「读附件」那条路;
|
||||
* · 附件 10 行,其中 XTSD 那行客户明说本轮不报 → **有效 PN 应是 9 条**;
|
||||
* · 两处缺口:UMW AO6604 缺制造商(俄文 BOM 那一列为空)、DAG… 缺年用量
|
||||
* → 必填缺(年用量)要派执行卡,选填缺(制造商)只提醒;
|
||||
* · 补齐后三分类判出 **5A / 3B / 1C**,有 C 类 → 去向是选型(to-alternative)。
|
||||
*
|
||||
* 附件给的是**基座读 xlsx 之后的形态**(制表符分列的文本),不是二进制:
|
||||
* 契约里 `attachments[].text` 就是「读出来的文本」,活动看见的正是这个。
|
||||
*/
|
||||
name: '老客户询价 · Itelma MD14.1(认得出客户 · 料在附件 · 10 行报 9 条)',
|
||||
input: {
|
||||
customerEmail: 'e.volkova@itelma.ru',
|
||||
subject: 'RFQ MD14.1 — 10 positions, BOM attached',
|
||||
body:
|
||||
'Dear Colleagues,\n\n' +
|
||||
'We are preparing the sourcing package for our new project MD14.1 and would like to request ' +
|
||||
'your quotation for the 10 positions listed in the attached BOM.\n\n' +
|
||||
'Please note the following requirements:\n' +
|
||||
'1. Prices in CNY including VAT, terms EXW.\n' +
|
||||
'2. Lead time in weeks. Please state SPQ and MOQ separately.\n' +
|
||||
'3. For position 4-02-01812 (TVM6C160K203R, varistor) AEC-Q200 certification is mandatory ' +
|
||||
'— please attach the certificate together with your offer.\n' +
|
||||
'4. Where the column "Analog" is filled in, the listed replacement is already pre-approved ' +
|
||||
'by our engineering department. Any other analog requires our written confirmation.\n' +
|
||||
'5. Please indicate the validity period of your prices.\n\n' +
|
||||
'Position 4-00-04187 (XTSD series, SD controller) is not finalised on our side yet ' +
|
||||
'— please skip it in this round.\n\n' +
|
||||
'We would appreciate receiving your offer by 17 July.\n\n' +
|
||||
'Best regards,\nEkaterina Volkova\nProcurement Department, Itelma',
|
||||
attachments: [
|
||||
{
|
||||
name: 'BOM_Itelma_MD14.1.xlsx',
|
||||
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
size: 18_432,
|
||||
ref: 'sample-bom-md141',
|
||||
/* 基座读 xlsx 的产物:首行列名,其后一行一条,制表符分列;空格 = 客户没填 */
|
||||
text: [
|
||||
'Itelma Code\tPN\tКатегория\tПроизводитель\tAnalog\tСертификация\tEAU (k)\tTarget price',
|
||||
'4-02-01812\tTVM6C160K203R\tВаристор\tThinking Electronic\t\tAEC-Q200\t30\t',
|
||||
'4-02-01733\tPESD2ETH-XR\tДиодная сборка\tNexperia\t\t\t30\t',
|
||||
'4-14-01389\tBSCH00160808R12J00\tДроссель\tYAGEO\tCLH1608T-R12J-S\t\t60\t',
|
||||
'4-14-01455\tBWVF005050201R0T00\tДроссель\tYAGEO\tLVF505020-1R0T-N\t\t90\t',
|
||||
'4-00-04102\tCJTB0101R6N\tМикросхема\tJiangsu Changjing\t\t\t60\t',
|
||||
'4-00-03987\tSGM2202-ADJYN6G/TR\tМикросхема\tSGMicro\t\t\t30\t',
|
||||
'4-02-01698\tUMW AO6604\tТранзисторная сборка\t\t\t\t30\t',
|
||||
'4-14-01502\tBBPY00201209601Y00\tФильтр\tYAGEO\tPBY201209T-601Y-N\t\t30\t',
|
||||
'9-00-00251\tDAG2400L321504XZB01-R0\tАнтенна\t浙江嘉康\t\t\t\t',
|
||||
'4-00-04187\tXTSD04GLGEAQG-I\tКонтроллер SD\t\t\t\t\t',
|
||||
].join('\n'),
|
||||
note: 'xlsx 已读出 11 行(含表头),10 条料',
|
||||
},
|
||||
],
|
||||
},
|
||||
expect: { ports: ['intake'] },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
* + 结晶片段脚本(可选:反复出现的确定性子序列)。
|
||||
*
|
||||
* **载方法,不载权力**:这里提到的工具都在本活动白名单内;技能不扩权、不改约。
|
||||
* 渐进披露:常驻上下文只有 `id label:applicability`,正文 Agent 说要才加载 ——
|
||||
* 一次把所有正文塞进去,上下文会被方法论淹掉,真正的局面反而看不见。
|
||||
*/
|
||||
import { skillIdSchema } from '@contracts';
|
||||
import type { Skill } from '@contracts';
|
||||
@@ -20,17 +22,21 @@ export const INTAKE_SKILLS: Skill[] = [
|
||||
'目标价常带货币符号或「目标价/target」,交期常带「周/weeks/wks/交期/lead」。',
|
||||
'',
|
||||
'三条纪律:',
|
||||
'① **原文没写的不要补**。宁可标 missingKeyFields,也不要按常识填一个「合理值」——',
|
||||
'① **原文没写的不要补**。宁可把 missing_key_fields 记上,也不要按常识填一个「合理值」——',
|
||||
' 受理阶段编出来的数,会一路流到定价和报价单上,越往后越难发现。',
|
||||
'② **单位归一但不换算口径**:12K → 12000 可以;「月用量 1000」不要自作主张乘 12,',
|
||||
' 那是业务判断,该走补全卡问客户。',
|
||||
'③ **一条需求一行**。同一 PN 不同数量档是两行,不是一行的两个值。',
|
||||
'',
|
||||
'拆不动就交人:缺 PN / 数量 / 交期任一,把 missingKeyFields 加一,让 D1 判断把补全卡发出去。',
|
||||
'写台账时一拍写完:`ledger.write` 的 rows 收数组,一份 BOM 几十条也是一次写完 ——',
|
||||
'一行一拍等于把同一件事拆成几十次模型调用,每次都要重读一遍上下文。',
|
||||
'',
|
||||
'写完顺手把 `quote_intake.verdict` 的 line_total 与 missing_key_total 填上:',
|
||||
'那两个数是完整度判定表 F-01 的入参,也是验收的机检判据 —— 不写,下一步无从判起。',
|
||||
].join('\n'),
|
||||
crystallized: [],
|
||||
knowledgeRefs: ['《询价受理规范》'],
|
||||
version: '1.0.0',
|
||||
knowledgeRefs: ['《询价受理规范》F-01'],
|
||||
version: '2.0.0',
|
||||
},
|
||||
{
|
||||
id: skillIdSchema.parse('intake.classify-basis'),
|
||||
@@ -39,15 +45,48 @@ export const INTAKE_SKILLS: Skill[] = [
|
||||
method: [
|
||||
'P-03 只认三个入参:距上次报价天数、行情是否稳定、生命周期状态。三者齐了,结论是**算出来**的,不是判出来的。',
|
||||
'',
|
||||
'所以打标前唯一该做的事是**把判据取齐**:调历史价情报工具,拿回这三项。',
|
||||
'取不到(新料号无历史)不是错误——工具会回「无历史」,那本身就是判据:',
|
||||
'距上次报价记为极大值 → 命中 B 类「重估后报」,这是对的,新料号本就该重估。',
|
||||
'所以打标前唯一该做的事是**把判据取齐**:调 `crm.history-price-read`,',
|
||||
'**参数 pns 收数组** —— 这一单所有 PN 一次传进去。一条一条调,每调一次就多烧一拍模型,',
|
||||
'而拿回来的东西一模一样。',
|
||||
'',
|
||||
'不要绕过决策表自己判。规则表是可回放、可追责的;临场判断不是。',
|
||||
'取不到不是错误:新料号无历史,工具回 historyDays=9999、hit=false。',
|
||||
'**那本身就是判据** —— 距上次报价极大 → 命中 B 类「重估后报」,这是对的:新料号本就该重估。',
|
||||
'',
|
||||
'判据逐条写进 `quote_intake.type_table`(history_days / market_stable / lifecycle),',
|
||||
'再调 `quote-intake.classify` 一次把这几行传进去拿回 A/B/C。',
|
||||
'**不要绕过决策表自己判**:规则表可回放、可追责;临场判断不是。',
|
||||
].join('\n'),
|
||||
// 结晶片段:反复出现的确定性子序列 —— 取历史价这一步每单都一样
|
||||
crystallized: [{ tool: 'crm.history-price-read', input: { pn: 'pn' } }],
|
||||
// 结晶片段:取历史价这一步每单都一样 —— 确定性子序列,不必每次让模型重想
|
||||
crystallized: [{ tool: 'crm.history-price-read', input: { pns: 'pns' } }],
|
||||
knowledgeRefs: ['三分类规则 P-03'],
|
||||
version: '1.0.0',
|
||||
version: '2.0.0',
|
||||
},
|
||||
{
|
||||
id: skillIdSchema.parse('intake.route-and-dispatch'),
|
||||
label: '定去向:状况标不等于去向',
|
||||
applicability: '打标完成、要决定这一单往哪走(要不要选型、要不要寻源)时。',
|
||||
method: [
|
||||
'**状况标是判断的输入,不是去向本身**。同一个 B 标,可能只需寻源,也可能连带选型 ——',
|
||||
'把标直接当去向用,是这一步最常见的错。',
|
||||
'',
|
||||
'两问分开答,可以同时成立:',
|
||||
'· **要不要选型**:原厂料能正常供货且客户没点名替代 → 不需要;',
|
||||
' EOL/停产、长交期缺货,或客户点名要替代 → 需要,交 A2 替代选型。',
|
||||
'· **要不要寻源**:无近期历史价或行情波动(B 类)→ 需要,交 A3 寻源询价。',
|
||||
'· 两者可同时成立 —— 选型定案后仍要为替代料寻价。',
|
||||
'',
|
||||
'**不要在这一步开什么请求单**:随令牌流去下一个节点的只有**验收过的那份受理记录**,',
|
||||
'A2 / A3 拿到之后按自己的口径归一需要的数据。替它们先摆一份,',
|
||||
'等于同一份事实在两个活动里各存一份 —— 改一处就分叉,而分叉之后没人看得出以哪份为准。',
|
||||
'',
|
||||
'答完把 `quote_intake.verdict` 的 need_alternative / need_sourcing 两枚旗写死。',
|
||||
'**这两枚旗是下游两道网关唯一读的东西**:不写,整条流出了受理口就没路可走',
|
||||
'(踩过:两枚旗没有台账列承载,G1 全流死路)。',
|
||||
'',
|
||||
'吃不准就走判断,不许臆断直通:直通省下的十分钟,错了要用三天返工换。',
|
||||
].join('\n'),
|
||||
crystallized: [],
|
||||
knowledgeRefs: ['三分类规则 P-03', 'docs/MVP示例工作流.html §1.1 S6/G1'],
|
||||
version: '3.0.0',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* A1 两份执行页的样式表 —— **从 PRD 目标件原样搬过来**,不是我照着复述的。
|
||||
*
|
||||
* 出处:`~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理需求.html` 的整段 `<style>`,
|
||||
* 外加 `A1-询价受理记录.html` 里归档件独有的那几件(分类分布条 / 类徽 / 判定表 /
|
||||
* 结论横幅 / 落地动作 / 完成标)。**一个数值都没改**。
|
||||
*
|
||||
* 为什么是搬而不是抄:前三轮我用自己那套外壳「照着做」,块搬齐了、版面是两套 ——
|
||||
* 章节头成了彩色方块编号、多出摘要与元信息带、字号用 clamp。人一眼就看出不是那份件。
|
||||
* 目标件是**像素级的答案**,搬过来才不会在复述里走样。
|
||||
*
|
||||
* 选择器保留 `.proto-root .app-shell` 前缀,所以模版正文也要照原型那样包两层壳 ——
|
||||
* 少一层壳,整张样式表一条都不生效(踩过)。
|
||||
*/
|
||||
export const PRD_CSS = `
|
||||
/* ============================================================================
|
||||
JCAI 员工面工作台 · A1 受理归一的过程成果「询价受理需求」——报告本体
|
||||
立场:**AI 已从 RFQ 邮件抽取归一**,人在这里审核并就地修订。
|
||||
两态:读态(AI 稿 + 红/灰占位提示缺项) ⇄ 编辑态(全字段可改,保存 / 取消)
|
||||
核心是「有效 PN 清单」;清单之上四件受理要素:客户公司 / 联系人 / 客户属性 / 有效 PN。
|
||||
|
||||
令牌与既有组件取自 workbench/src/app/(proto)/app.css,数值未改。
|
||||
⚠️ 修正一处原型 bug:app.css:2062-2064 的 '.jc-rp-sign span'(权重 0,1,1)
|
||||
盖过了 '.jc-rp-ava'(0,1,0),把头像的 display:grid 覆盖成 block、字色改灰,
|
||||
导致落款头像里的字掉到圆外。本文件把该规则收窄为 '.jc-rp-sign > div > span'。
|
||||
新增件:四要素卡 .jc-four/.jc-fcard · 占位胶囊 .jc-ph · 分段控件 .jc-seg ·
|
||||
来源条 .jc-src · 录入表 .jc-tbl-in
|
||||
========================================================================== */
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body { background: #e6eaf1; -webkit-font-smoothing: antialiased; }
|
||||
|
||||
.proto-root .app-shell {
|
||||
--brand: #3370ff; --brand-deep: #245bdb;
|
||||
--brand-soft: #e1eaff; --brand-wash: #f0f4ff;
|
||||
--ai-grad: linear-gradient(135deg, #3370ff, #7f3bf5);
|
||||
--ink: #1f2329; --ink-2: #41464e; --ink-3: #646a73; --ink-4: #8f959e;
|
||||
--line: #dee0e3; --line-soft: #e8eaed; --soft: #f8f9fb;
|
||||
--jcai-bg: #e6eaf1; --cardline: #e1e6ec; --card-shadow: 0 1px 2px rgba(31, 35, 41, .05);
|
||||
--ok: #278717; --ok-soft: #e8f8e4;
|
||||
--warn: #de7802; --warn-accent: #ff8800; --warn-soft: #fff4e5;
|
||||
--danger: #d83931; --danger-soft: #feeae9;
|
||||
--mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace;
|
||||
--shadow-brand: 0 6px 16px rgba(51, 112, 255, .22);
|
||||
--t-fast: .16s cubic-bezier(.3, 0, .2, 1);
|
||||
--t-med: .26s cubic-bezier(.3, 0, .2, 1);
|
||||
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: var(--jcai-bg); min-height: 100vh; color: var(--ink);
|
||||
}
|
||||
.proto-root :where(button, input, select, a, [tabindex]):focus-visible {
|
||||
outline: 2px solid var(--brand); outline-offset: 2px; border-radius: 8px;
|
||||
}
|
||||
.proto-root ::selection { background: var(--brand-soft); }
|
||||
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes jcBlink { 0%,100% { opacity: 1 } 50% { opacity: .35 } }
|
||||
@keyframes jcStamp { from { opacity: 0; transform: rotate(-6deg) scale(1.7); } to { opacity: .9; transform: rotate(-6deg) scale(1); } }
|
||||
|
||||
/* 放开宽度:1160px 是「9 列全展开、一个字都不裁」的最小容器宽度(见下方列 min-width 之和) */
|
||||
.jc-stage { max-width: 1160px; margin: 0 auto; padding: 22px 16px 52px; }
|
||||
.proto-root .jc-mono { font-family: var(--mono); font-size: 11px; color: var(--ink-3); }
|
||||
.proto-root .jc-pulse-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--brand); display: inline-block; animation: jcBlink 1.1s ease-in-out infinite; }
|
||||
|
||||
/* ── 报告外壳 ───────────────────────────────────────────────────────── */
|
||||
.proto-root .jc-report { background: #fff; border: 1px solid var(--cardline); border-radius: 14px; box-shadow: var(--card-shadow); overflow: hidden; animation: rise var(--t-med) both; }
|
||||
.proto-root .jc-rp-head { position: relative; overflow: hidden; display: flex; align-items: flex-start; gap: 12px; padding: 16px 20px 13px; border-bottom: 2px solid var(--ink); }
|
||||
/* 题头下缘粗细双线 = 正式件版式 */
|
||||
.proto-root .jc-rp-head::after { content: ""; position: absolute; left: 0; right: 0; bottom: -4px; height: 1px; background: var(--line); }
|
||||
/* 题头右上的品牌光晕:与工作台标题区同一签名件 */
|
||||
.proto-root .jc-rp-head::before { content: ""; position: absolute; right: -70px; top: -96px; width: 240px; height: 240px; border-radius: 999px; background: radial-gradient(circle, rgba(51,112,255,.10), transparent 70%); pointer-events: none; }
|
||||
.proto-root .jc-rp-head > * { position: relative; z-index: 1; }
|
||||
.proto-root .jc-rp-badge { flex: none; width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand); }
|
||||
.proto-root .jc-rp-tit { flex: 1; min-width: 0; }
|
||||
.proto-root .jc-rp-tit em { display: block; font-style: normal; font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); font-family: var(--mono); }
|
||||
.proto-root .jc-rp-tit h3 { margin: 4px 0 0; font-size: 18px; font-weight: 800; color: var(--ink); letter-spacing: -.01em; line-height: 1.3; }
|
||||
.proto-root .jc-rp-wait { display: inline-flex; align-items: center; gap: 6px; flex: none; margin-top: 4px; font-size: 11px; font-weight: 700; border-radius: 999px; padding: 4px 12px; color: var(--brand-deep); background: var(--brand-wash); border: 1px solid var(--brand-soft); }
|
||||
.proto-root .jc-rp-wait.warn { color: var(--warn); background: var(--warn-soft); border-color: #ffd9a8; }
|
||||
.proto-root .jc-rp-wait.edit { color: #fff; background: var(--brand); border-color: var(--brand); }
|
||||
.proto-root .jc-rp-stamp { flex: none; margin-top: 2px; transform: rotate(-6deg); border: 2px solid currentColor; border-radius: 9px; padding: 4px 8px 3px 12px; font-size: 13px; font-weight: 900; letter-spacing: .28em; color: var(--ok); opacity: .9; animation: jcStamp .32s cubic-bezier(.2, 1.4, .4, 1) both; }
|
||||
.proto-root .jc-rp-bd { padding: 18px 20px 12px; }
|
||||
.proto-root .jc-rp-part { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 9px; margin: 20px 0 11px; }
|
||||
.proto-root .jc-rp-part:first-child { margin-top: 0; }
|
||||
.proto-root .jc-rp-part > b { flex: none; font-size: 11px; font-weight: 800; letter-spacing: .12em; color: var(--ink-3); }
|
||||
.proto-root .jc-rp-part > .ln { flex: 1; height: 1px; background: var(--line-soft); }
|
||||
.proto-root .jc-rp-part > em { flex: none; font-style: normal; font-size: 10px; font-weight: 700; color: var(--ink-4); font-family: var(--mono); background: var(--soft); border-radius: 999px; padding: 2px 9px; white-space: nowrap; }
|
||||
.proto-root .jc-rp-part > em.warn { color: var(--warn); background: var(--warn-soft); }
|
||||
|
||||
/* 落款 —— ⚠️ 选择器收窄到 > div > *,别再命中头像那个 span */
|
||||
.proto-root .jc-rp-foot { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 13px 20px 15px; border-top: 2px solid var(--line); background: #fbfcfd; }
|
||||
.proto-root .jc-rp-sign { display: flex; align-items: center; gap: 10px; flex: 1 1 200px; min-width: 0; }
|
||||
.proto-root .jc-rp-ava { flex: 0 0 auto; width: 30px; height: 30px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; line-height: 1; color: #fff; background: var(--brand); }
|
||||
.proto-root .jc-rp-sign > div { min-width: 0; }
|
||||
.proto-root .jc-rp-sign > div > b { display: block; font-size: 12px; font-weight: 700; color: var(--ink-2); }
|
||||
.proto-root .jc-rp-sign > div > span { display: block; margin-top: 1px; font-size: 10.5px; color: var(--ink-4); }
|
||||
.proto-root .jc-rp-acts { display: flex; align-items: center; gap: 8px; margin-left: auto; flex: none; }
|
||||
.proto-root .jc-hintline { font-size: 11px; font-weight: 700; color: var(--warn); line-height: 1.5; }
|
||||
|
||||
.proto-root .jc-btn { display: inline-flex; align-items: center; gap: 5px; border: 0; border-radius: 7px; padding: 8px 14px; font-size: 12px; font-weight: 600; font-family: inherit; background: var(--brand); color: #fff; cursor: pointer; transition: all var(--t-fast); }
|
||||
.proto-root .jc-btn:hover:not(:disabled) { background: var(--brand-deep); }
|
||||
.proto-root .jc-btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.proto-root .jc-btn-ghost { background: #fff; color: var(--ink-2); border: 1px solid var(--line); }
|
||||
.proto-root .jc-btn-ghost:hover:not(:disabled) { background: var(--brand-wash); color: var(--brand); border-color: var(--brand-soft); }
|
||||
.proto-root .jc-btn-primary { background: var(--ai-grad); }
|
||||
.proto-root .jc-btn-primary:hover:not(:disabled) { filter: brightness(1.07); box-shadow: var(--shadow-brand); transform: translateY(-1px); }
|
||||
|
||||
/* ── 来源信件:客户 RFQ 邮件原文(复用原型 jc-letter 件,青色 = 收件) ── */
|
||||
.proto-root .jc-letter { position: relative; border: 1px solid var(--cardline); border-left: 3px solid #0aa294; border-radius: 12px; padding: 14px 16px 15px; background: #fff; box-shadow: var(--card-shadow); }
|
||||
.proto-root .jc-letter-h { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 11px; }
|
||||
.proto-root .jc-letter-dir { display: inline-flex; align-items: center; gap: 5px; font-size: 10.5px; font-weight: 800; padding: 3px 9px; border-radius: 999px; background: #e4f8f5; color: #0a7f74; }
|
||||
.proto-root .jc-letter-h .jc-chip { font-size: 10.5px; padding: 3px 9px; border-radius: 999px; background: var(--brand-wash); color: var(--brand-deep); border: 1px solid var(--brand-soft); }
|
||||
.proto-root .jc-letter-h > code { margin-left: auto; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
|
||||
.proto-root .jc-letter-meta { display: flex; flex-direction: column; gap: 4px; margin-bottom: 11px; padding-bottom: 11px; border-bottom: 1px solid var(--line-soft); font-size: 11.5px; color: var(--ink-2); }
|
||||
.proto-root .jc-letter-meta p { margin: 0; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
|
||||
.proto-root .jc-letter-meta i { flex: none; font-style: normal; width: 44px; font-size: 10px; font-weight: 800; letter-spacing: .06em; color: var(--ink-4); }
|
||||
.proto-root .jc-letter-meta code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); overflow-wrap: anywhere; }
|
||||
.proto-root .jc-letter-sub { margin: 0 0 9px; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.45; overflow-wrap: anywhere; }
|
||||
/* 正文:原文照录,pre-wrap 保原始换行;不截断、不折叠 */
|
||||
.proto-root .jc-letter-body { margin: 0; font-size: 12.5px; line-height: 1.72; color: var(--ink-2); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-letter-body b { color: var(--ink); font-weight: 700; }
|
||||
.proto-root .jc-atts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; padding-top: 11px; border-top: 1px dashed var(--line-soft); }
|
||||
.proto-root .jc-att { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px 5px 8px; border: 1px solid var(--brand-soft); border-radius: 8px; background: var(--brand-wash); font-size: 11.5px; color: var(--brand-deep); }
|
||||
.proto-root .jc-att svg { flex: none; color: var(--brand); }
|
||||
.proto-root .jc-att code { font-family: var(--mono); font-size: 10.5px; }
|
||||
.proto-root .jc-att span { color: var(--ink-4); }
|
||||
/* 抽取指向:从原文到下方要素的一句衔接 */
|
||||
.proto-root .jc-derive { display: flex; align-items: flex-start; gap: 8px; margin-top: 11px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); font-size: 11.5px; line-height: 1.7; color: var(--brand-deep); }
|
||||
.proto-root .jc-derive svg { flex: none; margin-top: 2px; }
|
||||
|
||||
/* ── 四要素卡 ───────────────────────────────────────────────────────────
|
||||
色调字典:每张卡带一套 --tone / --tone-soft / --tone-line,组件只读变量。
|
||||
卡 3、卡 4 的色调随数据变(老客户绿 / 新客户橙;齐了蓝 / 有缺橙),
|
||||
所以这条要素带本身就是一行可读的状态,不只是四个格子。 */
|
||||
.proto-root .jc-four { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 11px; }
|
||||
.proto-root .jc-fcard {
|
||||
position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-width: 0;
|
||||
padding: 14px 14px 14px; border: 1px solid var(--cardline); border-radius: 13px; background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 8px 20px -14px rgba(31,35,41,.35);
|
||||
transition: transform var(--t-fast), box-shadow var(--t-fast), border-color var(--t-fast);
|
||||
--tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft);
|
||||
}
|
||||
/* 顶缘色棱 + 右上角光晕:卡的身份色,一眼分得清四件是四件 */
|
||||
.proto-root .jc-fcard::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 3px; background: var(--tone); }
|
||||
.proto-root .jc-fcard::after { content: ""; position: absolute; right: -46px; top: -52px; width: 132px; height: 132px; border-radius: 999px; background: radial-gradient(circle, var(--tone-soft), transparent 70%); pointer-events: none; }
|
||||
.proto-root .jc-fcard > * { position: relative; z-index: 1; }
|
||||
.proto-root .jc-fcard:hover { transform: translateY(-2px); border-color: var(--tone-line); box-shadow: 0 2px 4px rgba(31,35,41,.05), 0 14px 26px -16px rgba(31,35,41,.45); }
|
||||
.proto-root .jc-fcard.t-teal { --tone: #0aa294; --tone-soft: rgba(10,162,148,.15); --tone-line: #9fe0d9; }
|
||||
.proto-root .jc-fcard.t-ok { --tone: var(--ok); --tone-soft: rgba(39,135,23,.14); --tone-line: #bfedb4; }
|
||||
.proto-root .jc-fcard.t-warn { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.12); --tone-line: #ffd9a8; }
|
||||
|
||||
.proto-root .jc-fcard-k { display: flex; align-items: center; gap: 6px; font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
|
||||
.proto-root .jc-fcard-k > i { flex: none; width: 19px; height: 19px; border-radius: 6px; display: inline-grid; place-items: center; background: var(--tone-soft); color: var(--tone); }
|
||||
.proto-root .jc-fcard-b { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.proto-root .jc-fcard-t { min-width: 0; flex: 1; }
|
||||
/* 一律换行,不省略号——宁可多占一行,也不丢字 */
|
||||
.proto-root .jc-fcard-t > b { display: block; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.35; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-fcard-t > span { display: block; margin-top: 2px; font-size: 10.5px; color: var(--ink-4); line-height: 1.55; overflow-wrap: anywhere; }
|
||||
/* 公司标:圆角方 + AI 渐变 + 外圈光环(方=组织) */
|
||||
.proto-root .jc-logo { flex: none; width: 38px; height: 38px; border-radius: 12px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; letter-spacing: .04em; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand), 0 0 0 3px rgba(51,112,255,.10); }
|
||||
/* 人像:圆 + 青色系(圆=人,与方形的组织标一眼分开) */
|
||||
.proto-root .jc-face { flex: none; width: 38px; height: 38px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; color: #0a7f74; background: #e4f8f5; border: 1px solid #9fe0d9; box-shadow: 0 0 0 3px rgba(10,162,148,.10); }
|
||||
/* 邮箱做成可点的胶囊,复制按钮嵌在里面——比一行裸文字有抓手 */
|
||||
/* 邮箱:软底胶囊、无描边——有描边会读成输入框,而读态这里不是可填的 */
|
||||
.proto-root .jc-mailrow { display: flex; align-items: center; gap: 4px; margin-top: 5px; min-width: 0; max-width: 100%; padding: 3px 4px 3px 9px; border: 0; border-radius: 8px; background: var(--tone-soft); transition: background var(--t-fast); }
|
||||
.proto-root .jc-mailrow:hover { background: rgba(10,162,148,.2); }
|
||||
/* 编辑态里它包着输入框,软底会变成「框中框」,去掉 */
|
||||
.proto-root .jc-report.editing .jc-mailrow { background: transparent; padding: 0; margin-top: 6px; }
|
||||
.proto-root .jc-mailrow > code { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; line-height: 1.5; font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
|
||||
.proto-root .jc-copy { flex: none; width: 22px; height: 22px; display: inline-grid; place-items: center; border: 0; border-radius: 6px; background: #fff; color: var(--ink-4); cursor: pointer; box-shadow: 0 1px 2px rgba(31,35,41,.08); transition: all var(--t-fast); }
|
||||
.proto-root .jc-copy:hover { color: #0aa294; }
|
||||
.proto-root .jc-copy.ok { color: var(--ok); background: var(--ok-soft); box-shadow: none; }
|
||||
/* 客户属性徽章 */
|
||||
.proto-root .jc-tagbig { display: inline-flex; align-items: center; gap: 6px; align-self: flex-start; padding: 7px 14px; border-radius: 999px; font-size: 13.5px; font-weight: 800; line-height: 1; color: var(--tone); background: var(--tone-soft); border: 1px solid var(--tone-line); }
|
||||
.proto-root .jc-fcard-note { font-size: 10.5px; color: var(--ink-4); line-height: 1.55; }
|
||||
.proto-root .jc-fcard-note b { color: var(--ink-2); font-weight: 700; }
|
||||
.proto-root .jc-fcard-note b.bad { color: var(--tone); font-weight: 800; }
|
||||
/* 成交迷你柱:把「台账 6 笔成交」从一句话变成看得见的节奏 */
|
||||
.proto-root .jc-spark { display: flex; align-items: flex-end; gap: 4px; height: 26px; }
|
||||
.proto-root .jc-spark > i { flex: 1 1 0; min-width: 4px; max-width: 15px; border-radius: 3px 3px 1px 1px; background: var(--tone); opacity: .3; transition: opacity var(--t-fast), height var(--t-med); }
|
||||
.proto-root .jc-spark > i:last-child { opacity: 1; }
|
||||
.proto-root .jc-spark:hover > i { opacity: .55; }
|
||||
.proto-root .jc-spark:hover > i:last-child { opacity: 1; }
|
||||
.proto-root .jc-spark.empty { align-items: stretch; }
|
||||
.proto-root .jc-spark.empty > i { height: 100%; opacity: 1; background: repeating-linear-gradient(45deg, var(--tone-soft) 0 5px, transparent 5px 10px); border: 1px dashed var(--tone-line); border-radius: 5px; }
|
||||
/* 有效 PN 计数 */
|
||||
.proto-root .jc-count { display: flex; align-items: baseline; gap: 6px; }
|
||||
.proto-root .jc-count > b { font-family: var(--mono); font-size: 32px; font-weight: 800; line-height: .95; color: var(--ink); letter-spacing: -.04em; }
|
||||
.proto-root .jc-count > span { font-size: 11px; color: var(--ink-4); }
|
||||
/* 分段刻度条:一格 = 一条 PN,缺必填的那格直接标红——比一根实心进度条信息量大 */
|
||||
.proto-root .jc-segmeter { display: flex; gap: 3px; }
|
||||
.proto-root .jc-segmeter > i { flex: 1; height: 7px; border-radius: 2px; background: var(--ok); transition: background var(--t-fast); }
|
||||
.proto-root .jc-segmeter > i.bad { background: var(--danger); }
|
||||
.proto-root .jc-lockhint { display: inline-flex; align-items: center; gap: 4px; font-size: 10px; color: var(--ink-4); }
|
||||
|
||||
/* ── 占位胶囊:必填=红虚线,选填=灰虚线;**都带文字**,不靠颜色单打 ── */
|
||||
.proto-root .jc-ph { display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700; line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed; }
|
||||
.proto-root .jc-ph.req { color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
|
||||
.proto-root .jc-ph.opt { color: var(--ink-4); background: var(--soft); border-color: var(--line); }
|
||||
|
||||
/* ── 分段控件(新客户 / 老客户)── */
|
||||
.proto-root .jc-seg { display: inline-flex; padding: 2px; gap: 2px; border: 1px solid var(--cardline); border-radius: 9px; background: var(--soft); align-self: flex-start; }
|
||||
.proto-root .jc-seg button { border: 0; border-radius: 7px; padding: 6px 13px; font: inherit; font-size: 11.5px; font-weight: 700; color: var(--ink-3); background: transparent; cursor: pointer; transition: all var(--t-fast); }
|
||||
.proto-root .jc-seg button:hover:not(.on) { color: var(--brand); }
|
||||
.proto-root .jc-seg button.on { background: #fff; color: var(--ink); box-shadow: 0 1px 3px rgba(31,35,41,.12); }
|
||||
|
||||
/* ── 表:读态与编辑态共用骨架 ─────────────────────────────────────── */
|
||||
.proto-root .jc-tbl-wrap { overflow-x: auto; border: 1px solid var(--line-soft); border-radius: 10px; }
|
||||
.proto-root .jc-tbl-in { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; }
|
||||
.proto-root .jc-tbl-in th { background: var(--soft); text-align: left; padding: 8px 9px; font-size: 10.5px; font-weight: 800; letter-spacing: .03em; color: var(--ink-3); white-space: nowrap; border-bottom: 1px solid var(--line-soft); }
|
||||
.proto-root .jc-tbl-in th.req::after { content: "*"; margin-left: 1px; color: var(--danger); font-size: 10px; }
|
||||
.proto-root .jc-tbl-in td { padding: 4px 5px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
|
||||
.proto-root .jc-tbl-in tr:last-child td { border-bottom: 0; }
|
||||
.proto-root .jc-tbl-in tbody tr:hover td { background: #f7f9fd; }
|
||||
/* 缺必填的整行浅红打底 + 行首红标——不用找红胶囊,一眼扫到是哪几行 */
|
||||
.proto-root .jc-tbl-in tr.miss td { background: #fffbfb; }
|
||||
.proto-root .jc-tbl-in tbody tr.miss:hover td { background: #fff5f5; }
|
||||
.proto-root .jc-tbl-in td.jc-rn { position: relative; width: 38px; padding: 4px 6px 4px 13px; white-space: nowrap; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
|
||||
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); font-weight: 800; }
|
||||
/* 行首红标只在表态用 ::after —— 卡片态的 ::after 被「第 N 条」占着,不能抢 */
|
||||
@media (min-width: 961px) {
|
||||
.proto-root .jc-tbl-in tr.miss td.jc-rn::after { content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger); }
|
||||
}
|
||||
.proto-root .jc-rndot { display: inline-block; width: 5px; height: 5px; margin-left: 4px; border-radius: 999px; background: var(--danger); vertical-align: 2px; }
|
||||
/* 读态单元格 */
|
||||
.proto-root .jc-vcell { display: block; padding: 6px 6px; font-size: 12px; color: var(--ink); line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-vcell.mono { font-family: var(--mono); font-size: 11px; color: var(--ink-2); }
|
||||
.proto-root .jc-vcell.num { font-family: var(--mono); font-size: 11.5px; text-align: right; }
|
||||
/* 编辑态单元格:安静的电子表格 */
|
||||
.proto-root .jc-cell { width: 100%; min-width: 64px; border: 1px solid var(--line); border-radius: 6px; padding: 6px 7px; font: inherit; font-size: 12px; color: var(--ink); background: #fff; transition: border-color var(--t-fast), box-shadow var(--t-fast); }
|
||||
.proto-root .jc-cell.mono { font-family: var(--mono); font-size: 11px; }
|
||||
.proto-root .jc-cell.num { text-align: right; font-family: var(--mono); font-size: 11.5px; }
|
||||
.proto-root .jc-cell::placeholder { color: var(--ink-4); }
|
||||
.proto-root .jc-cell:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-wash); outline: none; }
|
||||
.proto-root .jc-cell.req-empty { border-color: #f3b6b3; background: #fffafa; }
|
||||
.proto-root .jc-cell.req-empty::placeholder { color: var(--danger); }
|
||||
/* 备注这类长文本用文本域:能换行、随内容长高,不裁字 */
|
||||
.proto-root textarea.jc-cell { display: block; resize: vertical; overflow: hidden; min-height: 31px; line-height: 1.55; }
|
||||
|
||||
/* 编辑态的整表提示带 */
|
||||
.proto-root .jc-editbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); border: 1px solid var(--brand-soft); font-size: 11.5px; color: var(--brand-deep); }
|
||||
.proto-root .jc-editbar svg { flex: none; }
|
||||
.proto-root .jc-editbar .sp { flex: 1 1 auto; }
|
||||
|
||||
/* 剔除行说明 */
|
||||
.proto-root .jc-drop { display: flex; align-items: flex-start; gap: 8px; margin-top: 10px; padding: 9px 12px; border-radius: 9px; border: 1px dashed var(--line); background: var(--soft); font-size: 11.5px; color: var(--ink-3); line-height: 1.6; }
|
||||
.proto-root .jc-drop svg { flex: none; margin-top: 2px; color: var(--ink-4); }
|
||||
.proto-root .jc-drop code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
|
||||
|
||||
.proto-root .jc-callout { display: flex; gap: 8px; align-items: flex-start; border-radius: 9px; padding: 11px 13px; font-size: 12.5px; line-height: 1.75; }
|
||||
.proto-root .jc-callout p { margin: 0; }
|
||||
.proto-root .jc-callout svg { flex: none; margin-top: 2px; }
|
||||
.proto-root .jc-callout.t-mute { background: var(--soft); color: var(--ink-2) }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.proto-root .jc-pulse-dot { animation: none; }
|
||||
.proto-root .jc-report, .proto-root .jc-rp-stamp { animation: none; }
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════
|
||||
响应式:宁可另起一行,也不损失完整度
|
||||
≥961px 六列全展开的表(列 min-width 之和 878px,容器有余量,绝不裁字、绝不横滚)
|
||||
≤960px 表 → 卡片:一条 PN 一张卡、一行一字段(标签 / 值),无横滚、无省略
|
||||
≤560px 字段行再拆成上下两行(标签在上、值在下)
|
||||
══════════════════════════════════════════════════════════════════════════ */
|
||||
@media (max-width: 1040px) { .proto-root .jc-four { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 560px) { .proto-root .jc-four { grid-template-columns: 1fr; } }
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.proto-root .jc-tbl-wrap { overflow: visible; border: 0; border-radius: 0; background: transparent; }
|
||||
.proto-root .jc-tbl-in,
|
||||
.proto-root .jc-tbl-in tbody,
|
||||
.proto-root .jc-tbl-in tr,
|
||||
.proto-root .jc-tbl-in td { display: block; width: 100%; }
|
||||
.proto-root .jc-tbl-in thead { display: none; }
|
||||
|
||||
/* 一条 PN = 一张卡 */
|
||||
.proto-root .jc-tbl-in tr { margin-bottom: 10px; padding: 4px 13px 9px; border: 1px solid var(--line-soft); border-radius: 11px; background: #fff; box-shadow: var(--card-shadow); }
|
||||
.proto-root .jc-tbl-in tr:last-child { margin-bottom: 0; }
|
||||
.proto-root .jc-tbl-in tbody tr:hover td { background: transparent; }
|
||||
.proto-root .jc-tbl-in tr.miss { border-color: #f3b6b3; background: #fffbfb; }
|
||||
|
||||
/* 卡头:第 N 条 */
|
||||
.proto-root .jc-tbl-in td.jc-rn { padding: 8px 0 8px; margin-bottom: 4px; border-bottom: 1px solid var(--line-soft); font-family: inherit; font-size: 12px; font-weight: 800; color: var(--ink-2); }
|
||||
.proto-root .jc-tbl-in td.jc-rn::before { content: "第 "; }
|
||||
.proto-root .jc-tbl-in td.jc-rn::after { content: " 条"; }
|
||||
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); }
|
||||
|
||||
/* 字段行:左标签 + 右值,值超长自动折行 */
|
||||
.proto-root .jc-tbl-in td:not(.jc-rn) { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 10px; align-items: center; padding: 6px 0; border-bottom: 1px dashed var(--line-soft); }
|
||||
.proto-root .jc-tbl-in tr td:last-child { border-bottom: 0; padding-bottom: 2px; }
|
||||
.proto-root .jc-tbl-in td:not(.jc-rn)::before {
|
||||
content: attr(data-h); font-size: 10.5px; font-weight: 700; letter-spacing: .02em; color: var(--ink-4); line-height: 1.5;
|
||||
}
|
||||
.proto-root .jc-tbl-in td[data-req="1"]:not(.jc-rn)::before { content: attr(data-h) " *"; }
|
||||
.proto-root .jc-tbl-in .jc-vcell { padding: 0; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-tbl-in .jc-vcell.num { text-align: left; }
|
||||
.proto-root .jc-tbl-in .jc-cell.num { text-align: left; }
|
||||
.proto-root .jc-tbl-in .jc-cell { min-width: 0; }
|
||||
}
|
||||
|
||||
/* 窄屏:落款三件各占一行,动作按钮撑满一行 */
|
||||
@media (max-width: 560px) {
|
||||
.proto-root .jc-rp-acts { margin-left: 0; width: 100%; flex-wrap: wrap; }
|
||||
.proto-root .jc-rp-acts .jc-btn { flex: 1 1 auto; justify-content: center; }
|
||||
.proto-root .jc-hintline { flex: 1 1 100%; }
|
||||
.proto-root .jc-tbl-in td:not(.jc-rn) { grid-template-columns: 1fr; gap: 3px; align-items: start; }
|
||||
}
|
||||
|
||||
|
||||
/* ══ 以下取自 A1-询价受理记录.html(归档件独有的件)══ */
|
||||
/* ── 料号分类:分布条 + 图例 ───────────────────────────────────────── */
|
||||
.proto-root .jc-dist { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 13px 15px; border: 1px solid var(--line-soft); border-radius: 12px; background: linear-gradient(180deg, #fbfcfe, #fff); }
|
||||
.proto-root .jc-distbar { flex: 1 1 320px; min-width: 260px; display: flex; gap: 3px; }
|
||||
.proto-root .jc-distbar > i { height: 30px; border-radius: 4px; display: grid; place-items: center; font-size: 10.5px; font-weight: 800; color: #fff; font-family: var(--mono); }
|
||||
.proto-root .jc-distbar > i.a { background: var(--ok); }
|
||||
.proto-root .jc-distbar > i.b { background: var(--brand); }
|
||||
.proto-root .jc-distbar > i.c { background: var(--warn-accent); }
|
||||
.proto-root .jc-distlg { display: flex; flex-wrap: wrap; gap: 8px 18px; }
|
||||
.proto-root .jc-distlg > span { display: inline-flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--ink-3); }
|
||||
.proto-root .jc-distlg em { width: 10px; height: 10px; border-radius: 3px; }
|
||||
.proto-root .jc-distlg em.a { background: var(--ok); } .proto-root .jc-distlg em.b { background: var(--brand); } .proto-root .jc-distlg em.c { background: var(--warn-accent); }
|
||||
.proto-root .jc-distlg b { font-family: var(--mono); font-weight: 800; color: var(--ink); }
|
||||
|
||||
/* 类别徽 A / B / C */
|
||||
.proto-root .jc-tbadge { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 6px; font-family: var(--mono); font-size: 11px; font-weight: 800; line-height: 1; color: #fff; }
|
||||
.proto-root .jc-tbadge.a { background: var(--ok); } .proto-root .jc-tbadge.b { background: var(--brand); } .proto-root .jc-tbadge.c { background: var(--warn-accent); }
|
||||
|
||||
/* ── 结论与去向 ─────────────────────────────────────────────────────────
|
||||
G2 是**分支网关**:三条互斥条件按序求值,只命中一条、只落一个活动。
|
||||
所以这里不是三张并列的去向卡,而是一张**判定表 + 一条结论**。 */
|
||||
/* 判定表:一行一条规则,命中行上色留痕(DMN 口径) */
|
||||
.proto-root .jc-dmn { border: 1px solid var(--line-soft); border-radius: 12px; overflow: hidden; }
|
||||
.proto-root .jc-dmn-h, .proto-root .jc-dmn-r { display: grid; grid-template-columns: 40px minmax(0, 1fr) 170px 200px; align-items: center; gap: 12px; padding: 9px 14px; }
|
||||
.proto-root .jc-dmn-h { background: var(--soft); border-bottom: 1px solid var(--line-soft); font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); }
|
||||
.proto-root .jc-dmn-r { font-size: 12px; color: var(--ink-4); background: #fff; }
|
||||
.proto-root .jc-dmn-r + .jc-dmn-r { border-top: 1px solid var(--line-soft); }
|
||||
.proto-root .jc-dmn-i { font-family: var(--mono); font-size: 11px; font-weight: 800; }
|
||||
.proto-root .jc-dmn-c { line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-dmn-t { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; }
|
||||
.proto-root .jc-dmn-t code { font-family: var(--mono); font-size: 10.5px; font-weight: 800; color: var(--ink-4); background: var(--soft); border: 1px solid var(--line); border-radius: 5px; padding: 2px 6px; }
|
||||
.proto-root .jc-dmn-v { font-size: 11px; line-height: 1.55; }
|
||||
/* 命中行:上色 + 左缘棱 + 加粗 */
|
||||
.proto-root .jc-dmn-r.hit { position: relative; background: var(--tone-soft); color: var(--ink-2); }
|
||||
.proto-root .jc-dmn-r.hit::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--tone); }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-i { color: var(--tone); }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-c { color: var(--ink); }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-c b { color: var(--tone); font-weight: 800; }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-t { color: var(--ink); }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-t code { color: #fff; background: var(--tone); border-color: var(--tone); }
|
||||
.proto-root .jc-dmn-v.y { display: inline-flex; align-items: center; gap: 5px; font-weight: 800; color: var(--tone); }
|
||||
.proto-root .jc-dmn-v.n { color: var(--ink-4); }
|
||||
/* 色调随命中类别 */
|
||||
.proto-root .jc-dmn.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.10); }
|
||||
.proto-root .jc-dmn.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.09); }
|
||||
.proto-root .jc-dmn.k-c { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.11); }
|
||||
|
||||
/* 结论横幅:命中条件 → 唯一去向 */
|
||||
.proto-root .jc-verdict {
|
||||
position: relative; overflow: hidden; display: flex; align-items: stretch; flex-wrap: wrap; gap: 16px;
|
||||
margin-top: 11px; padding: 16px 18px; border: 1px solid var(--tone-line); border-radius: 13px;
|
||||
background: linear-gradient(105deg, var(--tone-soft), #fff 62%);
|
||||
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 10px 24px -16px rgba(31,35,41,.4);
|
||||
--tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.14); --tone-line: #ffd9a8;
|
||||
}
|
||||
.proto-root .jc-verdict.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.13); --tone-line: #bfedb4; }
|
||||
.proto-root .jc-verdict.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft); }
|
||||
.proto-root .jc-verdict::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 5px; background: var(--tone); }
|
||||
.proto-root .jc-verdict > * { position: relative; z-index: 1; }
|
||||
.proto-root .jc-vd-l { flex: 1 1 300px; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
|
||||
.proto-root .jc-vd-k { font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
|
||||
.proto-root .jc-vd-cond { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 15px; font-weight: 800; color: var(--ink); }
|
||||
.proto-root .jc-vd-why { margin: 0; font-size: 11.5px; line-height: 1.7; color: var(--ink-3); overflow-wrap: anywhere; }
|
||||
.proto-root .jc-vd-why code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
|
||||
.proto-root .jc-vd-arrow { flex: none; align-self: center; color: var(--tone); }
|
||||
.proto-root .jc-vd-r { flex: 1 1 260px; min-width: 0; display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-radius: 11px; background: #fff; border: 1px solid var(--tone-line); }
|
||||
.proto-root .jc-vd-node { display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 800; color: var(--ink); }
|
||||
.proto-root .jc-vd-node code { font-family: var(--mono); font-size: 12px; font-weight: 800; color: #fff; background: var(--tone); border-radius: 6px; padding: 3px 8px; }
|
||||
.proto-root .jc-vd-who { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--ink-2); }
|
||||
.proto-root .jc-vd-who > i { width: 20px; height: 20px; border-radius: 999px; display: inline-grid; place-items: center; font-style: normal; font-size: 10px; font-weight: 700; color: #fff; background: var(--tone); }
|
||||
.proto-root .jc-vd-next { margin: 0; padding-top: 8px; border-top: 1px dashed var(--line-soft); font-size: 10.5px; line-height: 1.65; color: var(--ink-4); }
|
||||
.proto-root .jc-vd-next b { color: var(--ink-2); font-weight: 700; }
|
||||
/* 命中分支携带的条目 */
|
||||
.proto-root .jc-pnlist { display: flex; flex-wrap: wrap; gap: 5px; margin: 0; padding: 0; list-style: none; }
|
||||
.proto-root .jc-pnlist > li { display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 999px; background: #fff; border: 1px solid var(--tone-line); font-size: 11px; color: var(--ink-2); overflow-wrap: anywhere; }
|
||||
.proto-root .jc-pnlist > li > i { flex: none; width: 5px; height: 5px; border-radius: 999px; background: var(--tone); }
|
||||
.proto-root .jc-pnlist code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
|
||||
|
||||
/* ── 落地动作 ───────────────────────────────────────────────────────── */
|
||||
.proto-root .jc-acts { display: flex; flex-direction: column; gap: 0; border: 1px solid var(--line-soft); border-radius: 11px; overflow: hidden; }
|
||||
.proto-root .jc-act { display: flex; align-items: flex-start; gap: 9px; padding: 10px 13px; font-size: 12px; color: var(--ink-2); line-height: 1.6; background: #fff; }
|
||||
.proto-root .jc-act + .jc-act { border-top: 1px solid var(--line-soft); }
|
||||
.proto-root .jc-act > i { flex: none; width: 18px; height: 18px; margin-top: 1px; border-radius: 999px; display: inline-grid; place-items: center; color: #fff; background: var(--ok); }
|
||||
.proto-root .jc-act b { font-weight: 700; color: var(--ink); }
|
||||
.proto-root .jc-act span { color: var(--ink-4); }
|
||||
.proto-root .jc-act code { font-family: var(--mono); font-size: 10.5px; color: var(--brand-deep); background: var(--brand-wash); border-radius: 4px; padding: 1px 5px; }
|
||||
|
||||
|
||||
.proto-root .jc-done-tag { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; flex: none; padding: 6px 13px; border-radius: 999px; font-size: 11.5px; font-weight: 800; color: var(--ok); background: #fff; border: 1px solid #bfedb4; }
|
||||
.proto-root .jc-vcell.dim { color: var(--ink-4); }
|
||||
.proto-root .jc-tbl-in td.jc-ty { width: 44px; text-align: center; }
|
||||
.proto-root .jc-tbl-in td.jc-ty { width: auto; text-align: left; }
|
||||
`;
|
||||
|
||||
/**
|
||||
* 图标精灵 —— 与样式表同一出处,两份目标件的 `<symbol>` 取并集。
|
||||
* 沙箱里取不到图标字体,所以图标一律内联;`<use href="#i-xxx">` 引的就是这一坨。
|
||||
*/
|
||||
export const PRD_SPRITE = `<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs><symbol id="i-alert" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></symbol><symbol id="i-arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol><symbol id="i-boxes" viewBox="0 0 24 24"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></symbol><symbol id="i-building" viewBox="0 0 24 24"><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/><path d="M10 18h4"/></symbol><symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol><symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol><symbol id="i-file-check" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="m9 15 2 2 4-4"/></symbol><symbol id="i-file-text" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></symbol><symbol id="i-git-branch" viewBox="0 0 24 24"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></symbol><symbol id="i-history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></symbol><symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></symbol><symbol id="i-lock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol><symbol id="i-mail-open" viewBox="0 0 24 24"><path d="M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0z"/><path d="m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10"/></symbol><symbol id="i-pen" viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z"/></symbol><symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol><symbol id="i-sheet" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M8 13h2"/><path d="M14 13h2"/><path d="M8 17h2"/><path d="M14 17h2"/></symbol><symbol id="i-shield-check" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></symbol><symbol id="i-user" viewBox="0 0 24 24"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/></symbol><symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></symbol></defs></svg>`;
|
||||
@@ -1,77 +1,720 @@
|
||||
/**
|
||||
* A1 视图模版 —— 三种局面:事态全景 / 打标判据 / 成果预览(AM 07)。
|
||||
* 外壳与卡片件来自 `@sdk`:六个活动共用一套面貌,不各自发明。
|
||||
* A1 询价受理的 HTML 报告模版 —— **从 PRD 目标件搬过来的那两份件**。
|
||||
*
|
||||
* 出处(逐块照搬,样式表见 `prd-style.ts`,一个数值都没改):
|
||||
* · `~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理需求.html` → `INTAKE_OVERVIEW`
|
||||
* (阶段2 · 待处理页里嵌的那份);
|
||||
* · `~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理记录.html` → `INTAKE_RELEASE`
|
||||
* (阶段3 · 工作成果里定稿归档的那份)。
|
||||
*
|
||||
* ── 为什么是搬,不是照着做 ─────────────────────────────────────────────
|
||||
* 前三轮我拿自己那套 SDK 外壳「照着做」:块搬齐了,版面却是两套 —— 章节头成了
|
||||
* 彩色方块编号、多出一段摘要与一条元信息带、字号用 clamp 响应式。三次报「25 块到位」,
|
||||
* 而那是**结构断言**(某个 class 在不在),对版面一点约束都没有。人一眼就看出不是那份件。
|
||||
* 目标件是像素级的答案:搬过来,才不会在复述里走样。
|
||||
*
|
||||
* ── 搬的时候,哪些东西必须换掉 ─────────────────────────────────────────
|
||||
* 原型是**带脚本的演示页**:数据写死在 script 里,由 JS 造表、算计数、切编辑态。
|
||||
* 而这里的容器是 sandbox iframe,**一行脚本也跑不了**;填数只有三件事 ——
|
||||
* `data-slot` 取值、`data-rows` 逐行展开、`data-class-of` 按值上色。所以:
|
||||
* · 写死的行 → `data-rows` 模版,一行一条从台账草稿展开;
|
||||
* · JS 算的计数 → 台账里的汇总列,`data-slot` 取;
|
||||
* · JS 造的「必填/选填」占位胶囊 → CSS `:empty::after`(基座对空值留空,见 `data-blank`);
|
||||
* · JS 算的**命中行**与结论横幅 → 基座按契约的出口谓词求值后注入 `case:route*`
|
||||
* (与放行时发口的是同一次判定,不是这屏自己又算了一遍);
|
||||
* · 编辑态 → **就长在件里**(2026-08-01 改)。每个可改的格子渲两份:只读那份与
|
||||
* `<input class="jc-cell">` 孪生件,由根节点上的 `editing` 二选一显示。
|
||||
* 待处理那一屏摆的**只有这份件**,所以改数据也只能在件上改;
|
||||
* 工作成果不加那个 class,同一份 HTML 就是归档件。
|
||||
* 收值在外层(`allow-same-origin` 读得到 DOM),iframe 里仍然一行脚本都没有。
|
||||
* · 「修改 / 确认」按钮 → 不做:提交那一下在**对话区的卡**上,
|
||||
* 判据与作业指导书也在那儿 —— 工作区只放件本身。
|
||||
*
|
||||
* ── 搬不过来的两处,如实空着 ───────────────────────────────────────────
|
||||
* · 圆标里的首字母(IT / EV / 冯)—— 得算,而模版不许算;换成同形状里的图标。
|
||||
* · 「近 6 笔成交」迷你柱 —— `market.price_history` 有列没数据(种子不写、CRM 是桩),
|
||||
* 没有写入方。画六根编出来的柱子是撒谎,画一条空轨像组件坏了,所以那一格不画,
|
||||
* 把「为什么没有」写在卡脚。
|
||||
*
|
||||
* ── 取值路径三纪律(编错一个字,屏上就是一片「—」)────────────────────
|
||||
* ① 顶层只取汇总列与单值列;逐行列摊到顶层是「后写的赢」,摆到题头上是抽样不是概览。
|
||||
* ② 逐条数据一律走 `mine:域.对象` —— `域.对象` 是正式版全表,别单的行也在里面。
|
||||
* ③ 列名逐字照 `manifest.ts` 的 `declarations.ledger` 抄,不照记忆写。
|
||||
*/
|
||||
import { kvCard, noteCard, tableCard, viewShell } from '@sdk';
|
||||
import { PRD_CSS, PRD_SPRITE } from './prd-style.ts';
|
||||
|
||||
export const INTAKE_OVERVIEW = viewShell({
|
||||
title: 'RFQ 受理全景',
|
||||
kicker: 'A1 · 统一受理',
|
||||
tone: 'brand',
|
||||
/* ==================== 搬运期补的几条样式 ==================== */
|
||||
|
||||
/**
|
||||
* 原型用 JS 干、这里只能用 CSS 干的那几件 —— **补在原样式表之后,不改原值**。
|
||||
*
|
||||
* ① 空格子的「必填 / 选填」胶囊:原型是 JS 判空后插一个 `jc-ph`;
|
||||
* 这里改成 `:empty::after`,靠基座对空值留空(`data-blank`)触发,胶囊样式仍用原型的数值。
|
||||
* ② 缺必填的整行浅红 + 行首红条:原型给 `tr.miss`,这里用 `:has()` 认。
|
||||
* ③ 类徽 / 分布条 / 判定表命中行按值上色:原型由 JS 拼 class,
|
||||
* 这里由基座把值消毒成 `v-*` 追加进 class(`data-class-of`)—— 判断在样式表里,不在填数里。
|
||||
*/
|
||||
const BIND_CSS = `
|
||||
/* ① 空格子自己说话 —— 值真的空才轮到这三条(基座 data-blank)。
|
||||
* 占位三级(2026-08-03 补齐,用户:「目标价是建议」):必填红 / 建议橘 / 选填灰,
|
||||
* 档位跟台账声明走(required → req,expect → sug,其余 opt),颜色与 A2 同一套。 */
|
||||
/* 胶囊与同一行的数字坐同一条线(2026-08-03 用户:「没有和数字对齐」)——
|
||||
* 裸 inline-flex 的胶囊按 baseline 挂:盒子(10px 字 + 上下 3px 内距 + 边框 = 18px)比
|
||||
* 字的基线深好几像素,视觉上整枚往下坠。治法:空格子自己撑起文字那份盒高(12px×1.5),
|
||||
* 胶囊在里面垂直居中 —— 中线对中线,行高也不再忽高忽低。
|
||||
* :not(.on) 不能省(2026-08-03 用户:「编辑态太拥挤」):这条比「编辑态藏起只读份」
|
||||
* 的那条更特异,不圈住的话编辑态里胶囊会盖过 display:none 冒出来,54 个输入框旁边
|
||||
* 各站一枚 —— 编辑态里格子该不该填由闸与红带说,胶囊只属于看的那份。 */
|
||||
.proto-root .jc-tbl-in:not(.on) td.req > .jc-vcell:empty,
|
||||
.proto-root .jc-tbl-in:not(.on) td.sug > .jc-vcell:empty,
|
||||
.proto-root .jc-tbl-in:not(.on) td.opt > .jc-vcell:empty { display: flex; align-items: center; min-height: 18px; }
|
||||
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after,
|
||||
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after,
|
||||
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after {
|
||||
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
|
||||
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
|
||||
}
|
||||
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after { content: "必填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
|
||||
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after { content: "建议"; color: #b45309; background: #fff7ed; border-color: #fcd9a8; }
|
||||
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after { content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line); }
|
||||
/* 归档件里空格子就是「客户没给」,不再摆「你还能填」的胶囊 */
|
||||
.proto-root .jc-tbl-in td.dimempty > .jc-vcell:empty::after { content: "—"; color: var(--ink-4); }
|
||||
|
||||
/* ② 缺必填的整行 —— 原型是 tr.miss,这里由 :has() 认 */
|
||||
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td { background: #fffbfb; }
|
||||
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty):hover td { background: #fff5f5; }
|
||||
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn { color: var(--danger); font-weight: 800; position: relative; }
|
||||
@media (min-width: 1001px) {
|
||||
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn::after {
|
||||
content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger);
|
||||
}
|
||||
}
|
||||
/* 序号由 CSS 计数器排 —— 模版里没有循环变量,行是基座复制出来的 */
|
||||
.proto-root .jc-tbl-in tbody { counter-reset: rn; }
|
||||
.proto-root .jc-tbl-in td.jc-rn::before { counter-increment: rn; content: counter(rn); }
|
||||
/* 窄屏原型给的是 ::before「第 」+ ::after「 条」;这里 ::before 已被计数器占用,
|
||||
把两句合进 ::before,并把原型那句 ::after 收掉 —— 否则窄屏上写着「第 9 条 条」 */
|
||||
@media (max-width: 1000px) {
|
||||
.proto-root .jc-tbl-in td.jc-rn::before { content: "第 " counter(rn) " 条"; }
|
||||
.proto-root .jc-tbl-in td.jc-rn::after { content: none; }
|
||||
}
|
||||
|
||||
/* ④ 就地改:每个可改的格子渲了两份 —— 只读那份与输入那份,这里二选一。
|
||||
**默认锁着**:开关是那张表上的 on(外层按下「编辑」才加),
|
||||
一屏两张表各管各的。判断在样式表里,不在填数里,件本身仍是同一份。 */
|
||||
/* 默认锁着:只读那份在,输入孪生件收起来 —— **件首先是给人看的** */
|
||||
.proto-root .jc-cell { display: none; }
|
||||
/* 开闸是给那张表的容器加 「on」(外层按下「编辑」才加)—— 一屏两张表各管各的 */
|
||||
.proto-root .jc-tbl-in.on td > .jc-vcell,
|
||||
.proto-root .jc-tbl-in.on td > .jc-tbadge { display: none; }
|
||||
.proto-root .jc-tbl-in.on td > .jc-cell { display: block; }
|
||||
/* 类那一列平时只有一枚 24px 的徽,编辑态要放得下一个字 */
|
||||
.proto-root .jc-tbl-in.on td.jc-ty, .proto-root .jc-tbl-in.on th:nth-child(2) { width: 56px; min-width: 56px; }
|
||||
/*
|
||||
* **数值列也左对齐**(2026-08-02 用户:「目标价的价格也要跟标题左对齐,右对齐太奇怪了」)。
|
||||
*
|
||||
* 右对齐是给**要竖着相加的那种数**用的(金额合计、数量小计)——这张表里的目标价
|
||||
* 一行一个料、彼此不相加,右对齐只是让它和自己的表头分家:标题贴左、数贴右,
|
||||
* 中间空一条,眼睛得来回找。等宽字体留着(位数对齐仍有用),对齐方式跟表头走。
|
||||
*/
|
||||
.proto-root .jc-vcell.num, .proto-root .jc-cell.num { text-align: left; }
|
||||
.proto-root .jc-tbl-in.on td.num > .jc-cell, .proto-root .jc-tbl-in.on .jc-cell.num { text-align: left; }
|
||||
/* 编辑态下必填空格子:红框 + 红 placeholder(原型的 req-empty 同款,只是由 :placeholder-shown 认) */
|
||||
.proto-root .jc-tbl-in.on td.req > .jc-cell:placeholder-shown { border-color: #f3b6b3; background: #fffafa; }
|
||||
.proto-root .jc-tbl-in.on tbody tr:has(td.req > .jc-cell:placeholder-shown) td { background: #fffbfb; }
|
||||
|
||||
/* ⑥ **铺满工作区**(2026-08-02 用户定:「报告不要做成单独的嵌入层」)。
|
||||
原型那份是独立网页,jc-stage 给了 1160px 上限与 22/16/52px 的四周留白 ——
|
||||
摆进工作区就成了「画布上浮着的一张小卡」:左右各空一条,底下还坠着 52px。
|
||||
件在这里**就是这一屏的正文**,不是嵌在里面的另一个页面:去掉上限与横向留白,
|
||||
外框那圈圆角边也一并卸掉(工作区自己已经是一张卡,两层框叠着像贴纸)。 */
|
||||
.jc-stage { max-width: none; padding: 0 0 4px; }
|
||||
.proto-root .jc-report { border: 0; border-radius: 0; box-shadow: none; }
|
||||
|
||||
/* ⑤ 那道闸 —— 默认「已锁定 + 编辑」,开了变成「取消 / 保存」。
|
||||
整块只在这一屏可改时才出现(外层给根节点加 「editable」);
|
||||
归档件里它一个字都不该有 —— 那时件是件,不是表单。 */
|
||||
.proto-root .jc-editgate { display: none; }
|
||||
html.editable .proto-root .jc-editgate {
|
||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
margin-bottom: 10px; padding: 8px 12px; border-radius: 9px;
|
||||
background: var(--soft); border: 1px solid var(--line-soft);
|
||||
font-size: 11.5px; color: var(--ink-3);
|
||||
}
|
||||
.proto-root .jc-editgate-t { display: inline-flex; align-items: center; gap: 6px; flex: 1 1 auto; min-width: 0; }
|
||||
.proto-root .jc-editgate-t b { color: var(--ink-2); font-weight: 700; }
|
||||
.proto-root .jc-egbtn {
|
||||
flex: none; display: inline-flex; align-items: center; gap: 4px; cursor: pointer;
|
||||
border: 1px solid var(--line); border-radius: 7px; padding: 5px 11px;
|
||||
font: inherit; font-size: 11.5px; font-weight: 700; color: var(--ink-2); background: #fff;
|
||||
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
|
||||
}
|
||||
.proto-root .jc-egbtn:hover { border-color: var(--brand); color: var(--brand); }
|
||||
.proto-root .jc-egbtn.save { color: #fff; background: var(--brand); border-color: var(--brand); }
|
||||
.proto-root .jc-egbtn.save:hover { background: var(--brand-deep); border-color: var(--brand-deep); }
|
||||
/* 锁着的时候只有「编辑」;开了换成「取消 / 保存」—— 三个钮同时摆着,人不知道现在算哪一态 */
|
||||
.proto-root .jc-editgate .jc-egbtn.ghost, .proto-root .jc-editgate .jc-egbtn.save { display: none; }
|
||||
.proto-root .jc-editgate.on { background: var(--brand-wash); border-color: var(--brand-soft); color: var(--brand-deep); }
|
||||
.proto-root .jc-editgate.on .jc-egbtn.open { display: none; }
|
||||
.proto-root .jc-editgate.on .jc-egbtn.ghost, .proto-root .jc-editgate.on .jc-egbtn.save { display: inline-flex; }
|
||||
.proto-root .jc-editgate.on .jc-editgate-t { visibility: hidden; }
|
||||
/* 缺项红带 —— **只在这一屏可改时出现**,且一格不缺就不出(判断在样式表,不在填数里) */
|
||||
.proto-root .jc-gapbar { display: none; }
|
||||
html.editable .proto-root .jc-gapbar {
|
||||
display: flex; align-items: flex-start; gap: 8px;
|
||||
margin: 0 0 10px; padding: 9px 12px; border-radius: 9px;
|
||||
background: #fff5f5; border: 1px solid #f3b6b3; color: var(--danger);
|
||||
font-size: 12px; line-height: 1.65;
|
||||
}
|
||||
.proto-root .jc-gapbar svg { flex: 0 0 auto; margin-top: 2px; }
|
||||
.proto-root .jc-gapbar b { font-weight: 800; }
|
||||
.proto-root .jc-gapbar span { color: #9a3b36; }
|
||||
/* 一格不缺 —— 那条带没有话可说 */
|
||||
html.editable .proto-root .jc-gapbar.v-0 { display: none; }
|
||||
|
||||
/* 那条「怎么改」的提示只在开了闸之后说 —— 锁着的时候它是句废话 */
|
||||
.proto-root .jc-editbar { display: none; }
|
||||
.proto-root .jc-editgate.on + .jc-editbar { display: flex; }
|
||||
|
||||
/* ⑦ 增删行 —— 手改是兜 AI 的错那一手,而错不止「这一格填错了」:
|
||||
「这一行根本不该在」与「漏了一行」是同一类错,只给改格子就没有出口。 */
|
||||
/* 序号那一格一列两用:只读时是行号(计数器画的),编辑时是「撤掉这一行」的 × */
|
||||
.proto-root .jc-rowdel { display: none; }
|
||||
.proto-root .jc-tbl-in.on td.jc-rn::before { content: none; }
|
||||
.proto-root .jc-tbl-in.on .jc-rowdel {
|
||||
display: block; width: 20px; height: 20px; margin: 0 auto; cursor: pointer;
|
||||
border: 1px solid var(--line); border-radius: 6px; padding: 0;
|
||||
font: inherit; font-size: 13px; line-height: 1; color: var(--ink-3); background: #fff;
|
||||
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
|
||||
}
|
||||
.proto-root .jc-tbl-in.on .jc-rowdel:hover { border-color: var(--danger); color: var(--danger); background: #fff6f6; }
|
||||
/* 撤掉的行**不当场抹掉** —— 划着摆在那儿,人看得见自己删了哪一行,也才有得后悔 */
|
||||
.proto-root .jc-tbl-in.on tr.jc-row-gone td { background: #fff6f6; opacity: .55; }
|
||||
.proto-root .jc-tbl-in.on tr.jc-row-gone .jc-cell { text-decoration: line-through; color: var(--ink-3); }
|
||||
.proto-root .jc-tbl-in.on tr.jc-row-gone .jc-rowdel { border-color: var(--danger); color: var(--danger); background: #fff; }
|
||||
.proto-root .jc-tbl-in.on tr.jc-row-new td { background: #f2fbf7; }
|
||||
/* 表尾那条「加一行」—— 与闸同款,只在这一屏可改且开了闸时现身 */
|
||||
.proto-root .jc-addrow { display: none; }
|
||||
html.editable .proto-root .jc-tbl-wrap:has(table.on) + .jc-addrow {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
margin: -2px 0 12px; padding: 7px 12px; border-radius: 9px;
|
||||
background: var(--soft); border: 1px dashed var(--line);
|
||||
}
|
||||
.proto-root .jc-addrow em { font-style: normal; font-size: 11px; color: var(--ink-3); }
|
||||
|
||||
/* ③ 按值上色:基座把值消毒成 v-* 追加进 class */
|
||||
/* 刻度条按缺口:missing_key_fields 是条数,0 = 完整(绿,原型的默认色),非 0 = 缺必填(红) */
|
||||
.proto-root .jc-segmeter > i.v-1, .proto-root .jc-segmeter > i.v-2, .proto-root .jc-segmeter > i.v-3 { background: var(--danger); }
|
||||
/* 刻度条按状况标:归档屏那张卡上,一格即一条料的类别 */
|
||||
.proto-root .jc-segmeter > i.v-a { background: var(--ok); }
|
||||
.proto-root .jc-segmeter > i.v-b { background: var(--brand); }
|
||||
.proto-root .jc-segmeter > i.v-c { background: var(--warn-accent); }
|
||||
.proto-root .jc-tbadge { background: var(--ink-4); }
|
||||
.proto-root .jc-tbadge.v-a { background: var(--ok); }
|
||||
.proto-root .jc-tbadge.v-b { background: var(--brand); }
|
||||
.proto-root .jc-tbadge.v-c { background: var(--warn-accent); }
|
||||
/* 分布条:三段按条数分宽。原型是 JS 写 inline style="flex:N",而模版不许算 ——
|
||||
改由 data-class-of 把**条数本身**投影成 v-<N>,这里备一把梯子让 CSS 认。
|
||||
条数 0 的那段整段不出:摆一条零宽的彩带不如不摆。 */
|
||||
/* 原型那段是 grid + place-items:center;这里段里多了个 ::before 前缀,
|
||||
grid 会把前缀和数字排成上下两行 —— 改成一行居中的 flex */
|
||||
.proto-root .jc-distbar > i { flex: 24; min-width: 34px; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.proto-root .jc-distbar > i.v-0, .proto-root .jc-distbar > i:not([class*=" v-"]) { display: none; }
|
||||
.proto-root .jc-distbar > i.v-1 { flex: 1 } .proto-root .jc-distbar > i.v-2 { flex: 2 }
|
||||
.proto-root .jc-distbar > i.v-3 { flex: 3 } .proto-root .jc-distbar > i.v-4 { flex: 4 }
|
||||
.proto-root .jc-distbar > i.v-5 { flex: 5 } .proto-root .jc-distbar > i.v-6 { flex: 6 }
|
||||
.proto-root .jc-distbar > i.v-7 { flex: 7 } .proto-root .jc-distbar > i.v-8 { flex: 8 }
|
||||
.proto-root .jc-distbar > i.v-9 { flex: 9 } .proto-root .jc-distbar > i.v-10 { flex: 10 }
|
||||
.proto-root .jc-distbar > i.v-11 { flex: 11 } .proto-root .jc-distbar > i.v-12 { flex: 12 }
|
||||
.proto-root .jc-distbar > i.v-13 { flex: 13 } .proto-root .jc-distbar > i.v-14 { flex: 14 }
|
||||
.proto-root .jc-distbar > i.v-15 { flex: 15 } .proto-root .jc-distbar > i.v-16 { flex: 16 }
|
||||
.proto-root .jc-distbar > i.v-17 { flex: 17 } .proto-root .jc-distbar > i.v-18 { flex: 18 }
|
||||
.proto-root .jc-distbar > i.v-19 { flex: 19 } .proto-root .jc-distbar > i.v-20 { flex: 20 }
|
||||
/* 段里那句「A · 5」的前缀 —— 类别是这一段的固有属性,不必从数据里取 */
|
||||
.proto-root .jc-distbar > i.a::before { content: "A · "; }
|
||||
.proto-root .jc-distbar > i.b::before { content: "B · "; }
|
||||
.proto-root .jc-distbar > i.c::before { content: "C · "; }
|
||||
/* 判定表命中行:基座把命中的那一条注入成 v-r1/r2/r3,壳上带 class,行自己认 */
|
||||
.proto-root .jc-dmn.v-r1 { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.11); }
|
||||
.proto-root .jc-dmn.v-r2 { --tone: var(--brand); --tone-soft: rgba(51,112,255,.09); }
|
||||
.proto-root .jc-dmn.v-r3 { --tone: var(--ok); --tone-soft: rgba(39,135,23,.10); }
|
||||
.proto-root .jc-dmn.v-r1 .jc-dmn-r.r1, .proto-root .jc-dmn.v-r2 .jc-dmn-r.r2, .proto-root .jc-dmn.v-r3 .jc-dmn-r.r3 {
|
||||
position: relative; background: var(--tone-soft); color: var(--ink-2);
|
||||
}
|
||||
.proto-root .jc-dmn.v-r1 .jc-dmn-r.r1::before, .proto-root .jc-dmn.v-r2 .jc-dmn-r.r2::before, .proto-root .jc-dmn.v-r3 .jc-dmn-r.r3::before {
|
||||
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--tone);
|
||||
}
|
||||
.proto-root .jc-dmn.v-r1 .jc-dmn-r.r1 .jc-dmn-c, .proto-root .jc-dmn.v-r2 .jc-dmn-r.r2 .jc-dmn-c, .proto-root .jc-dmn.v-r3 .jc-dmn-r.r3 .jc-dmn-c { color: var(--ink); }
|
||||
.proto-root .jc-dmn.v-r1 .jc-dmn-r.r1 .jc-dmn-i, .proto-root .jc-dmn.v-r2 .jc-dmn-r.r2 .jc-dmn-i, .proto-root .jc-dmn.v-r3 .jc-dmn-r.r3 .jc-dmn-i { color: var(--tone); }
|
||||
.proto-root .jc-dmn.v-r1 .jc-dmn-r.r1 .jc-dmn-c b, .proto-root .jc-dmn.v-r2 .jc-dmn-r.r2 .jc-dmn-c b, .proto-root .jc-dmn.v-r3 .jc-dmn-r.r3 .jc-dmn-c b { color: var(--tone); font-weight: 800; }
|
||||
.proto-root .jc-dmn.v-r1 .jc-dmn-r.r1 .jc-dmn-t, .proto-root .jc-dmn.v-r2 .jc-dmn-r.r2 .jc-dmn-t, .proto-root .jc-dmn.v-r3 .jc-dmn-r.r3 .jc-dmn-t { color: var(--ink); }
|
||||
.proto-root .jc-dmn.v-r1 .jc-dmn-r.r1 .jc-dmn-t code, .proto-root .jc-dmn.v-r2 .jc-dmn-r.r2 .jc-dmn-t code, .proto-root .jc-dmn.v-r3 .jc-dmn-r.r3 .jc-dmn-t code {
|
||||
color: #fff; background: var(--tone); border-color: var(--tone);
|
||||
}
|
||||
/* 判定那一列:两句话都写在模版里,哪一句现身**由壳上那个 v-rN 决定** ——
|
||||
模版仍然不许算,而「命中 / 未求值」这层判断留在样式表。
|
||||
一条都没命中(算不出去向)时壳上没有 v-rN,两句都不出:不冒充判过。 */
|
||||
.proto-root .jc-dmn .jc-dmn-v > .y, .proto-root .jc-dmn .jc-dmn-v > .n { display: none; }
|
||||
.proto-root .jc-dmn.v-r1 .r1 .jc-dmn-v > .y, .proto-root .jc-dmn.v-r2 .r2 .jc-dmn-v > .y, .proto-root .jc-dmn.v-r3 .r3 .jc-dmn-v > .y {
|
||||
display: inline-flex; align-items: center; gap: 5px; font-weight: 800; color: var(--tone);
|
||||
}
|
||||
.proto-root .jc-dmn.v-r1 .r2 .jc-dmn-v > .n, .proto-root .jc-dmn.v-r1 .r3 .jc-dmn-v > .n,
|
||||
.proto-root .jc-dmn.v-r2 .r1 .jc-dmn-v > .n, .proto-root .jc-dmn.v-r2 .r3 .jc-dmn-v > .n,
|
||||
.proto-root .jc-dmn.v-r3 .r1 .jc-dmn-v > .n, .proto-root .jc-dmn.v-r3 .r2 .jc-dmn-v > .n { display: inline; color: var(--ink-4); }
|
||||
.proto-root .jc-verdict.v-r1 { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.14); --tone-line: #ffd9a8; }
|
||||
.proto-root .jc-verdict.v-r2 { --tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft); }
|
||||
.proto-root .jc-verdict.v-r3 { --tone: var(--ok); --tone-soft: rgba(39,135,23,.13); --tone-line: #bfedb4; }
|
||||
/* 圆标里放图标不放首字母 —— 首字母得算,而画一个算错的缩写不如画一个说得清类别的图标 */
|
||||
.proto-root .jc-logo svg, .proto-root .jc-face svg, .proto-root .jc-rp-ava svg { width: 17px; height: 17px; }
|
||||
`;
|
||||
|
||||
/** 一枚图标 —— 与原型同一写法:`use` 引精灵里的 symbol */
|
||||
const ico = (name: string, size = 11): string =>
|
||||
`<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-${name}"/></svg>`;
|
||||
|
||||
/**
|
||||
* 一份件的外壳 —— **照原型的三层壳**:`.proto-root > .app-shell > .jc-stage > article.jc-report`。
|
||||
* 少一层,整张样式表(选择器带 `.proto-root .app-shell` 前缀)一条都不生效。
|
||||
*/
|
||||
const doc = (input: {
|
||||
badge: string;
|
||||
kicker: string;
|
||||
title: string;
|
||||
titleRef: string;
|
||||
/** 题头右上:在办亮灯(jc-rp-wait);办结那一枚印章由归档态自己带 */
|
||||
state: string;
|
||||
body: string;
|
||||
foot: string;
|
||||
}): string => `<style>${PRD_CSS}${BIND_CSS}</style>
|
||||
${PRD_SPRITE}
|
||||
<div class="proto-root"><div class="app-shell"><div class="jc-stage">
|
||||
<article class="jc-report">
|
||||
<header class="jc-rp-head">
|
||||
<div class="jc-rp-badge">${ico(input.badge, 17)}</div>
|
||||
<div class="jc-rp-tit">
|
||||
<em>${input.kicker}</em>
|
||||
<h3>${input.title} <span data-slot="${input.titleRef}"></span></h3>
|
||||
</div>
|
||||
${input.state}
|
||||
</header>
|
||||
<div class="jc-rp-bd">${input.body}</div>
|
||||
<footer class="jc-rp-foot">${input.foot}</footer>
|
||||
</article>
|
||||
</div></div></div>`;
|
||||
|
||||
/** 章节头 —— 原型的 `jc-rp-part`:小标签 + 细线 + 右端小签 */
|
||||
const part = (label: string, aside = '', tone = ''): string =>
|
||||
`<div class="jc-rp-part"><b>${label}</b><i class="ln"></i>${aside === '' ? '' : `<em class="${tone}">${aside}</em>`}</div>`;
|
||||
|
||||
/** 落款 —— 署名取任务负责人(基座填);右端那枚签写「这份件在流里的位置」,不写状态 */
|
||||
const sign = (note: string, tag = ''): string =>
|
||||
`<div class="jc-rp-sign">
|
||||
<span class="jc-rp-ava">${ico('user', 15)}</span>
|
||||
<div><b data-slot="case:owner"></b><span>${note}</span></div>
|
||||
</div>${tag === '' ? '' : `<span class="jc-done-tag">${ico('git-branch', 13)}${tag}</span>`}`;
|
||||
|
||||
/* ==================== 共用件 ==================== */
|
||||
|
||||
/**
|
||||
* 来源信件 —— 原型的 `jc-letter`。正文 `pre-wrap` 原样照录:摘要过的原文就不是原文了。
|
||||
* 收件签旁那枚归属签(通道入站 → 归了谁)与右端入站时刻都由基座填 ——
|
||||
* 让活动自报「归属解析 → 冯兵」,等于让被指派的人自己说自己是谁。
|
||||
*/
|
||||
const LETTER = `<article class="jc-letter">
|
||||
<div class="jc-letter-h">
|
||||
<span class="jc-letter-dir">${ico('mail-open', 12)}收件</span>
|
||||
<span class="jc-chip"><b data-slot="case:origin"></b> · 归属解析 → <b data-slot="case:owner"></b></span>
|
||||
<code data-slot="case:openedAt"></code>
|
||||
</div>
|
||||
<div class="jc-letter-meta">
|
||||
<p><i>发件人</i><span data-slot="contact_name"></span><code data-slot="customerEmail"></code></p>
|
||||
</div>
|
||||
<h4 class="jc-letter-sub" data-slot="subject"></h4>
|
||||
<p class="jc-letter-body" data-slot="body"></p>
|
||||
<div class="jc-atts" data-rows="attachments">
|
||||
<span class="jc-att">${ico('sheet', 13)}<code data-slot="name"></code><span data-slot="note"></span></span>
|
||||
</div>
|
||||
</article>`;
|
||||
|
||||
/**
|
||||
* 受理要素四卡 —— 原型的 `jc-four`。
|
||||
* `kind` 决定第四张卡下面那条刻度条按什么上色:过程屏按缺口(红格 = 必填未补),
|
||||
* 归档屏按状况标(A 绿 / B 蓝 / C 橙)。**一格一条料**,每格都对得上清单里的一行。
|
||||
*/
|
||||
const four = (kind: 'gap' | 'type'): string => `<div class="jc-four">
|
||||
<div class="jc-fcard">
|
||||
<span class="jc-fcard-k"><i>${ico('building')}</i>客户公司</span>
|
||||
<div class="jc-fcard-b">
|
||||
<span class="jc-logo">${ico('building', 17)}</span>
|
||||
<div class="jc-fcard-t"><b data-slot="customer_name"></b><span data-slot="customer_kind"></span></div>
|
||||
</div>
|
||||
<span class="jc-fcard-note">已命中客户资料库 <code class="jc-mono" data-slot="customer_code"></code></span>
|
||||
</div>
|
||||
|
||||
<div class="jc-fcard t-teal">
|
||||
<span class="jc-fcard-k"><i>${ico('user')}</i>联系人</span>
|
||||
<div class="jc-fcard-b">
|
||||
<span class="jc-face">${ico('user', 17)}</span>
|
||||
<div class="jc-fcard-t">
|
||||
<b data-slot="contact_name"></b>
|
||||
<div class="jc-mailrow"><code data-slot="customerEmail"></code></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="jc-fcard-note">采购口 · 本单对外发件收件人</span>
|
||||
</div>
|
||||
|
||||
<div class="jc-fcard t-ok">
|
||||
<span class="jc-fcard-k"><i>${ico('history')}</i>客户属性</span>
|
||||
<span class="jc-tagbig">${ico('shield-check', 13)}<span data-slot="customer_kind"></span></span>
|
||||
<span class="jc-fcard-note">
|
||||
成交台账尚未接入 —— 历史价由下游定价环节现查;
|
||||
新客户走首单口径,<b>不得取锚点</b>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="jc-fcard${kind === 'type' ? ' t-ok' : ''}">
|
||||
<span class="jc-fcard-k"><i>${ico('boxes')}</i>有效 PN</span>
|
||||
<div class="jc-count"><b data-slot="line_total"></b><span>/ <b data-slot="bom_total"></b> 行 BOM</span></div>
|
||||
<div class="jc-segmeter" data-rows="mine:quote_intake.record">
|
||||
<i data-class-of="${kind === 'type' ? 'type_tag' : 'missing_key_fields'}"></i>
|
||||
</div>
|
||||
<span class="jc-fcard-note">
|
||||
${
|
||||
kind === 'type'
|
||||
? '一格一条料 · 按状况标着色 · <b data-slot="line_total"></b> 条已交下游'
|
||||
: '一格一条料 · 红格是必填未补 <b data-slot="missing_key_total"></b> 条'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
/**
|
||||
* 有效 PN 清单 —— 原型那六列,一列不多一列不少。
|
||||
*
|
||||
* 「客户料号」原型明写不收(『Itelma Code 不是本记录的字段』),这里也不摆:
|
||||
* 报告是给报价这条线看的,客户自己的编码在 A1 出口的载荷里,不占版面。
|
||||
* `req`/`opt` 只决定空格子出哪种胶囊;归档屏一律 `dimempty`(那时空就是「客户没给」,
|
||||
* 再摆「你还能填」的红胶囊就是在催一件已经办完的事)。
|
||||
*/
|
||||
const listTable = (mode: 'draft' | 'final'): string => {
|
||||
/*
|
||||
* `data-col` 一挂,这一格就多渲一个输入孪生件(基座 `twinInputs`),
|
||||
* 待处理那一屏可就地改;写的是**台账列名**,不是表头文字 ——
|
||||
* 表头改个说法不该让写回错位。归档件不挂:那时它是件,不是表单。
|
||||
*/
|
||||
const cell = (key: string, head: string, kind: 'req' | 'sug' | 'opt', style = ''): string => {
|
||||
const cls = mode === 'final' ? 'dimempty' : kind;
|
||||
return `<td class="${cls}" data-h="${head}"><span class="jc-vcell ${style}" data-slot="${key}" data-col="${key}" data-blank></span></td>`;
|
||||
};
|
||||
const req = mode === 'final' ? '' : 'req';
|
||||
/*
|
||||
* 序号那一格在编辑态换成「撤掉这一行」的 × —— 一列两用,不为它单开一列。
|
||||
* 只读时它是行号(CSS 计数器画的),编辑时人要的是「这一行不该在」的那个动作。
|
||||
*/
|
||||
const del = `<td class="jc-rn"><button type="button" class="jc-rowdel"
|
||||
data-edit-del="quote_intake.record" title="撤掉这一行">×</button></td>`;
|
||||
return `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
|
||||
<thead><tr>
|
||||
<th style="width:36px;min-width:36px"></th>
|
||||
${mode === 'final' ? '<th style="width:44px;min-width:44px">类</th>' : ''}
|
||||
<th class="${req}" style="min-width:178px">PN</th>
|
||||
<th style="min-width:88px">品类</th>
|
||||
<th class="${req}" style="min-width:78px">年用量</th>
|
||||
<th style="min-width:140px">制造商</th>
|
||||
<th style="min-width:84px">目标价</th>
|
||||
<th style="min-width:200px;width:100%">备注</th>
|
||||
</tr></thead>
|
||||
<tbody data-rows="mine:quote_intake.record" data-edit="quote_intake.record"><tr>
|
||||
${del}
|
||||
${mode === 'final' ? '<td class="jc-ty" data-h="分类"><span class="jc-tbadge" data-class-of="type_tag" data-slot="type_tag" data-col="type_tag"></span></td>' : ''}
|
||||
${cell('pn', 'PN', 'req', 'mono')}
|
||||
${cell('category', '品类', 'sug')}
|
||||
${cell('eau', '年用量', 'req', 'mono')}
|
||||
${cell('brand', '制造商', 'sug')}
|
||||
${cell('target_price', '目标价', 'sug', 'num')}
|
||||
${cell('remark', '备注', 'opt')}
|
||||
</tr></tbody>
|
||||
</table></div>`;
|
||||
};
|
||||
|
||||
/** 编辑态那条提示带 —— 原型的 `jc-editbar`,只在待处理那一屏现身 */
|
||||
/**
|
||||
* **缺哪几格,红字直说**(2026-08-02 用户:「执行单现在要怎么填不明显,在报告上明显一点」)。
|
||||
*
|
||||
* 从前这一屏只在空格子里摆一枚小小的「必填」胶囊 —— 九行料摊开,那几枚胶囊淹在表里,
|
||||
* 人得逐行扫过去才知道自己要动哪一格。而他打开这一屏就是来填的。
|
||||
*
|
||||
* 现在表上方直接一条红带:**还差 N 格,分别是哪一行的哪一列**。
|
||||
* 两个数都由基座现算(`域_对象_missing` / `_missing_keys`),模版不算也不判 ——
|
||||
* 「一格不缺就别出这条带」这个判断交给样式表:`data-class-of` 把数消毒成
|
||||
* `v-0` / `v-1` / …,`v-0` 那一档 `display:none`。件本身仍然只做取值。
|
||||
*/
|
||||
const gapBar = `<div class="jc-gapbar" data-class-of="quote_intake_record_missing">${ico('alert', 14)}
|
||||
<span><b>还差 <span data-slot="quote_intake_record_missing"></span> 格必填没补</b>
|
||||
—— <span data-slot="quote_intake_record_missing_keys"></span>。
|
||||
按右上角「编辑」开表,红框那几格就是要填的;补齐了才提交得了。</span>
|
||||
</div>`;
|
||||
|
||||
/**
|
||||
* 一张可改的表的**闸** —— 默认锁着,按「编辑」才开(2026-08-01 用户定)。
|
||||
*
|
||||
* 从前进这一屏整份件就是编辑态:54 个输入框一起亮着,人还没决定要改什么,
|
||||
* 屏上已经是一张待填的表 —— **看**与**改**是两件事,件首先是给人看的。
|
||||
*
|
||||
* 三个按钮由外层挂事件(容器 `allow-same-origin`,读得到也改得动 iframe 的 DOM);
|
||||
* **iframe 里仍然一行脚本都没有**。`data-edit-*` 是外层认它们的钩子:
|
||||
* · `open` → 给根节点加 `editing`,输入孪生件现身;
|
||||
* · `cancel` → 把每个输入还原成进编辑态那一刻的值,再关掉;
|
||||
* · `save` → 收值交给外层缓冲(提交那一下才真落库),再关掉。
|
||||
* 一屏可能有两张表(需求表 / 分类表),所以闸带 `data-edit-for="域.对象"` —— 各管各的。
|
||||
*/
|
||||
const editGate = (address: string, what: string): string => `<div class="jc-editgate" data-edit-for="${address}">
|
||||
<span class="jc-editgate-t">${ico('lock', 12)}${what}<b>已锁定</b> —— 看的时候不误碰,要改按右边</span>
|
||||
<button type="button" class="jc-egbtn open" data-edit-open="${address}">${ico('pen', 12)}编辑</button>
|
||||
<button type="button" class="jc-egbtn ghost" data-edit-cancel="${address}">取消</button>
|
||||
<button type="button" class="jc-egbtn save" data-edit-save="${address}">${ico('check', 12)}保存</button>
|
||||
</div>
|
||||
<div class="jc-editbar">${ico('pen', 14)}
|
||||
<span>改哪一格点哪一格:<b>整份清单每一格都能覆盖</b> —— AI 抽错的直接改,缺的补上;
|
||||
多出来的行按右边的 × 撤掉,漏了的用底下的「加一行」补。
|
||||
红框是必填未补。改动只落任务草稿层,<b>保存不等于放行</b>。</span>
|
||||
</div>`;
|
||||
|
||||
/**
|
||||
* 表尾那条「加一行」—— **只在编辑态现身**(样式表按根节点的 `editing` 决定)。
|
||||
*
|
||||
* 为什么人得能加行:手改本来就是**兜 AI 的错**的那一手,而错不止「这一格填错了」——
|
||||
* 「BOM 里有而它没抽出来」是同一类错,只给改格子就没有出口。
|
||||
*/
|
||||
const addRowBar = (address: string): string => `<div class="jc-addrow">
|
||||
<button type="button" class="jc-egbtn ghost" data-edit-add="${address}">${ico('plus', 12)}加一行</button>
|
||||
<em>照末行的样子添一行空的;主键(型号 PN)填了才存得下,不填这一行不落库。</em>
|
||||
</div>`;
|
||||
|
||||
/*
|
||||
* ── 这里**去掉了「本轮不报」那条横幅**(2026-08-02 用户当场指出:「也删了」)────
|
||||
*
|
||||
* 原文:「本轮不报 N 行:… —— 客户补齐规格后可另起一单。
|
||||
* 有效条目数 = 来件条目 − 本轮不报,两个数对不上就是拆漏了」。
|
||||
*
|
||||
* 绝大多数单子它是「0 行:—」——**一条永远摆在那儿、九成时候说「没有」的横幅**,
|
||||
* 读的人每次都得扫过它才知道没事。而后半句是讲给拆料那一步听的对账口诀,
|
||||
* 不是给验收的人看的:拆漏没拆漏由收步判据 `record_rows = line_total` 当场拦着,
|
||||
* 拦不住的时候摆一句口诀也没用。
|
||||
*
|
||||
* 题头那一行仍如实写着「N 条 · 已剔除 M」——「剔除了几条」这个事实没有丢。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 料号分类:分布条 + 图例 —— 原型的 `jc-dist`。
|
||||
*
|
||||
* 三段按条数分宽:段宽在原型里是 JS 写的 `style="flex:N"`,模版不许算,
|
||||
* 于是把**条数本身**投影成 class(`data-class-of` → `v-5`),让样式表按梯子取宽。
|
||||
* 段里印的数字与图例读的是同三个汇总列 —— 一处数据两处呈现,对不上就是台账自己错了。
|
||||
*/
|
||||
const DIST = `<div class="jc-dist">
|
||||
<div class="jc-distbar">
|
||||
<i class="a" data-class-of="quote_intake_record_type_tag_a" data-slot="quote_intake_record_type_tag_a"></i>
|
||||
<i class="b" data-class-of="quote_intake_record_type_tag_b" data-slot="quote_intake_record_type_tag_b"></i>
|
||||
<i class="c" data-class-of="quote_intake_record_type_tag_c" data-slot="quote_intake_record_type_tag_c"></i>
|
||||
</div>
|
||||
<div class="jc-distlg">
|
||||
<span><em class="a"></em>A 类 · 行情完备 <b data-slot="quote_intake_record_type_tag_a"></b></span>
|
||||
<span><em class="b"></em>B 类 · 需寻源询价 <b data-slot="quote_intake_record_type_tag_b"></b></span>
|
||||
<span><em class="c"></em>C 类 · 需替代选型 <b data-slot="quote_intake_record_type_tag_c"></b></span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
/* ==================== 局面一 · 受理中(阶段2 待处理页嵌的那份)==================== */
|
||||
|
||||
export const INTAKE_OVERVIEW = doc({
|
||||
badge: 'file-text',
|
||||
kicker: '统一报价 · A1 受理归一 · 过程成果',
|
||||
title: '询价受理需求',
|
||||
titleRef: 'rfq_ref',
|
||||
state:
|
||||
'<span class="jc-rp-wait warn">缺必填 <b data-slot="missing_key_total"></b> · 缺选填 <b data-slot="missing_minor_total"></b></span>',
|
||||
body:
|
||||
kvCard('询价来源', [
|
||||
['客户编码', 'customer_code', 'mono'],
|
||||
['询价单号', 'rfqRef', 'mono'],
|
||||
['主题', 'subject'],
|
||||
]) +
|
||||
kvCard('归一结果', [
|
||||
['型号 PN', 'pn', 'mono'],
|
||||
['厂牌', 'brand'],
|
||||
['年用量 EAU', 'eau'],
|
||||
['目标价', 'target_price'],
|
||||
['交期', 'lead_time'],
|
||||
['缺关键字段', 'missing_key_fields'],
|
||||
]) +
|
||||
noteCard('这一屏在说什么', '归一由模型从询价原文抽取,逐条落<b>任务级草稿层</b>:本任务可见、正式版不可见、不产生数据事件。放行前的一切都还改得动。'),
|
||||
part('来源 · 客户 RFQ 邮件', '<span data-slot="case:openedAt"></span> 入站', 'jc-mono') +
|
||||
LETTER +
|
||||
`<div class="jc-derive">${ico('info', 14)}
|
||||
<span>下方受理要素与 PN 清单,均由 AI 从这封来函与附件 BOM 抽取归一而来 —— 正文里的口径要求
|
||||
(币种 / 贸易条款 / 交期单位 / 认证 / 预授权替代 / 本轮跳哪条)已逐条落到对应字段与备注;
|
||||
附件那一行写的是<b>基座读取的结果</b>,读得出文本才作数。</span>
|
||||
</div>` +
|
||||
part('受理要素', 'F-01 完整度自检') +
|
||||
four('gap') +
|
||||
part('有效 PN 清单', '<b data-slot="line_total"></b> 条 · 已剔除 <b data-slot="skipped_total"></b>') +
|
||||
gapBar +
|
||||
editGate('quote_intake.record', '有效 PN 清单') +
|
||||
listTable('draft') +
|
||||
addRowBar('quote_intake.record') +
|
||||
`<div class="jc-drop">${ico('info', 13)}
|
||||
<span>带 * 的两列是<b>必填</b>(PN / 年用量):缺任一按 F-01 退回补全,不许放行;
|
||||
品类 / 制造商 / 目标价缺了标橘「<b>建议</b>」—— 值得追问、尽量补上,备注缺了标灰「选填」。
|
||||
三档都<b>不为它们拦单</b>,先受理、后追问,但那一问不能省。
|
||||
<b>原文没写的一律不补</b>:受理阶段编出来的数会一路流到定价和报价单上,越往后越难发现。</span>
|
||||
</div>`,
|
||||
foot: sign('AI 拟稿,你审定 —— 要改哪一格,在「待处理」那张单的需求表上就地改', '统一报价 · A1 受理归一'),
|
||||
});
|
||||
|
||||
export const INTAKE_CLASSIFIED = viewShell({
|
||||
title: '料号分类与判据',
|
||||
kicker: 'A1 · 三分类 P-03',
|
||||
tone: 'ok',
|
||||
/* ==================== 局面三 · 已分类 ==================== */
|
||||
|
||||
export const INTAKE_CLASSIFIED = doc({
|
||||
badge: 'shield-check',
|
||||
kicker: '统一报价 · A1 受理归一 · 三分类 P-03',
|
||||
title: '料号分类表',
|
||||
titleRef: 'rfq_ref',
|
||||
state: '<span class="jc-rp-wait">已分级 · 未打标 <b data-slot="untyped_total"></b></span>',
|
||||
body:
|
||||
kvCard('分类结论', [
|
||||
['型号 PN', 'pn', 'mono'],
|
||||
['Type 标', 'type_tag', 'tag'],
|
||||
['分级依据', 'basis'],
|
||||
]) +
|
||||
kvCard('打标判据(DMN 入参)', [
|
||||
['距上次报价', 'history_days'],
|
||||
['行情稳定', 'market_stable'],
|
||||
['生命周期', 'lifecycle'],
|
||||
['历史价命中', 'history_hit'],
|
||||
]) +
|
||||
noteCard(
|
||||
'为什么是确定性的',
|
||||
'三分类规则 P-03 是<b>决策表</b>不是模型判断:同样的判据必得同样的结论,零方差零 token,命中的规则行随出参留痕。' +
|
||||
'A = 直接快答;B = 重估后报(转寻源);C = 转案处理(转替代选型)。',
|
||||
),
|
||||
part('料号分类', '三分类规则 P-03 · 逐条判定') +
|
||||
DIST +
|
||||
part('逐条分级(判据 → 结论)', '按序求值 · 首个命中即出') +
|
||||
editGate('quote_intake.type_table', '料号分类表') +
|
||||
`<div class="jc-tbl-wrap"><table class="jc-tbl-in">
|
||||
<thead><tr>
|
||||
<th style="width:36px;min-width:36px"></th>
|
||||
<th style="min-width:178px">型号 PN</th>
|
||||
<th style="min-width:120px">距上次报价(天)</th>
|
||||
<th style="min-width:88px">行情稳定</th>
|
||||
<th style="min-width:96px">生命周期</th>
|
||||
<th style="width:44px;min-width:44px">类</th>
|
||||
<th style="min-width:200px;width:100%">分级依据</th>
|
||||
</tr></thead>
|
||||
<tbody data-rows="mine:quote_intake.type_table" data-edit="quote_intake.type_table"><tr>
|
||||
<td class="jc-rn"></td>
|
||||
<td data-h="型号 PN"><span class="jc-vcell mono" data-slot="pn" data-col="pn"></span></td>
|
||||
<td data-h="距上次报价"><span class="jc-vcell num" data-slot="history_days" data-col="history_days"></span></td>
|
||||
<td data-h="行情稳定"><span class="jc-vcell" data-slot="market_stable" data-col="market_stable"></span></td>
|
||||
<td data-h="生命周期"><span class="jc-vcell" data-slot="lifecycle" data-col="lifecycle"></span></td>
|
||||
<td class="jc-ty" data-h="分类"><span class="jc-tbadge" data-class-of="type_tag" data-slot="type_tag" data-col="type_tag"></span></td>
|
||||
<td data-h="分级依据"><span class="jc-vcell" data-slot="basis" data-col="basis"></span></td>
|
||||
</tr></tbody>
|
||||
</table></div>` +
|
||||
`<div class="jc-drop">${ico('info', 13)}
|
||||
<span>三分类规则 P-03 是<b>决策表</b>,不是临场判断:同样的输入必得同样的结论,凭哪一条命中都留了痕。
|
||||
<b>A</b> = 历史价 ≤ 90 天且行情稳;<b>B</b> = 无历史 / 过期 / 波动;<b>C</b> = EOL / 缺货 / 客户要求替代。
|
||||
规则从上往下第一条命中即出 —— C 那一行排在最前,EOL 的料哪怕历史价很新也不判 A。
|
||||
<b>拿不准的一律降 B</b>:错标一个 A,就是让一条本该重新核价的料带着旧价走到报价单上。</span>
|
||||
</div>`,
|
||||
foot: sign('状况标由判定表 P-03 求值 —— 判据与结论逐条可回核', '统一报价 · A1 受理归一'),
|
||||
});
|
||||
|
||||
export const INTAKE_RELEASE = viewShell({
|
||||
title: '统一受理记录 · 待放行',
|
||||
kicker: 'A1 · 成果预览',
|
||||
tone: 'case',
|
||||
/* ==================== 局面四 · 待放行(阶段3 工作成果里的那份)==================== */
|
||||
|
||||
/**
|
||||
* G2 分支网关 —— 三条互斥条件按序求值,只命中一条、只落一个活动。
|
||||
*
|
||||
* **命中哪一条不是这屏自己算的**:契约给每个出口写了判据,基座在放行那一刻按序求值,
|
||||
* 把结果注入 `case:route*`(与发口用的是同一次判定)。这里只把它摆出来:
|
||||
* 壳上带 `v-r1/r2/r3`,对应那一行自己上色。
|
||||
* 摆成三张并列的去向卡就丢了「按序求值、只落一个」那层意思,所以照原型做成判定表。
|
||||
*/
|
||||
const dmnRow = (n: 1 | 2 | 3, cond: string, node: string, to: string, hit: string): string =>
|
||||
`<div class="jc-dmn-r r${n}">
|
||||
<span class="jc-dmn-i">${n}</span>
|
||||
<span class="jc-dmn-c">${cond}</span>
|
||||
<span class="jc-dmn-t"><code>${node}</code>${to}</span>
|
||||
<span class="jc-dmn-v"><b class="y">${ico('check', 13)}命中 · ${hit}</b><b class="n">未命中 · 排他网关只落一口</b></span>
|
||||
</div>`;
|
||||
|
||||
const G2 = `<div class="jc-dmn" data-class-of="case:routeKey">
|
||||
<div class="jc-dmn-h"><span>序</span><span>条件(按序求值)</span><span>去向</span><span>判定</span></div>
|
||||
${dmnRow(1, '存在 <b>C 类</b>(需替代选型)', 'A2', '替代选型', 'C 类 <b data-slot="quote_intake_record_type_tag_c"></b> 条')}
|
||||
${dmnRow(2, '无 C 类、存在 <b>B 类</b>(需寻源询价)', 'A3', '渠道寻源', 'B 类 <b data-slot="quote_intake_record_type_tag_b"></b> 条')}
|
||||
${dmnRow(3, '全部为 <b>A 类</b>(行情完备)', 'A4', '成本定价', 'A 类 <b data-slot="quote_intake_record_type_tag_a"></b> 条')}
|
||||
</div>
|
||||
<div class="jc-verdict" data-class-of="case:routeKey">
|
||||
<div class="jc-vd-l">
|
||||
<span class="jc-vd-k">本次判定 · 按序求值,首个命中即出</span>
|
||||
<div class="jc-vd-cond"><span data-slot="case:routeTo"></span></div>
|
||||
<p class="jc-vd-why">去向是<b>分类表算出来的</b>:三条规则按序求值、首个命中即出,
|
||||
在你签字那一刻现算。所以它不是谁点出来的一次手感 ——
|
||||
<b>要改去向,就在上面的清单里改料号的类,这一格会跟着变。</b></p>
|
||||
</div>
|
||||
<span class="jc-vd-arrow">${ico('arrow-right', 26)}</span>
|
||||
<div class="jc-vd-r">
|
||||
<span class="jc-vd-k">转入活动</span>
|
||||
<div class="jc-vd-node"><span data-slot="case:routeTo"></span></div>
|
||||
<span class="jc-vd-who"><i>${ico('user', 11)}</i>下游负责人按运营分派落定</span>
|
||||
<p class="jc-vd-next">放行即落地:草稿层合并进正式版、intake 口的数据事件此刻才发射,
|
||||
这一口的载荷随之交给下游。<b>门前的都还改得动,门后的都算数了。</b></p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export const INTAKE_RELEASE = doc({
|
||||
badge: 'file-check',
|
||||
kicker: '统一报价 · A1 受理归一 · 交付成果',
|
||||
title: '询价受理记录',
|
||||
titleRef: 'rfq_ref',
|
||||
/*
|
||||
* 题头这枚灯**在办与归档同一句话才算数**:这份件放行前嵌在放行卡上、放行后原样进
|
||||
* 「工作成果」。写「待放行」进了档案就成了谎,写「受理完成」在放行前就是替人签了字 ——
|
||||
* 所以只写这份件本身是什么:归一了几条、三分类已定。
|
||||
*/
|
||||
state: '<span class="jc-rp-wait">受理记录 · <b data-slot="line_total"></b> 条 · 三分类已定</span>',
|
||||
body:
|
||||
tableCard(
|
||||
'本单交付',
|
||||
'mine:quote_intake.record',
|
||||
[
|
||||
['型号', 'pn', 'mono'],
|
||||
['厂牌', 'brand'],
|
||||
['年用量', 'eau'],
|
||||
['目标价', 'target_price'],
|
||||
['交期', 'lead_time'],
|
||||
],
|
||||
'只列<b>本单</b>写下的行 —— 别单放行过的记录不混进来。',
|
||||
) +
|
||||
kvCard('分流去向', [
|
||||
['Type 标', 'type_tag', 'tag'],
|
||||
['依据', 'basis'],
|
||||
]) +
|
||||
noteCard(
|
||||
'放行意味着什么',
|
||||
'放行通过<b>即刻落地</b>:草稿层合并入正式版、数据事件此刻才发射,G2 网关按 Type 分流。' +
|
||||
'放行是草稿态与事实态之间的那道门 —— 门前的都还改得动,门后的都算数了。',
|
||||
),
|
||||
/*
|
||||
* **来件原文留在归档件里**(2026-08-02 用户指出:「成果里为啥没有邮箱原文了」)。
|
||||
*
|
||||
* 待处理与工作成果本来就是**同一份件**,差别只在编辑态开不开。
|
||||
* 归档件少一块来件,就等于把「凭什么这么受理」的出处从档案里抽走了 ——
|
||||
* 三个月后回看这一单,九条料是怎么来的、客户那句「本轮跳过」在哪儿说的,
|
||||
* 全都无从对起。件要自己说得清自己。
|
||||
*/
|
||||
part('来源 · 客户 RFQ 邮件', '<span data-slot="case:openedAt"></span> 入站', 'jc-mono') +
|
||||
LETTER +
|
||||
part('受理要素', 'F-01 完整度自检 · 通过', 'ok') +
|
||||
four('type') +
|
||||
part('料号分类', '三分类规则 P-03 · 逐条判定') +
|
||||
DIST +
|
||||
part('有效 PN 清单', '<b data-slot="line_total"></b> 条 · 已剔除 <b data-slot="skipped_total"></b>') +
|
||||
editGate('quote_intake.record', '有效 PN 清单') +
|
||||
listTable('final') +
|
||||
addRowBar('quote_intake.record') +
|
||||
part('结论与去向', '分支判定 · 起单 ×1', 'ok') +
|
||||
G2 +
|
||||
/*
|
||||
* ── 这里**去掉了两张请求单表与那条对账说明**(2026-08-02 用户当场指出)──────
|
||||
*
|
||||
* 原来摆的是「开出的替代评估请求(交 A2)」「开出的询价请求(交 A3)」两张明细表,
|
||||
* 外加一句「这两条等式在你签字前由系统当场对账,对不上直接拒收」。
|
||||
*
|
||||
* 两个理由:
|
||||
* · 那句话**已经不成立** —— `route-alt` / `route-rfq` 两条对账判据同日删掉了
|
||||
* (它们是内部一致性,该由引擎自己看住,不占放行卡)。件上还写着「系统当场对账」,
|
||||
* 就是在说一件不再发生的事;
|
||||
* · 这份件是**受理记录**:它答的是「这一单受理成了什么样」。派出去的两类单是
|
||||
* **下游的输入**,不是这一单的成果 —— 要看它们,去 A2 / A3 那两条流水线上看。
|
||||
* 摆在这儿只是把同一份数据在两处各画一遍。
|
||||
*
|
||||
* 「这一签下去开出了几张单」仍看得见:放行之后验收卡那一屏(落地结果)按表点名,
|
||||
* 询价请求 / 替代评估请求各几行都在上面 —— 那才是它该出现的时刻与位置。
|
||||
*/
|
||||
part('放行后即执行的落地动作', '门前 → 门后') +
|
||||
`<div class="jc-acts">
|
||||
<div class="jc-act"><i>${ico('check', 11)}</i>
|
||||
<div><b>草稿层合并进正式版</b> <span>本任务写下的受理记录、分类表、结论与两类请求单一并落账;
|
||||
人工补全与就地修订的那几处随之落定。</span></div>
|
||||
</div>
|
||||
<div class="jc-act"><i>${ico('check', 11)}</i>
|
||||
<div><b>受理结果此刻才对外发出</b> <span>连同
|
||||
<b data-slot="line_total"></b> 条有效料号与它们的分类一并交出 —— 放行之前,世界上什么都没有发生。</span></div>
|
||||
</div>
|
||||
<div class="jc-act"><i>${ico('check', 11)}</i>
|
||||
<div><b>按分类算出的去向落下游一单</b> <span>本次转入
|
||||
<b data-slot="case:routeTo"></b>,已进对方的任务清单;另两条去向本次不起单。</span></div>
|
||||
</div>
|
||||
</div>`,
|
||||
/*
|
||||
* ── 这里**去掉了「受理结论」那张表**(2026-08-02 用户当场指出:「这个也多余」)──
|
||||
*
|
||||
* 摆的是 `bom_total / skipped_total / skipped_note / summary` 四格,而
|
||||
* 前三格与同日删掉的那条「本轮不报」横幅说的是同一件事,
|
||||
* 第四格是一句 AI 复述的总结 —— 底下的清单、分布条、去向横幅把该说的都说了,
|
||||
* 再加一句转述只是让人多读一行。
|
||||
*
|
||||
* 一份件里同一个数出现两次,人得停下来对一遍两处一不一样 —— 那是白花的注意力。
|
||||
*/
|
||||
foot: sign('AI 拟稿 · 待审定放行 —— 要改哪一格,在这张放行单的成果表上就地改', '统一报价 · A1 受理归一 → 交下游'),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,616 @@
|
||||
/**
|
||||
* A6 报价议价的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。
|
||||
* 案例活动没有 `run`,自测不跑回合;测的是清单本身与四处对齐
|
||||
* (判据谓词 / 出口载荷与 `when` / 剧本声明 / 两把尺的档位)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { activityManifestSchema } from '@contracts';
|
||||
import { evaluateCondition, evaluateUnaryTest } from '@sfeel';
|
||||
import { createMockHost } from '@sdk';
|
||||
import { N01_DMN, manifest } from '../src/manifest.ts';
|
||||
import activity from '../src/index.ts';
|
||||
|
||||
describe('清单本身合规', () => {
|
||||
it('过 @contracts 的 schema', () => {
|
||||
expect(() => activityManifestSchema.parse(manifest)).not.toThrow();
|
||||
});
|
||||
|
||||
it('监听时限已配 —— 无悬空监听', () => {
|
||||
expect(manifest.declarations.listeners.length).toBeGreaterThan(0);
|
||||
expect(manifest.declarations.listeners.every((l) => /^P/.test(l.deadline))).toBe(true);
|
||||
});
|
||||
|
||||
it('L1/L2 判据都给了可机检的谓词 —— 验收等级不得谎报', () => {
|
||||
for (const c of manifest.contract.acceptanceCriteria) {
|
||||
if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef, c.key).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('禁区短而硬,授权边界给了调人预算', () => {
|
||||
expect(manifest.contract.prohibitions.length).toBeGreaterThan(0);
|
||||
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('每件必交付物都有台账地址', () => {
|
||||
for (const d of manifest.contract.requiredDeliverables) {
|
||||
expect(d.ledger, d.key).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **宽进严出**:起单一个必填都不设 —— 上游(A4 的 draft 口)发的是
|
||||
* `{rfqRef, customerCode, items}`,而上一版必填这三个,单飞那条路直接被门口拦掉。
|
||||
*/
|
||||
it('起单宽进:input.required 是空的,两种来路的键都声明着', () => {
|
||||
expect(manifest.contract.input.data['required']).toEqual([]);
|
||||
const properties = manifest.contract.input.data['properties'] as Record<string, unknown>;
|
||||
for (const key of ['rfqRef', 'customerCode', 'items', 'subject', 'body']) {
|
||||
expect(properties[key], `入参少了 ${key}`).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **客户表态是必填** —— 这是 A6 的门面判据:件上的缺表态红带、执行卡的提交闸、
|
||||
* 放行门 L1 三处同源(都数基座的 `_missing`)。声明丢了 required,三处一起失明。
|
||||
*/
|
||||
it('客户表态标了 required —— 红带 / 提交闸 / 放行门三处同源的那个数才存在', () => {
|
||||
const line = manifest.declarations.ledger.find((d) => d.object === 'line');
|
||||
expect(line?.fields.find((f) => f.name === 'reply_type')?.required).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* **客户还价用 `requiredWhen` 钉在还价那一行** —— 表级 required 会让接受 / 拒绝的行
|
||||
* 永远缺一格,把执行卡的闸拦成死锁(A2 逐颗派单踩过同一坑)。
|
||||
*/
|
||||
it('客户还价是行级必填:只有还价那一行才算缺', () => {
|
||||
const line = manifest.declarations.ledger.find((d) => d.object === 'line');
|
||||
const counter = line?.fields.find((f) => f.name === 'counter_price');
|
||||
expect(counter?.requiredWhen).toEqual({ column: 'reply_type', value: 'COUNTER' });
|
||||
expect(counter?.required, '整表必填会把接受/拒绝的行也拦住').toBeUndefined();
|
||||
});
|
||||
|
||||
/*
|
||||
* **客户那一侧的三列 AI 一格都不写**(结构治,不是措辞治):
|
||||
* 一条要模型分辨「这行能不能替客户填」的规矩会走岔;「这几列不写」没有分支可走岔。
|
||||
*/
|
||||
it('客户表态 / 还价 / 原话三列写死了「你一格都不写」', () => {
|
||||
const guide = manifest.assets.businessModel.steps?.find((s) => s.item === 'S1_compose')?.guide ?? '';
|
||||
expect(guide).toMatch(/reply_type[^\n]*counter_price[^\n]*reply_note|这三列你一格都不写/);
|
||||
expect(guide).toContain('一格都不写');
|
||||
});
|
||||
|
||||
it('表态、定案、让步处置三列都给了取值域 —— 条数由基座现数,不由活动自报', () => {
|
||||
const line = manifest.declarations.ledger.find((d) => d.object === 'line');
|
||||
expect(line?.fields.find((f) => f.name === 'reply_type')?.tally).toEqual(['ACCEPT', 'COUNTER', 'REJECT', 'NOREPLY']);
|
||||
expect(line?.fields.find((f) => f.name === 'deal_status')?.tally).toEqual(['WON', 'HAGGLE', 'REVISE', 'LOST']);
|
||||
expect(line?.fields.find((f) => f.name === 'concession')?.tally).toEqual(['ACCEPT_COUNTER', 'NEGOTIATE', 'ESCALATE']);
|
||||
});
|
||||
|
||||
/*
|
||||
* 我方写的注不标 `expect` —— 标了 `_gaps` 恒大于 0,读它的判据永远绿不了(A1 的账)。
|
||||
* 客户还价那一格由 `requiredWhen` 管,不再重复标 expect。
|
||||
*/
|
||||
it('没有一列滥标 expect —— `_gaps` 不是拿来常亮的', () => {
|
||||
const line = manifest.declarations.ledger.find((d) => d.object === 'line');
|
||||
expect((line?.fields ?? []).filter((f) => f.expect === true)).toEqual([]);
|
||||
});
|
||||
|
||||
it('三条常备案例形状真不同:流内一轮成交 / 客户压价 / 单飞超时', () => {
|
||||
expect(manifest.samples.length).toBe(3);
|
||||
const [flowIn, haggled, solo] = manifest.samples;
|
||||
expect((flowIn?.input as { items?: unknown[] }).items?.length).toBeGreaterThan(0);
|
||||
expect(flowIn?.expect?.ports).toEqual(['won']);
|
||||
expect(haggled?.expect?.ports).toEqual(['revise']);
|
||||
expect((solo?.input as { items?: unknown[] }).items).toBeUndefined();
|
||||
expect(solo?.expect?.ports).toEqual(['lost']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('案例模型 —— 双表示同版本', () => {
|
||||
const model = manifest.assets.businessModel;
|
||||
const cmmn = parseCmmnLite(model.cmmnXml);
|
||||
|
||||
it('CMMN 与说明书同在,不允许分叉', () => {
|
||||
expect(model.cmmnXml).toContain('<casePlanModel');
|
||||
expect(model.narrative.length).toBeGreaterThan(400);
|
||||
});
|
||||
|
||||
/* 照 MVP 示例工作流 §1.4 的 S1 发报留档 / S2 跟进议价 / S3 定案交接,一步一项 */
|
||||
it('计划项照 SOP 一步一项:排版 + 发出落回音 + 逐条定案 + 异议拍板 + 放行', () => {
|
||||
expect(items(model.cmmnXml)).toEqual(['S1_compose', 'S2_send', 'S3_settle', 'T_haggle', 'T_release']);
|
||||
});
|
||||
|
||||
it('三处人的门都标了人工激活:发出并等回音 · 异议拍板 · 结果放行(逐条定案是查表,不标)', () => {
|
||||
expect(cmmn.manualActivation).toEqual(['S2_send', 'T_haggle', 'T_release']);
|
||||
});
|
||||
|
||||
it('不用阶段 / 里程碑 / 裁量项', () => {
|
||||
for (const tag of ['<stage', '<milestone', '<planningTable', '<discretionaryItem']) {
|
||||
expect(model.cmmnXml, `不该出现 ${tag}`).not.toContain(tag);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **还价才叫人** —— 这个活动的要害:哨兵读基座按值现数的还价条数,
|
||||
* 而不是活动自报的 `haggle_total`;且要等回音落齐(客户直接接受或拒绝的单
|
||||
* 一个人都不必额外惊动)。
|
||||
*/
|
||||
it('异议拍板的哨兵读**客户自己填的那一格**,不读模型判出来的定案', () => {
|
||||
const condition = sentryIf(model.cmmnXml, 's_haggle');
|
||||
expect(condition).toContain('quote_negotiate_line_reply_type_counter > 0');
|
||||
expect(condition).toContain('replied_total');
|
||||
/*
|
||||
* **「该不该叫人」不能由被叫的那一方说了算**(2026-08-03 两轮实测):
|
||||
* 哨兵读 deal_status 时,模型在定案那一步直接写 REVISE、HAGGLE 一条不留,
|
||||
* 这道门整个不开 —— 单子 released 而决策卡 0 张,让步梯度成了摆设。
|
||||
*/
|
||||
expect(condition, '哨兵不该读模型判出来的定案').not.toContain('deal_status');
|
||||
// 客户还过价且回音落过 → 叫人
|
||||
expect(safeEval(condition, { quote_negotiate_line_reply_type_counter: 1, replied_total: 2 })).toBe(true);
|
||||
// 没人还价 → 不叫人(这一步整个不入场)
|
||||
expect(safeEval(condition, { quote_negotiate_line_reply_type_counter: 0, replied_total: 2 })).toBe(false);
|
||||
// 回音还没落 → 先别叫
|
||||
expect(safeEval(condition, { quote_negotiate_line_reply_type_counter: 1 })).toBe(false);
|
||||
// **模型自己把定案写成 REVISE 也绕不过去** —— 客户那一格还在那儿
|
||||
expect(
|
||||
safeEval(condition, {
|
||||
quote_negotiate_line_reply_type_counter: 1,
|
||||
quote_negotiate_line_deal_status_haggle: 0,
|
||||
quote_negotiate_line_deal_status_revise: 1,
|
||||
replied_total: 2,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* 入场哨兵也归「机检只读基座数」管(A1 第四次违反就藏在哨兵里)——
|
||||
* 读活动自报的条数,模型不记那个账,门就永不开,而它一步也没做错。
|
||||
*/
|
||||
it('交人发出的入场哨兵读基座行数,不读自报的报价条数', () => {
|
||||
const condition = sentryIf(model.cmmnXml, 's_composed');
|
||||
expect(condition).toContain('quote_negotiate_line_rows');
|
||||
expect(condition).not.toContain('quote_total');
|
||||
expect(safeEval(condition, { line_total: 2, quote_negotiate_line_rows: 2, composed_total: 2 })).toBe(true);
|
||||
// 行落了但排版那一轮还没走完(composed_total 没写过)→ 门不开
|
||||
expect(safeEval(condition, { line_total: 2, quote_negotiate_line_rows: 2 })).toBe(false);
|
||||
});
|
||||
|
||||
it('逐条定案的入场哨兵认「回音齐了」:人交回 + 基座数的缺项为 0', () => {
|
||||
const condition = sentryIf(model.cmmnXml, 's_replied');
|
||||
expect(condition).toContain('quote_negotiate_line_missing = 0');
|
||||
expect(safeEval(condition, { replied_total: 2, quote_negotiate_line_rows: 2, quote_negotiate_line_missing: 0 })).toBe(true);
|
||||
expect(safeEval(condition, { replied_total: 2, quote_negotiate_line_rows: 2, quote_negotiate_line_missing: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it('放行门哨兵带 ifPart:每条有表态 + 定案守恒 + 待拍板清零 + 定盘记过,全读基座数', () => {
|
||||
const condition = sentryIf(model.cmmnXml, 's_ready');
|
||||
expect(condition).toContain('quote_negotiate_line_missing = 0');
|
||||
expect(condition).toContain('quote_negotiate_line_deal_status_haggle = 0');
|
||||
const done = {
|
||||
route_decided_total: 1,
|
||||
quote_negotiate_line_rows: 3,
|
||||
quote_negotiate_line_missing: 0,
|
||||
quote_negotiate_line_deal_status_won: 2,
|
||||
quote_negotiate_line_deal_status_haggle: 0,
|
||||
quote_negotiate_line_deal_status_revise: 0,
|
||||
quote_negotiate_line_deal_status_lost: 1,
|
||||
};
|
||||
expect(safeEval(condition, done)).toBe(true);
|
||||
// 还有一行缺客户表态 → 门不开
|
||||
expect(safeEval(condition, { ...done, quote_negotiate_line_missing: 1 })).toBe(false);
|
||||
// 定案没记齐(守恒不成立)→ 门不开
|
||||
expect(safeEval(condition, { ...done, quote_negotiate_line_deal_status_lost: 0 })).toBe(false);
|
||||
/*
|
||||
* **还悬着的还价放不了行**(与 A4 的「拦截档必须清零」同一形状):
|
||||
* HAGGLE 是过渡态,拍板那一下必须把它换成 WON / REVISE / LOST。
|
||||
*/
|
||||
expect(
|
||||
safeEval(condition, {
|
||||
...done,
|
||||
quote_negotiate_line_deal_status_won: 1,
|
||||
quote_negotiate_line_deal_status_haggle: 1,
|
||||
quote_negotiate_line_deal_status_lost: 1,
|
||||
}),
|
||||
).toBe(false);
|
||||
// 退回重批是结论,不是悬着 —— 照样放得了行
|
||||
expect(
|
||||
safeEval(condition, {
|
||||
...done,
|
||||
quote_negotiate_line_deal_status_won: 1,
|
||||
quote_negotiate_line_deal_status_revise: 1,
|
||||
quote_negotiate_line_deal_status_lost: 1,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('每个计划项都有剧本', () => {
|
||||
const ids = items(model.cmmnXml);
|
||||
expect((model.steps ?? []).map((s) => s.item)).toEqual(ids);
|
||||
for (const step of model.steps ?? []) {
|
||||
expect(step.guide.length, `${step.item} 的做法`).toBeGreaterThan(80);
|
||||
expect(step.intent.length, `${step.item} 的意图`).toBeGreaterThan(6);
|
||||
}
|
||||
});
|
||||
|
||||
it('说明书不点名计划项', () => {
|
||||
const named = [...model.narrative.matchAll(/\b(?:S\d+_[A-Za-z]+|T_[a-z]+)\b/g)].map((m) => m[0]);
|
||||
const ids = new Set(items(model.cmmnXml));
|
||||
for (const name of named) {
|
||||
expect(ids.has(name), `说明书提到的 ${name} 不在案例模型里`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('说明书只讲整单的事:成品、怎么说话、怎么收口', () => {
|
||||
for (const topic of ['开案', '要说话', '一拍连做', '结案']) {
|
||||
expect(model.narrative, topic).toContain(topic);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **决策卡那三处写死的东西也进说明书**(2026-08-03 实测逼出来的):
|
||||
* 当下可为为空那几拍(依赖断点)引擎只说「请调人」,而拍板那一步的 guide
|
||||
* 根本没摊开 —— 模型手上只剩计划项的中文名与自己的想象:
|
||||
* 一轮把中文名当 itemId(连拒两拍),一轮换了问法于是同一个岔路口发出去两张卡。
|
||||
* **说明书每一拍都在提示词里**,写死在这儿才是每一拍都看得见。
|
||||
*/
|
||||
it('说明书写死了那一张决策卡的 itemId / ask / 三个 value —— 断点那几拍只剩它', () => {
|
||||
expect(model.narrative).toContain('"itemId":"T_haggle"');
|
||||
expect(model.narrative).toContain('按哪条路办');
|
||||
for (const value of ['accept-counter', 'revise', 'hold']) {
|
||||
expect(model.narrative, `说明书少了选项 ${value}`).toContain(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('声明与契约对得上', () => {
|
||||
const columns = new Set(
|
||||
manifest.declarations.ledger.flatMap((d) => [`${d.domain}.${d.object}`, ...d.fields.map((f) => f.name)]),
|
||||
);
|
||||
|
||||
it('必交付物的台账地址都声明过', () => {
|
||||
for (const d of manifest.contract.requiredDeliverables) {
|
||||
expect(columns.has(d.ledger ?? ''), `${d.key} → ${d.ledger}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('editableAtRelease 的列都在成果表的声明里', () => {
|
||||
const line = manifest.declarations.ledger.find((d) => d.object === 'line');
|
||||
const names = new Set((line?.fields ?? []).map((f) => f.name));
|
||||
for (const d of manifest.contract.requiredDeliverables) {
|
||||
for (const col of d.editableAtRelease ?? []) {
|
||||
expect(names.has(col), `editableAtRelease 里的 ${col}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const rowReadings = new Set([
|
||||
...manifest.declarations.ledger.map((d) => `${d.domain}_${d.object}_rows`),
|
||||
...manifest.declarations.ledger
|
||||
.filter((d) => d.fields.some((f) => f.required === true || f.requiredWhen !== undefined))
|
||||
.map((d) => `${d.domain}_${d.object}_missing`),
|
||||
...manifest.declarations.ledger.flatMap((d) =>
|
||||
d.fields.flatMap((f) =>
|
||||
(f.tally ?? []).map((v) => `${d.domain}_${d.object}_${f.name}_${v.toLowerCase().replace(/[^a-z0-9_]/g, '')}`),
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
it('L1/L2 谓词读的名字都有承载(台账列或基座读数)', () => {
|
||||
for (const c of manifest.contract.acceptanceCriteria) {
|
||||
if (c.level !== 'L1' && c.level !== 'L2') continue;
|
||||
for (const identifier of (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? []) {
|
||||
if (['and', 'or', 'not', 'true', 'false', 'null', 'in', 'between'].includes(identifier)) continue;
|
||||
if (rowReadings.has(identifier)) continue;
|
||||
expect(columns.has(identifier), `${c.key} 的谓词引用了 ${identifier}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **机检必须至少有一边是基座数**(违反史已记五次,每次都是同一个死法:
|
||||
* 那个数要模型自己重算,漏算/改小一次门就废了)。
|
||||
*/
|
||||
it('每条 L1/L2 都至少读一个基座读数 —— 不许两边都由活动自报', () => {
|
||||
for (const c of manifest.contract.acceptanceCriteria) {
|
||||
if (c.level !== 'L1' && c.level !== 'L2') continue;
|
||||
const names = (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? [];
|
||||
expect(names.some((n) => rowReadings.has(n)), `${c.key} 一个基座读数都没读`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **三口的去向是算出来的**:每一口都得写 `when`,少一口基座就退回「人选一口」——
|
||||
* 而选择组恰选一口是这个活动的全部业务分支(E11/E12/E13 读的就是它)。
|
||||
*/
|
||||
it('三个出口对齐流程包 E11/E12/E13,且每一口都带 when', () => {
|
||||
const group = manifest.contract.output.selective[0];
|
||||
expect(group?.group).toBe('negotiation');
|
||||
expect(group?.ports.map((p) => p.name)).toEqual(['revise', 'won', 'lost']);
|
||||
for (const port of group?.ports ?? []) {
|
||||
expect(port.when, `${port.name} 少了 when —— 少一口整组就退回人选`).toBeDefined();
|
||||
expect(port.when).toContain('quote_negotiate_line_deal_status_');
|
||||
expect(port.when, `${port.name} 的 when 不该读过渡态 HAGGLE`).not.toContain('_haggle');
|
||||
}
|
||||
});
|
||||
|
||||
it('去向按序求值:还价优先退回重批,其次成交转履约,都没成转归档', () => {
|
||||
const ports = manifest.contract.output.selective[0]?.ports ?? [];
|
||||
const pick = (scope: Record<string, unknown>): string | undefined =>
|
||||
ports.find((p) => safeEval(p.when ?? '', scope))?.name;
|
||||
expect(pick({ quote_negotiate_line_deal_status_revise: 1, quote_negotiate_line_deal_status_won: 1, quote_negotiate_line_deal_status_lost: 0 })).toBe('revise');
|
||||
expect(pick({ quote_negotiate_line_deal_status_revise: 0, quote_negotiate_line_deal_status_won: 2, quote_negotiate_line_deal_status_lost: 1 })).toBe('won');
|
||||
expect(pick({ quote_negotiate_line_deal_status_revise: 0, quote_negotiate_line_deal_status_won: 0, quote_negotiate_line_deal_status_lost: 2 })).toBe('lost');
|
||||
});
|
||||
|
||||
/* 下游 A7 的入参要 rfqRef / customerCode / quoteNo —— 不成交那一口必须带齐 */
|
||||
it('不成交那一口带齐 A7 要的三个键', () => {
|
||||
const lost = manifest.contract.output.selective[0]?.ports.find((p) => p.name === 'lost');
|
||||
const required = (lost?.payload as { required?: string[] }).required ?? [];
|
||||
for (const key of ['rfqRef', 'customerCode', 'quoteNo']) expect(required, `lost 口少了 ${key}`).toContain(key);
|
||||
});
|
||||
|
||||
it('出口载荷的标量字段都有台账列承载', () => {
|
||||
const snake = (name: string) => name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
|
||||
const ports = [
|
||||
...manifest.contract.output.mandatory,
|
||||
...manifest.contract.output.selective.flatMap((g) => g.ports),
|
||||
];
|
||||
for (const port of ports) {
|
||||
const properties = (port.payload as { properties?: Record<string, { type?: string }> }).properties ?? {};
|
||||
for (const [field, spec] of Object.entries(properties)) {
|
||||
if (spec.type === 'array') continue;
|
||||
expect(columns.has(snake(field)), `${port.name}.${field} → ${snake(field)}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('技能引用的工具都在白名单声明内 —— 载方法不载权力', () => {
|
||||
const declared = new Set(manifest.declarations.tools.map((t) => t.name));
|
||||
for (const skill of manifest.assets.skills) {
|
||||
for (const fragment of skill.crystallized) {
|
||||
expect(declared.has(fragment.tool), `${skill.id} 用了 ${fragment.tool}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('L3 判据的整句出现在放行剧本里 —— judge 才对得上名', () => {
|
||||
const l3 = manifest.contract.acceptanceCriteria.find((c) => c.level === 'L3');
|
||||
const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
|
||||
expect(l3).toBeDefined();
|
||||
expect(release?.guide, 'T_release 的 guide 要整句抄 L3 判据名').toContain(l3?.label ?? '');
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* 两把尺逐行求值 —— 能算的不问 LLM(规范 §5.6)。
|
||||
* 出参就是台账 `deal_status` / `concession` 的取值:模型算完照抄,不必再翻译一道。
|
||||
*/
|
||||
describe('议价结果 N-01 —— 客户表态 → 定案', () => {
|
||||
const table = N01_DMN.tables.find((t) => t.id === 'n01-outcome');
|
||||
|
||||
it('客户接受 → WON 成交', () => {
|
||||
expect(decideBy(table, { reply_type: 'ACCEPT' })).toMatchObject({ outputs: ['WON'] });
|
||||
});
|
||||
|
||||
it('客户还价 → HAGGLE 压在还价上,要人拍板', () => {
|
||||
expect(decideBy(table, { reply_type: 'COUNTER' })).toMatchObject({ rule: 'n01-2', outputs: ['HAGGLE'] });
|
||||
});
|
||||
|
||||
it('客户拒绝与超时无回复都 → LOST(两条路一个结果,缘由记在备注)', () => {
|
||||
expect(decideBy(table, { reply_type: 'REJECT' })).toMatchObject({ outputs: ['LOST'] });
|
||||
expect(decideBy(table, { reply_type: 'NOREPLY' })).toMatchObject({ outputs: ['LOST'] });
|
||||
});
|
||||
|
||||
it('**认不出的表态按不成交处置** —— 兜底不许猜成接受', () => {
|
||||
expect(decideBy(table, { reply_type: '客户说再看看' })).toMatchObject({ rule: 'n01-default', outputs: ['LOST'] });
|
||||
});
|
||||
|
||||
/*
|
||||
* 这把尺只出三个码:**REVISE 不在其中** —— 「退回重批」是人在决策卡上拍的,
|
||||
* 判定表算不出来。所以是子集关系,不是等号;而多出来的那一个恰好只有一个,
|
||||
* 就是拍板那条路(测这一条,免得哪天有人往 tally 里塞一个没人写得出的码)。
|
||||
*/
|
||||
it('尺出的定案码都在台账取值域里,只有「退回重批」由人拍板补上', () => {
|
||||
const tally = manifest.declarations.ledger
|
||||
.find((d) => d.object === 'line')
|
||||
?.fields.find((f) => f.name === 'deal_status')?.tally ?? [];
|
||||
const fromTable = new Set(table?.rules.map((r) => String(r.then[0])));
|
||||
for (const code of fromTable) expect(tally, `尺出了台账没有的码 ${code}`).toContain(code);
|
||||
expect([...tally].filter((v) => !fromTable.has(v))).toEqual(['REVISE']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('让步梯度 N-02 —— 还价幅度 → 让不让、谁来让', () => {
|
||||
const table = N01_DMN.tables.find((t) => t.id === 'n02-concession');
|
||||
|
||||
it('三个点以内 → 接了拿单', () => {
|
||||
expect(decideBy(table, { gap: 0.02 })).toMatchObject({ outputs: ['ACCEPT_COUNTER', true] });
|
||||
});
|
||||
|
||||
it('三到十个点 → 授权内回旋(FIRST 命中,第二行就是这一档)', () => {
|
||||
expect(decideBy(table, { gap: 0.08 })).toMatchObject({ rule: 'n02-2', outputs: ['NEGOTIATE', true] });
|
||||
});
|
||||
|
||||
it('超过一成 → 超出授权,退回成本定价重批', () => {
|
||||
expect(decideBy(table, { gap: 0.204 })).toMatchObject({ outputs: ['ESCALATE', false] });
|
||||
});
|
||||
|
||||
it('三个处置码与台账取值域逐字一致', () => {
|
||||
const tally = manifest.declarations.ledger
|
||||
.find((d) => d.object === 'line')
|
||||
?.fields.find((f) => f.name === 'concession')?.tally;
|
||||
expect(new Set(table?.rules.map((r) => String(r.then[0])))).toEqual(new Set(tally));
|
||||
});
|
||||
});
|
||||
|
||||
describe('两把尺的档位三处同一份 —— 判定表 / 剧本 / 工作单', () => {
|
||||
const settle = manifest.assets.businessModel.steps?.find((s) => s.item === 'S3_settle')?.guide ?? '';
|
||||
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-worksheet')?.html ?? '';
|
||||
|
||||
it('四个表态码剧本与工作单都写着(大写,一个字母不许差)', () => {
|
||||
for (const code of ['ACCEPT', 'COUNTER', 'REJECT', 'NOREPLY']) {
|
||||
expect(settle, `剧本少了 ${code}`).toContain(code);
|
||||
expect(sheet, `工作单少了 ${code}`).toContain(code);
|
||||
}
|
||||
});
|
||||
|
||||
it('三个定案码与三个让步处置码剧本里写死', () => {
|
||||
for (const code of ['WON', 'HAGGLE', 'REVISE', 'LOST', 'ACCEPT_COUNTER', 'NEGOTIATE', 'ESCALATE']) {
|
||||
expect(settle, `剧本少了 ${code}`).toContain(code);
|
||||
}
|
||||
});
|
||||
|
||||
it('让步梯度的三档门槛剧本与工作单说的是同一组数', () => {
|
||||
for (const threshold of ['0.03', '0.10']) expect(settle, `剧本少了 ${threshold}`).toContain(threshold);
|
||||
for (const band of ['≤ 3%', '3% ~ 10%', '10%']) expect(sheet, `工作单少了 ${band}`).toContain(band);
|
||||
});
|
||||
});
|
||||
|
||||
describe('报告模版 —— 活动持有,容器无脚本执行权', () => {
|
||||
it('两个局面各一张:议价工作单(干活)与成交定案清单(收口)', () => {
|
||||
expect(manifest.assets.viewTemplates.map((t) => t.name)).toEqual(['negotiate-worksheet', 'negotiate-roster']);
|
||||
});
|
||||
|
||||
it('剧本每一步的 view 都在模版名单里 —— 换不到画面屏上就不动', () => {
|
||||
const names = new Set(manifest.assets.viewTemplates.map((t) => t.name));
|
||||
for (const step of manifest.assets.businessModel.steps ?? []) {
|
||||
expect(names.has(step.view ?? ''), `${step.item} 的 view=${step.view}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('放行那一步摆的是验收目标件 —— 最后摆上去的那张就是归档件', () => {
|
||||
const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
|
||||
expect(release?.view).toBe('negotiate-roster');
|
||||
});
|
||||
|
||||
it('模版里没有脚本、没有事件处理器、没有外链 —— 样式自带', () => {
|
||||
for (const template of manifest.assets.viewTemplates) {
|
||||
expect(template.html, template.name).not.toMatch(/<script|\son[a-z]+=|https?:\/\//i);
|
||||
expect(template.html, template.name).toContain('<style>');
|
||||
}
|
||||
});
|
||||
|
||||
it('议价工作单是干活的件:逐颗档案 + 两把尺 + 填回工作表', () => {
|
||||
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-worksheet')?.html ?? '';
|
||||
expect(sheet).toContain('jc-selcard');
|
||||
expect(sheet).toContain('议价结果 N-01');
|
||||
expect(sheet).toContain('让步梯度 N-02');
|
||||
expect(sheet).toContain('data-edit="quote_negotiate.line"');
|
||||
});
|
||||
|
||||
it('缺表态红带与提交闸同源 —— 件上摆的是基座的 `_missing` 与主键名单', () => {
|
||||
for (const name of ['negotiate-worksheet', 'negotiate-roster']) {
|
||||
const html = manifest.assets.viewTemplates.find((t) => t.name === name)?.html ?? '';
|
||||
expect(html, name).toContain('quote_negotiate_line_missing');
|
||||
expect(html, name).toContain('quote_negotiate_line_missing_keys');
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* 编辑面精确等于人在那个局面的职权面 + **行的钥匙 pn**:
|
||||
* 主键格不挂 `data-col`,服务端按「全表一致列」补键,两行会对到同一行上
|
||||
* (A2 浏览器走查逮过,e2e 走 API 自带主键测不出来)。
|
||||
*
|
||||
* 两张件的编辑面**故意不同**:干活那张收客户的话,收口那张只让改成交价与原话
|
||||
* (= `editableAtRelease`)—— 不同工作各有各的件。
|
||||
*/
|
||||
it('工作单的编辑面 = 客户表态 + 客户还价 + 客户原话 + 行的钥匙 pn', () => {
|
||||
const html = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-worksheet')?.html ?? '';
|
||||
const cols = [...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]);
|
||||
expect(new Set(cols)).toEqual(new Set(['pn', 'reply_type', 'counter_price', 'reply_note']));
|
||||
});
|
||||
|
||||
it('收口件的编辑面 = editableAtRelease 那两列 + 行的钥匙 pn', () => {
|
||||
const html = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-roster')?.html ?? '';
|
||||
const cols = new Set([...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]));
|
||||
const editable = manifest.contract.requiredDeliverables[0]?.editableAtRelease ?? [];
|
||||
expect(cols).toEqual(new Set(['pn', ...editable]));
|
||||
});
|
||||
|
||||
it('定案分布读的是基座按值现数的三段 —— 守恒等式的可视形', () => {
|
||||
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-roster')?.html ?? '';
|
||||
for (const code of ['won', 'haggle', 'revise', 'lost']) {
|
||||
expect(roster).toContain(`quote_negotiate_line_deal_status_${code}`);
|
||||
}
|
||||
});
|
||||
|
||||
/* 三口活动:收口件摆去向判定,由基座在放行那一刻求值后投影(件不算不判) */
|
||||
it('收口件摆落边图,去向由 case:route* 投影', () => {
|
||||
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-roster')?.html ?? '';
|
||||
expect(roster).toContain('data-class-of="case:routeKey"');
|
||||
expect(roster).toContain('case:routeTo');
|
||||
expect(roster).toContain('case:routeWhy');
|
||||
});
|
||||
|
||||
it('模版只做取值 —— data-class-of 不以 data-slot 为前提(纯色块曾整条静默失色)', () => {
|
||||
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-worksheet')?.html ?? '';
|
||||
expect(sheet).toContain('<i data-class-of="deal_status"></i>');
|
||||
});
|
||||
|
||||
it('宿主拒推含脚本的模版', async () => {
|
||||
const host = createMockHost({ contract: CONTRACT });
|
||||
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
|
||||
});
|
||||
});
|
||||
|
||||
const CONTRACT = {
|
||||
activityId: 'quote-negotiate',
|
||||
version: '1.0.0',
|
||||
data: { rfqRef: 'RFQ MD14.1', items: [] },
|
||||
message: undefined,
|
||||
outputPorts: { mandatory: [], selective: ['revise', 'won', 'lost'] },
|
||||
prohibitions: [],
|
||||
discretion: [],
|
||||
} as const;
|
||||
|
||||
describe('代码入口 —— 案例活动没有 run,不是残包', () => {
|
||||
it('默认导出是一个有名字的活动定义', () => {
|
||||
expect(activity.name).toBe('quote-negotiate');
|
||||
});
|
||||
|
||||
it('mock 宿主如实报「没有 run」,不静默返回一个假的 outcome', async () => {
|
||||
const host = createMockHost({ contract: CONTRACT });
|
||||
await expect(host.run(activity)).rejects.toThrow(/没有 run/);
|
||||
});
|
||||
});
|
||||
|
||||
/** 按 DMN 语义逐行求值:FIRST 命中即定,单元格谓词复用 @sfeel */
|
||||
function decideBy(
|
||||
table: (typeof N01_DMN)['tables'][number] | undefined,
|
||||
scope: Record<string, unknown>,
|
||||
): { outputs: readonly unknown[]; rule: string } | undefined {
|
||||
if (table === undefined) return undefined;
|
||||
for (const rule of table.rules) {
|
||||
const hit = table.inputs.every((input, i) => evaluateUnaryTest(rule.when[i] ?? '-', scope[input.expression], scope));
|
||||
if (hit) return { outputs: rule.then, rule: rule.id };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function items(xml: string): string[] {
|
||||
return [...xml.matchAll(/<(?:humanTask|decisionTask|processTask|caseTask) id="([^"]+)"/g)].map((m) => m[1] ?? '');
|
||||
}
|
||||
|
||||
function parseCmmnLite(xml: string): { manualActivation: string[]; repetition: string[] } {
|
||||
const manualActivation: string[] = [];
|
||||
const repetition: string[] = [];
|
||||
for (const m of xml.matchAll(/<humanTask id="([^"]+)"[\s\S]*?<\/humanTask>/g)) {
|
||||
if (m[0].includes('manualActivationRule')) manualActivation.push(m[1] ?? '');
|
||||
if (m[0].includes('repetitionRule')) repetition.push(m[1] ?? '');
|
||||
}
|
||||
return { manualActivation, repetition };
|
||||
}
|
||||
|
||||
function sentryIf(xml: string, sentryId: string): string {
|
||||
const block = xml.slice(xml.indexOf(`<sentry id="${sentryId}"`));
|
||||
const raw = /<condition>([\s\S]*?)<\/condition>/.exec(block.slice(0, block.indexOf('</sentry>')))?.[1] ?? '';
|
||||
return raw.replaceAll('>', '>').replaceAll('<', '<').replaceAll('&', '&');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,77 +1,441 @@
|
||||
/**
|
||||
* A6 报价议价的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
|
||||
*
|
||||
* 2026-07-29 由步序声明(flow)改写而来。发出报价是**触达外界**的一下,
|
||||
* 恒需人工激活(AM 4.1 原理一);客户回音是外部事件,靠 userEventListener 等,
|
||||
* 不靠轮询。三个去向(成交 / 有异议 / 拒绝)由验收放行时的选择组给出。
|
||||
* 业务出处:`docs/MVP示例工作流.html` §1.4(S1 发报留档 → S2 跟进议价 → S3 定案交接),
|
||||
* 按 A4 同一条形状建模(2026-08-03 重写 @3.0.0):
|
||||
*
|
||||
* 上游(A5 批准的报价草案,A4 `draft` 口的载荷 `{rfqRef, customerCode, items}`)进来 →
|
||||
* 归一成**正式报价函**底表(逐条条款齐备 + 按 P-04 注明有效期)→ **一张执行单**
|
||||
* 交 BD:把报价函发给客户,回音回来后逐条落单(客户表态那一列 AI 一格都不写)→
|
||||
* 按议价结果 N-01 逐条定案(接受 / 还价 / 不成交)→ **只有还价那几条才叫人拍板**
|
||||
* (决策卡:照客户价成交 / 退回定价重批 / 守住现价)→ 放行,去向由三口的谓词算出来。
|
||||
*
|
||||
* 上一版(@2.1.0)四处不合现行口径,这一版整个置换:
|
||||
* ① 入参必填 `rfqRef + customerCode + items` —— 而宽进严出是全线口径,归一在活动内做;
|
||||
* ② 三条 L1/L2 判据读的是活动自报的 `missing_field_total` / `validity_days` /
|
||||
* `followup_total` —— 「机检只读基座数得出来的数」违反史第六类;
|
||||
* ③ 台账一个 `required` 都没有 —— 执行卡的闸没牙,人不填也能交卷;
|
||||
* ④ 三个出口没有 `when`:往哪走要模型自己选,而**去向该是算出来的**。
|
||||
*
|
||||
* 【为什么没有 `listen` 动作】等客户回音这一段落在**执行工单**上,不落 `listen`:
|
||||
* `listen` 在引擎里恒挂起(status: 'awaiting'),要外部事件才唤得醒;而这一单里
|
||||
* 「客户回了什么」的落单人本来就是 BD(他从自己的邮箱收信,再把结论录进系统)——
|
||||
* MVP 示例工作流 §1.4 的 S1「生成发出」与 S2「记录反馈」写的也都是**人工任务**。
|
||||
* 时限那一侧照旧硬约束:`declarations.listeners` 三条静态监听(D+3 催 / D+7 到期 /
|
||||
* 客户回音)由基座在开案那一刻注册,时限必配 —— 「无来源又无时限的等待」一条都没有。
|
||||
*/
|
||||
|
||||
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
|
||||
<case id="negotiate">
|
||||
<!--
|
||||
案卷项的 id **就是任务视图里的键名**。上游载荷(items)与本活动台账列都摊平到
|
||||
任务视图顶层,照它们命名;quote_negotiate_* 那几个是**基座读数**
|
||||
(行数 / 缺必填 / 按值现数),由基座现算,模型碰不着。
|
||||
-->
|
||||
<caseFileModel>
|
||||
<caseFileItem id="draft" name="报价草案"/>
|
||||
<caseFileItem id="quote" name="正式报价单"/>
|
||||
<caseFileItem id="reply" name="客户回音"/>
|
||||
<caseFileItem id="items" name="上游报价草案条目(已批准,流内开案即有)"/>
|
||||
<caseFileItem id="line_total" name="报价函条数(自报)"/>
|
||||
<caseFileItem id="composed_total" name="已排版落表的条数(排版走完的凭据)"/>
|
||||
<caseFileItem id="replied_total" name="已落客户回音的条数(人环走完的凭据)"/>
|
||||
<caseFileItem id="route_decided_total" name="已定案"/>
|
||||
<caseFileItem id="quote_negotiate_line_rows" name="报价函行数(基座现数)"/>
|
||||
<caseFileItem id="quote_negotiate_line_missing" name="缺客户表态的行数(基座现数)"/>
|
||||
<caseFileItem id="quote_negotiate_line_deal_status_haggle" name="还价了但还没拍板的条数(基座现数)"/>
|
||||
<caseFileItem id="quote_negotiate_line_reply_type_counter" name="客户还了价的条数(基座现数 —— 客户自己那一格)"/>
|
||||
</caseFileModel>
|
||||
<casePlanModel id="plan" name="报价议价" autoComplete="true">
|
||||
<sentry id="s_draft">
|
||||
<onPart id="op_df" caseFileItemRef="draft"><standardEvent>create</standardEvent></onPart>
|
||||
<!--
|
||||
① 排版完了 —— 交 BD 发出那一步的入场前提。
|
||||
行数两边对上(自报 = 基座现数),且排版那一轮走完(composed_total 是排完之后
|
||||
显式写下的数;「大于等于 0」对「写过的 0」为真、对「没写过」为假)。
|
||||
-->
|
||||
<sentry id="s_composed">
|
||||
<onPart id="op_cp" caseFileItemRef="composed_total"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_cp"><condition>line_total > 0 and quote_negotiate_line_rows = line_total and composed_total >= 0</condition></ifPart>
|
||||
</sentry>
|
||||
<sentry id="s_quote_ready">
|
||||
<onPart id="op_qr" caseFileItemRef="quote"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_qr"><condition>missingQuoteFields = 0</condition></ifPart>
|
||||
<!--
|
||||
② 回音齐了 —— 逐条定案那一步的入场前提。
|
||||
replied_total 只在人交回执行单之后才产生,它就是「经过人了」的凭据;
|
||||
缺表态的行数由基座按 required 现数(与执行卡的提交闸、放行门 L1 同一个数)。
|
||||
-->
|
||||
<sentry id="s_replied">
|
||||
<onPart id="op_rp" caseFileItemRef="replied_total"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_rp"><condition>replied_total > 0 and quote_negotiate_line_rows > 0 and quote_negotiate_line_missing = 0</condition></ifPart>
|
||||
</sentry>
|
||||
<sentry id="s_sent">
|
||||
<onPart id="op_st" caseFileItemRef="quote"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_st"><condition>sent = true</condition></ifPart>
|
||||
<!--
|
||||
③ **还价才叫人** —— 这个活动的要害(与 A4「超限才叫人」同一形状)。
|
||||
|
||||
它读的是**客户自己那一格**(reply_type = COUNTER 的条数,基座按取值域现数),
|
||||
不是模型判出来的 deal_status。两版都栽过,病根是同一个 ——
|
||||
**「该不该叫人」不能由被叫的那一方说了算**:
|
||||
· 一版读 deal_status = HAGGLE,而 revise 那条路让「定案不动」,人点完了那个数
|
||||
还在,哨兵照旧成立,模型读同一段做法就再发一张卡(同一张连发六次);
|
||||
· 修成「拍板必须清 HAGGLE」之后,模型改在定案那一步**直接写 REVISE** ——
|
||||
HAGGLE 一条没有,这道门整个不开,单子一路 released 而没人拍过板
|
||||
(实测:决策卡 0 张,让步梯度成了摆设)。
|
||||
现在钉在客户填的那一格上:客户还了价,这一步就必须入场,模型绕不过去。
|
||||
-->
|
||||
<sentry id="s_haggle">
|
||||
<onPart id="op_hg" caseFileItemRef="quote_negotiate_line_reply_type_counter"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_hg"><condition>quote_negotiate_line_reply_type_counter > 0 and replied_total > 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 > 0 and quote_negotiate_line_rows > 0 and quote_negotiate_line_missing = 0 and quote_negotiate_line_deal_status_haggle = 0 and quote_negotiate_line_deal_status_won + quote_negotiate_line_deal_status_haggle + quote_negotiate_line_deal_status_revise + quote_negotiate_line_deal_status_lost = quote_negotiate_line_rows</condition></ifPart>
|
||||
</sentry>
|
||||
|
||||
<stage id="S1" name="成单发出" autoComplete="true">
|
||||
<entryCriterion id="ec_s1" sentryRef="s_draft"/>
|
||||
<humanTask id="T_compose" name="人工生成正式报价单">
|
||||
<itemControl><manualActivationRule id="m_cp"/><repetitionRule id="r_cp"/></itemControl>
|
||||
<!-- ① 归一报价函:逐条条款齐备 + 按 P-04 注明有效期(能算的不问人) -->
|
||||
<humanTask id="S1_compose" name="落正式报价函:逐条条款齐备,按报价有效期注明到期日">
|
||||
<itemControl><repetitionRule id="r_s1"/></itemControl>
|
||||
</humanTask>
|
||||
<humanTask id="T_send" name="人工发出报价单">
|
||||
<entryCriterion id="ec_send" sentryRef="s_quote_ready"/>
|
||||
<itemControl><manualActivationRule id="m_sd"/></itemControl>
|
||||
</humanTask>
|
||||
</stage>
|
||||
<milestone id="M_sent" name="报价已发出"/>
|
||||
|
||||
<stage id="S2" name="跟进议价" autoComplete="true">
|
||||
<entryCriterion id="ec_s2" sentryRef="s_sent"/>
|
||||
<humanTask id="T_followup" name="人工按 D+3/D+7 跟进">
|
||||
<itemControl><manualActivationRule id="m_fu"/><repetitionRule id="r_fu"/></itemControl>
|
||||
<!--
|
||||
② 发出去并把回音落回来 —— **一张执行单**(触达客户的那一下由人做)。
|
||||
manualActivation + 不给 doneWhen(发卡触达真人,引擎不替人按)。
|
||||
-->
|
||||
<humanTask id="S2_send" name="发出报价函并落客户回音:工作单上逐条落表态">
|
||||
<entryCriterion id="ec_s2" sentryRef="s_composed"/>
|
||||
<itemControl><manualActivationRule id="m_s2"/></itemControl>
|
||||
</humanTask>
|
||||
<planningTable id="pt_s2">
|
||||
<humanTask id="T_revise" name="人工议价调整"/>
|
||||
</planningTable>
|
||||
</stage>
|
||||
|
||||
<humanTask id="T_release" name="人工议价结果验收放行">
|
||||
<!--
|
||||
③ 逐条定案 —— SOP 里的 S3,一件独立的事:按客户表态判成交 / 还价 / 不成交,
|
||||
还价的再按让步梯度 N-02 给出处置建议。它不触达世界,所以给 doneWhen,
|
||||
引擎自己收 —— 从前这一段藏在「等回音」的收尾里,模型写完 replied_total
|
||||
就把那一项收了,定案一格没写,放行门当场判「定案没落定」而拒回只会说
|
||||
「数不对/行不对」——两扇打不开的门(A4 实测 8 次拒回熔断)。
|
||||
-->
|
||||
<humanTask id="S3_settle" name="逐条定案:按客户表态判成交/待拍板/不成交,还价的给让步处置">
|
||||
<entryCriterion id="ec_s3" sentryRef="s_replied"/>
|
||||
<itemControl><repetitionRule id="r_s3"/></itemControl>
|
||||
</humanTask>
|
||||
|
||||
<!-- ④ 异议拍板:让价触达公司利益的那一下,必须人工激活;只在还有还价条目时入场 -->
|
||||
<humanTask id="T_haggle" name="异议拍板:客户还的价接不接,要人定">
|
||||
<entryCriterion id="ec_hg" sentryRef="s_haggle"/>
|
||||
<itemControl><manualActivationRule id="m_hg"/><repetitionRule id="r_hg"/></itemControl>
|
||||
</humanTask>
|
||||
|
||||
<!-- 议价结果放行:终局唯放行 —— 触达世界,必须人工激活 -->
|
||||
<humanTask id="T_release" name="议价结果验收放行">
|
||||
<entryCriterion id="ec_rel" sentryRef="s_ready"/>
|
||||
<itemControl><manualActivationRule id="m_rl"/></itemControl>
|
||||
</humanTask>
|
||||
|
||||
<timerEventListener id="TL_d3" name="D+3 跟进">
|
||||
<timerExpression>P3D</timerExpression>
|
||||
</timerEventListener>
|
||||
<timerEventListener id="TL_d7" name="D+7 跟进">
|
||||
<timerExpression>P7D</timerExpression>
|
||||
</timerEventListener>
|
||||
<userEventListener id="UL_reply" name="客户回音"/>
|
||||
</casePlanModel>
|
||||
</case>
|
||||
</definitions>`;
|
||||
|
||||
export const NARRATIVE = `§0 开案:拿到报价草案即开案。成品是**一份发出去的正式报价单**,外加一个明确的结果。
|
||||
/**
|
||||
* 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本,两处不重复写同一件事)。
|
||||
*/
|
||||
export const NARRATIVE = `§0 开案:**一份已批准的报价要发出去,并谈出一个结果**。来路两种,形状都收(宽进):
|
||||
· **流内转来**:任务数据里有 \`items\` 数组 —— A4 的报价草案条目(每条带拟报单价与门禁处置),
|
||||
A5 已经批过;这是最全的一种来路;
|
||||
· **单飞**:只有一段需求文字,说了要给谁发报价、几颗料什么价。
|
||||
|
||||
S1 成单发出:把草案写成正式报价单(中英双语 PDF)—— 单价、交期、SPQ、MOQ、贸易条款、
|
||||
有效期六样缺一不可,有效期按 P-04 注明。**发出这一下必须人工按**:它触达客户,
|
||||
系统不替人做这个决定。写类动作先登记意向,验收放行的那一刻才真发。
|
||||
**成品只有一样**:一份**正式报价函** —— 整份条目都在、**每一条都有客户表态**,
|
||||
而且每一条都定了案:成交 / 退回重批 / 不成交。人签的不是「发出去了」,
|
||||
是**这一单谈成了什么**。它决定整条流往哪走。
|
||||
(还价那一档 HAGGLE 是过渡态:**客户还了价、还没拍板**。放行前它必须清零 ——
|
||||
拍板那一下把它换成成交、退回重批或不成交,三条路都是结论,都算办完。)
|
||||
|
||||
S2 跟进议价:没有跟进的报价等于没报 —— D+3、D+7 两记提醒自动到点,人去问。
|
||||
客户提异议就议价调整(裁量项:改价、改交期、换料都可能),改完是新一版报价单,重新走发出。
|
||||
**能算的不问人,该客户说的不代说**:议价结果 N-01(表态 → 定案)、让步梯度 N-02
|
||||
(还价幅度 → 处置建议),两支判定表说了算,你不临场拍;而**客户表态那一列是人落的,
|
||||
你一格都不写** —— 客户回了什么只有收信的人知道,系统不替客户表态。
|
||||
|
||||
§Z 结案:验收时**恰选一口**给出去向 —— 成交 / 有异议 / 拒绝或超时。
|
||||
选哪一口就是这一单的结论,下游网关读的就是它;不选口不许放行,含糊的结果比没结果更坏。`;
|
||||
**四步走**:归一排版报价函 → 交 BD 发出并落回音 → 逐条定案 → 拍板定案放行。
|
||||
|
||||
**这一单要经过人一到两次**:BD 在议价工作单上逐条落客户表态(那张单绑着整份报价函,
|
||||
表态缺一条按不下提交 —— 基座的闸看着,他填的格子直接落账);
|
||||
**只有客户还了价的那几条才另外叫人拍板**(一张决策卡)。每条都叫人,人就成了橡皮图章;
|
||||
一条都不叫,授权红线就是摆设。最后 BD 放行签字。
|
||||
|
||||
**去向是算出来的,不是你选的**:有条目判了退回重批 → 退回定价重批;有成交 → 转订单履约;
|
||||
全都没成 → 转情报归档。三口各带谓词,放行那一刻基座按序求值,你只要把定案落准。
|
||||
|
||||
【这一单只有一个岔路口,它长这样 —— 无论哪一拍都照这个发,别自己另编一张】
|
||||
客户还了价要人拍板,那张决策卡**三处写死**:
|
||||
· \`"itemId":"T_haggle"\` —— 英文计划项号。**别写中文名「异议拍板」**:中文名不是 id,
|
||||
写了当场被拒(【目标缺口】那一段摆的就是中文名,别照它抄);
|
||||
· \`"ask":"<N> 条客户还了价,按哪条路办?"\` —— 只把 N 换成条数,**别的字一个都不改**。
|
||||
换了问法,系统就认不出这是同一张卡:人已经点过的那张会被你再发一遍;
|
||||
· 三个 \`value\`:\`accept-encounter\` 不对,是 \`accept-counter\`(照客户还的价成交)、
|
||||
\`revise\`(退回成本定价重批)、\`hold\`(守住现价不让)。label 与 consequence 可以按
|
||||
本单的数改写,**value 不行**。
|
||||
**REVISE(退回重批)这个码只有拍板那一步写得出来** —— 定案那一步遇到客户还价一律落
|
||||
HAGGLE,替他写了 REVISE,那张卡就永远不会发,让步梯度成了摆设。
|
||||
拍板之前**别急着发卡也别绕路** —— 定案那一步先把三档写完、finish;
|
||||
下一拍引擎会把拍板那一步的做法摊开给你,那时再发。
|
||||
|
||||
【一路上要说话】负责人正看着这一单。**每一拍都带一句 say** —— 说业务的话:
|
||||
报价函排了几条、有效期到哪天、客户回了什么、哪几条要拍板;不说系统词。
|
||||
到了要找人的那一拍,先说清这一轮办完了什么、为什么现在需要他。
|
||||
|
||||
【怎么把一项干完 · 一拍连做】write 和 tool 都不会自动收步 —— 做完用 finish 宣告。
|
||||
动作排进同一拍的 actions 串一口气给;只有发卡 / 提交(挂起)必须停拍。
|
||||
**这一单常路 6-8 拍就该收口**。
|
||||
|
||||
§Z 结案:BD 在验收单上签字,系统把草稿合并成正式报价函并按定案发出对应那一口的载荷 ——
|
||||
成交转订单履约,有异议退回成本定价,不成交转情报归档。`;
|
||||
|
||||
/**
|
||||
* 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。
|
||||
*
|
||||
* 三处不给 `doneWhen`:`S2_send`(发卡触达真人)、`T_haggle`(要人拍板)、
|
||||
* `T_release`(签下去触达世界)—— 给了等于给引擎一把替人签字的钥匙。
|
||||
*/
|
||||
export const NEGOTIATE_STEPS = [
|
||||
{
|
||||
item: 'S1_compose',
|
||||
intent: '整份归一成正式报价函:逐条抄齐条款、补 SPQ/MOQ/贸易条款、按 P-04 注明有效期',
|
||||
writes: ['quote_negotiate.line', 'quote_negotiate.verdict'],
|
||||
/* 上游载荷数组不声明就会被整表段的截断刀砍掉(砍掉的恰是要归一的行) */
|
||||
reads: ['items'],
|
||||
view: 'negotiate-worksheet',
|
||||
tools: [],
|
||||
/*
|
||||
* 归一 + 排版走完才算办完:行数两边对上(一边是基座现数),
|
||||
* 且 composed_total 写过(S-FEEL 未绑定的名字参与比较为假,所以「没写过」进不来)。
|
||||
*/
|
||||
doneWhen: 'line_total > 0 and quote_negotiate_line_rows = line_total and composed_total >= 0',
|
||||
guide: `第一件事:把这一单的**正式报价函**落出来 —— 一拍连做,别拆成两拍。
|
||||
|
||||
【条目从哪儿来】
|
||||
· 流内转来(\`items\` 里的行带 quoted_price / disposition):照抄 —— **报出去的价就是
|
||||
上游批准的那个价**,一个数字都别改;带 quote_line_total 这类总数的是结论行,不是料,
|
||||
别落进报价函。
|
||||
· 单飞:照正文归一,列了几颗落几行;正文里说的批准价就是报出去的价。
|
||||
**按 pn 归并,一颗料一行**;pn 一个字符不许改(它是主键,改一个字就是新开一行)。
|
||||
|
||||
【写 \`quote_negotiate.line\`,一颗料一行】(rows 收数组)
|
||||
rfq_ref(入参有就原样抄;没有从主题认;认不出才自己造一个,**造出来的必须以 \`NEG-\` 开头**
|
||||
(\`NEG-<主题或第一颗料的型号>\`)。两条硬规矩:
|
||||
**① 整单只有一个单号,不是一颗料一个** —— 它是这一单的案号,报价函所有行、verdict 那一行
|
||||
用的都得是同一个串(实测栽过:两颗料造出两个号,一单裂成两单);
|
||||
**② 只从本单来件取材** —— 台账里躺着的 RFQ-xxxx 是别人的单,借它的号等于把两单的行
|
||||
混进同一个案卷(实测栽过一轮))、pn、customer_pn、category、brand、eau、lead_time、
|
||||
remark(客户约束/急料,一个字别丢)、type_tag(照上游现标;认不出按 B)、
|
||||
**quoted_price**(上游批准的报价,原样抄)。
|
||||
条款三列**照这个口径补齐**(它们是报价函的必备条款,不是可选项):
|
||||
· spq —— 最小包装量:上游给了就抄;没给按 eau 的量级给一个常规值(千位以下 100,
|
||||
千到万 1000,万以上 5000)并在 remark 里注明是按常规包装估的;
|
||||
· moq —— 最小起订量:同上,没给就取 spq;
|
||||
· trade_terms —— 贸易条款:统一写 \`EXW 深圳\`,客户来件另有约定的照客户的写。
|
||||
· valid_until —— 有效期到期日:按**报价有效期 P-04:7 个日历天**从今天算,写成
|
||||
\`YYYY-MM-DD\`。整单同一个日子,不是一行一个。
|
||||
|
||||
【**这三列你一格都不写**:reply_type / counter_price / reply_note】
|
||||
客户表态、客户还的价、客户原话 —— 这三样只有收信的人知道,**系统不替客户说话**。
|
||||
它们是 BD 在工作单上落的;deal_status / concession / final_price 三列要等表态回来
|
||||
才判得了,这一步也别写。
|
||||
这不是客气话:一条要你分辨「这行能不能替客户填」的规矩会走岔;
|
||||
**「这几列不写」没有分支可走岔。**
|
||||
|
||||
【同一拍写 \`quote_negotiate.verdict\`(一行)】
|
||||
rfq_ref、**quote_no**(报价单号:\`Q-\` 开头 + 本单案号的尾段,整单一个)、
|
||||
customer_code / customer_name(顶层有就抄)、**line_total** = 报价函行数、
|
||||
**valid_days** = 7、**composed_total** = 这一拍排版落表的条数(0 也要写)、
|
||||
summary 一句人话(几条料、报给谁、有效期到哪天)。
|
||||
写完 finish 这一项。`,
|
||||
},
|
||||
{
|
||||
item: 'S2_send',
|
||||
intent: '一张执行单交 BD:把报价函发给客户,回音回来后逐条落客户表态',
|
||||
writes: ['quote_negotiate.verdict'],
|
||||
reads: ['quote_negotiate.line'],
|
||||
view: 'negotiate-worksheet',
|
||||
tools: [],
|
||||
/* 不给 doneWhen —— 发卡触达真人(manualActivation),引擎不替人按 */
|
||||
guide: `报价函排好了,**该 BD 出场了** —— 这是本活动唯一的一张执行单。
|
||||
触达客户的那一下由人做:他把报价函发出去、按 D+3/D+7 催问、拿回客户的话。
|
||||
工作区摆的是**议价工作单**:逐颗料一张报价档案卡(条款、报价、客户约束),
|
||||
让步梯度与议价纪律挂在上面,填回区长在同一屏 —— 他在「客户表态」那一列逐行落。
|
||||
|
||||
这一步标了人工激活:发卡就算启动,不必单发 start;引擎也不会替你收 ——
|
||||
人交回后要你自己 finish。
|
||||
|
||||
【照这个骨架发,别少字段】(卡面是纯文本,steps/standard 里别写星号记号)
|
||||
{"kind":"human","itemId":"S2_send","workOrder":"execution","card":{
|
||||
"title":"报价函发出并跟进:<单号> 共 <N> 条待回音",
|
||||
"ask":"报价函已按批准价排好版,条款与有效期都在工作单上。请把它发给客户,按 D+3/D+7 的节奏催问;客户回话之后,在工作单的「客户表态」一列逐行落:接受 / 还价 / 拒绝 / 无回复,还价的把客户报的那个价也填上。",
|
||||
"steps":["发出去之前先核一遍条款:单价、交期、SPQ、MOQ、贸易条款、有效期 —— 报价函一旦发出,这几项就是对客户的承诺",
|
||||
"客户表态每一行都要有 —— 缺一行这张单按不下提交;客户只回了一部分料的,没回的那几行按无回复落,别替客户猜",
|
||||
"客户还价的行:把他报的价填进「客户还价」那一格,再把他的原话记进备注 —— 争的是价格、交期还是 MOQ,后面拍板全看这一句",
|
||||
"D+3 第一轮催问确认收到探态度,D+7 最后一催;催到有效期过仍无回复的,按无回复定案,不要一直挂着"],
|
||||
"standard":"整份报价函每一条都有客户表态,还价的那几条填了客户报的价并记下他的原话 —— 缺一条这张单按不下提交(系统的闸看着)。",
|
||||
"basis":[{"k":"<PN>","v":"<报价 · 交期 · 有效期到 YYYY-MM-DD>"}]}}
|
||||
· \`basis\` 逐条列本单的料:PN + 报价与有效期。几颗写几条,别铺全表。
|
||||
· **卡上不摆表、不摆格子**(执行卡零输入):整份报价函就在工作单那份件上,
|
||||
客户表态、客户还价、备注三列都能就地改、行能撤 —— **别往卡里塞 \`blocks\` 或 \`fields\`**。
|
||||
· **这张卡带提交闸**:客户表态(必填列)还空着,他按不下提交 —— 缺几格、缺在哪行,
|
||||
闸上与件上的红带都写着(与放行门 L1 同一个数同一套算法)。
|
||||
发完卡这一拍就结束了(发卡挂起)。
|
||||
|
||||
【人交回之后 —— 他落的表态基座已经替你写回台账了】这一步你只做两件事,然后 finish:
|
||||
① 写 verdict:**replied_total** = 已落表态的条数(照报价函行数记);
|
||||
他在件上撤掉了行的话,**line_total 跟着改成撤后的实数**。
|
||||
② finish 这一项。
|
||||
**别在这一拍判定案** —— 逐条定案是下一步的活(它有自己的一段做法),这里判了也白判。
|
||||
报价函行你一列都不用重写:他落的值基座已经落进台账了,重写会被「写了等于没写」拒回。
|
||||
|
||||
【这张卡一单只发一次】人交回来之后不许再发同一张卡 —— 必填有提交闸兜着,
|
||||
卡回来时客户表态一定是齐的(或那几行已被他撤掉)。`,
|
||||
},
|
||||
{
|
||||
item: 'S3_settle',
|
||||
intent: '逐条定案:按客户表态判成交/待拍板/不成交,还价的按让步梯度给处置建议',
|
||||
writes: ['quote_negotiate.line', 'quote_negotiate.verdict'],
|
||||
reads: ['quote_negotiate.line'],
|
||||
view: 'negotiate-worksheet',
|
||||
tools: [],
|
||||
/*
|
||||
* **收步判据与验收判据同源**:四档定案的条数加起来等于行数(两边都是基座现数)——
|
||||
* 判完了才算办完。`_rows > 0` 那一半不能省:开案时行数是 0,而 0+0+0+0 = 0
|
||||
* 会让这一步开局即收(未绑定名字的坑之外,还有「全零相等」这一坑)。
|
||||
*/
|
||||
doneWhen:
|
||||
'quote_negotiate_line_rows > 0 and quote_negotiate_line_deal_status_haggle = quote_negotiate_line_reply_type_counter and quote_negotiate_line_deal_status_won + quote_negotiate_line_deal_status_haggle + quote_negotiate_line_deal_status_revise + quote_negotiate_line_deal_status_lost = quote_negotiate_line_rows',
|
||||
guide: `客户的话都落回来了,**现在才轮到定案** —— 这一步是纯查表,一拍连做:
|
||||
|
||||
① **逐行按客户表态 reply_type 判 deal_status**,按议价结果 N-01,
|
||||
只许写这四个大写码之一,一个字母不许差:
|
||||
· ACCEPT(客户接受)→ **WON**,同一行 final_price 记 quoted_price(成交价就是报价);
|
||||
· COUNTER(客户还价)→ **HAGGLE**,final_price **留空**(还没谈定,别替客户成交);
|
||||
· REJECT(客户拒绝)→ **LOST**,final_price 留空;
|
||||
· NOREPLY(催到有效期过仍无回复)→ **LOST**,final_price 留空。
|
||||
**第四个码 REVISE「退回重批」这一步一格都不写** —— 它是人在决策卡上拍出来的,
|
||||
不是你判出来的。你替他写了,那张卡就永远不会发,让步梯度成了摆设(实测栽过一轮:
|
||||
一单从头到尾 released 而没有人拍过板)。客户还了价 = 落 **HAGGLE**,句号。
|
||||
**两个数要对上,都是基座现数,你改不动它们**:
|
||||
· 待拍板条数 quote_negotiate_line_deal_status_haggle
|
||||
**必须等于**客户还价条数 quote_negotiate_line_reply_type_counter ——
|
||||
对不上这一步收不掉(把写错的那行改回 HAGGLE 就对上了);
|
||||
· 四档定案的条数加起来必须等于 quote_negotiate_line_rows(基座数的行数),一行都不能漏。
|
||||
现在四档都是 0,就是一行都还没判 —— 这一步没办完,放行门开不了。
|
||||
② **还价的那几行(且只有那几行)再算一格 concession**:让步梯度 N-02 看还价幅度
|
||||
gap =(quoted_price − counter_price)/ quoted_price,只许写这三个大写码之一:
|
||||
· gap ≤ 0.03(三个点以内)→ **ACCEPT_COUNTER**(意思意思的压价,接了拿单);
|
||||
· 0.03 < gap ≤ 0.10(三到十个点)→ **NEGOTIATE**(在授权内回旋一轮,让一半再报回去);
|
||||
· gap > 0.10(超过一成)→ **ESCALATE**(超出授权,退回成本定价重批,不私自破线)。
|
||||
不是还价的行 concession 给空串 \`""\`,**不许写 0 也不许编一个码** ——
|
||||
没还价就没有让步幅度这回事。
|
||||
③ 看这一拍写完之后的**待拍板条数**(deal_status = HAGGLE 的行):
|
||||
· 一条都没有 → 同一拍把 verdict 写全:**outcome**(全按 N-01 的结果算:
|
||||
有 WON 就写 \`won\`,一条 WON 都没有就写 \`lost\`)、**route_decided_total 记 1**、
|
||||
**settled_total** = 定案条数、reason(不成交时写清缘由:客户拒绝还是超时无回复)、
|
||||
summary 补一句人话,然后 finish 这一项;
|
||||
· 还有 → **别写 outcome 也别写 route_decided_total**,只写 settled_total 再 finish ——
|
||||
下一拍会有一步专门叫人拍板。
|
||||
|
||||
【finish 之后这一拍到此为止】**别在这一拍顺手发决策卡** —— 那张卡属于下一步
|
||||
(itemId 要写 T_haggle),这一步收掉之后再往它上面挂任何行动都会被拒
|
||||
(实测:同一串里 write / write / finish / decide,最后那个 decide 挂着 S3_settle,当场被拒)。
|
||||
下一拍引擎会把「异议拍板」那一步的做法摊开给你,那时再发。`,
|
||||
},
|
||||
{
|
||||
item: 'T_haggle',
|
||||
intent: '客户还的价接不接要人定 —— 发一张决策卡,照他选的那条路把定案改掉',
|
||||
writes: ['quote_negotiate.line', 'quote_negotiate.verdict'],
|
||||
reads: ['quote_negotiate.line'],
|
||||
view: 'negotiate-roster',
|
||||
tools: [],
|
||||
/* 人工激活 + 要人拍板 —— 不给 doneWhen,引擎不许替人收 */
|
||||
guide: `**这一单只发一张决策卡。动手之前先在轨迹里找一行 \`[card-reply]\` 带「人选了:」:**
|
||||
|
||||
· **找到了** —— 跳到下面【人点完之后】那一段,照他选的那条路把那几行的 deal_status
|
||||
从 HAGGLE 改掉、写 outcome 与 route_decided_total、finish。**一个 decide 都不许再发**
|
||||
(实测栽过:人点完了,而这一步又从头念了一遍「发一张决策卡」,同一张卡连发六次,
|
||||
前两次无事发生、后四次被拒 —— 他点过的那一下就白点了);
|
||||
· **没找到** —— 才轮到下面【发一张决策卡】那一段。
|
||||
|
||||
【这一步办完了没有,看一个数】待拍板条数 quote_negotiate_line_deal_status_haggle:
|
||||
**它归零就是这一步办完了**。HAGGLE 是过渡态(客户还了价、还没拍板),
|
||||
拍板那一下必须把它换成 WON / REVISE / LOST 之一 —— 三条路都是结论。
|
||||
这个数不归零,放行门开不了;它归零,这一步也就不会再找你。
|
||||
|
||||
这一步只在**客户还过价**(基座现数 quote_negotiate_line_reply_type_counter > 0)时入场 ——
|
||||
读的是客户自己填的那一格,不是你判的定案:该不该叫人,不由被叫的那一方说了算。
|
||||
它触达公司利益,标了人工激活:发卡就算启动,不必单发 start。
|
||||
工作区这时摆的是**成交定案清单**,整份报价函与定案分布都在上面,他看得见全局再拍板。
|
||||
|
||||
【发一张决策卡,照这个骨架】(选项少于两个基座拒发)
|
||||
**三处一个字都不许改**(与说明书里那一段是同一份):
|
||||
· \`itemId\` 只写英文计划项号 \`T_haggle\` —— **别写中文名「异议拍板」**,
|
||||
中文名不是 id,写了当场被拒(实测栽过两拍:「计划项不存在:异议拍板」);
|
||||
【目标缺口】那一段摆的是中文名,它不是 id,别照它抄。
|
||||
· \`ask\` 照抄「<N> 条客户还了价,按哪条路办?」,**只把 N 换成条数** ——
|
||||
换了问法系统就认不出这是同一张卡,人已经点过的那张会被你再发一遍(实测发出去两张)。
|
||||
· 三个 \`value\` 照抄 \`accept-counter\` / \`revise\` / \`hold\` —— 它们是这一单三条路的
|
||||
代号,换了名字下一段的对应关系就对不上,你自己也不知道人点的是哪条。
|
||||
label 与 consequence 可以按本单的数改写,value 不行。
|
||||
{"kind":"decide","itemId":"T_haggle","ask":"<N> 条客户还了价,按哪条路办?",
|
||||
"options":[
|
||||
{"value":"accept-counter","label":"照客户还的价成交","consequence":"当场拿下这几条,毛利让到客户的价位上,让出去的部分不再回来"},
|
||||
{"value":"revise","label":"退回成本定价重批","consequence":"这几条超出让价授权,回 A4 重新算价再谈 —— 这一单本轮不成交,客户要多等一轮"},
|
||||
{"value":"hold","label":"守住现价不让","consequence":"报价不动,客户不接受即视为不成交,这几条转情报归档"}],
|
||||
"basis":[{"k":"<PN>","v":"报价 <X> · 客户还价 <Y> · 让幅 <Z>% · 让步梯度判 <码>"}]}
|
||||
· basis 逐条摆还价的那几颗料:报价、客户还价、让幅、N-02 的处置码 —— 裸卡基座拒发。
|
||||
· **recommended 按让步梯度 N-02 标**,标在与处置码对应的那一项上,只标一项:
|
||||
ACCEPT_COUNTER → accept-counter;NEGOTIATE → accept-counter;ESCALATE → revise。
|
||||
几条料的码不一致时,按**最靠后那一档**标(有一条要 ESCALATE 就标 revise)。
|
||||
· 发完这一拍就结束了(发卡挂起)。
|
||||
|
||||
【人点完之后】轨迹上那一行长这样:\`[card-reply] …人选了:xxx\`。**照那条路把定案改掉,
|
||||
别再发第二张卡**,一拍连做 —— 三条路**都要动 deal_status**,一条都不许留在 HAGGLE:
|
||||
· 选 accept-counter:那几行 deal_status 改 **WON**、**final_price 记该行的
|
||||
counter_price**(那个数不是你定的,是客户报的、人点头认的,你只是替他落账)、
|
||||
reply_note 补一句「按客户还价成交,由负责人拍板」;verdict 的 outcome 写 \`won\`;
|
||||
· 选 revise:那几行 deal_status 改 **REVISE**、final_price 留空,
|
||||
reply_note 补一句「让幅超出授权,退回成本定价重批」;verdict 的 outcome 写 \`revise\`、
|
||||
reason 写清超了多少;
|
||||
· 选 hold:那几行 deal_status 改 **LOST**、final_price 留空,
|
||||
reply_note 补一句「守价不让,客户未接受」;verdict 的 outcome 写 \`lost\`、
|
||||
reason 写「客户还价未达成,守价不让」。
|
||||
然后把 verdict 写全:**route_decided_total 记 1**、settled_total = 定案条数、
|
||||
summary 补一句拍板结果,finish 这一项。
|
||||
**outcome 那一格照上面三条写死的对应关系填,别自己另造词** —— 下游三条边读的就是它。
|
||||
改完之后待拍板条数就是 0 了,这一步再也不会找你。`,
|
||||
},
|
||||
{
|
||||
item: 'T_release',
|
||||
intent: '定案已落,提交放行交 BD 验收',
|
||||
writes: [],
|
||||
reads: [],
|
||||
view: 'negotiate-roster',
|
||||
tools: [],
|
||||
/* 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 doneWhen) */
|
||||
guide: `每条都有客户表态、定案守恒、待拍板清零、定案已记 —— 可以走放行了
|
||||
(T_release 人工激活:动它就算启动,不必单发 start)。先把裁量判据裁掉,然后 submit,同一拍连做:
|
||||
|
||||
· judge criterion="议价过程说得清:报价函条款齐备、每一轮客户反馈与让价理由逐条可溯,BD 对这个结果负责" ——
|
||||
看报价函里每一行的条款、客户原话与让价理由:说得出来路就 satisfied=true;
|
||||
有行说不出的(比如还价成交却没记客户的话),satisfied=**false**,rationale 逐行写清,
|
||||
交签字的人定。**裁成不满足照样能提交** —— 理由连着卡一起交给他。
|
||||
criterion 照判据名**整句原样抄**,写别的名字对不上。
|
||||
|
||||
**去向不必你选**:三口各带谓词,放行那一刻基座按序求值 ——
|
||||
有条目判了退回重批就走「退回调整」,有成交就走「转订单履约」,全都没成就走「转情报归档」。
|
||||
你要做的只是把定案落准,别在提交里手选出口。
|
||||
|
||||
终局唯放行:你不能自己宣布完成。`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段;
|
||||
* 放行那一格由基座统一加,人看到的是 x/5。
|
||||
*/
|
||||
export const NEGOTIATE_PHASES = [
|
||||
{ name: '排版报价函', items: ['S1_compose'] },
|
||||
{ name: '发出并等回音', items: ['S2_send'] },
|
||||
{ name: '逐条定案', items: ['S3_settle'] },
|
||||
{ name: '异议拍板', items: ['T_haggle'] },
|
||||
];
|
||||
|
||||
@@ -1,50 +1,18 @@
|
||||
/**
|
||||
* 统一报价 A6 · 报价议价 —— 活动包代码入口(ADR-004)。
|
||||
* 统一报价 A6 · 报价议价 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
|
||||
*
|
||||
* 「发出的同时必须建立 D+3/D+7 跟进 —— 没有跟进的报价等于没报。」
|
||||
* 这里把跟进监听立起来:AI 对世界只有触达(动作,落在放行)与等待(监听)两种姿势。
|
||||
* **这个活动没有 `run`,不是缺件。**
|
||||
*
|
||||
* 案例活动的推进由 Loop 驱动,行为全部写在清单里:案例模型 + 说明书 + 步骤剧本 +
|
||||
* 两把判定表 + 两条技能 + 台账声明 + 两张 HTML 报告模版。
|
||||
* 上一版这里有一段 `run()`(读报价单、拼 table 块、按条款齐不齐返回 await/submit,
|
||||
* 末尾还立了一条 D+3 监听)—— 案例活动根本不会调它;摆一个空转的 run 去假装有实现,
|
||||
* 更坏:它写的那套呈现与 `viewTemplates` 里的模版各说各话,而没人看得出以哪份为准;
|
||||
* 那条 D+3 监听也与 `declarations.listeners` 里声明的三条重复,谁真谁假说不清。
|
||||
*
|
||||
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 —— 它是**被静态审扫描的产物文本**
|
||||
* (查 import 与全局引用,证明活动包零环境能力)。
|
||||
*/
|
||||
import { defineActivity } from '@sdk';
|
||||
import type { ActivityBase, ActivityOutcome } from '@sdk';
|
||||
|
||||
const QUOTE = 'quote_negotiate.formal_quote';
|
||||
|
||||
export default defineActivity({
|
||||
name: 'quote-negotiate',
|
||||
paradigm: 'flow',
|
||||
|
||||
async run(base: ActivityBase): Promise<ActivityOutcome> {
|
||||
const lines = await base.ledger.read(QUOTE);
|
||||
|
||||
await base.interaction.pushView([
|
||||
{
|
||||
t: 'table',
|
||||
title: '正式报价单',
|
||||
cols: ['PN', '单价CNY', '交期', 'MOQ', '有效期'],
|
||||
rows: lines.map((r) => [
|
||||
String(r['pn'] ?? ''),
|
||||
String(r['unit_price'] ?? '—'),
|
||||
String(r['lead_time'] ?? '—'),
|
||||
String(r['moq'] ?? '—'),
|
||||
String(r['valid_until'] ?? '—'),
|
||||
]),
|
||||
},
|
||||
]);
|
||||
|
||||
const incomplete = lines.filter((r) => r['unit_price'] === undefined || r['valid_until'] === undefined);
|
||||
if (lines.length === 0 || incomplete.length > 0) {
|
||||
await base.interaction.say('报价单条目尚未齐备,继续排版。', 'conclusion');
|
||||
return { kind: 'await', reason: '等待报价单生成步完成' };
|
||||
}
|
||||
|
||||
// 建 D+3 跟进:等客户回音 —— 时限必配,无死等
|
||||
await base.tools.listen({
|
||||
eventType: 'data',
|
||||
keyFilter: { kind: 'customer-reply', customerCode: String(base.contract.data['customerCode'] ?? '') },
|
||||
deadline: 'P3D',
|
||||
});
|
||||
await base.interaction.say('已建 D+3 跟进监听 —— 没有跟进的报价等于没报。', 'guidance');
|
||||
|
||||
return { kind: 'submit', deliverable: 'formal-quote', rationale: '报价单齐备且已建跟进,提交放行即发出' };
|
||||
},
|
||||
});
|
||||
export default defineActivity({ name: 'quote-negotiate' });
|
||||
|
||||
@@ -1,155 +1,580 @@
|
||||
/**
|
||||
* 活动包清单 —— 统一报价 A6 · 报价议价。
|
||||
* 活动包清单 —— 统一报价 A6 · 报价议价(案例活动)。
|
||||
*
|
||||
* 业务素材:docs/MVP示例工作流.html §1.4(S1 生成发出 → S2 CRM 留档 → S3 建立跟进)。
|
||||
* 关键机制:生成正式报价单是**写类动作** → 登记为动作意向 → 落地预检 →
|
||||
* 放行通过即落地(台账回写 + 出站发送)→ 输出口发射;并建 D+3 跟进监听。
|
||||
* 业务素材:`docs/MVP示例工作流.html` §1.4(S1 发报留档 → S2 跟进议价 → S3 定案交接):
|
||||
* 「审批通过后 15 分钟内发出……没有跟进的报价等于没报」
|
||||
* 「客户不回不是结论,催到表态或有效期过」
|
||||
* 「让价在授权范围内回旋,要破底价门禁红线的交回发起流程重批,不私自破线」。
|
||||
*
|
||||
* 形状与 A4 同一条(2026-08-03 重写 @3.0.0):归一排版 → 一张执行单交人(发出去 +
|
||||
* 把客户回音落回来)→ 逐条定案 → **还价才叫人**拍板 → 放行。
|
||||
* **能算的不问人**(N-01 议价结果、N-02 让步梯度两把尺写死),
|
||||
* **该客户说的不代说**(客户表态那三列 AI 一格都不写)。
|
||||
*
|
||||
* 与 @2.1.0 的差别(四处都是踩过的账):
|
||||
* ① `input.required` 从 `['rfqRef','customerCode','items']` 清空 —— 宽进严出;
|
||||
* ② 台账补 `required` / `requiredWhen` —— 从前一个必填都没有,执行卡的闸没牙;
|
||||
* ③ 判据与哨兵全部换成**基座读数**(`_rows` / `_missing` / `_<列>_<值>`)——
|
||||
* 从前三条 L1/L2 读 `missing_field_total` / `validity_days` / `followup_total`,
|
||||
* 三个都是活动自报的数(「机检只读基座数」违反史第六类);
|
||||
* ④ 三个出口补 `when` —— 去向是**算出来的**,不是模型手选的。
|
||||
*/
|
||||
import type { ActivityManifest } from '@contracts';
|
||||
import { NEGOTIATE_SKILLS } from './skills.ts';
|
||||
import { CMMN_XML, NARRATIVE } from './case-model.ts';
|
||||
import type { ActivityManifest, DmnModel } from '@contracts';
|
||||
import { activityIdSchema } from '@contracts';
|
||||
import { QUOTE_COMPOSED, QUOTE_RELEASE } from './views/report.ts';
|
||||
import { NEGOTIATE_SKILLS } from './skills.ts';
|
||||
import { CMMN_XML, NARRATIVE, NEGOTIATE_PHASES, NEGOTIATE_STEPS } from './case-model.ts';
|
||||
import { NEGOTIATE_ROSTER, NEGOTIATE_WORKSHEET } from './views/report.ts';
|
||||
|
||||
const DOMAIN = 'quote_negotiate';
|
||||
const D = 'quote_negotiate';
|
||||
const ACTIVITY_ID = activityIdSchema.parse('quote-negotiate');
|
||||
|
||||
/**
|
||||
* 两把尺 —— **规则本身不是数据**,随包冻结。
|
||||
*
|
||||
* · 议价结果 N-01:客户表态 → 定案(出参就是台账 `deal_status` 的取值);
|
||||
* · 让步梯度 N-02:还价幅度 → 让步处置(出参就是台账 `concession` 的取值)。
|
||||
*
|
||||
* 出参键名与台账列同名,模型算完照抄即可(规范 §5.6)。
|
||||
* 两把尺的档位在三处出现:这张表、剧本 guide、工作单上的规则块 ——
|
||||
* 包内自测把三处钉在一起(改一处不改另两处,测就红)。
|
||||
*/
|
||||
export const N01_DMN: DmnModel = {
|
||||
drd: [
|
||||
{ decision: 'n01-outcome', requires: [], inputs: ['reply_type'] },
|
||||
{ decision: 'n02-concession', requires: ['n01-outcome'], inputs: ['gap'] },
|
||||
],
|
||||
tables: [
|
||||
{
|
||||
id: 'n01-outcome',
|
||||
label: '议价结果 N-01',
|
||||
hitPolicy: 'FIRST',
|
||||
inputs: [{ name: 'reply_type', expression: 'reply_type' }],
|
||||
outputs: [{ name: 'deal_status' }],
|
||||
rules: [
|
||||
{ id: 'n01-1', when: ['"ACCEPT"'], then: ['WON'], annotation: '客户明确接受这个价 → 成交,转订单履约' },
|
||||
{ id: 'n01-2', when: ['"COUNTER"'], then: ['HAGGLE'], annotation: '客户还了一个价 → 压在还价上,要人拍板' },
|
||||
{ id: 'n01-3', when: ['"REJECT"'], then: ['LOST'], annotation: '客户明确不做了 → 不成交,情报归档' },
|
||||
{ id: 'n01-4', when: ['"NOREPLY"'], then: ['LOST'], annotation: '催到有效期过仍不表态 → 按超时定案' },
|
||||
{ id: 'n01-default', when: ['-'], then: ['LOST'], annotation: '认不出的表态按不成交处置 —— 不许猜成接受' },
|
||||
],
|
||||
},
|
||||
{
|
||||
/** 让步梯度 N-02 —— FIRST 命中即定,所以第二行等价于「三到十个点」 */
|
||||
id: 'n02-concession',
|
||||
label: '让步梯度 N-02',
|
||||
hitPolicy: 'FIRST',
|
||||
inputs: [{ name: 'gap', expression: 'gap' }],
|
||||
outputs: [{ name: 'concession' }, { name: 'in_authority' }],
|
||||
rules: [
|
||||
{ id: 'n02-1', when: ['<= 0.03'], then: ['ACCEPT_COUNTER', true], annotation: '三个点以内 —— 意思意思的压价,接了拿单' },
|
||||
{ id: 'n02-2', when: ['<= 0.10'], then: ['NEGOTIATE', true], annotation: '三到十个点 —— 授权内回旋一轮,让一半再报回去' },
|
||||
{ id: 'n02-3', when: ['> 0.10'], then: ['ESCALATE', false], annotation: '超过一成 —— 超出授权,退回成本定价重批,不私自破线' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const manifest: ActivityManifest = {
|
||||
identity: {
|
||||
activityId: ACTIVITY_ID,
|
||||
name: 'A6 · 报价议价',
|
||||
packageVersion: '0.1.5',
|
||||
packageVersion: '3.5.0',
|
||||
category: '统一报价',
|
||||
sdkVersion: '0.1.0-m5',
|
||||
checksum: 'dev-checksum-quote-negotiate',
|
||||
},
|
||||
|
||||
/* ── 契约七件 ────────────────────────────────────────────────── */
|
||||
contract: {
|
||||
activityId: ACTIVITY_ID,
|
||||
version: '0.1.0',
|
||||
version: '1.0.0',
|
||||
input: {
|
||||
/*
|
||||
* **宽进到底:一个必填都不设**(与 A1/A2/A3/A4 同口径 —— 宽进严出,
|
||||
* 输入契约在活动内部归一 + 补全)。
|
||||
*
|
||||
* 来路两种形状:流内转来(A4 `draft` 口的 `{rfqRef, customerCode, items}`)、
|
||||
* 单飞(一段文字)。上一版必填 `rfqRef + customerCode + items`,门口拦的恰是主路。
|
||||
*/
|
||||
data: {
|
||||
type: 'object',
|
||||
required: ['customerCode', 'items'],
|
||||
properties: { customerCode: { type: 'string' }, items: { type: 'array' } },
|
||||
required: [],
|
||||
properties: {
|
||||
rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' },
|
||||
customerCode: { type: 'string', title: '客户码' },
|
||||
items: { type: 'array', title: '报价草案条目(已批准,每条带拟报单价与门禁处置)' },
|
||||
rows: { type: 'array', title: '上游清单行(别的来路也收)' },
|
||||
subject: { type: 'string', title: '需求主题' },
|
||||
body: { type: 'string', title: '需求正文' },
|
||||
attachments: {
|
||||
type: 'array',
|
||||
format: 'file',
|
||||
title: '附件',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', title: '文件名' },
|
||||
contentType: { type: 'string', title: '类型' },
|
||||
size: { type: 'integer', title: '字节数' },
|
||||
ref: { type: 'string', title: '存档号' },
|
||||
text: { type: 'string', title: '读出来的文本' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
messageAllowed: true,
|
||||
messageHint: '客户的议价习惯、竞对价位、这一单的让价授权、催办节奏写这里',
|
||||
},
|
||||
output: {
|
||||
/*
|
||||
* **没有必交付口**:这个活动的成品就是「谈出一个结果」,而结果本身是分支 ——
|
||||
* 三口恰发一口,发出去的那一口就是这一单的 outcome(基座在放行时把口名
|
||||
* 写进事实,下游 E11/E12/E13 读的就是它)。
|
||||
*/
|
||||
mandatory: [],
|
||||
// 议价结果是**业务分支**:必须建模为选择组的一口,随放行发射 —— 不得用异常终态编码
|
||||
/*
|
||||
* **三条出边 = 一个 XOR 选择组**(对齐流程包 E11/E12/E13)。
|
||||
* 每一口都写了 `when`(读基座按值现数的 tally 读数),这一单往哪走是**算出来的**:
|
||||
* 放行时基座按序求值首个命中即出,卡上只如实告诉人走哪一口、凭哪一条。
|
||||
*
|
||||
* 顺序即优先级:**判了退回重批 → 先退回重批**(谈不拢的那几条不能被
|
||||
* 「另外几条成交了」盖过去);其次有成交 → 转履约;都没成 → 转归档。
|
||||
*/
|
||||
selective: [
|
||||
{
|
||||
group: 'negotiation',
|
||||
ports: [
|
||||
{ name: 'won', payload: { type: 'object' }, description: '成交 → 转订单履约(ORM)' },
|
||||
{ name: 'revise', payload: { type: 'object' }, description: '有异议 → 返回调整 A4' },
|
||||
{ name: 'lost', payload: { type: 'object' }, description: '拒绝/超时 → 转 A7 情报归档' },
|
||||
{
|
||||
name: 'revise',
|
||||
payload: {
|
||||
type: 'object',
|
||||
required: ['rfqRef', 'outcome'],
|
||||
properties: {
|
||||
rfqRef: { type: 'string', title: '询价单号' },
|
||||
customerCode: { type: 'string', title: '客户码' },
|
||||
outcome: { type: 'string', title: '议价结果(固定 revise)' },
|
||||
reason: { type: 'string', title: '退回缘由(让幅超授权多少)' },
|
||||
rows: { type: 'array', title: '报价函(带客户还价与让步处置)' },
|
||||
},
|
||||
},
|
||||
description: '有条目经人拍板判了退回重批 —— 让幅超出授权,回成本定价重算再谈',
|
||||
when: `${D}_line_deal_status_revise > 0`,
|
||||
},
|
||||
{
|
||||
name: 'won',
|
||||
payload: {
|
||||
type: 'object',
|
||||
required: ['rfqRef', 'outcome'],
|
||||
properties: {
|
||||
rfqRef: { type: 'string', title: '询价单号' },
|
||||
customerCode: { type: 'string', title: '客户码' },
|
||||
quoteNo: { type: 'string', title: '报价单号' },
|
||||
outcome: { type: 'string', title: '议价结果(固定 won)' },
|
||||
rows: { type: 'array', title: '成交条目(带成交价)' },
|
||||
},
|
||||
},
|
||||
description: '有条目谈成 —— 转订单履约(ORM)',
|
||||
when: `${D}_line_deal_status_won > 0`,
|
||||
},
|
||||
{
|
||||
/* 下游 A7 的入参要 `rfqRef / customerCode / quoteNo` —— 边读什么、下游要什么,这一口就得发什么 */
|
||||
name: 'lost',
|
||||
payload: {
|
||||
type: 'object',
|
||||
required: ['rfqRef', 'customerCode', 'quoteNo', 'outcome'],
|
||||
properties: {
|
||||
rfqRef: { type: 'string', title: '询价单号' },
|
||||
customerCode: { type: 'string', title: '客户码' },
|
||||
quoteNo: { type: 'string', title: '报价单号(A7 归档的主键之一)' },
|
||||
outcome: { type: 'string', title: '议价结果(固定 lost)' },
|
||||
reason: { type: 'string', title: '不成交缘由(拒绝 / 超时无回复)' },
|
||||
rows: { type: 'array', title: '报价函(带客户表态与定案)' },
|
||||
},
|
||||
},
|
||||
description: '一条都没谈成:客户拒绝或超时无回复 —— 转情报归档',
|
||||
when: `${D}_line_deal_status_lost > 0`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
/*
|
||||
* 必交付物只有一件:**正式报价函**。议价结论(verdict)是它的结论栏,
|
||||
* `ledgers` 说清这份成果由哪几张表承载(一份成果,不是两份)。
|
||||
*/
|
||||
requiredDeliverables: [
|
||||
{ key: 'formal-quote', label: '正式报价单(PDF · 中英双语)' },
|
||||
{ key: 'followup', label: 'D+3/D+7 跟进提醒' },
|
||||
{
|
||||
key: 'formal-quote',
|
||||
label: '正式报价函(逐条条款 + 客户表态与定案)',
|
||||
ledger: `${D}.line`,
|
||||
/* 验收页上让 BD 就地改的两列 —— 恰是他的职权面(成交价与客户原话) */
|
||||
editableAtRelease: ['final_price', 'reply_note'],
|
||||
ledgers: [`${D}.line`, `${D}.verdict`],
|
||||
},
|
||||
],
|
||||
/*
|
||||
* 验收判据 —— **机检(L1/L2)只读基座数得出来的数**:
|
||||
* `_rows` 基座数行数、`_missing` 基座按 required / requiredWhen 现数
|
||||
* (与执行卡提交闸同一个数同一套算法)、`_<列>_<值>` 基座按值现数。
|
||||
* 上一版三条机检读活动自报的 `missing_field_total` / `validity_days` /
|
||||
* `followup_total` —— 那不是门,是「改哪个数能过」的提示。
|
||||
*/
|
||||
acceptanceCriteria: [
|
||||
{ key: 'quote-complete', label: '单价/交期/SPQ/MOQ/贸易条款/有效期齐备', level: 'L1', predicate: 'missingQuoteFields = 0' },
|
||||
{ key: 'validity', label: '按报价有效期 P-04 注明有效期', level: 'L2', predicate: 'validityDays > 0' },
|
||||
{ key: 'followup-set', label: '已建 D+3/D+7 跟进 —— 没有跟进的报价等于没报', level: 'L2', predicate: 'followupSet = true' },
|
||||
{ key: 'bd-release', label: 'BD 放行', level: 'L5' },
|
||||
{
|
||||
key: 'roster-complete',
|
||||
label: '报价函齐:上游条目逐条承继,说了几条报价函里就躺着几行',
|
||||
level: 'L1',
|
||||
predicate: `line_total > 0 and ${D}_line_rows = line_total`,
|
||||
},
|
||||
{
|
||||
key: 'replied-all',
|
||||
label: '每条都有客户表态:还价的那几条还落下了客户报的那个价,缺一行放不了',
|
||||
level: 'L1',
|
||||
predicate: `${D}_line_rows > 0 and ${D}_line_missing = 0`,
|
||||
},
|
||||
{
|
||||
key: 'reply-typed',
|
||||
label: '表态逐条落成四种之一:接受 / 还价 / 拒绝 / 无回复 —— 没有含糊其辞的格子',
|
||||
level: 'L2',
|
||||
predicate: `${D}_line_reply_type_accept + ${D}_line_reply_type_counter + ${D}_line_reply_type_reject + ${D}_line_reply_type_noreply = ${D}_line_rows`,
|
||||
},
|
||||
{
|
||||
key: 'settled',
|
||||
label: '定案逐条落定:成交 / 待拍板 / 退回重批 / 不成交,四档相加等于报价函条数',
|
||||
level: 'L2',
|
||||
modelRef: 'n01-outcome',
|
||||
predicate: `${D}_line_rows > 0 and ${D}_line_deal_status_won + ${D}_line_deal_status_haggle + ${D}_line_deal_status_revise + ${D}_line_deal_status_lost = ${D}_line_rows`,
|
||||
},
|
||||
{
|
||||
/* 与 A4 的「拦截档清零」同一形状:还价的条目要么接、要么退回重批、要么守价不让 */
|
||||
key: 'haggle-cleared',
|
||||
label: '客户还的价都拍过板了:接了、退回重批了、还是守价不让 —— 没有还悬着的条目',
|
||||
level: 'L2',
|
||||
predicate: `${D}_line_deal_status_haggle = 0`,
|
||||
},
|
||||
{
|
||||
/*
|
||||
* 退回重批的行**必是客户还过价的行** —— 两边都是基座按值现数。
|
||||
* 没人还价却判了「退回重批」,是模型给自己找了一条不必叫人的近路(实测栽过一轮)。
|
||||
*/
|
||||
key: 'revise-from-counter',
|
||||
label: '判了退回重批的,都是客户真还过价的那几条 —— 没有凭空退回的条目',
|
||||
level: 'L2',
|
||||
predicate: `${D}_line_deal_status_revise <= ${D}_line_reply_type_counter`,
|
||||
},
|
||||
/*
|
||||
* 议价过程可核是**裁量**(L3):客户的话记没记全、让价的理由站不站得住,
|
||||
* 机器数不出来。这句 label 与剧本里 judge 的 criterion **必须逐字一致**。
|
||||
*/
|
||||
{
|
||||
key: 'negotiation-defensible',
|
||||
label: '议价过程说得清:报价函条款齐备、每一轮客户反馈与让价理由逐条可溯,BD 对这个结果负责',
|
||||
level: 'L3',
|
||||
},
|
||||
/* L5 是责任关:那张卡本身就是这道关,卡面上不摆它(基座统一滤) */
|
||||
{ key: 'bd-release', label: 'BD 放行:签下去这一单的议价结果即定案并按去向交接', level: 'L5' },
|
||||
],
|
||||
revisableAtRelease: [],
|
||||
prohibitions: [
|
||||
{ key: 'no-break-line', statement: '客户议价超出授权范围的,回 A5 重批,不私自破线' },
|
||||
{ key: 'no-send-before-release', statement: '报价单在放行前不得发出 —— 写类动作集中在放行时落地' },
|
||||
{ key: 'no-speak-for-customer', statement: '不许替客户表态:客户没回的行按无回复落,不得写成接受 —— 那是在系统里伪造一份客户承诺' },
|
||||
{ key: 'no-break-line', statement: '客户要的价超出让价授权的,退回成本定价重批,不私自破线' },
|
||||
{ key: 'no-send-before-release', statement: '报价函在放行前不得对外发出 —— 触达世界的动作集中在放行那一刻' },
|
||||
],
|
||||
authorization: {
|
||||
discretion: [{ key: 'haggle-band', statement: '在已批准的价格区间内回旋;出区间必须回审批' }],
|
||||
// 预授权必须逐项列举:催办模板可即时发,报价单不行
|
||||
preauthorizedActions: [{ tool: 'mail.send', operation: 'nudge', limit: '仅催办模板' }],
|
||||
humanCallBudget: 3,
|
||||
discretion: [{ key: 'haggle-band', statement: '让步梯度 N-02 给的授权区间内自决;超过一成的让幅一律回成本定价重批' }],
|
||||
preauthorizedActions: [],
|
||||
/* 一张执行单 + 可能一张决策卡 + 放行 = 三次;留一倍余量 */
|
||||
humanCallBudget: 5,
|
||||
},
|
||||
resources: {
|
||||
tools: [{ name: 'mail.send' }, { name: 'crm.history-price-read' }],
|
||||
/*
|
||||
* **不调工具**。上一版声明了 `mail.send`(写类:登记意向、放行时落地)——
|
||||
* 而「把报价函发给客户」这件事在 MVP 示例工作流 §1.4 里本来就是**人工任务**:
|
||||
* 收信的邮箱是 BD 的,客户回什么也只有他看得见。让活动登记一条发信意向,
|
||||
* 既要过落地预检(收件人 / 主题 / 工具授权三道),又与执行单上那句
|
||||
* 「请把它发给客户」重复,而没人说得清究竟哪一份才真发了出去。
|
||||
*/
|
||||
tools: [],
|
||||
skills: NEGOTIATE_SKILLS.map((s) => s.id),
|
||||
models: ['deepseek-v4-flash'],
|
||||
roles: ['BD'],
|
||||
budget: { steps: 50, tokens: 120_000, activeMinutes: 60, humanCalls: 3 },
|
||||
/* SLA:15 分钟内发出、D+3/D+7 催、有效期 7 天;预算按 A4 实测(6-9 拍)留一倍余量 */
|
||||
budget: { steps: 60, tokens: 400_000, activeMinutes: 120, humanCalls: 5 },
|
||||
},
|
||||
},
|
||||
|
||||
/* ── 声明 ────────────────────────────────────────────────────── */
|
||||
declarations: {
|
||||
models: ['deepseek-v4-flash'],
|
||||
tools: [{ name: 'mail.send' }, { name: 'crm.history-price-read' }],
|
||||
tools: [],
|
||||
ledger: [
|
||||
{
|
||||
domain: DOMAIN,
|
||||
object: 'formal_quote',
|
||||
label: '正式报价单',
|
||||
domain: D,
|
||||
object: 'line',
|
||||
kind: 'record',
|
||||
caseKey: 'rfq_ref',
|
||||
label: '正式报价函',
|
||||
fields: [
|
||||
{ name: 'quote_no', type: 'text', primaryKey: true, masked: false, label: '报价单号' },
|
||||
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
|
||||
{ name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' },
|
||||
{ name: 'unit_price', type: 'number', primaryKey: false, masked: false, label: '单价CNY' },
|
||||
{ name: 'customer_pn', type: 'text', primaryKey: false, masked: false, label: '客户料号' },
|
||||
{ name: 'category', type: 'text', primaryKey: false, masked: false, label: '品类' },
|
||||
{ name: 'brand', type: 'text', primaryKey: false, masked: false, label: '制造商' },
|
||||
{ name: 'eau', type: 'integer', primaryKey: false, masked: false, label: '年用量 EAU' },
|
||||
{ name: 'lead_time', type: 'text', primaryKey: false, masked: false, label: '交期' },
|
||||
{ name: 'spq', type: 'integer', primaryKey: false, masked: false, label: 'SPQ' },
|
||||
{ name: 'moq', type: 'integer', primaryKey: false, masked: false, label: 'MOQ' },
|
||||
/* 客户备注承继自上游 —— 认证要求、预授权、急料标注,议价时的第一手依据 */
|
||||
{ name: 'remark', type: 'text', primaryKey: false, masked: false, label: '客户备注(约束/急料)' },
|
||||
{ name: 'type_tag', type: 'text', primaryKey: false, masked: false, label: '类别(承继)', tally: ['A', 'B', 'C'] },
|
||||
/* ── 报价函的条款面:发出去就是对客户的承诺 ─────────────────── */
|
||||
{ name: 'quoted_price', type: 'number', primaryKey: false, masked: false, label: '报价 CNY(已批准)' },
|
||||
{ name: 'spq', type: 'integer', primaryKey: false, masked: false, label: 'SPQ 最小包装' },
|
||||
{ name: 'moq', type: 'integer', primaryKey: false, masked: false, label: 'MOQ 最小起订' },
|
||||
{ name: 'trade_terms', type: 'text', primaryKey: false, masked: false, label: '贸易条款' },
|
||||
{ name: 'valid_until', type: 'date', primaryKey: false, masked: false, label: '有效期' },
|
||||
{ name: 'valid_until', type: 'date', primaryKey: false, masked: false, label: '有效期至' },
|
||||
/*
|
||||
* ── 客户那一侧的三列:**AI 一格都不写** ────────────────────────
|
||||
*
|
||||
* 与 A4 的 `quoted_price` 同一处结构治:一条要模型分辨「这行能不能替客户填」
|
||||
* 的规矩会走岔;「这几列不写」没有分支可走岔。
|
||||
*
|
||||
* `reply_type` 标 `required` —— 件上的缺项红带、执行卡的提交闸、放行门 L1
|
||||
* 三处同源(都数基座的 `_missing`)。
|
||||
* `counter_price` 用 `requiredWhen`:**只有还价那一行才算必填**
|
||||
* (表级 required 会让接受/拒绝的行永远缺一格,把闸拦成死锁)。
|
||||
*/
|
||||
{
|
||||
name: 'reply_type',
|
||||
type: 'text',
|
||||
primaryKey: false,
|
||||
masked: false,
|
||||
required: true,
|
||||
label: '客户表态',
|
||||
tally: ['ACCEPT', 'COUNTER', 'REJECT', 'NOREPLY'],
|
||||
},
|
||||
{
|
||||
name: 'counter_price',
|
||||
type: 'number',
|
||||
primaryKey: false,
|
||||
masked: false,
|
||||
requiredWhen: { column: 'reply_type', value: 'COUNTER' },
|
||||
label: '客户还价',
|
||||
},
|
||||
{ name: 'reply_note', type: 'text', primaryKey: false, masked: false, label: '客户原话 / 让价理由' },
|
||||
/* ── 定案面:两把尺算出来的,模型照抄 ─────────────────────────── */
|
||||
/*
|
||||
* 定案四档,**HAGGLE 是过渡态**(客户还了价、还没拍板):拍板那一下把它换成
|
||||
* WON / REVISE / LOST 之一,于是「还价才叫人」那道哨兵当场为假。
|
||||
* 上一版只有三档、退回重批那条路「定案不动」—— 人点完了 haggle 还是那个数,
|
||||
* 哨兵照旧成立,模型读同一段做法就再发一张卡:**实测同一张连发六次**。
|
||||
* 措辞治不了这种事,得让「办完了」在数上看得见(2026-08-03 逼出来的账)。
|
||||
*/
|
||||
{ name: 'deal_status', type: 'text', primaryKey: false, masked: false, label: '定案', tally: ['WON', 'HAGGLE', 'REVISE', 'LOST'] },
|
||||
{
|
||||
name: 'concession',
|
||||
type: 'text',
|
||||
primaryKey: false,
|
||||
masked: false,
|
||||
label: '让步处置(N-02)',
|
||||
tally: ['ACCEPT_COUNTER', 'NEGOTIATE', 'ESCALATE'],
|
||||
},
|
||||
{ name: 'final_price', type: 'number', primaryKey: false, masked: false, label: '成交价' },
|
||||
],
|
||||
},
|
||||
{
|
||||
domain: 'shared',
|
||||
object: 'history_price',
|
||||
label: '历史价格情报(共享域)',
|
||||
/*
|
||||
* 议价结论 —— 一单一行的**汇总表**,哨兵与报告读它。
|
||||
* 汇总列与逐行列**故意不同名**:任务视图把草稿行摊平到顶层是「后写的赢」,
|
||||
* 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。
|
||||
*/
|
||||
domain: D,
|
||||
object: 'verdict',
|
||||
kind: 'record',
|
||||
caseKey: 'rfq_ref',
|
||||
label: '议价结论',
|
||||
fields: [
|
||||
{ name: 'pn', type: 'text', primaryKey: true, masked: false },
|
||||
{ name: 'quoted_at', type: 'date', primaryKey: true, masked: false, label: '报价日期' },
|
||||
{ name: 'unit_price', type: 'number', primaryKey: false, masked: false },
|
||||
{ name: 'lead_time', type: 'text', primaryKey: false, masked: false },
|
||||
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
|
||||
{ name: 'quote_no', type: 'text', primaryKey: false, masked: false, label: '报价单号' },
|
||||
{ name: 'customer_code', type: 'text', primaryKey: false, masked: false, label: '客户码' },
|
||||
{ name: 'customer_name', type: 'text', primaryKey: false, masked: false, label: '客户名称' },
|
||||
{ name: 'line_total', type: 'integer', primaryKey: false, masked: false, label: '报价函条数' },
|
||||
{ name: 'valid_days', type: 'integer', primaryKey: false, masked: false, label: '报价有效期天数' },
|
||||
/* 排版走完的凭据(0 也要写)—— 哨兵按它放交人发出那一步入场 */
|
||||
{ name: 'composed_total', type: 'integer', primaryKey: false, masked: false, label: '已排版条数' },
|
||||
/* 「客户回音落齐了」的凭据 —— 只在人环走完才产生 */
|
||||
{ name: 'replied_total', type: 'integer', primaryKey: false, masked: false, label: '已落客户表态条数' },
|
||||
{ name: 'settled_total', type: 'integer', primaryKey: false, masked: false, label: '已定案条数' },
|
||||
{ name: 'route_decided_total', type: 'integer', primaryKey: false, masked: false, label: '已定案定盘' },
|
||||
{ name: 'outcome', type: 'text', primaryKey: false, masked: false, label: '议价结果(won/revise/lost)' },
|
||||
{ name: 'reason', type: 'text', primaryKey: false, masked: false, label: '不成交 / 退回缘由' },
|
||||
{ name: 'summary', type: 'text', primaryKey: false, masked: false, label: '议价结论' },
|
||||
],
|
||||
},
|
||||
],
|
||||
/*
|
||||
* 三条静态监听 —— **时限必配**(AM 4.4)。等客户回音这件事的落单人是 BD
|
||||
* (见 case-model 头注),这三条管的是「别把它挂到天荒地老」:
|
||||
* D+3 催一轮、D+7 最后一催并升级、客户真从通道回信时也认得出是这一单。
|
||||
*/
|
||||
listeners: [
|
||||
{
|
||||
origin: 'static',
|
||||
eventType: 'time',
|
||||
keyFilter: { kind: 'followup-d3' },
|
||||
semantic: 'observe',
|
||||
deadline: 'P3D',
|
||||
escalation: ['nudge'],
|
||||
},
|
||||
{
|
||||
origin: 'static',
|
||||
eventType: 'time',
|
||||
keyFilter: { kind: 'quote-expiry' },
|
||||
semantic: 'observe',
|
||||
deadline: 'P7D',
|
||||
escalation: ['nudge', 'escalate'],
|
||||
},
|
||||
{
|
||||
origin: 'static',
|
||||
eventType: 'data',
|
||||
keyFilter: { kind: 'customer-reply' },
|
||||
semantic: 'complete-step',
|
||||
// 建 D+3 跟进监听:等客户回音,时限必配 —— 无死等
|
||||
deadline: 'P3D',
|
||||
escalation: ['nudge', 'escalate'],
|
||||
semantic: 'observe',
|
||||
deadline: 'P7D',
|
||||
escalation: ['nudge'],
|
||||
},
|
||||
],
|
||||
harness: {
|
||||
memoryWindow: 20,
|
||||
stallAfter: 'PT15M',
|
||||
nudge: [{ after: 'PT10M', action: 'nudge' }],
|
||||
memoryWindow: 22,
|
||||
// 报价有效期 P-04 是 7 天,但这一单的**活办完**只需两个人环
|
||||
stallAfter: 'PT2H',
|
||||
nudge: [{ after: 'PT1H', action: 'nudge' }],
|
||||
retry: { times: 2, backoff: 'PT30S' },
|
||||
},
|
||||
},
|
||||
|
||||
/* ── 资产初始版 ──────────────────────────────────────────────── */
|
||||
assets: {
|
||||
/* 资产初始版:案例模型(2026-07-29 起活动只有这一种业务模型) */
|
||||
businessModel: {
|
||||
cmmnXml: CMMN_XML,
|
||||
narrative: NARRATIVE,
|
||||
dmn: { drd: [], tables: [] },
|
||||
dmn: N01_DMN,
|
||||
steps: NEGOTIATE_STEPS,
|
||||
phases: NEGOTIATE_PHASES,
|
||||
},
|
||||
skills: NEGOTIATE_SKILLS,
|
||||
/*
|
||||
* 两个局面各一张(与 A2/A3/A4 同款口径):
|
||||
* · `negotiate-worksheet` 议价工作单 —— 排版、发出与落回音期间摆它(逐颗报价档案、
|
||||
* 两把尺、填回工作表);
|
||||
* · `negotiate-roster` 成交定案清单 —— 拍板、放行、归档摆它。
|
||||
*/
|
||||
viewTemplates: [
|
||||
{ name: 'quote-composed', html: QUOTE_COMPOSED },
|
||||
{ name: 'quote-release', html: QUOTE_RELEASE },
|
||||
{ name: 'negotiate-worksheet', html: NEGOTIATE_WORKSHEET },
|
||||
{ name: 'negotiate-roster', html: NEGOTIATE_ROSTER },
|
||||
],
|
||||
cardSchemas: {},
|
||||
distillHooks: [{ on: 'terminal', prompt: '归纳本单议价的经验:客户的还价幅度、成交与否的关键因素。' }],
|
||||
distillHooks: [
|
||||
{
|
||||
on: 'terminal',
|
||||
prompt: '归纳本单议价:客户的还价幅度与说法、让步梯度哪一档最常命中、成交与不成交的关键因素。',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
/* ── 样例集 —— 三条形状真不同(常备案例即验收案例)───────────── */
|
||||
samples: [
|
||||
{
|
||||
name: '车规 Analog 报价',
|
||||
input: { customerCode: 'C001', items: [{ pn: 'LM358DR', quotedPrice: 1.95 }] },
|
||||
/*
|
||||
* ① 流内常路:A4 `draft` 口的真实形状 —— items 里草案行(带批准价与门禁处置)
|
||||
* 混着结论行。客户一轮就全接受,一个人都不必额外惊动 → won。
|
||||
*/
|
||||
name: '流内转来 · 两条已批准 · 客户一轮全接受(常路)',
|
||||
input: {
|
||||
rfqRef: 'RFQ MD14.1',
|
||||
customerCode: 'CU-0142',
|
||||
customer_name: 'Itelma',
|
||||
items: [
|
||||
{
|
||||
rfq_ref: 'RFQ MD14.1',
|
||||
pn: 'BBPY00201209601Y00',
|
||||
customer_pn: '4-14-01502',
|
||||
category: '滤波器',
|
||||
brand: 'YAGEO',
|
||||
eau: 30000,
|
||||
lead_time: '8周',
|
||||
type_tag: 'B',
|
||||
quoted_price: 0.085,
|
||||
disposition: 'PASS',
|
||||
price_note: '客户长单,按目标价接',
|
||||
},
|
||||
{
|
||||
rfq_ref: 'RFQ MD14.1',
|
||||
pn: 'CJTB0101R6N',
|
||||
customer_pn: '4-00-04102',
|
||||
category: '集成电路',
|
||||
brand: 'Jiangsu Changjing',
|
||||
eau: 60000,
|
||||
lead_time: '6周',
|
||||
type_tag: 'A',
|
||||
quoted_price: 0.42,
|
||||
disposition: 'PASS',
|
||||
},
|
||||
{ rfq_ref: 'RFQ MD14.1', quote_line_total: 2, customer_name: 'Itelma', summary: '两条已批准,交议价' },
|
||||
],
|
||||
},
|
||||
expect: { ports: ['won'] },
|
||||
},
|
||||
{
|
||||
/*
|
||||
* ② 客户压价:一条还了价、压得超过一成 —— 让步梯度判 ESCALATE(超出授权),
|
||||
* 必须叫人拍板(决策卡);拍板选「退回成本定价重批」→ revise。
|
||||
*/
|
||||
name: '客户压价一成以上 · 决策卡拍板 → 退回定价重批',
|
||||
input: {
|
||||
rfqRef: 'RFQ-2026-0777',
|
||||
customerCode: 'CU-0311',
|
||||
customer_name: '青岛海洋电子',
|
||||
items: [
|
||||
{
|
||||
rfq_ref: 'RFQ-2026-0777',
|
||||
pn: 'PESD2ETH-XR',
|
||||
category: '二极管',
|
||||
brand: 'Nexperia',
|
||||
eau: 30000,
|
||||
lead_time: '8周',
|
||||
type_tag: 'B',
|
||||
quoted_price: 0.098,
|
||||
disposition: 'NOTE',
|
||||
price_note: '竞对价压,已让到底价线内',
|
||||
remark: '客户明说这个价是竞对给的,要跟',
|
||||
},
|
||||
{
|
||||
rfq_ref: 'RFQ-2026-0777',
|
||||
pn: 'NE555P',
|
||||
category: '定时器',
|
||||
brand: 'TI',
|
||||
eau: 5000,
|
||||
lead_time: '4周',
|
||||
type_tag: 'A',
|
||||
quoted_price: 1.05,
|
||||
disposition: 'PASS',
|
||||
},
|
||||
{ rfq_ref: 'RFQ-2026-0777', quote_line_total: 2, customer_name: '青岛海洋电子' },
|
||||
],
|
||||
},
|
||||
expect: { ports: ['revise'] },
|
||||
},
|
||||
{
|
||||
/*
|
||||
* ③ 单飞宽进:没有单号没有清单,正文说了发给谁、报什么价 —— 归一造案号;
|
||||
* 客户两轮催问都不回,按超时定案 → lost(下游 A7 情报归档)。
|
||||
*/
|
||||
name: '单飞需求 · 正文列两颗料 · 客户 D+7 无回复(超时定案)',
|
||||
input: {
|
||||
subject: 'MD14.2 报价函发客户',
|
||||
body: '定价审批已通过:XCJ-7788T 电源模块 8K,批准价 14.80;LMR-3302QQ 稳压器 15K,批准价 2.35。客户是新客户 深圳市锐驰电子,联系人王工 wang@ruichi.example.cn,交期都按 6 周报。请发正式报价函并按 D+3/D+7 跟进。',
|
||||
},
|
||||
expect: { ports: ['lost'] },
|
||||
},
|
||||
],
|
||||
|
||||
entry: { file: 'index.js', checksum: 'dev-entry-quote-negotiate' },
|
||||
|
||||
@@ -1,42 +1,56 @@
|
||||
/**
|
||||
* A6 技能 —— 议价与出单的方法经验(AM 08)。
|
||||
* A6 技能 —— 议价的方法经验。载方法,不载权力。
|
||||
*
|
||||
* 与剧本的分工:剧本写「这一步做什么、写哪几列」,技能写「为什么这么谈、
|
||||
* 拿不准时按哪条走」。剧本改了口径,技能里同一句要跟着改(规范 §5.9)。
|
||||
*/
|
||||
import { skillIdSchema } from '@contracts';
|
||||
import type { Skill } from '@contracts';
|
||||
|
||||
export const NEGOTIATE_SKILLS: Skill[] = [
|
||||
{
|
||||
id: skillIdSchema.parse('negotiate.compose-quote'),
|
||||
label: '拟一份能发出去的报价单',
|
||||
applicability: '定价已过审批,要生成正式报价单时。',
|
||||
id: skillIdSchema.parse('negotiate.read-reply'),
|
||||
label: '读懂客户的那一句回音',
|
||||
applicability: '客户回话了,要把它落成一个表态时。',
|
||||
method: [
|
||||
'报价单是**要发给客户**的东西,所以每一项都得能兑现:',
|
||||
'· 单价与交期照审批通过的草案抄,不做任何「顺手优化」——改价要重新过审批点。',
|
||||
'· SPQ / MOQ / 贸易条款 / 有效期缺一不可:缺了客户会追问,追问就是又一轮往返。',
|
||||
'· 有效期按行情波动定,不要一律写「长期有效」——那等于把汇率与料价风险自己扛了。',
|
||||
'客户的话落到系统里只有四种表态,别的都是这四种的说法之一:',
|
||||
'· ACCEPT —— 明确接受这个价:「按这个价走」「下单吧」「PO 稍后发」。',
|
||||
'· COUNTER —— 还了一个价,或明确说「贵了多少」「对标某某家便宜多少」。',
|
||||
' **还价必须落下客户报的那个数**:没有数字的「太贵了」先追问,追不到才按无回复落。',
|
||||
'· REJECT —— 明确不做了:选了别家、项目取消、规格不匹配。',
|
||||
'· NOREPLY —— 催到有效期过仍不表态。**客户不回不是结论**:',
|
||||
' D+3 第一轮催问确认收到探态度,D+7 最后一催,催过了才轮到按超时定案。',
|
||||
'',
|
||||
'拿不准的项**宁可空着走补全**,不要填一个看起来合理的值:',
|
||||
'报价单一旦发出就是承诺,发出前的空白是成本,发出后的错误是损失。',
|
||||
'两条别做的事:',
|
||||
'· 不要替客户表态。他只回了一部分料的,没回的那几行就是 NOREPLY ——',
|
||||
' 把「大概是接受了吧」写成 ACCEPT,是在系统里伪造一份客户承诺。',
|
||||
'· 不要把口头承诺留在脑子里。每轮反馈当天记回备注,',
|
||||
' 下一轮谈判、事后复盘、情报归档读的都是这一格。',
|
||||
].join('\n'),
|
||||
crystallized: [],
|
||||
knowledgeRefs: ['报价单模版规范'],
|
||||
version: '1.0.0',
|
||||
knowledgeRefs: ['议价结果 N-01'],
|
||||
version: '2.0.0',
|
||||
},
|
||||
{
|
||||
id: skillIdSchema.parse('negotiate.pick-outcome'),
|
||||
label: '选对议价出口',
|
||||
applicability: '客户回复后,要确认议价结果出口时。',
|
||||
id: skillIdSchema.parse('negotiate.concede'),
|
||||
label: '让价守线',
|
||||
applicability: '客户还了价,要决定让不让、让多少时。',
|
||||
method: [
|
||||
'三个出口对应三种后续,**恰发一口**:',
|
||||
'· 成交 → 转订单履约,这条流走到终点。',
|
||||
'· 有异议 → 回 A4 重开新实例重算 —— 注意是**新实例**,旧的已终、不再入算。',
|
||||
'· 拒绝 / 超时 → 转 A7 情报归档:把拒绝原因与竞对价位登记下来。',
|
||||
'先弄清争的是什么:价格、交期、MOQ,还是走过场压一轮 ——',
|
||||
'**对症再谈,不上来就让**。争交期的用交期换,争 MOQ 的用包装换;',
|
||||
'一上来就降价,后面每一轮就都只剩降价这一招。',
|
||||
'',
|
||||
'「拒绝」不是失败终态。这一单丢了,但拒绝原因回写历史情报后,',
|
||||
'下一单的三分类与利润模型会用上它 —— 这才是归档存在的理由。',
|
||||
'让步梯度 N-02 是尺,不是台阶(不必逐档走下去):',
|
||||
'· 三个点以内 —— 意思意思的压价,接了拿单,来回一轮的时间成本比这三个点贵;',
|
||||
'· 三到十个点 —— 授权内回旋:让一半再报回去,把让出去的换成量或账期;',
|
||||
'· 超过一成 —— 超出授权,**交回成本定价重批,不私自破线**。',
|
||||
' 破线不是勇气是事故:底价线以下的单,每补一次货都在亏。',
|
||||
'',
|
||||
'让了就要说清为什么让:客户长单、带单品、竞对压价 ——',
|
||||
'理由随定案一起交给签字的人。没有理由的让价,到复盘那一关同样过不去。',
|
||||
].join('\n'),
|
||||
crystallized: [],
|
||||
knowledgeRefs: [],
|
||||
version: '1.0.0',
|
||||
knowledgeRefs: ['让步梯度 N-02', '底价门禁 P-02'],
|
||||
version: '2.0.0',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* A1 两份执行页的样式表 —— **从 PRD 目标件原样搬过来**,不是我照着复述的。
|
||||
*
|
||||
* 出处:`~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理需求.html` 的整段 `<style>`,
|
||||
* 外加 `A1-询价受理记录.html` 里归档件独有的那几件(分类分布条 / 类徽 / 判定表 /
|
||||
* 结论横幅 / 落地动作 / 完成标)。**一个数值都没改**。
|
||||
*
|
||||
* 为什么是搬而不是抄:前三轮我用自己那套外壳「照着做」,块搬齐了、版面是两套 ——
|
||||
* 章节头成了彩色方块编号、多出摘要与元信息带、字号用 clamp。人一眼就看出不是那份件。
|
||||
* 目标件是**像素级的答案**,搬过来才不会在复述里走样。
|
||||
*
|
||||
* 选择器保留 `.proto-root .app-shell` 前缀,所以模版正文也要照原型那样包两层壳 ——
|
||||
* 少一层壳,整张样式表一条都不生效(踩过)。
|
||||
*/
|
||||
export const PRD_CSS = `
|
||||
/* ============================================================================
|
||||
JCAI 员工面工作台 · A1 受理归一的过程成果「询价受理需求」——报告本体
|
||||
立场:**AI 已从 RFQ 邮件抽取归一**,人在这里审核并就地修订。
|
||||
两态:读态(AI 稿 + 红/灰占位提示缺项) ⇄ 编辑态(全字段可改,保存 / 取消)
|
||||
核心是「有效 PN 清单」;清单之上四件受理要素:客户公司 / 联系人 / 客户属性 / 有效 PN。
|
||||
|
||||
令牌与既有组件取自 workbench/src/app/(proto)/app.css,数值未改。
|
||||
⚠️ 修正一处原型 bug:app.css:2062-2064 的 '.jc-rp-sign span'(权重 0,1,1)
|
||||
盖过了 '.jc-rp-ava'(0,1,0),把头像的 display:grid 覆盖成 block、字色改灰,
|
||||
导致落款头像里的字掉到圆外。本文件把该规则收窄为 '.jc-rp-sign > div > span'。
|
||||
新增件:四要素卡 .jc-four/.jc-fcard · 占位胶囊 .jc-ph · 分段控件 .jc-seg ·
|
||||
来源条 .jc-src · 录入表 .jc-tbl-in
|
||||
========================================================================== */
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body { background: #e6eaf1; -webkit-font-smoothing: antialiased; }
|
||||
|
||||
.proto-root .app-shell {
|
||||
--brand: #3370ff; --brand-deep: #245bdb;
|
||||
--brand-soft: #e1eaff; --brand-wash: #f0f4ff;
|
||||
--ai-grad: linear-gradient(135deg, #3370ff, #7f3bf5);
|
||||
--ink: #1f2329; --ink-2: #41464e; --ink-3: #646a73; --ink-4: #8f959e;
|
||||
--line: #dee0e3; --line-soft: #e8eaed; --soft: #f8f9fb;
|
||||
--jcai-bg: #e6eaf1; --cardline: #e1e6ec; --card-shadow: 0 1px 2px rgba(31, 35, 41, .05);
|
||||
--ok: #278717; --ok-soft: #e8f8e4;
|
||||
--warn: #de7802; --warn-accent: #ff8800; --warn-soft: #fff4e5;
|
||||
--danger: #d83931; --danger-soft: #feeae9;
|
||||
--mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace;
|
||||
--shadow-brand: 0 6px 16px rgba(51, 112, 255, .22);
|
||||
--t-fast: .16s cubic-bezier(.3, 0, .2, 1);
|
||||
--t-med: .26s cubic-bezier(.3, 0, .2, 1);
|
||||
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: var(--jcai-bg); min-height: 100vh; color: var(--ink);
|
||||
}
|
||||
.proto-root :where(button, input, select, a, [tabindex]):focus-visible {
|
||||
outline: 2px solid var(--brand); outline-offset: 2px; border-radius: 8px;
|
||||
}
|
||||
.proto-root ::selection { background: var(--brand-soft); }
|
||||
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes jcBlink { 0%,100% { opacity: 1 } 50% { opacity: .35 } }
|
||||
@keyframes jcStamp { from { opacity: 0; transform: rotate(-6deg) scale(1.7); } to { opacity: .9; transform: rotate(-6deg) scale(1); } }
|
||||
|
||||
/* 放开宽度:1160px 是「9 列全展开、一个字都不裁」的最小容器宽度(见下方列 min-width 之和) */
|
||||
.jc-stage { max-width: 1160px; margin: 0 auto; padding: 22px 16px 52px; }
|
||||
.proto-root .jc-mono { font-family: var(--mono); font-size: 11px; color: var(--ink-3); }
|
||||
.proto-root .jc-pulse-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--brand); display: inline-block; animation: jcBlink 1.1s ease-in-out infinite; }
|
||||
|
||||
/* ── 报告外壳 ───────────────────────────────────────────────────────── */
|
||||
.proto-root .jc-report { background: #fff; border: 1px solid var(--cardline); border-radius: 14px; box-shadow: var(--card-shadow); overflow: hidden; animation: rise var(--t-med) both; }
|
||||
.proto-root .jc-rp-head { position: relative; overflow: hidden; display: flex; align-items: flex-start; gap: 12px; padding: 16px 20px 13px; border-bottom: 2px solid var(--ink); }
|
||||
/* 题头下缘粗细双线 = 正式件版式 */
|
||||
.proto-root .jc-rp-head::after { content: ""; position: absolute; left: 0; right: 0; bottom: -4px; height: 1px; background: var(--line); }
|
||||
/* 题头右上的品牌光晕:与工作台标题区同一签名件 */
|
||||
.proto-root .jc-rp-head::before { content: ""; position: absolute; right: -70px; top: -96px; width: 240px; height: 240px; border-radius: 999px; background: radial-gradient(circle, rgba(51,112,255,.10), transparent 70%); pointer-events: none; }
|
||||
.proto-root .jc-rp-head > * { position: relative; z-index: 1; }
|
||||
.proto-root .jc-rp-badge { flex: none; width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand); }
|
||||
.proto-root .jc-rp-tit { flex: 1; min-width: 0; }
|
||||
.proto-root .jc-rp-tit em { display: block; font-style: normal; font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); font-family: var(--mono); }
|
||||
.proto-root .jc-rp-tit h3 { margin: 4px 0 0; font-size: 18px; font-weight: 800; color: var(--ink); letter-spacing: -.01em; line-height: 1.3; }
|
||||
.proto-root .jc-rp-wait { display: inline-flex; align-items: center; gap: 6px; flex: none; margin-top: 4px; font-size: 11px; font-weight: 700; border-radius: 999px; padding: 4px 12px; color: var(--brand-deep); background: var(--brand-wash); border: 1px solid var(--brand-soft); }
|
||||
.proto-root .jc-rp-wait.warn { color: var(--warn); background: var(--warn-soft); border-color: #ffd9a8; }
|
||||
.proto-root .jc-rp-wait.edit { color: #fff; background: var(--brand); border-color: var(--brand); }
|
||||
.proto-root .jc-rp-stamp { flex: none; margin-top: 2px; transform: rotate(-6deg); border: 2px solid currentColor; border-radius: 9px; padding: 4px 8px 3px 12px; font-size: 13px; font-weight: 900; letter-spacing: .28em; color: var(--ok); opacity: .9; animation: jcStamp .32s cubic-bezier(.2, 1.4, .4, 1) both; }
|
||||
.proto-root .jc-rp-bd { padding: 18px 20px 12px; }
|
||||
.proto-root .jc-rp-part { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 9px; margin: 20px 0 11px; }
|
||||
.proto-root .jc-rp-part:first-child { margin-top: 0; }
|
||||
.proto-root .jc-rp-part > b { flex: none; font-size: 11px; font-weight: 800; letter-spacing: .12em; color: var(--ink-3); }
|
||||
.proto-root .jc-rp-part > .ln { flex: 1; height: 1px; background: var(--line-soft); }
|
||||
.proto-root .jc-rp-part > em { flex: none; font-style: normal; font-size: 10px; font-weight: 700; color: var(--ink-4); font-family: var(--mono); background: var(--soft); border-radius: 999px; padding: 2px 9px; white-space: nowrap; }
|
||||
.proto-root .jc-rp-part > em.warn { color: var(--warn); background: var(--warn-soft); }
|
||||
|
||||
/* 落款 —— ⚠️ 选择器收窄到 > div > *,别再命中头像那个 span */
|
||||
.proto-root .jc-rp-foot { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 13px 20px 15px; border-top: 2px solid var(--line); background: #fbfcfd; }
|
||||
.proto-root .jc-rp-sign { display: flex; align-items: center; gap: 10px; flex: 1 1 200px; min-width: 0; }
|
||||
.proto-root .jc-rp-ava { flex: 0 0 auto; width: 30px; height: 30px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; line-height: 1; color: #fff; background: var(--brand); }
|
||||
.proto-root .jc-rp-sign > div { min-width: 0; }
|
||||
.proto-root .jc-rp-sign > div > b { display: block; font-size: 12px; font-weight: 700; color: var(--ink-2); }
|
||||
.proto-root .jc-rp-sign > div > span { display: block; margin-top: 1px; font-size: 10.5px; color: var(--ink-4); }
|
||||
.proto-root .jc-rp-acts { display: flex; align-items: center; gap: 8px; margin-left: auto; flex: none; }
|
||||
.proto-root .jc-hintline { font-size: 11px; font-weight: 700; color: var(--warn); line-height: 1.5; }
|
||||
|
||||
.proto-root .jc-btn { display: inline-flex; align-items: center; gap: 5px; border: 0; border-radius: 7px; padding: 8px 14px; font-size: 12px; font-weight: 600; font-family: inherit; background: var(--brand); color: #fff; cursor: pointer; transition: all var(--t-fast); }
|
||||
.proto-root .jc-btn:hover:not(:disabled) { background: var(--brand-deep); }
|
||||
.proto-root .jc-btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.proto-root .jc-btn-ghost { background: #fff; color: var(--ink-2); border: 1px solid var(--line); }
|
||||
.proto-root .jc-btn-ghost:hover:not(:disabled) { background: var(--brand-wash); color: var(--brand); border-color: var(--brand-soft); }
|
||||
.proto-root .jc-btn-primary { background: var(--ai-grad); }
|
||||
.proto-root .jc-btn-primary:hover:not(:disabled) { filter: brightness(1.07); box-shadow: var(--shadow-brand); transform: translateY(-1px); }
|
||||
|
||||
/* ── 来源信件:客户 RFQ 邮件原文(复用原型 jc-letter 件,青色 = 收件) ── */
|
||||
.proto-root .jc-letter { position: relative; border: 1px solid var(--cardline); border-left: 3px solid #0aa294; border-radius: 12px; padding: 14px 16px 15px; background: #fff; box-shadow: var(--card-shadow); }
|
||||
.proto-root .jc-letter-h { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 11px; }
|
||||
.proto-root .jc-letter-dir { display: inline-flex; align-items: center; gap: 5px; font-size: 10.5px; font-weight: 800; padding: 3px 9px; border-radius: 999px; background: #e4f8f5; color: #0a7f74; }
|
||||
.proto-root .jc-letter-h .jc-chip { font-size: 10.5px; padding: 3px 9px; border-radius: 999px; background: var(--brand-wash); color: var(--brand-deep); border: 1px solid var(--brand-soft); }
|
||||
.proto-root .jc-letter-h > code { margin-left: auto; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
|
||||
.proto-root .jc-letter-meta { display: flex; flex-direction: column; gap: 4px; margin-bottom: 11px; padding-bottom: 11px; border-bottom: 1px solid var(--line-soft); font-size: 11.5px; color: var(--ink-2); }
|
||||
.proto-root .jc-letter-meta p { margin: 0; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
|
||||
.proto-root .jc-letter-meta i { flex: none; font-style: normal; width: 44px; font-size: 10px; font-weight: 800; letter-spacing: .06em; color: var(--ink-4); }
|
||||
.proto-root .jc-letter-meta code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); overflow-wrap: anywhere; }
|
||||
.proto-root .jc-letter-sub { margin: 0 0 9px; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.45; overflow-wrap: anywhere; }
|
||||
/* 正文:原文照录,pre-wrap 保原始换行;不截断、不折叠 */
|
||||
.proto-root .jc-letter-body { margin: 0; font-size: 12.5px; line-height: 1.72; color: var(--ink-2); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-letter-body b { color: var(--ink); font-weight: 700; }
|
||||
.proto-root .jc-atts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; padding-top: 11px; border-top: 1px dashed var(--line-soft); }
|
||||
.proto-root .jc-att { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px 5px 8px; border: 1px solid var(--brand-soft); border-radius: 8px; background: var(--brand-wash); font-size: 11.5px; color: var(--brand-deep); }
|
||||
.proto-root .jc-att svg { flex: none; color: var(--brand); }
|
||||
.proto-root .jc-att code { font-family: var(--mono); font-size: 10.5px; }
|
||||
.proto-root .jc-att span { color: var(--ink-4); }
|
||||
/* 抽取指向:从原文到下方要素的一句衔接 */
|
||||
.proto-root .jc-derive { display: flex; align-items: flex-start; gap: 8px; margin-top: 11px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); font-size: 11.5px; line-height: 1.7; color: var(--brand-deep); }
|
||||
.proto-root .jc-derive svg { flex: none; margin-top: 2px; }
|
||||
|
||||
/* ── 四要素卡 ───────────────────────────────────────────────────────────
|
||||
色调字典:每张卡带一套 --tone / --tone-soft / --tone-line,组件只读变量。
|
||||
卡 3、卡 4 的色调随数据变(老客户绿 / 新客户橙;齐了蓝 / 有缺橙),
|
||||
所以这条要素带本身就是一行可读的状态,不只是四个格子。 */
|
||||
.proto-root .jc-four { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 11px; }
|
||||
.proto-root .jc-fcard {
|
||||
position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-width: 0;
|
||||
padding: 14px 14px 14px; border: 1px solid var(--cardline); border-radius: 13px; background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 8px 20px -14px rgba(31,35,41,.35);
|
||||
transition: transform var(--t-fast), box-shadow var(--t-fast), border-color var(--t-fast);
|
||||
--tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft);
|
||||
}
|
||||
/* 顶缘色棱 + 右上角光晕:卡的身份色,一眼分得清四件是四件 */
|
||||
.proto-root .jc-fcard::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 3px; background: var(--tone); }
|
||||
.proto-root .jc-fcard::after { content: ""; position: absolute; right: -46px; top: -52px; width: 132px; height: 132px; border-radius: 999px; background: radial-gradient(circle, var(--tone-soft), transparent 70%); pointer-events: none; }
|
||||
.proto-root .jc-fcard > * { position: relative; z-index: 1; }
|
||||
.proto-root .jc-fcard:hover { transform: translateY(-2px); border-color: var(--tone-line); box-shadow: 0 2px 4px rgba(31,35,41,.05), 0 14px 26px -16px rgba(31,35,41,.45); }
|
||||
.proto-root .jc-fcard.t-teal { --tone: #0aa294; --tone-soft: rgba(10,162,148,.15); --tone-line: #9fe0d9; }
|
||||
.proto-root .jc-fcard.t-ok { --tone: var(--ok); --tone-soft: rgba(39,135,23,.14); --tone-line: #bfedb4; }
|
||||
.proto-root .jc-fcard.t-warn { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.12); --tone-line: #ffd9a8; }
|
||||
|
||||
.proto-root .jc-fcard-k { display: flex; align-items: center; gap: 6px; font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
|
||||
.proto-root .jc-fcard-k > i { flex: none; width: 19px; height: 19px; border-radius: 6px; display: inline-grid; place-items: center; background: var(--tone-soft); color: var(--tone); }
|
||||
.proto-root .jc-fcard-b { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.proto-root .jc-fcard-t { min-width: 0; flex: 1; }
|
||||
/* 一律换行,不省略号——宁可多占一行,也不丢字 */
|
||||
.proto-root .jc-fcard-t > b { display: block; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.35; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-fcard-t > span { display: block; margin-top: 2px; font-size: 10.5px; color: var(--ink-4); line-height: 1.55; overflow-wrap: anywhere; }
|
||||
/* 公司标:圆角方 + AI 渐变 + 外圈光环(方=组织) */
|
||||
.proto-root .jc-logo { flex: none; width: 38px; height: 38px; border-radius: 12px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; letter-spacing: .04em; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand), 0 0 0 3px rgba(51,112,255,.10); }
|
||||
/* 人像:圆 + 青色系(圆=人,与方形的组织标一眼分开) */
|
||||
.proto-root .jc-face { flex: none; width: 38px; height: 38px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; color: #0a7f74; background: #e4f8f5; border: 1px solid #9fe0d9; box-shadow: 0 0 0 3px rgba(10,162,148,.10); }
|
||||
/* 邮箱做成可点的胶囊,复制按钮嵌在里面——比一行裸文字有抓手 */
|
||||
/* 邮箱:软底胶囊、无描边——有描边会读成输入框,而读态这里不是可填的 */
|
||||
.proto-root .jc-mailrow { display: flex; align-items: center; gap: 4px; margin-top: 5px; min-width: 0; max-width: 100%; padding: 3px 4px 3px 9px; border: 0; border-radius: 8px; background: var(--tone-soft); transition: background var(--t-fast); }
|
||||
.proto-root .jc-mailrow:hover { background: rgba(10,162,148,.2); }
|
||||
/* 编辑态里它包着输入框,软底会变成「框中框」,去掉 */
|
||||
.proto-root .jc-report.editing .jc-mailrow { background: transparent; padding: 0; margin-top: 6px; }
|
||||
.proto-root .jc-mailrow > code { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; line-height: 1.5; font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
|
||||
.proto-root .jc-copy { flex: none; width: 22px; height: 22px; display: inline-grid; place-items: center; border: 0; border-radius: 6px; background: #fff; color: var(--ink-4); cursor: pointer; box-shadow: 0 1px 2px rgba(31,35,41,.08); transition: all var(--t-fast); }
|
||||
.proto-root .jc-copy:hover { color: #0aa294; }
|
||||
.proto-root .jc-copy.ok { color: var(--ok); background: var(--ok-soft); box-shadow: none; }
|
||||
/* 客户属性徽章 */
|
||||
.proto-root .jc-tagbig { display: inline-flex; align-items: center; gap: 6px; align-self: flex-start; padding: 7px 14px; border-radius: 999px; font-size: 13.5px; font-weight: 800; line-height: 1; color: var(--tone); background: var(--tone-soft); border: 1px solid var(--tone-line); }
|
||||
.proto-root .jc-fcard-note { font-size: 10.5px; color: var(--ink-4); line-height: 1.55; }
|
||||
.proto-root .jc-fcard-note b { color: var(--ink-2); font-weight: 700; }
|
||||
.proto-root .jc-fcard-note b.bad { color: var(--tone); font-weight: 800; }
|
||||
/* 成交迷你柱:把「台账 6 笔成交」从一句话变成看得见的节奏 */
|
||||
.proto-root .jc-spark { display: flex; align-items: flex-end; gap: 4px; height: 26px; }
|
||||
.proto-root .jc-spark > i { flex: 1 1 0; min-width: 4px; max-width: 15px; border-radius: 3px 3px 1px 1px; background: var(--tone); opacity: .3; transition: opacity var(--t-fast), height var(--t-med); }
|
||||
.proto-root .jc-spark > i:last-child { opacity: 1; }
|
||||
.proto-root .jc-spark:hover > i { opacity: .55; }
|
||||
.proto-root .jc-spark:hover > i:last-child { opacity: 1; }
|
||||
.proto-root .jc-spark.empty { align-items: stretch; }
|
||||
.proto-root .jc-spark.empty > i { height: 100%; opacity: 1; background: repeating-linear-gradient(45deg, var(--tone-soft) 0 5px, transparent 5px 10px); border: 1px dashed var(--tone-line); border-radius: 5px; }
|
||||
/* 有效 PN 计数 */
|
||||
.proto-root .jc-count { display: flex; align-items: baseline; gap: 6px; }
|
||||
.proto-root .jc-count > b { font-family: var(--mono); font-size: 32px; font-weight: 800; line-height: .95; color: var(--ink); letter-spacing: -.04em; }
|
||||
.proto-root .jc-count > span { font-size: 11px; color: var(--ink-4); }
|
||||
/* 分段刻度条:一格 = 一条 PN,缺必填的那格直接标红——比一根实心进度条信息量大 */
|
||||
.proto-root .jc-segmeter { display: flex; gap: 3px; }
|
||||
.proto-root .jc-segmeter > i { flex: 1; height: 7px; border-radius: 2px; background: var(--ok); transition: background var(--t-fast); }
|
||||
.proto-root .jc-segmeter > i.bad { background: var(--danger); }
|
||||
.proto-root .jc-lockhint { display: inline-flex; align-items: center; gap: 4px; font-size: 10px; color: var(--ink-4); }
|
||||
|
||||
/* ── 占位胶囊:必填=红虚线,选填=灰虚线;**都带文字**,不靠颜色单打 ── */
|
||||
.proto-root .jc-ph { display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700; line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed; }
|
||||
.proto-root .jc-ph.req { color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
|
||||
.proto-root .jc-ph.opt { color: var(--ink-4); background: var(--soft); border-color: var(--line); }
|
||||
|
||||
/* ── 分段控件(新客户 / 老客户)── */
|
||||
.proto-root .jc-seg { display: inline-flex; padding: 2px; gap: 2px; border: 1px solid var(--cardline); border-radius: 9px; background: var(--soft); align-self: flex-start; }
|
||||
.proto-root .jc-seg button { border: 0; border-radius: 7px; padding: 6px 13px; font: inherit; font-size: 11.5px; font-weight: 700; color: var(--ink-3); background: transparent; cursor: pointer; transition: all var(--t-fast); }
|
||||
.proto-root .jc-seg button:hover:not(.on) { color: var(--brand); }
|
||||
.proto-root .jc-seg button.on { background: #fff; color: var(--ink); box-shadow: 0 1px 3px rgba(31,35,41,.12); }
|
||||
|
||||
/* ── 表:读态与编辑态共用骨架 ─────────────────────────────────────── */
|
||||
.proto-root .jc-tbl-wrap { overflow-x: auto; border: 1px solid var(--line-soft); border-radius: 10px; }
|
||||
.proto-root .jc-tbl-in { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; }
|
||||
.proto-root .jc-tbl-in th { background: var(--soft); text-align: left; padding: 8px 9px; font-size: 10.5px; font-weight: 800; letter-spacing: .03em; color: var(--ink-3); white-space: nowrap; border-bottom: 1px solid var(--line-soft); }
|
||||
.proto-root .jc-tbl-in th.req::after { content: "*"; margin-left: 1px; color: var(--danger); font-size: 10px; }
|
||||
.proto-root .jc-tbl-in td { padding: 4px 5px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
|
||||
.proto-root .jc-tbl-in tr:last-child td { border-bottom: 0; }
|
||||
.proto-root .jc-tbl-in tbody tr:hover td { background: #f7f9fd; }
|
||||
/* 缺必填的整行浅红打底 + 行首红标——不用找红胶囊,一眼扫到是哪几行 */
|
||||
.proto-root .jc-tbl-in tr.miss td { background: #fffbfb; }
|
||||
.proto-root .jc-tbl-in tbody tr.miss:hover td { background: #fff5f5; }
|
||||
.proto-root .jc-tbl-in td.jc-rn { position: relative; width: 38px; padding: 4px 6px 4px 13px; white-space: nowrap; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
|
||||
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); font-weight: 800; }
|
||||
/* 行首红标只在表态用 ::after —— 卡片态的 ::after 被「第 N 条」占着,不能抢 */
|
||||
@media (min-width: 961px) {
|
||||
.proto-root .jc-tbl-in tr.miss td.jc-rn::after { content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger); }
|
||||
}
|
||||
.proto-root .jc-rndot { display: inline-block; width: 5px; height: 5px; margin-left: 4px; border-radius: 999px; background: var(--danger); vertical-align: 2px; }
|
||||
/* 读态单元格 */
|
||||
.proto-root .jc-vcell { display: block; padding: 6px 6px; font-size: 12px; color: var(--ink); line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-vcell.mono { font-family: var(--mono); font-size: 11px; color: var(--ink-2); }
|
||||
.proto-root .jc-vcell.num { font-family: var(--mono); font-size: 11.5px; text-align: right; }
|
||||
/* 编辑态单元格:安静的电子表格 */
|
||||
.proto-root .jc-cell { width: 100%; min-width: 64px; border: 1px solid var(--line); border-radius: 6px; padding: 6px 7px; font: inherit; font-size: 12px; color: var(--ink); background: #fff; transition: border-color var(--t-fast), box-shadow var(--t-fast); }
|
||||
.proto-root .jc-cell.mono { font-family: var(--mono); font-size: 11px; }
|
||||
.proto-root .jc-cell.num { text-align: right; font-family: var(--mono); font-size: 11.5px; }
|
||||
.proto-root .jc-cell::placeholder { color: var(--ink-4); }
|
||||
.proto-root .jc-cell:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-wash); outline: none; }
|
||||
.proto-root .jc-cell.req-empty { border-color: #f3b6b3; background: #fffafa; }
|
||||
.proto-root .jc-cell.req-empty::placeholder { color: var(--danger); }
|
||||
/* 备注这类长文本用文本域:能换行、随内容长高,不裁字 */
|
||||
.proto-root textarea.jc-cell { display: block; resize: vertical; overflow: hidden; min-height: 31px; line-height: 1.55; }
|
||||
|
||||
/* 编辑态的整表提示带 */
|
||||
.proto-root .jc-editbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); border: 1px solid var(--brand-soft); font-size: 11.5px; color: var(--brand-deep); }
|
||||
.proto-root .jc-editbar svg { flex: none; }
|
||||
.proto-root .jc-editbar .sp { flex: 1 1 auto; }
|
||||
|
||||
/* 剔除行说明 */
|
||||
.proto-root .jc-drop { display: flex; align-items: flex-start; gap: 8px; margin-top: 10px; padding: 9px 12px; border-radius: 9px; border: 1px dashed var(--line); background: var(--soft); font-size: 11.5px; color: var(--ink-3); line-height: 1.6; }
|
||||
.proto-root .jc-drop svg { flex: none; margin-top: 2px; color: var(--ink-4); }
|
||||
.proto-root .jc-drop code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
|
||||
|
||||
.proto-root .jc-callout { display: flex; gap: 8px; align-items: flex-start; border-radius: 9px; padding: 11px 13px; font-size: 12.5px; line-height: 1.75; }
|
||||
.proto-root .jc-callout p { margin: 0; }
|
||||
.proto-root .jc-callout svg { flex: none; margin-top: 2px; }
|
||||
.proto-root .jc-callout.t-mute { background: var(--soft); color: var(--ink-2) }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.proto-root .jc-pulse-dot { animation: none; }
|
||||
.proto-root .jc-report, .proto-root .jc-rp-stamp { animation: none; }
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════
|
||||
响应式:宁可另起一行,也不损失完整度
|
||||
≥961px 六列全展开的表(列 min-width 之和 878px,容器有余量,绝不裁字、绝不横滚)
|
||||
≤960px 表 → 卡片:一条 PN 一张卡、一行一字段(标签 / 值),无横滚、无省略
|
||||
≤560px 字段行再拆成上下两行(标签在上、值在下)
|
||||
══════════════════════════════════════════════════════════════════════════ */
|
||||
@media (max-width: 1040px) { .proto-root .jc-four { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 560px) { .proto-root .jc-four { grid-template-columns: 1fr; } }
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.proto-root .jc-tbl-wrap { overflow: visible; border: 0; border-radius: 0; background: transparent; }
|
||||
.proto-root .jc-tbl-in,
|
||||
.proto-root .jc-tbl-in tbody,
|
||||
.proto-root .jc-tbl-in tr,
|
||||
.proto-root .jc-tbl-in td { display: block; width: 100%; }
|
||||
.proto-root .jc-tbl-in thead { display: none; }
|
||||
|
||||
/* 一条 PN = 一张卡 */
|
||||
.proto-root .jc-tbl-in tr { margin-bottom: 10px; padding: 4px 13px 9px; border: 1px solid var(--line-soft); border-radius: 11px; background: #fff; box-shadow: var(--card-shadow); }
|
||||
.proto-root .jc-tbl-in tr:last-child { margin-bottom: 0; }
|
||||
.proto-root .jc-tbl-in tbody tr:hover td { background: transparent; }
|
||||
.proto-root .jc-tbl-in tr.miss { border-color: #f3b6b3; background: #fffbfb; }
|
||||
|
||||
/* 卡头:第 N 条 */
|
||||
.proto-root .jc-tbl-in td.jc-rn { padding: 8px 0 8px; margin-bottom: 4px; border-bottom: 1px solid var(--line-soft); font-family: inherit; font-size: 12px; font-weight: 800; color: var(--ink-2); }
|
||||
.proto-root .jc-tbl-in td.jc-rn::before { content: "第 "; }
|
||||
.proto-root .jc-tbl-in td.jc-rn::after { content: " 条"; }
|
||||
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); }
|
||||
|
||||
/* 字段行:左标签 + 右值,值超长自动折行 */
|
||||
.proto-root .jc-tbl-in td:not(.jc-rn) { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 10px; align-items: center; padding: 6px 0; border-bottom: 1px dashed var(--line-soft); }
|
||||
.proto-root .jc-tbl-in tr td:last-child { border-bottom: 0; padding-bottom: 2px; }
|
||||
.proto-root .jc-tbl-in td:not(.jc-rn)::before {
|
||||
content: attr(data-h); font-size: 10.5px; font-weight: 700; letter-spacing: .02em; color: var(--ink-4); line-height: 1.5;
|
||||
}
|
||||
.proto-root .jc-tbl-in td[data-req="1"]:not(.jc-rn)::before { content: attr(data-h) " *"; }
|
||||
.proto-root .jc-tbl-in .jc-vcell { padding: 0; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-tbl-in .jc-vcell.num { text-align: left; }
|
||||
.proto-root .jc-tbl-in .jc-cell.num { text-align: left; }
|
||||
.proto-root .jc-tbl-in .jc-cell { min-width: 0; }
|
||||
}
|
||||
|
||||
/* 窄屏:落款三件各占一行,动作按钮撑满一行 */
|
||||
@media (max-width: 560px) {
|
||||
.proto-root .jc-rp-acts { margin-left: 0; width: 100%; flex-wrap: wrap; }
|
||||
.proto-root .jc-rp-acts .jc-btn { flex: 1 1 auto; justify-content: center; }
|
||||
.proto-root .jc-hintline { flex: 1 1 100%; }
|
||||
.proto-root .jc-tbl-in td:not(.jc-rn) { grid-template-columns: 1fr; gap: 3px; align-items: start; }
|
||||
}
|
||||
|
||||
|
||||
/* ══ 以下取自 A1-询价受理记录.html(归档件独有的件)══ */
|
||||
/* ── 料号分类:分布条 + 图例 ───────────────────────────────────────── */
|
||||
.proto-root .jc-dist { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 13px 15px; border: 1px solid var(--line-soft); border-radius: 12px; background: linear-gradient(180deg, #fbfcfe, #fff); }
|
||||
.proto-root .jc-distbar { flex: 1 1 320px; min-width: 260px; display: flex; gap: 3px; }
|
||||
.proto-root .jc-distbar > i { height: 30px; border-radius: 4px; display: grid; place-items: center; font-size: 10.5px; font-weight: 800; color: #fff; font-family: var(--mono); }
|
||||
.proto-root .jc-distbar > i.a { background: var(--ok); }
|
||||
.proto-root .jc-distbar > i.b { background: var(--brand); }
|
||||
.proto-root .jc-distbar > i.c { background: var(--warn-accent); }
|
||||
.proto-root .jc-distlg { display: flex; flex-wrap: wrap; gap: 8px 18px; }
|
||||
.proto-root .jc-distlg > span { display: inline-flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--ink-3); }
|
||||
.proto-root .jc-distlg em { width: 10px; height: 10px; border-radius: 3px; }
|
||||
.proto-root .jc-distlg em.a { background: var(--ok); } .proto-root .jc-distlg em.b { background: var(--brand); } .proto-root .jc-distlg em.c { background: var(--warn-accent); }
|
||||
.proto-root .jc-distlg b { font-family: var(--mono); font-weight: 800; color: var(--ink); }
|
||||
|
||||
/* 类别徽 A / B / C */
|
||||
.proto-root .jc-tbadge { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 6px; font-family: var(--mono); font-size: 11px; font-weight: 800; line-height: 1; color: #fff; }
|
||||
.proto-root .jc-tbadge.a { background: var(--ok); } .proto-root .jc-tbadge.b { background: var(--brand); } .proto-root .jc-tbadge.c { background: var(--warn-accent); }
|
||||
|
||||
/* ── 结论与去向 ─────────────────────────────────────────────────────────
|
||||
G2 是**分支网关**:三条互斥条件按序求值,只命中一条、只落一个活动。
|
||||
所以这里不是三张并列的去向卡,而是一张**判定表 + 一条结论**。 */
|
||||
/* 判定表:一行一条规则,命中行上色留痕(DMN 口径) */
|
||||
.proto-root .jc-dmn { border: 1px solid var(--line-soft); border-radius: 12px; overflow: hidden; }
|
||||
.proto-root .jc-dmn-h, .proto-root .jc-dmn-r { display: grid; grid-template-columns: 40px minmax(0, 1fr) 170px 200px; align-items: center; gap: 12px; padding: 9px 14px; }
|
||||
.proto-root .jc-dmn-h { background: var(--soft); border-bottom: 1px solid var(--line-soft); font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); }
|
||||
.proto-root .jc-dmn-r { font-size: 12px; color: var(--ink-4); background: #fff; }
|
||||
.proto-root .jc-dmn-r + .jc-dmn-r { border-top: 1px solid var(--line-soft); }
|
||||
.proto-root .jc-dmn-i { font-family: var(--mono); font-size: 11px; font-weight: 800; }
|
||||
.proto-root .jc-dmn-c { line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-dmn-t { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; }
|
||||
.proto-root .jc-dmn-t code { font-family: var(--mono); font-size: 10.5px; font-weight: 800; color: var(--ink-4); background: var(--soft); border: 1px solid var(--line); border-radius: 5px; padding: 2px 6px; }
|
||||
.proto-root .jc-dmn-v { font-size: 11px; line-height: 1.55; }
|
||||
/* 命中行:上色 + 左缘棱 + 加粗 */
|
||||
.proto-root .jc-dmn-r.hit { position: relative; background: var(--tone-soft); color: var(--ink-2); }
|
||||
.proto-root .jc-dmn-r.hit::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--tone); }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-i { color: var(--tone); }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-c { color: var(--ink); }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-c b { color: var(--tone); font-weight: 800; }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-t { color: var(--ink); }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-t code { color: #fff; background: var(--tone); border-color: var(--tone); }
|
||||
.proto-root .jc-dmn-v.y { display: inline-flex; align-items: center; gap: 5px; font-weight: 800; color: var(--tone); }
|
||||
.proto-root .jc-dmn-v.n { color: var(--ink-4); }
|
||||
/* 色调随命中类别 */
|
||||
.proto-root .jc-dmn.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.10); }
|
||||
.proto-root .jc-dmn.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.09); }
|
||||
.proto-root .jc-dmn.k-c { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.11); }
|
||||
|
||||
/* 结论横幅:命中条件 → 唯一去向 */
|
||||
.proto-root .jc-verdict {
|
||||
position: relative; overflow: hidden; display: flex; align-items: stretch; flex-wrap: wrap; gap: 16px;
|
||||
margin-top: 11px; padding: 16px 18px; border: 1px solid var(--tone-line); border-radius: 13px;
|
||||
background: linear-gradient(105deg, var(--tone-soft), #fff 62%);
|
||||
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 10px 24px -16px rgba(31,35,41,.4);
|
||||
--tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.14); --tone-line: #ffd9a8;
|
||||
}
|
||||
.proto-root .jc-verdict.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.13); --tone-line: #bfedb4; }
|
||||
.proto-root .jc-verdict.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft); }
|
||||
.proto-root .jc-verdict::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 5px; background: var(--tone); }
|
||||
.proto-root .jc-verdict > * { position: relative; z-index: 1; }
|
||||
.proto-root .jc-vd-l { flex: 1 1 300px; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
|
||||
.proto-root .jc-vd-k { font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
|
||||
.proto-root .jc-vd-cond { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 15px; font-weight: 800; color: var(--ink); }
|
||||
.proto-root .jc-vd-why { margin: 0; font-size: 11.5px; line-height: 1.7; color: var(--ink-3); overflow-wrap: anywhere; }
|
||||
.proto-root .jc-vd-why code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
|
||||
.proto-root .jc-vd-arrow { flex: none; align-self: center; color: var(--tone); }
|
||||
.proto-root .jc-vd-r { flex: 1 1 260px; min-width: 0; display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-radius: 11px; background: #fff; border: 1px solid var(--tone-line); }
|
||||
.proto-root .jc-vd-node { display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 800; color: var(--ink); }
|
||||
.proto-root .jc-vd-node code { font-family: var(--mono); font-size: 12px; font-weight: 800; color: #fff; background: var(--tone); border-radius: 6px; padding: 3px 8px; }
|
||||
.proto-root .jc-vd-who { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--ink-2); }
|
||||
.proto-root .jc-vd-who > i { width: 20px; height: 20px; border-radius: 999px; display: inline-grid; place-items: center; font-style: normal; font-size: 10px; font-weight: 700; color: #fff; background: var(--tone); }
|
||||
.proto-root .jc-vd-next { margin: 0; padding-top: 8px; border-top: 1px dashed var(--line-soft); font-size: 10.5px; line-height: 1.65; color: var(--ink-4); }
|
||||
.proto-root .jc-vd-next b { color: var(--ink-2); font-weight: 700; }
|
||||
/* 命中分支携带的条目 */
|
||||
.proto-root .jc-pnlist { display: flex; flex-wrap: wrap; gap: 5px; margin: 0; padding: 0; list-style: none; }
|
||||
.proto-root .jc-pnlist > li { display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 999px; background: #fff; border: 1px solid var(--tone-line); font-size: 11px; color: var(--ink-2); overflow-wrap: anywhere; }
|
||||
.proto-root .jc-pnlist > li > i { flex: none; width: 5px; height: 5px; border-radius: 999px; background: var(--tone); }
|
||||
.proto-root .jc-pnlist code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
|
||||
|
||||
/* ── 落地动作 ───────────────────────────────────────────────────────── */
|
||||
.proto-root .jc-acts { display: flex; flex-direction: column; gap: 0; border: 1px solid var(--line-soft); border-radius: 11px; overflow: hidden; }
|
||||
.proto-root .jc-act { display: flex; align-items: flex-start; gap: 9px; padding: 10px 13px; font-size: 12px; color: var(--ink-2); line-height: 1.6; background: #fff; }
|
||||
.proto-root .jc-act + .jc-act { border-top: 1px solid var(--line-soft); }
|
||||
.proto-root .jc-act > i { flex: none; width: 18px; height: 18px; margin-top: 1px; border-radius: 999px; display: inline-grid; place-items: center; color: #fff; background: var(--ok); }
|
||||
.proto-root .jc-act b { font-weight: 700; color: var(--ink); }
|
||||
.proto-root .jc-act span { color: var(--ink-4); }
|
||||
.proto-root .jc-act code { font-family: var(--mono); font-size: 10.5px; color: var(--brand-deep); background: var(--brand-wash); border-radius: 4px; padding: 1px 5px; }
|
||||
|
||||
|
||||
.proto-root .jc-done-tag { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; flex: none; padding: 6px 13px; border-radius: 999px; font-size: 11.5px; font-weight: 800; color: var(--ok); background: #fff; border: 1px solid #bfedb4; }
|
||||
.proto-root .jc-vcell.dim { color: var(--ink-4); }
|
||||
.proto-root .jc-tbl-in td.jc-ty { width: 44px; text-align: center; }
|
||||
.proto-root .jc-tbl-in td.jc-ty { width: auto; text-align: left; }
|
||||
`;
|
||||
|
||||
/**
|
||||
* 图标精灵 —— 与样式表同一出处,两份目标件的 `<symbol>` 取并集。
|
||||
* 沙箱里取不到图标字体,所以图标一律内联;`<use href="#i-xxx">` 引的就是这一坨。
|
||||
*/
|
||||
export const PRD_SPRITE = `<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs><symbol id="i-alert" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></symbol><symbol id="i-arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol><symbol id="i-boxes" viewBox="0 0 24 24"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></symbol><symbol id="i-building" viewBox="0 0 24 24"><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/><path d="M10 18h4"/></symbol><symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol><symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol><symbol id="i-file-check" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="m9 15 2 2 4-4"/></symbol><symbol id="i-file-text" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></symbol><symbol id="i-git-branch" viewBox="0 0 24 24"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></symbol><symbol id="i-history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></symbol><symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></symbol><symbol id="i-lock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol><symbol id="i-mail-open" viewBox="0 0 24 24"><path d="M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0z"/><path d="m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10"/></symbol><symbol id="i-pen" viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z"/></symbol><symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol><symbol id="i-sheet" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M8 13h2"/><path d="M14 13h2"/><path d="M8 17h2"/><path d="M14 17h2"/></symbol><symbol id="i-shield-check" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></symbol><symbol id="i-user" viewBox="0 0 24 24"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/></symbol><symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></symbol></defs></svg>`;
|
||||
@@ -1,50 +1,720 @@
|
||||
/**
|
||||
* A6 视图模版 —— 议价两屏:正式报价单 / 待放行即发出(AM 07)。
|
||||
* 这是**唯一会触达外界**的活动:放行一刻要发信、要回写台账,所以那一屏必须说清后果。
|
||||
* A6 报价议价的 HTML 报告模版 —— **活动自己持有的资产**,随包版本冻结。
|
||||
*
|
||||
* 两个局面各一张(与 A2/A3/A4 同款口径):
|
||||
* · `negotiate-worksheet` **议价工作单** —— 排版、发出与落客户回音期间摆它:
|
||||
* 逐颗料一张报价档案卡(条款在左、议价在右)、议价结果 N-01 与让步梯度 N-02 两把尺、
|
||||
* 填回工作表就长在同一屏;
|
||||
* · `negotiate-roster` **成交定案清单** —— 异议拍板、放行与归档摆它:整份报价函,
|
||||
* 定案分布、去向落边图与落地动作在上面。
|
||||
*
|
||||
* 「1:1」的落点是**每份件与它的办结快照**(在办可编辑 ↔ 回执收下那一刻的 finalHtml),
|
||||
* 不是全程只许一张:作业的件与收口的件是两件工作两份件(规范姊妹篇 §1)。
|
||||
*
|
||||
* 版面语言沿用 A1 从 PRD 目标件机械抽取的那套(`prd-style.ts` 原样拷入)——
|
||||
* A6 没有自己的 PRD 目标件,与 A2/A3/A4 同一处置:沿用家族版面自造。
|
||||
*
|
||||
* 取值三纪律:顶层只取汇总列与单值列;逐条走 `mine:quote_negotiate.line`;
|
||||
* 列名逐字照 `declarations.ledger` 抄。**模版不算不判** —— 缺表态红带、定案徽、分布条、
|
||||
* 落边图全部由基座算好的数经 `data-class-of` 投影,判断留在样式表。
|
||||
*/
|
||||
import { kvCard, noteCard, tableCard, viewShell } from '@sdk';
|
||||
import { PRD_CSS, PRD_SPRITE } from './prd-style.ts';
|
||||
|
||||
export const QUOTE_COMPOSED = viewShell({
|
||||
title: '正式报价单',
|
||||
kicker: 'A6 · 报价议价',
|
||||
tone: 'brand',
|
||||
/* ==================== 绑定期补的样式(照 A1/A3/A4 的 BIND_CSS,地址换成本活动)==================== */
|
||||
|
||||
const BIND_CSS = `
|
||||
/* 空占位三级(2026-08-02 全局口径):必填红 / 建议填橘 / 选填灰 —— 值真的空才轮到 */
|
||||
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after,
|
||||
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after,
|
||||
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after {
|
||||
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
|
||||
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
|
||||
}
|
||||
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after { content: "必填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
|
||||
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after { content: "建议填"; color: var(--warn-accent); background: var(--warn-soft); border-color: #f0cf9a; }
|
||||
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after { content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line); }
|
||||
/* 归档件里空格子就是「没有」,不再摆「你还能填」的胶囊 */
|
||||
.proto-root .jc-tbl-in td.dimempty > .jc-vcell:empty::after { content: "—"; color: var(--ink-4); border: 0; background: none; padding: 0; }
|
||||
|
||||
/* 缺客户表态的整行浅红 + 行首红条 —— 原型的 tr.miss,这里由 :has() 认 */
|
||||
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td { background: #fffbfb; }
|
||||
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty):hover td { background: #fff5f5; }
|
||||
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn { color: var(--danger); font-weight: 800; position: relative; }
|
||||
@media (min-width: 1001px) {
|
||||
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn::after {
|
||||
content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
/* 序号由 CSS 计数器排 */
|
||||
.proto-root .jc-tbl-in tbody { counter-reset: rn; }
|
||||
.proto-root .jc-tbl-in td.jc-rn::before { counter-increment: rn; content: counter(rn); }
|
||||
@media (max-width: 1000px) {
|
||||
.proto-root .jc-tbl-in td.jc-rn::before { content: "第 " counter(rn) " 条"; }
|
||||
.proto-root .jc-tbl-in td.jc-rn::after { content: none; }
|
||||
}
|
||||
|
||||
/* 就地改:可改格子渲了只读与输入两份,默认锁着,表上加 on 才换孪生件 */
|
||||
.proto-root .jc-cell { display: none; }
|
||||
.proto-root .jc-tbl-in.on td > .jc-vcell,
|
||||
.proto-root .jc-tbl-in.on td > .jc-tbadge { display: none; }
|
||||
.proto-root .jc-tbl-in.on td > .jc-cell { display: block; }
|
||||
/* 类徽 / 表态徽 / 定案徽不进编辑态(承继与引擎产物),on 时也照旧摆着 */
|
||||
.proto-root .jc-tbl-in.on td > .jc-obadge,
|
||||
.proto-root .jc-tbl-in.on td > .jc-dbadge { display: inline-flex; }
|
||||
/* 数值列与表头同侧左对齐(一行一个料,彼此不相加) */
|
||||
.proto-root .jc-vcell.num, .proto-root .jc-cell.num { text-align: left; }
|
||||
/* 编辑态下必填空格子:红框 + 整行浅红(由 :placeholder-shown 认) */
|
||||
.proto-root .jc-tbl-in.on td.req > .jc-cell:placeholder-shown { border-color: #f3b6b3; background: #fffafa; }
|
||||
.proto-root .jc-tbl-in.on tbody tr:has(td.req > .jc-cell:placeholder-shown) td { background: #fffbfb; }
|
||||
|
||||
/* 铺满工作区 */
|
||||
.jc-stage { max-width: none; padding: 0 0 4px; }
|
||||
.proto-root .jc-report { border: 0; border-radius: 0; box-shadow: none; }
|
||||
|
||||
/* 那道闸 —— 默认「已锁定 + 编辑」,开了变成「取消 / 保存」;只在可改屏现身 */
|
||||
.proto-root .jc-editgate { display: none; }
|
||||
html.editable .proto-root .jc-editgate {
|
||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
margin-bottom: 10px; padding: 8px 12px; border-radius: 9px;
|
||||
background: var(--soft); border: 1px solid var(--line-soft);
|
||||
font-size: 11.5px; color: var(--ink-3);
|
||||
}
|
||||
.proto-root .jc-editgate-t { display: inline-flex; align-items: center; gap: 6px; flex: 1 1 auto; min-width: 0; }
|
||||
.proto-root .jc-editgate-t b { color: var(--ink-2); font-weight: 700; }
|
||||
.proto-root .jc-egbtn {
|
||||
flex: none; display: inline-flex; align-items: center; gap: 4px; cursor: pointer;
|
||||
border: 1px solid var(--line); border-radius: 7px; padding: 5px 11px;
|
||||
font: inherit; font-size: 11.5px; font-weight: 700; color: var(--ink-2); background: #fff;
|
||||
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
|
||||
}
|
||||
.proto-root .jc-egbtn:hover { border-color: var(--brand); color: var(--brand); }
|
||||
.proto-root .jc-egbtn.save { color: #fff; background: var(--brand); border-color: var(--brand); }
|
||||
.proto-root .jc-egbtn.save:hover { background: var(--brand-deep); border-color: var(--brand-deep); }
|
||||
.proto-root .jc-editgate .jc-egbtn.ghost, .proto-root .jc-editgate .jc-egbtn.save { display: none; }
|
||||
.proto-root .jc-editgate.on { background: var(--brand-wash); border-color: var(--brand-soft); color: var(--brand-deep); }
|
||||
.proto-root .jc-editgate.on .jc-egbtn.open { display: none; }
|
||||
.proto-root .jc-editgate.on .jc-egbtn.ghost, .proto-root .jc-editgate.on .jc-egbtn.save { display: inline-flex; }
|
||||
.proto-root .jc-editgate.on .jc-editgate-t { visibility: hidden; }
|
||||
|
||||
/* 缺表态红带 —— 只在可改屏出,一格不缺自动收(v-0 那一档 display:none) */
|
||||
.proto-root .jc-gapbar { display: none; }
|
||||
html.editable .proto-root .jc-gapbar {
|
||||
display: flex; align-items: flex-start; gap: 8px;
|
||||
margin: 0 0 10px; padding: 9px 12px; border-radius: 9px;
|
||||
background: #fff5f5; border: 1px solid #f3b6b3; color: var(--danger);
|
||||
font-size: 12px; line-height: 1.65;
|
||||
}
|
||||
.proto-root .jc-gapbar svg { flex: 0 0 auto; margin-top: 2px; }
|
||||
.proto-root .jc-gapbar b { font-weight: 800; }
|
||||
.proto-root .jc-gapbar span { color: #9a3b36; }
|
||||
html.editable .proto-root .jc-gapbar.v-0 { display: none; }
|
||||
|
||||
/* 那条「怎么改」的提示只在开了闸之后说 */
|
||||
.proto-root .jc-editbar { display: none; }
|
||||
.proto-root .jc-editgate.on + .jc-editbar { display: flex; }
|
||||
|
||||
/* 增删行 */
|
||||
.proto-root .jc-rowdel { display: none; }
|
||||
.proto-root .jc-tbl-in.on td.jc-rn::before { content: none; }
|
||||
.proto-root .jc-tbl-in.on .jc-rowdel {
|
||||
display: block; width: 20px; height: 20px; margin: 0 auto; cursor: pointer;
|
||||
border: 1px solid var(--line); border-radius: 6px; padding: 0;
|
||||
font: inherit; font-size: 13px; line-height: 1; color: var(--ink-3); background: #fff;
|
||||
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
|
||||
}
|
||||
.proto-root .jc-tbl-in.on .jc-rowdel:hover { border-color: var(--danger); color: var(--danger); background: #fff6f6; }
|
||||
.proto-root .jc-tbl-in.on tr.jc-row-gone td { background: #fff6f6; opacity: .55; }
|
||||
.proto-root .jc-tbl-in.on tr.jc-row-gone .jc-cell { text-decoration: line-through; color: var(--ink-3); }
|
||||
.proto-root .jc-tbl-in.on tr.jc-row-gone .jc-rowdel { border-color: var(--danger); color: var(--danger); background: #fff; }
|
||||
.proto-root .jc-tbl-in.on tr.jc-row-new td { background: #f2fbf7; }
|
||||
.proto-root .jc-addrow { display: none; }
|
||||
html.editable .proto-root .jc-tbl-wrap:has(table.on) + .jc-addrow {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
margin: -2px 0 12px; padding: 7px 12px; border-radius: 9px;
|
||||
background: var(--soft); border: 1px dashed var(--line);
|
||||
}
|
||||
.proto-root .jc-addrow em { font-style: normal; font-size: 11px; color: var(--ink-3); }
|
||||
|
||||
/* 类徽按值上色(承继上游的三分类) */
|
||||
.proto-root .jc-tbadge { background: var(--ink-4); }
|
||||
.proto-root .jc-tbadge.v-a { background: var(--ok); }
|
||||
.proto-root .jc-tbadge.v-b { background: var(--brand); }
|
||||
.proto-root .jc-tbadge.v-c { background: var(--warn-accent); }
|
||||
.proto-root .jc-segmeter > i.v-won { background: var(--ok); }
|
||||
.proto-root .jc-segmeter > i.v-haggle { background: var(--warn-accent); }
|
||||
.proto-root .jc-segmeter > i.v-revise { background: var(--brand); }
|
||||
.proto-root .jc-segmeter > i.v-lost { background: var(--ink-4); }
|
||||
|
||||
/* 客户表态徽:接受绿 / 还价橙 / 拒绝红 / 无回复灰 —— 值是机器码,人读名交给样式表 */
|
||||
.proto-root .jc-obadge {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 52px; height: 20px; padding: 0 7px; border-radius: 6px;
|
||||
font-size: 10px; font-weight: 800; letter-spacing: .02em;
|
||||
color: var(--ink-4); border: 1.5px dashed var(--ink-4); background: #fff;
|
||||
}
|
||||
.proto-root .jc-obadge::after { content: "待回音"; }
|
||||
.proto-root .jc-obadge.v-accept { color: var(--ok); border-color: var(--ok); border-style: solid; }
|
||||
.proto-root .jc-obadge.v-accept::after { content: "接受"; }
|
||||
.proto-root .jc-obadge.v-counter { color: var(--warn-accent); border-color: var(--warn-accent); border-style: solid; }
|
||||
.proto-root .jc-obadge.v-counter::after { content: "还价"; }
|
||||
.proto-root .jc-obadge.v-reject { color: var(--danger); border-color: var(--danger); border-style: solid; }
|
||||
.proto-root .jc-obadge.v-reject::after { content: "拒绝"; }
|
||||
.proto-root .jc-obadge.v-noreply { color: var(--ink-3); border-color: var(--ink-4); border-style: solid; }
|
||||
.proto-root .jc-obadge.v-noreply::after { content: "无回复"; }
|
||||
|
||||
/* 定案徽:成交绿 / 还在还价橙 / 不成交灰 */
|
||||
.proto-root .jc-dbadge {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 62px; height: 20px; padding: 0 7px; border-radius: 6px;
|
||||
font-size: 10px; font-weight: 800; letter-spacing: .02em;
|
||||
color: var(--ink-4); border: 1.5px solid var(--ink-4); background: #fff;
|
||||
}
|
||||
.proto-root .jc-dbadge::after { content: "待定案"; }
|
||||
.proto-root .jc-dbadge.v-won { color: var(--ok); border-color: var(--ok); }
|
||||
.proto-root .jc-dbadge.v-won::after { content: "成交"; }
|
||||
.proto-root .jc-dbadge.v-haggle { color: var(--warn-accent); border-color: var(--warn-accent); border-style: dashed; }
|
||||
.proto-root .jc-dbadge.v-haggle::after { content: "待你拍板"; }
|
||||
.proto-root .jc-dbadge.v-revise { color: var(--brand); border-color: var(--brand); }
|
||||
.proto-root .jc-dbadge.v-revise::after { content: "退回重批"; }
|
||||
.proto-root .jc-dbadge.v-lost { color: var(--ink-3); border-color: var(--ink-4); }
|
||||
.proto-root .jc-dbadge.v-lost::after { content: "不成交"; }
|
||||
|
||||
/* 让步处置(N-02)—— 只在还价的行上有值 */
|
||||
.proto-root .jc-cbadge { font-size: 10.5px; font-weight: 700; color: var(--ink-4); }
|
||||
.proto-root .jc-cbadge.v-accept_counter { color: var(--ok); }
|
||||
.proto-root .jc-cbadge.v-accept_counter::after { content: "接了拿单"; }
|
||||
.proto-root .jc-cbadge.v-negotiate { color: var(--brand); }
|
||||
.proto-root .jc-cbadge.v-negotiate::after { content: "授权内回旋"; }
|
||||
.proto-root .jc-cbadge.v-escalate { color: var(--danger); }
|
||||
.proto-root .jc-cbadge.v-escalate::after { content: "超授权 · 退回重批"; }
|
||||
|
||||
/* 分布条:三段按条数分宽 —— 条数投影成 v-<N>,梯子取宽;零条那段自己消失 */
|
||||
.proto-root .jc-distbar > i { flex: 24; min-width: 34px; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.proto-root .jc-distbar > i.v-0, .proto-root .jc-distbar > i:not([class*=" v-"]) { display: none; }
|
||||
.proto-root .jc-distbar > i.v-1 { flex: 1 } .proto-root .jc-distbar > i.v-2 { flex: 2 }
|
||||
.proto-root .jc-distbar > i.v-3 { flex: 3 } .proto-root .jc-distbar > i.v-4 { flex: 4 }
|
||||
.proto-root .jc-distbar > i.v-5 { flex: 5 } .proto-root .jc-distbar > i.v-6 { flex: 6 }
|
||||
.proto-root .jc-distbar > i.v-7 { flex: 7 } .proto-root .jc-distbar > i.v-8 { flex: 8 }
|
||||
.proto-root .jc-distbar > i.v-9 { flex: 9 } .proto-root .jc-distbar > i.v-10 { flex: 10 }
|
||||
.proto-root .jc-distbar > i.v-11 { flex: 11 } .proto-root .jc-distbar > i.v-12 { flex: 12 }
|
||||
.proto-root .jc-distbar > i.v-13 { flex: 13 } .proto-root .jc-distbar > i.v-14 { flex: 14 }
|
||||
.proto-root .jc-distbar > i.v-15 { flex: 15 } .proto-root .jc-distbar > i.v-16 { flex: 16 }
|
||||
.proto-root .jc-distbar > i.v-17 { flex: 17 } .proto-root .jc-distbar > i.v-18 { flex: 18 }
|
||||
.proto-root .jc-distbar > i.v-19 { flex: 19 } .proto-root .jc-distbar > i.v-20 { flex: 20 }
|
||||
.proto-root .jc-distbar > i.p { background: var(--ok); }
|
||||
.proto-root .jc-distbar > i.n { background: var(--warn-accent); }
|
||||
.proto-root .jc-distbar > i.r { background: var(--brand); }
|
||||
.proto-root .jc-distbar > i.k { background: var(--ink-4); }
|
||||
.proto-root .jc-distbar > i.p::before { content: "成交 · "; }
|
||||
.proto-root .jc-distbar > i.n::before { content: "待拍板 · "; }
|
||||
.proto-root .jc-distbar > i.r::before { content: "退回重批 · "; }
|
||||
.proto-root .jc-distbar > i.k::before { content: "不成交 · "; }
|
||||
.proto-root .jc-logo svg, .proto-root .jc-face svg, .proto-root .jc-rp-ava svg { width: 17px; height: 17px; }
|
||||
|
||||
/* ── 报价档案卡(工作单独有,照 A4 定价档案卡的骨架)──────────────────────
|
||||
一颗料一张卡,data-rows 逐行复制:左条款、右议价。
|
||||
形态按定案分档(data-class-of):还在还价的整卡描橙,不成交的压灰 —— 判断在样式表。 */
|
||||
.proto-root .jc-selcards { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
|
||||
.proto-root .jc-selcard { border: 1px solid var(--line); border-radius: 11px; background: #fff; overflow: hidden; }
|
||||
.proto-root .jc-selcard.v-haggle { border-color: #f0cf9a; box-shadow: 0 1px 0 rgba(191,132,32,.06); }
|
||||
.proto-root .jc-selcard.v-lost { border-color: var(--line); opacity: .82; }
|
||||
.proto-root .jc-selcard-h {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
padding: 9px 14px; background: var(--soft); border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
.proto-root .jc-selcard.v-haggle .jc-selcard-h { background: var(--warn-soft); border-bottom-color: #f0cf9a; }
|
||||
.proto-root .jc-selcard-h code { font-size: 13px; font-weight: 800; color: var(--ink); }
|
||||
.proto-root .jc-selcard-h .why { flex: 1 1 auto; min-width: 0; font-size: 11px; color: var(--ink-3); }
|
||||
.proto-root .jc-selcard-b { display: grid; grid-template-columns: 1fr 1fr; gap: 0; }
|
||||
@media (max-width: 1000px) { .proto-root .jc-selcard-b { grid-template-columns: 1fr; } }
|
||||
.proto-root .jc-selcol { padding: 11px 14px; }
|
||||
.proto-root .jc-selcol + .jc-selcol { border-left: 1px solid var(--line-soft); }
|
||||
@media (max-width: 1000px) { .proto-root .jc-selcol + .jc-selcol { border-left: 0; border-top: 1px solid var(--line-soft); } }
|
||||
.proto-root .jc-selcol h5 { margin: 0 0 7px; font-size: 10.5px; font-weight: 800; letter-spacing: .08em; color: var(--ink-3); }
|
||||
.proto-root .jc-kvrow { display: flex; gap: 8px; padding: 3px 0; font-size: 12px; line-height: 1.55; }
|
||||
.proto-root .jc-kvrow i { flex: 0 0 84px; font-style: normal; color: var(--ink-4); font-size: 11px; padding-top: 1px; }
|
||||
.proto-root .jc-kvrow span { flex: 1 1 auto; min-width: 0; color: var(--ink); overflow-wrap: anywhere; }
|
||||
.proto-root .jc-kvrow span:empty::after { content: "—"; color: var(--ink-4); }
|
||||
.proto-root .jc-selcard .sc-req:empty::after {
|
||||
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
|
||||
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
|
||||
content: "必填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3;
|
||||
}
|
||||
.proto-root .jc-selcard .sc-sug:empty::after {
|
||||
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
|
||||
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
|
||||
content: "建议填"; color: var(--warn-accent); background: var(--warn-soft); border-color: #f0cf9a;
|
||||
}
|
||||
.proto-root .jc-selcard .sc-opt:empty::after {
|
||||
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
|
||||
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
|
||||
content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line);
|
||||
}
|
||||
|
||||
/* 两把尺(静态规则表 —— 尺挂在墙上,不是这一单的数据) */
|
||||
.proto-root .jc-graderule { border: 1px solid var(--line); border-radius: 10px; overflow: hidden; margin-bottom: 12px; }
|
||||
.proto-root .jc-graderule-r { display: flex; gap: 12px; align-items: flex-start; padding: 9px 13px; font-size: 12px; line-height: 1.6; }
|
||||
.proto-root .jc-graderule-r + .jc-graderule-r { border-top: 1px solid var(--line-soft); }
|
||||
.proto-root .jc-graderule-r p { margin: 0; color: var(--ink-2); }
|
||||
.proto-root .jc-graderule-r p b { color: var(--ink); }
|
||||
.proto-root .jc-band {
|
||||
flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; margin-top: 1px;
|
||||
min-width: 84px; height: 20px; padding: 0 8px; border-radius: 6px;
|
||||
font-size: 10.5px; font-weight: 800; letter-spacing: .04em; border: 1.5px solid; background: #fff;
|
||||
}
|
||||
.proto-root .jc-band.ga { color: var(--ok); border-color: var(--ok); }
|
||||
.proto-root .jc-band.gb { color: var(--brand); border-color: var(--brand); }
|
||||
.proto-root .jc-band.gc { color: var(--warn-accent); border-color: var(--warn-accent); }
|
||||
.proto-root .jc-band.gk { color: var(--danger); border-color: var(--danger); }
|
||||
.proto-root .jc-band.gz { color: var(--ink-3); border-color: var(--ink-4); }
|
||||
|
||||
/* 一单议价怎么走完:报价 → 发出 → 客户表态 → 定案 → 去向,内联线稿走位图 */
|
||||
.proto-root .jc-priceline { display: flex; align-items: center; gap: 0; margin: 2px 0 14px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 11px; background: var(--soft); }
|
||||
.proto-root .jc-plnode { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 3px; align-items: flex-start; }
|
||||
.proto-root .jc-plnode b { font-size: 10.5px; font-weight: 800; letter-spacing: .06em; color: var(--ink-3); }
|
||||
.proto-root .jc-plnode span { font-size: 12px; color: var(--ink-2); line-height: 1.5; }
|
||||
.proto-root .jc-plarrow { flex: 0 0 auto; padding: 0 10px; color: var(--brand-soft); }
|
||||
@media (max-width: 1000px) { .proto-root .jc-priceline { flex-direction: column; align-items: stretch; gap: 8px; } .proto-root .jc-plarrow { display: none; } }
|
||||
|
||||
/* ── 落边图:定案清单 → 三个去向(命中那条实线亮,别的留灰虚线;件不算不判)── */
|
||||
.proto-root .route { margin: 2px 0 14px; padding: 10px 6px 6px; border: 1px solid var(--line); border-radius: 11px; background: #fff; }
|
||||
.proto-root .route svg { width: 100%; height: auto; display: block; }
|
||||
.proto-root .route .rspine { stroke: var(--line); stroke-width: 2; }
|
||||
.proto-root .route .redge { stroke: var(--line); stroke-width: 2; stroke-dasharray: 5 5; fill: none; }
|
||||
.proto-root .route .rnode { fill: #fff; stroke: var(--line); stroke-width: 1.5; }
|
||||
.proto-root .route .rtxt { font-size: 12px; font-weight: 800; fill: var(--ink-3); }
|
||||
.proto-root .route .rsub { font-size: 10px; fill: var(--ink-4); }
|
||||
.proto-root .route .rcond { font-size: 10.5px; font-weight: 700; fill: var(--ink-4); }
|
||||
.proto-root .route .rtip { fill: var(--line); }
|
||||
.proto-root .route.v-r1 .e1, .proto-root .route.v-r2 .e2, .proto-root .route.v-r3 .e3 { stroke: var(--brand); stroke-dasharray: none; }
|
||||
.proto-root .route.v-r1 .n1, .proto-root .route.v-r2 .n2, .proto-root .route.v-r3 .n3 { stroke: var(--brand); stroke-width: 2; }
|
||||
.proto-root .route.v-r1 .t1, .proto-root .route.v-r2 .t2, .proto-root .route.v-r3 .t3 { fill: var(--brand-deep); }
|
||||
.proto-root .route.v-r1 .k1, .proto-root .route.v-r2 .k2, .proto-root .route.v-r3 .k3 { fill: var(--brand); }
|
||||
.proto-root .route.v-r1 .c1, .proto-root .route.v-r2 .c2, .proto-root .route.v-r3 .c3 { fill: var(--brand-deep); }
|
||||
.proto-root .route .legend { display: flex; gap: 14px; flex-wrap: wrap; padding: 6px 10px 2px; font-size: 10.5px; color: var(--ink-4); }
|
||||
|
||||
/* 结论带 */
|
||||
.proto-root .concl { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; align-items: start; margin: 2px 0 14px; padding: 11px 14px; border: 1px solid var(--line); border-radius: 11px; background: var(--soft); }
|
||||
.proto-root .concl svg { grid-row: span 2; margin-top: 2px; color: var(--brand); }
|
||||
.proto-root .concl .cl-main { font-size: 12.5px; color: var(--ink); line-height: 1.6; }
|
||||
.proto-root .concl .cl-main b { font-weight: 800; }
|
||||
.proto-root .concl .cl-route { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; font-size: 11.5px; color: var(--ink-3); }
|
||||
.proto-root .concl .cl-route em { font-style: normal; color: var(--ink-4); }
|
||||
.proto-root .concl .cl-route b { color: var(--brand-deep); font-weight: 800; }
|
||||
.proto-root .concl .cl-route code { font-family: var(--mono, ui-monospace, monospace); font-size: 10.5px; color: var(--ink-4); }
|
||||
`;
|
||||
|
||||
/** 一枚图标 —— 与原型同一写法 */
|
||||
const ico = (name: string, size = 11): string =>
|
||||
`<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-${name}"/></svg>`;
|
||||
|
||||
/** 三层壳照原型:少一层样式表全失效 */
|
||||
const doc = (input: { badge: string; kicker: string; title: string; titleRef: string; state: string; body: string; foot: string }): string =>
|
||||
`<style>${PRD_CSS}${BIND_CSS}</style>
|
||||
${PRD_SPRITE}
|
||||
<div class="proto-root"><div class="app-shell"><div class="jc-stage">
|
||||
<article class="jc-report">
|
||||
<header class="jc-rp-head">
|
||||
<div class="jc-rp-badge">${ico(input.badge, 17)}</div>
|
||||
<div class="jc-rp-tit">
|
||||
<em>${input.kicker}</em>
|
||||
<h3>${input.title} <span data-slot="${input.titleRef}"></span></h3>
|
||||
</div>
|
||||
${input.state}
|
||||
</header>
|
||||
<div class="jc-rp-bd">${input.body}</div>
|
||||
<footer class="jc-rp-foot">${input.foot}</footer>
|
||||
</article>
|
||||
</div></div></div>`;
|
||||
|
||||
const part = (label: string, aside = '', tone = ''): string =>
|
||||
`<div class="jc-rp-part"><b>${label}</b><i class="ln"></i>${aside === '' ? '' : `<em class="${tone}">${aside}</em>`}</div>`;
|
||||
|
||||
const sign = (note: string, tag = ''): string =>
|
||||
`<div class="jc-rp-sign">
|
||||
<span class="jc-rp-ava">${ico('user', 15)}</span>
|
||||
<div><b data-slot="case:owner"></b><span>${note}</span></div>
|
||||
</div>${tag === '' ? '' : `<span class="jc-done-tag">${ico('git-branch', 13)}${tag}</span>`}`;
|
||||
|
||||
/* ==================== 组成件 ==================== */
|
||||
|
||||
/** 要素四卡:客户 / 待回音 / 一格一料的定案刻度 / 议价的尺 */
|
||||
const FOUR = `<div class="jc-four">
|
||||
<div class="jc-fcard">
|
||||
<span class="jc-fcard-k"><i>${ico('building')}</i>客户公司</span>
|
||||
<div class="jc-fcard-b">
|
||||
<span class="jc-logo">${ico('building', 17)}</span>
|
||||
<div class="jc-fcard-t"><b data-slot="customer_name" data-blank></b><span data-slot="customer_code" data-blank></span></div>
|
||||
</div>
|
||||
<span class="jc-fcard-note">承自上游;单飞起单时此栏可空</span>
|
||||
</div>
|
||||
|
||||
<div class="jc-fcard t-teal">
|
||||
<span class="jc-fcard-k"><i>${ico('boxes')}</i>待回音</span>
|
||||
<div class="jc-count"><b data-slot="quote_negotiate_line_missing"></b><span>/ <b data-slot="quote_negotiate_line_rows"></b> 条报价</span></div>
|
||||
<span class="jc-fcard-note">客户表态还空着的行 —— 这一列只有收信的人填得了,系统不替客户说话;补一格少一格,基座现数</span>
|
||||
</div>
|
||||
|
||||
<div class="jc-fcard">
|
||||
<span class="jc-fcard-k"><i>${ico('shield-check')}</i>逐条定案</span>
|
||||
<div class="jc-segmeter" data-rows="mine:quote_negotiate.line">
|
||||
<i data-class-of="deal_status"></i>
|
||||
</div>
|
||||
<span class="jc-fcard-note">一格一条料 · 绿=成交,橙=客户还了价待拍板,蓝=退回重批,灰=不成交(拒绝或超时)</span>
|
||||
</div>
|
||||
|
||||
<div class="jc-fcard t-ok">
|
||||
<span class="jc-fcard-k"><i>${ico('history')}</i>报价有效期</span>
|
||||
<span class="jc-tagbig">${ico('check', 13)}<span><b data-slot="valid_days"></b> 天 · 至 <b data-slot="valid_until" data-blank></b></span></span>
|
||||
<span class="jc-fcard-note">按报价有效期 P-04 注明;D+3 催一轮、D+7 最后一催,催过仍不表态才按超时定案</span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
/** 一单议价是怎么走完的 —— 走位图(形状写死,不算不判) */
|
||||
const DEAL_LINE = `<div class="jc-priceline">
|
||||
<div class="jc-plnode"><b>批准价</b><span>A5 批过的那个数 —— 报出去的价一个数字都不改</span></div>
|
||||
<div class="jc-plarrow">${ico('arrow-right', 18)}</div>
|
||||
<div class="jc-plnode"><b>报价函发出</b><span>条款齐备(SPQ/MOQ/贸易条款/有效期),BD 发给客户</span></div>
|
||||
<div class="jc-plarrow">${ico('arrow-right', 18)}</div>
|
||||
<div class="jc-plnode"><b>客户表态</b><span>接受 / 还价 / 拒绝 / 无回复 —— 这一格由收信的人落</span></div>
|
||||
<div class="jc-plarrow">${ico('arrow-right', 18)}</div>
|
||||
<div class="jc-plnode"><b>定案 N-01</b><span>成交 / 待拍板 / 退回重批 / 不成交,逐条落定</span></div>
|
||||
<div class="jc-plarrow">${ico('arrow-right', 18)}</div>
|
||||
<div class="jc-plnode"><b>去向</b><span>退回重批→回定价 · 有成交→转履约 · 都没成→转归档</span></div>
|
||||
</div>`;
|
||||
|
||||
/** 缺表态红带 —— 与提交闸、放行门 L1 同一个数同一套算法(基座现数);一格不缺自动收 */
|
||||
const GAP_BAR = `<div class="jc-gapbar" data-class-of="quote_negotiate_line_missing">${ico('alert', 14)}
|
||||
<span><b>还差 <span data-slot="quote_negotiate_line_missing"></span> 行客户表态没落</b>
|
||||
—— <span data-slot="quote_negotiate_line_missing_keys"></span>。
|
||||
按右上角「编辑」开表,红框那几格就是要填的:接受填 ACCEPT,还价填 COUNTER 并把客户报的那个价
|
||||
填进「客户还价」,拒绝填 REJECT,催到有效期过仍不表态填 NOREPLY。补齐了执行单才提交得了。</span>
|
||||
</div>`;
|
||||
|
||||
const editGate = (address: string, what: string): string => `<div class="jc-editgate" data-edit-for="${address}">
|
||||
<span class="jc-editgate-t">${ico('lock', 12)}${what}<b>已锁定</b> —— 看的时候不误碰,要改按右边</span>
|
||||
<button type="button" class="jc-egbtn open" data-edit-open="${address}">${ico('pen', 12)}编辑</button>
|
||||
<button type="button" class="jc-egbtn ghost" data-edit-cancel="${address}">取消</button>
|
||||
<button type="button" class="jc-egbtn save" data-edit-save="${address}">${ico('check', 12)}保存</button>
|
||||
</div>
|
||||
<div class="jc-editbar">${ico('pen', 14)}
|
||||
<span>可填的是<b>客户表态</b>、<b>客户还价</b>与<b>客户原话</b>三列:
|
||||
<b>这三列系统一格都不写</b> —— 客户回了什么只有你看得见,别让系统替他表态。
|
||||
还价的行务必把他报的那个数填上,后面拍板全看这一格。本轮不报的料按行首 × 撤掉。
|
||||
改动只落任务草稿层,<b>保存不等于放行</b>。</span>
|
||||
</div>`;
|
||||
|
||||
const addRowBar = (address: string): string => `<div class="jc-addrow">
|
||||
<button type="button" class="jc-egbtn ghost" data-edit-add="${address}">${ico('plus', 12)}加一行</button>
|
||||
<em>照末行的样子添一行空的;主键(型号 PN)填了才存得下,不填这一行不落库。</em>
|
||||
</div>`;
|
||||
|
||||
/**
|
||||
* 报价函主表 —— 一行一料:条款列 + 客户那一侧三列 + 定案列。
|
||||
*
|
||||
* `data-col` 只挂人可改的三列(reply_type / counter_price / reply_note)+ 主键 pn ——
|
||||
* 编辑面精确等于 BD 的职权面;**主键格必挂 `data-col`**(它不是编辑面,是行的钥匙:
|
||||
* 不挂,服务端按「全表一致列」补键,两行会对到同一行上,后者盖前者 —— A2 走查逮过)。
|
||||
* 客户表态挂 `req`:空着出「必填」胶囊、整行标底,与提交闸、放行门同源(都数 `_missing`)。
|
||||
*/
|
||||
const LINE_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
|
||||
<thead><tr>
|
||||
<th style="width:36px;min-width:36px"></th>
|
||||
<th style="width:44px;min-width:44px">类</th>
|
||||
<th style="min-width:170px">PN</th>
|
||||
<th style="min-width:84px">品类</th>
|
||||
<th style="min-width:70px">年用量</th>
|
||||
<th style="min-width:70px">交期</th>
|
||||
<th style="min-width:64px">SPQ</th>
|
||||
<th style="min-width:64px">MOQ</th>
|
||||
<th style="min-width:86px">贸易条款</th>
|
||||
<th style="min-width:88px">报价 CNY</th>
|
||||
<th class="req" style="width:88px;min-width:88px">客户表态(你落)</th>
|
||||
<th style="min-width:80px">客户还价</th>
|
||||
<th style="width:80px;min-width:80px">定案</th>
|
||||
<th style="min-width:150px;width:100%">客户原话 / 让价理由</th>
|
||||
</tr></thead>
|
||||
<tbody data-rows="mine:quote_negotiate.line" data-edit="quote_negotiate.line"><tr>
|
||||
<td class="jc-rn"><button type="button" class="jc-rowdel" data-edit-del="quote_negotiate.line" title="撤掉这一行">×</button></td>
|
||||
<td class="jc-ty" data-h="类"><span class="jc-tbadge" data-class-of="type_tag" data-slot="type_tag"></span></td>
|
||||
<td data-h="PN"><span class="jc-vcell mono" data-slot="pn" data-col="pn" data-blank></span></td>
|
||||
<td class="dimempty" data-h="品类"><span class="jc-vcell" data-slot="category" data-blank></span></td>
|
||||
<td class="dimempty" data-h="年用量"><span class="jc-vcell mono" data-slot="eau" data-blank></span></td>
|
||||
<td class="dimempty" data-h="交期"><span class="jc-vcell" data-slot="lead_time" data-blank></span></td>
|
||||
<td class="dimempty" data-h="SPQ"><span class="jc-vcell num" data-slot="spq" data-blank></span></td>
|
||||
<td class="dimempty" data-h="MOQ"><span class="jc-vcell num" data-slot="moq" data-blank></span></td>
|
||||
<td class="dimempty" data-h="贸易条款"><span class="jc-vcell" data-slot="trade_terms" data-blank></span></td>
|
||||
<td class="dimempty" data-h="报价 CNY"><span class="jc-vcell num" data-slot="quoted_price" data-blank></span></td>
|
||||
<td class="req" data-h="客户表态(你落)">
|
||||
<span class="jc-obadge" data-class-of="reply_type"></span>
|
||||
<span class="jc-vcell" data-slot="reply_type" data-col="reply_type" data-blank></span>
|
||||
</td>
|
||||
<td class="sug" data-h="客户还价"><span class="jc-vcell num" data-slot="counter_price" data-col="counter_price" data-blank></span></td>
|
||||
<td data-h="定案"><span class="jc-dbadge" data-class-of="deal_status"></span></td>
|
||||
<td class="opt" data-h="客户原话 / 让价理由"><span class="jc-vcell" data-slot="reply_note" data-col="reply_note" data-blank></span></td>
|
||||
</tr></tbody>
|
||||
</table></div>`;
|
||||
|
||||
/**
|
||||
* 成交定案表(收口件用)—— 编辑面换成**放行时可改的两列**(成交价与客户原话),
|
||||
* 与 `requiredDeliverables[].editableAtRelease` 逐字对齐。
|
||||
*/
|
||||
const SETTLED_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
|
||||
<thead><tr>
|
||||
<th style="width:36px;min-width:36px"></th>
|
||||
<th style="width:44px;min-width:44px">类</th>
|
||||
<th style="min-width:170px">PN</th>
|
||||
<th style="min-width:88px">报价 CNY</th>
|
||||
<th style="width:88px;min-width:88px">客户表态</th>
|
||||
<th style="min-width:80px">客户还价</th>
|
||||
<th style="min-width:110px">让步处置</th>
|
||||
<th style="width:80px;min-width:80px">定案</th>
|
||||
<th style="min-width:88px">成交价</th>
|
||||
<th style="min-width:150px;width:100%">客户原话 / 让价理由</th>
|
||||
</tr></thead>
|
||||
<tbody data-rows="mine:quote_negotiate.line" data-edit="quote_negotiate.line"><tr>
|
||||
<td class="jc-rn"><button type="button" class="jc-rowdel" data-edit-del="quote_negotiate.line" title="撤掉这一行">×</button></td>
|
||||
<td class="jc-ty" data-h="类"><span class="jc-tbadge" data-class-of="type_tag" data-slot="type_tag"></span></td>
|
||||
<td data-h="PN"><span class="jc-vcell mono" data-slot="pn" data-col="pn" data-blank></span></td>
|
||||
<td class="dimempty" data-h="报价 CNY"><span class="jc-vcell num" data-slot="quoted_price" data-blank></span></td>
|
||||
<td data-h="客户表态"><span class="jc-obadge" data-class-of="reply_type"></span></td>
|
||||
<td class="dimempty" data-h="客户还价"><span class="jc-vcell num" data-slot="counter_price" data-blank></span></td>
|
||||
<td class="dimempty" data-h="让步处置"><span class="jc-cbadge" data-class-of="concession"></span></td>
|
||||
<td data-h="定案"><span class="jc-dbadge" data-class-of="deal_status"></span></td>
|
||||
<td class="opt" data-h="成交价"><span class="jc-vcell num" data-slot="final_price" data-col="final_price" data-blank></span></td>
|
||||
<td class="opt" data-h="客户原话 / 让价理由"><span class="jc-vcell" data-slot="reply_note" data-col="reply_note" data-blank></span></td>
|
||||
</tr></tbody>
|
||||
</table></div>`;
|
||||
|
||||
/** 定案分布:四段按条数分宽 —— 守恒等式(四档相加 = 行数)的可视形 */
|
||||
const DIST = `<div class="jc-dist">
|
||||
<div class="jc-distbar">
|
||||
<i class="p" data-class-of="quote_negotiate_line_deal_status_won" data-slot="quote_negotiate_line_deal_status_won"></i>
|
||||
<i class="n" data-class-of="quote_negotiate_line_deal_status_haggle" data-slot="quote_negotiate_line_deal_status_haggle"></i>
|
||||
<i class="r" data-class-of="quote_negotiate_line_deal_status_revise" data-slot="quote_negotiate_line_deal_status_revise"></i>
|
||||
<i class="k" data-class-of="quote_negotiate_line_deal_status_lost" data-slot="quote_negotiate_line_deal_status_lost"></i>
|
||||
</div>
|
||||
<div class="jc-distlg">
|
||||
<span><em class="a"></em>成交 · 客户接受 <b data-slot="quote_negotiate_line_deal_status_won"></b></span>
|
||||
<span><em class="b"></em>待拍板 · 客户还了价 <b data-slot="quote_negotiate_line_deal_status_haggle"></b></span>
|
||||
<span><em class="b"></em>退回重批 · 让幅超授权 <b data-slot="quote_negotiate_line_deal_status_revise"></b></span>
|
||||
<span><em class="c"></em>不成交 · 拒绝或超时 <b data-slot="quote_negotiate_line_deal_status_lost"></b></span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
/* ==================== 局面一 · 议价工作单(排版 / 发出 / 落回音)==================== */
|
||||
|
||||
/**
|
||||
* 逐颗报价档案 —— 一颗料一张卡(data-rows 逐行复制):左条款、右议价。
|
||||
*
|
||||
* **容器用 `<section>` 不用 `<div>`**:渲染器的行展开按第一个同名闭合标签截行内容,
|
||||
* 行模版里嵌着 `<div>` 时 `<div data-rows>` 会把行截成套娃(A2 逮过,记在陷阱表)。
|
||||
*/
|
||||
const QUOTE_CARDS = `<section class="jc-selcards" data-rows="mine:quote_negotiate.line">
|
||||
<article class="jc-selcard" data-class-of="deal_status">
|
||||
<div class="jc-selcard-h">
|
||||
<span class="jc-tbadge" data-class-of="type_tag" data-slot="type_tag"></span>
|
||||
<code data-slot="pn"></code>
|
||||
<span class="why" data-slot="remark" data-blank></span>
|
||||
<span class="jc-obadge" data-class-of="reply_type"></span>
|
||||
<span class="jc-dbadge" data-class-of="deal_status"></span>
|
||||
</div>
|
||||
<div class="jc-selcard-b">
|
||||
<div class="jc-selcol">
|
||||
<h5>报价函条款(发出去就是承诺)</h5>
|
||||
<div class="jc-kvrow"><i>客户料号</i><span data-slot="customer_pn" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>品类 / 制造商</i><span data-slot="category" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>年用量</i><span data-slot="eau" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>报价 CNY</i><span class="num" data-slot="quoted_price" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>交期</i><span data-slot="lead_time" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>SPQ / MOQ</i><span class="num" data-slot="spq" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>贸易条款</i><span data-slot="trade_terms" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>有效期至</i><span data-slot="valid_until" data-blank></span></div>
|
||||
</div>
|
||||
<div class="jc-selcol">
|
||||
<h5>议价(填回下表,这里回显)</h5>
|
||||
<div class="jc-kvrow"><i>客户表态</i><span class="sc-req" data-slot="reply_type" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>客户还价</i><span class="sc-sug num" data-slot="counter_price" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>客户原话</i><span class="sc-opt" data-slot="reply_note" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>让步处置</i><span class="jc-cbadge" data-class-of="concession"></span></div>
|
||||
<div class="jc-kvrow"><i>定案</i><span class="jc-dbadge" data-class-of="deal_status"></span></div>
|
||||
<div class="jc-kvrow"><i>成交价</i><span class="num" data-slot="final_price" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>客户约束</i><span data-slot="remark" data-blank></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>`;
|
||||
|
||||
/** 议价结果 N-01 的四档 —— 静态规则,尺挂在墙上(素材:MVP 示例工作流 §1.4) */
|
||||
const OUTCOME_RULES = `<div class="jc-graderule">
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band ga">ACCEPT</span>
|
||||
<p><b>客户明确接受这个价。</b>「按这个价走」「下单吧」「PO 稍后发」——
|
||||
定案成交,成交价就是报价,转订单履约。</p>
|
||||
</div>
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band gc">COUNTER</span>
|
||||
<p><b>客户还了一个价。</b>把他报的那个数落进「客户还价」那一格 ——
|
||||
没有数字的「太贵了」先追问,追不到才按无回复落。还价的条目<b>先落成「待拍板」</b>,
|
||||
等人按让步梯度拍一下才有结论:接了算成交、退回重批、或守价不让 —— 系统不替你让价。</p>
|
||||
</div>
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band gk">REJECT</span>
|
||||
<p><b>客户明确不做了。</b>选了别家、项目取消、规格不匹配 ——
|
||||
定案不成交,把缘由记进备注,转情报归档:这一单丢了,情报留下。</p>
|
||||
</div>
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band gz">NOREPLY</span>
|
||||
<p><b>催到有效期过仍不表态。</b><b>客户不回不是结论</b> ——
|
||||
D+3 第一轮催问确认收到探态度,D+7 最后一催;催过了才轮到按超时定案。</p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
/** 让步梯度 N-02 的三档 —— 静态规则(素材:MVP 示例工作流 §1.4 让价核线) */
|
||||
const CONCESSION_RULES = `<div class="jc-graderule">
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band ga">≤ 3%</span>
|
||||
<p><b>接了拿单。</b>意思意思的压价 —— 来回一轮的时间成本比这三个点贵,
|
||||
当场接住,把单子锁下来。</p>
|
||||
</div>
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band gb">3% ~ 10%</span>
|
||||
<p><b>授权内回旋。</b>让一半再报回去,把让出去的换成量或账期 ——
|
||||
先弄清争的是价格、交期还是 MOQ:争交期的用交期换,一上来就降价,后面只剩降价这一招。</p>
|
||||
</div>
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band gk">> 10%</span>
|
||||
<p><b>超出授权,退回成本定价重批。</b>这一档系统不替任何人放行,也不许私自破线 ——
|
||||
底价线以下的单,每补一次货都在亏。<b>破线不是勇气是事故</b>。</p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export const NEGOTIATE_WORKSHEET = doc({
|
||||
badge: 'sheet',
|
||||
kicker: '统一报价 · A6 报价议价 · 议价作业',
|
||||
title: '议价工作单',
|
||||
titleRef: 'rfq_ref',
|
||||
state:
|
||||
'<span class="jc-rp-wait warn">待回音 <b data-slot="quote_negotiate_line_missing"></b> / <b data-slot="quote_negotiate_line_rows"></b> 条</span>',
|
||||
body:
|
||||
kvCard('报价抬头', [
|
||||
['报价单号', 'quoteNo', 'mono'],
|
||||
['客户编码', 'customerCode', 'mono'],
|
||||
['型号 PN', 'pn', 'mono'],
|
||||
]) +
|
||||
kvCard('报价条款', [
|
||||
['单价', 'unit_price'],
|
||||
['交期', 'lead_time'],
|
||||
['最小包装量 SPQ', 'spq'],
|
||||
['最小起订量 MOQ', 'moq'],
|
||||
['贸易条款', 'trade_terms'],
|
||||
['有效期', 'valid_until'],
|
||||
]) +
|
||||
noteCard('这一步还没碰外界', '报价单此刻只是<b>草稿</b>。发出动作是<b>写类</b>的,过程中只登记为「动作意向」——入参冻结、等放行,放行前一封信都不会发出去。'),
|
||||
part('议价任务', '客户 <b data-slot="customer_name" data-blank></b> · 报价函 <b data-slot="quote_no" data-blank></b> · 共 <b data-slot="line_total"></b> 条') +
|
||||
`<div class="jc-derive">${ico('info', 14)}
|
||||
<span>这一屏是一份报价函的作业台:系统已按 A5 批准的价排好版,条款与有效期都在下面 ——
|
||||
<b>报出去的价一个数字都不改</b>(要改价得回审批)。你要做的是把它发给客户,
|
||||
按 D+3 / D+7 的节奏催问,<b>客户回话之后在「客户表态」那一列逐行落</b>。
|
||||
这一列系统一格都不写:客户回了什么,只有收信的人看得见。</span>
|
||||
</div>` +
|
||||
FOUR +
|
||||
part('一单议价是怎么走完的', '批准价 → 发出 → 客户表态 → 定案 → 去向') +
|
||||
DEAL_LINE +
|
||||
part('逐颗报价档案', '条款在左 · 议价在右') +
|
||||
QUOTE_CARDS +
|
||||
part('议价结果 N-01', '客户的话只有四种表态 —— 尺挂在墙上') +
|
||||
OUTCOME_RULES +
|
||||
part('让步梯度 N-02', '还价幅度决定让不让、谁来让') +
|
||||
CONCESSION_RULES +
|
||||
part('填回工作表', '客户表态落在这 —— 补齐了执行单才提交得了') +
|
||||
GAP_BAR +
|
||||
editGate('quote_negotiate.line', '报价函填回表') +
|
||||
LINE_TABLE +
|
||||
addRowBar('quote_negotiate.line') +
|
||||
`<div class="jc-drop">${ico('info', 13)}
|
||||
<span>带红标的<b>客户表态</b>是必填,而且<b>只由你填</b> —— 缺一行执行单按不下提交,
|
||||
与放行门是同一个数同一套算法。四个取值一个字母不许差:
|
||||
<b>ACCEPT</b> 接受 · <b>COUNTER</b> 还价 · <b>REJECT</b> 拒绝 · <b>NOREPLY</b> 无回复。
|
||||
还价的行务必把客户报的那个价填进<b>客户还价</b> —— 那一格空着这张单同样提交不了,
|
||||
后面拍板全看它。客户只回了一部分料的,没回的那几行按 NOREPLY 落,
|
||||
<b>别替客户猜</b>;本轮不报的料按行首 × 撤掉。</span>
|
||||
</div>`,
|
||||
foot: sign('BD 议价 · AI 排版报价函并按两把尺定案,客户那一侧的话由你落', '统一报价 · A6 报价议价 · 在办'),
|
||||
});
|
||||
|
||||
export const QUOTE_RELEASE = viewShell({
|
||||
title: '正式报价单 · 放行即发出',
|
||||
kicker: 'A6 · 成果预览',
|
||||
tone: 'danger',
|
||||
/* ==================== 局面二 · 成交定案清单(拍板 / 放行 / 归档)==================== */
|
||||
|
||||
/** 落边图:定案清单 → 三个去向(命中边由壳上 v-r1/v-r2/v-r3 上色,件不算不判) */
|
||||
const ROUTE_FIG = `<div class="route" data-class-of="case:routeKey">
|
||||
<svg viewBox="0 0 660 250" fill="none">
|
||||
<rect class="rnode" x="8" y="95" width="150" height="60" rx="10"/>
|
||||
<text class="rtxt" x="83" y="120" text-anchor="middle">成交定案清单</text>
|
||||
<text class="rsub" x="83" y="138" text-anchor="middle">共 <tspan data-slot="quote_negotiate_line_rows"></tspan> 条</text>
|
||||
<path class="rspine" d="M158 125 H210"/>
|
||||
<path class="redge e1" d="M210 125 C280 125 300 40 460 40"/>
|
||||
<path class="redge e2" d="M210 125 C300 125 340 125 460 125"/>
|
||||
<path class="redge e3" d="M210 125 C280 125 300 210 460 210"/>
|
||||
<path class="rtip k1" d="M448 34 l14 6 -14 6 z"/>
|
||||
<path class="rtip k2" d="M448 119 l14 6 -14 6 z"/>
|
||||
<path class="rtip k3" d="M448 204 l14 6 -14 6 z"/>
|
||||
<text class="rcond c1" x="300" y="60">退回重批 <tspan data-slot="quote_negotiate_line_deal_status_revise"></tspan> 条</text>
|
||||
<text class="rcond c2" x="300" y="115">成交 <tspan data-slot="quote_negotiate_line_deal_status_won"></tspan> 条</text>
|
||||
<text class="rcond c3" x="300" y="230">不成交 <tspan data-slot="quote_negotiate_line_deal_status_lost"></tspan> 条</text>
|
||||
<rect class="rnode n1" x="460" y="8" width="180" height="64" rx="10"/>
|
||||
<text class="rtxt t1" x="550" y="35" text-anchor="middle">A4 · 成本定价</text>
|
||||
<text class="rsub" x="550" y="53" text-anchor="middle">让幅超授权 · 退回重批</text>
|
||||
<rect class="rnode n2" x="460" y="93" width="180" height="64" rx="10"/>
|
||||
<text class="rtxt t2" x="550" y="120" text-anchor="middle">转订单履约 ORM</text>
|
||||
<text class="rsub" x="550" y="138" text-anchor="middle">成交 · 这条流走到终点</text>
|
||||
<rect class="rnode n3" x="460" y="178" width="180" height="64" rx="10"/>
|
||||
<text class="rtxt t3" x="550" y="205" text-anchor="middle">A7 · 情报归档</text>
|
||||
<text class="rsub" x="550" y="223" text-anchor="middle">拒绝 / 超时 · 情报留下</text>
|
||||
</svg>
|
||||
<div class="legend">
|
||||
<span>命中边实线亮 · 未命中留灰虚线</span>
|
||||
<span>按序求值首个命中即出:判了退回重批 → 回定价;其次有成交 → 转履约;都没成 → 转归档</span>
|
||||
<span>判定在你签字那一刻由系统现算 —— 要改去向,在这张表上改定案那几格</span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export const NEGOTIATE_ROSTER = doc({
|
||||
badge: 'file-check',
|
||||
kicker: '统一报价 · A6 报价议价 · 交付成果',
|
||||
title: '成交定案清单',
|
||||
titleRef: 'rfq_ref',
|
||||
state:
|
||||
'<span class="jc-rp-wait">报价 <b data-slot="quote_negotiate_line_rows"></b> 条 · 待拍板 <b data-slot="quote_negotiate_line_deal_status_haggle"></b> 条(要归零)</span>',
|
||||
body:
|
||||
tableCard(
|
||||
'本单报价',
|
||||
'mine:quote_negotiate.formal_quote',
|
||||
[
|
||||
['报价单号', 'quote_no', 'mono'],
|
||||
['型号', 'pn', 'mono'],
|
||||
['单价', 'unit_price'],
|
||||
['交期', 'lead_time'],
|
||||
],
|
||||
) +
|
||||
noteCard(
|
||||
'按下放行会发生什么',
|
||||
'① <b>落地预检</b>先跑:交付物齐备、动作意向可执行、授权在契约边界内 —— 预检不过不发卡。' +
|
||||
'② 放行通过<b>即刻落地</b>:台账回写 + 出站发送(报价单发给客户),动作附幂等键。' +
|
||||
'③ <b>落地收敛</b>后输出口才发射 —— 下游只见落地完成的任务;已成动作留存<b>不回滚</b>。' +
|
||||
'④ 出口三选一(成交 / 有异议 / 拒绝)<b>恰发一口</b>,随放行一并确认,G4 网关按它分流。',
|
||||
),
|
||||
part('议价要素', '报价议价 · 能查表的归判定表,要让价的归人') +
|
||||
FOUR +
|
||||
part('定案分布', '成交 + 待拍板 + 退回重批 + 不成交 = 总条数') +
|
||||
DIST +
|
||||
`<div class="jc-derive">${ico('info', 14)}
|
||||
<span>四段条数由基座逐行现数,不是谁报上来的。橙段(<b>待拍板</b>)是过渡态 ——
|
||||
客户还了价而还没人拍板,它<b>归零这一单才走得到放行门</b>:接了算成交、
|
||||
退回重批算一条正经的路、守价不让算不成交。要让价就得在授权内让,
|
||||
超过一成的让幅一律回去重批,<b>不私自破线</b>。</span>
|
||||
</div>` +
|
||||
part('正式报价函', '<b data-slot="line_total"></b> 条 · 每条都带客户的话') +
|
||||
GAP_BAR +
|
||||
editGate('quote_negotiate.line', '成交定案表') +
|
||||
SETTLED_TABLE +
|
||||
`<div class="jc-drop">${ico('info', 13)}
|
||||
<span>每一行摆的是这一条的<b>来路</b>:报了多少、客户怎么表态、还了什么价、
|
||||
让步梯度判在哪一档、最后定成什么。签字前请重点看两处:<b>定案列</b>里非绿的条目,
|
||||
与<b>客户原话列</b> —— 没有客户原话的还价,下一轮谈判就是重新开始。
|
||||
这一屏可改的是<b>成交价</b>与<b>客户原话</b>两列(你的职权面)。</span>
|
||||
</div>` +
|
||||
part('落边 · 这一签流向哪里', '出口按序求值 · 首个命中即出') +
|
||||
ROUTE_FIG +
|
||||
`<div class="concl">
|
||||
${ico('git-branch', 15)}
|
||||
<div class="cl-main"><b>议价结论</b> —— <span data-slot="summary" data-blank></span></div>
|
||||
<div class="cl-route"><em>转入活动</em><b data-slot="case:routeTo"></b><code data-slot="case:routeWhy"></code></div>
|
||||
</div>` +
|
||||
part('放行后即执行的落地动作', '门前 → 门后') +
|
||||
`<div class="jc-acts">
|
||||
<div class="jc-act"><i>${ico('check', 11)}</i>
|
||||
<div><b>草稿层合并进正式版</b> <span>本任务写下的报价函与议价结论一并落账;
|
||||
你就地改过的成交价与客户原话随之落定。</span></div>
|
||||
</div>
|
||||
<div class="jc-act"><i>${ico('check', 11)}</i>
|
||||
<div><b>命中的那一口此刻才发射</b> <span>报价函
|
||||
<b data-slot="quote_negotiate_line_rows"></b> 条随载荷交给上面亮线指向的下游;
|
||||
另两口本次不起单 —— 放行之前,世界上什么都没有发生。</span></div>
|
||||
</div>
|
||||
<div class="jc-act"><i>${ico('check', 11)}</i>
|
||||
<div><b>不成交也留资产</b> <span>拒绝原因与竞对价位随载荷交给情报归档 ——
|
||||
这一单丢了,下一单的三分类与利润模型会用上它。</span></div>
|
||||
</div>
|
||||
</div>`,
|
||||
foot: sign('BD 议价放行 · AI 排版与定案留痕 —— 要改哪一格,在这张定案表上就地改', '统一报价 · A6 报价议价 → 定去向'),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
/**
|
||||
* A4 成本定价的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。
|
||||
* 案例活动没有 `run`,自测不跑回合;测的是清单本身与四处对齐
|
||||
* (判据谓词 / 出口载荷 / 剧本声明 / 两把尺的档位)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { activityManifestSchema } from '@contracts';
|
||||
import { evaluateCondition, evaluateUnaryTest } from '@sfeel';
|
||||
import { createMockHost } from '@sdk';
|
||||
import { P01_DMN, manifest } from '../src/manifest.ts';
|
||||
import activity from '../src/index.ts';
|
||||
|
||||
describe('清单本身合规', () => {
|
||||
it('过 @contracts 的 schema', () => {
|
||||
expect(() => activityManifestSchema.parse(manifest)).not.toThrow();
|
||||
});
|
||||
|
||||
it('监听时限已配 —— 无悬空监听', () => {
|
||||
expect(manifest.declarations.listeners.every((l) => /^P/.test(l.deadline))).toBe(true);
|
||||
});
|
||||
|
||||
it('L1/L2 判据都给了可机检的谓词 —— 验收等级不得谎报', () => {
|
||||
for (const c of manifest.contract.acceptanceCriteria) {
|
||||
if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef, c.key).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('禁区短而硬,授权边界给了调人预算', () => {
|
||||
expect(manifest.contract.prohibitions.length).toBeGreaterThan(0);
|
||||
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('每件必交付物都有台账地址', () => {
|
||||
for (const d of manifest.contract.requiredDeliverables) {
|
||||
expect(d.ledger, d.key).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **宽进严出**:起单一个必填都不设 —— 上游(A3 的 quotation 口)发的是
|
||||
* `{rfqRef, rows}`,而上一版必填 `rfqRef + customerCode + records`,
|
||||
* 门口拦的恰是主路。归一在活动内部做。
|
||||
*/
|
||||
it('起单宽进:input.required 是空的,三种来路的键都声明着', () => {
|
||||
expect(manifest.contract.input.data['required']).toEqual([]);
|
||||
const properties = manifest.contract.input.data['properties'] as Record<string, unknown>;
|
||||
for (const key of ['rfqRef', 'rows', 'records', 'subject', 'body']) {
|
||||
expect(properties[key], `入参少了 ${key}`).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **拟报单价是必填** —— 这是 A4 的门面判据:件上的缺价红带、执行卡的提交闸、
|
||||
* 放行门 L1 三处同源(都数基座的 `_missing`)。声明丢了 required,三处一起失明。
|
||||
*/
|
||||
it('拟报单价标了 required —— 红带 / 提交闸 / 放行门三处同源的那个数才存在', () => {
|
||||
const draft = manifest.declarations.ledger.find((d) => d.object === 'draft');
|
||||
expect(draft?.fields.find((f) => f.name === 'quoted_price')?.required).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* **建议价与拟报单价分两列是结构治,不是措辞治**(2026-08-03 单飞案例逼出来的):
|
||||
* 只有一列价时,剧本写「算不出的留空」,模型照样给库里查不到的料编了价 ——
|
||||
* 一条要它分辨「算得出/算不出」的规矩会走岔;「这一列你不写」没有分支可走岔。
|
||||
*/
|
||||
it('建议价与拟报单价是两列 —— AI 写建议价,人写拟报单价', () => {
|
||||
const draft = manifest.declarations.ledger.find((d) => d.object === 'draft');
|
||||
const suggested = draft?.fields.find((f) => f.name === 'suggested_price');
|
||||
expect(suggested, '缺 suggested_price 这一列').toBeDefined();
|
||||
expect(suggested?.required, '建议价不该是必填 —— 算不出就该空着').toBeUndefined();
|
||||
for (const step of ['S1_price', 'S3_gate']) {
|
||||
const guide = manifest.assets.businessModel.steps?.find((s) => s.item === step)?.guide ?? '';
|
||||
expect(guide, `${step} 没写死「quoted_price 你一格都不写」`).toMatch(/quoted_price[^\n]*一格都不写|一格都不写[^\n]*quoted_price/);
|
||||
}
|
||||
});
|
||||
|
||||
it('门禁处置给了取值域 —— 三档条数由基座现数,不由活动自报', () => {
|
||||
const draft = manifest.declarations.ledger.find((d) => d.object === 'draft');
|
||||
expect(draft?.fields.find((f) => f.name === 'disposition')?.tally).toEqual(['PASS', 'NOTE', 'BLOCK']);
|
||||
});
|
||||
|
||||
/*
|
||||
* 我方写的注不标 `expect` —— 标了 `_gaps` 恒大于 0,读它的判据永远绿不了(A1 的账)。
|
||||
* 客户目标价才是「客户该给而没给的」那一问。
|
||||
*/
|
||||
it('expect 只标客户该给的那一格,不标我方写的注', () => {
|
||||
const draft = manifest.declarations.ledger.find((d) => d.object === 'draft');
|
||||
expect(draft?.fields.find((f) => f.name === 'target_price')?.expect).toBe(true);
|
||||
expect(draft?.fields.find((f) => f.name === 'price_note')?.expect).toBeUndefined();
|
||||
});
|
||||
|
||||
it('三条常备案例形状真不同:寻源转来 / 客户压价超限 / 单飞无基准', () => {
|
||||
expect(manifest.samples.length).toBe(3);
|
||||
const [flowIn, overLimit, solo] = manifest.samples;
|
||||
expect((flowIn?.input as { rows?: unknown[] }).rows?.length).toBeGreaterThan(0);
|
||||
expect((overLimit?.input as { rows?: Array<Record<string, number>> }).rows?.[0]?.['target_price']).toBeLessThan(
|
||||
(overLimit?.input as { rows?: Array<Record<string, number>> }).rows?.[0]?.['base_price'] ?? 0,
|
||||
);
|
||||
expect((solo?.input as { rows?: unknown[] }).rows).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('案例模型 —— 双表示同版本', () => {
|
||||
const model = manifest.assets.businessModel;
|
||||
const cmmn = parseCmmnLite(model.cmmnXml);
|
||||
|
||||
it('CMMN 与说明书同在,不允许分叉', () => {
|
||||
expect(model.cmmnXml).toContain('<casePlanModel');
|
||||
expect(model.narrative.length).toBeGreaterThan(400);
|
||||
});
|
||||
|
||||
/* 照 MVP 示例工作流 §1.2 的 S1 取价基准 / S2 逐条定价 / S3 底价自检,一步一项 */
|
||||
it('计划项照 SOP 一步一项:算建议价 + 交人定价 + 底价自检 + 超限拍板 + 放行', () => {
|
||||
expect(items(model.cmmnXml)).toEqual(['S1_price', 'S2_review', 'S3_gate', 'T_approve', 'T_release']);
|
||||
});
|
||||
|
||||
it('三处人的门都标了人工激活:BD 定价 · 超限拍板 · 草案放行(底价自检是算术,不标)', () => {
|
||||
expect(cmmn.manualActivation).toEqual(['S2_review', 'T_approve', 'T_release']);
|
||||
});
|
||||
|
||||
it('不用阶段 / 里程碑 / 裁量项', () => {
|
||||
for (const tag of ['<stage', '<milestone', '<planningTable', '<discretionaryItem']) {
|
||||
expect(model.cmmnXml, `不该出现 ${tag}`).not.toContain(tag);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **超限才叫人** —— 这个活动的要害:哨兵读基座按值现数的拦截档条数,
|
||||
* 而不是活动自报的 `over_total`;且要等 BD 核过价(他自己回调掉的不必再惊动第二个人)。
|
||||
*/
|
||||
it('超限拍板的哨兵读基座现数,且等人核过价 —— 不是每条都叫人,也不是一条都不叫', () => {
|
||||
const condition = sentryIf(model.cmmnXml, 's_over');
|
||||
expect(condition).toContain('quote_pricing_draft_disposition_block > 0');
|
||||
expect(condition).toContain('confirmed_total');
|
||||
// 有拦截档且人核过价 → 叫人
|
||||
expect(safeEval(condition, { quote_pricing_draft_disposition_block: 1, confirmed_total: 2 })).toBe(true);
|
||||
// 拦截档清零 → 不叫人(这一步整个不入场)
|
||||
expect(safeEval(condition, { quote_pricing_draft_disposition_block: 0, confirmed_total: 2 })).toBe(false);
|
||||
// 人还没核价 → 先别叫(他自己回调掉的那几条不该再惊动一个人)
|
||||
expect(safeEval(condition, { quote_pricing_draft_disposition_block: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
/*
|
||||
* 入场哨兵也归「机检只读基座数」管(第四次违反就藏在哨兵里)——
|
||||
* 上一版 `s_priced` 读活动自报的 `draft_total`:模型不记这个账,门就永不开。
|
||||
*/
|
||||
it('交人核价的入场哨兵读基座行数,不读自报的草案条数', () => {
|
||||
const condition = sentryIf(model.cmmnXml, 's_priced');
|
||||
expect(condition).toContain('quote_pricing_draft_rows');
|
||||
expect(condition).not.toContain('draft_total');
|
||||
expect(safeEval(condition, { line_total: 2, quote_pricing_draft_rows: 2, priced_total: 2 })).toBe(true);
|
||||
// 行落了但算价那一轮还没走完(priced_total 没写过)→ 门不开
|
||||
expect(safeEval(condition, { line_total: 2, quote_pricing_draft_rows: 2 })).toBe(false);
|
||||
});
|
||||
|
||||
it('放行门哨兵带 ifPart:每行有价 + 处置守恒 + 拦截清零 + 定盘记过,全读基座数', () => {
|
||||
const condition = sentryIf(model.cmmnXml, 's_ready');
|
||||
expect(condition).toContain('quote_pricing_draft_missing = 0');
|
||||
expect(condition).toContain('quote_pricing_draft_disposition_block = 0');
|
||||
const done = {
|
||||
route_decided_total: 1,
|
||||
quote_pricing_draft_rows: 3,
|
||||
quote_pricing_draft_missing: 0,
|
||||
quote_pricing_draft_disposition_pass: 2,
|
||||
quote_pricing_draft_disposition_note: 1,
|
||||
quote_pricing_draft_disposition_block: 0,
|
||||
};
|
||||
expect(safeEval(condition, done)).toBe(true);
|
||||
// 还有一行缺拟报价 → 门不开
|
||||
expect(safeEval(condition, { ...done, quote_pricing_draft_missing: 1 })).toBe(false);
|
||||
// 还有条目压在拦截档 → 门不开(哪怕守恒仍成立)
|
||||
expect(
|
||||
safeEval(condition, { ...done, quote_pricing_draft_note: 0, quote_pricing_draft_disposition_note: 0, quote_pricing_draft_disposition_block: 1 }),
|
||||
).toBe(false);
|
||||
// 处置没记齐(守恒不成立)→ 门不开
|
||||
expect(safeEval(condition, { ...done, quote_pricing_draft_disposition_note: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
it('每个计划项都有剧本', () => {
|
||||
const ids = items(model.cmmnXml);
|
||||
expect((model.steps ?? []).map((s) => s.item)).toEqual(ids);
|
||||
for (const step of model.steps ?? []) {
|
||||
expect(step.guide.length, `${step.item} 的做法`).toBeGreaterThan(80);
|
||||
expect(step.intent.length, `${step.item} 的意图`).toBeGreaterThan(6);
|
||||
}
|
||||
});
|
||||
|
||||
it('说明书不点名计划项', () => {
|
||||
const named = [...model.narrative.matchAll(/\b(?:S\d+_[A-Za-z]+|T_[a-z]+)\b/g)].map((m) => m[0]);
|
||||
const ids = new Set(items(model.cmmnXml));
|
||||
for (const name of named) {
|
||||
expect(ids.has(name), `说明书提到的 ${name} 不在案例模型里`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('说明书只讲整单的事:成品、怎么说话、怎么收口', () => {
|
||||
for (const topic of ['开案', '要说话', '一拍连做', '结案']) {
|
||||
expect(model.narrative, topic).toContain(topic);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('声明与契约对得上', () => {
|
||||
const columns = new Set(
|
||||
manifest.declarations.ledger.flatMap((d) => [`${d.domain}.${d.object}`, ...d.fields.map((f) => f.name)]),
|
||||
);
|
||||
|
||||
it('必交付物的台账地址都声明过', () => {
|
||||
for (const d of manifest.contract.requiredDeliverables) {
|
||||
expect(columns.has(d.ledger ?? ''), `${d.key} → ${d.ledger}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('editableAtRelease 的列都在成果表的声明里', () => {
|
||||
const draft = manifest.declarations.ledger.find((d) => d.object === 'draft');
|
||||
const names = new Set((draft?.fields ?? []).map((f) => f.name));
|
||||
for (const d of manifest.contract.requiredDeliverables) {
|
||||
for (const col of d.editableAtRelease ?? []) {
|
||||
expect(names.has(col), `editableAtRelease 里的 ${col}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const rowReadings = new Set([
|
||||
...manifest.declarations.ledger.map((d) => `${d.domain}_${d.object}_rows`),
|
||||
...manifest.declarations.ledger
|
||||
.filter((d) => d.fields.some((f) => f.required === true))
|
||||
.map((d) => `${d.domain}_${d.object}_missing`),
|
||||
...manifest.declarations.ledger.flatMap((d) =>
|
||||
d.fields.flatMap((f) =>
|
||||
(f.tally ?? []).map((v) => `${d.domain}_${d.object}_${f.name}_${v.toLowerCase().replace(/[^a-z0-9_]/g, '')}`),
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
it('L1/L2 谓词读的名字都有承载(台账列或基座读数)', () => {
|
||||
for (const c of manifest.contract.acceptanceCriteria) {
|
||||
if (c.level !== 'L1' && c.level !== 'L2') continue;
|
||||
for (const identifier of (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? []) {
|
||||
if (['and', 'or', 'not', 'true', 'false', 'null', 'in', 'between'].includes(identifier)) continue;
|
||||
if (rowReadings.has(identifier)) continue;
|
||||
expect(columns.has(identifier), `${c.key} 的谓词引用了 ${identifier}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **机检必须至少有一边是基座数**(违反史已记四次,每次都是同一个死法:
|
||||
* 那个数要模型自己重算,漏算/改小一次门就废了)。
|
||||
*/
|
||||
it('每条 L1/L2 都至少读一个基座读数 —— 不许两边都由活动自报', () => {
|
||||
for (const c of manifest.contract.acceptanceCriteria) {
|
||||
if (c.level !== 'L1' && c.level !== 'L2') continue;
|
||||
const names = (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? [];
|
||||
expect(names.some((n) => rowReadings.has(n)), `${c.key} 一个基座读数都没读`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('出口载荷的标量字段都有台账列承载', () => {
|
||||
const snake = (name: string) => name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
|
||||
for (const port of manifest.contract.output.mandatory) {
|
||||
const properties = (port.payload as { properties?: Record<string, { type?: string }> }).properties ?? {};
|
||||
for (const [field, spec] of Object.entries(properties)) {
|
||||
if (spec.type === 'array') continue;
|
||||
expect(columns.has(snake(field)), `${port.name}.${field} → ${snake(field)}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('技能引用的工具都在白名单声明内 —— 载方法不载权力', () => {
|
||||
const declared = new Set(manifest.declarations.tools.map((t) => t.name));
|
||||
for (const skill of manifest.assets.skills) {
|
||||
for (const fragment of skill.crystallized) {
|
||||
expect(declared.has(fragment.tool), `${skill.id} 用了 ${fragment.tool}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('L3 判据的整句出现在放行剧本里 —— judge 才对得上名', () => {
|
||||
const l3 = manifest.contract.acceptanceCriteria.find((c) => c.level === 'L3');
|
||||
const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
|
||||
expect(l3).toBeDefined();
|
||||
expect(release?.guide, 'T_release 的 guide 要整句抄 L3 判据名').toContain(l3?.label ?? '');
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* 两把尺逐行求值 —— 能算的不问 LLM(规范 §5.6)。
|
||||
* 出参就是台账 `disposition` 的取值:模型算完照抄,不必再翻译一道。
|
||||
*/
|
||||
describe('利润模型 P-01 —— 客户等级 × 类别 → 毛利档', () => {
|
||||
const table = P01_DMN.tables.find((t) => t.id === 'p01-margin');
|
||||
|
||||
it('战略客户 + A 类 → 8%~10%(量换价那一档)', () => {
|
||||
expect(decideBy(table, { customer_grade: '战略客户', type_tag: 'A' })).toMatchObject({ outputs: [0.08, 0.1] });
|
||||
});
|
||||
|
||||
it('普通客户 + A 类 → 12%~15%', () => {
|
||||
expect(decideBy(table, { customer_grade: '普通客户', type_tag: 'A' })).toMatchObject({ rule: 'p01-2' });
|
||||
});
|
||||
|
||||
it('B 类不看客户等级 → 15%~20%(战略客户的 B 类也走这一档)', () => {
|
||||
expect(decideBy(table, { customer_grade: '战略客户', type_tag: 'B' })).toMatchObject({ outputs: [0.15, 0.2] });
|
||||
});
|
||||
|
||||
it('C 类替代/停产件 → 20%~50% 一事一议', () => {
|
||||
expect(decideBy(table, { customer_grade: '普通客户', type_tag: 'C' })).toMatchObject({ rule: 'p01-4' });
|
||||
});
|
||||
|
||||
it('等级与类别都认不出 → 兜底按普通客户标准件档,不是拒绝求值', () => {
|
||||
expect(decideBy(table, {})).toMatchObject({ rule: 'p01-default', outputs: [0.12, 0.15] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('底价门禁 P-02 —— 偏离底价线两成即止', () => {
|
||||
const table = P01_DMN.tables.find((t) => t.id === 'p02-floor');
|
||||
|
||||
it('在底价线之上 → PASS 直接放行', () => {
|
||||
expect(decideBy(table, { deviation: 0.12 })).toMatchObject({ outputs: ['PASS', true] });
|
||||
expect(decideBy(table, { deviation: 0 })).toMatchObject({ outputs: ['PASS', true] });
|
||||
});
|
||||
|
||||
it('低于底价线两成以内 → NOTE 附理由放行', () => {
|
||||
expect(decideBy(table, { deviation: -0.08 })).toMatchObject({ outputs: ['NOTE', true] });
|
||||
});
|
||||
|
||||
it('低于两成以上 → BLOCK 拦截,人拍板', () => {
|
||||
expect(decideBy(table, { deviation: -0.25 })).toMatchObject({ outputs: ['BLOCK', false] });
|
||||
});
|
||||
|
||||
it('三个处置码与台账取值域逐字一致 —— 模型照抄即可写回', () => {
|
||||
const tally = manifest.declarations.ledger
|
||||
.find((d) => d.object === 'draft')
|
||||
?.fields.find((f) => f.name === 'disposition')?.tally;
|
||||
expect(new Set(table?.rules.map((r) => String(r.then[0])))).toEqual(new Set(tally));
|
||||
});
|
||||
});
|
||||
|
||||
describe('两把尺的档位三处同一份 —— 判定表 / 剧本 / 工作单', () => {
|
||||
const guide = manifest.assets.businessModel.steps?.find((s) => s.item === 'S1_price')?.guide ?? '';
|
||||
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'pricing-worksheet')?.html ?? '';
|
||||
|
||||
it('毛利四档三处都写着同样的数', () => {
|
||||
for (const band of ['8%~10%', '12%~15%', '15%~20%', '20%~50%']) {
|
||||
expect(guide, `剧本少了 ${band}`).toContain(band);
|
||||
expect(sheet, `工作单少了 ${band}`).toContain(band);
|
||||
}
|
||||
});
|
||||
|
||||
/* 门禁在人定完价之后才算得出来,所以三个码写死在底价自检那一步的 guide 里 */
|
||||
it('三个处置码剧本里写死(大写,一个字母不许差)', () => {
|
||||
const gate = manifest.assets.businessModel.steps?.find((s) => s.item === 'S3_gate')?.guide ?? '';
|
||||
for (const code of ['PASS', 'NOTE', 'BLOCK']) expect(gate).toContain(code);
|
||||
});
|
||||
});
|
||||
|
||||
describe('报告模版 —— 活动持有,容器无脚本执行权', () => {
|
||||
it('两个局面各一张:定价工作单(干活)与报价草案清单(收口)', () => {
|
||||
expect(manifest.assets.viewTemplates.map((t) => t.name)).toEqual(['pricing-worksheet', 'pricing-roster']);
|
||||
});
|
||||
|
||||
it('剧本每一步的 view 都在模版名单里 —— 换不到画面屏上就不动', () => {
|
||||
const names = new Set(manifest.assets.viewTemplates.map((t) => t.name));
|
||||
for (const step of manifest.assets.businessModel.steps ?? []) {
|
||||
expect(names.has(step.view ?? ''), `${step.item} 的 view=${step.view}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('放行那一步摆的是验收目标件 —— 最后摆上去的那张就是归档件', () => {
|
||||
const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
|
||||
expect(release?.view).toBe('pricing-roster');
|
||||
});
|
||||
|
||||
it('模版里没有脚本、没有事件处理器、没有外链 —— 样式自带', () => {
|
||||
for (const template of manifest.assets.viewTemplates) {
|
||||
expect(template.html, template.name).not.toMatch(/<script|\son[a-z]+=|https?:\/\//i);
|
||||
expect(template.html, template.name).toContain('<style>');
|
||||
}
|
||||
});
|
||||
|
||||
it('定价工作单是干活的件:逐颗档案 + 两把尺 + 填回工作表', () => {
|
||||
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'pricing-worksheet')?.html ?? '';
|
||||
expect(sheet).toContain('jc-selcard');
|
||||
expect(sheet).toContain('底价线');
|
||||
expect(sheet).toContain('利润模型 P-01');
|
||||
expect(sheet).toContain('底价门禁 P-02');
|
||||
expect(sheet).toContain('data-edit="quote_pricing.draft"');
|
||||
});
|
||||
|
||||
it('缺价红带与提交闸同源 —— 件上摆的是基座的 `_missing` 与主键名单', () => {
|
||||
for (const name of ['pricing-worksheet', 'pricing-roster']) {
|
||||
const html = manifest.assets.viewTemplates.find((t) => t.name === name)?.html ?? '';
|
||||
expect(html, name).toContain('quote_pricing_draft_missing');
|
||||
expect(html, name).toContain('quote_pricing_draft_missing_keys');
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* 编辑面精确等于 BD 的职权面(定价与让价理由)+ **行的钥匙 pn**:
|
||||
* 主键格不挂 `data-col`,服务端按「全表一致列」补键,两行会对到同一行上
|
||||
* (A2 浏览器走查逮过,e2e 走 API 自带主键测不出来)。
|
||||
*/
|
||||
it('编辑面 = 拟报单价 + 让价理由 + 行的钥匙 pn', () => {
|
||||
for (const name of ['pricing-worksheet', 'pricing-roster']) {
|
||||
const html = manifest.assets.viewTemplates.find((t) => t.name === name)?.html ?? '';
|
||||
const cols = [...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]);
|
||||
expect(new Set(cols), name).toEqual(new Set(['pn', 'quoted_price', 'price_note']));
|
||||
}
|
||||
});
|
||||
|
||||
it('门禁分布读的是基座按值现数的三段 —— 守恒等式的可视形', () => {
|
||||
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'pricing-roster')?.html ?? '';
|
||||
for (const code of ['pass', 'note', 'block']) {
|
||||
expect(roster).toContain(`quote_pricing_draft_disposition_${code}`);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* A4 只有一个出口(E8 无条件边),**没有去向要判** —— 件上不摆判定表,
|
||||
* 也不许残留 `case:route*` 的空壳(没有选择组它永远是空的,一屏灰行像判定没跑)。
|
||||
*/
|
||||
it('单出口活动:没有选择组,件上也不摆去向判定', () => {
|
||||
expect(manifest.contract.output.selective).toEqual([]);
|
||||
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'pricing-roster')?.html ?? '';
|
||||
expect(roster).not.toContain('case:routeKey');
|
||||
});
|
||||
|
||||
it('模版只做取值 —— data-class-of 不以 data-slot 为前提(纯色块曾整条静默失色)', () => {
|
||||
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'pricing-roster')?.html ?? '';
|
||||
expect(roster).toContain('<i data-class-of="disposition"></i>');
|
||||
});
|
||||
|
||||
it('宿主拒推含脚本的模版', async () => {
|
||||
const host = createMockHost({ contract: CONTRACT });
|
||||
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
|
||||
});
|
||||
});
|
||||
|
||||
const CONTRACT = {
|
||||
activityId: 'quote-pricing',
|
||||
version: '1.0.0',
|
||||
data: { rfqRef: 'RFQ MD14.1', rows: [] },
|
||||
message: undefined,
|
||||
outputPorts: { mandatory: ['draft'], selective: [] },
|
||||
prohibitions: [],
|
||||
discretion: [],
|
||||
} as const;
|
||||
|
||||
describe('代码入口 —— 案例活动没有 run,不是残包', () => {
|
||||
it('默认导出是一个有名字的活动定义', () => {
|
||||
expect(activity.name).toBe('quote-pricing');
|
||||
});
|
||||
|
||||
it('mock 宿主如实报「没有 run」,不静默返回一个假的 outcome', async () => {
|
||||
const host = createMockHost({ contract: CONTRACT });
|
||||
await expect(host.run(activity)).rejects.toThrow(/没有 run/);
|
||||
});
|
||||
});
|
||||
|
||||
/** 按 DMN 语义逐行求值:FIRST 命中即定,单元格谓词复用 @sfeel */
|
||||
function decideBy(
|
||||
table: (typeof P01_DMN)['tables'][number] | undefined,
|
||||
scope: Record<string, unknown>,
|
||||
): { outputs: readonly unknown[]; rule: string } | undefined {
|
||||
if (table === undefined) return undefined;
|
||||
for (const rule of table.rules) {
|
||||
const hit = table.inputs.every((input, i) => evaluateUnaryTest(rule.when[i] ?? '-', scope[input.expression], scope));
|
||||
if (hit) return { outputs: rule.then, rule: rule.id };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function items(xml: string): string[] {
|
||||
return [...xml.matchAll(/<(?:humanTask|decisionTask|processTask|caseTask) id="([^"]+)"/g)].map((m) => m[1] ?? '');
|
||||
}
|
||||
|
||||
function parseCmmnLite(xml: string): { manualActivation: string[]; repetition: string[] } {
|
||||
const manualActivation: string[] = [];
|
||||
const repetition: string[] = [];
|
||||
for (const m of xml.matchAll(/<humanTask id="([^"]+)"[\s\S]*?<\/humanTask>/g)) {
|
||||
if (m[0].includes('manualActivationRule')) manualActivation.push(m[1] ?? '');
|
||||
if (m[0].includes('repetitionRule')) repetition.push(m[1] ?? '');
|
||||
}
|
||||
return { manualActivation, repetition };
|
||||
}
|
||||
|
||||
function sentryIf(xml: string, sentryId: string): string {
|
||||
const block = xml.slice(xml.indexOf(`<sentry id="${sentryId}"`));
|
||||
const raw = /<condition>([\s\S]*?)<\/condition>/.exec(block.slice(0, block.indexOf('</sentry>')))?.[1] ?? '';
|
||||
return raw.replaceAll('>', '>').replaceAll('<', '<').replaceAll('&', '&');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,81 +1,387 @@
|
||||
/**
|
||||
* A4 成本定价的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
|
||||
*
|
||||
* 2026-07-29 由步序声明(flow)改写而来。原六步各归其位:取价基准与逐条定价属「算价」,
|
||||
* 偏离率与底价门禁属「自检」(两支 DMN:P-01 利润模型、P-02 底价门禁),
|
||||
* 超限条目调人决策是**触达客户利益的一下**,必须人工激活;末尾一张验收单。
|
||||
* 业务出处:`docs/MVP示例工作流.html` §1.2(S1 取价基准 → S2 逐条定价 → S3 底价自检),
|
||||
* 按 A3 同一条形状建模(2026-08-03 重写,@3.0.0):
|
||||
*
|
||||
* 上游(A3 的目标报价清单 / A2 的询价受理清单 / A1 直达的受理记录)进来 → 归一成
|
||||
* **报价草案**底表,逐条按 P-01 利润模型算出底价线与拟报单价、过 P-02 底价门禁 →
|
||||
* **一张执行单**交 BD 核价(拟报单价可校可改,无基准的那几行由他定价)→
|
||||
* **只有压在拦截档上的那几条才叫人拍板**(决策卡)→ 放行,整份报价草案交定价审批。
|
||||
*
|
||||
* 上一版(@2.0.0)三处不合现行口径,这一版整个置换:
|
||||
* ① 入参必填 `rfqRef + customerCode + records` —— 而 A3 发的是 `{rfqRef, rows}`,
|
||||
* 门口拦的恰是主路(现在宽进到底,归一在活动内做);
|
||||
* ② 台账一个 `required` 都没有 —— 执行卡的闸没牙,人不填也能交卷;
|
||||
* ③ 入场哨兵 `s_priced` 读活动自报的 `draft_total`(「机检只读基座数」第 5 类违反)——
|
||||
* 模型不记这个账,门就永不开,而它一步也没做错。
|
||||
*/
|
||||
|
||||
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
|
||||
<case id="pricing">
|
||||
<!--
|
||||
案卷项的 id **就是任务视图里的键名**。上游载荷(rows / records)与本活动台账列
|
||||
都摊平到任务视图顶层,照它们命名;quote_pricing_* 那几个是**基座读数**
|
||||
(行数 / 缺必填 / 按值现数),由基座现算,模型碰不着。
|
||||
-->
|
||||
<caseFileModel>
|
||||
<caseFileItem id="records" name="待定价条目"/>
|
||||
<caseFileItem id="basis" name="取价基准"/>
|
||||
<caseFileItem id="draft" name="报价草案"/>
|
||||
<caseFileItem id="floors" name="底价表"/>
|
||||
<caseFileItem id="rows" name="上游清单行(寻源/选型转来,流内开案即有)"/>
|
||||
<caseFileItem id="records" name="上游受理记录(受理直达时有)"/>
|
||||
<caseFileItem id="line_total" name="草案条目数(自报)"/>
|
||||
<caseFileItem id="priced_total" name="已算出拟报价的条数"/>
|
||||
<caseFileItem id="confirmed_total" name="BD 已核价条数(人环走完的凭据)"/>
|
||||
<caseFileItem id="route_decided_total" name="已定盘"/>
|
||||
<caseFileItem id="quote_pricing_draft_rows" name="草案行数(基座现数)"/>
|
||||
<caseFileItem id="quote_pricing_draft_missing" name="缺拟报单价的行数(基座现数)"/>
|
||||
<caseFileItem id="quote_pricing_draft_disposition_block" name="压在拦截档的条数(基座现数)"/>
|
||||
</caseFileModel>
|
||||
<casePlanModel id="plan" name="成本定价" autoComplete="true">
|
||||
<sentry id="s_records">
|
||||
<onPart id="op_rc" caseFileItemRef="records"><standardEvent>create</standardEvent></onPart>
|
||||
</sentry>
|
||||
<sentry id="s_basis">
|
||||
<onPart id="op_bs" caseFileItemRef="basis"><standardEvent>create</standardEvent></onPart>
|
||||
</sentry>
|
||||
<!--
|
||||
① 算完了 —— 交 BD 核价那一步的入场前提。
|
||||
行数两边对上(自报 = 基座现数),且算价那一轮走完(priced_total 是算完之后
|
||||
显式写下的数;「大于等于 0」对「写过的 0」为真、对「没写过」为假)。
|
||||
-->
|
||||
<sentry id="s_priced">
|
||||
<onPart id="op_pd" caseFileItemRef="draft"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_pd"><condition>unpriced = 0</condition></ifPart>
|
||||
<onPart id="op_pr" caseFileItemRef="priced_total"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_pr"><condition>line_total > 0 and quote_pricing_draft_rows = line_total and priced_total >= 0</condition></ifPart>
|
||||
</sentry>
|
||||
<sentry id="s_breach">
|
||||
<onPart id="op_br" caseFileItemRef="draft"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_br"><condition>breaches > 0</condition></ifPart>
|
||||
<!--
|
||||
② 人定完价了 —— 底价自检那一步的入场前提。
|
||||
confirmed_total 只在人交回执行单之后才产生,它就是「经过人了」的凭据。
|
||||
-->
|
||||
<sentry id="s_confirmed">
|
||||
<onPart id="op_cf" caseFileItemRef="confirmed_total"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_cf"><condition>confirmed_total > 0</condition></ifPart>
|
||||
</sentry>
|
||||
<!--
|
||||
③ **超限才叫人** —— 这个活动的要害。
|
||||
「压在拦截档上的条数」由基座按 disposition 的取值域现数(不是活动自报),
|
||||
且要等 BD 核过价(confirmed_total 只在人交回执行单之后才产生)——
|
||||
他自己回调掉的那几条不必再惊动第二个人。
|
||||
-->
|
||||
<sentry id="s_over">
|
||||
<onPart id="op_ov" caseFileItemRef="quote_pricing_draft_disposition_block"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_ov"><condition>quote_pricing_draft_disposition_block > 0 and confirmed_total > 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 > 0 and quote_pricing_draft_rows > 0 and quote_pricing_draft_missing = 0 and quote_pricing_draft_disposition_block = 0 and quote_pricing_draft_disposition_pass + quote_pricing_draft_disposition_note + quote_pricing_draft_disposition_block = quote_pricing_draft_rows</condition></ifPart>
|
||||
</sentry>
|
||||
|
||||
<stage id="S1" name="算价" autoComplete="true">
|
||||
<entryCriterion id="ec_s1" sentryRef="s_records"/>
|
||||
<humanTask id="T_basis" name="人工取价基准">
|
||||
<itemControl><manualActivationRule id="m_bs"/></itemControl>
|
||||
<!-- ① 归一草案 + 取基准 + 逐条算价过门禁(能算的不问人) -->
|
||||
<humanTask id="S1_price" name="归一报价草案:取成本基准、按利润模型算底价线与建议价">
|
||||
<itemControl><repetitionRule id="r_s1"/></itemControl>
|
||||
</humanTask>
|
||||
<decisionTask id="D_price" name="逐条定价" decisionRef="p01-margin">
|
||||
<entryCriterion id="ec_price" sentryRef="s_basis"/>
|
||||
</decisionTask>
|
||||
</stage>
|
||||
<milestone id="M_priced" name="全条已定价"/>
|
||||
|
||||
<stage id="S2" name="自检" autoComplete="true">
|
||||
<!--
|
||||
② 交 BD 核价 —— **一张执行单**(拟报价可校可改,无基准的行由他定价)。
|
||||
manualActivation + 不给 doneWhen(发卡触达真人,引擎不替人按)。
|
||||
-->
|
||||
<humanTask id="S2_review" name="交BD定价:工作单上逐条定价,收回后过底价门禁">
|
||||
<entryCriterion id="ec_s2" sentryRef="s_priced"/>
|
||||
<decisionTask id="D_floor" name="底价门禁" decisionRef="p02-floor"/>
|
||||
<humanTask id="T_breach" name="人工裁超限条目(回调还是注明理由)">
|
||||
<entryCriterion id="ec_breach" sentryRef="s_breach"/>
|
||||
<itemControl><manualActivationRule id="m_br"/><repetitionRule id="r_br"/></itemControl>
|
||||
<itemControl><manualActivationRule id="m_s2"/></itemControl>
|
||||
</humanTask>
|
||||
<planningTable id="pt_s2">
|
||||
<humanTask id="T_refresh_floor" name="人工刷新底价"/>
|
||||
</planningTable>
|
||||
</stage>
|
||||
<milestone id="M_gated" name="门禁已过"/>
|
||||
|
||||
<humanTask id="T_release" name="人工报价草案验收放行">
|
||||
<!--
|
||||
③ 底价自检 —— SOP 里的 S3,一件独立的事:按人定的价算毛利、偏离度、门禁处置。
|
||||
它不触达世界,所以给 doneWhen(三档处置的条数加起来等于行数就算判完了),
|
||||
引擎自己收 —— 从前这一段藏在「交人核价」的收尾里,模型写完 confirmed_total
|
||||
就把那一项收了,三列处置一格没写,放行门当场判「处置没落定」,而拒回只会说
|
||||
「数不对/行不对」—— 两扇打不开的门(实测 8 次拒回熔断)。
|
||||
-->
|
||||
<humanTask id="S3_gate" name="底价自检:按定下的价算毛利与偏离度,逐条过底价门禁">
|
||||
<entryCriterion id="ec_s3" sentryRef="s_confirmed"/>
|
||||
<itemControl><repetitionRule id="r_s3"/></itemControl>
|
||||
</humanTask>
|
||||
|
||||
<!-- ④ 超限拍板:触达客户利益的那一下,必须人工激活;只在拦截档还有条目时入场 -->
|
||||
<humanTask id="T_approve" name="超限拍板:压在拦截档的条目要人定怎么办">
|
||||
<entryCriterion id="ec_ap" sentryRef="s_over"/>
|
||||
<itemControl><manualActivationRule id="m_ap"/><repetitionRule id="r_ap"/></itemControl>
|
||||
</humanTask>
|
||||
|
||||
<!-- 草案放行:终局唯放行 —— 触达世界,必须人工激活 -->
|
||||
<humanTask id="T_release" name="报价草案验收放行">
|
||||
<entryCriterion id="ec_rel" sentryRef="s_ready"/>
|
||||
<itemControl><manualActivationRule id="m_rl"/></itemControl>
|
||||
</humanTask>
|
||||
|
||||
<timerEventListener id="TL_half" name="时限过半">
|
||||
<timerExpression>PT8H</timerExpression>
|
||||
</timerEventListener>
|
||||
</casePlanModel>
|
||||
</case>
|
||||
</definitions>`;
|
||||
|
||||
export const NARRATIVE = `§0 开案:拿到一批待定价条目即开案。成品是**一份全条目带偏离度的报价草案** ——
|
||||
每条有拟报单价与毛利率,且都过了底价门禁。
|
||||
/**
|
||||
* 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本,两处不重复写同一件事)。
|
||||
*/
|
||||
export const NARRATIVE = `§0 开案:**一批料要定出各自的拟报单价**。来路三种,形状都收(宽进):
|
||||
· **寻源转来**:任务数据里有 \`rows\` 数组 —— 目标报价清单的落地行,每行带底价(base_price)
|
||||
与价源;这是最全的一种来路;
|
||||
· **受理/选型直达**:有 \`records\` 或 \`rows\` 而行上没有底价 —— 只有料号与年用量;
|
||||
· **单飞**:只有一段需求文字,列了几颗料要报价。
|
||||
|
||||
S1 算价:先取价基准(历史成交价 / 供应商回价 / 替代料对标价,按可得性择优),
|
||||
再按利润模型 P-01 逐条定价 —— 客户等级、数量档、料号类型、行情共同决定加价率。
|
||||
基准取不到的条目不许拍脑袋定价,回头找寻源或标注「无基准」。
|
||||
**成品只有一样**:一份**报价草案** —— 整份行都在、**每一行都有拟报单价**,
|
||||
而且每个价都说得出来路:基准从哪来、毛利档是哪一档、偏离底价线多少、门禁怎么判。
|
||||
人签的不是一个数字,是**这个数字的来路**。它是定价审批的直接输入。
|
||||
|
||||
S2 自检:算出偏离度(基准价 ↔ 拟报价),按底价门禁 P-02 逐条判。
|
||||
**偏离 ±20% 以内自动放过;超限的一律调人** —— 要么回调价格,要么写明为什么这样报。
|
||||
底价超一周没更新的先刷底价再判(裁量项),拿旧底价判新行情等于没判。
|
||||
**能算的不问人,该人签的不代签**:P-01(利润模型)给毛利档、P-02(底价门禁)给处置,
|
||||
两支判定表说了算,你不临场拍价;而**拟报单价那一列是 BD 定的,你一格都不写** ——
|
||||
你给的是**建议价**,他确认或改成自己的数。你的活是把判据取齐、把结论落进台账。
|
||||
|
||||
§Z 结案:每条都有拟报单价与毛利率、超限条目已处置、BD 签字放行,自动结案。
|
||||
草案交出去就是下游议价的起点 —— 它不是最终报价,但每一条都要经得起问「为什么是这个价」。`;
|
||||
**四步走**:归一取基准算建议价 → 交 BD 定价 → 底价自检(按他定的价算毛利、偏离度、
|
||||
门禁处置)→ 定盘放行。
|
||||
|
||||
**这一单要经过人一到两次**:BD 在定价工作单上逐条定价(那张单绑着整份草案,
|
||||
拟报单价缺一行按不下提交 —— 基座的闸看着,他填的格子直接落账);
|
||||
**只有还压在拦截档上的条目才另外叫人拍板**(一张决策卡)。每条都叫人,人就成了橡皮图章;
|
||||
一条都不叫,底价门禁就是摆设。最后 BD 放行签字。
|
||||
|
||||
【一路上要说话】负责人正看着这一单。**每一拍都带一句 say** —— 说业务的话:
|
||||
这批料算下来什么价、哪几条压线、要不要找人拍板;不说系统词。
|
||||
到了要找人的那一拍,先说清这一轮办完了什么、为什么现在需要他。
|
||||
|
||||
【怎么把一项干完 · 一拍连做】write 和 tool 都不会自动收步 —— 做完用 finish 宣告。
|
||||
动作排进同一拍的 actions 串一口气给;只有调工具(出参下一拍才看得见)与
|
||||
发卡 / 提交(挂起)必须停拍。**这一单常路 7-9 拍就该收口**。
|
||||
|
||||
§Z 结案:BD 在验收单上签字,系统把草稿合并成正式草案并发出 draft 口的载荷 ——
|
||||
整份报价草案交给定价审批。`;
|
||||
|
||||
/**
|
||||
* 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。
|
||||
*
|
||||
* 三处不给 `doneWhen`:`S2_review`(发卡触达真人)、`T_approve`(要人拍板)、
|
||||
* `T_release`(签下去触达世界)—— 给了等于给引擎一把替人签字的钥匙。
|
||||
*/
|
||||
export const PRICING_STEPS = [
|
||||
{
|
||||
item: 'S1_price',
|
||||
intent: '整份归一成报价草案:取成本基准 → 按利润模型算出底价线与建议价,逐条落表',
|
||||
writes: ['quote_pricing.draft', 'quote_pricing.verdict'],
|
||||
/* 上游载荷数组不声明就会被整表段的截断刀砍掉(砍掉的恰是要归一的行) */
|
||||
reads: ['rows', 'records'],
|
||||
view: 'pricing-worksheet',
|
||||
tools: ['crm.history-price-read'],
|
||||
/*
|
||||
* 归一 + 算价两轮都走完才算办完:行数两边对上(一边是基座现数),
|
||||
* 且 priced_total 写过(全是无基准的单子这个数就是 0;S-FEEL 未绑定的名字
|
||||
* 参与比较为假,所以「没写过」进不来)。
|
||||
*/
|
||||
doneWhen: 'line_total > 0 and quote_pricing_draft_rows = line_total and priced_total >= 0',
|
||||
guide: `第一件事:把这一单的**报价草案**落出来,并把能算的价都算出来。
|
||||
|
||||
【料从哪儿来】
|
||||
· 寻源转来(\`rows\` 里的行带 base_price / price_basis):照抄 —— 底价就是成本基准,
|
||||
一个数字都别改;带 quote_line_total 这类总数的是结论行,不是料,别落进草案。
|
||||
· 受理/选型直达(\`records\` 或没有底价的 \`rows\`):料号、品类、制造商、年用量、
|
||||
客户目标价、类别(type_tag)照抄,**底价一栏留空**。
|
||||
· 单飞:照正文归一,列了几颗落几行;没说类别的按 B。
|
||||
**按 pn 归并,一颗料一行**;pn 一个字符不许改(它是主键,改一个字就是新开一行)。
|
||||
|
||||
【第一拍:只做两件事 —— 落草案行 + 串尾发一次查价】
|
||||
① 写 \`quote_pricing.draft\`,一颗料一行(rows 收数组):
|
||||
rfq_ref(入参有就原样抄;没有从主题认;认不出才自己造一个,**造出来的必须以 \`PRC-\` 开头**
|
||||
(\`PRC-<主题或第一颗料的型号>\`)。两条硬规矩:
|
||||
**① 整单只有一个单号,不是一颗料一个** —— 它是这一单的案号,草案所有行、verdict 那一行
|
||||
用的都得是同一个串(实测栽过:两颗料造出两个号,一单裂成两单);
|
||||
**② 只从本单来件取材** —— 台账里躺着的 RFQ-xxxx 是别人的单,借它的号等于把两单的行
|
||||
混进同一个案卷(实测栽过一轮))、pn、customer_pn、category、
|
||||
brand、eau、target_price(客户目标价)、lead_time、remark(客户约束/急料,一个字别丢)、
|
||||
type_tag(照上游现标;单飞按 B)、base_price(上游给了就抄,没有留空)。
|
||||
suggested_price / margin_band / floor_price **这一拍全部留空** —— 基准还没取齐。
|
||||
**quoted_price / margin / deviation / disposition 这四列从头到尾都不是你写的**(见下)。
|
||||
② 串尾调 \`crm.history-price-read\`:pns 收数组,把**底价还空着的那几颗料**一次传完
|
||||
(上游已经给了底价的不必查)。
|
||||
**这一拍不写 \`quote_pricing.verdict\`,一列都不写** —— 尤其 priced_total:
|
||||
它是「算价走完了」的凭据,提前写等于把闸提前打开,引擎会在下一拍开头当场收掉这一步,
|
||||
**算价从此不会发生**(A3 实测栽过:嘴上说预填了,台账里没有)。
|
||||
|
||||
【**这一列你一格都不写**:quoted_price】
|
||||
拟报单价是 **BD 在工作单上定的那个数** —— 人签的就是它。你给的是**建议价**
|
||||
(suggested_price),他确认或改成自己的数;margin / deviation / disposition 三列
|
||||
要等他定完价才算得出来,这一步也别写。
|
||||
这不是客气话:从前只有一列价、剧本写着「算不出的留空」,实测跑单时两颗库里查不到的料
|
||||
照样被编出了价(一颗抄客户目标价、一颗凭空写了个数),而交代里还说着「价空着等你定」。
|
||||
**一条要你分辨「这行算得出/算不出」的规矩会走岔;「这一列不写」没有分支可走岔。**
|
||||
|
||||
【第二拍:算建议价 + 记账 + 收步,一拍连做】
|
||||
① 补基准:**只补上一拍 base_price 空着的那几行** —— 工具出参里 hit=true 的写
|
||||
base_price = lastPrice、price_basis 写 **HIST**;hit=false 的**别编价**,base_price 留空,
|
||||
它就是「无基准」那一档。
|
||||
**上游已经给了 base_price 的行:工具回什么都不采用,那一格一个数字都别改** ——
|
||||
上游给的是本单问回来的货源底价,工具给的是库内历史价;这一单的成本是前者
|
||||
(实测栽过:两行上游底价被历史价盖掉,毛利从 20% 变成 115%,数字全对而结论是假的)。
|
||||
② 逐条算**建议价**,规矩写死,照做即可(**这些是算术,不是判断**):
|
||||
· 毛利档 margin_band 按利润模型 P-01 取(客户等级 × 类别):
|
||||
战略客户 + A 类(大批量长单)→ 8%~10%;普通客户 + A 类 → 12%~15%;
|
||||
B 类 → 15%~20%;C 类 → 20%~50%;拿不准按 12%~15%。写成「12%~15%」这样的文本。
|
||||
· **底价线** floor_price = base_price ×(1 + 档下限)。保留四位小数。
|
||||
· **建议价** suggested_price:
|
||||
- 客户没给目标价(target_price 空)→ 取 base_price ×(1 + 档上限);
|
||||
- 客户目标价 ≥ 底价线 → 就取客户目标价(接得住,毛利还在档内);
|
||||
- 客户目标价 < 底价线 → 仍取客户目标价(贴着客户报),这一条到门禁那儿会亮灯;
|
||||
- **base_price 空(无基准)→ margin_band / floor_price / suggested_price 三列一起留空**。
|
||||
不要拿一个不相干的价去凑一个数出来 —— 那比没有建议更危险,它看起来像有依据。
|
||||
· **「没有」要给空串 \`""\`,不许写 0** —— 0 是一个数,底价线写 0 看起来像「成本为零」,
|
||||
偏离度写 0 看起来像「正好压在底价线上」。空串基座会当成「这一列不写」。
|
||||
③ 这时才写 \`quote_pricing.verdict\`(一次写全):rfq_ref、customer_code / customer_name
|
||||
(顶层有就抄)、customer_grade(来件认得出就写,认不出写「普通客户」)、
|
||||
**line_total** = 草案行数、**basis_total** = 有成本基准的条数、
|
||||
**priced_total** = 这一拍算出建议价的条数(0 也要写)。
|
||||
④ finish 这一项。`,
|
||||
},
|
||||
{
|
||||
item: 'S2_review',
|
||||
intent: '一张执行单交 BD:工作单上逐条定价(拟报单价归他);收回后过底价门禁并定盘',
|
||||
writes: ['quote_pricing.draft', 'quote_pricing.verdict'],
|
||||
reads: ['quote_pricing.draft'],
|
||||
view: 'pricing-worksheet',
|
||||
tools: [],
|
||||
/* 不给 doneWhen —— 发卡触达真人(manualActivation),引擎不替人按 */
|
||||
guide: `建议价算完了,**该 BD 出场了** —— 这是本活动唯一的一张执行单,
|
||||
**拟报单价是他定的,不是你定的**。工作区摆的是**定价工作单**:逐颗料一张定价档案卡
|
||||
(成本基准、毛利档、客户目标价、底价线、系统建议价),利润模型与底价门禁两把尺挂在上面,
|
||||
填回区长在同一屏 —— 他在「拟报单价」那一列逐行填(照建议价确认,或改成自己的数)。
|
||||
|
||||
这一步标了人工激活:发卡就算启动,不必单发 start;引擎也不会替你收 —— 人交回后要你自己 finish。
|
||||
|
||||
【照这个骨架发,别少字段】(卡面是纯文本,steps/standard 里**别写星号记号**):
|
||||
{"kind":"human","itemId":"S2_review","workOrder":"execution","card":{
|
||||
"title":"报价定价:<单号> 共 <N> 条待你定价",
|
||||
"ask":"系统已按成本基准与利润模型算出每一颗的建议价,请在工作单的「拟报单价」一列逐行定价:认可建议价就照抄,不认可就改成你的数;库内没有成本基准的那几行没有建议价,由你定。",
|
||||
"steps":["拟报单价每一行都要有 —— 缺一行这张单按不下提交;它是这一单最后签下去的那个数,系统不替你填",
|
||||
"建议价是从成本基准按利润模型的毛利档推出来的:照抄就是认可这个毛利,改高改低都可以,改了在备注里写一句缘由",
|
||||
"低于底价线的价会被底价门禁标出来:两成以内附理由即可放行,超过两成的要另外找人拍板 —— 你在这里往上调一点,就不必再惊动一个人",
|
||||
"库内查不到成本的料没有建议价:按你的经验定价,并在备注写明依据;实在定不了的按行首 × 把这一条撤出本轮"],
|
||||
"standard":"每一行都有拟报单价,而且每个价都说得出来路(基准 / 毛利档 / 让价理由)—— 缺一行这张单按不下提交(系统的闸看着)。",
|
||||
"basis":[{"k":"<PN>","v":"<类别 · 成本基准 · 毛利档 · 建议价>"}]}}
|
||||
· \`basis\` 逐条列本单的料:PN + 基准与建议价。几颗写几条,别铺全表。
|
||||
· **卡上不摆表、不摆格子**(执行卡零输入):整份草案就在工作单那份件上,
|
||||
拟报单价与备注两列都能就地改、行能撤 —— **别往卡里塞 \`blocks\` 或 \`fields\`**。
|
||||
· **这张卡带提交闸**:拟报单价(必填列)还空着,他按不下提交 —— 缺几格、缺在哪行,
|
||||
闸上与件上的红带都写着(与放行门 L1 同一个数同一套算法)。
|
||||
发完卡这一拍就结束了(发卡挂起)。
|
||||
|
||||
【人交回之后 —— 他填的价基座已经替你写回台账了】这一步你只做两件事,然后 finish:
|
||||
① 写 verdict:**confirmed_total** = 他定过价的条数(照草案行数记);
|
||||
他在件上撤掉了行的话,**line_total 跟着改成撤后的实数**。
|
||||
② finish 这一项。
|
||||
**别在这一拍算门禁** —— 底价自检是下一步的活(它有自己的一段做法),这里算了也白算。
|
||||
草案行你一列都不用重写:他填的值基座已经落进台账了,重写会被「写了等于没写」拒回。
|
||||
|
||||
【这张卡一单只发一次】人交回来之后不许再发同一张卡 —— 必填有提交闸兜着,
|
||||
卡回来时拟报单价一定是齐的(或那几行已被他撤掉)。`,
|
||||
},
|
||||
{
|
||||
item: 'S3_gate',
|
||||
intent: '底价自检:按人定下的价逐条算毛利、偏离度与门禁处置,判完即定盘',
|
||||
writes: ['quote_pricing.draft', 'quote_pricing.verdict'],
|
||||
reads: ['quote_pricing.draft'],
|
||||
view: 'pricing-worksheet',
|
||||
tools: [],
|
||||
/*
|
||||
* **收步判据与验收判据同源**:三档处置的条数加起来等于行数(两边都是基座现数)——
|
||||
* 判完了才算办完。`_rows > 0` 那一半不能省:开案时行数是 0,而 0+0+0 = 0
|
||||
* 会让这一步开局即收(未绑定名字的坑之外,还有「全零相等」这一坑)。
|
||||
*/
|
||||
doneWhen:
|
||||
'quote_pricing_draft_rows > 0 and quote_pricing_draft_disposition_pass + quote_pricing_draft_disposition_note + quote_pricing_draft_disposition_block = quote_pricing_draft_rows',
|
||||
guide: `人把价定完了,**现在才轮到算门禁** —— 这一步是纯算术,一拍连做:
|
||||
|
||||
① **逐行按他定的 quoted_price 算三列**(**quoted_price 你一格都不写,只写这三列**):
|
||||
· margin =(quoted_price − base_price)/ base_price,四位小数;
|
||||
· **偏离度** deviation =(quoted_price − floor_price)/ floor_price,四位小数;
|
||||
· **门禁处置** disposition 按底价门禁 P-02 判,只许写这三个大写码之一,一个字母不许差:
|
||||
- deviation ≥ 0 → **PASS**(在底价线之上,直接放行)
|
||||
- −0.20 ≤ deviation < 0 → **NOTE**(低于底价线两成以内,附理由放行)
|
||||
- deviation < −0.20 → **BLOCK**(低于两成以上,压在拦截档,要人拍板)
|
||||
· **base_price 空(无基准)的行**:margin / deviation 给空串 \`""\`(**不许写 0** ——
|
||||
0 看起来像「正好压在底价线上」,而这一行压根没有底价线)、disposition 记 **NOTE**,
|
||||
price_note 里保留他写的定价依据(他没写就补一句「库内无成本基准,由 BD 定价」)。
|
||||
**一行都不能漏**:三档处置的条数加起来必须等于 quote_pricing_draft_rows(基座数的行数)。
|
||||
现在三档都是 0,就是一行都还没判 —— 这一步没办完,放行门开不了。
|
||||
② 看这一拍写完之后的**拦截档条数**(disposition = BLOCK 的行):
|
||||
· 一条都没有 → 同一拍把 verdict 的 **route_decided_total 记 1**(定盘)、
|
||||
summary 写一句人话(几条直接放行、几条附理由),然后 finish 这一项;
|
||||
· 还有 → **别写 route_decided_total**,只 finish 这一项 —— 下一拍会有一步专门叫人拍板。`,
|
||||
},
|
||||
{
|
||||
item: 'T_approve',
|
||||
intent: '压在拦截档的条目要人拍板 —— 发一张决策卡,照他选的那条路把草案改掉',
|
||||
writes: ['quote_pricing.draft', 'quote_pricing.verdict'],
|
||||
reads: ['quote_pricing.draft'],
|
||||
view: 'pricing-roster',
|
||||
tools: [],
|
||||
/* 人工激活 + 要人拍板 —— 不给 doneWhen,引擎不许替人收 */
|
||||
guide: `**这一单只发一张决策卡。动手之前先分清你在哪一半:**
|
||||
|
||||
· 轨迹里**已经有**「decision 卡回传」/「人选了:xxx」—— 那就跳到下面【人点完之后】那一段,
|
||||
照他选的那条路改草案、写 route_decided_total、finish。**一个 decide 都不许再发**
|
||||
(实测栽过:人点完了,而这一步又从头念了一遍「发一张决策卡」,同一张卡连发四次,
|
||||
调人预算烧光,单子 exhausted —— 他点过的那一下就白点了);
|
||||
· 轨迹里**还没有** —— 才轮到下面【发一张决策卡】那一段。
|
||||
|
||||
这一步只在还有条目压在拦截档(基座现数 quote_pricing_draft_disposition_block > 0)时入场;
|
||||
它触达客户利益,标了人工激活:发卡就算启动,不必单发 start。
|
||||
工作区这时摆的是**报价草案清单**,整份草案与门禁分布都在上面,他看得见全局再拍板。
|
||||
|
||||
【发一张决策卡,照这个骨架】(选项少于两个基座拒发):
|
||||
{"kind":"decide","itemId":"T_approve","ask":"<N> 条低于底价线两成以上,按哪条路办?",
|
||||
"options":[
|
||||
{"value":"floor","label":"回调到底价线报","consequence":"守住毛利下限,客户可能不接受这个价","recommended":true},
|
||||
{"value":"as-is","label":"照现价破线报,附理由","consequence":"接住客户目标价拿单,毛利跌破下限,理由随草案交审批"},
|
||||
{"value":"drop","label":"这几条本轮不报","consequence":"撤出本单草案,客户拿到的是一份缺项的报价"}],
|
||||
"basis":[{"k":"<PN>","v":"成本基准 <X> · 底价线 <Y> · 拟报 <Z> · 偏离 <W>"}]}
|
||||
· basis 逐条摆压在拦截档的那几颗料:基准、底价线、拟报价、偏离度 —— 裸卡基座拒发。
|
||||
· 发完这一拍就结束了(发卡挂起)。
|
||||
|
||||
【人点完之后】轨迹上会出现「decision 卡回传 —— 人选了:xxx」。**照那条路把草案改掉,
|
||||
别再发第二张卡**,一拍连做:
|
||||
· 选 floor:**这是全活动唯一一次你可以写 quoted_price** —— 那个数不是你定的,
|
||||
是他在卡上点下的(就是该行的底价线),你只是替他落账:
|
||||
那几行 quoted_price 改成本行的 floor_price,margin 记该行毛利档的下限、deviation 记 0、
|
||||
disposition 改 **PASS**,price_note 写「按底价线回调,由负责人拍板」;
|
||||
· 选 as-is:价不动,disposition 改 **NOTE**,price_note 写明破线的理由与拍板人;
|
||||
· 选 drop:用 \`drop\` 把那几行撤出本单草稿(主键照 \`quote_pricing_draft_keys\` 名单**原样粘**),
|
||||
并把 verdict 的 line_total 改成撤后的实数。
|
||||
然后写 verdict:**route_decided_total 记 1**、summary 补一句拍板结果,finish 这一项。
|
||||
拦截档清零之后,放行门才开得了。`,
|
||||
},
|
||||
{
|
||||
item: 'T_release',
|
||||
intent: '草案已定盘,提交放行交 BD 验收',
|
||||
writes: [],
|
||||
reads: [],
|
||||
view: 'pricing-roster',
|
||||
tools: [],
|
||||
/* 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 doneWhen) */
|
||||
guide: `每行有价、门禁处置守恒、拦截档清零、定盘已记 —— 可以走放行了
|
||||
(T_release 人工激活:动它就算启动,不必单发 start)。先把裁量判据裁掉,然后 submit,同一拍连做:
|
||||
|
||||
· judge criterion="报价的来路说得清:基准价来源、毛利档与让价理由逐条可溯,BD 对报价负责" ——
|
||||
看草案里每一行的基准、毛利档与备注:说得出来路就 satisfied=true;
|
||||
有行说不出的(比如无基准由人定的价没写缘由),satisfied=**false**,rationale 逐行写清,
|
||||
交签字的人定。**裁成不满足照样能提交** —— 理由连着卡一起交给他。
|
||||
criterion 照判据名**整句原样抄**,写别的名字对不上。
|
||||
|
||||
终局唯放行:你不能自己宣布完成。`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段;
|
||||
* 放行那一格由基座统一加,人看到的是 x/4。
|
||||
*/
|
||||
export const PRICING_PHASES = [
|
||||
{ name: '取基准算建议价', items: ['S1_price'] },
|
||||
{ name: '交BD定价', items: ['S2_review'] },
|
||||
{ name: '底价自检', items: ['S3_gate'] },
|
||||
{ name: '超限拍板', items: ['T_approve'] },
|
||||
];
|
||||
|
||||
@@ -1,80 +1,17 @@
|
||||
/**
|
||||
* 统一报价 A4 · 成本定价 —— 活动包代码入口(ADR-004)。
|
||||
* 统一报价 A4 · 成本定价 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
|
||||
*
|
||||
* flow 活动:下一步由步序声明给定。本文件只做声明装不下的两件事 ——
|
||||
* 视图流控,与「结论不可静默覆写」的呈现:P-02 拦下的条目必须显式呈现给人。
|
||||
* **这个活动没有 `run`,不是缺件。**
|
||||
*
|
||||
* 案例活动的推进由 Loop 驱动,行为全部写在清单里:案例模型 + 说明书 + 步骤剧本 +
|
||||
* 两把判定表 + 两条技能 + 台账声明 + 两张 HTML 报告模版。
|
||||
* 上一版这里有一段 `run()`(读草案、拼 kv/table 块、按拦下条数返回 await/submit)——
|
||||
* 案例活动根本不会调它;摆一个空转的 run 去假装有实现,更坏:
|
||||
* 它写的那套呈现与 `viewTemplates` 里的模版各说各话,而没人看得出以哪份为准。
|
||||
*
|
||||
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 —— 它是**被静态审扫描的产物文本**
|
||||
* (查 import 与全局引用,证明活动包零环境能力)。
|
||||
*/
|
||||
import { defineActivity } from '@sdk';
|
||||
import type { ActivityBase, ActivityOutcome } from '@sdk';
|
||||
|
||||
const DRAFT = 'quote_pricing.draft';
|
||||
|
||||
export default defineActivity({
|
||||
name: 'quote-pricing',
|
||||
paradigm: 'flow',
|
||||
|
||||
async run(base: ActivityBase): Promise<ActivityOutcome> {
|
||||
const items = await base.ledger.read(DRAFT);
|
||||
const priced = items.filter((r) => typeof r['quoted_price'] === 'number');
|
||||
// 底价门禁 P-02 拦下的条目 —— DMN 结论是**约束性输入**,不得静默覆写
|
||||
const blocked = items.filter((r) => String(r['disposition'] ?? '').includes('拦截'));
|
||||
|
||||
await base.interaction.pushView([
|
||||
{
|
||||
t: 'kv',
|
||||
title: '定价概览',
|
||||
items: [
|
||||
{ k: '条目', v: `${items.length} 条` },
|
||||
{ k: '已定价', v: `${priced.length} 条` },
|
||||
{ k: '底价门禁拦下', v: `${blocked.length} 条` },
|
||||
],
|
||||
},
|
||||
{
|
||||
t: 'table',
|
||||
title: '报价草案',
|
||||
cols: ['PN', '拟报单价', '毛利率', '偏离度', '门禁处置'],
|
||||
rows: items.map((r) => [
|
||||
String(r['pn'] ?? ''),
|
||||
fmt(r['quoted_price']),
|
||||
pct(r['margin']),
|
||||
pct(r['deviation']),
|
||||
String(r['disposition'] ?? '待判'),
|
||||
]),
|
||||
linkDonut: {
|
||||
col: 4,
|
||||
title: '门禁分布',
|
||||
map: [
|
||||
{ match: '直接放行', label: '直接放行', tone: 'ok' },
|
||||
{ match: 'PM', label: 'PM 附理由', tone: 'warn' },
|
||||
{ match: '拦截', label: '拦截', tone: 'danger' },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
if (priced.length < items.length) {
|
||||
await base.interaction.say(`还有 ${items.length - priced.length} 条未定价,继续按利润模型 P-01 求值。`, 'conclusion');
|
||||
return { kind: 'await', reason: '等待定价步完成' };
|
||||
}
|
||||
|
||||
if (blocked.length > 0) {
|
||||
// 超出授权边界 → 走「人 · 决策」,不自行破线
|
||||
await base.interaction.say(
|
||||
`${blocked.length} 条低于底价 20% 以上,已被底价门禁 P-02 拦下 —— 需要人裁决回调还是注明理由。`,
|
||||
'criterion',
|
||||
);
|
||||
return { kind: 'await', reason: '等待超限条目的人裁决' };
|
||||
}
|
||||
|
||||
await base.interaction.say(`${items.length} 条已全部定价且过底价门禁,可提交审批。`, 'criterion');
|
||||
return { kind: 'submit', deliverable: 'quote-draft', rationale: '全条目带偏离度,底价门禁全过' };
|
||||
},
|
||||
});
|
||||
|
||||
function fmt(value: unknown): string {
|
||||
return typeof value === 'number' ? value.toFixed(2) : '—';
|
||||
}
|
||||
|
||||
function pct(value: unknown): string {
|
||||
return typeof value === 'number' ? `${(value * 100).toFixed(1)}%` : '—';
|
||||
}
|
||||
export default defineActivity({ name: 'quote-pricing' });
|
||||
|
||||
@@ -1,27 +1,45 @@
|
||||
/**
|
||||
* 活动包清单 —— 统一报价 A4 · 成本定价。
|
||||
* 活动包清单 —— 统一报价 A4 · 成本定价(案例活动)。
|
||||
*
|
||||
* 业务素材:docs/MVP示例工作流.html §1.2(S1 取价基准 → S2 逐条定价 → S3 底价自检)。
|
||||
* README《已裁定的落地决策》③:**A4 = flow** —— 取价、查表定价、自检的判断
|
||||
* **全部装进 DMN 决策表确定性求值**;一旦出现「由 AI 决定怎么办」,按 AM 2.1 上升。
|
||||
* 业务素材:`docs/MVP示例工作流.html` §1.2(S1 取价基准 → S2 逐条定价 → S3 底价自检):
|
||||
* 「BD 逐条定价:价格 = 底价 × 利润模型。提交审批前先自查底价门禁 ——
|
||||
* 超限的自己先调,不把问题留给 PM。」
|
||||
*
|
||||
* 形状与 A3 同一条(2026-08-03 重写 @3.0.0):归一算价 → 一张执行单交人 → 超限才叫人
|
||||
* 拍板 → 放行。**能算的不问人**(P-01 毛利档、P-02 底价门禁两把尺写死),
|
||||
* **该人负责的不自算**(拟报单价是必填,库内没有基准的那几行系统一个价都不编)。
|
||||
*
|
||||
* 与 @2.0.0 的差别(三处都是踩过的账):
|
||||
* ① `input.required` 从 `['rfqRef','customerCode','records']` 清空 —— 宽进严出,
|
||||
* 上游(A3)发的是 `{rfqRef, rows}`,门口拦的恰是主路;
|
||||
* ② 台账补 `required` —— 从前一个必填都没有,执行卡的闸没牙,人不填也能交卷;
|
||||
* ③ 判据与哨兵全部换成**基座读数**(`_rows` / `_missing` / `_<列>_<值>`)——
|
||||
* 从前 L1 读 `unpriced_total`、哨兵读 `draft_total`,两个都是活动自报的数。
|
||||
*/
|
||||
import type { ActivityManifest, DmnModel } from '@contracts';
|
||||
import { PRICING_SKILLS } from './skills.ts';
|
||||
import { CMMN_XML, NARRATIVE } from './case-model.ts';
|
||||
import { activityIdSchema } from '@contracts';
|
||||
import { PRICING_BASIS, PRICING_PRICED, PRICING_RELEASE } from './views/report.ts';
|
||||
import { PRICING_SKILLS } from './skills.ts';
|
||||
import { CMMN_XML, NARRATIVE, PRICING_PHASES, PRICING_STEPS } from './case-model.ts';
|
||||
import { PRICING_ROSTER, PRICING_WORKSHEET } from './views/report.ts';
|
||||
|
||||
const DOMAIN = 'quote_pricing';
|
||||
const D = 'quote_pricing';
|
||||
const ACTIVITY_ID = activityIdSchema.parse('quote-pricing');
|
||||
|
||||
/**
|
||||
* 利润模型 P-01 —— 品牌 × 类型 × 客户等级 × 数量档 → 目标毛利。
|
||||
* 「经验」段落不入决策表(那是技能 / 活动记忆种子:载方法不载权力)。
|
||||
* 两把尺 —— **规则本身不是数据**,随包冻结。
|
||||
*
|
||||
* · 利润模型 P-01:客户等级 × 类别 → 目标毛利区间(FIRST,行序即业务优先级);
|
||||
* · 底价门禁 P-02:相对底价线的偏离度 → 处置码。
|
||||
*
|
||||
* 出参就是台账列的取值(`disposition` 的三个大写码),模型算完照抄即可 ——
|
||||
* 「出参键名与台账列同名」是为了它拿回去直接 write(规范 §5.6)。
|
||||
* 两把尺的档位在三处出现:这张表、剧本 guide、工作单上的规则块 ——
|
||||
* 包内自测把三处钉在一起(改一处不改另两处,测就红)。
|
||||
*/
|
||||
export const P01_DMN: DmnModel = {
|
||||
drd: [
|
||||
{ decision: 'p01-margin', requires: [], inputs: ['customerGrade', 'materialType', 'volumeTier'] },
|
||||
{ decision: 'p02-floor', requires: ['p01-margin'], inputs: ['quotedPrice', 'floorPrice'] },
|
||||
{ decision: 'p01-margin', requires: [], inputs: ['customer_grade', 'type_tag'] },
|
||||
{ decision: 'p02-floor', requires: ['p01-margin'], inputs: ['deviation'] },
|
||||
],
|
||||
tables: [
|
||||
{
|
||||
@@ -29,30 +47,29 @@ export const P01_DMN: DmnModel = {
|
||||
label: '利润模型 P-01',
|
||||
hitPolicy: 'FIRST',
|
||||
inputs: [
|
||||
{ name: 'customerGrade', expression: 'customerGrade' },
|
||||
{ name: 'materialType', expression: 'materialType' },
|
||||
{ name: 'volumeTier', expression: 'volumeTier' },
|
||||
{ name: 'customer_grade', expression: 'customer_grade' },
|
||||
{ name: 'type_tag', expression: 'type_tag' },
|
||||
],
|
||||
outputs: [{ name: 'marginLow' }, { name: 'marginHigh' }],
|
||||
outputs: [{ name: 'margin_low' }, { name: 'margin_high' }],
|
||||
rules: [
|
||||
{ id: 'p01-1', when: ['"战略客户"', '"A"', '"大批量"'], then: [0.08, 0.1], annotation: '战略客户 A 类标准件大批量 8%–10%' },
|
||||
{ id: 'p01-2', when: ['"普通客户"', '"A"', '-'], then: [0.12, 0.15], annotation: '普通客户 A 类标准件 12%–15%' },
|
||||
{ id: 'p01-3', when: ['-', '"B"', '-'], then: [0.15, 0.2], annotation: 'B 类波动件 15%–20%' },
|
||||
{ id: 'p01-4', when: ['-', '"C"', '-'], then: [0.2, 0.5], annotation: 'C 类替代/EOL ≥20% 或一事一议' },
|
||||
{ id: 'p01-default', when: ['-', '-', '-'], then: [0.12, 0.15], annotation: '兜底按普通客户标准件' },
|
||||
{ id: 'p01-1', when: ['"战略客户"', '"A"'], then: [0.08, 0.1], annotation: '战略客户 A 类标准件(大批量长单)8%~10%' },
|
||||
{ id: 'p01-2', when: ['"普通客户"', '"A"'], then: [0.12, 0.15], annotation: '普通客户 A 类标准件 12%~15%' },
|
||||
{ id: 'p01-3', when: ['-', '"B"'], then: [0.15, 0.2], annotation: 'B 类波动件 15%~20%' },
|
||||
{ id: 'p01-4', when: ['-', '"C"'], then: [0.2, 0.5], annotation: 'C 类替代/停产件 20%~50%(一事一议)' },
|
||||
{ id: 'p01-default', when: ['-', '-'], then: [0.12, 0.15], annotation: '拿不准按普通客户标准件档' },
|
||||
],
|
||||
},
|
||||
{
|
||||
/** 底价门禁 P-02 —— 偏离底价 ±20% 即止 */
|
||||
/** 底价门禁 P-02 —— 偏离底价线 ±20% 即止;出参就是台账 `disposition` 的取值 */
|
||||
id: 'p02-floor',
|
||||
label: '底价门禁 P-02',
|
||||
hitPolicy: 'FIRST',
|
||||
inputs: [{ name: 'deviation', expression: 'deviation' }],
|
||||
outputs: [{ name: 'disposition' }, { name: 'passable' }],
|
||||
rules: [
|
||||
{ id: 'p02-1', when: ['>= 0'], then: ['直接放行', true], annotation: '≥底价 → 直接放行' },
|
||||
{ id: 'p02-2', when: ['[-0.20..0)'], then: ['PM 附理由放行', true], annotation: '低于底价 ≤20% → PM 附理由' },
|
||||
{ id: 'p02-3', when: ['< -0.20'], then: ['拦截 · BOSS 拍板', false], annotation: '低于底价 >20% → 拦截' },
|
||||
{ id: 'p02-1', when: ['>= 0'], then: ['PASS', true], annotation: '在底价线之上 → 直接放行' },
|
||||
{ id: 'p02-2', when: ['[-0.20..0)'], then: ['NOTE', true], annotation: '低于底价线两成以内 → 附理由放行' },
|
||||
{ id: 'p02-3', when: ['< -0.20'], then: ['BLOCK', false], annotation: '低于两成以上 → 拦截,人拍板' },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -62,130 +79,389 @@ export const manifest: ActivityManifest = {
|
||||
identity: {
|
||||
activityId: ACTIVITY_ID,
|
||||
name: 'A4 · 成本定价',
|
||||
packageVersion: '0.1.6',
|
||||
packageVersion: '3.5.0',
|
||||
category: '统一报价',
|
||||
sdkVersion: '0.1.0-m5',
|
||||
checksum: 'dev-checksum-quote-pricing',
|
||||
},
|
||||
|
||||
/* ── 契约七件 ────────────────────────────────────────────────── */
|
||||
contract: {
|
||||
activityId: ACTIVITY_ID,
|
||||
version: '0.1.0',
|
||||
version: '1.0.0',
|
||||
input: {
|
||||
/*
|
||||
* **宽进到底:一个必填都不设**(与 A1/A2/A3 同口径 —— 宽进严出,
|
||||
* 输入契约在活动内部归一 + 补全)。
|
||||
*
|
||||
* 来路三种形状:寻源转来(rows,行上带 base_price)、受理/选型直达(records/rows,
|
||||
* 没有底价)、单飞(一段文字)。上一版必填 `rfqRef + customerCode + records`,
|
||||
* 而 A3 的 `quotation` 口发的是 `{rfqRef, rows}` —— 门口拦的恰是主路。
|
||||
*/
|
||||
data: {
|
||||
type: 'object',
|
||||
required: ['customerCode', 'records'],
|
||||
required: [],
|
||||
properties: {
|
||||
customerCode: { type: 'string' },
|
||||
records: { type: 'array' },
|
||||
// B/C 类由 A3/A2 交回的底价与替代方案;A 类直通时为空
|
||||
floorPrices: { type: 'array' },
|
||||
rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' },
|
||||
customerCode: { type: 'string', title: '客户码' },
|
||||
rows: { type: 'array', title: '目标报价清单(寻源/选型转来,行上带底价)' },
|
||||
records: { type: 'array', title: '上游受理记录(受理直达)' },
|
||||
subject: { type: 'string', title: '需求主题' },
|
||||
body: { type: 'string', title: '需求正文' },
|
||||
attachments: {
|
||||
type: 'array',
|
||||
format: 'file',
|
||||
title: '附件',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', title: '文件名' },
|
||||
contentType: { type: 'string', title: '类型' },
|
||||
size: { type: 'integer', title: '字节数' },
|
||||
ref: { type: 'string', title: '存档号' },
|
||||
text: { type: 'string', title: '读出来的文本' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
messageAllowed: true,
|
||||
messageHint: '客户等级、急单、竞对价、这一单的让价授权写这里',
|
||||
},
|
||||
output: {
|
||||
/*
|
||||
* 只有一个出口:报价草案交定价审批(流程包 E8 是无条件边)。没有选择组 ——
|
||||
* 没有去向要选,定盘就是把账写齐。
|
||||
*
|
||||
* 载荷键名照下游(A6 议价)的入参写:`rfqRef / customerCode / items` ——
|
||||
* 边读什么、下游要什么,上游就得发什么。标量都有台账列承载(自测钉着)。
|
||||
*/
|
||||
mandatory: [
|
||||
{
|
||||
name: 'draft',
|
||||
payload: {
|
||||
type: 'object',
|
||||
required: ['items'],
|
||||
properties: { items: { type: 'array' } },
|
||||
required: ['rfqRef', 'items'],
|
||||
properties: {
|
||||
rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' },
|
||||
customerCode: { type: 'string', title: '客户码' },
|
||||
items: { type: 'array', title: '报价草案(每条带拟报单价、偏离度与门禁处置)' },
|
||||
},
|
||||
description: '报价草案(逐条带偏离度)',
|
||||
},
|
||||
description: '报价草案(全条目带拟报单价与门禁处置)',
|
||||
},
|
||||
],
|
||||
selective: [],
|
||||
},
|
||||
requiredDeliverables: [{ key: 'quote-draft', label: '报价草案(全条目带偏离度)' }],
|
||||
acceptanceCriteria: [
|
||||
{ key: 'all-priced', label: '每条都有拟报单价与毛利率', level: 'L1', predicate: 'unpriced = 0' },
|
||||
// 底价门禁沉为 L2 机检 —— 「约束力收缩」的终点
|
||||
{ key: 'floor-gate', label: '偏离底价 ≤±20%,超限者已回调或注明理由', level: 'L2', modelRef: 'p02-floor' },
|
||||
{ key: 'pm-release', label: 'BD 放行', level: 'L5' },
|
||||
/*
|
||||
* 必交付物只有一件:**报价草案**。定价结论(verdict)是它的结论栏,
|
||||
* `ledgers` 说清这份成果由哪几张表承载(一份成果,不是两份)。
|
||||
*/
|
||||
requiredDeliverables: [
|
||||
{
|
||||
key: 'quote-draft',
|
||||
label: '报价草案(每条带拟报单价与门禁处置)',
|
||||
ledger: `${D}.draft`,
|
||||
/* 验收页上让 BD 就地改的两列 —— 恰是他的职权面(定价与让价理由) */
|
||||
editableAtRelease: ['quoted_price', 'price_note'],
|
||||
ledgers: [`${D}.draft`, `${D}.verdict`],
|
||||
},
|
||||
],
|
||||
/*
|
||||
* 验收判据 —— **机检(L1/L2)只读基座数得出来的数**:
|
||||
* `_rows` 基座数行数、`_missing` 基座按 required 现数(与执行卡提交闸同一个数
|
||||
* 同一套算法)、`_disposition_*` 基座按值现数。
|
||||
* 上一版两条机检读活动自报的 `unpriced_total` / `unresolved_over_total` ——
|
||||
* 那不是门,是「改哪个数能过」的提示(违反史已记四次,这是第五类)。
|
||||
*/
|
||||
acceptanceCriteria: [
|
||||
{
|
||||
key: 'roster-complete',
|
||||
label: '草案齐:上游条目逐条承继,说了几条草案里就躺着几行',
|
||||
level: 'L1',
|
||||
predicate: `line_total > 0 and ${D}_draft_rows = line_total`,
|
||||
},
|
||||
{
|
||||
key: 'all-priced',
|
||||
label: '每条都有拟报单价:库内无基准价的那几行也由人定过价,缺一行放不了',
|
||||
level: 'L1',
|
||||
predicate: `${D}_draft_rows > 0 and ${D}_draft_missing = 0`,
|
||||
},
|
||||
{
|
||||
key: 'floor-gate',
|
||||
label: '底价门禁已过:处置逐条落定,没有还压在拦截档上的条目 —— 超限的要么回调、要么经人拍板附理由',
|
||||
level: 'L2',
|
||||
modelRef: 'p02-floor',
|
||||
predicate: `${D}_draft_disposition_block = 0 and ${D}_draft_disposition_pass + ${D}_draft_disposition_note + ${D}_draft_disposition_block = ${D}_draft_rows`,
|
||||
},
|
||||
/*
|
||||
* 报价来路可核是**裁量**(L3):基准是不是那个基准、让价理由站不站得住,
|
||||
* 机器数不出来。这句 label 与剧本里 judge 的 criterion **必须逐字一致**。
|
||||
*/
|
||||
{
|
||||
key: 'price-defensible',
|
||||
label: '报价的来路说得清:基准价来源、毛利档与让价理由逐条可溯,BD 对报价负责',
|
||||
level: 'L3',
|
||||
},
|
||||
/* L5 是责任关:那张卡本身就是这道关,卡面上不摆它(基座统一滤) */
|
||||
{ key: 'bd-release', label: 'BD 放行:签下去这份报价草案即定盘并交定价审批', level: 'L5' },
|
||||
],
|
||||
revisableAtRelease: [],
|
||||
prohibitions: [
|
||||
{ key: 'no-silent-override', statement: '不得静默绕开 P-02 的结论;要破线只能走「人 · 决策」' },
|
||||
{ key: 'no-stale-floor', statement: '底价超一周未更新的,先刷底价再判' },
|
||||
{ key: 'no-silent-override', statement: '不得静默绕开底价门禁的结论;要破线只能走「人 · 决策」并留下理由' },
|
||||
{ key: 'no-made-up-basis', statement: '没有成本基准的料不许编一个偏离率出来:基准空着就空着,价由人定并注明依据' },
|
||||
{ key: 'no-stale-floor', statement: '底价超一周未更新的,先刷底价再判 —— 拿过期的成本算出来的毛利是假的' },
|
||||
],
|
||||
authorization: {
|
||||
discretion: [{ key: 'margin-band', statement: '在 P-01 给出的毛利区间内自决;出区间必须调人决策' }],
|
||||
discretion: [{ key: 'margin-band', statement: '在 P-01 给出的毛利区间内自决;出区间要么回调,要么调人拍板' }],
|
||||
preauthorizedActions: [],
|
||||
humanCallBudget: 3,
|
||||
/* 一张执行单 + 可能一张决策卡 + 放行 = 三次;留一倍余量 */
|
||||
humanCallBudget: 5,
|
||||
},
|
||||
resources: {
|
||||
tools: [{ name: 'quote-pricing.basis' }, { name: 'crm.history-price-read' }],
|
||||
tools: [{ name: 'crm.history-price-read' }],
|
||||
skills: PRICING_SKILLS.map((s) => s.id),
|
||||
models: ['deepseek-v4-flash'],
|
||||
roles: ['BD'],
|
||||
budget: { steps: 60, tokens: 150_000, activeMinutes: 120, humanCalls: 3 },
|
||||
/* SLA <2h(MVP示例 §1.2);预算按 A3 实测(6-8 拍 / 40-50k)留一倍余量 */
|
||||
budget: { steps: 60, tokens: 400_000, activeMinutes: 120, humanCalls: 5 },
|
||||
},
|
||||
},
|
||||
|
||||
/* ── 声明 ────────────────────────────────────────────────────── */
|
||||
declarations: {
|
||||
models: ['deepseek-v4-flash'],
|
||||
tools: [{ name: 'crm.history-price-read' }, { name: 'quote-pricing.basis' }],
|
||||
tools: [{ name: 'crm.history-price-read' }],
|
||||
ledger: [
|
||||
{
|
||||
domain: DOMAIN,
|
||||
domain: D,
|
||||
object: 'draft',
|
||||
kind: 'record',
|
||||
caseKey: 'rfq_ref',
|
||||
label: '报价草案',
|
||||
fields: [
|
||||
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
|
||||
{ name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' },
|
||||
{ name: 'quoted_price', type: 'number', primaryKey: false, masked: false, label: '拟报单价' },
|
||||
{ name: 'customer_pn', type: 'text', primaryKey: false, masked: false, label: '客户料号' },
|
||||
{ name: 'category', type: 'text', primaryKey: false, masked: false, label: '品类' },
|
||||
{ name: 'brand', type: 'text', primaryKey: false, masked: false, label: '制造商' },
|
||||
{ name: 'eau', type: 'integer', primaryKey: false, masked: false, label: '年用量 EAU' },
|
||||
{ name: 'lead_time', type: 'text', primaryKey: false, masked: false, label: '客户要求交期' },
|
||||
/* 客户备注承继自上游 —— 认证要求、预授权、急料标注,让价时的第一手依据 */
|
||||
{ name: 'remark', type: 'text', primaryKey: false, masked: false, label: '客户备注(约束/急料)' },
|
||||
{ name: 'type_tag', type: 'text', primaryKey: false, masked: false, label: '类别(承继)', tally: ['A', 'B', 'C'] },
|
||||
/*
|
||||
* **客户目标价标 `expect`** —— 客户该给而没给的那一问不能省(件上出橘色
|
||||
* 「建议填」胶囊)。它不拦单:`_gaps` 只喂 L4,而本活动没有 L4 判据。
|
||||
* 注意边界:我方自己写的注(price_note)**不标 expect** —— 标了 `_gaps`
|
||||
* 恒大于 0,那条判据永远绿不了(A1 的账)。
|
||||
*/
|
||||
{ name: 'target_price', type: 'number', primaryKey: false, masked: false, expect: true, label: '客户目标价' },
|
||||
/* 成本基准:上游底价或库内历史价。**取不到就空着** —— 空着不是异常,是「无基准」那一档 */
|
||||
{ name: 'base_price', type: 'number', primaryKey: false, masked: false, label: '成本基准' },
|
||||
{ name: 'price_basis', type: 'text', primaryKey: false, masked: false, label: '基准来源', tally: ['HIST', 'SOURCED'] },
|
||||
{ name: 'margin_band', type: 'text', primaryKey: false, masked: false, label: '毛利档(P-01)' },
|
||||
{ name: 'floor_price', type: 'number', primaryKey: false, masked: false, label: '底价线' },
|
||||
/*
|
||||
* **系统建议价是 AI 的活,拟报单价是人的活** —— 两列分开是**结构治**,不是措辞治。
|
||||
*
|
||||
* 实测账(2026-08-03,单飞那条案例):从前只有 `quoted_price` 一列,剧本写着
|
||||
* 「无基准的行留空,由 BD 定」—— 模型照样给两颗库里查不到的料编了价
|
||||
* (一颗抄了客户目标价,另一颗凭空写了 0.085),交代里还说着「价空着等你定」。
|
||||
* 一条要模型分辨「这行算得出/算不出」的规矩,它十次里会走岔;
|
||||
* 而「这一列你一格都不写」没有分支可走岔。
|
||||
*
|
||||
* 于是:AI 只写 `suggested_price`(算得出就给,算不出就空着),
|
||||
* **`quoted_price` 永远是人在件上定的那个数** —— 它标 `required`,
|
||||
* 件上的缺价红带、执行卡的提交闸、放行门 L1 三处同源(都数基座的 `_missing`)。
|
||||
* 不用 `requiredWhen`:每一行都要有价,没有「这一行还没轮到」的情形。
|
||||
*/
|
||||
{ name: 'suggested_price', type: 'number', primaryKey: false, masked: false, label: '系统建议价' },
|
||||
{ name: 'quoted_price', type: 'number', primaryKey: false, masked: false, required: true, label: '拟报单价(BD 定)' },
|
||||
{ name: 'margin', type: 'number', primaryKey: false, masked: false, label: '毛利率' },
|
||||
{ name: 'last_price', type: 'number', primaryKey: false, masked: false, label: '基准价' },
|
||||
{ name: 'deviation', type: 'number', primaryKey: false, masked: false, label: '偏离度' },
|
||||
{ name: 'disposition', type: 'text', primaryKey: false, masked: false, label: '门禁处置' },
|
||||
{ name: 'deviation', type: 'number', primaryKey: false, masked: false, label: '偏离底价线' },
|
||||
/*
|
||||
* **门禁处置:取值域给出来,条数由基座现数** —— PASS / NOTE / BLOCK。
|
||||
* 守恒判据、分布条、门禁徽读的全是基座读数;值是机器码,
|
||||
* 人读名(直接放行 / 附理由 / 拦截待拍板)由模版样式表翻译。
|
||||
*/
|
||||
{ name: 'disposition', type: 'text', primaryKey: false, masked: false, label: '门禁处置', tally: ['PASS', 'NOTE', 'BLOCK'] },
|
||||
{ name: 'price_note', type: 'text', primaryKey: false, masked: false, label: '备注 / 让价理由' },
|
||||
],
|
||||
},
|
||||
{
|
||||
/*
|
||||
* 定价结论 —— 一单一行的**汇总表**,哨兵与报告读它。
|
||||
* 汇总列与逐行列**故意不同名**:任务视图把草稿行摊平到顶层是「后写的赢」,
|
||||
* 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。
|
||||
*/
|
||||
domain: D,
|
||||
object: 'verdict',
|
||||
kind: 'record',
|
||||
caseKey: 'rfq_ref',
|
||||
label: '定价结论',
|
||||
fields: [
|
||||
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
|
||||
{ name: 'customer_code', type: 'text', primaryKey: false, masked: false, label: '客户码' },
|
||||
{ name: 'customer_name', type: 'text', primaryKey: false, masked: false, label: '客户名称' },
|
||||
/* 客户等级是 P-01 的入参之一(战略客户 / 普通客户),认不出按普通客户 */
|
||||
{ name: 'customer_grade', type: 'text', primaryKey: false, masked: false, label: '客户等级' },
|
||||
{ name: 'line_total', type: 'integer', primaryKey: false, masked: false, label: '草案条目数' },
|
||||
{ name: 'basis_total', type: 'integer', primaryKey: false, masked: false, label: '有成本基准的条数' },
|
||||
/* 算价走完的凭据(0 也要写)—— 哨兵按它放交人核价那一步入场 */
|
||||
{ name: 'priced_total', type: 'integer', primaryKey: false, masked: false, label: '已算出建议价条数' },
|
||||
/* 「BD 核过价了」的凭据 —— 只在人环走完才产生 */
|
||||
{ name: 'confirmed_total', type: 'integer', primaryKey: false, masked: false, label: 'BD 已核价条数' },
|
||||
{ name: 'route_decided_total', type: 'integer', primaryKey: false, masked: false, label: '已定盘' },
|
||||
{ name: 'summary', type: 'text', primaryKey: false, masked: false, label: '定价结论' },
|
||||
],
|
||||
},
|
||||
],
|
||||
listeners: [
|
||||
{
|
||||
/** 时限过半:SLA <2h,给 BD 与负责人提个醒 —— 静态监听,时限必配 */
|
||||
origin: 'static',
|
||||
eventType: 'human',
|
||||
keyFilter: { kind: 'decision' },
|
||||
semantic: 'complete-step',
|
||||
deadline: 'PT4H',
|
||||
escalation: ['nudge', 'escalate'],
|
||||
eventType: 'time',
|
||||
keyFilter: { kind: 'half-deadline' },
|
||||
semantic: 'observe',
|
||||
deadline: 'PT1H',
|
||||
escalation: ['nudge'],
|
||||
},
|
||||
],
|
||||
// SLA <2h(MVP示例 §1.2 留痕 · SLA)
|
||||
harness: {
|
||||
memoryWindow: 20,
|
||||
memoryWindow: 22,
|
||||
// SLA <2h(MVP示例 §1.2 留痕 · SLA)
|
||||
stallAfter: 'PT2H',
|
||||
nudge: [{ after: 'PT1H', action: 'nudge' }],
|
||||
retry: { times: 2, backoff: 'PT30S' },
|
||||
},
|
||||
},
|
||||
|
||||
/* ── 资产初始版 ──────────────────────────────────────────────── */
|
||||
assets: {
|
||||
/* 资产初始版:案例模型(2026-07-29 起活动只有这一种业务模型) */
|
||||
businessModel: {
|
||||
cmmnXml: CMMN_XML,
|
||||
narrative: NARRATIVE,
|
||||
dmn: P01_DMN,
|
||||
steps: PRICING_STEPS,
|
||||
phases: PRICING_PHASES,
|
||||
},
|
||||
skills: PRICING_SKILLS,
|
||||
/*
|
||||
* 两个局面各一张(与 A2/A3 同款口径):
|
||||
* · `pricing-worksheet` 定价工作单 —— 算价与 BD 核价期间摆它(逐颗定价档案、
|
||||
* 两把尺、填回工作表);
|
||||
* · `pricing-roster` 报价草案清单 —— 拍板、放行、归档摆它。
|
||||
*/
|
||||
viewTemplates: [
|
||||
{ name: 'pricing-basis', html: PRICING_BASIS },
|
||||
{ name: 'pricing-priced', html: PRICING_PRICED },
|
||||
{ name: 'pricing-release', html: PRICING_RELEASE },
|
||||
{ name: 'pricing-worksheet', html: PRICING_WORKSHEET },
|
||||
{ name: 'pricing-roster', html: PRICING_ROSTER },
|
||||
],
|
||||
cardSchemas: {},
|
||||
distillHooks: [
|
||||
{
|
||||
on: 'terminal',
|
||||
prompt: '归纳本单定价的经验:哪些组合的毛利档被人回调过、底价门禁拦下的形态、破线的理由。',
|
||||
prompt: '归纳本单定价:哪些组合的毛利档被人回调过、底价门禁拦下的形态、破线的理由与拍板结果。',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
/* ── 样例集 —— 三条形状真不同(常备案例即验收案例)───────────── */
|
||||
samples: [
|
||||
{
|
||||
name: '战略客户 A 类大批量',
|
||||
input: { customerCode: 'C001', records: [{ pn: 'LM358DR', type: 'A' }] },
|
||||
/*
|
||||
* ① 寻源转来 · 常路:A3 `quotation` 口的真实形状 —— rows 里清单行(带底价与价源)
|
||||
* 混着结论行。客户目标价都在底价线之上,全 PASS,不必叫人拍板。
|
||||
*/
|
||||
name: '寻源转来 · 两条料底价齐 · 全在底价线之上(常路)',
|
||||
input: {
|
||||
rfqRef: 'RFQ MD14.1',
|
||||
customerCode: 'CU-0142',
|
||||
customer_name: 'Itelma',
|
||||
rows: [
|
||||
{
|
||||
rfq_ref: 'RFQ MD14.1',
|
||||
pn: 'BBPY00201209601Y00',
|
||||
customer_pn: '4-14-01502',
|
||||
category: '滤波器',
|
||||
brand: 'YAGEO',
|
||||
eau: 30000,
|
||||
target_price: 0.085,
|
||||
lead_time: '8周',
|
||||
type_tag: 'B',
|
||||
base_price: 0.058,
|
||||
price_basis: 'SOURCED',
|
||||
supplier: '深圳华强渠道',
|
||||
},
|
||||
{
|
||||
rfq_ref: 'RFQ MD14.1',
|
||||
pn: 'CJTB0101R6N',
|
||||
customer_pn: '4-00-04102',
|
||||
category: '集成电路',
|
||||
brand: 'Jiangsu Changjing',
|
||||
eau: 60000,
|
||||
target_price: 0.42,
|
||||
lead_time: '6周',
|
||||
type_tag: 'A',
|
||||
base_price: 0.35,
|
||||
price_basis: 'HIST',
|
||||
},
|
||||
{ rfq_ref: 'RFQ MD14.1', quote_line_total: 2, route_decided_total: 1, customer_name: 'Itelma', summary: '两条底价齐,交定价' },
|
||||
],
|
||||
},
|
||||
expect: { ports: ['draft'] },
|
||||
},
|
||||
{
|
||||
/*
|
||||
* ② 有超限条目:客户把目标价压到成本之下(0.06 对成本 0.082)——
|
||||
* 偏离底价线超过两成,门禁判 BLOCK,必须叫人拍板(决策卡)。
|
||||
*/
|
||||
name: '客户压价 · 一条低于底价线两成以上 → 决策卡叫人拍板',
|
||||
input: {
|
||||
rfqRef: 'RFQ-2026-0777',
|
||||
customerCode: 'CU-0311',
|
||||
customer_name: '青岛海洋电子',
|
||||
rows: [
|
||||
{
|
||||
rfq_ref: 'RFQ-2026-0777',
|
||||
pn: 'PESD2ETH-XR',
|
||||
category: '二极管',
|
||||
brand: 'Nexperia',
|
||||
eau: 30000,
|
||||
target_price: 0.06,
|
||||
lead_time: '8周',
|
||||
type_tag: 'B',
|
||||
base_price: 0.082,
|
||||
price_basis: 'SOURCED',
|
||||
remark: '客户明说这个价是竞对给的,要跟',
|
||||
},
|
||||
{
|
||||
rfq_ref: 'RFQ-2026-0777',
|
||||
pn: 'NE555P',
|
||||
category: '定时器',
|
||||
brand: 'TI',
|
||||
eau: 5000,
|
||||
target_price: 1.05,
|
||||
lead_time: '4周',
|
||||
type_tag: 'A',
|
||||
base_price: 0.85,
|
||||
price_basis: 'HIST',
|
||||
},
|
||||
{ rfq_ref: 'RFQ-2026-0777', quote_line_total: 2, route_decided_total: 1, customer_name: '青岛海洋电子' },
|
||||
],
|
||||
},
|
||||
expect: { ports: ['draft'] },
|
||||
},
|
||||
{
|
||||
/*
|
||||
* ③ 单飞宽进:没有单号没有清单,正文列料 —— 归一造案号;库内查不到历史价,
|
||||
* 系统一个价都不编,两行拟报单价空着交 BD 定 → **提交闸真拦**空手交卷。
|
||||
*/
|
||||
name: '单飞需求 · 正文列两颗料 · 库内无基准 → 价由人定(闸真拦)',
|
||||
input: {
|
||||
subject: '两颗料报个价',
|
||||
body: '客户要两颗料的报价:XCJ-7788T 电源模块 8K,目标价 12.5;LMR-3302QQ 稳压器 15K,没给目标价。库里应该没做过这两颗,你先出个草案。',
|
||||
},
|
||||
expect: { ports: ['draft'] },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/**
|
||||
* A4 技能 —— 定价的方法经验(AM 08)。载方法,不载权力。
|
||||
* A4 技能 —— 定价的方法经验。载方法,不载权力。
|
||||
*
|
||||
* 与剧本的分工:剧本写「这一步做什么、写哪几列」,技能写「为什么这么定、
|
||||
* 拿不准时按哪条走」。剧本改了口径,技能里同一句要跟着改(规范 §5.9)。
|
||||
*/
|
||||
import { skillIdSchema } from '@contracts';
|
||||
import type { Skill } from '@contracts';
|
||||
@@ -8,34 +11,41 @@ export const PRICING_SKILLS: Skill[] = [
|
||||
{
|
||||
id: skillIdSchema.parse('pricing.pick-basis'),
|
||||
label: '选对价格基准',
|
||||
applicability: '定价开始,需要确定「跟谁比」时。',
|
||||
applicability: '定价开始,需要确定「这个价跟谁比」时。',
|
||||
method: [
|
||||
'基准的优先级:① 本客户近期成交价 → ② 同型号他客户成交价 → ③ 供应商底价表 → ④ 无基准。',
|
||||
'基准的优先级:① 上游寻源问回来的货源底价 → ② 本客户近期成交价 → ③ 同型号他客户成交价 → ④ 无基准。',
|
||||
'越靠前越贴近这一单的真实议价空间;越靠后越要留余量。',
|
||||
'',
|
||||
'**无基准不是异常**,是一种基准:新料号、久未报价的老料号都会落到这里。',
|
||||
'此时偏离率记 0(没有可偏离的对象),由底价门禁按「无基准」放行 —— ',
|
||||
'此时成本基准、底价线、偏离度**一律留空**,拟报单价交给人定 ——',
|
||||
'不要拿一个不相干的价去凑一个偏离率出来,那比没有基准更危险:它看起来像有依据。',
|
||||
'',
|
||||
'基准超一周未更新的先刷再判:拿过期成本算出来的毛利,数字是真的,结论是假的。',
|
||||
].join('\n'),
|
||||
crystallized: [{ tool: 'crm.history-price-read', input: { pn: 'pn' } }],
|
||||
knowledgeRefs: ['利润模型 P-01'],
|
||||
version: '1.0.0',
|
||||
version: '2.0.0',
|
||||
},
|
||||
{
|
||||
id: skillIdSchema.parse('pricing.read-gate'),
|
||||
label: '读懂底价门禁的处置',
|
||||
applicability: '底价门禁(P-02)给出处置结论,需要决定下一步时。',
|
||||
applicability: '底价门禁(P-02)给出处置码,需要决定下一步时。',
|
||||
method: [
|
||||
'门禁不是「拦下」而是**分流**:',
|
||||
'· 直接放行 —— 偏离在容许带内,照常提审。',
|
||||
'· 超限待裁 —— 偏离超 ±20%,交人决断:回调价格,还是注明理由硬报。',
|
||||
' 两条路都合法,但都要留下**理由**:这条留痕将来会成为客户等级与利润模型的迭代素材。',
|
||||
'门禁不是「拦下」而是**分流**,三档各有各的下一步:',
|
||||
'· PASS(在底价线之上)—— 照常提审,不必解释。',
|
||||
'· NOTE(低于底价线两成以内)—— 附理由放行:让价的缘由写进备注,',
|
||||
' 理由随草案一起交审批。不写理由的让价,到审批那一关同样过不去。',
|
||||
'· BLOCK(低于两成以上)—— 系统不替任何人放行,交人拍板:回调到底价线、',
|
||||
' 破线报并留理由、或这一条本轮不报。三条路都合法,都要留痕。',
|
||||
'',
|
||||
'不要为了让门禁「过」而回头改毛利参数 —— 那是把判据改成结论,',
|
||||
'决策表就失去了它唯一的价值:同输入必同结论。',
|
||||
'两条别做的事:',
|
||||
'· 不要为了让门禁「过」回头改毛利档或改基准 —— 那是把判据改成结论,',
|
||||
' 决策表就失去了它唯一的价值:同输入必同结论;',
|
||||
'· 不要每一条都叫人。BD 自己能回调掉的,回调掉就是处置 ——',
|
||||
' 每条都叫人,人就成了橡皮图章;一条都不叫,门禁就是摆设。',
|
||||
].join('\n'),
|
||||
crystallized: [],
|
||||
knowledgeRefs: ['底价门禁 P-02'],
|
||||
version: '1.0.0',
|
||||
version: '2.0.0',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* A1 两份执行页的样式表 —— **从 PRD 目标件原样搬过来**,不是我照着复述的。
|
||||
*
|
||||
* 出处:`~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理需求.html` 的整段 `<style>`,
|
||||
* 外加 `A1-询价受理记录.html` 里归档件独有的那几件(分类分布条 / 类徽 / 判定表 /
|
||||
* 结论横幅 / 落地动作 / 完成标)。**一个数值都没改**。
|
||||
*
|
||||
* 为什么是搬而不是抄:前三轮我用自己那套外壳「照着做」,块搬齐了、版面是两套 ——
|
||||
* 章节头成了彩色方块编号、多出摘要与元信息带、字号用 clamp。人一眼就看出不是那份件。
|
||||
* 目标件是**像素级的答案**,搬过来才不会在复述里走样。
|
||||
*
|
||||
* 选择器保留 `.proto-root .app-shell` 前缀,所以模版正文也要照原型那样包两层壳 ——
|
||||
* 少一层壳,整张样式表一条都不生效(踩过)。
|
||||
*/
|
||||
export const PRD_CSS = `
|
||||
/* ============================================================================
|
||||
JCAI 员工面工作台 · A1 受理归一的过程成果「询价受理需求」——报告本体
|
||||
立场:**AI 已从 RFQ 邮件抽取归一**,人在这里审核并就地修订。
|
||||
两态:读态(AI 稿 + 红/灰占位提示缺项) ⇄ 编辑态(全字段可改,保存 / 取消)
|
||||
核心是「有效 PN 清单」;清单之上四件受理要素:客户公司 / 联系人 / 客户属性 / 有效 PN。
|
||||
|
||||
令牌与既有组件取自 workbench/src/app/(proto)/app.css,数值未改。
|
||||
⚠️ 修正一处原型 bug:app.css:2062-2064 的 '.jc-rp-sign span'(权重 0,1,1)
|
||||
盖过了 '.jc-rp-ava'(0,1,0),把头像的 display:grid 覆盖成 block、字色改灰,
|
||||
导致落款头像里的字掉到圆外。本文件把该规则收窄为 '.jc-rp-sign > div > span'。
|
||||
新增件:四要素卡 .jc-four/.jc-fcard · 占位胶囊 .jc-ph · 分段控件 .jc-seg ·
|
||||
来源条 .jc-src · 录入表 .jc-tbl-in
|
||||
========================================================================== */
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body { background: #e6eaf1; -webkit-font-smoothing: antialiased; }
|
||||
|
||||
.proto-root .app-shell {
|
||||
--brand: #3370ff; --brand-deep: #245bdb;
|
||||
--brand-soft: #e1eaff; --brand-wash: #f0f4ff;
|
||||
--ai-grad: linear-gradient(135deg, #3370ff, #7f3bf5);
|
||||
--ink: #1f2329; --ink-2: #41464e; --ink-3: #646a73; --ink-4: #8f959e;
|
||||
--line: #dee0e3; --line-soft: #e8eaed; --soft: #f8f9fb;
|
||||
--jcai-bg: #e6eaf1; --cardline: #e1e6ec; --card-shadow: 0 1px 2px rgba(31, 35, 41, .05);
|
||||
--ok: #278717; --ok-soft: #e8f8e4;
|
||||
--warn: #de7802; --warn-accent: #ff8800; --warn-soft: #fff4e5;
|
||||
--danger: #d83931; --danger-soft: #feeae9;
|
||||
--mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace;
|
||||
--shadow-brand: 0 6px 16px rgba(51, 112, 255, .22);
|
||||
--t-fast: .16s cubic-bezier(.3, 0, .2, 1);
|
||||
--t-med: .26s cubic-bezier(.3, 0, .2, 1);
|
||||
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: var(--jcai-bg); min-height: 100vh; color: var(--ink);
|
||||
}
|
||||
.proto-root :where(button, input, select, a, [tabindex]):focus-visible {
|
||||
outline: 2px solid var(--brand); outline-offset: 2px; border-radius: 8px;
|
||||
}
|
||||
.proto-root ::selection { background: var(--brand-soft); }
|
||||
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes jcBlink { 0%,100% { opacity: 1 } 50% { opacity: .35 } }
|
||||
@keyframes jcStamp { from { opacity: 0; transform: rotate(-6deg) scale(1.7); } to { opacity: .9; transform: rotate(-6deg) scale(1); } }
|
||||
|
||||
/* 放开宽度:1160px 是「9 列全展开、一个字都不裁」的最小容器宽度(见下方列 min-width 之和) */
|
||||
.jc-stage { max-width: 1160px; margin: 0 auto; padding: 22px 16px 52px; }
|
||||
.proto-root .jc-mono { font-family: var(--mono); font-size: 11px; color: var(--ink-3); }
|
||||
.proto-root .jc-pulse-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--brand); display: inline-block; animation: jcBlink 1.1s ease-in-out infinite; }
|
||||
|
||||
/* ── 报告外壳 ───────────────────────────────────────────────────────── */
|
||||
.proto-root .jc-report { background: #fff; border: 1px solid var(--cardline); border-radius: 14px; box-shadow: var(--card-shadow); overflow: hidden; animation: rise var(--t-med) both; }
|
||||
.proto-root .jc-rp-head { position: relative; overflow: hidden; display: flex; align-items: flex-start; gap: 12px; padding: 16px 20px 13px; border-bottom: 2px solid var(--ink); }
|
||||
/* 题头下缘粗细双线 = 正式件版式 */
|
||||
.proto-root .jc-rp-head::after { content: ""; position: absolute; left: 0; right: 0; bottom: -4px; height: 1px; background: var(--line); }
|
||||
/* 题头右上的品牌光晕:与工作台标题区同一签名件 */
|
||||
.proto-root .jc-rp-head::before { content: ""; position: absolute; right: -70px; top: -96px; width: 240px; height: 240px; border-radius: 999px; background: radial-gradient(circle, rgba(51,112,255,.10), transparent 70%); pointer-events: none; }
|
||||
.proto-root .jc-rp-head > * { position: relative; z-index: 1; }
|
||||
.proto-root .jc-rp-badge { flex: none; width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand); }
|
||||
.proto-root .jc-rp-tit { flex: 1; min-width: 0; }
|
||||
.proto-root .jc-rp-tit em { display: block; font-style: normal; font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); font-family: var(--mono); }
|
||||
.proto-root .jc-rp-tit h3 { margin: 4px 0 0; font-size: 18px; font-weight: 800; color: var(--ink); letter-spacing: -.01em; line-height: 1.3; }
|
||||
.proto-root .jc-rp-wait { display: inline-flex; align-items: center; gap: 6px; flex: none; margin-top: 4px; font-size: 11px; font-weight: 700; border-radius: 999px; padding: 4px 12px; color: var(--brand-deep); background: var(--brand-wash); border: 1px solid var(--brand-soft); }
|
||||
.proto-root .jc-rp-wait.warn { color: var(--warn); background: var(--warn-soft); border-color: #ffd9a8; }
|
||||
.proto-root .jc-rp-wait.edit { color: #fff; background: var(--brand); border-color: var(--brand); }
|
||||
.proto-root .jc-rp-stamp { flex: none; margin-top: 2px; transform: rotate(-6deg); border: 2px solid currentColor; border-radius: 9px; padding: 4px 8px 3px 12px; font-size: 13px; font-weight: 900; letter-spacing: .28em; color: var(--ok); opacity: .9; animation: jcStamp .32s cubic-bezier(.2, 1.4, .4, 1) both; }
|
||||
.proto-root .jc-rp-bd { padding: 18px 20px 12px; }
|
||||
.proto-root .jc-rp-part { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 9px; margin: 20px 0 11px; }
|
||||
.proto-root .jc-rp-part:first-child { margin-top: 0; }
|
||||
.proto-root .jc-rp-part > b { flex: none; font-size: 11px; font-weight: 800; letter-spacing: .12em; color: var(--ink-3); }
|
||||
.proto-root .jc-rp-part > .ln { flex: 1; height: 1px; background: var(--line-soft); }
|
||||
.proto-root .jc-rp-part > em { flex: none; font-style: normal; font-size: 10px; font-weight: 700; color: var(--ink-4); font-family: var(--mono); background: var(--soft); border-radius: 999px; padding: 2px 9px; white-space: nowrap; }
|
||||
.proto-root .jc-rp-part > em.warn { color: var(--warn); background: var(--warn-soft); }
|
||||
|
||||
/* 落款 —— ⚠️ 选择器收窄到 > div > *,别再命中头像那个 span */
|
||||
.proto-root .jc-rp-foot { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 13px 20px 15px; border-top: 2px solid var(--line); background: #fbfcfd; }
|
||||
.proto-root .jc-rp-sign { display: flex; align-items: center; gap: 10px; flex: 1 1 200px; min-width: 0; }
|
||||
.proto-root .jc-rp-ava { flex: 0 0 auto; width: 30px; height: 30px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; line-height: 1; color: #fff; background: var(--brand); }
|
||||
.proto-root .jc-rp-sign > div { min-width: 0; }
|
||||
.proto-root .jc-rp-sign > div > b { display: block; font-size: 12px; font-weight: 700; color: var(--ink-2); }
|
||||
.proto-root .jc-rp-sign > div > span { display: block; margin-top: 1px; font-size: 10.5px; color: var(--ink-4); }
|
||||
.proto-root .jc-rp-acts { display: flex; align-items: center; gap: 8px; margin-left: auto; flex: none; }
|
||||
.proto-root .jc-hintline { font-size: 11px; font-weight: 700; color: var(--warn); line-height: 1.5; }
|
||||
|
||||
.proto-root .jc-btn { display: inline-flex; align-items: center; gap: 5px; border: 0; border-radius: 7px; padding: 8px 14px; font-size: 12px; font-weight: 600; font-family: inherit; background: var(--brand); color: #fff; cursor: pointer; transition: all var(--t-fast); }
|
||||
.proto-root .jc-btn:hover:not(:disabled) { background: var(--brand-deep); }
|
||||
.proto-root .jc-btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.proto-root .jc-btn-ghost { background: #fff; color: var(--ink-2); border: 1px solid var(--line); }
|
||||
.proto-root .jc-btn-ghost:hover:not(:disabled) { background: var(--brand-wash); color: var(--brand); border-color: var(--brand-soft); }
|
||||
.proto-root .jc-btn-primary { background: var(--ai-grad); }
|
||||
.proto-root .jc-btn-primary:hover:not(:disabled) { filter: brightness(1.07); box-shadow: var(--shadow-brand); transform: translateY(-1px); }
|
||||
|
||||
/* ── 来源信件:客户 RFQ 邮件原文(复用原型 jc-letter 件,青色 = 收件) ── */
|
||||
.proto-root .jc-letter { position: relative; border: 1px solid var(--cardline); border-left: 3px solid #0aa294; border-radius: 12px; padding: 14px 16px 15px; background: #fff; box-shadow: var(--card-shadow); }
|
||||
.proto-root .jc-letter-h { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 11px; }
|
||||
.proto-root .jc-letter-dir { display: inline-flex; align-items: center; gap: 5px; font-size: 10.5px; font-weight: 800; padding: 3px 9px; border-radius: 999px; background: #e4f8f5; color: #0a7f74; }
|
||||
.proto-root .jc-letter-h .jc-chip { font-size: 10.5px; padding: 3px 9px; border-radius: 999px; background: var(--brand-wash); color: var(--brand-deep); border: 1px solid var(--brand-soft); }
|
||||
.proto-root .jc-letter-h > code { margin-left: auto; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
|
||||
.proto-root .jc-letter-meta { display: flex; flex-direction: column; gap: 4px; margin-bottom: 11px; padding-bottom: 11px; border-bottom: 1px solid var(--line-soft); font-size: 11.5px; color: var(--ink-2); }
|
||||
.proto-root .jc-letter-meta p { margin: 0; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
|
||||
.proto-root .jc-letter-meta i { flex: none; font-style: normal; width: 44px; font-size: 10px; font-weight: 800; letter-spacing: .06em; color: var(--ink-4); }
|
||||
.proto-root .jc-letter-meta code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); overflow-wrap: anywhere; }
|
||||
.proto-root .jc-letter-sub { margin: 0 0 9px; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.45; overflow-wrap: anywhere; }
|
||||
/* 正文:原文照录,pre-wrap 保原始换行;不截断、不折叠 */
|
||||
.proto-root .jc-letter-body { margin: 0; font-size: 12.5px; line-height: 1.72; color: var(--ink-2); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-letter-body b { color: var(--ink); font-weight: 700; }
|
||||
.proto-root .jc-atts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; padding-top: 11px; border-top: 1px dashed var(--line-soft); }
|
||||
.proto-root .jc-att { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px 5px 8px; border: 1px solid var(--brand-soft); border-radius: 8px; background: var(--brand-wash); font-size: 11.5px; color: var(--brand-deep); }
|
||||
.proto-root .jc-att svg { flex: none; color: var(--brand); }
|
||||
.proto-root .jc-att code { font-family: var(--mono); font-size: 10.5px; }
|
||||
.proto-root .jc-att span { color: var(--ink-4); }
|
||||
/* 抽取指向:从原文到下方要素的一句衔接 */
|
||||
.proto-root .jc-derive { display: flex; align-items: flex-start; gap: 8px; margin-top: 11px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); font-size: 11.5px; line-height: 1.7; color: var(--brand-deep); }
|
||||
.proto-root .jc-derive svg { flex: none; margin-top: 2px; }
|
||||
|
||||
/* ── 四要素卡 ───────────────────────────────────────────────────────────
|
||||
色调字典:每张卡带一套 --tone / --tone-soft / --tone-line,组件只读变量。
|
||||
卡 3、卡 4 的色调随数据变(老客户绿 / 新客户橙;齐了蓝 / 有缺橙),
|
||||
所以这条要素带本身就是一行可读的状态,不只是四个格子。 */
|
||||
.proto-root .jc-four { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 11px; }
|
||||
.proto-root .jc-fcard {
|
||||
position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-width: 0;
|
||||
padding: 14px 14px 14px; border: 1px solid var(--cardline); border-radius: 13px; background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 8px 20px -14px rgba(31,35,41,.35);
|
||||
transition: transform var(--t-fast), box-shadow var(--t-fast), border-color var(--t-fast);
|
||||
--tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft);
|
||||
}
|
||||
/* 顶缘色棱 + 右上角光晕:卡的身份色,一眼分得清四件是四件 */
|
||||
.proto-root .jc-fcard::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 3px; background: var(--tone); }
|
||||
.proto-root .jc-fcard::after { content: ""; position: absolute; right: -46px; top: -52px; width: 132px; height: 132px; border-radius: 999px; background: radial-gradient(circle, var(--tone-soft), transparent 70%); pointer-events: none; }
|
||||
.proto-root .jc-fcard > * { position: relative; z-index: 1; }
|
||||
.proto-root .jc-fcard:hover { transform: translateY(-2px); border-color: var(--tone-line); box-shadow: 0 2px 4px rgba(31,35,41,.05), 0 14px 26px -16px rgba(31,35,41,.45); }
|
||||
.proto-root .jc-fcard.t-teal { --tone: #0aa294; --tone-soft: rgba(10,162,148,.15); --tone-line: #9fe0d9; }
|
||||
.proto-root .jc-fcard.t-ok { --tone: var(--ok); --tone-soft: rgba(39,135,23,.14); --tone-line: #bfedb4; }
|
||||
.proto-root .jc-fcard.t-warn { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.12); --tone-line: #ffd9a8; }
|
||||
|
||||
.proto-root .jc-fcard-k { display: flex; align-items: center; gap: 6px; font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
|
||||
.proto-root .jc-fcard-k > i { flex: none; width: 19px; height: 19px; border-radius: 6px; display: inline-grid; place-items: center; background: var(--tone-soft); color: var(--tone); }
|
||||
.proto-root .jc-fcard-b { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.proto-root .jc-fcard-t { min-width: 0; flex: 1; }
|
||||
/* 一律换行,不省略号——宁可多占一行,也不丢字 */
|
||||
.proto-root .jc-fcard-t > b { display: block; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.35; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-fcard-t > span { display: block; margin-top: 2px; font-size: 10.5px; color: var(--ink-4); line-height: 1.55; overflow-wrap: anywhere; }
|
||||
/* 公司标:圆角方 + AI 渐变 + 外圈光环(方=组织) */
|
||||
.proto-root .jc-logo { flex: none; width: 38px; height: 38px; border-radius: 12px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; letter-spacing: .04em; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand), 0 0 0 3px rgba(51,112,255,.10); }
|
||||
/* 人像:圆 + 青色系(圆=人,与方形的组织标一眼分开) */
|
||||
.proto-root .jc-face { flex: none; width: 38px; height: 38px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; color: #0a7f74; background: #e4f8f5; border: 1px solid #9fe0d9; box-shadow: 0 0 0 3px rgba(10,162,148,.10); }
|
||||
/* 邮箱做成可点的胶囊,复制按钮嵌在里面——比一行裸文字有抓手 */
|
||||
/* 邮箱:软底胶囊、无描边——有描边会读成输入框,而读态这里不是可填的 */
|
||||
.proto-root .jc-mailrow { display: flex; align-items: center; gap: 4px; margin-top: 5px; min-width: 0; max-width: 100%; padding: 3px 4px 3px 9px; border: 0; border-radius: 8px; background: var(--tone-soft); transition: background var(--t-fast); }
|
||||
.proto-root .jc-mailrow:hover { background: rgba(10,162,148,.2); }
|
||||
/* 编辑态里它包着输入框,软底会变成「框中框」,去掉 */
|
||||
.proto-root .jc-report.editing .jc-mailrow { background: transparent; padding: 0; margin-top: 6px; }
|
||||
.proto-root .jc-mailrow > code { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; line-height: 1.5; font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
|
||||
.proto-root .jc-copy { flex: none; width: 22px; height: 22px; display: inline-grid; place-items: center; border: 0; border-radius: 6px; background: #fff; color: var(--ink-4); cursor: pointer; box-shadow: 0 1px 2px rgba(31,35,41,.08); transition: all var(--t-fast); }
|
||||
.proto-root .jc-copy:hover { color: #0aa294; }
|
||||
.proto-root .jc-copy.ok { color: var(--ok); background: var(--ok-soft); box-shadow: none; }
|
||||
/* 客户属性徽章 */
|
||||
.proto-root .jc-tagbig { display: inline-flex; align-items: center; gap: 6px; align-self: flex-start; padding: 7px 14px; border-radius: 999px; font-size: 13.5px; font-weight: 800; line-height: 1; color: var(--tone); background: var(--tone-soft); border: 1px solid var(--tone-line); }
|
||||
.proto-root .jc-fcard-note { font-size: 10.5px; color: var(--ink-4); line-height: 1.55; }
|
||||
.proto-root .jc-fcard-note b { color: var(--ink-2); font-weight: 700; }
|
||||
.proto-root .jc-fcard-note b.bad { color: var(--tone); font-weight: 800; }
|
||||
/* 成交迷你柱:把「台账 6 笔成交」从一句话变成看得见的节奏 */
|
||||
.proto-root .jc-spark { display: flex; align-items: flex-end; gap: 4px; height: 26px; }
|
||||
.proto-root .jc-spark > i { flex: 1 1 0; min-width: 4px; max-width: 15px; border-radius: 3px 3px 1px 1px; background: var(--tone); opacity: .3; transition: opacity var(--t-fast), height var(--t-med); }
|
||||
.proto-root .jc-spark > i:last-child { opacity: 1; }
|
||||
.proto-root .jc-spark:hover > i { opacity: .55; }
|
||||
.proto-root .jc-spark:hover > i:last-child { opacity: 1; }
|
||||
.proto-root .jc-spark.empty { align-items: stretch; }
|
||||
.proto-root .jc-spark.empty > i { height: 100%; opacity: 1; background: repeating-linear-gradient(45deg, var(--tone-soft) 0 5px, transparent 5px 10px); border: 1px dashed var(--tone-line); border-radius: 5px; }
|
||||
/* 有效 PN 计数 */
|
||||
.proto-root .jc-count { display: flex; align-items: baseline; gap: 6px; }
|
||||
.proto-root .jc-count > b { font-family: var(--mono); font-size: 32px; font-weight: 800; line-height: .95; color: var(--ink); letter-spacing: -.04em; }
|
||||
.proto-root .jc-count > span { font-size: 11px; color: var(--ink-4); }
|
||||
/* 分段刻度条:一格 = 一条 PN,缺必填的那格直接标红——比一根实心进度条信息量大 */
|
||||
.proto-root .jc-segmeter { display: flex; gap: 3px; }
|
||||
.proto-root .jc-segmeter > i { flex: 1; height: 7px; border-radius: 2px; background: var(--ok); transition: background var(--t-fast); }
|
||||
.proto-root .jc-segmeter > i.bad { background: var(--danger); }
|
||||
.proto-root .jc-lockhint { display: inline-flex; align-items: center; gap: 4px; font-size: 10px; color: var(--ink-4); }
|
||||
|
||||
/* ── 占位胶囊:必填=红虚线,选填=灰虚线;**都带文字**,不靠颜色单打 ── */
|
||||
.proto-root .jc-ph { display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700; line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed; }
|
||||
.proto-root .jc-ph.req { color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
|
||||
.proto-root .jc-ph.opt { color: var(--ink-4); background: var(--soft); border-color: var(--line); }
|
||||
|
||||
/* ── 分段控件(新客户 / 老客户)── */
|
||||
.proto-root .jc-seg { display: inline-flex; padding: 2px; gap: 2px; border: 1px solid var(--cardline); border-radius: 9px; background: var(--soft); align-self: flex-start; }
|
||||
.proto-root .jc-seg button { border: 0; border-radius: 7px; padding: 6px 13px; font: inherit; font-size: 11.5px; font-weight: 700; color: var(--ink-3); background: transparent; cursor: pointer; transition: all var(--t-fast); }
|
||||
.proto-root .jc-seg button:hover:not(.on) { color: var(--brand); }
|
||||
.proto-root .jc-seg button.on { background: #fff; color: var(--ink); box-shadow: 0 1px 3px rgba(31,35,41,.12); }
|
||||
|
||||
/* ── 表:读态与编辑态共用骨架 ─────────────────────────────────────── */
|
||||
.proto-root .jc-tbl-wrap { overflow-x: auto; border: 1px solid var(--line-soft); border-radius: 10px; }
|
||||
.proto-root .jc-tbl-in { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; }
|
||||
.proto-root .jc-tbl-in th { background: var(--soft); text-align: left; padding: 8px 9px; font-size: 10.5px; font-weight: 800; letter-spacing: .03em; color: var(--ink-3); white-space: nowrap; border-bottom: 1px solid var(--line-soft); }
|
||||
.proto-root .jc-tbl-in th.req::after { content: "*"; margin-left: 1px; color: var(--danger); font-size: 10px; }
|
||||
.proto-root .jc-tbl-in td { padding: 4px 5px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
|
||||
.proto-root .jc-tbl-in tr:last-child td { border-bottom: 0; }
|
||||
.proto-root .jc-tbl-in tbody tr:hover td { background: #f7f9fd; }
|
||||
/* 缺必填的整行浅红打底 + 行首红标——不用找红胶囊,一眼扫到是哪几行 */
|
||||
.proto-root .jc-tbl-in tr.miss td { background: #fffbfb; }
|
||||
.proto-root .jc-tbl-in tbody tr.miss:hover td { background: #fff5f5; }
|
||||
.proto-root .jc-tbl-in td.jc-rn { position: relative; width: 38px; padding: 4px 6px 4px 13px; white-space: nowrap; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
|
||||
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); font-weight: 800; }
|
||||
/* 行首红标只在表态用 ::after —— 卡片态的 ::after 被「第 N 条」占着,不能抢 */
|
||||
@media (min-width: 961px) {
|
||||
.proto-root .jc-tbl-in tr.miss td.jc-rn::after { content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger); }
|
||||
}
|
||||
.proto-root .jc-rndot { display: inline-block; width: 5px; height: 5px; margin-left: 4px; border-radius: 999px; background: var(--danger); vertical-align: 2px; }
|
||||
/* 读态单元格 */
|
||||
.proto-root .jc-vcell { display: block; padding: 6px 6px; font-size: 12px; color: var(--ink); line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-vcell.mono { font-family: var(--mono); font-size: 11px; color: var(--ink-2); }
|
||||
.proto-root .jc-vcell.num { font-family: var(--mono); font-size: 11.5px; text-align: right; }
|
||||
/* 编辑态单元格:安静的电子表格 */
|
||||
.proto-root .jc-cell { width: 100%; min-width: 64px; border: 1px solid var(--line); border-radius: 6px; padding: 6px 7px; font: inherit; font-size: 12px; color: var(--ink); background: #fff; transition: border-color var(--t-fast), box-shadow var(--t-fast); }
|
||||
.proto-root .jc-cell.mono { font-family: var(--mono); font-size: 11px; }
|
||||
.proto-root .jc-cell.num { text-align: right; font-family: var(--mono); font-size: 11.5px; }
|
||||
.proto-root .jc-cell::placeholder { color: var(--ink-4); }
|
||||
.proto-root .jc-cell:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-wash); outline: none; }
|
||||
.proto-root .jc-cell.req-empty { border-color: #f3b6b3; background: #fffafa; }
|
||||
.proto-root .jc-cell.req-empty::placeholder { color: var(--danger); }
|
||||
/* 备注这类长文本用文本域:能换行、随内容长高,不裁字 */
|
||||
.proto-root textarea.jc-cell { display: block; resize: vertical; overflow: hidden; min-height: 31px; line-height: 1.55; }
|
||||
|
||||
/* 编辑态的整表提示带 */
|
||||
.proto-root .jc-editbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); border: 1px solid var(--brand-soft); font-size: 11.5px; color: var(--brand-deep); }
|
||||
.proto-root .jc-editbar svg { flex: none; }
|
||||
.proto-root .jc-editbar .sp { flex: 1 1 auto; }
|
||||
|
||||
/* 剔除行说明 */
|
||||
.proto-root .jc-drop { display: flex; align-items: flex-start; gap: 8px; margin-top: 10px; padding: 9px 12px; border-radius: 9px; border: 1px dashed var(--line); background: var(--soft); font-size: 11.5px; color: var(--ink-3); line-height: 1.6; }
|
||||
.proto-root .jc-drop svg { flex: none; margin-top: 2px; color: var(--ink-4); }
|
||||
.proto-root .jc-drop code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
|
||||
|
||||
.proto-root .jc-callout { display: flex; gap: 8px; align-items: flex-start; border-radius: 9px; padding: 11px 13px; font-size: 12.5px; line-height: 1.75; }
|
||||
.proto-root .jc-callout p { margin: 0; }
|
||||
.proto-root .jc-callout svg { flex: none; margin-top: 2px; }
|
||||
.proto-root .jc-callout.t-mute { background: var(--soft); color: var(--ink-2) }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.proto-root .jc-pulse-dot { animation: none; }
|
||||
.proto-root .jc-report, .proto-root .jc-rp-stamp { animation: none; }
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════
|
||||
响应式:宁可另起一行,也不损失完整度
|
||||
≥961px 六列全展开的表(列 min-width 之和 878px,容器有余量,绝不裁字、绝不横滚)
|
||||
≤960px 表 → 卡片:一条 PN 一张卡、一行一字段(标签 / 值),无横滚、无省略
|
||||
≤560px 字段行再拆成上下两行(标签在上、值在下)
|
||||
══════════════════════════════════════════════════════════════════════════ */
|
||||
@media (max-width: 1040px) { .proto-root .jc-four { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 560px) { .proto-root .jc-four { grid-template-columns: 1fr; } }
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.proto-root .jc-tbl-wrap { overflow: visible; border: 0; border-radius: 0; background: transparent; }
|
||||
.proto-root .jc-tbl-in,
|
||||
.proto-root .jc-tbl-in tbody,
|
||||
.proto-root .jc-tbl-in tr,
|
||||
.proto-root .jc-tbl-in td { display: block; width: 100%; }
|
||||
.proto-root .jc-tbl-in thead { display: none; }
|
||||
|
||||
/* 一条 PN = 一张卡 */
|
||||
.proto-root .jc-tbl-in tr { margin-bottom: 10px; padding: 4px 13px 9px; border: 1px solid var(--line-soft); border-radius: 11px; background: #fff; box-shadow: var(--card-shadow); }
|
||||
.proto-root .jc-tbl-in tr:last-child { margin-bottom: 0; }
|
||||
.proto-root .jc-tbl-in tbody tr:hover td { background: transparent; }
|
||||
.proto-root .jc-tbl-in tr.miss { border-color: #f3b6b3; background: #fffbfb; }
|
||||
|
||||
/* 卡头:第 N 条 */
|
||||
.proto-root .jc-tbl-in td.jc-rn { padding: 8px 0 8px; margin-bottom: 4px; border-bottom: 1px solid var(--line-soft); font-family: inherit; font-size: 12px; font-weight: 800; color: var(--ink-2); }
|
||||
.proto-root .jc-tbl-in td.jc-rn::before { content: "第 "; }
|
||||
.proto-root .jc-tbl-in td.jc-rn::after { content: " 条"; }
|
||||
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); }
|
||||
|
||||
/* 字段行:左标签 + 右值,值超长自动折行 */
|
||||
.proto-root .jc-tbl-in td:not(.jc-rn) { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 10px; align-items: center; padding: 6px 0; border-bottom: 1px dashed var(--line-soft); }
|
||||
.proto-root .jc-tbl-in tr td:last-child { border-bottom: 0; padding-bottom: 2px; }
|
||||
.proto-root .jc-tbl-in td:not(.jc-rn)::before {
|
||||
content: attr(data-h); font-size: 10.5px; font-weight: 700; letter-spacing: .02em; color: var(--ink-4); line-height: 1.5;
|
||||
}
|
||||
.proto-root .jc-tbl-in td[data-req="1"]:not(.jc-rn)::before { content: attr(data-h) " *"; }
|
||||
.proto-root .jc-tbl-in .jc-vcell { padding: 0; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-tbl-in .jc-vcell.num { text-align: left; }
|
||||
.proto-root .jc-tbl-in .jc-cell.num { text-align: left; }
|
||||
.proto-root .jc-tbl-in .jc-cell { min-width: 0; }
|
||||
}
|
||||
|
||||
/* 窄屏:落款三件各占一行,动作按钮撑满一行 */
|
||||
@media (max-width: 560px) {
|
||||
.proto-root .jc-rp-acts { margin-left: 0; width: 100%; flex-wrap: wrap; }
|
||||
.proto-root .jc-rp-acts .jc-btn { flex: 1 1 auto; justify-content: center; }
|
||||
.proto-root .jc-hintline { flex: 1 1 100%; }
|
||||
.proto-root .jc-tbl-in td:not(.jc-rn) { grid-template-columns: 1fr; gap: 3px; align-items: start; }
|
||||
}
|
||||
|
||||
|
||||
/* ══ 以下取自 A1-询价受理记录.html(归档件独有的件)══ */
|
||||
/* ── 料号分类:分布条 + 图例 ───────────────────────────────────────── */
|
||||
.proto-root .jc-dist { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 13px 15px; border: 1px solid var(--line-soft); border-radius: 12px; background: linear-gradient(180deg, #fbfcfe, #fff); }
|
||||
.proto-root .jc-distbar { flex: 1 1 320px; min-width: 260px; display: flex; gap: 3px; }
|
||||
.proto-root .jc-distbar > i { height: 30px; border-radius: 4px; display: grid; place-items: center; font-size: 10.5px; font-weight: 800; color: #fff; font-family: var(--mono); }
|
||||
.proto-root .jc-distbar > i.a { background: var(--ok); }
|
||||
.proto-root .jc-distbar > i.b { background: var(--brand); }
|
||||
.proto-root .jc-distbar > i.c { background: var(--warn-accent); }
|
||||
.proto-root .jc-distlg { display: flex; flex-wrap: wrap; gap: 8px 18px; }
|
||||
.proto-root .jc-distlg > span { display: inline-flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--ink-3); }
|
||||
.proto-root .jc-distlg em { width: 10px; height: 10px; border-radius: 3px; }
|
||||
.proto-root .jc-distlg em.a { background: var(--ok); } .proto-root .jc-distlg em.b { background: var(--brand); } .proto-root .jc-distlg em.c { background: var(--warn-accent); }
|
||||
.proto-root .jc-distlg b { font-family: var(--mono); font-weight: 800; color: var(--ink); }
|
||||
|
||||
/* 类别徽 A / B / C */
|
||||
.proto-root .jc-tbadge { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 6px; font-family: var(--mono); font-size: 11px; font-weight: 800; line-height: 1; color: #fff; }
|
||||
.proto-root .jc-tbadge.a { background: var(--ok); } .proto-root .jc-tbadge.b { background: var(--brand); } .proto-root .jc-tbadge.c { background: var(--warn-accent); }
|
||||
|
||||
/* ── 结论与去向 ─────────────────────────────────────────────────────────
|
||||
G2 是**分支网关**:三条互斥条件按序求值,只命中一条、只落一个活动。
|
||||
所以这里不是三张并列的去向卡,而是一张**判定表 + 一条结论**。 */
|
||||
/* 判定表:一行一条规则,命中行上色留痕(DMN 口径) */
|
||||
.proto-root .jc-dmn { border: 1px solid var(--line-soft); border-radius: 12px; overflow: hidden; }
|
||||
.proto-root .jc-dmn-h, .proto-root .jc-dmn-r { display: grid; grid-template-columns: 40px minmax(0, 1fr) 170px 200px; align-items: center; gap: 12px; padding: 9px 14px; }
|
||||
.proto-root .jc-dmn-h { background: var(--soft); border-bottom: 1px solid var(--line-soft); font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); }
|
||||
.proto-root .jc-dmn-r { font-size: 12px; color: var(--ink-4); background: #fff; }
|
||||
.proto-root .jc-dmn-r + .jc-dmn-r { border-top: 1px solid var(--line-soft); }
|
||||
.proto-root .jc-dmn-i { font-family: var(--mono); font-size: 11px; font-weight: 800; }
|
||||
.proto-root .jc-dmn-c { line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-dmn-t { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; }
|
||||
.proto-root .jc-dmn-t code { font-family: var(--mono); font-size: 10.5px; font-weight: 800; color: var(--ink-4); background: var(--soft); border: 1px solid var(--line); border-radius: 5px; padding: 2px 6px; }
|
||||
.proto-root .jc-dmn-v { font-size: 11px; line-height: 1.55; }
|
||||
/* 命中行:上色 + 左缘棱 + 加粗 */
|
||||
.proto-root .jc-dmn-r.hit { position: relative; background: var(--tone-soft); color: var(--ink-2); }
|
||||
.proto-root .jc-dmn-r.hit::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--tone); }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-i { color: var(--tone); }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-c { color: var(--ink); }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-c b { color: var(--tone); font-weight: 800; }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-t { color: var(--ink); }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-t code { color: #fff; background: var(--tone); border-color: var(--tone); }
|
||||
.proto-root .jc-dmn-v.y { display: inline-flex; align-items: center; gap: 5px; font-weight: 800; color: var(--tone); }
|
||||
.proto-root .jc-dmn-v.n { color: var(--ink-4); }
|
||||
/* 色调随命中类别 */
|
||||
.proto-root .jc-dmn.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.10); }
|
||||
.proto-root .jc-dmn.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.09); }
|
||||
.proto-root .jc-dmn.k-c { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.11); }
|
||||
|
||||
/* 结论横幅:命中条件 → 唯一去向 */
|
||||
.proto-root .jc-verdict {
|
||||
position: relative; overflow: hidden; display: flex; align-items: stretch; flex-wrap: wrap; gap: 16px;
|
||||
margin-top: 11px; padding: 16px 18px; border: 1px solid var(--tone-line); border-radius: 13px;
|
||||
background: linear-gradient(105deg, var(--tone-soft), #fff 62%);
|
||||
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 10px 24px -16px rgba(31,35,41,.4);
|
||||
--tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.14); --tone-line: #ffd9a8;
|
||||
}
|
||||
.proto-root .jc-verdict.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.13); --tone-line: #bfedb4; }
|
||||
.proto-root .jc-verdict.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft); }
|
||||
.proto-root .jc-verdict::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 5px; background: var(--tone); }
|
||||
.proto-root .jc-verdict > * { position: relative; z-index: 1; }
|
||||
.proto-root .jc-vd-l { flex: 1 1 300px; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
|
||||
.proto-root .jc-vd-k { font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
|
||||
.proto-root .jc-vd-cond { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 15px; font-weight: 800; color: var(--ink); }
|
||||
.proto-root .jc-vd-why { margin: 0; font-size: 11.5px; line-height: 1.7; color: var(--ink-3); overflow-wrap: anywhere; }
|
||||
.proto-root .jc-vd-why code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
|
||||
.proto-root .jc-vd-arrow { flex: none; align-self: center; color: var(--tone); }
|
||||
.proto-root .jc-vd-r { flex: 1 1 260px; min-width: 0; display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-radius: 11px; background: #fff; border: 1px solid var(--tone-line); }
|
||||
.proto-root .jc-vd-node { display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 800; color: var(--ink); }
|
||||
.proto-root .jc-vd-node code { font-family: var(--mono); font-size: 12px; font-weight: 800; color: #fff; background: var(--tone); border-radius: 6px; padding: 3px 8px; }
|
||||
.proto-root .jc-vd-who { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--ink-2); }
|
||||
.proto-root .jc-vd-who > i { width: 20px; height: 20px; border-radius: 999px; display: inline-grid; place-items: center; font-style: normal; font-size: 10px; font-weight: 700; color: #fff; background: var(--tone); }
|
||||
.proto-root .jc-vd-next { margin: 0; padding-top: 8px; border-top: 1px dashed var(--line-soft); font-size: 10.5px; line-height: 1.65; color: var(--ink-4); }
|
||||
.proto-root .jc-vd-next b { color: var(--ink-2); font-weight: 700; }
|
||||
/* 命中分支携带的条目 */
|
||||
.proto-root .jc-pnlist { display: flex; flex-wrap: wrap; gap: 5px; margin: 0; padding: 0; list-style: none; }
|
||||
.proto-root .jc-pnlist > li { display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 999px; background: #fff; border: 1px solid var(--tone-line); font-size: 11px; color: var(--ink-2); overflow-wrap: anywhere; }
|
||||
.proto-root .jc-pnlist > li > i { flex: none; width: 5px; height: 5px; border-radius: 999px; background: var(--tone); }
|
||||
.proto-root .jc-pnlist code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
|
||||
|
||||
/* ── 落地动作 ───────────────────────────────────────────────────────── */
|
||||
.proto-root .jc-acts { display: flex; flex-direction: column; gap: 0; border: 1px solid var(--line-soft); border-radius: 11px; overflow: hidden; }
|
||||
.proto-root .jc-act { display: flex; align-items: flex-start; gap: 9px; padding: 10px 13px; font-size: 12px; color: var(--ink-2); line-height: 1.6; background: #fff; }
|
||||
.proto-root .jc-act + .jc-act { border-top: 1px solid var(--line-soft); }
|
||||
.proto-root .jc-act > i { flex: none; width: 18px; height: 18px; margin-top: 1px; border-radius: 999px; display: inline-grid; place-items: center; color: #fff; background: var(--ok); }
|
||||
.proto-root .jc-act b { font-weight: 700; color: var(--ink); }
|
||||
.proto-root .jc-act span { color: var(--ink-4); }
|
||||
.proto-root .jc-act code { font-family: var(--mono); font-size: 10.5px; color: var(--brand-deep); background: var(--brand-wash); border-radius: 4px; padding: 1px 5px; }
|
||||
|
||||
|
||||
.proto-root .jc-done-tag { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; flex: none; padding: 6px 13px; border-radius: 999px; font-size: 11.5px; font-weight: 800; color: var(--ok); background: #fff; border: 1px solid #bfedb4; }
|
||||
.proto-root .jc-vcell.dim { color: var(--ink-4); }
|
||||
.proto-root .jc-tbl-in td.jc-ty { width: 44px; text-align: center; }
|
||||
.proto-root .jc-tbl-in td.jc-ty { width: auto; text-align: left; }
|
||||
`;
|
||||
|
||||
/**
|
||||
* 图标精灵 —— 与样式表同一出处,两份目标件的 `<symbol>` 取并集。
|
||||
* 沙箱里取不到图标字体,所以图标一律内联;`<use href="#i-xxx">` 引的就是这一坨。
|
||||
*/
|
||||
export const PRD_SPRITE = `<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs><symbol id="i-alert" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></symbol><symbol id="i-arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol><symbol id="i-boxes" viewBox="0 0 24 24"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></symbol><symbol id="i-building" viewBox="0 0 24 24"><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/><path d="M10 18h4"/></symbol><symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol><symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol><symbol id="i-file-check" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="m9 15 2 2 4-4"/></symbol><symbol id="i-file-text" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></symbol><symbol id="i-git-branch" viewBox="0 0 24 24"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></symbol><symbol id="i-history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></symbol><symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></symbol><symbol id="i-lock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol><symbol id="i-mail-open" viewBox="0 0 24 24"><path d="M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0z"/><path d="m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10"/></symbol><symbol id="i-pen" viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z"/></symbol><symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol><symbol id="i-sheet" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M8 13h2"/><path d="M14 13h2"/><path d="M8 17h2"/><path d="M14 17h2"/></symbol><symbol id="i-shield-check" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></symbol><symbol id="i-user" viewBox="0 0 24 24"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/></symbol><symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></symbol></defs></svg>`;
|
||||
@@ -1,67 +1,579 @@
|
||||
/**
|
||||
* A4 视图模版 —— 定价的三屏:取价基准 / 定价与门禁 / 报价草案待放行(AM 07)。
|
||||
* 定价这件事最需要「把判据摊开」:人签的不是一个数字,是这个数字的来路。
|
||||
* A4 成本定价的 HTML 报告模版 —— **活动自己持有的资产**,随包版本冻结。
|
||||
*
|
||||
* 两个局面各一张(与 A2/A3 同款口径):
|
||||
* · `pricing-worksheet` **定价工作单** —— 算价与 BD 核价期间摆它:逐颗料一张定价档案卡
|
||||
* (题面在左、定价推演在右)、利润模型 P-01 的毛利档、底价门禁 P-02 的三档尺、
|
||||
* 填回工作表就长在同一屏;
|
||||
* · `pricing-roster` **报价草案清单** —— 超限拍板、放行与归档摆它:整份草案只读,
|
||||
* 门禁分布与落地动作在上面。
|
||||
*
|
||||
* 「1:1」的落点是**每份件与它的办结快照**(在办可编辑 ↔ 回执收下那一刻的 finalHtml),
|
||||
* 不是全程只许一张:作业的件与收口的件是两件工作两份件(规范姊妹篇 §1)。
|
||||
*
|
||||
* 版面语言沿用 A1 从 PRD 目标件机械抽取的那套(`prd-style.ts` 原样拷入)——
|
||||
* A4 没有自己的 PRD 目标件,与 A2/A3 同一处置:沿用家族版面自造。
|
||||
*
|
||||
* 取值三纪律:顶层只取汇总列与单值列;逐条走 `mine:quote_pricing.draft`;
|
||||
* 列名逐字照 `declarations.ledger` 抄。**模版不算不判** —— 缺价红带、门禁徽、分布条
|
||||
* 全部由基座算好的数经 `data-class-of` 投影,判断留在样式表。
|
||||
*/
|
||||
import { kvCard, noteCard, tableCard, viewShell } from '@sdk';
|
||||
import { PRD_CSS, PRD_SPRITE } from './prd-style.ts';
|
||||
|
||||
export const PRICING_BASIS = viewShell({
|
||||
title: '取价基准',
|
||||
kicker: 'A4 · 成本定价',
|
||||
tone: 'brand',
|
||||
/* ==================== 绑定期补的样式(照 A1/A3 的 BIND_CSS,地址换成本活动)==================== */
|
||||
|
||||
const BIND_CSS = `
|
||||
/* 空占位三级(2026-08-02 全局口径):必填红 / 建议填橘 / 选填灰 —— 值真的空才轮到 */
|
||||
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after,
|
||||
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after,
|
||||
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after {
|
||||
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
|
||||
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
|
||||
}
|
||||
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after { content: "必填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
|
||||
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after { content: "建议填"; color: var(--warn-accent); background: var(--warn-soft); border-color: #f0cf9a; }
|
||||
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after { content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line); }
|
||||
/* 归档件里空格子就是「没有」,不再摆「你还能填」的胶囊 */
|
||||
.proto-root .jc-tbl-in td.dimempty > .jc-vcell:empty::after { content: "—"; color: var(--ink-4); border: 0; background: none; padding: 0; }
|
||||
|
||||
/* 缺拟报价的整行浅红 + 行首红条 —— 原型的 tr.miss,这里由 :has() 认 */
|
||||
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td { background: #fffbfb; }
|
||||
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty):hover td { background: #fff5f5; }
|
||||
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn { color: var(--danger); font-weight: 800; position: relative; }
|
||||
@media (min-width: 1001px) {
|
||||
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn::after {
|
||||
content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
/* 序号由 CSS 计数器排 */
|
||||
.proto-root .jc-tbl-in tbody { counter-reset: rn; }
|
||||
.proto-root .jc-tbl-in td.jc-rn::before { counter-increment: rn; content: counter(rn); }
|
||||
@media (max-width: 1000px) {
|
||||
.proto-root .jc-tbl-in td.jc-rn::before { content: "第 " counter(rn) " 条"; }
|
||||
.proto-root .jc-tbl-in td.jc-rn::after { content: none; }
|
||||
}
|
||||
|
||||
/* 就地改:可改格子渲了只读与输入两份,默认锁着,表上加 on 才换孪生件 */
|
||||
.proto-root .jc-cell { display: none; }
|
||||
.proto-root .jc-tbl-in.on td > .jc-vcell,
|
||||
.proto-root .jc-tbl-in.on td > .jc-tbadge { display: none; }
|
||||
.proto-root .jc-tbl-in.on td > .jc-cell { display: block; }
|
||||
/* 类徽与门禁徽不进编辑态(承继与引擎产物),on 时也照旧摆着 */
|
||||
.proto-root .jc-tbl-in.on td > .jc-obadge, .proto-root .jc-tbl-in.on td > .jc-gbadge { display: inline-flex; }
|
||||
/* 数值列与表头同侧左对齐(一行一个料,彼此不相加) */
|
||||
.proto-root .jc-vcell.num, .proto-root .jc-cell.num { text-align: left; }
|
||||
/* 编辑态下必填空格子:红框 + 整行浅红(由 :placeholder-shown 认) */
|
||||
.proto-root .jc-tbl-in.on td.req > .jc-cell:placeholder-shown { border-color: #f3b6b3; background: #fffafa; }
|
||||
.proto-root .jc-tbl-in.on tbody tr:has(td.req > .jc-cell:placeholder-shown) td { background: #fffbfb; }
|
||||
|
||||
/* 铺满工作区 */
|
||||
.jc-stage { max-width: none; padding: 0 0 4px; }
|
||||
.proto-root .jc-report { border: 0; border-radius: 0; box-shadow: none; }
|
||||
|
||||
/* 那道闸 —— 默认「已锁定 + 编辑」,开了变成「取消 / 保存」;只在可改屏现身 */
|
||||
.proto-root .jc-editgate { display: none; }
|
||||
html.editable .proto-root .jc-editgate {
|
||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
margin-bottom: 10px; padding: 8px 12px; border-radius: 9px;
|
||||
background: var(--soft); border: 1px solid var(--line-soft);
|
||||
font-size: 11.5px; color: var(--ink-3);
|
||||
}
|
||||
.proto-root .jc-editgate-t { display: inline-flex; align-items: center; gap: 6px; flex: 1 1 auto; min-width: 0; }
|
||||
.proto-root .jc-editgate-t b { color: var(--ink-2); font-weight: 700; }
|
||||
.proto-root .jc-egbtn {
|
||||
flex: none; display: inline-flex; align-items: center; gap: 4px; cursor: pointer;
|
||||
border: 1px solid var(--line); border-radius: 7px; padding: 5px 11px;
|
||||
font: inherit; font-size: 11.5px; font-weight: 700; color: var(--ink-2); background: #fff;
|
||||
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
|
||||
}
|
||||
.proto-root .jc-egbtn:hover { border-color: var(--brand); color: var(--brand); }
|
||||
.proto-root .jc-egbtn.save { color: #fff; background: var(--brand); border-color: var(--brand); }
|
||||
.proto-root .jc-egbtn.save:hover { background: var(--brand-deep); border-color: var(--brand-deep); }
|
||||
.proto-root .jc-editgate .jc-egbtn.ghost, .proto-root .jc-editgate .jc-egbtn.save { display: none; }
|
||||
.proto-root .jc-editgate.on { background: var(--brand-wash); border-color: var(--brand-soft); color: var(--brand-deep); }
|
||||
.proto-root .jc-editgate.on .jc-egbtn.open { display: none; }
|
||||
.proto-root .jc-editgate.on .jc-egbtn.ghost, .proto-root .jc-editgate.on .jc-egbtn.save { display: inline-flex; }
|
||||
.proto-root .jc-editgate.on .jc-editgate-t { visibility: hidden; }
|
||||
|
||||
/* 缺价红带 —— 只在可改屏出,一格不缺自动收(v-0 那一档 display:none) */
|
||||
.proto-root .jc-gapbar { display: none; }
|
||||
html.editable .proto-root .jc-gapbar {
|
||||
display: flex; align-items: flex-start; gap: 8px;
|
||||
margin: 0 0 10px; padding: 9px 12px; border-radius: 9px;
|
||||
background: #fff5f5; border: 1px solid #f3b6b3; color: var(--danger);
|
||||
font-size: 12px; line-height: 1.65;
|
||||
}
|
||||
.proto-root .jc-gapbar svg { flex: 0 0 auto; margin-top: 2px; }
|
||||
.proto-root .jc-gapbar b { font-weight: 800; }
|
||||
.proto-root .jc-gapbar span { color: #9a3b36; }
|
||||
html.editable .proto-root .jc-gapbar.v-0 { display: none; }
|
||||
|
||||
/* 那条「怎么改」的提示只在开了闸之后说 */
|
||||
.proto-root .jc-editbar { display: none; }
|
||||
.proto-root .jc-editgate.on + .jc-editbar { display: flex; }
|
||||
|
||||
/* 增删行 */
|
||||
.proto-root .jc-rowdel { display: none; }
|
||||
.proto-root .jc-tbl-in.on td.jc-rn::before { content: none; }
|
||||
.proto-root .jc-tbl-in.on .jc-rowdel {
|
||||
display: block; width: 20px; height: 20px; margin: 0 auto; cursor: pointer;
|
||||
border: 1px solid var(--line); border-radius: 6px; padding: 0;
|
||||
font: inherit; font-size: 13px; line-height: 1; color: var(--ink-3); background: #fff;
|
||||
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
|
||||
}
|
||||
.proto-root .jc-tbl-in.on .jc-rowdel:hover { border-color: var(--danger); color: var(--danger); background: #fff6f6; }
|
||||
.proto-root .jc-tbl-in.on tr.jc-row-gone td { background: #fff6f6; opacity: .55; }
|
||||
.proto-root .jc-tbl-in.on tr.jc-row-gone .jc-cell { text-decoration: line-through; color: var(--ink-3); }
|
||||
.proto-root .jc-tbl-in.on tr.jc-row-gone .jc-rowdel { border-color: var(--danger); color: var(--danger); background: #fff; }
|
||||
.proto-root .jc-tbl-in.on tr.jc-row-new td { background: #f2fbf7; }
|
||||
.proto-root .jc-addrow { display: none; }
|
||||
html.editable .proto-root .jc-tbl-wrap:has(table.on) + .jc-addrow {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
margin: -2px 0 12px; padding: 7px 12px; border-radius: 9px;
|
||||
background: var(--soft); border: 1px dashed var(--line);
|
||||
}
|
||||
.proto-root .jc-addrow em { font-style: normal; font-size: 11px; color: var(--ink-3); }
|
||||
|
||||
/* 类徽按值上色(承继上游的三分类) */
|
||||
.proto-root .jc-tbadge { background: var(--ink-4); }
|
||||
.proto-root .jc-tbadge.v-a { background: var(--ok); }
|
||||
.proto-root .jc-tbadge.v-b { background: var(--brand); }
|
||||
.proto-root .jc-tbadge.v-c { background: var(--warn-accent); }
|
||||
.proto-root .jc-segmeter > i.v-pass { background: var(--ok); }
|
||||
.proto-root .jc-segmeter > i.v-note { background: var(--warn-accent); }
|
||||
.proto-root .jc-segmeter > i.v-block { background: var(--danger); }
|
||||
|
||||
/* 门禁徽:直接放行绿 / 附理由橙 / 拦截红 —— 值(PASS/NOTE/BLOCK)是机器码,人读名交给样式表 */
|
||||
.proto-root .jc-gbadge {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 56px; height: 20px; padding: 0 7px; border-radius: 6px;
|
||||
font-size: 10px; font-weight: 800; letter-spacing: .02em;
|
||||
color: var(--ink-4); border: 1.5px solid var(--ink-4); background: #fff;
|
||||
}
|
||||
.proto-root .jc-gbadge::after { content: "待判"; }
|
||||
.proto-root .jc-gbadge.v-pass { color: var(--ok); border-color: var(--ok); }
|
||||
.proto-root .jc-gbadge.v-pass::after { content: "直接放行"; }
|
||||
.proto-root .jc-gbadge.v-note { color: var(--warn-accent); border-color: var(--warn-accent); }
|
||||
.proto-root .jc-gbadge.v-note::after { content: "附理由"; }
|
||||
.proto-root .jc-gbadge.v-block { color: var(--danger); border-color: var(--danger); }
|
||||
.proto-root .jc-gbadge.v-block::after { content: "拦截待拍板"; }
|
||||
|
||||
/* 分布条:三段按条数分宽 —— 条数投影成 v-<N>,梯子取宽;零条那段自己消失 */
|
||||
.proto-root .jc-distbar > i { flex: 24; min-width: 34px; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.proto-root .jc-distbar > i.v-0, .proto-root .jc-distbar > i:not([class*=" v-"]) { display: none; }
|
||||
.proto-root .jc-distbar > i.v-1 { flex: 1 } .proto-root .jc-distbar > i.v-2 { flex: 2 }
|
||||
.proto-root .jc-distbar > i.v-3 { flex: 3 } .proto-root .jc-distbar > i.v-4 { flex: 4 }
|
||||
.proto-root .jc-distbar > i.v-5 { flex: 5 } .proto-root .jc-distbar > i.v-6 { flex: 6 }
|
||||
.proto-root .jc-distbar > i.v-7 { flex: 7 } .proto-root .jc-distbar > i.v-8 { flex: 8 }
|
||||
.proto-root .jc-distbar > i.v-9 { flex: 9 } .proto-root .jc-distbar > i.v-10 { flex: 10 }
|
||||
.proto-root .jc-distbar > i.v-11 { flex: 11 } .proto-root .jc-distbar > i.v-12 { flex: 12 }
|
||||
.proto-root .jc-distbar > i.v-13 { flex: 13 } .proto-root .jc-distbar > i.v-14 { flex: 14 }
|
||||
.proto-root .jc-distbar > i.v-15 { flex: 15 } .proto-root .jc-distbar > i.v-16 { flex: 16 }
|
||||
.proto-root .jc-distbar > i.v-17 { flex: 17 } .proto-root .jc-distbar > i.v-18 { flex: 18 }
|
||||
.proto-root .jc-distbar > i.v-19 { flex: 19 } .proto-root .jc-distbar > i.v-20 { flex: 20 }
|
||||
.proto-root .jc-distbar > i.p { background: var(--ok); }
|
||||
.proto-root .jc-distbar > i.n { background: var(--warn-accent); }
|
||||
.proto-root .jc-distbar > i.k { background: var(--danger); }
|
||||
.proto-root .jc-distbar > i.p::before { content: "直接放行 · "; }
|
||||
.proto-root .jc-distbar > i.n::before { content: "附理由 · "; }
|
||||
.proto-root .jc-distbar > i.k::before { content: "拦截 · "; }
|
||||
.proto-root .jc-logo svg, .proto-root .jc-face svg, .proto-root .jc-rp-ava svg { width: 17px; height: 17px; }
|
||||
|
||||
/* ── 定价档案卡(工作单独有,照 A3 询价档案卡的骨架)──────────────────────
|
||||
一颗料一张卡,data-rows 逐行复制:左题面、右推演。
|
||||
形态按门禁处置分档(data-class-of):拦截档整卡描红,附理由档描橙 —— 判断在样式表。 */
|
||||
.proto-root .jc-selcards { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
|
||||
.proto-root .jc-selcard { border: 1px solid var(--line); border-radius: 11px; background: #fff; overflow: hidden; }
|
||||
.proto-root .jc-selcard.v-note { border-color: #f0cf9a; box-shadow: 0 1px 0 rgba(191,132,32,.06); }
|
||||
.proto-root .jc-selcard.v-block { border-color: #f3b6b3; box-shadow: 0 1px 0 rgba(207,64,58,.08); }
|
||||
.proto-root .jc-selcard-h {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
padding: 9px 14px; background: var(--soft); border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
.proto-root .jc-selcard.v-block .jc-selcard-h { background: var(--danger-soft); border-bottom-color: #f3b6b3; }
|
||||
.proto-root .jc-selcard.v-note .jc-selcard-h { background: var(--warn-soft); border-bottom-color: #f0cf9a; }
|
||||
.proto-root .jc-selcard-h code { font-size: 13px; font-weight: 800; color: var(--ink); }
|
||||
.proto-root .jc-selcard-h .why { flex: 1 1 auto; min-width: 0; font-size: 11px; color: var(--ink-3); }
|
||||
.proto-root .jc-selcard-b { display: grid; grid-template-columns: 1fr 1fr; gap: 0; }
|
||||
@media (max-width: 1000px) { .proto-root .jc-selcard-b { grid-template-columns: 1fr; } }
|
||||
.proto-root .jc-selcol { padding: 11px 14px; }
|
||||
.proto-root .jc-selcol + .jc-selcol { border-left: 1px solid var(--line-soft); }
|
||||
@media (max-width: 1000px) { .proto-root .jc-selcol + .jc-selcol { border-left: 0; border-top: 1px solid var(--line-soft); } }
|
||||
.proto-root .jc-selcol h5 { margin: 0 0 7px; font-size: 10.5px; font-weight: 800; letter-spacing: .08em; color: var(--ink-3); }
|
||||
.proto-root .jc-kvrow { display: flex; gap: 8px; padding: 3px 0; font-size: 12px; line-height: 1.55; }
|
||||
.proto-root .jc-kvrow i { flex: 0 0 84px; font-style: normal; color: var(--ink-4); font-size: 11px; padding-top: 1px; }
|
||||
.proto-root .jc-kvrow span { flex: 1 1 auto; min-width: 0; color: var(--ink); overflow-wrap: anywhere; }
|
||||
.proto-root .jc-kvrow span:empty::after { content: "—"; color: var(--ink-4); }
|
||||
.proto-root .jc-selcard .sc-req:empty::after {
|
||||
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
|
||||
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
|
||||
content: "必填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3;
|
||||
}
|
||||
.proto-root .jc-selcard .sc-sug:empty::after {
|
||||
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
|
||||
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
|
||||
content: "建议填"; color: var(--warn-accent); background: var(--warn-soft); border-color: #f0cf9a;
|
||||
}
|
||||
.proto-root .jc-selcard .sc-opt:empty::after {
|
||||
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
|
||||
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
|
||||
content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line);
|
||||
}
|
||||
|
||||
/* 两把尺(静态规则表 —— 尺挂在墙上,不是这一单的数据) */
|
||||
.proto-root .jc-graderule { border: 1px solid var(--line); border-radius: 10px; overflow: hidden; margin-bottom: 12px; }
|
||||
.proto-root .jc-graderule-r { display: flex; gap: 12px; align-items: flex-start; padding: 9px 13px; font-size: 12px; line-height: 1.6; }
|
||||
.proto-root .jc-graderule-r + .jc-graderule-r { border-top: 1px solid var(--line-soft); }
|
||||
.proto-root .jc-graderule-r p { margin: 0; color: var(--ink-2); }
|
||||
.proto-root .jc-graderule-r p b { color: var(--ink); }
|
||||
.proto-root .jc-band {
|
||||
flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; margin-top: 1px;
|
||||
min-width: 74px; height: 20px; padding: 0 8px; border-radius: 6px;
|
||||
font-size: 10.5px; font-weight: 800; letter-spacing: .04em; border: 1.5px solid; background: #fff;
|
||||
}
|
||||
.proto-root .jc-band.ga { color: var(--ok); border-color: var(--ok); }
|
||||
.proto-root .jc-band.gb { color: var(--brand); border-color: var(--brand); }
|
||||
.proto-root .jc-band.gc { color: var(--warn-accent); border-color: var(--warn-accent); }
|
||||
.proto-root .jc-band.gp { color: var(--ok); border-color: var(--ok); }
|
||||
.proto-root .jc-band.gn { color: var(--warn-accent); border-color: var(--warn-accent); }
|
||||
.proto-root .jc-band.gk { color: var(--danger); border-color: var(--danger); }
|
||||
|
||||
/* 一个价怎么长出来的:成本 → 底价线 → 拟报价,内联线稿 SVG 走位图 */
|
||||
.proto-root .jc-priceline { display: flex; align-items: center; gap: 0; margin: 2px 0 14px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 11px; background: var(--soft); }
|
||||
.proto-root .jc-plnode { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 3px; align-items: flex-start; }
|
||||
.proto-root .jc-plnode b { font-size: 10.5px; font-weight: 800; letter-spacing: .06em; color: var(--ink-3); }
|
||||
.proto-root .jc-plnode span { font-size: 12px; color: var(--ink-2); line-height: 1.5; }
|
||||
.proto-root .jc-plarrow { flex: 0 0 auto; padding: 0 10px; color: var(--brand-soft); }
|
||||
@media (max-width: 1000px) { .proto-root .jc-priceline { flex-direction: column; align-items: stretch; gap: 8px; } .proto-root .jc-plarrow { display: none; } }
|
||||
`;
|
||||
|
||||
/** 一枚图标 —— 与原型同一写法 */
|
||||
const ico = (name: string, size = 11): string =>
|
||||
`<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-${name}"/></svg>`;
|
||||
|
||||
/** 三层壳照原型:少一层样式表全失效 */
|
||||
const doc = (input: { badge: string; kicker: string; title: string; titleRef: string; state: string; body: string; foot: string }): string =>
|
||||
`<style>${PRD_CSS}${BIND_CSS}</style>
|
||||
${PRD_SPRITE}
|
||||
<div class="proto-root"><div class="app-shell"><div class="jc-stage">
|
||||
<article class="jc-report">
|
||||
<header class="jc-rp-head">
|
||||
<div class="jc-rp-badge">${ico(input.badge, 17)}</div>
|
||||
<div class="jc-rp-tit">
|
||||
<em>${input.kicker}</em>
|
||||
<h3>${input.title} <span data-slot="${input.titleRef}"></span></h3>
|
||||
</div>
|
||||
${input.state}
|
||||
</header>
|
||||
<div class="jc-rp-bd">${input.body}</div>
|
||||
<footer class="jc-rp-foot">${input.foot}</footer>
|
||||
</article>
|
||||
</div></div></div>`;
|
||||
|
||||
const part = (label: string, aside = '', tone = ''): string =>
|
||||
`<div class="jc-rp-part"><b>${label}</b><i class="ln"></i>${aside === '' ? '' : `<em class="${tone}">${aside}</em>`}</div>`;
|
||||
|
||||
const sign = (note: string, tag = ''): string =>
|
||||
`<div class="jc-rp-sign">
|
||||
<span class="jc-rp-ava">${ico('user', 15)}</span>
|
||||
<div><b data-slot="case:owner"></b><span>${note}</span></div>
|
||||
</div>${tag === '' ? '' : `<span class="jc-done-tag">${ico('git-branch', 13)}${tag}</span>`}`;
|
||||
|
||||
/* ==================== 组成件 ==================== */
|
||||
|
||||
/** 要素四卡:客户 / 待定价 / 一格一料的门禁刻度 / 定价的尺 */
|
||||
const FOUR = `<div class="jc-four">
|
||||
<div class="jc-fcard">
|
||||
<span class="jc-fcard-k"><i>${ico('building')}</i>客户公司</span>
|
||||
<div class="jc-fcard-b">
|
||||
<span class="jc-logo">${ico('building', 17)}</span>
|
||||
<div class="jc-fcard-t"><b data-slot="customer_name" data-blank></b><span data-slot="customer_code" data-blank></span></div>
|
||||
</div>
|
||||
<span class="jc-fcard-note">承自上游;单飞起单时此栏可空</span>
|
||||
</div>
|
||||
|
||||
<div class="jc-fcard t-teal">
|
||||
<span class="jc-fcard-k"><i>${ico('boxes')}</i>待定价</span>
|
||||
<div class="jc-count"><b data-slot="quote_pricing_draft_missing"></b><span>/ <b data-slot="quote_pricing_draft_rows"></b> 条草案</span></div>
|
||||
<span class="jc-fcard-note">拟报单价还空着的行 —— 这一列由你落笔,系统只给建议价;补一格少一格,基座现数</span>
|
||||
</div>
|
||||
|
||||
<div class="jc-fcard">
|
||||
<span class="jc-fcard-k"><i>${ico('shield-check')}</i>底价门禁逐条</span>
|
||||
<div class="jc-segmeter" data-rows="mine:quote_pricing.draft">
|
||||
<i data-class-of="disposition"></i>
|
||||
</div>
|
||||
<span class="jc-fcard-note">一格一条料 · 绿=在底价线之上,橙=低于两成内附理由,红=压在拦截档待拍板</span>
|
||||
</div>
|
||||
|
||||
<div class="jc-fcard t-ok">
|
||||
<span class="jc-fcard-k"><i>${ico('history')}</i>取价的尺</span>
|
||||
<span class="jc-tagbig">${ico('check', 13)}<span>成本 × 利润模型 → 底价线</span></span>
|
||||
<span class="jc-fcard-note">基准取上游底价或库内历史价;毛利档由 P-01 定;偏离底价线两成即止(P-02)</span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
/** 一个价是怎么长出来的 —— 走位图(形状写死,不算不判) */
|
||||
const PRICE_LINE = `<div class="jc-priceline">
|
||||
<div class="jc-plnode"><b>成本基准</b><span>上游底价 / 库内历史价 —— 取不到就空着,不编</span></div>
|
||||
<div class="jc-plarrow">${ico('arrow-right', 18)}</div>
|
||||
<div class="jc-plnode"><b>毛利档 P-01</b><span>客户等级 × 类别 × 量档 → 一段区间</span></div>
|
||||
<div class="jc-plarrow">${ico('arrow-right', 18)}</div>
|
||||
<div class="jc-plnode"><b>底价线</b><span>成本 ×(1 + 档下限)—— 比它低就要有人负责</span></div>
|
||||
<div class="jc-plarrow">${ico('arrow-right', 18)}</div>
|
||||
<div class="jc-plnode"><b>建议价 → 你定价</b><span>系统给建议,拟报单价那一格由你落笔</span></div>
|
||||
<div class="jc-plarrow">${ico('arrow-right', 18)}</div>
|
||||
<div class="jc-plnode"><b>门禁 P-02</b><span>偏离底价线:≥0 放行 / 两成内附理由 / 超两成拍板</span></div>
|
||||
</div>`;
|
||||
|
||||
/** 缺价红带 —— 与提交闸、放行门 L1 同一个数同一套算法(基座现数);一格不缺自动收 */
|
||||
const GAP_BAR = `<div class="jc-gapbar" data-class-of="quote_pricing_draft_missing">${ico('alert', 14)}
|
||||
<span><b>还差 <span data-slot="quote_pricing_draft_missing"></span> 行拟报单价没定</b>
|
||||
—— <span data-slot="quote_pricing_draft_missing_keys"></span>。
|
||||
按右上角「编辑」开表,红框那几格就是要填的:认可系统建议价就照抄那个数,不认可就改成你的;
|
||||
库内查不到成本的那几行没有建议价,由你按经验定并在备注写明依据。补齐了执行单才提交得了。</span>
|
||||
</div>`;
|
||||
|
||||
const editGate = (address: string, what: string): string => `<div class="jc-editgate" data-edit-for="${address}">
|
||||
<span class="jc-editgate-t">${ico('lock', 12)}${what}<b>已锁定</b> —— 看的时候不误碰,要改按右边</span>
|
||||
<button type="button" class="jc-egbtn open" data-edit-open="${address}">${ico('pen', 12)}编辑</button>
|
||||
<button type="button" class="jc-egbtn ghost" data-edit-cancel="${address}">取消</button>
|
||||
<button type="button" class="jc-egbtn save" data-edit-save="${address}">${ico('check', 12)}保存</button>
|
||||
</div>
|
||||
<div class="jc-editbar">${ico('pen', 14)}
|
||||
<span>可填的是<b>拟报单价</b>与<b>备注</b>两列:<b>拟报单价这一列系统不替你填</b> ——
|
||||
认可建议价就照抄那个数,改高改低都可以,改了在备注里写一句缘由;
|
||||
毛利、偏离度与门禁结论随后按你定的价重算。本轮不报的料按行首 × 撤掉。
|
||||
改动只落任务草稿层,<b>保存不等于放行</b>。</span>
|
||||
</div>`;
|
||||
|
||||
const addRowBar = (address: string): string => `<div class="jc-addrow">
|
||||
<button type="button" class="jc-egbtn ghost" data-edit-add="${address}">${ico('plus', 12)}加一行</button>
|
||||
<em>照末行的样子添一行空的;主键(型号 PN)填了才存得下,不填这一行不落库。</em>
|
||||
</div>`;
|
||||
|
||||
/**
|
||||
* 草案主表 —— 一行一料:承继列 + 定价五列 + 类徽与门禁徽。
|
||||
* `data-col` 只挂人可改的两列(quoted_price / price_note)+ 主键 pn ——
|
||||
* 编辑面精确等于 BD 的职权面;**主键格必挂 `data-col`**(它不是编辑面,是行的钥匙:
|
||||
* 不挂,服务端按「全表一致列」补键,两行会对到同一行上,后者盖前者 —— A2 走查逮过)。
|
||||
* 拟报单价挂 `req`:空着出「必填」胶囊、整行标底,与提交闸、放行门同源(都数 `_missing`)。
|
||||
*/
|
||||
const DRAFT_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
|
||||
<thead><tr>
|
||||
<th style="width:36px;min-width:36px"></th>
|
||||
<th style="width:44px;min-width:44px">类</th>
|
||||
<th style="min-width:170px">PN</th>
|
||||
<th style="min-width:84px">品类</th>
|
||||
<th style="min-width:70px">年用量</th>
|
||||
<th style="min-width:78px">成本基准</th>
|
||||
<th style="min-width:86px">毛利档</th>
|
||||
<th style="min-width:78px">底价线</th>
|
||||
<th style="min-width:78px">客户目标价</th>
|
||||
<th style="min-width:82px">系统建议价</th>
|
||||
<th class="req" style="min-width:96px">拟报单价(你定)</th>
|
||||
<th style="min-width:70px">毛利率</th>
|
||||
<th style="min-width:70px">偏离度</th>
|
||||
<th style="width:78px;min-width:78px">门禁</th>
|
||||
<th style="min-width:150px;width:100%">备注 / 让价理由</th>
|
||||
</tr></thead>
|
||||
<tbody data-rows="mine:quote_pricing.draft" data-edit="quote_pricing.draft"><tr>
|
||||
<td class="jc-rn"><button type="button" class="jc-rowdel" data-edit-del="quote_pricing.draft" title="撤掉这一行">×</button></td>
|
||||
<td class="jc-ty" data-h="类"><span class="jc-tbadge" data-class-of="type_tag" data-slot="type_tag"></span></td>
|
||||
<td data-h="PN"><span class="jc-vcell mono" data-slot="pn" data-col="pn" data-blank></span></td>
|
||||
<td class="dimempty" data-h="品类"><span class="jc-vcell" data-slot="category" data-blank></span></td>
|
||||
<td class="dimempty" data-h="年用量"><span class="jc-vcell mono" data-slot="eau" data-blank></span></td>
|
||||
<td class="dimempty" data-h="成本基准"><span class="jc-vcell num" data-slot="base_price" data-blank></span></td>
|
||||
<td class="dimempty" data-h="毛利档"><span class="jc-vcell" data-slot="margin_band" data-blank></span></td>
|
||||
<td class="dimempty" data-h="底价线"><span class="jc-vcell num" data-slot="floor_price" data-blank></span></td>
|
||||
<td class="sug" data-h="客户目标价"><span class="jc-vcell num" data-slot="target_price" data-blank></span></td>
|
||||
<td class="dimempty" data-h="系统建议价"><span class="jc-vcell num" data-slot="suggested_price" data-blank></span></td>
|
||||
<td class="req" data-h="拟报单价(你定)"><span class="jc-vcell num" data-slot="quoted_price" data-col="quoted_price" data-blank></span></td>
|
||||
<td class="dimempty" data-h="毛利率"><span class="jc-vcell num" data-slot="margin" data-blank></span></td>
|
||||
<td class="dimempty" data-h="偏离度"><span class="jc-vcell num" data-slot="deviation" data-blank></span></td>
|
||||
<td data-h="门禁"><span class="jc-gbadge" data-class-of="disposition"></span></td>
|
||||
<td class="opt" data-h="备注 / 让价理由"><span class="jc-vcell" data-slot="price_note" data-col="price_note" data-blank></span></td>
|
||||
</tr></tbody>
|
||||
</table></div>`;
|
||||
|
||||
/** 门禁分布:三段按条数分宽 —— 守恒等式(三档相加 = 行数)的可视形 */
|
||||
const DIST = `<div class="jc-dist">
|
||||
<div class="jc-distbar">
|
||||
<i class="p" data-class-of="quote_pricing_draft_disposition_pass" data-slot="quote_pricing_draft_disposition_pass"></i>
|
||||
<i class="n" data-class-of="quote_pricing_draft_disposition_note" data-slot="quote_pricing_draft_disposition_note"></i>
|
||||
<i class="k" data-class-of="quote_pricing_draft_disposition_block" data-slot="quote_pricing_draft_disposition_block"></i>
|
||||
</div>
|
||||
<div class="jc-distlg">
|
||||
<span><em class="a"></em>直接放行 · 在底价线之上 <b data-slot="quote_pricing_draft_disposition_pass"></b></span>
|
||||
<span><em class="b"></em>附理由放行 · 低于两成内 <b data-slot="quote_pricing_draft_disposition_note"></b></span>
|
||||
<span><em class="c"></em>拦截待拍板 · 低于两成以上 <b data-slot="quote_pricing_draft_disposition_block"></b></span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
/* ==================== 局面一 · 定价工作单(算价与 BD 核价期间)==================== */
|
||||
|
||||
/**
|
||||
* 逐颗定价档案 —— 一颗料一张卡(data-rows 逐行复制):左题面、右推演。
|
||||
*
|
||||
* **容器用 `<section>` 不用 `<div>`**:渲染器的行展开按第一个同名闭合标签截行内容,
|
||||
* 行模版里嵌着 `<div>` 时 `<div data-rows>` 会把行截成套娃(A2 逮过,记在陷阱表)。
|
||||
*/
|
||||
const PRICE_CARDS = `<section class="jc-selcards" data-rows="mine:quote_pricing.draft">
|
||||
<article class="jc-selcard" data-class-of="disposition">
|
||||
<div class="jc-selcard-h">
|
||||
<span class="jc-tbadge" data-class-of="type_tag" data-slot="type_tag"></span>
|
||||
<code data-slot="pn"></code>
|
||||
<span class="why" data-slot="remark" data-blank></span>
|
||||
<span class="jc-gbadge" data-class-of="disposition"></span>
|
||||
</div>
|
||||
<div class="jc-selcard-b">
|
||||
<div class="jc-selcol">
|
||||
<h5>题面(客户要什么)</h5>
|
||||
<div class="jc-kvrow"><i>客户料号</i><span data-slot="customer_pn" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>品类</i><span data-slot="category" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>制造商</i><span data-slot="brand" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>年用量</i><span data-slot="eau" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>客户目标价</i><span class="sc-sug num" data-slot="target_price" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>交期要求</i><span data-slot="lead_time" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>客户约束</i><span data-slot="remark" data-blank></span></div>
|
||||
</div>
|
||||
<div class="jc-selcol">
|
||||
<h5>定价推演(填回下表,这里回显)</h5>
|
||||
<div class="jc-kvrow"><i>成本基准</i><span class="num" data-slot="base_price" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>毛利档</i><span data-slot="margin_band" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>底价线</i><span class="num" data-slot="floor_price" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>系统建议价</i><span class="num" data-slot="suggested_price" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>拟报单价</i><span class="sc-req num" data-slot="quoted_price" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>毛利率</i><span class="num" data-slot="margin" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>偏离底价线</i><span class="num" data-slot="deviation" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>让价理由</i><span class="sc-opt" data-slot="price_note" data-blank></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>`;
|
||||
|
||||
/** 利润模型 P-01 的毛利档 —— 静态规则,尺挂在墙上(素材:MVP 示例工作流 §1.2) */
|
||||
const MARGIN_RULES = `<div class="jc-graderule">
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band ga">8%~10%</span>
|
||||
<p><b>战略客户 · A 类标准件 · 大批量。</b>量换价,毛利让到下限也要守住 —— 这一档的价不是拿来谈的,
|
||||
是拿来锁长单的。</p>
|
||||
</div>
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band gb">12%~15%</span>
|
||||
<p><b>普通客户 · A 类标准件。</b>常规档,也是拿不准时的兜底档 —— 行情透明的料按这一档报不吃亏。</p>
|
||||
</div>
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band gc">15%~20%</span>
|
||||
<p><b>B 类波动件。</b>货源与行情都会动,毛利要留出被动让价的余地。</p>
|
||||
</div>
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band gc">20%~50%</span>
|
||||
<p><b>C 类替代 / 停产件。</b>一事一议:找料本身就是成本,替代还带验证风险 ——
|
||||
报低了后面每一次补货都在亏。</p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
/** 底价门禁 P-02 的三档尺 —— 静态规则(素材:MVP 示例工作流 §1.2 底价自检) */
|
||||
const GATE_RULES = `<div class="jc-graderule">
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band gp">≥ 底价线</span>
|
||||
<p><b>直接放行。</b>报价在底价线之上,毛利还在档内 —— 不必解释,提审即可。</p>
|
||||
</div>
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band gn">低于两成内</span>
|
||||
<p><b>附理由放行。</b>为拿单让到底价线以下两成以内:在备注里写清让价的缘由
|
||||
(客户长单、带单品、竞对压价)—— 理由随草案一起交审批,不写等于没有依据。</p>
|
||||
</div>
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-band gk">低于两成以上</span>
|
||||
<p><b>拦截,要人拍板。</b>这一档系统不替任何人放行:要么回调到底价线,要么由负责人
|
||||
拍板破线并留下理由,要么这一条本轮不报。<b>私自绕开门禁不算处置</b>。</p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export const PRICING_WORKSHEET = doc({
|
||||
badge: 'sheet',
|
||||
kicker: '统一报价 · A4 成本定价 · 定价作业',
|
||||
title: '定价工作单',
|
||||
titleRef: 'rfq_ref',
|
||||
state:
|
||||
'<span class="jc-rp-wait warn">待定价 <b data-slot="quote_pricing_draft_missing"></b> / <b data-slot="quote_pricing_draft_rows"></b> 条</span>',
|
||||
body:
|
||||
kvCard('本单标的', [
|
||||
['客户编码', 'customerCode', 'mono'],
|
||||
['型号 PN', 'pn', 'mono'],
|
||||
['物料类型', 'materialType', 'tag'],
|
||||
]) +
|
||||
kvCard('价格基准', [
|
||||
['上次成交价', 'last_price'],
|
||||
['客户等级', 'customerGrade'],
|
||||
['数量档', 'volumeTier'],
|
||||
]) +
|
||||
noteCard('基准从哪来', '基准价取自历史价情报(CRM)。新料号无历史时基准记 0,由底价门禁按<b>无基准</b>处置 —— 不凭空编一个偏离率出来。'),
|
||||
part('定价任务', '客户 <b data-slot="customer_name" data-blank></b> · 共 <b data-slot="line_total"></b> 条 · 有基准价 <b data-slot="basis_total"></b> 条') +
|
||||
`<div class="jc-derive">${ico('info', 14)}
|
||||
<span>这一屏是一份报价草案的作业台:系统按「成本基准 × 利润模型」给出底价线与
|
||||
<b>建议价</b>,<b>拟报单价那一列由你落笔</b> —— 人签的就是这个数,系统不代签。
|
||||
库内查不到成本的料连建议价都没有(不编),那几行按你的经验定并在备注写明依据。
|
||||
你定完价,毛利、偏离度与底价门禁的结论跟着重算。</span>
|
||||
</div>` +
|
||||
FOUR +
|
||||
part('一个价是怎么长出来的', '成本 → 毛利档 → 底价线 → 拟报价 → 门禁') +
|
||||
PRICE_LINE +
|
||||
part('逐颗定价档案', '题面在左 · 推演在右') +
|
||||
PRICE_CARDS +
|
||||
part('利润模型 P-01 的毛利档', '客户等级 × 类别 × 量档 —— 尺挂在墙上') +
|
||||
MARGIN_RULES +
|
||||
part('底价门禁 P-02', '偏离底价线两成即止') +
|
||||
GATE_RULES +
|
||||
part('填回工作表', '定价落在这 —— 补齐了执行单才提交得了') +
|
||||
GAP_BAR +
|
||||
editGate('quote_pricing.draft', '报价草案填回表') +
|
||||
DRAFT_TABLE +
|
||||
addRowBar('quote_pricing.draft') +
|
||||
`<div class="jc-drop">${ico('info', 13)}
|
||||
<span>带红标的<b>拟报单价</b>是必填,而且<b>只由你填</b> —— 缺一行执行单按不下提交,
|
||||
与放行门是同一个数同一套算法。系统给的是建议价:照抄即认可,改了以你的为准。
|
||||
查不到成本的料没有建议价(系统不编):按你的经验定一个价并在备注写明依据,
|
||||
或者按行首 × 把这一条撤出本轮。压在拦截档(红)的条目在你这儿往上调一点,
|
||||
就不必再惊动第二个人。</span>
|
||||
</div>`,
|
||||
foot: sign('BD 核价 · AI 按利润模型与底价门禁算价,你定最后那个数', '统一报价 · A4 成本定价 · 在办'),
|
||||
});
|
||||
|
||||
export const PRICING_PRICED = viewShell({
|
||||
title: '定价与底价门禁',
|
||||
kicker: 'A4 · DMN 两表',
|
||||
tone: 'ok',
|
||||
body:
|
||||
kvCard('定价结论', [
|
||||
['拟报单价', 'quoted_price'],
|
||||
['目标毛利', 'margin'],
|
||||
['相对基准偏离', 'deviation'],
|
||||
['门禁处置', 'disposition', 'tag'],
|
||||
]) +
|
||||
noteCard(
|
||||
'两张决策表各管一段',
|
||||
'<b>利润模型 P-01</b>:客户等级 × 物料类型 × 数量档 → 目标毛利区间。' +
|
||||
'<b>底价门禁 P-02</b>:偏离底价 ±20% 即止 —— 超限的不是「拦下」,而是转人裁断(回调还是注明理由)。' +
|
||||
'两表都是确定性求值,同输入必同结论;命中的规则行随出参留痕,事后可逐条复盘。',
|
||||
),
|
||||
});
|
||||
/* ==================== 局面二 · 报价草案清单(拍板 / 放行 / 归档)==================== */
|
||||
|
||||
export const PRICING_RELEASE = viewShell({
|
||||
title: '报价草案 · 待放行',
|
||||
kicker: 'A4 · 成果预览',
|
||||
tone: 'case',
|
||||
export const PRICING_ROSTER = doc({
|
||||
badge: 'file-check',
|
||||
kicker: '统一报价 · A4 成本定价 · 交付成果',
|
||||
title: '报价草案',
|
||||
titleRef: 'rfq_ref',
|
||||
state:
|
||||
'<span class="jc-rp-wait">草案 <b data-slot="quote_pricing_draft_rows"></b> 条 · 待拍板 <b data-slot="quote_pricing_draft_disposition_block"></b></span>',
|
||||
body:
|
||||
tableCard(
|
||||
'本单报价草案',
|
||||
'mine:quote_pricing.draft',
|
||||
[
|
||||
['型号', 'pn', 'mono'],
|
||||
['基准价', 'last_price'],
|
||||
['拟报价', 'quoted_price'],
|
||||
['毛利', 'margin'],
|
||||
['偏离', 'deviation'],
|
||||
['门禁', 'disposition'],
|
||||
],
|
||||
'逐条列出,便于放行前逐条核对。',
|
||||
) +
|
||||
noteCard(
|
||||
'放行之后',
|
||||
'放行通过 → 草稿合并入正式版 → 进 <b>AP 定价审批</b>(卡到 PM 工位)。' +
|
||||
'审批驳回按声明走向<b>回 A4 重开新实例</b> —— 新任务 ID、留痕关联,不复活旧任务。',
|
||||
),
|
||||
part('定价要素', '成本定价 · 能算的归判定表,超限的归人') +
|
||||
FOUR +
|
||||
part('底价门禁分布', '直接放行 + 附理由 + 拦截 = 总条数') +
|
||||
DIST +
|
||||
`<div class="jc-derive">${ico('info', 14)}
|
||||
<span>三段条数由基座逐行现数,不是谁报上来的:红段一条不剩,这份草案才走得到放行门。
|
||||
压在拦截档的条目只有三条出路 —— 回调到底价线、由负责人拍板破线并留理由、
|
||||
或者本轮不报。</span>
|
||||
</div>` +
|
||||
part('报价草案', '<b data-slot="line_total"></b> 条 · 每条都带来路') +
|
||||
GAP_BAR +
|
||||
editGate('quote_pricing.draft', '报价草案') +
|
||||
DRAFT_TABLE +
|
||||
`<div class="jc-drop">${ico('info', 13)}
|
||||
<span>每一行摆的是这个价的<b>来路</b>:成本基准从哪来、毛利档是哪一档、底价线在哪、
|
||||
偏离多少、门禁怎么判。签字前请重点看两处:<b>门禁列</b>里非绿的条目,
|
||||
与<b>备注列</b>里的让价理由 —— 没有理由的让价,到了审批那一关同样过不去。</span>
|
||||
</div>` +
|
||||
part('放行后即执行的落地动作', '门前 → 门后') +
|
||||
`<div class="jc-acts">
|
||||
<div class="jc-act"><i>${ico('check', 11)}</i>
|
||||
<div><b>草稿层合并进正式版</b> <span>本任务写下的报价草案与定价结论一并落账;
|
||||
你就地改过的那几个价随之落定。</span></div>
|
||||
</div>
|
||||
<div class="jc-act"><i>${ico('check', 11)}</i>
|
||||
<div><b>草案此刻才对外发出</b> <span>整份 <b data-slot="quote_pricing_draft_rows"></b> 条
|
||||
(每条带拟报单价与门禁处置)交出 —— 放行之前,世界上什么都没有发生。</span></div>
|
||||
</div>
|
||||
<div class="jc-act"><i>${ico('check', 11)}</i>
|
||||
<div><b>转入定价审批</b> <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('>', '>').replaceAll('<', '<').replaceAll('&', '&');
|
||||
}
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -1,120 +1,282 @@
|
||||
/**
|
||||
* A3 渠道寻源的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
|
||||
* 「说明书是 XML 的叙事投影,迭代时一起放行」(AM 3.1)。
|
||||
* A3 寻源询价的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
|
||||
*
|
||||
* 业务出处:`docs/MVP示例工作流.html` §1.7,按 **MVP 简化口径**建模
|
||||
* (2026-08-02 用户定:A3 与 A2 同为复杂活动,MVP 期间不展开大规模业务建模):
|
||||
*
|
||||
* 上游(A2 的询价受理清单,或 A1 直达的 B 类受理记录)进来 → 归一成
|
||||
* **目标报价清单**底表,A 类行按库内历史价预填基准 → **一张执行单**让采购(PRM)
|
||||
* 在系统外完成询价、把 B 类行的货源价格填回来 → 汇稿定盘 → 放行,
|
||||
* 整份目标报价清单交给成本定价。
|
||||
*
|
||||
* SOP 里接需核史 / 选渠定策 / 发询比价那几段的实质工作(圈渠道、发函、催回价、
|
||||
* 验价)在 PRM 系统外;四种场景(BOM早期/后期/现货/样品)的尺度之分是他的判断,
|
||||
* 不是系统的步。系统的本分:归一、预填、组卡、齐检、留痕。
|
||||
*
|
||||
* 上一版(@2.0.0)只有「派单 + 放行」两项,入参必填 `pn + scenario`(单料形状,
|
||||
* 与上游发的整份清单对不上),两条 L2 判据读活动自报数。这一版整个置换。
|
||||
*/
|
||||
|
||||
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
|
||||
<case id="sourcing">
|
||||
<!--
|
||||
案卷项的 id **就是任务视图里的键名**。流内起单时上游载荷(rows / records)
|
||||
与本活动台账列都会摊平到任务视图顶层,照它们命名。
|
||||
-->
|
||||
<caseFileModel>
|
||||
<caseFileItem id="inquiry" name="询价请求"/>
|
||||
<caseFileItem id="supplier_quote" name="供应商回价单"/>
|
||||
<caseFileItem id="compare_table" name="寻源比价表"/>
|
||||
<caseFileItem id="rows" name="上游清单行(A2 载荷,流内开案即有)"/>
|
||||
<caseFileItem id="records" name="上游受理记录(A1 直达时有)"/>
|
||||
<caseFileItem id="quote_line_total" name="清单条目数(自报)"/>
|
||||
<caseFileItem id="quote_sourcing_quote_list_rows" name="清单行数(基座现数)"/>
|
||||
<caseFileItem id="pending_total" name="待补价条数"/>
|
||||
<caseFileItem id="hist_priced_total" name="A 类已按历史价预填条数"/>
|
||||
<caseFileItem id="sourced_total" name="PRM 已补价条数"/>
|
||||
<caseFileItem id="quote_sourcing_quote_list_missing" name="缺基准价的行数(基座现数)"/>
|
||||
<caseFileItem id="route_decided_total" name="已定盘"/>
|
||||
</caseFileModel>
|
||||
<casePlanModel id="plan" name="渠道寻源" autoComplete="true">
|
||||
<sentry id="s_inquiry">
|
||||
<onPart id="op_inquiry" caseFileItemRef="inquiry"><standardEvent>create</standardEvent></onPart>
|
||||
<casePlanModel id="plan" name="寻源询价" autoComplete="true">
|
||||
<!--
|
||||
① 归一并预填完了 —— 交 PRM 那一步的入场前提。
|
||||
行数两边对上(自报 = 基座现数),且 A 类预填那一轮走完
|
||||
(hist_priced_total 是预填后显式写下的数;没有 A 类就是 0,
|
||||
「大于等于 0」对「写过的 0」为真、对「没写过」为假)。
|
||||
-->
|
||||
<sentry id="s_normalized">
|
||||
<onPart id="op_nm" caseFileItemRef="hist_priced_total"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_nm"><condition>quote_line_total > 0 and quote_sourcing_quote_list_rows = quote_line_total and hist_priced_total >= 0</condition></ifPart>
|
||||
</sentry>
|
||||
<sentry id="s_need_quote">
|
||||
<onPart id="op_need" caseFileItemRef="inquiry"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_need"><condition>needNewQuote = true</condition></ifPart>
|
||||
</sentry>
|
||||
<sentry id="s_channels">
|
||||
<onPart id="op_ch" caseFileItemRef="inquiry"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_ch"><condition>channelsPicked >= 2</condition></ifPart>
|
||||
</sentry>
|
||||
<sentry id="s_quotes_in">
|
||||
<onPart id="op_q" caseFileItemRef="supplier_quote"><standardEvent>create</standardEvent></onPart>
|
||||
<!--
|
||||
② PRM 的答复处理过了 —— 汇稿那一步的入场前提。
|
||||
sourced_total 是「人交回执行单之后」由 AI 清点写下的数(B 类行补上价的条数)——
|
||||
只在人环走完才存在,它就是「经过人了」的凭据。
|
||||
-->
|
||||
<sentry id="s_sourced">
|
||||
<onPart id="op_sc" caseFileItemRef="sourced_total"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_sc"><condition>sourced_total >= 0</condition></ifPart>
|
||||
</sentry>
|
||||
<!--
|
||||
── 放行门的入场条件:**认「办完了没有」,且读基座数**────────────────
|
||||
· 每行都有基准价(quote_sourcing_quote_list_missing 由基座按 required 现数,
|
||||
与执行卡的提交闸、验收 L1 同一个数同一套算法);
|
||||
· 价源守恒:历史价 + 货源价 = 行数(两边都是基座按值现数);
|
||||
· 定盘过了(route_decided_total 是汇稿那一步显式写下的记账数)。
|
||||
-->
|
||||
<sentry id="s_ready">
|
||||
<onPart id="op_r" caseFileItemRef="compare_table"><standardEvent>create</standardEvent></onPart>
|
||||
<onPart id="op_rd" caseFileItemRef="route_decided_total"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_rd"><condition>route_decided_total > 0 and quote_sourcing_quote_list_rows > 0 and quote_sourcing_quote_list_missing = 0 and quote_sourcing_quote_list_price_basis_hist + quote_sourcing_quote_list_price_basis_sourced = quote_sourcing_quote_list_rows</condition></ifPart>
|
||||
</sentry>
|
||||
|
||||
<stage id="S1" name="接需核史" autoComplete="true">
|
||||
<entryCriterion id="ec_s1" sentryRef="s_inquiry"/>
|
||||
<humanTask id="T_history" name="人工查历史报价">
|
||||
<itemControl><manualActivationRule id="m_hist"/></itemControl>
|
||||
</humanTask>
|
||||
<decisionTask id="D_strategy" name="判定采用策略" decisionRef="sourcing-strategy">
|
||||
<itemControl><manualActivationRule id="m_str"/></itemControl>
|
||||
</decisionTask>
|
||||
</stage>
|
||||
<milestone id="M_strategy" name="策略已定"/>
|
||||
|
||||
<stage id="S2" name="选渠定策" autoComplete="true">
|
||||
<entryCriterion id="ec_s2" sentryRef="s_need_quote"/>
|
||||
<humanTask id="T_pick" name="人工按场景圈渠道">
|
||||
<itemControl><manualActivationRule id="m_pick"/></itemControl>
|
||||
</humanTask>
|
||||
<planningTable id="pt_s2">
|
||||
<humanTask id="T_verify_channel" name="人工渠道资质核实"/>
|
||||
</planningTable>
|
||||
</stage>
|
||||
<milestone id="M_channels" name="渠道圈定"/>
|
||||
|
||||
<stage id="S3" name="发询比价" autoComplete="true">
|
||||
<entryCriterion id="ec_s3" sentryRef="s_channels"/>
|
||||
<humanTask id="T_send" name="人工批量发询">
|
||||
<itemControl><manualActivationRule id="m_send"/><repetitionRule id="r_send"/></itemControl>
|
||||
</humanTask>
|
||||
<humanTask id="T_log_quote" name="人工登记回价">
|
||||
<entryCriterion id="ec_log" sentryRef="s_quotes_in"/>
|
||||
<itemControl><manualActivationRule id="m_log"/><repetitionRule id="r_log"/></itemControl>
|
||||
</humanTask>
|
||||
<humanTask id="T_verify_price" name="人工价格验证">
|
||||
<itemControl><manualActivationRule id="m_vp"/><repetitionRule id="r_vp"/></itemControl>
|
||||
</humanTask>
|
||||
<planningTable id="pt_s3">
|
||||
<humanTask id="T_chase" name="人工追价补询"/>
|
||||
</planningTable>
|
||||
</stage>
|
||||
<milestone id="M_quotes" name="报价确认"/>
|
||||
|
||||
<stage id="S4" name="反馈交付" autoComplete="true">
|
||||
<entryCriterion id="ec_s4" sentryRef="s_ready"/>
|
||||
<humanTask id="T_fill_table" name="人工填寻源比价表">
|
||||
<itemControl><manualActivationRule id="m_fill"/></itemControl>
|
||||
</humanTask>
|
||||
<planningTable id="pt_s4">
|
||||
<humanTask id="T_order" name="人工下单与跟踪"/>
|
||||
</planningTable>
|
||||
</stage>
|
||||
<milestone id="M_delivered" name="交付完成"/>
|
||||
|
||||
<humanTask id="T_knowledge" name="人工知识入库">
|
||||
<itemControl><manualActivationRule id="m_kb"/></itemControl>
|
||||
<!-- ① 归一目标报价清单 + A 类按历史价预填基准 -->
|
||||
<humanTask id="S1_normalize" name="归一目标报价清单:整份落表,A 类按历史价预填基准">
|
||||
<itemControl><repetitionRule id="r_s1"/></itemControl>
|
||||
</humanTask>
|
||||
|
||||
<timerEventListener id="TL_deadline" name="时限预警">
|
||||
<timerExpression>P4D</timerExpression>
|
||||
</timerEventListener>
|
||||
<timerEventListener id="TL_quote_expiry" name="回价到期">
|
||||
<timerExpression>P7D</timerExpression>
|
||||
</timerEventListener>
|
||||
<!--
|
||||
② 补货源价格交 PRM —— **一张执行单**(询价的实质工作在系统外)。
|
||||
manualActivation + 不给 doneWhen(发卡触达真人,引擎不替人按)。
|
||||
-->
|
||||
<humanTask id="S2_fill" name="补货源价格交PRM:执行单上把 B 类行的供应商与底价填回来">
|
||||
<entryCriterion id="ec_s2" sentryRef="s_normalized"/>
|
||||
<itemControl><manualActivationRule id="m_s2"/></itemControl>
|
||||
</humanTask>
|
||||
|
||||
<!-- ③ 汇稿定盘:价源归档、齐检、结论 -->
|
||||
<humanTask id="S3_compile" name="汇稿定盘:价源归档,每行齐检,生成目标报价清单">
|
||||
<entryCriterion id="ec_s3" sentryRef="s_sourced"/>
|
||||
</humanTask>
|
||||
|
||||
<!-- 清单放行:终局唯放行 —— 触达世界,必须人工激活 -->
|
||||
<humanTask id="T_release" name="目标报价清单验收放行">
|
||||
<entryCriterion id="ec_rel" sentryRef="s_ready"/>
|
||||
<itemControl><manualActivationRule id="m_rl"/></itemControl>
|
||||
</humanTask>
|
||||
</casePlanModel>
|
||||
</case>
|
||||
</definitions>`;
|
||||
|
||||
export const NARRATIVE = `§0 开案:本案例覆盖四种询价场景 —— BOM早期、BOM后期、现货询价、样品询价。
|
||||
场景不同,供应商选择尺度与价格验证规则各异,但都遵循同一主线:
|
||||
PM 提交询价请求 → 查历史报价判定可否直采 →(须询价时)按场景圈渠道 → 发询验证 → 填入寻源比价表反馈。
|
||||
/**
|
||||
* 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本,两处不重复写同一件事)。
|
||||
*/
|
||||
export const NARRATIVE = `§0 开案:**一份清单要补齐每一行的价格基准**。来路三种,形状都收(宽进):
|
||||
· **选型转来**:任务数据里有 \`rows\` 数组 —— 替代选型放行的全部落地行混在一起
|
||||
(带 alt_pn / 现标的是清单行,带各种总数的是结论行,按 PN 归并自辨);
|
||||
· **受理直达**:有 \`records\` 数组 —— 受理放行的落地行(受理记录行 + 分类表行 + 结论行);
|
||||
· **单飞**:只有一段需求文字,列了几颗料要问价。
|
||||
|
||||
S1 接需核史:系统带出在供供应商与历史报价。先核历史,按场景规则逐 PN 判定是否可直接采用 ——
|
||||
可直采则跳过询价直接进入反馈交付;须新询价则进入选渠阶段。
|
||||
· 查历史报价:未达 3 次询价但有效期已过的,标记须重询。
|
||||
· 判定采用策略:BOM早期有效期内直采、过期浮动≤10%直采、10-20%取均价、>20%重询;
|
||||
BOM后期历史最低(≥3次)直采,否则降 20% 谈;现货/样品一律重询。
|
||||
**这个活动是复杂活动的 MVP 简化形**:询价的实质工作(圈渠道、发函、催回价、验价)
|
||||
由采购(PRM)在**系统外**完成;四种场景(BOM早期/后期/现货/样品)的尺度是他的判断。
|
||||
系统只负责:归一清单、**A 类行按库内历史价预填基准**、把执行单组好交给他、
|
||||
收回后齐检定盘。
|
||||
|
||||
S2 选渠定策:BOM早期/样品渠道可放宽;BOM后期按主→备选→贸易商顺序圈 2~3 家,贸易商尽量不用;
|
||||
现货仅选有库存且可提供仓库实拍的供应商。渠道资质核实是「看情况」的裁量项。
|
||||
**成品只有一样**:一份**目标报价清单** —— 整份行都在、**每一行都有基准价**
|
||||
(A 类取历史价,B 类取 PRM 问回来的货源价),价源逐行可溯。它是成本定价的直接输入。
|
||||
|
||||
S3 发询比价:同一料所有渠道一次发齐;PN/数量/目标价/回价时限写死在询价里。
|
||||
回价逐单登记,一一对应不可缺项。价格验证因场景而异。回价异常或未应答则追价补询(裁量项)。
|
||||
**这一单要经过人两次,不多不少**:PRM 在执行单上补货源价(那张单绑着整份清单,
|
||||
必填空着按不下提交 —— 基座的闸看着,他改的格子也直接落账)、放行时 PRM 签字。
|
||||
|
||||
S4 反馈交付:确认报价/LT/SPQ/有效期逐项填入寻源比价表,供 PM 直接查阅。
|
||||
现货/样品场景多一步下单与跟踪(裁量项)。
|
||||
【一路上要说话】负责人正看着这一单。**每一拍都带一句 say** —— 说业务的话:
|
||||
哪几颗料等着问价、历史价预填了几条、这单接下来到谁手上;不说系统词。
|
||||
到了要找人的那一拍,先说清这一轮办完了什么、为什么现在需要他。
|
||||
|
||||
全程随时可做:知识入库 —— 回价与成交沉淀历史价格情报,渠道表现回写供应商渠道目录。
|
||||
【怎么把一项干完 · 一拍连做】write 和 tool 都不会自动收步 —— 做完用 finish 宣告。
|
||||
动作排进同一拍的 actions 串一口气给;只有调工具(出参下一拍才看得见)与
|
||||
发卡 / 提交(挂起)必须停拍。**这一单常路 6-8 拍就该收口**。
|
||||
|
||||
§Z 结案:必做的事都办完、没有进行中的事,自动算结案(CMMN AutoComplete)。
|
||||
寻源比价表交付 PM 即结案 —— 每询一轮,行情底账厚一层。`;
|
||||
§Z 结案:PRM 在验收单上签字,系统把草稿合并成正式清单并发出 quotation 口的载荷 ——
|
||||
整份目标报价清单交给成本定价。`;
|
||||
|
||||
/**
|
||||
* 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。
|
||||
*/
|
||||
export const SOURCING_STEPS = [
|
||||
{
|
||||
item: 'S1_normalize',
|
||||
intent: '上游清单整份归一成目标报价清单;A 类行按库内历史价预填基准',
|
||||
writes: ['quote_sourcing.quote_list', 'quote_sourcing.verdict'],
|
||||
/* 上游载荷数组不声明就会被整表段的截断刀砍掉(按字符截,砍掉的恰是要归一的行) */
|
||||
reads: ['rows', 'records'],
|
||||
/* 归一预填与 PRM 补价期间摆**询价工作单**;汇稿定盘后才换收口那张清单 */
|
||||
view: 'sourcing-worksheet',
|
||||
tools: ['crm.history-price-read'],
|
||||
/*
|
||||
* 归一 + 预填两轮都走完才算办完:行数两边对上,且 hist_priced_total 写过
|
||||
* (没有 A 类就是 0;S-FEEL 未绑定的名字参与比较为假,所以「没写过」进不来)。
|
||||
*/
|
||||
doneWhen: 'quote_line_total > 0 and quote_sourcing_quote_list_rows = quote_line_total and hist_priced_total >= 0',
|
||||
guide: `第一件事:把这一单的**目标报价清单**落出来,并把 A 类行的基准价预填好。
|
||||
|
||||
【料从哪儿来】
|
||||
· 选型转来(有 \`rows\`):清单行带 pn / alt_pn / type_tag(现标)—— 照抄;
|
||||
带 alt_line_total 这类总数的是结论行,不是料,别落进清单。
|
||||
· 受理直达(有 \`records\`):受理记录行(带 eau/brand)为准,type_tag 照抄;
|
||||
分类表行与结论行不落。**按 pn 归并**,一颗料一行。
|
||||
· 单飞:照正文归一,列了几颗落几行;没说类别的按 B(要问价的料本来就是 B)。
|
||||
|
||||
【第一拍:只做两件事 —— 落清单行 + 串尾发一次查价】
|
||||
① 写 \`quote_sourcing.quote_list\`,一颗料一行(rows 收数组):
|
||||
pn(**一个字符不许改**)、customer_pn、category、brand、eau、target_price、lead_time、
|
||||
**remark(客户备注:认证要求、预授权、急料标注 —— 问价口径的第一手依据,一个字别丢)**、
|
||||
alt_pn(上游有就承继)、type_tag(照上游现标;单飞按 B)。
|
||||
supplier / base_price / moq / source_lead_time / note **全部留空** —— 价还没来。
|
||||
② 串尾调 \`crm.history-price-read\`:pns 收数组,把 **A 类行的取价对象**一次传完 ——
|
||||
**取价对象 = alt_pn 有值取 alt_pn,否则取 pn**(选型转来的 A 行,行情稳的是替代料)。
|
||||
**这一拍不写 \`quote_sourcing.verdict\`,一列都不写** —— 尤其 hist_priced_total:
|
||||
它是「预填走完了」的凭据,提前写等于把闸提前打开,引擎会在下一拍开头当场收掉这一步,
|
||||
**预填从此不会发生**(实测:A 类行底价空着、执行卡照发,而交代里说着「已预填」——
|
||||
台账与嘴对不上)。没有 A 类行也一样:查价那一步省掉,直接进第二拍。
|
||||
|
||||
【第二拍:预填 A 行基准 + 记账 + 收步,一拍连做】
|
||||
① 工具出参逐行照抄:hit=true 的 A 行写 base_price = lastPrice、price_basis 写 **HIST**
|
||||
(大写,一个字母不许差 —— 基座按值现数,写别的数不出来)、note 记一句
|
||||
「库内历史价 <N> 天」;hit=false 的 A 行**别编价** —— base_price 留空,
|
||||
当 B 类待询处理(pending_total 把它数上;type_tag 不动,那是上游的标)。
|
||||
② 这时才写 \`quote_sourcing.verdict\`(一次写全):rfq_ref(入参有就原样抄,
|
||||
没有从主题认,认不出才造「SRC-<型号或类型>」,从头用到尾。
|
||||
**只从本单来件取材,不许从台账里已有的别单单号里挑** —— 正式表里躺着的
|
||||
MD14.1 之类是别人的单,借它的号等于把两单的行混进同一个案卷)、
|
||||
customer_code / customer_name(顶层有就抄)、**quote_line_total** = 清单行数、
|
||||
**pending_total** = 底价还空着的条数、**hist_priced_total** = 刚预填的条数(0 也要写)。
|
||||
③ finish 这一项。`,
|
||||
},
|
||||
{
|
||||
item: 'S2_fill',
|
||||
intent: '一张执行单交 PRM:照询价工作单在系统外询价,供应商与底价就地填回',
|
||||
writes: ['quote_sourcing.verdict'],
|
||||
reads: ['quote_sourcing.quote_list'],
|
||||
view: 'sourcing-worksheet',
|
||||
tools: [],
|
||||
/* 不给 doneWhen —— 发卡触达真人(manualActivation),引擎不替人按 */
|
||||
guide: `清单归一好、A 类基准预填了,**该 PRM 出场了** —— 这是本活动唯一的一张执行单。
|
||||
工作区摆的是**寻源询价工作单**:逐颗待询料的询价档案(要素/取价对象/客户约束)、
|
||||
按场景圈渠道的规则、价格验证的尺、发询纪律与时限,填回区就长在上面。
|
||||
|
||||
这一步标了人工激活:发卡就算启动,不必单发 start;
|
||||
引擎也不会替你收 —— 人交回后要你自己 finish。
|
||||
|
||||
【照这个骨架发,别少字段】(卡面是纯文本,steps/standard 里**别写星号记号**):
|
||||
{"kind":"human","itemId":"S2_fill","workOrder":"execution","card":{
|
||||
"title":"寻源补价:<单号> 待补价 <N> 颗",
|
||||
"ask":"清单里底价空着的行要补货源价格:请照工作单上的场景规则在系统外完成询价,把供应商与底价填回表里对应行;MOQ、货源交期、备注能问到就一并填。",
|
||||
"steps":["底价空着的行是本单要问价的料 —— 每行必须补上:供应商、底价(未税单价按内部口径归一);取价对象是替代料一栏有值的取替代料,否则取原型号",
|
||||
"圈渠道按场景走(细则见工作单):BOM早期与样品可放宽;BOM后期按主供应商、备选、贸易商的顺序圈两三家,贸易商尽量不用;现货只选有库存实拍的供应商",
|
||||
"回价登记一一对应不可缺项:价格、MOQ、交期能问到就填;验价按场景的尺核过再采,不实回价或远低行情的换渠道补询,别直接采用",
|
||||
"确实问不到价的料,先与负责人商定:在件上把那一行撤掉(剔出本轮),或按内部估价填并在备注写明;A 类行已按库内历史价预填,可校可改"],
|
||||
"standard":"每一行都有基准价与价源可溯,渠道与验价按场景的尺走过 —— 底价缺一行,这张单按不下提交(系统的闸看着)。",
|
||||
"basis":[{"k":"<待补价 PN>","v":"<类别与年用量 + 客户约束(认证/急料),取价对象标明>"}]}}
|
||||
· \`basis\` 逐条列待补价的料:PN + 类别/年用量。几颗写几条,别铺全表。
|
||||
· **卡上不摆表、不摆格子**(2026-08-02 用户定:填写全在工作区):整份清单就在
|
||||
工作单那份件上,供应商/底价/MOQ/货源交期/备注五列都能就地改、行能撤 ——
|
||||
**别往卡里塞 \`blocks\` 或 \`fields\`**,塞了就是同一张表摆两处。
|
||||
· **这张卡带提交闸**:底价(必填列)还空着,他按不下提交 —— 缺几格、缺在哪行,
|
||||
闸上与件上的红带都写着(与放行门 L1 同一个数同一套算法)。
|
||||
发完卡这一拍就结束了(发卡挂起)。
|
||||
|
||||
【人交回之后 —— 件上的值基座已经替你写回了】他在件上按「保存」那一刻改动就落进了
|
||||
草稿层,按「提交」只是把卡交回来。你只做两件事,然后 finish:
|
||||
① 清点原本底价空着、现在有价的条数,把 **sourced_total** 写进 verdict
|
||||
(他在件上撤了行也算处置过,照实清点;0 也要写);
|
||||
② 别再 write 那些行 —— 值没变的写入会被「写了等于没写」拒回。
|
||||
|
||||
【这张卡一单只发一次】人交回来之后不许再发同一张卡 —— 必填有提交闸兜着,
|
||||
卡回来时底价一定是齐的(或那几行已被他撤掉)。`,
|
||||
},
|
||||
{
|
||||
item: 'S3_compile',
|
||||
intent: '价源逐行归档,齐检定盘:目标报价清单成稿',
|
||||
writes: ['quote_sourcing.quote_list', 'quote_sourcing.verdict'],
|
||||
reads: ['quote_sourcing.quote_list'],
|
||||
view: 'sourcing-roster',
|
||||
tools: [],
|
||||
/*
|
||||
* **收步判据与验收判据同源**:每行有价(基座按 required 现数)、价源守恒
|
||||
* (基座按值现数)、定盘记账 —— 三样齐才收,松出来的那一截就是返工。
|
||||
*/
|
||||
doneWhen:
|
||||
'quote_sourcing_quote_list_missing = 0 and quote_sourcing_quote_list_price_basis_hist + quote_sourcing_quote_list_price_basis_sourced = quote_sourcing_quote_list_rows and route_decided_total > 0',
|
||||
guide: `PRM 补回来了,现在把清单**定盘**,一拍连做:
|
||||
|
||||
① **价源归档**:PRM 补了价的行(price_basis 还空着的),逐行写 price_basis = **SOURCED**
|
||||
(大写,一个字母不许差);A 类预填的行已是 HIST,不动。
|
||||
按 rfq_ref + pn 对行,主键一个字符不许改。
|
||||
② 若还有行缺底价(他留空又没撤):照他备注的说明办 —— 写明剔除的用 \`drop\`
|
||||
把那一行撤出本单草稿(主键照 \`_keys\` 名单**原样粘**),撤了哪几颗、为什么写进
|
||||
verdict.summary,并把 quote_line_total 与 pending_total 改成撤后的实数;
|
||||
什么说明都没有的,发一张**决策卡(decide)**问负责人「这几颗问不到价,剔出本轮还是退回」,
|
||||
照答复办。**只问这一次**。
|
||||
③ 写 verdict:**route_decided_total 记 1**、summary 一句人话
|
||||
(几行历史价、几行货源价、剔了几颗、整份可交定价)。
|
||||
④ finish 这一项。引擎按「每行有价 + 价源守恒 + 定盘记过」收步,三样有一样不齐都收不掉。
|
||||
|
||||
这一单只有一个出口(交成本定价),**没有去向要选** —— 定盘就是把账写齐。`,
|
||||
},
|
||||
{
|
||||
item: 'T_release',
|
||||
view: 'sourcing-roster',
|
||||
intent: '清单已定盘,提交放行交 PRM 验收',
|
||||
writes: [],
|
||||
reads: [],
|
||||
tools: [],
|
||||
/* 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 doneWhen) */
|
||||
guide: `每行有价、价源守恒、定盘已记 —— 可以走放行了(T_release 人工激活:
|
||||
动它就算启动,不必单发 start)。先把裁量判据裁掉,然后 submit,同一拍连做:
|
||||
|
||||
· judge criterion="货源与价格来源可核:B 类行的供应商与底价来自真实询价或可靠渠道,PRM 对报价负责" ——
|
||||
看清单里 SOURCED 行的供应商与备注:说得出来源就 satisfied=true;
|
||||
有行说不出的,satisfied=**false**,rationale 逐行写清,交签字的人定。
|
||||
**裁成不满足照样能提交** —— 理由连着卡一起交给他。
|
||||
criterion 照判据名**整句原样抄**,写别的名字对不上。
|
||||
|
||||
终局唯放行:你不能自己宣布完成。`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段;
|
||||
* 放行那一格由基座统一加,人看到的是 x/4。
|
||||
*/
|
||||
export const SOURCING_PHASES = [
|
||||
{ name: '归一预填', items: ['S1_normalize'] },
|
||||
{ name: '补货源价', items: ['S2_fill'] },
|
||||
{ name: '汇稿定盘', items: ['S3_compile'] },
|
||||
];
|
||||
|
||||
@@ -1,59 +1,15 @@
|
||||
/**
|
||||
* 统一报价 A3 · 渠道寻源 —— 活动包代码入口(ADR-004)。
|
||||
* 统一报价 A3 · 寻源询价 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
|
||||
*
|
||||
* **loop 活动**:下一步由 Agent 在案例模型的结构上临场决断,引擎只推进到 Enabled 为止。
|
||||
* 本文件只做视图流控 —— 把阶段、里程碑与比价进展摆给人看。
|
||||
* **这个活动没有 `run`,不是缺件。**
|
||||
*
|
||||
* 案例活动的推进由 Loop 驱动,行为全部写在清单里:案例模型 + 说明书 + 步骤剧本 +
|
||||
* 两条技能 + 台账声明 + 一张 HTML 报告模版。上一版这里有一段 `run()` ——
|
||||
* loop 活动根本不会调它;摆一个空转的 run 去假装有实现,更坏。
|
||||
*
|
||||
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 —— 它是**被静态审扫描的产物文本**
|
||||
* (查 import 与全局引用,证明活动包零环境能力)。
|
||||
*/
|
||||
import { defineActivity } from '@sdk';
|
||||
import type { ActivityBase, ActivityOutcome } from '@sdk';
|
||||
|
||||
const QUOTES = 'quote_sourcing.supplier_quote';
|
||||
const COMPARE = 'quote_sourcing.compare_table';
|
||||
|
||||
export default defineActivity({
|
||||
name: 'quote-sourcing',
|
||||
paradigm: 'loop',
|
||||
|
||||
async run(base: ActivityBase): Promise<ActivityOutcome> {
|
||||
const quotes = await base.ledger.read(QUOTES);
|
||||
const compare = await base.ledger.read(COMPARE);
|
||||
const suppliers = new Set(quotes.map((q) => String(q['supplier'] ?? '')));
|
||||
|
||||
await base.interaction.pushView([
|
||||
{
|
||||
t: 'kv',
|
||||
title: '寻源进展',
|
||||
items: [
|
||||
{ k: '场景', v: String(base.contract.data['scenario'] ?? '') },
|
||||
{ k: '已圈渠道', v: `${suppliers.size} 家` },
|
||||
{ k: '已收回价', v: `${quotes.length} 单` },
|
||||
{ k: '比价表', v: compare.length > 0 ? '已填' : '未填' },
|
||||
],
|
||||
},
|
||||
{
|
||||
t: 'table',
|
||||
title: '供应商回价',
|
||||
cols: ['供应商', '回价', 'MOQ', '交期', '渠道等级'],
|
||||
rows: quotes.map((q) => [
|
||||
String(q['supplier'] ?? ''),
|
||||
String(q['price'] ?? '—'),
|
||||
String(q['moq'] ?? '—'),
|
||||
String(q['lead_time'] ?? '—'),
|
||||
String(q['grade'] ?? '—'),
|
||||
]),
|
||||
},
|
||||
]);
|
||||
|
||||
if (compare.length === 0) {
|
||||
// 交集为空即断点 —— 由引擎判定、Agent 定夺调人还是立监听,这里不代它决定
|
||||
await base.interaction.say(
|
||||
suppliers.size === 0 ? '尚未圈定渠道,先按场景定策。' : `已收 ${quotes.length} 单回价,待填寻源比价表。`,
|
||||
'conclusion',
|
||||
);
|
||||
return { kind: 'await', reason: '寻源比价表未交付' };
|
||||
}
|
||||
|
||||
await base.interaction.say('寻源比价表已填齐,可交回 PM。', 'criterion');
|
||||
return { kind: 'submit', deliverable: 'compare-table', rationale: '每料有有效报价或明确无货' };
|
||||
},
|
||||
});
|
||||
export default defineActivity({ name: 'quote-sourcing' });
|
||||
|
||||
@@ -1,141 +1,265 @@
|
||||
/**
|
||||
* 活动包清单 —— 统一报价 A3 · 渠道寻源(**案例活动 · loop**)。
|
||||
* 活动包清单 —— 统一报价 A3 · 寻源询价(案例活动 · MVP 简化形)。
|
||||
*
|
||||
* 业务素材:docs/MVP示例工作流.html §1.5。按 README《归一化对照表》映射:
|
||||
* 案例图 → CMMN XML;案例说明书 → 同版本叙事面;
|
||||
* `!必做` → entryCriterion 常项;`看情况` → discretionary(planningTable);
|
||||
* `#可反复` → repetitionRule;`▶需拍板` → manualActivation + 放行;
|
||||
* `⏱时限/回价到期` → 时间监听(**时限必配**)。
|
||||
* 业务素材:`docs/MVP示例工作流.html` §1.7,按 2026-08-02 用户改盘的口径归一:
|
||||
* **A3 与 A2 同为复杂活动(需要大规模业务建模),MVP 期间不展开** —— 只做一张精美的
|
||||
* HTML 验收报告 + 一张执行单:AI 归一目标报价清单、A 类行按库内历史价预填基准,
|
||||
* 采购(PRM)在系统外询价、把 B 类行的货源价格填回来,汇稿定盘后放行 ——
|
||||
* **生成最终的目标报价清单**交给成本定价。提交就是输出。
|
||||
*
|
||||
* ── 这里**去掉了 `STRATEGY_DMN`(采用策略判定表)**(2026-08-02)────────────────
|
||||
* 它声明着 scenario / priceDrift / quoteCount 三个入参,台账上没有任何列承载、
|
||||
* 剧本里也没有一步调它 —— 一张没有求值器的判定表不是资产,是错觉。
|
||||
* 四种场景(BOM早期/后期/现货/样品)的尺度之分是 PRM 系统外的判断,
|
||||
* MVP 口径下系统不假装接得住。
|
||||
*/
|
||||
import type { ActivityManifest, DmnModel } from '@contracts';
|
||||
import { SOURCING_SKILLS } from './skills.ts';
|
||||
import { SOURCING_CASE } from './views/report.ts';
|
||||
import { activityIdSchema } from '@contracts';
|
||||
import { CMMN_XML, NARRATIVE } from './case-model.ts';
|
||||
import { SOURCING_SKILLS } from './skills.ts';
|
||||
import { SOURCING_ROSTER, SOURCING_WORKSHEET } from './views/report.ts';
|
||||
import { CMMN_XML, NARRATIVE, SOURCING_PHASES, SOURCING_STEPS } from './case-model.ts';
|
||||
|
||||
const DOMAIN = 'quote_sourcing';
|
||||
const D = 'quote_sourcing';
|
||||
const ACTIVITY_ID = activityIdSchema.parse('quote-sourcing');
|
||||
|
||||
/** 采用策略 —— 「能算的不问 LLM」:场景 × 历史情形 → 直采 / 重询 */
|
||||
export const STRATEGY_DMN: DmnModel = {
|
||||
drd: [{ decision: 'sourcing-strategy', requires: [], inputs: ['scenario', 'priceDrift', 'quoteCount'] }],
|
||||
tables: [
|
||||
{
|
||||
id: 'sourcing-strategy',
|
||||
label: '采用策略判定',
|
||||
hitPolicy: 'FIRST',
|
||||
inputs: [
|
||||
{ name: 'scenario', expression: 'scenario' },
|
||||
{ name: 'priceDrift', expression: 'priceDrift' },
|
||||
{ name: 'quoteCount', expression: 'quoteCount' },
|
||||
],
|
||||
outputs: [{ name: 'strategy' }, { name: 'needNewQuote' }],
|
||||
rules: [
|
||||
{ id: 'st-spot', when: ['"现货","样品"', '-', '-'], then: ['一律重询', true] },
|
||||
{ id: 'st-early-direct', when: ['"BOM早期"', '<= 0.10', '-'], then: ['直采', false] },
|
||||
{ id: 'st-early-avg', when: ['"BOM早期"', '[0.10..0.20]', '-'], then: ['取均价', false] },
|
||||
{ id: 'st-late-direct', when: ['"BOM后期"', '-', '>= 3'], then: ['历史最低直采', false] },
|
||||
{ id: 'st-default', when: ['-', '-', '-'], then: ['重询', true] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
/** MVP 口径:A3 没有判定表(见文件头注释)—— 取价规则写死在剧本,价源逐行落表 */
|
||||
export const SOURCING_DMN: DmnModel = { drd: [], tables: [] };
|
||||
|
||||
export const manifest: ActivityManifest = {
|
||||
identity: {
|
||||
activityId: ACTIVITY_ID,
|
||||
name: 'A3 · 渠道寻源',
|
||||
packageVersion: '0.1.3',
|
||||
name: 'A3 · 寻源询价',
|
||||
packageVersion: '3.7.0',
|
||||
category: '统一报价',
|
||||
sdkVersion: '0.1.0-m5',
|
||||
checksum: 'dev-checksum-quote-sourcing',
|
||||
},
|
||||
|
||||
/* ── 契约七件 ────────────────────────────────────────────────── */
|
||||
contract: {
|
||||
activityId: ACTIVITY_ID,
|
||||
version: '0.1.0',
|
||||
version: '1.0.0',
|
||||
input: {
|
||||
/*
|
||||
* **宽进到底:一个必填都不设**(2026-08-02 用户定:宽进严出,
|
||||
* 输入契约在活动内部归一 + 补全)。
|
||||
*
|
||||
* 来路三种形状:A2 转来(rows)、A1 直达(records)、单飞(一段文字)。
|
||||
* 上一版必填 `rfqRef + pn + scenario` 是**单料形状** —— 上游发的是整份清单,
|
||||
* 门口拦的恰是主路。
|
||||
*/
|
||||
data: {
|
||||
type: 'object',
|
||||
required: ['pn', 'scenario'],
|
||||
required: [],
|
||||
properties: {
|
||||
pn: { type: 'string' },
|
||||
scenario: { type: 'string' },
|
||||
quantity: { type: 'integer' },
|
||||
targetPrice: { type: 'number' },
|
||||
rfqRef: { type: 'string', title: '询价单号' },
|
||||
rows: { type: 'array', title: '询价受理清单(选型转来)' },
|
||||
records: { type: 'array', title: '上游受理记录(受理直达)' },
|
||||
subject: { type: 'string', title: '需求主题' },
|
||||
body: { type: 'string', title: '需求正文' },
|
||||
attachments: {
|
||||
type: 'array',
|
||||
format: 'file',
|
||||
title: '附件',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', title: '文件名' },
|
||||
contentType: { type: 'string', title: '类型' },
|
||||
size: { type: 'integer', title: '字节数' },
|
||||
ref: { type: 'string', title: '存档号' },
|
||||
text: { type: 'string', title: '读出来的文本' },
|
||||
note: { type: 'string', title: '读取说明' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
messageAllowed: true,
|
||||
messageHint: '急料、客户特殊要求写这里',
|
||||
messageHint: '急料、场景(BOM早期/后期/现货/样品)、客户特殊要求写这里',
|
||||
},
|
||||
output: {
|
||||
/*
|
||||
* 只有一个出口:目标报价清单交成本定价(流程包 E7 是无条件边)。
|
||||
* 没有选择组 —— 没有去向要选,定盘就是把账写齐。
|
||||
*/
|
||||
mandatory: [
|
||||
{
|
||||
name: 'compare',
|
||||
payload: { type: 'object', required: ['rows'], properties: { rows: { type: 'array' } } },
|
||||
description: '寻源比价表(多渠道横比定底)',
|
||||
name: 'quotation',
|
||||
payload: {
|
||||
type: 'object',
|
||||
required: ['rfqRef', 'rows'],
|
||||
properties: {
|
||||
rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' },
|
||||
rows: { type: 'array', title: '目标报价清单(每行带基准价与价源)' },
|
||||
},
|
||||
},
|
||||
description: '目标报价清单(每行带基准价与价源)',
|
||||
},
|
||||
],
|
||||
selective: [],
|
||||
},
|
||||
requiredDeliverables: [{ key: 'compare-table', label: '寻源比价表' }],
|
||||
acceptanceCriteria: [
|
||||
{ key: 'each-has-quote', label: '每料有有效报价或明确无货', level: 'L2', predicate: 'unquoted = 0' },
|
||||
{ key: 'channels-enough', label: 'BOM后期每料 2~3 家渠道', level: 'L2', predicate: 'channelsPicked >= 2' },
|
||||
{ key: 'cross-check', label: '价格验证按场景规则做过', level: 'L3' },
|
||||
{ key: 'prm-release', label: '采购放行', level: 'L5' },
|
||||
/*
|
||||
* 必交付物只有一件:**目标报价清单**。寻源结论(verdict)是它的结论栏,
|
||||
* `ledgers` 说清这份成果由哪几张表承载。
|
||||
*/
|
||||
requiredDeliverables: [
|
||||
{
|
||||
key: 'quotation-roster',
|
||||
label: '目标报价清单(每行带基准价)',
|
||||
ledger: `${D}.quote_list`,
|
||||
/* 验收页上让 PRM 就地改的那几列 —— 恰是他的职权面(价格五列) */
|
||||
editableAtRelease: ['supplier', 'base_price', 'moq', 'source_lead_time', 'note'],
|
||||
ledgers: [`${D}.quote_list`, `${D}.verdict`],
|
||||
},
|
||||
],
|
||||
/*
|
||||
* 验收判据 —— **机检(L1/L2)只读基座数得出来的数**:
|
||||
* `_rows` 基座数行数、`_missing` 基座按 required 现数(与执行卡提交闸同一个数
|
||||
* 同一套算法)、`_price_basis_hist/_sourced` 基座按值现数。
|
||||
* 上一版两条 L2 读活动自报的 verdict 列(unquoted_total / supplier_total)——
|
||||
* 那不是门,是「改哪个数能过」的提示。
|
||||
*/
|
||||
acceptanceCriteria: [
|
||||
{
|
||||
key: 'roster-complete',
|
||||
label: '清单齐:上游清单逐条承继,说了几条清单里就躺着几行',
|
||||
level: 'L1',
|
||||
predicate: 'quote_line_total > 0 and quote_sourcing_quote_list_rows = quote_line_total',
|
||||
},
|
||||
{
|
||||
key: 'price-complete',
|
||||
label: '每行有基准价:A 类历史价、B 类货源价,缺一行放不了',
|
||||
level: 'L1',
|
||||
predicate: 'quote_sourcing_quote_list_rows > 0 and quote_sourcing_quote_list_missing = 0',
|
||||
},
|
||||
{
|
||||
key: 'basis-conserved',
|
||||
label: '价源守恒:历史价条数加货源价条数恰等于总行数,逐行留徽可溯',
|
||||
level: 'L2',
|
||||
predicate:
|
||||
'quote_sourcing_quote_list_price_basis_hist + quote_sourcing_quote_list_price_basis_sourced = quote_sourcing_quote_list_rows',
|
||||
},
|
||||
/*
|
||||
* 来源可核是**裁量**(L3):供应商靠不靠谱、价问没问真,机器数不出来。
|
||||
* 这句 label 与剧本里 judge 的 criterion **必须逐字一致**。
|
||||
*/
|
||||
{
|
||||
key: 'source-verifiable',
|
||||
label: '货源与价格来源可核:B 类行的供应商与底价来自真实询价或可靠渠道,PRM 对报价负责',
|
||||
level: 'L3',
|
||||
},
|
||||
/* L5 是责任关:那张卡本身就是这道关,卡面上不摆它(基座统一滤) */
|
||||
{ key: 'prm-release', label: 'PRM 放行:签下去这份清单即定盘并交成本定价', level: 'L5' },
|
||||
],
|
||||
revisableAtRelease: [],
|
||||
prohibitions: [
|
||||
{ key: 'no-made-up-price', statement: '问不到的价不许编:要么剔出本轮,要么按内部估价填并在备注写明' },
|
||||
{ key: 'no-stockless-trader', statement: '现货场景不得选无库存转单商' },
|
||||
{ key: 'no-unverified', statement: '不实回价或远低行情的,必须换渠道补询,不得直接采用' },
|
||||
],
|
||||
authorization: {
|
||||
discretion: [{ key: 'channel-band', statement: '按场景放宽或收紧渠道尺度;贸易商尽量不用' }],
|
||||
preauthorizedActions: [{ tool: 'mail.send', operation: 'inquiry', limit: '询价模板' }],
|
||||
humanCallBudget: 8,
|
||||
discretion: [
|
||||
{ key: 'channel-band', statement: 'PRM 按场景放宽或收紧渠道尺度;贸易商尽量不用' },
|
||||
],
|
||||
/*
|
||||
* 空着不是省事:这个字段全仓没有一个消费者(债 ⑨)。
|
||||
* 上一版这里写着 `mail.send` 的预授权 —— 写了也不会被执行,一并撤掉。
|
||||
*/
|
||||
preauthorizedActions: [],
|
||||
humanCallBudget: 4,
|
||||
},
|
||||
resources: {
|
||||
tools: [{ name: 'mail.send' }, { name: 'crm.history-price-read' }],
|
||||
/*
|
||||
* 只留历史价一件工具(A 类预填的取数口)。上一版还声明 `mail.send` ——
|
||||
* MVP 口径下发询在 PRM 系统外,系统不代发,声明它只是白占白名单。
|
||||
*/
|
||||
tools: [{ name: 'crm.history-price-read' }],
|
||||
skills: SOURCING_SKILLS.map((s) => s.id),
|
||||
models: ['deepseek-v4-flash'],
|
||||
roles: ['PRM'],
|
||||
budget: { steps: 120, tokens: 300_000, activeMinutes: 480, humanCalls: 8 },
|
||||
/* PRM 系统外询价,按场景时限 BOM后期 3~9 日 —— activeMinutes 给足等待 */
|
||||
budget: { steps: 50, tokens: 250_000, activeMinutes: 480, humanCalls: 4 },
|
||||
},
|
||||
},
|
||||
|
||||
/* ── 声明 ────────────────────────────────────────────────────── */
|
||||
declarations: {
|
||||
models: ['deepseek-v4-flash'],
|
||||
tools: [{ name: 'mail.send' }, { name: 'crm.history-price-read' }],
|
||||
tools: [{ name: 'crm.history-price-read' }],
|
||||
ledger: [
|
||||
/*
|
||||
* ── 这里**去掉了 `supplier_quote` / `compare_table` 两张表**(2026-08-02)──
|
||||
*
|
||||
* MVP 口径下逐家回价与横比都不在系统内产出 —— 询价的实质工作在 PRM 系统外,
|
||||
* 系统收的是**结论**(定盘的供应商与底价),落在清单行的价格五列上。
|
||||
* 撤掉声明不 drop 物理表(物化只增不破坏),历史行还在库里。
|
||||
*/
|
||||
{
|
||||
domain: DOMAIN,
|
||||
object: 'supplier_quote',
|
||||
label: '供应商回价单',
|
||||
domain: D,
|
||||
object: 'quote_list',
|
||||
kind: 'record',
|
||||
caseKey: 'rfq_ref',
|
||||
label: '目标报价清单',
|
||||
fields: [
|
||||
{ name: 'pn', type: 'text', primaryKey: true, masked: false },
|
||||
{ name: 'supplier', type: 'text', primaryKey: true, masked: false, label: '供应商' },
|
||||
{ name: 'price', type: 'number', primaryKey: false, masked: false, label: '回价' },
|
||||
{ name: 'moq', type: 'integer', primaryKey: false, masked: false, label: 'MOQ' },
|
||||
{ name: 'lead_time', type: 'text', primaryKey: false, masked: false, label: '交期' },
|
||||
{ name: 'valid_until', type: 'date', primaryKey: false, masked: false, label: '有效期' },
|
||||
{ name: 'grade', type: 'text', primaryKey: false, masked: false, label: '渠道等级' },
|
||||
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
|
||||
{ name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' },
|
||||
{ name: 'customer_pn', type: 'text', primaryKey: false, masked: false, label: '客户料号' },
|
||||
{ name: 'category', type: 'text', primaryKey: false, masked: false, label: '品类' },
|
||||
{ name: 'brand', type: 'text', primaryKey: false, masked: false, label: '制造商' },
|
||||
{ name: 'eau', type: 'integer', primaryKey: false, masked: false, label: '年用量 EAU' },
|
||||
{ name: 'target_price', type: 'number', primaryKey: false, masked: false, label: '目标价' },
|
||||
{ name: 'lead_time', type: 'text', primaryKey: false, masked: false, label: '客户要求交期' },
|
||||
/* 客户备注承继自上游 —— 认证要求、预授权、急料标注都在这一格,是问价口径的第一手依据 */
|
||||
{ name: 'remark', type: 'text', primaryKey: false, masked: false, label: '客户备注(约束/急料)' },
|
||||
/* 选型转来的行带替代料 —— 取价对象是它;受理直达与单飞的行此列空着 */
|
||||
{ name: 'alt_pn', type: 'text', primaryKey: false, masked: false, label: '替代料 PN' },
|
||||
{ name: 'type_tag', type: 'text', primaryKey: false, masked: false, label: '类别(承继)', tally: ['A', 'B', 'C'] },
|
||||
{ name: 'supplier', type: 'text', primaryKey: false, masked: false, label: '供应商' },
|
||||
/*
|
||||
* **底价是必填** —— 标了 `required`,基座就数得出「缺基准价的有几行」
|
||||
* (读数 `quote_sourcing_quote_list_missing` / `_missing_keys`)。
|
||||
* 这个数同时喂三处:件上的缺价红带、执行卡的提交闸、放行门 L1 ——
|
||||
* 同一个数、同一套算法,三处永远说同一句话(同源纪律)。
|
||||
*/
|
||||
{ name: 'base_price', type: 'number', primaryKey: false, masked: false, required: true, label: '底价' },
|
||||
/* MOQ 常带单位与档位(1K / 2500+),按文本收,不硬转数值 */
|
||||
{ name: 'moq', type: 'text', primaryKey: false, masked: false, label: 'MOQ' },
|
||||
{ name: 'source_lead_time', type: 'text', primaryKey: false, masked: false, label: '货源交期' },
|
||||
/*
|
||||
* **价源:取值域给出来,条数由基座现数** —— HIST(库内历史价)/ SOURCED(本单问回)。
|
||||
* 价源守恒判据、分布条、价源徽读的全是基座读数;值是机器码,
|
||||
* 人读名(历史价/货源价)由模版样式表翻译。
|
||||
*/
|
||||
{ name: 'price_basis', type: 'text', primaryKey: false, masked: false, label: '价源', tally: ['HIST', 'SOURCED'] },
|
||||
/* 备注是我方写的注,不标 expect(标了 `_gaps` 恒大于 0,门就废了 —— A1 的账) */
|
||||
{ name: 'note', type: 'text', primaryKey: false, masked: false, label: '备注' },
|
||||
],
|
||||
},
|
||||
{
|
||||
domain: DOMAIN,
|
||||
object: 'compare_table',
|
||||
label: '寻源比价表',
|
||||
/* 寻源结论 —— 一单一行:汇总数(哨兵与报告读)、一句人话结论 */
|
||||
domain: D,
|
||||
object: 'verdict',
|
||||
kind: 'record',
|
||||
caseKey: 'rfq_ref',
|
||||
label: '寻源结论',
|
||||
fields: [
|
||||
{ name: 'pn', type: 'text', primaryKey: true, masked: false },
|
||||
{ name: 'supplier', type: 'text', primaryKey: false, masked: false },
|
||||
{ name: 'price', type: 'number', primaryKey: false, masked: false },
|
||||
{ name: 'conclusion', type: 'text', primaryKey: false, masked: false, label: '结论' },
|
||||
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
|
||||
{ name: 'customer_code', type: 'text', primaryKey: false, masked: false, label: '客户码' },
|
||||
{ name: 'customer_name', type: 'text', primaryKey: false, masked: false, label: '客户名称' },
|
||||
{ name: 'quote_line_total', type: 'integer', primaryKey: false, masked: false, label: '清单条目数' },
|
||||
{ name: 'pending_total', type: 'integer', primaryKey: false, masked: false, label: '待补价条数' },
|
||||
/* A 类预填走完的凭据(0 也要写)—— 哨兵按它放 PRM 那一步入场 */
|
||||
{ name: 'hist_priced_total', type: 'integer', primaryKey: false, masked: false, label: 'A 类已按历史价预填条数' },
|
||||
/* 「PRM 的答复处理过了」的凭据 —— 只在人环走完才产生 */
|
||||
{ name: 'sourced_total', type: 'integer', primaryKey: false, masked: false, label: 'PRM 已补价条数' },
|
||||
{ name: 'route_decided_total', type: 'integer', primaryKey: false, masked: false, label: '已定盘' },
|
||||
{ name: 'summary', type: 'text', primaryKey: false, masked: false, label: '寻源结论' },
|
||||
],
|
||||
},
|
||||
],
|
||||
listeners: [
|
||||
{
|
||||
// 回价到期:临过期提醒复核确认,过期触发重询 —— 时限必配
|
||||
/** 供应商回价到达:记一笔观察(PRM 在系统外收回价,这里只接得到通道转进来的) */
|
||||
origin: 'static',
|
||||
eventType: 'data',
|
||||
keyFilter: { kind: 'supplier-quote' },
|
||||
@@ -144,6 +268,7 @@ export const manifest: ActivityManifest = {
|
||||
escalation: ['nudge', 'escalate'],
|
||||
},
|
||||
{
|
||||
/** 回价临过期:提醒复核确认 —— 时限必配 */
|
||||
origin: 'static',
|
||||
eventType: 'time',
|
||||
keyFilter: { kind: 'quote-expiry' },
|
||||
@@ -152,33 +277,124 @@ export const manifest: ActivityManifest = {
|
||||
escalation: ['nudge', 'reroute'],
|
||||
},
|
||||
],
|
||||
// 按场景时限:BOM早期/现货 1~4 日、样品 1~3 日、BOM后期 3~9 日 —— 取最宽做停滞阈值
|
||||
harness: {
|
||||
memoryWindow: 30,
|
||||
memoryWindow: 24,
|
||||
// PRM 在系统外询价,按场景时限最宽 BOM后期 3~9 日 —— 停滞阈值放到两天
|
||||
stallAfter: 'P2D',
|
||||
nudge: [{ after: 'P1D', action: 'nudge' }],
|
||||
retry: { times: 2, backoff: 'PT5M' },
|
||||
},
|
||||
},
|
||||
|
||||
/* ── 资产初始版 ──────────────────────────────────────────────── */
|
||||
assets: {
|
||||
businessModel: {
|
||||
cmmnXml: CMMN_XML,
|
||||
// 双表示同版本:XML 是机器面,说明书是叙事面 —— 不允许分叉
|
||||
narrative: NARRATIVE,
|
||||
dmn: STRATEGY_DMN,
|
||||
dmn: SOURCING_DMN,
|
||||
steps: SOURCING_STEPS,
|
||||
phases: SOURCING_PHASES,
|
||||
},
|
||||
skills: SOURCING_SKILLS,
|
||||
viewTemplates: [{ name: 'case-overview', html: SOURCING_CASE }],
|
||||
/*
|
||||
* 两个局面各一张(与 A2 同款口径,2026-08-02):
|
||||
* · `sourcing-worksheet` 寻源询价工作单 —— 归一预填与 PRM 补价期间摆它:
|
||||
* 逐颗待询料的询价档案、按场景圈渠道的规则、价格验证的尺、发询纪律与时限,
|
||||
* 填回区就长在上面(素材出处 docs/MVP示例工作流.html §1.7,只作文字参考);
|
||||
* · `sourcing-roster` 目标报价清单 —— 汇稿定盘后与放行、归档摆它。
|
||||
*/
|
||||
viewTemplates: [
|
||||
{ name: 'sourcing-worksheet', html: SOURCING_WORKSHEET },
|
||||
{ name: 'sourcing-roster', html: SOURCING_ROSTER },
|
||||
],
|
||||
cardSchemas: {},
|
||||
/*
|
||||
* distillHooks 只留 terminal —— 上一版还有 `milestone: 'M_quotes'`,
|
||||
* 而案例模型里从来没有这个里程碑(死链,钩子永不触发)。
|
||||
*/
|
||||
distillHooks: [
|
||||
{ on: 'milestone', milestone: 'M_quotes', prompt: '归纳各渠道的回价节奏与配合度。' },
|
||||
{ on: 'terminal', prompt: '归纳本单寻源:哪些渠道靠谱、哪些场景的价格验证规则该收紧。' },
|
||||
{
|
||||
on: 'terminal',
|
||||
prompt: '归纳本单寻源:哪些料历史价可用、货源价与历史价的偏离形态、剔单的原因分布。',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
/* ── 样例集 —— 三条形状真不同(常备案例即验收案例)───────────── */
|
||||
samples: [
|
||||
{ name: 'BOM后期询价', input: { pn: 'LM358DR', scenario: 'BOM后期', quantity: 5000 }, expect: { ports: ['compare'] } },
|
||||
{
|
||||
/*
|
||||
* ① 选型转来:A2 放行载荷的真实形状 —— rows 里清单行(带 alt_pn 与现标)
|
||||
* 混着结论行;A 类行的取价对象是替代料。
|
||||
*/
|
||||
name: '选型转来 · 一颗转 B 的替代料要问价(A 类行历史价预填)',
|
||||
input: {
|
||||
rfqRef: 'RFQ MD14.1',
|
||||
customer_code: 'CU-0142',
|
||||
customer_name: 'Itelma',
|
||||
rows: [
|
||||
{
|
||||
rfq_ref: 'RFQ MD14.1',
|
||||
pn: 'BBPY00201209601Y00',
|
||||
customer_pn: '4-14-01502',
|
||||
category: '滤波器',
|
||||
brand: 'YAGEO',
|
||||
eau: 30000,
|
||||
alt_pn: 'PBY201209T-601Y-N',
|
||||
orig_type: 'C',
|
||||
type_tag: 'B',
|
||||
basis: '替代料 PBY201209T-601Y-N 库内无历史 → 转 B',
|
||||
},
|
||||
{
|
||||
rfq_ref: 'RFQ MD14.1',
|
||||
pn: 'CJTB0101R6N',
|
||||
customer_pn: '4-00-04102',
|
||||
category: '集成电路',
|
||||
brand: 'Jiangsu Changjing',
|
||||
eau: 60000,
|
||||
orig_type: 'A',
|
||||
type_tag: 'A',
|
||||
},
|
||||
{
|
||||
rfq_ref: 'RFQ MD14.1',
|
||||
alt_line_total: 2,
|
||||
c_total: 1,
|
||||
need_sourcing: true,
|
||||
route_decided_total: 1,
|
||||
customer_name: 'Itelma',
|
||||
},
|
||||
],
|
||||
},
|
||||
expect: { ports: ['quotation'] },
|
||||
},
|
||||
{
|
||||
/*
|
||||
* ② 受理直达(E3 那条路):A1 载荷形状 —— records 混行,B 类两颗要问价,
|
||||
* A 类一颗历史价预填。
|
||||
*/
|
||||
name: '受理直达 · 两颗 B 类要问价(records 混行形状)',
|
||||
input: {
|
||||
rfqRef: 'RFQ-2026-0555',
|
||||
customer_name: '青岛海洋电子',
|
||||
records: [
|
||||
{ rfq_ref: 'RFQ-2026-0555', pn: 'PESD2ETH-XR', category: '二极管', brand: 'Nexperia', eau: 30000, type_tag: 'B' },
|
||||
{ rfq_ref: 'RFQ-2026-0555', pn: 'PESD2ETH-XR', history_days: 9999, market_stable: false, lifecycle: '未知', type_tag: 'B', basis: '无历史/过期/波动 → B' },
|
||||
{ rfq_ref: 'RFQ-2026-0555', pn: 'NE555P', category: '定时器', brand: 'TI', eau: 5000, type_tag: 'B' },
|
||||
{ rfq_ref: 'RFQ-2026-0555', pn: 'UMW AO6604', category: '晶体管', brand: 'UMW', eau: 30000, type_tag: 'A' },
|
||||
{ rfq_ref: 'RFQ-2026-0555', line_total: 3, type_a_total: 1, type_b_total: 2, type_c_total: 0, need_sourcing: true },
|
||||
],
|
||||
},
|
||||
expect: { ports: ['quotation'] },
|
||||
},
|
||||
{
|
||||
/* ③ 单飞宽进:没有单号没有清单,正文列料 —— 归一造案号,全按 B 待询 */
|
||||
name: '单飞需求 · 正文列两颗料要问底价(宽进归一)',
|
||||
input: {
|
||||
subject: '两颗现货料问底价',
|
||||
body: '帮忙问两颗料的现货底价:SS8050 三极管 20K,XC6206P332MR LDO 10K。现货场景,只要有库存实拍的供应商。',
|
||||
},
|
||||
expect: { ports: ['quotation'] },
|
||||
},
|
||||
],
|
||||
|
||||
entry: { file: 'index.js', checksum: 'dev-entry-quote-sourcing' },
|
||||
|
||||
@@ -1,46 +1,49 @@
|
||||
/**
|
||||
* A3 技能 —— 寻源的方法经验(AM 08)。
|
||||
* loop 里技能由 AI **按适用描述临场装配**:常驻上下文只有目录,按事加载正文。
|
||||
* A3 技能 —— 寻源询价 MVP 简化形的方法经验(AM 08)。
|
||||
*
|
||||
* **载方法,不载权力**;渐进披露:常驻上下文只有目录,按事加载正文。
|
||||
* 剧本改了口径,技能里同一句也要跟着改 —— 2026-08-02 改盘后:
|
||||
* 询价的实质工作在 PRM 系统外,系统管归一 / 预填 / 组卡 / 齐检;
|
||||
* 旧的「按场景圈渠道」「该等还是该问」讲的是 PRM 脑子里的方法,系统不替他办,删。
|
||||
*/
|
||||
import { skillIdSchema } from '@contracts';
|
||||
import type { Skill } from '@contracts';
|
||||
|
||||
export const SOURCING_SKILLS: Skill[] = [
|
||||
{
|
||||
id: skillIdSchema.parse('sourcing.pick-channels'),
|
||||
label: '按场景圈渠道',
|
||||
applicability: '拿到料号与场景,要决定向谁发询时。',
|
||||
id: skillIdSchema.parse('sourcing.normalize-roster'),
|
||||
label: '归一目标报价清单',
|
||||
applicability: '开案第一步:把上游清单(或单飞需求)整份落成目标报价清单时。',
|
||||
method: [
|
||||
'渠道不是越多越好:发得越广,回价越慢、口径越乱、对供应商的信用消耗越大。',
|
||||
'上游载荷是**全部落地行混在一起**的一份:选型转来(`rows`)的清单行带 alt_pn 与现标,',
|
||||
'受理直达(`records`)的以受理记录行(带 eau/brand)为准;带总数的结论行不是料,别落。',
|
||||
'**按 pn 归并**,一颗料一行,一拍写完(rows 收数组)。',
|
||||
'',
|
||||
'按场景定档:',
|
||||
'· 常规补货 → 原厂代理 2 家足够,比的是账期与交期。',
|
||||
'· BOM 后期 / 急单 → 加 1–2 家现货商,比的是**能不能拿到货**,价格其次。',
|
||||
'· 停产料 → 直接问现货商与拆解渠道,原厂问了也是白问。',
|
||||
'',
|
||||
'每多发一家,就多一份要等的回价 —— 发询前先想清楚:这一家的回价会改变你的决定吗?',
|
||||
'不会,就别发。',
|
||||
'三条纪律:',
|
||||
'① PN 一个字符不许改;上游没给的列留空字符串,绝不写 0 顶空。',
|
||||
'② type_tag 照上游现标承继,本活动不改类 —— 改类是上游的职权。',
|
||||
'③ 单号 rfq_ref 原样承继;单飞认不出才造「SRC-<型号或类型>」,造完从头用到尾。',
|
||||
].join('\n'),
|
||||
crystallized: [],
|
||||
knowledgeRefs: ['渠道分级台账'],
|
||||
version: '1.0.0',
|
||||
knowledgeRefs: ['docs/MVP示例工作流.html §1.7'],
|
||||
version: '2.0.0',
|
||||
},
|
||||
{
|
||||
id: skillIdSchema.parse('sourcing.wait-or-ask'),
|
||||
label: '该等还是该问',
|
||||
applicability: '当下可为与目标所需交集为空(依赖断点)时。',
|
||||
id: skillIdSchema.parse('sourcing.prefill-hist'),
|
||||
label: 'A 类按历史价预填基准',
|
||||
applicability: '清单落好后,要给 A 类行预填基准价时。',
|
||||
method: [
|
||||
'断点只有两种解法,选错就是白等或白问:',
|
||||
'· 缺口**在世界侧**(供应商还没回价、样品还在路上)→ 立监听,必配时限。',
|
||||
' 时限到了要有升级动作:催办 → 升级 → 改道,不能无限等。',
|
||||
'· 缺口**只有人知道**(客户能不能接受替代、预算上限多少)→ 调人。',
|
||||
'A 类的意思就是**库内历史价可用**(≤90 天且行情稳)—— 基准价直接取历史价,',
|
||||
'不必劳烦 PRM 再问一遍。调 `crm.history-price-read`:**pns 收数组一次传完**;',
|
||||
'**取价对象 = alt_pn 有值取 alt_pn,否则取 pn**(选型转来的 A 行,行情稳的是替代料)。',
|
||||
'',
|
||||
'判断标准很简单:**这个信息在系统外会自己到来吗?**',
|
||||
'会 → 等(监听);不会 → 问(调人)。',
|
||||
'把「该问的」等成超时,是寻源里最常见也最贵的错误。',
|
||||
'出参逐行照抄:base_price = lastPrice、price_basis 写 HIST(大写,基座按值现数)。',
|
||||
'hit=false 的 A 行**别编价** —— 留空当 B 类待询处理:上游打标与库内现状偶有出入,',
|
||||
'照实处理,不替谁圆场。',
|
||||
].join('\n'),
|
||||
crystallized: [],
|
||||
knowledgeRefs: [],
|
||||
version: '1.0.0',
|
||||
// 结晶片段:查历史价这一步每单都一样 —— 确定性子序列,不必每次让模型重想
|
||||
crystallized: [{ tool: 'crm.history-price-read', input: { pns: 'pns' } }],
|
||||
knowledgeRefs: ['三分类规则 P-03(A 行的定义)'],
|
||||
version: '2.0.0',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* A1 两份执行页的样式表 —— **从 PRD 目标件原样搬过来**,不是我照着复述的。
|
||||
*
|
||||
* 出处:`~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理需求.html` 的整段 `<style>`,
|
||||
* 外加 `A1-询价受理记录.html` 里归档件独有的那几件(分类分布条 / 类徽 / 判定表 /
|
||||
* 结论横幅 / 落地动作 / 完成标)。**一个数值都没改**。
|
||||
*
|
||||
* 为什么是搬而不是抄:前三轮我用自己那套外壳「照着做」,块搬齐了、版面是两套 ——
|
||||
* 章节头成了彩色方块编号、多出摘要与元信息带、字号用 clamp。人一眼就看出不是那份件。
|
||||
* 目标件是**像素级的答案**,搬过来才不会在复述里走样。
|
||||
*
|
||||
* 选择器保留 `.proto-root .app-shell` 前缀,所以模版正文也要照原型那样包两层壳 ——
|
||||
* 少一层壳,整张样式表一条都不生效(踩过)。
|
||||
*/
|
||||
export const PRD_CSS = `
|
||||
/* ============================================================================
|
||||
JCAI 员工面工作台 · A1 受理归一的过程成果「询价受理需求」——报告本体
|
||||
立场:**AI 已从 RFQ 邮件抽取归一**,人在这里审核并就地修订。
|
||||
两态:读态(AI 稿 + 红/灰占位提示缺项) ⇄ 编辑态(全字段可改,保存 / 取消)
|
||||
核心是「有效 PN 清单」;清单之上四件受理要素:客户公司 / 联系人 / 客户属性 / 有效 PN。
|
||||
|
||||
令牌与既有组件取自 workbench/src/app/(proto)/app.css,数值未改。
|
||||
⚠️ 修正一处原型 bug:app.css:2062-2064 的 '.jc-rp-sign span'(权重 0,1,1)
|
||||
盖过了 '.jc-rp-ava'(0,1,0),把头像的 display:grid 覆盖成 block、字色改灰,
|
||||
导致落款头像里的字掉到圆外。本文件把该规则收窄为 '.jc-rp-sign > div > span'。
|
||||
新增件:四要素卡 .jc-four/.jc-fcard · 占位胶囊 .jc-ph · 分段控件 .jc-seg ·
|
||||
来源条 .jc-src · 录入表 .jc-tbl-in
|
||||
========================================================================== */
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body { background: #e6eaf1; -webkit-font-smoothing: antialiased; }
|
||||
|
||||
.proto-root .app-shell {
|
||||
--brand: #3370ff; --brand-deep: #245bdb;
|
||||
--brand-soft: #e1eaff; --brand-wash: #f0f4ff;
|
||||
--ai-grad: linear-gradient(135deg, #3370ff, #7f3bf5);
|
||||
--ink: #1f2329; --ink-2: #41464e; --ink-3: #646a73; --ink-4: #8f959e;
|
||||
--line: #dee0e3; --line-soft: #e8eaed; --soft: #f8f9fb;
|
||||
--jcai-bg: #e6eaf1; --cardline: #e1e6ec; --card-shadow: 0 1px 2px rgba(31, 35, 41, .05);
|
||||
--ok: #278717; --ok-soft: #e8f8e4;
|
||||
--warn: #de7802; --warn-accent: #ff8800; --warn-soft: #fff4e5;
|
||||
--danger: #d83931; --danger-soft: #feeae9;
|
||||
--mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace;
|
||||
--shadow-brand: 0 6px 16px rgba(51, 112, 255, .22);
|
||||
--t-fast: .16s cubic-bezier(.3, 0, .2, 1);
|
||||
--t-med: .26s cubic-bezier(.3, 0, .2, 1);
|
||||
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: var(--jcai-bg); min-height: 100vh; color: var(--ink);
|
||||
}
|
||||
.proto-root :where(button, input, select, a, [tabindex]):focus-visible {
|
||||
outline: 2px solid var(--brand); outline-offset: 2px; border-radius: 8px;
|
||||
}
|
||||
.proto-root ::selection { background: var(--brand-soft); }
|
||||
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes jcBlink { 0%,100% { opacity: 1 } 50% { opacity: .35 } }
|
||||
@keyframes jcStamp { from { opacity: 0; transform: rotate(-6deg) scale(1.7); } to { opacity: .9; transform: rotate(-6deg) scale(1); } }
|
||||
|
||||
/* 放开宽度:1160px 是「9 列全展开、一个字都不裁」的最小容器宽度(见下方列 min-width 之和) */
|
||||
.jc-stage { max-width: 1160px; margin: 0 auto; padding: 22px 16px 52px; }
|
||||
.proto-root .jc-mono { font-family: var(--mono); font-size: 11px; color: var(--ink-3); }
|
||||
.proto-root .jc-pulse-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--brand); display: inline-block; animation: jcBlink 1.1s ease-in-out infinite; }
|
||||
|
||||
/* ── 报告外壳 ───────────────────────────────────────────────────────── */
|
||||
.proto-root .jc-report { background: #fff; border: 1px solid var(--cardline); border-radius: 14px; box-shadow: var(--card-shadow); overflow: hidden; animation: rise var(--t-med) both; }
|
||||
.proto-root .jc-rp-head { position: relative; overflow: hidden; display: flex; align-items: flex-start; gap: 12px; padding: 16px 20px 13px; border-bottom: 2px solid var(--ink); }
|
||||
/* 题头下缘粗细双线 = 正式件版式 */
|
||||
.proto-root .jc-rp-head::after { content: ""; position: absolute; left: 0; right: 0; bottom: -4px; height: 1px; background: var(--line); }
|
||||
/* 题头右上的品牌光晕:与工作台标题区同一签名件 */
|
||||
.proto-root .jc-rp-head::before { content: ""; position: absolute; right: -70px; top: -96px; width: 240px; height: 240px; border-radius: 999px; background: radial-gradient(circle, rgba(51,112,255,.10), transparent 70%); pointer-events: none; }
|
||||
.proto-root .jc-rp-head > * { position: relative; z-index: 1; }
|
||||
.proto-root .jc-rp-badge { flex: none; width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand); }
|
||||
.proto-root .jc-rp-tit { flex: 1; min-width: 0; }
|
||||
.proto-root .jc-rp-tit em { display: block; font-style: normal; font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); font-family: var(--mono); }
|
||||
.proto-root .jc-rp-tit h3 { margin: 4px 0 0; font-size: 18px; font-weight: 800; color: var(--ink); letter-spacing: -.01em; line-height: 1.3; }
|
||||
.proto-root .jc-rp-wait { display: inline-flex; align-items: center; gap: 6px; flex: none; margin-top: 4px; font-size: 11px; font-weight: 700; border-radius: 999px; padding: 4px 12px; color: var(--brand-deep); background: var(--brand-wash); border: 1px solid var(--brand-soft); }
|
||||
.proto-root .jc-rp-wait.warn { color: var(--warn); background: var(--warn-soft); border-color: #ffd9a8; }
|
||||
.proto-root .jc-rp-wait.edit { color: #fff; background: var(--brand); border-color: var(--brand); }
|
||||
.proto-root .jc-rp-stamp { flex: none; margin-top: 2px; transform: rotate(-6deg); border: 2px solid currentColor; border-radius: 9px; padding: 4px 8px 3px 12px; font-size: 13px; font-weight: 900; letter-spacing: .28em; color: var(--ok); opacity: .9; animation: jcStamp .32s cubic-bezier(.2, 1.4, .4, 1) both; }
|
||||
.proto-root .jc-rp-bd { padding: 18px 20px 12px; }
|
||||
.proto-root .jc-rp-part { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 9px; margin: 20px 0 11px; }
|
||||
.proto-root .jc-rp-part:first-child { margin-top: 0; }
|
||||
.proto-root .jc-rp-part > b { flex: none; font-size: 11px; font-weight: 800; letter-spacing: .12em; color: var(--ink-3); }
|
||||
.proto-root .jc-rp-part > .ln { flex: 1; height: 1px; background: var(--line-soft); }
|
||||
.proto-root .jc-rp-part > em { flex: none; font-style: normal; font-size: 10px; font-weight: 700; color: var(--ink-4); font-family: var(--mono); background: var(--soft); border-radius: 999px; padding: 2px 9px; white-space: nowrap; }
|
||||
.proto-root .jc-rp-part > em.warn { color: var(--warn); background: var(--warn-soft); }
|
||||
|
||||
/* 落款 —— ⚠️ 选择器收窄到 > div > *,别再命中头像那个 span */
|
||||
.proto-root .jc-rp-foot { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 13px 20px 15px; border-top: 2px solid var(--line); background: #fbfcfd; }
|
||||
.proto-root .jc-rp-sign { display: flex; align-items: center; gap: 10px; flex: 1 1 200px; min-width: 0; }
|
||||
.proto-root .jc-rp-ava { flex: 0 0 auto; width: 30px; height: 30px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; line-height: 1; color: #fff; background: var(--brand); }
|
||||
.proto-root .jc-rp-sign > div { min-width: 0; }
|
||||
.proto-root .jc-rp-sign > div > b { display: block; font-size: 12px; font-weight: 700; color: var(--ink-2); }
|
||||
.proto-root .jc-rp-sign > div > span { display: block; margin-top: 1px; font-size: 10.5px; color: var(--ink-4); }
|
||||
.proto-root .jc-rp-acts { display: flex; align-items: center; gap: 8px; margin-left: auto; flex: none; }
|
||||
.proto-root .jc-hintline { font-size: 11px; font-weight: 700; color: var(--warn); line-height: 1.5; }
|
||||
|
||||
.proto-root .jc-btn { display: inline-flex; align-items: center; gap: 5px; border: 0; border-radius: 7px; padding: 8px 14px; font-size: 12px; font-weight: 600; font-family: inherit; background: var(--brand); color: #fff; cursor: pointer; transition: all var(--t-fast); }
|
||||
.proto-root .jc-btn:hover:not(:disabled) { background: var(--brand-deep); }
|
||||
.proto-root .jc-btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.proto-root .jc-btn-ghost { background: #fff; color: var(--ink-2); border: 1px solid var(--line); }
|
||||
.proto-root .jc-btn-ghost:hover:not(:disabled) { background: var(--brand-wash); color: var(--brand); border-color: var(--brand-soft); }
|
||||
.proto-root .jc-btn-primary { background: var(--ai-grad); }
|
||||
.proto-root .jc-btn-primary:hover:not(:disabled) { filter: brightness(1.07); box-shadow: var(--shadow-brand); transform: translateY(-1px); }
|
||||
|
||||
/* ── 来源信件:客户 RFQ 邮件原文(复用原型 jc-letter 件,青色 = 收件) ── */
|
||||
.proto-root .jc-letter { position: relative; border: 1px solid var(--cardline); border-left: 3px solid #0aa294; border-radius: 12px; padding: 14px 16px 15px; background: #fff; box-shadow: var(--card-shadow); }
|
||||
.proto-root .jc-letter-h { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 11px; }
|
||||
.proto-root .jc-letter-dir { display: inline-flex; align-items: center; gap: 5px; font-size: 10.5px; font-weight: 800; padding: 3px 9px; border-radius: 999px; background: #e4f8f5; color: #0a7f74; }
|
||||
.proto-root .jc-letter-h .jc-chip { font-size: 10.5px; padding: 3px 9px; border-radius: 999px; background: var(--brand-wash); color: var(--brand-deep); border: 1px solid var(--brand-soft); }
|
||||
.proto-root .jc-letter-h > code { margin-left: auto; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
|
||||
.proto-root .jc-letter-meta { display: flex; flex-direction: column; gap: 4px; margin-bottom: 11px; padding-bottom: 11px; border-bottom: 1px solid var(--line-soft); font-size: 11.5px; color: var(--ink-2); }
|
||||
.proto-root .jc-letter-meta p { margin: 0; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
|
||||
.proto-root .jc-letter-meta i { flex: none; font-style: normal; width: 44px; font-size: 10px; font-weight: 800; letter-spacing: .06em; color: var(--ink-4); }
|
||||
.proto-root .jc-letter-meta code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); overflow-wrap: anywhere; }
|
||||
.proto-root .jc-letter-sub { margin: 0 0 9px; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.45; overflow-wrap: anywhere; }
|
||||
/* 正文:原文照录,pre-wrap 保原始换行;不截断、不折叠 */
|
||||
.proto-root .jc-letter-body { margin: 0; font-size: 12.5px; line-height: 1.72; color: var(--ink-2); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-letter-body b { color: var(--ink); font-weight: 700; }
|
||||
.proto-root .jc-atts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; padding-top: 11px; border-top: 1px dashed var(--line-soft); }
|
||||
.proto-root .jc-att { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px 5px 8px; border: 1px solid var(--brand-soft); border-radius: 8px; background: var(--brand-wash); font-size: 11.5px; color: var(--brand-deep); }
|
||||
.proto-root .jc-att svg { flex: none; color: var(--brand); }
|
||||
.proto-root .jc-att code { font-family: var(--mono); font-size: 10.5px; }
|
||||
.proto-root .jc-att span { color: var(--ink-4); }
|
||||
/* 抽取指向:从原文到下方要素的一句衔接 */
|
||||
.proto-root .jc-derive { display: flex; align-items: flex-start; gap: 8px; margin-top: 11px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); font-size: 11.5px; line-height: 1.7; color: var(--brand-deep); }
|
||||
.proto-root .jc-derive svg { flex: none; margin-top: 2px; }
|
||||
|
||||
/* ── 四要素卡 ───────────────────────────────────────────────────────────
|
||||
色调字典:每张卡带一套 --tone / --tone-soft / --tone-line,组件只读变量。
|
||||
卡 3、卡 4 的色调随数据变(老客户绿 / 新客户橙;齐了蓝 / 有缺橙),
|
||||
所以这条要素带本身就是一行可读的状态,不只是四个格子。 */
|
||||
.proto-root .jc-four { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 11px; }
|
||||
.proto-root .jc-fcard {
|
||||
position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-width: 0;
|
||||
padding: 14px 14px 14px; border: 1px solid var(--cardline); border-radius: 13px; background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 8px 20px -14px rgba(31,35,41,.35);
|
||||
transition: transform var(--t-fast), box-shadow var(--t-fast), border-color var(--t-fast);
|
||||
--tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft);
|
||||
}
|
||||
/* 顶缘色棱 + 右上角光晕:卡的身份色,一眼分得清四件是四件 */
|
||||
.proto-root .jc-fcard::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 3px; background: var(--tone); }
|
||||
.proto-root .jc-fcard::after { content: ""; position: absolute; right: -46px; top: -52px; width: 132px; height: 132px; border-radius: 999px; background: radial-gradient(circle, var(--tone-soft), transparent 70%); pointer-events: none; }
|
||||
.proto-root .jc-fcard > * { position: relative; z-index: 1; }
|
||||
.proto-root .jc-fcard:hover { transform: translateY(-2px); border-color: var(--tone-line); box-shadow: 0 2px 4px rgba(31,35,41,.05), 0 14px 26px -16px rgba(31,35,41,.45); }
|
||||
.proto-root .jc-fcard.t-teal { --tone: #0aa294; --tone-soft: rgba(10,162,148,.15); --tone-line: #9fe0d9; }
|
||||
.proto-root .jc-fcard.t-ok { --tone: var(--ok); --tone-soft: rgba(39,135,23,.14); --tone-line: #bfedb4; }
|
||||
.proto-root .jc-fcard.t-warn { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.12); --tone-line: #ffd9a8; }
|
||||
|
||||
.proto-root .jc-fcard-k { display: flex; align-items: center; gap: 6px; font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
|
||||
.proto-root .jc-fcard-k > i { flex: none; width: 19px; height: 19px; border-radius: 6px; display: inline-grid; place-items: center; background: var(--tone-soft); color: var(--tone); }
|
||||
.proto-root .jc-fcard-b { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.proto-root .jc-fcard-t { min-width: 0; flex: 1; }
|
||||
/* 一律换行,不省略号——宁可多占一行,也不丢字 */
|
||||
.proto-root .jc-fcard-t > b { display: block; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.35; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-fcard-t > span { display: block; margin-top: 2px; font-size: 10.5px; color: var(--ink-4); line-height: 1.55; overflow-wrap: anywhere; }
|
||||
/* 公司标:圆角方 + AI 渐变 + 外圈光环(方=组织) */
|
||||
.proto-root .jc-logo { flex: none; width: 38px; height: 38px; border-radius: 12px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; letter-spacing: .04em; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand), 0 0 0 3px rgba(51,112,255,.10); }
|
||||
/* 人像:圆 + 青色系(圆=人,与方形的组织标一眼分开) */
|
||||
.proto-root .jc-face { flex: none; width: 38px; height: 38px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; color: #0a7f74; background: #e4f8f5; border: 1px solid #9fe0d9; box-shadow: 0 0 0 3px rgba(10,162,148,.10); }
|
||||
/* 邮箱做成可点的胶囊,复制按钮嵌在里面——比一行裸文字有抓手 */
|
||||
/* 邮箱:软底胶囊、无描边——有描边会读成输入框,而读态这里不是可填的 */
|
||||
.proto-root .jc-mailrow { display: flex; align-items: center; gap: 4px; margin-top: 5px; min-width: 0; max-width: 100%; padding: 3px 4px 3px 9px; border: 0; border-radius: 8px; background: var(--tone-soft); transition: background var(--t-fast); }
|
||||
.proto-root .jc-mailrow:hover { background: rgba(10,162,148,.2); }
|
||||
/* 编辑态里它包着输入框,软底会变成「框中框」,去掉 */
|
||||
.proto-root .jc-report.editing .jc-mailrow { background: transparent; padding: 0; margin-top: 6px; }
|
||||
.proto-root .jc-mailrow > code { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; line-height: 1.5; font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
|
||||
.proto-root .jc-copy { flex: none; width: 22px; height: 22px; display: inline-grid; place-items: center; border: 0; border-radius: 6px; background: #fff; color: var(--ink-4); cursor: pointer; box-shadow: 0 1px 2px rgba(31,35,41,.08); transition: all var(--t-fast); }
|
||||
.proto-root .jc-copy:hover { color: #0aa294; }
|
||||
.proto-root .jc-copy.ok { color: var(--ok); background: var(--ok-soft); box-shadow: none; }
|
||||
/* 客户属性徽章 */
|
||||
.proto-root .jc-tagbig { display: inline-flex; align-items: center; gap: 6px; align-self: flex-start; padding: 7px 14px; border-radius: 999px; font-size: 13.5px; font-weight: 800; line-height: 1; color: var(--tone); background: var(--tone-soft); border: 1px solid var(--tone-line); }
|
||||
.proto-root .jc-fcard-note { font-size: 10.5px; color: var(--ink-4); line-height: 1.55; }
|
||||
.proto-root .jc-fcard-note b { color: var(--ink-2); font-weight: 700; }
|
||||
.proto-root .jc-fcard-note b.bad { color: var(--tone); font-weight: 800; }
|
||||
/* 成交迷你柱:把「台账 6 笔成交」从一句话变成看得见的节奏 */
|
||||
.proto-root .jc-spark { display: flex; align-items: flex-end; gap: 4px; height: 26px; }
|
||||
.proto-root .jc-spark > i { flex: 1 1 0; min-width: 4px; max-width: 15px; border-radius: 3px 3px 1px 1px; background: var(--tone); opacity: .3; transition: opacity var(--t-fast), height var(--t-med); }
|
||||
.proto-root .jc-spark > i:last-child { opacity: 1; }
|
||||
.proto-root .jc-spark:hover > i { opacity: .55; }
|
||||
.proto-root .jc-spark:hover > i:last-child { opacity: 1; }
|
||||
.proto-root .jc-spark.empty { align-items: stretch; }
|
||||
.proto-root .jc-spark.empty > i { height: 100%; opacity: 1; background: repeating-linear-gradient(45deg, var(--tone-soft) 0 5px, transparent 5px 10px); border: 1px dashed var(--tone-line); border-radius: 5px; }
|
||||
/* 有效 PN 计数 */
|
||||
.proto-root .jc-count { display: flex; align-items: baseline; gap: 6px; }
|
||||
.proto-root .jc-count > b { font-family: var(--mono); font-size: 32px; font-weight: 800; line-height: .95; color: var(--ink); letter-spacing: -.04em; }
|
||||
.proto-root .jc-count > span { font-size: 11px; color: var(--ink-4); }
|
||||
/* 分段刻度条:一格 = 一条 PN,缺必填的那格直接标红——比一根实心进度条信息量大 */
|
||||
.proto-root .jc-segmeter { display: flex; gap: 3px; }
|
||||
.proto-root .jc-segmeter > i { flex: 1; height: 7px; border-radius: 2px; background: var(--ok); transition: background var(--t-fast); }
|
||||
.proto-root .jc-segmeter > i.bad { background: var(--danger); }
|
||||
.proto-root .jc-lockhint { display: inline-flex; align-items: center; gap: 4px; font-size: 10px; color: var(--ink-4); }
|
||||
|
||||
/* ── 占位胶囊:必填=红虚线,选填=灰虚线;**都带文字**,不靠颜色单打 ── */
|
||||
.proto-root .jc-ph { display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700; line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed; }
|
||||
.proto-root .jc-ph.req { color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
|
||||
.proto-root .jc-ph.opt { color: var(--ink-4); background: var(--soft); border-color: var(--line); }
|
||||
|
||||
/* ── 分段控件(新客户 / 老客户)── */
|
||||
.proto-root .jc-seg { display: inline-flex; padding: 2px; gap: 2px; border: 1px solid var(--cardline); border-radius: 9px; background: var(--soft); align-self: flex-start; }
|
||||
.proto-root .jc-seg button { border: 0; border-radius: 7px; padding: 6px 13px; font: inherit; font-size: 11.5px; font-weight: 700; color: var(--ink-3); background: transparent; cursor: pointer; transition: all var(--t-fast); }
|
||||
.proto-root .jc-seg button:hover:not(.on) { color: var(--brand); }
|
||||
.proto-root .jc-seg button.on { background: #fff; color: var(--ink); box-shadow: 0 1px 3px rgba(31,35,41,.12); }
|
||||
|
||||
/* ── 表:读态与编辑态共用骨架 ─────────────────────────────────────── */
|
||||
.proto-root .jc-tbl-wrap { overflow-x: auto; border: 1px solid var(--line-soft); border-radius: 10px; }
|
||||
.proto-root .jc-tbl-in { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; }
|
||||
.proto-root .jc-tbl-in th { background: var(--soft); text-align: left; padding: 8px 9px; font-size: 10.5px; font-weight: 800; letter-spacing: .03em; color: var(--ink-3); white-space: nowrap; border-bottom: 1px solid var(--line-soft); }
|
||||
.proto-root .jc-tbl-in th.req::after { content: "*"; margin-left: 1px; color: var(--danger); font-size: 10px; }
|
||||
.proto-root .jc-tbl-in td { padding: 4px 5px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
|
||||
.proto-root .jc-tbl-in tr:last-child td { border-bottom: 0; }
|
||||
.proto-root .jc-tbl-in tbody tr:hover td { background: #f7f9fd; }
|
||||
/* 缺必填的整行浅红打底 + 行首红标——不用找红胶囊,一眼扫到是哪几行 */
|
||||
.proto-root .jc-tbl-in tr.miss td { background: #fffbfb; }
|
||||
.proto-root .jc-tbl-in tbody tr.miss:hover td { background: #fff5f5; }
|
||||
.proto-root .jc-tbl-in td.jc-rn { position: relative; width: 38px; padding: 4px 6px 4px 13px; white-space: nowrap; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
|
||||
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); font-weight: 800; }
|
||||
/* 行首红标只在表态用 ::after —— 卡片态的 ::after 被「第 N 条」占着,不能抢 */
|
||||
@media (min-width: 961px) {
|
||||
.proto-root .jc-tbl-in tr.miss td.jc-rn::after { content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger); }
|
||||
}
|
||||
.proto-root .jc-rndot { display: inline-block; width: 5px; height: 5px; margin-left: 4px; border-radius: 999px; background: var(--danger); vertical-align: 2px; }
|
||||
/* 读态单元格 */
|
||||
.proto-root .jc-vcell { display: block; padding: 6px 6px; font-size: 12px; color: var(--ink); line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-vcell.mono { font-family: var(--mono); font-size: 11px; color: var(--ink-2); }
|
||||
.proto-root .jc-vcell.num { font-family: var(--mono); font-size: 11.5px; text-align: right; }
|
||||
/* 编辑态单元格:安静的电子表格 */
|
||||
.proto-root .jc-cell { width: 100%; min-width: 64px; border: 1px solid var(--line); border-radius: 6px; padding: 6px 7px; font: inherit; font-size: 12px; color: var(--ink); background: #fff; transition: border-color var(--t-fast), box-shadow var(--t-fast); }
|
||||
.proto-root .jc-cell.mono { font-family: var(--mono); font-size: 11px; }
|
||||
.proto-root .jc-cell.num { text-align: right; font-family: var(--mono); font-size: 11.5px; }
|
||||
.proto-root .jc-cell::placeholder { color: var(--ink-4); }
|
||||
.proto-root .jc-cell:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-wash); outline: none; }
|
||||
.proto-root .jc-cell.req-empty { border-color: #f3b6b3; background: #fffafa; }
|
||||
.proto-root .jc-cell.req-empty::placeholder { color: var(--danger); }
|
||||
/* 备注这类长文本用文本域:能换行、随内容长高,不裁字 */
|
||||
.proto-root textarea.jc-cell { display: block; resize: vertical; overflow: hidden; min-height: 31px; line-height: 1.55; }
|
||||
|
||||
/* 编辑态的整表提示带 */
|
||||
.proto-root .jc-editbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); border: 1px solid var(--brand-soft); font-size: 11.5px; color: var(--brand-deep); }
|
||||
.proto-root .jc-editbar svg { flex: none; }
|
||||
.proto-root .jc-editbar .sp { flex: 1 1 auto; }
|
||||
|
||||
/* 剔除行说明 */
|
||||
.proto-root .jc-drop { display: flex; align-items: flex-start; gap: 8px; margin-top: 10px; padding: 9px 12px; border-radius: 9px; border: 1px dashed var(--line); background: var(--soft); font-size: 11.5px; color: var(--ink-3); line-height: 1.6; }
|
||||
.proto-root .jc-drop svg { flex: none; margin-top: 2px; color: var(--ink-4); }
|
||||
.proto-root .jc-drop code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
|
||||
|
||||
.proto-root .jc-callout { display: flex; gap: 8px; align-items: flex-start; border-radius: 9px; padding: 11px 13px; font-size: 12.5px; line-height: 1.75; }
|
||||
.proto-root .jc-callout p { margin: 0; }
|
||||
.proto-root .jc-callout svg { flex: none; margin-top: 2px; }
|
||||
.proto-root .jc-callout.t-mute { background: var(--soft); color: var(--ink-2) }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.proto-root .jc-pulse-dot { animation: none; }
|
||||
.proto-root .jc-report, .proto-root .jc-rp-stamp { animation: none; }
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════
|
||||
响应式:宁可另起一行,也不损失完整度
|
||||
≥961px 六列全展开的表(列 min-width 之和 878px,容器有余量,绝不裁字、绝不横滚)
|
||||
≤960px 表 → 卡片:一条 PN 一张卡、一行一字段(标签 / 值),无横滚、无省略
|
||||
≤560px 字段行再拆成上下两行(标签在上、值在下)
|
||||
══════════════════════════════════════════════════════════════════════════ */
|
||||
@media (max-width: 1040px) { .proto-root .jc-four { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 560px) { .proto-root .jc-four { grid-template-columns: 1fr; } }
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.proto-root .jc-tbl-wrap { overflow: visible; border: 0; border-radius: 0; background: transparent; }
|
||||
.proto-root .jc-tbl-in,
|
||||
.proto-root .jc-tbl-in tbody,
|
||||
.proto-root .jc-tbl-in tr,
|
||||
.proto-root .jc-tbl-in td { display: block; width: 100%; }
|
||||
.proto-root .jc-tbl-in thead { display: none; }
|
||||
|
||||
/* 一条 PN = 一张卡 */
|
||||
.proto-root .jc-tbl-in tr { margin-bottom: 10px; padding: 4px 13px 9px; border: 1px solid var(--line-soft); border-radius: 11px; background: #fff; box-shadow: var(--card-shadow); }
|
||||
.proto-root .jc-tbl-in tr:last-child { margin-bottom: 0; }
|
||||
.proto-root .jc-tbl-in tbody tr:hover td { background: transparent; }
|
||||
.proto-root .jc-tbl-in tr.miss { border-color: #f3b6b3; background: #fffbfb; }
|
||||
|
||||
/* 卡头:第 N 条 */
|
||||
.proto-root .jc-tbl-in td.jc-rn { padding: 8px 0 8px; margin-bottom: 4px; border-bottom: 1px solid var(--line-soft); font-family: inherit; font-size: 12px; font-weight: 800; color: var(--ink-2); }
|
||||
.proto-root .jc-tbl-in td.jc-rn::before { content: "第 "; }
|
||||
.proto-root .jc-tbl-in td.jc-rn::after { content: " 条"; }
|
||||
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); }
|
||||
|
||||
/* 字段行:左标签 + 右值,值超长自动折行 */
|
||||
.proto-root .jc-tbl-in td:not(.jc-rn) { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 10px; align-items: center; padding: 6px 0; border-bottom: 1px dashed var(--line-soft); }
|
||||
.proto-root .jc-tbl-in tr td:last-child { border-bottom: 0; padding-bottom: 2px; }
|
||||
.proto-root .jc-tbl-in td:not(.jc-rn)::before {
|
||||
content: attr(data-h); font-size: 10.5px; font-weight: 700; letter-spacing: .02em; color: var(--ink-4); line-height: 1.5;
|
||||
}
|
||||
.proto-root .jc-tbl-in td[data-req="1"]:not(.jc-rn)::before { content: attr(data-h) " *"; }
|
||||
.proto-root .jc-tbl-in .jc-vcell { padding: 0; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-tbl-in .jc-vcell.num { text-align: left; }
|
||||
.proto-root .jc-tbl-in .jc-cell.num { text-align: left; }
|
||||
.proto-root .jc-tbl-in .jc-cell { min-width: 0; }
|
||||
}
|
||||
|
||||
/* 窄屏:落款三件各占一行,动作按钮撑满一行 */
|
||||
@media (max-width: 560px) {
|
||||
.proto-root .jc-rp-acts { margin-left: 0; width: 100%; flex-wrap: wrap; }
|
||||
.proto-root .jc-rp-acts .jc-btn { flex: 1 1 auto; justify-content: center; }
|
||||
.proto-root .jc-hintline { flex: 1 1 100%; }
|
||||
.proto-root .jc-tbl-in td:not(.jc-rn) { grid-template-columns: 1fr; gap: 3px; align-items: start; }
|
||||
}
|
||||
|
||||
|
||||
/* ══ 以下取自 A1-询价受理记录.html(归档件独有的件)══ */
|
||||
/* ── 料号分类:分布条 + 图例 ───────────────────────────────────────── */
|
||||
.proto-root .jc-dist { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 13px 15px; border: 1px solid var(--line-soft); border-radius: 12px; background: linear-gradient(180deg, #fbfcfe, #fff); }
|
||||
.proto-root .jc-distbar { flex: 1 1 320px; min-width: 260px; display: flex; gap: 3px; }
|
||||
.proto-root .jc-distbar > i { height: 30px; border-radius: 4px; display: grid; place-items: center; font-size: 10.5px; font-weight: 800; color: #fff; font-family: var(--mono); }
|
||||
.proto-root .jc-distbar > i.a { background: var(--ok); }
|
||||
.proto-root .jc-distbar > i.b { background: var(--brand); }
|
||||
.proto-root .jc-distbar > i.c { background: var(--warn-accent); }
|
||||
.proto-root .jc-distlg { display: flex; flex-wrap: wrap; gap: 8px 18px; }
|
||||
.proto-root .jc-distlg > span { display: inline-flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--ink-3); }
|
||||
.proto-root .jc-distlg em { width: 10px; height: 10px; border-radius: 3px; }
|
||||
.proto-root .jc-distlg em.a { background: var(--ok); } .proto-root .jc-distlg em.b { background: var(--brand); } .proto-root .jc-distlg em.c { background: var(--warn-accent); }
|
||||
.proto-root .jc-distlg b { font-family: var(--mono); font-weight: 800; color: var(--ink); }
|
||||
|
||||
/* 类别徽 A / B / C */
|
||||
.proto-root .jc-tbadge { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 6px; font-family: var(--mono); font-size: 11px; font-weight: 800; line-height: 1; color: #fff; }
|
||||
.proto-root .jc-tbadge.a { background: var(--ok); } .proto-root .jc-tbadge.b { background: var(--brand); } .proto-root .jc-tbadge.c { background: var(--warn-accent); }
|
||||
|
||||
/* ── 结论与去向 ─────────────────────────────────────────────────────────
|
||||
G2 是**分支网关**:三条互斥条件按序求值,只命中一条、只落一个活动。
|
||||
所以这里不是三张并列的去向卡,而是一张**判定表 + 一条结论**。 */
|
||||
/* 判定表:一行一条规则,命中行上色留痕(DMN 口径) */
|
||||
.proto-root .jc-dmn { border: 1px solid var(--line-soft); border-radius: 12px; overflow: hidden; }
|
||||
.proto-root .jc-dmn-h, .proto-root .jc-dmn-r { display: grid; grid-template-columns: 40px minmax(0, 1fr) 170px 200px; align-items: center; gap: 12px; padding: 9px 14px; }
|
||||
.proto-root .jc-dmn-h { background: var(--soft); border-bottom: 1px solid var(--line-soft); font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); }
|
||||
.proto-root .jc-dmn-r { font-size: 12px; color: var(--ink-4); background: #fff; }
|
||||
.proto-root .jc-dmn-r + .jc-dmn-r { border-top: 1px solid var(--line-soft); }
|
||||
.proto-root .jc-dmn-i { font-family: var(--mono); font-size: 11px; font-weight: 800; }
|
||||
.proto-root .jc-dmn-c { line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.proto-root .jc-dmn-t { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; }
|
||||
.proto-root .jc-dmn-t code { font-family: var(--mono); font-size: 10.5px; font-weight: 800; color: var(--ink-4); background: var(--soft); border: 1px solid var(--line); border-radius: 5px; padding: 2px 6px; }
|
||||
.proto-root .jc-dmn-v { font-size: 11px; line-height: 1.55; }
|
||||
/* 命中行:上色 + 左缘棱 + 加粗 */
|
||||
.proto-root .jc-dmn-r.hit { position: relative; background: var(--tone-soft); color: var(--ink-2); }
|
||||
.proto-root .jc-dmn-r.hit::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--tone); }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-i { color: var(--tone); }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-c { color: var(--ink); }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-c b { color: var(--tone); font-weight: 800; }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-t { color: var(--ink); }
|
||||
.proto-root .jc-dmn-r.hit .jc-dmn-t code { color: #fff; background: var(--tone); border-color: var(--tone); }
|
||||
.proto-root .jc-dmn-v.y { display: inline-flex; align-items: center; gap: 5px; font-weight: 800; color: var(--tone); }
|
||||
.proto-root .jc-dmn-v.n { color: var(--ink-4); }
|
||||
/* 色调随命中类别 */
|
||||
.proto-root .jc-dmn.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.10); }
|
||||
.proto-root .jc-dmn.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.09); }
|
||||
.proto-root .jc-dmn.k-c { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.11); }
|
||||
|
||||
/* 结论横幅:命中条件 → 唯一去向 */
|
||||
.proto-root .jc-verdict {
|
||||
position: relative; overflow: hidden; display: flex; align-items: stretch; flex-wrap: wrap; gap: 16px;
|
||||
margin-top: 11px; padding: 16px 18px; border: 1px solid var(--tone-line); border-radius: 13px;
|
||||
background: linear-gradient(105deg, var(--tone-soft), #fff 62%);
|
||||
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 10px 24px -16px rgba(31,35,41,.4);
|
||||
--tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.14); --tone-line: #ffd9a8;
|
||||
}
|
||||
.proto-root .jc-verdict.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.13); --tone-line: #bfedb4; }
|
||||
.proto-root .jc-verdict.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft); }
|
||||
.proto-root .jc-verdict::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 5px; background: var(--tone); }
|
||||
.proto-root .jc-verdict > * { position: relative; z-index: 1; }
|
||||
.proto-root .jc-vd-l { flex: 1 1 300px; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
|
||||
.proto-root .jc-vd-k { font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
|
||||
.proto-root .jc-vd-cond { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 15px; font-weight: 800; color: var(--ink); }
|
||||
.proto-root .jc-vd-why { margin: 0; font-size: 11.5px; line-height: 1.7; color: var(--ink-3); overflow-wrap: anywhere; }
|
||||
.proto-root .jc-vd-why code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
|
||||
.proto-root .jc-vd-arrow { flex: none; align-self: center; color: var(--tone); }
|
||||
.proto-root .jc-vd-r { flex: 1 1 260px; min-width: 0; display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-radius: 11px; background: #fff; border: 1px solid var(--tone-line); }
|
||||
.proto-root .jc-vd-node { display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 800; color: var(--ink); }
|
||||
.proto-root .jc-vd-node code { font-family: var(--mono); font-size: 12px; font-weight: 800; color: #fff; background: var(--tone); border-radius: 6px; padding: 3px 8px; }
|
||||
.proto-root .jc-vd-who { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--ink-2); }
|
||||
.proto-root .jc-vd-who > i { width: 20px; height: 20px; border-radius: 999px; display: inline-grid; place-items: center; font-style: normal; font-size: 10px; font-weight: 700; color: #fff; background: var(--tone); }
|
||||
.proto-root .jc-vd-next { margin: 0; padding-top: 8px; border-top: 1px dashed var(--line-soft); font-size: 10.5px; line-height: 1.65; color: var(--ink-4); }
|
||||
.proto-root .jc-vd-next b { color: var(--ink-2); font-weight: 700; }
|
||||
/* 命中分支携带的条目 */
|
||||
.proto-root .jc-pnlist { display: flex; flex-wrap: wrap; gap: 5px; margin: 0; padding: 0; list-style: none; }
|
||||
.proto-root .jc-pnlist > li { display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 999px; background: #fff; border: 1px solid var(--tone-line); font-size: 11px; color: var(--ink-2); overflow-wrap: anywhere; }
|
||||
.proto-root .jc-pnlist > li > i { flex: none; width: 5px; height: 5px; border-radius: 999px; background: var(--tone); }
|
||||
.proto-root .jc-pnlist code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
|
||||
|
||||
/* ── 落地动作 ───────────────────────────────────────────────────────── */
|
||||
.proto-root .jc-acts { display: flex; flex-direction: column; gap: 0; border: 1px solid var(--line-soft); border-radius: 11px; overflow: hidden; }
|
||||
.proto-root .jc-act { display: flex; align-items: flex-start; gap: 9px; padding: 10px 13px; font-size: 12px; color: var(--ink-2); line-height: 1.6; background: #fff; }
|
||||
.proto-root .jc-act + .jc-act { border-top: 1px solid var(--line-soft); }
|
||||
.proto-root .jc-act > i { flex: none; width: 18px; height: 18px; margin-top: 1px; border-radius: 999px; display: inline-grid; place-items: center; color: #fff; background: var(--ok); }
|
||||
.proto-root .jc-act b { font-weight: 700; color: var(--ink); }
|
||||
.proto-root .jc-act span { color: var(--ink-4); }
|
||||
.proto-root .jc-act code { font-family: var(--mono); font-size: 10.5px; color: var(--brand-deep); background: var(--brand-wash); border-radius: 4px; padding: 1px 5px; }
|
||||
|
||||
|
||||
.proto-root .jc-done-tag { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; flex: none; padding: 6px 13px; border-radius: 999px; font-size: 11.5px; font-weight: 800; color: var(--ok); background: #fff; border: 1px solid #bfedb4; }
|
||||
.proto-root .jc-vcell.dim { color: var(--ink-4); }
|
||||
.proto-root .jc-tbl-in td.jc-ty { width: 44px; text-align: center; }
|
||||
.proto-root .jc-tbl-in td.jc-ty { width: auto; text-align: left; }
|
||||
`;
|
||||
|
||||
/**
|
||||
* 图标精灵 —— 与样式表同一出处,两份目标件的 `<symbol>` 取并集。
|
||||
* 沙箱里取不到图标字体,所以图标一律内联;`<use href="#i-xxx">` 引的就是这一坨。
|
||||
*/
|
||||
export const PRD_SPRITE = `<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs><symbol id="i-alert" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></symbol><symbol id="i-arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol><symbol id="i-boxes" viewBox="0 0 24 24"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></symbol><symbol id="i-building" viewBox="0 0 24 24"><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/><path d="M10 18h4"/></symbol><symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol><symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol><symbol id="i-file-check" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="m9 15 2 2 4-4"/></symbol><symbol id="i-file-text" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></symbol><symbol id="i-git-branch" viewBox="0 0 24 24"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></symbol><symbol id="i-history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></symbol><symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></symbol><symbol id="i-lock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol><symbol id="i-mail-open" viewBox="0 0 24 24"><path d="M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0z"/><path d="m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10"/></symbol><symbol id="i-pen" viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z"/></symbol><symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol><symbol id="i-sheet" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M8 13h2"/><path d="M14 13h2"/><path d="M8 17h2"/><path d="M14 17h2"/></symbol><symbol id="i-shield-check" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></symbol><symbol id="i-user" viewBox="0 0 24 24"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/></symbol><symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></symbol></defs></svg>`;
|
||||
@@ -1,25 +1,530 @@
|
||||
/**
|
||||
* A3 视图模版 —— 案例全景(AM 07:loop 按案例模型的**阶段与里程碑**组织汇报)。
|
||||
* A3 寻源询价的 HTML 报告模版 —— **一张件贯穿始终**:目标报价清单。
|
||||
*
|
||||
* 画面素材全部来自语义引擎:阶段的生命周期态、当下可为(Enabled 集)、目标缺口。
|
||||
* 这些是算出来的确定事实,零 LLM 即可成屏 —— 模型只决定「下一步做什么」,不负责画面。
|
||||
* 在办(PRM 补货源价时)与归档(放行后进工作成果)是**同一份 HTML**,
|
||||
* 差别只在外层给不给编辑态(`html.editable`)。版面语言沿用 A1 从 PRD 目标件
|
||||
* 机械抽取的那套(`prd-style.ts` 原样拷入)—— A3 没有自己的 PRD 目标件
|
||||
* (2026-08-02 用户定:沿用 A1 版面语言自造)。
|
||||
*
|
||||
* 取值路径三纪律照 A1:顶层只取汇总列与单值列;逐条走 `mine:quote_sourcing.quote_list`;
|
||||
* 列名逐字照 `declarations.ledger` 抄。模版不算不判 —— 缺价红带、价源徽、分布条
|
||||
* 全部由基座算好的数经 `data-class-of` 投影,判断留在样式表。
|
||||
*/
|
||||
import { kvCard, noteCard, stagesCard, viewShell } from '@sdk';
|
||||
import { PRD_CSS, PRD_SPRITE } from './prd-style.ts';
|
||||
|
||||
export const SOURCING_CASE = viewShell({
|
||||
title: '渠道寻源 · 案例全景',
|
||||
kicker: 'A3 · 案例活动(loop)',
|
||||
tone: 'case',
|
||||
/* ==================== 绑定期补的样式(照 A1 的 BIND_CSS,地址换成本活动)==================== */
|
||||
|
||||
const BIND_CSS = `
|
||||
/* 空格子自己说话 —— 值真的空才轮到(基座 data-blank) */
|
||||
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after,
|
||||
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after {
|
||||
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
|
||||
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
|
||||
}
|
||||
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after { content: "必填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
|
||||
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after { content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line); }
|
||||
/* 归档件里空格子就是「没有」,不再摆「你还能填」的胶囊 */
|
||||
.proto-root .jc-tbl-in td.dimempty > .jc-vcell:empty::after { content: "—"; color: var(--ink-4); border: 0; background: none; padding: 0; }
|
||||
|
||||
/* 缺基准价的整行浅红 + 行首红条 —— 原型的 tr.miss,这里由 :has() 认 */
|
||||
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td { background: #fffbfb; }
|
||||
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty):hover td { background: #fff5f5; }
|
||||
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn { color: var(--danger); font-weight: 800; position: relative; }
|
||||
@media (min-width: 1001px) {
|
||||
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn::after {
|
||||
content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
/* 序号由 CSS 计数器排 */
|
||||
.proto-root .jc-tbl-in tbody { counter-reset: rn; }
|
||||
.proto-root .jc-tbl-in td.jc-rn::before { counter-increment: rn; content: counter(rn); }
|
||||
@media (max-width: 1000px) {
|
||||
.proto-root .jc-tbl-in td.jc-rn::before { content: "第 " counter(rn) " 条"; }
|
||||
.proto-root .jc-tbl-in td.jc-rn::after { content: none; }
|
||||
}
|
||||
|
||||
/* 就地改:可改格子渲了只读与输入两份,默认锁着,表上加 on 才换孪生件 */
|
||||
.proto-root .jc-cell { display: none; }
|
||||
.proto-root .jc-tbl-in.on td > .jc-vcell,
|
||||
.proto-root .jc-tbl-in.on td > .jc-tbadge { display: none; }
|
||||
.proto-root .jc-tbl-in.on td > .jc-cell { display: block; }
|
||||
/* 类与价源两枚徽不进编辑态(它们是承继与引擎产物),on 时也照旧摆着 */
|
||||
.proto-root .jc-tbl-in.on td > .jc-obadge, .proto-root .jc-tbl-in.on td > .jc-pbadge { display: inline-flex; }
|
||||
/* 数值列与表头同侧左对齐 */
|
||||
.proto-root .jc-vcell.num, .proto-root .jc-cell.num { text-align: left; }
|
||||
/* 编辑态下必填空格子:红框 + 整行浅红(由 :placeholder-shown 认) */
|
||||
.proto-root .jc-tbl-in.on td.req > .jc-cell:placeholder-shown { border-color: #f3b6b3; background: #fffafa; }
|
||||
.proto-root .jc-tbl-in.on tbody tr:has(td.req > .jc-cell:placeholder-shown) td { background: #fffbfb; }
|
||||
|
||||
/* 铺满工作区 */
|
||||
.jc-stage { max-width: none; padding: 0 0 4px; }
|
||||
.proto-root .jc-report { border: 0; border-radius: 0; box-shadow: none; }
|
||||
|
||||
/* 那道闸 —— 默认「已锁定 + 编辑」,开了变成「取消 / 保存」;只在可改屏现身 */
|
||||
.proto-root .jc-editgate { display: none; }
|
||||
html.editable .proto-root .jc-editgate {
|
||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
margin-bottom: 10px; padding: 8px 12px; border-radius: 9px;
|
||||
background: var(--soft); border: 1px solid var(--line-soft);
|
||||
font-size: 11.5px; color: var(--ink-3);
|
||||
}
|
||||
.proto-root .jc-editgate-t { display: inline-flex; align-items: center; gap: 6px; flex: 1 1 auto; min-width: 0; }
|
||||
.proto-root .jc-editgate-t b { color: var(--ink-2); font-weight: 700; }
|
||||
.proto-root .jc-egbtn {
|
||||
flex: none; display: inline-flex; align-items: center; gap: 4px; cursor: pointer;
|
||||
border: 1px solid var(--line); border-radius: 7px; padding: 5px 11px;
|
||||
font: inherit; font-size: 11.5px; font-weight: 700; color: var(--ink-2); background: #fff;
|
||||
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
|
||||
}
|
||||
.proto-root .jc-egbtn:hover { border-color: var(--brand); color: var(--brand); }
|
||||
.proto-root .jc-egbtn.save { color: #fff; background: var(--brand); border-color: var(--brand); }
|
||||
.proto-root .jc-egbtn.save:hover { background: var(--brand-deep); border-color: var(--brand-deep); }
|
||||
.proto-root .jc-editgate .jc-egbtn.ghost, .proto-root .jc-editgate .jc-egbtn.save { display: none; }
|
||||
.proto-root .jc-editgate.on { background: var(--brand-wash); border-color: var(--brand-soft); color: var(--brand-deep); }
|
||||
.proto-root .jc-editgate.on .jc-egbtn.open { display: none; }
|
||||
.proto-root .jc-editgate.on .jc-egbtn.ghost, .proto-root .jc-editgate.on .jc-egbtn.save { display: inline-flex; }
|
||||
.proto-root .jc-editgate.on .jc-editgate-t { visibility: hidden; }
|
||||
|
||||
/* 缺价红带 —— 只在可改屏出,一格不缺自动收(v-0 那一档 display:none) */
|
||||
.proto-root .jc-gapbar { display: none; }
|
||||
html.editable .proto-root .jc-gapbar {
|
||||
display: flex; align-items: flex-start; gap: 8px;
|
||||
margin: 0 0 10px; padding: 9px 12px; border-radius: 9px;
|
||||
background: #fff5f5; border: 1px solid #f3b6b3; color: var(--danger);
|
||||
font-size: 12px; line-height: 1.65;
|
||||
}
|
||||
.proto-root .jc-gapbar svg { flex: 0 0 auto; margin-top: 2px; }
|
||||
.proto-root .jc-gapbar b { font-weight: 800; }
|
||||
.proto-root .jc-gapbar span { color: #9a3b36; }
|
||||
html.editable .proto-root .jc-gapbar.v-0 { display: none; }
|
||||
|
||||
/* 那条「怎么改」的提示只在开了闸之后说 */
|
||||
.proto-root .jc-editbar { display: none; }
|
||||
.proto-root .jc-editgate.on + .jc-editbar { display: flex; }
|
||||
|
||||
/* 增删行 */
|
||||
.proto-root .jc-rowdel { display: none; }
|
||||
.proto-root .jc-tbl-in.on td.jc-rn::before { content: none; }
|
||||
.proto-root .jc-tbl-in.on .jc-rowdel {
|
||||
display: block; width: 20px; height: 20px; margin: 0 auto; cursor: pointer;
|
||||
border: 1px solid var(--line); border-radius: 6px; padding: 0;
|
||||
font: inherit; font-size: 13px; line-height: 1; color: var(--ink-3); background: #fff;
|
||||
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
|
||||
}
|
||||
.proto-root .jc-tbl-in.on .jc-rowdel:hover { border-color: var(--danger); color: var(--danger); background: #fff6f6; }
|
||||
.proto-root .jc-tbl-in.on tr.jc-row-gone td { background: #fff6f6; opacity: .55; }
|
||||
.proto-root .jc-tbl-in.on tr.jc-row-gone .jc-cell { text-decoration: line-through; color: var(--ink-3); }
|
||||
.proto-root .jc-tbl-in.on tr.jc-row-gone .jc-rowdel { border-color: var(--danger); color: var(--danger); background: #fff; }
|
||||
.proto-root .jc-tbl-in.on tr.jc-row-new td { background: #f2fbf7; }
|
||||
.proto-root .jc-addrow { display: none; }
|
||||
html.editable .proto-root .jc-tbl-wrap:has(table.on) + .jc-addrow {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
margin: -2px 0 12px; padding: 7px 12px; border-radius: 9px;
|
||||
background: var(--soft); border: 1px dashed var(--line);
|
||||
}
|
||||
.proto-root .jc-addrow em { font-style: normal; font-size: 11px; color: var(--ink-3); }
|
||||
|
||||
/* 按值上色 */
|
||||
.proto-root .jc-tbadge { background: var(--ink-4); }
|
||||
.proto-root .jc-tbadge.v-a { background: var(--ok); }
|
||||
.proto-root .jc-tbadge.v-b { background: var(--brand); }
|
||||
.proto-root .jc-tbadge.v-c { background: var(--warn-accent); }
|
||||
.proto-root .jc-segmeter > i.v-a { background: var(--ok); }
|
||||
.proto-root .jc-segmeter > i.v-b { background: var(--brand); }
|
||||
.proto-root .jc-segmeter > i.v-c { background: var(--warn-accent); }
|
||||
/* 价源徽:历史价绿(库内既有事实)/ 货源价蓝(本单问回来的) */
|
||||
.proto-root .jc-pbadge {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 44px; height: 20px; padding: 0 7px; border-radius: 6px;
|
||||
font-size: 10px; font-weight: 800; letter-spacing: .02em;
|
||||
color: var(--ink-4); border: 1.5px solid var(--ink-4); background: #fff;
|
||||
}
|
||||
.proto-root .jc-pbadge.v-hist { color: var(--ok); border-color: var(--ok); }
|
||||
.proto-root .jc-pbadge.v-sourced { color: var(--brand); border-color: var(--brand); }
|
||||
/* 价源徽的字也由 class 出 —— 值(HIST/SOURCED)是机器码,人读名交给样式表 */
|
||||
.proto-root .jc-pbadge::after { content: "待定"; }
|
||||
.proto-root .jc-pbadge.v-hist::after { content: "历史价"; }
|
||||
.proto-root .jc-pbadge.v-sourced::after { content: "货源价"; }
|
||||
|
||||
/* 分布条:两段按条数分宽 —— 条数投影成 v-<N>,梯子取宽;零条那段自己消失 */
|
||||
.proto-root .jc-distbar > i { flex: 24; min-width: 34px; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.proto-root .jc-distbar > i.v-0, .proto-root .jc-distbar > i:not([class*=" v-"]) { display: none; }
|
||||
.proto-root .jc-distbar > i.v-1 { flex: 1 } .proto-root .jc-distbar > i.v-2 { flex: 2 }
|
||||
.proto-root .jc-distbar > i.v-3 { flex: 3 } .proto-root .jc-distbar > i.v-4 { flex: 4 }
|
||||
.proto-root .jc-distbar > i.v-5 { flex: 5 } .proto-root .jc-distbar > i.v-6 { flex: 6 }
|
||||
.proto-root .jc-distbar > i.v-7 { flex: 7 } .proto-root .jc-distbar > i.v-8 { flex: 8 }
|
||||
.proto-root .jc-distbar > i.v-9 { flex: 9 } .proto-root .jc-distbar > i.v-10 { flex: 10 }
|
||||
.proto-root .jc-distbar > i.v-11 { flex: 11 } .proto-root .jc-distbar > i.v-12 { flex: 12 }
|
||||
.proto-root .jc-distbar > i.v-13 { flex: 13 } .proto-root .jc-distbar > i.v-14 { flex: 14 }
|
||||
.proto-root .jc-distbar > i.v-15 { flex: 15 } .proto-root .jc-distbar > i.v-16 { flex: 16 }
|
||||
.proto-root .jc-distbar > i.v-17 { flex: 17 } .proto-root .jc-distbar > i.v-18 { flex: 18 }
|
||||
.proto-root .jc-distbar > i.v-19 { flex: 19 } .proto-root .jc-distbar > i.v-20 { flex: 20 }
|
||||
.proto-root .jc-distbar > i.h { background: var(--ok); }
|
||||
.proto-root .jc-distbar > i.s { background: var(--brand); }
|
||||
.proto-root .jc-distbar > i.h::before { content: "历史价 · "; }
|
||||
.proto-root .jc-distbar > i.s::before { content: "货源价 · "; }
|
||||
.proto-root .jc-logo svg, .proto-root .jc-face svg, .proto-root .jc-rp-ava svg { width: 17px; height: 17px; }
|
||||
|
||||
/* ── 询价档案卡(工作单独有,照 A2 选型档案卡的骨架)──────────────────────
|
||||
一颗料一张卡,data-rows 逐行复制;形态按类别分档(data-class-of):
|
||||
v-b / v-c = 待询价,整卡摊开;v-a = 已按历史价预填,收成一行 —— 判断在样式表。 */
|
||||
.proto-root .jc-selcards { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
|
||||
.proto-root .jc-selcard { border: 1px solid var(--line); border-radius: 11px; background: #fff; overflow: hidden; }
|
||||
.proto-root .jc-selcard.v-b, .proto-root .jc-selcard.v-c { border-color: var(--brand-soft); box-shadow: 0 1px 0 rgba(51,112,255,.06); }
|
||||
.proto-root .jc-selcard-h {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
padding: 9px 14px; background: var(--soft); border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
.proto-root .jc-selcard.v-b .jc-selcard-h, .proto-root .jc-selcard.v-c .jc-selcard-h { background: var(--brand-wash); border-bottom-color: var(--brand-soft); }
|
||||
.proto-root .jc-selcard-h code { font-size: 13px; font-weight: 800; color: var(--ink); }
|
||||
.proto-root .jc-selcard-h .why { flex: 1 1 auto; min-width: 0; font-size: 11px; color: var(--ink-3); }
|
||||
.proto-root .jc-selcard-b { display: none; }
|
||||
.proto-root .jc-selcard.v-b .jc-selcard-b, .proto-root .jc-selcard.v-c .jc-selcard-b { display: grid; grid-template-columns: 1fr 1fr; gap: 0; }
|
||||
@media (max-width: 1000px) { .proto-root .jc-selcard.v-b .jc-selcard-b, .proto-root .jc-selcard.v-c .jc-selcard-b { grid-template-columns: 1fr; } }
|
||||
.proto-root .jc-selcol { padding: 11px 14px; }
|
||||
.proto-root .jc-selcol + .jc-selcol { border-left: 1px solid var(--line-soft); }
|
||||
@media (max-width: 1000px) { .proto-root .jc-selcol + .jc-selcol { border-left: 0; border-top: 1px solid var(--line-soft); } }
|
||||
.proto-root .jc-selcol h5 { margin: 0 0 7px; font-size: 10.5px; font-weight: 800; letter-spacing: .08em; color: var(--ink-3); }
|
||||
.proto-root .jc-kvrow { display: flex; gap: 8px; padding: 3px 0; font-size: 12px; line-height: 1.55; }
|
||||
.proto-root .jc-kvrow i { flex: 0 0 84px; font-style: normal; color: var(--ink-4); font-size: 11px; padding-top: 1px; }
|
||||
.proto-root .jc-kvrow span { flex: 1 1 auto; min-width: 0; color: var(--ink); overflow-wrap: anywhere; }
|
||||
.proto-root .jc-kvrow span:empty::after { content: "—"; color: var(--ink-4); }
|
||||
.proto-root .jc-selcard.v-b .sc-req:empty::after, .proto-root .jc-selcard.v-c .sc-req:empty::after {
|
||||
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
|
||||
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
|
||||
content: "待填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3;
|
||||
}
|
||||
.proto-root .jc-selcard .sc-opt:empty::after {
|
||||
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
|
||||
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
|
||||
content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line);
|
||||
}
|
||||
.proto-root .jc-selcard-skip { display: none; }
|
||||
.proto-root .jc-selcard.v-a .jc-selcard-skip { display: block; padding: 0 14px 9px; font-size: 11px; color: var(--ink-4); }
|
||||
.proto-root .jc-selcard.v-a .jc-selcard-h { border-bottom: 0; padding-bottom: 4px; }
|
||||
|
||||
/* 场景规则表(静态 —— 尺挂在墙上;场景徽是模版文字不是数据) */
|
||||
.proto-root .jc-graderule { border: 1px solid var(--line); border-radius: 10px; overflow: hidden; margin-bottom: 12px; }
|
||||
.proto-root .jc-graderule-r { display: flex; gap: 12px; align-items: flex-start; padding: 9px 13px; font-size: 12px; line-height: 1.6; }
|
||||
.proto-root .jc-graderule-r + .jc-graderule-r { border-top: 1px solid var(--line-soft); }
|
||||
.proto-root .jc-graderule-r p { margin: 0; color: var(--ink-2); }
|
||||
.proto-root .jc-graderule-r p b { color: var(--ink); }
|
||||
.proto-root .jc-scene {
|
||||
flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; margin-top: 1px;
|
||||
min-width: 52px; height: 20px; padding: 0 8px; border-radius: 6px;
|
||||
font-size: 10.5px; font-weight: 800; letter-spacing: .04em; border: 1.5px solid; background: #fff;
|
||||
}
|
||||
.proto-root .jc-scene.early { color: #0f766e; border-color: #0f766e; }
|
||||
.proto-root .jc-scene.late { color: var(--brand); border-color: var(--brand); }
|
||||
.proto-root .jc-scene.spot { color: var(--warn-accent); border-color: var(--warn-accent); }
|
||||
.proto-root .jc-scene.sample { color: var(--ok); border-color: var(--ok); }
|
||||
`;
|
||||
|
||||
/** 一枚图标 —— 与原型同一写法 */
|
||||
const ico = (name: string, size = 11): string =>
|
||||
`<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-${name}"/></svg>`;
|
||||
|
||||
/** 三层壳照原型:少一层样式表全失效 */
|
||||
const doc = (input: { badge: string; kicker: string; title: string; titleRef: string; state: string; body: string; foot: string }): string =>
|
||||
`<style>${PRD_CSS}${BIND_CSS}</style>
|
||||
${PRD_SPRITE}
|
||||
<div class="proto-root"><div class="app-shell"><div class="jc-stage">
|
||||
<article class="jc-report">
|
||||
<header class="jc-rp-head">
|
||||
<div class="jc-rp-badge">${ico(input.badge, 17)}</div>
|
||||
<div class="jc-rp-tit">
|
||||
<em>${input.kicker}</em>
|
||||
<h3>${input.title} <span data-slot="${input.titleRef}"></span></h3>
|
||||
</div>
|
||||
${input.state}
|
||||
</header>
|
||||
<div class="jc-rp-bd">${input.body}</div>
|
||||
<footer class="jc-rp-foot">${input.foot}</footer>
|
||||
</article>
|
||||
</div></div></div>`;
|
||||
|
||||
const part = (label: string, aside = '', tone = ''): string =>
|
||||
`<div class="jc-rp-part"><b>${label}</b><i class="ln"></i>${aside === '' ? '' : `<em class="${tone}">${aside}</em>`}</div>`;
|
||||
|
||||
const sign = (note: string, tag = ''): string =>
|
||||
`<div class="jc-rp-sign">
|
||||
<span class="jc-rp-ava">${ico('user', 15)}</span>
|
||||
<div><b data-slot="case:owner"></b><span>${note}</span></div>
|
||||
</div>${tag === '' ? '' : `<span class="jc-done-tag">${ico('git-branch', 13)}${tag}</span>`}`;
|
||||
|
||||
/* ==================== 组成件 ==================== */
|
||||
|
||||
/** 要素四卡:客户 / 待补价量 / 一格一料的清单刻度 / 取价的尺 */
|
||||
const FOUR = `<div class="jc-four">
|
||||
<div class="jc-fcard">
|
||||
<span class="jc-fcard-k"><i>${ico('building')}</i>客户公司</span>
|
||||
<div class="jc-fcard-b">
|
||||
<span class="jc-logo">${ico('building', 17)}</span>
|
||||
<div class="jc-fcard-t"><b data-slot="customer_name" data-blank></b><span data-slot="customer_code" data-blank></span></div>
|
||||
</div>
|
||||
<span class="jc-fcard-note">承自上游;单飞起单时此栏可空</span>
|
||||
</div>
|
||||
|
||||
<div class="jc-fcard t-teal">
|
||||
<span class="jc-fcard-k"><i>${ico('boxes')}</i>待补价</span>
|
||||
<div class="jc-count"><b data-slot="quote_sourcing_quote_list_missing"></b><span>/ <b data-slot="quote_line_total"></b> 行清单</span></div>
|
||||
<span class="jc-fcard-note">底价还空着的行 —— 本单要问价的对象;补一格少一格,基座现数</span>
|
||||
</div>
|
||||
|
||||
<div class="jc-fcard">
|
||||
<span class="jc-fcard-k"><i>${ico('history')}</i>清单分类(承继)</span>
|
||||
<div class="jc-segmeter" data-rows="mine:quote_sourcing.quote_list">
|
||||
<i data-class-of="type_tag"></i>
|
||||
</div>
|
||||
<span class="jc-fcard-note">一格一条料 · A 绿 B 蓝按上游现标着色;类别本单不改</span>
|
||||
</div>
|
||||
|
||||
<div class="jc-fcard t-ok">
|
||||
<span class="jc-fcard-k"><i>${ico('shield-check')}</i>取价的尺</span>
|
||||
<span class="jc-tagbig">${ico('check', 13)}<span>A 取历史价 · B 问货源价</span></span>
|
||||
<span class="jc-fcard-note">A 类按库内历史价预填;B 类由采购系统外询价补回 —— 价源逐行留徽,可溯</span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
/** 缺价红带 —— 与提交闸、放行门 L1 同一个数同一套算法(基座现数);一格不缺自动收 */
|
||||
const GAP_BAR = `<div class="jc-gapbar" data-class-of="quote_sourcing_quote_list_missing">${ico('alert', 14)}
|
||||
<span><b>还差 <span data-slot="quote_sourcing_quote_list_missing"></span> 行基准价没补</b>
|
||||
—— <span data-slot="quote_sourcing_quote_list_missing_keys"></span>。
|
||||
按右上角「编辑」开表,红框那几格就是要填的;问不到价的行按行首 × 撤掉并在备注写明。
|
||||
补齐了执行单才提交得了。</span>
|
||||
</div>`;
|
||||
|
||||
const editGate = (address: string, what: string): string => `<div class="jc-editgate" data-edit-for="${address}">
|
||||
<span class="jc-editgate-t">${ico('lock', 12)}${what}<b>已锁定</b> —— 看的时候不误碰,要改按右边</span>
|
||||
<button type="button" class="jc-egbtn open" data-edit-open="${address}">${ico('pen', 12)}编辑</button>
|
||||
<button type="button" class="jc-egbtn ghost" data-edit-cancel="${address}">取消</button>
|
||||
<button type="button" class="jc-egbtn save" data-edit-save="${address}">${ico('check', 12)}保存</button>
|
||||
</div>
|
||||
<div class="jc-editbar">${ico('pen', 14)}
|
||||
<span>底价空着的行是本单要补的:<b>供应商 / 底价 / MOQ / 货源交期 / 备注</b>逐格可填;
|
||||
A 类预填的历史价也能改 —— 改了以你的为准。问不到价的行按行首 × 撤掉(剔出本轮)。
|
||||
改动只落任务草稿层,<b>保存不等于放行</b>。</span>
|
||||
</div>`;
|
||||
|
||||
const addRowBar = (address: string): string => `<div class="jc-addrow">
|
||||
<button type="button" class="jc-egbtn ghost" data-edit-add="${address}">${ico('plus', 12)}加一行</button>
|
||||
<em>照末行的样子添一行空的;主键(型号 PN)填了才存得下,不填这一行不落库。</em>
|
||||
</div>`;
|
||||
|
||||
/**
|
||||
* 清单主表 —— 一行一料:承继列 + 价格五列 + 类与价源两枚徽。
|
||||
* `data-col` 只挂人可改的五列(supplier / base_price / moq / source_lead_time / note)——
|
||||
* 编辑面精确等于 PRM 的职权面。底价格挂 `req`:空着出「必填」胶囊、整行标底,
|
||||
* 与提交闸、放行门同源(都数 `_missing`)。
|
||||
*/
|
||||
const ROSTER = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
|
||||
<thead><tr>
|
||||
<th style="width:36px;min-width:36px"></th>
|
||||
<th style="width:44px;min-width:44px">类</th>
|
||||
<th style="min-width:170px">PN</th>
|
||||
<th style="min-width:130px">替代料 PN</th>
|
||||
<th style="min-width:84px">品类</th>
|
||||
<th style="min-width:72px">年用量</th>
|
||||
<th style="min-width:72px">目标价</th>
|
||||
<th style="min-width:120px">供应商</th>
|
||||
<th class="req" style="min-width:80px">底价</th>
|
||||
<th style="min-width:64px">MOQ</th>
|
||||
<th style="min-width:84px">货源交期</th>
|
||||
<th style="width:64px;min-width:64px">价源</th>
|
||||
<th style="min-width:150px;width:100%">备注</th>
|
||||
</tr></thead>
|
||||
<tbody data-rows="mine:quote_sourcing.quote_list" data-edit="quote_sourcing.quote_list"><tr>
|
||||
<td class="jc-rn"><button type="button" class="jc-rowdel" data-edit-del="quote_sourcing.quote_list" title="撤掉这一行">×</button></td>
|
||||
<td class="jc-ty" data-h="类"><span class="jc-tbadge" data-class-of="type_tag" data-slot="type_tag"></span></td>
|
||||
<td data-h="PN"><span class="jc-vcell mono" data-slot="pn" data-col="pn" data-blank></span></td>
|
||||
<td class="dimempty" data-h="替代料 PN"><span class="jc-vcell mono" data-slot="alt_pn" data-blank></span></td>
|
||||
<td class="dimempty" data-h="品类"><span class="jc-vcell" data-slot="category" data-blank></span></td>
|
||||
<td class="dimempty" data-h="年用量"><span class="jc-vcell mono" data-slot="eau" data-blank></span></td>
|
||||
<td class="dimempty" data-h="目标价"><span class="jc-vcell num" data-slot="target_price" data-blank></span></td>
|
||||
<td class="opt" data-h="供应商"><span class="jc-vcell" data-slot="supplier" data-col="supplier" data-blank></span></td>
|
||||
<td class="req" data-h="底价"><span class="jc-vcell num" data-slot="base_price" data-col="base_price" data-blank></span></td>
|
||||
<td class="opt" data-h="MOQ"><span class="jc-vcell mono" data-slot="moq" data-col="moq" data-blank></span></td>
|
||||
<td class="opt" data-h="货源交期"><span class="jc-vcell" data-slot="source_lead_time" data-col="source_lead_time" data-blank></span></td>
|
||||
<td data-h="价源"><span class="jc-pbadge" data-class-of="price_basis"></span></td>
|
||||
<td class="opt" data-h="备注"><span class="jc-vcell" data-slot="note" data-col="note" data-blank></span></td>
|
||||
</tr></tbody>
|
||||
</table></div>`;
|
||||
|
||||
/** 价源分布:两段按条数分宽 —— 历史价 / 货源价,守恒等式的可视形 */
|
||||
const DIST = `<div class="jc-dist">
|
||||
<div class="jc-distbar">
|
||||
<i class="h" data-class-of="quote_sourcing_quote_list_price_basis_hist" data-slot="quote_sourcing_quote_list_price_basis_hist"></i>
|
||||
<i class="s" data-class-of="quote_sourcing_quote_list_price_basis_sourced" data-slot="quote_sourcing_quote_list_price_basis_sourced"></i>
|
||||
</div>
|
||||
<div class="jc-distlg">
|
||||
<span><em class="a"></em>历史价 · 库内既有 <b data-slot="quote_sourcing_quote_list_price_basis_hist"></b></span>
|
||||
<span><em class="b"></em>货源价 · 本单问回 <b data-slot="quote_sourcing_quote_list_price_basis_sourced"></b></span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
/* ==================== 那份件:目标报价清单(在办与归档同一张)==================== */
|
||||
|
||||
export const SOURCING_ROSTER = doc({
|
||||
badge: 'sheet',
|
||||
kicker: '统一报价 · A3 寻源询价 · 交付成果',
|
||||
title: '目标报价清单',
|
||||
titleRef: 'rfq_ref',
|
||||
state:
|
||||
'<span class="jc-rp-wait">清单 <b data-slot="quote_sourcing_quote_list_rows"></b> 条 · 待补价 <b data-slot="quote_sourcing_quote_list_missing"></b></span>',
|
||||
body:
|
||||
kvCard('本单标的', [
|
||||
['型号 PN', 'pn', 'mono'],
|
||||
['场景', 'scenario'],
|
||||
['当前阶段', 'activeStage', 'tag'],
|
||||
['当下可为', 'enabledCount'],
|
||||
['目标缺口', 'gapCount'],
|
||||
]) +
|
||||
stagesCard('阶段(CMMN)', 'case:stages', '阶段的生命周期态由语义引擎推进到 <b>Enabled</b> 为止;凡触达世界或产出关键交付的计划项必须人工启动 —— <b>Enabled ≠ Active</b>。') +
|
||||
stagesCard('当下可为', 'case:enabled', 'AI 只能在这个集合里选下一步:<b>可以越谱,不可越权</b>。') +
|
||||
stagesCard('目标缺口', 'case:gaps', '缺口只有人知道 → 调人;缺口在世界侧 → 立监听(必配时限)。当下可为 ∩ 目标所需 = 空,就是<b>依赖断点</b>。') +
|
||||
noteCard('这个活动为什么是 loop', '寻源是<b>一事一议</b>:几家供应商、谁先回、要不要追加一轮,事前排不出谱。调度权交给 AI,系统只保证边框 —— 预算、停滞熔断、放行归人。'),
|
||||
part('寻源要素', '寻源询价 · MVP 口径:询价在系统外,价格落表内') +
|
||||
FOUR +
|
||||
part('目标报价清单', '<b data-slot="quote_line_total"></b> 条 · 每行必有基准价') +
|
||||
GAP_BAR +
|
||||
editGate('quote_sourcing.quote_list', '目标报价清单') +
|
||||
ROSTER +
|
||||
addRowBar('quote_sourcing.quote_list') +
|
||||
`<div class="jc-drop">${ico('info', 13)}
|
||||
<span>带 * 的<b>底价</b>是必填:每一行都要有基准价 —— <b>A 类</b>取库内历史价(系统预填,可校可改),
|
||||
<b>B 类</b>由采购系统外询价补回。价源那一列逐行留徽:绿「历史价」是库内既有事实,
|
||||
蓝「货源价」是本单问回来的 —— 两段合起来恰等于总行数,对不上就是账没记齐。
|
||||
<b>问不到的价不许编</b>:剔出本轮(行首 ×)或按内部估价填并在备注写明。</span>
|
||||
</div>` +
|
||||
part('价源分布', '历史价 + 货源价 = 总行数') +
|
||||
DIST +
|
||||
part('放行后即执行的落地动作', '门前 → 门后') +
|
||||
`<div class="jc-acts">
|
||||
<div class="jc-act"><i>${ico('check', 11)}</i>
|
||||
<div><b>草稿层合并进正式版</b> <span>本任务写下的目标报价清单与寻源结论一并落账;
|
||||
PRM 补价与就地修订的那几处随之落定。</span></div>
|
||||
</div>
|
||||
<div class="jc-act"><i>${ico('check', 11)}</i>
|
||||
<div><b>清单此刻才对外发出</b> <span>整份 <b data-slot="quote_sourcing_quote_list_rows"></b> 行
|
||||
(每行带基准价与价源)交出 —— 放行之前,世界上什么都没有发生。</span></div>
|
||||
</div>
|
||||
<div class="jc-act"><i>${ico('check', 11)}</i>
|
||||
<div><b>转入成本定价</b> <span>本活动只有一个出口 —— 目标报价清单是定价环节的直接输入,
|
||||
已进对方的任务清单。</span></div>
|
||||
</div>
|
||||
</div>`,
|
||||
foot: sign('PRM 询价 · AI 归一齐检 —— 要改哪一格,在这张单的清单表上就地改', '统一报价 · A3 寻源询价 → 交定价'),
|
||||
});
|
||||
|
||||
/* ==================== 局面一 · 寻源询价工作单(归一预填与 PRM 补价期间)==================== */
|
||||
|
||||
/**
|
||||
* 逐颗询价档案 —— 一颗料一张卡(data-rows 逐行复制)。
|
||||
* 形态按类别分档:B/C 摊开成「题面 + 定案回显」两栏;A 收成一行「已按历史价预填」。
|
||||
*
|
||||
* **容器用 `<section>` 不用 `<div>`**:渲染器的行展开按第一个同名闭合标签截行内容,
|
||||
* 行模版里嵌着 `<div>` 时 `<div data-rows>` 会把行截成套娃(A2 逮过,记在陷阱表)。
|
||||
*/
|
||||
const INQ_CARDS = `<section class="jc-selcards" data-rows="mine:quote_sourcing.quote_list">
|
||||
<article class="jc-selcard" data-class-of="type_tag">
|
||||
<div class="jc-selcard-h">
|
||||
<span class="jc-tbadge" data-class-of="type_tag" data-slot="type_tag"></span>
|
||||
<code data-slot="pn"></code>
|
||||
<span class="why" data-slot="remark" data-blank></span>
|
||||
</div>
|
||||
<div class="jc-selcard-b">
|
||||
<div class="jc-selcol">
|
||||
<h5>询价题面(要素与口径)</h5>
|
||||
<div class="jc-kvrow"><i>取价对象</i><span data-slot="alt_pn" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>品类</i><span data-slot="category" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>制造商</i><span data-slot="brand" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>客户料号</i><span data-slot="customer_pn" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>年用量</i><span data-slot="eau" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>目标价</i><span data-slot="target_price" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>交期要求</i><span data-slot="lead_time" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>客户约束</i><span data-slot="remark" data-blank></span></div>
|
||||
</div>
|
||||
<div class="jc-selcol">
|
||||
<h5>问价定案(填回下表,这里回显)</h5>
|
||||
<div class="jc-kvrow"><i>供应商</i><span class="sc-req" data-slot="supplier" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>底价</i><span class="sc-req num" data-slot="base_price" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>MOQ</i><span class="sc-opt" data-slot="moq" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>货源交期</i><span class="sc-opt" data-slot="source_lead_time" data-blank></span></div>
|
||||
<div class="jc-kvrow"><i>备注</i><span class="sc-opt" data-slot="note" data-blank></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="jc-selcard-skip">已按库内历史价预填基准 · 可校可改;取价对象为空即原型号本身。</div>
|
||||
</article>
|
||||
</section>`;
|
||||
|
||||
/** 按场景圈渠道 —— 素材:MVP 示例工作流 §1.7 选渠定策(只作文字参考,尺挂在墙上) */
|
||||
const CHANNEL_RULES = `<div class="jc-graderule">
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-scene early">早期</span>
|
||||
<p><b>BOM 早期:渠道可放宽。</b>系统带出的在供供应商直接圈入即可 —— 这一段要的是快,
|
||||
价格验证也宽(首个报价即用,品牌价差两成内算正常)。</p>
|
||||
</div>
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-scene late">后期</span>
|
||||
<p><b>BOM 后期:必须靠谱。</b>按主供应商 → 备选 → 贸易商的顺序圈两三家,<b>贸易商尽量不用</b>;
|
||||
核实授权代理资质与授权期限,贸易商核查货源链可靠性。默认两轮:首轮回价内部核过再发次轮。</p>
|
||||
</div>
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-scene spot">现货</span>
|
||||
<p><b>现货:只认有库存、可提供仓库实拍的供应商。</b>无库存转单商不可选 ——
|
||||
转一手就是一手风险,货期与成色都不由你控。</p>
|
||||
</div>
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-scene sample">样品</span>
|
||||
<p><b>样品:量小求快。</b>渠道可放宽,低于线上商城价即可用;库内不足线上补。</p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
/** 价格验证的尺 —— 素材:MVP 示例工作流 §1.7 发询比价(只作文字参考) */
|
||||
const VERIFY_RULES = `<div class="jc-graderule">
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-scene early">早期</span>
|
||||
<p>首个报价即用;品牌之间价差两成以内算正常,不必追平。</p>
|
||||
</div>
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-scene late">后期</span>
|
||||
<p>按历史最低价降两成去谈;两轮核价,首轮回价内部核过再谈次轮。</p>
|
||||
</div>
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-scene spot">现货</span>
|
||||
<p>多重校验:低于线上商城价、比过区域市场价、比过订货价(高出两成以内可接受)。</p>
|
||||
</div>
|
||||
<div class="jc-graderule-r">
|
||||
<span class="jc-scene sample">样品</span>
|
||||
<p>低于线上商城价即用 —— 样品要的是拿得到,不是省到底。</p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export const SOURCING_WORKSHEET = doc({
|
||||
badge: 'mail-open',
|
||||
kicker: '统一报价 · A3 寻源询价 · 询价作业',
|
||||
title: '寻源询价工作单',
|
||||
titleRef: 'rfq_ref',
|
||||
state:
|
||||
'<span class="jc-rp-wait warn">待补价 <b data-slot="quote_sourcing_quote_list_missing"></b> / <b data-slot="quote_line_total"></b> 行</span>',
|
||||
body:
|
||||
part('询价任务', '客户 <b data-slot="customer_name" data-blank></b> · 待补价 <b data-slot="pending_total"></b> 颗 · A 类已预填 <b data-slot="hist_priced_total"></b> 颗') +
|
||||
`<div class="jc-derive">${ico('info', 14)}
|
||||
<span>下面每颗料一张<b>询价档案卡</b>:左边是题面(要素、取价对象与客户约束),右边回显你的定案。
|
||||
蓝标 B 的料是本单要问价的对象;绿标 A 已按库内历史价预填基准,可校可改。
|
||||
询价在系统外做(发函、催回价、比价都在你手上),<b>定案落回本单</b> —— 供应商、底价、
|
||||
MOQ、货源交期逐颗可溯。发询纪律:同一料所有渠道一次发齐,PN / 数量 / 目标价 / 回价时限
|
||||
写死在询价里;回价登记一一对应不可缺项;不实回价或远低行情的换渠道补询,别直接采用。</span>
|
||||
</div>` +
|
||||
part('逐颗询价档案', '题面在左 · 定案在右') +
|
||||
INQ_CARDS +
|
||||
part('按场景圈渠道', 'BOM早期 / BOM后期 / 现货 / 样品 —— 场景定尺度') +
|
||||
CHANNEL_RULES +
|
||||
part('价格验证的尺', '按场景核过再采') +
|
||||
VERIFY_RULES +
|
||||
`<div class="jc-drop">${ico('info', 13)}
|
||||
<span><b>时限参考</b>:BOM早期 / 现货 1~4 日,样品 1~3 日,BOM后期 3~9 日 —— 急料电话催办;
|
||||
回价临过期先复核确认,过期触发重询。历史价未满三次询价但有效期已过的,标记须重询。</span>
|
||||
</div>` +
|
||||
part('填回工作表', '定案落在这 —— 补齐了执行单才提交得了') +
|
||||
GAP_BAR +
|
||||
editGate('quote_sourcing.quote_list', '寻源询价填回表') +
|
||||
ROSTER +
|
||||
addRowBar('quote_sourcing.quote_list') +
|
||||
`<div class="jc-drop">${ico('info', 13)}
|
||||
<span>带 * 的<b>底价</b>是必填(未税单价按内部口径归一)—— 缺一行执行单按不下提交,
|
||||
与放行门是同一个数同一套算法。<b>问不到的价不许编</b>:剔出本轮(行首 ×)
|
||||
或按内部估价填并在备注写明。</span>
|
||||
</div>`,
|
||||
foot: sign('PRM 询价作业 · 定案落表,齐检定盘由系统接着办', '统一报价 · A3 寻源询价 · 在办'),
|
||||
});
|
||||
|
||||
@@ -6,7 +6,8 @@ export { ActivityNotInstalledError, InstallRejectedError, ToolNotDeclaredError }
|
||||
export type { Clock, IdGenerator, LedgerDeclarationApplier, ModelBindingProbe, RegistryStore, TemplateGuard, ToolContractProbe, } from './port.ts';
|
||||
export type { RegistryServiceDeps } from './internal/usecase/registry-service.ts';
|
||||
export { createRegistryService } from './internal/usecase/registry-service.ts';
|
||||
export { acceptanceLevelFindings, auditPassed, danglingListeners, loopFindings, scanArtifact, staticAudit, } from './internal/domain/static-audit.ts';
|
||||
export type { AcceptanceAuditInput, DeliverableAuditInput } from './internal/domain/static-audit.ts';
|
||||
export { acceptanceLevelFindings, deliverableAddressFindings, ledgerKindFindings, auditPassed, danglingListeners, loopFindings, scanArtifact, staticAudit, } from './internal/domain/static-audit.ts';
|
||||
export { ASSET_KINDS, assertTransition, buildLoadEnvelope, canTransition, IllegalIncrementTransitionError, INCREMENT_STATES, judgeReplay, mayTakeEffect, shouldSeedAsset, } from './internal/domain/versioning.ts';
|
||||
export { createRegistryStore } from './internal/adapter/registry-store.ts';
|
||||
export { activityAssetTable, activityPackageTable, assetIncrementTable } from './internal/adapter/registry-schema.ts';
|
||||
|
||||
+33
-1
@@ -54,6 +54,8 @@ export type IssuedChatCard = {
|
||||
};
|
||||
export type ReplyOutcome = {
|
||||
readonly order: WorkOrder;
|
||||
/** 这张卡挂在哪一单 —— 装配层要拿它把「人就地改的那几行」写回草稿层 */
|
||||
readonly taskId: TaskId;
|
||||
/** 完成只出自放行通过 —— 这里为真,任务才可收敛终态 */
|
||||
readonly producesCompletion: boolean;
|
||||
/** 驳回:环内回转,任务继续 */
|
||||
@@ -78,9 +80,39 @@ export type HumanToolService = {
|
||||
}): Promise<IssuedWorkOrder>;
|
||||
issueChatCard(context: IssueContext, card: ChatCard): Promise<IssuedChatCard>;
|
||||
replyWorkOrder(reply: WorkOrderReply, by: StaffId): Promise<ReplyOutcome>;
|
||||
replyChatCard(reply: ChatCardReply, by: StaffId): Promise<ChatCard>;
|
||||
/**
|
||||
* 回传带上 `taskId` —— **数据录入卡的确认要当场写草稿**,而写库要知道写给哪一单。
|
||||
* 由本模块自己写会让 human-tool 依赖 ledger(越层),所以把事实交出去,由组装层落笔。
|
||||
*/
|
||||
replyChatCard(reply: ChatCardReply, by: StaffId): Promise<{
|
||||
card: ChatCard;
|
||||
taskId: TaskId;
|
||||
}>;
|
||||
/** 驳回重答:新卡实例,原卡闭合 —— 只增不改 */
|
||||
rejectAndReask(cardId: string, reason: string): Promise<IssuedChatCard>;
|
||||
/**
|
||||
* 给已发出的验收工单补上选择组(AM 1.2 恰选一口)。
|
||||
*
|
||||
* 为什么分两步:卡由 `release.submitForRelease` 发 —— 它守的是预检那道门,
|
||||
* 不该也不必知道「这个活动有哪些出口」。出口是契约的事实,由 app 从 manifest 现读后贴上。
|
||||
*/
|
||||
attachPortChoice(workOrderId: string, choice: {
|
||||
group: string;
|
||||
ports: ReadonlyArray<{
|
||||
name: string;
|
||||
label?: string | undefined;
|
||||
}>;
|
||||
}): Promise<void>;
|
||||
/**
|
||||
* **算出来的去向**上卡 —— 不给人选,只告诉他走哪一口、凭哪一条。
|
||||
* 去向是台账那几个数的函数;摆成选择题,等于把可回放的判定换成一次手感。
|
||||
*/
|
||||
attachRoutedPort(workOrderId: string, routed: {
|
||||
group: string;
|
||||
name: string;
|
||||
label?: string | undefined;
|
||||
because: string;
|
||||
}): Promise<void>;
|
||||
openWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
|
||||
/** 全部工单(含已办结)—— 工作成果要回看定稿的报告,只给 open 就永远是空的 */
|
||||
allWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
|
||||
|
||||
@@ -17,6 +17,8 @@ export type CardStore = {
|
||||
} | undefined>;
|
||||
listWorkOrders(taskId: TaskId, openOnly: boolean): Promise<WorkOrder[]>;
|
||||
markWorkOrderDone(id: string, done: NonNullable<WorkOrder['done']>): Promise<void>;
|
||||
/** 局部改卡面 —— 只增不改是对**已回传**的卡;未答的卡还可补齐契约事实(如选择组) */
|
||||
patchWorkOrder(id: string, patch: Partial<WorkOrder>): Promise<void>;
|
||||
saveChatCard(taskId: TaskId, card: ChatCard, recipientId: StaffId): Promise<void>;
|
||||
getChatCard(id: string): Promise<{
|
||||
taskId: TaskId;
|
||||
|
||||
+20
-1
@@ -50,17 +50,36 @@ export type InteractionService = {
|
||||
/** 视图流控:活动按局面推结构化块 */
|
||||
pushView(taskId: TaskId, blocks: readonly ViewBlock[]): Promise<void>;
|
||||
/** 视图流控:活动推 HTML 模版 —— 过净化门,不过即抛 */
|
||||
pushViewHtml(taskId: TaskId, html: string): Promise<void>;
|
||||
/**
|
||||
* 推一帧 HTML。`template` **要一起存下来** —— 件是投影,而投影得能按当下数据**重渲**。
|
||||
*
|
||||
* 只存渲染好的 HTML,那一帧就成了快照:人在件上改完几格,由数据算出来的东西
|
||||
* (类徽 A/B/C、分布条、结论横幅那条去向)全停在渲染那一刻 ——
|
||||
* 屏上一半新一半旧,那不是没更新,是界面在撒谎。记住模版名,才重得出来。
|
||||
*/
|
||||
pushViewHtml(taskId: TaskId, html: string, template?: string): Promise<void>;
|
||||
/** 视图区当前呈现什么 —— 只读投影,刷新即重建(前端零过程状态) */
|
||||
viewOf(taskId: TaskId): Promise<{
|
||||
blocks?: readonly ViewBlock[];
|
||||
html?: string;
|
||||
template?: string;
|
||||
} | undefined>;
|
||||
/** 控制权转交 —— 随引擎的激活 / 完成事件自动发生 */
|
||||
setPhase(taskId: TaskId, phase: InteractionPhase): Promise<ControlHolder>;
|
||||
phaseOf(taskId: TaskId): Promise<InteractionPhase>;
|
||||
/** SSE:单连接、按信封目标失效缓存 */
|
||||
subscribe(staffId: StaffId, onPush: (envelope: PushEnvelope) => void): Subscription;
|
||||
/**
|
||||
* **系统级转接**:装配层把所有推送接出去(转给前端的 SSE 广播),不按人筛。
|
||||
*
|
||||
* 为什么不复用 `subscribe`:那个要一个收件人,而转接是系统的事 ——
|
||||
* 为它编一个假员工 id 是撒谎。不按人筛也不漏:推送**不带业务载荷**
|
||||
* (只说「哪个任务动了」),谁该看见由前端自己那几把查询决定(§9.2 前端零过程状态)。
|
||||
*
|
||||
* 没有这个口子的后果实测过:AI 说的每一句、推的每一帧画面都广播给了空气 ——
|
||||
* `subscribe` 一个订阅者都没有,前端只好靠人点刷新。
|
||||
*/
|
||||
onPush(listener: (envelope: PushEnvelope) => void): Subscription;
|
||||
/** 供上层广播失效信号 */
|
||||
invalidate(target: 'task' | 'workflow' | 'desk', id: string): void;
|
||||
};
|
||||
|
||||
@@ -16,10 +16,12 @@ export type InteractionStore = {
|
||||
putView(taskId: TaskId, view: {
|
||||
blocks?: readonly ViewBlock[];
|
||||
html?: string;
|
||||
template?: string;
|
||||
}): Promise<void>;
|
||||
getView(taskId: TaskId): Promise<{
|
||||
blocks?: readonly ViewBlock[];
|
||||
html?: string;
|
||||
template?: string;
|
||||
} | undefined>;
|
||||
setPhase(taskId: TaskId, phase: InteractionPhase): Promise<void>;
|
||||
phaseOf(taskId: TaskId): Promise<InteractionPhase | undefined>;
|
||||
|
||||
@@ -51,6 +51,12 @@ export type DraftWrite = {
|
||||
export type ReleaseWriteResult = {
|
||||
readonly mergedRows: number;
|
||||
readonly emittedEvents: number;
|
||||
/**
|
||||
* **试跑单被挡下的主数据行数** —— 合到出参里去,是因为界面不撒谎:
|
||||
* 放行痕上写「合并 N 行」而其中几行根本没进表,那句话就是假的。
|
||||
* 非试跑单恒为 0。
|
||||
*/
|
||||
readonly simulatedRows: number;
|
||||
/**
|
||||
* 合并进正式版的那些行(键 + 值)。
|
||||
* 「落地收敛,输出口才发射」——发射的载荷就该是**这些已落地的事实**,
|
||||
@@ -78,6 +84,22 @@ export type LedgerService = {
|
||||
}>>;
|
||||
systemWrite(write: SystemWrite): Promise<void>;
|
||||
draftWrite(write: DraftWrite): Promise<void>;
|
||||
/**
|
||||
* **撤掉本单草稿层的一行** —— 写错了得有退路(2026-08-01 加)。
|
||||
*
|
||||
* 不是第四种写:它只作用在**本单草稿层**,正式版一个字都动不了,也不抛事件 ——
|
||||
* 撤的是「我这一单还没过门的那一笔」。放行门的位置没变。
|
||||
*
|
||||
* 没有它的后果实测过:客户说本轮跳过的那颗料被写进了有效清单,条目数报 9 而表里躺着 10,
|
||||
* 判据 `record_rows = line_total` 正确地拦住,而模型**改不动** ——
|
||||
* 它唯一能改的是那个数(改了 skipped_total 又不对),13 次拒回、23 拍、234k token 才熔断。
|
||||
* 门拦住一件办错的事却不给改正的手段,那扇门就只是个死胡同。
|
||||
*/
|
||||
draftDrop(drop: {
|
||||
taskId: TaskId;
|
||||
address: ObjectAddress;
|
||||
key: Record<string, unknown>;
|
||||
}): Promise<boolean>;
|
||||
releaseWrite(taskId: TaskId): Promise<ReleaseWriteResult>;
|
||||
/** 正式版查询 —— 跨任务只见过门的事实 */
|
||||
query(query: LedgerQuery): Promise<LedgerPage>;
|
||||
|
||||
+32
-1
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* module: ledger —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
||||
*/
|
||||
import type { EnvelopeDraft, LedgerKey, LedgerQuery, LedgerRow } from '@contracts';
|
||||
import type { EnvelopeDraft, LedgerKey, LedgerQuery, LedgerRow, LedgerObjectKind } from '@contracts';
|
||||
import type { MaterializedObject, SchemaChange } from './internal/domain/schema-merge.ts';
|
||||
import type { DraftEntry } from './internal/domain/task-view.ts';
|
||||
export type Clock = {
|
||||
@@ -14,6 +14,20 @@ export type IdGenerator = {
|
||||
export type EventPublisher = {
|
||||
publish(envelope: EnvelopeDraft): Promise<void>;
|
||||
};
|
||||
/**
|
||||
* 这一单是不是**试跑** —— app 注入(设计台的两条试跑路径都算,见 `container.ts`)。
|
||||
*
|
||||
* 台账在 L1,认不出「谁起的单」;而放行写要不要落主数据,取决的恰恰是这件事。
|
||||
* 所以由 app 把这一个布尔判断喂进来 —— 与 `EventPublisher` 同一个套路:
|
||||
* 模块只声明「我需要知道什么」,不声明「去哪儿问」。
|
||||
*
|
||||
* 为什么只挡主数据、不挡单据:单据按单标识分行,一次试跑写一行自己的,
|
||||
* 清运行数据就没了;主数据是**按业务实体**分行的档案,试跑往里建一位客户,
|
||||
* 「新客户」那条样例就此再也试不出来 —— 它成了老客户。
|
||||
*/
|
||||
export type SimulationProbe = {
|
||||
isSimulated(taskId: string): Promise<boolean>;
|
||||
};
|
||||
export type ObjectRef = {
|
||||
readonly domain: string;
|
||||
readonly object: string;
|
||||
@@ -26,9 +40,18 @@ export type LedgerStore = {
|
||||
/** 已物化对象的列与主键;未物化返回 undefined */
|
||||
describe(ref: ObjectRef): Promise<MaterializedObject | undefined>;
|
||||
/** 出参带人读名(表注释);没登记就没有 label,呈现退回对象键 */
|
||||
/** 记下对象性质(单据/主数据/派生)与单标识 —— 随声明走,不随建表走 */
|
||||
setObjectMeta(ref: ObjectRef, kind: LedgerObjectKind, caseKey: string | undefined): Promise<void>;
|
||||
listObjects(): Promise<Array<ObjectRef & {
|
||||
label?: string | undefined;
|
||||
}>>;
|
||||
/**
|
||||
* 哪些对象是**主数据** —— 放行写要据此把试跑单的主数据挡在正式表外。
|
||||
*
|
||||
* 单独一个方法而不是让调用方 `listObjects` 自己筛:性质记在 `__object_meta` 里,
|
||||
* 一次 where 就够;`describe` 读的是 information_schema,那里没有性质这一维。
|
||||
*/
|
||||
masterRefs(): Promise<ReadonlyArray<ObjectRef>>;
|
||||
/** 执行「只增不破坏」的变更计划 */
|
||||
applyChanges(ref: ObjectRef, changes: readonly SchemaChange[]): Promise<void>;
|
||||
select(query: LedgerQuery): Promise<{
|
||||
@@ -46,5 +69,13 @@ export type DraftStore = {
|
||||
list(taskId: string): Promise<Array<DraftEntry & ObjectRef>>;
|
||||
listForObject(taskId: string, ref: ObjectRef): Promise<DraftEntry[]>;
|
||||
put(taskId: string, ref: ObjectRef, entry: DraftEntry): Promise<void>;
|
||||
/**
|
||||
* 撤掉本单草稿层的**一行** —— 写错了得有退路(2026-08-01 加)。
|
||||
*
|
||||
* 只动 `taskId` 那一份,正式版碰不到。没有它的后果实测过:客户说本轮跳过的那颗料
|
||||
* 被写进了有效清单,条目数报 9 而表里躺着 10 —— 判据正确地拦住,而模型改不动
|
||||
* (唯一能改的是那个数,改了别的判据又不对),13 次拒回、23 拍、234k token 才熔断。
|
||||
*/
|
||||
drop(taskId: string, ref: ObjectRef, key: LedgerKey): Promise<void>;
|
||||
clear(taskId: string): Promise<void>;
|
||||
};
|
||||
|
||||
@@ -31,6 +31,12 @@ export type ModelCall = {
|
||||
readonly prompt: string;
|
||||
readonly temperature?: number;
|
||||
readonly maxOutputTokens?: number;
|
||||
/**
|
||||
* 压推理:`off` 不许想、`low` 少想;缺省由着模型。
|
||||
* 只对画像里声明了 `reasoningControl` 的厂家生效 —— 没姿势的厂家如实忽略。
|
||||
* 规划型调用(loop 单拍)该关:隐藏思考按秒计,而它的判断本就交给了判定表与工具。
|
||||
*/
|
||||
readonly reasoning?: 'off' | 'low';
|
||||
/** 出参 schema(JSON Schema);给定即要求结构化出参并校验 */
|
||||
readonly outputSchema?: Record<string, unknown>;
|
||||
/** 录制/回放的定位:任务 × 步序号(§7.3) */
|
||||
|
||||
+229
-2
@@ -8,7 +8,8 @@ import type { LoopModel, TaskId } from '@contracts';
|
||||
import type { CaseModel, PlanItem, PlanItemType } from './internal/domain/cmmn-parse.ts';
|
||||
import type { CaseRuntime, CaseState, EnabledEntry, EnabledSet, ItemRuntime, ItemState, Navigation, TerminalAuthority } from './internal/domain/lifecycle.ts';
|
||||
import type { DecisionResult, DecisionTrace, Gap } from './internal/domain/dmn.ts';
|
||||
export type { CaseModel, CaseRuntime, CaseState, DecisionResult, DecisionTrace, EnabledEntry, EnabledSet, Gap, ItemRuntime, ItemState, LoopModel, Navigation, PlanItem, PlanItemType, TerminalAuthority, };
|
||||
import type { PhaseProgress } from './internal/domain/phase.ts';
|
||||
export type { CaseModel, CaseRuntime, CaseState, DecisionResult, DecisionTrace, EnabledEntry, EnabledSet, Gap, ItemRuntime, ItemState, LoopModel, Navigation, PhaseProgress, PlanItem, PlanItemType, TerminalAuthority, };
|
||||
export { CmmnParseError } from './internal/domain/cmmn-parse.ts';
|
||||
export { IllegalTransitionError } from './internal/domain/lifecycle.ts';
|
||||
export declare class ForbiddenTerminalError extends Error {
|
||||
@@ -28,6 +29,48 @@ export type AgentAction =
|
||||
readonly tool: string;
|
||||
readonly input: Record<string, unknown>;
|
||||
}
|
||||
/**
|
||||
* 写台账草稿 —— **活动过程写的唯一形态**(铁律②:登记那一刻世界上什么都没变)。
|
||||
*
|
||||
* 一拍可写多行:逐条把一份 BOM 解析成几十行,是**一件事**,不该拆成几十拍
|
||||
* 各烧一次模型。地址必须在活动清单声明过的台账对象之内(越域写与越权调工具同罪)。
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'write';
|
||||
readonly itemId: string;
|
||||
/**
|
||||
* 台账地址「域.对象」—— **一个字符串,不是两个字段**。
|
||||
*
|
||||
* 系统上下都用这一种写法(可写台账字典、`requiredDeliverables[].ledger` 都是它),
|
||||
* 拆成 domain + object 只会让模型把整个地址塞进其中一格 —— 实测第一拍就这么错。
|
||||
*/
|
||||
readonly address: string;
|
||||
readonly rows: ReadonlyArray<{
|
||||
readonly key: Record<string, string | number>;
|
||||
readonly patch: Record<string, unknown>;
|
||||
}>;
|
||||
}
|
||||
/**
|
||||
* **撤掉自己写错的那一行** —— 写错了得有退路(2026-08-01 加)。
|
||||
*
|
||||
* 只作用在**本单草稿层**:正式版一个字都动不了,也不抛事件 ——
|
||||
* 撤的是「我这一单还没过门的那一笔」,放行门的位置没变。
|
||||
*
|
||||
* 起因是实测的一次死胡同:客户明说本轮跳过的那颗料被写进了有效清单,
|
||||
* 条目数报 9(对)而表里躺着 10,判据 `record_rows = line_total` 正确地拦住 ——
|
||||
* 而模型**改不动**:它唯一能改的是那个数,改了 `skipped_total` 又不对。
|
||||
* 13 次拒回、23 拍、234k token 才熔断。**门拦住一件办错的事却不给改正的手段,
|
||||
* 那扇门就只是个死胡同。**
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'drop';
|
||||
readonly itemId: string;
|
||||
/** 台账地址「域.对象」—— 与 write 同一种写法 */
|
||||
readonly address: string;
|
||||
/** 要撤的那几行的主键 —— 认不出的行如实回「草稿层里没有这一行」 */
|
||||
readonly keys: ReadonlyArray<Record<string, string | number>>;
|
||||
readonly reason: string;
|
||||
}
|
||||
/** 调人:四卡之一 —— 调人 = 立监听听人 */
|
||||
| {
|
||||
readonly kind: 'human';
|
||||
@@ -35,6 +78,119 @@ export type AgentAction =
|
||||
readonly card: Record<string, unknown>;
|
||||
readonly workOrder: 'execution' | 'acceptance';
|
||||
}
|
||||
/**
|
||||
* **发补全卡** —— 对话卡族之一,与执行工单是两种东西。
|
||||
*
|
||||
* 「按缺口清单追问补全」(A1-S2)要的就是它:缺口清单摆在对话区、带一张表单,
|
||||
* 人就地填齐。而执行工单是「派一件活给你办」—— 派一张工单去问几个字段,
|
||||
* 收卡的人得进待处理、办完再回来,重得不成比例。
|
||||
*
|
||||
* 从前 Agent **发不出对话卡**:行动型里只有 `human` 且只能选执行 / 验收两种工单,
|
||||
* 于是 SOP 上画着「补全卡」的那一步只能拿执行工单顶 —— 形似而神非。
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'ask';
|
||||
readonly itemId: string;
|
||||
/** 问什么 —— 一句话说清缺口 */
|
||||
readonly ask: string;
|
||||
/**
|
||||
* 要对方填的格子。`hint` 是格子里的灰字提示;
|
||||
* **`value` 是预填值** —— AI 从来件里读得出的先替人填上(公司名、联系人姓名),
|
||||
* 人只需核对一眼、补上填不出的那个,而不是对着一排空格从头敲。
|
||||
* 读不出来的就别给:填错了人还得先擦掉再写,比空着更费事。
|
||||
*/
|
||||
readonly fields: ReadonlyArray<{
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly hint?: string;
|
||||
readonly value?: string;
|
||||
}>;
|
||||
/** 依据快照:哪几条缺哪几个字段 —— 调用必附上下文,裸调用拒发 */
|
||||
readonly basis?: ReadonlyArray<{
|
||||
readonly k: string;
|
||||
readonly v: string;
|
||||
}>;
|
||||
}
|
||||
/**
|
||||
* 发**数据录入卡** —— 人类工具的第五类:**人按下确认那一刻就是写入**。
|
||||
*
|
||||
* 与补全卡(`ask`)的分工:那张收的是「答案」,你拿到之后还要自己 `write` 一遍;
|
||||
* 这张收的是「这一行数据」—— 卡上写明落哪张表、哪个主键,人确认,基座当场落草稿层,
|
||||
* **你不经手**。建档这类事该用它:主数据建错一次,后面每一单都跟着错,
|
||||
* 那一笔该由人亲手落下,不该经你转述一遍。
|
||||
*
|
||||
* 你的活是**把能填的先填好**(`value`),填不出的留空 —— 界面会标「待填写」。
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'entry';
|
||||
readonly itemId: string;
|
||||
readonly ask: string;
|
||||
/** 落哪张表:`域.对象` */
|
||||
readonly ledger: string;
|
||||
/** 主键值 —— 确认时按它定位那一行 */
|
||||
readonly key: Readonly<Record<string, string>>;
|
||||
readonly fields: ReadonlyArray<{
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly value?: string;
|
||||
readonly required?: boolean;
|
||||
}>;
|
||||
/**
|
||||
* **同一次确认还要落的另几张表**(2026-08-02)。
|
||||
*
|
||||
* 建一位新客户落的是**两条主数据**:公司档 + 联系人档。只把公司那一半摆上卡、
|
||||
* 联系人由你自己写,等于让人确认了一半 —— 而联系人档的 `contact_code`
|
||||
* 恰恰是你编出来的那一个。分成两张卡也能办,但那是把一件事拆成两次打断。
|
||||
*/
|
||||
readonly also?: ReadonlyArray<{
|
||||
/** 这一段是什么 —— 卡上那行小标题(如「联系人档案」) */
|
||||
readonly title: string;
|
||||
readonly ledger: string;
|
||||
readonly key: Readonly<Record<string, string>>;
|
||||
readonly fields: ReadonlyArray<{
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly value?: string;
|
||||
readonly required?: boolean;
|
||||
}>;
|
||||
}>;
|
||||
readonly basis?: ReadonlyArray<{
|
||||
readonly k: string;
|
||||
readonly v: string;
|
||||
}>;
|
||||
}
|
||||
/**
|
||||
* 发**决策卡** —— 对话卡族的第二种:把一个岔路口摆给人,人点一个选项。
|
||||
*
|
||||
* 与补全卡的分工:补全卡问的是**事实**(缺哪几个字段,你填);
|
||||
* 决策卡问的是**处置**(这一步该往哪走,你选)。A1 的
|
||||
* 「字段没齐 —— 要我系统外去问客户,还是替你草拟一封邮件」正是后者:
|
||||
* 两条路都合法,选哪条是业务判断,不该由模型替人决定。
|
||||
*
|
||||
* 从前决策卡**只有 UI 没有 Agent 侧**:卡面在对话区画得出来,而行动型里发不出它 ——
|
||||
* 于是所有岔路口都被写成补全卡,人拿到的是一张要自己敲字的表单,而不是两个可点的选项。
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'decide';
|
||||
readonly itemId: string;
|
||||
/** 问什么 —— 一句话说清这是个什么岔路 */
|
||||
readonly ask: string;
|
||||
/** 选项:一条路一项。`consequence` 写清选了它会发生什么 */
|
||||
readonly options: ReadonlyArray<{
|
||||
readonly value: string;
|
||||
readonly label: string;
|
||||
readonly detail?: string;
|
||||
readonly consequence?: string;
|
||||
readonly recommended?: boolean;
|
||||
}>;
|
||||
/** 依据快照:凭什么问这一问 —— 调用必附上下文,裸调用拒发 */
|
||||
readonly basis?: ReadonlyArray<{
|
||||
readonly k: string;
|
||||
readonly v: string;
|
||||
}>;
|
||||
/** 允许人给出选项之外的处置(附理由) */
|
||||
readonly allowCustom?: boolean;
|
||||
}
|
||||
/** 立监听:AI 不能调用世界,对世界只有两种合法姿势 —— 触达它(动作)与等它(监听) */
|
||||
| {
|
||||
readonly kind: 'listen';
|
||||
@@ -66,10 +222,38 @@ export type AgentAction =
|
||||
readonly satisfied: boolean;
|
||||
readonly rationale: string;
|
||||
}
|
||||
/** 判定成果已齐,提交放行 */
|
||||
/**
|
||||
* 这一项干完了 —— **完成由 Agent 自己宣告,不是某个动作的副作用**。
|
||||
*
|
||||
* 从前 `tool` / `write` 一做完就把计划项收了。可一个计划项常常要好几个动作才交得出成果
|
||||
* (先逐条写记录、再结算总数;先调工具取判据、再把判据写进台账)——
|
||||
* 一收就再也动不了它,于是那一项该产出的东西永远缺着,而环在原地打转。
|
||||
* 实测:20 拍烧掉 120k token,卡在「当下可为 ∩ 目标所需 = 空」。
|
||||
*
|
||||
* 宣告完成不等于成果被认可:终局仍唯放行,人验收才算数。
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'finish';
|
||||
readonly itemId: string;
|
||||
readonly rationale: string;
|
||||
}
|
||||
/**
|
||||
* 判定成果已齐,提交放行。
|
||||
*
|
||||
* `fields` = 验收报告上**让人当场改**的那几个值(PRD:「AI 已填好、可编辑」)——
|
||||
* `ai` 是活动拟的值,`ledger` 是改完落回哪个台账地址(`域.对象`)。
|
||||
* 不给 `fields`,验收报告就只能看不能改;给了 `ai` 不给 `ledger`,人改了也只留在
|
||||
* 工单留痕里,落地用的还是活动的原值 —— 两样都得有,修订才落得下去。
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'submit';
|
||||
readonly rationale: string;
|
||||
readonly fields?: ReadonlyArray<{
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly ai?: string;
|
||||
readonly ledger?: string;
|
||||
}>;
|
||||
}
|
||||
/**
|
||||
* 本回合**已在工具循环里调过人**:卡已发、监听已立(工具总线执行的)。
|
||||
@@ -81,6 +265,41 @@ export type AgentAction =
|
||||
readonly cardId: string;
|
||||
readonly rationale: string;
|
||||
};
|
||||
/**
|
||||
* **对人说的一句话** —— 交代进展,并可顺手换一张局面画面。
|
||||
*
|
||||
* 它**不是一型行动,是每一拍的附带** —— 这一点是改过的:一开始把它做成独立行动,
|
||||
* 结果它占一整拍又不推进局面,预算下的 Agent 理所当然跳过它,对话区照旧空白。
|
||||
* 让说话与干活抢拍,是把「必须说」设计成了「有空才说」。
|
||||
*
|
||||
* 现在:一拍 = 一个行动 + 一句交代。说话不花额外的拍,也就没有不说的理由。
|
||||
* `view` 是可选的局面模版名 —— 局面变了,人既要听到一句为什么,也要看到新画面。
|
||||
*/
|
||||
export type Narration = {
|
||||
readonly text: string;
|
||||
/** 语气签:指导 / 判据 / 结论 —— 前端各有其样式 */
|
||||
readonly tag?: 'guidance' | 'criterion' | 'conclusion';
|
||||
readonly view?: string;
|
||||
};
|
||||
/**
|
||||
* 一拍的产出:**一串行动**(按序执行)+ 对人交代一句。
|
||||
*
|
||||
* 为什么是一串不是一件:一拍只许一个行动时,每个 write、每个 finish、每个 start
|
||||
* 都单烧一次完整模型调用(说明书 + 轨迹整段重读)。实测 A1 六步的活恰好花 21 拍 ——
|
||||
* 其中 19 拍是这个约束下的**最短路径**,浪费不在模型跑偏,在架构按拍计费。
|
||||
* 收成一串后,「写记录、写结算、finish、start 下一项」一口气给,一单 5-8 拍收得了口。
|
||||
*
|
||||
* 执行纪律(引擎强制,不靠模型自觉):
|
||||
* · 按序执行,谁被拒就停在谁那儿 —— 已执行的前缀落笔留痕,理由喂回下一拍;
|
||||
* · `tool` 之后截断 —— 出参没看见,后面的行动是闭着眼计划的;
|
||||
* · `ask` / `decide` / `human` / `listen` / `submit` 让本拍挂起 —— 只能是最后一个;
|
||||
* · 一串封顶 `TURN_ACTION_CAP` 件,超出的直接丢弃(留痕)。
|
||||
*/
|
||||
export declare const TURN_ACTION_CAP = 6;
|
||||
export type AgentTurn = {
|
||||
readonly actions: readonly AgentAction[];
|
||||
readonly say?: Narration;
|
||||
};
|
||||
export type TurnOutcome = {
|
||||
readonly runtime: CaseRuntime;
|
||||
readonly enabled: EnabledSet;
|
||||
@@ -96,6 +315,14 @@ export type LoopEngineService = {
|
||||
/** 装载:XML 进语义引擎,说明书进 Agent 上下文;版本全部入信封 */
|
||||
load(taskId: TaskId, model: LoopModel): Promise<CaseRuntime>;
|
||||
runtime(taskId: TaskId): Promise<CaseRuntime | undefined>;
|
||||
/**
|
||||
* 进度刻度:这一单在第几阶段(见 `internal/domain/phase.ts`)。
|
||||
*
|
||||
* 由 loop 出而不是让调用方自己算:阶段声明与计划项状态**同在一行运行时里**,
|
||||
* 调用方要自己算就得先把模型和运行时都捞出来 —— 那是把内部结构摊给外面。
|
||||
* 活动没声明阶段就回 `undefined`:界面照此不画进度条,不编一个出来。
|
||||
*/
|
||||
phaseOf(taskId: TaskId, terminal: boolean): Promise<PhaseProgress | undefined>;
|
||||
/** 发布 Enabled 集与红绿灯 —— 引擎推进的终点 */
|
||||
publish(taskId: TaskId): Promise<EnabledSet>;
|
||||
/** 跑一个回合:定位 → 择向 → 决断 → 行动 → 写数据 */
|
||||
|
||||
+3
-3
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* module: loop-engine —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||
*/
|
||||
export type { AgentAction, CaseModel, CaseRuntime, CaseState, DecisionResult, DecisionTrace, EnabledEntry, EnabledSet, Gap, ItemRuntime, ItemState, LoopEngineService, LoopModel, Navigation, PlanItem, PlanItemType, TerminalAuthority, TurnOutcome, } from './contract.ts';
|
||||
export { AutomaticOnWorldTouchingError, CmmnParseError, ForbiddenTerminalError, IllegalTransitionError, } from './contract.ts';
|
||||
export type { AgentAction, AgentTurn, Narration, CaseModel, CaseRuntime, CaseState, DecisionResult, DecisionTrace, EnabledEntry, EnabledSet, Gap, ItemRuntime, ItemState, LoopEngineService, LoopModel, Navigation, PlanItem, PlanItemType, TerminalAuthority, TurnOutcome, } from './contract.ts';
|
||||
export { AutomaticOnWorldTouchingError, CmmnParseError, ForbiddenTerminalError, IllegalTransitionError, TURN_ACTION_CAP, } from './contract.ts';
|
||||
export type { AgentPort, CardGateway, Clock, ListenerRegistrar, LoopStateStore, MemoryRecorder, Sfeel, TaskContext, TaskDataGateway, ToolGateway, } from './port.ts';
|
||||
export type { TurnRunnerDeps } from './internal/usecase/turn-runner.ts';
|
||||
export { createTurnRunner } from './internal/usecase/turn-runner.ts';
|
||||
export { parseCmmn, parseXml, PLAN_ITEM_TYPES } from './internal/domain/cmmn-parse.ts';
|
||||
export { assertItemTransition, autoCompleteReady, canTransitionCase, canTransitionItem, CASE_STATES, evaluateCriterion, initialRuntime, ITEM_STATES, mayCompleteCase, mayTerminateCase, navigate, occur, publishEnabled, reenter, transitionItem, } from './internal/domain/lifecycle.ts';
|
||||
export { assertItemTransition, achieveMilestones, autoCompleteReady, canTransitionCase, canTransitionItem, criterionKey, readingsOf, CASE_STATES, evaluateCriterion, initialRuntime, ITEM_STATES, mayCompleteCase, mayTerminateCase, navigate, occur, publishEnabled, reenter, transitionItem, } from './internal/domain/lifecycle.ts';
|
||||
export { deduceGaps, evaluateTable, evaluationOrder, isBindingConclusion } from './internal/domain/dmn.ts';
|
||||
export { createLoopStateStore } from './internal/adapter/loop-state-store.ts';
|
||||
export { loopStateTable } from './internal/adapter/loop-state-schema.ts';
|
||||
|
||||
+200
-7
@@ -4,7 +4,7 @@
|
||||
import type { LoopModel, StaffId, TaskId } from '@contracts';
|
||||
import type { CaseModel } from './internal/domain/cmmn-parse.ts';
|
||||
import type { CaseRuntime, EnabledSet } from './internal/domain/lifecycle.ts';
|
||||
import type { AgentAction } from './contract.ts';
|
||||
import type { AgentTurn } from './contract.ts';
|
||||
export type Clock = {
|
||||
now(): Date;
|
||||
};
|
||||
@@ -27,13 +27,53 @@ export type Sfeel = {
|
||||
/** 任务数据 —— app 注入 `ledger`(L1):引擎对**任务视图**求值,不依赖事件 */
|
||||
export type TaskDataGateway = {
|
||||
view(taskId: TaskId): Promise<Record<string, unknown>>;
|
||||
/**
|
||||
* 本活动**声明过**的台账对象(域.对象 → 列名与中文标)。
|
||||
*
|
||||
* 两用:喂给 Agent(不知道能写哪儿就只会瞎猜列名),以及校验 `write` 行动 ——
|
||||
* 没声明过的地址一律拒写。越域写与越权调工具同罪,都得在结构上拦住。
|
||||
*/
|
||||
writable(taskId: TaskId): Promise<Array<{
|
||||
domain: string;
|
||||
object: string;
|
||||
label: string;
|
||||
keys: string[];
|
||||
fields: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
}>;
|
||||
}>>;
|
||||
/**
|
||||
* 写草稿,并回报**这一笔到底有没有改变什么**。
|
||||
*
|
||||
* 值和上次一模一样的写是空转。不回报,Agent 就看不出自己在原地打转 ——
|
||||
* 实测:同一行 verdict 被反复重写十几拍,每拍烧一次模型,预算就这么淌完。
|
||||
*/
|
||||
draftWrite(input: {
|
||||
taskId: TaskId;
|
||||
domain: string;
|
||||
object: string;
|
||||
key: Record<string, string | number>;
|
||||
patch: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
}): Promise<{
|
||||
changed: boolean;
|
||||
}>;
|
||||
/**
|
||||
* **撤掉本单草稿层的一行** —— 写错了得有退路。
|
||||
*
|
||||
* 回 `false` = 草稿层里根本没有这一行(多半是主键写法对不上,
|
||||
* `DAG…` 与 `DAG…-R0` 是两行)。如实说比静默成功有用:静默成功会让模型
|
||||
* 以为撤掉了,下一拍判据照旧不过,它又去撤一次。
|
||||
*/
|
||||
draftDrop(input: {
|
||||
taskId: TaskId;
|
||||
domain: string;
|
||||
object: string;
|
||||
key: Record<string, string | number>;
|
||||
}): Promise<{
|
||||
dropped: boolean;
|
||||
}>;
|
||||
};
|
||||
/**
|
||||
* Agent 回路 —— app 注入(Mastra Agent 在 adapter,§6.1)。
|
||||
@@ -46,7 +86,32 @@ export type AgentPort = {
|
||||
narrative: string;
|
||||
enabled: EnabledSet;
|
||||
scope: Record<string, unknown>;
|
||||
}): Promise<AgentAction>;
|
||||
/**
|
||||
* 这一单能调什么 —— **声明 × 目录**编译出来的工具清单(`compileToolBus`)。
|
||||
*
|
||||
* 不给它这份清单,它就只知道说明书里提过的工具名,连入参长什么样都得猜 ——
|
||||
* 实测的后果是:该调工具的那一步改成了发一张卡问人(「请为以下三个PN查询历史价格」),
|
||||
* 明明系统里就有这件工具。工具是声明式的,清单得摆在它眼前。
|
||||
*/
|
||||
tools: Array<{
|
||||
id: string;
|
||||
description: string;
|
||||
input: Record<string, unknown>;
|
||||
effect: 'read' | 'write';
|
||||
}>;
|
||||
/** 本活动可写的台账地址与列名 —— 不给它字典,它只会照想象编列名 */
|
||||
writable: Array<{
|
||||
domain: string;
|
||||
object: string;
|
||||
label: string;
|
||||
keys: string[];
|
||||
fields: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
}>;
|
||||
}>;
|
||||
}): Promise<AgentTurn>;
|
||||
};
|
||||
/** 工具 / 人类工具 / 监听 —— 三型行动的落点 */
|
||||
export type ToolGateway = {
|
||||
@@ -61,6 +126,16 @@ export type ToolGateway = {
|
||||
error?: {
|
||||
message: string;
|
||||
};
|
||||
/**
|
||||
* 已转人代办,等人回填(§6.2)——「人是工具的一种实现」。
|
||||
*
|
||||
* 系统里没有这件工具时,不是失败:基座已开了一张执行卡并立了监听。
|
||||
* 这一拍该**挂起等人**,不该把计划项判 Failed —— 判 Failed 等于告诉 Agent
|
||||
* 「这条路走不通,换一条」,可这条路明明正在走,只是走的人换成了人。
|
||||
*/
|
||||
awaitingHuman?: {
|
||||
readonly workOrderId: string;
|
||||
};
|
||||
}>;
|
||||
isWriteEffect(tool: string): Promise<boolean>;
|
||||
registerIntent(input: {
|
||||
@@ -69,6 +144,13 @@ export type ToolGateway = {
|
||||
input: Record<string, unknown>;
|
||||
label: string;
|
||||
}): Promise<void>;
|
||||
/** 这一单能调什么(声明 × 目录)—— 单拍路径与开环路径吃同一份清单 */
|
||||
busOf(activityId: string): Promise<Array<{
|
||||
id: string;
|
||||
description: string;
|
||||
input: Record<string, unknown>;
|
||||
effect: 'read' | 'write';
|
||||
}>>;
|
||||
};
|
||||
/**
|
||||
* 在途验收(提交放行的持久痕):卡发出即记,裁决到达再记 verdict ——
|
||||
@@ -80,6 +162,39 @@ export type PendingAcceptance = {
|
||||
readonly selectedPorts?: Record<string, string>;
|
||||
};
|
||||
export type CardGateway = {
|
||||
/** 数据录入卡 —— 人确认那一下由基座直接写草稿层,不回到 Agent 手里 */
|
||||
entry(input: {
|
||||
taskId: TaskId;
|
||||
ownerId: StaffId;
|
||||
humanCallCap: number;
|
||||
ask: string;
|
||||
ledger: string;
|
||||
key: Readonly<Record<string, string>>;
|
||||
fields: ReadonlyArray<{
|
||||
key: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
required?: boolean;
|
||||
}>;
|
||||
/** 同一次确认还要落的另几张表 —— 建一位新客户 = 公司档 + 联系人档 */
|
||||
also?: ReadonlyArray<{
|
||||
title: string;
|
||||
ledger: string;
|
||||
key: Readonly<Record<string, string>>;
|
||||
fields: ReadonlyArray<{
|
||||
key: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
required?: boolean;
|
||||
}>;
|
||||
}>;
|
||||
basis?: ReadonlyArray<{
|
||||
k: string;
|
||||
v: string;
|
||||
}>;
|
||||
}): Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
issue(input: {
|
||||
taskId: TaskId;
|
||||
ownerId: StaffId;
|
||||
@@ -89,6 +204,56 @@ export type CardGateway = {
|
||||
}): Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
/**
|
||||
* 发**补全卡**(对话卡族)—— 缺口清单摆在对话区、带一张表单,人就地填齐。
|
||||
*
|
||||
* 与执行工单分开是因为它们是两件事:工单是「派一件活给你办」,
|
||||
* 派一张工单去问几个字段,收卡的人得进待处理、办完再回来,重得不成比例。
|
||||
* SOP 上画着「补全卡」的那一步(A1-S2),要的就是这一件。
|
||||
*/
|
||||
ask(input: {
|
||||
taskId: TaskId;
|
||||
ownerId: StaffId;
|
||||
humanCallCap: number;
|
||||
ask: string;
|
||||
fields: ReadonlyArray<{
|
||||
key: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
}>;
|
||||
basis?: ReadonlyArray<{
|
||||
k: string;
|
||||
v: string;
|
||||
}>;
|
||||
}): Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
/**
|
||||
* 发**决策卡**(对话卡族)—— 岔路口摆给人**点一个**,不是让人敲字。
|
||||
*
|
||||
* 与补全卡的分工:补全卡问事实(缺哪几个字段,你填);决策卡问处置(往哪走,你选)。
|
||||
* 两条路都合法、选哪条是业务判断的时候,该发的是这一件。
|
||||
*/
|
||||
decide(input: {
|
||||
taskId: TaskId;
|
||||
ownerId: StaffId;
|
||||
humanCallCap: number;
|
||||
ask: string;
|
||||
options: ReadonlyArray<{
|
||||
value: string;
|
||||
label: string;
|
||||
detail?: string;
|
||||
consequence?: string;
|
||||
recommended?: boolean;
|
||||
}>;
|
||||
basis?: ReadonlyArray<{
|
||||
k: string;
|
||||
v: string;
|
||||
}>;
|
||||
allowCustom?: boolean;
|
||||
}): Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
export type ListenerRegistrar = {
|
||||
register(input: {
|
||||
@@ -107,12 +272,21 @@ export type ListenerRegistrar = {
|
||||
* 零 LLM 即可成屏;模型只决定「下一步做什么」,不负责画面。
|
||||
*/
|
||||
export type ViewGateway = {
|
||||
/**
|
||||
* 推一帧画面。**回报推没推上** —— 从前是 `Promise<void>` 且模版查不到就静默 return:
|
||||
* 引擎每拍推一个硬编码的 `case-overview`,而 A1 声明的四张模版一个都不叫这个名字,
|
||||
* 于是**每一帧都被悄悄丢掉**,工作区从头到尾空白,日志里也没有一个字。
|
||||
* 静默失败比报错坏得多:它让「没画面」看起来像「活动还没产出」。
|
||||
*/
|
||||
push(input: {
|
||||
taskId: TaskId;
|
||||
activityId: string;
|
||||
template: string;
|
||||
scope: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
}): Promise<{
|
||||
pushed: boolean;
|
||||
templates: string[];
|
||||
}>;
|
||||
};
|
||||
/**
|
||||
* 技能 —— app 注入 `skill-engine`(L4)。
|
||||
@@ -134,7 +308,7 @@ export type SkillGateway = {
|
||||
export type MemoryRecorder = {
|
||||
append(input: {
|
||||
taskId: TaskId;
|
||||
kind: 'engine-transition' | 'evaluation' | 'thought' | 'action' | 'result' | 'observation';
|
||||
kind: 'engine-transition' | 'evaluation' | 'thought' | 'action' | 'result' | 'observation' | 'narration';
|
||||
title: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
@@ -143,10 +317,19 @@ export type MemoryRecorder = {
|
||||
layer: string;
|
||||
statement: string;
|
||||
}>>;
|
||||
/** 本任务自己的最近轨迹(窗口大小由活动声明的 memoryWindow 定)—— 喂回叙事面,Agent 每拍不失忆 */
|
||||
window(taskId: TaskId): Promise<Array<{
|
||||
/**
|
||||
* 本任务自己的最近轨迹 —— 喂回叙事面,Agent 每拍不失忆。
|
||||
*
|
||||
* 不给 `size` 就用活动声明的 `memoryWindow`(叙事面按它给;窗口越大越贵)。
|
||||
* **熔断检测要另开一个更大的窗**:它数的是「同一堵墙撞了几次」,
|
||||
* 而一次提交被拒会连带写下交代、评估、推画面好几条 —— 20 条的叙事窗里
|
||||
* 至多装得下三四次撞墙,于是撞十次也数不到 5(实测:同一条判据拒回 10 次没熔断,
|
||||
* 另一单拒回 33 次,40 万 token 烧到 exhausted 才停)。
|
||||
*/
|
||||
window(taskId: TaskId, size?: number): Promise<Array<{
|
||||
kind: string;
|
||||
title: string;
|
||||
detail?: Record<string, unknown> | undefined;
|
||||
}>>;
|
||||
};
|
||||
export type TaskContext = {
|
||||
@@ -155,4 +338,14 @@ export type TaskContext = {
|
||||
ownerId: StaffId;
|
||||
humanCallCap: number;
|
||||
}>;
|
||||
/**
|
||||
* 这一单的**机检判据名**(归一化后的 key 与 label 都在里头)。
|
||||
*
|
||||
* 给 `judge` 行动当门用:L1/L2 是拿 S-FEEL 对台账求值的,Agent 裁不动它 ——
|
||||
* 判据投影压根不读 Agent 对机检项的裁定。可从前引擎照收不误,还往轨迹里
|
||||
* 写一句「语义性前提评估:XXX → 满足」:模型于是以为这条已经过了,
|
||||
* 转头就 submit,被门以同一条判据拒回;再裁、再拒,连拒五拍熔断(实测 A2 死于此)。
|
||||
* 轨迹里那句「满足」是**假话** —— 界面不撒谎,对 Agent 也一样。
|
||||
*/
|
||||
machineCriteria(taskId: TaskId): Promise<ReadonlySet<string>>;
|
||||
};
|
||||
|
||||
@@ -28,6 +28,11 @@ export type SubmitForReleaseInput = {
|
||||
readonly criteria: readonly CriterionCheck[];
|
||||
/** 报告可修订:AI 拟值已填的表单字段 */
|
||||
readonly fields?: ReadonlyArray<Record<string, unknown>>;
|
||||
/**
|
||||
* 验收页的正文块 —— 与执行工单同一套件,可含**绑台账的可编辑表**。
|
||||
* 验收是最重的一道门:人得能在成果本身上改,不只是改几个整单级字段。
|
||||
*/
|
||||
readonly blocks?: ReadonlyArray<Record<string, unknown>>;
|
||||
};
|
||||
export type SubmitOutcome = {
|
||||
readonly report: PrecheckReport;
|
||||
|
||||
@@ -23,6 +23,8 @@ export type LedgerGateway = {
|
||||
releaseWrite(taskId: TaskId): Promise<{
|
||||
mergedRows: number;
|
||||
emittedEvents: number;
|
||||
/** 试跑单被挡在正式表外的主数据行数(非试跑恒为 0)—— 放行痕要如实说 */
|
||||
simulatedRows: number;
|
||||
/** 合并进正式版的行 —— 输出口发射的载荷取自这里 */
|
||||
merged: ReadonlyArray<{
|
||||
domain: string;
|
||||
@@ -79,12 +81,27 @@ export type CardGateway = {
|
||||
label: string;
|
||||
level: 'L1' | 'L2' | 'L3' | 'L4' | 'L5';
|
||||
pass: boolean;
|
||||
settled?: boolean | undefined;
|
||||
rationale?: string | undefined;
|
||||
}>;
|
||||
actions: ReadonlyArray<{
|
||||
label: string;
|
||||
effect: 'read' | 'write';
|
||||
detail: string;
|
||||
}>;
|
||||
/**
|
||||
* 可修订字段 —— 验收报告「AI 已填好、可编辑」改的就是它们(带 `ai` 拟值与 `ledger` 落回地址)。
|
||||
* **端口不声明它,值就在装配层被丢掉**:上游一路传下来,而 TypeScript 对条件展开
|
||||
* 不做多余属性检查,于是编译不报、运行时静默丢 —— 卡面 `fields` 恒空(踩过)。
|
||||
*/
|
||||
fields?: ReadonlyArray<Record<string, unknown>>;
|
||||
/**
|
||||
* 验收页的正文块 —— 绑台账的可编辑成果表走这里。
|
||||
* **端口不声明它,值就在装配层被丢掉**(与 `fields` 同一个坑,又踩了一次:
|
||||
* 契约声明了、submit 传了、release 转了,到装配层那个显式对象里被抹平,
|
||||
* 编译不报、运行时静默丢 —— 卡面 blocks 恒空)。
|
||||
*/
|
||||
blocks?: ReadonlyArray<Record<string, unknown>>;
|
||||
}): Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
|
||||
@@ -32,6 +32,8 @@ export type Task = {
|
||||
/** 起单来源:工作流节点;登记触发的直起单为 undefined */
|
||||
readonly workflowId?: string;
|
||||
readonly nodeId?: string;
|
||||
/** `testbed` = 设计台试跑 —— 认得出才拔得掉(仿真不该在别人工作台上过夜) */
|
||||
readonly origin?: string;
|
||||
readonly ownerId: StaffId;
|
||||
readonly title: string;
|
||||
readonly runtime: TaskRuntimeState;
|
||||
@@ -52,6 +54,7 @@ export type StartTaskInput = {
|
||||
readonly title: string;
|
||||
readonly workflowId?: string;
|
||||
readonly nodeId?: string;
|
||||
readonly origin?: string;
|
||||
/** 输入:data(按 schema 校验,缺项不得启动)+ message(委托消息,可为空) */
|
||||
readonly data: Record<string, unknown>;
|
||||
readonly message?: string;
|
||||
|
||||
+29
-2
@@ -26,10 +26,34 @@ export type ActivityLoader = {
|
||||
envelope: Record<string, unknown>;
|
||||
/** 输入 data 的 JSON Schema —— 缺项不得启动 */
|
||||
inputSchema: Record<string, unknown>;
|
||||
/**
|
||||
* 这一单是被一封信叫起来的 —— 活动清单声明的**来件字段映射**。
|
||||
* 声明了,起单那一刻就把来件归进沟通记录(工作台那一屏)。
|
||||
*/
|
||||
inboundComm?: {
|
||||
channel: string;
|
||||
partyField: string;
|
||||
subjectField?: string | undefined;
|
||||
bodyField?: string | undefined;
|
||||
attachmentsField?: string | undefined;
|
||||
};
|
||||
businessModel: unknown;
|
||||
stallAfter: string;
|
||||
}>;
|
||||
};
|
||||
/** 沟通记录 —— 一单往来的原件存档(工作台「沟通记录」那一屏读它) */
|
||||
export type CommRecorder = {
|
||||
record(input: {
|
||||
taskId: TaskId;
|
||||
direction: 'in' | 'out';
|
||||
channel: string;
|
||||
party: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
attachments: readonly unknown[];
|
||||
note: string;
|
||||
}): Promise<void>;
|
||||
};
|
||||
export type SchemaValidator = {
|
||||
validate(schema: Record<string, unknown>, value: unknown): {
|
||||
ok: boolean;
|
||||
@@ -76,8 +100,11 @@ export type LandingGateway = {
|
||||
export type MemoryRecorder = {
|
||||
append(input: {
|
||||
taskId: TaskId;
|
||||
/** control = 治理干预(叫停 / 恢复 / 改派):人对过程的裁断,归人裁决侧 */
|
||||
kind: 'engine-transition' | 'terminal' | 'observation' | 'control';
|
||||
/**
|
||||
* control = 治理干预(叫停 / 恢复 / 改派):人对过程的裁断,归人裁决侧。
|
||||
* trigger = 触发事件:这一单被什么叫起来的 —— 不是机器产出,也不是谁的动作。
|
||||
*/
|
||||
kind: 'engine-transition' | 'terminal' | 'observation' | 'control' | 'trigger';
|
||||
title: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
|
||||
+5
-3
@@ -3,15 +3,17 @@
|
||||
*/
|
||||
export type { ActionIntent, AuditFinding, CallDisposition, EffectLevel, EvolutionViolation, InvocationTrace, InvokerMode, Preauthorization, RegisterToolInput, RegistrationVerdict, ToolCall, ToolContract, ToolInvoker, ToolResult, ToolboxService, } from './contract.ts';
|
||||
export { INVOKER_MODES, RegistrationRejectedError, ToolDisabledError, ToolNotAllowedError, ToolNotFoundError, ToolReplayMissError, } from './contract.ts';
|
||||
export type { Clock, IdGenerator, SchemaValidator, SecretBox, ToolRegistry, ToolReplayStore, ToolRuntime, } from './port.ts';
|
||||
export type { Clock, IdGenerator, SchemaValidator, SecretBox, ToolHandoff, ToolHandoffStore, ToolRegistry, ToolReplayStore, ToolRuntime, } from './port.ts';
|
||||
export type { ToolInvokerDeps } from './internal/usecase/tool-invoker.ts';
|
||||
export { createToolInvoker, toolReplayKey } from './internal/usecase/tool-invoker.ts';
|
||||
export type { ToolboxServiceDeps } from './internal/usecase/toolbox-service.ts';
|
||||
export { createToolboxService } from './internal/usecase/toolbox-service.ts';
|
||||
export { checkEvolution, dispositionOf, EFFECT_LEVEL_LABEL, EFFECT_LEVELS } from './internal/domain/tool-contract.ts';
|
||||
export type { BusTool, CatalogEntry, ToolBusInput, ToolBusReport } from './internal/domain/tool-bus.ts';
|
||||
export { compileToolBus, HUMAN_TOOL, LLM_TOOLS } from './internal/domain/tool-bus.ts';
|
||||
export { compileToolBus, HUMAN_TOOL, LEDGER_TOOL, LLM_TOOLS } from './internal/domain/tool-bus.ts';
|
||||
export { auditPassed, staticAudit } from './internal/domain/registration-audit.ts';
|
||||
export type { HandoffField } from './internal/domain/handoff.ts';
|
||||
export { callKeyOf, coerceBySchema, handoffFields } from './internal/domain/handoff.ts';
|
||||
export type { ToolHandler } from './internal/adapter/toolbox-store.ts';
|
||||
export { createSchemaValidator, createToolRegistry, createToolReplayStore, createToolRuntime, isAllowed, } from './internal/adapter/toolbox-store.ts';
|
||||
export { createSchemaValidator, createToolRegistry, createToolReplayStore, createToolHandoffStore, createToolRuntime, isAllowed, } from './internal/adapter/toolbox-store.ts';
|
||||
export { replayToolTable, toolAllowlistTable, toolTable } from './internal/adapter/toolbox-schema.ts';
|
||||
|
||||
@@ -43,6 +43,26 @@ export type ToolReplayStore = {
|
||||
* 工具执行 —— 实现对系统:server 侧执行代码,运行时受限,
|
||||
* **只可访问注册申报的连接目标,无其他环境能力**(OS 7.3)。
|
||||
*/
|
||||
/**
|
||||
* 工具转人台(§6.2)—— 人是工具的一种实现:发出去的卡、答回来的出参都记在这。
|
||||
* 内容地址 `callKey` 保证同一次调用只问人一次。
|
||||
*/
|
||||
export type ToolHandoffStore = {
|
||||
find(taskId: string, callKey: string): Promise<ToolHandoff | undefined>;
|
||||
open(handoff: Omit<ToolHandoff, 'state' | 'output'>): Promise<void>;
|
||||
/** 人答完:值即出参 */
|
||||
answer(workOrderId: string, output: Record<string, unknown>): Promise<ToolHandoff | undefined>;
|
||||
};
|
||||
export type ToolHandoff = {
|
||||
readonly id: string;
|
||||
readonly taskId: string;
|
||||
readonly tool: string;
|
||||
readonly callKey: string;
|
||||
readonly input: Record<string, unknown>;
|
||||
readonly workOrderId: string;
|
||||
readonly state: 'pending' | 'answered';
|
||||
readonly output?: Record<string, unknown> | undefined;
|
||||
};
|
||||
export type ToolRuntime = {
|
||||
execute(input: {
|
||||
readonly contract: ToolContract;
|
||||
|
||||
+2
-2
@@ -10,6 +10,6 @@ export { defineActivity } from './define.ts';
|
||||
export type { ActivityMemoryLayer, ChatCardDraft, FormFieldDraft, LedgerKey, LedgerRow, ViewBlockLike, WorkOrderDraft, } from './types.ts';
|
||||
export type { HostOptions, HostRecording, MockHost } from './host.ts';
|
||||
export { createMockHost } from './host.ts';
|
||||
export type { Tone } from './view.ts';
|
||||
export { kvCard, noteCard, stagesCard, tableCard, viewShell } from './view.ts';
|
||||
export type { ColStyle, FactTone, Stamp, Tone } from './view.ts';
|
||||
export { actionList, bigCount, factCards, letter, meter, noteOnly, ruleTable, section, stagesOnly, statsBand, tableOnly, verdict, viewShell, } from './view.ts';
|
||||
export declare const SDK_VERSION = "0.1.0-m5";
|
||||
|
||||
+347
-4
@@ -116,9 +116,9 @@ export declare const listGrantsRoute: {
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"governance">;
|
||||
function: z.ZodEnum<{
|
||||
trigger: "trigger";
|
||||
data: "data";
|
||||
task: "task";
|
||||
trigger: "trigger";
|
||||
system: "system";
|
||||
tool: "tool";
|
||||
activity: "activity";
|
||||
@@ -155,9 +155,9 @@ export declare const setGrantsRoute: {
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"governance">;
|
||||
function: z.ZodEnum<{
|
||||
trigger: "trigger";
|
||||
data: "data";
|
||||
task: "task";
|
||||
trigger: "trigger";
|
||||
system: "system";
|
||||
tool: "tool";
|
||||
activity: "activity";
|
||||
@@ -187,9 +187,9 @@ export declare const setGrantsRoute: {
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"governance">;
|
||||
function: z.ZodEnum<{
|
||||
trigger: "trigger";
|
||||
data: "data";
|
||||
task: "task";
|
||||
trigger: "trigger";
|
||||
system: "system";
|
||||
tool: "tool";
|
||||
activity: "activity";
|
||||
@@ -508,6 +508,7 @@ export declare const govTaskLogRoute: {
|
||||
id: z.ZodString;
|
||||
at: z.ZodString;
|
||||
sideLabel: z.ZodString;
|
||||
action: z.ZodString;
|
||||
title: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
@@ -1835,12 +1836,20 @@ export declare const installActivityRoute: {
|
||||
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
messageAllowed: z.ZodDefault<z.ZodBoolean>;
|
||||
messageHint: z.ZodOptional<z.ZodString>;
|
||||
inboundComm: z.ZodOptional<z.ZodObject<{
|
||||
channel: z.ZodDefault<z.ZodString>;
|
||||
partyField: z.ZodString;
|
||||
subjectField: z.ZodOptional<z.ZodString>;
|
||||
bodyField: z.ZodOptional<z.ZodString>;
|
||||
attachmentsField: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
output: z.ZodObject<{
|
||||
mandatory: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
description: z.ZodOptional<z.ZodString>;
|
||||
when: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
selective: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
group: z.ZodString;
|
||||
@@ -1848,6 +1857,7 @@ export declare const installActivityRoute: {
|
||||
name: z.ZodString;
|
||||
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
description: z.ZodOptional<z.ZodString>;
|
||||
when: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>;
|
||||
@@ -1855,6 +1865,9 @@ export declare const installActivityRoute: {
|
||||
key: z.ZodString;
|
||||
label: z.ZodString;
|
||||
schema: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
||||
ledger: z.ZodOptional<z.ZodString>;
|
||||
ledgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
editableAtRelease: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>>;
|
||||
acceptanceCriteria: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
@@ -1869,6 +1882,11 @@ export declare const installActivityRoute: {
|
||||
predicate: z.ZodOptional<z.ZodString>;
|
||||
modelRef: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
revisableAtRelease: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
label: z.ZodString;
|
||||
ledger: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
prohibitions: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
statement: z.ZodString;
|
||||
@@ -1911,6 +1929,12 @@ export declare const installActivityRoute: {
|
||||
domain: z.ZodString;
|
||||
object: z.ZodString;
|
||||
label: z.ZodOptional<z.ZodString>;
|
||||
kind: z.ZodDefault<z.ZodEnum<{
|
||||
record: "record";
|
||||
master: "master";
|
||||
derived: "derived";
|
||||
}>>;
|
||||
caseKey: z.ZodOptional<z.ZodString>;
|
||||
fields: z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
type: z.ZodEnum<{
|
||||
@@ -1925,6 +1949,14 @@ export declare const installActivityRoute: {
|
||||
label: z.ZodOptional<z.ZodString>;
|
||||
primaryKey: z.ZodDefault<z.ZodBoolean>;
|
||||
masked: z.ZodDefault<z.ZodBoolean>;
|
||||
required: z.ZodOptional<z.ZodBoolean>;
|
||||
tally: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
expect: z.ZodOptional<z.ZodBoolean>;
|
||||
auto: z.ZodOptional<z.ZodBoolean>;
|
||||
requiredWhen: z.ZodOptional<z.ZodObject<{
|
||||
column: z.ZodString;
|
||||
value: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>>;
|
||||
listeners: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
@@ -2001,6 +2033,20 @@ export declare const installActivityRoute: {
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
steps: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
item: z.ZodString;
|
||||
intent: z.ZodString;
|
||||
guide: z.ZodString;
|
||||
writes: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
reads: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
view: z.ZodOptional<z.ZodString>;
|
||||
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
doneWhen: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
items: z.ZodArray<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>;
|
||||
skills: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "SkillId", "out">;
|
||||
@@ -2139,7 +2185,13 @@ export declare const listActivitiesRoute: {
|
||||
name: z.ZodString;
|
||||
type: z.ZodString;
|
||||
required: z.ZodBoolean;
|
||||
label: z.ZodOptional<z.ZodString>;
|
||||
format: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
samples: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
input: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
}, z.core.$strip>>>;
|
||||
outputPorts: z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
description: z.ZodOptional<z.ZodString>;
|
||||
@@ -2184,6 +2236,12 @@ export declare const listLedgerObjectsRoute: {
|
||||
domain: z.ZodString;
|
||||
object: z.ZodString;
|
||||
label: z.ZodOptional<z.ZodString>;
|
||||
kind: z.ZodOptional<z.ZodEnum<{
|
||||
record: "record";
|
||||
master: "master";
|
||||
derived: "derived";
|
||||
}>>;
|
||||
caseKey: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
@@ -2355,6 +2413,30 @@ export declare const testRunRoute: {
|
||||
}>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/**
|
||||
* 仿真测试台 · 试跑**一个活动** —— 与试跑流程包并列的第二种试法。
|
||||
*
|
||||
* 为什么要单有一条:开发一个活动时,关心的是「这个活动自己跑不跑得动」——
|
||||
* 案例模型推不推得开、判据取不取得齐、台账写不写得对、验收卡长什么样。
|
||||
* 走流程包试,前面几个节点的噪音全得先趟一遍,而且每改一次活动就要重跑整条流。
|
||||
*
|
||||
* 起出来的是**真任务**(不挂工作流),照旧落到被指派人的工作台、照旧过 Harness 与放行门 ——
|
||||
* 试跑不等于假跑:假跑试不出任何东西。区别只在它带 `origin='testbed'`,可以连根拔掉。
|
||||
*/
|
||||
export declare const testRunActivityRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/testbed/run-activity";
|
||||
readonly body: z.ZodObject<{
|
||||
activityId: z.ZodString;
|
||||
data: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
||||
message: z.ZodDefault<z.ZodString>;
|
||||
ownerId: z.ZodDefault<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
taskId: z.ZodString;
|
||||
title: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/**
|
||||
* 停止仿真 —— **把这一单连根拔掉**:工作流、它的活动任务、工单、实例、trace 全部销毁。
|
||||
* 仿真不是生产:试跑留下的东西不该在库里、在别人工作台上过夜。
|
||||
@@ -2362,8 +2444,10 @@ export declare const testRunRoute: {
|
||||
export declare const testStopRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/testbed/stop";
|
||||
/** 两种试法各有其拔法:流程包给 workflowId,单活动给 taskId;都不给 = 拔掉所有试跑残留 */
|
||||
readonly body: z.ZodObject<{
|
||||
workflowId: z.ZodString;
|
||||
workflowId: z.ZodDefault<z.ZodString>;
|
||||
taskId: z.ZodDefault<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
destroyed: z.ZodBoolean;
|
||||
@@ -2371,12 +2455,271 @@ export declare const testStopRoute: {
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/** 当前在跑的仿真(测试台一次只跑一条)—— 刷新页面也认得回来 */
|
||||
/**
|
||||
* 当前这一台仪器上的试跑单(测试台一次只跑一条)—— 刷新页面也认得回来。
|
||||
*
|
||||
* **返回还在跑的那一单,以及刚跑完的那一单** —— 两者要分得开:
|
||||
* `live` 为真才锁旋钮(在跑的不许换台);为假表示它已收尾,观测面照旧摆着结果给人看,
|
||||
* 但可以再起一单了。从前不分,已终结的单照样被当成「在跑」——
|
||||
* 跑完之后旋钮永久锁住,人只能刷新整页才起得了下一单(实测)。
|
||||
*/
|
||||
export declare const testCurrentRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/testbed/current";
|
||||
readonly output: z.ZodObject<{
|
||||
workflowId: z.ZodOptional<z.ZodString>;
|
||||
subject: z.ZodOptional<z.ZodString>;
|
||||
taskId: z.ZodOptional<z.ZodString>;
|
||||
activityId: z.ZodOptional<z.ZodString>;
|
||||
live: z.ZodDefault<z.ZodBoolean>;
|
||||
terminal: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/**
|
||||
* 单活动试跑的**观测面** —— 一屏看穿这一单:
|
||||
* 轨迹(每一拍干了什么)· 台账草稿(成果落到哪儿了)· 卡(问了谁什么)· 预算读数。
|
||||
*
|
||||
* 流程包那侧看的是 trace 树(节点 → 步骤);活动这侧要看的是**活动内部**,
|
||||
* 拿 trace 树看不出「第三拍为什么被拒」。
|
||||
*/
|
||||
export declare const testActivityStateRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/testbed/activity/:taskId";
|
||||
readonly params: z.ZodObject<{
|
||||
taskId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
taskId: z.ZodString;
|
||||
activityId: z.ZodString;
|
||||
title: z.ZodString;
|
||||
runtime: z.ZodEnum<{
|
||||
running: "running";
|
||||
"awaiting-human": "awaiting-human";
|
||||
"awaiting-event": "awaiting-event";
|
||||
suspended: "suspended";
|
||||
"landing-blocked": "landing-blocked";
|
||||
terminal: "terminal";
|
||||
}>;
|
||||
terminal: z.ZodOptional<z.ZodEnum<{
|
||||
released: "released";
|
||||
blocked: "blocked";
|
||||
stalled: "stalled";
|
||||
exhausted: "exhausted";
|
||||
terminated: "terminated";
|
||||
}>>;
|
||||
reason: z.ZodOptional<z.ZodString>;
|
||||
used: z.ZodObject<{
|
||||
steps: z.ZodNumber;
|
||||
tokens: z.ZodNumber;
|
||||
humanCalls: z.ZodNumber;
|
||||
}, z.core.$strip>;
|
||||
caps: z.ZodObject<{
|
||||
steps: z.ZodNumber;
|
||||
tokens: z.ZodNumber;
|
||||
humanCalls: z.ZodNumber;
|
||||
}, z.core.$strip>;
|
||||
trail: z.ZodArray<z.ZodObject<{
|
||||
at: z.ZodString;
|
||||
kind: z.ZodString;
|
||||
title: z.ZodString;
|
||||
detail: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
||||
}, z.core.$strip>>;
|
||||
drafts: z.ZodArray<z.ZodObject<{
|
||||
address: z.ZodString;
|
||||
key: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
patch: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
}, z.core.$strip>>;
|
||||
cards: z.ZodArray<z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
mode: z.ZodEnum<{
|
||||
execution: "execution";
|
||||
acceptance: "acceptance";
|
||||
}>;
|
||||
title: z.ZodString;
|
||||
done: z.ZodBoolean;
|
||||
result: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/**
|
||||
* 上传一个文件 —— 走 base64 而不是 multipart。
|
||||
*
|
||||
* 这一层所有路由都由同一套 zod 契约与 typed-fetch 收着,multipart 得在它旁边另开一条
|
||||
* 不受契约管的路;而这里要传的是 BOM、规格书这类几十 KB 的件,base64 那点膨胀
|
||||
* 换来「照旧一条契约管到底」是划算的。真大的件不该走界面上传,该走通道。
|
||||
*/
|
||||
export declare const uploadFileRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/upload";
|
||||
readonly body: z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
contentType: z.ZodDefault<z.ZodString>;
|
||||
dataBase64: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
ref: z.ZodString;
|
||||
name: z.ZodString;
|
||||
contentType: z.ZodString;
|
||||
size: z.ZodNumber;
|
||||
text: z.ZodOptional<z.ZodString>;
|
||||
note: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export type UploadedFile = z.infer<typeof uploadFileRoute.output>;
|
||||
/** 取回原件 —— 存的是真字节,人点得开才算真留住了 */
|
||||
export declare const downloadFileRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/upload/:id";
|
||||
readonly params: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
contentType: z.ZodString;
|
||||
dataBase64: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/**
|
||||
* 一次试跑的存档 —— **销毁那一刻拍的快照**。
|
||||
*
|
||||
* 「停止仿真 = 连根拔掉」这条不变(试跑起的是真任务,不该在别人工作台上过夜);
|
||||
* 拔掉之前把这一单的观测面存下来。开发活动是反复试的活,「上一版跑了几拍、
|
||||
* 卡在哪一步、台账写成什么样」正是要拿来比对的东西 —— 拔干净等于每改一次
|
||||
* 就把上一次的账一并烧了。
|
||||
*/
|
||||
export declare const testLogRowSchema: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
kind: z.ZodEnum<{
|
||||
activity: "activity";
|
||||
package: "package";
|
||||
}>;
|
||||
subject: z.ZodString;
|
||||
target: z.ZodString;
|
||||
taskId: z.ZodOptional<z.ZodString>;
|
||||
workflowId: z.ZodOptional<z.ZodString>;
|
||||
terminal: z.ZodOptional<z.ZodString>;
|
||||
reason: z.ZodOptional<z.ZodString>;
|
||||
used: z.ZodObject<{
|
||||
steps: z.ZodOptional<z.ZodNumber>;
|
||||
tokens: z.ZodOptional<z.ZodNumber>;
|
||||
humanCalls: z.ZodOptional<z.ZodNumber>;
|
||||
}, z.core.$strip>;
|
||||
caps: z.ZodObject<{
|
||||
steps: z.ZodOptional<z.ZodNumber>;
|
||||
tokens: z.ZodOptional<z.ZodNumber>;
|
||||
humanCalls: z.ZodOptional<z.ZodNumber>;
|
||||
}, z.core.$strip>;
|
||||
trailCount: z.ZodNumber;
|
||||
draftCount: z.ZodNumber;
|
||||
cardCount: z.ZodNumber;
|
||||
startedAt: z.ZodOptional<z.ZodString>;
|
||||
archivedAt: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
export type TestLogRow = z.infer<typeof testLogRowSchema>;
|
||||
export declare const listTestLogsRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/testbed/logs";
|
||||
readonly output: z.ZodObject<{
|
||||
runs: z.ZodArray<z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
kind: z.ZodEnum<{
|
||||
activity: "activity";
|
||||
package: "package";
|
||||
}>;
|
||||
subject: z.ZodString;
|
||||
target: z.ZodString;
|
||||
taskId: z.ZodOptional<z.ZodString>;
|
||||
workflowId: z.ZodOptional<z.ZodString>;
|
||||
terminal: z.ZodOptional<z.ZodString>;
|
||||
reason: z.ZodOptional<z.ZodString>;
|
||||
used: z.ZodObject<{
|
||||
steps: z.ZodOptional<z.ZodNumber>;
|
||||
tokens: z.ZodOptional<z.ZodNumber>;
|
||||
humanCalls: z.ZodOptional<z.ZodNumber>;
|
||||
}, z.core.$strip>;
|
||||
caps: z.ZodObject<{
|
||||
steps: z.ZodOptional<z.ZodNumber>;
|
||||
tokens: z.ZodOptional<z.ZodNumber>;
|
||||
humanCalls: z.ZodOptional<z.ZodNumber>;
|
||||
}, z.core.$strip>;
|
||||
trailCount: z.ZodNumber;
|
||||
draftCount: z.ZodNumber;
|
||||
cardCount: z.ZodNumber;
|
||||
startedAt: z.ZodOptional<z.ZodString>;
|
||||
archivedAt: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/** 一次试跑的正文 —— 逐拍轨迹 / 台账草稿 / 发过的卡 */
|
||||
export declare const testLogDetailRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/testbed/logs/:id";
|
||||
readonly params: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
kind: z.ZodEnum<{
|
||||
activity: "activity";
|
||||
package: "package";
|
||||
}>;
|
||||
subject: z.ZodString;
|
||||
target: z.ZodString;
|
||||
taskId: z.ZodOptional<z.ZodString>;
|
||||
workflowId: z.ZodOptional<z.ZodString>;
|
||||
terminal: z.ZodOptional<z.ZodString>;
|
||||
reason: z.ZodOptional<z.ZodString>;
|
||||
used: z.ZodObject<{
|
||||
steps: z.ZodOptional<z.ZodNumber>;
|
||||
tokens: z.ZodOptional<z.ZodNumber>;
|
||||
humanCalls: z.ZodOptional<z.ZodNumber>;
|
||||
}, z.core.$strip>;
|
||||
caps: z.ZodObject<{
|
||||
steps: z.ZodOptional<z.ZodNumber>;
|
||||
tokens: z.ZodOptional<z.ZodNumber>;
|
||||
humanCalls: z.ZodOptional<z.ZodNumber>;
|
||||
}, z.core.$strip>;
|
||||
trailCount: z.ZodNumber;
|
||||
draftCount: z.ZodNumber;
|
||||
cardCount: z.ZodNumber;
|
||||
startedAt: z.ZodOptional<z.ZodString>;
|
||||
archivedAt: z.ZodString;
|
||||
trail: z.ZodArray<z.ZodObject<{
|
||||
at: z.ZodString;
|
||||
kind: z.ZodString;
|
||||
title: z.ZodString;
|
||||
detail: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
||||
}, z.core.$strip>>;
|
||||
drafts: z.ZodArray<z.ZodObject<{
|
||||
address: z.ZodString;
|
||||
key: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
patch: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
}, z.core.$strip>>;
|
||||
cards: z.ZodArray<z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
mode: z.ZodString;
|
||||
title: z.ZodString;
|
||||
done: z.ZodBoolean;
|
||||
result: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/** 删一条 / 清空 —— 日志是给人比对用的,留多久由人说了算 */
|
||||
export declare const deleteTestLogRoute: {
|
||||
readonly method: "DELETE";
|
||||
readonly path: "/api/gov/testbed/logs/:id";
|
||||
readonly params: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
deleted: z.ZodNumber;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export declare const clearTestLogsRoute: {
|
||||
readonly method: "DELETE";
|
||||
readonly path: "/api/gov/testbed/logs";
|
||||
readonly output: z.ZodObject<{
|
||||
deleted: z.ZodNumber;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/** 全链 Trace:一条工作流的 span 树(节点 → 步骤 / 工具 / 模型 / 人卡) */
|
||||
|
||||
+764
-2
File diff suppressed because it is too large
Load Diff
+618
@@ -70,6 +70,7 @@ export declare const formFieldSchema: z.ZodObject<{
|
||||
group: z.ZodOptional<z.ZodString>;
|
||||
hint: z.ZodOptional<z.ZodString>;
|
||||
ai: z.ZodOptional<z.ZodString>;
|
||||
ledger: z.ZodOptional<z.ZodString>;
|
||||
unit: z.ZodOptional<z.ZodString>;
|
||||
required: z.ZodOptional<z.ZodBoolean>;
|
||||
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
|
||||
@@ -144,6 +145,8 @@ export declare const viewBlockSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
|
||||
editable: z.ZodOptional<z.ZodBoolean>;
|
||||
editableCols: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
|
||||
ledger: z.ZodOptional<z.ZodString>;
|
||||
fields: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
linkDonut: z.ZodOptional<z.ZodObject<{
|
||||
col: z.ZodNumber;
|
||||
title: z.ZodOptional<z.ZodString>;
|
||||
@@ -379,6 +382,8 @@ export declare const deliverableSchema: z.ZodObject<{
|
||||
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
|
||||
editable: z.ZodOptional<z.ZodBoolean>;
|
||||
editableCols: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
|
||||
ledger: z.ZodOptional<z.ZodString>;
|
||||
fields: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
linkDonut: z.ZodOptional<z.ZodObject<{
|
||||
col: z.ZodNumber;
|
||||
title: z.ZodOptional<z.ZodString>;
|
||||
@@ -641,6 +646,8 @@ export declare const executionWorkOrderSchema: z.ZodObject<{
|
||||
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
|
||||
editable: z.ZodOptional<z.ZodBoolean>;
|
||||
editableCols: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
|
||||
ledger: z.ZodOptional<z.ZodString>;
|
||||
fields: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
linkDonut: z.ZodOptional<z.ZodObject<{
|
||||
col: z.ZodNumber;
|
||||
title: z.ZodOptional<z.ZodString>;
|
||||
@@ -837,11 +844,17 @@ export declare const executionWorkOrderSchema: z.ZodObject<{
|
||||
group: z.ZodOptional<z.ZodString>;
|
||||
hint: z.ZodOptional<z.ZodString>;
|
||||
ai: z.ZodOptional<z.ZodString>;
|
||||
ledger: z.ZodOptional<z.ZodString>;
|
||||
unit: z.ZodOptional<z.ZodString>;
|
||||
required: z.ZodOptional<z.ZodBoolean>;
|
||||
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
|
||||
}, z.core.$strip>>;
|
||||
submitLabel: z.ZodOptional<z.ZodString>;
|
||||
gate: z.ZodOptional<z.ZodObject<{
|
||||
ready: z.ZodBoolean;
|
||||
why: z.ZodString;
|
||||
blanks: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
||||
at: z.ZodISODateTime;
|
||||
title: z.ZodString;
|
||||
@@ -861,6 +874,7 @@ export declare const executionWorkOrderSchema: z.ZodObject<{
|
||||
}>;
|
||||
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
}, z.core.$strip>>;
|
||||
finalHtml: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
export type ExecutionWorkOrder = z.infer<typeof executionWorkOrderSchema>;
|
||||
export declare const acceptanceCriterionCheckSchema: z.ZodObject<{
|
||||
@@ -873,6 +887,8 @@ export declare const acceptanceCriterionCheckSchema: z.ZodObject<{
|
||||
L5: "L5";
|
||||
}>;
|
||||
pass: z.ZodBoolean;
|
||||
settled: z.ZodOptional<z.ZodBoolean>;
|
||||
rationale: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
export type AcceptanceCriterionCheck = z.infer<typeof acceptanceCriterionCheckSchema>;
|
||||
/** 待落地动作意向:预检全过才上卡,卡面呈现的即已验证的动作清单(OS 7.3) */
|
||||
@@ -900,6 +916,8 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{
|
||||
L5: "L5";
|
||||
}>;
|
||||
pass: z.ZodBoolean;
|
||||
settled: z.ZodOptional<z.ZodBoolean>;
|
||||
rationale: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
actions: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
label: z.ZodString;
|
||||
@@ -932,10 +950,221 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{
|
||||
group: z.ZodOptional<z.ZodString>;
|
||||
hint: z.ZodOptional<z.ZodString>;
|
||||
ai: z.ZodOptional<z.ZodString>;
|
||||
ledger: z.ZodOptional<z.ZodString>;
|
||||
unit: z.ZodOptional<z.ZodString>;
|
||||
required: z.ZodOptional<z.ZodBoolean>;
|
||||
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
|
||||
}, z.core.$strip>>>;
|
||||
blocks: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
t: z.ZodLiteral<"callout">;
|
||||
tone: z.ZodEnum<{
|
||||
ok: "ok";
|
||||
warn: "warn";
|
||||
danger: "danger";
|
||||
brand: "brand";
|
||||
muted: "muted";
|
||||
}>;
|
||||
text: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
t: z.ZodLiteral<"kv">;
|
||||
title: z.ZodOptional<z.ZodString>;
|
||||
items: z.ZodArray<z.ZodObject<{
|
||||
k: z.ZodString;
|
||||
v: z.ZodString;
|
||||
fromItem: z.ZodOptional<z.ZodObject<{
|
||||
item: z.ZodString;
|
||||
field: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
t: z.ZodLiteral<"badges">;
|
||||
title: z.ZodOptional<z.ZodString>;
|
||||
items: z.ZodArray<z.ZodObject<{
|
||||
label: z.ZodString;
|
||||
on: z.ZodBoolean;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
t: z.ZodLiteral<"table">;
|
||||
title: z.ZodOptional<z.ZodString>;
|
||||
cols: z.ZodArray<z.ZodString>;
|
||||
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
|
||||
editable: z.ZodOptional<z.ZodBoolean>;
|
||||
editableCols: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
|
||||
ledger: z.ZodOptional<z.ZodString>;
|
||||
fields: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
linkDonut: z.ZodOptional<z.ZodObject<{
|
||||
col: z.ZodNumber;
|
||||
title: z.ZodOptional<z.ZodString>;
|
||||
unit: z.ZodOptional<z.ZodString>;
|
||||
map: z.ZodArray<z.ZodObject<{
|
||||
match: z.ZodString;
|
||||
label: z.ZodString;
|
||||
tone: z.ZodEnum<{
|
||||
ok: "ok";
|
||||
warn: "warn";
|
||||
danger: "danger";
|
||||
brand: "brand";
|
||||
muted: "muted";
|
||||
}>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
t: z.ZodLiteral<"bars">;
|
||||
title: z.ZodOptional<z.ZodString>;
|
||||
max: z.ZodOptional<z.ZodNumber>;
|
||||
redline: z.ZodOptional<z.ZodNumber>;
|
||||
items: z.ZodArray<z.ZodObject<{
|
||||
label: z.ZodString;
|
||||
value: z.ZodNumber;
|
||||
text: z.ZodOptional<z.ZodString>;
|
||||
tone: z.ZodOptional<z.ZodEnum<{
|
||||
ok: "ok";
|
||||
warn: "warn";
|
||||
danger: "danger";
|
||||
brand: "brand";
|
||||
muted: "muted";
|
||||
}>>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
t: z.ZodLiteral<"donut">;
|
||||
title: z.ZodOptional<z.ZodString>;
|
||||
unit: z.ZodOptional<z.ZodString>;
|
||||
items: z.ZodArray<z.ZodObject<{
|
||||
label: z.ZodString;
|
||||
value: z.ZodNumber;
|
||||
tone: z.ZodEnum<{
|
||||
ok: "ok";
|
||||
warn: "warn";
|
||||
danger: "danger";
|
||||
brand: "brand";
|
||||
muted: "muted";
|
||||
}>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
t: z.ZodLiteral<"mail">;
|
||||
dir: z.ZodEnum<{
|
||||
out: "out";
|
||||
in: "in";
|
||||
}>;
|
||||
statusLabel: z.ZodOptional<z.ZodString>;
|
||||
editable: z.ZodOptional<z.ZodBoolean>;
|
||||
from: z.ZodString;
|
||||
fromAddr: z.ZodOptional<z.ZodString>;
|
||||
to: z.ZodString;
|
||||
cc: z.ZodOptional<z.ZodString>;
|
||||
subject: z.ZodString;
|
||||
time: z.ZodString;
|
||||
body: z.ZodString;
|
||||
attachments: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
meta: z.ZodOptional<z.ZodString>;
|
||||
file: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
kind: z.ZodLiteral<"html">;
|
||||
name: z.ZodString;
|
||||
html: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"markdown">;
|
||||
name: z.ZodString;
|
||||
md: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"excel">;
|
||||
name: z.ZodString;
|
||||
sheet: z.ZodString;
|
||||
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"word">;
|
||||
name: z.ZodString;
|
||||
blocks: z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
|
||||
t: z.ZodEnum<{
|
||||
h1: "h1";
|
||||
h2: "h2";
|
||||
p: "p";
|
||||
}>;
|
||||
text: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
t: z.ZodLiteral<"ul">;
|
||||
items: z.ZodArray<z.ZodString>;
|
||||
}, z.core.$strip>]>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"pdf">;
|
||||
name: z.ZodString;
|
||||
src: z.ZodString;
|
||||
}, z.core.$strip>], "kind">>;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
t: z.ZodLiteral<"cards">;
|
||||
title: z.ZodOptional<z.ZodString>;
|
||||
items: z.ZodArray<z.ZodObject<{
|
||||
tag: z.ZodString;
|
||||
icon: z.ZodOptional<z.ZodString>;
|
||||
tone: z.ZodOptional<z.ZodEnum<{
|
||||
ok: "ok";
|
||||
warn: "warn";
|
||||
danger: "danger";
|
||||
brand: "brand";
|
||||
muted: "muted";
|
||||
}>>;
|
||||
title: z.ZodString;
|
||||
code: z.ZodOptional<z.ZodString>;
|
||||
swap: z.ZodOptional<z.ZodObject<{
|
||||
from: z.ZodString;
|
||||
to: z.ZodString;
|
||||
hint: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
big: z.ZodString;
|
||||
bigLabel: z.ZodOptional<z.ZodString>;
|
||||
meta: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
k: z.ZodString;
|
||||
v: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
note: z.ZodOptional<z.ZodString>;
|
||||
from: z.ZodOptional<z.ZodString>;
|
||||
time: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
t: z.ZodLiteral<"waiting">;
|
||||
label: z.ZodString;
|
||||
hint: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
t: z.ZodLiteral<"files">;
|
||||
title: z.ZodOptional<z.ZodString>;
|
||||
items: z.ZodArray<z.ZodObject<{
|
||||
label: z.ZodString;
|
||||
desc: z.ZodString;
|
||||
file: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
kind: z.ZodLiteral<"html">;
|
||||
name: z.ZodString;
|
||||
html: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"markdown">;
|
||||
name: z.ZodString;
|
||||
md: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"excel">;
|
||||
name: z.ZodString;
|
||||
sheet: z.ZodString;
|
||||
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"word">;
|
||||
name: z.ZodString;
|
||||
blocks: z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
|
||||
t: z.ZodEnum<{
|
||||
h1: "h1";
|
||||
h2: "h2";
|
||||
p: "p";
|
||||
}>;
|
||||
text: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
t: z.ZodLiteral<"ul">;
|
||||
items: z.ZodArray<z.ZodString>;
|
||||
}, z.core.$strip>]>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"pdf">;
|
||||
name: z.ZodString;
|
||||
src: z.ZodString;
|
||||
}, z.core.$strip>], "kind">;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>], "t">>>;
|
||||
portChoice: z.ZodOptional<z.ZodObject<{
|
||||
group: z.ZodString;
|
||||
ports: z.ZodArray<z.ZodObject<{
|
||||
@@ -943,6 +1172,22 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{
|
||||
label: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>;
|
||||
landing: z.ZodOptional<z.ZodObject<{
|
||||
rows: z.ZodNumber;
|
||||
simulated: z.ZodDefault<z.ZodNumber>;
|
||||
tables: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
label: z.ZodString;
|
||||
rows: z.ZodNumber;
|
||||
}, z.core.$strip>>>;
|
||||
ports: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
carried: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>;
|
||||
routedPort: z.ZodOptional<z.ZodObject<{
|
||||
group: z.ZodString;
|
||||
name: z.ZodString;
|
||||
label: z.ZodOptional<z.ZodString>;
|
||||
because: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
passLabel: z.ZodOptional<z.ZodString>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
||||
at: z.ZodISODateTime;
|
||||
@@ -963,6 +1208,7 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{
|
||||
}>;
|
||||
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
}, z.core.$strip>>;
|
||||
finalHtml: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
export type AcceptanceWorkOrder = z.infer<typeof acceptanceWorkOrderSchema>;
|
||||
export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
@@ -1009,6 +1255,8 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
|
||||
editable: z.ZodOptional<z.ZodBoolean>;
|
||||
editableCols: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
|
||||
ledger: z.ZodOptional<z.ZodString>;
|
||||
fields: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
linkDonut: z.ZodOptional<z.ZodObject<{
|
||||
col: z.ZodNumber;
|
||||
title: z.ZodOptional<z.ZodString>;
|
||||
@@ -1205,11 +1453,17 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
group: z.ZodOptional<z.ZodString>;
|
||||
hint: z.ZodOptional<z.ZodString>;
|
||||
ai: z.ZodOptional<z.ZodString>;
|
||||
ledger: z.ZodOptional<z.ZodString>;
|
||||
unit: z.ZodOptional<z.ZodString>;
|
||||
required: z.ZodOptional<z.ZodBoolean>;
|
||||
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
|
||||
}, z.core.$strip>>;
|
||||
submitLabel: z.ZodOptional<z.ZodString>;
|
||||
gate: z.ZodOptional<z.ZodObject<{
|
||||
ready: z.ZodBoolean;
|
||||
why: z.ZodString;
|
||||
blanks: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
||||
at: z.ZodISODateTime;
|
||||
title: z.ZodString;
|
||||
@@ -1229,6 +1483,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
}>;
|
||||
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
}, z.core.$strip>>;
|
||||
finalHtml: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
mode: z.ZodLiteral<"acceptance">;
|
||||
deliverable: z.core.$ZodBranded<z.ZodString, "DeliverableId", "out">;
|
||||
@@ -1242,6 +1497,8 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
L5: "L5";
|
||||
}>;
|
||||
pass: z.ZodBoolean;
|
||||
settled: z.ZodOptional<z.ZodBoolean>;
|
||||
rationale: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
actions: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
label: z.ZodString;
|
||||
@@ -1274,10 +1531,221 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
group: z.ZodOptional<z.ZodString>;
|
||||
hint: z.ZodOptional<z.ZodString>;
|
||||
ai: z.ZodOptional<z.ZodString>;
|
||||
ledger: z.ZodOptional<z.ZodString>;
|
||||
unit: z.ZodOptional<z.ZodString>;
|
||||
required: z.ZodOptional<z.ZodBoolean>;
|
||||
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
|
||||
}, z.core.$strip>>>;
|
||||
blocks: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
t: z.ZodLiteral<"callout">;
|
||||
tone: z.ZodEnum<{
|
||||
ok: "ok";
|
||||
warn: "warn";
|
||||
danger: "danger";
|
||||
brand: "brand";
|
||||
muted: "muted";
|
||||
}>;
|
||||
text: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
t: z.ZodLiteral<"kv">;
|
||||
title: z.ZodOptional<z.ZodString>;
|
||||
items: z.ZodArray<z.ZodObject<{
|
||||
k: z.ZodString;
|
||||
v: z.ZodString;
|
||||
fromItem: z.ZodOptional<z.ZodObject<{
|
||||
item: z.ZodString;
|
||||
field: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
t: z.ZodLiteral<"badges">;
|
||||
title: z.ZodOptional<z.ZodString>;
|
||||
items: z.ZodArray<z.ZodObject<{
|
||||
label: z.ZodString;
|
||||
on: z.ZodBoolean;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
t: z.ZodLiteral<"table">;
|
||||
title: z.ZodOptional<z.ZodString>;
|
||||
cols: z.ZodArray<z.ZodString>;
|
||||
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
|
||||
editable: z.ZodOptional<z.ZodBoolean>;
|
||||
editableCols: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
|
||||
ledger: z.ZodOptional<z.ZodString>;
|
||||
fields: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
linkDonut: z.ZodOptional<z.ZodObject<{
|
||||
col: z.ZodNumber;
|
||||
title: z.ZodOptional<z.ZodString>;
|
||||
unit: z.ZodOptional<z.ZodString>;
|
||||
map: z.ZodArray<z.ZodObject<{
|
||||
match: z.ZodString;
|
||||
label: z.ZodString;
|
||||
tone: z.ZodEnum<{
|
||||
ok: "ok";
|
||||
warn: "warn";
|
||||
danger: "danger";
|
||||
brand: "brand";
|
||||
muted: "muted";
|
||||
}>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
t: z.ZodLiteral<"bars">;
|
||||
title: z.ZodOptional<z.ZodString>;
|
||||
max: z.ZodOptional<z.ZodNumber>;
|
||||
redline: z.ZodOptional<z.ZodNumber>;
|
||||
items: z.ZodArray<z.ZodObject<{
|
||||
label: z.ZodString;
|
||||
value: z.ZodNumber;
|
||||
text: z.ZodOptional<z.ZodString>;
|
||||
tone: z.ZodOptional<z.ZodEnum<{
|
||||
ok: "ok";
|
||||
warn: "warn";
|
||||
danger: "danger";
|
||||
brand: "brand";
|
||||
muted: "muted";
|
||||
}>>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
t: z.ZodLiteral<"donut">;
|
||||
title: z.ZodOptional<z.ZodString>;
|
||||
unit: z.ZodOptional<z.ZodString>;
|
||||
items: z.ZodArray<z.ZodObject<{
|
||||
label: z.ZodString;
|
||||
value: z.ZodNumber;
|
||||
tone: z.ZodEnum<{
|
||||
ok: "ok";
|
||||
warn: "warn";
|
||||
danger: "danger";
|
||||
brand: "brand";
|
||||
muted: "muted";
|
||||
}>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
t: z.ZodLiteral<"mail">;
|
||||
dir: z.ZodEnum<{
|
||||
out: "out";
|
||||
in: "in";
|
||||
}>;
|
||||
statusLabel: z.ZodOptional<z.ZodString>;
|
||||
editable: z.ZodOptional<z.ZodBoolean>;
|
||||
from: z.ZodString;
|
||||
fromAddr: z.ZodOptional<z.ZodString>;
|
||||
to: z.ZodString;
|
||||
cc: z.ZodOptional<z.ZodString>;
|
||||
subject: z.ZodString;
|
||||
time: z.ZodString;
|
||||
body: z.ZodString;
|
||||
attachments: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
meta: z.ZodOptional<z.ZodString>;
|
||||
file: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
kind: z.ZodLiteral<"html">;
|
||||
name: z.ZodString;
|
||||
html: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"markdown">;
|
||||
name: z.ZodString;
|
||||
md: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"excel">;
|
||||
name: z.ZodString;
|
||||
sheet: z.ZodString;
|
||||
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"word">;
|
||||
name: z.ZodString;
|
||||
blocks: z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
|
||||
t: z.ZodEnum<{
|
||||
h1: "h1";
|
||||
h2: "h2";
|
||||
p: "p";
|
||||
}>;
|
||||
text: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
t: z.ZodLiteral<"ul">;
|
||||
items: z.ZodArray<z.ZodString>;
|
||||
}, z.core.$strip>]>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"pdf">;
|
||||
name: z.ZodString;
|
||||
src: z.ZodString;
|
||||
}, z.core.$strip>], "kind">>;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
t: z.ZodLiteral<"cards">;
|
||||
title: z.ZodOptional<z.ZodString>;
|
||||
items: z.ZodArray<z.ZodObject<{
|
||||
tag: z.ZodString;
|
||||
icon: z.ZodOptional<z.ZodString>;
|
||||
tone: z.ZodOptional<z.ZodEnum<{
|
||||
ok: "ok";
|
||||
warn: "warn";
|
||||
danger: "danger";
|
||||
brand: "brand";
|
||||
muted: "muted";
|
||||
}>>;
|
||||
title: z.ZodString;
|
||||
code: z.ZodOptional<z.ZodString>;
|
||||
swap: z.ZodOptional<z.ZodObject<{
|
||||
from: z.ZodString;
|
||||
to: z.ZodString;
|
||||
hint: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
big: z.ZodString;
|
||||
bigLabel: z.ZodOptional<z.ZodString>;
|
||||
meta: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
k: z.ZodString;
|
||||
v: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
note: z.ZodOptional<z.ZodString>;
|
||||
from: z.ZodOptional<z.ZodString>;
|
||||
time: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
t: z.ZodLiteral<"waiting">;
|
||||
label: z.ZodString;
|
||||
hint: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
t: z.ZodLiteral<"files">;
|
||||
title: z.ZodOptional<z.ZodString>;
|
||||
items: z.ZodArray<z.ZodObject<{
|
||||
label: z.ZodString;
|
||||
desc: z.ZodString;
|
||||
file: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
kind: z.ZodLiteral<"html">;
|
||||
name: z.ZodString;
|
||||
html: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"markdown">;
|
||||
name: z.ZodString;
|
||||
md: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"excel">;
|
||||
name: z.ZodString;
|
||||
sheet: z.ZodString;
|
||||
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"word">;
|
||||
name: z.ZodString;
|
||||
blocks: z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
|
||||
t: z.ZodEnum<{
|
||||
h1: "h1";
|
||||
h2: "h2";
|
||||
p: "p";
|
||||
}>;
|
||||
text: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
t: z.ZodLiteral<"ul">;
|
||||
items: z.ZodArray<z.ZodString>;
|
||||
}, z.core.$strip>]>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"pdf">;
|
||||
name: z.ZodString;
|
||||
src: z.ZodString;
|
||||
}, z.core.$strip>], "kind">;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>], "t">>>;
|
||||
portChoice: z.ZodOptional<z.ZodObject<{
|
||||
group: z.ZodString;
|
||||
ports: z.ZodArray<z.ZodObject<{
|
||||
@@ -1285,6 +1753,22 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
label: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>;
|
||||
landing: z.ZodOptional<z.ZodObject<{
|
||||
rows: z.ZodNumber;
|
||||
simulated: z.ZodDefault<z.ZodNumber>;
|
||||
tables: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
label: z.ZodString;
|
||||
rows: z.ZodNumber;
|
||||
}, z.core.$strip>>>;
|
||||
ports: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
carried: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>;
|
||||
routedPort: z.ZodOptional<z.ZodObject<{
|
||||
group: z.ZodString;
|
||||
name: z.ZodString;
|
||||
label: z.ZodOptional<z.ZodString>;
|
||||
because: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
passLabel: z.ZodOptional<z.ZodString>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
||||
at: z.ZodISODateTime;
|
||||
@@ -1305,6 +1789,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
}>;
|
||||
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
}, z.core.$strip>>;
|
||||
finalHtml: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>], "mode">;
|
||||
export type WorkOrder = z.infer<typeof workOrderSchema>;
|
||||
/** AI 发言的类型标记 —— 对话区可分辨:结论 / 指导 / 判据 */
|
||||
@@ -1355,6 +1840,7 @@ export declare const elicitationCardSchema: z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
label: z.ZodString;
|
||||
placeholder: z.ZodOptional<z.ZodString>;
|
||||
value: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
done: z.ZodOptional<z.ZodObject<{
|
||||
at: z.ZodISODateTime;
|
||||
@@ -1366,6 +1852,49 @@ export declare const elicitationCardSchema: z.ZodObject<{
|
||||
at: z.ZodISODateTime;
|
||||
}, z.core.$strip>;
|
||||
export type ElicitationCard = z.infer<typeof elicitationCardSchema>;
|
||||
export declare const dataEntryCardSchema: z.ZodObject<{
|
||||
k: z.ZodLiteral<"entry">;
|
||||
ask: z.ZodString;
|
||||
basis: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
k: z.ZodString;
|
||||
v: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
ledger: z.ZodString;
|
||||
ledgerLabel: z.ZodOptional<z.ZodString>;
|
||||
key: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||
keyLabels: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
fields: z.ZodArray<z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
label: z.ZodString;
|
||||
value: z.ZodOptional<z.ZodString>;
|
||||
required: z.ZodDefault<z.ZodBoolean>;
|
||||
placeholder: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
autoNote: z.ZodOptional<z.ZodString>;
|
||||
also: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
title: z.ZodString;
|
||||
ledger: z.ZodString;
|
||||
ledgerLabel: z.ZodOptional<z.ZodString>;
|
||||
key: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||
keyLabels: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
fields: z.ZodArray<z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
label: z.ZodString;
|
||||
value: z.ZodOptional<z.ZodString>;
|
||||
required: z.ZodDefault<z.ZodBoolean>;
|
||||
placeholder: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
autoNote: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
done: z.ZodOptional<z.ZodObject<{
|
||||
at: z.ZodISODateTime;
|
||||
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
values: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||
at: z.ZodISODateTime;
|
||||
}, z.core.$strip>;
|
||||
export type DataEntryCard = z.infer<typeof dataEntryCardSchema>;
|
||||
/** 评价卡:任务评价(评分 1–5 + 改进建议) */
|
||||
export declare const ratingCardSchema: z.ZodObject<{
|
||||
k: z.ZodLiteral<"rating">;
|
||||
@@ -1408,6 +1937,47 @@ export declare const chatCardSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
}, z.core.$strip>>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||
at: z.ZodISODateTime;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
k: z.ZodLiteral<"entry">;
|
||||
ask: z.ZodString;
|
||||
basis: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
k: z.ZodString;
|
||||
v: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
ledger: z.ZodString;
|
||||
ledgerLabel: z.ZodOptional<z.ZodString>;
|
||||
key: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||
keyLabels: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
fields: z.ZodArray<z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
label: z.ZodString;
|
||||
value: z.ZodOptional<z.ZodString>;
|
||||
required: z.ZodDefault<z.ZodBoolean>;
|
||||
placeholder: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
autoNote: z.ZodOptional<z.ZodString>;
|
||||
also: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
title: z.ZodString;
|
||||
ledger: z.ZodString;
|
||||
ledgerLabel: z.ZodOptional<z.ZodString>;
|
||||
key: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||
keyLabels: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
fields: z.ZodArray<z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
label: z.ZodString;
|
||||
value: z.ZodOptional<z.ZodString>;
|
||||
required: z.ZodDefault<z.ZodBoolean>;
|
||||
placeholder: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
autoNote: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
done: z.ZodOptional<z.ZodObject<{
|
||||
at: z.ZodISODateTime;
|
||||
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
values: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||
at: z.ZodISODateTime;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
k: z.ZodLiteral<"elicitation">;
|
||||
ask: z.ZodString;
|
||||
@@ -1419,6 +1989,7 @@ export declare const chatCardSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
label: z.ZodString;
|
||||
placeholder: z.ZodOptional<z.ZodString>;
|
||||
value: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
done: z.ZodOptional<z.ZodObject<{
|
||||
at: z.ZodISODateTime;
|
||||
@@ -1471,6 +2042,7 @@ export declare const chatMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
control: "control";
|
||||
}>;
|
||||
text: z.ZodString;
|
||||
openComms: z.ZodOptional<z.ZodBoolean>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||
at: z.ZodISODateTime;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
@@ -1497,6 +2069,47 @@ export declare const chatMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
}, z.core.$strip>>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||
at: z.ZodISODateTime;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
k: z.ZodLiteral<"entry">;
|
||||
ask: z.ZodString;
|
||||
basis: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
k: z.ZodString;
|
||||
v: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
ledger: z.ZodString;
|
||||
ledgerLabel: z.ZodOptional<z.ZodString>;
|
||||
key: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||
keyLabels: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
fields: z.ZodArray<z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
label: z.ZodString;
|
||||
value: z.ZodOptional<z.ZodString>;
|
||||
required: z.ZodDefault<z.ZodBoolean>;
|
||||
placeholder: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
autoNote: z.ZodOptional<z.ZodString>;
|
||||
also: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
title: z.ZodString;
|
||||
ledger: z.ZodString;
|
||||
ledgerLabel: z.ZodOptional<z.ZodString>;
|
||||
key: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||
keyLabels: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
fields: z.ZodArray<z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
label: z.ZodString;
|
||||
value: z.ZodOptional<z.ZodString>;
|
||||
required: z.ZodDefault<z.ZodBoolean>;
|
||||
placeholder: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
autoNote: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
done: z.ZodOptional<z.ZodObject<{
|
||||
at: z.ZodISODateTime;
|
||||
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
values: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||
at: z.ZodISODateTime;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
k: z.ZodLiteral<"elicitation">;
|
||||
ask: z.ZodString;
|
||||
@@ -1508,6 +2121,7 @@ export declare const chatMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
label: z.ZodString;
|
||||
placeholder: z.ZodOptional<z.ZodString>;
|
||||
value: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
done: z.ZodOptional<z.ZodObject<{
|
||||
at: z.ZodISODateTime;
|
||||
@@ -1560,6 +2174,10 @@ export declare const chatCardReplySchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
cardId: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||
values: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
reason: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
k: z.ZodLiteral<"entry">;
|
||||
cardId: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||
values: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
k: z.ZodLiteral<"rating">;
|
||||
cardId: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||
|
||||
@@ -16,12 +16,20 @@ export declare const contractInputSchema: z.ZodObject<{
|
||||
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
messageAllowed: z.ZodDefault<z.ZodBoolean>;
|
||||
messageHint: z.ZodOptional<z.ZodString>;
|
||||
inboundComm: z.ZodOptional<z.ZodObject<{
|
||||
channel: z.ZodDefault<z.ZodString>;
|
||||
partyField: z.ZodString;
|
||||
subjectField: z.ZodOptional<z.ZodString>;
|
||||
bodyField: z.ZodOptional<z.ZodString>;
|
||||
attachmentsField: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
export type ContractInput = z.infer<typeof contractInputSchema>;
|
||||
export declare const outputPortSchema: z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
description: z.ZodOptional<z.ZodString>;
|
||||
when: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
export type OutputPort = z.infer<typeof outputPortSchema>;
|
||||
export declare const outputPortsSchema: z.ZodObject<{
|
||||
@@ -29,6 +37,7 @@ export declare const outputPortsSchema: z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
description: z.ZodOptional<z.ZodString>;
|
||||
when: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
selective: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
group: z.ZodString;
|
||||
@@ -36,6 +45,7 @@ export declare const outputPortsSchema: z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
description: z.ZodOptional<z.ZodString>;
|
||||
when: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>;
|
||||
@@ -44,6 +54,9 @@ export declare const requiredDeliverableSchema: z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
label: z.ZodString;
|
||||
schema: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
||||
ledger: z.ZodOptional<z.ZodString>;
|
||||
ledgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
editableAtRelease: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>;
|
||||
export type RequiredDeliverable = z.infer<typeof requiredDeliverableSchema>;
|
||||
export declare const acceptanceCriterionSchema: z.ZodObject<{
|
||||
@@ -109,12 +122,20 @@ export declare const sevenPartContractSchema: z.ZodObject<{
|
||||
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
messageAllowed: z.ZodDefault<z.ZodBoolean>;
|
||||
messageHint: z.ZodOptional<z.ZodString>;
|
||||
inboundComm: z.ZodOptional<z.ZodObject<{
|
||||
channel: z.ZodDefault<z.ZodString>;
|
||||
partyField: z.ZodString;
|
||||
subjectField: z.ZodOptional<z.ZodString>;
|
||||
bodyField: z.ZodOptional<z.ZodString>;
|
||||
attachmentsField: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
output: z.ZodObject<{
|
||||
mandatory: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
description: z.ZodOptional<z.ZodString>;
|
||||
when: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
selective: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
group: z.ZodString;
|
||||
@@ -122,6 +143,7 @@ export declare const sevenPartContractSchema: z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
description: z.ZodOptional<z.ZodString>;
|
||||
when: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>;
|
||||
@@ -129,6 +151,9 @@ export declare const sevenPartContractSchema: z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
label: z.ZodString;
|
||||
schema: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
||||
ledger: z.ZodOptional<z.ZodString>;
|
||||
ledgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
editableAtRelease: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>>;
|
||||
acceptanceCriteria: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
@@ -143,6 +168,11 @@ export declare const sevenPartContractSchema: z.ZodObject<{
|
||||
predicate: z.ZodOptional<z.ZodString>;
|
||||
modelRef: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
revisableAtRelease: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
label: z.ZodString;
|
||||
ledger: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
prohibitions: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
statement: z.ZodString;
|
||||
|
||||
+51
@@ -49,12 +49,55 @@ export declare const fieldDeclarationSchema: z.ZodObject<{
|
||||
label: z.ZodOptional<z.ZodString>;
|
||||
primaryKey: z.ZodDefault<z.ZodBoolean>;
|
||||
masked: z.ZodDefault<z.ZodBoolean>;
|
||||
required: z.ZodOptional<z.ZodBoolean>;
|
||||
tally: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
expect: z.ZodOptional<z.ZodBoolean>;
|
||||
auto: z.ZodOptional<z.ZodBoolean>;
|
||||
requiredWhen: z.ZodOptional<z.ZodObject<{
|
||||
column: z.ZodString;
|
||||
value: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
export type FieldDeclaration = z.infer<typeof fieldDeclarationSchema>;
|
||||
/**
|
||||
* 台账对象的**性质** —— 这条数据归谁管、活多久、谁有权改它。
|
||||
*
|
||||
* 从前台账只有「地址 + 列」,没有这一维,于是**没人需要回答「这张表是什么」**——
|
||||
* 后果不是不好看,是主键写歪了还没人发现:A4 的报价草案本该一单一份,
|
||||
* 主键却只有 `pn`,第二单报同一个料号直接 upsert 覆盖上一单
|
||||
* (实测:跑了四轮 A4,`quote_pricing.draft` 只剩最后一轮的 3 行;
|
||||
* 而主键带了单号的 `quote_intake.record` 是 61 行 / 20 单,一单不少)。
|
||||
*
|
||||
* 分三类,判据是**机械的**,不靠感觉:
|
||||
*
|
||||
* · `record` 单据 —— 一单一份的过程与结论。主键**必含单标识**(`caseKey`),
|
||||
* 放行即定稿、只增不改。它不需要「管理」,需要的是查得到、回放得出。
|
||||
* · `master` 主数据 —— 跨单积累的经营资产(客户、供应商、料号规格、历史价)。
|
||||
* 主键是**业务实体**,会被反复更新,下一单读它做判断。治理都在这一类上:
|
||||
* 谁能改、依据是什么、怎么去重、多久算过期。
|
||||
* · `derived` 派生 —— 能由前两类算出来的指标(落地率、命中率)。
|
||||
* **本不该手写**:手写的派生值能和它的来源打架,而没人会发现。
|
||||
* 标出来至少让它可被重算校验。
|
||||
*/
|
||||
export declare const LEDGER_OBJECT_KINDS: readonly ["record", "master", "derived"];
|
||||
export declare const ledgerObjectKindSchema: z.ZodEnum<{
|
||||
record: "record";
|
||||
master: "master";
|
||||
derived: "derived";
|
||||
}>;
|
||||
export type LedgerObjectKind = z.infer<typeof ledgerObjectKindSchema>;
|
||||
export declare const LEDGER_KIND_LABEL: Readonly<Record<LedgerObjectKind, string>>;
|
||||
export declare const LEDGER_KIND_DESC: Readonly<Record<LedgerObjectKind, string>>;
|
||||
export declare const objectDeclarationSchema: z.ZodObject<{
|
||||
domain: z.ZodString;
|
||||
object: z.ZodString;
|
||||
label: z.ZodOptional<z.ZodString>;
|
||||
kind: z.ZodDefault<z.ZodEnum<{
|
||||
record: "record";
|
||||
master: "master";
|
||||
derived: "derived";
|
||||
}>>;
|
||||
caseKey: z.ZodOptional<z.ZodString>;
|
||||
fields: z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
type: z.ZodEnum<{
|
||||
@@ -69,6 +112,14 @@ export declare const objectDeclarationSchema: z.ZodObject<{
|
||||
label: z.ZodOptional<z.ZodString>;
|
||||
primaryKey: z.ZodDefault<z.ZodBoolean>;
|
||||
masked: z.ZodDefault<z.ZodBoolean>;
|
||||
required: z.ZodOptional<z.ZodBoolean>;
|
||||
tally: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
expect: z.ZodOptional<z.ZodBoolean>;
|
||||
auto: z.ZodOptional<z.ZodBoolean>;
|
||||
requiredWhen: z.ZodOptional<z.ZodObject<{
|
||||
column: z.ZodString;
|
||||
value: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
export type ObjectDeclaration = z.infer<typeof objectDeclarationSchema>;
|
||||
|
||||
+151
@@ -42,6 +42,12 @@ export declare const manifestDeclarationsSchema: z.ZodObject<{
|
||||
domain: z.ZodString;
|
||||
object: z.ZodString;
|
||||
label: z.ZodOptional<z.ZodString>;
|
||||
kind: z.ZodDefault<z.ZodEnum<{
|
||||
record: "record";
|
||||
master: "master";
|
||||
derived: "derived";
|
||||
}>>;
|
||||
caseKey: z.ZodOptional<z.ZodString>;
|
||||
fields: z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
type: z.ZodEnum<{
|
||||
@@ -56,6 +62,14 @@ export declare const manifestDeclarationsSchema: z.ZodObject<{
|
||||
label: z.ZodOptional<z.ZodString>;
|
||||
primaryKey: z.ZodDefault<z.ZodBoolean>;
|
||||
masked: z.ZodDefault<z.ZodBoolean>;
|
||||
required: z.ZodOptional<z.ZodBoolean>;
|
||||
tally: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
expect: z.ZodOptional<z.ZodBoolean>;
|
||||
auto: z.ZodOptional<z.ZodBoolean>;
|
||||
requiredWhen: z.ZodOptional<z.ZodObject<{
|
||||
column: z.ZodString;
|
||||
value: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>>;
|
||||
listeners: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
@@ -154,6 +168,50 @@ export declare const dmnModelSchema: z.ZodObject<{
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
export type DmnModel = z.infer<typeof dmnModelSchema>;
|
||||
/**
|
||||
* **步骤剧本** —— 一个计划项一条,把「这一步是什么状态」写成机器读得懂的东西。
|
||||
*
|
||||
* 为什么要有它(2026-07-31 加,起因是 A1 反复空转):
|
||||
* 从前每一拍喂给模型的是**整本说明书 + 全部工具 + 全部可写台账**,
|
||||
* 模型得自己从六步里认出「我现在在第几步」。认对了还好,认岔了就开始转圈 ——
|
||||
* 实测 S1 已经把八字段写齐了,而说明书里 S3 写着「把八字段补到齐」,
|
||||
* 模型于是把同样三行又写一遍,被「写了等于没写」拒回,下一拍再写一遍,如此往复。
|
||||
*
|
||||
* 治法不是把说明书写得更啰嗦,是**把状态挪进引擎**:
|
||||
* ① `doneWhen` 让引擎自己算得出「这一步的成果已经在那儿了」—— 是就直接收掉,
|
||||
* **不问模型**(能算的不问 LLM:同输入必同结论、零 token);
|
||||
* ② `guide` / `writes` / `tools` 让每一拍的上下文**只装当前这一步要用的东西** ——
|
||||
* 上下文小了,模型跑偏的余地也就小了。
|
||||
*
|
||||
* 不写剧本的活动照旧跑(整本说明书 + 全部工具),只是没有这两样好处。
|
||||
*/
|
||||
export declare const planStepSchema: z.ZodObject<{
|
||||
item: z.ZodString;
|
||||
intent: z.ZodString;
|
||||
guide: z.ZodString;
|
||||
writes: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
reads: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
view: z.ZodOptional<z.ZodString>;
|
||||
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
doneWhen: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
export type PlanStep = z.infer<typeof planStepSchema>;
|
||||
/**
|
||||
* 阶段 —— **给人看的进度刻度**,引擎一眼都不看。
|
||||
*
|
||||
* 计划项是给引擎的:一件事拆到能判「干完没有」的粒度,A1 有六项。
|
||||
* 可六格进度条对人没有意义 —— 「查历史价 / 打标 / 定去向」在业务上是**一件事**
|
||||
* (判这单该怎么走),人关心的是「到哪一步了」,不是引擎拆了几项。
|
||||
* 所以另立一层:几个计划项合成一个阶段,进度条按阶段走。
|
||||
*
|
||||
* **只声明激活态的阶段**。结束态不在这里 —— 每个活动都以放行收口(铁律③),
|
||||
* 那是系统的规矩不是活动的阶段;进度条自己在末尾加这一格。
|
||||
*/
|
||||
export declare const casePhaseSchema: z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
items: z.ZodArray<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
export type CasePhase = z.infer<typeof casePhaseSchema>;
|
||||
export declare const loopModelSchema: z.ZodObject<{
|
||||
cmmnXml: z.ZodString;
|
||||
narrative: z.ZodString;
|
||||
@@ -187,6 +245,20 @@ export declare const loopModelSchema: z.ZodObject<{
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
steps: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
item: z.ZodString;
|
||||
intent: z.ZodString;
|
||||
guide: z.ZodString;
|
||||
writes: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
reads: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
view: z.ZodOptional<z.ZodString>;
|
||||
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
doneWhen: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
items: z.ZodArray<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>;
|
||||
export type LoopModel = z.infer<typeof loopModelSchema>;
|
||||
/**
|
||||
@@ -227,6 +299,20 @@ export declare const businessModelSchema: z.ZodObject<{
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
steps: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
item: z.ZodString;
|
||||
intent: z.ZodString;
|
||||
guide: z.ZodString;
|
||||
writes: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
reads: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
view: z.ZodOptional<z.ZodString>;
|
||||
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
doneWhen: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
items: z.ZodArray<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>;
|
||||
export type BusinessModel = z.infer<typeof businessModelSchema>;
|
||||
export declare const skillSchema: z.ZodObject<{
|
||||
@@ -276,6 +362,20 @@ export declare const manifestAssetsSchema: z.ZodObject<{
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
steps: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
item: z.ZodString;
|
||||
intent: z.ZodString;
|
||||
guide: z.ZodString;
|
||||
writes: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
reads: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
view: z.ZodOptional<z.ZodString>;
|
||||
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
doneWhen: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
items: z.ZodArray<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>;
|
||||
skills: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "SkillId", "out">;
|
||||
@@ -331,12 +431,20 @@ export declare const activityManifestSchema: z.ZodObject<{
|
||||
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
messageAllowed: z.ZodDefault<z.ZodBoolean>;
|
||||
messageHint: z.ZodOptional<z.ZodString>;
|
||||
inboundComm: z.ZodOptional<z.ZodObject<{
|
||||
channel: z.ZodDefault<z.ZodString>;
|
||||
partyField: z.ZodString;
|
||||
subjectField: z.ZodOptional<z.ZodString>;
|
||||
bodyField: z.ZodOptional<z.ZodString>;
|
||||
attachmentsField: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
output: z.ZodObject<{
|
||||
mandatory: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
description: z.ZodOptional<z.ZodString>;
|
||||
when: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
selective: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
group: z.ZodString;
|
||||
@@ -344,6 +452,7 @@ export declare const activityManifestSchema: z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
description: z.ZodOptional<z.ZodString>;
|
||||
when: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>;
|
||||
@@ -351,6 +460,9 @@ export declare const activityManifestSchema: z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
label: z.ZodString;
|
||||
schema: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
||||
ledger: z.ZodOptional<z.ZodString>;
|
||||
ledgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
editableAtRelease: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>>;
|
||||
acceptanceCriteria: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
@@ -365,6 +477,11 @@ export declare const activityManifestSchema: z.ZodObject<{
|
||||
predicate: z.ZodOptional<z.ZodString>;
|
||||
modelRef: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
revisableAtRelease: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
label: z.ZodString;
|
||||
ledger: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
prohibitions: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
statement: z.ZodString;
|
||||
@@ -407,6 +524,12 @@ export declare const activityManifestSchema: z.ZodObject<{
|
||||
domain: z.ZodString;
|
||||
object: z.ZodString;
|
||||
label: z.ZodOptional<z.ZodString>;
|
||||
kind: z.ZodDefault<z.ZodEnum<{
|
||||
record: "record";
|
||||
master: "master";
|
||||
derived: "derived";
|
||||
}>>;
|
||||
caseKey: z.ZodOptional<z.ZodString>;
|
||||
fields: z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
type: z.ZodEnum<{
|
||||
@@ -421,6 +544,14 @@ export declare const activityManifestSchema: z.ZodObject<{
|
||||
label: z.ZodOptional<z.ZodString>;
|
||||
primaryKey: z.ZodDefault<z.ZodBoolean>;
|
||||
masked: z.ZodDefault<z.ZodBoolean>;
|
||||
required: z.ZodOptional<z.ZodBoolean>;
|
||||
tally: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
expect: z.ZodOptional<z.ZodBoolean>;
|
||||
auto: z.ZodOptional<z.ZodBoolean>;
|
||||
requiredWhen: z.ZodOptional<z.ZodObject<{
|
||||
column: z.ZodString;
|
||||
value: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>>;
|
||||
listeners: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
@@ -497,6 +628,20 @@ export declare const activityManifestSchema: z.ZodObject<{
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
steps: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
item: z.ZodString;
|
||||
intent: z.ZodString;
|
||||
guide: z.ZodString;
|
||||
writes: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
reads: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
view: z.ZodOptional<z.ZodString>;
|
||||
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
doneWhen: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
items: z.ZodArray<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>;
|
||||
skills: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "SkillId", "out">;
|
||||
@@ -552,6 +697,9 @@ export declare const staticAuditFindingSchema: z.ZodObject<{
|
||||
"dynamic-eval": "dynamic-eval";
|
||||
"ledger-type-conflict": "ledger-type-conflict";
|
||||
"acceptance-level-misreported": "acceptance-level-misreported";
|
||||
"acceptance-predicate-unbound": "acceptance-predicate-unbound";
|
||||
"deliverable-address-unbound": "deliverable-address-unbound";
|
||||
"ledger-kind-invalid": "ledger-kind-invalid";
|
||||
}>;
|
||||
severity: z.ZodEnum<{
|
||||
error: "error";
|
||||
@@ -590,6 +738,9 @@ export declare const installVerdictSchema: z.ZodObject<{
|
||||
"dynamic-eval": "dynamic-eval";
|
||||
"ledger-type-conflict": "ledger-type-conflict";
|
||||
"acceptance-level-misreported": "acceptance-level-misreported";
|
||||
"acceptance-predicate-unbound": "acceptance-predicate-unbound";
|
||||
"deliverable-address-unbound": "deliverable-address-unbound";
|
||||
"ledger-kind-invalid": "ledger-kind-invalid";
|
||||
}>;
|
||||
severity: z.ZodEnum<{
|
||||
error: "error";
|
||||
|
||||
+4
-4
@@ -41,9 +41,9 @@ export declare const GRANT_ACTION_LABEL: Readonly<Record<GrantAction, string>>;
|
||||
/** 治理台九功能 —— 授权客体之一 */
|
||||
export declare const GOVERNANCE_FUNCTIONS: readonly ["task", "staff", "process", "trigger", "activity", "tool", "data", "memory", "system"];
|
||||
export declare const governanceFunctionSchema: z.ZodEnum<{
|
||||
trigger: "trigger";
|
||||
data: "data";
|
||||
task: "task";
|
||||
trigger: "trigger";
|
||||
system: "system";
|
||||
tool: "tool";
|
||||
activity: "activity";
|
||||
@@ -61,9 +61,9 @@ export declare const grantObjectSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"governance">;
|
||||
function: z.ZodEnum<{
|
||||
trigger: "trigger";
|
||||
data: "data";
|
||||
task: "task";
|
||||
trigger: "trigger";
|
||||
system: "system";
|
||||
tool: "tool";
|
||||
activity: "activity";
|
||||
@@ -86,9 +86,9 @@ export declare const grantSchema: z.ZodObject<{
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"governance">;
|
||||
function: z.ZodEnum<{
|
||||
trigger: "trigger";
|
||||
data: "data";
|
||||
task: "task";
|
||||
trigger: "trigger";
|
||||
system: "system";
|
||||
tool: "tool";
|
||||
activity: "activity";
|
||||
@@ -152,9 +152,9 @@ export declare const sessionSchema: z.ZodObject<{
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"governance">;
|
||||
function: z.ZodEnum<{
|
||||
trigger: "trigger";
|
||||
data: "data";
|
||||
task: "task";
|
||||
trigger: "trigger";
|
||||
system: "system";
|
||||
tool: "tool";
|
||||
activity: "activity";
|
||||
|
||||
+2
-1
@@ -60,12 +60,13 @@ export declare const workflowStateSchema: z.ZodEnum<{
|
||||
}>;
|
||||
export type WorkflowState = z.infer<typeof workflowStateSchema>;
|
||||
export declare const WORKFLOW_STATE_LABEL: Readonly<Record<WorkflowState, string>>;
|
||||
export declare const ATTRIBUTION_SIDES: readonly ["machine", "human", "human-verdict", "world-wait"];
|
||||
export declare const ATTRIBUTION_SIDES: readonly ["machine", "human", "human-verdict", "world-wait", "trigger"];
|
||||
export declare const attributionSideSchema: z.ZodEnum<{
|
||||
machine: "machine";
|
||||
human: "human";
|
||||
"human-verdict": "human-verdict";
|
||||
"world-wait": "world-wait";
|
||||
trigger: "trigger";
|
||||
}>;
|
||||
export type AttributionSide = z.infer<typeof attributionSideSchema>;
|
||||
export declare const ATTRIBUTION_SIDE_LABEL: Readonly<Record<AttributionSide, string>>;
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"seed": "node dist/seed.js",
|
||||
"migrate": "node dist/migrate.js",
|
||||
"set-password": "node dist/set-password.js",
|
||||
"reset-runs": "node dist/reset-runs.js"
|
||||
"reset-runs": "node dist/reset-runs.js",
|
||||
"install-activities": "node dist/install-activities.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai-compatible": "^3.0.14",
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* 验收判据的达标口径 —— **这段错了不报错,只是那张卡上永远挂着消不掉的红叉**。
|
||||
*
|
||||
* 用户实测报的就是它:一单办得干干净净,卡面却停在 5/8 —— 因为 L3/L4 这类
|
||||
* 机器判不了的判据被写死成 `false`,而 Agent 的 `judge` 裁定只进了留痕、
|
||||
* 从没回流到判据。于是「达标数」这个读数失去意义(反正它永远不满),
|
||||
* 人签字时面对一排红叉,只能凭感觉签,前面那些机检也就白做了。
|
||||
*
|
||||
* 口径(用户 2026-07-31 定):**提交放行时只许剩「人放行」那一条**。
|
||||
* 机检未过、或裁量判据一条没裁,都不该发卡 —— 那不是「请你验收」,
|
||||
* 是把没办完的活推给人。
|
||||
*
|
||||
* 这里测的是那条判定规则本身(纯函数级),不拉起整个装配层。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { evaluateCondition } from '@sfeel';
|
||||
import { machineCheckable } from '@contracts';
|
||||
import type { AcceptanceCriterion } from '@contracts';
|
||||
/*
|
||||
* 判据名归一**用生产的那一个**,不再在测里抄一份。
|
||||
* 抄一份的下场刚踩过:生产那边加了「削掉引擎自己贴的等级标记」,
|
||||
* 测里这份还是旧的 —— 测全绿,线上照样连拒五拍熔断。
|
||||
*/
|
||||
import { criterionKey } from '@modules/loop-engine';
|
||||
import { criterionCheck } from '../wiring/engines.ts';
|
||||
|
||||
|
||||
/** 与 `engines.ts` 里那段判定同一口径 —— 改一处这里要跟着改,不然测的是旧规矩 */
|
||||
function passOf(
|
||||
c: AcceptanceCriterion,
|
||||
scope: Record<string, unknown>,
|
||||
judged: ReadonlyMap<string, boolean>,
|
||||
): boolean {
|
||||
if (machineCheckable(c)) {
|
||||
if (c.predicate === undefined || c.predicate.trim() === '') return false;
|
||||
try {
|
||||
return evaluateCondition(c.predicate, scope);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// L5 = 人放行:只有人按下去才算,引擎与 Agent 都不许代答
|
||||
if (c.level === 'L5') return false;
|
||||
return judged.get(criterionKey(c.key)) ?? judged.get(criterionKey(c.label)) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交门 —— **两类判据把两道不同的关**(与 `engines.ts` 那段同一口径):
|
||||
* · 机检(L1/L2):数不对就是活没干完 —— 必须为真;
|
||||
* · 裁量(L3/L4):必须**裁过**,裁成什么不拦。
|
||||
*
|
||||
* 后半句是 2026-08-01 用实测改回来的:从前对裁量项也要求「为真」,
|
||||
* 而裁量的本义恰恰是可以裁出「不满足」,且不满足正是最该交给人的情形。
|
||||
* 于是模型只剩两条路 —— 改数据,或改裁断,两条都是撒谎,它两条都走过
|
||||
* (先把 missing_minor_total 从 1 改回 0;换成 L4 后又把「未满足」翻成「满足」)。
|
||||
* **一条只接受「是」的判据不是判据,是一句教模型说谎的提示。**
|
||||
*/
|
||||
const submittable = (
|
||||
criteria: readonly AcceptanceCriterion[],
|
||||
scope: Record<string, unknown>,
|
||||
judged: ReadonlyMap<string, boolean>,
|
||||
): string[] =>
|
||||
criteria
|
||||
.filter((c) => {
|
||||
if (c.level === 'L5') return false;
|
||||
if (machineCheckable(c)) return !passOf(c, scope, judged);
|
||||
// 裁量项:裁过就放行提交,裁断本身随卡交给人
|
||||
return judged.get(criterionKey(c.key)) === undefined && judged.get(criterionKey(c.label)) === undefined;
|
||||
})
|
||||
.map((c) => c.label);
|
||||
|
||||
/** 照 A1 的判据表建一份(等级与谓词都取真的) */
|
||||
const CRITERIA: AcceptanceCriterion[] = [
|
||||
{ key: 'lines-present', label: '至少归一出一条需求', level: 'L1', predicate: 'line_total > 0' },
|
||||
{ key: 'fields-complete', label: '三要素齐(PN/EAU/交期)', level: 'L1', predicate: 'missing_key_total = 0' },
|
||||
{ key: 'typed', label: '每条都已打标 A/B/C', level: 'L2', predicate: 'untyped_total = 0' },
|
||||
{ key: 'downgrade-safe', label: '吃不准降 B,不许臆断 A', level: 'L3' },
|
||||
{ key: 'route-judgement', label: '去向判断合乎业务常理', level: 'L4' },
|
||||
{ key: 'owner-release', label: 'BD 放行', level: 'L5' },
|
||||
];
|
||||
|
||||
/** 台账都写齐了的局面 */
|
||||
const DONE: Record<string, unknown> = { line_total: 3, missing_key_total: 0, untyped_total: 0 };
|
||||
|
||||
describe('裁量判据读 Agent 的裁定 —— 不再一律判 false', () => {
|
||||
it('**没人裁过 ≠ 通过** —— 一条没裁时 L3/L4 仍是未达标', () => {
|
||||
const judged = new Map<string, boolean>();
|
||||
expect(passOf(CRITERIA[3]!, DONE, judged)).toBe(false);
|
||||
expect(passOf(CRITERIA[4]!, DONE, judged)).toBe(false);
|
||||
});
|
||||
|
||||
it('裁定为满足 → 达标', () => {
|
||||
const judged = new Map([[criterionKey('吃不准降 B,不许臆断 A'), true]]);
|
||||
expect(passOf(CRITERIA[3]!, DONE, judged)).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* **这一条是拿实测的事故写的**:模型把判据名抄成了全角标点、还吃掉了空格
|
||||
* (「吃不准降B,不许臆断A」),逐字比对当然对不上 —— 那条判据永远未达标,
|
||||
* 提交放行被拒 32 次直到预算烧完(exhausted)。一个标点差异吃掉一整单。
|
||||
*/
|
||||
it('**全角标点 / 少空格照样认得出** —— 一个标点不该吃掉一整单', () => {
|
||||
const judged = new Map([
|
||||
[criterionKey('吃不准降B,不许臆断A'), true],
|
||||
[criterionKey('去向判断合乎业务常理(客户特殊要求、急单等)'), true],
|
||||
]);
|
||||
expect(passOf(CRITERIA[3]!, DONE, judged)).toBe(true);
|
||||
const l4: AcceptanceCriterion = {
|
||||
key: 'route-judgement',
|
||||
label: '去向判断合乎业务常理(客户特殊要求、急单等)',
|
||||
level: 'L4',
|
||||
};
|
||||
expect(passOf(l4, DONE, judged)).toBe(true);
|
||||
});
|
||||
|
||||
it('归一不做模糊匹配 —— 两条不同的判据不许混成一条', () => {
|
||||
const judged = new Map([[criterionKey('吃不准降 B'), true]]);
|
||||
expect(passOf(CRITERIA[3]!, DONE, judged)).toBe(false);
|
||||
});
|
||||
|
||||
it('裁定为不满足 → 照旧未达标(裁了不等于过)', () => {
|
||||
const judged = new Map([[criterionKey('吃不准降 B,不许臆断 A'), false]]);
|
||||
expect(passOf(CRITERIA[3]!, DONE, judged)).toBe(false);
|
||||
});
|
||||
|
||||
/* 模型写 key 还是写中文名都可能 —— 两种都认,免得一个称呼差异吃掉一条判据 */
|
||||
it('criterion 用 key 写也认', () => {
|
||||
const judged = new Map([[criterionKey('downgrade-safe'), true]]);
|
||||
expect(passOf(CRITERIA[3]!, DONE, judged)).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* L5 是这道门的底:它必须由人按下去。
|
||||
* 给 Agent 留一条「裁定自己放行」的缝,终局唯放行就成了一句空话。
|
||||
*/
|
||||
it('**L5 人放行谁也代答不了** —— 连 judge 裁了也不算', () => {
|
||||
const judged = new Map([[criterionKey('BD 放行'), true], [criterionKey('owner-release'), true]]);
|
||||
expect(passOf(CRITERIA[5]!, DONE, judged)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('提交放行只许剩「人放行」那一条', () => {
|
||||
it('机检没过就提交 —— 拒,并说得出是哪一条', () => {
|
||||
const gappy = { line_total: 3, missing_key_total: 2, untyped_total: 0 };
|
||||
const judged = new Map([
|
||||
[criterionKey('吃不准降 B,不许臆断 A'), true],
|
||||
[criterionKey('去向判断合乎业务常理'), true],
|
||||
]);
|
||||
expect(submittable(CRITERIA, gappy, judged)).toEqual(['三要素齐(PN/EAU/交期)']);
|
||||
});
|
||||
|
||||
it('机检全过但裁量项一条没裁 —— 照样拒(这正是 5/8 那一单)', () => {
|
||||
expect(submittable(CRITERIA, DONE, new Map())).toEqual(['吃不准降 B,不许臆断 A', '去向判断合乎业务常理']);
|
||||
});
|
||||
|
||||
/*
|
||||
* 裁成「不满足」照样能提交 —— 这一条是 2026-08-01 拿两轮实测换来的:
|
||||
* 门只接受「是」的时候,模型先改数据、后改裁断,两轮都撒了谎才过得去。
|
||||
* 不满足连着理由进验收卡,才是签字的人该看见的东西。
|
||||
*/
|
||||
it('**裁量裁成「不满足」也能提交** —— 判断交给人,不是逼 AI 说是', () => {
|
||||
const judged = new Map([
|
||||
[criterionKey('吃不准降 B,不许臆断 A'), false],
|
||||
[criterionKey('去向判断合乎业务常理'), false],
|
||||
]);
|
||||
expect(submittable(CRITERIA, DONE, judged)).toEqual([]);
|
||||
// 但它在卡面上仍是未达标 —— 提交得了,不等于这一条过了
|
||||
expect(passOf(CRITERIA[3]!, DONE, judged)).toBe(false);
|
||||
});
|
||||
|
||||
it('机检没过时,裁量裁了也拦得住 —— 两道关互不代替', () => {
|
||||
const gappy = { line_total: 3, missing_key_total: 2, untyped_total: 0 };
|
||||
const judged = new Map([
|
||||
[criterionKey('吃不准降 B,不许臆断 A'), false],
|
||||
[criterionKey('去向判断合乎业务常理'), false],
|
||||
]);
|
||||
expect(submittable(CRITERIA, gappy, judged)).toEqual(['三要素齐(PN/EAU/交期)']);
|
||||
});
|
||||
|
||||
it('**机检全过 + 裁量都裁了 → 放行,此时只剩人那一条**', () => {
|
||||
const judged = new Map([
|
||||
[criterionKey('吃不准降 B,不许臆断 A'), true],
|
||||
[criterionKey('去向判断合乎业务常理'), true],
|
||||
]);
|
||||
expect(submittable(CRITERIA, DONE, judged)).toEqual([]);
|
||||
// 剩下的那一条恰恰是 L5,并且它此刻确实还没达标 —— 等人按
|
||||
expect(CRITERIA.filter((c) => !passOf(c, DONE, judged)).map((c) => c.level)).toEqual(['L5']);
|
||||
});
|
||||
|
||||
it('L1/L2 漏配 predicate 判 false —— 装不出证据就不算过', () => {
|
||||
const noPredicate: AcceptanceCriterion[] = [{ key: 'x', label: '没写谓词的机检项', level: 'L2' }];
|
||||
expect(submittable(noPredicate, DONE, new Map())).toEqual(['没写谓词的机检项']);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **卡面上不摆 L5**(2026-08-01 用户当场指出:「当前卡片就是放行卡,
|
||||
* 也不该显示在这里 —— 这里是判据」)。
|
||||
*
|
||||
* L5 是责任关,而放行卡本身就是那道关。把它列进判据表,等于在卡上摆一条
|
||||
* 恒为「未达标」的项:人一路核到底,最后一行永远是红的,而消掉它的动作
|
||||
* 就是按下同一张卡上的「确认通过」;「n/m 达标」那个数也因此永远差一条。
|
||||
* 判据是**签字前要核的东西**,不是签字这件事自己。
|
||||
*
|
||||
* 声明照旧留在契约里(它说明这份交付物必须由人放行),提交门与预检也照旧
|
||||
* 把它排除在外 —— 少的只是这一处呈现。这一组把两件事分开钉死。
|
||||
*/
|
||||
describe('放行卡的判据表:摆 L1–L4,不摆 L5', () => {
|
||||
/** 与 `engines.ts` 组卡那一处同款:落到卡面之前滤掉 L5 */
|
||||
const onCard = (list: readonly AcceptanceCriterion[]) => list.filter((c) => c.level !== 'L5');
|
||||
|
||||
it('卡面判据里没有 L5,而 L1–L4 一条不少', () => {
|
||||
const shown = onCard(CRITERIA);
|
||||
expect(shown.map((c) => c.level)).toEqual(['L1', 'L1', 'L2', 'L3', 'L4']);
|
||||
expect(shown.some((c) => c.label === 'BD 放行')).toBe(false);
|
||||
});
|
||||
|
||||
it('**「n/m 达标」不再永远差一条** —— 全办完时卡面是满的', () => {
|
||||
const judged = new Map([
|
||||
[criterionKey('吃不准降 B,不许臆断 A'), true],
|
||||
[criterionKey('去向判断合乎业务常理'), true],
|
||||
]);
|
||||
const shown = onCard(CRITERIA);
|
||||
const passN = shown.filter((c) => passOf(c, DONE, judged)).length;
|
||||
expect(`${passN}/${shown.length}`).toBe('5/5');
|
||||
// 摆着 L5 的时候它永远是 5/6 —— 最后一行只有按下按钮才会绿
|
||||
expect(CRITERIA.filter((c) => passOf(c, DONE, judged)).length).toBe(5);
|
||||
});
|
||||
|
||||
it('契约里仍然声明着 L5 —— 少的只是呈现,不是那道关', () => {
|
||||
expect(CRITERIA.some((c) => c.level === 'L5')).toBe(true);
|
||||
// 提交门照旧不因为它拦人(它由人按下去消掉,不由 Agent 办)
|
||||
const judged = new Map([
|
||||
[criterionKey('吃不准降 B,不许臆断 A'), true],
|
||||
[criterionKey('去向判断合乎业务常理'), true],
|
||||
]);
|
||||
expect(submittable(CRITERIA, DONE, judged)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **裁量判据带谓词时,谓词说了算**(2026-08-02 用户实测逼出来的)。
|
||||
*
|
||||
* 「选填缺口:已补齐,或由你放行时确认可以先受理」是个复合命题:
|
||||
* 前半句(已补齐)基座数得出来,后半句(人确认先受理)才需要人。
|
||||
* 从前整条只读裁断,于是模型凭记忆写理由 —— 用户在件上把目标价填好了,
|
||||
* 它照样裁「未满足」,理由写「NE555P 缺目标价,客户未提供」。
|
||||
* 用户原话:「这个价格是我填写的,但是它判错了」。
|
||||
*
|
||||
* **数得出来的就别让它自报** —— 这条纪律在这个仓里第三次被违反。
|
||||
*/
|
||||
describe('criterionCheck —— 卡面与提交门同一段', () => {
|
||||
const optional = {
|
||||
key: 'optional-asked',
|
||||
label: '选填缺口:已补齐,或由你放行时确认可以先受理',
|
||||
level: 'L4' as const,
|
||||
predicate: 'quote_intake_record_gaps = 0',
|
||||
};
|
||||
const stale = { satisfied: false, rationale: 'NE555P 缺目标价,客户未提供,已按历史价估 8 元' };
|
||||
|
||||
it('**数据说补齐了 → 达标,模型那句相反的话一并丢掉**', () => {
|
||||
const got = criterionCheck(optional, { quote_intake_record_gaps: 0 }, stale);
|
||||
expect(got.pass).toBe(true);
|
||||
expect(got.settled).toBe(true);
|
||||
expect(got.rationale).toBeUndefined(); // 一件已办成的事,不需要谁再解释为什么没办成
|
||||
});
|
||||
|
||||
it('数据说还缺着 → 轮到裁量:裁断与理由照旧交给签字的人', () => {
|
||||
const got = criterionCheck(optional, { quote_intake_record_gaps: 2 }, stale);
|
||||
expect(got.pass).toBe(false);
|
||||
expect(got.settled).toBe(true); // 裁过了(裁成不满足照样能提交)
|
||||
expect(got.rationale).toContain('NE555P');
|
||||
});
|
||||
|
||||
it('数据说还缺着、又没人裁过 → 未达标且**未裁**,提交门该拦住', () => {
|
||||
const got = criterionCheck(optional, { quote_intake_record_gaps: 2 }, undefined);
|
||||
expect(got.pass).toBe(false);
|
||||
expect(got.settled).toBe(false);
|
||||
});
|
||||
|
||||
it('**不带谓词的裁量项照旧只读裁断** —— 机器判不了的别硬判', () => {
|
||||
const l3 = { key: 'downgrade-safe', label: '吃不准降 B,不许臆断 A', level: 'L3' as const };
|
||||
expect(criterionCheck(l3, {}, undefined).pass).toBe(false);
|
||||
expect(criterionCheck(l3, {}, { satisfied: true }).pass).toBe(true);
|
||||
});
|
||||
|
||||
it('机检项不掺裁断 —— 模型裁「满足」也翻不动一个假的数', () => {
|
||||
const l1 = { key: 'k', label: '必填齐', level: 'L1' as const, predicate: 'quote_intake_record_missing = 0' };
|
||||
expect(criterionCheck(l1, { quote_intake_record_missing: 1 }, { satisfied: true }).pass).toBe(false);
|
||||
expect(criterionCheck(l1, { quote_intake_record_missing: 1 }, { satisfied: true }).settled).toBeUndefined();
|
||||
});
|
||||
|
||||
it('L5 只有人按得下 —— 引擎与 Agent 都不许代答', () => {
|
||||
const l5 = { key: 'bd', label: 'BD 放行', level: 'L5' as const, predicate: '1 = 1' };
|
||||
expect(criterionCheck(l5, {}, { satisfied: true }).pass).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **「活没干完」得说成活没干完**(2026-08-02 实测逼出来的)。
|
||||
*
|
||||
* 那一单跳过了打标与定去向就来提交:判据正确地拦住了,而拒回的话给了两条路 ——
|
||||
* 改数、改行。两条都不对:数是对的(它诚实地报了「3 条没打标」)、
|
||||
* 行也是对的(记录三行一行不多),**真正该做的是回去把那一步办了**。
|
||||
* 于是它只能原样重提,连撞五拍熔断。
|
||||
*
|
||||
* 判法很硬:判据里点名的那张表,这一单一行都没写(基座数的 `_rows` 是 0),
|
||||
* 那就不是数错也不是行错,是那一步压根没办。
|
||||
*/
|
||||
describe('拒回指路:一行都没写 = 那一步还没办', () => {
|
||||
/** 与 `engines.ts` 那段挑表同一口径 —— 改一处这里要跟着改 */
|
||||
const emptyTables = (predicates: readonly string[], scope: Record<string, unknown>): string[] => [
|
||||
...new Set(
|
||||
predicates.flatMap((p) =>
|
||||
[...p.matchAll(/([a-z][a-z0-9_]*)_rows\b/g)]
|
||||
.map((m) => m[1] ?? '')
|
||||
.filter((name) => name !== '' && Number(scope[`${name}_rows`] ?? -1) === 0),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
it('**那张表一行都没写 → 点名它**,别让模型去改数改行', () => {
|
||||
const got = emptyTables(['quote_intake_type_table_rows = quote_intake_record_rows'], {
|
||||
quote_intake_type_table_rows: 0,
|
||||
quote_intake_record_rows: 3,
|
||||
});
|
||||
expect(got).toEqual(['quote_intake_type_table']);
|
||||
});
|
||||
|
||||
it('表写过了、只是数对不上 → 不点名,照旧走「数还是行」那条指路', () => {
|
||||
const got = emptyTables(['quote_intake_type_table_rows = quote_intake_record_rows'], {
|
||||
quote_intake_type_table_rows: 2,
|
||||
quote_intake_record_rows: 3,
|
||||
});
|
||||
expect(got).toEqual([]);
|
||||
});
|
||||
|
||||
it('读不到那个数就不猜 —— 宁可少说一句,不可编一句', () => {
|
||||
expect(emptyTables(['quote_intake_type_table_rows = 3'], {})).toEqual([]);
|
||||
});
|
||||
|
||||
it('几条判据点到同一张表只说一遍', () => {
|
||||
const got = emptyTables(
|
||||
['quote_intake_type_table_rows = quote_intake_record_rows', 'quote_intake_type_table_rows > 0'],
|
||||
{ quote_intake_type_table_rows: 0, quote_intake_record_rows: 3 },
|
||||
);
|
||||
expect(got).toEqual(['quote_intake_type_table']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 模型出参归一 —— **宽进到什么程度**,这里说死。
|
||||
*
|
||||
* 每一条宽进都对应一次实测踩坑:模型把字段叫成另一个名字、把地址整串塞进一格、
|
||||
* 把行数组写成半个形状。这些差异吃掉的是一整拍(有的还白发一张卡给人),
|
||||
* 而它们全都不涉及「行动合不合规」—— 合规照旧由引擎拒。
|
||||
*
|
||||
* 反过来也要守住:看不懂就转人,**不许猜着往世界上动手**。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { EnabledSet } from '@modules/loop-engine';
|
||||
import { normalizeAction, normalizeTurn } from '../agent-runner.ts';
|
||||
|
||||
const ENABLED: EnabledSet = {
|
||||
entries: [{ id: 'T1', name: '干活', type: 'humanTask', requiresManualStart: false, discretionary: false }],
|
||||
inProgress: [],
|
||||
judgements: [],
|
||||
gaps: ['缺记录'],
|
||||
activeStages: [],
|
||||
};
|
||||
|
||||
describe('行动型的叫法', () => {
|
||||
it('`kind` 也认 `action` —— 实测模型这么写过,其余字段一字不差', () => {
|
||||
const action = normalizeAction(
|
||||
{ action: 'write', itemId: 'T1', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 1 } }] },
|
||||
ENABLED,
|
||||
);
|
||||
expect(action.kind).toBe('write');
|
||||
});
|
||||
|
||||
it('两个都给时以 `kind` 为准 —— 契约字段优先,别名只是兜底', () => {
|
||||
expect(normalizeAction({ kind: 'submit', action: 'write', rationale: 'r' }, ENABLED).kind).toBe('submit');
|
||||
});
|
||||
});
|
||||
|
||||
describe('台账地址的写法', () => {
|
||||
const rows = [{ key: { pn: 'X' }, patch: { eau: 1 } }];
|
||||
const addressOf = (raw: Record<string, unknown>) => {
|
||||
const action = normalizeAction({ kind: 'write', itemId: 'T1', rows, ...raw }, ENABLED);
|
||||
return action.kind === 'write' ? action.address : '(不是 write)';
|
||||
};
|
||||
|
||||
it('规范写法:address 一个字符串', () => {
|
||||
expect(addressOf({ address: 'quote_intake.record' })).toBe('quote_intake.record');
|
||||
});
|
||||
|
||||
it('整串塞进 object 里(实测第一拍就这么错)', () => {
|
||||
expect(addressOf({ object: 'quote_intake.record' })).toBe('quote_intake.record');
|
||||
});
|
||||
|
||||
it('整串塞进 domain 里', () => {
|
||||
expect(addressOf({ domain: 'quote_intake.record' })).toBe('quote_intake.record');
|
||||
});
|
||||
|
||||
it('老老实实拆成两格也认', () => {
|
||||
expect(addressOf({ domain: 'quote_intake', object: 'record' })).toBe('quote_intake.record');
|
||||
});
|
||||
|
||||
it('什么都没给 → 空地址,交引擎当场拒(不猜)', () => {
|
||||
expect(addressOf({})).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('写行的形状', () => {
|
||||
const rowsOf = (raw: unknown) => {
|
||||
const action = normalizeAction({ kind: 'write', itemId: 'T1', address: 'd.o', rows: raw }, ENABLED);
|
||||
return action.kind === 'write' ? action.rows : [];
|
||||
};
|
||||
|
||||
it('形状不对的行**丢掉而不是猜** —— 猜错就往台账落一行来路不明的数', () => {
|
||||
expect(rowsOf([{ key: { pn: 'X' }, patch: { eau: 1 } }, { pn: 'Y', eau: 2 }, null, 7])).toEqual([
|
||||
{ key: { pn: 'X' }, patch: { eau: 1 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('主键值归一为字符串或数字 —— 键要能稳定比对', () => {
|
||||
expect(rowsOf([{ key: { pn: 'X', qty: 3, flag: true }, patch: {} }])).toEqual([
|
||||
{ key: { pn: 'X', qty: 3, flag: 'true' }, patch: {} },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('看不懂就转人,不猜着动手', () => {
|
||||
it('行动型不认识 → 发执行卡,卡面只说人话 —— JSON 原话归工作日志,不上卡', () => {
|
||||
const action = normalizeAction({ kind: '我自己想的行动' }, ENABLED);
|
||||
expect(action.kind).toBe('human');
|
||||
if (action.kind !== 'human') return;
|
||||
// 2026-08-03 实测:原话上卡,人看到的是一坨代码(「卡片里又是乱七八糟的东西」)
|
||||
expect(action.card['modelReply']).toBeUndefined();
|
||||
expect(String(action.card['reason'])).toContain('工作日志');
|
||||
expect(action.card['kindSeen']).toBe('我自己想的行动');
|
||||
});
|
||||
|
||||
it('立监听没给时限 → 兜底 P3D,绝不留幽灵等待', () => {
|
||||
const action = normalizeAction({ kind: 'listen', itemId: 'T1', eventType: 'data' }, ENABLED);
|
||||
expect(action.kind === 'listen' && action.deadline).toBe('P3D');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **补全卡已全局退役**(2026-08-02 用户定:「彻底全局退役补全卡,执行卡完全就是
|
||||
* 包含补全语义的人类工具」)—— 归一层把旧输出里的 `ask` 原地转成零输入执行工单:
|
||||
* 问句上卡,字段名并进指导书(人补在工作区的件上),`fields` 不上卡。
|
||||
* 拒回只会让模型换着花样再发,归一才是把路铺平。
|
||||
*/
|
||||
describe('补全卡退役:ask 原地转成零输入执行工单', () => {
|
||||
const norm = (raw: Record<string, unknown>) => normalizeAction({ kind: 'ask', itemId: 'T1', ...raw }, ENABLED);
|
||||
|
||||
it('转成 human + execution,问句在 ask,字段名进指导书', () => {
|
||||
const a = norm({ ask: '缺交期,补一下', fields: [{ 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('fields 落在 card 里也认(旧模型这么写过)—— 一样进指导书', () => {
|
||||
const a = norm({ card: { ask: '补一下', fields: [{ 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('连问句都没有:兜一个标题,不吃掉整拍', () => {
|
||||
const a = norm({});
|
||||
expect(a.kind).toBe('human');
|
||||
if (a.kind !== 'human') return;
|
||||
expect(String((a.card as Record<string, unknown>)['title'])).not.toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 验收报告的**可修订字段** —— PRD:「一张工单 → 卡组一(HTML 报告 · AI 已填好、可编辑)」。
|
||||
*
|
||||
* 两样都得有才落得下去:`ai` 是「已填好」的那个值,`ledger` 是改完落回哪个台账地址。
|
||||
* 少了 ai 就只能看不能改;少了 ledger,人改了也只留在工单留痕里,落地用的还是活动的原值。
|
||||
*/
|
||||
describe('提交放行带的可修订字段', () => {
|
||||
const fieldsOf = (raw: Record<string, unknown>) => {
|
||||
const a = normalizeAction({ kind: 'submit', rationale: 'r', ...raw }, ENABLED);
|
||||
return a.kind === 'submit' ? (a.fields ?? []) : [];
|
||||
};
|
||||
|
||||
it('带 ai 与 ledger 一起过来', () => {
|
||||
expect(
|
||||
fieldsOf({ fields: [{ key: 'need_sourcing', label: '需要寻源', ai: 'true', ledger: 'quote_intake.verdict' }] }),
|
||||
).toEqual([{ key: 'need_sourcing', label: '需要寻源', ai: 'true', ledger: 'quote_intake.verdict' }]);
|
||||
});
|
||||
|
||||
it('落在 card 里也认', () => {
|
||||
expect(fieldsOf({ card: { fields: [{ key: 'x', label: 'X', ai: '1' }] } })).toEqual([
|
||||
{ key: 'x', label: 'X', ai: '1' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('**不替它补 ledger** —— 猜出来的地址会把值写到别的对象上', () => {
|
||||
expect(fieldsOf({ fields: [{ key: 'x', label: 'X', ai: '1' }] })[0]).not.toHaveProperty('ledger');
|
||||
});
|
||||
|
||||
it('没给字段就不带 —— 验收报告只看不改也是一种合法形态', () => {
|
||||
const a = normalizeAction({ kind: 'submit', rationale: 'r' }, ENABLED);
|
||||
expect(a.kind === 'submit' && a.fields).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 一拍出参归一 —— 三种来路都认:{actions:[…]}(规范形)、裸行动对象(旧形)、顶层数组。
|
||||
* 串里不认识的项丢掉;整串一个都不认识才退化为单张转人卡 ——
|
||||
* 不能让串中途一个写坏的项给人甩一张卡。
|
||||
*/
|
||||
describe('一拍一串行动的归一', () => {
|
||||
it('规范形:actions 数组 + 拍级 say', () => {
|
||||
const turn = normalizeTurn(
|
||||
{
|
||||
say: '登记完毕,顺手收场',
|
||||
tag: 'conclusion',
|
||||
actions: [
|
||||
{ kind: 'write', itemId: 'T1', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 1 } }] },
|
||||
{ kind: 'finish', itemId: 'T1', rationale: '齐了' },
|
||||
],
|
||||
},
|
||||
ENABLED,
|
||||
);
|
||||
expect(turn.actions.map((a) => a.kind)).toEqual(['write', 'finish']);
|
||||
expect(turn.say?.text).toBe('登记完毕,顺手收场');
|
||||
});
|
||||
|
||||
it('旧形:裸行动对象包成单元素串 —— 换了拍形,旧模型输出不作废', () => {
|
||||
const turn = normalizeTurn({ kind: 'submit', rationale: 'r', say: '交卷' }, ENABLED);
|
||||
expect(turn.actions.map((a) => a.kind)).toEqual(['submit']);
|
||||
expect(turn.say?.text).toBe('交卷');
|
||||
});
|
||||
|
||||
it('串里的垃圾项丢掉,认识的留下 —— 不在批中途甩卡', () => {
|
||||
const turn = normalizeTurn(
|
||||
{ actions: [{ kind: '胡编的' }, { kind: 'finish', itemId: 'T1' }, null, 7] },
|
||||
ENABLED,
|
||||
);
|
||||
expect(turn.actions.map((a) => a.kind)).toEqual(['finish']);
|
||||
});
|
||||
|
||||
it('整串一个都不认识 → 标 unrecognized(decide 先就地重问)+ 单张转人卡兜底', () => {
|
||||
const turn = normalizeTurn({ actions: [{ kind: '胡编的' }] }, ENABLED);
|
||||
expect(turn.actions).toHaveLength(1);
|
||||
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 个 —— 引擎侧还会再守一道', () => {
|
||||
const many = Array.from({ length: 9 }, (_, i) => ({ kind: 'finish', itemId: `T${i}` }));
|
||||
expect(normalizeTurn({ actions: many }, ENABLED).actions).toHaveLength(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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 绑台账的可编辑表 —— **行由基座铺,不由活动塞**。
|
||||
*
|
||||
* 人要在卡上改的是那份清单本身(PRD 的执行页就是整份逐格可改)。
|
||||
* 让活动把整份塞进回复里有两处死结,两处都实测栽过:
|
||||
* · 回复有长度上限,铺长了半途截断,整拍作废;
|
||||
* · 那些行台账里已经有了,再抄一遍是拿 token 换一次抄错的机会
|
||||
* (抄的时候把 BOM 原样的 30 填了进去,而台账里已经是 30000)。
|
||||
*
|
||||
* 这里测的是那段铺行的纯逻辑:同一份实现在 `engines.ts` 的 `fillBoundTables`。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
type Draft = { domain: string; object: string; key: Record<string, unknown>; patch: Record<string, unknown> };
|
||||
|
||||
/** 与 `engines.ts` 的 `fillBoundTables` 同一口径 —— 改一处这里要跟着改 */
|
||||
function fill(drafts: readonly Draft[], blocks: readonly unknown[]): unknown[] {
|
||||
return blocks.map((block) => {
|
||||
if (typeof block !== 'object' || block === null) return block;
|
||||
const b = block as Record<string, unknown>;
|
||||
if (b['t'] !== 'table' || typeof b['ledger'] !== 'string' || !Array.isArray(b['fields'])) return block;
|
||||
const address = b['ledger'];
|
||||
const fields = (b['fields'] as unknown[]).map((f) => String(f));
|
||||
const rows = drafts
|
||||
.filter((d) => `${d.domain}.${d.object}` === address)
|
||||
.map((d) => {
|
||||
const row = { ...d.key, ...d.patch } as Record<string, unknown>;
|
||||
return fields.map((f) => {
|
||||
const v = row[f];
|
||||
return v === null || v === undefined ? '' : String(v);
|
||||
});
|
||||
});
|
||||
return rows.length === 0 ? block : { ...b, rows };
|
||||
});
|
||||
}
|
||||
|
||||
const DRAFTS: Draft[] = [
|
||||
{ domain: 'quote_intake', object: 'record', key: { pn: 'LM358DR' }, patch: { eau: 12000, brand: 'TI' } },
|
||||
{ domain: 'quote_intake', object: 'record', key: { pn: 'NE555P' }, patch: { eau: 5000, brand: '' } },
|
||||
{ domain: 'quote_intake', object: 'verdict', key: { rfq_ref: 'R1' }, patch: { line_total: 2 } },
|
||||
];
|
||||
|
||||
describe('绑台账的表', () => {
|
||||
const block = {
|
||||
t: 'table',
|
||||
title: '有效 PN 清单',
|
||||
editable: true,
|
||||
ledger: 'quote_intake.record',
|
||||
cols: ['PN', '年用量', '制造商'],
|
||||
fields: ['pn', 'eau', 'brand'],
|
||||
};
|
||||
|
||||
it('**整份铺出来** —— 一条料一行,主键也在(它是对行的钥匙)', () => {
|
||||
const [out] = fill(DRAFTS, [block]) as [Record<string, unknown>];
|
||||
expect(out['rows']).toEqual([
|
||||
['LM358DR', '12000', 'TI'],
|
||||
['NE555P', '5000', ''],
|
||||
]);
|
||||
});
|
||||
|
||||
it('只铺这张表的行 —— 别的对象不混进来', () => {
|
||||
const [out] = fill(DRAFTS, [{ ...block, ledger: 'quote_intake.verdict', cols: ['条目数'], fields: ['line_total'] }]) as [
|
||||
Record<string, unknown>,
|
||||
];
|
||||
expect(out['rows']).toEqual([['2']]);
|
||||
});
|
||||
|
||||
it('空值铺成空字符串 —— 界面自己标红「待填写」,不写「无」这种话', () => {
|
||||
const [out] = fill(DRAFTS, [block]) as [Record<string, unknown>];
|
||||
expect((out['rows'] as string[][])[1]?.[2]).toBe('');
|
||||
});
|
||||
|
||||
it('这一单还没写过那张表 → 原样留着活动给的行,不把卡弄成空表', () => {
|
||||
const given = { ...block, ledger: 'quote_intake.alt_request', rows: [['X', '1', 'Y']] };
|
||||
expect(fill(DRAFTS, [given])).toEqual([given]);
|
||||
});
|
||||
|
||||
it('没绑 ledger 的表一个字不动 —— 老卡照旧', () => {
|
||||
const plain = { t: 'table', cols: ['A'], rows: [['1']], editable: true };
|
||||
expect(fill(DRAFTS, [plain])).toEqual([plain]);
|
||||
});
|
||||
|
||||
it('不是表的块原样透过', () => {
|
||||
const note = { t: 'callout', text: '看这里' };
|
||||
expect(fill(DRAFTS, [note])).toEqual([note]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* 台账列的归型 —— **最坏的失败点是「人签完字之后」**。
|
||||
*
|
||||
* 实测踩过:模型用 `""` 表示「原文没给目标价」(业务判断完全正确),草稿层照单全收;
|
||||
* 到 `releaseWrite` 往真表里写时 PG 掀桌 ——
|
||||
* `invalid input syntax for type double precision: ""` —— 而那已经是人签完字之后,
|
||||
* 整单落成 `blocked`。前面全对,最后一步炸,这种失败最伤人。
|
||||
*
|
||||
* 所以归型放在写进草稿那一刻:归得了就归,归不了当场拒(理由喂回下一拍)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { coerceToColumn, localStamp, missingReadings, rowReadings, rowKeyList, tallyReadings, toolCallReadings } from '../wiring/engines.ts';
|
||||
|
||||
describe('空值 = 这一列没有值(不写)', () => {
|
||||
it('空串 / 全空白 / null / undefined 一律不写 —— 「原文没写的不要补」的数据面落点', () => {
|
||||
for (const v of ['', ' ', null, undefined]) {
|
||||
expect(coerceToColumn(v, 'number').kind, JSON.stringify(v)).toBe('empty');
|
||||
expect(coerceToColumn(v, 'text').kind, JSON.stringify(v)).toBe('empty');
|
||||
}
|
||||
});
|
||||
|
||||
it('0 与 false 是**有值**,不能被当成空 —— 这两个值最容易被顺手丢掉', () => {
|
||||
expect(coerceToColumn(0, 'number')).toEqual({ kind: 'value', value: 0 });
|
||||
expect(coerceToColumn('0', 'integer')).toEqual({ kind: 'value', value: 0 });
|
||||
expect(coerceToColumn(false, 'boolean')).toEqual({ kind: 'value', value: false });
|
||||
expect(coerceToColumn('false', 'boolean')).toEqual({ kind: 'value', value: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('数值列', () => {
|
||||
it('数字字符串转成数字 —— 模型给 "12000" 是常态', () => {
|
||||
expect(coerceToColumn('12000', 'integer')).toEqual({ kind: 'value', value: 12000 });
|
||||
expect(coerceToColumn('8.4', 'number')).toEqual({ kind: 'value', value: 8.4 });
|
||||
});
|
||||
|
||||
it('不是数的当场拒 —— 拒在写入,不拖到放行', () => {
|
||||
expect(coerceToColumn('待定', 'number').kind).toBe('reject');
|
||||
expect(coerceToColumn({ a: 1 }, 'number').kind).toBe('reject');
|
||||
expect(coerceToColumn(Number.NaN, 'number').kind).toBe('reject');
|
||||
});
|
||||
});
|
||||
|
||||
describe('布尔列', () => {
|
||||
it('"true"/"false" 转真布尔', () => {
|
||||
expect(coerceToColumn('true', 'boolean')).toEqual({ kind: 'value', value: true });
|
||||
});
|
||||
|
||||
it('模棱两可的字样当场拒 —— "是"/"1" 各人理解不同,不替它猜', () => {
|
||||
expect(coerceToColumn('是', 'boolean').kind).toBe('reject');
|
||||
expect(coerceToColumn(1, 'boolean').kind).toBe('reject');
|
||||
});
|
||||
});
|
||||
|
||||
describe('文本与 json 列', () => {
|
||||
it('text 收字符串;对象序列化 —— 比把 [object Object] 写进库强', () => {
|
||||
expect(coerceToColumn('8周', 'text')).toEqual({ kind: 'value', value: '8周' });
|
||||
expect(coerceToColumn({ a: 1 }, 'text')).toEqual({ kind: 'value', value: '{"a":1}' });
|
||||
expect(coerceToColumn(12, 'text')).toEqual({ kind: 'value', value: '12' });
|
||||
});
|
||||
|
||||
it('json 列什么都装得下,不归型', () => {
|
||||
expect(coerceToColumn({ rows: [1, 2] }, 'json')).toEqual({ kind: 'value', value: { rows: [1, 2] } });
|
||||
});
|
||||
|
||||
it('日期列按文本走(PG 自己解析格式)', () => {
|
||||
expect(coerceToColumn('2026-07-30', 'date')).toEqual({ kind: 'value', value: '2026-07-30' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('回归:那一单为什么会 blocked', () => {
|
||||
it('NE555P 没给目标价 → 这一列不写(列留 NULL),而不是把 "" 塞进 numeric', () => {
|
||||
const patch = { pn: 'NE555P', eau: '5000', target_price: '', lead_time: '6周' };
|
||||
const types: Record<string, string> = { pn: 'text', eau: 'integer', target_price: 'number', lead_time: 'text' };
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
const c = coerceToColumn(v, types[k] ?? 'text');
|
||||
if (c.kind === 'value') out[k] = c.value;
|
||||
}
|
||||
expect(out).toEqual({ pn: 'NE555P', eau: 5000, lead_time: '6周' });
|
||||
expect('target_price' in out).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 行数读数 —— **判据的另一边不能也由活动自报**。
|
||||
*
|
||||
* 实测的一次「对账过关而账是错的」:活动把条目数记成 9,而受理记录里躺着 10 行
|
||||
* (一条 PN 抄漏了后缀,补写时成了新的一行);三类条数与派单数同样自报同错,
|
||||
* 于是 `2 = 2` 顺利过关,人签完字,下游拿到一份多一行的交付物。
|
||||
*/
|
||||
describe('行数读数:域_对象_rows', () => {
|
||||
it('按 mine: 那一份数行,名字换成 S-FEEL 认得的下划线', () => {
|
||||
expect(
|
||||
rowReadings({
|
||||
'mine:quote_intake.record': [{}, {}, {}],
|
||||
'mine:quote_intake.verdict': [{}],
|
||||
}),
|
||||
).toEqual({ quote_intake_record_rows: 3, quote_intake_verdict_rows: 1 });
|
||||
});
|
||||
|
||||
it('不是 mine: 的键一概不数 —— 别单放行过的正式记录不算本单的行', () => {
|
||||
expect(rowReadings({ 'quote_intake.record': [{}, {}] })).toEqual({});
|
||||
});
|
||||
|
||||
/*
|
||||
* **一行没写也是一个读数:0**(2026-08-01,全 A 类那条路逼出来的)。
|
||||
*
|
||||
* 从前只数「写过的表」:一单没有 C 类,替代评估请求那张表一行都没写,
|
||||
* 于是 `quote_intake_alt_request_rows` 整个键不存在;而判据是
|
||||
* `quote_intake_alt_request_rows = type_c_total`(0 = 0 本该成立)——
|
||||
* 未绑定的名字参与比较求值为假,这条判据**永远过不去**,模型连撞五拍熔断,
|
||||
* 而数据本身完全合规。「没写过」与「写了 0 行」在业务上是同一件事。
|
||||
*/
|
||||
it('声明过但一行没写的表读数是 0 —— 零条也是一个数,判据得算得下去', () => {
|
||||
const declared = [
|
||||
{ domain: 'quote_intake', object: 'record' },
|
||||
{ domain: 'quote_intake', object: 'alt_request' },
|
||||
];
|
||||
expect(rowReadings({ 'mine:quote_intake.record': [{}, {}] }, declared)).toEqual({
|
||||
quote_intake_record_rows: 2,
|
||||
quote_intake_alt_request_rows: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('没给声明就还是只数写过的 —— 铺 0 的名单来自活动的台账声明', () => {
|
||||
expect(rowReadings({})).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 时刻摆到屏上要**本地的**。
|
||||
*
|
||||
* 直接切 ISO 串那八个字符,屏上写着 `07-31 20:16` 而人这边是 `08-01 04:16` ——
|
||||
* 差八小时的时刻比没有时刻更坏:它看起来像一个可信的事实(实测:来件那一行就这么错过一次)。
|
||||
*/
|
||||
describe('本地时刻', () => {
|
||||
it('UTC 串按本地时区落到「月-日 时:分」', () => {
|
||||
const iso = '2026-07-31T20:16:00.000Z';
|
||||
const at = new Date(iso);
|
||||
const two = (n: number): string => String(n).padStart(2, '0');
|
||||
expect(localStamp(iso)).toBe(`${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`);
|
||||
});
|
||||
|
||||
it('不是时刻就留空 —— 宁可没有,不摆一个假的', () => {
|
||||
expect(localStamp('这不是时刻')).toBe('');
|
||||
});
|
||||
|
||||
it('归零到分 —— 秒不进报告(同一单的两份件不该看起来不一样)', () => {
|
||||
expect(localStamp('2026-08-01T04:16:59.999Z')).toBe(localStamp('2026-08-01T04:16:01.000Z'));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 主键名单 —— **行数对不上时,「是哪几行」只有基座数得出来**。
|
||||
*
|
||||
* 判据比的是两张表的行数;模型看得见「10 ≠ 9」,却不知道多的是哪一行。
|
||||
* 名单一摆就是一句话能办的事:多的那个 PN 就在名单里对着。
|
||||
* 摆的是主键不是整行 —— 认一行只需要它的主键,整行铺出来会把拒回理由撑爆。
|
||||
*/
|
||||
describe('主键名单:域_对象_keys', () => {
|
||||
const DECL = [
|
||||
{
|
||||
domain: 'quote_intake',
|
||||
object: 'record',
|
||||
fields: [
|
||||
{ name: 'rfq_ref', primaryKey: true },
|
||||
{ name: 'pn', primaryKey: true },
|
||||
{ name: 'eau', primaryKey: false },
|
||||
],
|
||||
},
|
||||
{ domain: 'quote_intake', object: 'alt_request', fields: [{ name: 'pn', primaryKey: true }] },
|
||||
];
|
||||
|
||||
/*
|
||||
* **名单要能直接照抄进 `drop` 的 keys** —— 所以摆 JSON,不摆 `A/B` 拼串。
|
||||
*
|
||||
* 实测栽过:名单写作 `RFQ MD14.1/XTSD04GLGEAQG-I`,模型得自己猜第一段是哪一列;
|
||||
* 列名它猜对了,值却把空格写成了横杠(`RFQ-MD14.1` 是更常见的写法)——
|
||||
* 连撤三次都撤不到,而那一行就在名单里。主键是认行的钥匙,一个字符都不能差。
|
||||
*/
|
||||
it('主键摆成 JSON —— 列名带着值,模型原样粘进 drop 就行', () => {
|
||||
expect(
|
||||
rowKeyList({ 'mine:quote_intake.record': [{ rfq_ref: 'RFQ MD14.1', pn: 'LM358DR', eau: 30000 }] }, DECL),
|
||||
).toEqual({ quote_intake_record_keys: '{"rfq_ref":"RFQ MD14.1","pn":"LM358DR"}' });
|
||||
});
|
||||
|
||||
it('值里的空格原样留着 —— 转写一次就撤不到那一行了', () => {
|
||||
const out = rowKeyList({ 'mine:quote_intake.alt_request': [{ pn: 'UMW AO6604' }] }, DECL);
|
||||
expect(out['quote_intake_alt_request_keys']).toBe('{"pn":"UMW AO6604"}');
|
||||
});
|
||||
|
||||
it('一行没写的表不出名单 —— 空名单比没有更像坏了', () => {
|
||||
expect(rowKeyList({ 'mine:quote_intake.record': [] }, DECL)).toEqual({});
|
||||
expect(rowKeyList({}, DECL)).toEqual({});
|
||||
});
|
||||
|
||||
it('**封顶 12 行**,超出的如实说还有几行 —— 再多就是把台账抄了一遍', () => {
|
||||
const rows = Array.from({ length: 15 }, (_, i) => ({ pn: `P${i}` }));
|
||||
const out = rowKeyList({ 'mine:quote_intake.alt_request': rows }, DECL);
|
||||
expect(out['quote_intake_alt_request_keys']).toContain('{"pn":"P0"}');
|
||||
expect(out['quote_intake_alt_request_keys']).toContain('(还有 3 行)');
|
||||
expect(out['quote_intake_alt_request_keys']).not.toContain('"P12"');
|
||||
});
|
||||
|
||||
it('主键那一格空着就不摆那一格 —— 摆一个 "" 会让模型照抄一个撤不到的键', () => {
|
||||
expect(rowKeyList({ 'mine:quote_intake.record': [{ rfq_ref: '', pn: 'X' }] }, DECL)).toEqual({
|
||||
quote_intake_record_keys: '{"pn":"X"}',
|
||||
});
|
||||
});
|
||||
|
||||
it('整行主键都空 → 那一行不进名单,一行都没有就不出这个读数', () => {
|
||||
expect(rowKeyList({ 'mine:quote_intake.alt_request': [{ pn: '' }] }, DECL)).toEqual({});
|
||||
});
|
||||
|
||||
it('没有主键声明的表不出名单 —— 认不出「一行是谁」就别硬凑', () => {
|
||||
const noKey = [{ domain: 'd', object: 'o', fields: [{ name: 'x', primaryKey: false }] }];
|
||||
expect(rowKeyList({ 'mine:d.o': [{ x: 1 }] }, noKey)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 缺必填读数 —— **让机检判据读得到基座数出来的数**。
|
||||
*
|
||||
* A1 的「必填齐(PN / 年用量)」是 L1 机检,从前读的却是活动自己写的 `missing_key_total`:
|
||||
* 人在卡上补了年用量、基座已经写回草稿层,而那个汇总列还要模型自己重算 ——
|
||||
* 它漏算一次这条判据就永远绿不了(连跑十轮里三轮死在这,每次连拒五拍熔断)。
|
||||
* **活动自报的数不是门,是一句「改哪个数能过」的提示。**
|
||||
*/
|
||||
describe('缺必填读数:域_对象_missing', () => {
|
||||
const DECL = [
|
||||
{
|
||||
domain: 'quote_intake',
|
||||
object: 'record',
|
||||
fields: [
|
||||
{ name: 'pn', primaryKey: true, required: false },
|
||||
{ name: 'eau', primaryKey: false, required: true, label: '年用量' },
|
||||
// 选填但缺了要问一句 —— 它进另一组读数 `_gaps`
|
||||
{ name: 'brand', primaryKey: false, expect: true, label: '制造商' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
it('缺必填的行数由基座现数,并说清缺在哪几行、缺什么', () => {
|
||||
const out = missingReadings(
|
||||
{ 'mine:quote_intake.record': [{ pn: 'A', eau: 30000 }, { pn: 'B', eau: '' }, { pn: 'C' }] },
|
||||
DECL,
|
||||
);
|
||||
expect(out['quote_intake_record_missing']).toBe(2);
|
||||
// 缺口摆**中文列名**:这个数会进拒回与卡面,给人看的地方不摆列名
|
||||
expect(out['quote_intake_record_missing_keys']).toBe('B 缺年用量;C 缺年用量');
|
||||
});
|
||||
|
||||
it('**补齐了就归零** —— 人在卡上填回来那一刻,判据自己就绿了', () => {
|
||||
const out = missingReadings({ 'mine:quote_intake.record': [{ pn: 'A', eau: 30000 }] }, DECL);
|
||||
expect(out['quote_intake_record_missing']).toBe(0);
|
||||
expect('quote_intake_record_missing_keys' in out).toBe(false);
|
||||
});
|
||||
|
||||
it('一行没写也是 0 —— 零条也是一个数(与 `_rows` 铺 0 同一口径)', () => {
|
||||
// 两组都铺 0:必填与选填各算各的
|
||||
expect(missingReadings({}, DECL)).toEqual({ quote_intake_record_missing: 0, quote_intake_record_gaps: 0 });
|
||||
});
|
||||
|
||||
/*
|
||||
* **选填缺口另算一组**(2026-08-02 用户实测:「补了目标价为啥 L4 还在那乱判」)。
|
||||
* 必填缺一条都不许放行;选填缺不拦单,但那一问不能省 —— 两件事两个数。
|
||||
* 从前选填那个数由活动自报,人补齐了它也不会变,于是 L4 照旧裁「不满足」。
|
||||
*/
|
||||
it('选填缺口进 `_gaps`,与必填的 `_missing` 各算各的', () => {
|
||||
const rows = [{ pn: 'A', eau: 1, brand: '' }, { pn: 'B', eau: '', brand: 'X' }];
|
||||
const out = missingReadings({ 'mine:quote_intake.record': rows }, DECL);
|
||||
expect(out['quote_intake_record_missing']).toBe(1);
|
||||
expect(out['quote_intake_record_gaps']).toBe(1);
|
||||
expect(out['quote_intake_record_gaps_keys']).toBe('A 缺制造商');
|
||||
});
|
||||
|
||||
it('**人补齐那一格,这个数当场归零** —— 模型改不动它,所以裁得准', () => {
|
||||
const out = missingReadings({ 'mine:quote_intake.record': [{ pn: 'A', eau: 1, brand: 'X' }] }, DECL);
|
||||
expect(out['quote_intake_record_gaps']).toBe(0);
|
||||
expect('quote_intake_record_gaps_keys' in out).toBe(false);
|
||||
});
|
||||
|
||||
it('没有一列标 required 的表不出这个读数 —— 没声明就不该假装数得出来', () => {
|
||||
const noReq = [{ domain: 'd', object: 'o', fields: [{ name: 'x', primaryKey: true }] }];
|
||||
expect(missingReadings({ 'mine:d.o': [{}] }, noReq)).toEqual({});
|
||||
});
|
||||
|
||||
it('空白串按缺算 —— 与写入归型那一侧的空值口径一致', () => {
|
||||
const out = missingReadings({ 'mine:quote_intake.record': [{ pn: 'A', eau: ' ' }] }, DECL);
|
||||
expect(out['quote_intake_record_missing']).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 按值现数 —— **手工修改的全部意义就是纠正 AI 的判断**。
|
||||
*
|
||||
* 三分类条数从前是活动自己写的 `type_a_total` 之流,而分布条、放行时的去向谓词、
|
||||
* 验收判据读的全是它们。于是人在放行页把九条料全改成 A:类徽变了,
|
||||
* 分布条还是 5/3/1、去向还是「去选型」—— 逐行数据变了,汇总数没人重算。
|
||||
* **改了类标而流向不跟着变,那个兜底功能就是假的。**
|
||||
*/
|
||||
describe('按值现数:域_对象_列_值', () => {
|
||||
const DECL = [
|
||||
{
|
||||
domain: 'quote_intake',
|
||||
object: 'record',
|
||||
fields: [{ name: 'pn' }, { name: 'type_tag', tally: ['A', 'B', 'C'] }],
|
||||
},
|
||||
];
|
||||
|
||||
it('照取值域逐个数,值消毒成小写', () => {
|
||||
const rows = [{ type_tag: 'A' }, { type_tag: 'A' }, { type_tag: 'B' }, { type_tag: 'C' }];
|
||||
expect(tallyReadings({ 'mine:quote_intake.record': rows }, DECL)).toEqual({
|
||||
quote_intake_record_type_tag_a: 2,
|
||||
quote_intake_record_type_tag_b: 1,
|
||||
quote_intake_record_type_tag_c: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('**人全改成 A → C 类归零**,去向谓词 `…_type_tag_c > 0` 当场变假', () => {
|
||||
const rows = [{ type_tag: 'A' }, { type_tag: 'A' }, { type_tag: 'A' }];
|
||||
const out = tallyReadings({ 'mine:quote_intake.record': rows }, DECL);
|
||||
expect(out['quote_intake_record_type_tag_a']).toBe(3);
|
||||
expect(out['quote_intake_record_type_tag_c']).toBe(0);
|
||||
});
|
||||
|
||||
it('没出现的值也铺 0 —— 零条也是一个数,判据才算得下去', () => {
|
||||
expect(tallyReadings({ 'mine:quote_intake.record': [] }, DECL)).toEqual({
|
||||
quote_intake_record_type_tag_a: 0,
|
||||
quote_intake_record_type_tag_b: 0,
|
||||
quote_intake_record_type_tag_c: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('没声明取值域的列不出读数 —— 数不出取值域就别假装数得全', () => {
|
||||
expect(tallyReadings({ 'mine:d.o': [{ x: 'A' }] }, [{ domain: 'd', object: 'o', fields: [{ name: 'x' }] }])).toEqual({});
|
||||
});
|
||||
|
||||
it('值两边的空白不算数上的差别', () => {
|
||||
expect(
|
||||
tallyReadings({ 'mine:quote_intake.record': [{ type_tag: ' A ' }] }, DECL)['quote_intake_record_type_tag_a'],
|
||||
).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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* **后端发生的事,前端得自己知道** —— 广播面的回归。
|
||||
*
|
||||
* 用户实测报的:任务列表、消息气泡、工作区、对话区都不自己动,得手点刷新。
|
||||
* 前端那一侧是好的(单条 SSE + 按前缀失效缓存),缺的全在后端 —— 它压根没说。
|
||||
*
|
||||
* 三处各有各的成因,所以分三段守:
|
||||
* ① 推进器只在**状态换名字**时广播,而案例活动连跑十几拍全程都是 `running`;
|
||||
* ② `interaction` 的推送(AI 发言、换画面)一个订阅者都没有,全广播给了空气;
|
||||
* ③ 发消息不经事件投递(那条路才有广播),于是收件人的消息气泡不动。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createBroadcaster } from '../broadcast.ts';
|
||||
import { worthBroadcasting } from '../workers/task-advancer.ts';
|
||||
|
||||
describe('① 推进器:推动了就说一声', () => {
|
||||
const running = { runtime: 'running' };
|
||||
|
||||
it('状态没换名字但这一拍干了事 → 广播', () => {
|
||||
// 案例活动的常态:连跑十几拍,runtime 一直是 running,而台账/对话/画面每拍都在变
|
||||
expect(worthBroadcasting(running, { progressed: true, task: { runtime: 'running' } })).toBe(true);
|
||||
});
|
||||
|
||||
it('空拍不广播 —— 不给在线浏览器发噪声', () => {
|
||||
expect(worthBroadcasting(running, { progressed: false, task: { runtime: 'running' } })).toBe(false);
|
||||
});
|
||||
|
||||
it('挂起去等人:没推动但状态换了 → 广播', () => {
|
||||
expect(worthBroadcasting(running, { progressed: false, task: { runtime: 'awaiting-human' } })).toBe(true);
|
||||
});
|
||||
|
||||
it('走到终态 → 广播(「办完的活自己挪到已完成」靠它)', () => {
|
||||
expect(
|
||||
worthBroadcasting(running, { progressed: false, task: { runtime: 'terminal', terminal: 'released' } }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('第一次见到这个任务(没有前态)→ 广播', () => {
|
||||
expect(worthBroadcasting(undefined, { progressed: false, task: { runtime: 'running' } })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('广播器:一个订阅者出错不牵连别人', () => {
|
||||
it('抛错的订阅者被摘掉,其余照收', () => {
|
||||
const b = createBroadcaster();
|
||||
const got: string[] = [];
|
||||
b.subscribe(() => {
|
||||
throw new Error('这个订阅者坏了');
|
||||
});
|
||||
b.subscribe((t) => got.push(t.reason));
|
||||
b.publish({ kind: 'task', id: 't1', reason: 'advanced' });
|
||||
b.publish({ kind: 'task', id: 't1', reason: 'again' });
|
||||
expect(got).toEqual(['advanced', 'again']);
|
||||
expect(b.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* ④ 收尾这一下动的不只是任务(2026-07-30)。
|
||||
*
|
||||
* 放行那一刻草稿合并进正式版、动作意向落地、输出口发射 —— 数据域多出真行、
|
||||
* 驾驶舱的完成数变了。那几屏的键都在 `gov` 下、不以任务为轴,只按任务广播
|
||||
* 它们一个也收不到(实测:一单放行完,数据域里还是空的,直到手动刷新)。
|
||||
*/
|
||||
describe('④ 终态要额外发一次 system —— 台账与指标不以任务为轴', () => {
|
||||
it('走到终态时,除任务/流之外还有一条全局推送', () => {
|
||||
const b = createBroadcaster();
|
||||
const kinds: string[] = [];
|
||||
b.subscribe((t) => kinds.push(t.kind));
|
||||
|
||||
// 照 task-advancer 的顺序重放一次「这一拍走到了终态」
|
||||
const outcome = { progressed: true, task: { runtime: 'terminal', terminal: 'released', workflowId: 'W1' } };
|
||||
if (worthBroadcasting({ runtime: 'running' }, outcome)) {
|
||||
b.publish({ kind: 'task', id: 'T1', reason: 'advanced' });
|
||||
if (outcome.task.workflowId !== undefined) b.publish({ kind: 'workflow', id: 'W1', reason: 'task-advanced' });
|
||||
if (outcome.task.terminal !== undefined) b.publish({ kind: 'system', id: 'terminal', reason: outcome.task.terminal });
|
||||
}
|
||||
expect(kinds).toEqual(['task', 'workflow', 'system']);
|
||||
});
|
||||
|
||||
it('没到终态就不发 system —— 每拍都发等于让治理台整屏重取', () => {
|
||||
const b = createBroadcaster();
|
||||
const kinds: string[] = [];
|
||||
b.subscribe((t) => kinds.push(t.kind));
|
||||
const outcome = { progressed: true, task: { runtime: 'running' as string, terminal: undefined } };
|
||||
b.publish({ kind: 'task', id: 'T1', reason: 'advanced' });
|
||||
if (outcome.task.terminal !== undefined) b.publish({ kind: 'system', id: 'terminal', reason: 'x' });
|
||||
expect(kinds).toEqual(['task']);
|
||||
});
|
||||
});
|
||||
@@ -1,61 +1,67 @@
|
||||
/**
|
||||
* 四域十九对象 —— 声明即物化的输入。守三件事:数量与域别齐、主键齐、按名可取。
|
||||
* 主数据四域 —— 客户 / 项目 / 供应商 / 市场。
|
||||
*
|
||||
* 这份测试从前断言的是「四域十九对象」,那个四域是 project / customer / intel / supplier,
|
||||
* 装的是**退役占位槽**的声明,里面混着一单一份的流转单据(受理记录、比价表、报价草案)。
|
||||
* 2026-07-31 重分:单据归各活动自己的域,这个文件只留**主数据**,按常规 CRM / ERP 分四域。
|
||||
*
|
||||
* 守的是三条,每条都能机检:
|
||||
* · 只有这四个域,不多不少 —— 多一个就说明有人把单据塞进主数据了;
|
||||
* · 每张表都是 `master` 且**不带单标识** —— 带了就是单据归错类(安装静态审也会拦);
|
||||
* · 主键是**业务实体**,不是单。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MVP_OBJECTS, declarationsFor } from '../seed-mvp-data.ts';
|
||||
import { MASTER_DOMAINS, MASTER_OBJECTS } from '../seed-mvp-data.ts';
|
||||
|
||||
describe('MVP_OBJECTS', () => {
|
||||
it('四域十九对象,不多不少 —— 定价审批表载体是审批流,不入台账', () => {
|
||||
expect(MVP_OBJECTS).toHaveLength(19);
|
||||
const count = (domain: string) => MVP_OBJECTS.filter((o) => o.domain === domain).length;
|
||||
expect(count('project')).toBe(13);
|
||||
expect(count('customer')).toBe(1);
|
||||
expect(count('intel')).toBe(3);
|
||||
expect(count('supplier')).toBe(2);
|
||||
describe('主数据四域', () => {
|
||||
it('只有客户 / 项目 / 供应商 / 市场四个域', () => {
|
||||
expect([...MASTER_DOMAINS]).toEqual(['customer', 'project', 'supplier', 'market']);
|
||||
expect([...new Set(MASTER_OBJECTS.map((o) => o.domain))].sort()).toEqual([...MASTER_DOMAINS].sort());
|
||||
});
|
||||
|
||||
it('每个对象至少一个主键字段 —— 只增不破坏靠主键立身', () => {
|
||||
for (const o of MVP_OBJECTS) {
|
||||
expect(o.fields.some((f) => f.primaryKey), `${o.domain}.${o.object}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('每个字段都有中文名 —— 界面上不许只见键不见名', () => {
|
||||
for (const o of MVP_OBJECTS) {
|
||||
expect(o.label, `${o.domain}.${o.object} 缺对象名`).toBeTruthy();
|
||||
for (const f of o.fields) {
|
||||
expect(f.label, `${o.domain}.${o.object}.${f.name} 缺字段名`).toBeTruthy();
|
||||
}
|
||||
it('四个域各自都有表 —— 空占位也得立得住', () => {
|
||||
for (const d of MASTER_DOMAINS) {
|
||||
expect(MASTER_OBJECTS.filter((o) => o.domain === d).length, `${d} 域一张表都没有`).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* 受理结论是**一单一行**,主键只有询价单号。
|
||||
*
|
||||
* 这条断言守的是一个会静默出错的设计:出口的标量字段取自 `landed.flat`,
|
||||
* 而 `flat` 是把这一单所有落地行 reduce 展开成一个平对象。两枚去向旗若挂在
|
||||
* 按料号分行的对象上,六个料号就有六行同名列、最后合并的那行赢 ——
|
||||
* 而归并规则是「任一条命中 C 即需选型」,网关会照着错值分叉且无人察觉。
|
||||
* 这一条是分类的要害:主数据按**业务实体**分行,不按单。
|
||||
* 带上单标识就说明它其实是一单一份的单据 —— 归错类,治理方式全跟着错。
|
||||
*/
|
||||
it('受理结论一单一行 —— 去向旗不能挂在按料号分行的对象上', () => {
|
||||
const verdict = MVP_OBJECTS.find((o) => o.object === 'intake_verdict');
|
||||
expect(verdict, '缺 project.intake_verdict').toBeDefined();
|
||||
expect(verdict?.domain).toBe('project');
|
||||
expect(verdict?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['rfq_ref']);
|
||||
for (const flag of ['need_alternative', 'need_sourcing']) {
|
||||
expect(verdict?.fields.find((f) => f.name === flag)?.type, flag).toBe('boolean');
|
||||
}
|
||||
// 反过来也要守:按料号分行的对象上不许出现去向旗
|
||||
for (const o of MVP_OBJECTS) {
|
||||
if (o.object === 'intake_verdict') continue;
|
||||
const names = o.fields.map((f) => f.name);
|
||||
expect(names, `${o.domain}.${o.object} 不该带去向旗`).not.toContain('need_alternative');
|
||||
expect(names, `${o.domain}.${o.object} 不该带去向旗`).not.toContain('need_sourcing');
|
||||
it('每张表都是 master 且不带单标识', () => {
|
||||
for (const o of MASTER_OBJECTS) {
|
||||
expect(o.kind, `${o.domain}.${o.object}`).toBe('master');
|
||||
expect(o.caseKey, `${o.domain}.${o.object} 不该有单标识`).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('declarationsFor 按名取子集,名不存在即抛', () => {
|
||||
expect(declarationsFor(['project.intake_record', 'intel.history_price'])).toHaveLength(2);
|
||||
expect(() => declarationsFor(['project.nope'])).toThrow('未知对象');
|
||||
it('每张表都有主键 —— 没主键的台账合并与去重无从谈起', () => {
|
||||
for (const o of MASTER_OBJECTS) {
|
||||
const pk = o.fields.filter((f) => f.primaryKey).map((f) => f.name);
|
||||
expect(pk.length, `${o.domain}.${o.object} 没有主键`).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
/* 主键得是业务实体键 —— 单标识那几个名字出现在主键里,就是归错了类 */
|
||||
it('主键里不出现单标识那几个名字', () => {
|
||||
for (const o of MASTER_OBJECTS) {
|
||||
const pk = o.fields.filter((f) => f.primaryKey).map((f) => f.name);
|
||||
for (const caseish of ['rfq_ref', 'quote_no', 'task_id']) {
|
||||
expect(pk, `${o.domain}.${o.object} 的主键里有单标识 ${caseish}`).not.toContain(caseish);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* 个人信息必须标脱敏 —— 主数据里存联系人,治理面照 `masked` 裁剪 */
|
||||
it('联系人的邮箱与电话标了脱敏', () => {
|
||||
const contacts = MASTER_OBJECTS.filter((x) => x.object === 'contact');
|
||||
expect(contacts.length).toBeGreaterThan(0);
|
||||
for (const o of contacts) {
|
||||
for (const name of ['email', 'phone']) {
|
||||
const field = o.fields.find((x) => x.name === name);
|
||||
expect(field?.masked, `${o.domain}.${o.object}.${name} 未标脱敏`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,73 +1,60 @@
|
||||
/**
|
||||
* 六个活动槽位 —— 契约就绪、实现留白。守:岗位/出口与 MVP 一致;
|
||||
* 声明只有名(工具按名引用、域对象按名取)—— 活动与工具的解耦就靠这一条。
|
||||
* A5 定价审批是**内置审批点**,零开发、不入库 —— 不在槽位之列。
|
||||
* 槽位退役名单 —— 六个占位槽必须一个不漏地被请出去。
|
||||
*
|
||||
* 这份测试从前断言的是「MVP_SLOTS 有 5 份占位清单、各自长什么样」——
|
||||
* 那批清单在 2026-07-31 全部退役:六个真活动包(`activities/quote-*`)转正后,
|
||||
* 占位清单成了会骗人的重影:
|
||||
* · 活动库与试跑台上并排两个同名活动,选错一个就试了个空壳;
|
||||
* · 数据域里 12 对同名台账(`project.intake_record` vs `quote_intake.record` …),
|
||||
* 人分不出该看哪一个 —— 而旧的那 19 张表一行数据都没有;
|
||||
* · 工具授权面各留一份,治理台的授权清单跟着翻倍。
|
||||
*
|
||||
* 所以断言的对象换了:不再是「占位清单长什么样」,而是**「它们都在退役名单里」**。
|
||||
* 名单漏一个,那个槽位下次播种又装回来,重影跟着回来。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MVP_SLOTS, SLOT_ARTIFACT } from '../seed-mvp-slots.ts';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
describe('MVP_SLOTS', () => {
|
||||
it('六槽位(A5 审批点内置,不入库)', () => {
|
||||
const ids = MVP_SLOTS.map((m) => m.identity.activityId as string);
|
||||
expect(MVP_SLOTS).toHaveLength(6);
|
||||
expect(ids).toEqual(
|
||||
expect.arrayContaining(['rfq-intake', 'alt-selection', 'sourcing-inquiry', 'cost-pricing', 'quote-negotiation', 'intel-archive']),
|
||||
);
|
||||
expect(ids).not.toContain('pricing-approval');
|
||||
const source = readFileSync(fileURLToPath(new URL('../seed-mvp-slots.ts', import.meta.url)), 'utf8');
|
||||
|
||||
/** 被真活动包顶替掉的六个槽位 —— 顺序即 SOP 次序 */
|
||||
const RETIRED = [
|
||||
'rfq-intake', // A1 → quote-intake
|
||||
'alt-selection', // A2 → quote-alternative
|
||||
'sourcing-inquiry', // A3 → quote-sourcing
|
||||
'cost-pricing', // A4 → quote-pricing
|
||||
'quote-negotiation', // A6 → quote-negotiate
|
||||
'intel-archive', // A7 → quote-archive
|
||||
] as const;
|
||||
|
||||
describe('槽位退役', () => {
|
||||
it('六个槽位一个不漏地在退役名单里', () => {
|
||||
for (const id of RETIRED) {
|
||||
expect(source, `${id} 不在退役名单里 —— 下次播种它会装回来`).toContain(`'${id}'`);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* 占位骨架的形状(2026-07-29 范式统一后):**案例模型**——
|
||||
* SOP 每件事一个人工任务 + 末尾一张收口验收。两条都是踩过坑才立的:
|
||||
* · 触达世界的项必须人工激活 —— 占位尤其不许替人自动做事
|
||||
* · 没有收口验收 → 活动永远不 release →「等人却没东西给人答」
|
||||
* 占位清单必须**真的删掉**,不是留在文件里不引用 ——
|
||||
* 留着它,下一个人很容易「顺手再装回去」,重影就回来了。
|
||||
*/
|
||||
it('占位骨架:案例模型双表示齐备,项全人工激活,末尾一张收口验收', () => {
|
||||
for (const m of MVP_SLOTS) {
|
||||
const model = m.assets.businessModel;
|
||||
const id = m.identity.activityId;
|
||||
expect(model.cmmnXml, `${id} 缺 CMMN`).toContain('<casePlanModel');
|
||||
expect(model.narrative.length, `${id} 缺说明书`).toBeGreaterThan(60);
|
||||
expect(model.cmmnXml, `${id} 缺收口验收`).toContain('id="T_release"');
|
||||
// 每个 humanTask 都带人工激活规则 —— 一个自动跑的占位项都不许有
|
||||
for (const task of model.cmmnXml.matchAll(/<humanTask id="([^"]+)"[\s\S]*?<\/humanTask>/g)) {
|
||||
expect(task[0], `${id} 的 ${task[1]} 应人工激活`).toContain('manualActivationRule');
|
||||
}
|
||||
// 占位不做业务判断,所以不带决策表
|
||||
expect(model.dmn.tables, `${id} 占位期不该有 DMN`).toHaveLength(0);
|
||||
}
|
||||
it('占位清单已从源码里移除', () => {
|
||||
expect(source).not.toContain('MVP_SLOTS');
|
||||
expect(source).not.toContain('SLOT_ARTIFACT');
|
||||
});
|
||||
|
||||
it('A1 出口带两枚去向旗;A2 透传 needSourcing —— 网关条件的数据来源', () => {
|
||||
const a1 = MVP_SLOTS.find((m) => m.identity.activityId === 'rfq-intake')!;
|
||||
const p1 = (a1.contract.output.mandatory[0]!.payload as { properties?: Record<string, unknown> }).properties ?? {};
|
||||
expect(Object.keys(p1)).toEqual(expect.arrayContaining(['needAlternative', 'needSourcing']));
|
||||
const a2 = MVP_SLOTS.find((m) => m.identity.activityId === 'alt-selection')!;
|
||||
const p2 = (a2.contract.output.mandatory[0]!.payload as { properties?: Record<string, unknown> }).properties ?? {};
|
||||
expect(Object.keys(p2)).toContain('needSourcing');
|
||||
it('播种只做退役这一件事 —— 不再装任何槽位', () => {
|
||||
expect(source).toContain('registry.uninstall');
|
||||
expect(source).not.toContain('registry.install(');
|
||||
});
|
||||
|
||||
/*
|
||||
* 三个去向是**选择组**,不是必交口里的一个字段:人在放行时恰选一口,
|
||||
* 那一口就是这次运行的 outcome(引擎按选口注入),G4 判的正是它。
|
||||
* 曾写成 formal 口的必填 outcome + selective: [] —— 引擎只在选择组恰发一口时
|
||||
* 才注入 outcome,没有选择组就结构上永远没有判据,G4 三条边全不成立(实测)。
|
||||
* 卸不掉要如实说、且不掀翻播种(多半是还有在途任务引着那个活动)。
|
||||
* 吞掉错误会让人以为清干净了,而治理台上那个重影还在。
|
||||
*/
|
||||
it('A6 三个去向是选择组 —— 放行恰选一口,选口即 G4 的判据', () => {
|
||||
const a6 = MVP_SLOTS.find((m) => m.identity.activityId === 'quote-negotiation')!;
|
||||
const groups = a6.contract.output.selective;
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0]!.group).toBe('议价结果');
|
||||
expect(groups[0]!.ports.map((p) => p.name)).toEqual(['won', 'revise', 'lost']);
|
||||
// outcome 不再是必交口的必填字段 —— 它由选口给出,不靠活动自己写
|
||||
const formal = a6.contract.output.mandatory[0]!.payload as { required?: string[] };
|
||||
expect(formal.required ?? []).not.toContain('outcome');
|
||||
});
|
||||
|
||||
it('槽位不携实现:入口是抛「未实现」的占位,工具声明只有名', () => {
|
||||
expect(SLOT_ARTIFACT).toContain('未实现');
|
||||
for (const m of MVP_SLOTS) {
|
||||
for (const t of m.declarations.tools) expect(typeof t.name).toBe('string');
|
||||
}
|
||||
it('卸不掉如实报出来,不掀翻播种', () => {
|
||||
expect(source).toContain('卸不掉');
|
||||
expect(source).toContain('catch');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 附件里的表先替模型拆好 —— **数格子是能算的事,不该问 LLM**。
|
||||
*
|
||||
* 实测同一行栽过两次:BOM 上白纸黑字写着「Антенна · 浙江嘉康 · (空) EAU」,
|
||||
* 模型拆出来是「集成电路 · (空) · 30000」—— 品类错了、制造商丢了、年用量凭空多一个。
|
||||
* 连着两个制表符是中间那一格空着,而它把右边的值往前挪了。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { attachmentPart, sheetRows, withSheetRows } from '../agent-runner.ts';
|
||||
|
||||
const BOM = [
|
||||
'Itelma Code\tPN\tКатегория\tПроизводитель\tAnalog\tСертификация\tEAU (k)\tTarget price',
|
||||
'4-02-01812\tTVM6C160K203R\tВаристор\tThinking Electronic\t\tAEC-Q200\t30\t',
|
||||
'9-00-00251\tDAG2400L321504XZB01-R0\tАнтенна\t浙江嘉康\t\t\t\t',
|
||||
].join('\n');
|
||||
|
||||
describe('制表符表 → 逐行对象', () => {
|
||||
it('键就是表头,空格子就是空串 —— 不往前挪', () => {
|
||||
const rows = sheetRows(BOM);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows?.[1]).toEqual({
|
||||
'Itelma Code': '9-00-00251',
|
||||
PN: 'DAG2400L321504XZB01-R0',
|
||||
'Категория': 'Антенна',
|
||||
'Производитель': '浙江嘉康',
|
||||
Analog: '',
|
||||
'Сертификация': '',
|
||||
'EAU (k)': '',
|
||||
'Target price': '',
|
||||
});
|
||||
});
|
||||
|
||||
it('**行尾连续制表符是末尾几列都空**,不是这一行到此为止', () => {
|
||||
const rows = sheetRows(BOM);
|
||||
expect(Object.keys(rows?.[1] ?? {})).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('列数不齐就不认这张表 —— 半张读歪的表比没有更坏', () => {
|
||||
expect(sheetRows('A\tB\tC\n1\t2')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('不成表的文本不认(只有表头 / 单列)', () => {
|
||||
expect(sheetRows('就一行字')).toBeUndefined();
|
||||
expect(sheetRows('A\tB')).toBeUndefined();
|
||||
expect(sheetRows('一列\n值1\n值2')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('表头有空列名就按位置补 —— 键不许是空串', () => {
|
||||
expect(sheetRows('A\t\nx\ty')?.[0]).toEqual({ A: 'x', 列2: 'y' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('附件旁边多给一份拆好的行', () => {
|
||||
it('读得出表就换成 rows —— **原文换下来**,两份并排会把这一段撑爆', () => {
|
||||
const out = withSheetRows({ attachments: [{ name: 'bom.xlsx', text: BOM }] });
|
||||
const one = (out['attachments'] as Array<Record<string, unknown>>)[0]!;
|
||||
expect(one['text']).toBeUndefined();
|
||||
expect((one['rows'] as unknown[]).length).toBe(2);
|
||||
// 换下来了要说一声,别让人以为附件读空了
|
||||
expect(String(one['sheet'])).toContain('已拆成 2 行');
|
||||
});
|
||||
|
||||
/*
|
||||
* 附件**单独成段**:它是这一单的料本身,混进按字符截的整表段里会被砍掉 ——
|
||||
* 实测加了 rows 之后三轮里那一行照错不误,因为它压根没进提示词。
|
||||
*/
|
||||
it('附件自成一段,不跟整表挤在一起', () => {
|
||||
const seg = attachmentPart({ attachments: [{ name: 'bom.xlsx', text: BOM }] });
|
||||
expect(seg).toContain('随件附件');
|
||||
expect(seg).toContain('DAG2400L321504XZB01-R0');
|
||||
});
|
||||
|
||||
it('没有附件就不摆这一段 —— 空标题只占位置', () => {
|
||||
expect(attachmentPart({})).toBe('');
|
||||
expect(attachmentPart({ attachments: [] })).toBe('');
|
||||
});
|
||||
|
||||
it('读不出表就一个字不动', () => {
|
||||
const scope = { attachments: [{ name: 'a.pdf', note: '这类文件读不出文本' }] };
|
||||
expect(withSheetRows(scope)).toEqual(scope);
|
||||
});
|
||||
|
||||
it('没有附件的单原样返回 —— 不平白多一个键', () => {
|
||||
const scope = { subject: 'x' };
|
||||
expect(withSheetRows(scope)).toBe(scope);
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,9 @@
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { StaffId, TaskId } from '@contracts';
|
||||
import { HUMAN_TOOL, LLM_TOOLS } from '@modules/toolbox';
|
||||
import { HUMAN_TOOL, LEDGER_TOOL, LLM_TOOLS } from '@modules/toolbox';
|
||||
import type { BusTool } from '@modules/toolbox';
|
||||
import { toolCatalog } from '../agent-runner.ts';
|
||||
import { buildMastraTools } from '../tool-bus.ts';
|
||||
import type { ToolBusPorts, ToolBusTrace } from '../tool-bus.ts';
|
||||
|
||||
@@ -38,10 +39,11 @@ type Calls = {
|
||||
cards: Array<{ workOrder: string; card: Record<string, unknown> }>;
|
||||
listens: Array<{ keyFilter: Record<string, string> }>;
|
||||
generated: Array<{ model: string; label: string; prompt: string }>;
|
||||
written: Array<{ address: string; rows: number }>;
|
||||
};
|
||||
|
||||
function harness(writeTools: string[] = ['mail.send']) {
|
||||
const calls: Calls = { invoked: [], intents: [], cards: [], listens: [], generated: [] };
|
||||
const calls: Calls = { invoked: [], intents: [], cards: [], listens: [], generated: [], written: [] };
|
||||
const ports: ToolBusPorts = {
|
||||
invoke: async (input) => {
|
||||
calls.invoked.push({ tool: input.tool, input: input.input });
|
||||
@@ -59,6 +61,10 @@ function harness(writeTools: string[] = ['mail.send']) {
|
||||
calls.listens.push({ keyFilter: input.keyFilter });
|
||||
return { id: 'lsn-1' };
|
||||
},
|
||||
draftWrite: async (input) => {
|
||||
calls.written.push({ address: input.address, rows: input.rows.length });
|
||||
return { written: input.rows.length };
|
||||
},
|
||||
generate: async (input) => {
|
||||
calls.generated.push({ model: input.model, label: input.label, prompt: input.prompt });
|
||||
return { text: '{"label":"C"}', object: { label: 'C', rationale: '据规则' } };
|
||||
@@ -116,6 +122,48 @@ describe('装配:人类工具(系统内化)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('装配:台账工具(系统内化)', () => {
|
||||
it('写草稿落草稿层,**不登记动作意向** —— 草稿不触达世界,不该走动作那条路', async () => {
|
||||
const h = harness();
|
||||
const tools = buildMastraTools([LEDGER_TOOL], h.ports, { taskId: TASK, ownerId: OWNER, humanCallCap: 9, model: 'm' }, h.trace);
|
||||
const out = await run(Object.values(tools)[0], {
|
||||
address: 'quote_intake.record',
|
||||
rows: [
|
||||
{ key: { pn: 'LM358DR' }, patch: { eau: 12000 } },
|
||||
{ key: { pn: 'NE555P' }, patch: { eau: 5000 } },
|
||||
],
|
||||
});
|
||||
expect(h.calls.written).toEqual([{ address: 'quote_intake.record', rows: 2 }]);
|
||||
expect(h.calls.intents).toHaveLength(0);
|
||||
expect(out).toEqual({ ok: true, written: 2 });
|
||||
});
|
||||
|
||||
it('行的形状不对 → 入参 schema 当场拦下,错误原文回给模型让它这一拍改对', async () => {
|
||||
const h = harness();
|
||||
const tools = buildMastraTools([LEDGER_TOOL], h.ports, { taskId: TASK, ownerId: OWNER, humanCallCap: 9, model: 'm' }, h.trace);
|
||||
const out = await run(Object.values(tools)[0], {
|
||||
address: 'quote_intake.record',
|
||||
rows: [{ key: { pn: 'X' }, patch: { eau: 1 } }, { pn: 'Y', eau: 2 }, null],
|
||||
});
|
||||
// 一行不合规就整笔不写:半份数据比没数据更难查
|
||||
expect(h.calls.written).toEqual([]);
|
||||
expect(String(out['message'])).toContain('rows.1.key');
|
||||
});
|
||||
|
||||
it('地址不合规(装配层抛错)如实回报,不掀翻这一拍', async () => {
|
||||
const h = harness();
|
||||
const rejecting: ToolBusPorts = {
|
||||
...h.ports,
|
||||
draftWrite: async () => {
|
||||
throw new Error('台账地址未声明:other.thing');
|
||||
},
|
||||
};
|
||||
const tools = buildMastraTools([LEDGER_TOOL], rejecting, { taskId: TASK, ownerId: OWNER, humanCallCap: 9, model: 'm' }, h.trace);
|
||||
const out = await run(Object.values(tools)[0], { address: 'other.thing', rows: [{ key: {}, patch: {} }] });
|
||||
expect(out).toEqual({ ok: false, error: '台账地址未声明:other.thing' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('装配:LLM 五件(系统内化)', () => {
|
||||
it('经模型网关调,用活动声明的用途名;提示词归系统', async () => {
|
||||
const h = harness();
|
||||
@@ -139,3 +187,28 @@ describe('装配:LLM 五件(系统内化)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 单拍路径的工具清单 —— **声明式工具的提示词面**。
|
||||
*
|
||||
* 从前单拍路径的提示词里**根本没有工具清单**(第 12 章⑧):Agent 只知道说明书里
|
||||
* 顺口提过的工具名,入参长什么样得猜。实测的后果是它把该调工具的一步改成发卡问人
|
||||
* (「请为以下三个 PN 查询历史价格」),而系统里明明就有这件工具 —— 白占人的时间。
|
||||
*/
|
||||
describe('工具清单进提示词', () => {
|
||||
it('id · 效应 · 说明 · 入参键名都在,必填带星', () => {
|
||||
const listing = toolCatalog([
|
||||
{ id: 'crm.history-price-read', description: '读历史价', input: READ_TOOL.input, effect: 'read' },
|
||||
{ id: 'mail.send', description: '发邮件', input: WRITE_TOOL.input, effect: 'write' },
|
||||
]);
|
||||
expect(listing).toContain('crm.history-price-read(读类):读历史价');
|
||||
expect(listing).toContain('入参:pn:string*');
|
||||
expect(listing).toContain('mail.send(写类)');
|
||||
// 写类要如实说清「现在还没生效」,否则 Agent 会当成已经发出去了
|
||||
expect(listing).toContain('登记为动作意向');
|
||||
});
|
||||
|
||||
it('一件工具都没有就不摆这一段 —— 不给上下文塞空标题', () => {
|
||||
expect(toolCatalog([])).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* 上传件读文本 —— **这段错了不报错,只是活动拿到一份读不懂的料**。
|
||||
*
|
||||
* 测法不用录制的样本文件:测里现造一个**真的 xlsx**(ZIP + 那几份 XML,按规范逐字节拼),
|
||||
* 再让被测代码去读。造的那一侧与读的那一侧互不共享代码,读得回来才算真的读得懂。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { deflateRawSync } from 'node:zlib';
|
||||
import { extractText } from '../upload-extract.ts';
|
||||
|
||||
/* ==================== 现造一个 xlsx ==================== */
|
||||
|
||||
const enc = (s: string): Uint8Array => new TextEncoder().encode(s);
|
||||
|
||||
/** 按 ZIP 规范拼一个包:本地头 + 数据 + 中央目录 + EOCD */
|
||||
function makeZip(entries: ReadonlyArray<{ name: string; body: Uint8Array; store?: boolean }>): Uint8Array {
|
||||
const locals: Uint8Array[] = [];
|
||||
const centrals: Uint8Array[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const e of entries) {
|
||||
const name = enc(e.name);
|
||||
const data = e.store === true ? e.body : new Uint8Array(deflateRawSync(e.body));
|
||||
const method = e.store === true ? 0 : 8;
|
||||
|
||||
const local = new Uint8Array(30 + name.length + data.length);
|
||||
const lv = new DataView(local.buffer);
|
||||
lv.setUint32(0, 0x04034b50, true);
|
||||
lv.setUint16(8, method, true);
|
||||
lv.setUint32(18, data.length, true);
|
||||
lv.setUint32(22, e.body.length, true);
|
||||
lv.setUint16(26, name.length, true);
|
||||
local.set(name, 30);
|
||||
local.set(data, 30 + name.length);
|
||||
locals.push(local);
|
||||
|
||||
const central = new Uint8Array(46 + name.length);
|
||||
const cv = new DataView(central.buffer);
|
||||
cv.setUint32(0, 0x02014b50, true);
|
||||
cv.setUint16(10, method, true);
|
||||
cv.setUint32(20, data.length, true);
|
||||
cv.setUint32(24, e.body.length, true);
|
||||
cv.setUint16(28, name.length, true);
|
||||
cv.setUint32(42, offset, true);
|
||||
central.set(name, 46);
|
||||
centrals.push(central);
|
||||
|
||||
offset += local.length;
|
||||
}
|
||||
|
||||
const centralSize = centrals.reduce((n, c) => n + c.length, 0);
|
||||
const eocd = new Uint8Array(22);
|
||||
const ev = new DataView(eocd.buffer);
|
||||
ev.setUint32(0, 0x06054b50, true);
|
||||
ev.setUint16(8, entries.length, true);
|
||||
ev.setUint16(10, entries.length, true);
|
||||
ev.setUint32(12, centralSize, true);
|
||||
ev.setUint32(16, offset, true);
|
||||
|
||||
const total = offset + centralSize + eocd.length;
|
||||
const out = new Uint8Array(total);
|
||||
let at = 0;
|
||||
for (const b of [...locals, ...centrals, eocd]) {
|
||||
out.set(b, at);
|
||||
at += b.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 一份两列三行的 BOM:表头 + 两条料 —— 字符串走共享串表,数字直接落 `<v>` */
|
||||
function bomXlsx(): Uint8Array {
|
||||
const strings = ['型号', '年用量', 'LM358DR', 'NE555P'];
|
||||
const sharedXml =
|
||||
`<?xml version="1.0"?><sst count="4" uniqueCount="4">` +
|
||||
strings.map((s) => `<si><t>${s}</t></si>`).join('') +
|
||||
`</sst>`;
|
||||
const sheetXml =
|
||||
`<?xml version="1.0"?><worksheet><sheetData>` +
|
||||
`<row r="1"><c r="A1" t="s"><v>0</v></c><c r="B1" t="s"><v>1</v></c></row>` +
|
||||
`<row r="2"><c r="A2" t="s"><v>2</v></c><c r="B2"><v>12000</v></c></row>` +
|
||||
`<row r="3"><c r="A3" t="s"><v>3</v></c><c r="B3"><v>5000</v></c></row>` +
|
||||
`<row r="4"/>` +
|
||||
`</sheetData></worksheet>`;
|
||||
return makeZip([
|
||||
{ name: '[Content_Types].xml', body: enc('<?xml version="1.0"?><Types/>') },
|
||||
{ name: 'xl/sharedStrings.xml', body: enc(sharedXml) },
|
||||
{ name: 'xl/worksheets/sheet1.xml', body: enc(sheetXml) },
|
||||
]);
|
||||
}
|
||||
|
||||
/* ==================== 断言 ==================== */
|
||||
|
||||
describe('xlsx —— 一份真的表读得成行列', () => {
|
||||
const got = extractText('BOM.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', bomXlsx());
|
||||
|
||||
it('读得出内容,而不是回一句「读不出」', () => {
|
||||
expect(got.text).toBeDefined();
|
||||
});
|
||||
|
||||
it('共享串表查得对 —— 字符串单元格不是数字下标', () => {
|
||||
expect(got.text).toContain('LM358DR');
|
||||
expect(got.text).toContain('型号');
|
||||
});
|
||||
|
||||
it('数字单元格取原值,不查串表(查了会变成别的字)', () => {
|
||||
expect(got.text).toContain('12000');
|
||||
});
|
||||
|
||||
it('一行一行、制表符分列 —— 行列对齐了模型才拆得动', () => {
|
||||
expect(got.text?.split('\n')).toEqual(['型号\t年用量', 'LM358DR\t12000', 'NE555P\t5000']);
|
||||
});
|
||||
|
||||
it('整行皆空的不留 —— 表尾那些格式化过的空行不该占一行', () => {
|
||||
expect(got.text?.split('\n')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('读数如实报行数', () => {
|
||||
expect(got.note).toContain('3 行');
|
||||
});
|
||||
});
|
||||
|
||||
describe('不压缩的条目也读得了 —— store(0) 与 deflate(8) 都是 xlsx 的出厂法', () => {
|
||||
it('store 法存的表照样读得出', () => {
|
||||
const zip = makeZip([
|
||||
{
|
||||
name: 'xl/worksheets/sheet1.xml',
|
||||
body: enc('<worksheet><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>直存</t></is></c></row></sheetData></worksheet>'),
|
||||
store: true,
|
||||
},
|
||||
]);
|
||||
expect(extractText('a.xlsx', '', zip).text).toBe('直存');
|
||||
});
|
||||
});
|
||||
|
||||
describe('读不出来就说读不出来 —— 绝不编一段内容出来', () => {
|
||||
it('不是 ZIP 的 .xlsx:如实说打不开', () => {
|
||||
const got = extractText('坏的.xlsx', '', enc('这不是一个 zip'));
|
||||
expect(got.text).toBeUndefined();
|
||||
expect(got.note).toContain('打不开');
|
||||
});
|
||||
|
||||
it('二进制类型(pdf):说清只有文件名进得了活动', () => {
|
||||
const got = extractText('规格书.pdf', 'application/pdf', new Uint8Array([1, 2, 3]));
|
||||
expect(got.text).toBeUndefined();
|
||||
expect(got.note).toContain('读不出文本');
|
||||
});
|
||||
|
||||
it('空表:不是「读出 0 行」,是明说这份表是空的', () => {
|
||||
const zip = makeZip([{ name: 'xl/worksheets/sheet1.xml', body: enc('<worksheet><sheetData/></worksheet>') }]);
|
||||
expect(extractText('空.xlsx', '', zip).note).toContain('空的');
|
||||
});
|
||||
});
|
||||
|
||||
describe('纯文本族 —— 按 UTF-8 直接解', () => {
|
||||
it('csv 原样读出(中文不乱码)', () => {
|
||||
const got = extractText('bom.csv', 'text/csv', enc('型号,年用量\nLM358DR,12000'));
|
||||
expect(got.text).toBe('型号,年用量\nLM358DR,12000');
|
||||
});
|
||||
|
||||
it('超长的截断,并在读数里说明截了', () => {
|
||||
const got = extractText('big.txt', 'text/plain', enc('x'.repeat(30_000)));
|
||||
expect(got.text).toHaveLength(20_000);
|
||||
expect(got.note).toContain('截断');
|
||||
});
|
||||
});
|
||||
|
||||
describe('XML 实体还原 —— `&lt;` 之流不能原样端给模型', () => {
|
||||
it('转义字符还原成本来的字', () => {
|
||||
const zip = makeZip([
|
||||
// 表里真实存的形态:`A&B <100>` 在 XML 里写作 `A&B <100>`
|
||||
{ name: 'xl/sharedStrings.xml', body: enc('<sst><si><t>A&B <100></t></si></sst>') },
|
||||
{
|
||||
name: 'xl/worksheets/sheet1.xml',
|
||||
body: enc('<worksheet><sheetData><row r="1"><c r="A1" t="s"><v>0</v></c></row></sheetData></worksheet>'),
|
||||
},
|
||||
]);
|
||||
expect(extractText('e.xlsx', '', zip).text).toBe('A&B <100>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* **件上就地改** —— 人手改的那几格,是不是落得下去、按不按得下提交。
|
||||
*
|
||||
* 两件事在这儿测,都是用户实测逼出来的(2026-08-02,同一单):
|
||||
*
|
||||
* ① **按地址收,不按「当下那张卡的第几块」收**。
|
||||
* 载荷从前是 `{workOrderId, values:{'table:0': …}}`:列名由那张工单的 `blocks[0]`
|
||||
* 给出,于是「这份件能不能改」被绑在了「此刻开着的那张卡长什么样」上。
|
||||
* 实测:一单在放行门连拒五拍熔断,引擎发的是一张**没有 blocks 的熔断卡**;
|
||||
* 屏上摆着的正是受理记录那份件,闸在、编辑进得去、保存按得下 ——
|
||||
* 前端找不到匹配的块,**一格都没往下送**。而人要补的恰恰是把这一单
|
||||
* 卡在那儿的那个必填格。
|
||||
*
|
||||
* ② **必填还空着就按不下提交**。同一单里,受理需求那张卡上一条料缺年用量(必填),
|
||||
* 人照样交了回来;这一单于是一路走到放行门被 L1 拦住,模型连拒五拍熔断。
|
||||
* 同一件事在提交那一刻拦下便宜得多 —— 那时缺的是哪一格就摆在他眼前。
|
||||
*
|
||||
* 测的是生产那两段本身(`writesOf` / `gateOf`),不在测里抄一份实现:
|
||||
* 抄一份的下场踩过 —— 生产改了、测没跟着改,测全绿而线上照旧。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { gateAddressesOf, gateOf, rowChanged, writesOf } from '../routes/desk.ts';
|
||||
import { missingReadings } from '../wiring/engines.ts';
|
||||
|
||||
/** A1 受理记录的声明(裁到这一段用得着的几列):PN 与年用量必填 */
|
||||
const RECORD = {
|
||||
domain: 'quote_intake',
|
||||
object: 'record',
|
||||
fields: [
|
||||
{ name: 'rfq_ref', type: 'text', primaryKey: true },
|
||||
{ name: 'pn', type: 'text', primaryKey: true, label: '型号 PN', required: true },
|
||||
{ name: 'eau', type: 'number', primaryKey: false, label: '年用量 EAU', required: true },
|
||||
{ name: 'brand', type: 'text', primaryKey: false, label: '制造商', expect: true },
|
||||
],
|
||||
};
|
||||
const VERDICT = {
|
||||
domain: 'quote_intake',
|
||||
object: 'verdict',
|
||||
fields: [{ name: 'rfq_ref', type: 'text', primaryKey: true }],
|
||||
};
|
||||
|
||||
describe('件上按保存:按地址收', () => {
|
||||
it('**地址在声明里就写** —— 当下开着的是哪张卡、带没带块,一概不问', () => {
|
||||
const got = writesOf(
|
||||
[RECORD, VERDICT],
|
||||
{ 'quote_intake.record': [{ pn: 'NE555P', eau: '30000' }] },
|
||||
);
|
||||
expect(got).toHaveLength(1);
|
||||
expect(got[0]?.declared.object).toBe('record');
|
||||
expect(got[0]?.lines).toEqual([{ pn: 'NE555P', eau: '30000' }]);
|
||||
});
|
||||
|
||||
it('一次收好几张表 —— 需求表与分类表在同一份件上,各写各的', () => {
|
||||
const got = writesOf([RECORD, VERDICT], {
|
||||
'quote_intake.record': [{ pn: 'A', eau: '1' }],
|
||||
'quote_intake.verdict': [{ rfq_ref: 'R1' }],
|
||||
});
|
||||
expect(got.map((x) => x.declared.object)).toEqual(['record', 'verdict']);
|
||||
});
|
||||
|
||||
it('**活动没声明过的地址不写** —— 载荷从浏览器来,门得在服务端', () => {
|
||||
expect(writesOf([RECORD], { 'customer.profile': [{ name: '改我' }] })).toEqual([]);
|
||||
});
|
||||
|
||||
it('地址不成形的不写 —— 「域.对象」两段,多一段少一段都不认', () => {
|
||||
expect(writesOf([RECORD], { record: [{ pn: 'A' }] })).toEqual([]);
|
||||
expect(writesOf([RECORD], { 'a.b.c': [{ pn: 'A' }] })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('提交闸:必填没填就交不了', () => {
|
||||
const gate = (rows: Array<Record<string, unknown>>) =>
|
||||
gateOf(missingReadings({ 'mine:quote_intake.record': rows }, [RECORD]), [RECORD]);
|
||||
|
||||
it('一格不缺 → 交得了', () => {
|
||||
expect(gate([{ rfq_ref: 'R1', pn: 'NE555P', eau: 30000 }]).ready).toBe(true);
|
||||
});
|
||||
|
||||
it('**缺必填 → 交不了,并说清差在哪一行哪一格**', () => {
|
||||
const got = gate([
|
||||
{ rfq_ref: 'R1', pn: 'NE555P', eau: 30000 },
|
||||
{ rfq_ref: 'R1', pn: 'DAG2400L321504XZB01-R0', eau: null },
|
||||
]);
|
||||
expect(got.ready).toBe(false);
|
||||
expect(got.why).toContain('1 条必填');
|
||||
expect(got.blanks).toEqual(['R1/DAG2400L321504XZB01-R0 缺年用量 EAU']);
|
||||
});
|
||||
|
||||
it('**选填缺不拦单** —— 那一问不能省,但它不是交不了的理由', () => {
|
||||
expect(gate([{ rfq_ref: 'R1', pn: 'NE555P', eau: 30000, brand: '' }]).ready).toBe(true);
|
||||
});
|
||||
|
||||
it('空串与 null 同等看待 —— 模型用 `""` 表示「原文没给」', () => {
|
||||
expect(gate([{ rfq_ref: 'R1', pn: 'NE555P', eau: '' }]).ready).toBe(false);
|
||||
});
|
||||
|
||||
it('**与放行门的 L1 同一个数** —— 两处各算各的,就会出现「卡上说能交、放行门说不行」', () => {
|
||||
const rows = [{ rfq_ref: 'R1', pn: 'A', eau: 1 }, { rfq_ref: 'R1', pn: 'B', eau: null }];
|
||||
const readings = missingReadings({ 'mine:quote_intake.record': rows }, [RECORD]);
|
||||
// L1 判据读的就是这个键:`quote_intake_record_missing = 0`
|
||||
expect(readings['quote_intake_record_missing']).toBe(1);
|
||||
expect(gateOf(readings, [RECORD]).ready).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* **行级必填 `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('只写真改了的那几行', () => {
|
||||
const row = { rfq_ref: 'R1', pn: 'NE555P', eau: 30000, brand: 'TI' };
|
||||
|
||||
it('**一个字没改就不算一次修订** —— 件上按保存,整张表都回传上来,它不知道人动了哪一格', () => {
|
||||
expect(rowChanged(row, { eau: 30000, brand: 'TI' })).toBe(false);
|
||||
});
|
||||
|
||||
it('改了一格就算 —— 只按值比,不按有没有回传比', () => {
|
||||
expect(rowChanged(row, { eau: 45000, brand: 'TI' })).toBe(true);
|
||||
});
|
||||
|
||||
it('**数字与它的字符串写法相等** —— 台账里躺的是 30000,人手填回来的是 "30000"', () => {
|
||||
expect(rowChanged(row, { eau: '30000' })).toBe(false);
|
||||
});
|
||||
|
||||
it('空与没有相等 —— 空格子回传成 `""`,而台账里那一列压根没写过', () => {
|
||||
expect(rowChanged(row, { remark: '' })).toBe(false);
|
||||
expect(rowChanged({ ...row, remark: null }, { remark: '' })).toBe(false);
|
||||
});
|
||||
|
||||
it('台账里还没有这一行 → 是新增,当然算改', () => {
|
||||
expect(rowChanged(undefined, { eau: 30000 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **增删行**(2026-08-02 用户:「编辑列表应该还能增删行」)。
|
||||
*
|
||||
* 手改本来就是**兜 AI 的错**的那一手,而错不止「这一格填错了」:
|
||||
* 「这一行根本不该在」(客户点名本轮跳过的料被写进了有效清单)与「漏了一行」
|
||||
* 是同一类错 —— 只给改格子,前两种就没有出口,那一单会一路走到放行门被判据拦住,
|
||||
* 而人明明一眼就看出问题在哪(实测那一单 13 次拒回、234k token 才熔断)。
|
||||
*/
|
||||
describe('增删行:删要说出来,不靠「没回传就是删了」', () => {
|
||||
it('**删走 `drops`,与改走 `tables` 分开** —— 两份都按地址过同一道声明门', () => {
|
||||
const drops = writesOf([RECORD], { 'quote_intake.record': [{ pn: 'NE555P', rfq_ref: 'R1' }] });
|
||||
expect(drops).toHaveLength(1);
|
||||
expect(drops[0]?.lines).toEqual([{ pn: 'NE555P', rfq_ref: 'R1' }]);
|
||||
});
|
||||
|
||||
it('删的地址也得在声明里 —— 载荷从浏览器来,撤行比改格子更该有门', () => {
|
||||
expect(writesOf([RECORD], { 'customer.profile': [{ name: '撤我' }] })).toEqual([]);
|
||||
});
|
||||
|
||||
it('**回传里没提到的行不算删** —— 一次不完整的回传不该变成一次删表', () => {
|
||||
// 三行的表只回传一行:那是「这次只说了这一行」,不是「另外两行撤掉」
|
||||
const got = writesOf([RECORD], { 'quote_intake.record': [{ pn: 'A' }] });
|
||||
expect(got[0]?.lines).toHaveLength(1); // 删与不删由 drops 说了算,tables 不承载删的语义
|
||||
});
|
||||
|
||||
it('新增行照样过归型与必填 —— 加一行只填了 PN,闸照旧拦着', () => {
|
||||
const readings = missingReadings(
|
||||
{ 'mine:quote_intake.record': [{ rfq_ref: 'R1', pn: '老料', eau: 1 }, { rfq_ref: 'R1', pn: '人加的新料' }] },
|
||||
[RECORD],
|
||||
);
|
||||
const got = gateOf(readings, [RECORD]);
|
||||
expect(got.ready).toBe(false);
|
||||
expect(got.blanks).toEqual(['R1/人加的新料 缺年用量 EAU']);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **自增主键由基座生成**(2026-08-02 用户:「联系人ID自增啊,不用填」)。
|
||||
*
|
||||
* 联系人编码不承载业务含义,只是「这家客户下的第几位」。让人填是白占一格
|
||||
* 他答不上来的表单(他哪知道已经有几位);让模型编更糟 —— 编重了就是覆盖别人那一行,
|
||||
* 而主数据覆盖错了后面每一单都跟着错。
|
||||
*/
|
||||
describe('自增主键:前缀-两位序号', () => {
|
||||
/** 与 `routes/desk.ts` 的 `autoKeys` 同一口径 —— 改一处这里要跟着改 */
|
||||
const gen = (others: string[], given: Record<string, string>, count: number, names: string[]) => {
|
||||
const prefix = others.map((k) => given[k] ?? '').filter((v) => v !== '').join('-');
|
||||
const out: Record<string, string> = {};
|
||||
for (const [i, n] of names.entries()) {
|
||||
const seq = String(count + 1 + i).padStart(2, '0');
|
||||
out[n] = prefix === '' ? seq : `${prefix}-${seq}`;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
it('**照种子里那个写法**:第一位联系人是 `客户编码-01`', () => {
|
||||
expect(gen(['customer_code'], { customer_code: 'CU-9001' }, 0, ['contact_code'])).toEqual({
|
||||
contact_code: 'CU-9001-01',
|
||||
});
|
||||
});
|
||||
|
||||
it('这家客户已经有两位 → 新的是 -03,不跟谁撞', () => {
|
||||
expect(gen(['customer_code'], { customer_code: 'CU-0142' }, 2, ['contact_code'])['contact_code']).toBe('CU-0142-03');
|
||||
});
|
||||
|
||||
it('同一单里连加两位 → 各自拿到 -01 / -02(后一位不覆盖前一位)', () => {
|
||||
expect(Object.values(gen(['customer_code'], { customer_code: 'CU-1' }, 0, ['a', 'b']))).toEqual([
|
||||
'CU-1-01',
|
||||
'CU-1-02',
|
||||
]);
|
||||
});
|
||||
|
||||
it('没有其余主键(它自己就是唯一那把键)→ 退回纯序号', () => {
|
||||
expect(gen([], {}, 4, ['seq'])).toEqual({ seq: '05' });
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,10 +9,12 @@ import { Pool } from 'pg';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { evaluateCondition } from '@sfeel';
|
||||
import type { Db } from '@shared/db.ts';
|
||||
import type { TaskId } from '@contracts';
|
||||
|
||||
import type { EventService, TriggerMatcher } from '@modules/event';
|
||||
import { createEventService, createEventStore } from '@modules/event';
|
||||
import type { LedgerService } from '@modules/ledger';
|
||||
import type { LoopEngineService } from '@modules/loop-engine';
|
||||
import { createDraftStore, createLedgerService, createLedgerStore, DRAFT_DDL } from '@modules/ledger';
|
||||
import type { OrgService, InFlightWorkProbe } from '@modules/org';
|
||||
import {
|
||||
@@ -39,6 +41,7 @@ import { createActivityMemoryStore, createMemoryService, createTaskMemoryStore }
|
||||
import type { ToolboxService, ToolHandler, ToolInvoker } from '@modules/toolbox';
|
||||
import {
|
||||
createSchemaValidator,
|
||||
createToolHandoffStore,
|
||||
createToolInvoker,
|
||||
createToolRegistry,
|
||||
createToolReplayStore,
|
||||
@@ -103,6 +106,12 @@ export type Container = {
|
||||
readonly registry: ActivityRegistryService;
|
||||
readonly release: ReleaseService;
|
||||
readonly task: TaskService;
|
||||
/** 案例引擎 —— 工作台按它算阶段进度;推进照旧走 task,不从这里走 */
|
||||
readonly loop: LoopEngineService;
|
||||
/** 视图闸 —— 人在件上改完数据,按同一张模版用新数据重渲一帧(`views.repush`) */
|
||||
readonly views: { repush(taskId: TaskId): Promise<boolean> };
|
||||
/** 此刻这一单的判据 —— 按当下数据现算(发卡那一刻算死的话,人补一格判据不会动) */
|
||||
readonly checks: { of(taskId: TaskId): Promise<ReadonlyArray<{ key: string; label: string; level: string; pass: boolean; settled?: boolean; rationale?: string }>> };
|
||||
readonly process: ProcessService;
|
||||
readonly trigger: TriggerService;
|
||||
readonly minute: MinuteService;
|
||||
@@ -161,6 +170,14 @@ const sfeelEvaluator = (expression: string, scope: Readonly<Record<string, unkno
|
||||
export const firstPartyToolHandlers = new Map<string, ToolHandler>(FIRST_PARTY_TOOLS);
|
||||
|
||||
export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
/*
|
||||
* 广播器**先建** —— 装配期就要往它上面接线。
|
||||
*
|
||||
* 它从前是在 `return` 里现造的,于是装配期谁也拿不到它:
|
||||
* 对话流推送、视图推送、发消息这三条**全都没有广播**,
|
||||
* 前端只好靠人点刷新(用户实测报的:任务列表 / 消息气泡 / 工作区 / 对话区都不自己动)。
|
||||
*/
|
||||
const broadcaster = createBroadcaster();
|
||||
const pool = new Pool({ connectionString: config.databaseUrl, max: 10 });
|
||||
/*
|
||||
* 空闲连接被服务端掐断(重启、`pg_terminate_backend`、网络抖动)会以 'error' 事件抛到池上;
|
||||
@@ -233,6 +250,25 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
events: { publish: async (envelope) => void (await event.publish(envelope)) },
|
||||
clock: systemClock,
|
||||
ids,
|
||||
/*
|
||||
* 试跑判定 —— 设计台有**两条**试跑路径,认漏一条就等于没挡:
|
||||
* 单活动试跑:起的是不挂工作流的单,标 `task.origin = 'testbed'`
|
||||
* 流程包试跑:节点任务挂在工作流上,标在**流**身上 `workflow.source->>'ref'`
|
||||
* 一条 SQL 两条都认。认不出就当生产单(宁可少挡也不误挡:误挡会让真单丢主数据)。
|
||||
*/
|
||||
simulation: {
|
||||
isSimulated: async (taskId) => {
|
||||
const res = await pool.query<{ simulated: boolean }>(
|
||||
`select exists (
|
||||
select 1 from sys.task t
|
||||
left join sys.workflow w on w.id = t.workflow_id
|
||||
where t.id = $1 and (t.origin = 'testbed' or w.source->>'ref' = 'testbed')
|
||||
) as simulated`,
|
||||
[taskId],
|
||||
);
|
||||
return res.rows[0]?.simulated === true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const instances = createLlmInstanceRepository(db);
|
||||
@@ -278,6 +314,8 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
secrets,
|
||||
clock: systemClock,
|
||||
});
|
||||
/** 工具转人台(§6.2):系统里没有这件工具时,请人代办 —— 人答回的值就是这次调用的出参 */
|
||||
const toolHandoff = createToolHandoffStore(db);
|
||||
const toolInvoker = createToolInvoker({
|
||||
registry: toolRegistry,
|
||||
runtime: toolRuntime,
|
||||
@@ -334,9 +372,41 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
ids,
|
||||
});
|
||||
|
||||
/*
|
||||
* **对话流与视图推送接进广播** —— `interaction.subscribe` 从前一个订阅者都没有:
|
||||
* AI 说的每一句、推的每一帧画面都广播给了空气,前端只好靠人点刷新。
|
||||
*
|
||||
* 这里只做转接,不带业务载荷:推送说「这个任务动了」,画面照旧由服务端投影重建
|
||||
* (§9.2 前端零过程状态)。收件人是谁不在信封里,所以按任务广播 ——
|
||||
* 前端按任务键失效,负责人那一屏自然重取。
|
||||
*/
|
||||
interaction.onPush((envelope) => {
|
||||
// invalidate 信封自己说目标(task / workflow / desk);其余信封都以任务为轴
|
||||
if (envelope.kind === 'invalidate') {
|
||||
broadcaster.publish({
|
||||
kind: envelope.target === 'workflow' ? 'workflow' : 'task',
|
||||
id: envelope.id,
|
||||
reason: 'invalidate',
|
||||
});
|
||||
return;
|
||||
}
|
||||
broadcaster.publish({ kind: 'task', id: envelope.taskId, reason: envelope.kind });
|
||||
});
|
||||
|
||||
const humanTool = createHumanToolService({
|
||||
cards: createCardStore(db),
|
||||
outbox: { sendElicitation: (input) => message.sendElicitation(input) },
|
||||
outbox: {
|
||||
/*
|
||||
* 跨人卡以系统消息送达对方 —— 送到了就**广播给那个人**:
|
||||
* 他的消息气泡该立刻多一条,而不是等他自己点刷新。
|
||||
* 发消息不经事件投递(那条路才有广播),所以这里得自己说一声。
|
||||
*/
|
||||
sendElicitation: async (input) => {
|
||||
const sent = await message.sendElicitation(input);
|
||||
broadcaster.publish({ kind: 'staff', id: input.staffId, reason: 'elicitation' });
|
||||
return sent;
|
||||
},
|
||||
},
|
||||
memory: {
|
||||
append: async (input) =>
|
||||
void (await memory.append({
|
||||
@@ -346,6 +416,17 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
},
|
||||
events: {
|
||||
publishHumanEvent: async (input) => {
|
||||
/*
|
||||
* 转人代办的回传**就是工具出参**:先落转人台,再抛事件。
|
||||
*
|
||||
* 顺序不能反 —— 事件会唤起下一拍,那一拍 invoke 必须立刻取到答案;
|
||||
* 先抛后写就会空跑一拍(拍里还是「等人」,而人其实已经答了)。
|
||||
* 卡不是转人卡时 `answer` 查无此行,静默无事发生。
|
||||
*/
|
||||
const values = input.payload['values'];
|
||||
if (typeof values === 'object' && values !== null && !Array.isArray(values)) {
|
||||
await toolHandoff.answer(input.cardId, values as Record<string, unknown>);
|
||||
}
|
||||
await event.publish({
|
||||
type: 'human',
|
||||
correlation: {
|
||||
@@ -461,7 +542,19 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
deliverable: input.deliverable as never,
|
||||
criteria: [...input.criteria],
|
||||
actions: input.actions.map((a) => ({ ...a })),
|
||||
fields: [],
|
||||
/*
|
||||
* 可修订字段照传 —— 从前这里写死 `fields: []`,是这条链上的最后一处丢值:
|
||||
* 契约声明了、submit 传了、release 转了,到这儿被一句空数组抹平,
|
||||
* 验收报告于是只能看不能改(第 12 章③)。
|
||||
*/
|
||||
fields: (input.fields ?? []).map((f) => ({ ...f })) as never,
|
||||
/*
|
||||
* 正文块照传 —— 与 `fields` 同一个坑:这个显式对象是这条链的最后一关,
|
||||
* 少写一行,上游传了一路的东西就在这里静默落地(卡面 blocks 恒空,踩过两次)。
|
||||
*/
|
||||
...(input.blocks === undefined || input.blocks.length === 0
|
||||
? {}
|
||||
: { blocks: input.blocks.map((b) => ({ ...b })) as never }),
|
||||
},
|
||||
);
|
||||
return { id: issued.order.id };
|
||||
@@ -537,6 +630,7 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
memory,
|
||||
toolbox,
|
||||
toolInvoker,
|
||||
toolHandoff,
|
||||
humanTool,
|
||||
interaction,
|
||||
message,
|
||||
@@ -581,7 +675,7 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
|
||||
return {
|
||||
config,
|
||||
broadcaster: createBroadcaster(),
|
||||
broadcaster,
|
||||
pool,
|
||||
db,
|
||||
org,
|
||||
@@ -599,6 +693,10 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
registry,
|
||||
release,
|
||||
task: engines.task,
|
||||
loop: engines.loop,
|
||||
/** 件是投影 —— 人在件上改完数据,得能按同一张模版用新数据重渲一帧(见 `views.repush`) */
|
||||
views: engines.views,
|
||||
checks: engines.checks,
|
||||
process: engines.process,
|
||||
trigger: engines.trigger,
|
||||
minute: deskElements.minute,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user