Archived
Compare commits
12
Commits
43aecde9da
...
f77c6325fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f77c6325fe | ||
|
|
a40fc1a459 | ||
|
|
7b85a79323 | ||
|
|
909b1fe4b7 | ||
|
|
0923fbfe4d | ||
|
|
75553371b0 | ||
|
|
94e66d5a58 | ||
|
|
91458219ee | ||
|
|
b98ec75a89 | ||
|
|
ffa1ef1076 | ||
|
|
fa27cef60e | ||
|
|
51c87af583 |
@@ -1388,6 +1388,200 @@
|
|||||||
e2e 脚本加 `JCAI_E2E_BEARER` 直供旁路(卡会落到 token 主人的工作台、不续期;
|
e2e 脚本加 `JCAI_E2E_BEARER` 直供旁路(卡会落到 token 主人的工作台、不续期;
|
||||||
服务账号 `client_credentials` 仍是正路)。
|
服务账号 `client_credentials` 仍是正路)。
|
||||||
|
|
||||||
|
- **数据工单确认即入库**(2026-08-27 下午,用户:「数据工单里面填的数据应该是确认这个数据工单后
|
||||||
|
这些数据就应该入库了」):查下来草稿层通往正式表只有整单放行一道口子(`releaseWrite(taskId)`)——
|
||||||
|
W05 的 P-08/P-10 卡上写着「确认后即入库」,而人建好的主数据要等整条评估放行才进表,试跑单
|
||||||
|
(不落主数据)永远不进。**不能简单地「确认时 releaseWrite 那几张表再清草稿」**:引擎的 `mine:` /
|
||||||
|
`_rows` / 哨兵(`parts_profile_rows >= created`)、视图 `data-rows="mine:parts.profile"` 全靠草稿层
|
||||||
|
认「本单写下的行」,清掉就当场断。修法:`releaseWrite(taskId, { only, cause })` **范围放行** ——
|
||||||
|
只并这张卡提交闸盯的那几张表(`gateLedgers` / 绑的 `blocks`,与闸同一份名单),并过的行**留在
|
||||||
|
草稿层打 `landed` 记号**(`__draft.landed_at`):读面一字不变,整单放行不再写它、不抛事件、不做
|
||||||
|
冲突检查(`landedEarlierRows` 分开报);再写一笔记号擦掉、基线换成落地后的值。事件不指回本单
|
||||||
|
(它正等自己的人事件)。app 侧 `data-order-landing.ts` 在回传路由里、保存与回传落定后调它;
|
||||||
|
失败不拦人(痕上说清,放行时再并)。`createOnly` 与 `drop` 对本单已落地的行改口(别 drop ——
|
||||||
|
撤了读面少一行);数据域「在办草稿」不再数已落地的行。活动 1.4.8:两张数据工单的剧本改口
|
||||||
|
「已入正式表,别再写、别 drop」。测:ledger IT 3 条(范围/记号/再写/试跑)+ app 7 条 + 域 1 条 +
|
||||||
|
活动 1 条;e2e w05 数据工单交卷即验痕(`JCAI_E2E_LAND_MASTER=1` 时连正式表一起验)。
|
||||||
|
|
||||||
|
- **验收卡只许有一种样式;验收件题头改验收绿**(2026-08-27 下午,用户:「验收卡怎么有两种样式,
|
||||||
|
统一都使用评审放行与落账确认这一步派出的这个验收卡的样式;中央 html 模版区域如果是验收工单
|
||||||
|
把标题部分的红色改为和验收卡一样的绿色」):①审核卡(决策卡 `variant:'review'`)从前只换了头上
|
||||||
|
的字、身子还是 A/B 选项列表;现在照终局验收卡 `AcceptForm` 那一套摆(`ReviewForm`:请你验收 ·
|
||||||
|
验收工单入口 · 理由框 · [重做] … [确认通过]),机制不变 —— 通过交 approve 项,重做写了理由走
|
||||||
|
`custom:<理由>`(基座按字面点名对行)、没写走驳回项;判断抽成纯函数 `review-face.ts`(7 测)。
|
||||||
|
「验收工单」钮对没有工单行的审核卡也要有反应:`ViewRegion.locate` 找不到那张单就把待处理翻出来
|
||||||
|
(件在那一屏)。②W05 两张验收件(ACC1 差异评估结论审核 / ACC2 评审放行与落账确认)的 `--ledger`
|
||||||
|
由目标件的 #b23a2e 红改成对话区验收卡同一枚 #0aa294(`--st-collab`),题头渐变与徽章投影同改;
|
||||||
|
CONDITIONAL 状态徽标是语义色,保留原红。W05@1.4.9 已装;浏览器走查未做(需登录统一鉴权)。
|
||||||
|
|
||||||
|
- **S1 清单把人刚填的参数摆成缺口:示例引了原话,模型照抄示例**(2026-08-27 下午,用户:「第一步已经
|
||||||
|
补充了 XL2596S-5.0 使能阈值,当前这一步还要填一次?」):人在 P-08 件上填了使能阈值 2.5(已入库),
|
||||||
|
两分钟后 S1 清单上却有一行「XL2596S-5.0 使能阈值 · 原厂未披露」。查下来 S1 剧本写着
|
||||||
|
`input_item 如「XL2596S-5.0 使能阈值」`、说明书⑤卡骨架写着「原厂未披露的参数(如使能阈值)」——
|
||||||
|
模型按示例造行,不读现值(值就在 mine:/focus 里)。与 08-03「反例引了原话,模型照抄反例」同一课:
|
||||||
|
**示例也不引具体料号 + 参数名**。W05@1.4.10:示例改成占位形「<料号> <参数名>」,写明「先读现值,
|
||||||
|
人填了值的参数不是缺口、零行合法」;包内测钉死。顺带看见:压拍时 `focus:` 只带拍首那一步的 reads
|
||||||
|
(S0d_p10 那一拍连做 S1 的写与发卡,S1 声明的 parts.parameter 不在 focus 段,只靠 3000 字的 mine: 段)。
|
||||||
|
|
||||||
|
- **运营中心 · 活动模块接口落地(L1/L2/L3 十一条,全 `ops`)**(2026-08-27,`docs/API文档-活动模块.md` 已按契约回写):
|
||||||
|
契约 `packages/contracts/src/{activity-ops,api/activity-ops}.ts`;服务端住 app 层 `apps/api/src/app/activity-ops/*`
|
||||||
|
(纯函数:启动参数派生 / 五态映射 / 时间线 / 摘要 / 视图注入;raw SQL 存取;服务编排)+ `routes/activities.ts`,
|
||||||
|
写全走既有 port(`task.start` origin=ops / `task.control('terminate')` / `registry.setEnabled`)。
|
||||||
|
**设计端两条改名让位**:`GET/POST /api/gov/activities` → `/api/gov/activity-packages`(前端零改动,import 的是路由对象)。
|
||||||
|
迁移 0034(`sys.task.seq` 人读实例号 `INS-<seq>` + 按活动索引)/ 0035(`sys.activity_launch` 幂等键一句 SQL 判占、
|
||||||
|
`sys.activity_profile` 负责人/说明/图标/色相,seed 按岗位填三个真包,取不到留空)。
|
||||||
|
五态是对 task 的如实映射(`pending` = 建单未被推进器领到;`rejected` = 打回未重提交那一段)。
|
||||||
|
W05@1.5.0:四个启动字段加 `x-control`/`x-options`/`description`(combo 候选从正式台账现读),
|
||||||
|
全流程分支图作 `activity-overview` 沙箱视图(模版无脚本,报高脚本由出口注入,协议 `jcai:view-height`)。
|
||||||
|
前端 §10 对接另起一轮。测试:契约 7 + app 层 33 + W05 包内 +1。
|
||||||
|
|
||||||
|
- **差异评估结论审核件改版:一个候选一张表**(2026-08-27 傍晚,用户改了目标件
|
||||||
|
`docs/SS02-ACT-WORK-05-html/ACT-WORK-05-验收工单-01-差异评估结论审核.html`:「存在多个替代 PN 要对比
|
||||||
|
差异时看不清楚,现在将每个待选 PN 分开」):渲染器从前只认一层 `data-rows`,做不出「外层按候选复制、
|
||||||
|
里层只列它名下的明细」。补**嵌套行模版 + `data-by="子列[=父列]"` 父键过滤**(`view-render.ts`,里层先按
|
||||||
|
本行展开再填本行标量;4 测),它也能当查表用 —— 品牌不在 relation 上,`<span data-rows="parts.profile"
|
||||||
|
data-by="internal_pn=candidate_internal_pn">` 从整表挑那一颗料的行。件照目标件重搬:候选 PN 结论汇总
|
||||||
|
(一个候选一条;差异统计绑 `difference_summary`,S3 剧本本就让它写成「7 项比对:4 MATCH / …」)+
|
||||||
|
`<article data-rows>` 包 `<section class="cand">` 的分组明细(`<tbody data-by="substitution_relation_id">`);
|
||||||
|
判据四条照目标件改;ACC1_CSS 整段重搬(.cand*/.rec*/.stat*,CONDITIONAL 改蓝),主题色仍取卡的
|
||||||
|
#0aa294(目标件改版自己写的是 #16a34a)。W05@1.4.11。规范 §4 绑定表补 `data-by` 一行。
|
||||||
|
|
||||||
|
- **「库里明明有值,清单还要填一遍」第三次复现:根因不是措辞,是让模型宣布台账事实**(2026-08-27
|
||||||
|
傍晚,用户:「都已经出现几次这个问题了,自己找到根因并解决」):1.4.10 改了措辞照样复现 ——
|
||||||
|
这一单 XL2596S-5.0 已在库、三颗料 21 个参数全有值、说明书已是新版,模型仍写出
|
||||||
|
「使能阈值 · 原厂未披露」并在交代里说「原厂没披露」。三条线索:①S1 的「待确认」行是**模型对参数表
|
||||||
|
的一句断言**,基座没有任何读数核它;②说明书里「使能阈值」与「未披露 / UNKNOWN」同句出现三处,
|
||||||
|
其中一处是我 1.4.10 写的反例原话「使能阈值 · 原厂未披露」(08-03「反例一律不引原话」那课又犯一遍);
|
||||||
|
③S-FEEL 没有列表过滤,想拿判据核「gap 行数 = 未披露参数数」也写不出。修(W05@1.5.1):**S1 一行参数
|
||||||
|
都不写**(item_key 不许以 unknown_ 开头);件上加「候选参数现值」只读块 —— `<article data-rows=
|
||||||
|
"mine:sub_eval.pn_check">` 逐颗复制,里层 `parts.parameter` 按 `data-by="internal_pn"` 现读,人看得见
|
||||||
|
每个参数此刻的值;未披露的由 S3 判 UNKNOWN + 验证任务(本来就是它的活)。说明书全文不再出现
|
||||||
|
「使能阈值」(包内测钉死);S3 示例改「实测<参数名>」。设计律:**台账里有的事实,不许由模型在别处再宣布一遍。**
|
||||||
|
|
||||||
|
- **「你真的改好了吗」—— 改是改好了,库里没有**(2026-08-27 傍晚):新起的单 `loop_state` 里还是旧说明书。
|
||||||
|
查下来两条:①`sys.activity_package` 里 1.5.0 / 1.5.1 两行都是**另一个会话**在同一个 checkout 上
|
||||||
|
改出来的(activity-overview 视图 + 启动表单 x-control),各比我的安装早几分钟占了版本号 ——
|
||||||
|
我两次 `activities:install`(候选分组件 / S1 参数行退役)全是**同版重装 no-op**,脚本还打印
|
||||||
|
「活动包已装」;②`manifestOf` 每次读库,不是服务缓存。修:升 1.5.2 重装并**用 SQL 核库里的清单**
|
||||||
|
(查 JSON 文本别用 `\"` 手写转义 —— 第一次核出个假阴性);`InstallVerdict.stored`(契约,默认 true)
|
||||||
|
+ 安装脚本同版时改口「已是这一版,未重装」。规矩:**装完看库,不看脚本的话;两个会话同改一个包,
|
||||||
|
版本号先问库里最高的是几。**
|
||||||
|
|
||||||
|
- **「验收卡又变成需要你定」「差异明细两张表没渲染出来」—— 同一个根:稳定版后端跑的是早上的代码**
|
||||||
|
(2026-08-27 傍晚):`.stable/RELEASE` 是 `3171d8a`(11:53 构的),早于「审核卡摆成验收工单」那一提交,
|
||||||
|
更早于当天所有改动;它与开发后端(:8787)抢同一个库的租约,抢到哪一拍就用旧代码发那一拍的卡、
|
||||||
|
推那一拍的帧 —— 决策卡没 variant、帧里 `data-rows`/`data-by` 原样漏出(旧渲染器不认嵌套)。
|
||||||
|
修:`./stable.sh update`(稳定版现为 `7555337+未提交改动`,与开发一致);并把审核卡的面孔改成
|
||||||
|
**引擎按剧本定**(`faceOf`:点名的项优先,挂不到就按当下在办的那一步 → `cards.decide.variant`),
|
||||||
|
不再只靠模型给对 itemId(2 测)。排查法:先看 `.stable/RELEASE` 的 commit 与 `lsof -iTCP:8790` 的
|
||||||
|
进程启动时间 —— **两台后端一个库,先问另一台跑的是哪一版。**
|
||||||
|
|
||||||
|
- **「叫了人我都没填你就直接推进了」—— 两道结构闸**(2026-08-27 傍晚,用户:「不能再有叫了人又继续
|
||||||
|
自动推进的问题」):实测账 —— S1 的执行卡 17:48:00.2 发出,0.3 秒后投到的是**上一张(P-10)卡回传的
|
||||||
|
人事件**(回传落库那一刻已 wake、推进器读 card-reply 先跑了那一拍,事件晚 12 秒),`deliver` 照样
|
||||||
|
tick,再一拍模型读到事实行「这一步已经发过卡,人已经被问过了,照『人交回之后』办」,把没人答的 S1
|
||||||
|
收掉。①`task.deliver`:人事件带 `occurredAt`,任务在这条回传之后推进过就只记到达痕(stale)不推;
|
||||||
|
②`runTurn`:`cards.unanswered(taskId)` 非空这一拍不问模型(`human-tool.pendingCards` = 没办结工单 +
|
||||||
|
没作答且没被顶替的对话卡),谁叫醒都推不动 —— 这一条管住所有多余唤醒(另一台后端 / 手动 wake)。
|
||||||
|
5 测(task 3 + turn-runner 2)。两台后端都要装上:dev 自动重载,稳定版 `./stable.sh update`。
|
||||||
|
|
||||||
|
- **审核卡 = 终局验收卡的结构:判据表,不是参考信息**(2026-08-27 傍晚,用户:「给你说了要最后一个
|
||||||
|
评审放行与落账确认这个验收卡的样式和结构,没有参考信息这个东西,变成 L1、L2 这种东西」):
|
||||||
|
`decisionCardSchema.criteria`(可选)—— 任务详情读时对**没作答的 review 卡**现算同一摞判据
|
||||||
|
(`checks.of`,L5 不上卡,过契约 schema 归型),与终局验收卡同一段算法同一份读数;`ReviewForm` 摆
|
||||||
|
`Crit` 判据表 + 头上「n/m 达标」,卡上不再有参考信息 / AI 建议。答过的卡不带判据。
|
||||||
|
|
||||||
|
- **验收卡的条目 = 人工验收清单,人点「达标 / 不达标」,点全才能通过**(2026-08-27 傍晚,用户定,先只做
|
||||||
|
ACT-WORK-05):从前卡上摆的是系统判的判据(L1/L2…)让人签;用户定「不是系统判断的条目,是人要对这个
|
||||||
|
验收工单的内容进行审核需要的步骤,每份验收工单固定」。契约:`PlanStep.acceptanceChecklist`(剧本声明)→
|
||||||
|
`AcceptanceWorkOrder.checklist` / `DecisionCard.checklist`;回传 `checked`(落 `done.checked` /
|
||||||
|
`chosen.checked`);human-tool 回传校验:通过必须 `checked ⊇ checklist`(`checklist-incomplete`),审核卡只拦
|
||||||
|
「通过」选项。引擎:审核卡随面孔带清单(`faceOf`),终局验收卡在 submit 时从剧本取(声明了清单且不是审核步)。
|
||||||
|
前端 `Checklist`:序号格 + 右侧开关(**待确认灰 ↔ 已确认绿**,用户定;不叫「不达标」—— 没点过不是判了不合格),
|
||||||
|
头上「已确认 n/m」,确认通过灰到点全;答过的只读摆当时点的。W05@1.5.3:
|
||||||
|
S4_review 4 条、T_release 5 条(按两份目标件的【验收判据】写成人眼能核的动作)。E04 / I01 未动(用户定先不管)。
|
||||||
|
`decisionCardSchema.criteria`(上一轮加的)撤掉。测:card-rules 2 + turn-runner + review-face + 包内 1。
|
||||||
|
|
||||||
|
- **「标红的填了还是不让推进」:红带点的是「缺输入项」,不是补充内容**(2026-08-27 晚,用户实测):S1 清单
|
||||||
|
11 行全没有 `input_item`(必填的人读名列),模型这一拍只写了 status/detail/origin;件上红带的固定前缀写死
|
||||||
|
「补充内容没填」,人于是把 11 行的补充内容全填成「1」,闸照拦(它拦的是输入项)。病根:剧本只在括号里提过
|
||||||
|
一次「input_item 用人读名」,逐行做法里从没再说 —— 措辞的「从没说过」那一档。修(W05@1.5.5):剧本逐行写死
|
||||||
|
六列与每行的人读名(客户 / 客户需求 / 原 PN / 候选 N / 应用场景 / BOM 位 / 替代类型 / 规则版本 / 负责人期限 /
|
||||||
|
原 PN 资料版本)+ 发卡前自核 `_input_gap_missing = 0`;红带前缀改「必填格」不写死列名;包内测钉死。
|
||||||
|
顺带答用户「这些输入项库里拿不到吗」:输入项只是每行的名字(AI 该写),不是库里的数据;真要人填的只有
|
||||||
|
应用场景 / BOM 位两格 —— 它们不在任何表里(P-01 是本单新建的、P-09 BOM 没建模)。
|
||||||
|
|
||||||
|
- **进度条钉在 1/6 · 重做意见变成「驳回(不写具体意见)」**(2026-08-27 晚,用户实测两条):①`phaseProgress`
|
||||||
|
从前「一段里的项全收完才算过」,都在库时数据工单两项永远 Available,条钉死在第一段;改成「案子走到了哪一段」——
|
||||||
|
最后一个有项动过(Active / Completed / Terminated)的阶段,全收完就进下一段,没收完停那一段;一项没动过才退回
|
||||||
|
老规矩(3 测)。②模型把审核卡骨架里的 `allowCustom` 抄丢,引擎默认 false,前端写了意见的重做只能退回驳回项,
|
||||||
|
卡面摆的是选项原标签;现在审核卡一律收自定义意见(`allowCustom ?? reviewFace`),答过的卡摆**验收的话**
|
||||||
|
(确认通过 / 重做:<意见> / 重做(未写意见)),不摆选项标签。
|
||||||
|
|
||||||
|
- **「评审放行与落账确认」派了两次:先一张执行单,人答完才 submit 又一张验收单**(2026-08-27 晚,用户送来
|
||||||
|
5616e13d 那单):S5 在同一拍里被自动收步后模型接着被问,读到人工激活步的事实行「唯一合法的动作是先发那一张卡
|
||||||
|
(human/entry/decide)或立监听」,就给 T_release 发了一张执行工单;下一拍才 judge + submit。修三处:
|
||||||
|
①`isReleaseStep`(剧本声明了人工验收清单且不是审核步)—— `human` 挂到放行步当场拒并指向 submit;
|
||||||
|
②放行步的事实行只指向 submit(不再套执行步那句);③验收卡题目取案例模型里那一项的名字
|
||||||
|
(「评审放行与落账确认(ACT-GATE-03)」),「收口验收」只是兜底。W05@1.5.6 剧本补一句「不发执行工单」。
|
||||||
|
2 测 + 包内 1 测。
|
||||||
|
|
||||||
|
- **工作成果详情右上角只翻页,不翻份**(2026-08-27 晚,用户:「这个东西不是让你在整个工作成果里面切换,
|
||||||
|
是让你在单个工单有多个页面的情况下切换的」):08-23 加的「翻份」撤掉;右上角那对箭头只在这一份件里翻页
|
||||||
|
(一页也摆 1/1),换一份成果回列表点。
|
||||||
|
|
||||||
|
- **验收卡「又变成老的」:投影按标题里有没有「验收」二字挑面孔**(2026-08-27 晚,用户实测):
|
||||||
|
`chat-projection` 从前拿 `title.includes('验收')` 决定 card-sent 是判据卡(`criterion` → `AcceptForm`)
|
||||||
|
还是执行卡(`guidance` → 通用「派出工单」气泡),而同晚验收卡题目改成了案例模型项名
|
||||||
|
「评审放行与落账确认(ACT-GATE-03)」—— 四个字不在了,人工验收清单整张不见,摆出的是 11 条判据 +
|
||||||
|
「打开报告」。同文件上面几行早写过「标题措辞是给人看的、随时会改」。改认痕里的结构化键 `mode`
|
||||||
|
(发卡时随 payload 入痕),老痕没有它才退回标题。2 测;读时投影,在途单刷新即好;稳定版已更新。
|
||||||
|
|
||||||
|
- **「字已签下,正在落地」停住不动:迟到守卫把放行裁决也丢了**(2026-08-27 晚,用户送来 317c9124):
|
||||||
|
同晚加的「迟到的人事件不再推」(`lastProgressAt > occurredAt` → 只记痕不投)前提是**那一拍能从
|
||||||
|
留痕 / 卡库读到答复**(执行单填报、决策卡选项都如此);而放行裁决只走事件这一条路 —— loop 的
|
||||||
|
`onEvent` 把 verdict 落进在途验收信封。实测账:签字 04.129 落库 → `wake` → 推进器抢先一拍
|
||||||
|
(在途验收没裁决原地返回,但 lastProgressAt 挪到 04.483)→ 事件 05.774 才到 → 判 stale 丢掉,
|
||||||
|
再没有任何事件叫醒它。修:`payload.mode === 'acceptance'` 的人事件不进迟到守卫(认结构化键);
|
||||||
|
1 测。那一单以「删 event_consumption 行 + 投递行翻回 pending」让派发器按新路重投 → released、
|
||||||
|
合并 24 行、发口 assessment-released。**两台后端都装上再重投** —— 稳定版的派发器同样会抢这条。
|
||||||
|
|
||||||
|
- **P-10 的卡对着 P-08 的件:画面跟着这一拍发出去的卡走**(2026-08-27 晚,用户送来 3ef3fc3e:
|
||||||
|
「当前这一步不是应该给我推 P-10 的数据工单吗?怎么还是推送的芯片主数据创建」):模型没收掉
|
||||||
|
S0d_p08_master(剧本两处白纸黑字「先 finish 再发下一张」,措辞治不了)就发了 S0d_p10 的数据工单,
|
||||||
|
两步同时 Active,推帧按「第一步没干完的」取了 P-08 的件 —— 人拿着 P-10 的卡,屏上没有资料表可填,
|
||||||
|
提交闸拦「缺资料」。08-23 那刀(按 currentRuntime 重算)治不了两人环项相邻的形状(debt 早记了)。
|
||||||
|
结构治:`applyAction` 的 human / entry / decide 三条路把 `issuedFor`(卡是哪一步的)交回拍内,
|
||||||
|
推帧时那一步声明了画面就推它,压过别的规则;没发卡的拍照旧。1 测;debt 销账。
|
||||||
|
在途那单以「task_view.template 改成 p10-document-create + `views.repush`」救活(tsx 起容器跑脚本,
|
||||||
|
`-e` 不支持顶层 await,要写成 .mts 文件)。
|
||||||
|
**根治**(用户:「根因修了吗?不要只修表象」):病根是**这一步办完没办完是引擎手里现成的事实
|
||||||
|
(卡答了、数据入库了),却让模型去记得 finish**。①引擎:人工激活的项在 `worldTouched` 之后照常求
|
||||||
|
doneWhen(此前一律跳过,怕替人签字 —— 门槛保留:卡没发过不收),拍首扫一遍、**拍尾再扫一遍**
|
||||||
|
(人答卡那一拍模型写完东西 doneWhen 才真;刚发卡的项排除;submit 拍不扫免擦信封);
|
||||||
|
②W05@1.5.8:P-08 / P-10 各给一条只读入库事实的 doneWhen(要建的每颗料 = absent + created,
|
||||||
|
主数据/参数/资料都落了、必填不缺、逐颗对账),S1 哨兵不再等模型写 `created` 旗(那是「机检只读
|
||||||
|
基座数」的哨兵形);剧本里「顺序不能反」改口「引擎也会自己收」。现有 E04 / I01 / W05 没有任何
|
||||||
|
人工激活项声明过 doneWhen → 零回归。3 引擎测 + 包内 220 测;规范 §4.4 / 建模纪律 3、4 改口。
|
||||||
|
e2e w05 这轮没跑(用户的测试台占着,起单会顶掉他的单)。
|
||||||
|
|
||||||
|
- **工作区正文里的占位上下居中**(2026-08-27 晚,用户:「AI 推进中 / 这单已收尾 / 还没有往来记录 …居中显示」):
|
||||||
|
`.jc-empty` 早写了 `margin:auto`,可它住在自绘滚动区的视口 `.wf-body`(块级)里,纵向 auto 边距算 0,
|
||||||
|
全贴顶边。与 08-x 治五模块空态同一招:`.wf-body:has(> .jc-empty / > .jc-working)` 才切成纵向 flex,
|
||||||
|
件 / 日志 / 往来记录这些正文照旧从顶往下排。浏览器实证「这单已收尾」「还没有往来记录」两屏居中。
|
||||||
|
|
||||||
|
- **运营中心活动详情沙箱换成「关键台账数据」看板,数据接真台账**(2026-08-27 晚,用户定:只摆最近一次已放行
|
||||||
|
实例、替换掉全流程分支图):模版搬自 `docs/ss02-act-05/ACT-WORK-05-关键台账数据.html`(P-01 KV / P-08 逐颗 +
|
||||||
|
参数瓷贴 / P-11 主记录 + 差异明细 / P-10 / P-06 本单 + 同料号历史),徽标文案全由 `data-class-of` 投影。
|
||||||
|
**取数归活动声明、执行归基座**:契约新增 `viewTemplates[].data`(`case{ledger,key,prefix}` + `rows[{as,ledger,by,not,orderBy}]`,
|
||||||
|
`from` 只能指 `case` 或前面的 `<as>.<列>`),`view-scope.ts` 按声明逐表等值读正式表、摊成扁平作用域
|
||||||
|
(`<as>` / `<as>.<列>` / `_rows` / tally 计数 / `case:*` / `board:state` 三态)交 `renderView`,`/view` 出口照旧注入报高脚本。
|
||||||
|
案号 = 前缀 + 开案时刻 14 位(`case-stamp.ts`,与引擎注给模型铸主键的 `case:openedAt` 同一函数 —— 实测 INS-20
|
||||||
|
`13:51:03Z` ↔ `W05-20260827215103` 一字不差);放行后草稿层已清空,所以只能这么圈。`store.latestReleased`。
|
||||||
|
W05@1.6.0(库里核过带 `data` 11 条);`views/overview.ts` 删除(docs 里的源文件不动)。测:契约 3 + view-scope 9 +
|
||||||
|
service 3 + store 1 + 包内 4;真库渲染 `v-ready`、INS-21 / `W05-20260827215148`。欠账见 debt(挑单 / 行级来源 /
|
||||||
|
`vtok` 三份 / `data` 装包静态审)。
|
||||||
|
|
||||||
## 文档权威地图
|
## 文档权威地图
|
||||||
|
|
||||||
| 层 | 文件 | 说明 |
|
| 层 | 文件 | 说明 |
|
||||||
|
|||||||
@@ -13,8 +13,14 @@ import { manifest, D, RULE_VERSION } from '../src/manifest.ts';
|
|||||||
import activity from '../src/index.ts';
|
import activity from '../src/index.ts';
|
||||||
|
|
||||||
const ITEMS = ['S0_bind', 'S0d_p08_master', 'S0d_p10_document', 'S1_input_confirm', 'S2_normalize', 'S3_assess', 'S4_review', 'S5_prepare_landing', 'T_release'];
|
const ITEMS = ['S0_bind', 'S0d_p08_master', 'S0d_p10_document', 'S1_input_confirm', 'S2_normalize', 'S3_assess', 'S4_review', 'S5_prepare_landing', 'T_release'];
|
||||||
const NO_DONE_WHEN = new Set(['S0d_p08_master', 'S0d_p10_document', 'S1_input_confirm', 'S4_review', 'T_release']);
|
/*
|
||||||
|
* 2026-08-27 晚:两张数据工单改成**给 doneWhen**(只读入库事实)—— 引擎只在人答过卡之后求它,
|
||||||
|
* 不再靠模型记得 finish。仍不给的三处:输入确认(交人补缺口)/ 审核循环 / 放行。
|
||||||
|
*/
|
||||||
|
const NO_DONE_WHEN = new Set(['S1_input_confirm', 'S4_review', 'T_release']);
|
||||||
const VIEWS = ['p08-master-create', 'p10-document-create', 'eval-input-list', 'difference-review', 'landing-confirm'];
|
const VIEWS = ['p08-master-create', 'p10-document-create', 'eval-input-list', 'difference-review', 'landing-confirm'];
|
||||||
|
/** 运营中心 L2 沙箱里那张关键台账数据看板 —— 不是工单件,只在「都声明了」这一条里出现;自己那组断言在文件尾 */
|
||||||
|
const BOARD_VIEW = 'activity-ledger';
|
||||||
const byAddress = (domain: string, object: string) => manifest.declarations.ledger.find((l) => l.domain === domain && l.object === object);
|
const byAddress = (domain: string, object: string) => manifest.declarations.ledger.find((l) => l.domain === domain && l.object === object);
|
||||||
const viewHtml = (name: string) => manifest.assets.viewTemplates.find((t) => t.name === name)?.html ?? '';
|
const viewHtml = (name: string) => manifest.assets.viewTemplates.find((t) => t.name === name)?.html ?? '';
|
||||||
|
|
||||||
@@ -67,8 +73,8 @@ describe('清单本身合规', () => {
|
|||||||
expect(newCustomer).toBeDefined();
|
expect(newCustomer).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('五张视图模版都声明了,剧本每一步引用的 view 都在其中', () => {
|
it('五张工单视图 + 一张运营侧看板都声明了,剧本每一步引用的 view 都在其中', () => {
|
||||||
expect(new Set(manifest.assets.viewTemplates.map((v) => v.name))).toEqual(new Set(VIEWS));
|
expect(new Set(manifest.assets.viewTemplates.map((v) => v.name))).toEqual(new Set([...VIEWS, BOARD_VIEW]));
|
||||||
for (const step of manifest.assets.businessModel.steps as ReadonlyArray<{ view?: string }>) {
|
for (const step of manifest.assets.businessModel.steps as ReadonlyArray<{ view?: string }>) {
|
||||||
if (step.view !== undefined) expect(VIEWS, step.view).toContain(step.view);
|
if (step.view !== undefined) expect(VIEWS, step.view).toContain(step.view);
|
||||||
}
|
}
|
||||||
@@ -132,6 +138,12 @@ describe('台账声明:授权面与《统一方案》口径', () => {
|
|||||||
expect(byAddress(D, 'pn_check')?.fields.find((f) => f.name === 'abandon_reason')?.requiredWhen).toEqual({ column: 'exists_status', value: 'abandoned' });
|
expect(byAddress(D, 'pn_check')?.fields.find((f) => f.name === 'abandon_reason')?.requiredWhen).toEqual({ column: 'exists_status', value: 'abandoned' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('三张主数据表标 createOnly:数据工单只建库里没有的料,在库的写下来当场拒(2026-08-27 实测:模型在数据工单里多铺了一行不该建的料号,件上多出一行要人填)', () => {
|
||||||
|
for (const obj of ['profile', 'parameter', 'document'] as const) expect(byAddress('parts', obj)?.createOnly, obj).toBe(true);
|
||||||
|
// 凭空拼出来的料号(实测 XL2596S-ADJ)由这一道管:只许建核对表里核过的
|
||||||
|
expect(byAddress('parts', 'profile')?.fields.find((f) => f.name === 'internal_pn')?.refersTo).toBe(`${D}.pn_check.internal_pn`);
|
||||||
|
});
|
||||||
|
|
||||||
it('判「在库」必须填得出引用依据,基座拿它去主数据里查(2026-08-27 实测:原 PN 不在库而模型把两颗都写成 found,硬阻塞那条路整个被跳过)', () => {
|
it('判「在库」必须填得出引用依据,基座拿它去主数据里查(2026-08-27 实测:原 PN 不在库而模型把两颗都写成 found,硬阻塞那条路整个被跳过)', () => {
|
||||||
const ref = byAddress(D, 'pn_check')?.fields.find((f) => f.name === 'found_pn_ref');
|
const ref = byAddress(D, 'pn_check')?.fields.find((f) => f.name === 'found_pn_ref');
|
||||||
expect(ref?.refersTo).toBe('parts.profile.internal_pn');
|
expect(ref?.refersTo).toBe('parts.profile.internal_pn');
|
||||||
@@ -152,7 +164,7 @@ describe('案例模型与清单的对齐', () => {
|
|||||||
expect((manifest.assets.businessModel.steps as ReadonlyArray<{ item: string }>).map((s) => s.item)).toEqual(ITEMS);
|
expect((manifest.assets.businessModel.steps as ReadonlyArray<{ item: string }>).map((s) => s.item)).toEqual(ITEMS);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('五处不给 doneWhen(两张数据工单 / 输入确认 / 审核 / 放行),其余四处都给', () => {
|
it('三处不给 doneWhen(输入确认 / 审核 / 放行),其余六处都给 —— 两张数据工单的只读入库事实', () => {
|
||||||
for (const s of manifest.assets.businessModel.steps as ReadonlyArray<{ item: string; doneWhen?: string }>) {
|
for (const s of manifest.assets.businessModel.steps as ReadonlyArray<{ item: string; doneWhen?: string }>) {
|
||||||
if (NO_DONE_WHEN.has(s.item)) expect(s.doneWhen, s.item).toBeUndefined();
|
if (NO_DONE_WHEN.has(s.item)) expect(s.doneWhen, s.item).toBeUndefined();
|
||||||
else expect(s.doneWhen, s.item).toBeDefined();
|
else expect(s.doneWhen, s.item).toBeDefined();
|
||||||
@@ -185,15 +197,13 @@ describe('案例模型与清单的对齐', () => {
|
|||||||
for (const name of mentioned) expect(ITEMS, `说明书提到但不存在的计划项: ${name}`).toContain(name);
|
for (const name of mentioned) expect(ITEMS, `说明书提到但不存在的计划项: ${name}`).toContain(name);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('两种决策卡骨架都在说明书里:候选放弃/创建(create/abandon)与审核(approve/reject/custom:)', () => {
|
it('说明书里只剩审核那一种决策卡(approve/reject/custom:);候选放弃/创建那张已退役 —— 缺库一律自动派数据工单(2026-08-27 用户定)', () => {
|
||||||
const n = manifest.assets.businessModel.narrative;
|
const n = manifest.assets.businessModel.narrative;
|
||||||
expect(n).toContain('"kind":"decide","itemId":"S0d_p08_master"');
|
expect(n).not.toContain('"value":"create"');
|
||||||
expect(n).toContain('"value":"create"');
|
expect(n).not.toContain('"value":"abandon"');
|
||||||
expect(n).toContain('"value":"abandon"');
|
expect(n).toContain('"value":"approve"');
|
||||||
expect(n).toContain('"kind":"decide","itemId":"S4_review"');
|
expect(n).toContain('"value":"reject"');
|
||||||
expect(n).toContain('"approve"');
|
expect(n).toContain('custom:');
|
||||||
expect(n).toContain('"reject"');
|
|
||||||
expect(n).toContain('"custom:"');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('T_release 剧本里 judge criterion 的原文逐字对得上 acceptanceCriteria 的 label', () => {
|
it('T_release 剧本里 judge criterion 的原文逐字对得上 acceptanceCriteria 的 label', () => {
|
||||||
@@ -309,6 +319,23 @@ describe('徽标别名与值域相关的坑(2026-08-27 实测账,回归网)', ()
|
|||||||
{ view: 'landing-confirm', domain: 'substitution', object: 'relation', column: 'analysis_status' },
|
{ view: 'landing-confirm', domain: 'substitution', object: 'relation', column: 'analysis_status' },
|
||||||
{ view: 'landing-confirm', domain: 'substitution', object: 'relation', column: 'risk_level' },
|
{ view: 'landing-confirm', domain: 'substitution', object: 'relation', column: 'risk_level' },
|
||||||
{ view: 'landing-confirm', domain: 'demand', object: 'request', column: 'request_status' },
|
{ view: 'landing-confirm', domain: 'demand', object: 'request', column: 'request_status' },
|
||||||
|
/* 运营侧看板把六张表的枚举列全摆成徽标 —— 每一列的每个值都得有文案,漏一个就是一枚「—」 */
|
||||||
|
{ view: BOARD_VIEW, domain: 'parts', object: 'profile', column: 'lifecycle_status' },
|
||||||
|
{ view: BOARD_VIEW, domain: 'parts', object: 'document', column: 'document_type' },
|
||||||
|
{ view: BOARD_VIEW, domain: 'parts', object: 'document', column: 'document_status' },
|
||||||
|
{ view: BOARD_VIEW, domain: 'demand', object: 'request', column: 'request_status' },
|
||||||
|
{ view: BOARD_VIEW, domain: 'demand', object: 'request', column: 'risk_level' },
|
||||||
|
{ view: BOARD_VIEW, domain: 'substitution', object: 'relation', column: 'substitution_type' },
|
||||||
|
{ view: BOARD_VIEW, domain: 'substitution', object: 'relation', column: 'recommendation_level' },
|
||||||
|
{ view: BOARD_VIEW, domain: 'substitution', object: 'relation', column: 'analysis_status' },
|
||||||
|
{ view: BOARD_VIEW, domain: 'substitution', object: 'relation', column: 'risk_level' },
|
||||||
|
{ view: BOARD_VIEW, domain: 'substitution', object: 'difference_item', column: 'constraint_type' },
|
||||||
|
{ view: BOARD_VIEW, domain: 'substitution', object: 'difference_item', column: 'difference_status' },
|
||||||
|
{ view: BOARD_VIEW, domain: 'substitution', object: 'difference_item', column: 'risk_level' },
|
||||||
|
{ view: BOARD_VIEW, domain: 'validation', object: 'record', column: 'validation_type' },
|
||||||
|
{ view: BOARD_VIEW, domain: 'validation', object: 'record', column: 'validation_status' },
|
||||||
|
{ view: BOARD_VIEW, domain: D, object: 'pn_check', column: 'role' },
|
||||||
|
{ view: BOARD_VIEW, domain: D, object: 'pn_check', column: 'exists_status' },
|
||||||
];
|
];
|
||||||
|
|
||||||
it('每张模版摆了徽标的每个 tally 列,声明里的每个值都有对应的 v-<token> 规则(选择器是从值算出来的,不是手写的)', () => {
|
it('每张模版摆了徽标的每个 tally 列,声明里的每个值都有对应的 v-<token> 规则(选择器是从值算出来的,不是手写的)', () => {
|
||||||
@@ -396,3 +423,134 @@ const CONTRACT = {
|
|||||||
prohibitions: [],
|
prohibitions: [],
|
||||||
discretion: [],
|
discretion: [],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/* 验收件题头是验收绿(2026-08-27 用户定:与对话区验收卡同色,不再是目标件的红) */
|
||||||
|
describe('两张验收件的主题色', () => {
|
||||||
|
it('ACC1 / ACC2 的 --ledger 是验收绿 #0aa294,题头渐变不再带红;CONDITIONAL 徽标保留语义红', async () => {
|
||||||
|
const { ACC1_CSS, ACC2_CSS } = await import('../src/views/target-style.ts');
|
||||||
|
for (const css of [ACC1_CSS, ACC2_CSS]) {
|
||||||
|
expect(css).toContain('--ledger:#0aa294');
|
||||||
|
expect(css).not.toContain('--ledger:#b23a2e');
|
||||||
|
expect(css).not.toContain('#fdf3f1');
|
||||||
|
expect(css).toContain('rgba(10,162,148,.25)');
|
||||||
|
}
|
||||||
|
// ACC2 的 CONDITIONAL 徽标保留目标件的语义红;ACC1 目标件 2026-08-27 改版把它改成了品牌蓝,照抄
|
||||||
|
expect(ACC2_CSS).toContain('.st.cond{color:#b23a2e');
|
||||||
|
expect(ACC1_CSS).toContain('.st.cond{color:var(--brand-deep)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 差异评估结论审核 · 目标件 2026-08-27 改版:「一个候选一张表」(用户:存在多个替代 PN 要对比差异时看不清楚)。
|
||||||
|
* 外层按 relation 逐个复制 <section class="cand">,里层明细只列该候选名下的行(data-by 父键过滤);
|
||||||
|
* 汇总表一个候选一条;品牌从 parts.profile 整表按料号挑(取值,不是算)。
|
||||||
|
*/
|
||||||
|
describe('difference-review:一个候选一张表', () => {
|
||||||
|
const html = viewHtml('difference-review');
|
||||||
|
it('候选块是嵌套行模版:外层 relation、里层明细按 substitution_relation_id 过滤;容器标签不在行模版里再出现', () => {
|
||||||
|
expect(html).toContain('<article class="cands" data-rows="mine:substitution.relation"><section class="cand">');
|
||||||
|
expect(html).toContain('data-rows="mine:substitution.difference_item" data-by="substitution_relation_id"');
|
||||||
|
const inner = html.slice(html.indexOf('<section class="cand">'), html.indexOf('</section></article>'));
|
||||||
|
expect(inner).not.toContain('<article');
|
||||||
|
expect(inner).toContain('class="cand-idx"');
|
||||||
|
expect(inner).toContain('data-slot="original_internal_pn"');
|
||||||
|
});
|
||||||
|
it('汇总表一个候选一条:推荐程度 / 风险级别按值上色,差异统计绑 difference_summary,品牌按料号从 parts.profile 挑', () => {
|
||||||
|
expect(html).toContain('<tbody data-rows="mine:substitution.relation"><tr>');
|
||||||
|
expect(html).toContain('<span class="rec" data-class-of="recommendation_level"></span>');
|
||||||
|
expect(html).toContain('class="stat" data-slot="difference_summary"');
|
||||||
|
expect(html).toContain('data-rows="parts.profile" data-by="internal_pn=candidate_internal_pn"');
|
||||||
|
expect(html).toContain('data-slot="substitution_relation_rows"');
|
||||||
|
expect(html).not.toContain('【AI 产出的差异明细】');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* 评估输入清单的件上摆「候选参数现值」:一颗料一张、里层按料号过滤、只读(没有 data-edit) */
|
||||||
|
describe('eval-input-list:候选参数现值只读参考', () => {
|
||||||
|
it('外层按核对表逐颗复制,里层 parts.parameter 按 internal_pn 过滤,不是可编辑表', () => {
|
||||||
|
const html = viewHtml('eval-input-list');
|
||||||
|
expect(html).toContain(`<article class="prefs" data-rows="mine:${D}.pn_check"><section class="pref">`);
|
||||||
|
expect(html).toContain('<tbody data-rows="parts.parameter" data-by="internal_pn"><tr>');
|
||||||
|
const block = html.slice(html.indexOf('<article class="prefs"'), html.indexOf('</section></article>'));
|
||||||
|
expect(block).not.toContain('data-edit=');
|
||||||
|
expect(block).toContain('data-slot="param_value"');
|
||||||
|
expect(html).toContain('【候选参数现值】');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 运营中心沙箱看板(2026-08-27 用户定:替换全流程分支图,只摆最近一次已放行实例的关键台账落账快照)。
|
||||||
|
* 模版归活动,「哪几行是这一单的」也归活动 —— `data` 声明:案号怎么铸、哪张表按哪一列圈;基座只照它查正式表。
|
||||||
|
*/
|
||||||
|
describe('activity-ledger:关键台账数据看板', () => {
|
||||||
|
const board = manifest.assets.viewTemplates.find((v) => v.name === BOARD_VIEW);
|
||||||
|
const html = board?.html ?? '';
|
||||||
|
const data = board?.data;
|
||||||
|
const declaredAddresses = new Set(manifest.declarations.ledger.map((l) => `${l.domain}.${l.object}`));
|
||||||
|
|
||||||
|
it('取数声明:案号台账是 record 且 key 是它的 caseKey、前缀 W05-;每条 rows 的表都已声明;from 只指 case 或前面的 as.列', () => {
|
||||||
|
expect(data).toBeDefined();
|
||||||
|
if (data === undefined) return;
|
||||||
|
const caseLedger = manifest.declarations.ledger.find((l) => `${l.domain}.${l.object}` === data.case.ledger);
|
||||||
|
expect(caseLedger?.kind).toBe('record');
|
||||||
|
expect(caseLedger?.caseKey).toBe(data.case.key);
|
||||||
|
expect(data.case.prefix).toBe('W05-');
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const entry of data.rows) {
|
||||||
|
expect(declaredAddresses.has(entry.ledger), entry.ledger).toBe(true);
|
||||||
|
const columns = new Set(manifest.declarations.ledger.find((l) => `${l.domain}.${l.object}` === entry.ledger)?.fields.map((f) => f.name));
|
||||||
|
expect(columns.has(entry.by.column), `${entry.as} by ${entry.by.column}`).toBe(true);
|
||||||
|
for (const ref of [entry.by.from, entry.not?.from]) {
|
||||||
|
if (ref === undefined || ref === 'case') continue;
|
||||||
|
const at = ref.lastIndexOf('.');
|
||||||
|
const as = ref.slice(0, at);
|
||||||
|
const column = ref.slice(at + 1);
|
||||||
|
expect(seen.has(as), `${entry.as} 引用了后面 / 不存在的集合 ${as}`).toBe(true);
|
||||||
|
const source = data.rows.find((r) => r.as === as);
|
||||||
|
const sourceColumns = new Set(manifest.declarations.ledger.find((l) => `${l.domain}.${l.object}` === source?.ledger)?.fields.map((f) => f.name));
|
||||||
|
expect(sourceColumns.has(column), `${ref} 不是 ${source?.ledger} 的列`).toBe(true);
|
||||||
|
}
|
||||||
|
seen.add(entry.as);
|
||||||
|
}
|
||||||
|
// 六张主表 + 案号两张 + 同料号历史都圈了
|
||||||
|
expect([...seen]).toEqual(
|
||||||
|
expect.arrayContaining([`${D}.verdict`, `${D}.pn_check`, 'demand.request', 'customer.profile', 'parts.profile', 'parts.parameter', 'parts.document', 'substitution.relation', 'substitution.difference_item', 'validation.record', 'validation.history']),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('html 里每个 data-rows 键都是声明里的一个 as(行由基座圈过,不是整表);嵌套只用 data-by 父键过滤', () => {
|
||||||
|
const as = new Set(data?.rows.map((r) => r.as) ?? []);
|
||||||
|
const keys = [...html.matchAll(/data-rows="([^"]+)"/g)].map((m) => m[1] ?? '');
|
||||||
|
expect(keys.length).toBeGreaterThan(8);
|
||||||
|
for (const key of keys) expect(as.has(key), key).toBe(true);
|
||||||
|
expect(html).toContain('<span class="pnrow" data-rows="sub_eval.pn_check" data-by="internal_pn">');
|
||||||
|
expect(html).toContain('<div class="pchips" data-rows="parts.parameter" data-by="internal_pn">');
|
||||||
|
expect(html).toContain('<tbody class="hist" data-rows="validation.history">');
|
||||||
|
expect(html).not.toContain('mine:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无脚本、无 on*、无外链(过安装静态审);有 </body> 给出口注入报高脚本;三态由 board:state 投影;只读', () => {
|
||||||
|
expect(html.length).toBeGreaterThan(5000);
|
||||||
|
expect(html).not.toMatch(/<\s*script\b/i);
|
||||||
|
expect(html).not.toMatch(/\son[a-z]+\s*=/i);
|
||||||
|
expect(html).not.toMatch(/(src|href)\s*=\s*["']?\s*(https?:)?\/\//i);
|
||||||
|
expect(html.toLowerCase()).toContain('</body>');
|
||||||
|
expect(html).toContain('<div class="page" data-class-of="board:state">');
|
||||||
|
expect(html).toContain('.page.v-none .notice.none{display:flex}');
|
||||||
|
expect(html).toContain('.page.v-missing .notice.missing{display:flex}');
|
||||||
|
expect(html).not.toContain('data-edit=');
|
||||||
|
expect(html).not.toContain('data-col=');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('目标件六张表的表头与页头要素都在;MATCH 行淡显、放弃的候选只在有 abandoned 时露面 —— 都是 class 交样式表,模版不判', () => {
|
||||||
|
for (const header of ['<th>内部料号</th>', '<th>替代关系 ID</th>', '<th>参数项</th>', '<th>关联料号</th>', '<th>验证 ID</th>', '<th>标准化参数集</th>']) expect(html).toContain(header);
|
||||||
|
expect(html).toContain('data-slot="case:instanceNo"');
|
||||||
|
expect(html).toContain('data-slot="case:ref"');
|
||||||
|
expect(html).toContain(`data-slot="${D}.verdict.original_internal_pn"`);
|
||||||
|
expect(html).toContain('data-slot="demand.request.request_id"');
|
||||||
|
expect(html).toContain('<tr data-class-of="difference_status">');
|
||||||
|
expect(html).toContain('tr.v-match td{opacity:.55}');
|
||||||
|
expect(html).toContain('.aband:has(i.v-abandoned){display:block}');
|
||||||
|
expect(html).toContain('data-slot="substitution_difference_item_difference_status_unknown"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -139,7 +139,15 @@ describe('doneWhen —— 开局不许真,干完必须真', () => {
|
|||||||
describe('说明书与剧本的骨架', () => {
|
describe('说明书与剧本的骨架', () => {
|
||||||
it('五张卡的骨架都住在说明书里(压拍之后只有说明书还在)', () => {
|
it('五张卡的骨架都住在说明书里(压拍之后只有说明书还在)', () => {
|
||||||
expect(NARRATIVE).toContain('"kind":"entry","itemId":"S0_bind"');
|
expect(NARRATIVE).toContain('"kind":"entry","itemId":"S0_bind"');
|
||||||
expect(NARRATIVE).toContain('"kind":"decide","itemId":"S0d_p08_master"');
|
expect(NARRATIVE).not.toContain('"kind":"decide","itemId":"S0d_p08_master"');
|
||||||
|
// 两张数据工单都得标 variant:"data" —— 对话区凭它换成「数据工单」那副面孔
|
||||||
|
expect(NARRATIVE.match(/"variant":"data"/g)?.length).toBe(2);
|
||||||
|
// 但模型抄卡会把它丢掉(2026-08-27 实测)—— 真正作数的是剧本上的声明,引擎按它决定
|
||||||
|
for (const item of ['S0d_p08_master', 'S0d_p10_document']) {
|
||||||
|
expect(ASSESSMENT_STEPS.find((s) => s.item === item)?.cardVariant, item).toBe('data');
|
||||||
|
}
|
||||||
|
expect(ASSESSMENT_STEPS.find((s) => s.item === 'S4_review')?.cardVariant).toBe('review');
|
||||||
|
for (const step of ASSESSMENT_STEPS) if (!['S0d_p08_master', 'S0d_p10_document', 'S4_review'].includes(step.item)) expect(step.cardVariant, step.item).toBeUndefined();
|
||||||
expect(NARRATIVE).toContain('"title":"数据工单 · 芯片主数据创建(P-08)');
|
expect(NARRATIVE).toContain('"title":"数据工单 · 芯片主数据创建(P-08)');
|
||||||
expect(NARRATIVE).toContain('"title":"数据工单 · 原厂技术资料创建(P-10)');
|
expect(NARRATIVE).toContain('"title":"数据工单 · 原厂技术资料创建(P-10)');
|
||||||
expect(NARRATIVE).toContain('"title":"评估输入与资料补齐确认');
|
expect(NARRATIVE).toContain('"title":"评估输入与资料补齐确认');
|
||||||
@@ -166,22 +174,60 @@ describe('说明书与剧本的骨架', () => {
|
|||||||
expect(secondPass).toMatch(/收不了步|收不掉/);
|
expect(secondPass).toMatch(/收不了步|收不掉/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('S0 剧本写明都在库时数据工单两项本就不会进「当下可为」;S0d_p08_master 剧本按候选总数判断该不该问人(=1 必建、≥2 才问、原 PN 硬阻塞)', () => {
|
/*
|
||||||
const s0 = ASSESSMENT_STEPS.find((s) => s.item === 'S0_bind')?.guide ?? '';
|
* 数据工单确认即入库(2026-08-27 用户定:「数据工单里填的数据,确认后就应该入库」)。
|
||||||
expect(s0).toContain('当下可为');
|
* 基座在确认那一刻把卡上 gateLedgers 那几张表并进正式表;剧本这一头不能再教模型「已经落草稿层」——
|
||||||
expect(s0).toContain('不要对计划项用');
|
* 那句话会让它以为放行前还能改、还能 drop,而在库的键写下来会被当场拒。
|
||||||
const s0d = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
|
*/
|
||||||
expect(s0d).toContain('硬阻塞');
|
it('两张数据工单的剧本都写明:确认后已入正式表,别再写、别 drop 那几行;P-10 的卡骨架也说「确认后即入库」', () => {
|
||||||
expect(s0d).toContain('候选总数 = 1');
|
for (const item of ['S0d_p08_master', 'S0d_p10_document']) {
|
||||||
|
const guide = ASSESSMENT_STEPS.find((s) => s.item === item)?.guide ?? '';
|
||||||
|
expect(guide, item).toContain('入了正式表');
|
||||||
|
expect(guide, item).not.toContain('已经落草稿层');
|
||||||
|
expect(guide, item).toContain('别去 drop');
|
||||||
|
}
|
||||||
|
expect(NARRATIVE).toContain('确认后这批主数据即入库');
|
||||||
|
expect(NARRATIVE).toContain('确认后这批资料记录即入库');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('S0d_p08_master 剧本按候选总数(不是「不在库的颗数」)判断要不要问人 —— 候选总数 ≥2 时逐颗不在库的都要问(2026-08-27 实测逼出的措辞修正)', () => {
|
/*
|
||||||
|
* S1 的「待确认」行只许来自真写着「未披露」的参数(2026-08-27 用户:「我在第一步已经补充了
|
||||||
|
* XL2596S-5.0 使能阈值,当前这一步还要填写一次?」)—— 剧本从前给了一句原话示例
|
||||||
|
* 「input_item 如『XL2596S-5.0 使能阈值』」,模型照抄示例、不读现值,把人刚填的 2.5 摆成缺口。
|
||||||
|
* 提示词里唯一该出现的原话,是你想听到的那句;示例行一律不引具体料号 + 参数名。
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
* 第三次同一病(2026-08-27):措辞改了两遍照样复现 —— 台账里每个参数都有值,模型还是写了一行
|
||||||
|
* 「使能阈值 · 原厂未披露」。参数值有没有是台账里的事实,不该由模型在清单里宣布:
|
||||||
|
* S1 从此**一行参数都不写**,件上摆一张基座现读的「候选参数现值」表;说明书全文不再出现「使能阈值」。
|
||||||
|
*/
|
||||||
|
it('S1 不写参数行(item_key 不许以 unknown_ 开头);说明书全文没有「使能阈值」这个可照抄的名字', () => {
|
||||||
|
const s1 = ASSESSMENT_STEPS.find((s) => s.item === 'S1_input_confirm')?.guide ?? '';
|
||||||
|
expect(s1).toContain('候选参数一行都不写');
|
||||||
|
expect(s1).toContain('不许以 unknown_ 开头');
|
||||||
|
expect(s1).not.toContain('待确认');
|
||||||
|
expect(NARRATIVE).not.toContain('使能阈值');
|
||||||
|
for (const step of ASSESSMENT_STEPS) expect(step.guide, step.item).not.toContain('使能阈值');
|
||||||
|
const skeleton = NARRATIVE.slice(NARRATIVE.indexOf('⑤ 执行工单 01'), NARRATIVE.indexOf('"standard":"每一条「待补」行'));
|
||||||
|
expect(skeleton).toContain('候选参数现值');
|
||||||
|
expect(skeleton).not.toContain('待确认');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('S0 剧本写明都在库时数据工单两项本就不会进「当下可为」;S0d_p08_master 剧本写明 found 的一颗都不建', () => {
|
||||||
|
const s0 = ASSESSMENT_STEPS.find((s) => s.item === 'S0_bind')?.guide ?? '';
|
||||||
|
expect(s0).toContain('当下可为');
|
||||||
|
expect(s0).toMatch(/都在库/);
|
||||||
const s0d = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
|
const s0d = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
|
||||||
expect(s0d).toContain('候选总数');
|
expect(s0d).toContain('absent');
|
||||||
expect(s0d).toContain('不在库');
|
expect(s0d).toMatch(/found" 的一行都不建/);
|
||||||
expect(s0d).toMatch(/候选总数\s*≥\s*2/);
|
});
|
||||||
// 反面提示:「不在库的只有一颗」不该被读成跳过决策卡的理由
|
|
||||||
expect(s0d).toContain('不是跳过决策卡的理由');
|
it('S0d_p08_master:查无记录一律派数据工单,不问人 —— 决策卡退役(2026-08-27 用户定:「只要有缺失就要自动派数据工单不需要人选择」)', () => {
|
||||||
|
const s0d = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
|
||||||
|
expect(s0d).toMatch(/一律建,不问人/);
|
||||||
|
expect(s0d).toMatch(/exists_status = "found" 的一行都不建/);
|
||||||
|
expect(s0d).not.toMatch(/发一张决策卡问人/);
|
||||||
|
expect(s0d).not.toMatch(/候选总数\s*≥\s*2/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('s_bound 哨兵真的检查了「有料号不在库」这件事(都在库时门不该开;退回旧写法会红)', () => {
|
it('s_bound 哨兵真的检查了「有料号不在库」这件事(都在库时门不该开;退回旧写法会红)', () => {
|
||||||
@@ -199,28 +245,47 @@ describe('说明书与剧本的骨架', () => {
|
|||||||
for (const step of ASSESSMENT_STEPS) expect(step.guide, step.item).not.toMatch(disableCallPattern);
|
for (const step of ASSESSMENT_STEPS) expect(step.guide, step.item).not.toMatch(disableCallPattern);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('S0d 拆成两项各自绑自己的 view(P-10 那份件不会永远推不出来),两项都没有 doneWhen', () => {
|
it('S0d 拆成两项各自绑自己的 view(P-10 那份件不会永远推不出来)', () => {
|
||||||
const p08 = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master');
|
const p08 = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master');
|
||||||
const p10 = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p10_document');
|
const p10 = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p10_document');
|
||||||
expect(p08?.view).toBe('p08-master-create');
|
expect(p08?.view).toBe('p08-master-create');
|
||||||
expect(p10?.view).toBe('p10-document-create');
|
expect(p10?.view).toBe('p10-document-create');
|
||||||
expect(p08?.doneWhen).toBeUndefined();
|
|
||||||
expect(p10?.doneWhen).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('候选放弃到只剩最后一颗时不许再放弃(至少留一颗,防止全放弃死锁)', () => {
|
/*
|
||||||
const s0d = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
|
* **两张数据工单的 doneWhen 只读入库事实**(2026-08-27 晚):引擎只在人答过卡之后求它 —— 值是人在件上填的、
|
||||||
expect(s0d).toContain('至少留一颗候选');
|
* 引擎数得出来,不再靠模型记得 finish(剧本写了两处它照样忘,件停在 P-08)。
|
||||||
|
*/
|
||||||
|
it('P-08:要建的每颗料主数据与参数都落了、必填不缺 → 收得掉;少建一颗 / 还缺必填 / 参数行对不上账 → 收不掉', () => {
|
||||||
|
const ok = { parts_profile_rows: 2, [`${D}_pn_check_exists_status_absent`]: 2, [`${D}_pn_check_exists_status_created`]: 0, parts_profile_missing: 0, parts_parameter_missing: 0, parts_parameter_rows: 14, parts_parameter_distinct_param_name: 7 };
|
||||||
|
expect(safeEval(doneOf('S0d_p08_master'), ok)).toBe(true);
|
||||||
|
// 模型把其中一颗改成 created 也一样算(absent + created 才是要建的料数)
|
||||||
|
expect(safeEval(doneOf('S0d_p08_master'), { ...ok, [`${D}_pn_check_exists_status_absent`]: 1, [`${D}_pn_check_exists_status_created`]: 1 })).toBe(true);
|
||||||
|
expect(safeEval(doneOf('S0d_p08_master'), { ...ok, parts_profile_rows: 1, parts_parameter_rows: 7 })).toBe(false);
|
||||||
|
expect(safeEval(doneOf('S0d_p08_master'), { ...ok, parts_profile_missing: 1 })).toBe(false);
|
||||||
|
expect(safeEval(doneOf('S0d_p08_master'), { ...ok, parts_parameter_rows: 13 })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('P-08 交回之后先 finish 再发 P-10 卡,顺序写死进两段 guide(2026-08-27 实测:漏收步会让工作区画面停在 P-08 那两张表,P-10 卡发出后人却看不见资料表、提交闸拦死)', () => {
|
it('P-10:建过的每颗料都有一行资料记录且必填不缺 → 收得掉;资料少一行 / 骨架还空着 → 收不掉', () => {
|
||||||
|
const ok = { parts_document_rows: 2, parts_profile_rows: 2, parts_document_missing: 0 };
|
||||||
|
expect(safeEval(doneOf('S0d_p10_document'), ok)).toBe(true);
|
||||||
|
expect(safeEval(doneOf('S0d_p10_document'), { ...ok, parts_document_rows: 1 })).toBe(false);
|
||||||
|
expect(safeEval(doneOf('S0d_p10_document'), { ...ok, parts_document_missing: 3 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('S1 的入场哨兵不再等模型写 created 旗:入库事实齐了门就开', () => {
|
||||||
|
const cond = /<sentry id="s_pns_resolved">[\s\S]*?<condition>([\s\S]*?)<\/condition>/.exec(CMMN_XML)?.[1] ?? '';
|
||||||
|
expect(cond).not.toContain('sub_eval_pn_check_exists_status_absent = 0');
|
||||||
|
expect(cond).toContain('sub_eval_pn_check_exists_status_absent + sub_eval_pn_check_exists_status_created');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('P-08 交回之后的收步不再靠模型记得 finish:剧本写明引擎按入库事实自己收,finish 只是省一拍(2026-08-27 晚:「顺序写死」两处模型照样忘)', () => {
|
||||||
const s0d = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
|
const s0d = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
|
||||||
expect(s0d).toContain('finish S0d_p08_master');
|
expect(s0d).toContain('finish S0d_p08_master');
|
||||||
expect(s0d).toMatch(/顺序不能反/);
|
expect(s0d).toMatch(/引擎也会自己收/);
|
||||||
expect(s0d).toContain('p08-master-create');
|
expect(s0d).not.toMatch(/顺序不能反/);
|
||||||
const s0d10 = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p10_document')?.guide ?? '';
|
const s0d10 = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p10_document')?.guide ?? '';
|
||||||
expect(s0d10).toContain('finish S0d_p08_master');
|
expect(s0d10).toMatch(/引擎按入库事实自己收/);
|
||||||
expect(s0d10).toMatch(/若它还开着|先.*finish/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('「发卡前清单必须先有行」写在说明书里,不只在剧本里(2026-08-27 实测:模型把 P-10 收步与 S1 发卡压成一拍,那一拍 S1 剧本没摊开,卡发出去时 input_gap 一行都没有,人对着空表无从下手)', () => {
|
it('「发卡前清单必须先有行」写在说明书里,不只在剧本里(2026-08-27 实测:模型把 P-10 收步与 S1 发卡压成一拍,那一拍 S1 剧本没摊开,卡发出去时 input_gap 一行都没有,人对着空表无从下手)', () => {
|
||||||
@@ -239,8 +304,8 @@ describe('说明书与剧本的骨架', () => {
|
|||||||
|
|
||||||
it('数据工单的名单只认 absent,「建哪几颗」与「要不要问人」分开写(2026-08-27 实测:原 PN 缺库那单,模型把在库的候选派成了数据工单,缺的那颗没人建)', () => {
|
it('数据工单的名单只认 absent,「建哪几颗」与「要不要问人」分开写(2026-08-27 实测:原 PN 缺库那单,模型把在库的候选派成了数据工单,缺的那颗没人建)', () => {
|
||||||
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
|
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
|
||||||
expect(g).toMatch(/问题一 · 建哪几颗/);
|
expect(g).toMatch(/【名单】/);
|
||||||
expect(g).toMatch(/问题二 · 建之前要不要先问人/);
|
expect(g).toMatch(/一律建,不问人/);
|
||||||
expect(g).toMatch(/exists_status = "found" 的一行都不建/);
|
expect(g).toMatch(/exists_status = "found" 的一行都不建/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -268,7 +333,7 @@ describe('说明书与剧本的骨架', () => {
|
|||||||
expect(NARRATIVE).toContain('parts.document` 必须先有骨架行');
|
expect(NARRATIVE).toContain('parts.document` 必须先有骨架行');
|
||||||
expect(NARRATIVE).toMatch(/压拍/);
|
expect(NARRATIVE).toMatch(/压拍/);
|
||||||
// 标了几颗建好,主数据就得有几行 —— 逐颗对账(第一刀写成「≥1 行就算数」太松,三候选那单从缝里过去了)
|
// 标了几颗建好,主数据就得有几行 —— 逐颗对账(第一刀写成「≥1 行就算数」太松,三候选那单从缝里过去了)
|
||||||
expect(CMMN_XML).toContain('parts_profile_rows >= sub_eval_pn_check_exists_status_created');
|
expect(CMMN_XML).toContain('parts_profile_rows >= sub_eval_pn_check_exists_status_absent + sub_eval_pn_check_exists_status_created');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('评估输入那张卡的**前提**写在说明书的卡骨架上(2026-08-27 e2e:两颗缺库,模型只问只建了第一颗,CN 还 absent 就把 S1 的卡发了出去 —— 发卡的授权面是调人预算,绕得过哨兵)', () => {
|
it('评估输入那张卡的**前提**写在说明书的卡骨架上(2026-08-27 e2e:两颗缺库,模型只问只建了第一颗,CN 还 absent 就把 S1 的卡发了出去 —— 发卡的授权面是调人预算,绕得过哨兵)', () => {
|
||||||
@@ -293,15 +358,15 @@ describe('说明书与剧本的骨架', () => {
|
|||||||
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
|
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
|
||||||
expect(g).toMatch(/参数名一律用中文/);
|
expect(g).toMatch(/参数名一律用中文/);
|
||||||
// 措辞写过两遍都没拦住 → 上结构:把「同一套名字」变成基座算得出的数
|
// 措辞写过两遍都没拦住 → 上结构:把「同一套名字」变成基座算得出的数
|
||||||
expect(CMMN_XML).toContain('parts_parameter_rows = parts_parameter_distinct_param_name * sub_eval_pn_check_exists_status_created');
|
expect(CMMN_XML).toContain('parts_parameter_rows = parts_parameter_distinct_param_name * (sub_eval_pn_check_exists_status_absent + sub_eval_pn_check_exists_status_created)');
|
||||||
expect(g).toContain('input_voltage_range');
|
expect(g).toContain('input_voltage_range');
|
||||||
expect(g).toMatch(/不是同一行/);
|
expect(g).toMatch(/不是同一行/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('第二颗缺库的候选也要先问(每一颗都有被放弃的权利)', () => {
|
it('第二颗缺库的候选也要先问(每一颗都有被放弃的权利)', () => {
|
||||||
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
|
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
|
||||||
expect(g).toMatch(/这一颗也要先发一张决策卡问人/);
|
expect(g).toMatch(/不问人,直接建/);
|
||||||
expect(g).toMatch(/第一颗问过了不代表第二颗也问过/);
|
expect(g).not.toMatch(/这一颗也要先发一张决策卡问人/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('created 认的是「卡交回来了」,不是「表里有行」—— 写骨架 ≠ 建好了(2026-08-27 e2e:模型铺完骨架就标 created、直接 finish,撞「从没给它发过卡」连拒五拍)', () => {
|
it('created 认的是「卡交回来了」,不是「表里有行」—— 写骨架 ≠ 建好了(2026-08-27 e2e:模型铺完骨架就标 created、直接 finish,撞「从没给它发过卡」连拒五拍)', () => {
|
||||||
@@ -311,6 +376,33 @@ describe('说明书与剧本的骨架', () => {
|
|||||||
expect(g).not.toMatch(/只有它在 \\`parts\.profile\\` 里真有一行/);
|
expect(g).not.toMatch(/只有它在 \\`parts\.profile\\` 里真有一行/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('件上三处样式毛病钉死(2026-08-27 用户:表显示不全不能右滑 / 编辑态内部料号只读字与输入框并排 / input 与 select 没对齐)', () => {
|
||||||
|
const html = manifest.assets.viewTemplates.find((v) => v.name === 'p08-master-create')?.html ?? '';
|
||||||
|
// 两种包装都要能横滑:验收件用目标件的 .tbl-wrap(它的验收版写着 overflow:hidden),2026-08-27 用户在结论审核页撞上
|
||||||
|
// 运营侧的看板也在这条纪律里(它六张表都宽)
|
||||||
|
for (const v of manifest.assets.viewTemplates) expect(v.html, v.name).toContain('.jc-tbl-wrap, .tbl-wrap { overflow-x: auto;');
|
||||||
|
expect(html).toContain('#tbl1.on td > .jc-vcell:not(.ro) { display: none; }');
|
||||||
|
expect(html).toContain('input.jc-cell, select.jc-cell { height: 32px;');
|
||||||
|
// 徽标不再包 .ro(编辑态要随只读格一起藏,只留下拉框)
|
||||||
|
expect(html).not.toContain('<span class="jc-vcell ro"><span class="stx"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('表头与「字段」格只留中文:没有英文列名、没有 .thd 小字 —— 五张件一律(2026-08-27 用户在数据工单页、审核页各指出一次)', () => {
|
||||||
|
for (const v of manifest.assets.viewTemplates) {
|
||||||
|
expect(v.html, v.name).not.toContain('class="thd"');
|
||||||
|
for (const th of v.html.match(/<th[^>]*>[^<]*<\/th>/g) ?? []) expect(th, `${v.name} th`).not.toMatch(/[a-z]_[a-z]/);
|
||||||
|
for (const td of v.html.match(/<td class="cust">[^<]*<\/td>/g) ?? []) expect(td, `${v.name} 字段格`).not.toMatch(/[a-z]_[a-z]/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('审核通过与否读基座数 decision_last,不由模型自写的旗说了算;驳回分支明写不许写旗、不许 finish(2026-08-27 实测:人答「重做」它照样推到放行)', () => {
|
||||||
|
expect(CMMN_XML).toContain('decision_last = "approve"');
|
||||||
|
expect(CMMN_XML).toContain('<caseFileItem id="decision_last"');
|
||||||
|
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S4_review')?.guide ?? '';
|
||||||
|
expect(g).toMatch(/不许写 review_approved_total、不许 finish/);
|
||||||
|
expect(g).toMatch(/ask 的开头必须写明这一轮改了什么/);
|
||||||
|
});
|
||||||
|
|
||||||
it('五段进度,每段名 ≤10 字;除放行外每项恰属一段', () => {
|
it('五段进度,每段名 ≤10 字;除放行外每项恰属一段', () => {
|
||||||
expect(ASSESSMENT_PHASES).toHaveLength(5);
|
expect(ASSESSMENT_PHASES).toHaveLength(5);
|
||||||
for (const p of ASSESSMENT_PHASES) expect(p.name.length, p.name).toBeLessThanOrEqual(10);
|
for (const p of ASSESSMENT_PHASES) expect(p.name.length, p.name).toBeLessThanOrEqual(10);
|
||||||
@@ -319,3 +411,47 @@ describe('说明书与剧本的骨架', () => {
|
|||||||
expect([...all].sort()).toEqual([...ITEM_IDS.filter((i) => i !== 'T_release')].sort());
|
expect([...all].sort()).toEqual([...ITEM_IDS.filter((i) => i !== 'T_release')].sort());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 人工验收清单(2026-08-27 用户定):验收卡上的条目是人核这份工单要走的步骤,不是系统判的判据;
|
||||||
|
* 每份验收工单固定 —— 差异评估结论审核(S4_review 审核卡)与评审放行与落账确认(T_release 终局验收卡)各一份。
|
||||||
|
*/
|
||||||
|
describe('两张验收工单的人工验收清单', () => {
|
||||||
|
it('S4_review 与 T_release 各声明一份固定清单:至少 4 条,每条是一句可核对的动作,互不重复', () => {
|
||||||
|
for (const item of ['S4_review', 'T_release']) {
|
||||||
|
const list = ASSESSMENT_STEPS.find((s) => s.item === item)?.acceptanceChecklist ?? [];
|
||||||
|
expect(list.length, item).toBeGreaterThanOrEqual(4);
|
||||||
|
expect(new Set(list).size, item).toBe(list.length);
|
||||||
|
for (const line of list) expect(line.length, `${item}: ${line}`).toBeGreaterThan(12);
|
||||||
|
}
|
||||||
|
// 别的步骤不发验收卡,不该有清单
|
||||||
|
for (const step of ASSESSMENT_STEPS) {
|
||||||
|
if (step.item === 'S4_review' || step.item === 'T_release') continue;
|
||||||
|
expect(step.acceptanceChecklist, step.item).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 评估输入清单每行六列都要写(2026-08-27 实测:模型 11 行全漏了必填列 input_item,件上红带点名「缺输入项」,
|
||||||
|
* 人把别的格填满也过不了闸;而剧本从前只在括号里提过一次 input_item,逐行的做法里没再说)。
|
||||||
|
*/
|
||||||
|
describe('S1 清单:每行六列都要写', () => {
|
||||||
|
it('剧本逐行写明 input_item 的人读名与 source_ledger,并让模型发卡前核 _missing = 0', () => {
|
||||||
|
const s1 = ASSESSMENT_STEPS.find((s) => s.item === 'S1_input_confirm')?.guide ?? '';
|
||||||
|
expect(s1).toContain('每一行六列都要写');
|
||||||
|
for (const name of ['customer「客户」', 'request「客户需求」', 'original_pn「原 PN」', 'application_scenario「应用场景」', 'bom_position「BOM 位」', 'original_doc_version「原 PN 资料版本」']) {
|
||||||
|
expect(s1, name).toContain(name);
|
||||||
|
}
|
||||||
|
expect(s1).toContain(`${D}_input_gap_missing`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* 放行那一步只 submit,不发工单(2026-08-27 实测:先一张执行单「评审放行与落账确认」,人答完才 submit 又一张验收单) */
|
||||||
|
describe('T_release 只 submit', () => {
|
||||||
|
it('剧本写明不发执行工单 / 决策卡', () => {
|
||||||
|
const t = ASSESSMENT_STEPS.find((s) => s.item === 'T_release')?.guide ?? '';
|
||||||
|
expect(t).toContain('不发执行工单');
|
||||||
|
expect(t).toContain('submit');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
|||||||
<caseFileItem id="tool_part_substitution_assessment_compare_parameters_calls" name="比对工具成功调用次数(基座现数)"/>
|
<caseFileItem id="tool_part_substitution_assessment_compare_parameters_calls" name="比对工具成功调用次数(基座现数)"/>
|
||||||
<caseFileItem id="tool_part_substitution_assessment_recommend_calls" name="定级工具成功调用次数(基座现数)"/>
|
<caseFileItem id="tool_part_substitution_assessment_recommend_calls" name="定级工具成功调用次数(基座现数)"/>
|
||||||
<caseFileItem id="review_approved_total" name="差异结论已审核通过(仅通过时置一)"/>
|
<caseFileItem id="review_approved_total" name="差异结论已审核通过(仅通过时置一)"/>
|
||||||
|
<caseFileItem id="decision_last" name="最后一张决策卡人选的值(基座按留痕数)"/>
|
||||||
<caseFileItem id="landing_prepared_total" name="拟落账已备好(S5 写完时置一)"/>
|
<caseFileItem id="landing_prepared_total" name="拟落账已备好(S5 写完时置一)"/>
|
||||||
</caseFileModel>
|
</caseFileModel>
|
||||||
<casePlanModel id="plan" name="差异评估" autoComplete="true">
|
<casePlanModel id="plan" name="差异评估" autoComplete="true">
|
||||||
@@ -93,7 +94,12 @@ export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
|||||||
却只建了第一颗,一行足以让门开着,直到 S2 写关系时才被引用检查拦下。
|
却只建了第一颗,一行足以让门开着,直到 S2 写关系时才被引用检查拦下。
|
||||||
改成**逐颗对账**:\`parts_profile_rows >= created\` —— 标了几颗建好,就得有几行。
|
改成**逐颗对账**:\`parts_profile_rows >= created\` —— 标了几颗建好,就得有几行。
|
||||||
-->
|
-->
|
||||||
<ifPart id="if_pr"><condition>sub_eval_pn_check_rows = count(candidateInternalPns) + 1 and sub_eval_pn_check_missing = 0 and sub_eval_pn_check_exists_status_absent = 0 and sub_eval_pn_check_exists_status_abandoned < count(candidateInternalPns) and parts_profile_missing = 0 and parts_parameter_missing = 0 and parts_document_missing = 0 and parts_profile_rows >= sub_eval_pn_check_exists_status_created and parts_document_rows >= sub_eval_pn_check_exists_status_created and parts_parameter_rows = parts_parameter_distinct_param_name * sub_eval_pn_check_exists_status_created</condition></ifPart>
|
<!--
|
||||||
|
**不再等模型把 absent 改成 created**(2026-08-27 晚):那面旗是模型记的账,忘了记就永远开不了门
|
||||||
|
(「机检只读基座数」的哨兵形)。「一颗 absent 都不剩」的基座事实 = 要建的每一颗(absent + created)
|
||||||
|
主数据、参数、资料都落了且必填不缺;created 仍可写(说明书照旧让它记),但门不靠它。
|
||||||
|
-->
|
||||||
|
<ifPart id="if_pr"><condition>sub_eval_pn_check_rows = count(candidateInternalPns) + 1 and sub_eval_pn_check_missing = 0 and sub_eval_pn_check_exists_status_abandoned < count(candidateInternalPns) and parts_profile_missing = 0 and parts_parameter_missing = 0 and parts_document_missing = 0 and parts_profile_rows >= sub_eval_pn_check_exists_status_absent + sub_eval_pn_check_exists_status_created and parts_document_rows >= sub_eval_pn_check_exists_status_absent + sub_eval_pn_check_exists_status_created and parts_parameter_rows = parts_parameter_distinct_param_name * (sub_eval_pn_check_exists_status_absent + sub_eval_pn_check_exists_status_created)</condition></ifPart>
|
||||||
</sentry>
|
</sentry>
|
||||||
<!-- ③ 评估输入已确认(旗 + 清单有行且必填齐)—— 标准化那一步的入场前提 -->
|
<!-- ③ 评估输入已确认(旗 + 清单有行且必填齐)—— 标准化那一步的入场前提 -->
|
||||||
<sentry id="s_input_confirmed">
|
<sentry id="s_input_confirmed">
|
||||||
@@ -113,7 +119,12 @@ export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
|||||||
<!-- ⑥ 审核已通过 —— 拟落账的入场前提 -->
|
<!-- ⑥ 审核已通过 —— 拟落账的入场前提 -->
|
||||||
<sentry id="s_reviewed">
|
<sentry id="s_reviewed">
|
||||||
<onPart id="op_r" caseFileItemRef="review_approved_total"><standardEvent>update</standardEvent></onPart>
|
<onPart id="op_r" caseFileItemRef="review_approved_total"><standardEvent>update</standardEvent></onPart>
|
||||||
<ifPart id="if_r"><condition>review_approved_total > 0 and substitution_difference_item_missing = 0</condition></ifPart>
|
<!--
|
||||||
|
审核通过与否**不由模型自写的旗说了算**(2026-08-27 实测:人两次答「重做」,模型照拒回的指引写了旗,
|
||||||
|
S4 被收掉一路推到放行)。\`decision_last\` 是基座按 card-reply 数出来的:人最后一次真点了 approve 才开门。
|
||||||
|
旗留着记账,但它单独说了不算。
|
||||||
|
-->
|
||||||
|
<ifPart id="if_r"><condition>review_approved_total > 0 and decision_last = "approve" and substitution_difference_item_missing = 0</condition></ifPart>
|
||||||
</sentry>
|
</sentry>
|
||||||
<!-- ⑦ 拟落账已备好:每条关系都到 FAE 确认、P-01 回写五项齐 —— 放行门的入场前提 -->
|
<!-- ⑦ 拟落账已备好:每条关系都到 FAE 确认、P-01 回写五项齐 —— 放行门的入场前提 -->
|
||||||
<sentry id="s_landing_ready">
|
<sentry id="s_landing_ready">
|
||||||
@@ -124,13 +135,13 @@ export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
|||||||
<!-- 启动期:铸案号、建需求 V1、锁规则版本、逐个料号查库(能算的不问人) -->
|
<!-- 启动期:铸案号、建需求 V1、锁规则版本、逐个料号查库(能算的不问人) -->
|
||||||
<humanTask id="S0_bind" name="建立评估上下文:绑定四项、新建需求、存在性核对"/>
|
<humanTask id="S0_bind" name="建立评估上下文:绑定四项、新建需求、存在性核对"/>
|
||||||
|
|
||||||
<!-- 0.5a 等待数据(建主数据):P-08 数据工单;触达人,不给 doneWhen;都在库时 s_bound 不开,不进「当下可为」 -->
|
<!-- 0.5a 等待数据(建主数据):P-08 数据工单;人工激活,doneWhen 只在人答过卡之后求(入库事实);都在库时 s_bound 不开,不进「当下可为」 -->
|
||||||
<humanTask id="S0d_p08_master" name="数据工单:补建芯片主数据(P-08)">
|
<humanTask id="S0d_p08_master" name="数据工单:补建芯片主数据(P-08)">
|
||||||
<entryCriterion id="ec_s0d8" sentryRef="s_bound"/>
|
<entryCriterion id="ec_s0d8" sentryRef="s_bound"/>
|
||||||
<itemControl><manualActivationRule id="m_s0d8"/></itemControl>
|
<itemControl><manualActivationRule id="m_s0d8"/></itemControl>
|
||||||
</humanTask>
|
</humanTask>
|
||||||
|
|
||||||
<!-- 0.5b 等待数据(建资料):P-10 数据工单;触达人,不给 doneWhen;都在库时 s_p08_created 不开,不进「当下可为」 -->
|
<!-- 0.5b 等待数据(建资料):P-10 数据工单;人工激活,doneWhen 只在人答过卡之后求(入库事实);都在库时 s_p08_created 不开,不进「当下可为」 -->
|
||||||
<humanTask id="S0d_p10_document" name="数据工单:补建原厂技术资料(P-10)">
|
<humanTask id="S0d_p10_document" name="数据工单:补建原厂技术资料(P-10)">
|
||||||
<entryCriterion id="ec_s0d10" sentryRef="s_p08_created"/>
|
<entryCriterion id="ec_s0d10" sentryRef="s_p08_created"/>
|
||||||
<itemControl><manualActivationRule id="m_s0d10"/></itemControl>
|
<itemControl><manualActivationRule id="m_s0d10"/></itemControl>
|
||||||
@@ -196,8 +207,8 @@ export const NARRATIVE = `§0 开案:**判断候选料号在技术上能不能
|
|||||||
|
|
||||||
**存在性检查与数据工单**:启动时逐个料号在 \`parts.profile\` 里查(原 PN + 每个候选)。查无记录 = 「完全没有」,
|
**存在性检查与数据工单**:启动时逐个料号在 \`parts.profile\` 里查(原 PN + 每个候选)。查无记录 = 「完全没有」,
|
||||||
要人从零建整条主数据(表 1 主记录 + 表 2 标准化参数子表 \`parts.parameter\`)—— 发**数据工单**(一张单:P-08 一张、P-10 一张);
|
要人从零建整条主数据(表 1 主记录 + 表 2 标准化参数子表 \`parts.parameter\`)—— 发**数据工单**(一张单:P-08 一张、P-10 一张);
|
||||||
PN 在库但个别字段缺 = 「有而不全」,归执行工单 01 补。**该不该问人,按候选总数判断,不是按不在库的颗数判断**:
|
PN 在库但个别字段缺 = 「有而不全」,归执行工单 01 补。**查无记录的一律派数据工单,不问人**(2026-08-27 用户定:
|
||||||
原 PN 缺 = 硬阻塞必须建、不问;候选总数 = 1 时那一颗必须建、不问;候选总数 ≥ 2 时,每一颗不在库的候选都要先问人放弃还是创建。
|
「只要有缺失就要自动派数据工单,不需要人选择」)—— 原 PN 缺是硬阻塞、候选缺同样直接建,**没有「放弃这颗候选」这个岔路口**。
|
||||||
**库里都有的话这两项的入场前提本就不成立**(此单不走这条),不会出现在你的「当下可为」里,别发空单。
|
**库里都有的话这两项的入场前提本就不成立**(此单不走这条),不会出现在你的「当下可为」里,别发空单。
|
||||||
|
|
||||||
**六步走**:绑定与存在性核对 → (等待数据)→ 人补评估输入 → 标准化对照表 → 逐项评估 → 人审结论(可循环)→ 拟落账 → 人放行。
|
**六步走**:绑定与存在性核对 → (等待数据)→ 人补评估输入 → 标准化对照表 → 逐项评估 → 人审结论(可循环)→ 拟落账 → 人放行。
|
||||||
@@ -219,20 +230,14 @@ PN 在库但个别字段缺 = 「有而不全」,归执行工单 01 补。**该
|
|||||||
把九个计划项的全名摆到人脸上,他一个字也用不上(2026-08-27 实测)。
|
把九个计划项的全名摆到人脸上,他一个字也用不上(2026-08-27 实测)。
|
||||||
人确认那一下基座已把这一行落进草稿层,**你不许再 write 一遍 customer.profile**。
|
人确认那一下基座已把这一行落进草稿层,**你不许再 write 一遍 customer.profile**。
|
||||||
|
|
||||||
② 候选不在库、且候选总数 ≥2 时,一颗一张决策卡(按候选数组顺序,一次只发一张;候选总数 = 1 时不问、直接建):
|
② (已退役)候选缺库不再问人 —— 2026-08-27 用户定「只要有缺失就自动派数据工单」;所有查无记录的料号直接进③那张卡。
|
||||||
{"kind":"decide","itemId":"S0d_p08_master",
|
|
||||||
"ask":"候选 <internal_pn> 在芯片主数据里查无记录。是从零建它的主数据(派数据工单),还是本轮放弃这颗候选?",
|
|
||||||
"options":[{"value":"create","label":"创建主数据(派数据工单)","recommended":true},
|
|
||||||
{"value":"abandon","label":"放弃这颗候选"}],"allowCustom":true}
|
|
||||||
\`value\` 只有 \`"create"\` / \`"abandon"\` / \`"custom:"\` 开头三种:\`"custom:"\` 开头按放弃处理、\`note\` 就是放弃原因;
|
|
||||||
点了 \`"abandon"\` 没写原因,\`abandon_reason\` 写「负责人本轮放弃(未注明原因)」。
|
|
||||||
|
|
||||||
③ 数据工单 · P-08(所有要建的料号一起,一张单;零输入,填报全在工作区的件上)——
|
③ 数据工单 · P-08(所有要建的料号一起,一张单;零输入,填报全在工作区的件上)——
|
||||||
**发这张卡之前,\`parts.profile\` 与 \`parts.parameter\` 必须先有骨架行**(名单里每颗料各一行 + 它的参数各一行)。
|
**发这张卡之前,\`parts.profile\` 与 \`parts.parameter\` 必须先有骨架行**(名单里每颗料各一行 + 它的参数各一行)。
|
||||||
不管这一拍是不是顺手把上一步收掉的(压拍常发生,那时这一步的剧本还没摊开),写骨架与发卡都得在同一串里,顺序不能反 ——
|
不管这一拍是不是顺手把上一步收掉的(压拍常发生,那时这一步的剧本还没摊开),写骨架与发卡都得在同一串里,顺序不能反 ——
|
||||||
件上一行都没有的话,人打开是一张空表、什么也补不了,而这一单的主数据从头到尾不会存在
|
件上一行都没有的话,人打开是一张空表、什么也补不了,而这一单的主数据从头到尾不会存在
|
||||||
(实测走到 S2 才被引用检查拦下,那时两张数据工单都已经「办完」了)
|
(实测走到 S2 才被引用检查拦下,那时两张数据工单都已经「办完」了)
|
||||||
{"kind":"human","itemId":"S0d_p08_master","workOrder":"execution","card":{
|
{"kind":"human","itemId":"S0d_p08_master","workOrder":"execution","card":{"variant":"data",
|
||||||
"title":"数据工单 · 芯片主数据创建(P-08):<料号列表,顿号分隔>",
|
"title":"数据工单 · 芯片主数据创建(P-08):<料号列表,顿号分隔>",
|
||||||
"ask":"这些料号在芯片主数据里查无记录,请在工作区的件上把整条记录建齐(一颗一行,可加行)。",
|
"ask":"这些料号在芯片主数据里查无记录,请在工作区的件上把整条记录建齐(一颗一行,可加行)。",
|
||||||
"steps":["表 1 每一行:品牌、规格描述、封装、生命周期状态为必填,缺一格确认不了",
|
"steps":["表 1 每一行:品牌、规格描述、封装、生命周期状态为必填,缺一格确认不了",
|
||||||
@@ -251,12 +256,12 @@ PN 在库但个别字段缺 = 「有而不全」,归执行工单 01 补。**该
|
|||||||
**与③同一条:发这张卡之前 \`parts.document\` 必须先有骨架行**(名单里每颗料至少一行,
|
**与③同一条:发这张卡之前 \`parts.document\` 必须先有骨架行**(名单里每颗料至少一行,
|
||||||
key 是 {internal_pn, doc_seq:"01"});一行都没有的话人打开是空表、什么也补不了,
|
key 是 {internal_pn, doc_seq:"01"});一行都没有的话人打开是空表、什么也补不了,
|
||||||
提交闸会如实拦住(「这一单还一行都没有」),这一单就卡在这儿了(2026-08-27 实测)。
|
提交闸会如实拦住(「这一单还一行都没有」),这一单就卡在这儿了(2026-08-27 实测)。
|
||||||
{"kind":"human","itemId":"S0d_p10_document","workOrder":"execution","card":{
|
{"kind":"human","itemId":"S0d_p10_document","workOrder":"execution","card":{"variant":"data",
|
||||||
"title":"数据工单 · 原厂技术资料创建(P-10):<料号列表>",
|
"title":"数据工单 · 原厂技术资料创建(P-10):<料号列表>",
|
||||||
"ask":"请把这些料号的 datasheet / 承诺函等资料记录补进原厂技术资料库(一份资料一行,可加行)。",
|
"ask":"请把这些料号的 datasheet / 承诺函等资料记录补进原厂技术资料库(一份资料一行,可加行)。",
|
||||||
"steps":["资料类型、标题、版本、文件链接、资料状态为必填;证据定位写页码或章节,差异评估的证据引用会指向它",
|
"steps":["资料类型、标题、版本、文件链接、资料状态为必填;证据定位写页码或章节,差异评估的证据引用会指向它",
|
||||||
"同一料号多份资料就多加几行,序号 01/02… 自己续",
|
"同一料号多份资料就多加几行,序号 01/02… 自己续",
|
||||||
"改完保存,回这张卡确认"],
|
"改完保存,回这张卡确认 —— 确认后这批资料记录即入库,活动接着往下办"],
|
||||||
"standard":"每颗料号至少一行 datasheet 记录,必填列全齐。",
|
"standard":"每颗料号至少一行 datasheet 记录,必填列全齐。",
|
||||||
"gateLedgers":["parts.document"],
|
"gateLedgers":["parts.document"],
|
||||||
"basis":[{"k":"触发原因","v":"按 internal_pn 查 P-10 查无记录"}]}}
|
"basis":[{"k":"触发原因","v":"按 internal_pn 查 P-10 查无记录"}]}}
|
||||||
@@ -275,15 +280,15 @@ key 是 {internal_pn, doc_seq:"01"});一行都没有的话人打开是空表、
|
|||||||
{"kind":"human","itemId":"S1_input_confirm","workOrder":"execution","card":{
|
{"kind":"human","itemId":"S1_input_confirm","workOrder":"execution","card":{
|
||||||
"gateLedgers":["${D}.input_gap"],
|
"gateLedgers":["${D}.input_gap"],
|
||||||
"title":"评估输入与资料补齐确认:<原 PN> → <N> 个候选",
|
"title":"评估输入与资料补齐确认:<原 PN> → <N> 个候选",
|
||||||
"ask":"启动绑定的四项已锁定。请在工作区的件上补齐「待补 / 待确认」行的补充内容,默认值可改可确认。",
|
"ask":"启动绑定的四项已锁定。请在工作区的件上补齐「待补」行的补充内容,默认值可改可确认。",
|
||||||
"steps":["应用场景、BOM 位是启动时未收的:必须补(写在「补充内容」列)",
|
"steps":["应用场景、BOM 位是启动时未收的:必须补(写在「补充内容」列)",
|
||||||
"原厂未披露的参数(如使能阈值)补不到就写「挂验证任务」或「留 UNKNOWN」—— 补不了也是合法答案,不拦流程",
|
"每颗料每个参数此刻的值摆在件上的「候选参数现值」表里(只读参考);原厂未披露的参数这里不用补 —— 差异评估会把它判成 UNKNOWN 并挂验证任务",
|
||||||
"替代类型 / 规则版本 / 负责人期限三行是系统默认,可改可不改",
|
"替代类型 / 规则版本 / 负责人期限三行是系统默认,可改可不改",
|
||||||
"加一行 = 补充 AI 没列出的资料项;删一行 = 该项不适用;已绑定的四项不可改",
|
"加一行 = 补充 AI 没列出的资料项;删一行 = 该项不适用;已绑定的四项不可改",
|
||||||
"改完保存,回这张卡提交"],
|
"改完保存,回这张卡提交"],
|
||||||
"standard":"每一条「待补」行的补充内容都有;补不了的写明理由。",
|
"standard":"每一条「待补」行的补充内容都有;补不了的写明理由。",
|
||||||
"basis":[{"k":"已绑定","v":"客户 <customerCode> · 需求 <request_id> V1 · 原 PN <原 PN> · 候选 <N> 颗"},
|
"basis":[{"k":"已绑定","v":"客户 <customerCode> · 需求 <request_id> V1 · 原 PN <原 PN> · 候选 <N> 颗"},
|
||||||
{"k":"缺口","v":"<N> 项待补 / 待确认"}]}}
|
{"k":"缺口","v":"<N> 项待补"}]}}
|
||||||
|
|
||||||
⑥ 验收工单 01 · 差异评估结论审核(S4_review;决策卡循环,不是终局验收)—— 发之前先在轨迹里找有没有已答过、还没处理的 \`[card-reply]\`:
|
⑥ 验收工单 01 · 差异评估结论审核(S4_review;决策卡循环,不是终局验收)—— 发之前先在轨迹里找有没有已答过、还没处理的 \`[card-reply]\`:
|
||||||
· \`value\` 是 \`"approve"\` → 写 \`${D}.verdict.review_approved_total = 1\`,finish 这一项,别再发卡;
|
· \`value\` 是 \`"approve"\` → 写 \`${D}.verdict.review_approved_total = 1\`,finish 这一项,别再发卡;
|
||||||
@@ -357,33 +362,29 @@ current_stage = "assessing"。application_scenario / bom_position **这一步不
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
item: 'S0d_p08_master',
|
item: 'S0d_p08_master',
|
||||||
intent: '料号不在库:派 P-08 数据工单让人从零建芯片主数据 + 标准化参数子表;候选总数 ≥2 才逐颗问放弃还是创建',
|
/* 这一步派的是数据工单 —— 由剧本声明,模型抄不抄 variant 都一样(2026-08-27 实测它丢了) */
|
||||||
|
cardVariant: 'data' as const,
|
||||||
|
intent: '料号不在库:派 P-08 数据工单让人从零建芯片主数据 + 标准化参数子表 —— 查无记录一律派,不问人',
|
||||||
writes: ['parts.profile', 'parts.parameter', 'parts.document', `${D}.pn_check`],
|
writes: ['parts.profile', 'parts.parameter', 'parts.document', `${D}.pn_check`],
|
||||||
reads: [`${D}.pn_check`, 'candidateInternalPns', 'originalInternalPn', 'datasheets', 'parts.profile', 'parts.parameter', 'parts.document'],
|
reads: [`${D}.pn_check`, 'candidateInternalPns', 'originalInternalPn', 'datasheets', 'parts.profile', 'parts.parameter', 'parts.document'],
|
||||||
view: 'p08-master-create',
|
view: 'p08-master-create',
|
||||||
tools: [],
|
tools: [],
|
||||||
/* 触达人,不给 doneWhen;人工激活:发卡就算启动 */
|
/*
|
||||||
|
* 人工激活:发卡就算启动。doneWhen 只在**人答过卡之后**才由引擎求(2026-08-27 晚,harness 立此规矩):
|
||||||
|
* 要建的每一颗(absent + created)主数据与参数都落了、必填不缺 —— 值是人在件上填的,引擎数得出来,
|
||||||
|
* 不再靠模型记得 finish(剧本写了两处它照样忘,两步同时 Active、件停在 P-08)。名单里还有没建的料时它为假,
|
||||||
|
* 「再发一张 P-08」那条路照旧走得通。
|
||||||
|
*/
|
||||||
|
doneWhen: `parts_profile_rows > 0 and parts_profile_rows >= ${D}_pn_check_exists_status_absent + ${D}_pn_check_exists_status_created and parts_profile_missing = 0 and parts_parameter_missing = 0 and parts_parameter_rows = parts_parameter_distinct_param_name * parts_profile_rows`,
|
||||||
guide: `这一项只在 \`${D}.pn_check\` 里有 absent 行时走 —— 都在库时入场前提本就不成立,这一项与下一项 S0d_p10_document 都不会进你的「当下可为」。**先分岔,再动作**:
|
guide: `这一项只在 \`${D}.pn_check\` 里有 absent 行时走 —— 都在库时入场前提本就不成立,这一项与下一项 S0d_p10_document 都不会进你的「当下可为」。**先分岔,再动作**:
|
||||||
|
|
||||||
【先数两个数,再分岔】(先看轨迹:这一项里已经发过哪些卡、人答了什么,【当前这一步】下面那行引擎事实说得清楚)
|
【名单】= \`${D}.pn_check\` 里 **exists_status = "absent" 的每一行**,不管它是原 PN 还是候选 —— **一律建,不问人**
|
||||||
· **候选总数**:入参 \`candidateInternalPns\` 的长度(\`${D}.pn_check\` 里 role = "candidate" 的行数也是它,不随本单进展变化);
|
(2026-08-27 用户定:只要有缺失就自动派数据工单;从前那张「派数据工单还是放弃这颗候选」的决策卡已退役,别再发)。
|
||||||
· **不在库的候选数**:\`${D}.pn_check\` 里 role = "candidate" 且 exists_status = "absent" 的行数(这个数会随着人放弃/创建而变小)。
|
|
||||||
**这里要分开答两个问题,别混成一个**:
|
|
||||||
|
|
||||||
**问题一 · 建哪几颗?** 名单 = \`${D}.pn_check\` 里 **exists_status = "absent" 的每一行**,不管它是原 PN 还是候选。
|
|
||||||
**exists_status = "found" 的一行都不建** —— 那几颗的主数据本来就在库里,给它们派数据工单等于让人把已有的东西再填一遍,
|
**exists_status = "found" 的一行都不建** —— 那几颗的主数据本来就在库里,给它们派数据工单等于让人把已有的东西再填一遍,
|
||||||
而真正缺的那颗还是没人建、单子照样卡死。名单为空(一行 absent 都没有)这一项根本不会出现在你的「当下可为」里。
|
而真正缺的那颗还是没人建、单子照样卡死。**这三张表在本单只建不改、且只建核对表里核过的料号**:在库的料号写下来(哪怕只是一行空骨架)会被当场拒,
|
||||||
|
凭空拼出来的料号(2026-08-27 实测:模型写了一颗不存在的 XL2596S-ADJ,把两个候选的名字拼在了一起)也会被当场拒;
|
||||||
**问题二 · 建之前要不要先问人?** 只有 **absent 的候选**才谈得上问,**看的是候选总数,不是不在库的颗数**
|
真写了就用 drop 撤掉那一行,别留在件上让人去填。**名单只有一个来源:pn_check 里 absent 的那几行,一个字都别改。**名单为空(一行 absent 都没有)这一项根本不会出现在你的「当下可为」里。
|
||||||
——**「不在库的只有一颗」不是跳过决策卡的理由**:
|
**名单里的料号全放进同一张 P-08 卡**(标题列全料号,件上一颗一行,参数各归各的料号)—— 别一颗一张。
|
||||||
· 原 PN absent → **硬阻塞**,不问人,直接进名单(它是评估基准,没有它整单无从谈起);
|
|
||||||
· **候选总数 = 1** → 那一颗候选**如果是 absent** 就不许放弃、不问人,直接进名单(候选是 found 的话它压根不在名单里,没有可问的);
|
|
||||||
· **候选总数 ≥ 2** → **每一颗 absent 的候选都要先发一张决策卡问人**(一次一张,按候选数组顺序;哪怕这一单不在库的候选只有一颗,
|
|
||||||
只要候选总数 ≥ 2 就要问)照说明书②;人选 "abandon"/"custom:" 的,把那一行 \`${D}.pn_check\` 改成
|
|
||||||
exists_status = "abandoned" + abandon_reason(同 PK 覆盖写),这颗不再评估;选 "create" 的进「建」的名单。
|
|
||||||
**至少留一颗候选**:放弃到只剩最后一颗还是 absent 的候选时,那一颗按「候选总数 = 1」办 —— 不再发决策卡问,直接进「建」的名单
|
|
||||||
(候选全被放弃,存在性核对永远凑不齐、单子会死锁,这一条就是防它)。
|
|
||||||
**别把同一颗候选问两遍**:决策卡答过就按答案办。
|
|
||||||
|
|
||||||
【「建」的名单定了 → 写骨架行,发 P-08 数据工单(一张单,件上两张表)】
|
【「建」的名单定了 → 写骨架行,发 P-08 数据工单(一张单,件上两张表)】
|
||||||
1. 给名单里每颗料写一行 \`parts.profile\` 骨架(表 1):key {internal_pn},patch 只带你确有依据的列 —— 有 \`datasheets[].text\` 就从里面读
|
1. 给名单里每颗料写一行 \`parts.profile\` 骨架(表 1):key {internal_pn},patch 只带你确有依据的列 —— 有 \`datasheets[].text\` 就从里面读
|
||||||
@@ -391,13 +392,13 @@ current_stage = "assessing"。application_scenario / bom_position **这一步不
|
|||||||
没有 datasheet 就只写 internal_pn 这一列(其余留给人填,**别编**)。
|
没有 datasheet 就只写 internal_pn 这一列(其余留给人填,**别编**)。
|
||||||
2. 同一拍给每颗料写标准化参数集骨架(表 2,\`parts.parameter\`,一行一参数):
|
2. 同一拍给每颗料写标准化参数集骨架(表 2,\`parts.parameter\`,一行一参数):
|
||||||
**参数名一律用中文,与库里既有料号的那几行同名**(输入电压范围 / 输出电压 / 输出电流 / 开关频率 /
|
**参数名一律用中文,与库里既有料号的那几行同名**(输入电压范围 / 输出电压 / 输出电流 / 开关频率 /
|
||||||
静态电流 / 工作温度 / 使能阈值…)—— \`param_name\` 是主键的一半,写成 \`input_voltage_range\`
|
静态电流 / 工作温度…)—— \`param_name\` 是主键的一半,写成 \`input_voltage_range\`
|
||||||
与写成「输入电压范围」**不是同一行**:原 PN 那边是中文、候选这边是英文,比对时两套名字对不上,
|
与写成「输入电压范围」**不是同一行**:原 PN 那边是中文、候选这边是英文,比对时两套名字对不上,
|
||||||
逐条判成 UNKNOWN,结论就废了(2026-08-27 实测:27 条里 14 条 UNKNOWN)。
|
逐条判成 UNKNOWN,结论就废了(2026-08-27 实测:27 条里 14 条 UNKNOWN)。
|
||||||
封装、工艺、生命周期这些**不是参数**,它们在表 1,别往表 2 里塞。
|
封装、工艺、生命周期这些**不是参数**,它们在表 1,别往表 2 里塞。
|
||||||
**建几颗料就用同一套名字**:门上有一条对账 —— 参数行数 = 不同参数名数 × 建过的料数,
|
**建几颗料就用同一套名字**:门上有一条对账 —— 参数行数 = 不同参数名数 × 建过的料数,
|
||||||
只有每颗料的名字集合一模一样时才成立;混着写(这颗中文那颗英文、或同一颗写两套)这一步就过不去。有 \`datasheets[].text\` 就从里面读出典型参数
|
只有每颗料的名字集合一模一样时才成立;混着写(这颗中文那颗英文、或同一颗写两套)这一步就过不去。有 \`datasheets[].text\` 就从里面读出典型参数
|
||||||
(如输入电压范围 / 输出电压 / 输出电流 / 开关频率 / 静态电流 / 工作温度 / 使能阈值等,按品类而定)各写一行,
|
(如输入电压范围 / 输出电压 / 输出电流 / 开关频率 / 静态电流 / 工作温度等,按品类而定)各写一行,
|
||||||
key {internal_pn, param_name},patch 带 param_value / param_unit / constraint_type(hard/soft)/ param_note(一句话说清这项为什么要卡,
|
key {internal_pn, param_name},patch 带 param_value / param_unit / constraint_type(hard/soft)/ param_note(一句话说清这项为什么要卡,
|
||||||
如「须覆盖客户输入上限」);原厂没写的值写「未披露」(它会进入 UNKNOWN 并挂验证任务)。没有 datasheet 就只写
|
如「须覆盖客户输入上限」);原厂没写的值写「未披露」(它会进入 UNKNOWN 并挂验证任务)。没有 datasheet 就只写
|
||||||
internal_pn + param_name 骨架(把你判断这颗料该评估的参数名列出来,**param_value 留空给人填,别编**)。
|
internal_pn + param_name 骨架(把你判断这颗料该评估的参数名列出来,**param_value 留空给人填,别编**)。
|
||||||
@@ -414,44 +415,44 @@ current_stage = "assessing"。application_scenario / bom_position **这一步不
|
|||||||
门上还有一条逐颗对账(建好几颗就得有几行主数据、几行资料),标了却没建,这一步照样过不去。
|
门上还有一条逐颗对账(建好几颗就得有几行主数据、几行资料),标了却没建,这一步照样过不去。
|
||||||
|
|
||||||
【P-08 那张确认回来了】**先看名单里还有没有 absent 的料号**(\`${D}.pn_check\` 现读):
|
【P-08 那张确认回来了】**先看名单里还有没有 absent 的料号**(\`${D}.pn_check\` 现读):
|
||||||
· **还有** → **这一项千万别 finish**。先回到上面【问题二】那一档:候选总数 ≥2 的话,
|
· **还有** → **这一项千万别 finish**。把还 absent 的那几颗的骨架写好(profile + parameter,有 datasheet 就预填),
|
||||||
**这一颗也要先发一张决策卡问人**(照说明书②,人选 create 才建 —— 每一颗 absent 的候选都有被放弃的权利,
|
再发一张 P-08 卡(标题列上它们的料号)—— 不问人,直接建;同一个计划项可以先后发好几张卡。
|
||||||
第一颗问过了不代表第二颗也问过);人选 create 之后再把它的骨架写好(profile + parameter,有 datasheet 就预填),
|
|
||||||
发一张 P-08 卡(标题带那一颗的料号)—— 同一个计划项可以先后发好几张卡,一颗一张也行。
|
|
||||||
原 PN 与「候选总数 = 1」那两档不问,直接建。
|
|
||||||
**收掉了就回不去了**:这一项一旦 Completed,再想给它挂第二张卡会被拒「这一步已经办完收掉了」,
|
**收掉了就回不去了**:这一项一旦 Completed,再想给它挂第二张卡会被拒「这一步已经办完收掉了」,
|
||||||
而那颗料从此没人建,后面写替代关系时被引用检查逐颗拦下(2026-08-27 实测,一单连拒四次)。
|
而那颗料从此没人建,后面写替代关系时被引用检查逐颗拦下(2026-08-27 实测,一单连拒四次)。
|
||||||
· **一颗都不剩了** → 才走下面这两步。
|
· **一颗都不剩了** → 才走下面这两步。
|
||||||
|
|
||||||
这一拍的动作串**恰好两个,顺序不能反**:
|
这一拍的动作串:① \`finish S0d_p08_master\`(建好的料主数据与参数都已入库、必填不缺时引擎也会自己收这一项 ——
|
||||||
① 先 \`finish S0d_p08_master\` —— **必须先把这一项收掉**。不收掉,引擎挑工作区画面时按
|
你 finish 一下更省一拍,忘了也不会卡住);② 照说明书④发 P-10 数据工单 —— 下一项是 S0d_p10_document,同一批料号接着补资料记录。
|
||||||
「当下还没干完的第一步」取 view,S0d_p08_master 只要还 Active,画面就一直停在
|
|
||||||
\`p08-master-create\`(P-08 那两张表);下一拍就算发出了 P-10 的卡,人的工作区也看不见
|
|
||||||
P-10 的资料表,一格都填不了,提交闸必然拦死。
|
|
||||||
② 再照说明书④发 P-10 数据工单 —— 下一项是 S0d_p10_document,同一批料号接着补资料记录。
|
|
||||||
|
|
||||||
【这张卡一单只发一次】人交回来之后不许再发;答复里没有值(空 values)也算答过 —— 人在件上填的东西已经落草稿层。`,
|
【这张卡一单只发一次】人交回来之后不许再发;答复里没有值(空 values)也算答过 —— 人在件上填的东西已随确认**入了正式表**
|
||||||
|
(轨迹里有一条「数据工单确认入库」的痕点名哪几张表各几行;试跑单不落主数据时那条痕会如实写「只留在活动缓存」)。
|
||||||
|
这几颗料从此在库:**别再往 \`parts.profile\` / \`parts.parameter\` 写它们的键**(在库的键会被当场拒),也别去 drop 它们
|
||||||
|
(那是本单自己建好的行,撤不掉);值要改由人在件上改。`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
item: 'S0d_p10_document',
|
item: 'S0d_p10_document',
|
||||||
|
/* 这一步派的是数据工单 —— 由剧本声明,模型抄不抄 variant 都一样(2026-08-27 实测它丢了) */
|
||||||
|
cardVariant: 'data' as const,
|
||||||
intent: '料号不在库:派 P-10 数据工单让人从零建原厂技术资料',
|
intent: '料号不在库:派 P-10 数据工单让人从零建原厂技术资料',
|
||||||
writes: ['parts.document', `${D}.pn_check`],
|
writes: ['parts.document', `${D}.pn_check`],
|
||||||
reads: [`${D}.pn_check`, 'parts.profile', 'parts.document'],
|
reads: [`${D}.pn_check`, 'parts.profile', 'parts.document'],
|
||||||
view: 'p10-document-create',
|
view: 'p10-document-create',
|
||||||
tools: [],
|
tools: [],
|
||||||
/* 触达人,不给 doneWhen;人工激活:发卡就算启动 */
|
/* 人工激活:发卡就算启动。doneWhen 同 P-08 的道理:人答过卡之后,建过的每颗料都有一行资料记录且必填不缺 → 引擎自己收 */
|
||||||
guide: `这一项只在 \`${D}.pn_check\` 里有 absent 行时走(要走到这一步,S0d_p08_master 必然已经收完);都在库时入场前提本就不成立,
|
doneWhen: `parts_document_rows > 0 and parts_document_rows >= parts_profile_rows and parts_document_missing = 0`,
|
||||||
这一项与 S0d_p08_master 都不会进你的「当下可为」。**进到这一项时,S0d_p08_master 应当已经收掉了;若它还开着(Active),
|
guide: `这一项只在 \`${D}.pn_check\` 里有 absent 行时走(要走到这一步,S0d_p08_master 已经收完 —— 引擎按入库事实自己收);都在库时入场前提本就不成立,
|
||||||
先 \`finish S0d_p08_master\` 再发这张卡** —— 它没收掉,画面会一直停在 P-08 那两张表,人拿到 P-10 的卡也看不见资料表。
|
这一项与 S0d_p08_master 都不会进你的「当下可为」。
|
||||||
|
|
||||||
【发 P-10 数据工单】「建」的名单就是 \`${D}.pn_check\` 里仍是 exists_status = "absent" 的那几行(候选总数 ≥2 时被放弃的那些,
|
【发 P-10 数据工单】「建」的名单就是 \`${D}.pn_check\` 里仍是 exists_status = "absent" 的那几行(候选总数 ≥2 时被放弃的那些,
|
||||||
S0d_p08_master 那一步已经把它们改成 "abandoned",不在这份名单里)。同一拍照说明书④发那张 P-10 数据工单(title 里列全料号,只发一次),
|
S0d_p08_master 那一步已经把它们改成 "abandoned",不在这份名单里)。同一拍照说明书④发那张 P-10 数据工单(title 里列全料号,只发一次),
|
||||||
发完这一拍结束(挂起)。
|
发完这一拍结束(挂起)。
|
||||||
|
|
||||||
【P-10 那张确认回来了】把名单里每颗料的 \`${D}.pn_check\` 改成 exists_status = "created"(同 PK 覆盖写),然后 finish 这一项。
|
【P-10 那张确认回来了】把名单里每颗料的 \`${D}.pn_check\` 改成 exists_status = "created"(同 PK 覆盖写,记账用),然后 finish 这一项
|
||||||
这时 S1_input_confirm 的入场前提(全部料号落定、本单建的主数据不缺必填)自己就成立了 —— 缺必填由提交闸兜着,卡回来时一定是齐的。
|
(资料记录都入库、必填不缺时引擎也会自己收)。S1_input_confirm 的入场前提认的是**入库事实**(要建的每颗料主数据 / 参数 / 资料都落了且不缺必填),
|
||||||
|
不等这面旗 —— 缺必填由提交闸兜着,卡回来时一定是齐的。
|
||||||
|
|
||||||
【这张卡一单只发一次】人交回来之后不许再发;答复里没有值(空 values)也算答过 —— 人在件上填的东西已经落草稿层。`,
|
【这张卡一单只发一次】人交回来之后不许再发;答复里没有值(空 values)也算答过 —— 人在件上填的东西已随确认**入了正式表**
|
||||||
|
(轨迹里有一条「数据工单确认入库」的痕)。这几份资料记录从此在库:**别再往 \`parts.document\` 写它们的键**(会被当场拒),也别去 drop 它们。`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
item: 'S1_input_confirm',
|
item: 'S1_input_confirm',
|
||||||
@@ -464,14 +465,20 @@ S0d_p08_master 那一步已经把它们改成 "abandoned",不在这份名单里)
|
|||||||
guide: `料号全部落定了,**该 PW 补评估输入了** —— 这是本活动唯一的执行工单,零输入。**先分岔**:【当前这一步】那行引擎事实说
|
guide: `料号全部落定了,**该 PW 补评估输入了** —— 这是本活动唯一的执行工单,零输入。**先分岔**:【当前这一步】那行引擎事实说
|
||||||
「从没给它发过卡」→ 走【发卡】;说「人已经被问过了」→ 走【收口】。
|
「从没给它发过卡」→ 走【发卡】;说「人已经被问过了」→ 走【收口】。
|
||||||
|
|
||||||
【发卡】先把评估输入清单写下来(\`${D}.input_gap\`,一行一项,item_key 用英文短键,input_item 用人读名),一拍连做:
|
【发卡】先把评估输入清单写下来(\`${D}.input_gap\`,一行一项),一拍连做。
|
||||||
· 已绑定四项(status = "ok",detail 写绑定值,origin = "ai"):item_key = customer / request / original_pn / candidates
|
**每一行六列都要写,一列都不能少**:item_key(英文短键)/ input_item(人读名,就是下面每行「」里那几个字)/ source_ledger(来源台账)/
|
||||||
(候选多颗就 candidate_1、candidate_2… 一颗一行,detail 写料号与在库状态);
|
status / detail / origin = "ai"。input_item 是必填列 —— 漏了它,件上红带会点名「缺输入项」,人对着一张没有名字的清单什么也补不了,
|
||||||
|
卡就此交不回来(2026-08-27 实测:11 行全漏了 input_item,人把别的格填满也过不了闸)。
|
||||||
|
· 已绑定四项(status = "ok",detail 写绑定值):item_key = customer「客户」(source_ledger = "L-02")/ request「客户需求」(source_ledger = "P-01")/
|
||||||
|
original_pn「原 PN」(source_ledger = "P-08")/ 候选多颗就 candidate_1「候选 1」、candidate_2「候选 2」… 一颗一行(source_ledger = "P-08",detail 写料号与在库状态);
|
||||||
· 待补两项(status = "gap",detail 写「启动时未收」):application_scenario「应用场景」(source_ledger = "P-01")、bom_position「BOM 位」(source_ledger = "P-09");
|
· 待补两项(status = "gap",detail 写「启动时未收」):application_scenario「应用场景」(source_ledger = "P-01")、bom_position「BOM 位」(source_ledger = "P-09");
|
||||||
· 默认可改三项(status = "default",detail 写默认值):substitution_type「替代类型」= "Pin-to-Pin"、rule_version「规则版本」= "${RULE_VERSION}"、owner「负责人 / 期限」= "按客户线默认 · 1 个工作日";
|
· 默认可改三项(status = "default",detail 写默认值,source_ledger = "GOV"):substitution_type「替代类型」= "Pin-to-Pin"、rule_version「规则版本」= "${RULE_VERSION}"、owner「负责人 / 期限」= "按客户线默认 · 1 个工作日";
|
||||||
· 待确认(status = "gap"):逐颗候选看它名下 \`parts.parameter\` 的那几行,param_value 是「未披露」的各一行(item_key = unknown_<料号>_<序号>,
|
· **候选参数一行都不写**:原厂未披露的参数不是这份清单的事 —— 差异评估那一步(S3)会把它判成 UNKNOWN 并挂验证任务;
|
||||||
input_item 如「XL2596S-5.0 使能阈值」,source_ledger = "P-08",detail 写「原厂未披露(有而不全)」);
|
件上另有一张只读的「候选参数现值」表(P-08 标准化参数集,一颗料一张,基座从台账现读),人看得见每颗料每个参数此刻的值。
|
||||||
· 齐备项(status = "ok"):原 PN 资料版本(source_ledger = "P-10",detail 写 datasheet 版本)。
|
这份清单里**不许出现任何参数行**(item_key 不许以 unknown_ 开头)—— 参数值有没有,是台账里的事实,不由你在这里替它宣布
|
||||||
|
(2026-08-27 实测三次:人在 P-08 填好的参数被这份清单当成缺口再问一遍);
|
||||||
|
· 齐备项(status = "ok"):original_doc_version「原 PN 资料版本」(source_ledger = "P-10",detail 写 datasheet 版本)。
|
||||||
|
发卡前自己核一遍:\`${D}_input_gap_missing\` 这个读数必须是 0(它只该等 supplement 那一列由人来填;不是 0 就是你漏写了必填列,回去补写再发)。
|
||||||
写完同一拍照说明书⑤发执行工单,发完这一拍结束(挂起)。
|
写完同一拍照说明书⑤发执行工单,发完这一拍结束(挂起)。
|
||||||
|
|
||||||
【收口】人交回之后只做三件事,然后 finish:
|
【收口】人交回之后只做三件事,然后 finish:
|
||||||
@@ -544,7 +551,7 @@ difference_status / compare_result / risk_level / impact_statement / unknown_dis
|
|||||||
这一步的收步判据里就有这两个数,**不调够,写多少行都收不了步**。
|
这一步的收步判据里就有这两个数,**不调够,写多少行都收不了步**。
|
||||||
① 逐条 \`substitution.difference_item\` 同 PK 覆盖写:difference_status / compare_result / risk_level 照第一拍出参**一字不改**;
|
① 逐条 \`substitution.difference_item\` 同 PK 覆盖写:difference_status / compare_result / risk_level 照第一拍出参**一字不改**;
|
||||||
impact_statement(MATCH 写「无」;其余说清对应用 / 验证 / 报价的影响,如「输入上限 32V < 客户 40V,不能上板」);
|
impact_statement(MATCH 写「无」;其余说清对应用 / 验证 / 报价的影响,如「输入上限 32V < 客户 40V,不能上板」);
|
||||||
difference_status = unknown 的行**必须**写 unknown_disposition(「挂验证任务 VAL-<案号数字串>-01:实测使能阈值」或「向原厂补来源」);confidence(0~1)。
|
difference_status = unknown 的行**必须**写 unknown_disposition(「挂验证任务 VAL-<案号数字串>-01:实测<参数名>」或「向原厂补来源」);confidence(0~1)。
|
||||||
② 每个 UNKNOWN 写一行 \`validation.record\`:validation_id = "VAL-<案号数字串>-01/02…"、request_id、validation_internal_pn(候选)、
|
② 每个 UNKNOWN 写一行 \`validation.record\`:validation_id = "VAL-<案号数字串>-01/02…"、request_id、validation_internal_pn(候选)、
|
||||||
validation_type = "pin_to_pin"、validation_status = "not_started"、next_validation_action(要测什么、什么条件)、test_condition。
|
validation_type = "pin_to_pin"、validation_status = "not_started"、next_validation_action(要测什么、什么条件)、test_condition。
|
||||||
③ 逐条 \`substitution.relation\` 同 PK 覆盖写:recommendation_level / risk_level 照第二拍出参、recommendation_reason(工具 reason 扩写成人话,
|
③ 逐条 \`substitution.relation\` 同 PK 覆盖写:recommendation_level / risk_level 照第二拍出参、recommendation_reason(工具 reason 扩写成人话,
|
||||||
@@ -557,6 +564,15 @@ difference_status / compare_result / risk_level / impact_statement / unknown_dis
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
item: 'S4_review',
|
item: 'S4_review',
|
||||||
|
/* 中间审核摆成验收工单的样子(机制是可循环的决策卡);由剧本声明,模型抄不抄都一样 */
|
||||||
|
cardVariant: 'review' as const,
|
||||||
|
/* 人工验收清单(2026-08-27 用户定):FAE/RI 拿着「差异评估结论审核」这份件要亲自核的步骤,固定 */
|
||||||
|
acceptanceChecklist: [
|
||||||
|
'逐个候选核对结论汇总:推荐程度、风险级别与它下方明细表的四态分布吻合(有硬约束 MISMATCH 或悬空 UNKNOWN 的候选没有给「推荐」)',
|
||||||
|
'逐条核对差异明细:每个候选的每个参数都有 MATCH / MISMATCH / UNKNOWN / CONDITIONAL 之一,没有漏项',
|
||||||
|
'核对 UNKNOWN 项的去向:每个 UNKNOWN 都挂了验证任务或写明补来源动作,没有悬空',
|
||||||
|
'核对证据引用:每项结论能追溯到 P-08 / P-10 / P-06 的记录或本单数据工单的创建记录;硬约束的 MATCH 都有证据支持',
|
||||||
|
],
|
||||||
intent: '差异评估结论审核:决策卡循环,通过或驳回 + AI 按意见改台账重发',
|
intent: '差异评估结论审核:决策卡循环,通过或驳回 + AI 按意见改台账重发',
|
||||||
writes: ['substitution.relation', 'substitution.difference_item', 'validation.record', `${D}.verdict`],
|
writes: ['substitution.relation', 'substitution.difference_item', 'validation.record', `${D}.verdict`],
|
||||||
reads: ['substitution.relation', 'substitution.difference_item', 'validation.record', `${D}.verdict`],
|
reads: ['substitution.relation', 'substitution.difference_item', 'validation.record', `${D}.verdict`],
|
||||||
@@ -567,11 +583,13 @@ difference_status / compare_result / risk_level / impact_statement / unknown_dis
|
|||||||
轨迹里找这一项已答过、还没处理的 [card-reply]:
|
轨迹里找这一项已答过、还没处理的 [card-reply]:
|
||||||
· 没有 → 照说明书⑥发第一张决策卡(ask 里把候选数、比对条数、四态各几条、逐候选推荐等级说全),发完这一拍结束。
|
· 没有 → 照说明书⑥发第一张决策卡(ask 里把候选数、比对条数、四态各几条、逐候选推荐等级说全),发完这一拍结束。
|
||||||
· "approve" → 写 \`${D}.verdict.review_approved_total = 1\`,finish 这一项。别再发卡。
|
· "approve" → 写 \`${D}.verdict.review_approved_total = 1\`,finish 这一项。别再发卡。
|
||||||
· "reject" / "custom:…" → **驳回**。按意见改台账(同 PK 覆盖写,不开新行、不改行数):
|
· "reject" / "custom:…" → **驳回**。**不许写 review_approved_total、不许 finish** —— 下一步的门读的是基座数
|
||||||
|
\`decision_last\`(人最后一次真点了 approve 才开),写了旗也开不了门,只会把自己绕进死循环。按意见改台账(同 PK 覆盖写,不开新行、不改行数):
|
||||||
· 意见点名某个参数 → 改那一行 \`substitution.difference_item\` 的 impact_statement / unknown_disposition / evidence_ref;要挂验证的补一行 \`validation.record\`;
|
· 意见点名某个参数 → 改那一行 \`substitution.difference_item\` 的 impact_statement / unknown_disposition / evidence_ref;要挂验证的补一行 \`validation.record\`;
|
||||||
· 意见说推荐理由站不住 → 改 \`substitution.relation.recommendation_reason\`(等级不许改 —— 那是工具算的;要改先回 S3 补证据重比,本活动不回头,写清楚为什么维持);
|
· 意见说推荐理由站不住 → 改 \`substitution.relation.recommendation_reason\`(等级不许改 —— 那是工具算的;要改先回 S3 补证据重比,本活动不回头,写清楚为什么维持);
|
||||||
· 没写具体意见("reject")→ 自己挑最站不住的一处改实(证据最含糊的那条 evidence_ref、去向最空的那条 unknown_disposition),**至少改对一处**;
|
· 没写具体意见("reject")→ 自己挑最站不住的一处改实(证据最含糊的那条 evidence_ref、去向最空的那条 unknown_disposition),**至少改对一处**;
|
||||||
然后**换一种问法**再发一张决策卡(把改了什么写进 ask),发完结束。
|
然后**换一种问法**再发一张决策卡 —— **ask 的开头必须写明这一轮改了什么**(与上一张字面不同;
|
||||||
|
字面一样的卡会被当成同一张吞掉,连吞两次就算被拒),发完结束。
|
||||||
**基座对着字面比对意见与草稿**:观察里点名了哪一行,就改那一行,不挪到别的行头上。`,
|
**基座对着字面比对意见与草稿**:观察里点名了哪一行,就改那一行,不挪到别的行头上。`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -595,13 +613,23 @@ difference_status / compare_result / risk_level / impact_statement / unknown_dis
|
|||||||
{
|
{
|
||||||
item: 'T_release',
|
item: 'T_release',
|
||||||
intent: '拟落账内容已备好,提交 ACT-GATE-03 交 FAE/RI 放行;被打回就改台账再交',
|
intent: '拟落账内容已备好,提交 ACT-GATE-03 交 FAE/RI 放行;被打回就改台账再交',
|
||||||
|
/* 人工验收清单(2026-08-27 用户定):FAE/RI 拿着「评审放行与落账确认」这份件要亲自核的步骤,固定 */
|
||||||
|
acceptanceChecklist: [
|
||||||
|
'核对拟写 P-11 的每条替代关系:推荐等级与差异明细一致(有 UNKNOWN / CONDITIONAL 的候选没有给「推荐」),推荐理由与差异摘要说得通',
|
||||||
|
'核对分析状态:每条只写分析层(分析状态 = FAE 确认),客户批准状态保持「未提交」,不由本单产生',
|
||||||
|
'核对规则版本:每条结论都带本次锁定的 GOV 差异规则版本(R-2026.07)',
|
||||||
|
'核对拟回写 P-01 的五项:风险等级、验证要求、输出链接、下一步动作、当前阶段都填了,且与结论一致',
|
||||||
|
'核对验证任务:每个 UNKNOWN 对应的验证记录都在,要测什么、什么条件写清楚了',
|
||||||
|
],
|
||||||
/* 被打回重做时要改的就是这几张 —— 不声明就写不进去 */
|
/* 被打回重做时要改的就是这几张 —— 不声明就写不进去 */
|
||||||
writes: ['substitution.relation', 'substitution.difference_item', 'validation.record', 'demand.request', `${D}.verdict`],
|
writes: ['substitution.relation', 'substitution.difference_item', 'validation.record', 'demand.request', `${D}.verdict`],
|
||||||
reads: ['substitution.relation', 'substitution.difference_item', 'validation.record', 'demand.request', `${D}.verdict`],
|
reads: ['substitution.relation', 'substitution.difference_item', 'validation.record', 'demand.request', `${D}.verdict`],
|
||||||
view: 'landing-confirm',
|
view: 'landing-confirm',
|
||||||
tools: [],
|
tools: [],
|
||||||
/* 放行触达世界,必须人按一下 —— 引擎不自动收它 */
|
/* 放行触达世界,必须人按一下 —— 引擎不自动收它 */
|
||||||
guide: `落账确认那一屏摆着拟写 P-11 主记录与拟回写 P-01。**先看轨迹里有没有「验收被打回重做」**:
|
guide: `落账确认那一屏摆着拟写 P-11 主记录与拟回写 P-01。**这一步不发执行工单、不发决策卡**:放行卡由 submit 发
|
||||||
|
(基座预检后自己发验收卡),发执行工单等于把同一件事问人两遍(2026-08-27 实测:先一张执行单、人答完才 submit 又一张验收单)。
|
||||||
|
**先看轨迹里有没有「验收被打回重做」**:
|
||||||
有 → 这一拍不是再提交一遍。照意见改**台账本身**(同 PK 覆盖写,不开新行):
|
有 → 这一拍不是再提交一遍。照意见改**台账本身**(同 PK 覆盖写,不开新行):
|
||||||
· 他说推荐等级与明细不符 → 核对 \`substitution.relation.recommendation_level\` 与该关系明细的四态计数,不符的把 recommendation_reason 改实、把漏挂的验证要求补上;
|
· 他说推荐等级与明细不符 → 核对 \`substitution.relation.recommendation_level\` 与该关系明细的四态计数,不符的把 recommendation_reason 改实、把漏挂的验证要求补上;
|
||||||
· 他说 P-01 回写不全 → 补 \`demand.request\` 那五项;
|
· 他说 P-01 回写不全 → 补 \`demand.request\` 那五项;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { activityIdSchema } from '@contracts';
|
|||||||
import { ASSESSMENT_SKILLS } from './skills.ts';
|
import { ASSESSMENT_SKILLS } from './skills.ts';
|
||||||
import { CMMN_XML, NARRATIVE, ASSESSMENT_PHASES, ASSESSMENT_STEPS } from './case-model.ts';
|
import { CMMN_XML, NARRATIVE, ASSESSMENT_PHASES, ASSESSMENT_STEPS } from './case-model.ts';
|
||||||
import { P08_MASTER_CREATE, P10_DOCUMENT_CREATE, EVAL_INPUT_LIST, DIFFERENCE_REVIEW, LANDING_CONFIRM } from './views/report.ts';
|
import { P08_MASTER_CREATE, P10_DOCUMENT_CREATE, EVAL_INPUT_LIST, DIFFERENCE_REVIEW, LANDING_CONFIRM } from './views/report.ts';
|
||||||
|
import { LEDGER_BOARD, LEDGER_BOARD_DATA } from './views/ledger-board.ts';
|
||||||
import { ACTIVITY_ID_TEXT, D, TOOL_COMPARE, TOOL_RECOMMEND } from './constants.ts';
|
import { ACTIVITY_ID_TEXT, D, TOOL_COMPARE, TOOL_RECOMMEND } from './constants.ts';
|
||||||
|
|
||||||
export { D, RULE_VERSION, TOOL_COMPARE, TOOL_RECOMMEND } from './constants.ts';
|
export { D, RULE_VERSION, TOOL_COMPARE, TOOL_RECOMMEND } from './constants.ts';
|
||||||
@@ -89,7 +90,7 @@ export const manifest: ActivityManifest = {
|
|||||||
identity: {
|
identity: {
|
||||||
activityId: ACTIVITY_ID,
|
activityId: ACTIVITY_ID,
|
||||||
name: 'ACT-WORK-05 · 差异评估',
|
name: 'ACT-WORK-05 · 差异评估',
|
||||||
packageVersion: '1.3.6',
|
packageVersion: '1.6.2',
|
||||||
category: '需求分析与方案匹配',
|
category: '需求分析与方案匹配',
|
||||||
sdkVersion: '0.1.0-m5',
|
sdkVersion: '0.1.0-m5',
|
||||||
checksum: 'dev-checksum-part-substitution-assessment',
|
checksum: 'dev-checksum-part-substitution-assessment',
|
||||||
@@ -109,20 +110,57 @@ export const manifest: ActivityManifest = {
|
|||||||
data: {
|
data: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['customerCode', 'requestDescription', 'originalInternalPn', 'candidateInternalPns'],
|
required: ['customerCode', 'requestDescription', 'originalInternalPn', 'candidateInternalPns'],
|
||||||
|
/*
|
||||||
|
* 运营端「启动活动」表单照这几个字段渲染(2026-08-27,`docs/API文档-活动模块.md` §4):
|
||||||
|
* `x-control` 定控件(combo = 选择 + 新增,textarea = 多行),`x-options` 指候选从哪张正式台账现读,`x-order` 定表单次序
|
||||||
|
* (清单存在 jsonb 里,书写顺序到不了运营端 —— PG 会按键长重排),
|
||||||
|
* `description` 是控件下那句辅助说明。台账里没有的值**照样启动**,启动后由 S0 的存在性核对派数据工单。
|
||||||
|
* datasheets 是文件,本期不上启动表单(在数据工单里传)。
|
||||||
|
*/
|
||||||
properties: {
|
properties: {
|
||||||
customerCode: { type: 'string', title: '客户编码(L-02 客户主台账)', examples: ['CU-0142'] },
|
customerCode: {
|
||||||
customerName: { type: 'string', title: '客户名称(仅客户不在库时填,启动后先建档)', examples: ['深圳恒星电源有限公司'] },
|
type: 'string',
|
||||||
|
title: '客户',
|
||||||
|
description: '台账里有就直接选;是新客户就自己填客户编码,启动后派数据录入卡建档。',
|
||||||
|
examples: ['CU-0142'],
|
||||||
|
'x-control': 'combo',
|
||||||
|
'x-options': { ledger: 'customer.profile', value: 'customer_code', label: 'name' },
|
||||||
|
'x-order': 1,
|
||||||
|
},
|
||||||
|
customerName: {
|
||||||
|
type: 'string',
|
||||||
|
title: '客户名称(仅客户不在库时填)',
|
||||||
|
description: '新客户先把名字摆上,启动后建档时让人核。',
|
||||||
|
examples: ['深圳恒星电源有限公司'],
|
||||||
|
'x-order': 2,
|
||||||
|
},
|
||||||
requestDescription: {
|
requestDescription: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
title: '客户需求(原始描述 —— 据此新建 P-01 需求主记录 · 版本 V1)',
|
title: '客户需求',
|
||||||
|
description: '保留原始需求语义,据此新建 P-01 需求主记录(版本 V1)。',
|
||||||
examples: ['工业电源项目在用 LM2596S-5.0(TI,12V→5V),原厂 PCN 转 NRND,客户要求验证替代料'],
|
examples: ['工业电源项目在用 LM2596S-5.0(TI,12V→5V),原厂 PCN 转 NRND,客户要求验证替代料'],
|
||||||
|
'x-control': 'textarea',
|
||||||
|
'x-order': 3,
|
||||||
|
},
|
||||||
|
originalInternalPn: {
|
||||||
|
type: 'string',
|
||||||
|
title: '原内部料号',
|
||||||
|
description: '差异评估基准;库里没有该 PN 也可启动,启动后派数据工单补参数。',
|
||||||
|
examples: ['LM2596S-5.0'],
|
||||||
|
'x-control': 'combo',
|
||||||
|
'x-options': { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn' },
|
||||||
|
'x-order': 4,
|
||||||
},
|
},
|
||||||
originalInternalPn: { type: 'string', title: '原内部料号(P-08)', examples: ['LM2596S-5.0'] },
|
|
||||||
candidateInternalPns: {
|
candidateInternalPns: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
title: '候选内部料号(数组,≥1;不在库的启动后经数据工单创建)',
|
title: '候选内部料号',
|
||||||
|
description: '被评估物料;库里有就直接选,没有就自己填料号,启动后经数据工单创建。',
|
||||||
items: { type: 'string' },
|
items: { type: 'string' },
|
||||||
examples: [['XL2596S-5.0', 'LM2596S-ADJ']],
|
examples: [['XL2596S-5.0', 'LM2596S-ADJ']],
|
||||||
|
/* 运营端启动表单只选一颗(2026-08-27 用户定):摆成与原料号同款的下拉;提交的单个料号由后端包成一元数组,活动契约不变 */
|
||||||
|
'x-control': 'combo',
|
||||||
|
'x-options': { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn' },
|
||||||
|
'x-order': 5,
|
||||||
},
|
},
|
||||||
datasheets: {
|
datasheets: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
@@ -257,7 +295,7 @@ export const manifest: ActivityManifest = {
|
|||||||
skills: ASSESSMENT_SKILLS.map((s) => s.id),
|
skills: ASSESSMENT_SKILLS.map((s) => s.id),
|
||||||
models: ['deepseek-v4-flash'],
|
models: ['deepseek-v4-flash'],
|
||||||
roles: ['FAE/RI'],
|
roles: ['FAE/RI'],
|
||||||
budget: { steps: 90, tokens: 600_000, activeMinutes: 120, humanCalls: 16 },
|
budget: { steps: 90, tokens: 2_000_000, activeMinutes: 120, humanCalls: 16 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -285,10 +323,17 @@ export const manifest: ActivityManifest = {
|
|||||||
{
|
{
|
||||||
domain: 'parts',
|
domain: 'parts',
|
||||||
object: 'profile',
|
object: 'profile',
|
||||||
|
/* 数据工单从零建主数据:本单只建库里没有的,在库的料号写下来当场拒(2026-08-27) */
|
||||||
|
createOnly: true,
|
||||||
kind: 'master',
|
kind: 'master',
|
||||||
label: '芯片主数据(P-08)',
|
label: '芯片主数据(P-08)',
|
||||||
fields: [
|
fields: [
|
||||||
col({ name: 'internal_pn', type: 'text', label: '内部料号', primaryKey: true }),
|
/*
|
||||||
|
* **只许建本单核过的料号**(2026-08-27 实测):模型给一颗**不存在的** XL2596S-ADJ 铺了空骨架 ——
|
||||||
|
* 库里没有、入参里也没有,是把两个候选的名字拼出来的;件上于是多出一行要人填的记录。
|
||||||
|
* 引用核对表:料号必须是这一单(或别单)在 pn_check 里核过的,凭空拼出来的当场拒。
|
||||||
|
*/
|
||||||
|
col({ name: 'internal_pn', type: 'text', label: '内部料号', primaryKey: true, refersTo: `${D}.pn_check.internal_pn` }),
|
||||||
col({ name: 'brand_name', type: 'text', label: '品牌名称', required: true }),
|
col({ name: 'brand_name', type: 'text', label: '品牌名称', required: true }),
|
||||||
col({ name: 'manufacturer_name', type: 'text', label: '制造商名称', expect: true }),
|
col({ name: 'manufacturer_name', type: 'text', label: '制造商名称', expect: true }),
|
||||||
col({ name: 'specification_description', type: 'text', label: '规格描述', required: true }),
|
col({ name: 'specification_description', type: 'text', label: '规格描述', required: true }),
|
||||||
@@ -305,6 +350,8 @@ export const manifest: ActivityManifest = {
|
|||||||
{
|
{
|
||||||
domain: 'parts',
|
domain: 'parts',
|
||||||
object: 'parameter',
|
object: 'parameter',
|
||||||
|
/* 数据工单从零建主数据:本单只建库里没有的,在库的料号写下来当场拒(2026-08-27) */
|
||||||
|
createOnly: true,
|
||||||
kind: 'master',
|
kind: 'master',
|
||||||
label: '标准化参数集(P-08 子表)',
|
label: '标准化参数集(P-08 子表)',
|
||||||
fields: [
|
fields: [
|
||||||
@@ -345,6 +392,8 @@ export const manifest: ActivityManifest = {
|
|||||||
{
|
{
|
||||||
domain: 'parts',
|
domain: 'parts',
|
||||||
object: 'document',
|
object: 'document',
|
||||||
|
/* 数据工单从零建主数据:本单只建库里没有的,在库的料号写下来当场拒(2026-08-27) */
|
||||||
|
createOnly: true,
|
||||||
kind: 'master',
|
kind: 'master',
|
||||||
label: '原厂技术资料库(P-10)',
|
label: '原厂技术资料库(P-10)',
|
||||||
fields: [
|
fields: [
|
||||||
@@ -550,6 +599,9 @@ export const manifest: ActivityManifest = {
|
|||||||
{ name: 'eval-input-list', html: EVAL_INPUT_LIST },
|
{ name: 'eval-input-list', html: EVAL_INPUT_LIST },
|
||||||
{ name: 'difference-review', html: DIFFERENCE_REVIEW },
|
{ name: 'difference-review', html: DIFFERENCE_REVIEW },
|
||||||
{ name: 'landing-confirm', html: LANDING_CONFIRM },
|
{ name: 'landing-confirm', html: LANDING_CONFIRM },
|
||||||
|
/* 运营中心 L2 的沙箱视图(2026-08-27,用户定替换掉全流程分支图):关键台账数据看板 —— 最近一次已放行
|
||||||
|
实例的落账快照,行由基座按 `data` 声明从正式表圈;无脚本,报高脚本由服务端出口注入 */
|
||||||
|
{ name: 'activity-ledger', html: LEDGER_BOARD, data: LEDGER_BOARD_DATA },
|
||||||
],
|
],
|
||||||
cardSchemas: {},
|
cardSchemas: {},
|
||||||
distillHooks: [{ on: 'terminal', prompt: '归纳本次差异评估:哪些候选、各自推荐等级与关键依据、UNKNOWN 挂了什么验证任务、审核是否被驳回过(为什么)、数据工单建了哪些主数据。' }],
|
distillHooks: [{ on: 'terminal', prompt: '归纳本次差异评估:哪些候选、各自推荐等级与关键依据、UNKNOWN 挂了什么验证任务、审核是否被驳回过(为什么)、数据工单建了哪些主数据。' }],
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* 徽标别名的生成器(与 `report.ts` 里那一段同一套逻辑;report.ts 另有一份 —— 并成一处记在 debt)。
|
||||||
|
*
|
||||||
|
* **2026-08-27 实测事故**:手写的 `.jc-tag.v-pin_to_pin::after{…}` 永远匹配不上,件上显示「待定」,
|
||||||
|
* 而台账里 `substitution_type` 明明是 `pin_to_pin`。`data-class-of` 投影出的 class 走 `view-render.ts` 的
|
||||||
|
* `classToken`(只留 `[a-z0-9-]`,**吃掉下划线**,封顶 24 字),所以选择器一律从 tally 值**算**出来。
|
||||||
|
*/
|
||||||
|
/** 必须与 `classToken` 一字不差:小写、只留 `[a-z0-9-]`、封顶 24 字。 */
|
||||||
|
export const vtok = (v: string): string => v.toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 24);
|
||||||
|
/** `[值, 人读名, 可选颜色声明]`:值与文案成对写在一处 */
|
||||||
|
export type BadgeSpec = readonly [value: string, label: string, color?: string];
|
||||||
|
export const badgeCss = (selector: string, specs: readonly BadgeSpec[]): string =>
|
||||||
|
specs
|
||||||
|
.map(([v, label, color]) => {
|
||||||
|
const cls = `${selector}.v-${vtok(v)}`;
|
||||||
|
return color === undefined ? `${cls}::after { content: "${label}"; }` : `${cls}::after { content: "${label}"; }\n${cls} { ${color} }`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
/**
|
||||||
|
* 运营中心 L2 沙箱视图 —— ACT-WORK-05「关键台账数据」看板(2026-08-27)。
|
||||||
|
*
|
||||||
|
* 版式与样式逐字节搬自 `docs/ss02-act-05/ACT-WORK-05-关键台账数据.html`(只改两处:`.tbl-wrap` 放开横滑;
|
||||||
|
* 徽标文案由 `data-class-of` 投影 + 生成式 `::after` 出,不再写死)。溯源口径照 `…-数据溯源.md`:
|
||||||
|
* 页头 / P-01 一条 / P-08 逐颗 + 参数瓷贴(一格一列,不叠灰字次列 —— 2026-08-27 用户定)/ P-11 主记录 + 差异明细 / P-10(同样一格一列)/ P-06(本单 + 同料号历史)。
|
||||||
|
*
|
||||||
|
* **它摆的是最近一次已放行实例的落账快照**(用户定):行由基座按 `LEDGER_BOARD_DATA` 的声明从**正式表**圈出 ——
|
||||||
|
* 每个 `data-rows` 键都是声明里的一个 `as`,不是整表;`<as>.<列>` 是那一集合首行的标量,`<as 去符号>_rows` /
|
||||||
|
* `_<列>_<值>` 是基座数出来的读数(`apps/api/src/app/activity-ops/view-scope.ts`)。模版仍**不算不判**:
|
||||||
|
* MATCH 行淡显、放弃的候选、三态横幅,全是 `data-class-of` 投出的 class 交样式表。
|
||||||
|
* 无脚本、无外链(过安装静态审);报高脚本由服务端出口注入。
|
||||||
|
*/
|
||||||
|
import type { ViewData } from '@contracts';
|
||||||
|
import { badgeCss } from './badge.ts';
|
||||||
|
import { D } from '../constants.ts';
|
||||||
|
|
||||||
|
/** 案号 `W05-<开案时刻 14 位>`(与说明书让模型铸 `eval_ref` 的写法同源);行集按案号 → 核对表 → 料号 / 需求逐表圈 */
|
||||||
|
export const LEDGER_BOARD_DATA: ViewData = {
|
||||||
|
case: { ledger: `${D}.verdict`, key: 'eval_ref', prefix: 'W05-' },
|
||||||
|
rows: [
|
||||||
|
{ as: `${D}.verdict`, ledger: `${D}.verdict`, by: { column: 'eval_ref', from: 'case' } },
|
||||||
|
/* 原 PN 排第一(original > candidate),下游按料号圈的表都随这个顺序 */
|
||||||
|
{ as: `${D}.pn_check`, ledger: `${D}.pn_check`, by: { column: 'eval_ref', from: 'case' }, orderBy: [{ field: 'role', dir: 'desc' }, { field: 'internal_pn', dir: 'asc' }] },
|
||||||
|
{ as: 'demand.request', ledger: 'demand.request', by: { column: 'request_id', from: `${D}.verdict.request_id` } },
|
||||||
|
{ as: 'customer.profile', ledger: 'customer.profile', by: { column: 'customer_code', from: 'demand.request.customer_code' } },
|
||||||
|
{ as: 'parts.profile', ledger: 'parts.profile', by: { column: 'internal_pn', from: `${D}.pn_check.internal_pn` } },
|
||||||
|
{ as: 'parts.parameter', ledger: 'parts.parameter', by: { column: 'internal_pn', from: `${D}.pn_check.internal_pn` }, orderBy: [{ field: 'param_name', dir: 'asc' }] },
|
||||||
|
{ as: 'parts.document', ledger: 'parts.document', by: { column: 'internal_pn', from: `${D}.pn_check.internal_pn` }, orderBy: [{ field: 'doc_seq', dir: 'asc' }] },
|
||||||
|
{ as: 'substitution.relation', ledger: 'substitution.relation', by: { column: 'eval_ref', from: 'case' }, orderBy: [{ field: 'substitution_relation_id', dir: 'asc' }] },
|
||||||
|
{
|
||||||
|
as: 'substitution.difference_item',
|
||||||
|
ledger: 'substitution.difference_item',
|
||||||
|
by: { column: 'substitution_relation_id', from: 'substitution.relation.substitution_relation_id' },
|
||||||
|
orderBy: [{ field: 'difference_item_id', dir: 'asc' }],
|
||||||
|
},
|
||||||
|
{ as: 'validation.record', ledger: 'validation.record', by: { column: 'request_id', from: `${D}.verdict.request_id` }, orderBy: [{ field: 'validation_id', dir: 'asc' }] },
|
||||||
|
/* 同料号在别单 / 别处留下的验证记录:本次评估的历史证据引用(目标件 P-06 里 VAL-2026-0040 / 0032 那两行的位置) */
|
||||||
|
{
|
||||||
|
as: 'validation.history',
|
||||||
|
ledger: 'validation.record',
|
||||||
|
by: { column: 'validation_internal_pn', from: `${D}.pn_check.internal_pn` },
|
||||||
|
not: { column: 'request_id', from: `${D}.verdict.request_id` },
|
||||||
|
orderBy: [{ field: 'validation_id', dir: 'desc' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ==================== 目标件样式(逐字节搬;两处改动标了 [改]) ==================== */
|
||||||
|
const TARGET_CSS = `
|
||||||
|
:root{
|
||||||
|
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
|
||||||
|
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
|
||||||
|
--brand:#3370ff; --brand-deep:#245bdb; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
|
||||||
|
--data:#3370ff; --data-soft:#e1eaff; --data-line:#c3d6ff;
|
||||||
|
--human:#d97706; --human-soft:#fdf1dc; --human-line:#f0d8a8;
|
||||||
|
--ledger:#16a34a; --ledger-soft:#e6f6ec; --ledger-line:#b7e5c6;
|
||||||
|
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
|
||||||
|
--amber:#8f6a12; --amber-soft:#fdf3d7; --amber-line:#ecd9a0;
|
||||||
|
--mono:"SF Mono",Menlo,Consolas,monospace;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
html,body{margin:0;padding:0}
|
||||||
|
body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#fff;color:var(--ink);font-size:14px;line-height:1.7;-webkit-font-smoothing:antialiased}
|
||||||
|
.page{max-width:none;margin:0;padding:0}
|
||||||
|
|
||||||
|
/* 头部 */
|
||||||
|
.mast{background:#fff;border:1px solid var(--line-soft);border-radius:14px;padding:22px 26px;position:relative;overflow:hidden}
|
||||||
|
.mast::before{content:"";position:absolute;inset:0 0 auto 0;height:4px;background:linear-gradient(90deg,var(--brand) 0%,var(--brand-deep) 100%)}
|
||||||
|
.mast .kick{font-size:11px;letter-spacing:.12em;color:var(--ink-4);font-weight:800}
|
||||||
|
.mast h1{margin:4px 0 6px;font-size:23px;font-weight:800;line-height:1.3}
|
||||||
|
.mast h1 .code{font-family:var(--mono);font-size:14px;font-weight:700;color:var(--brand-deep);background:var(--brand-wash);border:1px solid var(--brand-soft);border-radius:8px;padding:3px 10px;margin-right:10px;vertical-align:3px}
|
||||||
|
.mast .sub{font-size:13px;color:var(--ink-3)}
|
||||||
|
.mast .sub b{color:var(--ink-2)}
|
||||||
|
|
||||||
|
/* 数据来源图例 */
|
||||||
|
.legend{display:flex;gap:10px;flex-wrap:wrap;margin:12px 0 0;font-size:12px;color:var(--ink-3)}
|
||||||
|
.legend .li{display:inline-flex;align-items:center;gap:6px;background:#fff;border:1px solid var(--line-soft);border-radius:999px;padding:3px 12px}
|
||||||
|
.dot{width:8px;height:8px;border-radius:50%;flex:none}
|
||||||
|
.dot.full{background:var(--brand)}
|
||||||
|
.dot.half{background:var(--amber)}
|
||||||
|
|
||||||
|
/* 分层图例 */
|
||||||
|
.layers{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin:14px 0 18px}
|
||||||
|
.layer{background:#fff;border:1px solid var(--line-soft);border-radius:11px;padding:11px 14px;font-size:12px;color:var(--ink-2);border-top:3px solid var(--brand)}
|
||||||
|
.layer b{display:block;font-size:12.5px;color:var(--ink);margin-bottom:2px}
|
||||||
|
.layer .lg{font-family:var(--mono);font-weight:800;color:var(--brand-deep)}
|
||||||
|
.layer.core-out{border-top-color:var(--ledger)} .layer.core-out .lg{color:var(--ledger)}
|
||||||
|
.layer.evid{border-top-color:var(--human)} .layer.evid .lg{color:var(--human)}
|
||||||
|
.layer.loop{border-top-color:var(--amber)} .layer.loop .lg{color:var(--amber)}
|
||||||
|
.layer.ctx{border-top-color:var(--ink-4)} .layer.ctx .lg{color:var(--ink-3)}
|
||||||
|
|
||||||
|
/* 区块 */
|
||||||
|
section{background:#fff;border:1px solid var(--line-soft);border-radius:14px;padding:18px 22px;margin-bottom:16px}
|
||||||
|
.sec-head{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap}
|
||||||
|
.sec-head h2{margin:0;font-size:15.5px;font-weight:800}
|
||||||
|
.sec-head h2 .lid{font-family:var(--mono);font-size:12px;color:var(--brand-deep);background:var(--brand-wash);border:1px solid var(--brand-soft);border-radius:6px;padding:1px 8px;margin-right:8px}
|
||||||
|
.ltag{font-size:10.5px;font-weight:800;padding:2px 9px;border-radius:999px;border:1px solid transparent;white-space:nowrap}
|
||||||
|
.ltag.in{color:var(--brand-deep);background:var(--brand-wash);border-color:var(--brand-soft)}
|
||||||
|
.ltag.out{color:var(--ledger);background:var(--ledger-soft);border-color:var(--ledger-line)}
|
||||||
|
.ltag.evid{color:var(--human);background:var(--human-soft);border-color:var(--human-line)}
|
||||||
|
.ltag.loop{color:var(--amber);background:var(--amber-soft);border-color:var(--amber-line)}
|
||||||
|
.ltag.ctx{color:var(--ink-3);background:var(--soft);border-color:var(--line)}
|
||||||
|
.sec-head .why{font-size:12px;color:var(--ink-4)}
|
||||||
|
.sec-head .cnt{margin-left:auto;font-size:11.5px;color:var(--ink-4);font-family:var(--mono)}
|
||||||
|
|
||||||
|
/* 表格 —— [改] 目标件写 overflow:hidden,列一多右边被裁且滑不动;本仓 2026-08-23 定:表宽超出就得能横滑 */
|
||||||
|
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow-x:auto;overflow-y:hidden}
|
||||||
|
.jc-tbl-wrap, .tbl-wrap { overflow-x: auto; overflow-y: hidden; }
|
||||||
|
table{width:100%;border-collapse:collapse;font-size:12.5px}
|
||||||
|
th{text-align:left;background:var(--soft);font-size:11px;letter-spacing:.05em;font-weight:800;color:var(--ink-3);padding:9px 12px;border-bottom:1px solid var(--line);white-space:nowrap}
|
||||||
|
td{padding:9px 12px;border-bottom:1px solid var(--line-soft);vertical-align:top}
|
||||||
|
tr:last-child td{border-bottom:0}
|
||||||
|
tbody tr{transition:background .15s}
|
||||||
|
tbody tr:hover{background:var(--brand-wash)}
|
||||||
|
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
|
||||||
|
.pn{font-weight:700}
|
||||||
|
|
||||||
|
/* 状态 pill */
|
||||||
|
.st{display:inline-block;padding:1px 9px;border-radius:999px;font-size:11px;font-weight:800;white-space:nowrap;border:1px solid transparent}
|
||||||
|
.st.ok{color:var(--ok);background:var(--ok-soft);border-color:#bfe6b4}
|
||||||
|
.st.miss{color:var(--danger);background:var(--danger-soft);border-color:#f3b9b5}
|
||||||
|
.st.unknown{color:var(--amber);background:var(--amber-soft);border-color:var(--amber-line)}
|
||||||
|
.st.cond{color:var(--brand-deep);background:var(--brand-wash);border-color:var(--brand-soft)}
|
||||||
|
.st.gray{color:var(--ink-3);background:var(--soft);border-color:var(--line)}
|
||||||
|
.st.warn{color:var(--human);background:var(--human-soft);border-color:var(--human-line)}
|
||||||
|
|
||||||
|
/* P-01 单记录 KV 卡 */
|
||||||
|
.kv{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--line-soft);border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
|
||||||
|
.kv .cell{background:#fff;padding:10px 14px}
|
||||||
|
.kv .k{font-size:11px;font-weight:800;color:var(--ink-4);margin-bottom:2px}
|
||||||
|
.kv .v{font-size:12.5px;color:var(--ink);line-height:1.5}
|
||||||
|
.kv .span2{grid-column:span 2}
|
||||||
|
|
||||||
|
/* 参数集摘要 chips */
|
||||||
|
.pchips{display:flex;flex-wrap:wrap;gap:4px}
|
||||||
|
.pchips span{font-size:11px;font-family:var(--mono);background:var(--soft);border:1px solid var(--line-soft);border-radius:6px;padding:1px 7px;color:var(--ink-2);white-space:nowrap}
|
||||||
|
|
||||||
|
/* 次要台账条 */
|
||||||
|
.minor{display:flex;gap:10px;flex-wrap:wrap}
|
||||||
|
.minor .m{flex:1;min-width:220px;border:1px solid var(--line-soft);border-radius:10px;padding:11px 14px;font-size:12px;color:var(--ink-2)}
|
||||||
|
.minor .m b{display:block;font-size:12.5px;color:var(--ink);margin-bottom:3px}
|
||||||
|
.minor .m .lid{font-family:var(--mono);color:var(--ink-4);font-weight:700;margin-right:6px}
|
||||||
|
|
||||||
|
footer{margin-top:18px;font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
|
||||||
|
@media(max-width:900px){.layers{grid-template-columns:repeat(2,1fr)}.kv{grid-template-columns:1fr 1fr}.kv .span2{grid-column:span 2}}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/* ==================== 绑定层:徽标别名 / 三态横幅 / 淡显 —— 只取值,不算不判 ==================== */
|
||||||
|
const OK = 'color:var(--ok);background:var(--ok-soft);border-color:#bfe6b4;';
|
||||||
|
const MISS = 'color:var(--danger);background:var(--danger-soft);border-color:#f3b9b5;';
|
||||||
|
const UNKNOWN = 'color:var(--amber);background:var(--amber-soft);border-color:var(--amber-line);';
|
||||||
|
const COND = 'color:var(--brand-deep);background:var(--brand-wash);border-color:var(--brand-soft);';
|
||||||
|
const GRAY = 'color:var(--ink-3);background:var(--soft);border-color:var(--line);';
|
||||||
|
const WARN = 'color:var(--human);background:var(--human-soft);border-color:var(--human-line);';
|
||||||
|
|
||||||
|
const BIND_CSS = `
|
||||||
|
/* 徽标:值域各归各族(同一个词在不同列意思不同,如 pending 在资料状态是待生效、在需求状态是待处理),
|
||||||
|
文案与颜色由 data-class-of 投出的 v-<token> 决定;没投出 class 的格子摆「—」,客户批准那格摆「未提交」 */
|
||||||
|
.st::after{content:"—"}
|
||||||
|
.st.ap::after{content:"未提交"}
|
||||||
|
${badgeCss('.st.lc', [
|
||||||
|
['active', 'Active', OK],
|
||||||
|
['nrnd', 'NRND', MISS],
|
||||||
|
['eol', 'EOL', MISS],
|
||||||
|
['discontinued', '停产', MISS],
|
||||||
|
['risk', '风险', WARN],
|
||||||
|
])}
|
||||||
|
${badgeCss('.st.rec', [
|
||||||
|
['recommended', '推荐', OK],
|
||||||
|
['conditional', '条件推荐', WARN],
|
||||||
|
['alternate', '备选', COND],
|
||||||
|
['not_recommended', '不推荐', MISS],
|
||||||
|
])}
|
||||||
|
${badgeCss('.st.an', [
|
||||||
|
['ai_draft', 'AI 草稿', GRAY],
|
||||||
|
['fae_confirmed', 'FAE确认', COND],
|
||||||
|
['pending_customer', '待客户验证', WARN],
|
||||||
|
['customer_approved', '客户已批准', OK],
|
||||||
|
['rejected', '不批准', MISS],
|
||||||
|
['void', '作废', GRAY],
|
||||||
|
])}
|
||||||
|
${badgeCss('.st.rk', [
|
||||||
|
['low', '低', OK],
|
||||||
|
['medium', '中', WARN],
|
||||||
|
['high', '高', MISS],
|
||||||
|
['critical', '危急', MISS],
|
||||||
|
])}
|
||||||
|
${badgeCss('.st.dst', [
|
||||||
|
['match', 'MATCH', OK],
|
||||||
|
['mismatch', 'MISMATCH', MISS],
|
||||||
|
['unknown', 'UNKNOWN', UNKNOWN],
|
||||||
|
['conditional', 'CONDITIONAL', COND],
|
||||||
|
])}
|
||||||
|
${badgeCss('.st.vs', [
|
||||||
|
['not_started', '未开始', GRAY],
|
||||||
|
['in_progress', '进行中', COND],
|
||||||
|
['passed', '通过', OK],
|
||||||
|
['failed', '未通过', MISS],
|
||||||
|
['conditional', '条件通过', WARN],
|
||||||
|
['pending_evidence', '待证据', UNKNOWN],
|
||||||
|
])}
|
||||||
|
${badgeCss('.st.ds', [
|
||||||
|
['valid', '有效', OK],
|
||||||
|
['expired', '过期', MISS],
|
||||||
|
['pending', '待生效', WARN],
|
||||||
|
['void', '作废', GRAY],
|
||||||
|
])}
|
||||||
|
${badgeCss('.st.rq', [
|
||||||
|
['pending', '待处理', GRAY],
|
||||||
|
['responded', '已响应', COND],
|
||||||
|
['won', '已成交', OK],
|
||||||
|
['abandoned', '已放弃', MISS],
|
||||||
|
['closed', '已关闭', GRAY],
|
||||||
|
])}
|
||||||
|
${badgeCss('.st.stg', [
|
||||||
|
['assessing', '评估中', GRAY],
|
||||||
|
['gate03', '提交 ACT-GATE-03', OK],
|
||||||
|
])}
|
||||||
|
${badgeCss('.st.ex', [
|
||||||
|
['found', '在库直读', COND],
|
||||||
|
['created', '本单建档', OK],
|
||||||
|
['absent', '缺库', MISS],
|
||||||
|
['abandoned', '已放弃', GRAY],
|
||||||
|
])}
|
||||||
|
${badgeCss('.st.rl', [
|
||||||
|
['original', '原 PN', COND],
|
||||||
|
['candidate', '候选', GRAY],
|
||||||
|
])}
|
||||||
|
/* 纯文字的枚举列:只补文案 */
|
||||||
|
.enumtxt::after{content:"—"}
|
||||||
|
${badgeCss('.enumtxt.ty', [
|
||||||
|
['pin_to_pin', 'Pin-to-Pin'],
|
||||||
|
['functional', '功能替代'],
|
||||||
|
['parametric', '参数替代'],
|
||||||
|
['other', '其他'],
|
||||||
|
])}
|
||||||
|
${badgeCss('.enumtxt.con', [
|
||||||
|
['hard', '硬约束'],
|
||||||
|
['soft', '软约束'],
|
||||||
|
])}
|
||||||
|
${badgeCss('.enumtxt.vt', [
|
||||||
|
['pin_to_pin', 'Pin-to-Pin'],
|
||||||
|
['functional', '功能'],
|
||||||
|
['reliability', '可靠性'],
|
||||||
|
['certification', '认证'],
|
||||||
|
['retest', '复测'],
|
||||||
|
])}
|
||||||
|
${badgeCss('.enumtxt.dt', [
|
||||||
|
['datasheet', 'Datasheet'],
|
||||||
|
['app_note', '应用笔记'],
|
||||||
|
['reference_design', '参考设计'],
|
||||||
|
['white_paper', '白皮书'],
|
||||||
|
['sample_doc', '样品资料'],
|
||||||
|
['commitment_letter', '承诺函'],
|
||||||
|
['other', '其他'],
|
||||||
|
])}
|
||||||
|
|
||||||
|
/* 页头候选料号:一行内逐个摆,用顿号连;一个都没有就摆「—」 */
|
||||||
|
.cands b+b::before{content:"、";font-weight:400;color:var(--ink-3)}
|
||||||
|
.cands:empty::after{content:"—"}
|
||||||
|
/* 参数瓷贴:参数名淡、值粗、单位紧跟 */
|
||||||
|
.pchips i{font-style:normal;color:var(--ink-4);margin-right:3px}
|
||||||
|
.pchips b{font-weight:700}
|
||||||
|
.pchips em{font-style:normal;margin-left:1px}
|
||||||
|
.pchips:empty::after{content:"参数集未登记";font-size:11px;color:var(--ink-4)}
|
||||||
|
/* 料号格里的两枚小徽(角色 / 在库状态) */
|
||||||
|
.pnrow{display:flex;flex-wrap:wrap;gap:4px;margin-top:3px}
|
||||||
|
.pnrow .st{font-size:10px;padding:0 7px}
|
||||||
|
/* 差异明细:MATCH 行淡显(不藏:那也是比对过的事实),非 MATCH 才是要看的 */
|
||||||
|
tr.v-match td{opacity:.55}
|
||||||
|
/* 已放弃的候选:只在有 abandoned 行时露面,别的核对行不显 */
|
||||||
|
.aband{display:none;font-size:12px;color:var(--ink-3);margin:8px 0 0}
|
||||||
|
.aband i{font-style:normal;font-family:var(--mono);color:var(--ink-2)}
|
||||||
|
.aband i:not(.v-abandoned){display:none}
|
||||||
|
.aband i+i.v-abandoned::before{content:"、"}
|
||||||
|
.aband:has(i.v-abandoned){display:block}
|
||||||
|
/* P-06 历史行:首格挂一枚「历史」灰签 */
|
||||||
|
tbody.hist td:first-child::after{content:"历史";display:inline-block;margin-left:6px;font-size:10px;font-weight:800;padding:0 6px;border-radius:999px;color:var(--ink-3);background:var(--soft);border:1px solid var(--line);font-family:Inter,ui-sans-serif,system-ui,sans-serif}
|
||||||
|
td .sub{display:block;font-size:11px;color:var(--ink-3);font-weight:400}
|
||||||
|
td .sub:empty{display:none}
|
||||||
|
/* 三态横幅:none = 本活动还没有已放行实例;missing = 有实例但案号在正式表里查无 */
|
||||||
|
.notice{display:none;align-items:flex-start;gap:8px;margin:0 0 16px;padding:12px 16px;border-radius:12px;font-size:13px;line-height:1.6}
|
||||||
|
.notice.none{background:var(--soft);border:1px solid var(--line);color:var(--ink-2)}
|
||||||
|
.notice.missing{background:var(--amber-soft);border:1px solid var(--amber-line);color:var(--amber)}
|
||||||
|
.page.v-none .notice.none{display:flex}
|
||||||
|
.page.v-missing .notice.missing{display:flex}
|
||||||
|
.page.v-none .layers,.page.v-none section,.page.v-none .mast .sub.case,.page.v-none .mast .sub.verdict{display:none}
|
||||||
|
.page.v-missing section,.page.v-missing .mast .sub.verdict{display:none}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const th = (...cols: string[]): string => `<thead><tr>${cols.map((c) => `<th>${c}</th>`).join('')}</tr></thead>`;
|
||||||
|
|
||||||
|
export const LEDGER_BOARD = `<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>ACT-WORK-05 差异评估 · 关键台账数据 · SS02 需求分析与方案匹配</title>
|
||||||
|
<style>${TARGET_CSS}${BIND_CSS}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page" data-class-of="board:state">
|
||||||
|
|
||||||
|
<header class="mast">
|
||||||
|
<div class="kick">SS02 需求分析与方案匹配 · 活动台账视图</div>
|
||||||
|
<h1><span class="code">ACT-WORK-05</span>关键台账数据</h1>
|
||||||
|
<p class="sub case">视角:本活动最近一次已放行实例 <b data-slot="case:instanceNo"></b>(<b data-slot="case:openedAt"></b> 开案 · <b data-slot="case:releasedAt"></b> 放行 · 负责人 <b data-slot="case:owner" data-blank></b>)的落账快照 · 案号 <b class="mono" data-slot="case:ref"></b> · 关联需求 <b data-slot="demand.request.request_id"></b>(客户 <b data-slot="demand.request.customer_code"></b> <span data-slot="customer.profile.name" data-blank></span>)· 评估基准 <b data-slot="${D}.verdict.original_internal_pn"></b>(<span data-slot="parts.profile.brand_name" data-blank></span>)→ 候选 <span class="cands" data-rows="substitution.relation"><b data-slot="candidate_internal_pn"></b></span> · 规则版本 <b data-slot="${D}.verdict.rule_version"></b></p>
|
||||||
|
<p class="sub verdict">评估结论:<span data-slot="${D}.verdict.summary" data-blank></span></p>
|
||||||
|
<div class="legend">
|
||||||
|
<span class="li"><span class="dot full"></span>正式台账直读 —— 放行落地后的事实,按案号圈出这一单的行</span>
|
||||||
|
<span class="li"><span class="dot half"></span>同料号的历史验证记录(别单 / 线下留下的,作本次评估的证据引用)</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="notice none">本活动还没有已放行的实例 —— 有一单放行后,这里摆它的关键台账落账快照。</div>
|
||||||
|
<div class="notice missing">实例 <b data-slot="case:instanceNo"></b> 已放行,但案号 <b class="mono" data-slot="case:ref"></b> 在正式表里查无 —— 多半是起单时关了「落真库」(主数据只留在活动缓存),这一单没有可摆的落账数据。</div>
|
||||||
|
|
||||||
|
<!-- 重要性分层 -->
|
||||||
|
<div class="layers">
|
||||||
|
<div class="layer"><b><span class="lg">P-08</span> 芯片主数据</b>核心输入 · 评估原材料,查无即硬阻塞</div>
|
||||||
|
<div class="layer core-out"><b><span class="lg">P-11</span> 替代批准关系库</b>核心产出 · 结论与明细的落账地</div>
|
||||||
|
<div class="layer evid"><b><span class="lg">P-10</span> 原厂技术资料库</b>证据链 · 每项结论的版本化出处</div>
|
||||||
|
<div class="layer loop"><b><span class="lg">P-06</span> 样品验证记录</b>闭环去向 · UNKNOWN 的实测收口</div>
|
||||||
|
<div class="layer ctx"><b><span class="lg">P-01</span> 客户需求主台账</b>上下文 · 需求源头与回写对象</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- P-01 -->
|
||||||
|
<section id="p01">
|
||||||
|
<div class="sec-head">
|
||||||
|
<h2><span class="lid">P-01</span>客户需求主台账</h2>
|
||||||
|
<span class="ltag ctx">上下文 · R+W</span>
|
||||||
|
<span class="why">启动时新建需求;结束时回写风险 / 验证要求 / 输出链接 / 下一步 / 当前阶段</span>
|
||||||
|
<span class="cnt">正式表 · <i data-slot="demand_request_rows"></i> 条</span>
|
||||||
|
</div>
|
||||||
|
<div class="kv">
|
||||||
|
<div class="cell"><div class="k">需求编号</div><div class="v mono" data-slot="demand.request.request_id"></div></div>
|
||||||
|
<div class="cell"><div class="k">客户</div><div class="v mono"><span data-slot="demand.request.customer_code"></span> <span class="sub" data-slot="customer.profile.name" data-blank></span></div></div>
|
||||||
|
<div class="cell"><div class="k">需求版本</div><div class="v mono" data-slot="demand.request.requirement_version"></div></div>
|
||||||
|
<div class="cell"><div class="k">需求状态</div><div class="v"><span class="st rq" data-class-of="demand.request.request_status"></span></div></div>
|
||||||
|
<div class="cell span2"><div class="k">需求描述</div><div class="v" data-slot="demand.request.request_description"></div></div>
|
||||||
|
<div class="cell"><div class="k">应用场景</div><div class="v" data-slot="demand.request.application_scenario" data-blank></div></div>
|
||||||
|
<div class="cell"><div class="k">BOM 位</div><div class="v mono" data-slot="demand.request.bom_position" data-blank></div></div>
|
||||||
|
<div class="cell"><div class="k">原内部料号</div><div class="v mono" data-slot="demand.request.original_internal_pn"></div></div>
|
||||||
|
<div class="cell"><div class="k">候选内部料号</div><div class="v mono cands" data-rows="substitution.relation"><b data-slot="candidate_internal_pn"></b></div></div>
|
||||||
|
<div class="cell"><div class="k">风险等级(回写)</div><div class="v"><span class="st rk" data-class-of="demand.request.risk_level"></span></div></div>
|
||||||
|
<div class="cell"><div class="k">验证要求(回写)</div><div class="v mono" data-slot="demand.request.verification_requirement" data-blank></div></div>
|
||||||
|
<div class="cell"><div class="k">当前阶段</div><div class="v"><span class="st stg" data-class-of="demand.request.current_stage"></span></div></div>
|
||||||
|
<div class="cell"><div class="k">标准输出链接</div><div class="v mono" data-slot="demand.request.output_ref" data-blank></div></div>
|
||||||
|
<div class="cell span2"><div class="k">下一步动作</div><div class="v" data-slot="demand.request.next_action" data-blank></div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- P-08 -->
|
||||||
|
<section id="p08">
|
||||||
|
<div class="sec-head">
|
||||||
|
<h2><span class="lid">P-08</span>芯片主数据台账</h2>
|
||||||
|
<span class="ltag in">核心输入 · R(查无 → 数据工单创建)</span>
|
||||||
|
<span class="why">原 PN 与候选 PN 的物料基准 + 标准化参数集(子表 P-08 参数),逐项比对的原材料</span>
|
||||||
|
<span class="cnt">正式表 · <i data-slot="parts_profile_rows"></i> 条(本活动相关)</span>
|
||||||
|
</div>
|
||||||
|
<div class="tbl-wrap">
|
||||||
|
<table>
|
||||||
|
${th('内部料号', '品牌', '规格描述', '封装', '生命周期', 'Datasheet 版本', '标准化参数集', '适配场景')}
|
||||||
|
<tbody data-rows="parts.profile">
|
||||||
|
<tr>
|
||||||
|
<td class="pn mono"><span data-slot="internal_pn"></span><span class="pnrow" data-rows="${D}.pn_check" data-by="internal_pn"><i class="st rl" data-class-of="role"></i><i class="st ex" data-class-of="exists_status"></i></span></td>
|
||||||
|
<td data-slot="brand_name" data-blank></td>
|
||||||
|
<td data-slot="specification_description" data-blank></td>
|
||||||
|
<td class="mono" data-slot="package" data-blank></td>
|
||||||
|
<td><span class="st lc" data-class-of="lifecycle_status"></span></td>
|
||||||
|
<td class="mono" data-slot="datasheet_version" data-blank></td>
|
||||||
|
<td><div class="pchips" data-rows="parts.parameter" data-by="internal_pn"><span><i data-slot="param_name"></i><b data-slot="param_value" data-blank></b><em data-slot="param_unit" data-blank></em></span></div></td>
|
||||||
|
<td data-slot="application_scenarios" data-blank></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="aband">本轮放弃的候选(未建档、未评估):<span data-rows="${D}.pn_check"><i data-class-of="exists_status" data-slot="internal_pn"></i></span></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- P-11 -->
|
||||||
|
<section id="p11">
|
||||||
|
<div class="sec-head">
|
||||||
|
<h2><span class="lid">P-11</span>替代选型与批准关系库 · 替代关系主记录</h2>
|
||||||
|
<span class="ltag out">核心产出 · R+W</span>
|
||||||
|
<span class="why">每个候选一条关系记录;客户批准状态为只读边界,本活动不写</span>
|
||||||
|
<span class="cnt">正式表 · <i data-slot="substitution_relation_rows"></i> 条</span>
|
||||||
|
</div>
|
||||||
|
<div class="tbl-wrap" style="margin-bottom:14px">
|
||||||
|
<table>
|
||||||
|
${th('替代关系 ID', '原 → 候选', '替代类型', '推荐等级', '分析状态', '风险', '差异摘要', '验证要求 / 回链', '规则版本', '客户批准(只读)')}
|
||||||
|
<tbody data-rows="substitution.relation">
|
||||||
|
<tr>
|
||||||
|
<td class="mono" data-slot="substitution_relation_id"></td>
|
||||||
|
<td class="mono"><span data-slot="original_internal_pn"></span> → <b data-slot="candidate_internal_pn"></b></td>
|
||||||
|
<td><span class="enumtxt ty" data-class-of="substitution_type"></span></td>
|
||||||
|
<td><span class="st rec" data-class-of="recommendation_level"></span></td>
|
||||||
|
<td><span class="st an" data-class-of="analysis_status"></span></td>
|
||||||
|
<td><span class="st rk" data-class-of="risk_level"></span></td>
|
||||||
|
<td><span data-slot="difference_summary" data-blank></span><span class="sub" data-slot="recommendation_reason" data-blank></span></td>
|
||||||
|
<td class="mono"><span data-slot="validation_id" data-blank></span><span class="sub" data-slot="verification_requirement" data-blank></span></td>
|
||||||
|
<td class="mono" data-slot="rule_version" data-blank></td>
|
||||||
|
<td><span class="st ap" data-class-of="customer_approval_status"></span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sec-head" style="margin-bottom:10px">
|
||||||
|
<h2 style="font-size:13.5px"><span class="lid">P-11</span>差异明细子表(由双方 P-08 标准化参数集逐项比对得出;MATCH 项淡显)</h2>
|
||||||
|
<span class="cnt"><i data-slot="substitution_difference_item_rows"></i> 条 · MATCH <i data-slot="substitution_difference_item_difference_status_match"></i> · MISMATCH <i data-slot="substitution_difference_item_difference_status_mismatch"></i> · UNKNOWN <i data-slot="substitution_difference_item_difference_status_unknown"></i> · CONDITIONAL <i data-slot="substitution_difference_item_difference_status_conditional"></i></span>
|
||||||
|
</div>
|
||||||
|
<div class="tbl-wrap">
|
||||||
|
<table>
|
||||||
|
${th('所属关系', '参数项', '原值 → 候选值', '约束', '结论', '风险', '证据引用', '影响说明')}
|
||||||
|
<tbody data-rows="substitution.difference_item">
|
||||||
|
<tr data-class-of="difference_status">
|
||||||
|
<td class="mono" data-slot="substitution_relation_id"></td>
|
||||||
|
<td data-slot="parameter_name"></td>
|
||||||
|
<td class="mono"><span data-slot="original_value" data-blank></span> → <span data-slot="candidate_value" data-blank></span> <span data-slot="parameter_unit" data-blank></span></td>
|
||||||
|
<td><span class="enumtxt con" data-class-of="constraint_type"></span></td>
|
||||||
|
<td><span class="st dst" data-class-of="difference_status"></span></td>
|
||||||
|
<td><span class="st rk" data-class-of="risk_level"></span></td>
|
||||||
|
<td class="mono" data-slot="evidence_ref" data-blank></td>
|
||||||
|
<td><span data-slot="impact_statement" data-blank></span><span class="sub" data-slot="unknown_disposition" data-blank></span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- P-10 -->
|
||||||
|
<section id="p10">
|
||||||
|
<div class="sec-head">
|
||||||
|
<h2><span class="lid">P-10</span>原厂技术资料库</h2>
|
||||||
|
<span class="ltag evid">证据链 · R(查无 → 数据工单创建)</span>
|
||||||
|
<span class="why">每项结论的 datasheet 出处,版本回链</span>
|
||||||
|
<span class="cnt">正式表 · <i data-slot="parts_document_rows"></i> 条(本活动相关料号)</span>
|
||||||
|
</div>
|
||||||
|
<div class="tbl-wrap">
|
||||||
|
<table>
|
||||||
|
${th('关联料号', '品牌', '资料类型', '资料文件', '版本', '状态')}
|
||||||
|
<tbody data-rows="parts.document">
|
||||||
|
<tr>
|
||||||
|
<td class="mono"><span data-slot="internal_pn"></span> · <span data-slot="doc_seq" data-blank></span></td>
|
||||||
|
<td data-slot="brand_name" data-blank></td>
|
||||||
|
<td><span class="enumtxt dt" data-class-of="document_type"></span></td>
|
||||||
|
<td class="mono" data-slot="document_ref" data-blank></td>
|
||||||
|
<td class="mono" data-slot="document_version" data-blank></td>
|
||||||
|
<td><span class="st ds" data-class-of="document_status"></span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- P-06 -->
|
||||||
|
<section id="p06">
|
||||||
|
<div class="sec-head">
|
||||||
|
<h2><span class="lid">P-06</span>样品交付与芯片验证记录台账</h2>
|
||||||
|
<span class="ltag loop">闭环去向 · R + 条件写(不可创建,只能实测产生)</span>
|
||||||
|
<span class="why">UNKNOWN / CONDITIONAL 的收口处;验证完成后回链 P-11</span>
|
||||||
|
<span class="cnt">本单挂的 · <i data-slot="validation_record_rows"></i> 条 · 同料号历史 · <i data-slot="validation_history_rows"></i> 条</span>
|
||||||
|
</div>
|
||||||
|
<div class="tbl-wrap">
|
||||||
|
<table>
|
||||||
|
${th('验证 ID', '关联需求', '验证料号', '验证类型', '测试条件', '结果 / 状态', '问题与风险', '后续动作')}
|
||||||
|
<tbody data-rows="validation.record">
|
||||||
|
<tr>
|
||||||
|
<td class="mono" data-slot="validation_id"></td>
|
||||||
|
<td class="mono" data-slot="request_id" data-blank></td>
|
||||||
|
<td class="mono" data-slot="validation_internal_pn"></td>
|
||||||
|
<td><span class="enumtxt vt" data-class-of="validation_type"></span></td>
|
||||||
|
<td data-slot="test_condition" data-blank></td>
|
||||||
|
<td><span class="st vs" data-class-of="validation_status"></span><span class="sub" data-slot="test_result" data-blank></span><span class="sub mono" data-slot="evidence_ref" data-blank></span></td>
|
||||||
|
<td data-slot="issue_risk" data-blank></td>
|
||||||
|
<td data-slot="next_validation_action" data-blank></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tbody class="hist" data-rows="validation.history">
|
||||||
|
<tr>
|
||||||
|
<td class="mono" data-slot="validation_id"></td>
|
||||||
|
<td class="mono" data-slot="request_id" data-blank></td>
|
||||||
|
<td class="mono" data-slot="validation_internal_pn"></td>
|
||||||
|
<td><span class="enumtxt vt" data-class-of="validation_type"></span></td>
|
||||||
|
<td data-slot="test_condition" data-blank></td>
|
||||||
|
<td><span class="st vs" data-class-of="validation_status"></span><span class="sub" data-slot="test_result" data-blank></span><span class="sub mono" data-slot="evidence_ref" data-blank></span></td>
|
||||||
|
<td data-slot="issue_risk" data-blank></td>
|
||||||
|
<td data-slot="next_validation_action" data-blank></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 次要台账 -->
|
||||||
|
<section id="minor">
|
||||||
|
<div class="sec-head">
|
||||||
|
<h2>次要台账(只读引用,不进主看板)</h2>
|
||||||
|
</div>
|
||||||
|
<div class="minor">
|
||||||
|
<div class="m"><b><span class="lid">P-09</span>客户技术与机会档案</b>提供 BOM 位与客户技术栈背景;缺口由执行工单补齐。</div>
|
||||||
|
<div class="m"><b><span class="lid">EXT-02</span>外部来源清单</b>登记本次评估用到的可信外部来源(原厂官网 datasheet 等)。</div>
|
||||||
|
<div class="m"><b><span class="lid">GOV</span>评估规则库</b>四态判定口径;本次锁定版本 <span class="mono" data-slot="${D}.verdict.rule_version"></span>,随结论一并落账。</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<span>SS02 · ACT-WORK-05 差异评估 · 关键台账数据视图</span>
|
||||||
|
<span style="font-family:var(--mono)">数据来源:正式台账(parts / demand / substitution / validation / sub_eval)· 实例 <span data-slot="case:instanceNo"></span> · 案号 <span data-slot="case:ref"></span></span>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
@@ -29,6 +29,22 @@ import { D } from '../constants.ts';
|
|||||||
* 绑定层公共开关 —— 五张模版共用,与目标件自己的样式表分开存(那份要保持逐字节可比)
|
* 绑定层公共开关 —— 五张模版共用,与目标件自己的样式表分开存(那份要保持逐字节可比)
|
||||||
* ==================================================================================== */
|
* ==================================================================================== */
|
||||||
const BIND_COMMON = `
|
const BIND_COMMON = `
|
||||||
|
/* ── 2026-08-27 用户四连指出的件上毛病,病根三处都在「目标件的样式与平台的编辑态没对上」 ── */
|
||||||
|
/* ① 表宽超出就得能横着滑(E04/I01 08-23 同款):两种包装都管 —— 数据工单/执行件用平台的 .jc-tbl-wrap,
|
||||||
|
两张验收件照目标件用 .tbl-wrap,而验收版目标件写的是 overflow:hidden(只有数据工单那版补了 overflow-x:auto)。
|
||||||
|
2026-08-27 用户:「差异评估结论审核这个页面,表格部分不能左右滑动」—— 上一刀只管了前者 */
|
||||||
|
.jc-tbl-wrap, .tbl-wrap { overflow-x: auto; overflow-y: hidden; }
|
||||||
|
/* ② 编辑态只留输入格:目标件 #tbl1 .jc-vcell{display:inline}(id 特异度)盖过了 table.on td>.jc-vcell{display:none},
|
||||||
|
于是表 1 每格都是「只读字 + 输入框」并排 —— 内部料号那格恰好有字,人看见的就是它 */
|
||||||
|
#tbl1.on td > .jc-vcell:not(.ro) { display: none; }
|
||||||
|
/* ③ 一行里的 input 与 select 同高同线:select 天生比 input 矮 4px,编辑态里一排格子高低不齐 */
|
||||||
|
input.jc-cell, select.jc-cell { height: 32px; box-sizing: border-box; padding-top: 0; padding-bottom: 0; vertical-align: middle; }
|
||||||
|
/* ④ 加行 / 删行要看得见(2026-08-27 用户:「加一行后点旁边的叉没有反应」):E04 那套记号样式挂在它自己的
|
||||||
|
外壳类下,本件按目标件重做后表是 table.on —— 一条都没跟过来,× 按了类是切了、屏上没动静 */
|
||||||
|
table.on tr.jc-row-new td { background: #f2fbf7; }
|
||||||
|
table.on tr.jc-row-gone td { background: #fff6f6; opacity: .55; }
|
||||||
|
table.on tr.jc-row-gone .jc-cell { text-decoration: line-through; color: var(--ink-3); }
|
||||||
|
table.on tr.jc-row-gone .jc-rowdel { border-color: var(--danger); color: var(--danger); background: #fff; }
|
||||||
/* 只在「待处理」这一态露面;归档 / 验收只读态即便 HTML 里还带着 data-edit 也不摆闸和加行钮
|
/* 只在「待处理」这一态露面;归档 / 验收只读态即便 HTML 里还带着 data-edit 也不摆闸和加行钮
|
||||||
(目标件是静态 demo,不区分这两态;html.editable 是平台在 iframe 根节点上真开的关) */
|
(目标件是静态 demo,不区分这两态;html.editable 是平台在 iframe 根节点上真开的关) */
|
||||||
html:not(.editable) .jc-editgate, html:not(.editable) .jc-addrow { display: none !important; }
|
html:not(.editable) .jc-editgate, html:not(.editable) .jc-addrow { display: none !important; }
|
||||||
@@ -124,6 +140,14 @@ ${badgeCss('.ct', [
|
|||||||
|
|
||||||
/* ==================== 执行工单(评估输入清单):徽标别名 ==================== */
|
/* ==================== 执行工单(评估输入清单):徽标别名 ==================== */
|
||||||
const BIND_EXEC = `
|
const BIND_EXEC = `
|
||||||
|
/* 候选参数现值(只读参考块):一颗料一张,复用 .jc-tbl-wrap;块头摆料号 / 角色 / 在库状态 */
|
||||||
|
.prefs { display: block; }
|
||||||
|
.pref { border: 1px solid var(--line-soft); border-radius: 10px; overflow: hidden; margin-bottom: 12px; }
|
||||||
|
.pref:last-of-type { margin-bottom: 0; }
|
||||||
|
.pref-head { display: flex; align-items: center; gap: 10px; padding: 9px 14px; background: var(--soft); border-bottom: 1px solid var(--line); }
|
||||||
|
.pref-head b { font-size: 13.5px; }
|
||||||
|
.pref .jc-tbl-wrap { border: 0; border-radius: 0; }
|
||||||
|
|
||||||
/* 存在性核对的两列(角色 / 在库状态)与评估输入清单的状态列共用目标件自己的 .st 徽标基座 ——
|
/* 存在性核对的两列(角色 / 在库状态)与评估输入清单的状态列共用目标件自己的 .st 徽标基座 ——
|
||||||
取值域互不重叠(found/absent/created/abandoned 与 gap/default/ok 没有一个词撞车)。
|
取值域互不重叠(found/absent/created/abandoned 与 gap/default/ok 没有一个词撞车)。
|
||||||
四档配色**原样照抄目标件自己的 .st.bound / .st.ok / .st.miss / .st.def 那四条声明**,只换成 v- 版。 */
|
四档配色**原样照抄目标件自己的 .st.bound / .st.ok / .st.miss / .st.def 那四条声明**,只换成 v- 版。 */
|
||||||
@@ -165,14 +189,21 @@ ${badgeCss('.st.dst', [
|
|||||||
['match', 'MATCH', OK],
|
['match', 'MATCH', OK],
|
||||||
['mismatch', 'MISMATCH', DANGER],
|
['mismatch', 'MISMATCH', DANGER],
|
||||||
['unknown', 'UNKNOWN', AMBER],
|
['unknown', 'UNKNOWN', AMBER],
|
||||||
['conditional', 'CONDITIONAL', 'color: var(--ledger); background: var(--ledger-soft); border-color: var(--ledger-line);'],
|
// 目标件 2026-08-27 改版:CONDITIONAL 从题头红改成品牌蓝(.st.cond),原样照抄
|
||||||
|
['conditional', 'CONDITIONAL', 'color: var(--brand-deep); background: var(--brand-wash); border-color: var(--brand-soft);'],
|
||||||
])}
|
])}
|
||||||
${badgeCss('.st.rec', [
|
/* 推荐程度 pill —— 目标件 .rec.yes / .rec.cond / .rec.no 三条声明原样照抄;台账是四档(备选借条件推荐那档琥珀) */
|
||||||
|
${badgeCss('.rec', [
|
||||||
['recommended', '推荐', OK],
|
['recommended', '推荐', OK],
|
||||||
['conditional', '条件推荐', 'color: var(--ledger); background: var(--ledger-soft); border-color: var(--ledger-line);'],
|
['conditional', '有条件推荐', 'color: var(--amber); background: var(--amber-soft); border-color: var(--amber-line);'],
|
||||||
['alternate', '备选', AMBER],
|
['alternate', '备选', 'color: var(--amber); background: var(--amber-soft); border-color: var(--amber-line);'],
|
||||||
['not_recommended', '不推荐', DANGER],
|
['not_recommended', '不推荐', DANGER],
|
||||||
])}
|
])}
|
||||||
|
/* 候选块序号(目标件 .cand-idx 写死 1/2)—— 交给 CSS 计数器;块头的风险级别前缀「风险 」也在目标件里 */
|
||||||
|
.card-bd { counter-reset: cand; }
|
||||||
|
.cand { counter-increment: cand; }
|
||||||
|
.cand-idx::before { content: counter(cand); }
|
||||||
|
.cand-con .risk::before { content: "风险 "; }
|
||||||
/*
|
/*
|
||||||
* 风险等级(原样照抄目标件 .risk.low/.mid/.hi 三条声明)——加 critical 一档:这一屏「各候选
|
* 风险等级(原样照抄目标件 .risk.low/.mid/.hi 三条声明)——加 critical 一档:这一屏「各候选
|
||||||
* 推荐等级」摘要读的是 substitution.relation.risk_level(声明了 low/medium/high/critical
|
* 推荐等级」摘要读的是 substitution.relation.risk_level(声明了 low/medium/high/critical
|
||||||
@@ -180,7 +211,7 @@ ${badgeCss('.st.rec', [
|
|||||||
*/
|
*/
|
||||||
${badgeCss('.risk', [
|
${badgeCss('.risk', [
|
||||||
['low', '低', 'color: var(--ok);'],
|
['low', '低', 'color: var(--ok);'],
|
||||||
['medium', '中', 'color: #8f6a12;'],
|
['medium', '中', 'color: var(--amber, #8f6a12);'],
|
||||||
['high', '高', 'color: var(--danger);'],
|
['high', '高', 'color: var(--danger);'],
|
||||||
['critical', '危急', 'color: var(--danger); font-weight: 900;'],
|
['critical', '危急', 'color: var(--danger); font-weight: 900;'],
|
||||||
])}
|
])}
|
||||||
@@ -230,7 +261,7 @@ ${badgeCss('.st', [
|
|||||||
/* 风险等级比验收 01 多一档 critical(P-11 主记录的 risk_level 声明了它,差异明细没有) */
|
/* 风险等级比验收 01 多一档 critical(P-11 主记录的 risk_level 声明了它,差异明细没有) */
|
||||||
${badgeCss('.risk', [
|
${badgeCss('.risk', [
|
||||||
['low', '低', 'color: var(--ok);'],
|
['low', '低', 'color: var(--ok);'],
|
||||||
['medium', '中', 'color: #8f6a12;'],
|
['medium', '中', 'color: var(--amber, #8f6a12);'],
|
||||||
['high', '高', 'color: var(--danger);'],
|
['high', '高', 'color: var(--danger);'],
|
||||||
['critical', '危急', 'color: var(--danger); font-weight: 900;'],
|
['critical', '危急', 'color: var(--danger); font-weight: 900;'],
|
||||||
])}
|
])}
|
||||||
@@ -305,10 +336,14 @@ const cell = (col: string, cls = ''): string => `<span class="jc-vcell${cls ===
|
|||||||
const ro = (col: string, cls = ''): string => `<span class="jc-vcell ro${cls === '' ? '' : ` ${cls}`}" data-slot="${col}" data-blank></span>`;
|
const ro = (col: string, cls = ''): string => `<span class="jc-vcell ro${cls === '' ? '' : ` ${cls}`}" data-slot="${col}" data-blank></span>`;
|
||||||
const select = (col: string, options: ReadonlyArray<readonly [string, string]>, required = false): string =>
|
const select = (col: string, options: ReadonlyArray<readonly [string, string]>, required = false): string =>
|
||||||
`<select class="jc-cell" data-col="${col}"${required ? ' required' : ''}><option value=""></option>${options.map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}</select>`;
|
`<select class="jc-cell" data-col="${col}"${required ? ' required' : ''}><option value=""></option>${options.map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}</select>`;
|
||||||
/** 枚举徽标(可改表里用,旁边挨着 `.jc-cell` 孪生件时要包一层 `.ro` 才不被编辑态藏起来) */
|
/**
|
||||||
const badge = (klass: string, col: string, wrapRo = false): string => {
|
* 枚举徽标。可改表里挨着 `<select class="jc-cell">` 孪生件时包一层 `.jc-vcell`(不带 `.ro`):
|
||||||
|
* 看的时候摆徽标,**编辑态随只读格一起藏起来,只留下拉框** —— 从前包的是 `.ro`(编辑态留着),
|
||||||
|
* 徽标压在下拉框头上把那一格顶低 13px,一排格子高低不齐(2026-08-27 用户指出)。
|
||||||
|
*/
|
||||||
|
const badge = (klass: string, col: string, wrapTwin = false): string => {
|
||||||
const inner = `<span class="${klass}" data-class-of="${col}"></span>`;
|
const inner = `<span class="${klass}" data-class-of="${col}"></span>`;
|
||||||
return wrapRo ? `<span class="jc-vcell ro">${inner}</span>` : inner;
|
return wrapTwin ? `<span class="jc-vcell">${inner}</span>` : inner;
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ==================== 局面 0 · 数据工单 P-08(搬自「数据工单-01」)==================== */
|
/* ==================== 局面 0 · 数据工单 P-08(搬自「数据工单-01」)==================== */
|
||||||
@@ -316,17 +351,17 @@ const badge = (klass: string, col: string, wrapRo = false): string => {
|
|||||||
const P08_TABLE = `<div class="jc-tbl-wrap"><table id="tbl1">
|
const P08_TABLE = `<div class="jc-tbl-wrap"><table id="tbl1">
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th style="width:38px">#</th>
|
<th style="width:38px">#</th>
|
||||||
<th class="req" data-h="内部料号">内部料号 internal_pn<span class="thd">全链路统一基准,不可自由文本</span></th>
|
<th class="req" data-h="内部料号">内部料号</th>
|
||||||
<th class="req" data-h="品牌名称">品牌 brand_name<span class="thd">与制造商、供应商分开</span></th>
|
<th class="req" data-h="品牌名称">品牌</th>
|
||||||
<th data-h="制造商名称">制造商 manufacturer_name<span class="thd">与品牌不一致时分开记录</span></th>
|
<th data-h="制造商名称">制造商</th>
|
||||||
<th class="req" data-h="规格描述">规格描述 specification_description<span class="thd">按原厂资料口径</span></th>
|
<th class="req" data-h="规格描述">规格描述</th>
|
||||||
<th class="req" data-h="封装">封装 package<span class="thd">影响硬约束比对</span></th>
|
<th class="req" data-h="封装">封装</th>
|
||||||
<th data-h="工艺">工艺 process_technology<span class="thd">硬约束比较项</span></th>
|
<th data-h="工艺">工艺</th>
|
||||||
<th class="req" data-h="生命周期状态">生命周期 lifecycle_status<span class="thd">Active/NRND/EOL/停产/风险</span></th>
|
<th class="req" data-h="生命周期状态">生命周期</th>
|
||||||
<th data-h="EOL/PCN状态">EOL/PCN eol_pcn_status<span class="thd">通知/影响/生效时间</span></th>
|
<th data-h="EOL/PCN状态">EOL/PCN</th>
|
||||||
<th data-h="Datasheet引用">Datasheet 引用 datasheet_ref</th>
|
<th data-h="Datasheet引用">Datasheet 引用</th>
|
||||||
<th data-h="Datasheet版本">Datasheet 版本 datasheet_version<span class="thd">与引用成对</span></th>
|
<th data-h="Datasheet版本">Datasheet 版本</th>
|
||||||
<th data-h="适配应用场景">适配场景 application_scenarios<span class="thd">判断实际适用性</span></th>
|
<th data-h="适配应用场景">适配场景</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody data-rows="mine:parts.profile" data-edit="parts.profile"><tr>
|
<tbody data-rows="mine:parts.profile" data-edit="parts.profile"><tr>
|
||||||
<td class="rn"><span class="jc-rn-num"></span><button class="jc-rowdel" type="button" title="删除此行" data-edit-del="parts.profile">×</button></td>
|
<td class="rn"><span class="jc-rn-num"></span><button class="jc-rowdel" type="button" title="删除此行" data-edit-del="parts.profile">×</button></td>
|
||||||
@@ -354,7 +389,7 @@ const P08_TABLE = `<div class="jc-tbl-wrap"><table id="tbl1">
|
|||||||
const P08_PARAMS_TABLE = `<div class="jc-tbl-wrap"><table id="tbl2">
|
const P08_PARAMS_TABLE = `<div class="jc-tbl-wrap"><table id="tbl2">
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th style="width:38px">#</th>
|
<th style="width:38px">#</th>
|
||||||
<th class="req" data-h="所属料号">所属料号 internal_pn<span class="thd">归属表 1 哪条记录</span></th>
|
<th class="req" data-h="所属料号">所属料号</th>
|
||||||
<th class="req" data-h="参数名">参数名</th>
|
<th class="req" data-h="参数名">参数名</th>
|
||||||
<th class="req" data-h="值">值</th>
|
<th class="req" data-h="值">值</th>
|
||||||
<th data-h="单位">单位</th>
|
<th data-h="单位">单位</th>
|
||||||
@@ -408,15 +443,15 @@ export const P08_MASTER_CREATE = doc({
|
|||||||
const P10_TABLE = `<div class="jc-tbl-wrap"><table>
|
const P10_TABLE = `<div class="jc-tbl-wrap"><table>
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th style="width:38px">#</th>
|
<th style="width:38px">#</th>
|
||||||
<th class="req" data-h="关联料号">关联料号 internal_pn<span class="thd">对应 P-08 那条记录</span></th>
|
<th class="req" data-h="关联料号">关联料号</th>
|
||||||
<th class="req" data-h="资料序号">资料序号 doc_seq<span class="thd">同一料号下 01/02… 自己续</span></th>
|
<th class="req" data-h="资料序号">资料序号</th>
|
||||||
<th class="req" data-h="资料类型">资料类型 document_type</th>
|
<th class="req" data-h="资料类型">资料类型</th>
|
||||||
<th data-h="品牌名称">品牌 brand_name</th>
|
<th data-h="品牌名称">品牌</th>
|
||||||
<th class="req" data-h="资料标题">资料标题 document_title</th>
|
<th class="req" data-h="资料标题">资料标题</th>
|
||||||
<th class="req" data-h="资料版本">资料版本 document_version</th>
|
<th class="req" data-h="资料版本">资料版本</th>
|
||||||
<th class="req" data-h="文件链接">文件链接 document_ref</th>
|
<th class="req" data-h="文件链接">文件链接</th>
|
||||||
<th data-h="证据定位">证据定位 citation_locator<span class="thd">页码/章节/表格</span></th>
|
<th data-h="证据定位">证据定位</th>
|
||||||
<th class="req" data-h="资料状态">资料状态 document_status</th>
|
<th class="req" data-h="资料状态">资料状态</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody data-rows="mine:parts.document" data-edit="parts.document"><tr>
|
<tbody data-rows="mine:parts.document" data-edit="parts.document"><tr>
|
||||||
<td class="rn"><span class="jc-rn-num"></span><button class="jc-rowdel" type="button" title="删除此行" data-edit-del="parts.document">×</button></td>
|
<td class="rn"><span class="jc-rn-num"></span><button class="jc-rowdel" type="button" title="删除此行" data-edit-del="parts.document">×</button></td>
|
||||||
@@ -476,6 +511,25 @@ const PN_CHECK_TABLE = `<div class="jc-tbl-wrap"><table>
|
|||||||
</tr></tbody>
|
</tr></tbody>
|
||||||
</table></div>`;
|
</table></div>`;
|
||||||
|
|
||||||
|
/** 候选参数现值 —— **只读参考,基座从台账现读**(2026-08-27,用户三次指出「库里明明有值,清单还要填一遍」):
|
||||||
|
* 从前「待确认」行由模型判「哪个参数原厂未披露」再写进清单 —— 参数值有没有是台账里的事实,不该由模型宣布;
|
||||||
|
* 现在每颗料一张小表(外层按核对表逐颗复制,里层 `data-by="internal_pn"` 只列它名下的参数行),
|
||||||
|
* 人看得见现值;未披露的由差异评估判成 UNKNOWN 并挂验证任务。 */
|
||||||
|
const PARAM_REF = `<article class="prefs" data-rows="mine:${D}.pn_check"><section class="pref">
|
||||||
|
<div class="pref-head"><b class="mono" data-slot="internal_pn" data-blank></b><span class="rl" data-class-of="role"></span>${badge('st', 'exists_status')}</div>
|
||||||
|
<div class="jc-tbl-wrap"><table>
|
||||||
|
<thead><tr><th style="width:38px">#</th><th data-h="参数名">参数名</th><th data-h="值">值</th><th data-h="单位">单位</th><th data-h="约束类型">约束类型</th><th data-h="说明">说明</th></tr></thead>
|
||||||
|
<tbody data-rows="parts.parameter" data-by="internal_pn"><tr>
|
||||||
|
<td class="rn"><span class="jc-rn-num"></span></td>
|
||||||
|
<td>${ro('param_name')}</td>
|
||||||
|
<td>${ro('param_value', 'mono')}</td>
|
||||||
|
<td>${ro('param_unit')}</td>
|
||||||
|
<td><span class="jc-vcell ro"><span class="ct" data-class-of="constraint_type"></span></span></td>
|
||||||
|
<td>${ro('param_note')}</td>
|
||||||
|
</tr></tbody>
|
||||||
|
</table></div>
|
||||||
|
</section></article>`;
|
||||||
|
|
||||||
const GAP_TABLE = `<div class="jc-tbl-wrap"><table id="tbl">
|
const GAP_TABLE = `<div class="jc-tbl-wrap"><table id="tbl">
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th style="width:38px">#</th>
|
<th style="width:38px">#</th>
|
||||||
@@ -510,13 +564,16 @@ export const EVAL_INPUT_LIST = doc({
|
|||||||
zone('做什么', '启动 4 项已绑定,其余信息在工单里补齐') +
|
zone('做什么', '启动 4 项已绑定,其余信息在工单里补齐') +
|
||||||
note([
|
note([
|
||||||
'<span class="done">AI 已完成</span>:系统已按启动传入的 <b>4 项</b>完成绑定——客户、客户需求(新建 P-01 · 版本 V1)、原 PN、候选 PN(数组,逐个评估)——并生成输入快照。',
|
'<span class="done">AI 已完成</span>:系统已按启动传入的 <b>4 项</b>完成绑定——客户、客户需求(新建 P-01 · 版本 V1)、原 PN、候选 PN(数组,逐个评估)——并生成输入快照。',
|
||||||
'<span class="need">需要你补齐</span>:启动时未收的信息(应用场景、BOM 位)<b>直接在下表补</b>;默认值(替代类型 / 规则版本 / 负责人期限)可确认可改。本单缺口只覆盖「<b>有而不全</b>」;「完全没有」(料号不在库)已由数据工单创建。未补齐的缺口项将按 <b>UNKNOWN</b> 进入差异评估。',
|
'<span class="need">需要你补齐</span>:启动时未收的信息(应用场景、BOM 位)<b>直接在下表补</b>;默认值(替代类型 / 规则版本 / 负责人期限)可确认可改。「完全没有」(料号不在库)已由数据工单创建;每颗料的参数现值摆在「候选参数现值」里,写着「未披露」的参数将按 <b>UNKNOWN</b> 进入差异评估并挂验证任务,不用在这里补。',
|
||||||
]) +
|
]) +
|
||||||
zone('料号存在性核对', '只读参考 · 与下表「补充内容」互不冲突') +
|
zone('料号存在性核对', '只读参考 · 与下表「补充内容」互不冲突') +
|
||||||
PN_CHECK_TABLE +
|
PN_CHECK_TABLE +
|
||||||
|
zone('候选参数现值', '只读参考 · P-08 标准化参数集,一颗料一张,基座从台账现读;写着「未披露」的由差异评估判成 UNKNOWN 并挂验证任务') +
|
||||||
|
PARAM_REF +
|
||||||
zone('交回什么', '评估输入清单(已绑定项照实核对,缺口项补齐,*为必填)') +
|
zone('交回什么', '评估输入清单(已绑定项照实核对,缺口项补齐,*为必填)') +
|
||||||
gapbar(`${D}_input_gap`, '补充内容') +
|
// 红带说「必填格」不说「补充内容」:缺的可能是 AI 该写的输入项(2026-08-27 实测),写死列名会把人指向错的格子
|
||||||
editGate(`${D}.input_gap`, `<b>还有 <span data-slot="${D}_input_gap_missing"></span> 项待补 / 待确认</b>,点「编辑」在「补充内容」列填入 —— 已绑定那几行的「输入项/来源/绑定值」抄的是启动入参,发现记录本身有误请退回触发方重新启动,不要在这里改`) +
|
gapbar(`${D}_input_gap`, '必填格') +
|
||||||
|
editGate(`${D}.input_gap`, `<b>还有 <span data-slot="${D}_input_gap_missing"></span> 项待补</b>,点「编辑」在「补充内容」列填入 —— 已绑定那几行的「输入项/来源/绑定值」抄的是启动入参,发现记录本身有误请退回触发方重新启动,不要在这里改`) +
|
||||||
GAP_TABLE +
|
GAP_TABLE +
|
||||||
addrow(`${D}.input_gap`, '+ 加一行(补充资料项)', '新行的「键」必须写(它是这一行的钥匙,写一个英文短名,如 thermal_note);「输入项」与「补充内容」也是必填;删一行 = 该输入项不适用;补不了的写「留 UNKNOWN」或「挂验证任务」') +
|
addrow(`${D}.input_gap`, '+ 加一行(补充资料项)', '新行的「键」必须写(它是这一行的钥匙,写一个英文短名,如 thermal_note);「输入项」与「补充内容」也是必填;删一行 = 该输入项不适用;补不了的写「留 UNKNOWN」或「挂验证任务」') +
|
||||||
flow(
|
flow(
|
||||||
@@ -526,42 +583,59 @@ export const EVAL_INPUT_LIST = doc({
|
|||||||
|
|
||||||
/* ==================== 局面 2 · 差异评估结论审核(搬自「验收工单-01」)==================== */
|
/* ==================== 局面 2 · 差异评估结论审核(搬自「验收工单-01」)==================== */
|
||||||
|
|
||||||
const RELATION_SUMMARY_TABLE = `<div class="tbl-wrap"><table>
|
/** 候选 PN 结论汇总 —— 目标件 2026-08-27 改版:一个候选一条结论(P-11 主表草稿)。
|
||||||
|
* 品牌不在 relation 上:用嵌套行模版 `data-by` 从 `parts.profile` 整表里挑这一颗料那一行(取值,不是算)。
|
||||||
|
* 「差异统计(四态)」绑 difference_summary —— S3 剧本让模型按「7 项比对:4 MATCH / 1 MISMATCH / …」的形写它,
|
||||||
|
* 模版不数四态(数出来的画面一律不做假)。 */
|
||||||
|
const RELATION_SUMMARY_TABLE = `<div class="tbl-wrap" style="margin-bottom:20px"><table>
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th style="width:40px">#</th>
|
<th style="width:40px">#</th>
|
||||||
<th data-h="候选料号">候选料号</th>
|
<th data-h="候选PN">候选 PN</th>
|
||||||
<th data-h="替代类型">替代类型</th>
|
<th data-h="品牌">品牌</th>
|
||||||
<th data-h="推荐等级">推荐等级</th>
|
<th data-h="推荐程度">推荐程度</th>
|
||||||
<th data-h="风险等级">风险等级</th>
|
<th data-h="风险级别">风险级别</th>
|
||||||
<th data-h="推荐理由">推荐理由</th>
|
<th data-h="差异统计">差异统计(四态)</th>
|
||||||
<th data-h="差异摘要">差异摘要</th>
|
<th data-h="结论摘要">结论摘要</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody data-rows="mine:substitution.relation"><tr>
|
<tbody data-rows="mine:substitution.relation"><tr>
|
||||||
<td class="num"></td>
|
<td class="num"></td>
|
||||||
<td class="mono" data-slot="candidate_internal_pn" data-blank></td>
|
<td class="cust mono" data-slot="candidate_internal_pn" data-blank></td>
|
||||||
<td><span class="enumtxt" data-class-of="substitution_type"></span></td>
|
<td><span data-rows="parts.profile" data-by="internal_pn=candidate_internal_pn"><i data-slot="brand_name" data-blank></i></span></td>
|
||||||
<td>${badge('st rec', 'recommendation_level')}</td>
|
<td>${badge('rec', 'recommendation_level')}</td>
|
||||||
<td>${badge('risk', 'risk_level')}</td>
|
<td>${badge('risk', 'risk_level')}</td>
|
||||||
|
<td class="stat" data-slot="difference_summary" data-blank></td>
|
||||||
<td class="reason" data-slot="recommendation_reason" data-blank></td>
|
<td class="reason" data-slot="recommendation_reason" data-blank></td>
|
||||||
<td class="reason" data-slot="difference_summary" data-blank></td>
|
|
||||||
</tr></tbody>
|
</tr></tbody>
|
||||||
</table></div>`;
|
</table></div>`;
|
||||||
|
|
||||||
const DIFFERENCE_ITEM_TABLE = `<div class="tbl-wrap"><table>
|
/** 差异明细 · 按候选 PN 分组 —— 目标件 2026-08-27 改版:**一个候选一张表**(用户:「存在多个替代 PN 要对比差异时看不清楚」)。
|
||||||
<thead><tr><th style="width:40px">#</th><th data-h="参数项 parameter_name">参数项 parameter_name</th><th data-h="原PN值 original_value">原 PN 值 original_value</th><th data-h="候选PN值 candidate_value">候选 PN 值 candidate_value</th><th data-h="单位 parameter_unit">单位 parameter_unit</th><th data-h="约束类型 constraint_type">约束类型 constraint_type</th><th data-h="结论 difference_status">结论 difference_status</th><th data-h="风险等级">风险等级</th><th data-h="证据引用 evidence_ref">证据引用 evidence_ref</th><th data-h="AI置信度">AI 置信度</th></tr></thead>
|
* 外层容器 <article> 按 relation 逐个复制行模版 <section class="cand">,里层 <tbody data-by="substitution_relation_id">
|
||||||
<tbody data-rows="mine:substitution.difference_item"><tr>
|
* 只列该候选名下的明细(渲染器 2026-08-27 补的嵌套 + 父键过滤)。容器标签名不得在行模版内部再出现:
|
||||||
<td class="num"></td>
|
* <article> 里是 section / div / table,不撞。 */
|
||||||
<td class="cust" data-slot="parameter_name" data-blank></td>
|
const CANDIDATE_SECTIONS = `<article class="cands" data-rows="mine:substitution.relation"><section class="cand">
|
||||||
<td class="mono" data-slot="original_value" data-blank></td>
|
<div class="cand-head">
|
||||||
<td class="mono" data-slot="candidate_value" data-blank></td>
|
<span class="cand-idx"></span>
|
||||||
<td data-slot="parameter_unit" data-blank></td>
|
<span class="cand-name mono" data-slot="candidate_internal_pn" data-blank></span>
|
||||||
<td><span class="con" data-class-of="constraint_type"></span></td>
|
<span class="cand-brand" data-rows="parts.profile" data-by="internal_pn=candidate_internal_pn"><i data-slot="brand_name" data-blank></i></span>
|
||||||
<td>${badge('st dst', 'difference_status')}</td>
|
<span class="cand-vs">vs 原 PN · <i data-slot="original_internal_pn" data-blank></i></span>
|
||||||
<td>${badge('risk', 'risk_level')}</td>
|
<span class="cand-con">${badge('rec', 'recommendation_level')}${badge('risk', 'risk_level')}</span>
|
||||||
<td class="mono" data-slot="evidence_ref" data-blank></td>
|
</div>
|
||||||
<td class="mono" data-slot="confidence" data-blank></td>
|
<div class="tbl-wrap"><table>
|
||||||
</tr></tbody>
|
<thead><tr><th style="width:40px">#</th><th data-h="参数项">参数项</th><th data-h="原PN值">原 PN 值</th><th data-h="候选PN值">候选 PN 值</th><th data-h="单位">单位</th><th data-h="约束类型">约束类型</th><th data-h="结论">结论</th><th data-h="风险等级">风险等级</th><th data-h="证据引用">证据引用</th><th data-h="AI置信度">AI 置信度</th></tr></thead>
|
||||||
</table></div>`;
|
<tbody data-rows="mine:substitution.difference_item" data-by="substitution_relation_id"><tr>
|
||||||
|
<td class="num"></td>
|
||||||
|
<td class="cust" data-slot="parameter_name" data-blank></td>
|
||||||
|
<td class="mono" data-slot="original_value" data-blank></td>
|
||||||
|
<td class="mono" data-slot="candidate_value" data-blank></td>
|
||||||
|
<td data-slot="parameter_unit" data-blank></td>
|
||||||
|
<td><span class="con" data-class-of="constraint_type"></span></td>
|
||||||
|
<td>${badge('st dst', 'difference_status')}</td>
|
||||||
|
<td>${badge('risk', 'risk_level')}</td>
|
||||||
|
<td class="mono" data-slot="evidence_ref" data-blank></td>
|
||||||
|
<td class="mono" data-slot="confidence" data-blank></td>
|
||||||
|
</tr></tbody>
|
||||||
|
</table></div>
|
||||||
|
</section></article>`;
|
||||||
|
|
||||||
export const DIFFERENCE_REVIEW = doc({
|
export const DIFFERENCE_REVIEW = doc({
|
||||||
css: ACC1_CSS,
|
css: ACC1_CSS,
|
||||||
@@ -575,18 +649,18 @@ export const DIFFERENCE_REVIEW = doc({
|
|||||||
body:
|
body:
|
||||||
zone('AI 结论说明', '结论是怎么算出来的') +
|
zone('AI 结论说明', '结论是怎么算出来的') +
|
||||||
note([
|
note([
|
||||||
'<b>AI 已完成的计算</b>:按 GOV 评估规则(锁定版本 <b class="mono" data-slot="rule_version" data-blank></b>),对原 PN <b class="mono" data-slot="original_internal_pn" data-blank></b> 与每个候选逐项比对,产出四态结论——<b>MATCH / MISMATCH / UNKNOWN / CONDITIONAL</b>,并给出风险等级、证据引用与置信度。差异明细 = <b>P-11 差异明细子表</b>(一条参数差异一条记录);当前为 AI 草稿,<b>FAE 审核通过前不落账</b>。评估参数可能含本次数据工单创建的主数据(确认人背书;未附 datasheet 的参数项会显式标注「人工录入」)。',
|
'<b>AI 已完成的计算</b>:按 GOV 评估规则(锁定版本 <b class="mono" data-slot="rule_version" data-blank></b>),以原 PN <b class="mono" data-slot="original_internal_pn" data-blank></b> 为基准,对 <b data-slot="substitution_relation_rows"></b> 个候选 PN 分别逐项比对,每项产出四态结论——<b>MATCH / MISMATCH / UNKNOWN / CONDITIONAL</b>,并给出风险等级、证据引用与置信度;再按候选汇总出下方的【候选 PN 结论汇总】(一个候选一条结论)。差异明细 = <b>P-11 差异明细子表</b>(一条参数差异一条记录,按候选分组);结论汇总 = <b>P-11 主表草稿</b>。当前均为 AI 草稿,<b>FAE 审核通过前不落账</b>。评估参数可能含本次数据工单创建的主数据(确认人背书;未附 datasheet 的参数项会显式标注「人工录入」)。',
|
||||||
]) +
|
]) +
|
||||||
criteria('判断这份差异评估结论是否可信,请逐项核对', [
|
criteria('判断这份差异评估结论是否可信,请逐项核对', [
|
||||||
'<b>关键参数差异均有明确状态</b>:逐项结论无漏项,每个参数都有四态之一的判定;',
|
'<b>汇总结论与明细一致</b>:结论表中每个候选的推荐程度 / 风险级别,与其下方差异明细表的四态分布吻合(存在 MISMATCH 硬约束或悬空 UNKNOWN 的候选不得给「推荐」);',
|
||||||
|
'<b>关键参数差异均有明确状态</b>:每个候选的逐项结论无漏项,每个参数都有四态之一的判定;',
|
||||||
'<b>UNKNOWN 项均有去向</b>:每个 UNKNOWN 都挂了验证任务或补来源动作,不允许悬空;',
|
'<b>UNKNOWN 项均有去向</b>:每个 UNKNOWN 都挂了验证任务或补来源动作,不允许悬空;',
|
||||||
'<b>证据链完整</b>:每项结论可追溯至 P-08 / P-10 / P-06 的具体记录,或本单数据工单的创建记录(标注:人工录入);',
|
'<b>证据链完整</b>:每项结论可追溯至 P-08 / P-10 / P-06 的具体记录,或本单数据工单的创建记录(标注:人工录入);硬约束项未经证据支持不得判 MATCH。',
|
||||||
'<b>硬约束项未被自动判 MATCH</b>:封装 / 工艺等硬约束未经证据支持不得判 MATCH。',
|
|
||||||
]) +
|
]) +
|
||||||
zone('各候选推荐等级', '只读展示(P-11 主记录草稿,一个候选一条)') +
|
zone('候选 PN 结论汇总', '每个候选一条结论,内容 = 对应下方明细表的汇总判定(P-11 主表草稿)') +
|
||||||
RELATION_SUMMARY_TABLE +
|
RELATION_SUMMARY_TABLE +
|
||||||
zone('AI 产出的差异明细', '只读展示(P-11 差异明细子表草稿 · 一条参数一条记录 difference_item_id)') +
|
zone('差异明细 · 按候选 PN 分组', '每个候选一张表,与原 PN 逐项对比(P-11 差异明细子表草稿 · 一条参数一条记录 difference_item_id)') +
|
||||||
DIFFERENCE_ITEM_TABLE +
|
CANDIDATE_SECTIONS +
|
||||||
flow(
|
flow(
|
||||||
'<b>审批:</b>本工单只承载内容;「通过 / 驳回」请在对话区的【验收卡】中进行。审核意见与补充条件写入 P-06(验证要求 verification_requirement);<b>通过后去向:</b>进入步骤 5 · 评审放行与落账确认。',
|
'<b>审批:</b>本工单只承载内容;「通过 / 驳回」请在对话区的【验收卡】中进行。审核意见与补充条件写入 P-06(验证要求 verification_requirement);<b>通过后去向:</b>进入步骤 5 · 评审放行与落账确认。',
|
||||||
),
|
),
|
||||||
@@ -599,31 +673,31 @@ export const DIFFERENCE_REVIEW = doc({
|
|||||||
const P11_FIELD_TABLE = `<div class="tbl-wrap"><table>
|
const P11_FIELD_TABLE = `<div class="tbl-wrap"><table>
|
||||||
<thead><tr><th style="width:40px">#</th><th style="width:180px" data-h="字段">字段</th><th data-h="拟写入值">拟写入值</th></tr></thead>
|
<thead><tr><th style="width:40px">#</th><th style="width:180px" data-h="字段">字段</th><th data-h="拟写入值">拟写入值</th></tr></thead>
|
||||||
<tbody data-rows="mine:substitution.relation">
|
<tbody data-rows="mine:substitution.relation">
|
||||||
<tr><td class="num"></td><td class="cust">替代类型 substitution_type</td><td><span class="enumtxt" data-class-of="substitution_type"></span></td></tr>
|
<tr><td class="num"></td><td class="cust">替代类型</td><td><span class="enumtxt" data-class-of="substitution_type"></span></td></tr>
|
||||||
<tr><td class="num"></td><td class="cust">原内部料号 original_internal_pn</td><td class="mono" data-slot="original_internal_pn" data-blank></td></tr>
|
<tr><td class="num"></td><td class="cust">原内部料号</td><td class="mono" data-slot="original_internal_pn" data-blank></td></tr>
|
||||||
<tr><td class="num"></td><td class="cust">候选内部料号 candidate_internal_pn</td><td class="mono" data-slot="candidate_internal_pn" data-blank></td></tr>
|
<tr><td class="num"></td><td class="cust">候选内部料号</td><td class="mono" data-slot="candidate_internal_pn" data-blank></td></tr>
|
||||||
<tr><td class="num"></td><td class="cust">推荐等级 recommendation_level</td><td>${badge('st', 'recommendation_level')} <span class="reason">(枚举:推荐 / 条件推荐 / 备选 / 不推荐,四态之一)</span></td></tr>
|
<tr><td class="num"></td><td class="cust">推荐等级</td><td>${badge('st', 'recommendation_level')} <span class="reason">(枚举:推荐 / 条件推荐 / 备选 / 不推荐,四态之一)</span></td></tr>
|
||||||
<tr><td class="num"></td><td class="cust">推荐理由 recommendation_reason</td><td data-slot="recommendation_reason" data-blank></td></tr>
|
<tr><td class="num"></td><td class="cust">推荐理由</td><td data-slot="recommendation_reason" data-blank></td></tr>
|
||||||
<tr><td class="num"></td><td class="cust">差异摘要 difference_summary</td><td data-slot="difference_summary" data-blank></td></tr>
|
<tr><td class="num"></td><td class="cust">差异摘要</td><td data-slot="difference_summary" data-blank></td></tr>
|
||||||
<tr><td class="num"></td><td class="cust">替代分析报告 analysis_report_ref</td><td class="mono" data-slot="analysis_report_ref" data-blank></td></tr>
|
<tr><td class="num"></td><td class="cust">替代分析报告</td><td class="mono" data-slot="analysis_report_ref" data-blank></td></tr>
|
||||||
<tr><td class="num"></td><td class="cust">分析状态 analysis_status</td><td>${badge('st', 'analysis_status')} <span class="reason">(枚举:AI草稿 / FAE确认 / 待客户验证 / 客户已批准 / 不批准 / 作废)</span></td></tr>
|
<tr><td class="num"></td><td class="cust">分析状态</td><td>${badge('st', 'analysis_status')} <span class="reason">(枚举:AI草稿 / FAE确认 / 待客户验证 / 客户已批准 / 不批准 / 作废)</span></td></tr>
|
||||||
<tr><td class="num"></td><td class="cust">客户批准状态 customer_approval_status</td><td>未提交 <span class="reason">(由审批流程维护,本活动不产生)</span></td></tr>
|
<tr><td class="num"></td><td class="cust">客户批准状态</td><td>未提交 <span class="reason">(由审批流程维护,本活动不产生)</span></td></tr>
|
||||||
<tr><td class="num"></td><td class="cust">风险等级 risk_level</td><td>${badge('risk', 'risk_level')}</td></tr>
|
<tr><td class="num"></td><td class="cust">风险等级</td><td>${badge('risk', 'risk_level')}</td></tr>
|
||||||
<tr><td class="num"></td><td class="cust">验证要求 verification_requirement</td><td class="mono" data-slot="verification_requirement" data-blank></td></tr>
|
<tr><td class="num"></td><td class="cust">验证要求</td><td class="mono" data-slot="verification_requirement" data-blank></td></tr>
|
||||||
<tr><td class="num"></td><td class="cust">证据可信度 confidence_score</td><td class="mono" data-slot="confidence_score" data-blank></td></tr>
|
<tr><td class="num"></td><td class="cust">证据可信度</td><td class="mono" data-slot="confidence_score" data-blank></td></tr>
|
||||||
<tr><td class="num"></td><td class="cust">差异规则版本 rule_version</td><td class="mono" data-slot="rule_version" data-blank></td></tr>
|
<tr><td class="num"></td><td class="cust">差异规则版本</td><td class="mono" data-slot="rule_version" data-blank></td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table></div>`;
|
</table></div>`;
|
||||||
|
|
||||||
const P01_FIELD_TABLE = `<div class="tbl-wrap"><table>
|
const P01_FIELD_TABLE = `<div class="tbl-wrap"><table>
|
||||||
<thead><tr><th style="width:40px">#</th><th style="width:180px" data-h="字段">字段</th><th data-h="拟写入值">拟写入值</th></tr></thead>
|
<thead><tr><th style="width:40px">#</th><th style="width:180px" data-h="字段">字段</th><th data-h="拟写入值">拟写入值</th></tr></thead>
|
||||||
<tbody data-rows="mine:demand.request">
|
<tbody data-rows="mine:demand.request">
|
||||||
<tr><td class="num"></td><td class="cust">风险等级 risk_level</td><td>${badge('risk', 'risk_level')}</td></tr>
|
<tr><td class="num"></td><td class="cust">风险等级</td><td>${badge('risk', 'risk_level')}</td></tr>
|
||||||
<tr><td class="num"></td><td class="cust">验证要求 verification_requirement</td><td class="mono" data-slot="verification_requirement" data-blank></td></tr>
|
<tr><td class="num"></td><td class="cust">验证要求</td><td class="mono" data-slot="verification_requirement" data-blank></td></tr>
|
||||||
<tr><td class="num"></td><td class="cust">输出链接 output_ref</td><td class="mono" data-slot="output_ref" data-blank></td></tr>
|
<tr><td class="num"></td><td class="cust">输出链接</td><td class="mono" data-slot="output_ref" data-blank></td></tr>
|
||||||
<tr><td class="num"></td><td class="cust">下一步动作 next_action</td><td data-slot="next_action" data-blank></td></tr>
|
<tr><td class="num"></td><td class="cust">下一步动作</td><td data-slot="next_action" data-blank></td></tr>
|
||||||
<tr><td class="num"></td><td class="cust">当前阶段 current_stage</td><td>${badge('st', 'current_stage')}</td></tr>
|
<tr><td class="num"></td><td class="cust">当前阶段</td><td>${badge('st', 'current_stage')}</td></tr>
|
||||||
<tr><td class="num"></td><td class="cust">需求状态 request_status</td><td>${badge('st', 'request_status')}</td></tr>
|
<tr><td class="num"></td><td class="cust">需求状态</td><td>${badge('st', 'request_status')}</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table></div>`;
|
</table></div>`;
|
||||||
|
|
||||||
|
|||||||
@@ -208,13 +208,22 @@ tr.ro td{background:var(--soft)}
|
|||||||
table.on td>.jc-vcell.ro{display:block}
|
table.on td>.jc-vcell.ro{display:block}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 两张验收件(ACC1 差异评估结论审核 / ACC2 评审放行与落账确认)的主题色**不再逐字节等于目标件**:
|
||||||
|
* 目标件的题头用 #b23a2e 红(--ledger),2026-08-27 用户定「中央 HTML 模版区域如果是验收工单,
|
||||||
|
* 标题部分的红色改为和验收卡一样的绿色」—— 与对话区验收卡同一枚 #0aa294(app.css --st-collab)。
|
||||||
|
* 只换主题变量与题头渐变 / 徽章投影;ACC2 的 CONDITIONAL 状态徽标是语义色,保留原红。
|
||||||
|
* ACC1 于 2026-08-27 下午随目标件改版整段重搬(「一个候选一张表」:.cand* / .rec* / .stat*,CONDITIONAL 改蓝),
|
||||||
|
* 主题色仍取卡的那一枚绿。
|
||||||
|
*/
|
||||||
export const ACC1_CSS = `
|
export const ACC1_CSS = `
|
||||||
:root{
|
:root{
|
||||||
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
|
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
|
||||||
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
|
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
|
||||||
--brand:#3370ff; --brand-deep:#245bdb; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
|
--brand:#3370ff; --brand-deep:#245bdb; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
|
||||||
--ledger:#b23a2e; --ledger-soft:#f7e4e0; --ledger-line:#ecc5bd;
|
--ledger:#0aa294; --ledger-soft:#e4f8f5; --ledger-line:#b5e6df; /* 验收绿(2026-08-27 用户定:与对话区验收卡同色;目标件 2026-08-27 改版写的是 #16a34a,这里取卡的那一枚) */
|
||||||
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
|
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
|
||||||
|
--amber:#8f6a12; --amber-soft:#fdf3d7; --amber-line:#ecd9a0;
|
||||||
--mono:"SF Mono",Menlo,Consolas,monospace;
|
--mono:"SF Mono",Menlo,Consolas,monospace;
|
||||||
}
|
}
|
||||||
*{box-sizing:border-box}
|
*{box-sizing:border-box}
|
||||||
@@ -222,8 +231,8 @@ html,body{margin:0;padding:0}
|
|||||||
body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#eef1f6;color:var(--ink);font-size:14px;line-height:1.7;-webkit-font-smoothing:antialiased}
|
body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#eef1f6;color:var(--ink);font-size:14px;line-height:1.7;-webkit-font-smoothing:antialiased}
|
||||||
.page{margin:0;padding:0}
|
.page{margin:0;padding:0}
|
||||||
.card{background:#fff;overflow:hidden;border:0;border-radius:0;box-shadow:none}
|
.card{background:#fff;overflow:hidden;border:0;border-radius:0;box-shadow:none}
|
||||||
.card-head{display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:2px solid var(--ink);background:linear-gradient(120deg,#fdf3f1 0%,#fff 55%,#fbf2f0 100%)}
|
.card-head{display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:2px solid var(--ink);background:linear-gradient(120deg,#eefaf8 0%,#fff 55%,#ecf8f5 100%)}
|
||||||
.badge{flex:none;width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--ledger);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(178,58,46,.25)}
|
.badge{flex:none;width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--ledger);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(10,162,148,.25)}
|
||||||
.tit{flex:1;min-width:0}
|
.tit{flex:1;min-width:0}
|
||||||
.tit .kick{font-size:11px;letter-spacing:.08em;color:var(--ink-4);font-weight:700}
|
.tit .kick{font-size:11px;letter-spacing:.08em;color:var(--ink-4);font-weight:700}
|
||||||
.tit h1{margin:2px 0 0;font-size:19px;font-weight:800;line-height:1.3}
|
.tit h1{margin:2px 0 0;font-size:19px;font-weight:800;line-height:1.3}
|
||||||
@@ -249,27 +258,43 @@ table{width:100%;border-collapse:collapse;font-size:13px}
|
|||||||
th{text-align:left;background:var(--soft);font-size:11px;letter-spacing:.05em;font-weight:800;color:var(--ink-3);padding:9px 12px;border-bottom:1px solid var(--line)}
|
th{text-align:left;background:var(--soft);font-size:11px;letter-spacing:.05em;font-weight:800;color:var(--ink-3);padding:9px 12px;border-bottom:1px solid var(--line)}
|
||||||
td{padding:11px 12px;border-bottom:1px solid var(--line-soft);vertical-align:top}
|
td{padding:11px 12px;border-bottom:1px solid var(--line-soft);vertical-align:top}
|
||||||
tr:last-child td{border-bottom:0}
|
tr:last-child td{border-bottom:0}
|
||||||
|
.cand .tbl-wrap{border:0;border-radius:0}
|
||||||
.num{font-family:var(--mono);color:var(--ink-4);white-space:nowrap}
|
.num{font-family:var(--mono);color:var(--ink-4);white-space:nowrap}
|
||||||
.cust{font-weight:700}
|
.cust{font-weight:700}
|
||||||
.prio{font-family:var(--mono);font-weight:800}
|
|
||||||
.prio.hi{color:var(--danger)}
|
|
||||||
.prio.mid{color:var(--ink-2)}
|
|
||||||
.reason{font-size:12.5px;color:var(--ink-2)}
|
.reason{font-size:12.5px;color:var(--ink-2)}
|
||||||
.flow{margin-top:16px;padding:9px 12px;border-radius:8px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
|
.flow{margin-top:16px;padding:9px 12px;border-radius:8px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
|
||||||
.flow b{color:var(--ink)}
|
.flow b{color:var(--ink)}
|
||||||
.card-foot{padding:13px 22px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
|
.card-foot{padding:13px 22px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
|
||||||
@media(max-width:600px){.card-head{flex-wrap:wrap}.side{align-items:flex-start}}
|
@media(max-width:600px){.card-head{flex-wrap:wrap}.side{align-items:flex-start}}
|
||||||
/* 补充:四态结论 pill 与风险等级(仅追加,不改动基件样式) */
|
/* 四态结论 pill 与风险等级 */
|
||||||
.st{display:inline-block;padding:1px 9px;border-radius:999px;font-size:11px;font-weight:800;letter-spacing:.04em;font-family:var(--mono);white-space:nowrap;border:1px solid transparent}
|
.st{display:inline-block;padding:1px 9px;border-radius:999px;font-size:11px;font-weight:800;letter-spacing:.04em;font-family:var(--mono);white-space:nowrap;border:1px solid transparent}
|
||||||
.st.ok{color:var(--ok);background:var(--ok-soft);border-color:#bfe6b4}
|
.st.ok{color:var(--ok);background:var(--ok-soft);border-color:#bfe6b4}
|
||||||
.st.miss{color:var(--danger);background:var(--danger-soft);border-color:#f3b9b5}
|
.st.miss{color:var(--danger);background:#feeae9;border-color:#f3b9b5}
|
||||||
.st.unknown{color:#8f6a12;background:#fdf3d7;border-color:#ecd9a0}
|
.st.unknown{color:var(--amber);background:var(--amber-soft);border-color:var(--amber-line)}
|
||||||
.st.cond{color:var(--ledger);background:var(--ledger-soft);border-color:var(--ledger-line)}
|
.st.cond{color:var(--brand-deep);background:var(--brand-wash);border-color:var(--brand-soft)}
|
||||||
.risk{font-weight:800;font-size:12.5px}
|
.risk{font-weight:800;font-size:12.5px}
|
||||||
.risk.low{color:var(--ok)}
|
.risk.low{color:var(--ok)}
|
||||||
.risk.mid{color:#8f6a12}
|
.risk.mid{color:var(--amber)}
|
||||||
.risk.hi{color:var(--danger)}
|
.risk.hi{color:var(--danger)}
|
||||||
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
|
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
|
||||||
|
/* 推荐程度 pill */
|
||||||
|
.rec{display:inline-block;padding:2px 11px;border-radius:999px;font-size:11.5px;font-weight:800;white-space:nowrap;border:1px solid transparent}
|
||||||
|
.rec.yes{color:var(--ok);background:var(--ok-soft);border-color:#bfe6b4}
|
||||||
|
.rec.cond{color:var(--amber);background:var(--amber-soft);border-color:var(--amber-line)}
|
||||||
|
.rec.no{color:var(--danger);background:#feeae9;border-color:#f3b9b5}
|
||||||
|
/* 差异统计(结论表内) */
|
||||||
|
.stat{font-family:var(--mono);font-size:11.5px;color:var(--ink-3);white-space:nowrap}
|
||||||
|
.stat b{font-weight:800}
|
||||||
|
.stat .m{color:var(--ok)} .stat .x{color:var(--danger)} .stat .u{color:var(--amber)} .stat .c{color:var(--brand-deep)}
|
||||||
|
/* 候选 PN 明细块:一个候选一张表 */
|
||||||
|
.cand{border:1px solid var(--line-soft);border-radius:10px;overflow:hidden;margin-bottom:16px}
|
||||||
|
.cand:last-of-type{margin-bottom:0}
|
||||||
|
.cand-head{display:flex;align-items:center;gap:10px;padding:11px 14px;background:linear-gradient(120deg,#f6f8fc 0%,#fff 70%);border-bottom:1px solid var(--line);flex-wrap:wrap}
|
||||||
|
.cand-idx{flex:none;width:22px;height:22px;border-radius:6px;display:grid;place-items:center;background:var(--ink);color:#fff;font-size:11px;font-weight:800;font-family:var(--mono)}
|
||||||
|
.cand-name{font-weight:800;font-size:14px}
|
||||||
|
.cand-brand{color:var(--ink-3);font-size:12px}
|
||||||
|
.cand-vs{font-family:var(--mono);font-size:11.5px;color:var(--ink-4)}
|
||||||
|
.cand-con{margin-left:auto;display:flex;align-items:center;gap:8px}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const ACC2_CSS = `
|
export const ACC2_CSS = `
|
||||||
@@ -277,7 +302,7 @@ export const ACC2_CSS = `
|
|||||||
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
|
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
|
||||||
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
|
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
|
||||||
--brand:#3370ff; --brand-deep:#245bdb; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
|
--brand:#3370ff; --brand-deep:#245bdb; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
|
||||||
--ledger:#b23a2e; --ledger-soft:#f7e4e0; --ledger-line:#ecc5bd;
|
--ledger:#0aa294; --ledger-soft:#e4f8f5; --ledger-line:#b5e6df; /* 验收绿(2026-08-27 用户定:与对话区验收卡同色,原目标件是 #b23a2e 红) */
|
||||||
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
|
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
|
||||||
--mono:"SF Mono",Menlo,Consolas,monospace;
|
--mono:"SF Mono",Menlo,Consolas,monospace;
|
||||||
}
|
}
|
||||||
@@ -286,8 +311,8 @@ html,body{margin:0;padding:0}
|
|||||||
body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#eef1f6;color:var(--ink);font-size:14px;line-height:1.7;-webkit-font-smoothing:antialiased}
|
body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#eef1f6;color:var(--ink);font-size:14px;line-height:1.7;-webkit-font-smoothing:antialiased}
|
||||||
.page{margin:0;padding:0}
|
.page{margin:0;padding:0}
|
||||||
.card{background:#fff;overflow:hidden;border:0;border-radius:0;box-shadow:none}
|
.card{background:#fff;overflow:hidden;border:0;border-radius:0;box-shadow:none}
|
||||||
.card-head{display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:2px solid var(--ink);background:linear-gradient(120deg,#fdf3f1 0%,#fff 55%,#fbf2f0 100%)}
|
.card-head{display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:2px solid var(--ink);background:linear-gradient(120deg,#eefaf8 0%,#fff 55%,#ecf8f5 100%)}
|
||||||
.badge{flex:none;width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--ledger);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(178,58,46,.25)}
|
.badge{flex:none;width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--ledger);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(10,162,148,.25)}
|
||||||
.tit{flex:1;min-width:0}
|
.tit{flex:1;min-width:0}
|
||||||
.tit .kick{font-size:11px;letter-spacing:.08em;color:var(--ink-4);font-weight:700}
|
.tit .kick{font-size:11px;letter-spacing:.08em;color:var(--ink-4);font-weight:700}
|
||||||
.tit h1{margin:2px 0 0;font-size:19px;font-weight:800;line-height:1.3}
|
.tit h1{margin:2px 0 0;font-size:19px;font-weight:800;line-height:1.3}
|
||||||
@@ -328,7 +353,7 @@ tr:last-child td{border-bottom:0}
|
|||||||
.st.ok{color:var(--ok);background:var(--ok-soft);border-color:#bfe6b4}
|
.st.ok{color:var(--ok);background:var(--ok-soft);border-color:#bfe6b4}
|
||||||
.st.miss{color:var(--danger);background:var(--danger-soft);border-color:#f3b9b5}
|
.st.miss{color:var(--danger);background:var(--danger-soft);border-color:#f3b9b5}
|
||||||
.st.unknown{color:#8f6a12;background:#fdf3d7;border-color:#ecd9a0}
|
.st.unknown{color:#8f6a12;background:#fdf3d7;border-color:#ecd9a0}
|
||||||
.st.cond{color:var(--ledger);background:var(--ledger-soft);border-color:var(--ledger-line)}
|
.st.cond{color:#b23a2e;background:#f7e4e0;border-color:#ecc5bd}
|
||||||
.risk{font-weight:800;font-size:12.5px}
|
.risk{font-weight:800;font-size:12.5px}
|
||||||
.risk.mid{color:#8f6a12}
|
.risk.mid{color:#8f6a12}
|
||||||
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
|
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
|
||||||
|
|||||||
@@ -117,6 +117,15 @@ export type HumanToolService = {
|
|||||||
/** 全部工单(含已办结)—— 工作成果要回看定稿的报告,只给 open 就永远是空的 */
|
/** 全部工单(含已办结)—— 工作成果要回看定稿的报告,只给 open 就永远是空的 */
|
||||||
allWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
|
allWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
|
||||||
chatCards(taskId: TaskId): Promise<ChatCard[]>;
|
chatCards(taskId: TaskId): Promise<ChatCard[]>;
|
||||||
|
/**
|
||||||
|
* **这一单正等人交回的卡**(2026-08-27):没办结的工单 + 没作答、没被顶替的对话卡。
|
||||||
|
* 引擎每拍开头问它 —— 有卡还没答,这一拍就不该问模型(叫了人就等人,不许自己往下推)。
|
||||||
|
*/
|
||||||
|
pendingCards(taskId: TaskId): Promise<Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
kind: 'work-order' | 'chat-card';
|
||||||
|
}>>;
|
||||||
/** org 停用前置检查要问的「待答卡」 */
|
/** org 停用前置检查要问的「待答卡」 */
|
||||||
openCardsOf(staffId: StaffId): Promise<Array<{
|
openCardsOf(staffId: StaffId): Promise<Array<{
|
||||||
cardId: string;
|
cardId: string;
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export type CardStore = {
|
|||||||
recipientId: StaffId;
|
recipientId: StaffId;
|
||||||
} | undefined>;
|
} | undefined>;
|
||||||
listChatCards(taskId: TaskId): Promise<ChatCard[]>;
|
listChatCards(taskId: TaskId): Promise<ChatCard[]>;
|
||||||
|
/** 还开着的对话卡(没被新卡顶替、没作废)—— 「这一单有没有卡在等人」问的是它,不是全部历史 */
|
||||||
|
listOpenChatCards(taskId: TaskId): Promise<ChatCard[]>;
|
||||||
updateChatCard(card: ChatCard): Promise<void>;
|
updateChatCard(card: ChatCard): Promise<void>;
|
||||||
closeChatCard(id: string, supersededBy: string, reason: string): Promise<void>;
|
closeChatCard(id: string, supersededBy: string, reason: string): Promise<void>;
|
||||||
/** 调人次数 —— 打扰率与预算的计数面 */
|
/** 调人次数 —— 打扰率与预算的计数面 */
|
||||||
|
|||||||
+31
-3
@@ -48,9 +48,29 @@ export type DraftWrite = {
|
|||||||
readonly patch: LedgerRow;
|
readonly patch: LedgerRow;
|
||||||
readonly tombstone?: boolean;
|
readonly tombstone?: boolean;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* **放行写的范围**(2026-08-27,数据工单确认即入库)。
|
||||||
|
*
|
||||||
|
* 不给 = 整单放行:草稿层全部并进正式表、清空。
|
||||||
|
* 给了 = 只并这几张表 —— 它们的门不是放行卡,是**数据工单的确认**(那张卡的提交闸
|
||||||
|
* 盯的就是这几张表:必填齐了才按得下确认,确认那一刻就入库)。并过的行**留在草稿层、
|
||||||
|
* 打上已落地记号**:它仍是本单写下的行(任务视图 / `mine:` / `_rows` 照旧认它),
|
||||||
|
* 只是整单放行时不再写它、不再为它抛事件。
|
||||||
|
* `cause` 落进数据事件的归因(`ledger-write:<cause>`),回看时认得出是哪张卡签下去的。
|
||||||
|
*/
|
||||||
|
export type ReleaseScope = {
|
||||||
|
readonly only: readonly ObjectAddress[];
|
||||||
|
readonly cause: string;
|
||||||
|
};
|
||||||
export type ReleaseWriteResult = {
|
export type ReleaseWriteResult = {
|
||||||
readonly mergedRows: number;
|
readonly mergedRows: number;
|
||||||
readonly emittedEvents: number;
|
readonly emittedEvents: number;
|
||||||
|
/**
|
||||||
|
* **早已落地、这次不再写的行数**(2026-08-27)—— 数据工单确认时并进去的那几行,
|
||||||
|
* 整单放行时只把它们当事实读回来(出口载荷要它们),不再写、不再抛事件。
|
||||||
|
* 放行痕上要分开说:「合并 N 行」里若混着这几行,那句话就多报了。
|
||||||
|
*/
|
||||||
|
readonly landedEarlierRows: number;
|
||||||
/**
|
/**
|
||||||
* **试跑单被挡下的主数据行数** —— 合到出参里去,是因为界面不撒谎:
|
* **试跑单被挡下的主数据行数** —— 合到出参里去,是因为界面不撒谎:
|
||||||
* 放行痕上写「合并 N 行」而其中几行根本没进表,那句话就是假的。
|
* 放行痕上写「合并 N 行」而其中几行根本没进表,那句话就是假的。
|
||||||
@@ -100,7 +120,13 @@ export type LedgerService = {
|
|||||||
address: ObjectAddress;
|
address: ObjectAddress;
|
||||||
key: Record<string, unknown>;
|
key: Record<string, unknown>;
|
||||||
}): Promise<boolean>;
|
}): Promise<boolean>;
|
||||||
releaseWrite(taskId: TaskId): Promise<ReleaseWriteResult>;
|
/**
|
||||||
|
* 放行写。`scope` 见 `ReleaseScope`:不给 = 整单;给了 = 只并那几张表(数据工单确认那一道门),
|
||||||
|
* 并过的行留在草稿层打记号。**不是第四种写**,也不是绕门:同一条冲突检查 → 原子合并 → 抛事件的路,
|
||||||
|
* 只是门换成了那张卡的确认闸 —— 落哪几张表由发卡的人说了算(`gateLedgers`),调用方指不了别的表进来
|
||||||
|
* (清单没声明的表这一单本就写不进草稿层)。
|
||||||
|
*/
|
||||||
|
releaseWrite(taskId: TaskId, scope?: ReleaseScope): Promise<ReleaseWriteResult>;
|
||||||
/** 正式版查询 —— 跨任务只见过门的事实 */
|
/** 正式版查询 —— 跨任务只见过门的事实 */
|
||||||
query(query: LedgerQuery): Promise<LedgerPage>;
|
query(query: LedgerQuery): Promise<LedgerPage>;
|
||||||
/** 任务视图 = 正式版 + 本任务草稿层 */
|
/** 任务视图 = 正式版 + 本任务草稿层 */
|
||||||
@@ -115,8 +141,10 @@ export type LedgerService = {
|
|||||||
object: string;
|
object: string;
|
||||||
key: Record<string, unknown>;
|
key: Record<string, unknown>;
|
||||||
patch: Record<string, unknown>;
|
patch: Record<string, unknown>;
|
||||||
|
/** 已在数据工单确认那一道门上并进正式表(见 `ReleaseScope`);仍是本单写下的行 */
|
||||||
|
landed: boolean;
|
||||||
}>>;
|
}>>;
|
||||||
/** 落地预检第五项:数据冲突检查 */
|
/** 落地预检第五项:数据冲突检查;`only` 给了就只查那几张表(与 `releaseWrite` 的 scope 同一口径) */
|
||||||
checkConflicts(taskId: TaskId): Promise<ConflictFinding[]>;
|
checkConflicts(taskId: TaskId, only?: readonly ObjectAddress[]): Promise<ConflictFinding[]>;
|
||||||
discardDrafts(taskId: TaskId): Promise<void>;
|
discardDrafts(taskId: TaskId): Promise<void>;
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* module: ledger —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
* module: ledger —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||||
*/
|
*/
|
||||||
export type { ConflictFinding, DraftWrite, LedgerAddress, LedgerKey, LedgerPage, LedgerQuery, LedgerRow, LedgerService, MaterializedObject, MergeConflict, MergeNotice, MergedObject, ObjectAddress, ObjectDeclaration, ReleaseWriteResult, SchemaApplyResult, SystemWrite, WriteKind, } from './contract.ts';
|
export type { ConflictFinding, DraftWrite, LedgerAddress, LedgerKey, LedgerPage, LedgerQuery, LedgerRow, LedgerService, MaterializedObject, MergeConflict, MergeNotice, MergedObject, ObjectAddress, ObjectDeclaration, ReleaseScope, ReleaseWriteResult, SchemaApplyResult, SystemWrite, WriteKind, } from './contract.ts';
|
||||||
export { DraftConflictError, ObjectNotMaterializedError, SchemaConflictError } from './contract.ts';
|
export { DraftConflictError, ObjectNotMaterializedError, SchemaConflictError } from './contract.ts';
|
||||||
export type { Clock, DraftStore, EventPublisher, IdGenerator, LedgerStore, ObjectRef } from './port.ts';
|
export type { Clock, DraftStore, EventPublisher, IdGenerator, LedgerStore, ObjectRef } from './port.ts';
|
||||||
export type { LedgerServiceDeps } from './internal/usecase/ledger-service.ts';
|
export type { LedgerServiceDeps } from './internal/usecase/ledger-service.ts';
|
||||||
|
|||||||
@@ -77,5 +77,14 @@ export type DraftStore = {
|
|||||||
* (唯一能改的是那个数,改了别的判据又不对),13 次拒回、23 拍、234k token 才熔断。
|
* (唯一能改的是那个数,改了别的判据又不对),13 次拒回、23 拍、234k token 才熔断。
|
||||||
*/
|
*/
|
||||||
drop(taskId: string, ref: ObjectRef, key: LedgerKey): Promise<void>;
|
drop(taskId: string, ref: ObjectRef, key: LedgerKey): Promise<void>;
|
||||||
|
/**
|
||||||
|
* 给本单草稿层的一行打上「已落地」记号(2026-08-27,数据工单确认即入库)。
|
||||||
|
*
|
||||||
|
* 那一行刚经一道更早的门并进了正式表:条目**留着**(它仍是本单写下的行,
|
||||||
|
* 任务视图 / `mine:` / `_rows` 照旧认它),只是放行时不再写它。
|
||||||
|
* `baseline` 换成落地后正式表里的值 —— 之后再往这一行写一笔(记号随之擦掉),
|
||||||
|
* 冲突检查比的才是「我改之前表里是什么」,而不是落地前那份早已过时的基线。
|
||||||
|
*/
|
||||||
|
markLanded(taskId: string, ref: ObjectRef, key: LedgerKey, baseline: LedgerRow): Promise<void>;
|
||||||
clear(taskId: string): Promise<void>;
|
clear(taskId: string): Promise<void>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -207,6 +207,8 @@ export type CardGateway = {
|
|||||||
ownerId: StaffId;
|
ownerId: StaffId;
|
||||||
humanCallCap: number;
|
humanCallCap: number;
|
||||||
workOrder: 'execution' | 'acceptance';
|
workOrder: 'execution' | 'acceptance';
|
||||||
|
/** 这张卡挂在哪个计划项上 —— 引擎按它查剧本声明(如 `cardVariant`),不靠模型抄卡骨架;引擎自己发的卡(验收/兜底)没有项 */
|
||||||
|
itemId?: string | undefined;
|
||||||
card: Record<string, unknown>;
|
card: Record<string, unknown>;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -258,9 +260,28 @@ export type CardGateway = {
|
|||||||
v: string;
|
v: string;
|
||||||
}>;
|
}>;
|
||||||
allowCustom?: boolean;
|
allowCustom?: boolean;
|
||||||
|
/** 这张卡挂在哪个计划项上 —— 组装层按它查剧本声明(`cardVariant: 'review'` = 摆成验收工单) */
|
||||||
|
itemId?: string | undefined;
|
||||||
|
/**
|
||||||
|
* **这张卡穿哪副面孔,引擎已经按剧本定好**(2026-08-27):模型没给 itemId、或给的项挂不上时,
|
||||||
|
* 组装层按 itemId 查剧本就查不到,卡就退成「需要你定」—— 而这一步的剧本明明写着 `cardVariant: 'review'`。
|
||||||
|
* 引擎知道当下在办哪一步,由它把面孔定下来递过去;组装层只在这里没给时才自己按 itemId 查。
|
||||||
|
*/
|
||||||
|
variant?: 'review' | undefined;
|
||||||
|
/** 人工验收清单(剧本 `acceptanceChecklist`)—— 审核卡上一条一枚「达标 / 不达标」开关 */
|
||||||
|
checklist?: ReadonlyArray<string> | undefined;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
}>;
|
}>;
|
||||||
|
/**
|
||||||
|
* **这一单还有哪几张卡在等人交回**(2026-08-27,用户:「你叫了人我都没填你就直接推进了」)。
|
||||||
|
* 每拍开头问一次:有卡没答,这一拍不问模型 —— 叫了人就等人;
|
||||||
|
* 任何多余的唤醒(迟到的回传事件、另一台后端的推进器)都推不动它。
|
||||||
|
*/
|
||||||
|
unanswered(taskId: TaskId): Promise<ReadonlyArray<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
}>>;
|
||||||
};
|
};
|
||||||
export type ListenerRegistrar = {
|
export type ListenerRegistrar = {
|
||||||
register(input: {
|
register(input: {
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export type SubmitForReleaseInput = {
|
|||||||
}>;
|
}>;
|
||||||
readonly deliverables: readonly DeliverableCheck[];
|
readonly deliverables: readonly DeliverableCheck[];
|
||||||
readonly criteria: readonly CriterionCheck[];
|
readonly criteria: readonly CriterionCheck[];
|
||||||
|
/** 人工验收清单(剧本声明,固定):卡上一条一枚开关,全部达标才能通过 */
|
||||||
|
readonly checklist?: ReadonlyArray<string>;
|
||||||
/** 报告可修订:AI 拟值已填的表单字段 */
|
/** 报告可修订:AI 拟值已填的表单字段 */
|
||||||
readonly fields?: ReadonlyArray<Record<string, unknown>>;
|
readonly fields?: ReadonlyArray<Record<string, unknown>>;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export type LedgerGateway = {
|
|||||||
emittedEvents: number;
|
emittedEvents: number;
|
||||||
/** 试跑单被挡在正式表外的主数据行数(非试跑恒为 0)—— 放行痕要如实说 */
|
/** 试跑单被挡在正式表外的主数据行数(非试跑恒为 0)—— 放行痕要如实说 */
|
||||||
simulatedRows: number;
|
simulatedRows: number;
|
||||||
|
/** 早已在数据工单确认时入库、这次不再写的行数 —— 放行痕上分开说,「合并 N 行」不多报 */
|
||||||
|
landedEarlierRows: number;
|
||||||
/** 合并进正式版的行 —— 输出口发射的载荷取自这里 */
|
/** 合并进正式版的行 —— 输出口发射的载荷取自这里 */
|
||||||
merged: ReadonlyArray<{
|
merged: ReadonlyArray<{
|
||||||
domain: string;
|
domain: string;
|
||||||
@@ -89,6 +91,8 @@ export type CardGateway = {
|
|||||||
effect: 'read' | 'write';
|
effect: 'read' | 'write';
|
||||||
detail: string;
|
detail: string;
|
||||||
}>;
|
}>;
|
||||||
|
/** 人工验收清单 —— 原样带上卡 */
|
||||||
|
checklist?: ReadonlyArray<string> | undefined;
|
||||||
/**
|
/**
|
||||||
* 可修订字段 —— 验收报告「AI 已填好、可编辑」改的就是它们(带 `ai` 拟值与 `ledger` 落回地址)。
|
* 可修订字段 —— 验收报告「AI 已填好、可编辑」改的就是它们(带 `ai` 拟值与 `ledger` 落回地址)。
|
||||||
* **端口不声明它,值就在装配层被丢掉**:上游一路传下来,而 TypeScript 对条件展开
|
* **端口不声明它,值就在装配层被丢掉**:上游一路传下来,而 TypeScript 对条件展开
|
||||||
|
|||||||
@@ -77,10 +77,15 @@ export type TaskService = {
|
|||||||
/** 推进一拍:**预算在环外先扣先判**,再交范式引擎 */
|
/** 推进一拍:**预算在环外先扣先判**,再交范式引擎 */
|
||||||
tick(taskId: TaskId): Promise<TickOutcome>;
|
tick(taskId: TaskId): Promise<TickOutcome>;
|
||||||
/** 事件驱动续跑 —— 挂起期间事件不丢,恢复即补投 */
|
/** 事件驱动续跑 —— 挂起期间事件不丢,恢复即补投 */
|
||||||
|
/**
|
||||||
|
* 投递一条事件并推一拍。`occurredAt` = 事件发生时刻(信封的 occurredAt):人事件要拿它与任务的
|
||||||
|
* 最近推进时刻比 —— 回传那一刻已经 wake、推进器抢先跑了那一拍的,这条事件只是迟到的回声,不再推。
|
||||||
|
*/
|
||||||
deliver(taskId: TaskId, event: {
|
deliver(taskId: TaskId, event: {
|
||||||
kind: 'data' | 'time' | 'human' | 'control';
|
kind: 'data' | 'time' | 'human' | 'control';
|
||||||
ref: string;
|
ref: string;
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
|
occurredAt?: string;
|
||||||
}): Promise<TickOutcome>;
|
}): Promise<TickOutcome>;
|
||||||
/** 控制三语义:暂停 / 叫停 / 终止 */
|
/** 控制三语义:暂停 / 叫停 / 终止 */
|
||||||
control(taskId: TaskId, semantic: ControlSemantic, by: StaffId, reason?: string): Promise<ControlOutcome>;
|
control(taskId: TaskId, semantic: ControlSemantic, by: StaffId, reason?: string): Promise<ControlOutcome>;
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
+353
@@ -0,0 +1,353 @@
|
|||||||
|
/**
|
||||||
|
* 运营中心 · 活动模块的契约(L1 活动清单 / L2 活动详情 / L3 实例详情;`docs/API文档-活动模块.md`)。
|
||||||
|
*
|
||||||
|
* 只有 zod schema 与推导类型。两个概念(2026-08-25 用户定):
|
||||||
|
* · **活动** = 装进来的活动包在运营面上的投影 —— 名称 / 分类 / 版本 / 模型来自清单,
|
||||||
|
* 负责人 / 说明 / 图标 / 色相来自运营侧档案(`sys.activity_profile`,活动包本身不载这些);
|
||||||
|
* · **实例** = 这个活动的一次运行(一张 task)—— 五态是对 task 运行态 / 终态的**如实映射**,
|
||||||
|
* 不是第六套状态机(见 `INSTANCE_STATE_LABEL` 旁的口径)。
|
||||||
|
*/
|
||||||
|
import { z } from 'zod';
|
||||||
|
/** 运营态:激活中 / 已禁用 —— 禁用只拦「新启动」,已完成与在途实例都不受影响(2026-08-27 用户定) */
|
||||||
|
export declare const ACTIVITY_STATUSES: readonly ["active", "disabled"];
|
||||||
|
export declare const activityStatusSchema: z.ZodEnum<{
|
||||||
|
active: "active";
|
||||||
|
disabled: "disabled";
|
||||||
|
}>;
|
||||||
|
export type ActivityStatus = z.infer<typeof activityStatusSchema>;
|
||||||
|
export declare const ACTIVITY_STATUS_LABEL: Readonly<Record<ActivityStatus, string>>;
|
||||||
|
/** 业务色相 —— 一卡一色;缺省由前端兜底(`blue`) */
|
||||||
|
export declare const ACTIVITY_HUES: readonly ["blue", "teal", "amber", "violet"];
|
||||||
|
export declare const activityHueSchema: z.ZodEnum<{
|
||||||
|
blue: "blue";
|
||||||
|
teal: "teal";
|
||||||
|
amber: "amber";
|
||||||
|
violet: "violet";
|
||||||
|
}>;
|
||||||
|
export type ActivityHue = z.infer<typeof activityHueSchema>;
|
||||||
|
/** 凡出人都是两段:展示用 name,筛选 / 提交用 id */
|
||||||
|
export declare const staffRefSchema: z.ZodObject<{
|
||||||
|
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
|
name: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type StaffRef = z.infer<typeof staffRefSchema>;
|
||||||
|
export declare const activitySummarySchema: z.ZodObject<{
|
||||||
|
id: z.ZodString;
|
||||||
|
name: z.ZodString;
|
||||||
|
biz: z.ZodString;
|
||||||
|
status: z.ZodEnum<{
|
||||||
|
active: "active";
|
||||||
|
disabled: "disabled";
|
||||||
|
}>;
|
||||||
|
version: z.ZodString;
|
||||||
|
owner: z.ZodNullable<z.ZodObject<{
|
||||||
|
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
|
name: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
llm: z.ZodString;
|
||||||
|
instanceCount: z.ZodNumber;
|
||||||
|
createdAt: z.ZodString;
|
||||||
|
ico: z.ZodOptional<z.ZodString>;
|
||||||
|
hue: z.ZodOptional<z.ZodEnum<{
|
||||||
|
blue: "blue";
|
||||||
|
teal: "teal";
|
||||||
|
amber: "amber";
|
||||||
|
violet: "violet";
|
||||||
|
}>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type ActivitySummary = z.infer<typeof activitySummarySchema>;
|
||||||
|
export declare const activityDetailSchema: z.ZodObject<{
|
||||||
|
name: z.ZodString;
|
||||||
|
id: z.ZodString;
|
||||||
|
version: z.ZodString;
|
||||||
|
status: z.ZodEnum<{
|
||||||
|
active: "active";
|
||||||
|
disabled: "disabled";
|
||||||
|
}>;
|
||||||
|
createdAt: z.ZodString;
|
||||||
|
biz: z.ZodString;
|
||||||
|
owner: z.ZodNullable<z.ZodObject<{
|
||||||
|
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
|
name: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
llm: z.ZodString;
|
||||||
|
ico: z.ZodOptional<z.ZodString>;
|
||||||
|
hue: z.ZodOptional<z.ZodEnum<{
|
||||||
|
blue: "blue";
|
||||||
|
teal: "teal";
|
||||||
|
amber: "amber";
|
||||||
|
violet: "violet";
|
||||||
|
}>>;
|
||||||
|
desc: z.ZodString;
|
||||||
|
view: z.ZodNullable<z.ZodObject<{
|
||||||
|
url: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type ActivityDetail = z.infer<typeof activityDetailSchema>;
|
||||||
|
/**
|
||||||
|
* 沙箱视图向父页报高的消息类型(postMessage 载荷 `{ type, height }`)。
|
||||||
|
* 视图模版本身**无脚本**(安装静态审拦着);报高脚本由服务端在出口那一刻注入。
|
||||||
|
*/
|
||||||
|
export declare const ACTIVITY_VIEW_HEIGHT_MESSAGE = "jcai:view-height";
|
||||||
|
/**
|
||||||
|
* 实例五态(2026-08-25 用户定),对 task 的如实映射:
|
||||||
|
* pending 已建单、推进器还没领到(`used.steps = 0`)
|
||||||
|
* running 在途(含等人回传 / 监听中 / 挂起 / 落地受阻)
|
||||||
|
* completed 终态 released
|
||||||
|
* rejected 验收被负责人打回、模型还没重新提交的那一段(重新提交后回 running)
|
||||||
|
* failed 终态 blocked / stalled / exhausted,以及手动终止(靠 `reason.kind='terminated'` 区分)
|
||||||
|
*/
|
||||||
|
export declare const INSTANCE_STATES: readonly ["pending", "running", "completed", "rejected", "failed"];
|
||||||
|
export declare const instanceStateSchema: z.ZodEnum<{
|
||||||
|
running: "running";
|
||||||
|
completed: "completed";
|
||||||
|
pending: "pending";
|
||||||
|
failed: "failed";
|
||||||
|
rejected: "rejected";
|
||||||
|
}>;
|
||||||
|
export type InstanceState = z.infer<typeof instanceStateSchema>;
|
||||||
|
export declare const INSTANCE_STATE_LABEL: Readonly<Record<InstanceState, string>>;
|
||||||
|
export declare const instanceSummarySchema: z.ZodObject<{
|
||||||
|
id: z.ZodString;
|
||||||
|
no: z.ZodString;
|
||||||
|
state: z.ZodEnum<{
|
||||||
|
running: "running";
|
||||||
|
completed: "completed";
|
||||||
|
pending: "pending";
|
||||||
|
failed: "failed";
|
||||||
|
rejected: "rejected";
|
||||||
|
}>;
|
||||||
|
owner: z.ZodObject<{
|
||||||
|
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
|
name: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
input: z.ZodString;
|
||||||
|
output: z.ZodNullable<z.ZodString>;
|
||||||
|
startedAt: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type InstanceSummary = z.infer<typeof instanceSummarySchema>;
|
||||||
|
export declare const instanceStatsSchema: z.ZodObject<{
|
||||||
|
total: z.ZodNumber;
|
||||||
|
byState: z.ZodRecord<z.ZodEnum<{
|
||||||
|
running: "running";
|
||||||
|
completed: "completed";
|
||||||
|
pending: "pending";
|
||||||
|
failed: "failed";
|
||||||
|
rejected: "rejected";
|
||||||
|
}>, z.ZodNumber>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type InstanceStats = z.infer<typeof instanceStatsSchema>;
|
||||||
|
export declare const instanceListSchema: z.ZodObject<{
|
||||||
|
stats: z.ZodObject<{
|
||||||
|
total: z.ZodNumber;
|
||||||
|
byState: z.ZodRecord<z.ZodEnum<{
|
||||||
|
running: "running";
|
||||||
|
completed: "completed";
|
||||||
|
pending: "pending";
|
||||||
|
failed: "failed";
|
||||||
|
rejected: "rejected";
|
||||||
|
}>, z.ZodNumber>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
total: z.ZodNumber;
|
||||||
|
page: z.ZodNumber;
|
||||||
|
size: z.ZodNumber;
|
||||||
|
items: z.ZodArray<z.ZodObject<{
|
||||||
|
id: z.ZodString;
|
||||||
|
no: z.ZodString;
|
||||||
|
state: z.ZodEnum<{
|
||||||
|
running: "running";
|
||||||
|
completed: "completed";
|
||||||
|
pending: "pending";
|
||||||
|
failed: "failed";
|
||||||
|
rejected: "rejected";
|
||||||
|
}>;
|
||||||
|
owner: z.ZodObject<{
|
||||||
|
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
|
name: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
input: z.ZodString;
|
||||||
|
output: z.ZodNullable<z.ZodString>;
|
||||||
|
startedAt: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type InstanceList = z.infer<typeof instanceListSchema>;
|
||||||
|
export declare const instanceFileSchema: z.ZodObject<{
|
||||||
|
index: z.ZodNumber;
|
||||||
|
name: z.ZodString;
|
||||||
|
contentType: z.ZodString;
|
||||||
|
size: z.ZodNumber;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type InstanceFile = z.infer<typeof instanceFileSchema>;
|
||||||
|
export declare const INSTANCE_REASON_KINDS: readonly ["rejected", "failed", "terminated"];
|
||||||
|
export declare const instanceReasonSchema: z.ZodObject<{
|
||||||
|
kind: z.ZodEnum<{
|
||||||
|
terminated: "terminated";
|
||||||
|
failed: "failed";
|
||||||
|
rejected: "rejected";
|
||||||
|
}>;
|
||||||
|
text: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type InstanceReason = z.infer<typeof instanceReasonSchema>;
|
||||||
|
export declare const STEP_RESULTS: readonly ["done", "fail", "current", "pending"];
|
||||||
|
export declare const instanceStepSchema: z.ZodObject<{
|
||||||
|
seq: z.ZodNumber;
|
||||||
|
name: z.ZodString;
|
||||||
|
by: z.ZodEnum<{
|
||||||
|
human: "human";
|
||||||
|
agent: "agent";
|
||||||
|
}>;
|
||||||
|
byName: z.ZodString;
|
||||||
|
at: z.ZodNullable<z.ZodString>;
|
||||||
|
durationMs: z.ZodNullable<z.ZodNumber>;
|
||||||
|
res: z.ZodEnum<{
|
||||||
|
pending: "pending";
|
||||||
|
done: "done";
|
||||||
|
fail: "fail";
|
||||||
|
current: "current";
|
||||||
|
}>;
|
||||||
|
note: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type InstanceStep = z.infer<typeof instanceStepSchema>;
|
||||||
|
/** 起单来源:manual(运营端手动启动)/ upstream(工作流节点)/ mail 等通道名 / testbed(测试台试跑)/ system */
|
||||||
|
export declare const instanceSourceSchema: z.ZodObject<{
|
||||||
|
kind: z.ZodString;
|
||||||
|
label: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type InstanceSource = z.infer<typeof instanceSourceSchema>;
|
||||||
|
export declare const instanceDetailSchema: z.ZodObject<{
|
||||||
|
id: z.ZodString;
|
||||||
|
state: z.ZodEnum<{
|
||||||
|
running: "running";
|
||||||
|
completed: "completed";
|
||||||
|
pending: "pending";
|
||||||
|
failed: "failed";
|
||||||
|
rejected: "rejected";
|
||||||
|
}>;
|
||||||
|
startedAt: z.ZodString;
|
||||||
|
owner: z.ZodObject<{
|
||||||
|
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
|
name: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
no: z.ZodString;
|
||||||
|
activityId: z.ZodString;
|
||||||
|
llm: z.ZodString;
|
||||||
|
endedAt: z.ZodNullable<z.ZodString>;
|
||||||
|
durationMs: z.ZodNullable<z.ZodNumber>;
|
||||||
|
reason: z.ZodNullable<z.ZodObject<{
|
||||||
|
kind: z.ZodEnum<{
|
||||||
|
terminated: "terminated";
|
||||||
|
failed: "failed";
|
||||||
|
rejected: "rejected";
|
||||||
|
}>;
|
||||||
|
text: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
input: z.ZodObject<{
|
||||||
|
content: z.ZodString;
|
||||||
|
source: z.ZodObject<{
|
||||||
|
kind: z.ZodString;
|
||||||
|
label: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
submittedAt: z.ZodString;
|
||||||
|
files: z.ZodArray<z.ZodObject<{
|
||||||
|
index: z.ZodNumber;
|
||||||
|
name: z.ZodString;
|
||||||
|
contentType: z.ZodString;
|
||||||
|
size: z.ZodNumber;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
output: z.ZodObject<{
|
||||||
|
files: z.ZodArray<z.ZodObject<{
|
||||||
|
index: z.ZodNumber;
|
||||||
|
name: z.ZodString;
|
||||||
|
contentType: z.ZodString;
|
||||||
|
size: z.ZodNumber;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
steps: z.ZodArray<z.ZodObject<{
|
||||||
|
seq: z.ZodNumber;
|
||||||
|
name: z.ZodString;
|
||||||
|
by: z.ZodEnum<{
|
||||||
|
human: "human";
|
||||||
|
agent: "agent";
|
||||||
|
}>;
|
||||||
|
byName: z.ZodString;
|
||||||
|
at: z.ZodNullable<z.ZodString>;
|
||||||
|
durationMs: z.ZodNullable<z.ZodNumber>;
|
||||||
|
res: z.ZodEnum<{
|
||||||
|
pending: "pending";
|
||||||
|
done: "done";
|
||||||
|
fail: "fail";
|
||||||
|
current: "current";
|
||||||
|
}>;
|
||||||
|
note: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type InstanceDetail = z.infer<typeof instanceDetailSchema>;
|
||||||
|
/**
|
||||||
|
* 控件种类:text 单行 / textarea 多行 / select 单选(只能选候选)/ combo 选择 + 新增 / list 字符串数组。
|
||||||
|
* 新增种类前后端一起升级;前端遇到不认识的 type 摆「暂不支持的控件」。
|
||||||
|
*/
|
||||||
|
export declare const LAUNCH_PARAM_TYPES: readonly ["text", "textarea", "select", "combo", "list"];
|
||||||
|
export declare const launchParamTypeSchema: z.ZodEnum<{
|
||||||
|
text: "text";
|
||||||
|
textarea: "textarea";
|
||||||
|
select: "select";
|
||||||
|
combo: "combo";
|
||||||
|
list: "list";
|
||||||
|
}>;
|
||||||
|
export type LaunchParamType = z.infer<typeof launchParamTypeSchema>;
|
||||||
|
export declare const launchOptionSchema: z.ZodObject<{
|
||||||
|
value: z.ZodString;
|
||||||
|
label: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type LaunchOption = z.infer<typeof launchOptionSchema>;
|
||||||
|
export declare const launchParamSchema: z.ZodObject<{
|
||||||
|
key: z.ZodString;
|
||||||
|
label: z.ZodString;
|
||||||
|
type: z.ZodEnum<{
|
||||||
|
text: "text";
|
||||||
|
textarea: "textarea";
|
||||||
|
select: "select";
|
||||||
|
combo: "combo";
|
||||||
|
list: "list";
|
||||||
|
}>;
|
||||||
|
required: z.ZodOptional<z.ZodBoolean>;
|
||||||
|
desc: z.ZodOptional<z.ZodString>;
|
||||||
|
placeholder: z.ZodOptional<z.ZodString>;
|
||||||
|
options: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
|
value: z.ZodString;
|
||||||
|
label: z.ZodString;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type LaunchParam = z.infer<typeof launchParamSchema>;
|
||||||
|
export declare const launchParamsSchema: z.ZodObject<{
|
||||||
|
activityId: z.ZodString;
|
||||||
|
version: z.ZodString;
|
||||||
|
params: z.ZodArray<z.ZodObject<{
|
||||||
|
key: z.ZodString;
|
||||||
|
label: z.ZodString;
|
||||||
|
type: z.ZodEnum<{
|
||||||
|
text: "text";
|
||||||
|
textarea: "textarea";
|
||||||
|
select: "select";
|
||||||
|
combo: "combo";
|
||||||
|
list: "list";
|
||||||
|
}>;
|
||||||
|
required: z.ZodOptional<z.ZodBoolean>;
|
||||||
|
desc: z.ZodOptional<z.ZodString>;
|
||||||
|
placeholder: z.ZodOptional<z.ZodString>;
|
||||||
|
options: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
|
value: z.ZodString;
|
||||||
|
label: z.ZodString;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type LaunchParams = z.infer<typeof launchParamsSchema>;
|
||||||
|
export declare const launchInputSchema: z.ZodObject<{
|
||||||
|
idempotencyKey: z.ZodString;
|
||||||
|
ownerId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
|
params: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type LaunchInput = z.infer<typeof launchInputSchema>;
|
||||||
@@ -0,0 +1,522 @@
|
|||||||
|
/**
|
||||||
|
* 运营中心 · 活动模块的路由契约(`docs/API文档-活动模块.md`)。
|
||||||
|
*
|
||||||
|
* 全部 `access: 'ops'` —— 活动区落在运营中心,与工作流 / 任务看板同端。
|
||||||
|
* `/api/gov/*` 的写路由由 app 中间件自动广播 `{kind:'system', id:'gov'}`。
|
||||||
|
* 设计端的活动库(包版本 / 资产版本 / 装入)在 `/api/gov/activity-packages`(governance.ts),
|
||||||
|
* `/api/gov/activities/*` 整个归运营端(2026-08-27 用户定)。
|
||||||
|
*/
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { RouteContract } from './http.ts';
|
||||||
|
export declare const listOpsActivitiesRoute: {
|
||||||
|
readonly method: "GET";
|
||||||
|
readonly path: "/api/gov/activities";
|
||||||
|
readonly access: "ops";
|
||||||
|
/** 不带 status = 全量;量小不分页 */
|
||||||
|
readonly query: z.ZodObject<{
|
||||||
|
status: z.ZodOptional<z.ZodEnum<{
|
||||||
|
active: "active";
|
||||||
|
disabled: "disabled";
|
||||||
|
}>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly output: z.ZodObject<{
|
||||||
|
activities: z.ZodArray<z.ZodObject<{
|
||||||
|
id: z.ZodString;
|
||||||
|
name: z.ZodString;
|
||||||
|
biz: z.ZodString;
|
||||||
|
status: z.ZodEnum<{
|
||||||
|
active: "active";
|
||||||
|
disabled: "disabled";
|
||||||
|
}>;
|
||||||
|
version: z.ZodString;
|
||||||
|
owner: z.ZodNullable<z.ZodObject<{
|
||||||
|
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
|
name: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
llm: z.ZodString;
|
||||||
|
instanceCount: z.ZodNumber;
|
||||||
|
createdAt: z.ZodString;
|
||||||
|
ico: z.ZodOptional<z.ZodString>;
|
||||||
|
hue: z.ZodOptional<z.ZodEnum<{
|
||||||
|
blue: "blue";
|
||||||
|
teal: "teal";
|
||||||
|
amber: "amber";
|
||||||
|
violet: "violet";
|
||||||
|
}>>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
};
|
||||||
|
export declare const getOpsActivityRoute: {
|
||||||
|
readonly method: "GET";
|
||||||
|
readonly path: "/api/gov/activities/:activityId";
|
||||||
|
readonly access: "ops";
|
||||||
|
readonly params: z.ZodObject<{
|
||||||
|
activityId: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly output: z.ZodObject<{
|
||||||
|
name: z.ZodString;
|
||||||
|
id: z.ZodString;
|
||||||
|
version: z.ZodString;
|
||||||
|
status: z.ZodEnum<{
|
||||||
|
active: "active";
|
||||||
|
disabled: "disabled";
|
||||||
|
}>;
|
||||||
|
createdAt: z.ZodString;
|
||||||
|
biz: z.ZodString;
|
||||||
|
owner: z.ZodNullable<z.ZodObject<{
|
||||||
|
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
|
name: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
llm: z.ZodString;
|
||||||
|
ico: z.ZodOptional<z.ZodString>;
|
||||||
|
hue: z.ZodOptional<z.ZodEnum<{
|
||||||
|
blue: "blue";
|
||||||
|
teal: "teal";
|
||||||
|
amber: "amber";
|
||||||
|
violet: "violet";
|
||||||
|
}>>;
|
||||||
|
desc: z.ZodString;
|
||||||
|
view: z.ZodNullable<z.ZodObject<{
|
||||||
|
url: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 沙箱要渲染的活动视图 —— 处理器回 `text/html`(不是 JSON),`output` 只是形式上的字符串。
|
||||||
|
* iframe 带不了 Authorization 头,走票据 `?ticket=`(GET,中间件已认)。404 = 该活动没配视图。
|
||||||
|
*/
|
||||||
|
export declare const opsActivityViewRoute: {
|
||||||
|
readonly method: "GET";
|
||||||
|
readonly path: "/api/gov/activities/:activityId/view";
|
||||||
|
readonly access: "ops";
|
||||||
|
readonly params: z.ZodObject<{
|
||||||
|
activityId: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly output: z.ZodString;
|
||||||
|
};
|
||||||
|
export declare const listOpsInstancesRoute: {
|
||||||
|
readonly method: "GET";
|
||||||
|
readonly path: "/api/gov/activities/:activityId/instances";
|
||||||
|
readonly access: "ops";
|
||||||
|
readonly params: z.ZodObject<{
|
||||||
|
activityId: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly query: z.ZodObject<{
|
||||||
|
id: z.ZodOptional<z.ZodString>;
|
||||||
|
ownerId: z.ZodOptional<z.ZodString>;
|
||||||
|
state: z.ZodOptional<z.ZodEnum<{
|
||||||
|
running: "running";
|
||||||
|
completed: "completed";
|
||||||
|
pending: "pending";
|
||||||
|
failed: "failed";
|
||||||
|
rejected: "rejected";
|
||||||
|
}>>;
|
||||||
|
startedFrom: z.ZodOptional<z.ZodString>;
|
||||||
|
startedTo: z.ZodOptional<z.ZodString>;
|
||||||
|
page: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
||||||
|
size: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly output: z.ZodObject<{
|
||||||
|
stats: z.ZodObject<{
|
||||||
|
total: z.ZodNumber;
|
||||||
|
byState: z.ZodRecord<z.ZodEnum<{
|
||||||
|
running: "running";
|
||||||
|
completed: "completed";
|
||||||
|
pending: "pending";
|
||||||
|
failed: "failed";
|
||||||
|
rejected: "rejected";
|
||||||
|
}>, z.ZodNumber>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
total: z.ZodNumber;
|
||||||
|
page: z.ZodNumber;
|
||||||
|
size: z.ZodNumber;
|
||||||
|
items: z.ZodArray<z.ZodObject<{
|
||||||
|
id: z.ZodString;
|
||||||
|
no: z.ZodString;
|
||||||
|
state: z.ZodEnum<{
|
||||||
|
running: "running";
|
||||||
|
completed: "completed";
|
||||||
|
pending: "pending";
|
||||||
|
failed: "failed";
|
||||||
|
rejected: "rejected";
|
||||||
|
}>;
|
||||||
|
owner: z.ZodObject<{
|
||||||
|
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
|
name: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
input: z.ZodString;
|
||||||
|
output: z.ZodNullable<z.ZodString>;
|
||||||
|
startedAt: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
};
|
||||||
|
export declare const opsLaunchParamsRoute: {
|
||||||
|
readonly method: "GET";
|
||||||
|
readonly path: "/api/gov/activities/:activityId/launch-params";
|
||||||
|
readonly access: "ops";
|
||||||
|
readonly params: z.ZodObject<{
|
||||||
|
activityId: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly output: z.ZodObject<{
|
||||||
|
activityId: z.ZodString;
|
||||||
|
version: z.ZodString;
|
||||||
|
params: z.ZodArray<z.ZodObject<{
|
||||||
|
key: z.ZodString;
|
||||||
|
label: z.ZodString;
|
||||||
|
type: z.ZodEnum<{
|
||||||
|
text: "text";
|
||||||
|
textarea: "textarea";
|
||||||
|
select: "select";
|
||||||
|
combo: "combo";
|
||||||
|
list: "list";
|
||||||
|
}>;
|
||||||
|
required: z.ZodOptional<z.ZodBoolean>;
|
||||||
|
desc: z.ZodOptional<z.ZodString>;
|
||||||
|
placeholder: z.ZodOptional<z.ZodString>;
|
||||||
|
options: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
|
value: z.ZodString;
|
||||||
|
label: z.ZodString;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 启动活动 = 创建实例。同一 `idempotencyKey` 重放返回第一次创建的实例;
|
||||||
|
* 412 活动已禁用;400 缺必填 / 类型不符 / select 值不在候选 / 责任人不在职;409 同键正在创建中。
|
||||||
|
*/
|
||||||
|
export declare const launchOpsInstanceRoute: {
|
||||||
|
readonly method: "POST";
|
||||||
|
readonly path: "/api/gov/activities/:activityId/instances";
|
||||||
|
readonly access: "ops";
|
||||||
|
readonly params: z.ZodObject<{
|
||||||
|
activityId: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly body: z.ZodObject<{
|
||||||
|
idempotencyKey: z.ZodString;
|
||||||
|
ownerId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
|
params: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly output: z.ZodObject<{
|
||||||
|
id: z.ZodString;
|
||||||
|
state: z.ZodEnum<{
|
||||||
|
running: "running";
|
||||||
|
completed: "completed";
|
||||||
|
pending: "pending";
|
||||||
|
failed: "failed";
|
||||||
|
rejected: "rejected";
|
||||||
|
}>;
|
||||||
|
startedAt: z.ZodString;
|
||||||
|
owner: z.ZodObject<{
|
||||||
|
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
|
name: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
no: z.ZodString;
|
||||||
|
activityId: z.ZodString;
|
||||||
|
llm: z.ZodString;
|
||||||
|
endedAt: z.ZodNullable<z.ZodString>;
|
||||||
|
durationMs: z.ZodNullable<z.ZodNumber>;
|
||||||
|
reason: z.ZodNullable<z.ZodObject<{
|
||||||
|
kind: z.ZodEnum<{
|
||||||
|
terminated: "terminated";
|
||||||
|
failed: "failed";
|
||||||
|
rejected: "rejected";
|
||||||
|
}>;
|
||||||
|
text: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
input: z.ZodObject<{
|
||||||
|
content: z.ZodString;
|
||||||
|
source: z.ZodObject<{
|
||||||
|
kind: z.ZodString;
|
||||||
|
label: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
submittedAt: z.ZodString;
|
||||||
|
files: z.ZodArray<z.ZodObject<{
|
||||||
|
index: z.ZodNumber;
|
||||||
|
name: z.ZodString;
|
||||||
|
contentType: z.ZodString;
|
||||||
|
size: z.ZodNumber;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
output: z.ZodObject<{
|
||||||
|
files: z.ZodArray<z.ZodObject<{
|
||||||
|
index: z.ZodNumber;
|
||||||
|
name: z.ZodString;
|
||||||
|
contentType: z.ZodString;
|
||||||
|
size: z.ZodNumber;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
steps: z.ZodArray<z.ZodObject<{
|
||||||
|
seq: z.ZodNumber;
|
||||||
|
name: z.ZodString;
|
||||||
|
by: z.ZodEnum<{
|
||||||
|
human: "human";
|
||||||
|
agent: "agent";
|
||||||
|
}>;
|
||||||
|
byName: z.ZodString;
|
||||||
|
at: z.ZodNullable<z.ZodString>;
|
||||||
|
durationMs: z.ZodNullable<z.ZodNumber>;
|
||||||
|
res: z.ZodEnum<{
|
||||||
|
pending: "pending";
|
||||||
|
done: "done";
|
||||||
|
fail: "fail";
|
||||||
|
current: "current";
|
||||||
|
}>;
|
||||||
|
note: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
};
|
||||||
|
export declare const getOpsInstanceRoute: {
|
||||||
|
readonly method: "GET";
|
||||||
|
readonly path: "/api/gov/activities/:activityId/instances/:instanceId";
|
||||||
|
readonly access: "ops";
|
||||||
|
readonly params: z.ZodObject<{
|
||||||
|
activityId: z.ZodString;
|
||||||
|
instanceId: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly output: z.ZodObject<{
|
||||||
|
id: z.ZodString;
|
||||||
|
state: z.ZodEnum<{
|
||||||
|
running: "running";
|
||||||
|
completed: "completed";
|
||||||
|
pending: "pending";
|
||||||
|
failed: "failed";
|
||||||
|
rejected: "rejected";
|
||||||
|
}>;
|
||||||
|
startedAt: z.ZodString;
|
||||||
|
owner: z.ZodObject<{
|
||||||
|
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
|
name: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
no: z.ZodString;
|
||||||
|
activityId: z.ZodString;
|
||||||
|
llm: z.ZodString;
|
||||||
|
endedAt: z.ZodNullable<z.ZodString>;
|
||||||
|
durationMs: z.ZodNullable<z.ZodNumber>;
|
||||||
|
reason: z.ZodNullable<z.ZodObject<{
|
||||||
|
kind: z.ZodEnum<{
|
||||||
|
terminated: "terminated";
|
||||||
|
failed: "failed";
|
||||||
|
rejected: "rejected";
|
||||||
|
}>;
|
||||||
|
text: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
input: z.ZodObject<{
|
||||||
|
content: z.ZodString;
|
||||||
|
source: z.ZodObject<{
|
||||||
|
kind: z.ZodString;
|
||||||
|
label: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
submittedAt: z.ZodString;
|
||||||
|
files: z.ZodArray<z.ZodObject<{
|
||||||
|
index: z.ZodNumber;
|
||||||
|
name: z.ZodString;
|
||||||
|
contentType: z.ZodString;
|
||||||
|
size: z.ZodNumber;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
output: z.ZodObject<{
|
||||||
|
files: z.ZodArray<z.ZodObject<{
|
||||||
|
index: z.ZodNumber;
|
||||||
|
name: z.ZodString;
|
||||||
|
contentType: z.ZodString;
|
||||||
|
size: z.ZodNumber;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
steps: z.ZodArray<z.ZodObject<{
|
||||||
|
seq: z.ZodNumber;
|
||||||
|
name: z.ZodString;
|
||||||
|
by: z.ZodEnum<{
|
||||||
|
human: "human";
|
||||||
|
agent: "agent";
|
||||||
|
}>;
|
||||||
|
byName: z.ZodString;
|
||||||
|
at: z.ZodNullable<z.ZodString>;
|
||||||
|
durationMs: z.ZodNullable<z.ZodNumber>;
|
||||||
|
res: z.ZodEnum<{
|
||||||
|
pending: "pending";
|
||||||
|
done: "done";
|
||||||
|
fail: "fail";
|
||||||
|
current: "current";
|
||||||
|
}>;
|
||||||
|
note: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 实例文件:默认 JSON + base64(与 `GET /api/gov/upload/:id` 同形);`?raw=1` 直出字节
|
||||||
|
* (RFC 5987 文件名),配 `?ticket=` 给 `<a href download>` 用。
|
||||||
|
*/
|
||||||
|
export declare const opsInstanceFileRoute: {
|
||||||
|
readonly method: "GET";
|
||||||
|
readonly path: "/api/gov/activities/:activityId/instances/:instanceId/files/:io/:index";
|
||||||
|
readonly access: "ops";
|
||||||
|
readonly params: z.ZodObject<{
|
||||||
|
activityId: z.ZodString;
|
||||||
|
instanceId: z.ZodString;
|
||||||
|
io: z.ZodEnum<{
|
||||||
|
output: "output";
|
||||||
|
input: "input";
|
||||||
|
}>;
|
||||||
|
index: z.ZodCoercedNumber<unknown>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly query: z.ZodObject<{
|
||||||
|
raw: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly output: z.ZodObject<{
|
||||||
|
name: z.ZodString;
|
||||||
|
contentType: z.ZodString;
|
||||||
|
dataBase64: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
};
|
||||||
|
/** 任何时刻都可禁用:只拦新启动,在途实例继续跑完;已禁用再禁用 = 幂等 200 */
|
||||||
|
export declare const disableOpsActivityRoute: {
|
||||||
|
readonly method: "POST";
|
||||||
|
readonly path: "/api/gov/activities/:activityId/disable";
|
||||||
|
readonly access: "ops";
|
||||||
|
readonly params: z.ZodObject<{
|
||||||
|
activityId: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly output: z.ZodObject<{
|
||||||
|
name: z.ZodString;
|
||||||
|
id: z.ZodString;
|
||||||
|
version: z.ZodString;
|
||||||
|
status: z.ZodEnum<{
|
||||||
|
active: "active";
|
||||||
|
disabled: "disabled";
|
||||||
|
}>;
|
||||||
|
createdAt: z.ZodString;
|
||||||
|
biz: z.ZodString;
|
||||||
|
owner: z.ZodNullable<z.ZodObject<{
|
||||||
|
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
|
name: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
llm: z.ZodString;
|
||||||
|
ico: z.ZodOptional<z.ZodString>;
|
||||||
|
hue: z.ZodOptional<z.ZodEnum<{
|
||||||
|
blue: "blue";
|
||||||
|
teal: "teal";
|
||||||
|
amber: "amber";
|
||||||
|
violet: "violet";
|
||||||
|
}>>;
|
||||||
|
desc: z.ZodString;
|
||||||
|
view: z.ZodNullable<z.ZodObject<{
|
||||||
|
url: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
};
|
||||||
|
export declare const activateOpsActivityRoute: {
|
||||||
|
readonly method: "POST";
|
||||||
|
readonly path: "/api/gov/activities/:activityId/activate";
|
||||||
|
readonly access: "ops";
|
||||||
|
readonly params: z.ZodObject<{
|
||||||
|
activityId: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly output: z.ZodObject<{
|
||||||
|
name: z.ZodString;
|
||||||
|
id: z.ZodString;
|
||||||
|
version: z.ZodString;
|
||||||
|
status: z.ZodEnum<{
|
||||||
|
active: "active";
|
||||||
|
disabled: "disabled";
|
||||||
|
}>;
|
||||||
|
createdAt: z.ZodString;
|
||||||
|
biz: z.ZodString;
|
||||||
|
owner: z.ZodNullable<z.ZodObject<{
|
||||||
|
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
|
name: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
llm: z.ZodString;
|
||||||
|
ico: z.ZodOptional<z.ZodString>;
|
||||||
|
hue: z.ZodOptional<z.ZodEnum<{
|
||||||
|
blue: "blue";
|
||||||
|
teal: "teal";
|
||||||
|
amber: "amber";
|
||||||
|
violet: "violet";
|
||||||
|
}>>;
|
||||||
|
desc: z.ZodString;
|
||||||
|
view: z.ZodNullable<z.ZodObject<{
|
||||||
|
url: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
};
|
||||||
|
/** body 空;→ `state='failed'` + `reason.kind='terminated'`;412 实例已终态 */
|
||||||
|
export declare const terminateOpsInstanceRoute: {
|
||||||
|
readonly method: "POST";
|
||||||
|
readonly path: "/api/gov/activities/:activityId/instances/:instanceId/terminate";
|
||||||
|
readonly access: "ops";
|
||||||
|
readonly params: z.ZodObject<{
|
||||||
|
activityId: z.ZodString;
|
||||||
|
instanceId: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly output: z.ZodObject<{
|
||||||
|
id: z.ZodString;
|
||||||
|
state: z.ZodEnum<{
|
||||||
|
running: "running";
|
||||||
|
completed: "completed";
|
||||||
|
pending: "pending";
|
||||||
|
failed: "failed";
|
||||||
|
rejected: "rejected";
|
||||||
|
}>;
|
||||||
|
startedAt: z.ZodString;
|
||||||
|
owner: z.ZodObject<{
|
||||||
|
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
|
name: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
no: z.ZodString;
|
||||||
|
activityId: z.ZodString;
|
||||||
|
llm: z.ZodString;
|
||||||
|
endedAt: z.ZodNullable<z.ZodString>;
|
||||||
|
durationMs: z.ZodNullable<z.ZodNumber>;
|
||||||
|
reason: z.ZodNullable<z.ZodObject<{
|
||||||
|
kind: z.ZodEnum<{
|
||||||
|
terminated: "terminated";
|
||||||
|
failed: "failed";
|
||||||
|
rejected: "rejected";
|
||||||
|
}>;
|
||||||
|
text: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
input: z.ZodObject<{
|
||||||
|
content: z.ZodString;
|
||||||
|
source: z.ZodObject<{
|
||||||
|
kind: z.ZodString;
|
||||||
|
label: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
submittedAt: z.ZodString;
|
||||||
|
files: z.ZodArray<z.ZodObject<{
|
||||||
|
index: z.ZodNumber;
|
||||||
|
name: z.ZodString;
|
||||||
|
contentType: z.ZodString;
|
||||||
|
size: z.ZodNumber;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
output: z.ZodObject<{
|
||||||
|
files: z.ZodArray<z.ZodObject<{
|
||||||
|
index: z.ZodNumber;
|
||||||
|
name: z.ZodString;
|
||||||
|
contentType: z.ZodString;
|
||||||
|
size: z.ZodNumber;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
steps: z.ZodArray<z.ZodObject<{
|
||||||
|
seq: z.ZodNumber;
|
||||||
|
name: z.ZodString;
|
||||||
|
by: z.ZodEnum<{
|
||||||
|
human: "human";
|
||||||
|
agent: "agent";
|
||||||
|
}>;
|
||||||
|
byName: z.ZodString;
|
||||||
|
at: z.ZodNullable<z.ZodString>;
|
||||||
|
durationMs: z.ZodNullable<z.ZodNumber>;
|
||||||
|
res: z.ZodEnum<{
|
||||||
|
pending: "pending";
|
||||||
|
done: "done";
|
||||||
|
fail: "fail";
|
||||||
|
current: "current";
|
||||||
|
}>;
|
||||||
|
note: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
};
|
||||||
|
/** 全部 11 条 —— 文档总表与覆盖测试按它数 */
|
||||||
|
export declare const ACTIVITY_OPS_ROUTES: readonly RouteContract[];
|
||||||
+37
-2
@@ -1993,10 +1993,12 @@ export declare const saveWorkGroupRoute: {
|
|||||||
* 安装两审在服务端跑:静态审读清单(工具契约在否、模型名绑没绑、模版有没有脚本、
|
* 安装两审在服务端跑:静态审读清单(工具契约在否、模型名绑没绑、模版有没有脚本、
|
||||||
* 声明与已物化的台账冲不冲),回放审重演历史(首装无历史即免,以样例集替代)。
|
* 声明与已物化的台账冲不冲),回放审重演历史(首装无历史即免,以样例集替代)。
|
||||||
* **恒为新增、版本并存**:装入不覆盖旧版本,在途任务按其启动时版本跑完。
|
* **恒为新增、版本并存**:装入不覆盖旧版本,在途任务按其启动时版本跑完。
|
||||||
|
* **路径(2026-08-27)**:`/api/gov/activity-packages` —— 从前叫 `/api/gov/activities`;那个前缀整个让给了运营端
|
||||||
|
* 活动模块(L1/L2/L3,`api/activity-ops.ts`),这里管的是**包**(版本 / 资产 / 装入),名副其实。
|
||||||
*/
|
*/
|
||||||
export declare const installActivityRoute: {
|
export declare const installActivityRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/gov/activities";
|
readonly path: "/api/gov/activity-packages";
|
||||||
readonly access: "design";
|
readonly access: "design";
|
||||||
readonly body: z.ZodObject<{
|
readonly body: z.ZodObject<{
|
||||||
manifest: z.ZodObject<{
|
manifest: z.ZodObject<{
|
||||||
@@ -2115,6 +2117,7 @@ export declare const installActivityRoute: {
|
|||||||
derived: "derived";
|
derived: "derived";
|
||||||
}>>;
|
}>>;
|
||||||
caseKey: z.ZodOptional<z.ZodString>;
|
caseKey: z.ZodOptional<z.ZodString>;
|
||||||
|
createOnly: z.ZodOptional<z.ZodBoolean>;
|
||||||
file: z.ZodOptional<z.ZodObject<{
|
file: z.ZodOptional<z.ZodObject<{
|
||||||
titleColumn: z.ZodString;
|
titleColumn: z.ZodString;
|
||||||
bodyColumn: z.ZodString;
|
bodyColumn: z.ZodString;
|
||||||
@@ -2229,6 +2232,11 @@ export declare const installActivityRoute: {
|
|||||||
view: z.ZodOptional<z.ZodString>;
|
view: z.ZodOptional<z.ZodString>;
|
||||||
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
doneWhen: z.ZodOptional<z.ZodString>;
|
doneWhen: z.ZodOptional<z.ZodString>;
|
||||||
|
cardVariant: z.ZodOptional<z.ZodEnum<{
|
||||||
|
data: "data";
|
||||||
|
review: "review";
|
||||||
|
}>>;
|
||||||
|
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
@@ -2250,6 +2258,32 @@ export declare const installActivityRoute: {
|
|||||||
viewTemplates: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
viewTemplates: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
html: z.ZodString;
|
html: z.ZodString;
|
||||||
|
data: z.ZodOptional<z.ZodObject<{
|
||||||
|
case: z.ZodObject<{
|
||||||
|
ledger: z.ZodString;
|
||||||
|
key: z.ZodString;
|
||||||
|
prefix: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
rows: z.ZodArray<z.ZodObject<{
|
||||||
|
as: z.ZodString;
|
||||||
|
ledger: z.ZodString;
|
||||||
|
by: z.ZodObject<{
|
||||||
|
column: z.ZodString;
|
||||||
|
from: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
not: z.ZodOptional<z.ZodObject<{
|
||||||
|
column: z.ZodString;
|
||||||
|
from: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
orderBy: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
|
field: z.ZodString;
|
||||||
|
dir: z.ZodEnum<{
|
||||||
|
desc: "desc";
|
||||||
|
asc: "asc";
|
||||||
|
}>;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
cardSchemas: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
|
cardSchemas: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
|
||||||
distillHooks: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
distillHooks: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
@@ -2348,9 +2382,10 @@ export declare const registerToolRoute: {
|
|||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
};
|
};
|
||||||
|
/** 路径同 `installActivityRoute` 的说明:`/api/gov/activity-packages`(2026-08-27 让位给运营端活动模块) */
|
||||||
export declare const listActivitiesRoute: {
|
export declare const listActivitiesRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/activities";
|
readonly path: "/api/gov/activity-packages";
|
||||||
readonly access: "design";
|
readonly access: "design";
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
activities: z.ZodArray<z.ZodObject<{
|
activities: z.ZodArray<z.ZodObject<{
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export * from './governance.ts';
|
|||||||
export * from './org-directory.ts';
|
export * from './org-directory.ts';
|
||||||
export * from './ingest.ts';
|
export * from './ingest.ts';
|
||||||
export * from './mail.ts';
|
export * from './mail.ts';
|
||||||
|
export * from './activity-ops.ts';
|
||||||
|
|||||||
+1207
-1168
File diff suppressed because it is too large
Load Diff
+30
@@ -604,6 +604,7 @@ export declare const workDoneSchema: z.ZodObject<{
|
|||||||
pass: "pass";
|
pass: "pass";
|
||||||
}>;
|
}>;
|
||||||
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||||
|
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
export type WorkDone = z.infer<typeof workDoneSchema>;
|
export type WorkDone = z.infer<typeof workDoneSchema>;
|
||||||
/** 执行工单:作业指导书 + 空表单(补全并入此类);落地失败卡是它的系统预置形态 */
|
/** 执行工单:作业指导书 + 空表单(补全并入此类);落地失败卡是它的系统预置形态 */
|
||||||
@@ -612,6 +613,9 @@ export declare const executionWorkOrderSchema: z.ZodObject<{
|
|||||||
preset: z.ZodOptional<z.ZodEnum<{
|
preset: z.ZodOptional<z.ZodEnum<{
|
||||||
"landing-failure": "landing-failure";
|
"landing-failure": "landing-failure";
|
||||||
}>>;
|
}>>;
|
||||||
|
variant: z.ZodOptional<z.ZodEnum<{
|
||||||
|
data: "data";
|
||||||
|
}>>;
|
||||||
ask: z.ZodString;
|
ask: z.ZodString;
|
||||||
standard: z.ZodOptional<z.ZodString>;
|
standard: z.ZodOptional<z.ZodString>;
|
||||||
steps: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
steps: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
@@ -881,6 +885,7 @@ export declare const executionWorkOrderSchema: z.ZodObject<{
|
|||||||
pass: "pass";
|
pass: "pass";
|
||||||
}>;
|
}>;
|
||||||
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||||
|
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
finalHtml: z.ZodOptional<z.ZodString>;
|
finalHtml: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -927,6 +932,7 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{
|
|||||||
settled: z.ZodOptional<z.ZodBoolean>;
|
settled: z.ZodOptional<z.ZodBoolean>;
|
||||||
rationale: z.ZodOptional<z.ZodString>;
|
rationale: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
|
checklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
actions: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
actions: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
label: z.ZodString;
|
label: z.ZodString;
|
||||||
effect: z.ZodEnum<{
|
effect: z.ZodEnum<{
|
||||||
@@ -1217,6 +1223,7 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{
|
|||||||
pass: "pass";
|
pass: "pass";
|
||||||
}>;
|
}>;
|
||||||
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||||
|
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
finalHtml: z.ZodOptional<z.ZodString>;
|
finalHtml: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -1226,6 +1233,9 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
preset: z.ZodOptional<z.ZodEnum<{
|
preset: z.ZodOptional<z.ZodEnum<{
|
||||||
"landing-failure": "landing-failure";
|
"landing-failure": "landing-failure";
|
||||||
}>>;
|
}>>;
|
||||||
|
variant: z.ZodOptional<z.ZodEnum<{
|
||||||
|
data: "data";
|
||||||
|
}>>;
|
||||||
ask: z.ZodString;
|
ask: z.ZodString;
|
||||||
standard: z.ZodOptional<z.ZodString>;
|
standard: z.ZodOptional<z.ZodString>;
|
||||||
steps: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
steps: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
@@ -1495,6 +1505,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
pass: "pass";
|
pass: "pass";
|
||||||
}>;
|
}>;
|
||||||
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||||
|
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
finalHtml: z.ZodOptional<z.ZodString>;
|
finalHtml: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>, z.ZodObject<{
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
@@ -1513,6 +1524,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
settled: z.ZodOptional<z.ZodBoolean>;
|
settled: z.ZodOptional<z.ZodBoolean>;
|
||||||
rationale: z.ZodOptional<z.ZodString>;
|
rationale: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
|
checklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
actions: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
actions: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
label: z.ZodString;
|
label: z.ZodString;
|
||||||
effect: z.ZodEnum<{
|
effect: z.ZodEnum<{
|
||||||
@@ -1803,6 +1815,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
pass: "pass";
|
pass: "pass";
|
||||||
}>;
|
}>;
|
||||||
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||||
|
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
finalHtml: z.ZodOptional<z.ZodString>;
|
finalHtml: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>], "mode">;
|
}, z.core.$strip>], "mode">;
|
||||||
@@ -1819,6 +1832,10 @@ export declare const AI_TAG_LABEL: Readonly<Record<AiTag, string>>;
|
|||||||
/** 决策卡:分支条件选择表;含自定义收尾项 */
|
/** 决策卡:分支条件选择表;含自定义收尾项 */
|
||||||
export declare const decisionCardSchema: z.ZodObject<{
|
export declare const decisionCardSchema: z.ZodObject<{
|
||||||
k: z.ZodLiteral<"decision">;
|
k: z.ZodLiteral<"decision">;
|
||||||
|
variant: z.ZodOptional<z.ZodEnum<{
|
||||||
|
review: "review";
|
||||||
|
}>>;
|
||||||
|
checklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
ask: z.ZodString;
|
ask: z.ZodString;
|
||||||
basis: z.ZodArray<z.ZodObject<{
|
basis: z.ZodArray<z.ZodObject<{
|
||||||
k: z.ZodString;
|
k: z.ZodString;
|
||||||
@@ -1838,6 +1855,7 @@ export declare const decisionCardSchema: z.ZodObject<{
|
|||||||
at: z.ZodISODateTime;
|
at: z.ZodISODateTime;
|
||||||
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
note: z.ZodOptional<z.ZodString>;
|
note: z.ZodOptional<z.ZodString>;
|
||||||
|
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
finalHtml: z.ZodOptional<z.ZodString>;
|
finalHtml: z.ZodOptional<z.ZodString>;
|
||||||
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||||
@@ -1931,6 +1949,10 @@ export declare const ratingCardSchema: z.ZodObject<{
|
|||||||
export type RatingCard = z.infer<typeof ratingCardSchema>;
|
export type RatingCard = z.infer<typeof ratingCardSchema>;
|
||||||
export declare const chatCardSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
export declare const chatCardSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||||
k: z.ZodLiteral<"decision">;
|
k: z.ZodLiteral<"decision">;
|
||||||
|
variant: z.ZodOptional<z.ZodEnum<{
|
||||||
|
review: "review";
|
||||||
|
}>>;
|
||||||
|
checklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
ask: z.ZodString;
|
ask: z.ZodString;
|
||||||
basis: z.ZodArray<z.ZodObject<{
|
basis: z.ZodArray<z.ZodObject<{
|
||||||
k: z.ZodString;
|
k: z.ZodString;
|
||||||
@@ -1950,6 +1972,7 @@ export declare const chatCardSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
at: z.ZodISODateTime;
|
at: z.ZodISODateTime;
|
||||||
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
note: z.ZodOptional<z.ZodString>;
|
note: z.ZodOptional<z.ZodString>;
|
||||||
|
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
finalHtml: z.ZodOptional<z.ZodString>;
|
finalHtml: z.ZodOptional<z.ZodString>;
|
||||||
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||||
@@ -2064,6 +2087,10 @@ export declare const chatMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
at: z.ZodISODateTime;
|
at: z.ZodISODateTime;
|
||||||
}, z.core.$strip>, z.ZodObject<{
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
k: z.ZodLiteral<"decision">;
|
k: z.ZodLiteral<"decision">;
|
||||||
|
variant: z.ZodOptional<z.ZodEnum<{
|
||||||
|
review: "review";
|
||||||
|
}>>;
|
||||||
|
checklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
ask: z.ZodString;
|
ask: z.ZodString;
|
||||||
basis: z.ZodArray<z.ZodObject<{
|
basis: z.ZodArray<z.ZodObject<{
|
||||||
k: z.ZodString;
|
k: z.ZodString;
|
||||||
@@ -2083,6 +2110,7 @@ export declare const chatMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
at: z.ZodISODateTime;
|
at: z.ZodISODateTime;
|
||||||
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
note: z.ZodOptional<z.ZodString>;
|
note: z.ZodOptional<z.ZodString>;
|
||||||
|
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
finalHtml: z.ZodOptional<z.ZodString>;
|
finalHtml: z.ZodOptional<z.ZodString>;
|
||||||
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||||
@@ -2180,6 +2208,7 @@ export declare const workOrderReplySchema: z.ZodDiscriminatedUnion<[z.ZodObject<
|
|||||||
values: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
values: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||||
comment: z.ZodOptional<z.ZodString>;
|
comment: z.ZodOptional<z.ZodString>;
|
||||||
selectedPorts: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
selectedPorts: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||||
|
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>], "mode">;
|
}, z.core.$strip>], "mode">;
|
||||||
export type WorkOrderReply = z.infer<typeof workOrderReplySchema>;
|
export type WorkOrderReply = z.infer<typeof workOrderReplySchema>;
|
||||||
export declare const chatCardReplySchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
export declare const chatCardReplySchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||||
@@ -2187,6 +2216,7 @@ export declare const chatCardReplySchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
cardId: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
cardId: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||||
value: z.ZodString;
|
value: z.ZodString;
|
||||||
note: z.ZodOptional<z.ZodString>;
|
note: z.ZodOptional<z.ZodString>;
|
||||||
|
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>, z.ZodObject<{
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
k: z.ZodLiteral<"elicitation">;
|
k: z.ZodLiteral<"elicitation">;
|
||||||
cardId: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
cardId: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||||
|
|||||||
@@ -14,4 +14,5 @@ export * from './process-package.ts';
|
|||||||
export * from './manifest.ts';
|
export * from './manifest.ts';
|
||||||
export * from './org.ts';
|
export * from './org.ts';
|
||||||
export * from './mail.ts';
|
export * from './mail.ts';
|
||||||
|
export * from './activity-ops.ts';
|
||||||
export * from './api/index.ts';
|
export * from './api/index.ts';
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export declare const objectDeclarationSchema: z.ZodObject<{
|
|||||||
derived: "derived";
|
derived: "derived";
|
||||||
}>>;
|
}>>;
|
||||||
caseKey: z.ZodOptional<z.ZodString>;
|
caseKey: z.ZodOptional<z.ZodString>;
|
||||||
|
createOnly: z.ZodOptional<z.ZodBoolean>;
|
||||||
file: z.ZodOptional<z.ZodObject<{
|
file: z.ZodOptional<z.ZodObject<{
|
||||||
titleColumn: z.ZodString;
|
titleColumn: z.ZodString;
|
||||||
bodyColumn: z.ZodString;
|
bodyColumn: z.ZodString;
|
||||||
|
|||||||
+169
@@ -48,6 +48,7 @@ export declare const manifestDeclarationsSchema: z.ZodObject<{
|
|||||||
derived: "derived";
|
derived: "derived";
|
||||||
}>>;
|
}>>;
|
||||||
caseKey: z.ZodOptional<z.ZodString>;
|
caseKey: z.ZodOptional<z.ZodString>;
|
||||||
|
createOnly: z.ZodOptional<z.ZodBoolean>;
|
||||||
file: z.ZodOptional<z.ZodObject<{
|
file: z.ZodOptional<z.ZodObject<{
|
||||||
titleColumn: z.ZodString;
|
titleColumn: z.ZodString;
|
||||||
bodyColumn: z.ZodString;
|
bodyColumn: z.ZodString;
|
||||||
@@ -201,6 +202,11 @@ export declare const planStepSchema: z.ZodObject<{
|
|||||||
view: z.ZodOptional<z.ZodString>;
|
view: z.ZodOptional<z.ZodString>;
|
||||||
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
doneWhen: z.ZodOptional<z.ZodString>;
|
doneWhen: z.ZodOptional<z.ZodString>;
|
||||||
|
cardVariant: z.ZodOptional<z.ZodEnum<{
|
||||||
|
data: "data";
|
||||||
|
review: "review";
|
||||||
|
}>>;
|
||||||
|
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
export type PlanStep = z.infer<typeof planStepSchema>;
|
export type PlanStep = z.infer<typeof planStepSchema>;
|
||||||
/**
|
/**
|
||||||
@@ -261,6 +267,11 @@ export declare const loopModelSchema: z.ZodObject<{
|
|||||||
view: z.ZodOptional<z.ZodString>;
|
view: z.ZodOptional<z.ZodString>;
|
||||||
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
doneWhen: z.ZodOptional<z.ZodString>;
|
doneWhen: z.ZodOptional<z.ZodString>;
|
||||||
|
cardVariant: z.ZodOptional<z.ZodEnum<{
|
||||||
|
data: "data";
|
||||||
|
review: "review";
|
||||||
|
}>>;
|
||||||
|
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
@@ -315,6 +326,11 @@ export declare const businessModelSchema: z.ZodObject<{
|
|||||||
view: z.ZodOptional<z.ZodString>;
|
view: z.ZodOptional<z.ZodString>;
|
||||||
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
doneWhen: z.ZodOptional<z.ZodString>;
|
doneWhen: z.ZodOptional<z.ZodString>;
|
||||||
|
cardVariant: z.ZodOptional<z.ZodEnum<{
|
||||||
|
data: "data";
|
||||||
|
review: "review";
|
||||||
|
}>>;
|
||||||
|
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
@@ -335,6 +351,95 @@ export declare const skillSchema: z.ZodObject<{
|
|||||||
version: z.ZodString;
|
version: z.ZodString;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
export type Skill = z.infer<typeof skillSchema>;
|
export type Skill = z.infer<typeof skillSchema>;
|
||||||
|
/**
|
||||||
|
* 运营中心沙箱看板的**取数声明**(2026-08-27):模版归活动,「哪几行是这一单的」也归活动 —— 案号前缀、
|
||||||
|
* 哪张表按哪一列圈,平台不该写死;基座只照声明逐表查**正式表**、把行集摊成扁平作用域交渲染器。
|
||||||
|
*
|
||||||
|
* 求值:先定案号 `case.prefix + 实例开案时刻 YYYYMMDDHHmmss`(与引擎注给模型铸主键的 `case:openedAt` 同一串);
|
||||||
|
* `rows` 按序求值,每条 = `ledger` 里 `by.column ∈ values(by.from)` 的行(`from` 只能引用 `case` 或前面已求出的
|
||||||
|
* 集合),可再用 `not` 排除一批、`orderBy` 排序。作用域里 `<as>` = 行数组、`<as>.<列>` = 首行标量、
|
||||||
|
* `<as 去符号>_rows` = 行数、`<as 去符号>_<列>_<值>` = tally 计数。案号那一行查无 → 这一单没落账,看板如实说。
|
||||||
|
*/
|
||||||
|
export declare const viewDataRowsSchema: z.ZodObject<{
|
||||||
|
as: z.ZodString;
|
||||||
|
ledger: z.ZodString;
|
||||||
|
by: z.ZodObject<{
|
||||||
|
column: z.ZodString;
|
||||||
|
from: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
not: z.ZodOptional<z.ZodObject<{
|
||||||
|
column: z.ZodString;
|
||||||
|
from: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
orderBy: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
|
field: z.ZodString;
|
||||||
|
dir: z.ZodEnum<{
|
||||||
|
desc: "desc";
|
||||||
|
asc: "asc";
|
||||||
|
}>;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type ViewDataRows = z.infer<typeof viewDataRowsSchema>;
|
||||||
|
export declare const viewDataSchema: z.ZodObject<{
|
||||||
|
case: z.ZodObject<{
|
||||||
|
ledger: z.ZodString;
|
||||||
|
key: z.ZodString;
|
||||||
|
prefix: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
rows: z.ZodArray<z.ZodObject<{
|
||||||
|
as: z.ZodString;
|
||||||
|
ledger: z.ZodString;
|
||||||
|
by: z.ZodObject<{
|
||||||
|
column: z.ZodString;
|
||||||
|
from: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
not: z.ZodOptional<z.ZodObject<{
|
||||||
|
column: z.ZodString;
|
||||||
|
from: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
orderBy: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
|
field: z.ZodString;
|
||||||
|
dir: z.ZodEnum<{
|
||||||
|
desc: "desc";
|
||||||
|
asc: "asc";
|
||||||
|
}>;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type ViewData = z.infer<typeof viewDataSchema>;
|
||||||
|
/** `by.from` / `not.from` 里指案号本身的那个字 */
|
||||||
|
export declare const VIEW_DATA_CASE_REF = "case";
|
||||||
|
export declare const viewTemplateSchema: z.ZodObject<{
|
||||||
|
name: z.ZodString;
|
||||||
|
html: z.ZodString;
|
||||||
|
data: z.ZodOptional<z.ZodObject<{
|
||||||
|
case: z.ZodObject<{
|
||||||
|
ledger: z.ZodString;
|
||||||
|
key: z.ZodString;
|
||||||
|
prefix: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
rows: z.ZodArray<z.ZodObject<{
|
||||||
|
as: z.ZodString;
|
||||||
|
ledger: z.ZodString;
|
||||||
|
by: z.ZodObject<{
|
||||||
|
column: z.ZodString;
|
||||||
|
from: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
not: z.ZodOptional<z.ZodObject<{
|
||||||
|
column: z.ZodString;
|
||||||
|
from: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
orderBy: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
|
field: z.ZodString;
|
||||||
|
dir: z.ZodEnum<{
|
||||||
|
desc: "desc";
|
||||||
|
asc: "asc";
|
||||||
|
}>;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type ViewTemplate = z.infer<typeof viewTemplateSchema>;
|
||||||
export declare const manifestAssetsSchema: z.ZodObject<{
|
export declare const manifestAssetsSchema: z.ZodObject<{
|
||||||
businessModel: z.ZodObject<{
|
businessModel: z.ZodObject<{
|
||||||
cmmnXml: z.ZodString;
|
cmmnXml: z.ZodString;
|
||||||
@@ -378,6 +483,11 @@ export declare const manifestAssetsSchema: z.ZodObject<{
|
|||||||
view: z.ZodOptional<z.ZodString>;
|
view: z.ZodOptional<z.ZodString>;
|
||||||
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
doneWhen: z.ZodOptional<z.ZodString>;
|
doneWhen: z.ZodOptional<z.ZodString>;
|
||||||
|
cardVariant: z.ZodOptional<z.ZodEnum<{
|
||||||
|
data: "data";
|
||||||
|
review: "review";
|
||||||
|
}>>;
|
||||||
|
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
@@ -399,6 +509,32 @@ export declare const manifestAssetsSchema: z.ZodObject<{
|
|||||||
viewTemplates: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
viewTemplates: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
html: z.ZodString;
|
html: z.ZodString;
|
||||||
|
data: z.ZodOptional<z.ZodObject<{
|
||||||
|
case: z.ZodObject<{
|
||||||
|
ledger: z.ZodString;
|
||||||
|
key: z.ZodString;
|
||||||
|
prefix: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
rows: z.ZodArray<z.ZodObject<{
|
||||||
|
as: z.ZodString;
|
||||||
|
ledger: z.ZodString;
|
||||||
|
by: z.ZodObject<{
|
||||||
|
column: z.ZodString;
|
||||||
|
from: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
not: z.ZodOptional<z.ZodObject<{
|
||||||
|
column: z.ZodString;
|
||||||
|
from: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
orderBy: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
|
field: z.ZodString;
|
||||||
|
dir: z.ZodEnum<{
|
||||||
|
desc: "desc";
|
||||||
|
asc: "asc";
|
||||||
|
}>;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
cardSchemas: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
|
cardSchemas: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
|
||||||
distillHooks: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
distillHooks: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
@@ -537,6 +673,7 @@ export declare const activityManifestSchema: z.ZodObject<{
|
|||||||
derived: "derived";
|
derived: "derived";
|
||||||
}>>;
|
}>>;
|
||||||
caseKey: z.ZodOptional<z.ZodString>;
|
caseKey: z.ZodOptional<z.ZodString>;
|
||||||
|
createOnly: z.ZodOptional<z.ZodBoolean>;
|
||||||
file: z.ZodOptional<z.ZodObject<{
|
file: z.ZodOptional<z.ZodObject<{
|
||||||
titleColumn: z.ZodString;
|
titleColumn: z.ZodString;
|
||||||
bodyColumn: z.ZodString;
|
bodyColumn: z.ZodString;
|
||||||
@@ -651,6 +788,11 @@ export declare const activityManifestSchema: z.ZodObject<{
|
|||||||
view: z.ZodOptional<z.ZodString>;
|
view: z.ZodOptional<z.ZodString>;
|
||||||
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
doneWhen: z.ZodOptional<z.ZodString>;
|
doneWhen: z.ZodOptional<z.ZodString>;
|
||||||
|
cardVariant: z.ZodOptional<z.ZodEnum<{
|
||||||
|
data: "data";
|
||||||
|
review: "review";
|
||||||
|
}>>;
|
||||||
|
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
@@ -672,6 +814,32 @@ export declare const activityManifestSchema: z.ZodObject<{
|
|||||||
viewTemplates: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
viewTemplates: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
html: z.ZodString;
|
html: z.ZodString;
|
||||||
|
data: z.ZodOptional<z.ZodObject<{
|
||||||
|
case: z.ZodObject<{
|
||||||
|
ledger: z.ZodString;
|
||||||
|
key: z.ZodString;
|
||||||
|
prefix: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
rows: z.ZodArray<z.ZodObject<{
|
||||||
|
as: z.ZodString;
|
||||||
|
ledger: z.ZodString;
|
||||||
|
by: z.ZodObject<{
|
||||||
|
column: z.ZodString;
|
||||||
|
from: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
not: z.ZodOptional<z.ZodObject<{
|
||||||
|
column: z.ZodString;
|
||||||
|
from: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
orderBy: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
|
field: z.ZodString;
|
||||||
|
dir: z.ZodEnum<{
|
||||||
|
desc: "desc";
|
||||||
|
asc: "asc";
|
||||||
|
}>;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
cardSchemas: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
|
cardSchemas: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
|
||||||
distillHooks: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
distillHooks: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
@@ -778,5 +946,6 @@ export declare const installVerdictSchema: z.ZodObject<{
|
|||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
skippedNoHistory: z.ZodDefault<z.ZodBoolean>;
|
skippedNoHistory: z.ZodDefault<z.ZodBoolean>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
|
stored: z.ZodDefault<z.ZodBoolean>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
export type InstallVerdict = z.infer<typeof installVerdictSchema>;
|
export type InstallVerdict = z.infer<typeof installVerdictSchema>;
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/** 实例五态:对 task 运行态 / 终态的如实映射(含「驳回是环内回转」那一段) */
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { instanceStateOf, verdictFactOf } from '../activity-ops/instance-state.ts';
|
||||||
|
|
||||||
|
const base = { terminal: undefined, reason: undefined, runtime: 'running', usedSteps: 3, lastVerdict: undefined, pending: undefined };
|
||||||
|
|
||||||
|
describe('instanceStateOf', () => {
|
||||||
|
it('终态:released 完成;terminated 失败且 kind=terminated;其余终态失败带原话', () => {
|
||||||
|
expect(instanceStateOf({ ...base, terminal: 'released' })).toEqual({ state: 'completed', reason: null });
|
||||||
|
expect(instanceStateOf({ ...base, terminal: 'terminated', reason: '由 柴浩 手动终止' })).toEqual({ state: 'failed', reason: { kind: 'terminated', text: '由 柴浩 手动终止' } });
|
||||||
|
// exhausted 存的是预算执行器的 JSON —— 摆给人的要说哪一项用了多少;认不出的形状原样交
|
||||||
|
expect(instanceStateOf({ ...base, terminal: 'exhausted', reason: '{"kind":"exhausted","breaches":[{"item":"steps","used":90,"cap":90},{"item":"activeMs","used":7200000,"cap":7200000}]}' }).reason)
|
||||||
|
.toEqual({ kind: 'failed', text: '预算耗尽:步数 90/90、活跃时长 120/120 分钟' });
|
||||||
|
expect(instanceStateOf({ ...base, terminal: 'exhausted', reason: '{"breaches":[]}' }).reason?.text).toBe('{"breaches":[]}');
|
||||||
|
expect(instanceStateOf({ ...base, terminal: 'exhausted', reason: undefined }).reason?.text).toBe('预算耗尽');
|
||||||
|
expect(instanceStateOf({ ...base, terminal: 'stalled' }).reason).toEqual({ kind: 'failed', text: '停滞熔断' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('待执行 = 建了单还没被推进器领到;等人回传仍是进行中', () => {
|
||||||
|
expect(instanceStateOf({ ...base, usedSteps: 0 }).state).toBe('pending');
|
||||||
|
expect(instanceStateOf({ ...base, runtime: 'awaiting-human', usedSteps: 0 }).state).toBe('running');
|
||||||
|
expect(instanceStateOf({ ...base, runtime: 'awaiting-event' }).state).toBe('running');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('驳回:裁决刚到(pending.verdict=reject)或重做已被消费(pending 清空)且最近裁决不是 pass;重新提交后回进行中', () => {
|
||||||
|
const redo = { pass: false, comment: '毛利低于阈值,退回重算', byName: '柴浩' };
|
||||||
|
expect(instanceStateOf({ ...base, lastVerdict: redo, pending: { verdict: 'reject', comment: redo.comment } })).toEqual({
|
||||||
|
state: 'rejected',
|
||||||
|
reason: { kind: 'rejected', text: '验收驳回:毛利低于阈值,退回重算 —— 负责人 柴浩' },
|
||||||
|
});
|
||||||
|
expect(instanceStateOf({ ...base, lastVerdict: redo, pending: undefined }).state).toBe('rejected');
|
||||||
|
// 新验收卡已发(pending 立起、还没裁决)→ running
|
||||||
|
expect(instanceStateOf({ ...base, lastVerdict: redo, pending: { verdict: undefined, comment: undefined } }).state).toBe('running');
|
||||||
|
// 上一次裁决是 pass(落地中)→ running
|
||||||
|
expect(instanceStateOf({ ...base, lastVerdict: { pass: true, comment: '', byName: '柴浩' }, pending: { verdict: 'pass', comment: undefined } }).state).toBe('running');
|
||||||
|
// 没写意见也要说人话
|
||||||
|
expect(instanceStateOf({ ...base, lastVerdict: { pass: false, comment: '', byName: undefined } }).reason?.text).toBe('验收驳回:负责人未写明具体意见,已退回重做');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verdictFactOf', () => {
|
||||||
|
it('取最近一条 `裁决:`;落地那一路同 kind 但标题不同,不算', () => {
|
||||||
|
const entries = [
|
||||||
|
{ kind: 'release-verdict', title: '裁决:redo', detail: { by: 's-1', comment: '再核一遍' } },
|
||||||
|
{ kind: 'release-verdict', title: '裁决:pass', detail: { by: 's-1' } },
|
||||||
|
{ kind: 'release-verdict', title: '落地收敛,发射输出口:assessment-released', detail: { ports: ['assessment-released'] } },
|
||||||
|
];
|
||||||
|
expect(verdictFactOf(entries, (id) => (id === 's-1' ? '柴浩' : undefined))).toEqual({ pass: true, comment: '', byName: '柴浩' });
|
||||||
|
expect(verdictFactOf(entries.slice(0, 1))).toEqual({ pass: false, comment: '再核一遍', byName: undefined });
|
||||||
|
expect(verdictFactOf([])).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
/**
|
||||||
|
* 启动参数表:从清单 `input.data` 派生 + 服务端校验。
|
||||||
|
* 夹具照 W05 / E04 的清单形状手写(apps 不得 import activities —— depcruise 在守)。
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { controlOf, fieldSpecsOf, launchParamOf, launchParamsOf, validateLaunchParams } from '../activity-ops/launch-params.ts';
|
||||||
|
|
||||||
|
/** W05 的四参 + 选填文件,按 2026-08-27 的清单写法 */
|
||||||
|
const W05_INPUT = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['customerCode', 'requestDescription', 'originalInternalPn', 'candidateInternalPns'],
|
||||||
|
properties: {
|
||||||
|
customerCode: {
|
||||||
|
type: 'string',
|
||||||
|
title: '客户',
|
||||||
|
description: '台账里有就直接选;新客户自己填',
|
||||||
|
examples: ['CU-0142'],
|
||||||
|
'x-control': 'combo',
|
||||||
|
'x-options': { ledger: 'customer.profile', value: 'customer_code', label: 'name' },
|
||||||
|
},
|
||||||
|
customerName: { type: 'string', title: '客户名称', examples: ['深圳恒星电源有限公司'] },
|
||||||
|
requestDescription: { type: 'string', title: '客户需求', 'x-control': 'textarea', examples: ['工业电源项目在用 LM2596S-5.0'] },
|
||||||
|
originalInternalPn: { type: 'string', title: '原内部料号', 'x-control': 'combo', 'x-options': { ledger: 'parts.profile', value: 'internal_pn' } },
|
||||||
|
candidateInternalPns: { type: 'array', title: '候选内部料号', items: { type: 'string' }, examples: [['XL2596S-5.0', 'LM2596S-ADJ']] },
|
||||||
|
datasheets: { type: 'array', format: 'file', title: 'datasheet', items: { type: 'object' } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** E04:两级下拉 + 数组(元素形状不声明)+ 选填说明 */
|
||||||
|
const E04_INPUT = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['trigger'],
|
||||||
|
properties: {
|
||||||
|
trigger: { type: 'string', enum: ['monthly', 'event'], enumNames: ['月度例行', '事件驱动'], title: '触发方式', default: 'monthly' },
|
||||||
|
eventType: { type: 'string', enum: ['new_customer', 'major_deal'], title: '事件类型', showWhen: { field: 'trigger', equals: 'event' } },
|
||||||
|
customers: { type: 'array', title: '本轮涉及的客户', examples: [['CU-0142']] },
|
||||||
|
note: { type: 'string', title: '本轮说明' },
|
||||||
|
budget: { type: 'integer', title: '预算' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const noResolve = async () => undefined;
|
||||||
|
|
||||||
|
describe('fieldSpecsOf / controlOf', () => {
|
||||||
|
it('保属性序;必填从 required[] 认;x-control / x-options / description 都读到', () => {
|
||||||
|
const specs = fieldSpecsOf(W05_INPUT);
|
||||||
|
expect(specs.map((s) => s.key)).toEqual(['customerCode', 'customerName', 'requestDescription', 'originalInternalPn', 'candidateInternalPns', 'datasheets']);
|
||||||
|
expect(specs[0]).toMatchObject({ required: true, control: 'combo', description: '台账里有就直接选;新客户自己填', optionsSource: { ledger: 'customer.profile', value: 'customer_code', label: 'name' } });
|
||||||
|
// label 列没写就用 value 列当标签
|
||||||
|
expect(specs[3]?.optionsSource).toEqual({ ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn' });
|
||||||
|
expect(specs[1]?.required).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('控件推断:文件不上表单、字符串数组默认 list、enum 是 select、其余 text;数组标了 combo/select 就摆成单选', () => {
|
||||||
|
const specs = fieldSpecsOf(W05_INPUT);
|
||||||
|
expect(specs.map((s) => controlOf(s))).toEqual(['combo', 'text', 'textarea', 'combo', 'list', undefined]);
|
||||||
|
const e04 = fieldSpecsOf(E04_INPUT);
|
||||||
|
expect(e04.map((s) => controlOf(s))).toEqual(['select', 'select', 'list', 'text', 'text']);
|
||||||
|
// 数组字段标 combo → 摆成单选(提交时后端包成数组);标 list 或不标 → list
|
||||||
|
expect(controlOf({ ...specs[4]!, control: 'combo' })).toBe('combo');
|
||||||
|
expect(controlOf({ ...specs[4]!, control: 'list' })).toBe('list');
|
||||||
|
expect(controlOf({ ...specs[4]!, itemType: 'object' })).toBeUndefined();
|
||||||
|
expect(controlOf({ ...specs[1]!, type: 'object' })).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('x-order', () => {
|
||||||
|
it('声明了次序的按数字排,没声明的殿后保持原序(jsonb 不保键序,书写顺序靠不住)', () => {
|
||||||
|
const specs = fieldSpecsOf({
|
||||||
|
properties: {
|
||||||
|
customerCode: { type: 'string', 'x-order': 1 },
|
||||||
|
customerName: { type: 'string', 'x-order': 2 },
|
||||||
|
originalInternalPn: { type: 'string', 'x-order': 4 },
|
||||||
|
requestDescription: { type: 'string', 'x-order': 3 },
|
||||||
|
zz: { type: 'string' },
|
||||||
|
aa: { type: 'string' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(specs.map((s) => s.key)).toEqual(['customerCode', 'customerName', 'requestDescription', 'originalInternalPn', 'zz', 'aa']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('launchParamsOf', () => {
|
||||||
|
it('W05:五参(datasheets 省略);combo 候选来自 resolver;placeholder 取 examples[0]', async () => {
|
||||||
|
const asked: string[] = [];
|
||||||
|
const params = await launchParamsOf(W05_INPUT, async (src) => {
|
||||||
|
asked.push(src.ledger);
|
||||||
|
return src.ledger === 'customer.profile' ? [{ value: 'CU-0142', label: 'Itelma' }] : undefined;
|
||||||
|
});
|
||||||
|
expect(params.map((p) => p.key)).toEqual(['customerCode', 'customerName', 'requestDescription', 'originalInternalPn', 'candidateInternalPns']);
|
||||||
|
expect(asked).toEqual(['customer.profile', 'parts.profile']);
|
||||||
|
expect(params[0]).toEqual({
|
||||||
|
key: 'customerCode',
|
||||||
|
label: '客户',
|
||||||
|
type: 'combo',
|
||||||
|
required: true,
|
||||||
|
desc: '台账里有就直接选;新客户自己填',
|
||||||
|
placeholder: 'CU-0142',
|
||||||
|
options: [{ value: 'CU-0142', label: 'Itelma' }],
|
||||||
|
});
|
||||||
|
// 读不到候选的 combo 仍是 combo(自由填),只是没有 options
|
||||||
|
expect(params[3]).toEqual({ key: 'originalInternalPn', label: '原内部料号', type: 'combo', required: true });
|
||||||
|
expect(params[4]).toMatchObject({ type: 'list', placeholder: 'XL2596S-5.0', required: true });
|
||||||
|
expect(params[2]).toMatchObject({ type: 'textarea', placeholder: '工业电源项目在用 LM2596S-5.0' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('E04:enum → select 带人读名与 default;没 enumNames 拿值当标签;数组元素形状不声明也当 list', async () => {
|
||||||
|
const params = await launchParamsOf(E04_INPUT, noResolve);
|
||||||
|
expect(params[0]).toEqual({
|
||||||
|
key: 'trigger',
|
||||||
|
label: '触发方式',
|
||||||
|
type: 'select',
|
||||||
|
required: true,
|
||||||
|
options: [{ value: 'monthly', label: '月度例行' }, { value: 'event', label: '事件驱动' }],
|
||||||
|
default: 'monthly',
|
||||||
|
});
|
||||||
|
expect(params[1]?.options).toEqual([{ value: 'new_customer', label: 'new_customer' }, { value: 'major_deal', label: 'major_deal' }]);
|
||||||
|
expect(params[2]).toMatchObject({ type: 'list', placeholder: 'CU-0142' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('x-control:select 却一个候选都没有 → 降成 text(只能选却无可选等于锁死)', () => {
|
||||||
|
const spec = fieldSpecsOf({ properties: { k: { type: 'string', 'x-control': 'select' } } })[0]!;
|
||||||
|
expect(launchParamOf(spec, undefined)).toEqual({ key: 'k', label: 'k', type: 'text' });
|
||||||
|
expect(launchParamOf(spec, [])?.type).toBe('text');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('数组字段摆成单选(2026-08-27 用户定:候选料号与原料号同款下拉,只选一颗)', () => {
|
||||||
|
const INPUT = {
|
||||||
|
required: ['candidateInternalPns'],
|
||||||
|
properties: {
|
||||||
|
candidateInternalPns: {
|
||||||
|
type: 'array',
|
||||||
|
title: '候选内部料号',
|
||||||
|
items: { type: 'string' },
|
||||||
|
examples: [['XL2596S-5.0', 'LM2596S-ADJ']],
|
||||||
|
default: ['LM2596S-ADJ'],
|
||||||
|
'x-control': 'combo',
|
||||||
|
'x-options': { ledger: 'parts.profile', value: 'internal_pn' },
|
||||||
|
},
|
||||||
|
tags: { type: 'array', items: { type: 'string' }, enum: ['a', 'b'], 'x-control': 'select' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('数组 + x-control:combo → combo 控件,占位与预填取第一颗,候选照读', async () => {
|
||||||
|
const params = await launchParamsOf(INPUT, async () => [{ value: 'LM2596S-ADJ', label: 'LM2596S-ADJ' }]);
|
||||||
|
expect(params[0]).toEqual({
|
||||||
|
key: 'candidateInternalPns',
|
||||||
|
label: '候选内部料号',
|
||||||
|
type: 'combo',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'XL2596S-5.0',
|
||||||
|
options: [{ value: 'LM2596S-ADJ', label: 'LM2596S-ADJ' }],
|
||||||
|
default: 'LM2596S-ADJ',
|
||||||
|
});
|
||||||
|
expect(params[1]).toMatchObject({ type: 'select', options: [{ value: 'a', label: 'a' }, { value: 'b', label: 'b' }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('提交单个字符串包成一元数组;传数组也照收;空的按必填拦;select 逐项对候选', async () => {
|
||||||
|
const specs = fieldSpecsOf(INPUT);
|
||||||
|
const params = await launchParamsOf(INPUT, noResolve);
|
||||||
|
expect(validateLaunchParams(params, { candidateInternalPns: 'XL2596S-5.0' }, specs)).toEqual({ ok: true, data: { candidateInternalPns: ['XL2596S-5.0'] } });
|
||||||
|
expect(validateLaunchParams(params, { candidateInternalPns: ['XL2596S-5.0', ' LM2596S-ADJ '] }, specs)).toEqual({ ok: true, data: { candidateInternalPns: ['XL2596S-5.0', 'LM2596S-ADJ'] } });
|
||||||
|
const empty = validateLaunchParams(params, { candidateInternalPns: ' ' }, specs);
|
||||||
|
expect(empty).toEqual({ ok: false, issues: [{ key: 'candidateInternalPns', message: '必填' }] });
|
||||||
|
const off = validateLaunchParams(params, { candidateInternalPns: 'x', tags: ['a', 'z'] }, specs);
|
||||||
|
expect(off).toEqual({ ok: false, issues: [{ key: 'tags', message: '不在候选里(单选只能选候选)' }] });
|
||||||
|
expect(validateLaunchParams(params, { candidateInternalPns: 'x', tags: 'b' }, specs)).toEqual({ ok: true, data: { candidateInternalPns: ['x'], tags: ['b'] } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateLaunchParams', () => {
|
||||||
|
it('必填空 / 未知键 / 类型不符 / select 不在候选 各点名一格;combo 候选之外合法', async () => {
|
||||||
|
const specs = fieldSpecsOf(W05_INPUT);
|
||||||
|
const params = await launchParamsOf(W05_INPUT, async () => [{ value: 'CU-0142', label: 'Itelma' }]);
|
||||||
|
const bad = validateLaunchParams(params, { customerCode: 'CU-NEW', requestDescription: '', candidateInternalPns: [' '], oops: 'x' }, specs);
|
||||||
|
expect(bad.ok).toBe(false);
|
||||||
|
if (!bad.ok) {
|
||||||
|
expect(bad.issues.map((i) => i.key).sort()).toEqual(['candidateInternalPns', 'oops', 'originalInternalPn', 'requestDescription']);
|
||||||
|
}
|
||||||
|
const typed = validateLaunchParams(params, { customerCode: ['CU-1'], requestDescription: 'x', originalInternalPn: 'p', candidateInternalPns: 'PN-1' }, specs);
|
||||||
|
expect(typed.ok).toBe(false);
|
||||||
|
// 标量格传数组是错;list 格传单个字符串**照收**(包成一元数组)
|
||||||
|
if (!typed.ok) expect(typed.issues).toEqual([{ key: 'customerCode', message: '要传字符串' }]);
|
||||||
|
const wrapped = validateLaunchParams(params, { customerCode: 'CU-1', requestDescription: 'x', originalInternalPn: 'p', candidateInternalPns: ' PN-1 ' }, specs);
|
||||||
|
expect(wrapped).toMatchObject({ ok: true, data: { candidateInternalPns: ['PN-1'] } });
|
||||||
|
|
||||||
|
const good = validateLaunchParams(
|
||||||
|
params,
|
||||||
|
{ customerCode: 'CU-NEW', customerName: '', requestDescription: ' 客户要求验证替代料 ', originalInternalPn: 'LM2596S-5.0', candidateInternalPns: ['XL2596S-5.0', '', ' LM2596S-ADJ '] },
|
||||||
|
specs,
|
||||||
|
);
|
||||||
|
expect(good).toEqual({
|
||||||
|
ok: true,
|
||||||
|
// 可选空串不落入参;list 逐项去空白、丢空项;combo 候选之外原样收
|
||||||
|
data: { customerCode: 'CU-NEW', requestDescription: '客户要求验证替代料', originalInternalPn: 'LM2596S-5.0', candidateInternalPns: ['XL2596S-5.0', 'LM2596S-ADJ'] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('select 只认候选;整数按清单类型转回数字', async () => {
|
||||||
|
const specs = fieldSpecsOf(E04_INPUT);
|
||||||
|
const params = await launchParamsOf(E04_INPUT, noResolve);
|
||||||
|
const bad = validateLaunchParams(params, { trigger: 'weekly' }, specs);
|
||||||
|
expect(bad.ok).toBe(false);
|
||||||
|
if (!bad.ok) expect(bad.issues[0]).toMatchObject({ key: 'trigger', message: expect.stringContaining('候选') });
|
||||||
|
expect(validateLaunchParams(params, { trigger: 'event', budget: '12' }, specs)).toEqual({ ok: true, data: { trigger: 'event', budget: 12 } });
|
||||||
|
const nan = validateLaunchParams(params, { trigger: 'event', budget: '1.5' }, specs);
|
||||||
|
expect(nan.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,528 @@
|
|||||||
|
/**
|
||||||
|
* 活动模块用例:投影 / 启动(幂等三档)/ 实例清单与详情 / 终止 / 禁用。
|
||||||
|
* 依赖全是内存字面量 —— 服务只声明用到的那几面,不用造整本清单。
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { LaunchInput, TaskId } from '@contracts';
|
||||||
|
import { staffIdSchema, taskIdSchema } from '@contracts';
|
||||||
|
import type { ActivityCard } from '@modules/activity-registry';
|
||||||
|
import { parseCmmn } from '@modules/loop-engine';
|
||||||
|
import type { ItemRuntime } from '@modules/loop-engine';
|
||||||
|
import { StaffNotFoundError } from '@modules/org';
|
||||||
|
import type { StartTaskInput } from '@modules/task';
|
||||||
|
import { createActivityOps, LAUNCH_CLAIM_STALE_MS } from '../activity-ops/service.ts';
|
||||||
|
import type { ActivityOpsDeps, HistoryEntry, LoopFace, ManifestFace } from '../activity-ops/service.ts';
|
||||||
|
import type { ActivityOpsStore, ActivityProfile, LaunchClaim, TaskRow } from '../activity-ops/store.ts';
|
||||||
|
|
||||||
|
const W05 = 'part-substitution-assessment';
|
||||||
|
const OWNER = staffIdSchema.parse('s-owner');
|
||||||
|
const ME = { id: staffIdSchema.parse('s-me'), name: '冯兵' };
|
||||||
|
|
||||||
|
const XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL" id="d">
|
||||||
|
<case id="c" name="试"><casePlanModel id="p" name="p">
|
||||||
|
<humanTask id="S1" name="归一"/>
|
||||||
|
<humanTask id="S2" name="交人确认"><itemControl><manualActivationRule id="m"/></itemControl></humanTask>
|
||||||
|
<humanTask id="T_release" name="放行"/>
|
||||||
|
</casePlanModel></case>
|
||||||
|
</definitions>`;
|
||||||
|
|
||||||
|
const card = (over: Partial<ActivityCard> = {}): ActivityCard => ({
|
||||||
|
activityId: W05,
|
||||||
|
name: 'ACT-WORK-05 · 差异评估',
|
||||||
|
category: '需求分析与方案匹配',
|
||||||
|
packageVersions: [
|
||||||
|
{ activityId: W05, version: '1.4.9', installedAt: '2026-08-26T01:00:00.000Z', state: 'active' },
|
||||||
|
{ activityId: W05, version: '1.5.0', installedAt: '2026-08-27T01:00:00.000Z', state: 'active' },
|
||||||
|
],
|
||||||
|
assetVersions: [],
|
||||||
|
enabled: true,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const manifest = (over: Partial<ManifestFace> = {}): ManifestFace => ({
|
||||||
|
identity: { name: 'ACT-WORK-05 · 差异评估' },
|
||||||
|
contract: {
|
||||||
|
input: {
|
||||||
|
data: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['customerCode', 'requestDescription', 'candidateInternalPns'],
|
||||||
|
properties: {
|
||||||
|
customerCode: { type: 'string', title: '客户', 'x-control': 'combo', 'x-options': { ledger: 'customer.profile', value: 'customer_code', label: 'name' } },
|
||||||
|
requestDescription: { type: 'string', title: '客户需求', 'x-control': 'textarea' },
|
||||||
|
candidateInternalPns: { type: 'array', title: '候选料号', items: { type: 'string' } },
|
||||||
|
datasheets: { type: 'array', format: 'file', items: { type: 'object' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
declarations: {
|
||||||
|
models: ['deepseek-v4-flash'],
|
||||||
|
ledger: [
|
||||||
|
{ domain: 'sub_eval', object: 'verdict', fields: [{ name: 'eval_ref' }, { name: 'request_id' }] },
|
||||||
|
{ domain: 'parts', object: 'profile', fields: [{ name: 'internal_pn' }, { name: 'lifecycle_status', tally: ['active', 'nrnd', 'eol'] }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
assets: { viewTemplates: [{ name: 'activity-ledger', html: '<html><body><h1>一览</h1></body></html>' }] },
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 一张带取数声明的看板模版:案号 → 核对表 → 料号圈主数据,页头摆实例与案号 */
|
||||||
|
const BOARD_HTML = [
|
||||||
|
'<html><body><div class="page" data-class-of="board:state">',
|
||||||
|
'<p><b data-slot="case:instanceNo"></b> · <i data-slot="case:ref"></i> · <u data-slot="case:owner"></u></p>',
|
||||||
|
'<p>需求 <span data-slot="sub_eval.verdict.request_id"></span> · 料号 <span data-slot="parts_profile_rows"></span> 条 · NRND <span data-slot="parts_profile_lifecycle_status_nrnd"></span></p>',
|
||||||
|
'<table><tbody data-rows="parts.profile"><tr><td data-slot="internal_pn"></td><td><span class="st" data-class-of="lifecycle_status"></span></td></tr></tbody></table>',
|
||||||
|
'</div></body></html>',
|
||||||
|
].join('');
|
||||||
|
const BOARD_DATA = {
|
||||||
|
case: { ledger: 'sub_eval.verdict', key: 'eval_ref', prefix: 'W05-' },
|
||||||
|
rows: [
|
||||||
|
{ as: 'sub_eval.verdict', ledger: 'sub_eval.verdict', by: { column: 'eval_ref', from: 'case' } },
|
||||||
|
{ as: 'sub_eval.pn_check', ledger: 'sub_eval.pn_check', by: { column: 'eval_ref', from: 'case' } },
|
||||||
|
{ as: 'parts.profile', ledger: 'parts.profile', by: { column: 'internal_pn', from: 'sub_eval.pn_check.internal_pn' } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const boardManifest = (): ManifestFace => manifest({ assets: { viewTemplates: [{ name: 'activity-ledger', html: BOARD_HTML, data: BOARD_DATA }] } });
|
||||||
|
/** 正式表的一角:两单的行并排躺着,看板只许圈出案号对上的那一单 */
|
||||||
|
const FORMAL: Record<string, Array<Record<string, unknown>>> = {
|
||||||
|
'sub_eval.verdict': [
|
||||||
|
{ eval_ref: 'W05-20260827100000', request_id: 'REQ-20260827100000' },
|
||||||
|
{ eval_ref: 'W05-20260827000000', request_id: 'REQ-20260827000000' },
|
||||||
|
],
|
||||||
|
'sub_eval.pn_check': [
|
||||||
|
{ eval_ref: 'W05-20260827100000', internal_pn: 'LM2596S-5.0' },
|
||||||
|
{ eval_ref: 'W05-20260827100000', internal_pn: 'XL2596S-5.0' },
|
||||||
|
{ eval_ref: 'W05-20260827000000', internal_pn: 'OTHER' },
|
||||||
|
],
|
||||||
|
'parts.profile': [
|
||||||
|
{ internal_pn: 'LM2596S-5.0', lifecycle_status: 'nrnd' },
|
||||||
|
{ internal_pn: 'XL2596S-5.0', lifecycle_status: 'active' },
|
||||||
|
{ internal_pn: 'OTHER', lifecycle_status: 'active' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const formalLedger: ActivityOpsDeps['ledger'] = {
|
||||||
|
describe: async (address) => (FORMAL[`${address.domain}.${address.object}`] === undefined ? undefined : { columns: [] }),
|
||||||
|
query: async (q) => ({
|
||||||
|
rows: (FORMAL[`${q.address.domain}.${q.address.object}`] ?? []).filter((r) => Object.entries(q.where).every(([k, v]) => String(r[k]) === String(v))),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
/** 本地时区里 2026-08-27 10:00:00 起的单 —— 案号 W05-20260827100000 */
|
||||||
|
const RELEASED_AT = new Date(2026, 7, 27, 10, 0, 0).toISOString();
|
||||||
|
|
||||||
|
const row = (over: Partial<TaskRow> = {}): TaskRow => ({
|
||||||
|
id: 't-1',
|
||||||
|
no: 'INS-7',
|
||||||
|
activityId: W05,
|
||||||
|
workflowId: null,
|
||||||
|
origin: 'ops',
|
||||||
|
ownerId: OWNER,
|
||||||
|
title: 'ACT-WORK-05 · 差异评估',
|
||||||
|
runtime: 'running',
|
||||||
|
terminal: null,
|
||||||
|
reason: null,
|
||||||
|
envelope: { packageVersion: '1.5.0' },
|
||||||
|
data: { customerCode: 'CU-0142', requestDescription: '工业电源项目在用 LM2596S-5.0', candidateInternalPns: ['XL2596S-5.0'], datasheets: [{ name: 'a.pdf', contentType: 'application/pdf', size: 3, ref: 'u-1' }] },
|
||||||
|
usedSteps: 2,
|
||||||
|
startedAt: '2026-08-27T02:00:00.000Z',
|
||||||
|
finishedAt: null,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
function memoryStore(rows: TaskRow[] = [], profiles: ActivityProfile[] = []) {
|
||||||
|
const claims = new Map<string, LaunchClaim>();
|
||||||
|
const profileMap = new Map(profiles.map((p) => [p.activityId, p]));
|
||||||
|
const store: ActivityOpsStore = {
|
||||||
|
async countByActivity() {
|
||||||
|
const m = new Map<string, number>();
|
||||||
|
for (const r of rows) m.set(r.activityId, (m.get(r.activityId) ?? 0) + 1);
|
||||||
|
return m;
|
||||||
|
},
|
||||||
|
async listTasks(activityId) {
|
||||||
|
return rows.filter((r) => r.activityId === activityId);
|
||||||
|
},
|
||||||
|
async getTask(activityId, taskId) {
|
||||||
|
return rows.find((r) => r.activityId === activityId && r.id === taskId);
|
||||||
|
},
|
||||||
|
async latestReleased(activityId) {
|
||||||
|
return rows
|
||||||
|
.filter((r) => r.activityId === activityId && r.terminal === 'released')
|
||||||
|
.sort((a, b) => Date.parse(b.finishedAt ?? b.startedAt) - Date.parse(a.finishedAt ?? a.startedAt))[0];
|
||||||
|
},
|
||||||
|
async claimLaunch(input) {
|
||||||
|
const hit = claims.get(input.key);
|
||||||
|
if (hit !== undefined) return hit;
|
||||||
|
claims.set(input.key, { ...input, taskId: null, claimedAt: clock.now().toISOString() });
|
||||||
|
return 'claimed';
|
||||||
|
},
|
||||||
|
async reclaimLaunch(key, staleMs, ownerId, launchedBy) {
|
||||||
|
const hit = claims.get(key);
|
||||||
|
if (hit === undefined || hit.taskId !== null || clock.now().getTime() - Date.parse(hit.claimedAt) < staleMs) return false;
|
||||||
|
claims.set(key, { ...hit, ownerId, launchedBy, claimedAt: clock.now().toISOString() });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
async bindLaunch(key, taskId) {
|
||||||
|
const hit = claims.get(key);
|
||||||
|
if (hit !== undefined) claims.set(key, { ...hit, taskId });
|
||||||
|
},
|
||||||
|
async launchOf(taskId) {
|
||||||
|
return [...claims.values()].find((c) => c.taskId === taskId);
|
||||||
|
},
|
||||||
|
async profilesOf() {
|
||||||
|
return profileMap;
|
||||||
|
},
|
||||||
|
async upsertProfile(p) {
|
||||||
|
profileMap.set(p.activityId, p);
|
||||||
|
},
|
||||||
|
async workflowOf() {
|
||||||
|
return { subject: '统一报价 · Itelma', source: { kind: 'event' } };
|
||||||
|
},
|
||||||
|
async inboundCommOf(taskId) {
|
||||||
|
return taskId === 't-mail' ? { channel: 'mail', party: 'quotes@itelma.ru', subject: 'RFQ' } : undefined;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const clock = { at: Date.parse('2026-08-27T03:00:00.000Z'), now: () => new Date(clock.at) };
|
||||||
|
return { store, claims, clock, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
function harness(
|
||||||
|
opts: {
|
||||||
|
rows?: TaskRow[];
|
||||||
|
profiles?: ActivityProfile[];
|
||||||
|
enabled?: boolean;
|
||||||
|
history?: Record<string, HistoryEntry[]>;
|
||||||
|
loops?: Record<string, LoopFace>;
|
||||||
|
manifest?: ManifestFace;
|
||||||
|
ledger?: ActivityOpsDeps['ledger'];
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const mem = memoryStore(opts.rows ?? [], opts.profiles ?? []);
|
||||||
|
const started: StartTaskInput[] = [];
|
||||||
|
const controlled: Array<{ taskId: TaskId; reason: string | undefined }> = [];
|
||||||
|
let enabled = opts.enabled ?? true;
|
||||||
|
let n = 0;
|
||||||
|
const staff = [
|
||||||
|
{ id: OWNER, name: '柴浩', state: 'active' },
|
||||||
|
{ id: ME.id, name: ME.name, state: 'active' },
|
||||||
|
{ id: 's-gone', name: '离职者', state: 'disabled' },
|
||||||
|
];
|
||||||
|
const deps: ActivityOpsDeps = {
|
||||||
|
registry: {
|
||||||
|
list: async () => [card({ enabled })],
|
||||||
|
get: async (id) => (id === W05 ? card({ enabled }) : undefined),
|
||||||
|
manifestOf: async () => opts.manifest ?? manifest(),
|
||||||
|
setEnabled: async (_id, on) => {
|
||||||
|
enabled = on;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
task: {
|
||||||
|
start: async (input) => {
|
||||||
|
started.push(input);
|
||||||
|
n += 1;
|
||||||
|
const id = taskIdSchema.parse(`t-new-${n}`);
|
||||||
|
mem.rows.push(row({ id, no: `INS-${100 + n}`, ownerId: input.ownerId, data: input.data, usedSteps: 0, origin: input.origin ?? null }));
|
||||||
|
return { id };
|
||||||
|
},
|
||||||
|
control: async (taskId, _semantic, _by, reason) => {
|
||||||
|
controlled.push({ taskId, reason });
|
||||||
|
const at = mem.rows.findIndex((r) => r.id === taskId);
|
||||||
|
const hit = mem.rows[at];
|
||||||
|
if (hit !== undefined) mem.rows.splice(at, 1, { ...hit, terminal: 'terminated', reason: reason ?? null, finishedAt: mem.clock.now().toISOString() });
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
org: {
|
||||||
|
listStaff: async () => ({ staff }),
|
||||||
|
getStaff: async (id) => {
|
||||||
|
const hit = staff.find((s) => s.id === id);
|
||||||
|
if (hit === undefined) throw new StaffNotFoundError(id);
|
||||||
|
return hit;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
memory: { history: async (taskId) => opts.history?.[taskId] ?? [] },
|
||||||
|
ledger: opts.ledger ?? {
|
||||||
|
describe: async (address) => (address.object === 'profile' ? { columns: [{ name: 'customer_code' }, { name: 'name' }] } : undefined),
|
||||||
|
query: async () => ({ rows: [{ customer_code: 'CU-0142', name: 'Itelma' }, { customer_code: 'CU-0001', name: '' }] }),
|
||||||
|
},
|
||||||
|
loopState: { load: async (taskId) => opts.loops?.[taskId] },
|
||||||
|
store: mem.store,
|
||||||
|
files: {
|
||||||
|
readUpload: async (ref) => (ref === 'u-1' ? { name: 'a.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2, 3]) } : undefined),
|
||||||
|
listArchived: async (taskId) => (taskId === 't-done' ? [{ address: 'content.asset', key: { id: '1' }, format: 'docx', name: '报告.docx', size: 9 }] : []),
|
||||||
|
readArchived: async () => ({ bytes: new Uint8Array([9]), name: '报告.docx', format: 'docx' }),
|
||||||
|
},
|
||||||
|
clock: mem.clock,
|
||||||
|
warn: () => undefined,
|
||||||
|
};
|
||||||
|
return { ops: createActivityOps(deps), mem, started, controlled };
|
||||||
|
}
|
||||||
|
|
||||||
|
const launchInput = (over: Partial<LaunchInput> = {}): LaunchInput => ({
|
||||||
|
idempotencyKey: 'k-1',
|
||||||
|
ownerId: OWNER,
|
||||||
|
params: { customerCode: 'CU-0142', requestDescription: '验证替代料', candidateInternalPns: ['XL2596S-5.0'] },
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('活动投影', () => {
|
||||||
|
it('清单:版本取最新一版、创建时刻取最早一版、负责人与图标来自档案、实例按活动数;status 过滤', async () => {
|
||||||
|
const { ops } = harness({
|
||||||
|
rows: [row(), row({ id: 't-2', no: 'INS-8' })],
|
||||||
|
profiles: [{ activityId: W05, ownerId: OWNER, description: '逐项差异评估', ico: 'git-compare', hue: 'teal' }],
|
||||||
|
});
|
||||||
|
const [a] = await ops.listActivities();
|
||||||
|
expect(a).toEqual({
|
||||||
|
id: W05,
|
||||||
|
name: 'ACT-WORK-05 · 差异评估',
|
||||||
|
biz: '需求分析与方案匹配',
|
||||||
|
status: 'active',
|
||||||
|
version: '1.5.0',
|
||||||
|
owner: { id: OWNER, name: '柴浩' },
|
||||||
|
llm: 'deepseek-v4-flash',
|
||||||
|
instanceCount: 2,
|
||||||
|
createdAt: '2026-08-26T01:00:00.000Z',
|
||||||
|
ico: 'git-compare',
|
||||||
|
hue: 'teal',
|
||||||
|
});
|
||||||
|
expect(await ops.listActivities('disabled')).toEqual([]);
|
||||||
|
const detail = await ops.getActivity(W05);
|
||||||
|
expect(detail).toMatchObject({ desc: '逐项差异评估', view: { url: `/api/gov/activities/${W05}/view` } });
|
||||||
|
expect(detail).not.toHaveProperty('instanceCount');
|
||||||
|
expect(await ops.getActivity('nope')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('没建档:负责人 null、说明空串、图标色相缺席;色相不在四色里也不摆', async () => {
|
||||||
|
const { ops } = harness({ profiles: [{ activityId: W05, ownerId: null, description: '', ico: null, hue: 'pink' }] });
|
||||||
|
const [a] = await ops.listActivities();
|
||||||
|
expect(a).toMatchObject({ owner: null, instanceCount: 0 });
|
||||||
|
expect(a).not.toHaveProperty('ico');
|
||||||
|
expect(a).not.toHaveProperty('hue');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('禁用 / 激活幂等,回活动详情;视图注入报高脚本、没配视图 404', async () => {
|
||||||
|
const { ops } = harness();
|
||||||
|
expect((await ops.setEnabled(W05, false))?.status).toBe('disabled');
|
||||||
|
expect((await ops.setEnabled(W05, false))?.status).toBe('disabled');
|
||||||
|
expect((await ops.setEnabled(W05, true))?.status).toBe('active');
|
||||||
|
expect(await ops.setEnabled('nope', true)).toBeUndefined();
|
||||||
|
const view = await ops.viewHtml(W05);
|
||||||
|
expect(view.kind).toBe('html');
|
||||||
|
if (view.kind === 'html') expect(view.html).toContain('jcai:view-height');
|
||||||
|
expect((await ops.viewHtml('nope')).kind).toBe('no-activity');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('沙箱看板:最近一次已放行实例的落账快照', () => {
|
||||||
|
const released = (over: Partial<TaskRow> = {}) =>
|
||||||
|
row({ id: 't-rel', no: 'INS-20', runtime: 'terminal', terminal: 'released', startedAt: RELEASED_AT, finishedAt: new Date(2026, 7, 27, 10, 6, 0).toISOString(), ...over });
|
||||||
|
|
||||||
|
it('按案号只圈这一单的行:页头实例号 / 案号 / 负责人,料号两颗、别单的 OTHER 不进来,徽标按值投影,读数摆出来', async () => {
|
||||||
|
const older = released({ id: 't-old', no: 'INS-3', startedAt: new Date(2026, 7, 27, 0, 0, 0).toISOString(), finishedAt: new Date(2026, 7, 27, 0, 5, 0).toISOString() });
|
||||||
|
const { ops } = harness({ rows: [row({ id: 't-live' }), older, released()], manifest: boardManifest(), ledger: formalLedger });
|
||||||
|
const view = await ops.viewHtml(W05);
|
||||||
|
expect(view.kind).toBe('html');
|
||||||
|
if (view.kind !== 'html') return;
|
||||||
|
expect(view.html).toContain('class="page v-ready"');
|
||||||
|
expect(view.html).toContain('<b>INS-20</b> · <i>W05-20260827100000</i> · <u>柴浩</u>');
|
||||||
|
expect(view.html).toContain('需求 <span>REQ-20260827100000</span> · 料号 <span>2</span> 条 · NRND <span>1</span>');
|
||||||
|
expect(view.html).toContain('<td>LM2596S-5.0</td><td><span class="st v-nrnd"></span></td>');
|
||||||
|
expect(view.html).toContain('<td>XL2596S-5.0</td><td><span class="st v-active"></span></td>');
|
||||||
|
expect(view.html).not.toContain('OTHER');
|
||||||
|
expect(view.html).not.toContain('data-rows=');
|
||||||
|
expect(view.html).toContain('jcai:view-height');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('没放行过 → none 态、实例事实空着;案号在正式表里查无 → missing 态、表全空(不摆别单的行)', async () => {
|
||||||
|
const none = harness({ rows: [row({ id: 't-live' })], manifest: boardManifest(), ledger: formalLedger });
|
||||||
|
const v1 = await none.ops.viewHtml(W05);
|
||||||
|
if (v1.kind === 'html') {
|
||||||
|
expect(v1.html).toContain('class="page v-none"');
|
||||||
|
expect(v1.html).toContain('<b>—</b>');
|
||||||
|
expect(v1.html).toContain('料号 <span>0</span>');
|
||||||
|
} else expect.unreachable();
|
||||||
|
|
||||||
|
const missing = harness({ rows: [released({ startedAt: new Date(2026, 7, 27, 11, 0, 0).toISOString() })], manifest: boardManifest(), ledger: formalLedger });
|
||||||
|
const v2 = await missing.ops.viewHtml(W05);
|
||||||
|
if (v2.kind === 'html') {
|
||||||
|
expect(v2.html).toContain('class="page v-missing"');
|
||||||
|
expect(v2.html).toContain('W05-20260827110000');
|
||||||
|
expect(v2.html).not.toContain('LM2596S-5.0');
|
||||||
|
} else expect.unreachable();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('一张表读不了不拖垮整屏:那一块空着,别的照摆;模版没带 data 就原样出', async () => {
|
||||||
|
const half: ActivityOpsDeps['ledger'] = { ...formalLedger, describe: async (a) => (a.object === 'profile' ? undefined : formalLedger.describe(a)) };
|
||||||
|
const { ops } = harness({ rows: [released()], manifest: boardManifest(), ledger: half });
|
||||||
|
const v = await ops.viewHtml(W05);
|
||||||
|
if (v.kind === 'html') {
|
||||||
|
expect(v.html).toContain('class="page v-ready"');
|
||||||
|
expect(v.html).toContain('需求 <span>REQ-20260827100000</span> · 料号 <span>0</span>');
|
||||||
|
} else expect.unreachable();
|
||||||
|
const plain = harness({ rows: [released()] });
|
||||||
|
const p = await plain.ops.viewHtml(W05);
|
||||||
|
if (p.kind === 'html') expect(p.html).toContain('<h1>一览</h1>');
|
||||||
|
else expect.unreachable();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('启动', () => {
|
||||||
|
it('参数表:combo 候选从台账读(空标签退回值);文件字段不上表', async () => {
|
||||||
|
const { ops } = harness();
|
||||||
|
const table = await ops.launchParams(W05);
|
||||||
|
expect(table?.version).toBe('1.5.0');
|
||||||
|
expect(table?.params.map((p) => p.key)).toEqual(['customerCode', 'requestDescription', 'candidateInternalPns']);
|
||||||
|
expect(table?.params[0]?.options).toEqual([{ value: 'CU-0142', label: 'Itelma' }, { value: 'CU-0001', label: 'CU-0001' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('禁用 412 / 责任人不在职 400 / 参数不合 400 —— 都不起单', async () => {
|
||||||
|
const { ops, started } = harness({ enabled: false });
|
||||||
|
expect((await ops.launch(W05, launchInput(), ME)).kind).toBe('disabled');
|
||||||
|
const live = harness();
|
||||||
|
expect(await live.ops.launch(W05, launchInput({ ownerId: staffIdSchema.parse('s-gone') }), ME)).toMatchObject({ kind: 'owner-invalid' });
|
||||||
|
expect(await live.ops.launch(W05, launchInput({ ownerId: staffIdSchema.parse('s-nobody') }), ME)).toMatchObject({ kind: 'owner-invalid', message: '责任人不在名册里' });
|
||||||
|
const bad = await live.ops.launch(W05, launchInput({ params: { customerCode: 'CU-0142' } }), ME);
|
||||||
|
expect(bad.kind).toBe('invalid');
|
||||||
|
if (bad.kind === 'invalid') expect(bad.issues.map((i) => i.key)).toEqual(['requestDescription', 'candidateInternalPns']);
|
||||||
|
expect((await live.ops.launch('nope', launchInput(), ME)).kind).toBe('not-found');
|
||||||
|
expect(started).toEqual([]);
|
||||||
|
expect(live.started).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('起单:origin=ops、标题取清单名、入参归一后交 task.start;同键重放回同一单、不起第二个', async () => {
|
||||||
|
const { ops, started } = harness();
|
||||||
|
const first = await ops.launch(W05, launchInput(), ME);
|
||||||
|
expect(first.kind).toBe('ok');
|
||||||
|
if (first.kind !== 'ok') return;
|
||||||
|
expect(first.created).toBe(true);
|
||||||
|
expect(started).toEqual([
|
||||||
|
{ activityId: W05, ownerId: OWNER, title: 'ACT-WORK-05 · 差异评估', origin: 'ops', data: { customerCode: 'CU-0142', requestDescription: '验证替代料', candidateInternalPns: ['XL2596S-5.0'] } },
|
||||||
|
]);
|
||||||
|
expect(first.detail).toMatchObject({ id: 't-new-1', state: 'pending', owner: { id: OWNER, name: '柴浩' }, input: { source: { kind: 'manual', label: '手动启动 · 冯兵' } } });
|
||||||
|
|
||||||
|
const again = await ops.launch(W05, launchInput({ params: { customerCode: 'X', requestDescription: 'y', candidateInternalPns: ['z'] } }), ME);
|
||||||
|
expect(again).toMatchObject({ kind: 'ok', created: false, detail: { id: 't-new-1' } });
|
||||||
|
expect(started).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('键占了单还没建成:60 秒内 409;过期后重占再起', async () => {
|
||||||
|
const h = harness();
|
||||||
|
await h.mem.store.claimLaunch({ key: 'k-1', activityId: W05, ownerId: OWNER, launchedBy: 's-x' });
|
||||||
|
expect(await h.ops.launch(W05, launchInput(), ME)).toMatchObject({ kind: 'busy' });
|
||||||
|
h.mem.clock.at += LAUNCH_CLAIM_STALE_MS + 1;
|
||||||
|
const late = await h.ops.launch(W05, launchInput(), ME);
|
||||||
|
expect(late.kind).toBe('ok');
|
||||||
|
expect(h.started).toHaveLength(1);
|
||||||
|
expect(h.mem.claims.get('k-1')?.taskId).toBe('t-new-1');
|
||||||
|
// 别的活动拿同一枚键 → 409
|
||||||
|
await h.mem.store.claimLaunch({ key: 'k-2', activityId: 'other', ownerId: OWNER, launchedBy: 's-x' });
|
||||||
|
expect(await h.ops.launch(W05, launchInput({ idempotencyKey: 'k-2' }), ME)).toMatchObject({ kind: 'busy' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('实例清单与详情', () => {
|
||||||
|
const items = parseCmmn(XML).items;
|
||||||
|
const rt = (states: Record<string, ItemRuntime['state']>): ItemRuntime[] => Object.entries(states).map(([id, state]) => ({ id, state, instance: 1 }));
|
||||||
|
const loop = (states: Record<string, ItemRuntime['state']>, pending?: LoopFace['pending']): LoopFace => ({
|
||||||
|
model: { steps: [{ item: 'S1', intent: '把四项绑定' }] },
|
||||||
|
parsed: { items },
|
||||||
|
runtime: { items: rt(states) },
|
||||||
|
...(pending === undefined ? {} : { pending }),
|
||||||
|
});
|
||||||
|
const line = (kind: string, title: string, at: string, detail?: Record<string, unknown>): HistoryEntry => ({ kind, title, at, ...(detail === undefined ? {} : { detail }) });
|
||||||
|
|
||||||
|
const rows = [
|
||||||
|
row({ id: 't-1', no: 'INS-7' }),
|
||||||
|
row({ id: 't-redo', no: 'INS-6', startedAt: '2026-08-26T02:00:00.000Z' }),
|
||||||
|
row({ id: 't-done', no: 'INS-5', terminal: 'released', runtime: 'terminal', finishedAt: '2026-08-25T03:00:00.000Z', startedAt: '2026-08-25T02:00:00.000Z' }),
|
||||||
|
row({ id: 't-dead', no: 'INS-4', terminal: 'terminated', reason: '由 冯兵 手动终止', finishedAt: '2026-08-24T02:30:00.000Z', startedAt: '2026-08-24T02:00:00.000Z', ownerId: ME.id }),
|
||||||
|
row({ id: 't-mail', no: 'INS-3', origin: null, usedSteps: 0, data: {}, startedAt: '2026-08-23T02:00:00.000Z' }),
|
||||||
|
];
|
||||||
|
const history = {
|
||||||
|
't-redo': [line('release-verdict', '裁决:redo', '2026-08-26T03:00:00.000Z', { by: OWNER, comment: '证据不足' })],
|
||||||
|
't-done': [line('release-verdict', '落地收敛,发射输出口:assessment-released', '2026-08-25T03:00:00.000Z', { ports: ['assessment-released'] })],
|
||||||
|
't-1': [line('engine-transition', '开始办:归一', '2026-08-27T02:00:10.000Z'), line('engine-transition', '完成:归一', '2026-08-27T02:01:10.000Z'), line('engine-transition', '开始办:交人确认', '2026-08-27T02:01:20.000Z')],
|
||||||
|
};
|
||||||
|
const loops = { 't-1': loop({ S1: 'Completed', S2: 'Active' }), 't-redo': loop({ S1: 'Completed', S2: 'Completed', T_release: 'Completed' }) };
|
||||||
|
|
||||||
|
it('五态各归其位;统计不随筛选动;产出只给已完成的;分页按筛后走', async () => {
|
||||||
|
const { ops } = harness({ rows, history, loops });
|
||||||
|
const all = await ops.listInstances(W05, { page: 1, size: 8 });
|
||||||
|
expect(all?.stats).toEqual({ total: 5, byState: { pending: 1, running: 1, completed: 1, rejected: 1, failed: 1 } });
|
||||||
|
expect(all?.items.map((i) => [i.no, i.state, i.output])).toEqual([
|
||||||
|
['INS-7', 'running', null],
|
||||||
|
['INS-6', 'rejected', null],
|
||||||
|
['INS-5', 'completed', '报告.docx'],
|
||||||
|
['INS-4', 'failed', null],
|
||||||
|
['INS-3', 'pending', null],
|
||||||
|
]);
|
||||||
|
expect(all?.items[0]?.input).toBe('工业电源项目在用 LM2596S-5.0');
|
||||||
|
// 入参空的那单退回来件主题
|
||||||
|
expect(all?.items[4]?.input).toBe('RFQ');
|
||||||
|
const page2 = await ops.listInstances(W05, { page: 2, size: 2, ownerId: OWNER });
|
||||||
|
expect(page2).toMatchObject({ total: 4, page: 2, size: 2 });
|
||||||
|
expect(page2?.items.map((i) => i.no)).toEqual(['INS-5', 'INS-3']);
|
||||||
|
expect(page2?.stats.total).toBe(5);
|
||||||
|
expect((await ops.listInstances(W05, { page: 1, size: 8, id: 'ins-4' }))?.items.map((i) => i.no)).toEqual(['INS-4']);
|
||||||
|
expect(await ops.listInstances('nope', { page: 1, size: 8 })).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('详情:时间线 / 来源 / 文件 / 原因 / 用时', async () => {
|
||||||
|
const { ops } = harness({ rows, history, loops });
|
||||||
|
const live = await ops.instanceDetail(W05, 't-1');
|
||||||
|
expect(live).toMatchObject({
|
||||||
|
no: 'INS-7',
|
||||||
|
state: 'running',
|
||||||
|
reason: null,
|
||||||
|
endedAt: null,
|
||||||
|
durationMs: null,
|
||||||
|
llm: 'deepseek-v4-flash',
|
||||||
|
input: { content: '工业电源项目在用 LM2596S-5.0', source: { kind: 'manual', label: '手动启动 · 运营端' }, files: [{ index: 0, name: 'a.pdf', contentType: 'application/pdf', size: 3 }] },
|
||||||
|
output: { files: [] },
|
||||||
|
});
|
||||||
|
expect(live?.steps.map((s) => [s.name, s.by, s.byName, s.res, s.durationMs, s.note])).toEqual([
|
||||||
|
['归一', 'agent', 'deepseek-v4-flash', 'done', 60_000, '把四项绑定'],
|
||||||
|
['交人确认', 'human', '柴浩', 'current', null, ''],
|
||||||
|
['放行', 'agent', 'deepseek-v4-flash', 'pending', null, ''],
|
||||||
|
]);
|
||||||
|
expect(live).not.toHaveProperty('input.output');
|
||||||
|
|
||||||
|
const redo = await ops.instanceDetail(W05, 't-redo');
|
||||||
|
expect(redo?.reason).toEqual({ kind: 'rejected', text: '验收驳回:证据不足 —— 负责人 柴浩' });
|
||||||
|
expect(redo?.steps.map((s) => s.res)).toEqual(['done', 'done', 'fail']);
|
||||||
|
|
||||||
|
const dead = await ops.instanceDetail(W05, 't-dead');
|
||||||
|
expect(dead).toMatchObject({ state: 'failed', reason: { kind: 'terminated', text: '由 冯兵 手动终止' }, endedAt: '2026-08-24T02:30:00.000Z', durationMs: 30 * 60_000, owner: { name: '冯兵' } });
|
||||||
|
|
||||||
|
const done = await ops.instanceDetail(W05, 't-done');
|
||||||
|
expect(done?.output.files).toEqual([{ index: 0, name: '报告.docx', contentType: expect.stringContaining('wordprocessingml'), size: 9 }]);
|
||||||
|
|
||||||
|
const mail = await ops.instanceDetail(W05, 't-mail');
|
||||||
|
expect(mail?.input.source).toEqual({ kind: 'mail', label: '邮件 · quotes@itelma.ru' });
|
||||||
|
expect(await ops.instanceDetail(W05, 'nope')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('上游工作流起的单标 upstream;测试台起的标 testbed', async () => {
|
||||||
|
const { ops } = harness({ rows: [row({ id: 't-wf', workflowId: 'wf-1', origin: null }), row({ id: 't-tb', origin: 'testbed', data: {} })] });
|
||||||
|
expect((await ops.instanceDetail(W05, 't-wf'))?.input.source).toEqual({ kind: 'upstream', label: '上游 · 统一报价 · Itelma' });
|
||||||
|
expect((await ops.instanceDetail(W05, 't-tb'))?.input.source).toEqual({ kind: 'testbed', label: '测试台试跑' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('文件:input 走上传件、output 走存档;越界 / 无单各回明确结果', async () => {
|
||||||
|
const { ops } = harness({ rows });
|
||||||
|
expect(await ops.instanceFile(W05, 't-1', 'input', 0)).toMatchObject({ kind: 'file', name: 'a.pdf', contentType: 'application/pdf' });
|
||||||
|
expect((await ops.instanceFile(W05, 't-1', 'input', 1)).kind).toBe('no-file');
|
||||||
|
expect(await ops.instanceFile(W05, 't-done', 'output', 0)).toMatchObject({ kind: 'file', name: '报告.docx' });
|
||||||
|
expect((await ops.instanceFile(W05, 't-1', 'output', 0)).kind).toBe('no-file');
|
||||||
|
expect((await ops.instanceFile(W05, 'nope', 'output', 0)).kind).toBe('no-instance');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('终止:走 task.control,理由带人名;已终态 412;之后详情是 failed + terminated', async () => {
|
||||||
|
const { ops, controlled } = harness({ rows: [row({ id: 't-1' }), row({ id: 't-done', terminal: 'released', runtime: 'terminal' })] });
|
||||||
|
const done = await ops.terminate(W05, 't-1', ME);
|
||||||
|
expect(done).toMatchObject({ kind: 'ok', detail: { state: 'failed', reason: { kind: 'terminated', text: '由 冯兵 手动终止' } } });
|
||||||
|
expect(controlled).toEqual([{ taskId: 't-1', reason: '由 冯兵 手动终止' }]);
|
||||||
|
expect((await ops.terminate(W05, 't-done', ME)).kind).toBe('terminal');
|
||||||
|
expect((await ops.terminate(W05, 't-1', ME)).kind).toBe('terminal');
|
||||||
|
expect((await ops.terminate(W05, 'nope', ME)).kind).toBe('not-found');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/** L3 动作过程记录:计划项 × 运行态 × 引擎痕 */
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { parseCmmn } from '@modules/loop-engine';
|
||||||
|
import type { ItemRuntime } from '@modules/loop-engine';
|
||||||
|
import { stepsOf } from '../activity-ops/instance-steps.ts';
|
||||||
|
|
||||||
|
const XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL" id="d">
|
||||||
|
<case id="c" name="试">
|
||||||
|
<casePlanModel id="p" name="p">
|
||||||
|
<humanTask id="S1" name="归一"/>
|
||||||
|
<humanTask id="S2" name="交人确认">
|
||||||
|
<itemControl><manualActivationRule id="m_s2"/></itemControl>
|
||||||
|
</humanTask>
|
||||||
|
<humanTask id="S3" name="放行"/>
|
||||||
|
<milestone id="M1" name="里程碑"/>
|
||||||
|
</casePlanModel>
|
||||||
|
</case>
|
||||||
|
</definitions>`;
|
||||||
|
|
||||||
|
const rt = (states: Record<string, ItemRuntime['state']>): ItemRuntime[] => Object.entries(states).map(([id, state]) => ({ id, state, instance: 1 }));
|
||||||
|
const line = (title: string, at: string) => ({ kind: 'engine-transition', title, at });
|
||||||
|
const intents = new Map([['S1', '把来件归一成台账行'], ['S2', '派一张执行单交人']]);
|
||||||
|
|
||||||
|
describe('stepsOf', () => {
|
||||||
|
const items = parseCmmn(XML).items;
|
||||||
|
|
||||||
|
it('只摆任务项(里程碑不算),按文档序;人工激活的项是人办的;时刻与用时来自引擎痕', () => {
|
||||||
|
const steps = stepsOf({
|
||||||
|
items,
|
||||||
|
runtimeItems: rt({ S1: 'Completed', S2: 'Active', S3: 'Available' }),
|
||||||
|
intents,
|
||||||
|
history: [
|
||||||
|
line('开始办:归一', '2026-08-27T02:00:00.000Z'),
|
||||||
|
line('完成:归一', '2026-08-27T02:03:00.000Z'),
|
||||||
|
line('开始办:交人确认', '2026-08-27T02:03:30.000Z'),
|
||||||
|
],
|
||||||
|
state: 'running',
|
||||||
|
ownerName: '柴浩',
|
||||||
|
llm: 'deepseek-v4-flash',
|
||||||
|
});
|
||||||
|
expect(steps.map((s) => [s.seq, s.name, s.by, s.byName, s.res])).toEqual([
|
||||||
|
[1, '归一', 'agent', 'deepseek-v4-flash', 'done'],
|
||||||
|
[2, '交人确认', 'human', '柴浩', 'current'],
|
||||||
|
[3, '放行', 'agent', 'deepseek-v4-flash', 'pending'],
|
||||||
|
]);
|
||||||
|
expect(steps[0]).toMatchObject({ at: '2026-08-27T02:00:00.000Z', durationMs: 180_000, note: '把来件归一成台账行' });
|
||||||
|
expect(steps[1]).toMatchObject({ at: '2026-08-27T02:03:30.000Z', durationMs: null, note: '派一张执行单交人' });
|
||||||
|
expect(steps[2]).toMatchObject({ at: null, durationMs: null, note: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('自动收步的项拿收步那一刻当时刻;没有 runtime 的项是 pending', () => {
|
||||||
|
const steps = stepsOf({
|
||||||
|
items,
|
||||||
|
runtimeItems: rt({ S1: 'Completed' }),
|
||||||
|
intents,
|
||||||
|
history: [line('自动收步:S1 的成果已在台账里(rows > 0)—— 不必再问模型', '2026-08-27T02:01:00.000Z')],
|
||||||
|
state: 'running',
|
||||||
|
ownerName: '柴浩',
|
||||||
|
llm: 'm',
|
||||||
|
});
|
||||||
|
expect(steps[0]).toMatchObject({ res: 'done', at: '2026-08-27T02:01:00.000Z', durationMs: null });
|
||||||
|
expect(steps[1]?.res).toBe('pending');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('终止 / 失败:正在办的那一步记 fail;都没开就记第一个没到的;驳回记最后一个已完成的', () => {
|
||||||
|
const facts = { items, intents, history: [], ownerName: '柴浩', llm: 'm' };
|
||||||
|
expect(stepsOf({ ...facts, runtimeItems: rt({ S1: 'Completed', S2: 'Active' }), state: 'failed' }).map((s) => s.res)).toEqual(['done', 'fail', 'pending']);
|
||||||
|
expect(stepsOf({ ...facts, runtimeItems: rt({ S1: 'Terminated', S2: 'Terminated' }), state: 'failed' }).map((s) => s.res)).toEqual(['fail', 'pending', 'pending']);
|
||||||
|
expect(stepsOf({ ...facts, runtimeItems: rt({ S1: 'Completed', S2: 'Completed', S3: 'Completed' }), state: 'rejected' }).map((s) => s.res)).toEqual(['done', 'done', 'fail']);
|
||||||
|
expect(stepsOf({ ...facts, runtimeItems: rt({ S1: 'Failed' }), state: 'running' }).map((s) => s.res)).toEqual(['fail', 'pending', 'pending']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('重复实例化的项看最新那一实例', () => {
|
||||||
|
const steps = stepsOf({
|
||||||
|
items,
|
||||||
|
runtimeItems: [{ id: 'S2', state: 'Completed', instance: 1 }, { id: 'S2', state: 'Active', instance: 2 }],
|
||||||
|
intents,
|
||||||
|
history: [],
|
||||||
|
state: 'running',
|
||||||
|
ownerName: '柴浩',
|
||||||
|
llm: 'm',
|
||||||
|
});
|
||||||
|
expect(steps[1]?.res).toBe('current');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* 启动幂等键在真库上的并发:两条连接同时占同一枚键,**恰一个赢**;单没建成的过期键可被重占。
|
||||||
|
* 独占一个库(照 mail-store.it 的先例):建表 / 清表都在 `jcai_it_activity_ops` 里,谁的库都不碰。
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import type { Db } from '@shared/db.ts';
|
||||||
|
import { createActivityOpsStore } from '../activity-ops/store.ts';
|
||||||
|
|
||||||
|
const IT_DB = 'jcai_it_activity_ops';
|
||||||
|
|
||||||
|
async function provisionDatabase(): Promise<{ url: string; stop(): Promise<void> } | undefined> {
|
||||||
|
const base = process.env['JCAI_TEST_DATABASE_URL'] ?? process.env['DATABASE_URL'] ?? 'postgres://jcai:jcai@localhost:54329/jcai';
|
||||||
|
try {
|
||||||
|
const admin = new Pool({ connectionString: base, max: 1, connectionTimeoutMillis: 1500 });
|
||||||
|
const exists = await admin.query(`select 1 from pg_database where datname = $1`, [IT_DB]);
|
||||||
|
if (exists.rowCount === 0) await admin.query(`create database ${IT_DB}`);
|
||||||
|
await admin.end();
|
||||||
|
return { url: base.replace(/\/[^/]*$/, `/${IT_DB}`), stop: async () => undefined };
|
||||||
|
} catch {
|
||||||
|
/* 开发实例连不上,试 Testcontainers */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { PostgreSqlContainer } = await import('@testcontainers/postgresql');
|
||||||
|
const started = await new PostgreSqlContainer('postgres:16-alpine').start();
|
||||||
|
return { url: started.getConnectionUri(), stop: () => started.stop().then(() => undefined) };
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write(`⚠️ activity-ops store it 跳过:既连不上开发实例也起不了 Testcontainers(${error instanceof Error ? error.message : String(error)})\n`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const database = await provisionDatabase();
|
||||||
|
let poolA: Pool | undefined;
|
||||||
|
let poolB: Pool | undefined;
|
||||||
|
let dbA: Db;
|
||||||
|
let dbB: Db;
|
||||||
|
|
||||||
|
const MIGRATION = new URL('../../../../../db/migrations/0035_activity_ops.sql', import.meta.url);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
if (database === undefined) return;
|
||||||
|
poolA = new Pool({ connectionString: database.url, max: 2 });
|
||||||
|
poolB = new Pool({ connectionString: database.url, max: 2 });
|
||||||
|
dbA = drizzle(poolA);
|
||||||
|
dbB = drizzle(poolB);
|
||||||
|
await poolA.query('create schema if not exists sys');
|
||||||
|
await poolA.query('drop table if exists sys.activity_launch; drop table if exists sys.activity_profile');
|
||||||
|
await poolA.query(readFileSync(MIGRATION, 'utf8'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await poolA?.end();
|
||||||
|
await poolB?.end();
|
||||||
|
await database?.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(database === undefined)('activity_launch 幂等键(真库)', () => {
|
||||||
|
it('两条连接同时占同一枚键,恰一个 claimed,另一个读回那一行;绑定后重放拿到 task_id', async () => {
|
||||||
|
const a = createActivityOpsStore(dbA);
|
||||||
|
const b = createActivityOpsStore(dbB);
|
||||||
|
const input = { key: 'race-1', activityId: 'w05', ownerId: 'owner', launchedBy: 'me' };
|
||||||
|
const results = await Promise.all(Array.from({ length: 20 }, (_, i) => (i % 2 === 0 ? a : b).claimLaunch(input)));
|
||||||
|
expect(results.filter((r) => r === 'claimed')).toHaveLength(1);
|
||||||
|
for (const r of results) if (r !== 'claimed') expect(r).toMatchObject({ key: 'race-1', activityId: 'w05', taskId: null });
|
||||||
|
await a.bindLaunch('race-1', 't-1');
|
||||||
|
expect(await b.claimLaunch(input)).toMatchObject({ taskId: 't-1' });
|
||||||
|
expect(await b.launchOf('t-1')).toMatchObject({ key: 'race-1', launchedBy: 'me' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('单没建成的键:没过期不许重占;过期后恰一个重占成功;已绑 task 的永远不许', async () => {
|
||||||
|
const a = createActivityOpsStore(dbA);
|
||||||
|
const b = createActivityOpsStore(dbB);
|
||||||
|
expect(await a.claimLaunch({ key: 'stale-1', activityId: 'w05', ownerId: 'o', launchedBy: 'x' })).toBe('claimed');
|
||||||
|
expect(await b.reclaimLaunch('stale-1', 60_000, 'o', 'y')).toBe(false);
|
||||||
|
await dbA.execute(sql`update sys.activity_launch set claimed_at = now() - interval '2 minutes' where key = 'stale-1'`);
|
||||||
|
const won = await Promise.all([a.reclaimLaunch('stale-1', 60_000, 'o', 'y'), b.reclaimLaunch('stale-1', 60_000, 'o', 'z')]);
|
||||||
|
expect(won.filter(Boolean)).toHaveLength(1);
|
||||||
|
await a.bindLaunch('stale-1', 't-2');
|
||||||
|
await dbA.execute(sql`update sys.activity_launch set claimed_at = now() - interval '2 minutes' where key = 'stale-1'`);
|
||||||
|
expect(await b.reclaimLaunch('stale-1', 60_000, 'o', 'q')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('活动档案:upsert 冲突即更新,负责人可空', async () => {
|
||||||
|
const a = createActivityOpsStore(dbA);
|
||||||
|
await a.upsertProfile({ activityId: 'w05', ownerId: null, description: '一', ico: 'git-compare', hue: 'teal' });
|
||||||
|
await a.upsertProfile({ activityId: 'w05', ownerId: 'owner', description: '二', ico: 'git-compare', hue: 'teal' });
|
||||||
|
expect((await a.profilesOf()).get('w05')).toEqual({ activityId: 'w05', ownerId: 'owner', description: '二', ico: 'git-compare', hue: 'teal' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* 活动模块的 app 层存取:SQL 不求值,只看「几笔、带了什么」与行 → 视图的映射(时间戳归一、seq → 实例号)。
|
||||||
|
* 真库上的并发抢键在 `activity-ops-store.it.test.ts`。
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { createActivityOpsStore } from '../activity-ops/store.ts';
|
||||||
|
|
||||||
|
type Fake = { calls: string[]; execute: (query: unknown) => Promise<{ rows: unknown[] }> };
|
||||||
|
|
||||||
|
/** 每次 execute 按顺序吐一份 rows;SQL 片段拼成字符串留着断言 */
|
||||||
|
function fakeDb(...results: unknown[][]): Fake {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const queue = [...results];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
execute: async (query: unknown) => {
|
||||||
|
const q = query as { queryChunks?: unknown[] };
|
||||||
|
calls.push(JSON.stringify(q.queryChunks ?? query));
|
||||||
|
return { rows: queue.shift() ?? [] };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const RAW_TASK = {
|
||||||
|
id: 't-1',
|
||||||
|
seq: '17',
|
||||||
|
activity_id: 'part-substitution-assessment',
|
||||||
|
workflow_id: null,
|
||||||
|
origin: 'ops',
|
||||||
|
owner_id: 's-1',
|
||||||
|
title: 'W05',
|
||||||
|
runtime: 'running',
|
||||||
|
terminal: null,
|
||||||
|
reason: null,
|
||||||
|
envelope: { packageVersion: '1.5.0' },
|
||||||
|
input_data: { customerCode: 'CU-0142' },
|
||||||
|
used_steps: '3',
|
||||||
|
// pg 的 timestamptz 在 raw execute 这一路回文本(实测),也可能是 Date —— 两种都要归一成 ISO
|
||||||
|
started_at: '2026-08-27 16:55:05.123+08',
|
||||||
|
finished_at: new Date('2026-08-27T08:55:14.383Z'),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('createActivityOpsStore', () => {
|
||||||
|
it('listTasks:seq → INS-n、时间戳归一成 ISO、jsonb 取成对象、数字列转数', async () => {
|
||||||
|
const db = fakeDb([RAW_TASK]);
|
||||||
|
const rows = await createActivityOpsStore(db).listTasks('part-substitution-assessment');
|
||||||
|
expect(rows).toEqual([
|
||||||
|
{
|
||||||
|
id: 't-1',
|
||||||
|
no: 'INS-17',
|
||||||
|
activityId: 'part-substitution-assessment',
|
||||||
|
workflowId: null,
|
||||||
|
origin: 'ops',
|
||||||
|
ownerId: 's-1',
|
||||||
|
title: 'W05',
|
||||||
|
runtime: 'running',
|
||||||
|
terminal: null,
|
||||||
|
reason: null,
|
||||||
|
envelope: { packageVersion: '1.5.0' },
|
||||||
|
data: { customerCode: 'CU-0142' },
|
||||||
|
usedSteps: 3,
|
||||||
|
startedAt: '2026-08-27T08:55:05.123Z',
|
||||||
|
finishedAt: '2026-08-27T08:55:14.383Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(db.calls[0]).toContain('order by started_at desc, seq desc');
|
||||||
|
expect(db.calls[0]).toContain('activity_id = ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('latestReleased 只看已放行的、按放行时刻倒序取一条;没有回 undefined', async () => {
|
||||||
|
const db = fakeDb([RAW_TASK]);
|
||||||
|
const one = await createActivityOpsStore(db).latestReleased('part-substitution-assessment');
|
||||||
|
expect(one?.no).toBe('INS-17');
|
||||||
|
expect(db.calls[0]).toContain("terminal = 'released'");
|
||||||
|
expect(db.calls[0]).toContain('order by finished_at desc nulls last, started_at desc limit 1');
|
||||||
|
expect(await createActivityOpsStore(fakeDb([])).latestReleased('x')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getTask 认活动 + 任务两把钥匙;没命中回 undefined', async () => {
|
||||||
|
const db = fakeDb([]);
|
||||||
|
expect(await createActivityOpsStore(db).getTask('a', 't')).toBeUndefined();
|
||||||
|
expect(db.calls[0]).toContain('activity_id = ');
|
||||||
|
expect(db.calls[0]).toContain('and id = ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('countByActivity 按活动数;profilesOf 按活动键映射', async () => {
|
||||||
|
const db = fakeDb([{ activity_id: 'a', n: '2' }, { activity_id: 'b', n: 1 }], [{ activity_id: 'a', owner_id: null, description: null, ico: 'target', hue: 'blue' }]);
|
||||||
|
const store = createActivityOpsStore(db);
|
||||||
|
expect([...(await store.countByActivity()).entries()]).toEqual([['a', 2], ['b', 1]]);
|
||||||
|
const profiles = await store.profilesOf();
|
||||||
|
expect(profiles.get('a')).toEqual({ activityId: 'a', ownerId: null, description: '', ico: 'target', hue: 'blue' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('claimLaunch:一句 SQL 判 + 占(on conflict do nothing returning);插不进去就读回那一行', async () => {
|
||||||
|
const claimed = fakeDb([{ key: 'k' }]);
|
||||||
|
expect(await createActivityOpsStore(claimed).claimLaunch({ key: 'k', activityId: 'a', ownerId: 'o', launchedBy: 'me' })).toBe('claimed');
|
||||||
|
expect(claimed.calls[0]).toContain('on conflict (key) do nothing');
|
||||||
|
expect(claimed.calls[0]).toContain('returning key');
|
||||||
|
expect(claimed.calls).toHaveLength(1);
|
||||||
|
|
||||||
|
const taken = fakeDb([], [{ key: 'k', activity_id: 'a', owner_id: 'o', launched_by: 'x', task_id: 't-9', claimed_at: new Date('2026-08-27T08:00:00.000Z') }]);
|
||||||
|
expect(await createActivityOpsStore(taken).claimLaunch({ key: 'k', activityId: 'a', ownerId: 'o', launchedBy: 'me' })).toEqual({
|
||||||
|
key: 'k',
|
||||||
|
activityId: 'a',
|
||||||
|
ownerId: 'o',
|
||||||
|
launchedBy: 'x',
|
||||||
|
taskId: 't-9',
|
||||||
|
claimedAt: '2026-08-27T08:00:00.000Z',
|
||||||
|
});
|
||||||
|
expect(taken.calls).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reclaimLaunch 只占「单没建成且已过期」的键;bindLaunch 落 task_id;upsertProfile 冲突即更新', async () => {
|
||||||
|
const db = fakeDb([{ key: 'k' }], [], []);
|
||||||
|
const store = createActivityOpsStore(db);
|
||||||
|
expect(await store.reclaimLaunch('k', 60_000, 'o', 'me')).toBe(true);
|
||||||
|
expect(db.calls[0]).toContain('task_id is null');
|
||||||
|
expect(db.calls[0]).toContain("milliseconds')::interval");
|
||||||
|
await store.bindLaunch('k', 't-1');
|
||||||
|
expect(db.calls[1]).toContain('set task_id = ');
|
||||||
|
await store.upsertProfile({ activityId: 'a', ownerId: null, description: 'd', ico: null, hue: null });
|
||||||
|
expect(db.calls[2]).toContain('on conflict (activity_id) do update');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/** 清单一句话与检索 */
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { InstanceSummary } from '@contracts';
|
||||||
|
import { staffIdSchema } from '@contracts';
|
||||||
|
import { fieldSpecsOf } from '../activity-ops/launch-params.ts';
|
||||||
|
import { applyInstanceFilters, clip, inputFilesOf, inputSummaryOf, outputSummaryOf, pageOf, statsOf } from '../activity-ops/summaries.ts';
|
||||||
|
|
||||||
|
const specs = fieldSpecsOf({
|
||||||
|
properties: {
|
||||||
|
customerCode: { type: 'string' },
|
||||||
|
requestDescription: { type: 'string', 'x-control': 'textarea' },
|
||||||
|
datasheets: { type: 'array', format: 'file' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inputSummaryOf / inputFilesOf / outputSummaryOf', () => {
|
||||||
|
it('textarea 那格优先;没有就最长的字符串;再没有退回来件主题;文件字段不算', () => {
|
||||||
|
expect(inputSummaryOf({ customerCode: 'CU-0142', requestDescription: ' 工业电源项目\n在用 LM2596S-5.0 ' }, specs)).toBe('工业电源项目 在用 LM2596S-5.0');
|
||||||
|
expect(inputSummaryOf({ customerCode: 'CU-0142', datasheets: [{ ref: 'r', name: 'a-very-long-file-name.pdf' }] }, specs)).toBe('CU-0142');
|
||||||
|
expect(inputSummaryOf({}, specs, 'RE: 询价')).toBe('RE: 询价');
|
||||||
|
expect(inputSummaryOf({}, specs)).toBe('');
|
||||||
|
expect(clip('x'.repeat(100))).toHaveLength(80);
|
||||||
|
expect(clip('x'.repeat(100)).endsWith('…')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('文件只认带 ref 的;index 跨字段连续', () => {
|
||||||
|
const files = inputFilesOf({ datasheets: [{ name: 'a.pdf', contentType: 'application/pdf', size: 10, ref: 'u-1' }, { name: 'no-ref' }, 'junk'] }, specs);
|
||||||
|
expect(files).toEqual([{ index: 0, name: 'a.pdf', contentType: 'application/pdf', size: 10, ref: 'u-1' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('产出:有文件摆文件名;没有摆发口;都没有 null', () => {
|
||||||
|
expect(outputSummaryOf(['报告.docx', '对照表.xlsx'], ['x'])).toBe('报告.docx、对照表.xlsx');
|
||||||
|
expect(outputSummaryOf([], ['assessment-released'])).toBe('已发口:assessment-released');
|
||||||
|
expect(outputSummaryOf([], [])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const owner = { id: 's-1', name: '柴浩' };
|
||||||
|
const mk = (id: string, no: string, state: InstanceSummary['state'], startedAt: string, ownerId = 's-1'): InstanceSummary => ({
|
||||||
|
id,
|
||||||
|
no,
|
||||||
|
state,
|
||||||
|
owner: { id: staffIdSchema.parse(ownerId), name: owner.name },
|
||||||
|
input: 'x',
|
||||||
|
output: null,
|
||||||
|
startedAt,
|
||||||
|
});
|
||||||
|
const items = [
|
||||||
|
mk('t-aaa', 'INS-24', 'running', '2026-08-25T02:12:00.000Z'),
|
||||||
|
mk('t-bbb', 'INS-17', 'completed', '2026-08-24T09:03:00.000Z', 's-2'),
|
||||||
|
mk('t-ccc', 'INS-9', 'failed', '2026-08-20T01:00:00.000Z'),
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('applyInstanceFilters / statsOf / pageOf', () => {
|
||||||
|
it('实例号模糊、负责人精确、状态精确、日期段含两端整日(本地时区)', () => {
|
||||||
|
expect(applyInstanceFilters(items, { id: 'ins-2' }).map((i) => i.no)).toEqual(['INS-24']);
|
||||||
|
expect(applyInstanceFilters(items, { id: 'bbb' }).map((i) => i.no)).toEqual(['INS-17']);
|
||||||
|
expect(applyInstanceFilters(items, { ownerId: 's-2' }).map((i) => i.no)).toEqual(['INS-17']);
|
||||||
|
expect(applyInstanceFilters(items, { state: 'failed' }).map((i) => i.no)).toEqual(['INS-9']);
|
||||||
|
const day = (iso: string) => {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
expect(applyInstanceFilters(items, { startedFrom: day('2026-08-24T09:03:00.000Z'), startedTo: day('2026-08-24T09:03:00.000Z') }).map((i) => i.no)).toEqual(['INS-17']);
|
||||||
|
expect(applyInstanceFilters(items, { startedFrom: day('2026-08-24T09:03:00.000Z') }).map((i) => i.no)).toEqual(['INS-24', 'INS-17']);
|
||||||
|
expect(applyInstanceFilters(items, { startedTo: day('2026-08-20T01:00:00.000Z') }).map((i) => i.no)).toEqual(['INS-9']);
|
||||||
|
expect(applyInstanceFilters(items, {})).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('统计五键齐;分页越界给空', () => {
|
||||||
|
expect(statsOf(items)).toEqual({ total: 3, byState: { pending: 0, running: 1, completed: 1, rejected: 0, failed: 1 } });
|
||||||
|
expect(pageOf(items, 1, 2).map((i) => i.no)).toEqual(['INS-24', 'INS-17']);
|
||||||
|
expect(pageOf(items, 2, 2).map((i) => i.no)).toEqual(['INS-9']);
|
||||||
|
expect(pageOf(items, 3, 2)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/** 沙箱视图出口:模版原文不改,只在 </body> 前注入报高脚本 */
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { ACTIVITY_VIEW_HEIGHT_MESSAGE } from '@contracts';
|
||||||
|
import { injectHeightScript } from '../activity-ops/view-html.ts';
|
||||||
|
|
||||||
|
describe('injectHeightScript', () => {
|
||||||
|
it('插在 </body> 之前;没有 body 就追加;原文一字不改', () => {
|
||||||
|
const html = '<!doctype html><html><body><h1>一览</h1></body></html>';
|
||||||
|
const out = injectHeightScript(html);
|
||||||
|
expect(out.startsWith('<!doctype html><html><body><h1>一览</h1>')).toBe(true);
|
||||||
|
expect(out.indexOf('<script>')).toBeLessThan(out.indexOf('</body>'));
|
||||||
|
expect(out).toContain(ACTIVITY_VIEW_HEIGHT_MESSAGE);
|
||||||
|
expect(out).toContain("postMessage({type:T,height:h()},'*')");
|
||||||
|
const bare = injectHeightScript('<p>x</p>');
|
||||||
|
expect(bare.startsWith('<p>x</p>')).toBe(true);
|
||||||
|
expect(bare).toContain('<script>');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* 沙箱看板的取数与作用域:声明怎么求值、行集怎么摊平 —— 零 IO,读表用一张内存表替。
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { ViewData } from '@contracts';
|
||||||
|
import { caseStamp, openedAtText } from '../case-stamp.ts';
|
||||||
|
import { resolveViewData, viewScopeOf, slugOf, localMinute } from '../activity-ops/view-scope.ts';
|
||||||
|
import type { ViewRead, ViewRow } from '../activity-ops/view-scope.ts';
|
||||||
|
|
||||||
|
const SPEC: ViewData = {
|
||||||
|
case: { ledger: 'sub_eval.verdict', key: 'eval_ref', prefix: 'W05-' },
|
||||||
|
rows: [
|
||||||
|
{ as: 'sub_eval.verdict', ledger: 'sub_eval.verdict', by: { column: 'eval_ref', from: 'case' } },
|
||||||
|
{ as: 'sub_eval.pn_check', ledger: 'sub_eval.pn_check', by: { column: 'eval_ref', from: 'case' }, orderBy: [{ field: 'role', dir: 'desc' }] },
|
||||||
|
{ as: 'parts.profile', ledger: 'parts.profile', by: { column: 'internal_pn', from: 'sub_eval.pn_check.internal_pn' } },
|
||||||
|
{ as: 'substitution.relation', ledger: 'substitution.relation', by: { column: 'eval_ref', from: 'case' } },
|
||||||
|
{ as: 'validation.record', ledger: 'validation.record', by: { column: 'request_id', from: 'sub_eval.verdict.request_id' } },
|
||||||
|
{
|
||||||
|
as: 'validation.history',
|
||||||
|
ledger: 'validation.record',
|
||||||
|
by: { column: 'validation_internal_pn', from: 'sub_eval.pn_check.internal_pn' },
|
||||||
|
not: { column: 'request_id', from: 'sub_eval.verdict.request_id' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const TABLES: Record<string, ViewRow[]> = {
|
||||||
|
'sub_eval.verdict': [
|
||||||
|
{ eval_ref: 'W05-20260827215103', request_id: 'REQ-20260827215103', original_internal_pn: 'LM2596S-5.0' },
|
||||||
|
{ eval_ref: 'W05-20260827000000', request_id: 'REQ-20260827000000', original_internal_pn: 'X' },
|
||||||
|
],
|
||||||
|
'sub_eval.pn_check': [
|
||||||
|
{ eval_ref: 'W05-20260827215103', internal_pn: 'XL2596S-5.0', role: 'candidate', exists_status: 'created' },
|
||||||
|
{ eval_ref: 'W05-20260827215103', internal_pn: 'LM2596S-5.0', role: 'original', exists_status: 'found' },
|
||||||
|
{ eval_ref: 'W05-20260827000000', internal_pn: 'X', role: 'original', exists_status: 'found' },
|
||||||
|
],
|
||||||
|
'parts.profile': [
|
||||||
|
{ internal_pn: 'LM2596S-5.0', brand_name: 'TI', lifecycle_status: 'nrnd' },
|
||||||
|
{ internal_pn: 'XL2596S-5.0', brand_name: 'XLSEMI', lifecycle_status: 'active' },
|
||||||
|
{ internal_pn: 'X', brand_name: '?', lifecycle_status: 'active' },
|
||||||
|
],
|
||||||
|
'substitution.relation': [
|
||||||
|
{ substitution_relation_id: 'REL-1', eval_ref: 'W05-20260827215103', recommendation_level: 'conditional' },
|
||||||
|
{ substitution_relation_id: 'REL-9', eval_ref: 'W05-20260827000000', recommendation_level: 'alternate' },
|
||||||
|
],
|
||||||
|
'validation.record': [
|
||||||
|
{ validation_id: 'VAL-A', request_id: 'REQ-20260827215103', validation_internal_pn: 'XL2596S-5.0' },
|
||||||
|
{ validation_id: 'VAL-OLD', request_id: 'REQ-20260101000000', validation_internal_pn: 'XL2596S-5.0' },
|
||||||
|
{ validation_id: 'VAL-OTHER', request_id: 'REQ-20260101000001', validation_internal_pn: 'Z' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 内存表:等值过滤 + 单列排序;记下每一次读 */
|
||||||
|
function reader(tables: Record<string, ViewRow[]>, broken: ReadonlySet<string> = new Set()): { read: ViewRead; calls: string[] } {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const read: ViewRead = async (address, where, orderBy) => {
|
||||||
|
const name = `${address.domain}.${address.object}`;
|
||||||
|
calls.push(`${name} ${JSON.stringify(where)}`);
|
||||||
|
if (broken.has(name)) return undefined;
|
||||||
|
const rows = (tables[name] ?? []).filter((r) => Object.entries(where).every(([k, v]) => String(r[k]) === v));
|
||||||
|
const order = orderBy[0];
|
||||||
|
if (order === undefined) return rows;
|
||||||
|
return [...rows].sort((a, b) => String(a[order.field]).localeCompare(String(b[order.field])) * (order.dir === 'asc' ? 1 : -1));
|
||||||
|
};
|
||||||
|
return { read, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
const DECLARED = [
|
||||||
|
{ domain: 'parts', object: 'profile', fields: [{ name: 'internal_pn' }, { name: 'lifecycle_status', tally: ['active', 'nrnd', 'eol'] }] },
|
||||||
|
{ domain: 'substitution', object: 'relation', fields: [{ name: 'recommendation_level', tally: ['recommended', 'conditional', 'alternate', 'not_recommended'] }] },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('caseStamp / openedAtText', () => {
|
||||||
|
it('同一串:文本带分隔符、案号只留 14 位数字;认不出的时刻两者都是空串', () => {
|
||||||
|
const iso = new Date(2026, 7, 27, 21, 51, 3).toISOString();
|
||||||
|
expect(openedAtText(iso)).toBe('2026-08-27 21:51:03');
|
||||||
|
expect(caseStamp(iso)).toBe('20260827215103');
|
||||||
|
expect(openedAtText('not-a-date')).toBe('');
|
||||||
|
expect(caseStamp('')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveViewData', () => {
|
||||||
|
it('按序链式求值:案号 → 核对表 → 料号圈主数据;行序随 from 值的顺序(原 PN 排第一)', async () => {
|
||||||
|
const { read, calls } = reader(TABLES);
|
||||||
|
const out = await resolveViewData(SPEC, { caseRef: 'W05-20260827215103', read });
|
||||||
|
expect(out.caseFound).toBe(true);
|
||||||
|
expect(out.skipped).toEqual([]);
|
||||||
|
expect(out.sets['sub_eval.verdict']).toHaveLength(1);
|
||||||
|
expect(out.sets['sub_eval.pn_check']?.map((r) => r['internal_pn'])).toEqual(['LM2596S-5.0', 'XL2596S-5.0']);
|
||||||
|
expect(out.sets['parts.profile']?.map((r) => r['brand_name'])).toEqual(['TI', 'XLSEMI']);
|
||||||
|
expect(out.sets['substitution.relation']?.map((r) => r['substitution_relation_id'])).toEqual(['REL-1']);
|
||||||
|
// 每个值单独一次等值读:别单的行一条都不进来
|
||||||
|
expect(calls).toContain('parts.profile {"internal_pn":"LM2596S-5.0"}');
|
||||||
|
expect(calls).toContain('parts.profile {"internal_pn":"XL2596S-5.0"}');
|
||||||
|
expect(calls.filter((c) => c.startsWith('parts.profile'))).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('not 排除:同料号的历史验证记录不含本单挂的那条;别的料号不进来', async () => {
|
||||||
|
const { read } = reader(TABLES);
|
||||||
|
const out = await resolveViewData(SPEC, { caseRef: 'W05-20260827215103', read });
|
||||||
|
expect(out.sets['validation.record']?.map((r) => r['validation_id'])).toEqual(['VAL-A']);
|
||||||
|
expect(out.sets['validation.history']?.map((r) => r['validation_id'])).toEqual(['VAL-OLD']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('案号查无 → caseFound=false,下游集合全空、一次多余的读都不发', async () => {
|
||||||
|
const { read, calls } = reader(TABLES);
|
||||||
|
const out = await resolveViewData(SPEC, { caseRef: 'W05-19990101000000', read });
|
||||||
|
expect(out.caseFound).toBe(false);
|
||||||
|
expect(out.sets['parts.profile']).toEqual([]);
|
||||||
|
expect(calls.some((c) => c.startsWith('parts.profile'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('某张表读不了:那个集合为空并点名,别的集合照常', async () => {
|
||||||
|
const { read } = reader(TABLES, new Set(['parts.profile']));
|
||||||
|
const out = await resolveViewData(SPEC, { caseRef: 'W05-20260827215103', read });
|
||||||
|
expect(out.sets['parts.profile']).toEqual([]);
|
||||||
|
expect(out.sets['substitution.relation']).toHaveLength(1);
|
||||||
|
expect(out.skipped).toEqual(['parts.profile']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('from 指向没定义过的集合 → 点名、集合为空(声明写错不炸)', async () => {
|
||||||
|
const { read } = reader(TABLES);
|
||||||
|
const spec: ViewData = { ...SPEC, rows: [{ as: 'x', ledger: 'parts.profile', by: { column: 'internal_pn', from: 'nope.internal_pn' } }] };
|
||||||
|
const out = await resolveViewData(spec, { caseRef: 'W05-20260827215103', read });
|
||||||
|
expect(out.sets['x']).toEqual([]);
|
||||||
|
expect(out.skipped).toEqual(['x']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('viewScopeOf', () => {
|
||||||
|
it('行数组 / 首行标量 / 行数 / tally 计数(先铺 0)/ 实例事实 / 三态', () => {
|
||||||
|
const sets = {
|
||||||
|
'parts.profile': [
|
||||||
|
{ internal_pn: 'LM2596S-5.0', lifecycle_status: 'nrnd' },
|
||||||
|
{ internal_pn: 'XL2596S-5.0', lifecycle_status: 'active' },
|
||||||
|
],
|
||||||
|
'substitution.relation': [],
|
||||||
|
};
|
||||||
|
const scope = viewScopeOf({
|
||||||
|
spec: SPEC,
|
||||||
|
sets,
|
||||||
|
declared: DECLARED,
|
||||||
|
instance: { no: 'INS-20', startedAt: new Date(2026, 7, 27, 21, 51, 3).toISOString(), finishedAt: new Date(2026, 7, 27, 21, 57, 56).toISOString(), owner: '柴浩' },
|
||||||
|
caseRef: 'W05-20260827215103',
|
||||||
|
state: 'ready',
|
||||||
|
});
|
||||||
|
expect(scope['board:state']).toBe('ready');
|
||||||
|
expect(scope['case:instanceNo']).toBe('INS-20');
|
||||||
|
expect(scope['case:openedAt']).toBe('2026-08-27 21:51');
|
||||||
|
expect(scope['case:releasedAt']).toBe('2026-08-27 21:57');
|
||||||
|
expect(scope['case:owner']).toBe('柴浩');
|
||||||
|
expect(scope['case:ref']).toBe('W05-20260827215103');
|
||||||
|
expect(scope['parts.profile']).toBe(sets['parts.profile']);
|
||||||
|
expect(scope['parts.profile.internal_pn']).toBe('LM2596S-5.0');
|
||||||
|
expect(scope['parts_profile_rows']).toBe(2);
|
||||||
|
expect(scope['parts_profile_lifecycle_status_nrnd']).toBe(1);
|
||||||
|
expect(scope['parts_profile_lifecycle_status_active']).toBe(1);
|
||||||
|
expect(scope['parts_profile_lifecycle_status_eol']).toBe(0);
|
||||||
|
expect(scope['substitution_relation_rows']).toBe(0);
|
||||||
|
expect(scope['substitution_relation_recommendation_level_not_recommended']).toBe(0);
|
||||||
|
// 声明里没求出来的集合也铺空数组:模版的 data-rows 不会对着 undefined
|
||||||
|
expect(scope['sub_eval.verdict']).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('没有已放行实例:none 态,实例事实全空串', () => {
|
||||||
|
const scope = viewScopeOf({ spec: SPEC, sets: {}, declared: DECLARED, state: 'none' });
|
||||||
|
expect(scope['board:state']).toBe('none');
|
||||||
|
expect(scope['case:instanceNo']).toBe('');
|
||||||
|
expect(scope['case:releasedAt']).toBe('');
|
||||||
|
expect(scope['parts_profile_rows']).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('slug 与时刻', () => {
|
||||||
|
expect(slugOf('sub_eval.verdict')).toBe('sub_eval_verdict');
|
||||||
|
expect(slugOf('validation.history')).toBe('validation_history');
|
||||||
|
expect(slugOf('not_recommended')).toBe('not_recommended');
|
||||||
|
expect(localMinute(null)).toBe('');
|
||||||
|
expect(localMinute('garbage')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* 活动模块路由的归属:11 条全部进契约、注册表建得起来(与设计端的 activity-packages 互不撞),全归运营中心。
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import * as contracts from '@contracts';
|
||||||
|
import { ACTIVITY_OPS_ROUTES } from '@contracts';
|
||||||
|
import { buildRouteRegistry, routesOf } from '../route-registry.ts';
|
||||||
|
|
||||||
|
describe('活动模块路由契约', () => {
|
||||||
|
it('11 条全在契约命名空间里,注册表能建起来', () => {
|
||||||
|
const registry = buildRouteRegistry(routesOf(Object.values(contracts)));
|
||||||
|
expect(ACTIVITY_OPS_ROUTES).toHaveLength(11);
|
||||||
|
for (const route of ACTIVITY_OPS_ROUTES) expect(registry.has(route.method, route.path), route.path).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('全归 ops;设计端活动库让位到 activity-packages 后仍归 design', () => {
|
||||||
|
const registry = buildRouteRegistry(routesOf(Object.values(contracts)));
|
||||||
|
for (const route of ACTIVITY_OPS_ROUTES) expect(route.access).toBe('ops');
|
||||||
|
expect(registry.lookup('GET', '/api/gov/activities')).toBe('ops');
|
||||||
|
expect(registry.lookup('GET', '/api/gov/activity-packages')).toBe('design');
|
||||||
|
expect(registry.lookup('POST', '/api/gov/activity-packages')).toBe('design');
|
||||||
|
expect(registry.lookup('GET', '/api/gov/activities/part-substitution-assessment/view')).toBe('ops');
|
||||||
|
expect(registry.lookup('POST', '/api/gov/activities/part-substitution-assessment/instances/t-1/terminate')).toBe('ops');
|
||||||
|
expect(registry.lookup('GET', '/api/gov/activities/x/instances/t-1/files/output/0')).toBe('ops');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* 活动模块路由:zod 校验 → 用例 → 结果翻状态码。用例是替身(用例本身另有测试),不挂鉴权中间件,
|
||||||
|
* 身份由测试自己塞进上下文(终止 / 启动要知道是谁按的)。
|
||||||
|
*/
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { InstanceDetail, Principal } from '@contracts';
|
||||||
|
import { positionIdSchema, staffIdSchema } from '@contracts';
|
||||||
|
import type { AppEnv } from '../http-env.ts';
|
||||||
|
import { registerActivityRoutes } from '../routes/activities.ts';
|
||||||
|
import type { ActivityOps } from '../activity-ops/service.ts';
|
||||||
|
|
||||||
|
const W05 = 'part-substitution-assessment';
|
||||||
|
const owner = { id: staffIdSchema.parse('s-1'), name: '柴浩' };
|
||||||
|
const detail: InstanceDetail = {
|
||||||
|
id: 't-1',
|
||||||
|
no: 'INS-7',
|
||||||
|
state: 'running',
|
||||||
|
owner,
|
||||||
|
output: { files: [] },
|
||||||
|
startedAt: '2026-08-27T02:00:00.000Z',
|
||||||
|
activityId: W05,
|
||||||
|
llm: 'deepseek-v4-flash',
|
||||||
|
endedAt: null,
|
||||||
|
durationMs: null,
|
||||||
|
reason: null,
|
||||||
|
input: { content: 'x', source: { kind: 'manual', label: '手动启动 · 柴浩' }, submittedAt: '2026-08-27T02:00:00.000Z', files: [] },
|
||||||
|
steps: [],
|
||||||
|
};
|
||||||
|
const activity = { id: W05, name: 'W05', biz: '需求分析', status: 'active' as const, version: '1.5.0', owner, llm: 'm', createdAt: 'c', desc: '', view: null };
|
||||||
|
|
||||||
|
function fakeOps(over: Partial<ActivityOps> = {}): ActivityOps {
|
||||||
|
return {
|
||||||
|
listActivities: async () => [{ ...activity, instanceCount: 1 }],
|
||||||
|
getActivity: async (id) => (id === W05 ? activity : undefined),
|
||||||
|
viewHtml: async (id) => (id === W05 ? { kind: 'html', html: '<html><body>一览<script>x</script></body></html>' } : { kind: 'no-view' }),
|
||||||
|
listInstances: async (id, q) => (id === W05 ? { stats: { total: 1, byState: { pending: 0, running: 1, completed: 0, rejected: 0, failed: 0 } }, total: 1, page: q.page, size: q.size, items: [] } : undefined),
|
||||||
|
launchParams: async (id) => (id === W05 ? { activityId: id, version: '1.5.0', params: [] } : undefined),
|
||||||
|
launch: async (id, input) => {
|
||||||
|
if (id !== W05) return { kind: 'not-found' };
|
||||||
|
if (input.idempotencyKey === 'busy') return { kind: 'busy', message: '正在创建' };
|
||||||
|
if (input.idempotencyKey === 'bad') return { kind: 'invalid', issues: [{ key: 'customerCode', message: '必填' }] };
|
||||||
|
if (input.idempotencyKey === 'off') return { kind: 'disabled' };
|
||||||
|
if (input.idempotencyKey === 'owner') return { kind: 'owner-invalid', message: '责任人不在名册里' };
|
||||||
|
return { kind: 'ok', detail, created: true };
|
||||||
|
},
|
||||||
|
instanceDetail: async (id, iid) => (id === W05 && iid === 't-1' ? detail : undefined),
|
||||||
|
instanceFile: async (id, iid, io, index) =>
|
||||||
|
id === W05 && iid === 't-1' && io === 'input' && index === 0 ? { kind: 'file', name: '报 告.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2, 3]) } : iid === 't-1' ? { kind: 'no-file' } : { kind: 'no-instance' },
|
||||||
|
setEnabled: async (id, enabled) => (id === W05 ? { ...activity, status: enabled ? 'active' : 'disabled' } : undefined),
|
||||||
|
terminate: async (id, iid) => (id !== W05 || iid === 'nope' ? { kind: 'not-found' } : iid === 't-done' ? { kind: 'terminal' } : { kind: 'ok', detail: { ...detail, state: 'failed', reason: { kind: 'terminated', text: '由 柴浩 手动终止' } } }),
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function harness(opts: { anonymous?: boolean } = {}) {
|
||||||
|
const app = new Hono<AppEnv>();
|
||||||
|
const principal: Principal = {
|
||||||
|
staff: { id: owner.id, account: 'chaihao', name: owner.name, positionId: positionIdSchema.parse('pm'), state: 'active', roles: [], kind: 'person' },
|
||||||
|
position: { id: positionIdSchema.parse('pm'), code: 'PM', name: '项目经理', note: '' },
|
||||||
|
roles: [],
|
||||||
|
ends: ['ops'],
|
||||||
|
};
|
||||||
|
if (!opts.anonymous) {
|
||||||
|
app.use('*', async (c, next) => {
|
||||||
|
c.set('principal', principal);
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
registerActivityRoutes(app, { activityOps: fakeOps() });
|
||||||
|
const json = (method: string, path: string, body?: unknown) =>
|
||||||
|
app.request(path, { method, headers: { 'content-type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||||||
|
return { app, json };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('活动路由', () => {
|
||||||
|
it('L1 / L2:清单按 status 过滤入参校验;详情 404;视图回 text/html', async () => {
|
||||||
|
const h = harness();
|
||||||
|
expect(await (await h.json('GET', '/api/gov/activities')).json()).toMatchObject({ activities: [{ id: W05, instanceCount: 1 }] });
|
||||||
|
expect((await h.json('GET', '/api/gov/activities?status=paused')).status).toBe(400);
|
||||||
|
expect((await h.json('GET', `/api/gov/activities/${W05}`)).status).toBe(200);
|
||||||
|
expect((await h.json('GET', '/api/gov/activities/nope')).status).toBe(404);
|
||||||
|
const view = await h.json('GET', `/api/gov/activities/${W05}/view`);
|
||||||
|
expect(view.status).toBe(200);
|
||||||
|
expect(view.headers.get('content-type')).toContain('text/html');
|
||||||
|
expect(view.headers.get('cache-control')).toBe('no-store');
|
||||||
|
expect(await view.text()).toContain('一览');
|
||||||
|
expect((await h.json('GET', '/api/gov/activities/nope/view')).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('实例清单:查询默认值与上限;启动参数表 404', async () => {
|
||||||
|
const h = harness();
|
||||||
|
expect(await (await h.json('GET', `/api/gov/activities/${W05}/instances`)).json()).toMatchObject({ page: 1, size: 8 });
|
||||||
|
expect(await (await h.json('GET', `/api/gov/activities/${W05}/instances?page=2&size=20&state=running`)).json()).toMatchObject({ page: 2, size: 20 });
|
||||||
|
expect((await h.json('GET', `/api/gov/activities/${W05}/instances?size=99`)).status).toBe(400);
|
||||||
|
expect((await h.json('GET', `/api/gov/activities/${W05}/instances?startedFrom=8-1`)).status).toBe(400);
|
||||||
|
expect((await h.json('GET', '/api/gov/activities/nope/instances')).status).toBe(404);
|
||||||
|
expect((await h.json('GET', `/api/gov/activities/${W05}/launch-params`)).status).toBe(200);
|
||||||
|
expect((await h.json('GET', '/api/gov/activities/nope/launch-params')).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('启动:400 入参 / 412 禁用 / 409 在途 / 404 / 401 没身份 / 200 详情', async () => {
|
||||||
|
const h = harness();
|
||||||
|
const body = { idempotencyKey: 'k', ownerId: 's-1', params: { customerCode: 'CU-1' } };
|
||||||
|
expect((await h.json('POST', `/api/gov/activities/${W05}/instances`, { ownerId: 's-1' })).status).toBe(400);
|
||||||
|
const bad = await h.json('POST', `/api/gov/activities/${W05}/instances`, { ...body, idempotencyKey: 'bad' });
|
||||||
|
expect(bad.status).toBe(400);
|
||||||
|
expect(await bad.json()).toMatchObject({ code: 'validation-failed', detail: [{ key: 'customerCode' }] });
|
||||||
|
const owner = await h.json('POST', `/api/gov/activities/${W05}/instances`, { ...body, idempotencyKey: 'owner' });
|
||||||
|
expect(owner.status).toBe(400);
|
||||||
|
expect(await owner.json()).toMatchObject({ detail: [{ key: 'ownerId' }] });
|
||||||
|
expect((await h.json('POST', `/api/gov/activities/${W05}/instances`, { ...body, idempotencyKey: 'off' })).status).toBe(412);
|
||||||
|
expect((await h.json('POST', `/api/gov/activities/${W05}/instances`, { ...body, idempotencyKey: 'busy' })).status).toBe(409);
|
||||||
|
expect((await h.json('POST', '/api/gov/activities/nope/instances', body)).status).toBe(404);
|
||||||
|
const ok = await h.json('POST', `/api/gov/activities/${W05}/instances`, body);
|
||||||
|
expect(ok.status).toBe(200);
|
||||||
|
expect(await ok.json()).toMatchObject({ id: 't-1', no: 'INS-7' });
|
||||||
|
expect((await harness({ anonymous: true }).json('POST', `/api/gov/activities/${W05}/instances`, body)).status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('L3:详情 404;文件默认 JSON+base64、raw=1 直出字节带 RFC 5987 文件名;序号不合法 400', async () => {
|
||||||
|
const h = harness();
|
||||||
|
expect((await h.json('GET', `/api/gov/activities/${W05}/instances/t-1`)).status).toBe(200);
|
||||||
|
expect((await h.json('GET', `/api/gov/activities/${W05}/instances/nope`)).status).toBe(404);
|
||||||
|
const json = await h.json('GET', `/api/gov/activities/${W05}/instances/t-1/files/input/0`);
|
||||||
|
expect(await json.json()).toEqual({ name: '报 告.pdf', contentType: 'application/pdf', dataBase64: Buffer.from([1, 2, 3]).toString('base64') });
|
||||||
|
const raw = await h.json('GET', `/api/gov/activities/${W05}/instances/t-1/files/input/0?raw=1`);
|
||||||
|
expect(raw.status).toBe(200);
|
||||||
|
expect(raw.headers.get('content-type')).toBe('application/pdf');
|
||||||
|
expect(raw.headers.get('content-disposition')).toBe(`attachment; filename*=UTF-8''${encodeURIComponent('报 告.pdf')}`);
|
||||||
|
expect(new Uint8Array(await raw.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3]));
|
||||||
|
expect((await h.json('GET', `/api/gov/activities/${W05}/instances/t-1/files/output/0`)).status).toBe(404);
|
||||||
|
expect((await h.json('GET', `/api/gov/activities/${W05}/instances/nope/files/input/0`)).status).toBe(404);
|
||||||
|
expect((await h.json('GET', `/api/gov/activities/${W05}/instances/t-1/files/both/0`)).status).toBe(400);
|
||||||
|
expect((await h.json('GET', `/api/gov/activities/${W05}/instances/t-1/files/input/x`)).status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('禁用 / 激活回活动详情;终止 412 已终态、404、401 没身份', async () => {
|
||||||
|
const h = harness();
|
||||||
|
expect(await (await h.json('POST', `/api/gov/activities/${W05}/disable`)).json()).toMatchObject({ status: 'disabled' });
|
||||||
|
expect(await (await h.json('POST', `/api/gov/activities/${W05}/activate`)).json()).toMatchObject({ status: 'active' });
|
||||||
|
expect((await h.json('POST', '/api/gov/activities/nope/disable')).status).toBe(404);
|
||||||
|
const done = await h.json('POST', `/api/gov/activities/${W05}/instances/t-1/terminate`);
|
||||||
|
expect(done.status).toBe(200);
|
||||||
|
expect(await done.json()).toMatchObject({ state: 'failed', reason: { kind: 'terminated' } });
|
||||||
|
expect((await h.json('POST', `/api/gov/activities/${W05}/instances/t-done/terminate`)).status).toBe(412);
|
||||||
|
expect((await h.json('POST', `/api/gov/activities/${W05}/instances/nope/terminate`)).status).toBe(404);
|
||||||
|
expect((await harness({ anonymous: true }).json('POST', `/api/gov/activities/${W05}/instances/t-1/terminate`)).status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* 数据工单确认即入库(2026-08-27 用户定)—— 这段错了不报错:人按下确认、卡记成办结,
|
||||||
|
* 而库里一行没有;要到整单放行才发现,或者永远发现不了(试跑单不落主数据)。
|
||||||
|
*
|
||||||
|
* 这里测的是编排(纯逻辑 + 假台账):落哪几张、落成什么样留什么痕;真库那一侧在
|
||||||
|
* `ledger-store.it.test.ts`(范围放行三条)。
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { TaskId, WorkOrder } from '@contracts';
|
||||||
|
import { workOrderSchema } from '@contracts';
|
||||||
|
import type { ReleaseScope, ReleaseWriteResult } from '@modules/ledger';
|
||||||
|
import { dataOrderLedgers, landDataOrder } from '../data-order-landing.ts';
|
||||||
|
|
||||||
|
const T = 'task-1' as TaskId;
|
||||||
|
|
||||||
|
/** 夹具走契约 schema 造 —— 造出来的就是一张合法工单,不靠 as 硬转 */
|
||||||
|
const order = (shape: Record<string, unknown>): WorkOrder =>
|
||||||
|
workOrderSchema.parse({
|
||||||
|
id: 'wo-1',
|
||||||
|
at: '2026-08-27T10:00:00Z',
|
||||||
|
title: '数据工单 · 芯片主数据创建(P-08):LM2596S-3.3',
|
||||||
|
basis: [{ k: '触发原因', v: '查无记录' }],
|
||||||
|
ask: '请建齐',
|
||||||
|
fields: [],
|
||||||
|
...shape,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dataOrder = (extra: Record<string, unknown> = {}): WorkOrder =>
|
||||||
|
order({ mode: 'execution', variant: 'data', gateLedgers: ['parts.profile', 'parts.parameter'], ...extra });
|
||||||
|
|
||||||
|
describe('dataOrderLedgers —— 确认之后该入库的表', () => {
|
||||||
|
it('数据工单:与提交闸同源 —— 绑了 blocks 看绑的,否则看 gateLedgers;去重、去脏', () => {
|
||||||
|
expect(dataOrderLedgers(dataOrder())).toEqual(['parts.profile', 'parts.parameter']);
|
||||||
|
expect(dataOrderLedgers(dataOrder({ gateLedgers: [' parts.profile ', 'parts.profile', 'bad address', ''] }))).toEqual(['parts.profile']);
|
||||||
|
const bound = dataOrder({ blocks: [{ t: 'table', title: '表', cols: ['a'], rows: [], ledger: 'parts.document' }] });
|
||||||
|
expect(dataOrderLedgers(bound)).toEqual(['parts.document']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('普通执行工单 / 验收工单 / 没说自己建哪张表的数据工单 → 空(不替它猜,更不回落到必交付物)', () => {
|
||||||
|
expect(dataOrderLedgers(order({ mode: 'execution', gateLedgers: ['parts.profile'] }))).toEqual([]);
|
||||||
|
expect(dataOrderLedgers(dataOrder({ gateLedgers: undefined }))).toEqual([]);
|
||||||
|
expect(dataOrderLedgers(order({ mode: 'acceptance', criteria: [], actions: [], deliverable: 'x', gateLedgers: ['parts.profile'] }))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function harness(reply: ReleaseWriteResult | Error) {
|
||||||
|
const calls: Array<{ taskId: TaskId; scope: ReleaseScope | undefined }> = [];
|
||||||
|
const notes: Array<{ kind: string; title: string; detail: Record<string, unknown> }> = [];
|
||||||
|
let touched = 0;
|
||||||
|
const deps = {
|
||||||
|
ledger: {
|
||||||
|
releaseWrite: async (taskId: TaskId, scope?: ReleaseScope) => {
|
||||||
|
calls.push({ taskId, scope });
|
||||||
|
if (reply instanceof Error) throw reply;
|
||||||
|
return reply;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
memory: {
|
||||||
|
append: async (entry: { kind: string; title: string; detail: Record<string, unknown> }) => {
|
||||||
|
notes.push({ kind: entry.kind, title: entry.title, detail: entry.detail });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
labelOf: async (address: string) => ({ 'parts.profile': '芯片主数据', 'parts.parameter': '标准化参数集' })[address] ?? address,
|
||||||
|
touched: () => {
|
||||||
|
touched += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { deps, calls, notes, touched: () => touched };
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = (rows: Array<[string, string]>): ReleaseWriteResult => ({
|
||||||
|
mergedRows: rows.length,
|
||||||
|
emittedEvents: rows.length,
|
||||||
|
landedEarlierRows: 0,
|
||||||
|
simulatedRows: 0,
|
||||||
|
merged: rows.map(([object, pn]) => ({ domain: 'parts', object, key: { internal_pn: pn }, values: {} })),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('landDataOrder —— 确认落下之后', () => {
|
||||||
|
it('只并这张卡的那几张表(范围放行),归因带卡号;痕点名哪几张表各几行,并给模型指路;数据域跟着刷', async () => {
|
||||||
|
const h = harness(merged([['profile', 'A'], ['parameter', 'A'], ['parameter', 'A']]));
|
||||||
|
const got = await landDataOrder(h.deps, T, dataOrder());
|
||||||
|
expect(got.landed).toBe(true);
|
||||||
|
expect(h.calls).toEqual([
|
||||||
|
{ taskId: T, scope: { only: [{ domain: 'parts', object: 'profile' }, { domain: 'parts', object: 'parameter' }], cause: 'data-order:wo-1' } },
|
||||||
|
]);
|
||||||
|
expect(h.notes).toHaveLength(1);
|
||||||
|
expect(h.notes[0]?.kind).toBe('ledger-write');
|
||||||
|
expect(h.notes[0]?.title).toContain('芯片主数据 1 行、标准化参数集 2 行已进正式表');
|
||||||
|
expect(h.notes[0]?.title).toContain('别再往这几张表写它们');
|
||||||
|
expect(h.notes[0]?.detail).toMatchObject({
|
||||||
|
workOrderId: 'wo-1',
|
||||||
|
rows: 3,
|
||||||
|
tables: [
|
||||||
|
{ address: 'parts.profile', rows: 1 },
|
||||||
|
{ address: 'parts.parameter', rows: 2 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(h.touched()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('试跑单挡下的主数据:痕如实说「只留在活动缓存」,数据域不刷', async () => {
|
||||||
|
const h = harness({ mergedRows: 0, emittedEvents: 0, landedEarlierRows: 0, simulatedRows: 3, merged: [] });
|
||||||
|
await landDataOrder(h.deps, T, dataOrder());
|
||||||
|
expect(h.notes[0]?.title).toContain('试跑');
|
||||||
|
expect(h.notes[0]?.title).toContain('未落正式表');
|
||||||
|
expect(h.touched()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('同一张卡确认两次:第二次无事发生,痕也这么说', async () => {
|
||||||
|
const h = harness({ mergedRows: 0, emittedEvents: 0, landedEarlierRows: 3, simulatedRows: 0, merged: [] });
|
||||||
|
await landDataOrder(h.deps, T, dataOrder());
|
||||||
|
expect(h.notes[0]?.title).toContain('早已入库');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不是数据工单就一个字不动(不调台账、不留痕)', async () => {
|
||||||
|
const h = harness(merged([]));
|
||||||
|
const got = await landDataOrder(h.deps, T, order({ mode: 'execution' }));
|
||||||
|
expect(got).toEqual({ landed: false, reason: 'not-data-order' });
|
||||||
|
expect(h.calls).toEqual([]);
|
||||||
|
expect(h.notes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('入库失败不抛:人已确认的事实不倒退;原话留痕、行仍在草稿层等放行', async () => {
|
||||||
|
const h = harness(new Error('草稿与正式版并发冲突:1 处字段已被他单改动'));
|
||||||
|
const got = await landDataOrder(h.deps, T, dataOrder());
|
||||||
|
expect(got).toMatchObject({ landed: false, reason: 'failed' });
|
||||||
|
expect(h.notes[0]?.title).toContain('没能入库');
|
||||||
|
expect(h.notes[0]?.title).toContain('并发冲突');
|
||||||
|
expect(h.notes[0]?.title).toContain('放行时再并');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { emptyGate, findKnownRow, gateAddressesOf, gateOf, parseRowKey, resolveRowKey, rowChanged, writesOf } from '../routes/desk.ts';
|
import { emptyGate, findKnownRow, gateAddressesOf, gateOf, parseRowKey, resolveRowKey, rowChanged, writesOf } from '../routes/desk.ts';
|
||||||
import { missingReadings } from '../wiring/engines.ts';
|
import { createOnlyViolation, decisionReadings, missingReadings } from '../wiring/engines.ts';
|
||||||
|
|
||||||
/** A1 受理记录的声明(裁到这一段用得着的几列):PN 与年用量必填 */
|
/** A1 受理记录的声明(裁到这一段用得着的几列):PN 与年用量必填 */
|
||||||
const RECORD = {
|
const RECORD = {
|
||||||
@@ -550,3 +550,51 @@ describe('落不下的行要点名 —— 不许悄悄丢', () => {
|
|||||||
).toEqual({ customer_code: 'CU-9527', round_ref: 'COV-1' });
|
).toEqual({ customer_code: 'CU-9527', round_ref: 'COV-1' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 只建不改的表(契约 `createOnly`)—— 2026-08-27 W05:数据工单里模型多铺了一行不该建的料号,
|
||||||
|
* 件上多出一行要人填的记录、红带点名它缺品牌。这一项守「在库的料被重建」这一形。
|
||||||
|
*/
|
||||||
|
describe('createOnly:键已在正式表 → 拒并指路', () => {
|
||||||
|
const target = { label: '芯片主数据(P-08)', keys: ['internal_pn'] };
|
||||||
|
const formal = [{ internal_pn: 'LM2596S-ADJ', brand_name: 'TI' }, { internal_pn: 'LM2596S-5.0' }];
|
||||||
|
|
||||||
|
it('在库的料号写下来 → 一句带指路的拒回(点名 drop)', () => {
|
||||||
|
const why = createOnlyViolation(target, { internal_pn: 'LM2596S-ADJ' }, formal);
|
||||||
|
expect(why).toContain('已经有「internal_pn=LM2596S-ADJ」');
|
||||||
|
expect(why).toContain('drop');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('本单经数据工单确认已入库的那一行 → 拒回换一句话:建好了别再写,不点名 drop(撤了读面少一行)', () => {
|
||||||
|
const why = createOnlyViolation(target, { internal_pn: 'LM2596S-ADJ' }, formal, [{ internal_pn: 'LM2596S-ADJ' }]);
|
||||||
|
expect(why).toContain('已入库');
|
||||||
|
expect(why).not.toContain('drop');
|
||||||
|
// 另一颗在库的料号与本单无关,照旧是原来那句
|
||||||
|
expect(createOnlyViolation(target, { internal_pn: 'LM2596S-5.0' }, formal, [{ internal_pn: 'LM2596S-ADJ' }])).toContain('drop');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('库里没有的料号 → 放行;复合主键按全部键列比', () => {
|
||||||
|
expect(createOnlyViolation(target, { internal_pn: 'XL2596S-5.0' }, formal)).toBeUndefined();
|
||||||
|
const sub = { label: '标准化参数集', keys: ['internal_pn', 'param_name'] };
|
||||||
|
const rows = [{ internal_pn: 'LM2596S-ADJ', param_name: '输出电压' }];
|
||||||
|
expect(createOnlyViolation(sub, { internal_pn: 'LM2596S-ADJ', param_name: '输出电压' }, rows)).toBeDefined();
|
||||||
|
expect(createOnlyViolation(sub, { internal_pn: 'LM2596S-ADJ', param_name: '使能阈值' }, rows)).toBeUndefined();
|
||||||
|
expect(createOnlyViolation(sub, { internal_pn: 'XL2596S-5.0', param_name: '输出电压' }, rows)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 决策卡的回答是留痕里的事实(2026-08-27:审核哨兵不再读模型自写的旗) */
|
||||||
|
describe('decisionReadings:按 card-reply 数', () => {
|
||||||
|
it('最后一次选的值 + 按值计数;custom: 一律归 custom', () => {
|
||||||
|
const trail = [
|
||||||
|
{ kind: 'card-sent', detail: {} },
|
||||||
|
{ kind: 'card-reply', detail: { value: 'custom:重做一次' } },
|
||||||
|
{ kind: 'card-reply', detail: { value: 'custom:再改一下' } },
|
||||||
|
{ kind: 'card-reply', detail: { value: 'approve' } },
|
||||||
|
];
|
||||||
|
expect(decisionReadings(trail)).toEqual({ decision_last: 'approve', decision_approve_total: 1, decision_reject_total: 0, decision_custom_total: 2 });
|
||||||
|
});
|
||||||
|
it('一张都没答 → last 为空串、各计数铺 0(哨兵里 decision_last = "approve" 恒假,门关着)', () => {
|
||||||
|
expect(decisionReadings([])).toEqual({ decision_last: '', decision_approve_total: 0, decision_reject_total: 0, decision_custom_total: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* 实例五态 —— 对 task 运行态 / 终态的**如实映射**(`docs/API文档-活动模块.md` §0)。
|
||||||
|
*
|
||||||
|
* pending 建了单、推进器还没领到(`used.steps = 0`;`task.start` 不同步跑第一拍)
|
||||||
|
* running 在途:running / awaiting-human / awaiting-event / suspended / landing-blocked
|
||||||
|
* completed 终态 released
|
||||||
|
* rejected 验收被负责人打回、模型还没重新提交的那一段 —— 本引擎里驳回是环内回转不是终态
|
||||||
|
* (`loop_state.pending` 为 null 或 `verdict='reject'`,且最近一条 `裁决:` 不是 pass);
|
||||||
|
* 模型重新提交、新验收卡发出后 pending 再被立起,就回到 running
|
||||||
|
* failed 终态 blocked / stalled / exhausted(kind=failed),以及 terminated(kind=terminated)
|
||||||
|
*
|
||||||
|
* 纯函数;读数由调用方从 `sys.task` / `sys.task_memory` / `sys.loop_state` 取来。
|
||||||
|
*/
|
||||||
|
import type { InstanceReason, InstanceState, TerminalState } from '@contracts';
|
||||||
|
import { TERMINAL_STATE_LABEL } from '@contracts';
|
||||||
|
|
||||||
|
/** 最近一次人的裁决(`release-verdict` 里标题以 `裁决:` 开头的那条;落地那一路也记同 kind,不算) */
|
||||||
|
export type VerdictFact = { readonly pass: boolean; readonly comment: string; readonly byName: string | undefined };
|
||||||
|
/** 在途验收(`loop_state.pending`):卡发出即有;verdict 到达后填上;重做被消费后整个清空 */
|
||||||
|
export type PendingFact = { readonly verdict: 'pass' | 'reject' | undefined; readonly comment: string | undefined };
|
||||||
|
|
||||||
|
export type StateFacts = {
|
||||||
|
readonly terminal: TerminalState | undefined;
|
||||||
|
readonly reason: string | undefined;
|
||||||
|
readonly runtime: string;
|
||||||
|
readonly usedSteps: number;
|
||||||
|
readonly lastVerdict: VerdictFact | undefined;
|
||||||
|
readonly pending: PendingFact | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StateVerdict = { readonly state: InstanceState; readonly reason: InstanceReason | null };
|
||||||
|
|
||||||
|
export const VERDICT_PREFIX = '裁决:';
|
||||||
|
|
||||||
|
function rejectedReason(comment: string, byName: string | undefined): InstanceReason {
|
||||||
|
if (comment === '') return { kind: 'rejected', text: '验收驳回:负责人未写明具体意见,已退回重做' };
|
||||||
|
return { kind: 'rejected', text: `验收驳回:${comment}${byName === undefined ? '' : ` —— 负责人 ${byName}`}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUDGET_ITEM_LABEL: Readonly<Record<string, string>> = { steps: '步数', tokens: 'token', activeMs: '活跃时长', humanCalls: '调人次数' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `exhausted` 那一路 `sys.task.reason` 存的是预算执行器裁决的 JSON(`{"kind":"exhausted","breaches":[{item,used,cap}]}`)——
|
||||||
|
* 机器话;摆给人的一句要说「哪一项、用了多少、上限多少」。认不出的形状原样交出去(不编)。
|
||||||
|
*/
|
||||||
|
export function failedReasonText(terminal: TerminalState, reason: string | undefined): string {
|
||||||
|
if (reason === undefined || reason === '') return TERMINAL_STATE_LABEL[terminal];
|
||||||
|
if (terminal !== 'exhausted') return reason;
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(reason);
|
||||||
|
const breaches = typeof parsed === 'object' && parsed !== null ? (parsed as { breaches?: unknown }).breaches : undefined;
|
||||||
|
if (!Array.isArray(breaches) || breaches.length === 0) return reason;
|
||||||
|
const parts = breaches.map((b) => {
|
||||||
|
const one = (typeof b === 'object' && b !== null ? b : {}) as { item?: unknown; used?: unknown; cap?: unknown };
|
||||||
|
const item = typeof one.item === 'string' ? one.item : '';
|
||||||
|
const scale = item === 'activeMs' ? 60_000 : 1;
|
||||||
|
const fmt = (v: unknown) => (typeof v === 'number' ? String(Math.round(v / scale)) : '?');
|
||||||
|
return `${BUDGET_ITEM_LABEL[item] ?? item} ${fmt(one.used)}/${fmt(one.cap)}${item === 'activeMs' ? ' 分钟' : ''}`;
|
||||||
|
});
|
||||||
|
return `预算耗尽:${parts.join('、')}`;
|
||||||
|
} catch {
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function instanceStateOf(f: StateFacts): StateVerdict {
|
||||||
|
if (f.terminal === 'released') return { state: 'completed', reason: null };
|
||||||
|
if (f.terminal === 'terminated') return { state: 'failed', reason: { kind: 'terminated', text: f.reason ?? '已手动终止' } };
|
||||||
|
if (f.terminal !== undefined) {
|
||||||
|
return { state: 'failed', reason: { kind: 'failed', text: failedReasonText(f.terminal, f.reason) } };
|
||||||
|
}
|
||||||
|
if (f.pending?.verdict === 'reject') {
|
||||||
|
return { state: 'rejected', reason: rejectedReason((f.pending.comment ?? f.lastVerdict?.comment ?? '').trim(), f.lastVerdict?.byName) };
|
||||||
|
}
|
||||||
|
if (f.pending === undefined && f.lastVerdict !== undefined && !f.lastVerdict.pass) {
|
||||||
|
return { state: 'rejected', reason: rejectedReason(f.lastVerdict.comment.trim(), f.lastVerdict.byName) };
|
||||||
|
}
|
||||||
|
if (f.runtime === 'running' && f.usedSteps === 0) return { state: 'pending', reason: null };
|
||||||
|
return { state: 'running', reason: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从任务记忆里摘最近一次人的裁决;`nameOf` 把 `detail.by`(staffId)换成名字 */
|
||||||
|
export function verdictFactOf(
|
||||||
|
entries: ReadonlyArray<{ readonly kind: string; readonly title: string; readonly detail?: Record<string, unknown> | undefined }>,
|
||||||
|
nameOf: (staffId: string) => string | undefined = () => undefined,
|
||||||
|
): VerdictFact | undefined {
|
||||||
|
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
||||||
|
const entry = entries[i];
|
||||||
|
if (entry === undefined || entry.kind !== 'release-verdict' || !entry.title.startsWith(VERDICT_PREFIX)) continue;
|
||||||
|
const by = entry.detail?.['by'];
|
||||||
|
const comment = entry.detail?.['comment'];
|
||||||
|
return {
|
||||||
|
pass: entry.title === `${VERDICT_PREFIX}pass`,
|
||||||
|
comment: typeof comment === 'string' ? comment : '',
|
||||||
|
byName: typeof by === 'string' ? nameOf(by) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* L3 的「动作过程记录」—— 一个计划项一步,按案例模型的文档序排。
|
||||||
|
*
|
||||||
|
* 三处拼起来(没有哪一处单独够用):
|
||||||
|
* · `loop_state.parsed.items`(CMMN 计划项:名字、谁办 —— `manualActivation` 的项是触达人的那几步);
|
||||||
|
* · `loop_state.runtime.items[].state`(办到哪了:Completed / Active / Failed / …,**不带时间**);
|
||||||
|
* · 任务记忆里引擎写的三种痕(`开始办:<名>` / `完成:<名>` / `自动收步:<id> …`)给出时刻与用时。
|
||||||
|
* 阶段(stage)/ 里程碑 / 监听器不是「一步」,不摆。
|
||||||
|
*/
|
||||||
|
import type { InstanceState, InstanceStep } from '@contracts';
|
||||||
|
import type { ItemRuntime, PlanItem } from '@modules/loop-engine';
|
||||||
|
|
||||||
|
export const STEP_ITEM_TYPES: ReadonlySet<string> = new Set(['humanTask', 'decisionTask', 'processTask', 'caseTask']);
|
||||||
|
export const STEP_START = '开始办:';
|
||||||
|
export const STEP_DONE = '完成:';
|
||||||
|
export const STEP_AUTO = '自动收步:';
|
||||||
|
|
||||||
|
export type HistoryLine = { readonly kind: string; readonly title: string; readonly at: string };
|
||||||
|
|
||||||
|
export type StepFacts = {
|
||||||
|
readonly items: readonly PlanItem[];
|
||||||
|
readonly runtimeItems: readonly ItemRuntime[];
|
||||||
|
/** 剧本 `steps[].intent`(item id → 一句话) */
|
||||||
|
readonly intents: ReadonlyMap<string, string>;
|
||||||
|
readonly history: readonly HistoryLine[];
|
||||||
|
readonly state: InstanceState;
|
||||||
|
readonly ownerName: string;
|
||||||
|
readonly llm: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Res = InstanceStep['res'];
|
||||||
|
|
||||||
|
const IN_PROGRESS: ReadonlySet<string> = new Set(['Active', 'Enabled', 'Failed', 'Suspended']);
|
||||||
|
|
||||||
|
function baseRes(state: string | undefined): Res {
|
||||||
|
if (state === 'Completed') return 'done';
|
||||||
|
if (state === 'Active' || state === 'Enabled') return 'current';
|
||||||
|
if (state === 'Failed') return 'fail';
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同一项可能重复实例化(repetitionRule),看最新那一实例 */
|
||||||
|
function latestRuntime(items: readonly ItemRuntime[], id: string): ItemRuntime | undefined {
|
||||||
|
let best: ItemRuntime | undefined;
|
||||||
|
for (const it of items) if (it.id === id && (best === undefined || it.instance > best.instance)) best = it;
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function millis(iso: string): number | undefined {
|
||||||
|
const t = Date.parse(iso);
|
||||||
|
return Number.isFinite(t) ? t : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stepsOf(f: StepFacts): InstanceStep[] {
|
||||||
|
const rows = f.items
|
||||||
|
.filter((item) => STEP_ITEM_TYPES.has(item.type))
|
||||||
|
.map((item) => {
|
||||||
|
const rt = latestRuntime(f.runtimeItems, item.id);
|
||||||
|
return { item, rt, res: baseRes(rt?.state) as Res };
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 失败 / 驳回的那一行:正在办的那一步记 fail(过程面「当前步记已终止 / 驳回」)。
|
||||||
|
* 没有正在办的(终止前一步没开、或验收打回时项都已收)→ 失败取第一个没到的,驳回取最后一个已完成的。
|
||||||
|
*/
|
||||||
|
if (f.state === 'failed' || f.state === 'rejected') {
|
||||||
|
const live = rows.findIndex((r) => r.rt !== undefined && IN_PROGRESS.has(r.rt.state));
|
||||||
|
if (live >= 0) rows[live]!.res = 'fail';
|
||||||
|
else if (f.state === 'failed') {
|
||||||
|
const next = rows.findIndex((r) => r.res === 'pending');
|
||||||
|
if (next >= 0) rows[next]!.res = 'fail';
|
||||||
|
} else {
|
||||||
|
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
||||||
|
if (rows[i]!.res === 'done') {
|
||||||
|
rows[i]!.res = 'fail';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.map(({ item, res }, i) => {
|
||||||
|
const start = f.history.find((h) => h.title === `${STEP_START}${item.name}`);
|
||||||
|
const ends = f.history.filter((h) => h.title === `${STEP_DONE}${item.name}` || h.title.startsWith(`${STEP_AUTO}${item.id} `));
|
||||||
|
const end = ends[ends.length - 1];
|
||||||
|
const startMs = start === undefined ? undefined : millis(start.at);
|
||||||
|
const endMs = end === undefined ? undefined : millis(end.at);
|
||||||
|
const at = start?.at ?? (res === 'done' ? (end?.at ?? null) : null);
|
||||||
|
const durationMs = startMs !== undefined && endMs !== undefined && endMs >= startMs && res !== 'current' ? endMs - startMs : null;
|
||||||
|
return {
|
||||||
|
seq: i + 1,
|
||||||
|
name: item.name,
|
||||||
|
by: item.manualActivation ? 'human' : 'agent',
|
||||||
|
byName: item.manualActivation ? f.ownerName : f.llm,
|
||||||
|
at,
|
||||||
|
durationMs,
|
||||||
|
res,
|
||||||
|
note: f.intents.get(item.id) ?? '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
/**
|
||||||
|
* 启动参数表 —— 从活动契约的 `input.data`(JSON Schema)派生,一活动一份;前端只按它渲染,不写死字段。
|
||||||
|
*
|
||||||
|
* 清单里怎么写(都是 JSON Schema 的标准词或本仓已有的非标词,`jsonSchemaSchema` 是 `record(unknown)`,扩展键能过):
|
||||||
|
* · `title` → label;`description` → desc;`examples[0]` → placeholder;`default` → default;`required[]` → required
|
||||||
|
* · `enum` / `enumNames` → select 的候选(E04 / I01 已在用)
|
||||||
|
* · `x-control`:`text | textarea | select | combo` —— 不写就按类型推:enum → select、`array<string>` → list、string → text;
|
||||||
|
* **数组字段也可以标 combo / select**(表单只选一颗,提交单个字符串,后端包成一元数组;传数组也照收)
|
||||||
|
* · `x-options`:`{ ledger: '域.对象', value: '列', label: '列' }` —— combo / select 的候选从正式台账现读(客户 / 料号这类「台账里可能有」的)
|
||||||
|
* · `format: 'file'` 的字段**本期不上表单**(文档没有文件控件;W05 的 datasheet 在数据工单里传)
|
||||||
|
* · `x-order`:表单里的次序(数字,小的在前)。**必须声明**才有稳定次序 —— 清单存在 jsonb 里,
|
||||||
|
* PG 按键长再按字节序重排对象键,`properties` 的书写顺序到不了这里(实测:原料号跑到了客户需求前面)
|
||||||
|
*
|
||||||
|
* 校验与归一(`validateLaunchParams`)是服务端那一道:前端禁提交只是礼貌,拦住的得是这里。
|
||||||
|
* 纯函数、零 IO;候选的读取由调用方以 `resolve` 注入。
|
||||||
|
*/
|
||||||
|
import type { LaunchOption, LaunchParam, LaunchParamType } from '@contracts';
|
||||||
|
import { LAUNCH_PARAM_TYPES } from '@contracts';
|
||||||
|
|
||||||
|
export type OptionsSource = { readonly ledger: string; readonly value: string; readonly label: string };
|
||||||
|
|
||||||
|
/** 一个入参字段的规格 —— JSON Schema 里我们认的那几个词 */
|
||||||
|
export type FieldSpec = {
|
||||||
|
readonly key: string;
|
||||||
|
readonly required: boolean;
|
||||||
|
readonly title: string | undefined;
|
||||||
|
readonly description: string | undefined;
|
||||||
|
readonly type: string | undefined;
|
||||||
|
readonly itemType: string | undefined;
|
||||||
|
readonly format: string | undefined;
|
||||||
|
readonly control: string | undefined;
|
||||||
|
readonly enumValues: readonly unknown[] | undefined;
|
||||||
|
readonly enumNames: readonly unknown[] | undefined;
|
||||||
|
readonly example: unknown;
|
||||||
|
readonly defaultValue: unknown;
|
||||||
|
readonly optionsSource: OptionsSource | undefined;
|
||||||
|
/** 表单次序(`x-order`);没声明的排在声明了的后面、彼此保持读到的顺序 */
|
||||||
|
readonly order: number | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(value: unknown): string | undefined {
|
||||||
|
return typeof value === 'string' ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionsSourceOf(value: unknown): OptionsSource | undefined {
|
||||||
|
const raw = asRecord(value);
|
||||||
|
if (raw === undefined) return undefined;
|
||||||
|
const ledger = asString(raw['ledger']);
|
||||||
|
const valueCol = asString(raw['value']);
|
||||||
|
const labelCol = asString(raw['label']);
|
||||||
|
if (ledger === undefined || !ledger.includes('.') || valueCol === undefined || valueCol === '') return undefined;
|
||||||
|
return { ledger, value: valueCol, label: labelCol === undefined || labelCol === '' ? valueCol : labelCol };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `input.data` 的 properties 逐个读成规格,保属性序(表单顺序就是清单顺序) */
|
||||||
|
export function fieldSpecsOf(inputSchema: Readonly<Record<string, unknown>>): FieldSpec[] {
|
||||||
|
const props = asRecord(inputSchema['properties']) ?? {};
|
||||||
|
const requiredRaw = inputSchema['required'];
|
||||||
|
const required = Array.isArray(requiredRaw) ? requiredRaw.filter((k): k is string => typeof k === 'string') : [];
|
||||||
|
const specs = Object.entries(props).map(([key, raw]): FieldSpec => {
|
||||||
|
const spec = asRecord(raw) ?? {};
|
||||||
|
const items = asRecord(spec['items']);
|
||||||
|
const enumRaw = spec['enum'];
|
||||||
|
const namesRaw = spec['enumNames'];
|
||||||
|
const examples = spec['examples'];
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
required: required.includes(key),
|
||||||
|
title: asString(spec['title']),
|
||||||
|
description: asString(spec['description']),
|
||||||
|
type: asString(spec['type']),
|
||||||
|
itemType: items === undefined ? undefined : asString(items['type']),
|
||||||
|
format: asString(spec['format']),
|
||||||
|
control: asString(spec['x-control']),
|
||||||
|
enumValues: Array.isArray(enumRaw) ? enumRaw : undefined,
|
||||||
|
enumNames: Array.isArray(namesRaw) ? namesRaw : undefined,
|
||||||
|
example: Array.isArray(examples) ? examples[0] : undefined,
|
||||||
|
defaultValue: spec['default'],
|
||||||
|
optionsSource: optionsSourceOf(spec['x-options']),
|
||||||
|
order: typeof spec['x-order'] === 'number' && Number.isFinite(spec['x-order']) ? spec['x-order'] : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// 稳定排序:声明了 x-order 的按数字,没声明的殿后并保持原序
|
||||||
|
return specs
|
||||||
|
.map((spec, i) => ({ spec, i }))
|
||||||
|
.sort((a, b) => (a.spec.order ?? Number.POSITIVE_INFINITY) - (b.spec.order ?? Number.POSITIVE_INFINITY) || a.i - b.i)
|
||||||
|
.map((x) => x.spec);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLaunchType(value: string | undefined): value is LaunchParamType {
|
||||||
|
return value !== undefined && (LAUNCH_PARAM_TYPES as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这一格该长成什么控件;`undefined` = 本期不上表单(文件 / 对象 / 非字符串数组)。
|
||||||
|
* 数组一律 list(`x-control` 对数组不生效 —— 一串值只有一种摆法);标量按 `x-control` > enum > 文本。
|
||||||
|
*/
|
||||||
|
export function controlOf(spec: FieldSpec): LaunchParamType | undefined {
|
||||||
|
if (spec.format === 'file') return undefined;
|
||||||
|
if (spec.type === 'array') {
|
||||||
|
if (spec.itemType !== undefined && spec.itemType !== 'string') return undefined;
|
||||||
|
// 数组字段也可以摆成单选(`x-control: combo | select | text`):表单上只选一颗,提交时后端包成一元数组
|
||||||
|
return isLaunchType(spec.control) && spec.control !== 'list' ? spec.control : 'list';
|
||||||
|
}
|
||||||
|
if (spec.type === 'object') return undefined;
|
||||||
|
if (isLaunchType(spec.control) && spec.control !== 'list') return spec.control;
|
||||||
|
if (spec.enumValues !== undefined && spec.enumValues.length > 0) return 'select';
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 枚举 → 候选:有 `enumNames` 配对就给人读名,没有就拿枚举值本身当标签 */
|
||||||
|
export function enumOptionsOf(spec: FieldSpec): LaunchOption[] | undefined {
|
||||||
|
if (spec.enumValues === undefined || spec.enumValues.length === 0) return undefined;
|
||||||
|
return spec.enumValues.map((v, i) => ({ value: String(v), label: String(spec.enumNames?.[i] ?? v) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeholderOf(spec: FieldSpec, type: LaunchParamType): string | undefined {
|
||||||
|
const example = spec.example;
|
||||||
|
if (example === undefined || example === null) return undefined;
|
||||||
|
// 数组字段(list,或摆成单选的数组)的示例是一串:占位摆第一颗
|
||||||
|
if (type === 'list' || spec.type === 'array') {
|
||||||
|
const first = Array.isArray(example) ? example[0] : example;
|
||||||
|
return typeof first === 'string' ? first : undefined;
|
||||||
|
}
|
||||||
|
return typeof example === 'string' ? example : JSON.stringify(example);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultOf(spec: FieldSpec, type: LaunchParamType): string | string[] | undefined {
|
||||||
|
const value = spec.defaultValue;
|
||||||
|
if (type === 'list') {
|
||||||
|
return Array.isArray(value) && value.every((v): v is string => typeof v === 'string') ? value : undefined;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : undefined;
|
||||||
|
return typeof value === 'string' ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一格规格 → 一条参数。`resolved` 是从台账读回来的候选(`x-options`);没有就退回枚举。
|
||||||
|
* select 没有任何候选就降成 text —— 「只能选」却无可选,等于把这一格锁死。
|
||||||
|
*/
|
||||||
|
export function launchParamOf(spec: FieldSpec, resolved: readonly LaunchOption[] | undefined): LaunchParam | undefined {
|
||||||
|
let type = controlOf(spec);
|
||||||
|
if (type === undefined) return undefined;
|
||||||
|
const options = resolved === undefined ? enumOptionsOf(spec) : [...resolved];
|
||||||
|
if (type === 'select' && (options === undefined || options.length === 0)) type = 'text';
|
||||||
|
const withOptions = (type === 'select' || type === 'combo') && options !== undefined && options.length > 0;
|
||||||
|
const placeholder = placeholderOf(spec, type);
|
||||||
|
const fallback = defaultOf(spec, type);
|
||||||
|
return {
|
||||||
|
key: spec.key,
|
||||||
|
label: spec.title ?? spec.key,
|
||||||
|
type,
|
||||||
|
...(spec.required ? { required: true } : {}),
|
||||||
|
...(spec.description === undefined ? {} : { desc: spec.description }),
|
||||||
|
...(placeholder === undefined ? {} : { placeholder }),
|
||||||
|
...(withOptions ? { options } : {}),
|
||||||
|
...(fallback === undefined ? {} : { default: fallback }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 整份参数表;候选读取由调用方注入(读不到就当没有候选,**不炸**) */
|
||||||
|
export async function launchParamsOf(
|
||||||
|
inputSchema: Readonly<Record<string, unknown>>,
|
||||||
|
resolve: (source: OptionsSource) => Promise<readonly LaunchOption[] | undefined>,
|
||||||
|
): Promise<LaunchParam[]> {
|
||||||
|
const out: LaunchParam[] = [];
|
||||||
|
for (const spec of fieldSpecsOf(inputSchema)) {
|
||||||
|
const resolved = spec.optionsSource === undefined ? undefined : await resolve(spec.optionsSource);
|
||||||
|
const param = launchParamOf(spec, resolved);
|
||||||
|
if (param !== undefined) out.push(param);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LaunchIssue = { readonly key: string; readonly message: string };
|
||||||
|
/** 线上的形状:list 是字符串数组,其余是字符串 */
|
||||||
|
export type LaunchWire = Readonly<Record<string, string | readonly string[]>>;
|
||||||
|
export type LaunchValidation =
|
||||||
|
| { readonly ok: true; readonly data: Record<string, unknown> }
|
||||||
|
| { readonly ok: false; readonly issues: readonly LaunchIssue[] };
|
||||||
|
|
||||||
|
function coerceScalar(value: string, spec: FieldSpec | undefined): { value: unknown } | { error: string } {
|
||||||
|
const type = spec?.type;
|
||||||
|
if (type === 'integer' || type === 'number') {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n) || (type === 'integer' && !Number.isInteger(n))) return { error: `要填${type === 'integer' ? '整数' : '数字'}` };
|
||||||
|
return { value: n };
|
||||||
|
}
|
||||||
|
if (type === 'boolean') {
|
||||||
|
if (value === 'true' || value === 'false') return { value: value === 'true' };
|
||||||
|
return { error: '只能是 true / false' };
|
||||||
|
}
|
||||||
|
return { value };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务端的那一道校验:未知键 / 必填空 / 类型不符 / select 值不在候选 → issues(`key` 点名是哪一格);
|
||||||
|
* combo 候选之外的值合法(存在性检查在启动后由活动做);可选的空值不落入参。
|
||||||
|
* 通过时返回归一后的 `data`(按清单类型把数字 / 布尔转回真类型),直接交 `task.start`。
|
||||||
|
*/
|
||||||
|
export function validateLaunchParams(params: readonly LaunchParam[], input: LaunchWire, specs: readonly FieldSpec[] = []): LaunchValidation {
|
||||||
|
const issues: LaunchIssue[] = [];
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
const known = new Set(params.map((p) => p.key));
|
||||||
|
const specOf = new Map(specs.map((s) => [s.key, s]));
|
||||||
|
for (const key of Object.keys(input)) {
|
||||||
|
if (!known.has(key)) issues.push({ key, message: '不是这个活动的启动参数' });
|
||||||
|
}
|
||||||
|
for (const param of params) {
|
||||||
|
const raw = input[param.key];
|
||||||
|
const required = param.required === true;
|
||||||
|
const spec = specOf.get(param.key);
|
||||||
|
/*
|
||||||
|
* 清单里是数组的字段(list,或摆成单选的 combo / select):线上 string 与 string[] 都收,
|
||||||
|
* 归一成 string[] 交活动 —— 前端单选传一个字符串,后端包成一元数组(2026-08-27 用户定)。
|
||||||
|
*/
|
||||||
|
if (param.type === 'list' || spec?.type === 'array') {
|
||||||
|
const entries = (typeof raw === 'string' ? [raw] : (raw ?? [])).map((v) => v.trim()).filter((v) => v !== '');
|
||||||
|
if (entries.length === 0) {
|
||||||
|
if (required) issues.push({ key: param.key, message: param.type === 'list' ? '必填,至少一项' : '必填' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (param.type === 'select' && param.options !== undefined) {
|
||||||
|
const off = entries.find((v) => !param.options?.some((o) => o.value === v));
|
||||||
|
if (off !== undefined) {
|
||||||
|
issues.push({ key: param.key, message: '不在候选里(单选只能选候选)' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data[param.key] = entries;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (raw !== undefined && typeof raw !== 'string') {
|
||||||
|
issues.push({ key: param.key, message: '要传字符串' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const value = (raw ?? '').trim();
|
||||||
|
if (value === '') {
|
||||||
|
if (required) issues.push({ key: param.key, message: '必填' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (param.type === 'select' && param.options !== undefined && !param.options.some((o) => o.value === value)) {
|
||||||
|
issues.push({ key: param.key, message: '不在候选里(单选只能选候选)' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const coerced = coerceScalar(value, spec);
|
||||||
|
if ('error' in coerced) {
|
||||||
|
issues.push({ key: param.key, message: coerced.error });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
data[param.key] = coerced.value;
|
||||||
|
}
|
||||||
|
return issues.length === 0 ? { ok: true, data } : { ok: false, issues };
|
||||||
|
}
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
/**
|
||||||
|
* 运营中心 · 活动模块的用例编排(app 层;`docs/API文档-活动模块.md`)。
|
||||||
|
*
|
||||||
|
* 读是跨模块的投影:活动 = 注册表卡片 + 清单 + 运营侧档案 + 实例计数;
|
||||||
|
* 实例 = `sys.task` 一行 + 任务记忆里的裁决与引擎痕 + loop 运行态 + 输入 / 产出文件。
|
||||||
|
* 写一律走既有 port:`task.start`(`origin:'ops'`)、`task.control('terminate')`、`registry.setEnabled`。
|
||||||
|
*
|
||||||
|
* 启动的幂等:`sys.activity_launch` 按键占位(一句 SQL 判 + 占),同键重放回第一次那一单;
|
||||||
|
* 键占了、单还没建成的那 60 秒里再来 = 409(不是第二单,也不是无限等)。
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
LedgerQuery,
|
||||||
|
ViewData,
|
||||||
|
ActivityDetail,
|
||||||
|
ActivityHue,
|
||||||
|
ActivityStatus,
|
||||||
|
ActivitySummary,
|
||||||
|
InstanceDetail,
|
||||||
|
InstanceFile,
|
||||||
|
InstanceList,
|
||||||
|
InstanceSource,
|
||||||
|
InstanceState,
|
||||||
|
InstanceSummary,
|
||||||
|
LaunchInput,
|
||||||
|
LaunchOption,
|
||||||
|
LaunchParams,
|
||||||
|
StaffId,
|
||||||
|
StaffRef,
|
||||||
|
TaskId,
|
||||||
|
TerminalState,
|
||||||
|
} from '@contracts';
|
||||||
|
import { ACTIVITY_HUES, TERMINAL_STATES, staffIdSchema, taskIdSchema } from '@contracts';
|
||||||
|
import type { ActivityCard } from '@modules/activity-registry';
|
||||||
|
import type { ItemRuntime, PlanItem } from '@modules/loop-engine';
|
||||||
|
import { StaffNotFoundError } from '@modules/org';
|
||||||
|
import { renderView } from '@modules/interaction';
|
||||||
|
import type { StartTaskInput } from '@modules/task';
|
||||||
|
import { MIME_OF } from '../file-compose.ts';
|
||||||
|
import type { FileFormat } from '../file-compose.ts';
|
||||||
|
import { fieldSpecsOf, launchParamsOf, validateLaunchParams } from './launch-params.ts';
|
||||||
|
import type { FieldSpec, LaunchIssue, OptionsSource } from './launch-params.ts';
|
||||||
|
import { instanceStateOf, verdictFactOf } from './instance-state.ts';
|
||||||
|
import type { StateVerdict } from './instance-state.ts';
|
||||||
|
import { stepsOf } from './instance-steps.ts';
|
||||||
|
import { applyInstanceFilters, inputFilesOf, inputSummaryOf, outputSummaryOf, pageOf, statsOf } from './summaries.ts';
|
||||||
|
import type { InstanceQuery } from './summaries.ts';
|
||||||
|
import { LEDGER_TEMPLATE, injectHeightScript } from './view-html.ts';
|
||||||
|
import { resolveViewData, viewScopeOf } from './view-scope.ts';
|
||||||
|
import type { ViewRead } from './view-scope.ts';
|
||||||
|
import { caseStamp } from '../case-stamp.ts';
|
||||||
|
import type { ActivityOpsStore, TaskRow } from './store.ts';
|
||||||
|
|
||||||
|
/** 键占了、单还没建成:这么久之后允许重占(进程崩在 claim 与 start 之间不该把这枚键锁死) */
|
||||||
|
export const LAUNCH_CLAIM_STALE_MS = 60_000;
|
||||||
|
/** combo 候选内联上限(文档 §4:本期一律内联;更大的表以后走检索接口) */
|
||||||
|
export const OPTIONS_CAP = 500;
|
||||||
|
|
||||||
|
/** 输入 / 产出文件的存取 —— app 里现成的两段(`upload-store.ts` / `asset-archive.ts`),按接口注入好替身 */
|
||||||
|
export type InstanceFileStore = {
|
||||||
|
readUpload(ref: string): Promise<{ name: string; contentType: string; bytes: Uint8Array } | undefined>;
|
||||||
|
listArchived(taskId: TaskId): Promise<ReadonlyArray<{ address: string; key: Record<string, string>; format: FileFormat; name: string; size: number }>>;
|
||||||
|
readArchived(taskId: TaskId, address: string, key: Readonly<Record<string, string>>): Promise<{ bytes: Uint8Array; name: string; format: FileFormat } | undefined>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 依赖只声明**用到的那几面**(不是整个 `ActivityRegistryService` / `TaskService` …):
|
||||||
|
* 真服务结构上都装得进来(server.ts 装配处由 tsc 核),测试只需拼几个字面量,不用造整本清单。
|
||||||
|
*/
|
||||||
|
/** 清单里运营面要看的几处(台账声明只为沙箱看板铺 tally 读数) */
|
||||||
|
export type ManifestFace = {
|
||||||
|
readonly identity: { readonly name: string };
|
||||||
|
readonly contract: { readonly input: { readonly data: Readonly<Record<string, unknown>> } };
|
||||||
|
readonly declarations: {
|
||||||
|
readonly models: readonly string[];
|
||||||
|
readonly ledger: ReadonlyArray<{ readonly domain: string; readonly object: string; readonly fields: ReadonlyArray<{ readonly name: string; readonly tally?: readonly string[] | undefined }> }>;
|
||||||
|
};
|
||||||
|
readonly assets: { readonly viewTemplates: ReadonlyArray<{ readonly name: string; readonly html: string; readonly data?: ViewData | undefined }> };
|
||||||
|
};
|
||||||
|
export type RegistryFace = {
|
||||||
|
list(): Promise<ActivityCard[]>;
|
||||||
|
get(activityId: string): Promise<ActivityCard | undefined>;
|
||||||
|
manifestOf(activityId: string, packageVersion?: string): Promise<ManifestFace>;
|
||||||
|
setEnabled(activityId: string, enabled: boolean): Promise<void>;
|
||||||
|
};
|
||||||
|
export type TaskFace = {
|
||||||
|
start(input: StartTaskInput): Promise<{ readonly id: TaskId }>;
|
||||||
|
control(taskId: TaskId, semantic: 'terminate', by: StaffId, reason?: string): Promise<unknown>;
|
||||||
|
};
|
||||||
|
export type OrgFace = {
|
||||||
|
listStaff(): Promise<{ staff: ReadonlyArray<{ readonly id: string; readonly name: string }> }>;
|
||||||
|
getStaff(staffId: StaffId): Promise<{ readonly name: string; readonly state: string }>;
|
||||||
|
};
|
||||||
|
export type HistoryEntry = { readonly kind: string; readonly title: string; readonly at: string; readonly detail?: Record<string, unknown> | undefined };
|
||||||
|
export type MemoryFace = { history(taskId: TaskId): Promise<ReadonlyArray<HistoryEntry>> };
|
||||||
|
export type LedgerFace = {
|
||||||
|
describe(address: { domain: string; object: string }): Promise<{ readonly columns: ReadonlyArray<{ readonly name: string }> } | undefined>;
|
||||||
|
query(query: LedgerQuery): Promise<{ readonly rows: ReadonlyArray<Readonly<Record<string, unknown>>> }>;
|
||||||
|
};
|
||||||
|
export type LoopFace = {
|
||||||
|
readonly model: { readonly steps?: ReadonlyArray<{ readonly item: string; readonly intent: string }> | undefined };
|
||||||
|
readonly parsed: { readonly items: readonly PlanItem[] };
|
||||||
|
readonly runtime: { readonly items: readonly ItemRuntime[] };
|
||||||
|
readonly pending?: { readonly verdict?: 'pass' | 'reject' | undefined; readonly comment?: string | undefined } | undefined;
|
||||||
|
};
|
||||||
|
export type LoopStateFace = { load(taskId: TaskId): Promise<LoopFace | undefined> };
|
||||||
|
|
||||||
|
export type ActivityOpsDeps = {
|
||||||
|
readonly registry: RegistryFace;
|
||||||
|
readonly task: TaskFace;
|
||||||
|
readonly org: OrgFace;
|
||||||
|
readonly memory: MemoryFace;
|
||||||
|
readonly ledger: LedgerFace;
|
||||||
|
readonly loopState: LoopStateFace;
|
||||||
|
readonly store: ActivityOpsStore;
|
||||||
|
readonly files: InstanceFileStore;
|
||||||
|
readonly clock: { now(): Date };
|
||||||
|
/** 读不到候选这类**不该炸**的事,说一声就好 */
|
||||||
|
readonly warn?: (line: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Actor = { readonly id: StaffId; readonly name: string };
|
||||||
|
|
||||||
|
export type LaunchOutcome =
|
||||||
|
| { readonly kind: 'ok'; readonly detail: InstanceDetail; readonly created: boolean }
|
||||||
|
| { readonly kind: 'not-found' }
|
||||||
|
| { readonly kind: 'disabled' }
|
||||||
|
| { readonly kind: 'invalid'; readonly issues: readonly LaunchIssue[] }
|
||||||
|
| { readonly kind: 'owner-invalid'; readonly message: string }
|
||||||
|
| { readonly kind: 'busy'; readonly message: string };
|
||||||
|
|
||||||
|
export type TerminateOutcome = { readonly kind: 'ok'; readonly detail: InstanceDetail } | { readonly kind: 'not-found' } | { readonly kind: 'terminal' };
|
||||||
|
|
||||||
|
export type FileOutcome =
|
||||||
|
| { readonly kind: 'file'; readonly name: string; readonly contentType: string; readonly bytes: Uint8Array }
|
||||||
|
| { readonly kind: 'no-instance' }
|
||||||
|
| { readonly kind: 'no-file' };
|
||||||
|
|
||||||
|
export type ViewOutcome = { readonly kind: 'html'; readonly html: string } | { readonly kind: 'no-activity' } | { readonly kind: 'no-view' };
|
||||||
|
|
||||||
|
export type ActivityOps = {
|
||||||
|
listActivities(status?: ActivityStatus): Promise<ActivitySummary[]>;
|
||||||
|
getActivity(activityId: string): Promise<ActivityDetail | undefined>;
|
||||||
|
viewHtml(activityId: string): Promise<ViewOutcome>;
|
||||||
|
listInstances(activityId: string, query: InstanceQuery & { page: number; size: number }): Promise<InstanceList | undefined>;
|
||||||
|
launchParams(activityId: string): Promise<LaunchParams | undefined>;
|
||||||
|
launch(activityId: string, input: LaunchInput, by: Actor): Promise<LaunchOutcome>;
|
||||||
|
instanceDetail(activityId: string, instanceId: string): Promise<InstanceDetail | undefined>;
|
||||||
|
instanceFile(activityId: string, instanceId: string, io: 'input' | 'output', index: number): Promise<FileOutcome>;
|
||||||
|
setEnabled(activityId: string, enabled: boolean): Promise<ActivityDetail | undefined>;
|
||||||
|
terminate(activityId: string, instanceId: string, by: Actor): Promise<TerminateOutcome>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NAME_FALLBACK = '员工';
|
||||||
|
|
||||||
|
function isTerminal(value: string | null): value is TerminalState {
|
||||||
|
return value !== null && (TERMINAL_STATES as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hueOf(raw: string | null): ActivityHue | undefined {
|
||||||
|
return raw !== null && (ACTIVITY_HUES as readonly string[]).includes(raw) ? (raw as ActivityHue) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前生效的包版本:非 uninstalled 的最新一版(store 按 installedAt 升序给) */
|
||||||
|
function versionOf(card: ActivityCard): string {
|
||||||
|
const live = card.packageVersions.filter((p) => p.state !== 'uninstalled');
|
||||||
|
const pick = (live.length > 0 ? live : card.packageVersions).at(-1);
|
||||||
|
return pick?.version ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function createdAtOf(card: ActivityCard): string {
|
||||||
|
let earliest: string | undefined;
|
||||||
|
for (const p of card.packageVersions) if (earliest === undefined || p.installedAt < earliest) earliest = p.installedAt;
|
||||||
|
return earliest ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function llmOf(manifest: ManifestFace): string {
|
||||||
|
return manifest.declarations.models[0] ?? '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelLabel(channel: string): string {
|
||||||
|
if (channel === 'mail') return '邮件';
|
||||||
|
if (channel === 'im') return '即时消息';
|
||||||
|
return channel === '' ? '通道' : channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createActivityOps(deps: ActivityOpsDeps): ActivityOps {
|
||||||
|
const warn = deps.warn ?? ((line: string) => process.stderr.write(`${line}\n`));
|
||||||
|
|
||||||
|
async function namesOf(): Promise<Map<string, string>> {
|
||||||
|
const { staff } = await deps.org.listStaff();
|
||||||
|
return new Map(staff.map((s) => [s.id, s.name]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function staffRef(id: string, names: ReadonlyMap<string, string>): StaffRef {
|
||||||
|
return { id: staffIdSchema.parse(id), name: names.get(id) ?? NAME_FALLBACK };
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryOf(
|
||||||
|
card: ActivityCard,
|
||||||
|
manifest: ManifestFace,
|
||||||
|
profile: { ownerId: string | null; description: string; ico: string | null; hue: string | null } | undefined,
|
||||||
|
instanceCount: number,
|
||||||
|
names: ReadonlyMap<string, string>,
|
||||||
|
): ActivitySummary {
|
||||||
|
const hue = hueOf(profile?.hue ?? null);
|
||||||
|
return {
|
||||||
|
id: card.activityId,
|
||||||
|
name: card.name,
|
||||||
|
biz: card.category,
|
||||||
|
status: card.enabled ? 'active' : 'disabled',
|
||||||
|
version: versionOf(card),
|
||||||
|
owner: profile?.ownerId === null || profile?.ownerId === undefined ? null : staffRef(profile.ownerId, names),
|
||||||
|
llm: llmOf(manifest),
|
||||||
|
instanceCount,
|
||||||
|
createdAt: createdAtOf(card),
|
||||||
|
...(profile?.ico === null || profile?.ico === undefined || profile.ico === '' ? {} : { ico: profile.ico }),
|
||||||
|
...(hue === undefined ? {} : { hue }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detailOf(card: ActivityCard): Promise<ActivityDetail> {
|
||||||
|
const [manifest, profiles, names] = await Promise.all([deps.registry.manifestOf(card.activityId), deps.store.profilesOf(), namesOf()]);
|
||||||
|
const profile = profiles.get(card.activityId);
|
||||||
|
const { instanceCount: _drop, ...rest } = summaryOf(card, manifest, profile, 0, names);
|
||||||
|
void _drop;
|
||||||
|
const hasView = manifest.assets.viewTemplates.some((v) => v.name === LEDGER_TEMPLATE);
|
||||||
|
return { ...rest, desc: profile?.description ?? '', view: hasView ? { url: `/api/gov/activities/${card.activityId}/view` } : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** combo / select 的候选从正式台账现读;表没物化、列不存在、查询失败 → 没有候选(不炸,说一声) */
|
||||||
|
async function resolveOptions(source: OptionsSource): Promise<readonly LaunchOption[] | undefined> {
|
||||||
|
const [domain, object] = source.ledger.split('.', 2);
|
||||||
|
if (domain === undefined || object === undefined || domain === '' || object === '') return undefined;
|
||||||
|
const address = { domain, object };
|
||||||
|
const described = await deps.ledger.describe(address).catch(() => undefined);
|
||||||
|
if (described === undefined) {
|
||||||
|
warn(`[activity-ops] 启动参数候选读不到:${source.ledger} 尚未物化`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const columns = new Set(described.columns.map((c) => c.name));
|
||||||
|
if (!columns.has(source.value) || !columns.has(source.label)) {
|
||||||
|
warn(`[activity-ops] 启动参数候选读不到:${source.ledger} 没有列 ${source.value} / ${source.label}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const page = await deps.ledger
|
||||||
|
.query({ address, where: {}, orderBy: [{ field: source.label, dir: 'asc' }], limit: OPTIONS_CAP, offset: 0 })
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
warn(`[activity-ops] 启动参数候选查询失败:${source.ledger} —— ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
if (page === undefined) return undefined;
|
||||||
|
return page.rows
|
||||||
|
.map((row) => {
|
||||||
|
const value = row[source.value];
|
||||||
|
const label = row[source.label];
|
||||||
|
const v = value === undefined || value === null ? '' : String(value);
|
||||||
|
const l = label === undefined || label === null || String(label) === '' ? v : String(label);
|
||||||
|
return { value: v, label: l };
|
||||||
|
})
|
||||||
|
.filter((o) => o.value !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstanceCtx = { readonly names: ReadonlyMap<string, string>; readonly specs: readonly FieldSpec[] };
|
||||||
|
|
||||||
|
/** 一行 task → 状态裁定;非终态才需要翻记忆与 loop 运行态(判驳回) */
|
||||||
|
async function verdictOf(row: TaskRow, names: ReadonlyMap<string, string>): Promise<{ verdict: StateVerdict; history: ReadonlyArray<HistoryEntry> | undefined }> {
|
||||||
|
const terminal = isTerminal(row.terminal) ? row.terminal : undefined;
|
||||||
|
if (terminal !== undefined) {
|
||||||
|
return { verdict: instanceStateOf({ terminal, reason: row.reason ?? undefined, runtime: row.runtime, usedSteps: row.usedSteps, lastVerdict: undefined, pending: undefined }), history: undefined };
|
||||||
|
}
|
||||||
|
const taskId = taskIdSchema.parse(row.id);
|
||||||
|
const [history, loop] = await Promise.all([deps.memory.history(taskId), deps.loopState.load(taskId)]);
|
||||||
|
const pending = loop?.pending;
|
||||||
|
return {
|
||||||
|
verdict: instanceStateOf({
|
||||||
|
terminal: undefined,
|
||||||
|
reason: row.reason ?? undefined,
|
||||||
|
runtime: row.runtime,
|
||||||
|
usedSteps: row.usedSteps,
|
||||||
|
lastVerdict: verdictFactOf(history, (id) => names.get(id)),
|
||||||
|
pending: pending === undefined ? undefined : { verdict: pending.verdict, comment: pending.comment },
|
||||||
|
}),
|
||||||
|
history,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inputTextOf(row: TaskRow, specs: readonly FieldSpec[]): Promise<string> {
|
||||||
|
const fromData = inputSummaryOf(row.data, specs);
|
||||||
|
if (fromData !== '') return fromData;
|
||||||
|
const comm = await deps.store.inboundCommOf(row.id);
|
||||||
|
return inputSummaryOf({}, [], comm?.subject !== undefined && comm.subject !== '' ? comm.subject : row.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function outputTextOf(row: TaskRow, state: InstanceState, history: ReadonlyArray<HistoryEntry> | undefined): Promise<string | null> {
|
||||||
|
if (state !== 'completed') return null;
|
||||||
|
const taskId = taskIdSchema.parse(row.id);
|
||||||
|
const archived = await deps.files.listArchived(taskId);
|
||||||
|
const entries = history ?? (await deps.memory.history(taskId));
|
||||||
|
const fired = [...entries]
|
||||||
|
.reverse()
|
||||||
|
.find((e) => e.kind === 'release-verdict' && Array.isArray(e.detail?.['ports']))
|
||||||
|
?.detail?.['ports'];
|
||||||
|
const ports = Array.isArray(fired) ? fired.filter((p): p is string => typeof p === 'string') : [];
|
||||||
|
return outputSummaryOf(archived.map((f) => f.name), ports);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function summarizeRow(row: TaskRow, ctx: InstanceCtx): Promise<{ summary: InstanceSummary; verdict: StateVerdict; history: ReadonlyArray<HistoryEntry> | undefined }> {
|
||||||
|
const { verdict, history } = await verdictOf(row, ctx.names);
|
||||||
|
return {
|
||||||
|
summary: {
|
||||||
|
id: row.id,
|
||||||
|
no: row.no,
|
||||||
|
state: verdict.state,
|
||||||
|
owner: staffRef(row.ownerId, ctx.names),
|
||||||
|
input: await inputTextOf(row, ctx.specs),
|
||||||
|
output: null,
|
||||||
|
startedAt: row.startedAt,
|
||||||
|
},
|
||||||
|
verdict,
|
||||||
|
history,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sourceOf(row: TaskRow, names: ReadonlyMap<string, string>): Promise<InstanceSource> {
|
||||||
|
if (row.workflowId !== null) {
|
||||||
|
const wf = await deps.store.workflowOf(row.workflowId);
|
||||||
|
return { kind: 'upstream', label: wf === undefined || wf.subject === '' ? '上游工作流' : `上游 · ${wf.subject}` };
|
||||||
|
}
|
||||||
|
const claim = await deps.store.launchOf(row.id);
|
||||||
|
if (claim !== undefined || row.origin === 'ops') {
|
||||||
|
const who = claim === undefined ? '运营端' : (names.get(claim.launchedBy) ?? NAME_FALLBACK);
|
||||||
|
return { kind: 'manual', label: `手动启动 · ${who}` };
|
||||||
|
}
|
||||||
|
const comm = await deps.store.inboundCommOf(row.id);
|
||||||
|
if (comm !== undefined) {
|
||||||
|
const kind = comm.channel === '' ? 'mail' : comm.channel;
|
||||||
|
return { kind, label: `${channelLabel(comm.channel)} · ${comm.party !== '' ? comm.party : comm.subject}` };
|
||||||
|
}
|
||||||
|
if (row.origin === 'testbed') return { kind: 'testbed', label: '测试台试跑' };
|
||||||
|
return { kind: 'system', label: '系统起单' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 沙箱看板:最近一次**已放行**实例的落账快照(2026-08-27 用户定;在办的单不摆,行全从正式表圈)。
|
||||||
|
* 案号 = 声明的前缀 + 开案时刻(与模型铸主键那串同源);逐表读经 `describe → query → catch`,一张表读不了
|
||||||
|
* 不拖垮整屏(说一声);案号查无 → `board:state = missing`,没放行过的活动 → `none`,模版自己亮横幅。
|
||||||
|
*/
|
||||||
|
async function renderBoard(activityId: string, manifest: ManifestFace, html: string, spec: ViewData): Promise<string> {
|
||||||
|
const declared = manifest.declarations.ledger;
|
||||||
|
const instance = await deps.store.latestReleased(activityId);
|
||||||
|
if (instance === undefined) return renderView(html, viewScopeOf({ spec, sets: {}, declared, state: 'none' }));
|
||||||
|
const caseRef = `${spec.case.prefix}${caseStamp(instance.startedAt)}`;
|
||||||
|
const owner = await deps.org.getStaff(staffIdSchema.parse(instance.ownerId)).then((s) => s.name, () => '');
|
||||||
|
const read: ViewRead = async (address, where, orderBy) => {
|
||||||
|
const described = await deps.ledger.describe(address).catch(() => undefined);
|
||||||
|
if (described === undefined) return undefined;
|
||||||
|
const page = await deps.ledger.query({ address, where, orderBy: [...orderBy], limit: OPTIONS_CAP, offset: 0 }).catch(() => undefined);
|
||||||
|
return page?.rows;
|
||||||
|
};
|
||||||
|
const resolved = await resolveViewData(spec, { caseRef, read });
|
||||||
|
for (const name of new Set(resolved.skipped)) warn(`[activity-ops] 沙箱看板读不到 ${name}(${activityId} · ${caseRef}):表未物化或查询失败,这一块按空摆`);
|
||||||
|
const scope = viewScopeOf({
|
||||||
|
spec,
|
||||||
|
sets: resolved.sets,
|
||||||
|
declared,
|
||||||
|
instance: { no: instance.no, startedAt: instance.startedAt, finishedAt: instance.finishedAt, owner },
|
||||||
|
caseRef,
|
||||||
|
state: resolved.caseFound ? 'ready' : 'missing',
|
||||||
|
});
|
||||||
|
return renderView(html, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activityOf(activityId: string): Promise<{ card: ActivityCard; manifest: ManifestFace } | undefined> {
|
||||||
|
const card = await deps.registry.get(activityId);
|
||||||
|
if (card === undefined) return undefined;
|
||||||
|
return { card, manifest: await deps.registry.manifestOf(activityId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detailRow(row: TaskRow, manifest: ManifestFace, names: ReadonlyMap<string, string>): Promise<InstanceDetail> {
|
||||||
|
const specs = fieldSpecsOf(manifest.contract.input.data);
|
||||||
|
const { summary, verdict, history } = await summarizeRow(row, { names, specs });
|
||||||
|
const taskId = taskIdSchema.parse(row.id);
|
||||||
|
const [entries, loop, source, archived] = await Promise.all([
|
||||||
|
history === undefined ? deps.memory.history(taskId) : Promise.resolve(history),
|
||||||
|
deps.loopState.load(taskId),
|
||||||
|
sourceOf(row, names),
|
||||||
|
deps.files.listArchived(taskId),
|
||||||
|
]);
|
||||||
|
const llm = llmOf(manifest);
|
||||||
|
const steps = stepsOf({
|
||||||
|
items: loop?.parsed.items ?? [],
|
||||||
|
runtimeItems: loop?.runtime.items ?? [],
|
||||||
|
intents: new Map((loop?.model.steps ?? []).map((s) => [s.item, s.intent])),
|
||||||
|
history: entries.map((e) => ({ kind: e.kind, title: e.title, at: e.at })),
|
||||||
|
state: verdict.state,
|
||||||
|
ownerName: summary.owner.name,
|
||||||
|
llm,
|
||||||
|
});
|
||||||
|
const startedMs = Date.parse(row.startedAt);
|
||||||
|
const finishedMs = row.finishedAt === null ? undefined : Date.parse(row.finishedAt);
|
||||||
|
const durationMs = finishedMs !== undefined && Number.isFinite(startedMs) && Number.isFinite(finishedMs) && finishedMs >= startedMs ? finishedMs - startedMs : null;
|
||||||
|
const inputFiles: InstanceFile[] = inputFilesOf(row.data, specs).map((f) => ({ index: f.index, name: f.name, contentType: f.contentType, size: f.size }));
|
||||||
|
const outputFiles: InstanceFile[] = archived.map((f, index) => ({ index, name: f.name, contentType: MIME_OF[f.format], size: f.size }));
|
||||||
|
// 详情里 `output` 是文件清单(文档 §6),清单上那一句摘要在详情里不再出现
|
||||||
|
const { input: content, output: _outputText, ...rest } = summary;
|
||||||
|
void _outputText;
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
activityId: row.activityId,
|
||||||
|
llm,
|
||||||
|
endedAt: row.finishedAt,
|
||||||
|
durationMs,
|
||||||
|
reason: verdict.reason,
|
||||||
|
input: { content, source, submittedAt: row.startedAt, files: inputFiles },
|
||||||
|
output: { files: outputFiles },
|
||||||
|
steps,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function instanceDetail(activityId: string, instanceId: string): Promise<InstanceDetail | undefined> {
|
||||||
|
const found = await activityOf(activityId);
|
||||||
|
if (found === undefined) return undefined;
|
||||||
|
const row = await deps.store.getTask(activityId, instanceId);
|
||||||
|
if (row === undefined) return undefined;
|
||||||
|
const names = await namesOf();
|
||||||
|
return detailRow(row, found.manifest, names);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async listActivities(status) {
|
||||||
|
const cards = await deps.registry.list();
|
||||||
|
const [profiles, counts, names] = await Promise.all([deps.store.profilesOf(), deps.store.countByActivity(), namesOf()]);
|
||||||
|
const out: ActivitySummary[] = [];
|
||||||
|
for (const card of cards) {
|
||||||
|
const manifest = await deps.registry.manifestOf(card.activityId);
|
||||||
|
const summary = summaryOf(card, manifest, profiles.get(card.activityId), counts.get(card.activityId) ?? 0, names);
|
||||||
|
if (status === undefined || summary.status === status) out.push(summary);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getActivity(activityId) {
|
||||||
|
const card = await deps.registry.get(activityId);
|
||||||
|
return card === undefined ? undefined : detailOf(card);
|
||||||
|
},
|
||||||
|
|
||||||
|
async viewHtml(activityId) {
|
||||||
|
const found = await activityOf(activityId);
|
||||||
|
if (found === undefined) return { kind: 'no-activity' };
|
||||||
|
const template = found.manifest.assets.viewTemplates.find((v) => v.name === LEDGER_TEMPLATE);
|
||||||
|
if (template === undefined) return { kind: 'no-view' };
|
||||||
|
const html = template.data === undefined ? template.html : await renderBoard(activityId, found.manifest, template.html, template.data);
|
||||||
|
return { kind: 'html', html: injectHeightScript(html) };
|
||||||
|
},
|
||||||
|
|
||||||
|
async listInstances(activityId, query) {
|
||||||
|
const found = await activityOf(activityId);
|
||||||
|
if (found === undefined) return undefined;
|
||||||
|
const specs = fieldSpecsOf(found.manifest.contract.input.data);
|
||||||
|
const names = await namesOf();
|
||||||
|
const rows = await deps.store.listTasks(activityId);
|
||||||
|
const byId = new Map(rows.map((r) => [r.id, r]));
|
||||||
|
const summarized = await Promise.all(rows.map((row) => summarizeRow(row, { names, specs })));
|
||||||
|
const all = summarized.map((s) => s.summary);
|
||||||
|
const stats = statsOf(all);
|
||||||
|
const filtered = applyInstanceFilters(all, query);
|
||||||
|
const page = pageOf(filtered, query.page, query.size);
|
||||||
|
const items = await Promise.all(
|
||||||
|
page.map(async (summary) => {
|
||||||
|
const row = byId.get(summary.id);
|
||||||
|
const hit = summarized.find((s) => s.summary.id === summary.id);
|
||||||
|
const output = row === undefined || hit === undefined ? null : await outputTextOf(row, summary.state, hit.history);
|
||||||
|
return { ...summary, output };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { stats, total: filtered.length, page: query.page, size: query.size, items };
|
||||||
|
},
|
||||||
|
|
||||||
|
async launchParams(activityId) {
|
||||||
|
const found = await activityOf(activityId);
|
||||||
|
if (found === undefined) return undefined;
|
||||||
|
const params = await launchParamsOf(found.manifest.contract.input.data, resolveOptions);
|
||||||
|
return { activityId, version: versionOf(found.card), params };
|
||||||
|
},
|
||||||
|
|
||||||
|
async launch(activityId, input, by) {
|
||||||
|
const found = await activityOf(activityId);
|
||||||
|
if (found === undefined) return { kind: 'not-found' };
|
||||||
|
if (!found.card.enabled) return { kind: 'disabled' };
|
||||||
|
try {
|
||||||
|
const owner = await deps.org.getStaff(input.ownerId);
|
||||||
|
if (owner.state !== 'active') return { kind: 'owner-invalid', message: `责任人 ${owner.name} 已停用,换一位在职的人` };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof StaffNotFoundError) return { kind: 'owner-invalid', message: '责任人不在名册里' };
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const schema = found.manifest.contract.input.data;
|
||||||
|
const params = await launchParamsOf(schema, resolveOptions);
|
||||||
|
const checked = validateLaunchParams(params, input.params, fieldSpecsOf(schema));
|
||||||
|
if (!checked.ok) return { kind: 'invalid', issues: checked.issues };
|
||||||
|
|
||||||
|
const claim = await deps.store.claimLaunch({ key: input.idempotencyKey, activityId, ownerId: input.ownerId, launchedBy: by.id });
|
||||||
|
if (claim !== 'claimed') {
|
||||||
|
if (claim.activityId !== activityId) return { kind: 'busy', message: '这枚幂等键已用在别的活动上 —— 换一枚' };
|
||||||
|
if (claim.taskId !== null) {
|
||||||
|
const detail = await instanceDetail(activityId, claim.taskId);
|
||||||
|
return detail === undefined ? { kind: 'busy', message: '同一枚幂等键起的那一单已不在,换一枚键再启动' } : { kind: 'ok', detail, created: false };
|
||||||
|
}
|
||||||
|
const age = deps.clock.now().getTime() - Date.parse(claim.claimedAt);
|
||||||
|
if (!(age >= LAUNCH_CLAIM_STALE_MS)) return { kind: 'busy', message: '同一枚幂等键的实例正在创建,稍后再试' };
|
||||||
|
const won = await deps.store.reclaimLaunch(input.idempotencyKey, LAUNCH_CLAIM_STALE_MS, input.ownerId, by.id);
|
||||||
|
if (!won) return { kind: 'busy', message: '同一枚幂等键的实例正在创建,稍后再试' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await deps.task.start({ activityId, ownerId: input.ownerId, title: found.manifest.identity.name, origin: 'ops', data: checked.data });
|
||||||
|
await deps.store.bindLaunch(input.idempotencyKey, task.id);
|
||||||
|
const detail = await instanceDetail(activityId, task.id);
|
||||||
|
return detail === undefined ? { kind: 'busy', message: '实例已建、暂时读不到,稍后刷新' } : { kind: 'ok', detail, created: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
instanceDetail,
|
||||||
|
|
||||||
|
async instanceFile(activityId, instanceId, io, index) {
|
||||||
|
const found = await activityOf(activityId);
|
||||||
|
if (found === undefined) return { kind: 'no-instance' };
|
||||||
|
const row = await deps.store.getTask(activityId, instanceId);
|
||||||
|
if (row === undefined) return { kind: 'no-instance' };
|
||||||
|
const taskId = taskIdSchema.parse(row.id);
|
||||||
|
if (io === 'input') {
|
||||||
|
const file = inputFilesOf(row.data, fieldSpecsOf(found.manifest.contract.input.data))[index];
|
||||||
|
if (file === undefined) return { kind: 'no-file' };
|
||||||
|
const stored = await deps.files.readUpload(file.ref);
|
||||||
|
return stored === undefined ? { kind: 'no-file' } : { kind: 'file', name: stored.name, contentType: stored.contentType, bytes: stored.bytes };
|
||||||
|
}
|
||||||
|
const archived = (await deps.files.listArchived(taskId))[index];
|
||||||
|
if (archived === undefined) return { kind: 'no-file' };
|
||||||
|
const read = await deps.files.readArchived(taskId, archived.address, archived.key);
|
||||||
|
return read === undefined ? { kind: 'no-file' } : { kind: 'file', name: read.name, contentType: MIME_OF[read.format], bytes: read.bytes };
|
||||||
|
},
|
||||||
|
|
||||||
|
async setEnabled(activityId, enabled) {
|
||||||
|
const card = await deps.registry.get(activityId);
|
||||||
|
if (card === undefined) return undefined;
|
||||||
|
if (card.enabled !== enabled) await deps.registry.setEnabled(activityId, enabled);
|
||||||
|
const fresh = await deps.registry.get(activityId);
|
||||||
|
return fresh === undefined ? undefined : detailOf(fresh);
|
||||||
|
},
|
||||||
|
|
||||||
|
async terminate(activityId, instanceId, by) {
|
||||||
|
const found = await activityOf(activityId);
|
||||||
|
if (found === undefined) return { kind: 'not-found' };
|
||||||
|
const row = await deps.store.getTask(activityId, instanceId);
|
||||||
|
if (row === undefined) return { kind: 'not-found' };
|
||||||
|
if (row.terminal !== null) return { kind: 'terminal' };
|
||||||
|
await deps.task.control(taskIdSchema.parse(row.id), 'terminate', by.id, `由 ${by.name} 手动终止`);
|
||||||
|
const detail = await instanceDetail(activityId, instanceId);
|
||||||
|
return detail === undefined ? { kind: 'not-found' } : { kind: 'ok', detail };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
/**
|
||||||
|
* 运营中心 · 活动模块的 app 层存取 —— 对 `sys.task` / `sys.activity_launch` / `sys.activity_profile` /
|
||||||
|
* `sys.workflow` / `sys.task_comm` 的 raw SQL 读(先例 `asset-archive.ts` / `upload-store.ts` / `workers/task-claim.ts`)。
|
||||||
|
*
|
||||||
|
* 为什么不在 task 模块加 `listByActivity`:清单按活动取、带序号与计数,是**运营面的读投影**,
|
||||||
|
* 不是任务运行时的能力;app 是唯一能同时认识多个模块的层(编码规范 §5)。写一律仍走 port。
|
||||||
|
* `seq` / `activity_launch` / `activity_profile` 都是手写迁移(0034 / 0035)建的,不在 Drizzle schema 里。
|
||||||
|
*/
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
type Db = { execute(query: unknown): Promise<unknown> };
|
||||||
|
|
||||||
|
function rowsOf<T>(result: unknown): T[] {
|
||||||
|
if (Array.isArray(result)) return result as T[];
|
||||||
|
const rows = (result as { rows?: unknown }).rows;
|
||||||
|
return Array.isArray(rows) ? (rows as T[]) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 时刻一律回 ISO 8601 UTC(契约 §0)。pg 的 timestamptz 在 raw `db.execute` 这一路可能回**文本**
|
||||||
|
* (`2026-08-27 16:55:14.383+08`,实测),Date 与文本两种都归一;认不出的原样交出去(不编)。
|
||||||
|
*/
|
||||||
|
function iso(value: unknown): string {
|
||||||
|
if (value instanceof Date) return value.toISOString();
|
||||||
|
const text = String(value);
|
||||||
|
const ms = Date.parse(text);
|
||||||
|
return Number.isFinite(ms) ? new Date(ms).toISOString() : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `sys.task` 的一行,运营面要的那几列 */
|
||||||
|
export type TaskRow = {
|
||||||
|
readonly id: string;
|
||||||
|
/** 人读实例号 `INS-<seq>` */
|
||||||
|
readonly no: string;
|
||||||
|
readonly activityId: string;
|
||||||
|
readonly workflowId: string | null;
|
||||||
|
readonly origin: string | null;
|
||||||
|
readonly ownerId: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly runtime: string;
|
||||||
|
readonly terminal: string | null;
|
||||||
|
readonly reason: string | null;
|
||||||
|
readonly envelope: Record<string, unknown>;
|
||||||
|
readonly data: Record<string, unknown>;
|
||||||
|
readonly usedSteps: number;
|
||||||
|
readonly startedAt: string;
|
||||||
|
readonly finishedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LaunchClaim = {
|
||||||
|
readonly key: string;
|
||||||
|
readonly activityId: string;
|
||||||
|
readonly ownerId: string;
|
||||||
|
readonly launchedBy: string;
|
||||||
|
readonly taskId: string | null;
|
||||||
|
readonly claimedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActivityProfile = {
|
||||||
|
readonly activityId: string;
|
||||||
|
readonly ownerId: string | null;
|
||||||
|
readonly description: string;
|
||||||
|
readonly ico: string | null;
|
||||||
|
readonly hue: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActivityOpsStore = {
|
||||||
|
countByActivity(): Promise<ReadonlyMap<string, number>>;
|
||||||
|
listTasks(activityId: string): Promise<TaskRow[]>;
|
||||||
|
getTask(activityId: string, taskId: string): Promise<TaskRow | undefined>;
|
||||||
|
/** 最近一次已放行的实例(沙箱看板摆它):按放行时刻倒序,没放行过回 undefined */
|
||||||
|
latestReleased(activityId: string): Promise<TaskRow | undefined>;
|
||||||
|
/** 占键:插得进去回 `claimed`;键已在就回那一行(判与占一句 SQL,两台后端安全) */
|
||||||
|
claimLaunch(input: { key: string; activityId: string; ownerId: string; launchedBy: string }): Promise<'claimed' | LaunchClaim>;
|
||||||
|
/** 键在、单没建成(task_id 空)且已过 `staleMs` → 重占;抢到回 true */
|
||||||
|
reclaimLaunch(key: string, staleMs: number, ownerId: string, launchedBy: string): Promise<boolean>;
|
||||||
|
bindLaunch(key: string, taskId: string): Promise<void>;
|
||||||
|
launchOf(taskId: string): Promise<LaunchClaim | undefined>;
|
||||||
|
profilesOf(): Promise<ReadonlyMap<string, ActivityProfile>>;
|
||||||
|
upsertProfile(profile: ActivityProfile): Promise<void>;
|
||||||
|
workflowOf(workflowId: string): Promise<{ subject: string; source: Record<string, unknown> } | undefined>;
|
||||||
|
inboundCommOf(taskId: string): Promise<{ channel: string; party: string; subject: string } | undefined>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RawTask = {
|
||||||
|
id: string;
|
||||||
|
seq: unknown;
|
||||||
|
activity_id: string;
|
||||||
|
workflow_id: string | null;
|
||||||
|
origin: string | null;
|
||||||
|
owner_id: string;
|
||||||
|
title: string;
|
||||||
|
runtime: string;
|
||||||
|
terminal: string | null;
|
||||||
|
reason: string | null;
|
||||||
|
envelope: unknown;
|
||||||
|
input_data: unknown;
|
||||||
|
used_steps: unknown;
|
||||||
|
started_at: unknown;
|
||||||
|
finished_at: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function taskOf(r: RawTask): TaskRow {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
no: `INS-${String(r.seq ?? '')}`,
|
||||||
|
activityId: r.activity_id,
|
||||||
|
workflowId: r.workflow_id,
|
||||||
|
origin: r.origin,
|
||||||
|
ownerId: r.owner_id,
|
||||||
|
title: r.title,
|
||||||
|
runtime: r.runtime,
|
||||||
|
terminal: r.terminal,
|
||||||
|
reason: r.reason,
|
||||||
|
envelope: record(r.envelope),
|
||||||
|
data: record(r.input_data),
|
||||||
|
usedSteps: Number(r.used_steps ?? 0),
|
||||||
|
startedAt: iso(r.started_at),
|
||||||
|
finishedAt: r.finished_at === null || r.finished_at === undefined ? null : iso(r.finished_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawClaim = { key: string; activity_id: string; owner_id: string; launched_by: string; task_id: string | null; claimed_at: unknown };
|
||||||
|
|
||||||
|
function claimOf(r: RawClaim): LaunchClaim {
|
||||||
|
return { key: r.key, activityId: r.activity_id, ownerId: r.owner_id, launchedBy: r.launched_by, taskId: r.task_id, claimedAt: iso(r.claimed_at) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const TASK_COLUMNS = sql`id, seq, activity_id, workflow_id, origin, owner_id, title, runtime, terminal, reason, envelope, input_data, used_steps, started_at, finished_at`;
|
||||||
|
|
||||||
|
export function createActivityOpsStore(db: Db): ActivityOpsStore {
|
||||||
|
return {
|
||||||
|
async countByActivity() {
|
||||||
|
const rows = rowsOf<{ activity_id: string; n: unknown }>(
|
||||||
|
await db.execute(sql`select activity_id, count(*)::int as n from sys.task group by activity_id`),
|
||||||
|
);
|
||||||
|
return new Map(rows.map((r) => [r.activity_id, Number(r.n ?? 0)]));
|
||||||
|
},
|
||||||
|
|
||||||
|
async listTasks(activityId) {
|
||||||
|
const rows = rowsOf<RawTask>(
|
||||||
|
await db.execute(sql`select ${TASK_COLUMNS} from sys.task where activity_id = ${activityId} order by started_at desc, seq desc`),
|
||||||
|
);
|
||||||
|
return rows.map(taskOf);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getTask(activityId, taskId) {
|
||||||
|
const rows = rowsOf<RawTask>(
|
||||||
|
await db.execute(sql`select ${TASK_COLUMNS} from sys.task where activity_id = ${activityId} and id = ${taskId} limit 1`),
|
||||||
|
);
|
||||||
|
const one = rows[0];
|
||||||
|
return one === undefined ? undefined : taskOf(one);
|
||||||
|
},
|
||||||
|
|
||||||
|
async latestReleased(activityId) {
|
||||||
|
const rows = rowsOf<RawTask>(
|
||||||
|
await db.execute(sql`
|
||||||
|
select ${TASK_COLUMNS} from sys.task
|
||||||
|
where activity_id = ${activityId} and terminal = 'released'
|
||||||
|
order by finished_at desc nulls last, started_at desc limit 1`),
|
||||||
|
);
|
||||||
|
const one = rows[0];
|
||||||
|
return one === undefined ? undefined : taskOf(one);
|
||||||
|
},
|
||||||
|
|
||||||
|
async claimLaunch({ key, activityId, ownerId, launchedBy }) {
|
||||||
|
const inserted = rowsOf<{ key: string }>(
|
||||||
|
await db.execute(sql`
|
||||||
|
insert into sys.activity_launch (key, activity_id, owner_id, launched_by)
|
||||||
|
values (${key}, ${activityId}, ${ownerId}, ${launchedBy})
|
||||||
|
on conflict (key) do nothing
|
||||||
|
returning key`),
|
||||||
|
);
|
||||||
|
if (inserted.length > 0) return 'claimed';
|
||||||
|
const existing = rowsOf<RawClaim>(
|
||||||
|
await db.execute(sql`select key, activity_id, owner_id, launched_by, task_id, claimed_at from sys.activity_launch where key = ${key} limit 1`),
|
||||||
|
)[0];
|
||||||
|
// 插不进去又读不到:另一台刚回滚 —— 当作它还在创建,让调用方按 busy 处置
|
||||||
|
return existing === undefined ? { key, activityId, ownerId, launchedBy, taskId: null, claimedAt: new Date(0).toISOString() } : claimOf(existing);
|
||||||
|
},
|
||||||
|
|
||||||
|
async reclaimLaunch(key, staleMs, ownerId, launchedBy) {
|
||||||
|
const rows = rowsOf<{ key: string }>(
|
||||||
|
await db.execute(sql`
|
||||||
|
update sys.activity_launch
|
||||||
|
set claimed_at = now(), owner_id = ${ownerId}, launched_by = ${launchedBy}
|
||||||
|
where key = ${key} and task_id is null and claimed_at < now() - (${staleMs} || ' milliseconds')::interval
|
||||||
|
returning key`),
|
||||||
|
);
|
||||||
|
return rows.length > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
async bindLaunch(key, taskId) {
|
||||||
|
await db.execute(sql`update sys.activity_launch set task_id = ${taskId} where key = ${key}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async launchOf(taskId) {
|
||||||
|
const one = rowsOf<RawClaim>(
|
||||||
|
await db.execute(sql`select key, activity_id, owner_id, launched_by, task_id, claimed_at from sys.activity_launch where task_id = ${taskId} limit 1`),
|
||||||
|
)[0];
|
||||||
|
return one === undefined ? undefined : claimOf(one);
|
||||||
|
},
|
||||||
|
|
||||||
|
async profilesOf() {
|
||||||
|
const rows = rowsOf<{ activity_id: string; owner_id: string | null; description: string; ico: string | null; hue: string | null }>(
|
||||||
|
await db.execute(sql`select activity_id, owner_id, description, ico, hue from sys.activity_profile`),
|
||||||
|
);
|
||||||
|
return new Map(
|
||||||
|
rows.map((r) => [r.activity_id, { activityId: r.activity_id, ownerId: r.owner_id, description: r.description ?? '', ico: r.ico, hue: r.hue }]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async upsertProfile(p) {
|
||||||
|
await db.execute(sql`
|
||||||
|
insert into sys.activity_profile (activity_id, owner_id, description, ico, hue, updated_at)
|
||||||
|
values (${p.activityId}, ${p.ownerId}, ${p.description}, ${p.ico}, ${p.hue}, now())
|
||||||
|
on conflict (activity_id) do update
|
||||||
|
set owner_id = excluded.owner_id, description = excluded.description, ico = excluded.ico, hue = excluded.hue, updated_at = now()`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async workflowOf(workflowId) {
|
||||||
|
const one = rowsOf<{ subject: string; source: unknown }>(await db.execute(sql`select subject, source from sys.workflow where id = ${workflowId} limit 1`))[0];
|
||||||
|
return one === undefined ? undefined : { subject: one.subject ?? '', source: record(one.source) };
|
||||||
|
},
|
||||||
|
|
||||||
|
async inboundCommOf(taskId) {
|
||||||
|
const one = rowsOf<{ channel: string; party: string; subject: string }>(
|
||||||
|
await db.execute(sql`select channel, party, subject from sys.task_comm where task_id = ${taskId} and direction = 'in' order by at limit 1`),
|
||||||
|
)[0];
|
||||||
|
return one === undefined ? undefined : { channel: one.channel ?? '', party: one.party ?? '', subject: one.subject ?? '' };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* 清单上的一句话与检索 —— 输入摘要 / 输入文件 / 产出摘要 / 过滤 / 统计 / 分页,全是纯函数。
|
||||||
|
*/
|
||||||
|
import type { InstanceState, InstanceStats, InstanceSummary } from '@contracts';
|
||||||
|
import { INSTANCE_STATES } from '@contracts';
|
||||||
|
import type { FieldSpec } from './launch-params.ts';
|
||||||
|
|
||||||
|
export const SUMMARY_MAX = 80;
|
||||||
|
|
||||||
|
/** 压空白、截到一行能摆下的长度;截了才加省略号(加了等于说「后面还有」) */
|
||||||
|
export function clip(text: string, max = SUMMARY_MAX): string {
|
||||||
|
const one = text.replace(/\s+/g, ' ').trim();
|
||||||
|
return one.length > max ? `${one.slice(0, max - 1)}…` : one;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 输入一句话:清单标了 `x-control: textarea` 的那格最像「这单是什么」(客户需求 / 本轮说明),
|
||||||
|
* 没有就取最长的那个顶层字符串;都没有退回来件主题;再没有就空串。文件字段不算。
|
||||||
|
*/
|
||||||
|
export function inputSummaryOf(data: Readonly<Record<string, unknown>>, specs: readonly FieldSpec[], fallback?: string): string {
|
||||||
|
const fileKeys = new Set(specs.filter((s) => s.format === 'file').map((s) => s.key));
|
||||||
|
for (const spec of specs) {
|
||||||
|
if (spec.control !== 'textarea') continue;
|
||||||
|
const value = data[spec.key];
|
||||||
|
if (typeof value === 'string' && value.trim() !== '') return clip(value);
|
||||||
|
}
|
||||||
|
let longest = '';
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
if (fileKeys.has(key) || typeof value !== 'string') continue;
|
||||||
|
if (value.trim().length > longest.trim().length) longest = value;
|
||||||
|
}
|
||||||
|
if (longest.trim() !== '') return clip(longest);
|
||||||
|
return fallback === undefined ? '' : clip(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InputFileRef = { readonly index: number; readonly name: string; readonly contentType: string; readonly size: number; readonly ref: string };
|
||||||
|
|
||||||
|
/** `format: 'file'` 的字段里那些 `{name, contentType, size, ref}`;`index` 跨字段连续编号 */
|
||||||
|
export function inputFilesOf(data: Readonly<Record<string, unknown>>, specs: readonly FieldSpec[]): InputFileRef[] {
|
||||||
|
const out: InputFileRef[] = [];
|
||||||
|
for (const spec of specs) {
|
||||||
|
if (spec.format !== 'file') continue;
|
||||||
|
const raw = data[spec.key];
|
||||||
|
const list = Array.isArray(raw) ? raw : raw === undefined || raw === null ? [] : [raw];
|
||||||
|
for (const one of list) {
|
||||||
|
if (typeof one !== 'object' || one === null) continue;
|
||||||
|
const file = one as Record<string, unknown>;
|
||||||
|
const ref = file['ref'];
|
||||||
|
if (typeof ref !== 'string' || ref === '') continue;
|
||||||
|
const size = file['size'];
|
||||||
|
out.push({
|
||||||
|
index: out.length,
|
||||||
|
name: typeof file['name'] === 'string' ? file['name'] : '未命名',
|
||||||
|
contentType: typeof file['contentType'] === 'string' ? file['contentType'] : 'application/octet-stream',
|
||||||
|
size: typeof size === 'number' && Number.isFinite(size) ? size : 0,
|
||||||
|
ref,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 产出一句话:有存档文件摆文件名;没有就摆发了哪几口;都没有 = null(前端摆 `-`) */
|
||||||
|
export function outputSummaryOf(archivedNames: readonly string[], firedPorts: readonly string[]): string | null {
|
||||||
|
if (archivedNames.length > 0) return clip(archivedNames.join('、'));
|
||||||
|
if (firedPorts.length > 0) return clip(`已发口:${firedPorts.join('、')}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InstanceQuery = {
|
||||||
|
readonly id?: string | undefined;
|
||||||
|
readonly ownerId?: string | undefined;
|
||||||
|
readonly state?: InstanceState | undefined;
|
||||||
|
readonly startedFrom?: string | undefined;
|
||||||
|
readonly startedTo?: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
function dayBound(day: string, end: boolean): number | undefined {
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day);
|
||||||
|
if (m === null) return undefined;
|
||||||
|
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
||||||
|
// 服务进程的本地时区:单时区部署;「含两端整日」
|
||||||
|
return end ? new Date(y, mo - 1, d, 23, 59, 59, 999).getTime() : new Date(y, mo - 1, d, 0, 0, 0, 0).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 四条件 AND;`id` 模糊对实例号与任务 id 的子串(不分大小写) */
|
||||||
|
export function applyInstanceFilters(items: readonly InstanceSummary[], q: InstanceQuery): InstanceSummary[] {
|
||||||
|
const needle = q.id?.trim().toLowerCase() ?? '';
|
||||||
|
const from = q.startedFrom === undefined ? undefined : dayBound(q.startedFrom, false);
|
||||||
|
const to = q.startedTo === undefined ? undefined : dayBound(q.startedTo, true);
|
||||||
|
return items.filter((it) => {
|
||||||
|
if (needle !== '' && !it.no.toLowerCase().includes(needle) && !it.id.toLowerCase().includes(needle)) return false;
|
||||||
|
if (q.ownerId !== undefined && q.ownerId !== '' && it.owner.id !== q.ownerId) return false;
|
||||||
|
if (q.state !== undefined && it.state !== q.state) return false;
|
||||||
|
if (from !== undefined || to !== undefined) {
|
||||||
|
const t = Date.parse(it.startedAt);
|
||||||
|
if (!Number.isFinite(t)) return false;
|
||||||
|
if (from !== undefined && t < from) return false;
|
||||||
|
if (to !== undefined && t > to) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statsOf(items: readonly InstanceSummary[]): InstanceStats {
|
||||||
|
const byState = Object.fromEntries(INSTANCE_STATES.map((s) => [s, 0])) as Record<InstanceState, number>;
|
||||||
|
for (const it of items) byState[it.state] += 1;
|
||||||
|
return { total: items.length, byState };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageOf<T>(items: readonly T[], page: number, size: number): T[] {
|
||||||
|
const start = (Math.max(1, page) - 1) * Math.max(1, size);
|
||||||
|
return items.slice(start, start + Math.max(1, size));
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* L2 沙箱视图 —— 活动包里名为 `activity-ledger` 的视图模版(关键台账数据看板:最近一次已放行实例的落账快照,
|
||||||
|
* 行由基座按清单 `viewTemplates[].data` 的声明从正式表圈出,见 `view-scope.ts`)。
|
||||||
|
*
|
||||||
|
* 模版本身**无脚本**(安装静态审 `templateIsSafe` 拦着);而 iframe 是 `sandbox="allow-scripts"`
|
||||||
|
* 无同源,父页量不到它的高 —— 报高的脚本由服务端在**出口那一刻**注入,协议只有一条消息:
|
||||||
|
* `postMessage({ type: 'jcai:view-height', height }, '*')`(load + ResizeObserver + 1s 兜底)。
|
||||||
|
*/
|
||||||
|
import { ACTIVITY_VIEW_HEIGHT_MESSAGE } from '@contracts';
|
||||||
|
|
||||||
|
export const LEDGER_TEMPLATE = 'activity-ledger';
|
||||||
|
|
||||||
|
export function heightScript(): string {
|
||||||
|
return (
|
||||||
|
`<script>(function(){var T=${JSON.stringify(ACTIVITY_VIEW_HEIGHT_MESSAGE)};` +
|
||||||
|
'function h(){var b=document.body;return Math.max(document.documentElement.scrollHeight,b?b.scrollHeight:0)}' +
|
||||||
|
"function post(){try{window.parent.postMessage({type:T,height:h()},'*')}catch(e){}}" +
|
||||||
|
"window.addEventListener('load',post);" +
|
||||||
|
"if(typeof ResizeObserver!=='undefined'){new ResizeObserver(post).observe(document.documentElement)}" +
|
||||||
|
'setInterval(post,1000);post()})();</script>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 插在 `</body>` 之前(没有就追加在末尾);模版原文一个字不改 */
|
||||||
|
export function injectHeightScript(html: string): string {
|
||||||
|
const script = heightScript();
|
||||||
|
const at = html.toLowerCase().lastIndexOf('</body>');
|
||||||
|
return at < 0 ? `${html}\n${script}` : `${html.slice(0, at)}${script}${html.slice(at)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* 沙箱看板的取数与作用域(零 IO;读表经注入)—— 执行清单 `viewTemplates[].data` 那份声明。
|
||||||
|
*
|
||||||
|
* 分工:模版归活动、「哪几行是这一单的」归活动声明(案号前缀、哪张表按哪一列圈),这里只做两件机械的事:
|
||||||
|
* ① `resolveViewData`:按声明逐条求集合 —— `by.column ∈ values(by.from)`,`from` 只能指 `case`
|
||||||
|
* (案号本身)或前面已求出的 `<as>.<列>`;每个值单独查一次(正式表里历次运行的行并排躺着,
|
||||||
|
* `where` 只有等值),结果按 `from` 值的顺序拼接,再按 `not` 排除;
|
||||||
|
* ② `viewScopeOf`:把行集摊成渲染器要的扁平作用域 —— `<as>` 行数组、`<as>.<列>` 首行标量、
|
||||||
|
* `<as 去符号>_rows` 行数、`<as 去符号>_<列>_<值>` tally 计数(先铺 0,与引擎读数同一套命名习惯)、
|
||||||
|
* `case:*` 实例事实、`board:state` 三态(模版拿它亮横幅 / 藏区块)。
|
||||||
|
*
|
||||||
|
* 表未物化 / 查询失败 → 那个集合为空并记在 `skipped`(看板不因一张表 500);案号那一行查无 → `caseFound=false`
|
||||||
|
* (多半是起单时关了「落真库」,主数据只留在活动缓存),看板如实说「没落账」而不是摆一屏空表装没事。
|
||||||
|
*/
|
||||||
|
import type { ViewData, ViewDataRows } from '@contracts';
|
||||||
|
import { VIEW_DATA_CASE_REF } from '@contracts';
|
||||||
|
|
||||||
|
export type ViewRow = Readonly<Record<string, unknown>>;
|
||||||
|
export type ViewAddress = { readonly domain: string; readonly object: string };
|
||||||
|
export type ViewOrder = ReadonlyArray<{ readonly field: string; readonly dir: 'asc' | 'desc' }>;
|
||||||
|
/** 读一张正式表(等值过滤);表读不了回 undefined —— 由调用方决定怎么说 */
|
||||||
|
export type ViewRead = (address: ViewAddress, where: Readonly<Record<string, string>>, orderBy: ViewOrder) => Promise<readonly ViewRow[] | undefined>;
|
||||||
|
|
||||||
|
export type ViewSets = Readonly<Record<string, readonly ViewRow[]>>;
|
||||||
|
export type ResolvedViewData = { readonly sets: ViewSets; readonly caseFound: boolean; readonly skipped: readonly string[] };
|
||||||
|
|
||||||
|
export type BoardState = 'ready' | 'missing' | 'none';
|
||||||
|
export const BOARD_STATE_KEY = 'board:state';
|
||||||
|
|
||||||
|
export type DeclaredLedger = {
|
||||||
|
readonly domain: string;
|
||||||
|
readonly object: string;
|
||||||
|
readonly fields: ReadonlyArray<{ readonly name: string; readonly tally?: readonly string[] | undefined }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ViewInstance = {
|
||||||
|
readonly no: string;
|
||||||
|
readonly startedAt: string;
|
||||||
|
readonly finishedAt: string | null;
|
||||||
|
readonly owner: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function addressOf(ledger: string): ViewAddress | undefined {
|
||||||
|
const at = ledger.indexOf('.');
|
||||||
|
if (at <= 0 || at === ledger.length - 1) return undefined;
|
||||||
|
return { domain: ledger.slice(0, at), object: ledger.slice(at + 1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `<as>.<列>` 从最后一个点拆(列名是标识符,不含点;`as` 本身可以带点) */
|
||||||
|
function refOf(from: string): { as: string; column: string } | undefined {
|
||||||
|
const at = from.lastIndexOf('.');
|
||||||
|
if (at <= 0 || at === from.length - 1) return undefined;
|
||||||
|
return { as: from.slice(0, at), column: from.slice(at + 1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一列的值集:去空、转字符串、去重、保序 */
|
||||||
|
function valuesOf(rows: readonly ViewRow[], column: string): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const raw = row[column];
|
||||||
|
if (raw === undefined || raw === null || raw === '') continue;
|
||||||
|
const text = String(raw);
|
||||||
|
if (seen.has(text)) continue;
|
||||||
|
seen.add(text);
|
||||||
|
out.push(text);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceValues(from: string, caseRef: string, sets: Record<string, readonly ViewRow[]>): string[] | undefined {
|
||||||
|
if (from === VIEW_DATA_CASE_REF) return [caseRef];
|
||||||
|
const ref = refOf(from);
|
||||||
|
if (ref === undefined) return undefined;
|
||||||
|
const rows = sets[ref.as];
|
||||||
|
return rows === undefined ? undefined : valuesOf(rows, ref.column);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveViewData(spec: ViewData, io: { readonly caseRef: string; readonly read: ViewRead }): Promise<ResolvedViewData> {
|
||||||
|
const sets: Record<string, readonly ViewRow[]> = {};
|
||||||
|
const skipped: string[] = [];
|
||||||
|
const caseAddress = addressOf(spec.case.ledger);
|
||||||
|
const caseRows = caseAddress === undefined ? undefined : await io.read(caseAddress, { [spec.case.key]: io.caseRef }, []);
|
||||||
|
const caseFound = (caseRows?.length ?? 0) > 0;
|
||||||
|
if (caseRows === undefined) skipped.push(spec.case.ledger);
|
||||||
|
for (const entry of spec.rows) {
|
||||||
|
sets[entry.as] = await resolveEntry(entry, io.caseRef, sets, io.read, skipped);
|
||||||
|
}
|
||||||
|
return { sets, caseFound, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveEntry(entry: ViewDataRows, caseRef: string, sets: Record<string, readonly ViewRow[]>, read: ViewRead, skipped: string[]): Promise<readonly ViewRow[]> {
|
||||||
|
const address = addressOf(entry.ledger);
|
||||||
|
const values = sourceValues(entry.by.from, caseRef, sets);
|
||||||
|
if (address === undefined || values === undefined) {
|
||||||
|
skipped.push(entry.as);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (values.length === 0) return [];
|
||||||
|
const orderBy = entry.orderBy ?? [];
|
||||||
|
const out: ViewRow[] = [];
|
||||||
|
for (const value of values) {
|
||||||
|
const rows = await read(address, { [entry.by.column]: value }, orderBy);
|
||||||
|
if (rows === undefined) {
|
||||||
|
skipped.push(entry.as);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
out.push(...rows);
|
||||||
|
}
|
||||||
|
if (entry.not === undefined) return out;
|
||||||
|
const banned = new Set(sourceValues(entry.not.from, caseRef, sets) ?? []);
|
||||||
|
const notColumn = entry.not.column;
|
||||||
|
return out.filter((row) => {
|
||||||
|
const raw = row[notColumn];
|
||||||
|
return raw === undefined || raw === null || !banned.has(String(raw));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 与引擎读数同一套习惯:非字母数字一律下划线(`sub_eval.verdict` → `sub_eval_verdict`) */
|
||||||
|
export function slugOf(as: string): string {
|
||||||
|
return as.toLowerCase().replace(/[^a-z0-9]+/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 屏上摆的时刻:本地、到分、带年(看板隔着日子看,「08-27 21:51」不够) */
|
||||||
|
export function localMinute(iso: string | null): string {
|
||||||
|
if (iso === null) return '';
|
||||||
|
const at = new Date(iso);
|
||||||
|
if (Number.isNaN(at.getTime())) return '';
|
||||||
|
const two = (n: number): string => String(n).padStart(2, '0');
|
||||||
|
return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function viewScopeOf(input: {
|
||||||
|
readonly spec: ViewData | undefined;
|
||||||
|
readonly sets: ViewSets;
|
||||||
|
readonly declared: ReadonlyArray<DeclaredLedger>;
|
||||||
|
readonly instance?: ViewInstance | undefined;
|
||||||
|
readonly caseRef?: string | undefined;
|
||||||
|
readonly state: BoardState;
|
||||||
|
}): Record<string, unknown> {
|
||||||
|
const scope: Record<string, unknown> = {
|
||||||
|
[BOARD_STATE_KEY]: input.state,
|
||||||
|
'case:instanceNo': input.instance?.no ?? '',
|
||||||
|
'case:openedAt': input.instance === undefined ? '' : localMinute(input.instance.startedAt),
|
||||||
|
'case:releasedAt': input.instance === undefined ? '' : localMinute(input.instance.finishedAt),
|
||||||
|
'case:owner': input.instance?.owner ?? '',
|
||||||
|
'case:ref': input.caseRef ?? '',
|
||||||
|
};
|
||||||
|
for (const entry of input.spec?.rows ?? []) {
|
||||||
|
const rows = input.sets[entry.as] ?? [];
|
||||||
|
const slug = slugOf(entry.as);
|
||||||
|
scope[entry.as] = rows;
|
||||||
|
scope[`${slug}_rows`] = rows.length;
|
||||||
|
const first = rows[0];
|
||||||
|
if (first !== undefined) for (const [column, value] of Object.entries(first)) scope[`${entry.as}.${column}`] = value;
|
||||||
|
const address = addressOf(entry.ledger);
|
||||||
|
const declaration = address === undefined ? undefined : input.declared.find((d) => d.domain === address.domain && d.object === address.object);
|
||||||
|
for (const field of declaration?.fields ?? []) {
|
||||||
|
if (field.tally === undefined) continue;
|
||||||
|
for (const value of field.tally) scope[`${slug}_${field.name}_${slugOf(value)}`] = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
const raw = row[field.name];
|
||||||
|
if (raw === undefined || raw === null || raw === '') continue;
|
||||||
|
const key = `${slug}_${field.name}_${slugOf(String(raw))}`;
|
||||||
|
scope[key] = Number(scope[key] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* 开案时刻的两种写法 —— **同一个源头,两处消费**:
|
||||||
|
* · `openedAtText`:`YYYY-MM-DD HH:mm:ss`(本地时区,给到秒),引擎注进 Agent 作用域的 `case:openedAt`,
|
||||||
|
* 说明书让模型「去掉分隔符」铸案号(E04 `round_ref` / W05 `eval_ref` / `request_id`);
|
||||||
|
* · `caseStamp`:上一串去掉分隔符 = 案号里那 14 位数字。运营中心沙箱看板要按案号从正式表圈出
|
||||||
|
* 「这一单的行」,必须与模型铸主键用的是同一串 —— 所以两边都从这里取,不各写一份。
|
||||||
|
* 时区取服务进程本地(引擎那份从来如此;两台后端一个库时得跑同一时区,否则案号就对不上)。
|
||||||
|
*/
|
||||||
|
const two = (n: number): string => String(n).padStart(2, '0');
|
||||||
|
|
||||||
|
export function openedAtText(iso: string): string {
|
||||||
|
const at = new Date(iso);
|
||||||
|
if (Number.isNaN(at.getTime())) return '';
|
||||||
|
return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}:${two(at.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `openedAtText` 去掉分隔符:`20260827215103` 这一形 */
|
||||||
|
export function caseStamp(iso: string): string {
|
||||||
|
return openedAtText(iso).replace(/\D/g, '');
|
||||||
|
}
|
||||||
@@ -678,6 +678,8 @@ export async function createContainer(config: AppConfig, overrides: ContainerOve
|
|||||||
deliverable: input.deliverable as never,
|
deliverable: input.deliverable as never,
|
||||||
criteria: [...input.criteria],
|
criteria: [...input.criteria],
|
||||||
actions: input.actions.map((a) => ({ ...a })),
|
actions: input.actions.map((a) => ({ ...a })),
|
||||||
|
// 人工验收清单照传 —— 与 fields / blocks 同一个坑:这里少一行,上游传了一路的东西就静默落地
|
||||||
|
...(input.checklist === undefined || input.checklist.length === 0 ? {} : { checklist: [...input.checklist] }),
|
||||||
/*
|
/*
|
||||||
* 可修订字段照传 —— 从前这里写死 `fields: []`,是这条链上的最后一处丢值:
|
* 可修订字段照传 —— 从前这里写死 `fields: []`,是这条链上的最后一处丢值:
|
||||||
* 契约声明了、submit 传了、release 转了,到这儿被一句空数组抹平,
|
* 契约声明了、submit 传了、release 转了,到这儿被一句空数组抹平,
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* 数据工单确认即入库(2026-08-27 用户定:「数据工单里面填的数据应该是确认这个数据工单后
|
||||||
|
* 这些数据就应该入库了」)。
|
||||||
|
*
|
||||||
|
* 从前草稿层通往正式表只有整单放行这一道口子:人在数据工单的件上把主数据建齐、按下确认,
|
||||||
|
* 那几张表照旧躺在 `ledger.__draft` 里,要等整条评估跑到 T_release 才并进库 ——
|
||||||
|
* 卡上明写着「确认后这批主数据即入库」,而系统里没有一行代码兑现它;试跑单(不落主数据)更是永远不落。
|
||||||
|
*
|
||||||
|
* 现在:确认落下那一刻,把**这张卡的提交闸盯着的那几张表**并进正式表(`ledger.releaseWrite` 的范围形)。
|
||||||
|
* 闸看哪几张表,落地就落哪几张 —— 同一份名单(卡上绑的 `blocks` > 卡自报的 `gateLedgers`),
|
||||||
|
* 不回落到必交付物:那几张是整单放行才该落的单据,不归这张卡管。
|
||||||
|
* 并过的行留在草稿层打记号(仍是本单写下的行,`mine:` / `_rows` / 任务视图照旧认它),整单放行时不再写。
|
||||||
|
*/
|
||||||
|
import type { TaskId, ViewBlock, WorkOrder } from '@contracts';
|
||||||
|
import type { LedgerService, ReleaseWriteResult } from '@modules/ledger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这张卡确认之后该入库的表 —— 只有**数据工单**(执行工单的 `variant:'data'`)才有;
|
||||||
|
* 名单与提交闸同源:绑了 `blocks` 看绑的,否则看 `gateLedgers`。两样都没有就是空 ——
|
||||||
|
* 这张卡没说自己是来建哪张表的,基座不替它猜(猜错就把不该落的东西落进了库)。
|
||||||
|
*/
|
||||||
|
export function dataOrderLedgers(order: WorkOrder): string[] {
|
||||||
|
if (order.mode !== 'execution' || order.variant !== 'data') return [];
|
||||||
|
const bound = (order.blocks ?? [])
|
||||||
|
.filter((b): b is Extract<ViewBlock, { t: 'table' }> => b.t === 'table' && typeof b.ledger === 'string')
|
||||||
|
.map((b) => b.ledger as string);
|
||||||
|
const own = bound.length > 0 ? bound : (order.gateLedgers ?? []);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const raw of own) {
|
||||||
|
const address = raw.trim();
|
||||||
|
if (!/^[a-z0-9_]+\.[a-z0-9_]+$/i.test(address) || seen.has(address)) continue;
|
||||||
|
seen.add(address);
|
||||||
|
out.push(address);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DataOrderLandingDeps = {
|
||||||
|
readonly ledger: Pick<LedgerService, 'releaseWrite'>;
|
||||||
|
/** 留痕:一条 `ledger-write`(工作日志与模型轨迹都读它) */
|
||||||
|
readonly memory: {
|
||||||
|
append(entry: { taskId: TaskId; kind: 'ledger-write'; title: string; detail: Record<string, unknown> }): Promise<unknown>;
|
||||||
|
};
|
||||||
|
/** 表的人读名(台账声明的 `label`);认不出退地址 */
|
||||||
|
readonly labelOf: (address: string) => Promise<string>;
|
||||||
|
/** 治理面(数据域)那一屏要跟着刷 —— 正式表刚多了几行 */
|
||||||
|
readonly touched?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataOrderLanding =
|
||||||
|
| { readonly landed: true; readonly result: ReleaseWriteResult; readonly tables: ReadonlyArray<{ address: string; label: string; rows: number }> }
|
||||||
|
| { readonly landed: false; readonly reason: 'not-data-order' | 'failed'; readonly error?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确认落下之后调它。失败不抛:人已经确认了,那一笔**记成办结**是对的;入库没成就把原话留在痕上,
|
||||||
|
* 那几行仍在草稿层,整单放行时照旧并进去(退回从前的时机,不丢数据)。
|
||||||
|
*/
|
||||||
|
export async function landDataOrder(deps: DataOrderLandingDeps, taskId: TaskId, order: WorkOrder): Promise<DataOrderLanding> {
|
||||||
|
const addresses = dataOrderLedgers(order);
|
||||||
|
if (addresses.length === 0) return { landed: false, reason: 'not-data-order' };
|
||||||
|
const only = addresses.map((a) => {
|
||||||
|
const [domain = '', object = ''] = a.split('.');
|
||||||
|
return { domain, object };
|
||||||
|
});
|
||||||
|
let result: ReleaseWriteResult;
|
||||||
|
try {
|
||||||
|
result = await deps.ledger.releaseWrite(taskId, { only, cause: `data-order:${order.id}` });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
await deps.memory.append({
|
||||||
|
taskId,
|
||||||
|
kind: 'ledger-write',
|
||||||
|
title: `数据工单已确认,但这批数据没能入库:${message} —— 仍留在草稿层,放行时再并`,
|
||||||
|
detail: { workOrderId: order.id, tables: addresses, error: message },
|
||||||
|
});
|
||||||
|
return { landed: false, reason: 'failed', error: message };
|
||||||
|
}
|
||||||
|
|
||||||
|
const byTable = new Map<string, number>();
|
||||||
|
for (const row of result.merged) {
|
||||||
|
const at = `${row.domain}.${row.object}`;
|
||||||
|
byTable.set(at, (byTable.get(at) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const tables: Array<{ address: string; label: string; rows: number }> = [];
|
||||||
|
for (const address of addresses) {
|
||||||
|
tables.push({ address, label: await deps.labelOf(address), rows: byTable.get(address) ?? 0 });
|
||||||
|
}
|
||||||
|
const said = tables.map((t) => `${t.label} ${t.rows} 行`).join('、');
|
||||||
|
/*
|
||||||
|
* 一条痕,三种局面各说各的(界面不撒谎):
|
||||||
|
* · 落了 —— 点名哪几张表各几行,并给模型一句行动指南:这几行从此在库,别再写它们(在库的键会被当场拒);
|
||||||
|
* · 试跑挡下 —— 主数据只留在活动缓存,正式表一行没进;
|
||||||
|
* · 早已落过(同一张卡确认两次) —— 无事发生。
|
||||||
|
*/
|
||||||
|
const title =
|
||||||
|
result.mergedRows > 0
|
||||||
|
? `数据工单确认入库:${said}已进正式表 —— 这几行从此在库,别再往这几张表写它们(在库的键会被当场拒);要改由人在件上改`
|
||||||
|
: result.simulatedRows > 0
|
||||||
|
? `数据工单已确认(试跑:${said}只留在活动缓存,未落正式表)`
|
||||||
|
: `数据工单已确认(这批行早已入库,这次无事发生)`;
|
||||||
|
await deps.memory.append({
|
||||||
|
taskId,
|
||||||
|
kind: 'ledger-write',
|
||||||
|
title,
|
||||||
|
detail: {
|
||||||
|
workOrderId: order.id,
|
||||||
|
rows: result.mergedRows,
|
||||||
|
simulated: result.simulatedRows,
|
||||||
|
landedEarlier: result.landedEarlierRows,
|
||||||
|
tables: tables.map((t) => ({ address: t.address, rows: t.rows })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (result.mergedRows > 0) deps.touched?.();
|
||||||
|
return { landed: true, result, tables };
|
||||||
|
}
|
||||||
@@ -32,6 +32,8 @@ const RUN_TABLES = [
|
|||||||
'sys.upload',
|
'sys.upload',
|
||||||
// 产出的资料文件存档 —— 同样是跑出来的、同样没有指向 task 的外键
|
// 产出的资料文件存档 —— 同样是跑出来的、同样没有指向 task 的外键
|
||||||
'sys.task_asset_file',
|
'sys.task_asset_file',
|
||||||
|
// 运营端启动的幂等键是跑出来的(它指着 task);活动档案(sys.activity_profile)是配置,留
|
||||||
|
'sys.activity_launch',
|
||||||
'sys.node_instance',
|
'sys.node_instance',
|
||||||
// 全链 Trace:span 是运行痕,与工作流同生共死 —— 清运行数据不清它,观测面会挂着一堆孤儿
|
// 全链 Trace:span 是运行痕,与工作流同生共死 —— 清运行数据不清它,观测面会挂着一堆孤儿
|
||||||
'sys.trace_span',
|
'sys.trace_span',
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* 运营中心 · 活动模块路由(`docs/API文档-活动模块.md`)。归属在契约的 access 上(全 ops),中间件统一判;
|
||||||
|
* 这里只做 zod 校验、调用用例、把结果翻成状态码。写路由的广播由 `/api/gov/*` 中间件自动发。
|
||||||
|
*/
|
||||||
|
import type { Context, Hono } from 'hono';
|
||||||
|
import type { ApiError } from '@contracts';
|
||||||
|
import {
|
||||||
|
activateOpsActivityRoute,
|
||||||
|
disableOpsActivityRoute,
|
||||||
|
getOpsActivityRoute,
|
||||||
|
getOpsInstanceRoute,
|
||||||
|
launchOpsInstanceRoute,
|
||||||
|
listOpsActivitiesRoute,
|
||||||
|
listOpsInstancesRoute,
|
||||||
|
opsActivityViewRoute,
|
||||||
|
opsInstanceFileRoute,
|
||||||
|
opsLaunchParamsRoute,
|
||||||
|
terminateOpsInstanceRoute,
|
||||||
|
} from '@contracts';
|
||||||
|
import type { AppEnv } from '../http-env.ts';
|
||||||
|
import { staffOf } from '../http-env.ts';
|
||||||
|
import type { ActivityOps } from '../activity-ops/service.ts';
|
||||||
|
|
||||||
|
export type ActivityRouteDeps = { readonly activityOps: ActivityOps };
|
||||||
|
|
||||||
|
function invalid(c: Context<AppEnv>, message: string, detail: unknown): Response {
|
||||||
|
const body: ApiError = { code: 'validation-failed', message, detail };
|
||||||
|
return c.json(body, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notFound(c: Context<AppEnv>, message: string): Response {
|
||||||
|
const body: ApiError = { code: 'not-found', message };
|
||||||
|
return c.json(body, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
function precondition(c: Context<AppEnv>, message: string): Response {
|
||||||
|
const body: ApiError = { code: 'precondition-failed', message };
|
||||||
|
return c.json(body, 412);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unauthenticated(c: Context<AppEnv>): Response {
|
||||||
|
const body: ApiError = { code: 'unauthenticated', message: '这一步要知道是谁按的 —— 请求没带身份' };
|
||||||
|
return c.json(body, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function jsonBody(c: Context<AppEnv>): Promise<unknown> {
|
||||||
|
const text = await c.req.text();
|
||||||
|
if (text.trim() === '') return {};
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as unknown;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerActivityRoutes(app: Hono<AppEnv>, deps: ActivityRouteDeps): void {
|
||||||
|
const { activityOps } = deps;
|
||||||
|
// 通用 Context 上 param 是 string | undefined;路径模版里有这一段,空串只会落成 404
|
||||||
|
const activityId = (c: Context<AppEnv>): string => c.req.param('activityId') ?? '';
|
||||||
|
const instanceId = (c: Context<AppEnv>): string => c.req.param('instanceId') ?? '';
|
||||||
|
|
||||||
|
/* ── L1 ── */
|
||||||
|
|
||||||
|
app.get(listOpsActivitiesRoute.path, async (c) => {
|
||||||
|
const query = listOpsActivitiesRoute.query.safeParse(c.req.query());
|
||||||
|
if (!query.success) return invalid(c, '查询参数不合法', query.error.issues);
|
||||||
|
return c.json({ activities: await activityOps.listActivities(query.data.status) });
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── L2 ── */
|
||||||
|
|
||||||
|
app.get(getOpsActivityRoute.path, async (c) => {
|
||||||
|
const detail = await activityOps.getActivity(activityId(c));
|
||||||
|
return detail === undefined ? notFound(c, `活动不存在:${activityId(c)}`) : c.json(detail);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get(opsActivityViewRoute.path, async (c) => {
|
||||||
|
const view = await activityOps.viewHtml(activityId(c));
|
||||||
|
if (view.kind === 'no-activity') return notFound(c, `活动不存在:${activityId(c)}`);
|
||||||
|
if (view.kind === 'no-view') return notFound(c, '该活动没配视图');
|
||||||
|
return c.html(view.html, 200, { 'cache-control': 'no-store' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get(listOpsInstancesRoute.path, async (c) => {
|
||||||
|
const query = listOpsInstancesRoute.query.safeParse(c.req.query());
|
||||||
|
if (!query.success) return invalid(c, '查询参数不合法', query.error.issues);
|
||||||
|
const list = await activityOps.listInstances(activityId(c), query.data);
|
||||||
|
return list === undefined ? notFound(c, `活动不存在:${activityId(c)}`) : c.json(list);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── 启动 ── */
|
||||||
|
|
||||||
|
app.get(opsLaunchParamsRoute.path, async (c) => {
|
||||||
|
const table = await activityOps.launchParams(activityId(c));
|
||||||
|
return table === undefined ? notFound(c, `活动不存在:${activityId(c)}`) : c.json(table);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post(launchOpsInstanceRoute.path, async (c) => {
|
||||||
|
const parsed = launchOpsInstanceRoute.body.safeParse(await jsonBody(c));
|
||||||
|
if (!parsed.success) return invalid(c, '启动入参不合法', parsed.error.issues);
|
||||||
|
const me = staffOf(c);
|
||||||
|
if (me === undefined) return unauthenticated(c);
|
||||||
|
const outcome = await activityOps.launch(activityId(c), parsed.data, { id: me.id, name: me.name });
|
||||||
|
switch (outcome.kind) {
|
||||||
|
case 'ok':
|
||||||
|
return c.json(outcome.detail);
|
||||||
|
case 'not-found':
|
||||||
|
return notFound(c, `活动不存在:${activityId(c)}`);
|
||||||
|
case 'disabled':
|
||||||
|
return precondition(c, '活动已禁用,先激活再启动');
|
||||||
|
case 'invalid':
|
||||||
|
return invalid(c, `启动参数不合要求:${outcome.issues.map((i) => `${i.key} ${i.message}`).join('; ')}`, outcome.issues);
|
||||||
|
case 'owner-invalid':
|
||||||
|
return invalid(c, outcome.message, [{ key: 'ownerId', message: outcome.message }]);
|
||||||
|
case 'busy': {
|
||||||
|
const body: ApiError = { code: 'conflict', message: outcome.message };
|
||||||
|
return c.json(body, 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── L3 ── */
|
||||||
|
|
||||||
|
app.get(getOpsInstanceRoute.path, async (c) => {
|
||||||
|
const detail = await activityOps.instanceDetail(activityId(c), instanceId(c));
|
||||||
|
return detail === undefined ? notFound(c, `实例不存在:${instanceId(c)}`) : c.json(detail);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get(opsInstanceFileRoute.path, async (c) => {
|
||||||
|
const params = opsInstanceFileRoute.params.safeParse({
|
||||||
|
activityId: activityId(c),
|
||||||
|
instanceId: instanceId(c),
|
||||||
|
io: c.req.param('io'),
|
||||||
|
index: c.req.param('index'),
|
||||||
|
});
|
||||||
|
if (!params.success) return invalid(c, '文件序号不合法', params.error.issues);
|
||||||
|
const file = await activityOps.instanceFile(params.data.activityId, params.data.instanceId, params.data.io, params.data.index);
|
||||||
|
if (file.kind === 'no-instance') return notFound(c, `实例不存在:${params.data.instanceId}`);
|
||||||
|
if (file.kind === 'no-file') return notFound(c, '这个文件不在了');
|
||||||
|
if (c.req.query('raw') === '1') {
|
||||||
|
return c.body(file.bytes.slice().buffer as ArrayBuffer, 200, {
|
||||||
|
'content-type': file.contentType,
|
||||||
|
// 文件名走 RFC 5987:中文名在各家浏览器上才落得对
|
||||||
|
'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return c.json({ name: file.name, contentType: file.contentType, dataBase64: Buffer.from(file.bytes).toString('base64') });
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── 禁用 / 激活 / 终止 ── */
|
||||||
|
|
||||||
|
const setEnabled = (enabled: boolean) => async (c: Context<AppEnv>) => {
|
||||||
|
const detail = await activityOps.setEnabled(activityId(c), enabled);
|
||||||
|
return detail === undefined ? notFound(c, `活动不存在:${activityId(c)}`) : c.json(detail);
|
||||||
|
};
|
||||||
|
app.post(disableOpsActivityRoute.path, setEnabled(false));
|
||||||
|
app.post(activateOpsActivityRoute.path, setEnabled(true));
|
||||||
|
|
||||||
|
app.post(terminateOpsInstanceRoute.path, async (c) => {
|
||||||
|
const me = staffOf(c);
|
||||||
|
if (me === undefined) return unauthenticated(c);
|
||||||
|
const outcome = await activityOps.terminate(activityId(c), instanceId(c), { id: me.id, name: me.name });
|
||||||
|
if (outcome.kind === 'not-found') return notFound(c, `实例不存在:${instanceId(c)}`);
|
||||||
|
if (outcome.kind === 'terminal') return precondition(c, '实例已终态,不能再终止');
|
||||||
|
return c.json(outcome.detail);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
import { ReplyRejectedError } from '@modules/human-tool';
|
import { ReplyRejectedError } from '@modules/human-tool';
|
||||||
import { advanceOrder } from '@modules/process';
|
import { advanceOrder } from '@modules/process';
|
||||||
import { assetRowsOf, keyText, listArchived, readArchived } from '../asset-archive.ts';
|
import { assetRowsOf, keyText, listArchived, readArchived } from '../asset-archive.ts';
|
||||||
|
import { landDataOrder } from '../data-order-landing.ts';
|
||||||
import { composeFile, isFileFormat, MIME_OF, safeFileName, type FileFormat } from '../file-compose.ts';
|
import { composeFile, isFileFormat, MIME_OF, safeFileName, type FileFormat } from '../file-compose.ts';
|
||||||
import { toFileDoc } from '../file-doc.ts';
|
import { toFileDoc } from '../file-doc.ts';
|
||||||
import { displayLog } from './log-lines.ts';
|
import { displayLog } from './log-lines.ts';
|
||||||
@@ -1441,6 +1442,29 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
* 仍守铁律②:写的是草稿层,放行那一刻才并进正式版。
|
* 仍守铁律②:写的是草稿层,放行那一刻才并进正式版。
|
||||||
*/
|
*/
|
||||||
await applyBoundTableEdits(container, outcome.taskId, outcome.order, parsed.data);
|
await applyBoundTableEdits(container, outcome.taskId, outcome.order, parsed.data);
|
||||||
|
/*
|
||||||
|
* **数据工单确认即入库**(2026-08-27 用户定):这张卡让人从零建一张主数据表,
|
||||||
|
* 确认落下那一刻,它的提交闸盯着的那几张表就并进正式表 —— 不等整单放行。
|
||||||
|
* 从前卡上写着「确认后即入库」而系统里没有一行代码兑现它;试跑单更是永远不落。
|
||||||
|
* 放在保存与回传都落定之后:并进去的就是人交卷那一刻件上的样子。失败不拦人(痕上说清,放行时再并)。
|
||||||
|
*/
|
||||||
|
await landDataOrder(
|
||||||
|
{
|
||||||
|
ledger: container.ledger,
|
||||||
|
memory: {
|
||||||
|
append: (entry) =>
|
||||||
|
container.memory.append({ ...entry, stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] } }),
|
||||||
|
},
|
||||||
|
labelOf: async (address) => {
|
||||||
|
const task = await container.task.get(outcome.taskId).catch(() => undefined);
|
||||||
|
const manifest = task === undefined ? undefined : await container.registry.manifestOf(task.activityId).catch(() => undefined);
|
||||||
|
return manifest?.declarations.ledger.find((l) => `${l.domain}.${l.object}` === address)?.label ?? address;
|
||||||
|
},
|
||||||
|
touched: () => container.broadcaster.publish({ kind: 'system', id: 'ledger', reason: 'data-order-landed' }),
|
||||||
|
},
|
||||||
|
outcome.taskId,
|
||||||
|
outcome.order,
|
||||||
|
);
|
||||||
/*
|
/*
|
||||||
* **返回之前就把这单标成「在办事」**(2026-08-25 用户:「右边聊天框先有反应,中间的
|
* **返回之前就把这单标成「在办事」**(2026-08-25 用户:「右边聊天框先有反应,中间的
|
||||||
* html 过一会儿才变成 AI 正在推进中,看上去很不流畅」)。
|
* html 过一会儿才变成 AI 正在推进中,看上去很不流畅」)。
|
||||||
|
|||||||
@@ -1305,7 +1305,8 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
/** 草稿层:一次查完按地址分组 —— 每张表单独查会把这一屏拖成几十条 SQL */
|
/** 草稿层:一次查完按地址分组 —— 每张表单独查会把这一屏拖成几十条 SQL */
|
||||||
const draftRows = rowsOf<{ domain: string; object: string; n: string; at: Date | string }>(
|
const draftRows = rowsOf<{ domain: string; object: string; n: string; at: Date | string }>(
|
||||||
await container.db.execute(
|
await container.db.execute(
|
||||||
sql`select domain, object, count(*)::text as n, max(updated_at) as at from ledger.__draft group by domain, object`,
|
// 已在数据工单确认时入库的行(landed_at 非空)不算「在办草稿」—— 它们已经在正式行数里了
|
||||||
|
sql`select domain, object, count(*)::text as n, max(updated_at) as at from ledger.__draft where landed_at is null group by domain, object`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const drafts = new Map(
|
const drafts = new Map(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { activityManifestSchema } from '@contracts';
|
|||||||
import type { Container } from './container.ts';
|
import type { Container } from './container.ts';
|
||||||
import { MASTER_DOMAINS, MASTER_OBJECTS, MASTER_SAMPLE_ROWS } from './seed-mvp-data.ts';
|
import { MASTER_DOMAINS, MASTER_OBJECTS, MASTER_SAMPLE_ROWS } from './seed-mvp-data.ts';
|
||||||
import { W05_TOOLS, W05_TOOL_SAMPLE } from './tools-w05.ts';
|
import { W05_TOOLS, W05_TOOL_SAMPLE } from './tools-w05.ts';
|
||||||
|
import { createActivityOpsStore } from './activity-ops/store.ts';
|
||||||
|
|
||||||
/** 已开发完成、要随播种装上的真活动包(目录名 = 包目录,不是 activityId) */
|
/** 已开发完成、要随播种装上的真活动包(目录名 = 包目录,不是 activityId) */
|
||||||
/*
|
/*
|
||||||
@@ -34,6 +35,33 @@ import { W05_TOOLS, W05_TOOL_SAMPLE } from './tools-w05.ts';
|
|||||||
*/
|
*/
|
||||||
const REAL_PACKAGES = ['crm-relationship-coverage', 'brand-content-toolkit', 'part-substitution-assessment'] as const;
|
const REAL_PACKAGES = ['crm-relationship-coverage', 'brand-content-toolkit', 'part-substitution-assessment'] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运营侧活动档案(2026-08-27 用户定:负责人 / 说明 / 图标 / 色相另建 `sys.activity_profile`,活动包不载这些)。
|
||||||
|
* 按包目录名登记;负责人按**岗位**挑第一个在职的人 —— 员工 id 是 Keycloak 的 sub、首登即建,
|
||||||
|
* seed 拿不到真 sub,只能按岗位现查;**查不到就留空并说一声**,不编一个(界面不撒谎)。
|
||||||
|
* 重复播种是覆盖不是堆叠;运营面的改口另起(见 debt)。
|
||||||
|
*/
|
||||||
|
const ACTIVITY_PROFILES: Readonly<Record<(typeof REAL_PACKAGES)[number], { description: string; ico: string; hue: string; ownerPosition: string }>> = {
|
||||||
|
'crm-relationship-coverage': {
|
||||||
|
description: '按客户线月度 / 事件驱动地评估关系覆盖度,识别关键人缺口,编排经营计划并回收执行回执。',
|
||||||
|
ico: 'target',
|
||||||
|
hue: 'blue',
|
||||||
|
ownerPosition: 'BD',
|
||||||
|
},
|
||||||
|
'brand-content-toolkit': {
|
||||||
|
description: '受理品牌形象与销售工具包的内容需求,产出初稿交审核,发布后入品牌内容库。',
|
||||||
|
ico: 'palette',
|
||||||
|
hue: 'amber',
|
||||||
|
ownerPosition: 'OP',
|
||||||
|
},
|
||||||
|
'part-substitution-assessment': {
|
||||||
|
description: '对候选料号逐项比较参数、判四态、定推荐等级,审核后落 P-11 替代关系并回写 P-01。',
|
||||||
|
ico: 'git-compare',
|
||||||
|
hue: 'teal',
|
||||||
|
ownerPosition: 'FAE',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 活动包根目录。
|
* 活动包根目录。
|
||||||
*
|
*
|
||||||
@@ -122,6 +150,18 @@ export async function installRealActivities(container: Container): Promise<void>
|
|||||||
*/
|
*/
|
||||||
const declared = parsed.data.declarations.tools.map((t) => t.name);
|
const declared = parsed.data.declarations.tools.map((t) => t.name);
|
||||||
await container.toolbox.setAllowlist(parsed.data.identity.activityId, declared);
|
await container.toolbox.setAllowlist(parsed.data.identity.activityId, declared);
|
||||||
|
await seedProfile(container, dir, parsed.data.identity.activityId, log);
|
||||||
|
/*
|
||||||
|
* **同版重装不许说「已装」**(2026-08-27,一天里静默丢了两次):两审过了、版本已在库,
|
||||||
|
* 库里一个字没动 —— 这一句话就是人判断「改动上没上」的唯一依据,说错就是误导。
|
||||||
|
*/
|
||||||
|
if (!verdict.stored) {
|
||||||
|
log(
|
||||||
|
` 活动包已是这一版,未重装:${parsed.data.identity.name}(${parsed.data.identity.activityId}@${version})` +
|
||||||
|
' —— 改了内容要升 packageVersion 再装(同版重装是 no-op,库里仍是先前那份)',
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
log(
|
log(
|
||||||
` 活动包已装:${parsed.data.identity.name}(${parsed.data.identity.activityId}@${version})` +
|
` 活动包已装:${parsed.data.identity.name}(${parsed.data.identity.activityId}@${version})` +
|
||||||
(declared.length === 0 ? '' : ` · 已授权工具 ${declared.length} 件`) +
|
(declared.length === 0 ? '' : ` · 已授权工具 ${declared.length} 件`) +
|
||||||
@@ -155,3 +195,22 @@ export async function installRealActivities(container: Container): Promise<void>
|
|||||||
}
|
}
|
||||||
log(` 样例主数据已就位:${MASTER_SAMPLE_ROWS.length} 行(客户 / 项目 / 联系人 / 料号)`);
|
log(` 样例主数据已就位:${MASTER_SAMPLE_ROWS.length} 行(客户 / 项目 / 联系人 / 料号)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 运营侧档案落库:说明 / 图标 / 色相照表,负责人按岗位现查(取不到留空,如实报) */
|
||||||
|
async function seedProfile(container: Container, dir: (typeof REAL_PACKAGES)[number], activityId: string, log: (line: string) => void): Promise<void> {
|
||||||
|
const spec = ACTIVITY_PROFILES[dir];
|
||||||
|
const candidates = await container.org.staffOfPosition(spec.ownerPosition).catch(() => []);
|
||||||
|
const owner = candidates[0];
|
||||||
|
await createActivityOpsStore(container.db).upsertProfile({
|
||||||
|
activityId,
|
||||||
|
ownerId: owner === undefined ? null : owner.id,
|
||||||
|
description: spec.description,
|
||||||
|
ico: spec.ico,
|
||||||
|
hue: spec.hue,
|
||||||
|
});
|
||||||
|
log(
|
||||||
|
owner === undefined
|
||||||
|
? ` 活动档案已记(${activityId}):负责人留空 —— 岗位 ${spec.ownerPosition} 当前没有在职的人`
|
||||||
|
: ` 活动档案已记(${activityId}):负责人 ${owner.name}(岗位 ${spec.ownerPosition})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ import { registerGovernanceRoutes } from './routes/governance.ts';
|
|||||||
import { registerIngestRoutes } from './routes/ingest.ts';
|
import { registerIngestRoutes } from './routes/ingest.ts';
|
||||||
import { registerOrgDirectoryRoutes } from './routes/org-directory.ts';
|
import { registerOrgDirectoryRoutes } from './routes/org-directory.ts';
|
||||||
import { registerMailRoutes } from './routes/mail.ts';
|
import { registerMailRoutes } from './routes/mail.ts';
|
||||||
|
import { registerActivityRoutes } from './routes/activities.ts';
|
||||||
|
import { createActivityOps } from './activity-ops/service.ts';
|
||||||
|
import { createActivityOpsStore } from './activity-ops/store.ts';
|
||||||
|
import { createLoopStateStore } from '@modules/loop-engine';
|
||||||
|
import { readUpload } from './upload-store.ts';
|
||||||
|
import { listArchived, readArchived } from './asset-archive.ts';
|
||||||
import { registerStreamRoute } from './routes/stream.ts';
|
import { registerStreamRoute } from './routes/stream.ts';
|
||||||
import { registerWorkbenchRoutes } from './routes/workbench.ts';
|
import { registerWorkbenchRoutes } from './routes/workbench.ts';
|
||||||
|
|
||||||
@@ -123,6 +129,27 @@ export function createServer(container: Container): Hono<AppEnv> {
|
|||||||
registerRegistrationRoutes(app, container);
|
registerRegistrationRoutes(app, container);
|
||||||
registerGovernanceOpsRoutes(app, container);
|
registerGovernanceOpsRoutes(app, container);
|
||||||
registerMailRoutes(app, container);
|
registerMailRoutes(app, container);
|
||||||
|
/*
|
||||||
|
* 运营中心 · 活动模块(2026-08-27):跨模块的读投影 + 编排住 app 层,依赖只取用到的那几面;
|
||||||
|
* 写走既有 port(task.start / task.control / registry.setEnabled)。
|
||||||
|
*/
|
||||||
|
registerActivityRoutes(app, {
|
||||||
|
activityOps: createActivityOps({
|
||||||
|
registry: container.registry,
|
||||||
|
task: container.task,
|
||||||
|
org: container.org,
|
||||||
|
memory: container.memory,
|
||||||
|
ledger: container.ledger,
|
||||||
|
loopState: createLoopStateStore(container.db),
|
||||||
|
store: createActivityOpsStore(container.db),
|
||||||
|
files: {
|
||||||
|
readUpload: (ref) => readUpload(container.db, ref),
|
||||||
|
listArchived: (taskId) => listArchived(container.db, taskId),
|
||||||
|
readArchived: (taskId, address, key) => readArchived(container.db, taskId, address, key),
|
||||||
|
},
|
||||||
|
clock: systemClock,
|
||||||
|
}),
|
||||||
|
});
|
||||||
registerIngestRoutes(app, container);
|
registerIngestRoutes(app, container);
|
||||||
registerStreamRoute(app, container.broadcaster);
|
registerStreamRoute(app, container.broadcaster);
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import type { TriggerService } from '@modules/trigger';
|
|||||||
import { evaluate as evalSfeel, evaluateCondition, evaluateUnaryTest } from '@sfeel';
|
import { evaluate as evalSfeel, evaluateCondition, evaluateUnaryTest } from '@sfeel';
|
||||||
import type { StaffId, TaskId } from '@contracts';
|
import type { StaffId, TaskId } from '@contracts';
|
||||||
import { cardIdSchema, staffIdSchema } from '@contracts';
|
import { cardIdSchema, staffIdSchema } from '@contracts';
|
||||||
|
import { openedAtText } from '../case-stamp.ts';
|
||||||
import { archiveAssetFiles } from '../asset-archive.ts';
|
import { archiveAssetFiles } from '../asset-archive.ts';
|
||||||
import { createAgentRunner } from '../agent-runner.ts';
|
import { createAgentRunner } from '../agent-runner.ts';
|
||||||
import { createMastraAgentRunner } from '../mastra-agent-runner.ts';
|
import { createMastraAgentRunner } from '../mastra-agent-runner.ts';
|
||||||
@@ -190,6 +191,37 @@ export function localStamp(iso: string): string {
|
|||||||
return `${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
|
return `${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「只建不改」的表:这一笔写的键是不是已经在正式表里了 —— 在就回一句拒回的话(带指路),不在回 undefined。
|
||||||
|
* 纯函数:正式表的行由调用方查好递进来,这里只比键。
|
||||||
|
*/
|
||||||
|
export function createOnlyViolation(
|
||||||
|
target: { readonly label: string; readonly keys: ReadonlyArray<string> },
|
||||||
|
key: Readonly<Record<string, string | number>>,
|
||||||
|
formalRows: ReadonlyArray<Record<string, unknown>>,
|
||||||
|
/** 本单经数据工单确认**已入库**的行的键(2026-08-27)—— 它们也在正式表里,但拒回要说另一句话 */
|
||||||
|
landedKeys: ReadonlyArray<Record<string, unknown>> = [],
|
||||||
|
): string | undefined {
|
||||||
|
const same = (row: Record<string, unknown>) => target.keys.every((k) => String(row[k] ?? '') === String(key[k] ?? ''));
|
||||||
|
if (!formalRows.some(same)) return undefined;
|
||||||
|
const shown = target.keys.map((k) => `${k}=${String(key[k] ?? '')}`).join('、');
|
||||||
|
/*
|
||||||
|
* 这一行是**本单自己**在数据工单里建好、人确认后入库的 —— 指它去 drop 就错了:
|
||||||
|
* drop 撤的是草稿层那一笔,正式表一个字不动,撤了只会让本单的读面(`mine:` / `_rows`)少一行、哨兵开不了。
|
||||||
|
* 该说的是:它已经建好了,别再写;要改由人在件上改。
|
||||||
|
*/
|
||||||
|
if (landedKeys.some(same)) {
|
||||||
|
return (
|
||||||
|
`${target.label}里「${shown}」这一行是本单在数据工单里建好、人确认后已入库的 —— 建好了就别再写它;` +
|
||||||
|
'值要改由人在件上改(件上保存直落草稿层),你这一笔不用发,接着办下一件事。'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
`${target.label}里已经有「${shown}」这一行 —— 这张表在本单只许**新建库里没有的记录**(数据工单是从零建主数据用的),` +
|
||||||
|
'在库的料号不用建、也不许改:别再写它;要是本单草稿里已经替它铺了骨架,用 drop 把那一行撤掉,免得件上多出一行要人填的东西。'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function rowReadings(
|
export function rowReadings(
|
||||||
mine: Readonly<Record<string, readonly unknown[]>>,
|
mine: Readonly<Record<string, readonly unknown[]>>,
|
||||||
declared: ReadonlyArray<{ domain: string; object: string }> = [],
|
declared: ReadonlyArray<{ domain: string; object: string }> = [],
|
||||||
@@ -425,6 +457,31 @@ export function touchedRows(
|
|||||||
* 声明过的工具**一律先铺 0** —— 与 `_rows` 同一个理由:未绑定的名字参与比较求值为假,
|
* 声明过的工具**一律先铺 0** —— 与 `_rows` 同一个理由:未绑定的名字参与比较求值为假,
|
||||||
* 「没调过」与「调了 0 次」在业务上是同一件事,读数上也得是。
|
* 「没调过」与「调了 0 次」在业务上是同一件事,读数上也得是。
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* **决策卡的回答是留痕里的事实,不是模型的记账**(2026-08-27 W05 实测逼出):
|
||||||
|
* 审核那一步的入场哨兵读的是模型自写的 `review_approved_total`,人答了「重做」它照写 1,一路推到放行。
|
||||||
|
* 与 A6「该不该叫人不能由被叫的那一方说了算」同一课。基座按 card-reply 数:
|
||||||
|
* `decision_last` —— 这一单最后一张决策卡人选的值(原样;`custom:…` 保留前缀)
|
||||||
|
* `decision_<值>_total` —— 按值计数(`custom:` 开头的一律归 `decision_custom_total`)
|
||||||
|
* 值的语义(approve 是通过)归活动的哨兵去写;这里只把事实摆出来。
|
||||||
|
*/
|
||||||
|
export function decisionReadings(
|
||||||
|
trail: ReadonlyArray<{ readonly kind: string; readonly detail?: Record<string, unknown> | undefined }>,
|
||||||
|
): Record<string, number | string> {
|
||||||
|
const out: Record<string, number | string> = { decision_last: '', decision_approve_total: 0, decision_reject_total: 0, decision_custom_total: 0 };
|
||||||
|
for (const entry of trail) {
|
||||||
|
if (entry.kind !== 'card-reply') continue;
|
||||||
|
const raw = entry.detail?.['value'];
|
||||||
|
if (typeof raw !== 'string' || raw.trim() === '') continue;
|
||||||
|
const value = raw.trim();
|
||||||
|
out['decision_last'] = value;
|
||||||
|
const token = value.startsWith('custom:') ? 'custom' : value.replace(/[^0-9a-zA-Z]+/g, '_');
|
||||||
|
const key = `decision_${token}_total`;
|
||||||
|
out[key] = Number(out[key] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export function toolCallReadings(
|
export function toolCallReadings(
|
||||||
trail: ReadonlyArray<{ readonly kind: string; readonly title: string }>,
|
trail: ReadonlyArray<{ readonly kind: string; readonly title: string }>,
|
||||||
declared: ReadonlyArray<{ readonly name: string }>,
|
declared: ReadonlyArray<{ readonly name: string }>,
|
||||||
@@ -902,12 +959,8 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
* 同款),测试台里所有单于是共用同一个号,跨单主键相撞、上一单的正式行渗进
|
* 同款),测试台里所有单于是共用同一个号,跨单主键相撞、上一单的正式行渗进
|
||||||
* 下一单的读面。给全到秒(视图那份只到分,是给人看的;这份是给铸主键用的)。
|
* 下一单的读面。给全到秒(视图那份只到分,是给人看的;这份是给铸主键用的)。
|
||||||
*/
|
*/
|
||||||
const two = (n: number): string => String(n).padStart(2, '0');
|
// 写法与运营中心沙箱看板圈行用的案号同源(`case-stamp.ts`):模型拿这串铸主键,看板拿同一串回查
|
||||||
const opened = new Date(task.startedAt);
|
const openedAt = openedAtText(task.startedAt);
|
||||||
const openedAt = Number.isNaN(opened.getTime())
|
|
||||||
? ''
|
|
||||||
: `${opened.getFullYear()}-${two(opened.getMonth() + 1)}-${two(opened.getDate())} ` +
|
|
||||||
`${two(opened.getHours())}:${two(opened.getMinutes())}:${two(opened.getSeconds())}`;
|
|
||||||
const enriched: Record<string, unknown> = {
|
const enriched: Record<string, unknown> = {
|
||||||
'case:openedAt': openedAt,
|
'case:openedAt': openedAt,
|
||||||
...scope,
|
...scope,
|
||||||
@@ -917,6 +970,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
...tallyReadings(mine, declared),
|
...tallyReadings(mine, declared),
|
||||||
...distinctReadings(mine, declared),
|
...distinctReadings(mine, declared),
|
||||||
...toolCallReadings(trail, manifest.declarations.tools),
|
...toolCallReadings(trail, manifest.declarations.tools),
|
||||||
|
...decisionReadings(trail),
|
||||||
...mine,
|
...mine,
|
||||||
...touched,
|
...touched,
|
||||||
};
|
};
|
||||||
@@ -937,6 +991,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
label: d.label ?? `${d.domain}.${d.object}`,
|
label: d.label ?? `${d.domain}.${d.object}`,
|
||||||
keys: d.fields.filter((f) => f.primaryKey).map((f) => f.name),
|
keys: d.fields.filter((f) => f.primaryKey).map((f) => f.name),
|
||||||
fields: d.fields.map((f) => ({ name: f.name, label: f.label ?? f.name, type: f.type, refersTo: f.refersTo })),
|
fields: d.fields.map((f) => ({ name: f.name, label: f.label ?? f.name, type: f.type, refersTo: f.refersTo })),
|
||||||
|
createOnly: d.createOnly === true,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
@@ -975,6 +1030,26 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
throw new Error(`台账无此列:${strayed.join('、')}(${target.label} 可写列:${[...known].join('、')})`);
|
throw new Error(`台账无此列:${strayed.join('、')}(${target.label} 可写列:${[...known].join('、')})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **只建不改的表,键已在库就拒**(契约 `createOnly`,2026-08-27 W05 实测逼出):
|
||||||
|
* 数据工单让人从零建主数据,模型却可能给在库的料号也写一行空骨架 —— 件上多出一行
|
||||||
|
* 要人填的记录,红带点名它缺品牌,而正式表里它什么都不缺。剧本写了拦不住,写入那一刻查。
|
||||||
|
*/
|
||||||
|
if (target.createOnly && target.keys.length > 0) {
|
||||||
|
const formal = await deps.ledger.query({
|
||||||
|
address: { domain: input.domain, object: input.object },
|
||||||
|
where: {},
|
||||||
|
orderBy: [],
|
||||||
|
limit: 500,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
const landedKeys = (await deps.ledger.taskDrafts(input.taskId))
|
||||||
|
.filter((d) => d.landed && d.domain === input.domain && d.object === input.object)
|
||||||
|
.map((d) => d.key);
|
||||||
|
const why = createOnlyViolation(target, input.key, formal.rows as ReadonlyArray<Record<string, unknown>>, landedKeys);
|
||||||
|
if (why !== undefined) throw new Error(why);
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* **引用列查引用**(2026-08-24,措辞治不了的结构治):声明了 `refersTo` 的列,
|
* **引用列查引用**(2026-08-24,措辞治不了的结构治):声明了 `refersTo` 的列,
|
||||||
* 值必须在被引用的表里真实存在(正式 + 本单草稿叠加)。起因:经营计划的
|
* 值必须在被引用的表里真实存在(正式 + 本单草稿叠加)。起因:经营计划的
|
||||||
@@ -1073,6 +1148,24 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
})`,
|
})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
* **已入库的行撤不掉**(2026-08-27,数据工单确认即入库):那一行在数据工单确认那一刻就进了正式表,
|
||||||
|
* 草稿层里那一笔只是「本单写过它」的记号 —— 撤掉记号正式表一个字不动,反而让本单的读面少一行
|
||||||
|
* (`mine:` / `_rows` 全靠它;S1 的哨兵 `parts_profile_rows >= created` 会从此开不了)。
|
||||||
|
*/
|
||||||
|
const landedHit = (await deps.ledger.taskDrafts(input.taskId)).find(
|
||||||
|
(d) =>
|
||||||
|
d.landed &&
|
||||||
|
d.domain === input.domain &&
|
||||||
|
d.object === input.object &&
|
||||||
|
Object.keys(input.key).every((k) => String(d.key[k] ?? '') === String(input.key[k] ?? '')),
|
||||||
|
);
|
||||||
|
if (landedHit !== undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`${target.label}里这一行(${Object.entries(input.key).map(([k, v]) => `${k}=${String(v)}`).join('、')})` +
|
||||||
|
'是本单在数据工单里建好、人确认后已入库的,撤不掉;要改由人在件上改,你接着办下一件事。',
|
||||||
|
);
|
||||||
|
}
|
||||||
const dropped = await deps.ledger.draftDrop({
|
const dropped = await deps.ledger.draftDrop({
|
||||||
taskId: input.taskId,
|
taskId: input.taskId,
|
||||||
address: { domain: input.domain, object: input.object },
|
address: { domain: input.domain, object: input.object },
|
||||||
@@ -1180,6 +1273,10 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const cards = {
|
const cards = {
|
||||||
|
/** 这一单正等人交回的卡 —— 工单没办结的 + 对话卡没作答、没被顶替的(见 human-tool `pendingCards`) */
|
||||||
|
async unanswered(taskId: TaskId) {
|
||||||
|
return (await deps.humanTool.pendingCards(taskId)).map((c) => ({ id: c.id, title: c.title }));
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* 发**补全卡** —— 对话卡族:缺口清单摆在对话区、带一张表单,人就地填齐。
|
* 发**补全卡** —— 对话卡族:缺口清单摆在对话区、带一张表单,人就地填齐。
|
||||||
* 与执行工单分开:派一张工单去问几个字段,收卡的人得进待处理、办完再回来,重得不成比例。
|
* 与执行工单分开:派一张工单去问几个字段,收卡的人得进待处理、办完再回来,重得不成比例。
|
||||||
@@ -1289,6 +1386,9 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
}>;
|
}>;
|
||||||
basis?: ReadonlyArray<{ k: string; v: string }>;
|
basis?: ReadonlyArray<{ k: string; v: string }>;
|
||||||
allowCustom?: boolean;
|
allowCustom?: boolean;
|
||||||
|
itemId?: string | undefined;
|
||||||
|
variant?: 'review' | undefined;
|
||||||
|
checklist?: ReadonlyArray<string> | undefined;
|
||||||
}) {
|
}) {
|
||||||
const cardSpan = deps.trace.beginAction({
|
const cardSpan = deps.trace.beginAction({
|
||||||
taskId: input.taskId,
|
taskId: input.taskId,
|
||||||
@@ -1297,10 +1397,23 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
input: { options: input.options.map((o) => o.value) },
|
input: { options: input.options.map((o) => o.value) },
|
||||||
});
|
});
|
||||||
const recommended = input.options.find((o) => o.recommended === true);
|
const recommended = input.options.find((o) => o.recommended === true);
|
||||||
|
/*
|
||||||
|
* 审核卡(剧本 cardVariant:'review'):按验收工单的样子摆,机制仍是决策卡 —— 剧本说了算,不靠模型抄。
|
||||||
|
* 面孔**引擎按当下那一步定好递过来**(`input.variant`,2026-08-27:模型漏了 itemId,卡退成「需要你定」);
|
||||||
|
* 没递才退回按 itemId 查剧本。
|
||||||
|
*/
|
||||||
|
const reviewFace =
|
||||||
|
input.variant === 'review' ||
|
||||||
|
(input.itemId !== undefined &&
|
||||||
|
(await deps.registry.manifestOf((await taskContext.of(input.taskId)).activityId)).assets.businessModel.steps?.find(
|
||||||
|
(st) => st.item === input.itemId,
|
||||||
|
)?.cardVariant === 'review');
|
||||||
const issued = await deps.humanTool.issueChatCard(
|
const issued = await deps.humanTool.issueChatCard(
|
||||||
{ taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap },
|
{ taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap },
|
||||||
{
|
{
|
||||||
k: 'decision',
|
k: 'decision',
|
||||||
|
...(reviewFace ? { variant: 'review' as const } : {}),
|
||||||
|
...(input.checklist === undefined || input.checklist.length === 0 ? {} : { checklist: [...input.checklist] }),
|
||||||
id: cardIdSchema.parse(ids.next()),
|
id: cardIdSchema.parse(ids.next()),
|
||||||
at: clock.now().toISOString(),
|
at: clock.now().toISOString(),
|
||||||
ask: input.ask,
|
ask: input.ask,
|
||||||
@@ -1313,7 +1426,12 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
...(o.consequence === undefined ? {} : { consequence: o.consequence }),
|
...(o.consequence === undefined ? {} : { consequence: o.consequence }),
|
||||||
...(o.recommended === undefined ? {} : { recommended: o.recommended }),
|
...(o.recommended === undefined ? {} : { recommended: o.recommended }),
|
||||||
})),
|
})),
|
||||||
allowCustom: input.allowCustom ?? false,
|
/*
|
||||||
|
* 审核卡一律收自定义意见(2026-08-27 用户实测:填了重做意见点「重做」,卡上却写着
|
||||||
|
* 「你选了:驳回(不写具体意见)」)—— 模型常把骨架里的 allowCustom 抄丢,前端只好退回驳回项;
|
||||||
|
* 「重做」本来就是带意见退回去改,这一档由面孔定,不靠模型抄。
|
||||||
|
*/
|
||||||
|
allowCustom: input.allowCustom ?? reviewFace,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await taskStore.addConsumption(input.taskId, { humanCalls: 1 });
|
await taskStore.addConsumption(input.taskId, { humanCalls: 1 });
|
||||||
@@ -1326,6 +1444,8 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
ownerId: StaffId;
|
ownerId: StaffId;
|
||||||
humanCallCap: number;
|
humanCallCap: number;
|
||||||
workOrder: 'execution' | 'acceptance';
|
workOrder: 'execution' | 'acceptance';
|
||||||
|
/** 这张卡挂在哪个计划项上 —— 引擎按它查剧本声明(如 `cardVariant`),不靠模型抄卡骨架;引擎自己发的卡(验收/兜底)没有项 */
|
||||||
|
itemId?: string | undefined;
|
||||||
card: Record<string, unknown>;
|
card: Record<string, unknown>;
|
||||||
}) {
|
}) {
|
||||||
const context = { taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap };
|
const context = { taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap };
|
||||||
@@ -1602,10 +1722,18 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
);
|
);
|
||||||
const releaseBlocks = await fillBoundTables(input.taskId, editable);
|
const releaseBlocks = await fillBoundTables(input.taskId, editable);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 终局验收卡的人工验收清单:剧本里声明了 `acceptanceChecklist` 且不是审核卡那一步的(放行那一步)。
|
||||||
|
* 人核这份工单要走的步骤是固定的,由活动声明、这里带上卡,不靠模型抄。
|
||||||
|
*/
|
||||||
|
const releaseChecklist = manifest.assets.businessModel.steps?.find(
|
||||||
|
(st) => st.acceptanceChecklist !== undefined && st.acceptanceChecklist.length > 0 && st.cardVariant !== 'review',
|
||||||
|
)?.acceptanceChecklist;
|
||||||
const submitted = await deps.release.submitForRelease({
|
const submitted = await deps.release.submitForRelease({
|
||||||
taskId: input.taskId,
|
taskId: input.taskId,
|
||||||
ownerId: input.ownerId,
|
ownerId: input.ownerId,
|
||||||
humanCallCap: input.humanCallCap,
|
humanCallCap: input.humanCallCap,
|
||||||
|
...(releaseChecklist === undefined ? {} : { checklist: releaseChecklist }),
|
||||||
deliverableId: String(input.card['deliverable'] ?? manifest.contract.requiredDeliverables[0]?.key ?? 'deliverable'),
|
deliverableId: String(input.card['deliverable'] ?? manifest.contract.requiredDeliverables[0]?.key ?? 'deliverable'),
|
||||||
title,
|
title,
|
||||||
basis: basis.length > 0 ? basis : [{ k: '来源', v: '案例模型收口' }],
|
basis: basis.length > 0 ? basis : [{ k: '来源', v: '案例模型收口' }],
|
||||||
@@ -1694,6 +1822,20 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
const gateLedgers = Array.isArray(input.card['gateLedgers'])
|
const gateLedgers = Array.isArray(input.card['gateLedgers'])
|
||||||
? input.card['gateLedgers'].map((x) => String(x).trim()).filter((x) => /^[a-z0-9_]+\.[a-z0-9_]+$/i.test(x))
|
? input.card['gateLedgers'].map((x) => String(x).trim()).filter((x) => /^[a-z0-9_]+\.[a-z0-9_]+$/i.test(x))
|
||||||
: [];
|
: [];
|
||||||
|
/*
|
||||||
|
* 数据工单 = 执行工单的变体(契约 `variant`)—— **由剧本声明、这里决定**(`PlanStep.cardVariant`),
|
||||||
|
* 不靠模型抄卡骨架:说明书里写着 `"variant":"data"`,模型抄卡时把它丢了(2026-08-27 实测,
|
||||||
|
* 同一张卡 gateLedgers 抄到了、variant 没有),对话区于是照旧摆「执行工单」。
|
||||||
|
* 与 `view` 同一条道理:这一步派的是什么单,写剧本时就定死,模型写不写都一样。卡上写了只当提示。
|
||||||
|
*/
|
||||||
|
const stepVariant =
|
||||||
|
input.itemId === undefined
|
||||||
|
? undefined
|
||||||
|
: (await deps.registry.manifestOf((await taskContext.of(input.taskId)).activityId)).assets.businessModel.steps?.find(
|
||||||
|
(s) => s.item === input.itemId,
|
||||||
|
)?.cardVariant;
|
||||||
|
// 执行工单这一路只认 data(review 是决策卡那一路的面孔)
|
||||||
|
const variant = stepVariant === 'data' || input.card['variant'] === 'data' ? ('data' as const) : undefined;
|
||||||
const issued = await deps.humanTool.issueExecution(context, {
|
const issued = await deps.humanTool.issueExecution(context, {
|
||||||
title,
|
title,
|
||||||
basis: basis.length > 0 ? basis : [{ k: '来源', v: '案例模型当下局面' }],
|
basis: basis.length > 0 ? basis : [{ k: '来源', v: '案例模型当下局面' }],
|
||||||
@@ -1703,6 +1845,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
...(standard === '' ? {} : { standard }),
|
...(standard === '' ? {} : { standard }),
|
||||||
...(blocks === undefined ? {} : { blocks: blocks as ExecutionWorkOrderDraft['blocks'] }),
|
...(blocks === undefined ? {} : { blocks: blocks as ExecutionWorkOrderDraft['blocks'] }),
|
||||||
...(gateLedgers.length === 0 ? {} : { gateLedgers }),
|
...(gateLedgers.length === 0 ? {} : { gateLedgers }),
|
||||||
|
...(variant === undefined ? {} : { variant }),
|
||||||
});
|
});
|
||||||
// 打扰率的计量对象:每发一张卡就是调了一次人 —— 不计就等于没有上限
|
// 打扰率的计量对象:每发一张卡就是调了一次人 —— 不计就等于没有上限
|
||||||
await taskStore.addConsumption(input.taskId, { humanCalls: 1 });
|
await taskStore.addConsumption(input.taskId, { humanCalls: 1 });
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ const taskConsumer: Consumer = async (delivery, container) => {
|
|||||||
kind,
|
kind,
|
||||||
ref: envelope.correlation.cause.ref,
|
ref: envelope.correlation.cause.ref,
|
||||||
payload: envelope.payload.data,
|
payload: envelope.payload.data,
|
||||||
|
// 发生时刻要带过去:人事件靠它判「这条回传是不是已经被上一拍处理过了」
|
||||||
|
occurredAt: envelope.occurredAt,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,11 @@
|
|||||||
- **进化必过治理**:候选 → 回放 → 人放行,状态机不许跳过回放
|
- **进化必过治理**:候选 → 回放 → 人放行,状态机不许跳过回放
|
||||||
- **安装恒为新增、版本并存**:卸载只置 state,在途任务按其启动时版本跑完
|
- **安装恒为新增、版本并存**:卸载只置 state,在途任务按其启动时版本跑完
|
||||||
|
|
||||||
近变(2026-07-28):模块面未变。app 层的 `GET /api/gov/activities` 出参加 `contract` 面
|
近变(2026-08-27):模块面未变。app 层那两条路由改名 `GET/POST /api/gov/activity-packages`(从前叫 `/api/gov/activities`),
|
||||||
|
`/api/gov/activities/*` 整个让给运营中心的活动模块(L1/L2/L3,`packages/contracts/src/api/activity-ops.ts`);
|
||||||
|
运营端的禁用 / 激活走本模块既有的 `setEnabled`(从前只有 seed 调它,没有 HTTP 口)。
|
||||||
|
|
||||||
|
近变(2026-07-28):模块面未变。app 层的 `GET /api/gov/activities`(现 `/api/gov/activity-packages`)出参加 `contract` 面
|
||||||
(必填入参 / 入参字段 / 输出口与载荷字段 / 声明的工具·模型·域对象·岗位 / 调人预算),
|
(必填入参 / 入参字段 / 输出口与载荷字段 / 声明的工具·模型·域对象·岗位 / 调人预算),
|
||||||
由 `manifestOf` 逐个读清单组装。此前治理台看不到活动的契约:
|
由 `manifestOf` 逐个读清单组装。此前治理台看不到活动的契约:
|
||||||
编排的人引用一个活动却不知道它吃什么,边映射只能靠猜;
|
编排的人引用一个活动却不知道它吃什么,边映射只能靠猜;
|
||||||
|
|||||||
@@ -311,6 +311,8 @@ function boundNames(manifest: AcceptanceAuditInput): ReadonlySet<string> {
|
|||||||
for (const tool of manifest.declarations.tools ?? []) {
|
for (const tool of manifest.declarations.tools ?? []) {
|
||||||
out.add(`tool_${tool.name.replace(/[^0-9a-zA-Z]+/g, '_')}_calls`);
|
out.add(`tool_${tool.name.replace(/[^0-9a-zA-Z]+/g, '_')}_calls`);
|
||||||
}
|
}
|
||||||
|
// 决策卡回答读数(2026-08-27):人在决策卡上选了什么,基座按留痕数 —— 审核通过与否不再由模型自写的旗说了算
|
||||||
|
for (const name of ['decision_last', 'decision_approve_total', 'decision_reject_total', 'decision_custom_total']) out.add(name);
|
||||||
for (const id of manifest.assets.businessModel.cmmnXml.match(/caseFileItem\s+id="([^"]+)"/g) ?? []) {
|
for (const id of manifest.assets.businessModel.cmmnXml.match(/caseFileItem\s+id="([^"]+)"/g) ?? []) {
|
||||||
const m = /id="([^"]+)"/.exec(id);
|
const m = /id="([^"]+)"/.exec(id);
|
||||||
if (m?.[1] !== undefined) out.add(m[1]);
|
if (m?.[1] !== undefined) out.add(m[1]);
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export function createRegistryService(deps: RegistryServiceDeps): ActivityRegist
|
|||||||
|
|
||||||
const verdict: InstallVerdict = {
|
const verdict: InstallVerdict = {
|
||||||
accepted: mayTakeEffect(staticOk, replay),
|
accepted: mayTakeEffect(staticOk, replay),
|
||||||
|
stored: false,
|
||||||
staticFindings: findings,
|
staticFindings: findings,
|
||||||
replay: {
|
replay: {
|
||||||
oldPassStillPass: replay.oldPassStillPass,
|
oldPassStillPass: replay.oldPassStillPass,
|
||||||
@@ -90,6 +91,8 @@ export function createRegistryService(deps: RegistryServiceDeps): ActivityRegist
|
|||||||
|
|
||||||
// 安装恒为新增、版本并存;在途任务按其启动时版本跑完
|
// 安装恒为新增、版本并存;在途任务按其启动时版本跑完
|
||||||
const isNewPackageVersion = await deps.store.savePackage(manifest, artifact);
|
const isNewPackageVersion = await deps.store.savePackage(manifest, artifact);
|
||||||
|
// 同版重装 = 什么都没写:如实回给调用方,别让安装脚本再打印「已装」
|
||||||
|
verdict.stored = isNewPackageVersion;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 资产初始版随包播种。
|
* 资产初始版随包播种。
|
||||||
|
|||||||
@@ -32,3 +32,12 @@
|
|||||||
- **调人预算环外强制**:超过契约声明的打扰上限即 `HumanCallBudgetExceededError`,
|
- **调人预算环外强制**:超过契约声明的打扰上限即 `HumanCallBudgetExceededError`,
|
||||||
活动代码无从自查自守
|
活动代码无从自查自守
|
||||||
- **落地失败卡是执行工单的 preset**,不构成第五种卡
|
- **落地失败卡是执行工单的 preset**,不构成第五种卡
|
||||||
|
- **人工验收清单点全才能通过**(2026-08-27 用户定):验收工单 / 审核卡带 `checklist`(剧本 `acceptanceChecklist`,
|
||||||
|
每份工单固定)时,「通过」的回传必须带 `checked ⊇ checklist`,否则 `checklist-incomplete` 拒回;驳回不看清单。
|
||||||
|
审核卡只拦「通过」那一个选项(`reviewApproveValue`:值叫 approve 的 / 推荐的 / 第一个)。点过的条目落进
|
||||||
|
`done.checked` / `chosen.checked` 留痕
|
||||||
|
- **`pendingCards(taskId)`**(2026-08-27):这一单正等人交回的卡 = 没办结的工单 + 没作答、没被顶替的对话卡
|
||||||
|
(`CardStore.listOpenChatCards` 按 `superseded_by is null` 取)。引擎每拍开头问它:有卡没答就不问模型
|
||||||
|
- **数据工单是执行工单的 `variant:'data'`**,同样不构成新的 mode:发卡 / 闸 / 回传全走执行工单那条路,只是对话区换一副面孔(2026-08-27)。
|
||||||
|
**确认即入库**(2026-08-27 下午)不在本模块:回传落定之后由 app 的回传路由调 `landDataOrder`
|
||||||
|
(`apps/api/src/app/data-order-landing.ts`)把这张卡 `gateLedgers` / 绑的表并进正式表 —— 本模块只管回传本身
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import type {
|
import type {
|
||||||
AcceptanceWorkOrder,
|
AcceptanceWorkOrder,
|
||||||
ChatCard,
|
ChatCard,
|
||||||
|
ChatCardReply,
|
||||||
DeliverableId,
|
DeliverableId,
|
||||||
ExecutionWorkOrder,
|
ExecutionWorkOrder,
|
||||||
|
WorkOrder,
|
||||||
WorkOrderId,
|
WorkOrderId,
|
||||||
WorkOrderReply,
|
WorkOrderReply,
|
||||||
} from '@contracts';
|
} from '@contracts';
|
||||||
|
import { chatCardSchema, workOrderSchema } from '@contracts';
|
||||||
import {
|
import {
|
||||||
allCriteriaGreen,
|
allCriteriaGreen,
|
||||||
arrivalOf,
|
arrivalOf,
|
||||||
@@ -276,3 +279,57 @@ describe('录入卡分段:段号.列名', () => {
|
|||||||
expect(split({ customer_code: 'CU-1', name: '老写法' }, 1)).toEqual({});
|
expect(split({ customer_code: 'CU-1', name: '老写法' }, 1)).toEqual({});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 人工验收清单要点全才能通过(2026-08-27 用户定:「只有人把这些判据都点成达标了,才能点击确认通过」)——
|
||||||
|
* 按钮灰掉是给人看的,这里是门:验收工单的「通过」与审核卡的「通过」选项同一道。驳回不拦。
|
||||||
|
*/
|
||||||
|
describe('人工验收清单:点全才能通过', () => {
|
||||||
|
const list = ['核对甲', '核对乙'];
|
||||||
|
// 夹具走契约 schema 造 —— 造出来的就是一张合法工单,不靠 as 硬转(§12.5)
|
||||||
|
const order: WorkOrder = workOrderSchema.parse({
|
||||||
|
id: 'wo-1',
|
||||||
|
at: '2026-08-27T10:00:00.000Z',
|
||||||
|
title: '评审放行与落账确认',
|
||||||
|
basis: [{ k: '来源', v: '收口' }],
|
||||||
|
mode: 'acceptance',
|
||||||
|
deliverable: 'landing',
|
||||||
|
criteria: [],
|
||||||
|
fields: [],
|
||||||
|
checklist: list,
|
||||||
|
});
|
||||||
|
const pass = (checked?: string[]) =>
|
||||||
|
({ mode: 'acceptance', workOrderId: 'wo-1', verdict: 'pass', values: {}, selectedPorts: {}, ...(checked === undefined ? {} : { checked }) }) as WorkOrderReply;
|
||||||
|
|
||||||
|
it('验收工单:少点一条就拒,点全放行;驳回不看清单', () => {
|
||||||
|
expect(checkWorkOrderReply(order, pass(['核对甲'])).map((v) => v.code)).toEqual(['checklist-incomplete']);
|
||||||
|
expect(checkWorkOrderReply(order, pass())).toHaveLength(1);
|
||||||
|
expect(checkWorkOrderReply(order, pass(list))).toEqual([]);
|
||||||
|
const redo = { mode: 'acceptance', workOrderId: 'wo-1', verdict: 'redo', values: {}, comment: '再看看', selectedPorts: {} } as WorkOrderReply;
|
||||||
|
expect(checkWorkOrderReply(order, redo)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('审核卡(决策卡):选「通过」那一项要点全,驳回 / 自定义不看', () => {
|
||||||
|
const card: ChatCard = chatCardSchema.parse({
|
||||||
|
k: 'decision',
|
||||||
|
id: 'c1',
|
||||||
|
at: '2026-08-27T10:00:00.000Z',
|
||||||
|
ask: '请审核',
|
||||||
|
basis: [],
|
||||||
|
options: [
|
||||||
|
{ value: 'approve', label: '通过', recommended: true },
|
||||||
|
{ value: 'reject', label: '驳回' },
|
||||||
|
],
|
||||||
|
allowCustom: true,
|
||||||
|
variant: 'review',
|
||||||
|
checklist: list,
|
||||||
|
});
|
||||||
|
const reply = (value: string, checked?: string[], note?: string) =>
|
||||||
|
({ k: 'decision', cardId: 'c1', value, ...(checked === undefined ? {} : { checked }), ...(note === undefined ? {} : { note }) }) as ChatCardReply;
|
||||||
|
expect(checkChatCardReply(card, reply('approve')).map((v) => v.code)).toEqual(['checklist-incomplete']);
|
||||||
|
expect(checkChatCardReply(card, reply('approve', list))).toEqual([]);
|
||||||
|
expect(checkChatCardReply(card, reply('reject'))).toEqual([]);
|
||||||
|
// 自定义收尾项本就必附理由(既有规矩);清单不拦它
|
||||||
|
expect(checkChatCardReply(card, reply('custom:意见', undefined, '意见'))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ function cardStore(used: number, sent: string[]): CardStore {
|
|||||||
},
|
},
|
||||||
getChatCard: async () => undefined,
|
getChatCard: async () => undefined,
|
||||||
listChatCards: async () => [],
|
listChatCards: async () => [],
|
||||||
|
listOpenChatCards: async () => [],
|
||||||
updateChatCard: async () => undefined,
|
updateChatCard: async () => undefined,
|
||||||
closeChatCard: async () => undefined,
|
closeChatCard: async () => undefined,
|
||||||
humanCallCount: async () => used,
|
humanCallCount: async () => used,
|
||||||
|
|||||||
@@ -143,6 +143,11 @@ export type HumanToolService = {
|
|||||||
/** 全部工单(含已办结)—— 工作成果要回看定稿的报告,只给 open 就永远是空的 */
|
/** 全部工单(含已办结)—— 工作成果要回看定稿的报告,只给 open 就永远是空的 */
|
||||||
allWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
|
allWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
|
||||||
chatCards(taskId: TaskId): Promise<ChatCard[]>;
|
chatCards(taskId: TaskId): Promise<ChatCard[]>;
|
||||||
|
/**
|
||||||
|
* **这一单正等人交回的卡**(2026-08-27):没办结的工单 + 没作答、没被顶替的对话卡。
|
||||||
|
* 引擎每拍开头问它 —— 有卡还没答,这一拍就不该问模型(叫了人就等人,不许自己往下推)。
|
||||||
|
*/
|
||||||
|
pendingCards(taskId: TaskId): Promise<Array<{ id: string; title: string; kind: 'work-order' | 'chat-card' }>>;
|
||||||
/** org 停用前置检查要问的「待答卡」 */
|
/** org 停用前置检查要问的「待答卡」 */
|
||||||
openCardsOf(staffId: StaffId): Promise<Array<{ cardId: string; title: string }>>;
|
openCardsOf(staffId: StaffId): Promise<Array<{ cardId: string; title: string }>>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -99,6 +99,15 @@ export function createCardStore(db: Db): CardStore {
|
|||||||
return rows.map((row) => chatCardSchema.parse(row.payload));
|
return rows.map((row) => chatCardSchema.parse(row.payload));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async listOpenChatCards(taskId) {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(chatCardTable)
|
||||||
|
.where(and(eq(chatCardTable.taskId, taskId), isNull(chatCardTable.supersededBy)))
|
||||||
|
.orderBy(asc(chatCardTable.at));
|
||||||
|
return rows.map((row) => chatCardSchema.parse(row.payload));
|
||||||
|
},
|
||||||
|
|
||||||
async updateChatCard(card: ChatCard) {
|
async updateChatCard(card: ChatCard) {
|
||||||
await db.update(chatCardTable).set({ payload: card }).where(eq(chatCardTable.id, card.id));
|
await db.update(chatCardTable).set({ payload: card }).where(eq(chatCardTable.id, card.id));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -131,6 +131,16 @@ export function checkWorkOrderReply(order: WorkOrder, reply: WorkOrderReply): Ca
|
|||||||
* 为什么拦在这里而不是落地(land):回传是同步收单、落地在推进器里异步跑,
|
* 为什么拦在这里而不是落地(land):回传是同步收单、落地在推进器里异步跑,
|
||||||
* 拦晚了就是「工单已答完、任务卡死、推进器循环报错」(实测)。
|
* 拦晚了就是「工单已答完、任务卡死、推进器循环报错」(实测)。
|
||||||
*/
|
*/
|
||||||
|
/*
|
||||||
|
* **人工验收清单要点全才能通过**(2026-08-27 用户定):卡上那几条是人核这份工单要走的步骤,
|
||||||
|
* 按钮灰掉只是给人看的,这里才是门 —— 直调 API 把「通过」交回来也得把清单点全。
|
||||||
|
*/
|
||||||
|
if (reply.verdict === 'pass') {
|
||||||
|
const missing = checklistMissing((order as AcceptanceWorkOrder).checklist, reply.checked);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
out.push({ code: 'checklist-incomplete', message: `验收清单还有 ${missing.length} 条待确认:${missing[0] ?? ''}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
const choice = (order as AcceptanceWorkOrder).portChoice;
|
const choice = (order as AcceptanceWorkOrder).portChoice;
|
||||||
if (choice !== undefined && reply.verdict === 'pass') {
|
if (choice !== undefined && reply.verdict === 'pass') {
|
||||||
const picked = reply.selectedPorts[choice.group];
|
const picked = reply.selectedPorts[choice.group];
|
||||||
@@ -154,6 +164,20 @@ export function checkWorkOrderReply(order: WorkOrder, reply: WorkOrderReply): Ca
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 清单里还没点成「达标」的那几条;没有清单就是空 */
|
||||||
|
export function checklistMissing(checklist: ReadonlyArray<string> | undefined, checked: ReadonlyArray<string> | undefined): string[] {
|
||||||
|
const done = new Set(checked ?? []);
|
||||||
|
return (checklist ?? []).filter((item) => !done.has(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审核卡上哪一个选项是「通过」—— 与前端 `review-face.ts` 同一口径:值叫 `approve` 的,否则推荐的那个,否则第一个。
|
||||||
|
* 清单只拦这一个选项:驳回不需要把清单点全。
|
||||||
|
*/
|
||||||
|
export function reviewApproveValue(card: Extract<ChatCard, { k: 'decision' }>): string | undefined {
|
||||||
|
return (card.options.find((o) => o.value === 'approve') ?? card.options.find((o) => o.recommended === true) ?? card.options[0])?.value;
|
||||||
|
}
|
||||||
|
|
||||||
export function checkChatCardReply(card: ChatCard, reply: ChatCardReply): CardViolation[] {
|
export function checkChatCardReply(card: ChatCard, reply: ChatCardReply): CardViolation[] {
|
||||||
const out: CardViolation[] = [];
|
const out: CardViolation[] = [];
|
||||||
if (card.id !== reply.cardId) return [{ code: 'mismatch', message: '回传的卡 ID 与卡不符' }];
|
if (card.id !== reply.cardId) return [{ code: 'mismatch', message: '回传的卡 ID 与卡不符' }];
|
||||||
@@ -161,6 +185,11 @@ export function checkChatCardReply(card: ChatCard, reply: ChatCardReply): CardVi
|
|||||||
|
|
||||||
if (card.k === 'decision' && reply.k === 'decision') {
|
if (card.k === 'decision' && reply.k === 'decision') {
|
||||||
if (card.chosen !== undefined) return [{ code: 'already-answered', message: '该决策已选,只增不改' }];
|
if (card.chosen !== undefined) return [{ code: 'already-answered', message: '该决策已选,只增不改' }];
|
||||||
|
// 审核卡穿验收工单的衣服:选「通过」也得把人工验收清单点全(与验收工单同一道门)
|
||||||
|
if (reply.value === reviewApproveValue(card)) {
|
||||||
|
const missing = checklistMissing(card.checklist, reply.checked);
|
||||||
|
if (missing.length > 0) return [{ code: 'checklist-incomplete', message: `验收清单还有 ${missing.length} 条待确认:${missing[0] ?? ''}` }];
|
||||||
|
}
|
||||||
const known = card.options.some((o) => o.value === reply.value);
|
const known = card.options.some((o) => o.value === reply.value);
|
||||||
// 自定义收尾项:allowCustom 打开时允许选项之外的处置(必附理由)
|
// 自定义收尾项:allowCustom 打开时允许选项之外的处置(必附理由)
|
||||||
if (!known && !card.allowCustom) out.push({ code: 'unknown-option', message: `分支不在选项内:${reply.value}` });
|
if (!known && !card.allowCustom) out.push({ code: 'unknown-option', message: `分支不在选项内:${reply.value}` });
|
||||||
|
|||||||
@@ -197,7 +197,13 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
|
|||||||
const at = deps.clock.now().toISOString();
|
const at = deps.clock.now().toISOString();
|
||||||
const result = reply.mode === 'acceptance' ? reply.verdict : 'submitted';
|
const result = reply.mode === 'acceptance' ? reply.verdict : 'submitted';
|
||||||
const values = reply.mode === 'acceptance' ? reply.values : reply.values;
|
const values = reply.mode === 'acceptance' ? reply.values : reply.values;
|
||||||
await deps.cards.markWorkOrderDone(reply.workOrderId, { at, by, result, values });
|
await deps.cards.markWorkOrderDone(reply.workOrderId, {
|
||||||
|
at,
|
||||||
|
by,
|
||||||
|
result,
|
||||||
|
values,
|
||||||
|
...(reply.mode === 'acceptance' && reply.checked !== undefined ? { checked: [...reply.checked] } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 出口:**算出来的优先,而且在服务端算数**(2026-08-02 修)。
|
* 出口:**算出来的优先,而且在服务端算数**(2026-08-02 修)。
|
||||||
@@ -365,6 +371,15 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
|
|||||||
return deps.cards.listChatCards(taskId);
|
return deps.cards.listChatCards(taskId);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async pendingCards(taskId) {
|
||||||
|
const orders = (await deps.cards.listWorkOrders(taskId, true)).filter((o) => o.done === undefined);
|
||||||
|
const chats = (await deps.cards.listOpenChatCards(taskId)).filter((c) => !chatCardAnswered(c));
|
||||||
|
return [
|
||||||
|
...orders.map((o) => ({ id: String(o.id), title: o.title, kind: 'work-order' as const })),
|
||||||
|
...chats.map((c) => ({ id: String(c.id), title: c.ask, kind: 'chat-card' as const })),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
async openCardsOf(staffId) {
|
async openCardsOf(staffId) {
|
||||||
return deps.cards.openCardsOf(staffId);
|
return deps.cards.openCardsOf(staffId);
|
||||||
},
|
},
|
||||||
@@ -375,7 +390,13 @@ function applyChatReply(card: ChatCard, reply: ChatCardReply, by: StaffId, at: s
|
|||||||
if (card.k === 'decision' && reply.k === 'decision') {
|
if (card.k === 'decision' && reply.k === 'decision') {
|
||||||
return {
|
return {
|
||||||
...card,
|
...card,
|
||||||
chosen: { value: reply.value, at, by, ...(reply.note === undefined ? {} : { note: reply.note }) },
|
chosen: {
|
||||||
|
value: reply.value,
|
||||||
|
at,
|
||||||
|
by,
|
||||||
|
...(reply.note === undefined ? {} : { note: reply.note }),
|
||||||
|
...(reply.checked === undefined ? {} : { checked: [...reply.checked] }),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (card.k === 'elicitation' && reply.k === 'elicitation') {
|
if (card.k === 'elicitation' && reply.k === 'elicitation') {
|
||||||
@@ -392,3 +413,9 @@ function applyChatReply(card: ChatCard, reply: ChatCardReply, by: StaffId, at: s
|
|||||||
}
|
}
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 对话卡答没答:决策卡看 chosen,其余(录入 / 补全 / 评价)看 done */
|
||||||
|
function chatCardAnswered(card: ChatCard): boolean {
|
||||||
|
if (card.k === 'decision') return card.chosen !== undefined;
|
||||||
|
return 'done' in card && card.done !== undefined;
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export type CardStore = {
|
|||||||
saveChatCard(taskId: TaskId, card: ChatCard, recipientId: StaffId): Promise<void>;
|
saveChatCard(taskId: TaskId, card: ChatCard, recipientId: StaffId): Promise<void>;
|
||||||
getChatCard(id: string): Promise<{ taskId: TaskId; card: ChatCard; recipientId: StaffId } | undefined>;
|
getChatCard(id: string): Promise<{ taskId: TaskId; card: ChatCard; recipientId: StaffId } | undefined>;
|
||||||
listChatCards(taskId: TaskId): Promise<ChatCard[]>;
|
listChatCards(taskId: TaskId): Promise<ChatCard[]>;
|
||||||
|
/** 还开着的对话卡(没被新卡顶替、没作废)—— 「这一单有没有卡在等人」问的是它,不是全部历史 */
|
||||||
|
listOpenChatCards(taskId: TaskId): Promise<ChatCard[]>;
|
||||||
updateChatCard(card: ChatCard): Promise<void>;
|
updateChatCard(card: ChatCard): Promise<void>;
|
||||||
closeChatCard(id: string, supersededBy: string, reason: string): Promise<void>;
|
closeChatCard(id: string, supersededBy: string, reason: string): Promise<void>;
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,15 @@ describe('projectChat —— 对话流与工作日志同源不同筛选', () =>
|
|||||||
expect(msg?.k === 'ai' ? msg.tag : undefined).toBe('guidance');
|
expect(msg?.k === 'ai' ? msg.tag : undefined).toBe('guidance');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('验收件标判据、执行件标指导', () => {
|
it('验收件标判据、执行件标指导 —— 认痕里的 mode,不认标题', () => {
|
||||||
|
const out = projectChat([
|
||||||
|
entry('card-sent', { id: 'a', title: '评审放行与落账确认(ACT-GATE-03)', detail: { kind: 'work-order', id: 'wo-a', mode: 'acceptance' } }),
|
||||||
|
entry('card-sent', { id: 'b', title: '请你验收前先补齐说明', detail: { kind: 'work-order', id: 'wo-b', mode: 'execution' } }),
|
||||||
|
]);
|
||||||
|
expect(out.map((m) => (m.k === 'ai' ? m.tag : undefined))).toEqual(['criterion', 'guidance']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('老痕没有 mode 才退回看标题', () => {
|
||||||
const out = projectChat([
|
const out = projectChat([
|
||||||
entry('card-sent', { id: 'a', title: '请你验收:定价方案', detail: { kind: 'work-order', id: 'wo-a' } }),
|
entry('card-sent', { id: 'a', title: '请你验收:定价方案', detail: { kind: 'work-order', id: 'wo-a' } }),
|
||||||
entry('card-sent', { id: 'b', title: '请你执行:补齐说明', detail: { kind: 'work-order', id: 'wo-b' } }),
|
entry('card-sent', { id: 'b', title: '请你执行:补齐说明', detail: { kind: 'work-order', id: 'wo-b' } }),
|
||||||
|
|||||||
@@ -374,3 +374,64 @@ describe('data-file-of:这一格点开是一份资料文件', () => {
|
|||||||
expect(out).not.toContain('onclick="x"');
|
expect(out).not.toContain('onclick="x"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 嵌套行模版 + `data-by` 父键过滤(2026-08-27,W05 差异评估结论审核「一个候选一张表」逼出来的):
|
||||||
|
* 外层按候选逐个复制,里层只列该候选名下的明细 —— 从前渲染器只认一层,里层的 data-rows 原样漏到成品里。
|
||||||
|
*/
|
||||||
|
describe('嵌套行模版与 data-by —— 一个候选一张表', () => {
|
||||||
|
// 外层容器用 <article>:行模版是 <section>,里面不会再出现 <article>(行容器的标签名不得在行模版内部再出现)
|
||||||
|
const html =
|
||||||
|
'<article data-rows="rel"><section><h2 data-slot="pn"></h2>' +
|
||||||
|
'<tbody data-rows="items" data-by="rel_id"><tr><td data-slot="name"></td><td data-slot="pn"></td></tr></tbody>' +
|
||||||
|
'</section></article>';
|
||||||
|
const scope = {
|
||||||
|
rel: [
|
||||||
|
{ rel_id: 'R1', pn: 'XL' },
|
||||||
|
{ rel_id: 'R2', pn: 'AP' },
|
||||||
|
],
|
||||||
|
items: [
|
||||||
|
{ rel_id: 'R1', name: '输出电压' },
|
||||||
|
{ rel_id: 'R2', name: '使能逻辑' },
|
||||||
|
{ rel_id: 'R1', name: '工作温度' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('里层只留与外层同键的行;父行的槽位仍填父值;data-by 属性随之消费', () => {
|
||||||
|
const out = renderView(html, scope);
|
||||||
|
const [first = '', second = ''] = out.split('</section>');
|
||||||
|
expect(out.split('<section>').length - 1).toBe(2); // 一个候选一个 section
|
||||||
|
expect(first).toContain('<h2>XL</h2>');
|
||||||
|
expect(first).toContain('<td>输出电压</td>');
|
||||||
|
expect(first).toContain('<td>工作温度</td>');
|
||||||
|
expect(first).not.toContain('使能逻辑');
|
||||||
|
expect(second).toContain('<h2>AP</h2>');
|
||||||
|
expect(second).toContain('<td>使能逻辑</td>');
|
||||||
|
expect(second).not.toContain('输出电压');
|
||||||
|
expect(out).not.toContain('data-by');
|
||||||
|
expect(out).not.toContain('data-rows');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('里层槽位与父行同名时取里层那一行(先展开里层,再填父行)', () => {
|
||||||
|
const out = renderView(html, { ...scope, items: [{ rel_id: 'R1', name: 'n', pn: '子' }] });
|
||||||
|
expect(out).toContain('<h2>XL</h2>');
|
||||||
|
expect(out).toContain('<td>子</td>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('data-by="子列=父列":两边列名不同也对得上 —— 当查表用(整表里挑这一颗料的那一行)', () => {
|
||||||
|
const out = renderView(
|
||||||
|
'<ul data-rows="rel"><li><b data-slot="pn"></b><span data-rows="profile" data-by="internal_pn=pn"><i data-slot="brand"></i></span></li></ul>',
|
||||||
|
{ rel: [{ pn: 'XL' }, { pn: 'AP' }], profile: [{ internal_pn: 'AP', brand: 'Diodes' }, { internal_pn: 'XL', brand: 'XLSEMI' }] },
|
||||||
|
);
|
||||||
|
expect(out).toContain('<b>XL</b><span><i>XLSEMI</i></span>');
|
||||||
|
expect(out).toContain('<b>AP</b><span><i>Diodes</i></span>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('父值缺席一行不留;没有 data-by 的里层照旧全复制', () => {
|
||||||
|
const none = renderView(html, { ...scope, rel: [{ rel_id: '', pn: 'X' }] });
|
||||||
|
expect(none).toContain('<h2>X</h2>');
|
||||||
|
expect(none).not.toContain('<td>');
|
||||||
|
const all = renderView(html.replace(' data-by="rel_id"', ''), scope);
|
||||||
|
expect(all.split('使能逻辑').length - 1).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -143,8 +143,16 @@ export function projectChat(entries: readonly NarrativeEntry[]): ChatMessage[] {
|
|||||||
sayable = false;
|
sayable = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// 验收件请人裁决 = 判据;执行件派工 = 指导
|
/*
|
||||||
const tag = e.title.includes('验收') ? ('criterion' as const) : ('guidance' as const);
|
* 验收件请人裁决 = 判据;执行件派工 = 指导 —— **认痕里的 `mode`,不认标题**(2026-08-27 用户:
|
||||||
|
* 「评审放行与落账确认的验收单怎么验收卡又变成老的验收卡了」)。从前判的是 `title.includes('验收')`,
|
||||||
|
* 而验收卡的题目当天改成了案例模型里那一项的名字「评审放行与落账确认(ACT-GATE-03)」——
|
||||||
|
* 四个字不在了,投影就把它当执行工单摆:通用「派出工单」气泡 + 判据列表,人工验收清单一条不见。
|
||||||
|
* 上面 card-reply 那一段早写过同一课(标题措辞是给人看的、随时会改),这条却还认字面。
|
||||||
|
* `mode` 是发卡时随 payload 一并入痕的结构化键;老痕没有它才退回标题。
|
||||||
|
*/
|
||||||
|
const mode = e.detail?.['mode'];
|
||||||
|
const tag = (mode === undefined ? e.title.includes('验收') : mode === 'acceptance') ? ('criterion' as const) : ('guidance' as const);
|
||||||
/*
|
/*
|
||||||
* **一个气泡:结论 + 功能卡**(2026-08-01 用户定的形状)。
|
* **一个气泡:结论 + 功能卡**(2026-08-01 用户定的形状)。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -149,14 +149,24 @@ function paintClass(attrs: string, scope: Readonly<Record<string, unknown>>): st
|
|||||||
* 每行内部的 `data-slot` 相对**该行对象**取值 —— 这样「逐条清单」不必写死条数。
|
* 每行内部的 `data-slot` 相对**该行对象**取值 —— 这样「逐条清单」不必写死条数。
|
||||||
*/
|
*/
|
||||||
const ROWS = /<([a-zA-Z][\w-]*)\b([^>]*?)\bdata-rows\s*=\s*"([^"]+)"([^>]*)>([\s\S]*?)<\/\1>/g;
|
const ROWS = /<([a-zA-Z][\w-]*)\b([^>]*?)\bdata-rows\s*=\s*"([^"]+)"([^>]*)>([\s\S]*?)<\/\1>/g;
|
||||||
|
/**
|
||||||
|
* `data-by="子列"` / `data-by="子列=父列"`:**只留与外层那一行同键的行**(2026-08-27,W05 差异评估
|
||||||
|
* 「一个候选一张表」逼出来的)—— 行模版可以嵌套:外层按候选逐个复制,里层只列该候选名下的差异明细。
|
||||||
|
* 比的是字符串相等;父列的值取外层作用域(嵌套时外层那一行已铺进作用域)。父值取不到 = 一行不留:
|
||||||
|
* 没有父亲的孩子不该冒充谁的。它也能当**查表**用:`<span data-rows="parts.profile" data-by="internal_pn=candidate_internal_pn">`
|
||||||
|
* 从整表里挑出这一颗料那一行摆品牌 —— 仍是取值,不是算。
|
||||||
|
*/
|
||||||
|
const BY = /\s*\bdata-by\s*=\s*"([^"]+)"/;
|
||||||
|
|
||||||
export function renderRows(html: string, scope: Readonly<Record<string, unknown>>): string {
|
export function renderRows(html: string, scope: Readonly<Record<string, unknown>>): string {
|
||||||
return html.replace(ROWS, (_all, tag: string, before: string, path: string, after: string, inner: string) => {
|
return html.replace(ROWS, (_all, tag: string, before: string, path: string, after: string, inner: string) => {
|
||||||
const raw = `${before}${after}`;
|
const raw = `${before}${after}`;
|
||||||
const attrs = raw.replace(/\s+/g, ' ').trim();
|
const by = BY.exec(raw)?.[1];
|
||||||
|
const attrs = raw.replace(BY, '').replace(/\s+/g, ' ').trim();
|
||||||
const open = `<${tag}${attrs === '' ? '' : ` ${attrs}`}>`;
|
const open = `<${tag}${attrs === '' ? '' : ` ${attrs}`}>`;
|
||||||
const value = resolveSlot(scope, path);
|
const value = resolveSlot(scope, path);
|
||||||
const rows = Array.isArray(value) ? value : [];
|
const all = Array.isArray(value) ? value : [];
|
||||||
|
const rows = by === undefined ? all : byParent(all, by, scope);
|
||||||
/*
|
/*
|
||||||
* 这一组行**可就地改**吗 —— 容器上的 `data-edit="域.对象"` 说了算。
|
* 这一组行**可就地改**吗 —— 容器上的 `data-edit="域.对象"` 说了算。
|
||||||
* 是的话每一行多渲一份输入孪生件,并把行号钉在行上(写回时按它对行)。
|
* 是的话每一行多渲一份输入孪生件,并把行号钉在行上(写回时按它对行)。
|
||||||
@@ -173,13 +183,33 @@ export function renderRows(html: string, scope: Readonly<Record<string, unknown>
|
|||||||
.map((row, index) => {
|
.map((row, index) => {
|
||||||
const one = (typeof row === 'object' && row !== null ? row : { value: row }) as Record<string, unknown>;
|
const one = (typeof row === 'object' && row !== null ? row : { value: row }) as Record<string, unknown>;
|
||||||
const twinned = editable ? twinInputs(inner, index) : inner;
|
const twinned = editable ? twinInputs(inner, index) : inner;
|
||||||
return renderTemplate(uniqueNames(textareaCurrent(selectCurrent(fileLinks(twinned, one, address), one), one), index), one);
|
/*
|
||||||
|
* **里层的行模版先按这一行展开**(嵌套),再填这一行自己的标量槽 —— 顺序反了,
|
||||||
|
* 里层行内的槽位会被外层这一行填掉(与 renderView 「先展开行再填标量」同一条道理)。
|
||||||
|
* 里层作用域 = 外层作用域 + 这一行:`data-by` 的父列、里层要引整表的名字都从这里取。
|
||||||
|
*/
|
||||||
|
const nested = ROWS.test(twinned) ? renderRows(twinned, { ...scope, ...one }) : twinned;
|
||||||
|
ROWS.lastIndex = 0;
|
||||||
|
return renderTemplate(uniqueNames(textareaCurrent(selectCurrent(fileLinks(nested, one, address), one), one), index), one);
|
||||||
})
|
})
|
||||||
.join('');
|
.join('');
|
||||||
return `${open}${body}</${tag}>`;
|
return `${open}${body}</${tag}>`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `data-by` 的过滤:子列的值 = 外层作用域里父列的值(字符串相等);父值缺席一行不留 */
|
||||||
|
function byParent(rows: readonly unknown[], by: string, scope: Readonly<Record<string, unknown>>): unknown[] {
|
||||||
|
const [childCol = '', parentCol = childCol] = by.split('=').map((s) => s.trim());
|
||||||
|
const parent = scope[parentCol];
|
||||||
|
if (parent === undefined || parent === null || parent === '') return [];
|
||||||
|
const want = String(parent);
|
||||||
|
return rows.filter((row) => {
|
||||||
|
if (typeof row !== 'object' || row === null) return false;
|
||||||
|
const v = (row as Record<string, unknown>)[childCol];
|
||||||
|
return v !== undefined && v !== null && String(v) === want;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** `data-file-of="列名"` —— 这一格点开是一份资料文件,文件认哪一行由该列的值定 */
|
/** `data-file-of="列名"` —— 这一格点开是一份资料文件,文件认哪一行由该列的值定 */
|
||||||
const FILE_OF = /\s*\bdata-file-of\s*=\s*"([^"]+)"/;
|
const FILE_OF = /\s*\bdata-file-of\s*=\s*"([^"]+)"/;
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,15 @@
|
|||||||
撤不到如实回 `false`(主键写法对不上是常见原因)—— 静默成功会让调用方以为撤掉了
|
撤不到如实回 `false`(主键写法对不上是常见原因)—— 静默成功会让调用方以为撤掉了
|
||||||
- 正式版写入(system / release)**必**产生数据事件;草稿写**必不**产生
|
- 正式版写入(system / release)**必**产生数据事件;草稿写**必不**产生
|
||||||
- `releaseWrite` 先过数据冲突检查:任一冲突即抛 `DraftConflictError`,不做部分合并
|
- `releaseWrite` 先过数据冲突检查:任一冲突即抛 `DraftConflictError`,不做部分合并
|
||||||
|
- **`releaseWrite(taskId, scope)` 是同一道放行写,门换成了数据工单的确认**(2026-08-27):
|
||||||
|
`scope.only` 只并那几张表(这张卡的提交闸盯的那几张,`gateLedgers` / 绑的 `blocks`);
|
||||||
|
并过的行**留在草稿层打 `landed` 记号**(`ledger.__draft.landed_at`)—— 它仍是本单写下的行
|
||||||
|
(`taskDrafts` / 任务视图照旧认它,引擎的 `mine:` / `_rows` 全靠这份),整单放行时不再写它、
|
||||||
|
不抛事件、不做冲突检查(`landedEarlierRows` 单独计数),只把表里那一份读回来当事实(出口载荷要它)。
|
||||||
|
再往已落地的行写一笔(人在件上改 / 模型 write),记号擦掉、基线换成落地后表里的值 —— 那一笔照旧等放行。
|
||||||
|
范围放行抛的数据事件**不指回本单**(它正等着自己的人事件;别单的监听照旧认正式版事件),
|
||||||
|
归因 `ledger-write:<scope.cause>`。试跑单(不落主数据)在这一道门上同样挡住:行留着、不打记号。
|
||||||
|
**不是第四种写,也不是绕门**:同一条冲突检查 → 原子合并 → 抛事件的路,调用方指不了清单没声明的表。
|
||||||
- **试跑单的主数据不进正式表**:`SimulationProbe` 说这一单是试跑时,
|
- **试跑单的主数据不进正式表**:`SimulationProbe` 说这一单是试跑时,
|
||||||
`kind = 'master'` 的草稿行不 upsert、不删、不抛数据事件,但仍进 `merged`(出口载荷取它);
|
`kind = 'master'` 的草稿行不 upsert、不删、不抛数据事件,但仍进 `merged`(出口载荷取它);
|
||||||
`simulatedRows` 单独计数,不混进 `mergedRows`。单据(`record`)不受影响 —— 试跑仍是真跑
|
`simulatedRows` 单独计数,不混进 `mergedRows`。单据(`record`)不受影响 —— 试跑仍是真跑
|
||||||
@@ -52,3 +61,12 @@
|
|||||||
此前 label 在建表那一步就丢了,治理台只剩 `project__intake_record` 这种键给人看。
|
此前 label 在建表那一步就丢了,治理台只剩 `project__intake_record` 这种键给人看。
|
||||||
用注释而非另建元数据表:名字与表同生共死,不会对不上。
|
用注释而非另建元数据表:名字与表同生共死,不会对不上。
|
||||||
`comment on` 是工具语句不吃占位符,字面量在 adapter 内转义单引号。
|
`comment on` 是工具语句不吃占位符,字面量在 adapter 内转义单引号。
|
||||||
|
|
||||||
|
近变(2026-08-27 下午):**数据工单确认即入库** —— `releaseWrite` 加可选 `scope`(`ReleaseScope`),
|
||||||
|
`DraftEntry` / `taskDrafts` 出参加 `landed`,`DraftStore` 加 `markLanded`,`ReleaseWriteResult` 加
|
||||||
|
`landedEarlierRows`(release 的放行痕据此分开说)。起因:W05 的数据工单卡上写着「确认后即入库」,
|
||||||
|
而草稿层通往正式表只有整单放行一道口子 —— 人建好的主数据要等整条评估放行才进库,试跑单永远不进。
|
||||||
|
|
||||||
|
近变(2026-08-27):**对象声明加 `createOnly`** —— 标了的表在本单**只建不改**:Agent 写入那一刻查正式表,
|
||||||
|
键已在库当场拒并指路(撤掉草稿里那一行)。数据工单「从零建主数据」的三张表用它。只管 Agent 的写路径
|
||||||
|
(`engines.draftWrite` 那道门),人在件上的保存不受此限。
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ const T3 = 't-it-3' as TaskId;
|
|||||||
const T4 = 't-it-4' as TaskId;
|
const T4 = 't-it-4' as TaskId;
|
||||||
const T5 = 't-it-5' as TaskId;
|
const T5 = 't-it-5' as TaskId;
|
||||||
const T6 = 't-it-6' as TaskId;
|
const T6 = 't-it-6' as TaskId;
|
||||||
|
const T7 = 't-it-7' as TaskId;
|
||||||
|
const T8 = 't-it-8' as TaskId;
|
||||||
|
const T9 = 't-it-9' as TaskId;
|
||||||
|
|
||||||
/** 哪几单算试跑 —— 由用例现填,替代 app 里那条查 `task.origin` 的 SQL */
|
/** 哪几单算试跑 —— 由用例现填,替代 app 里那条查 `task.origin` 的 SQL */
|
||||||
const SIMULATED = new Set<string>();
|
const SIMULATED = new Set<string>();
|
||||||
@@ -73,7 +76,7 @@ afterAll(async () => {
|
|||||||
.query(`delete from ledger.__object_meta where domain in ($1, $2)`, [DOMAIN, MASTER_DOMAIN])
|
.query(`delete from ledger.__object_meta where domain in ($1, $2)`, [DOMAIN, MASTER_DOMAIN])
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
await pool
|
await pool
|
||||||
.query(`delete from ledger.__draft where task_id = any($1)`, [[T1, T2, T3, T4, T5, T6]])
|
.query(`delete from ledger.__draft where task_id = any($1)`, [[T1, T2, T3, T4, T5, T6, T7, T8, T9]])
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
await pool.end();
|
await pool.end();
|
||||||
}
|
}
|
||||||
@@ -194,6 +197,7 @@ describe('ledger · 真实 PG', () => {
|
|||||||
expect(await ledger.releaseWrite(T1)).toEqual({
|
expect(await ledger.releaseWrite(T1)).toEqual({
|
||||||
mergedRows: 0,
|
mergedRows: 0,
|
||||||
emittedEvents: 0,
|
emittedEvents: 0,
|
||||||
|
landedEarlierRows: 0,
|
||||||
simulatedRows: 0,
|
simulatedRows: 0,
|
||||||
merged: [],
|
merged: [],
|
||||||
});
|
});
|
||||||
@@ -371,3 +375,91 @@ describe('ledger · 试跑闸门', () => {
|
|||||||
expect(master.rows[0]).toMatchObject({ name: '真单建的客户' });
|
expect(master.rows[0]).toMatchObject({ name: '真单建的客户' });
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据工单确认即入库(2026-08-27 用户定:「数据工单里填的数据,确认这个数据工单后就应该入库」)。
|
||||||
|
*
|
||||||
|
* 从前草稿层通往正式表只有整单放行这一道口子;数据工单让人从零建主数据,人一确认,
|
||||||
|
* 那几张表就该进库 —— 而这一单还要接着跑。判据:
|
||||||
|
* ① 只并范围内那几张表,范围外的草稿一字不动;
|
||||||
|
* ② 并过的行**留在草稿层、打记号**:`taskDrafts` 照旧列它(`landed: true`)、任务视图照旧有它 ——
|
||||||
|
* 引擎的 `mine:` / `_rows` 全靠这份,清掉它 P-08 的件当场变空、S1 的哨兵永远开不了;
|
||||||
|
* ③ 再确认一次是空操作(不再写、不再抛事件);整单放行时也不再写它、不再抛事件,只把它当事实读回来;
|
||||||
|
* ④ 落地之后再往那一行写一笔,记号擦掉、基线换新 —— 那一笔照旧等放行,且不被误判成「他单改过」;
|
||||||
|
* ⑤ 试跑单(不落主数据)在这一道门上同样挡住:行留在草稿层、不打记号,如实报 simulatedRows。
|
||||||
|
*/
|
||||||
|
describe('ledger · 数据工单确认即入库(范围放行)', () => {
|
||||||
|
const MASTER = { domain: MASTER_DOMAIN, object: MASTER_OBJECT };
|
||||||
|
const RECORD = { domain: DOMAIN, object: OBJECT };
|
||||||
|
const page = (address: { domain: string; object: string }, where: Record<string, string>) => ({
|
||||||
|
address,
|
||||||
|
where,
|
||||||
|
orderBy: [],
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
itDb()('①②③ 只并范围内的表;并过的行留在草稿层打记号;再确认与整单放行都不重写', async () => {
|
||||||
|
await ledger.draftWrite({ taskId: T7, address: MASTER, key: { customer_code: 'C-DO' }, patch: { customer_code: 'C-DO', name: '数据工单建的' } });
|
||||||
|
await ledger.draftWrite({ taskId: T7, address: RECORD, key: { pn: 'DO-PN' }, patch: { grade: 'A' } });
|
||||||
|
|
||||||
|
published = [];
|
||||||
|
const first = await ledger.releaseWrite(T7, { only: [MASTER], cause: 'data-order:wo-1' });
|
||||||
|
expect(first.mergedRows).toBe(1);
|
||||||
|
expect(first.landedEarlierRows).toBe(0);
|
||||||
|
expect(first.emittedEvents).toBe(1);
|
||||||
|
// 事件不指回本单(它正等着自己的人事件),归因认得出是哪张卡签下去的
|
||||||
|
expect(published[0]?.correlation.target).toBeUndefined();
|
||||||
|
expect(published[0]?.correlation.cause).toEqual({ kind: 'ledger-write', ref: 'data-order:wo-1' });
|
||||||
|
|
||||||
|
// ① 主数据进了正式表,单据没进
|
||||||
|
expect((await ledger.query(page(MASTER, { customer_code: 'C-DO' }))).rows[0]).toMatchObject({ name: '数据工单建的' });
|
||||||
|
expect((await ledger.query(page(RECORD, { pn: 'DO-PN' }))).rows).toEqual([]);
|
||||||
|
|
||||||
|
// ② 仍是本单写下的行:草稿层列着(带记号),任务视图有它
|
||||||
|
const drafts = await ledger.taskDrafts(T7);
|
||||||
|
expect(drafts.find((d) => d.object === MASTER_OBJECT)).toMatchObject({ landed: true, patch: { name: '数据工单建的' } });
|
||||||
|
expect(drafts.find((d) => d.object === OBJECT)).toMatchObject({ landed: false });
|
||||||
|
expect((await ledger.taskView(T7, page(MASTER, { customer_code: 'C-DO' }))).rows).toHaveLength(1);
|
||||||
|
|
||||||
|
// ③ 再确认一次 = 空操作
|
||||||
|
published = [];
|
||||||
|
const again = await ledger.releaseWrite(T7, { only: [MASTER], cause: 'data-order:wo-1' });
|
||||||
|
expect(again).toMatchObject({ mergedRows: 0, landedEarlierRows: 1, emittedEvents: 0 });
|
||||||
|
expect(published).toEqual([]);
|
||||||
|
|
||||||
|
// ③ 整单放行:只写剩下那一行,已落地的只当事实读回来
|
||||||
|
published = [];
|
||||||
|
const final = await ledger.releaseWrite(T7);
|
||||||
|
expect(final).toMatchObject({ mergedRows: 1, landedEarlierRows: 1, emittedEvents: 1 });
|
||||||
|
expect(published[0]?.correlation.target).toEqual({ kind: 'task', id: T7 });
|
||||||
|
expect(final.merged.map((m) => m.object).sort()).toEqual([OBJECT, MASTER_OBJECT].sort());
|
||||||
|
expect(final.merged.find((m) => m.object === MASTER_OBJECT)?.values).toMatchObject({ name: '数据工单建的' });
|
||||||
|
expect(await ledger.taskDrafts(T7)).toEqual([]);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
itDb()('④ 落地之后再写一笔:记号擦掉、基线换新,那一笔等放行且不算冲突', async () => {
|
||||||
|
await ledger.draftWrite({ taskId: T8, address: MASTER, key: { customer_code: 'C-DO2' }, patch: { customer_code: 'C-DO2', name: '一版' } });
|
||||||
|
await ledger.releaseWrite(T8, { only: [MASTER], cause: 'data-order:wo-2' });
|
||||||
|
|
||||||
|
await ledger.draftWrite({ taskId: T8, address: MASTER, key: { customer_code: 'C-DO2' }, patch: { name: '二版' } });
|
||||||
|
expect((await ledger.taskDrafts(T8))[0]).toMatchObject({ landed: false, patch: { name: '二版' } });
|
||||||
|
// 基线是落地后表里的「一版」—— 不是落地前那份空基线,否则这里会被当成「他单改过」
|
||||||
|
expect(await ledger.checkConflicts(T8)).toEqual([]);
|
||||||
|
|
||||||
|
const final = await ledger.releaseWrite(T8);
|
||||||
|
expect(final).toMatchObject({ mergedRows: 1, landedEarlierRows: 0 });
|
||||||
|
expect((await ledger.query(page(MASTER, { customer_code: 'C-DO2' }))).rows[0]).toMatchObject({ name: '二版' });
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
itDb()('⑤ 试跑单:这一道门同样不落主数据 —— 行留在草稿层、不打记号、如实报数', async () => {
|
||||||
|
SIMULATED.add(T9);
|
||||||
|
await ledger.draftWrite({ taskId: T9, address: MASTER, key: { customer_code: 'C-DO-SIM' }, patch: { customer_code: 'C-DO-SIM', name: '试跑' } });
|
||||||
|
|
||||||
|
published = [];
|
||||||
|
const result = await ledger.releaseWrite(T9, { only: [MASTER], cause: 'data-order:wo-3' });
|
||||||
|
expect(result).toMatchObject({ mergedRows: 0, simulatedRows: 1, landedEarlierRows: 0, emittedEvents: 0 });
|
||||||
|
expect((await ledger.query(page(MASTER, { customer_code: 'C-DO-SIM' }))).rows).toEqual([]);
|
||||||
|
expect((await ledger.taskDrafts(T9))[0]).toMatchObject({ landed: false });
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
|
|||||||
@@ -91,4 +91,17 @@ describe('detectConflicts —— 落地预检第五项', () => {
|
|||||||
const officialByKey = new Map([[keyOf({ pn: 'LM358' }), { pn: 'LM358', eau: '1000' }]]);
|
const officialByKey = new Map([[keyOf({ pn: 'LM358' }), { pn: 'LM358', eau: '1000' }]]);
|
||||||
expect(detectConflicts([draft('LM358', { eau: 2000 }, { eau: 1000 })], officialByKey)).toEqual([]);
|
expect(detectConflicts([draft('LM358', { eau: 2000 }, { eau: 1000 })], officialByKey)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **已经落过地的行不再参与冲突检查**(2026-08-27,数据工单确认即入库):
|
||||||
|
* 那一行在数据工单确认那一刻就进了正式表,放行时不会再写它 ——
|
||||||
|
* 别单后来改了它,也不是「我这一笔要盖掉别人」;拿它拦放行是把已发生的事当成待发生的。
|
||||||
|
*/
|
||||||
|
it('已落地(landed)的草稿行:别单后来改了也不算冲突 —— 放行不会再写它', () => {
|
||||||
|
const officialByKey = new Map([[keyOf({ pn: 'LM358' }), { pn: 'LM358', grade: 'B' }]]);
|
||||||
|
const landed: DraftEntry = { ...draft('LM358', { grade: 'C' }, { grade: 'C' }), landed: true };
|
||||||
|
expect(detectConflicts([landed], officialByKey)).toEqual([]);
|
||||||
|
// 同一行没标 landed 就照旧是冲突 —— 免得这条测试被「反正都不冲突」蒙混过去
|
||||||
|
expect(detectConflicts([draft('LM358', { grade: 'C' }, { grade: 'C' })], officialByKey)).toHaveLength(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,9 +66,30 @@ export type DraftWrite = {
|
|||||||
readonly tombstone?: boolean;
|
readonly tombstone?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **放行写的范围**(2026-08-27,数据工单确认即入库)。
|
||||||
|
*
|
||||||
|
* 不给 = 整单放行:草稿层全部并进正式表、清空。
|
||||||
|
* 给了 = 只并这几张表 —— 它们的门不是放行卡,是**数据工单的确认**(那张卡的提交闸
|
||||||
|
* 盯的就是这几张表:必填齐了才按得下确认,确认那一刻就入库)。并过的行**留在草稿层、
|
||||||
|
* 打上已落地记号**:它仍是本单写下的行(任务视图 / `mine:` / `_rows` 照旧认它),
|
||||||
|
* 只是整单放行时不再写它、不再为它抛事件。
|
||||||
|
* `cause` 落进数据事件的归因(`ledger-write:<cause>`),回看时认得出是哪张卡签下去的。
|
||||||
|
*/
|
||||||
|
export type ReleaseScope = {
|
||||||
|
readonly only: readonly ObjectAddress[];
|
||||||
|
readonly cause: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ReleaseWriteResult = {
|
export type ReleaseWriteResult = {
|
||||||
readonly mergedRows: number;
|
readonly mergedRows: number;
|
||||||
readonly emittedEvents: number;
|
readonly emittedEvents: number;
|
||||||
|
/**
|
||||||
|
* **早已落地、这次不再写的行数**(2026-08-27)—— 数据工单确认时并进去的那几行,
|
||||||
|
* 整单放行时只把它们当事实读回来(出口载荷要它们),不再写、不再抛事件。
|
||||||
|
* 放行痕上要分开说:「合并 N 行」里若混着这几行,那句话就多报了。
|
||||||
|
*/
|
||||||
|
readonly landedEarlierRows: number;
|
||||||
/**
|
/**
|
||||||
* **试跑单被挡下的主数据行数** —— 合到出参里去,是因为界面不撒谎:
|
* **试跑单被挡下的主数据行数** —— 合到出参里去,是因为界面不撒谎:
|
||||||
* 放行痕上写「合并 N 行」而其中几行根本没进表,那句话就是假的。
|
* 放行痕上写「合并 N 行」而其中几行根本没进表,那句话就是假的。
|
||||||
@@ -114,7 +135,13 @@ export type LedgerService = {
|
|||||||
* 门拦住一件办错的事却不给改正的手段,那扇门就只是个死胡同。
|
* 门拦住一件办错的事却不给改正的手段,那扇门就只是个死胡同。
|
||||||
*/
|
*/
|
||||||
draftDrop(drop: { taskId: TaskId; address: ObjectAddress; key: Record<string, unknown> }): Promise<boolean>;
|
draftDrop(drop: { taskId: TaskId; address: ObjectAddress; key: Record<string, unknown> }): Promise<boolean>;
|
||||||
releaseWrite(taskId: TaskId): Promise<ReleaseWriteResult>;
|
/**
|
||||||
|
* 放行写。`scope` 见 `ReleaseScope`:不给 = 整单;给了 = 只并那几张表(数据工单确认那一道门),
|
||||||
|
* 并过的行留在草稿层打记号。**不是第四种写**,也不是绕门:同一条冲突检查 → 原子合并 → 抛事件的路,
|
||||||
|
* 只是门换成了那张卡的确认闸 —— 落哪几张表由发卡的人说了算(`gateLedgers`),调用方指不了别的表进来
|
||||||
|
* (清单没声明的表这一单本就写不进草稿层)。
|
||||||
|
*/
|
||||||
|
releaseWrite(taskId: TaskId, scope?: ReleaseScope): Promise<ReleaseWriteResult>;
|
||||||
|
|
||||||
/** 正式版查询 —— 跨任务只见过门的事实 */
|
/** 正式版查询 —— 跨任务只见过门的事实 */
|
||||||
query(query: LedgerQuery): Promise<LedgerPage>;
|
query(query: LedgerQuery): Promise<LedgerPage>;
|
||||||
@@ -125,8 +152,17 @@ export type LedgerService = {
|
|||||||
* 与 `taskView` 的区别:那个是「正式版 + 我的草稿」的合并视图,
|
* 与 `taskView` 的区别:那个是「正式版 + 我的草稿」的合并视图,
|
||||||
* 这个只回我写过的那些行 —— 引擎求值要区分「这一单的事实」与「别人放行后的事实」。
|
* 这个只回我写过的那些行 —— 引擎求值要区分「这一单的事实」与「别人放行后的事实」。
|
||||||
*/
|
*/
|
||||||
taskDrafts(taskId: TaskId): Promise<Array<{ domain: string; object: string; key: Record<string, unknown>; patch: Record<string, unknown> }>>;
|
taskDrafts(taskId: TaskId): Promise<
|
||||||
/** 落地预检第五项:数据冲突检查 */
|
Array<{
|
||||||
checkConflicts(taskId: TaskId): Promise<ConflictFinding[]>;
|
domain: string;
|
||||||
|
object: string;
|
||||||
|
key: Record<string, unknown>;
|
||||||
|
patch: Record<string, unknown>;
|
||||||
|
/** 已在数据工单确认那一道门上并进正式表(见 `ReleaseScope`);仍是本单写下的行 */
|
||||||
|
landed: boolean;
|
||||||
|
}>
|
||||||
|
>;
|
||||||
|
/** 落地预检第五项:数据冲突检查;`only` 给了就只查那几张表(与 `releaseWrite` 的 scope 同一口径) */
|
||||||
|
checkConflicts(taskId: TaskId, only?: readonly ObjectAddress[]): Promise<ConflictFinding[]>;
|
||||||
discardDrafts(taskId: TaskId): Promise<void>;
|
discardDrafts(taskId: TaskId): Promise<void>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export type {
|
|||||||
MergedObject,
|
MergedObject,
|
||||||
ObjectAddress,
|
ObjectAddress,
|
||||||
ObjectDeclaration,
|
ObjectDeclaration,
|
||||||
|
ReleaseScope,
|
||||||
ReleaseWriteResult,
|
ReleaseWriteResult,
|
||||||
SchemaApplyResult,
|
SchemaApplyResult,
|
||||||
SystemWrite,
|
SystemWrite,
|
||||||
|
|||||||
@@ -316,7 +316,10 @@ create table if not exists ${DRAFT_TABLE} (
|
|||||||
tombstone boolean not null default false,
|
tombstone boolean not null default false,
|
||||||
updated_at timestamptz not null default now(),
|
updated_at timestamptz not null default now(),
|
||||||
primary key (task_id, domain, object, key_hash)
|
primary key (task_id, domain, object, key_hash)
|
||||||
);`;
|
);
|
||||||
|
-- 已落地记号(2026-08-27,数据工单确认即入库):非空 = 这一行已在一道更早的门上并进正式表,
|
||||||
|
-- 放行时不再写它。加列走 if not exists —— 这张表由启动时的 DDL 自建,不走迁移。
|
||||||
|
alter table ${DRAFT_TABLE} add column if not exists landed_at timestamptz;`;
|
||||||
|
|
||||||
type DraftRow = {
|
type DraftRow = {
|
||||||
task_id: string;
|
task_id: string;
|
||||||
@@ -326,6 +329,7 @@ type DraftRow = {
|
|||||||
patch: LedgerRow;
|
patch: LedgerRow;
|
||||||
baseline: LedgerRow;
|
baseline: LedgerRow;
|
||||||
tombstone: boolean;
|
tombstone: boolean;
|
||||||
|
landed_at: Date | string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function toEntry(row: DraftRow): DraftEntry & ObjectRef {
|
function toEntry(row: DraftRow): DraftEntry & ObjectRef {
|
||||||
@@ -336,6 +340,7 @@ function toEntry(row: DraftRow): DraftEntry & ObjectRef {
|
|||||||
patch: row.patch,
|
patch: row.patch,
|
||||||
baseline: row.baseline,
|
baseline: row.baseline,
|
||||||
tombstone: row.tombstone,
|
tombstone: row.tombstone,
|
||||||
|
landed: row.landed_at !== null && row.landed_at !== undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,6 +371,8 @@ export function createDraftStore(runner: Queryable): DraftStore {
|
|||||||
-- 后续步新写的字段就没有基线,冲突检查会把它们一律当成「他单改过」。
|
-- 后续步新写的字段就没有基线,冲突检查会把它们一律当成「他单改过」。
|
||||||
baseline = excluded.baseline || ${DRAFT_TABLE}.baseline,
|
baseline = excluded.baseline || ${DRAFT_TABLE}.baseline,
|
||||||
tombstone = excluded.tombstone,
|
tombstone = excluded.tombstone,
|
||||||
|
-- 再写一笔就不再是「已落地」:新的这一笔要等它自己的门(放行)
|
||||||
|
landed_at = null,
|
||||||
updated_at = now()`,
|
updated_at = now()`,
|
||||||
[
|
[
|
||||||
taskId,
|
taskId,
|
||||||
@@ -394,6 +401,13 @@ export function createDraftStore(runner: Queryable): DraftStore {
|
|||||||
[taskId, ref.domain, ref.object, keyOf(key)],
|
[taskId, ref.domain, ref.object, keyOf(key)],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
async markLanded(taskId, ref, key, baseline) {
|
||||||
|
await runner.query(
|
||||||
|
`update ${DRAFT_TABLE} set landed_at = now(), baseline = $5
|
||||||
|
where task_id = $1 and domain = $2 and object = $3 and key_hash = $4`,
|
||||||
|
[taskId, ref.domain, ref.object, keyOf(key), JSON.stringify(baseline)],
|
||||||
|
);
|
||||||
|
},
|
||||||
async clear(taskId) {
|
async clear(taskId) {
|
||||||
await runner.query(`delete from ${DRAFT_TABLE} where task_id = $1`, [taskId]);
|
await runner.query(`delete from ${DRAFT_TABLE} where task_id = $1`, [taskId]);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ export type DraftEntry = {
|
|||||||
readonly tombstone: boolean;
|
readonly tombstone: boolean;
|
||||||
/** 草稿建立时读到的基线值 —— 放行前的数据冲突检查用 */
|
/** 草稿建立时读到的基线值 —— 放行前的数据冲突检查用 */
|
||||||
readonly baseline: LedgerRow;
|
readonly baseline: LedgerRow;
|
||||||
|
/**
|
||||||
|
* **这一行已经落过地了**(2026-08-27,数据工单确认即入库)——
|
||||||
|
* 它仍是「本单写下的行」(任务视图、`mine:`、`_rows` 这些读面照旧认它),
|
||||||
|
* 只是那一笔已经在一道更早的门(数据工单的确认)上并进了正式表:
|
||||||
|
* 放行时不再写它、不再为它抛事件、不拿它做冲突检查。
|
||||||
|
* 再往这一行写一笔(人在件上改、模型 write)就把这个记号擦掉 —— 新的那一笔照旧等放行。
|
||||||
|
* 缺省 = 没落过(老条目与不关心这一维的调用方不必填)。
|
||||||
|
*/
|
||||||
|
readonly landed?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 主键的规范化串:字段名排序后拼接,保证同一行的键恒等 */
|
/** 主键的规范化串:字段名排序后拼接,保证同一行的键恒等 */
|
||||||
@@ -90,6 +99,8 @@ export function detectConflicts(
|
|||||||
): ConflictFinding[] {
|
): ConflictFinding[] {
|
||||||
const findings: ConflictFinding[] = [];
|
const findings: ConflictFinding[] = [];
|
||||||
for (const draft of drafts) {
|
for (const draft of drafts) {
|
||||||
|
// 已落地的行放行时不会再写 —— 别单后来改了它,也不是「我这一笔要盖掉别人」
|
||||||
|
if (draft.landed === true) continue;
|
||||||
const official = officialByKey.get(keyOf(draft.key));
|
const official = officialByKey.get(keyOf(draft.key));
|
||||||
if (official === undefined) continue; // 草稿新增的行,无从冲突
|
if (official === undefined) continue; // 草稿新增的行,无从冲突
|
||||||
for (const field of Object.keys(draft.patch)) {
|
for (const field of Object.keys(draft.patch)) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
DraftWrite,
|
DraftWrite,
|
||||||
LedgerService,
|
LedgerService,
|
||||||
ObjectAddress,
|
ObjectAddress,
|
||||||
|
ReleaseScope,
|
||||||
ReleaseWriteResult,
|
ReleaseWriteResult,
|
||||||
SchemaApplyResult,
|
SchemaApplyResult,
|
||||||
SystemWrite,
|
SystemWrite,
|
||||||
@@ -50,6 +51,20 @@ function groupDeclarations(declarations: readonly ObjectDeclaration[]): Map<stri
|
|||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 范围过滤:不给范围 = 全收 */
|
||||||
|
function inScope(only: readonly ObjectAddress[] | undefined): (draft: ObjectRef) => boolean {
|
||||||
|
if (only === undefined) return () => true;
|
||||||
|
const wanted = new Set(only.map((a) => `${a.domain}.${a.object}`));
|
||||||
|
return (draft) => wanted.has(`${draft.domain}.${draft.object}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 落地后的基线:只取这一笔碰过的那几列,值取表里那一份 */
|
||||||
|
function baselineOf(stored: Record<string, unknown>, patch: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const field of Object.keys(patch)) out[field] = stored[field];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
|
export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
|
||||||
function dataEvent(
|
function dataEvent(
|
||||||
ref: ObjectRef,
|
ref: ObjectRef,
|
||||||
@@ -181,13 +196,29 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** ③ 放行写:冲突检查 → 原子合并 → 此刻抛事件 */
|
/**
|
||||||
async releaseWrite(taskId): Promise<ReleaseWriteResult> {
|
* ③ 放行写:冲突检查 → 原子合并 → 此刻抛事件。
|
||||||
const conflicts = await this.checkConflicts(taskId);
|
*
|
||||||
|
* `scope` 给了就是**数据工单确认那一道门**(2026-08-27 用户定:「数据工单里填的数据,
|
||||||
|
* 确认这个数据工单后就应该入库」):只并那几张表,并过的行留在草稿层打「已落地」记号 ——
|
||||||
|
* 它仍是本单写下的行(`mine:` / `_rows` / 任务视图照旧认它,不然 P-08 那张件当场变空、
|
||||||
|
* S1 的哨兵 `parts_profile_rows >= created` 永远开不了),只是整单放行时不再写它。
|
||||||
|
*/
|
||||||
|
async releaseWrite(taskId, scope?: ReleaseScope): Promise<ReleaseWriteResult> {
|
||||||
|
const conflicts = await this.checkConflicts(taskId, scope?.only);
|
||||||
if (conflicts.length > 0) throw new DraftConflictError(conflicts);
|
if (conflicts.length > 0) throw new DraftConflictError(conflicts);
|
||||||
|
|
||||||
const drafts = await deps.drafts.list(taskId);
|
const drafts = (await deps.drafts.list(taskId)).filter(inScope(scope?.only));
|
||||||
if (drafts.length === 0) return { mergedRows: 0, emittedEvents: 0, simulatedRows: 0, merged: [] };
|
const nothing: ReleaseWriteResult = { mergedRows: 0, emittedEvents: 0, landedEarlierRows: 0, simulatedRows: 0, merged: [] };
|
||||||
|
if (drafts.length === 0) return nothing;
|
||||||
|
const scoped = scope !== undefined;
|
||||||
|
const causeRef = scoped ? scope.cause : `release:${taskId}`;
|
||||||
|
/*
|
||||||
|
* 事件指不指回本单:整单放行指回(它正要收尾,与从前一样);数据工单那一道**不指回** ——
|
||||||
|
* 本单此刻正等着自己的人事件(卡的回传)叫醒,再喂它七八条 data 事件只会在日志里结巴,
|
||||||
|
* 而它对这些行的「读己之写」从没断过。别单的监听照旧按正式版事件认(跨任务只认过门的事实)。
|
||||||
|
*/
|
||||||
|
const target = scoped ? undefined : taskId;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* **试跑单不落主数据** —— 挡在这一处,是因为这里是草稿层通往世界的唯一一道口子。
|
* **试跑单不落主数据** —— 挡在这一处,是因为这里是草稿层通往世界的唯一一道口子。
|
||||||
@@ -208,6 +239,7 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
|
|||||||
const heldBack = (draft: { domain: string; object: string }): boolean =>
|
const heldBack = (draft: { domain: string; object: string }): boolean =>
|
||||||
simulated && masters.has(`${draft.domain}.${draft.object}`);
|
simulated && masters.has(`${draft.domain}.${draft.object}`);
|
||||||
let simulatedRows = 0;
|
let simulatedRows = 0;
|
||||||
|
let landedEarlierRows = 0;
|
||||||
|
|
||||||
const pending: EnvelopeDraft[] = [];
|
const pending: EnvelopeDraft[] = [];
|
||||||
/*
|
/*
|
||||||
@@ -224,6 +256,17 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
|
|||||||
await deps.store.transaction(async (store, draftStore) => {
|
await deps.store.transaction(async (store, draftStore) => {
|
||||||
for (const draft of drafts) {
|
for (const draft of drafts) {
|
||||||
const ref: ObjectRef = { domain: draft.domain, object: draft.object };
|
const ref: ObjectRef = { domain: draft.domain, object: draft.object };
|
||||||
|
if (draft.landed === true) {
|
||||||
|
/*
|
||||||
|
* 早已在数据工单确认那一道门上落过地:这次不写、不抛事件、不计入合并数;
|
||||||
|
* 只把表里那一份读回来当事实(出口载荷要它)。表里读不到(别单后来删了)就不摆。
|
||||||
|
*/
|
||||||
|
landedEarlierRows += 1;
|
||||||
|
if (draft.tombstone) continue;
|
||||||
|
const current = (await store.selectByKeys(ref, [draft.key]))[0];
|
||||||
|
if (current !== undefined) landed.push({ domain: draft.domain, object: draft.object, key: draft.key, values: current });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (heldBack(draft)) {
|
if (heldBack(draft)) {
|
||||||
simulatedRows += 1;
|
simulatedRows += 1;
|
||||||
if (draft.tombstone) continue; // 试跑不删主数据,连一行也不许少
|
if (draft.tombstone) continue; // 试跑不删主数据,连一行也不许少
|
||||||
@@ -243,9 +286,8 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
|
|||||||
}
|
}
|
||||||
if (draft.tombstone) {
|
if (draft.tombstone) {
|
||||||
await store.deleteRow(ref, draft.key);
|
await store.deleteRow(ref, draft.key);
|
||||||
pending.push(
|
pending.push(dataEvent(ref, draft.key, [], 'delete', target, { kind: 'ledger-write', ref: causeRef }));
|
||||||
dataEvent(ref, draft.key, [], 'delete', taskId, { kind: 'ledger-write', ref: `release:${taskId}` }),
|
if (scoped) await draftStore.markLanded(taskId, ref, draft.key, {});
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const existing = await store.selectByKeys(ref, [draft.key]);
|
const existing = await store.selectByKeys(ref, [draft.key]);
|
||||||
@@ -264,18 +306,24 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
|
|||||||
draft.key,
|
draft.key,
|
||||||
Object.keys(draft.patch),
|
Object.keys(draft.patch),
|
||||||
existing.length === 0 ? 'insert' : 'update',
|
existing.length === 0 ? 'insert' : 'update',
|
||||||
taskId,
|
target,
|
||||||
{ kind: 'ledger-write', ref: `release:${taskId}` },
|
{ kind: 'ledger-write', ref: causeRef },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
/*
|
||||||
|
* 数据工单那一道门:条目留着、打记号,基线换成落地后表里的值(PG 归型后的那一份)——
|
||||||
|
* 之后再往这一行写一笔,冲突检查比的才是「我改之前表里是什么」。
|
||||||
|
*/
|
||||||
|
if (scoped) await draftStore.markLanded(taskId, ref, draft.key, baselineOf(stored ?? draft.patch, draft.patch));
|
||||||
}
|
}
|
||||||
await draftStore.clear(taskId);
|
if (!scoped) await draftStore.clear(taskId);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const envelope of pending) await deps.events.publish(envelope);
|
for (const envelope of pending) await deps.events.publish(envelope);
|
||||||
return {
|
return {
|
||||||
mergedRows: drafts.length - simulatedRows,
|
mergedRows: drafts.length - simulatedRows - landedEarlierRows,
|
||||||
emittedEvents: pending.length,
|
emittedEvents: pending.length,
|
||||||
|
landedEarlierRows,
|
||||||
simulatedRows,
|
simulatedRows,
|
||||||
merged: landed,
|
merged: landed,
|
||||||
};
|
};
|
||||||
@@ -299,11 +347,11 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
|
|||||||
|
|
||||||
async taskDrafts(taskId) {
|
async taskDrafts(taskId) {
|
||||||
const drafts = await deps.drafts.list(taskId);
|
const drafts = await deps.drafts.list(taskId);
|
||||||
return drafts.map((d) => ({ domain: d.domain, object: d.object, key: d.key, patch: d.patch }));
|
return drafts.map((d) => ({ domain: d.domain, object: d.object, key: d.key, patch: d.patch, landed: d.landed === true }));
|
||||||
},
|
},
|
||||||
|
|
||||||
async checkConflicts(taskId) {
|
async checkConflicts(taskId, only) {
|
||||||
const drafts = await deps.drafts.list(taskId);
|
const drafts = (await deps.drafts.list(taskId)).filter(inScope(only));
|
||||||
if (drafts.length === 0) return [];
|
if (drafts.length === 0) return [];
|
||||||
const byObject = new Map<string, Array<(typeof drafts)[number]>>();
|
const byObject = new Map<string, Array<(typeof drafts)[number]>>();
|
||||||
for (const d of drafts) {
|
for (const d of drafts) {
|
||||||
|
|||||||
@@ -69,5 +69,14 @@ export type DraftStore = {
|
|||||||
* (唯一能改的是那个数,改了别的判据又不对),13 次拒回、23 拍、234k token 才熔断。
|
* (唯一能改的是那个数,改了别的判据又不对),13 次拒回、23 拍、234k token 才熔断。
|
||||||
*/
|
*/
|
||||||
drop(taskId: string, ref: ObjectRef, key: LedgerKey): Promise<void>;
|
drop(taskId: string, ref: ObjectRef, key: LedgerKey): Promise<void>;
|
||||||
|
/**
|
||||||
|
* 给本单草稿层的一行打上「已落地」记号(2026-08-27,数据工单确认即入库)。
|
||||||
|
*
|
||||||
|
* 那一行刚经一道更早的门并进了正式表:条目**留着**(它仍是本单写下的行,
|
||||||
|
* 任务视图 / `mine:` / `_rows` 照旧认它),只是放行时不再写它。
|
||||||
|
* `baseline` 换成落地后正式表里的值 —— 之后再往这一行写一笔(记号随之擦掉),
|
||||||
|
* 冲突检查比的才是「我改之前表里是什么」,而不是落地前那份早已过时的基线。
|
||||||
|
*/
|
||||||
|
markLanded(taskId: string, ref: ObjectRef, key: LedgerKey, baseline: LedgerRow): Promise<void>;
|
||||||
clear(taskId: string): Promise<void>;
|
clear(taskId: string): Promise<void>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -59,8 +59,16 @@
|
|||||||
(A1 一单连拒五拍,五次全是「S3_confirm 要先 start」)—— 那道墙保护不了任何东西:
|
(A1 一单连拒五拍,五次全是「S3_confirm 要先 start」)—— 那道墙保护不了任何东西:
|
||||||
start 是**模型自己的**动作,同一串连发即过,拦下只是逼它多演一步学不稳的礼仪。
|
start 是**模型自己的**动作,同一串连发即过,拦下只是逼它多演一步学不稳的礼仪。
|
||||||
人工激活真正的分量原样保留:**引擎不因哨兵满足替它启动**(哨兵只推到
|
人工激活真正的分量原样保留:**引擎不因哨兵满足替它启动**(哨兵只推到
|
||||||
Available/Enabled),**也不给 doneWhen 替人收**(自动收步跳过人工激活项)——
|
Available/Enabled),**人答卡之前不给 doneWhen 替人收**。`start` 仍是合法行动,只是可省。
|
||||||
收它的只能是模型自己的 finish。`start` 仍是合法行动,只是可省。
|
- **人工激活的项,人答过卡之后照常求 doneWhen**(2026-08-27 晚,用户:「根因修了吗?不要只修表象」):
|
||||||
|
自动收步对人工激活项的门槛是 `worldTouched`(卡真发过)且此刻没有未答的卡 —— 卡没发过的
|
||||||
|
doneWhen 再真也不收(那才是替人签字);答过之后「办完了没有」若是基座数得出来的事实
|
||||||
|
(W05 两张数据工单:人填的值已入库、必填不缺),引擎自己收,不再指望模型记得 finish
|
||||||
|
(剧本两处写死「先 finish 再发下一张」,模型照样忘,两步同时 Active、件停在上一步)。
|
||||||
|
拍首扫一遍,**拍尾再扫一遍**(人答卡那一拍里模型写完东西 doneWhen 才真;这一拍刚发卡的项排除;
|
||||||
|
提交放行那一拍不扫,免得把在途验收信封擦掉)。没声明 doneWhen 的人工激活项一个字不变。
|
||||||
|
- **画面跟着这一拍发出去的卡走**(同日):human / entry / decide 把「卡是哪一步的」交回拍内,
|
||||||
|
那一步声明了画面就推它,压过「第一步没干完的」—— 卡是发给人的那件事,件必须是那件事的件。
|
||||||
- **里程碑前提达成即达成**(2026-07-30,`achieveMilestones`):CMMN 的里程碑没有执行体,
|
- **里程碑前提达成即达成**(2026-07-30,`achieveMilestones`):CMMN 的里程碑没有执行体,
|
||||||
也没有谁去按它。从前只有 `onEvent(ref = 里程碑 id)` 会 occur,而系统里没有任何东西
|
也没有谁去按它。从前只有 `onEvent(ref = 里程碑 id)` 会 occur,而系统里没有任何东西
|
||||||
发这种事件 —— 于是里程碑永远 `Available`:既是必做项(堵死 autoComplete),
|
发这种事件 —— 于是里程碑永远 `Available`:既是必做项(堵死 autoComplete),
|
||||||
@@ -127,3 +135,20 @@
|
|||||||
与「写的授权面是台账声明」「调工具的授权面是白名单」同一条 ——
|
与「写的授权面是台账声明」「调工具的授权面是白名单」同一条 ——
|
||||||
立了没人叫醒的监听不是等待,是失联(实测:模型在依赖断点上临场立 data 监听,
|
立了没人叫醒的监听不是等待,是失联(实测:模型在依赖断点上临场立 data 监听,
|
||||||
而那个活动一个监听都没声明,单子永久挂在 `awaiting-event`)
|
而那个活动一个监听都没声明,单子永久挂在 `awaiting-event`)
|
||||||
|
|
||||||
|
近变(2026-08-27 傍晚):**叫了人就等人** —— `runTurn` 在途验收之后、判齐快道之前先问 `cards.unanswered(taskId)`:
|
||||||
|
这一单还有卡没交回(工单没办结 / 对话卡没作答),这一拍不问模型,留一条内部痕、状态仍是 awaiting。
|
||||||
|
不管是谁叫醒的(迟到的回传事件、另一台后端的推进器、手动 wake)都推不动它。起因:S1 的卡发出 0.3 秒后,
|
||||||
|
上一张卡回传的事件才投到,再一拍模型照事实行「已经发过卡,人已经被问过了」把没人答的 S1 收掉了。
|
||||||
|
`cards.decide` 另多一项可选 `variant`(引擎按剧本定面孔,点名的项挂不到就按当下在办的那一步)与 `checklist`
|
||||||
|
(剧本 `acceptanceChecklist`,人工验收清单,与面孔同一条路带上卡);终局验收卡的清单由组装层在 submit 时从
|
||||||
|
剧本里取(声明了清单且不是审核卡的那一步)。
|
||||||
|
|
||||||
|
近变(2026-08-27):`cards.issue` 的入参多一项可选 `itemId`(这张卡挂在哪个计划项上)—— 组装层据此查剧本声明
|
||||||
|
(`PlanStep.cardVariant`:这一步派的是数据工单),**不靠模型抄卡骨架**(实测它会把 `variant` 抄丢)。
|
||||||
|
引擎自己发的卡(验收 / 兜底)没有项,不传。
|
||||||
|
|
||||||
|
近变(2026-08-27):`cards.decide` 同样多一项可选 `itemId`(`cardVariant: 'review'` = 审核卡摆成验收工单的样子,机制仍是决策卡);
|
||||||
|
决策卡的回答进基座读数 `decision_last` / `decision_<值>_total`(按 card-reply 数)—— 审核通过与否不再由模型自写的旗说了算;
|
||||||
|
拒回 / 观察 / 事实行按答案分岔(`chosenGuidance`):`custom:` 不是通过,不许写通过旗、不许 finish。
|
||||||
|
|
||||||
|
|||||||
@@ -65,3 +65,31 @@ describe('阶段进度 —— 三段激活态 + 一格收口 = x/4', () => {
|
|||||||
expect(phaseProgress([], at([]), false)).toBeUndefined();
|
expect(phaseProgress([], at([]), false)).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 被跳过的项不许把进度条钉死(2026-08-27 用户实测:W05 都在库时数据工单两项一直 Available,
|
||||||
|
* 条永远停在 1/6,而单子早已办到审核)。案子走到哪一段,条就到哪一段。
|
||||||
|
*/
|
||||||
|
describe('被跳过的项不钉死进度条', () => {
|
||||||
|
const W05: CasePhase[] = [
|
||||||
|
{ name: '启动与存在性检查', items: ['S0_bind', 'S0d_p08_master', 'S0d_p10_document'] },
|
||||||
|
{ name: '评估输入确认', items: ['S1_input_confirm'] },
|
||||||
|
{ name: '差异评估', items: ['S2_normalize', 'S3_assess'] },
|
||||||
|
{ name: 'FAE 审核', items: ['S4_review'] },
|
||||||
|
];
|
||||||
|
it('数据工单两项从没进过场(Available),S1 一活起来条就到第二段;S3 办着就到第三段', () => {
|
||||||
|
const atS1 = items({ S0_bind: 'Completed', S0d_p08_master: 'Available', S0d_p10_document: 'Available', S1_input_confirm: 'Active' });
|
||||||
|
expect(phaseProgress(W05, atS1, false)).toMatchObject({ index: 2, total: 5, name: '评估输入确认' });
|
||||||
|
const atS3 = items({ S0_bind: 'Completed', S0d_p08_master: 'Available', S0d_p10_document: 'Available', S1_input_confirm: 'Completed', S2_normalize: 'Completed', S3_assess: 'Active' });
|
||||||
|
expect(phaseProgress(W05, atS3, false)).toMatchObject({ index: 3, name: '差异评估' });
|
||||||
|
});
|
||||||
|
it('走过的那一段全收完、下一段还没动:进下一段;最后一段收完:收口格', () => {
|
||||||
|
const done2 = items({ S0_bind: 'Completed', S0d_p08_master: 'Available', S0d_p10_document: 'Available', S1_input_confirm: 'Completed', S2_normalize: 'Available' });
|
||||||
|
expect(phaseProgress(W05, done2, false)).toMatchObject({ index: 3, name: '差异评估' });
|
||||||
|
const all = items({ S0_bind: 'Completed', S0d_p08_master: 'Available', S0d_p10_document: 'Available', S1_input_confirm: 'Completed', S2_normalize: 'Completed', S3_assess: 'Completed', S4_review: 'Completed' });
|
||||||
|
expect(phaseProgress(W05, all, false)).toMatchObject({ index: 5, name: CLOSING_PHASE });
|
||||||
|
});
|
||||||
|
it('一项都没动过:仍停在第一段(老规矩)', () => {
|
||||||
|
expect(phaseProgress(W05, items({ S0_bind: 'Available', S1_input_confirm: 'Available' }), false)).toMatchObject({ index: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ function harness(
|
|||||||
viewFn?: (written: Harness['written']) => Record<string, unknown>;
|
viewFn?: (written: Harness['written']) => Record<string, unknown>;
|
||||||
/** 这个活动清单声明过哪些监听(不给就当它声明了 data —— 老测试照跑) */
|
/** 这个活动清单声明过哪些监听(不给就当它声明了 data —— 老测试照跑) */
|
||||||
declaredListeners?: ReadonlyArray<{ eventType: string; keyFilter: Record<string, string> }>;
|
declaredListeners?: ReadonlyArray<{ eventType: string; keyFilter: Record<string, string> }>;
|
||||||
|
/** 这一单还有哪几张卡在等人交回(不给 = 一张都没有 —— 老测试照跑) */
|
||||||
|
unanswered?: ReadonlyArray<{ id: string; title: string }>;
|
||||||
} = {},
|
} = {},
|
||||||
): Harness {
|
): Harness {
|
||||||
const issued: Harness['issued'] = [];
|
const issued: Harness['issued'] = [];
|
||||||
@@ -157,6 +159,11 @@ function harness(
|
|||||||
issued.push({ workOrder: 'elicitation', card: { ask: input.ask, fields: input.fields.map((f) => f.key) } });
|
issued.push({ workOrder: 'elicitation', card: { ask: input.ask, fields: input.fields.map((f) => f.key) } });
|
||||||
return { id: `card-${issued.length}` };
|
return { id: `card-${issued.length}` };
|
||||||
},
|
},
|
||||||
|
decide: async (input: { ask: string; itemId?: string; variant?: string; checklist?: ReadonlyArray<string> }) => {
|
||||||
|
issued.push({ workOrder: 'decision', card: { ask: input.ask, itemId: input.itemId, variant: input.variant, checklist: input.checklist } });
|
||||||
|
return { id: `card-${issued.length}` };
|
||||||
|
},
|
||||||
|
unanswered: async () => over.unanswered ?? [],
|
||||||
} as never,
|
} as never,
|
||||||
listeners: {
|
listeners: {
|
||||||
register: async (input: { eventType: string }) => {
|
register: async (input: { eventType: string }) => {
|
||||||
@@ -1333,6 +1340,96 @@ describe('画面不再静默丢帧', () => {
|
|||||||
expect(h.pushed).toEqual(['step2-view']);
|
expect(h.pushed).toEqual(['step2-view']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('两步同时 Active、这一拍发的是第二步的卡 → 推第二步的画面:件跟着卡走,不跟「第一步没干完的」', async () => {
|
||||||
|
const TWO: CaseModel = {
|
||||||
|
name: '两步',
|
||||||
|
autoComplete: true,
|
||||||
|
items: [
|
||||||
|
{ id: 'i1', name: '数据工单 P-08', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] },
|
||||||
|
{ id: 'i2', name: '数据工单 P-10', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] },
|
||||||
|
],
|
||||||
|
sentries: [],
|
||||||
|
} as never;
|
||||||
|
const h = harness({
|
||||||
|
parsed: TWO,
|
||||||
|
// 模型没收掉第一步(剧本让它先 finish,它没照办)—— 第一步的卡人已答过,第二步刚站上场
|
||||||
|
runtime: { items: [{ id: 'i1', state: 'Active', worldTouched: true }, { id: 'i2', state: 'Active' }] } as never,
|
||||||
|
templates: ['step1-view', 'step2-view'],
|
||||||
|
steps: [
|
||||||
|
{ item: 'i1', intent: '办第一步', guide: '做法', writes: [], reads: [], tools: [], view: 'step1-view' },
|
||||||
|
{ item: 'i2', intent: '办第二步', guide: '做法', writes: [], reads: [], tools: [], view: 'step2-view' },
|
||||||
|
],
|
||||||
|
actions: [{ kind: 'human', itemId: 'i2', workOrder: 'execution', card: { title: '数据工单 · 原厂技术资料创建(P-10)', ask: '请补资料' } }],
|
||||||
|
});
|
||||||
|
await h.runner.runTurn(TASK);
|
||||||
|
expect(h.issued).toHaveLength(1);
|
||||||
|
expect(h.pushed).toEqual(['step2-view']);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **人工激活的项:人答过卡之后照常求 doneWhen**(2026-08-27 晚,用户:「根因修了吗?不要只修表象」)。
|
||||||
|
* W05 两张数据工单实测:卡人答了、数据入库了,模型没 finish 上一步就发了下一张 —— 两步同时 Active,
|
||||||
|
* 件停在上一步。办完了没有是引擎手里现成的事实,不该指望模型记得收。门槛仍是 worldTouched:
|
||||||
|
* 卡没发过的人工激活项,doneWhen 再真也不收 —— 那才是替人签字。
|
||||||
|
*/
|
||||||
|
it('人工激活的项:卡真发过(worldTouched)且 doneWhen 为真 → 拍首引擎自己收,不问模型', async () => {
|
||||||
|
const h = harness({
|
||||||
|
parsed: STAGE_MODEL,
|
||||||
|
runtime: { ...READY_RUNTIME2, items: READY_RUNTIME2.items.map((i) => (i.id === 'GATED' ? { ...i, state: 'Active', worldTouched: true } : i)) } as never,
|
||||||
|
steps: [{ item: 'GATED', intent: '派单', guide: '做法', writes: [], reads: [], tools: [], view: 'x', doneWhen: 'landed_rows > 0 and landed_missing = 0' }],
|
||||||
|
sfeelCondition: () => true,
|
||||||
|
action: { kind: 'judge', criterion: 'x', satisfied: true, rationale: 'y' },
|
||||||
|
});
|
||||||
|
const outcome = await h.runner.runTurn(TASK);
|
||||||
|
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Completed');
|
||||||
|
expect(h.appended.some((e) => e.title.startsWith('自动收步:GATED'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('人工激活的项:卡从没发过 → doneWhen 为真也不收(引擎不替人签字)', async () => {
|
||||||
|
const h = harness({
|
||||||
|
parsed: STAGE_MODEL,
|
||||||
|
runtime: READY_RUNTIME2,
|
||||||
|
steps: [{ item: 'GATED', intent: '派单', guide: '做法', writes: [], reads: [], tools: [], view: 'x', doneWhen: 'landed_rows > 0' }],
|
||||||
|
sfeelCondition: () => true,
|
||||||
|
action: { kind: 'judge', criterion: 'x', satisfied: true, rationale: 'y' },
|
||||||
|
});
|
||||||
|
const outcome = await h.runner.runTurn(TASK);
|
||||||
|
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).not.toBe('Completed');
|
||||||
|
expect(h.appended.some((e) => e.title.startsWith('自动收步:GATED'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('拍尾也扫:这一拍写完使上一步(人答过卡)的 doneWhen 为真、并发了下一步的卡 → 上一步当场收掉,下一步的卡不被误收', async () => {
|
||||||
|
const TWO: CaseModel = {
|
||||||
|
name: '两张数据工单',
|
||||||
|
autoComplete: true,
|
||||||
|
items: [
|
||||||
|
{ id: 'i1', name: 'P-08', type: 'humanTask', manualActivation: true, discretionary: false, entryCriteria: [], exitCriteria: [] },
|
||||||
|
{ id: 'i2', name: 'P-10', type: 'humanTask', manualActivation: true, discretionary: false, entryCriteria: [], exitCriteria: [] },
|
||||||
|
],
|
||||||
|
sentries: [],
|
||||||
|
} as never;
|
||||||
|
const h = harness({
|
||||||
|
parsed: TWO,
|
||||||
|
runtime: { items: [{ id: 'i1', state: 'Active', worldTouched: true }, { id: 'i2', state: 'Available' }] } as never,
|
||||||
|
templates: ['step1-view', 'step2-view'],
|
||||||
|
steps: [
|
||||||
|
{ item: 'i1', intent: '办第一步', guide: '做法', writes: [], reads: [], tools: [], view: 'step1-view', doneWhen: 'record_rows > 0' },
|
||||||
|
{ item: 'i2', intent: '办第二步', guide: '做法', writes: [], reads: [], tools: [], view: 'step2-view', doneWhen: 'record_rows > 0' },
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{ kind: 'write', itemId: 'i1', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 1 } }] },
|
||||||
|
{ kind: 'human', itemId: 'i2', workOrder: 'execution', card: { title: '数据工单 · 原厂技术资料创建(P-10)', ask: '请补资料' } },
|
||||||
|
],
|
||||||
|
viewFn: (written) => ({ record_rows: written.length }),
|
||||||
|
sfeelCondition: (_expr, scope) => Number(scope['record_rows'] ?? 0) > 0,
|
||||||
|
});
|
||||||
|
const outcome = await h.runner.runTurn(TASK);
|
||||||
|
expect(outcome.runtime.items.find((i) => i.id === 'i1')?.state).toBe('Completed');
|
||||||
|
// 这一拍刚发卡的那一步:doneWhen 同样为真,但卡还没人答 —— 不许收
|
||||||
|
expect(outcome.runtime.items.find((i) => i.id === 'i2')?.state).toBe('Active');
|
||||||
|
expect(h.pushed).toEqual(['step2-view']);
|
||||||
|
});
|
||||||
|
|
||||||
it('活步都收完 → 一帧都不推,最后摆上去的那一张(放行页)留在档案里', async () => {
|
it('活步都收完 → 一帧都不推,最后摆上去的那一张(放行页)留在档案里', async () => {
|
||||||
const h = harness({
|
const h = harness({
|
||||||
parsed: OPEN_MODEL,
|
parsed: OPEN_MODEL,
|
||||||
@@ -1729,7 +1826,16 @@ describe('「这张卡人已经答过了」摆成每拍都在的事实行', () =
|
|||||||
const line = answeredCardFact([sent('请审核结论'), { kind: 'card-reply', title: '请审核结论', detail: { value: 'approve' } }]);
|
const line = answeredCardFact([sent('请审核结论'), { kind: 'card-reply', title: '请审核结论', detail: { value: 'approve' } }]);
|
||||||
expect(line).toContain('人已经答过了');
|
expect(line).toContain('人已经答过了');
|
||||||
expect(line).toContain('他选的是「approve」');
|
expect(line).toContain('他选的是「approve」');
|
||||||
expect(line).toContain('finish');
|
expect(line).toContain('通过才收步');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('**custom: 的答复不是通过**(2026-08-27 W05:人两次答「重做一次」,旧话术「把结论落进台账再 finish」把模型推去写了通过旗,一路推到放行)', () => {
|
||||||
|
const line = answeredCardFact([sent('请审核结论'), { kind: 'card-reply', title: '请审核结论', detail: { value: 'custom:重做一次' } }]);
|
||||||
|
expect(line).toContain('写了自己的话:「重做一次」');
|
||||||
|
expect(line).toContain('这不是通过');
|
||||||
|
expect(line).toContain('换一种问法');
|
||||||
|
expect(line).toContain('不许 finish');
|
||||||
|
expect(line).not.toContain('把结论落进台账');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('执行工单没有值 → 说清「空答复 = 照原样确认」,不编一个选择出来', () => {
|
it('执行工单没有值 → 说清「空答复 = 照原样确认」,不编一个选择出来', () => {
|
||||||
@@ -1877,3 +1983,129 @@ describe('worldTouched 要落进保存的局面', () => {
|
|||||||
expect(gated?.worldTouched, '人答回来之后,发过卡这件事被抹掉了').toBe(true);
|
expect(gated?.worldTouched, '人答回来之后,发过卡这件事被抹掉了').toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 审核卡的面孔由引擎按剧本定(2026-08-27 用户:「这个验收卡又变成需要你定了」):
|
||||||
|
* 从前组装层只按行动里的 itemId 查剧本的 `cardVariant`,模型漏了 itemId(或给的项挂不上)
|
||||||
|
* 卡就退成「需要你定」。现在引擎把面孔递过去:点名的项优先,点不到就按当下在办的那一步。
|
||||||
|
*/
|
||||||
|
describe('决策卡的面孔按剧本定,不靠模型给对 itemId', () => {
|
||||||
|
const decide = (itemId?: string) => ({
|
||||||
|
kind: 'decide',
|
||||||
|
...(itemId === undefined ? {} : { itemId }),
|
||||||
|
ask: '请审核',
|
||||||
|
options: [
|
||||||
|
{ value: 'approve', label: '通过' },
|
||||||
|
{ value: 'reject', label: '驳回' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const reviewStep = {
|
||||||
|
item: 'FREE',
|
||||||
|
intent: '审核',
|
||||||
|
guide: '做法',
|
||||||
|
writes: [],
|
||||||
|
reads: [],
|
||||||
|
tools: [],
|
||||||
|
view: 'x',
|
||||||
|
cardVariant: 'review',
|
||||||
|
acceptanceChecklist: ['核对甲', '核对乙'],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('模型把卡挂到了别的项上(那一步没声明面孔):按当下在办的那一步 —— 剧本说 review 就是验收卡', async () => {
|
||||||
|
const plainStep = { item: 'GATED', intent: '别的活', guide: '做法', writes: [], reads: [], tools: [], view: 'y' };
|
||||||
|
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: decide('GATED'), steps: [reviewStep, plainStep] });
|
||||||
|
const out = await h.runner.runTurn(TASK);
|
||||||
|
const card = h.issued.find((c) => c.workOrder === 'decision');
|
||||||
|
expect(card, JSON.stringify({ status: out.status, appended: h.appended.slice(-3) })).toBeDefined();
|
||||||
|
expect(card?.card['variant']).toBe('review');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('模型点名了那一项:照它查;这一步剧本没声明 cardVariant 就不给面孔(普通决策卡)', async () => {
|
||||||
|
const named = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: decide('FREE'), steps: [reviewStep] });
|
||||||
|
await named.runner.runTurn(TASK);
|
||||||
|
expect(named.issued.find((c) => c.workOrder === 'decision')?.card['variant']).toBe('review');
|
||||||
|
// 人工验收清单随面孔一起按剧本带上卡
|
||||||
|
expect(named.issued.find((c) => c.workOrder === 'decision')?.card['checklist']).toEqual(['核对甲', '核对乙']);
|
||||||
|
|
||||||
|
const plain = harness({
|
||||||
|
parsed: STAGE_MODEL,
|
||||||
|
runtime: READY_RUNTIME2,
|
||||||
|
action: decide('FREE'),
|
||||||
|
steps: [{ ...reviewStep, cardVariant: undefined }],
|
||||||
|
});
|
||||||
|
await plain.runner.runTurn(TASK);
|
||||||
|
expect(plain.issued.find((c) => c.workOrder === 'decision')?.card['variant']).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 叫了人就等人(2026-08-27,用户:「你叫了人我都没填你就直接推进了,不能再有叫了人又继续自动推进的问题」):
|
||||||
|
* 有卡没交回,这一拍不问模型 —— 迟到的回传事件、另一台后端的推进器、手动 wake 都推不动它。
|
||||||
|
*/
|
||||||
|
describe('有卡等人交回:这一拍不问模型', () => {
|
||||||
|
const finishFree = { kind: 'finish', itemId: 'FREE', rationale: '写齐了' };
|
||||||
|
it('卡没答:不跑模型、不写、不发新卡,留一条内部痕,状态仍是等人', async () => {
|
||||||
|
const h = harness({
|
||||||
|
parsed: STAGE_MODEL,
|
||||||
|
runtime: READY_RUNTIME2,
|
||||||
|
action: finishFree,
|
||||||
|
unanswered: [{ id: 'wo-1', title: '评估输入与资料补齐确认' }],
|
||||||
|
});
|
||||||
|
const out = await h.runner.runTurn(TASK);
|
||||||
|
expect(out.status).toBe('awaiting');
|
||||||
|
expect(h.decided).toEqual([]);
|
||||||
|
expect(h.written).toEqual([]);
|
||||||
|
expect(h.issued).toEqual([]);
|
||||||
|
expect(out.runtime.items.find((i) => i.id === 'FREE')?.state).not.toBe('Completed');
|
||||||
|
expect(h.appended.some((e) => e.kind === 'observation' && e.title.includes('等人交回:评估输入与资料补齐确认'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('卡答完了(没有在等的卡):照常问模型', async () => {
|
||||||
|
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: finishFree, unanswered: [] });
|
||||||
|
const out = await h.runner.runTurn(TASK);
|
||||||
|
expect(out.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Completed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 放行那一步不发工单(2026-08-27 W05 实测):模型在 T_release 先发了一张执行工单「评审放行与落账确认」,
|
||||||
|
* 人答完它才 submit,验收卡又来一张 —— 同一件事问人两遍。认法:剧本给它声明了人工验收清单且不是审核卡那一步。
|
||||||
|
*/
|
||||||
|
describe('终局放行那一步:human 被拒并指向 submit', () => {
|
||||||
|
const releaseStep = {
|
||||||
|
item: 'FREE',
|
||||||
|
intent: '提交放行',
|
||||||
|
guide: '做法',
|
||||||
|
writes: [],
|
||||||
|
reads: [],
|
||||||
|
tools: [],
|
||||||
|
view: 'x',
|
||||||
|
acceptanceChecklist: ['核对甲', '核对乙'],
|
||||||
|
};
|
||||||
|
const humanCard = {
|
||||||
|
kind: 'human',
|
||||||
|
itemId: 'FREE',
|
||||||
|
workOrder: 'execution',
|
||||||
|
card: { title: '评审放行与落账确认', ask: '请确认', basis: [{ k: '来源', v: '收口' }] },
|
||||||
|
};
|
||||||
|
|
||||||
|
it('对放行步发执行工单:行动被拒,拒回点名 submit;一张卡都不发', async () => {
|
||||||
|
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: humanCard, steps: [releaseStep] });
|
||||||
|
await h.runner.runTurn(TASK);
|
||||||
|
expect(h.issued).toEqual([]);
|
||||||
|
const rejected = h.appended.find((e) => e.kind === 'result' && e.title.startsWith('行动被拒'));
|
||||||
|
expect(rejected?.title).toContain('submit');
|
||||||
|
expect(rejected?.title).toContain('问人两遍');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('审核卡那一步(cardVariant review)不算放行步 —— 照旧能发卡', async () => {
|
||||||
|
const h = harness({
|
||||||
|
parsed: STAGE_MODEL,
|
||||||
|
runtime: READY_RUNTIME2,
|
||||||
|
action: humanCard,
|
||||||
|
steps: [{ ...releaseStep, cardVariant: 'review' }],
|
||||||
|
});
|
||||||
|
await h.runner.runTurn(TASK);
|
||||||
|
expect(h.issued.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -43,6 +43,23 @@ export function phaseProgress(
|
|||||||
if (terminal) return { index: total, total, name: CLOSING_PHASE };
|
if (terminal) return { index: total, total, name: CLOSING_PHASE };
|
||||||
|
|
||||||
const settled = new Set(items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id));
|
const settled = new Set(items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id));
|
||||||
|
/*
|
||||||
|
* **被跳过的项不许把进度条钉死**(2026-08-27 用户实测:都在库时数据工单两项一直是 Available,
|
||||||
|
* 从前「一段里的项全收完才算过」让条永远停在 1/6,而单子早已办到审核了)。
|
||||||
|
* 现在先看「案子走到了哪一段」—— 最后一个有项**动过**(Active / Completed / Terminated)的阶段;
|
||||||
|
* 那一段全收完了就算过、进下一段,没收完就停在那一段。一段都没动过才退回老规矩(第一段)。
|
||||||
|
*/
|
||||||
|
const touched = new Set(
|
||||||
|
items.filter((i) => i.state === 'Active' || i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id),
|
||||||
|
);
|
||||||
|
let last = -1;
|
||||||
|
for (const [at, phase] of phases.entries()) if (phase.items.some((id) => touched.has(id))) last = at;
|
||||||
|
if (last >= 0) {
|
||||||
|
const passed = phases[last]!.items.every((id) => settled.has(id));
|
||||||
|
const current = passed ? last + 1 : last;
|
||||||
|
const phase = phases[current];
|
||||||
|
return phase === undefined ? { index: total, total, name: CLOSING_PHASE } : { index: current + 1, total, name: phase.name };
|
||||||
|
}
|
||||||
for (const [at, phase] of phases.entries()) {
|
for (const [at, phase] of phases.entries()) {
|
||||||
if (!phase.items.every((id) => settled.has(id))) return { index: at + 1, total, name: phase.name };
|
if (!phase.items.every((id) => settled.has(id))) return { index: at + 1, total, name: phase.name };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -330,7 +330,28 @@ export function answeredCardFact(
|
|||||||
const head = `【这张卡人已经答过了】「${reply.title.length <= 60 ? reply.title : `${reply.title.slice(0, 60)}…`}」`;
|
const head = `【这张卡人已经答过了】「${reply.title.length <= 60 ? reply.title : `${reply.title.slice(0, 60)}…`}」`;
|
||||||
return chose === undefined
|
return chose === undefined
|
||||||
? `${head} → 人已交回(**空答复 = 照原样确认**,不是没答)。别再发一张一样的卡 —— 照说明书里「人交回之后」那一段办,办完 finish 这一项。`
|
? `${head} → 人已交回(**空答复 = 照原样确认**,不是没答)。别再发一张一样的卡 —— 照说明书里「人交回之后」那一段办,办完 finish 这一项。`
|
||||||
: `${head} → **他选的是「${chose}」**。别再发一张一样的卡 —— 照说明书处理这个选择(通常是把结论落进台账的那一面记账旗,再 finish 这一项)。`;
|
: `${head} → ${chosenGuidance(chose)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **人选了什么,下一步就该怎么走** —— 拒回、观察、事实行三处同一句话,按答案分岔。
|
||||||
|
*
|
||||||
|
* 从前不分岔,一律说「照这个选择把结论落进台账那面旗,再 finish」—— 对「通过」是对的,
|
||||||
|
* 对「重做」是把模型往错路上推:2026-08-27 W05 实测,人两次答 `custom:重做一次`,模型照这句
|
||||||
|
* 写了 review_approved_total = 1,审核那一步就被收掉、一路推到放行。
|
||||||
|
* `custom:` 是平台定义的自由答复,平台知道它**不是任何一个选项**;选项值的语义平台不知道,只能指回说明书。
|
||||||
|
*/
|
||||||
|
export function chosenGuidance(chose: string | undefined): string {
|
||||||
|
if (chose === undefined) return '他选了什么就在你的轨迹里(card-reply 那一条,detail 带着选中的值),照说明书里那一档办。';
|
||||||
|
if (chose.startsWith('custom:')) {
|
||||||
|
const said = chose.slice(7).trim();
|
||||||
|
return (
|
||||||
|
`**他没选任何一个选项,写了自己的话:「${said}」** —— 这不是通过。按他写的去改台账(同 PK 覆盖写),` +
|
||||||
|
'改完**换一种问法**再发一张新卡(标题里写明这一轮改了什么;字面一样的卡会被当成同一张吞掉);' +
|
||||||
|
'**不许写「已通过」那类记账旗、不许 finish 这一项**。'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return `**他选的是「${chose}」** —— 照说明书里「${chose}」那一档办(通过才收步;驳回就改台账、换问法重发)。`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -366,6 +387,15 @@ export function handsOff(actions: ReadonlyArray<{ kind: string }>): boolean {
|
|||||||
|
|
||||||
const HANDOFF_KINDS = new Set(['human', 'decide', 'listen', 'submit']);
|
const HANDOFF_KINDS = new Set(['human', 'decide', 'listen', 'submit']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **这一步是不是终局放行那一步**(2026-08-27,W05 实测:模型在 T_release 先发了一张执行工单
|
||||||
|
* 「评审放行与落账确认」,人答完它才 submit —— 同一件事问了人两遍)。
|
||||||
|
* 认法:剧本给它声明了人工验收清单、又不是审核卡那一步 —— 放行卡由 `submit` 发,不该再有别的卡。
|
||||||
|
*/
|
||||||
|
export function isReleaseStep(step: { acceptanceChecklist?: ReadonlyArray<string> | undefined; cardVariant?: string | undefined } | undefined): boolean {
|
||||||
|
return step !== undefined && step.acceptanceChecklist !== undefined && step.acceptanceChecklist.length > 0 && step.cardVariant !== 'review';
|
||||||
|
}
|
||||||
|
|
||||||
export function sameGist(a: string, b: string): boolean {
|
export function sameGist(a: string, b: string): boolean {
|
||||||
const norm = (s: string): string => s.replace(/[\p{P}\p{S}\s]/gu, '');
|
const norm = (s: string): string => s.replace(/[\p{P}\p{S}\s]/gu, '');
|
||||||
const x = norm(a);
|
const x = norm(a);
|
||||||
@@ -622,6 +652,11 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
* 熔断检测(stalledTurns 数「行动被拒」)靠它兜底,纯空转的单不能悄无声息。
|
* 熔断检测(stalledTurns 数「行动被拒」)靠它兜底,纯空转的单不能悄无声息。
|
||||||
*/
|
*/
|
||||||
hasFollowUp = false,
|
hasFollowUp = false,
|
||||||
|
/**
|
||||||
|
* **这一步派的卡穿哪副面孔**(剧本 `cardVariant`,2026-08-27):按行动里的项号查,项号没给 / 挂不上就按
|
||||||
|
* 当下在办的那一步 —— 从前只按项号查,模型漏了 itemId,审核卡就退成「需要你定」。
|
||||||
|
*/
|
||||||
|
faceOf?: (itemId: string | undefined) => { variant?: 'review'; checklist?: ReadonlyArray<string>; release?: true } | undefined,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
runtime: CaseRuntime;
|
runtime: CaseRuntime;
|
||||||
status: TurnOutcome['status'];
|
status: TurnOutcome['status'];
|
||||||
@@ -629,6 +664,8 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
saved?: true;
|
saved?: true;
|
||||||
/** 这个行动自己已经把画面摆好了 —— 回合末别再盖一帧上去 */
|
/** 这个行动自己已经把画面摆好了 —— 回合末别再盖一帧上去 */
|
||||||
framed?: true;
|
framed?: true;
|
||||||
|
/** 这个行动发了一张卡给人,卡是哪一步的 —— 回合末的画面跟着它走(见 runTurn 推帧那段) */
|
||||||
|
issuedFor?: string;
|
||||||
}> {
|
}> {
|
||||||
const context = await deps.tasks.of(taskId);
|
const context = await deps.tasks.of(taskId);
|
||||||
// 项号先归一(简写认得出),再往下走 —— 一处归一,所有行动型都受用
|
// 项号先归一(简写认得出),再往下走 —— 一处归一,所有行动型都受用
|
||||||
@@ -1097,6 +1134,17 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
return { runtime, status: 'running' };
|
return { runtime, status: 'running' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
* **放行那一步不发工单**(2026-08-27 W05 实测):模型在 T_release 发了一张执行工单
|
||||||
|
* 「评审放行与落账确认」,人答完它才 submit,验收卡又来一张 —— 同一件事问人两遍。
|
||||||
|
* 放行卡由 submit 发(基座预检后发验收卡),这一步没有别的卡可发;拒回把路指清。
|
||||||
|
*/
|
||||||
|
if (faceOf?.(action.itemId)?.release === true) {
|
||||||
|
throw new Error(
|
||||||
|
'这一步是终局放行:放行卡由 submit 发(基座预检后自己发验收卡),不发执行工单 / 数据工单 —— ' +
|
||||||
|
'发了等于把同一件事问人两遍(先一张执行单、再一张验收单)。这一拍改成:judge 该裁的裁量判据,然后 submit。',
|
||||||
|
);
|
||||||
|
}
|
||||||
const { parsed: cardModel } = await loaded(taskId);
|
const { parsed: cardModel } = await loaded(taskId);
|
||||||
/*
|
/*
|
||||||
* **发卡的授权面是调人预算,不是计划项**(2026-08-23,E04 回归实测撞出来的):
|
* **发卡的授权面是调人预算,不是计划项**(2026-08-23,E04 回归实测撞出来的):
|
||||||
@@ -1126,6 +1174,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
taskId,
|
taskId,
|
||||||
ownerId: context.ownerId,
|
ownerId: context.ownerId,
|
||||||
humanCallCap: context.humanCallCap,
|
humanCallCap: context.humanCallCap,
|
||||||
|
itemId: action.itemId,
|
||||||
workOrder: action.workOrder,
|
workOrder: action.workOrder,
|
||||||
card: action.card,
|
card: action.card,
|
||||||
});
|
});
|
||||||
@@ -1139,6 +1188,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
return {
|
return {
|
||||||
runtime: cardUnattached === undefined ? markWorldTouched(advanced, action.itemId) : advanced,
|
runtime: cardUnattached === undefined ? markWorldTouched(advanced, action.itemId) : advanced,
|
||||||
status: 'awaiting',
|
status: 'awaiting',
|
||||||
|
issuedFor: action.itemId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1182,7 +1232,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
keyFilter: { cardId: entryCard.id },
|
keyFilter: { cardId: entryCard.id },
|
||||||
deadline: 'P3D',
|
deadline: 'P3D',
|
||||||
});
|
});
|
||||||
return { runtime: markWorldTouched(entryReady, action.itemId), status: 'awaiting' };
|
return { runtime: markWorldTouched(entryReady, action.itemId), status: 'awaiting', issuedFor: action.itemId };
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'decide': {
|
case 'decide': {
|
||||||
@@ -1210,11 +1260,8 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
if (verdict.kind === 'reject') {
|
if (verdict.kind === 'reject') {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`这个岔路口人已经选过了(「${verdict.answered}」)—— 别再发一张一样的决策卡。` +
|
`这个岔路口人已经选过了(「${verdict.answered}」)—— 别再发一张一样的决策卡。` +
|
||||||
(verdict.chose === undefined
|
chosenGuidance(verdict.chose) +
|
||||||
? '他选了什么就在你的轨迹里(card-reply 那一条,detail 带着选中的值):'
|
'真有**另一个**岔路口要人定,换一件事、换一个问法。',
|
||||||
: `**他选的是「${verdict.chose}」** —— `) +
|
|
||||||
'照这个选择往下办:照说明书把结论落进台账(通常是那一面记账旗),' +
|
|
||||||
'再 finish 这一项。真有**另一个**岔路口要人定,换一件事、换一个问法。',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await deps.memory.append({
|
await deps.memory.append({
|
||||||
@@ -1223,18 +1270,19 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
title: echoTitle,
|
title: echoTitle,
|
||||||
detail: {
|
detail: {
|
||||||
internal: true,
|
internal: true,
|
||||||
note:
|
note: '人已经在这个岔路口选过了。别再发同一张 —— ' + chosenGuidance(verdict.chose),
|
||||||
verdict.chose === undefined
|
|
||||||
? '人已经在这个岔路口选过了(选中的值在 card-reply 那一条)。别再发 —— 照他的选择接着办,办完把这一步 finish 掉。'
|
|
||||||
: `人已经在这个岔路口选过了,他选的是「${verdict.chose}」。别再发 —— 照这个选择把结论落进台账,再 finish 这一项。`,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// 什么也没做,局面原样交回
|
// 什么也没做,局面原样交回
|
||||||
return { runtime, status: 'running' };
|
return { runtime, status: 'running' };
|
||||||
}
|
}
|
||||||
const advanced = readyToWork((await loaded(taskId)).parsed, runtime, action.itemId);
|
const advanced = readyToWork((await loaded(taskId)).parsed, runtime, action.itemId);
|
||||||
|
const face = faceOf?.(action.itemId);
|
||||||
const card = await deps.cards.decide({
|
const card = await deps.cards.decide({
|
||||||
taskId,
|
taskId,
|
||||||
|
itemId: action.itemId,
|
||||||
|
...(face?.variant === undefined ? {} : { variant: face.variant }),
|
||||||
|
...(face?.checklist === undefined ? {} : { checklist: face.checklist }),
|
||||||
ownerId: context.ownerId,
|
ownerId: context.ownerId,
|
||||||
humanCallCap: context.humanCallCap,
|
humanCallCap: context.humanCallCap,
|
||||||
ask: action.ask,
|
ask: action.ask,
|
||||||
@@ -1248,7 +1296,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
keyFilter: { cardId: card.id },
|
keyFilter: { cardId: card.id },
|
||||||
deadline: 'P3D',
|
deadline: 'P3D',
|
||||||
});
|
});
|
||||||
return { runtime: markWorldTouched(advanced, action.itemId), status: 'awaiting' };
|
return { runtime: markWorldTouched(advanced, action.itemId), status: 'awaiting', issuedFor: action.itemId };
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'listen': {
|
case 'listen': {
|
||||||
@@ -1497,13 +1545,20 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
detail: { internal: true },
|
detail: { internal: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
* 验收卡的题目用放行那一步在案例模型里的名字(如「评审放行与落账确认(ACT-GATE-03)」)——
|
||||||
|
* 「收口验收」是没声明时的兜底,人看见的应该是活动自己给这一步起的名。
|
||||||
|
*/
|
||||||
|
const { model: releaseModel, parsed: releaseParsed } = await loaded(taskId);
|
||||||
|
const gateStep = releaseModel.steps?.find((st) => isReleaseStep(st)) ?? releaseStep;
|
||||||
|
const releaseName = gateStep === undefined ? undefined : releaseParsed.items.find((i) => i.id === gateStep.item)?.name;
|
||||||
const card = await deps.cards.issue({
|
const card = await deps.cards.issue({
|
||||||
taskId,
|
taskId,
|
||||||
ownerId: context.ownerId,
|
ownerId: context.ownerId,
|
||||||
humanCallCap: context.humanCallCap,
|
humanCallCap: context.humanCallCap,
|
||||||
workOrder: 'acceptance',
|
workOrder: 'acceptance',
|
||||||
card: {
|
card: {
|
||||||
title: '收口验收',
|
title: releaseName === undefined || releaseName.trim() === '' ? '收口验收' : releaseName,
|
||||||
basis: [{ k: '提交理由', v: rationale }],
|
basis: [{ k: '提交理由', v: rationale }],
|
||||||
...(fields === undefined || fields.length === 0 ? {} : { fields: fields.map((f) => ({ ...f })) }),
|
...(fields === undefined || fields.length === 0 ? {} : { fields: fields.map((f) => ({ ...f })) }),
|
||||||
},
|
},
|
||||||
@@ -1593,6 +1648,26 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **叫了人就等人**(2026-08-27,用户:「你叫了人我都没填你就直接推进了」)。
|
||||||
|
*
|
||||||
|
* 这一单还有卡没交回,这一拍就不问模型 —— 不管是谁叫醒的:迟到的回传事件、另一台后端的
|
||||||
|
* 推进器、手动 wake。从前每一次唤醒都跑一拍,而事实行对模型说「这一步已经发过卡,人已经被
|
||||||
|
* 问过了」,它就照「人交回之后」那一段把步骤收掉 —— 没人答的卡就这样被跳过(实测:S1 的卡
|
||||||
|
* 发出 0.3 秒后,上一张卡回传的事件才投到,再一拍就「完成:评估输入与资料补齐确认」)。
|
||||||
|
* 在途验收(上面那段)先于它:验收裁决本身就是那张卡的回传。
|
||||||
|
*/
|
||||||
|
const waiting = await deps.cards.unanswered(taskId);
|
||||||
|
if (waiting.length > 0) {
|
||||||
|
await deps.memory.append({
|
||||||
|
taskId,
|
||||||
|
kind: 'observation',
|
||||||
|
title: `等人交回:${waiting.map((c) => c.title).join('、')} —— 这一拍不动`,
|
||||||
|
detail: { internal: true, cards: waiting.map((c) => c.id) },
|
||||||
|
});
|
||||||
|
return { runtime, enabled, navigation: { kind: 'converged' }, status: 'awaiting' };
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 齐了即收:必做项完成且无进行中项 → 提交放行(发验收卡,终局唯放行)。
|
* 齐了即收:必做项完成且无进行中项 → 提交放行(发验收卡,终局唯放行)。
|
||||||
*
|
*
|
||||||
@@ -1755,22 +1830,37 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
* 只收 Active/Available 的项;已经收了的不重复收。收完重算 enabled(收掉一项
|
* 只收 Active/Available 的项;已经收了的不重复收。收完重算 enabled(收掉一项
|
||||||
* 可能让下一项的前提成立),循环到收不动为止 —— 封顶 8 轮,防剧本写出环。
|
* 可能让下一项的前提成立),循环到收不动为止 —— 封顶 8 轮,防剧本写出环。
|
||||||
*/
|
*/
|
||||||
let stateRuntime = runtime;
|
/**
|
||||||
let stateEnabled = enabled;
|
* 在场的项 = 当下可为 ∪ 进行中 —— 两摞都要看:
|
||||||
if (steps.length > 0) {
|
* 成果写完但还没 finish 的那一项正躺在「进行中」里,它才是最该被自动收的。
|
||||||
|
*
|
||||||
|
* **人工激活的项:人答过卡之后照常求 doneWhen**(2026-08-27 晚,用户:「根因修了吗?不要只修表象」)。
|
||||||
|
* 从前一律不收 —— 怕引擎替人签字(T_release 触达世界,收它得人按一下)。可 W05 的两张数据工单实测:
|
||||||
|
* 卡人答了、数据也入库了,「这一步办完了没有」是引擎手里现成的事实,却让模型去记得 finish ——
|
||||||
|
* 剧本两处白纸黑字「先 finish 再发下一张」,它照样忘,两步同时 Active、件停在上一步、人无处可填。
|
||||||
|
* 设计律:**指南让模型判的事,引擎有现成事实的,一律改成读事实。**
|
||||||
|
* 门槛仍在:`worldTouched`(卡真发过)—— 卡没发过的人工激活项 doneWhen 再真也不收,那才是替人签字;
|
||||||
|
* 走到这儿时没有未答的卡(前面 `cards.unanswered` 把住),拍尾那一遍另把这一拍刚发卡的项排除。
|
||||||
|
* 没声明 doneWhen 的人工激活项(T_release / 审核循环 / 交人补缺口)一个字不变。
|
||||||
|
*/
|
||||||
|
const sweepRipe = async (
|
||||||
|
runtimeIn: CaseRuntime,
|
||||||
|
enabledIn: ReturnType<typeof publishEnabled>,
|
||||||
|
scopeIn: typeof scope,
|
||||||
|
exclude?: string,
|
||||||
|
): Promise<{ runtime: CaseRuntime; enabled: ReturnType<typeof publishEnabled> }> => {
|
||||||
|
let runtimeNow = runtimeIn;
|
||||||
|
let enabledNow = enabledIn;
|
||||||
|
if (steps.length === 0) return { runtime: runtimeNow, enabled: enabledNow };
|
||||||
for (let round = 0; round < 8; round += 1) {
|
for (let round = 0; round < 8; round += 1) {
|
||||||
/*
|
const onStage = [...enabledNow.entries, ...enabledNow.inProgress];
|
||||||
* 在场的项 = 当下可为 ∪ 进行中 —— 两摞都要看:
|
|
||||||
* 成果写完但还没 finish 的那一项正躺在「进行中」里,它才是最该被自动收的。
|
|
||||||
* **人工激活的项不自动收**(T_release 触达世界,收它得人按一下)。
|
|
||||||
*/
|
|
||||||
const onStage = [...stateEnabled.entries, ...stateEnabled.inProgress];
|
|
||||||
const ripe = onStage.find((e) => {
|
const ripe = onStage.find((e) => {
|
||||||
if (e.requiresManualStart) return false;
|
if (e.id === exclude) return false;
|
||||||
|
if (e.requiresManualStart && runtimeNow.items.find((i) => i.id === e.id)?.worldTouched !== true) return false;
|
||||||
const step = stepOf(e.id);
|
const step = stepOf(e.id);
|
||||||
if (step?.doneWhen === undefined || step.doneWhen.trim() === '') return false;
|
if (step?.doneWhen === undefined || step.doneWhen.trim() === '') return false;
|
||||||
try {
|
try {
|
||||||
return deps.sfeel.condition(step.doneWhen, scope);
|
return deps.sfeel.condition(step.doneWhen, scopeIn);
|
||||||
} catch {
|
} catch {
|
||||||
// 谓词写坏了不该拖垮整拍:当作「没算出来」,照旧交给模型
|
// 谓词写坏了不该拖垮整拍:当作「没算出来」,照旧交给模型
|
||||||
return false;
|
return false;
|
||||||
@@ -1779,8 +1869,8 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
if (ripe === undefined) break;
|
if (ripe === undefined) break;
|
||||||
const step = stepOf(ripe.id);
|
const step = stepOf(ripe.id);
|
||||||
try {
|
try {
|
||||||
const active = readyToWork(parsed, stateRuntime, ripe.id);
|
const active = readyToWork(parsed, runtimeNow, ripe.id);
|
||||||
stateRuntime = transitionItem(active, ripe.id, 'Completed');
|
runtimeNow = transitionItem(active, ripe.id, 'Completed');
|
||||||
} catch {
|
} catch {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1790,11 +1880,13 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
title: `自动收步:${ripe.id} 的成果已在台账里(${step?.doneWhen ?? ''})—— 不必再问模型`,
|
title: `自动收步:${ripe.id} 的成果已在台账里(${step?.doneWhen ?? ''})—— 不必再问模型`,
|
||||||
detail: { internal: true, item: ripe.id, doneWhen: step?.doneWhen },
|
detail: { internal: true, item: ripe.id, doneWhen: step?.doneWhen },
|
||||||
});
|
});
|
||||||
stateEnabled = publishEnabled(parsed, stateRuntime, scope, changedRefs(scope), deps.sfeel.condition, [
|
enabledNow = publishEnabled(parsed, runtimeNow, scopeIn, changedRefs(scopeIn), deps.sfeel.condition, [...enabledNow.gaps]);
|
||||||
...stateEnabled.gaps,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
return { runtime: runtimeNow, enabled: enabledNow };
|
||||||
|
};
|
||||||
|
const swept = await sweepRipe(runtime, enabled, scope);
|
||||||
|
const stateRuntime = swept.runtime;
|
||||||
|
const stateEnabled = swept.enabled;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 当前这一步 = 当下可为里**第一个有剧本的项**(CMMN 的项序即业务次序)。
|
* 当前这一步 = 当下可为里**第一个有剧本的项**(CMMN 的项序即业务次序)。
|
||||||
@@ -1833,6 +1925,17 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
const declared = parsed.items.find((i) => i.id === current.item);
|
const declared = parsed.items.find((i) => i.id === current.item);
|
||||||
if (declared?.manualActivation !== true) return '';
|
if (declared?.manualActivation !== true) return '';
|
||||||
const touched = stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true;
|
const touched = stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true;
|
||||||
|
/*
|
||||||
|
* 终局放行那一步的事实行**只指向 submit**(2026-08-27 W05 实测):下面那句「唯一合法的动作是先发
|
||||||
|
* 那一张卡」本是给人工激活的执行 / 数据 / 决策步写的,放行步读到它就去发了一张执行工单。
|
||||||
|
*/
|
||||||
|
if (isReleaseStep(current)) {
|
||||||
|
return touched
|
||||||
|
? '这一步是终局放行:验收卡已经发出去了,人签字之前不要再动台账、不要再发任何卡;被打回重做照剧本改完再 submit。'
|
||||||
|
: '这一步是终局放行(触达世界):**唯一合法的动作是 submit(提交放行)** —— 基座预检后自己发验收卡。' +
|
||||||
|
'不要发执行工单 / 数据工单 / 决策卡,也不要立监听(发了会被拒,而且等于把同一件事问人两遍)。' +
|
||||||
|
'先 judge 该裁的裁量判据,再 submit,同一拍连做。';
|
||||||
|
}
|
||||||
return touched
|
return touched
|
||||||
? '这一步标了人工激活(触达世界):这一单里已经真的给它发过卡或立过监听——人已经被问过了,' +
|
? '这一步标了人工激活(触达世界):这一单里已经真的给它发过卡或立过监听——人已经被问过了,' +
|
||||||
'照下面「人交回之后」那一段办。'
|
'照下面「人交回之后」那一段办。'
|
||||||
@@ -2041,6 +2144,8 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
let saved = false;
|
let saved = false;
|
||||||
let framed = false;
|
let framed = false;
|
||||||
let lastAction: AgentAction | undefined;
|
let lastAction: AgentAction | undefined;
|
||||||
|
/** 这一拍发出去的卡是哪一步的 —— 画面跟卡走 */
|
||||||
|
let issuedFor: string | undefined;
|
||||||
/*
|
/*
|
||||||
* **finish 的 doneWhen 要按「此刻」的草稿算,不是拍首快照**(2026-08-22,E04 实测):
|
* **finish 的 doneWhen 要按「此刻」的草稿算,不是拍首快照**(2026-08-22,E04 实测):
|
||||||
* 「同一串先写后 finish 一拍连做」是提示词鼓励的省拍打法,而 doneWhen 若按拍首的
|
* 「同一串先写后 finish 一拍连做」是提示词鼓励的省拍打法,而 doneWhen 若按拍首的
|
||||||
@@ -2060,6 +2165,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
saved?: true;
|
saved?: true;
|
||||||
framed?: true;
|
framed?: true;
|
||||||
|
issuedFor?: string;
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
// AI 不得产生终局迁移 —— start 一个不存在的项按被拒处理,不掀翻整拍
|
// AI 不得产生终局迁移 —— start 一个不存在的项按被拒处理,不掀翻整拍
|
||||||
@@ -2086,6 +2192,20 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
index < planned.length - 1,
|
index < planned.length - 1,
|
||||||
|
// 面孔与人工验收清单按剧本定:行动点名的项声明了就用它的,没声明就按当下在办的那一步
|
||||||
|
(itemId) => {
|
||||||
|
const named = itemId === undefined ? undefined : stepOf(itemId);
|
||||||
|
const step = named?.cardVariant !== undefined || named?.acceptanceChecklist !== undefined ? named : (current ?? named);
|
||||||
|
const variant = step?.cardVariant === 'review' ? ('review' as const) : undefined;
|
||||||
|
const checklist = step?.acceptanceChecklist;
|
||||||
|
const release = isReleaseStep(step);
|
||||||
|
if (variant === undefined && checklist === undefined && !release) return undefined;
|
||||||
|
return {
|
||||||
|
...(variant === undefined ? {} : { variant }),
|
||||||
|
...(checklist === undefined ? {} : { checklist }),
|
||||||
|
...(release ? { release: true as const } : {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
);
|
);
|
||||||
if (action.kind === 'write' || action.kind === 'drop') scopeStale = true;
|
if (action.kind === 'write' || action.kind === 'drop') scopeStale = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2103,6 +2223,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
if (applied.reason !== undefined) blockedReason = applied.reason;
|
if (applied.reason !== undefined) blockedReason = applied.reason;
|
||||||
if (applied.saved === true) saved = true;
|
if (applied.saved === true) saved = true;
|
||||||
if (applied.framed === true) framed = true;
|
if (applied.framed === true) framed = true;
|
||||||
|
if (applied.issuedFor !== undefined) issuedFor = applied.issuedFor;
|
||||||
// 挂起型落地即收拍:卡已发 / 监听已立 / 验收已提 —— 这一拍到此为止
|
// 挂起型落地即收拍:卡已发 / 监听已立 / 验收已提 —— 这一拍到此为止
|
||||||
if (applied.status !== 'running') break;
|
if (applied.status !== 'running') break;
|
||||||
// 工具出参要先看见:同拍余下的行动是闭着眼计划的,作废并留痕
|
// 工具出参要先看见:同拍余下的行动是闭着眼计划的,作废并留痕
|
||||||
@@ -2117,6 +2238,21 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **拍尾再扫一遍**(2026-08-27 晚):人答卡的那一拍里模型写完东西,上一步的 doneWhen 往往这时才真
|
||||||
|
* (值是人在件上填的、旗是这一拍记的);不等下一拍 —— 下一拍可能要等人再答一张卡才来,
|
||||||
|
* 而画面与进度条在这之间都得对(实测:P-08 没收、P-10 的卡发了,人对着 P-08 的件无处可填)。
|
||||||
|
* 提交放行那一拍(saved)不扫:它已连在途验收一起落盘,再存一次会把信封擦掉。
|
||||||
|
* 这一拍刚发卡的项排除(卡还没人答,收它就是替人签字)。
|
||||||
|
*/
|
||||||
|
if (!saved) {
|
||||||
|
if (scopeStale) {
|
||||||
|
liveScope = await deps.data.view(taskId);
|
||||||
|
scopeStale = false;
|
||||||
|
}
|
||||||
|
const enabledTail = publishEnabled(parsed, currentRuntime, liveScope, changedRefs(liveScope), deps.sfeel.condition, [...stateEnabled.gaps]);
|
||||||
|
currentRuntime = (await sweepRipe(currentRuntime, enabledTail, liveScope, issuedFor)).runtime;
|
||||||
|
}
|
||||||
// 已经自己落过笔的行动(提交放行连在途验收一起存)不再兜底保存 —— 见 submit 分支的注释
|
// 已经自己落过笔的行动(提交放行连在途验收一起存)不再兜底保存 —— 见 submit 分支的注释
|
||||||
if (!saved) await deps.state.save(taskId, model, parsed, currentRuntime);
|
if (!saved) await deps.state.save(taskId, model, parsed, currentRuntime);
|
||||||
|
|
||||||
@@ -2164,11 +2300,22 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
.filter((e) => !settledNow.has(e.id))
|
.filter((e) => !settledNow.has(e.id))
|
||||||
.map((e) => stepOf(e.id))
|
.map((e) => stepOf(e.id))
|
||||||
.find((x) => x !== undefined);
|
.find((x) => x !== undefined);
|
||||||
const wantedNow = stepNow?.view !== undefined && stepNow.view !== '' ? stepNow.view : wanted;
|
/*
|
||||||
|
* **画面跟着这一拍发出去的卡走**(2026-08-27 晚,用户送来 3ef3fc3e:「当前这一步不是应该给我推
|
||||||
|
* P-10 的数据工单吗?怎么还是推送的芯片主数据创建,这样我都没法填数据了」)。
|
||||||
|
* 上面那条「按办完之后在办的那一步」在**两步同时 Active** 时仍会挑错:模型没收掉 S0d_p08(剧本白纸黑字
|
||||||
|
* 写了「先 finish 再发下一张」,两处;措辞治不了),直接发了 S0d_p10 的数据工单 —— 「第一步没干完的」
|
||||||
|
* 算出来还是 P-08,人拿着 P-10 的卡对着 P-08 的件,提交闸拦「缺资料」而屏上没有资料表可填。
|
||||||
|
* 卡是发给人的那件事,件必须是那件事的件:这一拍发了哪一步的卡(human / entry / decide),
|
||||||
|
* 那一步声明了画面就推它,压过别的规则;没发卡的拍照旧。
|
||||||
|
*/
|
||||||
|
const issuedStep = issuedFor === undefined ? undefined : stepOf(issuedFor);
|
||||||
|
const issuedView = issuedStep?.view !== undefined && issuedStep.view !== '' ? issuedStep.view : undefined;
|
||||||
|
const wantedNow = issuedView ?? (stepNow?.view !== undefined && stepNow.view !== '' ? stepNow.view : wanted);
|
||||||
const frame = wantedNow !== undefined && wantedNow !== '' ? wantedNow : steps.length === 0 ? 'case-overview' : undefined;
|
const frame = wantedNow !== undefined && wantedNow !== '' ? wantedNow : steps.length === 0 ? 'case-overview' : undefined;
|
||||||
if (!framed && frame !== undefined && frame !== '') {
|
if (!framed && frame !== undefined && frame !== '') {
|
||||||
try {
|
try {
|
||||||
const shown = await showView(taskId, frame, currentRuntime, stepNow?.view ?? liveStep?.view);
|
const shown = await showView(taskId, frame, currentRuntime, issuedView ?? stepNow?.view ?? liveStep?.view);
|
||||||
if (!shown.pushed) {
|
if (!shown.pushed) {
|
||||||
await deps.memory.append({
|
await deps.memory.append({
|
||||||
taskId,
|
taskId,
|
||||||
|
|||||||
@@ -169,6 +169,8 @@ export type CardGateway = {
|
|||||||
ownerId: StaffId;
|
ownerId: StaffId;
|
||||||
humanCallCap: number;
|
humanCallCap: number;
|
||||||
workOrder: 'execution' | 'acceptance';
|
workOrder: 'execution' | 'acceptance';
|
||||||
|
/** 这张卡挂在哪个计划项上 —— 引擎按它查剧本声明(如 `cardVariant`),不靠模型抄卡骨架;引擎自己发的卡(验收/兜底)没有项 */
|
||||||
|
itemId?: string | undefined;
|
||||||
card: Record<string, unknown>;
|
card: Record<string, unknown>;
|
||||||
}): Promise<{ id: string }>;
|
}): Promise<{ id: string }>;
|
||||||
/**
|
/**
|
||||||
@@ -206,7 +208,23 @@ export type CardGateway = {
|
|||||||
}>;
|
}>;
|
||||||
basis?: ReadonlyArray<{ k: string; v: string }>;
|
basis?: ReadonlyArray<{ k: string; v: string }>;
|
||||||
allowCustom?: boolean;
|
allowCustom?: boolean;
|
||||||
|
/** 这张卡挂在哪个计划项上 —— 组装层按它查剧本声明(`cardVariant: 'review'` = 摆成验收工单) */
|
||||||
|
itemId?: string | undefined;
|
||||||
|
/**
|
||||||
|
* **这张卡穿哪副面孔,引擎已经按剧本定好**(2026-08-27):模型没给 itemId、或给的项挂不上时,
|
||||||
|
* 组装层按 itemId 查剧本就查不到,卡就退成「需要你定」—— 而这一步的剧本明明写着 `cardVariant: 'review'`。
|
||||||
|
* 引擎知道当下在办哪一步,由它把面孔定下来递过去;组装层只在这里没给时才自己按 itemId 查。
|
||||||
|
*/
|
||||||
|
variant?: 'review' | undefined;
|
||||||
|
/** 人工验收清单(剧本 `acceptanceChecklist`)—— 审核卡上一条一枚「达标 / 不达标」开关 */
|
||||||
|
checklist?: ReadonlyArray<string> | undefined;
|
||||||
}): Promise<{ id: string }>;
|
}): Promise<{ id: string }>;
|
||||||
|
/**
|
||||||
|
* **这一单还有哪几张卡在等人交回**(2026-08-27,用户:「你叫了人我都没填你就直接推进了」)。
|
||||||
|
* 每拍开头问一次:有卡没答,这一拍不问模型 —— 叫了人就等人;
|
||||||
|
* 任何多余的唤醒(迟到的回传事件、另一台后端的推进器)都推不动它。
|
||||||
|
*/
|
||||||
|
unanswered(taskId: TaskId): Promise<ReadonlyArray<{ id: string; title: string }>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ListenerRegistrar = {
|
export type ListenerRegistrar = {
|
||||||
|
|||||||
@@ -20,6 +20,9 @@
|
|||||||
event(L1,输出口发射)、memory(L2)
|
event(L1,输出口发射)、memory(L2)
|
||||||
被依赖: loop-engine, flow-engine, task, app
|
被依赖: loop-engine, flow-engine, task, app
|
||||||
|
|
||||||
|
近变(2026-08-27):`SubmitForReleaseInput.checklist` / `CardGateway.issueAcceptance.checklist`(可选)——
|
||||||
|
人工验收清单原样带上验收卡;放行门本身不看它(它把的是「人核了没有」,由 human-tool 的回传校验拦)。
|
||||||
|
|
||||||
不变量(OS 7.3 / AM 12.1):
|
不变量(OS 7.3 / AM 12.1):
|
||||||
- **预检先于放行**:`submitForRelease` 预检不过即抛 `PrecheckFailedError`,**不发卡**
|
- **预检先于放行**:`submitForRelease` 预检不过即抛 `PrecheckFailedError`,**不发卡**
|
||||||
- **卡面即已验证的动作清单**:验收工单的 actions 只列预检通过的意向
|
- **卡面即已验证的动作清单**:验收工单的 actions 只列预检通过的意向
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ function harness(over: { conflicts?: Array<{ field: string; key: Record<string,
|
|||||||
mergedRows: 2,
|
mergedRows: 2,
|
||||||
emittedEvents: 2,
|
emittedEvents: 2,
|
||||||
simulatedRows: 0,
|
simulatedRows: 0,
|
||||||
|
landedEarlierRows: 0,
|
||||||
merged: [{ domain: 'd', object: 'o', key: { pn: 'X1' }, values: { type_tag: 'A' } }],
|
merged: [{ domain: 'd', object: 'o', key: { pn: 'X1' }, values: { type_tag: 'A' } }],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export type SubmitForReleaseInput = {
|
|||||||
readonly basis: ReadonlyArray<{ k: string; v: string }>;
|
readonly basis: ReadonlyArray<{ k: string; v: string }>;
|
||||||
readonly deliverables: readonly DeliverableCheck[];
|
readonly deliverables: readonly DeliverableCheck[];
|
||||||
readonly criteria: readonly CriterionCheck[];
|
readonly criteria: readonly CriterionCheck[];
|
||||||
|
/** 人工验收清单(剧本声明,固定):卡上一条一枚开关,全部达标才能通过 */
|
||||||
|
readonly checklist?: ReadonlyArray<string>;
|
||||||
/** 报告可修订:AI 拟值已填的表单字段 */
|
/** 报告可修订:AI 拟值已填的表单字段 */
|
||||||
readonly fields?: ReadonlyArray<Record<string, unknown>>;
|
readonly fields?: ReadonlyArray<Record<string, unknown>>;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ export function createReleaseService(deps: ReleaseServiceDeps): ReleaseService {
|
|||||||
})),
|
})),
|
||||||
// 卡面呈现的即**已验证**的动作清单
|
// 卡面呈现的即**已验证**的动作清单
|
||||||
actions: report.verified.map((v) => ({ label: v.label, effect: v.effect, detail: v.detail })),
|
actions: report.verified.map((v) => ({ label: v.label, effect: v.effect, detail: v.detail })),
|
||||||
|
// 人工验收清单:人核这份工单要走的步骤(剧本声明,固定)
|
||||||
|
...(input.checklist === undefined || input.checklist.length === 0 ? {} : { checklist: input.checklist }),
|
||||||
/*
|
/*
|
||||||
* 可修订字段 —— 验收报告「AI 已填好、可编辑」改的就是它们。
|
* 可修订字段 —— 验收报告「AI 已填好、可编辑」改的就是它们。
|
||||||
*
|
*
|
||||||
@@ -185,12 +187,14 @@ export function createReleaseService(deps: ReleaseServiceDeps): ReleaseService {
|
|||||||
kind: 'ledger-write',
|
kind: 'ledger-write',
|
||||||
// 试跑挡下的那几行要写在痕上 —— 只报「合并 N 行」而其中几行没进表,那句话是假的
|
// 试跑挡下的那几行要写在痕上 —— 只报「合并 N 行」而其中几行没进表,那句话是假的
|
||||||
title:
|
title:
|
||||||
merged.simulatedRows === 0
|
`放行写合并 ${merged.mergedRows} 行` +
|
||||||
? `放行写合并 ${merged.mergedRows} 行`
|
(merged.simulatedRows === 0 ? '' : `(试跑:另有 ${merged.simulatedRows} 行主数据只留在活动缓存,未落正式表)`) +
|
||||||
: `放行写合并 ${merged.mergedRows} 行(试跑:另有 ${merged.simulatedRows} 行主数据只留在活动缓存,未落正式表)`,
|
// 数据工单确认那一刻已入库的行不算在「合并」里 —— 那句话只报这一签落下的
|
||||||
|
(merged.landedEarlierRows === 0 ? '' : `(另有 ${merged.landedEarlierRows} 行已在数据工单确认时入库)`),
|
||||||
detail: {
|
detail: {
|
||||||
rows: merged.mergedRows,
|
rows: merged.mergedRows,
|
||||||
simulated: merged.simulatedRows,
|
simulated: merged.simulatedRows,
|
||||||
|
landedEarlier: merged.landedEarlierRows,
|
||||||
tables: [...byTable].map(([address, rows]) => ({ address, rows })),
|
tables: [...byTable].map(([address, rows]) => ({ address, rows })),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export type LedgerGateway = {
|
|||||||
emittedEvents: number;
|
emittedEvents: number;
|
||||||
/** 试跑单被挡在正式表外的主数据行数(非试跑恒为 0)—— 放行痕要如实说 */
|
/** 试跑单被挡在正式表外的主数据行数(非试跑恒为 0)—— 放行痕要如实说 */
|
||||||
simulatedRows: number;
|
simulatedRows: number;
|
||||||
|
/** 早已在数据工单确认时入库、这次不再写的行数 —— 放行痕上分开说,「合并 N 行」不多报 */
|
||||||
|
landedEarlierRows: number;
|
||||||
/** 合并进正式版的行 —— 输出口发射的载荷取自这里 */
|
/** 合并进正式版的行 —— 输出口发射的载荷取自这里 */
|
||||||
merged: ReadonlyArray<{
|
merged: ReadonlyArray<{
|
||||||
domain: string;
|
domain: string;
|
||||||
@@ -76,6 +78,8 @@ export type CardGateway = {
|
|||||||
rationale?: string | undefined;
|
rationale?: string | undefined;
|
||||||
}>;
|
}>;
|
||||||
actions: ReadonlyArray<{ label: string; effect: 'read' | 'write'; detail: string }>;
|
actions: ReadonlyArray<{ label: string; effect: 'read' | 'write'; detail: string }>;
|
||||||
|
/** 人工验收清单 —— 原样带上卡 */
|
||||||
|
checklist?: ReadonlyArray<string> | undefined;
|
||||||
/**
|
/**
|
||||||
* 可修订字段 —— 验收报告「AI 已填好、可编辑」改的就是它们(带 `ai` 拟值与 `ledger` 落回地址)。
|
* 可修订字段 —— 验收报告「AI 已填好、可编辑」改的就是它们(带 `ai` 拟值与 `ledger` 落回地址)。
|
||||||
* **端口不声明它,值就在装配层被丢掉**:上游一路传下来,而 TypeScript 对条件展开
|
* **端口不声明它,值就在装配层被丢掉**:上游一路传下来,而 TypeScript 对条件展开
|
||||||
|
|||||||
@@ -44,6 +44,13 @@
|
|||||||
任一项触顶即 `exhausted`,空转即 `stalled`
|
任一项触顶即 `exhausted`,空转即 `stalled`
|
||||||
- **监听中是健康态**:有监听在等或等人回传一律**不算停滞**
|
- **监听中是健康态**:有监听在等或等人回传一律**不算停滞**
|
||||||
- **终态五名封闭**:`decideTerminal` 是唯一命名口;`released` 是唯一成功终态
|
- **终态五名封闭**:`decideTerminal` 是唯一命名口;`released` 是唯一成功终态
|
||||||
|
- **迟到的人事件不再推一拍**(2026-08-27):`deliver` 的人事件带 `occurredAt`,任务在这条回传之后已推进过
|
||||||
|
(`lastProgressAt > occurredAt`)就只记一条到达痕(`detail.stale = true`),不 `onEvent`、不 `tick` ——
|
||||||
|
回传落库那一刻回传口已 `wake`,推进器读留痕里的 card-reply 先跑了那一拍;事件晚十几秒才到,再推一拍
|
||||||
|
就是让模型对着一张没人答的新卡「接着办」(实测把 S1 收掉)。没带 `occurredAt` 的老调用方行为照旧
|
||||||
|
**放行裁决(`payload.mode = 'acceptance'`)不在此列**:它只走事件这一条路(loop 的 `onEvent` 把 verdict
|
||||||
|
落进在途验收信封,拍里读的是信封,留痕 / 卡库里读不到它),迟到也必须投 —— 否则签字之后
|
||||||
|
`wake` → 推进器抢先一拍挪了 `lastProgressAt`,事件到时被判 stale,单子永远停在「字已签下,正在落地」
|
||||||
- **已终结不得再推进**:`tick` / `deliver` 对终态任务抛 `TaskAlreadyTerminalError` ——
|
- **已终结不得再推进**:`tick` / `deliver` 对终态任务抛 `TaskAlreadyTerminalError` ——
|
||||||
流级重做一律重开新实例,不复活旧任务
|
流级重做一律重开新实例,不复活旧任务
|
||||||
- **叫停不是终态**:转挂起待纠偏,并记**最重档纠正信号**
|
- **叫停不是终态**:转挂起待纠偏,并记**最重档纠正信号**
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* 迟到的人事件不再推一拍(2026-08-27,用户:「你叫了人我都没填你就直接推进了」)。
|
||||||
|
*
|
||||||
|
* 回传落库那一刻回传口已 wake、推进器随即跑了那一拍(读留痕里的 card-reply,不等事件);
|
||||||
|
* 人事件从队列里投过来晚十几秒,那时上一拍早已发出下一张卡 —— 再 tick 一拍,模型会把没人答的
|
||||||
|
* 那一步收掉。判法只有一个数:这一单在这条回传之后推进过没有(lastProgressAt > occurredAt)。
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { StaffId, TaskId } from '@contracts';
|
||||||
|
import type { LandedFacts, Task } from '../contract.ts';
|
||||||
|
import { createTaskService } from '../internal/usecase/task-service.ts';
|
||||||
|
import type { TaskServiceDeps } from '../internal/usecase/task-service.ts';
|
||||||
|
|
||||||
|
function harness(lastProgressAt: Date | undefined) {
|
||||||
|
const task: Task = {
|
||||||
|
id: 't-1' as TaskId,
|
||||||
|
activityId: 'w05',
|
||||||
|
ownerId: 's-1' as StaffId,
|
||||||
|
title: 'x',
|
||||||
|
runtime: 'awaiting-human',
|
||||||
|
envelope: {},
|
||||||
|
data: {},
|
||||||
|
caps: { steps: 40, tokens: 200000, humanCalls: 4, activeMinutes: 60 },
|
||||||
|
used: { steps: 0, tokens: 0, humanCalls: 0, activeMs: 0 },
|
||||||
|
startedAt: '2026-08-27T09:40:00.000Z',
|
||||||
|
};
|
||||||
|
const tasks = new Map<string, Task>([[task.id, task]]);
|
||||||
|
const appended: Array<{ kind: string; title: string; detail?: Record<string, unknown> }> = [];
|
||||||
|
let advanced = 0;
|
||||||
|
let onEvents = 0;
|
||||||
|
const deps: TaskServiceDeps = {
|
||||||
|
store: {
|
||||||
|
get: async (id) => tasks.get(id),
|
||||||
|
insert: async (t) => void tasks.set(t.id, t),
|
||||||
|
update: async (id, patch) => {
|
||||||
|
const next = { ...tasks.get(id)!, ...patch };
|
||||||
|
tasks.set(id, next);
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
addConsumption: async (id) => tasks.get(id)!.used,
|
||||||
|
lastProgressAt: async () => lastProgressAt,
|
||||||
|
markProgress: async () => undefined,
|
||||||
|
listByOwner: async () => [],
|
||||||
|
},
|
||||||
|
activities: { load: async () => ({ caps: task.caps, envelope: {}, inputSchema: {}, businessModel: {}, stallAfter: 'PT30M' }) },
|
||||||
|
schemas: { validate: () => ({ ok: true, errors: [] }) },
|
||||||
|
engine: {
|
||||||
|
load: async () => undefined,
|
||||||
|
advance: async () => {
|
||||||
|
advanced += 1;
|
||||||
|
return { status: 'awaiting' };
|
||||||
|
},
|
||||||
|
onEvent: async () => {
|
||||||
|
onEvents += 1;
|
||||||
|
return { status: 'running' };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
memory: {
|
||||||
|
append: async (entry) => void appended.push({ kind: entry.kind, title: entry.title, ...(entry.detail === undefined ? {} : { detail: entry.detail }) }),
|
||||||
|
hasHistory: async () => true,
|
||||||
|
seal: async () => undefined,
|
||||||
|
distill: async () => undefined,
|
||||||
|
},
|
||||||
|
landing: { land: async () => ({ converged: true, emittedPorts: [] as string[], ledgerRowsMerged: 0, facts: {} as LandedFacts }) },
|
||||||
|
listeners: { hasArmedListener: async () => true },
|
||||||
|
persistence: { hasDrafts: async () => false, hasRuntimeState: async () => true },
|
||||||
|
events: { publishTerminal: async () => undefined, publishControl: async () => undefined },
|
||||||
|
notifier: { notify: async () => undefined },
|
||||||
|
interaction: { setPhase: async () => undefined },
|
||||||
|
intake: { askCompletion: async () => undefined },
|
||||||
|
comms: { record: async () => undefined },
|
||||||
|
clock: { now: () => new Date('2026-08-27T09:48:30.000Z') },
|
||||||
|
ids: { next: () => 't-x' },
|
||||||
|
};
|
||||||
|
return { service: createTaskService(deps), appended, advanced: () => advanced, onEvents: () => onEvents };
|
||||||
|
}
|
||||||
|
|
||||||
|
const REPLY_AT = '2026-08-27T09:47:48.000Z';
|
||||||
|
|
||||||
|
describe('迟到的人事件', () => {
|
||||||
|
it('回传之后这一单已推进过 → 只记一条到达痕(标 stale),不 onEvent、不推一拍', async () => {
|
||||||
|
const h = harness(new Date('2026-08-27T09:48:00.200Z'));
|
||||||
|
const out = await h.service.deliver('t-1' as TaskId, { kind: 'human', ref: 'card-p10', payload: {}, occurredAt: REPLY_AT });
|
||||||
|
expect(out.progressed).toBe(false);
|
||||||
|
expect(h.advanced()).toBe(0);
|
||||||
|
expect(h.onEvents()).toBe(0);
|
||||||
|
const arrival = h.appended.find((e) => e.title === '事件到达(human):card-p10');
|
||||||
|
expect(arrival?.detail?.['stale']).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('回传之后没推进过 → 照常投递并推一拍(这就是叫醒它的那一下)', async () => {
|
||||||
|
const h = harness(new Date('2026-08-27T09:47:40.000Z'));
|
||||||
|
await h.service.deliver('t-1' as TaskId, { kind: 'human', ref: 'card-s1', payload: {}, occurredAt: REPLY_AT });
|
||||||
|
expect(h.onEvents()).toBe(1);
|
||||||
|
expect(h.advanced()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('放行裁决即使迟到也要投:它只走事件这一条路,拍里读不到它', async () => {
|
||||||
|
const h = harness(new Date('2026-08-27T09:48:00.000Z'));
|
||||||
|
const out = await h.service.deliver('t-1' as TaskId, {
|
||||||
|
kind: 'human',
|
||||||
|
ref: 'card-acc',
|
||||||
|
payload: { mode: 'acceptance', verdict: 'pass', values: {} },
|
||||||
|
occurredAt: REPLY_AT,
|
||||||
|
});
|
||||||
|
expect(h.onEvents()).toBe(1);
|
||||||
|
expect(h.advanced()).toBe(1);
|
||||||
|
expect(out.progressed).toBe(true);
|
||||||
|
const arrival = h.appended.find((e) => e.kind === 'observation' && e.title.startsWith('事件到达'));
|
||||||
|
expect(arrival?.detail?.['stale']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('没带 occurredAt(老调用方)/ 不是人事件 → 行为照旧', async () => {
|
||||||
|
const h = harness(new Date('2026-08-27T09:48:00.200Z'));
|
||||||
|
await h.service.deliver('t-1' as TaskId, { kind: 'human', ref: 'card-x', payload: {} });
|
||||||
|
await h.service.deliver('t-1' as TaskId, { kind: 'data', ref: 'row-1', payload: {}, occurredAt: REPLY_AT });
|
||||||
|
expect(h.advanced()).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -98,7 +98,14 @@ export type TaskService = {
|
|||||||
/** 推进一拍:**预算在环外先扣先判**,再交范式引擎 */
|
/** 推进一拍:**预算在环外先扣先判**,再交范式引擎 */
|
||||||
tick(taskId: TaskId): Promise<TickOutcome>;
|
tick(taskId: TaskId): Promise<TickOutcome>;
|
||||||
/** 事件驱动续跑 —— 挂起期间事件不丢,恢复即补投 */
|
/** 事件驱动续跑 —— 挂起期间事件不丢,恢复即补投 */
|
||||||
deliver(taskId: TaskId, event: { kind: 'data' | 'time' | 'human' | 'control'; ref: string; payload: Record<string, unknown> }): Promise<TickOutcome>;
|
/**
|
||||||
|
* 投递一条事件并推一拍。`occurredAt` = 事件发生时刻(信封的 occurredAt):人事件要拿它与任务的
|
||||||
|
* 最近推进时刻比 —— 回传那一刻已经 wake、推进器抢先跑了那一拍的,这条事件只是迟到的回声,不再推。
|
||||||
|
*/
|
||||||
|
deliver(
|
||||||
|
taskId: TaskId,
|
||||||
|
event: { kind: 'data' | 'time' | 'human' | 'control'; ref: string; payload: Record<string, unknown>; occurredAt?: string },
|
||||||
|
): Promise<TickOutcome>;
|
||||||
|
|
||||||
/** 控制三语义:暂停 / 叫停 / 终止 */
|
/** 控制三语义:暂停 / 叫停 / 终止 */
|
||||||
control(taskId: TaskId, semantic: ControlSemantic, by: StaffId, reason?: string): Promise<ControlOutcome>;
|
control(taskId: TaskId, semantic: ControlSemantic, by: StaffId, reason?: string): Promise<ControlOutcome>;
|
||||||
|
|||||||
@@ -310,6 +310,35 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
|
|||||||
* 从前 loop 的 onEvent 还会再记一条「事件到达(kind):ref」—— 同一次回传在日志里
|
* 从前 loop 的 onEvent 还会再记一条「事件到达(kind):ref」—— 同一次回传在日志里
|
||||||
* 三行几乎一样(卡回传 + 两层各一条到达),人看着像系统在结巴。
|
* 三行几乎一样(卡回传 + 两层各一条到达),人看着像系统在结巴。
|
||||||
*/
|
*/
|
||||||
|
/*
|
||||||
|
* **迟到的人事件不再推一拍**(2026-08-27,用户:「你叫了人我都没填你就直接推进了」)。
|
||||||
|
*
|
||||||
|
* 回传落库那一刻,回传口已经 `wake` 了这一单,推进器随即跑了那一拍(它读的是留痕里的
|
||||||
|
* card-reply,不等事件);人事件从队列里投过来往往晚十几秒 —— 那时上一拍早已发出了
|
||||||
|
* **下一张卡**。从前这里照样 `tick`:又一拍模型,而引擎的事实行对它说「这一步已经发过卡,
|
||||||
|
* 人已经被问过了」,模型就把没人答的那一步收掉了(实测:S1 的卡发出 0.3 秒后,P-10 那张
|
||||||
|
* 回传的事件才到,再一拍就「完成:评估输入与资料补齐确认」)。
|
||||||
|
* 判法只有一个数:**这一单在这条回传之后推进过没有**(`lastProgressAt > occurredAt`)——
|
||||||
|
* 推进过,回传一定已被那一拍消费(留痕先于事件落);没推进过,这条事件就是叫醒它的那一下。
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
* **验收裁决不在此列**(2026-08-27 晚,用户:「这一单怎么卡在字已签下,正在落地,没有完成」):
|
||||||
|
* 上面那条「推进过 = 已消费」的前提是**那一拍能从留痕 / 卡库里读到这份答复**(执行单的填报、
|
||||||
|
* 决策卡的选项都如此);而放行裁决只走事件这一条路 —— loop 的 onEvent 把 verdict 落进在途验收的
|
||||||
|
* 信封,拍里读的是信封。签字落库 → wake → 推进器抢先跑了一拍(在途验收没裁决,原地返回,
|
||||||
|
* 但 lastProgressAt 已经往前挪)→ 事件晚 1.6 秒到 → 被判 stale 丢掉:单子永远停在「字已签下,正在落地」。
|
||||||
|
* 认结构化键 `mode`(回传口随 reply 一并入事件),不认时刻。
|
||||||
|
*/
|
||||||
|
const verdictReply = event.payload['mode'] === 'acceptance';
|
||||||
|
const stale =
|
||||||
|
event.kind === 'human' &&
|
||||||
|
!verdictReply &&
|
||||||
|
event.occurredAt !== undefined &&
|
||||||
|
(await (async () => {
|
||||||
|
const last = await deps.store.lastProgressAt(taskId);
|
||||||
|
const at = new Date(event.occurredAt ?? '').getTime();
|
||||||
|
return last !== undefined && !Number.isNaN(at) && last.getTime() > at;
|
||||||
|
})());
|
||||||
await deps.memory.append({
|
await deps.memory.append({
|
||||||
taskId,
|
taskId,
|
||||||
kind: 'observation',
|
kind: 'observation',
|
||||||
@@ -318,8 +347,12 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
|
|||||||
* `kind` 要进 detail:对话区与工作日志都靠它认这是哪一类到达。
|
* `kind` 要进 detail:对话区与工作日志都靠它认这是哪一类到达。
|
||||||
* 从前只有 payload,两处只能去抠标题前缀 —— 标题是给人看的、随时会改。
|
* 从前只有 payload,两处只能去抠标题前缀 —— 标题是给人看的、随时会改。
|
||||||
*/
|
*/
|
||||||
detail: { ...event.payload, kind: event.kind },
|
detail: { ...event.payload, kind: event.kind, ...(stale ? { stale: true, note: '这条回传在到达前已被推进过的那一拍处理,这次不再推' } : {}) },
|
||||||
});
|
});
|
||||||
|
if (stale) {
|
||||||
|
const held = await require(taskId);
|
||||||
|
return { task: held, verdict: { kind: 'ok' }, progressed: false };
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 受理补全的答复(宽进):任务还在「等人」且谱一步没走 —— 这时候来的 human 事件
|
* 受理补全的答复(宽进):任务还在「等人」且谱一步没走 —— 这时候来的 human 事件
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user