Archived
harness 与工作台同日修:数据工单确认即入库 · 叫了人不再自动推进(两道结构闸)· 审核卡 = 终局验收卡结构 · 验收清单人点达标 · 迟到守卫不丢放行裁决 · 画面跟着这一拍发出去的卡走 · 成果详情只翻页 · 占位居中 · 进度条
human-tool / ledger / loop-engine / release / task / interaction 六模块与 app 层(desk 路由、engines、consumers、 data-order-landing、case-stamp、reset-runs、seed-activities、容器与 server 装配),web 的 desk-task (ViewRegion / chat-bubbles / review-face)与 app.css、ui-kit 图标;契约 card / manifest; 迁移 0034_task_seq 与迁移日志;api-report 快照;CLAUDE.md 快照、debt、活动开发规范、HTML 报告规范同步。 逐条口径见 CLAUDE.md 快照(2026-08-27 下午至晚)。另一会话做的,一直留在工作树没入库;与同批另两条提交互相依赖。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
This commit is contained in:
@@ -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` 装包静态审)。
|
||||||
|
|
||||||
## 文档权威地图
|
## 文档权威地图
|
||||||
|
|
||||||
| 层 | 文件 | 说明 |
|
| 层 | 文件 | 说明 |
|
||||||
|
|||||||
@@ -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 {};
|
||||||
+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
+15
@@ -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>;
|
||||||
/** 执行工单:作业指导书 + 空表单(补全并入此类);落地失败卡是它的系统预置形态 */
|
/** 执行工单:作业指导书 + 空表单(补全并入此类);落地失败卡是它的系统预置形态 */
|
||||||
@@ -884,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>;
|
||||||
@@ -930,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<{
|
||||||
@@ -1220,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>;
|
||||||
@@ -1501,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<{
|
||||||
@@ -1519,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<{
|
||||||
@@ -1809,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">;
|
||||||
@@ -1828,6 +1835,7 @@ export declare const decisionCardSchema: z.ZodObject<{
|
|||||||
variant: z.ZodOptional<z.ZodEnum<{
|
variant: z.ZodOptional<z.ZodEnum<{
|
||||||
review: "review";
|
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;
|
||||||
@@ -1847,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">;
|
||||||
@@ -1943,6 +1952,7 @@ export declare const chatCardSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
variant: z.ZodOptional<z.ZodEnum<{
|
variant: z.ZodOptional<z.ZodEnum<{
|
||||||
review: "review";
|
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;
|
||||||
@@ -1962,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">;
|
||||||
@@ -2079,6 +2090,7 @@ export declare const chatMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
variant: z.ZodOptional<z.ZodEnum<{
|
variant: z.ZodOptional<z.ZodEnum<{
|
||||||
review: "review";
|
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;
|
||||||
@@ -2098,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">;
|
||||||
@@ -2195,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<{
|
||||||
@@ -2202,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';
|
||||||
|
|||||||
+147
@@ -206,6 +206,7 @@ export declare const planStepSchema: z.ZodObject<{
|
|||||||
data: "data";
|
data: "data";
|
||||||
review: "review";
|
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>;
|
||||||
/**
|
/**
|
||||||
@@ -270,6 +271,7 @@ export declare const loopModelSchema: z.ZodObject<{
|
|||||||
data: "data";
|
data: "data";
|
||||||
review: "review";
|
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;
|
||||||
@@ -328,6 +330,7 @@ export declare const businessModelSchema: z.ZodObject<{
|
|||||||
data: "data";
|
data: "data";
|
||||||
review: "review";
|
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;
|
||||||
@@ -348,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;
|
||||||
@@ -395,6 +487,7 @@ export declare const manifestAssetsSchema: z.ZodObject<{
|
|||||||
data: "data";
|
data: "data";
|
||||||
review: "review";
|
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;
|
||||||
@@ -416,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<{
|
||||||
@@ -673,6 +792,7 @@ export declare const activityManifestSchema: z.ZodObject<{
|
|||||||
data: "data";
|
data: "data";
|
||||||
review: "review";
|
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;
|
||||||
@@ -694,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<{
|
||||||
@@ -800,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,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('放行时再并');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -565,6 +565,14 @@ describe('createOnly:键已在正式表 → 拒并指路', () => {
|
|||||||
expect(why).toContain('drop');
|
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('库里没有的料号 → 放行;复合主键按全部键列比', () => {
|
it('库里没有的料号 → 放行;复合主键按全部键列比', () => {
|
||||||
expect(createOnlyViolation(target, { internal_pn: 'XL2596S-5.0' }, formal)).toBeUndefined();
|
expect(createOnlyViolation(target, { internal_pn: 'XL2596S-5.0' }, formal)).toBeUndefined();
|
||||||
const sub = { label: '标准化参数集', keys: ['internal_pn', 'param_name'] };
|
const sub = { label: '标准化参数集', keys: ['internal_pn', 'param_name'] };
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -198,10 +199,23 @@ export function createOnlyViolation(
|
|||||||
target: { readonly label: string; readonly keys: ReadonlyArray<string> },
|
target: { readonly label: string; readonly keys: ReadonlyArray<string> },
|
||||||
key: Readonly<Record<string, string | number>>,
|
key: Readonly<Record<string, string | number>>,
|
||||||
formalRows: ReadonlyArray<Record<string, unknown>>,
|
formalRows: ReadonlyArray<Record<string, unknown>>,
|
||||||
|
/** 本单经数据工单确认**已入库**的行的键(2026-08-27)—— 它们也在正式表里,但拒回要说另一句话 */
|
||||||
|
landedKeys: ReadonlyArray<Record<string, unknown>> = [],
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
const same = (row: Record<string, unknown>) => target.keys.every((k) => String(row[k] ?? '') === String(key[k] ?? ''));
|
const same = (row: Record<string, unknown>) => target.keys.every((k) => String(row[k] ?? '') === String(key[k] ?? ''));
|
||||||
if (!formalRows.some(same)) return undefined;
|
if (!formalRows.some(same)) return undefined;
|
||||||
const shown = target.keys.map((k) => `${k}=${String(key[k] ?? '')}`).join('、');
|
const shown = target.keys.map((k) => `${k}=${String(key[k] ?? '')}`).join('、');
|
||||||
|
/*
|
||||||
|
* 这一行是**本单自己**在数据工单里建好、人确认后入库的 —— 指它去 drop 就错了:
|
||||||
|
* drop 撤的是草稿层那一笔,正式表一个字不动,撤了只会让本单的读面(`mine:` / `_rows`)少一行、哨兵开不了。
|
||||||
|
* 该说的是:它已经建好了,别再写;要改由人在件上改。
|
||||||
|
*/
|
||||||
|
if (landedKeys.some(same)) {
|
||||||
|
return (
|
||||||
|
`${target.label}里「${shown}」这一行是本单在数据工单里建好、人确认后已入库的 —— 建好了就别再写它;` +
|
||||||
|
'值要改由人在件上改(件上保存直落草稿层),你这一笔不用发,接着办下一件事。'
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
`${target.label}里已经有「${shown}」这一行 —— 这张表在本单只许**新建库里没有的记录**(数据工单是从零建主数据用的),` +
|
`${target.label}里已经有「${shown}」这一行 —— 这张表在本单只许**新建库里没有的记录**(数据工单是从零建主数据用的),` +
|
||||||
'在库的料号不用建、也不许改:别再写它;要是本单草稿里已经替它铺了骨架,用 drop 把那一行撤掉,免得件上多出一行要人填的东西。'
|
'在库的料号不用建、也不许改:别再写它;要是本单草稿里已经替它铺了骨架,用 drop 把那一行撤掉,免得件上多出一行要人填的东西。'
|
||||||
@@ -945,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,
|
||||||
@@ -1033,7 +1043,10 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
limit: 500,
|
limit: 500,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
});
|
});
|
||||||
const why = createOnlyViolation(target, input.key, formal.rows as ReadonlyArray<Record<string, unknown>>);
|
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);
|
if (why !== undefined) throw new Error(why);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1135,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 },
|
||||||
@@ -1242,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 }));
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* 发**补全卡** —— 对话卡族:缺口清单摆在对话区、带一张表单,人就地填齐。
|
* 发**补全卡** —— 对话卡族:缺口清单摆在对话区、带一张表单,人就地填齐。
|
||||||
* 与执行工单分开:派一张工单去问几个字段,收卡的人得进待处理、办完再回来,重得不成比例。
|
* 与执行工单分开:派一张工单去问几个字段,收卡的人得进待处理、办完再回来,重得不成比例。
|
||||||
@@ -1352,6 +1387,8 @@ 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;
|
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,
|
||||||
@@ -1360,17 +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'):按验收工单的样子摆,机制仍是决策卡 —— 剧本说了算,不靠模型抄 */
|
/*
|
||||||
|
* 审核卡(剧本 cardVariant:'review'):按验收工单的样子摆,机制仍是决策卡 —— 剧本说了算,不靠模型抄。
|
||||||
|
* 面孔**引擎按当下那一步定好递过来**(`input.variant`,2026-08-27:模型漏了 itemId,卡退成「需要你定」);
|
||||||
|
* 没递才退回按 itemId 查剧本。
|
||||||
|
*/
|
||||||
const reviewFace =
|
const reviewFace =
|
||||||
input.itemId !== undefined &&
|
input.variant === 'review' ||
|
||||||
(await deps.registry.manifestOf((await taskContext.of(input.taskId)).activityId)).assets.businessModel.steps?.find(
|
(input.itemId !== undefined &&
|
||||||
(st) => st.item === input.itemId,
|
(await deps.registry.manifestOf((await taskContext.of(input.taskId)).activityId)).assets.businessModel.steps?.find(
|
||||||
)?.cardVariant === 'review';
|
(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 } : {}),
|
...(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,
|
||||||
@@ -1383,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 });
|
||||||
@@ -1674,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: '案例模型收口' }],
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -32,4 +32,12 @@
|
|||||||
- **调人预算环外强制**:超过契约声明的打扰上限即 `HumanCallBudgetExceededError`,
|
- **调人预算环外强制**:超过契约声明的打扰上限即 `HumanCallBudgetExceededError`,
|
||||||
活动代码无从自查自守
|
活动代码无从自查自守
|
||||||
- **落地失败卡是执行工单的 preset**,不构成第五种卡
|
- **落地失败卡是执行工单的 preset**,不构成第五种卡
|
||||||
- **数据工单是执行工单的 `variant:'data'`**,同样不构成新的 mode:发卡 / 闸 / 回传全走执行工单那条路,只是对话区换一副面孔(2026-08-27)
|
- **人工验收清单点全才能通过**(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`)不受影响 —— 试跑仍是真跑
|
||||||
@@ -53,6 +62,11 @@
|
|||||||
用注释而非另建元数据表:名字与表同生共死,不会对不上。
|
用注释而非另建元数据表:名字与表同生共死,不会对不上。
|
||||||
`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 写入那一刻查正式表,
|
近变(2026-08-27):**对象声明加 `createOnly`** —— 标了的表在本单**只建不改**:Agent 写入那一刻查正式表,
|
||||||
键已在库当场拒并指路(撤掉草稿里那一行)。数据工单「从零建主数据」的三张表用它。只管 Agent 的写路径
|
键已在库当场拒并指路(撤掉草稿里那一行)。数据工单「从零建主数据」的三张表用它。只管 Agent 的写路径
|
||||||
(`engines.draftWrite` 那道门),人在件上的保存不受此限。
|
(`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),
|
||||||
@@ -128,6 +136,14 @@
|
|||||||
立了没人叫醒的监听不是等待,是失联(实测:模型在依赖断点上临场立 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`(这张卡挂在哪个计划项上)—— 组装层据此查剧本声明
|
近变(2026-08-27):`cards.issue` 的入参多一项可选 `itemId`(这张卡挂在哪个计划项上)—— 组装层据此查剧本声明
|
||||||
(`PlanStep.cardVariant`:这一步派的是数据工单),**不靠模型抄卡骨架**(实测它会把 `variant` 抄丢)。
|
(`PlanStep.cardVariant`:这一步派的是数据工单),**不靠模型抄卡骨架**(实测它会把 `variant` 抄丢)。
|
||||||
引擎自己发的卡(验收 / 兜底)没有项,不传。
|
引擎自己发的卡(验收 / 兜底)没有项,不传。
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -1886,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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -387,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);
|
||||||
@@ -643,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'];
|
||||||
@@ -650,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);
|
||||||
// 项号先归一(简写认得出),再往下走 —— 一处归一,所有行动型都受用
|
// 项号先归一(简写认得出),再往下走 —— 一处归一,所有行动型都受用
|
||||||
@@ -1118,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 回归实测撞出来的):
|
||||||
@@ -1161,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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1204,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': {
|
||||||
@@ -1249,9 +1277,12 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
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,
|
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,
|
||||||
@@ -1265,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': {
|
||||||
@@ -1514,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 })) }),
|
||||||
},
|
},
|
||||||
@@ -1610,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' };
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 齐了即收:必做项完成且无进行中项 → 提交放行(发验收卡,终局唯放行)。
|
* 齐了即收:必做项完成且无进行中项 → 提交放行(发验收卡,终局唯放行)。
|
||||||
*
|
*
|
||||||
@@ -1772,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;
|
||||||
@@ -1796,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;
|
||||||
}
|
}
|
||||||
@@ -1807,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 的项序即业务次序)。
|
||||||
@@ -1850,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
|
||||||
? '这一步标了人工激活(触达世界):这一单里已经真的给它发过卡或立过监听——人已经被问过了,' +
|
? '这一步标了人工激活(触达世界):这一单里已经真的给它发过卡或立过监听——人已经被问过了,' +
|
||||||
'照下面「人交回之后」那一段办。'
|
'照下面「人交回之后」那一段办。'
|
||||||
@@ -2058,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 若按拍首的
|
||||||
@@ -2077,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 一个不存在的项按被拒处理,不掀翻整拍
|
||||||
@@ -2103,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) {
|
||||||
@@ -2120,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;
|
||||||
// 工具出参要先看见:同拍余下的行动是闭着眼计划的,作废并留痕
|
// 工具出参要先看见:同拍余下的行动是闭着眼计划的,作废并留痕
|
||||||
@@ -2134,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);
|
||||||
|
|
||||||
@@ -2181,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,
|
||||||
|
|||||||
@@ -210,7 +210,21 @@ export type CardGateway = {
|
|||||||
allowCustom?: boolean;
|
allowCustom?: boolean;
|
||||||
/** 这张卡挂在哪个计划项上 —— 组装层按它查剧本声明(`cardVariant: 'review'` = 摆成验收工单) */
|
/** 这张卡挂在哪个计划项上 —— 组装层按它查剧本声明(`cardVariant: 'review'` = 摆成验收工单) */
|
||||||
itemId?: string | undefined;
|
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 事件
|
||||||
|
|||||||
@@ -2002,6 +2002,17 @@
|
|||||||
.proto-root .wf-card.jc-wc-fill > .jc-sa { flex: 1 1 auto; min-height: 0; }
|
.proto-root .wf-card.jc-wc-fill > .jc-sa { flex: 1 1 auto; min-height: 0; }
|
||||||
/* 正文滚动视口:卡已经有 16/18 的内边距,正文不再重复留白(右侧留一点给自绘 thumb) */
|
/* 正文滚动视口:卡已经有 16/18 的内边距,正文不再重复留白(右侧留一点给自绘 thumb) */
|
||||||
.proto-root .wf-body { min-height: 0; padding-right: 4px; }
|
.proto-root .wf-body { min-height: 0; padding-right: 4px; }
|
||||||
|
/*
|
||||||
|
工作区正文里的占位(AI 推进中 / 这单已收尾 / 还没有往来记录 / 手上没有待办 …)要**上下居中**
|
||||||
|
(2026-08-27 用户:「中央的几个占位的页面内容居中显示」)。
|
||||||
|
`.jc-empty` 早写了 `margin: auto`,可视口(`.wf-body` = 自绘滚动区的视口)是块级的 ——
|
||||||
|
`margin-top/bottom: auto` 只在 flex/grid 容器里才吃得开,于是占位全贴在顶边。
|
||||||
|
与 `.execution-panel.jc-panel-card:has(> .jc-empty)` 同一招:按内容挑,只有直接儿子是占位时
|
||||||
|
才把视口改成纵向 flex;件、日志、往来记录这些正文照旧从顶往下排。 */
|
||||||
|
.proto-root .wf-body:has(> .jc-empty),
|
||||||
|
.proto-root .wf-body:has(> .jc-working) { display: flex; flex-direction: column; }
|
||||||
|
.proto-root .wf-body > .jc-empty,
|
||||||
|
.proto-root .wf-body > .jc-working { margin: auto; flex: 0 0 auto; }
|
||||||
.proto-root .wf-body > .jc-mst-doc { padding: 0 0 6px; animation: rise var(--t-med) both; }
|
.proto-root .wf-body > .jc-mst-doc { padding: 0 0 6px; animation: rise var(--t-med) both; }
|
||||||
.proto-root .wf-body > .wf-log { padding-top: 2px; }
|
.proto-root .wf-body > .wf-log { padding-top: 2px; }
|
||||||
|
|
||||||
@@ -2440,6 +2451,13 @@
|
|||||||
.proto-root .jc-fc-doc:hover > svg { transform: translateX(2px); color: var(--fc); }
|
.proto-root .jc-fc-doc:hover > svg { transform: translateX(2px); color: var(--fc); }
|
||||||
/* 判据表(卡内紧凑版) */
|
/* 判据表(卡内紧凑版) */
|
||||||
.proto-root .jc-fc .jc-rp-crit.sm li { padding: 6px 11px; font-size: 11.5px; }
|
.proto-root .jc-fc .jc-rp-crit.sm li { padding: 6px 11px; font-size: 11.5px; }
|
||||||
|
/* 人工验收清单:序号格 + 右侧「待确认 / 已确认」开关(人点的,不是系统判的)—— 待确认灰、已确认绿 */
|
||||||
|
.proto-root .jc-checklist .jc-ck-n { flex: none; width: 18px; height: 18px; border-radius: 6px; display: grid; place-items: center; font-size: 10px; font-weight: 800; font-family: var(--mono); color: var(--ink-3); background: var(--soft); border: 1px solid var(--line-soft); }
|
||||||
|
.proto-root .jc-checklist li.ok .jc-ck-n { color: #fff; background: var(--ok); border-color: var(--ok); }
|
||||||
|
.proto-root .jc-checklist li.pend .r { color: var(--ink-3); }
|
||||||
|
.proto-root .jc-checklist .jc-tg { cursor: pointer; font: inherit; font-size: 11px; font-weight: 800; border: 1px solid var(--line); background: var(--soft); border-radius: 999px; padding: 2px 9px; color: var(--ink-3); transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast); }
|
||||||
|
.proto-root .jc-checklist li.ok .jc-tg { color: var(--ok); border-color: #bfe6b4; background: var(--ok-soft); }
|
||||||
|
.proto-root .jc-checklist .jc-tg:hover { border-color: var(--fc); }
|
||||||
/*
|
/*
|
||||||
* **作业指导书与验收判据同一套版面**(2026-08-02 用户定)。
|
* **作业指导书与验收判据同一套版面**(2026-08-02 用户定)。
|
||||||
* 从前指导书是一串松散的圆点(`.jc-steps`),判据是一摞带章的行 ——
|
* 从前指导书是一串松散的圆点(`.jc-steps`),判据是一摞带章的行 ——
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* 审核卡穿验收工单的衣服 —— 钮对选项、重做交什么(2026-08-27 用户:验收卡不许有两种样式)。
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { cardIdSchema } from '@contracts';
|
||||||
|
import { approveReply, checklistMissing, redoReply, reviewChoices } from '../internal/review-face.ts';
|
||||||
|
|
||||||
|
const c1 = cardIdSchema.parse('c1');
|
||||||
|
|
||||||
|
const opts = [
|
||||||
|
{ value: 'approve', label: '通过', recommended: true },
|
||||||
|
{ value: 'reject', label: '驳回(不写具体意见)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('reviewChoices —— 两颗钮对哪两个选项', () => {
|
||||||
|
it('值叫 approve / reject 的直接对上', () => {
|
||||||
|
const c = reviewChoices(opts);
|
||||||
|
expect(c?.approve.value).toBe('approve');
|
||||||
|
expect(c?.reject?.value).toBe('reject');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('没按这个名起的:推荐的 = 通过,其余第一个 = 驳回', () => {
|
||||||
|
const c = reviewChoices([{ value: 'no', label: '退回' }, { value: 'ok', label: '可以', recommended: true }]);
|
||||||
|
expect(c?.approve.value).toBe('ok');
|
||||||
|
expect(c?.reject?.value).toBe('no');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('只有一个选项:通过有了、驳回没有;一个都没有对不出面孔', () => {
|
||||||
|
const c = reviewChoices([{ value: 'approve', label: '通过' }]);
|
||||||
|
expect(c?.approve.value).toBe('approve');
|
||||||
|
expect(c?.reject).toBeUndefined();
|
||||||
|
expect(reviewChoices([])).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('redoReply / approveReply —— 交什么', () => {
|
||||||
|
const c = reviewChoices(opts)!;
|
||||||
|
|
||||||
|
it('写了理由且允许自定义 → custom:<理由>,note 带原文(基座按字面点名对行)', () => {
|
||||||
|
expect(redoReply({ id: c1, allowCustom: true }, c, ' 测试111 建议动作改成拜访CTO ')).toEqual({
|
||||||
|
k: 'decision',
|
||||||
|
cardId: c1,
|
||||||
|
value: 'custom:测试111 建议动作改成拜访CTO',
|
||||||
|
note: '测试111 建议动作改成拜访CTO',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('没写理由 → 卡上的驳回项;不许自定义时写了理由也走驳回项、理由进 note', () => {
|
||||||
|
expect(redoReply({ id: c1, allowCustom: true }, c, '')).toEqual({ k: 'decision', cardId: c1, value: 'reject' });
|
||||||
|
expect(redoReply({ id: c1, allowCustom: false }, c, '再看看')).toEqual({ k: 'decision', cardId: c1, value: 'reject', note: '再看看' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('既没驳回项又不许自定义 → 交不了(钮该灰着)', () => {
|
||||||
|
const only = reviewChoices([{ value: 'approve', label: '通过' }])!;
|
||||||
|
expect(redoReply({ id: c1, allowCustom: false }, only, '')).toBeUndefined();
|
||||||
|
expect(redoReply({ id: c1, allowCustom: false }, only, '理由')).toBeUndefined();
|
||||||
|
// 允许自定义就有路
|
||||||
|
expect(redoReply({ id: c1, allowCustom: true }, only, '理由')).toMatchObject({ value: 'custom:理由' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('确认通过交 approve 那一项;带上点成「达标」的清单条目', () => {
|
||||||
|
expect(approveReply({ id: c1 }, c)).toEqual({ k: 'decision', cardId: c1, value: 'approve' });
|
||||||
|
expect(approveReply({ id: c1 }, c, ['核对甲'])).toEqual({ k: 'decision', cardId: c1, value: 'approve', checked: ['核对甲'] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checklistMissing —— 清单点全了才能按「确认通过」', () => {
|
||||||
|
it('没点的那几条原样列出来;点全为空;没有清单也为空', () => {
|
||||||
|
expect(checklistMissing(['甲', '乙'], new Set(['甲']))).toEqual(['乙']);
|
||||||
|
expect(checklistMissing(['甲', '乙'], new Set(['甲', '乙']))).toEqual([]);
|
||||||
|
expect(checklistMissing(undefined, new Set())).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -270,7 +270,6 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
|
|||||||
const cur = openList[pos];
|
const cur = openList[pos];
|
||||||
const doneItem = doneSel === null ? undefined : doneList.find((o) => o.id === doneSel);
|
const doneItem = doneSel === null ? undefined : doneList.find((o) => o.id === doneSel);
|
||||||
/** 成果详情里当下摆的是第几份 —— 翻「份」用它 */
|
/** 成果详情里当下摆的是第几份 —— 翻「份」用它 */
|
||||||
const donePos = Math.max(0, doneList.findIndex((o) => o.id === doneSel));
|
|
||||||
/** 这一屏该出哪几组翻页(件 / 页 / 都没有) —— 判断抽在 `pager.ts`,有测 */
|
/** 这一屏该出哪几组翻页(件 / 页 / 都没有) —— 判断抽在 `pager.ts`,有测 */
|
||||||
const face = pagerFace({ items: openList.length, pages: docPages });
|
const face = pagerFace({ items: openList.length, pages: docPages });
|
||||||
|
|
||||||
@@ -301,6 +300,15 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
|
|||||||
setPage(Math.max(0, openList.findIndex((o) => o.id === locate.orderId)));
|
setPage(Math.max(0, openList.findIndex((o) => o.id === locate.orderId)));
|
||||||
setDocPage(0);
|
setDocPage(0);
|
||||||
}
|
}
|
||||||
|
} else if (locate.orderId !== undefined && locate.tab === undefined) {
|
||||||
|
/*
|
||||||
|
* 找不到那张单 —— 多半是审核卡(决策卡穿验收工单的衣服,2026-08-27)上的「验收工单」钮:
|
||||||
|
* 它没有工单行,件却在待处理那一屏(活动按剧本 `view` 推的)。至少把那一屏翻出来,
|
||||||
|
* 别让人点了没反应。
|
||||||
|
*/
|
||||||
|
setOpenFile(undefined);
|
||||||
|
setView('open');
|
||||||
|
setDocPage(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,35 +448,16 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
|
|||||||
/*
|
/*
|
||||||
* 成果详情右上角摆两组翻页 + 返回列表:
|
* 成果详情右上角摆两组翻页 + 返回列表:
|
||||||
* · **翻页**(2026-08-21):同一份带多页的件归了档,翻页的能力不该不见了;
|
* · **翻页**(2026-08-21):同一份带多页的件归了档,翻页的能力不该不见了;
|
||||||
* · **翻份**(2026-08-23 用户:「工作成果点进一个工作成果也需要这个东西」)——
|
* · 08-23 曾在这里加过「翻份」(在几份定稿之间切换),2026-08-27 用户定撤掉:
|
||||||
* 从前点进详情只有「返回列表」:想看下一份得先退出去再点一次,
|
* 右上角那对箭头的意思是**这一份件里翻页**;换一份成果回列表点。
|
||||||
* 而一单常有三五份定稿(执行单快照 / 验收单快照 / 决策卡快照),对着看是常事。
|
|
||||||
* 换份时页码归零 —— 上一份翻到第 3 页,下一份可能只有一页。
|
|
||||||
* 各自只在多于一个时现身;都只有一个就照旧只留「返回列表」。
|
|
||||||
*/
|
*/
|
||||||
<>
|
<>
|
||||||
{doneList.length > 1 && (
|
{/*
|
||||||
<Pager
|
* **右上角只翻页,不翻份**(2026-08-27 用户:「这个东西不是让你在整个工作成果里面切换,
|
||||||
{...(doneDocPages > 1 ? { mark: '份' } : {})}
|
* 是让你在单个工单有多个页面的情况下切换的」)。08-23 加的「翻份」撤掉:换一份成果回列表点,
|
||||||
pos={donePos}
|
* 右上角那对箭头只在这一份件里翻页 —— 与待处理那一屏同一个意思。一页也照旧摆 1/1(定得住)。
|
||||||
total={doneList.length}
|
*/}
|
||||||
what="份成果"
|
<Pager pos={doneDocPage} total={doneDocPages} what="页" onGo={setDoneDocPage} />
|
||||||
onGo={(next) => {
|
|
||||||
const to = doneList[Math.max(0, Math.min(next, doneList.length - 1))];
|
|
||||||
if (to !== undefined) setDoneSel(to.id);
|
|
||||||
setDoneDocPage(0);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{doneDocPages > 1 && (
|
|
||||||
<Pager
|
|
||||||
{...(doneList.length > 1 ? { mark: '页' } : {})}
|
|
||||||
pos={doneDocPage}
|
|
||||||
total={doneDocPages}
|
|
||||||
what="页"
|
|
||||||
onGo={setDoneDocPage}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<button type="button" className="jc-tnav-btn back" onClick={() => setDoneSel(null)}>
|
<button type="button" className="jc-tnav-btn back" onClick={() => setDoneSel(null)}>
|
||||||
<Ico name="chevron-left" s={14} /> 返回列表
|
<Ico name="chevron-left" s={14} /> 返回列表
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import type { FormField, AiTag, ChatCardReply, ChatMessage, WorkOrder, WorkOrder
|
|||||||
import { EVENT_TYPE_LABEL, isRejection } from '@contracts';
|
import { EVENT_TYPE_LABEL, isRejection } from '@contracts';
|
||||||
import { Ico } from '@web-shared/Ico.tsx';
|
import { Ico } from '@web-shared/Ico.tsx';
|
||||||
import { Btn } from '@web-shared/Btn.tsx';
|
import { Btn } from '@web-shared/Btn.tsx';
|
||||||
|
import { approveReply, checklistMissing, redoReply, reviewChoices } from './review-face.ts';
|
||||||
|
import type { DecisionMessage } from './review-face.ts';
|
||||||
import { ChatAvatar } from '@web-shared/ChatAvatar.tsx';
|
import { ChatAvatar } from '@web-shared/ChatAvatar.tsx';
|
||||||
import { atClock } from '@web-shared/at.ts';
|
import { atClock } from '@web-shared/at.ts';
|
||||||
|
|
||||||
@@ -506,14 +508,20 @@ function Msg({
|
|||||||
<p className="jc-content">{m.ask}</p>
|
<p className="jc-content">{m.ask}</p>
|
||||||
{/*
|
{/*
|
||||||
* 审核卡(契约 `variant:'review'`)摆成验收工单的样子(2026-08-27 用户:「验收工单弹出的是
|
* 审核卡(契约 `variant:'review'`)摆成验收工单的样子(2026-08-27 用户:「验收工单弹出的是
|
||||||
* 需要你定,明显不对」):平台的验收工单是终局,中间审核只能靠决策卡循环 —— 面孔换成
|
* 需要你定,明显不对」):平台的验收工单是终局,中间审核只能靠决策卡循环 —— 机制不变,面孔换。
|
||||||
* 「工单 · 验收」,选项就是通过 / 驳回,机制不变。
|
* **面孔要与终局验收卡一模一样**(同日用户第二次指出:「验收卡怎么有两种样式」):
|
||||||
|
* 第一版只换了头上的字、身子还是 A/B 选项列表 —— 人扫两张「验收」卡,落点不一致。
|
||||||
|
* 现在照 `AcceptForm` 那一套摆:验收工单入口 + 理由框 + [重做] … [确认通过](见 `ReviewForm`);
|
||||||
|
* 选项对不出面孔(一个选项都没有)才退回普通决策卡。
|
||||||
*/}
|
*/}
|
||||||
<div className={'jc-fc ' + (m.variant === 'review' ? 't-acc' : 't-dec')}>
|
{m.variant === 'review' && reviewChoices(m.options) !== undefined ? (
|
||||||
|
<ReviewForm m={m} onReplyCard={onReplyCard} onLocate={onLocate} chosenLabel={chosenLabel} />
|
||||||
|
) : (
|
||||||
|
<div className="jc-fc t-dec">
|
||||||
<div className="jc-fc-h">
|
<div className="jc-fc-h">
|
||||||
<Ico name={m.variant === 'review' ? 'shield-check' : 'git-branch'} s={13} />
|
<Ico name="git-branch" s={13} />
|
||||||
<b>{m.variant === 'review' ? '工单 · 验收' : '需要你定'}</b>
|
<b>需要你定</b>
|
||||||
<em>{m.variant === 'review' ? '审核' : `${m.options.length + (m.allowCustom ? 1 : 0)} 选 1`}</em>
|
<em>{`${m.options.length + (m.allowCustom ? 1 : 0)} 选 1`}</em>
|
||||||
</div>
|
</div>
|
||||||
<div className="jc-fc-b">
|
<div className="jc-fc-b">
|
||||||
<Kv items={m.basis} />
|
<Kv items={m.basis} />
|
||||||
@@ -567,12 +575,176 @@ function Msg({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Ag>
|
</Ag>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 审核卡的面孔 = 终局验收卡的面孔(`AcceptForm`):同一个头(请你验收)、同一个入口钮(验收工单 →
|
||||||
|
* 工作区那份件)、同一个理由框、同一对钮(重做 … 确认通过)。差别只在交出去的是决策卡的回传:
|
||||||
|
* 通过 = approve 那一项;重做 = 写了理由走 `custom:<理由>`(基座按字面点名对行),没写走驳回项。
|
||||||
|
* 判断在 `review-face.ts`(纯函数,有测);这里只摆。
|
||||||
|
*/
|
||||||
|
function ReviewForm({
|
||||||
|
m,
|
||||||
|
onReplyCard,
|
||||||
|
onLocate,
|
||||||
|
chosenLabel,
|
||||||
|
}: {
|
||||||
|
m: DecisionMessage;
|
||||||
|
onReplyCard: (reply: ChatCardReply) => void;
|
||||||
|
onLocate: (id: string) => void;
|
||||||
|
chosenLabel: string;
|
||||||
|
}) {
|
||||||
|
const busy = useReplyBusy();
|
||||||
|
const [why, setWhy] = useState('');
|
||||||
|
const choices = reviewChoices(m.options)!;
|
||||||
|
const redo = redoReply(m, choices, why);
|
||||||
|
const decided = m.chosen !== undefined;
|
||||||
|
const approved = decided && m.chosen?.value === choices.approve.value;
|
||||||
|
/*
|
||||||
|
* 判据那一摞与终局验收卡**同一摞**(服务端读时现算、L5 不上卡;见 `decisionCardSchema.criteria`)——
|
||||||
|
* 卡上没有「参考信息」这一段(2026-08-27 用户:「验收卡的样式和结构,没有参考信息这个东西,变成 L1、L2」)。
|
||||||
|
* 答过的卡服务端不再带判据,那一栏就不摆。
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
* 人工验收清单(与终局验收卡同一件事):一条一枚「达标 / 不达标」开关,全部达标才让按「确认通过」。
|
||||||
|
* 答过的卡摆当时点成达标的那几条(`chosen.checked`),只读。
|
||||||
|
*/
|
||||||
|
const checklist = m.checklist ?? [];
|
||||||
|
const [checked, setChecked] = useState<ReadonlySet<string>>(() => new Set());
|
||||||
|
const toggle = (item: string) =>
|
||||||
|
setChecked((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(item)) next.delete(item);
|
||||||
|
else next.add(item);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
const shown = decided ? new Set(m.chosen?.checked ?? []) : checked;
|
||||||
|
const unchecked = checklistMissing(checklist, shown);
|
||||||
|
/*
|
||||||
|
* 答过之后卡上说的是**验收的话**,不是选项的原标签(2026-08-27 用户实测:写了重做意见,
|
||||||
|
* 卡上却是「你选了:驳回(不写具体意见)」—— 那是模型给选项起的名,人看见的应是他自己写的意见)。
|
||||||
|
*/
|
||||||
|
const verdictText = ((): string => {
|
||||||
|
const c = m.chosen;
|
||||||
|
if (c === undefined) return chosenLabel;
|
||||||
|
if (c.value === choices.approve.value) return '确认通过';
|
||||||
|
const note = (c.note ?? '').trim();
|
||||||
|
if (c.value.startsWith('custom:')) return `重做:${note !== '' ? note : c.value.slice(7)}`;
|
||||||
|
return note !== '' ? `重做:${note}` : '重做(未写意见)';
|
||||||
|
})();
|
||||||
|
return (
|
||||||
|
<div className="jc-fc t-acc">
|
||||||
|
<div className="jc-fc-h">
|
||||||
|
<Ico name="shield-check" s={13} />
|
||||||
|
<b>请你验收</b>
|
||||||
|
{decided ? (
|
||||||
|
<em className={approved ? 'ok' : 'bad'}>{approved ? '已通过' : '已退回'}</em>
|
||||||
|
) : checklist.length > 0 ? (
|
||||||
|
<em className={unchecked.length === 0 ? 'ok' : 'bad'}>
|
||||||
|
已确认 {checklist.length - unchecked.length}/{checklist.length}
|
||||||
|
</em>
|
||||||
|
) : (
|
||||||
|
<em>审核</em>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="jc-fc-b">
|
||||||
|
{/* 与终局验收卡同一个入口:件在工作区(这一步推的那一屏),点它就跳过去 */}
|
||||||
|
<button className="jc-fc-doc" onClick={() => onLocate(m.id)}>
|
||||||
|
<span className="jc-fc-doc-ico">
|
||||||
|
<Ico name="file-text" s={15} />
|
||||||
|
</span>
|
||||||
|
<span className="jc-fc-doc-bd">
|
||||||
|
<b>验收工单</b>
|
||||||
|
<em>成果报告在工作区,可读可就地改</em>
|
||||||
|
</span>
|
||||||
|
<Ico name="chevron-right" s={14} />
|
||||||
|
</button>
|
||||||
|
{checklist.length > 0 && <Checklist items={checklist} checked={shown} onToggle={toggle} readonly={decided} />}
|
||||||
|
{decided ? (
|
||||||
|
<p className="jc-chosen">
|
||||||
|
<Ico name={approved ? 'circle-check' : 'circle-alert'} s={14} /> 你的裁决:
|
||||||
|
<b>{verdictText}</b>
|
||||||
|
<code className="jc-mono">{hm(m.chosen?.at ?? m.at)}</code>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<textarea
|
||||||
|
className="jc-note"
|
||||||
|
placeholder="要重做?写明原因(也可以在下面对话里聊)"
|
||||||
|
value={why}
|
||||||
|
onChange={(e) => setWhy(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!decided && (
|
||||||
|
<div className="jc-fc-ft">
|
||||||
|
<Btn className="jc-btn jc-btn-bad" disabled={redo === undefined} busy={busy} onClick={() => redo !== undefined && onReplyCard(redo)}>
|
||||||
|
重做
|
||||||
|
</Btn>
|
||||||
|
<span className="jc-fc-sp" />
|
||||||
|
<Btn
|
||||||
|
className="jc-btn jc-btn-ok"
|
||||||
|
busy={busy}
|
||||||
|
disabled={unchecked.length > 0}
|
||||||
|
title={unchecked.length > 0 ? `还有 ${unchecked.length} 条待确认` : undefined}
|
||||||
|
onClick={() => onReplyCard(approveReply(m, choices, checklist.length === 0 ? undefined : [...checked]))}
|
||||||
|
>
|
||||||
|
<Ico name="circle-check" s={14} /> 确认通过
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* 参考信息:表单卡内的结构化 kv 表 */
|
/* 参考信息:表单卡内的结构化 kv 表 */
|
||||||
|
/*
|
||||||
|
* **人工验收清单**(2026-08-27 用户定):人核这份工单要走的步骤,每条一枚「达标 / 不达标」开关。
|
||||||
|
* 版面与判据表同一套(`jc-rp-crit`),差别只在右边那一格是人点的,不是系统判的。
|
||||||
|
*/
|
||||||
|
function Checklist({
|
||||||
|
items,
|
||||||
|
checked,
|
||||||
|
onToggle,
|
||||||
|
readonly = false,
|
||||||
|
}: {
|
||||||
|
items: ReadonlyArray<string>;
|
||||||
|
checked: ReadonlySet<string>;
|
||||||
|
onToggle?: (item: string) => void;
|
||||||
|
readonly?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ul className="jc-rp-crit sm jc-checklist">
|
||||||
|
{items.map((item, i) => {
|
||||||
|
const ok = checked.has(item);
|
||||||
|
// 两态:待确认(灰,默认)↔ 已确认(绿)—— 2026-08-27 用户定;不叫「不达标」,没点过不是判了不合格
|
||||||
|
const face = (
|
||||||
|
<>
|
||||||
|
<Ico name={ok ? 'circle-check' : 'circle'} s={12} />
|
||||||
|
{ok ? '已确认' : '待确认'}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<li key={i} className={ok ? 'ok' : 'pend'}>
|
||||||
|
<span className="jc-ck-n">{i + 1}</span>
|
||||||
|
<span className="t">{item}</span>
|
||||||
|
{readonly ? (
|
||||||
|
<span className="r">{face}</span>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="r jc-tg" aria-pressed={ok} onClick={() => onToggle?.(item)}>
|
||||||
|
{face}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Kv({ items }: { items: ReadonlyArray<{ k: string; v: string }> }) {
|
function Kv({ items }: { items: ReadonlyArray<{ k: string; v: string }> }) {
|
||||||
return (
|
return (
|
||||||
<div className="jc-fc-kv">
|
<div className="jc-fc-kv">
|
||||||
@@ -959,15 +1131,36 @@ function AcceptForm({
|
|||||||
const [port, setPort] = useState<string | undefined>(undefined);
|
const [port, setPort] = useState<string | undefined>(undefined);
|
||||||
const choice = order.portChoice;
|
const choice = order.portChoice;
|
||||||
const needChoice = choice !== undefined;
|
const needChoice = choice !== undefined;
|
||||||
|
/*
|
||||||
|
* **人工验收清单**(2026-08-27 用户定):卡上的条目是人核这份工单要走的步骤,不是系统判的判据。
|
||||||
|
* 一条一枚「达标 / 不达标」开关,全部点成达标才让按「确认通过」;点过的条目随回传留痕(`checked`)。
|
||||||
|
* 没声明清单的老卡退回系统判据那一摞。
|
||||||
|
*/
|
||||||
|
const checklist = order.checklist ?? [];
|
||||||
|
const [checked, setChecked] = useState<ReadonlySet<string>>(() => new Set());
|
||||||
|
const toggle = (item: string) =>
|
||||||
|
setChecked((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(item)) next.delete(item);
|
||||||
|
else next.add(item);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
const unchecked = checklistMissing(checklist, checked);
|
||||||
const passN = order.criteria.filter((c) => c.pass).length;
|
const passN = order.criteria.filter((c) => c.pass).length;
|
||||||
return (
|
return (
|
||||||
<div className="jc-fc t-acc">
|
<div className="jc-fc t-acc">
|
||||||
<div className="jc-fc-h">
|
<div className="jc-fc-h">
|
||||||
<Ico name="shield-check" s={13} />
|
<Ico name="shield-check" s={13} />
|
||||||
<b>请你验收</b>
|
<b>请你验收</b>
|
||||||
<em className={passN === order.criteria.length ? 'ok' : 'bad'}>
|
{checklist.length > 0 ? (
|
||||||
{passN}/{order.criteria.length} 达标
|
<em className={unchecked.length === 0 ? 'ok' : 'bad'}>
|
||||||
</em>
|
已确认 {checklist.length - unchecked.length}/{checklist.length}
|
||||||
|
</em>
|
||||||
|
) : (
|
||||||
|
<em className={passN === order.criteria.length ? 'ok' : 'bad'}>
|
||||||
|
{passN}/{order.criteria.length} 达标
|
||||||
|
</em>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="jc-fc-b">
|
<div className="jc-fc-b">
|
||||||
<button className="jc-fc-doc" onClick={() => onLocate(order.id)}>
|
<button className="jc-fc-doc" onClick={() => onLocate(order.id)}>
|
||||||
@@ -981,8 +1174,8 @@ function AcceptForm({
|
|||||||
</span>
|
</span>
|
||||||
<Ico name="chevron-right" s={14} />
|
<Ico name="chevron-right" s={14} />
|
||||||
</button>
|
</button>
|
||||||
{/* 与办结卡同一份 —— 两处各写一遍,三态迟早只在一处生效 */}
|
{/* 有人工验收清单就摆清单(人点);没有的老卡退回系统判据 —— 与办结卡同一份 */}
|
||||||
<Crit list={order.criteria} />
|
{checklist.length > 0 ? <Checklist items={checklist} checked={checked} onToggle={toggle} /> : <Crit list={order.criteria} />}
|
||||||
{order.actions !== undefined && order.actions.length > 0 && (
|
{order.actions !== undefined && order.actions.length > 0 && (
|
||||||
<Kv
|
<Kv
|
||||||
items={order.actions.map((a) => ({
|
items={order.actions.map((a) => ({
|
||||||
@@ -1066,9 +1259,15 @@ function AcceptForm({
|
|||||||
<span className="jc-fc-sp" />
|
<span className="jc-fc-sp" />
|
||||||
<Btn
|
<Btn
|
||||||
className="jc-btn jc-btn-ok"
|
className="jc-btn jc-btn-ok"
|
||||||
disabled={needChoice && port === undefined}
|
disabled={(needChoice && port === undefined) || unchecked.length > 0}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
title={needChoice && port === undefined ? `先在上面选一个「${choice.group}」` : undefined}
|
title={
|
||||||
|
unchecked.length > 0
|
||||||
|
? `还有 ${unchecked.length} 条待确认`
|
||||||
|
: needChoice && port === undefined
|
||||||
|
? `先在上面选一个「${choice.group}」`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onReplyOrder({
|
onReplyOrder({
|
||||||
mode: 'acceptance',
|
mode: 'acceptance',
|
||||||
@@ -1076,6 +1275,8 @@ function AcceptForm({
|
|||||||
verdict: 'pass',
|
verdict: 'pass',
|
||||||
// 人在报告上改过的值随放行落地;一个字没改就是空的(= 全盘采信 AI 拟值)
|
// 人在报告上改过的值随放行落地;一个字没改就是空的(= 全盘采信 AI 拟值)
|
||||||
values: { ...(orderValues ?? {}) },
|
values: { ...(orderValues ?? {}) },
|
||||||
|
// 人工验收清单里点成「达标」的条目随回传留痕;服务端按它把关
|
||||||
|
...(checklist.length === 0 ? {} : { checked: [...checked] }),
|
||||||
/*
|
/*
|
||||||
* 恰选一口 —— **算出来的那一口也要交回去**。
|
* 恰选一口 —— **算出来的那一口也要交回去**。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* 审核卡(决策卡的 `variant:'review'`)穿验收工单的衣服 —— 两颗钮对应哪两个选项、
|
||||||
|
* 「重做」按下去交什么(2026-08-27 用户:「验收卡怎么有两种样式,统一都使用评审放行与
|
||||||
|
* 落账确认这一步派出的这个验收卡的样式」)。
|
||||||
|
*
|
||||||
|
* 机制不变:它仍是一张决策卡,回传仍是 `{k:'decision', value}`。变的只是面孔 ——
|
||||||
|
* 与终局验收卡同一套:「验收工单」入口 + 理由框 + [重做] … [确认通过]。
|
||||||
|
* 纯函数,零 React:选项怎么对到钮上是个判断,判断要能钉测。
|
||||||
|
*/
|
||||||
|
import type { ChatCardReply, ChatMessage } from '@contracts';
|
||||||
|
|
||||||
|
export type DecisionMessage = Extract<ChatMessage, { k: 'decision' }>;
|
||||||
|
type Option = DecisionMessage['options'][number];
|
||||||
|
|
||||||
|
export type ReviewChoices = {
|
||||||
|
/** 「确认通过」交的选项 */
|
||||||
|
readonly approve: Option;
|
||||||
|
/** 「重做」交的选项(理由框空着时);卡上没有这一项就只能带理由走 custom */
|
||||||
|
readonly reject: Option | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选项对到钮上:值叫 `approve` / `reject` 的优先(说明书里三个 value 只有这两种加 custom);
|
||||||
|
* 没按这个名起的,退回「推荐的那个 = 通过,其余第一个 = 驳回」。
|
||||||
|
* 一个选项都没有的卡对不出面孔 —— 回 undefined,调用方退回普通决策卡的样子。
|
||||||
|
*/
|
||||||
|
export function reviewChoices(options: ReadonlyArray<Option>): ReviewChoices | undefined {
|
||||||
|
if (options.length === 0) return undefined;
|
||||||
|
const approve = options.find((o) => o.value === 'approve') ?? options.find((o) => o.recommended === true) ?? options[0]!;
|
||||||
|
const reject = options.find((o) => o.value === 'reject') ?? options.find((o) => o !== approve);
|
||||||
|
return { approve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「重做」交什么:写了理由且卡允许自定义 → `custom:<理由>`(基座按字面点名对行,见 note-anchors);
|
||||||
|
* 没写理由 → 卡上的驳回项(「驳回(不写具体意见)」正是为它留的);
|
||||||
|
* 两样都没有 → 交不了(钮该灰着)。
|
||||||
|
*/
|
||||||
|
export function redoReply(m: Pick<DecisionMessage, 'id' | 'allowCustom'>, choices: ReviewChoices, why: string): ChatCardReply | undefined {
|
||||||
|
const txt = why.trim();
|
||||||
|
if (txt !== '' && m.allowCustom) return { k: 'decision', cardId: m.id, value: `custom:${txt}`, note: txt };
|
||||||
|
if (choices.reject !== undefined) return { k: 'decision', cardId: m.id, value: choices.reject.value, ...(txt === '' ? {} : { note: txt }) };
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「确认通过」交什么 —— 带上人工验收清单里点成「达标」的那几条(服务端按它把关) */
|
||||||
|
export function approveReply(m: Pick<DecisionMessage, 'id'>, choices: ReviewChoices, checked?: ReadonlyArray<string>): ChatCardReply {
|
||||||
|
return { k: 'decision', cardId: m.id, value: choices.approve.value, ...(checked === undefined ? {} : { checked: [...checked] }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人工验收清单还有哪几条没点成「达标」—— 空 = 可以按「确认通过」。
|
||||||
|
* 与服务端 `checklistMissing` 同一口径(2026-08-27 用户定:只有人把这些判据都点成达标了,才能点击确认通过)。
|
||||||
|
*/
|
||||||
|
export function checklistMissing(checklist: ReadonlyArray<string> | undefined, checked: ReadonlySet<string>): string[] {
|
||||||
|
return (checklist ?? []).filter((item) => !checked.has(item));
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- 人读实例号 INS-<seq>(运营中心 · 活动区,2026-08-27):id 仍是寻址键,seq 只给人看与模糊搜。
|
||||||
|
-- identity 列对存量行自动补号;Drizzle 的 taskTable 不声明它(与 land_master / advancing_until 同款:只在迁移与 raw SQL 里存在)。
|
||||||
|
alter table sys.task add column if not exists seq bigint generated by default as identity;
|
||||||
|
create unique index if not exists task_seq_key on sys.task (seq);
|
||||||
|
-- L2 实例清单按活动取、L1 按活动数实例 —— 从前只有按负责人的索引
|
||||||
|
create index if not exists task_activity_idx on sys.task (activity_id, started_at desc);
|
||||||
@@ -239,6 +239,20 @@
|
|||||||
"when": 1785130120181,
|
"when": 1785130120181,
|
||||||
"tag": "0033_mail",
|
"tag": "0033_mail",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 34,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785130120182,
|
||||||
|
"tag": "0034_task_seq",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 35,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785130120183,
|
||||||
|
"tag": "0035_activity_ops",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,7 @@
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `data-slot="键"` | 任意元素 | 取值填为文本。数值整数原样、小数截 4 位;数组给长度 |
|
| `data-slot="键"` | 任意元素 | 取值填为文本。数值整数原样、小数截 4 位;数组给长度 |
|
||||||
| `data-rows="mine:域.对象"` | 行容器(tbody 等) | 按本单草稿行逐行复制内部结构;行内 `data-slot` 相对该行取值;序号交 CSS 计数器 |
|
| `data-rows="mine:域.对象"` | 行容器(tbody 等) | 按本单草稿行逐行复制内部结构;行内 `data-slot` 相对该行取值;序号交 CSS 计数器 |
|
||||||
|
| `data-by="子列"` / `data-by="子列=父列"` | 与 `data-rows` 同元素(**嵌套**在另一个行模版里) | 只留子列值 = 外层那一行父列值的行 —— 「一个候选一张表」(外层按候选复制 `<section>`,里层只列它名下的明细);也能当查表用(`<span data-rows="parts.profile" data-by="internal_pn=candidate_internal_pn">` 从整表里挑这一颗料那一行摆品牌)。父值缺席一行不留。行模版可以嵌套:里层先按本行展开,再填本行标量;**外层容器的标签名不得在行模版内部再出现**(用 `<article>` 包 `<section>`) |
|
||||||
| `data-blank` | 与 `data-slot` 同元素 | 空值**真空**(不写「无」)—— 空格子的胶囊、占位交给 CSS `:empty::after` |
|
| `data-blank` | 与 `data-slot` 同元素 | 空值**真空**(不写「无」)—— 空格子的胶囊、占位交给 CSS `:empty::after` |
|
||||||
| `data-class-of="键"` | 任意元素 | 把值消毒成 `v-<小写值>` 追加进 class(数值就是 `v-0`/`v-1`/…)。**判断留在样式表** |
|
| `data-class-of="键"` | 任意元素 | 把值消毒成 `v-<小写值>` 追加进 class(数值就是 `v-0`/`v-1`/…)。**判断留在样式表** |
|
||||||
| `data-col="台账列名"` | 可改格子(与 `data-slot` 同元素) | 渲染时多渲一个孪生 `<input class="jc-cell">`;写的是**台账列名**不是表头文字(表头改说法不该让写回错位) |
|
| `data-col="台账列名"` | 可改格子(与 `data-slot` 同元素) | 渲染时多渲一个孪生 `<input class="jc-cell">`;写的是**台账列名**不是表头文字(表头改说法不该让写回错位) |
|
||||||
@@ -140,6 +141,34 @@ routeTo / routeWhy` = 引擎注入的案情与算出的去向。
|
|||||||
数值列**与表头同侧左对齐**(右对齐是给要竖着相加的数用的;一行一个料、彼此不相加的表,
|
数值列**与表头同侧左对齐**(右对齐是给要竖着相加的数用的;一行一个料、彼此不相加的表,
|
||||||
右对齐只让数和自己的标题分家),等宽字体留着。
|
右对齐只让数和自己的标题分家),等宽字体留着。
|
||||||
|
|
||||||
|
### 4.1 运营中心沙箱看板:`viewTemplates[].data` 取数声明(2026-08-27)
|
||||||
|
|
||||||
|
工单件的行由引擎按**本单草稿层**给(`mine:`);运营中心活动详情底部那张沙箱看板摆的是**最近一次已放行实例**
|
||||||
|
的落账快照 —— 草稿层放行后已清空,行只能按案号从**正式表**圈。「哪几行是这一单的」是活动自己的知识
|
||||||
|
(案号前缀、哪张表按哪一列圈),所以写在模版旁边的 `data` 里,基座只照它查:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{ name: 'activity-ledger', html: LEDGER_BOARD, data: {
|
||||||
|
case: { ledger: 'sub_eval.verdict', key: 'eval_ref', prefix: 'W05-' }, // 案号 = prefix + 开案时刻 14 位数字(与模型铸主键那串同源)
|
||||||
|
rows: [
|
||||||
|
{ 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: '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' } },
|
||||||
|
] } }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `rows` **按序**求值:`by.column ∈ values(by.from)`,`from` 只能是 `case` 或**前面**某个 `<as>.<列>`;每个值单独一次
|
||||||
|
等值读(正式表里历次运行的行并排躺着),结果按 `from` 值的顺序拼接 —— 想让原 PN 排第一,就让核对表按 `role desc` 排;
|
||||||
|
`not` 排除一批(同料号历史 = 按料号圈的减去本单挂的);同一张表圈两批就另起 `as`。
|
||||||
|
- 作用域:`<as>` 行数组(`data-rows="<as>"`,嵌套行照旧 `data-by`)、`<as>.<列>` 首行标量(一单一行的 verdict / demand
|
||||||
|
直接 `data-slot="demand.request.request_id"`)、`<as 去符号>_rows` 行数、`<as 去符号>_<列>_<值>` tally 计数(先铺 0)、
|
||||||
|
`case:instanceNo / openedAt / releasedAt / owner / ref`、`board:state`(`ready | missing | none`,挂在 `.page` 上
|
||||||
|
`data-class-of` 投成 `v-ready` 等,横幅与区块的显隐交样式表)。
|
||||||
|
- 表未物化 / 查询失败:那一块空着、stderr 一行,整屏不 500;案号那一行查无 → `missing`;没放行过 → `none`。
|
||||||
|
- 看板**只读**:没有 `data-edit` / `data-col`;仍不算不判(MATCH 行淡显、放弃的候选只在有 abandoned 行时露面,
|
||||||
|
都是 `data-class-of` 投出的 class 交样式表;计数摆基座读数)。渲染在 `apps/api/src/app/activity-ops/view-scope.ts`。
|
||||||
|
|
||||||
## 5. 模版不许算,但基座算过的可以摆
|
## 5. 模版不许算,但基座算过的可以摆
|
||||||
|
|
||||||
两件事别混:
|
两件事别混:
|
||||||
|
|||||||
+37
-1
@@ -1245,7 +1245,11 @@ pnpm 硬链到全局 store,真实占盘远小于这个数,但 `du` 看着吓人
|
|||||||
要么在拒回里明说「本平台没有弃用计划项这回事,不需要办的项什么都不用做」。
|
要么在拒回里明说「本平台没有弃用计划项这回事,不需要办的项什么都不用做」。
|
||||||
**现状**:活动侧全包清了 `disable`,并在哨兵上加了「真有料号不在库」才开门。
|
**现状**:活动侧全包清了 `disable`,并在哨兵上加了「真有料号不在库」才开门。
|
||||||
|
|
||||||
- **前一项不收掉,后一项的件就推不出来**:引擎挑画面按「当下还没干完的第一步」取 view
|
- ~~**前一项不收掉,后一项的件就推不出来**~~(2026-08-27 晚销账:用户送来 3ef3fc3e 当场复现 —— 模型没 finish
|
||||||
|
S0d_p08 就发了 P-10 的卡,件停在 P-08。现在**画面跟着这一拍发出去的卡走**:human / entry / decide 把
|
||||||
|
「发给了哪一步」交回拍内,那一步声明了画面就推它,压过「第一步没干完的」;有测。**根治**(同夜,用户:「不要只修表象」):
|
||||||
|
人工激活的项在人答过卡之后照常求 doneWhen(拍首拍尾各一遍),W05 两张数据工单的 doneWhen 只读入库事实,
|
||||||
|
S1 哨兵不再等模型写 created 旗 —— 上一步办完与否不再靠模型记得 finish。):引擎挑画面按「当下还没干完的第一步」取 view
|
||||||
(`turn-runner.ts` 2058-2064 附近)。模型把「人交回 → 发下一张卡」压成一拍、忘了 `finish`
|
(`turn-runner.ts` 2058-2064 附近)。模型把「人交回 → 发下一张卡」压成一拍、忘了 `finish`
|
||||||
上一项时,卡是新的、件还是旧的 —— **人拿到的卡写着「把资料填到件上」,屏上一个格子都没有**,
|
上一项时,卡是新的、件还是旧的 —— **人拿到的卡写着「把资料填到件上」,屏上一个格子都没有**,
|
||||||
提交闸如实拦死,整单挂在那儿。E04 的 S4→S5 撞过同一堵墙(2026-08-23 已按 `currentRuntime`
|
提交闸如实拦死,整单挂在那儿。E04 的 S4→S5 撞过同一堵墙(2026-08-23 已按 `currentRuntime`
|
||||||
@@ -1272,3 +1276,35 @@ pnpm 硬链到全局 store,真实占盘远小于这个数,但 `du` 看着吓人
|
|||||||
真治法待议:若一张必交付台账本单一行都没写,闸该拦并说「这张表还没有内容」;
|
真治法待议:若一张必交付台账本单一行都没写,闸该拦并说「这张表还没有内容」;
|
||||||
但也有活动是让人在件上加第一行的(那时零行合法)—— 得由活动声明区分。
|
但也有活动是让人在件上加第一行的(那时零行合法)—— 得由活动声明区分。
|
||||||
**现状**:活动侧把「发卡前清单必须先有行」写进说明书(每拍都在),配测试。
|
**现状**:活动侧把「发卡前清单必须先有行」写进说明书(每拍都在),配测试。
|
||||||
|
|
||||||
|
## 运营中心 · 活动模块接口(2026-08-27 落地时记下的)
|
||||||
|
|
||||||
|
- **实例清单的过滤 / 分页在内存里做**:`listInstances` 全量取该活动的 `sys.task`,非终态行逐单读记忆与
|
||||||
|
loop 运行态判驳回,再在内存里筛、数、切页。MVP 量级(一个活动几十到几百单)够用;
|
||||||
|
上千单后该把状态落成一列(或物化视图),过滤下推到 SQL。
|
||||||
|
- **`sys.activity_profile` 只有 seed 一个写口**:负责人 / 说明 / 图标 / 色相由 `seed-activities.ts` 的
|
||||||
|
`ACTIVITY_PROFILES` 按包填,负责人按岗位取第一个在职的人。运营面的改口(改负责人、改说明)
|
||||||
|
等「创建活动」功能一起定。
|
||||||
|
- **`format: 'file'` 的启动字段不上表单**:`launch-params` 直接省略它们(W05 的 datasheets 走数据工单)。
|
||||||
|
文档 §4 的控件种类里没有文件控件;要上得前后端一起加。
|
||||||
|
- **禁用只拦运营端启动**:`POST /api/gov/activities/:id/instances` 看 `enabled`;触发器 / 工作流 / 测试台起单
|
||||||
|
都不看它(`task.start` 里没有这道门)。文档 §0 的「禁用只拦新启动」目前就是这一个口。
|
||||||
|
- **combo 候选内联、封顶 500 行**:`x-options` 指的表按 label 升序取前 500;客户 / 料号台账更大时要走检索式候选
|
||||||
|
(文档 §4 预留的 `optionsSource`)。
|
||||||
|
- **E04 的「新客户」入参形状启动不了**:`customers` 数组元素可以是对象(新客户档案),启动表单只收字符串数组。
|
||||||
|
运营端要起新客户那条路,得给 list 控件一个对象形态或另开录入。
|
||||||
|
|
||||||
|
|
||||||
|
## 运营中心 · 活动详情沙箱看板(2026-08-27 接数据时记下的)
|
||||||
|
|
||||||
|
- **只摆最近一次已放行的实例,挑不了单**:用户定 MVP 先这样。要看别的单 / 在办中的单,`opsActivityViewRoute` 得加
|
||||||
|
`?instanceId=`(在办的单要走 `taskView` 连草稿层一起圈,`missing` 那一态也会多一种说法)。
|
||||||
|
- **行级来源标记只有 P-08 有**:P-08 借 `sub_eval.pn_check.exists_status`(found = 在库直读 / created = 本单建档)说了
|
||||||
|
每颗料是不是这一单建的;P-10 / P-06 只按「本单挂的 / 同料号历史」分两个 `<tbody>`,P-10 分不出哪份资料是本单
|
||||||
|
数据工单建的。要分,得在放行留痕里记主键名单(`ledger-write` 的 detail 现在只有表与行数)。
|
||||||
|
- **`vtok` / `badgeCss` 三处各一份**(`report.ts` / `views/badge.ts` / `activity.test.ts`)——都注明「必须与 `classToken`
|
||||||
|
一字不差」;该抽进 `@sdk` 一处,包内测试改 import。report.ts 当时另一会话在改,没动它。
|
||||||
|
- **`viewTemplates[].data` 没有装包期静态审**:`from` 指到没声明的表 / 后面的集合、`by.column` 不是那张表的列,
|
||||||
|
现在只有 W05 包内测试钉着 + 运行时「那一块空着 + stderr 点名」。该并进 `static-audit`(与 `refersTo` 目标核对同款)。
|
||||||
|
- **`docs/数据域字段说明-W05.md` §二 / §十二.4 的参数集口径过时**:仍写 `normalized_parameter_set` 是 text 列「不建子表」,
|
||||||
|
而清单早已拆成子表 `parts.parameter`(库里那一列全 null)。一览表也少 `parts.parameter` 一行。
|
||||||
|
|||||||
+30
-7
@@ -82,7 +82,7 @@ activities/<id>/src/ ──(pnpm activities:build)──> dist/{manifest.json,in
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `declarations.ledger` | 运行时**只准写**这里声明的地址与列名;**人从件上改回来的值也走同一道门**(§3.1) | 编出来的列当场拒;未声明地址的保存一格不落 |
|
| `declarations.ledger` | 运行时**只准写**这里声明的地址与列名;**人从件上改回来的值也走同一道门**(§3.1) | 编出来的列当场拒;未声明地址的保存一格不落 |
|
||||||
| `declarations.tools` | Agent 可调的工具名单 | 未白名单授权报错(越权不转人) |
|
| `declarations.tools` | Agent 可调的工具名单 | 未白名单授权报错(越权不转人) |
|
||||||
| `assets.viewTemplates` | 工作区能推的画面 | 模版名对不上,「换画面失败」留痕、屏上不动 |
|
| `assets.viewTemplates` | 工作区能推的画面;带 `data` 的那张(`activity-ledger`)是运营中心沙箱看板,`data` 定它按案号从正式表圈哪几张表的行(《HTML报告生成规范》§4.1) | 模版名对不上,「换画面失败」留痕、屏上不动;`data` 指了没声明的表或后面的集合 → 那一块空着、stderr 点名 |
|
||||||
|
|
||||||
静态审还核:**谓词里的名字必须是声明过的台账列或基座读数**(`域_对象_rows` /
|
静态审还核:**谓词里的名字必须是声明过的台账列或基座读数**(`域_对象_rows` /
|
||||||
`_missing` / `_gaps` / `_<列>_<值>` 这些基座读数在审的白名单里)。
|
`_missing` / `_gaps` / `_<列>_<值>` 这些基座读数在审的白名单里)。
|
||||||
@@ -112,6 +112,19 @@ activities/<id>/src/ ──(pnpm activities:build)──> dist/{manifest.json,in
|
|||||||
读己之写,不抛事件)/ `releaseWrite`(放行合并,此刻抛事件)。三个方法不是一个带 flag 的方法。
|
读己之写,不抛事件)/ `releaseWrite`(放行合并,此刻抛事件)。三个方法不是一个带 flag 的方法。
|
||||||
- **`draftDrop`**:撤掉本单草稿层的一行 —— 不是第四种写(正式版碰不到、不抛事件)。
|
- **`draftDrop`**:撤掉本单草稿层的一行 —— 不是第四种写(正式版碰不到、不抛事件)。
|
||||||
它存在的理由:门拦住一件办错的事却不给改正的手段,那扇门就只是个死胡同。
|
它存在的理由:门拦住一件办错的事却不给改正的手段,那扇门就只是个死胡同。
|
||||||
|
- **验收卡上的条目是人工验收清单,不是系统判据**(2026-08-27 用户定):派验收卡的那一步(终局 `submit`
|
||||||
|
的 T_release,或 `cardVariant:'review'` 的审核步)在剧本上声明 `acceptanceChecklist: string[]` —— 人拿着这份
|
||||||
|
工单要亲自核的步骤,一条一句可核对的动作,**每份验收工单固定**;引擎发卡时带上,卡上一条一枚「达标 / 不达标」
|
||||||
|
开关,全部达标才能「确认通过」(服务端同样把关)。系统判的 `acceptanceCriteria` 照旧把提交门,不再摆到卡上让人签。
|
||||||
|
写清单的口径:先看这份件人要看什么(汇总表 / 明细表 / 落库预览),每张表一条「核对什么、对到什么算对」;
|
||||||
|
别抄机检判据的话(那是引擎数的数),要写人眼睛能核的事。
|
||||||
|
- **数据工单确认即入库**(2026-08-27 用户定):`cardVariant: 'data'` 那一步派的卡,人一确认,
|
||||||
|
基座就把卡上 `gateLedgers`(或绑的 `blocks`)那几张表并进正式表 —— 与提交闸同一份名单,不等整单放行。
|
||||||
|
并过的行仍算「本单写下的行」(`mine:` / `_rows` / 任务视图照旧有它),整单放行时不再写。
|
||||||
|
活动侧要知道的只有两件:①剧本别再教模型「人填的东西已落草稿层、放行前还能改」—— 已入库的键
|
||||||
|
再 write 会被当场拒(拒回会说清是本单建好的行,别 drop);②**数据工单必须给 `gateLedgers`**,
|
||||||
|
不给就没有一张表会因确认而入库(闸也没牙)。轨迹里有一条「数据工单确认入库:表 N 行」的痕,
|
||||||
|
试跑单不落主数据时如实写「只留在活动缓存」。
|
||||||
- **归型前移**:`draftWrite` 按声明列型归(空值 = 不写这一列、数值/布尔字符串转真类型、
|
- **归型前移**:`draftWrite` 按声明列型归(空值 = 不写这一列、数值/布尔字符串转真类型、
|
||||||
归不了当场拒)。**人手改的值与 AI 写的值过同一道门**。
|
归不了当场拒)。**人手改的值与 AI 写的值过同一道门**。
|
||||||
- **字段五标记**(`fieldDeclarationSchema`):
|
- **字段五标记**(`fieldDeclarationSchema`):
|
||||||
@@ -252,7 +265,7 @@ activities/<id>/src/ ──(pnpm activities:build)──> dist/{manifest.json,in
|
|||||||
| `ask` | ~~已退役~~ | 基座原地转成零输入执行工单(字段名并进指导书);别再用 |
|
| `ask` | ~~已退役~~ | 基座原地转成零输入执行工单(字段名并进指导书);别再用 |
|
||||||
| `entry` | `{itemId, ask, ledger, entryKey, fields, also?}` | 数据录入卡,挂起;确认那一下基座直写 |
|
| `entry` | `{itemId, ask, ledger, entryKey, fields, also?}` | 数据录入卡,挂起;确认那一下基座直写 |
|
||||||
| `decide` | `{itemId, ask, options(≥2), basis}` | 决策卡,挂起 |
|
| `decide` | `{itemId, ask, options(≥2), basis}` | 决策卡,挂起 |
|
||||||
| `human` | `{itemId, workOrder, card{title,ask,steps?,standard?,basis}}` | 执行/验收工单,挂起。**执行卡零输入**(2026-08-02 用户定):不摆 `fields` 不绑 `blocks`,填写全在工作区的件上(editGate + 孪生件),人按「保存」基座直写草稿层;必填由提交闸看住 —— **卡上加 `gateLedgers:["域.对象"]` 告诉闸这一张要拦哪几张表**,不给才回落到必交付物声明。**数据工单**(让人从零建一张主数据表)在那一步的剧本上声明 `cardVariant: 'data'` —— 引擎那一侧与执行工单无异,对话区据此换成「数据工单」那副面孔;卡骨架上的 `variant:"data"` 只是给模型看的提示,模型抄丢了也不影响(实测它会丢) |
|
| `human` | `{itemId, workOrder, card{title,ask,steps?,standard?,basis}}` | 执行/验收工单,挂起。**执行卡零输入**(2026-08-02 用户定):不摆 `fields` 不绑 `blocks`,填写全在工作区的件上(editGate + 孪生件),人按「保存」基座直写草稿层;必填由提交闸看住 —— **卡上加 `gateLedgers:["域.对象"]` 告诉闸这一张要拦哪几张表**,不给才回落到必交付物声明。**数据工单**(让人从零建一张主数据表)在那一步的剧本上声明 `cardVariant: 'data'` —— 引擎那一侧与执行工单只差一处:**人一确认,卡上 `gateLedgers` 那几张表就并进正式表**(2026-08-27,见 §3.1),对话区据此换成「数据工单」那副面孔;卡骨架上的 `variant:"data"` 只是给模型看的提示,模型抄丢了也不影响(实测它会丢) |
|
||||||
| `listen` | `{itemId, eventType, keyFilter, deadline}` | 立监听,挂起;时限必配 |
|
| `listen` | `{itemId, eventType, keyFilter, deadline}` | 立监听,挂起;时限必配 |
|
||||||
| `finish` | `{itemId, rationale}` | 宣告一项完成;非人工激活的项就地过 Active 再收 |
|
| `finish` | `{itemId, rationale}` | 宣告一项完成;非人工激活的项就地过 Active 再收 |
|
||||||
| `submit` | `{rationale, fields?}` | 提交放行 → 预检 → 发验收卡挂起 |
|
| `submit` | `{rationale, fields?}` | 提交放行 → 预检 → 发验收卡挂起 |
|
||||||
@@ -266,7 +279,7 @@ activities/<id>/src/ ──(pnpm activities:build)──> dist/{manifest.json,in
|
|||||||
标了 `manualActivationRule` 的项,**模型显式动它(调工具/发卡/finish)就算启动**,
|
标了 `manualActivationRule` 的项,**模型显式动它(调工具/发卡/finish)就算启动**,
|
||||||
不必单发 `start`(2026-08-02 拆墙)。它的分量在引擎那一侧,原样保留:
|
不必单发 `start`(2026-08-02 拆墙)。它的分量在引擎那一侧,原样保留:
|
||||||
**引擎不因哨兵满足替它启动**(哨兵只推到 Available/Enabled),
|
**引擎不因哨兵满足替它启动**(哨兵只推到 Available/Enabled),
|
||||||
**也不给 doneWhen 替人收**(自动收步跳过人工激活项)—— 收它只能是模型的 finish。
|
**人答卡之前不给 doneWhen 替人收**;答过卡之后 doneWhen 照常生效(见 §4.4)。
|
||||||
|
|
||||||
实测账(两轮,同一堵墙):先是「话与门同宽」—— 提示词写「先 start 再调工具」只说了
|
实测账(两轮,同一堵墙):先是「话与门同宽」—— 提示词写「先 start 再调工具」只说了
|
||||||
调工具,而门拦 tool/ask/human/decide/entry/finish 一整片,模型发 `human` 照字面漏了
|
调工具,而门拦 tool/ask/human/decide/entry/finish 一整片,模型发 `human` 照字面漏了
|
||||||
@@ -279,8 +292,17 @@ start 已收项无事发生同族)。剧本里也别再教 start —— 教它
|
|||||||
### 4.4 自动收步(doneWhen)
|
### 4.4 自动收步(doneWhen)
|
||||||
|
|
||||||
引擎每拍开头对任务视图求每一步的 `doneWhen`,**真了就自己收那一项,不问模型**
|
引擎每拍开头对任务视图求每一步的 `doneWhen`,**真了就自己收那一项,不问模型**
|
||||||
(与模型同拍撞车时:引擎收过了,模型这拍的 finish 当已完成,不抛非法迁移)。
|
(与模型同拍撞车时:引擎收过了,模型这拍的 finish 当已完成,不抛非法迁移);
|
||||||
人工激活的项**不给 doneWhen** —— 给了等于给引擎一把替人签字的钥匙。
|
**拍尾再求一次**(人答卡那一拍里模型写完东西 doneWhen 才真,不等下一拍;这一拍刚发卡的项不算)。
|
||||||
|
|
||||||
|
**人工激活的项也可以给 doneWhen,引擎只在人答过卡之后才求它**(2026-08-27 晚立):
|
||||||
|
门槛是 `worldTouched`(卡真发过)且没有未答的卡 —— 卡没发过时 doneWhen 再真也不收,
|
||||||
|
那才是替人签字。答过之后,「办完了没有」若是基座数得出来的事实,就写成 doneWhen 交给引擎:
|
||||||
|
W05 的两张数据工单(人填的值已入库、必填不缺、逐颗对账)就是这一类 —— 从前靠模型记得 finish,
|
||||||
|
剧本两处写死「先 finish 再发下一张」它照样忘,两步同时 Active、件停在上一步、人无处可填。
|
||||||
|
**指南让模型判的事,引擎有现成事实的,一律改成读事实** —— 这条也管收步。
|
||||||
|
仍不给 doneWhen 的:终局放行(T_release,由 submit 走)、审核循环、以及「办完了没有」
|
||||||
|
数不出来只能由人说了算的项。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -331,12 +353,13 @@ A1 是 `customerEmail / subject / body / attachments`)。
|
|||||||
裁量项),模型全程跟 harness 较劲,一单二三十万 token 收不了口;扁平 7 项后零被拒。
|
裁量项),模型全程跟 harness 较劲,一单二三十万 token 收不了口;扁平 7 项后零被拒。
|
||||||
2. **案卷项 id 就是任务视图键名**(入参与台账列摊平后的名字)。对不上 = 前提永不满足 =
|
2. **案卷项 id 就是任务视图键名**(入参与台账列摊平后的名字)。对不上 = 前提永不满足 =
|
||||||
第一个项都不 Enabled,而每一层单看都「正常」。
|
第一个项都不 Enabled,而每一层单看都「正常」。
|
||||||
3. **`manualActivationRule` 只标触达世界与关键交付的项**,且**不给 doneWhen**。
|
3. **`manualActivationRule` 只标触达世界与关键交付的项**;它的 doneWhen 只在人答过卡之后才求 ——
|
||||||
|
「办完了没有」基座数得出来的(数据工单确认入库)就给,数不出来的(放行 / 审核 / 交人补缺口)不给。
|
||||||
A1 标两项:S3_confirm(要打断真人)、T_release(签下去触达世界)。
|
A1 标两项:S3_confirm(要打断真人)、T_release(签下去触达世界)。
|
||||||
4. **人的门要建成一项,不要挂成某一步收步条件里的半句。** 「缺东西才叫人」的写法,
|
4. **人的门要建成一项,不要挂成某一步收步条件里的半句。** 「缺东西才叫人」的写法,
|
||||||
一封齐整的询价进来那半句当场为真,引擎自己收步,人一眼没看过就被叫去验收
|
一封齐整的询价进来那半句当场为真,引擎自己收步,人一眼没看过就被叫去验收
|
||||||
(用户原话:「我都没确认需求 它就让我验收了」)。做法:独立一项 + 人工激活 +
|
(用户原话:「我都没确认需求 它就让我验收了」)。做法:独立一项 + 人工激活 +
|
||||||
不给 doneWhen + 下一步的入场前提取**只有人的回复才产生的计数列**(`confirmed_total`)。
|
人答卡之前不收 + 下一步的入场前提取**只有人的回复才产生的计数列**(`confirmed_total`)或人填的值入库的基座数。
|
||||||
5. **哨兵必须有 ifPart,且读基座数、认「办完了没有」**(§3.3 放行门那行的教训)。
|
5. **哨兵必须有 ifPart,且读基座数、认「办完了没有」**(§3.3 放行门那行的教训)。
|
||||||
6. `repetitionRule` 标常来回的项;判断菱形 = 两条互斥前提的 sentry,不是一个计划项。
|
6. `repetitionRule` 标常来回的项;判断菱形 = 两条互斥前提的 sentry,不是一个计划项。
|
||||||
7. `autoComplete="true"`;里程碑前提达成即达成;`ifPart` 前缀 `semantic:` 交 judge。
|
7. `autoComplete="true"`;里程碑前提达成即达成;`ifPart` 前缀 `semantic:` 交 judge。
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/** 沙箱看板的取数声明:形状、地址与列名的规矩;老清单(不带 data)照旧过 */
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { VIEW_DATA_CASE_REF, viewDataSchema, viewTemplateSchema } from '../index.ts';
|
||||||
|
|
||||||
|
const spec = {
|
||||||
|
case: { ledger: 'sub_eval.verdict', key: 'eval_ref', prefix: 'W05-' },
|
||||||
|
rows: [
|
||||||
|
{ as: 'sub_eval.verdict', ledger: 'sub_eval.verdict', by: { column: 'eval_ref', from: VIEW_DATA_CASE_REF } },
|
||||||
|
{ as: 'demand.request', ledger: 'demand.request', 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' },
|
||||||
|
orderBy: [{ field: 'validation_id', dir: 'desc' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('viewDataSchema', () => {
|
||||||
|
it('往返', () => {
|
||||||
|
expect(viewDataSchema.parse(spec)).toEqual(spec);
|
||||||
|
});
|
||||||
|
it('地址要写成 域.对象,列名只准标识符', () => {
|
||||||
|
expect(viewDataSchema.safeParse({ ...spec, case: { ...spec.case, ledger: 'verdict' } }).success).toBe(false);
|
||||||
|
expect(viewDataSchema.safeParse({ ...spec, case: { ...spec.case, key: 'Eval-Ref' } }).success).toBe(false);
|
||||||
|
expect(viewDataSchema.safeParse({ ...spec, rows: [{ as: 'x', ledger: 'a.b', by: { column: 'c d', from: 'case' } }] }).success).toBe(false);
|
||||||
|
});
|
||||||
|
it('模版不带 data 照旧;带了就按 schema 归型', () => {
|
||||||
|
expect(viewTemplateSchema.parse({ name: 'p08', html: '<p/>' })).toEqual({ name: 'p08', html: '<p/>' });
|
||||||
|
expect(viewTemplateSchema.parse({ name: 'board', html: '<p/>', data: spec }).data).toEqual(spec);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -307,6 +307,8 @@ export const workDoneSchema = z.object({
|
|||||||
by: staffIdSchema,
|
by: staffIdSchema,
|
||||||
result: workOrderResultSchema,
|
result: workOrderResultSchema,
|
||||||
values: z.record(z.string(), z.string()).optional(),
|
values: z.record(z.string(), z.string()).optional(),
|
||||||
|
/** 人工验收清单里被点成「达标」的那几条(留痕:谁核了什么) */
|
||||||
|
checked: z.array(z.string()).optional(),
|
||||||
});
|
});
|
||||||
export type WorkDone = z.infer<typeof workDoneSchema>;
|
export type WorkDone = z.infer<typeof workDoneSchema>;
|
||||||
|
|
||||||
@@ -429,6 +431,14 @@ export const acceptanceWorkOrderSchema = z.object({
|
|||||||
mode: z.literal('acceptance'),
|
mode: z.literal('acceptance'),
|
||||||
deliverable: deliverableIdSchema,
|
deliverable: deliverableIdSchema,
|
||||||
criteria: z.array(acceptanceCriterionCheckSchema),
|
criteria: z.array(acceptanceCriterionCheckSchema),
|
||||||
|
/**
|
||||||
|
* **人工验收清单**(2026-08-27 用户定:「验收卡的条目不是系统判断的条目,是人要对这个验收工单的内容
|
||||||
|
* 进行审核需要的步骤」)—— 人核这份工单要走的步骤,一条一句可核对的动作,**每份验收工单固定**
|
||||||
|
* (剧本 `acceptanceChecklist` 声明,引擎发卡时带上)。卡上一条一枚「达标 / 不达标」开关,
|
||||||
|
* 全部点成达标才能按「确认通过」;服务端同样把关(`checked` ⊇ `checklist`)。
|
||||||
|
* 系统判的判据(`criteria`)仍在 —— 它们把提交门,不再摆在卡上让人签。
|
||||||
|
*/
|
||||||
|
checklist: z.array(z.string()).optional(),
|
||||||
actions: z.array(actionIntentViewSchema).optional(),
|
actions: z.array(actionIntentViewSchema).optional(),
|
||||||
/** 报告可修订:字段随成果 schema 渲染,AI 拟值已填 */
|
/** 报告可修订:字段随成果 schema 渲染,AI 拟值已填 */
|
||||||
fields: z.array(formFieldSchema).default([]),
|
fields: z.array(formFieldSchema).default([]),
|
||||||
@@ -527,6 +537,8 @@ export const decisionCardSchema = z.object({
|
|||||||
* (工单 · 验收 / 通过 · 驳回),机制仍是决策卡。由剧本 `cardVariant` 声明、引擎决定,不靠模型抄。
|
* (工单 · 验收 / 通过 · 驳回),机制仍是决策卡。由剧本 `cardVariant` 声明、引擎决定,不靠模型抄。
|
||||||
*/
|
*/
|
||||||
variant: z.enum(['review']).optional(),
|
variant: z.enum(['review']).optional(),
|
||||||
|
/** 人工验收清单(与 `acceptanceWorkOrderSchema.checklist` 同一件事)—— 审核卡穿验收工单的衣服,清单也一样带 */
|
||||||
|
checklist: z.array(z.string()).optional(),
|
||||||
ask: z.string(),
|
ask: z.string(),
|
||||||
basis: basisSchema,
|
basis: basisSchema,
|
||||||
recommend: z.string().optional(),
|
recommend: z.string().optional(),
|
||||||
@@ -547,6 +559,8 @@ export const decisionCardSchema = z.object({
|
|||||||
at: z.iso.datetime(),
|
at: z.iso.datetime(),
|
||||||
by: staffIdSchema,
|
by: staffIdSchema,
|
||||||
note: z.string().optional(),
|
note: z.string().optional(),
|
||||||
|
/** 人工验收清单里被点成「达标」的那几条 */
|
||||||
|
checked: z.array(z.string()).optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
/**
|
/**
|
||||||
@@ -759,6 +773,8 @@ export const workOrderReplySchema = z.discriminatedUnion('mode', [
|
|||||||
comment: z.string().optional(),
|
comment: z.string().optional(),
|
||||||
/** 选择组的出口选择:恰发一口,随放行一并确认(AM 1.2) */
|
/** 选择组的出口选择:恰发一口,随放行一并确认(AM 1.2) */
|
||||||
selectedPorts: z.record(z.string(), z.string()).default({}),
|
selectedPorts: z.record(z.string(), z.string()).default({}),
|
||||||
|
/** 人工验收清单里点成「达标」的条目 —— 通过时必须把清单点全(服务端把关) */
|
||||||
|
checked: z.array(z.string()).optional(),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
export type WorkOrderReply = z.infer<typeof workOrderReplySchema>;
|
export type WorkOrderReply = z.infer<typeof workOrderReplySchema>;
|
||||||
@@ -769,6 +785,8 @@ export const chatCardReplySchema = z.discriminatedUnion('k', [
|
|||||||
cardId: cardIdSchema,
|
cardId: cardIdSchema,
|
||||||
value: z.string(),
|
value: z.string(),
|
||||||
note: z.string().optional(),
|
note: z.string().optional(),
|
||||||
|
/** 审核卡的人工验收清单里点成「达标」的条目 —— 选「通过」时必须点全 */
|
||||||
|
checked: z.array(z.string()).optional(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
k: z.literal('elicitation'),
|
k: z.literal('elicitation'),
|
||||||
|
|||||||
@@ -168,6 +168,13 @@ export const planStepSchema = z.object({
|
|||||||
* 归活动声明、不归模型临场发挥;模型写不写都一样。
|
* 归活动声明、不归模型临场发挥;模型写不写都一样。
|
||||||
*/
|
*/
|
||||||
cardVariant: z.enum(['data', 'review']).optional(),
|
cardVariant: z.enum(['data', 'review']).optional(),
|
||||||
|
/**
|
||||||
|
* **人工验收清单**(2026-08-27 用户定):这一步派出的验收卡(终局 `submit` 的验收工单,或
|
||||||
|
* `cardVariant:'review'` 的审核卡)上,人要走的核对步骤 —— 一条一句可核对的动作,对每份验收工单**固定**。
|
||||||
|
* 不是系统判的判据(那是 `acceptanceCriteria`,把提交门);是人拿着这份工单要亲自核什么。
|
||||||
|
* 卡上一条一枚「达标 / 不达标」开关,全部达标才能「确认通过」。
|
||||||
|
*/
|
||||||
|
acceptanceChecklist: z.array(z.string().min(1)).optional(),
|
||||||
});
|
});
|
||||||
export type PlanStep = z.infer<typeof planStepSchema>;
|
export type PlanStep = z.infer<typeof planStepSchema>;
|
||||||
|
|
||||||
@@ -229,11 +236,52 @@ export const skillSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type Skill = z.infer<typeof skillSchema>;
|
export type Skill = z.infer<typeof skillSchema>;
|
||||||
|
|
||||||
|
/** `域.对象` 两段地址(与 `ledgerAddressSchema` 同一套标识符规则,只是写成一串) */
|
||||||
|
const viewLedgerAddress = z.string().regex(/^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/, '台账地址写成 域.对象');
|
||||||
|
const viewColumn = z.string().regex(/^[a-z][a-z0-9_]*$/, '列名只准小写字母开头、字母数字下划线');
|
||||||
|
/** `case`(案号本身)或 `<as>.<列>`(前面某个集合那一列的值) */
|
||||||
|
const viewDataRef = z.string().min(1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运营中心沙箱看板的**取数声明**(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 const viewDataRowsSchema = z.object({
|
||||||
|
/** 作用域键(模版 `data-rows` 用它);与台账地址同名最省事,同一张表圈两批时另起名 */
|
||||||
|
as: z.string().min(1),
|
||||||
|
/** 必须在 `declarations.ledger` 里 */
|
||||||
|
ledger: viewLedgerAddress,
|
||||||
|
by: z.object({ column: viewColumn, from: viewDataRef }),
|
||||||
|
not: z.object({ column: viewColumn, from: viewDataRef }).optional(),
|
||||||
|
orderBy: z.array(z.object({ field: viewColumn, dir: z.enum(['asc', 'desc']) })).optional(),
|
||||||
|
});
|
||||||
|
export type ViewDataRows = z.infer<typeof viewDataRowsSchema>;
|
||||||
|
export const viewDataSchema = z.object({
|
||||||
|
case: z.object({ ledger: viewLedgerAddress, key: viewColumn, prefix: z.string() }),
|
||||||
|
rows: z.array(viewDataRowsSchema),
|
||||||
|
});
|
||||||
|
export type ViewData = z.infer<typeof viewDataSchema>;
|
||||||
|
/** `by.from` / `not.from` 里指案号本身的那个字 */
|
||||||
|
export const VIEW_DATA_CASE_REF = 'case';
|
||||||
|
|
||||||
|
export const viewTemplateSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
html: z.string(),
|
||||||
|
/** 只有运营中心沙箱看板这类**按实例圈行**的模版才带;工单件不带(它们的行由引擎按草稿层给) */
|
||||||
|
data: viewDataSchema.optional(),
|
||||||
|
});
|
||||||
|
export type ViewTemplate = z.infer<typeof viewTemplateSchema>;
|
||||||
|
|
||||||
export const manifestAssetsSchema = z.object({
|
export const manifestAssetsSchema = z.object({
|
||||||
businessModel: businessModelSchema,
|
businessModel: businessModelSchema,
|
||||||
skills: z.array(skillSchema).default([]),
|
skills: z.array(skillSchema).default([]),
|
||||||
/** 视图 HTML 模版 —— 模版是内容不是代码,容器净化渲染、无脚本执行权 */
|
/** 视图 HTML 模版 —— 模版是内容不是代码,容器净化渲染、无脚本执行权 */
|
||||||
viewTemplates: z.array(z.object({ name: z.string().min(1), html: z.string() })).default([]),
|
viewTemplates: z.array(viewTemplateSchema).default([]),
|
||||||
/** 卡内容 schema */
|
/** 卡内容 schema */
|
||||||
cardSchemas: z.record(z.string(), jsonSchemaSchema).default({}),
|
cardSchemas: z.record(z.string(), jsonSchemaSchema).default({}),
|
||||||
/** 蒸馏钩子:终态封卷 / 里程碑达成时从任务记忆归纳 */
|
/** 蒸馏钩子:终态封卷 / 里程碑达成时从任务记忆归纳 */
|
||||||
@@ -312,5 +360,12 @@ export const installVerdictSchema = z.object({
|
|||||||
accepted: z.boolean(),
|
accepted: z.boolean(),
|
||||||
staticFindings: z.array(staticAuditFindingSchema),
|
staticFindings: z.array(staticAuditFindingSchema),
|
||||||
replay: replayAuditReportSchema,
|
replay: replayAuditReportSchema,
|
||||||
|
/**
|
||||||
|
* **这一版真的存进库了吗**(2026-08-27)—— 安装恒新增、同版重装是 no-op:两审过了、版本已在库,
|
||||||
|
* 什么都没写。从前这一维不回,安装脚本照样打印「活动包已装」,改了内容忘升版本的人以为装上了
|
||||||
|
* (同一天两个会话共用一个库,各自升到同一个版本号,后装的那份就这么静默丢了两次)。
|
||||||
|
* `false` = 库里已有同版,本次未重装;老调用方不读它也不受影响。
|
||||||
|
*/
|
||||||
|
stored: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
export type InstallVerdict = z.infer<typeof installVerdictSchema>;
|
export type InstallVerdict = z.infer<typeof installVerdictSchema>;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const GLYPHS: Record<string, React.ReactNode> = {
|
|||||||
"chevron-down": (<path d="m6 9 6 6 6-6" />),
|
"chevron-down": (<path d="m6 9 6 6 6-6" />),
|
||||||
check: (<path d="M20 6 9 17l-5-5" />),
|
check: (<path d="M20 6 9 17l-5-5" />),
|
||||||
"circle-check": (<><circle cx="12" cy="12" r="10" /><path d="m9 12 2 2 4-4" /></>),
|
"circle-check": (<><circle cx="12" cy="12" r="10" /><path d="m9 12 2 2 4-4" /></>),
|
||||||
|
circle: (<circle cx="12" cy="12" r="10" />),
|
||||||
star: (<><path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" /></>),
|
star: (<><path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" /></>),
|
||||||
sparkles: (<><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .962 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.582a.5.5 0 0 1 0 .962L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.962 0z" /><path d="M20 3v4" /><path d="M22 5h-4" /><path d="M4 17v2" /><path d="M5 18H3" /></>),
|
sparkles: (<><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .962 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.582a.5.5 0 0 1 0 .962L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.962 0z" /><path d="M20 3v4" /><path d="M22 5h-4" /><path d="M4 17v2" /><path d="M5 18H3" /></>),
|
||||||
terminal: (<><polyline points="4 17 10 11 4 5" /><line x1="12" x2="20" y1="19" y2="19" /></>),
|
terminal: (<><polyline points="4 17 10 11 4 5" /><line x1="12" x2="20" y1="19" y2="19" /></>),
|
||||||
|
|||||||
Reference in New Issue
Block a user