diff --git a/.gitignore b/.gitignore index 9e1b088..75f4da4 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,7 @@ coverage/ # html-one-pager 技能的评测工作区(skill-creator 产物,不入库) .claude/skills/html-one-pager-workspace/ + +# 浏览器走查的截图与日志(本地取证用,不入库) +.playwright-mcp/ +/*.png diff --git a/CLAUDE.md b/CLAUDE.md index 14d3bfd..73fde80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1026,6 +1026,274 @@ 归档的 .xlsx 落盘后 `file` 认作 **Microsoft Excel 2007+**、python `zipfile` 读得出 整张对照表。新测 8 条(存哪些不存哪些 + 主键拼法);`reset-runs` 一并清这张表。 +- **两个后端连同一个库,把同一张单推坏了**(2026-08-24 柴浩实测:E04 一单五拍熔断; + 用户中途提示「本机和局域网另一台机器的同一个后端都连着这个库」——最后一块拼图): + 症状是**两堵墙对着撞** —— `finish S2` 被拒「从没给它发过卡」,而重发那张卡又被拒 + 「这件事刚问过,人也答过了」。查下来:卡确实发过(11:17:53 发、11:18:01 人答的), + 可局面里 **8 个计划项的 `worldTouched` 全是空**;同一步 S1 被收了两遍 + (两条 rationale 只差一个标点 —— 是两次独立采样,不是重放)。 + 根因:两边的推进器各自 2 秒扫一次「running 的单」,**同时推同一张**, + 两拍并发写同一份 `sys.loop_state`,**后写的整份覆盖先写的** —— 发过卡这个事实就此消失。 + 而互斥只在进程内(`task-lock.ts` 开头自己写着「MVP 单进程…多进程部署时再叠一层」)。 + **修**:`workers/task-claim.ts` —— 库里一列 `advancing_until`(迁移 0030)当跨进程租约, + 推之前一条 SQL 判+占(判与占分两句写就是经典竞态),推完就还,带 5 分钟过期 + (进程崩了不锁死);推进器与派发器两条推进路径都接上。 + 实测:两条真连接对着真库抢 20 轮,**每轮恰好一个赢**;租约过期后另一方接得上。 + **还欠**(记 debt):`loop_state` 本身没有乐观并发(版本号 / CAS)—— + 租约挡住了主路,但绕过它的第三条路仍会**悄无声息**地整份覆盖。 + 排查法留一条:`worldTouched` 全空 + 同一步收两遍 = 并发覆盖的指纹。 + +- **点了要有反应,在办事要说出来**(2026-08-24 用户:「点击了都不知道点没点上」): + 两件事各缺一半。①**键感**:治理台整套 `g-*` 控件一条 `:active` 都没有,工作台只有零星几个; + 更糟的是有 `:active` 的那几个把 transform 一起塞进了 0.16s 的过渡里 —— + 一次点击常常比这还短,手抬起来时键帽才刚下去一半。新增 `styles/press.css` + (三面共用、**装在最后**):按下 `scale(.97)`(整行整卡这类大件收到 `.995`,同样幅度会晃)、 + **按下这一刻不过渡**(松开仍走控件自己的过渡,回弹是软的)、`:focus-visible` 描圈。 + ②**在办**:请求在路上的那几百毫秒屏上从前一动不动 —— 新增 `shared/Btn.tsx`, + `busy`(递 `mutation.isPending`)或 `onClick` 返回的 Promise 一亮,按钮脚下走一道 + 不定长进度条。**不给 `disabled`**:那会把按钮画成灰的,而它此刻正在**做**这件事, + 不是做不了(界面不撒谎)。全仓 40 余处会去请求服务端的钮都换成了它; + 勾选与下拉给不了键感,改成把**那一行**点亮(认 `mutation.variables`,只亮真正在路上的那一条)。 + 途中两个坑各留一条账:**条不能画进按钮自己的 background** —— 第一版用 background-image, + 在真页面上量出来根本不存在:`.tbtn` 这类皮肤用 `background:` 简写上色, + 特异度压过 `[aria-busy]`,图片当场被顶回 none(动画照跑,条却没有);改用 `::after` + (全仓按钮类没有一条定义过伪元素,审过)。**``,渲染器只给自动孪生件(`data-slot`+ + `data-col` 空 span)填值,手写的一开编辑就是白框。修(E04@2.1.5 + 基座): + ①新数据源 `touched:域.对象` = 正式现值 + 本单草稿叠加、按草稿键只取本轮碰过的行 + (engines `touchedRows` 纯函数;不进提示词,restPart 滤掉);②预览两表改绑 touched、 + 文本格全部换自动孪生(select/textarea 本就有 current 填值);③**保存的「真改了吗」 + 也对着叠加行比**(从前只对草稿:回显值一保存全算「改了」,痕写「改了 2 行」而人 + 一格没动,日志撒谎)——「是不是新增」同理改判。 + 连带逮出并修死一个**新增变覆盖**:人在确认屏加联系人,编码空着(自增列本分), + 而 `resolveRowKey` 的 findKnownRow 拿半把钥匙(只有 customer_code)把新行错配到 + 该客户已有的草稿联系人行上 —— 编码从那行借来,模型刚建的那位被顶掉,痕还写 + 「改了 1 行」。立规:**空着的自增列不是身份线索,是「给我续个号」**——声明 auto + 的列件上摆着却空着,一律不走 findKnownRow/全表一致,直进续号(2 测钉死); + e2e 断言同批收紧(按填的姓名找行,防覆盖冒充新增再骗过一次)。 + **验收**:案例 0 released、被拒 0;定格的确认屏 HTML 六探针全中(Tier1 OEM / + 车规电子 / value="Itelma" / value="Ekaterina Volkova" / 测试CTO王工 / + CU-E2E-ADD-01);痕三条全部老实(新增记新增)。1676 测 / 五门禁绿。 + +- **括号少一个,一拍全对也白跑:括号栈补上「补」这一档**(2026-08-24 柴浩:「怎么又 + 推不动了」):卡在 S6 收口拍 —— 全流程括号最深的输出(decision_chain + + interaction_timeline 两个嵌套 JSON 列),关思考的模型在 profile 行上**少吐一个 `}`**; + drop 档(08-02 治「多括号」)把后面本该关 rows 的 `]` 当噪声丢掉,整段错位到尾, + 重问一次(温度 0.2)几乎原样复现,两连败 → 兜底卡「需要你判断下一步」交人, + 单子看着像卡死。修:`parseJsonLoose` 两档**有序**修复 —— 先 drop(多括号当噪声), + 失败再 close(闭括号对不上但栈更深处有它 → 把欠的补上;文本截断收尾 → 全补)。 + 顺序不能反:close 先跑会把多括号那形的外层提前关掉、行动串截半(测钉死)。 + 仍只合成闭括号,不发明内容。事故原文实测:三个 write 全解出。1679 测绿。 + **同夜第二形**(e2e 逼出):行对象没关、后面直接跟 `,{"key":…`(下一行)——括号到尾 + 能配平、strict 与两档全看不出;close 档补第三条:**对象里逗号后面冒出 `{`/`[`,就是 + 上一个对象没关**,把欠的 `}` 补在那个逗号之前(逗号其实属于外层数组)。两起原文 + 全解出(11 测)。e2e 同批:认出「需要你判断下一步」兜底卡当场判失败收工, + 不再空转到 20 分钟超时。 + +- **数据域记录浏览的 JSON 列不再是 `[object Object]`**(2026-08-24 用户报): + 格子文本 `String(row[col])` 对对象一律糊成那七个字 —— 决策链地图 / 互动时间线两列 + 整列不可读。补 `cellText`:对象/数组 stringify 原样摆,超 120 字截断加省略号、 + 全文进 title(悬停可读)。别处早就对了(试跑草稿面 JSON.stringify、视图 displayValue), + 只有这张表漏了。浏览器实证:决策链格摆出 `{"nodes":[…` 真内容,全页无 [object Object]。 + +- **「关键人待识别」从计划渗进主数据又渗回来:占位句式全链退役 + 引用墙**(2026-08-24 + 柴浩:「审批里怎么会有关键人待识别的项目」):三层病 —— + ①模型把 2.1.1 的占位句式(查无联系人客户的 -00 行)**泛化到缺口节点**,给 Itelma + 编 CU-0142-02/03 引用码 + 占位人名(措辞三连拦不住:反例描述/说明书纪律/禁令); + ②某单落库后这些占位行**以「关键人待识别」为姓名进了 customer.contact 主数据**, + 下一单从主数据读到它们照常规划 —— 污染自我强化;③一次 e2e 集合共用 bug + (录入卡与决策卡共用已答名单,decideSeq 序号被顶,点名驳回被跳过)。 + 修四层(E04@2.1.7 + 基座):①**占位句式全链退役** —— 查无联系人的候选客户改走 + **数据录入卡**先收第一位关键人(该问人的问人;骨架进 S3 剧本),真人建档后再排计划; + 一位联系人一行,缺口节点动作并进已知联系人那一行;②**契约新增 `refersTo`** + ('域.对象.列'):声明引用关系,基座 draftWrite 那一刻查引用表(正式+草稿叠加), + 查无此值当场拒、拒回指路;安装静态审核 refersTo 目标真实存在 + (`reference-target-unbound`,写错地址=永远查无此值的死墙);plan/receipt 的 + contact_code 都钉上;③主数据两次清污(占位联系人删除);④e2e:录入卡代答 + (entryValues)+ 计划违禁字样断言(forbidInPlan)+ 已答名单分家。 + **验收(@2.1.7,主数据清污后)**:三案例 3/3 released、全部被拒 0(16-18 拍); + 案例 0 计划恰 2 行全是真人(Ekaterina 那行把两个缺口节点的动作并进她名下、 + 王工按点名改成约见CTO),录入卡先收人 → 确认屏再补一位续号 -02;案例 2 单人单行。 + 注意:refersTo 墙在这轮从没响 —— 主数据干净时模型照 2.1.7 的「一位联系人一行」 + 就不再编码了;墙是备份,不是主路。**清污是前提**:主数据里躺着占位联系人时, + 引用检查会「合法」放行它们(引用的确存在),病就从主数据渗回来。 + +- **报价链 A1–A7 从系统卸载,只留 E04 / I01**(2026-08-24 用户定,**只卸不删源码**): + 六个 quote-* 包在库里置 `uninstalled` + 禁用(registry.uninstall),两个 RFQ 触发器 + (trg-rfq / trg-rfq-manual)停用 —— 触发器对着已卸载的活动起单只会生出坏任务; + 种子同步:`REAL_PACKAGES` 只剩 E04/I01,`seedMvpFlow` 以 `SEED_QUOTE_FLOW=false` + 整段不播(统一报价流程包 / 触发器 / 询价邮箱 —— 装不上的活动激活流程包会被闭合 + 校验拦下)。源码、e2e 套件、包内测试原样保留;`MVP_PACKAGE` 常量仍是流程编译与 + 触发装配那几组测试的夹具。**库里 `unified-quote` 流程包仍在**(active,节点指向已卸载 + 的活动 —— 蓝图待补清单会点它),等用户定要不要连流程包一起退。要装回:名字加回 + `REAL_PACKAGES` + 拨回 `SEED_QUOTE_FLOW`,跑 activities:install。 + +- **台账更新确认四件小账**(2026-08-24 柴浩连报,E04@2.1.8):①**重做后旧明细留在表里** + (SQ-0031 的覆盖度同时挂着 50→50 与 50→100):主键是序号,重做时模型新开一行而不是 + 同 seq 覆盖。结构治:明细表加 `update_key`(台账地址|客户|联系人|字段,必填, + `distinct`),S6 收步 + 新 L2「同一处字段只留一条更新」钉 distinct = rows; + T_release 重做分支写明过期行 `drop` 撤掉。②**依据列带机器键前缀** + (「回执 contact_code=CU-0087-02:…」):说明书与技能改教人话(客户名·人名的回执:…), + 不写编码、不带前缀。③**标题旁的单号不摆**:`doc()` 的 `titleRef` 改可选,四份件全去掉 + (页脚单号仍在)。④**验收判据与「AI 已完成」文案夹着 `coverage_score`/`decision_chain`** + → 全改人读名。当前那单的草稿当场手术:删过期行、去六条前缀。包内测试 91 条钉死四条口径。 + +- **计划 7 行回执 6 行照样交卷、两堵墙对撞熔断、对话区偶发乱序**(2026-08-24 柴浩三连): + ①**多行 write 不是原子的** —— 第 2 行撞 refersTo 墙被拒时第 1 行已落草稿,轨迹只记 + 「被拒」,模型与人都以为一行没写,回执骨架从此少一行;提交闸只数已有行缺不缺必填, + 数不出「少了一行」;放行门「每条计划都有回执」拒 → 模型删计划凑数 → 「每家候选都进了 + 计划」拒 → 加回来 → 循环五拍熔断,而缺的那行回执只有人能填。修:loop 的 write + **整批先验后写**(`draftWrite` 加 `dryRun`,两遍),被拒 = 一行都没落;E04@2.1.9 + 把「回执行数 = 计划行数」钉进 S3 收步与 S4 入场哨兵(派回执单前就得齐),S4 剧本 + 加/撤计划行同步骨架,T_release 写明回执少行 → 补骨架重派执行单、**不许删计划凑数**; + e2e 回执单派出那一刻断言骨架 = 计划。②**对话区按任务内序号排,不按墙上时钟** + (`chat-order.ts`):合并从前按 `at` 字符串排,两台后端交替推同一单、时钟一偏, + 上一拍的话就排到下一拍后面;记忆 `seq` 与时钟无关,AI/系统项本身是记忆条目、 + 卡对 card-sent、自由输入对 human-input(`say` 现在把消息 id 记进 detail), + 找不到落点的老数据按时刻插位。3 测。 + **验收**:脚本 e2e 案例 0 released、被拒 0、回执骨架 = 计划;浏览器全链(AI 账号, + 不落主数据):件上加客户 → 录入卡收 CTO → 点名驳回改对行 → 回执单待回执 2/2、 + 闸拦空手交卷、件上填两行保存即落库 → 验收判据全中文、落库预览回显 → **released** + (17 拍、被拒 1 —— 模型在 S3 先写了计划再写回执骨架、中间撞了一次 doneWhen 收步, + 下一拍补齐);对话区 AI 气泡 23:52 → 23:55 → 23:57 单调;更新明细依据人话、位置键唯一。 + +- **摆给人看的行标识去掉单号那一段**(2026-08-25 柴浩:「COV-…/CU-0142-01 只保留斜杠右边」): + 缺项红带 / 执行卡提交闸 / 拒回主键名单 / 就地修订痕从前把主键各列全拼上 —— + 可人正在这一单里看件,单号是脚下的地面,不是区分行的信息。基座 `rowLabel(id, caseKey)` + 按声明的单标识(`caseKey`)去掉那一段,四处同源同改;去完一段都不剩(主键只有单号) + 退回整段。`missingReadings` 入参补 `caseKey`。4 测;在途单 `views.repush` 一帧即见。 + +- **点击后两块同一帧变脸:「在办事」写在回传返回之前**(2026-08-25 柴浩:「右边聊天框 + 先有反应,中间的 html 过一会儿才变成 AI 正在推进中,看上去很不流畅」):人回传落库后 + runtime 要等派发器投递、推进器抢到租约、开跑前那一刻才翻成 `running` —— + 前端 onSuccess 那一次重取抓到的还是 `awaiting-event`:对话区(卡状态已落)先变, + 工作区(读 runtime)要等下一次推送。修:`TaskService.wake(taskId)` —— 只对 + awaiting-event / awaiting-human 翻 running 并 setPhase('active')(顺带广播), + 两条回传路由(工单 / 对话卡)在返回之前调它。浏览器探针实测(100ms 采样): + 点「通过」后 **401ms** 中间已是「AI 正在推进这单」、对话区同帧「你选了:通过 · + 持续推进中」;8.2s 推进完成两块同帧换成回执件 + 下一张卡。3 测;CONTRACT.md 已记。 + +- **对话区「上一步的卡跑到这一步下面」的真病根:并卡判紧邻只看对话项**(2026-08-25 + 柴浩第二次报):08-24 那刀(按记忆 seq 排序)治的是**跨机器时钟偏**,而这次时钟没偏 —— + I01 一单:「两份初稿…请审核」→ 发决策卡 → 人答 → 收口 → 发验收卡;`projectChat` + 的「一个气泡 = 交代 + 卡」只看 `out` 末项是不是还没挂卡的 AI 发言,而决策卡 / 录入卡 + 的 card-sent、人的答复、内部观察都**不进 out**,那句话在它眼里一直「空着」—— + 41 秒后的验收卡被挂到了上一步那句话上,以它的时刻排到决策卡前面。修:「刚说过话」 + 按**记忆序列**判(话与工单之间不许隔任何条目;装配戳这类信封件除外;跨人卡 / 决策卡 + 的 card-sent 也算隔开),一句话只领一张卡。3 测(26 → 全绿)。**同一症状两条独立的路** + (时钟偏 / 并卡误认),治完一条要把另一条也走一遍 —— 这是 08-23 灰底那课的第二次。 + ## 文档权威地图 | 层 | 文件 | 说明 | diff --git a/activities/crm-relationship-coverage/__tests__/activity.test.ts b/activities/crm-relationship-coverage/__tests__/activity.test.ts index 4a6d519..533f4ca 100644 --- a/activities/crm-relationship-coverage/__tests__/activity.test.ts +++ b/activities/crm-relationship-coverage/__tests__/activity.test.ts @@ -340,6 +340,10 @@ describe('声明与契约对得上', () => { (f.tally ?? []).map((v) => `${d.domain}_${d.object}_${f.name}_${v.toLowerCase().replace(/[^a-z0-9_]/g, '')}`), ), ), + // distinct 声明出的不同值计数 —— 覆盖闭合判据的落点(2026-08-24) + ...manifest.declarations.ledger.flatMap((d) => + d.fields.filter((f) => f.distinct === true).map((f) => `${d.domain}_${d.object}_distinct_${f.name}`), + ), ]); it('L1/L2 谓词读的名字都有承载(台账列或基座读数)', () => { @@ -428,10 +432,32 @@ describe('报告模版 —— 活动持有,容器无脚本执行权', () => { expect(html).not.toContain('data-col='); }); - it('台账更新明细只读:没有任何 data-edit / data-col —— 归档件不给改', () => { + it('台账更新确认摆全落库面:更新明细只读,客户档案与联系人档案两张落库预览可编辑、联系人可加行', () => { + // 2026-08-24 用户定:「没有把要更新的台账展示全;AI 分析填的字段也要能在这一步补」 const html = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-ledger-update')?.html ?? ''; - expect(html).not.toContain('data-edit='); - expect(html).not.toContain('data-col='); + // 更新明细那张表仍是摘要、只读 —— 它的 tbody 上不许有 data-edit + expect(html).toContain('data-rows="mine:crm_coverage.ledger_update"'); + expect(html).not.toContain('data-rows="mine:crm_coverage.ledger_update" data-edit'); + // 两张落库预览:将写入正式表的行本身,可就地补改 + expect(html).toContain('data-edit="customer.profile"'); + expect(html).toContain('data-edit="customer.contact"'); + /* + * 数据源必须是 touched:(正式现值 + 草稿叠加、只取本轮碰过的行)—— + * 绑 mine: 的话草稿里没有的列(Itelma 既有的客户类型/行业)整片空白, + * 编辑框也空(2026-08-24 实测);文本格走自动孪生(data-slot + data-col 空 span), + * 手写 渲染器不给填值,一开编辑就是白框。 + */ + expect(html).toContain('data-rows="touched:customer.profile"'); + expect(html).toContain('data-rows="touched:customer.contact"'); + expect(html).toContain('data-slot="name" data-col="name"'); + expect(html).not.toMatch(/data-edit="customer\.(?:profile|contact)"[\s\S]*? { diff --git a/activities/crm-relationship-coverage/__tests__/steps.test.ts b/activities/crm-relationship-coverage/__tests__/steps.test.ts index 4c2dc08..a62229d 100644 --- a/activities/crm-relationship-coverage/__tests__/steps.test.ts +++ b/activities/crm-relationship-coverage/__tests__/steps.test.ts @@ -150,23 +150,104 @@ describe('doneWhen 是「成果已存在」的判据 —— 开局不许真,干 ).toBe(false); }); - it('计划有行且必填齐 → S3 收得掉;缺一头都收不掉', () => { - expect(safeEval(doneOf('S3_plan'), { crm_coverage_plan_rows: 3, crm_coverage_plan_missing: 0 })).toBe(true); - expect(safeEval(doneOf('S3_plan'), { crm_coverage_plan_rows: 0, crm_coverage_plan_missing: 0 })).toBe(false); - expect(safeEval(doneOf('S3_plan'), { crm_coverage_plan_rows: 3, crm_coverage_plan_missing: 2 })).toBe(false); + it('计划有行、必填齐、且盖住每家候选 → S3 收得掉;缺一头都收不掉', () => { + const full = { + crm_coverage_plan_rows: 3, + crm_coverage_plan_missing: 0, + crm_coverage_plan_distinct_customer_code: 2, + crm_coverage_candidate_rows: 2, + crm_coverage_receipt_rows: 3, + }; + expect(safeEval(doneOf('S3_plan'), full)).toBe(true); + expect(safeEval(doneOf('S3_plan'), { ...full, crm_coverage_plan_rows: 0 })).toBe(false); + expect(safeEval(doneOf('S3_plan'), { ...full, crm_coverage_plan_missing: 2 })).toBe(false); }); - it('台账更新明细齐 + 主数据必填齐 → S6 收得掉', () => { + it('**回执骨架比计划少一行 → S3 收不掉、S4 哨兵不开**(2026-08-24 柴浩实测:计划 7 回执 6,人填完就能交卷)', () => { + expect( + safeEval(doneOf('S3_plan'), { + crm_coverage_plan_rows: 7, + crm_coverage_plan_missing: 0, + crm_coverage_plan_distinct_customer_code: 6, + crm_coverage_candidate_rows: 6, + crm_coverage_receipt_rows: 6, + }), + ).toBe(false); + const sentry = /([^<]+)<\/condition>/.exec(CMMN_XML)?.[1] ?? ''; + expect(sentry).toContain('crm_coverage_receipt_rows = crm_coverage_plan_rows'); + const s4 = COVERAGE_STEPS.find((x) => x.item === 'S4_plan_approval')?.guide ?? ''; + expect(s4).toContain('加了一条计划就同拍给它加一行'); + const rel = COVERAGE_STEPS.find((x) => x.item === 'T_release')?.guide ?? ''; + expect(rel).toContain('不许删计划来凑数'); + }); + + it('**计划漏了一家候选客户 → S3 收不掉**(2026-08-24 柴浩实测:人加的那家被从头无视到尾)', () => { + // 人在覆盖范围确认卡上加了一家(candidate 2 行),计划只盖住 AI 识别的那一家 + expect( + safeEval(doneOf('S3_plan'), { + crm_coverage_plan_rows: 3, + crm_coverage_plan_missing: 0, + crm_coverage_plan_distinct_customer_code: 1, + crm_coverage_candidate_rows: 2, + }), + ).toBe(false); + // S4 的入场哨兵是同一句闭合 —— 计划没盖全,审批卡就不该发出来 + const sentry = /([^<]+)<\/condition>/.exec(CMMN_XML)?.[1] ?? ''; + expect(sentry).toContain('crm_coverage_plan_distinct_customer_code = crm_coverage_candidate_rows'); + }); + + it('覆盖闭合的两边都是基座数:distinct 声明在 plan.customer_code 上,放行 L2 也钉同一句', () => { + const plan = manifest.declarations.ledger.find((l) => l.object === 'plan'); + expect(plan?.fields.find((f) => f.name === 'customer_code')?.distinct).toBe(true); + const criterion = manifest.contract.acceptanceCriteria.find((c) => c.key === 'plan-covers-scope'); + expect(criterion?.level).toBe('L2'); + expect(criterion?.predicate).toBe('crm_coverage_plan_distinct_customer_code = crm_coverage_candidate_rows'); + }); + + it('台账更新明细齐 + 主数据必填齐 + 无重复位置 → S6 收得掉', () => { expect( safeEval(doneOf('S6_ledger_update'), { crm_coverage_ledger_update_rows: 4, crm_coverage_ledger_update_missing: 0, + crm_coverage_ledger_update_distinct_update_key: 4, customer_profile_missing: 0, customer_contact_missing: 0, }), ).toBe(true); }); + it('**重做后旧明细没撤(同一处位置两条)→ S6 收不掉**(2026-08-24 用户:「重做后老的变更记录应该删除」)', () => { + expect( + safeEval(doneOf('S6_ledger_update'), { + crm_coverage_ledger_update_rows: 5, + crm_coverage_ledger_update_missing: 0, + crm_coverage_ledger_update_distinct_update_key: 4, + customer_profile_missing: 0, + customer_contact_missing: 0, + }), + ).toBe(false); + const decl = manifest.declarations.ledger.find((l) => l.object === 'ledger_update'); + const col = decl?.fields.find((f) => f.name === 'update_key'); + expect(col?.distinct).toBe(true); + expect(col?.required).toBe(true); + expect(manifest.contract.acceptanceCriteria.some((c) => c.key === 'no-duplicate-update' && c.level === 'L2')).toBe(true); + // 依据一列写人话:不带联系人编码这类机器键;重做分支写明过期行要撤 + const s6 = COVERAGE_STEPS.find((x) => x.item === 'S6_ledger_update')?.guide ?? ''; + expect(s6).toContain('不写联系人编码'); + expect(s6).toContain('update_key'); + const rel = COVERAGE_STEPS.find((x) => x.item === 'T_release')?.guide ?? ''; + expect(rel).toContain('过期的旧记录要撤掉'); + }); + + it('四份件标题旁不再摆单号(2026-08-24 用户定);验收判据与「AI 已完成」文案不夹字段名', () => { + for (const t of manifest.assets.viewTemplates) { + expect(t.html).not.toMatch(/

[^<]*/); + } + const html = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-ledger-update')?.html ?? ''; + const prose = html.replace(/