diff --git a/.claude/skills/html-one-pager/SKILL.md b/.claude/skills/html-one-pager/SKILL.md new file mode 100644 index 0000000..883ce47 --- /dev/null +++ b/.claude/skills/html-one-pager/SKILL.md @@ -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 元不是裸差值;台账口径与呈现 + 口径不一致的,也记欠账。判定表带「本单命中留痕」(哪条规则命中了哪几颗料)。 +- **演示脚本可以有**:集中在 `', ' ', html, flags=re.I) + + +def visible_text(html): + """人看得见的正文:去脚本、去注释、去 ', ' ', 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']*>([\s\S]*?)', 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(' 在)', ' 总量 ≥ 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('图标是内联精灵( + )', + ']*>\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() diff --git a/.gitignore b/.gitignore index f64b746..9e1b088 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ coverage/ # 本地开发日志(./start.sh 写的) .dev-logs/ + +# html-one-pager 技能的评测工作区(skill-creator 产物,不入库) +.claude/skills/html-one-pager-workspace/ diff --git a/CLAUDE.md b/CLAUDE.md index b8b1c7e..76dce7a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -414,8 +414,360 @@ - **A1 三案例常备**(2026-08-02):全 A 类直通 / 客户点名跳一条 / 新客户中文正文列料缺必填 —— 三条来件形状真不同(俄文 BOM 附件、正文点名跳料、中文正文列料无附件), 末次复跑 **3/3 released、全部被拒 0**(11 / 10 / 11 拍)。 -- 后端 19 模块 + 6 活动包 + 前端 features;五道门禁全绿(2026-08-02)。 -- **别用 `db:seed` 当装活动的手段**:它会重写整套治理台配置。装活动用 `pnpm activities:install`。 +- **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。 + 连带逮住一个渲染器约束并记进陷阱表:**行展开按第一个同名闭合标签截行内容** —— + `
` 的行模版里嵌 `
` 会把行截成套娃(第二张卡长进第一张的表头里), + 行容器要用行内不会再出现的标签(`
`/``)。 +- **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` 切换),保存/取消回稿;方法论(等级三卡/五项核对/三层筛)收进 + 原生 `
` 折叠条(零脚本);页高 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 复跑等测试台空出来再补(他的单还开着,不顶)。 ## 文档权威地图 diff --git a/activities/quote-alternative/__tests__/activity.test.ts b/activities/quote-alternative/__tests__/activity.test.ts new file mode 100644 index 0000000..79016a1 --- /dev/null +++ b/activities/quote-alternative/__tests__/activity.test.ts @@ -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(' { + 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('')), `${id} 少了 manualActivationRule`).toContain( + ' { + for (const tag of [' { + 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 }).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(/'); + } + }); + + 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(/ +${PRD_SPRITE}${EXTRA_SPRITE} +
+
+
+
${ico(input.badge, 17)}
+
+ ${input.kicker} +

${input.title}

+
+ ${input.state} +
+
${input.body}
+
+
+ ${ico('user', 15)} +
${input.foot}
+
+ ${ico('git-branch', 13)}${input.tag} +
+
+
`; + +/** 章节头 —— 家族的 jc-rp-part:小标签 + 细线 + 右端小签 */ +const part = (label: string, aside = '', tone = ''): string => + `
${label}${aside === '' ? '' : `${aside}`}
`; + +/* ==================== 选型执行工单(一颗一页)==================== */ + +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 = ` + + + + +`; + +/** 蓝图带:原厂芯片 → 五道核对 → 替代插座(空=虚线红槽,定了=实线绿槽) */ +const BLUEPRINT = `
+
原厂芯片${CHIP_FIG}
+
+ + + + + +
替代路径要穿过五道核对(封装 · 引脚 · 电气 · 温度 · 认证)
+
+
替代定案 + + + + + +
+
`; + +/** 等级三选(radio 不写 checked;当前档由 gopts 上的投影描虚线) */ +const GRADE_OPTS = `
+
P2P同封装同引脚 · 可直换 —— 五道核对逐项过了再选,同封装不等于同引脚${ico('pins', 15)}
+
FUNC功能兼容 —— 需外围电路或 layout 调整;差多少、怎么改,写进下面两格${ico('gear', 15)}
+
OTHER部分兼容 —— 须打样验证;怎么验写进验证建议${ico('flask', 15)}
+
`; + +/** 一页 = 一颗待选型料的选型档案(行模版;只有 orig_type=C 的行成页) */ +const SHEET_PAGE = ` + +
+
+
原厂 · 被替换
+
${ico('history')}为什么要替代  +

+
+ +
+ ${BLUEPRINT} +
+
+
${ico('file-text', 12)}题面 · 客户给的
+
客户料号
+
品类
+
制造商
+
年用量
+
目标价
+
交期要求
+
+
+
${ico('pen', 12)}替代定案 · 你的产出
+
原厂 PN(行键)
+
替代料 PN
+
替代料品牌
+
替代等级
+ ${GRADE_OPTS} +
参数差异项
+
风险与改动
+
验证建议
+
选型备注
+
+
+
`; + +export const ALT_WORKSHEET = doc({ + badge: 'boxes', + kicker: '统一报价 · A2 替代选型 · 选型作业', + title: '替代选型工单', + titleRef: 'rfq_ref', + state: '在办 · 逐颗定案', + css: SHEET_CSS, body: - statsBand([ - ['初筛候选', 'mine:quote_alternative.shortlist'], - ['已对标', 'mine:quote_alternative.benchmark'], - ['当下可为', 'enabledCount'], - ['目标缺口', 'gapCount'], - ]) + - section('初筛清单(本单撒网所得)', - tableOnly('mine:quote_alternative.shortlist', [ - ['原型号', 'original_pn', 'mono'], - ['候选型号', 'candidate_pn', 'mono'], - ['品牌', 'brand'], - ['生命周期', 'lifecycle'], - ]) + - noteOnly( - '先撒网后核验,常要两三轮:检索与聚合平台拉来的候选先落这一张,逐条人工核验之前都不算数。' + - '生命周期一栏是初筛就要看的第一道险 —— EOL/停产的当场淘汰;' + - '不足 2~3 颗就放宽参数或换角度重新撒网。这张表空着,是本单还没撒网,不是没找着。', - ), - { text: '常要两三轮' }, - ) + - section('替代对标表(本单交付件)', - tableOnly('mine:quote_alternative.benchmark', [ - ['候选型号', 'candidate_pn', 'mono'], - ['替代等级', 'grade'], - ['参数差异项', 'param_diff'], - ['差异风险', 'risk'], - ['验证建议', 'verify_advice'], - ['FAE 签字', 'signed_by'], - ]) + - noteOnly( - '这是要签字交回发起方的东西:差异没核清的不放行 —— 「差异风险」空着的行,' + - '就是此刻拦着放行的那几行。替代等级由判定表出(P2P / 功能兼容 / 其他),不是模型自己说了算;' + - '等级定评估深度:P2P 核确免改动,其余逐项评软件、外围电路与 layout。' + - '每项数据一律以原厂官网规格书为准。', - ), - { text: '差异没核清不放行' }, - ) + - section('案例阶段(CMMN 生命周期)', - stagesOnly('case:stages') + - noteOnly( - '阶段的生命周期态由语义引擎推进到 Enabled 为止;凡触达世界或产出关键交付的计划项' + - '必须人工启动 —— Enabled ≠ Active。选型是一事一议:撒几轮网、哪颗值得深评、' + - '要不要换角度重来,事前排不出谱,所以调度权交给 AI,系统只保证边框:预算、停滞熔断、放行归人。', - ), - { text: '推进到 Enabled 为止' }, - ) + - section('当下可为(AI 的合法选项)', - stagesOnly('case:enabled') + - noteOnly( - 'AI 只能在这个集合里选下一步:可以越谱,不可越权。标「待启动」的是触达世界或' + - '产出关键交付的项 —— 要按一下再动。这一栏空着不是它偷懒,是当下确实无可为:' + - '要么在等人回话,要么在等外面的回音。', - ), - { text: '本拍的合法选项' }, - ) + - section('目标缺口(离交付还差什么)', - stagesOnly('case:gaps') + - noteOnly( - '缺口只有人知道 → 调人。选型常要向 BD 补客户口径,那是跨任务征答:卡以系统消息送达' + - '对方、就地作答,不占对方的任务清单,也不开放本任务的对话流 —— 一次性的解答请求,' + - '不是责任转移。缺口在世界侧 → 立监听(时限必配)。' + - '当下可为 ∩ 目标所需 = 空,就是依赖断点。这一栏空着,是必做的事都办完了。', - ), - { text: '空 = 必做的都办完了' }, - ), + `
${ico('info', 14)} + 这份工单只办一件事:给每颗待选型(原标 C)的料定一颗替代 —— 客户 + 本单待选型 颗, + 一颗一页(右上角翻页)。每页上半是题面(客户给的,改不了),下半右栏是你的定案; + 定案保存后,查库判稳与转标(≤90 天且行情稳转 A,否则转 B 去寻源)由系统接办。 + 保存只落任务草稿层;交卷在对话区的执行卡上。 +
` + + part('替代定案 · 一颗一页', '可就地修订') + + `
+
${ico('alert', 14)} + 定案还差 格必填 + —— 。 + 翻到那一颗,按「编辑」开闸就地填;补齐了执行卡上才交得了卷。 +
+
+ ${ico('lock', 12)}替代定案已锁定 —— 看的时候不误碰,要改按右边 + + + +
+
${ico('pen', 14)} + 定案三格是必填(替代料 PN / 品牌 / 等级),等级直接点三档之一; + 这颗料整个不该选型,按档案头右上角的 × 剔除(划着摆在那儿,可后悔); + 漏了一颗,用底下的「补一颗待选型料」。改动只落任务草稿层,保存不等于放行。 +
+ ${SHEET_PAGE} +
+ + 照末页添一页空档案;行键(原厂 PN)填了才存得下,不填这一页不落库。 +
+
` + + part('选型方法 · 凭什么这么选', '替代等级口径') + + `
+
${ico('ruler', 16)}
封装尺寸本体 / 脚距 / 高度
+
${ico('pins', 16)}
引脚定义逐脚对数据手册
+
${ico('bolt', 16)}
电气参数供电 / 精度 / 速度
+
${ico('thermo', 16)}
温度等级工作温区不降级
+
${ico('shield-check', 16)}
认证车规 / 医疗不降级
+
+
+
① 参数硬指标封装 / 引脚 / 电气 / 温度 —— 任一不满足直接淘汰,不进下一层。
+
② 认证与合规车规 AEC-Q100/Q200、医疗、工业等级不可降级;客户点名禁用的品牌与型号不选。
+
③ 供应与价格在产 / 货态 / 品牌接受度;客户预授权的候选优先。
+
+
${ico('info', 13)} + 一律以原厂官网规格书为准,不以商城参数表为准。没有把握的替代不填: + 宁可在选型备注写明「替代未果」的缘由后保存交回,由判稳一步按剔除处置 —— + 编一个候选顶上,是把风险埋进下游的报价单。是否接受非原厂品牌、参数降级, + 必须问客户,不得替客户决定。 +
`, + 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 = `
+ + + + + + + + + + + + + + + + + + +
原厂料原标替代定案等级现标转标依据
`; + +/** 落边图:两条整段 path(命中哪条哪条亮);共段是中性 spine */ +const ROUTE_FIG = `
+ + + + + + + + + + 询价受理清单 + 共 条 · C 已清零 + + 还有 B 类? + 是 · B 类 条 + 否 · 全 A 类 + + A3 · 渠道寻源 + 补上 B 类的货源价格 + + A4 · 成本定价 + 行情完备 · 直通核价 + +
命中边实线亮 · 未命中留灰虚线判定在你签字那一刻由系统现算 —— 要改去向,在验收卡的成果表上改现标
+
`; + +export const ALT_ROSTER = doc({ + badge: 'file-check', + kicker: '统一报价 · A2 替代选型 · 交付成果', + title: '询价受理清单', + titleRef: 'rfq_ref', + state: '收口 · 待放行', + css: ROSTER_CSS, + body: + `
${ico('info', 14)} + 这份清单是 A2 的收口成果:上游受理记录逐条承继,待选型(原标 C)的料 + 已由 FAE 逐颗定案,系统按库内行情判稳转标(≤90 天且行情稳 → A,否则 → B 去寻源)—— + 询价受理记录就此转成询价受理清单,客户 。 + 本页只读:要改哪一格,在验收卡的成果表上就地改,这里跟着重渲。 +
` + + part('收口总览', 'C 类清零 · 判稳定标', 'ok') + + `
+
清单条目 · 逐条承继上游
+
A 类 · 行情完备
+
B 类 · 待补货源价
+
C 类 · 待选型未清(应为 0)
+
+
+ 待选型 颗→ + 已填回替代 颗→ + 判稳转 A 颗· + 转 B 待寻源 颗· + 剔除说明: +
` + + part('询价受理清单', ' 条 · 交下游的就是这一份') + + ROSTER_TABLE + + `
${ico('info', 13)} + 原标是上游受理时的三分类(留痕,本活动不改);现标是本活动的产物: + C 类经替代定案与判稳转成 A 或 B。现标定去向 —— 改一颗料的现标,下面的落边判定跟着变。 + 等级口径:P2P 同封装同引脚可直换 · FUNC 功能兼容需调整 · OTHER 部分兼容须打样验证。 +
` + + part('落边 · 这一签流向哪里', '出口按序求值 · 首个命中即出') + + ROUTE_FIG + + `
+ ${ico('git-branch', 15)} +
选型结论 ——
+
转入活动
+
` + + part('放行后即执行的落地动作', '门前 → 门后') + + `
+
${ico('check', 11)} +
草稿层合并进正式版 本任务写下的询价受理清单与选型结论一并落账; + 你在验收卡上就地改的那几格随之落定。
+
+
${ico('check', 11)} +
命中的那一口此刻才发射 清单 + 条随载荷交给上面亮线指向的下游; + 另一口本次不起单 —— 放行之前,世界上什么都没有发生。
+
+
`, + foot: 'AI 拟稿 · FAE 审定放行 —— 签下去这份清单即转正并流向下游', + tag: '统一报价 · A2 替代选型 → 交下游', }); diff --git a/activities/quote-archive/__tests__/activity.test.ts b/activities/quote-archive/__tests__/activity.test.ts new file mode 100644 index 0000000..9e37a4b --- /dev/null +++ b/activities/quote-archive/__tests__/activity.test.ts @@ -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; + 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(' { + 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 [' { + 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 }).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(/'); + } + }); + + 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(''); + }); + + it('宿主拒推含脚本的模版', async () => { + const host = createMockHost({ contract: CONTRACT }); + await expect(host.base.interaction.pushHtml('
')).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, +): { 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(//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(`([\s\S]*?)<\/condition>/.exec(block.slice(0, block.indexOf('')))?.[1] ?? ''; + return raw.replaceAll('>', '>').replaceAll('<', '<').replaceAll('&', '&'); +} + +function safeEval(expression: string, scope: Record): boolean { + try { + return evaluateCondition(expression, scope); + } catch { + return false; + } +} diff --git a/activities/quote-archive/__tests__/steps.test.ts b/activities/quote-archive/__tests__/steps.test.ts new file mode 100644 index 0000000..1b89490 --- /dev/null +++ b/activities/quote-archive/__tests__/steps.test.ts @@ -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): 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([]); + }); +}); diff --git a/activities/quote-archive/src/case-model.ts b/activities/quote-archive/src/case-model.ts index 41d3d65..062c56b 100644 --- a/activities/quote-archive/src/case-model.ts +++ b/activities/quote-archive/src/case-model.ts @@ -1,95 +1,371 @@ /** - * A7 情报归档的案例模型 —— **一步落三张表**(2026-07-31 照 A1 的状态机模式重做)。 + * A7 情报归档的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。 * - * 归档这件事没有分支,也没有判断:一单报价有了结果,把它沉淀成下一单用得上的情报 —— - * 历史价、客户画像、落地率统计。上一版把这三样各建成一个计划项, - * 可它们**互不依赖、同一拍就能写完**,分开只换来两次多余的 finish。 + * 业务出处:`docs/MVP示例工作流.html` §1.6(S1 结果登记 → S2 画像更新 → S3 落地率统计), + * 按 A4/A6 同一条形状重建(2026-08-03 重写 @3.0.0): * - * 两项: - * T_archive —— 一步落三张表(历史价 / 客户画像 / 落地率); - * T_release —— 封卷放行(人工激活)。 + * 上游(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 = ` + - - + + + + + + + + - - update - archived_total > 0 + + + update + intel_total > 0 and quote_archive_intel_rows = intel_total and collected_total >= 0 + + + + update + attributed_total > 0 and quote_archive_intel_rows > 0 and quote_archive_intel_missing = 0 and quote_archive_intel_share_scope_pending > 0 + + + + update + 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 - - - + + + - - + + + + + + + + + + + + + + + `; -export const NARRATIVE = `§0 开案:一单报价有了结果(成交 / 拒绝 / 超时)即开案。 -这一单的价值**全在下一单**:今天记下的拒绝原因,是下次定价时的判据; -今天记下的成交价,是下次的基准。所以归档不是收尾的仪式,是情报生产。 +/** + * 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本,两处不重复写同一件事)。 + */ +export const NARRATIVE = `§0 开案:**一单报价有了结果,把它沉淀成下一单用得上的情报**。来路两种,形状都收(宽进): +· **流内转来**:任务数据里有 \`rows\` 数组 —— A6 的报价函行(每条带报价、客户表态与定案), + 外加 \`outcome\`(won / lost)说明这一单是成了还是丢了;这是最全的一种来路; +· **单飞**:只有一段需求文字,说了哪一单、给谁报的、什么价、结果如何。 -**一步就能办完** —— 三张表互不依赖,同一拍写完: -· \`market.price_history\`:这一单报了什么价、什么时候报的、结果如何、拒绝原因是什么; -· \`customer.profile\`:客户画像该更新什么(价格敏感度、决策周期、常用条款); -· \`shared.landing_stats\`:这个月的落地率、A 类命中、三日回复率。 +**成品只有一样**:一份**情报卡清单** —— 一颗料一条,每条都有**输赢归因**, +每条都定了**入库口径**。人签的不是「归了个档」,是**这一单留下了什么、下一单谁用得上**。 -**不得空归档**:三张表都得有东西。「这一单没什么好记的」不是理由 —— -结果本身就是情报,拒绝的原因尤其是。写完把 archived_total 记上,再 finish。 +**这一单的价值全在下一单**:今天记下的败因,是下次定价时的判据;今天记下的成交价, +是下次的基准。所以归档不是收尾的仪式,是情报生产。 +**「客户没回」不是原因** —— 问不出来就如实写「说不清」,**不许编一个听着合理的理由**: +编出来的原因会一路流进下一单的判据里,而那时已经没人记得它是编的。 -【封卷放行】**先 start T_release**(人工激活),提交前用 judge 裁掉裁量判据 -(criterion 照判据名原样写),然后 submit。终局唯放行:你不能自己宣布完成。 +**能算的不问人,该人说的不代说**:败价幅度 A-01(价差 → 复用建议)、入库口径建议 A-02 +(归因 → 建议档位),两支判定表说了算,你不临场拍;而**输赢归因、竞对价、客户原话 +三列是人落的,你一格都不写** —— 客户到底为什么不下单,系统里没有一个字段答得了。 -【一路上要说话】每一拍带一句 say,说**业务的话**:这一单最后怎么样、为什么、 -下次遇到同类该注意什么。一两句、口语。别报「写草稿 N 行」这种系统词。`; +**三步走**:归一情报卡 → 交 BD 逐条归因 → 入库口径拍板 → 放行闭环。 + +**这一单要经过人两次**:BD 在情报归档工作单上逐条落归因(那张单绑着整份情报卡, +归因缺一条按不下提交 —— 基座的闸看着,他填的格子直接落账); +**入库口径另叫他拍一次板**(一张决策卡)。竞对报价与客户身份写进共享情报库之后, +下一单谁都读得到 —— 这条边界不该由系统自己越过。最后 BD 放行签字。 + +**这是全流程最后一站**:放行之后没有下一个节点,只有一口 \`archived\`(归档闭环)。 +去向不必你选,也没得选。 + +【这一单只有一个岔路口,它长这样 —— 无论哪一拍都照这个发,别自己另编一张】 +入库口径要人拍板,那张决策卡**三处写死**: +· \`"itemId":"T_scope"\` —— 英文计划项号。**别写中文名「入库口径拍板」**:中文名不是 id, + 写了当场被拒(【目标缺口】那一段摆的就是中文名,别照它抄); +· \`"ask":" 条情报待定入库口径,按哪一档进库?"\` —— 只把 N 换成条数,**别的字一个都不改**。 + 换了问法,系统就认不出这是同一张卡:人已经点过的那张会被你再发一遍; +· 三个 \`value\`:\`full\`(全量入库)、\`masked\`(脱敏入库)、\`internal\`(只留内部)。 + label 与 consequence 可以按本单的数改写,**value 不行**。 +**FULL / MASKED / INTERNAL 三个码只有拍板那一步写得出来** —— 归一那一步遇到情报一律落 +PENDING,替他写了口径,那张卡就永远不会发,这条边界成了摆设。 +拍板之前**别急着发卡也别绕路** —— 归一那一步先把情报卡写完、finish; +下一拍引擎会把拍板那一步的做法摊开给你,那时再发。 + +【一路上要说话】负责人正看着这一单。**每一拍都带一句 say** —— 说业务的话: +这一单最后怎么样、归了几条情报、下次遇到同类该注意什么;不说系统词。 +到了要找人的那一拍,先说清这一轮办完了什么、为什么现在需要他。 + +【怎么把一项干完 · 一拍连做】write 和 tool 都不会自动收步 —— 做完用 finish 宣告。 +动作排进同一拍的 actions 串一口气给;只有发卡 / 提交(挂起)必须停拍。 +**这一单常路 6-8 拍就该收口**。 + +§Z 结案:BD 在验收单上签字,系统把草稿合并成正式情报卡并发出 \`archived\` 那一口 —— +这条工作流就此走到终点。归档做得好不好,不在这一单看得出来, +在下一次报价能不能一眼看到「上次为什么丢」看得出来。`; /** - * 步骤剧本 —— 与 A1 同一套。放行那一项不给 `doneWhen`(触达世界)。 + * 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。 + * + * 三处不给 `doneWhen`:`S2_attribute`(发卡触达真人)、`T_scope`(要人拍板)、 + * `T_release`(签下去触达世界)—— 给了等于给引擎一把替人签字的钥匙。 */ export const ARCHIVE_STEPS = [ { - item: 'T_archive', - intent: '一步落三张表:历史价、客户画像、落地率统计', - writes: ['market.price_history', 'customer.profile', 'shared.landing_stats', 'quote_archive.verdict'], - reads: [], + item: 'S1_collect', + intent: '整份归一成情报卡:逐条承继报价与结果、标上报价日期、口径一律先落待定', + writes: ['quote_archive.intel', 'quote_archive.verdict'], + /* 上游载荷数组不声明就会被整表段的截断刀砍掉(砍掉的恰是要归一的行) */ + reads: ['rows'], + view: 'archive-worksheet', tools: [], - // 三张都写了才算归档过。不写 `!= null` —— 未绑定的名字那样求值为 true(A1 踩过) - doneWhen: 'archived_total > 0', - guide: `三张表互不依赖,**同一拍写完**: -· \`market.price_history\`:报了什么价、什么时候、结果如何、拒绝原因; -· \`customer.profile\`:客户画像该更新什么(价格敏感度、决策周期、常用条款); -· \`shared.landing_stats\`:本月落地率、A 类命中、三日回复率。 -**不得空归档** —— 「没什么好记的」不是理由,结果本身就是情报,拒绝原因尤其是。 -**同一拍再写一行 \`quote_archive.verdict\`**(验收机检读的就是它): -· archived_total = 已归档条数; -· reason_logged_total = 已回写拒绝原因/竞对价位的条数(要 > 0); -· profile_updated_total = 已更新客户画像标签的条数(要 > 0); -· summary 一句人话结论。写不对 submit 会被门挡回来。写完再 finish。 -**每一行都要带 \`rfq_ref\`** —— 它是这一单的单标识(起单入参里就有,原样抄进去)。 -台账主键第一列就是它:不写,这一行会和**别的单**同料号的那一行撞主键,把人家覆盖掉 -(实测栽过:A4 的报价草案主键只有 pn,跑四轮只剩最后一轮的 3 行)。`, + /* + * 归一走完才算办完:行数两边对上(一边是基座现数), + * 且 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":"情报归因:<单号> 共 条待归因", + "ask":"这一单的情报卡已按报价与结果归好,报价、成交价与日期都在工作单上。请逐条落输赢归因:输在价格 / 交期 / 认证 / 规格 / 关系,还是说不清;输在价格的把竞对报的那个价也填上,并把客户原话记进依据一栏。", + "steps":["归因只有六档,选最主要的那一个:PRICE 价格 · LEADTIME 交期 · CERT 认证资质 · SPEC 规格不匹配 · RELATION 关系或既有供应商 · OTHER 说不清", + "「客户没回」不是原因 —— 问不出来就落 OTHER 并在依据里写明「未获反馈」,不要编一个听着合理的理由:它会一路流进下一单的判据里", + "归因落 PRICE 的行必须填竞对价:没有那个数,「输在价格」下一单读不出任何东西 —— 缺这一格这张单按不下提交", + "依据一栏尽量记客户原话:「你们比 XX 贵两毛」比「价格偏高」值钱得多 —— 下一单定价时看的就是这一句"], + "standard":"整份情报卡每一条都有归因,归因落 PRICE 的那几条填了竞对报的价 —— 缺一条这张单按不下提交(系统的闸看着)。", + "basis":[{"k":"","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 换成条数** —— + 换了问法系统就认不出这是同一张卡,人已经点过的那张会被你再发一遍(实测发出去两张)。 +· 三个 \`value\` 照抄 \`full\` / \`masked\` / \`internal\` —— 它们是三档的代号, + 换了名字下一段的对应关系就对不上,你自己也不知道人点的是哪一档。 + label 与 consequence 可以按本单的数改写,value 不行。 +{"kind":"decide","itemId":"T_scope","ask":" 条情报待定入库口径,按哪一档进库?", + "options":[ + {"value":"full","label":"全量入库(客户与竞对都留名)","consequence":"下一单查这颗料时,客户是谁、竞对是谁、报了多少一目了然 —— 情报最值钱,但客户身份与第三方报价也就此对内公开"}, + {"value":"masked","label":"脱敏入库(只留价位与败因)","consequence":"竞对与客户的名字抹掉,价位、归因、日期照留 —— 下一单照样判得了价,追不到是谁说的"}, + {"value":"internal","label":"只留内部(不进共享情报库)","consequence":"这一单的情报只在本案卷里躺着,下一单查不到 —— 涉密或客户明说不外传时才选这一档"}], + "basis":[{"k":"","v":"归因 <码> · 报价 · 竞对价 · 败价幅度 <码>"}]} +· 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: '情报已沉淀,封卷放行', + intent: '情报已沉淀、口径已定,提交放行交 BD 验收', writes: [], reads: [], + view: 'archive-roster', tools: [], - guide: `**先 start T_release**(人工激活),提交前用 judge 裁掉裁量判据,然后 submit。 + /* 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 doneWhen) */ + guide: `每条都有归因、口径全定过、待定清零 —— 可以走放行了 +(T_release 人工激活:动它就算启动,不必单发 start)。先把裁量判据裁掉,然后 submit,同一拍连做: + +· judge criterion="情报站得住:每条归因说得出来路、竞对价与客户原话可溯,BD 对这份情报负责" —— + 看情报卡里每一行的归因与依据:说得出来路就 satisfied=true; + 有行说不出的(比如归因落了 PRICE 却没有客户原话),satisfied=**false**,rationale 逐行写清, + 交签字的人定。**裁成不满足照样能提交** —— 理由连着卡一起交给他。 + criterion 照判据名**整句原样抄**,写别的名字对不上。 + +**去向没得选**:这是全流程最后一站,只有一口 \`archived\`(归档闭环),放行那一刻自动发射。 +你要做的只是把情报落准,别在提交里手选出口。 + 终局唯放行:你不能自己宣布完成。`, }, ]; + +/** + * 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段; + * 放行那一格由基座统一加,人看到的是 x/4。 + */ +export const ARCHIVE_PHASES = [ + { name: '归一情报卡', items: ['S1_collect'] }, + { name: '逐条归因', items: ['S2_attribute'] }, + { name: '入库口径拍板', items: ['T_scope'] }, +]; diff --git a/activities/quote-archive/src/index.ts b/activities/quote-archive/src/index.ts index 0d25cba..e38317f 100644 --- a/activities/quote-archive/src/index.ts +++ b/activities/quote-archive/src/index.ts @@ -1,36 +1,18 @@ /** - * 统一报价 A7 · 情报归档 —— 活动包代码入口(ADR-004)。 - * 「不成交也留资产」:把归档结果摆给人看,确认无空档再提交放行。 + * 统一报价 A7 · 情报归档 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。 + * + * **这个活动没有 `run`,不是缺件。** + * + * 案例活动的推进由 Loop 驱动,行为全部写在清单里:案例模型 + 说明书 + 步骤剧本 + + * 两把判定表 + 技能 + 台账声明 + 两张 HTML 报告模版。 + * 上一版这里有一段 `run()`(读 `shared.history_price`、推一个 kv 块、按行数返回 + * await/submit)—— 案例活动根本不会调它;更坏的是它读的那张表**从来没被声明过** + * (声明的是 `market.price_history`),而它推的那套呈现与 `viewTemplates` 里的模版 + * 各说各话,没人看得出以哪份为准。 + * + * 这个文件之所以还存在,是因为清单的 `entry` 指着它 —— 它是**被静态审扫描的产物文本** + * (查 import 与全局引用,证明活动包零环境能力)。 */ import { defineActivity } from '@sdk'; -import type { ActivityBase, ActivityOutcome } from '@sdk'; -export default defineActivity({ - name: 'quote-archive', - - async run(base: ActivityBase): Promise { - 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' }); diff --git a/activities/quote-archive/src/manifest.ts b/activities/quote-archive/src/manifest.ts index 61b9d59..28a0a88 100644 --- a/activities/quote-archive/src/manifest.ts +++ b/activities/quote-archive/src/manifest.ts @@ -1,139 +1,392 @@ /** - * 活动包清单 —— 统一报价 A7 · 情报归档。 + * 活动包清单 —— 统一报价 A7 · 情报归档(案例活动)。 * - * 业务素材:docs/MVP示例工作流.html §1.7(S1 结果登记 → S2 画像更新 → S3 落地率统计)。 - * 「不成交也留资产:情报、客户画像、落地率」—— 写的是**共享域**,跨活动的沉淀事实。 + * 业务素材:`docs/MVP示例工作流.html` §1.6(S1 结果登记 → S2 画像更新 → S3 落地率统计): + * 「不成交也留资产:情报、客户画像、落地率」 + * 「拒绝或无回复的单子 7 天内闭环归档。每一单不成交的报价也是情报资产 —— + * 沉淀下来,下一单更快更准」。 + * + * 形状与 A4/A6 同一条(2026-08-03 重写 @3.0.0):归一情报卡 → 一张执行单交人 + * (逐条落输赢归因与竞对价)→ 按败价幅度给复用建议 → **入库口径要人拍板** + * (决策卡)→ 放行归档闭环。 + * **能算的不问人**(A-01 败价幅度、A-02 入库口径建议两把尺写死), + * **该人说的不代说**(归因、竞对价、客户原话那三列 AI 一格都不写)。 + * + * 与 @2.1.0 的差别(六处都是踩过的账,逐条见 case-model 头注): + * ① `input.required` 从 `['rfqRef','customerCode','quoteNo']` 清空 —— 宽进严出; + * ② 两条 L1/L2 判据读 `reason_logged_total` / `profile_updated_total` —— + * **活动自报的数**,写个 1 就过(「机检只读基座数」违反史第七类); + * ③ 台账补 `required` / `requiredWhen` —— 从前一个必填都没有,执行卡的闸没牙; + * ④ 补两处人的判断(归因 + 入库口径)—— 从前 AI 自己写完自己放行; + * ⑤ 三张共享域表退役(墓志铭见 `declarations.ledger` 头注); + * ⑥ `index.ts` 里那段 `run()` 删掉 —— 案例活动不会调它。 */ -import type { ActivityManifest } from '@contracts'; -import { ARCHIVE_SKILLS } from './skills.ts'; -import { CMMN_XML, NARRATIVE, ARCHIVE_STEPS } 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'); -/** 本活动自己的数据域 —— 三张沉淀表落 `shared`,只有归档结论落这儿 */ -const DOMAIN = 'quote_archive'; + +/** + * 两把尺 —— **规则本身不是数据**,随包冻结。 + * + * · 败价幅度 A-01:我方报价与竞对价的价差 → 这条情报下一单该怎么用 + * (出参就是台账 `price_gap_band` 的取值); + * · 入库口径建议 A-02:输赢归因 → 决策卡上标 recommended 的那一档 + * (出参是三个 `value` 之一,不落台账 —— 建议不是结论,结论由人拍)。 + * + * 出参键名与台账列同名,模型算完照抄即可(规范 §5.6)。 + * A-01 的档位在三处出现:这张表、剧本 guide、工作单上的规则块 —— + * 包内自测把三处钉在一起(改一处不改另两处,测就红)。 + */ +export const A01_DMN: DmnModel = { + drd: [ + { decision: 'a01-band', requires: [], inputs: ['gap'] }, + { decision: 'a02-scope', requires: [], inputs: ['cause_tag'] }, + ], + tables: [ + { + /** 败价幅度 A-01 —— FIRST 命中即定,所以第二行等价于「三到十个点」 */ + id: 'a01-band', + label: '败价幅度 A-01', + hitPolicy: 'FIRST', + inputs: [{ name: 'gap', expression: 'gap' }], + outputs: [{ name: 'price_gap_band' }], + rules: [ + { id: 'a01-1', when: ['<= 0.03'], then: ['SLIGHT'], annotation: '三个点以内 —— 擦肩而过,下一单同价位再试,输的多半不在价格' }, + { id: 'a01-2', when: ['<= 0.10'], then: ['MODERATE'], annotation: '三到十个点 —— 够得着,下一单让到这一档或换替代料降本' }, + { id: 'a01-3', when: ['> 0.10'], then: ['STRUCTURAL'], annotation: '超过一成 —— 结构性价差,下一单别再报同一价位,先走替代选型' }, + ], + }, + { + /** + * 入库口径建议 A-02 —— 只出**建议**,决策卡上那一枚 recommended 就是它。 + * 带着第三方报价的情报默认建议脱敏:竞对报价写进共享库,下一单谁都读得到。 + */ + id: 'a02-scope', + label: '入库口径建议 A-02', + hitPolicy: 'FIRST', + inputs: [{ name: 'cause_tag', expression: 'cause_tag' }], + outputs: [{ name: 'scope_hint' }], + rules: [ + { id: 'a02-1', when: ['"PRICE"'], then: ['masked'], annotation: '归因带竞对报价 —— 建议脱敏入库:价位与败因照留,谁说的抹掉' }, + { id: 'a02-default', when: ['-'], then: ['full'], annotation: '不涉第三方报价的败因 —— 建议全量入库,下一单查得越全越好' }, + ], + }, + ], +}; export const manifest: ActivityManifest = { identity: { activityId: ACTIVITY_ID, name: 'A7 · 情报归档', - packageVersion: '2.1.0', + 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: ['rfqRef', 'customerCode', 'quoteNo'], + required: [], properties: { - /** 归档写 (pn, quoted_at) 这一行,两个主键都由调用方给定 */ rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' }, - pn: { type: 'string' }, - quotedAt: { type: 'string' }, - customerCode: { type: 'string' }, - quoteNo: { type: 'string' }, - reason: { type: 'string' }, - competitorPrice: { type: 'number' }, + 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: [], }, /* - * 必交付物**必须给 `ledger` 地址**(2026-07-31 修)。 - * 不给的话预检退回 `scope[key]` 去找 —— key 是 `benchmark-table` 这样的短横线别名, - * 任务视图里永远没有这个键,于是「必交付物缺一不得提交放行」永远拒, - * 活办完了也放不了行,连拒五拍熔断(实测 A2 死于此)。 + * 必交付物只有一件:**情报卡清单**。归档结论(verdict)是它的结论栏, + * `ledgers` 说清这份成果由哪几张表承载(一份成果,不是两份)。 */ requiredDeliverables: [ - { key: 'history-archive', label: '历史价格情报(归档)', ledger: 'market.price_history' }, - { key: 'landing-stats', label: '报价落地率统计', ledger: 'shared.landing_stats' }, + { + key: 'intel-archive', + label: '情报卡清单(逐条输赢归因 + 入库口径)', + ledger: `${D}.intel`, + /* 验收页上让 BD 就地改的两列 —— 恰是他的职权面(归因与依据) */ + editableAtRelease: ['cause_tag', 'evidence'], + ledgers: [`${D}.intel`, `${D}.verdict`], + }, ], /* - * 机检谓词读的是 `verdict` 那张**汇总表**的列(照 A1 的口径)。 - * 上一版的名字(reasonLogged / profileUpdated)**台账里根本没有** —— S-FEEL 拿未绑定的名字 - * 比大小求值为假,判据永远达不了标,submit 门永远拒,连拒五拍熔断。 - * 判据的名字必须落在台账上,否则「机检」是一句空话(2026-07-31 销账)。 + * 验收判据 —— **机检(L1/L2)只读基座数得出来的数**: + * `_rows` 基座数行数、`_missing` 基座按 required / requiredWhen 现数 + * (与执行卡提交闸同一个数同一套算法)、`_<列>_<值>` 基座按值现数。 + * 上一版两条机检读活动自报的 `reason_logged_total` / `profile_updated_total` —— + * 那不是门,是「改哪个数能过」的提示(写个 1 就过去了)。 */ acceptanceCriteria: [ - { key: 'reason-logged', label: '拒绝原因 / 竞对价位已回写', level: 'L1', predicate: 'reason_logged_total > 0' }, - { key: 'profile-updated', label: '客户画像标签已更新', level: 'L2', predicate: 'profile_updated_total > 0' }, - { key: 'bd-release', label: 'BD 放行', level: 'L5' }, + { + 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' }, ], revisableAtRelease: [], - prohibitions: [{ key: 'no-blank-archive', statement: '不得空归档 —— 每一单不成交的报价也是情报资产' }], - authorization: { discretion: [], preauthorizedActions: [], humanCallBudget: 2 }, + 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: 'market', - object: 'price_history', - kind: 'master', - 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: '竞对价位' }, - ], - }, - { - domain: 'customer', - object: 'profile', - kind: 'master', - label: '客户资料库', - fields: [ - { name: 'customer_code', type: 'text', primaryKey: true, masked: false, label: '客户码' }, - { name: 'grade', type: 'text', primaryKey: false, masked: false, label: '客户等级' }, - { name: 'profile_tags', type: 'text', primaryKey: false, masked: false, label: '画像标签' }, - ], - }, - { - domain: 'shared', - object: 'landing_stats', - kind: 'derived', - label: '报价落地率统计', - fields: [ - { name: 'period', type: 'text', primaryKey: true, masked: false, label: '月份' }, - { name: 'landing_rate', type: 'number', primaryKey: false, masked: false, label: '落地率' }, - { name: 'type_a_hit', type: 'number', primaryKey: false, masked: false, label: 'TypeA命中' }, - { name: 'reply_rate_d3', type: 'number', primaryKey: false, masked: false, label: 'D+3回复率' }, + { name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' }, + { name: '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'], + }, ], }, { /* - * 归档结论 —— 一单一行的**汇总表**,验收机检谓词的入参就在这儿。 + * 归档结论 —— 一单一行的**汇总表**,哨兵与报告读它。 * 汇总列与逐行列**故意不同名**:任务视图把草稿行摊平到顶层是「后写的赢」, * 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。 */ - domain: DOMAIN, + domain: D, object: 'verdict', kind: 'record', caseKey: 'rfq_ref', @@ -141,42 +394,179 @@ export const manifest: ActivityManifest = { fields: [ { name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' }, { name: 'quote_no', type: 'text', primaryKey: false, masked: false, label: '报价单号' }, - { name: 'archived_total', type: 'integer', primaryKey: false, masked: false, label: '已归档条数' }, - { name: 'reason_logged_total', type: 'integer', primaryKey: false, masked: false, label: '已回写原因条数' }, - { name: 'profile_updated_total', type: 'integer', primaryKey: false, masked: false, label: '已更新画像条数' }, + { name: 'customer_code', type: 'text', primaryKey: false, masked: false, label: '客户码' }, + { name: 'customer_name', type: 'text', primaryKey: false, masked: false, label: '客户名称' }, + { name: 'outcome', type: 'text', primaryKey: false, masked: false, label: '这一单最后成没成(won/lost)' }, + { name: 'intel_total', type: 'integer', primaryKey: false, masked: false, label: '情报卡条数' }, + /* 归一走完的凭据(0 也要写)—— 哨兵按它放交人归因那一步入场 */ + { name: 'collected_total', type: 'integer', primaryKey: false, masked: false, label: '已归一条数' }, + /* 「归因落齐了」的凭据 —— 只在人环走完才产生 */ + { name: 'attributed_total', type: 'integer', primaryKey: false, masked: false, label: '已落归因条数' }, + { name: 'scope_decided_total', type: 'integer', primaryKey: false, masked: false, label: '入库口径已拍板' }, + { name: 'share_policy', type: 'text', primaryKey: false, masked: false, label: '入库口径(人拍的)' }, + /* MVP §1.6 S2「画像更新」的落点 —— 一句话,不代笔改主数据 */ + { name: 'buyer_profile', type: 'text', primaryKey: false, masked: false, label: '客户行为画像' }, { name: 'summary', type: 'text', primaryKey: false, masked: false, label: '归档结论' }, ], }, ], - listeners: [], - // 7 天闭环(MVP示例 §1.7) - harness: { memoryWindow: 15, stallAfter: 'P1D', nudge: [], retry: { times: 2, backoff: 'PT30S' } }, + /* + * 两条静态监听 —— **时限必配**(AM 4.4)。MVP §1.6 写死「7 天闭环」: + * 归档没有外部世界的回音要等,这两条管的是「别把它挂到天荒地老」—— + * D+3 提醒把归因补上,D+7 到期升级。 + */ + listeners: [ + { + origin: 'static', + eventType: 'time', + keyFilter: { kind: 'archive-d3' }, + semantic: 'observe', + deadline: 'P3D', + escalation: ['nudge'], + }, + { + origin: 'static', + eventType: 'time', + keyFilter: { kind: 'archive-deadline' }, + semantic: 'observe', + deadline: 'P7D', + escalation: ['nudge', 'escalate'], + }, + ], + harness: { + memoryWindow: 22, + // 7 天闭环是对**这一单办完**的约束;活本身只需两个人环 + stallAfter: 'PT2H', + nudge: [{ after: 'PT1H', action: 'nudge' }], + retry: { times: 2, backoff: 'PT30S' }, + }, }, + /* ── 资产初始版 ──────────────────────────────────────────────── */ assets: { - /* 资产初始版:案例模型(2026-07-29 起活动只有这一种业务模型) */ businessModel: { cmmnXml: CMMN_XML, narrative: NARRATIVE, - dmn: { drd: [], tables: [] }, - /* 步骤剧本 —— 与 A1 同一套:doneWhen 真了引擎自己收步,不问模型 */ + 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'] }, }, ], diff --git a/activities/quote-archive/src/skills.ts b/activities/quote-archive/src/skills.ts index 8d0256b..65ccf2c 100644 --- a/activities/quote-archive/src/skills.ts +++ b/activities/quote-archive/src/skills.ts @@ -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: [], diff --git a/activities/quote-archive/src/views/prd-style.ts b/activities/quote-archive/src/views/prd-style.ts new file mode 100644 index 0000000..9d2c0b5 --- /dev/null +++ b/activities/quote-archive/src/views/prd-style.ts @@ -0,0 +1,409 @@ +/** + * A1 两份执行页的样式表 —— **从 PRD 目标件原样搬过来**,不是我照着复述的。 + * + * 出处:`~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理需求.html` 的整段 ` +${PRD_SPRITE} +
+
+
+
${ico(input.badge, 17)}
+
+ ${input.kicker} +

${input.title}

+
+ ${input.state} +
+
${input.body}
+
${input.foot}
+
+
`; + +const part = (label: string, aside = '', tone = ''): string => + `
${label}${aside === '' ? '' : `${aside}`}
`; + +const sign = (note: string, tag = ''): string => + `
+ ${ico('user', 15)} +
${note}
+
${tag === '' ? '' : `${ico('git-branch', 13)}${tag}`}`; + +/* ==================== 组成件 ==================== */ + +/** 要素四卡:客户 / 待归因 / 一格一料的结果刻度 / 情报时效 */ +const FOUR = `
+
+ ${ico('building')}客户公司 +
+ +
+
+ 承自上游;单飞补历史时此栏可空 +
+ +
+ ${ico('boxes')}待归因 +
/ 条情报
+ 输赢归因还空着的行 —— 这一列只有跟单的人填得了,系统不替人归因;补一格少一格,基座现数 +
+ +
+ ${ico('shield-check')}逐条结果 +
+ +
+ 一格一条料 · 绿=成交(下一单的基准价),灰=不成交(下一单的判据在归因里) +
+ +
+ ${ico('history')}情报时效 + ${ico('check', 13)}报价日 + 下一单的三分类 P-03 看的就是「距上次报价多久」—— 这一格空着,这条情报到下一单就哑了 +
+
`; + +/** 一单情报是怎么走完的 —— 走位图(形状写死,不算不判) */ +const ARCHIVE_LINE = `
+
既成事实报过的价、谈成的价、什么时候报的 —— 一个数字都不改
+
${ico('arrow-right', 18)}
+
输赢归因价格 / 交期 / 认证 / 规格 / 关系 / 说不清 —— 这一格由跟单的人落
+
${ico('arrow-right', 18)}
+
败价幅度 A-01价差 ≤3% / 3~10% / >10%,决定下一单怎么用这条情报
+
${ico('arrow-right', 18)}
+
入库口径全量 / 脱敏 / 只留内部 —— 进共享库的边界由人拍板
+
${ico('arrow-right', 18)}
+
下一单三分类取时效 · 定价取基准 · 客户画像取行为
+
`; + +/** 缺归因红带 —— 与提交闸、放行门 L1 同一个数同一套算法(基座现数);一格不缺自动收 */ +const GAP_BAR = `
${ico('alert', 14)} + 还差 行归因没落 + —— 。 + 按右上角「编辑」开表,红框那几格就是要填的:输在价格填 PRICE 并把竞对报的那个价填进「竞对价」, + 交期填 LEADTIME,认证资质填 CERT,规格不符填 SPEC,既有供应关系填 RELATION, + 问不出来填 OTHER 并在依据里写明未获反馈。补齐了执行单才提交得了。 +
`; + +const editGate = (address: string, what: string): string => `
+ ${ico('lock', 12)}${what}已锁定 —— 看的时候不误碰,要改按右边 + + + +
+
${ico('pen', 14)} + 可填的是输赢归因、竞对价、竞对是谁与依据 / 客户原话四列: + 这四列系统一格都不写 —— 客户为什么不下单只有你看得见,别让系统替他解释。 + 归因落 PRICE 的行务必把竞对报的那个数填上,败价幅度全看这一格。不该归档的料按行首 × 撤掉。 + 改动只落任务草稿层,保存不等于放行。 +
`; + +const addRowBar = (address: string): string => `
+ + 照末行的样子添一行空的;主键(型号 PN)填了才存得下,不填这一行不落库。 +
`; + +/** + * 情报卡主表 —— 一行一料:既成事实列 + 人那一侧四列 + 算出来的败价幅度。 + * + * `data-col` 只挂人可改的四列(cause_tag / competitor_price / competitor_name / evidence) + * + 主键 pn —— 编辑面精确等于 BD 的职权面;**主键格必挂 `data-col`**(它不是编辑面, + * 是行的钥匙:不挂,服务端按「全表一致列」补键,两行会对到同一行上,后者盖前者 —— + * A2 走查逮过)。 + * 归因挂 `req`:空着出「必填」胶囊、整行标底,与提交闸、放行门同源(都数 `_missing`)。 + */ +const INTEL_TABLE = `
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
果PN品类年用量报价日期我方报价成交价输赢归因(你落)竞对价竞对是谁败价幅度依据 / 客户原话
+ + +
`; + +/** + * 情报清单表(收口件用)—— 编辑面换成**放行时可改的两列**(归因与依据), + * 与 `requiredDeliverables[].editableAtRelease` 逐字对齐。 + */ +const ARCHIVED_TABLE = `
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
果PN报价日期我方报价成交价输赢归因竞对价败价幅度入库口径依据 / 客户原话
+ + +
`; + +/** 入库口径分布:四段按条数分宽 —— 守恒等式(四档相加 = 行数)的可视形 */ +const DIST = `
+
+ + + + +
+
+ 全量入库 · 客户与竞对都留名 + 待拍板 · 口径还没定 + 脱敏入库 · 只留价位与败因 + 只留内部 · 不进共享库 +
+
`; + +/* ==================== 局面一 · 情报归档工作单(归一 / 逐条归因)==================== */ + +/** + * 逐颗情报档案 —— 一颗料一张卡(data-rows 逐行复制):左既成事实、右归因。 + * + * **容器用 `
` 不用 `
`**:渲染器的行展开按第一个同名闭合标签截行内容, + * 行模版里嵌着 `
` 时 `
` 会把行截成套娃(A2 逮过,记在陷阱表)。 + */ +const INTEL_CARDS = `
+
+
+ + + + + +
+
+
+
既成事实(报过的价,一个数字都不改)
+
客户
+
品类 / 制造商
+
年用量
+
我方报价
+
成交价
+
报价日期
+
结果
+
+
+
归因(填回下表,这里回显)
+
输赢归因
+
竞对价
+
竞对是谁
+
依据 / 原话
+
败价幅度
+
入库口径
+
+
+
+
`; + +/** 归因六档 —— 静态规则,尺挂在墙上(素材:MVP 示例工作流 §1.6 结果登记) */ +const CAUSE_RULES = `
+
+ PRICE +

输在价格。客户明说了别家更便宜,或直接给出一个我们够不着的数 —— + 这一档必须填竞对价:没有那个数,「输在价格」下一单读不出任何东西, + 而有了它,败价幅度 A-01 当场算得出来。

+
+
+ LEADTIME +

输在交期。客户等不起 —— 别家现货、我们八周。 + 价格再低也没用,下一单同一颗料先问现货商。

+
+
+ CERT +

认证资质。车规、医疗、军工的门槛没过,或供应商资质不在客户合格名录里 —— + 下一单这一类需求先核认证,别报完价才发现进不去。

+
+
+ SPEC +

规格不匹配。参数、封装、温度范围对不上 —— 这是选型的账, + 下一单同一需求直接从替代选型开始,别在原厂料上耗。

+
+
+ RELATION +

既有供应关系。客户手上已经有稳定的供应商,这一轮只是来比个价 —— + 情报照记:下一单知道要拿什么去撬,或者干脆不投这个标。

+
+
+ OTHER +

说不清。「客户没回」不是原因 —— 问不出来就落这一档, + 并在依据里写明「未获反馈」。不许编一个听着合理的理由: + 编出来的原因会一路流进下一单的判据里,而那时已经没人记得它是编的。

+
+
`; + +/** 败价幅度 A-01 的三档 —— 静态规则(素材:MVP 示例工作流 §1.6 情报复用) */ +const BAND_RULES = `
+
+ ≤ 3% +

擦肩而过。三个点以内的价差不足以定输赢 —— 下一单同价位再试, + 真正输的多半在交期、认证或关系上,回头看看依据那一栏写了什么。

+
+
+ 3% ~ 10% +

够得着。下一单要么让到这一档,要么换替代料把成本降下来 —— + 这是替代选型最值钱的入口:同样的功能,换一颗料就把十个点让出来了。

+
+
+ > 10% +

结构性价差。一成以上不是让价能填的坑,是成本结构不对 —— + 下一单别再报同一价位,先走替代选型或重新寻源。拿同一个价再报一次不叫坚持,叫没记账。

+
+
`; + +export const ARCHIVE_WORKSHEET = doc({ + badge: 'file-text', + kicker: '统一报价 · A7 情报归档 · 归因作业', + title: '情报归档工作单', + titleRef: 'rfq_ref', + state: + '待归因 / 条', body: - statsBand([ - ['报价日期', 'quoted_at'], - ['竞对价位', 'competitor_price'], - ['客户等级', 'grade'], - ]) + - section('本单登记的结果', - tableOnly('mine:shared.history_price', [ - ['型号 PN', 'pn', 'mono'], - ['报价日期', 'quoted_at', 'mono'], - ['拒绝原因', 'lost_reason'], - ['竞对价位', 'competitor_price'], - ]) + - noteOnly( - '「客户没回」不是原因。问不出来就写「未获反馈」—— 不许编一个听着合理的理由:' + - '编出来的原因会一路流进下一单的判据里,而那时已经没人记得它是编的。', - ), - { text: '只列本单写下的行' }, - ) + - section('什么样的原因才算数', - ladder([ - ['1', '竞对报价多少、差几个百分点', '要数,不要形容词'], - ['2', '输在价格、交期,还是资质 / 认证', '把败因归到一个维度上'], - ['3', '客户是否明说了可接受区间', '下一单的起点就藏在这句话里'], - ]) + - noteOnly( - '「价格高」不是原因,是结论。判据要能被下一单机器读取:' + - 'P-03 用「距上次报价 / 行情稳定」,P-01 用客户等级。' + - '写成一段散文,它就只能被人读;写成字段,它才进得了决策表。', - ), - { text: '写成字段' }, - ) + - section('这一单往哪儿沉 —— 三张共享域表', - ladder([ - ['1', '历史价格情报 · shared.history_price', '喂给下一单的三分类 P-03'], - ['2', '客户资料库 · shared.customer', '喂给下一单的利润模型 P-01'], - ['3', '报价落地率统计 · shared.landing_stats', '喂给驾驶舱的落地率与 D+3 回复率'], - ]), - { text: '跨活动的沉淀事实' }, - ) + - section('这一单的底线', - verdict('不得空归档 —— 每一单不成交的报价也是情报资产', 'wait', '原因未回写,预检当场退回') + - noteOnly( - '归档做得好不好,不在这一单看得出来 —— 在下一次报价能不能一眼看到「上次为什么丢」看得出来。', - ), - ), + part('归档任务', '客户 · 报价单 · 共 条') + + `
${ico('info', 14)} + 这一屏是一份情报的作业台:系统已按上游的结果归好情报卡,报价、成交价与日期都在下面 —— + 既成事实一个数字都不改。你要做的是逐条落输赢归因:客户到底为什么下单、 + 为什么不下单。这一列系统一格都不写:那件事只有跟单的人知道。 + 这一单之后再没有人会回头看它 —— 归因写错没有下一道工序纠正, + 而它会一路流进下一单的判据里。 +
` + + 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') + + `
${ico('info', 13)} + 带红标的输赢归因是必填,而且只由你填 —— 缺一行执行单按不下提交, + 与放行门是同一个数同一套算法。六个取值一个字母不许差: + PRICE 价格 · LEADTIME 交期 · CERT 认证资质 · SPEC 规格不符 · + RELATION 既有供应 · OTHER 说不清。 + 归因落 PRICE 的行务必把竞对报的那个价填进竞对价 —— 那一格空着这张单同样提交不了, + 败价幅度全看它。依据那一栏尽量记客户原话:「你们比 XX 贵两毛」比「价格偏高」 + 值钱得多 —— 下一单定价时看的就是这一句。不该归档的料按行首 × 撤掉。 +
`, + foot: sign('BD 归因 · AI 归一情报卡并按尺算败价幅度,输赢的缘由由你落', '统一报价 · A7 情报归档 · 在办'), }); -/** 局面二:闭环待放行 —— 签完这一笔,整条工作流走到终点 */ -export const ARCHIVE_RELEASE = viewShell({ - title: '情报归档 · 闭环待放行', - kicker: 'A7 · 情报归档 · 成果待签', - tone: 'case', - icon: 'archive', - stamp: { kind: 'live', text: '待签字封卷' }, - sign: 'A7 情报归档 · 闭环成果(待放行)', +/* ==================== 局面二 · 情报归档清单(拍板 / 放行 / 归档)==================== */ + +/** + * 落边图:情报卡清单 → 归档闭环 → 下一单的三个用处。 + * + * A7 是流程包的最后一站,**只有一口且无条件**(E14)—— 这条边不是选出来的, + * 所以画成实线亮是如实,不是替谁宣布。右边三个用处是这份情报的去处, + * 各挂本单条数(基座现数),没有的那一档条数为 0、人一眼看得出。 + */ +const ROUTE_FIG = `
+ + + 情报卡清单 + 共 条 + + + + 归档闭环 + 这条流走到终点 + + + + + + + 时效 · 报价日期 + 价格 条 + 行为画像 + + 下一单 · 三分类 P-03 + 距上次报价多久 · 行情稳不稳 + + 下一单 · 成本定价 + 基准价与竞对价位 + + 客户画像 · 利润模型 P-01 + 价格敏感度 · 决策周期 + +
+ A7 只有一口且无条件 —— 它不是选出来的,是这条流的终点 + 右边三个用处是这份情报的去处:入库口径定的就是它们读不读得到 + 只留内部那一档不进共享库 —— 三条虚线到那一单就全断了 +
+
`; + +/** 入库口径三档 —— 静态规则,决策卡上那一枚 recommended 由 A-02 标 */ +const SCOPE_RULES = `
+
+ 全量入库 +

客户与竞对都留名。下一单查这颗料时,客户是谁、竞对是谁、报了多少一目了然 —— + 情报最值钱的形态。代价是客户身份与第三方报价就此对内公开。

+
+
+ 脱敏入库 +

只留价位与败因。竞对与客户的名字抹掉,价位、归因、日期照留 —— + 下一单照样判得了价,只是追不到是谁说的。带着第三方报价的情报默认走这一档: + 竞对的价是他给客户的,不是给我们的。

+
+
+ 只留内部 +

不进共享情报库。这一单的情报只在本案卷里躺着,下一单查不到 —— + 涉密、客户明说不外传、或项目还在谈时才选这一档。选它等于承认这份情报只值一次。

+
+
`; + +export const ARCHIVE_ROSTER = doc({ + badge: 'file-check', + kicker: '统一报价 · A7 情报归档 · 交付成果', + title: '情报归档清单', + titleRef: 'rfq_ref', + state: + '情报 条 · 待拍板 条(要归零)', body: - statsBand([ - ['统计周期', 'period'], - ['落地率', 'landing_rate'], - ['TypeA 命中', 'type_a_hit'], - ['D+3 回复率', 'reply_rate_d3'], - ]) + - section('历史价格情报回写', - tableOnly('mine:shared.history_price', [ - ['型号 PN', 'pn', 'mono'], - ['报价日期', 'quoted_at', 'mono'], - ['拒绝原因', 'lost_reason'], - ['竞对价位', 'competitor_price'], - ]), - { text: '下一单 P-03 取它' }, - ) + - section('客户画像更新', - tableOnly('mine:shared.customer', [ - ['客户码', 'customer_code', 'mono'], - ['客户等级', 'grade'], - ['画像标签', 'profile_tags'], - ]), - { text: '下一单 P-01 取它' }, - ) + - section('报价落地率统计', - tableOnly('mine:shared.landing_stats', [ - ['月份', 'period', 'mono'], - ['落地率', 'landing_rate'], - ['TypeA 命中', 'type_a_hit'], - ['D+3 回复率', 'reply_rate_d3'], - ]) + - noteOnly( - '三张表都写在共享域:它们是跨活动的沉淀事实,不属于这一单 ——' + - '这也是活动之间除了流程边以外,唯一一条合法的信息通路。', - ), - { text: '驾驶舱取它' }, - ) + - section('放行前要过的三道判据', - ladder([ - ['1', '拒绝原因 / 竞对价位已回写', 'L1 机检 · reasonLogged = true'], - ['2', '客户画像标签已更新', 'L2 机检 · profileUpdated = true'], - ['3', 'BD 放行', 'L5 人签 · 签了才算数'], - ]), - { text: '两机检 · 一人签' }, - ) + - section('封卷之后会发生什么', - verdict('放行即闭环:这条工作流就此收敛为「已完成」', 'wait', '终态封卷触发蒸馏钩子') + - noteOnly( - '蒸馏从任务记忆归纳出活动记忆的候选条目(带证据链),经回放与人放行才成为' + - '下一版业务模型的一部分 —— 记忆不载权力。这一单丢了,但它把下一单的地形描出来了。', - ), - ), + part('归档要素', '情报归档 · 能算的归判定表,要担责的归人') + + FOUR + + part('入库口径分布', '全量 + 待拍板 + 脱敏 + 只留内部 = 总条数') + + DIST + + `
${ico('info', 14)} + 四段条数由基座逐行现数,不是谁报上来的。橙段(待拍板)是过渡态 —— + 情报归好了而口径还没定,它归零这一单才走得到放行门。 + 进了共享情报库的东西下一单谁都读得到,这条边界过去了就收不回来 —— + 所以系统不替你定,只按 A-02 给一枚建议。 +
` + + part('情报卡清单', ' 条 · 每条都有归因') + + GAP_BAR + + editGate('quote_archive.intel', '情报清单表') + + ARCHIVED_TABLE + + `
${ico('info', 13)} + 每一行摆的是这一条的来路:报了多少、成没成、输赢归在哪一档、 + 竞对报了什么价、败价幅度落在哪一级。签字前请重点看两处: + 归因列里落了 OTHER 的条目(说不清的情报到下一单就是哑的), + 与依据列 —— 没有客户原话的归因,下一单只能当传说看。 + 这一屏可改的是输赢归因与依据两列(你的职权面)。 +
` + + part('入库口径', '三档 · 建议归尺,结论归你') + + SCOPE_RULES + + part('落边 · 这一签之后谁用得上', '一口且无条件 · 归档闭环') + + ROUTE_FIG + + `
+ ${ico('git-branch', 15)} +
归档结论 ——
+
客户行为画像
+
` + + part('放行后即执行的落地动作', '门前 → 门后') + + `
+
${ico('check', 11)} +
草稿层合并进正式版 本任务写下的情报卡与归档结论一并落账; + 你就地改过的归因与依据随之落定。
+
+
${ico('check', 11)} +
归档闭环那一口此刻才发射 情报卡 + 条随载荷交出去,这条工作流走到终点 —— + 放行之前,世界上什么都没有发生。
+
+
${ico('check', 11)} +
下一单开始受用 按你定的口径,这几条价位、败因与日期 + 进入下一次询价的判据面 —— 归档做得好不好,不在这一单看得出来, + 在下一次报价能不能一眼看到「上次为什么丢」看得出来。
+
+
`, + foot: sign('BD 归档放行 · AI 归一与算尺留痕 —— 要改哪一格,在这张情报清单上就地改', '统一报价 · A7 情报归档 → 归档闭环'), }); diff --git a/activities/quote-intake/__tests__/steps.test.ts b/activities/quote-intake/__tests__/steps.test.ts index 2f9f372..3f5afb1 100644 --- a/activities/quote-intake/__tests__/steps.test.ts +++ b/activities/quote-intake/__tests__/steps.test.ts @@ -11,7 +11,7 @@ */ import { describe, expect, it } from 'vitest'; import { evaluateCondition } from '@sfeel'; -import { INTAKE_PHASES, INTAKE_STEPS } from '../src/case-model.ts'; +import { INTAKE_PHASES, INTAKE_STEPS, NARRATIVE } from '../src/case-model.ts'; import { CMMN_XML } from '../src/case-model.ts'; import { manifest } from '../src/manifest.ts'; @@ -152,8 +152,23 @@ describe('doneWhen 是「成果已存在」的判据,不是「该不该做」的 const confirm = INTAKE_STEPS.find((s) => s.item === 'S3_confirm'); expect(confirm, '受理需求交人审定这一步不见了').toBeDefined(); expect(confirm?.view).toBe('intake-overview'); - // 卡上那张表绑台账整份铺开 —— 人改的是九行,不是缺的那一两行 - expect(confirm?.guide).toContain('"ledger":"quote_intake.record"'); + /* + * **执行卡零输入**(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'); }); diff --git a/activities/quote-intake/src/case-model.ts b/activities/quote-intake/src/case-model.ts index 96df88c..aaad916 100644 --- a/activities/quote-intake/src/case-model.ts +++ b/activities/quote-intake/src/case-model.ts @@ -50,6 +50,7 @@ export const CMMN_XML = ` (连跑十轮里三轮死在这)。自报的两个数照旧写,它们是给报告和人看的。 --> + @@ -74,10 +75,17 @@ export const CMMN_XML = ` - update - line_total > 0 + update + quote_intake_record_rows > 0 @@ -135,7 +143,7 @@ export const CMMN_XML = ` @@ -226,6 +234,14 @@ S5 逐条定状况标 → S6 定去向与派单 → 提交放行。 「LM358DR 有近期成交价可以直接报;NE555P 半年多没动过要重新核;TL072CDR 停产了,得找替代。」 · S6 定完去向 → view = intake-release。**说这单接下来往哪走、谁接手**。 +**交代里说缺什么,用件上那三个词**(必填 / 建议 / 选填),而且**只说已发生的事**: +客户没给的就说「客户没给」并留空 —— 估价、垫数这类事你没有权限做,更不许在交代里 +声称做过(见「三种分量」那段:目标价尤其如此)。历史价、三分类、去向是**审定之后** +那几步的事:这一拍它们连数都还没有,一个字都不要提。 +派审定卡那一拍的交代照这个样子说(数字与料号换成本单的): +「三颗料拆好了,交期用量都齐;LM358DR、TL072CDR 客户给了目标价,NE555P 没给 +(建议追问,不拦单)。整份受理需求请你审定,哪一格不对在工作区的件上就地改。」 + 别每拍一个腔调复读(「已完成X,现在进行Y」)。事情不同,话就该不同 —— 调工具那一拍说「我去查这三颗的历史价」,拿到结果那一拍说查出了什么, 两句话该是不一样的语气。有风险、要他拿主意、或者你打算叫人,**先说出来**, @@ -240,6 +256,31 @@ S5 逐条定状况标 → S6 定去向与派单 → 提交放行。 发卡 / 提交会挂起(它们只能是串尾)。**这一单常路 5-7 拍就该收口** —— 每多拆一拍,就是多烧一遍完整上下文。 +【受理需求审定卡的骨架 —— 哪一拍发都照这个,一个字段别少】 +拆完受理记录就要派这张卡(S3_confirm)。**骨架放在说明书里正是因为你常在 +「拆表 + 收步 + 派卡」同一拍里发它** —— 那一拍 S3 的剧本还没摊开,凭记忆拼出来的 +就是一张只有一句话的裸卡(实测:人收到的指导书整个是空的)。照抄: +{"kind":"human","itemId":"S3_confirm","workOrder":"execution","card":{ + "title":"受理需求审定:<单号> 共条","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 口的载荷。`; /** @@ -261,7 +302,7 @@ S5 逐条定状况标 → S6 定去向与派单 → 提交放行。 export const INTAKE_STEPS = [ { item: 'S1_customer', - intent: '按发件邮箱认客户;认不出就发补全卡请人建档,建完写进客户主数据', + intent: '按发件邮箱认客户;认不出就发数据录入卡请人建档,建完写进客户主数据', writes: ['customer.profile', 'customer.contact', 'quote_intake.verdict'], /* 认客户就靠这两张 —— 没声明的话它们会被整表段的截断刀砍掉(踩过) */ reads: ['customer.contact', 'customer.profile'], @@ -320,7 +361,7 @@ export const INTAKE_STEPS = [ ③ **记账两个数,引擎按它们收步**(别绕过去,绕不过去): · customer_new_total —— 第①步认出来了记 0;认不出、要建档记 1; - · customer_asked_total —— 发过建档补全卡就记 1。 + · customer_asked_total —— 发过建档卡就记 1。 **新客户没问过人,这一步收不了** —— 实测有过一次:模型从邮箱域名 编出 \`CUST-SZ-MACH\` 直接把档建了,人根本没被问过一句,而那个编码是错的。 客户编码是主数据的主键,编错一次后面每一单都跟着错。 @@ -406,14 +447,29 @@ export const INTAKE_STEPS = [ 【一张卡别铺太长】缺口卡的 basis 与表格**只列真正缺的那几行**,不要把全部条目铺进去: 整份回复有长度上限,铺太长会在半途被截断 —— 截断的 JSON 解析不了, 这一拍作废、还会弹一张「需要你判断下一步」把人叫来(实测)。缺几条就写几条。 -【两种分量 —— **必填只有两项,别的一个都不是**】 +【三种分量 —— 与件上的空占位、执行卡的指导书**逐字同一套话**】 +台账声明里怎么标,件上就是什么颜色,你说话就得用哪个词。三档只有这三个词: · **必填(只有这两项)**:\`pn\`、\`eau\` 年用量 —— 缺了这一单没法报价, - **这是验收标准**,不齐不许放行,记进 missing_key_total。 -· **选填(除上面两项之外的一切)**:客户料号、品类、制造商、**目标价**、交期、品质要求、备注…… - 缺了照样往下走,只当提醒,记进 missing_minor_total。 - **目标价尤其常缺** —— 客户不给目标价是常态,不是缺陷:实测栽过一次, - 一颗料只缺目标价却被记成必填缺、还派了执行卡去问客户,而按 F-01 它根本不该拦单。 - 拿不准某个字段算哪一档,就问自己一句:**它是不是 PN 或年用量?** 不是就是选填。 + **这是验收标准**,不齐不许放行(件上标红「必填」),记进 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 不同数量档算两行)。 列名照台账字典写,别自己起名: @@ -473,7 +529,7 @@ export const INTAKE_STEPS = [ * **不给 doneWhen** —— 这一步触达世界(把件交到人手上),给了就等于给引擎 * 一把替人签字的钥匙。而它的成果按定义也判不出来:「人看过、改过、认了」 * 不是台账里任何一个数能自己变出来的。人工激活也一并给上: - * 引擎不会替你按下这一步,你得先 start 再发卡。 + * 引擎不会替你按下这一步,也不会替你收 —— 发卡就算启动,收要你自己 finish。 */ guide: `拆好的九条摆在那儿了,**这一单现在要经过人的手**。 @@ -485,39 +541,15 @@ export const INTAKE_STEPS = [ 人一眼没看过受理需求,下一次被叫到已经是终局验收 —— 用户当场说 「我都没确认需求 它就让我验收了」。 -【怎么发】先 start 这一步(它是人工激活的),再照这个骨架发,别少字段: -{"kind":"human","itemId":"S3_confirm","workOrder":"execution","card":{ - "title":"…","ask":"这是从来件与附件 BOM 归一出来的受理需求,请核对确认;哪一格不对就地改", - "steps":["型号 PN 与年用量 EAU 必须有 —— 缺一条这一单就走不到放行;客户没给的去问,问不到也要在备注里写清「客户未提供」", - "目标价尽量试探出来 —— 有它才谈得上「报得进不进得去」;客户不肯给,问一句「贵司这颗现在的成交价区间」也常有答案", - "抽错的就地改,多出来的行按行首 × 撤掉,漏掉的用表尾「加一行」补上;核完回这张卡提交"], -· **卡面是纯文本**:\`steps\` 与 \`standard\` 里**别写星号那类记号** —— 它们不会变成粗体, - 只会原样摆在人眼前(实测过一次)。要强调就靠句子本身的次序与用词。 - "standard":"必填(PN / 年用量)一条不缺;选填该问的都问过了 —— 问到的填上,问不到的留空并在备注里写明,放行时由你决定先受理还是退回。", - "basis":[{"k":"DAG2400L321504XZB01-R0","v":"缺年用量(必填,须补)"}, - {"k":"UMW AO6604","v":"缺制造商(选填,可先受理)"}], - "blocks":[{"t":"table","title":"有效 PN 清单(可就地改)", - "ledger":"quote_intake.record","editable":true, - "cols":["PN","客户料号","品类","年用量","制造商","目标价","备注"], - "fields":["pn","customer_pn","category","eau","brand","target_price","remark"]}]}} -**\`steps\` 与 \`standard\` 是作业指导书** —— 卡上照原样摆给人看:要核什么、核到什么程度算完。 -照上面抄即可(这一步每一单都一样,不必每次重写)。少了它,人拿到的只有 -「请核对确认」四个字,不知道该核什么。 -· \`ask\` 照当下情形写:**有必填缺**就写「有几条缺 PN/年用量,须向客户问清后填回」; - **只有选填缺**写「有几条缺选填项,可先受理也可去问」;**都齐**写「请核对确认」。 -· **别自己抄行**:绑了 \`ledger\` + \`fields\`,基座发卡那一刻就把这一单**整份清单** - 从草稿层铺进去 —— 人看到的是完整的九行,哪一格不对都能就地改,不只是缺的那一两行。 - 你抄一遍的下场实测过两次:回复有长度上限,铺长了半途截断、整拍作废; - 抄的时候还把 BOM 原样的 30 填了进去(台账里已经是 30000)。 -· \`fields\` 与 \`cols\` **一一对应**,列名照台账字典写。 -· \`basis\` 逐条写清**谁缺什么、是必填还是选填** —— 分量不同,人心里要有数; - 一格不缺就写这一单的要点(几条料、几条本轮不报、客户点名的口径要求)。 +【怎么发】**卡的骨架在说明书里(「受理需求审定卡的骨架」那一段),逐字段照抄** —— +不管你在哪一拍发它,骨架都在说明书里等着,不要凭记忆拼一张裸卡。 +这一步人工激活:发卡就算启动,不必单发 start;引擎也不会替你收 —— 人交回后要你自己 finish。 发完卡把 **asked_total 加 1**(记账用:它证明这一问没省)。 -【人提交之后 —— **表里的值基座已经替你写回了**】 -那张表绑着台账,人按下提交那一刻基座就把改动落进了草稿层(与数据录入卡同一条规矩: -该由人亲手落下的,不经你的手)。所以**别再 write 一遍那几行** —— -值没变的写入会被「写了等于没写」拒回(实测)。 +【人提交之后 —— **件上的改动基座已经替你写回了**】 +人在件上按「保存」那一刻,改动就落进了草稿层(与数据录入卡同一条规矩: +该由人亲手落下的,不经你的手);他按「提交」只是把这张卡交回来。 +所以**别再 write 一遍那几行** —— 值没变的写入会被「写了等于没写」拒回(实测)。 你这一拍只做三件事,然后 finish: ① **把 missing_key_total / missing_minor_total 重算** —— 照台账里现在的值, @@ -526,14 +558,14 @@ export const INTAKE_STEPS = [ ③ answered_total 加 1(答案已经处理过了)。 漏掉①的下场实测过:值明明补上了,汇总列还写着「缺 1 条」,提交被判据一次次拦回来, 连拒五拍熔断 —— 而你只要把那个数改对就过去了。 -回填时**按卡上那一列的 PN 对行**:卡上的 PN 就是台账里那一行的主键, +真要补写某一行时**按台账里那一行的 PN 对行**:PN 是主键, 换一个写法就是**新开一行**(实测:补写时把 PN 补全成 \`…-R0\`,于是同一颗料在表里成了两行, 条目数说 9 而表里躺着 10)。真发现第一次的 PN 抄错了,在 say 里说明并交给人, 别自己用新 PN 再写一行。 【**这张卡一单只发一次**】人交回来之后,不论还缺什么,**都不许再发同一张卡**。 必填(PN / 年用量)有提交闸兜着:缺一条他根本按不下提交,所以卡交回来时必填一定是齐的; -选填缺口不拦单,留着交放行的人定。 +建议档的缺口不拦单,留着交放行的人定。 实测栽过多次(2026-08-02 那一单连撞四拍):从前这儿写着「人交回来还缺必填,就再发一张卡」—— 而提交闸上线后那条路已经不存在了,模型照它办,每次都撞在 「这件事刚问过,人也答过了」上,白烧四拍。 @@ -542,7 +574,7 @@ export const INTAKE_STEPS = [ 这时候要做的**不是再问一遍**(基座会当场拒:同一件事不许问第二遍),而是: · confirmed_total 记 1、answered_total 记 1(人已经处理过了); · 缺口数**照实留着**(缺就是缺,别改小); -· 这一步就算办完了 —— 选填缺不拦单,放行时由人定「先受理还是退回」。 +· 这一步就算办完了 —— 建议档的缺口不拦单,放行时由人定「先受理还是退回」。 实测栽过一次:人留空交回,模型又发一张一模一样的卡,被拒; 转头把同一行原样再写一遍,又被「写了等于没写」拒;连拒五拍熔断,一单就这么停了。`, }, @@ -664,19 +696,19 @@ A2 / A3 拿到之后会按自己的口径归一需要的数据。你替它们先 tools: [], // 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 doneWhen) guide: `line_total > 0、missing_key_total = 0、untyped_total = 0、两枚旗已定 —— -**先 start T_release**(它标了人工激活:放行会触达世界,必须按一下)。 +可以走放行了(T_release 人工激活:动它就算启动,不必单发 start)。 -**start 之后、submit 之前,把清单里**每一条**裁量判据(L3/L4)都裁掉** -(同一拍里排在 start 后面即可)。**一条都不能漏** —— 漏一条,submit 当场被拒, +**submit 之前,把清单里**每一条**裁量判据(L3/L4)都裁掉**。 +**一条都不能漏** —— 漏一条,submit 当场被拒, 而拒回来的理由里就写着漏的是哪一条:照它原样抄 criterion 再裁一次即可, **别原样重提同一个 submit**(实测连提七次,每次都被同一条拒回来)。 眼下**只剩一条**,照名字逐字抄(2026-08-02 起「吃不准降 B」那条已从清单里删掉, 别再裁它 —— 裁一个不存在的判据会被拒): -· judge criterion="选填缺口(客户料号/品类/制造商/目标价)已补齐,或由你放行时确认可以先受理" —— - **只看 \`quote_intake_record_gaps\` 这一个数**(基座现数的:还有几行缺选填; +· judge criterion="建议追问的四格(客户料号/品类/制造商/目标价)已补齐,或由你放行时确认可以先受理" —— + **只看 \`quote_intake_record_gaps\` 这一个数**(基座现数的:还有几行缺**建议追问**的格子; \`..._gaps_keys\` 写着缺在哪几行的哪一格)。就两种写法,没有第三种: - · **= 0** → satisfied=true,rationale 写「选填已补齐」; + · **= 0** → satisfied=true,rationale 写「建议追问的那几格都补齐了」; · **> 0** → satisfied=**false**,rationale 照 \`..._gaps_keys\` 逐条写清缺在哪儿 (例:「DAG2400L321504XZB01-R0 缺目标价」),交放行的人定「先受理还是退回」。 diff --git a/activities/quote-intake/src/manifest.ts b/activities/quote-intake/src/manifest.ts index 2800789..790db3a 100644 --- a/activities/quote-intake/src/manifest.ts +++ b/activities/quote-intake/src/manifest.ts @@ -73,7 +73,7 @@ export const manifest: ActivityManifest = { identity: { activityId: ACTIVITY_ID, name: 'A1 · 询价受理', - packageVersion: '42.0.0', + packageVersion: '49.5.0', category: '统一报价', sdkVersion: '0.1.0-m5', checksum: 'dev-checksum-quote-intake', @@ -192,15 +192,23 @@ export const manifest: ActivityManifest = { * 等于把一个可回放的判定换成一次不可回放的手感。 */ 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', 'altRequests'], + required: ['rfqRef', 'records'], properties: { rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' }, records: { type: 'array', title: '询价受理记录(已打标)' }, - altRequests: { type: 'array', title: '替代评估请求' }, }, }, description: '有 C 类要替代 —— 去选型', @@ -211,11 +219,10 @@ export const manifest: ActivityManifest = { name: 'to-sourcing', payload: { type: 'object', - required: ['rfqRef', 'records', 'rfqRequests'], + required: ['rfqRef', 'records'], properties: { rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' }, records: { type: 'array', title: '询价受理记录(已打标)' }, - rfqRequests: { type: 'array', title: '询价请求' }, }, }, description: '无选型但有 B 类 —— 去寻源', @@ -365,7 +372,7 @@ export const manifest: ActivityManifest = { */ { key: 'optional-asked', - label: '选填缺口(客户料号/品类/制造商/目标价)已补齐,或由你放行时确认可以先受理', + label: '建议追问的四格(客户料号/品类/制造商/目标价)已补齐,或由你放行时确认可以先受理', level: 'L4', predicate: 'quote_intake_record_gaps = 0', }, diff --git a/activities/quote-intake/src/views/report.ts b/activities/quote-intake/src/views/report.ts index 4dbaf65..d053020 100644 --- a/activities/quote-intake/src/views/report.ts +++ b/activities/quote-intake/src/views/report.ts @@ -55,13 +55,27 @@ import { PRD_CSS, PRD_SPRITE } from './prd-style.ts'; * 这里由基座把值消毒成 `v-*` 追加进 class(`data-class-of`)—— 判断在样式表里,不在填数里。 */ const BIND_CSS = ` -/* ① 空格子自己说话 —— 值真的空才轮到这两条(基座 data-blank) */ +/* ① 空格子自己说话 —— 值真的空才轮到这三条(基座 data-blank)。 + * 占位三级(2026-08-03 补齐,用户:「目标价是建议」):必填红 / 建议橘 / 选填灰, + * 档位跟台账声明走(required → req,expect → sug,其余 opt),颜色与 A2 同一套。 */ +/* 胶囊与同一行的数字坐同一条线(2026-08-03 用户:「没有和数字对齐」)—— + * 裸 inline-flex 的胶囊按 baseline 挂:盒子(10px 字 + 上下 3px 内距 + 边框 = 18px)比 + * 字的基线深好几像素,视觉上整枚往下坠。治法:空格子自己撑起文字那份盒高(12px×1.5), + * 胶囊在里面垂直居中 —— 中线对中线,行高也不再忽高忽低。 + * :not(.on) 不能省(2026-08-03 用户:「编辑态太拥挤」):这条比「编辑态藏起只读份」 + * 的那条更特异,不圈住的话编辑态里胶囊会盖过 display:none 冒出来,54 个输入框旁边 + * 各站一枚 —— 编辑态里格子该不该填由闸与红带说,胶囊只属于看的那份。 */ +.proto-root .jc-tbl-in:not(.on) td.req > .jc-vcell:empty, +.proto-root .jc-tbl-in:not(.on) td.sug > .jc-vcell:empty, +.proto-root .jc-tbl-in:not(.on) td.opt > .jc-vcell:empty { display: flex; align-items: center; min-height: 18px; } .proto-root .jc-tbl-in td.req > .jc-vcell:empty::after, +.proto-root .jc-tbl-in td.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); } @@ -388,7 +402,7 @@ const listTable = (mode: 'draft' | 'final'): string => { * 待处理那一屏可就地改;写的是**台账列名**,不是表头文字 —— * 表头改个说法不该让写回错位。归档件不挂:那时它是件,不是表单。 */ - const cell = (key: string, head: string, kind: 'req' | 'opt', style = ''): string => { + const cell = (key: string, head: string, kind: 'req' | 'sug' | 'opt', style = ''): string => { const cls = mode === 'final' ? 'dimempty' : kind; return ``; }; @@ -414,10 +428,10 @@ const listTable = (mode: 'draft' | 'final'): string => { ${del} ${mode === 'final' ? '' : ''} ${cell('pn', 'PN', 'req', 'mono')} - ${cell('category', '品类', 'opt')} + ${cell('category', '品类', 'sug')} ${cell('eau', '年用量', 'req', 'mono')} - ${cell('brand', '制造商', 'opt')} - ${cell('target_price', '目标价', 'opt', 'num')} + ${cell('brand', '制造商', 'sug')} + ${cell('target_price', '目标价', 'sug', 'num')} ${cell('remark', '备注', 'opt')}
`; @@ -537,7 +551,8 @@ export const INTAKE_OVERVIEW = doc({ addRowBar('quote_intake.record') + `
${ico('info', 13)} 带 * 的两列是必填(PN / 年用量):缺任一按 F-01 退回补全,不许放行; - 其余列缺了标灰「选填」—— 先受理、后追问,不为它们拦单,但那一问不能省。 + 品类 / 制造商 / 目标价缺了标橘「建议」—— 值得追问、尽量补上,备注缺了标灰「选填」。 + 三档都不为它们拦单,先受理、后追问,但那一问不能省。 原文没写的一律不补:受理阶段编出来的数会一路流到定价和报价单上,越往后越难发现。
`, foot: sign('AI 拟稿,你审定 —— 要改哪一格,在「待处理」那张单的需求表上就地改', '统一报价 · A1 受理归一'), diff --git a/activities/quote-negotiate/__tests__/activity.test.ts b/activities/quote-negotiate/__tests__/activity.test.ts new file mode 100644 index 0000000..00ba984 --- /dev/null +++ b/activities/quote-negotiate/__tests__/activity.test.ts @@ -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; + 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(' { + 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 [' { + 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 | 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 }).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(/'); + } + }); + + 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(''); + }); + + it('宿主拒推含脚本的模版', async () => { + const host = createMockHost({ contract: CONTRACT }); + await expect(host.base.interaction.pushHtml('
')).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, +): { 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(//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(`([\s\S]*?)<\/condition>/.exec(block.slice(0, block.indexOf('')))?.[1] ?? ''; + return raw.replaceAll('>', '>').replaceAll('<', '<').replaceAll('&', '&'); +} + +function safeEval(expression: string, scope: Record): boolean { + try { + return evaluateCondition(expression, scope); + } catch { + return false; + } +} diff --git a/activities/quote-negotiate/__tests__/steps.test.ts b/activities/quote-negotiate/__tests__/steps.test.ts new file mode 100644 index 0000000..4da4a80 --- /dev/null +++ b/activities/quote-negotiate/__tests__/steps.test.ts @@ -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 = { + rfqRef: 'RFQ MD14.1', + customerCode: 'CU-0142', + items: [{ pn: 'BBPY00201209601Y00', quoted_price: 0.085 }], + }; + const FRESH_SOLO: Record = { 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): Record => ({ + 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): Record => ({ + 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): boolean { + try { + return evaluateCondition(expression, scope); + } catch { + return false; + } +} diff --git a/activities/quote-negotiate/src/case-model.ts b/activities/quote-negotiate/src/case-model.ts index a3d52f3..2ef8adc 100644 --- a/activities/quote-negotiate/src/case-model.ts +++ b/activities/quote-negotiate/src/case-model.ts @@ -1,115 +1,441 @@ /** - * A6 报价议价的案例模型 —— **发出去是触达世界的一下,等回音是立监听** - * (2026-07-31 照 A1 的状态机模式重做)。 + * A6 报价议价的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。 * - * 这个活动的要害不是步数,是**两处不可省的边界**: - * · 发正式报价单 = 触达外界 → 必须人工激活(铁律③ / AM 4.1); - * · 客户什么时候回是**外界的事** → 立监听等它,不在这一单里轮询、也不假装等到了。 + * 业务出处:`docs/MVP示例工作流.html` §1.4(S1 发报留档 → S2 跟进议价 → S3 定案交接), + * 按 A4 同一条形状建模(2026-08-03 重写 @3.0.0): * - * 三项: - * T_compose —— 把草案落成正式报价单(条款齐全),写进台账; - * T_send —— 发出并等回音:人工激活 → 登记发信意向 + 立监听; - * T_release —— 放行(人工激活),三个去向由输出选择组给出。 + * 上游(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 = ` + - - + + + + + + + + + + - update - quote_total > 0 + update + line_total > 0 and quote_negotiate_line_rows = line_total and composed_total >= 0 + + + + update + replied_total > 0 and quote_negotiate_line_rows > 0 and quote_negotiate_line_missing = 0 + + + + update + quote_negotiate_line_reply_type_counter > 0 and replied_total > 0 + + + + update + 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 - - - + + + - - - - + + + + + + + + + + + + + + + + + - + `; -export const NARRATIVE = `§0 开案:拿到报价草案即开案。成品是**一份发出去的正式报价单**,外加一个明确的结果 -(成交 / 有异议 / 拒绝)—— 那三个去向由放行时的选择组定。 +/** + * 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本,两处不重复写同一件事)。 + */ +export const NARRATIVE = `§0 开案:**一份已批准的报价要发出去,并谈出一个结果**。来路两种,形状都收(宽进): +· **流内转来**:任务数据里有 \`items\` 数组 —— A4 的报价草案条目(每条带拟报单价与门禁处置), + A5 已经批过;这是最全的一种来路; +· **单飞**:只有一段需求文字,说了要给谁发报价、几颗料什么价。 -这一单有两处不能含糊: -· **发出去是触达世界的一下** —— 那一项标了人工激活,人不按你发不出去; -· **客户什么时候回是外界的事** —— 立监听等它,别在这一单里轮询,更别假装等到了。 +**成品只有一样**:一份**正式报价函** —— 整份条目都在、**每一条都有客户表态**, +而且每一条都定了案:成交 / 退回重批 / 不成交。人签的不是「发出去了」, +是**这一单谈成了什么**。它决定整条流往哪走。 +(还价那一档 HAGGLE 是过渡态:**客户还了价、还没拍板**。放行前它必须清零 —— +拍板那一下把它换成成交、退回重批或不成交,三条路都是结论,都算办完。) -【T_compose 落正式报价单】把草案落成能发出去的件:单价 / 交期 / SPQ / MOQ / 贸易条款 / -有效期,逐条写进 \`quote_negotiate.formal_quote\`,并把 quote_total(条数)写上。 -条款缺一样都不算一份能发的报价单 —— 缺了就问,别自己填一个"看起来合理"的。写完 finish。 +**能算的不问人,该客户说的不代说**:议价结果 N-01(表态 → 定案)、让步梯度 N-02 +(还价幅度 → 处置建议),两支判定表说了算,你不临场拍;而**客户表态那一列是人落的, +你一格都不写** —— 客户回了什么只有收信的人知道,系统不替客户表态。 -【T_send 发出并等回音】**先 start T_send**(人工激活:发出去触达客户)。 -然后用 \`mail.send\` 登记发信意向(写类工具:过程中只登记,放行那一刻才真发), -接着 listen 一个 data 事件等客户回音,**时限必配**(比如 P7D)—— -不配时限就是个幽灵等待,这一单会永远挂着。 -回音到了再看:成交 / 有异议 / 拒绝,分别对应放行时的三个出口。 +**四步走**:归一排版报价函 → 交 BD 发出并落回音 → 逐条定案 → 拍板定案放行。 -【提交放行】**先 start T_release**(人工激活),提交前用 judge 裁掉裁量判据 -(criterion 照判据名原样写),然后 submit。终局唯放行:你不能自己宣布完成。 +**这一单要经过人一到两次**:BD 在议价工作单上逐条落客户表态(那张单绑着整份报价函, +表态缺一条按不下提交 —— 基座的闸看着,他填的格子直接落账); +**只有客户还了价的那几条才另外叫人拍板**(一张决策卡)。每条都叫人,人就成了橡皮图章; +一条都不叫,授权红线就是摆设。最后 BD 放行签字。 -【一路上要说话】每一拍带一句 say,说**业务的话**:报了什么价、什么时候发的、客户回了什么。 -一两句、口语。别报「写草稿 N 行」这种系统词。`; +**去向是算出来的,不是你选的**:有条目判了退回重批 → 退回定价重批;有成交 → 转订单履约; +全都没成 → 转情报归档。三口各带谓词,放行那一刻基座按序求值,你只要把定案落准。 + +【这一单只有一个岔路口,它长这样 —— 无论哪一拍都照这个发,别自己另编一张】 +客户还了价要人拍板,那张决策卡**三处写死**: +· \`"itemId":"T_haggle"\` —— 英文计划项号。**别写中文名「异议拍板」**:中文名不是 id, + 写了当场被拒(【目标缺口】那一段摆的就是中文名,别照它抄); +· \`"ask":" 条客户还了价,按哪条路办?"\` —— 只把 N 换成条数,**别的字一个都不改**。 + 换了问法,系统就认不出这是同一张卡:人已经点过的那张会被你再发一遍; +· 三个 \`value\`:\`accept-encounter\` 不对,是 \`accept-counter\`(照客户还的价成交)、 + \`revise\`(退回成本定价重批)、\`hold\`(守住现价不让)。label 与 consequence 可以按 + 本单的数改写,**value 不行**。 +**REVISE(退回重批)这个码只有拍板那一步写得出来** —— 定案那一步遇到客户还价一律落 +HAGGLE,替他写了 REVISE,那张卡就永远不会发,让步梯度成了摆设。 +拍板之前**别急着发卡也别绕路** —— 定案那一步先把三档写完、finish; +下一拍引擎会把拍板那一步的做法摊开给你,那时再发。 + +【一路上要说话】负责人正看着这一单。**每一拍都带一句 say** —— 说业务的话: +报价函排了几条、有效期到哪天、客户回了什么、哪几条要拍板;不说系统词。 +到了要找人的那一拍,先说清这一轮办完了什么、为什么现在需要他。 + +【怎么把一项干完 · 一拍连做】write 和 tool 都不会自动收步 —— 做完用 finish 宣告。 +动作排进同一拍的 actions 串一口气给;只有发卡 / 提交(挂起)必须停拍。 +**这一单常路 6-8 拍就该收口**。 + +§Z 结案:BD 在验收单上签字,系统把草稿合并成正式报价函并按定案发出对应那一口的载荷 —— +成交转订单履约,有异议退回成本定价,不成交转情报归档。`; /** - * 步骤剧本 —— 与 A1 同一套。 - * `T_send` 与 `T_release` 都不给 `doneWhen`:一个触达客户、一个触达世界,都得人按一下。 + * 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。 + * + * 三处不给 `doneWhen`:`S2_send`(发卡触达真人)、`T_haggle`(要人拍板)、 + * `T_release`(签下去触达世界)—— 给了等于给引擎一把替人签字的钥匙。 */ export const NEGOTIATE_STEPS = [ { - item: 'T_compose', - intent: '把草案落成条款齐全的正式报价单,并结算条数', - writes: ['quote_negotiate.formal_quote', 'quote_negotiate.verdict'], - reads: [], + item: 'S1_compose', + intent: '整份归一成正式报价函:逐条抄齐条款、补 SPQ/MOQ/贸易条款、按 P-04 注明有效期', + writes: ['quote_negotiate.line', 'quote_negotiate.verdict'], + /* 上游载荷数组不声明就会被整表段的截断刀砍掉(砍掉的恰是要归一的行) */ + reads: ['items'], + view: 'negotiate-worksheet', tools: [], - doneWhen: 'quote_total > 0', - guide: `逐条写进 \`quote_negotiate.formal_quote\`:单价 / 交期 / SPQ / MOQ / 贸易条款 / 有效期, -并把 quote_total 写上。条款缺一样都不算一份能发的报价单 —— -缺了就问,**别自己填一个「看起来合理」的值**。 -**同一拍再写一行 \`quote_negotiate.verdict\`**(验收机检读的就是它): -· quote_total = 报价行条数;missing_field_total = 六项条款还缺的条数(要 = 0); -· validity_days = 报价有效期天数(要 > 0); -· followup_total = 已建跟进条数(**要 ≥2** —— D+3 与 D+7 两条,没有跟进的报价等于没报); -· summary 一句人话结论。写不对 submit 会被门挡回来。写完再 finish。 -**每一行都要带 \`rfq_ref\`** —— 它是这一单的单标识(起单入参里就有,原样抄进去)。 -台账主键第一列就是它:不写,这一行会和**别的单**同料号的那一行撞主键,把人家覆盖掉 -(实测栽过:A4 的报价草案主键只有 pn,跑四轮只剩最后一轮的 3 行)。`, + /* + * 归一 + 排版走完才算办完:行数两边对上(一边是基座现数), + * 且 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: 'T_send', - intent: '发出报价并立监听等客户回音', - writes: [], - reads: [], - tools: ['mail.send'], - // 触达客户 —— 人工激活,不给 doneWhen - guide: `**先 start T_send**(人工激活:发出去触达客户)。 -用 \`mail.send\` 登记发信意向(写类工具:只登记,放行那一刻才真发), -再 listen 一个 data 事件等回音,**时限必配**(如 P7D)—— 不配就是幽灵等待,这一单会永远挂着。`, + 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":"报价函发出并跟进:<单号> 共 条待回音", + "ask":"报价函已按批准价排好版,条款与有效期都在工作单上。请把它发给客户,按 D+3/D+7 的节奏催问;客户回话之后,在工作单的「客户表态」一列逐行落:接受 / 还价 / 拒绝 / 无回复,还价的把客户报的那个价也填上。", + "steps":["发出去之前先核一遍条款:单价、交期、SPQ、MOQ、贸易条款、有效期 —— 报价函一旦发出,这几项就是对客户的承诺", + "客户表态每一行都要有 —— 缺一行这张单按不下提交;客户只回了一部分料的,没回的那几行按无回复落,别替客户猜", + "客户还价的行:把他报的价填进「客户还价」那一格,再把他的原话记进备注 —— 争的是价格、交期还是 MOQ,后面拍板全看这一句", + "D+3 第一轮催问确认收到探态度,D+7 最后一催;催到有效期过仍无回复的,按无回复定案,不要一直挂着"], + "standard":"整份报价函每一条都有客户表态,还价的那几条填了客户报的价并记下他的原话 —— 缺一条这张单按不下提交(系统的闸看着)。", + "basis":[{"k":"","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 换成条数** —— + 换了问法系统就认不出这是同一张卡,人已经点过的那张会被你再发一遍(实测发出去两张)。 +· 三个 \`value\` 照抄 \`accept-counter\` / \`revise\` / \`hold\` —— 它们是这一单三条路的 + 代号,换了名字下一段的对应关系就对不上,你自己也不知道人点的是哪条。 + label 与 consequence 可以按本单的数改写,value 不行。 +{"kind":"decide","itemId":"T_haggle","ask":" 条客户还了价,按哪条路办?", + "options":[ + {"value":"accept-counter","label":"照客户还的价成交","consequence":"当场拿下这几条,毛利让到客户的价位上,让出去的部分不再回来"}, + {"value":"revise","label":"退回成本定价重批","consequence":"这几条超出让价授权,回 A4 重新算价再谈 —— 这一单本轮不成交,客户要多等一轮"}, + {"value":"hold","label":"守住现价不让","consequence":"报价不动,客户不接受即视为不成交,这几条转情报归档"}], + "basis":[{"k":"","v":"报价 · 客户还价 · 让幅 % · 让步梯度判 <码>"}]} +· 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 验收', + intent: '定案已落,提交放行交 BD 验收', writes: [], reads: [], + view: 'negotiate-roster', tools: [], - guide: `**先 start T_release**(人工激活),提交前用 judge 裁掉裁量判据,然后 submit。 -三个去向(成交 / 有异议 / 拒绝)由放行时的选择组定。 -**台账里要写 outcome**(won / revise / lost)—— 下游三条边读的就是它,不写整条流走不下去。`, + /* 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 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'] }, +]; diff --git a/activities/quote-negotiate/src/index.ts b/activities/quote-negotiate/src/index.ts index 906b78f..6de3472 100644 --- a/activities/quote-negotiate/src/index.ts +++ b/activities/quote-negotiate/src/index.ts @@ -1,49 +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', - - async run(base: ActivityBase): Promise { - 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' }); diff --git a/activities/quote-negotiate/src/manifest.ts b/activities/quote-negotiate/src/manifest.ts index cce20b6..afa6aa1 100644 --- a/activities/quote-negotiate/src/manifest.ts +++ b/activities/quote-negotiate/src/manifest.ts @@ -1,168 +1,393 @@ /** - * 活动包清单 —— 统一报价 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, NEGOTIATE_STEPS } 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: '2.1.0', + 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: ['rfqRef', 'customerCode', 'items'], - properties: { rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' }, 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: [ - /* - * 载荷里**必须带 `outcome`** —— 流程包 E10/E11/E12 三条出边读的就是它 - * (`outcome = "won"/"revise"/"lost"`)。从前三个口的载荷都是空对象 `{}`, - * 于是三条边一条也不成立:整条流走到 A6 就没路可走,而口是"发射"过的。 - * 这与 A1 当年 G1 全流死路是同一种错 —— **边读什么,口就得发什么**。 - */ - { - name: 'won', - payload: { - type: 'object', - required: ['outcome'], - properties: { outcome: { type: 'string', title: '议价结果(固定 won)' } }, - }, - description: '成交 → 转订单履约(ORM)', - }, { name: 'revise', payload: { type: 'object', - required: ['outcome'], - properties: { outcome: { type: 'string', title: '议价结果(固定 revise)' } }, + 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: '有异议 → 返回调整 A4', + description: '有条目经人拍板判了退回重批 —— 让幅超出授权,回成本定价重算再谈', + when: `${D}_line_deal_status_revise > 0`, }, { + name: 'won', + payload: { + type: 'object', + required: ['rfqRef', 'outcome'], + properties: { + rfqRef: { type: 'string', title: '询价单号' }, + customerCode: { type: 'string', title: '客户码' }, + quoteNo: { type: 'string', title: '报价单号' }, + outcome: { type: 'string', title: '议价结果(固定 won)' }, + rows: { type: 'array', title: '成交条目(带成交价)' }, + }, + }, + description: '有条目谈成 —— 转订单履约(ORM)', + when: `${D}_line_deal_status_won > 0`, + }, + { + /* 下游 A7 的入参要 `rfqRef / customerCode / quoteNo` —— 边读什么、下游要什么,这一口就得发什么 */ name: 'lost', payload: { type: 'object', - required: ['outcome'], - properties: { outcome: { type: 'string', title: '议价结果(固定 lost)' } }, + 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: '拒绝/超时 → 转 A7 情报归档', + description: '一条都没谈成:客户拒绝或超时无回复 —— 转情报归档', + when: `${D}_line_deal_status_lost > 0`, }, ], }, ], }, /* - * 必交付物**必须给 `ledger` 地址**(2026-07-31 修)。 - * 不给的话预检退回 `scope[key]` 去找 —— key 是 `benchmark-table` 这样的短横线别名, - * 任务视图里永远没有这个键,于是「必交付物缺一不得提交放行」永远拒, - * 活办完了也放不了行,连拒五拍熔断(实测 A2 死于此)。 + * 必交付物只有一件:**正式报价函**。议价结论(verdict)是它的结论栏, + * `ledgers` 说清这份成果由哪几张表承载(一份成果,不是两份)。 */ requiredDeliverables: [ - { key: 'formal-quote', label: '正式报价单(PDF · 中英双语)', ledger: `${DOMAIN}.formal_quote` }, - // 跟进不单独立表 —— 建了几条记在议价结论的 followup_total 上 - { key: 'followup', label: 'D+3/D+7 跟进提醒', ledger: `${DOMAIN}.verdict` }, + { + key: 'formal-quote', + label: '正式报价函(逐条条款 + 客户表态与定案)', + ledger: `${D}.line`, + /* 验收页上让 BD 就地改的两列 —— 恰是他的职权面(成交价与客户原话) */ + editableAtRelease: ['final_price', 'reply_note'], + ledgers: [`${D}.line`, `${D}.verdict`], + }, ], /* - * 机检谓词读的是 `verdict` 那张**汇总表**的列(照 A1 的口径)。 - * 上一版的名字(missingQuoteFields / validityDays / followupSet)**台账里根本没有** —— S-FEEL 拿未绑定的名字 - * 比大小求值为假,判据永远达不了标,submit 门永远拒,连拒五拍熔断。 - * 判据的名字必须落在台账上,否则「机检」是一句空话(2026-07-31 销账)。 + * 验收判据 —— **机检(L1/L2)只读基座数得出来的数**: + * `_rows` 基座数行数、`_missing` 基座按 required / requiredWhen 现数 + * (与执行卡提交闸同一个数同一套算法)、`_<列>_<值>` 基座按值现数。 + * 上一版三条机检读活动自报的 `missing_field_total` / `validity_days` / + * `followup_total` —— 那不是门,是「改哪个数能过」的提示。 */ acceptanceCriteria: [ - { key: 'quote-complete', label: '单价/交期/SPQ/MOQ/贸易条款/有效期齐备', level: 'L1', predicate: 'missing_field_total = 0' }, - { key: 'validity', label: '按报价有效期 P-04 注明有效期', level: 'L2', predicate: 'validity_days > 0' }, - { key: 'followup-set', label: '已建 D+3/D+7 跟进 —— 没有跟进的报价等于没报', level: 'L2', predicate: 'followup_total >= 2' }, - { 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', + domain: D, + object: 'line', kind: 'record', caseKey: 'rfq_ref', - label: '正式报价单', + label: '正式报价函', fields: [ { name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' }, - { name: 'quote_no', type: 'text', primaryKey: false, masked: false, label: '报价单号' }, { name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' }, - { name: 'unit_price', type: 'number', primaryKey: false, masked: false, label: '单价CNY' }, + { name: '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: '有效期' }, - ], - }, - { - domain: 'market', - object: 'price_history', - kind: 'master', - 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: '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: '成交价' }, ], }, { /* - * 议价结论 —— 一单一行的**汇总表**,验收机检谓词的入参就在这儿。 + * 议价结论 —— 一单一行的**汇总表**,哨兵与报告读它。 * 汇总列与逐行列**故意不同名**:任务视图把草稿行摊平到顶层是「后写的赢」, * 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。 */ - domain: DOMAIN, + domain: D, object: 'verdict', kind: 'record', caseKey: 'rfq_ref', @@ -170,58 +395,186 @@ export const manifest: ActivityManifest = { fields: [ { name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' }, { name: 'quote_no', type: 'text', primaryKey: false, masked: false, label: '报价单号' }, - { name: 'quote_total', type: 'integer', primaryKey: false, masked: false, label: '报价行条数' }, - { name: 'missing_field_total', type: 'integer', primaryKey: false, masked: false, label: '条款缺失条数' }, - { name: 'validity_days', type: 'integer', primaryKey: false, masked: false, label: '报价有效期天数' }, - { name: 'followup_total', type: 'integer', primaryKey: false, masked: false, label: '已建跟进条数' }, + { name: '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: [] }, - /* 步骤剧本 —— 与 A1 同一套:doneWhen 真了引擎自己收步,不问模型 */ + 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' }, diff --git a/activities/quote-negotiate/src/skills.ts b/activities/quote-negotiate/src/skills.ts index d72d5d5..b6b201f 100644 --- a/activities/quote-negotiate/src/skills.ts +++ b/activities/quote-negotiate/src/skills.ts @@ -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', }, ]; diff --git a/activities/quote-negotiate/src/views/prd-style.ts b/activities/quote-negotiate/src/views/prd-style.ts new file mode 100644 index 0000000..9d2c0b5 --- /dev/null +++ b/activities/quote-negotiate/src/views/prd-style.ts @@ -0,0 +1,409 @@ +/** + * A1 两份执行页的样式表 —— **从 PRD 目标件原样搬过来**,不是我照着复述的。 + * + * 出处:`~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理需求.html` 的整段 ` +${PRD_SPRITE} +
+
+
+
${ico(input.badge, 17)}
+
+ ${input.kicker} +

${input.title}

+
+ ${input.state} +
+
${input.body}
+
${input.foot}
+
+
`; + +const part = (label: string, aside = '', tone = ''): string => + `
${label}${aside === '' ? '' : `${aside}`}
`; + +const sign = (note: string, tag = ''): string => + `
+ ${ico('user', 15)} +
${note}
+
${tag === '' ? '' : `${ico('git-branch', 13)}${tag}`}`; + +/* ==================== 组成件 ==================== */ + +/** 要素四卡:客户 / 待回音 / 一格一料的定案刻度 / 议价的尺 */ +const FOUR = `
+
+ ${ico('building')}客户公司 +
+ +
+
+ 承自上游;单飞起单时此栏可空 +
+ +
+ ${ico('boxes')}待回音 +
/ 条报价
+ 客户表态还空着的行 —— 这一列只有收信的人填得了,系统不替客户说话;补一格少一格,基座现数 +
+ +
+ ${ico('shield-check')}逐条定案 +
+ +
+ 一格一条料 · 绿=成交,橙=客户还了价待拍板,蓝=退回重批,灰=不成交(拒绝或超时) +
+ +
+ ${ico('history')}报价有效期 + ${ico('check', 13)} 天 · 至 + 按报价有效期 P-04 注明;D+3 催一轮、D+7 最后一催,催过仍不表态才按超时定案 +
+
`; + +/** 一单议价是怎么走完的 —— 走位图(形状写死,不算不判) */ +const DEAL_LINE = `
+
批准价A5 批过的那个数 —— 报出去的价一个数字都不改
+
${ico('arrow-right', 18)}
+
报价函发出条款齐备(SPQ/MOQ/贸易条款/有效期),BD 发给客户
+
${ico('arrow-right', 18)}
+
客户表态接受 / 还价 / 拒绝 / 无回复 —— 这一格由收信的人落
+
${ico('arrow-right', 18)}
+
定案 N-01成交 / 待拍板 / 退回重批 / 不成交,逐条落定
+
${ico('arrow-right', 18)}
+
去向退回重批→回定价 · 有成交→转履约 · 都没成→转归档
+
`; + +/** 缺表态红带 —— 与提交闸、放行门 L1 同一个数同一套算法(基座现数);一格不缺自动收 */ +const GAP_BAR = `
${ico('alert', 14)} + 还差 行客户表态没落 + —— 。 + 按右上角「编辑」开表,红框那几格就是要填的:接受填 ACCEPT,还价填 COUNTER 并把客户报的那个价 + 填进「客户还价」,拒绝填 REJECT,催到有效期过仍不表态填 NOREPLY。补齐了执行单才提交得了。 +
`; + +const editGate = (address: string, what: string): string => `
+ ${ico('lock', 12)}${what}已锁定 —— 看的时候不误碰,要改按右边 + + + +
+
${ico('pen', 14)} + 可填的是客户表态、客户还价与客户原话三列: + 这三列系统一格都不写 —— 客户回了什么只有你看得见,别让系统替他表态。 + 还价的行务必把他报的那个数填上,后面拍板全看这一格。本轮不报的料按行首 × 撤掉。 + 改动只落任务草稿层,保存不等于放行。 +
`; + +const addRowBar = (address: string): string => `
+ + 照末行的样子添一行空的;主键(型号 PN)填了才存得下,不填这一行不落库。 +
`; + +/** + * 报价函主表 —— 一行一料:条款列 + 客户那一侧三列 + 定案列。 + * + * `data-col` 只挂人可改的三列(reply_type / counter_price / reply_note)+ 主键 pn —— + * 编辑面精确等于 BD 的职权面;**主键格必挂 `data-col`**(它不是编辑面,是行的钥匙: + * 不挂,服务端按「全表一致列」补键,两行会对到同一行上,后者盖前者 —— A2 走查逮过)。 + * 客户表态挂 `req`:空着出「必填」胶囊、整行标底,与提交闸、放行门同源(都数 `_missing`)。 + */ +const LINE_TABLE = `
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
类PN品类年用量交期SPQMOQ贸易条款报价 CNY客户表态(你落)客户还价定案客户原话 / 让价理由
+ + +
`; + +/** + * 成交定案表(收口件用)—— 编辑面换成**放行时可改的两列**(成交价与客户原话), + * 与 `requiredDeliverables[].editableAtRelease` 逐字对齐。 + */ +const SETTLED_TABLE = `
+ + + + + + + + + + + + + + + + + + + + + + + + +
类PN报价 CNY客户表态客户还价让步处置定案成交价客户原话 / 让价理由
`; + +/** 定案分布:四段按条数分宽 —— 守恒等式(四档相加 = 行数)的可视形 */ +const DIST = `
+
+ + + + +
+
+ 成交 · 客户接受 + 待拍板 · 客户还了价 + 退回重批 · 让幅超授权 + 不成交 · 拒绝或超时 +
+
`; + +/* ==================== 局面一 · 议价工作单(排版 / 发出 / 落回音)==================== */ + +/** + * 逐颗报价档案 —— 一颗料一张卡(data-rows 逐行复制):左条款、右议价。 + * + * **容器用 `
` 不用 `
`**:渲染器的行展开按第一个同名闭合标签截行内容, + * 行模版里嵌着 `
` 时 `
` 会把行截成套娃(A2 逮过,记在陷阱表)。 + */ +const QUOTE_CARDS = `
+
+
+ + + + + +
+
+
+
报价函条款(发出去就是承诺)
+
客户料号
+
品类 / 制造商
+
年用量
+
报价 CNY
+
交期
+
SPQ / MOQ
+
贸易条款
+
有效期至
+
+
+
议价(填回下表,这里回显)
+
客户表态
+
客户还价
+
客户原话
+
让步处置
+
定案
+
成交价
+
客户约束
+
+
+
+
`; + +/** 议价结果 N-01 的四档 —— 静态规则,尺挂在墙上(素材:MVP 示例工作流 §1.4) */ +const OUTCOME_RULES = `
+
+ ACCEPT +

客户明确接受这个价。「按这个价走」「下单吧」「PO 稍后发」—— + 定案成交,成交价就是报价,转订单履约。

+
+
+ COUNTER +

客户还了一个价。把他报的那个数落进「客户还价」那一格 —— + 没有数字的「太贵了」先追问,追不到才按无回复落。还价的条目先落成「待拍板」, + 等人按让步梯度拍一下才有结论:接了算成交、退回重批、或守价不让 —— 系统不替你让价。

+
+
+ REJECT +

客户明确不做了。选了别家、项目取消、规格不匹配 —— + 定案不成交,把缘由记进备注,转情报归档:这一单丢了,情报留下。

+
+
+ NOREPLY +

催到有效期过仍不表态。客户不回不是结论 —— + D+3 第一轮催问确认收到探态度,D+7 最后一催;催过了才轮到按超时定案。

+
+
`; + +/** 让步梯度 N-02 的三档 —— 静态规则(素材:MVP 示例工作流 §1.4 让价核线) */ +const CONCESSION_RULES = `
+
+ ≤ 3% +

接了拿单。意思意思的压价 —— 来回一轮的时间成本比这三个点贵, + 当场接住,把单子锁下来。

+
+
+ 3% ~ 10% +

授权内回旋。让一半再报回去,把让出去的换成量或账期 —— + 先弄清争的是价格、交期还是 MOQ:争交期的用交期换,一上来就降价,后面只剩降价这一招。

+
+
+ > 10% +

超出授权,退回成本定价重批。这一档系统不替任何人放行,也不许私自破线 —— + 底价线以下的单,每补一次货都在亏。破线不是勇气是事故。

+
+
`; + +export const NEGOTIATE_WORKSHEET = doc({ + badge: 'sheet', + kicker: '统一报价 · A6 报价议价 · 议价作业', + title: '议价工作单', + titleRef: 'rfq_ref', + state: + '待回音 / 条', body: - /* `items` 是起单入参的数组,基座渲染数组时给的是条数 —— 正好是「这一单要报几条」 */ - statsBand([ - ['报价条目', 'items'], - ['单价 CNY', 'unit_price'], - ['交期', 'lead_time'], - ['有效期', 'valid_until'], - ]) + - section('报价条款六样 —— 缺一不可', - kvGrid([ - ['单价 CNY', 'unit_price'], - ['交期', 'lead_time'], - ['最小包装量 SPQ', 'spq'], - ['最小起订量 MOQ', 'moq'], - ['贸易条款', 'trade_terms'], - ['有效期', 'valid_until'], - ]) + - noteOnly( - '单价与交期照审批通过的草案抄,不做任何「顺手优化」—— 改价要重新过审批点。' + - '有效期按行情波动定,不要一律写「长期有效」:那等于把汇率与料价风险自己扛了。' + - '拿不准的项宁可空着走补全,不要填一个看起来合理的值。', - ), - { text: '缺一样多一轮' }, - ) + - section('本单已成形的报价行', - tableOnly('mine:quote_negotiate.formal_quote', [ - ['报价单号', 'quote_no', 'mono'], - ['型号 PN', 'pn', 'mono'], - ['单价', 'unit_price'], - ['交期', 'lead_time'], - ['有效期', 'valid_until'], - ]), - { text: '只列本单写下的行' }, - ) + - section('议价的节拍 —— 一轮一版', - ladder([ - ['1', '写成正式报价单', '中英双语,六样齐备'], - ['2', '人工按下发出', '触达客户的一下,系统不替人做这个决定'], - ['3', 'D+3 提醒到点', '没有跟进的报价等于没报'], - ['4', 'D+7 提醒到点', '第二记催办,仍无回音即等超时'], - ['5', '客户回音 → 议价调整', '改价、改交期、换料都是新一版,重新走发出'], - ]), - { text: '往来看得出先后' }, - ) + - section('案例当下进程', stagesOnly('case:stages'), { text: 'Enabled ≠ Active' }) + - section('这一步还没碰外界', - verdict('报价单此刻只是草稿 —— 一封信都还没发出去', 'wait', '发出这一下必须人工按') + - noteOnly( - '发出是写类动作:过程中只登记为「动作意向」—— 入参冻结、等放行,放行前不触达世界。' + - '预授权是逐项列举的:催办模板可即时发,报价单不行。', - ), - ), + part('议价任务', '客户 · 报价函 · 共 条') + + `
${ico('info', 14)} + 这一屏是一份报价函的作业台:系统已按 A5 批准的价排好版,条款与有效期都在下面 —— + 报出去的价一个数字都不改(要改价得回审批)。你要做的是把它发给客户, + 按 D+3 / D+7 的节奏催问,客户回话之后在「客户表态」那一列逐行落。 + 这一列系统一格都不写:客户回了什么,只有收信的人看得见。 +
` + + 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') + + `
${ico('info', 13)} + 带红标的客户表态是必填,而且只由你填 —— 缺一行执行单按不下提交, + 与放行门是同一个数同一套算法。四个取值一个字母不许差: + ACCEPT 接受 · COUNTER 还价 · REJECT 拒绝 · NOREPLY 无回复。 + 还价的行务必把客户报的那个价填进客户还价 —— 那一格空着这张单同样提交不了, + 后面拍板全看它。客户只回了一部分料的,没回的那几行按 NOREPLY 落, + 别替客户猜;本轮不报的料按行首 × 撤掉。 +
`, + foot: sign('BD 议价 · AI 排版报价函并按两把尺定案,客户那一侧的话由你落', '统一报价 · A6 报价议价 · 在办'), }); -/** 局面二:待放行 —— 按下的那一刻信就发出去,这是最后一次能改的机会 */ -export const QUOTE_RELEASE = viewShell({ - title: '正式报价单 · 放行即发出', - kicker: 'A6 · 报价议价 · 成果待签', - tone: 'case', - icon: 'send', - stamp: { kind: 'live', text: '待签字放行' }, - sign: 'A6 报价议价 · 正式报价单(待放行即发出)', +/* ==================== 局面二 · 成交定案清单(拍板 / 放行 / 归档)==================== */ + +/** 落边图:定案清单 → 三个去向(命中边由壳上 v-r1/v-r2/v-r3 上色,件不算不判) */ +const ROUTE_FIG = `
+ + + 成交定案清单 + 共 条 + + + + + + + + 退回重批 条 + 成交 条 + 不成交 条 + + A4 · 成本定价 + 让幅超授权 · 退回重批 + + 转订单履约 ORM + 成交 · 这条流走到终点 + + A7 · 情报归档 + 拒绝 / 超时 · 情报留下 + +
+ 命中边实线亮 · 未命中留灰虚线 + 按序求值首个命中即出:判了退回重批 → 回定价;其次有成交 → 转履约;都没成 → 转归档 + 判定在你签字那一刻由系统现算 —— 要改去向,在这张表上改定案那几格 +
+
`; + +export const NEGOTIATE_ROSTER = doc({ + badge: 'file-check', + kicker: '统一报价 · A6 报价议价 · 交付成果', + title: '成交定案清单', + titleRef: 'rfq_ref', + state: + '报价 条 · 待拍板 条(要归零)', body: - statsBand([ - ['单价 CNY', 'unit_price'], - ['交期', 'lead_time'], - ['最小起订量 MOQ', 'moq'], - ['有效期', 'valid_until'], - ]) + - section('本单交付:正式报价单', - tableOnly('mine:quote_negotiate.formal_quote', [ - ['报价单号', 'quote_no', 'mono'], - ['型号 PN', 'pn', 'mono'], - ['单价', 'unit_price'], - ['交期', 'lead_time'], - ['SPQ', 'spq'], - ['MOQ', 'moq'], - ['贸易条款', 'trade_terms'], - ['有效期', 'valid_until'], - ]), - { text: '发出去的就是这几行' }, - ) + - section('历次报价对照 —— 这一轮让了多少', - /* - * 这一张**故意不用 `mine:`**:它取共享域整表,含既往单的行。 - * 本单成果只看 `mine:`(见上一节),而「让了多少」这件事天然要跟别的单比 —— - * 没有上一轮的价摆在旁边,人签字时判断不了这一让的分寸。 - */ - tableOnly('shared.history_price', [ - ['型号 PN', 'pn', 'mono'], - ['报价日期', 'quoted_at', 'mono'], - ['单价', 'unit_price'], - ['交期', 'lead_time'], - ]) + - noteOnly( - '这一节不是本单成果,是共享域里同一料号的历次报价(含既往单)—— 摆在这里只做一件事:' + - '给这一轮的价一个参照系。报价日期在列上,先后按日期对;它由 A7 情报归档一单一单沉下来。', - ), - { text: '含既往单' }, - ) + - section('放行前要过的四道判据', - ladder([ - ['1', '单价 / 交期 / SPQ / MOQ / 贸易条款 / 有效期齐备', 'L1 机检 · missingQuoteFields = 0'], - ['2', '按报价有效期 P-04 注明有效期', 'L2 机检 · validityDays 大于 0'], - ['3', '已建 D+3 / D+7 跟进', 'L2 机检 · 没有跟进的报价等于没报'], - ['4', 'BD 放行', 'L5 人签 · 签了才算数'], - ]), - { text: '三机检 · 一人签' }, - ) + - section('出口三选一 —— 恰发一口', - ladder([ - ['1', '成交', '转订单履约(ORM),这条流走到终点'], - ['2', '有异议', '回 A4 重开新实例重算 —— 旧的已终,不再入算'], - ['3', '拒绝 / 超时', '转 A7 情报归档,把拒绝原因与竞对价位登记下来'], - ]) + - noteOnly( - '三个出口对应三种后续,随放行恰选一口,下游网关读的就是它;不选口不许放行 ——' + - '含糊的结果比没结果更坏。「拒绝」不是失败终态:原因回写历史情报后,下一单的三分类与利润模型会用上它。', - ), - { text: '下游只读这一口' }, - ) + - section('按下放行会发生什么', - verdict('放行即落地:台账回写 + 报价单出站发送', 'wait', '已成动作留存,不回滚') + - noteOnly( - '① 落地预检先跑:交付物齐备、动作意向可执行、授权在契约边界内 —— 预检不过不发卡;' + - '② 放行通过即刻落地,出站动作附幂等键;' + - '③ 落地收敛之后输出口才发射 —— 下游只见落地完成的任务。', - ), - ), + part('议价要素', '报价议价 · 能查表的归判定表,要让价的归人') + + FOUR + + part('定案分布', '成交 + 待拍板 + 退回重批 + 不成交 = 总条数') + + DIST + + `
${ico('info', 14)} + 四段条数由基座逐行现数,不是谁报上来的。橙段(待拍板)是过渡态 —— + 客户还了价而还没人拍板,它归零这一单才走得到放行门:接了算成交、 + 退回重批算一条正经的路、守价不让算不成交。要让价就得在授权内让, + 超过一成的让幅一律回去重批,不私自破线。 +
` + + part('正式报价函', ' 条 · 每条都带客户的话') + + GAP_BAR + + editGate('quote_negotiate.line', '成交定案表') + + SETTLED_TABLE + + `
${ico('info', 13)} + 每一行摆的是这一条的来路:报了多少、客户怎么表态、还了什么价、 + 让步梯度判在哪一档、最后定成什么。签字前请重点看两处:定案列里非绿的条目, + 与客户原话列 —— 没有客户原话的还价,下一轮谈判就是重新开始。 + 这一屏可改的是成交价与客户原话两列(你的职权面)。 +
` + + part('落边 · 这一签流向哪里', '出口按序求值 · 首个命中即出') + + ROUTE_FIG + + `
+ ${ico('git-branch', 15)} +
议价结论 ——
+
转入活动
+
` + + part('放行后即执行的落地动作', '门前 → 门后') + + `
+
${ico('check', 11)} +
草稿层合并进正式版 本任务写下的报价函与议价结论一并落账; + 你就地改过的成交价与客户原话随之落定。
+
+
${ico('check', 11)} +
命中的那一口此刻才发射 报价函 + 条随载荷交给上面亮线指向的下游; + 另两口本次不起单 —— 放行之前,世界上什么都没有发生。
+
+
${ico('check', 11)} +
不成交也留资产 拒绝原因与竞对价位随载荷交给情报归档 —— + 这一单丢了,下一单的三分类与利润模型会用上它。
+
+
`, + foot: sign('BD 议价放行 · AI 排版与定案留痕 —— 要改哪一格,在这张定案表上就地改', '统一报价 · A6 报价议价 → 定去向'), }); diff --git a/activities/quote-pricing/__tests__/activity.test.ts b/activities/quote-pricing/__tests__/activity.test.ts new file mode 100644 index 0000000..ed6f4d6 --- /dev/null +++ b/activities/quote-pricing/__tests__/activity.test.ts @@ -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; + 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> }).rows?.[0]?.['target_price']).toBeLessThan( + (overLimit?.input as { rows?: Array> }).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(' { + 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 [' { + 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 }).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(/'); + } + }); + + 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(''); + }); + + it('宿主拒推含脚本的模版', async () => { + const host = createMockHost({ contract: CONTRACT }); + await expect(host.base.interaction.pushHtml('
')).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, +): { 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(//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(`([\s\S]*?)<\/condition>/.exec(block.slice(0, block.indexOf('')))?.[1] ?? ''; + return raw.replaceAll('>', '>').replaceAll('<', '<').replaceAll('&', '&'); +} + +function safeEval(expression: string, scope: Record): boolean { + try { + return evaluateCondition(expression, scope); + } catch { + return false; + } +} diff --git a/activities/quote-pricing/__tests__/steps.test.ts b/activities/quote-pricing/__tests__/steps.test.ts new file mode 100644 index 0000000..abb7c71 --- /dev/null +++ b/activities/quote-pricing/__tests__/steps.test.ts @@ -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 = { + rfqRef: 'RFQ MD14.1', + rows: [{ pn: 'BBPY00201209601Y00', type_tag: 'B', base_price: 0.058 }], + }; + const FRESH_INTAKE: Record = { + rfqRef: 'RFQ-2026-0555', + records: [{ pn: 'PESD2ETH-XR', type_tag: 'B' }], + }; + const FRESH_SOLO: Record = { 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): Record => ({ + 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): Record => ({ + 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): boolean { + try { + return evaluateCondition(expression, scope); + } catch { + return false; + } +} diff --git a/activities/quote-pricing/src/case-model.ts b/activities/quote-pricing/src/case-model.ts index 5c524d4..5a4cd2d 100644 --- a/activities/quote-pricing/src/case-model.ts +++ b/activities/quote-pricing/src/case-model.ts @@ -1,137 +1,387 @@ /** - * A4 成本定价的案例模型 —— **算价归判定表,超限那一下归人**(2026-07-31 照 A1 的状态机模式重做)。 + * A4 成本定价的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。 * - * 与 A2/A3 的分工:那两个是「一次专家判断」,派一张执行单收回结论就够了; - * 定价不同 —— 它**有确定性规则可依**(P-01 利润模型、P-02 底价门禁两支 DMN), - * 算价这件事该算不该问。所以它照 A1 的样子分步建模,而不是甩一张单给人。 + * 业务出处:`docs/MVP示例工作流.html` §1.2(S1 取价基准 → S2 逐条定价 → S3 底价自检), + * 按 A3 同一条形状建模(2026-08-03 重写,@3.0.0): * - * 三项: - * T_price —— 逐条算价并落草案(拟报单价 / 毛利 / 偏离度 / 门禁处置); - * T_approve —— **只在门禁判超限时入场**:触达客户利益的一下,人工激活,派决策卡; - * T_release —— 放行(人工激活)。 + * 上游(A3 的目标报价清单 / A2 的询价受理清单 / A1 直达的受理记录)进来 → 归一成 + * **报价草案**底表,逐条按 P-01 利润模型算出底价线与拟报单价、过 P-02 底价门禁 → + * **一张执行单**交 BD 核价(拟报单价可校可改,无基准的那几行由他定价)→ + * **只有压在拦截档上的那几条才叫人拍板**(决策卡)→ 放行,整份报价草案交定价审批。 * - * 「超限才叫人」是这个活动的要害:每条都叫人,人就成了橡皮图章;一条都不叫, - * 底价门禁就是摆设。所以那一项挂前提(`s_over`),由判定表的结论决定它入不入场。 + * 上一版(@2.0.0)三处不合现行口径,这一版整个置换: + * ① 入参必填 `rfqRef + customerCode + records` —— 而 A3 发的是 `{rfqRef, rows}`, + * 门口拦的恰是主路(现在宽进到底,归一在活动内做); + * ② 台账一个 `required` 都没有 —— 执行卡的闸没牙,人不填也能交卷; + * ③ 入场哨兵 `s_priced` 读活动自报的 `draft_total`(「机检只读基座数」第 5 类违反)—— + * 模型不记这个账,门就永不开,而它一步也没做错。 */ export const CMMN_XML = ` - - - + + + + + + + + + + - update - draft_total > 0 + update + line_total > 0 and quote_pricing_draft_rows = line_total and priced_total >= 0 - + + + update + confirmed_total > 0 + + - update - over_total > 0 + update + quote_pricing_draft_disposition_block > 0 and confirmed_total > 0 + + + + update + 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 - - - + + + - - + + + + + + + + + + + + + + - + - + `; -export const NARRATIVE = `§0 开案:拿到一批待定价条目即开案。成品是**一份全条目带偏离度的报价草案** —— -人签的不是一个数字,是**这个数字的来路**:基准从哪来、毛利怎么算、偏离多少、门禁怎么判。 +/** + * 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本,两处不重复写同一件事)。 + */ +export const NARRATIVE = `§0 开案:**一批料要定出各自的拟报单价**。来路三种,形状都收(宽进): +· **寻源转来**:任务数据里有 \`rows\` 数组 —— 目标报价清单的落地行,每行带底价(base_price) + 与价源;这是最全的一种来路; +· **受理/选型直达**:有 \`records\` 或 \`rows\` 而行上没有底价 —— 只有料号与年用量; +· **单飞**:只有一段需求文字,列了几颗料要报价。 -**能算的不问人**:P-01(利润模型)与 P-02(底价门禁)两支判定表说了算,你不临场拍价。 -你的活是把判据取齐、把结论落进台账;**只有超限的那几条才叫人**。 +**成品只有一样**:一份**报价草案** —— 整份行都在、**每一行都有拟报单价**, +而且每个价都说得出来路:基准从哪来、毛利档是哪一档、偏离底价线多少、门禁怎么判。 +人签的不是一个数字,是**这个数字的来路**。它是定价审批的直接输入。 -【T_price 逐条算价】基准价取自历史价情报(A3 的比价结论 / CRM 历史价)。新料号无历史时 -基准记 0,由底价门禁按「无基准」处置 —— **不凭空编一个偏离率出来**。 -逐条写进 \`quote_pricing.draft\`:拟报单价 / 目标毛利 / 相对基准偏离 / 门禁处置。 -同一拍接着把 draft_total(条数)与 over_total(门禁判「超限」的条数)写上 —— -这两个数一个是完成判据,一个决定要不要叫人。写完 finish。 +**能算的不问人,该人签的不代签**:P-01(利润模型)给毛利档、P-02(底价门禁)给处置, +两支判定表说了算,你不临场拍价;而**拟报单价那一列是 BD 定的,你一格都不写** —— +你给的是**建议价**,他确认或改成自己的数。你的活是把判据取齐、把结论落进台账。 -【T_approve 超限决策】**只在 over_total > 0 时走这一步**。它触达客户利益, -所以标了人工激活:**先 start,再发决策卡**(decide,itemId 填 T_approve)。 -选项按门禁的处置给(照原价报 / 按底价报 / 退回重算),consequence 写清各自的后果; -basis 摆上是哪几条、偏离多少、底价是多少(裸卡基座拒发)。 -人点完,轨迹上会出现「decision 卡回传 —— 人选了:xxx」—— **看到它就照那条路把 draft 改掉**, -别再发第二张卡。改完 finish。 +**四步走**:归一取基准算建议价 → 交 BD 定价 → 底价自检(按他定的价算毛利、偏离度、 +门禁处置)→ 定盘放行。 -【提交放行】草案有行了 —— **先 start T_release**(人工激活),然后 submit,BD 验收。 -提交前把两条裁量判据裁掉(judge,criterion 照判据名原样写);终局唯放行,你不能自己宣布完成。 +**这一单要经过人一到两次**:BD 在定价工作单上逐条定价(那张单绑着整份草案, +拟报单价缺一行按不下提交 —— 基座的闸看着,他填的格子直接落账); +**只有还压在拦截档上的条目才另外叫人拍板**(一张决策卡)。每条都叫人,人就成了橡皮图章; +一条都不叫,底价门禁就是摆设。最后 BD 放行签字。 -【一路上要说话】每一拍带一句 say,说**业务的话**:这批料算下来什么价、哪几条压线、 -要不要找人拍板。一两句、口语。别报「写草稿 N 行」这种系统词。`; +【一路上要说话】负责人正看着这一单。**每一拍都带一句 say** —— 说业务的话: +这批料算下来什么价、哪几条压线、要不要找人拍板;不说系统词。 +到了要找人的那一拍,先说清这一轮办完了什么、为什么现在需要他。 + +【怎么把一项干完 · 一拍连做】write 和 tool 都不会自动收步 —— 做完用 finish 宣告。 +动作排进同一拍的 actions 串一口气给;只有调工具(出参下一拍才看得见)与 +发卡 / 提交(挂起)必须停拍。**这一单常路 7-9 拍就该收口**。 + +§Z 结案:BD 在验收单上签字,系统把草稿合并成正式草案并发出 draft 口的载荷 —— +整份报价草案交给定价审批。`; /** - * 步骤剧本 —— 与 A1 同一套:`doneWhen` 为真,引擎自己收步、**不问模型**。 + * 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。 * - * 两处不给 `doneWhen`:`T_approve`(人工激活,要人拍板)与 `T_release`(触达世界)。 - * 给了就等于给引擎一把替人签字的钥匙。 + * 三处不给 `doneWhen`:`S2_review`(发卡触达真人)、`T_approve`(要人拍板)、 + * `T_release`(签下去触达世界)—— 给了等于给引擎一把替人签字的钥匙。 */ export const PRICING_STEPS = [ { - item: 'T_price', - intent: '逐条算价并落草案:拟报单价 / 毛利 / 偏离度 / 门禁处置,并结算条数与超限数', + item: 'S1_price', + intent: '整份归一成报价草案:取成本基准 → 按利润模型算出底价线与建议价,逐条落表', writes: ['quote_pricing.draft', 'quote_pricing.verdict'], - reads: [], + /* 上游载荷数组不声明就会被整表段的截断刀砍掉(砍掉的恰是要归一的行) */ + reads: ['rows', 'records'], + view: 'pricing-worksheet', + tools: ['crm.history-price-read'], + /* + * 归一 + 算价两轮都走完才算办完:行数两边对上(一边是基座现数), + * 且 priced_total 写过(全是无基准的单子这个数就是 0;S-FEEL 未绑定的名字 + * 参与比较为假,所以「没写过」进不来)。 + */ + doneWhen: 'line_total > 0 and quote_pricing_draft_rows = line_total and priced_total >= 0', + guide: `第一件事:把这一单的**报价草案**落出来,并把能算的价都算出来。 + +【料从哪儿来】 +· 寻源转来(\`rows\` 里的行带 base_price / price_basis):照抄 —— 底价就是成本基准, + 一个数字都别改;带 quote_line_total 这类总数的是结论行,不是料,别落进草案。 +· 受理/选型直达(\`records\` 或没有底价的 \`rows\`):料号、品类、制造商、年用量、 + 客户目标价、类别(type_tag)照抄,**底价一栏留空**。 +· 单飞:照正文归一,列了几颗落几行;没说类别的按 B。 +**按 pn 归并,一颗料一行**;pn 一个字符不许改(它是主键,改一个字就是新开一行)。 + +【第一拍:只做两件事 —— 落草案行 + 串尾发一次查价】 +① 写 \`quote_pricing.draft\`,一颗料一行(rows 收数组): + rfq_ref(入参有就原样抄;没有从主题认;认不出才自己造一个,**造出来的必须以 \`PRC-\` 开头** + (\`PRC-<主题或第一颗料的型号>\`)。两条硬规矩: + **① 整单只有一个单号,不是一颗料一个** —— 它是这一单的案号,草案所有行、verdict 那一行 + 用的都得是同一个串(实测栽过:两颗料造出两个号,一单裂成两单); + **② 只从本单来件取材** —— 台账里躺着的 RFQ-xxxx 是别人的单,借它的号等于把两单的行 + 混进同一个案卷(实测栽过一轮))、pn、customer_pn、category、 + brand、eau、target_price(客户目标价)、lead_time、remark(客户约束/急料,一个字别丢)、 + type_tag(照上游现标;单飞按 B)、base_price(上游给了就抄,没有留空)。 + suggested_price / margin_band / floor_price **这一拍全部留空** —— 基准还没取齐。 + **quoted_price / margin / deviation / disposition 这四列从头到尾都不是你写的**(见下)。 +② 串尾调 \`crm.history-price-read\`:pns 收数组,把**底价还空着的那几颗料**一次传完 + (上游已经给了底价的不必查)。 +**这一拍不写 \`quote_pricing.verdict\`,一列都不写** —— 尤其 priced_total: +它是「算价走完了」的凭据,提前写等于把闸提前打开,引擎会在下一拍开头当场收掉这一步, +**算价从此不会发生**(A3 实测栽过:嘴上说预填了,台账里没有)。 + +【**这一列你一格都不写**:quoted_price】 +拟报单价是 **BD 在工作单上定的那个数** —— 人签的就是它。你给的是**建议价** +(suggested_price),他确认或改成自己的数;margin / deviation / disposition 三列 +要等他定完价才算得出来,这一步也别写。 +这不是客气话:从前只有一列价、剧本写着「算不出的留空」,实测跑单时两颗库里查不到的料 +照样被编出了价(一颗抄客户目标价、一颗凭空写了个数),而交代里还说着「价空着等你定」。 +**一条要你分辨「这行算得出/算不出」的规矩会走岔;「这一列不写」没有分支可走岔。** + +【第二拍:算建议价 + 记账 + 收步,一拍连做】 +① 补基准:**只补上一拍 base_price 空着的那几行** —— 工具出参里 hit=true 的写 + base_price = lastPrice、price_basis 写 **HIST**;hit=false 的**别编价**,base_price 留空, + 它就是「无基准」那一档。 + **上游已经给了 base_price 的行:工具回什么都不采用,那一格一个数字都别改** —— + 上游给的是本单问回来的货源底价,工具给的是库内历史价;这一单的成本是前者 + (实测栽过:两行上游底价被历史价盖掉,毛利从 20% 变成 115%,数字全对而结论是假的)。 +② 逐条算**建议价**,规矩写死,照做即可(**这些是算术,不是判断**): + · 毛利档 margin_band 按利润模型 P-01 取(客户等级 × 类别): + 战略客户 + A 类(大批量长单)→ 8%~10%;普通客户 + A 类 → 12%~15%; + B 类 → 15%~20%;C 类 → 20%~50%;拿不准按 12%~15%。写成「12%~15%」这样的文本。 + · **底价线** floor_price = base_price ×(1 + 档下限)。保留四位小数。 + · **建议价** suggested_price: + - 客户没给目标价(target_price 空)→ 取 base_price ×(1 + 档上限); + - 客户目标价 ≥ 底价线 → 就取客户目标价(接得住,毛利还在档内); + - 客户目标价 < 底价线 → 仍取客户目标价(贴着客户报),这一条到门禁那儿会亮灯; + - **base_price 空(无基准)→ margin_band / floor_price / suggested_price 三列一起留空**。 + 不要拿一个不相干的价去凑一个数出来 —— 那比没有建议更危险,它看起来像有依据。 + · **「没有」要给空串 \`""\`,不许写 0** —— 0 是一个数,底价线写 0 看起来像「成本为零」, + 偏离度写 0 看起来像「正好压在底价线上」。空串基座会当成「这一列不写」。 +③ 这时才写 \`quote_pricing.verdict\`(一次写全):rfq_ref、customer_code / customer_name + (顶层有就抄)、customer_grade(来件认得出就写,认不出写「普通客户」)、 + **line_total** = 草案行数、**basis_total** = 有成本基准的条数、 + **priced_total** = 这一拍算出建议价的条数(0 也要写)。 +④ finish 这一项。`, + }, + { + item: 'S2_review', + intent: '一张执行单交 BD:工作单上逐条定价(拟报单价归他);收回后过底价门禁并定盘', + writes: ['quote_pricing.draft', 'quote_pricing.verdict'], + reads: ['quote_pricing.draft'], + view: 'pricing-worksheet', tools: [], - // 草案有行了才算算完。不写 `!= null` —— 未绑定的名字那样求值为 true(A1 踩过) - doneWhen: 'draft_total > 0', - guide: `基准价取自历史价情报(A3 的比价结论 / CRM 历史价)。新料号无历史时基准记 0, -由底价门禁按「无基准」处置 —— **不凭空编一个偏离率**。 -逐条写进 \`quote_pricing.draft\`:拟报单价 / 目标毛利 / 相对基准偏离 / 门禁处置。 -**同一拍接着**把 draft_total 与 over_total(门禁判超限的条数)写上 —— -前者是完成判据,后者决定要不要叫人。 -**同一拍再写一行 \`quote_pricing.verdict\`**(验收机检读的就是它): -· draft_total / unpriced_total = 草案条数 / 还没定出价的条数(后者要 = 0); -· over_total = 门禁判超限的条数; -· unresolved_over_total = 超限里**还没处置**的条数(要 = 0 —— 回调了或注明了理由都算已处置); -· summary 一句人话结论。写不对 submit 会被门挡回来。三笔写完再 finish。 -**每一行都要带 \`rfq_ref\`** —— 它是这一单的单标识(起单入参里就有,原样抄进去)。 -台账主键第一列就是它:不写,这一行会和**别的单**同料号的那一行撞主键,把人家覆盖掉 -(实测栽过:A4 的报价草案主键只有 pn,跑四轮只剩最后一轮的 3 行)。`, + /* 不给 doneWhen —— 发卡触达真人(manualActivation),引擎不替人按 */ + guide: `建议价算完了,**该 BD 出场了** —— 这是本活动唯一的一张执行单, +**拟报单价是他定的,不是你定的**。工作区摆的是**定价工作单**:逐颗料一张定价档案卡 +(成本基准、毛利档、客户目标价、底价线、系统建议价),利润模型与底价门禁两把尺挂在上面, +填回区长在同一屏 —— 他在「拟报单价」那一列逐行填(照建议价确认,或改成自己的数)。 + +这一步标了人工激活:发卡就算启动,不必单发 start;引擎也不会替你收 —— 人交回后要你自己 finish。 + +【照这个骨架发,别少字段】(卡面是纯文本,steps/standard 里**别写星号记号**): +{"kind":"human","itemId":"S2_review","workOrder":"execution","card":{ + "title":"报价定价:<单号> 共 条待你定价", + "ask":"系统已按成本基准与利润模型算出每一颗的建议价,请在工作单的「拟报单价」一列逐行定价:认可建议价就照抄,不认可就改成你的数;库内没有成本基准的那几行没有建议价,由你定。", + "steps":["拟报单价每一行都要有 —— 缺一行这张单按不下提交;它是这一单最后签下去的那个数,系统不替你填", + "建议价是从成本基准按利润模型的毛利档推出来的:照抄就是认可这个毛利,改高改低都可以,改了在备注里写一句缘由", + "低于底价线的价会被底价门禁标出来:两成以内附理由即可放行,超过两成的要另外找人拍板 —— 你在这里往上调一点,就不必再惊动一个人", + "库内查不到成本的料没有建议价:按你的经验定价,并在备注写明依据;实在定不了的按行首 × 把这一条撤出本轮"], + "standard":"每一行都有拟报单价,而且每个价都说得出来路(基准 / 毛利档 / 让价理由)—— 缺一行这张单按不下提交(系统的闸看着)。", + "basis":[{"k":"","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'], - reads: [], + intent: '压在拦截档的条目要人拍板 —— 发一张决策卡,照他选的那条路把草案改掉', + writes: ['quote_pricing.draft', 'quote_pricing.verdict'], + reads: ['quote_pricing.draft'], + view: 'pricing-roster', tools: [], - // 人工激活 + 要人拍板 —— 不给 doneWhen,引擎不许替人收 - guide: `**只在 over_total > 0 时走这一步**。它触达客户利益,标了人工激活: -**先 start T_approve**(与发卡排同一拍即可),再发决策卡(decide,itemId 填 T_approve)。 -选项按门禁处置给(照原价报 / 按底价报 / 退回重算),consequence 写清后果; -basis 摆上哪几条、偏离多少、底价多少(**裸卡基座拒发**)。 -人点完轨迹上会出现「decision 卡回传 —— 人选了:xxx」,**照那条路改 draft,别再发第二张卡**。`, + /* 人工激活 + 要人拍板 —— 不给 doneWhen,引擎不许替人收 */ + guide: `**这一单只发一张决策卡。动手之前先分清你在哪一半:** + +· 轨迹里**已经有**「decision 卡回传」/「人选了:xxx」—— 那就跳到下面【人点完之后】那一段, + 照他选的那条路改草案、写 route_decided_total、finish。**一个 decide 都不许再发** + (实测栽过:人点完了,而这一步又从头念了一遍「发一张决策卡」,同一张卡连发四次, + 调人预算烧光,单子 exhausted —— 他点过的那一下就白点了); +· 轨迹里**还没有** —— 才轮到下面【发一张决策卡】那一段。 + +这一步只在还有条目压在拦截档(基座现数 quote_pricing_draft_disposition_block > 0)时入场; +它触达客户利益,标了人工激活:发卡就算启动,不必单发 start。 +工作区这时摆的是**报价草案清单**,整份草案与门禁分布都在上面,他看得见全局再拍板。 + +【发一张决策卡,照这个骨架】(选项少于两个基座拒发): +{"kind":"decide","itemId":"T_approve","ask":" 条低于底价线两成以上,按哪条路办?", + "options":[ + {"value":"floor","label":"回调到底价线报","consequence":"守住毛利下限,客户可能不接受这个价","recommended":true}, + {"value":"as-is","label":"照现价破线报,附理由","consequence":"接住客户目标价拿单,毛利跌破下限,理由随草案交审批"}, + {"value":"drop","label":"这几条本轮不报","consequence":"撤出本单草案,客户拿到的是一份缺项的报价"}], + "basis":[{"k":"","v":"成本基准 · 底价线 · 拟报 · 偏离 "}]} +· 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 验收', + intent: '草案已定盘,提交放行交 BD 验收', writes: [], reads: [], + view: 'pricing-roster', tools: [], - guide: `草案有行了 —— **先 start T_release**(人工激活),提交前用 judge 裁掉裁量判据 -(criterion 照判据名原样写),然后 submit。终局唯放行:你不能自己宣布完成。`, + /* 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 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'] }, +]; diff --git a/activities/quote-pricing/src/index.ts b/activities/quote-pricing/src/index.ts index 76cb9d6..959cfe1 100644 --- a/activities/quote-pricing/src/index.ts +++ b/activities/quote-pricing/src/index.ts @@ -1,79 +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', - - async run(base: ActivityBase): Promise { - 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' }); diff --git a/activities/quote-pricing/src/manifest.ts b/activities/quote-pricing/src/manifest.ts index e1bf687..9dd67c4 100644 --- a/activities/quote-pricing/src/manifest.ts +++ b/activities/quote-pricing/src/manifest.ts @@ -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, PRICING_STEPS } 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,95 +79,162 @@ export const manifest: ActivityManifest = { identity: { activityId: ACTIVITY_ID, name: 'A4 · 成本定价', - packageVersion: '2.0.0', + 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: ['rfqRef', 'customerCode', 'records'], + required: [], properties: { rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' }, - customerCode: { type: 'string' }, - records: { type: 'array' }, - // B/C 类由 A3/A2 交回的底价与替代方案;A 类直通时为空 - floorPrices: { type: 'array' }, + 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: ['rfqRef', 'items'], - properties: { rfqRef: { type: 'string' }, items: { type: 'array' } }, + properties: { + rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' }, + customerCode: { type: 'string', title: '客户码' }, + items: { type: 'array', title: '报价草案(每条带拟报单价、偏离度与门禁处置)' }, + }, }, - description: '报价草案(逐条带偏离度)', + description: '报价草案(全条目带拟报单价与门禁处置)', }, ], selective: [], }, /* - * 必交付物**必须给 `ledger` 地址**(2026-07-31 修)。 - * 不给的话预检退回 `scope[key]` 去找 —— key 是 `benchmark-table` 这样的短横线别名, - * 任务视图里永远没有这个键,于是「必交付物缺一不得提交放行」永远拒, - * 活办完了也放不了行,连拒五拍熔断(实测 A2 死于此)。 + * 必交付物只有一件:**报价草案**。定价结论(verdict)是它的结论栏, + * `ledgers` 说清这份成果由哪几张表承载(一份成果,不是两份)。 */ requiredDeliverables: [ - { key: 'quote-draft', label: '报价草案(全条目带偏离度)', ledger: `${DOMAIN}.draft` }, + { + key: 'quote-draft', + label: '报价草案(每条带拟报单价与门禁处置)', + ledger: `${D}.draft`, + /* 验收页上让 BD 就地改的两列 —— 恰是他的职权面(定价与让价理由) */ + editableAtRelease: ['quoted_price', 'price_note'], + ledgers: [`${D}.draft`, `${D}.verdict`], + }, ], /* - * 机检谓词读的是 `verdict` 那张**汇总表**的列(照 A1 的口径)。 - * 上一版的名字(unpriced;floor-gate 更是连谓词都没有)**台账里根本没有** —— S-FEEL 拿未绑定的名字 - * 比大小求值为假,判据永远达不了标,submit 门永远拒,连拒五拍熔断。 - * 判据的名字必须落在台账上,否则「机检」是一句空话(2026-07-31 销账)。 + * 验收判据 —— **机检(L1/L2)只读基座数得出来的数**: + * `_rows` 基座数行数、`_missing` 基座按 required 现数(与执行卡提交闸同一个数 + * 同一套算法)、`_disposition_*` 基座按值现数。 + * 上一版两条机检读活动自报的 `unpriced_total` / `unresolved_over_total` —— + * 那不是门,是「改哪个数能过」的提示(违反史已记四次,这是第五类)。 */ acceptanceCriteria: [ - { key: 'all-priced', label: '每条都有拟报单价与毛利率', level: 'L1', predicate: 'unpriced_total = 0' }, - // 底价门禁沉为 L2 机检 —— 「约束力收缩」的终点 - { key: 'floor-gate', label: '偏离底价 ≤±20%,超限者已回调或注明理由', level: 'L2', modelRef: 'p02-floor', predicate: 'unresolved_over_total = 0' }, - { key: 'pm-release', label: 'BD 放行', level: 'L5' }, + { + 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: { - /* - * `quote-pricing.basis` 已从声明里去掉(2026-07-31):工具箱里从没注册过它, - * 装包时静态审一直报「运行时转人代办」—— 而重做后的案例模型压根不调它 - * (算价归判定表 P-01/P-02,取基准读历史价)。声明一件不存在也不用的工具, - * 只会让每一单跑到 A4 就弹一张莫名其妙的转人卡。 - */ tools: [{ name: 'crm.history-price-read' }], skills: PRICING_SKILLS.map((s) => s.id), models: ['deepseek-v4-flash'], roles: ['BD'], - 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' }], ledger: [ { - domain: DOMAIN, + domain: D, object: 'draft', kind: 'record', caseKey: 'rfq_ref', @@ -158,82 +242,226 @@ export const manifest: ActivityManifest = { 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: DOMAIN, + domain: D, object: 'verdict', kind: 'record', caseKey: 'rfq_ref', label: '定价结论', fields: [ { name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' }, - { name: 'pn', type: 'text', primaryKey: false, masked: false, label: '型号' }, - { name: 'draft_total', type: 'integer', primaryKey: false, masked: false, label: '草案条数' }, - { name: 'unpriced_total', type: 'integer', primaryKey: false, masked: false, label: '未定价条数' }, - { name: 'over_total', type: 'integer', primaryKey: false, masked: false, label: '超限条数' }, - { name: 'unresolved_over_total', type: 'integer', primaryKey: false, masked: false, label: '超限未处置条数' }, + { name: '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, - /* 步骤剧本 —— 与 A1 同一套:doneWhen 真了引擎自己收步,不问模型 */ 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'] }, }, ], diff --git a/activities/quote-pricing/src/skills.ts b/activities/quote-pricing/src/skills.ts index 0db4b7d..2901a6b 100644 --- a/activities/quote-pricing/src/skills.ts +++ b/activities/quote-pricing/src/skills.ts @@ -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', }, ]; diff --git a/activities/quote-pricing/src/views/prd-style.ts b/activities/quote-pricing/src/views/prd-style.ts new file mode 100644 index 0000000..9d2c0b5 --- /dev/null +++ b/activities/quote-pricing/src/views/prd-style.ts @@ -0,0 +1,409 @@ +/** + * A1 两份执行页的样式表 —— **从 PRD 目标件原样搬过来**,不是我照着复述的。 + * + * 出处:`~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理需求.html` 的整段 ` +${PRD_SPRITE} +
+
+
+
${ico(input.badge, 17)}
+
+ ${input.kicker} +

${input.title}

+
+ ${input.state} +
+
${input.body}
+
${input.foot}
+
+
`; + +const part = (label: string, aside = '', tone = ''): string => + `
${label}${aside === '' ? '' : `${aside}`}
`; + +const sign = (note: string, tag = ''): string => + `
+ ${ico('user', 15)} +
${note}
+
${tag === '' ? '' : `${ico('git-branch', 13)}${tag}`}`; + +/* ==================== 组成件 ==================== */ + +/** 要素四卡:客户 / 待定价 / 一格一料的门禁刻度 / 定价的尺 */ +const FOUR = `
+
+ ${ico('building')}客户公司 +
+ +
+
+ 承自上游;单飞起单时此栏可空 +
+ +
+ ${ico('boxes')}待定价 +
/ 条草案
+ 拟报单价还空着的行 —— 这一列由你落笔,系统只给建议价;补一格少一格,基座现数 +
+ +
+ ${ico('shield-check')}底价门禁逐条 +
+ +
+ 一格一条料 · 绿=在底价线之上,橙=低于两成内附理由,红=压在拦截档待拍板 +
+ +
+ ${ico('history')}取价的尺 + ${ico('check', 13)}成本 × 利润模型 → 底价线 + 基准取上游底价或库内历史价;毛利档由 P-01 定;偏离底价线两成即止(P-02) +
+
`; + +/** 一个价是怎么长出来的 —— 走位图(形状写死,不算不判) */ +const PRICE_LINE = `
+
成本基准上游底价 / 库内历史价 —— 取不到就空着,不编
+
${ico('arrow-right', 18)}
+
毛利档 P-01客户等级 × 类别 × 量档 → 一段区间
+
${ico('arrow-right', 18)}
+
底价线成本 ×(1 + 档下限)—— 比它低就要有人负责
+
${ico('arrow-right', 18)}
+
建议价 → 你定价系统给建议,拟报单价那一格由你落笔
+
${ico('arrow-right', 18)}
+
门禁 P-02偏离底价线:≥0 放行 / 两成内附理由 / 超两成拍板
+
`; + +/** 缺价红带 —— 与提交闸、放行门 L1 同一个数同一套算法(基座现数);一格不缺自动收 */ +const GAP_BAR = `
${ico('alert', 14)} + 还差 行拟报单价没定 + —— 。 + 按右上角「编辑」开表,红框那几格就是要填的:认可系统建议价就照抄那个数,不认可就改成你的; + 库内查不到成本的那几行没有建议价,由你按经验定并在备注写明依据。补齐了执行单才提交得了。 +
`; + +const editGate = (address: string, what: string): string => `
+ ${ico('lock', 12)}${what}已锁定 —— 看的时候不误碰,要改按右边 + + + +
+
${ico('pen', 14)} + 可填的是拟报单价与备注两列:拟报单价这一列系统不替你填 —— + 认可建议价就照抄那个数,改高改低都可以,改了在备注里写一句缘由; + 毛利、偏离度与门禁结论随后按你定的价重算。本轮不报的料按行首 × 撤掉。 + 改动只落任务草稿层,保存不等于放行。 +
`; + +const addRowBar = (address: string): string => `
+ + 照末行的样子添一行空的;主键(型号 PN)填了才存得下,不填这一行不落库。 +
`; + +/** + * 草案主表 —— 一行一料:承继列 + 定价五列 + 类徽与门禁徽。 + * `data-col` 只挂人可改的两列(quoted_price / price_note)+ 主键 pn —— + * 编辑面精确等于 BD 的职权面;**主键格必挂 `data-col`**(它不是编辑面,是行的钥匙: + * 不挂,服务端按「全表一致列」补键,两行会对到同一行上,后者盖前者 —— A2 走查逮过)。 + * 拟报单价挂 `req`:空着出「必填」胶囊、整行标底,与提交闸、放行门同源(都数 `_missing`)。 + */ +const DRAFT_TABLE = `
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
类PN品类年用量成本基准毛利档底价线客户目标价系统建议价拟报单价(你定)毛利率偏离度门禁备注 / 让价理由
`; + +/** 门禁分布:三段按条数分宽 —— 守恒等式(三档相加 = 行数)的可视形 */ +const DIST = `
+
+ + + +
+
+ 直接放行 · 在底价线之上 + 附理由放行 · 低于两成内 + 拦截待拍板 · 低于两成以上 +
+
`; + +/* ==================== 局面一 · 定价工作单(算价与 BD 核价期间)==================== */ + +/** + * 逐颗定价档案 —— 一颗料一张卡(data-rows 逐行复制):左题面、右推演。 + * + * **容器用 `
` 不用 `
`**:渲染器的行展开按第一个同名闭合标签截行内容, + * 行模版里嵌着 `
` 时 `
` 会把行截成套娃(A2 逮过,记在陷阱表)。 + */ +const PRICE_CARDS = `
+
+
+ + + + +
+
+
+
题面(客户要什么)
+
客户料号
+
品类
+
制造商
+
年用量
+
客户目标价
+
交期要求
+
客户约束
+
+
+
定价推演(填回下表,这里回显)
+
成本基准
+
毛利档
+
底价线
+
系统建议价
+
拟报单价
+
毛利率
+
偏离底价线
+
让价理由
+
+
+
+
`; + +/** 利润模型 P-01 的毛利档 —— 静态规则,尺挂在墙上(素材:MVP 示例工作流 §1.2) */ +const MARGIN_RULES = `
+
+ 8%~10% +

战略客户 · A 类标准件 · 大批量。量换价,毛利让到下限也要守住 —— 这一档的价不是拿来谈的, + 是拿来锁长单的。

+
+
+ 12%~15% +

普通客户 · A 类标准件。常规档,也是拿不准时的兜底档 —— 行情透明的料按这一档报不吃亏。

+
+
+ 15%~20% +

B 类波动件。货源与行情都会动,毛利要留出被动让价的余地。

+
+
+ 20%~50% +

C 类替代 / 停产件。一事一议:找料本身就是成本,替代还带验证风险 —— + 报低了后面每一次补货都在亏。

+
+
`; + +/** 底价门禁 P-02 的三档尺 —— 静态规则(素材:MVP 示例工作流 §1.2 底价自检) */ +const GATE_RULES = `
+
+ ≥ 底价线 +

直接放行。报价在底价线之上,毛利还在档内 —— 不必解释,提审即可。

+
+
+ 低于两成内 +

附理由放行。为拿单让到底价线以下两成以内:在备注里写清让价的缘由 + (客户长单、带单品、竞对压价)—— 理由随草案一起交审批,不写等于没有依据。

+
+
+ 低于两成以上 +

拦截,要人拍板。这一档系统不替任何人放行:要么回调到底价线,要么由负责人 + 拍板破线并留下理由,要么这一条本轮不报。私自绕开门禁不算处置。

+
+
`; + +export const PRICING_WORKSHEET = doc({ + badge: 'sheet', + kicker: '统一报价 · A4 成本定价 · 定价作业', + title: '定价工作单', + titleRef: 'rfq_ref', + state: + '待定价 / 条', body: - /* - * 指标带摆的是**利润模型 P-01 的三个入参 + 这一单的工作量**,不是所有字段。 - * 三个档位键取的是清单里 P-01 声明的决策入参名(customerGrade / materialType / - * volumeTier)—— 决策表照这三个名字取值,模版就照这三个名字摆,不另起炉灶。 - * `records` 是起单入参的数组,基座渲染数组时给的是条数,正好是「这一单要定几条价」。 - */ - statsBand([ - ['待定价条目', 'records'], - ['客户等级', 'customerGrade'], - ['料号类型', 'materialType'], - ['数量档', 'volumeTier'], - ]) + - section('基准从哪来 —— 四级优先级', - ladder([ - ['1', '本客户近期成交价', '最贴近这一单的真实议价空间'], - ['2', '同型号他客户成交价', '价位可比,但议价空间要留余量'], - ['3', '供应商底价表', '成本侧的锚,不含客户侧信息'], - ['4', '无基准', '新料号、久未报价的老料号都会落到这里'], - ]) + - noteOnly( - '无基准不是异常,它本身就是一种基准。此时偏离率记 0 —— 没有可偏离的对象,' + - '由底价门禁按「无基准」处置。不要拿一个不相干的价去凑一个偏离率出来:' + - '那比没有基准更危险,因为它看起来像有依据。', - ), - { text: '越靠前越贴近实情' }, - ) + - section('本单待定价条目', - tableOnly('mine:quote_pricing.draft', [ - ['型号 PN', 'pn', 'mono'], - ['基准价', 'last_price'], - ['拟报单价', 'quoted_price'], - ['毛利率', 'margin'], - ]), - { text: '只列本单写下的行' }, - ) + - section('案例当下进程', stagesOnly('case:stages'), { text: 'Enabled ≠ Active' }), + part('定价任务', '客户 · 共 条 · 有基准价 条') + + `
${ico('info', 14)} + 这一屏是一份报价草案的作业台:系统按「成本基准 × 利润模型」给出底价线与 + 建议价,拟报单价那一列由你落笔 —— 人签的就是这个数,系统不代签。 + 库内查不到成本的料连建议价都没有(不编),那几行按你的经验定并在备注写明依据。 + 你定完价,毛利、偏离度与底价门禁的结论跟着重算。 +
` + + 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') + + `
${ico('info', 13)} + 带红标的拟报单价是必填,而且只由你填 —— 缺一行执行单按不下提交, + 与放行门是同一个数同一套算法。系统给的是建议价:照抄即认可,改了以你的为准。 + 查不到成本的料没有建议价(系统不编):按你的经验定一个价并在备注写明依据, + 或者按行首 × 把这一条撤出本轮。压在拦截档(红)的条目在你这儿往上调一点, + 就不必再惊动第二个人。 +
`, + foot: sign('BD 核价 · AI 按利润模型与底价门禁算价,你定最后那个数', '统一报价 · A4 成本定价 · 在办'), }); -/** 局面二:S2 自检 —— 人此刻要核的是「凭什么是这个价、门禁怎么判的」 */ -export const PRICING_PRICED = viewShell({ - title: '定价与底价门禁', - kicker: 'A4 · 成本定价 · S2 自检', - tone: 'brand', - icon: 'gauge', - stamp: { kind: 'live', text: '自检中' }, - sign: 'A4 成本定价 · 定价自检(判定表 P-01 / P-02)', - body: - /* 标签一律照台账声明的列名(基准价 / 拟报单价 / 毛利率 / 偏离度 / 门禁处置): - 同一个数在指标带上叫一个名、在下面的表里叫另一个名,签字的人就得先花一秒确认它俩是不是一回事 */ - statsBand([ - ['基准价', 'last_price'], - ['拟报单价', 'quoted_price'], - ['毛利率', 'margin'], - ['偏离度', 'deviation'], - ]) + - section('逐条定价结论', - tableOnly('mine:quote_pricing.draft', [ - ['型号 PN', 'pn', 'mono'], - ['基准价', 'last_price'], - ['拟报单价', 'quoted_price'], - ['毛利率', 'margin'], - ['偏离度', 'deviation'], - ['门禁处置', 'disposition'], - ]), - { text: '判据在左 · 结论在右' }, - ) + - section('目标毛利怎么定的 —— 利润模型 P-01', - ladder([ - ['1', '战略客户 · A 类标准件 · 大批量', '目标毛利 8%–10%'], - ['2', '普通客户 · A 类标准件', '12%–15%'], - ['3', 'B 类波动件', '15%–20%'], - ['4', 'C 类替代 / EOL', '≥20%,或一事一议'], - ['5', '以上都不命中', '兜底按普通客户标准件 12%–15%'], - ]) + - noteOnly( - '自上而下取第一条命中的规则,同输入必得同结论、零方差零 token,命中的规则行随出参留痕。' + - '不要为了让门禁「过」而回头改毛利参数 —— 那是把判据改成结论,决策表也就失去了它唯一的价值。', - ), - { text: '首条命中' }, - ) + - section('偏离怎么判的 —— 底价门禁 P-02', - ladder([ - ['1', '拟报价 ≥ 底价', '直接放行'], - ['2', '低于底价 ≤ 20%', 'PM 附理由放行'], - ['3', '低于底价 超 20%', '拦截 · BOSS 拍板'], - ]) + - noteOnly( - '门禁不是「拦下」而是分流:超限的一律调人 —— 要么回调价格,要么写明为什么这样报,' + - '两条路都合法,但都要留下理由。这条留痕将来会成为客户等级与利润模型的迭代素材。', - ), - { text: '±20% 即止' }, - ) + - section('本单门禁结论', - /* - * 结论章里嵌 `data-slot`:处置与偏离都是**这一单算出来的事实**, - * 写死一句「已通过」就是替判定表下结论。橙档在此的意思是「还没定,等人处置」。 - */ - verdict('底价门禁 P-02 处置:', 'wait', '偏离度 ') + - noteOnly( - '禁区两条随本单同行:不得静默绕开 P-02 的结论 —— 要破线只能走「人 · 决策」;' + - '底价超一周未更新的,先刷底价再判 —— 拿旧底价判新行情等于没判。', - ), - ), -}); +/* ==================== 局面二 · 报价草案清单(拍板 / 放行 / 归档)==================== */ -/** 局面三:待放行 —— 验收单上人要签的那份成果报告 */ -export const PRICING_RELEASE = viewShell({ - title: '报价草案 · 待放行', - kicker: 'A4 · 成本定价 · 成果待签', - tone: 'case', - icon: 'shield-check', - stamp: { kind: 'live', text: '待签字放行' }, - sign: 'A4 成本定价 · 报价草案(待放行)', +export const PRICING_ROSTER = doc({ + badge: 'file-check', + kicker: '统一报价 · A4 成本定价 · 交付成果', + title: '报价草案', + titleRef: 'rfq_ref', + state: + '草案 条 · 待拍板 ', body: - statsBand([ - ['拟报单价', 'quoted_price'], - ['毛利率', 'margin'], - ['偏离度', 'deviation'], - ['门禁处置', 'disposition'], - ]) + - section('本单交付:报价草案', - tableOnly('mine:quote_pricing.draft', [ - ['型号 PN', 'pn', 'mono'], - ['基准价', 'last_price'], - ['拟报单价', 'quoted_price'], - ['毛利率', 'margin'], - ['偏离度', 'deviation'], - ['门禁处置', 'disposition'], - ]) + - noteOnly('只列本单写下的行 —— 别单放行过的记录不混进来。逐条列出,便于放行前逐条核对。'), - { text: '本单草稿' }, - ) + - section('放行前要过的三道判据', - ladder([ - ['1', '每条都有拟报单价与毛利率', 'L1 机检 · unpriced = 0'], - ['2', '偏离底价 ≤ ±20%,超限者已回调或注明理由', 'L2 机检 · 底价门禁 P-02'], - ['3', 'BD 放行', 'L5 人签 · 签了才算数'], - ]), - { text: '两机检 · 一人签' }, - ) + - section('放行之后会发生什么', - verdict('放行即落地:草稿合并入正式版,草案提交 A5 定价审批', 'wait', '审批点卡在 PM 工位') + - noteOnly( - '审批驳回按声明走向,不由临场决定:回 A4 重开新实例 —— 新任务 ID、留痕关联,' + - '不复活旧任务。所以这一屏上还改得动的每一格,过了这道门就要花一整个新实例才改得回来。', - ), - ), + part('定价要素', '成本定价 · 能算的归判定表,超限的归人') + + FOUR + + part('底价门禁分布', '直接放行 + 附理由 + 拦截 = 总条数') + + DIST + + `
${ico('info', 14)} + 三段条数由基座逐行现数,不是谁报上来的:红段一条不剩,这份草案才走得到放行门。 + 压在拦截档的条目只有三条出路 —— 回调到底价线、由负责人拍板破线并留理由、 + 或者本轮不报。 +
` + + part('报价草案', ' 条 · 每条都带来路') + + GAP_BAR + + editGate('quote_pricing.draft', '报价草案') + + DRAFT_TABLE + + `
${ico('info', 13)} + 每一行摆的是这个价的来路:成本基准从哪来、毛利档是哪一档、底价线在哪、 + 偏离多少、门禁怎么判。签字前请重点看两处:门禁列里非绿的条目, + 与备注列里的让价理由 —— 没有理由的让价,到了审批那一关同样过不去。 +
` + + part('放行后即执行的落地动作', '门前 → 门后') + + `
+
${ico('check', 11)} +
草稿层合并进正式版 本任务写下的报价草案与定价结论一并落账; + 你就地改过的那几个价随之落定。
+
+
${ico('check', 11)} +
草案此刻才对外发出 整份 条 + (每条带拟报单价与门禁处置)交出 —— 放行之前,世界上什么都没有发生。
+
+
${ico('check', 11)} +
转入定价审批 本活动只有一个出口 —— 报价草案是审批环节的直接输入, + 已进对方的任务清单。
+
+
`, + foot: sign('BD 定价 · AI 按判定表算价并留痕 —— 要改哪一格,在这张单的草案表上就地改', '统一报价 · A4 成本定价 → 交审批'), }); diff --git a/activities/quote-sourcing/__tests__/activity.test.ts b/activities/quote-sourcing/__tests__/activity.test.ts new file mode 100644 index 0000000..6c05f94 --- /dev/null +++ b/activities/quote-sourcing/__tests__/activity.test.ts @@ -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(' { + 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('')), `${id} 少了 manualActivationRule`).toContain( + ' { + for (const tag of [' { + 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 }).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(/'); + } + }); + + /* + * 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('
')).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(//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(`([\s\S]*?)<\/condition>/.exec(block.slice(0, block.indexOf('')))?.[1] ?? ''; + return raw.replaceAll('>', '>').replaceAll('<', '<').replaceAll('&', '&'); +} + +function safeEval(expression: string, scope: Record): boolean { + try { + return evaluateCondition(expression, scope); + } catch { + return false; + } +} diff --git a/activities/quote-sourcing/__tests__/steps.test.ts b/activities/quote-sourcing/__tests__/steps.test.ts new file mode 100644 index 0000000..0944609 --- /dev/null +++ b/activities/quote-sourcing/__tests__/steps.test.ts @@ -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 = { + rfqRef: 'RFQ MD14.1', + rows: [{ pn: 'BBPY00201209601Y00', type_tag: 'B', alt_pn: 'PBY201209T-601Y-N' }], + }; + const FRESH_INTAKE: Record = { + rfqRef: 'RFQ-2026-0555', + records: [{ pn: 'PESD2ETH-XR', type_tag: 'B' }], + }; + const FRESH_SOLO: Record = { 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) => ({ + 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 = 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): 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([]); + }); +}); diff --git a/activities/quote-sourcing/src/case-model.ts b/activities/quote-sourcing/src/case-model.ts index 631e008..1b391cb 100644 --- a/activities/quote-sourcing/src/case-model.ts +++ b/activities/quote-sourcing/src/case-model.ts @@ -1,132 +1,282 @@ /** - * A3 渠道寻源的案例模型 —— **一张执行单办完**(2026-07-31 用户定,与 A2 同一口径)。 + * A3 寻源询价的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。 * - * 上一版按四种询价场景(BOM 早期 / BOM 后期 / 现货 / 样品)把流程铺开成一串计划项。 - * 场景之分在业务上是真的,可它**不改变系统要管的事**:发函、收回价、比价、给结论。 - * 而这四步在一次寻源里是连着做的 —— 采购不会办完「发函」就交回来等系统叫他。 - * 把它切成四项,换来的只是 Agent 每一步都要跟 harness 对一次账。 + * 业务出处:`docs/MVP示例工作流.html` §1.7,按 **MVP 简化口径**建模 + * (2026-08-02 用户定:A3 与 A2 同为复杂活动,MVP 期间不展开大规模业务建模): * - * 现在只有两项: - * T_source —— 派一张执行单给采购(卡面两张可编辑表:供应商回价 / 寻源比价),他填完提交; - * T_release —— 放行(触达世界,人工激活)。 + * 上游(A2 的询价受理清单,或 A1 直达的 B 类受理记录)进来 → 归一成 + * **目标报价清单**底表,A 类行按库内历史价预填基准 → **一张执行单**让采购(PRM) + * 在系统外完成询价、把 B 类行的货源价格填回来 → 汇稿定盘 → 放行, + * 整份目标报价清单交给成本定价。 * - * 场景之分不进模型,进**说明书**:采购自己知道 BOM 早期该广撒网、现货该问库存。 - * 那是他的判断,不是系统的步。 + * SOP 里接需核史 / 选渠定策 / 发询比价那几段的实质工作(圈渠道、发函、催回价、 + * 验价)在 PRM 系统外;四种场景(BOM早期/后期/现货/样品)的尺度之分是他的判断, + * 不是系统的步。系统的本分:归一、预填、组卡、齐检、留痕。 + * + * 上一版(@2.0.0)只有「派单 + 放行」两项,入参必填 `pn + scenario`(单料形状, + * 与上游发的整份清单对不上),两条 L2 判据读活动自报数。这一版整个置换。 */ export const CMMN_XML = ` - - + + + + + + + + + - - - update - compare_total > 0 + + + + update + quote_line_total > 0 and quote_sourcing_quote_list_rows = quote_line_total and hist_priced_total >= 0 + + + + update + sourced_total >= 0 + + + + update + 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 - - - + + + - - - + + + + + + + + + + + + + + `; -export const NARRATIVE = `§0 开案:A1 判出一条料要寻源(无近期历史价、或行情波动),这一单就到你手上。 -成品只有一样:**一份能拿去定价的比价结论** —— 几家报了什么价、交期多久、推荐哪一家、为什么。 +/** + * 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本,两处不重复写同一件事)。 + */ +export const NARRATIVE = `§0 开案:**一份清单要补齐每一行的价格基准**。来路三种,形状都收(宽进): +· **选型转来**:任务数据里有 \`rows\` 数组 —— 替代选型放行的全部落地行混在一起 + (带 alt_pn / 现标的是清单行,带各种总数的是结论行,按 PN 归并自辨); +· **受理直达**:有 \`records\` 数组 —— 受理放行的落地行(受理记录行 + 分类表行 + 结论行); +· **单飞**:只有一段需求文字,列了几颗料要问价。 -**这一单只有一步**:把寻源这件事派给采购(执行工单),他填完你收进台账,然后提交放行。 -四种询价场景(BOM 早期 / BOM 后期 / 现货 / 样品)的打法差别是**采购的判断**, -不是系统要管的步 —— 你在 ask 里说清是哪种场景就够了,别替他把流程铺开。 +**这个活动是复杂活动的 MVP 简化形**:询价的实质工作(圈渠道、发函、催回价、验价) +由采购(PRM)在**系统外**完成;四种场景(BOM早期/后期/现货/样品)的尺度是他的判断。 +系统只负责:归一清单、**A 类行按库内历史价预填基准**、把执行单组好交给他、 +收回后齐检定盘。 -【T_source 派执行单】发 human 行动(workOrder="execution"),卡面 blocks 放**两张可编辑表**: -· 供应商回价:列 = 型号 / 供应商 / 回价 / MOQ / 交期 / 有效期; -· 寻源比价:列 = 型号 / 供应商 / 回价 / 结论。 -两张都 editable:true;没有的值给空字符串 —— 空格子界面会自己标红「待填写」。 -ask 说清要他做什么(哪颗料、什么场景、要几家、什么时候要); -basis 摆上型号、数量、目标价、客户交期要求(裸卡基座拒发)。 +**成品只有一样**:一份**目标报价清单** —— 整份行都在、**每一行都有基准价** +(A 类取历史价,B 类取 PRM 问回来的货源价),价源逐行可溯。它是成本定价的直接输入。 -他提交后,回传值出现在你的轨迹里:表格那一份的键是 **table:0 / table:1**(块序号), -值是整张表的 JSON —— **第一行是列名**,其余每行一条,列序与你派出去时一致。 -照它 write 进 \`quote_sourcing.supplier_quote\` 与 \`quote_sourcing.compare_table\`, -并把比价表条数记进 compare_total。**写完就 finish T_source,别再发第二张单** —— 他已经答过了。 +**这一单要经过人两次,不多不少**:PRM 在执行单上补货源价(那张单绑着整份清单, +必填空着按不下提交 —— 基座的闸看着,他改的格子也直接落账)、放行时 PRM 签字。 -【提交放行】比价表有行了 —— **先 start T_release**(人工激活:放行触达世界,必须按一下), -然后 submit,BD 验收。终局唯放行:你不能自己宣布完成。 +【一路上要说话】负责人正看着这一单。**每一拍都带一句 say** —— 说业务的话: +哪几颗料等着问价、历史价预填了几条、这单接下来到谁手上;不说系统词。 +到了要找人的那一拍,先说清这一轮办完了什么、为什么现在需要他。 -【一路上要说话】负责人正看着这一单。每一拍带一句 say,说**业务的话**: -哪颗料在寻源、找了几家、回价怎么样、这一单往哪走。 -一两句、口语。别报「写草稿 2 行」这种系统词。`; +【怎么把一项干完 · 一拍连做】write 和 tool 都不会自动收步 —— 做完用 finish 宣告。 +动作排进同一拍的 actions 串一口气给;只有调工具(出参下一拍才看得见)与 +发卡 / 提交(挂起)必须停拍。**这一单常路 6-8 拍就该收口**。 + +§Z 结案:PRM 在验收单上签字,系统把草稿合并成正式清单并发出 quotation 口的载荷 —— +整份目标报价清单交给成本定价。`; /** - * 步骤剧本 —— 与 A1/A2 同一套机制:`doneWhen` 为真,引擎自己收步、**不问模型**。 - * 放行那一项不给 `doneWhen` —— 它触达世界,必须人按一下(铁律③)。 + * 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。 */ export const SOURCING_STEPS = [ { - item: 'T_source', - intent: '派一张执行单给采购,收回供应商回价与比价结论', - writes: ['quote_sourcing.supplier_quote', 'quote_sourcing.compare_table', 'quote_sourcing.verdict'], - reads: [], + 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 记一句 + 「库内历史价 天」;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":"寻源补价:<单号> 待补价 颗", + "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: [], /* - * 比价表有行了才算办完。**不能写 `compare_total != null`** —— - * S-FEEL 里未绑定的名字 `!= null` 求值为 true,那一步开局即被收掉(A1 踩过)。 + * **收步判据与验收判据同源**:每行有价(基座按 required 现数)、价源守恒 + * (基座按值现数)、定盘记账 —— 三样齐才收,松出来的那一截就是返工。 */ - doneWhen: 'compare_total > 0', - guide: `**照这个骨架发,一个字段都别少**(prose 描述不管用,直接抄): -{"kind":"human","itemId":"T_source","workOrder":"execution","card":{ - "title":"…","ask":"这颗料要寻源,请找几家报价并给比价结论", - "basis":[{"k":"…","v":"…"}], - "blocks":[ - {"t":"table","title":"供应商回价","cols":["型号","供应商","回价","MOQ"],"editable":true, - "rows":[["NE555P","","",""],["NE555P","","",""]]}, - {"t":"table","title":"寻源比价","cols":["型号","供应商","回价","结论"],"editable":true, - "rows":[["NE555P","","",""],["NE555P","","",""]]} - ]}} -**一条料至少摆 2 行** —— 判据「BOM后期每料 2~3 家渠道」机检的就是渠道数, -只问一家过不了门(型号那列你先填,供应商与价格留给采购)。 -**rows 必须有行**:一条料一行,不知道的值给空字符串(界面自己标红「待填写」)。 -只给 cols 不给 rows,人看到的是一排表头加一片空白,一个格子都填不了(实测栽过)。 -**blocks 少了整张卡就废了** —— 基座会用当下局面兜底成一句「需要你判断下一步」, -人拿到一张没有表的空卡,这一步等于没派(实测栽过)。 + 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 补回来了,现在把清单**定盘**,一拍连做: -**rows 一条料一行**,型号那一列先填上;ask 说清什么场景、要几家、什么时候要; -basis 摆上型号、数量、目标价、交期要求(**裸卡基座拒发**)。 +① **价源归档**: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 这一项。引擎按「每行有价 + 价源守恒 + 定盘记过」收步,三样有一样不齐都收不掉。 -他提交后回传值进你的轨迹:表格的键是 **table:0 / table:1**,值是整张表的 JSON, -**第一行是列名**。照它 write 进两张台账,并把比价表条数记进 compare_total。 -**同一拍再写一行 \`quote_sourcing.verdict\`**(验收机检读的就是它): -· supplier_total = 询了几家渠道(判据要 ≥2,**只问一家过不了门**); -· quoted_total / unquoted_total = 已回价 / 未回价条数(未回价要 = 0); -· compare_total = 比价表条数;recommend_supplier 推荐哪家;summary 一句人话结论。 -这几个数写不对,submit 会被门挡回来 —— **它们不是台账装饰,是判据本身**。 -**写完就 finish,别再发第二张单** —— 他已经答过了。 -**每一行都要带 \`rfq_ref\`** —— 它是这一单的单标识(起单入参里就有,原样抄进去)。 -台账主键第一列就是它:不写,这一行会和**别的单**同料号的那一行撞主键,把人家覆盖掉 -(实测栽过:A4 的报价草案主键只有 pn,跑四轮只剩最后一轮的 3 行)。`, +这一单只有一个出口(交成本定价),**没有去向要选** —— 定盘就是把账写齐。`, }, { item: 'T_release', - intent: '比价结论已齐,提交放行交 BD 验收', + view: 'sourcing-roster', + intent: '清单已定盘,提交放行交 PRM 验收', writes: [], reads: [], tools: [], - guide: `比价表有行了 —— **先 start T_release**(它标了人工激活),然后 submit,BD 验收。 + /* 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 doneWhen) */ + guide: `每行有价、价源守恒、定盘已记 —— 可以走放行了(T_release 人工激活: +动它就算启动,不必单发 start)。先把裁量判据裁掉,然后 submit,同一拍连做: + +· judge criterion="货源与价格来源可核:B 类行的供应商与底价来自真实询价或可靠渠道,PRM 对报价负责" —— + 看清单里 SOURCED 行的供应商与备注:说得出来源就 satisfied=true; + 有行说不出的,satisfied=**false**,rationale 逐行写清,交签字的人定。 + **裁成不满足照样能提交** —— 理由连着卡一起交给他。 + criterion 照判据名**整句原样抄**,写别的名字对不上。 + 终局唯放行:你不能自己宣布完成。`, }, ]; + +/** + * 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段; + * 放行那一格由基座统一加,人看到的是 x/4。 + */ +export const SOURCING_PHASES = [ + { name: '归一预填', items: ['S1_normalize'] }, + { name: '补货源价', items: ['S2_fill'] }, + { name: '汇稿定盘', items: ['S3_compile'] }, +]; diff --git a/activities/quote-sourcing/src/index.ts b/activities/quote-sourcing/src/index.ts index 9250438..b8eaa82 100644 --- a/activities/quote-sourcing/src/index.ts +++ b/activities/quote-sourcing/src/index.ts @@ -1,58 +1,15 @@ /** - * 统一报价 A3 · 渠道寻源 —— 活动包代码入口(ADR-004)。 + * 统一报价 A3 · 寻源询价 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。 * - * **loop 活动**:下一步由 Agent 在案例模型的结构上临场决断,引擎只推进到 Enabled 为止。 - * 本文件只做视图流控 —— 把阶段、里程碑与比价进展摆给人看。 + * **这个活动没有 `run`,不是缺件。** + * + * 案例活动的推进由 Loop 驱动,行为全部写在清单里:案例模型 + 说明书 + 步骤剧本 + + * 两条技能 + 台账声明 + 一张 HTML 报告模版。上一版这里有一段 `run()` —— + * loop 活动根本不会调它;摆一个空转的 run 去假装有实现,更坏。 + * + * 这个文件之所以还存在,是因为清单的 `entry` 指着它 —— 它是**被静态审扫描的产物文本** + * (查 import 与全局引用,证明活动包零环境能力)。 */ import { defineActivity } from '@sdk'; -import type { ActivityBase, ActivityOutcome } from '@sdk'; -const QUOTES = 'quote_sourcing.supplier_quote'; -const COMPARE = 'quote_sourcing.compare_table'; - -export default defineActivity({ - name: 'quote-sourcing', - - async run(base: ActivityBase): Promise { - 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' }); diff --git a/activities/quote-sourcing/src/manifest.ts b/activities/quote-sourcing/src/manifest.ts index 581d0e5..da7a29b 100644 --- a/activities/quote-sourcing/src/manifest.ts +++ b/activities/quote-sourcing/src/manifest.ts @@ -1,185 +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, SOURCING_STEPS } from './case-model.ts'; +import { SOURCING_SKILLS } from './skills.ts'; +import { SOURCING_ROSTER, SOURCING_WORKSHEET } from './views/report.ts'; +import { CMMN_XML, NARRATIVE, SOURCING_PHASES, SOURCING_STEPS } from './case-model.ts'; -const DOMAIN = 'quote_sourcing'; +const D = 'quote_sourcing'; const ACTIVITY_ID = activityIdSchema.parse('quote-sourcing'); -/** 采用策略 —— 「能算的不问 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: '2.0.0', + 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: ['rfqRef', 'pn', 'scenario'], + required: [], properties: { - rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' }, - 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: [], }, /* - * 必交付物**必须给 `ledger` 地址**(2026-07-31 修)。 - * 不给的话预检退回 `scope[key]` 去找 —— key 是 `benchmark-table` 这样的短横线别名, - * 任务视图里永远没有这个键,于是「必交付物缺一不得提交放行」永远拒, - * 活办完了也放不了行,连拒五拍熔断(实测 A2 死于此)。 + * 必交付物只有一件:**目标报价清单**。寻源结论(verdict)是它的结论栏, + * `ledgers` 说清这份成果由哪几张表承载。 */ requiredDeliverables: [ - { key: 'compare-table', label: '寻源比价表', ledger: `${DOMAIN}.compare_table` }, + { + key: 'quotation-roster', + label: '目标报价清单(每行带基准价)', + ledger: `${D}.quote_list`, + /* 验收页上让 PRM 就地改的那几列 —— 恰是他的职权面(价格五列) */ + editableAtRelease: ['supplier', 'base_price', 'moq', 'source_lead_time', 'note'], + ledgers: [`${D}.quote_list`, `${D}.verdict`], + }, ], /* - * 机检谓词读的是 `verdict` 那张**汇总表**的列(照 A1 的口径)。 - * 上一版的名字(unquoted / channelsPicked)**台账里根本没有** —— S-FEEL 拿未绑定的名字 - * 比大小求值为假,判据永远达不了标,submit 门永远拒,连拒五拍熔断。 - * 判据的名字必须落在台账上,否则「机检」是一句空话(2026-07-31 销账)。 + * 验收判据 —— **机检(L1/L2)只读基座数得出来的数**: + * `_rows` 基座数行数、`_missing` 基座按 required 现数(与执行卡提交闸同一个数 + * 同一套算法)、`_price_basis_hist/_sourced` 基座按值现数。 + * 上一版两条 L2 读活动自报的 verdict 列(unquoted_total / supplier_total)—— + * 那不是门,是「改哪个数能过」的提示。 */ acceptanceCriteria: [ - { key: 'each-has-quote', label: '每料有有效报价或明确无货', level: 'L2', predicate: 'unquoted_total = 0' }, - { key: 'channels-enough', label: 'BOM后期每料 2~3 家渠道', level: 'L2', predicate: 'supplier_total >= 2' }, - { key: 'cross-check', label: '价格验证按场景规则做过', level: 'L3' }, - { key: 'prm-release', label: '采购放行', level: 'L5' }, + { + 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', + domain: D, + object: 'quote_list', kind: 'record', caseKey: 'rfq_ref', - label: '供应商回价单', + label: '目标报价清单', fields: [ { name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' }, - { name: 'pn', type: 'text', primaryKey: true, masked: false }, - { name: 'supplier', type: 'text', primaryKey: true, masked: false, label: '供应商' }, - { name: 'price', type: 'number', primaryKey: false, masked: false, label: '回价' }, - { 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: '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', - kind: 'record', - caseKey: 'rfq_ref', - label: '寻源比价表', - fields: [ - { name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' }, - { name: 'pn', type: 'text', primaryKey: true, masked: false }, - { name: 'supplier', type: 'text', primaryKey: false, masked: false }, - { name: 'price', type: 'number', primaryKey: false, masked: false }, - { name: 'conclusion', type: 'text', primaryKey: false, masked: false, label: '结论' }, - ], - }, - { - /* - * 寻源结论 —— 一单一行的**汇总表**,验收机检谓词的入参就在这儿。 - * 汇总列与逐行列**故意不同名**:任务视图把草稿行摊平到顶层是「后写的赢」, - * 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。 - */ - domain: DOMAIN, + /* 寻源结论 —— 一单一行:汇总数(哨兵与报告读)、一句人话结论 */ + domain: D, object: 'verdict', kind: 'record', caseKey: 'rfq_ref', label: '寻源结论', fields: [ { name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' }, - { name: 'pn', type: 'text', primaryKey: false, masked: false, label: '型号' }, - { name: 'supplier_total', type: 'integer', primaryKey: false, masked: false, label: '已询渠道数' }, - { name: 'quoted_total', type: 'integer', primaryKey: false, masked: false, label: '已回价条数' }, - { name: 'unquoted_total', type: 'integer', primaryKey: false, masked: false, label: '未回价条数' }, - { name: 'compare_total', type: 'integer', primaryKey: false, masked: false, label: '比价条数' }, - { name: 'recommend_supplier', type: 'text', primaryKey: false, masked: false, label: '推荐供应商' }, + { name: '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' }, @@ -188,6 +268,7 @@ export const manifest: ActivityManifest = { escalation: ['nudge', 'escalate'], }, { + /** 回价临过期:提醒复核确认 —— 时限必配 */ origin: 'static', eventType: 'time', keyFilter: { kind: 'quote-expiry' }, @@ -196,35 +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, - /* 步骤剧本 —— 与 A1 同一套:doneWhen 真了引擎自己收步,不问模型 */ + 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' }, diff --git a/activities/quote-sourcing/src/skills.ts b/activities/quote-sourcing/src/skills.ts index b1381d9..652df7b 100644 --- a/activities/quote-sourcing/src/skills.ts +++ b/activities/quote-sourcing/src/skills.ts @@ -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', }, ]; diff --git a/activities/quote-sourcing/src/views/prd-style.ts b/activities/quote-sourcing/src/views/prd-style.ts new file mode 100644 index 0000000..9d2c0b5 --- /dev/null +++ b/activities/quote-sourcing/src/views/prd-style.ts @@ -0,0 +1,409 @@ +/** + * A1 两份执行页的样式表 —— **从 PRD 目标件原样搬过来**,不是我照着复述的。 + * + * 出处:`~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理需求.html` 的整段 ` +${PRD_SPRITE} +
+
+
+
${ico(input.badge, 17)}
+
+ ${input.kicker} +

${input.title}

+
+ ${input.state} +
+
${input.body}
+
${input.foot}
+
+
`; + +const part = (label: string, aside = '', tone = ''): string => + `
${label}${aside === '' ? '' : `${aside}`}
`; + +const sign = (note: string, tag = ''): string => + `
+ ${ico('user', 15)} +
${note}
+
${tag === '' ? '' : `${ico('git-branch', 13)}${tag}`}`; + +/* ==================== 组成件 ==================== */ + +/** 要素四卡:客户 / 待补价量 / 一格一料的清单刻度 / 取价的尺 */ +const FOUR = `
+
+ ${ico('building')}客户公司 +
+ +
+
+ 承自上游;单飞起单时此栏可空 +
+ +
+ ${ico('boxes')}待补价 +
/ 行清单
+ 底价还空着的行 —— 本单要问价的对象;补一格少一格,基座现数 +
+ +
+ ${ico('history')}清单分类(承继) +
+ +
+ 一格一条料 · A 绿 B 蓝按上游现标着色;类别本单不改 +
+ +
+ ${ico('shield-check')}取价的尺 + ${ico('check', 13)}A 取历史价 · B 问货源价 + A 类按库内历史价预填;B 类由采购系统外询价补回 —— 价源逐行留徽,可溯 +
+
`; + +/** 缺价红带 —— 与提交闸、放行门 L1 同一个数同一套算法(基座现数);一格不缺自动收 */ +const GAP_BAR = `
${ico('alert', 14)} + 还差 行基准价没补 + —— 。 + 按右上角「编辑」开表,红框那几格就是要填的;问不到价的行按行首 × 撤掉并在备注写明。 + 补齐了执行单才提交得了。 +
`; + +const editGate = (address: string, what: string): string => `
+ ${ico('lock', 12)}${what}已锁定 —— 看的时候不误碰,要改按右边 + + + +
+
${ico('pen', 14)} + 底价空着的行是本单要补的:供应商 / 底价 / MOQ / 货源交期 / 备注逐格可填; + A 类预填的历史价也能改 —— 改了以你的为准。问不到价的行按行首 × 撤掉(剔出本轮)。 + 改动只落任务草稿层,保存不等于放行。 +
`; + +const addRowBar = (address: string): string => `
+ + 照末行的样子添一行空的;主键(型号 PN)填了才存得下,不填这一行不落库。 +
`; + +/** + * 清单主表 —— 一行一料:承继列 + 价格五列 + 类与价源两枚徽。 + * `data-col` 只挂人可改的五列(supplier / base_price / moq / source_lead_time / note)—— + * 编辑面精确等于 PRM 的职权面。底价格挂 `req`:空着出「必填」胶囊、整行标底, + * 与提交闸、放行门同源(都数 `_missing`)。 + */ +const ROSTER = `
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
类PN替代料 PN品类年用量目标价供应商底价MOQ货源交期价源备注
`; + +/** 价源分布:两段按条数分宽 —— 历史价 / 货源价,守恒等式的可视形 */ +const DIST = `
+
+ + +
+
+ 历史价 · 库内既有 + 货源价 · 本单问回 +
+
`; + +/* ==================== 那份件:目标报价清单(在办与归档同一张)==================== */ + +export const SOURCING_ROSTER = doc({ + badge: 'sheet', + kicker: '统一报价 · A3 寻源询价 · 交付成果', + title: '目标报价清单', + titleRef: 'rfq_ref', + state: + '清单 条 · 待补价 ', body: - statsBand([ - ['已收回价', 'mine:quote_sourcing.supplier_quote'], - ['比价表行数', 'mine:quote_sourcing.compare_table'], - ['当下可为', 'enabledCount'], - ['目标缺口', 'gapCount'], - ]) + - section('供应商回价(本单登记)', - tableOnly('mine:quote_sourcing.supplier_quote', [ - ['型号 PN', 'pn', 'mono'], - ['供应商', 'supplier'], - ['渠道等级', 'grade'], - ['回价', 'price', 'mono'], - ['交期', 'lead_time'], - ['有效期', 'valid_until', 'mono'], - ]) + - noteOnly( - '回价逐单登记、一一对应不可缺项:发出去几家就该收回几条,没回音的也要留一条写明' + - '「无货 / 未应答」—— 少一条,下游读到的就是「没这回事」。渠道等级是场景尺度落到纸面的地方:' + - 'BOM后期按主 → 备选 → 贸易商圈 2~3 家、贸易商尽量不用,现货只选有库存的。' + - '「有效期」是回价到期监听盯的那一列,过期即触发重询。表空着,是本单还没登记回价。', - ), - { text: '一料一渠一行' }, - ) + - section('寻源比价表(本单交付件)', - tableOnly('mine:quote_sourcing.compare_table', [ - ['型号 PN', 'pn', 'mono'], - ['供应商', 'supplier'], - ['回价', 'price', 'mono'], - ['结论', 'conclusion'], - ]) + - noteOnly( - '这是交回 PM 的成果本体:一料一结论,多渠道横比定底。不实回价或远低行情的必须换渠道补询,' + - '不得直接采用;现货场景不得选无库存转单商。采用策略由判定表出(直采 / 取均价 / 重询),' + - '不是模型自己说了算。表空着,是本单还没走到反馈交付。', - ), - { text: '交回 PM 的成果' }, - ) + - section('案例阶段(CMMN 生命周期)', - stagesOnly('case:stages') + - noteOnly( - '阶段的生命周期态由语义引擎推进到 Enabled 为止;凡触达世界或产出关键交付的计划项' + - '必须人工启动 —— Enabled ≠ Active。寻源是一事一议:几家供应商、谁先回、' + - '要不要追加一轮,事前排不出谱,所以调度权交给 AI,系统只保证边框:预算、停滞熔断、放行归人。', - ), - { text: '推进到 Enabled 为止' }, - ) + - section('当下可为(AI 的合法选项)', - stagesOnly('case:enabled') + - noteOnly( - 'AI 只能在这个集合里选下一步:可以越谱,不可越权。发询价信是触达世界的动作 ——' + - '标「待启动」的项要人按一下再动,信发出去就收不回来。这一栏空着不是它偷懒,' + - '是当下确实无可为:要么在等人回话,要么在等供应商的回音。', - ), - { text: '本拍的合法选项' }, - ) + - section('目标缺口(离交付还差什么)', - stagesOnly('case:gaps') + - noteOnly( - '缺口只有人知道 → 调人;缺口在世界侧(等供应商回价)→ 立监听,时限必配 ——' + - '没有时限的等待就是无限期挂起,而回价的时限本就写死在询价信里。' + - '当下可为 ∩ 目标所需 = 空,就是依赖断点。这一栏空着,是必做的事都办完了。', - ), - { text: '空 = 必做的都办完了' }, - ), + part('寻源要素', '寻源询价 · MVP 口径:询价在系统外,价格落表内') + + FOUR + + part('目标报价清单', ' 条 · 每行必有基准价') + + GAP_BAR + + editGate('quote_sourcing.quote_list', '目标报价清单') + + ROSTER + + addRowBar('quote_sourcing.quote_list') + + `
${ico('info', 13)} + 带 * 的底价是必填:每一行都要有基准价 —— A 类取库内历史价(系统预填,可校可改), + B 类由采购系统外询价补回。价源那一列逐行留徽:绿「历史价」是库内既有事实, + 蓝「货源价」是本单问回来的 —— 两段合起来恰等于总行数,对不上就是账没记齐。 + 问不到的价不许编:剔出本轮(行首 ×)或按内部估价填并在备注写明。 +
` + + part('价源分布', '历史价 + 货源价 = 总行数') + + DIST + + part('放行后即执行的落地动作', '门前 → 门后') + + `
+
${ico('check', 11)} +
草稿层合并进正式版 本任务写下的目标报价清单与寻源结论一并落账; + PRM 补价与就地修订的那几处随之落定。
+
+
${ico('check', 11)} +
清单此刻才对外发出 整份 行 + (每行带基准价与价源)交出 —— 放行之前,世界上什么都没有发生。
+
+
${ico('check', 11)} +
转入成本定价 本活动只有一个出口 —— 目标报价清单是定价环节的直接输入, + 已进对方的任务清单。
+
+
`, + foot: sign('PRM 询价 · AI 归一齐检 —— 要改哪一格,在这张单的清单表上就地改', '统一报价 · A3 寻源询价 → 交定价'), +}); + +/* ==================== 局面一 · 寻源询价工作单(归一预填与 PRM 补价期间)==================== */ + +/** + * 逐颗询价档案 —— 一颗料一张卡(data-rows 逐行复制)。 + * 形态按类别分档:B/C 摊开成「题面 + 定案回显」两栏;A 收成一行「已按历史价预填」。 + * + * **容器用 `
` 不用 `
`**:渲染器的行展开按第一个同名闭合标签截行内容, + * 行模版里嵌着 `
` 时 `
` 会把行截成套娃(A2 逮过,记在陷阱表)。 + */ +const INQ_CARDS = `
+
+
+ + + +
+
+
+
询价题面(要素与口径)
+
取价对象
+
品类
+
制造商
+
客户料号
+
年用量
+
目标价
+
交期要求
+
客户约束
+
+
+
问价定案(填回下表,这里回显)
+
供应商
+
底价
+
MOQ
+
货源交期
+
备注
+
+
+
已按库内历史价预填基准 · 可校可改;取价对象为空即原型号本身。
+
+
`; + +/** 按场景圈渠道 —— 素材:MVP 示例工作流 §1.7 选渠定策(只作文字参考,尺挂在墙上) */ +const CHANNEL_RULES = `
+
+ 早期 +

BOM 早期:渠道可放宽。系统带出的在供供应商直接圈入即可 —— 这一段要的是快, + 价格验证也宽(首个报价即用,品牌价差两成内算正常)。

+
+
+ 后期 +

BOM 后期:必须靠谱。按主供应商 → 备选 → 贸易商的顺序圈两三家,贸易商尽量不用; + 核实授权代理资质与授权期限,贸易商核查货源链可靠性。默认两轮:首轮回价内部核过再发次轮。

+
+
+ 现货 +

现货:只认有库存、可提供仓库实拍的供应商。无库存转单商不可选 —— + 转一手就是一手风险,货期与成色都不由你控。

+
+
+ 样品 +

样品:量小求快。渠道可放宽,低于线上商城价即可用;库内不足线上补。

+
+
`; + +/** 价格验证的尺 —— 素材:MVP 示例工作流 §1.7 发询比价(只作文字参考) */ +const VERIFY_RULES = `
+
+ 早期 +

首个报价即用;品牌之间价差两成以内算正常,不必追平。

+
+
+ 后期 +

按历史最低价降两成去谈;两轮核价,首轮回价内部核过再谈次轮。

+
+
+ 现货 +

多重校验:低于线上商城价、比过区域市场价、比过订货价(高出两成以内可接受)。

+
+
+ 样品 +

低于线上商城价即用 —— 样品要的是拿得到,不是省到底。

+
+
`; + +export const SOURCING_WORKSHEET = doc({ + badge: 'mail-open', + kicker: '统一报价 · A3 寻源询价 · 询价作业', + title: '寻源询价工作单', + titleRef: 'rfq_ref', + state: + '待补价 / 行', + body: + part('询价任务', '客户 · 待补价 颗 · A 类已预填 颗') + + `
${ico('info', 14)} + 下面每颗料一张询价档案卡:左边是题面(要素、取价对象与客户约束),右边回显你的定案。 + 蓝标 B 的料是本单要问价的对象;绿标 A 已按库内历史价预填基准,可校可改。 + 询价在系统外做(发函、催回价、比价都在你手上),定案落回本单 —— 供应商、底价、 + MOQ、货源交期逐颗可溯。发询纪律:同一料所有渠道一次发齐,PN / 数量 / 目标价 / 回价时限 + 写死在询价里;回价登记一一对应不可缺项;不实回价或远低行情的换渠道补询,别直接采用。 +
` + + part('逐颗询价档案', '题面在左 · 定案在右') + + INQ_CARDS + + part('按场景圈渠道', 'BOM早期 / BOM后期 / 现货 / 样品 —— 场景定尺度') + + CHANNEL_RULES + + part('价格验证的尺', '按场景核过再采') + + VERIFY_RULES + + `
${ico('info', 13)} + 时限参考:BOM早期 / 现货 1~4 日,样品 1~3 日,BOM后期 3~9 日 —— 急料电话催办; + 回价临过期先复核确认,过期触发重询。历史价未满三次询价但有效期已过的,标记须重询。 +
` + + part('填回工作表', '定案落在这 —— 补齐了执行单才提交得了') + + GAP_BAR + + editGate('quote_sourcing.quote_list', '寻源询价填回表') + + ROSTER + + addRowBar('quote_sourcing.quote_list') + + `
${ico('info', 13)} + 带 * 的底价是必填(未税单价按内部口径归一)—— 缺一行执行单按不下提交, + 与放行门是同一个数同一套算法。问不到的价不许编:剔出本轮(行首 ×) + 或按内部估价填并在备注写明。 +
`, + foot: sign('PRM 询价作业 · 定案落表,齐检定盘由系统接着办', '统一报价 · A3 寻源询价 · 在办'), }); diff --git a/api-report/packages/contracts/src/api/governance.d.ts b/api-report/packages/contracts/src/api/governance.d.ts index f473b24..a73ff9f 100644 --- a/api-report/packages/contracts/src/api/governance.d.ts +++ b/api-report/packages/contracts/src/api/governance.d.ts @@ -1953,6 +1953,10 @@ export declare const installActivityRoute: { tally: z.ZodOptional>; expect: z.ZodOptional; auto: z.ZodOptional; + requiredWhen: z.ZodOptional>; }, z.core.$strip>>; }, z.core.$strip>>>; listeners: z.ZodDefault; values: z.ZodOptional>; }, z.core.$strip>>; + finalHtml: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ mode: z.ZodLiteral<"acceptance">; deliverable: z.core.$ZodBranded; @@ -1164,6 +1165,7 @@ export declare const taskDetailSchema: z.ZodObject<{ }>; values: z.ZodOptional>; }, z.core.$strip>>; + finalHtml: z.ZodOptional; }, z.core.$strip>], "mode">>; chat: z.ZodArray; @@ -2157,6 +2159,7 @@ export declare const taskDetailRoute: { }>; values: z.ZodOptional>; }, z.core.$strip>>; + finalHtml: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ mode: z.ZodLiteral<"acceptance">; deliverable: z.core.$ZodBranded; @@ -2462,6 +2465,7 @@ export declare const taskDetailRoute: { }>; values: z.ZodOptional>; }, z.core.$strip>>; + finalHtml: z.ZodOptional; }, z.core.$strip>], "mode">>; chat: z.ZodArray; diff --git a/api-report/packages/contracts/src/card.d.ts b/api-report/packages/contracts/src/card.d.ts index f4e4f57..06541fa 100644 --- a/api-report/packages/contracts/src/card.d.ts +++ b/api-report/packages/contracts/src/card.d.ts @@ -874,6 +874,7 @@ export declare const executionWorkOrderSchema: z.ZodObject<{ }>; values: z.ZodOptional>; }, z.core.$strip>>; + finalHtml: z.ZodOptional; }, z.core.$strip>; export type ExecutionWorkOrder = z.infer; export declare const acceptanceCriterionCheckSchema: z.ZodObject<{ @@ -1207,6 +1208,7 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{ }>; values: z.ZodOptional>; }, z.core.$strip>>; + finalHtml: z.ZodOptional; }, z.core.$strip>; export type AcceptanceWorkOrder = z.infer; export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ @@ -1481,6 +1483,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ }>; values: z.ZodOptional>; }, z.core.$strip>>; + finalHtml: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ mode: z.ZodLiteral<"acceptance">; deliverable: z.core.$ZodBranded; @@ -1786,6 +1789,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ }>; values: z.ZodOptional>; }, z.core.$strip>>; + finalHtml: z.ZodOptional; }, z.core.$strip>], "mode">; export type WorkOrder = z.infer; /** AI 发言的类型标记 —— 对话区可分辨:结论 / 指导 / 判据 */ diff --git a/api-report/packages/contracts/src/ledger.d.ts b/api-report/packages/contracts/src/ledger.d.ts index d7ae052..863a8db 100644 --- a/api-report/packages/contracts/src/ledger.d.ts +++ b/api-report/packages/contracts/src/ledger.d.ts @@ -53,6 +53,10 @@ export declare const fieldDeclarationSchema: z.ZodObject<{ tally: z.ZodOptional>; expect: z.ZodOptional; auto: z.ZodOptional; + requiredWhen: z.ZodOptional>; }, z.core.$strip>; export type FieldDeclaration = z.infer; /** @@ -112,6 +116,10 @@ export declare const objectDeclarationSchema: z.ZodObject<{ tally: z.ZodOptional>; expect: z.ZodOptional; auto: z.ZodOptional; + requiredWhen: z.ZodOptional>; }, z.core.$strip>>; }, z.core.$strip>; export type ObjectDeclaration = z.infer; diff --git a/api-report/packages/contracts/src/manifest.d.ts b/api-report/packages/contracts/src/manifest.d.ts index 9b1e4ff..ea5f813 100644 --- a/api-report/packages/contracts/src/manifest.d.ts +++ b/api-report/packages/contracts/src/manifest.d.ts @@ -66,6 +66,10 @@ export declare const manifestDeclarationsSchema: z.ZodObject<{ tally: z.ZodOptional>; expect: z.ZodOptional; auto: z.ZodOptional; + requiredWhen: z.ZodOptional>; }, z.core.$strip>>; }, z.core.$strip>>>; listeners: z.ZodDefault>; expect: z.ZodOptional; auto: z.ZodOptional; + requiredWhen: z.ZodOptional>; }, z.core.$strip>>; }, z.core.$strip>>>; listeners: z.ZodDefault { }); describe('看不懂就转人,不猜着动手', () => { - it('行动型不认识 → 发执行卡,并把**模型原话**摆上去', () => { - const action = normalizeAction({ kind: '我自己想的行动' }, ENABLED, '{"kind":"我自己想的行动"}'); + it('行动型不认识 → 发执行卡,卡面只说人话 —— JSON 原话归工作日志,不上卡', () => { + const action = normalizeAction({ kind: '我自己想的行动' }, ENABLED); expect(action.kind).toBe('human'); if (action.kind !== 'human') return; - expect(action.card['reason']).toBe('模型未给出合规的行动'); - // 只写「不合规」的话,收卡的人分不清是模型跑偏、行动型少了一种、还是提示词没交代清楚 - expect(String(action.card['modelReply'])).toContain('我自己想的行动'); + // 2026-08-03 实测:原话上卡,人看到的是一坨代码(「卡片里又是乱七八糟的东西」) + expect(action.card['modelReply']).toBeUndefined(); + expect(String(action.card['reason'])).toContain('工作日志'); expect(action.card['kindSeen']).toBe('我自己想的行动'); }); @@ -98,44 +98,38 @@ describe('看不懂就转人,不猜着动手', () => { }); /** - * 补全卡的格子在哪儿 —— **两处都认**。 - * - * 提示词里执行工单的写法是 `card.fields`,模型很自然把补全卡也那么写(实测就是), - * 于是每一拍都被「fields 为空」拒回来。空不空由引擎验,这里只负责不让一个位置差异吃掉一整拍。 + * **补全卡已全局退役**(2026-08-02 用户定:「彻底全局退役补全卡,执行卡完全就是 + * 包含补全语义的人类工具」)—— 归一层把旧输出里的 `ask` 原地转成零输入执行工单: + * 问句上卡,字段名并进指导书(人补在工作区的件上),`fields` 不上卡。 + * 拒回只会让模型换着花样再发,归一才是把路铺平。 */ -describe('补全卡的格子', () => { - const fieldsOf = (raw: Record) => { - const a = normalizeAction({ kind: 'ask', itemId: 'T1', ...raw }, ENABLED); - return a.kind === 'ask' ? a.fields : []; - }; +describe('补全卡退役:ask 原地转成零输入执行工单', () => { + const norm = (raw: Record) => normalizeAction({ kind: 'ask', itemId: 'T1', ...raw }, ENABLED); - it('顶层 fields', () => { - expect(fieldsOf({ ask: '补一下', fields: [{ key: 'lead_time', label: '交期' }] })).toEqual([ - { key: 'lead_time', label: '交期' }, - ]); + 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; + expect(card['ask']).toBe('缺交期,补一下'); + expect(JSON.stringify(card['steps'])).toContain('交期'); + // 零输入:fields 不上卡 + expect(card['fields']).toBeUndefined(); }); - it('落在 card 里也认(实测模型这么写过)', () => { - expect(fieldsOf({ card: { ask: '补一下', fields: [{ key: 'eau', label: '年用量' }] } })).toEqual([ - { key: 'eau', label: '年用量' }, - ]); + 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)['steps'])).toContain('年用量'); }); - it('写成一串字段名也认 —— label 用 key 顶上,宁可显示字段名不显示空白', () => { - expect(fieldsOf({ ask: 'x', fields: ['eau', 'lead_time'] })).toEqual([ - { key: 'eau', label: 'eau' }, - { key: 'lead_time', label: 'lead_time' }, - ]); - }); - - it('没有 key 的格子丢掉 —— 没有 key 就没法回填', () => { - expect(fieldsOf({ ask: 'x', fields: [{ label: '只有标签' }, { key: 'ok', label: '行' }] })).toEqual([ - { key: 'ok', label: '行' }, - ]); - }); - - it('一个都收不到就是空数组,交引擎拒(不替它编格子)', () => { - expect(fieldsOf({ ask: 'x' })).toEqual([]); + it('连问句都没有:兜一个标题,不吃掉整拍', () => { + const a = norm({}); + expect(a.kind).toBe('human'); + if (a.kind !== 'human') return; + expect(String((a.card as Record)['title'])).not.toBe(''); }); }); @@ -209,10 +203,16 @@ describe('一拍一串行动的归一', () => { expect(turn.actions.map((a) => a.kind)).toEqual(['finish']); }); - it('整串一个都不认识 → 单张转人卡,模型原话摆上去', () => { - const turn = normalizeTurn({ actions: [{ kind: '胡编的' }] }, ENABLED, '{"kind":"胡编的"}'); + 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 个 —— 引擎侧还会再守一道', () => { diff --git a/apps/api/src/app/__tests__/agent-runner-retry.test.ts b/apps/api/src/app/__tests__/agent-runner-retry.test.ts new file mode 100644 index 0000000..1544e2a --- /dev/null +++ b/apps/api/src/app/__tests__/agent-runner-retry.test.ts @@ -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'); + }); +}); diff --git a/apps/api/src/app/__tests__/ledger-coerce.test.ts b/apps/api/src/app/__tests__/ledger-coerce.test.ts index 3e8e16d..9769430 100644 --- a/apps/api/src/app/__tests__/ledger-coerce.test.ts +++ b/apps/api/src/app/__tests__/ledger-coerce.test.ts @@ -9,7 +9,7 @@ * 所以归型放在写进草稿那一刻:归得了就归,归不了当场拒(理由喂回下一拍)。 */ import { describe, expect, it } from 'vitest'; -import { coerceToColumn, localStamp, missingReadings, rowReadings, rowKeyList, tallyReadings } from '../wiring/engines.ts'; +import { coerceToColumn, localStamp, missingReadings, rowReadings, rowKeyList, tallyReadings, toolCallReadings } from '../wiring/engines.ts'; describe('空值 = 这一列没有值(不写)', () => { it('空串 / 全空白 / null / undefined 一律不写 —— 「原文没写的不要补」的数据面落点', () => { @@ -342,3 +342,42 @@ describe('按值现数:域_对象_列_值', () => { ).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', + ]); + }); +}); diff --git a/apps/api/src/app/__tests__/view-table-edit.test.ts b/apps/api/src/app/__tests__/view-table-edit.test.ts index 67b91b5..1b99ded 100644 --- a/apps/api/src/app/__tests__/view-table-edit.test.ts +++ b/apps/api/src/app/__tests__/view-table-edit.test.ts @@ -19,7 +19,7 @@ * 抄一份的下场踩过 —— 生产改了、测没跟着改,测全绿而线上照旧。 */ import { describe, expect, it } from 'vitest'; -import { gateOf, rowChanged, writesOf } from '../routes/desk.ts'; +import { gateAddressesOf, gateOf, rowChanged, writesOf } from '../routes/desk.ts'; import { missingReadings } from '../wiring/engines.ts'; /** A1 受理记录的声明(裁到这一段用得着的几列):PN 与年用量必填 */ @@ -103,6 +103,100 @@ describe('提交闸:必填没填就交不了', () => { }); }); +/* + * **行级必填 `requiredWhen`:必填钉在该轮到的那一行**(2026-08-02 用户: + * 「工作区的 HTML 必填项必须填写了才能提交啊,不填写能算任务完成了吗?」)。 + * + * 逐颗按序派单(A2 的 sel_stage 队列)撞上表级 required 的死结:定案三格整列必填, + * 第一颗的卡会被第二颗(还没轮到、件上不可见)的空格拦死;整列不标,闸又没牙, + * 空手就能交卷(用户实测点名)。requiredWhen 只在行上指定列等于指定值时才算必填。 + */ +describe('行级必填:requiredWhen 只数该轮到的那一行', () => { + const LIST = { + domain: 'quote_alternative', + object: 'list', + fields: [ + { name: 'rfq_ref', type: 'text', primaryKey: true }, + { name: 'pn', type: 'text', primaryKey: true }, + { name: 'alt_pn', type: 'text', primaryKey: false, label: '替代料 PN', requiredWhen: { column: 'sel_stage', value: 'NOW' } }, + { name: 'alt_grade', type: 'text', primaryKey: false, label: '替代等级', requiredWhen: { column: 'sel_stage', value: 'NOW' } }, + ], + }; + const read = (rows: Array>) => + 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' }; diff --git a/apps/api/src/app/agent-runner.ts b/apps/api/src/app/agent-runner.ts index eb24343..691d1f1 100644 --- a/apps/api/src/app/agent-runner.ts +++ b/apps/api/src/app/agent-runner.ts @@ -54,6 +54,14 @@ const ACTION_ITEM_SCHEMA = { reason: { type: 'string' }, basis: { type: 'array' }, fields: { type: 'array' }, + /* + * 作业指导书两件(2026-08-03 补进 schema):`steps` 一行一条 + `standard` 收口标准。 + * 它们从前不在这份 schema 里,能到卡面纯靠厂商 JSON 模式宽松放行 —— + * 而本仓早有实测纪律:「结构化出参只吐 schema 里声明过的键,光在提示词里交代不算」 + * (ai/ledger 那两个键当年就是这么丢的)。指导书是执行卡的正文,不能靠运气。 + */ + steps: { type: 'array', items: { type: 'string' }, description: '作业指导书:一行一条,纯文本(不写星号记号)' }, + standard: { type: 'string', description: '收口标准:办到什么程度算完,一句话' }, /* * 卡面正文块 —— **人要在数据上作判断,数据就得摆在表单旁边**。 * @@ -216,6 +224,12 @@ export type AgentRunnerDeps = { readonly modelOf: (taskId: TaskId) => Promise; /** token 回报:计入任务预算 —— 不计,预算熔断就永远不会响 */ readonly onUsage: (taskId: TaskId, tokens: number) => Promise; + /** + * 留一条内部痕(进工作日志,不进对话区)。输出不成形就地重试时用它把 + * **模型原文**存档 —— 卡面不再摆 JSON 原话(人看着像乱码),原文归日志, + * 想尸检的时候有处可查。可选:不给就只重试不留痕。 + */ + readonly note?: (taskId: TaskId, title: string, raw: string) => Promise; }; /** 可写台账摊成字典:地址 · 中文标 · 主键 · 列名 —— 模型照这个填,不照想象编 */ @@ -395,7 +409,7 @@ export function createAgentRunner(deps: AgentRunnerDeps): { const prompt = [ '你是这一单任务的案例工作者。下面是语义引擎发布的**当下可为**与**目标缺口**。', '这一拍你给**一串行动**(actions 数组,1-6 个,按序执行):write(写台账草稿)/', - 'tool(调工具)/ ask(补全卡)/ decide(决策卡)/ human(调人)/ listen(立监听),外加 start(启动须手动启动的项)、', + 'tool(调工具)/ decide(决策卡)/ human(调人)/ listen(立监听),外加 start(启动一项,可省)、', 'plan(规划裁量项)、disable(此单不走这条)、judge(评估语义性前提)、finish(宣告一项完成)、submit(提交放行)。', /* * **写错了有退路**(2026-08-01 加 drop)。没有它的时候实测过一次死胡同: @@ -443,42 +457,42 @@ export function createAgentRunner(deps: AgentRunnerDeps): { ' 挂不上(项已完成等)写也照落 —— 写的授权在台账声明,不在计划项。', ' tool / ask 的 `itemId` 则要落在**当下可为**里;阶段(stage)只能 start。', /* - * **话说的范围要盖住门拦的范围**(2026-08-02 实测逼出来的)。 + * **「须你启动」不再是一堵墙**(2026-08-02 拆掉)。 * - * 从前这一句写的是「标了「须你启动」的项要先 start **再调工具**」—— - * 只说了调工具。而拦截那一侧(`readyToWork`)管的是 tool / ask / human / - * decide / entry / finish 一整片。模型那一拍要发的是 `human`(派工单), - * 照字面读这条规矩管不着它,于是漏了 start,撞上 - * 「S3_confirm 标了人工激活,要先 start 再干活」白烧一拍。 - * - * **门拦住的事和话说的事必须是同一件** —— 话窄一格,模型就正好漏在那一格上。 - * 例外只有 write:它的 itemId 只是记账,挂不上照写不拦(见 turn-runner 的注释)。 + * 从前标了人工激活的项要求先发显式 `start`,不发就拒;还为「话没盖住门」 + * 修过一轮措辞(话窄一格,模型正好漏在那一格上)。措辞修全之后实测仍熔断: + * A1 一单连拒五拍,五次全是「S3_confirm 要先 start」—— 提示词与拒回都写着, + * 它就是学不稳。那道墙也保护不了任何东西:start 是模型自己的动作, + * 同一串连发即过。现在**动它就算启动**(引擎就地过 Active), + * 「须你启动」只剩它本来的意思:引擎不替你启动、也不给 doneWhen 替人收。 */ - ' 标了「须你启动」的项,**动它之前先 start** —— 调工具、发卡(ask / human / decide / entry)、', - ' 宣告完成(finish)都算,一拍里 start 排在那个动作前面即可。', - ' 只有 write 例外:它的 itemId 只是记账,挂不上也照写。', + ' 标了「须你启动」的项,你**直接动它就算启动**(不必单发 start);', + ' 它的分量在另一头:引擎不会替你启动它,也不会自动收它 —— 收它要你自己 finish。', '② **系统能干的活不要交给人**:调工具就在「可调工具」里挑,照它的入参键名填 ——', ' 把一件工具做得到的事发成一张卡问人,是白占人的时间(实测踩过:该调历史价工具,', ' 却发了一张「请查这三个 PN 的历史价」的卡)。触达世界的动作也只能 tool', ' (写类会被登记为动作意向,放行那一刻才真执行)。', - '③ 调人(human)必须把卡填实:title 一句话说清要什么、ask 写明具体问题、', - ' basis 给出判断依据(键值对数组)、fields 列出要对方回填的字段名。', - ' 只写「请处理」的卡收卡人无从下手,等于白发一张。**人答回的值在轨迹里,', - ' 看到了要用 write 落进台账** —— 系统不会替你写。', - '④ **缺字段要人补 → ask(补全卡)**:缺口清单摆在对话区、带一张表单,人就地填齐。', - ' **能填的先替他填上**:格子给 `value` 预填(公司名、联系人姓名这类来件里写着的),', - ' 人核对一眼比从头敲快得多;你填不出来的(如内部客户编码)留空,别硬凑。', /* - * 形状里**必须带 `value`** —— 这一条实测栽过: - * 活动剧本让模型"能填的先替人填上",还给了带 value 的 JSON 骨架; - * 模型把骨架里的 ask 一字不差抄了过来,`value` 却全是空的 —— - * 因为**系统提示词这一行写死的形状里没有它**,活动剧本盖不过系统提示词。 - * 形状是模型真正照抄的那一份,漏一个键就等于那个键不存在。 + * ③ 的措辞是一次实测教训(2026-08-02):零输入改造时这里曾写成 + * 「执行工单不摆表单……**别再 write 一遍**」—— 一句全局禁令,把 A1 说明书里 + * 「人交回确认卡后要写 confirmed_total 记账」顶翻了:模型收到人的答复后 + * 不写不收,又觉得这卡没答成,重发同一张卡连拒五拍熔断(用户实测送来的单)。 + * **全局提示词只说通用真理**:值怎么处理(哪些基座已替写、哪些要记账) + * 是各活动说明书的事,别在这儿下一刀切的禁令。 */ - ' 形状:{kind:"ask", itemId, ask:"一句话说清缺口", fields:[{key,label,hint?,value?}], basis:[{k,v}]}。', - ' `value` = 预填值:来件里读得出的先替人填上(公司名、联系人姓名),读不出的留空。', + '③ 调人(human)必须把卡填实:title 一句话说清要什么、ask 写明具体问题、', + ' basis 给出判断依据(键值对数组);执行工单不摆表单字段(填报在工作区的件上)。', + ' 只写「请处理」的卡收卡人无从下手,等于白发一张。**人答回的东西在轨迹里,', + ' 看到了要照说明书处理(该记账的记账)** —— 别当没答过再发一张。', + /* + * **补全卡(ask)已全局退役**(2026-08-02 用户定:「彻底全局退役补全卡, + * 执行卡完全就是包含补全语义的人类工具」)—— 缺信息也是发执行工单: + * 指导书写清缺什么,人在工作区的件上补(保存即落草稿层)。 + * 旧输出里的 ask 会被基座原地转成执行工单,但别再用它。 + */ + '④ **缺信息要人补 → human(执行工单)**:指导书写清缺哪几样、去哪儿补(工作区的件上),', + ' 人补完保存、回卡提交 —— 值由基座写回草稿层,你别再 write 一遍;该记账的记账。', '④·2 **要往台账写一行、而那一行该由人过目 → entry(数据录入卡)**:', - ' 与补全卡的分别在**谁写库**:补全卡收回来的值要你自己再 write 一遍;', ' 数据录入卡是**人按下确认那一刻基座直接写**,你不经手 —— 建档这类事该用它,', ' 主数据建错一次后面每单都跟着错,那一笔该由人亲手落下。', ' 形状:{kind:"entry", itemId, ask, ledger:"域.对象", entryKey:{主键列:值},', @@ -489,8 +503,8 @@ export function createAgentRunner(deps: AgentRunnerDeps): { ' 机检判不了,只能你当场裁:{kind:"judge", criterion:"照判据名原样写", satisfied:true/false, rationale:"凭什么"}。', ' **提交放行前必须裁完** —— 除「人放行」外还有判据没达标时,submit 会被当场拒。', '④\' **岔路口要人定 → decide(决策卡)**:把几条路摆出来,人点一个 —— 不用敲字。', - ' 补全卡问**事实**(缺哪几个字段,你填);决策卡问**处置**(往哪走,你选)。', - ' 两条路都合法、选哪条是业务判断时,发这一张;别拿补全卡去问「你想怎么办」。', + ' 执行工单问**事实与作业**(办完、补齐);决策卡问**处置**(往哪走,你选)。', + ' 两条路都合法、选哪条是业务判断时,发这一张;别拿执行工单去问「你想怎么办」。', ' 形状:{kind:"decide", itemId, ask, options:[{value,label,consequence?,recommended?}], basis:[{k,v}]}。', ' `fields` 一个都不给会被拒 —— 空表单等于没问;一个缺口一个格子,label 写人话。', ' 不要拿执行工单(human)去问几个字段 —— 那是「派一件活」,收卡的人得进待处理、', @@ -564,27 +578,44 @@ export function createAgentRunner(deps: AgentRunnerDeps): { const model = await deps.modelOf(taskId); try { - const result = await deps.gateway.generate({ - model, - prompt, - outputSchema: TURN_SCHEMA, - // 规划型出参要的是稳,不是花样 —— 低温少绕弯,解码就是墙上时钟 - temperature: 0.2, - /* - * 关思考 —— 三档都实测过(A1 同一单): - * 由着想:拍 9-56s,最慢拍 9.8k 字隐藏思考,八成五的时间在解码没人读的字; - * 'low':这家厂商上没用 —— 推理仍 5-9.7k 字、拍 24-45s,和不压一样; - * 'off':拍 2-4s(十倍差)。 - * off 暴露过一次业务误判(把缺目标价当关键字段拦单)—— 治法是把规则写死进 - * 说明书(「关键字段只有 PN/EAU/交期 三个」),不是给模型留思考额度: - * 这个架构里判断本就归判定表与说明书,模型只做编排。 - */ - reasoning: 'off', - taskId, - label: 'loop-turn', - }); - await deps.onUsage(taskId, result.usage.inputTokens + result.usage.outputTokens); - return normalizeTurn(result.object, enabled, result.text); + /* + * **输出不成形先就地重试,别一抖就叫人**(2026-08-03 用户实测送来的单)。 + * + * 关思考的模型偶发把这一拍的输出写坏(多个闭括号那种已由括号栈自修; + * 这次是另一形状)。从前一次没认出就直接发「需要你判断下一步」的兜底卡, + * 还把模型的 JSON 原话整段摆在卡面上 —— 人打开工单看到的是一坨代码 + * (用户原话:「卡片里又是乱七八糟的东西」)。 + * 现在:第一次没认出 → 原文存进工作日志(内部痕,留档好尸检), + * 附一句「只输出 JSON」就地再问一次;第二次仍不成形才交人, + * 而卡面只说人话(原文在日志里,不上卡)。 + */ + let hint = ''; + for (let attempt = 1; ; attempt += 1) { + const result = await deps.gateway.generate({ + model, + prompt: hint === '' ? prompt : `${prompt}\n\n${hint}`, + outputSchema: TURN_SCHEMA, + // 规划型出参要的是稳,不是花样 —— 低温少绕弯,解码就是墙上时钟 + temperature: 0.2, + /* + * 关思考 —— 三档都实测过(A1 同一单): + * 由着想:拍 9-56s,最慢拍 9.8k 字隐藏思考,八成五的时间在解码没人读的字; + * 'low':这家厂商上没用 —— 推理仍 5-9.7k 字、拍 24-45s,和不压一样; + * 'off':拍 2-4s(十倍差)。 + * off 暴露过一次业务误判(把缺目标价当关键字段拦单)—— 治法是把规则写死进 + * 说明书(「关键字段只有 PN/EAU/交期 三个」),不是给模型留思考额度: + * 这个架构里判断本就归判定表与说明书,模型只做编排。 + */ + reasoning: 'off', + taskId, + label: 'loop-turn', + }); + await deps.onUsage(taskId, result.usage.inputTokens + result.usage.outputTokens); + const turn = normalizeTurn(result.object, enabled); + if (!turn.unrecognized || attempt >= 2) return turn; + await deps.note?.(taskId, '这一拍的输出没成形(不是合法的行动串),就地重问一次', (result.text ?? '').slice(0, 2000)); + hint = '【重要】你上一次的输出不是合法的行动 JSON,整拍作废了。只输出一个 JSON 对象,形如 {"say":"…","actions":[{"kind":"…",…}]},不要输出任何 JSON 之外的文字。'; + } } catch (error) { /* * 模型这一轮问不出结果(端点不可达、鉴权失败、超时…)。 @@ -631,13 +662,14 @@ const KNOWN_KINDS = new Set([ * * 三种来路都认:`{actions:[…]}`(规范形)、裸行动对象(旧形 —— 包成单元素串)、 * 顶层就是数组。串里**不认识的项丢掉**(认识不认识由 KNOWN_KINDS 定); - * 整串一个都不认识才退化为单张「转人裁断」卡(把模型原话摆上去)—— + * 整串一个都不认识才标 `unrecognized` 退化为单张「转人裁断」卡 —— * 不能让串里一个写坏的项在批中途给人甩一张卡。 + * 调用方(decide)看到 `unrecognized` 先就地重问一次,原文进工作日志;卡面不摆 JSON。 * * 交代**不是一型行动**:一开始把它做成 `kind: 'say'`,结果它占一整拍又不推进局面, * 预算下的模型理所当然跳过它 —— 对话区照旧空白(实测)。所以它是拍级的附带字段。 */ -export function normalizeTurn(object: unknown, enabled: EnabledSet, rawText?: string): AgentTurn { +export function normalizeTurn(object: unknown, enabled: EnabledSet): AgentTurn & { unrecognized?: true } { const raw = (Array.isArray(object) ? { actions: object } : (object ?? {})) as Record; const text = String(raw['say'] ?? raw['text'] ?? '').trim(); const tag = raw['tag']; @@ -649,11 +681,12 @@ export function normalizeTurn(object: unknown, enabled: EnabledSet, rawText?: st .filter((a) => KNOWN_KINDS.has(String(a['kind'] ?? a['action'] ?? ''))); const actions = recognized.length > 0 - ? recognized.slice(0, TURN_ACTION_CAP).map((a) => normalizeAction(a, enabled, rawText)) - : [normalizeAction(source[0] ?? {}, enabled, rawText)]; + ? recognized.slice(0, TURN_ACTION_CAP).map((a) => normalizeAction(a, enabled)) + : [normalizeAction(source[0] ?? {}, enabled)]; return { actions, + ...(recognized.length === 0 ? { unrecognized: true as const } : {}), ...(text === '' ? {} : { @@ -842,7 +875,7 @@ function normalizeRows(raw: unknown): Array<{ key: Record; /* * `kind` 也认 `action` —— 实测里模型把这一格叫 `action`(其余字段一字不差全对), @@ -892,26 +925,41 @@ export function normalizeAction(object: unknown, enabled: EnabledSet, rawText?: }; case 'ask': { /* - * 格子与问题**两处都认**:`fields` / `ask` 在顶层,或落在 `card` 里。 - * 提示词里执行工单的写法是 `card.fields`,模型很自然把补全卡也那么写 —— - * 实测它就是这么写的,于是每一拍都被「fields 为空」拒回来。 - * 宽进不是纵容:空不空引擎照验,这里只是不让一个位置差异吃掉一整拍。 + * **补全卡已全局退役**(2026-08-02 用户定:「彻底全局退役补全卡, + * 执行卡完全就是包含补全语义的人类工具」)—— 「问人补信息」不再是 + * 一种单独的卡:发一张零输入的执行工单,指导书写清缺什么、 + * 人在工作区的件上补(保存即落草稿层)或办完确认。 + * + * 这里做的是**归一而不是拒**:旧说明书、旧模型输出里的 `ask` 照收, + * 原地转成执行工单 —— 拒回只会让模型换着花样再发一遍。 + * `fields` 不上卡(执行卡零输入),但字段名并进指导书, + * 人知道要补的是哪几样、补在件上。 */ const nested = (typeof raw['card'] === 'object' && raw['card'] !== null ? raw['card'] : {}) as Record; + const asked = String(raw['ask'] ?? nested['ask'] ?? '').trim(); + const wanted = normalizeFields(raw['fields'] ?? nested['fields']) + .map((f) => ((f.label ?? '') === '' ? f.key : (f.label ?? ''))) + .filter((x) => x !== ''); + const basis = Array.isArray(raw['basis']) + ? raw['basis'] + .filter((b): b is { k: unknown; v: unknown } => typeof b === 'object' && b !== null && 'k' in b) + .map((b) => ({ k: String(b.k), v: String(b.v) })) + : []; return { - kind: 'ask', + kind: 'human', itemId, - ask: String(raw['ask'] ?? nested['ask'] ?? ''), - fields: normalizeFields(raw['fields'] ?? nested['fields']), - ...(Array.isArray(raw['basis']) - ? { - basis: raw['basis'] - .filter((b): b is { k: unknown; v: unknown } => typeof b === 'object' && b !== null && 'k' in b) - .map((b) => ({ k: String(b.k), v: String(b.v) })), - } - : {}), + workOrder: 'execution', + card: { + title: asked === '' ? '请补齐这一单缺的信息' : asked.slice(0, 60), + ask: asked === '' ? '缺的信息见下面的依据:补在工作区的件上,补完回这张卡提交。' : asked, + ...(wanted.length === 0 + ? {} + : { steps: [`要补的是:${wanted.join('、')} —— 在工作区的件上就地填,保存后回这张卡提交;确实拿不到的,备注里写明缘由`] }), + basis, + }, }; } + case 'entry': { /* * 数据录入卡 —— 与补全卡同款的宽进(顶层或 `card` 里都认)。 @@ -1021,19 +1069,19 @@ export function normalizeAction(object: unknown, enabled: EnabledSet, rawText?: /* * 看不懂就等人,不擅自触达世界。 * - * 但**要把模型的原话摆在卡上**:只写「模型未给出合规的行动」,收卡的人无从判断 - * 是模型跑偏了、还是行动型少了一种、还是提示词没交代清楚 —— 而这三种的处置完全不同。 - * 界面不撒谎的另一半是:不确定的时候,把手上的原始证据交出去。 + * 卡面**只说人话,不摆模型的 JSON 原话**(2026-08-03 用户实测:原话上卡, + * 人打开工单看到的是一坨代码 ——「卡片里又是乱七八糟的东西」)。 + * 原始证据仍然要留:decide 那一侧在重问之前已把原文存进工作日志(内部痕), + * 想分辨是模型跑偏、行动型少了一种、还是提示词没交代清楚,去日志里看。 */ return { kind: 'human', itemId, card: { title: '需要你判断下一步', - reason: '模型未给出合规的行动', - modelReply: rawText === undefined || rawText === '' ? '(空)' : rawText.slice(0, 1200), + reason: '模型这一拍没给出合规的行动,就地重问一次仍未成形;它的原话在这一单的工作日志里,不摆到卡上', kindSeen: kind === '' ? '(未给 kind)' : kind, - ask: '请判断下一步:接着走哪一项,或补齐缺口后让环继续。', + ask: '请判断下一步:接着走哪一项,或直接办完当前这一步让环继续。', fields: ['decision'], }, workOrder: 'execution', diff --git a/apps/api/src/app/routes/desk.ts b/apps/api/src/app/routes/desk.ts index bce753d..54ae576 100644 --- a/apps/api/src/app/routes/desk.ts +++ b/apps/api/src/app/routes/desk.ts @@ -497,8 +497,11 @@ async function applyBoundTableEdits( * (都走 `missingReadings`)。同源是要紧的:两处各算各的,就会出现 * 「卡上说能交、放行门说不行」——那种矛盾人是判不出对错的。 * - * 只对**绑了台账表**的执行卡出闸:算得出才拦,算不出(卡上没绑表)就不给闸, - * 按钮照常能按 —— **没有依据的时候不拦人**。 + * 看哪几张表:卡上绑了表就看绑的那几张;**卡上没绑表就回落到必交付物的台账声明** + * (`requiredDeliverables` 的 `ledgers`/`ledger`)—— 执行卡零输入之后(2026-08-02 + * 用户定:「填写全去工作区」)卡面不再绑 blocks,闸若只认卡上的表就整个失效, + * 缺着必填照样交得回来,一路走到放行门才被 L1 拦住(那正是当初立这道闸要治的病)。 + * 两条路都算不出(连交付物都没声明台账)才不给闸 —— **没有依据的时候不拦人**。 */ async function submitGate( container: Container, @@ -506,13 +509,13 @@ async function submitGate( order: WorkOrder, ): Promise<{ ready: boolean; why: string; blanks: string[] } | undefined> { if (order.mode !== 'execution' || order.done !== undefined) return undefined; - const addresses = (order.blocks ?? []) - .filter((b): b is Extract => b.t === 'table' && typeof b.ledger === 'string') - .map((b) => b.ledger as string); + const task = await container.task.get(taskId).catch(() => undefined); + if (task === undefined) return undefined; + const manifest = await container.registry.manifestOf(task.activityId).catch(() => undefined); + if (manifest === undefined) return undefined; + const addresses = gateAddressesOf(order.blocks, manifest.contract.requiredDeliverables); if (addresses.length === 0) return undefined; - const declarations = await declarationsOf(container, taskId); - if (declarations === undefined) return undefined; - const watched = declarations.filter((l) => addresses.includes(`${l.domain}.${l.object}`)); + const watched = manifest.declarations.ledger.filter((l) => addresses.includes(`${l.domain}.${l.object}`)); if (watched.length === 0) return undefined; // 本单写下的行 —— 与任务视图同一约定(`mine:域.对象`) @@ -523,6 +526,23 @@ async function submitGate( return gateOf(missingReadings(mine, watched), watched); } +/** + * 闸看哪几张表:卡上绑了表就看绑的那几张;没绑(执行卡零输入之后是常态) + * 回落到**必交付物的台账声明**(`ledgers` 全表,老清单退 `ledger` 单张)。 + * 两边都空才真的没有依据。抽成纯函数是为了钉测 —— 回落断了闸就整个失效, + * 而失效的闸在页面上看不出来(按钮照常绿着)。 + */ +export function gateAddressesOf( + blocks: ReadonlyArray | undefined, + deliverables: ReadonlyArray<{ ledger?: string | undefined; ledgers?: ReadonlyArray | undefined }>, +): string[] { + const bound = (blocks ?? []) + .filter((b): b is Extract => b.t === 'table' && typeof b.ledger === 'string') + .map((b) => b.ledger as string); + if (bound.length > 0) return bound; + return deliverables.flatMap((d) => [...(d.ledgers ?? (d.ledger === undefined ? [] : [d.ledger]))]); +} + /** * 读数 → 闸。**数与名单都来自基座的读数**,这里只负责说人话。 * @@ -913,6 +933,22 @@ export function registerDeskRoutes(app: Hono, container: Container): voi } } const outcome = await container.humanTool.replyWorkOrder(parsed.data, staff.id); + /* + * **办结那一刻,把当时的件原样存进这张工单**(2026-08-02 用户:「HTML UI、 + * 数据、内容、布局都要 1:1 地从待处理转换为静态报告放到工作成果」)。 + * 成果页渲的是这份快照 —— 不再渲「当下」的任务视图(随后续推进会变), + * 更不退回卡壳。快照失败不拦办结(留痕即可,人已交卷)。 + */ + try { + const shot = await container.interaction.viewOf(outcome.taskId); + if (shot?.html !== undefined) { + await container.db.execute( + sql`update sys.work_order set payload = jsonb_set(payload, '{finalHtml}', ${JSON.stringify(shot.html)}::jsonb) where id = ${parsed.data.workOrderId}`, + ); + } + } catch { + /* 件还没推过就没有快照 —— 成果页会退回旧路,不拦人交卷 */ + } /* * **绑台账的可编辑表:人改了什么,基座当场写回草稿层**(2026-08-01)。 * diff --git a/apps/api/src/app/wiring/engines.ts b/apps/api/src/app/wiring/engines.ts index 829f7c4..b93e8a4 100644 --- a/apps/api/src/app/wiring/engines.ts +++ b/apps/api/src/app/wiring/engines.ts @@ -246,6 +246,7 @@ export function missingReadings( primaryKey: boolean; required?: boolean | undefined; expect?: boolean | undefined; + requiredWhen?: { column: string; value: string } | undefined; }>; }>, ): Record { @@ -260,17 +261,23 @@ export function missingReadings( * · `required` → `_missing` 缺必填几行(**不齐不许放行**,是验收标准); * · `expect` → `_gaps` 缺选填几行(不拦单,但**那一问不能省**)。 * 缺口摆的是**中文列名**:这两个数会进拒回与卡面,给人看的地方不摆列名。 + * + * `requiredWhen` 把必填钉在**该轮到的那一行**(2026-08-02,逐颗派单的闸): + * 行上那一列不等于指定值,这一格就还没轮到,不算缺 —— 排队的颗不拦当前的卡。 */ for (const [mark, suffix] of [ ['required', 'missing'], ['expect', 'gaps'], ] as const) { - const watched = fields.filter((f) => f[mark] === true); + const watched = fields.filter((f) => f[mark] === true || (mark === 'required' && f.requiredWhen !== undefined)); if (watched.length === 0) continue; const bad: string[] = []; for (const row of rows) { const one = (typeof row === 'object' && row !== null ? row : {}) as Record; - const gaps = watched.filter((f) => blank(one, f.name)); + const due = watched.filter( + (f) => f.requiredWhen === undefined || String(one[f.requiredWhen.column] ?? '') === f.requiredWhen.value, + ); + const gaps = due.filter((f) => blank(one, f.name)); if (gaps.length === 0) continue; const id: Record = {}; for (const k of keys) if (!blank(one, k)) id[k] = String(one[k]); @@ -323,6 +330,40 @@ export function tallyReadings( return out; } +/** + * **这一单到底调没调那个工具** —— `{ 'tool_crm_history_price_read_calls': N }`。 + * + * 「机检只读基座数」这条纪律此前只覆盖到台账(`_rows` / `_missing` / tally), + * **覆盖不到工具**:一条判据若要说「转 A 的依据来自本单亲查的行情」, + * 从前只能读活动自报的列 —— 而模型偶发压根没调工具就把料判成「行情稳」, + * 自报的那一列照样写得漂亮(A2 实测约十次一次,去向就此走岔)。 + * 说明书已经写到头(「这一拍亲自查 alt_pn」「不许拿上游的数顶替」), + * **措辞治不了的结构治**(设计律第六条)。 + * + * 工具出参本来就入痕(`turn-runner` 每次调完记一条 `kind:'result'`、 + * 标题 `${tool} 返回`),所以「调没调」是**基座数得出来的事实**,不必让活动自报。 + * + * 只数**成功返回**的那几次:调失败等于没查到行情,不该拿来当依据。 + * 名字取 `tool_<工具名去符号>_calls`(S-FEEL 不认点号与横杠); + * 声明过的工具**一律先铺 0** —— 与 `_rows` 同一个理由:未绑定的名字参与比较求值为假, + * 「没调过」与「调了 0 次」在业务上是同一件事,读数上也得是。 + */ +export function toolCallReadings( + trail: ReadonlyArray<{ readonly kind: string; readonly title: string }>, + declared: ReadonlyArray<{ readonly name: string }>, +): Record { + const key = (tool: string): string => `tool_${tool.replace(/[^0-9a-zA-Z]+/g, '_')}_calls`; + const out: Record = {}; + for (const { name } of declared) out[key(name)] = 0; + for (const entry of trail) { + if (entry.kind !== 'result') continue; + for (const { name } of declared) { + if (entry.title.startsWith(`${name} 返回`)) out[key(name)] = (out[key(name)] ?? 0) + 1; + } + } + return out; +} + export function rowKeyList( mine: Readonly>, declared: ReadonlyArray<{ domain: string; object: string; fields: ReadonlyArray<{ name: string; primaryKey: boolean }> }>, @@ -752,8 +793,19 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { * 而判据要写成 `quote_intake_record_rows = line_total` 这样一句才有用。 * 只数**本单自己写下的行**(草稿层),别单放行过的记录不算在内。 */ - const declared = (await deps.registry.manifestOf(task.activityId)).declarations.ledger; - return { ...scope, ...rowReadings(mine, declared), ...rowKeyList(mine, declared), ...missingReadings(mine, declared), ...tallyReadings(mine, declared), ...mine }; + const manifest = await deps.registry.manifestOf(task.activityId); + const declared = manifest.declarations.ledger; + // 工具调用是**留痕里的事实**:判据要说「亲查过行情」,得有一边不是活动自报的 + const trail = await deps.memory.window(taskId, 400); + return { + ...scope, + ...rowReadings(mine, declared), + ...rowKeyList(mine, declared), + ...missingReadings(mine, declared), + ...tallyReadings(mine, declared), + ...toolCallReadings(trail, manifest.declarations.tools), + ...mine, + }; }, /** * 本活动声明过的台账对象 —— 清单 `declarations.ledger` 就是这份名单。 @@ -1447,7 +1499,13 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { await cardSpan?.end({ output: { cardId: submitted.workOrderId, precheck: submitted.report.verified.length } }); return { id: submitted.workOrderId }; } - const fields = declaredFields ?? [{ key: 'result', label: '结果', kind: 'textarea' as const, required: true }]; + /* + * **执行卡不带表单字段**(2026-08-02 用户定:「填写全去工作区」)—— + * 填报的地方是工作区那份件(editGate + 孪生件 + 增删行),卡上只剩 + * 指导书、依据与提交钮。从前这里兜底一格必填「结果」textarea: + * 活动没声明字段就替它造一个,人对着一张没处下笔的卡还得编一句话才按得下提交。 + */ + const fields = declaredFields ?? []; /* * 卡面正文块 —— **人要在数据上作判断,数据就得摆在表单旁边**。 * @@ -1859,7 +1917,24 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { }, }, }) - : createAgentRunner({ gateway: tracedGateway, onUsage: onAgentUsage, modelOf }); + : createAgentRunner({ + gateway: tracedGateway, + onUsage: onAgentUsage, + modelOf, + /* + * 输出不成形就地重问时,把模型原文存进工作日志(内部痕)—— + * 卡面从此不摆 JSON 原话,尸检去日志里看。stamp 与 memoryRecorder 同款兜底。 + */ + note: async (taskId, title, raw) => { + await deps.memory.append({ + taskId, + kind: 'result', + title, + detail: { internal: true, raw }, + stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] }, + }); + }, + }); /* ── loop-engine ──────────────────────────────────────────────── */ const loop = createTurnRunner({ diff --git a/apps/api/src/modules/activity-registry/__tests__/acceptance-predicate.test.ts b/apps/api/src/modules/activity-registry/__tests__/acceptance-predicate.test.ts index 46c7007..dd4cd1b 100644 --- a/apps/api/src/modules/activity-registry/__tests__/acceptance-predicate.test.ts +++ b/apps/api/src/modules/activity-registry/__tests__/acceptance-predicate.test.ts @@ -269,3 +269,29 @@ describe('行数读数认得出,写错的照旧拒装', () => { expect(codes(manifest({ predicate: 'd_recrod_rows = line_total' }))).toContain('acceptance-predicate-unbound'); }); }); + +/** + * **工具调用读数也算绑得上**(2026-08-03)—— + * 基座按留痕数「这一单成功调了几次某工具」(`tool_<工具名去符号>_calls`), + * 声明过的工具一律先铺 0。判据的两边不能都由活动自报:A2 实测约十次一次, + * 判稳那一步压根没调行情工具就把料判成「稳」转 A,自报的依据列却写得漂亮。 + */ +describe('工具调用读数绑得上', () => { + const withTool = (predicate: string) => + acceptanceLevelFindings({ + contract: { + acceptanceCriteria: [{ key: 'k', label: 'l', level: 'L2', predicate }], + input: { data: { type: 'object', properties: {} } }, + }, + declarations: { ledger: [], tools: [{ name: 'crm.history-price-read' }] }, + assets: { businessModel: { cmmnXml: '' } }, + } as never); + + it('声明过的工具 → `tool_..._calls` 绑得上', () => { + expect(withTool('tool_crm_history_price_read_calls > 0')).toEqual([]); + }); + + it('没声明的工具照旧拒装 —— 声明面就是授权面,写错一个字当场拦下', () => { + expect(withTool('tool_erp_write_calls > 0').length).toBeGreaterThan(0); + }); +}); diff --git a/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts b/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts index 5438d82..1551bca 100644 --- a/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts +++ b/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts @@ -37,7 +37,9 @@ export type AcceptanceAuditInput = { readonly contract: Pick & { readonly input: Pick; }; - readonly declarations: Pick; + // tools 也要:判据能读「这一单调了几次某工具」(`tool_..._calls`,基座按留痕数) + readonly declarations: Pick & + Partial>; readonly assets: { readonly businessModel: Pick; }; @@ -290,6 +292,19 @@ function boundNames(manifest: AcceptanceAuditInput): ReadonlySet { } } } + /* + * **工具调用读数** `tool_<工具名去符号>_calls`(2026-08-03 起)—— + * 基座按留痕数「这一单成功调了几次某工具」,声明过的工具一律先铺 0。 + * + * 它存在的理由与 `_rows` 一模一样:**判据的两边不能都由活动自报**。 + * A2 实测约十次一次 —— 判稳那一步压根没调行情工具就把料判成「稳」转 A, + * 而自报的依据列写得漂漂亮亮,去向就此从寻源走成定价。 + * 有了这个名字,「转 A 的依据来自本单亲查的行情」才是一条真机检。 + */ + // 旧调用方(与老测试)只传 ledger —— 缺 tools 就当没声明工具,别掀翻整份审计 + for (const tool of manifest.declarations.tools ?? []) { + out.add(`tool_${tool.name.replace(/[^0-9a-zA-Z]+/g, '_')}_calls`); + } for (const id of manifest.assets.businessModel.cmmnXml.match(/caseFileItem\s+id="([^"]+)"/g) ?? []) { const m = /id="([^"]+)"/.exec(id); if (m?.[1] !== undefined) out.add(m[1]); diff --git a/apps/api/src/modules/human-tool/__tests__/card-rules.test.ts b/apps/api/src/modules/human-tool/__tests__/card-rules.test.ts index 17adcac..6d82ca9 100644 --- a/apps/api/src/modules/human-tool/__tests__/card-rules.test.ts +++ b/apps/api/src/modules/human-tool/__tests__/card-rules.test.ts @@ -68,8 +68,9 @@ describe('工单形态机检', () => { it('规范的执行工单通过', () => { expect(checkWorkOrder(execution())).toEqual([]); }); - it('执行工单 = 指导书 + 空表单,表单不得为空', () => { - expect(checkWorkOrder(execution({ fields: [] })).map((v) => v.code)).toContain('fields-missing'); + it('**执行工单空表单是常态** —— 填写全在工作区的件上,零字段照发(2026-08-02 用户定)', () => { + // 从前这儿拒发空表单;通道换到件上之后,那道拒发让活动照新规矩发卡被基座连拒到熔断(实测) + expect(checkWorkOrder(execution({ fields: [] }))).toEqual([]); }); it('radio 字段缺选项即拒', () => { const v = checkWorkOrder(execution({ fields: [{ key: 'x', label: 'X', kind: 'radio' }] })); diff --git a/apps/api/src/modules/human-tool/internal/domain/card-rules.ts b/apps/api/src/modules/human-tool/internal/domain/card-rules.ts index e98eb82..c14c452 100644 --- a/apps/api/src/modules/human-tool/internal/domain/card-rules.ts +++ b/apps/api/src/modules/human-tool/internal/domain/card-rules.ts @@ -43,9 +43,14 @@ export function checkWorkOrder(order: WorkOrder): CardViolation[] { function checkExecution(order: ExecutionWorkOrder): CardViolation[] { const out: CardViolation[] = []; if (order.ask.trim() === '') out.push({ code: 'ask-missing', message: '执行工单必须给出工作要求' }); - if (order.fields.length === 0) { - out.push({ code: 'fields-missing', message: '执行工单 = 指导书 + 空表单,表单不得为空' }); - } + /* + * **执行卡零输入**(2026-08-02 用户定:「执行卡片不要有任何填写的了,填写全去 + * 工作区」)—— 空表单是常态:填报在工作区的件上(editGate + 孪生件), + * 卡上只剩指导书、依据与提交钮。从前这里拒发空表单(「执行工单 = 指导书 + + * 空表单,表单不得为空」),那一版把表单当交付通道;通道换到件上之后, + * 这道拒发就成了活动照新规矩发卡、基座按旧规矩连拒到熔断(实测 A1 五拍)。 + * 带字段的卡(如熔断卡的 decision)照旧合法 —— 只是不再强制。 + */ out.push(...checkFieldKeys(order.fields)); return out; } diff --git a/apps/api/src/modules/human-tool/internal/usecase/human-tool-service.ts b/apps/api/src/modules/human-tool/internal/usecase/human-tool-service.ts index 1787258..8b8d4e0 100644 --- a/apps/api/src/modules/human-tool/internal/usecase/human-tool-service.ts +++ b/apps/api/src/modules/human-tool/internal/usecase/human-tool-service.ts @@ -199,18 +199,48 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer const values = reply.mode === 'acceptance' ? reply.values : reply.values; await deps.cards.markWorkOrderDone(reply.workOrderId, { at, by, result, values }); + /* + * 出口:**算出来的优先,而且在服务端算数**(2026-08-02 修)。 + * + * 契约给每个出口写了 `when`,放行那一刻这一口就已经由基座按序求值定下了 + * (`routedPort`),人回传里带不带都不作数 —— 去向是台账那几个数的函数, + * 不是一道让人点的选择题。算不出来的组才退回 `selectedPorts`。 + * + * 这段话从前只对**返回值**成立:抛回任务的人事件载荷是原始 reply, + * 而放行落地读的恰是事件载荷 —— 于是「routedPort 顶上」只发生在没人看的那一份里, + * 浏览器靠自己把算出的口塞进回传才活着(chat-bubbles 那段注释), + * 换个客户端(脚本、API 调用)就死在「选择组未选出口」(A2 e2e 实测)。 + * **服务端说了算的事,不靠每个客户端自觉** —— 事件与返回值现在用同一份有效口。 + */ + const routed = (found.order as AcceptanceWorkOrder).routedPort; + const effectivePorts: Readonly> = + reply.mode !== 'acceptance' ? {} : routed !== undefined ? { [routed.group]: routed.name } : reply.selectedPorts; + // 回传即留痕:谁、何时、答了什么、依据什么,随卡入任务记忆 + /* + * **空答复要说成人话**(2026-08-02 用户实测送来的熔断单):执行卡零输入之后, + * 人「看过、确认、提交」的回传 `values` 就是空的 —— 痕上一个值都没有, + * 在模型眼里像「没答成」,于是把同一张卡再发一遍(连发五拍熔断)。 + * 空不是没答:**是人照原样确认了**(件上若改过,那几笔已由保存那一刻落进草稿层)。 + */ + const blank = reply.mode === 'execution' && Object.keys(values).length === 0; await deps.memory.append({ taskId: found.taskId, kind: reply.mode === 'acceptance' ? 'release-verdict' : 'card-reply', title: reply.mode === 'acceptance' ? `裁决:${reply.verdict}` : found.order.title, - detail: { by, values, mode: reply.mode, ...(reply.mode === 'acceptance' ? { comment: reply.comment } : {}) }, + detail: { + by, + values, + mode: reply.mode, + ...(blank ? { note: '人已办完并交回(卡上无表单,改动若有已在件上保存时落进草稿层)—— 接着办说明书里「人交回之后」的事' } : {}), + ...(reply.mode === 'acceptance' ? { comment: reply.comment } : {}), + }, }); - // 回传经统一入口以人事件抛回任务 + // 回传经统一入口以人事件抛回任务 —— 载荷带的是**有效口**,不是人碰巧发来的那份 await deps.events.publishHumanEvent({ taskId: found.taskId, cardId: reply.workOrderId, - payload: { ...reply }, + payload: { ...reply, ...(reply.mode === 'acceptance' ? { selectedPorts: effectivePorts } : {}) }, }); const updated = await deps.cards.getWorkOrder(reply.workOrderId); @@ -223,19 +253,7 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer ...(reply.mode === 'acceptance' && reply.verdict !== 'pass' ? { rejection: { kind: reply.verdict, comment: reply.comment ?? '' } } : {}), - /* - * 出口:**算出来的优先**。 - * - * 契约给每个出口写了 `when`,放行那一刻这一口就已经由基座按序求值定下了 - * (`routedPort`),人回传里带不带都不作数 —— 去向是台账那几个数的函数, - * 不是一道让人点的选择题。算不出来的组才退回 `selectedPorts`。 - */ - selectedPorts: - reply.mode !== 'acceptance' - ? {} - : (order as AcceptanceWorkOrder).routedPort !== undefined - ? { [(order as AcceptanceWorkOrder).routedPort!.group]: (order as AcceptanceWorkOrder).routedPort!.name } - : reply.selectedPorts, + selectedPorts: effectivePorts, }; }, diff --git a/apps/api/src/modules/interaction/internal/domain/view-render.ts b/apps/api/src/modules/interaction/internal/domain/view-render.ts index d17cc9d..eb219c5 100644 --- a/apps/api/src/modules/interaction/internal/domain/view-render.ts +++ b/apps/api/src/modules/interaction/internal/domain/view-render.ts @@ -165,13 +165,21 @@ export function renderRows(html: string, scope: Readonly const body = rows .map((row, index) => { const one = (typeof row === 'object' && row !== null ? row : { value: row }) as Record; - return renderTemplate(editable ? twinInputs(inner, index) : inner, one); + return renderTemplate(uniqueNames(editable ? twinInputs(inner, index) : inner, index), one); }) .join(''); return `${open}${body}`; }); } +/** + * **行内表单组名唯一化** —— 行模版里手写的 radio 组(如 A2 替代等级三选)带着 + * 固定 `name`;行复制 N 份后同名 radio 在整份文档里成了一组,第 2 页点一下 + * 会把第 1 页已选的顶掉。每行的 name 缀上行号,组内互斥只在本行内发生。 + */ +const uniqueNames = (inner: string, rowIndex: number): string => + inner.replace(/\bname\s*=\s*"([^"]+)"/g, (_m, n: string) => `name="${n}__r${rowIndex}"`); + /** 带 `data-col` 的取值元素:`` */ const EDITABLE_CELL = /<(span|b)\b([^>]*?\bdata-col\s*=\s*"([^"]+)"[^>]*)><\/\1>/g; diff --git a/apps/api/src/modules/llm/__tests__/parse-json-loose.test.ts b/apps/api/src/modules/llm/__tests__/parse-json-loose.test.ts new file mode 100644 index 0000000..83402f5 --- /dev/null +++ b/apps/api/src/modules/llm/__tests__/parse-json-loose.test.ts @@ -0,0 +1,44 @@ +/** + * 结构化出参的提取 —— 两次实测栽跟头,各钉一条: + * + * ① 模型一次吐两个对象(把下一拍也写了):按第一个配平的取,后面当噪声。 + * ② **关思考的模型偶发多吐一个闭括号**(2026-08-02,A2 案例 0 实测): + * `…"type_tag":"A"}}]}},{"kind":…` —— 该是 `}}]},`。从前只数花括号, + * 多出的 `}` 把计数带到零,切出的半截连 `[` 都没关上,解析失败; + * 整拍六个动作全对,却退化成一张「需要你判断下一步」的兜底卡。 + * 括号栈知道当下该关谁:对不上的闭括号当噪声丢(不发明内容,只丢一个多余字符)。 + */ +import { describe, expect, it } from 'vitest'; +import { parseJsonLoose } from '../internal/adapter/ai-sdk-provider.ts'; + +describe('parseJsonLoose:模型出参的宽容提取', () => { + it('干净 JSON 原样过', () => { + expect(parseJsonLoose('{"a":1}')).toEqual({ a: 1 }); + }); + + it('```json 围栏剥掉', () => { + expect(parseJsonLoose('```json\n{"a":1}\n```')).toEqual({ a: 1 }); + }); + + it('一次吐两个对象:取第一个配平的,后面当噪声', () => { + expect(parseJsonLoose('{"a":1}\n\n{"b":2}')).toEqual({ a: 1 }); + }); + + it('**多吐一个闭括号:当噪声丢,整拍不作废**(实测形状)', () => { + const slip = '{"say":"归一好了","actions":[{"kind":"write","rows":[{"patch":{"type_tag":"A"}}]}},{"kind":"finish","itemId":"S1"}]}'; + const got = parseJsonLoose(slip) as { actions: Array<{ kind: string }> }; + expect(got.actions.map((a) => a.kind)).toEqual(['write', 'finish']); + }); + + it('值里带括号的字符串不把栈带偏', () => { + expect(parseJsonLoose('{"a":"x}y]z","b":2}')).toEqual({ a: 'x}y]z', b: 2 }); + }); + + it('转义引号照认', () => { + expect(parseJsonLoose('{"a":"he said \\"}\\" ok"}')).toEqual({ a: 'he said "}" ok' }); + }); + + it('实在不是 JSON:undefined,不抛', () => { + expect(parseJsonLoose('这不是 JSON')).toBeUndefined(); + }); +}); diff --git a/apps/api/src/modules/llm/internal/adapter/ai-sdk-provider.ts b/apps/api/src/modules/llm/internal/adapter/ai-sdk-provider.ts index 55a299d..17d2b58 100644 --- a/apps/api/src/modules/llm/internal/adapter/ai-sdk-provider.ts +++ b/apps/api/src/modules/llm/internal/adapter/ai-sdk-provider.ts @@ -108,38 +108,55 @@ export function parseJsonLoose(text: string): unknown { * 发一张兜底卡把人叫来。实测栽过:模型给的 9 颗料判据完全正确、 * 分类器都要调了,只因为它多写了一个对象,整拍白跑、人被无谓地打扰了一次。 * - * 现在按括号配平扫第一个完整对象,后面多出来的当噪声丢掉 —— - * 多写的那一份本就是下一拍的事,这一拍不该替它执行。 - * 扫的时候要认字符串与转义,否则值里带 `}` 的会把括号计数带偏。 + * 扫法是**真正的括号栈**,不是单数花括号(2026-08-02 又栽一次才改的): + * 关思考的模型偶发多吐一个闭括号(`…}}]}},{…` 该是 `…}}]},{…`), + * 只数花括号的计数被它带到零,切出的半截连 `[` 都没关上 —— 解析失败, + * 整拍(六个动作全对)退化成一张「需要你判断下一步」的兜底卡。 + * 栈知道当下该关的是哪个括号:**对不上的闭括号当噪声丢掉**(自修这类手滑; + * 丢的只是一个多余字符,不发明任何内容),栈清空即取到第一个完整值, + * 后面多出来的照旧当噪声 —— 多写的那一份本就是下一拍的事。 + * 字符串与转义照认,否则值里带 `}` 的会把栈带偏。 */ function firstBalanced(text: string): unknown { const start = text.search(/[[{]/); if (start < 0) return undefined; - const open = text[start]; - const close = open === '{' ? '}' : ']'; - let depth = 0; + const stack: string[] = []; + const kept: string[] = []; let inString = false; let escaped = false; for (let i = start; i < text.length; i += 1) { - const ch = text[i]; + const ch = text[i] as string; if (inString) { + kept.push(ch); if (escaped) escaped = false; else if (ch === '\\') escaped = true; else if (ch === '"') inString = false; continue; } - if (ch === '"') inString = true; - else if (ch === open) depth += 1; - else if (ch === close) { - depth -= 1; - if (depth === 0) { + if (ch === '"') { + inString = true; + kept.push(ch); + continue; + } + if (ch === '{' || ch === '[') { + stack.push(ch === '{' ? '}' : ']'); + kept.push(ch); + continue; + } + if (ch === '}' || ch === ']') { + if (stack[stack.length - 1] !== ch) continue; // 对不上的闭括号 = 噪声,丢 + stack.pop(); + kept.push(ch); + if (stack.length === 0) { try { - return JSON.parse(text.slice(start, i + 1)); + return JSON.parse(kept.join('')); } catch { return undefined; } } + continue; } + kept.push(ch); } return undefined; } diff --git a/apps/api/src/modules/loop-engine/CONTRACT.md b/apps/api/src/modules/loop-engine/CONTRACT.md index eee29f9..b4aa00c 100644 --- a/apps/api/src/modules/loop-engine/CONTRACT.md +++ b/apps/api/src/modules/loop-engine/CONTRACT.md @@ -50,11 +50,17 @@ - 地址与列名的把关在装配层(它认识活动清单)—— 未声明的地址与列一律拒写, 越域写与越权调工具同罪;拒了抛错,由回合收成「行动被拒」喂回下一拍,引擎不崩; - **写的授权面是台账声明,不是计划项**(2026-07-30):`itemId` 只是记账, - 挂得上就挂(项活性化),挂不上(已完成 / 人工激活未 start / 阶段 / 不存在)照写不拦, + 挂得上就挂(项活性化),挂不上(已完成 / 阶段 / 不存在)照写不拦, 留痕注明「未挂到计划项」。从前绑死在项生命周期上,死锁过一单:S5 收完才发现打标 没写全,所有能挂的项都 Completed —— 每拍补写都被拒,连拒五拍熔断,而数据本身合规; - - 写完把该项收了(未 Active 的就地过一下 Active)。**这不是绕开「Enabled ≠ Active」**: - 那条纪律管的是触达世界的项,而草稿写触达不到任何东西 —— 为它单烧一次模型去按那一下是纯仪式。 + - 写完把该项收了(未 Active 的就地过一下 Active)。 + - **人工激活的项:模型显式动它 = 启动它**(2026-08-02 拆墙,`readyToWork`)。 + 从前它要求先发显式 `start`,不发就拒;提示词与拒回都写全之后实测仍熔断 + (A1 一单连拒五拍,五次全是「S3_confirm 要先 start」)—— 那道墙保护不了任何东西: + start 是**模型自己的**动作,同一串连发即过,拦下只是逼它多演一步学不稳的礼仪。 + 人工激活真正的分量原样保留:**引擎不因哨兵满足替它启动**(哨兵只推到 + Available/Enabled),**也不给 doneWhen 替人收**(自动收步跳过人工激活项)—— + 收它的只能是模型自己的 finish。`start` 仍是合法行动,只是可省。 - **里程碑前提达成即达成**(2026-07-30,`achieveMilestones`):CMMN 的里程碑没有执行体, 也没有谁去按它。从前只有 `onEvent(ref = 里程碑 id)` 会 occur,而系统里没有任何东西 发这种事件 —— 于是里程碑永远 `Available`:既是必做项(堵死 autoComplete), diff --git a/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts b/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts index c89b81b..0c2bcae 100644 --- a/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts +++ b/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts @@ -6,7 +6,7 @@ */ import { describe, expect, it } from 'vitest'; import type { TaskId } from '@contracts'; -import { createTurnRunner, handsOff, sameGist, stalledTurns } from '../internal/usecase/turn-runner.ts'; +import { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns } from '../internal/usecase/turn-runner.ts'; import type { TurnRunnerDeps } from '../internal/usecase/turn-runner.ts'; import type { PendingAcceptance } from '../port.ts'; import type { CaseModel } from '../internal/domain/cmmn-parse.ts'; @@ -521,12 +521,14 @@ describe('干活前先站上场', () => { * 实测的死锁:模型收完 S5 才发现打标结果没写全,此时所有能挂的项都 Completed, * 唯一没完成的只剩标人工激活的 T_release —— 每拍补写都被「要先 start」拒回来, * 连拒五拍熔断交人,而那几行数据本身合法合规。 + * 2026-08-02 拆墙后,人工激活的项也**挂得上**了(动它就算启动): + * 写照落不变,记账从「挂不上、留痕注明」变成「挂上、点亮进行中」—— 更诚实。 */ - it('标了人工激活的项:写照落、不挂项、项不动 —— 挂项是记账,不是授权', async () => { + it('标了人工激活的项:写照落、挂上点亮 —— 授权面仍是台账声明', async () => { const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: writeTo('GATED') }); const outcome = await h.runner.runTurn(TASK); expect(h.written).toHaveLength(1); - expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Available'); + expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Active'); expect(outcome.status).toBe('running'); }); @@ -629,11 +631,18 @@ describe('宣告完成', () => { expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Completed'); }); - it('标了人工激活的项仍要先 start —— 那条纪律管的是触达世界与关键交付', async () => { + /* + * **人工激活的项:动它就算启动**(2026-08-02 拆墙)。 + * 从前这儿钉的是「要先 start,不发就拒」;实测那道墙保护不了任何东西 + * (start 是模型自己的动作,同一串连发即过),却多出一种死法:A1 一单 + * 连拒五拍熔断,五次全是「S3_confirm 要先 start」,提示词与拒回都写着, + * 模型就是学不稳。人工激活真正的分量在引擎那一侧:不替它启动、不替人收 —— + * 那两条各有各的测钉着(哨兵不自动激活 / 自动收步跳过人工激活项)。 + */ + it('标了人工激活的项:模型显式动它 = 启动它,就地过 Active 再收', async () => { const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: finish('GATED') }); const outcome = await h.runner.runTurn(TASK); - expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Available'); - expect(outcome.status).toBe('running'); // 拒了不崩,理由喂回下一拍 + expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Completed'); }); it('编出来的项收不了', async () => { @@ -784,7 +793,7 @@ describe('说话按轮不按拍:只有交出控制权的那一拍才进对话区 }); it('轮到人了才出气泡:问一句 / 派工单 / 摆岔路 / 等外部 / 提交放行', () => { - for (const kind of ['ask', 'human', 'decide', 'listen', 'submit']) { + for (const kind of ['human', 'decide', 'listen', 'submit']) { expect(handsOff([{ kind }]), kind).toBe(true); } }); @@ -878,13 +887,11 @@ describe('画面不再静默丢帧', () => { }); /** - * 补全卡 —— 对话卡族,与执行工单是两件事。 - * - * SOP 上 A1-S2「按缺口清单追问补全」画的就是它。从前 Agent **发不出对话卡**: - * 行动型里只有 `human` 且只能选执行 / 验收两种工单,于是那一步只能拿执行工单顶 —— - * 形似而神非:派一张工单去问几个字段,收卡的人得进待处理、办完再回来,重得不成比例。 + * **补全卡已全局退役**(2026-08-02 用户定)—— 引擎侧这个行动型不再发卡: + * 归一层(agent-runner)会把 `ask` 原地转成零输入执行工单,所以正常到不了这里; + * 别的调用方直发就如实拒并指对路(human + execution)。 */ -describe('补全卡', () => { +describe('补全卡退役:引擎不再发 elicitation 卡', () => { const ASK = { kind: 'ask', itemId: 'i1', @@ -892,18 +899,9 @@ describe('补全卡', () => { fields: [{ key: 'NE555P_lead_time', label: 'NE555P 交期' }], }; - it('发出去即挂起等填 —— 调人 = 立监听听人', async () => { + it('直发 ask 被拒:一张卡都不发,理由指向执行工单', async () => { const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: ASK }); const outcome = await h.runner.runTurn(TASK); - expect(outcome.status).toBe('awaiting'); - expect(h.issued).toEqual([ - { workOrder: 'elicitation', card: { ask: '这两条缺交期,麻烦补一下', fields: ['NE555P_lead_time'] } }, - ]); - }); - - it('一个格子都没有就拒发 —— 空表单等于没问', async () => { - const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: { ...ASK, fields: [] } }); - const outcome = await h.runner.runTurn(TASK); expect(h.issued).toHaveLength(0); expect(outcome.status).toBe('running'); // 拒了不崩,理由喂回下一拍 }); @@ -1225,6 +1223,30 @@ describe('办完的项:话要说对,按一下不报错', () => { expect(h.issued).toHaveLength(0); }); + /* + * **A1 基线熔断实测(2026-08-03)**:人答完之后那一步已经收掉,模型又发了一遍同一张卡。 + * 从前 `readyToWork` 走在重发判断之前 —— Completed 项当场抛「不能再往它上面挂活」, + * 于是「重发已答过的卡 = 无事发生」这条唯一活路在最需要它的时候够不着, + * 同一条拒回重复五遍熔断(13 拍 124k,发口一个都没有)。顺序换过来才叫墙要配路。 + */ + it('重发**已答过**的卡,哪怕那一步已经收掉 → 无事发生,不撞「已办完」的墙', async () => { + const h = harness({ + parsed: DONE_MODEL, + runtime: DONE_RUNTIME, + action: { kind: 'human', itemId: 'S3', workOrder: 'execution', card: { title: '请审定这份清单', ask: 'a' } }, + trail: [ + { kind: 'card-sent', title: '请审定这份清单' }, + { kind: 'card-reply', title: '请审定这份清单' }, + ] as never, + }); + const outcome = await h.runner.runTurn(TASK); + // 没发第二张卡(人不被打扰、humanCalls 不烧),也没被拒 + expect(h.issued).toHaveLength(0); + expect(outcome.status).not.toBe('awaiting'); + // 收掉的项原样收着 —— 无事发生不该顺手把它推回场上 + expect(h.envelope.runtime.items.find((i) => i.id === 'S3')?.state).toBe('Completed'); + }); + it('**再 start 一次收掉的项是无事发生,不是非法迁移** —— 别让两堵墙对着撞', async () => { const h = harness({ parsed: DONE_MODEL, runtime: DONE_RUNTIME, action: { kind: 'start', itemId: 'S3' } }); const outcome = await h.runner.runTurn(TASK); @@ -1233,3 +1255,44 @@ describe('办完的项:话要说对,按一下不报错', () => { expect(h.envelope.runtime.items.find((i) => i.id === 'S3')?.state).toBe('Completed'); }); }); + +/** + * **重发已答过的卡:执行工单与决策卡同一把尺**(2026-08-03,A4 实测逼出来的)。 + * + * 从前只有执行工单那一路有兜底,决策卡一道都没有 —— 同一个岔路口连发四张, + * 每张扣一次调人预算,四次扣完整单 `exhausted`。卡族不同,病是同一个。 + * 三档由 `resendVerdict` 定:照发 / 无事发生(头两次)/ 第三次起当被拒(熔断看得见)。 + */ +describe('同一张卡再发一遍的三档', () => { + const sent = (title: string) => ({ kind: 'card-sent', title }); + const replied = (title: string) => ({ kind: 'card-reply', title }); + const echoed = (answered: string) => ({ kind: 'observation', title: echoTitleOf(answered) }); + + it('没发过这一问 → 照发', () => { + expect(resendVerdict([sent('别的事'), replied('别的事')], '这一问').kind).toBe('send'); + }); + + it('发过但**人还没答** → 照发 —— 未答的重发由卡自己去重管,不归这里', () => { + expect(resendVerdict([sent('这一问')], '这一问').kind).toBe('send'); + }); + + it('人答过了 → 头两次无事发生(卡不真发、预算不烧)', () => { + const win = [sent('这一问'), replied('这一问')]; + expect(resendVerdict(win, '这一问')).toEqual({ kind: 'echo', answered: '这一问' }); + expect(resendVerdict([...win, echoed('这一问')], '这一问').kind).toBe('echo'); + }); + + it('第三次起当被拒 —— 那时是真卡死了,熔断兜底要看得见它', () => { + const win = [sent('这一问'), replied('这一问'), echoed('这一问'), echoed('这一问')]; + expect(resendVerdict(win, '这一问')).toEqual({ kind: 'reject', answered: '这一问' }); + }); + + it('问句只少个尾巴也算同一问 —— 变体重发同样烧预算(与 sameQuestion 同源)', () => { + const win = [sent('请审定这份受理需求清单,确认无误后我就往下走'), replied('请审定这份受理需求清单,确认无误后我就往下走')]; + expect(resendVerdict(win, '请审定这份受理需求清单').kind).toBe('echo'); + }); + + it('空问句不拦 —— 认不出身份的卡不该被这道兜底吃掉', () => { + expect(resendVerdict([sent(''), replied('')], ' ').kind).toBe('send'); + }); +}); diff --git a/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts b/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts index a089a85..5b96f30 100644 --- a/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts +++ b/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts @@ -101,9 +101,16 @@ function payloadOf(detail: Record | undefined, limit: number = * 人以为自己白点了一次。补全卡那一路早就有(`values`),决策卡是漏的那一半。 */ const chosen = detail['value']; + const note = detail['note']; if (typeof chosen === 'string' && chosen !== '') { - const note = detail['note']; parts.push(`人选了:${chosen}${typeof note === 'string' && note !== '' ? `(${note})` : ''}`); + } else if (typeof note === 'string' && note !== '') { + /* + * 没有选项也可能有话要带 —— 零输入执行卡的空答复痕靠这一句说清 + * 「人已办完并交回」,不然痕上一个值都没有,模型会当「没答成」再发一张卡 + * (2026-08-02 实测熔断单)。 + */ + parts.push(note); } const output = detail['output']; @@ -186,7 +193,47 @@ export function sameQuestion(a: string, b: string): boolean { const norm = (s: string): string => s.replace(/[\p{P}\p{S}\s]/gu, '').toLowerCase(); const x = norm(a); const y = norm(b); - return x !== '' && x === y; + if (x === '' || y === '') return false; + if (x === y) return true; + /* + * **前缀也算同一件事**(2026-08-02 用户实测送来的单):第一张 + * 「请审定这份受理需求清单,确认无误后我就往下走。」人答了;第二张 + * 「请审定这份受理需求清单」—— 只少了个尾巴,全等比对放它过去, + * 又烧掉一次人环预算(那单 humanCalls 4/4 耗尽,放行卡都没发出去)。 + * 短的一方够长(≥6 字)且是长方前缀,就是同一问 —— 阈值保守: + * 拦错一问比多问一问更坏,真的新问题不该被吃掉。 + */ + const [short, long] = x.length <= y.length ? [x, y] : [y, x]; + return short.length >= 6 && long.startsWith(short); +} + +/** 「已答过、没有再发」那条观察痕的标题 —— 记与数是同一句话,不能各写各的 */ +export function echoTitleOf(answered: string): string { + return `这张卡人已答过,没有再发(「${answered}」)`; +} + +/** + * **同一张卡该不该再发一遍** —— 执行工单与决策卡共用这一把尺(纯判断,好测)。 + * + * 从前只有执行工单那一路有这道兜底,决策卡**一道都没有**:同一个岔路口连发四张, + * 每张扣一次调人预算,四次扣完整单 `exhausted`(2026-08-03 A4 实测:人点完之后 + * 那一步被重念,剧本头一段写着「发一张决策卡」,它就照办再发一张)。 + * 卡族不同,病是同一个 —— **按钮按两下不该变成墙**,也不该变成四张卡。 + * + * 三档:`send` 照发;`echo` 卡不真发(人不被打扰、humanCalls 不烧), + * 只把「已答过」的事实摆回轨迹;第三次起 `reject` —— 那时是真卡死了,熔断要看得见它。 + */ +export function resendVerdict( + window: ReadonlyArray<{ readonly kind: string; readonly title: string }>, + asked: string, +): { readonly kind: 'send' } | { readonly kind: 'echo' | 'reject'; readonly answered: string } { + const trimmed = asked.trim(); + if (trimmed === '') return { kind: 'send' }; + const replied = new Set(window.filter((t) => t.kind === 'card-reply').map((t) => t.title)); + const same = window.find((t) => t.kind === 'card-sent' && replied.has(t.title) && sameQuestion(t.title, trimmed)); + if (same === undefined) return { kind: 'send' }; + const echoed = window.filter((t) => t.kind === 'observation' && t.title === echoTitleOf(same.title)).length; + return { kind: echoed >= 2 ? 'reject' : 'echo', answered: same.title }; } /** @@ -220,7 +267,7 @@ export function handsOff(actions: ReadonlyArray<{ kind: string }>): boolean { return actions.some((a) => HANDOFF_KINDS.has(a.kind)); } -const HANDOFF_KINDS = new Set(['ask', 'human', 'decide', 'listen', 'submit']); +const HANDOFF_KINDS = new Set(['human', 'decide', 'listen', 'submit']); export function sameGist(a: string, b: string): boolean { const norm = (s: string): string => s.replace(/[\p{P}\p{S}\s]/gu, ''); @@ -304,11 +351,15 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService { /** * 干活的那一下:把计划项推到 Active,再由调用方收成 Completed。 * - * 拓扑不许 `Available → Completed`,所以中间那一步总得有人走。谁走,分情况: - * · 项标了 `manualActivationRule` → **只能 Agent 自己 start**。那条纪律管的是 - * 触达世界与关键交付:按一下再动,为的是别让 AI 悄悄改了外面。 - * · 没标 → 引擎就地过一下。为一次读类调用或一次草稿写单烧一拍模型去按那一下, - * 是纯仪式;实测里它还会卡死:模型反复挑同一个没 start 的项,每拍都被拓扑拒回来。 + * 拓扑不许 `Available → Completed`,所以中间那一步总得有人走 —— **引擎就地过一下**, + * 人工激活的项也一样(2026-08-02 拆墙)。从前人工激活的项要求模型先发一个显式 + * `start`,不发就拒;可那道墙保护不了任何东西:start 是**模型自己的**动作, + * 同一串里连发即过,拦下只是逼它多演一步礼仪 —— 而礼仪学不稳就是死法: + * 实测 A1 一单连拒五拍熔断,五次全是「S3_confirm 要先 start」,提示词与拒回 + * 都写着这句话,它就是学不会。人工激活真正要保的两样一样不少: + * 引擎不因哨兵满足**替它启动**(那在 sentry 那一侧),也不给 doneWhen + * **替人收步**(那在自动收步那一侧)。「模型显式动它」本身就是启动的意思表示, + * 与 finish 就地过 Active、start 已收项无事发生同族 —— 状态机的礼仪不该长成墙。 * * 项不存在就如实报,并把合法的选项摆出来 —— 模型会编 id(实测编过), * 只回一句「(不存在)」它下一拍还是照编。 @@ -357,9 +408,6 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService { `要接着办就挂当下这一步(可选:${live.join('、') || '无'});这一单该收口了就直接 submit`, ); } - if (item.manualActivation) { - throw new Error(`${itemId} 标了人工激活(触达世界或关键交付),要先 start 再干活 —— 当下是 ${state}`); - } return transitionItem(runtime, itemId, 'Active'); } @@ -667,8 +715,15 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService { * 拒了不掀翻回合,草稿也确实没被改脏(写进去的值与原值相同)。 */ if (changed === 0) { + /* + * 「换一件事做」四个字不够指方向(2026-08-02 实测:A2 一单连撞四拍, + * 每拍都把同一行原样再写一遍)—— 拒回的话是模型下一拍的行动指南, + * 得说清「这些值**已经在**台账里」意味着什么:多半是该办下一件事了。 + */ throw new Error( - `写了等于没写:${action.address} 这 ${action.rows.length} 行的值与现有草稿完全相同,局面没有推进 —— 换一件事做`, + `写了等于没写:${action.address} 这 ${action.rows.length} 行的值与现有草稿完全相同 —— ` + + `这些值**已经在台账里了**,不必再写。该办的多半是下一件事:还没写的列/表、` + + `该 finish 的项,或按剧本走下一步;把同样的值再写一遍只会再被拒一次`, ); } await deps.memory.append({ @@ -755,11 +810,19 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService { * Active → Active 是非法迁移,当场拒;模型只会原样再试一遍,连拒五拍熔断。 * 而它想做的事完全合法 —— 一个计划项本来就可以先问一句、再派一张单。 * - * `readyToWork` 是同一件事的宽容写法:已在场就原样返回,人工激活未 start 才拦。 + * `readyToWork` 是同一件事的宽容写法:已在场就原样返回,没在场就地站上 + * (2026-08-02 起人工激活的项同样如此 —— 动它就算启动)。 */ - const { parsed: cardModel } = await loaded(taskId); - const advanced = readyToWork(cardModel, runtime, action.itemId); /* + * **重发的判断要走在站上场之前**(2026-08-03,A1 熔断实测逼出来的顺序)。 + * + * 原先先 `readyToWork` 再判重发:而人答完之后那一步常常已经**收掉**了, + * `readyToWork` 对 Completed 项直接抛「这一步已经办完,不能再往它上面挂活」—— + * 于是「重发已答过的卡 = 无事发生」这条唯一的活路**在最需要它的时候够不着**, + * 模型原样重发、连拒五拍熔断(A1 基线实测:13 拍 124k,同一条拒回重复 5 遍)。 + * 重发一张已答过的卡本来就不需要那一项在场 —— 它什么也不做。 + * 「墙要配路」(设计律第一条):先给路,给不了路再谈墙。 + * * **同一件事不许问第二遍**(2026-08-01,实测账)。 * * 熔断管的是「连续被拒」,管不了这种:每一拍的行动都合法,只是**问的是同一件事**。 @@ -775,19 +838,44 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService { const asked = String(face['title'] ?? face['ask'] ?? '').trim(); if (asked !== '') { const window = await deps.memory.window(taskId, STALL_WINDOW); - const sent = window.filter((t) => t.kind === 'card-sent'); - const replied = new Set(window.filter((t) => t.kind === 'card-reply').map((t) => t.title)); - const same = sent.find((t) => replied.has(t.title) && sameQuestion(t.title, asked)); - if (same !== undefined) { - throw new Error( - `这件事刚问过,人也答过了(「${same.title}」)—— 别再发一张一样的卡。` + - '答案就在你的轨迹里(card-reply 那一条的 values)。' + - '**他把格子留空提交回来,就是对方给不了** —— 那也是一个答案:' + - '如实记账(问过了 / 答过了),缺口照实留着,把这一步收掉往下走;' + - '真有别的东西要问,换一件事、换一个问法。', - ); + const verdict = resendVerdict(window, asked); + if (verdict.kind !== 'send') { + const same = { title: verdict.answered }; + /* + * **重发已答过的卡:头两次当无事发生,不当被拒**(2026-08-02 用户实测 + * 送来的熔断单)。从前这里直接拒 —— 拒回的话把「答案在轨迹里」说得 + * 再清楚,模型偶发就是读不进:零输入执行卡的空答复在它眼里像「没答成」, + * 于是原样重发、被拒、再重发,五拍熔断,人被无谓叫来。 + * 拒回喂的是对抗,观察喂的是事实 —— 与 start 已收项无事发生、 + * finish 已收项无事发生同族:**按钮按两下不该变成墙**。 + * 卡不真发(人不被打扰、humanCalls 不烧),只把「已答过」的事实 + * 连着下一步指路摆回去;第三次起仍按被拒算 —— 那时是真卡死了, + * 熔断兜底要看得见它。 + */ + const echoTitle = echoTitleOf(same.title); + if (verdict.kind === 'reject') { + throw new Error( + `这件事刚问过,人也答过了(「${same.title}」)—— 别再发一张一样的卡。` + + '答案就在你的轨迹里(card-reply 那一条)。**空答复 = 人照原样确认了**,' + + '不是没答:接着办说明书里「人交回之后」的那几件事,把这一步收掉往下走;' + + '真有别的东西要问,换一件事、换一个问法。', + ); + } + await deps.memory.append({ + taskId, + kind: 'observation', + title: echoTitle, + detail: { + internal: true, + note: '人已答过这张卡(空答复 = 照原样确认)。别再发了 —— 接着办说明书里「人交回之后」的那几件事,办完把这一步 finish 掉。', + }, + }); + // 什么也没做,局面原样交回 —— 这一拍不该顺手把那一项推上场 + return { runtime, status: 'running' }; } } + const { parsed: cardModel } = await loaded(taskId); + const advanced = readyToWork(cardModel, runtime, action.itemId); const card = await deps.cards.issue({ taskId, ownerId: context.ownerId, @@ -806,32 +894,12 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService { case 'ask': { /* - * 发补全卡 = 立监听听人:卡发到对话区,本拍就此挂起等填。 - * - * **先验迁移,再发卡**:迁移非法时抛在前面,世界上不会留下一张已发出的卡 —— - * 卡收不回,负责人的对话区就堆起一摞谁也不知道该怎么答的表单。 + * **补全卡已全局退役**(2026-08-02 用户定:「彻底全局退役补全卡, + * 执行卡完全就是包含补全语义的人类工具」)。归一层(agent-runner)已把 + * `ask` 原地转成零输入执行工单,这个分支正常到不了 —— 真到了 + * (别的调用方直发)就如实拒,并指对路。 */ - if (action.fields.length === 0) { - throw new Error( - '补全卡要有格子:一个缺口一个格子,形状 fields:[{key,label,hint?}] —— 空表单等于没问', - ); - } - const advanced = readyToWork((await loaded(taskId)).parsed, runtime, action.itemId); - const card = await deps.cards.ask({ - taskId, - ownerId: context.ownerId, - humanCallCap: context.humanCallCap, - ask: action.ask, - fields: action.fields, - ...(action.basis === undefined ? {} : { basis: action.basis }), - }); - await deps.listeners.register({ - taskId, - eventType: 'human', - keyFilter: { cardId: card.id }, - deadline: 'P3D', - }); - return { runtime: advanced, status: 'awaiting' }; + throw new Error('补全卡已退役:缺信息发零输入执行工单(human + workOrder:"execution"),指导书写清缺什么,人在工作区的件上补'); } case 'entry': { @@ -875,7 +943,38 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService { * 以及先验迁移再发卡(与补全卡同理 —— 卡发出去收不回)。 */ if (action.options.length < 2) { - throw new Error('决策卡至少两个选项:只有一条路就不是决策,直接干或者发补全卡'); + throw new Error('决策卡至少两个选项:只有一条路就不是决策,直接干或者派一张执行工单'); + } + /* + * 同款顺序:**先判重发,再站上场**(见 human 那一路的账 —— 人选完之后 + * 那一步常已收掉,先站上场就会撞在「已办完」的墙上,活路够不着)。 + * + * **人已经选过的岔路口不再发第二张**(2026-08-03,A4 实测:同一张连发四次, + * 四次调人预算烧光落 `exhausted`)。与执行工单同一把尺 —— 决策卡的身份是问句(`ask`), + * 它入痕时记的正是 `ask`(`issueChatCard` → `card-sent` 标题)。 + */ + const window = await deps.memory.window(taskId, STALL_WINDOW); + const verdict = resendVerdict(window, action.ask); + if (verdict.kind !== 'send') { + const echoTitle = echoTitleOf(verdict.answered); + if (verdict.kind === 'reject') { + throw new Error( + `这个岔路口人已经选过了(「${verdict.answered}」)—— 别再发一张一样的决策卡。` + + '他选了什么就在你的轨迹里(card-reply 那一条,detail 带着选中的值):' + + '照那个选择往下办,把这一步收掉;真有**另一个**岔路口要人定,换一件事、换一个问法。', + ); + } + await deps.memory.append({ + taskId, + kind: 'observation', + title: echoTitle, + detail: { + internal: true, + note: '人已经在这个岔路口选过了(选中的值在 card-reply 那一条)。别再发 —— 照他的选择接着办,办完把这一步 finish 掉。', + }, + }); + // 什么也没做,局面原样交回 + return { runtime, status: 'running' }; } const advanced = readyToWork((await loaded(taskId)).parsed, runtime, action.itemId); const card = await deps.cards.decide({ @@ -931,11 +1030,12 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService { /* * 宣告完成。完成不等于成果被认可:终局仍唯放行,人验收才算数。 * - * 没站上场的普通项**就地过 Active 再收**(readyToWork 同一套):这一项的活 + * 没站上场的项**就地过 Active 再收**(readyToWork 同一套):这一项的活 * 常常在同一串里已经干了(write 落了行),只是记账没挂上 —— 为「按一下」 * 单烧一拍模型是纯仪式(实测:finish S6 被「没在进行中」拒,下一拍 - * start S6 + finish 原样重来,白花一拍)。标了人工激活的仍要先 start: - * 那条纪律管的是触达世界与关键交付,readyToWork 会替它把门。 + * start S6 + finish 原样重来,白花一拍)。人工激活的项也一样(2026-08-02 + * 拆墙,见 readyToWork 注释)—— 但它仍旧**没有 doneWhen、引擎不自动收**, + * 收它的只能是模型自己的这一下 finish。 */ const { parsed: finishModel } = await loaded(taskId); /* diff --git a/apps/web/src/app/styles/app.css b/apps/web/src/app/styles/app.css index b42bdbe..98008e8 100644 --- a/apps/web/src/app/styles/app.css +++ b/apps/web/src/app/styles/app.css @@ -2108,7 +2108,7 @@ .proto-root .jc-rp-vals dd { margin: 0; padding: 8px 12px; font-size: 12px; color: var(--ink); line-height: 1.65; background: #fff; } /* 落款 */ -.proto-root .jc-rp-foot { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 13px 20px 15px; border-top: 2px solid var(--line); background: #fbfcfd; } +.proto-root .jc-rp-foot { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; padding: 13px 20px 15px; border-top: 2px solid var(--line); background: #fbfcfd; } .proto-root .jc-rp-sign { display: flex; align-items: center; gap: 9px; flex: 1 1 200px; min-width: 0; } .proto-root .jc-rp-ava { flex: none; width: 30px; height: 30px; border-radius: 999px; display: grid; place-items: center; font-size: 12px; font-weight: 700; color: #fff; background: var(--brand); } .proto-root .jc-rp-sign b { display: block; font-size: 12px; color: var(--ink-2); } @@ -2301,7 +2301,8 @@ .proto-root .jc-fc-h em.bad { color: var(--danger); } /* 卡体与动作栏 */ .proto-root .jc-fc-b { padding: 11px 13px; display: flex; flex-direction: column; gap: 10px; } -.proto-root .jc-fc-ft { display: flex; align-items: center; gap: 8px; padding: 9px 13px; border-top: 1px solid var(--line-soft); background: #fbfcfe; } +/* 动作靠右(2026-08-03 用户:「提交按钮放左侧太奇怪了」)—— 验收卡的「重做|通过」有 jc-fc-sp 撑开,不受影响 */ +.proto-root .jc-fc-ft { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 9px 13px; border-top: 1px solid var(--line-soft); background: #fbfcfe; } /* * **交不了的时候,说清差在哪** —— 只摆一个灰按钮等于让人猜自己哪儿做错了。 * 底色用警示色而不是错误红:这不是出错,是活还没干完。 diff --git a/apps/web/src/features/desk-task/internal/ViewRegion.tsx b/apps/web/src/features/desk-task/internal/ViewRegion.tsx index 4147acd..d290d0d 100644 --- a/apps/web/src/features/desk-task/internal/ViewRegion.tsx +++ b/apps/web/src/features/desk-task/internal/ViewRegion.tsx @@ -81,6 +81,13 @@ function collectEdits(edits: ViewEdits, order: WorkOrder, onField: (k: string, v export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: ViewRegionProps) { const [view, setView] = useState('open'); const [page, setPage] = useState(0); + /* + * **件内翻页**(2026-08-02 用户:「一个执行单可以派多个 HTML 待执行报告, + * 合理利用右上角的翻页和待处理的数字气泡」):件里带 `data-page` 的可见段 + * 各算一页(A2 = 一颗待选型芯片一页)。单卡多页时,右上角那对箭头翻的是页。 + */ + const [docPage, setDocPage] = useState(0); + const [docPages, setDocPages] = useState(1); /** 工作成果:列表里点开的那份定稿报告 */ const [doneSel, setDoneSel] = useState(null); const [openFile, setOpenFile] = useState(undefined); @@ -279,29 +286,56 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi 返回列表 ) : view === 'open' ? ( - - - - {openList.length === 0 ? 0 : pos + 1}/{openList.length} - - - + /* 单卡多页时这对箭头翻**件里的页**(一颗芯片一页);多卡时照旧翻件 */ + docPages > 1 && openList.length <= 1 ? ( + + + + {docPage + 1}/{docPages} + + + + ) : ( + + + + {openList.length === 0 ? 0 : pos + 1}/{openList.length} + + + + ) ) : null}
@@ -416,12 +450,21 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi ) : view === 'done' ? ( doneItem !== undefined ? ( /* - * 定稿归档 = **同一份件,不加编辑态**(§9.3 第 2 条:在办与归档同一张模版)。 - * 与待处理那一屏一样,这里也只放件本身 —— 判据留在对话区那张办结卡上, - * 归档之后「凭什么算过」比当时更常被问起,所以它跟着卡走,不跟着件走。 + * **定稿归档 = 办结那一刻的件快照,1:1 静态重放**(2026-08-02 用户: + * 「HTML UI、数据、内容、布局都要 1:1 地从待处理状态转换为静态报告 + * 放到工作成果」—— 反复强调过)。 + * + * 从前两条路都不对:验收单渲**当下**的任务视图(随后续推进还会变), + * 执行单干脆退回 WorkOrderReport 卡壳(题头/编号带/blocks)—— + * 没有一样是人办结时看到的那份。现在办结那一刻基座把渲好的件 + * 存进工单(`finalHtml`),这里原样静态渲它;老单没有快照的, + * 退最近一帧任务视图,再没有才退卡壳(历史兼容)。 */ - doneItem.mode === 'acceptance' && detail.viewHtml !== undefined ? ( - + doneItem.finalHtml !== undefined || detail.viewHtml !== undefined ? ( + ) : (
{ /* diff --git a/docs/HTML-One-Pager/A2-选型执行工单.html b/docs/HTML-One-Pager/A2-选型执行工单.html new file mode 100644 index 0000000..a096337 --- /dev/null +++ b/docs/HTML-One-Pager/A2-选型执行工单.html @@ -0,0 +1,669 @@ + + + + + + +替代选型工作单 · JCAI one-pager 目标件 + + + + + +
+
+
+
+
+ 统一报价 · A2 替代选型 · 执行工单 +

替代选型工作单 RFQ MD14.1

+
+ 在办 · 待选型 2 颗 · 已定案 1 颗 +
+
+ + + +
+
+
+ +
冯兵AI 拟稿,你审定 —— 落款说明按件改写
+
+ 统一报价 · A2 替代选型 · 执行工单 +
+
+
+ + + + + + + diff --git a/docs/HTML-One-Pager/A2-选型验收报告.html b/docs/HTML-One-Pager/A2-选型验收报告.html new file mode 100644 index 0000000..b058982 --- /dev/null +++ b/docs/HTML-One-Pager/A2-选型验收报告.html @@ -0,0 +1,669 @@ + + + + + + +替代选型验收报告 · JCAI one-pager 目标件 + + + + + +
+
+
+
+
+ 统一报价 · A2 替代选型 · 验收报告 +

替代选型验收报告 RFQ MD14.1

+
+ 待放行 · 清单 5 条已转正 +
+
+ + + +
+
+
+ +
冯兵AI 拟稿,你审定 —— 落款说明按件改写
+
+ 统一报价 · A2 替代选型 · 验收报告 +
+
+
+ + + + + + + diff --git a/docs/HTML报告生成规范.md b/docs/HTML报告生成规范.md index e6c8adc..2b572f2 100644 --- a/docs/HTML报告生成规范.md +++ b/docs/HTML报告生成规范.md @@ -22,8 +22,12 @@ 三条硬规矩: -1. **一单一模版贯穿始终**:同一个局面,在办与归档用同一张;局面变了换张 - (A1 三张:受理需求 → 料号分类 → 受理记录),不在一张里堆条件分支。 +1. **每份件两态 1:1,不同工作各有各的件**(2026-08-02/03 用户两次校准): + 「1:1」的落点是**每份件与它的办结快照**(在办可编辑 ↔ 回执收下那一刻存 + `finalHtml`,成果页静态重放,数据/内容/布局一个字不差)—— 不是全程只许一张。 + 作业的件(如 A2 选型 One Pager)与收口的件(整合清单)是两件工作两份件: + 「清单是所有选型完成后收口才整合的东西」。别再把两态统一误执行成一张到底 + (踩过:验收工单打开还是选型页,用户当场指出)。 2. **剧本每步的 `view` 定推哪张**,写清单时定死,不归模型临场挑;放行那一步的 `view` 必须指向验收目标件 —— 最后摆上去的那张就是归档件。 3. **数据变了基座重渲**(repush):人在件上保存、在卡上回填,落草稿层后服务端拿 @@ -180,6 +184,14 @@ A1 两份报告是 1:1 平移 PRD 目标件:样式表整段搬(`prd-style.ts` ## 8. 版式纪律 +- **空占位三级统一**(2026-08-02 用户定,全局口径):**必填(红)· 建议填(橘)· + 选填(灰)** —— 与声明的 `required` / `expect` / 其余一一对应,胶囊文案就写这三个词。 + 归档静态态它们如实呈现「没有」。 +- **选择列直接选**(如 A2 替代等级):三枚自绘 radio(`input[type=radio].jc-cell[data-col]`, + radio 盖满选项格可点、`:has(:checked)` 高亮),**不写 checked** —— 当前值由 + `data-class-of` 投影 v-* 描虚线;基座对未选中的 radio 整列不交(「没点」=「不改」)。 + 收值按「input 的祖父」归行:一张卡里所有输入格的祖父必须是同一个元素。 + - **铺满工作区**:去掉外层宽度上限与横向留白、卸掉外框圆角边 —— 件就是这一屏的正文, 不是嵌在里面的另一个页面(`jc-stage { max-width:none }`)。 - 必填/选填胶囊只在**在办态**摆;归档件一律 `dimempty`(那时空格的意思不再是 @@ -187,6 +199,51 @@ A1 两份报告是 1:1 平移 PRD 目标件:样式表整段搬(`prd-style.ts` - 别摆恒常说「没有」的横幅(「本轮不报 0 行:—」),别把同一个数摆两处 (人得停下来对一遍两处一不一样);已在别处说过的结论不再复述一遍。 - 标记里不写 `**` 星号这类记号 —— 卡面与件都是纯文本/纯 HTML,记号不会变粗体。 +- **概念图形是报告的第一语言**(A2 迭代周用户反复要「图形占比大一点」): + 芯片小像、流光走线、引脚对照、漏斗 —— 内联线稿 SVG(stroke currentColor, + 配色走样式表)把「这是件什么事」画出来;数据格是第二语言。一份让人愿意看的 + 报告先立视觉隐喻,再摆数。 +- **收值三铁律**(件上就地改的结构约束,违者行互相覆盖或值丢失): + ① 同一行所有输入格的**祖父必须是同一个元素**(collect 按祖父归行); + ② 主键格必挂 `data-col`(行的钥匙,可以是隐藏格); + ③ radio 不写 `checked`,「没点」=「不改」(当前值靠 `data-class-of` 投影描样式)。 + +## 8.5 一页汇报(slide 语言)—— 强交互全走纯 CSS + +用户对 A2 选型件的要求(2026-08-02):「不够华丽,要强交互的汇报形式, +直接拿去当一页 PPT 的程度」。沙箱零脚本不变 —— **「强交互」全部由样式表承担**, +数据仍只经 `data-slot` / `data-class-of` 投影,动效是呈现,不是判断。 +参考实现:`activities/quote-alternative/src/views/report.ts` 的 `ALT_WORKSHEET`。 + +**构图**:主视觉(hero)+ 编号小节(slide part)推进。 + +- **hero 深空底**:`radial-gradient` 两团光 + `linear-gradient` 深空 + + `::before` 电路点阵(radial-gradient 1px 点)+ `::after` 顶部一道光。 + hero 内配色自成一组(亮字/暗丸/信标),别把 PRD 亮色变量直接用在深底上。 +- **slide 小节头**(`jc-sp`):`01/02/…` 编号丸 + 题 + 一句注 + 延伸线 —— + 编号是版面语言,不是数据。 +- **逐段入场**(`jc-rise` + `.d1…d6` 延迟):`opacity/transform` 动画, + **全部包在 `@media (prefers-reduced-motion: no-preference)` 里**(连 `@keyframes` + 一起包 —— 引用未定义的动画名等于没有动画,减弱设置下自然全静)。 +- **流光走线**:两条同 path 的 SVG(`wire` 底线 + `pulse` 虚线), + `stroke-dasharray` + `stroke-dashoffset` 动画 + `drop-shadow` 发光。 +- **悬停揭示**:卡片 hover 浮起(`transform + box-shadow` 过渡); + 等级卡的口径段 `max-height:0 → hover/focus-within 展开`,加 `tabindex="0"` + 让键盘也进得去;**触屏常开**(`@media (hover: none)` 里展开、藏掉「悬停展开」hint)。 +- **空节自折**:整节只在有数据时出现 —— 包一层 wrapper, + `:not(:has(.行.v-xxx)) { display:none }`(如 nowwrap/donewrap)。 + 没有 NOW 行时「当前这一颗」整节(含小节头)不出,不留空壳。 + +**三条工程账**(都踩过): + +1. **媒体查询要排在被它覆盖的基础规则之后** —— 同权重下后定义胜。 + 竖排断点里写了 `.jc-trace{display:none}`,而 `.jc-trace{display:flex}` 基础规则 + 定义在媒体块之后,于是竖排下走线照显:横着一根流光指向右边的空气。 +2. **动效只动 `transform`/`opacity`/`box-shadow`**,别动布局属性; + `jc-rise` 会在每次 srcdoc 重渲后重播(保存一次播一次)—— 半秒内是 slide 感, + 长了就是折磨,入场动画别超过 0.6s。 +3. 队列丸/信标这类**状态灯**照旧走 `data-class-of`(`v-now` 信标呼吸、`v-done` 绿、 + `v-wait` 暗)—— 「哪颗在办」是数据说的,动画只是把它说响一点。 ## 9. 自测与浏览器走查 @@ -208,6 +265,9 @@ A1 两份报告是 1:1 平移 PRD 目标件:样式表整段搬(`prd-style.ts` |---|---|---| | 点「编辑」毫无反应,一切选择器都对 | `instanceof Element` 跨 realm 恒 false;或监听挂在 about:blank 上 | 认能力不认血统;幂等重挂(§3) | | 保存时件「闪黑」一下 | srcDoc 换文档整页重载 | 就地换 body.innerHTML(§3) | +| 就地换了还是「闪一下」(2026-08-03 再报) | innerHTML 仍是**整树重建**:47KB 样式在 body 再解析一遍(head 那份还在,一文档两份)、SVG 全重建、CSS 动画回第 0 帧重起 | 第二层:逐节点 morph(`ViewContainer.replaceInPlace`)—— 一样的节点不碰,样式归位 head 去重,涟漪只落在真改了的格子上;任何一步栽了退回整树重建(闪但不错) | +| **保存后旧值还摆着一会儿**(真正让人以为没生效的那个) | 保存要跑个来回(POST 落草稿 → 失效 → 重取详情 → 新件推回),而闸在按下那一刻就关了 —— 这段时间屏上是只读那份的**旧值** | 按下保存**先把新值写进只读孪生件**再关闸(`mirrorTwins`),回声到达时 morph 一比已经一样;只镜像格子自己的值,红带/分布条/去向等基座算的数一律等回声 | +| 镜像/取值找不到孪生件而无声失效 | 渲染那一遍把 `data-slot` **连同槽位一起消费掉**,活 DOM 里两份都没有这个属性 | 认**位置**不认属性:`twinInputs` 把 input 紧接着只读那份写出,`previousElementSibling` 就是孪生件;**验证要在真单的 DOM 上做** —— 合成 fixture 里两份都写了 `data-slot`,照样绿 | | 人保存了,一格都没落库 | 保存借道当下那张卡的 blocks | 按 `data-edit` 地址收,授权=台账声明(§2) | | 改了类标,分布条/去向不动 | 汇总数是自报的没人重算;或没 repush | 分类列标 `tally` 让基座数;保存后必 repush(§1) | | 纯色块整条灰着像没算出来 | `data-class-of` 曾以 `data-slot` 为前提 | 已修;自测钉住 | @@ -216,3 +276,6 @@ A1 两份报告是 1:1 平移 PRD 目标件:样式表整段搬(`prd-style.ts` | 报告与卡说的判据不一致 | 两处各算各的 | 全部读基座读数,同源(§2) | | 归档件上还有编辑闸/胶囊 | 闸没锁在 `editable` 根类下;胶囊没分态 | §6-1、§8 | | 版面「像又不像」目标件 | 用拼装件照着做而不是搬 | §7;截图比对 | +| 人保存后一行的值被另一行盖掉 | **主键格没挂 `data-col`** —— 收上去的行没有钥匙,服务端按「全表一致列」补键,多行对到同一行 | 主键列必挂 `data-col`(它不是编辑面,是行的钥匙);e2e 走 API 自带主键测不出,只有件上真按保存才现形(A2 走查逮过) | +| 逐行卡片克隆成套娃(第二行长进第一行里) | 渲染器按**第一个同名闭合标签**截行内容 —— `
` 的行模版里嵌了别的 `
`,行在半截被断开 | **行容器的标签名不得在行模版内部再出现**:容器换 `
`/`` 这类行内不会嵌的标签(A2 选型档案卡逮过) | +| 空格胶囊比同行的数字**往下坠几像素** | 裸 inline-flex 胶囊按 baseline 挂:盒(字 + 内距 + 边框)比字的基线深,行高也随之忽高忽低 | 空格子(`:empty`)转 flex + `align-items:center` + `min-height` 撑住文字那份盒高,胶囊中线对数字中线(A1 逮过,2026-08-03) | diff --git a/docs/debt.md b/docs/debt.md index 95a81f9..0555e3e 100644 --- a/docs/debt.md +++ b/docs/debt.md @@ -787,3 +787,168 @@ JS 拼的 class 与 `style="flex:N"`→`data-class-of`)。规范 §10.3.1 已立 `customer.contact`(主数据)里,可老客户不重写主数据、本单草稿里根本没有那一行; 顶层裸键又与 `customer.profile.name` 撞名(后写的赢)。 **一张写着公司名的联系人卡比空着更坏**,所以跟 `customer_name` 同一笔写下。 + +## 算出来的口只在前端算数(2026-08-02,A2 e2e 逼出来的,已销) + +A2 第一轮端到端:七拍零被拒、去向已算出 `to-sourcing`、验收 pass —— +死在最后一步:「放行未确认分支结论:选择组 alt-route 未选出口」,台账已合并、单落 `blocked`。 + +查下来是 2026-08-01 那次「routedPort 那一路 selectedPorts 恒空」的修法**只修了一半**: +补的是**前端**(chat-bubbles 回传时把算出的口塞进 `selectedPorts`)与 +**服务端返回值**(human-tool-service 的 outcome)。而放行落地读的是第三样东西 —— +**人事件载荷**(`publishHumanEvent({ payload: { ...reply } })`,原始回传), +那一份谁都没动。于是浏览器活着(它自带口)、返回值好看(没人读它), +换个客户端(脚本、API 调用)就死在落地,而服务端注释还写着 +「人回传里带不带都不作数」—— **注释描述的行为在引擎读的那条路上从来不成立**。 + +修法:`replyWorkOrder` 在发事件**之前**算好有效口(routedPort 优先、算不出才认人选), +事件载荷与返回值用同一份。**服务端说了算的事,不靠每个客户端自觉** —— +前端那段照旧发,发什么都会被有效口盖掉。 + +顺带两条实测账(代答脚本要知道的): +- 执行卡 `fields` 缺席时基座兜底加一枚**必填**「结果」回执字段(`fillCard`), + 交卷载荷要带 `values.result`,否则「必填字段未回填:结果」; +- A3 的提交闸第一次真拦人:单飞归一出两颗料、脚本只填一颗,交卷被点名 + 「还有 1 条必填没填 —— SRC-…/XC6206P332MR 缺底价」—— 与件上红带、放行门 L1 + 同一个数同一套算法,拦的位置和话术都对。 + +## 2026-08-02 · A2 判稳该 decisionTask 化 —— 说明书拦不住「不查工具直接编」 + +单飞案例连续两轮走岔:S3 判稳一拍,模型**一次都没调** `crm.history-price-read`, +直接给替代料写「历史 43 天·行情稳 → 转 A」(数是编的),去向从 to-sourcing +岔成 to-pricing。说明书已写到头(「判稳的行情只有一条来路 —— 这一拍亲自查 +alt_pn」「不许拿上游的数顶替」都在),流内两案例被这些话治住了,单飞治不住: +没有上游数可抄时它就自己编,措辞层面无解。 + +**该走的路**:判稳是死规则(≤90 天且稳 → A,否则 → B)+ 机器可得的入参 +(工具出参)—— 「能算的不问 LLM」,这一步本该像 A1 的 S5 那样交给引擎: +① 判稳规则进 DMN 表(`ALT_DMN` 现在是空的);② S3 改 decisionTask 或 +第一方工具出参直接带 `type_tag`/`basis`(模型只照抄,抄错有对号检查); +③ 收步判据加「凭据齐」(原 C 行 `history_days` 非空 —— 需要 `requiredWhen` +或「非空行数」读数承载)。 + +**现状兜底没断**:验收页 `editableAtRelease` 含 `type_tag`,人改标去向即变 +(routedPort 读 tally 现算)—— AI 第一稿错,人签字前看得见改得动。 + +## 2026-08-02 · 入场哨兵读自报列 —— A2/A3 也有同一条缝 + +A1 踩实的病(用户实测单 584655b6,humanCalls 4/4 耗尽 exhausted):`s_parsed` 读 +模型自报的 `line_total`,模型把 record 三行在 S1 拍写完、没记这个数,S2 空手收步 —— +门永不开,引擎报「依赖断点」,提示词「断点请调人」把模型推去发**补全卡**绕路, +两张多余的卡烧光人环预算。已修:s_parsed 改读基座数的 `quote_intake_record_rows` +(A1@46.0.0),该形态复跑 released、被拒 0、断点 0、补全卡 0。 + +**同一条缝还在 A2/A3 的入场哨兵里**(尚未咬人,记账待修): +- A2 `s_normalized` 读自报 `alt_line_total`、`s_filled` 读自报 `filled_total`; +- A3 对应哨兵同款。 +S1 的 guide 都教了「第一件事写这个数」,所以节奏对时不踩;但 A1 证明了 +节奏是模型抖出来的,不是承诺。照 A1 的方子换成 `_rows` 类基座读数即可 +(s_filled 这种「人环收回」型要另想承载 —— filled_total 是「答复处理过」的记账, +基座数不出来,或可读 DONE 计数 `_sel_stage_done`)。 + +## 2026-08-02 · 补全卡退役的扫尾 + +行为已全局退役(归一层 ask→零输入执行工单;引擎直发拒;提示词/规范/三活动清理)。 +留着的旧物,后续轮清: +- 契约类型 `ChatCard` 的 elicitation 分支与 `checkChatCard`/`checkChatCardReply` + 的对应校验(历史卡回放还要认,删除走三步弃用); +- 前端补全卡渲染(历史对话里旧卡要能显示); +- `process.start` 起单缺 required 顶层键时发补全卡的老路 —— 三活动 required + 全空后实际不触发,彻底移除时一并清。 + + +## 2026-08-02 · A2 已落的两条全局口径,A1/A3 待跟进 + +- **一张模版贯穿到底**(用户:「待处理的 HTML 报告完成后直接转变为静态的工作成果」): + A2 已并成一张(alt-roster 退役);A1(四张按局面换)与 A3(worksheet/roster 两张) + 仍是旧制,按 A2 的样子归一。 +- **空占位三级统一**(必填红/建议填橘/选填灰):A2 已上;~~A1~~(2026-08-03 已上: + 品类/制造商/目标价按声明的 expect 标橘「建议」,quote-intake@48.0.0,用户点的是目标价)/ + A3 的占位词还是旧两级(待填红/选填灰),跟着声明的 required/expect 换新三级。 + +## 2026-08-03 · A4 补齐时发现、没动的两处共享面 + +- **放行门拒回仍会指向两扇打不开的门**:`_rows > 0` 但某个 `tally` 列一格没填时, + 拒回只给「改数 / 改行」两条路(而两者都对),模型原样重提到熔断。 + 基座那条「`_rows = 0` → 直说那一步还没办」只覆盖了「一行都没写」, + 没覆盖「行写了但分类/处置列没填」。**拒回的话是下一拍的行动指南**(已立纪律), + 该按「哪一列还空着」点名。A4 靠独立成项 `S3_gate` 绕过去了,病根还在。 +- **单飞造号会借用正式表里别单的单号**(第三次现形:A2 借走 MD14.1、A4 借走 RFQ-2026-0777)。 + A2/A3/A4 三处都只靠说明书措辞防守(「只从本单来件取材」「必须 PRC- 开头」)。 + **措辞治不了的结构治**(设计律第六条):造号该由基座给(同组主键 + 序号, + 与 `FieldDeclaration.auto` 同一条路),不该让模型自己编。 + +~~**决策卡没有幂等兜底**~~(2026-08-03 已修:`resendVerdict` 抽成纯函数, +执行工单与决策卡同一把尺,头两次无事发生、第三次起算被拒,6 测钉死)。 + +## 2026-08-03 · A6 补齐时发现、没动的三处基座欠账 + +- **`listen` 动作在引擎里恒挂起**:`turn-runner` 对 `listen` 返回 `awaiting`,要外部 + data/time 事件才唤得醒,而 desk 侧**没有任何路由能把 data 事件投给指定任务** —— + 「等客户回音」这类等外部的活,活动只能落在执行工单上(人收到信、把回音填进件里), + 时限那一侧靠 `declarations.listeners` 静态监听。A6 已按这条口径建模并有测钉死 + (剧本不许教 `listen`)。要让 `listen` 真能用,得补一条「把外部事件投给某任务」的入口。 +- **依赖断点那几拍,全局提示词只说「请调人或立监听」**,而当下可为为空时模型手上 + 只剩计划项的**中文名** —— 它会拿中文名当 `itemId`(实测连拒两拍)。活动侧只能靠 + 说明书兜(A6 把 itemId / ask / 选项值写死进说明书)。**治法在基座**:断点提示里 + 把可用项的英文 id 一并摆出来。 +- **`mail.send` 至今没有活动敢用**:写类工具放行落地要过「入参 schema + `isInvocable` + + 目标预校验」三道,而治理台白名单授权口仍挂在 §15⑤。A6 `tools: []`, + 报价函靠执行单上那句话交给人发 —— 现状如实,但「谁真发了出去」系统并不知道。 + +## ~~2026-08-03 · A2 判稳偶发不查工具~~(当日已结构治) + +**症状**:A2 单飞那条案例约十次一次 —— 替代料没查行情就判「稳」转 A, +去向从 `to-sourcing` 走成 `to-pricing`(实测 2026-08-03 全量回归一次)。 +说明书已经写到头(「判稳的行情只有这一条来路 —— 这一拍亲自查 alt_pn」 +「不许传原 PN」「不许拿上游带进来的数顶替」),**措辞治不了的结构治**(设计律第六条)。 + +**可行治法**(基座已具备一半):工具出参本来就入痕 —— +`turn-runner` 每次调完写一条 `kind: 'result'` / 标题 `${tool} 返回` / detail 带 output。 +所以「这一单到底调没调 `crm.history-price-read`」是**基座数得出来的事实**, +不必让活动自报。补一类判据谓词(如 `tool_called_crm_history_price_read > 0`, +与 `域_对象_rows` 同族的读数),A2 就能立一条 L2: +**「转 A 的依据来自本单亲查的行情」** —— 机检只读基座数,这条纪律第一次真正覆盖到工具。 + +**当下兜底**(已在):验收页 `type_tag` 可改、去向跟着变;人在放行那一屏能纠。 + +**~~已落地~~**(2026-08-03 当日):基座新增读数 `tool_<工具名去符号>_calls` +(`toolCallReadings`,按留痕数成功返回的次数,声明过的工具一律先铺 0; +调失败不算查过 —— 调不通等于没拿到行情)。静态审与包内自测的「谓词绑得上」 +名单同批扩到这一类。A2@4.14.0 立 L2: +`quote_alternative_list_type_tag_a = 0 or tool_crm_history_price_read_calls > 0` +——**写成析取**:一颗都没转 A 就无需查行情(全 B 那条路合法), +有 A 才必须有过这一次亲查。**「机检只读基座数」这条纪律第一次覆盖到工具**。 + +## 2026-08-03 · A2 常路案例会**跳过派给 FAE 的那一步**(未修,已定位) + +**症状**:`a2` 案例①(两颗 C · 替代无历史)连续三轮 6 拍收口,**一张执行工单都没发**—— +轨迹里模型自称「2 颗替代料都查过行情、都转成 B 类」:替代料是它自己填的, +FAE 从头到尾没被叫过,单子照样 released(去向还对)。案例②③ 正常派单(7 拍)。 + +**病根**:S2「派选型单」的 `doneWhen` 认的是**件上那几格填没填** +(`alt_pn/alt_brand/alt_grade` 由 requiredWhen 钉在 orig_type=C 行)—— +而这几格模型自己也写得进去。它一写,收步条件当场成立,引擎就把这一步收了, +**人环整个消失**。这正是「凡触达世界的项一律不给 `doneWhen`」那条纪律的反面教材: +「把清单交给 FAE 选型」触达的是人,它的完成与否不该由数据形状推出来。 +同时它也踩了 `no-fake-alt` 禁令(没把握的替代不该编一个顶上)。 + +**治法**(与 T_release 同款,未施工):S2 改 **人工激活 + 不给 doneWhen** —— +派单那一下由模型显式做,收步由人交回触发;并在剧本里写死「替代料三列一格都不许自己填」。 +**验证口径**:`a2` 三案例都必须出现「件上保存 / 交卷执行卡」两行,6 拍收口即判失败。 + +**排除项**:不是 2026-08-03 那条工具留痕判据引起的 —— 撤掉判据(@4.15.0)后原样复现。 + +## 2026-08-03 · 件上保存偶发不落那一格(间歇,两处独立撞见) + +**症状**:`a1` 案例把 `DAG2400L321504XZB01-R0` 的年用量存进件上(`POST /tasks/:id/table`), +返回 200,而紧接着的提交闸仍报「缺年用量 EAU」,连拦三次判失败; +同一条案例前后几轮都正常。A6 那一轮独立撞见**同一行同一列**的同款症状。 + +**已排除**:不是提交闸失效(拿单直接空手交卷实测吃 400,且点名缺哪一行哪一格); +不是那一轮的说明书改动(A1@49.x 只改措辞,保存走的是基座那条链)。 + +**下一步怎么查**:保存那一下服务端按「收件自己说的话」(`data-edit` 地址 + `data-col` 列名) +落草稿层,主键靠行内 `data-col` 的主键格给出 —— 怀疑点在**主键补齐**那一段 +(A2 走查逮过一次「主键格没挂 data-col → 服务端按全表一致列补键 → 两行对到同一行」)。 +复现要碰运气,建议下次带着「保存请求体 + 落库后草稿行」两份留痕对着看。 diff --git a/docs/汇报-产品设计与系统机制.html b/docs/汇报-产品设计与系统机制.html new file mode 100644 index 0000000..6d19b89 --- /dev/null +++ b/docs/汇报-产品设计与系统机制.html @@ -0,0 +1,780 @@ + + + + + + +JCAI-WOS · 产品设计与系统机制 + + + + + +
+ +
+ JCAI-WOS · 产品汇报 · 2026-08 +

企业级 AI Native
任务操作系统

+

把组织的工作定义为可编排的工作流;工作流由「活动」编排而成 —— + 活动是完成一项任务的 Agent 机制,系统外开发、安装即用。
+ 本篇讲三件事:产品怎么设计的 · 系统靠什么机制立住 · 已经跑出了什么。

+
+ 确定 交系统 + 临场 交 AI + 落地 交人 +
+
+ JCAI-WOS +
+ + +
+ 01 · 为什么做 +

企业落 AI,现有三条路各断在一处

+

不是没人试过让「系统 + 人 + AI」干活 —— 而是每条路都把三种成分混在一个执行者身上,断点因此结构性地无解。

+
+
+

路线一传统流程系统 / BPM

+

把每一步写死在代码里。断点:变更成本 —— 业务改一处,流程改一版,排期以月计; + 长尾业务永远排不上,例外全靠线下走,系统里的流程和真实的干法渐行渐远。 + 它把「临场」也当成了「确定」来编。

+
+
+

路线二人肉 + IM + 表格

+

靠资深员工的手感把活串起来。断点:不可见、不可度量 —— 单子在谁手里、卡在哪一步, + 管理者看不见;判断标准在人脑子里,人一走经验清零。 + 它把「确定」也交给了「临场」。

+
+
+

路线三裸大模型 / 通用 Agent

+

能说会写,演示惊艳。断点:不可控、无责任 —— 没有授权边界(什么都能碰), + 没有责任交割(错了没人签过字),关键数字可能是编的。 + 企业敢让它聊天,不敢让它办事。它把「落地」也托付给了「临场」。

+
+
+ + + + + + + + + 确定性 · 从路线一继承 + 临场力 · 从路线三继承 + 责任制 · 从路线二继承 + + + JCAI-WOS:按成分拆开,各归其位 + 确定交系统 · 临场交 AI · 落地交人 —— 边界由机制立死,不靠自觉 + + 01 / 14 +
+ + +
+ 02 · 核心命题 +

把一件工作拆开,答案就清楚了

+

以一封客户询价邮件为例:同一单工作在三条泳道间流转,每一段都落在最擅长它的执行者手里 —— 谁该做什么,机制说了算。

+ + + + + + + 确定的部分交系统 + 临场的部分交 AI + 落地的部分交人 + + + 规则写成判定表 · 门与闸把关口 + 同样输入,必得同样结论 + 读懂来件 · 归一数据 · 编排步骤 + 每一步留痕,可回核 + 责任点与放行点 + 字签下去,世界才改变 + + + + + + + + + + 来件 + AI 归一拆成受理清单 + 判定表分级A / B / C 三分类 + 人审定放行就地改 · 签字 + 落账发出此刻才算数 + 下游活动按分类分流 + + +
+

确定沉淀为资产

三分类规则、完整度门槛、利润档位 —— 全部写成判定表与判据装进活动包。 + 从此这类判断不再依赖任何人的手感:换人、换模型,结论不变;要改口径,改表,一处生效。

+

临场收敛成编排

俄文附件、中文正文、表格贴在邮件里 —— 来件形状千奇百怪,这正是大模型擅长的。 + 但 AI 在这里只做归一与编排,不做裁断:它算出的每个数都要过系统的门,过不了就得回去改。

+

落地绑定到责任

建客户档案、审定受理需求、放行发出 —— 触达世界的动作全部落在具名的人身上。 + 谁签的字、签之前看到了什么、签下去发生了什么,台账留痕,事后可完整回放。

+
+ 02 / 14 +
+ + +
+ 03 · 产品形态 +

一套系统,三个面

+

三种角色各有各的门面,底下是同一套台账 —— 定义工作的、看住工作的、干工作的,各看各的屏。

+ + + + + + 设计中心/design + 蓝图 · 触发器 · 流程包活动库 · 工具箱 · 数据域知识库 · 大模型 · 测试台(九区 —— 定义工作怎么做) + 设计人员 + + + + + 运营中心/ops + 驾驶舱 · 工作流 · 任务员工 · 台账 · 系统(六区 —— 看住正在发生的)在途全景,卡点一眼可见 + 管理者 + + + + + 工作台/ + 对话区:AI 交代 + 工作卡工作区:件(报告即表单)待办 · 成果 · 沟通记录 · 消息(员工每天呆的地方) + 员工 + + + + + + + + 上线 + 派单 + 台账与指标实时回流 + + +
+

设计装配式,不是开发式

设计人员在治理台装配流程与活动:挑活动、连节点、配触发器、绑模型与工具 —— + 不写代码。测试台里投一封信就能整流试跑,历次试跑存档可回放,改完当场验。

+

运营全程可观测

每一单在哪个节点、AI 跑到第几步、等谁签字,驾驶舱一屏尽收; + 配置变更、任务流转、台账写入全部实时推送 —— 不用刷新,不看快照。

+

员工像带助理,不像用系统

AI 把活干到只剩「该人拍板的那一下」再来找你:一轮一句交代进展, + 该办的事变成一张卡,该看的件铺满工作区。你面对的是一个汇报有条理的同事,不是一堆待填表单。

+
+ 03 / 14 +
+ + +
+ 04 · 组织方式 +

工作流 → 活动 → 单,三层各管各的

+

工作流定顺序,活动定做法,单是一次真实发生 —— 三层解耦,才谈得上各自演化、互不牵连。

+ + + + 工作流(流程包) + 节点 + 边;边只表达顺序与条件,数据按字段名流动 —— 编排层不掺业务逻辑 + + + + + + + + + + A1A2A3分流A4A6A7 + + + + 活动 —— 完成一项任务的 Agent 机制 + 案例模型(每步剧本) + 说明书 + 判定表 + 台账声明系统外开发,像装 App 一样装进系统,升版本迭代 + 安装即用 · 可替换 + + + + + + TY-2026-018 · 询价受理 + 一次触发起一单 · 起单宽进:门口不拒缺项 + 推进中 + 等你签字 + + 单(任务) + +
+

治理版本有纪律

一条流恰有一个生效版本;新版本上线,在途的单按启动时的版本跑完,不被中途换规则。 + 活动改了内容必须升版本号 —— 同版重装无效,升级永远是显式动作,可追溯、可回退。

+

宽进门口不拒,过程补齐

触发一到就起单 —— 缺字段不挡在门外(挡了就是把例外赶回线下)。 + 缺什么由活动在过程里向人开口补,严出由放行门把住:该有的没有,签不了字。

+

价值长尾业务也养得起

新业务 = 装一个新活动 + 在流程包里连上节点,不动基座一行代码。 + 流程系统最养不起的长尾场景,在这里变成「多装一个包」的边际成本。

+
+ 04 / 14 +
+ + +
+ 05 · 解耦机制 +

活动是安装进来的,声明就是授权

+

系统(基座)与活动之间只有一条通道:安装。装进来的是一份「声明 + 剧本」,基座照声明给它一个能跑的世界 —— 没声明的,一律办不成。

+ + + + 系统基座(不动的部分) + + 编排引擎 —— 逐拍推进 · 纪律把关 + 台账基座 —— 草稿层 / 正式版两层账 + 人环 —— 四类工作卡 · 工作区渲件 + 门与闸 —— 判据 · 提交闸 · 放行门 + 治理台 —— 装载 · 版本 · 授权 + + 对每个活动,基座只做一件事: + 照它的声明,给它一个能跑的世界 + + + 安装通道(唯一入口) + 读产物文件装入 · 改内容必升版本 + + + 活动包(系统外开发) + quote-intake@49 + + 案例模型每个计划项一条剧本 + 活动说明书AI 的作业规程 + 判定表 DMN确定的判断查表出 + 台账声明写哪几张表 = 授权面 + HTML 报告模版件 · 表单 · 归档件一体 + 出口与谓词去向按数据算,不靠手感 + + + +
+

写权台账声明

活动声明自己要写哪几张表、哪些列、哪些必填 —— 声明之外的表一个字都写不进。 + 列声明还顺带驱动界面:必填红、建议橘、自增列不问人,全从同一份声明长出来。

+

调权 / 发权工具与出口声明

能调哪些工具、能向哪几个出口发数据、载荷什么形状 —— 全部声明制。 + AI 在运行期结构上不可能越权:它想调一个没声明的工具,引擎连这个选项都不给它。

+

产业含义活动是可交易的能力单元

活动在系统外开发、测试、打包 —— 意味着能力可以由不同团队并行生产、按版本分发。 + 基座越用越稳,业务能力靠「装活动」长出来,这是「操作系统」三个字的分量。

+
+ 05 / 14 +
+ + +
+ 06 · 运行机制 +

一单的一生:门前的都改得动,门后的都算数

+

所有改动先落草稿层;人签字放行那一刻才合并进正式版、才对外发出 —— 放行之前,世界上什么都没有发生。

+ + + + + + + + 来件入站沟通记录归档 + 起单宽进不拒缺 + AI 逐拍办事写草稿 · 全留痕 + 人环 · 工作卡该问人的问人 + + + 提交门 + 判据逐条过 + + + 放行人签字 + 责任在此,清清楚楚 + + 发射出口载荷 = 验收过的成果 + + + 门前 · 都改得动门后 · 都算数 + + + 草稿层AI 写的、人在件上改的、撤掉的行 —— 全在这层,随时可改可撤 + AI 写入 + 人改两格 + 撤掉一行 + + 正式版组织的事实。只有放行那一刻的合并能写进来;下游读到的永远是这层 + + 合并(放行那一刻) + + +
+

类比Git 的工作方式,搬进业务

草稿层之于正式版,如同分支之于主干:AI 和人都在「分支」上放开干、随时改、随时撤; + 合入主干只有一条路 —— 人签字。写错一行有退路(撤的只是草稿,正式版一个字不动)。

+

放行那一刻四件事原子发生

草稿合并进正式版 → 出口按谓词求值、命中哪口发哪口 → 载荷带走验收过的成果 → + 结构化留痕落档。签完那一屏直接回答「这一签下去发生了什么」:进了哪几张表各几行、转去了哪儿。

+

止损面错误出不了门

AI 抽错一格、多写一行、编了一个数 —— 最坏情况也只是草稿层的一笔脏数据, + 被红带点名、被闸拦住、被人改掉。对外发出、写进正式账的,必然是人过目签字之后的版本。

+
+ 06 / 14 +
+ + +
+ 07 · AI 的缰绳 +

AI 怎么被管住:一拍一串行动,纪律引擎当场把关

+

AI 不是放出去自由发挥 —— 每一拍只看该看的局面,发出的每个行动都过纪律引擎;判断归判定表,AI 只做编排。

+
+ + + + 一拍 + 1.5 - 4.9 秒 + + + + + + + + 局面只摊当前这一步该看的 + 模型决策编排,不裁断 + 行动串(至多 6 个)记账 · 查表 · 调工具 · 发卡 + 纪律引擎非法行动当场拒回 + + +
+

局面上下文按状态组织

+

每拍只摊开当前这一步的做法与该用的东西,其余步骤各留一行;台账读数、剧本、轨迹、可用工具全在局面里。 + AI 不靠记忆干活 —— 它看到的就是此刻的真实世界。

+

行动十四种,各有边界

+

记账、查判定表、调工具、发四类卡、宣告完成、提交放行、撤草稿…… 每种行动引擎都有一套合法性检查; + 一拍最多六个、被拒即停在被拒处(前缀生效),不会带病往下跑。

+

自动收步与熔断

+

每步剧本带「办成了没有」的判据 —— 数据到位引擎自己收步翻页,不再问模型,省拍省钱; + 触达世界的步骤例外(不给引擎替人签字的钥匙)。连续被拒五拍熔断交人,调人有预算 —— 不空转、不越权、不无限烧。

+
+
+
+

纪律一机检只读系统数出来的数

判据要么由基座从台账现算(缺几格、写了几行、打了几个标),要么交人裁量。 + AI 自报的数不当判据 —— 否则它总有一天会学会「把数改对」。

+

纪律二拒回的话是行动指南

拦下一个错误动作时,连着告诉它:差的是数还是行、差在哪一行哪一格、哪条路能走通。 + 指错方向比不给指引更糟 —— 这是我们在实测里反复验证过的工程铁律。

+

纪律三全程留痕,三色日志

每一拍谁干了什么 —— 负责人绿、智能体蓝、系统灰,一单一本工作日志; + 对话区只留一轮一句的交代,过程细节沉在日志里,随时回放,永不失忆。

+
+ 07 / 14 +
+ + +
+ 08 · 门的构造 +

放行门里有什么:五层判据,各归其位

+

「能不能放行」不是一个感觉,是一摞可逐条核对的判据 —— 机器判得了的机器判,判不了的交人,责任那一层永远留给签字的人。

+
+
L1机检必填齐 —— 型号 / 年用量一格不缺基座现算:缺几格、缺在哪一行哪一列
+
L2机检账面守恒 —— 每条都打了标、行数对得上、该查的工具真查过基座读数两边对账,自报无效
+
L3裁量质的判断 —— 拿不准的分类是否已按规则降级处理AI 裁,裁断与理由随卡交给人
+
L4裁量缺口处置 —— 选填缺口已补齐,或由你确认可以先受理带谓词:数据已齐则自动达标,否则才轮到裁量
+
L5人放行 —— 这一签本身就是这道关不列进判据表:那张卡就是 L5
+
+
+ 达标 —— 机器核过,绿 + AI 判为不满足 · 交你定 —— 琥珀,理由附在旁边 + 未达标 —— 红,差什么直说 +
+
+

三态「裁了但不满足」不等于「没裁」

判据在卡面上分三态呈现。AI 判为不满足的那条不假装绿、也不冒充红 —— + 连同理由一起摆到签字人面前,由人定夺。裁量项裁过即可提交,不逼 AI 把裁断翻供成「满足」。

+

可信判据必须指向一个可做的动作

每条判据背后都有一条能走的路:缺数去补、多行去撤、没办的回去办。 + 我们删掉过一批「恒不可满足」和「给自己盖章」式的判据 —— 门是用来把关的,不是用来表演严格的。

+

对老板的含义审计级的放行记录

每一单放行都留着:当时哪几条判据什么状态、AI 的裁断与理由是什么、人最终怎么定的。 + 合规回查时,这就是一份逐条可回放的决策档案。

+
+ 08 / 14 +
+ + +
+ 09 · 人的位置 +

人只在该出现的地方出现,出现时体验是对的

+

人的介入收敛成四类工作卡;要看要改的内容是一份件 —— 看是报告,按「编辑」就地改,办结即归档,三个身份同一份 HTML。

+
+ 执行工单 —— 把活干了:指导书说清缺什么,填报全在件上,零输入框卡片 + 验收工单 —— 把关签字:判据三态 + 就地可改 + 落地结果回执 + 决策卡 —— 岔路口拍板:至少两个可点的选项,不让人对着空表单猜 + 录入卡 —— 建档确认:一卡两段(公司 + 联系人),自增编号不问人 +
+ + + + + + 员工工作台 + + 对话区 + + AI:三颗料归一完毕,两格必填缺着 + + 执行工单补齐必填 · 建议追问目标价 + 查看工单 + + 卡上只有整单级:判据 · 指导书 · 提交钮 + + 工作区 · 件(报告即表单) + 询价受理需求 · TY-2026-018 + 还差 2 格必填没补 —— 第 2 行·年用量 / 第 3 行·型号 + + SS8050  三极管  20,000  长电 + TL072CDR 运放  (必填)  TI + NE555P  定时器 12,000  TI + + 已锁定 —— 要改按「编辑」 + 编辑 + + 点卡,跳到件 + +
+

分工逐行数据归件,整单级归卡

编辑在件(表格就地改、能增删行、红带点名缺口),提交在卡(判据 + 指导书 + 那一枚提交钮)。 + 两个区各说各的话,不再有第二套表单 —— 人填的每一格都落在它将来归档的位置上。

+

兜错手改是兜 AI 的错的那一手

改格子、撤掉不该在的行、补漏掉的行 —— 三类错都有出口;删是挂记号不当场抹掉,看得见、可后悔。 + 保存只落草稿层,保存不等于放行;必填还空着,提交钮按不下去,闸上直说差在哪。

+

克制该问人的问人,答不上来的不摆

卡面摆表的人读名不摆库表英文名;系统能生成的编号不让人填; + AI 已办完的不再请示。人被打扰的每一次,都是真正需要人的那一次。

+
+ 09 / 14 +
+ + +
+ 10 · 信任工程 +

界面不撒谎:一个数,三处呈现,同一个源

+

「还缺几格必填」由基座从台账现算 —— 件上的红带、卡上的提交闸、放行门的机检,读的是同一个数、同一套算法。没有两套口径,就没有扯皮。

+ + + + 基座读数 · 缺必填 = 2 + 从台账现算,不由 AI 自报 + + + + + + + + 件上 · 缺项红带 + + 还差 2 格必填 —— 差在哪行哪列 + + 卡上 · 提交闸 + 提交(按不下) + 缺着就拦,补齐自动开 + + 放行门 · 机检判据 + 必填齐(型号 / 年用量) + 未达标 + + +
+

如实状态、错误、能力都不装

模型没绑密钥就写「不可用」,不摆假数字;外部系统报错,把厂商原话带给人看; + 附件读不出文本,就写「读不出」—— 宁可难看,不可失真。

+

实时屏上的永远是现在

任务推进、台账写入、配置变更,全部事件推送实时刷新;别处销毁的单不会在你屏上留鬼影; + 连「签完字到落定之间」的中间态都有自己的话:「字已签下,正在落地」。

+

不冒充算不出就空着

去向没算出来,判定表一行都不高亮 —— 不冒充判过;没有数据来源的图表不画 —— + 画六根编出来的柱子是撒谎。报告只呈现,不计算、不判断,判断永远发生在有留痕的地方。

+
+ 10 / 14 +
+ + +
+ 11 · 跑起来的样子 +

实例:统一报价工作流,七节点全链

+

客户一封询价邮件进来,到报价单发出、情报归档 —— 六个活动包 + 一道人工审批;每个分岔的去向都由出口谓词按数据现算。

+ + + + + 客户 RFQ + A1 询价受理归一 · 三分类 + 分流 + A2 替代选型FAE 选替代 · 判稳 + A3 渠道寻源圈渠道 · 拿货源价 + A4 成本定价利润模型 · 底价门禁 + + + A5 定价审批 · 人 + A6 报价议价发报价函 · 让步梯度 + A7 情报归档输赢归因 · 竞对价 + + + + + + + + + + + + + + + + + 有 C 类 · 需替代 + 无 C 有 B + 全 A · 直通 + 判稳仍有 B + 判稳全转 A · 免寻源 + 批下来 + 再议 · 回核价重批 + 成交 / 未成 + + +
+

真实形状来件千奇百怪,照单全收

常备案例真不相同:俄文 BOM 附件、中文正文列料无附件、客户点名「这颗本轮跳过」—— + 全部端到端跑通。宽进的意义就在这:业务的真实形状不需要先被系统「格式化」。

+

交接随令牌流向下游的只有验收过的成果

上游发什么、下游要什么,契约写死;每一步交接的载荷都是人签过字的正式数据。 + A1 不替 A2 摆输入,A2 进场自己归一 —— 同一份事实只存一份,不分叉。

+

去向算出来的,不是点出来的

「转替代还是转寻源」由分类计数按谓词现算,在放行那一刻与发口同一次判定; + 要改去向,改的是清单里料号的分类,去向跟着变 —— 决策有据,回头可核。

+
+ 11 / 14 +
+ + +
+ 12 · 质量工程 +

可靠性不是许愿,是一条流水线

+

AI 系统最怕「演示能跑,生产翻车」。我们把可靠性拆成三道防线:门禁拦代码,测试台拦设计,验收口径拦侥幸。

+ + + + + + + + 类型 + 规范静态检查零豁免 + 依赖边界模块分层不许穿 + 契约入库接口变更必显式登记 + 千余条测试含活动包内自测 + 五道全绿才合入以退出码验,不许口头绿 + + +
+

测试台设计中心里的风洞

投一封信就能试跑整条流或单个活动;逐拍轨迹、台账草稿、发出的人卡全存档, + 可回放可对比。设计人员改完配置,当场吹一遍风洞再上线。

+

验收口径released + 被拒 0

每个活动配三条形状不同的常备案例,标准是双硬指标:全部跑到放行收尾,且 AI 行动被拒 0 次 —— + 每一次被拒都被当成设计欠账修掉,而不是靠重试蒙混。这个口径逼着系统把「话与门」对齐。

+

基线动共享面,全量回归

A1 是不可破的基线(连跑 10/10 全放行):任何人改了引擎、提示词、渲染层这些共享面, + 全部案例重跑一遍 —— 「稳定的活动突然出错」这类事故,在流程上被堵死。

+
+ 12 / 14 +
+ + +
+ 13 · 成本工程 +

效率不是口号,是一笔一笔抠出来的账

+

三组真实优化数据 —— 每一刀都从逐拍 trace 里挖出来(输入 / 输出 / 推理字数逐拍实证),这个体系的成本曲线是可持续下压的。

+
+
+

每拍时延

+
20 - 56s→1.5 - 4.9s
+

病根:模型的隐藏推理在烧时间。处方:判断归判定表,模型只做编排 —— 关掉思考额度, + 规则写死在说明书与判定表里。快了一个数量级,判断反而更稳(查表不抖)。

+
+
+

一单开销

+
21 拍 · 17.3万 token→7 拍 · 4.9万
+

病根:一拍只许一个行动,每个动作都重烧一遍完整上下文。处方:一拍一串行动(封顶六个, + 纪律引擎保证串内被拒即停)+ 成果已在就自动收步。同一单活,开销降到三分之一以下。

+
+
+

对话密度

+
逐拍刷屏 9 条→一轮一句 5 条
+

病根:AI 每拍都播报,员工被日志淹没。处方:只有交出控制权那一拍才进对话区, + 过程细节沉进工作日志。员工看到的是同事式汇报,细节要查随时能查 —— 体验和成本一起赢。

+
+
+
+

方法先度量,后动刀

每拍的输入 token、输出 token、推理字数、耗时全部落 trace;优化是对着数据找最慢的那一环, + 不是拍脑袋换模型。同样的方法可以持续用在新活动、新模型上。

+

结构贵的能力只花在刀刃上

归一、编排这类临场活交给模型;分级、算价、判稳这类确定活交给判定表(零 token、零延迟、零抖动)。 + 成分拆得越清,单位业务的 AI 成本越低 —— 这是三分法在成本上的红利。

+

底线省钱不省责任

所有优化都在「验收口径不降」的前提下做:全部案例放行、被拒 0、数据逐轮对得上。 + 快和便宜,从来不以「少一道门」为代价。

+
+ 13 / 14 +
+ + +
+ 14 · 现状与下一步 +

不是概念稿 —— 是跑出来的

+

以下全部来自 2026-08 端到端验收记录(测试台真跑 + 浏览器真人走查):

+
+
6 个真活动包全部到验收线(受理 / 替代 / 寻源 / 定价 / 议价 / 归档),经安装通道装入
+
16 条端到端案例全部放行收尾,来件形状各不相同(俄文附件 / 中文正文 / 点名跳料…)
+
0 次末轮 AI 行动被拒 —— 每次被拒都当设计欠账修掉,不靠重试蒙混
+
1.5-4.9s每拍时延(自 20-56s 优化而来);一单 6-11 拍、五万至十万 token
+
10 / 10A1 连跑十轮全部放行、零被拒 —— 稳定性有基线,动共享面必全量回归
+
5 道工程门禁(类型 / 规范 / 依赖 / 契约 / 千余条测试)全绿才许合入
+
+
+ 下一步:七节点全流程连跑常态化 + 运营度量与评价体系 + 活动包生态扩展 +
+

一句话收尾:确定交系统,临场交 AI,落地交人 —— 效率给机器,责任给人。

+ 14 / 14 +
+ + + diff --git a/docs/活动开发规范.md b/docs/活动开发规范.md index 603f4d9..28b4d9f 100644 --- a/docs/活动开发规范.md +++ b/docs/活动开发规范.md @@ -152,8 +152,8 @@ activities//src/ ──(pnpm activities:build)──> dist/{manifest.json,in | 门 | 在哪 | 规则 | |---|---|---| -| **起单宽进** | `process.start` | 不按 Schema 拒缺项;缺 required 顶层键 → 发补全卡(不占预算)。「没法填」给理由即带缺口开跑 | -| **执行卡提交闸** | 卡面 `gate` + 服务端 | 卡上绑了台账表的执行单,必填缺着按不下提交(`{ready,why,blanks}`,与放行门 L1 **同一个数同一套算法**);**服务端在收下回传之前也把一遍**(前端灰按钮只是给人看的)。算不出来(卡没绑表)不给闸 —— 没有依据的时候不拦人 | +| **起单宽进** | `process.start` | 不按 Schema 拒缺项。三活动 `input.required` 一律空(宽进严出),起单补全卡那条老路实际不再触发(彻底移除记 debt) | +| **执行卡提交闸** | 卡面 `gate` + 服务端 | 必填缺着按不下提交(`{ready,why,blanks}`,与放行门 L1 **同一个数同一套算法**);**服务端在收下回传之前也把一遍**(前端灰按钮只是给人看的)。看哪几张表:卡上绑了 blocks 看绑的;**没绑(执行卡零输入后是常态)回落到必交付物声明的 `ledgers`/`ledger`**(`gateAddressesOf`,有测钉着)。两边都空才不给闸 —— 没有依据的时候不拦人 | | **提交门**(`submit` 预检) | engines | 机检(L1/L2)**必须为真**;裁量(L3/L4)**裁过即可**(裁成「不满足」照样能提交,裁断连理由随卡交给人 —— 一条只接受「是」的判据是一句教模型说谎的提示);**L4 带谓词时谓词说了算**(谓词真 = 已达标,模型那句相反的理由一并丢掉;谓词假才轮到裁量);必交付物按 ledger 地址数本单草稿行 | | **放行门入场哨兵** | CMMN sentry | **必须有 ifPart,且读基座数、认「办完了没有」** —— 只有 onPart 的哨兵等于「有人写过一笔就开门」,模型写个 `false` 就推开了(实测:跳过三步直达放行门) | | **熔断** | Harness | 连续 5 拍行动被拒 → 停问模型,发熔断卡把拒绝理由摆给人(比对削掉串位前缀、另开 60 条窗) | @@ -165,13 +165,13 @@ activities//src/ ──(pnpm activities:build)──> dist/{manifest.json,in - 已收掉的项按状态分开说(「挂到当下这一步」+ 列出可为的项),别说「先 start」—— start 一个 Completed 项同样被拒,两堵墙对着撞。 -### 3.4 人类工具:五类卡 +### 3.4 人类工具:四类卡(补全卡已退役) | 类 | 行动 | 形态与硬规矩 | |---|---|---| -| **执行** | `human` + `workOrder:'execution'` | 卡 = 查看工单钮(四字粗体,与验收卡同形)+ ask + **作业指导书**(`steps`+`standard`,与判据同一套版面、不画对勾)+ 依据 + 表单 + 提交闸。指导书写「哪几格必须有、哪几格值得追」,不写「怎么核表」。**`steps`/`standard` 是纯文本** —— 别写 `**` 这类记号,它们不会变粗体,只会原样摆在人眼前 | +| **执行** | `human` + `workOrder:'execution'` | 卡 = 查看工单钮(四字粗体,与验收卡同形)+ ask + **作业指导书**(`steps`+`standard`,与判据同一套版面、不画对勾)+ 依据 + 提交闸。**零输入**(2026-08-02 用户定):卡上没有任何填写 —— 不摆 `fields`、不绑 `blocks`,填报全在工作区的件上(人按「保存」基座直写草稿层,**别再 write 一遍**);空表单是常态,基座照发(从前的「表单不得为空」拒发已删)。指导书写「哪几格必须有、哪几格值得追」,不写「怎么核表」。**`steps`/`standard` 是纯文本** —— 别写 `**` 这类记号,它们不会变粗体,只会原样摆在人眼前 | | **验收** | `submit` | 判据表(读时现算 —— 人在件上补一格判据跟着变;**L5 不上卡**:那张卡本身就是那道关,列进去等于摆一条恒红的项)+ 落地动作 + 可修订值 + 出口(算得出摆 `routedPort` 告知,算不出退 `portChoice` 让人选)。**办结之后换成落地结果**(landing):按交付物点名落了几行、转出哪一口、带过去哪些字段;一个口都没发直说「到此为止」 | -| **补全** | `ask` | 对话卡就地填;`fields` 空即拒;能预填的带 `value` 预填 | +| ~~补全~~ | ~~`ask`~~ | **已全局退役**(2026-08-02 用户定:「执行卡完全就是包含补全语义的人类工具」)—— 缺信息发**零输入执行工单**:指导书写清缺什么,人在工作区的件上补(保存即落草稿层)。旧输出里的 `ask` 由基座原地转成执行工单(归一不拒);引擎侧直发 `ask` 拒并指路 | | **决策** | `decide` | 选择题(≥2 个选项,少了拒发);人点哪个进轨迹 | | **录入** | `entry` | **人按下确认那一刻基座直接写库,AI 不经手** —— 建档这类主数据用它。可多段(`also`):建客户 = 公司档 + 联系人档,一张卡两段、确认一次落两行(值按 `段号.列名` 隔开)。卡面摆表的**人读名**(不摆 `customer.profile`);`auto` 列不进表单、另摆一句说明 | @@ -179,8 +179,9 @@ activities//src/ ──(pnpm activities:build)──> dist/{manifest.json,in - **一单同一张卡只发一次**。人交回来之后不论还缺什么都不许再发 —— 必填有提交闸兜着(卡回来时必填一定齐);**人留空交回来 = 对方给不了,那也是一个答案**: 如实记账、缺口照实留着、把这一步收掉往下走。 -- 发卡即挂起,回传即续跑;在途验收先于一切;人答的值出现在下一拍轨迹里, - **看到了要自己 write 落台账**(绑台账的表除外 —— 基座直写)。 +- 发卡即挂起,回传即续跑;在途验收先于一切。**值怎么落**:件上保存的、录入卡确认的, + 基座直写;执行卡零输入(空答复 = 人照原样确认,痕上写明);决策卡选项进轨迹 —— + 该记账的记账,**别当没答过再发一张**。 - 调人预算(`humanCallBudget`)环外扣账。 ### 3.5 视图渲染(概要,详见《HTML报告生成规范》) @@ -229,17 +230,17 @@ activities//src/ ──(pnpm activities:build)──> dist/{manifest.json,in | 行动 | 形状 | 语义 | |---|---|---| -| `start` | `{itemId}` | 启动标了人工激活的项;**已 Active / 已收掉再按 = 无事发生** | +| `start` | `{itemId}` | 显式启动一项(**可省**:2026-08-02 起动它就算启动);已 Active / 已收掉再按 = 无事发生 | | `plan` | `{itemId}` | 裁量项入场 | | `disable` | `{itemId, reason}` | 此单不走这条 | | `judge` | `{criterion, satisfied, rationale}` | 裁语义性前提 / L3-L4 判据;criterion 照判据名**整句原样抄** | | `write` | `{itemId, address, rows:[{key,patch}]}` | 写草稿。授权面是台账声明;`itemId` 只是记账,挂不上照写不拦;patch 只带有值的列;空转写(一行没变)即拒 | | `drop` | `{itemId, address, keys:[{主键}], reason}` | 撤本单草稿层的行;主键与写进去时一字不差 | | `tool` | `{itemId, tool, input}` | 读类即调;写类转意向;未命中转人挂起 | -| `ask` | `{itemId, ask, fields:[{key,label,hint?,value?}], basis}` | 补全卡,挂起 | +| `ask` | ~~已退役~~ | 基座原地转成零输入执行工单(字段名并进指导书);别再用 | | `entry` | `{itemId, ask, ledger, entryKey, fields, also?}` | 数据录入卡,挂起;确认那一下基座直写 | | `decide` | `{itemId, ask, options(≥2), basis}` | 决策卡,挂起 | -| `human` | `{itemId, workOrder, card{title,ask,steps?,standard?,basis,fields?,blocks?}}` | 执行/验收工单,挂起。`blocks` 里绑 `ledger`+`fields` 的表**由基座铺行、由基座写回**,别自己抄行(回复有长度上限,铺长了截断整拍作废) | +| `human` | `{itemId, workOrder, card{title,ask,steps?,standard?,basis}}` | 执行/验收工单,挂起。**执行卡零输入**(2026-08-02 用户定):不摆 `fields` 不绑 `blocks`,填写全在工作区的件上(editGate + 孪生件),人按「保存」基座直写草稿层;必填由提交闸看住(闸回落到必交付物的台账声明) | | `listen` | `{itemId, eventType, keyFilter, deadline}` | 立监听,挂起;时限必配 | | `finish` | `{itemId, rationale}` | 宣告一项完成;非人工激活的项就地过 Active 再收 | | `submit` | `{rationale, fields?}` | 提交放行 → 预检 → 发验收卡挂起 | @@ -248,16 +249,20 @@ activities//src/ ──(pnpm activities:build)──> dist/{manifest.json,in `tool` 之后截断(出参没看见,后面是闭着眼计划的);挂起型收拍;超封顶丢弃留痕。 拍级附带 `say{text, tag, view?}`,不占行动位。 -### 4.3 人工激活与 start(话与门同宽) +### 4.3 人工激活:动它就算启动(start 墙已拆) -标了 `manualActivationRule` 的项,**动它之前必须先 `start`** —— 调工具、发卡 -(ask/human/decide/entry)、宣告完成(finish)都算,一拍里 start 排在那个动作前面即可。 -只有 `write` 例外(itemId 只是记账)。 +标了 `manualActivationRule` 的项,**模型显式动它(调工具/发卡/finish)就算启动**, +不必单发 `start`(2026-08-02 拆墙)。它的分量在引擎那一侧,原样保留: +**引擎不因哨兵满足替它启动**(哨兵只推到 Available/Enabled), +**也不给 doneWhen 替人收**(自动收步跳过人工激活项)—— 收它只能是模型的 finish。 -实测账(2026-08-02):这条规矩从前在提示词里写成「先 start **再调工具**」—— -只说了调工具,而门拦的是一整片;模型那一拍发的是 `human`,照字面读规矩管不着它, -于是漏了 start 白烧一拍。**话说的范围必须盖住门拦的范围,话窄一格,模型就正好漏在那一格上。** -写全之后三案例复跑全部被拒 0。 +实测账(两轮,同一堵墙):先是「话与门同宽」—— 提示词写「先 start 再调工具」只说了 +调工具,而门拦 tool/ask/human/decide/entry/finish 一整片,模型发 `human` 照字面漏了 +start 白烧一拍;措辞写全之后**仍然熔断**(A1 一单连拒五拍,五次全是「要先 start」, +提示词与拒回都写着,它就是学不稳)。这才看清:那道墙保护不了任何东西 —— +start 是**模型自己的**动作,同一串连发即过,拦下只是逼它多演一步礼仪, +而学不稳的礼仪就是死法。**状态机的礼仪不该长成墙**(与 finish 就地过 Active、 +start 已收项无事发生同族)。剧本里也别再教 start —— 教它白烧 token。 ### 4.4 自动收步(doneWhen) @@ -431,6 +436,33 @@ A1 是 `customerEmail / subject / body / attachments`)。 --- +## 6.5 设计律(2026-08-02/03 一整轮实测沉淀 —— 每条都付过学费) + +排障表治的是「这一处坏了」;这几条治的是「为什么总在坏」。改 Harness、写活动、 +做报告之前先过一遍: + +1. **墙要配路**:任何一道门拦住一件事时,必须存在一个模型(或人)做得出的动作 + 把事办对 —— 没有出路的门只是死胡同(drop 之于「多写一行」、撤行之于「替代未果」)。 +2. **学不稳的礼仪就是死法**:纯礼仪的墙一律拆(start 墙、空表单拒发)。 + 模型十次里学会九次不算学会 —— 第十次熔断的是用户的单。幂等优于拒回: + 按钮按两下不该报错(start / finish / 重发已答卡,同族三例)。 +3. **拒回喂对抗,观察喂事实**:拒回的话是模型下一拍的行动指南,指错方向比不给更糟; + 头几次犯浑先转成 observation 指路(卡已答过→接着办交回后的事),连犯才算撞墙进熔断。 +4. **「机检只读基座数」管的是一整片**:验收判据、收步判据、放行哨兵、**入场哨兵**、 + 提交闸、红带 —— 任何一处读模型自报的数,那道门就等一个可能永远不来的记账 + (第四次违反藏在 s_parsed 哨兵里:行落了门却不开,模型被「依赖断点」推去发卡绕路)。 +5. **提示词分层**:全局提示词只说通用真理,活动级细节归说明书 —— 一句全局禁令 + (「别再 write 一遍」)能把所有活动的既有约定顶翻,且症状出在别的活动身上, + 查起来像闹鬼。改全局面 = 改所有活动,回归也要按「所有活动 × 全部常备案例」跑。 +6. **措辞治不了的要结构治**:模型「不查工具直接编结论」这类顽疾,说明书写死到头 + 仍概率性犯 —— 根治靠机制(规则进判定表由引擎求值 / 工具出参直接带结论只让照抄), + 措辞只能降频。一条纪律连续两轮被同一形状踩穿,就该停止改话、开始改结构。 +7. **改共享面必须全量回归**:提示词、turn-runner、解析器、容器 —— 动一处, + 三活动的全部常备案例都要重跑(A1 新客户路的熔断就是「只回归了老客户路」漏出来的)。 + 「稳定的活动出错」比新功能不 work 伤得多。 +8. **用户重复提的要求,说明上一轮没打中病根**:往下再挖一层,别在同一层再修一遍 + (「工作成果 1:1」挖了三层才到「办结那一刻存快照」这个真解)。 + ## 7. 排障速查表(症状 → 根因 → 处置;全部实测账) | 症状 | 根因 | 处置 | @@ -445,7 +477,15 @@ A1 是 `customerEmail / subject / body / attachments`)。 | 同一条理由连拒到熔断而数与行都对 | 拒回把「活没干完」说成「数/行不对」 | 已修:`_rows`=0 直说那一步没办 | | 行数对不上,模型只会改数字 | 拒回没摆主键名单 | 已修:`_keys` 名单;照名单 drop | | 「要先 start」而 start 也被拒 | 拒回没按状态分开说 | 已修;start 已收的项无事发生 | -| 模型漏 start 白烧一拍 | 提示词的话比门窄(只说了调工具) | 已修:话与门同宽(§4.3) | +| 「要先 start」连拒五拍熔断(措辞已写全) | start 墙本身:学不稳的礼仪就是死法 | 已拆墙(2026-08-02):动它就算启动(§4.3) | +| 零输入执行卡被「表单不得为空」拒发到熔断 | 基座旧机检把表单当交付通道 | 已删该拒发:空表单是常态(卡族表「执行」行) | +| 整拍六个动作全对却退化成「需要你判断下一步」 | 模型多吐一个闭括号,单数花括号的配平切出半截 | 已修:括号栈 + 丢不配对闭括号(`parseJsonLoose`,有测) | +| 「需要你判断下一步」卡面摆着一坨 JSON 原话 | 整拍输出没成形,旧兜底一次就发卡还把原话上卡 | 已修(2026-08-03):就地重问一次、原话归工作日志、卡面只说人话(agent-runner,有测) | +| 模型逐字复读一句被明令禁止的话 | **反例把原话引进了提示词**(「别说『…』」)—— 关思考的模型不读否定框,只认框里现成的句子 | 反例只描述、不引原话;要引原话就引**正例**(想听哪句就示范哪句)。A1「照历史价估」逮过(2026-08-03) | +| 执行卡上作业指导书整个是空的(basis 里是「当下可为(空)/目标缺口」这类家具) | **卡骨架写在那一步的剧本里**,而模型在「干活+收步+派卡」压成的一拍里发了它 —— 那一拍该步剧本没摊开,模型手上没有骨架,凭记忆拼出裸卡,基座兜底 basis 又摆上局面家具 | **凡要模型照抄的卡骨架,放说明书不放步骤剧本**(说明书每拍都在;A6 决策卡先立此法,A1 审定卡 2026-08-03 同坑)。另:`steps`/`standard` 已补进出参 schema —— 从前能到卡面纯靠厂商 JSON 模式宽松 | +| 人答完后同一条拒回重复五遍熔断(「这一步已办完,不能再往它上面挂活」) | **重发判断走在站上场之后** —— 人答完那一步常已收掉,`readyToWork` 先抛,于是「重发已答过的卡 = 无事发生」这条活路在最需要时够不着 | 已修(2026-08-03):**先判重发再站上场**;重发一张已答过的卡本就不需要那一项在场(墙要配路,有测) | +| 同一个岔路口连发四张决策卡,调人预算烧光落 `exhausted` | 决策卡没有幂等兜底(执行工单一直有) | 已修:`resendVerdict` 两族共用 —— 头两次无事发生、第三次起算被拒 | +| 收上一步时连拒「写了等于没写」 | 值已在台账(件上保存/上一拍写过),模型照抄重写;或提前干了下一步的活(如在 S2 查行情) | 拒回已带指南「该办下一件事」;剧本明写这一拍只有哪几件事 | | 人答了卡,模型又发一张一样的 | 剧本残留「还缺就再发一张卡」死路 | §5.5-5:一单一卡;必填有闸兜着 | | 人在卡上填了值,台账还是空的 | doneWhen 写成「问过就算完」 | 等「答案落了表」的计数(`answered_total`) | | 该经过人的一步整个没发生 | 「经过人」写成了收步条件里的半句 | §5.3-4:独立一项+人工激活 | @@ -466,6 +506,8 @@ A1 是 `customerEmail / subject / body / attachments`)。 | 下游网关取不到值 | 出口标量没有台账列承载 | §5.2-② | | 改了包重装没生效 | 同版重装 no-op | 升 packageVersion | | 收口前一两拍预算耗尽 | budget 给太紧 | §5.2-⑦ 留一倍余量 | +| 代答脚本交执行卡被拒「必填字段未回填:结果」 | 卡没声明 `fields` 时基座兜底加必填「结果」回执字段 | 交卷 `values.result` 带一句 | +| 验收 pass 后落地判「选择组未选出口」 | routedPort 只在前端回传里顶上(已修:服务端发事件前算有效口) | 升级基座;临时:回传自带 `selectedPorts` | ## 8. 待实现与已知不一致 diff --git a/packages/contracts/src/card.ts b/packages/contracts/src/card.ts index f2d29be..95d3fa8 100644 --- a/packages/contracts/src/card.ts +++ b/packages/contracts/src/card.ts @@ -310,6 +310,16 @@ const workOrderBase = { basis: basisSchema, stage: stageIdSchema.optional(), done: workDoneSchema.optional(), + /** + * **办结那一刻的件快照**(2026-08-02 用户:「HTML UI、数据、内容、布局都要 + * 1:1 地从待处理状态转换为静态报告放到工作成果」)。 + * + * 从前工作成果点开渲的是**当下**的任务视图(随后续推进还会变),执行工单的 + * 定稿甚至退回卡壳(题头/编号带/blocks)—— 三样没有一样是人办结时看到的那份。 + * 现在收下回传那一刻,基座把当时渲好的件原样存这里:成果页 1:1 静态重放, + * 数据、内容、布局与人交卷时一个字不差。 + */ + finalHtml: z.string().optional(), }; /** 执行工单:作业指导书 + 空表单(补全并入此类);落地失败卡是它的系统预置形态 */ diff --git a/packages/contracts/src/ledger.ts b/packages/contracts/src/ledger.ts index f468885..0d0a76e 100644 --- a/packages/contracts/src/ledger.ts +++ b/packages/contracts/src/ledger.ts @@ -126,6 +126,19 @@ export const fieldDeclarationSchema = z.object({ * 同一条规矩的另一半:**人答不上来的,也别摆到他面前假装他该答**。 */ auto: z.boolean().optional(), + /** + * **行级必填:同行某列等于某值时,这一列才算必填**(2026-08-02 用户: + * 「工作区的 HTML 必填项必须填写了才能提交啊,不填写能算任务完成了吗?」)。 + * + * 逐颗按序派单(A2 的 sel_stage 队列)撞上表级 `required` 的死结: + * 定案三格若整列必填,第一颗的卡会被**第二颗**(还没轮到、在件上根本不可见) + * 的空格拦死 —— 人无处可填,闸成了死路;整列不标,闸又没牙, + * 空手就能交卷(用户实测点名)。 + * 这一维把「必填」钉在**该轮到的那一行**上:`{ column: 'sel_stage', value: 'NOW' }` + * 即「排到 NOW 的那一行,这一格必填」。`_missing` / `_missing_keys` / + * 提交闸 / 件上红带全部同源生效;`required` 不带它照旧整列必填。 + */ + requiredWhen: z.object({ column: z.string().min(1), value: z.string().min(1) }).optional(), }); export type FieldDeclaration = z.infer; diff --git a/packages/ui-kit/src/ViewContainer.tsx b/packages/ui-kit/src/ViewContainer.tsx index c9d6159..f2e7343 100644 --- a/packages/ui-kit/src/ViewContainer.tsx +++ b/packages/ui-kit/src/ViewContainer.tsx @@ -38,11 +38,117 @@ export type ViewContainerProps = { readonly editable?: boolean; /** 人按下「保存」时把那张表收上来;不传就是纯呈现 */ readonly onEdits?: (save: ViewSave) => void; + /** + * **多页件**(2026-08-02 用户:「一个执行单可以派多个 HTML 待执行报告, + * 合理利用右上角的翻页」):件里带 `data-page` 的可见段各算一页, + * 外层用这两个口翻 —— `page` 显示第几页(0 起),`onPages` 回报共几页。 + * 不传 `page` 或只有一页时全部照常显示。 + */ + readonly page?: number; + readonly onPages?: (total: number) => void; }; /** 量不到高度时的兜底,避免塌成 0 高 */ const FALLBACK_HEIGHT = 420; +/** + * **就地换内容的第二层:只换真变了的节点**(2026-08-03 用户:「保存的时候 HTML 会闪一下」)。 + * + * 2026-08-02 那一刀把 srcDoc 重载换成了 `body.innerHTML = html` —— 文档不再重载, + * 可整棵 DOM 仍是**推倒重建**:47KB 样式表在 body 里再解析一遍(head 那份还在,从此一文档 + * 两份同样的表)、二十来个 SVG 全部重建、CSS 动画一律回到第 0 帧重新起跳 —— 人眼里就是 + * 保存那一下整张件「闪一下」。同一个症状,病根在下一层。 + * + * 治法:新旧两棵树**逐节点对齐**,一样的一根手指都不碰 —— 样式表不重解析、精灵不重建、 + * 没变的段落连重绘都没有;保存的涟漪只落在真改了的那几个格子上。 + * + * 细则: + * · 顶层 `