E04 修到零被拒线,harness 补七刀:跨进程租约与广播桥、原子写、引用墙、对话区按序号排

活动侧(E04 2.1.0 → 2.1.9,I01 1.2.2):
- 覆盖闭合成为机检:基座新增 distinct 读数,「计划盖住的客户数 = 候选行数」钉进
  S3 收步 / S4 哨兵 / 放行 L2;人加的候选客户不再被整单无视
- 「关键人待识别」占位句式全链退役:查无联系人先发数据录入卡收人;契约新增 refersTo,
  计划/回执的 contact_code 只许引用真实存在的联系人(安装期核引用目标)
- 台账更新确认摆全落库面:客户档案/联系人档案两张落库预览(touched: 数据源,
  正式现值 + 草稿叠加),可补改、可加行(自增主键由基座续号);姓名升必填
- 更新明细:update_key 位置键 + 「同一处只留一条」L2;依据写人话不带机器键;
  标题旁不摆单号;判据与文案不夹字段名;行标识去掉单标识那一段
- 回执骨架 = 计划行数钉进 S3 收步与 S4 哨兵;重做分支不许删计划凑数
- 阶段刻度对齐文档 7 步;起单样例预填满

基座侧:
- 两个后端连同一个库:task-claim 跨进程租约(迁移 0030)+ Postgres LISTEN/NOTIFY
  广播桥;对话区按任务内记忆序号排,不按墙上时钟;并卡「刚说过话」按记忆序列判紧邻
- 多行 write 整批先验后写(dryRun),被拒 = 一行都没落
- 决策卡意见与本单草稿字面比对,归属以观察进轨迹(note-anchors)
- 括号栈补「补闭括号」两形(少一个 } / 行对象没关直接跟下一行)
- 件上加行:空着的自增主键直进续号,不从别的行借号;未落下的行点名回给前端
- 人答复落库那一刻就把 runtime 翻 running(task.wake),两块同帧变脸
- 试跑单可落真库(land_master,迁移 0031,默认开);数据域按活动分组;
  JSON 列不再是 [object Object];按钮键感 + 在办进度条 + 防抖
- 报价链 A1–A7 从系统卸载(只卸不删源码),种子只装 E04/I01

契约变更(api-report 已刷新):FieldDeclaration.distinct / refersTo,
objectDeclaration.file,landMaster,unkeyed,assetFiles,ledgerByActivityRoute。
1700 测 / typecheck / lint / depcruise / api:check 绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
This commit is contained in:
xcs
2026-08-25 01:23:30 +08:00
co-authored by Claude Fable 5
parent 92dd729c36
commit f3397a60ef
91 changed files with 3919 additions and 350 deletions
@@ -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),
* 手写 <input> 渲染器不给填值,一开编辑就是白框。
*/
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]*?<input class="jc-cell"/);
// 主键列必挂 data-col(行的钥匙);联系人可加行,编码由基座续号(自增列不问人)
expect(html).toContain('data-slot="customer_code" data-col="customer_code"');
expect(html).toContain('data-slot="contact_code" data-col="contact_code"');
expect(html).toContain('data-edit-add="customer.contact"');
// 红带与放行哨兵同源 —— 摆的是基座的 _missing
expect(html).toContain('data-slot="customer_profile_missing"');
expect(html).toContain('data-slot="customer_contact_missing"');
});
it('执行回执混排:主键联系人编码挂 data-col,四个可填列各自挂了 data-col', () => {
@@ -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 = /<ifPart id="if_pa"><condition>([^<]+)<\/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 = /<ifPart id="if_pd"><condition>([^<]+)<\/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(/<h3>[^<]*<span data-slot="round_ref">/);
}
const html = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-ledger-update')?.html ?? '';
const prose = html.replace(/<style>[\s\S]*?<\/style>/, '').replace(/data-[a-z-]+="[^"]*"/g, '');
expect(prose).not.toMatch(/coverage_score|decision_chain/);
});
it('**收口写了明细却漏了联系人必填(如 influence)→ S6 收不掉** —— 新客户路的死锁口', () => {
expect(
safeEval(doneOf('S6_ledger_update'), {
@@ -197,6 +278,45 @@ describe('实测账本对应的剧本口径 —— 改口要连测试一起改',
* T_release 的剧本「裁判据 → submit」原样又递回来 —— 剧本里压根没有「被打回了怎么办」
* 这一支)。基座那道闸只拦得住原样重提,拦不出「该改哪一张表」——那是活动自己的知识。
*/
it('驳回意见先认归属:S4 剧本写死「照基座比对的观察认、不许改到没点名的行、认不出唯一就重发卡问」', () => {
// 2026-08-24 柴浩实测:意见点名「测试111」,模型把改动落到了 Itelma 那一行
const guide = COVERAGE_STEPS.find((x) => x.item === 'S4_plan_approval')?.guide ?? '';
expect(guide).toContain('先认归属');
expect(guide).toContain('意见按字面点名对上了');
expect(guide).toContain('不是把改动挪到别家');
expect(guide).toContain('不许猜');
});
it('姓名是必填:客户档案与联系人档案都不许无名落库(2026-08-24 两条空联系人 + 无名新客户档案)', () => {
const profile = manifest.declarations.ledger.find((l) => l.domain === 'customer' && l.object === 'profile');
const contact = manifest.declarations.ledger.find((l) => l.domain === 'customer' && l.object === 'contact');
expect(profile?.fields.find((f) => f.name === 'name')?.required).toBe(true);
expect(contact?.fields.find((f) => f.name === 'name')?.required).toBe(true);
// 行业/区域是建议档:AI 推得出就填,推不出橘胶囊留给人(确认屏上现在改得了)
expect(profile?.fields.find((f) => f.name === 'industry')?.expect).toBe(true);
expect(profile?.fields.find((f) => f.name === 'region')?.expect).toBe(true);
// 剧本写死:没有真实姓名的人不建联系人行;碰过的行 name 照现值带上
const guide = COVERAGE_STEPS.find((x) => x.item === 'S6_writeback')?.guide ?? '';
expect(guide).toContain('没有真实姓名的人不许建联系人行');
expect(guide).toContain('name');
expect(guide).toContain('照候选行抄');
expect(guide).toContain('不许编造一个查无出处的行业或地区');
});
it('计划只指向真人:占位句式已退役,查无联系人走数据录入卡(2026-08-24 用户裁定)', () => {
// 实测:模型把占位句式泛化到缺口节点,给 Itelma 编了两个顺延引用码 + 占位人名
const guide = COVERAGE_STEPS.find((x) => x.item === 'S3_plan')?.guide ?? '';
expect(guide).toContain('真实存在的编码');
expect(guide).toContain('数据录入卡');
expect(guide).toContain('"kind":"entry"');
// 占位引用码的句式一个不留 —— 给了可照抄的样板,模型就会把它用到不该用的地方
expect(guide).not.toContain('-00');
expect(guide).not.toContain('待识别');
const s6 = COVERAGE_STEPS.find((x) => x.item === 'S6_writeback')?.guide ?? '';
expect(s6).not.toContain('-00');
expect(manifest.contract.prohibitions.some((x) => x.key === 'no-phantom-contact')).toBe(true);
});
it('放行剧本带「被打回重做」那一支,且写面覆盖要改的那几张表', () => {
const release = COVERAGE_STEPS.find((s) => s.item === 'T_release');
expect(release?.guide).toContain('验收被打回重做');
@@ -315,3 +435,27 @@ function safeEval(expression: string, scope: Record<string, unknown>): boolean {
return false;
}
}
/**
* **进度条的刻度要与业务流程的步数对得上**(2026-08-24 用户:「E04 应该是有 7 步、
* 给人确认是 4 步,这里要是 x/7 才对」)。从前粗分三段 → 屏上 x/4,
* 而文档开篇写的是「整体流程(7 步)」:同一件事两个数,人只能挑一个信。
*/
describe('阶段刻度 = 文档那 7 步', () => {
it('声明 6 段 —— 加上基座那一格「放行收口」,人看到的是 x/7', () => {
expect(COVERAGE_PHASES).toHaveLength(6);
});
it('一段一步:除「更新关系台账」外每段恰好一个计划项', () => {
const multi = COVERAGE_PHASES.filter((p) => p.items.length > 1).map((p) => p.name);
expect(multi).toEqual(['更新关系台账']); // 写回主数据 + 明细留痕,业务上是同一步⑥
});
it('四张卡对应的那四步各占一格 —— 「给人确认是 4 步」说的就是它们', () => {
const names = COVERAGE_PHASES.map((p) => p.name);
// 人确认的三格在声明里,第四格(放行收口)由基座加
expect(names).toContain('覆盖范围确认');
expect(names).toContain('经营计划审批');
expect(names).toContain('执行经营动作与回执');
});
});
@@ -35,11 +35,13 @@ export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<caseFileItem id="scope_confirmed_total" name="覆盖范围已核对确认(人环走完的凭据)"/>
<caseFileItem id="crm_coverage_plan_rows" name="经营计划行数(基座现数)"/>
<caseFileItem id="crm_coverage_plan_missing" name="经营计划缺必填的行数(基座现数)"/>
<caseFileItem id="crm_coverage_plan_distinct_customer_code" name="经营计划盖住了几家客户(基座现数不同值)"/>
<caseFileItem id="plan_approved_total" name="经营计划已审批通过(仅通过时置一)"/>
<caseFileItem id="crm_coverage_receipt_rows" name="执行回执行数(基座现数)"/>
<caseFileItem id="crm_coverage_receipt_missing" name="执行回执缺必填的行数(基座现数)"/>
<caseFileItem id="crm_coverage_ledger_update_rows" name="台账更新明细行数(基座现数)"/>
<caseFileItem id="crm_coverage_ledger_update_missing" name="台账更新明细缺必填的行数(基座现数)"/>
<caseFileItem id="crm_coverage_ledger_update_distinct_update_key" name="台账更新明细覆盖了几处不同位置(基座现数不同值)"/>
<caseFileItem id="customer_profile_rows" name="本单写下的客户档案行数(基座现数)"/>
<caseFileItem id="customer_profile_missing" name="客户档案缺必填的行数(基座现数)"/>
<caseFileItem id="customer_contact_rows" name="本单写下的客户联系人行数(基座现数)"/>
@@ -60,12 +62,12 @@ export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<!-- ③ 经营计划已起草且齐 —— 审批那一步的入场前提 -->
<sentry id="s_plan_drafted">
<onPart id="op_pd" caseFileItemRef="crm_coverage_plan_rows"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_pd"><condition>crm_coverage_plan_rows &gt; 0 and crm_coverage_plan_missing = 0</condition></ifPart>
<ifPart id="if_pd"><condition>crm_coverage_plan_rows &gt; 0 and crm_coverage_plan_missing = 0 and crm_coverage_plan_distinct_customer_code = crm_coverage_candidate_rows</condition></ifPart>
</sentry>
<!-- ④ 经营计划已审批通过 —— 交人执行那一步的入场前提 -->
<sentry id="s_plan_approved">
<onPart id="op_pa" caseFileItemRef="plan_approved_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_pa"><condition>plan_approved_total &gt; 0</condition></ifPart>
<ifPart id="if_pa"><condition>plan_approved_total &gt; 0 and crm_coverage_receipt_rows = crm_coverage_plan_rows</condition></ifPart>
</sentry>
<!--
⑤ 执行回执已交回且齐 —— 收口写台账那一步的入场前提。
@@ -180,6 +182,17 @@ export const NARRATIVE = `§0 开案:**这一轮要经营哪些客户的关系,
**六步走**:识别候选 → 人核对范围 → 制定计划 → 人审批(可能来回改一轮)→
人执行填回执 → 收口写台账 → 人签字放行。
**人核对过的候选清单是后面每一步的输入契约**:覆盖范围确认那一步人可能增删行
(轨迹里「就地修订成果」那条痕点名了增删的是谁),**他加的行与你识别的同等作数、
他删的行别再写回去** —— 缺口分析、经营计划、执行回执、收口写台账,每一步都以
台账现读的 \`crm_coverage.candidate\` 全表为准,不以你 S1 识别时的那份记忆为准。
经营计划必须一家不落(收步判据数这个闭合:计划盖住的客户数 = 候选清单行数)。
**计划与回执只指向真实存在的关键人**:\`crm_coverage.plan\` / \`receipt\` 的
contact_code 必须是 \`customer.contact\` 里已有的编码,contact_name 必须是真人姓名 ——
任何一步都不许自造联系人编码、不许拿占位称呼顶在人名格里。查无联系人的候选客户,
先在 S3 发数据录入卡把第一位关键人收进来(骨架见 S3 剧本),再排它的计划。
【本单可能用到的另一种卡 —— 新客户建档,长这样,只在 S1_identify_candidates 这一项里
发生,\`customers\` 里出现对象形状的元素才发】新客户这一刻没有编码,**不能自己编一个**
(客户编码是主数据的主键,编错一次后面每一单都跟着错——这条纪律与 A1 quote-intake
@@ -383,7 +396,20 @@ cust_type(选填,\`customer.profile.cust_type\` 有值就直接抄它——新
reads: ['crm_coverage.candidate', 'customer.profile', 'customer.contact'],
view: 'coverage-plan',
tools: ['crm-relationship-coverage.recompute-score', 'crm-relationship-coverage.rank-key-persons'],
doneWhen: 'crm_coverage_plan_rows > 0 and crm_coverage_plan_missing = 0',
/*
* 覆盖闭合(2026-08-24 柴浩实测):只数「计划有没有行」的话,人在覆盖范围确认卡上
* 加的那家客户被漏掉后这一步照样自动收步 —— 计划盖住的客户数(distinct,基座数)
* 必须等于候选清单行数(人增删后的现数),一家不落这一步才算办完。
*/
doneWhen:
'crm_coverage_plan_rows > 0 and crm_coverage_plan_missing = 0 and ' +
'crm_coverage_plan_distinct_customer_code = crm_coverage_candidate_rows and ' +
/*
* 回执骨架与计划一一对应(2026-08-24 柴浩实测:计划 7 行回执 6 行,人填完 6 条就能交卷,
* 到放行门才撞墙)—— 少一行骨架人就少一格可填,而提交闸只数已有行缺不缺必填,
* 数不出「少了一行」。这里与 S4 入场哨兵同一句,派回执单之前就得齐。
*/
'crm_coverage_receipt_rows = crm_coverage_plan_rows',
guide: `候选范围定了,**现在读现状、找缺口、排计划** —— 固定三拍,一拍一件事,别在同一拍里
把「调工具」和「写台账」串在一起(**工具调用后同拍其余动作作废,结果要下一拍才看得见**;
串了也是白串,只会多烧一拍)。这一步只需要调工具**各一次**,拿到结果后不要再调第二次。
@@ -396,6 +422,11 @@ cust_type(选填,\`customer.profile.cust_type\` 有值就直接抄它——新
候选范围毫无关系)——动手前先把候选清单的 \`customer_code\` 抄一遍,写计划时逐条核对
是不是都在这张抄出来的清单里,不在的一律不许写。
**也一家都不能少** —— 抄名单抄的是台账现读的 \`crm_coverage.candidate\` **全表**,
不是你 S1 识别时记住的那几家:人核对时可能加了行(轨迹里那条「就地修订成果:新增」
点名了加的是谁),**人加的行与你识别的同等作数**。收步判据现在数这个闭合
(计划盖住的客户数 = 候选清单行数),漏一家这一步就收不了。
【第一拍:缺口分析,只调工具,别的什么都不写】把本轮全部候选客户(读
\`crm_coverage.candidate\`)一次性打包,调一次
\`crm-relationship-coverage.recompute-score\`,入参:
@@ -421,6 +452,9 @@ cust_type(选填,\`customer.profile.cust_type\` 有值就直接抄它——新
任何节点、白算了这一趟。空缺字段(如 \`influence\` 还没定过)照现有值填,是空字符串
或 0 都行,工具认得。
多家候选客户就都塞进同一个 \`customers\` 数组里,**一次调完,不要一家调一次**。
某家候选客户在 \`customer.contact\` 里一个联系人都没有(人核对时额外纳入的客户常是
这种)—— \`contacts\` 传空数组 \`[]\`,工具会如实给出「三个节点全是缺口」,
这正是要的答案,不是异常。
调完这一拍就结束(工具出参要下一拍才看得见)。
【第二拍:排序,只调工具,别的什么都不写】把上一拍 \`results\` 里出现过的**所有联系人**
@@ -436,6 +470,14 @@ cust_type(选填,\`customer.profile.cust_type\` 有值就直接抄它——新
离这个节点最近的联系人**(按 \`decisionNode\` 匹配,匹配不上就挑影响力最高的那位)
写一条「去覆盖这个节点」的动作。**\`gapNodes\` 全员缺口是正常结果,不是异常,
不需要回去重调工具确认。**
**计划表里的 contact_code 只许写 \`customer.contact\` 里真实存在的编码,
contact_name 只许写真人姓名** —— 不许为缺口节点自造顺延的引用码、不许拿占位称呼
顶在关键人那一格(实测走岔过:模型给两个缺口节点各编了一个顺延号、关键人一栏
填了占位称呼 —— 那不是计划,是把「还没认识人」伪装成「有人」;审批的人一眼就退)。
缺口节点的动作挂在**已知联系人**名下,「识别/引荐」写进动作内容,不写进人名。
计划表**一位联系人一行**(主键就是联系人编码):同一位联系人罩几个缺口节点,
几个动作并进**他那一行**的 action(分号连写),不为节点另开行。
引擎会核 contact_code 真的在 \`customer.contact\` 里 —— 编出来的号写不进去。
② **写 \`crm_coverage.plan\`,按第二拍的 rank_score 降序、一个关键人一行**(rows 收数组;
模版不排序,你必须已经按序写)。round_ref、contact_code(**必填**)、customer_code
@@ -447,6 +489,25 @@ cust_type(选填,\`customer.profile.cust_type\` 有值就直接抄它——新
\`"low"\`)、relationship_gap(**必填**,第二拍算出的数)、rank_score(**必填**,第二拍
算出的数)、target_date(**必填**,具体日期,没有时间点的建议不算数)。
**查无任何联系人的候选客户,先把人收进来,再排它的计划**(人核对时额外纳入的客户
常是这种 —— 该经营谁,加行的人自己知道;你不许替他编一个人):在调工具**之前**
给这样的客户各发一张数据录入卡,照这个骨架(一家一张、一单只发一次;
人确认那一下基座直接落草稿,你不经手):
{"kind":"entry","itemId":"S3_plan",
"ask":"新纳入的客户 <客户名> 还没有联系人档案,请录入这一轮要经营的第一位关键人",
"ledger":"customer.contact","key":{"customer_code":"<候选行的客户编码>"},
"fields":[
{"key":"name","label":"姓名","required":true},
{"key":"title","label":"职务"},
{"key":"decision_node","label":"决策链节点(bt-01 采购 / bt-02 技术 / bt-03 高层)"}
]}
联系人编码由基座续号,不进卡面。发完这一拍就结束(发卡挂起);人答回之后这家客户
就有真实联系人了(在 \`customer.contact\` 里看得到),照上面三拍走,计划行引用
**这位真人**的编码与姓名,动作写首访与关系建立(influence/relationship 还没首评,
排序入参按默认:influence 按职务估、没法估写 \`"low"\`,relationship 写 1)。
**不许因为查无联系人就把这家客户跳过** —— 跳过它,收步判据
(计划客户数 = 候选行数)就差一家,这一步永远收不了。
③ **同一拍,给每条计划配一行 \`crm_coverage.receipt\` 骨架**(这一步只写骨架,完成状态那几列
留空,S5 那一步才由人填):round_ref、contact_code(与 plan 同一个,**必填**)、
customer_code(选填,照抄)、customer_name(选填,照抄 —— 执行回执工单的「对象」列长
@@ -455,8 +516,10 @@ cust_type(选填,\`customer.profile.cust_type\` 有值就直接抄它——新
completion_status / result_summary / customer_feedback / key_person_change
**这一步一格都不写**。
④ 若某候选客户的 \`cust_type\`/\`bt_level\` 在 S1 那步没判出来,顺手补写
\`crm_coverage.candidate\` 的这两列(同 PK 覆盖写)。
④ 若某候选客户的 \`cust_type\`/\`bt_level\` **还空着**,顺手补写
\`crm_coverage.candidate\` 的这两列(同 PK 覆盖写)。**两列都已有值的行一个字都不写**
—— 原值重写会被当空转拒(实测:这一句从前没写「还空着」,模型把有值的行
照抄了一遍,白吃一次拒)。
写完这一拍就结束(不必你 finish)——\`crm_coverage_plan_rows > 0 and
crm_coverage_plan_missing = 0\` 一旦为真,引擎自己收这一步。`,
@@ -494,20 +557,34 @@ crm_coverage_plan_missing = 0\` 一旦为真,引擎自己收这一步。`,
发完这一拍就结束了(发卡挂起)。
【驳回之后 —— 不管有没有具体意见,都只处理这一轮,不许再追问一遍】
① \`value\` 以 \`"custom:"\` 开头 —— \`note\` 就是他写的具体意见,**按这条意见**修订
\`crm_coverage.plan\`(同 PK 覆盖写对应的行,不开新计划项、不重新进入 sentry)。
① \`value\` 以 \`"custom:"\` 开头 —— \`note\` 就是他写的具体意见。**先认归属,再动笔**:
意见里点了名(客户名 / 关键人名 / 编码)就拿那个名字与计划表逐行比对
(customer_name / contact_name / customer_code / contact_code 四列;轨迹里若有一条
「意见按字面点名对上了…」的观察,那是基座比对好的答案,**照它认,不自己另猜**)。
**改的必须是被点名的那一行** —— 点名的客户没有意见里说的那种联系人
(比如让去见某个职位的人,而这家的关键人还待识别),改的是这一行自己的动作内容
(把首访对象换成那个职位),**不是把改动挪到别家碰巧有这种联系人的行头上**;
名字对不上任何一行、或对上不止一行认不出唯一,**不许猜**:重新发卡,把
「你理解要改哪一行」写进 ask 请人确认(这算修订重提交,不算追问理由)。
认准之后**按这条意见**修订 \`crm_coverage.plan\`
(同 PK 覆盖写对应的行,不开新计划项、不重新进入 sentry)。
② \`value\` 是 \`"reject"\`(点了固定按钮,没写具体意见)—— **不许再发卡追问理由**
(那两个按钮点出来的回复永远没有 note,再问一遍是把人指向一扇打不开的门,
会被同一句话拒回,连问出真话的机会都没有)。改成你自己复核:排序有没有明显不合理、
有没有遗漏更紧要的缺口节点、理由那一句站不站得住,尽量改得更有说服力;复核后**至少要有
一处实质性变化**(改排序、换动作、补理由,不能原样重发)。
③ **改了 \`action\` 这一列的话,\`crm_coverage.receipt\` 同一行的 \`action\` 也要跟着改**
③ **计划与回执骨架一一对应,改哪边都得同步**:加了一条计划就同拍给它加一行
\`crm_coverage.receipt\` 骨架(round_ref + contact_code,其余照抄计划,完成状态那几列留空);
撤了一条计划就 \`drop\` 它的回执骨架。引擎数「回执行数 = 计划行数」,差一行,执行单派不出去。
**改了 \`action\` 这一列的话,\`crm_coverage.receipt\` 同一行的 \`action\` 也要跟着改**
(同 PK——\`round_ref\`+\`contact_code\`——覆盖写)。receipt.action 是 S3 那一拍从
plan.action **抄**过来的展示副本(执行回执工单直接读它、只读展示),这里只改了
plan 不改 receipt,S5 那一步人看到的还是驳回前的旧动作——已经实测过:审批来回改了
三稿,receipt 里摆的还是第一稿,推进到执行回执时对不上人刚通过的那一版。
④ **重新发卡的 \`ask\` 必须换一种说法**,带上修订轮次与改了什么,例如
"已复核并修订(第2稿:调整了 CU-0142 的优先级,补充了理由)请再次审批"——
④ **重新发卡的 \`ask\` 必须换一种说法**,带上修订轮次与**改了哪一家哪位、改成了什么**
(用人读名:客户名 · 关键人名,别只给编码 —— 这句话是给人核对归属的,
他要一眼看出你改的是不是他点名的那一行),例如
"已复核并修订(第2稿:把 客户甲·采购经理 的动作改为下月带认证报告拜访)请再次审批"——
措辞不换,系统会把它当成同一张卡:前两次静默吞掉(人看不到新卡),第三次直接拒。
**每一轮的 \`ask\` 都要与上一轮明显不同**,不止改个数字。
⑤ 再走一遍【发一张决策卡】那一段(用新措辞,\`allowCustom\` 仍然要给),然后这一拍结束
@@ -608,9 +685,13 @@ crm_coverage_plan_missing = 0\` 一旦为真,引擎自己收这一步。`,
【第二拍:只写这两张,别的表这一拍不碰】
· **写 \`customer.contact\`**(对应联系人行,同 PK 覆盖写):**碰过的每一行,
\`influence\` / \`relationship\` / \`key_person\` 三列一列都不许缺**(基座数缺必填
只看本单写下的行 —— 你写了这一行却漏了其中一列,\`customer_contact_missing\`
就大于零,放行门永远打不开):\`influence\` 写推导后的评级(没变化就照现值抄一遍)、
\`name\` / \`influence\` / \`relationship\` / \`key_person\` 四列一列都不许缺**
(基座数缺必填只看本单写下的行 —— 你写了这一行却漏了其中一列,
\`customer_contact_missing\` 就大于零,放行门永远打不开;name 照现值抄一遍即可)。
**没有真实姓名的人不许建联系人行** —— 「计划要经营的岗位」不是人,节点缺口
留在 decision_chain 里如实标 covered:false,不落 customer.contact
(实测炸过:两条人名职务全空的"联系人"进了正式表,还标着关键人、亲密度3 ——
那是把计划冒充成了档案,后来只能人工删掉):\`influence\` 写推导后的评级(没变化就照现值抄一遍)、
\`relationship\` 写推导后的新值(没变化照现值抄)、\`key_person\` 照这轮判断写;
**本单新建档的联系人**(录入卡只落了姓名/职务/邮箱)这三列由你首评:
\`influence\` 按职务估(总监/VP 级 high、经理级 medium、其余 low)、
@@ -618,10 +699,25 @@ crm_coverage_plan_missing = 0\` 一旦为真,引擎自己收这一步。`,
\`role\`/\`decision_node\`/\`comm_pref\` 若这轮有新信息一并补;
\`change_log\` 追加一句本轮变动(离职/调岗从回执的 \`key_person_change\` 抓,
没有变动不必写这一列)。
· **写 \`customer.profile\`**(对应客户行,同 PK 覆盖写):\`coverage_score\` = 工具
· **写 \`customer.profile\`**(对应客户行,同 PK 覆盖写):**碰过的每一行把
\`name\` 照现值带上(必填,漏了放行门不开)**;\`coverage_score\` = 工具
算出的新分数(**必填**);\`decision_chain\` = 更新后的节点覆盖状态(**必填**,
整个数组重写,不是增量合并);\`interaction_timeline\` 追加本轮的互动条目(选填,
没有新增互动的客户不必写这一列)。
**本轮新建档的客户(候选清单里人加的那家)要把档案填实,不许只落两个数**:
\`name\` / \`cust_type\` 照候选行抄(candidate 里人核对过的就是权威);
\`industry\` / \`region\` 从**已知信息**里分析 —— 来件正文、背景说明(note)、
客户类型、回执里人写的内容,推得出就填、推不出留空(空格会以「建议填」摆在
台账更新确认屏上,人补得了)—— **不许编造一个查无出处的行业或地区**。
· 回执若带回了**新认识的关键人**(姓名/职务在 \`result_summary\`/\`key_person_change\` 里),
给这家客户**新建**联系人行 —— \`contact_code\` 照声明的续号规则铸:
\`<customer_code>-两位序号\`,序号接在该客户名下现有联系人(查 \`customer.contact\`
全表)最大号之后,一位都没有就从 \`-01\` 起;姓名/职务照回执写,首评三列
(influence/relationship/key_person)按上面「本单新建档」那条规则给。
回执没带回人,就什么都不建,如实留缺口。
**人工新增的候选客户在 \`customer.profile\` 里还没有档案的,这一拍补一行**
(customer_code/name 照候选行抄,coverage_score 与 decision_chain 照工具出参写)——
没有档案行,这家客户的覆盖度就没有落点。
· **把办完的候选行状态置 \`covered\`**(写 \`crm_coverage.candidate\`,同 PK 覆盖写,
只带 status 一列):回执里有 \`done\` 动作的客户置 ASCII \`covered\`;
全部 \`skipped\` 的留 \`pending\` 如实 —— 清单上那枚状态徽是给人看的,
@@ -638,7 +734,9 @@ crm_coverage_plan_missing = 0\` 一旦为真,引擎自己收这一步。`,
view: 'coverage-ledger-update',
tools: [],
doneWhen:
'crm_coverage_ledger_update_rows > 0 and crm_coverage_ledger_update_missing = 0 and customer_profile_missing = 0 and customer_contact_missing = 0',
'crm_coverage_ledger_update_rows > 0 and crm_coverage_ledger_update_missing = 0 and ' +
'crm_coverage_ledger_update_distinct_update_key = crm_coverage_ledger_update_rows and ' +
'customer_profile_missing = 0 and customer_contact_missing = 0',
guide: `主数据已在上一步写回,**这一步只写两张表、一拍连做**:先写更新明细
(它最费篇幅,放在串首),再写结论。**上一步那三张表(contact/profile/candidate)
这一拍不许再写一遍** —— 再写一遍原值只会被当空转拒掉。
@@ -649,8 +747,12 @@ crm_coverage_plan_missing = 0\` 一旦为真,引擎自己收这一步。`,
如"客户主台账·关系覆盖度")、ledger_address(**必填**,机器地址,如
"customer.profile.coverage_score")、customer_code(**必填**)、contact_code
(选填,客户级字段留空)、field_label(**必填**,人读列名)、before_value(**必填**,
无值写字面"(无)")、after_value(**必填**)、evidence_ref(**必填**,引用具体回执
事实,如"回执 contact_code=CU-0142-01:已完成拜访,亲密度由3提升至4")。
无值写字面"(无)")、after_value(**必填**)、evidence_ref(**必填**,**用人话**引用具体
回执事实,如"Itelma·Ekaterina 的回执:已完成拜访,亲密度由3提升至4" —— 写客户名与
人名,**不写联系人编码、不带任何机器键前缀**:这一列是摆给签字的人看的)、
update_key(**必填**,更新位置键,按「台账地址|客户编码|联系人编码|字段」四段用竖线
拼,客户级字段第三段留空,如"customer.profile.coverage_score|CU-0142||关系覆盖度"
—— 同一处位置本轮只能有一行,引擎数这个)。
**没有真实变化的字段不必开一行** —— 明细表只记「真改了什么」。
每一处真实变化的依据都在回执与上一步写下的新值里,对照着写,别凭记忆。
· **写 \`crm_coverage.verdict\`**(同一拍接着写):ledger_update_confirmed_total = 1、
@@ -669,7 +771,10 @@ crm_coverage_plan_missing = 0\` 一旦为真,引擎自己收这一步。`,
view: 'coverage-ledger-update',
tools: [],
/* 放行触达世界,必须人按一下 —— 引擎不自动收它 */
guide: `台账更新明细齐了、主数据缺项也补齐了,**可以走放行了**
guide: `台账更新确认那一屏摆着三样:更新明细(你的摘要)+ **两张落库预览**
(将写入的客户档案 / 联系人档案,人在上面补字段、加联系人都是合法的兜底 ——
他补的值、他加的行已在草稿层,**你不许再 write 覆盖**;判齐与哨兵读的是现值,
人补完门自己开)。台账更新明细齐了、主数据缺项也补齐了,**可以走放行了**
(T_release 人工激活:动它就算启动,不必单发 start)。
【**先看一眼轨迹里有没有「验收被打回重做」**】有的话,这一拍**不是**再提交一遍 ——
@@ -682,10 +787,17 @@ crm_coverage_plan_missing = 0\` 一旦为真,引擎自己收这一步。`,
· 他说**台账更新没依据 / 数不对** → 改 \`crm_coverage.ledger_update\` 的
\`evidence_ref\`(点名是哪条回执支持它)或 \`before_value\`/\`after_value\`。
· 他说**结论没说清** → 改 \`crm_coverage.verdict.summary\`。
· 放行门说**「每条计划都收到执行回执」不成立**(回执比计划少行)→ **不许删计划来凑数**
(删了「每家候选都进了计划」立刻不成立,两堵墙对着撞);补上缺的那行回执骨架
(round_ref + contact_code,照计划抄动作),再发一张执行工单让人把那条回执填回来 ——
回执是人办事的凭据,只有人能填。
· 他**没写具体意见**(只点了重做)→ 自己把成果复核一遍,挑最站不住的那处改实
(理由最空的那条计划、依据最含糊的那条更新),**至少改对一处**;
实在判断不出他要什么,发一张零输入执行工单问清楚,别原样重提。
**改的是值,不是数**:同一行覆盖写,别新开行、别把行数改了 —— 那会把收步判据弄假。
**明细里过期的旧记录要撤掉,不留旧账**(2026-08-24 用户当场指出):重做后某条更新
不再成立(数变了、结论变了),就用 \`drop\` 撤掉那一行再写新的,或直接同 seq 覆盖写;
同一处位置(update_key 相同)出现两条,判据「同一处字段只留一条更新」当场拦。
【没被打回(或已按意见改过了)】先把裁量判据裁掉,然后 submit,同一拍连做:
@@ -708,10 +820,28 @@ crm_coverage_plan_missing = 0\` 一旦为真,引擎自己收这一步。`,
/**
* 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段;
* 放行那一格由基座统一加,人看到的是 x/4。
* 放行那一格由基座统一加(`CLOSING_PHASE`),所以声明 6 段、人看到的是 **x/7**。
*
* **刻度要与业务流程的步数对得上**(2026-08-24 用户:「E04 应该是有 7 步、
* 给人确认是 4 步,这里要是 x/7 才对」)。从前这里按「归口」粗分成三段,
* 于是屏上是 x/4 —— 而 `E04-活动流程与工单说明.md` 开篇写的是「整体流程(7 步)」:
* 人对着文档数是 7,对着进度条数是 4,**同一件事两个数**。
* 现在一段对一步,与文档那张表逐行对齐:
*
* ① 启动与覆盖范围确认 → 识别候选客户(AI 汇总)+ 覆盖范围确认(**人**,执行工单1)
* ②③④ 读现状 / 缺口分析 / 制定计划 → 制定关系经营计划(AI 算)
* ④ 计划审批 → 经营计划审批(**人**,验收工单1)
* ⑤ 执行经营动作 → 执行与回执(**人**,执行工单2)
* ⑥ 更新关系台账 → 写回主数据 + 台账明细留痕
* ⑦ 复盘与完结(门禁) → 放行收口(**人**,验收工单2,基座那一格)
*
* 四张卡就是那 4 个「给人确认」的刻度 —— 其余三格是 AI 在办事。
*/
export const COVERAGE_PHASES = [
{ name: '确认覆盖范围', items: ['S1_identify_candidates', 'S2_scope_confirm'] },
{ name: '制定与审批计划', items: ['S3_plan', 'S4_plan_approval'] },
{ name: '执行与更新台账', items: ['S5_execute_receipt', 'S6_writeback', 'S6_ledger_update'] },
{ name: '识别候选客户', items: ['S1_identify_candidates'] },
{ name: '覆盖范围确认', items: ['S2_scope_confirm'] },
{ name: '缺口分析与制定计划', items: ['S3_plan'] },
{ name: '经营计划审批', items: ['S4_plan_approval'] },
{ name: '执行经营动作与回执', items: ['S5_execute_receipt'] },
{ name: '更新关系台账', items: ['S6_writeback', 'S6_ledger_update'] },
];
@@ -40,7 +40,7 @@ export const manifest: ActivityManifest = {
identity: {
activityId: ACTIVITY_ID,
name: 'E04 · 关系覆盖与关键人经营',
packageVersion: '2.0.9',
packageVersion: '2.1.9',
category: '客户关系经营',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-crm-relationship-coverage',
@@ -185,6 +185,18 @@ export const manifest: ActivityManifest = {
level: 'L2',
predicate: `${D}_plan_rows > 0 and ${D}_plan_missing = 0`,
},
{
/*
* **覆盖闭合**(2026-08-24 柴浩实测补):人核对后的候选清单是后续每一步的输入契约 ——
* 他加的那家客户,计划里必须有它。两边都是基座数:计划里有几家不同客户
* (`distinct`,声明在 plan.customer_code 上)= 候选清单几行(增删后现数)。
* S3 的收步判据与 S4 的入场哨兵同一句 —— 这里是第二道墙,防的是绕过收步的路。
*/
key: 'plan-covers-scope',
label: '每家确认纳入的候选客户都进了经营计划(含人核对时新增的,一家不落)',
level: 'L2',
predicate: `${D}_plan_distinct_customer_code = ${D}_candidate_rows`,
},
{
key: 'receipt-matches-plan',
label: '每条计划都收到执行回执,且完成状态、结果摘要、关键人变动均已填写',
@@ -197,6 +209,13 @@ export const manifest: ActivityManifest = {
level: 'L2',
predicate: `${D}_ledger_update_rows > 0 and ${D}_ledger_update_missing = 0`,
},
{
/* 2026-08-24:重做后过期的旧明细不许留 —— 同一处字段只能有一条更新 */
key: 'no-duplicate-update',
label: '同一处字段只留一条更新(重做后过期的旧记录已撤掉)',
level: 'L2',
predicate: `${D}_ledger_update_distinct_update_key = ${D}_ledger_update_rows`,
},
{
key: 'no-missed-update',
label: "每条「已完成」的执行回执至少有一条对应的台账更新(没有漏更)",
@@ -218,6 +237,8 @@ export const manifest: ActivityManifest = {
{ key: 'no-guess-contact', statement: '决策链节点对应哪位联系人不确定时不得臆断 —— 匹配不上就留空,那就是缺口' },
{ key: 'no-skip-evidence', statement: '台账更新明细每条都要有回执依据,不得凭空推算写入主数据' },
{ key: 'no-external-write', statement: '放行前不得触达外部系统 —— 触达世界的动作集中在放行那一刻' },
/* 2026-08-24:模型给缺口节点自造过顺延引用码 + 占位人名 —— 计划里「有人」必须真的有人 */
{ key: 'no-phantom-contact', statement: '计划与回执只许引用 customer.contact 已有的联系人编码与真人姓名;不得自造编码或以占位称呼顶格 —— 查无联系人先以数据录入卡收人' },
],
authorization: {
discretion: [],
@@ -263,9 +284,16 @@ export const manifest: ActivityManifest = {
* 说明书让录入卡落什么,声明里就得有什么(物理列平台基线本就有,声明是权责面)。
* 与 A1 的 name 声明并存(add-column 合并,同名同型不冲突)。
*/
{ name: 'name', type: 'text', primaryKey: false, masked: false, label: '客户名称' },
{ name: 'industry', type: 'text', primaryKey: false, masked: false, label: '所属行业' },
{ name: 'region', type: 'text', primaryKey: false, masked: false, label: '区域' },
/*
* name 必填(2026-08-24 柴浩实测:新客户档案落库连名字都是空的 —— 候选行里
* 明明写着,模型没抄)。`customer_profile_missing` 本就在 S6 收步与放行哨兵里,
* 标上它,「无名档案」从此进不了正式表;代价是碰过的客户行要把 name 照现值带上
* (剧本写死)。industry/region 标建议填:AI 从来件与背景说明推得出就填,
* 推不出留橘胶囊给人 —— 台账更新确认屏上现在改得了。
*/
{ name: 'name', type: 'text', primaryKey: false, masked: false, required: true, label: '客户名称' },
{ name: 'industry', type: 'text', primaryKey: false, masked: false, expect: true, label: '所属行业' },
{ name: 'region', type: 'text', primaryKey: false, masked: false, expect: true, label: '区域' },
/*
* L-02 文档明列的字段,权威维护者是尚未建成的 T02(客户建档评估) —— 平时
* E04 不写它;唯一的例外是"新客户建立合作"这一刻,E04 代替不存在的 T02
@@ -296,8 +324,14 @@ export const manifest: ActivityManifest = {
* (静默退化成留空,不报错)。
*/
{ name: 'contact_code', type: 'text', primaryKey: true, masked: false, auto: true, label: '联系人编码' },
/* 建档卡的联系人段会写的三列 —— 同上,声明即授权面;email 照平台口径标脱敏 */
{ name: 'name', type: 'text', primaryKey: false, masked: false, label: '姓名' },
/*
* 建档卡的联系人段会写的三列 —— 同上,声明即授权面;email 照平台口径标脱敏。
* name 必填(2026-08-24 柴浩实测:早前一轮模型替「计划要经营的岗位」凭空建了
* 两条无名联系人档,人名职务全空、却标着关键人/亲密度3 —— 那不是联系人,是把
* 计划里的节点冒充成了人)。`customer_contact_missing` 本就在收步与放行哨兵里,
* 标上它,无名建档从此进不了正式表;代价是碰过的联系人行把 name 照现值带上。
*/
{ name: 'name', type: 'text', primaryKey: false, masked: false, required: true, label: '姓名' },
{ name: 'title', type: 'text', primaryKey: false, masked: false, label: '职务' },
{ name: 'email', type: 'text', primaryKey: false, masked: true, label: '邮箱' },
{ name: 'role', type: 'text', primaryKey: false, masked: false, expect: true, label: '角色' },
@@ -346,8 +380,21 @@ export const manifest: ActivityManifest = {
label: '关键人经营计划',
fields: [
{ name: 'round_ref', type: 'text', primaryKey: true, masked: false, label: '覆盖轮次编号' },
{ name: 'contact_code', type: 'text', primaryKey: true, masked: false, label: '联系人编码' },
{ name: 'customer_code', type: 'text', primaryKey: false, masked: false, required: true, label: '客户编码' },
/*
* `refersTo` —— 计划只许指向真实存在的联系人(2026-08-24,措辞治不了的结构治):
* 模型三次给缺口节点自造顺延引用码 + 占位人名,反例描述/说明书纪律/禁令
* 全拦不住;现在基座在写入那一刻查 customer.contact(正式 + 本单草稿),
* 查无此码当场拒并指路(先发数据录入卡收人)。receipt 同款。
*/
{ name: 'contact_code', type: 'text', primaryKey: true, masked: false, refersTo: 'customer.contact.contact_code', label: '联系人编码' },
/*
* `distinct: true` —— 基座数「计划里有几家不同客户」(`crm_coverage_plan_distinct_customer_code`)。
* 它是覆盖闭合判据的落点(2026-08-24 柴浩实测:人在覆盖范围确认卡上加了一家客户,
* 行落进了台账,而计划/回执/收口把它从头无视到尾 —— 收步与放行只数「计划有没有行」,
* 「盖没盖住每一家候选」没有任何一个基座数说得出来)。计划行数 ≠ 客户数
* (一家客户几位关键人就几行),所以必须另数这一个。
*/
{ name: 'customer_code', type: 'text', primaryKey: false, masked: false, required: true, distinct: true, label: '客户编码' },
{ name: 'customer_name', type: 'text', primaryKey: false, masked: false, expect: true, label: '客户名称' },
{ name: 'contact_name', type: 'text', primaryKey: false, masked: false, expect: true, label: '联系人姓名' },
{ name: 'action', type: 'text', primaryKey: false, masked: false, required: true, label: '建议动作' },
@@ -385,7 +432,7 @@ export const manifest: ActivityManifest = {
label: '经营计划执行回执',
fields: [
{ name: 'round_ref', type: 'text', primaryKey: true, masked: false, label: '覆盖轮次编号' },
{ name: 'contact_code', type: 'text', primaryKey: true, masked: false, label: '联系人编码(与计划一一对应)' },
{ name: 'contact_code', type: 'text', primaryKey: true, masked: false, refersTo: 'customer.contact.contact_code', label: '联系人编码(与计划一一对应)' },
{ name: 'customer_code', type: 'text', primaryKey: false, masked: false, expect: true, label: '客户编码' },
/* 目标件的「对象」列长「AvtoKom · 产品 VP」—— 客户名与人名都要在,S3 从计划抄 */
{ name: 'customer_name', type: 'text', primaryKey: false, masked: false, label: '客户名称(承继自计划,只读展示)' },
@@ -430,7 +477,15 @@ export const manifest: ActivityManifest = {
{ name: 'field_label', type: 'text', primaryKey: false, masked: false, required: true, label: '字段(人读列名)' },
{ name: 'before_value', type: 'text', primaryKey: false, masked: false, required: true, label: '更新前(无值写"(无)")' },
{ name: 'after_value', type: 'text', primaryKey: false, masked: false, required: true, label: '更新后' },
{ name: 'evidence_ref', type: 'text', primaryKey: false, masked: false, required: true, label: '依据(引用具体回执事实)' },
{ name: 'evidence_ref', type: 'text', primaryKey: false, masked: false, required: true, label: '依据(用人话引用回执事实)' },
/*
* 更新位置键(2026-08-24 用户:「重做后老的变更记录应该删除」):主键是序号,
* 同一处字段(同一张表·同一客户·同一联系人·同一字段)重做时若新开一行,旧行就留在
* 明细里骗人 —— SQ-0031 的覆盖度一度同时挂着 50→50 与 50→100 两条。
* 这一列由模型按「台账地址|客户编码|联系人编码|字段」拼,`distinct` 让基座数
* 「有几处不同位置」;判据钉 distinct = rows —— 同一处只留一条。
*/
{ name: 'update_key', type: 'text', primaryKey: false, masked: false, required: true, distinct: true, label: '更新位置键(台账地址|客户|联系人|字段)' },
],
},
{
@@ -61,8 +61,9 @@ export const COVERAGE_SKILLS: Skill[] = [
applicability: '执行回执收齐之后,要把结果写回 L-02/L-08 主数据时。',
method: [
'每一条更新都要能在回执里找到对应的事实 —— `evidence_ref` 不是可选的装饰,',
'是验收判据①的唯一抓手:写「回执 contact_code=CU-0142-01:已完成拜访,',
'亲密度由3提升至4」这种能溯源的话,不写「综合判断」这类查无实据的话。',
'是验收判据①的唯一抓手:写「Itelma·Ekaterina 的回执:已完成拜访,亲密度由3提升至4」',
'这种能溯源的**人话**(客户名·人名,不写编码、不带机器键前缀),',
'不写「综合判断」这类查无实据的话。',
'',
'覆盖度 `coverage_score` 收口时按执行后的最新数据重算一次(调',
'`crm-relationship-coverage.recompute-score`),不要凭回执的完成条数估一个大概值 ——',
@@ -171,6 +171,11 @@ html.editable .proto-root .jc-tbl-wrap:has(table.on) + .jc-addrow {
.proto-root .jc-tag.v-changed { color: var(--danger); }
.proto-root .jc-tag.v-unchanged::after { content: "无变动"; }
.proto-root .jc-tag.v-unchanged { color: var(--ink-4); }
/* 关键人标记(boolean 列):值是 true/false,人读字由这里出 —— 与其余枚举徽同一机制 */
.proto-root .jc-tag.v-true::after { content: "是"; }
.proto-root .jc-tag.v-true { color: var(--ok); }
.proto-root .jc-tag.v-false::after { content: "否"; }
.proto-root .jc-tag.v-false { color: var(--ink-4); }
/*
* **覆盖深度同一坑的另一半**(2026-08-21 用户:「编辑的下拉框是中文,显示是英文」)——
* depth 本身不含中文(说明书写死 ASCII deep/standard/light,不撞 classToken 那道墙),
@@ -219,7 +224,8 @@ const doc = (input: {
tag: string;
kicker: string;
title: string;
titleRef: string;
/** 标题旁的单号 —— 2026-08-24 用户定:不摆(页脚已有),留作可选 */
titleRef?: string;
/** 目标件 side 第二行:静态步骤文案(哪份工单盖哪几步是写清单时定死的) */
step: string;
state: string;
@@ -233,7 +239,7 @@ const doc = (input: {
<div class="jc-rp-badge">${input.badge}</div>
<div class="jc-rp-tit">
<em>${input.kicker}</em>
<h3>${input.title} <span data-slot="${input.titleRef}"></span></h3>
<h3>${input.title}${input.titleRef === undefined ? '' : ` <span data-slot="${input.titleRef}"></span>`}</h3>
<div class="jc-rp-sub">收件人:<span data-slot="case:owner"></span> · 关联任务:<span class="jc-rp-taskid" data-slot="case:taskId" data-blank></span></div>
</div>
<div class="jc-rp-side">
@@ -314,7 +320,6 @@ export const COVERAGE_SCOPE = doc({
tag: '执行工单',
kicker: 'E04 关系覆盖与关键人经营 · 执行工单 1/2',
title: '覆盖范围确认',
titleRef: 'round_ref',
step: '步骤 ① 启动与覆盖范围确认',
state: `<span class="jc-rp-wait">共 <b data-slot="${D}_candidate_rows"></b> 家候选</span>`,
body:
@@ -387,7 +392,6 @@ export const COVERAGE_PLAN = doc({
tag: '验收工单',
kicker: 'E04 关系覆盖与关键人经营 · 验收工单 1/2',
title: '关系经营计划审批',
titleRef: 'round_ref',
step: '步骤 ②③④ 产出',
state: `<span class="jc-rp-wait">共 <b data-slot="${D}_plan_rows"></b> 位关键人 · 按影响力 × 亲密度缺口排序</span>`,
body:
@@ -461,7 +465,6 @@ export const COVERAGE_RECEIPT = doc({
tag: '执行工单',
kicker: 'E04 关系覆盖与关键人经营 · 执行工单 2/2',
title: '经营计划执行回执',
titleRef: 'round_ref',
step: '步骤 ⑤⑥ 执行经营动作',
state: `<span class="jc-rp-wait warn">待回执 <b data-slot="${D}_receipt_missing"></b> / <b data-slot="${D}_receipt_rows"></b> 项</span>`,
body:
@@ -517,6 +520,84 @@ const LEDGER_UPDATE_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
</tbody>
</table></div>`;
/*
* ==================== 落库预览两张表(2026-08-24 用户定)====================
* 「没有把要更新的台账展示全」:更新明细是模型写的**摘要**,而真正落库的是
* customer.profile / customer.contact 的整行 —— 签字的人要看的是**将要写进正式表的
* 东西本身**,不是转述。两张表直接摆本单将落的行,且**可编辑**:AI 分析填不出的
* 字段(行业/区域)人在这儿补,AI 没建上的联系人人在这儿加一行(联系人编码留空,
* 由系统按「客户编码-两位序号」续号 —— 自增列不问人,件上同规)。
*/
const PROFILE_LANDING_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
<thead><tr>
<th style="width:36px;min-width:36px"></th>
<th style="min-width:100px">客户编号</th>
<th class="req" style="min-width:150px">客户名称</th>
<th class="sug" style="min-width:110px">客户类型</th>
<th class="sug" style="min-width:120px">所属行业</th>
<th class="sug" style="min-width:110px">区域</th>
<th style="width:90px;min-width:90px">覆盖度</th>
</tr></thead>
<tbody data-rows="touched:customer.profile" data-edit="customer.profile"><tr>
<td class="jc-rn"></td>
<td data-h="客户编号"><span class="jc-vcell mono" data-slot="customer_code" data-col="customer_code" data-blank></span></td>
<td class="req" data-h="客户名称"><span class="jc-vcell cust" data-slot="name" data-col="name" data-blank></span></td>
<td class="sug" data-h="客户类型"><span class="jc-vcell" data-slot="cust_type" data-col="cust_type" data-blank></span></td>
<td class="sug" data-h="所属行业"><span class="jc-vcell" data-slot="industry" data-col="industry" data-blank></span></td>
<td class="sug" data-h="区域"><span class="jc-vcell" data-slot="region" data-col="region" data-blank></span></td>
<td class="dimempty" data-h="覆盖度"><span class="jc-vcell mono" data-slot="coverage_score" data-blank></span></td>
</tr></tbody>
</table></div>`;
const CONTACT_LANDING_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
<thead><tr>
<th style="width:36px;min-width:36px"></th>
<th style="min-width:96px">客户编号</th>
<th style="min-width:104px">联系人编码</th>
<th class="req" style="min-width:120px">姓名</th>
<th class="sug" style="min-width:100px">职务</th>
<th class="sug" style="width:150px;min-width:150px">决策链节点</th>
<th class="req" style="width:96px;min-width:96px">影响力</th>
<th class="req" style="width:96px;min-width:96px">亲密度</th>
<th class="req" style="width:96px;min-width:96px">关键人</th>
</tr></thead>
<tbody data-rows="touched:customer.contact" data-edit="customer.contact"><tr>
<td class="jc-rn"><button type="button" class="jc-rowdel" data-edit-del="customer.contact" title="撤掉这一行">×</button></td>
<td data-h="客户编号"><span class="jc-vcell mono" data-slot="customer_code" data-col="customer_code" data-blank></span></td>
<td class="dimempty" data-h="联系人编码"><span class="jc-vcell mono" data-slot="contact_code" data-col="contact_code" data-blank></span></td>
<td class="req" data-h="姓名"><span class="jc-vcell cust" data-slot="name" data-col="name" data-blank></span></td>
<td class="sug" data-h="职务"><span class="jc-vcell" data-slot="title" data-col="title" data-blank></span></td>
<td class="sug" data-h="决策链节点">
<select class="jc-cell" data-col="decision_node">
<option value=""></option>
<option value="bt-01">BT-01 · 采购/供应链</option>
<option value="bt-02">BT-02 · 技术</option>
<option value="bt-03">BT-03 · 高层</option>
</select>
<span class="jc-tag" data-class-of="decision_node"></span>
</td>
<td class="req" data-h="影响力">
<select class="jc-cell" data-col="influence" required>
<option value=""></option>
<option value="high">高</option>
<option value="medium">中</option>
<option value="low">低</option>
</select>
<span class="jc-tag" data-class-of="influence"></span>
</td>
<td class="req" data-h="亲密度"><span class="jc-vcell mono" data-slot="relationship" data-col="relationship" data-blank></span></td>
<td class="req" data-h="关键人">
<select class="jc-cell" data-col="key_person" required>
<option value=""></option>
<option value="true">是</option>
<option value="false">否</option>
</select>
<span class="jc-tag" data-class-of="key_person"></span>
</td>
</tr></tbody>
</table></div>`;
/*
* 「字段」格里多一行「依据」、「台账」格里多一行编码(有意偏离目标件):三条 L4 判据
* 让签字的人核「有没有依据 / 有没有张冠李戴」,而 evidence_ref / customer_code 从前
@@ -528,24 +609,57 @@ export const COVERAGE_LEDGER_UPDATE = doc({
tag: '验收工单',
kicker: 'E04 关系覆盖与关键人经营 · 验收工单 2/2',
title: '关系台账更新确认',
titleRef: 'round_ref',
step: '步骤 ⑥⑦ 更新台账',
state: `<span class="jc-rp-wait">共 <b data-slot="${D}_ledger_update_rows"></b> 项更新</span>`,
body:
part('AI 结论说明', 'AI 收到回执后做了哪些更新') +
`<div class="jc-derive">
<p><b>AI 已完成</b>:收到执行回执后,重算关系覆盖度(coverage_score)、更新决策链地图(decision_chain)、
<p><b>AI 已完成</b>:收到执行回执后,重算关系覆盖度、更新决策链地图、
更新 L-08 联系人档案(角色 / 影响力 / 亲密度 / 关键人标记)、追加互动时间线。
本轮覆盖结论:<span data-slot="summary" data-blank></span></p>
</div>` +
part('验收判据', '你据此判断「通过 / 驳回」') +
criteria('判断这次台账更新是否无误,请逐项核对', [
'<b>更新是否有依据</b>:每条更新是否都能在「执行回执」里找到对应事实(如 coverage_score 上升对应哪几次拜访);',
'<b>数值是否合理</b>:coverage_score、亲密度等数值变化幅度与回执是否吻合;',
'<b>有无漏更或误更</b>:该更新的字段都更新了、不该动的没动。',
'<b>更新是否有依据</b>:每条更新是否都能在「执行回执」里找到对应事实(如关系覆盖度上升对应哪几次拜访);',
'<b>数值是否合理</b>:关系覆盖度、亲密度等数值变化幅度与回执是否吻合;',
'<b>有无漏更或误更</b>:对照下方「将写入的客户档案 / 联系人档案」两张落库预览 —— 该更新的都在、不该动的没动;缺的字段、漏建的联系人可直接在表上补。',
]) +
part('拟更新的台账', '只读展示') +
part('更新明细', 'AI 的更新摘要,逐条带依据') +
LEDGER_UPDATE_TABLE +
flow('<b>审批:</b>本工单只承载内容;「通过入库 / 驳回」请在对话区的【验收卡】中进行。'),
part('将写入的客户档案', '落库预览 · 字段可就地补改') +
`<div class="jc-gapbar" data-class-of="customer_profile_missing">
<span><b>客户档案还差 <span data-slot="customer_profile_missing"></span> 行必填没齐</b>
—— <span data-slot="customer_profile_missing_keys"></span>。补齐了这一单才放行得了。</span>
</div>` +
editGate(
'customer.profile',
`<b>本轮将写入 <span data-slot="customer_profile_rows"></span> 行客户档案</b>,点「编辑」补改字段`,
) +
`<div class="jc-editbar">
<span>这就是签字后要进正式表的行。<b>客户名称</b>必填;<b>客户类型 / 行业 / 区域</b>是建议档
—— AI 从来件里推得出的已经填上,推不出的橘胶囊留给你。覆盖度是算出来的数,改不了。
改动只落任务草稿层,<b>保存不等于放行</b>。</span>
</div>` +
PROFILE_LANDING_TABLE +
part('将写入的联系人档案', '落库预览 · 可补改,也可加行') +
`<div class="jc-gapbar" data-class-of="customer_contact_missing">
<span><b>联系人档案还差 <span data-slot="customer_contact_missing"></span> 行必填没齐</b>
—— <span data-slot="customer_contact_missing_keys"></span>。补齐了这一单才放行得了。</span>
</div>` +
editGate(
'customer.contact',
`<b>本轮将写入 <span data-slot="customer_contact_rows"></span> 行联系人档案</b>,点「编辑」补改或加行`,
) +
`<div class="jc-editbar">
<span><b>姓名 / 影响力 / 亲密度 / 关键人</b>是必填;执行中结识了新的关键人而 AI 没建上档的,
在这儿<b>加一行</b>——联系人编码留空,由系统按「客户编码-两位序号」续号,不用你编。
改动只落任务草稿层,<b>保存不等于放行</b>。</span>
</div>` +
CONTACT_LANDING_TABLE +
`<div class="jc-addrow">
<button type="button" class="jc-egbtn ghost" data-edit-add="customer.contact">+ 加一行(补录联系人)</button>
<em>新加行填客户编号与姓名(必填);联系人编码留空由系统续号。</em>
</div>` +
flow('<b>审批:</b>本工单只承载内容;「通过入库 / 驳回」请在对话区的【验收卡】中进行。上面三张表就是签字后落库的全部内容。'),
foot: sign('E04 关系覆盖与关键人经营 · 验收工单 2/2 · 关系台账更新确认', '工单类型:验收 → 完结'),
});